"""
Software-rendered :class:`.VideoDriver` that stores frames in an :class:`~array.array`.
.. seealso::
:class:`.VideoDriver`
The protocol this driver implements.
"""
import itertools
from array import array
from copy import deepcopy
from dataclasses import dataclass
from typing import final, override
from warnings import warn
from libretro.api.av import retro_game_geometry, retro_system_av_info
from libretro.api.video import MemoryAccess, PixelFormat, Rotation, retro_framebuffer
from ..driver import FrameBufferSpecial, Screenshot
from .base import SoftwareVideoDriver
@dataclass(frozen=True)
class FramebufferDimensions:
width: int
height: int
pitch: int
@property
def length(self) -> int:
return self.height * self.pitch
[docs]
@final
class ArrayVideoDriver(SoftwareVideoDriver):
"""
Video driver that stores frames in an :class:`~array.array`.
Does not actually do any rendering,
but supports taking screenshots of the framebuffer.
"""
[docs]
def __init__(self):
"""Initialize the video driver."""
self._frame: array[int] | None = None
self._pixel_format: PixelFormat = PixelFormat.RGB1555
self._system_av_info: retro_system_av_info | None = None
self._rotation: Rotation = Rotation.NONE
self._frame_dims: FramebufferDimensions | None = None
[docs]
@override
def refresh(
self, data: memoryview | FrameBufferSpecial, width: int, height: int, pitch: int
) -> None:
requested_size = height * pitch
match data:
case memoryview() if self._frame is None:
self._frame = array("B", itertools.repeat(0, requested_size))
case memoryview() if self._frame:
if len(data) > len(self._frame):
# Reallocate frame buffer
self._frame = array("B", itertools.repeat(0, len(data)))
frameview = memoryview(self._frame)
frameview[: len(data)] = data
case FrameBufferSpecial.DUPE:
pass # Do nothing
case FrameBufferSpecial.HARDWARE:
warn("RETRO_HW_FRAME_BUFFER_VALID passed to software-only video refresh callback")
case _:
raise TypeError(
f"Expected a memoryview or a FrameBufferSpecial, got {type(data).__name__}"
)
self._frame_dims = FramebufferDimensions(width=width, height=height, pitch=pitch)
@property
@override
def needs_reinit(self) -> bool:
return self._frame is None
[docs]
@override
def reinit(self) -> None:
if not self._system_av_info:
raise RuntimeError("Cannot reinitialize video driver without system AV info from core")
geometry = self._system_av_info.geometry
bufsize = geometry.max_width * geometry.max_height * self._pixel_format.bytes_per_pixel
self._frame = array("B", itertools.repeat(0, bufsize))
@property
@override
def rotation(self) -> Rotation:
return self._rotation
@rotation.setter
@override
def rotation(self, rotation: Rotation) -> None:
if not isinstance(rotation, Rotation):
raise TypeError(f"Expected a Rotation, got {type(rotation).__name__}")
if rotation not in Rotation:
raise ValueError(f"Invalid rotation: {rotation}")
self._rotation = rotation
@property
@override
def pixel_format(self) -> PixelFormat:
return self._pixel_format
@pixel_format.setter
@override
def pixel_format(self, format: PixelFormat) -> None:
if format not in PixelFormat:
raise ValueError(f"Invalid pixel format: {format}")
if not isinstance(format, PixelFormat):
raise TypeError(f"Expected a PixelFormat, got {type(format).__name__}")
if self._pixel_format != format:
# If the pixel format has changed, recreate the frame buffer
self._frame = None
self._pixel_format = format
[docs]
@override
def screenshot(self, prerotate: bool = True) -> Screenshot | None:
if not (self._frame and self._frame_dims):
return None
last_frame_length = self._frame_dims.length
screen = self._frame[:last_frame_length]
screen_out = bytearray(self._frame_dims.width * self._frame_dims.height * 4)
pixel_buf = array("B", (0, 0, 0, 255))
rot = self._rotation if prerotate else Rotation.NONE
# Select rotation coefficients
# NOTE: output buffer is assumed to be four bytes per pixel (ABGR)
match rot:
case Rotation.NONE:
start_y = 0
delta_x = 4
delta_y = self._frame_dims.width * 4
is_sideways = False
case Rotation.NINETY:
start_y = (self._frame_dims.width - 4) * self._frame_dims.height * 4
delta_x = self._frame_dims.height * -4
delta_y = 4
is_sideways = True
case Rotation.ONE_EIGHTY:
start_y = self._frame_dims.width * self._frame_dims.height * 4 - 4
delta_x = -4
delta_y = self._frame_dims.width * -4
is_sideways = False
case Rotation.TWO_SEVENTY:
start_y = self._frame_dims.height * 4 - 4
delta_x = self._frame_dims.height * 4
delta_y = -4
is_sideways = True
# Copy from input buffer to output buffer, converting the pixel format
# and taking into account rotation (if prerotate is True).
if self._pixel_format == PixelFormat.XRGB8888:
for y in range(self._frame_dims.height):
i = y * self._frame_dims.pitch
o = start_y + y * delta_y
for _ in range(self._frame_dims.width):
next_i = i + 3
pixel_buf[2::-1] = screen[i:next_i]
screen_out[o : o + 4] = pixel_buf
i = next_i + 1
o += delta_x
elif self._pixel_format == PixelFormat.RGB565:
for y in range(self._frame_dims.height):
i = y * self._frame_dims.pitch
o = start_y + y * delta_y
for _ in range(self._frame_dims.width):
next_i = i + 2
b, r = screen[i:next_i]
g = ((b & 0xE0) >> 3) | ((r & 0x07) << 5)
b = (b & 0x1F) << 3
pixel_buf[0] = (r & 0xF8) | (r >> 5)
pixel_buf[1] = g | (g >> 6)
pixel_buf[2] = b | (b >> 5)
screen_out[o : o + 4] = pixel_buf
i = next_i
o += delta_x
elif self._pixel_format == PixelFormat.RGB1555:
for y in range(self._frame_dims.height):
i = y * self._frame_dims.pitch
o = start_y + y * delta_y
for _ in range(self._frame_dims.width):
next_i = i + 2
b, g = screen[i:next_i]
r = (g & 0x7C) << 1
g = ((b & 0xE0) >> 2) | ((g & 0x03) << 6)
b = (b & 0x1F) << 3
pixel_buf[0] = r | (r >> 5)
pixel_buf[1] = g | (g >> 5)
pixel_buf[2] = b | (b >> 5)
screen_out[o : o + 4] = pixel_buf
i = next_i
o += delta_x
# Swap width and height if buffer is rotated 90 or 270 degrees.
if is_sideways:
return Screenshot(
memoryview(screen_out),
self._frame_dims.height,
self._frame_dims.width,
self._rotation,
self._pixel_format,
)
return Screenshot(
memoryview(screen_out),
self._frame_dims.width,
self._frame_dims.height,
self._rotation,
self._pixel_format,
)
[docs]
@override
def get_software_framebuffer(
self, width: int, height: int, flags: MemoryAccess
) -> retro_framebuffer | None:
pass # TODO: Implement by returning a pointer to the internal frame buffer, and reinitializing it if the size has changed
@property
@override
def system_av_info(self) -> retro_system_av_info | None:
return deepcopy(self._system_av_info) if self._system_av_info else None
@system_av_info.setter
@override
def system_av_info(self, av_info: retro_system_av_info) -> None:
if not isinstance(av_info, retro_system_av_info):
raise TypeError(f"Expected a retro_system_av_info, got {type(av_info).__name__}")
self._system_av_info = deepcopy(av_info)
self.reinit()
@property
@override
def geometry(self) -> retro_game_geometry | None:
if not self._system_av_info:
return None
return deepcopy(self._system_av_info.geometry)
@geometry.setter
@override
def geometry(self, geometry: retro_game_geometry) -> None:
if not isinstance(geometry, retro_game_geometry):
raise TypeError(f"Expected a retro_game_geometry, got {type(geometry).__name__}")
if not self._system_av_info:
raise RuntimeError("Cannot set geometry without system AV info from core")
self._system_av_info.geometry.base_width = geometry.base_width
self._system_av_info.geometry.base_height = geometry.base_height
self._system_av_info.geometry.aspect_ratio = geometry.aspect_ratio
__all__ = ["ArrayVideoDriver"]