Skip to content

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

dictpen-vm — 有道词典笔实机模拟开发环境

在 Windows 上用 QEMU 运行词典笔真实固件的虚拟机(不需要管理员权限,也不需要 WSL)。 框架本身不含固件:一台机器会变的东西全在 packs/<名字>/ 里,启动时用 --pack 选一台。 仓库自带十个包:a6pro / a7p / a7pro / p5 / s6pro / s7pro / x3s342 / x5pro / x6pro / x7。

最快上手

第一次用,先下载(之后每次只要第 4 步)。bootstrap.py 下的是 QEMU、通用内核与 initramfs 工具链,以及固件镜像:a6pro / a7pro / s6pro / s7pro / x5pro 取上游资产; a7p / p5 / x3s342 的已切好镜像也在 release 里,直接下载并核对 MD5(支持断点续传; 大文件按 Range 多线程下,默认 32 线,可用 DICTPEN_DL_THREADS 调低)。 固件按包分开、都标 optional,python scripts\bootstrap.py --only firmware-x5pro 只取一台。 x6pro、x7 仍要手工提取:没有现成整镜像(x6pro/x7 从各自 OTA 包提取,步骤见 docs/发布说明.md §3);--check 会把缺的文件与出处一起打印出来。

:: 1) 拿到仓库:clone,或从 GitHub Releases 下载 dictpen-vm-<版本>.zip 解压
git clone https://github.com/penosext/dictpen-vm.git

:: 2) 补齐依赖与固件镜像(只做一次,约 4GB;先加 --check 可以只看缺什么)
python scripts\bootstrap.py

:: 3) 控制台要 PySide6(只用命令行可以跳过)
python -m pip install PySide6

:: 4) 打开控制台,点工具条上的「启动」
vm-console.cmd

控制台就是「笔的屏幕 + 状态栏 + 抽屉」:工具条下拉换机型,抽屉里有串口 / 应用 / 命令 / 文件 / 配置 / 蓝牙 / 声音。不想开窗口也可以全走命令行:

python scripts\dpctl.py up --pack s7pro    :: 起机(--pack 选机型)
python scripts\dpctl.py status             :: 看状态
python scripts\dpctl.py down               :: 关机

只想看笔的屏幕:run-vm.cmd 会弹出画面窗口(等价 dpctl up,默认显示前端是 vmgui)。

常用命令

命令 一句话
dpctl packs [名字] 列出八个包:镜像在不在、MD5 对不对
dpctl up --pack <名> 起一台机器;--fresh 清空状态盘、--fast 用快照约 6 秒回首页、--display none 不开窗
dpctl down / status 关机 / 看状态(run-vm.cmd 起的也认)
dpctl console 打开控制台窗口(等价双击 vm-console.cmd)
dpctl wait --adb 等笔起来,顺带把 adb 连上
dpctl sh -c "id" 在笔里跑一条命令(返回真实退出码)
dpctl shell 进笔的命令行(root@127.0.0.1:2222)
dpctl put 本地 远端 / get 远端 本地 传文件进出
dpctl screenshot --out x.png 截笔的屏幕
dpctl tap X Y 在笔屏上点一下(坐标按包声明的面板换算)
dpctl fast save / up --fast 存 / 用首页快照(约 6 秒;快照不含声音)
dpctl adb shell 电脑端 adb 调试(自动 connect)

完整参数用 python scripts\dpctl.py <子命令> --help。三条最容易踩的:控制台占着 QMP, 它开着时 dpctl tap 会被拒(先 up --display none);dpctl down 不会关控制台窗口; 快速启动没有声音(usb-audio 不可迁移)。

文档

文档 给谁看
docs/使用手册.md 想用起来:依赖、固件、启动、看画面与操作、传文件、adb、持久化、虚拟硬件、激活
docs/固件包开发教程.md 想加一台机器:pack.toml 全字段、适配方式、写 runtime、检查清单
docs/插件模型.md 想改框架 / 写插件:宿主与四个插件点、接口契约、零机型自检
docs/疑难排查.md 卡住了:黑屏花屏、触摸、联网、首启、激活、SSH/adb
docs/固件能力盘点.md 想补 miniapp 设备能力:各机型固件契约与 VM 缺口
docs/发布说明.md 安装 / 升级:本次更新、四步安装、镜像命名、已知问题

已验证能力

