@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.
- package/README.i18n.yaml +2 -2
- package/README.md +66 -3
- package/README.zh.md +66 -3
- package/assets/whale/pet.json +169 -20
- package/assets/whale-refined/pet.json +17 -2
- package/lib/client.js +438 -21
- package/lib/client.js.map +1 -1
- package/lib/index.js +752 -36
- package/lib/live2d-vendor.js +995 -0
- package/lib/live2d-vendor.js.map +1 -0
- package/lib/types/client/PetDockEntry.d.ts.map +1 -1
- package/lib/types/client/PetDockEntry.js +2 -1
- package/lib/types/client/PetSettingsCard.d.ts +10 -0
- package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
- package/lib/types/client/PetSettingsCard.js +22 -1
- package/lib/types/client/PetSprite.d.ts +7 -1
- package/lib/types/client/PetSprite.d.ts.map +1 -1
- package/lib/types/client/PetSprite.js +17 -8
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/index.js +5 -0
- package/lib/types/client/locales.d.ts +10 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/locales.js +10 -0
- package/lib/types/client/phase-stream.d.ts +22 -0
- package/lib/types/client/phase-stream.d.ts.map +1 -0
- package/lib/types/client/phase-stream.js +28 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
- package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
- package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
- package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/runtime.js +63 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
- package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
- package/lib/types/client/renderers/live2d.d.ts +52 -0
- package/lib/types/client/renderers/live2d.d.ts.map +1 -0
- package/lib/types/client/renderers/live2d.js +180 -0
- package/lib/types/client/renderers/registry.d.ts +32 -0
- package/lib/types/client/renderers/registry.d.ts.map +1 -0
- package/lib/types/client/renderers/registry.js +49 -0
- package/lib/types/contracts/renderer.d.ts +50 -0
- package/lib/types/contracts/renderer.d.ts.map +1 -0
- package/lib/types/contracts/renderer.js +15 -0
- package/lib/types/manifest-v2.d.ts +129 -0
- package/lib/types/manifest-v2.d.ts.map +1 -0
- package/lib/types/manifest-v2.js +387 -0
- package/lib/types/model3.d.ts +26 -0
- package/lib/types/model3.d.ts.map +1 -0
- package/lib/types/model3.js +96 -0
- package/lib/types/registry.d.ts +57 -3
- package/lib/types/registry.d.ts.map +1 -1
- package/lib/types/registry.js +208 -33
- package/lib/types/routes.d.ts +39 -2
- package/lib/types/routes.d.ts.map +1 -1
- package/lib/types/routes.js +183 -12
- package/lib/types/service.d.ts +5 -1
- package/lib/types/service.d.ts.map +1 -1
- package/lib/types/service.js +4 -0
- package/package.json +3 -1
- package/src/client/PetDockEntry.tsx +19 -11
- package/src/client/PetSettingsCard.tsx +39 -0
- package/src/client/PetSprite.test.tsx +11 -0
- package/src/client/PetSprite.tsx +24 -9
- package/src/client/index.ts +6 -0
- package/src/client/locales.ts +10 -0
- package/src/client/phase-stream.test.ts +76 -0
- package/src/client/phase-stream.ts +39 -0
- package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
- package/src/client/renderers/PetRendererSwitch.tsx +48 -0
- package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
- package/src/client/renderers/live2d/runtime.test.ts +61 -0
- package/src/client/renderers/live2d/runtime.ts +119 -0
- package/src/client/renderers/live2d/vendor-entry.ts +14 -0
- package/src/client/renderers/live2d.test.ts +242 -0
- package/src/client/renderers/live2d.ts +218 -0
- package/src/client/renderers/registry.ts +58 -0
- package/src/client/settings-section.module.css +23 -0
- package/src/contracts/renderer.ts +54 -0
- package/src/manifest-v2.test.ts +251 -0
- package/src/manifest-v2.ts +414 -0
- package/src/model3.test.ts +79 -0
- package/src/model3.ts +91 -0
- package/src/registry.test.ts +215 -2
- package/src/registry.ts +246 -34
- package/src/routes.ts +202 -13
- 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
|
|
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
|
-
*
|
|
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
|
|
1638
|
-
|
|
1639
|
-
|
|
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:
|
|
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
|
|
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
|
|
2283
|
-
*
|
|
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 &&
|
|
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
|
-
/**
|
|
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 [
|
|
3133
|
+
return [
|
|
3134
|
+
...apiRoutes,
|
|
3135
|
+
assetRoute,
|
|
3136
|
+
runtimeRoute
|
|
3137
|
+
];
|
|
2422
3138
|
}
|
|
2423
3139
|
//#endregion
|
|
2424
3140
|
//#region src/mount-once.ts
|