@linxin666/dsh-pet 0.2.2 → 0.2.3

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 (90) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +66 -3
  3. package/README.zh.md +66 -3
  4. package/assets/whale/pet.json +169 -20
  5. package/assets/whale-refined/pet.json +17 -2
  6. package/lib/client.js +438 -21
  7. package/lib/client.js.map +1 -1
  8. package/lib/index.js +752 -36
  9. package/lib/live2d-vendor.js +995 -0
  10. package/lib/live2d-vendor.js.map +1 -0
  11. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  12. package/lib/types/client/PetDockEntry.js +2 -1
  13. package/lib/types/client/PetSettingsCard.d.ts +10 -0
  14. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  15. package/lib/types/client/PetSettingsCard.js +22 -1
  16. package/lib/types/client/PetSprite.d.ts +7 -1
  17. package/lib/types/client/PetSprite.d.ts.map +1 -1
  18. package/lib/types/client/PetSprite.js +17 -8
  19. package/lib/types/client/index.d.ts.map +1 -1
  20. package/lib/types/client/index.js +5 -0
  21. package/lib/types/client/locales.d.ts +10 -0
  22. package/lib/types/client/locales.d.ts.map +1 -1
  23. package/lib/types/client/locales.js +10 -0
  24. package/lib/types/client/phase-stream.d.ts +22 -0
  25. package/lib/types/client/phase-stream.d.ts.map +1 -0
  26. package/lib/types/client/phase-stream.js +28 -0
  27. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  28. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  29. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  30. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  31. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  32. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  33. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  34. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  35. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  36. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  37. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  38. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  39. package/lib/types/client/renderers/live2d.d.ts +52 -0
  40. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  41. package/lib/types/client/renderers/live2d.js +180 -0
  42. package/lib/types/client/renderers/registry.d.ts +32 -0
  43. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  44. package/lib/types/client/renderers/registry.js +49 -0
  45. package/lib/types/contracts/renderer.d.ts +50 -0
  46. package/lib/types/contracts/renderer.d.ts.map +1 -0
  47. package/lib/types/contracts/renderer.js +15 -0
  48. package/lib/types/manifest-v2.d.ts +129 -0
  49. package/lib/types/manifest-v2.d.ts.map +1 -0
  50. package/lib/types/manifest-v2.js +387 -0
  51. package/lib/types/model3.d.ts +26 -0
  52. package/lib/types/model3.d.ts.map +1 -0
  53. package/lib/types/model3.js +96 -0
  54. package/lib/types/registry.d.ts +57 -3
  55. package/lib/types/registry.d.ts.map +1 -1
  56. package/lib/types/registry.js +208 -33
  57. package/lib/types/routes.d.ts +39 -2
  58. package/lib/types/routes.d.ts.map +1 -1
  59. package/lib/types/routes.js +183 -12
  60. package/lib/types/service.d.ts +5 -1
  61. package/lib/types/service.d.ts.map +1 -1
  62. package/lib/types/service.js +4 -0
  63. package/package.json +3 -1
  64. package/src/client/PetDockEntry.tsx +19 -11
  65. package/src/client/PetSettingsCard.tsx +39 -0
  66. package/src/client/PetSprite.test.tsx +11 -0
  67. package/src/client/PetSprite.tsx +24 -9
  68. package/src/client/index.ts +6 -0
  69. package/src/client/locales.ts +10 -0
  70. package/src/client/phase-stream.test.ts +76 -0
  71. package/src/client/phase-stream.ts +39 -0
  72. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  73. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  74. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  75. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  76. package/src/client/renderers/live2d/runtime.ts +119 -0
  77. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  78. package/src/client/renderers/live2d.test.ts +242 -0
  79. package/src/client/renderers/live2d.ts +218 -0
  80. package/src/client/renderers/registry.ts +58 -0
  81. package/src/client/settings-section.module.css +23 -0
  82. package/src/contracts/renderer.ts +54 -0
  83. package/src/manifest-v2.test.ts +251 -0
  84. package/src/manifest-v2.ts +414 -0
  85. package/src/model3.test.ts +79 -0
  86. package/src/model3.ts +91 -0
  87. package/src/registry.test.ts +215 -2
  88. package/src/registry.ts +246 -34
  89. package/src/routes.ts +202 -13
  90. package/src/service.ts +6 -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";
@@ -1373,19 +1373,409 @@ function savePetPersist(data, dir = petHomeDir()) {
1373
1373
  writeFileSync(tmp, JSON.stringify(data, null, 2), "utf8");
1374
1374
  renameSync(tmp, target);
1375
1375
  }
