mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 03:15:36 +00:00
Emulator: multiboot slots from the page, flash controller, portable tests
flash controller: store ACR/OPTKEYR instead of swallowing them, which is what stopped the factory bootloader from starting slots over the firmware's own serial protocol (0x0720 family); uvk5_socket/uvk5_testenv so a fresh checkout skips instead of failing; web UI slot table and Multiboot button; quick start, CONTRIBUTING, and stop tracking firmware images and radio dumps
This commit is contained in:
1 parent
ee80939c78
commit
2667e046e8
54 files changed
+5345
-347
No files matched your search
+96
-5
@@ -8,6 +8,8 @@ and the CLI screenshotter cannot drift apart.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
import tempfile
|
||||
import zlib
|
||||
|
||||
LCD_WIDTH = 128
|
||||
@@ -59,6 +61,19 @@ def encode_png(pixels, scale: int = 4) -> bytes:
|
||||
+ chunk(b"IEND", b""))
|
||||
|
||||
|
||||
# The display controller's own settings. Inversion and display-on are panel state,
|
||||
# not framebuffer content, so nothing in guest RAM reflects them -- which is exactly
|
||||
# why a menu entry that changes them looks like it did nothing.
|
||||
PANEL_PATH = "/machine/panel"
|
||||
|
||||
|
||||
def default_spool_dir() -> str:
|
||||
"""A tmpfs when the host has one, the system temp directory otherwise."""
|
||||
if os.path.isdir("/dev/shm"):
|
||||
return "/dev/shm"
|
||||
return tempfile.gettempdir()
|
||||
|
||||
|
||||
class FrameGrabber:
|
||||
"""Reads the LCD out of guest memory over QMP.
|
||||
|
||||
@@ -75,13 +90,18 @@ class FrameGrabber:
|
||||
"""
|
||||
|
||||
def __init__(self, client, frame_addr: int, status_addr: int,
|
||||
spool_dir: str = "/dev/shm"):
|
||||
spool_dir: str = None):
|
||||
self._client = client
|
||||
self._frame_addr = frame_addr
|
||||
self._status_addr = status_addr
|
||||
# pmemsave writes to a path, so a tmpfs avoids disk I/O every frame.
|
||||
self._frame_path = os.path.join(spool_dir, "uvk5-frame.bin")
|
||||
self._status_path = os.path.join(spool_dir, "uvk5-status.bin")
|
||||
# memsave writes to a path, so a tmpfs avoids disk I/O every frame --
|
||||
# where there is one. /dev/shm does not exist on Windows, and naming it
|
||||
# there makes every frame grab fail, which shows up only as a blank
|
||||
# screen with "no frame available" from the web UI.
|
||||
self._panel_warned = False
|
||||
self._spool_dir = spool_dir or default_spool_dir()
|
||||
self._frame_path = os.path.join(self._spool_dir, "uvk5-frame.bin")
|
||||
self._status_path = os.path.join(self._spool_dir, "uvk5-status.bin")
|
||||
|
||||
def raw(self) -> tuple[bytes, bytes]:
|
||||
"""Return (status, frame) exactly as the firmware holds them."""
|
||||
@@ -95,6 +115,77 @@ class FrameGrabber:
|
||||
status = fh.read(STATUS_BYTES)
|
||||
return status, frame
|
||||
|
||||
def panel_state(self):
|
||||
"""(invert, contrast, display_on) as the display controller holds them.
|
||||
|
||||
Read from the panel model rather than the framebuffer: 0xA6/0xA7, 0x81 and
|
||||
0xAE/0xAF live in the controller. If the model is not there (an older
|
||||
emulator build) the defaults describe an ordinary, unobstructed panel.
|
||||
"""
|
||||
try:
|
||||
invert = bool(self._client.command("qom-get", path=PANEL_PATH,
|
||||
property="invert"))
|
||||
contrast = int(self._client.command("qom-get", path=PANEL_PATH,
|
||||
property="contrast"))
|
||||
display_on = bool(self._client.command("qom-get", path=PANEL_PATH,
|
||||
property="display-on"))
|
||||
except Exception as exc:
|
||||
# Not fatal -- an emulator built without the panel model, or one that
|
||||
# just went away, still has a framebuffer worth showing. But say so
|
||||
# once: swallowing this silently is how a wrong render looks like a
|
||||
# firmware that ignores the setting. (It hid a stub bug in the test
|
||||
# for this very method.)
|
||||
if not self._panel_warned:
|
||||
self._panel_warned = True
|
||||
print(f"panel state unavailable, rendering as-is: {exc}",
|
||||
file=sys.stderr)
|
||||
return (False, 0, True)
|
||||
return (invert, contrast, display_on)
|
||||
|
||||
def panel_gram(self) -> bytes:
|
||||
"""The display controller's own display RAM: the screen as it is shown.
|
||||
|
||||
Preferred over raw() when the caller does not know where *this* firmware
|
||||
keeps its buffers. Builds that share an ancestor still differ in their
|
||||
display logic, and the multi-system release keeps its image somewhere else
|
||||
entirely -- but every one of them pushes pixels through the same controller.
|
||||
"""
|
||||
hexed = self._client.command("qom-get", path=PANEL_PATH, property="gram")
|
||||
return bytes.fromhex(hexed)
|
||||
|
||||
def panel_pixels(self):
|
||||
"""The screen as pixels, from the controller's own memory.
|
||||
|
||||
No hardware mirroring is applied. The driver programs 0xA1 (segment reverse)
|
||||
and 0xC0, but the columns arrive in the order the glass needs, so mirroring
|
||||
on top of the data flips the picture: against the guest's own framebuffer at
|
||||
the same instant, 8153 of 8192 pixels agree with no mirror and 6557 with
|
||||
one. The flags stay reported, not acted on.
|
||||
|
||||
Raises if the panel model is absent, which is how the caller knows to fall
|
||||
back to guest RAM -- see uvk5_stream.FramePump.
|
||||
"""
|
||||
gram = self.panel_gram()
|
||||
if len(gram) != TOTAL_ROWS * LCD_WIDTH:
|
||||
raise ValueError("panel GRAM is %d bytes, expected %d"
|
||||
% (len(gram), TOTAL_ROWS * LCD_WIDTH))
|
||||
return self._apply_panel(unpack(gram[:STATUS_BYTES], gram[STATUS_BYTES:]))
|
||||
|
||||
def panel_png(self, scale: int = 4) -> bytes:
|
||||
"""The panel's own memory, encoded as a PNG."""
|
||||
return encode_png(self.panel_pixels(), scale)
|
||||
def _apply_panel(self, pixels):
|
||||
"""Apply the panel settings that change the picture (inversion only)."""
|
||||
invert, _contrast, display_on = self.panel_state()
|
||||
if invert:
|
||||
# 0xA7: the panel inverts the whole image, which is a visible, fully
|
||||
# determined effect -- so the render follows it. Contrast is analogue
|
||||
# and cannot be rendered; display-on is deliberately *not* acted on
|
||||
# here: whether a software reset (0xE2) clears the display-on latch is
|
||||
# not certain, and blanking the screen on a guess would be worse than
|
||||
# reporting the flag and leaving the image alone.
|
||||
pixels = [[1 - value for value in row] for row in pixels]
|
||||
return pixels
|
||||
def png(self, scale: int = 4) -> bytes:
|
||||
status, frame = self.raw()
|
||||
return encode_png(unpack(status, frame), scale)
|
||||
return encode_png(self._apply_panel(unpack(status, frame)), scale)
|
||||
Reference in new issue
Block a user