能力 状态
实机 rootfs(EROFS/ext4)完整引导 ✅ 主机名 YoudaoDictionaryPen-000
root SSH 登录(密钥认证,端口 2222) ✅
串口控制台(TCP 4444,可脚本化) ✅
可写根文件系统(overlayfs) ✅ 改动本次运行有效
GUI 显示窗口(vmgui,默认;QEMU GTK 为回退) ✅ 按包声明的面板尺寸(560×170…960×480)显示 framebuffer;vmgui 按取到的帧尺寸自适应分辨率与缩放(见"用 vmgui 客户端")
鼠标 = 触摸(点击 QEMU 窗口驱动笔的 UI) ✅ uinput 触摸桥 + BTN_TOUCH,见"触控"一节
键盘/鼠标输入设备 ✅ virtio-keyboard/tablet 已接入
词典笔控制台(dpctl console) ✅ 设备画面 + 状态栏 + 抽屉(串口 / guest 应用 / 命令 / 文件 / 配置 / 蓝牙 / 声音);设备下拉切换、F11 全屏、实体键与快速启动工具条
声音输出(宿主) ✅ QEMU usb-audio + 宿主 dsound;控制台声音页读写客机音量/静音,并常驻提示宿主输出设备的静音/音量;选中其他操作会中断上一条;a6/a7 固件仅带 tinyalsa(tinyplay 需 48 kHz 立体声)
实体键(Home:6 台;音量±:仅 x6pro) ✅ 客机内 uinput 替身 + 控制台工具条;Home 回桌面;音量改真实 ALSA(见各包 README 的实体键节、packs/x6pro/DEVLOG.md §7.11、台账 Q92)
快速启动(首页快照) ✅ dpctl fast save 把普通状态盘复制成 qcow2 并在首页 savevm;dpctl up --fast / 控制台「快速启动」约 6 s 恢复首页。快照无音频(usb-audio 不可迁移),要声音请用普通「启动」冷启动(见台账 Q93)
持久化状态盘(/state、/userdata 重启不丢) ✅ 第二块 virtio 盘,首次开机自动格式化 ext4
PC 端 adb 调试(adb shell / push / pull) ✅ guest adbd 走 TCP,host 侧自动 connect
有道 UI(miniapp/falcon/Skia)渲染 ✅ 上屏、方向正确;窗口按包声明的面板尺寸显示(见"显示与输入"节)
Wi-Fi 硬件虚拟化 ✅ mac80211_hwsim 虚拟网卡(wlan0)+ 笔自带的 nl80211 栈(wpa_supplicant/wifi-manager);wlan1 上是可扫描/可关联的虚拟热点 Youdao-VM,并带转发+NAT 上行(见"无线")
蓝牙硬件虚拟化 ⏳ 客机内虚拟 HCI(hci_uart + 宿主 bt-hci-emu.py);控制台可上电/有界扫描,能触发模拟设备;不是电脑蓝牙,连接真实设备需 USB 直通
跳过激活(开发凭证) ✅ S00license 开机写入 /userdata/cfg/license,app 直接进桌面(见"激活与凭证恢复")
电池/电源 ✅ S00battery 用 tmpfs 发布健康电量,修掉进桌面后约一分钟的"低电量关机"(见"电源")
崩溃日志清理 ✅ S00corelog 开机把 /userdisk/corefile 裁到最新 500 个,并常驻 janitor 每 10 分钟再裁(防状态盘被堆满导致点击失效)

开发与调试(dpctl)

dpctl 覆盖「启动 → 进系统 → 传文件 → 调试」的闭环,常用命令见上面的表;完整参数用 --help,场景化说明见 docs/使用手册.md。

端口

用途 宿主机 客机
SSH (dropbear) 127.0.0.1:2222 22
ADB (adbd) 127.0.0.1:15037 5555
串口控制台 127.0.0.1:4444 —
蓝牙 HCI(hci_uart) 127.0.0.1:4447 /dev/ttyAMA1
QEMU 监视器 127.0.0.1:4445 —
QMP(tap 注入用) 127.0.0.1:4446 —

ADB 宿主端口特意选在 adb 的模拟器探测区间(5554–5585)之外,避免 adb devices 里出现重复的 emulator-XXXX。

排查显示问题时,可以用 QEMU 的 trace 看 guest 到底有没有把画面推给 host:

set DICTPEN_TRACE=virtio_gpu*
python scripts\dpctl.py up
:: 事件写到 vm\trace.log,关注 res_flush / res_xfer_toh_2d 是否还在增长

adb 说明

真机的 adbd 默认只走 USB(usb_ffs + S98usbdevice),QEMU 里没有 USB gadget, 因此由 dpctl adb 在实机内以 TCP 方式拉起它(ADB_TCP_PORT=5555,见 /etc/profile.d/adbd_tcp_port.sh),并预建 adbd 需要的 /data/misc/adb/adb_keys 与 /tmp/.adb_auth_verified(后者是 /usr/bin/adbd_auth.sh 的免密标记)。

手工使用时:adb connect 127.0.0.1:15037 之后即可 adb shell。

持久化

改动落在第二块 virtio 盘上,每个固件包一份(vm/state-<包名>.img,不同机型不串味), 重启不丢:

  • overlay 的 upper 层改为 /state/upper(系统级修改变为持久);
  • /userdata、/userdisk 由 /state/... bind mount(/data 是指向 /userdata 的软链,随之一并持久);
  • 状态盘格式为 ext4,首次开机由实机自带 e2fsprogs(chroot 调 /sbin/mke2fs)创建;
  • 若状态盘不可用,自动回落到原来的 tmpfs 行为,VM 仍能正常开机。

扩容只改 VM 状态盘:控制台「配置」页把它扩到更大后,客机 df 会变;x6pro、x7、 a6pro、x5pro、s6pro、s7pro、p5、x3s342 的设置页已由 preload shim 改成读 VM 状态盘(扩容后 会跟着变,两条入口「空间清理」「关于→存储容量」都跟随)。a7p 用户态是 uClibc,现有 glibc shim 装不进去,这一台仍显示固件固定容量。 实际数字看「配置」页的宿主/客机两格。

dpctl up --fresh 可删掉当前包的盘重来(下次开机重新格式化)。

GUI 与硬件虚拟化说明

显示(GUI)

  • 虚拟机通过 bochs-display 提供显示设备(线性显存,guest 写内存即上屏),内核创建 /dev/fb0(bochs-drmdrmfb),QEMU 用 GTK 窗口呈现画面(-display gtk)。 早期用的是 virtio-gpu-pci,因为 falcon 不做 flush 而看不到画面,见 显示:换掉 virtio-gpu;
  • 可通过 console.cmd 或 SSH 往 /dev/tty1 写内容,画面即显示在该窗口。
  • ⚠️ SDL 后端在本机有兼容问题(QEMU 主线程会卡死),故使用 GTK; 若 GTK 不可用,可试 -display sdl 或 -display default。

无线:Wi-Fi 与蓝牙硬件虚拟化

