Skip to content

Lua 脚本 API ​

📥 下载此API文档:lua-api.md

pico-hid-mapper 内置 Lua 5.4.6 解释器,让你用脚本自定义按键/鼠标行为。典型用途:压枪宏、连招宏、一键丢弃、自动化脚本等。


数据流 ​

USB HID 设备 / 手柄 (Gamepad) / 触摸板 / WebSocket / WebHID (键盘/鼠标/自定义事件)
      │
      ▼
映射引擎 (去抖 / 边沿检测)
      │  按键/按钮事件(仅"边沿":按下、松开)
      │  鼠标移动/滚轮事件
      │  触摸板瞬时快照(on_touchpad,变化时整帧上报)
      │  自定义字符串事件
      ▼
Lua 引擎 (bitmap 过滤: 只有你声明监听的键/事件才进入 Lua VM)
      │
      ▼
你的 Lua 脚本(多模块项目,按优先级聚合)
  on_key / on_mouse_btn / on_mouse_move / on_mouse_wheel
  on_custom_event / on_boot_button / on_touchpad / tick
      │  返回 true = 拦截, false = 放行
      ▼
触摸事件队列 → USB HID 触屏 → 上位机 (手机/电脑)

外部文本输入路径(均可触发 on_custom_event(str)):

Web 前端 / LuaEditorDialog → WebSocket CMD 0x01 → ws_ipc → lua_binder_on_custom_event
webctrl WebHID 工具         → HID OUT CMD 0xFA    → pio_device → lua_binder_on_custom_event

板载 BOOTSEL 按键路径(触发 on_boot_button(pressed)):

core0 BOOTSEL 消抖轮询 (10ms 采样, 连续 3 次一致) → 引擎 IPC → lua_binder_on_boot_button

脚本主动输出(两条互不相干的通路,别混淆):

dkb_* / dmo_*  → USB HID 键盘 / 鼠标 → 上位机   直通输出:不经过映射引擎,与「映射开关」无关
input_*        → 回到上面的「映射引擎」入口       注入:当作真实输入重走一遍管线,会触屏、受开关影响

关键:脚本运行在输入热路径上,返回 true 会"吃掉"事件让 core 不再做默认处理,返回 false 则原样放行。 对 on_mouse_move/on_mouse_wheel 而言,"拦截" = 把位移量(dx/dy 或 wheel) 清零后再交给 core。


脚本模板 ​

运行模式:多模块项目 ​

设备上运行的是一个多模块项目:项目由若干 .lua 模块文件组成,在面板「Lua 工作台 → 模块」页管理(新建 / 重命名 / 复制 / 删除 / 启停 / 排序)。列表顺序 = 优先级 = 执行顺序(排在前面的先执行);停用的模块不装载。所有模块在同一个 Lua VM 里运行,装载规则:

  • 每个模块在独立的 _ENV 中执行:模块顶层的 local、函数和全局变量写都留在模块自己内部;读会穿透到 _G(内建 API 与其它模块显式共享的东西)。模块之间默认互相看不见——想让别的模块用到你的函数/数据,必须显式 _G.xxx = ...。
  • 入口函数按优先级聚合:每个模块按需定义入口函数(见「入口函数」),固件把同名入口按优先级串起来调用:
    • 可拦截事件(on_key / on_mouse_btn / on_mouse_move / on_mouse_wheel / on_gamepad_btn / on_custom_event):按优先级依次调用,首个返回 true 的模块拦截,排在后面的模块不再收到该事件;
    • 广播事件(init / tick / on_touchpad / on_boot_button):每个定义了的模块都会被调用,返回值忽略。
  • 单模块出错不影响别人:某个模块某次调用报错只记日志,其余模块照常运行;全部模块失败才回落默认模板。
  • 没有任何模块定义某个入口 → 该入口不安装,对应事件零开销跳过(与单脚本时代"未定义回调"一致)。

单个模块长什么样 ​

每个模块就是一段普通 Lua 源码。你按需定义入口函数(都是可选项),并在顶层用 enable_listen_* 声明你关心哪些事件(多模块下的监听语义见「声明 / 解除监听」):

lua
-- 顶层: 声明要监听的键/鼠标按钮/移动/滚轮 (不声明 → 对应回调永远不触发)
enable_listen_keys(0x13, 0x1E)              -- 监听 KEY_P、KEY_1 (HID 键码)
enable_listen_mouse_btn(0x00, 0x01)               -- 监听鼠标左键(0x00)、右键(0x01)
-- enable_listen_mouse_move()               -- 监听鼠标相对移动
-- enable_listen_mouse_wheel()              -- 监听鼠标滚轮

-- 脚本加载/重载时 & 配置/槽位变更时被调用: 在此缓存慢速值
function init()
    scale_x, scale_y = get_scale()
    view_speed_x, view_speed_y = get_view_speed()
end

-- 按键事件 (仅按下/松开边沿, 无重复)。return true = 拦截, false = 放行
function on_key(keycode, down)
    if keycode == 0x13 and down then
        local id = touch_down(0x3FFFFFFF, 0x3FFFFFFF)  -- 点屏幕中心
        touch_up(id)
    end
    return true
end

-- 鼠标按钮事件
function on_mouse_btn(button, down)
    return false
end

-- 鼠标相对移动 (dx,dy 为本次位移像素)。return true = 拦截 (dx,dy 归零)
function on_mouse_move(dx, dy)
    return false
end

-- 鼠标滚轮 (wheel 为本次滚动量)。return true = 拦截 (wheel 归零)
function on_mouse_wheel(wheel)
    return false
end

-- 手柄按键事件 (仅按下/松开边沿)。return true = 拦截, false = 放行
function on_gamepad_btn(code, pressed)
    return false
end

-- 周期回调 (主循环限频调用, 1000Hz)。dt_us = 距上次 tick 的微秒差
function tick(dt_us)
end

-- 自定义字符串事件 (来自 WebSocket / WebHID)
function on_custom_event(str)
    return false
end

-- 板载 BOOTSEL 按键事件 (按下/松开边沿, 无需声明监听)
function on_boot_button(pressed)
end

执行过程与生命周期 ​

时刻发生什么
项目装载 / 上传新项目 / 热重载VM 全新重建 → 按优先级逐个执行每个启用模块的顶层代码(各模块顶层 listen_* 在此生效)→ 安装聚合入口 → 自动调用一次各模块的 init()
收到键盘/鼠标事件 (边沿)core 去抖后,若该键在监听 bitmap 内 → 同步调用 on_key/on_mouse_btn(多模块按优先级,首个 true 拦截)
收到鼠标移动/滚轮若已(由任一模块)enable_listen_mouse_move/enable_listen_mouse_wheel → 每次移动/滚动同步调用 (非边沿, 连续触发)
主循环 (限频 0.1ms, core1)调用 tick(dt_us) (约 10kHz,所有模块都调用)
切换配置槽位 / 下发新配置慢速值 (scale/view_speed) 重算 → 再次调用 init() (VM 不重建, 全局保留)
写入存储将 RAM 里的整份项目镜像持久化到 Flash。写完不重启(RAM 内已生效;写 Flash 期间 USB 总线会短暂中断 ~ms 级)

要点:

  • init() 是 "(重新)缓存慢速值" 的统一钩子。把 get_scale/get_view_speed 的结果存进全局变量,后续在 on_key/tick 里直接读全局 (最快)。
  • 热重载 (上传新项目) = VM 重建,所有全局/状态清空后按优先级重新执行各模块。
  • on_key/on_mouse_btn 只在边沿触发 (按一次、松一次),不会按住反复触发。想 "按住期间持续动作",用 tick + 一个 down 标志。
  • on_mouse_move/on_mouse_wheel 不是边沿:只要有移动/滚动就会触发 (可能每毫秒多次)。别在里面做重活。
  • ⚠️ 调试完成后,一定记得点击 "写入存储" 按钮,否则项目会在重启后丢失。

按键体系(三套编号,必读) ​

固件里同时存在三套按键编号。混用是脚本出错最常见的原因——写任何涉及按键的脚本前,先确认自己在用哪一套。

本文档所有键值一律写作十六进制(0x 前缀),键盘、鼠标、手柄都一样。 只有 bit 位号(bit0)、槽位号、触点编号这类"位置/序号"用十进制。

编号格式谁用它
统一 16 位 CODE键盘 0x000–0x0FF、鼠标 0x100+按钮、手柄 0x200|按钮配置与映射、动作状态:get_code_map、get_code_action_state、is_code_down,以及 get_key_map_by_index() 返回的 code
各设备原始编号键盘 0x00–0xFE、鼠标 0x00–0x07、手柄 0x01–0x1B实时输入:on_key / on_mouse_btn / on_gamepad_btn、enable_listen_*、is_key_down / is_mouse_btn_down / is_gamepad_btn_down、input_*
宿主协议编号HID 键码 / 鼠标按钮位打到手机/电脑:dkb_key、dmo_btn

三种输入的 API 对照 ​

能力键盘鼠标手柄
声明监听enable_listen_keysenable_listen_mouse_btnenable_listen_gamepad_btn
事件回调on_key(code, down)on_mouse_btn(btn, down)on_gamepad_btn(code, down)
按住查询is_key_down(code)is_mouse_btn_down(btn)is_gamepad_btn_down(btn)
注入引擎input_keyboard(code, down)input_mouse_button(btn, down)input_gamepad_button(code, down)
查映射get_code_map(code)同左同左
动作状态get_code_action_state(code)同左同左

