# Lua 脚本 API

> 📥 **下载此API文档**：<a href="/api/lua-api.md" download>lua-api.md</a>

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_keys` | `enable_listen_mouse_btn` | `enable_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`: bool | `true`=拦截, `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`: bool | `true`=拦截 | 仅对 `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)` | 见 [触摸板](#触摸板-on-touchpad) | 忽略 | 手柄触摸板快照：触点数据**变化时整帧上报**（固件逐字节去重，静止不调用）；拔出补发一帧全零。**无需 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 位 CODE | `state, type, touch_ids, step, total, timer_us` / 该键当前**没有**进行中的动作返回 **nil**。旧名 `get_action_state()` 作为兼容别名保留。 |

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

| 字段 | 含义 |
|------|------|
| `state` | `1`=ACTIVE（动作推进中；**TOGGLE 下即"触点保持按下"**）、`2`=HELD（等松键）、`3`=RELEASING（反向释放中） |
| `type` | `ACTION_*` 编号，与 `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 再松"这种时序，用「[异步动作序列](#异步动作序列-协程-coroutine)」里的协程：
> `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, y` | vmouse 状态。 |
| `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_released` | `enable` = 是否开启 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–Z | `0x04`–`0x1D` | 1–9 / 0 | `0x1E`–`0x26` / `0x27` |
| Enter / Esc / Backspace | `0x28` / `0x29` / `0x2A` | Tab / Space | `0x2B` / `0x2C` |
| F1–F12 | `0x3A`–`0x45` | F13–F24 | `0x68`–`0x73` |
| 方向键 → ← ↓ ↑ | `0x4F`–`0x52` | 小键盘 | `0x53`–`0x63` |
| 左/右 Ctrl | `0xE0` / `0xE4` | 左/右 GUI | `0xE3` / `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` | 前进 |

### 手柄按键码

| 码 | 按键 | 码 | 按键 |
|----|------|----|------|
| `0x01` | A / Cross | `0x10` | 十字键 上 |
| `0x02` | B / Circle | `0x11` | 十字键 下 |
| `0x03` | X / Square | `0x12` | 十字键 左 |
| `0x04` | Y / Triangle | `0x13` | 十字键 右 |
| `0x05` | LB / L1 | `0x14`–`0x1B` | EXTRA_1–8 |
| `0x06` | RB / R1 | | |
| `0x07` | LT / L2 | | |
| `0x08` | RT / R2 | | |
| `0x09` | SELECT / Back / Share | | |
| `0x0A` | START / Options | | |
| `0x0B` | LS / L3 (左摇杆按下) | | |
| `0x0C` | RS / R3 (右摇杆按下) | | |
| `0x0D` | HOME / Guide / PS | | |
| `0x0E` | MISC (触控板 / Mute) | | |

> 三套编号的分工、API 对照与常见错误见前面的「按键体系」一节。
> 完整键码表见 [HID 键码参考](/api/hid-code)。

---

## 性能模型与规则

- **`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/info` | flash 存储元信息（`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 **无法拦截**这个组合键。
