dsh-plugin-vrmpet3d 0.0.0-stage → 0.1.0

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 (67) hide show
  1. package/LICENSE +43 -0
  2. package/README.md +249 -2
  3. package/assets/models/BlueFatFish.vrm.br +0 -0
  4. package/assets/models/README.md +99 -0
  5. package/assets/models/SampleModel.vrm.br +0 -0
  6. package/assets/models/vrmpet3d-models.json +116 -0
  7. package/assets/motions/README.md +177 -0
  8. package/assets/motions/attention/wave hand 1.vrma.br +0 -0
  9. package/assets/motions/attention/wave hand 2.vrma.br +0 -0
  10. package/assets/motions/attention/wave hand 3.vrma.br +0 -0
  11. package/assets/motions/dance/dance1.vrma.br +0 -0
  12. package/assets/motions/dance/dance2.vrma.br +0 -0
  13. package/assets/motions/fail/fall down1.vrma.br +0 -0
  14. package/assets/motions/fail/fall down2.vrma.br +0 -0
  15. package/assets/motions/idle/hungry1.vrma.br +0 -0
  16. package/assets/motions/idle/hungry2.vrma.br +0 -0
  17. package/assets/motions/idle/idle lookaround1.vrma.br +0 -0
  18. package/assets/motions/idle/idle lookaround2.vrma.br +0 -0
  19. package/assets/motions/idle/idle stretch1.vrma.br +0 -0
  20. package/assets/motions/idle/idle stretch2.vrma.br +0 -0
  21. package/assets/motions/idle/idle stretch3.vrma.br +0 -0
  22. package/assets/motions/idle/idle stretch4.vrma.br +0 -0
  23. package/assets/motions/idle/idle.vrma.br +0 -0
  24. package/assets/motions/idle/idle2.vrma.br +0 -0
  25. package/assets/motions/idle/idle3.vrma.br +0 -0
  26. package/assets/motions/idle/idle4.vrma.br +0 -0
  27. package/assets/motions/idle/touch hair1.vrma.br +0 -0
  28. package/assets/motions/idle/touch hair2.vrma.br +0 -0
  29. package/assets/motions/offline/sleeping.vrma.br +0 -0
  30. package/assets/motions/success/jump 1.vrma.br +0 -0
  31. package/assets/motions/success/jump 2.vrma.br +0 -0
  32. package/assets/motions/success/jump 3.vrma.br +0 -0
  33. package/assets/motions/vrmpet3d-motions.json +283 -0
  34. package/assets/motions/working/dance1.vrma.br +0 -0
  35. package/cordis.patch.yml +7 -0
  36. package/desktop/main.js +1782 -0
  37. package/desktop/package.json +7 -0
  38. package/desktop/preload.js +66 -0
  39. package/desktop/probe-motion-picker.js +122 -0
  40. package/desktop/probe-parts-editor.js +247 -0
  41. package/lib/brotli.js +74 -0
  42. package/lib/client.core.js +7563 -0
  43. package/lib/client.js +5031 -0
  44. package/lib/client.menu.js +501 -0
  45. package/lib/client.scene.js +5128 -0
  46. package/lib/client.vendor.js +55286 -0
  47. package/lib/config-page.html +194 -0
  48. package/lib/config-page.js +4754 -0
  49. package/lib/core.js +7541 -0
  50. package/lib/desktop-page.html +131 -0
  51. package/lib/desktop-page.js +3927 -0
  52. package/lib/desktop.js +536 -0
  53. package/lib/env.js +20 -0
  54. package/lib/global-page.html +338 -0
  55. package/lib/index.js +4170 -0
  56. package/lib/menu.js +479 -0
  57. package/lib/model-picker.js +794 -0
  58. package/lib/pages.css +350 -0
  59. package/lib/picker-page.html +64 -0
  60. package/lib/picker-page.js +145 -0
  61. package/lib/placeholder.js +566 -0
  62. package/lib/scene.js +5106 -0
  63. package/lib/settings.js +259 -0
  64. package/lib/setup-page.html +240 -0
  65. package/lib/setup-page.js +706 -0
  66. package/lib/vrm-info.js +926 -0
  67. package/package.json +55 -4
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "vrmpet3d-desktop-window",
3
+ "version": "0.0.0",
4
+ "private": true,
5
+ "description": "Electron entry for the pet's always-on-top window, started by the plugin's host half.",
6
+ "main": "main.js"
7
+ }
@@ -0,0 +1,66 @@
1
+ // The bridge between the pet's window and the page inside it.
2
+ //
3
+ // Deliberately tiny, and deliberately the ONLY way the page can talk to Electron: the page
4
+ // is a normal web page loading the plugin's own sources, and it runs with
5
+ // `contextIsolation: true` and no Node integration. Everything crossing this bridge is a
6
+ // plain number or a boolean, so a compromised page cannot reach the filesystem, the
7
+ // process, or any other window.
8
+ const { contextBridge, ipcRenderer } = require('electron');
9
+
10
+ contextBridge.exposeInMainWorld('vrmpet3dDesktop', {
11
+ /**
12
+ * Ask for the launch configuration: which model to show, where the asset route lives.
13
+ * Sent as an invoke so the page can await it before mounting anything.
14
+ */
15
+ config: () => ipcRenderer.invoke('vrmpet3d:config'),
16
+
17
+ /** The global cursor position, pushed by the main process at frame rate. */
18
+ onCursor: (callback) => {
19
+ if (typeof callback !== 'function') return;
20
+ ipcRenderer.on('vrmpet3d:cursor', (_event, point) => callback(point));
21
+ },
22
+
23
+ /**
24
+ * Tell the main process where the pet is, so it can decide between receiving clicks and
25
+ * passing them through. `dragging` keeps the window interactive for the whole gesture
26
+ * rather than only while the cursor happens to be over the pet.
27
+ */
28
+ reportRect: (rect) => ipcRenderer.send('vrmpet3d:rect', rect),
29
+
30
+ /** Announce that the pet is mounted and drawing, with the facts the launcher reports. */
31
+ ready: (info) => ipcRenderer.send('vrmpet3d:ready', info),
32
+
33
+ /**
34
+ * Ask for a MODEL FILE, with a NATIVE file picker.
35
+ *
36
+ * A FILE, not a folder, and the operator's reason is the design: 「选择VRM模型所在的文件,并导入到插件的本地配置
37
+ * 中去,不再是选中一个文件夹了,万一文件夹移位了,也会出bug」. The launcher answers with the chosen path — or null
38
+ * when the user cancelled — and the PAGE hands it to the HOST over `/vrmpet3d/models`, which COPIES it into the
39
+ * plugin's own storage. The host stays the only writer of that list, and the `VRMPET3D_MODEL_FILES_FILE` /
40
+ * `VRMPET3D_IMPORT_DIR` overrides the suites rely on are not bypassed.
41
+ */
42
+ pickModelFile: () => ipcRenderer.invoke('vrmpet3d:pick-path', { kind: 'model' }),
43
+
44
+ /**
45
+ * The same dialog for an imported MOTION folder (`/vrmpet3d/motions`), which stays a DIRECTORY: a motion pack is
46
+ * a folder of clips filed by state, and the host copies the folder rather than a single file out of it.
47
+ *
48
+ * One channel for both, with `kind` deciding a FILE dialog from a folder one and the title each shows.
49
+ */
50
+ pickMotionDir: () => ipcRenderer.invoke('vrmpet3d:pick-path', { kind: 'motion' }),
51
+
52
+ /**
53
+ * Tell the launcher that a VIEW ORBIT is starting or ending — and let IT take the pointer lock.
54
+ *
55
+ * The page cannot do it itself, and that is a measurement rather than a rule of thumb: this window is created
56
+ * `focusable: false`, and Chromium refuses a lock request from a document that is not focused (「The root
57
+ * document of this element is not valid for pointer lock.」). Even once the launcher has focused it, a request
58
+ * that the PAGE makes has crossed an IPC boundary, and that is not the moment Chromium wants. So the launcher
59
+ * owns both halves: it focuses the window and requests the lock with Electron's `userGesture` flag, and it
60
+ * exits the lock when the drag ends. The page only READS the result (`pointerlockchange`).
61
+ */
62
+ orbit: (active) => ipcRenderer.send('vrmpet3d:orbit', active === true),
63
+
64
+ /** The one control the page has over its own window. */
65
+ quit: () => ipcRenderer.send('vrmpet3d:quit'),
66
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * The 状态与动作 clip picker's probe: ONE script, evaluated in the settings window by `desktop/main.js`.
3
+ *
4
+ * It lives in its own file for the reason `probe-parts-editor.js` documents: a template literal inside `main.js`
5
+ * is one stray backtick away from breaking the LAUNCHER rather than a test, and a plain file can be proved to
6
+ * parse by the suite before anything is spawned.
7
+ *
8
+ * WHY IT EXISTS. The operator reported "the last clip cannot be selected" on 2026-10-12, and the cause was in
9
+ * the page's own collection step: `onMotionToggle` gathered EVERY checked checkbox in the state's row, which
10
+ * included each ticked clip's 凝视 switch — and a checkbox written without a `value` attribute has the DOM
11
+ * default `'on'`, so ticking a clip stored the literal string `on` as a clip id. Those junk entries then took
12
+ * slots in the per-state list, and the clip the operator had just ticked was the one pushed out. Nothing outside
13
+ * the page can see that: the ids arrive at the host as a perfectly valid-looking POST.
14
+ *
15
+ * So this probe drives the real box and compares two numbers that must agree — what the PAGE thinks is selected
16
+ * against what the CORE will keep — and reports the junk. Nothing here is asserted; the numbers travel to
17
+ * `scripts/verify-desktop.mjs`, where the suite decides.
18
+ *
19
+ * It is an EXPRESSION (an immediately-invoked async arrow), so `executeJavaScript` awaits it and hands back the
20
+ * resolved JSON string.
21
+ */
22
+ (async () => {
23
+ const state = () => globalThis.__VRMPET3D_CONFIG_STATE__ || {};
24
+ const row = () => document.querySelector('#motion-list .motion-state[data-state="resting"]');
25
+ const tickBoxes = () => Array.from(row() ? row().querySelectorAll('.picks label.pick input[type=checkbox]') : []);
26
+ const gazeBoxes = () => Array.from(row() ? row().querySelectorAll('label.clip-gaze input[type=checkbox]') : []);
27
+
28
+ const out = { error: null };
29
+ if (!row()) { out.error = 'no 空闲 box set'; return JSON.stringify(out); }
30
+
31
+ /**
32
+ * THE FOLDER ↔ STATE SCOPING, measured on every state at once.
33
+ *
34
+ * Operator's request (2026-10-12): the folders under `assets/motions` are not a filing convention, they are
35
+ * the mapping — "把对应的动作直接解析到指定目录,这样在选择动作的时候就不会出现很长一串". So each state's box set is
36
+ * offered only its own folder's clips. What is measured here is that promise, per state: which folder the
37
+ * state declares, and whether every box drawn in that row belongs to it. With no `stateFolders` in the
38
+ * payload (an older host) nothing is declared and every state is offered everything — the compatible answer.
39
+ * The suite's stub now declares ONE mapping as well, because the shipped pack declares six and an
40
+ * undeclared-everywhere stub is what let the empty-list defect ship.
41
+ */
42
+ const mappings = (state().motions || {}).stateFolders || {};
43
+ out.stateFolders = mappings;
44
+ out.scoped = (state().motions ? (state().motions.perState || {}) : {});
45
+ // The catalogue as the PAGE holds it, published as id → name, so this needs no second fetch and no guess.
46
+ const byId = new Map(Object.entries((state().motions || {}).offeredNames || {}));
47
+ /**
48
+ * A state's folders as a LIST, whatever the payload carried.
49
+ *
50
+ * A manifest value may be ONE folder or SEVERAL (`"resting": ["idle", "dance"]`), and the host normalises it
51
+ * to a list before serving it. The probe reads either shape, because it measures the PAGE: a page that
52
+ * compared that list to a single folder name is the defect it reported on 2026-10-12.
53
+ */
54
+ const folderList = (value) => (Array.isArray(value) ? value : (value ? [value] : []))
55
+ .filter((name) => typeof name === 'string' && name);
56
+ out.rows = {};
57
+ for (const name of Object.keys(out.scoped)) {
58
+ const stateRow = document.querySelector(`#motion-list .motion-state[data-state="${name}"]`);
59
+ if (!stateRow) continue;
60
+ const all = Array.from(stateRow.querySelectorAll('.picks label.pick input[type=checkbox]'));
61
+ const values = all.map((box) => box.value);
62
+ const folder = mappings[name] || null;
63
+ const folders = folderList(mappings[name]);
64
+ /**
65
+ * What must never be in a scoped row: a clip from ANOTHER folder that the user did not tick. A ticked clip
66
+ * from outside IS drawn, deliberately — a stored mapping is never hidden from the person who made it — so
67
+ * the check is on the unticked rows, which are the list being read when choosing.
68
+ */
69
+ const ticked = new Set(all.filter((box) => box.checked).map((box) => box.value));
70
+ const outside = folders.length
71
+ ? values.filter((id) => !ticked.has(id)
72
+ && !folders.some((dir) => String(byId.get(id) || '').indexOf(dir + '/') === 0))
73
+ : [];
74
+ out.rows[name] = { folder, folders, drawn: values.length, outsideFolder: outside };
75
+ }
76
+
77
+ const boxes = tickBoxes();
78
+ out.offered = boxes.length;
79
+ out.gazeRows = gazeBoxes().length;
80
+ /**
81
+ * What the page's own published state says is selected, and what the core would KEEP from the same list.
82
+ *
83
+ * `motions.ticked` is `core.motionIdList(preferences.motions).length` and `motions.stateIds` is the cleaned
84
+ * list for this state, so a junk id shows up here as a value that is not one of the offered ids — which is the
85
+ * check that would have caught the `on` entries immediately.
86
+ */
87
+ out.tickedBefore = (state().motions || {}).ticked;
88
+ const offeredIds = boxes.map((box) => box.value);
89
+ out.offeredIds = offeredIds;
90
+ out.junkBefore = (((state().motions || {}).stateIds || {}).resting || [])
91
+ .filter((id) => offeredIds.indexOf(id) === -1);
92
+
93
+ // Tick the LAST clip, exactly as the operator did — a real click on a real box.
94
+ const last = boxes[boxes.length - 1];
95
+ if (!last) { out.error = 'the state offers no clips'; return JSON.stringify(out); }
96
+ out.lastId = last.value;
97
+ out.lastDisabled = last.disabled;
98
+ if (!last.checked) last.click();
99
+
100
+ /**
101
+ * Wait for the page to republish rather than reading the state straight away.
102
+ *
103
+ * The published object is rebuilt on a poll, so reading it immediately after the click answers what the page
104
+ * looked like a moment AGO — the same trap `probe-parts-editor.js` documents for the 部位 table.
105
+ */
106
+ const wanted = offeredIds.length;
107
+ for (let attempt = 0; attempt < 40; attempt += 1) {
108
+ const seen = (((state().motions || {}).stateIds || {}).resting || []).length;
109
+ if (seen >= wanted) break;
110
+ await new Promise((resolve) => setTimeout(resolve, 100));
111
+ }
112
+
113
+ const after = state().motions || {};
114
+ out.tickedAfter = after.ticked;
115
+ out.stateIdsAfter = ((after.stateIds || {}).resting || []).slice();
116
+ out.junkAfter = out.stateIdsAfter.filter((id) => offeredIds.indexOf(id) === -1);
117
+ // And the DOM's own answer: a tick that did not stick is what the operator actually saw.
118
+ const sameBox = tickBoxes().filter((box) => box.value === out.lastId)[0];
119
+ out.lastStillTicked = Boolean(sameBox && sameBox.checked);
120
+ out.checkedInDom = tickBoxes().filter((box) => box.checked).map((box) => box.value);
121
+ return JSON.stringify(out);
122
+ })();
@@ -0,0 +1,247 @@
1
+ /**
2
+ * The 部位 editor's probe: ONE script, evaluated in the settings window by `desktop/main.js`.
3
+ *
4
+ * It lives in its own file because it used to be a template literal INSIDE main.js — and a stray backtick in a
5
+ * comment (twice: once in a comment mentioning a route, once in one mentioning a `<datalist>`) ended that literal
6
+ * early. The result was not a test failure but a BROKEN LAUNCHER: `main.js` no longer parsed, so the floating
7
+ * window could not open at all, and the only symptom was an Electron error dialog on the operator's screen. A
8
+ * plain file cannot be broken that way, and `node --check desktop/probe-parts-editor.js` can prove it parses —
9
+ * which `scripts/verify-desktop.mjs` now does before it spawns anything.
10
+ *
11
+ * It is an EXPRESSION (an immediately-invoked async arrow), so `executeJavaScript` awaits it and hands back the
12
+ * resolved JSON string.
13
+ *
14
+ * Nothing here is asserted: the numbers travel to `scripts/verify-desktop.mjs`, where the suite decides.
15
+ */
16
+ (async () => {
17
+ const state = () => globalThis.__VRMPET3D_CONFIG_STATE__ || {};
18
+ const parts = () => (state().parts || {});
19
+ const rows = () => Array.from(document.querySelectorAll('#part-body tr:not(.part-detail)'));
20
+ // COUNTED FROM THE DOM, not from the published state: that state is republished on a poll, so reading it
21
+ // straight after a click answers what the page looked like a moment AGO — measured as "新增部位 adds a part
22
+ // (5 -> 5)" while the row it had just added was right there in the table.
23
+ const out = { labelsBefore: (parts().labels || []).slice() };
24
+ out.before = rows().length;
25
+ const add = document.getElementById('part-add');
26
+ if (!add) { out.error = 'no 新增部位 button'; return JSON.stringify(out); }
27
+ add.click();
28
+ out.afterAdd = rows().length;
29
+ const last = rows()[rows().length - 1];
30
+ if (!last) { out.error = 'no row after adding'; return JSON.stringify(out); }
31
+ /**
32
+ * THE BROWSER PANEL, driven the way the operator complained about.
33
+ *
34
+ * The report was "添加结构的下拉框无法滚动,也没有滚动条" — a native input+datalist popup, drawn by Chromium and
35
+ * impossible to scroll or inspect from here. The panel that replaced it is the page's own DOM, so these are the
36
+ * things that make it usable: it opens, it is FULL of the model's names, it really scrolls (a scrollTop that
37
+ * moves, against a box shorter than its content), and clicking a row adds that structure to the part.
38
+ */
39
+ const browse = rows()[rows().length - 1].querySelector('.part-browse');
40
+ if (browse) browse.click();
41
+ const list = document.getElementById('part-picker-list');
42
+ const panel = document.getElementById('part-picker');
43
+ out.pickerOpen = Boolean(panel) && panel.hidden === false;
44
+ const names = list ? Array.from(list.querySelectorAll('.part-picker-row')) : [];
45
+ out.pickerRows = names.length;
46
+ out.pickerSample = names.slice(0, 3).map((row) => row.getAttribute('data-structure'));
47
+ out.pickerScrollHeight = list ? list.scrollHeight : 0;
48
+ out.pickerClientHeight = list ? list.clientHeight : 0;
49
+ if (list) {
50
+ list.scrollTop = 60;
51
+ out.pickerScrolledTo = list.scrollTop;
52
+ }
53
+ // The filter narrows the list without touching the table beneath it.
54
+ const filter = document.getElementById('part-picker-filter');
55
+ if (filter) {
56
+ filter.value = 'hair';
57
+ filter.dispatchEvent(new Event('input'));
58
+ out.pickerFilteredRows = list ? list.querySelectorAll('.part-picker-row').length : 0;
59
+ filter.value = '';
60
+ filter.dispatchEvent(new Event('input'));
61
+ }
62
+ // And a click on a row is how a structure is added from the browser.
63
+ const target = list ? list.querySelector('.part-picker-row') : null;
64
+ out.pickerPicked = target ? target.getAttribute('data-structure') : null;
65
+ if (target) target.click();
66
+ out.chipsFromPicker = rows()[rows().length - 1].querySelectorAll('.chip').length;
67
+ const closePicker = document.getElementById('part-picker-close');
68
+ if (closePicker) closePicker.click();
69
+ out.pickerClosed = Boolean(panel) && panel.hidden === true;
70
+ /**
71
+ * And 浏览… opens it AGAIN after 关闭. The structure browser keeps its open state in module state and a static
72
+ * panel, so it has no stale-reference trap to fall into — this is the assertion that says so, right next to the one
73
+ * that caught it in the expression picker.
74
+ */
75
+ const browseAgain = rows()[rows().length - 1].querySelector('.part-browse');
76
+ if (browseAgain) browseAgain.click();
77
+ out.pickerReopened = Boolean(panel) && panel.hidden === false;
78
+ if (closePicker) closePicker.click();
79
+ out.pickerClosedAgain = Boolean(panel) && panel.hidden === true;
80
+ // Back to a clean part for the gestures below, which are about the text box and the chips.
81
+ rows()[rows().length - 1].querySelectorAll('.chip-drop').forEach((drop) => drop.click());
82
+ const box = rows()[rows().length - 1].querySelector('.part-add-structure');
83
+ if (!box) { out.error = 'no structure box'; return JSON.stringify(out); }
84
+ box.value = 'LiuHai';
85
+ box.dispatchEvent(new Event('change'));
86
+ let row = rows()[rows().length - 1];
87
+ const chip = row.querySelector('.chip');
88
+ out.chip = chip ? chip.firstChild.textContent : null;
89
+ out.chipHint = chip ? (chip.querySelector('.note') ? chip.querySelector('.note').textContent.trim() : null) : null;
90
+ out.structures = row.querySelectorAll('.chip').length;
91
+ const mode = row.querySelector('.chip-mode');
92
+ out.modeBefore = mode ? mode.textContent : null;
93
+ if (mode) mode.click();
94
+ row = rows()[rows().length - 1];
95
+ const modeAfter = row.querySelector('.chip-mode');
96
+ out.modeAfter = modeAfter ? modeAfter.textContent : null;
97
+ const drop = row.querySelector('.chip-drop');
98
+ if (drop) drop.click();
99
+ row = rows()[rows().length - 1];
100
+ out.chipsAfterDrop = row.querySelectorAll('.chip').length;
101
+ /**
102
+ * RENAME and DELETE, the other two things the section promises.
103
+ *
104
+ * Both go through a full repaint of the table, so every read below re-queries its row: holding one across a
105
+ * change is what made the first version of this probe report "5 -> 5".
106
+ */
107
+ const label = rows()[rows().length - 1].querySelector('.part-label');
108
+ if (label) {
109
+ label.value = '刘海';
110
+ label.dispatchEvent(new Event('change'));
111
+ }
112
+ out.renamedLabel = rows()[rows().length - 1].querySelector('.part-label').value;
113
+ out.pickerHasModelNames = out.pickerSample.some((entry) => /head|hair|arm|tail|eye/i.test(String(entry)));
114
+ const remove = rows()[rows().length - 1].querySelector('.part-remove');
115
+ if (remove) remove.click();
116
+ out.afterDelete = rows().length;
117
+ out.after = rows().length;
118
+ out.rowsDrawn = rows().length;
119
+ out.publishedCount = parts().count;
120
+
121
+ /**
122
+ * THE JUMP — the report this block exists for now: "在部位中勾选或者取消表情时,每次点击都会让页面返回到最上面一条".
123
+ *
124
+ * The page is scrolled down, an expression is ticked from a row's dropdown, and the page has to STAY PUT. The
125
+ * scroll offset is read from `document.scrollingElement` before and after, because the cause was the document
126
+ * momentarily collapsing when a repaint emptied its `<tbody>` — a fact no assertion about the stored value can
127
+ * see.
128
+ *
129
+ * The menu itself is read back as well: one button per row, the choices inside a bounded scrollable panel, and
130
+ * no expression checkbox left in the row (`input[data-name]` outside a `.pick-menu` must be zero).
131
+ */
132
+ const scroller = document.scrollingElement || document.documentElement;
133
+ out.pageScrolls = scroller.scrollHeight > scroller.clientHeight + 20;
134
+ scroller.scrollTop = out.pageScrolls ? 140 : 0;
135
+ out.scrollBefore = scroller.scrollTop;
136
+ const pickButton = document.querySelector('#part-body .pick-select');
137
+ out.pickTriggerBefore = pickButton ? pickButton.textContent : null;
138
+ if (pickButton) pickButton.click();
139
+ const menu = document.querySelector('.pick-menu');
140
+ out.menuOpen = Boolean(menu);
141
+ out.menuBoxes = menu ? menu.querySelectorAll('input[data-name]').length : 0;
142
+ out.menuScrolls = menu ? menu.scrollHeight > menu.clientHeight : false;
143
+ /**
144
+ * BOUNDED, measured here rather than from the page's published state: the page publishes on its own poll, so a menu
145
+ * this probe has just opened may not be in that snapshot yet. `scrollHeight > clientHeight` is the wrong question
146
+ * anyway — it is false on a four-expression model, where the CSS ceiling is still what keeps a twenty-expression
147
+ * one from growing the row.
148
+ */
149
+ const menuStyle = menu && window.getComputedStyle ? window.getComputedStyle(menu) : null;
150
+ out.menuOverflow = menuStyle ? menuStyle.overflowY : null;
151
+ out.menuMaxHeight = menuStyle ? menuStyle.maxHeight : null;
152
+ out.menuBounded = Boolean(menuStyle) && menuStyle.overflowY === 'auto' && /px$/.test(String(menuStyle.maxHeight));
153
+ out.inlineBoxes = document.querySelectorAll('#emotion-body input[data-name], #part-body input[data-name]').length
154
+ - out.menuBoxes;
155
+ const tick = menu ? menu.querySelector('input[data-name]') : null;
156
+ out.tickedName = tick ? tick.getAttribute('data-name') : null;
157
+ if (tick) {
158
+ tick.checked = true;
159
+ tick.dispatchEvent(new Event('change'));
160
+ }
161
+ out.pickTriggerAfter = pickButton ? pickButton.textContent : null;
162
+ out.scrollAfter = scroller.scrollTop;
163
+ out.stayedPut = Math.abs(scroller.scrollTop - out.scrollBefore) <= 2;
164
+
165
+ /**
166
+ * AND THE MENU OPENS AGAIN — the reported bug: "选中一个表情后,再点表情的下拉列表就无效了,必须刷新页面重新进".
167
+ *
168
+ * Closing removed the panel but left the row's own reference to it, so every later click on that button took the
169
+ * "close" branch and the row was dead until a repaint (a page reload, since a tick no longer repaints). Three
170
+ * closings are driven here because they take three different paths out: the trigger itself, Escape, and an outside
171
+ * pointerdown — an orphaned outside-click listener is the other half of the same defect.
172
+ */
173
+ const menuOf = () => document.querySelector('.pick-menu');
174
+ if (pickButton) pickButton.click(); // close it the way the user would
175
+ out.closedByTrigger = !menuOf();
176
+ if (pickButton) pickButton.click(); // …and open it again: this is the bug
177
+ out.reopenedAfterTrigger = Boolean(menuOf());
178
+ // The rebuilt panel must show the live row, which still has the expression ticked from above.
179
+ const reopenedBox = menuOf() ? menuOf().querySelector('input[data-name]') : null;
180
+ out.reopenedTicked = reopenedBox ? reopenedBox.checked : null;
181
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
182
+ out.closedByEscape = !menuOf();
183
+ if (pickButton) pickButton.click();
184
+ out.reopenedAfterEscape = Boolean(menuOf());
185
+ /**
186
+ * The away listener is attached a TICK AFTER the menu opens (so the click that opened it is not read as an outside
187
+ * click), so the outside click has to happen after that tick — dispatching it immediately is the mistake this
188
+ * block made first, and the three failures that followed were all downstream of it.
189
+ */
190
+ await new Promise((resolve) => setTimeout(resolve, 30));
191
+ document.body.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }));
192
+ out.closedByOutside = !menuOf();
193
+ if (pickButton) pickButton.click();
194
+ out.reopenedAfterOutside = Boolean(menuOf());
195
+
196
+ // Put the row back to 无 so the profile the later checks see is the shipped one, then close the menu.
197
+ const lastTick = menuOf() ? menuOf().querySelector('input[data-name]') : null;
198
+ if (lastTick) {
199
+ lastTick.checked = false;
200
+ lastTick.dispatchEvent(new Event('change'));
201
+ }
202
+ if (pickButton) pickButton.click();
203
+ out.menuClosedAtEnd = !menuOf();
204
+ out.pickTriggerRestored = pickButton ? pickButton.textContent : null;
205
+
206
+ /**
207
+ * AND THE SOURCE TAKES THE BUILT-IN VOICE OFF THE 部位 TABLE — the second half of 「如果设置了AI碎碎念,就停止输出内置的
208
+ * 碎碎念」 (operator, 2026-10-12).
209
+ *
210
+ * 胸部 ships answering 变态!/大色狼! with nothing written in the user's settings, and its box placeholder is what
211
+ * PROMISES that. The promise is a property of the SOURCE, so a source that is not 内置短语 has to take it away.
212
+ *
213
+ * It is driven through the STORE, and that is the honest path for THIS page: the 台词来源 picker lives on the other
214
+ * settings page (通用设置), so a model page can never see it move — there is no `#pref-speech-source` here to drive.
215
+ * What this page does see is another document writing the store, which is exactly what its `storage` listener
216
+ * handles (two open settings windows, or an edit from the app's own panel). The DOM is read back afterwards
217
+ * because the published state is republished on a poll and would answer what the page looked like a moment ago.
218
+ *
219
+ * The store is put back at the end, because a probe must not leave the profile in a state the next suite reads.
220
+ */
221
+ const storeKey = (globalThis.__VRMPET3D_CORE__ || {}).STORAGE_KEY || 'dsh.vrmpet3d.v1';
222
+ const storeBefore = globalThis.localStorage.getItem(storeKey);
223
+ const openWithSource = (source) => {
224
+ let stored = {};
225
+ try { stored = JSON.parse(storeBefore || '{}') || {}; } catch (_error) { stored = {}; }
226
+ stored.speech = Object.assign({}, stored.speech, { source: source });
227
+ globalThis.localStorage.setItem(storeKey, JSON.stringify(stored));
228
+ globalThis.dispatchEvent(new StorageEvent('storage', { key: storeKey }));
229
+ };
230
+ const placeholderOf = (label) => {
231
+ const row = rows().filter((entry) => {
232
+ const box = entry.querySelector('.part-label');
233
+ return box && box.value === label;
234
+ })[0];
235
+ const detail = row ? row.nextElementSibling : null;
236
+ const box = detail ? detail.querySelector('.part-lines') : null;
237
+ return box ? box.placeholder : null;
238
+ };
239
+ out.partLinesBuiltin = placeholderOf('胸部');
240
+ openWithSource('ai');
241
+ out.partLinesAi = placeholderOf('胸部');
242
+ if (storeBefore === null) globalThis.localStorage.removeItem(storeKey);
243
+ else globalThis.localStorage.setItem(storeKey, storeBefore);
244
+ globalThis.dispatchEvent(new StorageEvent('storage', { key: storeKey }));
245
+ out.partLinesRestored = placeholderOf('胸部');
246
+ return JSON.stringify(out);
247
+ })()
package/lib/brotli.js ADDED
@@ -0,0 +1,74 @@
1
+ // Brotli decompression for the bundled motion pack's compressed clips.
2
+ //
3
+ // WHY BROTLI, AND WHY NOT LZMA2. The operator's dance clips are large (their first one is 8.0 MB), so the pack
4
+ // stores them compressed and the bytes are unpacked into memory on the way to the renderer. LZMA2 would be
5
+ // about 10% smaller — 46% of the original against brotli's 51% on a real clip — but Node's `zlib` does NOT
6
+ // speak LZMA (measured 2026-10-12: `unzipSync`/`inflateSync`/`gunzipSync` all fail with Z_DATA_ERROR on a file
7
+ // the reference liblzma wrote), and this plugin ships with NO npm dependencies, so LZMA would mean maintaining
8
+ // a hand-written LZMA2 decoder in `lib/xz.js`. That decoder was written and does NOT yet decode real binary
9
+ // content correctly, so it is NOT in this path: a decompressor that returns plausible garbage would hand the
10
+ // renderer a malformed model, which is worse than not supporting the format at all.
11
+ //
12
+ // Brotli, by contrast, is IN Node and in every Chromium the pet runs in. On the operator's own dance clip:
13
+ // 8,080,556 bytes -> 2,836,574 (35%), byte-identical, in about 13 seconds.
14
+ //
15
+ // Node is the only side that needs this file: the HOST unpacks the clip and serves plain VRMA bytes, so the
16
+ // browser half never learns that a clip arrived compressed. See `motionBytesFor` in lib/index.js.
17
+ import { brotliDecompressSync } from 'node:zlib';
18
+
19
+ /** How large an unpacked clip may be before the host refuses it, as a decompression-bomb guard. */
20
+ export const MOTION_PACK_LIMIT = 512 * 1024 * 1024;
21
+
22
+ /** The extension a compressed clip carries, appended to the format's own: `x.vrma.br`. */
23
+ export const COMPRESSED_MOTION_EXTENSION = '.br';
24
+
25
+ /**
26
+ * The suffix appended WITHOUT a dot, for a caller composing "the compressed twin of this name".
27
+ *
28
+ * `COMPRESSED_MOTION_EXTENSION` is what a reader sees in a file name; this is what a catalogue builds to ask
29
+ * "is there an archive beside this clip?". Keeping both spelled out is what stops one of them drifting into the
30
+ * other's job — a schedule that appends `.br` here would look for `x.vrma..br`.
31
+ */
32
+ export const COMPRESSED_SUFFIX = 'br';
33
+
34
+ /**
35
+ * Decompress one brotli-compressed clip.
36
+ *
37
+ * @param {Buffer|Uint8Array} bytes the file exactly as it sits on disk.
38
+ * @param {object} [options]
39
+ * @param {number} [options.limit] most bytes to produce before refusing.
40
+ * @returns {Buffer} the original bytes.
41
+ * @throws {Error} with a message a caller can show — never a half-decoded clip.
42
+ */
43
+ export function decompressBrotli(bytes, options = {}) {
44
+ const limit = Number.isFinite(options.limit) ? options.limit : MOTION_PACK_LIMIT;
45
+ if (!bytes || typeof bytes.length !== 'number' || bytes.length === 0) {
46
+ throw new Error('the compressed clip is empty');
47
+ }
48
+ let out;
49
+ try {
50
+ out = brotliDecompressSync(bytes, { maxOutputLength: limit });
51
+ } catch (error) {
52
+ /**
53
+ * `maxOutputLength` is what turns a decompression bomb into a refusal rather than an out-of-memory crash,
54
+ * and Node reports it by code. The message is rewritten because the raw one ("Cannot create a Buffer
55
+ * larger than...") says nothing about which clip or why.
56
+ */
57
+ if (error && error.code === 'ERR_BUFFER_TOO_LARGE') {
58
+ throw new Error(`the clip unpacks to more than ${limit} bytes`);
59
+ }
60
+ throw new Error(`the compressed clip could not be unpacked: ${(error && error.message) || error}`);
61
+ }
62
+ return out;
63
+ }
64
+
65
+ /**
66
+ * Does this file name describe a brotli-compressed clip?
67
+ *
68
+ * Total and cheap, like every other format check in this plugin: the extension decides, and the bytes are
69
+ * never sniffed to guess. A file whose name says `.br` but whose bytes are not brotli is REFUSED by
70
+ * `decompressBrotli` with a reason, which is the honest outcome — the alternative is loading it as a model.
71
+ */
72
+ export function isCompressedMotionName(fileName) {
73
+ return typeof fileName === 'string' && fileName.toLowerCase().endsWith(COMPRESSED_MOTION_EXTENSION);
74
+ }