Skip to content

TenoDX 同步触摸系统 · 项目总览 ​

一句话:两片 PSoC 采自电容(CapSense)→ STM32H503 通过 I2C 轮询 raw 并下发扫描配置 → 固件内做基线/判定 → USB CDC0 输出 → 上位机 TouchHost-kaede 做波形调试、日志与游戏直出;另有游戏内 MelonLoader mod melonloader-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 固件 ×2PSoC 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.pypython main.py,或 build.ps1 打包 exe
游戏内 Modmelonloader-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. 全链路数据流(逐跳) ​

#位置动作数据形态
1PSoCCapSense 组件扫描 17 个传感器,写入 EZI2C 共享缓存ezi2cBuffer[154]
2PSoC主循环刷新 RAW 区(小端 16-bit × 17)34 B
3STM32 psoc_comm异步 I2C 读 [Status(1) + Raw(34)] = 35 B,两片交替PsocDevice.raw[35]
4aSTM32 tenodataraw 模式:每 2 ms 组 70 B 帧推 CDC0status + dev0(34) + dev1(34) + sum
4bSTM32 mai2touchMai2Touch 模式:位图一变即推 9 B 帧,4 ms 保底续发0x28 + 7×5bit + 0x29
5STM32 touch_pipelineIIR(÷2) → 100 帧预热 → 100 帧采基线 → 34 路 Detector → 区域 OR 合并34-bit 区域位图
6CDC0 → TouchHost-kaedeserial_reader 校验/去重 → signal_pipeline → 共享判定核心Packet → FrameResult
7TouchHost-kaede波形图 / 数据表 / CSV 日志 / GameCommunicatorCSV、0x28 … 0x29 帧
8CDC0 → TenoDXIO.dll(游戏内 Mod,与 6/7 互斥).NET SerialService 校验/去重 → 双板独立 180 Hz 令牌桶 → TouchEngineushort[34] → 34-bit 掩码
9游戏内 ModHarmony prefix 抑制原版触摸驱动 → postfix(Priority.Last)写 InputManager.SetNewTouchPanel34-bit 掩码直接进游戏

三种 CDC0 模式(按完整帧特征即时切换) ​

模式值CDC0 内容判定在哪周期
raw070 B:两片 PSoC 原始 raw上位机(调用共享判定核心)2 ms
Mai2Touch19 B:0x28 + 7 字节 5bit 位图 + 0x29固件(调用共享判定核心)变化即发 / 4 ms 保底
IOA6 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 字节) ​

偏移长度内容方向
01状态与控制字读写
13417 通道 Raw(小端,低字节在前)只读
353417 通道物理电容 Cp只读
691717 通道 Compensation IDAC只读
861717 通道扫描分辨率写
1031717 通道 Modulation IDAC写
1201717 通道 Sense Clock Divider写
1371717 通道 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 上电/重配流程 ​

  1. EZI2C_Start() → 状态置 0x00 → 死等状态被改写(期间循环检查软复位)。
  2. 收到 0x01:CyDelay(100) 等待主机把多条 I2C 分包写完。
  3. 0x11 启组件 → 0x12 逐通道应用扫描参数,带参数净化:res 非法或为 0 → 12bit,mod 0 → 50,sns 0 → 2,modClk 0 → 2(防止时钟分频为 0 导致硬件扫描死锁)。
  4. 0x13 读物理电容 GetSensorCp()(依赖 PSoC Creator 勾选 Enable BIST,否则卡死)。
  5. 0x14 SAR 二分逼近校准 Compensation IDAC:从 bit 64 逐位逼近,目标 raw = 满量程 × 60%;纯整数运算(Cortex-M0 无 FPU)。
  6. 0x15 InitializeAllBaselines() → 0x02 交权进入运行循环。
  7. 运行循环: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 / PID 0x5740 / bcdUSB 0x0200,产品名 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 写入都会占用主循环,排在按键后面就等于把这段耗时加进按键时延。

