unity-open-mcp 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. package/dist/capabilities/cost-hints.js +73 -0
  2. package/dist/capabilities/cost-hints.js.map +1 -1
  3. package/dist/capabilities/intent-groups.js +76 -4
  4. package/dist/capabilities/intent-groups.js.map +1 -1
  5. package/dist/capabilities/lifecycle.js +9 -0
  6. package/dist/capabilities/lifecycle.js.map +1 -1
  7. package/dist/capabilities/tool-groups.js +75 -17
  8. package/dist/capabilities/tool-groups.js.map +1 -1
  9. package/dist/compressible-router.d.ts +4 -1
  10. package/dist/compressible-router.js +27 -11
  11. package/dist/compressible-router.js.map +1 -1
  12. package/dist/constants.d.ts +2 -2
  13. package/dist/constants.js +2 -2
  14. package/dist/event-stream.d.ts +10 -0
  15. package/dist/event-stream.js +29 -0
  16. package/dist/event-stream.js.map +1 -1
  17. package/dist/game-view-settle.d.ts +18 -0
  18. package/dist/game-view-settle.js +81 -0
  19. package/dist/game-view-settle.js.map +1 -0
  20. package/dist/jobs/adapters.d.ts +59 -0
  21. package/dist/jobs/adapters.js +226 -0
  22. package/dist/jobs/adapters.js.map +1 -1
  23. package/dist/live-client.d.ts +17 -60
  24. package/dist/live-client.js +52 -87
  25. package/dist/live-client.js.map +1 -1
  26. package/dist/local-tools.js +3 -0
  27. package/dist/local-tools.js.map +1 -1
  28. package/dist/offline/api.d.ts +2 -0
  29. package/dist/offline/api.js +4 -2
  30. package/dist/offline/api.js.map +1 -1
  31. package/dist/offline/model.js +1 -1
  32. package/dist/offline/model.js.map +1 -1
  33. package/dist/offline/parse.d.ts +1 -1
  34. package/dist/offline/parse.js +20 -8
  35. package/dist/offline/parse.js.map +1 -1
  36. package/dist/playmode-routes.d.ts +17 -0
  37. package/dist/playmode-routes.js +50 -0
  38. package/dist/playmode-routes.js.map +1 -0
  39. package/dist/playtest/png.d.ts +27 -0
  40. package/dist/playtest/png.js +248 -0
  41. package/dist/playtest/png.js.map +1 -0
  42. package/dist/playtest/recorder.d.ts +203 -0
  43. package/dist/playtest/recorder.js +945 -0
  44. package/dist/playtest/recorder.js.map +1 -0
  45. package/dist/playtest/redact.d.ts +16 -0
  46. package/dist/playtest/redact.js +124 -0
  47. package/dist/playtest/redact.js.map +1 -0
  48. package/dist/playtest/report.d.ts +73 -0
  49. package/dist/playtest/report.js +477 -0
  50. package/dist/playtest/report.js.map +1 -0
  51. package/dist/playtest/session-store.d.ts +78 -0
  52. package/dist/playtest/session-store.js +224 -0
  53. package/dist/playtest/session-store.js.map +1 -0
  54. package/dist/skill/SKILL.md +22 -3
  55. package/dist/skill/references/senses-and-tests.md +3 -0
  56. package/dist/skill/references/yaml-and-offline-work.md +1 -1
  57. package/dist/tool-router.d.ts +44 -0
  58. package/dist/tool-router.js +249 -7
  59. package/dist/tool-router.js.map +1 -1
  60. package/dist/tools/batch-execute.js +5 -1
  61. package/dist/tools/batch-execute.js.map +1 -1
  62. package/dist/tools/capture-inline.js +30 -12
  63. package/dist/tools/capture-inline.js.map +1 -1
  64. package/dist/tools/capture-series.d.ts +46 -0
  65. package/dist/tools/capture-series.js +116 -0
  66. package/dist/tools/capture-series.js.map +1 -0
  67. package/dist/tools/game-view.d.ts +44 -0
  68. package/dist/tools/game-view.js +103 -0
  69. package/dist/tools/game-view.js.map +1 -0
  70. package/dist/tools/index.d.ts +1 -0
  71. package/dist/tools/index.js +34 -7
  72. package/dist/tools/index.js.map +1 -1
  73. package/dist/tools/inputsim-gesture.d.ts +46 -0
  74. package/dist/tools/inputsim-gesture.js +123 -0
  75. package/dist/tools/inputsim-gesture.js.map +1 -0
  76. package/dist/tools/inputsim-pointer.js +35 -2
  77. package/dist/tools/inputsim-pointer.js.map +1 -1
  78. package/dist/tools/inputsim-touch.js +42 -22
  79. package/dist/tools/inputsim-touch.js.map +1 -1
  80. package/dist/tools/jobs.js +1 -1
  81. package/dist/tools/jobs.js.map +1 -1
  82. package/dist/tools/player-state.d.ts +42 -0
  83. package/dist/tools/player-state.js +142 -0
  84. package/dist/tools/player-state.js.map +1 -0
  85. package/dist/tools/playtest-conditions.d.ts +334 -0
  86. package/dist/tools/playtest-conditions.js +99 -0
  87. package/dist/tools/playtest-conditions.js.map +1 -0
  88. package/dist/tools/playtest-session.d.ts +45 -0
  89. package/dist/tools/playtest-session.js +105 -0
  90. package/dist/tools/playtest-session.js.map +1 -0
  91. package/dist/tools/playtest-wait.d.ts +46 -0
  92. package/dist/tools/playtest-wait.js +86 -0
  93. package/dist/tools/playtest-wait.js.map +1 -0
  94. package/dist/tools/screenshot.js +31 -8
  95. package/dist/tools/screenshot.js.map +1 -1
  96. package/dist/tools/text-target.d.ts +22 -0
  97. package/dist/tools/text-target.js +39 -0
  98. package/dist/tools/text-target.js.map +1 -0
  99. package/dist/tools/ui-snapshot.d.ts +41 -0
  100. package/dist/tools/ui-snapshot.js +87 -0
  101. package/dist/tools/ui-snapshot.js.map +1 -0
  102. package/package.json +1 -1
