@linxin666/dsh-pet 0.2.2 → 0.2.4

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 (125) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +129 -3
  3. package/README.zh.md +129 -3
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/assets/whale/pet.json +169 -20
  7. package/assets/whale-refined/pet.json +17 -2
  8. package/contracts/pet-manifest-v2.schema.json +281 -0
  9. package/contracts/status-decoration-v1.schema.json +144 -0
  10. package/contracts/voice-pack-v1.schema.json +234 -0
  11. package/lib/client.js +581 -38
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.js +1684 -59
  14. package/lib/live2d-vendor.js +995 -0
  15. package/lib/live2d-vendor.js.map +1 -0
  16. package/lib/types/chatter.d.ts +61 -3
  17. package/lib/types/chatter.d.ts.map +1 -1
  18. package/lib/types/chatter.js +71 -12
  19. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  20. package/lib/types/client/PetDockEntry.js +2 -1
  21. package/lib/types/client/PetSettingsCard.d.ts +14 -0
  22. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  23. package/lib/types/client/PetSettingsCard.js +24 -1
  24. package/lib/types/client/PetSprite.d.ts +7 -1
  25. package/lib/types/client/PetSprite.d.ts.map +1 -1
  26. package/lib/types/client/PetSprite.js +120 -12
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +5 -0
  29. package/lib/types/client/locales.d.ts +14 -0
  30. package/lib/types/client/locales.d.ts.map +1 -1
  31. package/lib/types/client/locales.js +14 -0
  32. package/lib/types/client/phase-stream.d.ts +22 -0
  33. package/lib/types/client/phase-stream.d.ts.map +1 -0
  34. package/lib/types/client/phase-stream.js +28 -0
  35. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  36. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  37. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  38. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  39. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  40. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  41. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  42. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  43. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  44. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  45. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  46. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  47. package/lib/types/client/renderers/live2d.d.ts +52 -0
  48. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  49. package/lib/types/client/renderers/live2d.js +192 -0
  50. package/lib/types/client/renderers/registry.d.ts +32 -0
  51. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  52. package/lib/types/client/renderers/registry.js +49 -0
  53. package/lib/types/client/settings-form.d.ts.map +1 -1
  54. package/lib/types/client/settings-form.js +9 -6
  55. package/lib/types/contracts/renderer.d.ts +50 -0
  56. package/lib/types/contracts/renderer.d.ts.map +1 -0
  57. package/lib/types/contracts/renderer.js +15 -0
  58. package/lib/types/contracts/status-decoration.d.ts +85 -0
  59. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  60. package/lib/types/contracts/status-decoration.js +21 -0
  61. package/lib/types/decoration.d.ts +39 -0
  62. package/lib/types/decoration.d.ts.map +1 -0
  63. package/lib/types/decoration.js +210 -0
  64. package/lib/types/event-projection.d.ts +8 -3
  65. package/lib/types/event-projection.d.ts.map +1 -1
  66. package/lib/types/event-projection.js +9 -4
  67. package/lib/types/index.d.ts +2 -0
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/index.js +2 -0
  70. package/lib/types/manifest-v2.d.ts +129 -0
  71. package/lib/types/manifest-v2.d.ts.map +1 -0
  72. package/lib/types/manifest-v2.js +387 -0
  73. package/lib/types/model3.d.ts +26 -0
  74. package/lib/types/model3.d.ts.map +1 -0
  75. package/lib/types/model3.js +96 -0
  76. package/lib/types/registry.d.ts +112 -5
  77. package/lib/types/registry.d.ts.map +1 -1
  78. package/lib/types/registry.js +395 -38
  79. package/lib/types/routes.d.ts +39 -2
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +312 -13
  82. package/lib/types/service.d.ts +38 -1
  83. package/lib/types/service.d.ts.map +1 -1
  84. package/lib/types/service.js +46 -3
  85. package/lib/types/voice-pack.d.ts +98 -0
  86. package/lib/types/voice-pack.d.ts.map +1 -0
  87. package/lib/types/voice-pack.js +384 -0
  88. package/package.json +13 -10
  89. package/src/chatter.test.ts +89 -2
  90. package/src/chatter.ts +120 -14
  91. package/src/client/PetDockEntry.tsx +19 -11
  92. package/src/client/PetSettingsCard.tsx +57 -0
  93. package/src/client/PetSprite.test.tsx +265 -12
  94. package/src/client/PetSprite.tsx +165 -33
  95. package/src/client/index.ts +6 -0
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/phase-stream.test.ts +76 -0
  98. package/src/client/phase-stream.ts +39 -0
  99. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  100. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  101. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  102. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  103. package/src/client/renderers/live2d/runtime.ts +119 -0
  104. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  105. package/src/client/renderers/live2d.test.ts +263 -0
  106. package/src/client/renderers/live2d.ts +231 -0
  107. package/src/client/renderers/registry.ts +58 -0
  108. package/src/client/settings-form.ts +8 -6
  109. package/src/client/settings-section.module.css +23 -0
  110. package/src/contracts/renderer.ts +54 -0
  111. package/src/contracts/status-decoration.ts +78 -0
  112. package/src/decoration.test.ts +178 -0
  113. package/src/decoration.ts +220 -0
  114. package/src/event-projection.ts +10 -5
  115. package/src/index.ts +2 -0
  116. package/src/manifest-v2.test.ts +251 -0
  117. package/src/manifest-v2.ts +414 -0
  118. package/src/model3.test.ts +79 -0
  119. package/src/model3.ts +91 -0
  120. package/src/registry.test.ts +438 -2
  121. package/src/registry.ts +470 -38
  122. package/src/routes.ts +328 -14
  123. package/src/service.ts +65 -3
  124. package/src/voice-pack.test.ts +216 -0
  125. package/src/voice-pack.ts +413 -0
