@linxin666/dsh-pet 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +129 -3
  3. package/README.zh.md +129 -3
  4. package/assets/decorations/whale/decoration.json +20 -0
  5. package/assets/decorations/whale/whale-frames.png +0 -0
  6. package/assets/whale/pet.json +169 -20
  7. package/assets/whale-refined/pet.json +17 -2
  8. package/contracts/pet-manifest-v2.schema.json +281 -0
  9. package/contracts/status-decoration-v1.schema.json +144 -0
  10. package/contracts/voice-pack-v1.schema.json +234 -0
  11. package/lib/client.js +581 -38
  12. package/lib/client.js.map +1 -1
  13. package/lib/index.js +1684 -59
  14. package/lib/live2d-vendor.js +995 -0
  15. package/lib/live2d-vendor.js.map +1 -0
  16. package/lib/types/chatter.d.ts +61 -3
  17. package/lib/types/chatter.d.ts.map +1 -1
  18. package/lib/types/chatter.js +71 -12
  19. package/lib/types/client/PetDockEntry.d.ts.map +1 -1
  20. package/lib/types/client/PetDockEntry.js +2 -1
  21. package/lib/types/client/PetSettingsCard.d.ts +14 -0
  22. package/lib/types/client/PetSettingsCard.d.ts.map +1 -1
  23. package/lib/types/client/PetSettingsCard.js +24 -1
  24. package/lib/types/client/PetSprite.d.ts +7 -1
  25. package/lib/types/client/PetSprite.d.ts.map +1 -1
  26. package/lib/types/client/PetSprite.js +120 -12
  27. package/lib/types/client/index.d.ts.map +1 -1
  28. package/lib/types/client/index.js +5 -0
  29. package/lib/types/client/locales.d.ts +14 -0
  30. package/lib/types/client/locales.d.ts.map +1 -1
  31. package/lib/types/client/locales.js +14 -0
  32. package/lib/types/client/phase-stream.d.ts +22 -0
  33. package/lib/types/client/phase-stream.d.ts.map +1 -0
  34. package/lib/types/client/phase-stream.js +28 -0
  35. package/lib/types/client/renderers/PetRendererSwitch.d.ts +25 -0
  36. package/lib/types/client/renderers/PetRendererSwitch.d.ts.map +1 -0
  37. package/lib/types/client/renderers/PetRendererSwitch.js +24 -0
  38. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts +22 -0
  39. package/lib/types/client/renderers/live2d/Live2dVisualMount.d.ts.map +1 -0
  40. package/lib/types/client/renderers/live2d/Live2dVisualMount.js +75 -0
  41. package/lib/types/client/renderers/live2d/runtime.d.ts +89 -0
  42. package/lib/types/client/renderers/live2d/runtime.d.ts.map +1 -0
  43. package/lib/types/client/renderers/live2d/runtime.js +63 -0
  44. package/lib/types/client/renderers/live2d/vendor-entry.d.ts +15 -0
  45. package/lib/types/client/renderers/live2d/vendor-entry.d.ts.map +1 -0
  46. package/lib/types/client/renderers/live2d/vendor-entry.js +14 -0
  47. package/lib/types/client/renderers/live2d.d.ts +52 -0
  48. package/lib/types/client/renderers/live2d.d.ts.map +1 -0
  49. package/lib/types/client/renderers/live2d.js +192 -0
  50. package/lib/types/client/renderers/registry.d.ts +32 -0
  51. package/lib/types/client/renderers/registry.d.ts.map +1 -0
  52. package/lib/types/client/renderers/registry.js +49 -0
  53. package/lib/types/client/settings-form.d.ts.map +1 -1
  54. package/lib/types/client/settings-form.js +9 -6
  55. package/lib/types/contracts/renderer.d.ts +50 -0
  56. package/lib/types/contracts/renderer.d.ts.map +1 -0
  57. package/lib/types/contracts/renderer.js +15 -0
  58. package/lib/types/contracts/status-decoration.d.ts +85 -0
  59. package/lib/types/contracts/status-decoration.d.ts.map +1 -0
  60. package/lib/types/contracts/status-decoration.js +21 -0
  61. package/lib/types/decoration.d.ts +39 -0
  62. package/lib/types/decoration.d.ts.map +1 -0
  63. package/lib/types/decoration.js +210 -0
  64. package/lib/types/event-projection.d.ts +8 -3
  65. package/lib/types/event-projection.d.ts.map +1 -1
  66. package/lib/types/event-projection.js +9 -4
  67. package/lib/types/index.d.ts +2 -0
  68. package/lib/types/index.d.ts.map +1 -1
  69. package/lib/types/index.js +2 -0
  70. package/lib/types/manifest-v2.d.ts +129 -0
  71. package/lib/types/manifest-v2.d.ts.map +1 -0
  72. package/lib/types/manifest-v2.js +387 -0
  73. package/lib/types/model3.d.ts +26 -0
  74. package/lib/types/model3.d.ts.map +1 -0
  75. package/lib/types/model3.js +96 -0
  76. package/lib/types/registry.d.ts +112 -5
  77. package/lib/types/registry.d.ts.map +1 -1
  78. package/lib/types/registry.js +395 -38
  79. package/lib/types/routes.d.ts +39 -2
  80. package/lib/types/routes.d.ts.map +1 -1
  81. package/lib/types/routes.js +312 -13
  82. package/lib/types/service.d.ts +38 -1
  83. package/lib/types/service.d.ts.map +1 -1
  84. package/lib/types/service.js +46 -3
  85. package/lib/types/voice-pack.d.ts +98 -0
  86. package/lib/types/voice-pack.d.ts.map +1 -0
  87. package/lib/types/voice-pack.js +384 -0
  88. package/package.json +13 -10
  89. package/src/chatter.test.ts +89 -2
  90. package/src/chatter.ts +120 -14
  91. package/src/client/PetDockEntry.tsx +19 -11
  92. package/src/client/PetSettingsCard.tsx +57 -0
  93. package/src/client/PetSprite.test.tsx +265 -12
  94. package/src/client/PetSprite.tsx +165 -33
  95. package/src/client/index.ts +6 -0
  96. package/src/client/locales.ts +14 -0
  97. package/src/client/phase-stream.test.ts +76 -0
  98. package/src/client/phase-stream.ts +39 -0
  99. package/src/client/renderers/PetRendererSwitch.test.tsx +66 -0
  100. package/src/client/renderers/PetRendererSwitch.tsx +48 -0
  101. package/src/client/renderers/live2d/Live2dVisualMount.tsx +98 -0
  102. package/src/client/renderers/live2d/runtime.test.ts +61 -0
  103. package/src/client/renderers/live2d/runtime.ts +119 -0
  104. package/src/client/renderers/live2d/vendor-entry.ts +14 -0
  105. package/src/client/renderers/live2d.test.ts +263 -0
  106. package/src/client/renderers/live2d.ts +231 -0
  107. package/src/client/renderers/registry.ts +58 -0
  108. package/src/client/settings-form.ts +8 -6
  109. package/src/client/settings-section.module.css +23 -0
  110. package/src/contracts/renderer.ts +54 -0
  111. package/src/contracts/status-decoration.ts +78 -0
  112. package/src/decoration.test.ts +178 -0
  113. package/src/decoration.ts +220 -0
  114. package/src/event-projection.ts +10 -5
  115. package/src/index.ts +2 -0
  116. package/src/manifest-v2.test.ts +251 -0
  117. package/src/manifest-v2.ts +414 -0
  118. package/src/model3.test.ts +79 -0
  119. package/src/model3.ts +91 -0
  120. package/src/registry.test.ts +438 -2
  121. package/src/registry.ts +470 -38
  122. package/src/routes.ts +328 -14
  123. package/src/service.ts +65 -3
  124. package/src/voice-pack.test.ts +216 -0
  125. package/src/voice-pack.ts +413 -0