@@ -1,4 +1,7 @@
1
- import { testRunOperation, projectCommandOperation } from "./jobs/adapters.js";
1
+ import { randomUUID } from "node:crypto";
2
+ import { testRunOperation, projectCommandOperation, playmodeOperation, pollPlaymodeOperation, playmodeResult, toolOutput, toolResultBody, PLAYMODE_OPERATION_TOOLS, playmodeEstimateMs, playmodeFailureCode } from "./jobs/adapters.js";
3
+ import { PLAYMODE_ROUTES } from "./playmode-routes.js";
4
+ import { routeGameView as settleGameView } from "./game-view-settle.js";
2
5
  import { JobManager, JobManagerError } from "./jobs/job-manager.js";
3
6
  import { handleJobs } from "./jobs/handler.js";
4
7
  import { PROCESS_AGENT_ID, PROCESS_IDENTITY } from "./agent-identity.js";
@@ -29,8 +32,12 @@ import { generateSkill } from "./skill/generate-skill.js";
29
32
  import { getKnownClientKeys } from "./skill/client-paths.js";
30
33
  import { ALL_TOOLS } from "./tools/index.js";
31
34
  import { lockPath, readInstanceLock, classifyInstance, isPidAlive } from "./instance-discovery.js";
32
- import { PORT_ENV_VAR, EXPECTED_BRIDGE_WIRE_CONTRACT } from "./constants.js";
35
+ import { PORT_ENV_VAR, EXPECTED_BRIDGE_WIRE_CONTRACT, BRIDGE_HOST_SAFE_TIMEOUT_CAP_MS } from "./constants.js";
33
36
  import { findUnityForProject, readProcessCommandLine } from "./running-unity.js";
37
+ import { PlaytestRecorder, PlaytestError, PLAYTEST_SESSION_TOOL } from "./playtest/recorder.js";
38
+ import { secretEnvValues } from "./playtest/redact.js";
39
+ import { sessionsRoot } from "./playtest/session-store.js";
40
+ import { PLAYTEST_SESSION_ACTION_KEYS } from "./tools/playtest-session.js";
34
41
  // M31-optimizations Plan 1 / L14 — opt-in route-logging gate. Resolved once at
35
42
  // module load into a boolean; the per-dispatch log sites read the boolean
36
43
  // (not process.env on every call) so the hot path stays a single branch.
@@ -573,6 +580,11 @@ export class ToolRouter {
573
580
  if (!this.jobs.hasOperation(name))
574
581
  this.jobs.register(name, testRunOperation(clientFor));
575
582
  }
