android: fix real-device defects (temp double-conversion, FFC temp jump, image orientation per grip, remote raw+metadata stream)

This commit is contained in:
ZXCLI
2026-09-11 03:01:13 +08:00
parent 0919f5e186
commit 464cfcb5e8
26 changed files with 1938 additions and 277 deletions
+42 -15
View File
@@ -66,8 +66,9 @@ android/app/src/main/kotlin/com/mag160c/thermal/
│ ├─ LiveScreen.kt 稳定单分支相机布局:顶栏5控制项(FFC/变倍/追踪/调色板/画中画)
│ │ +底部快门区(相册/拍照/录像)+SurfaceView+PIP浮层
│ ├─ PipCameraView.kt 可见光PIP相机引擎(Camera2最小实现,异常只记日志)
LiveRenderer.kt SurfaceView 软件渲染:图像恒90°旋转(3:4竖)钉在竖屏框架固定区域,
文字恒屏幕水平;色标条/圆圈标记/中心温OSD
ImageTransform.kt 方向/翻转/letterbox/缩放几何 + 传感器↔屏幕互逆映射(纯 Kotlin,单测覆盖)
└─ LiveRenderer.kt SurfaceView 软件渲染:图像按握持角补偿旋转(rot=90-φ)、
│ letterbox+变倍、色标条/圆环标记/中心温OSD(标签按旋转包围盒定位)
├─ ui/gallery/ 相册页(MediaStore DCIM/MAG160C 扫描 + 内嵌JPEG缩略图 + 运行时媒体权限)
├─ ui/analyze/ MDT 离线分析(缩放/调色板重渲染/温度条+点击测温/备注回写/PDF报告)
├─ ui/settings/ 设置页(默认调色板/发射率/报警温度/语言/云同步/远程预览两行/关于)
@@ -129,25 +130,39 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
- 旧 analysis/protocol_spec.md 的 66f/670 描述("prepare/version query")不准,
以 magcx_official_flow.md 为准。
### 4.3 实时画面渲染(最终约定:构图钉死竖屏框架
### 4.3 实时画面渲染(2026-09-11 修订:图像内容随握持朝向补偿
- **Activity 锁定竖屏**manifest `screenOrientation="portrait"`):屏幕相对手机框架
永不旋转。顶栏、热像区域、底栏的**绝对位置**永远贴着手机竖屏的物理顶边(挖孔侧)、
中间、物理底边;手机怎么物理旋转,构图都不动(用户明确要求:屏显方向必须始终
与热像镜头实际方向对应,横屏后显示区域跟着屏幕转是错的)。**不要改回 fullSensor。**
- **图标/文字按物理持机朝向补偿旋转**(第六轮定稿):`ui/DeviceOrientation.kt` 用
加速度计得出手机相对竖屏的顺时针物理转角 φ(0/90/180/270,带滞回;竖屏锁定下
Display.rotation 恒 0 不可用)。顶栏/底部导航条目 `graphicsLayer rotationZ=-φ`
原位预旋转;渲染器 OSD 文字 `canvas.rotate(-φ)` 绕锚点旋转(标记圆点、色标条
几何仍钉死在图像上)。对话框与其他页签暂不补偿
中间、物理底边;手机怎么物理旋转,构图都不动。**不要改回 fullSensor。**
- **图像内容补偿旋转(本轮修订,勿回退)**:构图不动,但**画面内容**必须随握持朝向
反向补偿,否则转身后场景跟着转、与镜头实际指向脱节。规则:
`rot = 90 - φ`(φ = DeviceOrientation 握持角 0/90/180/270)。
依据官方实现:`MainActivity.windowOrientationListener` 把 Display.rotation
(0/1/2/3) 映射成图像 90/0/270/180,再经 `ImageViewer.drawImage` 的
`matrix.postRotate` 应用;官方**窗口会随传感器旋转**,故可见结果恒为 90
我们锁了窗口,图像就要承担全部差值 → `90 - φ`。
显示旋转索引→角度约定取自官方自家相机代码
`VisibleCameraHelper.setPreviewOrientation`case 0→0/1→90/2→180/3→270
即索引 N = 顺时针 N×90)。
- **手工修正项**(官方"旋转USB画面/水平翻转/竖直翻转"同款):设置页三项,
叠加在自动补偿之上,持久化于 `AppSettings.imageRotateDeg/imageFlipH/imageFlipV`
用于传感器安装方向特殊的机器。翻转在像素拷贝阶段完成,保证几何映射可测。
- **图标/文字按握持角补偿**`graphicsLayer rotationZ=-φ`Compose/
`canvas.rotate(-φ)`(Canvas)。**标签一律按旋转后包围盒定位**(
`ImageTransform.rotatedBoxHalfExtents` + `drawGripText`),不要再拿基线锚点
摆位——色标条两端数字曾因此与色条错位。
- **几何单一来源**`ui/live/ImageTransform.kt`(纯 Kotlin,可单测)同时供
渲染器与点击/探针映射使用(`sensorToScreen` / `screenToSensor` 互为逆映射,
已对 0/90/180/270 × 翻转组合做往返测试)。渲染与取温映射**必须**用同一套参数,
否则标记会落到错误像素上。
- **加速度计符号约定(第七轮教训,勿改回)**:真机 TYPE_ACCELEROMETER 静止读数
指向世界上方(竖屏正持 y=+9.81);模拟器虚拟传感器是反的重力约定(y=-9.81)。
DeviceOrientation 映射按**真机约定**写,模拟器测试须用反号值驱动
(φ=0→`adb emu sensor set acceleration 0:9.81:0`)。
- **图像恒 90°CW 绘制为 3:4 竖向**,填满可用区域(顶栏下~底导航上)。
- **文字/图标恒屏幕水平**(可读);标记文字位置自动跟随(probeToScreen 固定 90° 映射:
`fx=1-sy/120, fy=sx/160`
- 顶栏=4 个相机控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称),恒在顶部并带小字
标签,`safeDrawing` 顶部inset 适配挖孔屏。
- **追踪开关控制最高+最低两个标记**(此前只关最高,最低永远画着)。
- 顶栏=5 个控制项(FFC / 变倍×N / 追踪·开 / 调色板+名称 / 画中画),恒在顶部并带
小字标签,`safeDrawing` 顶部 inset 适配挖孔屏
- 底部(仅实时页)导航栏上方为**相机快门区**:相册快捷入口 / 大快门拍照 /
录像-停止;快门区高度并入 `vm.uiBottomPx`= 导航高+快门区高)。
- 顶栏高度经 `onSizeChanged`→`vm.uiTopPx`**必须挂在 safeDrawing inset 之前**
@@ -158,6 +173,18 @@ res/drawable/*.xml 自绘矢量图标(双弧圆等可靠几何图形
`connect()` 置 `status="no_device"`LiveScreen 显示占位文案"未检测到热像仪,
请插入MAG160C",渲染器无帧黑底;startDemo/合成帧代码已删除。
### 4.3.1 温度显示(2026-09-11 修复三个真机缺陷)
- **中心温度**`probeTemp()` 返回的**已经是毫度**,界面只许除以 1000 一次。
旧代码又调了一次 `countsToTempMc()`,真机显示 108.7℃(实际约 24℃)。
- **FFC 期间的温度跳变**:FFC 参考帧过去被解码进 `nuc`OSD 采样的同一缓冲区),
未补偿的原始 counts 直接参与显示 → 最高/最低短暂跳到 ~150℃。
现在参考帧走独立 `refScratch` 缓冲。
- **上电/未就绪**:首帧渲染前 `nuc` 全 0,而 `countsToTempMc(0) = -161.0℃`
(看着像合法读数)。`RenderPipeline.tempsReady()` / `IrSession.tempsReady()`
门控 UI 取温;远程页同样门控。
- 回归测试:`core/PipelineTemperatureStateTest.kt`。
### 4.4 温度
- 温度 = 毫度 int(÷1000 = ℃)。`counts_to_temp_mc`(NUC域→T2E逆映射)用于探针/OSD,
在真机上需按 DDT 标定核对绝对值(待办)。
+14 -3
View File
@@ -324,12 +324,23 @@ class PipCameraEngine(val context, val textureView) :
`{"type":"welcome","w":160,"h":120,"fps":15,"serial":...}`
- `{"cmd":"start"}` → `{"type":"stream-start"}` 后开始二进制帧
- `{"cmd":"stop"}` → `{"type":"stream-stop"}`
- `{"cmd":"ffc"}` → 主机触发 FFC → `{"type":"ok"}`
- `{"cmd":"ffc"}` → 主机触发 FFC → `{"type":"ok"}`**仅未出流时回复**;出流后
客户端处于帧模式,主机只执行不回复,见下)
- **图像帧**stream-start 之后,TCP 二进制流):
`[u32 LE 0x1BB1B11B][u32 LE frameCounter][u32 LE 38400][38400B 原始 u16LE 像素]`
共 38412B/帧。**客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染**
`[u32 LE 0x1BB1B11B][u32 LE frameCounter][u32 LE 38400]`
`[u32 LE flags][u32 LE ffcPhase][i32 LE shutter][38400B 原始 u16LE 像素]`
头部 24B,共 **38424B/帧**。**客户端用本地 RenderPipeline(160,120)+内置 DDT 自行渲染**
(调色板/变倍全在客户端本地,无需回传)。
- `flags` bit0 = 主机本帧是否渲染成功;`ffcPhase` = 主机管线 FFC 阶段
(0 正常 / 1 快门关闭 / 2 参考帧采集);`shutter` = 本帧相机温度(原始单位)。
- **为什么必须带这几项**2026-09-11 修订):NUC 表按快门温度插值,缺了它
counts 会饱和(真机表现为满屏噪声 + 读数 -161℃);参考帧还必须在客户端
按同一规则平均。早期版本只传像素、客户端独立跑 FFC 状态机,画面不正确。
- 客户端相应入口:`RenderPipeline.frameRemote(frame, phase, shutter, out)`。
- **保活**:主机 3s 无帧发 `{"type":"ping"}`;客户端 10s 无任何数据判死重连。
- **单客户端**:已有客户端时,第二连接立即收到 `{"type":"busy"}` 并被关闭。
- **流中不写控制回复**stream-start 之后客户端处于帧模式,此时主机不得再写
JSON 行(会被当作帧内杂散字节);`ffc` 在流中执行但不回复。
### F2. 文件与职责
1. `net/RemoteContract.kt`:常量(端口/魔数)、JSON data class、
+16 -1
View File
@@ -39,7 +39,9 @@
| 17 | A:设置页 → "远程预览服务端"(点一下) | `[remote] host started (name=<机型> serial=0)` | 该行文案变为"已开启" |
| 18 | A:若第 17 步弹"先连接热像仪" | (无日志) | 说明 USB 会话不活跃:先回到实时页确认出流 |
| 19 | B:设置页 → "远程预览客户端" → "查找主机" | (B 端)列表出现 A 的主机名 | 10 秒内列出 A(卡片显示 `<主机名>``<IP>:47511`);未列出可用"手动添加"填 A 的 IP |
| 20 | B:点该卡片(或手动 IP 后点"连接" | A 端:`[remote] client connected from <B的IP>`B 端:`[remote] client connected to <A的IP>:47511``[remote] line: {"type":"welcome",...}``[remote] line: {"type":"stream-start"}``[remote] first remote frame` | B 显示 A 的热像实时画面(同一构图:竖屏 3:4、顶栏 4 项、右侧色标条 |
| 20 | B:点该卡片(或手动 IP 后点"连接" | A 端:`[remote] client connected from <B的IP>`B 端:`[remote] client connected to <A的IP>:47511``[remote] line: {"type":"welcome",...}``[remote] line: {"type":"stream-start"}``[remote] first remote frame` | B 显示 A 的热像实时画面,**温度读数与 A 端一致**(同一场景下中心温/最高最低相差应在 1℃ 内);画面朝向与 A 端相同(两机握持姿态不同时,各自按自己的姿态补偿 |
| 20b | B 端确认温度不再是 -161℃、画面不是满屏噪声 | B 端 `[remote] first remote frame` 之后读数正常 | 这是 2026-09-11 修复项:协议改为随帧传 FFC 阶段+相机温度(`[flags][ffcPhase][shutter]`,帧记录 38424B)。若仍异常,检查两端 APK 是否同版本(新旧协议不兼容) |
| 20c | 第三台设备(或 A 本机再连一次)尝试连接同一主机 | A 端:`[remote] rejecting <IP>: already serving a client` | 后到者立即收到 `{"type":"busy"}`,B 端弹"主机正忙(已有客户端连接)"并返回列表;**已在流的那台不受影响** |
| 21 | B:顶栏点变倍/追踪/调色板 | (无日志,全部本地) | 立即生效、无卡顿(调色板/变倍不下发到 A) |
| 22 | B:顶栏点 FFC | A 端出现一次快门校正;B 画面随之更新 | FFC 经 A 的热像仪执行 |
| 23 | B:点底部红色"断开"圆钮 | A 端:`[remote] client <IP> disconnected (frames=<N>)` | B 返回主机列表并弹出"已断开"提示;A 服务端保持"已开启"待重连 |
@@ -49,6 +51,19 @@
中途断网/主机退出时 B 显示"连接已断开";A 端相机与本地画面**始终不受网络影响**;
日志中 `frames=…` 每 300 帧记一次。
## 方向与温度(2026-09-11 修复项,重点验证)
| # | 操作 | 预期 | 通过标准 |
|---|------|------|----------|
| 25 | 竖屏正持,观察画面 | (无日志) | 画面正常;中心温读数与手摸/环境常识一致(**不是 -161℃、不是 108℃ 这类离谱值**) |
| 26 | 手持手机**顺时针转 90°**(横过来),观察画面内容 | (无日志) | **场景方向不变**(画面内容跟着手反向补偿),顶栏/底栏文字仍可读;不是整个场景跟着转 |
| 27 | 点"追踪"关闭 | (无日志) | **最高温和最低温两个标记同时消失**;再点开→两个都出现(此前最低温标记关不掉) |
| 28 | 点 FFC,紧盯最高/最低温读数 | `[cmd] FFC(0) write=8/8` | 读数**不出现 ~150℃ 的瞬时跳变**(可短暂保持不变,但不得跳到离谱值) |
| 29 | 设置页"旋转USB画面"依次选 0/90/180/270° | (无日志) | 画面按所选角度整体旋转,用于修正传感器安装方向 |
| 30 | 设置页"水平翻转"/"竖直翻转"开关 | (无日志) | 画面镜像;与官方 app 的同名设置表现一致 |
| 31 | 横屏持机时观察色标条 | (无日志) | 色标条**两端**的最高/最低温数字紧贴色条两端,**不与色条重叠**、不偏移 |
| 32 | 分析页打开一张 MDT,观察顶部温度条 | (无日志) | 温度条显示"中心/最低/最高",数值与拍照时实时页读数接近 |
## 相机(PIP)失败时的表现(设计如此,不算 bug)
PIP 的相机是可选功能,任何相机异常都只记日志并关闭小窗,**不影响热像主画面**:
+52
View File
@@ -451,6 +451,58 @@
(CAMERA 权限原本会让相机变必需,已显式声明为可选)。
**未 push**(按用户指令)。
## 用户反馈修复 第十八轮(2026-09-11,真机实测:温度/朝向/远程三类缺陷)
用户实机安装测试(含两台手机远程预览)报出以下问题,本轮全部处理:
- [x] **中心温度测量错误**`probeTemp()` 返回的**已是毫度**`updateTemps` 又调了
一次 `countsToTempMc()` → 真机显示 108.7℃(实际约 24℃)。
远程页同一 bug(显示 -161℃)。两处都改为只除 1000 一次,并在参数注释里
写明单位契约。
- [x] **FFC 时最高/最低温跳到 ~150℃**FFC 参考帧被解码进 `nuc`OSD 采样的同一
缓冲区),未补偿的原始 counts 被当成温度。改为独立 `refScratch` 缓冲;
另加 `tempsReady()` 门控(首帧渲染前 `nuc` 全 0,而 `countsToTempMc(0)`
= **-161.0℃**,正是远程截图显示的读数)。
新增回归测试 `PipelineTemperatureStateTest`3 项)。
- [x] **追踪开关只关最高温**:最低温标记未受开关控制(`LiveRenderer.drawOsd`)。
现在开=最高+最低都画("高"/"低"),关=都不画。
- [x] **画面转向反了 / 横屏应翻 180°**:原先"图像恒 90°CW 不动"的约定在真机上
表现为转身后场景跟着转。改为**图像内容按握持角补偿**:`rot = 90 - φ`
依据官方实现(`MainActivity.windowOrientationListener` 把 Display.rotation
映射成图像 90/0/270/180,经 `ImageViewer.drawImage``matrix.postRotate`
应用;官方窗口随传感器转,可见结果恒 90);显示旋转索引→角度取官方自家
相机代码 `VisibleCameraHelper.setPreviewOrientation`N→N×90)。
构图(条栏/图像区绝对位置)仍钉死竖屏框架不变。
- [x] **新增官方同款方向设置**:设置页"旋转USB画面"0/90/180/270)、
"水平翻转"、"竖直翻转",叠加在自动补偿之上并持久化。
- [x] **色标条两端最高/最低温与色条错位**:标签原先按未旋转的基线锚点定位。
改为**按旋转后包围盒**定位(`ImageTransform.rotatedBoxHalfExtents` +
`drawGripText`),任意握持角都贴着色条两端。
- [x] **几何单一来源**:新增 `ui/live/ImageTransform.kt`(纯 Kotlin),渲染器与
点击/探针映射共用同一套参数与互逆映射;新增 `ImageTransformTest`6 项,
覆盖 0/90/180/270 × 两种翻转的往返一致性)+ `ImageTransformOrientationTest`
(4 项,锁官方映射与"转身时图像反向")。
- [x] **远程预览画面不正确**(用户截图:-161℃、满屏噪声):根因是协议只传像素,
客户端独立跑 FFC 状态机且**拿不到帧的相机温度**,而 NUC 表要按快门温度插值
→ counts 饱和、参考帧缺失。协议改为传元数据:
`[magic][counter][38400][flags][ffcPhase][shutter][像素]`(头 24B,记录 38424B),
客户端用 `RenderPipeline.frameRemote(frame, phase, shutter, out)` 复刻主机状态。
(用户建议"传原始数据本地渲染"正是此方向;此前的错误在于只传了数据的一半。)
- [x] **顺带修核查报告的两项证伪**`RemoteSession.send()` 改为**单写协程 + 队列**
(原先每条命令各起协程写同一 socket,`hello`/`start` 可能乱序,~6.7% 丢
`welcome`;新增 `commandOrderIsPreservedUnderRapidSends` 回归);
主机实现"后来者写 busy"accept 循环不再阻塞在 serve 上,第二客户端立即
收到 `{"type":"busy"}`;新增 `secondClientIsRejectedWithBusy`)。
另删除未使用的 `ACCESS_NETWORK_STATE` 权限。
- [x] 单测 44 → **61 项全绿**debug + release(R8) 双构建通过;APK 已更新
12.66MB)。`screenOrientation=portrait` 复核保持、camera 系列仍
not-required、多余权限已消失。
⚠️ **规格偏离(已告知用户)**:本轮推翻了第四/五轮"图像恒 90°CW 不动"的约定
(用户实测该约定导致转向错误)。构图绝对位置不变,仅图像**内容**随握持角补偿。
## 本轮(2026-09-10 执行计划 A→F)总结
七个阶段全部落地,每阶段一次 commit:
+454
View File
@@ -0,0 +1,454 @@
# Phase A→F 独立核查报告(可交给修复模型)
> **核查者立场**:本报告由独立核查 AI 撰写,不采信执行者(下称"执行者")的
> 总结/session_state/checklist 叙述,全部结论来自 ① git 提交内容
> ② 仓库内官方逆向产物 ③ 核查者亲自重跑的命令与亲自编写的独立工具
> ④ 明确标注"无法判定"的真机项。
>
> **核查范围**`06c1f30..8e1312a`7 个提交),基线 `087e15c`
> 核查时 HEAD = `0919f5e`(含核查指南提交)。
> **本轮核查未修改任何源码、未提交、未 push**;本报告文件是唯一新增物
> (未提交,`git status` 会显示为 untracked)。
>
> **总体结论**:第一关硬门槛(git 完整性 / 禁改清单 / debug+release 构建 /
> 44 项单测)**全部通过**。逐阶段核查发现 **2 项证伪**(Phase F 命令写入
> 未串行化、Phase F busy 未实现)、**1 项 Phase Z 越界**Manifest 改动)、
> **2 项低风险偏差**,其余声明证实;需真机/双设备的项一律"无法判定"。
> Phase C 最高风险项(索引 4/8/9 映射)经独立复核**证实**,且本次找到了比
> 执行者更强的决定性证据。
---
## 0. 修复任务清单(给修复模型)
按优先级排列。每条含:位置、现象、根因、建议改法、改完怎么验、不修的风险。
**修复时仍须遵守 `execution_plan.md` §0 的禁改清单**(竖屏锁定、命令字节序、
握手序列、LiveRenderer 构图、analysis/ 只读)。
### FIX-1(高)`RemoteSession.send()` 并发写同一 socket 导致命令乱序
- **位置**`android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt:256-269`
- **现象(已复现)**`hello()` 紧接 `startStream()` 时,主机可能先收到 `start`
主机随即回 `stream-start`,客户端 reader 切到帧模式
`RemoteClient.kt:194`),后到的 `welcome` 文本被 `FramePacketReader` 当垃圾丢弃。
- **实测数据**:裸 socket 抓字节,5 次运行中 1 次观测到 `hello`/`start` 顺序颠倒;
面向真实 `RemoteHost` 的生产序列压测 **120 次中 8 次丢失 `welcome` 行(≈6.7%**
帧本身始终正常(30/30 各轮均收到)。
- **根因**`send()` 每条命令 `scope.launch(Dispatchers.IO)` 新协程写同一个
`socket.getOutputStream()`。多协程竞争同一流,**顺序无法保证**。
- **建议改法****不要用 Mutex**Mutex 只保证互斥,不保证"launch 顺序=写入顺序"
本缺陷是乱序而非字节交错,加锁修不掉)。改为单写协程 + 队列:
```kotlin
// RemoteSession 内新增
private val outQueue = kotlinx.coroutines.channels.Channel<String>(
kotlinx.coroutines.channels.Channel.UNLIMITED,
)
private val writerStarted = java.util.concurrent.atomic.AtomicBoolean(false)
private fun ensureWriter() {
if (!writerStarted.compareAndSet(false, true)) return
scope.launch(Dispatchers.IO) {
val out = socket.getOutputStream()
try {
for (line in outQueue) {
out.write((line + "\n").toByteArray(Charsets.UTF_8))
out.flush()
}
} catch (e: Exception) {
if (!closed.get()) DebugLog.log("remote", "writer ended: ${e.javaClass.simpleName}")
}
}
}
private fun send(cmd: String) {
if (closed.get()) return
ensureWriter()
outQueue.trySend(cmd) // 调用方(UI 协程)顺序入队 → 顺序落盘
}
fun close() {
if (!closed.getAndSet(true)) {
runCatching { outQueue.close() }
runCatching { socket.close() }
}
}
```
要点:`trySend` 在**调用方线程**顺序入队,写 socket 只发生在唯一协程里。
- **可选补充(不替代上面的修复)**:客户端在帧模式下遇到非帧数据时,
可先尝试按 JSON 行解析再丢弃,作为对旧主机的兼容容错。属于加固,不解决根因。
- **验证方法**:复跑"高频命令"用例——`hello()`+`startStream()` 连续 3 对,
再接 400 次 `requestFfc()`,断言收到的行数、内容与**顺序**完全一致、
无嵌套花括号行。(核查者的独立 harness 已实现该用例,见 §6。)
- **不修的风险**:目前 `welcome` 只被打印(`RemoteViewerViewModel.kt:103`),
用户可见影响小;但 `hello→welcome` 契约已被破坏。同类乱序也可能发生在
快速 stop→start、连续 FFC 等序列上,属真实缺陷。**建议修**。
### FIX-2(中)"后来者拒绝写 busy" 未实现
- **位置**`android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt:130-151`acceptLoop/serve
- **现象**`serve(socket)` 在 accept 循环内**同步**执行,第二个客户端只会在内核
backlog 里排队等待第一个断开;主机**从不写 `busy`**。
`RemoteContract.isBusyLine()``RemoteContract.kt:147`)全仓库**无调用点**,是死代码。
- **与计划冲突**`execution_plan.md` F2 明确要求"单客户端,后来者拒绝写 busy"。
- **建议改法(二选一)**
- **(a) 实现**accept 循环不阻塞在 serve 上,用标志位拒绝后来者:
```kotlin
private val serving = java.util.concurrent.atomic.AtomicBoolean(false)
// acceptLoop 的 while(running) 内:
val socket = server.accept()
if (!serving.compareAndSet(false, true)) {
runCatching {
val o = socket.getOutputStream()
o.write((RemoteContract.busyLine() + "\n").toByteArray(Charsets.UTF_8))
o.flush()
}
runCatching { socket.close() }
continue
}
scope?.launch { try { serve(socket) } finally { serving.set(false) } }
```
需在 `RemoteContract` 补 `fun busyLine(): String = "{\"type\":\"busy\"}"`
客户端侧建议在 `lines` 收集处对 `busy` 记一条日志(否则用户只看到连不上)。
- **(b) 如不实现**:同步修改 `execution_plan.md` F2 与相关文档,如实写明
"第二客户端排队等待,不写 busy",并把 `isBusyLine()` 删除或标注保留用途。
- **验证方法**:单测/回环:client1 连上后 client2 连接,断言 client2 在 1s 内
收到 `{"type":"busy"}` 或被立即关闭,且 client1 的帧流不受影响。
- **不修的风险**:单客户端约束事实成立,风险低;但"计划声明了、代码没有、
解析器留了死代码"三者不一致,会误导后续维护者。
### FIX-3(低)lifetime 负载接受两种布局(超出官方规格的对冲)
- **位置**`android/app/src/main/kotlin/com/mag160c/thermal/usb/IrSession.kt:226-231`
- **现象**:实现同时接受 ①`[magic][magic][ms]`12B,计划代码片段的字面写法,
与计划自己的"共 8 字节"描述矛盾)②`[magic][ms]`(8B,官方 Java 唯一实现)。
- **官方权威**`jadx_magcx/.../UsbCommunication.java:190-205` —
`bb.getInt()==1538635105` 后再 `getInt()`,即 **8 字节 `[magic][ms]`**。
另注意 `IrSession.readResp` 已剥掉前 4 字节 magic`r.third` 是**去掉** magic 的负载),
故官方布局下 `r.first==8`、`r.third.size==4`、ms = `u32(r.third,0)`。
- **建议改法**(收紧为官方唯一布局):
```kotlin
readResp(conn, epResp, "GetLifeTime")?.let { r ->
// readResp 已剥离 4B magic;官方帧共 8B → 余 4B 即 i32 ms
if (r.second == MagProtocol.RSP_SEND_LIFETIME && r.third.size >= 4) {
deviceLifetimeMs = MagProtocol.u32(r.third, 0).toLong() and 0xFFFFFFFFL
}
}
```
- **验证方法**:真机清单第 3 步出现 `[session] device lifetime=<ms>ms` 且非 -1。
- **不修的风险**:不会误判为连接失败,仅接受一个官方不发送的布局;低。
### FIX-4(低)多余权限 `ACCESS_NETWORK_STATE`
- **位置**`android/app/src/main/AndroidManifest.xml:15`
- **现象**:全仓库 grep 无 `ConnectivityManager`/`NetworkCapabilities`/权限检查,
该权限从未被使用(计划 F9 只要求 INTERNET)。
- **建议改法**:删除该行;若保留,需在提交信息/文档中说明用途。
- **验证方法**`aapt2 dump badging build-artifacts/mag160c-app-debug.apk` 不再列出。
### FIX-5(低)Phase Z 越界:`8e1312a` 含 Manifest 功能性改动
- **位置**`8e1312a` 修改了 `android/app/src/main/AndroidManifest.xml`+3 条
`uses-feature``camera`、`camera.autofocus` 显式 `required=false`,并改写注释)。
- **性质**:改动本身**合理**(防止 CAMERA 权限隐式要求相机硬件,保证无相机
设备仍可运行热像主功能),执行者已主动交底;但违反"Z 内容须仅为文档/清单/APK"。
- **建议**:不需要回滚功能,**只需在 `session_state.md` / `execution_plan.md`
如实记录该改动发生在 Z 提交**(承认越界),避免后续核查再次对不上账。
### FIX-6(低)文档口径修正
1. `docs/android_app/real_device_checklist.md:29`(第 15 步):预期"返回后 PIP 画面
重新出现"是**推断而非实测**。代码里 `PipCameraEngine.released` 置位后不复位,
只能依赖 `onSurfaceTextureAvailable` 再次触发。**先按真机实测结果决定改文档
还是改代码**(指南 §3E 明确:若真机不符,应判定为清单预期有误)。
2. `analysis/sdk_re/android_app/palette_extraction_findings.md:71`:红热占位图的
分母表述混用了两类像素。实测(ImageIO 逐像素):
**全部像素 11777/11914 相同****不透明像素 8536/8673 相同**meanAbsDiff 2.18/765)。
原文"11914 个**不透明**像素中 11777 个相同"应改为上述两种口径之一。
3. `session_state.md` / `execution_plan.md`:补记本报告 §3-F 的两项证伪
FIX-1 乱序、FIX-2 busy 未实现),使文档不再高于实现。
### FIX-7(可选,测试加固)
1. `MdtTest`:补一条**含 EXIF 缩略图(内部含 `FF D9`)的 JPEG** 往返用例。
核查者已独立验证当前实现能正确取主图 EOI(不会被缩略图干扰),
但仓库测试缺失该场景,属回归风险。
2. `VendorPalettes``PalettesTest.vendorTablesCoverIndicesZeroThroughTen`
`PalettesTest.kt:47-63`)只证明 `Palettes.buildAll()` 用了 `VendorPalettes`
的对应槽位,**不能**证明 `VendorPalettes` 里 case N 的内容确实来自 case N
(全库仅 case 2 有官方锚点)。建议为 11 张表各加一条"金标"断言
(如 256 项 CRC32/采样点),把本次已外部验证过的映射冻结住。
3. `RemoteLoopbackTest`:在 FIX-1 完成后,补"高频命令顺序"用例(见 FIX-1 验证方法)。
4. 清理死代码:`Palettes.kt:77/89/98/110` 的 `rainbow()`/`highContrast()`/
`hotMetal()`/`jet()` 已无调用点(Phase C 接入精确表后遗留)。
### 判定为"可接受、无需修改"的项
- **Phase B 温度条固定未缩放 fitRect**:与计划字面一致(`imageRect(size,1f,Offset.Zero)`),
取舍合理。
- **Phase B 探针标签随缩放/平移**(`AnalyzeViewer.kt:266` 用 `imageRect(size,zoom,pan)`):
与计划"标签随 fitRect 走"字面不符,但**实现明显更正确**(标签须跟随像素),
判定为实现优于字面规格,建议保留(可在文档注明)。
- **Phase B 分析页直接映射(无 90° 旋转)**:`AnalyzeViewer` 画布 `aspectRatio(4f/3f)`
且 `drawImage` 无 `canvas.rotate`,位图 `Bitmap.createBitmap(160,120)` 为原始朝向,
直接映射正确。
---
## 1. 第一关:硬门槛(全部通过,故继续逐阶段核查)
| 声明 | 核查方法 | 结论 |
|---|---|---|
| 工作树干净 | `git status --short` 无输出 | 证实 |
| 范围内恰 7 个提交、消息与计划逐字一致 | `git log --oneline 087e15c..HEAD`,逐条比对计划"提交信息" | 证实 |
| 未 push | `git log --oneline -1 origin/main` = `087e15c` | 证实 |
| 竖屏锁定未改 | 源码 diff 无 `orientation` 删改;`aapt2 dump xmltree` 得 `screenOrientation(0x0101001e)=1` | 证实 |
| `MagProtocolTest` 未变 | `git diff --stat` 为空 | 证实 |
| `MagProtocol.kt` 仅新增常量 | diff 仅 `RSP_SEND_LIFETIME` 三行 | 证实 |
| `LiveRenderer.kt` 未变 | `git diff` 为空 | 证实 |
| `analysis/` 既有文件未被改 | `--diff-filter=M` 为空;仅 8 个新增文件 | 证实 |
| `IrSession` 握手序列未改 | 人工读 diff66b→66c→66f→[670]→673、800ms 超时、前半段失败即 abort 全部保留;删除行仅两处(cache 读后加 MD5、stats 行追加 lifetime | 证实 |
| `assembleDebug + assembleRelease + test` 全绿 | 亲自重跑:`BUILD SUCCESSFUL`release 走完 R8 | 证实 |
| 44 个单测、逐类分布 3/3/5/8/6/4/12/3 | `--rerun-tasks` 强制重跑,逐 XML 汇总 = 44/0/0;每类用例名与指南表格一一对应 | 证实 |
| `RenderPipelineTest.matchesCReferencePixelExact` 仍通过 | 重跑后该用例在列且失败数为 0 | 证实 |
---
## 2. 逐阶段核查明细
### Phase A`06c1f30`
| 声明 | 方法 | 结论 |
|---|---|---|
| `RSP_SEND_LIFETIME = 0x5BB5B561` = 官方 `D2P_SendLifeTime` | `jadx_magcx/.../D2PCmd.java:10` = 1538635105;手算 `0x5BB5B561` = 1538635105 | 证实 |
| `CMD_GET_LIFETIME` 未改 = 官方 `P2D_GetLifeTime` | `P2DCmd.java:8` = 1807136373 = `0x6BB6B675` | 证实 |
| lifetime 失败不阻断连接 | `IrSession.kt:219-235` 失败仅 log,无 return/abort;官方 `getDevLifeTime` 亦仅 `Logging.error` | 证实(仅静态审查,无运行时证据) |
| cali 缓存 MD5 对照不改变返回值 | cache-hit 分支先算 `cached`log `identical/differ` 后仍 `return cached` | 证实 |
| 心跳 stats 追加 lifetime | `IrSession.kt:559` `" lifetime=${deviceLifetimeMs}"` | 证实 |
| checklist 日志串与代码一致 | 独立重跑 `CheckStrings.java`exact=16/skeleton=30/review=7;并逐条人工追溯 7 条到模板与调用点(`"$name write=$n/${packet.size}"` 418 行、`"$name resp=0x%08X len=%d head=%s"` 433 行、`"BasePara1: serial=... @${fps}fps"` 292 行、`"ep 0x%02X fail#..."` 408 行;调用点 `"GetParameter1"` 185、`"GetParameter2"` 201)。另抽查 `hb: state=`、`first reads`、`first rendered frame`、`first run on this host`、`requesting permission`、`permission result`、`[crash]`DebugLog 崩溃钩子)均存在 | 证实 |
| 唯一偏差 | lifetime 负载接受两种布局(见 FIX-3) | 偏差(低) |
### Phase B`656d419`
核查者用 Gradle 缓存内的 Kotlin 编译器(`kotlin-compiler-embeddable-2.2.0.jar`
在仓库外搭建独立 harness,直接编译并驱动**仓库真实源码**
`Mdt.kt`/`TempMath.kt`/`OfficialTables.kt`),不依赖执行者的任何测试代码。
| 声明 | 方法 | 结论 |
|---|---|---|
| `Mdt.parse` 与 `compose` 互逆 | 独立构造**含 EXIF 缩略图(内部含 `FF D9`)**、长度 1081(非 4 对齐)的 JPEG,往返比对各段 | 证实:jpg/info0/info1/frame/text 全部字节一致,裁剪点为主图 EOI |
| 坏尾/截断返回 null | 翻转尾部 1 字节、截断至 100B | 证实 |
| text 去 NUL 填充 | `Mdt.kt:123` `trimEnd('\u0000')`;独立用例短文本往返 | 证实 |
| 温度图 = u16LE → `countsToTempMc` | **独立复算**:从 `csdk/src/mag160c_official_t2e.h` 解析 646 项真表(非 Kotlin 表),照 `mag160c_render.c:64` 重写 C 版算法,对 19200 像素全量比对 | 证实:19200/19200 完全一致 |
| 探针为"直接映射" | `AnalyzeViewer.kt``aspectRatio(4f/3f)`、`drawImage` 无 `canvas.rotate`、`Bitmap.createBitmap(160,120)` | 证实(静态) |
| 温度条固定未缩放 fitRect | `drawTemperatureOsd` 传 `imageRect(size,1f,Offset.Zero)` | 证实 |
| 探针标签 | 用 `imageRect(size,zoom,pan)`,与计划字面"随 fitRect"不同 | 偏差(实现更优,建议保留,见 §0) |
### Phase C`b7a928e`)—— 本轮最需深挖的一相
| 声明 | 方法 | 结论 |
|---|---|---|
| `libcxsdk.so` 无静态调色板表 | 独立重跑 `PalScan.java`:三种编码(`B,G,R,0`/`B,G,R,0xFF`/忽略第 4 字节)**全部 not found**;[A] 段自校准证明扫描非盲 | 证实 |
| 索引↔case 映射 0..10(case 标签即调色板序号) | ① `libcxsdk_decomp.txt:456-1180``switch(param_2)`case 0..10 各一份生成体(另有 `case 0xc`/`case 0xd`);② **核查者新找到的决定性证据**:`jadx_magcx/.../DialogFragmentPalette.java` 的 `mapIndex2Id_` 把 UI 索引 0..11 依次对应白热…红热,点击时 `DeviceController.setColorPalette(index)` 把 **UI 索引原样传给 native** → case 标签 = UI 索引;③ case 2 → `OfficialTables.PALETTE256_ARGB` 256/256(重跑生成链复现) | 证实 |
| 索引 4/8/9 映射成立(执行者交底的不一致项) | **自写独立工具**(JDK `ImageIO` 解码官方预览图,不用执行者的手写 PNG 解码器),从 `PalBody` 重算全部 case 颜色集合,对 12 张预览图做完整覆盖矩阵 | 证实:case 0..10 **各自都是自身预览图的最佳解释者**;红饱和 case9 26.7% vs case0 22.1%,其 meanChroma=9.7 解释了"像素加权最近色距离"为何误判 |
| 索引 11 未解决、`SOURCE_CASE[11]==-1`、保留近似曲线 | `VendorPalettes.kt:24`、`Palettes.kt:47` | 证实 |
| 铁虹仍是官方表 | `officialIronbow()` 直接返回 `OfficialTables.PALETTE256_ARGB``PalettesTest.ironbowMatchesOfficialTables` 通过 | 证实 |
| 生成物可复现 | 两文件一起编译 → 重新生成:锚点 `iron_bow vs OfficialTables anchor: 256/256``VendorPalettes.kt`、`palette_candidates.json`、`palette_match_report.txt` **diff 全空** | 证实 |
| 红热预览图为占位副本 | ImageIO 逐像素:全像素 11777/11914 相同;不透明像素 8536/8673 | 证实(文档分母表述需修正,见 FIX-6) |
| `extract_palettes.py` | 本机无 python3,无法运行 | 无法判定(弱旁证) |
### Phase D`c34940e`
| 声明 | 方法 | 结论 |
|---|---|---|
| Retrofit 2.11.0 已接入 | `libs.versions.toml:9,24,25``app/build.gradle.kts:52,53` | 证实 |
| 默认关闭 | `AppSettings.kt:29-38``CloudClientTest` 4 项全绿 | 证实 |
| 无生产路径静默联网 | `grep -rn CloudClient android/app/src/main` 仅命中声明与 `AppSettings.setEnabled`main 无 `api()` 调用点;`api()` 未开启即 `check()` 抛异常 | 证实 |
| release 混淆不破 | 亲自跑 `assembleRelease`R8)成功 | 证实 |
| PROGUARD 规则 | `proguard-rules.pro:5` `-keep class com.mag160c.thermal.cloud.** { *; }` | 证实 |
### Phase E`f8b3200`
| 声明 | 方法 | 结论 |
|---|---|---|
| CAMERA 权限 + 相机可选;usb.host 仍 required | `aapt2 dump badging`camera/camera.any/autofocus 均 not-required`usb.host`/`screen.portrait` required | 证实 |
| 顶栏第 5 项 + 图标 | `LiveScreen.kt` 5 个 `weight(1f)` 项,第 5 项 `ic_pip`+`画中画``ic_pip.xml` 双弧圆描边 + 右下实心矩形 | 证实 |
| 三档 96/128/160dp、高=宽×3/4 | `PIP_WIDTHS_DP``hDp = wDp * 3 / 4`72/96/120,整除无误差) | 证实 |
| 右侧留色标条位 | `PIP_RIGHT_MARGIN_DP = 32`,初始 `pipXf=1f,pipYf=0f` | 证实 |
| 异常不崩溃 | `PipCameraView.kt` 全部回调 try/catch,失败路径收敛到 `release()` | 证实(静态) |
| 相机释放三处 | `PipOverlay` 的 `DisposableEffect onDispose`、离页同路径、`LiveScreen` 的 `LifecycleEventObserver(ON_STOP)` | 证实(静态) |
| ON_STOP 后能否自动重开 | 代码侧 `released` 置位后不复位,重开依赖 `onSurfaceTextureAvailable`;本机无设备 | **无法判定** |
| 双 `pointerInput` 手势并存 | Compose 运行时行为无法在本机验证 | 无法判定 |
### Phase F`512508e`)—— 2 项证伪
核查者用独立 harness 在 JVM 上驱动**真实的** `RemoteHost`/`RemoteSession`
(仅对 `DebugLog` 做最小桩),不打桩网络层。
| 声明 | 方法 | 结论 |
|---|---|---|
| 端口/魔数/帧长/超时与计划一致 | `RemoteContract`47510/47511/`0x1BB1B11B`/38400/38412/3000ms/10000ms 逐项比对 | 证实 |
| 封包格式小端 | 独立断言字段偏移、字节序(`pkt[0..3]={1B,B1,B1,1B}`)、payload 位于 12 | 证实 |
| 粘包/截断/坏长度/重同步 | 3 帧一次读入→3 payload;按 1000B 任意切分→恰好重组 1 次;截断 20000B→0 输出且 `pending()==20000`;坏 length→下一 magic 恢复 | 证实 |
| 端到端可用 | 真实回环:connect→welcome→stream-start→5 帧(payload 逐字节)→ffc 到达主机回调→stream-stop`RemoteLoopbackTest` 3 项亦全绿 | 证实 |
| 渲染在客户端本地 | `RemoteViewerViewModel`:本地 `RenderPipeline(160,120)`+assets `mag160c.ddt``setPalette` 只调本地管线;`setZoom` 只改状态 | 证实 |
| 主机侧不拖慢相机 | `Channel(capacity=8, DROP_OLDEST)`+`trySend`USB 读线程非阻塞)+单一 `serve()` 协程写 socket | 证实 |
| 默认不启动;开启需活跃 USB 会话 | `setRemoteHostEnabled` 先 `if (!session.isStreaming()) return false`UI 弹"先连接热像仪" | 证实 |
| INTERNET 权限 | badging 已声明 | 证实 |
| 单客户端,后来者拒绝写 busy | `acceptLoop` 内同步 `serve()`,第二连接排队等待,从不写 busy;`isBusyLine` 死代码 | **证伪**(见 FIX-2 |
| 命令写入不交错 | 见 FIX-1:120 次生产序列中 8 次丢 `welcome`;裸 socket 抓到 `hello`/`start` 颠倒 | **证伪**(见 FIX-1 |
| UDP 广播真机可达性 | 仅回环证据 | **无法判定** |
| `ACCESS_NETWORK_STATE` | 全代码无使用点 | 偏差(见 FIX-4) |
### Phase Z`8e1312a`
| 声明 | 方法 | 结论 |
|---|---|---|
| 消息无强制格式 | `git log -1 --format=%B` | 证实 |
| 内容仅为文档/清单/APK | `git show --stat`3 份文档 + APK**外加 Manifest+3 条 uses-feature** | **证伪**(见 FIX-5 |
| 文档更新到位 | HANDOFF §3 含 `ui/remote/`、`net/`、`cloud/CloudApi.kt`session_state 勾选 AZ 并追加总结;execution_plan 顶部加执行状态 | 证实 |
| 全量构建 + 提交 APK,且不 push | 重跑 debug+release+test 成功;`origin/main` 仍 `087e15c` | 证实 |
| 提交的 APK 与 HEAD 一致 | 对已提交 APK 核验:`screenOrientation=1`、camera 系列 not-required、INTERNET/CAMERA 已声明、dex 内含 `画中画`/`正在扫描局域网主机`/`云同步`(E/D/F 代码确在包内)。APK 字节不可复现(时间戳),故以内容核验替代字节比对 | 证实(以内容为准) |
---
## 3. 核查边界:本机无法判定(须真机裁决,未默认通过)
本机(Windows)无模拟器、无连接设备、`adb devices` 为空。以下必须真机判定:
1. USB 出流与温度绝对值标定(清单 1–12 步)。
2. 可见光 PIP 全部运行时行为(13–15 步):**含"返回后画面是否重新出现"**、
单击换档/双击关闭/拖拽三种手势是否互不干扰。
3. 局域网远程预览双机行为(16–24 步):**含 `255.255.255.255` 广播在真实
Wi-Fi 上是否可达**(部分路由/AP 会过滤广播;息屏策略也可能影响收包)。
4. 分析页温度条/探针的实际显示与取温正确性(11 步)。
5. 任何"画面/像素"层面的确认。
清单本身已按指南机械核对:16 条字面命中、30 条骨架命中、7 条人工追溯全部
落实到真实模板与调用点,未发现凭空编造。清单可继续作为真机验证脚本。
---
## 4. 真机测试时请重点确认(与修复决策挂钩)
| 要确认的事 | 怎么看 | 影响哪个修复 |
|---|---|---|
| 远程预览:连上后是否每次都看到"welcome"日志 | B 端日志 `[remote] line: {"type":"welcome"...}` 是否出现;连续重连 10 次统计 | FIX-1(当前约 6.7% 丢失) |
| 快速 stop→start、连点 FFC 是否偶发失灵 | 反复操作,看 B 端画面是否与按钮状态不一致 | FIX-1 |
| PIP:Home 返回后小窗是否重新出现 | 清单 15 步 | FIX-6.1(决定改文档还是改代码) |
| PIP:单击换档 / 双击关闭 / 拖拽是否互不干扰 | 清单 14 步,各做 5 次 | 若互相干扰则需改手势实现(本机无法判定) |
| 分析页:点已知温度位置,读数是否合理 | 清单 11 步 | 探针直接映射的运行时确认 |
| 远程预览:局域网是否能在 10s 内发现主机 | 清单 19 步 | UDP 广播可达性 |
| lifetime 是否非 -1 | 清单第 3 步 `[session] device lifetime=<ms>ms` | FIX-3 |
---
## 5. 核查者使用的判据来源(可独立复核)
| 判据 | 路径 |
|---|---|
| 协议权威(官方 Java | `analysis/sdk_re/android_app/jadx_magcx/cn/com/magnity/magnitycx/sdk/{D2PCmd,P2DCmd,UsbCommunication}.java` |
| 官方调色板 UI 顺序与 native 调用点(**本次新用** | `analysis/sdk_re/android_app/jadx_magcx/cn/com/magnity/magnitycx/DialogFragmentPalette.java` |
| native 伪代码(12+2 个生成体) | `analysis/sdk_re/android_app/libcxsdk_decomp.txt``SetColorPalette @00026c70`case 0..10、0xc、0xd |
| 铁虹真表(真实内存布局 `B,G,R,0` | `csdk/src/mag160c_official_palette256.h` |
| 温度 C 参考实现 + T2E 真表 | `csdk/src/mag160c_render.c:64`、`csdk/src/mag160c_official_t2e.h` |
| 帧布局权威 | `csdk/src/mag160c_frame.c:11-13`(像素自 `+0x1c`,总长 `0x38+len` |
| 官方预览图 | `app/【普通版】MAG-Cx.apk` → `res/mipmap-hdpi-v4/palette_*.png`12 张) |
---
## 6. 复现命令(修复模型可直接照跑)
### 6.1 构建 + 全量单测(Windows Git Bash
```bash
cd /c/Project/MAG160C/android && export JAVA_HOME="C:\\Tools\\jdk-21" && \
cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:assembleDebug :app:assembleRelease test --no-daemon"
# 强制重跑(否则 UP-TO-DATE 不产生新结果)
cmd //c "C:\Project\MAG160C\android\gradlew.bat :app:testDebugUnitTest --rerun-tasks --no-daemon"
# 单测计数
grep -h -o 'tests="[0-9]*" skipped="[0-9]*" failures="[0-9]*" errors="[0-9]*"' \
/c/Project/MAG160C/android/app/build/test-results/testDebugUnitTest/*.xml
```
### 6.2 Phase C 否证复核 + 生成物可复现
```bash
cd /c/Project/MAG160C
"C:\Tools\jdk-21\bin\java" analysis/tools/PalScan.java \
analysis/sdk_re/android_app/bin/libcxsdk.so csdk/src/mag160c_official_palette256.h
# 期望:三种编码全部 not found[A] 段自校准 detected 1 candidate
mkdir -p /c/Users/zxc/AppData/Local/Temp/regen_build && \
cd /c/Users/zxc/AppData/Local/Temp/regen_build && \
cp "C:\Project\MAG160C\analysis\tools\PalIdentify.java" . && \
cp "C:\Project\MAG160C\analysis\tools\PalExport2.java" . && \
"C:\Tools\jdk-21\bin\javac" -d out PalIdentify.java PalExport2.java && \
"C:\Tools\jdk-21\bin\java" -cp out PalExport2 \
"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\OfficialTables.kt" \
"C:\Users\zxc\AppData\Local\Temp\cxres\res\mipmap-hdpi-v4" \
"C:\Users\zxc\AppData\Local\Temp\regen_core" \
"C:\Users\zxc\AppData\Local\Temp\regen_out"
# 期望末行:iron_bow vs OfficialTables anchor: 256/256
diff "C:\Users\zxc\AppData\Local\Temp\regen_core\VendorPalettes.kt" \
"C:\Project\MAG160C\android\app\src\main\kotlin\com\mag160c\thermal\core\VendorPalettes.kt"
diff "C:\Users\zxc\AppData\Local\Temp\regen_out\palette_candidates.json" \
"C:\Project\MAG160C\analysis\sdk_re\android_app\palette_candidates.json"
# 期望:两条 diff 均为空
```
### 6.3 独立 Kotlin harness(仓库外编译仓库真实源码)
无 gcc / 无 kotlinc,用 Gradle 缓存内的编译器:
```bash
KC=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin
KX=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlinx
STDLIB=$KC/kotlin-stdlib/2.2.0/fdfc65fbc42fda253a26f61dac3c0aca335fae96/kotlin-stdlib-2.2.0.jar
COMPILER=$KC/kotlin-compiler-embeddable/2.2.0/8cfa2b049a4006d94474296df4abd9b50f288821/kotlin-compiler-embeddable-2.2.0.jar
SCRIPT=$KC/kotlin-script-runtime/2.2.0/87c92e866fcd68680966a3005a2992e1ab8ec6ad/kotlin-script-runtime-2.2.0.jar
CORO=$KX/kotlinx-coroutines-core-jvm/1.8.0/ac1dc37a30a93150b704022f8d895ee1bd3a36b3/kotlinx-coroutines-core-jvm-1.8.0.jar
ANNOT=/c/Users/zxc/.gradle/caches/modules-2/files-2.1/org.jetbrains/annotations/23.0.0/8cc20c07506ec18e0834947b84a864bfc094484e/annotations-23.0.0.jar
# 编译(示例:Phase FPhase B 同理,只换成 core/media 源码 + 自己的断言)
java -cp "$COMPILER;$STDLIB;$SCRIPT;$CORO;$ANNOT" org.jetbrains.kotlin.cli.jvm.K2JVMCompiler \
-no-stdlib -cp "$STDLIB;$CORO" -d out \
<DebugLog 桩>.kt \
android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteContract.kt \
android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteClient.kt \
android/app/src/main/kotlin/com/mag160c/thermal/net/RemoteHost.kt \
<自己的断言>.kt
java -cp "out;$STDLIB;$CORO" <主类>Kt
```
要点:
- `DebugLog` 依赖 Android 类型,需自写同包同名的 JVM 桩
`package com.mag160c.thermal.media; object DebugLog { fun log(t: String, m: String) {} }`),
网络类即可在纯 JVM 上运行。
- Windows 版 java **不认** Git Bash 的 `/tmp`,路径必须写 `C:\...`。
### 6.4 其它
```bash
# 清单日志串核对(期望 exact=16 skeleton=30 review=7
"C:\Tools\jdk-21\bin\java" analysis/tools/CheckStrings.java \
docs/android_app/real_device_checklist.md android/app/src/main
# APK 清单核验
"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump badging build-artifacts/mag160c-app-debug.apk
"C:\Tools\android-sdk\build-tools\36.0.0\aapt2.exe" dump xmltree build-artifacts/mag160c-app-debug.apk --file AndroidManifest.xml
```
---
## 7. 结论一句话
**Phase A/B/C/D/E 的声明全部证实(C 的索引 4/8/9 映射经独立复核成立;
B 的温度换算经独立 C 移植全量比对一致);Phase F 有两处真实缺陷
(命令乱序导致约 6.7% 丢 welcome、busy 未实现),Phase Z 有一处越界
(Manifest 改动);其余为低风险偏差。真机相关项无法判定,等实测。**