package/src/registry.ts CHANGED
@@ -1,14 +1,24 @@
1
1
  /**
2
2
  * Pet registry — the multi-pet contract. One pet is a directory holding a
3
3
  * 'pet.json' manifest plus an atlas image; nothing else is required, and no
4
- * host or client code changes when a pet is added. The registry scans three
4
+ * host or client code changes when a pet is added. The registry scans four
5
5
  * sources, later sources overriding earlier ones on an id collision:
6
6
  *
7
7
  * 1. the package's own 'assets' subdirectories (built-in pets);
8
- * 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets);
9
- * 3. 'PetConfig.pets' manifests composed by the embedding application
8
+ * 2. '${CODEX_HOME:-~/.codex}/pets' subdirectories (hatch-pet custom pets,
9
+ * legacy source kept readable);
10
+ * 3. '$DSH_HOME/pets' subdirectories (the pet-center user directory);
11
+ * 4. 'PetConfig.pets' manifests composed by the embedding application
10
12
  * (highest precedence).
11
13
  *
14
+ * Manifests are parsed through manifest-v2 (pet-center M2, issue #623): v1
15
+ * manifests are compat-read as sprite2d, v2 manifests validate fail-closed,
16
+ * and structured diagnostics ride alongside the legacy warnings. Live2d
17
+ * entries (pet-center M3) list like any other pet: the entry carries the
18
+ * validated live2d block plus the model's reference closure (the servable
19
+ * set the asset route allows), and a model3.json that is unreadable or
20
+ * declares unsafe references rejects the entry with an error diagnostic.
21
+ *
12
22
  * The manifest follows the Codex/hatch-pet contract (8 columns x 9 rows of
13
23
  * 192x208 cells, the 9-state row order below). Legacy whale-girl manifests
14
24
  * that only carry 'frames' keep working: geometry, per-row frame counts and
@@ -17,12 +27,18 @@
17
27
  * @module @linxin666/dsh-pet/registry
18
28
  */
19
29
 
20
- import { existsSync, readdirSync, readFileSync } from 'node:fs'
30
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
21
31
  import { homedir } from 'node:os'
22
32
  import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
23
33
  import { fileURLToPath } from 'node:url'
24
34
  import type { ActivityPhase, PetAnimation } from './state.ts'
25
35
  import { normalizePetRemarks, type PetRemarks, type PetRemarksManifest } from './remarks.ts'
36
+ import { mergeVoicePacks, normalizeVoicePack, type PetPanelView, type VoicePack } from './voice-pack.ts'
37
+ import { parseDecorationManifest } from './decoration.ts'
38
+ import { PET_DECORATION_API_VERSION, type DecorationView } from './contracts/status-decoration.ts'
39
+ import { dshHome } from './dsh-home.ts'
40
+ import { parsePetManifest, type PetManifestLive2d, type PetManifestV2, type PetRendererKind } from './manifest-v2.ts'
41
+ import { collectModel3References } from './model3.ts'
26
42
 
