diff --git a/README.md b/README.md index 9a7eb50..8ce92cf 100644 --- a/README.md +++ b/README.md @@ -12,10 +12,12 @@ A terminal front-end for gambatte, the gameboy color emulator. It supports: -- 16 colors, 256 colors and 24-bit colors terminal +- All 16 colors, 256 colors and 24-bit color utf-8 terminals +- Kitty and sixel graphics where supported - Playing audio from the emulator - Using TAS input files as game input - Using keyboard presses as game input +- Remote execution over ssh or telnet (no audio) Quickstart @@ -44,8 +46,10 @@ Usage: ``` usage: gambaterm [-h] [--input-file INPUT_FILE] [--frame-advance FRAME_ADVANCE] [--break-after BREAK_AFTER] [--speed SPEED] [--force-gameboy] - [--skip-inputs SKIP_INPUTS] [--cpr-sync] [--disable-audio] - [--color-mode COLOR_MODE] + [--skip-inputs SKIP_INPUTS] [--cpr-sync [N]] [--disable-audio] + [--color-mode COLOR_MODE] [--graphics {text,sixel,kitty,auto}] + [--enable-controller] [--write-input FILE] [--status] + [--blit-visualizer] [--load-state N] [--save-directory DIR] ROM ``` @@ -61,40 +65,67 @@ Optional arguments: - `--frame-advance FRAME_ADVANCE, --fa FRAME_ADVANCE` - Number of frames to run before displaying the next one (default is 1) + Number of frames to run before displaying the next one - `--break-after BREAK_AFTER, --ba BREAK_AFTER` - Number of frames to run before forcing the emulator to stop (doesn't stop by default) + Number of frames to run before forcing the emulator to stop - `--speed SPEED, -s SPEED` - Control the execution speed (default is 1.0) + Control the execution speed - `--force-gameboy, --fg` - Force the emulator to treat the rom as a GB file + Force the emulator to treat the ROM as a GB file (monochrome) - `--skip-inputs SKIP_INPUTS, --si SKIP_INPUTS` - Number of frame inputs to skip in order to compensate for the lack of BIOS (default is 188) + Number of frame inputs to skip to compensate for the lack of BIOS - - `--cpr-sync, --cs` + - `--cpr-sync [N], --cs [N]` - Use CPR synchronization to prevent video buffering + Enable CPR synchronization to prevent video buffering. + N is the window of unacknowledged frames allowed (default 1 if flag given without value). + + - `--disable-audio, --da` + + Disable audio entirely + + - `--color-mode COLOR_MODE, -c COLOR_MODE` + + Force a color mode (1: 4 greyscale colors, 2: 16 colors, 3: 256 colors, 4: 24-bit colors). + The color mode can be cycled at runtime by pressing Tab. + + - `--graphics {text,sixel,kitty,auto}` + + Graphics rendering mode. Default "auto" detects available protocols. + Text mode uses unicode block characters at native resolution. - `--enable-controller, --ec` Enable game controller support - - `--disable-audio, --da` + - `--write-input FILE, --wi FILE` - Disable audio entirely + Record game inputs to a file - - `--color-mode COLOR_MODE, -c COLOR_MODE` + - `--status` + + Enable the status bar (toggle with backtick/tilde key) + Shows FPS, bandwidth, CPU usage, and current graphics mode. + + - `--blit-visualizer` - Force a color mode (1: 4 greyscale colors, 2: 16 colors, 3: 256 colors, 4: 24-bit colors) - Note: the color mode can be cycled at runtime by pressing the Tab key, which is useful for testing the different color modes supported by the terminal. + Enable blit visualizer overlay (toggle with F12 key) + + - `--load-state N, --ls N` + + Load save state N at startup (e.g. --load-state 8 loads ROM_8.gqs) + + - `--save-directory DIR, --sd DIR` + + Save directory path (defaults to the ROM directory when not specified) SSH server @@ -164,53 +195,77 @@ Not all terminals will actually offer a pleasant experience. The main criteria a This is not always well supported. - **Support for the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/)** - This is mandatory if you're using Wayland, and recommended on every other platforms. + This is mandatory if you're using Wayland, and recommended on every other platform. In the case where the kitty keyboard protocol is not detected by `gambaterm`, the following fallbacks are implemented: * Linux: uses X11 through `python-xlib` * Macos: uses `pynput` (it requires granting specific authorizations to the terminal app) * Windows: uses `pynput` (the key presses are detected even if the terminal windows is not focused) - It is also mandatory when connecting to a `gambaterm` SSH server. + Kitty keyboard support is mandatory for `gambaterm` telnet or SSH server. - **Good rendering performance** - The terminal has to be able to process about 500KB of requests per seconds for a smooth rendering of "intense" frames. + The terminal has to be able to process about 500KB or more of terminal sequences for a smooth 60fps rendering experience. + Typically, the most intense bursts happen during screen transitions of two detailed scenes. + Some forms of post-process blitting and compression is used in all modes, and so bandwidth is + of variable bitrate, but gameboy is capable of up to full-screen 60fps changes of up to 2Mbit + in the highest graphics modes. + +- **kitty or sixel graphics** + A terminal with support for kitty and sixel graphics provides crisp integer scaling to all + terminals where their graphics modes can be used and be automatically detected, though, at some + cost of higher bandwidth and CPU usage. + + Graphics scale is automatically capped to fit within the terminal at the best integer scale. + Bandwidth is managed via CPR sync (--cpr-sync) which prevents the terminal from being + overwhelmed with data. + + Although graphics modes (kitty, sixel) are preferred, counter-intuitively there is tremendous + bandwidth savings by using a larger text area or smaller font, and larger window resolution. + gambaterm automatically switches between graphics modes, changing to "text mode" when it + fits on the screen. + +- Press `TILDE` (~) or `GRAVE_ACCENT` (\`) to check the current mode of gambaterm. This enables a + status bar on the top of the window that displays the current FPS, Bandwidth, CPU Usage data, and + current graphics mode. Press `TAB` in text mode to switch between 4, 8, 16, 256, and 24-bit color + modes. Press `F12` to toggle the blit visualizer overlay. The table below sums up my findings when I tried the most common terminal emulators. Here's about linux: -| Linux | Status | Colors | Unicode rendering | Kitty keyboard protocol | Performance | Comments | -|------------------|------------|---------------|------------------------|-------------------------|-------------|-------------------------------------------------------------| -| Ghostty | Excellent | 24-bit colors | Good | Yes | 60 FPS | | -| Kitty | Excellent | 24-bit colors | Good | Yes | 60 FPS | | -| foot | Excellent | 24-bit colors | Good | Yes | 60 FPS | | -| Rio | Excellent | 24-bit colors | Good | Yes | 60 FPS | | -| Contour | Excellent | 24-bit colors | Good | Yes | 60 FPS | [Download latest for kitty support](https://github.com/contour-terminal/contour/releases) | -| Alacritty | Good | 24-bit colors | Good | No* | 60 FPS | *Fails kitty detection [due to reported bug](https://github.com/alacritty/alacritty/pull/8953) | -| Konsole | Good | 24-bit colors | Good | No | 60 FPS | | -| Gnome terminal | Good | 24-bit colors | Good | No | 60 FPS | | -| Terminator | Good | 24-bit colors | Good | No | 60 FPS | | -| XTerm | Good | 24-bit colors | Good | No | 60 FPS | Ctrl+Right click to resize, "Unreadable" locks up XTerm | -| Rxvt | Good | 24-bit colors | Good | No | 60 FPS | No resize shortcuts | -| Terminology | Good | 24-bit colors | Light misalignments | No | 60 FPS | Font sizes under ~9pt create horizontal line artifacts | -| Termit | Ok | 24-bit colors | Good | No | 60 FPS | No window title | -| Mlterm | Ok | 24-bit colors | Light misalignments | No | 60 FPS | No resize shortcuts | +| Linux | Status | Graphics | Colors | Unicode rendering | Kitty keyboard protocol | Performance | Comments | +|------------------|------------|---------------|---------------|------------------------|-------------------------|-------------|-------------------------------------------------------------| +| Ghostty | Excellent | Kitty | 24-bit colors | Good | Yes | 60 FPS | | +| Kitty | Excellent | Kitty | 24-bit colors | Good | Yes | 60 FPS | | +| foot | Excellent | Sixel | 24-bit colors | Good | Yes | 60 FPS | | +| Rio | Excellent | Kitty | 24-bit colors | Good | Yes | 60 FPS | \*suggest changing (render strategy)[https://rioterm.com/docs/config#rendererstrategy] to `game` | +| Contour | Excellent | Sixel | 24-bit colors | Good | Yes | 60 FPS | | +| XTerm | Good | Sixel* | 24-bit colors | Good | No | 60 FPS | \*Launch using `xterm -ti vt340` | +| Konsole | Good | Sixel | 24-bit colors | Good | No | 60 FPS | | +| Tabby | Good | Sixel | 24-bit colors | Good | No | 60 FPS | | +| Alacritty | Good | | 24-bit colors | Good | No* | 60 FPS | | +| Gnome terminal | Good | | 24-bit colors | Good | No | 60 FPS | | +| Terminator | Good | | 24-bit colors | Good | No | 60 FPS | | +| Rxvt | Good | | 24-bit colors | Good | No | 60 FPS | No resize shortcuts | +| Termit | Good | | 24-bit colors | Good | No | 60 FPS | | +| Mlterm | Ok | Sixel* | 24-bit colors | misalignments | No | 60 FPS | \* sixel blitting issues when window is out of focus, slow performance | +| Terminology | Ok | | 24-bit colors | Light misalignments | No | 60 FPS | Font sizes under ~9pt create horizontal line artifacts | +| Exoterm | Bad | | 24-bit colors | Light misalignments | No | 60 FPS | \* sixel issues and font graphics corruption both | About MacOS: -| MacOS | Status | Colors | Unicode rendering | Kitty keyboard protocol | Performance | Comments | -|------------------|------------|---------------|---------------------------|-------------------------|-------------|--------------------------| -| iTerm2 | Excellent | 24-bit colors | Good | Yes | 60 FPS | | -| Terminal.app | Bad | 24-bit colors | Bad--adjust font spacing! | No | 30 FPS | A bit jittery | +| MacOS | Status | Graphics | Colors | Unicode rendering | Kitty keyboard protocol | Performance | Comments | +|------------------|------------|---------------|------------------------------------|-------------------------|-------------|-----------------------------------------------------------------| +| iTerm2 | Excellent | Sixel* | 24-bit colors | Good | Yes | 60 FPS\* | Poor resize performance | +| Terminal.app | Bad | | 24-bit colors | Bad | No | 30 FPS | A bit jittery | About Windows: -| Windows | Status | Colors | Unicode rendering | Kitty keyboard protocol | Performance | Comments | +| Windows | Status | Graphics | Colors | Unicode rendering | Kitty keyboard protocol | Performance | Comments | |--------------------|------------|---------------|------------------------|-------------------------|-------------|--------------------------| -| Windows terminal | Excellent | 24-bit colors | Good | Yes | 60 FPS | [Download latest for kitty support)](https://github.com/microsoft/terminal/releases) | -| Cmder | Unplayable | 24-bit colors | Good | Yes | 2 FPS | No window title | -| Terminus | Unplayable | 24-bit colors | Misalignments | No | 10 FPS | | -| Command prompt | Bad | 24-bit colors | Good | No | 1 FPS | Slow/Unresponsive | -| Git bash (mingw64) | Ok | 24-bit colors | Good | No | N/A | | - +| Windows terminal | Excellent | | 24-bit colors | Good | Yes | 60 FPS | [Download latest for kitty support)](https://github.com/microsoft/terminal/releases) | +| Cmder | Unplayable | | 24-bit colors | Good | Yes | 2 FPS | No window title | +| Terminus | Unplayable | | 24-bit colors | Misalignments | No | 10 FPS | | +| Command prompt | Bad | | 24-bit colors | Good | No | 1 FPS | Slow/Unresponsive | +| Git bash (mingw64) | Ok | | 24-bit colors | Good | No | N/A | | Terminals without Kitty keyboard protocol require X11 to play locally, or X11 forwarding to use over SSH. @@ -219,6 +274,15 @@ Terminal size The emulator uses a single character on screen to display two vertically aligned pixels, like so `▄▀`. The gameboy being 160 pixels wide over 144 pixels high, you'll need your terminal to be at least 160 characters wide over 72 characters high to display the entire screen. Setting the terminal to full screen is usually enough but you might want to tweak the character size, typically using the `ctrl - / ctrl +` or `ctrl wheel` shortcuts. +Performance and window size +--------------------------- + +Larger terminal windows (more rows/columns) and smaller fonts improve performance in text mode because more pixels fit per character. In graphics mode (kitty/sixel), the integer scale is automatically chosen to best fit the window — larger windows support higher scales, giving a sharper image at the cost of more CPU and bandwidth. + +At high scales (8x and above), kitty keyframe encoding can take 25-30ms, which may cause audio clicking on slower machines. Use ``--cpr-sync`` to let the terminal pace the frame rate, and reduce the terminal window size or increase font size to lower the graphics scale. + +For SSH/telnet usage, lower scales produce less network traffic. The status bar (``~`` key) shows real-time bandwidth and FPS. + Keyboard, game controller and file inputs ----------------------------------------- diff --git a/gambaterm/audio.py b/gambaterm/audio.py index 6e14e2d..5f9662c 100644 --- a/gambaterm/audio.py +++ b/gambaterm/audio.py @@ -114,6 +114,13 @@ def adapt_sample_rate(self) -> None: # Return the adjusted sample rate self.sampling_ratio = self.nominal_sampling_ratio * correction + @property + def fill_fraction(self) -> float: + """Instantaneous ring buffer fill ratio (0.0–1.0).""" + if self.ring_size == 0: + return 0.0 + return max(0.0, (self.write_counter - self.read_counter) / self.ring_size) + def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None: # Resample input audio to output rate with speed adjustment resampled = self.resampler.process( @@ -250,6 +257,12 @@ def send(self, console: Console, audio: npt.NDArray[np.int16]) -> None: if self.audio_out is not None: self.audio_out.send(console, audio) + @property + def fill_fraction(self) -> float: + if self.audio_out is not None: + return self.audio_out.fill_fraction + return 0.0 + @contextmanager def audio_player( diff --git a/gambaterm/blessed_keyboard_input.py b/gambaterm/blessed_keyboard_input.py index 9574458..a863deb 100644 --- a/gambaterm/blessed_keyboard_input.py +++ b/gambaterm/blessed_keyboard_input.py @@ -70,7 +70,7 @@ def blessed_key_pressed_context( def _update() -> None: while True: - key = term.inkey(timeout=0) + key = term.inkey(timeout=0, capture_cpr=True) if not key: break if not key.released: diff --git a/gambaterm/colors.py b/gambaterm/colors.py index 7f7529a..c81e275 100644 --- a/gambaterm/colors.py +++ b/gambaterm/colors.py @@ -20,6 +20,13 @@ def cycle(self) -> ColorMode: value += 1 return ColorMode(value) + def cycle_back(self) -> ColorMode: + """Cycle to the previous color mode.""" + value = int(self) - 1 + if value <= int(ColorMode.COULD_NOT_DETECT): + value = int(ColorMode.HAS_24_BIT_COLOR) + return ColorMode(value) + def report(self) -> str: """Return a human-readable report of the color mode.""" if self == ColorMode.COULD_NOT_DETECT: diff --git a/gambaterm/console.py b/gambaterm/console.py index 1879bd4..3c4d1be 100644 --- a/gambaterm/console.py +++ b/gambaterm/console.py @@ -139,6 +139,7 @@ def __init__( self.gb = GB() self.force_gameboy = force_gameboy + self.last_video = np.zeros((self.HEIGHT, self.WIDTH), dtype=np.uint32) # Set save_directory if save_directory is not None: diff --git a/gambaterm/controller_input.py b/gambaterm/controller_input.py index 14b22fc..40eae51 100644 --- a/gambaterm/controller_input.py +++ b/gambaterm/controller_input.py @@ -51,13 +51,11 @@ def pygame_button_pressed_context( try: import pygame except ImportError: - raise SystemExit( - """\ + raise SystemExit("""\ The pygame library is not available, which is required to get controller support. Please use the following command to install gambaterm with controller support: pip3 install gambaterm[controller-support] -""" - ) +""") pygame.init() pygame.joystick.init() diff --git a/gambaterm/graphics_scaler.py b/gambaterm/graphics_scaler.py new file mode 100644 index 0000000..cf6e4d4 --- /dev/null +++ b/gambaterm/graphics_scaler.py @@ -0,0 +1,1005 @@ +"""Pixel scaler for sixel and kitty graphics protocols.""" + +from __future__ import annotations + +import atexit +import hashlib +import os +import time +import json +from typing import TYPE_CHECKING, Callable + +import numpy as np + +from .graphicsblit import ( + to_rgb, + to_rgba_u8, + encode_sixel, + encode_kitty_rgba, + quantize_colors, +) +from .remote_terminal import ( + GraphicsProtocol, + FORCE_KITTY_BLITLESS, + KITTY_GFX_CLEAR, + KITTY_GFX_GHOSTTY_CLEAR, + TEXT_HOME_CLEAR, + SIXEL_FORCE_REFRESH_ON_FOCUS, + XTERM_SIXEL_SCALE_CAP, + FORCE_KITTY_INDIVIDUAL_DELETES, +) + +if TYPE_CHECKING: + from blessed import Terminal + from .console import Console + +BASELINE_ID = 1 +DELTA_ID = 100 +# Fraction of changed pixels that triggers a full keyframe instead of a delta. +REBASELINE_THRESHOLD = float(os.environ.get("GAMBATERM_REBASELINE_THRESHOLD", "0.385")) +# Fraction of bounding-box area (vs total frame) that triggers a kitty keyframe, +# regardless of how few pixels changed -- catches sparse but widely-spread changes. +KITTY_REBASELINE_SPREAD = float(os.environ.get("GAMBATERM_KITTY_REBASELINE_SPREAD", "0.30")) + +SCALE_MAX = int(os.environ.get("GAMBATERM_SCALE_MAX", "10")) + +# Band layout table: (V, rows_per_band) per scale for kitty. +# Generated by tools/bandsize-estimate.py using the calibration frame +# (PCG64, seed=42). V=1 means the full frame fits in one band. +BAND_LAYOUT = { + 1: (1, 144), + 2: (2, 142), + 3: (2, 86), + 4: (2, 78), + 5: (2, 74), + 6: (3, 63), + 7: (3, 57), + 8: (3, 56), + 9: (4, 45), + 10: (4, 38), + 11: (4, 37), + 12: (5, 31), +} +MAX_FRAME_TRANSMISSION_SIZE = int( + os.environ.get("GAMBATERM_MAX_FRAME_TRANSMISSION_SIZE", str(128 * 1024)) +) + + +def blit_vis_channels(rgb: np.ndarray, mode: int) -> np.ndarray: + """Apply channel permutation for blit visualization.""" + if mode == 1: + return np.ascontiguousarray(rgb[..., [2, 1, 0]]) + if mode == 2: + return np.ascontiguousarray(rgb[..., [1, 0, 2]]) + return rgb + + +def _permute_rgba_channels(rgba: np.ndarray, mode: int) -> None: + """In-place RGBA channel permutation matching *blit_vis_channels*.""" + if mode == 1: + rgba[:, :, [0, 1, 2]] = rgba[:, :, [2, 1, 0]] + elif mode == 2: + rgba[:, :, [0, 1, 2]] = rgba[:, :, [1, 0, 2]] + + +class GraphicsScaler: + """Geometry-only: pixel scale, cell size, and positioning for terminal graphics.""" + + __slots__ = ( + "scale", + "kitty_scale", + "refx", + "refy", + "refx_kitty", + "refy_kitty", + "cell_h", + "cell_w", + ) + + def __init__( + self, + scale: int, + kitty_scale: int, + refx: int, + refy: int, + refx_kitty: int, + refy_kitty: int, + cell_h: int, + cell_w: int, + ) -> None: + self.scale = scale + self.kitty_scale = kitty_scale + self.refx = refx + self.refy = refy + self.refx_kitty = refx_kitty + self.refy_kitty = refy_kitty + self.cell_h = cell_h + self.cell_w = cell_w + + @property + def position(self) -> tuple[int, int]: + return self.refx_kitty, self.refy_kitty + + @classmethod + def recompute( + cls, + term: Terminal, + console: Console, + height: int, + width: int, + terminal_name: str = "", + ) -> GraphicsScaler: + """Query terminal pixel geometry and return a new GraphicsScaler.""" + pixel_h, pixel_w = term.get_sixel_height_and_width(force=True) + if pixel_h <= 0 or pixel_w <= 0: + return cls(1, 1, 1, 1, 1, 1, 1, 1) + natural_scale = max(1, min(pixel_w // console.WIDTH, pixel_h // console.HEIGHT)) + effective_cap = min(natural_scale, SCALE_MAX) + floor = max(1, natural_scale // 2) + graphics_scale = max(floor, effective_cap) + cell_h = max(1, pixel_h // height) + cell_w = max(1, pixel_w // width) + + sixel_scale = graphics_scale + if terminal_name.startswith(XTERM_SIXEL_SCALE_CAP): + sixel_scale = min(sixel_scale, 6) + if pixel_h - console.HEIGHT * sixel_scale < cell_h and sixel_scale > 1: + sixel_scale -= 1 + + while sixel_scale > 1: + img_h = console.HEIGHT * sixel_scale + rows = (img_h + cell_h - 1) // cell_h + refx = max(2, (pixel_h - img_h) // 2 // cell_h + 1) + if refx + rows <= height: + break + sixel_scale -= 1 + + kitty_scale = graphics_scale + + while kitty_scale > 1: + img_h = console.HEIGHT * kitty_scale + rows = (img_h + cell_h - 1) // cell_h + refx_candidate = max(2, (pixel_h - img_h) // 2 // cell_h + 1) + # Kitty scrolls when the image abuts the last row; reserve an + # extra margin row as a safety buffer (observed on 19-row terms). + if refx_candidate + rows + 1 <= height: + break + kitty_scale -= 1 + + def pos(img_h: int, img_w: int) -> tuple[int, int]: + rx = max(2, (pixel_h - img_h) // 2 // cell_h + 1) + ry = max(1, (pixel_w - img_w) // 2 // cell_w + 1) + return rx, ry + + refx, refy = pos(console.HEIGHT * sixel_scale, console.WIDTH * sixel_scale) + refx_kitty, refy_kitty = pos( + console.HEIGHT * kitty_scale, + console.WIDTH * kitty_scale, + ) + return cls( + sixel_scale, + kitty_scale, + refx, + refy, + refx_kitty, + refy_kitty, + cell_h, + cell_w, + ) + + +class FrameEncoder: + """Pixel-dimension-aware scaler for sixel and kitty frame output. + + Query happens once via ``recompute`` (call on resize / first frame). + Subsequent ``encode_sixel`` / ``encode_kitty`` calls use the cached scale + and position. + + Kitty frames use a baseline image with transparent delta updates + when only a fraction of the screen changes between frames. A + single-slot keyframe hash cache replays an identical keyframe via + ``a=p``, avoiding re-transmission of pixel data. + """ + + __slots__ = ( + "scale", + "kitty_scale", + "refx", + "refy", + "refx_kitty", + "refy_kitty", + "cell_h", + "cell_w", + "baseline", + "sixel_baseline", + "sixel_frame_no", + "_delta_is_placed", + "kitty_frame_no", + "dump_dir", + "stats_fh", + "blitter_vis", + "_keyframes", + "_deltas", + "_skipped", + "_bytes_out", + "_t0", + "_last_keyframe_hash", + "_cached_hits", + "_flash_pending", + "_frames_since_rebaseline", + "_force_rebaseline_every", + "force_transparent_offset", + "_frame_banding", + "_max_frame_transmission_size", + "_V", + "_v_current", + "_v_row_height", + ) + + def __init__( + self, + scale: int, + kitty_scale: int, + refx: int, + refy: int, + refx_kitty: int, + refy_kitty: int, + cell_h: int, + cell_w: int, + force_transparent_offset: bool = False, + frame_banding: bool = False, + ) -> None: + self.scale = scale + self.kitty_scale = kitty_scale + self.refx = refx + self.refy = refy + self.refx_kitty = refx_kitty + self.refy_kitty = refy_kitty + self.cell_h = cell_h + self.cell_w = cell_w + self.force_transparent_offset = force_transparent_offset + self._frame_banding = frame_banding + self._max_frame_transmission_size = MAX_FRAME_TRANSMISSION_SIZE + self._V = 0 + self._v_current = 0 + self._v_row_height = 144 + self.baseline: np.ndarray | None = None + self.sixel_baseline: np.ndarray | None = None + self._delta_is_placed = False + self.kitty_frame_no = 0 + self.sixel_frame_no = 0 + self.blitter_vis = 0 + self._keyframes = 0 + self._deltas = 0 + self._skipped = 0 + self._bytes_out = 0 + self._t0 = time.monotonic() + self._last_keyframe_hash: bytes | None = None + self._cached_hits = 0 + self._flash_pending = False + self._frames_since_rebaseline = 0 + self._force_rebaseline_every = int(os.environ.get( + "GAMBATERM_FORCE_REBASELINE", "0" + )) + self.dump_dir = os.environ.get("GAMBATERM_DUMP_FRAMES") + if self.dump_dir is not None: + os.makedirs(self.dump_dir, exist_ok=True) + csv_path = os.environ.get("GAMBATERM_PROFILE") + if csv_path is None: + self.stats_fh = open(os.devnull, "w") + else: + os.makedirs(os.path.dirname(csv_path) or ".", exist_ok=True) + self.stats_fh = open(csv_path, "w") + self.stats_fh.write( + "frame_no,changed_pct,action,bytes,time_us," + "row,col,cell_h,cell_w,padded_w,padded_h," + "x1,y1,rect_w,rect_h," + "refx_kitty,refy_kitty,scale\n" + ) + stem, _ = os.path.splitext(csv_path) + atexit.register(self._dump_profile, f"{stem}-summary.txt") + + def _dump_profile(self, path: str) -> None: + elapsed = time.monotonic() - self._t0 + if elapsed <= 0: + return + with open(path, "w") as f: + f.write( + f"elapsed_s={elapsed:.1f}\n" + f"keyframes={self._keyframes}\n" + f"deltas={self._deltas}\n" + f"skipped={self._skipped}\n" + f"cache_hits={self._cached_hits}\n" + f"total_bytes={self._bytes_out}\n" + f"KB_s={self._bytes_out / elapsed / 1000:.0f}\n" + ) + + @property + def position(self) -> tuple[int, int]: + return self.refx_kitty, self.refy_kitty + + def dump_frame(self, video: np.ndarray, n: int, **extra: object) -> None: + """Write frame NPY and scaler metadata when GAMBATERM_DUMP_FRAMES is set.""" + if self.dump_dir is None: + return + np.save(f"{self.dump_dir}/{n:05d}.npy", video, allow_pickle=False) + scaler_json = f"{self.dump_dir}/scaler.json" + if not os.path.exists(scaler_json): + with open(scaler_json, "w") as jf: + json.dump(extra, jf) + + def _log_frame( + self, + n: int, + pct_str: str, + action: str, + result_len: int, + elapsed_us: int, + extra: str = "", + ) -> None: + if self.stats_fh is None: + return + self.stats_fh.write( + f"{n},{pct_str},{action},{result_len},{elapsed_us},{extra}\n" + ) + self.stats_fh.flush() + + def close(self) -> None: + """Close the stats file handle if it is not /dev/null.""" + if self.stats_fh is not None and self.stats_fh.name != os.devnull: + self.stats_fh.close() + self.stats_fh = None # type: ignore[assignment] + + def _upscale_rgba(self, video: np.ndarray) -> np.ndarray: + """Convert *video* (uint32) to upscaled RGBA uint8 array.""" + rgba = to_rgba_u8(video) + if self.kitty_scale > 1: + rgba = np.repeat( + np.repeat(rgba, self.kitty_scale, axis=0), self.kitty_scale, axis=1 + ) + return rgba + + def _encode_sixel_keyframe( + self, video: np.ndarray, t0: float, n: int, pct_str: str, action: str, + ) -> bytes: + self._keyframes += 1 + self.sixel_baseline = video.copy() + colors = to_rgb(video) + if self.blitter_vis: + colors[:, :, :] = blit_vis_channels(colors, self.blitter_vis) + self._flash_pending = not self._flash_pending + result = encode_sixel(colors, max_colors=256, scale=self.scale) + self._bytes_out += len(result) + elapsed_us = int((time.perf_counter() - t0) * 1e6) + self._log_frame( + n, pct_str, action, len(result), elapsed_us, + f"{self.refx},{self.refy},,,,,", + ) + return self._sixel_prefix + result + + @property + def _sixel_prefix(self) -> bytes: + return f"\033[{self.refx};{self.refy}H\033[0m".encode() + + def encode_sixel( + self, + video: np.ndarray, + last_frame: np.ndarray | None, + ) -> bytes: + """Encode ``video`` as a sixel escape sequence. + + Uses a baseline frame with overlay delta updates for bandwidth + savings, falling back to a full keyframe when more than + ``REBASELINE_THRESHOLD`` of pixels change. + + Returns empty bytes if the frame is unchanged from *last_frame*. + """ + if ( + last_frame is not None + and video.shape == last_frame.shape + and np.array_equal(video, last_frame) + ): + self.sixel_frame_no += 1 + self._skipped += 1 + self._log_frame(self.sixel_frame_no, "0.0", "skip", 0, 0) + return b"" + + self.sixel_frame_no += 1 + n = self.sixel_frame_no + t0 = time.perf_counter() + + self.dump_frame( + video, + n, + cell_h=self.cell_h, + cell_w=self.cell_w, + scale=self.scale, + refx=self.refx, + refy=self.refy, + ) + + if self.sixel_baseline is None: + return self._encode_sixel_keyframe(video, t0, n, "0.0", "first_keyframe") + + total_pixels = video.size + diff = video != self.sixel_baseline + changed = diff.sum() + changed_pct = changed / total_pixels + + if self._flash_pending or changed_pct > REBASELINE_THRESHOLD: + return self._encode_sixel_keyframe( + video, t0, n, f"{changed_pct:.2f}", "rebaseline", + ) + + self._deltas += 1 + + # Quantize only changed pixels for the overlay delta; unchanged + # pixels are transparent (skip_index=255) and already on screen + # from the prior frame via P2=1 mode. + changed_pixels = video[diff].reshape(-1, 1) + changed_colors = to_rgb(changed_pixels) + max_colors = min(256, changed_colors.shape[0]) + indices_delta, palette = quantize_colors(changed_colors, max_colors) + palette = np.asarray(palette) + palette = blit_vis_channels(palette, self.blitter_vis) + indices = np.full(video.shape, 255, dtype=np.uint8) + indices[diff] = np.asarray(indices_delta).ravel() + + h, w = video.shape + result = encode_sixel( + np.empty((0, 0, 3), dtype=np.float32), # unused when h/w given + max_colors=256, + scale=self.scale, + indices=indices, + palette=palette, + skip_index=255, + h=h * self.scale, + w=w * self.scale, + ) + # Baseline updated every overlay: sixel P2=1 transparency requires + # frame-to-frame diffs so unchanged pixels aren't double-rendered. + self.sixel_baseline = video.copy() + self._bytes_out += len(result) + elapsed_us = int((time.perf_counter() - t0) * 1e6) + + self._log_frame( + n, f"{changed_pct:.2f}", "overlay_delta", len(result), elapsed_us, + f"{self.refx},{self.refy},,,,,", + ) + + return self._sixel_prefix + result + + def encode_sixel_full( + self, + video: np.ndarray, + ) -> bytes: + """Encode ``video`` as a full opaque sixel keyframe. + + No baseline tracking, no delta encoding, no P2=1 transparency. + Every frame is a standalone keyframe. Used for Contour which + does not support sixel blitting. + """ + self.sixel_frame_no += 1 + t0 = time.perf_counter() + colors = to_rgb(video) + result = encode_sixel(colors, max_colors=256, scale=self.scale) + self._keyframes += 1 + self._bytes_out += len(result) + elapsed_us = int((time.perf_counter() - t0) * 1e6) + self._log_frame( + self.sixel_frame_no, "0.0", "blitless_keyframe", len(result), elapsed_us, + f"{self.refx},{self.refy},,,,,", + ) + return self._sixel_prefix + result + + def encode_kitty_frame( + self, + video: np.ndarray, + encode_fn: Callable[..., bytes], + ) -> bytes: + """Encode kitty frame with single-slot keyframe cache. + + Deltas send the bounding box of changed pixels positioned via + cell-snapped cursor move, using p=1 placement replacement. + Rebaselines when either pixel-change count or bounding-box area + exceeds thresholds. Keyframes are compared against the prior + cached keyframe hash; a match replays via ``a=p``. + """ + t0 = time.perf_counter() + self.kitty_frame_no += 1 + n = self.kitty_frame_no + total_pixels = video.size + + self.dump_frame( + video, + n, + cell_h=self.cell_h, + cell_w=self.cell_w, + kitty_scale=self.kitty_scale, + refx_kitty=self.refx_kitty, + refy_kitty=self.refy_kitty, + ) + + if self.baseline is None: + self._keyframes += 1 + self.baseline = video.copy() + self._last_keyframe_hash = hashlib.md5(video.data).digest() + rgba = self._upscale_rgba(video) + h, w = rgba.shape[:2] + if self.blitter_vis: + _permute_rgba_channels(rgba, self.blitter_vis) + self._flash_pending = not self._flash_pending + parts: list[bytes] = [ + f"\033[{self.refx_kitty};{self.refy_kitty}H".encode(), + encode_fn(rgba.ravel(), w, h, image_id=BASELINE_ID, placement_id=1), + ] + self._delta_is_placed = False + result = b"".join(parts) + elapsed_us = int((time.perf_counter() - t0) * 1e6) + self._log_frame( + n, "0.0", "first_keyframe", len(result), elapsed_us, + ",,,,,,,,,,,,,", + ) + return result + + diff = video != self.baseline + changed = diff.sum() + changed_pct = changed / total_pixels + + rows = diff.any(axis=1) + cols = diff.any(axis=0) + y1 = int(rows.argmax()) + y2 = int(rows.shape[0] - rows[::-1].argmax()) + x1 = int(cols.argmax()) + x2 = int(cols.shape[0] - cols[::-1].argmax()) + rect_w = x2 - x1 + rect_h = y2 - y1 + + self._frames_since_rebaseline += 1 + force = ( + self._flash_pending + or ( + self._force_rebaseline_every > 0 + and self._frames_since_rebaseline >= self._force_rebaseline_every + ) + ) + if ( + force + or changed_pct > REBASELINE_THRESHOLD + or rect_w * rect_h > total_pixels * KITTY_REBASELINE_SPREAD + ): + self._keyframes += 1 + self._frames_since_rebaseline = 0 + self.baseline = video.copy() + + khash = hashlib.md5(video.data).digest() + if self._last_keyframe_hash == khash: + self._cached_hits += 1 + self._delta_is_placed = False + result = ( + b"\033_Ga=d,d=a,q=1\033\\" + + f"\033[{self.refx_kitty};{self.refy_kitty}H".encode() + + f"\033_Ga=p,i={BASELINE_ID},q=1\033\\".encode() + ) + elapsed_us = int((time.perf_counter() - t0) * 1e6) + self._log_frame( + n, f"{changed_pct:.2f}", "cache_hit_rebaseline", + len(result), elapsed_us, + ",,,,,,,,,,,,,", + ) + return result + self._last_keyframe_hash = khash + + rgba = self._upscale_rgba(video) + h, w = rgba.shape[:2] + if self.blitter_vis: + _permute_rgba_channels(rgba, self.blitter_vis) + self._flash_pending = not self._flash_pending + prefix = b"" + if self._delta_is_placed: + prefix = f"\033_Ga=d,d=i,i={DELTA_ID}\033\\".encode() + rebase_parts: list[bytes] = [ + prefix, + f"\033[{self.refx_kitty};{self.refy_kitty}H".encode(), + encode_fn(rgba.ravel(), w, h, image_id=BASELINE_ID, placement_id=1), + ] + self._delta_is_placed = False + result = b"".join(rebase_parts) + elapsed_us = int((time.perf_counter() - t0) * 1e6) + self._log_frame( + n, f"{changed_pct:.2f}", "rebaseline", + len(result), elapsed_us, + ",,,,,,,,,,,,,", + ) + return result + + self._deltas += 1 + + rect_video = np.ascontiguousarray(video[y1:y2, x1:x2]) + rect_rgba = to_rgba_u8(rect_video) + if self.kitty_scale > 1: + rect_rgba = np.repeat( + np.repeat(rect_rgba, self.kitty_scale, axis=0), + self.kitty_scale, + axis=1, + ) + scaled_w = rect_w * self.kitty_scale + scaled_h = rect_h * self.kitty_scale + + px = x1 * self.kitty_scale + py = y1 * self.kitty_scale + row = self.refx_kitty + py // self.cell_h + col = self.refy_kitty + px // self.cell_w + off_y = py % self.cell_h + off_x = px % self.cell_w + + if self.blitter_vis: + mask = rect_rgba[:, :, 3] != 0 + rect_rgba[mask, :3] = blit_vis_channels(rect_rgba[mask, :3], self.blitter_vis) + + # Workaround for Kitty 0.47.4(?) and earlier, https://github.com/kovidgoyal/kitty/pull/10256 + # Padd "blit" images using transparent pixels and anchor to (0, 0) setting X=0,Y=0 to avoid + # the bug of drawing an image at an offset until fixed + if self.force_transparent_offset and (off_y or off_x): + padded = np.zeros( + (scaled_h + off_y, scaled_w + off_x, 4), dtype=np.uint8 + ) + padded[off_y:off_y + scaled_h, off_x:off_x + scaled_w] = rect_rgba + rect_rgba = padded + scaled_w += off_x + scaled_h += off_y + off_x = 0 + off_y = 0 + + delete_old = ( + f"\033_Ga=d,d=i,i={DELTA_ID}\033\\".encode() + if self._delta_is_placed + else b"" + ) + result_parts: list[bytes] = [ + delete_old, + f"\033[{row};{col}H".encode(), + encode_fn( + rect_rgba.ravel(), + scaled_w, + scaled_h, + image_id=DELTA_ID, + X=off_x, + Y=off_y, + placement_id=1, + ), + ] + self._delta_is_placed = True + result = b"".join(result_parts) + elapsed_us = int((time.perf_counter() - t0) * 1e6) + self._log_frame( + n, f"{changed_pct:.2f}", "dirty_rect", + len(result), elapsed_us, + f"{row},{col},{self.cell_h},{self.cell_w},{scaled_w},{scaled_h}," + f"{x1},{y1},{rect_w},{rect_h}," + f"{self.refx_kitty},{self.refy_kitty},{self.kitty_scale}", + ) + return result + + def encode_kitty( + self, + video: np.ndarray, + last_frame: np.ndarray | None, + ) -> bytes: + """Encode ``video`` as a kitty RGBA escape sequence. + + Uses a baseline image with dirty-rect delta updates via p=1 + placement replacement. Returns empty bytes when unchanged. + """ + if self.is_banding: + return self._encode_kitty_band(video, last_frame) + + if ( + last_frame is not None + and video.shape == last_frame.shape + and np.array_equal(video, last_frame) + ): + self.kitty_frame_no += 1 + self._skipped += 1 + self._log_frame(self.kitty_frame_no, "0.0", "skip", 0, 0) + return b"" + result = self.encode_kitty_frame(video, encode_kitty_rgba) + self._bytes_out += len(result) + return result + + def _ensure_band_layout(self) -> None: + """Populate ``_V`` and ``_v_row_height`` from the band layout table.""" + entry = BAND_LAYOUT.get(self.kitty_scale) + if entry is None: + self._V = 1 + self._v_row_height = 144 + else: + self._V, self._v_row_height = entry + self._v_current = 0 + + @property + def is_banding(self) -> bool: + return self._frame_banding and self._V > 1 + + def _encode_kitty_band( + self, + video: np.ndarray, + last_frame: np.ndarray | None, + ) -> bytes: + """Encode one band of a kitty frame cycle. + + Each band is placed at its own cell-snapped cursor position with + sub-cell X/Y offsets for pixel-precise alignment, matching the + existing dirty-rect delta positioning strategy. + """ + t0 = time.perf_counter() + self.kitty_frame_no += 1 + n = self.kitty_frame_no + + y0 = self._v_current * self._v_row_height + y1 = min(y0 + self._v_row_height, 144) + + band_video = video[y0:y1, :] + # When _last_rendered is the zero sentinel, never skip. + if last_frame is not None and last_frame.shape == video.shape and np.any(last_frame): + if np.array_equal(band_video, last_frame[y0:y1, :]): + self._v_current = (self._v_current + 1) % self._V + self._skipped += 1 + self._log_frame(n, "0.0", "band_skip", 0, 0, + f"v={self._v_current}") + return b"" + + rgba = to_rgba_u8(band_video) + if self.kitty_scale > 1: + rgba = np.repeat( + np.repeat(rgba, self.kitty_scale, axis=0), + self.kitty_scale, axis=1, + ) + band_h = rgba.shape[0] + band_w = rgba.shape[1] + + # Cell-snapped cursor position. + py = y0 * self.kitty_scale + px = 0 + row = self.refx_kitty + py // self.cell_h + col = self.refy_kitty + px // self.cell_w + off_y = py % self.cell_h + off_x = px % self.cell_w + + if self.blitter_vis: + _permute_rgba_channels(rgba, self.blitter_vis) + self._flash_pending = not self._flash_pending + + image_id = self._v_current + 1 + + # Kitty pixel-offset scroll bug workaround. + if self.force_transparent_offset and (off_y or off_x): + padded = np.zeros( + (band_h + off_y, band_w + off_x, 4), dtype=np.uint8, + ) + padded[off_y:off_y + band_h, off_x:off_x + band_w] = rgba + rgba = padded + band_w += off_x + band_h += off_y + off_x = 0 + off_y = 0 + + # Delete old placement of this band before re-placing. + prefix = f"\033_Ga=d,d=i,i={image_id}\033\\".encode() + result = ( + prefix + + f"\033[{row};{col}H".encode() + + encode_kitty_rgba( + rgba.ravel(), band_w, band_h, + image_id=image_id, X=off_x, Y=off_y, + ) + ) + self._keyframes += 1 + self._bytes_out += len(result) + self._v_current = (self._v_current + 1) % self._V + elapsed_us = int((time.perf_counter() - t0) * 1e6) + self._log_frame( + n, "0.0", "band", len(result), elapsed_us, + f"{row},{col},{self.cell_h},{self.cell_w},,," + f",,{y0},{y1 - y0}," + f"{self.refx_kitty},{self.refy_kitty},{self.kitty_scale}", + ) + return result + + +class GraphicsRenderer: + """Owns runtime graphics state: scaler lifecycle, frame encoding. + + Instantiated once per ``run()`` session. ``on_state_change`` and + ``render`` are called from the main loop. + """ + + def __init__( + self, + term: Terminal, + console: Console, + terminal_name: str, + protocol: GraphicsProtocol, + force_transparent_offset: bool = False, + frame_banding: bool = False, + ) -> None: + self._term = term + self._console = console + self._terminal_name = terminal_name + self._force_transparent_offset = force_transparent_offset + self._frame_banding = frame_banding + + self.scaler: GraphicsScaler | None = None + self.encoder: FrameEncoder | None = None + self._blitter_vis = 0 + self._force_clear = False + self._force_keyframe = False + self._ghostty_kitty_gfx_clear = False + self._last_rendered: np.ndarray | None = None + + def _make_encoder(self) -> None: + assert self.scaler is not None + self.encoder = FrameEncoder( + self.scaler.scale, + self.scaler.kitty_scale, + self.scaler.refx, + self.scaler.refy, + self.scaler.refx_kitty, + self.scaler.refy_kitty, + self.scaler.cell_h, + self.scaler.cell_w, + force_transparent_offset=self._force_transparent_offset, + frame_banding=self._frame_banding, + ) + self.encoder.blitter_vis = self._blitter_vis + + @property + def blitter_vis(self) -> int: + return self._blitter_vis + + @blitter_vis.setter + def blitter_vis(self, vis: int) -> None: + self._blitter_vis = vis + if self.encoder is not None: + self.encoder.blitter_vis = vis + + @property + def scale(self) -> int | None: + if self.scaler is None: + return None + return self.scaler.scale + + @property + def kitty_scale(self) -> int | None: + if self.scaler is None: + return None + return self.scaler.kitty_scale + + def request_keyframe(self) -> None: + self._force_keyframe = True + + @property + def is_banding(self) -> bool: + return ( + self._frame_banding + and self.encoder is not None + and self.encoder.is_banding + ) + + def on_focus_change(self, protocol: GraphicsProtocol) -> None: + """Force a keyframe when terminal regains focus. + + mlterm and xterm drop sixel content on focus loss; a keyframe + redraws the full image without relying on stale baseline data. + """ + if ( + protocol is GraphicsProtocol.SIXEL + and self._terminal_name.startswith(SIXEL_FORCE_REFRESH_ON_FOCUS) + ): + self.request_keyframe() + + def on_state_change( + self, + old_protocol: GraphicsProtocol, + new_protocol: GraphicsProtocol, + old_color: object, + new_color: object, + resized: bool, + ) -> bytes: + """Handle protocol/color/resize change. + + Tears down the current scaler and returns the clear/delete escape + sequence to emit before the next frame. + """ + # Scaler teardown + if self.encoder is not None: + self.encoder.close() + self.scaler = None + self.encoder = None + self._last_rendered = None + + # Clear / delete sequence + clear = b"" + if ( + new_protocol != old_protocol + or new_color != old_color + or resized + ): + clear = TEXT_HOME_CLEAR + if GraphicsProtocol.KITTY in (old_protocol, new_protocol): + clear += KITTY_GFX_CLEAR + if self._terminal_name.startswith(FORCE_KITTY_INDIVIDUAL_DELETES): + clear += KITTY_GFX_GHOSTTY_CLEAR + return clear + + def render( + self, + video: np.ndarray, + last_frame: np.ndarray | None, + protocol: GraphicsProtocol, + height: int, + width: int, + ) -> bytes: + prefix, suffix = b"", b"" + + if self._ghostty_kitty_gfx_clear and protocol is GraphicsProtocol.KITTY: + clear_cmd = KITTY_GFX_GHOSTTY_CLEAR + prefix = b"\033[?2026h" + clear_cmd + suffix = b"\033[?2026l" + self._ghostty_kitty_gfx_clear = False + + if self._force_keyframe: + if self.encoder is not None: + self.encoder.close() + self.scaler = None + self.encoder = None + self._force_keyframe = False + + if self.scaler is None: + self.scaler = GraphicsScaler.recompute( + self._term, + self._console, + height, + width, + terminal_name=self._terminal_name, + ) + self._make_encoder() + if self._frame_banding and self.encoder is not None: + self.encoder._ensure_band_layout() + + # Use the last rendered frame as the comparison reference. + # Sentinel zeros on first call so every band renders initially. + if self._last_rendered is None: + self._last_rendered = np.zeros_like(video) + last_frame = self._last_rendered + + if self._force_clear: + if protocol in (GraphicsProtocol.SIXEL, GraphicsProtocol.BLITLESS_SIXEL): + prefix = b"\033[H\033[2J" + prefix + elif protocol is GraphicsProtocol.KITTY: + prefix = KITTY_GFX_CLEAR + prefix + self._force_clear = False + + if protocol is GraphicsProtocol.SIXEL: + result = self.encoder.encode_sixel(video, last_frame) + elif protocol is GraphicsProtocol.BLITLESS_SIXEL: + result = self.encoder.encode_sixel_full(video) + else: + v_before = self.encoder._v_current + result = self.encoder.encode_kitty(video, last_frame) + if self._terminal_name.startswith(FORCE_KITTY_BLITLESS): + self.encoder.baseline = None + if self.encoder._v_current != v_before and result: + y0 = v_before * self.encoder._v_row_height + y1 = min(y0 + self.encoder._v_row_height, video.shape[0]) + self._last_rendered[y0:y1, :] = video[y0:y1, :] + + if not result: + if prefix or suffix: + return prefix + suffix + return b"" + return prefix + result + suffix + + def close(self) -> None: + if self.encoder is not None: + self.encoder.close() diff --git a/gambaterm/input_getter.py b/gambaterm/input_getter.py index 787057c..5d42b8f 100644 --- a/gambaterm/input_getter.py +++ b/gambaterm/input_getter.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from blessed.keyboard import Keystroke from blessed.terminal import Terminal @@ -5,7 +7,13 @@ def pop_keystrokes_from_terminal(terminal: Terminal) -> list[Keystroke]: - return list(iter(lambda: terminal.inkey(timeout=0), "")) + result: list[Keystroke] = [] + while True: + key = terminal.inkey(timeout=0, capture_cpr=True) + if not key: + break + result.append(key) + return result class BaseInputGetter: diff --git a/gambaterm/keyboard_input.py b/gambaterm/keyboard_input.py index 0fb1857..0f55b94 100755 --- a/gambaterm/keyboard_input.py +++ b/gambaterm/keyboard_input.py @@ -23,6 +23,7 @@ - kitty https://sw.kovidgoyal.net/kitty/ - ghostty https://ghostty.org/ +- alacritty https://alacritty.org/ - foot https://codeberg.org/dnkl/foot - iTerm2 https://iterm2.com/ - Rio https://rioterm.com/ @@ -128,29 +129,27 @@ def pop_keystrokes(self) -> list[Keystroke]: return self._pop_keystrokes() -def is_kitty_keyboard_protocol_supported( - term: Terminal, timeout: float | None = None -) -> bool: +def is_kitty_keyboard_protocol_supported(term: Terminal) -> bool: """Check if the terminal supports the kitty keyboard protocol.""" - # Some terminals (eg. last release of Contour) responds to the kitty keyboard query but ignores - # the modes that we set, it is not fully implementing them. And so we verify that report_events - # is actually enabled after requesting it. - # - # Other terminals (eg. last release of Alacritty), *do* support the kitty keyboard protocol - # flags that set, but fail to accurately report their state! - # https://github.com/alacritty/alacritty/pull/8953 -- it's too bad alacritty also doesn't - # support XTVERSION either, or we could conditionally return True for a version range! - # - # In a sense, we tradeoff: "ensure contour is not wrongly detected" (report_events not - # implemented) for Alacritty is wrongly detected as missing support for kitty (report_events not - # reported due to bug). I hope that Alacritty will accept the reported bug and next release will - # be OK. - state = term.get_kitty_keyboard_state(timeout=timeout) + # Some terminals (last release of Contour) responds to the kitty keyboard query but ignores + # the modes that we set, because it is not fully implementing them, so we set and check state + # manually to detect this condition, that Contour does *not* report kitty keyboard for the + # mode required by gambaterm. + + state = term.get_kitty_keyboard_state() if state is None: return False - with term.enable_kitty_keyboard(report_events=True, timeout=timeout): - active = term.get_kitty_keyboard_state(timeout=timeout) - return active is not None and active.report_events + with term.enable_kitty_keyboard(report_events=True): + active = term.get_kitty_keyboard_state() + if active is not None and active.report_events: + return True + # Other terminals (last release of Alacritty), *do* support the kitty keyboard protocol flags + # when they are set, but fails to accurately report state, the bugfix was not accepted and so we + # manually work around it, https://github.com/alacritty/alacritty/pull/8953. + sv = term.get_software_version() + if sv is not None and sv.name == "alacritty": + return True + return False @contextmanager diff --git a/gambaterm/libgambatte.pyi b/gambaterm/libgambatte.pyi index acca99a..0cc08a6 100644 --- a/gambaterm/libgambatte.pyi +++ b/gambaterm/libgambatte.pyi @@ -10,6 +10,7 @@ class GB: SGB_MODE = ... READONLY_SAV = ... NO_BIOS = ... + def load(self, rom_file: str, flags: int = 0) -> int: ... def run_for( self, diff --git a/gambaterm/main.py b/gambaterm/main.py index 75d1f7d..184326f 100644 --- a/gambaterm/main.py +++ b/gambaterm/main.py @@ -2,6 +2,7 @@ from __future__ import annotations import time +import os import argparse from pathlib import Path from typing import ContextManager, TYPE_CHECKING @@ -14,6 +15,12 @@ from .console import GameboyColor, Console from .audio import audio_player from .colors import detect_local_color_mode, ColorMode +from .remote_terminal import ( + GraphicsProtocol, + FORCE_SIXEL_BLITLESS, + BAD_TEXT, + does_sixel, +) from .input_getter import BaseInputGetter from .keyboard_input import console_input_from_keyboard_context from .controller_input import combine_console_input_from_controller_context @@ -22,6 +29,7 @@ # `typing.Self` is not available in python 3.10 if TYPE_CHECKING: from typing import Self + from blessed.terminal import SoftwareVersion @dataclass @@ -33,7 +41,11 @@ class AppConfig: break_after: int | None speed: float skip_inputs: int - cpr_sync: bool + cpr_sync: int + graphics_protocol: GraphicsProtocol = GraphicsProtocol.TEXT + available_graphics: list[GraphicsProtocol] = field( + default_factory=lambda: [GraphicsProtocol.TEXT] + ) save_directory: Path | None = None console_namespace: argparse.Namespace = field(default_factory=argparse.Namespace) @@ -54,6 +66,14 @@ def from_namespace(cls, namespace: argparse.Namespace) -> Self: class LocalAppConfig(AppConfig): enable_controller: bool = False write_input: Path | None = None + load_state: int | None = None + + +@dataclass +class TerminalInfo: + terminal: Terminal + name: str + force_transparent_offset: bool def add_base_arguments(parser: argparse.ArgumentParser) -> None: @@ -70,7 +90,7 @@ def add_input_file_arguments(parser: argparse.ArgumentParser) -> None: type=int, default=188, help="Number of frame inputs to skip in order to compensate " - "for the lack of BIOS (default is 188)", + "for the lack of BIOS", ) @@ -90,7 +110,7 @@ def add_tuning_arguments(parser: argparse.ArgumentParser) -> None: "--fa", type=int, default=1, - help="Number of frames to run before displaying the next one (default is 1)", + help="Number of frames to run before displaying the next one", ) parser.add_argument( "--break-after", @@ -105,13 +125,24 @@ def add_tuning_arguments(parser: argparse.ArgumentParser) -> None: "-s", type=float, default=1.0, - help="Control the execution speed (default is 1.0)", + help="Control the execution speed", ) parser.add_argument( "--cpr-sync", "--cs", - action="store_true", - help="Use CPR synchronization to prevent video buffering", + nargs="?", + const=1, + type=int, + default=0, + help="CPR sync window: 0=off, N=allow N unacknowledged frames " + "(default: 1 if flag given without value)", + ) + parser.add_argument( + "--graphics", + choices=["text", "sixel", "kitty", "auto"], + default="auto", + help="Graphics rendering mode " + "(kitty, sixel, text, or auto-detect)", ) @@ -131,13 +162,114 @@ def add_local_only_arguments(parser: argparse.ArgumentParser) -> None: type=Path, help="Record inputs into a file", ) + parser.add_argument( + "--status", + action="store_true", + help="Enable the status bar (toggle with backtick/~ key)", + ) + parser.add_argument( + "--blit-visualizer", + action="store_true", + help="Enable blit visualizer (toggle with F12 key)", + ) + parser.add_argument( + "--frame-banding", + action="store_true", + default=os.environ.get("GAMBATERM_FRAME_BANDING", "").lower() + in ("1", "true", "yes"), + help="Split large kitty frames into bands to reduce audio drops " + "(env: GAMBATERM_FRAME_BANDING)", + ) + parser.add_argument( + "--load-state", + "--ls", + type=int, + default=None, + help="Load save state N at startup (e.g. --load-state 8 loads ROM_8.gqs)", + ) parser.add_argument( "--save-directory", "--sd", type=Path, default=None, - help="Path to the save directory (default to the ROM directory)", + help="Save directory path (defaults to the ROM directory when unspecified)", + ) + + +def detect_graphics_local( + terminal: Terminal, + sv: "SoftwareVersion | None" = None, +) -> tuple[GraphicsProtocol, list[GraphicsProtocol]]: + """Detect available graphics protocols on a local terminal. + + Returns (selected, available) where selected is the preferred protocol + and available lists all supported protocols. + """ + if os.environ.get("GAMBATERM_FORCE_SIXEL"): + return GraphicsProtocol.SIXEL, [GraphicsProtocol.TEXT, GraphicsProtocol.SIXEL] + if os.environ.get("GAMBATERM_FORCE_KITTY"): + return GraphicsProtocol.KITTY, [GraphicsProtocol.TEXT, GraphicsProtocol.KITTY] + + available = [GraphicsProtocol.TEXT] + if sv is None: + sv = terminal.get_software_version() + blitless = sv is not None and sv.name.lower() in FORCE_SIXEL_BLITLESS + if blitless: + available.append(GraphicsProtocol.BLITLESS_SIXEL) + return GraphicsProtocol.BLITLESS_SIXEL, available + if does_sixel(terminal, sv=sv): + available.append(GraphicsProtocol.SIXEL) + if terminal.does_kitty_graphics(): + available.append(GraphicsProtocol.KITTY) + # Prefer sixel, then kitty, then text + if GraphicsProtocol.SIXEL in available: + return GraphicsProtocol.SIXEL, available + return available[-1], available + + +def resolve_terminal_and_graphics( + graphics_value: str, + console: Console, +) -> tuple[TerminalInfo, GraphicsProtocol, list[GraphicsProtocol]]: + """Set up terminal, detect capabilities, and resolve the graphics protocol. + + Returns (terminal_info, protocol, available_protocols). + """ + terminal = Terminal() + sv = terminal.get_software_version() + terminal_name = sv.name.lower() if sv is not None else "" + force_transparent_offset = ( + sv is not None + and sv.name == "kitty" + and tuple(int(x) for x in sv.version.split(".")) <= (0, 47, 4) ) + tinfo = TerminalInfo(terminal, terminal_name, force_transparent_offset) + + available_graphics: list[GraphicsProtocol] = [GraphicsProtocol.TEXT] + if graphics_value == "auto": + protocol, available_graphics = detect_graphics_local(terminal, sv=sv) + elif graphics_value != "text": + protocol = GraphicsProtocol[graphics_value.upper()] + available_graphics = [GraphicsProtocol.TEXT, protocol] + else: + protocol = GraphicsProtocol.TEXT + + # Unknown terminals may have broken sixel transparency; force blitless. + if not terminal_name and protocol is GraphicsProtocol.SIXEL: + protocol = GraphicsProtocol.BLITLESS_SIXEL + + # Prefer text mode when it fits the terminal, unless the terminal has + # poor unicode rendering (Rio, mlterm). + term_height = terminal.height or 24 + term_width = terminal.width or 80 + if ( + term_width >= console.WIDTH + and term_height >= console.HEIGHT // 2 + and not terminal_name.startswith(BAD_TEXT) + ): + protocol = GraphicsProtocol.TEXT + + return tinfo, protocol, available_graphics def main( @@ -146,7 +278,9 @@ def main( ) -> None: # Create parser parser = argparse.ArgumentParser( - prog="gambaterm", description="Gambatte terminal front-end" + prog="gambaterm", + description="Gambatte terminal front-end", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) add_base_arguments(parser) add_input_file_arguments(parser) @@ -157,24 +291,35 @@ def main( # Parse arguments namespace = parser.parse_args(parser_args) disable_audio = getattr(namespace, "disable_audio", False) + show_status = namespace.__dict__.pop("status") + blit_visualizer = namespace.__dict__.pop("blit_visualizer") + frame_banding = namespace.__dict__.pop("frame_banding") + graphics_value: str = namespace.__dict__.pop("graphics") args = LocalAppConfig.from_namespace(namespace) # Check that the ROM file exists if not args.romfile.exists(): raise SystemExit(f"ROM file `{args.romfile}` does not exist") - # Instantiate the console and terminal + # Instantiate the console console = console_cls.from_app_config(args) - terminal = Terminal() + if args.load_state is not None: + console.gb.select_state(args.load_state % 10) + console.gb.load_state() + + # Detect terminal and resolve graphics protocol + tinfo, args.graphics_protocol, available_graphics = ( + resolve_terminal_and_graphics(graphics_value, console) + ) # Prepare input context input_context: ContextManager[BaseInputGetter] if args.input_file is not None: input_context = console_input_from_file_context( - console, terminal, args.input_file, args.skip_inputs + console, tinfo.terminal, args.input_file, args.skip_inputs ) else: - input_context = console_input_from_keyboard_context(console, terminal) + input_context = console_input_from_keyboard_context(console, tinfo.terminal) if args.enable_controller: input_context = combine_console_input_from_controller_context(input_context) @@ -187,36 +332,44 @@ def main( ) # Enter terminal raw mode - with terminal.raw(): + with tinfo.terminal.raw(), tinfo.terminal.focus_events(): try: # Detect color mode if args.color_mode is None: - args.color_mode = detect_local_color_mode(terminal) + args.color_mode = detect_local_color_mode(tinfo.terminal) if args.color_mode == ColorMode.COULD_NOT_DETECT: # TODO: add a prompt to ask the user to choose a color mode # instead of silently falling back to 8-bit args.color_mode = ColorMode.HAS_8_BIT_COLOR # Prepare alternate screen - terminal.stream.write( - terminal.enter_fullscreen + terminal.clear + terminal.hide_cursor + tinfo.terminal.stream.write( + tinfo.terminal.enter_fullscreen + tinfo.terminal.clear + + tinfo.terminal.hide_cursor + + "\033[?7l" # disable line wrap ) - terminal.stream.flush() + tinfo.terminal.stream.flush() # Enter input and audio contexts with input_context as get_gb_input: with audio_player(console, args.speed, disable_audio) as audio_out: - # Run the emulator run( console, get_gb_input, - term=terminal, + term=tinfo.terminal, audio_out=audio_out, frame_advance=args.frame_advance, color_mode=args.color_mode, break_after=args.break_after, speed=args.speed, use_cpr_sync=args.cpr_sync, + graphics_protocol=args.graphics_protocol, + available_graphics_protocols=available_graphics, + terminal_name=tinfo.name, + force_transparent_offset=tinfo.force_transparent_offset, + show_status=show_status, + blit_visualizer=blit_visualizer, + frame_banding=frame_banding, ) # Deal with ctrl+c and ctrl+d exceptions @@ -236,10 +389,15 @@ def main( # Wait for a possible CPR time.sleep(0.1) # Clear alternate screen - terminal.stream.write( - terminal.clear + terminal.exit_fullscreen + terminal.normal_cursor + restore = ( + tinfo.terminal.clear + tinfo.terminal.exit_fullscreen + + tinfo.terminal.normal_cursor ) - terminal.stream.flush() + if args.graphics_protocol is GraphicsProtocol.KITTY: + restore = b"\033_Ga=d,d=a,q=1\033\\".decode() + restore + restore = "\033[?7h" + restore # re-enable line wrap + tinfo.terminal.stream.write(restore) + tinfo.terminal.stream.flush() if __name__ == "__main__": diff --git a/gambaterm/remote_terminal.py b/gambaterm/remote_terminal.py index 96d6fea..a2cd013 100644 --- a/gambaterm/remote_terminal.py +++ b/gambaterm/remote_terminal.py @@ -6,8 +6,9 @@ import codecs from concurrent.futures import ThreadPoolExecutor, CancelledError -from enum import Enum +from enum import Enum, auto import hashlib +import os import contextlib from typing import IO, Callable, Generator, TypeAlias, TYPE_CHECKING @@ -16,6 +17,7 @@ if TYPE_CHECKING: from .main import AppConfig + from blessed.terminal import SoftwareVersion class RemoteTerminal(BlessedTerminal): @@ -98,6 +100,43 @@ class KeyboardSupport(Enum): X11 = "x11" +class GraphicsProtocol(Enum): + TEXT = auto() + SIXEL = auto() + KITTY = auto() + BLITLESS_SIXEL = auto() + + +def does_sixel( + term: BlessedTerminal, timeout: float = 1.0, sv: SoftwareVersion | None = None +) -> bool: + """Check if the terminal supports sixel graphics. + + Includes workarounds for terminals that report sixel support in error. + """ + if not term.does_sixel(timeout=timeout): + return False + if sv is None: + sv = term.get_software_version(timeout=timeout) + if sv is None: + return True + if sv.name.lower() == "rio" and version_in_range(sv.version, "0.3", "0.4.9"): + # rio supported sixel, then broke it in 0.4 release (still reports + # support via DA/XTGETTCAP), fixed in 0.4.9+. + return False + return True + + +def version_in_range(version: str, lo_excl: str, hi_excl: str) -> bool: + """Return True if *version* is in (lo_excl, hi_excl).""" + # Strip pre-release suffixes (e.g. "0.4.0-alpha") before numeric parse. + _nums = __import__("re").split(r"[^\d]", version) + v = tuple(int(p) for p in _nums if p) + lo = tuple(int(p) for p in lo_excl.split(".")) + hi = tuple(int(p) for p in hi_excl.split(".")) + return lo < v < hi + + class KeyboardSupportDetection: def __init__( self, @@ -136,11 +175,104 @@ def _detect(self, timeout: float = 3.0) -> KeyboardSupport: return KeyboardSupport.BASIC +# many terminals do not support multiple overlaying images or ID's, like used with kitty, or don't +# support the "transparent pixels" used with sixel. Other terminals, like 'rio' and 'mlterm' have +# common font alignment issues, where the "full unicode block" do not touch completely, and so they +# are "banned" from using text mode, preferring their graphics modes for all scales, instead. +FORCE_SIXEL_BLITLESS = ("contour", "tabby", "konsole", "mlterm", "iterm2", "wezterm", "foot") +FORCE_KITTY_BLITLESS = ("rio",) +BAD_TEXT = ("rio", "mlterm") + +# I think these sixel terminals draw directly to the X11 window without any memory, and so when they +# lose focus or get occluded by another window, their image contents are lost. Lucky for us, they +# support the DEC Private Mode for detecting FOCUS_IN and FOCUS_OUT events as a workaround to detect +# and force full keyframe refresh. +SIXEL_FORCE_REFRESH_ON_FOCUS = ("mlterm", "xterm") + +# xterm has a 1,000 x 1,000 image size (it begins to get cropped), an approximate scale cap is enforced. +XTERM_SIXEL_SCALE_CAP = ("xterm",) + +# Ghostty does not support the bulk a=d,d=a delete action; we emit individual +# a=d,d=i delete commands per ID instead. +FORCE_KITTY_INDIVIDUAL_DELETES = ("ghostty",) +# q=1 is required to suppress Gi=;OK response pollution on the input stream. +KITTY_GFX_CLEAR = b"\033_Ga=d,d=a,q=1\033\\" +KITTY_GFX_GHOSTTY_CLEAR = (b"\033_Ga=d,d=i,i=1\033\\" + + b"\033_Ga=d,d=i,i=100\033\\") +TEXT_HOME_CLEAR = b"\033[H\033[2J" + + +def detect_graphics_frontend( + terminal: RemoteTerminal, + config: "AppConfig", + keyboard_detection: KeyboardSupportDetection, +) -> "AppConfig": + """Probe terminal graphics capabilities and set config.graphics_protocol.""" + if os.environ.get("GAMBATERM_FORCE_SIXEL"): + config.available_graphics = [GraphicsProtocol.TEXT, GraphicsProtocol.SIXEL] + config.graphics_protocol = GraphicsProtocol.SIXEL + return config + if os.environ.get("GAMBATERM_FORCE_KITTY"): + config.available_graphics = [GraphicsProtocol.TEXT, GraphicsProtocol.KITTY] + config.graphics_protocol = GraphicsProtocol.KITTY + return config + + has_sixel = does_sixel(terminal) + + sv = terminal.get_software_version(timeout=1.0) + has_kitty_graphics = terminal.does_kitty_graphics() + blitless = sv is not None and sv.name.lower() in FORCE_SIXEL_BLITLESS + config.available_graphics = [GraphicsProtocol.TEXT] + if blitless: + config.available_graphics.append(GraphicsProtocol.BLITLESS_SIXEL) + config.graphics_protocol = GraphicsProtocol.TEXT + return config + if has_sixel: + config.available_graphics.append(GraphicsProtocol.SIXEL) + if has_kitty_graphics: + config.available_graphics.append(GraphicsProtocol.KITTY) + + # Prefer sixel, then kitty, then text + if has_sixel: + config.graphics_protocol = GraphicsProtocol.SIXEL + elif has_kitty_graphics: + config.graphics_protocol = GraphicsProtocol.KITTY + + return config + + FrontendCallback: TypeAlias = Callable[ [RemoteTerminal, "AppConfig", KeyboardSupportDetection], "AppConfig" ] +def make_graphics_frontend(graphics_value: str) -> FrontendCallback | None: + """Return a FrontendCallback for the given --graphics value, or None. + + ``"auto"`` returns :func:`detect_graphics_frontend`. Explicit protocol + names return a callback that forces that protocol. ``"text"`` returns + ``None`` (no frontend callback needed). + """ + if graphics_value == "auto": + return detect_graphics_frontend + if graphics_value != "text": + + def force_graphics( + term: RemoteTerminal, + config: "AppConfig", + kbd: KeyboardSupportDetection, + ) -> "AppConfig": + config.graphics_protocol = GraphicsProtocol[graphics_value.upper()] + config.available_graphics = [ + GraphicsProtocol.TEXT, + config.graphics_protocol, + ] + return config + + return force_graphics + return None + + def user_directory_name(username: str | None) -> str: """Hash the username into a safe directory name. @@ -150,3 +282,34 @@ def user_directory_name(username: str | None) -> str: if username is None: return "_anonymous" return hashlib.sha256(username.encode("utf-8")).hexdigest()[:16] + + +def resolve_graphics_protocol( + resized: bool, + current: GraphicsProtocol, + new_height: int, + new_width: int, + console_width: int, + console_height: int, + available: list[GraphicsProtocol], + terminal_name: str, +) -> GraphicsProtocol: + """Return the best graphics protocol for the current terminal size. + + On resize, switch between text and the best available graphics protocol. + """ + if not resized: + return current + + text_fits = new_width >= console_width and new_height >= console_height // 2 + if ( + text_fits + and current is not GraphicsProtocol.TEXT + and not terminal_name.startswith(BAD_TEXT) + ): + return GraphicsProtocol.TEXT + if not text_fits and current is GraphicsProtocol.TEXT: + for gp in reversed(available): + if gp is not GraphicsProtocol.TEXT: + return gp + return current diff --git a/gambaterm/run.py b/gambaterm/run.py index b7d0e69..9a2fa75 100644 --- a/gambaterm/run.py +++ b/gambaterm/run.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import re import sys import time import contextlib @@ -17,8 +16,13 @@ from .console import Console from .input_getter import BaseInputGetter from .colors import ColorMode +from .graphics_scaler import GraphicsRenderer +from .remote_terminal import GraphicsProtocol, resolve_graphics_protocol -_CPR_RE = re.compile(r"\x1b\[\d+;\d+R") +debug_input_fh = None +debug_input_path = os.environ.get("GAMBATERM_DEBUG_INPUT") +if debug_input_path: + debug_input_fh = open(debug_input_path, "w") @contextlib.contextmanager @@ -36,7 +40,7 @@ def get_ref(width: int, height: int, console: Console) -> tuple[int, int]: return refx, refy -def write_frame(term: Terminal, frame_data: bytes) -> None: +def write_frame(term: Terminal, frame_data: bytes | bytearray) -> None: # Fix code page issue on windows: # `sys.stdout.buffer.raw` is a `WindowsConsoleIO` that always support UTF-8 # regardless of the configured codepage @@ -47,6 +51,135 @@ def write_frame(term: Terminal, frame_data: bytes) -> None: os.write(term.stream.fileno(), frame_data) +class StatusBar: + """Terminal status bar: toggle, force-update, and ANSI encoding.""" + + IDLE_INTERVAL = 0.25 + + def __init__(self, term: Terminal, show: bool = False) -> None: + self._term = term + self.show = show + self.force_update = False + self.bar: bytes = b"" + self.needs_clear = False + self.last_sent = 0.0 + + def toggle(self) -> None: + self.show = not self.show + if not self.show: + self.needs_clear = True + + def encode(self, text: str, width: int) -> bytes: + return ( + f"\033[1;1H\033[48;2;132;94;167m\033[38;2;216;208;200m" + f"{self._term.center(text[:width])}" + f"\033[0m" + ).encode() + + +class FrameEmitter: + """Writes frame data to terminal with CPR, status bar, and tracking. + + Owns the mutable per-frame state (pending_cpr, blitter_vis) that was + previously scattered across run() locals. + """ + + CPR_SEQ = b"\033[1;1H\033[6n" + CLEAR_SEQ = b"\033[1;1H\033[K" + + def __init__( + self, + term: Terminal, + status_bar: StatusBar, + renderer: "GraphicsRenderer", + cpr_sync_window: int, + blitter_vis: int, + ) -> None: + self._term = term + self._status_bar = status_bar + self._renderer = renderer + self._cpr_sync_window = cpr_sync_window + self.pending_cpr = 0 + self.blitter_vis = blitter_vis + + @property + def cpr_ready(self) -> bool: + return not self._cpr_sync_window or self.pending_cpr < self._cpr_sync_window + + def on_cpr_response(self) -> None: + self.pending_cpr = max(0, self.pending_cpr - 1) + + def emit( + self, + frame_data: bytearray, + data_length: "deque[int]", + shown_frames: "deque[int]", + ) -> bytearray: + """Write *frame_data* and return a fresh empty bytearray.""" + if self._cpr_sync_window: + frame_data += self.CPR_SEQ + self.pending_cpr += 1 + if self._status_bar.show and self._status_bar.bar: + frame_data[:0] = self._status_bar.bar + if self._status_bar.needs_clear: + self._status_bar.needs_clear = False + frame_data[:0] = self.CLEAR_SEQ + if self.blitter_vis: + self.blitter_vis = 3 - self.blitter_vis + self._renderer.blitter_vis = self.blitter_vis + write_frame(self._term, frame_data) + if self._status_bar.show: + self._status_bar.last_sent = time.monotonic() + data_length.append(len(frame_data)) + shown_frames.append(True) + return bytearray() + + +def format_status( + *, + total_fps: float, + romfile: str, + speed: float, + emu_fps: float, + emu_percent: float, + video_fps: float, + video_percent: float, + data_rate: float, + audio_percent: float, + dropped_frames: int, + graphics_protocol: GraphicsProtocol, + color_mode: ColorMode, + blitter_vis: bool, + terminal_name: str, + graphics_scale: int = 0, + audio_buffer: float = 0.0, +) -> str: + """Build the human-readable status line.""" + parts = [f" {terminal_name} " if terminal_name else " "] + parts.append(f" {total_fps:.0f} FPS | ") + parts.append(f"{os.path.basename(romfile)} | ") + parts.append( + f"Emu {speed:.2f}x {emu_fps:.0f} FPS {emu_percent:.0f}% CPU | " + ) + maybe_dropped = f"-{dropped_frames}! " if dropped_frames else "" + parts.append( + f"Video {video_fps:.0f}{maybe_dropped}FPS {video_percent:.0f}% CPU " + f"{data_rate:.0f} KB/s | " + ) + parts.append(f"Audio {audio_percent:.0f}% CPU b:{audio_buffer*100:.0f}% | ") + if graphics_protocol is GraphicsProtocol.TEXT: + parts.append(f"{color_mode.report()} textmode ") + elif graphics_protocol is GraphicsProtocol.SIXEL: + parts.append(f"sixel{graphics_scale}x " if graphics_scale else "sixel ") + elif graphics_protocol is GraphicsProtocol.BLITLESS_SIXEL: + parts.append(f"sixel-blitless{graphics_scale}x " if graphics_scale else "sixel-blitless ") + else: + parts.append(f"kitty{graphics_scale}x " if graphics_scale else "kitty ") + if blitter_vis: + parts.append("(vis) ") + return "".join(parts) + + def run( console: Console, input_getter: BaseInputGetter, @@ -56,20 +189,31 @@ def run( color_mode: ColorMode = ColorMode.HAS_24_BIT_COLOR, break_after: int | None = None, speed: float = 1.0, - use_cpr_sync: bool = False, + use_cpr_sync: int = 0, + graphics_protocol: GraphicsProtocol = GraphicsProtocol.TEXT, + available_graphics_protocols: list[GraphicsProtocol] | None = None, + terminal_name: str = "", + force_transparent_offset: bool = False, + show_status: bool = False, + blit_visualizer: bool = False, + frame_banding: bool = False, ) -> None: assert color_mode > 0 # Prepare buffers with invalid data video = np.full((console.HEIGHT, console.WIDTH), 0, np.uint32) audio = np.full((2 * console.TICKS_IN_FRAME, 2), 0, np.int16) - last_frame = video.copy() + # Force first diff: 0x00000000 has alpha=0 which never matches + # real GB pixels (alpha always 0xFF). + last_frame = np.full((console.HEIGHT, console.WIDTH), 0x00000000, np.uint32) # Print area (default to 24x80 if terminal reports zero) height = term.height or 24 width = term.width or 80 refx, refy = get_ref(width, height, console) + # TODO: these two sections are better extracted to some external class & obj ? + # Prepare reporting fps = console.FPS * speed average_over = int(round(fps)) # frames @@ -82,14 +226,34 @@ def run( shifting: Deque[float] = deque(maxlen=average_over) shown_frames: Deque[int] = deque(maxlen=average_over) data_length: Deque[int] = deque(maxlen=average_over) + buffer_levels: Deque[float] = deque(maxlen=average_over) start = time.time() # Prepare state new_frame = False - screen_ready = True frame_start_time = None frame_data = bytearray() - current_title_sequence = b"" + + # Graphics renderer + renderer = GraphicsRenderer( + term, console, terminal_name, graphics_protocol, + force_transparent_offset=force_transparent_offset, + frame_banding=frame_banding, + ) + + # Status bar + status_bar = StatusBar(term, show=show_status) + + # Frame emitter: owns CPR sync state, blitter visualizer toggle, and output + frame_emitter = FrameEmitter( + term, status_bar, renderer, + cpr_sync_window=use_cpr_sync, + blitter_vis=1 if blit_visualizer else 0, + ) + + # Guard against missing available-protocols list. + if available_graphics_protocols is None: + available_graphics_protocols = [GraphicsProtocol.TEXT] # Loop over emulator frames for i in count(): @@ -100,6 +264,7 @@ def run( # Break when frame limit is reach if break_after is not None and i >= break_after: + renderer.close() return # Tick the emulator @@ -112,6 +277,7 @@ def run( # Send audio with timing(audio_deltas): audio_out.send(console, audio[:samples, :]) + buffer_levels.append(audio_out.fill_fraction) # Read keys for ctrl-c, ctrl-d, and CPR response. # If the kitty keyboard protocol is used, all inputs are sent as CSI sequences @@ -119,20 +285,39 @@ def run( # decoded `key_name` attribute, since it ends up being `KEY_CTRL_C` for ctrl+c # and `KEY_CTRL_D` for ctrl+d regardless of the underlying encoding. new_color_mode = color_mode + new_graphics_protocol = graphics_protocol for key in input_getter.pop_keystrokes(): - if key.key_name == "KEY_CTRL_C": + if debug_input_fh is not None: + debug_input_fh.write( + f"{time.monotonic():.4f} {key.key_name or key} {key!r}\n" + ) + if key.key_name == "CPR_RESPONSE": + frame_emitter.on_cpr_response() + elif key.key_name == "KEY_CTRL_C": raise KeyboardInterrupt - if key.key_name == "KEY_CTRL_D": + elif key.key_name == "KEY_CTRL_D": raise EOFError - if key.key_name == "KEY_TAB": - new_color_mode = color_mode.cycle() - if key.key_name in ("KEY_PGUP", "KEY_PGDOWN"): + # debug text color modes + elif key.key_name in ("KEY_TAB", "KEY_SHIFT_TAB"): + if key.key_name.startswith("KEY_SHIFT"): + new_color_mode = color_mode.cycle_back() + else: + new_color_mode = color_mode.cycle() + # debug runtime adjustment of --speed by 10% + elif key.key_name in ("KEY_PGUP", "KEY_PGDOWN"): speed += 0.1 if key.key_name == "KEY_PGUP" else -0.1 fps = console.FPS * speed average_over = int(round(fps)) # frames audio_out.update_speed(console, speed) - if _CPR_RE.match(str(key)): - screen_ready = True + elif key.key_name in ("FOCUS_IN", "FOCUS_OUT"): + renderer.on_focus_change(graphics_protocol) + elif key.key_name in ("KEY_GRAVE_ACCENT", "KEY_TILDE") or key in ("`", "~"): + status_bar.toggle() + elif key.key_name == "KEY_F12": + frame_emitter.blitter_vis = 0 if frame_emitter.blitter_vis else 1 + renderer.blitter_vis = frame_emitter.blitter_vis + elif key.key_name == "KEY_CTRL_L" or key in ("\x0c",): + renderer.request_keyframe() # Render video with timing(video_deltas): @@ -147,39 +332,78 @@ def run( # - a new frame is available from the emulator # - the screen is ready for a new frame (either CPR sync is disabled, or enabled and we received the CPR response) # - we are not currently shifting (to prevent flooding the terminal with new frames when the rendering is too slow) - if i % frame_advance == 0 and new_frame and screen_ready and not shift: + if ( + i % frame_advance == 0 + and new_frame + and frame_emitter.cpr_ready + and not shift + ): new_frame = False - - # Detect terminal resize and color mode change new_height = term.height or 24 new_width = term.width or 80 - maybe_clear_sequence = b"" - if (new_height, new_width) != ( - height, - width, - ) or new_color_mode != color_mode: - maybe_clear_sequence = b"\033[H\033[2J" + resized = (new_height, new_width) != (height, width) + + # Resolve protocol for resize + new_graphics_protocol = resolve_graphics_protocol( + resized=resized, + current=graphics_protocol, + new_height=new_height, + new_width=new_width, + console_width=console.WIDTH, + console_height=console.HEIGHT, + available=available_graphics_protocols, + terminal_name=terminal_name, + ) + + changed = ( + resized + or new_color_mode != color_mode + or new_graphics_protocol != graphics_protocol + ) + + clear_seq = b"" + if changed: + clear_seq = renderer.on_state_change( + old_protocol=graphics_protocol, + new_protocol=new_graphics_protocol, + old_color=color_mode, + new_color=new_color_mode, + resized=resized, + ) height, width = new_height, new_width refx, refy = get_ref(width, height, console) color_mode = new_color_mode + graphics_protocol = new_graphics_protocol term.number_of_colors = new_color_mode.number_of_colors last_frame.fill(0) + status_bar.force_update = True - # Render frame with synchronized output mode (DEC 2026) to prevent flickering - # when the screen is cleared, or an artificial CRT-like "rolling band" side-effects - # from fast "sprite blinking" meant to cause "transparency" effect on original HW, - # https://zladx.github.io/posts/links-awakening-partial-translucency - frame_data += b"\033[?2026h" - frame_data += maybe_clear_sequence - frame_data += blit( - video, last_frame, refx, refy, width - 1, height, color_mode - ) - frame_data += b"\033[?2026l" - last_frame = video.copy() + # Render frame + if graphics_protocol is GraphicsProtocol.TEXT: + if clear_seq: + frame_data += clear_seq + frame_data += b"\033[?2026h" + frame_data += blit( + video, last_frame, refx, refy, width - 1, height, color_mode, + frame_emitter.blitter_vis, + ) + frame_data += b"\033[?2026l" + frame_data = frame_emitter.emit( + frame_data, data_length, shown_frames, + ) + else: + if clear_seq: + frame_data += clear_seq + render_result = renderer.render( + video, last_frame, + graphics_protocol, height, width, + ) + frame_data += render_result + frame_data = frame_emitter.emit( + frame_data, data_length, shown_frames, + ) - # Update reporting - data_length.append(len(frame_data)) - shown_frames.append(True) + video, last_frame = last_frame, video # Ignore this video frame else: @@ -188,16 +412,12 @@ def run( # Pacing and synchronization with timing(sync_deltas): - # Video sync - if frame_data: - # Send CPR request - if use_cpr_sync: - frame_data += b"\033[1;1H\033[6n" - screen_ready = False - # Add the current title - frame_data += current_title_sequence - # Write the entire frame in one go to avoid fragmentation - write_frame(term, frame_data) + # Idle status bar heartbeat + if status_bar.show and status_bar.bar and not frame_data: + now = time.monotonic() + if now - status_bar.last_sent >= StatusBar.IDLE_INTERVAL: + write_frame(term, status_bar.bar) + status_bar.last_sent = now # Timing sync increment = samples / console.TICKS_IN_FRAME deadline = start + increment / fps @@ -208,23 +428,46 @@ def run( shifting.append(time.time() - deadline) start = deadline - # Prepare title for the next frame - if i % average_over == 1: + # Prepare status bar for the next frame + if i % average_over == 1 or status_bar.force_update: + status_bar.force_update = False tps = fps * console.TICKS_IN_FRAME - emu_fps = tps * len(ticks) / sum(ticks) - video_fps = emu_fps * sum(shown_frames) / len(shown_frames) - total_fps = len(total_deltas) / sum(total_deltas) - emu_percent = sum(emu_deltas) / len(emu_deltas) * total_fps * 100 - audio_percent = sum(audio_deltas) / len(audio_deltas) * total_fps * 100 - video_percent = sum(video_deltas) / len(video_deltas) * total_fps * 100 - data_rate = sum(data_length) / len(data_length) * total_fps / 1000 - title = f"Gambaterm - {total_fps:.0f} FPS | " - title += f"{os.path.basename(console.romfile)} | " - title += ( - f"Emu: {speed:.2f}x - {emu_fps:.0f} FPS - {emu_percent:.0f}% CPU | " + emu_fps = tps * len(ticks) / sum(ticks) if ticks else 0 + if shown_frames: + video_fps = emu_fps * sum(shown_frames) / len(shown_frames) + else: + video_fps = 0 + total_fps = len(total_deltas) / sum(total_deltas) if total_deltas else 0 + + emu_percent = sum(emu_deltas) / len(emu_deltas) * total_fps * 100 if emu_deltas else 0 + audio_percent = sum(audio_deltas) / len(audio_deltas) * total_fps * 100 if audio_deltas else 0 + video_percent = sum(video_deltas) / len(video_deltas) * total_fps * 100 if video_deltas else 0 + data_rate = sum(data_length) / len(data_length) * total_fps / 1000 if data_length else 0 + dropped_frames = len(shown_frames) - sum(shown_frames) + worst_buffer = min(buffer_levels) if buffer_levels else 1.0 + + status_bar.bar = status_bar.encode( + format_status( + total_fps=total_fps, + romfile=str(console.romfile), + speed=speed, + emu_fps=emu_fps, + emu_percent=emu_percent, + video_fps=video_fps, + video_percent=video_percent, + data_rate=data_rate, + audio_percent=audio_percent, + dropped_frames=dropped_frames, + graphics_protocol=graphics_protocol, + color_mode=color_mode, + blitter_vis=frame_emitter.blitter_vis, + terminal_name=terminal_name, + graphics_scale=( + renderer.kitty_scale + if graphics_protocol is GraphicsProtocol.KITTY + else renderer.scale + ) or 0, + audio_buffer=worst_buffer, + ), + width, ) - title += f"Video: {video_fps:.0f} FPS - {video_percent:.0f}% CPU - " - title += f"{data_rate:.0f} KB/s | " - title += f"Audio: {audio_percent:.0f}% CPU | " - title += f"{color_mode.report()} mode" - current_title_sequence = term.set_window_title(title).encode("utf-8") diff --git a/gambaterm/ssh.py b/gambaterm/ssh.py index 036a1ab..d50a0f0 100644 --- a/gambaterm/ssh.py +++ b/gambaterm/ssh.py @@ -44,9 +44,10 @@ from .remote_terminal import ( KeyboardSupport, + KeyboardSupportDetection, RemoteTerminal, + make_graphics_frontend, user_directory_name, - KeyboardSupportDetection, FrontendCallback, ) from .ssh_app_session import process_to_terminal @@ -198,10 +199,7 @@ def ssh_terminal_handler( input_source = detect_input_source(app_config, keyboard_support_detection) console_input_context: ContextManager[BaseInputGetter] if input_source is None: - message = ( - MESSAGE_SUGGESTING_KITTY_SUPPORT - + "\n\n" - + """\ + message = MESSAGE_SUGGESTING_KITTY_SUPPORT + "\n\n" + """\ Alternatively, X11 forwarding can be used in order to give the gambaterm-ssh server access to your keyboard, eg. `ssh -Y -p 8022 localhost`. ===============================[ WARNING ]===================================== @@ -210,7 +208,6 @@ def ssh_terminal_handler( sandbox. More information here: https://security.stackexchange.com/a/7496 ===============================[ WARNING ]===================================== """ - ) terminal.stream.write(message) terminal.stream.flush() session_logger.warning( @@ -268,6 +265,8 @@ def ssh_terminal_handler( break_after=app_config.break_after, speed=app_config.speed, use_cpr_sync=app_config.cpr_sync, + graphics_protocol=app_config.graphics_protocol, + available_graphics_protocols=app_config.available_graphics, ) return 0 finally: @@ -335,10 +334,10 @@ def __init__( users_directory: Path, executor: ThreadPoolExecutor, active_connections: dict[GambatermSSHServer, SSHServerConnection], - frontend: Callable[ - [RemoteTerminal, AppConfig, KeyboardSupportDetection], AppConfig - ] - | None = None, + frontend: ( + Callable[[RemoteTerminal, AppConfig, KeyboardSupportDetection], AppConfig] + | None + ) = None, ): self._gambaterm_console_cls = console_cls self._gambaterm_namespace = namespace @@ -529,7 +528,11 @@ def main( parser_args: tuple[str, ...] | None = None, console_cls: type[Console] = GameboyColor, ) -> None: - parser = argparse.ArgumentParser(description="Gambatte terminal front-end over ssh") + parser = argparse.ArgumentParser( + prog="gambaterm-ssh", + description="Gambatte terminal front-end over ssh", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) add_base_arguments(parser) add_input_file_arguments(parser) add_tuning_arguments(parser) @@ -575,6 +578,10 @@ def main( password: str = namespace.__dict__.pop("password") no_auth: bool = namespace.__dict__.pop("no_auth") users_directory: Path = namespace.__dict__.pop("users_directory") + graphics_value: str = namespace.__dict__.pop("graphics") + + # Determine frontend callback for graphics + frontend: FrontendCallback | None = make_graphics_frontend(graphics_value) # Determine authentication method if no_auth and password is None: @@ -617,6 +624,7 @@ async def async_main() -> None: command_parser, users_directory, executor, + frontend=frontend, ): await asyncio.Future() diff --git a/gambaterm/ssh_app_session.py b/gambaterm/ssh_app_session.py index 2a632d5..b1b988c 100644 --- a/gambaterm/ssh_app_session.py +++ b/gambaterm/ssh_app_session.py @@ -2,6 +2,7 @@ Provide an async context manager to create a blessed RemoteTerminal from an AsyncSSH process. """ + from __future__ import annotations import os diff --git a/gambaterm/telnet.py b/gambaterm/telnet.py index b06ea5b..d38075e 100644 --- a/gambaterm/telnet.py +++ b/gambaterm/telnet.py @@ -44,6 +44,7 @@ KeyboardSupport, KeyboardSupportDetection, RemoteTerminal, + make_graphics_frontend, user_directory_name, FrontendCallback, ) @@ -122,6 +123,8 @@ def thread_target( break_after=app_config.break_after, speed=app_config.speed, use_cpr_sync=app_config.cpr_sync, + graphics_protocol=app_config.graphics_protocol, + available_graphics_protocols=app_config.available_graphics, ) except (KeyboardInterrupt, EOFError): return 0 @@ -439,7 +442,9 @@ def main( console_cls: type[Console] = GameboyColor, ) -> None: parser = argparse.ArgumentParser( - description="Gambatte terminal front-end over telnet" + prog="gambaterm-telnet", + description="Gambatte terminal front-end over telnet", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, ) add_base_arguments(parser) add_input_file_arguments(parser) @@ -464,8 +469,7 @@ def main( "--robot-check", action="store_true", default=False, - help="reject bots by checking if client responds to " - "cursor position requests", + help="reject bots by checking if client responds to cursor position requests", ) parser.add_argument( "--port", @@ -494,6 +498,8 @@ def main( max_players: int = namespace.__dict__.pop("max_players") idle_timeout: float | None = namespace.__dict__.pop("idle_timeout") users_directory: Path = namespace.__dict__.pop("users_directory") + graphics_value: str = namespace.__dict__.pop("graphics") + frontend = make_graphics_frontend(graphics_value) try: with ThreadPoolExecutor(max_workers=32) as executor: @@ -509,6 +515,7 @@ async def async_main() -> None: namespace, users_directory, executor, + frontend=frontend, ): await asyncio.Future() diff --git a/gambaterm/telnet_app_session.py b/gambaterm/telnet_app_session.py index 4fe0d3e..254af8a 100644 --- a/gambaterm/telnet_app_session.py +++ b/gambaterm/telnet_app_session.py @@ -1,6 +1,7 @@ """ Provide paced output forwarding and terminal setup for the telnet server. """ + from __future__ import annotations import os diff --git a/gambaterm/termblit.pyi b/gambaterm/termblit.pyi index 7efc167..0a41df6 100644 --- a/gambaterm/termblit.pyi +++ b/gambaterm/termblit.pyi @@ -9,4 +9,5 @@ def blit( width: int, height: int, color_mode: int, + blitter_vis: int = 0, ) -> bytes: ... diff --git a/pyproject.toml b/pyproject.toml index e4f2902..74ebb4e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ classifiers = [ dependencies = [ "numpy>=2.0.0,<3.0.0", "asyncssh>=2.9,<3.0.0", - "blessed>=1.43.0,<2", + "blessed>=1.47.0,<2", "miniaudio>=1.2", "samplerate>=0.1.0,<0.2.0", "python-xlib; sys_platform == 'linux'", diff --git a/setup.py b/setup.py index 871378e..6a44416 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,14 @@ def get_extensions() -> list[Extension]: sources=["termblit_ext/termblit.pyx"], ) - return [gambatte_extension, termblit_extension] + graphicsblit_extension = Extension( + "gambaterm.graphicsblit", + language="c", + include_dirs=[numpy.get_include()], + sources=["termblit_ext/graphicsblit.pyx"], + ) + + return [gambatte_extension, termblit_extension, graphicsblit_extension] setup(ext_modules=get_extensions()) diff --git a/termblit_ext/graphicsblit.pyx b/termblit_ext/graphicsblit.pyx new file mode 100644 index 0000000..e9ccef0 --- /dev/null +++ b/termblit_ext/graphicsblit.pyx @@ -0,0 +1,283 @@ +# cython: language_level=3 + +from cython cimport boundscheck +from libc.stdlib cimport malloc, free +from libc.string cimport memcpy +from libc.stdint cimport uint32_t, uint8_t, int16_t +from cpython.bytes cimport PyBytes_FromStringAndSize + +cimport numpy as cnp +import numpy as np +import zlib +import base64 +import os + +cnp.import_array() + +ZLIB_LEVEL = int(os.environ.get("GAMBATERM_ZLIB_LEVEL", "1")) + + +SIXEL_BITS = np.array([1, 2, 4, 8, 16, 32], dtype=np.uint8) +DCS_START = b"\x1bP0;1;0q" +DCS_END = b"\x1b\\" +APC_START = b"\x1b_G" +APC_END = b"\x1b\\" + + +@boundscheck(False) +def to_rgb(uint32_t[:, ::1] video): + """Convert uint32 (H,W) in 0xAARRGGBB to float32 (H,W,3) in [0,1].""" + cdef int h = video.shape[0] + cdef int w = video.shape[1] + cdef int y, x + cdef uint32_t p + cdef float[:, :, ::1] out = np.empty((h, w, 3), dtype=np.float32) + for y in range(h): + for x in range(w): + p = video[y, x] + out[y, x, 0] = ((p >> 16) & 0xFF) / 255.0 + out[y, x, 1] = ((p >> 8) & 0xFF) / 255.0 + out[y, x, 2] = (p & 0xFF) / 255.0 + return np.asarray(out) + + +@boundscheck(False) +def to_rgba_u8(uint32_t[:, ::1] video): + """Convert uint32 (H,W) in 0xAARRGGBB to uint8 (H,W,4) RGBA.""" + cdef int h = video.shape[0] + cdef int w = video.shape[1] + cdef int y, x + cdef uint32_t p + cdef uint8_t[:, :, ::1] out = np.empty((h, w, 4), dtype=np.uint8) + for y in range(h): + for x in range(w): + p = video[y, x] + out[y, x, 0] = ((p >> 16) & 0xFF) + out[y, x, 1] = ((p >> 8) & 0xFF) + out[y, x, 2] = (p & 0xFF) + out[y, x, 3] = 255 + return np.asarray(out) + + +@boundscheck(False) +def quantize_colors(float[:, :, ::1] colors, int n_colors): + """Quantize RGB colors to a uniform cube palette. + + Returns (indices (H,W) uint8, palette (N,3) float32). + """ + cdef int levels = 2 + while (levels + 1) * (levels + 1) * (levels + 1) <= n_colors and levels < 6: + levels += 1 + + cdef int h = colors.shape[0] + cdef int w = colors.shape[1] + cdef int y, x, i + cdef int ri, gi, bi + cdef int n_actual = levels * levels * levels + cdef float lf = levels + cdef int levels_minus_1 = levels - 1 + + cdef uint8_t[:, ::1] indices = np.empty((h, w), dtype=np.uint8) + for y in range(h): + for x in range(w): + ri = (colors[y, x, 0] * lf) + gi = (colors[y, x, 1] * lf) + bi = (colors[y, x, 2] * lf) + if ri >= levels: + ri = levels_minus_1 + if gi >= levels: + gi = levels_minus_1 + if bi >= levels: + bi = levels_minus_1 + indices[y, x] = (ri * levels * levels + gi * levels + bi) + + cdef float[:, ::1] palette = np.zeros((n_actual, 3), dtype=np.float32) + for i in range(n_actual): + ri = i // (levels * levels) + gi = (i // levels) % levels + bi = i % levels + palette[i, 0] = (ri + 0.5) / lf + palette[i, 1] = (gi + 0.5) / lf + palette[i, 2] = (bi + 0.5) / lf + + return np.asarray(indices), np.asarray(palette) + + +@boundscheck(False) +def encode_sixel(float[:, :, ::1] colors, int max_colors=256, int scale=1, + uint8_t[:, ::1] indices=None, float[:, ::1] palette=None, + int skip_index=-1, int h=0, int w=0): + """Encode an RGB image as a sixel escape sequence. + + Returns bytes (the full escape sequence). + + If *indices* and *palette* are provided, internal quantization is + skipped and the pre-computed values are used. *skip_index* is a + colour index that is never emitted to the sixel stream, producing + transparent pixels when P2=1. + + When *indices*+*palette* are precomputed, *h*/*w* may be passed + directly; the *colors* array is then ignored (not read for pixel + values) and only the dimensional information is used. + """ + cdef int use_precomputed = indices is not None and palette is not None + if use_precomputed and h > 0 and w > 0: + pass # dimensions provided directly + else: + h = colors.shape[0] + w = colors.shape[1] + if scale > 1: + h *= scale + w *= scale + + # Scale indices when precomputed and not already handled + if use_precomputed and scale > 1: + indices = np.ascontiguousarray( + np.repeat(np.repeat(np.asarray(indices), scale, axis=0), + scale, axis=1), + dtype=np.uint8) + h = colors.shape[0] * scale + w = colors.shape[1] * scale + + # Scale colors (non-precomputed path) + cdef float[:, :, ::1] scaled_colors + if not use_precomputed and scale > 1: + arr = np.asarray(colors) + arr = np.repeat(np.repeat(arr, scale, axis=0), scale, axis=1) + scaled_colors = arr + elif not use_precomputed: + scaled_colors = colors + + # Pad indices to multiple of 6 rows + cdef int pad_h = (6 - h % 6) % 6 + if pad_h > 0 and use_precomputed: + indices_padded = np.full((h + pad_h, w), skip_index, dtype=np.uint8) + indices_padded[:indices.shape[0], :] = np.asarray(indices) + indices = indices_padded + h = h + pad_h + elif pad_h > 0: + arr = np.asarray(scaled_colors) + arr = np.pad(arr, ((0, pad_h), (0, 0), (0, 0)), constant_values=0) + scaled_colors = arr + h = scaled_colors.shape[0] + + cdef uint8_t[:, ::1] _indices + cdef float[:, ::1] _palette + if use_precomputed: + _indices = indices + _palette = palette + else: + indices, palette = quantize_colors(scaled_colors, max_colors) + _indices = indices + _palette = palette + + cdef int n_colors = _palette.shape[0] + cdef int i, band_y + + parts = [] + + # DCS header + parts.append(DCS_START) + parts.append(f'"1;1;{w};{h}'.encode()) + + # Palette + for i in range(n_colors): + parts.append( + f"#{i};2;{(_palette[i, 0] * 100)};" + f"{(_palette[i, 1] * 100)};{(_palette[i, 2] * 100)}".encode() + ) + + # Encode bands — single-pass sixel pattern computation per band + cdef uint8_t[:, ::1] band + cdef uint8_t[:] sixel_bits = SIXEL_BITS + cdef uint8_t[:, ::1] patterns + cdef int y, x + cdef uint8_t c + + for band_y in range(0, h, 6): + band = _indices[band_y:band_y + 6, :] + patterns = np.zeros((256, w), dtype=np.uint8) + + # Single pass: compute sixel patterns for all colors at once + for y in range(band.shape[0]): + for x in range(w): + c = band[y, x] + if c != skip_index: + patterns[c, x] |= sixel_bits[y] + + unique_colors = np.unique(np.asarray(band)) + for color_idx in unique_colors: + if color_idx == skip_index: + continue + parts.append(f"#{color_idx}".encode()) + + if w == 0: + continue + + parts.append(_sixel_rle(patterns[color_idx], w)) + parts.append(b"$") + parts.append(b"-") + + parts.append(DCS_END) + return b"".join(parts) + + +cdef int _sixel_rle_emit(unsigned char[:] buf, int pos, uint8_t pat, int length): + """Emit a single sixel RLE run into *buf* starting at *pos*. Returns new position.""" + cdef int n, ndigits + cdef unsigned char digits[4] + cdef unsigned char ch = (0x3F + pat) + + if length <= 3: + for n in range(length): + buf[pos] = ch + pos += 1 + else: + buf[pos] = 0x21 # '!' + pos += 1 + ndigits = 0 + n = length + while n > 0: + digits[ndigits] = 0x30 + (n % 10) + ndigits += 1 + n //= 10 + for n in range(ndigits - 1, -1, -1): + buf[pos] = digits[n] + pos += 1 + buf[pos] = ch + pos += 1 + return pos + + +cdef bytes _sixel_rle(uint8_t[:] patterns, int w): + """Run-length encode a row of sixel patterns.""" + cdef int start = 0 + cdef int i + cdef uint8_t cur = patterns[0] + cdef unsigned char[:] buf = bytearray(w * 4) + cdef int pos = 0 + + for i in range(1, w): + if patterns[i] != cur: + pos = _sixel_rle_emit(buf, pos, cur, i - start) + cur = patterns[i] + start = i + pos = _sixel_rle_emit(buf, pos, cur, w - start) + + return bytes(buf[:pos]) + + +def encode_kitty_rgba(const unsigned char[:] rgba_data, int w, int h, + int image_id=1, int X=0, int Y=0, int z=0, + int placement_id=0): + """Encode RGBA bytes as kitty f=32 escape sequence.""" + cdef bytes compressed = zlib.compress(rgba_data, ZLIB_LEVEL) + payload_b64 = base64.b64encode(compressed).decode() + cdef str control = f"a=T,i={image_id},q=1,f=32,s={w},v={h},o=z,N=1" + if z: + control += f",z={z}" + if X or Y: + control += f",X={X},Y={Y}" + if placement_id: + control += f",p={placement_id}" + return f"{APC_START.decode()}{control};{payload_b64}{APC_END.decode()}".encode() diff --git a/termblit_ext/termblit.pyx b/termblit_ext/termblit.pyx index a8682d3..feda5fb 100644 --- a/termblit_ext/termblit.pyx +++ b/termblit_ext/termblit.pyx @@ -143,12 +143,21 @@ cdef char* move_from_to( return move_relative(buff, to_x - from_x, to_y - from_y) +cdef inline uint32_t blit_vis_mode1(uint32_t c) noexcept nogil: + return c ^ 0x00AA5500u + + +cdef inline uint32_t blit_vis_mode2(uint32_t c) noexcept nogil: + return c ^ 0x0000AA55u + + @boundscheck(False) cdef char* _blit( uint32_t[:, ::1] image, uint32_t[:, ::1] last, int refx, int refy, int width, int height, int color_mode, + bint blitter_vis, char* base, ) noexcept nogil: @@ -185,6 +194,13 @@ cdef char* _blit( ): continue + if blitter_vis == 1: + color1 = blit_vis_mode1(color1) + color2 = blit_vis_mode1(color2) + elif blitter_vis == 2: + color1 = blit_vis_mode2(color1) + color2 = blit_vis_mode2(color2) + # Go to the new position new_x, new_y = row_index + refx, column_index + refy result = move_from_to(result, current_x, current_y, new_x, new_y) @@ -240,12 +256,13 @@ def blit( uint32_t[:, ::1] last, int refx, int refy, int width, int height, int color_mode, + int blitter_vis=0, ): cdef char* base with nogil: base = malloc(image.shape[0] * image.shape[1] * 30) - result = _blit(image, last, refx, refy, width, height, color_mode, base) + result = _blit(image, last, refx, refy, width, height, color_mode, blitter_vis, base) try: return base[:result - base] diff --git a/tests/_frontend_ssh_server.py b/tests/_frontend_ssh_server.py index 2a71fba..56ea339 100644 --- a/tests/_frontend_ssh_server.py +++ b/tests/_frontend_ssh_server.py @@ -9,7 +9,6 @@ from gambaterm.main import AppConfig from gambaterm.remote_terminal import RemoteTerminal, KeyboardSupportDetection - rom = Path(sys.argv[1]) diff --git a/tests/_frontend_telnet_server.py b/tests/_frontend_telnet_server.py index 350b4e9..3a4e988 100644 --- a/tests/_frontend_telnet_server.py +++ b/tests/_frontend_telnet_server.py @@ -9,7 +9,6 @@ from gambaterm.main import AppConfig from gambaterm.remote_terminal import RemoteTerminal, KeyboardSupportDetection - rom = Path(sys.argv[1]) diff --git a/tests/frames/input.raw b/tests/frames/input.raw new file mode 100644 index 0000000..0db06ef Binary files /dev/null and b/tests/frames/input.raw differ diff --git a/tests/frames/kitty-x1.golden b/tests/frames/kitty-x1.golden new file mode 100644 index 0000000..61d44dc --- /dev/null +++ b/tests/frames/kitty-x1.golden @@ -0,0 +1 @@ +_Ga=T,i=1,q=1,f=32,s=160,v=144,o=z,N=1;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\ \ No newline at end of file diff --git a/tests/frames/kitty-x2.golden b/tests/frames/kitty-x2.golden new file mode 100644 index 0000000..0c8ba57 --- /dev/null +++ b/tests/frames/kitty-x2.golden @@ -0,0 +1 @@ +_Ga=T,i=1,q=1,f=32,s=320,v=288,o=z,N=1;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\ \ No newline at end of file diff --git a/tests/frames/kitty-x3.golden b/tests/frames/kitty-x3.golden new file mode 100644 index 0000000..cc00766 --- /dev/null +++ b/tests/frames/kitty-x3.golden @@ -0,0 +1 @@ +_Ga=T,i=1,q=1,f=32,s=480,v=432,o=z,N=1;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\ \ No newline at end of file diff --git a/tests/frames/sixel-x1.golden b/tests/frames/sixel-x1.golden new file mode 100644 index 0000000..091cb26 --- /dev/null +++ b/tests/frames/sixel-x1.golden @@ -0,0 +1 @@ +P0;1;0q"1;1;160;144#0;2;8;8;8#1;2;8;8;25#2;2;8;8;41#3;2;8;8;58#4;2;8;8;75#5;2;8;8;91#6;2;8;25;8#7;2;8;25;25#8;2;8;25;41#9;2;8;25;58#10;2;8;25;75#11;2;8;25;91#12;2;8;41;8#13;2;8;41;25#14;2;8;41;41#15;2;8;41;58#16;2;8;41;75#17;2;8;41;91#18;2;8;58;8#19;2;8;58;25#20;2;8;58;41#21;2;8;58;58#22;2;8;58;75#23;2;8;58;91#24;2;8;75;8#25;2;8;75;25#26;2;8;75;41#27;2;8;75;58#28;2;8;75;75#29;2;8;75;91#30;2;8;91;8#31;2;8;91;25#32;2;8;91;41#33;2;8;91;58#34;2;8;91;75#35;2;8;91;91#36;2;25;8;8#37;2;25;8;25#38;2;25;8;41#39;2;25;8;58#40;2;25;8;75#41;2;25;8;91#42;2;25;25;8#43;2;25;25;25#44;2;25;25;41#45;2;25;25;58#46;2;25;25;75#47;2;25;25;91#48;2;25;41;8#49;2;25;41;25#50;2;25;41;41#51;2;25;41;58#52;2;25;41;75#53;2;25;41;91#54;2;25;58;8#55;2;25;58;25#56;2;25;58;41#57;2;25;58;58#58;2;25;58;75#59;2;25;58;91#60;2;25;75;8#61;2;25;75;25#62;2;25;75;41#63;2;25;75;58#64;2;25;75;75#65;2;25;75;91#66;2;25;91;8#67;2;25;91;25#68;2;25;91;41#69;2;25;91;58#70;2;25;91;75#71;2;25;91;91#72;2;41;8;8#73;2;41;8;25#74;2;41;8;41#75;2;41;8;58#76;2;41;8;75#77;2;41;8;91#78;2;41;25;8#79;2;41;25;25#80;2;41;25;41#81;2;41;25;58#82;2;41;25;75#83;2;41;25;91#84;2;41;41;8#85;2;41;41;25#86;2;41;41;41#87;2;41;41;58#88;2;41;41;75#89;2;41;41;91#90;2;41;58;8#91;2;41;58;25#92;2;41;58;41#93;2;41;58;58#94;2;41;58;75#95;2;41;58;91#96;2;41;75;8#97;2;41;75;25#98;2;41;75;41#99;2;41;75;58#100;2;41;75;75#101;2;41;75;91#102;2;41;91;8#103;2;41;91;25#104;2;41;91;41#105;2;41;91;58#106;2;41;91;75#107;2;41;91;91#108;2;58;8;8#109;2;58;8;25#110;2;58;8;41#111;2;58;8;58#112;2;58;8;75#113;2;58;8;91#114;2;58;25;8#115;2;58;25;25#116;2;58;25;41#117;2;58;25;58#118;2;58;25;75#119;2;58;25;91#120;2;58;41;8#121;2;58;41;25#122;2;58;41;41#123;2;58;41;58#124;2;58;41;75#125;2;58;41;91#126;2;58;58;8#127;2;58;58;25#128;2;58;58;41#129;2;58;58;58#130;2;58;58;75#131;2;58;58;91#132;2;58;75;8#133;2;58;75;25#134;2;58;75;41#135;2;58;75;58#136;2;58;75;75#137;2;58;75;91#138;2;58;91;8#139;2;58;91;25#140;2;58;91;41#141;2;58;91;58#142;2;58;91;75#143;2;58;91;91#144;2;75;8;8#145;2;75;8;25#146;2;75;8;41#147;2;75;8;58#148;2;75;8;75#149;2;75;8;91#150;2;75;25;8#151;2;75;25;25#152;2;75;25;41#153;2;75;25;58#154;2;75;25;75#155;2;75;25;91#156;2;75;41;8#157;2;75;41;25#158;2;75;41;41#159;2;75;41;58#160;2;75;41;75#161;2;75;41;91#162;2;75;58;8#163;2;75;58;25#164;2;75;58;41#165;2;75;58;58#166;2;75;58;75#167;2;75;58;91#168;2;75;75;8#169;2;75;75;25#170;2;75;75;41#171;2;75;75;58#172;2;75;75;75#173;2;75;75;91#174;2;75;91;8#175;2;75;91;25#176;2;75;91;41#177;2;75;91;58#178;2;75;91;75#179;2;75;91;91#180;2;91;8;8#181;2;91;8;25#182;2;91;8;41#183;2;91;8;58#184;2;91;8;75#185;2;91;8;91#186;2;91;25;8#187;2;91;25;25#188;2;91;25;41#189;2;91;25;58#190;2;91;25;75#191;2;91;25;91#192;2;91;41;8#193;2;91;41;25#194;2;91;41;41#195;2;91;41;58#196;2;91;41;75#197;2;91;41;91#198;2;91;58;8#199;2;91;58;25#200;2;91;58;41#201;2;91;58;58#202;2;91;58;75#203;2;91;58;91#204;2;91;75;8#205;2;91;75;25#206;2;91;75;41#207;2;91;75;58#208;2;91;75;75#209;2;91;75;91#210;2;91;91;8#211;2;91;91;25#212;2;91;91;41#213;2;91;91;58#214;2;91;91;75#215;2;91;91;91#0!49?C}??WSQ}?WSQ}?{ccW!4?AA}AA?wSSW?GSSc?C}C!4?cIIQc?{??{?y?C}C?wSSW!45?$#84!41?oGCA@@!113?$#141!41~NFB@!115?$#215!42?ow{}}~~z@~~fjl@~fjl@~BZZf!4~||@||~Fjjf~vjjZ~z@z!4~ZttlZ~B~~B~D~z@z~Fjjf!45~$-#0!50?@!5?@!4?@?B!9?@!4?@@@?@@@!4?@!5?@@@???@@@?@???@??@@@!45?$#84!40?~!119?$#141!40~!8?!112_$#215!41?!7~^^]!5^]!4^]^[!9^]!4^]]]^]]]!4^]!5^]]]^^^]]]^]^^^]^^]]]!45^$-#0!54?__!5?__???__!92?$#84!40?~!119?$#141!40~!120?$#215!41?!13~^^!5~^^~~~^^!92~$-#0!48?FWWF?NOON?O?XSSR?GQQL!91?$#84!40?~!119?$#141!40~!120?$#215!41?!7~wffw~onno~n~ejjk~vllq!91~$-#0!48?cIIQc?{AAQs?}IIIs!4?GSSc?wCCw?{??{?{CCw?wCC}!67?$#84!40?~!119?$#141!40~!120?$#215!41?!7~ZttlZ~B||lJ~@tttJ!4~vjjZ~FzzF~B~~B~BzzF~Fzz@!67~$-#0!19?___!4O___!20?@@@???!4@?!4@!5?@@@???@@???@@@?@??@??@@@!67?$#3!18?_???_??_???_!130?$#47!17?_???O?__?O???_!129?$#84!40?~!119?$#141!17~!4^!6N!4^!9~!120?$#215!41?!8~}}}~~~!4}~!4}!5~}}}~~~}}~~~}}}~}~~}~~}}}!67~$-#0!11?_OGCA@@@!4?__!4?@@@ACGO_!13?___!7?__???__!4?_!4?_!8?__???__!4?_???__!58?$#3!12?gSIpa?_`_ooWWoo_`_?apISg!124?$#47!11?O?_oK[}]]^NNFFNN^]]}[Ko_?O!123?$#84!40?~!119?$#141!11~NFB@!18?@BFN~~~!120?$#215!41?!9~^^^!7~^^~~~^^!4~^!4~^!8~^^~~~^^!4~^~~~^^!58~$-#0!11?NOoGStIIj!8djIItSGoON!11?MPciiPM!4?XSSR?NOON??@^???@^??!4C?XSSR?NOON??@^??NQQK!57?$#3!12?GGsiITDCI!6AICDTIisGG!124?$#47!11?OfFB@?_oOO!6WOOo_?@BFfO!123?$#84!40?~!119?$#141!11~_!24?_~~~!120?$#215!41?!7~pmZTTmp!4~ejjk~onno~~}_~~~}_~~!4z~ejjk~onno~~}_~~ollr!57~$-#0!12?oHFN?@dRq_!4?_qRd@?NFHo!18?o[Q[o?{CC?C}C?wSSW?{CwCw?y?wCCw!4?}???}?{CC?}CCw?y?{CCw?_SSw!45?$#84!40?~!119?$#141!12~F!22?F!4~!120?$#164!12?GUgOF??gHV!4@VHg??FOgUG!124?$#208!13?_O_w}YCCG!4}GCCY}w_O_!125?$#215!41?!13~NblbN~Bzz~z@z~Fjjf~BzFzF~D~FzzF!4~@~~~@~Bzz~@zzF~D~BzzF~^jjF!45~$-#0!12?BCGW_??@@?CAAC?@@??_WGCB!12?_OGggO`???@OHGo?oGHo?@Px?@ohgH?@??@@OGGo?oHHp?OHGo?@hhO?@?@??@??@@@!45?$#84!40?~!119?$#141!12~{wo_!16?_ow{!4~!120?$#164!13?ADAO_@AA@!4?@AA@_OADA!125?$#208!13?@ADN^}{{}z||z}{{}^NDA@!125?$#215!41?!7~^nvVVn]~~~}nuvN~NvuN~}mE~}NUVu~}~~}}nvvN~NuuM~nuvN~}UUn~}~}~~}~~}}}!45~$-#0!12?!4_PQ[__@?AA?@__[QP!4_!12?BCHIICb___?EDDC?BCCB???f??ACCB?!4@?EDdC?BcCB?EDDC?ACCB??_!6?_!47?$#84!40?~!119?$#141!12~!4^MK!12?KM!4^!4~!120?$#150!16?_!14?_!128?$#164!17?@A[WwroorwW[A@!129?$#169!17?_!12?_!129?$#208!18?@BFEKLLKEFB@!130?$#213!18?_!10?_!130?$#215!41?!7~{zuttz[^^^~xyyz~{zz{~~~W~~|zz{~!4}~xyYz~{Zz{~xyyz~|zz{~~^!6~^!47~$-#0!4?_oWCAFX`@!4?B[_?@@??@@?_[B!4?@`XFA!14?^OOON?GTT]?^@]@]?]?GTT]?^@@]!5?@]@??MTTU?^@@?^@@?]?MPPI?^CIP!44?$#84!40?~!119?$#141!4~^NFB@!30?@!120?$#150!10?E]}!4~{_!10?_{!4~}]E!122?$#164!23?@@!135?$#169!7?GC?_!7?AO@?!4A?@OA!7?_?C!120?$#213!6?_oww!8?@N}}!4{}}N@!8?ww!120?$#215!41?!13~_nnno~vii`~_}`}`~`~vii`~_}}`!5~}`}~~piih~_}}~_}}~`~pmmt~_ztm!44~$-#0??oMB!6?N!7?BKo!4?oKB!7?N!11?AA}AA?}CCw?y?GSSc!4?y?GSSc!4?G{IA{CC?wSSW?wSSW!4?GSSc?wCCw?G{IAC}C?[_W_[?_SSw?{CC?wSSW?C!4?GSSc?wSSW?wSSW???$#84!40?~!119?$#141~~N@!156?$#150!12?!7~{o!6?o{!7~!124?$#169???OC@!4?Bo!8?AG!4?GA!8?oB!122?$#213???_w}!4~{!9?@F!4~F@!9?{~~!120?$#215!41?!7~||@||~@zzF~D~vjjZ!4~D~vjjZ!4~vBt|Bzz~Fjjf~Fjjf!4~vjjZ~FzzF~vBt|z@z~b^f^b~^jjF~Bzz~Fjjf~z!4~vjjZ~Fjjf~Fjjf~~~$-#0_]B!8?o!9?~!4?~!9?o!11?OwP?wOP_?`OP_@@@?_OOO@_PP`?o??o?pOO@_OO_@`PO_@@@?_ogH`PO_?pPO??@?_OO`?_PO`?oOP`@o@?o?g@pPOb?_OOp@@???@@@??@@@???$#84!40?~!119?$#141^@!158?$#150!12?!9~!6?!9~!124?$#169?_C!8?N!10?~??~!10?N!123?$#213??w!8~!12?~~!12?~~~!120?$#215!41?!7~nFm~Fnm^~]nm^}}}~^nnn}^mm]~N~~N~Mnn}^nn^}]mn^}}}~^NVu]mn^~Mmn~~}~^nn]~^mn]~Nnm]}N}~N~V}Mmn[~^nnM}}~~~}}}~~}}}~~~$-#0N!9?oN!9?~!4?~!9?No!11?BC?F??F?BDDD!4?CdDa?BcCB_BCCF?F???BCCA?BDDD???__f__BcCB?F!6?BCCA?BCCB?NAA@?GDB??F?F??F?@IIF!16?$#84!40?~!119?$#150!11?o!9~!6?!9~o!123?$#164o!159?$#169?@!8?N!11?@??@!11?N!122?$#213?}!8~!12?}~~}!12?~~!120?$#215!41?!8~{z~w~~w~{yyy!4~zYy\~{Zz{^{zzw~w~~~{zz|~{yyy~~~^^W^^{Zz{~w!6~{zz|~{zz{~o||}~vy{~~w~w~~w~}ttw!16~$-#0N!4@AAAMA@!9?}@!4?@}!9?@AM!8?MPPI?MPPM?^@@]?MPP^?]?@NP?]?MPPM?^@@]?QTTH?O!6?^???^@@]?MTTU!4?~HHE?^@@?MPPM?Ehh^?^@@?GTT]?^@]@]!12?$#84!40?~!119?$#150!8?o{}!9~@!6?@!9~}{o!120?$#164oEAAADDc!152?$#169!21?}!4?}!133?$#208?w{{{wwW!152?$#213!7?@@@!12?!4~!12?@@!120?$#215!41?!7~pmmt~pmmp~_}}`~pmm_~`~}om~`~pmmp~_}}`~liiu~n!6~_~~~_}}`~piih!4~?uux~_}}~pmmp~xUU_~_}}~vii`~_}`}`!12~$-#0[_!6?EGO_!7?_^!6?^_!7?_OGE!8?y?GSSc!4?{ccW?{CC?wCCw?[__[?y?wCC}?wSSW?wCC}!4?o[Q[o?cIIQc!4?}?cIIQc!4?[_W_[?y?C}C?}CCw!4?}CW_}?{AAA{!6?$#84_!39?~!119?$#150!8?@FN^!7~^!8?^!7~^NF@!120?$#164B[o_???B??_!26?_!122?$#169!20?_!6?_!132?$#208?BN^~~~{wo!28?ow!120?$#213!21?!6~!133?$#215!41?!7~D~vjjZ!4~BZZf~Bzz~FzzF~b^^b~D~Fzz@~Fjjf~Fzz@!4~NblbN~ZttlZ!4~@~ZttlZ!4~b^f^b~D~z@z~@zzF!4~@zf^@~B|||B!6~$-#0??@ACGOo_???@ACG?AQ`!8?`QA?GCA@???_!8?p?`@p??OO_BoOO?pOO??PP_?oPP_?PwO@p@?o@@@?o@`@o?wOP_??PO_@PxO?_OOP?_PP`?_OO_?p?@o?`OO_@oPO?@!4?@???@??@@@!7?$#2!19?O!8_O!131?$#84~~}{wo_!33?~!119?$#150!13?@BBB@@!10?@@BBB@!125?$#164???@BEKKWo_`ACGS[s_?O!6?O?_s[SGCA`_oW!120?$#208!5?@BBFN^]{wo__!14?__ow{]^NF!120?$#213!17?GKMN!6^NMKG!129?$#215!41?!7~M~]}M~~nn^{Nnn~Mnn~~mm^~Nmm^~mFn}M}~N}}}~N}]}N~Fnm^~~mn^}mEn~^nnm~^mm]~^nn^~M~}N~]nn^}Nmn~}!4~}~~~}~~}}}!7~$-#0!7?^vN]{o!4?Gw?@A!4MA@?wG!4?o{]Nv!8?@E@E@?ADDF?F???F???ADDF?F??F??BC?GDB!5?@E@E@?F??F?ADDF??BC?CDDA?BCCB?BDDD?@EE@?BDDD?F???C!21?$#2!8?Go_!10?!6@!10?_oG!120?$#84!7~_!32?~!119?$#164!10?@BNo_?]sCHIK!4?KIHCs]?_oNB@!122?$#172!19?!10O!131?$#208!13?N^~`BBEC!6?CEBB`~^N!125?$#215!19?!10_!12?!7~}x}x}~|yyw~w~~~w~~~|yyw~w~~w~~{z~vy{!5~}x}x}~w~~w~|yyw~~{z~zyy|~{zz{~{yyy~}xx}~{yyy~w~~~z!21~$-#0???_?_?_Pf[wp`BAAQAAAb`@@`bAAAQAAB`pw[fP!120?$#2!10?BFM]{{{k{{{[]}}][{{{k{{{]MFB!122?$#84~~~^~^~^mW_!26?_Wm~!119?$#164!15?@???@@!6?@@???@!127?$#208!16?@@@!10?@@@!128?$#215!41?!119~$-#0DITMVM^m^n^n^~^~]}]!10{]}]~^~^n^n^!120?$#2!16?@@@!10B@@@!128?$#84ytipgp_P_O_O_?_?_?_!10?_?_?_?_O_O_~!119?$#215!41?!119~$-#0!12?@?@?@?@?@?@??@?@?@?@?@?@!124?$#84!12~}~}~}~}~}~}~~}~}~}~}~}~}!5~!119?$#141!48?!112_$#215!41?!7~!112^$-#84!41~!119?$#215!41?!119~$-#84!42~{wo__!113?$#215!42?BFN^^!113~$-\ \ No newline at end of file diff --git a/tests/frames/sixel-x2.golden b/tests/frames/sixel-x2.golden new file mode 100644 index 0000000..c32e812 --- /dev/null +++ b/tests/frames/sixel-x2.golden @@ -0,0 +1 @@ +P0;1;0q"1;1;320;288#0;2;8;8;8#1;2;8;8;25#2;2;8;8;41#3;2;8;8;58#4;2;8;8;75#5;2;8;8;91#6;2;8;25;8#7;2;8;25;25#8;2;8;25;41#9;2;8;25;58#10;2;8;25;75#11;2;8;25;91#12;2;8;41;8#13;2;8;41;25#14;2;8;41;41#15;2;8;41;58#16;2;8;41;75#17;2;8;41;91#18;2;8;58;8#19;2;8;58;25#20;2;8;58;41#21;2;8;58;58#22;2;8;58;75#23;2;8;58;91#24;2;8;75;8#25;2;8;75;25#26;2;8;75;41#27;2;8;75;58#28;2;8;75;75#29;2;8;75;91#30;2;8;91;8#31;2;8;91;25#32;2;8;91;41#33;2;8;91;58#34;2;8;91;75#35;2;8;91;91#36;2;25;8;8#37;2;25;8;25#38;2;25;8;41#39;2;25;8;58#40;2;25;8;75#41;2;25;8;91#42;2;25;25;8#43;2;25;25;25#44;2;25;25;41#45;2;25;25;58#46;2;25;25;75#47;2;25;25;91#48;2;25;41;8#49;2;25;41;25#50;2;25;41;41#51;2;25;41;58#52;2;25;41;75#53;2;25;41;91#54;2;25;58;8#55;2;25;58;25#56;2;25;58;41#57;2;25;58;58#58;2;25;58;75#59;2;25;58;91#60;2;25;75;8#61;2;25;75;25#62;2;25;75;41#63;2;25;75;58#64;2;25;75;75#65;2;25;75;91#66;2;25;91;8#67;2;25;91;25#68;2;25;91;41#69;2;25;91;58#70;2;25;91;75#71;2;25;91;91#72;2;41;8;8#73;2;41;8;25#74;2;41;8;41#75;2;41;8;58#76;2;41;8;75#77;2;41;8;91#78;2;41;25;8#79;2;41;25;25#80;2;41;25;41#81;2;41;25;58#82;2;41;25;75#83;2;41;25;91#84;2;41;41;8#85;2;41;41;25#86;2;41;41;41#87;2;41;41;58#88;2;41;41;75#89;2;41;41;91#90;2;41;58;8#91;2;41;58;25#92;2;41;58;41#93;2;41;58;58#94;2;41;58;75#95;2;41;58;91#96;2;41;75;8#97;2;41;75;25#98;2;41;75;41#99;2;41;75;58#100;2;41;75;75#101;2;41;75;91#102;2;41;91;8#103;2;41;91;25#104;2;41;91;41#105;2;41;91;58#106;2;41;91;75#107;2;41;91;91#108;2;58;8;8#109;2;58;8;25#110;2;58;8;41#111;2;58;8;58#112;2;58;8;75#113;2;58;8;91#114;2;58;25;8#115;2;58;25;25#116;2;58;25;41#117;2;58;25;58#118;2;58;25;75#119;2;58;25;91#120;2;58;41;8#121;2;58;41;25#122;2;58;41;41#123;2;58;41;58#124;2;58;41;75#125;2;58;41;91#126;2;58;58;8#127;2;58;58;25#128;2;58;58;41#129;2;58;58;58#130;2;58;58;75#131;2;58;58;91#132;2;58;75;8#133;2;58;75;25#134;2;58;75;41#135;2;58;75;58#136;2;58;75;75#137;2;58;75;91#138;2;58;91;8#139;2;58;91;25#140;2;58;91;41#141;2;58;91;58#142;2;58;91;75#143;2;58;91;91#144;2;75;8;8#145;2;75;8;25#146;2;75;8;41#147;2;75;8;58#148;2;75;8;75#149;2;75;8;91#150;2;75;25;8#151;2;75;25;25#152;2;75;25;41#153;2;75;25;58#154;2;75;25;75#155;2;75;25;91#156;2;75;41;8#157;2;75;41;25#158;2;75;41;41#159;2;75;41;58#160;2;75;41;75#161;2;75;41;91#162;2;75;58;8#163;2;75;58;25#164;2;75;58;41#165;2;75;58;58#166;2;75;58;75#167;2;75;58;91#168;2;75;75;8#169;2;75;75;25#170;2;75;75;41#171;2;75;75;58#172;2;75;75;75#173;2;75;75;91#174;2;75;91;8#175;2;75;91;25#176;2;75;91;41#177;2;75;91;58#178;2;75;91;75#179;2;75;91;91#180;2;91;8;8#181;2;91;8;25#182;2;91;8;41#183;2;91;8;58#184;2;91;8;75#185;2;91;8;91#186;2;91;25;8#187;2;91;25;25#188;2;91;25;41#189;2;91;25;58#190;2;91;25;75#191;2;91;25;91#192;2;91;41;8#193;2;91;41;25#194;2;91;41;41#195;2;91;41;58#196;2;91;41;75#197;2;91;41;91#198;2;91;58;8#199;2;91;58;25#200;2;91;58;41#201;2;91;58;58#202;2;91;58;75#203;2;91;58;91#204;2;91;75;8#205;2;91;75;25#206;2;91;75;41#207;2;91;75;58#208;2;91;75;75#209;2;91;75;91#210;2;91;91;8#211;2;91;91;25#212;2;91;91;41#213;2;91;91;58#214;2;91;91;75#215;2;91;91;91#0!98?oo{{!6?ooKK{{!4?ooKK{{??!6o!10?!4K{{!4K!4?!4o!6?!6o??oo{{oo!8?oo!6Koo??oo!4?oo??KK??oo{{oo!4?!4o!92?$#84!86?ooKK!4B!226?$#141!86~NNBB!230?$#215!88?oo!4{!4~NNBB!6~NNrrBB!4~NNrrBB~~!6N!10~!4rBB!4r!4~!4N!6~!6N~~NNBBNN!8~NN!6rNN~~NN!4~NN~~rr~~NNBBNN!4~!4N!92~$-#0!100?~~!4?NN!4K~~??NN!4K~~??~~!4oNN!12?~~!6?~~!4KNN??BB!4Koo!4?~~!10?oo!4BKKoo??~~!4?~~??~~!4?~~!4?~~!4KNN!90?$#84!82?{{BB!234?$#141!82~BB!236?$#215!84?{{!14~??!4~oo!4r??~~oo!4r??~~??!4Noo!12~??!6~??!4roo~~{{!4rNN!4~??!10~NN!4{rrNN~~??!4~??~~??!4~??!4~??!4roo!90~$-#0!100?BB!10?BB!8?BB??NN!18?BB!8?!6B??!6B!8?BB!10?!6B!6?!6B??BB!6?BB!4?!6B!90?$#84!80?~~!238?$#141!80~!240?$#215!82?!18~{{!10~{{!8~{{~~oo!18~{{!8~!6{~~!6{!8~{{!10~!6{!6~!6{~~{{!6~{{!4~!6{!90~$-#84!80?~~!238?$#141!80~!16?!224o$#215!82?!14~!224N$-#84!80?~~!238?$#141!80~!240?$#215!82?!238~$-#0!108?!4o!10?!4o!6?!4o!184?$#84!80?~~!238?$#141!80~!240?$#215!82?!26~!4N!10~!4N!6~!4N!184~$-#0!96?~~!4?~~??~~!4?~~!6?BB!4oNN!4?!4Krr!182?$#84!80?~~!238?$#141!80~!240?$#215!82?!14~??!4~??~~??!4~??!6~{{!4Noo!4~!4rKK!182~$-#0!98?!4N!4?BB!4KBB??KK??NN!6K??BB!4KBB!182?$#84!80?~~!238?$#141!80~!240?$#215!82?!16~!4o!4~{{!4r{{~~rr~~oo!6r~~{{!4r{{!182~$-#0!96?oo!6Koo??oo!6Koo??{{!6Koo!10?!6o!4?!4o!4?oo!4?oo??!6o!6?!4o{{!134?$#84!80?~~!238?$#141!80~!240?$#215!82?!14~NN!6rNN~~NN!6rNN~~BB!6rNN!10~!6N!4~!4N!4~NN!4~NN~~!6N!6~!4NBB!134~$-#0!96?oo!4BKKoo??~~!4?KK{{??~~!6B{{!8?BB!4Koo??~~!4?~~??~~!4?~~??~~!4?~~??~~!4?~~!134?$#84!80?~~!238?$#141!80~!240?$#215!82?!14~NN!4{rrNN~~??!4~rrBB~~??!6{BB!8~{{!4rNN~~??!4~??~~??!4~??~~??!4~??~~??!4~??!134~$-#0!98?!6B!6?!8B??!8B!10?!6B!6?!4B!6?!6B??BB!4?BB!4?!6B!134?$#84!80?~~!238?$#141!80~!240?$#215!82?!16~!6{!6~!8{~~!8{!10~!6{!6~!4{!6~!6{~~{{!4~{{!4~!6{!134~$-#0!38?!6o!8K!6o!262?$#3!36?oo!6?oo!4?oo!6?oo!260?$#47!34?oo!6?KK??!4o??KK!6?oo!258?$#84!80?~~!238?$#141!34~!8N!12B!8N!18~!240?$#215!82?!238~$-#0!28?ooKK!6B!20?!6BKKoo!252?$#3!26?ooKKBBKK!4?BB!16?BB!4?KKBBKKoo!250?$#47!30?!4o!6{!16~!6{!4o!254?$#84!80?~~!238?$#141!26~NNBB!36?BBNN!10~!240?$#215!82?!238~$-#0!22?ooKKBB!18?!4o!18?BBKKoo!26?!6o!14?!4o!6?!4o!8?oo!8?oo!16?!4o!6?!4o!8?oo!6?!4o!116?$#3!24?rrKKBB{{oo??!6o!4{!4N!4{!6o??oo{{BBKKrr!248?$#47!22?KK??oo{{BBNN~~!6N!4B!4?!4B!6N~~NNBB{{oo??KK!246?$#84!80?~~!238?$#141!22~BB!48?BB!6~!240?$#215!82?!18~!6N!14~!4N!6~!4N!8~NN!8~NN!16~!4N!6~!4N!8~NN!6~!4N!116~$-#0!22?~~!6?oorr!4KNN!16rNN!4Krroo!6?~~!22?{{BBoo!4KBB{{!8?BB!4oNN??~~!4?~~!4?BB~~!6?BB~~!4?!8o??BB!4oNN??~~!4?~~!4?BB~~!4?~~!4Koo!114?$#3!28?oo!4K!4roo!16Koo!4r!4Koo!252?$#47!24?!4~NNBB!32?BBNN!4~!248?$#84!80?~~!238?$#141!22~!52?!6~!240?$#215!82?!14~BB{{NN!4r{{BB!8~{{!4Noo~~??!4~??!4~{{??!6~{{??!4~!8N~~{{!4Noo~~??!4~??!4~{{??!4~??!4rNN!114~$-#0!22?BBKK{{BBKK{{!4Brr!16orr!4B{{KKBB{{KKBB!22?BBKKoo!4rKKBB!8?NN!6K??BB!4KBB!6?NN!8?NN!14?NN!6K??BB!4KBB!6?NN!4?BB!4KBB!114?$#3!24?!4B{{rrBBKK!4?BB!12?BB!4?KKBBrr{{!4B!248?$#47!22?KKoo!8?oo{{!4K!12N!4K{{oo!8?ooKK!246?$#84!80?~~!238?$#141!22~oo!48?oo!6~!240?$#215!82?!14~{{rrNN!4Krr{{!8~oo!6r~~{{!4r{{!6~oo!8~oo!14~oo!6r~~{{!4r{{!6~oo!4~{{!4r{{!114~$-#0!26?BB!4~??BBrrNNKK!12?KKNNrrBB??!4~BB!40?ooKKoo!4?!6o??oo{{oo!4?!4o!4?!4o??oo!4?KK!4?!4o!10?{{!6?{{??!6o??{{!4o!4?KK??!6o!6?!4o!92?$#84!80?~~!238?$#141!26~!44?!10~!240?$#164!26?{{!4?~~!6?BB~~!8B~~BB!6?~~!4?{{!250?$#208!34?{{KK!4o??!8{??!4oKK{{!258?$#215!82?!28~NNrrNN!4~!6N~~NNBBNN!4~!4N!4~!4N~~NN!4~rr!4~!4N!10~BB!6~BB~~!6N~~BB!4N!4~rr~~!6N!6~!4N!92~$-#0!24?{{BB??BB!4?ooKK{{oo!8?oo{{KKoo!4?BB??BB{{!36?{{NNKKNN{{??~~!8?~~!4?~~!4KNN??~~??~~??~~??~~??~~!4?~~!8?~~!6?~~??~~!6?~~!4?~~??~~??~~!4?~~??oo!4K~~!90?$#84!80?~~!238?$#141!24~!48?!8~!240?$#164!24?BBKKrrKK!6?rrBBKK!8?KKBBrr!6?KKrrKKBB!248?$#208!26?ooKKoo!4~NN!4?BB!8~BB!4?NN!4~ooKKoo!250?$#215!82?!26~BBoorrooBB~~??!8~??!4~??!4roo~~??~~??~~??~~??~~??!4~??!8~??!6~??~~??!6~??!4~??~~??~~??!4~??~~NN!4r??!90~$-#0!24?NNoo!10?!4B??oo!4Koo??!4B!10?ooNN!36?BB!6?BB??BB!10?BB!4?!6B??BB??BB??BB??BB!4?!4B!12?!6B!4?BB!6?!6B!4?BB??BB!4?BB!4?!6B!90?$#84!80?~~!238?$#141!24~oo!44?oo!8~!240?$#164!26?KKrrKK!4?BB!4KBB!8?BB!4KBB!4?KKrrKK!250?$#208!26?BBKKrr!4~{{!4o{{NN!4rNN{{!4o{{!4~rrKKBB!250?$#215!82?!26~{{!6~{{~~{{!10~{{!4~!6{~~{{~~{{~~{{~~{{!4~!4{!12~!6{!4~{{!6~!6{!4~{{~~{{!4~{{!4~!6{!90~$-#0!28?BBNNoo!28?ooNNBB!28?ooKKBB!4rKKoo!8?KK!4B{{??{{!4B{{!4?KK~~!4?{{!4rBB!12?KK!4B{{??{{!4B{{??KK!4B{{!4?!4rKK!114?$#84!80?~~!238?$#141!28~{{oo!32?oo{{!12~!240?$#164!32?KKoo!24?ooKK!256?$#208!32?BBNN!24~NNBB!256?$#215!82?!14~NNrr{{!4KrrNN!8~rr!4{BB~~BB!4{BB!4~rr??!4~BB!4K{{!12~rr!4{BB~~BB!4{BB~~rr!4{BB!4~!4Krr!114~$-#0!32?BBKKoo!4?BB??!4K??BB!4?ooKKBB!32?NNooBB!4KooNN!8?{{!4roo??NN!4oNN!6?~~!4?KK!4oNN??!8B??{{!4roo??NN!4oNN??{{!4roo??KK!4oNN!114?$#84!80?~~!238?$#141!32~{{oo!24?oo{{!16~!240?$#164!34?BBKKoo!4?NN!4?NN!4?ooKKBB!258?$#208!36?BBNN~~{{oo!4roo{{~~NNBB!260?$#215!82?!14~ooNN{{!4rNNoo!8~BB!4KNN~~oo!4Noo!6~??!4~rr!4Noo~~!8{~~BB!4KNN~~oo!4Noo~~BB!4KNN~~rr!4Noo!114~$-#0!24?!8o!4KNN!4o!12?!4oNN!4K!8o!28?!6B??!8o!26?oo!28?oo!6?oo!28?oo!12?oo!94?$#84!80?~~!238?$#141!24~!8N!4B!24?!4B!8N!8~!240?$#150!32?oo!28?oo!256?$#164!38?!4N~~!8{~~!4N!262?$#169!34?oo!24?oo!258?$#208!44?!8B!268?$#213!36?oo!20?oo!260?$#215!82?!18~!6{~~!8N!26~NN!28~NN!6~NN!28~NN!12~NN!94~$-#0!14?ooKK~~!6B!8?NNoo!4?!4B!4?!4B!4?ooNN!8?!6B~~KK!28?~~!6?~~!4?!4r{{??~~BB{{BB{{??{{!4?!4r{{??~~!4B{{!10?BB{{BB!4?{{!4r{{??~~!4B??~~!4B??{{??{{!4BKK??~~ooKKBB!88?$#84!80?~~!238?$#141!14~NNBB!60?BB!240?$#150!20?!6{!8~oo!24?oo!8~!6{!244?$#164!46?!4B!270?$#169!16?oo!18?KK??BB??!8K??BB??KK!18?oo!240?$#213!36?BB~~!4{!8o!4{~~BB!260?$#215!82?!26~??!6~??!4~!4KBB~~??{{BB{{BB~~BB!4~!4KBB~~??!4{BB!10~{{BB{{!4~BB!4KBB~~??!4{~~??!4{~~BB~~BB!4{rr~~??NNrr{{!88~$-#0!8?oo{{NN!6?NNoo!12?NNoo!16?ooNN!12?ooNN!32?NN!6KBB??BB!4KNN??NN??NN??NN??NN??BB!4KNN??NN!4?NN!12?NN!6?BB!6K??NN!6?NN!6?NN??BB!4KBB??NN??BBKK!88?$#84!80?~~!238?$#141!8~NNBB!308?$#150!22?NN!12~oo!20?oo!12~NN!246?$#169!14?BB!4?oo!16?KK!16?KK!16?oo!244?$#213!12?oo{{!4~!18?BB!16~BB!18?!4~!240?$#215!82?!26~oo!6r{{~~{{!4roo~~oo~~oo~~oo~~oo~~{{!4roo~~oo!4~oo!12~oo!6~{{!6r~~oo!6~oo!6~oo~~{{!4r{{~~oo~~{{rr!88~$-#0!6?{{NN!12?~~!14?NNoo!12?ooNN!14?~~!22?!4K{{!4K??{{!4o!4?KK!4?!6o!8?KK!4?!6o!10?oo!4K!6o!4?!4o!6?!4o!12?!6o!4?!4o!6?oo!4Koo{{oo??oo!6?oo!4?!4o!4?!6o!4?!4o!4?oo!10?!6o!4?!4o!6?!4o!8?$#84!80?~~!238?$#141!6~BB!312?$#150!24?!14~oo!16?oo!14~!248?$#169!8?ooBB!8?NN!18?KK!12?KK!18?NN!244?$#213!10?{{!8~oo!18?BB!12~BB!18?oo!4~!240?$#215!82?!14~!4rBB!4r~~BB!4N!4~rr!4~!6N!8~rr!4~!6N!10~NN!4r!6N!4~!4N!6~!4N!12~!6N!4~!4N!6~NN!4rNNBBNN~~NN!6~NN!4~!4N!4~!6N!4~!4N!4~NN!10~!6N!4~!4N!6~!4N!8~$-#0!4?{{BB!14?BB!16?BB{{!8?{{BB!16?BB!26?~~!6?~~!4?~~??~~??BB!4Koo!8?~~??BB!4Koo!8?BB~~BB??~~!6?~~!4KNN??~~!4KNN!8?BB!4Koo??~~!4?~~??BB~~BB!4?~~!4?NNooNNooNN??oo!4K~~??~~!6?~~!4KNN!12?BB!4Koo??~~!4KNN??~~!4KNN!6?$#84!80?~~!238?$#141!4~BB!314?$#150!24?!16~{{!12?{{!16~!248?$#169!6?KK!14?{{!18?BB!8?BB!18?{{!246?$#213!6?oo!14~!22?!8~!22?!6~!240?$#215!82?!18~??!6~??!4~??~~??~~{{!4rNN!8~??~~{{!4rNN!8~{{??{{~~??!6~??!4roo~~??!4roo!8~{{!4rNN~~??!4~??~~{{??{{!4~??!4~ooNNooNNoo~~NN!4r??~~??!6~??!4roo!12~{{!4rNN~~??!4roo~~??!4roo!6~$-#0??{{NN!36?~~!8?~~!46?BB!6?BB!4?BB??BB??!6B!10?BB??!6B!12?BB!4?BB!8?!6B!4?!6B!8?!6B!6?!4B!6?BB!8?BB!4?BB??BB!6?!6B??BB!8?!6B??NN!8?!6B!6?!6B!4?!6B!6?$#84!80?~~!238?$#141~~BB!316?$#150!24?!18~!12?!18~!248?$#169!4?oo!16?~~!20?~~!4?~~!20?~~!246?$#213!6?!16~!24?!4~!24?!6~!240?$#215!82?!18~{{!6~{{!4~{{~~{{~~!6{!10~{{~~!6{!12~{{!4~{{!8~!6{!4~!6{!8~!6{!6~!4{!6~{{!8~{{!4~{{~~{{!6~!6{~~{{!8~!6{~~oo!8~!6{!6~!6{!4~!6{!6~$-#0ooNN!18?{{!18?~~!8?~~!18?{{!22?KK~~KK??~~!4Koo??oo!4Koo!8?oo!6K??oo!4Koo??{{!4?{{??{{!4K??oo!4Koo??oo!4Koo!8?oo{{rrBBoo!4Koo??{{!4K!8?oo!4Koo??oo!4Koo??{{!4Koo??{{!4?{{??rr??{{!4Koo??oo!4K{{!32?$#84!80?~~!238?$#141NN!318?$#150!24?!18~!12?!18~!248?$#169??oo!18?BB!20?~~!4?~~!20?BB!246?$#213!4?!18~!24?!4~!24?!6~!240?$#215!82?!14~rr??rr~~??!4rNN~~NN!4rNN!8~NN!6r~~NN!4rNN~~BB!4~BB~~BB!4r~~NN!4rNN~~NN!4rNN!8~NNBBKK{{NN!4rNN~~BB!4r!8~NN!4rNN~~NN!4rNN~~BB!4rNN~~BB!4~BB~~KK~~BB!4rNN~~NN!4rBB!32~$-#0~~!20?~~!18?~~!8?~~!18?~~!24?NNoo??~~!4?~~??NN!6r!8?oo!4rKK??NN!4oNN??NN!4o~~??~~!6?NN!4oKK??NN!6r!10?~~!4?NN!4oNN??~~!12?NN!4oKK??NN!4oNN??~~!4KBB!4?rrNN!4?~~??~~!4?~~??BB!4K~~!32?$#84!80?~~!238?$#150!24?!18~!12?!18~!248?$#169??BB!16?~~!22?BB!4?BB!22?~~!244?$#213??{{!16~!24?{{!4~{{!24?!4~!240?$#215!82?!16~ooNN~~??!4~??~~oo!6K!8~NN!4Krr~~oo!4Noo~~oo!4N??~~??!6~oo!4Nrr~~oo!6K!10~??!4~oo!4Noo~~??!12~oo!4Nrr~~oo!4Noo~~??!4r{{!4~KKoo!4~??~~??!4~??~~{{!4r??!32~$-#0BB!18?{{BB!18?~~!8?~~!18?BB{{!56?oo??oo!4?oo!4?oo!42?!10o??oo!40?BB!8?BB!24?!4B!34?$#84!80?~~!238?$#150!22?{{!18~!12?!18~{{!246?$#164{{!318?$#169!20?BB!52?BB!244?$#213??!18~!24?!8~!24?!4~!240?$#215!82?!50~NN~~NN!4~NN!4~NN!42~!10N~~NN!40~{{!8~{{!24~!4{!34~$-#0~~!8B!6K{{KKBB!18?{{BB!8?BB{{!18?BBKK{{!16?{{!4BKK??{{!4B{{??~~!4B{{??{{!4B~~??{{??BB~~BB??{{??{{!4B{{??~~!4B{{??KK!4rBB!16?~~!6?~~!4B{{??{{!4r{{!8?~~!4B{{??~~!4B??{{!4B{{??{{!4B~~??~~!4B!4?!4r{{??~~BB{{BB{{!24?$#84!80?~~!238?$#150!18?oo{{!18~BB!12?BB!18~{{oo!242?$#164??{{!6K!4roo!304?$#169!42?{{!8?{{!266?$#208!4?!6o!310?$#213!14?!6B!24?!8~!24?!4B!240?$#215!82?!14~BB!4{rr~~BB!4{BB~~??!4{BB~~BB!4{??~~BB~~{{??{{~~BB~~BB!4{BB~~??!4{BB~~rr!4K{{!16~??!6~??!4{BB~~BB!4KBB!8~??!4{BB~~??!4{~~BB!4{BB~~BB!4{??~~??!4{!4~!4KBB~~??{{BB{{BB!24~$-#0BB!14?BB!22?~~!12?~~!22?BB!16?BB!4KBB??BB!4KBB??NN!4?NN??BB!4KNN??NN!4?BBKK??NN??BB!4KBB??NN!4?NN??!6KBB??KK!12?NN!6?NN!4?NN??BB!6K!8?~~!4B!4?NN!6?BB!4KBB!4?!4rNN??NN!6?BB!4KNN??NN??NN??NN!24?$#84!80?~~!238?$#150!16?{{!22~!16?!22~{{!240?$#164{{!12?oo!304?$#169!42?~~!8?~~!266?$#208??!12~NN!304?$#213!44?!8~!268?$#215!82?!14~{{!4r{{~~{{!4r{{~~oo!4~oo~~{{!4roo~~oo!4~{{rr~~oo~~{{!4r{{~~oo!4~oo~~!6r{{~~rr!12~oo!6~oo!4~oo~~{{!6r!8~??!4{!4~oo!6~{{!4r{{!4~!4Koo~~oo!6~{{!4roo~~oo~~oo~~oo!24~$-#0oo!14?{{!22?~~!12?~~!22?{{!16?KK!4?!6o!8?!6o!4?!6o!4?!4o!4?oo!4?oo??KK!4?!4o{{!4?!4o!6?!4o{{!10?ooKKoo!4?oo!6Koo!8?{{??oo!6Koo!8?oo!6?oo??KK??oo{{oo??{{!4o!10?{{oo!4?{{??oo!6Koo!12?$#84!80?~~!238?$#150!16?BB!22~!16?!22~BB!240?$#164NNoo!10?NN!304?$#208??NN!10~oo!304?$#213!42?!12~!266?$#215!82?!14~rr!4~!6N!8~!6N!4~!6N!4~!4N!4~NN!4~NN~~rr!4~!4NBB!4~!4N!6~!4NBB!10~NNrrNN!4~NN!6rNN!8~BB~~NN!6rNN!8~NN!6~NN~~rr~~NNBBNN~~BB!4N!10~BBNN!4~BB~~NN!6rNN!12~$-#0NNoo!14?BBKKoo!14?ooNN!12?NNoo!14?ooKKBB!18?~~??BB!4Koo!8?~~!4oNN??~~!6?~~!4?~~??NN!4oNN??~~??~~!4?~~??~~!4KNN??~~!4?~~!8?{{NNKKNN{{??oo!4BKKoo!8?~~??oo!4BKKoo!8?NNooNNooNN??~~!4?~~!4?~~!4?~~!8?~~??NNoo~~??~~!6?~~!12?$#84oo!78?~~!238?$#150!20?BBNN!14~NN!16?NN!14~NNBB!244?$#164??NN{{oo!12?oo!52?oo!244?$#169!40?oo!12?oo!264?$#208!4?BBNN!10~{{!56?{{~~!240?$#213!42?!12~!266?$#215!82?!14~??~~{{!4rNN!8~??!4Noo~~??!6~??!4~??~~oo!4Noo~~??~~??!4~??~~??!4roo~~??!4~??!8~BBoorrooBB~~NN!4{rrNN!8~??~~NN!4{rrNN!8~ooNNooNNoo~~??!4~??!4~??!4~??!8~??~~ooNN??~~??!6~??!12~$-#0!4?BBKKoo!14?BBKKoo!4?!4KBB!16?BB!4K!4?ooKKBB!24?BB??!6B!10?NN!8?BB!8?!4B!6?!4B!4?BB!4?!6B!4?!6B!4?!6B!8?BB!6?BB!4?!6B!10?BB!4?!6B!12?BB??BB!4?BB!6?BB??BB!4?BB!8?BB!6?BB!4?!6B!14?$#84!4~{{oo!72?~~!238?$#150!26?BB!6N!4B!20?!4B!6NBB!250?$#164!6?BBNN{{!4o!6?BBKKoo??!6o!24?!6o??ooKKBB!246?$#208!10?BB!4N!6~{{oo!44?oo{{!6~!240?$#213!36?oo{{!16~{{oo!260?$#215!82?!14~{{~~!6{!10~oo!8~{{!8~!4{!6~!4{!4~{{!4~!6{!4~!6{!4~!6{!8~{{!6~{{!4~!6{!10~{{!4~!6{!12~{{~~{{!4~{{!6~{{~~{{!4~{{!8~{{!6~{{!4~!6{!14~$-#0!10?BBKK{{oo!12?BB!4?KKoo!16?ooKK!4?BB!12?oo!16?{{??oo??{{!4?!4Koo??{{!4K??{{!4K!4?!4Koo??{{!4Koo??KK~~KK??{{!4?{{!8?{{??oo??{{??~~!4Koo!4?!4Koo??KK~~KK??oo!6K??oo!4Koo??oo!4Koo??{{!4?{{??oo!4Koo??{{!4K!46?$#2!38?KK!16oKK!262?$#84!10~{{oo!66?~~!238?$#164!12?!4BNN{{!4o!4?BBKKNN{{oo??KK!12?KK??oo{{NNKKBB!4?!4o{{NN!240?$#208!18?BB!4N!4~{{!4o!28?!4o{{!4~!4NBB!242?$#213!34?!8B!12N!8B!258?$#215!82?!14~BB~~NN~~BB!4~!4rNN~~BB!4r~~BB!4r!4~!4rNN~~BB!4rNN~~rr??rr~~BB!4~BB!8~BB~~NN~~BB~~??!4rNN!4~!4rNN~~rr??rr~~NN!6r~~NN!4rNN~~NN!4rNN~~BB!4~BB~~NN!4rNN~~BB!4r!46~$-#0!14?!6~{{oo!16?BBKK!8{KKBB!16?oo{{!4~!16?BB{{BB{{BB??KK!4r~~??~~!6?~~!6?KK!4r~~??~~!4?~~!4?NNoo!4?rrNN!10?BB{{BB{{BB??~~!4?~~??KK!4r~~!4?NNoo??oo!4rKK??NN!4oNN??NN!6r??BB!4{BB??NN!6r??~~!6?oo!42?$#2!42?!12B!266?$#84!14~!66?~~!238?$#164!20?BBNN~~!6?{{!4oBBKKoo!8?ooKKBB!4o{{!6?~~NNBB!244?$#208!26?!6~BB!4N{{oo!12?oo{{!4NBB!6~!250?$#215!82?!14~{{BB{{BB{{~~rr!4K??~~??!6~??!6~rr!4K??~~??!4~??!4~ooNN!4~KKoo!10~{{BB{{BB{{~~??!4~??~~rr!4K??!4~ooNN~~NN!4Krr~~oo!4Noo~~oo!6K~~{{!4B{{~~oo!6K~~??!6~NN!42~$-#0!14?NN{{BBNN~~{{!8?BB~~!6?!8B!6?~~BB!8?{{~~NNBB{{!82?BB!156?$#2!16?BB{{oo!52?oo{{BB!240?$#84!14~oo!64?~~!238?$#164!24?BB{{oo??NN{{??!6B!8?!6B??{{NN??oo{{BB!248?$#172!38?!20K!262?$#208!26?BBNN~~oo!28?oo~~NNBB!250?$#215!38?!20o!24?!80~{{!156~$-#0!16?BB~~oo??!4BNN!12KNN!8BNN!12KNN!4B??oo~~BB!240?$#2!20?NN~~!4{!16o!8{!16o!4{~~NN!244?$#84!16~{{!60?{{~~!238?$#164!30?BB!6?!4B!12?!4B!6?BB!254?$#208!32?!6B!20?!6B!256?$#215!82?!238~$-#0!6?oo??oo??ooKKooNN~~{{oo!6?KK!6?!4o!4?!4o!6?KK!6?oo{{~~NNooKK!240?$#2!24?BBNN!6~rr!6~!4N!4~!4N!6~rr!6~NNBB!248?$#84!6~NN~~NN~~NNrrNNoo!52?ooNNrr~~!238?$#215!82?!238~$-#0rrKKrr{{~~{{~~{{!16~!6{!20o!6{!16~!240?$#2!32?!6B!20N!6B!256?$#84KKrrKKBB??BB??BB!64?~~!238?$#215!82?!238~$-#0??BBKKBBKKBBNNrrNNrrNNrrNN~~NN~~NN~~NN!20~NN~~NN~~NN~~NNrrNNrrNN!240?$#84~~{{rr{{rr{{ooKKooKKooKKoo??oo??oo??oo!20?oo??oo??oo??ooKKooKKoo~~!238?$#215!82?!238~$-#0!24?BB??BB??BB??BB??BB??BB!4?BB??BB??BB??BB??BB??BB!248?$#84!24~{{~~{{~~{{~~{{~~{{~~{{!4~{{~~{{~~{{~~{{~~{{~~{{!10~!238?$#215!82?!238~$-#84!82~!238?$#141!96?!224o$#215!82?!14~!224N$-#84!82~!238?$#215!82?!238~$-#84!82~!238?$#215!82?!238~$-#84!84~oo!234?$#215!84?NN!234~$-#84!88~{{!4o!226?$#215!88?BB!4N!226~$-\ \ No newline at end of file diff --git a/tests/frames/sixel-x3.golden b/tests/frames/sixel-x3.golden new file mode 100644 index 0000000..2393e3a --- /dev/null +++ b/tests/frames/sixel-x3.golden @@ -0,0 +1 @@ +P0;1;0q"1;1;480;432#0;2;8;8;8#1;2;8;8;25#2;2;8;8;41#3;2;8;8;58#4;2;8;8;75#5;2;8;8;91#6;2;8;25;8#7;2;8;25;25#8;2;8;25;41#9;2;8;25;58#10;2;8;25;75#11;2;8;25;91#12;2;8;41;8#13;2;8;41;25#14;2;8;41;41#15;2;8;41;58#16;2;8;41;75#17;2;8;41;91#18;2;8;58;8#19;2;8;58;25#20;2;8;58;41#21;2;8;58;58#22;2;8;58;75#23;2;8;58;91#24;2;8;75;8#25;2;8;75;25#26;2;8;75;41#27;2;8;75;58#28;2;8;75;75#29;2;8;75;91#30;2;8;91;8#31;2;8;91;25#32;2;8;91;41#33;2;8;91;58#34;2;8;91;75#35;2;8;91;91#36;2;25;8;8#37;2;25;8;25#38;2;25;8;41#39;2;25;8;58#40;2;25;8;75#41;2;25;8;91#42;2;25;25;8#43;2;25;25;25#44;2;25;25;41#45;2;25;25;58#46;2;25;25;75#47;2;25;25;91#48;2;25;41;8#49;2;25;41;25#50;2;25;41;41#51;2;25;41;58#52;2;25;41;75#53;2;25;41;91#54;2;25;58;8#55;2;25;58;25#56;2;25;58;41#57;2;25;58;58#58;2;25;58;75#59;2;25;58;91#60;2;25;75;8#61;2;25;75;25#62;2;25;75;41#63;2;25;75;58#64;2;25;75;75#65;2;25;75;91#66;2;25;91;8#67;2;25;91;25#68;2;25;91;41#69;2;25;91;58#70;2;25;91;75#71;2;25;91;91#72;2;41;8;8#73;2;41;8;25#74;2;41;8;41#75;2;41;8;58#76;2;41;8;75#77;2;41;8;91#78;2;41;25;8#79;2;41;25;25#80;2;41;25;41#81;2;41;25;58#82;2;41;25;75#83;2;41;25;91#84;2;41;41;8#85;2;41;41;25#86;2;41;41;41#87;2;41;41;58#88;2;41;41;75#89;2;41;41;91#90;2;41;58;8#91;2;41;58;25#92;2;41;58;41#93;2;41;58;58#94;2;41;58;75#95;2;41;58;91#96;2;41;75;8#97;2;41;75;25#98;2;41;75;41#99;2;41;75;58#100;2;41;75;75#101;2;41;75;91#102;2;41;91;8#103;2;41;91;25#104;2;41;91;41#105;2;41;91;58#106;2;41;91;75#107;2;41;91;91#108;2;58;8;8#109;2;58;8;25#110;2;58;8;41#111;2;58;8;58#112;2;58;8;75#113;2;58;8;91#114;2;58;25;8#115;2;58;25;25#116;2;58;25;41#117;2;58;25;58#118;2;58;25;75#119;2;58;25;91#120;2;58;41;8#121;2;58;41;25#122;2;58;41;41#123;2;58;41;58#124;2;58;41;75#125;2;58;41;91#126;2;58;58;8#127;2;58;58;25#128;2;58;58;41#129;2;58;58;58#130;2;58;58;75#131;2;58;58;91#132;2;58;75;8#133;2;58;75;25#134;2;58;75;41#135;2;58;75;58#136;2;58;75;75#137;2;58;75;91#138;2;58;91;8#139;2;58;91;25#140;2;58;91;41#141;2;58;91;58#142;2;58;91;75#143;2;58;91;91#144;2;75;8;8#145;2;75;8;25#146;2;75;8;41#147;2;75;8;58#148;2;75;8;75#149;2;75;8;91#150;2;75;25;8#151;2;75;25;25#152;2;75;25;41#153;2;75;25;58#154;2;75;25;75#155;2;75;25;91#156;2;75;41;8#157;2;75;41;25#158;2;75;41;41#159;2;75;41;58#160;2;75;41;75#161;2;75;41;91#162;2;75;58;8#163;2;75;58;25#164;2;75;58;41#165;2;75;58;58#166;2;75;58;75#167;2;75;58;91#168;2;75;75;8#169;2;75;75;25#170;2;75;75;41#171;2;75;75;58#172;2;75;75;75#173;2;75;75;91#174;2;75;91;8#175;2;75;91;25#176;2;75;91;41#177;2;75;91;58#178;2;75;91;75#179;2;75;91;91#180;2;91;8;8#181;2;91;8;25#182;2;91;8;41#183;2;91;8;58#184;2;91;8;75#185;2;91;8;91#186;2;91;25;8#187;2;91;25;25#188;2;91;25;41#189;2;91;25;58#190;2;91;25;75#191;2;91;25;91#192;2;91;41;8#193;2;91;41;25#194;2;91;41;41#195;2;91;41;58#196;2;91;41;75#197;2;91;41;91#198;2;91;58;8#199;2;91;58;25#200;2;91;58;41#201;2;91;58;58#202;2;91;58;75#203;2;91;58;91#204;2;91;75;8#205;2;91;75;25#206;2;91;75;41#207;2;91;75;58#208;2;91;75;75#209;2;91;75;91#210;2;91;91;8#211;2;91;91;25#212;2;91;91;41#213;2;91;91;58#214;2;91;91;75#215;2;91;91;91#0!150?www!12?!6w!9?!6w!27?!15w!36?www!18?!9w!21?www!6?www!153?$#84!132?www!6F!339?$#141!132~FFF!345?$#215!135?!6w!9~FFF!12~!6F!9~!6F!27~!15F!36~FFF!18~!9F!21~FFF!6~FFF!153~$-#0!147?FFF~~~!6?wwwFFF???~~~???wwwFFF???~~~???~~~!6Fwww!18?~~~!9?www!6Fwww???www!9F???FFF~~~FFF!12?FFF!6w???FFF???~~~!6?~~~???www???FFF~~~FFF???www!6Fwww!135?$#84!126?wwwFFF!348?$#141!126~FFF!351?$#215!129?www!15~www???!6~FFFwww~~~???~~~FFFwww~~~???~~~???!6wFFF!18~???!9~FFF!6wFFF~~~FFF!9w~~~www???www!12~www!6F~~~www~~~???!6~???~~~FFF~~~www???www~~~FFF!6wFFF!135~$-#0!150?~~~!6?!9F~~~???!9F~~~???~~~!6wFFF!18?~~~!9?~~~!9F!6?!6Fwww!6?~~~!15?www!6?FFFwww???~~~!6?~~~???~~~!6?~~~!6?~~~!9F!135?$#84!123?~~~!354?$#141!123~!357?$#215!126?!24~???!6~!9w???~~~!9w???~~~???!6Fwww!18~???!9~???!9w!6~!6wFFF!6~???!15~FFF!6~wwwFFF~~~???!6~???~~~???!6~???!6~???!9w!135~$-#0!150?FFF!15?FFF!12?FFF???~~~!27?FFF!12?!9F???!9F!12?FFF!15?!9F!9?!9F???FFF!9?FFF!6?!9F!135?$#84!120?~~~!357?$#141!120~!360?$#215!123?!27~www!15~www!12~www~~~???!27~www!12~!9w~~~!9w!12~www!15~!9w!9~!9w~~~www!9~www!6~!9w!135~$-#84!120?~~~!357?$#141!120~!360?$#215!123?!357~$-#84!120?~~~!357?$#141!120~!24?!336w$#215!123?!21~!336F$-#84!120?~~~!357?$#141!120~!360?$#215!123?!357~$-#84!120?~~~!357?$#141!120~!360?$#215!123?!357~$-#0!162?!6w!15?!6w!9?!6w!276?$#84!120?~~~!357?$#141!120~!360?$#215!123?!39~!6F!15~!6F!9~!6F!276~$-#0!144?~~~!6?~~~???~~~!6?~~~!9?FFF!6?~~~!6?!6wFFF!273?$#84!120?~~~!357?$#141!120~!360?$#215!123?!21~???!6~???~~~???!6~???!9~www!6~???!6~!6Fwww!273~$-#0!144?FFF!6wFFF???~~~!6?~~~!9?www!6F!6?www!6?~~~!273?$#84!120?~~~!357?$#141!120~!360?$#215!123?!21~www!6Fwww~~~???!6~???!9~FFF!6w!6~FFF!6~???!273~$-#0!147?!6F!9?!6F!6?FFF???!12F!6?!6F!276?$#84!120?~~~!357?$#141!120~!360?$#215!123?!24~!6w!9~!6w!6~www~~~!12w!6~!6w!276~$-#0!147?!9w!9?!9w!6?!12w!84?www!201?$#84!120?~~~!357?$#141!120~!360?$#215!123?!24~!9F!9~!9F!6~!12F!84~FFF!201~$-#0!144?FFF!6w???FFF???~~~!9?FFF???~~~!9wFFF!12?www!9F???www!6Fwww???~~~!6?~~~???~~~!6Fwww???www!6F~~~!201?$#84!120?~~~!357?$#141!120~!360?$#215!123?!21~www!6F~~~www~~~???!9~www~~~???!9Fwww!12~FFF!9w~~~FFF!6wFFF~~~???!6~???~~~???!6wFFF~~~FFF!6w???!201~$-#0!144?www!6?FFFwww???~~~!6?FFF~~~???~~~!9?~~~!15?!6Fwww???~~~!6?~~~???~~~!6?~~~???~~~!6?~~~???~~~!6?~~~!201?$#84!120?~~~!357?$#141!120~!360?$#215!123?!21~FFF!6~wwwFFF~~~???!6~www???~~~???!9~???!15~!6wFFF~~~???!6~???~~~???!6~???~~~???!6~???~~~???!6~???!201~$-#0!147?!9F!9?!12F???!12F!15?!9F!9?!6F!9?!9F???FFF!6?FFF!6?!9F!201?$#84!120?~~~!357?$#141!120~!360?$#215!123?!24~!9w!9~!12w~~~!12w!15~!9w!9~!6w!9~!9w~~~www!6~www!6~!9w!201~$-#84!120?~~~!357?$#141!120~!360?$#215!123?!357~$-#0!57?!9w!12F!9w!393?$#3!54?www!9?www!6?www!9?www!390?$#47!51?www!9?FFF???!6w???FFF!9?www!387?$#84!120?~~~!357?$#141!51~!12F!18?!12F!27~!360?$#215!123?!357~$-#0!45?www!9F!30?!9Fwww!381?$#3!42?wwwFFFwww!6?FFF!24?FFF!6?wwwFFFwww!378?$#47!51?!9w!24~!9w!387?$#84!120?~~~!357?$#141!42~FFF!54?FFF!18~!360?$#215!123?!357~$-#0!39?wwwFFF!54?FFFwww!375?$#3!36?wwwFFFwww!24?!6w!24?wwwFFFwww!372?$#47!45?!24~!6F!24~!381?$#84!120?~~~!357?$#141!36~FFF!66?FFF!12~!360?$#215!123?!357~$-#0!33?wwwFFF!30?!6w!30?FFFwww!39?!9w!21?!6w!9?!6w!12?www!12?www!24?!6w!9?!6w!12?www!9?!6w!174?$#3!36?wwwFFF???~~~www???!9w!6~!6F!6~!9w???www~~~???FFFwww!372?$#47!33?FFF???www~~~???FFF~~~!9F!18?!9F~~~FFF???~~~www???FFF!369?$#84!120?~~~!357?$#141!33~!78?!9~!360?$#215!123?!27~!9F!21~!6F!9~!6F!12~FFF!12~FFF!24~!6F!9~!6F!12~FFF!9~!6F!174~$-#0!33?~~~!12?FFF!6w~~~!24F~~~!6wFFF!12?~~~!33?wwwFFF???!6wFFFwww!12?FFF!6?~~~???~~~!6?~~~!6?FFF~~~!9?FFF~~~!21?FFF!6?~~~???~~~!6?~~~!6?FFF~~~!6?~~~!6w!174?$#3!45?!6w!6F???!24w???!6F!6w!381?$#47!36?!9~FFF!48?FFF!9~!372?$#84!120?~~~!357?$#141!33~!78?!9~!360?$#215!123?!21~FFFwww~~~!6FwwwFFF!12~www!6~???~~~???!6~???!6~www???!9~www???!21~www!6~???~~~???!6~???!6~www???!6~???!6F!174~$-#0!33?~~~!6?www!6F!9w!24F!9w!6Fwww!6?~~~!33?~~~???FFF!6w???~~~!12?www!6F!6?~~~!6?~~~!9?~~~!12?~~~!6?!12F???www!6F!6?~~~!6?~~~!9?~~~!6?~~~!6?~~~!171?$#3!36?!6wFFF!6w!9Fwww!18?www!9F!6wFFF!6w!372?$#47!36?!6F!21?!18w!21?!6F!372?$#84!120?~~~!357?$#141!33~!78?!9~!360?$#215!123?!21~???~~~www!6F~~~???!12~FFF!6w!6~???!6~???!9~???!12~???!6~!12w~~~FFF!6w!6~???!6~???!9~???!6~???!6~???!171~$-#0!36?FFF~~~???FFF~~~!6?!30w!6?~~~FFF???~~~FFF!39?FFF!9wFFF!15?!12F!6?!6F!12?FFF!12?FFF!21?!12F!6?!6F!12?FFF!9?!6F!174?$#3!42?~~~www???FFF!36?FFF???www~~~!378?$#47!33?FFFwww!12?www~~~!30F~~~www!12?wwwFFF!369?$#84!120?~~~!357?$#141!33~www!72?www!9~!360?$#215!123?!24~www!9Fwww!15~!12w!6~!6w!12~www!12~www!21~!12w!6~!6w!12~www!9~!6w!174~$-#0!39?FFF!6~???!6F~~~www!18?www~~~!6F???!6~FFF!63?www!24?www!39?www!27?www!9?www!15?www!12?www!165?$#84!120?~~~!357?$#141!39~!66?!15~!360?$#164!39?www!6?~~~!9?FFF~~~!12F~~~FFF!9?~~~!6?www!375?$#208!51?!6w!9?!12w!9?!6w!387?$#215!123?!45~FFF!24~FFF!39~FFF!27~FFF!9~FFF!15~FFF!12~FFF!165~$-#0!39?wwwFFF~~~!6?FFF!30?FFF!6?~~~FFFwww!60?~~~???~~~!6?~~~!6F???FFF~~~FFF???www!6Fwww???~~~FFFwwwFFFwww???www???www!6Fwww!12?~~~!9?~~~???~~~!6F???~~~!6Fwww???www???~~~!6Fwww!6?!6Fwww!135?$#84!120?~~~!357?$#141!36~FFF!66?FFF!12~!360?$#164!36?wwwFFFwww???FFF!6?!6wFFF!12?FFF!6w!6?FFF???wwwFFFwww!372?$#208!48?www~~~www!6Fwww!12~www!6Fwww~~~www!384?$#215!123?!42~???~~~???!6~???!6w~~~www???www~~~FFF!6wFFF~~~???wwwFFFwwwFFF~~~FFF~~~FFF!6wFFF!12~???!9~???~~~???!6w~~~???!6wFFF~~~FFF~~~???!6wFFF!6~!6wFFF!135~$-#0!36?~~~!15?wwwFFF~~~www!12?www~~~FFFwww!15?~~~!54?~~~!9F~~~???~~~!12?~~~!6?~~~!9F???~~~???~~~???~~~???~~~???~~~!6?~~~!12?~~~!9?~~~???~~~!9?~~~!6?~~~???~~~???~~~!6?~~~???www!6F~~~!135?$#84!120?~~~!357?$#141!36~!72?!12~!360?$#164!39?FFFwwwFFF!9?www???FFF!12?FFF???www!9?FFFwwwFFF!375?$#208!39?wwwFFFwww!6~FFF!9?!12~!9?FFF!6~wwwFFFwww!375?$#215!123?!39~???!9w???~~~???!12~???!6~???!9w~~~???~~~???~~~???~~~???~~~???!6~???!12~???!9~???~~~???!9~???!6~???~~~???~~~???!6~???~~~FFF!6w???!135~$-#0!36?~~~!18?!6F!6?!6w!6?!6F!18?~~~!54?FFF!9?FFF???FFF!15?FFF!6?!9F???FFF???FFF???FFF???FFF!6?!6F!18?!9F!6?FFF!9?!9F!6?FFF???FFF!6?FFF!6?!9F!135?$#84!120?~~~!357?$#141!36~!72?!12~!360?$#164!39?wwwFFFwww!6?FFF!6wFFF!12?FFF!6wFFF!6?wwwFFFwww!375?$#208!39?FFFwwwFFF!6~www!6?www~~~!6F~~~www!6?www!6~FFFwwwFFF!375?$#215!123?!39~www!9~www~~~www!15~www!6~!9w~~~www~~~www~~~www~~~www!6~!6w!18~!9w!6~www!9~!9w!6~www~~~www!6~www!6~!9w!135~$-#0!39?FFF!6w!18?FFF!6?FFF!18?!6wFFF!45?!9w!21?!6w!9?!6w!12?www!9?!9w!21?!6w!9?!6w!9?!6w!9?!6w!174?$#84!120?~~~!357?$#141!39~www!60?www!15~!360?$#164!42?FFF!54?FFF!378?$#208!45?FFF!18~www!6~www!18~FFF!381?$#215!123?!27~!9F!21~!6F!9~!6F!12~FFF!9~!9F!21~!6F!9~!6F!9~!6F!9~!6F!174~$-#0!45?FFFwww!42?wwwFFF!45?wwwFFF???!6wFFFwww!12?FFF!6?~~~???~~~!6?~~~!6?FFF~~~!6?~~~!6w!21?FFF!6?~~~???~~~!6?~~~???FFF!6?~~~!6?!6wFFF!171?$#84!120?~~~!357?$#141!45~www!48?www!21~!360?$#164!48?FFFwww!36?wwwFFF!384?$#208!51?FFF!36~FFF!387?$#215!123?!21~FFFwww~~~!6FwwwFFF!12~www!6~???~~~???!6~???!6~www???!6~???!6F!21~www!6~???~~~???!6~???~~~www!6~???!6~!6Fwww!171~$-#0!48?FFFwww!9?FFF???!6w???FFF!9?wwwFFF!48?~~~???FFF!6w???~~~!12?www!6F!6?~~~!6?~~~!9?~~~!6?www!6?~~~???!12F???www!6F!6?~~~!6?~~~???www!6F!6?www!6?~~~!171?$#84!120?~~~!357?$#141!48~www!42?www!24~!360?$#164!51?FFFwww!9?~~~!6?~~~!9?wwwFFF!387?$#208!54?FFF!6~www???!6F???www!6~FFF!390?$#215!123?!21~???~~~www!6F~~~???!12~FFF!6w!6~???!6~???!9~???!6~FFF!6~???~~~!12w~~~FFF!6w!6~???!6~???~~~FFF!6w!6~FFF!6~???!171~$-#0!54?~~~!30?~~~!57?FFF!9wFFF!15?!12F!6?!6F!12?FFF!9?!6F!21?!12F!6?!6F!6?!12F!6?!6F!174?$#84!120?~~~!357?$#141!54~!36?!30~!360?$#164!57?~~~!6w!12?!6w~~~!393?$#208!60?!6F!12~!6F!396?$#215!123?!24~www!9Fwww!15~!12w!6~!6w!12~www!9~!6w!21~!12w!6~!6w!6~!12w!6~!6w!174~$-#0!36?!12w!9F!6w!18?!6w!9F!12w!54?!12w!39?www!42?www!9?www!42?www!18?www!141?$#84!120?~~~!357?$#141!36~!12F!48?!12F!12~!360?$#150!48?www!42?www!384?$#164!57?!6F!18~!6F!393?$#169!51?www!36?www!387?$#213!54?www!30?www!390?$#215!123?!39~!12F!39~FFF!42~FFF!9~FFF!42~FFF!18~FFF!141~$-#0!24?www~~~!9F!12?~~~!9?!6F!6?!6F!9?~~~!12?!9F~~~www!42?~~~!9?~~~!6?!6Fwww???~~~FFFwwwFFFwww???www!6?!6Fwww???~~~!6Fwww!15?FFFwwwFFF!6?www!6Fwww???~~~!6F???~~~!6F???www???www!6Fwww???~~~???wwwFFF!132?$#84!120?~~~!357?$#141!24~FFF!90?FFF!360?$#150!30?!9w!12~!42?!12~!9w!366?$#164!69?!6F!405?$#169!54?www???FFF???!12w???FFF???www!390?$#213!54?FFF~~~!6w!12?!6w~~~FFF!390?$#215!123?!39~???!9~???!6~!6wFFF~~~???wwwFFFwwwFFF~~~FFF!6~!6wFFF~~~???!6wFFF!15~wwwFFFwww!6~FFF!6wFFF~~~???!6w~~~???!6w~~~FFF~~~FFF!6wFFF~~~???~~~FFFwww!132~$-#0!18?wwwFFF???FFFwww!21?~~~!30?~~~!21?wwwFFF!45?~~~!9?~~~???www!6F~~~???~~~???~~~???~~~???~~~???www!6F~~~???~~~!6?~~~!18?~~~!9?~~~!9F???~~~!9?~~~!9?~~~???~~~!6?www???~~~FFFwww!135?$#84!120?~~~!357?$#141!18~FFF!459?$#150!30?FFF!21~!36?!21~FFF!366?$#169!21?wwwFFF!90?FFF!360?$#213!24?!6w!27?!30~!27?!6w!360?$#215!123?!39~???!9~???~~~FFF!6w???~~~???~~~???~~~???~~~???~~~FFF!6w???~~~???!6~???!18~???!9~???!9w~~~???!9~???!9~???~~~???!6~FFF~~~???wwwFFF!135~$-#0!12?www~~~FFF!9?FFFwww!18?FFFwww!24?wwwFFF!18?wwwFFF!48?!12F!9?!9F???FFF???FFF???FFF???FFF!6?!9F???FFF!6?FFF!18?FFF!12?!9F???FFF!9?FFF!9?FFF!6?!6F!6?FFF!6?FFF!132?$#84!120?~~~!357?$#141!12~FFF!465?$#150!33?FFF!18~www!30?www!18~FFF!369?$#169!30?www!24?FFF!24?FFF!24?www!366?$#213!18?www!9~!30?!24~!30?!6~!360?$#215!123?!39~!12w!9~!9w~~~www~~~www~~~www~~~www!6~!9w~~~www!6~www!18~www!12~!9w~~~www!9~www!9~www!6~!6w!6~www!6~www!132~$-#0!9?www~~~!18?~~~!21?~~~!24?~~~!21?~~~!33?!15w???www!12?www!27?www!33?!6w!87?!6w???www!132?$#84!120?~~~!357?$#141!9~FFF!468?$#150!36?!21~!30?!21~!372?$#169!15?FFF!12?~~~!27?www!18?www!27?~~~!366?$#213!15?www!12~!30?FFF!18~FFF!30?!6~!360?$#215!123?!21~!15F~~~FFF!12~FFF!27~FFF!33~!6F!87~!6F~~~FFF!132~$-#0!9?~~~!21?~~~!24?~~~!18?~~~!24?~~~!39?~~~!9?~~~!6Fwww???www???www!9F!12?www???www!9F!12?www~~~www???~~~!6F???www!6Fwww???www!6Fwww!12?www!9F???www!6Fwww???www~~~www???FFF~~~FFF???~~~???www???~~~!6?!6Fwww???~~~!6F???www!6Fwww???FFF!12?www!9F???www!6Fwww???www!6Fwww!9?$#84!120?~~~!357?$#141!9~!471?$#150!36?!24~!24?!24~!372?$#169!12?FFF!48?www!12?www!399?$#213!12?www!18~!30?FFF!12~FFF!30?!9~!360?$#215!123?!27~???!9~???!6wFFF~~~FFF~~~FFF!9w!12~FFF~~~FFF!9w!12~FFF???FFF~~~???!6w~~~FFF!6wFFF~~~FFF!6wFFF!12~FFF!9w~~~FFF!6wFFF~~~FFF???FFF~~~www???www~~~???~~~FFF~~~???!6~!6wFFF~~~???!6w~~~FFF!6wFFF~~~www!12~FFF!9w~~~FFF!6wFFF~~~FFF!6wFFF!9~$-#0!6?~~~!54?~~~!12?~~~!69?~~~!9?~~~!6?~~~???~~~!6?!6Fwww!12?~~~!6?!6Fwww!15?~~~!6?~~~!9?~~~!9F???~~~!9F!15?!6Fwww???~~~!6?~~~!6?~~~!9?~~~!6?FFFwwwFFFwwwFFF???www!6F~~~???~~~!9?~~~!9F!21?!6Fwww???~~~!9F???~~~!9F!9?$#84!120?~~~!357?$#141!6~!474?$#150!36?!27~!18?!27~!372?$#169!9?FFF!21?~~~!72?~~~!369?$#213!9?www!21~!33?!12~!33?!9~!360?$#215!123?!27~???!9~???!6~???~~~???!6~!6wFFF!12~???!6~!6wFFF!15~???!6~???!9~???!9w~~~???!9w!15~!6wFFF~~~???!6~???!6~???!9~???!6~wwwFFFwwwFFFwww~~~FFF!6w???~~~???!9~???!9w!21~!6wFFF~~~???!9w~~~???!9w!9~$-#0???www~~~!54?~~~!12?~~~!69?FFF!9?FFF!6?FFF???FFF???!9F!15?FFF???!9F!18?FFF!6?FFF!12?!9F!6?!9F!12?!9F!9?!6F!9?FFF!12?FFF!6?FFF???FFF!9?!9F???FFF!12?!9F???~~~!12?!9F!9?!9F!6?!9F!9?$#84!120?~~~!357?$#141~~~FFF!474?$#150!36?!27~!18?!27~!372?$#169!33?~~~!30?~~~!6?~~~!30?~~~!369?$#213!9?!24~!36?!6~!36?!9~!360?$#215!123?!27~www!9~www!6~www~~~www~~~!9w!15~www~~~!9w!18~www!6~www!12~!9w!6~!9w!12~!9w!9~!6w!9~www!12~www!6~www~~~www!9~!9w~~~www!12~!9w~~~???!12~!9w!9~!9w!6~!9w!9~$-#0???~~~!57?~~~!12?~~~!66?www!6?www!138?!6w!96?www!78?$#84!120?~~~!357?$#141~~~!477?$#150!36?!27~!18?!27~!372?$#169!6?FFF!24?~~~!30?~~~!6?~~~!30?~~~!369?$#213!6?www!24~!36?!6~!36?!9~!360?$#215!123?!24~FFF!6~FFF!138~!6F!96~FFF!78~$-#0wwwFFF!27?~~~!27?~~~!12?~~~!27?~~~!33?FFF~~~FFF???~~~!6Fwww???www!6Fwww!12?www!9F???www!6Fwww???~~~!6?~~~???~~~!6F???www!6Fwww???www!6Fwww!12?www~~~www???www!6Fwww???~~~!6F!12?www!6Fwww???www!6Fwww???~~~!6Fwww???~~~!6?~~~???www???~~~!6Fwww???www!6F~~~!48?$#84!120?~~~!357?$#141FFF!477?$#150!36?!27~!18?!27~!372?$#169???www!60?~~~!6?~~~!402?$#213!6?!27~!36?!6~!36?!9~!360?$#215!123?!21~www???www~~~???!6wFFF~~~FFF!6wFFF!12~FFF!9w~~~FFF!6wFFF~~~???!6~???~~~???!6w~~~FFF!6wFFF~~~FFF!6wFFF!12~FFF???FFF~~~FFF!6wFFF~~~???!6w!12~FFF!6wFFF~~~FFF!6wFFF~~~???!6wFFF~~~???!6~???~~~FFF~~~???!6wFFF~~~FFF!6w???!48~$-#0~~~!30?~~~!27?~~~!12?~~~!27?~~~!36?~~~!6?~~~!6?~~~???~~~!9F!15?!6Fwww???~~~!6?~~~???~~~!6?~~~???~~~!9?~~~!6?www???~~~!9F!15?~~~!6?~~~!6?~~~???~~~!18?~~~!6?www???~~~!6?~~~???~~~!6wFFF!6?FFF~~~!6?~~~???~~~!6?~~~???FFF!6w~~~!48?$#84!120?~~~!357?$#150!36?!27~!18?!27~!372?$#169???FFF!24?~~~!33?FFF!6?FFF!33?~~~!366?$#213???www!24~!36?www!6~www!36?!6~!360?$#215!123?!24~???!6~???!6~???~~~???!9w!15~!6wFFF~~~???!6~???~~~???!6~???~~~???!9~???!6~FFF~~~???!9w!15~???!6~???!6~???~~~???!18~???!6~FFF~~~???!6~???~~~???!6Fwww!6~www???!6~???~~~???!6~???~~~www!6F???!48~$-#0~~~!30?~~~!27?~~~!12?~~~!27?~~~!39?FFF???FFF!6?FFF!6?!9F!12?!9F!9?!6F!9?!9F???FFF!12?!6F!9?!9F!15?FFF!9?!6F!6?FFF!21?!6F!9?!6F!6?~~~!12?wwwFFF!9?FFF???FFF!6?FFF!6?!6wFFF!48?$#84!120?~~~!357?$#150!36?!27~!18?!27~!372?$#169!30?~~~!78?~~~!366?$#213???!27~!36?!12~!36?!6~!360?$#215!123?!27~www~~~www!6~www!6~!9w!12~!9w!9~!6w!9~!9w~~~www!12~!6w!9~!9w!15~www!9~!6w!6~www!21~!6w!9~!6w!6~???!12~FFFwww!9~www~~~www!6~www!6~!6Fwww!48~$-#0!30?~~~!30?~~~!12?~~~!30?~~~!84?www???www!6?www!6?www!63?!15w???www!171?$#84!120?~~~!357?$#150!33?!30~!18?!30~!369?$#164~~~!477?$#213???!27~!36?!12~!36?!6~!360?$#215!123?!75~FFF~~~FFF!6~FFF!6~FFF!63~!15F~~~FFF!171~$-#0~~~!12F!15wFFF!27?wwwFFF!12?FFFwww!27?FFF!6w!24?www!6Fwww???www!6Fwww???~~~!6Fwww???www!6F~~~???www???FFF~~~FFF???www???www!6Fwww???~~~!6Fwww???www!9F!24?~~~!9?~~~!6Fwww???www!6Fwww!12?~~~!6Fwww???~~~!6F???www!6Fwww???www!6F~~~???~~~!6F!6?!6Fwww???~~~FFFwwwFFFwww!36?$#84!120?~~~!357?$#150!30?www!27~FFF!18?FFF!27~www!366?$#164???!12w!6F!459?$#169!63?www!12?www!399?$#213!21?!9F!36?!12~!36?!6F!360?$#215!123?!21~FFF!6wFFF~~~FFF!6wFFF~~~???!6wFFF~~~FFF!6w???~~~FFF~~~www???www~~~FFF~~~FFF!6wFFF~~~???!6wFFF~~~FFF!9w!24~???!9~???!6wFFF~~~FFF!6wFFF!12~???!6wFFF~~~???!6w~~~FFF!6wFFF~~~FFF!6w???~~~???!6w!6~!6wFFF~~~???wwwFFFwwwFFF!36~$-#0~~~!21?~~~!33?~~~!18?~~~!33?~~~!24?~~~!6?www???~~~!6?~~~???~~~!6?~~~???~~~!6?~~~???~~~!6?~~~!6?~~~???~~~!6?~~~???~~~!6?~~~!6?!6Fwww!24?~~~!9?~~~!6?~~~???~~~!9F!12?~~~!6wFFF???~~~!9?~~~!6?~~~???FFF!6w~~~???~~~!9?www!6F~~~???~~~???~~~???~~~!36?$#84!120?~~~!357?$#150!27?!33~!24?!33~!363?$#164???FFF!9?!9F!456?$#169!63?~~~!12?~~~!399?$#208???www!9~!9w!456?$#213!66?!12~!402?$#215!123?!21~???!6~FFF~~~???!6~???~~~???!6~???~~~???!6~???~~~???!6~???!6~???~~~???!6~???~~~???!6~???!6~!6wFFF!24~???!9~???!6~???~~~???!9w!12~???!6Fwww~~~???!9~???!6~???~~~www!6F???~~~???!9~FFF!6w???~~~???~~~???~~~???!36~$-#0!60?~~~!18?~~~!63?!6F!9?!6F!6?FFF!6?FFF!6?!9F???FFF!9?FFF???FFF!6?!6F!6?FFF!6?FFF???!9F!6?FFF!18?FFF!9?FFF!6?FFF!6?!9F!12?~~~!12?FFF!12?!6F!9?!6wFFF???FFF!12?!9F???FFF???FFF???FFF!36?$#84!120?~~~!357?$#150!24?!36~!24?!36~!360?$#164~~~!18?www!456?$#169!63?~~~!12?~~~!399?$#208???!18~FFF!456?$#213!66?!12~!402?$#215!123?!24~!6w!9~!6w!6~www!6~www!6~!9w~~~www!9~www~~~www!6~!6w!6~www!6~www~~~!9w!6~www!18~www!9~www!6~www!6~!9w!12~???!12~www!12~!6w!9~!6Fwww~~~www!12~!9w~~~www~~~www~~~www!36~$-#0!24?www!33?~~~!18?~~~!33?www!24?www!84?www!12?www!27?www!18?www!12?!9w!15?www!6?!9w!33?www!6?www!6?www!21?www!9?www!6?!9w!21?$#84!120?~~~!357?$#150!24?FFF!33~!24?!33~FFF!360?$#164~~~!18?~~~!456?$#208???!18~!459?$#213!63?!18~!399?$#215!123?!21~FFF!84~FFF!12~FFF!27~FFF!18~FFF!12~!9F!15~FFF!6~!9F!33~FFF!6~FFF!6~FFF!21~FFF!9~FFF!6~!9F!21~$-#0~~~!21?FFFwww!30?~~~!18?~~~!30?wwwFFF!24?www???www!9F!12?~~~!6Fwww???~~~!6F???www!6Fwww???~~~!6?~~~???www???www!6F~~~???www!6Fwww???www!6F~~~!15?~~~???~~~!6?FFF!6w???FFF!12?~~~???FFF!6w???FFF!12?~~~???www???~~~???www???FFF~~~FFF???~~~!6Fwww!12?~~~FFFwww???~~~???~~~!9?~~~!18?$#84!120?~~~!357?$#150!27?FFF!30~!24?!30~FFF!363?$#164???~~~!474?$#208!6?!18~www!90?www!360?$#213!63?!18~!399?$#215!123?!21~FFF~~~FFF!9w!12~???!6wFFF~~~???!6w~~~FFF!6wFFF~~~???!6~???~~~FFF~~~FFF!6w???~~~FFF!6wFFF~~~FFF!6w???!15~???~~~???!6~www!6F~~~www!12~???~~~www!6F~~~www!12~???~~~FFF~~~???~~~FFF~~~www???www~~~???!6wFFF!12~???wwwFFF~~~???~~~???!9~???!18~$-#0FFFwww!24?FFFwww!21?wwwFFF!18?FFFwww!21?wwwFFF!30?~~~!6?!6Fwww!12?~~~!6wFFF???~~~!9?~~~!6?~~~???FFF!6wFFF???~~~???~~~!6?~~~???~~~!9F???~~~!6?~~~!12?~~~!9F~~~???www!6?FFFwww!12?~~~???www!6?FFFwww!12?FFFwwwFFFwwwFFF???~~~!6?~~~!6?~~~!6?~~~!12?~~~???FFFwww~~~???~~~!9?~~~!18?$#84www!117?~~~!357?$#150!33?FFF!21~FFF!24?FFF!21~FFF!369?$#164???FFF~~~www!18?www!78?www!366?$#169!60?www!18?www!396?$#208!9?FFF!18~!84?!6~!360?$#213!63?!18~!399?$#215!123?!21~???!6~!6wFFF!12~???!6Fwww~~~???!9~???!6~???~~~www!6Fwww~~~???~~~???!6~???~~~???!9w~~~???!6~???!12~???!9w???~~~FFF!6~wwwFFF!12~???~~~FFF!6~wwwFFF!12~wwwFFFwwwFFFwww~~~???!6~???!6~???!6~???!12~???~~~wwwFFF???~~~???!9~???!18~$-#0!6?FFFwww!24?FFFwww!9?!6wFFF!24?FFF!6w!9?wwwFFF!36?FFF???!9F!15?~~~!12?FFF!12?!6F!9?!6F!6?FFF!6?!9F!6?!9F!6?!9F!12?FFF!9?FFF!6?!9F!15?FFF!6?!9F!18?FFF???FFF!6?FFF!9?FFF???FFF!6?FFF!12?FFF!9?FFF!6?!9F!21?$#84!6~www!111?~~~!357?$#150!39?FFF!9~!6F!30?!6F!9~FFF!375?$#164!9?FFF~~~www!15?FFFwww!66?wwwFFF!369?$#208!15?FFF!15~www!72?www!9~!360?$#213!57?www!24~www!393?$#215!123?!21~www~~~!9w!15~???!12~www!12~!6w!9~!6w!6~www!6~!9w!6~!9w!6~!9w!12~www!9~www!6~!9w!15~www!6~!9w!18~www~~~www!6~www!9~www~~~www!6~www!12~www!9~www!6~!9w!21~$-#0!12?FFFwww!24?FFFwww!48?wwwFFF!132?www!48?www!30?www!159?$#84!12~www!105?~~~!357?$#164!15?FFF!6~www!12?FFFwwwFFF~~~FFF!36?FFF~~~FFFwwwFFF!12?www!360?$#208!24?FFF!12~www!60?www!12~FFF!360?$#213!51?www!36~www!387?$#215!123?!111~FFF!48~FFF!30~FFF!159~$-#0!18?FFF~~~www!27?FFFwww!24?wwwFFF!27?www!24?~~~???www???~~~!6?!6Fwww???~~~!6F???~~~!6F!6?!6Fwww???~~~!6Fwww???FFF~~~FFF???~~~!6?~~~!12?~~~???www???~~~???~~~!6Fwww!6?!6Fwww???FFF~~~FFF???www!9F???www!6Fwww???www!6Fwww???~~~!6?~~~???www!6Fwww???~~~!6F!69?$#2!57?FFF!24wFFF!393?$#84!18~www!99?~~~!357?$#164!24?FFF~~~!6w!9?!6F~~~www???FFF!18?FFF???www~~~!6F!9?!6w~~~FFF!360?$#208!30?!6F!9~!6w!42?!6w!9~!6F!366?$#213!63?!18F!399?$#215!123?!21~???~~~FFF~~~???!6~!6wFFF~~~???!6w~~~???!6w!6~!6wFFF~~~???!6wFFF~~~www???www~~~???!6~???!12~???~~~FFF~~~???~~~???!6wFFF!6~!6wFFF~~~www???www~~~FFF!9w~~~FFF!6wFFF~~~FFF!6wFFF~~~???!6~???~~~FFF!6wFFF~~~???!6w!69~$-#0!21?!9~www!27?FFF!18wFFF!27?www!6~!24?FFFwwwFFFwwwFFF???www!6F~~~???~~~!9?~~~!9?www!6F~~~???~~~!6?~~~!6?~~~!9?FFF~~~!15?FFFwwwFFFwwwFFF???~~~!6?~~~???www!6F~~~!6?~~~!9?!6Fwww???~~~!6?~~~???~~~!9F???FFF!6wFFF???~~~!9F???~~~!75?$#2!63?!18F!399?$#84!21~!99?~~~!357?$#164!30?FFF!6~!9?www!6?FFFwww!18?wwwFFF!6?www!9?!6~FFF!366?$#208!39?!9~FFF!6~www!24?www!6~FFF!9~!375?$#215!123?!21~wwwFFFwwwFFFwww~~~FFF!6w???~~~???!9~???!9~FFF!6w???~~~???!6~???!6~???!9~www???!15~wwwFFFwwwFFFwww~~~???!6~???~~~FFF!6w???!6~???!9~!6wFFF~~~???!6~???~~~???!9w~~~www!6Fwww~~~???!9w~~~???!75~$-#0!21?~~~FFF!9~!15?!6w!9?!12~!9?!6w!15?!9~FFF!27?FFF???FFF!9?!9F???FFF!9?FFF!12?!9F???FFF!6?FFF!9?FFF???wwwFFF!21?FFF???FFF!6?FFF!6?FFF!6?!9F!9?FFF???!9F!9?!6F!9?!9F!6?!6F!9?!9F???FFF!9?FFF!63?$#2!24?www!90?www!360?$#84!21~!99?~~~!357?$#164!36?~~~!9?~~~!6F!6w~~~!12?~~~!6w!6F~~~!9?~~~!372?$#208!39?!9~!9?!6F!18?!6F!9?!9~!375?$#215!123?!24~www~~~www!9~!9w~~~www!9~www!12~!9w~~~www!6~www!9~www~~~FFFwww!21~www~~~www!6~www!6~www!6~!9w!9~www~~~!9w!9~!6w!9~!9w!6~!6w!9~!9w~~~www!9~www!63~$-#0!21?FFF~~~???FFF!6~!15?~~~!30?~~~!15?!6~FFF???~~~!360?$#2!27?~~~www!78?www~~~!363?$#84!21~www!96?~~~!357?$#164!39?~~~www???FFF~~~!36?~~~FFF???www~~~!375?$#172!57?!30F!393?$#208!42?FFF~~~www!42?www~~~FFF!378?$#215!57?!30w!36?!357~$-#0!24?FFF~~~!6?!6F~~~!18w~~~!12F~~~!18w~~~!6F!6?~~~FFF!360?$#2!30?!6~!6w!24?!12w!24?!6w!6~!366?$#84!24~www!90?www~~~!357?$#164!45?FFF!9?!6F!18?!6F!9?FFF!381?$#208!48?!9F!30?!9F!384?$#215!123?!357~$-#0!27?FFF~~~www!72?www~~~FFF!363?$#2!33?FFF!72~FFF!369?$#84!27~www!84?www!6~!357?$#215!123?!357~$-#0!9?www???www???wwwFFFwwwFFF!6~www!9?FFF!9?!6w!6?!6w!9?FFF!9?www!6~FFFwwwFFF!360?$#2!39?FFF!9~www!9~!6F!6~!6F!9~www!9~FFF!375?$#84!9~FFF~~~FFF~~~FFFwwwFFFwww!78?wwwFFFwww~~~!357?$#215!123?!357~$-#0FFFwwwFFFwww~~~www~~~www!24~!9w!30?!9w!24~!360?$#2!48?!9F!30~!9F!384?$#84wwwFFFwwwFFF???FFF???FFF!96?~~~!357?$#215!123?!357~$-#0FFFwwwFFF~~~FFF!105~!360?$#84wwwFFFwww???www!105?~~~!357?$#215!123?!357~$-#0!6?FFF???FFF???FFFwwwFFFwwwFFFwwwFFF~~~FFF~~~FFF~~~FFF!30~FFF~~~FFF~~~FFF~~~FFFwwwFFFwwwFFF!360?$#84!6~www~~~www~~~wwwFFFwwwFFFwwwFFFwww???www???www???www!30?www???www???www???wwwFFFwwwFFFwww~~~!357?$#215!123?!357~$-#0!36?FFF???FFF???FFF???FFF???FFF???FFF!6?FFF???FFF???FFF???FFF???FFF???FFF!372?$#84!36~www~~~www~~~www~~~www~~~www~~~www!6~www~~~www~~~www~~~www~~~www~~~www!15~!357?$#215!123?!357~$-#84!123~!357?$#215!123?!357~$-#84!123~!357?$#141!144?!336w$#215!123?!21~!336F$-#84!123~!357?$#215!123?!357~$-#84!123~!357?$#215!123?!357~$-#84!123~!357?$#215!123?!357~$-#84!126~!354?$#215!126?!354~$-#84!129~www!348?$#215!129?FFF!348~$-#84!135~!6w!339?$#215!135?!6F!339~$-\ \ No newline at end of file diff --git a/tests/test_gambaterm.py b/tests/test_gambaterm.py index fb7a81d..0790f61 100644 --- a/tests/test_gambaterm.py +++ b/tests/test_gambaterm.py @@ -46,7 +46,7 @@ def test_gambaterm(interactive: bool, color_arg: str) -> None: assert TEST_ROM.exists() command = ( f"gambaterm {TEST_ROM} --break-after 10" - f" --input-file /dev/null --disable-audio" + f" --input-file /dev/null --disable-audio --graphics text" + (f" {color_arg}" if color_arg else "") ) result = run( @@ -57,8 +57,6 @@ def test_gambaterm(interactive: bool, color_arg: str) -> None: capture_output=True, ) assert result.stderr == "" - if interactive: - assert "| test_rom.gb |" in result.stdout if sys.platform == "linux": assert "▀ ▄▄ ▀" in result.stdout @@ -68,8 +66,9 @@ def test_gambaterm_ssh( ssh_config: Path, gambaterm_config: Path, color_arg: str ) -> None: assert TEST_ROM.exists() - command = f"gambaterm-ssh {TEST_ROM} --break-after 10 --input-file /dev/null" + ( - f" {color_arg}" if color_arg else "" + command = ( + f"gambaterm-ssh {TEST_ROM} --break-after 10 --input-file /dev/null --graphics text" + + (f" {color_arg}" if color_arg else "") ) env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" @@ -111,7 +110,6 @@ async def ssh_client() -> str: return result.stdout client_stdout = asyncio.run(ssh_client()) - assert "| test_rom.gb |" in client_stdout assert "▀ ▄▄ ▀" in client_stdout finally: server.send_signal(signal.SIGINT) @@ -128,7 +126,8 @@ def test_gambaterm_telnet(color_arg: str) -> None: assert TEST_ROM.exists() command = ( f"{sys.executable} -m gambaterm.telnet {TEST_ROM} --break-after 10" - f" --input-file /dev/null" + (f" {color_arg}" if color_arg else "") + f" --input-file /dev/null --graphics text" + + (f" {color_arg}" if color_arg else "") ) env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" @@ -171,7 +170,6 @@ async def telnet_client() -> str: return output.decode("utf-8", errors="replace") result = asyncio.run(telnet_client()) - assert "| test_rom.gb |" in result assert "\u2580" in result or "\u2584" in result finally: server.send_signal(signal.SIGINT) @@ -193,7 +191,7 @@ def test_gambaterm_telnet_unknown_term() -> None: assert TEST_ROM.exists() command = ( f"{sys.executable} -m gambaterm.telnet {TEST_ROM} --break-after 10" - f" --input-file /dev/null" + f" --input-file /dev/null --graphics text" ) env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" @@ -236,7 +234,6 @@ async def telnet_client() -> str: return output.decode("utf-8", errors="replace") result = asyncio.run(telnet_client()) - assert "| test_rom.gb |" in result assert "\u2580" in result or "\u2584" in result finally: server.send_signal(signal.SIGINT) @@ -292,7 +289,6 @@ async def ssh_client() -> str: client_stdout = asyncio.run(ssh_client()) assert "TEST_FRONTEND_ACTIVE" in client_stdout - assert "test_rom.gb" in client_stdout finally: server.send_signal(signal.SIGINT) server.wait() @@ -354,7 +350,6 @@ async def telnet_client() -> str: result = asyncio.run(telnet_client()) assert "TEST_FRONTEND_ACTIVE" in result - assert "test_rom.gb" in result finally: server.send_signal(signal.SIGINT) server.wait() diff --git a/tests/test_graphics_renderer.py b/tests/test_graphics_renderer.py new file mode 100644 index 0000000..6806ea6 --- /dev/null +++ b/tests/test_graphics_renderer.py @@ -0,0 +1,353 @@ +# uses tests/frames/ files: +# - input.raw -- raw video frame +# - kitty-x?.golden: converted to kitty at integer scale +# - sixel-x?.golden: converted to sixel graphics at integer scale +# +# "cat" these files to see their graphics in a terminal that supports them. +# +# These mostly help reduce regressions in either graphics renderer, especially the individual byte +# colors, the RGB ordering is a bit tricky between the native emulator screen and the two graphics +# renderers, or any other misfiring, or as confirmation of adjustments to blitters. +from pathlib import Path + +import numpy as np +import pytest + +from gambaterm.graphicsblit import ( + to_rgb, + to_rgba_u8, + encode_sixel, + encode_kitty_rgba, +) + +FRAMES_DIR = Path(__file__).parent / "frames" + + +def _load_frame(): + return np.fromfile(FRAMES_DIR / "input.raw", dtype=np.uint32).reshape(144, 160) + + +def _load_golden(name): + return (FRAMES_DIR / name).read_bytes() + + +def test_encode_sixel_emits_raster_attributes(): + colors = np.zeros((6, 10, 3), dtype=np.float32) + result = encode_sixel(colors, max_colors=256, scale=1).decode("latin-1") + assert result.startswith("\033P0;1;0q") + assert '"1;1;10;6' in result + assert result.endswith("\033\\") + + +@pytest.mark.parametrize("scale", [1, 2, 3]) +def test_encode_sixel_golden(scale): + video = _load_frame() + colors = to_rgb(video) + result = encode_sixel(colors, max_colors=256, scale=scale) + expected = _load_golden(f"sixel-x{scale}.golden") + assert result == expected + + +@pytest.mark.parametrize("scale", [1, 2, 3]) +def test_kitty_rgba_golden(scale): + video = _load_frame() + rgba = to_rgba_u8(video) + if scale > 1: + rgba = np.repeat(np.repeat(rgba, scale, axis=0), scale, axis=1) + h, w = rgba.shape[:2] + result = encode_kitty_rgba(rgba.tobytes(), w, h) + expected = _load_golden(f"kitty-x{scale}.golden") + assert result == expected + + +def test_encode_kitty_rgba_positioned(): + rgba = np.zeros((4, 4, 4), dtype=np.uint8) + rgba[:, :, 3] = 255 + result = encode_kitty_rgba(rgba.tobytes(), 4, 4, image_id=2, X=3, Y=5, z=1) + text = result.decode("latin-1") + assert text.startswith("\033_Ga=T,i=2,q=1,f=32,s=4,v=4,o=z,N=1,z=1,X=3,Y=5;") + assert text.endswith("\033\\") + + +def test_kitty_dirty_rect_delta(): + """Dirty-rect delta uses pixel-precise X/Y placement with cell-snapped cursor.""" + from gambaterm.graphics_scaler import FrameEncoder + + scaler = FrameEncoder( + scale=1, + kitty_scale=1, + refx=1, + refy=1, + refx_kitty=1, + refy_kitty=1, + cell_h=24, + cell_w=12, + ) + + baseline = np.full((10, 10), 0xFF00FF00, dtype=np.uint32) + scaler.encode_kitty(baseline, None) + + changed = baseline.copy() + changed[3, 4] = 0xFFFF0000 + result = scaler.encode_kitty(changed, baseline) + + text = result.decode("latin-1") + assert "X=4" in text + assert "Y=3" in text + assert "a=T" in text + assert "f=32" in text + assert ",i=1," not in text + + +def test_kitty_rebaseline_deletes_delta(): + """A rebaseline deletes the old delta (i=100) and sends a new keyframe (i=1).""" + from gambaterm.graphics_scaler import FrameEncoder + + scaler = FrameEncoder( + scale=1, + kitty_scale=1, + refx=1, + refy=1, + refx_kitty=1, + refy_kitty=1, + cell_h=24, + cell_w=12, + ) + + baseline = np.full((10, 10), 0xFF00FF00, dtype=np.uint32) + scaler.encode_kitty(baseline, None) + + changed = baseline.copy() + changed[0, 0] = 0xFFFF0000 + scaler.encode_kitty(changed, baseline) + + big_change = np.full((10, 10), 0xFF0000FF, dtype=np.uint32) + result = scaler.encode_kitty(big_change, changed) + text = result.decode("latin-1") + assert "a=d,d=i,i=100" in text + assert "a=T" in text + assert "f=32" in text + assert ",i=1," in text + + +def test_sixel_overlay_delta(): + """A small change produces a full-frame sixel overlay at refx,refy.""" + from gambaterm.graphics_scaler import FrameEncoder + + scaler = FrameEncoder( + scale=1, + kitty_scale=1, + refx=5, + refy=10, + refx_kitty=1, + refy_kitty=1, + cell_h=2, + cell_w=2, + ) + + baseline = np.full((10, 10), 0xFF00FF00, dtype=np.uint32) + scaler.encode_sixel(baseline, None) + + changed = baseline.copy() + changed[3, 4] = 0xFFFF0000 + result = scaler.encode_sixel(changed, baseline) + + text = result.decode("latin-1") + assert text.startswith("\033[5;10H\033[0m") + assert "\033P0;1;0q" in text + assert "\033\\" in text + + +def test_sixel_skip_identical(): + """Identical frames return empty bytes.""" + from gambaterm.graphics_scaler import FrameEncoder + + scaler = FrameEncoder( + scale=1, + kitty_scale=1, + refx=1, + refy=1, + refx_kitty=1, + refy_kitty=1, + cell_h=2, + cell_w=2, + ) + + frame = np.full((10, 10), 0xFF00FF00, dtype=np.uint32) + scaler.encode_sixel(frame, None) + result = scaler.encode_sixel(frame, frame) + assert result == b"" + + +def test_sixel_rebaseline_on_large_change(): + """A change exceeding the threshold produces a full-frame sixel at refx,refy.""" + from gambaterm.graphics_scaler import FrameEncoder + + scaler = FrameEncoder( + scale=1, + kitty_scale=1, + refx=2, + refy=3, + refx_kitty=1, + refy_kitty=1, + cell_h=2, + cell_w=2, + ) + + baseline = np.full((10, 10), 0xFF00FF00, dtype=np.uint32) + scaler.encode_sixel(baseline, None) + + changed = np.full((10, 10), 0xFF0000FF, dtype=np.uint32) + result = scaler.encode_sixel(changed, baseline) + + text = result.decode("latin-1") + assert text.startswith("\033[2;3H\033[0m") + assert "\033P0;1;0q" in text + assert "\033\\" in text + + +class TestKittyFrameCache: + @staticmethod + def test_keyframe_cache_hit(): + """Single-slot cache replays an identical rebaseline via a=p.""" + import os + from gambaterm.graphics_scaler import FrameEncoder + + os.environ["GAMBATERM_FORCE_REBASELINE"] = "1" + try: + scaler = FrameEncoder(1, 1, 1, 1, 1, 1, 24, 12) + frame_a = np.full((10, 10), 0xFF00FF00, dtype=np.uint32) + scaler.encode_kitty_frame( + frame_a, lambda d, w, h, **kw: b"\033_Ga=T") + result = scaler.encode_kitty_frame( + frame_a, lambda d, w, h, **kw: b"\033_Ga=T") + text = result.decode("latin-1") + assert "a=p" in text + assert "a=T" not in text + finally: + os.environ.pop("GAMBATERM_FORCE_REBASELINE", None) + + @staticmethod + def test_keyframe_cache_eviction(): + """Single-slot cache: only the most recent keyframe is cached.""" + import os + from gambaterm.graphics_scaler import FrameEncoder + + os.environ["GAMBATERM_FORCE_REBASELINE"] = "1" + try: + scaler = FrameEncoder(1, 1, 1, 1, 1, 1, 24, 12) + + frame_a = np.full((10, 10), 0xFF00FF00, dtype=np.uint32) + frame_b = np.full((10, 10), 0xFF0000FF, dtype=np.uint32) + + scaler.encode_kitty_frame( + frame_a, lambda d, w, h, **kw: b"\033_Ga=T") + scaler.encode_kitty_frame( + frame_b, lambda d, w, h, **kw: b"\033_Ga=T") + + result = scaler.encode_kitty_frame( + frame_a, lambda d, w, h, **kw: b"\033_Ga=T") + text = result.decode("latin-1") + assert "a=T" in text + assert "a=p" not in text + finally: + os.environ.pop("GAMBATERM_FORCE_REBASELINE", None) + + @staticmethod + def test_delta_after_cache_hit_keyframe(): + """Delta after a cache-hit keyframe uses DELTA_ID=100 with pixel-precise X/Y.""" + from gambaterm.graphics_scaler import FrameEncoder + + scaler = FrameEncoder(1, 1, 1, 1, 1, 1, 2, 2) + + frame_a = np.full((10, 10), 0xFF00FF00, dtype=np.uint32) + scaler.encode_kitty(frame_a, None) + + changed = frame_a.copy() + changed[3, 4] = 0xFFFF0000 + result_delta = scaler.encode_kitty(changed, frame_a) + text_delta = result_delta.decode("latin-1") + assert "Y=1" in text_delta + assert "a=T" in text_delta + assert "i=100" in text_delta + + +class TestKittyFrameBanding: + @staticmethod + def test_band_encode_positioned_images(): + """Each band is placed at its own cell-snapped cursor row.""" + from gambaterm.graphics_scaler import FrameEncoder + + scaler = FrameEncoder( + scale=1, kitty_scale=2, refx=1, refy=1, + refx_kitty=1, refy_kitty=1, cell_h=24, cell_w=12, + frame_banding=True, + ) + scaler._V = 3 + scaler._v_current = 0 + scaler._v_row_height = 48 + scaler.kitty_frame_no = 0 + + frame = np.full((144, 160), 0xFF00FF00, dtype=np.uint32) + last_frame = np.zeros((144, 160), dtype=np.uint32) + + # Band 0: y0=0, row=1, off_y=0. Includes delete of old band image. + result0 = scaler._encode_kitty_band(frame, last_frame) + text0 = result0.decode("latin-1") + assert "a=d,d=i,i=1" in text0 + assert "a=T" in text0 + assert "i=1" in text0 + assert "f=32" in text0 + assert "\033[1;1H" in text0 + assert scaler._v_current == 1 + + # Band 1: y0=48, py=96, row=1+96//24=5, off_y=96%24=0 + result1 = scaler._encode_kitty_band(frame, last_frame) + text1 = result1.decode("latin-1") + assert "i=2" in text1 + assert "\033[5;1H" in text1 + assert scaler._v_current == 2 + + # Band 2: y0=96, py=192, row=1+192//24=9, off_y=192%24=0 + result2 = scaler._encode_kitty_band(frame, last_frame) + text2 = result2.decode("latin-1") + assert "i=3" in text2 + assert "\033[9;1H" in text2 + assert scaler._v_current == 0 + + @staticmethod + def test_band_skip_unchanged(): + """Unchanged band regions return empty bytes and do not advance v_current.""" + from gambaterm.graphics_scaler import FrameEncoder + + scaler = FrameEncoder( + scale=1, kitty_scale=1, refx=1, refy=1, + refx_kitty=1, refy_kitty=1, cell_h=24, cell_w=12, + frame_banding=True, + ) + scaler._V = 2 + scaler._v_current = 0 + scaler._v_row_height = 72 + scaler.kitty_frame_no = 0 + + frame = np.full((144, 160), 0xFF00FF00, dtype=np.uint32) + last_frame = frame.copy() + + result = scaler._encode_kitty_band(frame, last_frame) + assert result == b"" + assert scaler._v_current == 1 + + @staticmethod + def test_is_banding_v1_noop(): + """V=1 means is_banding returns False (full frame fits in one band).""" + from gambaterm.graphics_scaler import FrameEncoder + + scaler = FrameEncoder( + scale=1, kitty_scale=1, refx=1, refy=1, + refx_kitty=1, refy_kitty=1, cell_h=24, cell_w=12, + frame_banding=True, + ) + scaler._V = 1 + assert scaler.is_banding is False + scaler._V = 2 + assert scaler.is_banding is True diff --git a/uv.lock b/uv.lock index 4117f61..eaa529e 100644 --- a/uv.lock +++ b/uv.lock @@ -30,15 +30,15 @@ wheels = [ [[package]] name = "blessed" -version = "1.44.0" +version = "1.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinxed" }, { name = "wcwidth" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7b/73/445c4ef32565d2f5af49f18c4163a32850c9328774ee0af74ed081d4ba15/blessed-1.44.0.tar.gz", hash = "sha256:d3b5037f0143eb7c80b2000be5bcdf86161af68aed231b91deadbce4e7379785", size = 14031337, upload-time = "2026-05-24T02:06:30.832Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/45/ad23d265373cdb7f255d2e3ed5f122b62914bd3c425bb21bca01ef699e5c/blessed-1.47.0.tar.gz", hash = "sha256:ea13e06ae40f24710325411c5fa9b689d215cf170276cf1fda41feddaec8d3e0", size = 14035743, upload-time = "2026-07-09T00:43:10.055Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7e/c3/e8effb323af40a5d8e85ab557da2f0475f12a31f3266f28d91d0bf406963/blessed-1.44.0-py3-none-any.whl", hash = "sha256:e1d2ed93d3d90d0a1494a8b134d188c83fb89ae25590af638b921e8c1c8fa223", size = 130232, upload-time = "2026-05-24T02:06:26.458Z" }, + { url = "https://files.pythonhosted.org/packages/99/d8/374001cbc3fb49d0c99e7b115498696c4beaf7468287aa84d36c34bb4e97/blessed-1.47.0-py3-none-any.whl", hash = "sha256:f4df54a32289b6a3eaca49387b4f6823ba7e04ddb5ffa18f5e1fde44e8b79681", size = 131212, upload-time = "2026-07-09T00:43:07.609Z" }, ] [[package]] @@ -254,7 +254,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "asyncssh", specifier = ">=2.9,<3.0.0" }, - { name = "blessed", specifier = ">=1.43.0,<2" }, + { name = "blessed", specifier = ">=1.47.0,<2" }, { name = "miniaudio", specifier = ">=1.2" }, { name = "numpy", specifier = ">=2.0.0,<3.0.0" }, { name = "pygame", marker = "extra == 'controller-support'", specifier = ">=1.9.5,<2.0.0" }, @@ -283,14 +283,14 @@ wheels = [ [[package]] name = "jinxed" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "ansicon", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/cb/eb/8821ce6e7386e96355f2c6be83944925b4a0870572896fd33a4e61b8aa5a/jinxed-2.0.0.tar.gz", hash = "sha256:64b960b8f8d9966e1b2e7cb57ea2b1aa0a8d7e68045b96dc7ef58201e43a1209", size = 127118, upload-time = "2026-05-08T21:25:25.917Z" } +sdist = { url = "https://files.pythonhosted.org/packages/39/d7/6e6d474ec5eaeca6a61acc17766bb19563b3a372b4b9d92910078f5fe49f/jinxed-2.1.0.tar.gz", hash = "sha256:7e755b831faa2443d44fb4ce7c0202eb9c3ed39bd5bf1193365888f4f6092b54", size = 61287, upload-time = "2026-07-03T15:46:48.153Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/00/b61668fd3b1e43b445979ec9a9e0af4781bf06884937d1e906f6a1be6dff/jinxed-2.0.0-py2.py3-none-any.whl", hash = "sha256:b3df1be5262a37145ef42875a8bbf918f1a563fbd035359650dd9fc0bb2b9294", size = 95364, upload-time = "2026-05-08T21:25:24.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1d/0a6be99b69bb448448403a45a241aff0dca751832ae54f027ed940d2eb52/jinxed-2.1.0-py2.py3-none-any.whl", hash = "sha256:43b802d18b70e405d410fb66eb2837d1101e7e5ea922e666507bb43f34d11d09", size = 104999, upload-time = "2026-07-03T15:46:47.114Z" }, ] [[package]] @@ -808,9 +808,9 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.7.0" +version = "0.8.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/74/c6428f875774288bec1396f5bfcbc2d925700a4dad61727fd5f2b12f249d/wcwidth-0.8.2.tar.gz", hash = "sha256:91fbef97204b96a3d4d421609b80340b760cf33e26da123ff243d76b1fda8dda", size = 1466253, upload-time = "2026-06-29T18:11:11.601Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, + { url = "https://files.pythonhosted.org/packages/96/42/3e5985a0a7e57de470b320c6d6a1a67c844f6737a587f3d44dd13d1819e7/wcwidth-0.8.2-py3-none-any.whl", hash = "sha256:d63947694a0539a1d51e01eda7caf800c291020e6cdd7e28ad7b14dd33ad4f85", size = 323166, upload-time = "2026-06-29T18:11:09.888Z" }, ]