真机的无线是 SDIO 组合芯片(Wi-Fi 由 atbm603x_wifi_sdio / bl_fdrv 带 Firmware 跑, 蓝牙由 rtc_hciattach 挂在 UART 上),QEMU 里没有这些硬件,VM 用等效的虚拟硬件顶替, 笔里的软件栈原样运行(wpa_supplicant -Dnl80211、wifi-manager、bluetoothd、bt-manager)。

Wi-Fi:wlan0 + 虚拟热点

  • guest 内核加载 rfkill / cfg80211 / libarc4 / mac80211 / mac80211_hwsim (mac80211_hwsim radios=2),生成 wlan0(给笔的 station 栈)和 wlan1;
  • 笔自带的脚本会自动接上:S21mountall.sh 里的 guardian_run runWpas / runWifiMgr 等到 wlan0 出现,随即启动 wpa_supplicant -Dnl80211 -iwlan0 与 wifi-manager, 所以 app 的「配置网络」页可用(开关、扫描、连接都能走通);
  • 可扫描、可关联的热点:笔的固件没有 hostapd(真机 AP 由芯片固件实现), 于是让 wlan1 跑一份 Debian aarch64 的 hostapd——guest 内核就是 aarch64, 64 位二进制直接能跑(只差 libc/libnl/libssl,和 hostapd 一起打进 initramfs)。 SSID Youdao-VM、open 网络、地址 10.201.126.1(与固件 dnsmasq 的 dhcp-range 同网段,S80dnsmasq 会直接开始发租约)。 实测:wlan0 扫描到 [ESS] Youdao-VM,关联后 wpa_state=COMPLETED。
  • 开关与配置:内核 cmdline 加 DICTPEN_AP=0 可关掉热点; hostapd.conf 落在 guest 的 /root/.vm/ap/,加 wpa=2 + wpa_passphrase= 即为 WPA2。
  • 热点的上行:guest 里没有 iptables,所以 initramfs 额外带了一份 Debian aarch64 iptables-legacy(libip4tc/libip6tc/libxtables + 用到的 xtables 插件)。 S00wifi-ap 在起完 AP 后会打开转发并装好 NAT:
    ip_forward = 1
    iptables -t nat -A POSTROUTING -o eth0 -s 10.201.126.0/24 -j MASQUERADE
    iptables -A FORWARD -i wlan1 -o eth0 -j ACCEPT
    iptables -A FORWARD -i eth0  -o wlan1 -j ACCEPT
    
    开机日志会打 S00wifi-ap: uplink NAT ready (10.201.126.0/24 -> eth0)。 实测:以 10.201.126.x 为源、经 eth0 出去的目标 3/3 通,/proc/net/nf_conntrack 里能看到 SNAT(src=10.201.126.1 → 8.8.8.8,回包 dst=10.0.2.15)。
  • ⚠️ 但 pen 自己没法当这个热点的客户端:热点的 AP(wlan1)和 pen 的站点(wlan0) 在同一个内核里,wlan0 发的 ARP 请求虽然上了空口(iw dev wlan1 station dump 的 rx/tx 计数会涨),但内核给它的 ARP 应答因为"目标 IP 也是本机地址"被本地消化、 不会发回空中,于是网关 MAC 永远解析不出来(/proc/net/arp 里一直是 0x0), 客户端方向的数据流根本起不来。这是同内核 AP+STA 的固有限制,不是配置问题。 真机上热点的客户端是手机(另一个网络栈),所以不受影响——上面的转发+NAT 规则 就是给那种客户端准备的。 实际影响:pen 关联热点后仍走 eth0(metric 1002 优于 Wi-Fi 的 3003),云功能照常。

蓝牙:hci0(hci_uart + 宿主控制器模拟)

  • 两条常见路子都不可用:QEMU 早已删除蓝牙模拟(没有 -bt、也没有 usb-bt-dongle), Debian 的 arm64 内核里也没有 hci_vhci.ko。因此改用 hci_uart + 第二个串口: QEMU 把 -chardev socket(TCP 4447)接到 guest 的 /dev/ttyAMA1, guest 里 btattach -B /dev/ttyAMA1 -S 115200 -P h4 挂上 H4 协议 → 得到 hci0;
  • 链路另一端是宿主上的 scripts/bt-hci-emu.py(由 dpctl up 自动拉起, dpctl down 自动结束;日志 vm/bt.log,每命令的 in/out 都有): 它按 H4 收发、扮演一个 4.2 双模控制器(Reset / Read Local Version / Features / Commands / BD_ADDR / Buffer Size / LE 系列命令,以及 Inquiry、LE 广播报告等)。 guest 内核因此能走完控制器初始化,BlueZ 会注册并上电 hci0;
    • 内核只在"回复比它期望的短"时才判失败,所以未知命令一律回成功 + 16 字节, 而不是 Unknown HCI Command(否则初始化链会在中途断掉);
  • guest 侧还差两件固件没做的事,initramfs 自动补上:
    1. 系统 D-Bus:这套固件只起了 session bus,而 bluetoothd 需要 /var/run/dbus/system_bus_socket,缺了就直接 Unable to get on D-Bus 退出;
    2. hciconfig 垫片:rootfs 里没有这个命令,而 bt_init.sh / runBluez 要用它轮询 hci0(缺了会死循环),垫片按 /sys/class/bluetooth 报告设备。
  • 笔的蓝牙栈与真机一致,由 app 的「蓝牙」入口拉起(runBluez → bluetoothd, runBtMgr → bt-manager)。手工验证:
ls /sys/class/bluetooth          # hci0 在不在
hciconfig                        # 垫片输出 hci0
/usr/bin/runBluez &              # 内含 bluetoothd -C -n -E
/usr/bin/dbus-send --system --print-reply --dest=org.bluez / \
    org.freedesktop.DBus.ObjectManager.GetManagedObjects | grep hci0
