Skip to main content
Glama
README.md
# dsh-houdini-bridge

把本机正在跑的 Houdini 接进 dsh:侧栏底部一个入口(用的是 `houdini.exe` 自己的图标),
点开就能看到连没连上、哪个 hip 开着、选中了什么、哪些节点在报错,并用一个「自动部署」按钮
把整条链路装好;输入框里 `@Houdini` 一下,就等于把"接下来这些话是对这个 Houdini 说的、
请用 MCP 工具真的去做"写进了每一轮上下文。

```
dsh Web GUI
   ├─ 侧栏面板(部署 + 状态)┐
   ├─ 输入框 @ 引用 ─────────┼── HTTP ──┐
   │                        │           │
   └─ mcp-client 插件 ── MCP · stdio ────┤
                                        │
                          dsh-houdini-bridge 宿主半侧 / houdini-mcp.mjs
                                        │  HTTP · 127.0.0.1:8765
                                        │
                                 houdini_link.py  (跑在 Houdini 进程里,零依赖)
                                        │  hdefereval
                                     hou 模块 —— 只能在主线程碰
```

为什么绕三层:`hou` 只能在 Houdini 进程内部 import,外面拿不到。为什么要有 `hdefereval` 那一跳:
`hou` 不是线程安全的,HTTP 请求跑在子线程,直接调 `hou.*` 会让 Houdini 崩。

## 组成

| 文件 | 位置 | 作用 |
|---|---|---|
| `index.js` | dsh 宿主进程 | 三条只读路由 + 一个部署动作路由(写 Houdini 目录与 profile patch) |
| `lib/client.js` | 浏览器 | 侧栏底部的入口 + 部署面板,以及输入框 `@` 菜单里的 Houdini 那一组 |
| `lib/setup.mjs` | 宿主 / CLI 共用 | 钩子与 patch 文本的生成与识别(纯字符串,可单测) |
| `houdini/houdini_link.py` | 拷进 Houdini 的 `scripts/python/` | 监听回环端口,把请求送回主线程执行 |
| `mcp/houdini-mcp.mjs` | 任意位置,dsh 会 spawn 它 | MCP 协议 ⇄ HTTP |
| `assets/houdini-icon.png` | 随包 | 从 `houdini.exe` 抽出来的那一枚图标(客户端已内联 base64) |
| `tools/extract-icon.mjs` | 开发用 | 从 exe 的 RT_ICON 资源抽图标 → PNG(见下面「图标」一节) |
| `tools/inject-icon.mjs` | 开发用 | 把 PNG 内联进 `lib/client.js`(换图标后重跑) |
| profile 的 `cordis.patch.yml` | `~/.dsh/profiles/web/` | 让 dsh 连上上面那个 MCP server |

**面板和 MCP 是分开的两件事**,故意如此:面板走插件自己的路由,MCP 走
`@deepseek-ai/dsh-mcp-client` 那一行。`serverName` 同一处挂两次会撞,所以插件里不挂 MCP。
好处是:MCP 那一行配错了,面板照样告诉你 Houdini 通不通。

## 装

```bash
dsh plugin --profile web add github:JUSTDOITzhw/dsh-houdini-bridge
```

正在改这个插件本身时用 `add link:<这个目录>` —— 源码目录直接进 profile,
改完客户端半侧约 1 秒自动生效,不用重装。

装完重启 dsh(**新增插件必须重启**,改已装插件的客户端代码才不用)。重启后打开
**侧栏底部 → Houdini → 自动部署**,下面三件事一次做完:

| 做什么 | 写到哪 | 为什么 |
|---|---|---|
| 装服务文件 | `<Houdini 用户目录>/scripts/python/dsh_houdini_link.py` | 让 `import dsh_houdini_link` 找得到(`scripts/python` 在 Houdini 的 Python 路径上) |
| 开启动自启 | `<Houdini 用户目录>/pythonX.Ylibs/pythonrc.py` **最前面**插一段钩子 | **Houdini 一启动就自动起服务**,不用每次手敲 |
| 挂 MCP | 装了本插件的 profile 的 `cordis.patch.yml` | 聊天里出现 `mcp__houdini__*` 工具 |

