dsh-wisp 1.44.0 → 1.48.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +599 -32
  2. package/assets/canon/attn.webp +0 -0
  3. package/assets/canon/eat.webp +0 -0
  4. package/assets/canon/happy.webp +0 -0
  5. package/assets/canon/idle.webp +0 -0
  6. package/assets/canon/poked.webp +0 -0
  7. package/assets/canon/proud.webp +0 -0
  8. package/assets/canon/sleepy.webp +0 -0
  9. package/assets/canon/work.webp +0 -0
  10. package/assets/classic/attn.webp +0 -0
  11. package/assets/classic/eat.webp +0 -0
  12. package/assets/classic/happy.webp +0 -0
  13. package/assets/classic/idle.webp +0 -0
  14. package/assets/classic/poked.webp +0 -0
  15. package/assets/classic/proud.webp +0 -0
  16. package/assets/classic/work.webp +0 -0
  17. package/assets/deepsea/attn.webp +0 -0
  18. package/assets/deepsea/eat.webp +0 -0
  19. package/assets/deepsea/happy.webp +0 -0
  20. package/assets/deepsea/idle.webp +0 -0
  21. package/assets/deepsea/poked.webp +0 -0
  22. package/assets/deepsea/proud.webp +0 -0
  23. package/assets/deepsea/sleepy.webp +0 -0
  24. package/assets/deepsea/work.webp +0 -0
  25. package/assets/motion/idle.webp +0 -0
  26. package/assets/motion/sleepy.webm +0 -0
  27. package/assets/motion/sleepy.webp +0 -0
  28. package/assets/motion/swim_attn.webp +0 -0
  29. package/assets/motion/swim_eat.webp +0 -0
  30. package/assets/motion/swim_happy.webp +0 -0
  31. package/assets/motion/swim_idle.webp +0 -0
  32. package/assets/motion/swim_poked.webp +0 -0
  33. package/assets/motion/swim_proud.webp +0 -0
  34. package/assets/motion/swim_sleepy.webp +0 -0
  35. package/assets/motion/swim_work.webp +0 -0
  36. package/assets/night/attn.webp +0 -0
  37. package/assets/night/eat.webp +0 -0
  38. package/assets/night/happy.webp +0 -0
  39. package/assets/night/idle.webp +0 -0
  40. package/assets/night/poked.webp +0 -0
  41. package/assets/night/proud.webp +0 -0
  42. package/assets/night/sleepy.webp +0 -0
  43. package/assets/night/work.webp +0 -0
  44. package/assets/pajama/attn.webp +0 -0
  45. package/assets/pajama/eat.webp +0 -0
  46. package/assets/pajama/happy.webp +0 -0
  47. package/assets/pajama/idle.webp +0 -0
  48. package/assets/pajama/poked.webp +0 -0
  49. package/assets/pajama/proud.webp +0 -0
  50. package/assets/pajama/sleepy.webp +0 -0
  51. package/assets/pajama/work.webp +0 -0
  52. package/assets/swim/attn.webp +0 -0
  53. package/assets/swim/eat.webp +0 -0
  54. package/assets/swim/happy.webp +0 -0
  55. package/assets/swim/idle.webp +0 -0
  56. package/assets/swim/poked.webp +0 -0
  57. package/assets/swim/proud.webp +0 -0
  58. package/assets/swim/sleepy.webp +0 -0
  59. package/assets/swim/work.webp +0 -0
  60. package/audit/generation-audit.jsonl +57 -0
  61. package/build.mjs +130 -8
  62. package/lib/client.js +826 -92
  63. package/lib/client.template.js +765 -53
  64. package/lib/index.js +194 -1
  65. package/package.json +1 -1
  66. package/tools/CHARACTER-PROMPT.md +65 -9
  67. package/tools/audit-log.mjs +11 -2
  68. package/tools/engine-probe.mjs +500 -2
  69. package/verify-wisp.mjs +1263 -32
@@ -17,12 +17,19 @@
17
17
  * 动画照跑但什么都不动。这条只有把 getKeyframes() 读出来才能证明。
18
18
  * 4. 动作幅度档:`静止` 下计算后的 animationName 是 none,姿势被钉在 0
19
19
  * 5. `prefers-reduced-motion`(CDP Emulation):姿势与动作都不写
20
+ * 6. 帧动画(v1.46.1 起是动图 WebP;v1.46.5 起是两条:idle + sleepy):
21
+ * `<img>` 真的解得开(naturalWidth = **素材自己的**画布宽度)、与立绘同一格、
22
+ * 冻结时 display:none 且立绘可见
23
+ * 7. 尺寸校正(v1.46.4):**计算后**的 transform 真的是 scale(1.186),底边真的下移
24
+ * 9.3% 个盒高 —— 她在那张 720p 素材里只占 0.8352 个盒高,立绘占 0.9883。
25
+ * v1.46.5:idle 那段**不该**带这条校正(差 1%,小于 2% 那条线),计算后是 none
20
26
  *
21
27
  * 用法:node tools/engine-probe.mjs (WISP_CHROME=<可执行文件> 可指定浏览器)
22
28
  * 退出码:0 通过或跳过 · 1 有检查没过 · 2 环境起不来(找不到可用的调试端口)
23
29
  * ------------------------------------------------------------------------- */
24
30
  import { spawn } from 'node:child_process'
25
- import { readFileSync, mkdtempSync, rmSync, existsSync } from 'node:fs'
31
+ import { createServer } from 'node:http'
32
+ import { readFileSync, mkdtempSync, rmSync, existsSync, readdirSync } from 'node:fs'
26
33
  import { tmpdir } from 'node:os'
27
34
  import { dirname, join, resolve } from 'node:path'
28
35
  import { fileURLToPath } from 'node:url'