583
+ else if (PLAYMODE_OPERATION_TOOLS.has(name)) {
584
+ // One generic adapter for every tool on the bridge's shared play-mode scheduler.
585
+ if (!this.jobs.hasOperation(name))
586
+ this.jobs.register(name, playmodeOperation(name, PLAYMODE_OPERATION_TOOLS.get(name), clientFor));
587
+ }
576
588
  else if (name.startsWith("project.") && !(typeof key === "string" && this.jobs.hasKey(owner, key))) {
577
589
  // A retry whose key already names a job gets that job (or
578
590
  // idempotency_conflict) from start() without the catalog, which may
@@ -602,6 +614,80 @@ export class ToolRouter {
602
614
  return localError(error instanceof JobManagerError ? error.code : "invalid_arguments", error instanceof Error ? error.message : String(error));
603
615
  }
604
616
  }
617
+ /**
618
+ * Play-mode operations (`inputsim_gesture`, `playtest_wait`,
619
+ * `capture_series`): the bridge starts the operation on its shared play-mode
620
+ * scheduler and answers at once; this route polls POST /playmode-operations
621
+ * until the terminal result, inside the host-safe window. An operation whose
622
+ * worst case (`playmodeEstimateMs`: last keyframe / capture, the wait's
623
+ * timeout, a series' start_on wait + schedule + encoding) exceeds the inline
624
+ * budget is started as a `jobs` play-mode operation instead and the call
625
+ * returns that job id. One that outlives the inline poll anyway (gameplay
626
+ * time, a paused Editor) is cancelled — which releases held input and
627
+ * restores a wait's time scale — and the error points at the jobs route.
628
+ */
629
+ async routePlaymodeOperation(name, args, live, identity) {
630
+ const spec = PLAYMODE_ROUTES[name];
631
+ const estimateMs = playmodeEstimateMs(name, args);
632
+ if (estimateMs > spec.inlineLimitMs) {
633
+ // The job owns the bridge operation id; a caller's operation_id keeps
634
+ // its retry meaning as the job's idempotency key.
635
+ const { operation_id: retryKey, ...jobArgs } = args;
636
+ const started = await this.routeJobs({ action: "start", tool_or_command: name, args: jobArgs,
637
+ ...(typeof retryKey === "string" ? { idempotency_key: retryKey } : {}) }, identity);
638
+ const body = toolResultBody(started);
639
+ if (started.isError || typeof body.job_id !== "string")
640
+ return started;
641
+ return sourceResult({ ...body, note: `${spec.label} can take ${Math.round(estimateMs)} ms (> ${spec.inlineLimitMs} ms inline budget); it runs as a job.`,
642
+ agentNextSteps: [`Call unity_open_mcp_jobs {"action":"wait","job_id":"${body.job_id}","timeout_ms":30000} until the state is terminal; the result holds ${spec.resultHint}.`] }, "local");
643
+ }
644
+ const operationId = typeof args.operation_id === "string" ? args.operation_id : randomUUID();
645
+ const start = await live.route(name, { ...args, operation_id: operationId });
646
+ const started = toolOutput(start);
647
+ if (start.isError || started.error || !started.operation)
648
+ return injectRouteMeta(start, { route: "live" });
649
+ const outcome = await pollPlaymodeOperation(live, operationId, {
650
+ deadlineMs: Math.min(estimateMs + 15_000, BRIDGE_HOST_SAFE_TIMEOUT_CAP_MS - 5_000),
651
+ pollIntervalMs: estimateMs < 2_000 ? 50 : 100,
652
+ unreachableMs: 10_000,
653
+ });
654
+ const meta = spec.startMeta(started);
655
+ // The poll's own deadline cancel, acknowledged: the operation stalled (a
656
+ // paused Editor never ticks it), it did not end by itself.
657
+ const stalled = outcome.kind === "deadline" || (outcome.kind === "terminal" && outcome.cancelSent && outcome.snapshot.state === "cancelled");
658
+ if (stalled) {
659
+ return sourceResult({ error: { code: spec.stalledCode, message: `The ${spec.noun} did not finish inside the inline window (a paused Editor, gameplay time, or a slow frame rate); it was cancelled and its cleanup ${spec.cleanup}.` },
660
+ operationId, last: outcome.snapshot, agentNextSteps: [`Check that play mode is running (not paused) and the Game view renders, then retry — or run it as a job: unity_open_mcp_jobs {"action":"start","tool_or_command":"${name}","args":{…}}, then wait on the job id.`] }, "live", true);
661
+ }
662
+ switch (outcome.kind) {
663
+ case "lost":
664
+ return sourceResult({ error: { code: "operation_lost", message: `The bridge lost the ${spec.noun} (domain reload or Editor restart); its cleanup ${spec.cleanup}.` }, operationId }, "live", true);
665
+ case "unreachable":
666
+ return sourceResult({ error: { code: "job_status_unavailable", message: outcome.error }, operationId }, "live", true);
667
+ }
668
+ if (outcome.kind !== "terminal")
669
+ throw new Error("unreachable play-mode poll outcome");
670
+ const snapshot = outcome.snapshot;
671
+ const result = { ...meta, ...playmodeResult(snapshot) };
672
+ if (snapshot.state === "succeeded")
673
+ return sourceResult({ status: "ok", ...result }, "live");
674
+ const code = playmodeFailureCode(snapshot, spec.failedCode);
675
+ // The operation's own `error` (a message string) becomes error.message.
676
+ return sourceResult({ ...result, error: { code, message: String(snapshot.result?.error ?? `${spec.label} ended ${snapshot.reason ?? snapshot.state}; its cleanup ${spec.cleanup}.`) },
677
+ ...(spec.failureNextSteps(code) ? { agentNextSteps: spec.failureNextSteps(code) } : {}) }, "live", true);
678
+ }
679
+ /**
680
+ * `game_view`: set_resolution / set_device in play mode return once the game
681
+ * reports the new size — the bridge starts a settle watch on its play-mode
682
+ * scheduler and this route polls it (then re-reads `get`), or answers
683
+ * `not_settled`. get / list_devices and edit-mode changes pass through.
684
+ */
685
+ async routeGameView(args, live) {
686
+ const outcome = await settleGameView(live, args);
687
+ if (outcome.kind === "passthrough")
688
+ return injectRouteMeta(outcome.result, { route: "live" });
689
+ return sourceResult(outcome.body, "live", outcome.isError);
690
+ }
605
691
  modelCache = new AssetModelCache();