### `<Houdini 用户目录>` 是问出来的,不是猜出来的

按 `~/Documents/houdiniX.Y` 猜,在「文档」被重定向的机器上是死路 —— 本机就是这样:文档其实在
`D:\文档`,于是脚本被装进 `C:\Users\<用户>\Documents\houdini20.5` 这个 **Houdini 从来不读的
空壳目录**:面板显示"装好了",打开 Houdini 却永远连不上(心跳日志一条都没有)。现在按可信度
往下问四级(实现在 `lib/discover.mjs`):

1. 配置里写死的 `houdiniDirs` —— 硬指定,连问都不问;
2. 环境变量 `HOUDINI_USER_PREF_DIR`;
3. **注册表找到安装目录 → 用它自带的 `hython` 问 `$HOUDINI_USER_PREF_DIR`** —— 权威来源:
   连版本与内置 Python 版本一起问出来,于是 `houdiniX.Y`、`pythonX.Ylibs`、`scripts/python`
   全都不用再查表(手抄的对照表迟早会在某个新版本上过期);
4. 都问不到:Windows「已知文件夹」里的文档目录(**重定向也算进去**)
   → `~/Documents/houdiniX.Y` → `~/houdiniX.Y`,以及它们下面同主版本的 `houdini*` 目录。

**探到权威答案就只装那一个目录**,探不到才退回"存在的候选都装一份"。安装目录从
`HKLM\SOFTWARE\Side Effects Software` 来,拿不到就扫 `Program Files` / `/opt/hfs*` / PATH。
面板与 `install.mjs` 共用同一份逻辑,不会出现"命令行装对了、面板装错了"。

排障第一眼看面板「部署」卡片里的**来源**(`hython` / `env` / `documents` / `fallback`)——
它直接说明路径是怎么定下来的。想让它别去起 `hython`(CI、需要可复现的测试)就设
`DSH_HOUDINI_NO_DISCOVER=1`。

> ⚠️ **钩子必须落在 `pythonX.Ylibs/` 里**,不是用户目录根上的 `pythonrc.py`。Houdini 只在
> `pythonX.Ylibs/pythonrc.py` 里找启动脚本(`X.Y` = 它内置的 Python,20.5 是 `python3.11libs`);
> 放根上那份**根本不会被执行**。实测方式:把探针分别放到根上和 `python3.11libs/` 里跑
> `hython` 与 `houdinifx` —— 根上那份毫无动静,libs 里那份立刻生效。
> 所以插件按"用户目录里已有的 `python*.libs` → **问到的内置 Python 版本** → Houdini 版本查表
> → 兜底全写"四级决定落点;0.2 早期误写在根上的那份会在下次部署时**改名留档**
> (`pythonrc.py.bak-<时间>`,只在那个文件整个就是我们的钩子时才动它)。
>
> ⚠️ **钩子插在文件最前面**(只让开 shebang 与编码声明)。`pythonrc.py` 里任何一行抛错,
> **它后面**的代码就全部不执行 —— 追加在末尾等于把自己吊在别人那行绳子上。这条不是理论:
> 0.3.0 就栽过一次,文件头上有一行写文件的探针,探针的目录被删之后整份 `pythonrc.py`
> 从第一行崩起,钩子一次都没跑到,而面板还显示"启动自启已开"。

**钩子到底跑没跑:心跳**

每执行一次 `start_if_gui()` 就往系统临时目录的 `dsh-houdini-bridge-autostart.log` 追加一行:

```
2026-09-17T16:36:49	pid=12728	gui=False	result=skipped-not-gui	version=0.3.1
2026-09-17T16:39:28	pid=22020	gui=True	result=started	version=0.3.1
```