package/lib/index.js CHANGED
@@ -2,8 +2,8 @@ import { _ as RemarkPicker, a as AFFINITY_MAX, c as applyInteraction, d as empty
2
2
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
3
3
  import z from "schemastery";
4
4
  import { Service } from "@deepseek-ai/cordis";
5
- import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
6
- import { basename, dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
6
+ import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { fileURLToPath } from "node:url";
9
9
  import { readFile } from "node:fs/promises";
@@ -145,6 +145,40 @@ const STATUS_POOLS = {
145
145
  "蹲一个继续的指令"
146
146
  ]
147
147
  };
148
+ /** Every status scene key, in declaration order (voice-pack key allow-list). */
149
+ const STATUS_SCENES = [
150
+ "prepare",
151
+ "waiting",
152
+ "thinking",
153
+ "review",
154
+ "toolResult",
155
+ "done",
156
+ "failed",
157
+ "toolFailed",
158
+ "maxTokens",
159
+ "interrupted",
160
+ "blocked"
161
+ ];
162
+ /** Every tool-family key, in declaration order (voice-pack key allow-list). */
163
+ const TOOL_CATEGORIES = [
164
+ "read",
165
+ "write",
166
+ "edit",
167
+ "shell",
168
+ "grep",
169
+ "find",
170
+ "ls",
171
+ "webSearch",
172
+ "webFetch",
173
+ "mcp",
174
+ "memory",
175
+ "subagent",
176
+ "todo",
177
+ "browser",
178
+ "git",
179
+ "ask",
180
+ "generic"
181
+ ];
148
182
  /** Map a raw tool name onto its copy family (working-activity style regexes). */
149
183
  function toolCategory(toolName) {
150
184
  const name = toolName.toLowerCase();
@@ -420,12 +454,14 @@ function toolArgHint(toolName, argumentsJson) {
420
454
  * stretch keeps changing its wording.
421
455
  */
422
456
  var StatusVoice = class {
457
+ pools;
423
458
  rotateMs;
424
459
  counters = /* @__PURE__ */ new Map();
425
460
  lastScene = "";
426
461
  lastLine = "";
427
462
  lastLineAt = Number.NEGATIVE_INFINITY;
428
- constructor(rotateMs = STATUS_ROTATE_MS) {
463
+ constructor(pools = () => BUILTIN_VOICE_PACK, rotateMs = STATUS_ROTATE_MS) {
464
+ this.pools = pools;
429
465
  this.rotateMs = rotateMs;
430
466
  }
431
467
  /** Draw the next line of one pool, advancing its round-robin cursor. */
@@ -442,18 +478,31 @@ var StatusVoice = class {
442
478
  this.lastLineAt = nowMs;
443
479
  return this.lastLine;
444
480
  }
481
+ /**
482
+ * A scene's effective pool: the voice-pack override when it carries lines,
483
+ * else the built-in pool. Empty overrides fall back rather than blank the
484
+ * bubble — a scene line always renders.
485
+ */
486
+ scenePool(scene) {
487
+ const override = this.pools().status?.[scene];
488
+ return override !== void 0 && override.length > 0 ? override : STATUS_POOLS[scene];
489
+ }
445
490
  /** Status line for a phase scene. */
446
491
  scene(scene, nowMs) {
447
- return this.voice("scene:" + scene, "pool:" + scene, STATUS_POOLS[scene], nowMs);
492
+ return this.voice("scene:" + scene, "pool:" + scene, this.scenePool(scene), nowMs);
448
493
  }
449
494
  /** Status line for a tool call, with the real-argument hint when known. */
450
495
  tool(toolName, displayName, hint, nowMs) {
451
496
  const category = toolCategory(toolName);
452
- return this.voice("tool:" + category, "tool:" + category, TOOL_POOLS[category], nowMs).replace("{tool}", displayName).replace("{hint}", hint ?? displayName);
497
+ const override = this.pools().tools?.[category];
498
+ const pool = override !== void 0 && override.length > 0 ? override : TOOL_POOLS[category];
499
+ return this.voice("tool:" + category, "tool:" + category, pool, nowMs).replaceAll("{tool}", displayName).replaceAll("{hint}", hint ?? displayName);
453
500
  }
454
501
  /** Status line while sibling tools still run (always reflects the count). */
455
502
  toolRemaining(count, nowMs) {
456
- return this.voice("toolRemaining", "toolRemaining", TOOL_REMAINING_POOL, nowMs).replace("{n}", String(count));
503
+ const override = this.pools().toolRemaining;
504
+ const pool = override !== void 0 && override.length > 0 ? override : TOOL_REMAINING_POOL;
505
+ return this.voice("toolRemaining", "toolRemaining", pool, nowMs).replaceAll("{n}", String(count));
457
506
  }
458
507
  };
459
508
  /** Murmur pacing: cooldown between whispers and output volume that earns one. */
@@ -786,26 +835,52 @@ const WHISPER_RULES = [
786
835
  ]
787
836
  }
788
837
  ];
838
+ /** The built-in voice pack: the plugin's default copy, unchanged since v1. */
839
+ const BUILTIN_VOICE_PACK = {
840
+ status: STATUS_POOLS,
841
+ tools: TOOL_POOLS,
842
+ toolRemaining: TOOL_REMAINING_POOL,
843
+ whispers: {
844
+ generic: WHISPER_GENERIC_POOL,
845
+ rules: WHISPER_RULES
846
+ }
847
+ };
789
848
  /**
790
849
  * The murmur engine (碎碎念): watches the model's own output and lets the pet
791
850
  * whisper its inner voice. Two ways to earn a whisper:
792
851
  * - a keyword rule matches the fresh chunk text (themed whisper);
793
852
  * - enough output volume flowed by without one (ambient whisper).
794
853
  * A cooldown keeps whispers occasional; all picks are round-robin so tests
795
- * reproduce exact lines.
854
+ * reproduce exact lines. The voice-pack provider (pet-center M4) swaps the
855
+ * pools at draw time, so a pet switch re-voices live engines in place.
796
856
  */
797
857
  var WhisperEngine = class {
858
+ pools;
798
859
  cooldownMs;
799
860
  charBudget;
800
861
  counters = /* @__PURE__ */ new Map();
801
862
  genericCursor = 0;
802
863
  lastWhisperAt = Number.NEGATIVE_INFINITY;
803
864
  charsSinceWhisper = 0;
804
- constructor(cooldownMs = WHISPER_COOLDOWN_MS, charBudget = 420) {
865
+ constructor(pools = () => BUILTIN_VOICE_PACK, cooldownMs = WHISPER_COOLDOWN_MS, charBudget = 420) {
866
+ this.pools = pools;
805
867
  this.cooldownMs = cooldownMs;
806
868
  this.charBudget = charBudget;
807
869
  }
808
870
  /**
871
+ * Effective keyword rules: an override replaces the built-in rules as a
872
+ * whole; an explicit empty array disables keyword-triggered whispers.
873
+ */
874
+ rules() {
875
+ const override = this.pools().whispers?.rules;
876
+ return override === void 0 ? WHISPER_RULES : override;
877
+ }
878
+ /** Effective ambient pool (an explicit empty array mutes ambient whispers). */
879
+ generic() {
880
+ const override = this.pools().whispers?.generic;
881
+ return override === void 0 ? WHISPER_GENERIC_POOL : override;
882
+ }
883
+ /**
809
884
  * Feed one model-output chunk (reasoning or text). Returns the whisper to
810
885
  * show, or undefined when the moment stays quiet.
811
886
  */
@@ -816,8 +891,9 @@ var WhisperEngine = class {
816
891
  return;
817
892
  }
818
893
  const haystack = text.toLowerCase();
819
- for (let ruleIndex = 0; ruleIndex < WHISPER_RULES.length; ruleIndex += 1) {
820
- const rule = WHISPER_RULES[ruleIndex];
894
+ const rules = this.rules();
895
+ for (let ruleIndex = 0; ruleIndex < rules.length; ruleIndex += 1) {
896
+ const rule = rules[ruleIndex];
821
897
  if (!rule.keywords.some((keyword) => haystack.includes(keyword))) continue;
822
898
  const index = (this.counters.get(ruleIndex) ?? 0) % rule.pool.length;
823
899
  this.counters.set(ruleIndex, index + 1);
@@ -825,7 +901,9 @@ var WhisperEngine = class {
825
901
  }
826
902
  this.charsSinceWhisper += text.length;
827
903
  if (this.charsSinceWhisper < this.charBudget) return void 0;
828
- const line = WHISPER_GENERIC_POOL[this.genericCursor % WHISPER_GENERIC_POOL.length];
904
+ const generic = this.generic();
905
+ if (generic.length === 0) return void 0;
906
+ const line = generic[this.genericCursor % generic.length];
829
907
  this.genericCursor += 1;
830
908
  return this.speak(line, nowMs);
831
909
  }
@@ -837,14 +915,19 @@ var WhisperEngine = class {
837
915
  };
838
916
  //#endregion
839
917
  //#region src/event-projection.ts
840
- /** Fresh projection runtime for a newly seen session. */
841
- function emptyProjectionRuntime() {
918
+ /**
919
+ * Fresh projection runtime for a newly seen session. The optional voice-pack
920
+ * provider (pet-center M4, issue #677) hands both chatter engines their
921
+ * pools; engines resolve overrides at draw time, so swapping the provider's
922
+ * pack re-voices live runtimes without rebuilding them.
923
+ */
924
+ function emptyProjectionRuntime(pools) {
842
925
  return {
843
926
  activeTools: /* @__PURE__ */ new Set(),
844
927
  officialEventsSeen: false,
845
928
  stepHadFailure: false,
846
- voice: new StatusVoice(),
847
- whispers: new WhisperEngine()
929
+ voice: new StatusVoice(pools),
930
+ whispers: new WhisperEngine(pools)
848
931
  };
849
932
  }
850
933
  /** Keep tool names readable inside the compact status bubble. */
@@ -1373,19 +1456,895 @@ function savePetPersist(data, dir = petHomeDir()) {
1373
1456
  writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
1374
1457
  renameSync(tmp, target);
1375
1458
  }
1459
+ /** Hover-panel action buttons a pack can show or hide (canonical order). */
1460
+ const PANEL_ACTIONS = [
1461
+ "feed",
1462
+ "rename",
1463
+ "hide"
1464
+ ];
1465
+ /** Panel label slots (unset slots keep the client's i18n dictionary copy). */
1466
+ const PANEL_LABEL_KEYS = [
1467
+ "feed",
1468
+ "rename",
1469
+ "hide",
1470
+ "confirm"
1471
+ ];
1472
+ /** Panel stat slots ({rank}/{n}/{points} interpolate the live values). */
1473
+ const PANEL_STAT_KEYS = [
1474
+ "rank",
1475
+ "treats",
1476
+ "points"
1477
+ ];
1478
+ /** Any '{token}' placeholder (no nesting, no newlines). */
1479
+ const PLACEHOLDER_PATTERN = /{[^{}]*}/g;
1480
+ /** Allowed placeholder tokens per pool kind (absent kind = none allowed). */
1481
+ const PLACEHOLDER_WHITELIST = {
1482
+ tools: ["{tool}", "{hint}"],
1483
+ toolRemaining: ["{n}"],
1484
+ stat: [
1485
+ "{rank}",
1486
+ "{n}",
1487
+ "{points}"
1488
+ ]
1489
+ };
1490
+ function isRecord$2(value) {
1491
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1492
+ }
1493
+ /** Trim, length-cap and placeholder-check one copy line; undefined to drop. */
1494
+ function normalizeLine(raw, kind, onWarning) {
1495
+ const trimmed = raw.trim();
1496
+ if (trimmed === "") return void 0;
1497
+ let capped = trimmed.length > 160 ? trimmed.slice(0, 160) : trimmed;
1498
+ const dangling = capped.lastIndexOf("{");
1499
+ if (dangling !== -1 && capped.indexOf("}", dangling) === -1) {
1500
+ onWarning("line cut at an unterminated placeholder; tail dropped: " + capped.slice(0, 40) + "...");
1501
+ capped = capped.slice(0, dangling);
1502
+ }
1503
+ if (capped === "") return void 0;
1504
+ const allowed = PLACEHOLDER_WHITELIST[kind];
1505
+ const tokens = capped.match(PLACEHOLDER_PATTERN) ?? [];
1506
+ for (const token of tokens) {
1507
+ if (allowed?.includes(token) === true) continue;
1508
+ const preview = capped.length > 40 ? capped.slice(0, 40) + "..." : capped;
1509
+ onWarning("line dropped (unsupported placeholder " + token + "): " + preview);
1510
+ return;
1511
+ }
1512
+ return capped;
1513
+ }
1514
+ /**
1515
+ * Normalize one pool slot. Accepts a single line or an array; non-string
1516
+ * entries warn and drop, empty lines drop silently, lines over the length
1517
+ * cap truncate, illegal placeholders drop the line, and pools over the line
1518
+ * cap keep their first lines. An explicit empty pool normalizes to [] (the
1519
+ * whisper channels read that as mute) while an absent slot normalizes to
1520
+ * undefined (the slot keeps the built-in pool).
1521
+ */
1522
+ function normalizePool(raw, kind, onWarning = () => {}) {
1523
+ if (raw === void 0) return void 0;
1524
+ const entries = typeof raw === "string" ? [raw] : Array.isArray(raw) ? raw : void 0;
1525
+ if (entries === void 0) {
1526
+ onWarning("pool must be a string or an array of strings");
1527
+ return;
1528
+ }
1529
+ if (entries.length > 64) onWarning("pool has more than 64 lines; extra lines are ignored");
1530
+ const pool = [];
1531
+ for (const entry of entries.slice(0, 64)) {
1532
+ if (typeof entry !== "string") {
1533
+ onWarning("non-string pool entry dropped");
1534
+ continue;
1535
+ }
1536
+ const line = normalizeLine(entry, kind, onWarning);
1537
+ if (line !== void 0) pool.push(line);
1538
+ }
1539
+ return pool;
1540
+ }
1541
+ /** Normalize the ordered keyword-rule list ([] disables the keyword channel). */
1542
+ function normalizeWhisperRules(raw, onWarning = () => {}) {
1543
+ if (raw === void 0) return void 0;
1544
+ if (!Array.isArray(raw)) {
1545
+ onWarning("whispers.rules must be an array");
1546
+ return;
1547
+ }
1548
+ if (raw.length > 32) onWarning("whispers.rules has more than 32 rules; extra rules are ignored");
1549
+ const rules = [];
1550
+ for (const item of raw.slice(0, 32)) {
1551
+ if (!isRecord$2(item)) {
1552
+ onWarning("whisper rule must be an object");
1553
+ continue;
1554
+ }
1555
+ for (const key of Object.keys(item)) if (key !== "keywords" && key !== "pool") onWarning("unknown whisper rule field " + key + " ignored");
1556
+ const keywordsRaw = item.keywords;
1557
+ const keywords = [];
1558
+ if (Array.isArray(keywordsRaw)) {
1559
+ for (const entry of keywordsRaw.slice(0, 16)) {
1560
+ if (typeof entry !== "string") {
1561
+ onWarning("non-string keyword dropped");
1562
+ continue;
1563
+ }
1564
+ const trimmed = entry.trim().toLowerCase().slice(0, 40);
1565
+ if (trimmed !== "") keywords.push(trimmed);
1566
+ }
1567
+ if (keywordsRaw.length > 16) onWarning("rule has more than 16 keywords; extra keywords are ignored");
1568
+ }
1569
+ const pool = normalizePool(item.pool, "whisperRule", onWarning);
1570
+ if (keywords.length === 0 || pool === void 0 || pool.length === 0) {
1571
+ onWarning("whisper rule dropped (needs keywords and a non-empty pool)");
1572
+ continue;
1573
+ }
1574
+ rules.push({
1575
+ keywords,
1576
+ pool
1577
+ });
1578
+ }
1579
+ return rules;
1580
+ }
1581
+ /** Normalize the panel block (labels / stats / actions; warn-and-drop). */
1582
+ function normalizePanel(raw, onWarning = () => {}) {
1583
+ if (!isRecord$2(raw)) {
1584
+ onWarning("panel must be an object");
1585
+ return;
1586
+ }
1587
+ const panel = {};
1588
+ const labelsRaw = raw.labels;
1589
+ if (labelsRaw !== void 0) if (!isRecord$2(labelsRaw)) onWarning("panel.labels must be an object");
1590
+ else {
1591
+ const labels = {};
1592
+ for (const key of PANEL_LABEL_KEYS) {
1593
+ const value = labelsRaw[key];
1594
+ if (value === void 0) continue;
1595
+ if (typeof value !== "string") {
1596
+ onWarning("panel.labels." + key + " must be a string");
1597
+ continue;
1598
+ }
1599
+ const line = normalizeLine(value, "label", onWarning);
1600
+ if (line !== void 0) labels[key] = line.slice(0, 40);
1601
+ }
1602
+ if (Object.keys(labels).length > 0) panel.labels = labels;
1603
+ }
1604
+ const statsRaw = raw.stats;
1605
+ if (statsRaw !== void 0) if (!isRecord$2(statsRaw)) onWarning("panel.stats must be an object");
1606
+ else {
1607
+ const stats = {};
1608
+ for (const key of PANEL_STAT_KEYS) {
1609
+ const value = statsRaw[key];
1610
+ if (value === void 0) continue;
1611
+ if (typeof value !== "string") {
1612
+ onWarning("panel.stats." + key + " must be a string");
1613
+ continue;
1614
+ }
1615
+ const line = normalizeLine(value, "stat", onWarning);
1616
+ if (line !== void 0) stats[key] = line.slice(0, 80);
1617
+ }
1618
+ if (Object.keys(stats).length > 0) panel.stats = stats;
1619
+ }
1620
+ const actionsRaw = raw.actions;
1621
+ if (actionsRaw !== void 0) if (!Array.isArray(actionsRaw)) onWarning("panel.actions must be an array");
1622
+ else {
1623
+ const seen = /* @__PURE__ */ new Set();
1624
+ for (const entry of actionsRaw) {
1625
+ if (typeof entry !== "string" || !PANEL_ACTIONS.includes(entry)) {
1626
+ onWarning("unknown panel action dropped: " + String(entry));
1627
+ continue;
1628
+ }
1629
+ seen.add(entry);
1630
+ }
1631
+ panel.actions = PANEL_ACTIONS.filter((action) => seen.has(action));
1632
+ }
1633
+ if (panel.labels === void 0 && panel.stats === void 0 && panel.actions === void 0) return void 0;
1634
+ return panel;
1635
+ }
1636
+ /** Voice-pack top-level fields ('$schema' mirrors the schema twin; drift-locked in tests). */
1637
+ const VOICE_PACK_KEYS = /* @__PURE__ */ new Set([
1638
+ "$schema",
1639
+ "voicePackVersion",
1640
+ "status",
1641
+ "tools",
1642
+ "toolRemaining",
1643
+ "whispers",
1644
+ "panel"
1645
+ ]);
1646
+ /** Allowed whisper-section fields (drift-locked in tests). */
1647
+ const WHISPER_KEYS = /* @__PURE__ */ new Set(["generic", "rules"]);
1648
+ /**
1649
+ * Normalize one raw voice.json document into a VoicePack, or undefined when
1650
+ * the file cannot serve as a pack at all (non-object root — structure is
1651
+ * fail-closed per file). Every slot issue is a warning, never a throw.
1652
+ */
1653
+ function normalizeVoicePack(raw, onWarning = () => {}) {
1654
+ if (raw === void 0) return void 0;
1655
+ if (!isRecord$2(raw)) {
1656
+ onWarning("voice.json must be a JSON object; the file is ignored");
1657
+ return;
1658
+ }
1659
+ for (const key of Object.keys(raw)) if (!VOICE_PACK_KEYS.has(key)) onWarning("unknown top-level field " + key + " ignored");
1660
+ const version = raw.voicePackVersion;
1661
+ if (version !== void 0 && (typeof version !== "number" || version !== 1)) onWarning("voicePackVersion " + String(version) + " is not supported; reading as v1 best-effort");
1662
+ const overrides = {};
1663
+ const statusRaw = raw.status;
1664
+ if (statusRaw !== void 0) if (!isRecord$2(statusRaw)) onWarning("status must be an object");
1665
+ else for (const key of Object.keys(statusRaw)) {
1666
+ if (!STATUS_SCENES.includes(key)) {
1667
+ onWarning("unknown status scene " + key + " ignored");
1668
+ continue;
1669
+ }
1670
+ const pool = normalizePool(statusRaw[key], "status", onWarning);
1671
+ if (pool !== void 0 && pool.length > 0) overrides.status = {
1672
+ ...overrides.status,
1673
+ [key]: pool
1674
+ };
1675
+ }
1676
+ const toolsRaw = raw.tools;
1677
+ if (toolsRaw !== void 0) if (!isRecord$2(toolsRaw)) onWarning("tools must be an object");
1678
+ else for (const key of Object.keys(toolsRaw)) {
1679
+ if (!TOOL_CATEGORIES.includes(key)) {
1680
+ onWarning("unknown tool family " + key + " ignored");
1681
+ continue;
1682
+ }
1683
+ const pool = normalizePool(toolsRaw[key], "tools", onWarning);
1684
+ if (pool !== void 0 && pool.length > 0) overrides.tools = {
1685
+ ...overrides.tools,
1686
+ [key]: pool
1687
+ };
1688
+ }
1689
+ const remainingRaw = raw.toolRemaining;
1690
+ if (remainingRaw !== void 0) {
1691
+ const pool = normalizePool(remainingRaw, "toolRemaining", onWarning);
1692
+ if (pool !== void 0 && pool.length > 0) overrides.toolRemaining = pool;
1693
+ }
1694
+ const whispersRaw = raw.whispers;
1695
+ if (whispersRaw !== void 0) if (!isRecord$2(whispersRaw)) onWarning("whispers must be an object");
1696
+ else {
1697
+ for (const key of Object.keys(whispersRaw)) if (!WHISPER_KEYS.has(key)) onWarning("unknown whispers field " + key + " ignored");
1698
+ const generic = normalizePool(whispersRaw.generic, "whisperGeneric", onWarning);
1699
+ const rules = normalizeWhisperRules(whispersRaw.rules, onWarning);
1700
+ if (generic !== void 0 || rules !== void 0) overrides.whispers = {
1701
+ ...generic === void 0 ? {} : { generic },
1702
+ ...rules === void 0 ? {} : { rules }
1703
+ };
1704
+ }
1705
+ const panel = raw.panel === void 0 ? void 0 : normalizePanel(raw.panel, onWarning);
1706
+ if (overrides.status === void 0 && overrides.tools === void 0 && overrides.toolRemaining === void 0 && overrides.whispers === void 0 && panel === void 0) return;
1707
+ return {
1708
+ overrides,
1709
+ ...panel === void 0 ? {} : { panel }
1710
+ };
1711
+ }
1712
+ /**
1713
+ * Merge voice-pack layers into one pack; later layers win per slot. The
1714
+ * built-in pools are NOT a layer here — the chatter engines fall back to
1715
+ * them per key at draw time. Merge order for a selected pet:
1716
+ * mergeVoicePacks(registry.globalVoice, entry.voice).
1717
+ */
1718
+ function mergeVoicePacks(...layers) {
1719
+ const overrides = {};
1720
+ const labels = {};
1721
+ const stats = {};
1722
+ let actions;
1723
+ let panelSeen = false;
1724
+ let any = false;
1725
+ for (const layer of layers) {
1726
+ if (layer === void 0) continue;
1727
+ any = true;
1728
+ if (layer.overrides.status !== void 0) overrides.status = {
1729
+ ...overrides.status,
1730
+ ...layer.overrides.status
1731
+ };
1732
+ if (layer.overrides.tools !== void 0) overrides.tools = {
1733
+ ...overrides.tools,
1734
+ ...layer.overrides.tools
1735
+ };
1736
+ if (layer.overrides.toolRemaining !== void 0) overrides.toolRemaining = layer.overrides.toolRemaining;
1737
+ if (layer.overrides.whispers !== void 0) overrides.whispers = {
1738
+ ...overrides.whispers,
1739
+ ...layer.overrides.whispers
1740
+ };
1741
+ if (layer.panel !== void 0) {
1742
+ panelSeen = true;
1743
+ if (layer.panel.labels !== void 0) Object.assign(labels, layer.panel.labels);
1744
+ if (layer.panel.stats !== void 0) Object.assign(stats, layer.panel.stats);
1745
+ if (layer.panel.actions !== void 0) actions = layer.panel.actions;
1746
+ }
1747
+ }
1748
+ if (!any) return void 0;
1749
+ const panel = {
1750
+ ...Object.keys(labels).length > 0 ? { labels } : {},
1751
+ ...Object.keys(stats).length > 0 ? { stats } : {},
1752
+ ...actions === void 0 ? {} : { actions }
1753
+ };
1754
+ const panelEmpty = panel.labels === void 0 && panel.stats === void 0 && panel.actions === void 0;
1755
+ return {
1756
+ overrides,
1757
+ ...panelSeen && !panelEmpty ? { panel } : {}
1758
+ };
1759
+ }
1760
+ /** Renderer kinds the pet center knows how to dispatch (M1 §2). */
1761
+ const PET_RENDERER_KINDS = ["sprite2d", "live2d"];
1762
+ /** The seven ActivityPhase semantics (pet-center owned; M1 §1). */
1763
+ const PET_ACTIVITY_PHASES = [
1764
+ "idle",
1765
+ "waiting",
1766
+ "thinking",
1767
+ "tool",
1768
+ "review",
1769
+ "done",
1770
+ "failed"
1771
+ ];
1772
+ const PET_ID_PATTERN$2 = /^[a-z0-9][a-z0-9-]*$/;
1773
+ const PATH_SEGMENT_PATTERN$2 = /^[A-Za-z0-9._-]+$/;
1774
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
1775
+ /**
1776
+ * Field allow-lists mirroring contracts/pet-manifest-v2.schema.json. Exported
1777
+ * so the drift test can lock the schema file and this validator together;
1778
+ * the CLI reuses parsePetManifest instead of these.
1779
+ */
1780
+ const KNOWN_TOP_LEVEL = /* @__PURE__ */ new Set([
1781
+ "$schema",
1782
+ "petManifestVersion",
1783
+ "id",
1784
+ "displayName",
1785
+ "description",
1786
+ "version",
1787
+ "author",
1788
+ "license",
1789
+ "homepage",
1790
+ "renderer",
1791
+ "sprite2d",
1792
+ "live2d",
1793
+ "sequences",
1794
+ "remarks"
1795
+ ]);
1796
+ /** sprite2d block field allow-list (drift-locked to the schema file). */
1797
+ const KNOWN_SPRITE2D = /* @__PURE__ */ new Set([
1798
+ "spritesheetPath",
1799
+ "cell",
1800
+ "columns",
1801
+ "atlasRows",
1802
+ "frames",
1803
+ "tracks"
1804
+ ]);
1805
+ /** live2d block field allow-list (drift-locked to the schema file). */
1806
+ const KNOWN_LIVE2D = /* @__PURE__ */ new Set([
1807
+ "model",
1808
+ "scale",
1809
+ "translate",
1810
+ "motions",
1811
+ "expressions",
1812
+ "hitAreas",
1813
+ "lipSync"
1814
+ ]);
1815
+ var Diagnostics$1 = class {
1816
+ list = [];
1817
+ source;
1818
+ constructor(source) {
1819
+ this.source = source;
1820
+ }
1821
+ error(message) {
1822
+ this.list.push({
1823
+ level: "error",
1824
+ message: this.source + ": " + message
1825
+ });
1826
+ }
1827
+ warn(message) {
1828
+ this.list.push({
1829
+ level: "warning",
1830
+ message: this.source + ": " + message
1831
+ });
1832
+ }
1833
+ get hasErrors() {
1834
+ return this.list.some((d) => d.level === "error");
1835
+ }
1836
+ };
1837
+ function isRecord$1(value) {
1838
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1839
+ }
1840
+ function unknownKeys$1(source, known) {
1841
+ return Object.keys(source).filter((key) => !known.has(key));
1842
+ }
1843
+ /**
1844
+ * Validate a manifest-relative asset path: no absolute paths, no backslashes,
1845
+ * no traversal, plain safe segments only. Returns the normalized path.
1846
+ */
1847
+ function safeManifestPath(raw) {
1848
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
1849
+ const value = raw.trim();
1850
+ if (isAbsolute(value) || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return void 0;
1851
+ const segments = value.split("/").filter((segment) => segment !== "");
1852
+ if (segments.length === 0) return void 0;
1853
+ if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$2.test(segment))) return void 0;
1854
+ return segments.join("/");
1855
+ }
1856
+ function parseStringBlock(record, key, diag, required) {
1857
+ const value = record[key];
1858
+ if (value === void 0) {
1859
+ if (required) diag.error("missing required field " + JSON.stringify(key));
1860
+ return;
1861
+ }
1862
+ if (typeof value !== "string" || value.trim() === "") {
1863
+ diag.error("field " + JSON.stringify(key) + " must be a non-empty string");
1864
+ return;
1865
+ }
1866
+ return value.trim();
1867
+ }
1868
+ /** Validate the phase-keyed string map shape shared by motions/expressions. */
1869
+ function parsePhaseStringMap(raw, field, diag) {
1870
+ if (raw === void 0) return void 0;
1871
+ if (!isRecord$1(raw)) {
1872
+ diag.error("field " + JSON.stringify(field) + " must be an object keyed by activity phase");
1873
+ return;
1874
+ }
1875
+ const result = {};
1876
+ for (const [phase, value] of Object.entries(raw)) {
1877
+ if (!PET_ACTIVITY_PHASES.includes(phase)) {
1878
+ diag.error(field + ": unknown activity phase " + JSON.stringify(phase));
1879
+ continue;
1880
+ }
1881
+ if (typeof value !== "string" || value.trim() === "") {
1882
+ diag.error(field + "." + phase + " must be a non-empty string");
1883
+ continue;
1884
+ }
1885
+ result[phase] = value.trim();
1886
+ }
1887
+ return result;
1888
+ }
1889
+ /** Structural gate for sequences: content stays warn-and-drop (registry's job). */
1890
+ function parseSequences(raw, diag) {
1891
+ if (raw === void 0) return void 0;
1892
+ if (!isRecord$1(raw)) {
1893
+ diag.warn("sequences must be an object keyed by activity phase; ignoring");
1894
+ return;
1895
+ }
1896
+ const sequences = {};
1897
+ for (const [phase, value] of Object.entries(raw)) {
1898
+ if (!PET_ACTIVITY_PHASES.includes(phase)) {
1899
+ diag.warn("sequences: unknown activity phase " + JSON.stringify(phase) + "; entry dropped");
1900
+ continue;
1901
+ }
1902
+ if (!Array.isArray(value) || value.length < 5 || value.some((item) => typeof item !== "string")) {
1903
+ diag.warn("sequences." + phase + " must be an array of at least 5 animation names; entry dropped");
1904
+ continue;
1905
+ }
1906
+ sequences[phase] = value;
1907
+ }
1908
+ return Object.keys(sequences).length === 0 ? void 0 : sequences;
1909
+ }
1910
+ function parseSprite2dBlock(raw, diag) {
1911
+ if (!isRecord$1(raw)) {
1912
+ diag.error("renderer sprite2d requires a \"sprite2d\" block object");
1913
+ return;
1914
+ }
1915
+ const extra = unknownKeys$1(raw, KNOWN_SPRITE2D);
1916
+ if (extra.length > 0) diag.error("sprite2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1917
+ const spritesheetPath = safeManifestPath(raw.spritesheetPath);
1918
+ if (spritesheetPath === void 0) diag.error("sprite2d.spritesheetPath must be a safe manifest-relative path");
1919
+ const block = { spritesheetPath: spritesheetPath ?? "" };
1920
+ if (raw.cell !== void 0) if (!isRecord$1(raw.cell)) diag.error("sprite2d.cell must be an object { width?, height? }");
1921
+ else block.cell = raw.cell;
1922
+ if (raw.columns !== void 0) if (typeof raw.columns !== "number" || !Number.isInteger(raw.columns) || raw.columns < 1) diag.error("sprite2d.columns must be a positive integer");
1923
+ else block.columns = raw.columns;
1924
+ if (raw.atlasRows !== void 0) if (typeof raw.atlasRows !== "number" || !Number.isInteger(raw.atlasRows) || raw.atlasRows < 1) diag.error("sprite2d.atlasRows must be a positive integer");
1925
+ else block.atlasRows = raw.atlasRows;
1926
+ if (raw.frames !== void 0) if (!Array.isArray(raw.frames) || raw.frames.some((v) => typeof v !== "number" || !Number.isInteger(v) || v < 0)) diag.error("sprite2d.frames must be an array of non-negative integers");
1927
+ else block.frames = raw.frames;
1928
+ if (raw.tracks !== void 0) if (!isRecord$1(raw.tracks)) diag.error("sprite2d.tracks must be an object keyed by animation");
1929
+ else block.tracks = raw.tracks;
1930
+ return diag.hasErrors ? void 0 : block;
1931
+ }
1932
+ function parseLive2dBlock(raw, diag) {
1933
+ if (!isRecord$1(raw)) {
1934
+ diag.error("renderer live2d requires a \"live2d\" block object");
1935
+ return;
1936
+ }
1937
+ const extra = unknownKeys$1(raw, KNOWN_LIVE2D);
1938
+ if (extra.length > 0) diag.error("live2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1939
+ const model = safeManifestPath(raw.model);
1940
+ if (model === void 0) diag.error("live2d.model must be a safe manifest-relative path to a .model3.json");
1941
+ else if (!model.endsWith(".model3.json")) diag.error("live2d.model must point at a .model3.json file");
1942
+ const motions = parsePhaseStringMap(raw.motions, "live2d.motions", diag);
1943
+ if (raw.motions === void 0) diag.error("live2d.motions is required (at least an \"idle\" group)");
1944
+ else if (motions !== void 0 && motions.idle === void 0) diag.error("live2d.motions.idle is required (unmapped phases fall back to it)");
1945
+ const block = {
1946
+ model: model ?? "",
1947
+ motions: motions ?? { idle: "" }
1948
+ };
1949
+ if (raw.scale !== void 0) if (typeof raw.scale !== "number" || !Number.isFinite(raw.scale) || raw.scale <= 0 || raw.scale > 10) diag.error("live2d.scale must be a number in (0, 10]");
1950
+ else block.scale = raw.scale;
1951
+ if (raw.translate !== void 0) if (!isRecord$1(raw.translate) || raw.translate.x !== void 0 && typeof raw.translate.x !== "number" || raw.translate.y !== void 0 && typeof raw.translate.y !== "number") diag.error("live2d.translate must be an object { x?: number, y?: number }");
1952
+ else block.translate = raw.translate;
1953
+ const expressions = parsePhaseStringMap(raw.expressions, "live2d.expressions", diag);
1954
+ if (expressions !== void 0) block.expressions = expressions;
1955
+ if (raw.hitAreas !== void 0) if (!Array.isArray(raw.hitAreas) || raw.hitAreas.some((v) => typeof v !== "string" || v.trim() === "")) diag.error("live2d.hitAreas must be an array of non-empty strings");
1956
+ else block.hitAreas = raw.hitAreas;
1957
+ if (raw.lipSync !== void 0) if (typeof raw.lipSync !== "boolean") diag.error("live2d.lipSync must be a boolean");
1958
+ else block.lipSync = raw.lipSync;
1959
+ return diag.hasErrors ? void 0 : block;
1960
+ }
1961
+ /** v1 compat read: map the legacy flat manifest onto the v2 sprite2d shape. */
1962
+ function compatV1(source, diag) {
1963
+ const id = parseStringBlock(source, "id", diag, true);
1964
+ if (id !== void 0 && !PET_ID_PATTERN$2.test(id)) diag.error("id " + JSON.stringify(id) + " is not a lowercase kebab id");
1965
+ const displayName = typeof source.displayName === "string" && source.displayName.trim() !== "" ? source.displayName.trim() : id;
1966
+ const spritesheetPath = safeManifestPath(source.spritesheetPath === void 0 ? "spritesheet.webp" : source.spritesheetPath);
1967
+ if (spritesheetPath === void 0) diag.error("spritesheetPath " + JSON.stringify(String(source.spritesheetPath)) + " is not a safe relative path");
1968
+ if (source.license === void 0) diag.warn("v1 compat read: no license field; run scripts/dsh-pet-migrate-v2 to migrate this pet");
1969
+ const sprite2d = { spritesheetPath: spritesheetPath ?? "spritesheet.webp" };
1970
+ if (isRecord$1(source.cell)) sprite2d.cell = source.cell;
1971
+ if (typeof source.columns === "number") sprite2d.columns = source.columns;
1972
+ if (Array.isArray(source.frames)) sprite2d.frames = source.frames;
1973
+ if (isRecord$1(source.tracks)) sprite2d.tracks = source.tracks;
1974
+ if (source.spriteVersionNumber === 2) sprite2d.atlasRows = 11;
1975
+ const manifest = {
1976
+ petManifestVersion: 2,
1977
+ id: id ?? "",
1978
+ displayName: displayName ?? "",
1979
+ renderer: "sprite2d",
1980
+ sprite2d
1981
+ };
1982
+ if (typeof source.description === "string" && source.description.trim() !== "") manifest.description = source.description.trim();
1983
+ if (typeof source.license === "string" && source.license.trim() !== "") manifest.license = source.license.trim();
1984
+ const sequences = parseSequences(source.sequences, diag);
1985
+ if (sequences !== void 0) manifest.sequences = sequences;
1986
+ if (source.remarks !== void 0) manifest.remarks = source.remarks;
1987
+ return diag.hasErrors ? void 0 : manifest;
1988
+ }
1989
+ /** Strict v2 validation (fail-closed on structure). */
1990
+ function parseV2(source, diag) {
1991
+ const extra = unknownKeys$1(source, KNOWN_TOP_LEVEL);
1992
+ if (extra.length > 0) diag.error("unknown top-level field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1993
+ if (source.petManifestVersion !== 2) diag.error("petManifestVersion must be 2 (got " + JSON.stringify(source.petManifestVersion) + ")");
1994
+ const id = parseStringBlock(source, "id", diag, true);
1995
+ if (id !== void 0 && (!PET_ID_PATTERN$2.test(id) || id.length > 64)) diag.error("id " + JSON.stringify(id) + " must be a lowercase kebab id of at most 64 chars");
1996
+ const displayName = parseStringBlock(source, "displayName", diag, true);
1997
+ const license = parseStringBlock(source, "license", diag, true);
1998
+ const rendererRaw = source.renderer === void 0 ? "sprite2d" : source.renderer;
1999
+ if (!PET_RENDERER_KINDS.includes(rendererRaw)) diag.error("unknown renderer " + JSON.stringify(rendererRaw) + "; expected one of " + PET_RENDERER_KINDS.join(", "));
2000
+ const renderer = rendererRaw;
2001
+ const manifest = {
2002
+ petManifestVersion: 2,
2003
+ id: id ?? "",
2004
+ displayName: displayName ?? "",
2005
+ renderer
2006
+ };
2007
+ if (license !== void 0) manifest.license = license;
2008
+ if (source.description !== void 0) if (typeof source.description !== "string" || source.description.length > 500) diag.error("description must be a string of at most 500 chars");
2009
+ else manifest.description = source.description;
2010
+ if (source.version !== void 0) if (typeof source.version !== "string" || !SEMVER_PATTERN.test(source.version)) diag.error("version must be a semver string (x.y.z)");
2011
+ else manifest.version = source.version;
2012
+ if (source.author !== void 0) if (typeof source.author !== "string" || source.author.length > 128) diag.error("author must be a string of at most 128 chars");
2013
+ else manifest.author = source.author;
2014
+ if (source.homepage !== void 0) if (typeof source.homepage !== "string") diag.error("homepage must be a string URL");
2015
+ else manifest.homepage = source.homepage;
2016
+ if (renderer === "sprite2d") {
2017
+ const block = parseSprite2dBlock(source.sprite2d, diag);
2018
+ if (block !== void 0) manifest.sprite2d = block;
2019
+ if (source.live2d !== void 0) diag.error("renderer sprite2d must not declare a live2d block");
2020
+ } else if (renderer === "live2d") {
2021
+ const block = parseLive2dBlock(source.live2d, diag);
2022
+ if (block !== void 0) manifest.live2d = block;
2023
+ if (source.sprite2d !== void 0) diag.error("renderer live2d must not declare a sprite2d block");
2024
+ }
2025
+ const sequences = parseSequences(source.sequences, diag);
2026
+ if (sequences !== void 0) manifest.sequences = sequences;
2027
+ if (source.remarks !== void 0 && !isRecord$1(source.remarks)) diag.error("remarks must be an object of remark pools");
2028
+ else if (source.remarks !== void 0) manifest.remarks = source.remarks;
2029
+ return diag.hasErrors ? void 0 : manifest;
2030
+ }
2031
+ /**
2032
+ * Parse one pet manifest: v1 (no petManifestVersion) is compat-read as a
2033
+ * sprite2d pet with a migration hint; v2 is validated fail-closed. The parse
2034
+ * never throws — every failure comes back as structured diagnostics.
2035
+ * @param raw - the parsed pet.json value.
2036
+ * @param sourceLabel - human-readable origin for diagnostics (dir or file).
2037
+ */
2038
+ function parsePetManifest(raw, sourceLabel) {
2039
+ const diag = new Diagnostics$1(sourceLabel);
2040
+ if (!isRecord$1(raw)) {
2041
+ diag.error("manifest is not an object");
2042
+ return {
2043
+ ok: false,
2044
+ diagnostics: diag.list
2045
+ };
2046
+ }
2047
+ if (raw.petManifestVersion === void 0) {
2048
+ const manifest = compatV1(raw, diag);
2049
+ if (manifest === void 0) return {
2050
+ ok: false,
2051
+ diagnostics: diag.list
2052
+ };
2053
+ diag.warn("v1 compat read: manifest treated as renderer \"sprite2d\"; run scripts/dsh-pet-migrate-v2 to migrate");
2054
+ return {
2055
+ ok: true,
2056
+ manifest,
2057
+ migrated: "v1-compat",
2058
+ diagnostics: diag.list
2059
+ };
2060
+ }
2061
+ const manifest = parseV2(raw, diag);
2062
+ if (manifest === void 0) return {
2063
+ ok: false,
2064
+ diagnostics: diag.list
2065
+ };
2066
+ return {
2067
+ ok: true,
2068
+ manifest,
2069
+ migrated: void 0,
2070
+ diagnostics: diag.list
2071
+ };
2072
+ }
2073
+ const DECORATION_ENTRY_EXTENSIONS = [".webp", ".png"];
2074
+ /** Field allow-list (drift-locked to the schema twin in tests). */
2075
+ const KNOWN_DECORATION_TOP_LEVEL = /* @__PURE__ */ new Set([
2076
+ "$schema",
2077
+ "decorationManifestVersion",
2078
+ "id",
2079
+ "displayName",
2080
+ "license",
2081
+ "entry",
2082
+ "cell",
2083
+ "columns",
2084
+ "frameMs",
2085
+ "durations",
2086
+ "loop",
2087
+ "phases"
2088
+ ]);
2089
+ const PET_ID_PATTERN$1 = /^[a-z0-9][a-z0-9-]*$/;
2090
+ const PATH_SEGMENT_PATTERN$1 = /^[A-Za-z0-9._-]+$/;
2091
+ var Diagnostics = class {
2092
+ list = [];
2093
+ source;
2094
+ constructor(source) {
2095
+ this.source = source;
2096
+ }
2097
+ error(message) {
2098
+ this.list.push({
2099
+ level: "error",
2100
+ message: this.source + ": " + message
2101
+ });
2102
+ }
2103
+ warn(message) {
2104
+ this.list.push({
2105
+ level: "warning",
2106
+ message: this.source + ": " + message
2107
+ });
2108
+ }
2109
+ get hasErrors() {
2110
+ return this.list.some((d) => d.level === "error");
2111
+ }
2112
+ };
2113
+ function isRecord(value) {
2114
+ return typeof value === "object" && value !== null && !Array.isArray(value);
2115
+ }
2116
+ function unknownKeys(source, known) {
2117
+ return Object.keys(source).filter((key) => !known.has(key));
2118
+ }
2119
+ /** Positive integer in [min, max], else undefined. */
2120
+ function finiteInt$1(value, min, max) {
2121
+ return typeof value === "number" && Number.isInteger(value) && value >= min && value <= max ? value : void 0;
2122
+ }
2123
+ /**
2124
+ * Validate a descriptor-relative entry path: no absolute paths, no
2125
+ * backslashes, no traversal, plain safe segments only, and an exact
2126
+ * lowercase PNG/WebP extension (the adopted entry discipline — SVG/CSS are
2127
+ * not accepted). The extension match is case-sensitive on purpose: the
2128
+ * asset route serves the declared path verbatim, so a case-mismatched
2129
+ * suffix (frames.PNG vs frames.png) would pass a lenient check but 403 on
2130
+ * case-sensitive filesystems.
2131
+ * Returns the normalized path or undefined.
2132
+ */
2133
+ function safeDecorationEntry(raw) {
2134
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
2135
+ const value = raw.trim();
2136
+ if (value.length > 256) return void 0;
2137
+ if (isAbsolute(value) || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return void 0;
2138
+ const segments = value.split("/").filter((segment) => segment !== "");
2139
+ if (segments.length === 0) return void 0;
2140
+ if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$1.test(segment))) return void 0;
2141
+ const last = segments[segments.length - 1];
2142
+ const dot = last.lastIndexOf(".");
2143
+ if (dot <= 0 || !DECORATION_ENTRY_EXTENSIONS.includes(last.slice(dot))) return void 0;
2144
+ return segments.join("/");
2145
+ }
2146
+ /** Normalize one phase binding value; undefined (warning) on bad content. */
2147
+ function normalizeSegment(raw, columns, diag) {
2148
+ if (raw === "hide") return "hide";
2149
+ if (!isRecord(raw)) {
2150
+ diag.warn("phase binding must be \"hide\" or { from, to }; binding dropped");
2151
+ return;
2152
+ }
2153
+ const from = finiteInt$1(raw.from, 0, columns - 1);
2154
+ const to = finiteInt$1(raw.to, 0, columns - 1);
2155
+ if (from === void 0 || to === void 0 || from > to) {
2156
+ diag.warn("phase frame segment out of range; binding dropped");
2157
+ return;
2158
+ }
2159
+ return {
2160
+ from,
2161
+ to
2162
+ };
2163
+ }
2164
+ /**
2165
+ * Parse and validate one decoration.json document. Fail-closed over the
2166
+ * structure (types, key sets, paths, ranges); phase-binding content issues
2167
+ * drop that binding only (warn-and-drop, the registry never-throw rule).
2168
+ */
2169
+ function parseDecorationManifest(raw, source = "decoration.json") {
2170
+ const diag = new Diagnostics(source);
2171
+ if (!isRecord(raw)) {
2172
+ diag.error("descriptor must be a JSON object");
2173
+ return {
2174
+ ok: false,
2175
+ diagnostics: diag.list
2176
+ };
2177
+ }
2178
+ for (const key of unknownKeys(raw, KNOWN_DECORATION_TOP_LEVEL)) diag.error("unknown top-level field " + JSON.stringify(key));
2179
+ if (raw.decorationManifestVersion !== 1) diag.error("decorationManifestVersion must be 1");
2180
+ const id = typeof raw.id === "string" ? raw.id.trim() : "";
2181
+ if (!PET_ID_PATTERN$1.test(id)) diag.error("id must be a lowercase kebab id");
2182
+ if (id.length > 64) diag.error("id must be at most 64 characters");
2183
+ const license = typeof raw.license === "string" ? raw.license.trim() : "";
2184
+ if (license === "") diag.error("license is required (asset provenance)");
2185
+ if (license.length > 128) diag.error("license must be at most 128 characters");
2186
+ const entry = safeDecorationEntry(raw.entry);
2187
+ if (entry === void 0) diag.error("entry must be a safe relative PNG/WebP path");
2188
+ const rawCell = isRecord(raw.cell) ? raw.cell : {};
2189
+ for (const key of Object.keys(rawCell)) if (key !== "width" && key !== "height") diag.warn("unknown cell field " + JSON.stringify(key) + " ignored");
2190
+ const cellWidth = finiteInt$1(rawCell.width, 1, 256);
2191
+ const cellHeight = finiteInt$1(rawCell.height, 1, 256);
2192
+ if (cellWidth === void 0 || cellHeight === void 0) diag.error("cell width/height must be integers in [1, 256]");
2193
+ const columns = finiteInt$1(raw.columns, 1, 16);
2194
+ if (columns === void 0) diag.error("columns must be an integer in [1, 16]");
2195
+ if (diag.hasErrors || id === "" || entry === void 0 || columns === void 0) return {
2196
+ ok: false,
2197
+ diagnostics: diag.list
2198
+ };
2199
+ const displayName = typeof raw.displayName === "string" && raw.displayName.trim() !== "" ? raw.displayName.trim().slice(0, 64) : id;
2200
+ let loop;
2201
+ if (raw.loop === void 0 || typeof raw.loop === "boolean") loop = raw.loop ?? true;
2202
+ else {
2203
+ diag.warn("loop must be a boolean; defaulting to true");
2204
+ loop = true;
2205
+ }
2206
+ const rawDurations = raw.durations;
2207
+ let durations;
2208
+ if (Array.isArray(rawDurations)) {
2209
+ const usable = rawDurations.filter((v) => typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 2e3);
2210
+ if (usable.length !== columns) {
2211
+ diag.warn("durations length must equal columns; using the constant frameMs instead");
2212
+ durations = [];
2213
+ } else durations = usable;
2214
+ } else if (rawDurations !== void 0) {
2215
+ diag.warn("durations must be an array; using the constant frameMs instead");
2216
+ durations = [];
2217
+ } else durations = [];
2218
+ if (durations.length === 0) {
2219
+ const frameMs = finiteInt$1(raw.frameMs, 1, 2e3) ?? 120;
2220
+ durations = Array.from({ length: columns }, () => frameMs);
2221
+ }
2222
+ const phases = {};
2223
+ const rawPhases = raw.phases;
2224
+ if (isRecord(rawPhases)) for (const [key, value] of Object.entries(rawPhases)) {
2225
+ if (!PET_ACTIVITY_PHASES.includes(key)) {
2226
+ diag.warn("unknown phase " + JSON.stringify(key) + "; binding ignored");
2227
+ continue;
2228
+ }
2229
+ const segment = normalizeSegment(value, columns, diag);
2230
+ if (segment !== void 0) phases[key] = segment;
2231
+ }
2232
+ else if (rawPhases !== void 0) diag.warn("phases must be an object; all phases hide");
2233
+ if (!Object.values(phases).some((segment) => segment !== "hide")) diag.warn("no phase shows the ornament; the decoration stays hidden");
2234
+ return {
2235
+ ok: true,
2236
+ manifest: {
2237
+ decorationManifestVersion: 1,
2238
+ id,
2239
+ displayName,
2240
+ license,
2241
+ entry,
2242
+ cell: {
2243
+ width: cellWidth,
2244
+ height: cellHeight
2245
+ },
2246
+ columns,
2247
+ durations,
2248
+ loop,
2249
+ phases
2250
+ },
2251
+ diagnostics: diag.list
2252
+ };
2253
+ }
2254
+ //#endregion
2255
+ //#region src/contracts/status-decoration.ts
2256
+ /** Contract version decorations declare against (independent of manifests). */
2257
+ const PET_DECORATION_API_VERSION = "x-org.linxin666.pet-center/status-decoration-v1";
2258
+ //#endregion
2259
+ //#region src/model3.ts
2260
+ /**
2261
+ * Live2D .model3.json reference closure — the set of files a model declares
2262
+ * (pet-center M2, issue #623). The host asset route only ever serves a pet's
2263
+ * declared manifest, its declared primary assets, and this closure; the CLI
2264
+ * validator reuses the same extractor so an install-time check proves the
2265
+ * serving set is complete.
2266
+ *
2267
+ * Cubism file family: Moc (.moc3), Textures (images), Physics (.physics3.json),
2268
+ * Pose (.pose3.json), DisplayInfo (.cdi3.json), Expressions[].File
2269
+ * (.exp3.json), Motions.<group>[].File (.motion3.json), UserData
2270
+ * (.userdata3.json). Every reference must be a safe manifest-relative path
2271
+ * (safeManifestPath); unsafe entries make the model unloadable.
2272
+ *
2273
+ * Erasable-syntax-only: scripts/ import this under node strip-only mode.
2274
+ * @module @linxin666/dsh-pet/model3
2275
+ */
2276
+ /** Collect the safe relative paths one model3.json references. */
2277
+ function collectModel3References(model3) {
2278
+ const errors = [];
2279
+ if (typeof model3 !== "object" || model3 === null) return {
2280
+ references: [],
2281
+ errors: ["model3.json is not an object"]
2282
+ };
2283
+ const fileReferences = model3.FileReferences;
2284
+ if (typeof fileReferences !== "object" || fileReferences === null) return {
2285
+ references: [],
2286
+ errors: ["model3.json has no FileReferences"]
2287
+ };
2288
+ const refs = fileReferences;
2289
+ const collected = /* @__PURE__ */ new Set();
2290
+ const push = (raw, field) => {
2291
+ const safe = safeManifestPath(raw);
2292
+ if (safe === void 0) {
2293
+ errors.push(field + " is not a safe relative path: " + JSON.stringify(String(raw)));
2294
+ return;
2295
+ }
2296
+ collected.add(safe);
2297
+ };
2298
+ if (refs.Moc !== void 0) push(refs.Moc, "FileReferences.Moc");
2299
+ if (Array.isArray(refs.Textures)) refs.Textures.forEach((texture, index) => push(texture, "FileReferences.Textures[" + index + "]"));
2300
+ for (const scalar of [
2301
+ "Physics",
2302
+ "Pose",
2303
+ "DisplayInfo",
2304
+ "UserData"
2305
+ ]) if (refs[scalar] !== void 0) push(refs[scalar], "FileReferences." + scalar);
2306
+ if (Array.isArray(refs.Expressions)) refs.Expressions.forEach((expression, index) => {
2307
+ const file = typeof expression === "object" && expression !== null ? expression.File : void 0;
2308
+ if (file !== void 0) push(file, "FileReferences.Expressions[" + index + "].File");
2309
+ });
2310
+ if (typeof refs.Motions === "object" && refs.Motions !== null) for (const [group, motions] of Object.entries(refs.Motions)) {
2311
+ if (!Array.isArray(motions)) {
2312
+ errors.push("FileReferences.Motions." + group + " is not an array");
2313
+ continue;
2314
+ }
2315
+ motions.forEach((motion, index) => {
2316
+ const file = typeof motion === "object" && motion !== null ? motion.File : void 0;
2317
+ if (file !== void 0) push(file, "FileReferences.Motions." + group + "[" + index + "].File");
2318
+ });
2319
+ }
2320
+ return {
2321
+ references: [...collected].sort(),
2322
+ errors
2323
+ };
2324
+ }
1376
2325
  //#endregion
1377
2326
  //#region src/registry.ts
1378
2327
  /**
1379
2328
  * Pet registry — the multi-pet contract. One pet is a directory holding a
1380
2329
  * 'pet.json' manifest plus an atlas image; nothing else is required, and no
1381
- * host or client code changes when a pet is added. The registry scans three
2330
+ * host or client code changes when a pet is added. The registry scans four
1382
2331
  * sources, later sources overriding earlier ones on an id collision:
1383
2332
  *
1384
2333
  * 1. the package's own 'assets' subdirectories (built-in pets);
1385
- * 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets);
1386
- * 3. 'PetConfig.pets' manifests composed by the embedding application
2334
+ * 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets,
2335
+ * legacy source kept readable);
2336
+ * 3. '$DSH_HOME/pets' subdirectories (the pet-center user directory);
2337
+ * 4. 'PetConfig.pets' manifests composed by the embedding application
1387
2338
  * (highest precedence).
1388
2339
  *
2340
+ * Manifests are parsed through manifest-v2 (pet-center M2, issue #623): v1
2341
+ * manifests are compat-read as sprite2d, v2 manifests validate fail-closed,
2342
+ * and structured diagnostics ride alongside the legacy warnings. Live2d
2343
+ * entries (pet-center M3) list like any other pet: the entry carries the
2344
+ * validated live2d block plus the model's reference closure (the servable
2345
+ * set the asset route allows), and a model3.json that is unreadable or
2346
+ * declares unsafe references rejects the entry with an error diagnostic.
2347
+ *
1389
2348
  * The manifest follows the Codex/hatch-pet contract (8 columns x 9 rows of
1390
2349
  * 192x208 cells, the 9-state row order below). Legacy whale-girl manifests
1391
2350
  * that only carry 'frames' keep working: geometry, per-row frame counts and
@@ -1596,6 +2555,33 @@ function normalizeSequences(raw, id, warn) {
1596
2555
  return Object.keys(sequences).length === 0 ? void 0 : sequences;
1597
2556
  }
1598
2557
  /**
2558
+ * Build the fully resolved animation tracks from the contract defaults plus
2559
+ * optional per-track overrides. Shared by the sprite2d resolver and the
2560
+ * live2d entry builder (which fills the sprite fields with contract
2561
+ * defaults so the flat PetDefinition shape holds for every renderer).
2562
+ */
2563
+ function buildTracks(rows, columns, trackOverrides, warn) {
2564
+ const tracks = {};
2565
+ for (const [row, animation] of PET_ROW_ORDER.entries()) {
2566
+ const pattern = DEFAULT_TRACK_PATTERNS[animation];
2567
+ const override = trackOverrides[animation];
2568
+ const durations = Array.isArray(override?.durations) && override.durations.length > 0 ? override.durations.filter((value) => typeof value === "number" && Number.isFinite(value) && value > 0) : pattern.durations;
2569
+ if (durations.length === 0) {
2570
+ warn("track " + animation + " carries no usable durations");
2571
+ return;
2572
+ }
2573
+ const frameCount = Math.max(1, Math.min(rows[row], columns));
2574
+ const sized = durations.length >= frameCount ? durations.slice(0, frameCount) : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]);
2575
+ tracks[animation] = {
2576
+ frames: Array.from({ length: frameCount }, (_, index) => index),
2577
+ durations: sized,
2578
+ loop: typeof override?.loop === "boolean" ? override.loop : pattern.loop,
2579
+ ...override?.fallback === void 0 ? pattern.fallback === void 0 ? {} : { fallback: pattern.fallback } : PET_ROW_ORDER.includes(override.fallback) ? { fallback: override.fallback } : pattern.fallback === void 0 ? {} : { fallback: pattern.fallback }
2580
+ };
2581
+ }
2582
+ return tracks;
2583
+ }
2584
+ /**
1599
2585
  * Normalize one parsed manifest into a renderable pet entry, or undefined
1600
2586
  * (with a warning recorded) when the manifest violates the contract.
1601
2587
  */
@@ -1634,29 +2620,14 @@ function resolvePetManifest(raw, dir, options = {}) {
1634
2620
  });
1635
2621
  const remarks = normalizePetRemarks(source.remarks, (message) => warn("manifest " + id + ": " + message));
1636
2622
  const sequences = normalizeSequences(source.sequences, id, warn);
1637
- const trackOverrides = typeof source.tracks === "object" && source.tracks !== null ? source.tracks : {};
1638
- const tracks = {};
1639
- for (const [row, animation] of PET_ROW_ORDER.entries()) {
1640
- const pattern = DEFAULT_TRACK_PATTERNS[animation];
1641
- const override = trackOverrides[animation];
1642
- const durations = Array.isArray(override?.durations) && override.durations.length > 0 ? override.durations.filter((value) => typeof value === "number" && Number.isFinite(value) && value > 0) : pattern.durations;
1643
- if (durations.length === 0) {
1644
- warn("manifest " + id + ": track " + animation + " carries no usable durations");
1645
- return;
1646
- }
1647
- const frameCount = Math.max(1, Math.min(rows[row], columns));
1648
- const sized = durations.length >= frameCount ? durations.slice(0, frameCount) : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]);
1649
- tracks[animation] = {
1650
- frames: Array.from({ length: frameCount }, (_, index) => index),
1651
- durations: sized,
1652
- loop: typeof override?.loop === "boolean" ? override.loop : pattern.loop,
1653
- ...override?.fallback === void 0 ? pattern.fallback === void 0 ? {} : { fallback: pattern.fallback } : PET_ROW_ORDER.includes(override.fallback) ? { fallback: override.fallback } : pattern.fallback === void 0 ? {} : { fallback: pattern.fallback }
1654
- };
1655
- }
2623
+ const tracks = buildTracks(rows, columns, typeof source.tracks === "object" && source.tracks !== null ? source.tracks : {}, (message) => warn("manifest " + id + ": " + message));
2624
+ if (tracks === void 0) return void 0;
2625
+ const sheet = spritesheetPath.join("/");
1656
2626
  return {
1657
2627
  id,
1658
2628
  displayName,
1659
2629
  description,
2630
+ renderer: "sprite2d",
1660
2631
  cell,
1661
2632
  columns,
1662
2633
  rows,
@@ -1666,7 +2637,106 @@ function resolvePetManifest(raw, dir, options = {}) {
1666
2637
  atlasUrl: assetUrl(assetPrefix, id, spritesheet),
1667
2638
  manifestUrl: assetUrl(assetPrefix, id, "pet.json"),
1668
2639
  dir,
1669
- spritesheetPath: spritesheetPath.join("/"),
2640
+ spritesheetPath: sheet,
2641
+ servable: [sheet],
2642
+ ...remarks === void 0 ? {} : { remarks }
2643
+ };
2644
+ }
2645
+ /**
2646
+ * Adapt a validated v2 manifest's sprite2d block onto the legacy flat shape
2647
+ * the established resolver consumes (pet-center M2 P2). The legacy resolver
2648
+ * only expresses 9-row (default) and 11-row (spriteVersionNumber 2) atlases,
2649
+ * so other atlasRows values are rejected here with a diagnostic.
2650
+ */
2651
+ function flattenV2Sprite2d(manifest) {
2652
+ const block = manifest.sprite2d;
2653
+ if (block === void 0) return void 0;
2654
+ const legacy = {
2655
+ id: manifest.id,
2656
+ displayName: manifest.displayName,
2657
+ spritesheetPath: block.spritesheetPath
2658
+ };
2659
+ if (manifest.description !== void 0) legacy.description = manifest.description;
2660
+ if (block.cell !== void 0) legacy.cell = block.cell;
2661
+ if (block.columns !== void 0) legacy.columns = block.columns;
2662
+ if (block.frames !== void 0) legacy.frames = block.frames;
2663
+ if (block.tracks !== void 0) legacy.tracks = block.tracks;
2664
+ if (block.atlasRows !== void 0) {
2665
+ if (block.atlasRows === 11) legacy.spriteVersionNumber = 2;
2666
+ else if (block.atlasRows !== 9) return void 0;
2667
+ }
2668
+ if (manifest.sequences !== void 0) legacy.sequences = manifest.sequences;
2669
+ if (manifest.remarks !== void 0) legacy.remarks = manifest.remarks;
2670
+ return legacy;
2671
+ }
2672
+ /**
2673
+ * Resolve a validated live2d manifest into a renderable entry (pet-center
2674
+ * M3). The model3.json is read at scan time: its reference closure becomes
2675
+ * the entry's servable set (the asset route's allow-list), and a model that
2676
+ * is unreadable or declares unsafe references rejects the entry fail-closed
2677
+ * with an error diagnostic. Closure files missing on disk warn but keep the
2678
+ * entry listed — the client renderer's diagnostic card reports the broken
2679
+ * render, matching the registry's never-throw philosophy (install-time
2680
+ * strictness belongs to the CLI validator). The sprite fields carry contract
2681
+ * defaults: the chrome sizes live2d pets off 'display.size', not the atlas.
2682
+ */
2683
+ function resolveLive2dEntry(manifest, dir, options) {
2684
+ const assetPrefix = options.assetPrefix ?? "/pet";
2685
+ const record = (level, message) => {
2686
+ options.diagnostics?.push({
2687
+ level,
2688
+ source: dir,
2689
+ message
2690
+ });
2691
+ options.warnings?.push(message);
2692
+ };
2693
+ const block = manifest.live2d;
2694
+ if (block === void 0) {
2695
+ record("error", "pet " + manifest.id + ": renderer live2d requires a live2d block");
2696
+ return;
2697
+ }
2698
+ let model3;
2699
+ try {
2700
+ model3 = JSON.parse(readFileSync(join(dir, block.model), "utf8"));
2701
+ } catch (error) {
2702
+ record("error", "pet " + manifest.id + ": live2d model " + block.model + " is not readable: " + (error instanceof Error ? error.message : String(error)));
2703
+ return;
2704
+ }
2705
+ const { references, errors } = collectModel3References(model3);
2706
+ if (errors.length > 0) {
2707
+ for (const message of errors) record("error", "pet " + manifest.id + ": live2d model " + block.model + ": " + message);
2708
+ return;
2709
+ }
2710
+ for (const reference of references) if (!existsSync(join(dir, reference))) record("warning", "pet " + manifest.id + ": live2d closure file missing: " + reference);
2711
+ const tracks = buildTracks(DEFAULT_FRAME_COUNTS, 8, {}, (message) => record("warning", "pet " + manifest.id + ": " + message));
2712
+ if (tracks === void 0) return void 0;
2713
+ const remarks = normalizePetRemarks(manifest.remarks, (message) => record("warning", "pet " + manifest.id + ": " + message));
2714
+ const modelUrl = assetUrl(assetPrefix, manifest.id, block.model);
2715
+ const live2d = {
2716
+ modelUrl,
2717
+ modelPath: block.model,
2718
+ ...block.scale === void 0 ? {} : { scale: block.scale },
2719
+ ...block.translate === void 0 ? {} : { translate: block.translate },
2720
+ motions: block.motions,
2721
+ ...block.expressions === void 0 ? {} : { expressions: block.expressions },
2722
+ ...block.hitAreas === void 0 ? {} : { hitAreas: block.hitAreas }
2723
+ };
2724
+ return {
2725
+ id: manifest.id,
2726
+ displayName: manifest.displayName,
2727
+ description: manifest.description ?? "",
2728
+ renderer: "live2d",
2729
+ live2d,
2730
+ cell: { ...DEFAULT_PET_CELL },
2731
+ columns: 8,
2732
+ rows: [...DEFAULT_FRAME_COUNTS],
2733
+ atlasRows: 9,
2734
+ tracks,
2735
+ atlasUrl: modelUrl,
2736
+ manifestUrl: assetUrl(assetPrefix, manifest.id, "pet.json"),
2737
+ dir,
2738
+ spritesheetPath: block.model,
2739
+ servable: [block.model, ...references],
1670
2740
  ...remarks === void 0 ? {} : { remarks }
1671
2741
  };
1672
2742
  }
@@ -1686,8 +2756,39 @@ function scanPetDir(dir, options) {
1686
2756
  if (!existsSync(manifestFile)) continue;
1687
2757
  const parsed = readPetJson(manifestFile, options.warnings);
1688
2758
  if (parsed === void 0) continue;
1689
- const entry = resolvePetManifest(parsed, join(dir, name), options);
1690
- if (entry !== void 0) entries.push(entry);
2759
+ const entryDir = join(dir, name);
2760
+ const verdict = parsePetManifest(parsed, entryDir);
2761
+ for (const diagnostic of verdict.diagnostics) {
2762
+ options.diagnostics?.push({
2763
+ level: diagnostic.level,
2764
+ source: entryDir,
2765
+ message: diagnostic.message
2766
+ });
2767
+ options.warnings?.push(diagnostic.message);
2768
+ }
2769
+ if (!verdict.ok) continue;
2770
+ let entry;
2771
+ if (verdict.manifest.renderer === "live2d") entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
2772
+ else {
2773
+ const legacy = flattenV2Sprite2d(verdict.manifest);
2774
+ if (legacy === void 0) {
2775
+ const note = "pet " + verdict.manifest.id + ": sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver";
2776
+ options.diagnostics?.push({
2777
+ level: "error",
2778
+ source: entryDir,
2779
+ message: note
2780
+ });
2781
+ options.warnings?.push(note);
2782
+ continue;
2783
+ }
2784
+ entry = resolvePetManifest(legacy, entryDir, options);
2785
+ }
2786
+ if (entry === void 0) continue;
2787
+ const voice = loadVoicePackFile(join(entryDir, "voice.json"), options);
2788
+ entries.push({
2789
+ ...entry,
2790
+ ...voice === void 0 ? {} : { voice }
2791
+ });
1691
2792
  }
1692
2793
  return entries;
1693
2794
  }
@@ -1701,6 +2802,135 @@ function readPetJson(file, warnings) {
1701
2802
  }
1702
2803
  }
