dsh-motion-background
给 DSH WebUI 用的动态背景插件:把对话界面的底色换成实时演算的画面,或一段素材。
两类背景,在设置页里切换:
| 类型 | 怎么出画 | 需要什么 |
|---|
| 着色器型 | WebGL2 + GLSL,逐帧实时演算 | mod.json + fragment.glsl(纯程序化,不用素材) |
| 媒体型 | 浏览器原生 <video> / <img> 铺底 | mod.json 的 media 字段 + 素材文件(mp4 / webm / gif / webp / png / jpg) |
两类可以混放,效果下拉框、回落与错误上报一视同仁。媒体型完全不碰 WebGL,
所以在没有 WebGL2 的环境里也能用。
效果不是写死在内核里的:每个效果是 mods/ 下的一个文件夹。
内核自身不含任何第三方着色器代码,全部 GLSL 由各效果自己的文件提供。
怎么加效果见下面的「加一个效果(扩展点)」。
这句"不含第三方着色器"的证据边界要说清(独立审核要求过):它建立在
"对若干公开着色器库抽出的 86 个函数名 + 179 个 uniform 名 + 2186 条特征行做全词匹配,
内核 0 命中;同款探测器在已知含第三方着色器代码的旧版皮肤上命中 14 个(阳性对照)"之上 ——
换来源、改名、或改写到无法按名字识别的代码,不在这个结论范围内。
检测方法本身写在 docs/KERNEL-REVIEW.md §A,本文档与那份都不列出
具体来源的名字。
开发环境:在 0.1.5-rc.2 的 DSH 上开发与验证;没有声明更宽的版本兼容范围。
三份文档,按你的角色读
结构
lib/index.js 宿主半:扫 mods/ 目录,提供 GET /motion-background/mods 与 /motion-background/media/<id>/<file>
lib/client.js 客户端半:内核(渲染 / mod 加载 / 设置面板 / 配置)
mods/<id>/ 一个效果 = 一个文件夹
├── mod.json 元数据 + 默认参数 + 面板映射(可选 range 量程 / order 排序 / media 媒体声明)
├── fragment.glsl 片元着色器(GLSL ES 3.00)——**着色器型**必需,媒体型不需要
└── vertex.glsl 可选
docs/ 上面三份 + 装配后的验收清单(POST-RESTART-CHECKLIST.md)与配置生命周期笔记
LICENSE MIT 正文(mods/ 的许可另见各自 mod.json,见文末「许可」)
verify.mjs 自检(五组,可在无 DSH 的环境复跑)
mutations.mjs 反证驱动器(注入坏实现,要求断言变红)
FALSIFICATION-*.md 反证执行记录(由 mutations.mjs 生成)
两类 mod:
| 类型 | 出画方式 | 必需 | 例子 |
|---|
| 着色器型 | WebGL2 + GLSL,程序化实时演算 | mod.json + fragment.glsl | mods/meteor |
| 媒体型 | 浏览器原生 <video> / <img> 铺背景 | mod.json 里的 media 字段 + 素材文件 | mods/aurora-video |
媒体型的意义之一是在没有 WebGL2 的环境里照样能跑(完全不碰 WebGL)。
两类可混装,扫描/下拉框/回落一视同仁。细节见 docs/MOD-FORMAT.md §2.2。
两类效果在面板里怎么区分
两者共用同一套控制栏(不是两套),所以效果下拉的名字后面会带一个类型标注:
程序化效果就叫「流星雨」,媒体型叫「极光(MP4 视频)」。标注由宿主半按素材扩展名派生
(media.label),不是写在 mod.json 的名字里 —— 换素材(mp4 → webm)标注自动跟着变,
也不会出现"标着 MP4、实际是 WebM"的谎报。
视频的播放模式(循环 / 往返)
选中视频型效果时,面板的「效果」下面会多出一个「播放」下拉框:
| 模式 | 行为 | 什么时候用 |
|---|
| 循环 | 播到结尾立刻跳回开头(原生 <video loop>) | 素材头尾本来就衔接得上 |
| 往返 | 正着放一遍,再倒着放回来 —— 两端在同一帧折返 | 头尾不衔接时,避免循环接缝处"跳一下" |
mods/*/mod.json 的 media.playMode 可以声明建议值(aurora-video 建议 pingpong),
但用户的选择永远优先,且会存进 localStorage 跨效果生效。
⚠️ 「倒放」没有原生实现,这不是取舍,是被规范挡死的:el.playbackRate = -1
在 Chromium 上直接抛 NotSupportedError(HTML 规范里 playbackRate 只接受非负值)。
所以往返的倒放段是脚本逐帧驱动:暂停元素,每个动画帧把 currentTime 往回挪
「距上一帧的墙上时间」那么多。实测(640×360 / 6s):速度约 0.99×、落点精确(误差 0)、
画面真的逐帧更新。代价是倒放段比原生播放略耗 CPU(verify.mjs 的 E19b 用像素指纹
钉住"画面真的在变",而不是只看 currentTime 这个标量)。
⚠️⚠️ 还有一个不能想当然的地方:倒放不许"每帧都发 seek"。
每个新的 currentTime 赋值都会掐掉在途的那次 seek,解码器于是永远从头开始 ——
越"努力"越卡。2026-09-25 三路对照实测(同机同素材,各 1.2s 媒体时间):
| 驱动写法 | 发出 seek | 真的落位 | 落位率 | 可见画面 |
|---|
| 每帧无条件发(最初版本) | 156 | 6~14 | ~5% | 57/12(≈23 fps 幻灯片) |
| 在途不发新 seek(现在) | 23 | 23 | 100% | 12/12(满帧) |
⚠️ 上表各 1.2s 媒体时间;采样窗口拉长到固定 2.0s 后,同一个"拆掉闸门"的坏实现
落位率回升到 0.64~0.72(7 次实测)—— 因此判据下限取 0.9(正常实现 4 次实测
恒为 1.000,零浪费)。下限是实测校准的,不是取个看起来宽松的数:
早先取 0.6 时与洪水版只差 0.04~0.12,这条反证于是 flaky(同一条变异一次被抓、一次放行)。
两种写法的速度都是 0.97~1.0×(不是"快了慢了",是"流畅还是幻灯片")。
现在的驱动有一个 seek 闸门:上一拍没落位时不发新的,但把墙上时间攒着留到下一拍补回
(否则倒放整体变慢);另有 250ms 兜底超时,避免元素卡在 seeking 上导致永久停摆。
verify.mjs 的 E19b 直接量落位率(landed / assigns ≥ 0.9)来钉住这条契约 ——
只数"不同像素指纹"是统计量,这种洪水写法也能蒙混过关(实测 40/82)。
📌 一个顺带的旁证:修复前 E19c 测到的往返闭环(正放 6s → 倒回 0)耗时约 14 秒,
修复后是 6.5 秒 —— 倒放段原本慢了一倍多,现在与正放段相当。
这也正是"中段生硬"的量化来源。
为什么往返模式下 loop 必须为 false:ended 事件只在非循环时触发,而它是"正放到头"的
唯一可靠信号。loop 若为 true,浏览器会自己跳回开头 ⇒ ended 永不触发 ⇒ 倒放支路根本不会启动。
加一个效果(扩展点)
一个效果 = mods/ 下的一个文件夹。加就是把这个文件夹放进去,删就是删掉它 ——
改完刷新页面即可,不需要重启 DSH(宿主半每次请求都重扫目录)。
原则
- 内核不认识"效果" —— 它只认契约:加载、编译、驱动绘制、渲染面板。
画面本身(GLSL、素材、参数、面板映射)全部待在你的文件夹里。
- 两类共用一个契约面 —— 着色器型与媒体型用同一份
mod.json,差别只在有没有 media 字段。
扫描、下拉框、回落、错误上报对两者一视同仁。
- 一个效果坏掉不影响别人 —— 编译失败或契约体检不过的会被摘出下拉框、记进
errors,整页不会挂。
- 不猜 —— 你没声明的东西内核不替你兜底:
colors 非法直接判该效果不可用;
panel 映射到不存在的 uniform 就把那个旋钮置灰(而不是给一个拖了没反应的滑块)。
要满足的条件
着色器型
mod.json + fragment.glsl(+ 可选的 vertex.glsl;缺省用内核自带的极简全屏 quad)
fragment.glsl 首行必须是 #version 300 es,必须声明并写入 out vec4 fragColor;
- 用
in vec2 v_uv; 取坐标(0..1,左下原点)
- 不得声明采样器(内核不绑定任何纹理;要随机就自己写 hash)
- 保留 uniform(
u_time / u_resolution / u_pixelRatio / u_colorBack / u_colorFront / u_colors)
的类型必须与契约一致,否则该效果判不可用
colors 必须是 "front" 或 "array"(非法值直接判该效果不可用,不静默兜底)
媒体型
mod.json 里含 media 字段(给了它就是媒体型,也不需要 colors)
- 素材扩展名限 mp4 / webm / gif / webp / png / jpg / jpeg —— 不在表里判不可用
media.src 只能是本效果目录下的文件名:不许含路径分隔符、不许含 ..,否则整个效果不加载
两类共同
- 文件夹名与
id 只允许 [a-z0-9-];id 按 mod.json 里的值去重(重复时按文件夹名升序取第一个)
- 含第三方代码或素材的,必须在
mod.json 的 author / license 里声明来源与许可
扩展完之后能调什么
下面这些都由你在 mod.json 里声明:
| 你想控制 | 写在哪 |
|---|
| 面板上那四个旋钮分别接哪个 uniform | panel(映射到不存在的 uniform ⇒ 该旋钮置灰) |
| 每个旋钮的量程 / 步长 | range,如 [0, 2] 或 [0, 2, 0.1](写坏了会兜底成合法区间) |
| 旋钮初值 | spec(内核不提供自己的默认值,免得盖掉你的设计) |
| 哪个效果默认选中 | order(升序,id 字典序兜底) |
| 颜色从哪来 | colors:"front" 单色系 / "array" 多色团 —— 都取 DSH 主题令牌,深浅主题自动跟随 |
| 效果在面板里的名字与副标题 | name / description(name 里别写类型词,标注由宿主半按扩展名派生) |
| (媒体型)铺满方式 | media.fit:cover(铺满、可能裁切)/ contain(完整可见、可能留白) |
| (媒体型)整体不透明度 | media.opacity(超出 0..1 会被夹回) |
| (媒体型)叠在界面上的混合方式 | media.blend: true ⇒ CSS mix-blend-mode: screen |
| (视频型)播放模式的建议值 | media.playMode:loop / pingpong(用户在面板里选过之后以用户的选择为准) |
不在上面这张表里的,目前改不了 —— 如实说清:
- 绘制帧率与渲染分辨率是内核级常量,不是面板项,也不是某个效果能声明的。
要改就改
lib/client.js 里的三个常量并重启 DSH,见「资源占用」一节。
- 媒体型不能通过
mod.json 设定视频帧率或清晰度 —— 那是素材文件本身的属性。
内核只按 media.fit 决定 CSS 的铺法(object-fit),不重编码、不改素材。
- 用户在面板上能动的只有那几个控件(四个旋钮 + 面板浓度 + 播放模式 + 启用开关),
以及各效果自己声明的量程与初值。
完整字段表与 GLSL 契约见 docs/MOD-FORMAT.md。
资源占用
背景是全屏绘制,开销大致与「帧率 × 像素量」成正比 —— 所以内核给两者都设了上限,
并按窗口状态分档。下面是一个效果被选中之后,各状态下实际发生的事。
各状态下的档位
| 你的状态 | 着色器型 | 媒体型 |
|---|
| 选中它(dsh 窗口在前台) | 绘制上限 30 fps;画布像素 = CSS 尺寸 × min(devicePixelRatio, 1) | 浏览器原生播放,不占 WebGL |
| 失焦(你在用别的窗口 / 两栏并排) | 上限降到 15 fps | 继续播(见下方「管不到的情况」) |
| 标签页切走 / 窗口最小化 | 完全不画(0 帧) | 暂停 <video>,切回来自动续播 |
prefers-reduced-motion 打开 | 完全不画(优先于上面所有档位) | 不动它(交给浏览器自身策略) |
| 关掉「启用」 | 画布被摘掉、WebGL 上下文回收 | 媒体元素被摘掉(真正释放解码内存) |
三个数字对应 lib/client.js 里的三个具名常量:
| # | 常量 | 取值 | 管什么 |
|---|
| 1 | FPS_ACTIVE | 30 fps | 窗口正常时的绘制上限 |
| 2 | FPS_BLURRED | 15 fps | 窗口失焦(用别的窗口 / 两栏并排)时的上限 |
| 3 | DPR_MAX | 1 | 渲染分辨率:画布像素 = CSS 尺寸 × min(devicePixelRatio, DPR_MAX) |
| + | 媒体型可见性门控 | document.hidden ⇒ pause() | 标签页切走 / 窗口最小化时停掉 <video>,可见时自动续播 |
为什么要设这些上限
症状(最初是怎么发现的):开着 dsh 的 WebUI,切到别的窗口后移动鼠标、点击都特别卡;
把两个窗口并排时也一样。
实测根因(不是 CPU —— 全程 CPU ≤0.6%)。两层,外加一个盲区:
- 帧率不受控:
requestAnimationFrame 会跟着显示器刷新率跑 —— 实测在 240 Hz 屏上是
240 次/秒;且窗口失焦时浏览器不会停 rAF(自动节流只管"标签页不可见"与
"窗口被完全遮挡","失焦但仍可见"这一档不管)⇒ 你用别的窗口时它还在后台全屏重绘。
- 像素量太大:2560×1600 屏 + Windows 缩放 150% ⇒
devicePixelRatio = 1.5 ⇒ 全屏画布
3840×2400 = 920 万像素/帧。只限帧率不够 —— 实测 30 fps 时 GPU 仍占 86.9%。
- 盲区:媒体型(视频)不被帧率闸管到:它的
frame() 是空实现(媒体由浏览器驱动),
而这里的视频是 muted ⇒ Chromium 对静音视频在后台标签页不会自动暂停 ⇒
切走后它继续解码。这一档只能靠可见性门控单独管。
实测(真机 dsh 页面,数 gl.drawArrays 与系统 GPU 计数器)
| 场景 | 修复前 | 修复后 |
|---|
| 前台绘制帧率 | 240 fps | 30 fps |
| 失焦绘制帧率 | 240 fps(照跑不误) | 15 fps |
| Chrome GPU 进程占用(前台) | — | 86.9% → 17.4% |
| 本机 GPU 总负载 | 接近饱和(≈99%) | ≈56% |
分辨率那一项比线性预期还好(44% 像素 ⇒ 预期 38%,实测 17.4%)—— 低分辨率下 shader 更省,不是线性的。
观感(必读)
- 两栏并排时,你会看到流星雨"慢下来" —— 那不是卡,是
FPS_BLURRED 在生效(15 fps)。
它换来的正是"你在别的窗口里不卡"。
- 降分辨率看不出来:这是慢速模糊的氛围背景,
DPR_MAX = 1 在 2560×1600 屏上用户实测
"背景没有糊"。想更锐可以调到 1.25 / 1.5,GPU 会同比例上升(1.25 ⇒ 像素 ×1.56 ⇒ 约 27%)。
- 切回来立刻恢复 30 fps —— 闸门是"跳过绘制但保留续帧",不需要等下次交互。
使用限制(这些情况它管不到,别当成 bug)
- "失焦但仍可见"这一档不适用媒体型:并排看视频时它继续播。取舍理由:并排时视频是
看得见的,暂停比继续播更突兀;而硬解全屏 MP4 的开销远小于全屏 shader 重绘。
(标签页切走/最小化仍然会停 —— 那一档是明确该停的。)
- 判据是
document.hasFocus():多显示器、虚拟机、远控(如 GameViewer)等环境下,
焦点语义与常规不同 ⇒ 分档可能"看起来没生效"。此时它退化为"始终按 30 fps 跑",不会更差。
- 用户手动把窗口调小 ⇒ 像素量自然下降,上限档位不变。
prefers-reduced-motion 打开时,着色器型完全不画(这一条优先于上面所有档位)。
要调的话改哪儿
三个常量都在 lib/client.js 的 createSurface 之前,改完必须重启 dsh(见下方"坑"):
const FPS_ACTIVE = 30; // 前台绘制上限
const FPS_BLURRED = 15; // 失焦上限("不和前台抢"的核心;调低更让路、调高更顺滑)
const DPR_MAX = 1; // 渲染分辨率上限(GPU 占用与它近似成正比于 DPR²)
⚠️ 不要去掉任何一道上限。去掉帧率上限意味着它跟着显示器刷新率跑;去掉分辨率上限
意味着 4 倍像素量 —— 两者都会把用户的桌面一起拖慢。
⚠️⚠️ 改了 lib/client.js 之后,必须重启 dsh,然后再刷新页面(2026-09-26 实测踩过)。
dsh 给客户端 bundle 的响应头是 cache-control: public, max-age=31536000, immutable,
而 URL 里的 rev(内容哈希)只在 dsh 启动时算一次。
⇒ 只改文件、不重启:服务端内容已是新版(curl 能拿到新版),但 rev 没变
⇒ 浏览器按 rev 命中 immutable 缓存 ⇒ 永远用旧代码,连普通刷新(F5)都绕不过。
判据:curl 模块表看 rev 有没有变;没变 = 没生效。
(症状表现:自己用无缓存的新实例测是好的,用户那边"依旧卡 / 依旧没变"。)
两条方法论(后人别重复踩)
⚠️ 测这个量必须数 drawArrays,不能数页面 rAF 频率:节流的设计是"跳过绘制但保留
续帧"(这样重新获得焦点能立刻恢复,不需要额外的 focus 监听),所以 rAF 频率本来就不变
—— 拿它当判据会得到一条永远通过的断言。
⚠️ 必须做"前后台对照"才能定位:dsh 页面不在前台时它的 GPU 占用是 0(浏览器把 rAF
停了)—— 只测一个状态推不出结论。并排比"在前台 / 不在前台"两个数,才知道是谁在吃。
⚠️ 自动化断言未覆盖(如实记录):verify.mjs 里没有这些上限的断言。原因是那个夹具
的动画时间轴不跑 —— 加了全套诊断后的实测:hook 自检为真、live=true、mod 已挂上、
probeMods() 能真画一帧、hidden=false / hasFocus=true、prefersReduce() 为假,
但 2 秒窗口内 drawArrays 0 次(E 组其它用例也都靠 freeze 单帧或 paint() 显式重绘,
从不依赖"动画自己跑")。与其留一条在 0 帧时也通过的恒真断言(本项目最反对的假绿),
这里如实不写。要把它变成断言,得先找到一个能驱动动画时间轴的夹具(未做)。
✅ 但媒体型的可见性门控有天然保护:现有的 E19 系列(~20 条,覆盖倒放 / 播放模式 /
reduce-motion)会在门控写错时直接变红 —— 加这一层前后它们都是 233/0。
为什么需要宿主半
客户端插件是单文件 bundle、跑在浏览器里,没有文件系统 —— 而"读 mods/ 目录"
是可插拔的前提。所以由宿主读目录、经一个 HTTP 端点交给客户端。
(纯样式类插件可以不要宿主半;这个不行。)
媒体型还需要第二个端点把素材字节交给浏览器。它只服务白名单扩展名、
路径必须是固定两段的 /media/<id>/<文件名> 且文件名不许含 .. 或分隔符
(不合格一律 404 且不泄漏目录内容),并必须支持 HTTP Range ——
浏览器播 mp4 时先发 Range: bytes=0- 探测,不支持 Range 会导致视频不播或不能循环。
跑自检
前置:自检需要一个 headless 浏览器,本仓库不把它列为依赖(运行时零依赖)。
先任选一种方式备好:
npm i -D playwright-core # 方式一:装在仓库里
# 方式二:用环境变量指到你已有的那一份
PLAYWRIGHT_CORE=/path/to/playwright-core node verify.mjs
找不到时 verify.mjs 会直接失败并打印上面这两条出路,不会悄悄跳过 ——
静默降级成"用假数据测通过"正是它要防的事。
node verify.mjs # 五组断言(宿主半 / 健壮性 / 客户端真跑 / 坏 mod 不崩 / 行为级)
node mutations.mjs # 反证:1 次基线 + 注入 20 种已知坏实现,要求断言**变红**,并写出 FALSIFICATION-<date>.md
verify.mjs 会:真 import 宿主半并用假 req/res 喂它、在临时副本里塞各类坏 mod 验证隔离性、
在 headless Chromium 里真跑客户端并数像素、注入非法 GLSL 确认内核不崩;第五组(E)再真调用
设置卡片组件树逐项检查四个旋钮(初值 / 量程 / 置灰)、真驱动 onChange 看运行时 spec 与
drawArrays 计数、读浅色与深色两种主题下的像素、并数 loseContext 确认卸载回收了 WebGL 上下文。
媒体功能另有 E18 组:真挂载 <video> 并核验 readyState / 尺寸 / muted / loop / currentTime
(真在播)、加载失败要判不可用、媒体坏了要回落着色器、切走后 DOM 里不留 <video>。
播放模式另有 E19 组:往返模式 loop 必须为 false、正放播完后方向真的翻转、倒放段
currentTime 单调递减、像素指纹真的在变("倒放"与"只改了时间标量"的区分手段)、
seek 落位率 ≥ 0.9("流畅倒放"与"每帧洪水式发 seek 退化成幻灯片"的区分手段 ——
这个量直接拦 currentTime setter 数发出次数、数 seeked 事件数落位次数)、
倒放速度 0.8~1.2× 与 可见帧率 ≥ 10 fps(后两条见下方"三个判据缺一不可")、
往返能闭环回到正放、「播放」下拉只在视频型出现且选择会落盘、类型标注出现在下拉的显示文案里。
三个判据缺一不可(2026-09-25 独立审核抓到的假绿通道)
最初只给 E19b 加了「落位率 ≥ 0.6」,独立审核复现出一个能全绿的坏实现:
把闸门换成纯时间限流(if ((now - lastIssue) < 400),不看 seeking)——
速度掉到 0.62×、可见帧率只有 2.5 fps(比修复前还差),却没有任何断言在拦它。
两条坏实现各命中不同判据(下表为本仓库实测,各 1 次独立整套运行):
| 判据 | 正常实现 | 拆闸门(每帧发 seek) | 纯限流 400ms |
|---|
| 落位率 ≥ 0.9 | 1.000 | 0.648 → 红 | 1.000(5 发 5 中)✅ 过 |
| 速度 0.8~1.2× | 0.996× | 1.000× ✅ 过 | 0.596× → 红 |
| 可见帧率 ≥ 10 fps | 49.9 fps | 43.1 fps ✅ 过 | 2.4 fps → 红 |
⇒ 三者互相独立、各抓一种坏法:落位率管"别洪水",速度管"别限流",
帧率管"用户看到了什么"。没有任何一条能单独顶替另外两条。
(上表也说明为什么不能用"落位率"去抓限流版 —— 它发得少、反而全部落位;反过来
"速度"也抓不住洪水版。这就是"三件套缺一不可"的实证依据 ——
判据数不等于覆盖度,判据之间是否互相独立才是。)
⚠️ 关于落位率 >1 的已知残余:独立审核指出"开窗前先 await waitSeeked() 再清零"
这一步没有修干净 —— waitSeeked 自带 200ms 超时,超时后仍会带着在途 seek 开窗,
CPU 降速下可复现 rate=1.045。判据只要求 ≥ 0.9(不要求 ≤1),所以不影响结论;
此处如实记录,不再假称已修掉。
📌 提交历史的诚实说明:倒放功能、E19 整组断言、E19f 类型标注、E19g 的 readyState 修复,
全都挤在 a174505 这一个提交里(a174505^ 里 tickReverse 出现次数为 0),
而该提交的 message 只描述了"加 seek 闸门"。⇒ 无法从 git 历史里剥离"哪部分是新增功能、
哪部分是修 bug"(也因此无法独立验证 E19g 声称的"修复前会卡死")。
根因是那些改动此前一直躺在未提交的工作区里。后续提交会按"一个变更一个提交"来做。
📌 另一处历史更正:提交 1a62a5e 的信息里写"限流版落位率 0.8 照样过线" ——
那个 0.8 未经复核,实测是 1.000(限流版发得少、5 发 5 中,落位率反而满值)。
它不影响该提交的结论(限流版确实该被判红,只是靠"速度/帧率"而非"落位率"抓到的),
但那句话读起来像是"落位率能抓住限流版",会误导后续维护者。历史不改写,在此更正。
报告器是 fail-closed 的:任何阶段抛错都会打印「验证未完成」并非零退出,不会打印成功标志。
⚠️ "现在是绿的"不构成验证。mutations.mjs 是验收的另一半:基线必须绿,而每条变异必须让对应的
断言变红。两者都过,才有资格说这套断言在观察事实 —— 独立审核(2026-09-25)正是在这一点上推翻了
本仓库早期的版本(当时注入 5 处坏实现,93 条断言仍全绿)。
写反证(变异)时必须注意的坑(都已踩过)
坑 1:变异"太弱" —— 删掉一处实现 ≠ 删掉这个能力。
el.play() 在 lib/client.js 里有两处(createMediaSurface().boot() 与 applyConfig())。
只干掉 boot 那处,mountSurface() 紧接着调的 applyConfig() 照样把视频播起来
⇒ currentTime > 0 依旧成立 ⇒ 断言全绿、还打印成功标志。
记录里当时的原话是:media-noplay | exit=0 | 187/0 | ❌ 反证失败(exit=0 red=0 hit=0 且打印了成功标志)。
教训:做变异前先确认这个能力在源码里一共几处。现在 media-noplay 用循环替换
一次干掉全部,并在命中数 < 2 时抛错。
坑 2:变异"根本没施加" —— 表现与坑 1 完全一样。
改上面那处时我误删了 media-keep-el 与 media-loud 两行 patch() 调用,
于是这两条变成"变异没施加",输出同样是 exit=0 / 全绿 / 打印成功标志。
"没施加"和"太弱"在结果上无法区分,都是假 PASS。
教训:现在有一道总闸 —— 若某条客户端变异跑完却一处都没改到,直接抛错;
另有"锚点出现次数 > 1 时打印警告"的守卫。两道都实测过真的会触发。
坑 3:总闸自己也会假绿 —— "打了标记"不等于"能力被拆掉"。
第一版总闸只检查 from.includes('(变异:'),而 patch() 无论替换成什么都会追加这个标记。
于是把 el.muted = true 换成等价写法 el.muted = !!1,总闸照样放行、断言 187/0 全绿 ——
它声称"能力被拆掉",实际只断言了"我来过"。独立审核(security-auditor)指出这一点。
教训:总闸改为按能力核对削减数 —— 每条变异要声明它该让哪个源码特征减少几处
(removeAll / minRemoved / mustIntroduce),闸门拿变异前/后的计数差来判。
只数削减还不够:=> !!1 同样能让计数掉 1,所以凡"削弱点是一段字面量"的还必须
引入预期的弱实现。这套现在实测能抓住等价替换(改造前会放行)。
宿主半(hostSource())走同一套纪律。
坑 4:连跑整套会被"删除配额"打断 —— 症状像环境抖动,其实是机制性的(2026-09-25)。
现象:套件跑到中途起,之后每条都记成 ERROR(本轮没跑完),而单跑任一条却完全正常。
根因(查了垫片源码 + 对照实验,不是猜的):调用方(Agent 运行时)经 NODE_OPTIONS
给每个 node 进程注入 safe-delete 垫片,它把 fs.rmSync / fs.rm / fs.unlink / fs.rmdir
换成"移入回收站",并按对话回合累计删除项数(scope:'turn';totalCount = 已用 + 本次项数;
达阈值即要求确认)。
⚠️ 这是调用方 Agent 施加的限制,不是这个插件本身的、也不是操作系统的通用行为 ——
换个不带该垫片的运行时跑,裸 rmSync 完全正常。换言之,运行本插件的那个 DeepSeek Harness
本身不一定有删除配额(配额取决于"谁在调它",不取决于本插件);这段是"遇到才需处理",
不是"必然发生"。
关键在越线之后每一笔删除都会被拒并抛错(不是只拦超标那一笔)⇒ 之后无论删多小的目录都炸。
多次 verify.mjs 共享同一回合的额度,于是前半程正常、后半程连续报"跑不完"。
⚠️ 阈值可配置(有一个默认值,本机被上调过若干次)—— 所以文档里不写任何具体阈值:
数字会随环境变。可断定的是机制本身。
实测对照(同一回合内):裸 rmSync 删 1 个目录 ⇒ 被拒并抛 SAFE_DELETE_BULK_CONFIRM_REQUIRED;
走 rmrf()(PowerShell Remove-Item)⇒ 删成功,且回合计数原值不变。
修法:verify.mjs 里清自建夹具/副本一律走 rmrf()。裸 rmSync 只允许出现在 rmrf() 内部
作为 PowerShell 不可用时的兜底。
⚠️ 这个坑还有一个认知陷阱:旧注释把原因写成"钩子会拒绝超过某个项数的单次递归删除"。
方向错了 —— 按那个理解会去"缩小单次删除量"(无效),因为真正超限的是回合累计。
排查时不要停在"看起来合理的因果"上:报错里的 count 远小于一次 buildCopy() 的项数,
这个数字本身就是"它数的是累计量"的线索。
写反证时的第三个陷阱:让无关组替被测代码背锅
反证跑不完(ERROR)与反证不成立(❌ 反证失败)都算失败,但成因完全不同 ——
ERROR 说明脚本自己崩了,那时没有任何断言被观察过,容易把"没测"误当成"测过了"。
踩到的一次:为了让路径类变异真的打在副本宿主半上,把 buildCopy() 提到 A 组之前;
于是 --mutate=no-isolation(见坏 mod 就中断整轮扫描)让 A 组的载荷塌成空,
而 C 组开头有一句 payloadC.mods.length === 0 ⇒ throw ⇒ 整轮在 C 阶段抛错,
被记成 ERROR 而不是"隔离性断言变红"。
根因是耦合:宿主半的故障归 A/B 组负责,却把只测客户端的 C/D/E 一起拖停了。
解法:C/D/E 的载荷改由 payloadForClient() 结算 —— 若 A 组载荷里没有 meteor,
就改用真仓库宿主半另取一份,并显式打印回落原因(回落必须留痕,否则就成了"悄悄换掉被测对象")。
写媒体安全断言时必须注意的坑:多道闸互相重叠
/motion-background/media 的路径校验是多道互相重叠的闸:① 段数必须为 2、
② 文件名不许含 ../分隔符、③ 扩展名必须在白名单里。
随便一条非法路径通常被其中好几道同时拦住 —— 于是"拆掉某一道闸"时,那些断言照样全绿,
看起来像"断言很稳",其实是假绿:它们根本没在观察那道闸。
踩过两次:
- 原来 18 条穿越向量全都够不着闸②(各被 ①/③ 兜住)⇒ 拆掉闸② 零红。
- 修上面那条时,给"闸①"打的标签也是错的 —— 实测那些向量在闸③/id 就死了,
拆掉段数校验后没有一条变成 200。
⇒ 现在的做法是:每道闸都配「只有它拦得住」的向量,且每条向量的落点文件必须真实存在
(否则拆掉那道闸后 statSync 会兜底 404,反证又静默失效)。落点存在性不能靠推理,
要在真目录里算一遍(实测有 2 条"看着合理"的向量落点其实不存在)。
verify.mjs 里有一条结构性断言按闸分别计数,谁删掉某道闸的证据向量就会立刻变红。
装进 DSH
从仓库直装(github: 走 pnpm 原生协议):
dsh plugin --profile web add github:ZhaoAndy821/dsh-motion-background
用本地克隆装(想改代码时):
- 在 DSH profile 的
package.json 里加 link 依赖与 bundle 项
- 在
profiles/web/node_modules/ 下建指向本目录的 junction
- 重启宿主(因为改了
dsh.client.inject,只刷新页面不够)
装好后按 docs/POST-RESTART-CHECKLIST.md 逐条验收 ——
六条 curl 自查 + 面板人眼确认 + 回滚步骤。
📌 两层,别混为一谈:profile 的 cordis.patch.yml 是热生效的 —— HMR 用 chokidar 监听
该文件,一变就重新组合(composeLive() 每次从磁盘重读),不必等重启。
而新增 / 卸载插件动的是 bundles 层,那是启动快照,必须重启。
详见 docs/CONFIG-LIFECYCLE.md。
📌 不要靠手改 dsh.profile.bundles 来停用某个插件 —— reconcilePlugins() 会在每次
dsh plugin 命令之后,把所有声明了 dsh.bundle 的 dependency 重新加回 bundles。
要停用就在 patch 层给它一条 disabled: true;那一条同时挡住宿主半与客户端半
(客户端模块扫描器的判据里含 !entry.disabled,停用的条目根本不进模块表)。
许可
根目录 LICENSE 是 MIT 正文,适用于内核与仓库自有文件
(lib/、docs/、verify.mjs、mutations.mjs、README.md)。
mods/ 明确排除在上面那条总括之外:每个 mod 的许可看它自己的 mod.json
(mods/meteor 是 MIT;mods/aurora-video 的素材是 ffmpeg 自产的测试视频,声明为 CC0-1.0)。
引入第三方代码/素材的 mod 必须在 mod.json 里声明来源与许可,且不得把第三方代码带进内核。
- ⚠️ "内核里没搜到某来源的着色器代码"不等于整个仓库具有再分发权:再分发还取决于仓库内其他资源
与各随包 mod 的许可条件。上面这条分工是把"哪些文件适用哪个许可"写清,不是对第三方的授权声明。