模块目录职责端口主要周期
touchApp/touch/PSoC 通信、状态机、管线判定、Mai2Touch、CDC0CDC0I2C 2 ms/片;raw 2 ms;Mai2Touch 变化即发 / 4 ms 保底
ledApp/led/Mai2LED 协议 + WS2812B(TIM3_CH3 + GPDMA1 + PWM,GRB + gamma)CDC1空闲彩虹 20 ms
aimeApp/aime/PN532 / FeliCa IDm / MIFARE(USART2 115200)CDC2轮询 33 ms
configApp/config/Magic 协议、Flash 配置、Debug CDCCDC3Magic 超时 100 ms
kbApp/kb/12KRO 键盘报告(14 B 报文)HID02 ms
buttonApp/button/12 键扫描 + MultiButton(长按回调;主键输入走免消抖即时读)GPIO5 ms
statusApp/status/PC13 状态灯GPIO1 s 周期

App/touch/ 文件职责:

文件内容
touch_app.c/h统一入口:touch_app_init/task,对上层暴露映射读写与状态快照
touch_mode.c/hCDC0 三模式自动识别:A6 02 / AA 01/02 / 合法 Mai2Touch 命令;切换时补喂 RAW 帧头或 Mai2Touch 命令
tenodata.c/h主状态机 + raw 模式 CDC0 组帧推流
tenodata_config.c/h34 通道「区域编号 + block」映射表(default_mapping[34]);由 block 派生固定扫描档位
psoc_comm.c/hPSoC I2C:探测/软复位/写配置/校准/异步读/总线恢复
touch_pipeline.c/h软件基线、逐通道调用共享判定核心、区域 OR 合并
touch_core_port.c/h便携适配层:把固件的映射与逐帧数据接进共享判定核心
mai2touch.c/hMai2Touch 协议输出 + {...} 命令解析 + mai2touch_replay_command()
cdc_manager.c/hCDC0 收发 + 路由开关(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_PSOCpsoc_comm_write_config_mask(payload136, init_target_mask)(每片含 HAL_Delay(5)),失败的片直接标不可用
WAIT_CALIBRATION100 ms 轮询、5000 ms 超时;只接受 0x02;确认后又回退 → 判不可用
RUNNING2 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_MS2raw 模式 CDC0 推流周期
TENODATA_I2C_POLL_PERIOD_MS2单次 I2C 读间隔(两片交替 → 每片约 250 Hz;单次 35 B 读约 0.9 ms,总线占用约 45%)
TENODATA_I2C_TRANSACTION_TIMEOUT_MS30单次 I2C 事务超时
TENODATA_I2C_FAILURE_LIMIT3连续失败 N 次判离线
TENODATA_SINGLE_DEVICE_WAIT_MS500启动时确认单设备/无设备的等待
TENODATA_PROBE_PERIOD_MS / _REPROBE_PERIOD_MS50 / 500初始探测 / 后台重探
TENODATA_PSOC_REBOOT_WAIT_MS / _TIMEOUT_MS150 / 2000软复位后等待 / 超时
TENODATA_CALIB_CHECK_PERIOD_MS / _TIMEOUT_MS100 / 5000校准轮询 / 超时
TENODATA_RECOVERY_BACKOFF_MIN_MS / _MAX_MS5000 / 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 字节,固定)