606
692
  /**
607
693
  * M28-refactoring Plan 3 (T3.2) — named-tool dispatch map. Replaces the
@@ -628,6 +714,9 @@ export class ToolRouter {
628
714
  ["unity_open_mcp_generate_skill", (_l, a) => this.routeGenerateSkill(a)],
629
715
  ["unity_open_mcp_manage_tools", (l, a, id) => this.routeManageTools(a, l, id)],
630
716
  ["unity_open_mcp_project_commands", (l, a) => this.routeProjectCommands(a, l)],
717
+ // Playtest session recorder: the log lives here (the server sees every
718
+ // call); bridge reads go through the normal route as the same owner.
719
+ [PLAYTEST_SESSION_TOOL, (l, a, id) => this.routePlaytestSession(a, l, id)],
631
720
  // Live tool whose release lookups (npm latest, GitHub tag) run HERE so
632
721
  // the Editor main thread never blocks on the network.
633
722
  ["unity_open_mcp_upgrade", (l, a) => this.routeUpgrade(a, l)],
@@ -636,6 +725,15 @@ export class ToolRouter {
636
725
  // the process agent id, whatever _meta.agentId the call carried. Direct,
637
726
  // manage_tools(invoke) and port-override calls therefore share one owner.
638
727
  ["unity_open_mcp_jobs", (_l, a, id) => this.routeJobs(a, id)],
728
+ // Gestures, waits and capture series run on the bridge's play-mode
729
+ // scheduler: start, then poll the operation to its terminal result here
730
+ // (long ones become a job).
731
+ ["unity_open_mcp_inputsim_gesture", (l, a, id) => this.routePlaymodeOperation("unity_open_mcp_inputsim_gesture", a, l, id)],
732
+ ["unity_open_mcp_playtest_wait", (l, a, id) => this.routePlaymodeOperation("unity_open_mcp_playtest_wait", a, l, id)],
733
+ ["unity_senses_capture_series", (l, a, id) => this.routePlaymodeOperation("unity_senses_capture_series", a, l, id)],
734
+ // Game view / Simulator changes settle over play-mode frames: poll the
735
+ // bridge's settle watch here so set_* returns once the game sees the size.
736
+ ["unity_open_mcp_game_view", (l, a) => this.routeGameView(a, l)],
639
737
  ["unity_open_mcp_bridge_status", (l, a) => this.routeBridgeStatus(a, l)],
640
738
  // M31 Plan 3 — Editor fd-exhaustion operator surfaces. restart_editor
641
739
  // is the reactive kill half (acts AFTER the Editor is hung);
@@ -756,7 +854,142 @@ export class ToolRouter {
756
854
  async routeOverride(toolName, args, overrideLive, identity) {
757
855
  return this.routeCore(overrideLive, toolName, args, identity);
758
856
  }
857
+ /**
858
+ * Every entry point funnels through here (direct, manage_tools invoke, port
859
+ * override). While the caller's agent owner has an open playtest session, a
860
+ * recorded call (input / wait / capture / project command / play-mode and
861
+ * Game view changes) is logged around its dispatch; everything else — and
862
+ * every call when no session is open — goes straight through. The recorder
863
+ * never changes a result and never fails a call.
864
+ */
759
865
  async routeCore(live, toolName, args, identity) {
866
+ const recorder = this.playtestRecorder;
867
+ if (!recorder || recorder.openCount === 0 || toolName === PLAYTEST_SESSION_TOOL)
868
+ return this.routeDispatch(live, toolName, args, identity);
869
+ const owner = this.ownerOf(identity);
870
+ if (!recorder.isRecording(owner))
871
+ return this.routeDispatch(live, toolName, args, identity);
872
+ if (toolName === "unity_open_mcp_jobs" && (args.action === "wait" || args.action === "status")) {
873
+ const observed = await this.routeDispatch(live, toolName, args, identity);
874
+ try {
875
+ recorder.observeJob(owner, args, observed);
876
+ }
877
+ catch (error) {
878
+ console.error("[unity-open-mcp] playtest recorder:", error);
879
+ }
880
+ return observed;
881
+ }
882
+ let handle = null;
883
+ try {
884
+ handle = recorder.begin(owner, toolName, args);
885
+ }
886
+ catch (error) {
887
+ console.error("[unity-open-mcp] playtest recorder:", error);
888
+ }
889
+ if (!handle)
890
+ return this.routeDispatch(live, toolName, args, identity);
891
+ this.eventStream.reconnectNow?.call(this.eventStream);
892
+ let result;
893
+ try {
894
+ result = await this.routeDispatch(live, toolName, args, identity);
895
+ }
896
+ catch (error) {
897
+ try {
898
+ recorder.finish(handle, args, null, error);
899
+ }
900
+ catch (inner) {
901
+ console.error("[unity-open-mcp] playtest recorder:", inner);
902
+ }
903
+ throw error;
904
+ }
905
+ try {
906
+ recorder.finish(handle, args, result);
907
+ }
908
+ catch (error) {
909
+ console.error("[unity-open-mcp] playtest recorder:", error);
910
+ }
911
+ return result;
912
+ }
913
+ /** The owner a request files sessions (and jobs) under. */
914
+ ownerOf(identity) {
915
+ return { project: this.projectPath || "default", agent: identity.agent, ...(identity.port === undefined ? {} : { port: identity.port }) };
916
+ }
917
+ playtestRecorder;
918
+ /** The session recorder, created on first use and fed by the bridge event stream. */
919
+ get playtest() {
920
+ if (!this.playtestRecorder) {
921
+ this.usePlaytestRecorder(new PlaytestRecorder({
922
+ root: sessionsRoot(),
923
+ projectPath: this.projectPath || "default",
924
+ knownSecrets: [this.projectPath ? resolveAuthToken(this.projectPath) : undefined, ...secretEnvValues()],
925
+ streamConnected: () => this.eventStream.isConnected === true,
926
+ }));
927
+ }
928
+ return this.playtestRecorder;
929
+ }
930
+ /** Install a recorder (tests use a temporary root). */
931
+ usePlaytestRecorder(recorder) {
932
+ this.playtestRecorder = recorder;
933
+ this.eventStream.addListener?.call(this.eventStream, event => recorder.onEvent(event));
934
+ }
935
+ /**
936
+ * `unity_open_mcp_playtest_session` — local route. Bridge reads the session
937
+ * needs (company / product, the Game view state, a player-state snapshot or
938
+ * diff) go through the normal route as the same owner, and only when the
939
+ * bridge answers /ping; otherwise the recorder falls back to disk.
940
+ */
941
+ async routePlaytestSession(args, live, identity) {
942
+ const action = String(args.action ?? "");
943
+ const allowed = PLAYTEST_SESSION_ACTION_KEYS[action];
944
+ if (!allowed)
945
+ return localError("invalid_arguments", `Unknown action '${action}'.`);
946
+ const extra = Object.keys(args).filter(key => key !== "action" && !allowed.includes(key));
947
+ if (extra.length)
948
+ return localError("invalid_arguments", `Keys not used by ${action}: ${extra.join(", ")}.`);
949
+ const recorder = this.playtest;
950
+ const owner = this.ownerOf(identity);
951
+ let available;
952
+ const call = async (tool, toolArgs) => {
953
+ available ??= await live.isLiveAvailable(1_500);
954
+ if (!available)
955
+ return localError("bridge_unavailable", "The Unity bridge is not reachable.");
956
+ const definition = ALL_TOOLS.find(t => t.name === tool);
957
+ return this.routeDispatch(live, tool, definition ? withSchemaDefaults(definition, toolArgs) : toolArgs, identity);
958
+ };
959
+ const sessionArg = typeof args.session === "string" ? args.session : undefined;
960
+ try {
961
+ if (action !== "status" || recorder.isRecording(owner) === false)
962
+ await recorder.prepare(call);
963
+ if (action === "start" || action === "resume" || action === "end")
964
+ this.eventStream.ensureSubscription?.call(this.eventStream);
965
+ let body;
966
+ switch (action) {
967
+ case "start":
968
+ body = await recorder.start(owner, args, call);
969
+ break;
970
+ case "mark":
971
+ body = recorder.mark(owner, args, sessionArg);
972
+ break;
973
+ case "status":
974
+ body = recorder.status(owner);
975
+ break;
976
+ case "resume":
977
+ body = recorder.resume(owner, sessionArg);
978
+ break;
979
+ case "end":
980
+ body = await recorder.end(owner, args, call);
981
+ break;
982
+ default: body = recorder.list(owner, typeof args.limit === "number" ? args.limit : 10);
983
+ }
984
+ return sourceResult(body, "local");
985
+ }
986
+ catch (error) {
987
+ if (error instanceof PlaytestError)
988
+ return sourceResult({ error: { code: error.code, message: error.message }, ...error.detail }, "local", true);
989
+ return localError("internal_error", error instanceof Error ? error.message : String(error));
990
+ }
991
+ }
992
+ async routeDispatch(live, toolName, args, identity) {
760
993
  // Every entry point funnels through here, so an explicit project_path is
761
994
  // retargeted before validation and wiring wherever the call came from.
762
995
  if ((toolName === "unity_open_mcp_capabilities" || toolName === "unity_open_mcp_bridge_status") && args.project_path !== undefined)
@@ -3166,15 +3399,24 @@ function pageSceneNodes(body, pageSize, cursor) {
3166
3399
  */
3167
3400
  function flattenSceneBody(body) {
3168
3401
  const out = [];
3169
- const walk = (node) => {
3402
+ // Rows are flat (no nested `children`), so each carries its hierarchy
3403
+ // `path` — otherwise same-named nodes on a page cannot be told apart or
3404
+ // addressed once a depth limit or truncation desyncs `childCount`.
3405
+ const walk = (node, parentPath) => {
3170
3406
  if (!node || typeof node !== "object")
3171
3407
  return;
3172
3408
  const obj = node;
3173
- out.push(obj);
3174
- const children = obj.children;
3409
+ const { children, ...flatNode } = obj;
3410
+ const name = typeof obj.name === "string" ? obj.name : undefined;
3411
+ const path = typeof obj.path === "string"
3412
+ ? obj.path
3413
+ : name === undefined ? undefined : parentPath === undefined ? name : `${parentPath}/${name}`;
3414
+ if (path !== undefined && flatNode.path === undefined)
3415
+ flatNode.path = path;
3416
+ out.push(flatNode);
3175
3417
  if (Array.isArray(children)) {
3176
3418
  for (const child of children)
3177
- walk(child);
3419
+ walk(child, path);
3178
3420
  }
3179
3421
  };
3180
3422
  // M3 — the bridge nests everything one level down under `body.scene`
@@ -3191,7 +3433,7 @@ function flattenSceneBody(body) {
3191
3433
  const rootField = Array.isArray(scene.roots) ? scene.roots : scene.rootGameObjects;
3192
3434
  if (Array.isArray(rootField)) {
3193
3435
  for (const root of rootField)
3194
- walk(root);
3436
+ walk(root, undefined);
3195
3437
  }
3196
3438
  return out;
3197
3439
  }