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,770 @@
|
|
|
1
|
+
import { clamp } from 'remeda';
|
|
2
|
+
import {
|
|
3
|
+
rgb15ToRgb24,
|
|
4
|
+
rgb15ToLuminance,
|
|
5
|
+
rgb15ToEmoji,
|
|
6
|
+
rgb15ToGrayscaleEmoji,
|
|
7
|
+
rgb24ToEmoji,
|
|
8
|
+
rgb24ToGrayscaleEmoji,
|
|
9
|
+
calculateLuminance,
|
|
10
|
+
rgbToAnsi256,
|
|
11
|
+
} from '../../utils/color';
|
|
12
|
+
import { getTerminalDimensions } from '../../utils/terminal';
|
|
13
|
+
import {
|
|
14
|
+
RESET,
|
|
15
|
+
HALF_BLOCK_TOP,
|
|
16
|
+
fgTrueColor,
|
|
17
|
+
bgTrueColor,
|
|
18
|
+
fgAnsi256,
|
|
19
|
+
bgAnsi256,
|
|
20
|
+
moveCursor,
|
|
21
|
+
moveCursorToRow,
|
|
22
|
+
clearScreen,
|
|
23
|
+
hideCursor,
|
|
24
|
+
showCursor,
|
|
25
|
+
} from '../shared/ansi';
|
|
26
|
+
import type { EffectOptions } from '../postProcessing';
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_SOURCE_WIDTH,
|
|
29
|
+
DEFAULT_SOURCE_HEIGHT,
|
|
30
|
+
DEFAULT_DISPLAY_WIDTH,
|
|
31
|
+
DEFAULT_DISPLAY_HEIGHT,
|
|
32
|
+
STATUS_LINE_ROWS,
|
|
33
|
+
DIFF_GAP_THRESHOLD,
|
|
34
|
+
EMOJI_COLUMN_WIDTH,
|
|
35
|
+
COLOR_CHANNEL_MAX,
|
|
36
|
+
CONTRAST_MIDPOINT,
|
|
37
|
+
GAMMA_LUT_SIZE,
|
|
38
|
+
DEFAULT_GAMMA,
|
|
39
|
+
DEFAULT_SCANLINES,
|
|
40
|
+
DEFAULT_SATURATION,
|
|
41
|
+
DEFAULT_BRIGHTNESS,
|
|
42
|
+
DEFAULT_CONTRAST,
|
|
43
|
+
DEFAULT_VIGNETTE,
|
|
44
|
+
LUMINANCE_R,
|
|
45
|
+
LUMINANCE_G,
|
|
46
|
+
LUMINANCE_B,
|
|
47
|
+
RGB24_BYTES_PER_PIXEL,
|
|
48
|
+
PACK_RED_SHIFT,
|
|
49
|
+
PACK_GREEN_SHIFT,
|
|
50
|
+
} from '..';
|
|
51
|
+
|
|
52
|
+
// RGB15 ANSI color helpers using shared utilities
|
|
53
|
+
const rgb15ToTrueColor = (color: number): string => {
|
|
54
|
+
const [r, g, b] = rgb15ToRgb24(color);
|
|
55
|
+
return fgTrueColor(r, g, b);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const rgb15ToBgTrueColor = (color: number): string => {
|
|
59
|
+
const [r, g, b] = rgb15ToRgb24(color);
|
|
60
|
+
return bgTrueColor(r, g, b);
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// RGB24 luminance helper using shared utilities
|
|
64
|
+
const rgb24ToLuminance = (r: number, g: number, b: number): number => calculateLuminance(r, g, b);
|
|
65
|
+
|
|
66
|
+
export interface RendererOptions extends Required<Pick<EffectOptions, 'gamma' | 'scanlines' | 'saturation' | 'brightness' | 'contrast' | 'vignette'>> {
|
|
67
|
+
width: number;
|
|
68
|
+
height: number;
|
|
69
|
+
colorEnabled: boolean;
|
|
70
|
+
trueColorEnabled: boolean;
|
|
71
|
+
asciiMode: boolean;
|
|
72
|
+
emojiMode: boolean;
|
|
73
|
+
sourceWidth: number; // Source framebuffer width (e.g., 256, 160 for GBC)
|
|
74
|
+
sourceHeight: number; // Source framebuffer height (e.g., 240, 144 for GBC)
|
|
75
|
+
enableDiffRendering: boolean; // Enable diff-based rendering optimization (default: true)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ASCII character ramps for different density levels
|
|
79
|
+
const ASCII_CHARS_DENSE = ' .\'`^",:;Il!i><~+_-][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$';
|
|
80
|
+
const ASCII_CHARS_SIMPLE = ' .-:=+*#%@';
|
|
81
|
+
|
|
82
|
+
export class TerminalRenderer {
|
|
83
|
+
private width: number;
|
|
84
|
+
private height: number;
|
|
85
|
+
private colorEnabled: boolean;
|
|
86
|
+
private trueColorEnabled: boolean;
|
|
87
|
+
private asciiMode: boolean;
|
|
88
|
+
private emojiMode: boolean;
|
|
89
|
+
private asciiChars: string;
|
|
90
|
+
private offsetCol: number = 0; // Horizontal offset for centering (0-based for padding)
|
|
91
|
+
private offsetRow: number = 1; // Vertical offset for centering (1-based for ANSI)
|
|
92
|
+
private sourceWidth: number; // Source framebuffer width
|
|
93
|
+
private sourceHeight: number; // Source framebuffer height
|
|
94
|
+
// Pre-computed padding string for centering
|
|
95
|
+
private paddingString: string = '';
|
|
96
|
+
// Diff-based rendering optimization
|
|
97
|
+
private enableDiffRendering: boolean;
|
|
98
|
+
// Diff-based rendering state
|
|
99
|
+
private prevFrameBufferRgb15: Uint16Array | null = null;
|
|
100
|
+
// Previous output grid for character-level diff detection
|
|
101
|
+
// Stores the rendered string for each character position
|
|
102
|
+
private prevOutputGrid: string[][] | null = null;
|
|
103
|
+
private frameNumber: number = 0;
|
|
104
|
+
// Color state tracking for batching (avoid redundant escape codes)
|
|
105
|
+
private currentFg: number = -1; // Current foreground color (packed RGB or -1 for unset)
|
|
106
|
+
private currentBg: number = -1; // Current background color (packed RGB or -1 for unset)
|
|
107
|
+
// Post-processing effects
|
|
108
|
+
private gamma: number = 1.0;
|
|
109
|
+
private scanlines: number = 0;
|
|
110
|
+
private saturation: number = 1.0;
|
|
111
|
+
private brightness: number = 1.0;
|
|
112
|
+
private contrast: number = 1.0;
|
|
113
|
+
private vignette: number = 0;
|
|
114
|
+
// Pre-computed lookup tables for effects
|
|
115
|
+
private gammaLUT: Uint8Array | null = null;
|
|
116
|
+
private vignetteMap: Float32Array | null = null;
|
|
117
|
+
// Track if any effects are enabled
|
|
118
|
+
private effectsEnabled: boolean = false;
|
|
119
|
+
|
|
120
|
+
constructor(options: Partial<RendererOptions> = {}) {
|
|
121
|
+
this.width = options.width ?? DEFAULT_DISPLAY_WIDTH;
|
|
122
|
+
this.height = options.height ?? DEFAULT_DISPLAY_HEIGHT;
|
|
123
|
+
this.colorEnabled = options.colorEnabled ?? true;
|
|
124
|
+
this.trueColorEnabled = options.trueColorEnabled ?? true;
|
|
125
|
+
this.asciiMode = options.asciiMode ?? false;
|
|
126
|
+
this.emojiMode = options.emojiMode ?? false;
|
|
127
|
+
this.sourceWidth = options.sourceWidth ?? DEFAULT_SOURCE_WIDTH;
|
|
128
|
+
this.sourceHeight = options.sourceHeight ?? DEFAULT_SOURCE_HEIGHT;
|
|
129
|
+
this.enableDiffRendering = options.enableDiffRendering ?? true;
|
|
130
|
+
// Post-processing effects
|
|
131
|
+
this.gamma = options.gamma ?? DEFAULT_GAMMA;
|
|
132
|
+
this.scanlines = options.scanlines ?? DEFAULT_SCANLINES;
|
|
133
|
+
this.saturation = options.saturation ?? DEFAULT_SATURATION;
|
|
134
|
+
this.brightness = options.brightness ?? DEFAULT_BRIGHTNESS;
|
|
135
|
+
this.contrast = options.contrast ?? DEFAULT_CONTRAST;
|
|
136
|
+
this.vignette = options.vignette ?? DEFAULT_VIGNETTE;
|
|
137
|
+
// Use dense character set for better detail in ASCII mode
|
|
138
|
+
this.asciiChars = this.asciiMode ? ASCII_CHARS_DENSE : ASCII_CHARS_SIMPLE;
|
|
139
|
+
// Calculate centering offsets
|
|
140
|
+
this.calculateOffsets();
|
|
141
|
+
// Initialize effect lookup tables
|
|
142
|
+
this.initializeEffects();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Calculate centering offsets based on terminal size
|
|
146
|
+
private calculateOffsets(): void {
|
|
147
|
+
const { width: termCols, height: termRows } = getTerminalDimensions();
|
|
148
|
+
|
|
149
|
+
// Leave rows for status line
|
|
150
|
+
const availableRows = termRows - STATUS_LINE_ROWS;
|
|
151
|
+
|
|
152
|
+
// Horizontal centering (0-based for padding)
|
|
153
|
+
// Emoji mode: each character is EMOJI_COLUMN_WIDTH terminal columns wide
|
|
154
|
+
const displayWidth = this.emojiMode ? this.width * EMOJI_COLUMN_WIDTH : this.width;
|
|
155
|
+
this.offsetCol = Math.max(0, Math.floor((termCols - displayWidth) / 2));
|
|
156
|
+
|
|
157
|
+
// Vertical centering (1-based for ANSI escape sequences)
|
|
158
|
+
this.offsetRow = Math.max(1, Math.floor((availableRows - this.height) / 2) + 1);
|
|
159
|
+
|
|
160
|
+
// Pre-compute padding string for centering
|
|
161
|
+
this.paddingString = this.offsetCol > 0 ? ' '.repeat(this.offsetCol) : '';
|
|
162
|
+
|
|
163
|
+
// Invalidate diff cache when offsets change
|
|
164
|
+
this.invalidateDiffCache();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Invalidate the diff cache (called on resize or mode change)
|
|
168
|
+
private invalidateDiffCache(): void {
|
|
169
|
+
this.prevFrameBufferRgb15 = null;
|
|
170
|
+
this.prevOutputGrid = null;
|
|
171
|
+
this.frameNumber = 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Initialize effect lookup tables
|
|
175
|
+
private initializeEffects(): void {
|
|
176
|
+
// Check if any effects are enabled
|
|
177
|
+
this.effectsEnabled = this.gamma !== DEFAULT_GAMMA || this.scanlines > DEFAULT_SCANLINES ||
|
|
178
|
+
this.saturation !== DEFAULT_SATURATION || this.brightness !== DEFAULT_BRIGHTNESS ||
|
|
179
|
+
this.contrast !== DEFAULT_CONTRAST || this.vignette > DEFAULT_VIGNETTE;
|
|
180
|
+
|
|
181
|
+
// Build gamma lookup table
|
|
182
|
+
if (this.gamma !== DEFAULT_GAMMA) {
|
|
183
|
+
this.gammaLUT = new Uint8Array(GAMMA_LUT_SIZE);
|
|
184
|
+
for (let i = 0; i < GAMMA_LUT_SIZE; i++) {
|
|
185
|
+
this.gammaLUT[i] = Math.round(Math.pow(i / COLOR_CHANNEL_MAX, this.gamma) * COLOR_CHANNEL_MAX);
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
this.gammaLUT = null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Build vignette map (based on display dimensions)
|
|
192
|
+
if (this.vignette > DEFAULT_VIGNETTE) {
|
|
193
|
+
// Use source dimensions for vignette calculation
|
|
194
|
+
// Half-block mode uses 2 vertical pixels per character
|
|
195
|
+
const HALF_BLOCK_VERTICAL_SCALE = 2;
|
|
196
|
+
const vignetteWidth = this.width;
|
|
197
|
+
const vignetteHeight = this.asciiMode || this.emojiMode ? this.height : this.height * HALF_BLOCK_VERTICAL_SCALE;
|
|
198
|
+
this.vignetteMap = new Float32Array(vignetteWidth * vignetteHeight);
|
|
199
|
+
const centerX = vignetteWidth / 2;
|
|
200
|
+
const centerY = vignetteHeight / 2;
|
|
201
|
+
const maxDist = Math.sqrt(centerX * centerX + centerY * centerY);
|
|
202
|
+
|
|
203
|
+
for (let y = 0; y < vignetteHeight; y++) {
|
|
204
|
+
for (let x = 0; x < vignetteWidth; x++) {
|
|
205
|
+
const dx = x - centerX;
|
|
206
|
+
const dy = y - centerY;
|
|
207
|
+
const dist = Math.sqrt(dx * dx + dy * dy) / maxDist;
|
|
208
|
+
// Smooth falloff using squared distance
|
|
209
|
+
const factor = 1.0 - this.vignette * dist * dist;
|
|
210
|
+
this.vignetteMap[y * vignetteWidth + x] = Math.max(0, factor);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
this.vignetteMap = null;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Apply all effects to an RGB color
|
|
219
|
+
// charX, charY are in character coordinates; isScanlineRow indicates output-based scanline
|
|
220
|
+
private applyEffects(r: number, g: number, b: number, charX: number, charY: number, isScanlineRow: boolean): [number, number, number] {
|
|
221
|
+
if (!this.effectsEnabled) {
|
|
222
|
+
return [r, g, b];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Apply saturation
|
|
226
|
+
if (this.saturation !== DEFAULT_SATURATION) {
|
|
227
|
+
const gray = LUMINANCE_R * r + LUMINANCE_G * g + LUMINANCE_B * b;
|
|
228
|
+
r = Math.round(gray + this.saturation * (r - gray));
|
|
229
|
+
g = Math.round(gray + this.saturation * (g - gray));
|
|
230
|
+
b = Math.round(gray + this.saturation * (b - gray));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Apply brightness
|
|
234
|
+
if (this.brightness !== DEFAULT_BRIGHTNESS) {
|
|
235
|
+
r = Math.round(r * this.brightness);
|
|
236
|
+
g = Math.round(g * this.brightness);
|
|
237
|
+
b = Math.round(b * this.brightness);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Apply contrast
|
|
241
|
+
if (this.contrast !== DEFAULT_CONTRAST) {
|
|
242
|
+
r = Math.round((r - CONTRAST_MIDPOINT) * this.contrast + CONTRAST_MIDPOINT);
|
|
243
|
+
g = Math.round((g - CONTRAST_MIDPOINT) * this.contrast + CONTRAST_MIDPOINT);
|
|
244
|
+
b = Math.round((b - CONTRAST_MIDPOINT) * this.contrast + CONTRAST_MIDPOINT);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Apply gamma correction
|
|
248
|
+
if (this.gammaLUT) {
|
|
249
|
+
r = this.gammaLUT[clamp(r, { min: 0, max: COLOR_CHANNEL_MAX })];
|
|
250
|
+
g = this.gammaLUT[clamp(g, { min: 0, max: COLOR_CHANNEL_MAX })];
|
|
251
|
+
b = this.gammaLUT[clamp(b, { min: 0, max: COLOR_CHANNEL_MAX })];
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Apply vignette
|
|
255
|
+
if (this.vignetteMap) {
|
|
256
|
+
const vignetteWidth = this.width;
|
|
257
|
+
const idx = Math.min(charY * vignetteWidth + charX, this.vignetteMap.length - 1);
|
|
258
|
+
const factor = this.vignetteMap[Math.max(0, idx)];
|
|
259
|
+
r = Math.round(r * factor);
|
|
260
|
+
g = Math.round(g * factor);
|
|
261
|
+
b = Math.round(b * factor);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Apply scanlines (output-based: consistent spacing regardless of scale)
|
|
265
|
+
if (this.scanlines > DEFAULT_SCANLINES && isScanlineRow) {
|
|
266
|
+
const darkFactor = DEFAULT_BRIGHTNESS - this.scanlines;
|
|
267
|
+
r = Math.round(r * darkFactor);
|
|
268
|
+
g = Math.round(g * darkFactor);
|
|
269
|
+
b = Math.round(b * darkFactor);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Clamp values
|
|
273
|
+
return [
|
|
274
|
+
clamp(r, { min: 0, max: COLOR_CHANNEL_MAX }),
|
|
275
|
+
clamp(g, { min: 0, max: COLOR_CHANNEL_MAX }),
|
|
276
|
+
clamp(b, { min: 0, max: COLOR_CHANNEL_MAX })
|
|
277
|
+
];
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Reset color state (call at start of frame or after cursor movement)
|
|
281
|
+
private resetColorState(): void {
|
|
282
|
+
this.currentFg = -1;
|
|
283
|
+
this.currentBg = -1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Pack RGB into a single number for comparison
|
|
287
|
+
private packRgb(r: number, g: number, b: number): number {
|
|
288
|
+
return (r << PACK_RED_SHIFT) | (g << PACK_GREEN_SHIFT) | b;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Emit foreground color escape sequence only if changed
|
|
292
|
+
private emitFg(r: number, g: number, b: number): string {
|
|
293
|
+
const packed = this.packRgb(r, g, b);
|
|
294
|
+
if (packed === this.currentFg) {return '';}
|
|
295
|
+
this.currentFg = packed;
|
|
296
|
+
return fgTrueColor(r, g, b);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Emit background color escape sequence only if changed
|
|
300
|
+
private emitBg(r: number, g: number, b: number): string {
|
|
301
|
+
const packed = this.packRgb(r, g, b);
|
|
302
|
+
if (packed === this.currentBg) {return '';}
|
|
303
|
+
this.currentBg = packed;
|
|
304
|
+
return bgTrueColor(r, g, b);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Emit ANSI 256 foreground color only if changed
|
|
308
|
+
private emitFg256(code: number): string {
|
|
309
|
+
if (code === this.currentFg) {return '';}
|
|
310
|
+
this.currentFg = code;
|
|
311
|
+
return fgAnsi256(code);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Emit ANSI 256 background color only if changed
|
|
315
|
+
private emitBg256(code: number): string {
|
|
316
|
+
if (code === this.currentBg) {return '';}
|
|
317
|
+
this.currentBg = code;
|
|
318
|
+
return bgAnsi256(code);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Simple full-frame render for RGB15 without any diff caching
|
|
322
|
+
// Optimized: tracks color state to avoid redundant escape codes
|
|
323
|
+
private renderFullFrameSimpleRgb15(frameBuffer: Uint16Array, scaleX: number, scaleY: number): string {
|
|
324
|
+
const output: string[] = [];
|
|
325
|
+
|
|
326
|
+
for (let charY = 0; charY < this.height; charY++) {
|
|
327
|
+
// Reset color state at start of each line
|
|
328
|
+
this.resetColorState();
|
|
329
|
+
let line = this.paddingString;
|
|
330
|
+
|
|
331
|
+
if (this.emojiMode) {
|
|
332
|
+
for (let charX = 0; charX < this.width; charX++) {
|
|
333
|
+
const srcX = Math.floor(charX * scaleX);
|
|
334
|
+
const srcY = Math.floor(charY * scaleY);
|
|
335
|
+
const pixel = frameBuffer[srcY * this.sourceWidth + srcX];
|
|
336
|
+
line += this.colorEnabled ? rgb15ToEmoji(pixel) : rgb15ToGrayscaleEmoji(pixel);
|
|
337
|
+
}
|
|
338
|
+
} else if (this.asciiMode) {
|
|
339
|
+
for (let charX = 0; charX < this.width; charX++) {
|
|
340
|
+
const srcX = Math.floor(charX * scaleX);
|
|
341
|
+
const srcY = Math.floor(charY * scaleY);
|
|
342
|
+
const pixel = frameBuffer[srcY * this.sourceWidth + srcX];
|
|
343
|
+
const lum = rgb15ToLuminance(pixel);
|
|
344
|
+
const char = this.grayscaleChar(lum);
|
|
345
|
+
if (this.colorEnabled) {
|
|
346
|
+
let [r, g, b] = rgb15ToRgb24(pixel);
|
|
347
|
+
[r, g, b] = this.applyEffects(r, g, b, charX, charY, (charY & 1) === 1);
|
|
348
|
+
line += this.emitFg(r, g, b) + char;
|
|
349
|
+
} else {
|
|
350
|
+
line += char;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
if (this.colorEnabled) {line += RESET;}
|
|
354
|
+
} else {
|
|
355
|
+
// Terminal mode: half-block characters with color batching
|
|
356
|
+
for (let charX = 0; charX < this.width; charX++) {
|
|
357
|
+
const srcX = Math.floor(charX * scaleX);
|
|
358
|
+
const srcY1 = Math.floor(charY * 2 * scaleY);
|
|
359
|
+
const srcY2 = Math.floor((charY * 2 + 1) * scaleY);
|
|
360
|
+
const topPixel = frameBuffer[srcY1 * this.sourceWidth + srcX];
|
|
361
|
+
const bottomPixel = frameBuffer[srcY2 * this.sourceWidth + srcX];
|
|
362
|
+
|
|
363
|
+
if (this.colorEnabled) {
|
|
364
|
+
if (this.trueColorEnabled) {
|
|
365
|
+
let [r1, g1, b1] = rgb15ToRgb24(topPixel);
|
|
366
|
+
let [r2, g2, b2] = rgb15ToRgb24(bottomPixel);
|
|
367
|
+
[r1, g1, b1] = this.applyEffects(r1, g1, b1, charX, charY, false);
|
|
368
|
+
[r2, g2, b2] = this.applyEffects(r2, g2, b2, charX, charY, true);
|
|
369
|
+
line += this.emitFg(r1, g1, b1);
|
|
370
|
+
line += this.emitBg(r2, g2, b2);
|
|
371
|
+
line += HALF_BLOCK_TOP;
|
|
372
|
+
} else {
|
|
373
|
+
let [r1, g1, b1] = rgb15ToRgb24(topPixel);
|
|
374
|
+
let [r2, g2, b2] = rgb15ToRgb24(bottomPixel);
|
|
375
|
+
[r1, g1, b1] = this.applyEffects(r1, g1, b1, charX, charY, false);
|
|
376
|
+
[r2, g2, b2] = this.applyEffects(r2, g2, b2, charX, charY, true);
|
|
377
|
+
const fg = rgbToAnsi256(r1, g1, b1);
|
|
378
|
+
const bg = rgbToAnsi256(r2, g2, b2);
|
|
379
|
+
line += this.emitFg256(fg) + this.emitBg256(bg) + HALF_BLOCK_TOP;
|
|
380
|
+
}
|
|
381
|
+
} else {
|
|
382
|
+
const lumTop = rgb15ToLuminance(topPixel);
|
|
383
|
+
const lumBottom = rgb15ToLuminance(bottomPixel);
|
|
384
|
+
let grayTop = Math.round(lumTop * COLOR_CHANNEL_MAX);
|
|
385
|
+
let grayBottom = Math.round(lumBottom * COLOR_CHANNEL_MAX);
|
|
386
|
+
[grayTop, , ] = this.applyEffects(grayTop, grayTop, grayTop, charX, charY, false);
|
|
387
|
+
[grayBottom, , ] = this.applyEffects(grayBottom, grayBottom, grayBottom, charX, charY, true);
|
|
388
|
+
line += this.emitFg(grayTop, grayTop, grayTop);
|
|
389
|
+
line += this.emitBg(grayBottom, grayBottom, grayBottom);
|
|
390
|
+
line += HALF_BLOCK_TOP;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
line += RESET;
|
|
394
|
+
}
|
|
395
|
+
output.push(line);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
this.frameNumber++;
|
|
399
|
+
return this.moveCursorHome() + output.join('\n');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Render a single character for RGB15 mode
|
|
403
|
+
// Returns the ANSI escape sequence + character for this position
|
|
404
|
+
private renderCharRgb15(frameBuffer: Uint16Array, charX: number, charY: number, scaleX: number, scaleY: number): string {
|
|
405
|
+
const srcX = Math.floor(charX * scaleX);
|
|
406
|
+
|
|
407
|
+
if (this.emojiMode) {
|
|
408
|
+
const srcY = Math.floor(charY * scaleY);
|
|
409
|
+
const pixel = frameBuffer[srcY * this.sourceWidth + srcX];
|
|
410
|
+
return this.colorEnabled ? rgb15ToEmoji(pixel) : rgb15ToGrayscaleEmoji(pixel);
|
|
411
|
+
} else if (this.asciiMode) {
|
|
412
|
+
const srcY = Math.floor(charY * scaleY);
|
|
413
|
+
const pixel = frameBuffer[srcY * this.sourceWidth + srcX];
|
|
414
|
+
const lum = rgb15ToLuminance(pixel);
|
|
415
|
+
const char = this.grayscaleChar(lum);
|
|
416
|
+
|
|
417
|
+
if (this.colorEnabled) {
|
|
418
|
+
return rgb15ToTrueColor(pixel) + char + RESET;
|
|
419
|
+
} else {
|
|
420
|
+
return char;
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
// Terminal mode: half-block characters
|
|
424
|
+
const srcY1 = Math.floor(charY * 2 * scaleY);
|
|
425
|
+
const srcY2 = Math.floor((charY * 2 + 1) * scaleY);
|
|
426
|
+
|
|
427
|
+
const topPixel = frameBuffer[srcY1 * this.sourceWidth + srcX];
|
|
428
|
+
const bottomPixel = frameBuffer[srcY2 * this.sourceWidth + srcX];
|
|
429
|
+
|
|
430
|
+
if (this.colorEnabled) {
|
|
431
|
+
if (this.trueColorEnabled) {
|
|
432
|
+
return rgb15ToTrueColor(topPixel) + rgb15ToBgTrueColor(bottomPixel) + HALF_BLOCK_TOP + RESET;
|
|
433
|
+
} else {
|
|
434
|
+
const [r1, g1, b1] = rgb15ToRgb24(topPixel);
|
|
435
|
+
const [r2, g2, b2] = rgb15ToRgb24(bottomPixel);
|
|
436
|
+
const fg = rgbToAnsi256(r1, g1, b1);
|
|
437
|
+
const bg = rgbToAnsi256(r2, g2, b2);
|
|
438
|
+
return fgAnsi256(fg) + bgAnsi256(bg) + HALF_BLOCK_TOP + RESET;
|
|
439
|
+
}
|
|
440
|
+
} else {
|
|
441
|
+
// Grayscale mode: use half-blocks with grayscale ANSI colors
|
|
442
|
+
const lumTop = rgb15ToLuminance(topPixel);
|
|
443
|
+
const lumBottom = rgb15ToLuminance(bottomPixel);
|
|
444
|
+
const grayTop = Math.round(lumTop * COLOR_CHANNEL_MAX);
|
|
445
|
+
const grayBottom = Math.round(lumBottom * COLOR_CHANNEL_MAX);
|
|
446
|
+
return fgTrueColor(grayTop, grayTop, grayTop) + bgTrueColor(grayBottom, grayBottom, grayBottom) + HALF_BLOCK_TOP + RESET;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Check if source pixels for a character position have changed (RGB15 version)
|
|
452
|
+
private hasPixelChangedRgb15(
|
|
453
|
+
frameBuffer: Uint16Array,
|
|
454
|
+
prevFrameBuffer: Uint16Array,
|
|
455
|
+
charX: number,
|
|
456
|
+
charY: number,
|
|
457
|
+
scaleX: number,
|
|
458
|
+
scaleY: number
|
|
459
|
+
): boolean {
|
|
460
|
+
const srcX = Math.floor(charX * scaleX);
|
|
461
|
+
|
|
462
|
+
if (this.asciiMode || this.emojiMode) {
|
|
463
|
+
const srcY = Math.floor(charY * scaleY);
|
|
464
|
+
const idx = srcY * this.sourceWidth + srcX;
|
|
465
|
+
return frameBuffer[idx] !== prevFrameBuffer[idx];
|
|
466
|
+
} else {
|
|
467
|
+
const srcY1 = Math.floor(charY * 2 * scaleY);
|
|
468
|
+
const srcY2 = Math.floor((charY * 2 + 1) * scaleY);
|
|
469
|
+
const idx1 = srcY1 * this.sourceWidth + srcX;
|
|
470
|
+
const idx2 = srcY2 * this.sourceWidth + srcX;
|
|
471
|
+
return frameBuffer[idx1] !== prevFrameBuffer[idx1] ||
|
|
472
|
+
frameBuffer[idx2] !== prevFrameBuffer[idx2];
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Render full frame without diff optimization for RGB15 (used for first frame or when >50% changed)
|
|
477
|
+
// Uses color batching for output, but still updates grid for future diff comparisons
|
|
478
|
+
private renderFullFrameRgb15(frameBuffer: Uint16Array, scaleX: number, scaleY: number): string {
|
|
479
|
+
const outputGrid = this.prevOutputGrid!;
|
|
480
|
+
|
|
481
|
+
// Update all characters in output grid (needed for future diff comparisons)
|
|
482
|
+
for (let y = 0; y < this.height; y++) {
|
|
483
|
+
for (let x = 0; x < this.width; x++) {
|
|
484
|
+
outputGrid[y][x] = this.renderCharRgb15(frameBuffer, x, y, scaleX, scaleY);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// Update previous frame buffer
|
|
489
|
+
this.prevFrameBufferRgb15!.set(frameBuffer);
|
|
490
|
+
|
|
491
|
+
// Render with color batching (more efficient than joining grid entries)
|
|
492
|
+
const result = this.renderFullFrameSimpleRgb15(frameBuffer, scaleX, scaleY);
|
|
493
|
+
|
|
494
|
+
// renderFullFrameSimpleRgb15 increments frameNumber, so don't double-increment
|
|
495
|
+
return result;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// Render RGB15 frame buffer (for GBC and other RGB15 cores) with diff-based optimization
|
|
499
|
+
renderRgb15(frameBuffer: Uint16Array): string {
|
|
500
|
+
const scaleX = this.sourceWidth / this.width;
|
|
501
|
+
const scaleY = (this.asciiMode || this.emojiMode)
|
|
502
|
+
? this.sourceHeight / this.height
|
|
503
|
+
: this.sourceHeight / (this.height * 2);
|
|
504
|
+
|
|
505
|
+
// If diff rendering is disabled, always render the full frame
|
|
506
|
+
if (!this.enableDiffRendering) {
|
|
507
|
+
return this.renderFullFrameSimpleRgb15(frameBuffer, scaleX, scaleY);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Check if frame buffer size changed (can happen with SNES resolution changes)
|
|
511
|
+
if (this.prevFrameBufferRgb15 !== null && frameBuffer.length !== this.prevFrameBufferRgb15.length) {
|
|
512
|
+
this.prevFrameBufferRgb15 = new Uint16Array(frameBuffer.length);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// First frame or after cache invalidation: render everything
|
|
516
|
+
const isFirstFrame = this.prevFrameBufferRgb15 === null || this.prevOutputGrid === null;
|
|
517
|
+
|
|
518
|
+
if (isFirstFrame) {
|
|
519
|
+
// Initialize previous frame buffer
|
|
520
|
+
this.prevFrameBufferRgb15 = new Uint16Array(frameBuffer.length);
|
|
521
|
+
this.prevFrameBufferRgb15.set(frameBuffer);
|
|
522
|
+
|
|
523
|
+
// Initialize output grid (needed for future diff comparisons)
|
|
524
|
+
this.prevOutputGrid = [];
|
|
525
|
+
for (let y = 0; y < this.height; y++) {
|
|
526
|
+
this.prevOutputGrid[y] = [];
|
|
527
|
+
for (let x = 0; x < this.width; x++) {
|
|
528
|
+
this.prevOutputGrid[y][x] = this.renderCharRgb15(frameBuffer, x, y, scaleX, scaleY);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Render with color batching (renderFullFrameSimpleRgb15 increments frameNumber)
|
|
533
|
+
return this.renderFullFrameSimpleRgb15(frameBuffer, scaleX, scaleY);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// These are guaranteed non-null after the isFirstFrame check above
|
|
537
|
+
const prevFrame = this.prevFrameBufferRgb15!;
|
|
538
|
+
const outputGrid = this.prevOutputGrid!;
|
|
539
|
+
|
|
540
|
+
// Count changed characters to decide whether to use diff or full render
|
|
541
|
+
const totalChars = this.width * this.height;
|
|
542
|
+
let changedCount = 0;
|
|
543
|
+
|
|
544
|
+
for (let charY = 0; charY < this.height && changedCount <= totalChars / 2; charY++) {
|
|
545
|
+
for (let charX = 0; charX < this.width && changedCount <= totalChars / 2; charX++) {
|
|
546
|
+
if (this.hasPixelChangedRgb15(frameBuffer, prevFrame, charX, charY, scaleX, scaleY)) {
|
|
547
|
+
changedCount++;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// If more than 50% changed, use full frame render (more efficient)
|
|
553
|
+
if (changedCount > totalChars / 2) {
|
|
554
|
+
return this.renderFullFrameRgb15(frameBuffer, scaleX, scaleY);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Diff-based rendering: only output changed characters
|
|
558
|
+
const output: string[] = [];
|
|
559
|
+
|
|
560
|
+
// Column width in terminal cells (emoji is EMOJI_COLUMN_WIDTH columns wide)
|
|
561
|
+
const colWidth = this.emojiMode ? EMOJI_COLUMN_WIDTH : 1;
|
|
562
|
+
|
|
563
|
+
for (let charY = 0; charY < this.height; charY++) {
|
|
564
|
+
// Find runs of changed characters on this line
|
|
565
|
+
let runStart = -1;
|
|
566
|
+
let runChars: string[] = [];
|
|
567
|
+
|
|
568
|
+
for (let charX = 0; charX < this.width; charX++) {
|
|
569
|
+
const changed = this.hasPixelChangedRgb15(frameBuffer, prevFrame, charX, charY, scaleX, scaleY);
|
|
570
|
+
|
|
571
|
+
if (changed) {
|
|
572
|
+
// Render the new character
|
|
573
|
+
const charStr = this.renderCharRgb15(frameBuffer, charX, charY, scaleX, scaleY);
|
|
574
|
+
outputGrid[charY][charX] = charStr;
|
|
575
|
+
|
|
576
|
+
if (runStart === -1) {
|
|
577
|
+
runStart = charX;
|
|
578
|
+
}
|
|
579
|
+
runChars.push(charStr);
|
|
580
|
+
} else if (runStart !== -1) {
|
|
581
|
+
// End of a run - check if we should continue or output
|
|
582
|
+
const gapEnd = Math.min(charX + DIFF_GAP_THRESHOLD, this.width);
|
|
583
|
+
let nextChanged = -1;
|
|
584
|
+
for (let i = charX; i < gapEnd; i++) {
|
|
585
|
+
if (this.hasPixelChangedRgb15(frameBuffer, prevFrame, i, charY, scaleX, scaleY)) {
|
|
586
|
+
nextChanged = i;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (nextChanged !== -1) {
|
|
592
|
+
// Small gap - include unchanged chars to avoid cursor movement
|
|
593
|
+
for (let i = charX; i < nextChanged; i++) {
|
|
594
|
+
runChars.push(outputGrid[charY][i]);
|
|
595
|
+
}
|
|
596
|
+
// Skip past the gap - loop will increment to nextChanged
|
|
597
|
+
charX = nextChanged - 1;
|
|
598
|
+
} else {
|
|
599
|
+
// Large gap or end of line - output the run
|
|
600
|
+
const row = this.offsetRow + charY;
|
|
601
|
+
const col = this.offsetCol + runStart * colWidth + 1;
|
|
602
|
+
output.push(moveCursor(row, col) + runChars.join(''));
|
|
603
|
+
runStart = -1;
|
|
604
|
+
runChars = [];
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Output any remaining run at end of line
|
|
610
|
+
if (runStart !== -1) {
|
|
611
|
+
const row = this.offsetRow + charY;
|
|
612
|
+
const col = this.offsetCol + runStart * colWidth + 1;
|
|
613
|
+
output.push(moveCursor(row, col) + runChars.join(''));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// Update previous frame buffer
|
|
618
|
+
prevFrame.set(frameBuffer);
|
|
619
|
+
this.frameNumber++;
|
|
620
|
+
|
|
621
|
+
// If nothing changed, return empty string
|
|
622
|
+
if (output.length === 0) {
|
|
623
|
+
return '';
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return output.join('');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Render RGB24 frame buffer (for libretro and other RGB24 cores)
|
|
630
|
+
// Optimized: tracks color state to avoid redundant escape codes
|
|
631
|
+
renderRgb24(frameBuffer: Uint8Array): string {
|
|
632
|
+
const scaleX = this.sourceWidth / this.width;
|
|
633
|
+
const scaleY = (this.asciiMode || this.emojiMode)
|
|
634
|
+
? this.sourceHeight / this.height
|
|
635
|
+
: this.sourceHeight / (this.height * 2);
|
|
636
|
+
|
|
637
|
+
const output: string[] = [];
|
|
638
|
+
|
|
639
|
+
for (let charY = 0; charY < this.height; charY++) {
|
|
640
|
+
// Reset color state at start of each line
|
|
641
|
+
this.resetColorState();
|
|
642
|
+
let line = this.paddingString;
|
|
643
|
+
|
|
644
|
+
if (this.emojiMode) {
|
|
645
|
+
for (let charX = 0; charX < this.width; charX++) {
|
|
646
|
+
const srcX = Math.floor(charX * scaleX);
|
|
647
|
+
const srcY = Math.floor(charY * scaleY);
|
|
648
|
+
const idx = (srcY * this.sourceWidth + srcX) * RGB24_BYTES_PER_PIXEL;
|
|
649
|
+
const r = frameBuffer[idx];
|
|
650
|
+
const g = frameBuffer[idx + 1];
|
|
651
|
+
const b = frameBuffer[idx + 2];
|
|
652
|
+
line += this.colorEnabled ? rgb24ToEmoji(r, g, b) : rgb24ToGrayscaleEmoji(r, g, b);
|
|
653
|
+
}
|
|
654
|
+
} else if (this.asciiMode) {
|
|
655
|
+
for (let charX = 0; charX < this.width; charX++) {
|
|
656
|
+
const srcX = Math.floor(charX * scaleX);
|
|
657
|
+
const srcY = Math.floor(charY * scaleY);
|
|
658
|
+
const idx = (srcY * this.sourceWidth + srcX) * RGB24_BYTES_PER_PIXEL;
|
|
659
|
+
let r = frameBuffer[idx];
|
|
660
|
+
let g = frameBuffer[idx + 1];
|
|
661
|
+
let b = frameBuffer[idx + 2];
|
|
662
|
+
const lum = rgb24ToLuminance(r, g, b);
|
|
663
|
+
const char = this.grayscaleChar(lum);
|
|
664
|
+
if (this.colorEnabled) {
|
|
665
|
+
[r, g, b] = this.applyEffects(r, g, b, charX, charY, (charY & 1) === 1);
|
|
666
|
+
line += this.emitFg(r, g, b) + char;
|
|
667
|
+
} else {
|
|
668
|
+
line += char;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (this.colorEnabled) {line += RESET;}
|
|
672
|
+
} else {
|
|
673
|
+
// Terminal mode: half-block characters with color batching
|
|
674
|
+
for (let charX = 0; charX < this.width; charX++) {
|
|
675
|
+
const srcX = Math.floor(charX * scaleX);
|
|
676
|
+
const srcY1 = Math.floor(charY * 2 * scaleY);
|
|
677
|
+
const srcY2 = Math.floor((charY * 2 + 1) * scaleY);
|
|
678
|
+
const idx1 = (srcY1 * this.sourceWidth + srcX) * RGB24_BYTES_PER_PIXEL;
|
|
679
|
+
const idx2 = (srcY2 * this.sourceWidth + srcX) * RGB24_BYTES_PER_PIXEL;
|
|
680
|
+
let r1 = frameBuffer[idx1];
|
|
681
|
+
let g1 = frameBuffer[idx1 + 1];
|
|
682
|
+
let b1 = frameBuffer[idx1 + 2];
|
|
683
|
+
let r2 = frameBuffer[idx2];
|
|
684
|
+
let g2 = frameBuffer[idx2 + 1];
|
|
685
|
+
let b2 = frameBuffer[idx2 + 2];
|
|
686
|
+
|
|
687
|
+
if (this.colorEnabled) {
|
|
688
|
+
if (this.trueColorEnabled) {
|
|
689
|
+
[r1, g1, b1] = this.applyEffects(r1, g1, b1, charX, charY, false);
|
|
690
|
+
[r2, g2, b2] = this.applyEffects(r2, g2, b2, charX, charY, true);
|
|
691
|
+
line += this.emitFg(r1, g1, b1);
|
|
692
|
+
line += this.emitBg(r2, g2, b2);
|
|
693
|
+
line += HALF_BLOCK_TOP;
|
|
694
|
+
} else {
|
|
695
|
+
[r1, g1, b1] = this.applyEffects(r1, g1, b1, charX, charY, false);
|
|
696
|
+
[r2, g2, b2] = this.applyEffects(r2, g2, b2, charX, charY, true);
|
|
697
|
+
const fg = rgbToAnsi256(r1, g1, b1);
|
|
698
|
+
const bg = rgbToAnsi256(r2, g2, b2);
|
|
699
|
+
line += this.emitFg256(fg) + this.emitBg256(bg) + HALF_BLOCK_TOP;
|
|
700
|
+
}
|
|
701
|
+
} else {
|
|
702
|
+
const lum1 = rgb24ToLuminance(r1, g1, b1);
|
|
703
|
+
const lum2 = rgb24ToLuminance(r2, g2, b2);
|
|
704
|
+
let grayTop = Math.round(lum1 * COLOR_CHANNEL_MAX);
|
|
705
|
+
let grayBottom = Math.round(lum2 * COLOR_CHANNEL_MAX);
|
|
706
|
+
[grayTop, , ] = this.applyEffects(grayTop, grayTop, grayTop, charX, charY, false);
|
|
707
|
+
[grayBottom, , ] = this.applyEffects(grayBottom, grayBottom, grayBottom, charX, charY, true);
|
|
708
|
+
line += this.emitFg(grayTop, grayTop, grayTop);
|
|
709
|
+
line += this.emitBg(grayBottom, grayBottom, grayBottom);
|
|
710
|
+
line += HALF_BLOCK_TOP;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
line += RESET;
|
|
714
|
+
}
|
|
715
|
+
output.push(line);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
this.frameNumber++;
|
|
719
|
+
return this.moveCursorHome() + output.join('\n');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Convert luminance to ASCII character
|
|
723
|
+
private grayscaleChar(luminance: number): string {
|
|
724
|
+
const index = Math.floor(luminance * (this.asciiChars.length - 1));
|
|
725
|
+
return this.asciiChars[Math.min(index, this.asciiChars.length - 1)];
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Get ANSI escape sequence to move cursor to centered start position
|
|
729
|
+
moveCursorHome(): string {
|
|
730
|
+
return moveCursor(this.offsetRow, 1);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// Clear screen
|
|
734
|
+
clearScreen(): string {
|
|
735
|
+
return clearScreen();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// Hide cursor
|
|
739
|
+
hideCursor(): string {
|
|
740
|
+
return hideCursor();
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// Show cursor
|
|
744
|
+
showCursor(): string {
|
|
745
|
+
return showCursor();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// Get the row number for the status line (below the rendered frame)
|
|
749
|
+
getStatusRow(): number {
|
|
750
|
+
return this.offsetRow + this.height;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// Move cursor to a specific row
|
|
754
|
+
moveCursorToRow(row: number): string {
|
|
755
|
+
return moveCursorToRow(row);
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Update display dimensions (for terminal resize handling)
|
|
759
|
+
setDimensions(width: number, height: number): void {
|
|
760
|
+
this.width = width;
|
|
761
|
+
this.height = height;
|
|
762
|
+
// Recalculate centering offsets
|
|
763
|
+
this.calculateOffsets();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Get current dimensions
|
|
767
|
+
getDimensions(): { width: number; height: number } {
|
|
768
|
+
return { width: this.width, height: this.height };
|
|
769
|
+
}
|
|
770
|
+
}
|