怎么选 ​

  • 事件回调永远按设备分:on_key / on_mouse_btn / on_gamepad_btn 各收各的原始编号,回调参数不是统一码。
  • 手里已经有统一码(从 get_key_map_by_index() 遍历来的、配置里的疾跑键/切换键)→ 用 is_code_down(code)。
  • 已经知道是哪个设备 → 用分设备接口,编号更直白:is_gamepad_btn_down(0x01) 比 is_code_down(0x201) 好读。

命名约定:接受统一 CODE 的接口带 *_code*(get_code_map / get_code_action_state / is_code_down);带 key / mouse / gamepad 的只收该设备自己的原始编号。 get_key_map() / get_action_state() 是改名前的兼容别名,与新名等价;新脚本请用新名。

常见错误 ​

写法结果
is_key_down(0x101)恒 false —— 它只收键盘键码
is_mouse_btn_down(0x101)恒 false —— 它只收 0x00–0x07
is_gamepad_btn_down(0x201)恒 false —— 它只收 0x01–0x1B
在 on_gamepad_btn 里把 code 直接传给 get_code_map查不到 —— 少了 0x200 前缀
用 dmo_btn(0x01) / dkb_key(0x101) 混统一码宿主编号是第三套,dmo_btn 只认 0x00–0x04、dkb_key 只认 HID 键码

具体键值表见后面的「键码与按钮编号」。


声明 / 解除监听 ​

函数说明
enable_listen_keys(code1, code2, ...)声明 on_key 要接收的 HID 键码 (0x00–0xFF, 可多个)。不声明则 on_key 不触发。 可多次调用 (累加)。每次脚本重载时清空。轮盘方向键 (默认 W/A/S/D, HID 0x1A/0x04/0x16/0x07) 与疾跑键 (默认 Shift 0xE1) 也不例外——想监听它们就得在这里声明。
disable_listen_keys(code1, ...)解除对指定键码的监听 (可多个);无参数则清空全部键监听。
enable_listen_mouse_btn(btn1, btn2, ...)声明 on_mouse_btn 要接收的鼠标按钮 (0x00–0x07)。语义同上。
disable_listen_mouse_btn(btn1, ...)解除对指定按钮的监听 (variadic);无参数则清空全部按钮监听。
enable_listen_mouse_move()开启 on_mouse_move 监听。
disable_listen_mouse_move()关闭 on_mouse_move 监听。
enable_listen_mouse_wheel()开启 on_mouse_wheel 监听。
disable_listen_mouse_wheel()关闭 on_mouse_wheel 监听。
enable_listen_gamepad_btn(code1, code2, ...)声明 on_gamepad_btn 要接收的手柄按键码 (0x01–0x1B, 可多个)。语义同上。
disable_listen_gamepad_btn(code1, ...)解除对指定手柄按键码的监听 (variadic);无参数则清空全部。

多模块语义(必读):

  • 监听位图是设备级、所有模块共享的,不按模块记账。任意一个模块 enable 了某类事件,该类事件就会进 VM,由聚合入口按优先级分给定义了对应函数的模块(没定义的不调用、零开销;没有任何模块定义则完全不进 VM)。
  • 惯例:在模块顶层 enable_listen_* 调用一次即可;disable_* 几乎用不到——绝大多数脚本只 enable、从不 disable。需要"临时不接管"时,用返回值表达(on_mouse_move 返回 false)比关监听更安全。
  • ⚠️ disable_* 同样是全局的:disable_listen_keys()(无参)会清空所有模块注册的键监听;disable_listen_keys(0x13) 也会解除所有模块对 0x13 的监听。不要在工具/库模块里随手 disable——那会静默关掉其它模块的功能。
  • 每次项目装载(上传/热重载)时所有监听清空,各模块需重新声明。

入口函数 ​

函数参数返回触发时机
init()无忽略脚本重载时 + 每次配置/槽位变更时。用于缓存慢速值。
on_key(keycode, down)keycode: HID 键码 (int); down: booltrue=拦截, false=放行仅对 enable_listen_keys 声明过的键、且状态变化 (边沿) 时。
on_mouse_btn(button, down)button: 0x00–0x07; down: bool同上仅对 enable_listen_mouse_btn 声明过的按钮、边沿。
on_mouse_move(dx, dy)dx,dy: 本次相对移动像素 (int, 可正可负)true=拦截 (dx/dy 归零)已 enable_listen_mouse_move 且有移动时。
on_mouse_wheel(wheel)wheel: 本次滚轮量 (int, 可正可负)true=拦截 (wheel 归零)已 enable_listen_mouse_wheel 且有滚动时。
on_gamepad_btn(code, pressed)code: 手柄按键码 (0x01–0x1B, 见 手柄按键码表); pressed: booltrue=拦截仅对 enable_listen_gamepad_btn 声明过的按键、边沿。
on_custom_event(str)str: 字符串, 最大 128 字节 (WS) / 60 字节 (HID)true=拦截外部通过 WebSocket (CMD 0x01) 或 HID CMD 0xFA 发送文本时触发。无需 enable_listen。
on_boot_button(pressed)pressed: bool (true=按下, false=松开)忽略板载 BOOTSEL 按键状态变化时触发 (固件消抖确认, 约 20–30ms 延迟)。无需 enable_listen, 无拦截语义, 仅定义了此函数的脚本才会收到事件。
on_touchpad(meta, dims, x0, y0, ..., x9, y9)见 触摸板忽略手柄触摸板快照:触点数据变化时整帧上报(固件逐字节去重,静止不调用);拔出补发一帧全零。无需 enable_listen, 无拦截语义。
tick(dt_us)dt_us: 距上次 tick 的微秒差忽略主循环限频调用, 约 10kHz (0.1ms 一次)。

拦截语义 (重要):

  • on_key/on_mouse_btn 返回 true → core 跳过对该键的全部默认处理 (包括「修饰键+数字行 1~9」切槽热键、鼠标切换键、WASD 轮盘、配置映射)。返回 false → Lua处理完毕后,交给映射继续处理原始逻辑。
  • 多模块下按优先级依次调用:首个返回 true 的模块拦截后,排在后面的模块和 core 默认处理都不再进行;想让后面的模块也看到事件就返回 false。广播事件(init/tick/on_touchpad/on_boot_button)不受返回值影响,每个模块都调用。
  • 轮盘方向键(以及疾跑键)也走 on_key:映射配置里「轮盘方向键」(WHEEL.WASD) 那四个键 (默认 W/A/S/D) 并不是被 core 提前吃掉的——它们和其它键一样先交给 Lua,on_key 返回 true 时轮盘不会动。反过来, 想让脚本看到它们同样必须 enable_listen_keys(...) 声明 (不声明就收不到)。默认模板只声明了 KEY_P/KEY_1/KEY_2, 所以"按 W 脚本没反应"通常是漏了这一步, 而不是被轮盘拦截。
  • on_mouse_move/on_mouse_wheel 返回 true → 对应位移量清零。移动和滚轮分开判定: 只拦截 on_mouse_move 不影响同帧滚轮,反之亦然。

触摸输出 ​

函数说明
id = touch_down(x, y)在触摸坐标 (x,y) 按下一个新触点,返回触点 id (整数)。无空闲触点时返回 0xFF。
id = touch_move(id, x, y)把触点 id 移到 (x,y),返回 id。
touch_up(id)抬起触点 id。

触点最多 12 个。用完务必 touch_up 释放,否则 touch_down 返回 0xFF。建议为每个按键维护独立触点 id。


状态 Getter ​

函数返回冷/热用法
get_scale()scale_x, scale_y慢 (配置/槽位变才变)在 init() 里缓存进全局。像素→触摸缩放因子。
get_view_speed()view_speed_x, view_speed_y慢鼠标控制视角的灵敏度=触摸缩放因子x用户自定义的视角灵敏度。
get_vmouse()x, y, show, down热 (每次鼠标移动都变)随用随取,别缓存。x,y = 虚拟光标当前屏幕像素坐标。
get_screen_size()w, h慢当前配置的屏幕尺寸 (像素)。

映射开关 / 输入状态 ​

函数返回 / 参数说明
is_map_on()bool映射是否开启。
set_map_on(on)on: bool设置映射开关。切换时自动清理触摸、复位视角/轮盘/vmouse。
is_key_down(keycode)bool某键当前是否按下;支持修饰键 (0xE0–0xE7) 与普通键。持续检测建议用 on_key 事件而非轮询。
is_mouse_btn_down(btn)bool某鼠标按键 (0x00–0x07) 当前是否按下。
is_gamepad_btn_down(btn)bool某手柄按键 (0x01–0x1B) 当前是否按下,编号与 on_gamepad_btn / input_gamepad_button 一致。
is_code_down(code)bool按统一 16 位 CODE 查按下状态,跨设备通用(见下)。
get_now_ms()int32开机至今毫秒 (约 24.8 天回绕)。
get_rand_num(mini, max)int32[mini, max] 闭区间随机整数。

统一 16 位 CODE:0x000–0x0FF = 键盘键码,0x100+按钮 = 鼠标(右键 = 0x101),0x200|按钮 = 手柄(A = 0x201)。 三个 is_*_down 用各设备的原始编号(is_key_down(0x1A) 是 W,is_mouse_btn_down(0x01) 是右键,is_gamepad_btn_down(0x01) 是 A); is_code_down() 用统一码。手里是哪种编号就用对应的那个——配置与映射相关(get_code_map / get_key_map_by_index / get_code_action_state)一律是统一码。


按键映射查询 ​

查询当前配置中某个键的映射信息。坐标返回 触控坐标 (0..TOUCH_MAX),与 get_view_now_pos() / get_wheel_start_pos() 一致。