@@ -30,6 +37,25 @@ import { fileURLToPath } from 'node:url'
30
37
  const here = resolve(dirname(fileURLToPath(import.meta.url)), '..')
31
38
  const bundle = readFileSync(join(here, 'lib', 'client.js'), 'utf8')
32
39
 
40
+ /* 素材自己的画布尺寸(VP8X 里那三字节,存的是值-1)。探针要断言的是 naturalWidth
41
+ **等于素材实际宽度**,而不是等于一个写死在探针里的 720 —— 素材换了尺寸、探针还
42
+ 念着旧数字,正是这种断言最该抓到的漂移。 */
43
+ const motionCanvas = (name) => {
44
+ const file = join(here, 'assets', 'motion', name)
45
+ /* 缺素材不是崩溃的理由:探针要能**报**"这一条没有/解不开",而不是在 readFileSync
46
+ 上抛出去 —— 那样整份探针会在第一条缺失的素材上停住,后面的断言一条都跑不到。 */
47
+ if (!existsSync(file)) return { w: 0, h: 0 }
48
+ const buf = readFileSync(file)
49
+ for (let off = 12; off + 8 <= buf.length;) {
50
+ const id = buf.subarray(off, off + 4).toString('latin1')
51
+ const size = buf.readUInt32LE(off + 4)
52
+ if (id === 'VP8X') return { w: buf.readUIntLE(off + 12, 3) + 1, h: buf.readUIntLE(off + 15, 3) + 1 }
53
+ off += 8 + size + (size % 2)
54
+ }
55
+ return { w: 0, h: 0 }
56
+ }
57
+ const IDLE_CANVAS = motionCanvas('idle.webp')
58
+
33
59
  /* 浏览器从哪来:环境变量优先,然后按平台猜几个常见位置。
34
60
  找不到就跳过 —— 这台机器没浏览器不是这个插件的缺陷。 */