27
43
  /** Fixed row order of the 9-state animation contract. */
28
44
  export const PET_ROW_ORDER: readonly PetAnimation[] = [
@@ -156,11 +172,33 @@ export interface PetTrackOverride {
156
172
  fallback?: PetAnimation
157
173
  }
158
174
 
175
+ /** The live2d renderer block as served to the browser half (pet-center M3). */
176
+ export interface PetLive2dDefinition {
177
+ /** Browser URL of the .model3.json (served by the host asset route). */
178
+ modelUrl: string
179
+ /** Manifest-relative model path (host route allow-list key). */
180
+ modelPath: string
181
+ /** Scale multiplier over the canvas auto-fit, (0, 10]. */
182
+ scale?: number
183
+ /** Model offset in canvas px from the center-bottom anchor. */
184
+ translate?: { x?: number; y?: number }
185
+ /** ActivityPhase -> motion group; unmapped phases fall back to idle. */
186
+ motions: Partial<Record<ActivityPhase, string>> & { idle: string }
187
+ /** Optional ActivityPhase -> expression name layered over the motion. */
188
+ expressions?: Partial<Record<ActivityPhase, string>>
189
+ /** Hit area names triggering the tap motion; defaults to every model HitArea. */
190
+ hitAreas?: string[]
191
+ }
192
+
159
193
  /** A normalized pet as served to the browser half. */
160
194
  export interface PetDefinition {
161
195
  id: string
162
196
  displayName: string
163
197
  description: string
198
+ /** The renderer this entry mounts with (pet-center M2). */
199
+ renderer: PetRendererKind
200
+ /** Live2d render block; present exactly when renderer is 'live2d' (M3). */
201
+ live2d?: PetLive2dDefinition
164
202
  /** Atlas cell size in px. */
165
203
  cell: PetCell
166
204
  /** Columns per row. */
@@ -177,6 +215,8 @@ export interface PetDefinition {
177
215
  atlasUrl: string
178
216
  /** Browser URL of the manifest (served by the host asset route). */
179
217
  manifestUrl: string
218
+ /** Hover-panel chrome overrides (voice.json 'panel'; pet-center M4). */
219
+ panel?: PetPanelView
180
220
  }
181
221
 
182
222
  /** A resolved pet plus its host-side file location. */
@@ -185,17 +225,64 @@ export interface PetEntry extends PetDefinition {
185
225
  dir: string
186
226
  /** Atlas path relative to 'dir' (declared by the manifest). */
187
227
  spritesheetPath: string
228
+ /**
229
+ * Manifest-relative files the asset route may serve beyond pet.json and
230
+ * 'previews/*' (pet-center M3): the sprite2d atlas, or the live2d model
231
+ * plus its model3.json reference closure.
232
+ */
233
+ servable: readonly string[]
188
234
  /** Normalized per-pet remark pools (manifest 'remarks'), when declared. */
189
235
  remarks?: PetRemarks
236
+ /**
237
+ * Normalized per-pet voice pack (the directory's voice.json; pet-center
238
+ * M4). Host-side only — the browser half receives its 'panel' slice.
239
+ */
240
+ voice?: VoicePack
190
241
  }
191
242
 
192
243
  /** Registry load result: resolved entries plus load warnings. */
193
244
  export interface PetRegistry {
194
245
  entries: PetEntry[]
195
246
  warnings: string[]
247
+ /** Structured diagnostics from the manifest-v2 parse (superset detail of warnings). */
248
+ diagnostics: PetRegistryDiagnostic[]
196
249
  byId(id: string): PetEntry | undefined
197
250
  /** The pet an installation falls back to when the selection is unknown. */
198
251
  defaultEntry(): PetEntry
252
+ /**
253
+ * The global voice override ('$DSH_HOME/pets/.voice.json'), when present —
254
+ * layers under every per-pet pack and over the built-in pools (M4, #677).
255
+ */
256
+ globalVoice?: VoicePack
257
+ /**
258
+ * Status decorations (pet-center M5, #567): built-in 'assets/decorations'
259
+ * entries overridden by same-id user entries under
260
+ * '$DSH_HOME/pets/decorations'. Independent of the pet entries. Optional
261
+ * so prebuilt test registries without decorations keep compiling.
262
+ */
263
+ decorations?: DecorationEntry[]
264
+ /** Look up one decoration by id. */
265
+ decorationById?(id: string): DecorationEntry | undefined
266
+ }
267
+
268
+ /** One resolved status decoration plus its host-side file location. */
269
+ export interface DecorationEntry extends DecorationView {
270
+ /** Absolute directory holding the descriptor and strip. */
271
+ dir: string
272
+ /** Strip path relative to 'dir' (declared by the descriptor). */
273
+ entryPath: string
274
+ /** Descriptor-relative files the decoration asset route may serve. */
275
+ servable: readonly string[]
276
+ /** Asset license identifier (required by the descriptor). */
277
+ license: string
278
+ }
279
+
280
+ /** One structured registry diagnostic (manifest-v2 era). */
281
+ export interface PetRegistryDiagnostic {
282
+ level: 'error' | 'warning'
283
+ /** Where the diagnostic originates (directory or file). */
284
+ source: string
285
+ message: string
199
286
  }
200
287
 
201
288
  /** Registry sources. */
@@ -206,6 +293,8 @@ export interface PetRegistryOptions {
206
293
  assetPrefix?: string
207
294
  /** Custom pet directory (defaults to '${CODEX_HOME:-~/.codex}/pets'). */
208
295
  petsDir?: string
296
+ /** Pet-center user directory (defaults to '$DSH_HOME/pets'; '' disables). */
297
+ dshPetsDir?: string
209
298
  /** Extra manifest entries composed by the embedding application. */
210
299
  extra?: readonly PetManifest[]
211
300
  }
@@ -249,6 +338,47 @@ function normalizeSequences(
249
338
  return Object.keys(sequences).length === 0 ? undefined : sequences
250
339
  }
251
340
 
341
+ /**
342
+ * Build the fully resolved animation tracks from the contract defaults plus
343
+ * optional per-track overrides. Shared by the sprite2d resolver and the
344
+ * live2d entry builder (which fills the sprite fields with contract
345
+ * defaults so the flat PetDefinition shape holds for every renderer).
346
+ */
347
+ function buildTracks(
348
+ rows: readonly number[],
349
+ columns: number,
350
+ trackOverrides: Partial<Record<PetAnimation, PetTrackOverride>>,
351
+ warn: (message: string) => void,
352
+ ): Record<PetAnimation, PetTrackDef> | undefined {
353
+ const tracks = {} as Record<PetAnimation, PetTrackDef>
354
+ for (const [row, animation] of PET_ROW_ORDER.entries()) {
355
+ const pattern = DEFAULT_TRACK_PATTERNS[animation]
356
+ const override = trackOverrides[animation]
357
+ const durations = Array.isArray(override?.durations) && override.durations.length > 0
358
+ ? override.durations.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0)
359
+ : pattern.durations
360
+ if (durations.length === 0) {
361
+ warn('track ' + animation + ' carries no usable durations')
362
+ return undefined
363
+ }
364
+ const frameCount = Math.max(1, Math.min(rows[row]!, columns))
365
+ const sized = durations.length >= frameCount
366
+ ? durations.slice(0, frameCount)
367
+ : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]!)
368
+ tracks[animation] = {
369
+ frames: Array.from({ length: frameCount }, (_, index) => index),
370
+ durations: sized,
371
+ loop: typeof override?.loop === 'boolean' ? override.loop : pattern.loop,
372
+ ...(override?.fallback === undefined
373
+ ? pattern.fallback === undefined ? {} : { fallback: pattern.fallback }
374
+ : PET_ROW_ORDER.includes(override.fallback)
375
+ ? { fallback: override.fallback }
376
+ : pattern.fallback === undefined ? {} : { fallback: pattern.fallback }),
377
+ }
378
+ }
379
+ return tracks
380
+ }
381
+
252
382
  /**
253
383
  * Normalize one parsed manifest into a renderable pet entry, or undefined
254
384
  * (with a warning recorded) when the manifest violates the contract.
@@ -304,36 +434,14 @@ export function resolvePetManifest(
304
434
  const remarks = normalizePetRemarks(source.remarks, message => warn('manifest ' + id + ': ' + message))
305
435
  const sequences = normalizeSequences(source.sequences, id, warn)
306
436
  const trackOverrides = (typeof source.tracks === 'object' && source.tracks !== null ? source.tracks : {}) as Partial<Record<PetAnimation, PetTrackOverride>>
307
- const tracks = {} as Record<PetAnimation, PetTrackDef>
308
- for (const [row, animation] of PET_ROW_ORDER.entries()) {
309
- const pattern = DEFAULT_TRACK_PATTERNS[animation]
310
- const override = trackOverrides[animation]
311
- const durations = Array.isArray(override?.durations) && override.durations.length > 0
312
- ? override.durations.filter((value): value is number => typeof value === 'number' && Number.isFinite(value) && value > 0)
313
- : pattern.durations
314
- if (durations.length === 0) {
315
- warn('manifest ' + id + ': track ' + animation + ' carries no usable durations')
316
- return undefined
317
- }
318
- const frameCount = Math.max(1, Math.min(rows[row]!, columns))
319
- const sized = durations.length >= frameCount
320
- ? durations.slice(0, frameCount)
321
- : Array.from({ length: frameCount }, (_, index) => durations[index % durations.length]!)
322
- tracks[animation] = {
323
- frames: Array.from({ length: frameCount }, (_, index) => index),
324
- durations: sized,
325
- loop: typeof override?.loop === 'boolean' ? override.loop : pattern.loop,
326
- ...(override?.fallback === undefined
327
- ? pattern.fallback === undefined ? {} : { fallback: pattern.fallback }
328
- : PET_ROW_ORDER.includes(override.fallback)
329
- ? { fallback: override.fallback }
330
- : pattern.fallback === undefined ? {} : { fallback: pattern.fallback }),
331
- }
332
- }
437
+ const tracks = buildTracks(rows, columns, trackOverrides, message => warn('manifest ' + id + ': ' + message))
438
+ if (tracks === undefined) return undefined
439
+ const sheet = spritesheetPath.join('/')
333
440
  return {
334
441
  id,
335
442
  displayName,
336
443
  description,
444
+ renderer: 'sprite2d' as const,
337
445
  cell,
338
446
  columns,
339
447
  rows,
@@ -343,13 +451,121 @@ export function resolvePetManifest(
343
451
  atlasUrl: assetUrl(assetPrefix, id, spritesheet),
344
452
  manifestUrl: assetUrl(assetPrefix, id, 'pet.json'),
345
453
  dir,
346
- spritesheetPath: spritesheetPath.join('/'),
454
+ spritesheetPath: sheet,
455
+ servable: [sheet],
456
+ ...(remarks === undefined ? {} : { remarks }),
457
+ }
458
+ }
459
+
460
+ /**
461
+ * Adapt a validated v2 manifest's sprite2d block onto the legacy flat shape
462
+ * the established resolver consumes (pet-center M2 P2). The legacy resolver
463
+ * only expresses 9-row (default) and 11-row (spriteVersionNumber 2) atlases,
464
+ * so other atlasRows values are rejected here with a diagnostic.
465
+ */
466
+ function flattenV2Sprite2d(manifest: PetManifestV2): Record<string, unknown> | undefined {
467
+ const block = manifest.sprite2d
468
+ if (block === undefined) return undefined
469
+ const legacy: Record<string, unknown> = {
470
+ id: manifest.id,
471
+ displayName: manifest.displayName,
472
+ spritesheetPath: block.spritesheetPath,
473
+ }
474
+ if (manifest.description !== undefined) legacy.description = manifest.description
475
+ if (block.cell !== undefined) legacy.cell = block.cell
476
+ if (block.columns !== undefined) legacy.columns = block.columns
477
+ if (block.frames !== undefined) legacy.frames = block.frames
478
+ if (block.tracks !== undefined) legacy.tracks = block.tracks
479
+ if (block.atlasRows !== undefined) {
480
+ if (block.atlasRows === 11) legacy.spriteVersionNumber = 2
481
+ else if (block.atlasRows !== DEFAULT_PET_ROW_COUNT) return undefined
482
+ }
483
+ if (manifest.sequences !== undefined) legacy.sequences = manifest.sequences
484
+ if (manifest.remarks !== undefined) legacy.remarks = manifest.remarks
485
+ return legacy
486
+ }
487
+
488
+ /**
489
+ * Resolve a validated live2d manifest into a renderable entry (pet-center
490
+ * M3). The model3.json is read at scan time: its reference closure becomes
491
+ * the entry's servable set (the asset route's allow-list), and a model that
492
+ * is unreadable or declares unsafe references rejects the entry fail-closed
493
+ * with an error diagnostic. Closure files missing on disk warn but keep the
494
+ * entry listed — the client renderer's diagnostic card reports the broken
495
+ * render, matching the registry's never-throw philosophy (install-time
496
+ * strictness belongs to the CLI validator). The sprite fields carry contract
497
+ * defaults: the chrome sizes live2d pets off 'display.size', not the atlas.
498
+ */
499
+ function resolveLive2dEntry(
500
+ manifest: PetManifestV2,
501
+ dir: string,
502
+ options: { assetPrefix?: string; warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
503
+ ): PetEntry | undefined {
504
+ const assetPrefix = options.assetPrefix ?? '/pet'
505
+ const record = (level: 'error' | 'warning', message: string): void => {
506
+ options.diagnostics?.push({ level, source: dir, message })
507
+ options.warnings?.push(message)
508
+ }
509
+ const block = manifest.live2d as PetManifestLive2d | undefined
510
+ if (block === undefined) {
511
+ record('error', 'pet ' + manifest.id + ': renderer live2d requires a live2d block')
512
+ return undefined
513
+ }
514
+ let model3: unknown
515
+ try {
516
+ model3 = JSON.parse(readFileSync(join(dir, block.model), 'utf8'))
517
+ } catch (error) {
518
+ record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ' is not readable: '
519
+ + (error instanceof Error ? error.message : String(error)))
520
+ return undefined
521
+ }
522
+ const { references, errors } = collectModel3References(model3)
523
+ if (errors.length > 0) {
524
+ for (const message of errors) {
525
+ record('error', 'pet ' + manifest.id + ': live2d model ' + block.model + ': ' + message)
526
+ }
527
+ return undefined
528
+ }
529
+ for (const reference of references) {
530
+ if (!existsSync(join(dir, reference))) {
531
+ record('warning', 'pet ' + manifest.id + ': live2d closure file missing: ' + reference)
532
+ }
533
+ }
534
+ const tracks = buildTracks(DEFAULT_FRAME_COUNTS, DEFAULT_PET_COLUMNS, {}, message => record('warning', 'pet ' + manifest.id + ': ' + message))
535
+ if (tracks === undefined) return undefined
536
+ const remarks = normalizePetRemarks(manifest.remarks, message => record('warning', 'pet ' + manifest.id + ': ' + message))
537
+ const modelUrl = assetUrl(assetPrefix, manifest.id, block.model)
538
+ const live2d: PetLive2dDefinition = {
539
+ modelUrl,
540
+ modelPath: block.model,
541
+ ...(block.scale === undefined ? {} : { scale: block.scale }),
542
+ ...(block.translate === undefined ? {} : { translate: block.translate }),
543
+ motions: block.motions,
544
+ ...(block.expressions === undefined ? {} : { expressions: block.expressions }),
545
+ ...(block.hitAreas === undefined ? {} : { hitAreas: block.hitAreas }),
546
+ }
547
+ return {
548
+ id: manifest.id,
549
+ displayName: manifest.displayName,
550
+ description: manifest.description ?? '',
551
+ renderer: 'live2d' as const,
552
+ live2d,
553
+ cell: { ...DEFAULT_PET_CELL },
554
+ columns: DEFAULT_PET_COLUMNS,
555
+ rows: [...DEFAULT_FRAME_COUNTS],
556
+ atlasRows: DEFAULT_PET_ROW_COUNT,
557
+ tracks,
558
+ atlasUrl: modelUrl,
559
+ manifestUrl: assetUrl(assetPrefix, manifest.id, 'pet.json'),
560
+ dir,
561
+ spritesheetPath: block.model,
562
+ servable: [block.model, ...references],
347
563
  ...(remarks === undefined ? {} : { remarks }),
348
564
  }
349
565
  }