偏移长度内容
01状态:0x00 正常;0x02 重新初始化/写配置;校准阶段为 PSoC 当前状态码;0x01 请求上位机下发扫描参数
1340x08 的 17 通道 16-bit 小端
35340x09 的 17 通道 16-bit 小端
691sum(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_FACTOR2.0(1/2 滤波)
PIPELINE_WARMUP_FRAMES / PIPELINE_CALIB_FRAMES100 / 100
PIPELINE_HISTORY_SIZE16

判定本身的阈值与分支不在本文档展开,也不在固件仓库内单独维护: 判定只有一份实现,见根目录 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。
模块编号内容
global0x00全局命令:0x84 ENTER_DFU、0x85 USB_RECONNECT,均需 payload {0xA5}
touch0x10通道映射 / CDC0 模式 / 批量改映射 / PSoC 状态
light0x200x01 灯珠数量、0x02 彩虹模式
keyboard0x400x00..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 参数:

参数读写载荷
0x01RW68 B 完整映射表(34 × [region, block])
0x02RW1 B CDC0 初始模式:0 raw / 1 Mai2Touch / 2 自动识别(默认)。完整入站帧特征始终优先;当前实际在跑哪一侧看 0x04 状态
0x03W批量部分更新,每条 3 B [channel, region, block],1–34 条、不得重复通道
0x04RPSoC 状态快照(16 B = 4 B 头 + 2 × 6 B 设备信息);GET_INFO 另回 10 B 能力表(第 8 字节 bit1 = 支持 0x07;第 9/10 字节为只读参数列表 0x04/0x06)
0x06R校准量快照: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 取卡(0x10 MiFare / 0x20 FeliCa)、0x52 取块、0x61/0x64 状态、0x81/0x82 不回包。PN532 走 USART2,HAL_UARTEx_ReceiveToIdle_IT + 256 B 环形缓冲,33 ms 轮询、2 s 卡去抖、初始化 3 次重试。
  • LED(CDC1):Mai2 灯板协议(Sync 0xE0 / Marker 0xD0;0x31 SetLedGs8Bit、0x32 Multi、0x33 MultiFade、0x39 SetLedFet、0x3C SetLedGsUpdate、0x7B/0x7C EEPROM、0xF0 GetBoardInfo)。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.pyTouchOscilloscope:UI、串口控制、波形/数据表、限速分发到 CSV 与游戏通信
serial_reader.py70 B 帧解析、校验和、双设备去重、STM32 状态握手与参数下发
signal_pipeline.pyIIR、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.pySTM32 设备识别:VID/PID + 注册表 MI 接口号 → 找出 Touch CDC(CDC0)/ Debug Port(CDC3)
magic_client.pyMagic 协议最小客户端(走 CDC3 按需读 Cp,读完即断开)
csv_logger.pyCSV 记录,按会话目录 + 行数/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/2res/mod/sns/div,经 send_psoc_config() 下发给 STM32;与固件 block_profiles 逐区相同(见 6.2)
hardware_mapping0–16 = Touch0(0x08),17–33 = Touch1(0x09)通道 → 区域名,仅作回退:连接时以主控 Flash 的映射为准(经 Debug Port 读取并覆盖本表);area[0] 决定 det_group
game_areas34 项固定顺序34-bit 游戏位序(AREA_TO_INDEX);C 区只有 C1、C2
game_portCOM17游戏客户端串口
log_channels[A4]记录哪些区域;空列表 = 全 34 通道
log_fields12 项(见 5.5)CSV 写哪些列及其顺序;空列表 = 全部可用字段;非法字段名在加载/热重载时报错
log_chunk_rows500000单文件行数上限(另有 512 MB 硬上限)
report_rate_hz180每 Dev 独立的消费限速(Hz),0 = 不限速
iir_factor2.0IIR 系数(1.0 = 关闭),热重载生效
judge_backenddll判定后端: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/PID 0x0483: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, IsPressed
  • Baseline / 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-payloadTenoMod.Game框架游戏层 + 组装根
TenoMod.RuntimeTenoMod.Runtime事件总线、Hook 管理、调度、按键
TenoMod.ContractsTenoMod.Contracts接口与 POCO
tenodx-payloadTenoDXIO.Payload触摸功能(串口、桥接、日志)
TenoDXIO.CoreTenoDXIO.Core触摸判定内核(零游戏依赖)

✅ 只有一个入口工程、只有一个产物:TenoDXIO.dll。工作区里没有独立的 TenoDXIO.Host, 也不会再产出 tenodx.dll——历史上曾存在第二个入口,但两个入口会用同一批 Harmony 补丁注册两次 (第二个 id 是重复执行而不是替换),已删除。构建脚本仍会清理 Mods 里遗留的 tenodx.dll / tenomod.dll,防止旧文件重新引入重复补丁。

5.7.2 四层架构(为什么这么切) ​

层工程目标接触游戏可否脱机测
ContractsTenoMod.Contractsnet472;net8.0❌✅
RuntimeTenoMod.Runtimenet472;net8.0❌✅
GameTenoMod.Gamenet472✅ 唯一允许❌
HostTenoMod.Hostnet472❌(只做引导)❌

切分的目的是让最容易出错的逻辑能在没装游戏的机器上跑测试——修 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 个配置项(TouchTrigger 18 + VirtualCard 4), 单一真源在 TenoDXIO.Core/TenoDXIOConfigSchema.cs; 含版本迁移链、校验器、保留注释的往返、ChangedKeys 驱动的热重载。配置编辑器是独立的 PySide6 GUI(可打包为约 54 MB 免 Python 的 exe)。
  • 判定入口是转发层:逐帧调用共享核心 teno_touch_core.dll,阈值来自当前保留的旧版核心编译期默认值 (本端覆盖接口已提供,尚未接到 TOML)。三端接线一致性由核心自带的绑定比对验证。

⚠️ 扫描参数三份副本当前并不一致(2026-11-07 实测):

副本ABCDE
固件 block_profiles12/50/2/210/30/2/212/30/8/28/35/2/210/40/2/2
上位机 config.yaml: zone_params12/50/2/212/30/2/212/30/8/212/35/2/212/40/2/2
本 mod CalibratedScanConfig12/50/2/210/30/2/212/30/8/28/35/2/210/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 0x08E4, D4, B3, A3, C1, E3, D3, B2, A2, E2, D2, B1, A1, E1, D1, B8, A8
Touch1 0x09E8, 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}):