1376
+ /** Renderer kinds the pet center knows how to dispatch (M1 §2). */
1377
+ const PET_RENDERER_KINDS = ["sprite2d", "live2d"];
1378
+ /** The seven ActivityPhase semantics (pet-center owned; M1 §1). */
1379
+ const PET_ACTIVITY_PHASES = [
1380
+ "idle",
1381
+ "waiting",
1382
+ "thinking",
1383
+ "tool",
1384
+ "review",
1385
+ "done",
1386
+ "failed"
1387
+ ];
1388
+ const PET_ID_PATTERN$1 = /^[a-z0-9][a-z0-9-]*$/;
1389
+ const PATH_SEGMENT_PATTERN$1 = /^[A-Za-z0-9._-]+$/;
1390
+ const SEMVER_PATTERN = /^\d+\.\d+\.\d+$/;
1391
+ /**
1392
+ * Field allow-lists mirroring contracts/pet-manifest-v2.schema.json. Exported
1393
+ * so the drift test can lock the schema file and this validator together;
1394
+ * the CLI reuses parsePetManifest instead of these.
1395
+ */
1396
+ const KNOWN_TOP_LEVEL = /* @__PURE__ */ new Set([
1397
+ "$schema",
1398
+ "petManifestVersion",
1399
+ "id",
1400
+ "displayName",
1401
+ "description",
1402
+ "version",
1403
+ "author",
1404
+ "license",
1405
+ "homepage",
1406
+ "renderer",
1407
+ "sprite2d",
1408
+ "live2d",
1409
+ "sequences",
1410
+ "remarks"
1411
+ ]);
1412
+ /** sprite2d block field allow-list (drift-locked to the schema file). */
1413
+ const KNOWN_SPRITE2D = /* @__PURE__ */ new Set([
1414
+ "spritesheetPath",
1415
+ "cell",
1416
+ "columns",
1417
+ "atlasRows",
1418
+ "frames",
1419
+ "tracks"
1420
+ ]);
1421
+ /** live2d block field allow-list (drift-locked to the schema file). */
1422
+ const KNOWN_LIVE2D = /* @__PURE__ */ new Set([
1423
+ "model",
1424
+ "scale",
1425
+ "translate",
1426
+ "motions",
1427
+ "expressions",
1428
+ "hitAreas",
1429
+ "lipSync"
1430
+ ]);
1431
+ var Diagnostics = class {
1432
+ list = [];
1433
+ source;
1434
+ constructor(source) {
1435
+ this.source = source;
1436
+ }
1437
+ error(message) {
1438
+ this.list.push({
1439
+ level: "error",
1440
+ message: this.source + ": " + message
1441
+ });
1442
+ }
1443
+ warn(message) {
1444
+ this.list.push({
1445
+ level: "warning",
1446
+ message: this.source + ": " + message
1447
+ });
1448
+ }
1449
+ get hasErrors() {
1450
+ return this.list.some((d) => d.level === "error");
1451
+ }
1452
+ };
1453
+ function isRecord(value) {
1454
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1455
+ }
1456
+ function unknownKeys(source, known) {
1457
+ return Object.keys(source).filter((key) => !known.has(key));
1458
+ }
1459
+ /**
1460
+ * Validate a manifest-relative asset path: no absolute paths, no backslashes,
1461
+ * no traversal, plain safe segments only. Returns the normalized path.
1462
+ */
1463
+ function safeManifestPath(raw) {
1464
+ if (typeof raw !== "string" || raw.trim() === "") return void 0;
1465
+ const value = raw.trim();
1466
+ if (isAbsolute(value) || value.includes("\\") || /^[a-z][a-z0-9+.-]*:/i.test(value)) return void 0;
1467
+ const segments = value.split("/").filter((segment) => segment !== "");
1468
+ if (segments.length === 0) return void 0;
1469
+ if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_PATTERN$1.test(segment))) return void 0;
1470
+ return segments.join("/");
1471
+ }
1472
+ function parseStringBlock(record, key, diag, required) {
1473
+ const value = record[key];
1474
+ if (value === void 0) {
1475
+ if (required) diag.error("missing required field " + JSON.stringify(key));
1476
+ return;
1477
+ }
1478
+ if (typeof value !== "string" || value.trim() === "") {
1479
+ diag.error("field " + JSON.stringify(key) + " must be a non-empty string");
1480
+ return;
1481
+ }
1482
+ return value.trim();
1483
+ }
1484
+ /** Validate the phase-keyed string map shape shared by motions/expressions. */
1485
+ function parsePhaseStringMap(raw, field, diag) {
1486
+ if (raw === void 0) return void 0;
1487
+ if (!isRecord(raw)) {
1488
+ diag.error("field " + JSON.stringify(field) + " must be an object keyed by activity phase");
1489
+ return;
1490
+ }
1491
+ const result = {};
1492
+ for (const [phase, value] of Object.entries(raw)) {
1493
+ if (!PET_ACTIVITY_PHASES.includes(phase)) {
1494
+ diag.error(field + ": unknown activity phase " + JSON.stringify(phase));
1495
+ continue;
1496
+ }
1497
+ if (typeof value !== "string" || value.trim() === "") {
1498
+ diag.error(field + "." + phase + " must be a non-empty string");
1499
+ continue;
1500
+ }
1501
+ result[phase] = value.trim();
1502
+ }
1503
+ return result;
1504
+ }
1505
+ /** Structural gate for sequences: content stays warn-and-drop (registry's job). */
1506
+ function parseSequences(raw, diag) {
1507
+ if (raw === void 0) return void 0;
1508
+ if (!isRecord(raw)) {
1509
+ diag.warn("sequences must be an object keyed by activity phase; ignoring");
1510
+ return;
1511
+ }
1512
+ const sequences = {};
1513
+ for (const [phase, value] of Object.entries(raw)) {
1514
+ if (!PET_ACTIVITY_PHASES.includes(phase)) {
1515
+ diag.warn("sequences: unknown activity phase " + JSON.stringify(phase) + "; entry dropped");
1516
+ continue;
1517
+ }
1518
+ if (!Array.isArray(value) || value.length < 5 || value.some((item) => typeof item !== "string")) {
1519
+ diag.warn("sequences." + phase + " must be an array of at least 5 animation names; entry dropped");
1520
+ continue;
1521
+ }
1522
+ sequences[phase] = value;
1523
+ }
1524
+ return Object.keys(sequences).length === 0 ? void 0 : sequences;
1525
+ }
1526
+ function parseSprite2dBlock(raw, diag) {
1527
+ if (!isRecord(raw)) {
1528
+ diag.error("renderer sprite2d requires a \"sprite2d\" block object");
1529
+ return;
1530
+ }
1531
+ const extra = unknownKeys(raw, KNOWN_SPRITE2D);
1532
+ if (extra.length > 0) diag.error("sprite2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1533
+ const spritesheetPath = safeManifestPath(raw.spritesheetPath);
1534
+ if (spritesheetPath === void 0) diag.error("sprite2d.spritesheetPath must be a safe manifest-relative path");
1535
+ const block = { spritesheetPath: spritesheetPath ?? "" };
1536
+ if (raw.cell !== void 0) if (!isRecord(raw.cell)) diag.error("sprite2d.cell must be an object { width?, height? }");
1537
+ else block.cell = raw.cell;
1538
+ 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");
1539
+ else block.columns = raw.columns;
1540
+ 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");
1541
+ else block.atlasRows = raw.atlasRows;
1542
+ 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");
1543
+ else block.frames = raw.frames;
1544
+ if (raw.tracks !== void 0) if (!isRecord(raw.tracks)) diag.error("sprite2d.tracks must be an object keyed by animation");
1545
+ else block.tracks = raw.tracks;
1546
+ return diag.hasErrors ? void 0 : block;
1547
+ }
1548
+ function parseLive2dBlock(raw, diag) {
1549
+ if (!isRecord(raw)) {
1550
+ diag.error("renderer live2d requires a \"live2d\" block object");
1551
+ return;
1552
+ }
1553
+ const extra = unknownKeys(raw, KNOWN_LIVE2D);
1554
+ if (extra.length > 0) diag.error("live2d: unknown field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1555
+ const model = safeManifestPath(raw.model);
1556
+ if (model === void 0) diag.error("live2d.model must be a safe manifest-relative path to a .model3.json");
1557
+ else if (!model.endsWith(".model3.json")) diag.error("live2d.model must point at a .model3.json file");
1558
+ const motions = parsePhaseStringMap(raw.motions, "live2d.motions", diag);
1559
+ if (raw.motions === void 0) diag.error("live2d.motions is required (at least an \"idle\" group)");
1560
+ else if (motions !== void 0 && motions.idle === void 0) diag.error("live2d.motions.idle is required (unmapped phases fall back to it)");
1561
+ const block = {
1562
+ model: model ?? "",
1563
+ motions: motions ?? { idle: "" }
1564
+ };
1565
+ 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]");
1566
+ else block.scale = raw.scale;
1567
+ if (raw.translate !== void 0) if (!isRecord(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 }");
1568
+ else block.translate = raw.translate;
1569
+ const expressions = parsePhaseStringMap(raw.expressions, "live2d.expressions", diag);
1570
+ if (expressions !== void 0) block.expressions = expressions;
1571
+ 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");
1572
+ else block.hitAreas = raw.hitAreas;
1573
+ if (raw.lipSync !== void 0) if (typeof raw.lipSync !== "boolean") diag.error("live2d.lipSync must be a boolean");
1574
+ else block.lipSync = raw.lipSync;
1575
+ return diag.hasErrors ? void 0 : block;
1576
+ }
1577
+ /** v1 compat read: map the legacy flat manifest onto the v2 sprite2d shape. */
1578
+ function compatV1(source, diag) {
1579
+ const id = parseStringBlock(source, "id", diag, true);
1580
+ if (id !== void 0 && !PET_ID_PATTERN$1.test(id)) diag.error("id " + JSON.stringify(id) + " is not a lowercase kebab id");
1581
+ const displayName = typeof source.displayName === "string" && source.displayName.trim() !== "" ? source.displayName.trim() : id;
1582
+ const spritesheetPath = safeManifestPath(source.spritesheetPath === void 0 ? "spritesheet.webp" : source.spritesheetPath);
1583
+ if (spritesheetPath === void 0) diag.error("spritesheetPath " + JSON.stringify(String(source.spritesheetPath)) + " is not a safe relative path");
1584
+ if (source.license === void 0) diag.warn("v1 compat read: no license field; run scripts/dsh-pet-migrate-v2 to migrate this pet");
1585
+ const sprite2d = { spritesheetPath: spritesheetPath ?? "spritesheet.webp" };
1586
+ if (isRecord(source.cell)) sprite2d.cell = source.cell;
1587
+ if (typeof source.columns === "number") sprite2d.columns = source.columns;
1588
+ if (Array.isArray(source.frames)) sprite2d.frames = source.frames;
1589
+ if (isRecord(source.tracks)) sprite2d.tracks = source.tracks;
1590
+ if (source.spriteVersionNumber === 2) sprite2d.atlasRows = 11;
1591
+ const manifest = {
1592
+ petManifestVersion: 2,
1593
+ id: id ?? "",
1594
+ displayName: displayName ?? "",
1595
+ renderer: "sprite2d",
1596
+ sprite2d
1597
+ };
1598
+ if (typeof source.description === "string" && source.description.trim() !== "") manifest.description = source.description.trim();
1599
+ if (typeof source.license === "string" && source.license.trim() !== "") manifest.license = source.license.trim();
1600
+ const sequences = parseSequences(source.sequences, diag);
1601
+ if (sequences !== void 0) manifest.sequences = sequences;
1602
+ if (source.remarks !== void 0) manifest.remarks = source.remarks;
1603
+ return diag.hasErrors ? void 0 : manifest;
1604
+ }
1605
+ /** Strict v2 validation (fail-closed on structure). */
1606
+ function parseV2(source, diag) {
1607
+ const extra = unknownKeys(source, KNOWN_TOP_LEVEL);
1608
+ if (extra.length > 0) diag.error("unknown top-level field(s) " + extra.map((k) => JSON.stringify(k)).join(", "));
1609
+ if (source.petManifestVersion !== 2) diag.error("petManifestVersion must be 2 (got " + JSON.stringify(source.petManifestVersion) + ")");
1610
+ const id = parseStringBlock(source, "id", diag, true);
1611
+ if (id !== void 0 && (!PET_ID_PATTERN$1.test(id) || id.length > 64)) diag.error("id " + JSON.stringify(id) + " must be a lowercase kebab id of at most 64 chars");
1612
+ const displayName = parseStringBlock(source, "displayName", diag, true);
1613
+ const license = parseStringBlock(source, "license", diag, true);
1614
+ const rendererRaw = source.renderer === void 0 ? "sprite2d" : source.renderer;
1615
+ if (!PET_RENDERER_KINDS.includes(rendererRaw)) diag.error("unknown renderer " + JSON.stringify(rendererRaw) + "; expected one of " + PET_RENDERER_KINDS.join(", "));
1616
+ const renderer = rendererRaw;
1617
+ const manifest = {
1618
+ petManifestVersion: 2,
1619
+ id: id ?? "",
1620
+ displayName: displayName ?? "",
1621
+ renderer
1622
+ };
1623
+ if (license !== void 0) manifest.license = license;
1624
+ 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");
1625
+ else manifest.description = source.description;
1626
+ 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)");
1627
+ else manifest.version = source.version;
1628
+ 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");
1629
+ else manifest.author = source.author;
1630
+ if (source.homepage !== void 0) if (typeof source.homepage !== "string") diag.error("homepage must be a string URL");
1631
+ else manifest.homepage = source.homepage;
1632
+ if (renderer === "sprite2d") {
1633
+ const block = parseSprite2dBlock(source.sprite2d, diag);
1634
+ if (block !== void 0) manifest.sprite2d = block;
1635
+ if (source.live2d !== void 0) diag.error("renderer sprite2d must not declare a live2d block");
1636
+ } else if (renderer === "live2d") {
1637
+ const block = parseLive2dBlock(source.live2d, diag);
1638
+ if (block !== void 0) manifest.live2d = block;
1639
+ if (source.sprite2d !== void 0) diag.error("renderer live2d must not declare a sprite2d block");
1640
+ }
1641
+ const sequences = parseSequences(source.sequences, diag);
1642
+ if (sequences !== void 0) manifest.sequences = sequences;
1643
+ if (source.remarks !== void 0 && !isRecord(source.remarks)) diag.error("remarks must be an object of remark pools");
1644
+ else if (source.remarks !== void 0) manifest.remarks = source.remarks;
1645
+ return diag.hasErrors ? void 0 : manifest;
1646
+ }
1647
+ /**
1648
+ * Parse one pet manifest: v1 (no petManifestVersion) is compat-read as a
1649
+ * sprite2d pet with a migration hint; v2 is validated fail-closed. The parse
1650
+ * never throws — every failure comes back as structured diagnostics.
1651
+ * @param raw - the parsed pet.json value.
1652
+ * @param sourceLabel - human-readable origin for diagnostics (dir or file).
1653
+ */
1654
+ function parsePetManifest(raw, sourceLabel) {
1655
+ const diag = new Diagnostics(sourceLabel);
1656
+ if (!isRecord(raw)) {
1657
+ diag.error("manifest is not an object");
1658
+ return {
1659
+ ok: false,
1660
+ diagnostics: diag.list
1661
+ };
1662
+ }
1663
+ if (raw.petManifestVersion === void 0) {
1664
+ const manifest = compatV1(raw, diag);
1665
+ if (manifest === void 0) return {
1666
+ ok: false,
1667
+ diagnostics: diag.list
1668
+ };
1669
+ diag.warn("v1 compat read: manifest treated as renderer \"sprite2d\"; run scripts/dsh-pet-migrate-v2 to migrate");
1670
+ return {
1671
+ ok: true,
1672
+ manifest,
1673
+ migrated: "v1-compat",
1674
+ diagnostics: diag.list
1675
+ };
1676
+ }
1677
+ const manifest = parseV2(raw, diag);
1678
+ if (manifest === void 0) return {
1679
+ ok: false,
1680
+ diagnostics: diag.list
1681
+ };
1682
+ return {
1683
+ ok: true,
1684
+ manifest,
1685
+ migrated: void 0,
1686
+ diagnostics: diag.list
1687
+ };
1688
+ }
1689
+ //#endregion
1690
+ //#region src/model3.ts
1691
+ /**
1692
+ * Live2D .model3.json reference closure — the set of files a model declares
1693
+ * (pet-center M2, issue #623). The host asset route only ever serves a pet's
1694
+ * declared manifest, its declared primary assets, and this closure; the CLI
1695
+ * validator reuses the same extractor so an install-time check proves the
1696
+ * serving set is complete.
1697
+ *
1698
+ * Cubism file family: Moc (.moc3), Textures (images), Physics (.physics3.json),
1699
+ * Pose (.pose3.json), DisplayInfo (.cdi3.json), Expressions[].File
1700
+ * (.exp3.json), Motions.<group>[].File (.motion3.json), UserData
1701
+ * (.userdata3.json). Every reference must be a safe manifest-relative path
1702
+ * (safeManifestPath); unsafe entries make the model unloadable.
1703
+ *
1704
+ * Erasable-syntax-only: scripts/ import this under node strip-only mode.
1705
+ * @module @linxin666/dsh-pet/model3
1706
+ */
1707
+ /** Collect the safe relative paths one model3.json references. */
1708
+ function collectModel3References(model3) {
1709
+ const errors = [];
1710
+ if (typeof model3 !== "object" || model3 === null) return {
1711
+ references: [],
1712
+ errors: ["model3.json is not an object"]
1713
+ };
1714
+ const fileReferences = model3.FileReferences;
1715
+ if (typeof fileReferences !== "object" || fileReferences === null) return {
1716
+ references: [],
1717
+ errors: ["model3.json has no FileReferences"]
1718
+ };
1719
+ const refs = fileReferences;
1720
+ const collected = /* @__PURE__ */ new Set();
1721
+ const push = (raw, field) => {
1722
+ const safe = safeManifestPath(raw);
1723
+ if (safe === void 0) {
1724
+ errors.push(field + " is not a safe relative path: " + JSON.stringify(String(raw)));
1725
+ return;
1726
+ }
1727
+ collected.add(safe);
1728
+ };
1729
+ if (refs.Moc !== void 0) push(refs.Moc, "FileReferences.Moc");
1730
+ if (Array.isArray(refs.Textures)) refs.Textures.forEach((texture, index) => push(texture, "FileReferences.Textures[" + index + "]"));
1731
+ for (const scalar of [
1732
+ "Physics",
1733
+ "Pose",
1734
+ "DisplayInfo",
1735
+ "UserData"
1736
+ ]) if (refs[scalar] !== void 0) push(refs[scalar], "FileReferences." + scalar);
1737
+ if (Array.isArray(refs.Expressions)) refs.Expressions.forEach((expression, index) => {
1738
+ const file = typeof expression === "object" && expression !== null ? expression.File : void 0;
1739
+ if (file !== void 0) push(file, "FileReferences.Expressions[" + index + "].File");
1740
+ });
1741
+ if (typeof refs.Motions === "object" && refs.Motions !== null) for (const [group, motions] of Object.entries(refs.Motions)) {
1742
+ if (!Array.isArray(motions)) {
1743
+ errors.push("FileReferences.Motions." + group + " is not an array");
1744
+ continue;
1745
+ }
1746
+ motions.forEach((motion, index) => {
1747
+ const file = typeof motion === "object" && motion !== null ? motion.File : void 0;
1748
+ if (file !== void 0) push(file, "FileReferences.Motions." + group + "[" + index + "].File");
1749
+ });
1750
+ }
1751
+ return {
1752
+ references: [...collected].sort(),
1753
+ errors
1754
+ };
1755
+ }
1376
1756
  //#endregion
1377
1757
  //#region src/registry.ts
1378
1758
  /**
1379
1759
  * Pet registry — the multi-pet contract. One pet is a directory holding a
1380
1760
  * '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
1761
+ * host or client code changes when a pet is added. The registry scans four
1382
1762
  * sources, later sources overriding earlier ones on an id collision:
1383
1763
  *
1384
1764
  * 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
1765
+ * 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets,
1766
+ * legacy source kept readable);
1767
+ * 3. '$DSH_HOME/pets' subdirectories (the pet-center user directory);
1768
+ * 4. 'PetConfig.pets' manifests composed by the embedding application
1387
1769
  * (highest precedence).
1388
1770
  *
1771
+ * Manifests are parsed through manifest-v2 (pet-center M2, issue #623): v1
1772
+ * manifests are compat-read as sprite2d, v2 manifests validate fail-closed,
1773
+ * and structured diagnostics ride alongside the legacy warnings. Live2d
1774
+ * entries (pet-center M3) list like any other pet: the entry carries the
1775
+ * validated live2d block plus the model's reference closure (the servable
1776
+ * set the asset route allows), and a model3.json that is unreadable or
1777
+ * declares unsafe references rejects the entry with an error diagnostic.
1778
+ *
1389
1779
  * The manifest follows the Codex/hatch-pet contract (8 columns x 9 rows of
1390
1780
  * 192x208 cells, the 9-state row order below). Legacy whale-girl manifests
1391
1781
  * that only carry 'frames' keep working: geometry, per-row frame counts and
@@ -1596,6 +1986,33 @@ function normalizeSequences(raw, id, warn) {
1596
1986
  return Object.keys(sequences).length === 0 ? void 0 : sequences;
1597
1987
  }
1598
1988
  /**
1989
+ * Build the fully resolved animation tracks from the contract defaults plus
1990
+ * optional per-track overrides. Shared by the sprite2d resolver and the
1991
+ * live2d entry builder (which fills the sprite fields with contract
1992
+ * defaults so the flat PetDefinition shape holds for every renderer).
1993
+ */
1994
+ function buildTracks(rows, columns, trackOverrides, warn) {
1995
+ const tracks = {};
1996
+ for (const [row, animation] of PET_ROW_ORDER.entries()) {
1997
+ const pattern = DEFAULT_TRACK_PATTERNS[animation];
1998
+ const override = trackOverrides[animation];
1999
+ const durations = Array.isArray(override?.durations) && override.durations.length > 0 ? override.durations.filter((value) => typeof value === "number" && Number.isFinite(value) && value > 0) : pattern.durations;
2000
+ if (durations.length === 0) {
2001
+ warn("track " + animation + " carries no usable durations");
2002
+ return;
2003
+ }
2004
+ const frameCount = Math.max(1, Math.min(rows[row], columns));
2005
+ const sized = durations.length >= frameCount ? durations.slice(0, frameCount) : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]);
2006
+ tracks[animation] = {
2007
+ frames: Array.from({ length: frameCount }, (_, index) => index),
2008
+ durations: sized,
2009
+ loop: typeof override?.loop === "boolean" ? override.loop : pattern.loop,
2010
+ ...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 }
2011
+ };
2012
+ }
2013
+ return tracks;
2014
+ }
2015
+ /**
1599
2016
  * Normalize one parsed manifest into a renderable pet entry, or undefined
1600
2017
  * (with a warning recorded) when the manifest violates the contract.
1601
2018
  */
