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:
mckero committed 2026-10-01 14:54:34 +08:00
1 parent ee80939c78
commit 2667e046e8
54 files changed
+5345 -347

No files matched your search

+96 -5
View File
@@ -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)