350
566
 
351
567
  /** Scan one directory of pet folders; entries come back in name order. */
352
- function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: string[] }): PetEntry[] {
568
+ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] }): PetEntry[] {
353
569
  if (!existsSync(dir)) return []
354
570
  let names: string[] = []
355
571
  try {
@@ -364,8 +580,30 @@ function scanPetDir(dir: string, options: { assetPrefix?: string; warnings?: str
364
580
  if (!existsSync(manifestFile)) continue
365
581
  const parsed = readPetJson(manifestFile, options.warnings)
366
582
  if (parsed === undefined) continue
367
- const entry = resolvePetManifest(parsed, join(dir, name), options)
368
- if (entry !== undefined) entries.push(entry)
583
+ const entryDir = join(dir, name)
584
+ const verdict = parsePetManifest(parsed, entryDir)
585
+ for (const diagnostic of verdict.diagnostics) {
586
+ options.diagnostics?.push({ level: diagnostic.level, source: entryDir, message: diagnostic.message })
587
+ options.warnings?.push(diagnostic.message)
588
+ }
589
+ if (!verdict.ok) continue
590
+ let entry: PetEntry | undefined
591
+ if (verdict.manifest.renderer === 'live2d') {
592
+ entry = resolveLive2dEntry(verdict.manifest, entryDir, options)
593
+ } else {
594
+ const legacy = flattenV2Sprite2d(verdict.manifest)
595
+ if (legacy === undefined) {
596
+ const note = 'pet ' + verdict.manifest.id + ': sprite2d.atlasRows only supports 9 or 11 under the v1 compat resolver'
597
+ options.diagnostics?.push({ level: 'error', source: entryDir, message: note })
598
+ options.warnings?.push(note)
599
+ continue
600
+ }
601
+ entry = resolvePetManifest(legacy, entryDir, options)
602
+ }
603
+ if (entry === undefined) continue
604
+ // Optional voice pack (voice.json) — pure content, warn-and-drop (M4).
605
+ const voice = loadVoicePackFile(join(entryDir, 'voice.json'), options)
606
+ entries.push({ ...entry, ...(voice === undefined ? {} : { voice }) })
369
607
  }
370
608
  return entries
371
609
  }
@@ -380,6 +618,138 @@ function readPetJson(file: string, warnings: string[] | undefined): unknown {
380
618
  }
381
619
  }
382
620
 
621
+ /**
622
+ * Scan-time read ceiling for user-authored JSON descriptors (voice.json,
623
+ * .voice.json, decoration.json): the registry reads these synchronously at
624
+ * plugin startup, and a pathological file — multi-GB, or a FIFO/device
625
+ * symlink — must not hang or exhaust the host before the warn-and-drop
626
+ * discipline can apply (review-spd follow-up, pet-center M4/M5).
627
+ */
628
+ export const PET_SCAN_JSON_CAP = 64 * 1024
629
+
630
+ /**
631
+ * Stat one scanned JSON descriptor with a regular-file + size guard, so a
632
+ * pathological user file is skipped with a warning instead of stalling or
633
+ * OOM-ing the host at startup. Returns the Stats, or undefined when the
634
+ * caller must skip the file (a warning was recorded).
635
+ */
636
+ function guardedScannedJsonStat(
637
+ file: string,
638
+ options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
639
+ what: string,
640
+ ): ReturnType<typeof statSync> | undefined {
641
+ let st: ReturnType<typeof statSync>
642
+ try {
643
+ st = statSync(file)
644
+ } catch {
645
+ return undefined
646
+ }
647
+ const warn = (message: string): void => {
648
+ options.warnings?.push(file + ': ' + message)
649
+ options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message })
650
+ }
651
+ if (!st.isFile()) {
652
+ warn(what + ' is not a regular file; ignored')
653
+ return undefined
654
+ }
655
+ if (st.size > PET_SCAN_JSON_CAP) {
656
+ warn(what + ' exceeds the ' + PET_SCAN_JSON_CAP + '-byte scan ceiling; ignored')
657
+ return undefined
658
+ }
659
+ return st
660
+ }
661
+
662
+ /**
663
+ * Load and normalize one optional voice.json (pet-center M4). A missing
664
+ * file is silent; a broken file warns and drops. The pack is pure content,
665
+ * so every issue stays a warning — a bad voice.json never rejects a pet.
666
+ */
667
+ function loadVoicePackFile(
668
+ file: string,
669
+ options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] },
670
+ ): VoicePack | undefined {
671
+ if (!existsSync(file)) return undefined
672
+ if (guardedScannedJsonStat(file, options, 'voice pack') === undefined) return undefined
673
+ const warn = (message: string): void => {
674
+ options.warnings?.push(file + ': ' + message)
675
+ options.diagnostics?.push({ level: 'warning', source: file, message: file + ': ' + message })
676
+ }
677
+ let raw: unknown
678
+ try {
679
+ raw = JSON.parse(readFileSync(file, 'utf8'))
680
+ } catch (error) {
681
+ warn('voice pack is not valid JSON; ignored: ' + (error instanceof Error ? error.message : String(error)))
682
+ return undefined
683
+ }
684
+ return normalizeVoicePack(raw, warn)
685
+ }
686
+
687
+ /** Decoration asset URL prefix (served by the decoration route, M5). */
688
+ export const DECORATION_ASSET_PREFIX = '/api/pet/decoration'
689
+
690
+ /**
691
+ * Scan one directory of decoration folders ('decoration.json' + strip).
692
+ * Later scans override earlier ones on id collision; a bad descriptor warns
693
+ * and skips — the never-throw philosophy holds for decorations too (M5).
694
+ */
695
+ function scanDecorationDir(dir: string, options: { warnings?: string[]; diagnostics?: PetRegistryDiagnostic[] }): DecorationEntry[] {
696
+ if (!existsSync(dir)) return []
697
+ let names: string[] = []
698
+ try {
699
+ names = readdirSync(dir).filter(name => !name.startsWith('.'))
700
+ } catch {
701
+ return []
702
+ }
703
+ names.sort()
704
+ const entries: DecorationEntry[] = []
705
+ for (const name of names) {
706
+ const entryDir = join(dir, name)
707
+ const manifestFile = join(entryDir, 'decoration.json')
708
+ if (!existsSync(manifestFile)) continue
709
+ if (guardedScannedJsonStat(manifestFile, options, 'decoration descriptor') === undefined) continue
710
+ let raw: unknown
711
+ try {
712
+ raw = JSON.parse(readFileSync(manifestFile, 'utf8'))
713
+ } catch (error) {
714
+ const message = 'skipping ' + manifestFile + ': ' + (error instanceof Error ? error.message : String(error))
715
+ options.warnings?.push(message)
716
+ options.diagnostics?.push({ level: 'error', source: entryDir, message })
717
+ continue
718
+ }
719
+ const verdict = parseDecorationManifest(raw, manifestFile)
720
+ for (const diagnostic of verdict.diagnostics) {
721
+ options.diagnostics?.push({ level: diagnostic.level, source: entryDir, message: diagnostic.message })
722
+ options.warnings?.push(diagnostic.message)
723
+ }
724
+ if (!verdict.ok) continue
725
+ const manifest = verdict.manifest
726
+ // A missing strip keeps the entry listed (mirroring the live2d closure
727
+ // discipline) but earns a diagnostic: the ornament will silently render
728
+ // nothing, and the warning names the file to fix.
729
+ if (!existsSync(join(entryDir, manifest.entry))) {
730
+ const message = 'decoration ' + manifest.id + ': strip file missing: ' + manifest.entry
731
+ options.warnings?.push(message)
732
+ options.diagnostics?.push({ level: 'warning', source: entryDir, message })
733
+ }
734
+ entries.push({
735
+ apiVersion: PET_DECORATION_API_VERSION,
736
+ id: manifest.id,
737
+ dir: entryDir,
738
+ entryPath: manifest.entry,
739
+ servable: ['decoration.json', manifest.entry],
740
+ license: manifest.license,
741
+ assetBase: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id),
742
+ entryUrl: DECORATION_ASSET_PREFIX + '/' + encodeURIComponent(manifest.id) + '/' + manifest.entry,
743
+ cell: manifest.cell,
744
+ columns: manifest.columns,
745
+ durations: manifest.durations,
746
+ loop: manifest.loop,
747
+ phases: manifest.phases,
748
+ })
749
+ }
750
+ return entries
751
+ }
752
+
383
753
  /**
384
754
  * Load the pet registry: built-in 'assets/*' first, then the hatch-pet
385
755
  * custom pets directory, then composed 'extra' manifests (each later source
@@ -389,10 +759,11 @@ function readPetJson(file: string, warnings: string[] | undefined): unknown {
389
759
  export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
390
760
  const { packageRoot, assetPrefix = '/pet' } = options
391
761
  const warnings: string[] = []
762
+ const diagnostics: PetRegistryDiagnostic[] = []
392
763
  const byId = new Map<string, PetEntry>()
393
764
  const builtinIds = new Set<string>()
394
765
 
395
- for (const entry of scanPetDir(join(packageRoot, 'assets'), { assetPrefix, warnings })) {
766
+ for (const entry of scanPetDir(join(packageRoot, 'assets'), { assetPrefix, warnings, diagnostics })) {
396
767
  if (byId.has(entry.id)) {
397
768
  warnings.push('duplicate built-in pet id ' + entry.id + '; the first one wins')
398
769
  continue
@@ -403,12 +774,24 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
403
774
 
404
775
  const petsDir = options.petsDir ?? codexPetsDir()
405
776
  if (petsDir !== '') {
406
- for (const entry of scanPetDir(petsDir, { assetPrefix, warnings })) {
777
+ for (const entry of scanPetDir(petsDir, { assetPrefix, warnings, diagnostics })) {
407
778
  if (byId.has(entry.id)) warnings.push('custom pet ' + entry.id + ' overrides the built-in one')
408
779
  byId.set(entry.id, entry)
409
780
  }
410
781
  }
411
782
 
783
+ // The pet-center user directory ranks above the legacy hatch-pet source.
784
+ const dshPetsDir = options.dshPetsDir ?? join(dshHome(), 'pets')
785
+ let globalVoice: VoicePack | undefined
786
+ if (dshPetsDir !== '') {
787
+ for (const entry of scanPetDir(dshPetsDir, { assetPrefix, warnings, diagnostics })) {
788
+ if (byId.has(entry.id)) warnings.push('user pet ' + entry.id + ' overrides an earlier registration')
789
+ byId.set(entry.id, entry)
790
+ }
791
+ // The global voice override layers under every per-pet pack (M4, #677).
792
+ globalVoice = loadVoicePackFile(join(dshPetsDir, '.voice.json'), { warnings, diagnostics })
793
+ }
794
+
412
795
  for (const manifest of options.extra ?? []) {
413
796
  const raw = manifest.spritesheetPath
414
797
  const dir = raw === undefined || isAbsolute(raw)
@@ -427,21 +810,69 @@ export function loadPetRegistry(options: PetRegistryOptions): PetRegistry {
427
810
  byId.set(entry.id, entry)
428
811
  }
429
812
 
813
+ // Status decorations (pet-center M5, #567): built-in entries first,
814
+ // then same-id user entries under '$DSH_HOME/pets/decorations' override.
815
+ const decorationById = new Map<string, DecorationEntry>()
816
+ for (const entry of scanDecorationDir(join(packageRoot, 'assets', 'decorations'), { warnings, diagnostics })) {
817
+ decorationById.set(entry.id, entry)
818
+ }
819
+ if (dshPetsDir !== '') {
820
+ for (const entry of scanDecorationDir(join(dshPetsDir, 'decorations'), { warnings, diagnostics })) {
821
+ if (decorationById.has(entry.id)) {
822
+ warnings.push('user decoration ' + entry.id + ' overrides the built-in one')
823
+ }
824
+ decorationById.set(entry.id, entry)
825
+ }
826
+ }
827
+
430
828
  const entries = [...byId.values()]
829
+ const decorations = [...decorationById.values()]
431
830
  return {
432
831
  entries,
433
832
  warnings,
833
+ diagnostics,
434
834
  byId: (id: string) => byId.get(id),
435
835
  defaultEntry: () => entries.find(entry => builtinIds.has(entry.id)) ?? entries[0]!,
836
+ ...(globalVoice === undefined ? {} : { globalVoice }),
837
+ decorations,
838
+ decorationById: (id: string) => decorationById.get(id),
436
839
  }
437
840
  }
438
841
 
439
- /** Strip host-only fields, leaving the client-visible definition. */
440
- export function petEntryView(entry: PetEntry): PetDefinition {
842
+ /** The built-in default decoration id (M5): the first reference ornament. */
843
+ export const DEFAULT_DECORATION_ID = 'whale'
844
+
845
+ /** Strip host-only fields, leaving the browser-visible decoration view. */
846
+ export function decorationView(entry: DecorationEntry): DecorationView {
847
+ return {
848
+ apiVersion: PET_DECORATION_API_VERSION,
849
+ id: entry.id,
850
+ assetBase: entry.assetBase,
851
+ entryUrl: entry.entryUrl,
852
+ cell: entry.cell,
853
+ columns: entry.columns,
854
+ durations: entry.durations,
855
+ loop: entry.loop,
856
+ phases: entry.phases,
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Strip host-only fields, leaving the client-visible definition. When the
862
+ * registry carries a global voice pack, its panel chrome layers under the
863
+ * entry's own pack (per-slot merge, pet > global), mirroring the voice-pool
864
+ * layering (pet-center M4, issue #677).
865
+ */
866
+ export function petEntryView(entry: PetEntry, globalVoice?: VoicePack): PetDefinition {
867
+ const panel = globalVoice === undefined
868
+ ? entry.voice?.panel
869
+ : mergeVoicePacks(globalVoice, entry.voice)?.panel
441
870
  return {
442
871
  id: entry.id,
443
872
  displayName: entry.displayName,
444
873
  description: entry.description,
874
+ renderer: entry.renderer,
875
+ ...(entry.live2d === undefined ? {} : { live2d: entry.live2d }),
445
876
  cell: entry.cell,
446
877
  columns: entry.columns,
447
878
  rows: entry.rows,
@@ -450,6 +881,7 @@ export function petEntryView(entry: PetEntry): PetDefinition {
450
881
  ...(entry.sequences === undefined ? {} : { sequences: entry.sequences }),
451
882
  atlasUrl: entry.atlasUrl,
452
883
  manifestUrl: entry.manifestUrl,
884
+ ...(panel === undefined ? {} : { panel }),
453
885
  }
454
886
  }
455
887