主题
TenoDX 同步触摸系统 · 项目总览
一句话:两片 PSoC 采自电容(CapSense)→ STM32H503 通过 I2C 轮询 raw 并下发扫描配置 → 固件内做基线/判定 → USB CDC0 输出 → 上位机
TouchHost-kaede做波形调试、日志与游戏直出;另有游戏内 MelonLoader modmelonloader-mod/直接接管触摸,实机运行时不经过TouchHost-kaede。本文是入口级速查文档,只讲"是什么 / 在哪 / 怎么走"。算法细节与验证结论见第 11 节索引。
判定架构与三端分发约束见
docs/TENO-TOUCH-CORE-DISTRIBUTION.md。CDC0 三传输(IO / RAW / Mai2Touch)的切换规则与扫描参数重配语义见
docs/TOUCH-TRANSPORT-CONFIG-PROTOCOL.md。
维护约定见第 12 节:改动结构层逻辑必须回写本文。
⚠️ 当前状态(2026-09-26 观测,2026-10-02 复核仍未解除):触摸没有收到。 控制器按 USB 能找到,但串口打不开 (注册表端口号过期 + 板子当前未插)。游戏内 Mod 本身已跑通:单文件加载、框架接缝、配置迁移都验证过。 完整排查与交接见
melonloader-mod/docs/HANDOFF.md, 按键/触摸输入路径的勘察见melonloader-mod/TenoMod/docs/HANDOFF-TOUCH-INPUT.md。 另:虚拟刷卡已在 2026-10-11 以新形态重新引入([TenoDXIO.VirtualCard],默认按P, 配置 20 位CardNumber);它走游戏自己的TryAime.TestAimeId入场门,不伪造 AMDaemon。
0. 快速上手(TL;DR)
| 子工程 | 路径 | 角色 | 入口 | 构建 |
|---|---|---|---|---|
| PSoC 固件 ×2 | PSoC Creator/CY8C4245PVI-482-Touch0.cydsn、...Touch1.cydsn | 电容采集从机,EZI2C 暴露 raw/状态,接收扫描参数 | main.c 的 main() | PSoC Creator 打开 .cyprj → Build |
| STM32 固件 | TenoDXController-H503/ | 主控:I2C 主、配置下发、触摸判定、USB 复合设备 | Core/Src/main.c → App/app.c 的 app_init() / app_task() | STM32CubeIDE 导入,或 CMake(第 8 节) |
| 上位机 | TouchHost-kaede/ | 触摸调试上位机(示波器 + 判定 + 日志 + 游戏直出) | TouchHost-kaede/main.py | python main.py,或 build.ps1 打包 exe |
| 游戏内 Mod | melonloader-mod/ | MelonLoader mod:在游戏进程内直接接管触摸(实机路径,不走 TouchHost-kaede),另含框架层 | TenoMod/src/TenoMod.Host/TenoModSeed.cs(产物 TenoDXIO.dll) | melonloader-mod/build.ps1(第 8 节) |
判定架构(2026-10-03 起):判定逻辑只有一份,位于根目录
touch_core/, 由上位机(Python)、游戏内 Mod(C#)与 STM32 固件三端共用;三端只保留各自适配层。 改判定的顺序固定为:先改核心并通过核心自带验证,再更新三端产物、跑一致性比对。 STM32 的最终验收必须从原始数据入口回放,不能只喂判定后的结果。
⚠️ 文档约定(强制):本文与其它说明性文档只写大概流程 (有哪些模块、谁调用谁、数据大致怎么走),不写任何实现细节—— 判定条件、阈值数值、状态机/分支、公式、系数、接口/结构体字段、源码行号对照 一律不入文档。实现细节以代码为准。 文档要及时清理:被取代的说明文档直接删除,不要留在仓库里当"历史参考"; 变更历史按各仓库既有约定写进对应的 changelog / 状态文档。
⚠️
PSoC Creator/目录不在版本控制内,且不要修改其中任何文件。 仓库结构(根仓库 + 9 个独立子仓库,互不嵌套注册):根目录synchronous/只追踪顶层共享内容 (本文、docs/Mai2TouchREADME.md、docs/TENO-TOUCH-CORE-DISTRIBUTION.md、docs/TOUCH-TRANSPORT-CONFIG-PROTOCOL.md、tools/README.md、build.ps1、build-all.ps1、clean-all.ps1、VERSION、version.json、.gitignore)。TenoDXController-H503/、touch_core/、TouchHost-kaede/、melonloader-mod/,以及tools/set_com_ports/、tools/TenoDXConfigurationTool/、tools/flash_firmware/、tools/h503_dfu_flasher/、tools/TenoDXTouchMappingTool/各自是独立仓库。 注意touch_core/不被根仓库跟踪,要看它的历史必须单独进该仓库。 其中TouchHost-kaede/是 2026-10-02 建立的 kaede 触屏适配分支,即上位机的现役版本, 不是当前 α 算法真源;docs/PDF-psoc/与melonloader-mod/docs/reference/一样属于只读参考资料,不入库。 根仓库用.gitignore整体忽略这些子项目,所有工具目录均由各自仓库管理版本。 因此跨子项目看版本要分别进各自的仓库看git log。
1. 系统拓扑
text
[PSoC Touch0 ] [PSoC Touch1 ]
[CY8C4245 / M0 ] ◀── Touch0_BUSY_OUT / Touch0_BUSY_IN ──▶ [CY8C4245 / M0 ]
[EZI2C 从机 0x08 ] (交叉握手:同一时刻只有一片在扫描) [EZI2C 从机 0x09 ]
[17 个自电容通道 ] [17 个自电容通道 ]
│ │
└────────── I2C1 共用总线:PB7 SDA / PB8 SCL ───────────────────────┘
│
▼
[STM32H503CBT6 主控 / 唯一主机]
裸机超级循环,无 RTOS,250 MHz
USB FS Device(TinyUSB)
│
4×CDC + 1×HID(端口用途见第 7 节)
│
┌─────────────────┴──────────────────┐
▼ ▼
[TouchHost-kaede 上位机] [melonloader-mod 游戏内 Mod]
波形调试 / 调参 / CSV TenoDXIO.dll(net472)
判定在 PC 侧(raw 模式) 认 CDC0 为串口读 70 B raw,
经 GameCommunicator 在游戏进程内跑同一套判定,
走虚拟串口直出游戏 直接写 InputManager.SetNewTouchPanel
│ │
└────────────► 游戏客户端 ◄──────────┘
二者互斥,同一时刻只能用一条链路(见第 5.7 节)接口一览:CDC0 Touch、CDC1 LED、CDC2 Aime、CDC3 Debug(Magic)、HID0 Keyboard。
要点:
- 两片 PSoC 分工:34 个物理通道被打散交错分到两片(不是按 A/B/C/D/E 整区划分),降低互扰与走线影响。
- 打散后仍串行扫描:两片通过
BUSY_OUT/BUSY_IN交叉连线做四步握手,任一时刻只有一片在扫;握手带 10 ms 超时自愈,任一方死掉也不会拖死另一方。 - STM32 是唯一主控:PSoC 上电只等主机写状态字,扫描分辨率/IDAC/时钟全部由 STM32 下发。
- 出线以后有两条互斥路线:
TouchHost-kaede把判定结果经游戏串口喂回游戏(第 5 节);melonloader-mod让TenoDXIO.dll直接读 CDC0、在游戏进程内判定并接管触摸(第 5.7 节)。两条链路都占 CDC0,不能同时开。
2. 全链路数据流(逐跳)
| # | 位置 | 动作 | 数据形态 |
|---|---|---|---|
| 1 | PSoC | CapSense 组件扫描 17 个传感器,写入 EZI2C 共享缓存 | ezi2cBuffer[154] |
| 2 | PSoC | 主循环刷新 RAW 区(小端 16-bit × 17) | 34 B |
| 3 | STM32 psoc_comm | 异步 I2C 读 [Status(1) + Raw(34)] = 35 B,两片交替 | PsocDevice.raw[35] |
| 4a | STM32 tenodata | raw 模式:每 2 ms 组 70 B 帧推 CDC0 | status + dev0(34) + dev1(34) + sum |
| 4b | STM32 mai2touch | Mai2Touch 模式:位图一变即推 9 B 帧,4 ms 保底续发 | 0x28 + 7×5bit + 0x29 |
| 5 | STM32 touch_pipeline | IIR(÷2) → 100 帧预热 → 100 帧采基线 → 34 路 Detector → 区域 OR 合并 | 34-bit 区域位图 |
| 6 | CDC0 → TouchHost-kaede | serial_reader 校验/去重 → signal_pipeline → 共享判定核心 | Packet → FrameResult |
| 7 | TouchHost-kaede | 波形图 / 数据表 / CSV 日志 / GameCommunicator | CSV、0x28 … 0x29 帧 |
| 8 | CDC0 → TenoDXIO.dll(游戏内 Mod,与 6/7 互斥) | .NET SerialService 校验/去重 → 双板独立 180 Hz 令牌桶 → TouchEngine | ushort[34] → 34-bit 掩码 |
| 9 | 游戏内 Mod | Harmony prefix 抑制原版触摸驱动 → postfix(Priority.Last)写 InputManager.SetNewTouchPanel | 34-bit 掩码直接进游戏 |
三种 CDC0 模式(按完整帧特征即时切换)
| 模式 | 值 | CDC0 内容 | 判定在哪 | 周期 |
|---|---|---|---|---|
| raw | 0 | 70 B:两片 PSoC 原始 raw | 上位机(调用共享判定核心) | 2 ms |
| Mai2Touch | 1 | 9 B:0x28 + 7 字节 5bit 位图 + 0x29 | 固件(调用共享判定核心) | 变化即发 / 4 ms 保底 |
| IO | A6 02 01 A9 启用 | 9 B:A5 + buttons(2) + touch(5) + seq | 固件 | 2 ms |
默认是自动识别(AUTO),touch_mode.c 按"收到的帧特征"决定走哪条流程,不需要手工切换:
| 当前侧 | 判据 | 动作 |
|---|---|---|
| raw / IO | 收齐一条语法合法的 6 B Mai2Touch 命令 { + 4 B + } | 关闭 IO(如有),切到 Mai2Touch,并把这条命令补喂给解析器 |
| Mai2Touch / IO | 收到 0xAA 01/02(raw 参数帧头) | 关闭 IO(如有),切到 raw,继续解析 136 B payload |
| raw / Mai2Touch | 收到完整合法 A6 02 00/01 checksum | 切到 IO;关闭时恢复进入 IO 前的模式 |
两个方向故意不对称:
- Mai2Touch 的流里不可能出现
0xAA(数据字节只用低 5 bit ≤0x1F,命令全是 ASCII),所以0xAA 01/02是可靠标志,看到即可切。 - raw 帧的 136 B payload 全是小数值(
res/mod/sns/div≤ 16),但校验和字节可能是0x7B({)。若见到{就切,上位机每秒重发一次配置,迟早误切。所以 raw → Mai2Touch 必须凑齐 6 字节并校验命令语法({RSET}/{HALT}/{STAT}或{L|R}{0x41..0x62}{r|k}{value})。 - 补喂(replay)是必须的:raw 侧为了校验已把整条命令读走,而
{STAT}(开始推流)和{L/R..}(游戏靠逐条回显推进配置)都不是"复位"语义,不能靠mai2touch_init()顶替 —— 否则轻则游戏卡在配置阶段,重则 Drive 阶段收不到触摸数据直接报错。
关键结构差异:
- Mai2Touch 模式下 CDC0 还接收
{...}ASCII 命令({RSET}复位发送状态 /{HALT}停发 /{STAT}开始发 /{RATIO}、{SENS}原样回声),与上位机GameCommunicator的命令集一致。必须先收到{STAT}才会推流。 {STAT}之后立刻开始推流,不等 PSoC 数据:touch_pipeline_is_ready()为假时先发空白帧(34 位全 0)。之后掩码一变立即补发,无变化时按 4 ms 保底续发(主机对同一读周期内的多帧按位或合并,所以多发只有好处)。游戏的 Drive 状态只收不发,若这边不吐字节,读循环拿不到触摸结果会报错。- 只有 Mai2Touch 模式才把数据喂给固件管线(
touch_pipeline_feed);raw 模式下管线停在 WARMUP,touch_pipeline_is_ready()恒为 false。切到 Mai2Touch 时会touch_pipeline_init()+ 请求完整重配。 - 模式/映射变更不会复位或重新配置 PSoC。
- 路由是互斥的:
cdc_manager_try_send(from_mai2touch, …)与当前模式不符的数据直接丢弃。 - 自动识别只在
cdc_manager处于 AUTO 时生效;用 Magic 写入0/1强制固定某一侧后不再自动切换,写2恢复自动识别(touch defaults也走 AUTO)。写入与当前一致的模式值不会退出 AUTO,所以上位机读写配置本身不会误关自动识别。
3. 子系统 A · PSoC 固件(两片)
路径:PSoC Creator/CY8C4245PVI-482-Touch0.cydsn/ 与 .../Touch1.cydsn/ 两片源码只有握手引脚方向不同(Touch0_BUSY_OUT/IN ↔ Touch1_*),其余逻辑一致。
3.1 EZI2C 共享内存布局(154 字节)
| 偏移 | 长度 | 内容 | 方向 |
|---|---|---|---|
| 0 | 1 | 状态与控制字 | 读写 |
| 1 | 34 | 17 通道 Raw(小端,低字节在前) | 只读 |
| 35 | 34 | 17 通道物理电容 Cp | 只读 |
| 69 | 17 | 17 通道 Compensation IDAC | 只读 |
| 86 | 17 | 17 通道扫描分辨率 | 写 |
| 103 | 17 | 17 通道 Modulation IDAC | 写 |
| 120 | 17 | 17 通道 Sense Clock Divider | 写 |
| 137 | 17 | 17 通道 Modulator Clock Divider | 写 |
STM32 侧只读 0x00 起 35 B,配置写在 0x56 = 86 起(17×4 = 68 B)。这是跨芯片契约:改动任一偏移必须同时改两份 main.c 与 App/touch/psoc_comm.h。
3.2 状态字(OFFSET_STATUS)
| 值 | 含义 |
|---|---|
0x00 | 等待主机下配置(上电后死等,只有非 0 才跳出) |
0x01 | 主机已下配置,开始校准流程 |
0x11~0x15 | 校准进度:启动组件 → 应用配置 → 读 Cp → SAR 校准 → 初始化基线 |
0x02 | 校准完成,正式运行 |
0xAD | 软复位命令(主机写入,PSoC 检测到即 CySoftwareReset()) |
3.3 上电/重配流程
EZI2C_Start()→ 状态置0x00→ 死等状态被改写(期间循环检查软复位)。- 收到
0x01:CyDelay(100)等待主机把多条 I2C 分包写完。 0x11启组件 →0x12逐通道应用扫描参数,带参数净化:res非法或为 0 → 12bit,mod0 → 50,sns0 → 2,modClk0 → 2(防止时钟分频为 0 导致硬件扫描死锁)。0x13读物理电容GetSensorCp()(依赖 PSoC Creator 勾选 Enable BIST,否则卡死)。0x14SAR 二分逼近校准 Compensation IDAC:从 bit 64 逐位逼近,目标 raw = 满量程 × 60%;纯整数运算(Cortex-M0 无 FPU)。0x15InitializeAllBaselines()→0x02交权进入运行循环。- 运行循环:
Check_Soft_Reset_Command()→ 拉高BUSY_OUT→ 扫描 → 刷新 RAW → 拉低BUSY_OUT→ 与另一片四步握手(每步 10 ms 超时)。
Touch1.cydsn/main1.c是历史遗留副本,未参与构建(Touch1实际编译main.c)。
4. 子系统 B · STM32 固件(TenoDXController-H503/)
- MCU:STM32H503CBT6,裸机超级循环,无 RTOS(
CFG_TUSB_OS = OPT_OS_NONE),全部靠HAL_GetTick()做软件定时门限。 - 时钟:CSI → PLL1(PLLM=1 / PLLN=125 / PLLP=2)→ SYSCLK 250 MHz,
FLASH_LATENCY_5。 - USB:TinyUSB Device / Full Speed,
USB_DRD_FS,4×CDC(无 Notification 端点,自定义 IAD 描述符)+ 1×HID;CDC RX/TX 缓冲各 256 B、EP 64 B、端点上限 6、接口上限 9。 - USB 标识:VID
0x0483/ PID0x5740/ bcdUSB0x0200,产品名TenoDX Controller。 (固件README.md与Core/Src/usb_descriptors.c已对齐到这一组值。)
4.1 启动、调度与模块
text
Core/Src/main.c : HAL_Init → status_led_app_init → SystemClock_Config → 各 MX_*_Init → app_init() → while(1) app_task()
app_task() 顺序 : status_led → tud_task → button → keyboard → aime_reader → touch → mai2led → config注意
status_led_app_init()在SystemClock_Config()之前调用(main.c:82),其时基基于复位后的 HSI 时钟。它是app_init()失败时唯一给出可见错误指示的模块。
输入优先:按键采样与 HID 上报排在触摸/读卡之前。触摸提交(每个 2 ms 包一次 34 通道判定)和 Flash 写入都会占用主循环,排在按键后面就等于把这段耗时加进按键时延。
| 模块 | 目录 | 职责 | 端口 | 主要周期 |
|---|---|---|---|---|
| touch | App/touch/ | PSoC 通信、状态机、管线判定、Mai2Touch、CDC0 | CDC0 | I2C 2 ms/片;raw 2 ms;Mai2Touch 变化即发 / 4 ms 保底 |
| led | App/led/ | Mai2LED 协议 + WS2812B(TIM3_CH3 + GPDMA1 + PWM,GRB + gamma) | CDC1 | 空闲彩虹 20 ms |
| aime | App/aime/ | PN532 / FeliCa IDm / MIFARE(USART2 115200) | CDC2 | 轮询 33 ms |
| config | App/config/ | Magic 协议、Flash 配置、Debug CDC | CDC3 | Magic 超时 100 ms |
| kb | App/kb/ | 12KRO 键盘报告(14 B 报文) | HID0 | 2 ms |
| button | App/button/ | 12 键扫描 + MultiButton(长按回调;主键输入走免消抖即时读) | GPIO | 5 ms |
| status | App/status/ | PC13 状态灯 | GPIO | 1 s 周期 |
App/touch/ 文件职责:
| 文件 | 内容 |
|---|---|
touch_app.c/h | 统一入口:touch_app_init/task,对上层暴露映射读写与状态快照 |
touch_mode.c/h | CDC0 三模式自动识别:A6 02 / AA 01/02 / 合法 Mai2Touch 命令;切换时补喂 RAW 帧头或 Mai2Touch 命令 |
tenodata.c/h | 主状态机 + raw 模式 CDC0 组帧推流 |
tenodata_config.c/h | 34 通道「区域编号 + block」映射表(default_mapping[34]);由 block 派生固定扫描档位 |
psoc_comm.c/h | PSoC I2C:探测/软复位/写配置/校准/异步读/总线恢复 |
touch_pipeline.c/h | 软件基线、逐通道调用共享判定核心、区域 OR 合并 |
touch_core_port.c/h | 便携适配层:把固件的映射与逐帧数据接进共享判定核心 |
mai2touch.c/h | Mai2Touch 协议输出 + {...} 命令解析 + mai2touch_replay_command() |
cdc_manager.c/h | CDC0 收发 + 路由开关(AUTO / 初始 raw / 初始 Mai2Touch / IO;完整帧特征始终优先) |
4.2 touch 状态机(tenodata.c)
text
STATE_INIT_WAIT(0) → STATE_PREPARE_PSOC → STATE_WAIT_PSOC_REBOOT → STATE_VERIFY_PSOC_REBOOT
→ STATE_WRITE_CONFIG_TO_PSOC → STATE_WAIT_CALIBRATION → STATE_RUNNING
↘ STATE_REINIT_DRAIN(重配前先清输出、排空 I2C)
STATE_WAIT_HOST_CONFIG(遗留,编号追加在末尾以保持既有状态编号不变)状态编号对 Magic 工具可见(
touch参数0x04会回读),新状态只能追加在末尾,不能插队。
分状态要点:
| 状态 | 行为 |
|---|---|
INIT_WAIT | 每 50 ms psoc_comm_probe()(3 trials / 10 ms);两片全在或锁定掩码全在 → finish_device_discovery;否则等 500 ms 稳定候选后按已探测掩码继续 |
PREPARE_PSOC | 先读状态再决定是否发 0xAD:0x00 → 无需复位;0x02 → soft_reset;0x01/0x11~0x15 期间 0xAD 被新固件有意忽略,只能等 |
WAIT_PSOC_REBOOT | 等 150 ms |
VERIFY_PSOC_REBOOT | 每 50 ms 复查,总超时 2000 ms;期间最多补发两次 0xAD,用来区分"延迟复位的新固件"与"忽略 0xAD 的 legacy 固件"(后者置 legacy_firmware_mask + power_cycle_required_mask) |
WAIT_HOST_CONFIG | 仅 !mai2touch && 无 host override 时进入;无超时,按 CDC 推流周期(2 ms)上报 0x01 提示上位机下发参数 |
WRITE_CONFIG_TO_PSOC | psoc_comm_write_config_mask(payload136, init_target_mask)(每片含 HAL_Delay(5)),失败的片直接标不可用 |
WAIT_CALIBRATION | 100 ms 轮询、5000 ms 超时;只接受 0x02;确认后又回退 → 判不可用 |
RUNNING | 2 ms 交替异步读(每片约 250 Hz);连续 3 次失败 → 标不可用;30 ms 事务超时 → psoc_comm_recover_bus()(I2C1 DeInit+Init)并计一次失败 |
REINIT_DRAIN | 等 psoc_comm_is_bus_ready() 才清标志重建,最多等 30 ms 后强制恢复总线 |
关键时序常量(tenodata.c 头部集中定义):
| 常量 | 值 | 说明 |
|---|---|---|
TENODATA_CDC_PERIOD_MS | 2 | raw 模式 CDC0 推流周期 |
TENODATA_I2C_POLL_PERIOD_MS | 2 | 单次 I2C 读间隔(两片交替 → 每片约 250 Hz;单次 35 B 读约 0.9 ms,总线占用约 45%) |
TENODATA_I2C_TRANSACTION_TIMEOUT_MS | 30 | 单次 I2C 事务超时 |
TENODATA_I2C_FAILURE_LIMIT | 3 | 连续失败 N 次判离线 |
TENODATA_SINGLE_DEVICE_WAIT_MS | 500 | 启动时确认单设备/无设备的等待 |
TENODATA_PROBE_PERIOD_MS / _REPROBE_PERIOD_MS | 50 / 500 | 初始探测 / 后台重探 |
TENODATA_PSOC_REBOOT_WAIT_MS / _TIMEOUT_MS | 150 / 2000 | 软复位后等待 / 超时 |
TENODATA_CALIB_CHECK_PERIOD_MS / _TIMEOUT_MS | 100 / 5000 | 校准轮询 / 超时 |
TENODATA_RECOVERY_BACKOFF_MIN_MS / _MAX_MS | 5000 / 60000 | 初始化失败退避区间(指数翻倍) |
降级运行(重要语义):支持 0/1/2 片 PSoC。
- 缺失/恢复中的设备:其 17 通道当前映射到的所有 Mai2Touch 区域强制置 1。
- 两片都缺失 → 当前映射表覆盖的全部区域置 1。
- 恢复设备必须重新完成「软复位 → 写配置 → 硬件校准 → 软件基线」,期间继续保持强制置 1。
- 后台重探策略不对称:只认状态
0x00或0x02,单纯 ACK 不算发现;用 1 trial / 2 ms 以免阻塞在线片。 - 同上位机语义一致:
game_communicator.update_channel_state()中raw_val == 0 → 强制 pressed。
上位机参数覆盖的生命周期(host_config_override):
- 只有 raw 模式下"收到过的最后一份有效 139 B 参数帧"会存入该覆盖;有覆盖时优先于本地档位。
- 覆盖不会自动过期;新版发送端通过
AA 02显式要求应用并重配,即使参数相同也会生效。 - 覆盖不再跨模式粘连:切换 CDC0 模式时会清除(
touch_app_apply_config的mode_changed分支), 避免"raw 下调过参 → 切回 Mai2Touch"继续沿用主机参数而不是本地档位。 AA 02在一次重配完成前忽略重复帧,防止握手重试引发重配风暴。
寄生电容 Cp / 补偿 IDAC(psoc_comm_read_cal / Magic 0x06):
- PSoC 分两个阶段写入(两段在 EZI2C 里连续):
- 状态
0x13→ Cp 写入偏移0x23(35),17 通道 × 2 字节 大端 = 34 B; - 状态
0x14(Tune_CapSense_CompIDAC_SAR()按目标 raw 百分比调整之后)→ 补偿 IDAC 写入偏移0x45(69),17 通道 × 1 字节 = 17 B。
- 状态
- 固件在
STATE_WAIT_CALIBRATION轮询里按各自写入时机分别到点读一次 (Cp ≥0x13、补偿 IDAC ≥0x14),且一次 I2C 事务读连续 51 字节同时取回两者 (psoc_comm_read_cal());结果缓存在cp_raw[2][34]/comp_idac[2][17], 重配(start_initialization)时一起作废,因为 PSoC 会重新测量与重跑 SAR 校准。 - 运行循环不刷新这两个值,
0xAD软复位后清零。 - ⚠️ 数值不变 ≠ 没刷新:Cp / 补偿 IDAC 只在 PSoC 每次校准时测一次,而校准只在 「PSoC 上电」或「参数真正变化触发重配」时发生。因此:
- 上位机「调参并重新下发」若内容与固件缓存完全相同会被
memcmp去重跳过, 不会触发重配 → 数值当然不变(改一个值才会重配); - 裸 PCB(未接触摸面板)的 Cp 物理恒定,多次重配测出来逐字节相同是正常的;
- 为便于判断,Magic
0x06每片附带 快照年龄(ms):年龄在重配/上电后归零、 之后单调增长。上位机在 Cp/Comp 标签上显示"Xs 前",工具touch cp也会打印。
- 上位机「调参并重新下发」若内容与固件缓存完全相同会被
- 读取是阻塞的(约几十微秒),发生在本就没有实时性要求的校准窗口内。
4.3 CDC0 帧格式
raw 模式(70 字节,固定)
| 偏移 | 长度 | 内容 |
|---|---|---|
| 0 | 1 | 状态:0x00 正常;0x02 重新初始化/写配置;校准阶段为 PSoC 当前状态码;0x01 请求上位机下发扫描参数 |
| 1 | 34 | 0x08 的 17 通道 16-bit 小端 |
| 35 | 34 | 0x09 的 17 通道 16-bit 小端 |
| 69 | 1 | sum(frame[0..68]) & 0xFF |
Mai2Touch 模式(9 字节):0x28('(') + 7 字节位图 + 0x29(')'),34 个区域位按 5 bit/字节 从低位起打包。
细节与坑:
- 非
0x00状态帧的载荷填递增序号status_frame_seq(写在[1]与[35])。否则上位机按载荷去重会把"载荷恒 0"的状态帧一起丢掉,导致0x02→0x01的配置请求永远看不到。 - 未连接设备的 raw 区恒为 0。
- CDC0 的 TX 无队列:
cdc_manager_try_send检查tud_cdc_n_ready与tud_cdc_n_write_available >= len,不足则整帧丢弃(无重试)。
4.4 判定与输出(touch_pipeline.c)
text
逐设备 payload 去重 → 仅变化通道做 IIR → 预热 → 收集 setup_raw
→ READY 后算 deriv / deriv2 / diff → 34 路 touch_core_port_process(共享判定核心)
→ 区域合并 → 与 forced_mask 按位 OR → 34-bit 写入 mai2touch| 常量 | 值 |
|---|---|
PIPELINE_IIR_FACTOR | 2.0(1/2 滤波) |
PIPELINE_WARMUP_FRAMES / PIPELINE_CALIB_FRAMES | 100 / 100 |
PIPELINE_HISTORY_SIZE | 16 |
判定本身的阈值与分支不在本文档展开,也不在固件仓库内单独维护: 判定只有一份实现,见根目录 touch_core/(细节读代码,见本文开头的文档约定)。
4.5 Magic 配置协议(CDC3 Debug Port)
text
magic_seq = 91 3E ED 20 7C 99 58 AC
request = magic_seq + [module, cmd, param, len, payload..., sum]
response = [AC, status, module, cmd, param, len, payload..., sum]- 载荷上限 248 B,超限回
LENGTH_ERROR。 - 100 ms 内无新字节且解析器非 IDLE → 回
IO_ERROR(0x06)并复位解析器。 - 状态码:
OK 0x00/SUM 0x01/MODULE 0x02/CMD 0x03/PARAM 0x04/LENGTH 0x05/IO 0x06。
| 模块 | 编号 | 内容 |
|---|---|---|
| global | 0x00 | 全局命令:0x84 ENTER_DFU、0x85 USB_RECONNECT,均需 payload {0xA5} |
| touch | 0x10 | 通道映射 / CDC0 模式 / 批量改映射 / PSoC 状态 |
| light | 0x20 | 0x01 灯珠数量、0x02 彩虹模式 |
| keyboard | 0x40 | 0x00..0x0B 单键码、0x80 配置键组、0x81 主布局 |
命令:01 READ / 02 WRITE / 03 SAVE / 04 LOAD_DEFAULT / 05 GET_INFO, 以及 81 READ_ALL / 82 WRITE_ALL / 83 SAVE_ALL / 84 ENTER_DFU / 85 USB_RECONNECT(param ALL = 0x00)。 全局读写是 module,len,data… 的 TLV 串联;SAVE_ALL 走一次性的 flash_config_transaction_begin/commit 原子落盘。注册顺序:touch → keyboard → led。
Touch 参数:
| 参数 | 读写 | 载荷 |
|---|---|---|
0x01 | RW | 68 B 完整映射表(34 × [region, block]) |
0x02 | RW | 1 B CDC0 初始模式:0 raw / 1 Mai2Touch / 2 自动识别(默认)。完整入站帧特征始终优先;当前实际在跑哪一侧看 0x04 状态 |
0x03 | W | 批量部分更新,每条 3 B [channel, region, block],1–34 条、不得重复通道 |
0x04 | R | PSoC 状态快照(16 B = 4 B 头 + 2 × 6 B 设备信息);GET_INFO 另回 10 B 能力表(第 8 字节 bit1 = 支持 0x07;第 9/10 字节为只读参数列表 0x04/0x06) |
0x06 | R | 校准量快照:106 B = 每片 Cp(17 通道 × 2 B 大端) + 补偿 IDAC(17 通道 × 1 B) + 快照年龄(2 B 大端, ms),片内步进 53 B;无效片(未校准成功/刚重配)整片填 0。工具命令 touch cp |
Touch 的 Flash / ALL 序列化固定为 [version=2, mode, mapping[68]],共 70 B(有 _Static_assert)。 WRITE 只改 RAM,SAVE/SAVE_ALL 才落 Flash。
4.6 Flash 配置布局
- 区域:最后 8 KiB(
FLASH_CONFIG_AREA_SIZE = 0x2000),位于0x0801E000,即 1 个扇区。 - 结构:64 条 × 128 字节滚动记录(不是 4 个固定槽)。
- 记录头 16 B:
{ magic = 0x31474643 ('CFG1'), version = 2, record_size, sequence(u32), crc32(u32) },CRC32 覆盖第 16..127 字节。 - 写入顺序:先写 payload,最后写含 magic 的首四字 → 掉电不会产生"看似合法"的记录。无空槽时擦整区从 0 号重写;读取时取
sequence最新的一条。 - 兼容 legacy v1(magic
0x30474643、槽大小0x0800)的回读;槽容量按 payload 计:TOUCH 70 / LIGHT 20 / RESERVED 0 / KEYBOARD 8。 - 版本号:记录 v2、TOUCH payload v2、KEYBOARD flash v3(legacy v2)/ payload v2、LIGHT flash v3 / payload v2。
4.7 其他模块速览
- Aime(CDC2):帧以
0xE0起始、0xD0转义(解码+1/ 编码−1)、1 字节累加和,长度 5..64,字节间超时 100 ms。命令:0x30固件版本0x94、0x32硬件版本"837-15396"、0xF0扩展信息、0x40/0x41启停 PN532 轮询、0x42取卡(0x10MiFare /0x20FeliCa)、0x52取块、0x61/0x64状态、0x81/0x82不回包。PN532 走 USART2,HAL_UARTEx_ReceiveToIdle_IT+ 256 B 环形缓冲,33 ms 轮询、2 s 卡去抖、初始化 3 次重试。 - LED(CDC1):Mai2 灯板协议(Sync
0xE0/ Marker0xD0;0x31SetLedGs8Bit、0x32Multi、0x33MultiFade、0x39SetLedFet、0x3CSetLedGsUpdate、0x7B/0x7CEEPROM、0xF0GetBoardInfo)。8 个逻辑位 ×led_per_bit(默认 2、最大 4 → 32 像素)驱动 WS2812B;发送超时 5 ms、锁存 1 ms。长按BTN8≈5 s 恢复空闲灯效。 - Keyboard(HID0):12KRO,14 B 报文;IN 端点 2 ms 轮询,固件在端点空闲时立刻用当前按键状态补帧(不再等固定 2 ms 拍点)。键 0–7 按布局 1P =
W E D C X Z A Q/ 2P =KP8 KP9 KP6 KP3 KP2 KP1 KP4 KP7;键 8–11 可配置,默认{3, keypad_multiply, 8, 9}。落盘 8 B(first_key=8,key_count=4),兼容 legacy 6 B(旧 key10 = PB10 → 新 key11)。 - Button:12 个 GPIO(
BTN0-7= PA0–PA7,BTN8-11= PB0/PB1/PB2/PB10,全部上拉输入),复用multi_button,5 ms tick;主键高电平有效、配置键低电平有效;上电采样一次,引脚为高者视为未接入并禁用该键。主键的 HID / CDC0 IO / 空闲灯效输入不走消抖:BTN0-7由按键板开漏驱动,直接用button_app_is_pressed_immediate()读引脚电平,免掉3 × 5 ms = 15 ms的multi_button消抖;消抖状态机仍为长按/单击判定与裸开关BTN8-11服务。 - Status:PC13,状态机
INITIALIZING / RUNNING / CONFIG_WRITE / ERROR;运行态 1 s 周期(亮 100 ms);配置写入 80 ms 相位闪烁、成功后 480 ms 反馈;set_error()常亮并屏蔽其他状态。
5. 子系统 C · PC 侧(TouchHost-kaede/ 上位机 + melonloader-mod/ 游戏内 Mod)
技术栈:Python 3 + PyQt5 + pyqtgraph + pyserial + PyYAML(打包用 PyInstaller,产物 CapSenseOscilloscope.exe)。
本节 5.1–5.6 讲
TouchHost-kaede/(调试上位机,判定在 PC 上、再经游戏串口喂回游戏)。 5.7 讲melonloader-mod/:另一条路线,mod 在游戏进程内直接读 CDC0 并接管触摸,实机运行时不经过TouchHost-kaede。两者都占 CDC0,不可同时运行。
| 文件 | 职责 |
|---|---|
main.py | 先设 PYQTGRAPH_QT_LIB=PyQt5,再建窗口进入 Qt 事件循环;不参与数据处理 |
main_window.py | TouchOscilloscope:UI、串口控制、波形/数据表、限速分发到 CSV 与游戏通信 |
serial_reader.py | 70 B 帧解析、校验和、双设备去重、STM32 状态握手与参数下发 |
signal_pipeline.py | IIR、100 帧预热、100 帧 setup_raw 基线、逐帧调共享判定核心、维护 UI 快照 |
judge_backend.py | 判定后端工厂:默认装配共享核心 dll;测试可切仓库内 Python 实现(源码与 exe 都可显式启用,启用后界面/日志标记) |
judge_py.py | 测试参考判定实现(非生产):B/C/D/E 与核心同源、A 区为本机实验算法;不进生产路径,但会随 exe 打包以便显式启用 |
config.py + config.yaml | 统一配置表(含 judge_overrides 判定阈值覆盖、judge_backend 后端选择);支持运行期热重载 reload_config() |
device_finder.py | STM32 设备识别:VID/PID + 注册表 MI 接口号 → 找出 Touch CDC(CDC0)/ Debug Port(CDC3) |
magic_client.py | Magic 协议最小客户端(走 CDC3 按需读 Cp,读完即断开) |
csv_logger.py | CSV 记录,按会话目录 + 行数/512 MB 自动分块 |
game_communicator.py | 直出游戏客户端串口 |
docs/ | 数据流概览(只讲模块与流程,不含算法细节) |
线程模型:没有工作线程、没有跨线程 Qt signal。串口读取完全由主线程 QTimer(16 ms) 驱动,数据流是同步函数调用;唯一额外线程是 GameCommunicator._read_loop(daemon,10 ms 轮询)。
状态归属:串口与去重历史在 SerialReader;iir_history / setup_raws / 14 组 snapshot_* 在 SignalPipeline;判定递归量在共享判定核心(TenoTouchCore)内部,本端只持有本帧 Decision 的引用(_last_decisions);波形环形缓冲(window_size=3000)与限速令牌桶在 TouchOscilloscope;日志分块状态在 CsvLogger;游戏串口与 touch_state[34] 在 GameCommunicator。
判定后端:生产判定固定由共享核心 dll 提供;config.yaml: judge_backend 可把同一接口切到仓库内 judge_py.py(仅测试,见 5.3 与第 10 节第 31 条)。两个后端返回同一个 Decision 结构,因此管线、界面、CSV 三处消费者不需要区分后端。
5.1 串口层
- 帧长固定
PACKET_SIZE = 70;布局[0]=status | [1:35]=Dev1 | [35:69]=Dev2 | [69]=校验和;通道 0–16 属 Dev1、17–33 属 Dev2。 - 校验
sum(packet[:69]) & 0xFF;失配只丢 1 个字节再继续,即滑动窗口重同步。 - 状态帧内部消化,不返回给调用方:
0x00计为数据帧,0x01带 1 s 限流后调send_psoc_config(),0x02与0x11~0x15静默丢弃。连接建立并采用主控映射后,主窗口也会主动调用一次send_psoc_config()。 - 去重:与上一帧两侧载荷比较得
dev1_changed/dev2_changed;只有两侧都没变化才整帧丢弃。 - 参数下发(raw 模式的扫描参数覆盖):帧 =
0xAA 0x02 + 34×res + 34×mod + 34×sns + 34×div + checksum,共 139 B。AA 02应用并强制重配;AA 01仅保留幂等兼容。 - 下发的四个触发点:① 串口打开并采用主控映射后立即主动下发一次;② 收到固件
0x01(1 s 限流,原始机制);③ 上位机「重新加载配置」按钮检测到扫描参数变化后主动调用send_psoc_config();④ 热重载时串口未打开则暂存pending_scan_config_send,下次打开串口补发。send_psoc_config()返回bool(写入失败会打印原因并保留待下发标记)。 - 拔线检测与自动重连(已闭环,不再是风险点):
read_packets()用try/except (OSError, serial.SerialException)包住in_waiting/read——设备消失时 Windows 会抛ClearCommError (PermissionError(13)); 捕获后打印原因并主动close()。update_data()在read_packets()之后复查is_open(),一发现串口被关闭就立刻handle_disconnected():界面复位(按钮回「连接」、标签转「连接中...」)、 停止采集定时器、并交给常驻的自动识别定时器继续重连探测。- 效果:拔线 → 立即提示"读取失败(设备可能已拔出)/ 连接已断开,正在自动重连..."; 插回 → 自动重连。不再出现"波形静默停止、必须重启软件"。
5.2 管线与判定(速查)
process_packet()先继承上一帧快照,未更新通道不清零。- IIR:
config.yaml: iir_factor(1.0 = 关闭;可热重载),history = (history*(f-1) + raw)/f。 - 预热 100 帧 → 校准 100 帧(只收"该侧确有更新"的帧,取均值写入
setup_raws)→ 进入判定。 diff = val − setup_raw(静态坐标系);判定核心返回的Deriv/Deriv2在核心内部按帧计算;管线另外对判定核心返回的EffDiff逐帧求差分,得到日志列EffDiffDeriv/EffDiffDeriv2。- 判定后端由
config.yaml: judge_backend决定(dll默认 /python仅测试)。切换后端会重建核心并清空判定状态,等同于重新采集基线;judge_overrides对两个后端都生效。 - 用户对延迟敏感:观察窗口
crash_window控制在 2 帧以内。
5.3 配置(config.yaml + config.py)
| 键 | 默认值 | 含义 / 去处 |
|---|---|---|
zone_params.{A..E} | A 12/50/2/2、B 10/30/2/2、C 12/30/8/2、D 8/35/2/2、E 10/40/2/2 | res/mod/sns/div,经 send_psoc_config() 下发给 STM32;与固件 block_profiles 逐区相同(见 6.2) |
hardware_mapping | 0–16 = Touch0(0x08),17–33 = Touch1(0x09) | 通道 → 区域名,仅作回退:连接时以主控 Flash 的映射为准(经 Debug Port 读取并覆盖本表);area[0] 决定 det_group |
game_areas | 34 项固定顺序 | 34-bit 游戏位序(AREA_TO_INDEX);C 区只有 C1、C2 |
game_port | COM17 | 游戏客户端串口 |
log_channels | [A4] | 记录哪些区域;空列表 = 全 34 通道 |
log_fields | 12 项(见 5.5) | CSV 写哪些列及其顺序;空列表 = 全部可用字段;非法字段名在加载/热重载时报错 |
log_chunk_rows | 500000 | 单文件行数上限(另有 512 MB 硬上限) |
report_rate_hz | 180 | 每 Dev 独立的消费限速(Hz),0 = 不限速 |
iir_factor | 2.0 | IIR 系数(1.0 = 关闭),热重载生效 |
judge_backend | dll | 判定后端:dll = 共享核心(生产唯一选择)/ python = 仓库内测试实现(源码与 exe 都可显式启用;启用后界面标签、窗口标题、CSV 头都会标记为测试判定),热重载生效,见 5.2 |
判定阈值不在
config.yaml里:默认值编译在当前保留的旧版touch_core中(固定提交17b7c0b)。 本机要覆盖 B/C/D/E 的四个判定数值时用judge_overrides(只写要改的键,未写的回落到核心默认;A 区不可覆盖)。 覆盖会改变params_hash,三端比对时必须推同一组值。扫描参数(zone_params)走config.yaml并经串口下发。
热重载(「重新加载配置」按钮):
config.reload_config()重新读取 yaml 并就地更新MASTER_MAPPING/AREA_TO_INDEX/ZONE_PARAMS/HARDWARE_MAPPING/GAME_AREAS/GAME_PORT/LOG_CHANNELS/LOG_FIELDS/LOG_CHUNK_ROWS/REPORT_RATE_HZ/JUDGE_OVERRIDES/IIR_FACTOR/JUDGE_BACKEND(dict 清空重填,标量模块级重新绑定);log_fields、judge_overrides与judge_backend在落盘前先行校验,非法值会让整次重载失败并保持旧配置。 因此signal_pipeline.py/serial_reader.py/game_communicator.py在 import 期取得的引用仍指向同一对象并看到新值,无需重新 import。- 解析失败(文件缺失 / YAML 语法错 / 结构非法,如
hardware_mapping缺通道或区域名不在zone_params中)会抛异常且所有导出对象保持旧值,UI 弹窗提示。 - 点击后依次:重载 → 刷新
report_rate_hz/ IIR 系数 / 判定阈值覆盖 → 判定后端(变了则重建核心并清空基线) → 重建波形通道下拉框 → 刷新数据表首列标签 → 刷新CsvLogger的通道与分块行数 → 比对扫描参数;有变化且串口已打开则自动重新下发并弹窗,未打开则暂存待下次开串口补发,无变化则明确提示"无需重新下发"。普通连接/重连也会在采用主控映射后主动下发一次当前参数。 CsvLogger的通道 / 字段 / 分块参数在运行期读config模块属性(不再用 import 期绑定),因此重载能生效。- 正在记录日志时拒绝重载(避免同一个 CSV 内不同行的通道含义不一致)。
代码内硬编码、yaml 不可调:预热/校准各 100 帧、游戏波特率固定 115200、window_size=3000、 定时器 16 ms / 66 ms、触发电平(400 / −10 / 800 / −50,仅显示用、不是算法阈值)、LOG_CHUNK_MAX_BYTES=512MB。 A 区判定参数的默认值编译在当前保留的旧版 touch_core 中,且不可运行期覆盖。 扫描参数、映射与 IIR 系数可经「重新加载配置」按钮热更新;B/C/D/E 判定阈值走 judge_overrides; 判定后端走 judge_backend(python 仅测试用,源码与 exe 都可显式启用但要看得见标记,见第 10 节第 31 条)。
5.4 UI
- 布局:
QSplitter(Horizontal),左 = 控制行 + 垂直分割(Diff 图 / 导数图,setXLink联动横轴);右 = STM32 状态标签 + Hz 标签 + 34×5 数据表。 - 控件:触摸口状态标签(以自动识别为主)、波特率(115200/256000/460800/921600,默认 115200;CDC 下波特率无实际意义)、重新搜索触摸口、系统串口下拉框 +「连接指定端口」(绕开 TenoDX 自动识别、手动指定 COM 口,选中后暂停自动重连)、通道选择(按 A→E 分组,文本如
A1 区块 (CH 12))、连接 / 断开、日志开关、重新加载配置(热重载 yaml,见 5.3)。 - STM32 串口自动识别(无需选口)(
device_finder.py):按 VID/PID0x0483:5740找出控制器端口,再从注册表…\<VID&PID&MI_XX>\<instance>\Device Parameters\PortName反查 MI 接口号,换算 CDC 通道(MI_00= CDC0 = Touch)。- ⚠️ 不能靠串口名字识别:固件虽为各接口配了名字符串(
TenoDX Touch Port等,usb_descriptors.c),但 Windows 用微软usbser.inf,FriendlyName一律是通用的USB 串行设备 (COMn)(实测注册表确认),所以必须用 MI 接口号。 - 连接全自动、不可关闭:常驻定时器每 1.5 s 检查一次(
autodetect_timer),未连接时也运行,负责自动连上、掉线自动重连、插上新板子自动发现;只在状态变化时打印,不刷屏。 - 状态标签三态:
未找到 TenoDX 控制器(红) /COM3 · Touch Port (连接中...)(黄) /COM3 · Touch Port (已连接)(绿);用户点「断开」后显示COM3 · 已断开(点「连接」恢复)(灰)。 - 「连接 / 断开」按钮:断开是用户意图——置
user_disconnected,自动识别定时器随即跳过重连,不会自己连回来;再点「连接」即清除标记并立即重连。掉线(非用户意图)仍会自动重连。 - 状态标签的"正常运行"只有真的收到 STM32 状态帧后才会出现;刚连上显示
已连接 / 等待数据...。
- ⚠️ 不能靠串口名字识别:固件虽为各接口配了名字符串(
- Diff 图 4 条曲线:绿色
Diff(raw−setup_raw)、橙色有效信号(判定用)、浅蓝虚线动态基线(NaN 处断开)、红色虚线判定状态方波。 - 导数图 3 条曲线:有效值
EffDiff的一阶差分、二阶差分,以及判定状态方波(曲线名在界面上标为「有效值」)。 - 触发方波不是算法阈值线,而是直接由
result.pressed[ch]生成的高低电平。 - 控制流:
apply_loaded_config()不再重填已删除的重载参数列(改为_refresh_table_labels());refresh_manual_ports()与自动识别定时器在__init__最末尾启动,保证自动连接时数据表、状态标签、Cp 控件都已存在。 - 数据表列:
区块(CH) | Raw | 有效Diff | Cp | Comp(5 列;Res/Mod/Sns/Div、导数、2阶导三组列均已在 v3.0 移除, 静态扫描参数在config.yaml里已有、导数只在波形图与 CSV;判定状态高亮合并进「有效Diff」列 ——按下时深红底白字,未按下时暗底灰字。列索引见COL_RAW/COL_DIFF/COL_CP/COL_COMP/DATA_TABLE_COLUMNS)。 刷新由 66 ms 定时器驱动,校准完成前不刷新;表中没有 baseline 列(基线 / EffDiff / 有效值导数只在 CSV)。Raw为整数;有效Diff保留 1 位小数(取核心返回的有效值,即算法实际用于判定的那一路信号)。
- Cp / 补偿 IDAC:「读取 Cp / Comp」按钮经 Debug Port (CDC3) 读 Magic
0x06(106 B = 两片各 53 B), 一次填两列;状态字节首次回到0x00(PSoC 刚走完校准)时自动读一次。 Debug Port 用完立即断开(magic_client.MagicClient每次读取开一次、with退出即 close)。 两者都不进 CSV 日志。 - 判定后端标签(Cp 行右侧):
判定后端: DLL (生产)(绿)/判定后端: Python (测试)(黄,exe 内再加· exe)。 值来自config.yaml: judge_backend,热重载后立即反映;切换时清空基线并按新后端重新预热校准。 测试后端生效时窗口标题会追加[测试判定]后缀(不改布局,只在标题栏可见)。 - 扫描参数实时调试面板(控制行下方
QGroupBox):4 个QSpinBox(分辨率 6~14、Mod IDAC、Sense 分频、Mod 分频,后三者 1~255)+「调参并重新下发」按钮。- 按区块类型批量生效:面板跟随当前波形通道所属类型(
det_group),改了之后应用到该类型全部区块(选 A1 → A 区 8 个区块一起改;C 区只有 2 个)。分组:A/B/C/D/E = 8/8/2/8/8 通道,合计 34。 - 只改运行期内存(
config.MASTER_MAPPING),不写入config.yaml;点按钮即调send_psoc_config()下发 139 B 帧,STM32 走完整重配(软复位→写配置→校准→重建基线,判定暂停 1~2 s)。 - 未连接时只改内存并置
pending_scan_config_send,连上后自动补发;状态行显示"已下发 / 未连接暂存 / 下发失败"。 - ⚠️ 下发后会自动重新采集基线:参数一变 PSoC 会软复位重配、所有通道 raw 整体跳到新水平, 因此下发成功后立即
reset_system_data()(清 IIR 历史 /setup_raws/ 34 路判定核心状态), 回到 预热 100 帧 → 采基线 100 帧 → 判定。若不清,Diff会长时间停在 "新 raw − 旧基线"的巨大负值上(实测 -750 量级、A 区基线冻结后只能极慢向上追踪)。 期间数据表停止刷新、判定输出中断约 2 秒(刻意为之)。Cp/Comp 也会被标记为待重读。 - ⚠️ 点「重新加载配置」会用文件里的值覆盖面板的临时修改;重启上位机也会回到文件值(暂无回写文件按钮)。
- 按区块类型批量生效:面板跟随当前波形通道所属类型(
- 限速令牌桶:两个 Dev 各自累积额度(封顶
rate×0.1秒),从最新帧向最旧帧分配(丢旧留新,保延迟);校准完成前不限速。
5.5 CSV 日志
- 目录
logs/{启动时刻 %Y%m%d_%H%M%S}/(相对当前工作目录),文件capsense_log_0001.csv递增。 - 文件头四行元信息:
# session_start=/# channels=/# chunk=/# judge=。# judge=记录本次会话的判定后端身份(后端名 + ABI + 算法版本 + 参数指纹), 测试后端的日志由此可与生产日志区分,代码见csv_logger._open_chunk()。 Raw/SetupRaw是主机侧 IIR 之后的坐标(v3.0 起已无 raw 对齐,不再有额外增益/偏移),不是 PSoC EZI2C 里的原始计数;追查硬件原始值时要看 CDC0/固件原始 raw。- 12 列(可在
config.yaml的log_fields增删/改序):
text
Timestamp, Channel, Raw, SetupRaw, Diff, Baseline,
EffDiff, EffDiffDeriv, EffDiffDeriv2, Deriv, Deriv2, IsPressedBaseline/EffDiff是判定核心内部的递归量,无法由左侧列回算;EffDiffDeriv/EffDiffDeriv2是管线对EffDiff逐帧差分得到的一/二阶导数;Deriv/Deriv2是判定核心返回的一/二阶差分。- 日志回放验证必须用
EffDiff等真值列,不能用Raw/Diff反算(IIR 取整会累积漂移)。 None/NaN写空单元格;同一帧所有通道共享同一Timestamp(保证同帧不跨分块文件);行数或 512 MB 任一超限即分块。
5.6 游戏直出(game_communicator.py)
- 独立 daemon 线程读游戏串口,解析
{CMD}ASCII 命令:RSET忽略 /HALT停发 /STAT允许发,其它回(cmd)。波特率固定 115200。 - 触摸帧:
0x28('(') + 7 B(34 bit 按 5 bit/字节)+ 0x29(')'),位序即game_areas(AREA_TO_INDEX)。 raw_val == 0的通道强制置位(与固件"缺失设备强制置 1"语义一致)。
5.7 游戏内 Mod(melonloader-mod/,实机路径)
这是实机运行时真正用的那条链路:TenoDXIO.dll 在游戏进程内直接把触摸写进 InputManager,不再需要虚拟串口。
5.7.1 产物与部署
| 项 | 值 |
|---|---|
| 部署产物 | TenoDXIO.dll(约 109 KB,实测 111616 B) |
| 目标框架 | net472(游戏是 Unity 2018.4 + MelonLoader 0.6.4,Runtime Type = net35,不能是 netstandard) |
| 构建输出目录 | melonloader-mod/output/(工作区统一产物目录,不入库) |
| 安装位置 | 游戏 Mods/ 目录,只有一个文件(其余全部内嵌) |
| 配置 | TenoDXIO.ini(INI,放在游戏根目录;缺文件时首次启动生成) |
| 日志 | TenoDX_Logs/Log_<时间戳>/touch_log_partN.txt,每 4 MB 一卷 |
| 当前状态 | ✅ 已实机加载并验证框架接缝;❌ 触摸输入仍未验证(TenoMod 全链 292 项通过;TenoDXIO 内核自测有 39 项既有断言漂移,与刷卡改动无关) |
单文件打包:入口 TenoModSeed 只引用 mscorlib + System + MelonLoader,在静态构造函数里订阅 AppDomain.AssemblyResolve,把 5 个内嵌程序集(deflate 压缩)从内存加载:
| 内嵌程序集 | 来源工程 | 内容 |
|---|---|---|
tenomod-payload | TenoMod.Game | 框架游戏层 + 组装根 |
TenoMod.Runtime | TenoMod.Runtime | 事件总线、Hook 管理、调度、按键 |
TenoMod.Contracts | TenoMod.Contracts | 接口与 POCO |
tenodx-payload | TenoDXIO.Payload | 触摸功能(串口、桥接、日志) |
TenoDXIO.Core | TenoDXIO.Core | 触摸判定内核(零游戏依赖) |
✅ 只有一个入口工程、只有一个产物:
TenoDXIO.dll。工作区里没有独立的TenoDXIO.Host, 也不会再产出tenodx.dll——历史上曾存在第二个入口,但两个入口会用同一批 Harmony 补丁注册两次 (第二个 id 是重复执行而不是替换),已删除。构建脚本仍会清理Mods里遗留的tenodx.dll/tenomod.dll,防止旧文件重新引入重复补丁。
5.7.2 四层架构(为什么这么切)
| 层 | 工程 | 目标 | 接触游戏 | 可否脱机测 |
|---|---|---|---|---|
| Contracts | TenoMod.Contracts | net472;net8.0 | ❌ | ✅ |
| Runtime | TenoMod.Runtime | net472;net8.0 | ❌ | ✅ |
| Game | TenoMod.Game | net472 | ✅ 唯一允许 | ❌ |
| Host | TenoMod.Host | net472 | ❌(只做引导) | ❌ |
切分的目的是让最容易出错的逻辑能在没装游戏的机器上跑测试——修 mod 的机器常常就是没开游戏的那一台。 触摸判定内核(TenoDXIO.Core)也是零游戏依赖的,正因此才能在没装游戏的机器上跑绑定一致性比对。
5.7.3 触摸链路
两条传输二选一,由 EnableGameIo 决定(schema v15 起默认 IO);没有 IO 主控应答时自动回退 raw。
IO(默认):主控自己判定,mod 只转发结果。
text
CDC0 COM13(1P)/COM14(2P)(115200)→ GameIoService.Probe
├─ 先发 disable A6 02 00 A8(把可能卡在 IO 的主控切回 raw)
├─ 再发 139 B 扫描配置(AA 02 + 34×res/mod/sns/div + 校验和)
└─ 最后发 enable A6 02 01 A9 → 主控改发 9 B 状态流
A5 + buttons(2) + touch(5) + seq → GameIoStateRouter(seq 连续 3 帧才算"这是 IO")
主线程 Harmony postfix(NewTouchPanel.Execute, Priority.Last)
→ InputManager.SetNewTouchPanel(按键走 GameIoInputMerge 叠加,不覆盖机台 IO)顺序是刻意的:主控固件的 IO 模式会丢弃一切非 IO 字节,包括它自己
STATE_WAIT_HOST_CONFIG正在等的 139 B 配置帧。先 enable 再发配置会被吃掉,管线永远不 READY,9 B 帧的 touch 恒为 0。
raw:主机判定。
text
CDC0(115200)→ SerialService.ReadLoop
├─ 70 B 帧 + 校验和(失配只推进 1 字节重同步)
├─ 双板载荷去重 → 只有变化的板才进管线
├─ IIR ÷2 滤波
├─ 消费回报率限速:每板独立令牌桶,默认 180 Hz(校准期不限速)
└─ TouchEngine:100 帧预热 → 100 帧基线 → 34 路判定 → 34-bit 掩码
└─ Trace 回调 ──► FrameLog.HardwareSample(读 FrameClock 打时间戳)
主线程 MelonEvents.OnUpdate → FrameClock.Advance(游戏时钟) → FrameLog.EndFrame()
Harmony postfix(NewTouchPanel.Execute, Priority.Last) → InputManager.SetNewTouchPanel- 串口按设备识别,不认 COM 名:VID/PID
0x0483:5740+ 复合设备MI_00接口号 → CDC0。 理由与上位机device_finder.py完全相同(Windows 每次插拔重新分配 COM 号,且四个口同名)。COMPort留空即自动;填了就固定该口,不回退到自动发现。IO 模式是例外:它按协议要求固定用 COM13/COM14。 - 抑制原版驱动 + 帧内最后写入:prefix 只跳过原版触摸驱动,postfix(
Priority.Last)才写掩码,并每帧自增 counter(SetNewTouchPanel是 counter 令牌语义,counter 不变会直接拒绝写入)。 - 断线重连:连续 3 次读取失败判离线 → 自动重连 → 重新预热/校准。
- 缺传感器强制置位:
raw == 0的通道判为SensorLost并强制按下,与固件、game_communicator三处语义一致。
5.7.4 配置与判定对齐
TenoDXIO.toml由 schema v15 驱动,共 22 个配置项(TouchTrigger18 +VirtualCard4), 单一真源在TenoDXIO.Core/TenoDXIOConfigSchema.cs; 含版本迁移链、校验器、保留注释的往返、ChangedKeys驱动的热重载。配置编辑器是独立的 PySide6 GUI(可打包为约 54 MB 免 Python 的 exe)。- 判定入口是转发层:逐帧调用共享核心
teno_touch_core.dll,阈值来自当前保留的旧版核心编译期默认值 (本端覆盖接口已提供,尚未接到 TOML)。三端接线一致性由核心自带的绑定比对验证。
⚠️ 扫描参数三份副本当前并不一致(2026-11-07 实测):
副本 A B C D E 固件 block_profiles12/50/2/2 10/30/2/2 12/30/8/2 8/35/2/2 10/40/2/2 上位机 config.yaml: zone_params12/50/2/2 12/30/2/2 12/30/8/2 12/35/2/2 12/40/2/2 本 mod CalibratedScanConfig12/50/2/2 10/30/2/2 12/30/8/2 8/35/2/2 10/40/2/2 上位机 B/D/E 的
res被改成了 12,与另外两处不同源 —— 这是本地调试态。必须先对齐再发版: 工作点不同而阈值不变,判定会明显跑偏(就是 2026-10-25 那次"A 区飘、TouchHost 却正常"的成因)。 mod 侧已经硬钉住:BuildScanConfigFrame()只发CalibratedScanConfig,配置文件的ScanConfig_*不再参与下发,偏离标定表会被ConfigValidators报错。 固件tenodata_config.c的block_profiles注释写明与TouchHost-kaede/config.yaml: zone_params保持一致,mod 的ConfigChecks.ScanCalibrationIsFrozen断言标定表与 schema 默认值一致。 改任一处必须同步另外两处(第 12 节维护约定)。 另注意通道映射不随 139 B 帧下发。Mod 与上位机一样,运行期优先经 Debug Port 读取主控 Flash 映射;HardwareMapping只是读不到主控时的回退值,读取成功后判定分组与 139 B 扫描档位都跟随主控映射。 这条规则对两条传输都成立:raw 在SerialService的端口解析回调里读,IO 在启动时ApplyIoDeviceMapping()读一次(IO 不打开触摸口,这是它唯一能读到的时机)。只有ForceConfigMapping = true才跳过读取、强制用 TOML 映射。 ⚠️ IO 模式的读取曾经缺失(deviceMapping恒为 null,TOML 映射一直生效),2026-11-07 修复。
5.7.5 框架层
框架提供四件套,让后续功能以"订阅"而不是"打补丁"的方式编写:HookManager(逐条失败隔离)、 EventBus(优先级、异常隔离、订阅令牌)、Logger、ActionScheduler(帧/秒延迟、重复、取消)。 已声明的接缝共三条:scene.process.added / scene.process.released(ProcessManager)、 frame.tick(挂 GameMainObject.Update 而不是 GameMain.Update——后者是输入轮询驱动点)。
场景标识只能用具体子类类型名:游戏的
ProcessType枚举只有 7 个值,MusicSelectProcess、GameProcess、ResultProcess这类界面进程一律是默认的General,不能当界面标识。
虚拟刷卡(2026-10-11 schema v13 重新引入):配置 [TenoDXIO.VirtualCard] 的 Key(默认 P)、 20 位 CardNumber 与可选 AimeId(0 = 按卡号派生)后,按键即把卡号交给游戏自己的入场流程。 实现走 Process.Entry.TryAime.TestAimeId,并在 ConfirmPlay.Init 前把测试路径的 4 参数补成 5 参数、 用配置卡号替换占位访问码——不伪造 AMDaemon。边界:游戏用 AimeId(UInt32)查找账号, 20 位 AccessCode 不能直接当 AimeId;只有卡号时无法还原真实账号,派生值只能保证同一卡号对应 同一份游戏数据。历史移除原因与游戏侧知识见 melonloader-mod/项目状态总表.md 第 4 章。
其中 segatools 的 DEVICE\aime.txt 不是 20 位访问码,而是卡片 10 字节 LUid 的十六进制文本; 真实 AimeId 由 AimeDB 服务器(本机 aime.mumur.net)下发。用 segatools 扫一次卡, Mod 日志会打印 Aime reader produced the real AimeId=...,把该值填进 [TenoDXIO.VirtualCard] AimeId 即可复用旧账号。
曾短暂试过、已回滚的两个 Aime 改动(同样是教训,见第 10 节第 28、29 条):
- 强行把
IsAliveAimeReader置回 true,以及 - 拦
AimeReaderManager.GetResult()把Error改写成None。
两者都让游戏在不具备条件时继续往前走,行为变得很怪,因此已 git revert 撤销。 不要通过伪造硬件状态来消除症状。
⚠️ 读卡器不要与 segatools 同时用:两者走不同链路(segatools 在 COM1 串口层、本 mod 在 AMDaemon→USB CDC2), 同时启用会重复产卡、登录状态可能错乱。触摸则不冲突。本机
segatools.ini里[aime] enable=0, 所以当前不存在该冲突;要用刷卡就把enable改成1。
5.7.6 与 AquaMai 共存(实测结论)
同一个 Harmony 目标会被两个 mod 抢(InputManager.SetNewTouchPanel、IO.Jvs+JvsSwitch.Execute、 Bd15070_4IF._setColor*、NewTouchPanel.Start/Execute)。实测推翻了一条广泛流传的假设:
prefix 返回
false不会阻止其后的任何 prefix——所有 prefix 永远都执行,false只跳过原方法本体, postfix 照跑并可读__runOriginal。胜负不在"谁先返回 false",而在谁最后写共享状态。
| 规则 | 方向 |
|---|---|
| prefix 优先级 | 数值越高越先执行(First=800 最先,Normal=400 默认) |
| postfix 优先级 | 相反——数值越低越最后生效 |
| 相同优先级 | 按注册顺序,与 owner id 无关 |
[HarmonyBefore]/[HarmonyAfter] | 能压过数值优先级,但仅在对方已加载时有效,否则静默失效 |
因此本 mod 采用"prefix 抑制 + postfix(Priority.Last)写入"。不可覆盖的情况:AquaMai 的 CollectWantedPatches 先执行 OnBeforeEnableCheck 再判断启用条件,所以 AquaMai.toml 里写 Disabled = true 也拦不住副作用;唯一办法是不安装。
5.7.7 参考区(只读,永不入库、永不链接)
melonloader-mod/docs/reference/ 下是只读参考资料,已被 melonloader-mod/.gitignore 排除: AquaMai-main/(别人的 mod,Hook 点地图)、Assembly-CSharp-files/(游戏反编译源码,事实的唯一来源)、 Libs-game-copy/(与游戏安装逐字节相同的 dll)、segatools/、contract-taxonomy/。 独立性是硬约束:产物零外部依赖,源码与产物中 Aqua 命中数为 0,参考代码绝不复制进源码目录。
6. 通道映射与扫描参数
6.1 在线映射格式
34 个物理通道 0..33,每项 = [1 B Mai2Touch 区域编号, 1 B block]:
- 区域编号:
A1..A8 = 0..7、B1..B8 = 8..15、C1..C2 = 16..17、D1..D8 = 18..25、E1..E8 = 26..33(即 A=0 / B=8 / C=16 / D=18 / E=26 基址 + 序号−1)。 - 每个物理通道必须且只能映射一个区域;多个通道可以映射到同一区域。
block(A/B/C/D/E)由所选区域自动派生,在线格式中保留用于显示与校验,不能独立配置——校验时强制block == block_for_region(region);有效扫描参数由region反推。- 输出按区域重新汇总(同区域多通道"最后写入者胜"),所以共享同一区域的任一通道按下 → 该区域保持置位。
- 通道 0–16 属第 0 片,17–33 属第 1 片(
channel / 17)。
当前接线:
| 片 | 通道 0..16 |
|---|---|
Touch0 0x08 | E4, D4, B3, A3, C1, E3, D3, B2, A2, E2, D2, B1, A1, E1, D1, B8, A8 |
Touch1 0x09 | E8, D8, B7, A7, C2, E7, D7, B6, A6, E6, D6, B5, A5, E5, D5, B4, A4 |
历史上
TenoDXController-H503/ref/Touch_Algorithm/里的映射是旧版(按区整片划分), 该参考目录已于 2026-10-03 随旧判定实现一并删除。 映射的权威来源是固件tenodata_config.c: default_mapping[34](决定 PSoC 扫描档位)。 上位机连接时会经 Debug Port 读取固件映射并覆盖本机的config.yaml: hardware_mapping, 所以上位机不再持有独立副本——yaml 里那份只是读不到主控时的回退。 游戏内 Mod 也遵循同一规则:打开触摸口时找到同一块板子的 Debug Port (CDC3),读取固件default_mapping[34]作为运行期映射;TenoDXIO.toml: HardwareMapping只是读取失败时的回退。 映射不在 139 B 帧里、从不下发;Mod 读取到的映射只用于它自己的判定分组、日志区块名和 139 B 扫描档位的选择,不会写回固件(见第 10 节第 17 条)。
6.2 扫描参数
固件本地档位(tenodata_config.c 的 block_profiles[5],顺序 {res, mod, sns, div}):
| 区 | res | mod | sns | div |
|---|---|---|---|---|
| A | 12 | 50 | 2 | 2 |
| B | 10 | 30 | 2 | 2 |
| C | 12 | 30 | 8 | 2 |
| D | 8 | 35 | 2 | 2 |
| E | 10 | 40 | 2 | 2 |
载荷格式:34×res + 34×mod + 34×sns + 34×div = 136 B;psoc_comm 按 device_index × 17 拆成两片各 68 B(片内布局 [0..16]=res, [17..33]=mod, [34..50]=sns, [51..67]=div)写入偏移 0x56,随后触发校准。分辨率合法值 6/8/9/10/11/12/13/14。
⚠️ 扫描参数有两个来源:① 固件本地
block_profiles(Mai2Touch 模式走这条);② 上位机 139 B0xAA 0x02帧(仅 raw 模式消费,写入host_config_override;AA 02会强制重配,AA 01才按内容去重)。 ✅ 两个来源的数值现在逐区相同(2026-10-25 复核,此前"只有 D 一致"的结论已经作废): 固件block_profiles与上位机config.yaml: zone_params都是 A 12/50/2/2、B 10/30/2/2、C 12/30/8/2、D 8/35/2/2、E 10/40/2/2, 固件源码注释也明确写了"保持与TouchHost-kaede/config.yaml的zone_params相同"。 所以切换模式不会再顺带改变电容工作点。第三份副本melonloader-mod的ScanConfig_A..E也已对齐到同一组值,详见第 5.7.4 节与第 10 节第 22 条。 ⚠️ 但"逐区相同"是人为约定、不是代码保证:三处各自独立声明,任一处单独改动仍会重新分叉。 ⚠️ 第 3、4 行相对 PSoC 硬件命名是互换的:EZI2C 偏移120(文档记作 "Sense Clock Divider"、两端都叫sns)实际进的是CapSense_1_SetSenseClkDivider()→CapSense_1_senseClkDividerVal;偏移137("Modulator Clock Divider"、叫div)进的是CapSense_1_SetModulatorClkDivider()→CapSense_1_sampleClkDividerVal(即 modulator sample clock)。调参填值时按"第 3 行 = 物理 Sense 时钟分频、第 4 行 = 物理 modulator sample 时钟分频"理解即可,两端语义一致,只是名字反了。
🔁 二次下发(重要):固件只在
STATE_WAIT_HOST_CONFIG报0x01(进入条件!mai2touch && !host_config_override_valid),但上位机现在会在串口打开并采用主控映射后主动下发一次 139 B 参数帧。 因此"改 yaml → 重启上位机"会把新参数写入设备并触发一次完整重配。连接后主动下发成功会刷新 1 s 限流时间,避免紧接着收到0x01时重复发送。 当前入口:① 串口打开并采用主控映射后主动下发;② 上位机「重新加载配置」按钮(热重载 yaml,串口已打开则重新下发;未打开则暂存到下次开串口补发);③ Mod / 映射工具在连接或设备上报0x01时发送AA 02。Magic0x05 FORCE_RESEND已删除。
6.3 换映射表/模式的行为
固件会:立即清除旧触摸输出 → 安全等待当前 I2C 操作结束(REINIT_DRAIN)→ 按完整 init 流程重新探测 → 0xAD 软复位 → 写扫描配置 → 硬件校准 → 重置软件基线。 raw 模式下的新基线采集延后到切回 Mai2Touch 时开始。
6.4 CDC0 自动切换的代价与边界
- 切到 Mai2Touch 时不动 PSoC:游戏此时正处在
{HALT}之后的配置阶段,中间插一次 1~2 秒的软复位+校准会直接打断握手。 - 切回 raw 时会重走初始化(
tenodata_request_reconfigure()),重新探测/校准/上报0x01,耗时约 1~2 秒;这段时间触摸输出中断。 - 切换时清除
host_config_override:避免把 raw 模式收到的上位机参数带到别的模式;本地档位与上位机档位可能不同,不能假设两套数值等价。 - 识别器由当前活跃的消费者喂字节(raw 侧
consume_host_config_frame()/ Mai2Touch 侧mai2touch_process_rx()),切换在touch_mode_task()里执行,不在读字节当刻执行 —— 所以两个消费者都用touch_mode_pending_to_*()决定"是否立刻停手",避免把对方命令的剩余字节吃掉。 - 本机单元测试
tests/native_mode_test.c(tests/build_native_test.ps1,MinGW gcc)直接编译真实的cdc_manager.c/touch_mode.c/mai2touch.c,覆盖 11 个场景:完整握手、回显(含控制字符值)、{STAT}立即推流空白帧、{HALT}静默、双向切换、校验和0x7B不误切、非法命令体不切、{STAT}/{LAr2}触发切换后补喂、强制模式优先、半截帧头。
7. 硬件与 USB 速查
| 项 | 值 |
|---|---|
| MCU | STM32H503CBT6 @ 250 MHz |
| I2C1 | PB7 SDA / PB8 SCL,从机 0x08 / 0x09,Timing = 0x10C043E5,模拟滤波开 |
| USART2 | PB4 TX / PB5 RX,115200 8N1(PN532) |
| USART1 | PA9 TX / PA10 RX,调试串口,默认无输出(PN532_UART_DEBUG_ENABLED = 0) |
| 状态灯 | PC13,高电平点亮 |
| 按键 | BTN0..BTN7 = PA0..PA7(主键);BTN8..BTN11 = PB0/PB1/PB2/PB10(副键,键值可配) |
| 灯珠 | WS2812B on TIM3_CH3 + GPDMA1_Channel0(PB6);T0H 400 ns / T1H 800 ns / 周期 1250 ns;LED_PER_BIT 1–4,默认 2 → 最多 32 像素 |
| USB | VID 0x0483 / PID 0x5740 / bcdUSB 0x0200 / 产品名 TenoDX Controller |
CDC 端口用途(不要混用):
| 端口 | 接口号 | 端点 | 用途 |
|---|---|---|---|
| CDC0 | 0/1 | 0x01 / 0x81 | Touch(raw 70 B / Mai2Touch 9 B,互斥路由) |
| CDC1 | 2/3 | 0x02 / 0x82 | Mai2LED 协议 |
| CDC2 | 4/5 | 0x03 / 0x83 | Aime 主机协议(不再响应 Magic) |
| CDC3 | 6/7 | 0x04 / 0x84 | Magic 配置(只走二进制协议,不输出文本日志);上位机按需读取 Cp / 补偿 IDAC(Magic 0x06)也走这里,读完立即断开 |
| HID0 | 8 | 0x85 | 12KRO 键盘 |
状态灯语义:初始化常亮 ≥1 s;运行每秒闪一次;配置写入快闪 3 次;保存失败/致命错误常亮。
PC 侧运行环境:
| 项 | TouchHost-kaede/ | melonloader-mod/ |
|---|---|---|
| 运行时 | Python 3 + PyQt5 | 游戏内 .NET(Unity 2018.4 + MelonLoader 0.6.4,Runtime Type = net35) |
| 产物目标 | 无(脚本)/ PyInstaller 单 exe | net472(不能是 netstandard,net35 无 netstandard 门面) |
| 串口波特率 | 115200(CDC 下波特率无实际意义) | 115200 |
| 连接方式 | 触摸 CDC0 + 游戏串口(默认 COM17) | 只连 CDC0,不占游戏串口 |
| 设备识别 | VID/PID 0x0483:5740 + 注册表 MI_XX 接口号 | 同一规则,MI_00 = CDC0 |
8. 构建与运行
全量一键构建(发布用):根目录 build-all.ps1 按依赖顺序构建全部产物,统一收进 putout/, 文件名带 -v3.0-<时间戳>,并生成 manifest.json 与 PUTOUT-README.md。顺序固定:
text
touch_core → TenoDXController-H503 → tools/set_com_ports → tools/flash_firmware
→ tools/TenoDXConfigurationTool → tools/TenoDXTouchMappingTool → TouchHost-kaede
→ melonloader-modtouch_core必须最先:其余各端都内嵌/链接它,判定只有这一份实现。melonloader-mod必须最后:它内嵌touch_core的二进制。
powershell
powershell -ExecutionPolicy Bypass -File .\build-all.ps1 # Release 全量
powershell -ExecutionPolicy Bypass -File .\build-all.ps1 -Configuration Debug
powershell -ExecutionPolicy Bypass -File .\build-all.ps1 -Skip melonloader-mod
powershell -ExecutionPolicy Bypass -File .\build-all.ps1 -DryRun # 只看计划版本规则(强制):根 version.json 是唯一真源,参与合成构建的每个子项目目录各有一份 VERSION, 全部必须等于 version.json 里的 version。版本必须一致才能混用,因为固件、上位机、游戏内 Mod 各自内嵌同一份判定核心,版本不一致会静默错判。build-all.ps1 在开建前逐个校验,不一致直接拒绝构建 (当前基线:v3.0 / 3.0.0)。
例外:
tools/h503_dfu_flasher/独立版本(当前1.0.1)。它是纯粹的刷写小工具,既不内嵌判定核心, 也不进入putout/的发布包,所以不在build-all.ps1的版本锁与构建步骤里。它的VERSION只对自己的 仓库负责——改判定相关的东西时不需要跟着动它。
通用清理:根目录 clean-all.ps1 只删可再生的构建产物、缓存与日志,绝不碰源码、文档、VERSION 与 .git。
powershell
powershell -ExecutionPolicy Bypass -File .\clean-all.ps1 -All -DryRun # 先看要删什么
powershell -ExecutionPolicy Bypass -File .\clean-all.ps1 -All # 缓存+日志+dist+putout
powershell -ExecutionPolicy Bypass -File .\clean-all.ps1 -Cache -Logs
powershell -ExecutionPolicy Bypass -File .\clean-all.ps1 -All -Venv # 连虚拟环境一起删(下次要重装依赖)⚠️ 沙箱/无窗口环境注意:固件 CMake 配置会卡在编译器 ABI 探测,配置工具的 Tk 单元测试会超时—— 两者都需要在沙箱外运行。
build-all.ps1的每一步独立失败不阻断后续步骤,失败步骤仍会收集已产出的产物。
产物清单(每次构建都会写进 putout/PUTOUT-README.md 与 manifest.json):
| # | 步骤 | 主要产物 | 用途 |
|---|---|---|---|
| 1 | touch_core | teno_touch_core.dll、teno_touch_core_cli.exe、.o | 判定唯一实现,供其余各端内嵌/链接 |
| 2 | TenoDXController-H503 | maimai_controller_H503.hex / .bin / .elf / .map | STM32 固件(烧录 / 调试) |
| 3 | tools/set_com_ports | TenoDXSetComPorts.exe | 固定 4 个 CDC 到目标 COM 口 |
| 4 | tools/flash_firmware | TenoDXFirmwareFlasher.exe | ST-Link 刷固件 |
| 5 | tools/TenoDXConfigurationTool | TenoDXConfigurationTool / ControllerTest / Config / DFU.exe + release/ | 配置、综合测试、DFU 刷写 |
| 6 | tools/TenoDXTouchMappingTool | TenoDXTouchMappingTool.exe | 触摸区块预览 + RAW 映射校准;校准无误自动写入主控映射(单文件,素材内嵌) |
| 7 | TouchHost-kaede | CapSenseOscilloscope.exe + config.yaml + teno_touch_core.dll | PC 上位机(波形与判定调试);三个文件必须同目录 |
| 8 | melonloader-mod | TenoDXIO.dll(+ TenoDXIO.toml、schema) | 游戏内 Mod,只放 TenoDXIO.dll 进 Mods/ |
上位机产物默认跑生产判定,但允许显式启用测试判定:
judge_py.py由build.ps1用--hidden-import judge_py打进产物(它被importlib动态导入,静态分析看不到), 默认仍是dll;把config.yaml的judge_backend改成python即可在 exe 里启用, 此时界面标签(带· exe)、窗口标题[测试判定]与 CSV 头# judge=都会标记。 某次构建没带该模块时会自动回落dll并打印原因。 分发给他人时仍是三件套(exe +config.yaml+teno_touch_core.dll)。
一键构建(根目录脚本,推荐):build.ps1 是编排脚本,本身不含构建细节,逐个调用子项目自己的构建脚本(所以子项目仍可单独构建,逻辑只有一份):
powershell
powershell -ExecutionPolicy Bypass -File .\build.ps1 # 全部工具 + Release 固件
powershell -ExecutionPolicy Bypass -File .\build.ps1 -Configuration Debug -Clean
powershell -ExecutionPolicy Bypass -File .\build.ps1 -Tool set_com_ports # 只构建某个工具
powershell -ExecutionPolicy Bypass -File .\build.ps1 -SkipTools -Flash # 只构建固件并烧录| 参数 | 说明 |
|---|---|
-Configuration Release|Debug | 固件构建类型(默认 Release) |
-Clean / -Flash / -NoStamp / -Jobs N | 透传给固件脚本 |
-Tool <目录名> | 只构建 tools/<目录名>(默认跳过固件,加 -WithFirmware 可一起建) |
-SkipTools / -SkipFirmware | 分别跳过工具 / 固件 |
-BuildDir <相对路径> | 固件构建目录,默认 build\firmware;Debug 与 Release 建议用不同目录(同一目录会被互相覆盖),例如 -BuildDir build\firmware-debug |
- 工具:有
build.ps1的调用它自己的脚本;没有的 Python 工具用内置 PyInstaller 流程打包到该工具的dist/。 - 固件:调用
TenoDXController-H503/build.ps1(自动探测 STM32CubeCLT / CubeIDE 工具链)。 - 固件本机单元测试:
powershell -ExecutionPolicy Bypass -File .\tests\build_native_test.ps1(在TenoDXController-H503/下执行,用 MinGW gcc,不需要板子/工具链)。 - ⚠️
.ps1的编码要求:Windows PowerShell 5.1 在没有 UTF-8 BOM 时按 ANSI 读取.ps1, 中文会在解析阶段就乱码(甚至报语法错误)。所以:- 脚本里有中文 → 必须带 UTF-8 BOM(如
TenoDXController-H503/build.ps1); - 或者干脆写成纯 ASCII(如根目录
build.ps1、tools/set_com_ports/build.ps1)。
- 脚本里有中文 → 必须带 UTF-8 BOM(如
PSoC:PSoC Creator 打开 CY8C4245PVI-482-Touch0.cyprj / Touch1.cyprj → Build → Program。注意 CapSense 需勾选 Enable BIST(否则读 Cp 卡死)。不要修改该目录下的文件。
STM32(CubeIDE 推荐):File > Import > General > Existing Projects into Workspace,选 TenoDXController-H503/,不要勾选 Copy projects into workspace。构建后除原始 BIN/HEX 外还会生成带本地时间戳的副本 maimai_controller_H503_YYYYMMDD_HHMMSS.bin/.hex(由 build.ps1 自己生成;原 tools/stamp_firmware.ps1 已随 tools/ 一并删除)。
STM32(CMake):
powershell
cmake -S TenoDXController-H503 -B TenoDXController-H503/build/firmware `
-DCMAKE_BUILD_TYPE=Release `
-DCMAKE_TOOLCHAIN_FILE=cmake/arm-none-eabi.cmake
cmake --build TenoDXController-H503/build/firmware --parallel外设/引脚以 maimai_controller_H503.ioc 为准。CI:推送到 main / 面向 main 的 PR / 手动触发 Build firmware 工作流,用 Arm GNU Toolchain 13.3.Rel1 构建 Release,产物含带 UTC 时间戳的 BIN/HEX、ELF、MAP、空间占用报告与提交号(保留 30 天)。
⚠️ FLASH 只有 120 K(
.ld里LENGTH = 120K,即 128 K 减去最后 8 K 配置区,上限 122880 B)。 实测:Release-Os -g0→ 66468 B(余 56 K);Debug-Og -g3→ 79020 B(余 43 K); Debug-O0 -g3→ 123872 B,超出 1616 B,链接直接失败。 所以 CubeIDE 的 Debug 配置不要用-O0:把工程属性 → C/C++ Build → Settings → Debug → Optimization 设为-Og(推荐,DWARF 仍然真实、可单步)或-Os。 CMake 侧的 Debug 默认已是-Og(可用-DFIRMWARE_DEBUG_OPT=-O0覆盖,但会超出 FLASH)。 加新功能时注意余量:Debug 只剩约 43 K。
配置工具:
powershell
python -m pip install -r tools/TenoDXConfigurationTool/requirements.txt
python tools/TenoDXConfigurationTool/main.py连接时选 TenoDX Debug Port。常用命令:
text
touch get | touch set 0 A1 | touch set-many 2 A2 3 A2 12 B4 | touch mode mai2touch | touch save
touch status | touch watch
keyboard layout 2p | keyboard set 11 enter | keyboard set-all 3 keypad_multiply 8 9 | keyboard savetouch set / set-many 只改 RAM,需 touch save 才持久化。 上位机:
powershell
python -m pip install -r TouchHost-kaede/requirements.txt
python TouchHost-kaede/main.py判定内核的查找顺序(
TouchHost-kaede):绑定模块touch_core/host/teno_touch_core.py按 ① 环境变量TENO_TOUCH_CORE_DLL(显式覆盖)→ ② 程序自己所在目录下的teno_touch_core.dll(exe 取 exe 目录,源码取入口脚本目录)→ ③ 写死的相对目录touch_core/build/teno_touch_core.dll(开发构建产物)依次查找;都没有就抛FileNotFoundError并列出查找过哪些位置。不搜索系统 PATH,也不依赖当前工作目录。 源码运行与 exe 运行都不写死"项目里的固定位置",区别只在第 ② 步的基准目录。
打包:TouchHost-kaede/build.ps1(py -3.14 -m PyInstaller --windowed --paths ../touch_core/host --hidden-import teno_touch_core --hidden-import judge_py --add-data "config.yaml;." --exclude-module … --name CapSenseOscilloscope main.py;--onefile/--onedir 由 -Onedir 开关决定;会先删除并重新生成 CapSenseOscilloscope.spec,手改 spec 会被覆盖)。产物 TouchHost-kaede/dist/CapSenseOscilloscope.exe + 同目录 config.yaml,分发时必须同目录。
可选开关(都是提速/便捷用途):-Onedir(输出文件夹而非单文件,构建与启动都更快)、-SkipDeps(跳过依赖检查;requirements.txt 没变时脚本本来就会自动跳过 pip)、-SkipCore(不重建 touch_core)、-Clean(清 PyInstaller 缓存)、-DistRoot <路径>(换产物目录,用于并行/多版本构建)。脚本带阶段计时,慢在哪一眼能看出来。dist/ 里 build-all 留下的 package-* / previous-dist-* 目录不会被删——脚本只删自己产出的 exe。
游戏内 Mod(melonloader-mod/,由根 build-all.ps1 编排):
powershell
cd melonloader-mod
# 全链验证:构建全部层 + 离线自测(含内嵌资源解压校验与游戏程序集接缝核对)
powershell.exe -NoProfile -ExecutionPolicy Bypass -File .\verify.ps1 -Configuration Release
# 构建 + 安装到游戏
.\build.ps1 -GameModsDirectory "E:\SDEZ1.66\Package\Package\Mods"melonloader-mod/由根目录build-all.ps1第 8 步编排;单独构建时使用melonloader-mod/build.ps1。- 脚本默认带离线参数(
-m:1 -nodeReuse:false -p:NuGetAudit=false -p:UseSharedCompilation=false -p:BuildInParallel=false):TreatWarningsAsErrors会把"连不上 nuget.org 做漏洞审计"变成构建失败,受限机器也可能拒绝共享编译器要的命名管道。 网络可用时用-ExtraBuildArgs ''还原默认行为。 - 构建需要 .NET SDK 8+,并需要已安装的游戏程序集(路径在
Directory.Build.props,可用-p:GameManagedRoot=...覆盖)。 - 验证分六组:event bus / scheduler / hook manager / frame tick / packaging / game seams。后两组专门盯住构建期看不见的失败: 内嵌资源在
PrepareResources阶段就定稿,之后动态加会被静默忽略(构建成功但 dll 里没有资源),所以检查是把字节解压出来验证它是合法程序集;接缝目标则对着已安装的游戏程序集元数据核对,避免游戏更新后补丁静默失效。 - 判定算法不在本仓库实现:改动一律走根目录
touch_core/,并跑它自带的一键验证 (含三端一致性比对)。判定细节以该核心的代码为准。 - 产物统一落在
melonloader-mod/output/,只有TenoDXIO.dll一个文件需要放进游戏的Mods(第 5.7.1 节)。TenoDXIO/不再有独立入口:它只保留build/verify.ps1用于校验内核层(构建 + 自测 + schema 导出 + 编辑器自测)。
9. 目录速查
text
synchronous/ ← git 仓库(只含本文件、docs/、tools/README.md、build.ps1)
├─ build.ps1 一键构建:全部工具 + 固件(编排,调用各子项目自己的 build.ps1)
├─ tools/ 独立小工具(每个工具各自一个仓库)
│ ├─ README.md 工具索引(属于根仓库)
│ ├─ flash_firmware/ ← git 仓库(独立):ST-Link 固件刷写工具
│ ├─ set_com_ports/ ← git 仓库(独立)
│ 串口自动配置:固定 Touch=3 / LED=21 / Aime=1 / Debug=25
│ 双击 dist/TenoDXSetComPorts.exe 即用(需管理员权限)
│ ├─ TenoDXTouchMappingTool/ ← git 仓库(独立):触摸映射预览与校准工具
│ │ 一键打包:.\TenoDXTouchMappingTool\build.ps1(单文件 exe)
│ └─ TenoDXConfigurationTool/ ← git 仓库(独立)
│ 配置、综合测试与 DFU 刷写工具
├─ docs/
│ ├─ Mai2TouchREADME.md Mai2Touch 串口协议(逐行对照游戏源码;与 melonloader-mod/ 下的同名文件内容一致)
│ └─ TENO-TOUCH-CORE-DISTRIBUTION.md 判定单一真源与三端分发约束
├─ PSoC Creator/ 两片 PSoC Creator 工程(不在 git 内,禁止修改)
│ ├─ CY8C4245PVI-482-Touch0.cydsn/ main.c = 采集/EZI2C/握手
│ └─ CY8C4245PVI-482-Touch1.cydsn/ main.c 同上(引脚方向相反);main1.c 为遗留副本
├─ TenoDXController-H503/ STM32 固件(独立仓库)
│ ├─ App/{touch,led,aime,config,kb,button,status}/
│ ├─ Core/ CubeMX 生成的外设/HAL/中断/USB 描述符
│ ├─ Drivers/ tinyusb/ HAL + CMSIS + TinyUSB
│ ├─ tests/ 宿主机回归与回放驱动(MinGW,无板可跑)
│ ├─ ref/ 协议/原理图参考(旧的算法移植副本已删除)
│ ├─ README.md 固件功能与协议详述
│ └─ Touch_Migration_Notes.md touch 模块迁移记录(节点 15 起已部分作废)
├─ touch_core/ 判定唯一真源(根仓库追踪):三端共用,含宿主绑定与验证
├─ TouchHost-kaede/ 上位机(独立仓库):波形调试 / 日志 / 游戏直出,kaede 触屏适配
│ ├─ main.py / main_window.py / signal_pipeline.py / serial_reader.py
│ ├─ csv_logger.py / game_communicator.py / device_finder.py / magic_client.py
│ ├─ config.py + config.yaml 统一配置表(含 B/C/D/E 判定阈值覆盖 judge_overrides)
│ ├─ docs/capsense_data_flow_analysis.md 数据流分析(调判定参数时看数据怎么传)
│ ├─ tools/ probe_device_rate.py / probe_host_rate.py / magic_probe.py
│ ├─ replay-data/ 固定回放数据与 manifest(三端对齐用)
│ └─ algorithm_changelog.md 算法/架构变更日志(最新在最前)
│ 判定走共享核心;本地判定实现已移除,只留绑定与适配
├─ melonloader-mod/ 游戏内 Mod(独立仓库;根仓库整体忽略)
│ ├─ 项目状态总表.md 自包含全局状态:架构、产物、风险、下一步(**先读这份**)
│ ├─ README.md 工作区总览与产物说明
│ ├─ 需求.md 需求基线(读取侧 / 控制侧 / 框架侧三张清单)
│ ├─ Mai2TouchREADME.md 与 docs/ 下同名文件内容一致(SHA256 相同)
│ ├─ TenoMod/ ★ 框架 + 合并产物(实际部署 `TenoDXIO.dll`)
│ │ ├─ DESIGN.md 全游戏可 hook 点勘察稿(含反编译行号)
│ │ ├─ src/TenoMod.{Contracts,Runtime,Game,Host,DevKit}/
│ │ └─ build/{build,verify}.ps1
│ ├─ TenoDXIO/ ★ 触摸功能本体(无独立入口,被 TenoMod 内嵌)
│ │ ├─ src/TenoDXIO.{Core,Payload,DevKit}/
│ │ ├─ tools/config-editor/ PySide6 配置编辑器(可打包 exe)
│ │ └─ PROJECT-STATE.md 施工记录与全部踩坑
│ ├─ output/ ★ melonloader-mod 的全部构建产物(不入库):TenoDXIO.dll、编辑器 exe 等
│ └─ docs/reference/ ★ 只读参考,**永不入库、永不链接**
└─ 项目总览.md 本文件10. 已知约定 / 坑
- PSoC 是纯从机:任何扫描参数变更都要走完整
0xAD软复位 + 写配置 + 校准流程,不能"热改"。 - EZI2C 偏移与状态码是跨芯片契约:
0x00/0x56(86)、0x00/0x01/0x02/0x11-0x15/0xAD、每片 17 通道顺序、136 B 参数顺序——任一处不一致都会静默错位。 - 状态帧必须带递增序号:否则上位机去重会吞掉
0x02→0x01的状态变化。 - 缺失设备强制置 1:刻意的"宁可误触不可漏触"策略,固件与
game_communicator两端语义必须一致。 - 两片固件互为镜像:改
Touch0/main.c必须同步改Touch1/main.c(仅握手引脚名不同)。 - 判定参数只有一处是真源:判定逻辑与参数统一由根目录
touch_core/提供。当前为兼容验证固定在该项目的旧版提交17b7c0b,判定阈值按旧版默认值保留。扫描档位(res/mod/sns/div)不属于核心,由各端zone_params/block_profiles/ScanConfig_*提供(第 6.2 节)。B/C/D/E 调参由各端适配层兼容:host 通过 ABI 兼容路径或原生 API 写入judge_overrides,Mod 绑定层支持同样的兼容写入;固件不声明运行期调参能力。改判定前先读docs/TENO-TOUCH-CORE-DISTRIBUTION.md。 - 文档基线在 2026-10-02 已复核对齐:
docs/capsense_data_flow_analysis.md的阈值、固件README.md的 VID/PID 与 Flash 布局都已改正。后续再改结构层逻辑时,按第 12 节回写,避免重新漂移。 ref/下的映射表是旧版(按区整片分片),当前实现是交错分片。- 日志回放注意事项:帧间距不均匀(非零间隔中位 18–28 ms、最大 50 ms),按帧计数的规则会滞后一帧;用内部真值列而非
Raw/Diff反算。 - raw 模式下的强制掩码:
touch_pipeline只在 Mai2Touch 模式被喂数据,所以 raw 模式下touch_pipeline_is_ready()恒 false、recovery_forced_mask不会在 RUNNING 中被清除;长期影响未实测。 - 主循环长阻塞点:写 PSoC 配置含
HAL_Delay(5),Flash SAVE 走HAL_FLASH_Unlock/Program/Lock(擦扇区),都会打断 8 ms I2C 节奏与 USB/PN532 时延;30 ms 事务超时就是给这种抖动留的余量。 - USB 端口职责固定:改接口号要同时改
tusb_config.h(CFG_TUD_ENDPPOINT_MAX/CFG_TUD_INTERFACE_MAX/CONFIG_TOTAL_LEN)、usb_descriptors.c与各模块常量(DEBUG_CDC_ITF=3、AIME_CDC_ITF=2、MAI2LED_APP_CDC_ITF=1)。 - 容量断言不能破:设备数 2、每片 17 通道、mapping 2 B、touch payload 70 B、keyboard 8 B、LED 20 B、
WS2812B_PIXEL_MAX == MAI2LED_APP_MAX_LED_TOTAL == 32。 - 风险点:
mai2led_app.packet_write()未检查tud_cdc_n_write_available(存在静默丢字节风险);buffer_3s只维护不绘制(无消费者)。上位机的拔线/掉线自动重连已闭环(read_packets捕获串口异常并 close,update_data复查后进入断开流程;插回自动重连,重连会重新采集判定基线)。 status_led_app_init()早于SystemClock_Config():其时基基于复位后的 HSI,改时钟配置时注意这一处。- 扫描参数会在连接后主动重发并强制重配:固件只在
STATE_WAIT_HOST_CONFIG报0x01,而该状态要求"没有上位机参数覆盖"。新版 TouchHost / Mod / 映射工具统一发送AA 02,因此连接或显式应用参数时即使内容相同也会重配 PSoC 并重建基线;AA 01仅保留幂等兼容。另可走「重新加载配置」按钮,详见第 6.2 节。 - 映射只有固件那份是权威,上位机与 Mod 都读取它:上位机连接时经 Debug Port 读固件
tenodata_config.c: default_mapping[34]并覆盖本机config.yaml: hardware_mapping(yaml 仅作回退)。Mod 打开触摸口时也读取同一块板子 CDC3 上的映射;读取成功就用固件映射重建判定分组与 139 B 扫描档位,读取失败才使用TenoDXIO.toml: HardwareMapping。映射从不下发/写回固件,共享判定核心的参数指纹也刻意不含映射。 - EZI2C 第 3/4 行命名与实际硬件相反:
sns(偏移 120)实际是 modulator sample clock,div(偏移 137)实际是 sense clock。两端语义一致,但按名字调参极易填错位置,详见第 6.2 节。 - 重新下发会打断触摸:完整重配(软复位 → 写配置 → 校准 → 重建基线)期间两片 PSoC 约 1~2 s 不参与判定,且缺失/恢复设备区域会被强制置 1;上位机判定基线仍按旧参数采集,建议重开后重新采集基线。
- Cp / 补偿 IDAC 是静态量且依赖 BIST:
CapSense_1_GetSensorCp()(状态0x13)与 SAR 校准写出的补偿 IDAC(状态0x14)都只在配配置时产生,运行中不变、软复位后清零; 且需要 PSoC Creator 勾选 Enable BIST,否则0x13这一步会卡死。刚上电/刚重配就读会拿到 0。 - 帧率:每片新数据率已能跑满 180 Hz(此条已于 2026-09-25 订正,旧结论作废)。
- 旧实测(已作废):固件 CDC 推流 199.9 Hz,但每片载荷真正变化仅 62.3 Hz, 当时判定"瓶颈在 PSoC 扫描链、每片约 16 ms 才产出一份新 raw",并推出"要上 180 Hz/Dev 需把每片扫描压到约 5.5 ms"。
- 当前实测:每片新数据率已能跑满消费上限 180 Hz(
config.yaml: report_rate_hz,见第 5.3 节), PSoC 扫描链不再是瓶颈,上述"必须压到 5.5 ms"的结论随之作废。 - 调扫描参数或改限速后,仍应重新测三个数(设备投递率 / 新数据率 / 主机消费率)再下结论—— 诊断工具:
TouchHost-kaede/tools/probe_device_rate.py与probe_host_rate.py(只读,见TouchHost-kaede/tools/README.md)。 - ⚠️ 遗留待确认:各区判定阈值里的导数项是按"每帧"定义的。这套值当年是在 250 Hz(约 4 ms/帧)下标的,而设备长期只产出 62.3 Hz/片(约 16 ms/帧), 那时每帧导数被放大约 4 倍,阈值实际远比设计值敏感; 现在跑到 180 Hz(约 5.5 ms/帧)后,工作点反而移回了接近 250 Hz 标定条件(仅余约 1.4 倍)。 这既解释了此前可能存在的过于灵敏,也意味着灵敏度需要重新实测确认 (背景见
melonloader-mod/TenoDXIO/PROJECT-STATE.md6.5 第 4 条)。
- 扫描参数仍是三份独立副本,但当前数值已统一:固件
block_profiles(第 6.2 节的表)、 上位机config.yaml: zone_params、游戏内 ModTenoDXIO.ini: ScanConfig_A..E三处逐区逐项相同(A 12/50/2/2、B 10/30/2/2、C 12/30/8/2、D 8/35/2/2、E 10/40/2/2), 此前"只有 D 一致"的差异已消除(2026-10-25 复核)。 但相同是人为约定、不是代码保证:三处各自独立声明,没有构建期校验把三份拉到一起。 Mod 与上位机都走 raw 模式、都会发 139 B 帧覆盖固件,固件在 Mai2Touch 模式用自己的档位, 所以改扫描参数时必须同步改三处,并先确认这次运行到底由哪条链路下发(第 5.7.4、6.2 节)。 - 游戏内 Mod 的实机验证现状(2026-10-02 复核):离线自测全绿(TenoMod 183 项 + 内核 508 项 + 编辑器 67 项), 实机也已跑起来并验证了单文件加载、框架接缝与配置迁移。 唯一仍未验证的是触摸输入——控制器从未连上,日志里稳定出现
found the controller on COM3 (CDC0)紧接着cannot open COM3: The port does not exist.: 按 USB 设备找到了控制器,但注册表里的端口号是陈旧条目(设备重插后 Windows 重编了号), 需要插上板子后跑一次tools/set_com_ports重建映射。 启动后应看到TenoDXIO 2.0.0 starting; configuration: …、[TenoMod] [framework] started: 3/3 hooks applied、first frame tick observed三行——最后一行是刻意加的,因为没有它, "框架加载成功但什么都没发生"与"接缝根本没装上"看起来完全一样。 melonloader-mod/是独立仓库,根仓库看不到它的历史:2026-09-25 已git init(分支main),docs/reference/、output/、bin/obj/ 等按.gitignore排除。跨子项目看版本仍需分别进各自仓库看git log;根仓库整体忽略该目录,不会出现子模块登记或嵌套提交。- Mod 与 AquaMai 的冲突只能靠不装来解决:AquaMai 的
CollectWantedPatches先执行OnBeforeEnableCheck再判断启用条件,所以AquaMai.toml里写Disabled = true也拦不住串口线程与 provider 注册(详见第 5.7.6 节)。另外tenodx.dll与TenoDXIO.dll只能装一个,同时装会产生重复补丁。 - 不要用
[ModuleInitializer]:Unity 的 Mono 不执行模块初始化器(已在旧项目实测失败两次), 程序集解析器必须装在类型静态构造函数里。这是melonloader-mod/单文件打包能成立的硬前提。 - Mod 的串口失败也记成
[Info]:SerialService的日志回调接的是message => logger.Info(message), 所以"串口打不开"这种真实故障与正常信息同级。失败还会被RepeatSuppressor压成"每个原因只报一次" (失败原因或端口号变化才重新报告,连接成功会清空历史),排查时别只看级别,也要看是否两秒内反复重试。 - 不要伪造硬件状态来消除症状(已回滚的两个改动):没有读卡器时游戏会认为读卡器故障, 表现为「Aime 读卡器错误」提示、入场画面「参考卡号」按钮置灰、
TryAime的刷卡流程停在Nop, 并陷入一个错误屏循环(ErrorAime屏 3 秒后自动关闭并重建入场画面)。 曾试过两个"修补":强行把OperationManager.IsAliveAimeReader置回 true, 以及拦AimeReaderManager.GetResult()把Error改写成None。 两者都让游戏在不具备条件时继续往前走,整体行为变得很怪,已git revert撤销。 这类字段是硬件状态的如实反映,游戏用它决定走哪条路径(例如IsAimeLoginDisable()), 改它等于欺骗整个流程。现在若要做虚拟刷卡,走游戏自己的TryAime.TestAimeId门 (见 5.7.5);不要伪造AMDaemon的读卡器状态。 IsAliveAimeReader是字段不是属性:AquaMai 的IgnoreAimeServerError能 patchIsAliveAimeServer的 getter 直接返回 true,但同样的手法对IsAliveAimeReader无效—— 它是 public 字段,没有 getter 可挂。这条留作排查 Aime 问题时的参考事实: 先确认要处理的是字段还是属性,两者的修法完全不同(即便本项目已决定不碰它)。- 上位机已不做 raw 对齐:主机侧按区块类型缩放 raw 的
raw_alignment已在 v3.0 随本地判定实现一并移除, 现在 CSV 的Raw就是 IIR 滤波后的 CDC0 原始计数、SetupRaw是它的启动校准均值,没有任何主机侧增益/偏移。 追查硬件未滤波原始值时仍要回看固件 CDC0 的 raw(iir_factor=1.0可关掉唯一的主机侧处理)。 - 上位机的
judge_backend: python只是调试开关,不是第二条实现路线:它走judge_py.py, B/C/D/E 与核心同源、A 区是本机实验算法(当前为简化阈值比较,与 dll 口径不同)。 源码与 exe 都可显式启用(该模块会随产物打包;某次构建没带时自动回落dll并打印原因), 但启用后必须看得见:界面「判定后端」标签(exe 额外带· exe)、窗口标题[测试判定]、 CSV 头# judge=三处同时标记。 用途是在实机波形/CSV 上快速试判定改动,验证通过后必须回touch_core/src重写 + 递增算法版本 + 重基准化(见docs/TENO-TOUCH-CORE-DISTRIBUTION.md第 4 节)。 ⚠️ 禁止用该后端的 CSV 日志去跟 Mod / 固件做三端对账(A 区口径本就不同, 默认值一旦被改动 B/C/D/E 也会分叉)——上面三处标记就是为此设的。 改完判定想立刻知道"哪一帧开始不一样",用TouchHost-kaede/tools/replay_judge.py --synthetic(逐帧对拍:B/C/D/E 分叉=抄错或参数不同源,A 区分叉=预期;参数默认值不一致会在输出里点名)。
11. 文档索引
| 文档 | 位置 | 读它的场景 |
|---|---|---|
| 本文 | 项目总览.md | 第一次接触项目 / 找东西在哪 |
| 固件功能与协议 | TenoDXController-H503/README.md | 改 STM32、查 Magic/按键/灯光/USB |
| Touch 架构迁移记录 | TenoDXController-H503/Touch_Migration_Notes.md | 想知道 touch 模块为什么长这样 |
| 上位机数据流 | TouchHost-kaede/docs/capsense_data_flow_analysis.md | 调判定参数时看数据怎么传(只讲模块与流程,判定细节看代码) |
| 算法变更日志 | TouchHost-kaede/algorithm_changelog.md | 回看每次调参的背景、分析、变更、验证(最新在最前) |
| 判定核心与三端分发约束 | docs/TENO-TOUCH-CORE-DISTRIBUTION.md | 改判定、分发到三端、跑一致性比对前必读 |
| 判定核心本体 | touch_core/README.md | 唯一判定实现;构建与一键验证入口 |
| 回放数据目录 | TouchHost-kaede/replay-data/README.md | 查数据集来源、格式、哈希与限制 |
| 上位机协作约定 | TouchHost-kaede/CLAUDE.md | 在 TouchHost-kaede/ 下动手前的规则(含本文回写约定) |
| 游戏内 Mod 状态总表 | melonloader-mod/项目状态总表.md | 动 melonloader-mod/ 之前先读这份(自包含:架构、产物、风险、下一步) |
| 游戏内 Mod 框架说明 | melonloader-mod/TenoMod/README.md | 改框架层、加接缝、单文件打包与验证 |
| 游戏内 Mod 接缝勘察 | melonloader-mod/TenoMod/DESIGN.md | 要加新 hook 时查可 hook 点(含反编译行号) |
| Mod 触摸功能状态 | melonloader-mod/TenoDXIO/PROJECT-STATE.md | 改触摸逻辑(四层说明与全部踩坑) |
| Mod 与 AquaMai 共存 | melonloader-mod/TenoDXIO/docs/COEXISTENCE.md | 排查与 AquaMai 同装时的补丁冲突(含实测方法) |
| 上位机刷写与 USB 重连 | TenoDXController-H503/docs/UPPER-COMPUTER-FLASH-AND-RECONNECT.md | 实现或排查固件 DFU 刷写、USB 重连(含当前 DFU 不稳定说明) |
| 工具索引 | tools/README.md | 查找根目录下的串口配置、固件刷写和配置工具 |
| Mai2Touch 协议 | docs/Mai2TouchREADME.md | 逐行对照游戏源码的串口协议(设备端实现清单在第 8.8 节) |
当前算法基线:三端版本以第 11 节《触摸算法三端流水线与版本规范》为准; TouchHost-kaede/algorithm_changelog.md 的最新条目为 2026-10-30 — 上位机打包提速(build.ps1 重写 + 阶段计时),此前依次是 exe 内允许显式启用 py 判定 与 A 区判定改为简化阈值比较。 ⚠️ A 区目前有两套口径并存:共享核心 touch_core 的 master 是 4 状态状态机 (算法版本 1),实验分支 diff_judge 是"单纯比 diff 大小"(算法版本 2,照搬上位机 judge_py.py);上位机的 judge_py.py 自身仍标算法版本 1。跑起来看到 algorithm=2 就说明用的是 diff_judge 分支。状态机路径的结论为 4 状态 / 25 个判定分支点 / 32 条状态转移 / 8 条进入按下路径;43 条断言全通过、 364,800 组穷举无死分支、18,492 帧回放代数恒等式 0 不符。
12. 维护约定(重要)
改动"结构层"逻辑必须回写本文,保持文档与代码对齐。 触发条件:
| 触发条件 | 需要同步更新 |
|---|---|
| 新增/删除/重命名模块或文件,或改变模块间调用关系 | 第 0、4.1、5.7.2、9 节 |
| 改变数据流链路、CDC 端口归属、帧格式/长度/校验、两种输出模式的差异 | 第 2、4.3、7 节 |
| 改变 PSoC EZI2C 内存布局、状态字、软复位/校准流程 | 第 3 节(同时核对两份 main.c) |
| 改变通道映射规则、block 派生、扫描档位来源 | 第 6 节 |
| 改变 Magic 模块/参数表、Flash 记录布局、序列化版本 | 第 4.5、4.6 节 |
| 改变判定架构(状态机/基线策略/区域合并/强制置位语义) | 第 4.4、5.2 节 |
| 改变上位机 UI 结构、配置项生效关系、日志列 | 第 5 节 |
| 改变构建方式、依赖、产物名 | 第 8 节 |
| 新增"必须记住"的坑或跨端契约 | 第 10 节 |
改变 melonloader-mod/ 的产物/部署、触摸桥接、框架层接缝或 AquaMai 共存策略 | 第 5.7 节(同时在 melonloader-mod/项目状态总表.md 回写) |
改变任何一处扫描档位(固件 block_profiles / zone_params / ScanConfig_*) | 第 5.7.4、6.2 节 + 第 10 节第 22 条(三份副本必须一起改;当前三份数值相同,改一处不同步即重新分叉) |
配套规则:
- 版本号冻结:
version.json的version与各端VERSION文件未经允许不得修改 (当前3.0.0)。需要区分构建或变体时,只允许追加/修改-xxxx后缀(例如3.0.0-hotfix1),主版本号不动。build-all.ps1会拿version.json的version与全部 9 个VERSION做严格相等校验,所以一旦加后缀必须全部同步,否则构建直接拒绝。 - 在
TouchHost-kaede/下改动,仍按TouchHost-kaede/CLAUDE.md的追加日志要求,把完整记录追加到TouchHost-kaede/algorithm_changelog.md最前面,格式:## YYYY-MM-DD — <标题>+### 背景/### 分析/### 变更/### 验证(重要条目追加### 注意),内容具体到文件/行号/阈值/测试场景。 - 改动固件结构,按同样结构在
TenoDXController-H503/Touch_Migration_Notes.md追加"节点 N"。 - 在
melonloader-mod/下改动,先读项目状态总表.md(自包含全局状态),并按TenoDXIO/PROJECT-STATE.md与TenoMod/PROJECT-STATE.md的既有格式追加记录;改动后跑melonloader-mod/verify.ps1(离线自测 + 打包校验 + 接缝核对,共 292 项)。 - 只改阈值数值这类参数微调且不改变结构时,可只在 changelog 记录;一旦命中上表任一行,就必须回写本文,避免出现第 10 节第 6、7 条那种"文档与代码各说各话"的漂移。