@@ -1634,29 +2051,14 @@ function resolvePetManifest(raw, dir, options = {}) {
1634
2051
  });
1635
2052
  const remarks = normalizePetRemarks(source.remarks, (message) => warn("manifest " + id + ": " + message));
1636
2053
  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
- }
2054
+ const tracks = buildTracks(rows, columns, typeof source.tracks === "object" && source.tracks !== null ? source.tracks : {}, (message) => warn("manifest " + id + ": " + message));
2055
+ if (tracks === void 0) return void 0;
2056
+ const sheet = spritesheetPath.join("/");
1656
2057
  return {
1657
2058
  id,
1658
2059
  displayName,
1659
2060
  description,
2061
+ renderer: "sprite2d",
1660
2062
  cell,
1661
2063
  columns,
1662
2064
  rows,
@@ -1666,7 +2068,106 @@ function resolvePetManifest(raw, dir, options = {}) {
1666
2068
  atlasUrl: assetUrl(assetPrefix, id, spritesheet),
1667
2069
  manifestUrl: assetUrl(assetPrefix, id, "pet.json"),
1668
2070
  dir,
1669
- spritesheetPath: spritesheetPath.join("/"),
2071
+ spritesheetPath: sheet,
2072
+ servable: [sheet],
2073
+ ...remarks === void 0 ? {} : { remarks }
2074
+ };
2075
+ }
2076
+ /**
2077
+ * Adapt a validated v2 manifest's sprite2d block onto the legacy flat shape
2078
+ * the established resolver consumes (pet-center M2 P2). The legacy resolver
2079
+ * only expresses 9-row (default) and 11-row (spriteVersionNumber 2) atlases,
2080
+ * so other atlasRows values are rejected here with a diagnostic.
2081
+ */
2082
+ function flattenV2Sprite2d(manifest) {
2083
+ const block = manifest.sprite2d;
2084
+ if (block === void 0) return void 0;
2085
+ const legacy = {
2086
+ id: manifest.id,
2087
+ displayName: manifest.displayName,
2088
+ spritesheetPath: block.spritesheetPath
2089
+ };
2090
+ if (manifest.description !== void 0) legacy.description = manifest.description;
2091
+ if (block.cell !== void 0) legacy.cell = block.cell;
2092
+ if (block.columns !== void 0) legacy.columns = block.columns;
2093
+ if (block.frames !== void 0) legacy.frames = block.frames;
2094
+ if (block.tracks !== void 0) legacy.tracks = block.tracks;
2095
+ if (block.atlasRows !== void 0) {
2096
+ if (block.atlasRows === 11) legacy.spriteVersionNumber = 2;
2097
+ else if (block.atlasRows !== 9) return void 0;
2098
+ }
2099
+ if (manifest.sequences !== void 0) legacy.sequences = manifest.sequences;
2100
+ if (manifest.remarks !== void 0) legacy.remarks = manifest.remarks;
2101
+ return legacy;
2102
+ }
2103
+ /**
2104
+ * Resolve a validated live2d manifest into a renderable entry (pet-center
2105
+ * M3). The model3.json is read at scan time: its reference closure becomes
2106
+ * the entry's servable set (the asset route's allow-list), and a model that
2107
+ * is unreadable or declares unsafe references rejects the entry fail-closed
2108
+ * with an error diagnostic. Closure files missing on disk warn but keep the
2109
+ * entry listed — the client renderer's diagnostic card reports the broken
2110
+ * render, matching the registry's never-throw philosophy (install-time
2111
+ * strictness belongs to the CLI validator). The sprite fields carry contract
2112
+ * defaults: the chrome sizes live2d pets off 'display.size', not the atlas.
2113
+ */
2114
+ function resolveLive2dEntry(manifest, dir, options) {
2115
+ const assetPrefix = options.assetPrefix ?? "/pet";
2116
+ const record = (level, message) => {
2117
+ options.diagnostics?.push({
2118
+ level,
2119
+ source: dir,
2120
+ message
2121
+ });
2122
+ options.warnings?.push(message);
2123
+ };
2124
+ const block = manifest.live2d;
2125
+ if (block === void 0) {
2126
+ record("error", "pet " + manifest.id + ": renderer live2d requires a live2d block");
2127
+ return;
2128
+ }
2129
+ let model3;
2130
+ try {
2131
+ model3 = JSON.parse(readFileSync(join(dir, block.model), "utf8"));
2132
+ } catch (error) {
2133
+ record("error", "pet " + manifest.id + ": live2d model " + block.model + " is not readable: " + (error instanceof Error ? error.message : String(error)));
2134
+ return;
2135
+ }
2136
+ const { references, errors } = collectModel3References(model3);
2137
+ if (errors.length > 0) {
2138
+ for (const message of errors) record("error", "pet " + manifest.id + ": live2d model " + block.model + ": " + message);
2139
+ return;
2140
+ }
2141
+ for (const reference of references) if (!existsSync(join(dir, reference))) record("warning", "pet " + manifest.id + ": live2d closure file missing: " + reference);
2142
+ const tracks = buildTracks(DEFAULT_FRAME_COUNTS, 8, {}, (message) => record("warning", "pet " + manifest.id + ": " + message));
2143
+ if (tracks === void 0) return void 0;
2144
+ const remarks = normalizePetRemarks(manifest.remarks, (message) => record("warning", "pet " + manifest.id + ": " + message));
2145
+ const modelUrl = assetUrl(assetPrefix, manifest.id, block.model);
2146
+ const live2d = {
2147
+ modelUrl,
2148
+ modelPath: block.model,
2149
+ ...block.scale === void 0 ? {} : { scale: block.scale },
2150
+ ...block.translate === void 0 ? {} : { translate: block.translate },
2151
+ motions: block.motions,
2152
+ ...block.expressions === void 0 ? {} : { expressions: block.expressions },
2153
+ ...block.hitAreas === void 0 ? {} : { hitAreas: block.hitAreas }
2154
+ };
2155
+ return {
2156
+ id: manifest.id,
2157
+ displayName: manifest.displayName,
2158
+ description: manifest.description ?? "",
2159
+ renderer: "live2d",
2160
+ live2d,
2161
+ cell: { ...DEFAULT_PET_CELL },
2162
+ columns: 8,
2163
+ rows: [...DEFAULT_FRAME_COUNTS],
2164
+ atlasRows: 9,
2165
+ tracks,
2166
+ atlasUrl: modelUrl,
2167
+ manifestUrl: assetUrl(assetPrefix, manifest.id, "pet.json"),
2168
+ dir,
2169
+ spritesheetPath: block.model,
2170
+ servable: [block.model, ...references],
1670
2171
  ...remarks === void 0 ? {} : { remarks }
1671
2172
  };
1672
2173
  }