区resmodsnsdiv
A125022
B103022
C123082
D83522
E104022

载荷格式: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 B 0xAA 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。Magic 0x05 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 速查 ​

项值
MCUSTM32H503CBT6 @ 250 MHz
I2C1PB7 SDA / PB8 SCL,从机 0x08 / 0x09,Timing = 0x10C043E5,模拟滤波开
USART2PB4 TX / PB5 RX,115200 8N1(PN532)
USART1PA9 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 像素
USBVID 0x0483 / PID 0x5740 / bcdUSB 0x0200 / 产品名 TenoDX Controller

CDC 端口用途(不要混用):

端口接口号端点用途
CDC00/10x01 / 0x81Touch(raw 70 B / Mai2Touch 9 B,互斥路由)
CDC12/30x02 / 0x82Mai2LED 协议
CDC24/50x03 / 0x83Aime 主机协议(不再响应 Magic)
CDC36/70x04 / 0x84Magic 配置(只走二进制协议,不输出文本日志);上位机按需读取 Cp / 补偿 IDAC(Magic 0x06)也走这里,读完立即断开
HID080x8512KRO 键盘

状态灯语义:初始化常亮 ≥1 s;运行每秒闪一次;配置写入快闪 3 次;保存失败/致命错误常亮。

PC 侧运行环境:

项TouchHost-kaede/melonloader-mod/
运行时Python 3 + PyQt5游戏内 .NET(Unity 2018.4 + MelonLoader 0.6.4,Runtime Type = net35)
产物目标无(脚本)/ PyInstaller 单 exenet472(不能是 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-mod
  • touch_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):

#步骤主要产物用途
1touch_coreteno_touch_core.dll、teno_touch_core_cli.exe、.o判定唯一实现,供其余各端内嵌/链接
2TenoDXController-H503maimai_controller_H503.hex / .bin / .elf / .mapSTM32 固件(烧录 / 调试)
3tools/set_com_portsTenoDXSetComPorts.exe固定 4 个 CDC 到目标 COM 口
4tools/flash_firmwareTenoDXFirmwareFlasher.exeST-Link 刷固件
5tools/TenoDXConfigurationToolTenoDXConfigurationTool / ControllerTest / Config / DFU.exe + release/配置、综合测试、DFU 刷写
6tools/TenoDXTouchMappingToolTenoDXTouchMappingTool.exe触摸区块预览 + RAW 映射校准;校准无误自动写入主控映射(单文件,素材内嵌)
7TouchHost-kaedeCapSenseOscilloscope.exe + config.yaml + teno_touch_core.dllPC 上位机(波形与判定调试);三个文件必须同目录
8melonloader-modTenoDXIO.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)。

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 save

touch 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. 已知约定 / 坑 ​

  1. PSoC 是纯从机:任何扫描参数变更都要走完整 0xAD 软复位 + 写配置 + 校准流程,不能"热改"。
  2. EZI2C 偏移与状态码是跨芯片契约:0x00 / 0x56(86)、0x00/0x01/0x02/0x11-0x15/0xAD、每片 17 通道顺序、136 B 参数顺序——任一处不一致都会静默错位。
  3. 状态帧必须带递增序号:否则上位机去重会吞掉 0x02→0x01 的状态变化。
  4. 缺失设备强制置 1:刻意的"宁可误触不可漏触"策略,固件与 game_communicator 两端语义必须一致。
  5. 两片固件互为镜像:改 Touch0/main.c 必须同步改 Touch1/main.c(仅握手引脚名不同)。
  6. 判定参数只有一处是真源:判定逻辑与参数统一由根目录 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。
  7. 文档基线在 2026-10-02 已复核对齐:docs/capsense_data_flow_analysis.md 的阈值、固件 README.md 的 VID/PID 与 Flash 布局都已改正。后续再改结构层逻辑时,按第 12 节回写,避免重新漂移。
  8. ref/ 下的映射表是旧版(按区整片分片),当前实现是交错分片。
  9. 日志回放注意事项:帧间距不均匀(非零间隔中位 18–28 ms、最大 50 ms),按帧计数的规则会滞后一帧;用内部真值列而非 Raw/Diff 反算。
  10. raw 模式下的强制掩码:touch_pipeline 只在 Mai2Touch 模式被喂数据,所以 raw 模式下 touch_pipeline_is_ready() 恒 false、recovery_forced_mask 不会在 RUNNING 中被清除;长期影响未实测。
  11. 主循环长阻塞点:写 PSoC 配置含 HAL_Delay(5),Flash SAVE 走 HAL_FLASH_Unlock/Program/Lock(擦扇区),都会打断 8 ms I2C 节奏与 USB/PN532 时延;30 ms 事务超时就是给这种抖动留的余量。
  12. 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)。
  13. 容量断言不能破:设备数 2、每片 17 通道、mapping 2 B、touch payload 70 B、keyboard 8 B、LED 20 B、WS2812B_PIXEL_MAX == MAI2LED_APP_MAX_LED_TOTAL == 32。
  14. 风险点:mai2led_app.packet_write() 未检查 tud_cdc_n_write_available(存在静默丢字节风险);buffer_3s 只维护不绘制(无消费者)。上位机的拔线/掉线自动重连已闭环(read_packets 捕获串口异常并 close,update_data 复查后进入断开流程;插回自动重连,重连会重新采集判定基线)。
  15. status_led_app_init() 早于 SystemClock_Config():其时基基于复位后的 HSI,改时钟配置时注意这一处。
  16. 扫描参数会在连接后主动重发并强制重配:固件只在 STATE_WAIT_HOST_CONFIG 报 0x01,而该状态要求"没有上位机参数覆盖"。新版 TouchHost / Mod / 映射工具统一发送 AA 02,因此连接或显式应用参数时即使内容相同也会重配 PSoC 并重建基线;AA 01 仅保留幂等兼容。另可走「重新加载配置」按钮,详见第 6.2 节。
  17. 映射只有固件那份是权威,上位机与 Mod 都读取它:上位机连接时经 Debug Port 读固件 tenodata_config.c: default_mapping[34] 并覆盖本机 config.yaml: hardware_mapping(yaml 仅作回退)。Mod 打开触摸口时也读取同一块板子 CDC3 上的映射;读取成功就用固件映射重建判定分组与 139 B 扫描档位,读取失败才使用 TenoDXIO.toml: HardwareMapping。映射从不下发/写回固件,共享判定核心的参数指纹也刻意不含映射。
  18. EZI2C 第 3/4 行命名与实际硬件相反:sns(偏移 120)实际是 modulator sample clock,div(偏移 137)实际是 sense clock。两端语义一致,但按名字调参极易填错位置,详见第 6.2 节。
  19. 重新下发会打断触摸:完整重配(软复位 → 写配置 → 校准 → 重建基线)期间两片 PSoC 约 1~2 s 不参与判定,且缺失/恢复设备区域会被强制置 1;上位机判定基线仍按旧参数采集,建议重开后重新采集基线。
  20. Cp / 补偿 IDAC 是静态量且依赖 BIST:CapSense_1_GetSensorCp()(状态 0x13)与 SAR 校准写出的补偿 IDAC(状态 0x14)都只在配配置时产生,运行中不变、软复位后清零; 且需要 PSoC Creator 勾选 Enable BIST,否则 0x13 这一步会卡死。刚上电/刚重配就读会拿到 0。
  21. 帧率:每片新数据率已能跑满 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.md 6.5 第 4 条)。
  22. 扫描参数仍是三份独立副本,但当前数值已统一:固件 block_profiles(第 6.2 节的表)、 上位机 config.yaml: zone_params、游戏内 Mod TenoDXIO.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 节)。
  23. 游戏内 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 三行——最后一行是刻意加的,因为没有它, "框架加载成功但什么都没发生"与"接缝根本没装上"看起来完全一样。
  24. melonloader-mod/ 是独立仓库,根仓库看不到它的历史:2026-09-25 已 git init(分支 main), docs/reference/、output/、bin/obj/ 等按 .gitignore 排除。跨子项目看版本仍需分别进各自仓库看 git log;根仓库整体忽略该目录,不会出现子模块登记或嵌套提交。
  25. Mod 与 AquaMai 的冲突只能靠不装来解决:AquaMai 的 CollectWantedPatches 先执行 OnBeforeEnableCheck 再判断启用条件,所以 AquaMai.toml 里写 Disabled = true 也拦不住串口线程与 provider 注册(详见第 5.7.6 节)。另外 tenodx.dll 与 TenoDXIO.dll 只能装一个,同时装会产生重复补丁。
  26. 不要用 [ModuleInitializer]:Unity 的 Mono 不执行模块初始化器(已在旧项目实测失败两次), 程序集解析器必须装在类型静态构造函数里。这是 melonloader-mod/ 单文件打包能成立的硬前提。
  27. Mod 的串口失败也记成 [Info]:SerialService 的日志回调接的是 message => logger.Info(message), 所以"串口打不开"这种真实故障与正常信息同级。失败还会被 RepeatSuppressor 压成"每个原因只报一次" (失败原因或端口号变化才重新报告,连接成功会清空历史),排查时别只看级别,也要看是否两秒内反复重试。
  28. 不要伪造硬件状态来消除症状(已回滚的两个改动):没有读卡器时游戏会认为读卡器故障, 表现为「Aime 读卡器错误」提示、入场画面「参考卡号」按钮置灰、TryAime 的刷卡流程停在 Nop, 并陷入一个错误屏循环(ErrorAime 屏 3 秒后自动关闭并重建入场画面)。 曾试过两个"修补":强行把 OperationManager.IsAliveAimeReader 置回 true, 以及拦 AimeReaderManager.GetResult() 把 Error 改写成 None。 两者都让游戏在不具备条件时继续往前走,整体行为变得很怪,已 git revert 撤销。 这类字段是硬件状态的如实反映,游戏用它决定走哪条路径(例如 IsAimeLoginDisable()), 改它等于欺骗整个流程。现在若要做虚拟刷卡,走游戏自己的 TryAime.TestAimeId 门 (见 5.7.5);不要伪造 AMDaemon 的读卡器状态。
  29. IsAliveAimeReader 是字段不是属性:AquaMai 的 IgnoreAimeServerError 能 patch IsAliveAimeServer 的 getter 直接返回 true,但同样的手法对 IsAliveAimeReader 无效—— 它是 public 字段,没有 getter 可挂。这条留作排查 Aime 问题时的参考事实: 先确认要处理的是字段还是属性,两者的修法完全不同(即便本项目已决定不碰它)。
  30. 上位机已不做 raw 对齐:主机侧按区块类型缩放 raw 的 raw_alignment 已在 v3.0 随本地判定实现一并移除, 现在 CSV 的 Raw 就是 IIR 滤波后的 CDC0 原始计数、SetupRaw 是它的启动校准均值,没有任何主机侧增益/偏移。 追查硬件未滤波原始值时仍要回看固件 CDC0 的 raw(iir_factor=1.0 可关掉唯一的主机侧处理)。
  31. 上位机的 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 条那种"文档与代码各说各话"的漂移。

TenoDX · 内部文档