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 上开发与验证;没有声明更宽的版本兼容范围。
三份文档,按你的角色读
| 你是谁 | 读哪份 |
|---|---|
| 要实现/重写内核 | docs/KERNEL-TASK.md —— 自包含的任务书(契约 + 需求 + 验收 + 已踩的坑) |
| 要独立审查本实现 | docs/KERNEL-REVIEW.md —— 复现步骤 + 必查清单 + 证伪方法 |
| 要写一个效果 | docs/MOD-FORMAT.md —— mod 契约 |
结构
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,解码器于是永远从头开始 —— 越"努力"越卡。三种驱动写法的对照(各 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 —— 因此判据下限取 0.9(正常实现 恒为 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 对静音视频在后台标签页不会自动暂停 ⇒ 切走后它继续解码。这一档只能靠可见性门控单独管。
分辨率与帧率对照(数 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,然后再刷新页面(踩过)。 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 秒窗口内drawArrays0 次(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.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(后两条见下方"三个判据缺一不可")、
往返能闭环回到正放、「播放」下拉只在视频型出现且选择会落盘、类型标注出现在下拉的显示文案里。
三个判据缺一不可
只给 E19b 加「落位率 ≥ 0.6」是不够的 —— 下面这个坏实现能全绿:
把闸门换成纯时间限流(if ((now - lastIssue) < 400),不看 seeking)——
速度掉到 0.62×、可见帧率只有 2.5 fps(比修复前还差),却没有任何断言在拦它。
两条坏实现各命中不同判据:
| 判据 | 正常实现 | 拆闸门(每帧发 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),所以不影响结论; 此处如实记录,不再假称已修掉。
报告器是 fail-closed 的:任何阶段抛错都会打印「验证未完成」并非零退出,不会打印成功标志。
⚠️ "现在是绿的"不构成验证。
mutations.mjs是验收的另一半:基线必须绿,而每条变异必须让对应的 断言变红。两者都过,才有资格说这套断言在观察事实 —— 本仓库早期就吃过这个亏: 注入 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 全绿 ——
它声称"能力被拆掉",实际只断言了"我来过" —— 这正是最难发现的一类假绿。
教训:总闸改为按能力核对削减数 —— 每条变异要声明它该让哪个源码特征减少几处
(removeAll / minRemoved / mustIntroduce),闸门拿变异前/后的计数差来判。
只数削减还不够:=> !!1 同样能让计数掉 1,所以凡"削弱点是一段字面量"的还必须
引入预期的弱实现。这套现在能抓住等价替换(改造前会放行)。
宿主半(hostSource())走同一套纪律。
坑 4:连跑整套会被"删除配额"打断 —— 症状像环境抖动,其实是机制性的。
现象:套件跑到中途起,之后每条都记成 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 的许可条件。上面这条分工是把"哪些文件适用哪个许可"写清,不是对第三方的授权声明。
No comments yet. Be the first to write one.