@@ -1686,7 +2187,34 @@ function scanPetDir(dir, options) {
1686
2187
  if (!existsSync(manifestFile)) continue;
1687
2188
  const parsed = readPetJson(manifestFile, options.warnings);
1688
2189
  if (parsed === void 0) continue;
1689
- const entry = resolvePetManifest(parsed, join(dir, name), options);
2190
+ const entryDir = join(dir, name);
2191
+ const verdict = parsePetManifest(parsed, entryDir);
2192
+ for (const diagnostic of verdict.diagnostics) {
2193
+ options.diagnostics?.push({
2194
+ level: diagnostic.level,
2195
+ source: entryDir,
2196
+ message: diagnostic.message
2197
+ });
2198
+ options.warnings?.push(diagnostic.message);
2199
+ }
2200
+ if (!verdict.ok) continue;
2201
+ if (verdict.manifest.renderer === "live2d") {
2202
+ const entry = resolveLive2dEntry(verdict.manifest, entryDir, options);
2203
+ if (entry !== void 0) entries.push(entry);
2204
+ continue;
2205
+ }
2206
+ const legacy = flattenV2Sprite2d(verdict.manifest);
2207
+ if (legacy === void 0) {
2208
+ const note = "pet " + verdict.manifest.id + ": sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver";
2209
+ options.diagnostics?.push({
2210
+ level: "error",
2211
+ source: entryDir,
2212
+ message: note
2213
+ });
2214
+ options.warnings?.push(note);
2215
+ continue;
2216
+ }
2217
+ const entry = resolvePetManifest(legacy, entryDir, options);
1690
2218
  if (entry !== void 0) entries.push(entry);
1691
2219
  }