1703
2804
  /**
2805
+ * Stat one scanned JSON descriptor with a regular-file + size guard, so a
2806
+ * pathological user file is skipped with a warning instead of stalling or
2807
+ * OOM-ing the host at startup. Returns the Stats, or undefined when the
2808
+ * caller must skip the file (a warning was recorded).
2809
+ */
2810
+ function guardedScannedJsonStat(file, options, what) {
2811
+ let st;
2812
+ try {
2813
+ st = statSync(file);
2814
+ } catch {
2815
+ return;
2816
+ }
2817
+ const warn = (message) => {
2818
+ options.warnings?.push(file + ": " + message);
2819
+ options.diagnostics?.push({
2820
+ level: "warning",
2821
+ source: file,
2822
+ message: file + ": " + message
2823
+ });
2824
+ };
2825
+ if (!st.isFile()) {
2826
+ warn(what + " is not a regular file; ignored");
2827
+ return;
2828
+ }
2829
+ if (st.size > 65536) {
2830
+ warn(what + " exceeds the 65536-byte scan ceiling; ignored");
2831
+ return;
2832
+ }
2833
+ return st;
2834
+ }
2835
+ /**
2836
+ * Load and normalize one optional voice.json (pet-center M4). A missing
2837
+ * file is silent; a broken file warns and drops. The pack is pure content,
2838
+ * so every issue stays a warning — a bad voice.json never rejects a pet.
2839
+ */
2840
+ function loadVoicePackFile(file, options) {
2841
+ if (!existsSync(file)) return void 0;
2842
+ if (guardedScannedJsonStat(file, options, "voice pack") === void 0) return void 0;
2843
+ const warn = (message) => {
2844
+ options.warnings?.push(file + ": " + message);
2845
+ options.diagnostics?.push({
2846
+ level: "warning",
2847
+ source: file,
2848
+ message: file + ": " + message
2849
+ });
2850
+ };
2851
+ let raw;
2852
+ try {
2853
+ raw = JSON.parse(readFileSync(file, "utf8"));
2854
+ } catch (error) {
2855
+ warn("voice pack is not valid JSON; ignored: " + (error instanceof Error ? error.message : String(error)));
2856
+ return;
2857
+ }
2858
+ return normalizeVoicePack(raw, warn);
2859
+ }
2860
+ /** Decoration asset URL prefix (served by the decoration route, M5). */
2861
+ const DECORATION_ASSET_PREFIX = "/api/pet/decoration";
2862
+ /**
2863
+ * Scan one directory of decoration folders ('decoration.json' + strip).
2864
+ * Later scans override earlier ones on id collision; a bad descriptor warns
2865
+ * and skips — the never-throw philosophy holds for decorations too (M5).
2866
+ */
2867
+ function scanDecorationDir(dir, options) {
2868
+ if (!existsSync(dir)) return [];
2869
+ let names = [];
2870
+ try {
2871
+ names = readdirSync(dir).filter((name) => !name.startsWith("."));
2872
+ } catch {
2873
+ return [];
2874
+ }
2875
+ names.sort();
2876
+ const entries = [];
2877
+ for (const name of names) {
2878
+ const entryDir = join(dir, name);
2879
+ const manifestFile = join(entryDir, "decoration.json");
2880
+ if (!existsSync(manifestFile)) continue;
2881
+ if (guardedScannedJsonStat(manifestFile, options, "decoration descriptor") === void 0) continue;
2882
+ let raw;
2883
+ try {
2884
+ raw = JSON.parse(readFileSync(manifestFile, "utf8"));
2885
+ } catch (error) {
2886
+ const message = "skipping " + manifestFile + ": " + (error instanceof Error ? error.message : String(error));
2887
+ options.warnings?.push(message);
2888
+ options.diagnostics?.push({
2889
+ level: "error",
2890
+ source: entryDir,
2891
+ message
2892
+ });
2893
+ continue;
2894
+ }
2895
+ const verdict = parseDecorationManifest(raw, manifestFile);
2896
+ for (const diagnostic of verdict.diagnostics) {
2897
+ options.diagnostics?.push({
2898
+ level: diagnostic.level,
2899
+ source: entryDir,
2900
+ message: diagnostic.message
2901
+ });
2902
+ options.warnings?.push(diagnostic.message);
2903
+ }
2904
+ if (!verdict.ok) continue;
2905
+ const manifest = verdict.manifest;
2906
+ if (!existsSync(join(entryDir, manifest.entry))) {
2907
+ const message = "decoration " + manifest.id + ": strip file missing: " + manifest.entry;
2908
+ options.warnings?.push(message);
2909
+ options.diagnostics?.push({
2910
+ level: "warning",
2911
+ source: entryDir,
2912
+ message
2913
+ });
2914
+ }
2915
+ entries.push({
2916
+ apiVersion: PET_DECORATION_API_VERSION,
2917
+ id: manifest.id,
2918
+ dir: entryDir,
2919
+ entryPath: manifest.entry,
2920
+ servable: ["decoration.json", manifest.entry],
2921
+ license: manifest.license,
2922
+ assetBase: "/api/pet/decoration/" + encodeURIComponent(manifest.id),
2923
+ entryUrl: "/api/pet/decoration/" + encodeURIComponent(manifest.id) + "/" + manifest.entry,
2924
+ cell: manifest.cell,
2925
+ columns: manifest.columns,
2926
+ durations: manifest.durations,
2927
+ loop: manifest.loop,
2928
+ phases: manifest.phases
2929
+ });
2930
+ }
2931
+ return entries;
2932
+ }
2933
+ /**
1704
2934
  * Load the pet registry: built-in 'assets/*' first, then the hatch-pet
1705
2935
  * custom pets directory, then composed 'extra' manifests (each later source
1706
2936
  * overrides an earlier one on id collision). The registry never throws on a
@@ -1709,11 +2939,13 @@ function readPetJson(file, warnings) {
1709
2939
  function loadPetRegistry(options) {
1710
2940
  const { packageRoot, assetPrefix = "/pet" } = options;
1711
2941
  const warnings = [];
2942
+ const diagnostics = [];
1712
2943
  const byId = /* @__PURE__ */ new Map();