`result` 有四种:`started`(起了)/ `already`(本来就在跑)/ `skipped-not-gui`(命令行会话,
按设计跳过)/ `failed`(起失败)。面板「部署」卡片底部会把最近一条翻成人话,例如
「自启钩子上次执行:16:39 · 已起服务」。

**为什么值得单独记一份**:文件里有没有钩子只说明**写进去了**,心跳才说明**执行到了**。
两种失效在现场长得一模一样 —— 装好了、Houdini 开着、服务却不通 —— 只有这份日志能把
"没跑到"和"跑了没起来"分开。

每一步都是幂等的(内容没变就不写盘),每次改写前都会留一份带时间戳的 `.bak-<时间>`。
面板上还有三个单项按钮(只装服务文件 / 开关启动自启 / 挂或摘 MCP),想只做一半也行。

> dsh 的 patch 文件是**热加载**的(profile 里 `patchReload: live`),所以写完通常不用重启
> dsh,MCP 工具会自己出现。万一没出现,重启一次 dsh。

**手动装(不想要面板代劳时)**

```bash
node install.mjs --auto-start      # 装服务文件 + 写自启钩子(--dry-run 只看不写)
node install.mjs --patch           # 只打印要贴进 profile 的那段 YAML
```

然后在 Houdini 的 Python Shell 里。下面这行不依赖任何路径配置,最稳:

```python
exec(open(r"<这个目录>\houdini\houdini_link.py", encoding="utf-8").read())
start()
```

装的位置正好落在 Houdini 的搜索路径里,就可以短一点:`import dsh_houdini_link as L; L.start()`

> Houdini 的用户目录不一定是 `~/Documents/houdiniX.Y` —— 设了 `HOUDINI_USER_PREF_DIR`
> 就以那个为准。本机实测 `~/houdini20.5` 与 `~/Documents/houdini20.5` **同时存在**,
> 所以部署时存在的 `houdini<主版本>.*` 目录会**都装一份**(装一份不生效也不碍事,漏装才麻烦)。

## 面板

**侧栏底部**(在「设置」上面,和 GitHub 面板相邻):

```
◦ Houdini 未部署        ← 点了才展开
```

```
┌ Houdini 联动  v0.4.0                                    ×
│ 连接
│   已连接 · 20.5.370 · hero_shot_010.hip(未保存)· 帧 42 · 选中 2
│   127.0.0.1:8765 · 主线程 hdefereval · token 关 · pid 4242       [重新检测]
│ 部署
│   已部署(1 个 Houdini 目录 · MCP 已挂)
│   装到哪:问的是装好的 Houdini 自己 · Houdini 20.5 · 内置 Python 3.11
│   houdini20.5            模块 0.4.0 · 启动自启已开
│                          D:\文档\houdini20.5 → python3.11libs/pythonrc.py
│   dsh profile:web       MCP 已挂载
│   自启钩子上次执行:16:39 · 已起服务
│   [自动部署] [只装服务文件] [关掉启动自启] [摘掉 MCP]
│ 怎么用
│   自动部署做三件事:……
└
```

那一行的图标是 `houdini.exe` 自己那一枚(橙色方块 + 螺旋),不是手画的近似图形;
详见下面「图标」。

点有三种颜色:绿=连上了,灰=装好了但 Houdini 没开,橙=还没部署,红=宿主路由本身出错。

- 每 8 秒问一次,只为读几个标量;标签页在后台时不问。
- Houdini 没开时是灰点 + 一句「在 Houdini 里跑 start() 之后,127.0.0.1:8765 就会亮」,
  不弹窗、不变红抢注意力。
- 报错那块要遍历节点树,比概览贵一个数量级,所以是**打开面板时才拉**。
- 面板只读。改场景是 MCP 那几个工具的活;唯一会写盘的是「部署」那一区。

### 输入框里 `@Houdini`