函数返回 / 参数说明
get_code_map(code)code: unified keycode (0x00–0xFE=HID 键盘键, 0x100+鼠标按钮, 0x200+手柄按键)idx, type, flags, coord_count, interval_count / 无映射返回 nil。type: 0=PRESS, 1=MULT_PRESS, 2=SMART_TOGGLE, 3=WHEEL, 4=CLICK, 5=AUTO_FIRE, 6=DRAG, 7=TOGGLE。flags 按 type 划分命名空间: SMART_TOGGLE bit0=RELEASE_MOUSE, bit1=SEPARAT, bit2=TOUCH; WHEEL bit0=松开后关闭映射 (RELEASE_MAP_OFF)。
get_key_map_by_index(idx)idx: 直接 key_maps 索引 (0-63)code, type, flags, coord_count, interval_count, coord_start, interval_start / 越界返回 nil。用于遍历全部映射。
get_key_coord(key_map_idx, step)映射索引 + 步号 (0-based)x, y, r_px (触控坐标 + 像素级随机半径) / 越界返回三个 nil。
get_key_interval(key_map_idx, step)映射索引 + 步号 (0-based)interval_ms (毫秒) / 越界返回 nil。AUTO_FIRE 时 step=0=on_ms, step=1=off_ms。

命名约定:接受统一 16 位 CODE 的接口一律用 *_code_* / *_code(get_code_map / get_code_action_state / is_code_down), 带 key / mouse / gamepad 字样的接口只收该设备自己的原始编号。get_key_map() 是 get_code_map() 的旧名,作为兼容别名保留。 另外 get_key_map_by_index / get_key_coord / get_key_interval 里的 key_map 指映射条目(不是键盘),它们按索引取值、与设备无关。

典型用法: 由 get_code_map(code) 拿到 coord_count/interval_count, 再用 get_key_coord(idx,i) / get_key_interval(idx,i) 逐条读出。

lua
-- 查看 KEY_P (0x13) 的映射详情
local idx, typ, flags, ccnt, icnt = get_code_map(0x13)
if idx then
    print(string.format("P: idx=%d type=%d flags=%d", idx, typ, flags))
    for i = 0, ccnt - 1 do
        local x, y, r = get_key_coord(idx, i)
        print(string.format("  coord[%d]: x=%d y=%d r_px=%d", i, x, y, r))
    end
end

-- 遍历全部映射
for i = 0, 63 do
    local code, typ = get_key_map_by_index(i)
    if code and code ~= 0 then
        print(string.format("idx=%d code=0x%x type=%d", i, code, typ))
    end
end

动作运行状态 ​

查询映射引擎此刻正在执行的按键动作(运行态,不是配置,也不是输入状态)。

用途:判断 TOGGLE / 连发 / 自动释放这类动作的触点是否按着。注意 is_key_down() 只反映按键本身—— TOGGLE 松开按键后触点照样保持,两者并不等价。

函数参数说明
get_code_action_state(code)code: 统一 16 位 CODEstate, type, touch_ids, step, total, timer_us / 该键当前没有进行中的动作返回 nil。旧名 get_action_state() 作为兼容别名保留。

code 编法与 get_code_map() 一致:0x000-0x0FF = 键盘键码,0x100+按钮 = 鼠标键(右键 = 0x101),0x200+按钮 = 手柄键。

字段含义
state1=ACTIVE(动作推进中;TOGGLE 下即"触点保持按下")、2=HELD(等松键)、3=RELEASING(反向释放中)
typeACTION_* 编号,与 get_code_map() 的 type 同口径
touch_ids位图,bit N = 本动作正持有触点 N;非 0 即"触点按着"
step / total分段动作(MULT_PRESS / DRAG / AUTO_FIRE / SHORT_LONG)的进度
timer_us剩余计时(微秒),无计时为 0

该查询与映射引擎同核(core1),在 on_key / on_mouse_btn / tick 里直接调用即可,无需加锁。 想做"按住期间持续动作"就用 tick 轮询它;只关心边沿则在自己的 on_* 里查一次。

lua
local st, typ, touches = get_code_action_state(0x101)  -- 右键绑定的 TOGGLE
if st == nil then
    print("该键没有进行中的动作")
elseif touches ~= 0 then
    print("触点按着", string.format("0x%X", touches))
end

输入注入 ​

从 Lua 主动向映射引擎注入键盘/鼠标事件,走完整的 core 映射管线 (键状态去重、按键映射、触屏输出等),效果等同于真实 USB 外设产生的输入。注入期间跳过 Lua 拦截 (on_key/on_mouse_btn 等不会触发),防止递归死循环。

函数参数说明
input_keyboard(keycode, down)keycode: HID 键码 (0x00–0xFE); down: bool注入键盘按下/释放。keycode 为 0x00 或 >0xFE 则静默忽略。
input_mouse_button(button, down)button: 0x00–0x07; down: bool注入鼠标按键。button 越界静默忽略。
input_mouse_move(dx, dy[, wheel])dx,dy: 相对位移 (像素); wheel: 滚轮 (默认 0)注入鼠标移动/滚轮。效果取决于 map_on: 映射开→移动视角; 映射关→移动 vmouse。
input_gamepad_button(code, down)code: 手柄按键码 (0x01–0x1B, 见 手柄按键码表); down: bool注入手柄按键。code 越界 (≤0 或 >0x1B) 静默忽略。

注意: 注入的键事件会真实修改键状态 bitmap (key_state[])。若注入 input_keyboard(0x15, true) 后未注入对应的 false,映射引擎将认为该键一直处于按下状态,导致动作卡在 HOLD 阶段。

lua
-- 注入一次 R 键按下并释放
input_keyboard(0x15, true)   -- KEY_R down
input_keyboard(0x15, false)  -- KEY_R up

-- 注入鼠标左键点击
input_mouse_button(0x00, true)
input_mouse_button(0x00, false)

-- 注入鼠标向右移动 100 像素
input_mouse_move(100, 0)

键盘输出(打到手机/电脑) ​

dkb_ = direct keyboard(直接键盘输出)。与上面的 input_keyboard 不同:这一组不经过映射引擎,直接把按键作为一台 USB 键盘发给手机/电脑。 需要先在「参数配置 → 键盘直通」打开并重启设备(dkb_is_on() 可查询当前是否可用;开关关闭时这些函数不报错,只是什么都不做)。

直通输出不受「映射开关」影响:map_on 只决定物理键鼠是走触屏映射、还是(开关打开时)直通给上位机;dkb_* 任何时候都照发。

函数参数说明
dkb_key(keycode, down)keycode: 0x04–0x73 或 0xE0–0xE7; down: bool按下 / 抬起一个键。修饰键(0xE0–0xE7)同样适用。范围外的键码会被忽略。
dkb_release_all()无抬起所有键与修饰键。脚本异常或退出前调用可防"卡键"。
dkb_led()无读手机/电脑写回的键盘灯状态:bit0 Num、bit1 Caps、bit2 Scroll、bit3 Compose、bit4 Kana。
dkb_is_on()无键盘输出是否已启用(开关打开且设备已重启后为 true)。
dkb_state()无一次拿到整份按键状态,见下(返回 4 个整数)。
dkb_is_down(keycode)keycode: 同上该键当前是否按下(true/false)。范围同 dkb_key。

键码范围:只支持键盘区的 0x04–0x73(字母、数字、F1–F24、方向键、编辑键等)与修饰键 0xE0–0xE7。 媒体键等更靠后的键码不支持,会被忽略。

dkb_state() 的四个整数:拼起来是 16 字节(小端)——第 1 字节是修饰键位图(bit0 左Ctrl…bit7 右GUI), 第 2–15 字节是 112 位键位图,第 16 字节恒为 0。键 keycode(0x04–0x73)对应第 8 + (keycode - 0x04) 位; 修饰键 0xE0–0xE7 对应第 keycode - 0xE0 位。

