mirror of
https://github.com/MCKero6423/uv-k5-v3-emulator.git
synced 2026-10-02 11:07:31 +00:00
memsave, not pmemsave. The plan specified pmemsave on the strength of a timing measurement that never checked the contents; it turns out pmemsave takes a *physical* address and silently returns zeros for gFrameBuffer's virtual address. No error, no warning -- just a permanently blank screen. Caught by rendering a real frame and finding 0 lit pixels where the gdb path reported 1693. With memsave the count matches exactly, and the image reads correctly: both VFOs at 18.00000 MHz, PS/DWR/CL status bar. Two tests guard the decisions rather than the current text: the stub client raises if pmemsave is ever used, and a source check rejects subprocess/popen so frame reads cannot regress onto gdb, which would halt the guest. Measured 1.35 ms per frame with the guest still reporting status running.
101 lines
4.0 KiB
Python
101 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
|
"""LCD framebuffer decoding for the UV-K5 emulator.
|
|
|
|
The firmware keeps the display in gStatusLine (page 0) and gFrameBuffer
|
|
(pages 1-7), one byte per column, 8 vertical pixels per byte, LSB at the top --
|
|
the layout the ST7565 expects. Extracted from tools/screenshot.py so the web UI
|
|
and the CLI screenshotter cannot drift apart.
|
|
"""
|
|
import os
|
|
import struct
|
|
import zlib
|
|
|
|
LCD_WIDTH = 128
|
|
STATUS_ROWS = 1
|
|
FRAME_ROWS = 7
|
|
TOTAL_ROWS = STATUS_ROWS + FRAME_ROWS # 8 pages of 8 pixels = 64 lines
|
|
LCD_HEIGHT = TOTAL_ROWS * 8
|
|
FRAME_BYTES = FRAME_ROWS * LCD_WIDTH
|
|
STATUS_BYTES = LCD_WIDTH
|
|
|
|
|
|
def unpack(status: bytes, frame: bytes) -> list[list[int]]:
|
|
"""Column-major, LSB-at-top bytes -> a row-major pixel grid."""
|
|
pixels = [[0] * LCD_WIDTH for _ in range(LCD_HEIGHT)]
|
|
for page in range(TOTAL_ROWS):
|
|
src = status if page == 0 else frame[(page - 1) * LCD_WIDTH:page * LCD_WIDTH]
|
|
for col in range(LCD_WIDTH):
|
|
byte = src[col]
|
|
for bit in range(8):
|
|
if byte & (1 << bit):
|
|
pixels[page * 8 + bit][col] = 1
|
|
return pixels
|
|
|
|
|
|
def encode_png(pixels, scale: int = 4) -> bytes:
|
|
"""1-bit greyscale PNG, no third-party dependency.
|
|
|
|
Compression level 6 rather than 9: at streaming rates the CPU saving matters
|
|
more than the last few bytes on loopback.
|
|
"""
|
|
width, height = LCD_WIDTH * scale, LCD_HEIGHT * scale
|
|
raw = bytearray()
|
|
for row in pixels:
|
|
line = bytearray()
|
|
for value in row:
|
|
# Radio LCD is dark-on-light: 0 -> white, 1 -> black.
|
|
line.extend([0x00 if value else 0xFF] * scale)
|
|
for _ in range(scale):
|
|
raw.append(0) # filter type 0
|
|
raw.extend(line)
|
|
|
|
def chunk(tag: bytes, payload: bytes) -> bytes:
|
|
return (struct.pack(">I", len(payload)) + tag + payload
|
|
+ struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF))
|
|
|
|
return (b"\x89PNG\r\n\x1a\n"
|
|
+ chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 0, 0, 0, 0))
|
|
+ chunk(b"IDAT", zlib.compress(bytes(raw), 6))
|
|
+ chunk(b"IEND", b""))
|
|
|
|
|
|
class FrameGrabber:
|
|
"""Reads the LCD out of guest memory over QMP.
|
|
|
|
memsave, not pmemsave. The framebuffer symbols are CPU virtual addresses;
|
|
pmemsave interprets its argument as a *physical* address and silently returns
|
|
a block of zeros for these, which renders as a blank screen with no error
|
|
anywhere. memsave takes the virtual address and returns the real contents --
|
|
verified against the gdb path, both reporting 1693 lit bits on the same frame.
|
|
|
|
QMP, not gdb: measured ~1.35 ms per frame with the guest still reporting
|
|
status "running". The gdb path used by screenshot.py halts the guest on every
|
|
attach, which is unusable for a live stream and also perturbs key debounce
|
|
timing (see AGENTS.md). Do not reintroduce gdb here.
|
|
"""
|
|
|
|
def __init__(self, client, frame_addr: int, status_addr: int,
|
|
spool_dir: str = "/dev/shm"):
|
|
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")
|
|
|
|
def raw(self) -> tuple[bytes, bytes]:
|
|
"""Return (status, frame) exactly as the firmware holds them."""
|
|
self._client.command("memsave", val=self._frame_addr,
|
|
size=FRAME_BYTES, filename=self._frame_path)
|
|
self._client.command("memsave", val=self._status_addr,
|
|
size=STATUS_BYTES, filename=self._status_path)
|
|
with open(self._frame_path, "rb") as fh:
|
|
frame = fh.read(FRAME_BYTES)
|
|
with open(self._status_path, "rb") as fh:
|
|
status = fh.read(STATUS_BYTES)
|
|
return status, frame
|
|
|
|
def png(self, scale: int = 4) -> bytes:
|
|
status, frame = self.raw()
|
|
return encode_png(unpack(status, frame), scale)
|