打 `@`,菜单里就有一组 `Houdini`(`order: 1`,排在文件/会话之后、其它插件之前,
不用滚就能看到);打完 `@houdini`,别的组因为筛不到会自动消失,只剩这两行里属于我们的那条:

```
Houdini
  [图标] Houdini      20.5.445 · hero_shot_010.hip          ›
```

回车把它插成草稿里的一个 chip(`@` 完接着说要做什么就行);按 Tab 或点右边的 `›` 进下一层,
是一排常用起点:

```
Houdini · 常用
  [图标] 查看场景                    结构、选中与报错
  [图标] 搭节点                      创建并连好线
  [图标] 调参数                      改参数并说明改动
  [图标] 修报错                      定位并修掉节点报错
  [图标] 写脚本                      用 hou 脚本完成这次操作
```

**这个 chip 给模型的不是"一个名字",而是一段说明**:它带上 Houdini 连没连上、版本、当前 hip、
端点地址,以及"请用 `mcp__houdini__*` 真的去动它,不要只给步骤或示例代码,动手前后各读一次
场景"—— 所以不必每句话都重新交代一遍。下钻里挑了某一条时,还会多一句"用户选定的做法:……"。

没连上时说的是另一套:明说服务没在跑、这些工具会失败,别假装看到了场景。

#### 发出去之后,面板里看到的是什么(0.3.2)

那段说明**不该钉在某一条消息上**:用户中途把 Houdini 关了,旧消息里那句"已连上"就成了谎话;
而且面板会把整段 XML 原样显示,看着就是一坨字符。0.3.2 把两件事拆开:

| 谁 | 放在哪 | 什么时候求值 |
|---|---|---|
| 那行引用 `@Houdini` | 消息里(`serialize` 的返回值) | 发送那一刻 |
| 上面那段说明 | 宿主的系统提示词段 `houdini-bridge:live`(`order: 400`,`interpolate: false`) | **每一步**都重新求值 |

于是面板里只剩一行引用。引用是 dsh 自己投影出来的(消息里任何 `@词` 都会变成带
`data-ref-chip` 与 `title` 的节点),配一条 CSS 就能把官方那枚 file 图标换成 Houdini 自己的:

```css
[data-ref-chip][title="@Houdini"] [class*="_refIcon_"]{display:none}
[data-ref-chip][title="@Houdini"]::before{content:"";width:1em;height:1em;margin-right:4px;vertical-align:-.125em;background:url(<图标>) center/contain no-repeat}
```

输入框里那个 chip 同理 —— 它的 `@` 是个**字符节点**(`.yAWgPa_marker`),`title` 是我们给的
label,所以四条选择器一起把"字符 / 官方图标"换成真图标,别家引用(`@/some/dir/`)纹丝不动。

**为什么只能这么换**:`ReferenceChip` 是 `appearance === undefined ? "@" : <ReferenceIcon kind={appearance}/>`,
而 `ReferenceIcon` 是个**没有 default 的 switch**(只认 `session`/`file`/`folder`)—— 给它自定义 kind
只会什么都不画,插件侧也没有消息渲染扩展点(`conversation.chat.node` 是 keyed 槽,覆盖 `user`
等于把 markdown/图片/fork 全自己重写一遍)。CSS 是唯一贴得上去的地方。

⚠️ 宿主半侧**不热替换**:改完 `index.js` 要重启 dsh,`/state` 里才会出现 `prompt: true`。
在那之前 `serialize` 自动退回长版(`@Houdini` + 那段 XML)—— 指令一句不少,只是面板里还带着字符。

- 那一行的状态 15 秒才重新问一次宿主:菜单每敲一个字都会开合,不值得每次都打一趟。
- 既不是这一组、也不是 `houdini` 的前缀时,这一组**整体不出现** —— 免得用户 `@` 找文件时被插队。
- 下钻后筛不到时给一行说明(而不是空白列表),回车回到上一层。
- 打 `@houdini/` 后接着打中文也会当筛选词(`@houdini修` → 只剩「修报错」);
  长别名直接吞词是特意的,否则余下的字会把本组筛空,而"所有组都空"会让菜单在手指底下整个关掉。