lua
local words = {dkb_state()}          -- words[1]..words[4]
local n = 0x1A - 0x04                -- W 键
local w = words[(8 + n) // 32 + 1]   -- 落在哪个整数
local down = (w >> ((8 + n) % 32)) & 1 == 1

单独查一个键用 dkb_is_down() 更省事(Lua 5.4 支持 &、>> 等位运算)。

lua
-- 向手机输入 "Hi"(H=0x0B, i=0x0C,Shift 用修饰键 0xE1)
-- 按下与抬起必须自己配对;间隔太短可能被宿主吃掉,需要明显按下时长就分两步 + 协程 sleep
if dkb_is_on() then
    dkb_key(0xE1, true)   -- 左 Shift 按下
    dkb_key(0x0B, true)   -- H 按下
    dkb_key(0x0B, false)  -- H 抬起
    dkb_key(0xE1, false)  -- Shift 抬起
    dkb_key(0x0C, true); dkb_key(0x0C, false)   -- i
end

-- 组合键 Ctrl+C
dkb_key(0xE0, true); dkb_key(0x06, true); dkb_key(0x06, false); dkb_key(0xE0, false)

想要"按住 30ms 再松"这种时序,用「异步动作序列」里的协程: spawn(function() dkb_key(0x04, true); sleep_ms(30); dkb_key(0x04, false) end)。


鼠标直通(打到手机/电脑) ​

dmo_ = direct mouse output(直接鼠标输出)。与上面的 input_mouse_move 不同:这一组不经过映射引擎,直接把位移、滚轮、按键作为一台 USB 鼠标发给手机/电脑——脚本给多少像素光标就走多少(不乘"鼠标速度"倍率)。

只在「光标显示方式 → 直接鼠标」或「目标平台 → 苹果」(苹果模式下除「关闭」外的选择都按直接鼠标运行)下可用;切换后设备热重载 USB 生效。未启用时这些函数不报错,只是什么都不做。

函数参数说明
dmo_move(dx, dy[, wheel])相对位移(像素,正 dx = 右、正 dy = 下);wheel 缺省 0,正 = 上滚移动光标 / 滚动。纯滚动写 dmo_move(0, 0, -1)。
dmo_btn(button, down)button: 0x00=左 0x01=右 0x02=中 0x03=后退 0x04=前进按下 / 抬起一个鼠标键。
dmo_release_all()无抬起 dmo_ 按下过的所有键(插在设备上的物理鼠标不受影响)。
dmo_is_on()无鼠标直通是否可用。
dmo_state()无按键位图整数:bit0 左、bit1 右、bit2 中、bit3 后退、bit4 前进。
dmo_is_down(button)button: 0x00–0x04该鼠标键当前是否按下。

按键状态与物理鼠标各记一份:脚本按住的键不会被物理鼠标的同键操作带掉,反之亦然。按下与抬起同样要自己配对。

dmo_btn 只认 5 个真实鼠标键(0x00–0x04):滚轮伪键编号(0x05/0x06)在这里不接受,滚轮走 dmo_move 的第三个参数。 直通输出同样不受「映射开关」影响;dmo_* 任何时候都照发(未启用时除外)。

dkb_state() / dkb_is_down() / dmo_state() / dmo_is_down() 查的是发给手机/电脑的状态 (含直通进来的物理键鼠);想查"引擎收到了哪些键",用 is_key_down() / is_mouse_btn_down()。

lua
-- 光标右移 200 像素,再点一下左键
if dmo_is_on() then
    dmo_move(200, 0)
    dmo_btn(0x00, true)
    dmo_btn(0x00, false)
end

-- 滚轮下滚 3 格
dmo_move(0, 0, -3)

槽位操作 ​

函数返回说明
get_current_slot()int (0-8)当前激活的配置槽位索引。恒返回 0-8 有效值。
set_current_slot(idx)无切换到槽位 idx (0-8)。非法值或空槽时保持当前槽位不变;目标槽为当前槽时自动跳过。请求异步应用、无间隔限制,连续调用时每次都会实际切换(原子标志仅保留最新目标)。调用后立即 get_current_slot() 可能仍返回旧值(应用是异步的)。效果与槽位热键(修饰键 + 数字行 1~9,1 = 槽 0)一致。

临时叠加层 ​

叠加层临时使用另一个配置槽位的按键映射,不切换基础配置。坐标按当前基础层的坐标系解释;屏幕尺寸、视角、轮盘、陀螺仪等其它配置继续来自基础层。切换基础配置槽位时会清除叠加层。

函数返回说明
load_overlay(slot)无异步加载槽位 slot (0-8) 的键盘、鼠标按键和手柄按键映射,包括坐标、随机半径、间隔和动作参数。
clear_overlay()无异步清除叠加层;后续新按键使用基础层。已经开始的动作仍按按下时的来源层完成。
overlay_is_active()bool当前是否已应用叠加层。
overlay_get_slot()int (0-8) / nil当前叠加层的槽位索引;未启用时返回 nil。

对于每个新按键,叠加层映射优先;叠加层没有该按键时回退基础层。松开沿用按下时选中的映射。叠加层支持所有现有按键动作类型。请求异步执行,紧接 load_overlay() 查询状态可能仍读到旧状态。

空槽、无效槽位配置或运行时缓冲区繁忙时会拒绝加载,保留当前叠加层并在设备日志中记录原因。只有 clear_overlay() 会主动清除当前叠加层。加载槽位仍遵守设备激活限制。

示例:按住手柄 EXTRA1 时加载槽位索引 2,松开时清除叠加层。槽位索引为 0-8;如果你的“配置2”指从 1 开始数的第二个槽位,应使用索引 1。

lua
local EXTRA1 = 0x14
enable_listen_gamepad_btn(EXTRA1)

function on_gamepad_btn(code, pressed)
    if code ~= EXTRA1 then return false end
    if pressed then load_overlay(2) else clear_overlay() end
    return true
end

视角 (View) ​

坐标均为触控坐标 (0..TOUCH_MAX)。

函数说明
get_view_id() / set_view_id(id)当前视角触点 id (0xFF = 未触摸)。
get_view_now_pos() → x, y当前视角坐标。
move_view_offset(x, y)以偏移量移动视角。仅当 map_on 且已有视角触点时生效。建议使用方式为 鼠标移动 x (scaleX, yscaleY)。
get_view_start_pos() / set_view_start_pos(x, y)视角起始点。
get_view_speed() / set_view_speed(x, y)视角控制速度 (灵敏度)。
set_view_auto_center(on)视角越界时是否回到 start_pos。默认 true。
set_view_auto_release_timeout(ms)视角触点无移动多久 (毫秒) 后自动释放;0 = 永不自动释放。默认 400。
set_gyro_enable(on) / is_gyro_enable()陀螺仪总开关(默认开,运行时开关,重启恢复开)。关闭 = 全局屏蔽陀螺仪:视角辅助、生效时机、开镜/开火倍率、飞鼠全部不生效;打开后各按自己的配置生效(配置开关关着就还是关)。触摸板通道 on_touchpad 不受此开关影响。
get_gyro_state() → bitmap陀螺仪运行状态位图:bit0 总开关开|bit1 配置开关开|bit2 六轴在线|bit3 生效时机门内|bit4 开镜/开火倍率窗口(LT/RT 按住,任何模式)|bit5 视角模式工作中(映射开启且时机门内)|bit6 飞鼠就绪(切换键已绑定且已开启)|bit7 飞鼠工作中(映射关闭 + 飞鼠开 + 六轴在线)。视角(bit5)与飞鼠(bit7)由映射开关互斥;其余位是"就绪/窗口"状态而非工作状态。

虚拟光标 (vmouse) ​

坐标均为真实屏幕像素;触点 id 由内部管理,故封装为函数。

函数说明
get_vmouse_state() → show, down, x, yvmouse 状态。
set_vmouse_show(on) / set_vmouse_down(on)设置显示 / 按下。
move_vmouse_pos_offset(x, y)以偏移量移动 vmouse (x,y 为屏幕像素偏移,直接累加)。
move_vmouse_pos_target(x, y)移动 vmouse 到目标触控坐标 (内部 /scale 转为屏幕像素)。

轮盘 (Wheel) ​

中心为触控坐标,半径为归一化 float (相对屏幕宽)。

函数返回说明
get_wheel_start_pos()x, y轮盘中心坐标。
get_wheel_range()range, shift_range普通半径 / shift 疾跑半径。
get_wheel_shift_range_type()enable, hold_or_click, keep_on_releasedenable = 是否开启 shift 疾跑; hold_or_click = true 切换 (toggle) / false 按住 (hold); keep_on_released = 切换模式下松开 WASD 是否保持疾跑状态 (仅 toggle 模式有意义)。

调试输出 ​

函数说明
print(...)输出到 WebSocket 调试通道,带 [lua] 前缀。未连接 WS 客户端时丢弃。
warn(...)同 print,带 [lua warn] 前缀。

板载 RGB LED ​

控制设备面板上的 WS2812B RGB 指示灯。

函数说明
set_led(r, g, b)设置 LED 颜色 (每通道 0-255)。负数跳过该通道 (保持原值);正数 >255 自动钳位到 255。set_led(0,0,0) 等效熄灭。
led_off()熄灭 LED。

按通道部分更新:set_led 的三个参数分别对应红、绿、蓝。想只改一个通道,把另外两个传负数即可。

lua
-- 全通道设置
set_led(255, 0, 0)    -- 红
set_led(0, 255, 0)    -- 绿
set_led(0, 0, 255)    -- 蓝

-- 只改红色,绿蓝不变
set_led(255, -1, -1)

-- 只改绿色 (值 >255 自动钳位)
set_led(-1, 300, -1)

-- 改红和蓝,绿不变
set_led(128, -1, 64)

-- 熄灭两种方式
set_led(0, 0, 0)
led_off()

典型用途:用 LED 颜色指示脚本状态 (如 init() 亮绿、出错亮红、on_key 闪烁等)。


状态屏 (lcd_line) ​

设备外接的 ST7789 状态屏(240×240)底部是脚本自定义区(两行文本),用 lcd_line 设置内容:

函数说明
lcd_line(1, "文本")写第 1 行(主行,大字号)。
lcd_line(2, "文本")写第 2 行(次行,小字号)。
  • 超长自动截断;写入不是立即绘制,屏在下一次刷新扫描时更新(约 0.1~0.4 秒)。
  • 传空串清空该行。
  • 仅状态屏固件(--board lcd / lcd-dev 构建且屏幕正常点亮)提供;普通/蓝牙固件里没有这个函数(调用会报 attempt to call nil)。
  • 文本随整行一起重绘,频繁更新(如每帧改数字)没有意义,建议在状态变化时才写。
lua
function init()
    lcd_line(1, "压枪 M416 · 垂直 62")
    lcd_line(2, "配置:满配 M4")
end

坐标系与换算 ​

固件内部使用两套坐标体系:

屏幕像素 ​

get_screen_size() 返回手机实际分辨率 (如 1080×2400)。get_vmouse() 返回的光标坐标也是这个坐标系 → 范围 0..screen_w / 0..screen_h。

触控坐标 ​

touch_down/move/up、get_view_now_pos() 等使用触控坐标: 范围 0..0x7FFFFFFE 。无论手机分辨率多少,触控坐标范围永远不变。屏幕正中心永远是 0x3FFFFFFF (= 0x7FFFFFFE / 2)。

换算 ​

lua
-- 像素 → 触控 (乘 scale, 最常用)
touch_x = pixel_x * scale_x
touch_y = pixel_y * scale_y

-- 触控 → 像素 (除 scale, 少数 API 用到)
pixel_x = touch_x / scale_x

scale_x/scale_y 用 get_scale() 取,建议在 init() 缓存进全局。

各 API 坐标系速查 ​

API坐标系说明
get_vmouse() / get_vmouse_state()屏幕像素虚拟光标位置
move_vmouse_pos_offset屏幕像素偏移量直接累加
move_vmouse_pos_target屏幕像素传入坐标,直接移动光标到目标位置
touch_down/move/up触控坐标最终输出,必须用触控坐标
get_view_now_pos / move_view_offset触控坐标视角系统
get_view_start_pos / set_view_start_pos触控坐标视角起始点
get_wheel_start_pos触控坐标轮盘中心
get_key_coord触控坐标按键映射的坐标 (x,y,r_px)
get_screen_size()屏幕像素手机实际分辨率

示例:在手机屏幕中心点一下 ​

lua
function init()
    scale_x, scale_y = get_scale()
    sw, sh = get_screen_size()
end

-- 手机屏幕中心(像素) → 触控坐标
local cx_px = sw / 2    -- 如 540 (1080 宽屏)
local cy_px = sh / 2    -- 如 1200
local id = touch_down(cx_px * scale_x, cy_px * scale_y)
-- 等价于: touch_down(0x3FFFFFFF, 0x3FFFFFFF)
touch_up(id)

键码与按钮编号 ​

键盘键码 = 标准 USB HID Usage ID ​

键盘键码直接采用 USB HID Usage ID(Keyboard/Keypad 页),与标准 HID 键盘描述符逐字节一致,没有私有换算。常见锚点:

键码键码
A–Z0x04–0x1D1–9 / 00x1E–0x26 / 0x27
Enter / Esc / Backspace0x28 / 0x29 / 0x2ATab / Space0x2B / 0x2C
F1–F120x3A–0x45F13–F240x68–0x73
方向键 → ← ↓ ↑0x4F–0x52小键盘0x53–0x63
左/右 Ctrl0xE0 / 0xE4左/右 GUI0xE3 / 0xE7

修饰键按 Ctrl / Shift / Alt / GUI 顺序,左 0xE0–0xE3、右 0xE4–0xE7。

表中没列的键(- = [ ] \ ; ' , . /、CapsLock、Insert/Home/PageUp/Delete/End/PageDown、国际键等)按标准 HID 表取值即可,全部宏名见 defines.h。

唯一的例外:0xE8–0xFB 不是标准 HID,是本固件扩展的媒体键: 0xE8 播放/暂停、0xE9 停止、0xEA 上一曲、0xEB 下一曲、0xEC 弹出、0xED 音量+、0xEE 音量−、0xEF 静音、0xF0 WWW、0xF1 后退、0xF2 前进、0xF3 停止、0xF4 查找、0xF5/0xF6 滚轮上/下、0xF7 编辑、0xF8 睡眠、0xF9 咖啡、0xFA 刷新、0xFB 计算器。

鼠标按钮 (on_mouse_btn 的 button):

值按钮
0x00左键
0x01右键
0x02中键
0x03后退
0x04前进

手柄按键码 ​

码按键码按键
0x01A / Cross0x10十字键 上
0x02B / Circle0x11十字键 下
0x03X / Square0x12十字键 左
0x04Y / Triangle0x13十字键 右
0x05LB / L10x14–0x1BEXTRA_1–8
0x06RB / R1
0x07LT / L2
0x08RT / R2
0x09SELECT / Back / Share
0x0ASTART / Options
0x0BLS / L3 (左摇杆按下)
0x0CRS / R3 (右摇杆按下)
0x0DHOME / Guide / PS
0x0EMISC (触控板 / Mute)

三套编号的分工、API 对照与常见错误见前面的「按键体系」一节。 完整键码表见 HID 键码参考。


性能模型与规则 ​

  • on_key/on_mouse_btn 跑在输入热路径上 — 每次有按键/鼠标事件时调用,必须保持极快,不要在里面做重循环/大分配。
  • 只有声明过的键/按钮才进 VM (bitmap 预过滤),没声明零开销跳过。多模块下判据是"任一模块声明了该类事件 且 存在模块定义了对应入口"——两个条件都不满足时 VM 完全不进入。
  • 开启 on_mouse_move 的代价模型:开启后每次移动报文都进一次 VM(1kHz 鼠标 = 每秒 1000 次)。240MHz 下一次轻量回调(读参、做加减、调一次输出 API)约 5–15µs,1kHz 下约 1% core1 占用——正常使用完全无感;但不要在 handler 里建表/大循环,8kHz 高回报率鼠标下重 handler 会显著吃满 core1(手感变"发涩"而不是线性变慢)。
  • 慢/热分离: 慢速值 (scale、view_speed) 在 init() 缓存进全局,以减少每次调用的开销;热值 (vmouse) 随用随取。
  • 数字是 32 位 (固件设了 LUA_32BITS): 整数 int32、浮点 float32。别用超过 2^31 的整数。
  • 单核单线程 (引擎在 core1,同核串行): 脚本内无需考虑并发。
  • 没有阻塞 sleep: 回调必须立刻返回。如果需要使用sleep,用协程 (见下文)。
  • 单次进入 VM 有 10ms 墙钟预算: 固件每 2 万条指令轮询一次执行耗时,单次回调(含该次回调内恢复运行的所有协程分片)累计超过 10ms 即抛错中断,日志报 script exceeded 10000us step budget (infinite loop?)。正常回调只有几十~几百条指令(微秒级);协程每片都极短,长期运行不受影响。真正的死循环(如 while true do end)会在预算内被拦下。多模块下同一次事件的聚合调用算"一次进入 VM":该事件里所有模块的 handler 共享这 10ms——排在后面的模块可能被前面的重 handler 挤掉预算,别让任何单个回调做重活。
  • on_touchpad 自带固件级节流: 与上次分发的快照逐字节相同就不调用(手指静止零开销),但滑动时约等于手柄报文率(可达数百 Hz)——回调内只做差分/比较这类轻量计算,输出动作交给 input_* 或状态位,别在里面 print 每一帧。
  • 脚本大小 ≤ 64KB。

完整示例 ​

跑马灯 (LED 渐变循环) ​

利用 tick(dt_us) 驱动,在颜色之间平滑过渡(线性插值),每 1s 完成一次颜色切换。调节 MAX_BRIGHT 控制最大亮度。

lua
-- 跑马灯:红→绿→蓝 平滑渐变,每 1s 过渡完成
local COLORS = {
    {255, 0, 0},     -- 红
    {0, 255, 0},     -- 绿
    {0, 0, 255},     -- 蓝
}
local MAX_BRIGHT = 20     -- 最大亮度 (0-255),越小越暗

local idx = 1
local acc = 0
local TRANSITION = 1000000  -- 1s (微秒)
local cr, cg, cb = 0, 0, 0

function init()
    set_led(MAX_BRIGHT, 0, 0)
    cr, cg, cb = MAX_BRIGHT, 0, 0
end

function tick(dt_us)
    acc = acc + dt_us
    if acc >= TRANSITION then
        acc = acc - TRANSITION
        idx = idx + 1
        if idx > #COLORS then idx = 1 end
    end

    local t = acc / TRANSITION
    local prev = idx - 1
    if prev < 1 then prev = #COLORS end

    local r1 = math.floor(COLORS[prev][1] * MAX_BRIGHT / 255 + 0.5)
    local g1 = math.floor(COLORS[prev][2] * MAX_BRIGHT / 255 + 0.5)
    local b1 = math.floor(COLORS[prev][3] * MAX_BRIGHT / 255 + 0.5)
    local r2 = math.floor(COLORS[idx][1]  * MAX_BRIGHT / 255 + 0.5)
    local g2 = math.floor(COLORS[idx][2]  * MAX_BRIGHT / 255 + 0.5)
    local b2 = math.floor(COLORS[idx][3]  * MAX_BRIGHT / 255 + 0.5)

    local nr = math.floor(r1 + (r2 - r1) * t + 0.5)
    local ng = math.floor(g1 + (g2 - g1) * t + 0.5)
    local nb = math.floor(b1 + (b2 - b1) * t + 0.5)

    if nr ~= cr or ng ~= cg or nb ~= cb then
        cr, cg, cb = nr, ng, nb
        set_led(cr, cg, cb)
    end
end

提示:改 MAX_BRIGHT 调亮度,改 COLORS 表自定义颜色序列,改 TRANSITION 调过渡速度。相邻颜色之间线性插值,无跳变。

按键点击 (每键独立触点) ​

lua
enable_listen_keys(0x13, 0x1E, 0x1F)     -- KEY_P, KEY_1, KEY_2
local CENTER = 0x3FFFFFFF                  -- 屏幕中心 (触摸坐标)
local touches = {}                         -- [keycode] = touch_id

function on_key(keycode, down)
    if down then
        touches[keycode] = touch_down(CENTER, CENTER)
    else
        local id = touches[keycode]
        if id and id ~= 0xFF then
            touch_up(id)
            touches[keycode] = nil
        end
    end
    return true
end

连点 Autofire (按住 R → 每 50ms 在光标处点一下) ​

lua
enable_listen_keys(0x15)                  -- KEY_R
local firing = false
local acc = 0                              -- 距上次开火累计的微秒
local INTERVAL = 50000                     -- 50ms (微秒)

function init()
    scale_x, scale_y = get_scale()
end

function on_key(keycode, down)
    firing = down
    if down then acc = INTERVAL end        -- 按下即刻先点一次
    return true
end

function tick(dt_us)
    if not firing then return end
    acc = acc + dt_us
    if acc >= INTERVAL then
        acc = acc - INTERVAL
        local mx, my = get_vmouse()
        local id = touch_down(mx * scale_x, my * scale_y)
        if id ~= 0xFF then touch_up(id) end
    end
end

右键长按拖动 (触点跟随光标) ​

lua
enable_listen_mouse_btn(0x01)                    -- 鼠标右键
local drag = nil

function init()
    scale_x, scale_y = get_scale()
end

function on_mouse_btn(button, down)
    local mx, my = get_vmouse()
    if down then
        drag = touch_down(mx * scale_x, my * scale_y)
    elseif drag then
        touch_up(drag); drag = nil
    end
    return true
end

function tick(dt_us)
    if drag then
        local mx, my = get_vmouse()
        touch_move(drag, mx * scale_x, my * scale_y)
    end
end

接管鼠标移动 (中键拖动) ​

on_mouse_move 返回 true 会把本次 dx/dy 清零,core 的默认视角处理被跳过。下例:按住中键期间把移动转成 "在光标处拖动一个触点"。

lua
enable_listen_mouse_btn(0x02)         -- 中键
enable_listen_mouse_move()         -- 接管移动
local dragging = false
local drag_id  = 0xFF
local drag_x = 0
local drag_y = 0

function init()
    scale_x, scale_y = get_scale()
end

function on_mouse_btn(button, down)
    if button == 0x02 then
        dragging = down
        if not down and drag_id ~= 0xFF then
            touch_up(drag_id); drag_id = 0xFF
        end
    end
    return false
end

function on_mouse_move(dx, dy)
    if not dragging then return false end
    local mx, my = get_vmouse()
    if drag_id == 0xFF then
        drag_id = touch_down(mx * scale_x, my * scale_y)
        drag_x = mx * scale_x
        drag_y = my * scale_y
    else
        drag_x = drag_x + dx * scale_x
        drag_y = drag_y + dy * scale_y
        touch_move(drag_id,drag_x  , drag_y)
    end
    return true                    -- 吃掉本次移动
end

注意: 一旦 on_mouse_move 返回 true,core 的 vmouse 光标就不会随这次移动更新。若逻辑依赖光标位置,要么放行 (return false) 让 core 推进 vmouse,要么自行累加 dx/dy 维护坐标。

测量鼠标移动距离(按住左键拖动) ​

按住鼠标左键并拖动,松开后打印鼠标累计位移。可用于测量视角灵敏度。

lua
-- 测量鼠标移动距离脚本(监听鼠标左键)
enable_listen_mouse_btn(0x00)   -- 鼠标左键
enable_listen_mouse_move()

local recording = false
local total_dx = 0
local total_dy = 0

function init()
    recording = false
    total_dx = 0
    total_dy = 0
end

function on_mouse_btn(button, down)
    if button == 0x00 then
        if down and is_map_on() then
            recording = true
            total_dx = 0
            total_dy = 0
            print("测量开始(左键按住),移动鼠标...")
        elseif not down and recording then
            recording = false
            print(string.format(
                "测量结束 | 总位移: dx=%d, dy=%d",
                total_dx, total_dy
            ))
            total_dx = 0
            total_dy = 0
        end
    end
    return false   -- 放行左键,不影响正常操作(例如开火)
end

function on_mouse_move(dx, dy)
    if recording then
        total_dx = total_dx + dx
        total_dy = total_dy + dy
    end
    return false   -- 放行移动,不影响视角控制
end

采集压枪数据和压枪数据重放 ​

监听鼠标移动事件,并以100hz将鼠标移动打印在控制台

lua

enable_listen_mouse_btn(0x00)
enable_listen_mouse_move()

-- 全局状态变量
local is_recording = false   -- 记录开关状态
local total_dx = 0           -- 累计 X 轴位移
local total_dy = 0           -- 累计 Y 轴位移
local acc_us = 0             -- 时间累加器 (微秒)

local INTERVAL_US = 10000    -- 100Hz 对应的时间间隔:10,000 微秒 (10 毫秒)

-- 脚本初始化/热重载回调
function init()
    is_recording = false
    total_dx = 0
    total_dy = 0
    acc_us = 0
end

-- 鼠标按键事件 (边沿触发)
function on_mouse_btn(button, down)
    -- 仅响应鼠标左键 (button 0x00) 且需映射开启 (is_map_on)
    if button == 0x00 and is_map_on() then
        if down then
            -- 按下左键:开始记录并重置数据
            is_recording = true
            total_dx = 0
            total_dy = 0
            acc_us = 0
            print(">>> [压枪记录] 开始记录数据 (100Hz) <<<")
        else
            -- 松开左键:停止记录并打印最终汇总
            if is_recording then
                is_recording = false
                print(string.format(">>> [压枪记录] 停止记录 | 最终总位移 -> X: %d, Y: %d <<<", total_dx, total_dy))
            end
        end
    end
    
    -- 返回 false 放行按键,确保游戏内正常开火;若设为 true 会吃掉左键点击
    return false
end

-- 鼠标相对移动事件 (连续触发)
function on_mouse_move(dx, dy)
    -- 仅在记录开启状态下累加鼠标移动值
    if is_recording then
        total_dx = total_dx + dx
        total_dy = total_dy + dy
    end
    
    -- 返回 false 放行移动,确保视角能正常移动;若设为 true 视角会被卡住
    return false
end

-- 周期回调 (约 1000Hz 频率调用, dt_us 为距上次调用的微秒差)
function tick(dt_us)
    if not is_recording then return end

    acc_us = acc_us + dt_us
    -- 当时间累积达到 10ms (10,000µs) 时打印一次,实现 100Hz 打印频率
    if acc_us >= INTERVAL_US then
        acc_us = acc_us - INTERVAL_US
        print(string.format("[压枪数据] 累计总位移 -> dX: %d | dY: %d", total_dx, total_dy))
        total_dx = 0
        total_dy = 0
    end
end

将录制的鼠标位移数据,在按下左键的时候重放

lua

enable_listen_mouse_btn(0x00)
-- -----------------------------------------------------------------
-- 数据压缩区:一对一对填即可 [dx1, dy1,  dx2, dy2,  dx3, dy3, ...]
-- -----------------------------------------------------------------
local RECOIL_DATA = {
    0,1,1,8,1,11,1,7,3,20,2,19,1,24,3,30,2,34,3,42,3,46,3,57,1,52,2,60,2,63,2,66,3,67,3,68,3,70,2,71,3,72,3,70,3,69,4,71,4,68,4,67,4,66,4,66,3,66,3,65,2,65,2,66,0,66,1,65,1,64,2,64,1,25,3,101,1,62,1,62,0,61,-2,62,-1,61,-3,60,-2,61,-2,60,-1,61,-3,61,-3,62,-3,61,-3,62,-3,63,-2,62,-2,62,-2,62,-2,63,0,25,-1,100,-1,64,-1,64,-1,70,-2,61,0,58,-1,63,0,62,-1,63,0,63,1,65,1,66,0,65,0,66,1,66,1,66,0,66,2,67,2,59,3,71,2,63,3,63,2,63,3,64,2,61,2,60,1,60,1,60,2,60
}

local is_firing = false
local current_step = 1
local acc_us = 0
local INTERVAL_US = 10000 -- 10ms (100Hz)

local view_speed_x = 1
local view_speed_y = 1
function init()
    is_firing = false
    current_step = 1
    acc_us = 0
    view_speed_x , view_speed_y = get_view_speed()
end

function on_mouse_btn(button, down)
    if button == 0x00 and is_map_on() then
        if down then
            is_firing = true
            current_step = 1
            acc_us = 0
        else
            is_firing = false
        end
    end
    return false
end

function tick(dt_us)
    if not is_firing or #RECOIL_DATA == 0 then return end

    acc_us = acc_us + dt_us

    if acc_us >= INTERVAL_US then
        acc_us = acc_us - INTERVAL_US

        -- 自动按步骤计算出数组中 dx 和 dy 的位置
        local idx = (current_step - 1) * 2 + 1

        if idx <= #RECOIL_DATA then
            local dx = RECOIL_DATA[idx]
            local dy = RECOIL_DATA[idx + 1]
            move_view_offset(dx * view_speed_x , dy * view_speed_y )
            current_step = current_step + 1
        else
            -- 数据用尽后维持最后一帧的下压幅度
            local last_dx = RECOIL_DATA[#RECOIL_DATA - 1]
            local last_dy = RECOIL_DATA[#RECOIL_DATA]
            move_view_offset(last_dx * view_speed_x, last_dy * view_speed_y )
        end
    end
end

异步动作序列 (协程 coroutine) ​

引擎没有阻塞式 sleep。想写 "按下 → 等 50ms → 滑动 → 等 30ms → 抬起" 这种带时序的顺序逻辑,用固件内建的 spawn + sleep(协程):调度器已内建、全模块共享,不用再自己维护 tasks 表和 tick 里的排水循环。 例如vmouse模式下,右键将物品拖动到丢弃区域

lua
enable_listen_mouse_btn(0x01)                     -- 右键
local scale_x, scale_y = 1, 1

function init()
    scale_x, scale_y = get_scale()
end

function on_mouse_btn(button, down)
    if button == 0x01 and down then
        local mx, my = get_vmouse()             -- 快照按下瞬间的光标位置
        spawn(function()                        -- 内建全局:任何回调/init/顶层都能调
            local id = touch_down(mx * scale_x, my * scale_y)
            if id == 0xFF then return end       -- 没有空闲触点, 放弃
            sleep_ms(50)                        -- 停 50ms
            touch_move(id, 0x3FFFFFFF, 0x3FFFFFFF)  -- 滑到屏幕中心
            sleep_ms(30)                        -- 停 30ms
            touch_up(id)
        end)
    end
    return false
end

内建 API(固件自动注入,无需定义):

函数说明
spawn(fn)把函数包成协程入队,下个 tick 开始跑;报错会记日志 [lua_task] 模块名: ... 并移除,不影响其它任务
sleep_ms(ms)只能在 spawn 的协程里调用:暂停 ms 毫秒后继续
sleep_us(us)同上,单位微秒
sleep= sleep_ms 的别名(毫秒语义)

模式要点:

  • spawn(fn) 把任意 "动作序列函数" 变成异步任务;函数里用 sleep_ms/sleep_us 表达等待。带时序的顺序逻辑(连点、按住 N ms、逐字打字…)优先用异步,不要写成 tick 状态机。
  • sleep* 在回调/tick 里直接调用会报错("sleep 只能在 spawn 启动的协程里调用")——回调必须立刻返回。
  • 唤醒粒度 = tick 周期:实际等待向上取整到下一次调度,sleep_us(1) 不会真在 1µs 后继续。常规时序用 sleep_ms 就够。
  • 协程里每一步仍要轻量 — 别在一步里做重循环。等待外部条件时也一样:while not cond() do sleep_ms(10) end(每 10ms 醒来查一次),不要写无 sleep 的忙等——单次进入 VM 的 10ms 墙钟预算会被它烧穿。
  • 协程可以长期运行(宏循环这类),每片耗时极短即可;预算按"单次 tick 内所有分片累计"计,正常分片远碰不到。
  • 任务数上限 32。
  • coroutine 标准库可用 (create/resume/yield/status)。旧脚本自带 spawn/sleep 定义的(local 或全局)会遮蔽内建,行为不变——旧 µs 语义的自定义 sleep 继续按微秒等。

键鼠直通 (dkb_ / dmo_) ​

把 Pico 当成一台「键盘 + 鼠标」用:脚本直接在上位机(手机/电脑)上敲键、移动光标。与前面的触屏示例完全独立,不进触屏链路。

前置(面板里改完必须重启设备):

  • 键盘:「参数配置 → 键盘直通」打开
  • 鼠标:「参数配置 → 光标显示方式 → 直接鼠标」,或「PICO 系统配置 → 目标平台 → 苹果」

用法:在面板底部「发送文本到 Lua」输入下面任一指令(也可经 WebSocket CMD 0x01 发送):

指令效果
type:hello在上位机逐字打出 hello
click光标右移 100 像素后点一下左键
wheel:-3光标不动,向下滚 3 格
state回传一行直通状态(键盘 4 个位图整数 + 鼠标位图)
lua
-- ═══ 键鼠直通示例 ═══
-- 直通输出不经过映射引擎:这里的按键/位移不会变成触屏事件,也不受「映射开关」影响。

local TEXT_KEYS = {                       -- 演示用:字母/数字/空格/回车 (HID 键码)
    ["a"] = 0x04, ["b"] = 0x05, ["c"] = 0x06, ["d"] = 0x07, ["e"] = 0x08,
    ["f"] = 0x09, ["g"] = 0x0A, ["h"] = 0x0B, ["i"] = 0x0C, ["j"] = 0x0D,
    ["k"] = 0x0E, ["l"] = 0x0F, ["m"] = 0x10, ["n"] = 0x11, ["o"] = 0x12,
    ["p"] = 0x13, ["q"] = 0x14, ["r"] = 0x15, ["s"] = 0x16, ["t"] = 0x17,
    ["u"] = 0x18, ["v"] = 0x19, ["w"] = 0x1A, ["x"] = 0x1B, ["y"] = 0x1C,
    ["z"] = 0x1D, ["1"] = 0x1E, ["2"] = 0x1F, ["3"] = 0x20, ["4"] = 0x21,
    ["5"] = 0x22, ["6"] = 0x23, ["7"] = 0x24, ["8"] = 0x25, ["9"] = 0x26,
    ["0"] = 0x27, ["\r"] = 0x28, [" "] = 0x2C,
}

-- ── 协程调度(spawn/sleep_ms)为固件内建,见上一节「异步动作序列」──

-- ── 逐字打字:按下 → 停 15ms → 抬起 → 停 15ms ──
local function type_text(s)
    spawn(function()
        for i = 1, #s do
            local k = TEXT_KEYS[s:sub(i, i)]
            if k then
                dkb_key(k, true)
                sleep_ms(15)
                dkb_key(k, false)
                sleep_ms(15)
            end
        end
        dkb_release_all()                 -- 兜底: 中途出错也不会在上位机卡键
    end)
end

-- ── 移动 + 单击 ──
local function click_at_offset(dx, dy)
    spawn(function()
        dmo_move(dx, dy)
        sleep_ms(20)                      -- 等光标到位再点
        dmo_btn(0x00, true)                  -- 0 = 左键
        sleep_ms(30)                      -- 按住 30ms, 太短会被上位机忽略
        dmo_btn(0x00, false)
    end)
end

function on_custom_event(str)
    local cmd, arg = string.match(str, "^([%a_]+):?(.*)$")

    if cmd == "type" then
        if not dkb_is_on() then
            warn("键盘直通未启用: 请在「参数配置 → 键盘直通」打开后重启设备")
            return true
        end
        type_text(arg)

    elseif cmd == "click" then
        if not dmo_is_on() then
            warn("鼠标直通未启用: 请选「光标显示方式 → 直接鼠标」或「目标平台 → 苹果」后重启设备")
            return true
        end
        click_at_offset(100, 0)

    elseif cmd == "wheel" then
        dmo_move(0, 0, tonumber(arg) or -1)     -- 正 = 上滚

    elseif cmd == "state" then
        local w = { dkb_state() }               -- 4 个整数 = 16 字节小端位图
        print(string.format("[直通] 键盘 %08X %08X %08X %08X | A键=%s | 鼠标位图=%d | 左键=%s",
            w[1], w[2], w[3], w[4],
            tostring(dkb_is_down(0x04)),        -- 0x04 = A
            dmo_state(),
            tostring(dmo_is_down(0x00))))
    else
        warn("unknown cmd: " .. str)
    end
    return true
end

模式要点:

  • 两个直通开关不归脚本管:脚本只能用 dkb_is_on() / dmo_is_on() 查询;未启用时这些函数不报错,什么都不做(所以示例里先查再发,并在日志里说明原因)。
  • 按键必须自己配对按下/抬起(没有 *_tap);要时序就照上一节的协程写法。dkb_release_all() / dmo_release_all() 适合放在序列末尾兜底。
  • 物理键鼠与脚本各记一份按键状态:直通开关打开时,插在设备上的物理键盘也会直通给上位机(映射关闭时),但这与脚本按下的键互不干扰。
  • dkb_state() / dkb_is_down() / dmo_state() / dmo_is_down() 查的是发给上位机的状态(含直通进来的物理键鼠);查"引擎收到了哪些键"用 is_key_down() / is_mouse_btn_down()。

外部指令控制 (on_custom_event + 参数解码 + print 回传) ​

on_custom_event 是与外界双向通信的核心入口。外部可通过 WebSocket 或 HID 发送文本指令,脚本解码参数执行,再用 print() 回传结果。无需 enable_listen — 只要定义了 on_custom_event 就会触发。

lua
-- 指令格式:
--   "tap(540,1200)"           → 在 (540,1200) 点一下
--   "swipe(100,800,900,800)"  → 从 (100,800) 滑到 (900,800)
--   "getpos"                  → 返回当前 vmouse 位置

function init()
    scale_x, scale_y = get_scale()
end

function on_custom_event(str)
    -- 解码 "tap(x,y)" 指令
    local sx, sy = str:match("tap%((%d+),(%d+)%)")
    if sx then
        local x, y = tonumber(sx), tonumber(sy)
        local id = touch_down(x * scale_x, y * scale_y)
        if id ~= 0xFF then
            touch_up(id)
            print("tap ok: (" .. x .. "," .. y .. ")")
        else
            warn("tap fail: no free touch point")
        end
        return true
    end

    -- 解码 "swipe(x1,y1,x2,y2)" 指令
    local x1, y1, x2, y2 = str:match("swipe%((%d+),(%d+),(%d+),(%d+)%)")
    if x1 then
        local id = touch_down(tonumber(x1) * scale_x, tonumber(y1) * scale_y)
        if id == 0xFF then
            warn("swipe fail: no free touch point")
            return true
        end
        touch_move(id, tonumber(x2) * scale_x, tonumber(y2) * scale_y)
        touch_up(id)
        print("swipe ok: (" .. x1 .. "," .. y1 .. ") -> (" .. x2 .. "," .. y2 .. ")")
        return true
    end

    -- 查询指令 "getpos"
    if str == "getpos" then
        local mx, my, show, down = get_vmouse()
        print("vmouse: x=" .. mx .. " y=" .. my .. " show=" .. tostring(show) .. " down=" .. tostring(down))
        return true
    end

    warn("unknown cmd: " .. str)
    return true
end

发送端示例:

bash
# WebSocket CLI
echo -ne '\x01tap(540,1200)' | websocat ws://192.168.73.1:80/ws
# HID OUT 帧格式 (CMD 0xFA)
[55 AA][0D][FA]tap(540,1200)

WS 路径最大 128 字节,HID 路径最大 60 字节。超出截断。

BOOTSEL 按键 (on_boot_button) ​

板载 BOOTSEL 按键可作为脚本输入:短按/长按识别、无外接按键实现运行时切换。on_boot_button 是按下/松开边沿触发,"按住期间"的状态要用 tick + 计时标志。

lua
-- 短按 BOOTSEL → 循环切换配置槽位 (0-8);长按 1 秒 → LED 变红提示 (松开恢复)
local press_us  = nil     -- 已按下的持续时间 (µs), nil = 未按下
local long_fired = false

function on_boot_button(pressed)
    if pressed then
        press_us   = 0
        long_fired = false
    else
        if press_us and not long_fired then
            -- 短按: 切到下一个槽位, 效果同槽位热键(修饰键+数字行 1~9)
            set_current_slot((get_current_slot() + 1) % 9)
        end
        press_us = nil
        led_off()
    end
end

function tick(dt_us)
    if press_us then
        press_us = press_us + dt_us
        if press_us >= 1000000 and not long_fired then
            long_fired = true
            set_led(255, 0, 0)   -- 长按 1s: LED 变红作为反馈
        end
    end
end

事件由 core0 消抖后经 IPC 投递 (约 20–30ms 延迟),按下与松开各触发一次 on_boot_button。无需 enable_listen — 只要定义了此函数就会触发。

触摸板 (on_touchpad) ​

手柄触摸板(DS5 / DS Edge 及将来的触摸板设备)的瞬时快照通道:每次触点数据变化时调用一次,上报全部槽位的当前状态。无需 enable_listen(未定义此函数时零开销),无拦截语义(返回值被忽略)。

lua
-- meta 位域:bit0-9 = 触点 i 在位|bit10-13 = 设备最大点数|bit14-19 = 来源枚举(0=DS5/DS Edge)|其余保留
-- dims 位域:bit0-15 = 宽 W|bit16-31 = 高 H(DS5 为 1920 | 943<<16)
-- x0..y9   : 各槽位坐标,**触摸板原始分辨率**(固件零换算,宽高比保真);
--            无效槽位恒 0。设备拔出时补发一帧全零快照("设备已离开"信号)。
function on_touchpad(meta, dims, x0, y0, x1, y1, x2, y2, x3, y3,
                                  x4, y4, x5, y5, x6, y6, x7, y7, x8, y8, x9, y9) end

说明:

  • 固件对快照逐字节去重:与上次分发的快照完全相同就不调用——手指静止时不会刷屏;反过来,滑动的每一帧都会进来(约等于手柄报文率),回调里保持轻量。
  • 脚本可以只声明需要的参数(尾部多余的槽位实参被 Lua 自动丢弃),要全收就用 ...。
  • 触摸板的物理按压(Pad Click)不在此通道——它走 on_gamepad_btn 的 BTN_MISC (0x0E)。
  • 调试建议:先用 print(string.format("%d %d %d,%d", meta, dims, x0, y0)) 看原始流,确认去重与坐标行为后再往上叠逻辑。

例 1:光标控制(等同鼠标输入) ​

按槽位差分出位移,注入 input_mouse_move——映射关闭时移动虚拟光标,映射开启时移动视角(与真实鼠标同管线):

lua
-- 触摸板 → 相对鼠标(笔记本触摸板手感)。DS5 有两个槽位,这里只用槽 0。
local prev_x, prev_y = nil, nil
local SENS = 3            -- 灵敏度倍率(原始分辨率 1920×943 的坐标差直接乘)

function on_touchpad(meta, dims, x0, y0)
    if meta & 0x1 ~= 0 then                   -- 槽 0 有手指
        if prev_x then
            input_mouse_move((x0 - prev_x) * SENS, (y0 - prev_y) * SENS)
        end
        prev_x, prev_y = x0, y0
    else
        prev_x, prev_y = nil, nil             -- 抬起:清基线,防止下次落指跳变
    end
end

例 2:双指捏合 / 张开(简单手势) ​

手势开始记两指距离,任一指抬起时比较起止距离——一个手势只报一次,不用计时器:

lua
-- 距离用平方值即可比大小;阈值"×4"= 距离拉大一倍 / 缩小到一半
local d0, d1, seeing = 0, 0, false

function on_touchpad(meta, dims, x0, y0, x1, y1)
    if meta & 0x3 == 0x3 then                 -- 两指都在
        local dx, dy = x0 - x1, y0 - y1
        local d = dx * dx + dy * dy
        if not seeing then d0 = d end         -- 手势开始时的距离
        d1 = d
        seeing = true
    elseif seeing then                        -- 任一指抬起 = 手势结束
        if d1 > d0 * 4 then
            print("张开")                     -- 这里换成任意动作
        elseif d1 * 4 < d0 then
            print("捏合")
        end
        seeing = false
    end
end

例 3:触摸板观测(两个坐标 + 触摸板按压) ​

最基础的调参脚本:触点坐标和物理按压直接打印到日志页,粘贴即可运行:

lua
-- 触点坐标:固件已去重,变化即来;全部抬起时打印一次"抬起"
function on_touchpad(meta, dims, x0, y0, x1, y1)
    local c = meta & 0x3FF
    if c == 0 then print("TP 抬起")
    elseif c == 1 then print(string.format("P1=(%d,%d)", x0, y0))
    elseif c == 2 then print(string.format("P2=(%d,%d)", x1, y1))
    else print(string.format("P1=(%d,%d)  P2=(%d,%d)", x0, y0, x1, y1))
    end
end

-- 触摸板按压(Pad Click)是手柄按键 BTN_MISC(0x0E),走按键边沿
enable_listen_gamepad_btn(0x0E)
function on_gamepad_btn(code, pressed)
    if code == 0x0E then print(pressed and "TP 按下" or "TP 松开") end
end

空模板 ​

lua
function init() end
function on_key(keycode, down) return false end
function on_mouse_btn(button, down) return false end
function on_custom_event(str) return false end
function on_boot_button(pressed) end
function tick(dt_us) end

上传与调试 ​

上传项目 ​

面板「Lua 工作台」里编辑的是整份多模块项目:

  • 点击加载到内存 → 整份项目经 POST /lua/fs/image 一次性写入设备 RAM,立即热重载生效;
  • 调试完成后,点击写入存储 → POST /lua/save 把 RAM 镜像持久化到 flash(不重启,RAM 内早已生效),断电不丢。

HTTP 层面对应:

端点说明
GET /lua/fs/image读回当前 RAM 里的整份项目镜像(二进制)
POST /lua/fs/image整镜像流式写入并热重载(未激活 403)
POST /lua/save把 RAM 镜像持久化到 flash
GET /lua/infoflash 存储元信息(kind=fs 为多模块格式)
GET /lua/get、POST /lua/set旧单脚本接口已退役:恒返回 410 Gone,请改用 /lua/fs/image

查看状态 ​

GET /lua/state 返回运行状态:

  • loaded=1 source=modules modules=3 → 多模块项目装载成功(modules= 实际装载的模块数)。
  • loaded=0 source=default error=... → 全部模块失败,设备回落默认模板;error= 是首个失败的报错(文本自带 模块.lua:行号,面板日志里可点击跳转)。
  • entry=on_key tick ... → 聚合后实际安装的入口函数 (确认函数名没写错)。

运行期日志 ​

脚本里的 print(...) / warn(...) 统一经 WebSocket 调试通道下发。连接 WS 客户端即可实时查看。print 带 [lua] 前缀,warn 带 [lua warn] 前缀。多模块下 print 不带模块名——建议脚本自己加前缀区分来源(如 print("[aim] ..."));报错则自带 模块.lua:行号。

运行期日志 ​

脚本里的 print(...) / warn(...) 统一经 WebSocket 调试通道下发。连接 WS 客户端即可实时查看。print 带 [lua] 前缀,warn 带 [lua warn] 前缀。

双向通信: print/warn 是脚本主动向主机推送消息的唯一通道。配合 on_custom_event 接收指令,可构建 "主机发指令 → Lua 执行 → print 回报结果" 的完整交互。


常见坑 ​

  • 定义了 on_key 却没 enable_listen_keys → 永远不触发。必须声明监听。
  • 在工具/库模块里随手 disable_listen_* → 监听位图是全局的,会把其它模块注册的监听一起清掉(无参清空全部)。只 enable、基本不 disable;想"不接管"就用返回 false 表达。
  • 以为模块之间能互相看到函数 → 每个模块独立 _ENV,顶层 local/函数/全局写都在自己模块里;共享要显式 _G.xxx = ...,读穿透 _G。
  • 重模块挤压轻模块 → 同一次事件的聚合调用共享 10ms VM 预算,某个模块的回调做重活会挤压同事件里排在后面的模块。
  • print 分不清是哪个模块说的 → 输出不带模块名,自己加前缀;报错自带 模块.lua:行号。
  • 以为轮盘方向键 (默认 W/A/S/D) 被"提前吃掉" → 不是。这四个键与疾跑键和其它键完全一样:先进 on_key,返回 false 才轮到 core 驱动轮盘 (顺序见「拦截语义」)。脚本收不到它们,只可能是漏了 enable_listen_keys(0x1A, 0x04, 0x16, 0x07);反过来 on_key 返回 true 时轮盘不会动。
  • 把缓存值写成 init() 内部的 local → on_key/tick 看不到。要写全局或文件顶层 local。
  • 忘了 touch_up → 触点 (最多 10) 很快耗尽,touch_down 返回 0xFF。
  • 在 on_key/tick 里做重活 → 输入卡顿。
  • 当成 64 位整数用 → 实际 32 位,大数溢出。
  • 想"按住持续触发"却只写 on_key → 边沿只触发一次;用 tick + down 标志。
  • 多个键共用一个触点变量 → 会串。用 touches[keycode] 表分开管理。
  • on_custom_event 里忘了 return true → core 虽无默认处理,但返回 false 表示 "未处理"。
  • 字符串超上限 → on_custom_event 的 str 会被截断 (WS:128B / HID:60B)。长指令用简短格式或拆分发送。
  • 手柄 SELECT+RS 组合键 → 同时按下 SELECT + RS (右摇杆按下) 会切换映射开关(与槽位切槽热键不同,这是开/关切换)。切换时虚拟光标自动显示/隐藏。此行为在 on_gamepad_btn 之前执行,Lua 无法拦截这个组合键。