1713
2944
  const builtinIds = /* @__PURE__ */ new Set();
1714
2945
  for (const entry of scanPetDir(join(packageRoot, "assets"), {
1715
2946
  assetPrefix,
1716
- warnings
2947
+ warnings,
2948
+ diagnostics
1717
2949
  })) {
1718
2950
  if (byId.has(entry.id)) {
1719
2951
  warnings.push("duplicate built-in pet id " + entry.id + "; the first one wins");
@@ -1725,11 +2957,28 @@ function loadPetRegistry(options) {
1725
2957
  const petsDir = options.petsDir ?? codexPetsDir();
1726
2958
  if (petsDir !== "") for (const entry of scanPetDir(petsDir, {
1727
2959
  assetPrefix,
1728
- warnings
2960
+ warnings,
2961
+ diagnostics
1729
2962
  })) {
1730
2963
  if (byId.has(entry.id)) warnings.push("custom pet " + entry.id + " overrides the built-in one");
1731
2964
  byId.set(entry.id, entry);
1732
2965
  }
2966
+ const dshPetsDir = options.dshPetsDir ?? join(dshHome(), "pets");
2967
+ let globalVoice;
2968
+ if (dshPetsDir !== "") {
2969
+ for (const entry of scanPetDir(dshPetsDir, {
2970
+ assetPrefix,
2971
+ warnings,
2972
+ diagnostics
2973
+ })) {
2974
+ if (byId.has(entry.id)) warnings.push("user pet " + entry.id + " overrides an earlier registration");
2975
+ byId.set(entry.id, entry);
2976
+ }
2977
+ globalVoice = loadVoicePackFile(join(dshPetsDir, ".voice.json"), {
2978
+ warnings,
2979
+ diagnostics
2980
+ });
2981
+ }
1733
2982
  for (const manifest of options.extra ?? []) {
1734
2983
  const raw = manifest.spritesheetPath;
1735
2984
  const dir = raw === void 0 || isAbsolute(raw) ? join(packageRoot, "assets", "extra") : dirname(resolve(packageRoot, raw));
@@ -1744,20 +2993,61 @@ function loadPetRegistry(options) {
1744
2993
  if (byId.has(entry.id)) warnings.push("composed pet " + entry.id + " overrides an earlier registration");
1745
2994
  byId.set(entry.id, entry);
1746
2995
  }
2996
+ const decorationById = /* @__PURE__ */ new Map();
2997
+ for (const entry of scanDecorationDir(join(packageRoot, "assets", "decorations"), {
2998
+ warnings,
2999
+ diagnostics
3000
+ })) decorationById.set(entry.id, entry);
3001
+ if (dshPetsDir !== "") for (const entry of scanDecorationDir(join(dshPetsDir, "decorations"), {
3002
+ warnings,
3003
+ diagnostics
3004
+ })) {
3005
+ if (decorationById.has(entry.id)) warnings.push("user decoration " + entry.id + " overrides the built-in one");
3006
+ decorationById.set(entry.id, entry);
3007
+ }
1747
3008
  const entries = [...byId.values()];
3009
+ const decorations = [...decorationById.values()];
1748
3010
  return {
1749
3011
  entries,
1750
3012
  warnings,
3013
+ diagnostics,
1751
3014
  byId: (id) => byId.get(id),
1752
- defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0]
3015
+ defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0],
3016
+ ...globalVoice === void 0 ? {} : { globalVoice },
3017
+ decorations,
3018
+ decorationById: (id) => decorationById.get(id)
1753
3019
  };
1754
3020
  }
1755
- /** Strip host-only fields, leaving the client-visible definition. */
1756
- function petEntryView(entry) {
3021
+ /** The built-in default decoration id (M5): the first reference ornament. */
3022
+ const DEFAULT_DECORATION_ID = "whale";
3023
+ /** Strip host-only fields, leaving the browser-visible decoration view. */
3024
+ function decorationView(entry) {
3025
+ return {
3026
+ apiVersion: PET_DECORATION_API_VERSION,
3027
+ id: entry.id,
3028
+ assetBase: entry.assetBase,
3029
+ entryUrl: entry.entryUrl,
3030
+ cell: entry.cell,
3031
+ columns: entry.columns,
3032
+ durations: entry.durations,
3033
+ loop: entry.loop,
3034
+ phases: entry.phases
3035
+ };
3036
+ }
3037
+ /**
3038
+ * Strip host-only fields, leaving the client-visible definition. When the
3039
+ * registry carries a global voice pack, its panel chrome layers under the
3040
+ * entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
3041
+ * layering (pet-center M4, issue #677).
3042
+ */
3043
+ function petEntryView(entry, globalVoice) {
3044
+ const panel = globalVoice === void 0 ? entry.voice?.panel : mergeVoicePacks(globalVoice, entry.voice)?.panel;
1757
3045
  return {
1758
3046
  id: entry.id,
1759
3047
  displayName: entry.displayName,
1760
3048
  description: entry.description,
3049
+ renderer: entry.renderer,
3050
+ ...entry.live2d === void 0 ? {} : { live2d: entry.live2d },
1761
3051
  cell: entry.cell,
1762
3052
  columns: entry.columns,
1763
3053
  rows: entry.rows,
@@ -1765,7 +3055,8 @@ function petEntryView(entry) {
1765
3055
  tracks: entry.tracks,
1766
3056
  ...entry.sequences === void 0 ? {} : { sequences: entry.sequences },
1767
3057
  atlasUrl: entry.atlasUrl,
1768
- manifestUrl: entry.manifestUrl
3058
+ manifestUrl: entry.manifestUrl,
3059
+ ...panel === void 0 ? {} : { panel }
1769
3060
  };
1770
3061
  }
1771
3062
  /** Hard cap on simultaneously displayed session bubbles (most recent first). */
@@ -1784,10 +3075,18 @@ var PetService = class extends Service {
1784
3075
  registry;
1785
3076
  persistDir;
1786
3077
  enabled;
3078
+ /** Status-decoration master switch (M5, #567); mirrored from settings. */
3079
+ decorationEnabled;
1787
3080
  disposeActivity;
1788
3081
  /** Session whose most recent meaningful event currently drives the global pet. */
1789
3082
  displaySession;
1790
3083
  /**
3084
+ * Effective voice-pack overrides for the currently selected pet (M4,
3085
+ * #677). Cached per pet id; the registry is an immutable snapshot, so the
3086
+ * global pack and each entry's pack cannot change behind the cache.
3087
+ */
3088
+ voiceCache;
3089
+ /**
1791
3090
  * Per-session activity, most recent last (Map insertion order). Bounded by
1792
3091
  * MAX_SESSION_BUBBLES so a burst of sessions cannot grow it without bound;
1793
3092
  * disposed sessions are removed by the 'session/disposed' listener.
@@ -1819,8 +3118,26 @@ var PetService = class extends Service {
1819
3118
  };
1820
3119
  this.machine = new PetStateMachine(this.stateConfig);
1821
3120
  this.enabled = config.enabled ?? true;
3121
+ this.decorationEnabled = config.decorationEnabled ?? true;
1822
3122
  this.syncActivity();
1823
3123
  }
3124
+ /**
3125
+ * The draw-time voice-pool provider handed to every projection runtime.
3126
+ * It re-resolves when the selected pet changes, so live engines re-voice
3127
+ * on the next draw without being rebuilt (M4, #677).
3128
+ */
3129
+ voicePools() {
3130
+ return () => {
3131
+ const entry = this.activeEntry();
3132
+ if (this.voiceCache !== void 0 && this.voiceCache.petId === entry.id) return this.voiceCache.overrides;
3133
+ const overrides = mergeVoicePacks(this.registry.globalVoice, entry.voice)?.overrides ?? {};
3134
+ this.voiceCache = {
3135
+ petId: entry.id,
3136
+ overrides
3137
+ };
3138
+ return overrides;
3139
+ };
3140
+ }
1824
3141
  /** Whether the pet service consumes session activity while enabled. */
1825
3142
  isEnabled() {
1826
3143
  return this.enabled;
@@ -1835,12 +3152,25 @@ var PetService = class extends Service {
1835
3152
  }
1836
3153
  /** RPC: the registry entries the browser half renders and selects from. */
1837
3154
  async pets() {
1838
- return this.registry.entries.map(petEntryView);
3155
+ return this.registry.entries.map((entry) => petEntryView(entry, this.registry.globalVoice));
1839
3156
  }
1840
3157
  /** The loaded registry (the asset routes serve its entries). */
1841
3158
  registrySnapshot() {
1842
3159
  return this.registry;
1843
3160
  }
3161
+ /** RPC: structured registry diagnostics (pet-center M2, issue #623). */
3162
+ async diagnostics() {
3163
+ return { diagnostics: this.registry.diagnostics };
3164
+ }
3165
+ /**
3166
+ * The active status decoration view (M5, #567): the default 'whale' entry
3167
+ * (user directories override built-ins by id), gated by the master switch.
3168
+ */
3169
+ activeDecoration() {
3170
+ if (!this.decorationEnabled) return void 0;
3171
+ const entry = this.registry.decorationById?.(DEFAULT_DECORATION_ID);
3172
+ return entry === void 0 ? void 0 : decorationView(entry);
3173
+ }
1844
3174
  /** The selected pet's registry entry. */
1845
3175
  activeEntry() {
1846
3176
  return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
@@ -1924,7 +3254,7 @@ var PetService = class extends Service {
1924
3254
  let activity = this.sessionActivity.get(session);
1925
3255
  if (activity === void 0) {
1926
3256
  activity = {
1927
- runtime: emptyProjectionRuntime(),
3257
+ runtime: emptyProjectionRuntime(this.voicePools()),
1928
3258
  machine: new PetStateMachine(this.stateConfig)
1929
3259
  };
1930
3260
  this.sessionActivity.set(session, activity);
@@ -2018,6 +3348,7 @@ var PetService = class extends Service {
2018
3348
  * @param section - the resolved settings section.
2019
3349
  */
2020
3350
  applySettingsSection(section) {
3351
+ this.decorationEnabled = section.decorationEnabled ?? true;
2021
3352
  const selected = typeof section.petId === "string" ? this.registry.byId(section.petId) : void 0;
2022
3353
  if (selected !== void 0) {
2023
3354
  this.ledger.setPetId(selected.id);
@@ -2070,6 +3401,7 @@ var PetService = class extends Service {
2070
3401
  }
2071
3402
  const whisper = (this.displaySession === void 0 ? void 0 : this.sessionActivity.get(this.displaySession))?.whisper;
2072
3403
  const freshWhisper = whisper !== void 0 && Date.now() - whisper.at < 8e3 ? whisper.text : void 0;
3404
+ const decoration = this.activeDecoration();
2073
3405
  return {
2074
3406
  animation: snapshot.animation,
2075
3407
  ...snapshot.bubble === void 0 ? {} : { bubble: snapshot.bubble },
@@ -2077,6 +3409,7 @@ var PetService = class extends Service {
2077
3409
  sessionActive: snapshot.sessionActive,
2078
3410
  sessions,
2079
3411
  ...freshWhisper === void 0 ? {} : { whisper: freshWhisper },
3412
+ ...decoration === void 0 ? {} : { decoration },
2080
3413
  affinity: this.ledger.affinityView(Date.now()),
2081
3414
  display: { ...this.ledger.snapshot.display },
2082
3415
  pet: {
@@ -2161,6 +3494,46 @@ const PET_ASSET_PREFIX = "/pet";
2161
3494
  const MANIFEST_FILE = "pet.json";
2162
3495
  const PREVIEW_DIR = "previews";
2163
3496
  const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/;
3497
+ /**
3498
+ * Per-class size ceilings for served pet assets, in bytes (pet-center M2 P3,
3499
+ * issue #623). Constants are tested directly; makePetRoutes accepts an
3500
+ * override so tests can exercise the 413 path with tiny caps.
3501
+ */
3502
+ const PET_ASSET_CAPS = {
3503
+ /** pet.json manifest. */
3504
+ manifest: 64 * 1024,
3505
+ /** Atlas, preview and Live2D texture imagery. */
3506
+ image: 20 * 1024 * 1024,
3507
+ /** Live2D model closure files (.moc3, motion/physics/expression JSON; M3). */
3508
+ model: 32 * 1024 * 1024
3509
+ };
3510
+ /** Imagery extensions classify into the image cap; everything else served from a closure is model-class. */
3511
+ const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
3512
+ ".webp",
3513
+ ".png",
3514
+ ".gif",
3515
+ ".jpg",
3516
+ ".jpeg"
3517
+ ]);
3518
+ /** Lowercased file extension ('' when none). */
3519
+ function extensionOf(file) {
3520
+ const dot = file.lastIndexOf(".");
3521
+ return dot < 0 ? "" : file.slice(dot).toLowerCase();
3522
+ }
3523
+ /**
3524
+ * realpath containment: resolve both sides and require the candidate to stay
3525
+ * inside the base directory. A pet directory (or an atlas/preview inside it)
3526
+ * that is a symlink escaping its root is rejected, never followed.
3527
+ */
3528
+ function containedRealpath(base, candidate) {
3529
+ try {
3530
+ const realBase = realpathSync(base);
3531
+ const realCandidate = realpathSync(candidate);
3532
+ return realCandidate === realBase || realCandidate.startsWith(realBase + sep) ? realCandidate : void 0;
3533
+ } catch {
3534
+ return;
3535
+ }
3536
+ }
2164
3537
  const MIME_BY_EXT = {
2165
3538
  ".webp": "image/webp",
2166
3539
  ".png": "image/png",
@@ -2279,10 +3652,14 @@ function dirAliases(registry) {
2279
3652
  /**
2280
3653
  * The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
2281
3654
  * legacy directory alias), then serves exactly the files a manifest declares:
2282
- * pet.json, the declared spritesheet path, and optional 'previews/<name>'
2283
- * media. Composed pets without a manifest file get a synthesized pet.json.
3655
+ * pet.json, the entry's servable set (the sprite2d atlas, or the live2d
3656
+ * model3.json plus its reference closure pet-center M3), and optional
3657
+ * 'previews/<name>' media. The servable match is an exact string comparison
3658
+ * against scan-time normalized paths, so crafted '..' or '.' segments never
3659
+ * match; containedRealpath stays as the second layer. Composed pets without
3660
+ * a manifest file get a synthesized pet.json.
2284
3661
  */
2285
- function assetHandler(registry) {
3662
+ function assetHandler(registry, caps) {
2286
3663
  const aliases = dirAliases(registry);
2287
3664
  return (req, res) => {
2288
3665
  if (!guard(req, res)) return;
@@ -2338,13 +3715,13 @@ function assetHandler(registry) {
2338
3715
  const manifestFile = join(entry.dir, MANIFEST_FILE);
2339
3716
  file = existsSync(manifestFile) ? manifestFile : void 0;
2340
3717
  if (file === void 0) synthesized = true;
2341
- } else if (rest.length > 0 && rel === entry.spritesheetPath) file = join(entry.dir, entry.spritesheetPath);
3718
+ } else if (rest.length > 0 && entry.servable.includes(rel)) file = join(entry.dir, rel);
2342
3719
  else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1])) {
2343
3720
  const preview = join(entry.dir, PREVIEW_DIR, rest[1]);
2344
3721
  file = existsSync(preview) ? preview : void 0;
2345
3722
  }
2346
3723
  if (synthesized) {
2347
- const body = Buffer.from(JSON.stringify(petEntryView(entry), null, 2), "utf8");
3724
+ const body = Buffer.from(JSON.stringify(petEntryView(entry, registry.globalVoice), null, 2), "utf8");
2348
3725
  res.writeHead(200, {
2349
3726
  "content-type": "application/json; charset=utf-8",
2350
3727
  "content-length": String(body.byteLength),
@@ -2362,7 +3739,24 @@ function assetHandler(registry) {
2362
3739
  res.end();
2363
3740
  return;
2364
3741
  }
2365
- const resolved = file;
3742
+ const resolved = containedRealpath(entry.dir, file);
3743
+ if (resolved === void 0) {
3744
+ res.writeHead(403);
3745
+ res.end();
3746
+ return;
3747
+ }
3748
+ const cap = rest.length === 1 && rest[0] === MANIFEST_FILE ? caps.manifest : IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model;
3749
+ try {
3750
+ if (statSync(resolved).size > cap) {
3751
+ res.writeHead(413);
3752
+ res.end();
3753
+ return;
3754
+ }
3755
+ } catch {
3756
+ res.writeHead(404);
3757
+ res.end();
3758
+ return;
3759
+ }
2366
3760
  readFile(resolved).then((body) => {
2367
3761
  res.writeHead(200, {
2368
3762
  "content-type": mimeFor(resolved),
@@ -2380,12 +3774,223 @@ function assetHandler(registry) {
2380
3774
  });
2381
3775
  };
2382
3776
  }
2383
- /** Build the full route family (API + assets) for one service. */
3777
+ /** Browser-facing base path of the plugin runtime files (pet-center M3). */
3778
+ const PET_RUNTIME_PREFIX = "/api/pet/runtime";
3779
+ /**
3780
+ * The runtime files the route may serve, by exact name (no slashes, no
3781
+ * user-controlled path segments, so traversal is structurally impossible):
3782
+ * the user-supplied Cubism Core from the pet runtime directory (the plugin
3783
+ * never bundles or downloads it — issue #623 M1 §0) and the plugin-shipped
3784
+ * MIT vendor bundle from the package lib directory.
3785
+ */
3786
+ const RUNTIME_FILES = {
3787
+ "live2dcubismcore.min.js": { root: "runtimeDir" },
3788
+ "live2d-vendor.js": { root: "vendorDir" },
3789
+ "live2d-vendor.js.map": { root: "vendorDir" }
3790
+ };
3791
+ /**
3792
+ * The runtime handler behind '/api/pet/runtime/<name>'. A missing file
3793
+ * answers 404 with a JSON marker the client renderer turns into install
3794
+ * guidance (the Cubism Core is user-supplied, so its absence is a normal
3795
+ * state, not an error).
3796
+ */
3797
+ function runtimeHandler(roots) {
3798
+ return (req, res) => {
3799
+ if (!guard(req, res)) return;
3800
+ if (req.method !== "GET" && req.method !== "HEAD") {
3801
+ res.writeHead(405);
3802
+ res.end();
3803
+ return;
3804
+ }
3805
+ let pathname;
3806
+ try {
3807
+ pathname = new URL(req.url ?? "/", "http://pet.local").pathname;
3808
+ } catch {
3809
+ res.writeHead(400);
3810
+ res.end();
3811
+ return;
3812
+ }
3813
+ const rest = pathname.slice(16).replace(/^\/+/, "");
3814
+ let name;
3815
+ try {
3816
+ name = decodeURIComponent(rest);
3817
+ } catch {
3818
+ res.writeHead(400);
3819
+ res.end();
3820
+ return;
3821
+ }
3822
+ const spec = RUNTIME_FILES[name];
3823
+ if (spec === void 0) {
3824
+ res.writeHead(404);
3825
+ res.end();
3826
+ return;
3827
+ }
3828
+ const base = spec.root === "runtimeDir" ? roots.runtimeDir : roots.vendorDir;
3829
+ const file = join(base, name);
3830
+ if (!existsSync(file)) {
3831
+ json(res, 404, {
3832
+ ok: false,
3833
+ error: "runtime-file-missing",
3834
+ file: name
3835
+ });
3836
+ return;
3837
+ }
3838
+ const resolved = containedRealpath(base, file);
3839
+ if (resolved === void 0) {
3840
+ res.writeHead(403);
3841
+ res.end();
3842
+ return;
3843
+ }
3844
+ try {
3845
+ if (statSync(resolved).size > 16777216) {
3846
+ res.writeHead(413);
3847
+ res.end();
3848
+ return;
3849
+ }
3850
+ } catch {
3851
+ res.writeHead(404);
3852
+ res.end();
3853
+ return;
3854
+ }
3855
+ readFile(resolved).then((body) => {
3856
+ res.writeHead(200, {
3857
+ "content-type": name.endsWith(".map") ? "application/json" : "application/javascript; charset=utf-8",
3858
+ "content-length": String(body.byteLength),
3859
+ "cache-control": "no-cache"
3860
+ });
3861
+ if (req.method === "HEAD") {
3862
+ res.end();
3863
+ return;
3864
+ }
3865
+ res.end(body);
3866
+ }, () => {
3867
+ res.writeHead(404);
3868
+ res.end();
3869
+ });
3870
+ };
3871
+ }
3872
+ /**
3873
+ * The decoration asset handler behind '/api/pet/decoration/<id>/<file>'
3874
+ * (pet-center M5, #567). Serves exactly the files a decoration descriptor
3875
+ * declares — decoration.json and the PNG/WebP strip — by exact allow-list
3876
+ * match, with realpath containment and the same size ceilings as pet
3877
+ * assets. Crafted '..' or '.' segments never match the normalized closure.
3878
+ */
3879
+ function decorationHandler(registry, caps) {
3880
+ return (req, res) => {
3881
+ if (!guard(req, res)) return;
3882
+ if (req.method !== "GET" && req.method !== "HEAD") {
3883
+ res.writeHead(405);
3884
+ res.end();
3885
+ return;
3886
+ }
3887
+ let pathname;
3888
+ try {
3889
+ pathname = new URL(req.url ?? "/", "http://pet.local").pathname;
3890
+ } catch {
3891
+ res.writeHead(400);
3892
+ res.end();
3893
+ return;
3894
+ }
3895
+ const segments = pathname.split("/").filter((segment) => segment !== "");
3896
+ const prefixSegments = DECORATION_ASSET_PREFIX.split("/").filter((segment) => segment !== "");
3897
+ if (segments.length < prefixSegments.length + 2) {
3898
+ res.writeHead(404);
3899
+ res.end();
3900
+ return;
3901
+ }
3902
+ for (let i = 0; i < prefixSegments.length; i += 1) if (segments[i] !== prefixSegments[i]) {
3903
+ res.writeHead(404);
3904
+ res.end();
3905
+ return;
3906
+ }
3907
+ let id;
3908
+ try {
3909
+ id = decodeURIComponent(segments[prefixSegments.length]);
3910
+ } catch {
3911
+ res.writeHead(400);
3912
+ res.end();
3913
+ return;
3914
+ }
3915
+ const entry = registry.decorationById?.(id);
3916
+ if (entry === void 0) {
3917
+ res.writeHead(404);
3918
+ res.end();
3919
+ return;
3920
+ }
3921
+ const rest = [];
3922
+ for (const segment of segments.slice(prefixSegments.length + 1)) {
3923
+ let decoded;
3924
+ try {
3925
+ decoded = decodeURIComponent(segment);
3926
+ } catch {
3927
+ res.writeHead(400);
3928
+ res.end();
3929
+ return;
3930
+ }
3931
+ rest.push(decoded);
3932
+ }
3933
+ const rel = rest.join("/");
3934
+ if (!entry.servable.includes(rel)) {
3935
+ res.writeHead(404);
3936
+ res.end();
3937
+ return;
3938
+ }
3939
+ const file = join(entry.dir, rel);
3940
+ const resolved = containedRealpath(entry.dir, file);
3941
+ if (resolved === void 0) {
3942
+ res.writeHead(403);
3943
+ res.end();
3944
+ return;
3945
+ }
3946
+ const cap = rel === "decoration.json" ? caps.manifest : caps.image;
3947
+ let stat;
3948
+ try {
3949
+ stat = statSync(resolved);
3950
+ if (stat.size > cap) {
3951
+ res.writeHead(413);
3952
+ res.end();
3953
+ return;
3954
+ }
3955
+ } catch {
3956
+ res.writeHead(404);
3957
+ res.end();
3958
+ return;
3959
+ }
3960
+ const etag = "\"" + stat.size.toString(16) + "-" + Math.round(stat.mtimeMs).toString(16) + "\"";
3961
+ if (req.headers["if-none-match"] === etag) {
3962
+ res.writeHead(304, {
3963
+ etag,
3964
+ "cache-control": "no-cache"
3965
+ });
3966
+ res.end();
3967
+ return;
3968
+ }
3969
+ readFile(resolved).then((body) => {
3970
+ res.writeHead(200, {
3971
+ "content-type": mimeFor(resolved),
3972
+ "content-length": String(body.byteLength),
3973
+ "cache-control": "no-cache",
3974
+ etag
3975
+ });
3976
+ if (req.method === "HEAD") {
3977
+ res.end();
3978
+ return;
3979
+ }
3980
+ res.end(body);
3981
+ }, () => {
3982
+ res.writeHead(404);
3983
+ res.end();
3984
+ });
3985
+ };
3986
+ }
3987
+ /** Build the full route family (API + assets + runtime) for one service. */
2384
3988
  function makePetRoutes(deps) {
2385
3989
  const { service } = deps;
2386
3990
  const apiRoutes = [
2387
3991
  getRoute("/api/pet/state", () => service.state()),
2388
3992
  getRoute("/api/pet/pets", () => service.pets()),
3993
+ getRoute("/api/pet/diagnostics", () => service.diagnostics()),
2389
3994
  postRoute("/api/pet/interact", (body) => {
2390
3995
  const kind = body.kind;
2391
3996
  if (kind !== "pet" && kind !== "feed") return Promise.reject(/* @__PURE__ */ new Error("invalid-kind"));
@@ -2416,9 +4021,27 @@ function makePetRoutes(deps) {
2416
4021
  const assetRoute = {
2417
4022
  kind: "prefix",
2418
4023
  path: PET_ASSET_PREFIX,
2419
- handler: assetHandler(service.registrySnapshot())
4024
+ handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
4025
+ };
4026
+ const runtimeRoute = {
4027
+ kind: "prefix",
4028
+ path: PET_RUNTIME_PREFIX,
4029
+ handler: runtimeHandler({
4030
+ runtimeDir: deps.runtimeDir ?? join(dshHome(), "pets", ".runtime"),
4031
+ vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), "lib")
4032
+ })
2420
4033
  };
2421
- return [...apiRoutes, assetRoute];
4034
+ const decorationRoute = {
4035
+ kind: "prefix",
4036
+ path: DECORATION_ASSET_PREFIX,
4037
+ handler: decorationHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
4038
+ };
4039
+ return [
4040
+ ...apiRoutes,
4041
+ assetRoute,
4042
+ runtimeRoute,
4043
+ decorationRoute
4044
+ ];
2422
4045
  }
2423
4046
  //#endregion
2424
4047
  //#region src/mount-once.ts
@@ -2482,7 +4105,8 @@ function makePetSettingsSchema(fallbackPetId) {
2482
4105
  right: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(24),
2483
4106
  bottom: z.number().step(1).min(0).max(DISPLAY_INSET_MAX).default(20),
2484
4107
  petId: z.string().default(fallbackPetId),
2485
- enabled: z.boolean().default(true)
4108
+ enabled: z.boolean().default(true),
4109
+ decorationEnabled: z.boolean().default(true)
2486
4110
  });
2487
4111
  }
2488
4112
  /** Register the pet service and its API + asset routes on the context. */
@@ -2503,7 +4127,8 @@ function applyImpl(ctx, config = {}) {
2503
4127
  right: service.display().right,
2504
4128
  bottom: service.display().bottom,
2505
4129
  petId: service.selectedPetId(),
2506
- enabled: config.enabled ?? true
4130
+ enabled: config.enabled ?? true,
4131
+ decorationEnabled: config.decorationEnabled ?? true
2507
4132
  };
2508
4133
  const routes = makePetRoutes({ service });
2509
4134
  let disposeRoutes;