配置(profile 的 patch 行里加 `config:`,全部可选):

```yaml
- id: houdini-bridge
  name: dsh-houdini-bridge
  config:
    host: 127.0.0.1
    port: 8765
    token: ''            # Houdini 侧开了 token 校验时填同一个
    timeoutMs: 2500      # ping 的超时
    sceneTimeoutMs: 15000
    errorTimeoutMs: 30000
    houdiniDirs: []      # 部署目标;留空 = 自己扫(HOUDINI_USER_PREF_DIR 优先)
    houdiniVersion: '20.5'
    home: ''             # dsh home;留空 = $DSH_HOME,再退回 ~/.dsh
```

## 工具

dsh 里会多出四个工具,名字前缀 `mcp__houdini__`:

| 工具 | 干什么 |
|---|---|
| `status` | Houdini 连着吗、当前文件、帧、选中数量 |
| `scene` | hip 路径、未保存标记、帧与范围、选中节点(含非默认参数)、各上下文节点 |
| `errors` | 所有带错误/警告的节点及消息 |
| `exec` | 在 Houdini 主线程里跑一段 Python |

`exec` 是主力:`hou` 已导入,`print()` 的输出和 `RESULT` 变量的值都会带回来。

```python
# 模型写这种代码就能干活
n = hou.node('/obj').createNode('geo', 'hero')
box = n.createNode('box')
box.parm('size').set(2)
RESULT = n.path()
```

## 端点

Houdini 侧只提供四个:

| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/ping` | 存活、版本、文件、帧、选中数、服务自己那一版的版本号 |
| GET | `/scene` | 场景概览 |
| GET | `/errors` | 报错与警告 |
| POST | `/exec` | `{"code": "..."}` |

dsh 宿主侧四个(只允许本机同源访问):

| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/houdini-bridge/state` | 上面的 `/ping`,加一层「连不上」的说法,外加部署状态 |
| GET | `/api/houdini-bridge/scene` | 上面的 `/scene` |
| GET | `/api/houdini-bridge/errors` | 上面的 `/errors` |
| POST | `/api/houdini-bridge/action` | `{"action": "setup.all"}` —— 唯一会写文件的一个 |

`action` 认这六个值:`setup.all`、`setup.install`、`setup.autostart.on`、`setup.autostart.off`、
`setup.mcp.on`、`setup.mcp.off`。

手动验:

```bash
curl -s http://127.0.0.1:8765/ping
curl -s http://127.0.0.1:3080/api/houdini-bridge/state
curl -s -X POST http://127.0.0.1:3080/api/houdini-bridge/action -H "content-type: application/json" -d "{\"action\":\"setup.all\"}"
curl -s -X POST http://127.0.0.1:8765/exec -H "content-type: application/json" -d "{\"code\":\"RESULT = hou.frame()\"}"
```

## 配置

插件(profile 的 `cordis.patch.yml` 里按 row id 覆盖,patch 替换整行 `config`,字段要写全):

- `houdiniDirs`:**硬指定**要装进哪些用户目录(留空 = 按上面那四级去问/去推)。
- `houdiniVersion`:留空 = 以装好的 Houdini 自己报的为准;填了(如 `"20.5"`)就按它组织
  `houdiniX.Y` 目录名与 `pythonX.Ylibs`。
- `host` / `port` / `token` / `timeoutMs` / `sceneTimeoutMs` / `errorTimeoutMs`、`home`、`scriptPath` 见
  `index.js` 的 `DEFAULTS`。

> 环境变量 `DSH_HOUDINI_NO_DISCOVER=1` 会让发现层**完全不起子进程**(不读注册表、不跑
> `hython`),退回到"环境变量 + 老兜底"的老行为 —— CI 与需要可复现的测试用得上。