35
61
  const candidates = [
@@ -55,6 +81,46 @@ if (!executablePath) {
55
81
  }
56
82
 
57
83
  const PORT = Number(process.env.WISP_CDP_PORT ?? 9333)
84
+
85
+ /* ---- 探针自己起一个最小的 HTTP 载体(v1.47.0)--------------------------------
86
+ 动图不再是内联的 data URI,而是**页面自己 origin 下**的文件
87
+ `new URL('wisp-motion/<文件>', document.baseURI)`。所以"真引擎里它解得开吗"
88
+ 这个问题必须先有一个真的 origin 才能问:about:blank 上 baseURI 不是可解析的
89
+ 基址,那一行会抛,探针就会变成在测"环境不像真页面"。
90
+
91
+ 这里发的两个东西就是投递链的两端:
92
+ /probe.html 一个空页面(就是页面的 base)
93
+ /wisp-motion/<文件> assets/motion/ 里的真字节,规则与宿主半包那个 handler 一致
94
+ (宿主那一半在 verify-wisp.mjs 里被直接驱动着查过一遍;这里负责**浏览器**那一半:
95
+ 真 <img>、真解码、真 naturalWidth。) */
96
+ const MOTION_ROUTE = '/wisp-motion/'
97
+ const clipHits = new Map()
98
+ const clipServer = createServer((req, res) => {
99
+ let pathname = '/'
100
+ try { pathname = new URL(String(req.url ?? '/'), 'http://127.0.0.1').pathname } catch (e) { /* keep '/' */ }
101
+ if (pathname === '/' || pathname === '/probe.html') {
102
+ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
103
+ res.end('<!doctype html><html><head><meta charset="utf-8"><title>wisp engine probe</title></head><body></body></html>')
104
+ return
105
+ }
106
+ if (pathname.startsWith(MOTION_ROUTE)) {
107
+ const name = pathname.slice(MOTION_ROUTE.length)
108
+ const safe = /^[A-Za-z0-9][A-Za-z0-9._-]*\.webp$/.test(name)
109
+ const file = safe ? join(here, 'assets', 'motion', name) : null
110
+ if (file === null || !existsSync(file)) { res.writeHead(404); res.end(); return }
111
+ clipHits.set(name, (clipHits.get(name) ?? 0) + 1)
112
+ const bytes = readFileSync(file)
113
+ res.writeHead(200, { 'content-type': 'image/webp', 'content-length': String(bytes.length) })
114
+ res.end(bytes)
115
+ return
116
+ }
117
+ res.writeHead(404); res.end()
118
+ })
119
+ const ORIGIN = await new Promise((resolve, reject) => {
120
+ clipServer.once('error', reject)
121
+ clipServer.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${clipServer.address().port}`))
122
+ })
123
+
58
124
  const profile = mkdtempSync(join(tmpdir(), 'wisp-probe-'))
59
125
  const child = spawn(executablePath, [
60
126
  '--headless=new', '--disable-gpu', '--no-first-run', '--no-default-browser-check', '--disable-extensions',
@@ -75,6 +141,7 @@ const head = (title) => console.log(`\n=== ${title} ===`)
75
141
 
76
142
  const cleanup = async () => {
77
143
  try { child.kill() } catch (e) { /* ignore */ }
144
+ try { clipServer.close() } catch (e) { /* ignore */ }
78
145
  await sleep(300)
79
146
  try { rmSync(profile, { recursive: true, force: true }) } catch (e) { /* ignore */ }
80
147
  }
@@ -131,6 +198,22 @@ try {
131
198
  await send('Runtime.enable')
132
199
  await send('Page.enable')
133
200
 
201
+ /* 页面必须落在一个**真的 origin** 上(见上面 clipServer 那段):动图是相对
202
+ `document.baseURI` 解析的,about:blank 上解析不出来 —— 而那是探针环境的问题,
203
+ 不是插件的问题。导航之后等 load 再注入半包。 */
204
+ const loaded = new Promise((resolve) => {
205
+ const onMessage = (ev) => {
206
+ const msg = JSON.parse(ev.data)
207
+ if (msg.method === 'Page.loadEventFired') { ws.removeEventListener('message', onMessage); resolve(true) }
208
+ }
209
+ ws.addEventListener('message', onMessage)
210
+ })
211
+ await send('Page.navigate', { url: `${ORIGIN}/probe.html` })
212
+ await Promise.race([loaded, sleep(5000)])
213
+ const origin = await evaluate('location.origin')
214
+ check(origin === ORIGIN, 'the probe page runs on a real origin, so the clip URLs can resolve',
215
+ `${String(origin)} vs ${ORIGIN}`)
216
+
134
217
  console.log(`engine-probe: ${executablePath}`)
135
218
 
136
219
  head('mount')
@@ -234,6 +317,47 @@ try {
234
317
  check(String(focusedAfterClick).includes('wisp-body'),
235
318
  'clicking her still hands her the keyboard focus (so Enter still opens the menu)',
236
319
  String(focusedAfterClick))
320
+ /* 焦点环:只能是**贴着剪影的辉光**,不能是画在盒子上的矩形。用户报的"有时会出现
321
+ 素材的矩形边界线"就是后者 —— 默认焦点环画在 .wisp-body 那个 560×840 的盒子上,
322
+ 而盒子里大部分是透明的,于是一次点击之后屏幕上凭空多出一个方框。
323
+ 这里量的是引擎**真的算出来**的样式:outline-style 必须是 none,而不是 auto
324
+ (修之前这里就是 auto —— 假 DOM 验不出来,它是浏览器的默认行为)。 */
325
+ const ringState = () => evaluate(`(() => {
326
+ const body = document.querySelector('.wisp-body')
327
+ const root = document.querySelector('.wisp-root')
328
+ const img = document.querySelector('.wisp-img')
329
+ const filter = getComputedStyle(img).filter
330
+ return {
331
+ outline: getComputedStyle(body).outlineStyle,
332
+ focusAttr: root.dataset.focus === undefined ? null : root.dataset.focus,
333
+ shadows: (filter.match(/drop-shadow\\(/g) || []).length,
334
+ }
335
+ })()`)
336
+ const ringAfterClick = await ringState()
337
+ check(ringAfterClick.outline === 'none' && ringAfterClick.focusAttr === null,
338
+ 'a click gives her focus but paints no box outline (the default focus ring is off)',
339
+ JSON.stringify(ringAfterClick))
340
+
341
+ /* 键盘路径:Enter 开菜单、Esc 关掉并把焦点交还给她 —— 关掉默认环之后,键盘用户
342
+ 必须仍然看得见焦点,而且那圈光必须跟着她的剪影走(多一层 drop-shadow)。 */
343
+ const pressKey = async (k, code, keyCode) => {
344
+ await send('Input.dispatchKeyEvent', { type: 'rawKeyDown', key: k, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode })
345
+ await send('Input.dispatchKeyEvent', { type: 'keyUp', key: k, code, windowsVirtualKeyCode: keyCode, nativeVirtualKeyCode: keyCode })
346
+ await sleep(350)
347
+ }
348
+ await pressKey('Enter', 'Enter', 13)
349
+ await pressKey('Escape', 'Escape', 27)
350
+ const ringByKeyboard = await ringState()
351
+ check(ringByKeyboard.outline === 'none' && ringByKeyboard.focusAttr === 'key'
352
+ && ringByKeyboard.shadows === ringAfterClick.shadows + 1,
353
+ 'keyboard focus is shown by a silhouette halo (one more drop-shadow), never by a box outline',
354
+ JSON.stringify(ringByKeyboard))
355
+ await evaluate(`document.querySelector('.wisp-body').blur()`)
356
+ /* 滤镜有 .5s 过渡 —— 等它走完再量,否则量到的是「正在淡出」的那一帧。 */
357
+ await sleep(700)
358
+ const ringCleared = await ringState()
359
+ check(ringCleared.focusAttr === null && ringCleared.shadows === ringAfterClick.shadows,
360
+ 'and the halo is gone the moment focus leaves her', JSON.stringify(ringCleared))
237
361
  await evaluate(`window.__wisp.move(${wasAt.x}, ${wasAt.y})`)
238
362
 
239
363
  await sleep(1200)
@@ -244,7 +368,7 @@ try {
244
368
  amp: getComputedStyle(document.querySelector('.wisp-root')).getPropertyValue('--wisp-amp').trim(),
245
369
  tilt: document.querySelector('.wisp-root').style.getPropertyValue('--wisp-tilt'),
246
370
  }))()`)
247
- check(base.bodyAnim === 'wisp-bob', 'she is breathing (wisp-bob really applies)', base.bodyAnim)
371
+ check(base.bodyAnim === 'none', 'the standing bob is gone - nothing animates the body layer any more', base.bodyAnim)
248
372
  check(base.amp === '1' && base.tilt === '0deg',
249
373
  'the amplitude variable and the initial posture resolve', `--wisp-amp=${base.amp} --wisp-tilt=${base.tilt}`)
250
374
 
@@ -412,6 +536,380 @@ try {
412
536
  check(rm.bodyAnim === 'none' && rm.motionAnim === 'none',
413
537
  'and the media query stops every animation', `${rm.bodyAnim} / ${rm.motionAnim}`)
414
538
 
539
+ /* ---------------------- 帧动画:这一段只有真引擎能回答 ----------------------
540
+ 假 DOM 能证明"把动图藏起来了、画面交回立绘",证明不了**这张动图真的解得开**:
541
+ 动图 WebP 在浏览器里出不出画、`naturalWidth` 是不是 720、叠在立绘上有没有
542
+ 对齐 —— 全是解码与合成的事,而它的失败在页面上表现为"她还是不动",和"没有
543
+ 素材"长得一模一样。
544
+ 这里**不读像素**:alpha 是 WebP 格式自己保证的(角落就是透的),把一张 720x1280
545
+ 的动图缩进 8x8 画布只能测出 canvas 的重采样,测不出格式;而"她是不是真的透"
546
+ 已经由 `<img>` + WebP 这条路径决定了,不需要探针再证一遍。 */
547
+ head('the frame animations (real animated WebPs)')
548
+ /* 上一节把 Emulation 设成了 reduce 而且没恢复 —— 先清掉,
549
+ 否则下面看到的"冻结"其实是上一节那个设置还在(那不是这条要测的东西)。 */
550
+ await send('Emulation.setEmulatedMedia', { features: [] })
551
+
552
+ /* ---- idle:**默认**状态就该有动图(v1.46.5)----
553
+ 假 DOM 能证明"元素建了、映射对、显隐跟着状态走",证明不了这张 720x1280 的动图
554
+ 在真引擎里**解得开**:naturalWidth 是不是素材自己的宽度、变换**之前**是不是
555
+ 真的落在立绘那个盒子里。idle 不做尺寸校正(差 1.0~1.6%,小于 2% 那条线),
556
+ 所以它的计算后 transform 必须是 none —— 这条同时钉住"base 那条 1.186 没有
557
+ 落到 idle 头上"(落到她头上就是放大 18.6%,而页面上只会看起来"她今天有点大")。 */
558
+ const idleClip = await evaluate(`(async () => {
559
+ const waitFor = async (ok, ms) => {
560
+ const until = Date.now() + ms
561
+ while (Date.now() < until) { if (ok()) return true; await new Promise((r) => setTimeout(r, 50)) }
562
+ return ok()
563
+ }
564
+ window.__wisp.configure({ motion: 'full' })
565
+ window.__wisp.mood('idle')
566
+ await waitFor(() => document.querySelector('.wisp-video') !== null, 5000)
567
+ const motion = document.querySelector('.wisp-video')
568
+ if (!motion) return { built: false }
569
+ await waitFor(() => motion.complete && motion.naturalWidth > 0, 8000)
570
+ const img = document.querySelector('.wisp-img')
571
+ /* 与立绘同格必须在**变换之前**量:idle 没有变换,但这条断言要能和 sleepy 那段
572
+ 用同一把尺子(那边要临时把 transform 摘掉才量得到布局盒)。 */
573
+ const saved = motion.style.transform
574
+ motion.style.transform = 'none'
575
+ const a = motion.getBoundingClientRect()
576
+ motion.style.transform = saved
577
+ const b = img.getBoundingClientRect()
578
+ return {
579
+ built: true, tag: String(motion.tagName), clip: motion.dataset.clip ?? null,
580
+ complete: motion.complete, w: motion.naturalWidth, h: motion.naturalHeight,
581
+ display: getComputedStyle(motion).display,
582
+ transform: getComputedStyle(motion).transform,
583
+ inline: motion.style.transform,
584
+ dx: Math.abs(a.left - b.left), dy: Math.abs(a.top - b.top),
585
+ dw: Math.abs(a.width - b.width), dh: Math.abs(a.height - b.height),
586
+ visibility: getComputedStyle(img).visibility,
587
+ frame: document.querySelector('.wisp-root').dataset.frame ?? null,
588
+ }
589
+ })()`)
590
+ check(idleClip.built === true && idleClip.tag === 'IMG' && idleClip.clip === 'idle',
591
+ 'the idle state really builds the motion layer in the engine — the standing loop is live at mount (v1.46.5)',
592
+ idleClip.built ? `${idleClip.tag} clip=${String(idleClip.clip)}` : 'no motion layer was built')
593
+ check(idleClip.built === true && idleClip.complete === true
594
+ && idleClip.w === IDLE_CANVAS.w && idleClip.h === IDLE_CANVAS.h,
595
+ "and the idle animated WebP decodes at the asset's own size — naturalWidth is read from assets/motion/idle.webp, not hard-coded here",
596
+ idleClip.built ? `${idleClip.w}x${idleClip.h} vs asset ${IDLE_CANVAS.w}x${IDLE_CANVAS.h} complete=${idleClip.complete}` : 'no <img> was built')
597
+ check(idleClip.built === true && idleClip.dx < 1 && idleClip.dy < 1 && idleClip.dw < 1 && idleClip.dh < 1,
598
+ 'and it is laid out on exactly the sprite box, so handing the picture over cannot make her jump',
599
+ `delta ${Number(idleClip.dx ?? NaN).toFixed(2)},${Number(idleClip.dy ?? NaN).toFixed(2)} size ${Number(idleClip.dw ?? NaN).toFixed(2)}x${Number(idleClip.dh ?? NaN).toFixed(2)}`)
600
+ check(idleClip.built === true && String(idleClip.transform) === 'none' && idleClip.inline === 'none',
601
+ "and NO size correction is applied to it — computed transform is none, so the sleeping clip's 1.186 did not leak onto idle (v1.46.5)",
602
+ `computed ${String(idleClip.transform)} / inline ${JSON.stringify(idleClip.inline)}`)
603
+ check(idleClip.built === true && idleClip.display !== 'none'
604
+ && idleClip.visibility === 'hidden' && idleClip.frame === 'on',
605
+ 'and it owns the picture while the sprite is hidden — the two layers are never visible at once (v1.45.2)',
606
+ `display=${String(idleClip.display)} sprite visibility=${String(idleClip.visibility)} data-frame=${String(idleClip.frame)}`)
607
+
608
+ const clip = await evaluate(`(async () => {
609
+ const waitFor = async (ok, ms) => {
610
+ const until = Date.now() + ms
611
+ while (Date.now() < until) { if (ok()) return true; await new Promise((r) => setTimeout(r, 50)) }
612
+ return ok()
613
+ }
614
+ window.__wisp.configure({ motion: 'full' })
615
+ window.__wisp.mood('sleep')
616
+ await waitFor(() => document.querySelector('.wisp-video') !== null, 5000)
617
+ const motion = document.querySelector('.wisp-video')
618
+ if (!motion) return { built: false }
619
+ await waitFor(() => motion.complete && motion.naturalWidth > 0, 8000)
620
+ const img = document.querySelector('.wisp-img')
621
+ /* 尺寸校正(v1.46.4):动作层**故意**比立绘那一格大 —— 她在 720x1280 的素材里只占
622
+ 0.8352 个盒高,立绘占 0.9883。所以"两层同格"必须量**变换之前**的布局盒:
623
+ 行内 transform 临时置 none 读一次 rect,读完立刻还回去(行内这条盖得住样式表那条,
624
+ 所以快照真的是干净的布局盒)。渲染后的 rect 单独读一次,用来验放大倍率与底边落点。 */
625
+ const savedZoom = motion.style.transform
626
+ motion.style.transform = 'none'
627
+ const a = motion.getBoundingClientRect()
628
+ motion.style.transform = savedZoom
629
+ const b = img.getBoundingClientRect()
630
+ const cs = getComputedStyle(motion)
631
+ const shown = motion.getBoundingClientRect()
632
+ return {
633
+ built: true, tag: String(motion.tagName), isImg: motion instanceof HTMLImageElement,
634
+ src: String(motion.src).slice(0, 5),
635
+ complete: motion.complete, w: motion.naturalWidth, h: motion.naturalHeight,
636
+ display: getComputedStyle(motion).display,
637
+ pointerEvents: getComputedStyle(motion).pointerEvents,
638
+ /* 画面归谁(v1.45.2):这一条是**计算后**的 visibility,只有真引擎算得出来 ——
639
+ 假 DOM 里没有级联,只能读到"属性写了、规则也在",证明不了它真的生效。 */
640
+ visibility: getComputedStyle(img).visibility,
641
+ frame: document.querySelector('.wisp-root').dataset.frame ?? null,
642
+ dx: Math.abs(a.left - b.left), dy: Math.abs(a.top - b.top),
643
+ dw: Math.abs(a.width - b.width), dh: Math.abs(a.height - b.height),
644
+ transform: cs.transform,
645
+ origin: motion.style.transformOrigin,
646
+ layoutH: a.height,
647
+ zoom: shown.height / a.height,
648
+ drop: (shown.bottom - b.bottom) / a.height,
649
+ videos: document.querySelector('.wisp-root').querySelectorAll('video').length,
650
+ }
651
+ })()`)
652
+ check(clip.built === true && clip.tag === 'IMG' && clip.isImg === true && clip.videos === 0,
653
+ 'the motion layer really is an <img> in the engine — and not a single <video> exists under her root',
654
+ clip.built ? `${clip.tag} isImg=${clip.isImg} videos=${clip.videos}` : 'no motion layer was built')
655
+ check(clip.complete === true && clip.w === 720 && clip.h === 1280,
656
+ 'the animated WebP really decodes in the engine — 720x1280 natural size, not just a data URI in the bundle',
657
+ clip.built ? `${clip.src}… ${clip.w}x${clip.h} complete=${clip.complete}` : 'no <img> was built')
658
+ check(clip.display !== 'none',
659
+ 'and it is on screen by default — the browser runs the animation itself', `display=${clip.display}`)
660
+ check(clip.dx < 1 && clip.dy < 1 && clip.dw < 1 && clip.dh < 1,
661
+ 'and BEFORE the size correction it is laid out on exactly the sprite box, so handing the picture over cannot make her jump',
662
+ `delta ${clip.dx.toFixed(2)},${clip.dy.toFixed(2)} size ${clip.dw.toFixed(2)}x${clip.dh.toFixed(2)}`)
663
+ /* 尺寸校正(v1.46.4):**计算后**的 transform 才是"引擎认不认"的答案 —— 源码里写着
664
+ scale(1.186) 不算数,它要么生效、要么被别的东西盖掉。两个数一起看:
665
+ 她的显示高度放大了多少倍,底边相对立绘那条落地线下移了多少(都按盒高归一化)。 */
666
+ check(/^matrix\(1\.186/.test(String(clip.transform)) && Math.abs(clip.zoom - 1.186) < 0.004,
667
+ 'the engine really applies the size correction — the computed transform is scale(1.186), so she is as tall as the static sprite (v1.46.4)',
668
+ `computed ${clip.transform} → rendered height ${clip.zoom.toFixed(4)}x the box`)
669
+ check(Math.abs(clip.drop - 0.0930) < 0.004 && clip.origin === '50% 100%',
670
+ "and it is dropped 9.3% of its own height about a bottom-centre origin, so her feet land where the sprite's do (v1.46.4)",
671
+ `bottom shift ${(clip.drop * 100).toFixed(2)}% of ${clip.layoutH}px, inline transform-origin ${clip.origin}`)
672
+ check(clip.pointerEvents === 'none', 'the layer takes no pointer events', String(clip.pointerEvents))
673
+ /* 重影修复(v1.45.2):动图在画面上时立绘必须**真的**看不见(计算后的 visibility 是
674
+ hidden,而不是"读源码看到写了 visibility")。 */
675
+ check(clip.visibility === 'hidden' && clip.frame === 'on',
676
+ 'while the animation is on screen the static sprite is hidden in the real engine — the two layers are never visible at once (v1.45.2)',
677
+ `sprite visibility=${clip.visibility} data-frame=${String(clip.frame)}`)
678
+
679
+ const clipFrozen = await evaluate(`(() => {
680
+ window.__wisp.configure({ motion: 'off' })
681
+ const motion = document.querySelector('.wisp-video')
682
+ const img = document.querySelector('.wisp-img')
683
+ return {
684
+ display: getComputedStyle(motion).display,
685
+ sprite: getComputedStyle(img).display !== 'none' && Number(getComputedStyle(img).opacity) > 0.5,
686
+ spriteVisibility: getComputedStyle(img).visibility,
687
+ frame: document.querySelector('.wisp-root').dataset.frame ?? null,
688
+ doctor: window.__wisp.doctor().motion.frame,
689
+ }
690
+ })()`)
691
+ check(clipFrozen.display === 'none',
692
+ '"still" takes the animation off screen in the real engine too — an <img> has no pause(), so hiding it IS the freeze',
693
+ JSON.stringify(clipFrozen))
694
+ check(clipFrozen.sprite === true, 'and the static sprite is still painted underneath', String(clipFrozen.sprite))
695
+ /* 冻结时立绘必须**回来**:它这时是屏幕上唯一的那个她。 */
696
+ check(clipFrozen.spriteVisibility === 'visible' && clipFrozen.frame === null
697
+ && clipFrozen.doctor.showing === false,
698
+ 'and it is visible again, not just present — freezing hands the picture back to the sprite (v1.45.2)',
699
+ `sprite visibility=${clipFrozen.spriteVisibility} data-frame=${String(clipFrozen.frame)} doctor.showing=${clipFrozen.doctor.showing}`)
700
+
701
+ const clipBack = await evaluate(`(async () => {
702
+ window.__wisp.configure({ motion: 'full' })
703
+ await new Promise((r) => setTimeout(r, 400))
704
+ const v = document.querySelector('.wisp-video')
705
+ return { display: getComputedStyle(v).display, tag: String(v.tagName) }
706
+ })()`)
707
+ check(clipBack.display !== 'none' && clipBack.tag === 'IMG',
708
+ 'and putting the level back shows that same <img> again', JSON.stringify(clipBack))
709
+
710
+ /* 会话中途把系统设置改成"减少动态效果":CSS 的媒体查询是实时的,而这层显不显示是
711
+ JS 写的。插件订阅了 matchMedia 的 change,所以这一下必须**当场**生效 —— 不订阅
712
+ 的话,她会在你刚关掉动画之后继续动到下一次换表情。 */
713
+ await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] })
714
+ await sleep(600)
715
+ const clipReduced = await evaluate(`(() => {
716
+ const v = document.querySelector('.wisp-video')
717
+ const img = document.querySelector('.wisp-img')
718
+ return {
719
+ reduced: window.__wisp.doctor().motion.reduced,
720
+ display: getComputedStyle(v).display,
721
+ spriteVisibility: getComputedStyle(img).visibility,
722
+ }
723
+ })()`)
724
+ check(clipReduced.reduced === true && clipReduced.display === 'none'
725
+ && clipReduced.spriteVisibility === 'visible',
726
+ 'turning reduced motion on mid-session takes the animation off screen at once, with no reload',
727
+ JSON.stringify(clipReduced))
728
+ await send('Emulation.setEmulatedMedia', { features: [] })
729
+
730
+ /* 加载/解码失败的兜底:把 error 事件真的派给那张动图。假 DOM 里这条只证明了
731
+ "调了 hideMotion()";这里要证明的是**级联之后**立绘回到 visible —— 那正是
732
+ "动图坏了也还有她"这句话的全部内容。 */
733
+ const clipFailed = await evaluate(`(async () => {
734
+ const waitFor = async (ok, ms) => {
735
+ const until = Date.now() + ms
736
+ while (Date.now() < until) { if (ok()) return true; await new Promise((r) => setTimeout(r, 50)) }
737
+ return ok()
738
+ }
739
+ const v = document.querySelector('.wisp-video')
740
+ const root = document.querySelector('.wisp-root')
741
+ const img = document.querySelector('.wisp-img')
742
+ /* 先等**这一层真的回到画面上**:上一节把 Emulation 关掉之后,matchMedia 的 change
743
+ 是异步来的,这里不等就会在"她还没回来"的状态下快照 before —— 那样这条断言测的
744
+ 就不是"出错会交回画面",而是"上一节还没生效"。 */
745
+ await waitFor(() => v !== null && v.complete
746
+ && getComputedStyle(v).display !== 'none'
747
+ && getComputedStyle(img).visibility === 'hidden', 5000)
748
+ const before = {
749
+ visibility: getComputedStyle(img).visibility,
750
+ frame: root.dataset.frame ?? null,
751
+ tag: String(v.tagName),
752
+ }
753
+ v.dispatchEvent(new Event('error'))
754
+ return {
755
+ before,
756
+ visibility: getComputedStyle(img).visibility,
757
+ display: getComputedStyle(v).display,
758
+ frame: root.dataset.frame ?? null,
759
+ failed: window.__wisp.doctor().motion.frame.failed,
760
+ spriteHidden: window.__wisp.doctor().motion.frame.spriteHidden,
761
+ }
762
+ })()`)
763
+ check(clipFailed.before.visibility === 'hidden' && clipFailed.before.frame === 'on'
764
+ && clipFailed.visibility === 'visible' && clipFailed.display === 'none'
765
+ && clipFailed.frame === null && clipFailed.failed === true,
766
+ 'an animation that errors hands the picture straight back to the static sprite — hidden -> visible in the real engine (v1.45.2)',
767
+ JSON.stringify(clipFailed))
768
+
769
+ await evaluate(`window.__wisp.mood('idle')`)
770
+
771
+ /* ---- 泳装那八条:在真引擎里逐条解开(v1.47.0)------------------------------
772
+ "素材落盘了"和"页面上真的画出来了"是两件事。动图这条路(外置 URL -> <img> ->
773
+ 浏览器解码)只有真引擎能回答三个问题:URL 解析得出来吗、720x1280 的动图 WebP
774
+ 解得开吗(naturalWidth 是不是素材自己的宽度)、在**变换之前**是不是正好落在
775
+ 立绘那一格里。八条是八份独立素材,所以逐条查 —— 一条坏了在页面上只会表现为
776
+ "她这个状态不动",而那是静默降级,没有任何报错。 */
777
+ const SWIM_STATES = [
778
+ ['idle', 'swim_idle'], ['attn', 'swim_attn'], ['happy', 'swim_happy'], ['sleep', 'swim_sleepy'],
779
+ ['alert', 'swim_work'], ['proud', 'swim_proud'], ['eat', 'swim_eat'], ['poked', 'swim_poked'],
780
+ ]
781
+ const SWIM_CANVAS = Object.fromEntries(SWIM_STATES.map(([, clip]) => [clip, motionCanvas(`${clip}.webp`)]))
782
+ /* 判据是**磁盘上实际带着什么**:八条都在就逐条查,一条都没有就查真引擎里的静默降级。
783
+ 两者都是真的断言 —— 没有空过的一档(同 verify-wisp.mjs 的 SHIPPED_SWIM)。 */
784
+ const SHIPPED_SWIM = existsSync(join(here, 'assets', 'motion'))
785
+ ? readdirSync(join(here, 'assets', 'motion')).filter((f) => /^swim_[a-z]+\.webp$/.test(f)).sort()
786
+ : []
787
+ if (SHIPPED_SWIM.length === 8) {
788
+ const swimClips = await evaluate(`(async () => {
789
+ const waitFor = async (ok, ms) => {
790
+ const until = Date.now() + ms
791
+ while (Date.now() < until) { const v = ok(); if (v) return v; await new Promise((r) => setTimeout(r, 50)) }
792
+ return ok()
793
+ }
794
+ window.__wisp.configure({ motion: 'full' })
795
+ window.__wisp.setSkin('swim')
796
+ const out = []
797
+ for (const [state, clip] of ${JSON.stringify(SWIM_STATES)}) {
798
+ window.__wisp.mood(state)
799
+ const motion = await waitFor(() => {
800
+ const el = document.querySelector('.wisp-video')
801
+ return el && el.dataset.clip === clip && el.complete && el.naturalWidth > 0 ? el : null
802
+ }, 8000)
803
+ const img = document.querySelector('.wisp-img')
804
+ if (!motion) { out.push({ state, clip, built: false }); continue }
805
+ /* 与立绘同格必须在**变换之前**量(和 idle / sleepy 两段用同一把尺子)。 */
806
+ const saved = motion.style.transform
807
+ motion.style.transform = 'none'
808
+ const a = motion.getBoundingClientRect()
809
+ motion.style.transform = saved
810
+ const b = img.getBoundingClientRect()
811
+ out.push({
812
+ state, clip, built: true, tag: String(motion.tagName),
813
+ src: String(motion.getAttribute('src')),
814
+ w: motion.naturalWidth, h: motion.naturalHeight, complete: motion.complete,
815
+ display: getComputedStyle(motion).display,
816
+ visibility: getComputedStyle(img).visibility,
817
+ frame: document.querySelector('.wisp-root').dataset.frame ?? null,
818
+ dx: Math.abs(a.left - b.left), dy: Math.abs(a.top - b.top),
819
+ dw: Math.abs(a.width - b.width), dh: Math.abs(a.height - b.height),
820
+ })
821
+ }
822
+ return out
823
+ })()`)
824
+ const swimMissing = swimClips.filter((c) => c.built !== true || c.tag !== 'IMG')
825
+ check(swimMissing.length === 0,
826
+ 'all eight swimsuit clips really decode in the engine — every state builds its own <img> from the host route (v1.47.0)',
827
+ swimMissing.length ? swimMissing.map((c) => `${c.state}/${c.clip ?? '?'}`).join(', ') : swimClips.map((c) => c.clip).join(', '))
828
+ const swimSize = swimClips.filter((c) => c.built === true
829
+ && (c.w !== SWIM_CANVAS[c.clip]?.w || c.h !== SWIM_CANVAS[c.clip]?.h))
830
+ check(swimSize.length === 0,
831
+ "and each one decodes at its asset's own size — naturalWidth comes from assets/motion/<clip>.webp, not from a number written here",
832
+ swimSize.length
833
+ ? swimSize.map((c) => `${c.clip}: ${c.w}x${c.h} vs ${SWIM_CANVAS[c.clip]?.w}x${SWIM_CANVAS[c.clip]?.h}`).join(' | ')
834
+ : swimClips.map((c) => `${c.clip} ${c.w}x${c.h}`).join(', '))
835
+ const swimBox = swimClips.filter((c) => c.built === true
836
+ && !(c.dx < 1 && c.dy < 1 && c.dw < 1 && c.dh < 1))
837
+ check(swimBox.length === 0,
838
+ 'and every one is laid out on exactly the sprite box BEFORE its size correction — the correction is the only thing that moves her, never a second grid',
839
+ swimBox.length
840
+ ? swimBox.map((c) => `${c.clip}: d=${Number(c.dx).toFixed(2)},${Number(c.dy).toFixed(2)} s=${Number(c.dw).toFixed(2)}x${Number(c.dh).toFixed(2)}`).join(' | ')
841
+ : swimClips.map((c) => `${c.clip} d=${Number(c.dx).toFixed(2)},${Number(c.dy).toFixed(2)}`).join(', '))
842
+ const swimOwner = swimClips.filter((c) => c.built === true
843
+ && !(c.display !== 'none' && c.visibility === 'hidden' && c.frame === 'on'))
844
+ check(swimOwner.length === 0,
845
+ 'and while each one plays the swimsuit sprite is out of the picture — one of her, in the real engine (v1.45.2 rule)',
846
+ swimOwner.length
847
+ ? swimOwner.map((c) => `${c.clip}: display=${c.display} sprite=${c.visibility} frame=${String(c.frame)}`).join(' | ')
848
+ : `${swimClips.length} clips: display!=none, sprite hidden, data-frame=on`)
849
+ const swimUrls = swimClips.filter((c) => c.built === true && !String(c.src).startsWith(`${ORIGIN}/wisp-motion/`))
850
+ check(swimUrls.length === 0 && [...SWIM_STATES].every(([, clip]) => (clipHits.get(`${clip}.webp`) ?? 0) > 0),
851
+ 'and every clip really came over HTTP from the page’s own origin — the delivery path, not an inlined copy',
852
+ swimUrls.length
853
+ ? swimUrls.map((c) => `${c.clip}: ${c.src}`).join(' | ')
854
+ : `${clipHits.size} clip file(s) served, e.g. ${String(swimClips[0]?.src)}`)
855
+ } else {
856
+ /* 素材还没生成(0/8):真引擎里要看到的是**静默降级** —— 泳装那一档一个动图元素
857
+ 都不建、立绘一直在画面上,而且**不许**退回通用那段循环(穿着泳装播别的皮肤的
858
+ 动作,比不动更糟)。然后换回一套有素材的皮肤,动图必须立刻回来 ——
859
+ 这一条把"这个皮肤没有素材"和"帧动画坏了"分开。 */
860
+ const swimQuiet = await evaluate(`(async () => {
861
+ const waitFor = async (ok, ms) => {
862
+ const until = Date.now() + ms
863
+ while (Date.now() < until) { const v = ok(); if (v) return v; await new Promise((r) => setTimeout(r, 50)) }
864
+ return ok()
865
+ }
866
+ window.__wisp.configure({ motion: 'full' })
867
+ window.__wisp.setSkin('swim')
868
+ const leaks = []
869
+ for (const [state] of ${JSON.stringify(SWIM_STATES)}) {
870
+ window.__wisp.mood(state)
871
+ await waitFor(() => false, 120)
872
+ const v = document.querySelector('.wisp-video')
873
+ if (v && getComputedStyle(v).display !== 'none') leaks.push(state + ':' + (v.dataset.clip ?? '?'))
874
+ }
875
+ const img = document.querySelector('.wisp-img')
876
+ const quiet = {
877
+ leaks,
878
+ sprite: getComputedStyle(img).visibility,
879
+ frame: document.querySelector('.wisp-root').dataset.frame ?? null,
880
+ asset: window.__wisp.doctor().motion.frame.asset,
881
+ }
882
+ window.__wisp.mood('idle')
883
+ await waitFor(() => false, 120)
884
+ const beforeSwitch = document.querySelector('.wisp-video')
885
+ const beforeVideos = [...document.querySelectorAll('.wisp-video')]
886
+ .map((v) => String(v.dataset.clip) + ':' + getComputedStyle(v).display)
887
+ window.__wisp.setSkin('deepsea')
888
+ /* 用 idle 而不是 sleep:这一页上半段那条"加载失败静默降级"的检查**故意**把
889
+ sleepy 这一段标成坏过的(失败按素材记,不再重指源),拿它当"回来了"的判据
890
+ 会测到那条规则头上。 */
891
+ const back = await waitFor(() => {
892
+ const v = document.querySelector('.wisp-video')
893
+ return v && v.dataset.clip === 'idle' && v.complete && v.naturalWidth > 0 ? v : null
894
+ }, 8000)
895
+ return {
896
+ quiet,
897
+ beforeSwitch: beforeVideos.length === 0 || beforeVideos.every((s) => s.endsWith(':none')),
898
+ beforeVideos,
899
+ backClip: back === null ? null : back.dataset.clip,
900
+ backW: back === null ? 0 : back.naturalWidth,
901
+ }
902
+ })()`)
903
+ check(swimQuiet.quiet.leaks.length === 0 && swimQuiet.quiet.sprite === 'visible'
904
+ && swimQuiet.quiet.frame === null && swimQuiet.quiet.asset === null,
905
+ 'with no swimsuit clips shipped, the engine builds NO frame layer for any of the eight states — and the sprite keeps the picture (v1.47.0)',
906
+ `leaks=${swimQuiet.quiet.leaks.join(',') || 'none'} sprite=${swimQuiet.quiet.sprite} data-frame=${String(swimQuiet.quiet.frame)} asset=${String(swimQuiet.quiet.asset)}`)
907
+ check(swimQuiet.beforeSwitch === true && swimQuiet.backClip === 'idle' && swimQuiet.backW > 0,
908
+ 'and a skin WITH clips brings the animation straight back in the same engine — missing swimsuit art is not a broken frame layer',
909
+ `swim/idle quiet=${swimQuiet.beforeSwitch} → deepsea/idle clip=${String(swimQuiet.backClip)} ${swimQuiet.backW}px wide`)
910
+ console.log(` NOTE ${SHIPPED_SWIM.length}/8 swimsuit clips generated yet — the eight-clip checks are replaced by the degradation checks above`)
911
+ }
912
+
415
913
  console.log(`\n=== ${failures === 0 ? 'ENGINE PROBE PASSED' : `${failures} PROBE CHECK(S) FAILED`} ===`)
416
914
  } finally {
417
915
  try { ws.close() } catch (e) { /* ignore */ }