/usr/bin/dbus-send --system --print-reply --dest=org.bluez /org/bluez/hci0 \
    org.freedesktop.DBus.Properties.Set string:org.bluez.Adapter1 \
    string:Powered variant:boolean:true
  • 现状:hci0 存在、guest 内核初始化通过、BlueZ 有 /org/bluez/hci0 并可上电; btmgmt --timeout 8 find 会下发 Inquiry,模拟器也回了 11:22:33:44:55:66 (vm/bt.log 可见),但 btmgmt/BlueZ 的输出与连接/配对语义还没补齐。
  • 边界(控制台这一页的读法):开关/扫描的是客机 hci0,不是电脑蓝牙,也不碰 Windows 的蓝牙适配器;扫描只能碰到模拟器里的假设备。控制台「连接」已禁灰,并写明 真实耳机/音箱需要把一只 USB 蓝牙 dongle 直通给 QEMU(usb-host + 客机 btusb); 内置 Intel 蓝牙不参与。控制台的扫描已改用有界 btmgmt --timeout 8 find—— bluetoothctl 在 bluetoothd 没起来时会挂住 D-Bus。

电源:电池与"低电量关机"

固件的电量来自 /sys/class/power_supply/{battery,ac,usb,charger}:hal-battery 经 libydhal_devices.so 读这些文件,再用 ubus 发布给 app(YUbusSender)。VM 没有 PMIC, 该目录是空的,于是电量被读成 0%,app 进桌面约一分钟后弹"低电量即将关机"并真的关机—— 每次开机只有一分钟寿命。S00battery 钩子用 tmpfs 覆盖 /sys/class/power_supply, 写一份健康电量(80%、4.2V、Discharging、AC/USB/充电器均未接入),读数从此正常。

python scripts\dpctl.py sh -c "cat /sys/class/power_supply/battery/capacity"
:: 80

数值是静态的:插拔充电器不会改变它,safe_powerdown / system_power_off 这类脚本 读到的也是这份值。

CPU 加速(无法使用 WHPX)

  • 本机 Windows 的 WHPX 不支持 ARM64 客户机(QEMU 的 WHPX 仅支持 x86 客户机), 因此 无法使用硬件加速,只能用 QEMU 的 TCG 纯软件模拟。
  • 已启用多线程 TCG:-accel tcg,thread=multi;内存默认 2 G,vCPU 按运行框架给 (RK356x / CV18xx 保持 4,RV1106 单核默认 2 —— 多给的核空闲时也在 TCG 里空转, 实测 4→2 页面响应不变、宿主空闲 CPU 少约 7%)。单台可用 vm/machine.toml 覆盖。
  • 速度较慢属正常现象(32 位 ARM 用户态跑在 aarch64 内核的 COMPAT 层上)。

目录结构