Houdini 侧:`start(port=8765, host="127.0.0.1", token=None)`

- `token`:设了就要求请求带 `X-DSH-Token`;MCP 侧用环境变量 `DSH_HOUDINI_TOKEN` 对齐。
- `DSH_HOUDINI_URL`:MCP 侧连的地址,默认 `http://127.0.0.1:8765`。
- `DSH_HOUDINI_TIMEOUT_MS`:单次调用超时,默认 120000。

Houdini 侧另外还有 `start_if_gui()`:和 `start()` 一样,但**非图形界面会话(hython / hbatch)
里静默跳过**。pythonrc.py 在命令行 Houdini 里同样会被执行,用 `start()` 会让每个批处理进程
都去抢 8765,所以自动启动钩子调的是它。它每次被调用都写一行心跳(见上面「钩子到底跑没跑」)。

`pythonrc.py` 跑得很早(官方文档说"UI 就绪之前"),所以特意实测过 `hou.isUIAvailable()` 在那一刻
的取值:**真开一次 GUI Houdini,5 秒内 8765 就答上了**,说明在图形会话里它那时已经是 `true`,
钩子不会被误判成批处理而跳过。

单个返回值(stdout 或 RESULT)超过 20 万字符会被换成一句提示,避免把 JSON 撑爆。

## 排错

**连不上** —— Houdini 里没跑 `start()`,或者用的是命令行 hython(下面这条)。

**开了 Houdini 但还是没自启** —— 按面板「部署」卡片里的几行往下查,从便宜到贵:

1. **装到哪个目录了,凭什么**:卡片里那行列的用户目录,以及**来源**一行 ——
   `hython` = 问装好的 Houdini 问出来的(最可信);`env` = 环境变量指过去的;
   `documents` = 从「已知文件夹」的文档目录推的;`fallback` = 没问到,目录是猜的,
   **第一个要怀疑的就是它**。想固定住就设 `houdiniDirs`,想看清 CLI 判断过程就跑
   `node install.mjs --dry-run`。
2. **落点对不对**:那行列的是 `<用户目录>/python3.11libs/pythonrc.py` 还是
   `<用户目录>/pythonrc.py`。后者是 0.2 早期的错位置,Houdini 不读;点一次「自动部署」就会
   挪到对的地方并把旧的改名留档。
3. **钩子在不在文件最前面**:行尾若写着「但排在文件后段」,说明它排在别人代码后面 ——
   别人抛一次错它就没了。重新部署会把它挪到最前。
4. **心跳怎么说**(最有信息量的一条):卡片底部那行「自启钩子上次执行:……」
   - **「还没被执行过」** ⇒ 这份 `pythonrc.py` 根本没被读到:查落点,或那个 Houdini 是在
     部署**之前**就开着的(钩子只在下一次启动时才生效,重开一次)。
   - **「命令行会话,按设计跳过」** ⇒ 那次是 hython / hbatch 起的,属正常;开个图形界面的就有了。
   - **「起失败」** ⇒ 钩子跑到了、`start()` 抛了,原因在 Houdini 控制台的
     `[dsh-houdini] 自动启动失败:` 那一行。
   - **时间戳比当前 Houdini 的启动时间早** ⇒ 这次启动没走到钩子(同上第 1、2 条)。

心跳文件在系统临时目录(Windows:`%TEMP%\dsh-houdini-bridge-autostart.log`),只留最近 50 行。
正在开着的 Houdini**不会**补跑钩子 —— 钩子只在启动那一刻执行一次。不想关掉当前场景的话,
在它的 Python Shell 里跑 `import dsh_houdini_link; dsh_houdini_link.start()` 即可。

**`RuntimeError: 这个 Houdini 会话里既没有 hdefereval……`** —— 服务必须在**图形界面**的
Houdini 里跑。hython / 命令行模式两者都没有,起不来。