1692
2220
  return entries;
@@ -1709,11 +2237,13 @@ function readPetJson(file, warnings) {
1709
2237
  function loadPetRegistry(options) {
1710
2238
  const { packageRoot, assetPrefix = "/pet" } = options;
1711
2239
  const warnings = [];
2240
+ const diagnostics = [];
1712
2241
  const byId = /* @__PURE__ */ new Map();
1713
2242
  const builtinIds = /* @__PURE__ */ new Set();
1714
2243
  for (const entry of scanPetDir(join(packageRoot, "assets"), {
1715
2244
  assetPrefix,
1716
- warnings
2245
+ warnings,
2246
+ diagnostics
1717
2247
  })) {
1718
2248
  if (byId.has(entry.id)) {
1719
2249
  warnings.push("duplicate built-in pet id " + entry.id + "; the first one wins");
@@ -1725,11 +2255,21 @@ function loadPetRegistry(options) {
1725
2255
  const petsDir = options.petsDir ?? codexPetsDir();
1726
2256
  if (petsDir !== "") for (const entry of scanPetDir(petsDir, {
1727
2257
  assetPrefix,
1728
- warnings
2258
+ warnings,
2259
+ diagnostics
1729
2260
  })) {
1730
2261
  if (byId.has(entry.id)) warnings.push("custom pet " + entry.id + " overrides the built-in one");
1731
2262
  byId.set(entry.id, entry);
1732
2263
  }
2264
+ const dshPetsDir = options.dshPetsDir ?? join(dshHome(), "pets");
2265
+ if (dshPetsDir !== "") for (const entry of scanPetDir(dshPetsDir, {
2266
+ assetPrefix,
2267
+ warnings,
2268
+ diagnostics
2269
+ })) {
2270
+ if (byId.has(entry.id)) warnings.push("user pet " + entry.id + " overrides an earlier registration");
2271
+ byId.set(entry.id, entry);
2272
+ }
1733
2273
  for (const manifest of options.extra ?? []) {
1734
2274
  const raw = manifest.spritesheetPath;
1735
2275
  const dir = raw === void 0 || isAbsolute(raw) ? join(packageRoot, "assets", "extra") : dirname(resolve(packageRoot, raw));
@@ -1748,6 +2288,7 @@ function loadPetRegistry(options) {
1748
2288
  return {
1749
2289
  entries,
1750
2290
  warnings,
2291
+ diagnostics,
1751
2292
  byId: (id) => byId.get(id),
1752
2293
  defaultEntry: () => entries.find((entry) => builtinIds.has(entry.id)) ?? entries[0]
1753
2294
  };
@@ -1758,6 +2299,8 @@ function petEntryView(entry) {
1758
2299
  id: entry.id,
1759
2300
  displayName: entry.displayName,
1760
2301
  description: entry.description,
2302
+ renderer: entry.renderer,
2303
+ ...entry.live2d === void 0 ? {} : { live2d: entry.live2d },
1761
2304
  cell: entry.cell,
1762
2305
  columns: entry.columns,
1763
2306
  rows: entry.rows,
@@ -1841,6 +2384,10 @@ var PetService = class extends Service {
1841
2384
  registrySnapshot() {
1842
2385
  return this.registry;
1843
2386
  }
2387
+ /** RPC: structured registry diagnostics (pet-center M2, issue #623). */
2388
+ async diagnostics() {
2389
+ return { diagnostics: this.registry.diagnostics };
2390
+ }
1844
2391
  /** The selected pet's registry entry. */
1845
2392
  activeEntry() {
1846
2393
  return this.registry.byId(this.selectedPetId()) ?? this.registry.defaultEntry();
@@ -2161,6 +2708,46 @@ const PET_ASSET_PREFIX = "/pet";
2161
2708
  const MANIFEST_FILE = "pet.json";
2162
2709
  const PREVIEW_DIR = "previews";
2163
2710
  const PREVIEW_PATTERN = /^[A-Za-z0-9._-]+$/;
2711
+ /**
2712
+ * Per-class size ceilings for served pet assets, in bytes (pet-center M2 P3,
2713
+ * issue #623). Constants are tested directly; makePetRoutes accepts an
2714
+ * override so tests can exercise the 413 path with tiny caps.
2715
+ */
2716
+ const PET_ASSET_CAPS = {
2717
+ /** pet.json manifest. */
2718
+ manifest: 64 * 1024,
2719
+ /** Atlas, preview and Live2D texture imagery. */
2720
+ image: 20 * 1024 * 1024,
2721
+ /** Live2D model closure files (.moc3, motion/physics/expression JSON; M3). */
2722
+ model: 32 * 1024 * 1024
2723
+ };
2724
+ /** Imagery extensions classify into the image cap; everything else served from a closure is model-class. */
2725
+ const IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([
2726
+ ".webp",
2727
+ ".png",
2728
+ ".gif",
2729
+ ".jpg",
2730
+ ".jpeg"
2731
+ ]);
2732
+ /** Lowercased file extension ('' when none). */
2733
+ function extensionOf(file) {
2734
+ const dot = file.lastIndexOf(".");
2735
+ return dot < 0 ? "" : file.slice(dot).toLowerCase();
2736
+ }
2737
+ /**
2738
+ * realpath containment: resolve both sides and require the candidate to stay
2739
+ * inside the base directory. A pet directory (or an atlas/preview inside it)
2740
+ * that is a symlink escaping its root is rejected, never followed.
2741
+ */
2742
+ function containedRealpath(base, candidate) {
2743
+ try {
2744
+ const realBase = realpathSync(base);
2745
+ const realCandidate = realpathSync(candidate);
2746
+ return realCandidate === realBase || realCandidate.startsWith(realBase + sep) ? realCandidate : void 0;
2747
+ } catch {
2748
+ return;
2749
+ }
2750
+ }
2164
2751
  const MIME_BY_EXT = {
2165
2752
  ".webp": "image/webp",
2166
2753
  ".png": "image/png",
@@ -2279,10 +2866,14 @@ function dirAliases(registry) {
2279
2866
  /**
2280
2867
  * The one asset handler behind the '/pet' prefix. Resolves the pet by id (or
2281
2868
  * 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.
2869
+ * pet.json, the entry's servable set (the sprite2d atlas, or the live2d
2870
+ * model3.json plus its reference closure pet-center M3), and optional
2871
+ * 'previews/<name>' media. The servable match is an exact string comparison
2872
+ * against scan-time normalized paths, so crafted '..' or '.' segments never
2873
+ * match; containedRealpath stays as the second layer. Composed pets without
2874
+ * a manifest file get a synthesized pet.json.
2284
2875
  */
2285
- function assetHandler(registry) {
2876
+ function assetHandler(registry, caps) {
2286
2877
  const aliases = dirAliases(registry);
2287
2878
  return (req, res) => {
2288
2879
  if (!guard(req, res)) return;
@@ -2338,7 +2929,7 @@ function assetHandler(registry) {
2338
2929
  const manifestFile = join(entry.dir, MANIFEST_FILE);
2339
2930
  file = existsSync(manifestFile) ? manifestFile : void 0;
2340
2931
  if (file === void 0) synthesized = true;
2341
- } else if (rest.length > 0 && rel === entry.spritesheetPath) file = join(entry.dir, entry.spritesheetPath);
2932
+ } else if (rest.length > 0 && entry.servable.includes(rel)) file = join(entry.dir, rel);
2342
2933
  else if (rest.length === 2 && rest[0] === PREVIEW_DIR && PREVIEW_PATTERN.test(rest[1])) {
2343
2934
  const preview = join(entry.dir, PREVIEW_DIR, rest[1]);
2344
2935
  file = existsSync(preview) ? preview : void 0;
@@ -2362,7 +2953,24 @@ function assetHandler(registry) {
2362
2953
  res.end();
2363
2954
  return;
2364
2955
  }
2365
- const resolved = file;
2956
+ const resolved = containedRealpath(entry.dir, file);
2957
+ if (resolved === void 0) {
2958
+ res.writeHead(403);
2959
+ res.end();
2960
+ return;
2961
+ }
2962
+ const cap = rest.length === 1 && rest[0] === MANIFEST_FILE ? caps.manifest : IMAGE_EXTENSIONS.has(extensionOf(rel)) ? caps.image : caps.model;
2963
+ try {
2964
+ if (statSync(resolved).size > cap) {
2965
+ res.writeHead(413);
2966
+ res.end();
2967
+ return;
2968
+ }
2969
+ } catch {
2970
+ res.writeHead(404);
2971
+ res.end();
2972
+ return;
2973
+ }
2366
2974
  readFile(resolved).then((body) => {
2367
2975
  res.writeHead(200, {
2368
2976
  "content-type": mimeFor(resolved),
@@ -2380,12 +2988,108 @@ function assetHandler(registry) {
2380
2988
  });
2381
2989
  };
2382
2990
  }
2383
- /** Build the full route family (API + assets) for one service. */
2991
+ /** Browser-facing base path of the plugin runtime files (pet-center M3). */
2992
+ const PET_RUNTIME_PREFIX = "/api/pet/runtime";
2993
+ /**
2994
+ * The runtime files the route may serve, by exact name (no slashes, no
2995
+ * user-controlled path segments, so traversal is structurally impossible):
2996
+ * the user-supplied Cubism Core from the pet runtime directory (the plugin
2997
+ * never bundles or downloads it — issue #623 M1 §0) and the plugin-shipped
2998
+ * MIT vendor bundle from the package lib directory.
2999
+ */
3000
+ const RUNTIME_FILES = {
3001
+ "live2dcubismcore.min.js": { root: "runtimeDir" },
3002
+ "live2d-vendor.js": { root: "vendorDir" },
3003
+ "live2d-vendor.js.map": { root: "vendorDir" }
3004
+ };
3005
+ /**
3006
+ * The runtime handler behind '/api/pet/runtime/<name>'. A missing file
3007
+ * answers 404 with a JSON marker the client renderer turns into install
3008
+ * guidance (the Cubism Core is user-supplied, so its absence is a normal
3009
+ * state, not an error).
3010
+ */
3011
+ function runtimeHandler(roots) {
3012
+ return (req, res) => {
3013
+ if (!guard(req, res)) return;
3014
+ if (req.method !== "GET" && req.method !== "HEAD") {
3015
+ res.writeHead(405);
3016
+ res.end();
3017
+ return;
3018
+ }
3019
+ let pathname;
3020
+ try {
3021
+ pathname = new URL(req.url ?? "/", "http://pet.local").pathname;
3022
+ } catch {
3023
+ res.writeHead(400);
3024
+ res.end();
3025
+ return;
3026
+ }
3027
+ const rest = pathname.slice(16).replace(/^\/+/, "");
3028
+ let name;
3029
+ try {
3030
+ name = decodeURIComponent(rest);
3031
+ } catch {
3032
+ res.writeHead(400);
3033
+ res.end();
3034
+ return;
3035
+ }
3036
+ const spec = RUNTIME_FILES[name];
3037
+ if (spec === void 0) {
3038
+ res.writeHead(404);
3039
+ res.end();
3040
+ return;
3041
+ }
3042
+ const base = spec.root === "runtimeDir" ? roots.runtimeDir : roots.vendorDir;
3043
+ const file = join(base, name);
3044
+ if (!existsSync(file)) {
3045
+ json(res, 404, {
3046
+ ok: false,
3047
+ error: "runtime-file-missing",
3048
+ file: name
3049
+ });
3050
+ return;
3051
+ }
3052
+ const resolved = containedRealpath(base, file);
3053
+ if (resolved === void 0) {
3054
+ res.writeHead(403);
3055
+ res.end();
3056
+ return;
3057
+ }
3058
+ try {
3059
+ if (statSync(resolved).size > 16777216) {
3060
+ res.writeHead(413);
3061
+ res.end();
3062
+ return;
3063
+ }
3064
+ } catch {
3065
+ res.writeHead(404);
3066
+ res.end();
3067
+ return;
3068
+ }
3069
+ readFile(resolved).then((body) => {
3070
+ res.writeHead(200, {
3071
+ "content-type": name.endsWith(".map") ? "application/json" : "application/javascript; charset=utf-8",
3072
+ "content-length": String(body.byteLength),
3073
+ "cache-control": "no-cache"
3074
+ });
3075
+ if (req.method === "HEAD") {
3076
+ res.end();
3077
+ return;
3078
+ }
3079
+ res.end(body);
3080
+ }, () => {
3081
+ res.writeHead(404);
3082
+ res.end();
3083
+ });
3084
+ };
3085
+ }
3086
+ /** Build the full route family (API + assets + runtime) for one service. */
2384
3087
  function makePetRoutes(deps) {
2385
3088
  const { service } = deps;
2386
3089
  const apiRoutes = [
2387
3090
  getRoute("/api/pet/state", () => service.state()),
2388
3091
  getRoute("/api/pet/pets", () => service.pets()),
3092
+ getRoute("/api/pet/diagnostics", () => service.diagnostics()),
2389
3093
  postRoute("/api/pet/interact", (body) => {
2390
3094
  const kind = body.kind;
2391
3095
  if (kind !== "pet" && kind !== "feed") return Promise.reject(/* @__PURE__ */ new Error("invalid-kind"));
@@ -2416,9 +3120,21 @@ function makePetRoutes(deps) {
2416
3120
  const assetRoute = {
2417
3121
  kind: "prefix",
2418
3122
  path: PET_ASSET_PREFIX,
2419
- handler: assetHandler(service.registrySnapshot())
3123
+ handler: assetHandler(service.registrySnapshot(), deps.assetCaps ?? PET_ASSET_CAPS)
3124
+ };
3125
+ const runtimeRoute = {
3126
+ kind: "prefix",
3127
+ path: PET_RUNTIME_PREFIX,
3128
+ handler: runtimeHandler({
3129
+ runtimeDir: deps.runtimeDir ?? join(dshHome(), "pets", ".runtime"),
3130
+ vendorDir: deps.vendorDir ?? join(petPackageRoot(import.meta.url), "lib")
3131
+ })
2420
3132
  };
2421
- return [...apiRoutes, assetRoute];
3133
+ return [
3134
+ ...apiRoutes,
3135
+ assetRoute,
3136
+ runtimeRoute
3137
+ ];
2422
3138
  }
2423
3139
  //#endregion
2424
3140
  //#region src/mount-once.ts