Fix the two pixel bugs behind "the other firmware looks shifted"

uvk5_stream.py used STATUS_BYTES without importing it, so the panel branch raised NameError on every frame and a bare except swallowed it: every screen the page drew came from guest RAM at one build's addresses. The pump now reports which source it used and why, webui exposes it (/api/panel and frame_source), and test_uvk5_stream asserts the panel wins when reachable and that a fallback is announced.

The ST7565 column counter wrapped at 128 instead of the controller's 132, so addresses 128..131 came back as 0..3, fell outside the col>=4 store, and were dropped: every row lost its last four pixels, which is where the battery icon lives. Pre-fix, filling a page with 0xFF left columns 124..127 blank; now they carry content and the page's frame matches the panel memory 8192/8192.
This commit is contained in:
mckero committed 2026-10-01 15:59:39 +08:00
1 parent f4c9d343fc
commit 57106c0a66
6 files changed
+202 -7

No files matched your search

+32
View File
@@ -461,6 +461,38 @@ The general lesson: when a firmware's mode is chosen from a byte in RAM, the tri
input pin -- it is whatever wrote that byte before resetting. Find the writer in the source
(`0x05DD` here) and the `#ifdef` around it, and you have the whole condition.
### Two pixels bugs behind "the other firmware looks shifted"
Both were found by making the page *say where its picture came from*, and both had been
surviving because the wrong output looked plausible.
**The fallback that quietly drew every frame.** `uvk5_stream.py` used `STATUS_BYTES`
without importing it, so the panel branch raised `NameError` on every frame and a bare
`except Exception: pass` swallowed it. Every screen the page drew came from guest RAM at
one firmware build's addresses: right-looking for that build, plausible and offset for any
other. Found by reporting the source and the reason (`/api/panel` answered
`source: framebuffer, note: NameError: name 'STATUS_BYTES' is not defined`). With the panel
path working, the page's `/frame.png` matches the controller's own memory 8192/8192;
before the fix it was 5594/8192 against the same memory. `tools/test_uvk5_stream.py` now
asserts that the panel wins when it is reachable, and that a fallback is announced with its
reason.
**The column counter wrapped at 128 instead of 132.** The controller has 132 column
drivers and the glass shows 128 of them starting at column 4, which is why the model stores
pixels at `col - 4`. The counter was masked with `& 0x7f`, so addresses 128..131 came back
as 0..3, fell outside the `col >= 4` store, and were dropped: **every row lost its last four
pixels**. The battery icon lives in exactly those columns, so the symptom was a battery in
the wrong place and a picture that "looked shifted" on builds that draw to column 127 --
while the localised build, whose rightmost four columns are blank anyway, looked fine. That
is why this read as a firmware-specific problem. Measured, before and after: filling a page
with `0xFF` left columns 124..127 blank; now they light (10/10/12/7 lit across them), and
the same firmware's frame matches the panel memory 8192/8192.
The lesson in both cases is the same one this file keeps repeating: **a path that silently
substitutes a different source turns a hard error into a plausible wrong answer**, and a
byte that is off by four is invisible until something that matters lives in those four
columns. Report the source, and test that the preferred path is actually taken.
## The keypad: two real bugs, both fixed
The old note here said "keys reach the firmware but the UI does not react" and
+24
View File
@@ -376,6 +376,30 @@ PTT+SIDE1/SIDE2 与 MENU(那是应用的几个特殊模式)、开机窗口
复位前写这个字节的那个程序。去源码里找到写入者(这里是 `0x05DD`)和它外面的 `#ifdef`,整个条件
就到手了。
### 「换个固件就偏移」背后的两个像素 bug
两个都是靠让**页面自己说出画面来自哪里**找到的;两个能长期存活,都是因为错的那份输出看起来"挺合理"。
**那条悄悄画出了每一帧的回落路径。** `uvk5_stream.py` 用了 `STATUS_BYTES` 却没导入它,
于是面板分支每一帧都抛 `NameError`,被一句裸的 `except Exception: pass` 吞掉。网页画出的每一幅
画面都来自 guest RAM,用的是**某一份固件的地址**:装着那份固件时看着对,换别的就"貌似合理但整体
偏移"。让它现形的办法是报出**来源与原因**(`/api/panel` 回答:
`source: framebuffer, note: NameError: name 'STATUS_BYTES' is not defined`)。面板路径通了以后,
网页的 `/frame.png` 与控制器自己的显存**逐像素相同(8192/8192)**;修复前对同一份显存只有
**5594/8192**。`tools/test_uvk5_stream.py` 现在断言"面板可达时必须用面板"、"回落必须带原因被播报"。
**列计数器在 128 处回绕,而真实控制器是 132。** 控制器有 132 个列驱动,玻璃只显示其中 128 个、
且从第 4 列开始 —— 这就是模型把像素存到 `col - 4` 的原因。但计数器被 `& 0x7f` 掩过,于是地址
128..131 变成 0..3,落进 `col >= 4` 之外被丢弃:**每一行都丢掉了最右边 4 个像素**。而**电量图标
恰好就在那几列**,所以症状是"电量位置不对",以及在画到列 127 的固件上"画面看着偏了"——而那份汉化版
最右 4 列本来就是空的,看着正常。这就是它被读成"固件相关问题"的原因。前后都量了:把一页填满
`0xFF` 时,修复前列 124..127 全黑,修复后有内容(四列分别点亮 10/10/12/7),同一固件的画面与面板
显存 8192/8192。
两次的教训是这份文件反复在说的那一句:**一条悄悄换成别的来源的路径,会把一个硬错误变成貌似合理的
错答案**;而一个偏了四列的字节,在"重要的东西恰好住在那四列里"之前,是看不出来的。要报出来源,
并且要测"该走的那条优选路径确实被走了"。
## 键盘:两个真 bug,都已修复
这里原来的笔记写的是"按键到达了固件但界面不反应",并且归咎于机器模型。结果发现有**两个
+12 -2
View File
@@ -814,7 +814,7 @@ static uint8_t st7565_xfer(void *opaque, uint8_t out)
{
const char *panel_probe = g_getenv("UVK5_PANEL_PROBE");
static unsigned panel_probe_n;
if (panel_probe && panel_probe_n < 600) {
if (panel_probe && panel_probe_n < 40000) {
FILE *f = fopen(panel_probe, "a");
if (f) {
fprintf(f, "PANEL a0=%d cs=%d page=%d col=%d byte=%02x\n",
@@ -833,7 +833,17 @@ static uint8_t st7565_xfer(void *opaque, uint8_t out)
if (s->col >= 4 && s->col < 132) {
s->gram[s->page & 7][s->col - 4] = out;
}
s->col = (s->col + 1) & 0x7f;
/*
* The controller's column counter runs 0..131 -- it has 132 column drivers,
* and the glass shows 128 of them starting at 4, which is why the store above
* subtracts 4. Masking the counter to seven bits made it wrap at 128 instead:
* the four bytes addressed 128..131 were then re-read as 0..3, fell outside
* the store, and were dropped. Every row lost its last four pixels -- and the
* battery icon lives in the rightmost columns, so the symptom was a wrong
* battery and a picture that looked shifted, on builds that draw to column
* 127. Measured: filling a whole page with 0xFF left columns 124..127 blank.
*/
s->col = (s->col + 1) % 132;
return 0xff;
}
+62
View File
@@ -5,6 +5,7 @@ import threading
import time
import unittest
from uvk5_lcd import LCD_HEIGHT, LCD_WIDTH, STATUS_BYTES, TOTAL_ROWS, unpack
from uvk5_stream import FramePump
@@ -130,5 +131,66 @@ class TestFramePump(unittest.TestCase):
self.assertTrue(wait_for_frame(pump), "did not resume after rebind")
class FakePanel:
"""A grabber whose panel works, and whose guest RAM must never be touched.
read() raises, so any test that silently takes the fallback fails loudly instead of
comparing two wrong pictures -- which is how the bug below stayed invisible: the
fake client used by the tests above answers memsave and nothing else, so the panel
branch failed there too, the bare except swallowed it, and every one of them was
really exercising the fallback.
"""
def __init__(self):
self.gram = bytes((i * 7) & 0xFF for i in range(TOTAL_ROWS * LCD_WIDTH))
def panel_gram(self):
return self.gram
def panel_pixels(self):
return unpack(self.gram[:STATUS_BYTES], self.gram[STATUS_BYTES:])
def raw(self):
raise AssertionError("guest RAM was read while the panel was available")
class TestFrameSource(unittest.TestCase):
"""Which memory the page draws is not a detail -- it decides whose screen is right.
The panel is the only firmware-independent source: every build pushes pixels through
the same controller, while guest RAM is correct only for the build whose buffer
addresses were passed in. So when the panel is reachable it must be used.
uvk5_stream.py used STATUS_BYTES without importing it. The panel branch therefore
raised NameError on every frame, a bare except swallowed it, and every screen the
page drew came from guest RAM at one firmware's addresses -- right-looking for that
firmware, plausible and offset for any other. These two tests fail on that code.
"""
def test_the_panel_is_used_when_it_is_there(self):
pump = FramePump(None, 0, 0)
status, frame, pixels = pump._grab(FakePanel())
self.assertEqual(pump.source()[0], "panel",
"the panel is reachable, so guest RAM must not be used")
self.assertIsNone(pump.source()[1])
self.assertEqual(len(status), STATUS_BYTES)
self.assertEqual(len(frame), TOTAL_ROWS * LCD_WIDTH - STATUS_BYTES)
# unpack() returns one entry per LCD line, not per page: eight pages of eight.
self.assertEqual(len(pixels), LCD_HEIGHT)
self.assertEqual(len(pixels[0]), LCD_WIDTH)
def test_a_broken_panel_falls_back_and_says_why(self):
notes = []
pump = FramePump(None, 0, 0, on_fallback=notes.append)
broken = FakePanel()
broken.panel_gram = lambda: (_ for _ in ()).throw(RuntimeError("no panel model"))
broken.raw = lambda: (b"\x00" * STATUS_BYTES,
b"\x00" * (TOTAL_ROWS * LCD_WIDTH - STATUS_BYTES))
pump._grab(broken)
self.assertEqual(pump.source()[0], "framebuffer")
self.assertIn("no panel model", pump.source()[1])
self.assertEqual(len(notes), 1, "the fallback is announced once, with its reason")
if __name__ == "__main__":
unittest.main()
+40 -3
View File
@@ -17,12 +17,24 @@ looks live.
import threading
import time
from uvk5_lcd import FrameGrabber, default_spool_dir, encode_png, unpack
# STATUS_BYTES is not decoration: _grab() splits the controller's memory into the
# status line and the frame with it. It was missing from this import for the whole
# life of the panel path, so the panel branch raised NameError on every frame, the
# bare except below swallowed it, and every picture the page ever drew came from the
# guest-RAM fallback instead -- which needs *that build's* buffer addresses. With the
# CN addresses and a CN firmware that looked right; with any other firmware the screen
# was plausible and offset, which is exactly how it was reported.
from uvk5_lcd import STATUS_BYTES, FrameGrabber, default_spool_dir, encode_png, unpack
class FramePump:
def __init__(self, client, frame_addr: int, status_addr: int,
fps: int = 15, scale: int = 4, spool_dir: str = None):
fps: int = 15, scale: int = 4, spool_dir: str = None,
on_fallback=None):
# Called once with the reason the first time a frame has to come from guest RAM.
# It used to be swallowed whole, which is how a NameError in the panel branch
# went unnoticed for as long as the fallback kept producing plausible pictures.
self._on_fallback = on_fallback
self._frame_addr = frame_addr
self._status_addr = status_addr
# Resolved by FrameGrabber: /dev/shm on Linux, the temp directory on
@@ -36,6 +48,13 @@ class FramePump:
self._png = None
self._raw = None
self._generation = 0
# Which memory the pixels came from. The panel is preferred and is the only
# source that is firmware-independent; the guest-RAM fallback needs that
# build's own buffer addresses, so getting it wrong shows up as a picture
# that is plausible but offset. Reporting it is what turns "the screen looks
# wrong" into "it came from the fallback, and here is why".
self._source = None
self._note = None
self._stop = threading.Event()
self._thread = None
@@ -91,6 +110,11 @@ class FramePump:
self._stop.wait(slack)
def source(self):
"""(which memory the last frame came from, why the fallback happened)."""
with self._lock:
return self._source, self._note
def _grab(self, grabber):
"""One frame: (status, frame, pixels), from the panel if it is there.
@@ -105,8 +129,21 @@ class FramePump:
try:
pixels = grabber.panel_pixels()
gram = grabber.panel_gram()
self._source = "panel"
self._note = None
return gram[:STATUS_BYTES], gram[STATUS_BYTES:], pixels
except Exception:
except Exception as exc:
# Remember the first reason rather than overwriting it every frame: the
# cause does not change while the emulator runs, and the first one is
# the informative one.
if self._source != "framebuffer":
self._note = "%s: %s" % (type(exc).__name__, exc)
if self._on_fallback is not None:
try:
self._on_fallback(self._note)
except Exception:
pass
self._source = "framebuffer"
status, frame = grabber.raw()
return status, frame, unpack(status, frame)
def latest(self):
+32 -2
View File
@@ -154,7 +154,10 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4,
# One background grabber for every client. client may be None: the emulator
# can be powered off, and the page still has to load.
pump = FramePump(client, frame_addr, status_addr, fps=TARGET_FPS, scale=scale)
pump = FramePump(client, frame_addr, status_addr, fps=TARGET_FPS, scale=scale,
on_fallback=lambda note: log.add(
"qemu", "panel unavailable, drawing from guest RAM at "
f"0x{frame_addr:08X}: {note}"))
pump.start()
app.config["PUMP"] = pump
app.config["SUPERVISOR"] = supervisor
@@ -270,7 +273,34 @@ def create_app(client, frame_addr: int, status_addr: int, scale: int = 4,
# The emulator can die under us; that is a state to report, not a 500.
return jsonify(powered=False, status="unreachable", error=str(exc))
return jsonify(powered=True, speaker=speaker_on(),
panel=panel_state(), firmware=firmware_info(), **info)
panel=panel_state(), firmware=firmware_info(),
frame_source=pump.source()[0], **info)
@app.get("/api/panel")
def api_panel():
"""The display controller's own memory, and where /frame.png came from.
/frame.png prefers the panel and falls back to guest RAM when the panel model
is not there. The fallback needs the addresses of *that* build's buffers, so
it is the path that can look right and be offset at the same time -- and a
caller cannot tell which it got from the picture. This says which, gives the
reason for a fallback, and hands back the controller's own bytes so the two
can be compared without guessing.
"""
source, note = pump.source()
body = {"source": source, "note": note,
"frame_addr": frame_addr, "status_addr": status_addr}
target = active_client()
if target is None:
return jsonify(body, powered=False)
try:
body["gram"] = target.command("qom-get", path=PANEL_PATH, property="gram")
body["invert"] = bool(target.command("qom-get", path=PANEL_PATH,
property="invert"))
body["bytes"] = len(body["gram"]) // 2
except Exception as exc:
body["panel_error"] = str(exc)
return jsonify(body)
@app.get("/api/firmware")
def api_firmware():