路径 说明
scripts/dpctl.py 开发/调试入口:选包启动、传文件、实机命令行、adb(见上节)
packs/<名>/ 固件包:一台机器的全部会变的东西(pack.toml + images/ + patches/ + README.md),见"固件包与镜像选择"
packs/_shared/ 多个包共用的同一份载荷(目前是 p5 / x3s342 的 weston.tar、weston9.tar)。不是固件包(没有 pack.toml);包用 ../_shared/… 引用,dpctl pack export 会把它随包一起导出
scripts/packlib.py 包解析/校验/导入导出,以及"包 → initrd"的合并(框架通用 cpio + 包 patches + pack.env + EDID)
scripts/runtimes/ 框架内置的多种运行框架(boot backend):rv1106_linux.py 是第一个;包里的 [platform].runtime 选一个
deps/manifest.toml 依赖清单:缺什么、去哪拿、怎么校验;scripts/bootstrap.py 按它重建环境
scripts/bootstrap.py 一条命令检查/获取依赖并构建 initramfs(新环境入口)
scripts/package.py 打"可传播"的包(只含代码/文档/固件包描述,自检不含固件/私钥/大文件)
vm-console.cmd / run-vm.cmd / stop-vm.cmd / ssh-vm.cmd / console.cmd 一键脚本(Windows)——只做转发,QEMU 参数与解释器选择统一在 dpctl.py,两处不会漂移。.cmd 必须纯 ASCII(cmd.exe 按 OEM 码页读,UTF-8 中文会碎成乱码并劈断 echo),且 if (…) 块里的 echo 不许带括号() 会提前结束代码块,实测失败提示会在成功路径上整段印出来)。改这些文件还要用保留字节的工具:Git Bash 会把命令行里的 nul 重写成 /dev/null,用 heredoc 写出来的 >nul 落盘就不是那几个字节,cmd.exe 每跑一次多印一行"系统找不到指定的路径";这三条由 vmgui/tests/test_units.py 的 TestRootLaunchers 钉住
scripts/run-vm.sh Git Bash 版启动脚本
tools/msys2/ucrt64/ 便携版 QEMU 11.1.1 及全部依赖(免安装)
tools/kernel-root-gui/ Debian linux-image-6.1.0-50-arm64(含 DRM/virtio-gpu/ext4 模块)——框架自带通用内核
tools/busybox-root/ busybox-static(initramfs 用)
tools/ssh/ VM 访问密钥对(公钥已注入 VM 的 /root/.ssh/authorized_keys)
tools/platform-tools/ Android platform-tools(adb.exe),供 dpctl adb 使用
images/initramfs.cpio.gz 框架通用 initramfs(busybox + 内核模块 + 引导逻辑 + 虚拟热点的 arm64 hostapd/iptables),不含任何固件,只构建一次
scripts/initramfs-init.sh initramfs 的 /init 源码(改后需重新构建)
scripts/build-initramfs.py 重新打包通用 initramfs
scripts/console.py 串口 TCP 客户端(-c 单命令 / -f 批量,回传真实退出码)
scripts/bt-hci-emu.py 虚拟蓝牙控制器(H4,宿主侧),见"无线"一节
scripts/deb-arm64-fetch.py 抓取 arm64 的 Debian 包(hostapd 及其依赖)并解到 tools/arm64-rootfs/
tools/arm64-rootfs/ 从 Debian arm64 包解出的 hostapd + 运行库(打进 initramfs 供虚拟热点用)
scripts/restore_license.sh 真凭证恢复脚本(框架通用的激活脚本,包用 [adapt].license 打开)
scripts/mitm-verify.py 宿主侧假 verify 服务(443 + 自签证书),用于离线复现激活链路
vm/mitm/ 假服务的自签证书与请求日志(cert.pem/key.pem/requests.log)
vmgui/ QEMU 虚拟机客户端 GUI(PySide6):配置化、分辨率自适应、多显示模式、输入映射;vmgui/profiles/*.toml 是机型/固件 profile(见"用 vmgui 客户端")
vm/state-<包>.img 持久化状态盘(ext4,512M,按包分,--fresh 可重建)
vm/initrd-<包>.cpio.gz 本次启动用的 initrd(通用 initramfs + 包内容),按哈希缓存,换包/改包才重建
vm/ 运行期产物(serial.log、qemu.log、截屏、pid)

引导机制(改文件前必读)

真实设备启动链:BootROM → U-Boot → 内核(各机型 DTB) → /sbin/init。 模拟器替换为:QEMU virt 机型 + Debian 通用内核 + 框架通用 initramfs + 选中的固件包。 dpctl up --pack <名> 会把「框架通用 initramfs」与「包的 patches/** / pack.env / 面板 EDID」合并成 vm/initrd-<包>.cpio.gz(按哈希缓存),再做引导:

  1. initramfs 加载 virtio/erofs/overlay/DRM 模块,把 rootfs(EROFS 只读)挂到 /ro;
  2. 读 /pack/pack.env(本次启动选中的包,没有则不套用任何机型适配);
  3. DICTPEN_OVERLAY=1 时用 overlayfs 合并 /ro(只读)与可写层为 /mnt, 并改 /etc/inittab(login→直接 root shell;去掉 remount,ro);
  4. 若存在状态盘 /dev/vdb,把它挂到 /state(首次为空则用实机 e2fsprogs 建 ext4), overlay 的 upper 改用 /state/upper,并把 /userdata、/userdisk 改为 /state/... 的 bind mount;状态盘不可用时回落到 tmpfs;
  5. /data /userdata /userdisk /misc 若未持久化则用 tmpfs 顶替并预建 applog/cfg 等目录;
  6. 用实机自带 dropbearkey 生成主机密钥、注入公钥、在 inittab 加 dropbear 自启;
  7. 只按包声明做适配:[adapt].replace 覆盖 guest 文件(如显示 HAL)、 cfg_overlay 只改列出的 cfg.json 键、logo/touchbridge/battery/license/ap 各自装对应钩子、hooks 跑包自带脚本 —— 没声明的键一律不碰;
  8. switch_root 进入实机 /sbin/init(/state 会一并移入新根)。

框架里没有任何机型分支:grep -ri -e a6pro -e libfalcon -e pear 在 scripts/ (除 runtimes/ 的平台说明)与 vmgui/ 代码里 0 命中。

可写性

  • 有状态盘时:根文件系统改动与 /state、/userdata(含软链 /data)重启保留。
  • 状态盘不可用时:回落为 tmpfs,改动仅本次运行有效。
  • dpctl up --fresh 删除当前包的状态盘(vm/state-<包>.img),下次开机重新格式化。

显示与输入(原理摘要)

有道的 UI 框架是 greenui/falcon(Skia 渲染),它硬编码走 DRM/KMS(/dev/dri/card0), 而非 fbdev。VM 里让它上屏需要:换显示设备为 bochs-display、一套 libfalcon.so 二进制补丁、 再把面板 cfg.json 归一化(尺寸/方向/bpp)。这些都落在固件包里 (packs/a6pro/patches/,由 [adapt].replace / cfg_overlay 在开机时应用)。

原理、补丁清单与勘误(fourcc 非法常量、面板方向归一化、drmModeSetPlane failed (-13 EACCES)) 见 docs/插件模型.md §6;排障个案见 docs/疑难排查.md。

自检(实机日志)——AddFB2() ret=0 且没有 SetPlane failed 才算真上屏:

python scripts\dpctl.py sh -c "grep -a -e 'AddFB2() ret' -e SetPlane /data/applog/DictPen_*.log | tail"

显示:换掉 virtio-gpu

原因:falcon 直接往 DRM framebuffer 里画,不做 dirty / page-flip(不发 RESOURCE_FLUSH);而 virtio-gpu 只在收到 flush 时才把新像素传给 host,于是画面永久停在 启动图(-trace virtio_gpu* 的 trace 文件大小不再增长)。解法:换成 -device bochs-display,xres=560,yres=170——它的 framebuffer 就在设备的线性显存里, QEMU 扫描输出直接读,guest 写完内存画面即变(语义同真机 LCD 控制器)。 initramfs 按 ttm → drm_ttm_helper → drm_vram_helper → bochs 顺序加载,启动后 /dev/fb0 是 bochs-drmdrmfb。原理详见 docs/插件模型.md §6.1。

看画面

python scripts\dpctl.py view                :: 定尺寸窗口,实时显示笔的 GUI
python scripts\dpctl.py screenshot --zoom 2 :: 存 PNG

用 vmgui 客户端(默认显示前端)

dpctl up 默认不再用 QEMU 自带的 GTK 窗口,而是把画面交给仓库里的 vmgui (Python 3.11+ / PySide6):QEMU 以 -display none 启动,画面与输入都走 QMP。

python scripts\dpctl.py up                  :: 默认前端;自动找带 PySide6 的解释器
python scripts\dpctl.py up --display gtk    :: 回退到 QEMU 自带窗口
python scripts\dpctl.py up --display none   :: 不要窗口(纯 QMP,可用 screenshot 看)

也可以单独跑(VM 已在运行时直接连上去):

py -3.13 -m vmgui --profile pear-a6pro      :: PySide6 目前要求 Python < 3.15
py -3.13 -m vmgui --print-config            :: 打印生效配置 + 每键来源
py -3.13 -m vmgui --list-profiles
py -3.13 -m vmgui --mode stretch --scale-algo bilinear --rotation 90
py -3.13 -m vmgui --fullscreen --pointer-mode absolute

要点:

  • 不做任何分辨率假设:窗口大小与缩放都按 screendump 拿到的帧尺寸算。实测开机 阶段 640×480 → 560×170 的切换是原地重建(不闪、不丢焦点);换机型/固件只要帧 尺寸不同就自动跟随,不需要改代码。
  • 显示模式:1:1 / fit / stretch / free(用 display.zoom)/ fullscreen; 缩放算法 nearest(默认——笔屏像素少,放大用平滑会糊)/ bilinear / lanczos; 另有 display.rotation(0/90/180/270)与 display.crop(只显示帧缓冲的一角)。
  • 配置优先级:命令行 > --profile(可叠加,后者覆盖前者)> 用户配置(设置面板 写入)> 内置默认。菜单里改显示模式/算法是本次运行有效,「保存到用户配置」才落盘。
  • 输入:笔屏要 BTN_TOUCH,所以默认 input.touch_button = 330 且 send_click_with_touch = true(同时发 BTN_LEFT——touchbridge 正是靠它翻转的)。 input.pointer_mode = "auto" 时按后端能力在绝对/相对之间自动选。
  • 画布内自带触摸反馈环(display.pointer_marker),所以 vmgui 前端下不需要 dpctl pointer;那个覆盖层只服务 GTK 窗口。
  • 设置面板(「更多 → 设置…」,默认 Ctrl+Alt+P):连接/显示/输入/日志分节, 支持应用(热更新,只重建后端)、保存、从文件重载、恢复默认,以及 profile 多选切换。 默认快捷键:全屏 F11、截图 Ctrl+Alt+S、重置视图 Ctrl+Alt+R、设置 Ctrl+Alt+P、 退出 Ctrl+Alt+Q、捕获切换 Ctrl+Alt+G(都在 input.hotkeys / input.grab.toggle_key)。
  • 自测:py -3.13 -m unittest discover -s vmgui\tests -t .(配置合并/坐标逆变换/缩放/键映射), py -3.13 vmgui\tests\smoke_live.py --tap 280 85(连真机取帧、按各模式离屏渲染成 PNG、注入点击)。

QMP 同时只能有一个客户端,而且别去"探"它:vmgui 在前台时会一直占着 4446,此时 dpctl tap 会连不上(QEMU 的 socket chardev 只服务一个客户端)。更要紧的是不要用 port_open 之类的方式先探端口再断开——QEMU 的 chardev 会把这种"连上又不读数就断开" 的连接一直挂在 backlog 里(backlog=1),此后所有连接都被拒(WinError 10061), 只能重启 QEMU 才恢复。所以 dpctl 不对 4446 做探测,vmgui 连上后就握住连接等问候语。 dpctl screenshot 走监视器 4445,不受影响。

触控(鼠标点击 = 触摸笔屏)

app 从 cfg.json 的 device.tp 打开触摸设备;笔屏是触摸 UI(只认 BTN_TOUCH + ABS_X/ABS_Y),而 QEMU 的 virtio-tablet 是鼠标(点击只发 BTN_LEFT、从不发 BTN_TOUCH)——直接点不会有反应。VM 里由包声明的 touchbridge(touchbridge.pl:用 uinput 造一个名为 axs_ts_pen 的假触摸屏、把 BTN_LEFT 翻成 BTN_TOUCH、并把 cfg.json 的 tp 指到它)桥接。原理与踩坑见 docs/插件模型.md §6.4,个案见 docs/疑难排查.md。

操作:

python scripts\dpctl.py tap 280 85   :: 在笔屏坐标注入一次触摸(= 点一下)
python scripts\dpctl.py pointer      :: GTK 窗口下贴一层触摸反馈指针(up 开窗口时默认自动调出)
python scripts\dpctl.py nopointer    :: 中途收掉上面的反馈指针
  • dpctl tap 坐标就是笔屏像素(560×170),不需旋转换算;
  • 反馈指针完全在宿主侧(Tk 置顶、可点穿的覆盖层),vmgui 前端自带反馈环,用它时不必开 pointer;排查用 set DICTPEN_POINTER_DEBUG=1;
  • ⚠️ vmgui 在前台时一直占着 QMP,此时 dpctl tap 会连不上——先关掉 vmgui 窗口。

启动后自检(实机日志):

[5.978] S00touchbridge: touch device ready
touchbridge: reading /dev/input/event1 (event size 16)
touchbridge: uinput device "axs_ts_pen" created
touchbridge: /dev/input/by-path/axs_ts -> /dev/input/event2
tsdevice:/dev/input/by-path/axs_ts open success!
winInputLoop open tp success!

点击时 app 会走到 FBNativeWindow::inputTouchEvent (grep -c inputTouchEvent 可确认事件确实送到了 app)。

drmModeSetPlane failed (-13 EACCES) 是 virtio-gpu 时代的历史问题(换 bochs-display 后 不再复现);根因见 docs/插件模型.md §6, 排障见 docs/疑难排查.md。

miniapp launcher 的开关

app 由 S01systeminit 的 guardian_run /usr/bin/runDictPen 拉起、开机即跑;initramfs 把 S50launcher 改名禁用(避免重拉/刷屏)。手工重启 app:kill -9 <miniapp pid>(guardian 会自动重启)或 setsid /usr/bin/runDictPen。个案见 docs/疑难排查.md。

激活与凭证恢复(license)

真机开箱要"激活":app 拿设备码去有道服务器登记并换回一份凭证 (/userdata/cfg/license,174 字节)。VM 里设备码是空的——它存在设备数据分区的 system_info.bin 里,而那份文件由固件按硬件信息生成,QEMU 没有对应硬件:

python scripts\dpctl.py sh -c "strings /userdata/cfg/system_info.bin"
:: 02:00:00:00:00:00 / YDPA6-2 / 4.7.4 / OVERHEAD_A62_SKU_CHN_PRO / Linux ... / 560*170
:: 注意:只有 MAC/型号/版本/SKU,没有 ME 开头的序列号和 32 位 UUID

两条路线(两个开机钩子)

钩子 时机 行为
S00license app 启动之前 VM 没有设备码,直接写一份开发凭证
S99license 网络起来之后(dhcpcd 在 S21) system_info.bin 里有设备码时,用 scripts/restore_license.sh 向服务器换真凭证

开发凭证为什么够用:app 只判断这个文件是否存在——原生侧 YSystemInfoModulePrivate::licenseVerified() 只取一个内部状态位、从不检查内容, YVerifyManagerPrivate::checkResponse() 也只判断响应里某个字段是否为零。所以 174 字节 的固定占位串就能跳过整个激活页,干净开机直接进桌面。反过来,文件若在 app 已经 显示激活页之后才出现,app 不会重新读取——这就是开发凭证必须放在 S00 的原因。

  • 开关:dpctl up --no-license(等价内核 cmdline DICTPEN_LICENSE=0)同时关掉两个 钩子,开机就会停在激活页;
  • 手动跑真凭证路线:dpctl license(把仓库里的脚本推上去执行,输出能看到卡在哪一步)。

用真设备凭证激活

scripts/restore_license.sh 自动探测 cfg/applog 挂载点,从 system_info.bin 采集 SN/UUID/SKU/型号/版本/分辨率,判定 client,请求

https://fanyidan.youdao.com/fanyidanapi/activation/v2/verify

把返回的 license 校验后写回 /userdata/cfg/license,再重启 miniapp 验证。VM 里它会停在 [!] 参数采集失败 sn= uuid=——缺的是一台已注册设备的序列号/UUID。补齐方式二选一:

  1. 把那台设备的 system_info.bin 覆盖到 VM 的 /userdata/cfg/( dpctl put <你设备的>/system_info.bin /userdata/cfg/system_info.bin,MAC 字段也建议 一并改),删掉 /userdata/cfg/license 后重启——S00license 看到设备码会让路, S99license 去换真凭证;
  2. 或者只把 SN 与 UUID 给我,我在钩子里把它们写进 system_info.bin 对应字段 (400 字节定长结构,SN/UUID 槽位现在是全零)。

离线复现激活链路(scripts/mitm-verify.py)

想在不断网、不碰真机的前提下看激活请求长什么样,可让宿主冒充服务器:

  1. guest /etc/hosts 把 fanyidan.youdao.com(含测试域名 fanyiegg-test.youdao.com) 指向 10.0.2.2(slirp 网关 = 宿主机);
  2. 宿主起 python scripts/mitm-verify.py——用 vm/mitm/cert.pem(自签,SAN 覆盖两个 域名,vm/mitm/key.pem 为私钥)监听 443,请求记到 vm/mitm/requests.log, 回 {"code":0,"license":"1:<172 字节>"}。

注意:fanyidan.youdao.com 同时承载笔的词典查询 (POST /fanyidanapi/dictsearch),劫持期间查词会失败(界面显示"加载失败")。 用完请把 hosts 里那行删掉。激活本身用不到这条链路(开发凭证已足够), 它只在需要观察/伪造激活请求时才有用。

换成其他机型(再打一个固件包)

做法:复制一份 packs/a6pro/ 改成新名字,把 pack.toml 的 [image] 指到新机型的 镜像(镜像放进 packs/<新名>/images/),再 python scripts\dpctl.py packs <新名> 确认就位(详见"固件包与镜像选择")。各机型在仓库里的文件名:

机型 代号 平台 仓库文件 框架现状
A6pro(当前) Pear_RV1106 RV1106, Linux A6pro: a6-2-pro-ro-rootfs.img + boot_b(DTB) ✅ rv1106-linux-erofs
Hermes / Y09P / Y09 / Pear / A7pro — RV1106, Linux 各 release 的 rootfs + boot 🔶 同族,应只需改配置(未实测)
X7pro YDPX7 RV1106, Linux(aarch64+glibc) x7-system_a.img(无 boot,面板参数取自镜像内 cfg.json) ✅ rv1106-linux-erofs(fourcc/logo 补丁,见 packs/x7pro/README.md)
X5pro — RK3566, Android 系 X5pro: x5-1-pro-ro-rootfs_b.img + boot_b ⛔ 需要新的 runtime
X3s Cherry-3566 RK3566 X3s-OTA-OS3.4.2(增量合到 2.8.65 基线,见 packs/x3s342/README.md) ✅ rockchip-linux-ext4(packs/x3s342,miniapp 桌面;OS 2.8.65 旧 Qt 线已归档停做)
A7P Y09P RV1106, Linux Y09P_RV1106-OTA-OS99.99.91(需切分,见 packs/a7p/README.md) ✅ rv1106-linux-erofs(packs/a7p)
P5 Almond RK3566, Linux Almond-OTA-OS99.99.91(需切分,见 packs/p5/README.md) ✅ rockchip-linux-ext4(packs/p5,P 系首台)
X6plus Cherry-3566 RK3566 Cherry-3566-OTA-*(需解包 OTA) 🔶 同板族未建包(未实测)
X7(RK3562 代) RK3562_Orange RK3562 X7 等 ⛔ 需要新的 runtime(注意:x7-system_a.img 实为 RV1106 族,已按上行支持)
S6pro / S7pro PineApple / Y08_CV1813 Cvitek CV1826 S6pro / S7pro ⛔ 需要新的 runtime

RV1106 同族与本环境同构(Linux + erofs),理论上只差一份 pack.toml;异构平台 (RK*、CV18xx,多为 OTA / 安卓 A/B 分区)需要各自的内核/模块/面板研究,对应 runtimes/ 里再写一个 boot backend(框架核心不用动)。给异构平台建包后启动会明确 报 该平台 runtime 未实现:'<名字>',不会拿 RV1106 的补丁去覆盖它。

固件包与镜像选择

用哪台笔的固件 = 选哪一个包。 一个包是一个目录,装着一台机器的全部会变的东西:

packs/a6pro/
  pack.toml            # 元数据:平台/镜像/面板/适配开关/vmgui profile/校验
  images/              # 大文件(固件镜像),不进版本库,自行下载后放这里
  patches/             # 该机型"会变的东西":HAL 补丁、logo、触摸桥、cfg 覆盖表
  README.md            # 来源/版本/校验/已知问题
# packs/a6pro/pack.toml(节选)
[platform]  soc = "rv1106"   runtime = "rv1106-linux-erofs"
[image]     rootfs = "images/....img"   boot = "images/....img"
[display]   xres = 560  yres = 170  rotation = 0  bpp = 32
[adapt]     logo = "patches/logo.fb.gz"  battery = true  license = true  ap = true
[adapt.replace]
"patches/libfalcon.so" = "/usr/lib/libfalcon.so"     # 覆盖到 guest 的哪
[client]    profile = "pear-a6pro"                   # 交给 vmgui
python scripts\dpctl.py packs                 :: 有哪些包、镜像是否就位
python scripts\dpctl.py packs a6pro           :: 该包解析后的全部路径 + MD5 核对
python scripts\dpctl.py up --pack a6pro       :: 启动(只装一个包时可省略 --pack)
python scripts\dpctl.py pack export a6pro -o dist\a6pro.dpm   :: 整包带走
python scripts\dpctl.py pack import dist\a6pro.dpm --force     :: 整包装回来

pack.toml 里的路径一律相对包目录(写绝对路径也行)。dpctl up 启动前会校验 必需文件在不在、[checks] 的 MD5 对不对、该平台的 runtime 有没有,缺了直接告诉你 缺哪个,而不是让 QEMU 报一堆看不懂的错;[client].profile 会传给 vmgui。

框架不含固件:把 packs/ 与 downloads/ 整个移走,dpctl packs 只会说 "没有固件包",dpctl up 同样友好退出,框架不会崩。

分发与重建

包里只有代码。 整个工作目录 4GB+,其中 99% 是可再获取的外部依赖和不该外传的东西:

不进包 为什么 怎么补
packs/*/images/*.img(固件 rootfs/boot) 版权归网易有道,且 1.3GB 按 deps/manifest.toml 的指引自行下载到包内 images/,MD5 用上游 release 公布的值
packs/*/patches/libfalcon.so 等固件派生物 从固件里抠出来的二进制,同样归有道 同上(dpctl pack export 可整包搬,但要自己负责来源与授权)
tools/(QEMU 3GB、内核、platform-tools…) 可再获取 bootstrap.py 从镜像拉(MSYS2 / Debian tuna)
tools/ssh/id_devvm、vm/mitm/*.pem 私钥/证书,绝不能外传 bootstrap.py 在本机生成
vm/(状态盘 512MB、initrd、日志、截屏) 运行期产物 首次 dpctl up 自动生成
python scripts\package.py                     :: 看会打包哪些文件 + 自检(不写盘)
python scripts\package.py --out dist\dictpen-vm.zip

package.py 出的是纯代码包(不含包内适配二进制)。发布用的整树快照另外出, 它包含各包 patches/ 下的适配文件,但仍不含镜像、tools/、vm/ 与私钥:

git archive --format=zip -o dist\dictpen-vm-<版本>.zip HEAD

自检会挡住"固件/私钥/状态盘/大文件混进包"(固件包只带 pack.toml/README.md/ cfg.json.overrides/touchbridge.pl 这几个描述性文件)。收包的人:

python scripts\bootstrap.py --check   :: 只看缺什么,不动文件
python scripts\bootstrap.py           :: 能自动的自动拉/构建,缺固件的给出下载指引
python scripts\dpctl.py packs         :: 确认固件包/镜像就位
python scripts\dpctl.py up --pack a6pro

bootstrap.py 按 deps/manifest.toml 顺序做事:取 zstd → 取 MSYS2 包索引并装 QEMU → 解 busybox / 内核 deb → 取 arm64 依赖包(虚拟热点用)→ 生成 SSH 密钥并收紧权限 → 生成 mitm 自签证书 → build-initramfs.py。每步"已有就跳过",可反复跑; 不想用脚本时,deps/manifest.toml 每项的 where 字段就是手工步骤。

已知问题排查

按现象的完整排查手册见 docs/疑难排查.md(黑屏/花屏、触摸不响应、 联网误判、首启、激活、状态盘满、SSH/adb…)。

  • 启动日志在 vm/serial.log(每次开机重写),QEMU 自身输出在 vm/qemu.log。

版权与免责

镜像版权归网易有道所有,仅供个人学习、技术研究使用,禁止商用;下载后请遵守 dictpen-rootfs 仓库的免责声明。

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages