AJAZZ AK35i V3 Max 是一把三模机械键盘,带一块 240×135 的屏幕,官方控制程序只有 Windows 版。这个项目把它的功能移植到 macOS,做成原生应用 AK35i Control Center 和命令行工具 ak35i,覆盖灯效、单键颜色、系统设置、改键、宏、屏幕时钟、屏幕图片、电量,以及驱动原版的实时灯效和音乐律动。

整个过程没有 Windows 电脑,也就没有 USB 抓包。协议全部来自对驱动和键盘固件的静态分析,再在 Mac 上靠键盘的应答和实际效果逐项验证。

AK35i Control Center 概览页

分析对象

驱动取自 AJAZZ 官网驱动索引里与型号同名的条目 AJAZZ_AK35I MAX V3_Tripe mode RGB with screen_keyboard driver_V1.0.0.zip,下载后先记 SHA-256。归档里是一个 Inno Setup 6.1.0 安装器,用一次性 Alpine 容器里的 innoextract 只读解包,得到 145 个文件。驱动本身从未运行过。

逆向用到的文件如下。

文件 作用
DeviceDriver.exe 控制程序,32 位 x86 MFC,所有构包和发包逻辑都在这里
mui.dll 界面库,实时灯效动画和 RGB565 转换在这里
config.xml 设备表,USB 为 VID_0C45&PID_8009&MI_00,2.4G 接收器为 0C45:FDFD
rgb-keyboard.xml 104 键布局(键码、灯号、行列),发包间隔 cmd_delaytime 35 ms,亮度和速度上限 5,GIF 帧数上限 141
语言文件 界面字符串 ID,用来把控件和处理函数对上
两个固件升级程序 资源里带 256 KB 的主控固件镜像(ARM Cortex-M,SN32F290),只做只读反汇编

驱动把配置存在本地 SQLite 里,有 t_light_data、t_key_macro_data、t_macro_data 等十几张表。键盘的改键、宏和屏幕都读不回来,驱动界面上显示的改键和宏完全来自本地数据库。这些建表语句后来用来核对结构体偏移。

方法

静态分析

入口是 HID 调用。DeviceDriver.exe 导入 SetupAPI 和 DeviceIoControl,并动态解析 HidD_SetFeature、HidD_GetFeature。有线下所有设置都经过唯一一处 HidD_SetFeature 包装(0x452010),上一层是单包发送函数 0x44faa0 和实时帧发送函数 0x44fc90。从这几个函数的调用方往上追,借语言 ID 和控件 ID 把每个界面功能对到一个构包函数,再按指令读出每个字节的来源。

结构体字段靠数据库定位。例如灯效构包函数 0x42b400 之前,0x4084e0 执行 SELECT * FROM t_light_data WHERE profile = ? AND mode = ? 填结构体,对照建表列序就能确定 +0x10 是亮度、+0x14 是速度、+0x24 是颜色。

分析分了三轮。

  1. grok CLI 通过 REA 调 Hopper 6.6 反编译,产出功能拆分初稿。
  2. Claude 按指令复核传输封装、灯效、音乐律动和时钟。Hopper 的伪代码会把逐字节写入折叠起来,字段偏移一律以指令为准。
  3. 换用 llvm-objdump,Claude 开两个子代理分头追系统配置、改键、宏、屏幕和实时帧,主会话按地址抽查。

初稿的骨架是对的,但有几处照抄就会发错报文,都在复核时改掉了。

  • 改键和宏被合成一条命令。改键是 04 11 和 04 27,宏是 04 19 加 04 15,格式完全不同。
  • 0x418590 被当成屏幕帧提交,它是改键发送函数。
  • 0x4346a0 被当成用户灯效,它是侧灯模式,本机布局 sidelight="0",走不到。驱动里的“用户灯光”就是单键颜色 04 23。
  • 音乐律动被写成不发 HID,有线下它每帧都发一个 04 20。
  • 更早的记录把 DeviceIoControl(0xB0192) 写成读报告描述符,它是 IOCTL_HID_GET_FEATURE,驱动用它读回写入后的应答。

固件镜像套上 ELF 头后用 llvm-objdump 按 Thumb 反汇编。USB 命令阶段的分发在 0x5012,数据阶段在 0x515C,无线命令在 0x67C8。固件分析有两个用处,一是确认写入命令落在哪块闪存,屏幕上传前必须知道这一点,二是和驱动的 2.4G 分支互相印证。驱动包里的固件是 V1.17,实机后来查出是 V1.30,只靠固件得出的结论都要上实机复核。

验证路线

原计划是在 Windows 上用 USBPcap 抓包,逐条对照静态结果。没有 Windows 电脑,改成下面的流程。

  1. 按驱动逐字节还原报文,先写成测试。
  2. 在 Mac 上发送,按驱动的方式读回应答。
  3. 观察效果。灯效和屏幕目测;按键类设置用一次性的只读 IOKit 输入监听,记录键盘实际发出的 HID 用法;能读回的读回核对。
  4. 拔插数据线、切换连接档位,确认是否持久化。

试验顺序按可恢复性排。基础灯效最先,写错了能用键盘自带的 Fn+\|、Fn+↑/↓、Fn+-/= 恢复。改键、宏、屏幕读不回来,先找好恢复路径再试。改键写回两层全默认就能恢复,驱动的“重置键盘”发的就是这个;宏的恢复是上传空宏表;屏幕则先分析固件,确认上传不会覆盖出厂开机动画。

几条边界始终不变。只发驱动里还原出的序列,不穷举未知命令。不实现固件升级、DFU 和恢复出厂。每次写入都要在界面上确认,命令行要加 --apply。整个项目只破过一次例,见蓝牙一节。

有线传输层

接口

0C45:8009 有两个厂商接口。

接口 用途页 / 用途 报告 承载
3 0xFF13 / 0x01 Feature 64 字节 所有命令和设置数据
2 0xFF68 / 0x61 Output 4096 字节,Input 64 字节 屏幕图像

报文、会话和应答

驱动的 Feature 缓冲固定 65 字节,第 0 字节是报告 ID 0,后 64 字节是载荷。命令包载荷以 04 <命令号> 开头。一次设置是一个会话,通常是下面的形状,每包前等 35 ms。

04 18              开始,要求应答
04 <命令>          前导包,载荷偏移 8 = 后续数据包个数,要求应答
<数据包> × N       每包 64 字节,不读应答
04 02              提交,要求应答,设置在这一步持久化
04 F0              结束,部分功能没有

要求应答的包写完后再等 35 ms,用 IOCTL_HID_GET_FEATURE 读回。驱动以载荷偏移 3 为 0x01 算成功,但调用方从不检查这个结果,失败了照样往下发。

这条规则在 macOS 上先用已经验证过的时钟序列校准,避免和未验证的灯效报文混在一起。读回结果是命令包的应答为命令字原样回显、偏移 3 为 0x01;数据包的应答是整包回显,偏移 3 是数据本身。最终判定条件是前两字节等于命令字且偏移 3 为 0x01,只用于命令包。

macOS 实现

不需要内核扩展,用户态 IOKit 就够了。

  • 用 IOHIDManager 按 VID、PID、主用途页和主用途匹配接口。匹配结果不是恰好一个就停止,不猜。
  • IOHIDDeviceSetReport 的报告 ID 是单独参数,缓冲里要去掉驱动那个前导 0,只传 64 字节。IOHIDDeviceGetReport 读回的 64 字节同样不含报告 ID,偏移和驱动的载荷偏移一致。
  • 错误处理比驱动严。04 18 没有应答,一个设置字节都不发;之后某包没有应答,按驱动顺序发完收尾包再报错,避免把键盘留在半截会话里。
// 驱动的 65 字节缓冲去掉报告 ID,剩下 64 字节交给 IOKit
let payload = Array(report.dropFirst())
IOHIDDeviceSetReport(device, kIOHIDReportTypeFeature, 0, payload, payload.count)

// 应答判定,只用于 04 xx 命令包
let acked = response[0] == payload[0] && response[1] == payload[1] && response[3] == 0x01

各功能的协议

功能 命令 驱动函数 数据 读回
屏幕时钟 04 28 0x423ac0 1 包 无
基础灯效 04 13 0x42b400 1 包 无
单键颜色 04 23,读 04 F5 0x44c5f0、0x42b1e0 576 字节,9 包 有
系统设置 04 17 0x413ff0 1 包 无
改键 04 11 普通层,04 27 Fn 层 0x418590 576 字节,9 包 无
宏 04 19 后接 04 15 0x42db70 N 包 无
屏幕 04 72 0x422b00 4096 字节 Output 报告 无
实时帧 04 20 0x428110、0x432e70 512 字节,8 包 不保存

基础灯效 04 13

数据帧前 16 字节有效。

偏移 内容
0 模式号 0~19
1~3 R、G、B
8 多彩
9 亮度 0~5
10 速度 0~5
11 方向
14~15 AA 55

模式表来自驱动首次运行时写库的默认行(0x42b6b0),每个模式带一个 config_func 位掩码,决定界面显示亮度、速度、方向、颜色中的哪些控件。程序只允许驱动界面能发出的取值组合,比如方向值只有带对应位的模式才会变。

验证时发模式 1(静态恒亮)、颜色 00 FF 00、亮度 5、速度 3,三个应答都通过,全键变绿,拔插和切档后保持。之后实测确认亮度 0 为熄灭,速度 0 最慢、5 最快,方向 0 和 1 互为反向。

单键颜色 04 23 和 04 F5

576 字节的块里,每个灯在偏移 4×灯号处占 4 字节 [灯号, R, G, B],没设颜色的槽位全 0,偏移 574~575 为 AA 55。写入前驱动先用一次 04 13 把灯效切到自定义模式 0x80,再走 04 18、04 23(偏移 8 = 9)、9 个数据包、04 02、04 F0 的会话。

04 F5 读回 9 块 64 字节,格式相同。动画模式下连读三次,结果各不相同,非零区域跟着动画移动,说明它读的是键盘此刻点亮的颜色。只有在模式 0x80 下,读回值才等于保存的颜色。读回还会丢精度,写 FF 读回 FC,每通道约 6 位。

命令行据此实现成先切到 0x80,连读两次,不一致就停止,一致再把请求合并进去写入,写完再读一遍,按每通道亮或灭核对。界面上的单键编辑器则按画面所见整块写入,不和键盘里存的合并,避免写进用户看不到的颜色。

系统设置 04 17

会话是 04 18、04 17(偏移 2 = 1,偏移 8 = 1)、数据帧、04 02,没有 04 F0。

偏移 内容
0~1 00 01,固定
2 禁用 Alt+Tab
3 禁用 Alt+F4
4 禁用 Win 键
5 Fn 开关
6 休眠,0 不休眠,1/2/3 为 1、5、30 分钟
8 响应时间档位 1~5
62~63 AA 55

键盘没有读回命令,每次写全部六项,不带参数时写驱动首次运行的默认值。

效果用按键监听验证。键盘背面有 Win/Mac 拨动开关,Mac 档下 Win 键和左 Alt 分别发 Option 和 Command,三个禁用开关只在 Win 档下生效。禁用 Alt+F4、Alt+Tab 都有效,写回 0 就恢复。禁用 Win 单独置 1 时 Win 键照常发出,要和偏移 3 同时为 1 才拦截,复现两次。Windows 驱动只勾这一项时发出的字节相同,所以这是固件行为,程序只给提示,不擅自改别的位。休眠、响应时间和 Fn 开关没观察到可判断的效果,仍标为未验证。

判断按键效果要以 HID 记录为准。拿 Spotlight 是否弹出判断 Win 键、拿大写灯判断 CapsLock 改键都出过错,前者受拨动开关和输入法快捷键干扰,后者是因为 macOS 会忽略短于约 0.55 秒的 CapsLock 点按。

改键 04 11 和 04 27

每次发送整层 576 字节,会话同单键颜色。每键 4 字节记录在偏移 4×key_index,本机 104 键的 key_index 和灯号完全相同。没有记录的键写全 0,即固件默认。

类型 记录
默认 00 00 00 00
禁用 05 03 00 00
按键或组合键 02 <修饰位> <HID 用法> 00,Ctrl 1、Shift 2、Alt 4、Win 8
宏 06 <宏序号> <播放方式> <次数>
鼠标 01 01 <按键位> 00,滚轮 01 03 01 00 / 01 03 FF 00
多媒体 03 <Consumer 用法> 00 00
多键 07 <k1> <k2> <k3>

打开程序、网址、发送文字三类记录是 05 02 <编号> 00,键盘只上报编号,动作由驱动在电脑上执行,没有移植。

验证结果是 CapsLock 改成 Esc 后从发 0x39 变为 0x29,拔插后保持;Fn 层 F1 改成静音后,Fn+F1 从 Consumer 0x183 变为 0xE2。两层写回全默认后都恢复。

宏 04 19 和 04 15

会话是 04 19、04 15(偏移 8 = 块数 N)、等 30 ms、N 个数据包、等 30 ms、04 02,没有 04 18 和 04 F0。缓冲 3584 字节,布局如下。

  • 偏移 0~399 是 100 项索引,每项 4 字节,有内容时为数据块偏移(小端 16 位)加 00 00,空宏为 FF FF FF FF。
  • 数据块从 0x190 开始,8 字节块头的前两字节是条目数,后接 4 字节条目。
  • 按下 00 00 <HID> B0,松开 00 00 <HID> 30,鼠标按下 00 00 <键位> 90、松开 10,延时 <毫秒低> <毫秒高> 00 50,不足 10 ms 按 10 ms。
  • 设数据结束偏移为 E,E 是 64 的倍数时 N = E/64 + 1,否则 N = E/64 + 2,偏移 N×64−2 写 AA 55。

播放方式和次数不在宏表里,在触发它的改键记录里。实测把宏 A 绑到 F12 播放一次、Shift+B 绑到 F11 重复三次,步间隔 20 ms,拔插后仍然有效;上传空宏表并复位改键后恢复原样。

屏幕 04 72

上传前先看固件。0x72 把写地址设到外置闪存 0x950000,之后每收到一个 4096 字节块就写一块、地址加 0x1000。上电播放的出厂开机动画在 0x340000。所以 04 72 写的是可上传的 GIF 页,碰不到出厂动画;USB 协议里也没有读外置闪存的命令,GIF 页原内容覆盖后无法恢复。

图像格式如下。

  • 256 字节头,偏移 0 是帧数,偏移 1 起是每帧延时,单位 2 ms,其余填 FF。
  • 每帧 240×135,逐行从上到下,RGB565 小端(取 R 高 5 位、G 高 6 位、B 高 5 位)。
  • 整体用 FF 补齐到 4096 的倍数,帧数上限 141。同方案的 Aula F108 Pro 有人传过 214 帧,141 帧以后覆盖了屏幕菜单图形且无法恢复。

发送顺序是 Feature 接口上的 04 18、04 72(偏移 2 = 槽位 1,偏移 8~9 = 块数),接着在屏幕接口上逐块写 Output 报告,每块键盘回一个 Input 报告 01 5A 02,最后在 Feature 接口发 04 02。

macOS 上 Output 报告同样用 IOHIDDeviceSetReport,类型换成 kIOHIDReportTypeOutput;应答用 IOHIDDeviceRegisterInputReportCallback 注册回调,写完一块后跑 RunLoop 等回复,超时 1 秒。图片用 ImageIO 解码,等比缩放到 240×135 居中,GIF 保留帧延时。

单帧测试图共 16 块,每块约 0.16 秒,方向和颜色顺序正确,拔插后开机动画照常播放。查看 GIF 页要按 Fn+PrtSc(说明书写法)。按 V1.17 固件的 Fn 表,Fn+R 在满足另外两个状态条件时也会切到这个页面,但在 V1.30 实机上按了没有反应。

实时帧 04 20

驱动的实时灯效和音乐律动都由电脑逐帧推送,键盘不保存。每帧是 04 20(偏移 8 = 8)、512 字节分 8 包、04 02,没有 04 18 和 04 F0。数据按顺序写 [灯号, R, G, B],104 键用 416 字节。停止推送,键盘立即回到原来保存的灯效。

时序照 0x44fc90,命令包前后各等 5 ms,读回偏移 3 为 0xFF 表示忙,重发一次;数据包间隔 2 ms;04 20 没有应答就跳过这一帧。普通设置用的 35 ms 间隔在这里不可用,一帧要发 10 包,帧率会低于 3 帧每秒。按驱动时序实测约 14 帧每秒。

2.4G 接收器

驱动在连接模式为 2 时改走另一套短包,固件在 0x67C8 处理。这部分由两个子代理分别追驱动和固件,结论独立得出后逐条比对,完全一致。

接收器在 Mac 上报的是 05AC:024F,产品名 “2.4G Dongle”,这是苹果铝键盘的 ID,和驱动配置里的 0C45:FDFD 不同。它的第 3 个接口用途页 0xFF60、用途 0x61,输入输出都是 32 字节、不带报告 ID,对应驱动写的 33 字节。选接口时先按用途页和用途匹配,再在代码里核对产品名或 VID:PID 和报告长度。只按产品名让 IOHIDManager 过滤没有生效,会把同样带 32 字节厂商接口的罗技接收器也匹配进来,第一次试验就因为出现两个候选接口而在发包前停下。

包格式是 p0 命令、p1 长度、p2 序号、p3~p30 数据(28 字节)、p31 为前面各字节之和。键盘回显 p0~p2 表示收到。没有 04 18 / 04 02 会话,每条命令立即生效。

功能 报文
在线探测 02 00,应答带固件信息块
电量 20 01,应答字节 3 为百分比
时钟 0C 10 00 + 有线时钟数据帧,整体后移 3 字节
基础灯效 05 10 00 + 有线 04 13 数据帧前 14 字节 + AA 55
系统设置 07 10 00 + 有线 04 17 数据帧前 14 字节 + AA 55
改键 21 包 10(普通层)或 12(Fn 层),每包 28 字节,内容同有线 576 字节块
宏 若干包 09,内容同有线宏表
单键颜色 先 05 10 00 80 … 切模式,再 21 包 14
音乐律动 每帧一包 0B,带前景、背景色号和柱高,驱动发 21 列,固件只读前 18 列
屏幕 7F 03 00 <槽位> <包数 24 位>,之后每包命令字 0x80 + (i >> 16),长度和序号字段放 i 的低两字节

设置类数据和有线完全同源,只是切成 28 字节一包,所以有线和 2.4G 共用同一套编码器。

风险集中在固件的写入方式。单包设置命令每条都擦写一个 1 KB 闪存页,不适合跟着滑块连续发。多包流式写入按基址加 28×序号写,到页首就擦,序号没有上限检查。宏表写到第 110 包会进入 Fn 层改键所在的 0xAC00,而驱动自己的宏长度上限比这个还宽。程序在发送前拒绝超过 3,024 字节的宏表,这个边界写进了测试。

发送器的规则是先发 02 00 探测,键盘不应答就一包不发;之后每包等回显,超时 1 秒,没有回显立即停止,不重发,以免同一数据写两次闪存。屏幕数据是唯一的例外。它写在外置闪存,同一包重发只会把同样的字节写到同一地址,跨扇区的包会先重新擦除,结果一致,所以允许最多重发 3 次。第一次 2.4G 上传在 4,639 包中的第 497 包丢了回显,按规则停止;放开重发后,单帧图片 2,325 包、62 秒传完,重发 0 次。

2.4G 下逐项实测了时钟、电量、灯效、系统设置、改键、宏、单键颜色、音乐柱状图和屏幕。键盘在 2.4G 下没有逐键任意颜色的实时帧,也不能读回颜色,这两项只能用数据线。

电量只有 2.4G 能读。驱动的电量函数在非 2.4G 模式下直接返回;固件的电量来自 I2C 电量计,USB 命令处理代码里没有读取它的地方。版本号也从这里拿,探测应答里的信息块按社区项目 dantje/ajazz-ak35i-v3-max 的格式解析为 0C45:8009、V1.30;有线下则直接读 USB 设备描述符里的 bcdDevice,同为 0x0130。

蓝牙

蓝牙下键盘是 0C45:FEFE,HID 报告描述符里没有厂商命令通道,固件在蓝牙模式下也直接丢弃厂商帧,所以设置功能无法经蓝牙使用。程序只用 CoreBluetooth 读标准电池服务(180F / 2A19)。判断键盘是否在蓝牙档,是在 IORegistry 里查 0C45:FEFE 的 HID 服务,不打开设备,因此不需要输入监控权限。

GATT 里还有一个厂商服务 FEE0,特征 FEE1 可读可写。经过确认,往 FEE1 写过一次 2.4G 探测包,写入被接受但没有任何回应。事后查明 FEE0 / FEE1 是沁恒 CH583 系列 BLE OTA 例程的服务和特征,同方案键盘的无线芯片是 CH582F,例程里 0x81 是擦除命令。FEE1 是蓝牙模块的固件升级通道,现在明确禁止写入。这是整个项目里唯一一次发出驱动中没有的数据,正确的顺序应该是先查资料再试。

移植驱动的算法

实时灯效

驱动的 8 种实时灯效在 mui.dll 的 CustomLightMode 里计算。移植方式是逐指令还原更新函数,查找表不手抄。17 张表共 1,730 字节,由脚本从 mui.dll 按地址逐字节导出到 DriverLightTables.swift,每张表注明原地址。

  • 计时以 16 ms 为一拍,速度 s 时每 max(15−s, 1) 拍走一步。
  • 每一步按驱动顺序调用全部效果的更新函数,与当前用哪个效果无关,各效果的内部状态都在推进。
  • 每键输出颜色为 (颜色 × 亮度) >> 8。

驱动的随机取色每次都先 srand(0xE8) 再 rand(),MSVC 下恒为 796,实际取色序号只由一个 1~100 循环的计数器决定。序号能取到 106,而漫天飞舞的配色表只有 106 项,越界读到对齐填充和下一张表的首字节,结果是黑色。这些行为都按原样保留,测试用例直接从反汇编推出期望值。

音乐律动

驱动的音频管线是 WASAPI 环回采集,256 格环形缓冲,前半加 Hann 窗,实数 FFT,按频率加权,取 v = min(1, 0.5·log10(1.1m) + 0.9m),再做 17 点平滑,得到 256 个电平。键(行, 列)的采样点在 x = 11·列、y = 10·行 + 7,电平高于 (64 − y)/64 时显示前景色,否则显示背景色。11 种显示模式的配色和整数 HSV 算法也按地址读出。

Mac 上用 Core Audio 进程 tap 采集系统输出,CATapDescription 建全局立体声 tap,挂到一个私有聚合设备上读取,要求 macOS 14.2 以上并授予系统录音权限。没有用麦克风,它采到的是房间里的声音。

与驱动的差别有三处。输入是混成单声道的最近 512 帧;静音时柱子会落下,驱动是冻结;FFT 按标准实数布局计算,没有逐指令复刻驱动的浮点舍入。驱动默认振幅下样本被乘以 600,FFT 又不归一化,普通音量就会让全部频段饱和,这一点也照原样保留,靠振幅滑块调低。

2.4G 下改发柱高,同一套引擎算出 18 列高度放进 0B 包,由固件按调色板绘制。

工程结构

  • Swift 6 + SwiftPM,单个可执行目标。同一个二进制从 Finder 启动时打开 SwiftUI 界面,带参数运行时是命令行工具。
  • 和设备、媒体打交道的部分用 IOKit HID、CoreBluetooth、Core Audio 和 ImageIO,整个项目没有第三方依赖。
  • 协议编码器都是纯函数,输入设置、输出字节。传输层藏在 FeatureReportDevice、WirelessExchanging 这样的协议后面,等待时间也可注入,测试不需要真机。
  • 改协议编码器前先写失败的测试。测试断言的字节来自反汇编,或来自实机验证过的序列,比如把全键变绿的那组灯效报文原样写成了测试。目前共 122 个测试。
  • 发布包是 Intel 和 Apple Silicon 通用的 DMG,支持 macOS 13 以上,ad-hoc 签名,没有公证。

还没解决的问题

  • 禁用 Win 键为什么要同时置 Alt+F4 位才生效。
  • Fn+PrtSc 在固件里的出处,以及 Fn+R 在 V1.30 上为什么无效。
  • 多帧 GIF 和帧延时没有上过真机。
  • 休眠、响应时间、Fn 开关的效果,以及多彩纵横方向 2、3 的含义。
  • 换成驱动原版音乐引擎之后的 2.4G 柱状图还没实测。
  • 驱动的音乐律动在 Windows 上是否同样饱和,目前只是推断,没有 Windows 机器对照。

项目地址 Ike-li/ak35i-v3-max-macos-control-center。完整的逆向记录在 docs/STATIC_REVERSING.md,安全边界在 docs/SAFETY_MODEL.md。这是独立的社区项目,与 AJAZZ 没有隶属、授权或合作关系。