**自动部署报"没有找到装了本插件的 profile"** —— 插件在 `package.json` 的 `bundles` 里,
但 profile 目录名不是 `web` 且 manifest 里找不到 `dsh-houdini-bridge`。检查
`~/.dsh/profiles/*/package.json`。

**卡住不动** —— 两个可能:
1. Houdini 主线程忙(渲染、求解),请求排在队列里等。等到超时就返回"调用超时"。
2. 你在 Houdini 的 Python Shell 里**同步**发了个 HTTP 请求给自己。主线程在等响应,
   `hdefereval` 就没机会执行 → 死锁。要测就用外部工具(curl)或另开线程。

**`@` 里没有 Houdini** —— 三种可能,从便宜的查起:

1. **外壳的 `inputTriggers` 没就绪**。这个服务的注册走 `ctx.get` + `ctx.inject` 两条路,
   都没成的话只有 `@` 这一块没有,侧栏入口照常在。看控制台有没有
   `[dsh-houdini-bridge] @ 来源没挂上`。
2. **你在打别的词**。这一组只在"像是在打 houdini"时出现(是它的前缀),
   否则整体不出现 —— 这是有意的,免得你 `@` 找文件时被插一行。
3. **新装的插件没重启**。客户端 bundle 热替换只对**已装**插件生效;
   首次安装要重启 dsh(模块清单在启动时快照)。

**改了 `houdini_link.py` 要重启** —— 模块已经在 Houdini 里 import 过了,重新
`L.stop()` + `importlib.reload(L)` + `L.start()`,或者重开 Houdini(有自启钩子的话自动就起)。

## 开发

```bash
node test/smoke.mjs                       # MCP 侧,17 项
node test/plugin.mjs                      # 插件两侧(宿主路由 + 部署 + 浏览器纯函数 + @ 来源),101 项
hython.exe test/houdini_side_check.py     # Houdini 侧,25 项(要真 hou)
node test/fake-houdini.mjs                # 起个假 Houdini,不用开真家伙也能看面板
```

`smoke.mjs` 起一个假的 Houdini HTTP 服务,把 MCP server 完整喂一遍协议。
`plugin.mjs` 把宿主的四个路由(含回环守卫、token、超时、坏响应)打一遍,把部署动作真的
写进一个临时目录再逐项断言(幂等、备份、不动别人的条目),再用 loader 桩把客户端 bundle
物化出来断言纯函数、`@` 来源的候选与序列化、以及样式约束。
`houdini_side_check.py` 用真的 `hou` 验读写、异常处理与长度保护;主线程调度那一段
只在图形界面里成立,所以在命令行模式下它顺带验证"报错说得清楚",以及
`start_if_gui()` 在 hython 里确实静默跳过。

### 图标

侧栏入口和 `@` 菜单里用的是 `houdini.exe` 自己的图标,不是手画的:

```bash
node tools/extract-icon.mjs "D:/Houdini/bin/houdini.exe" assets/houdini-icon.png
node tools/inject-icon.mjs          # 把 PNG 内联进 lib/client.js(幂等)
```

`extract-icon.mjs` 自己解析 PE 资源:DOS 头 → `.rsrc` 节 → 资源目录三级 →
`RT_GROUP_ICON` 挑最大那一档 → 从 `RT_ICON` 读 DIB(`BITMAPINFOHEADER` 的
高度字段是两倍行数,下面还跟着一层 AND 掩码)→ 自己编 PNG(zlib + CRC32)。
**没有走 `System.Drawing`**:本机的安全策略把 `Add-Type`(连 `-AssemblyName`)拦掉了。
另外这台机器的 `houdini*.exe` 图标**全是 DIB**,所以"扫 PNG 签名"那个取巧办法一块都扫不到。

内联而不是走宿主路由,是因为 `@` 菜单是浮层,外链图片会让它先闪一下空图;
代价是 `lib/client.js` 大约大 8KB。换图标就重跑上面两条。