@genex-ai/cli-demo 0.38.0 → 0.40.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.
package/README.md CHANGED
@@ -15,7 +15,7 @@ genex sfx "<prompt>" # generate a sound fx → prints an asset URL
15
15
  genex texture "<prompt>" # generate a texture → prints an asset URL
16
16
  genex image "<prompt>" # generate an image → prints an asset URL
17
17
  genex video "<prompt>" # generate a video → prints an asset URL
18
- genex controller <type> # install a tuned character|car|drone controller → src/controllers/
18
+ genex controller <type> # character|car|drone|networked-physics → src/controllers/
19
19
  genex controller anims <sel…> # download extra character animation clips (by tag or name) → public/assets/anims/
20
20
  ```
21
21
 
@@ -184,6 +184,27 @@ GENEX_BROWSER="open -a Safari" genex init
184
184
  GENEX_BROWSER="'/Applications/My Browser.app/Contents/MacOS/My Browser'" genex init
185
185
  ```
186
186
 
187
+ ## Crash reporting
188
+
189
+ When a command hits an **unexpected** error, the CLI reports the crash to Sentry
190
+ so we can fix it. Expected failures (missing token, a rejected API request, bad
191
+ input) are just printed — they're not sent. Before anything leaves your machine
192
+ it's scrubbed: your `GENEX_TOKEN` and any credentials, credential-bearing URLs
193
+ (e.g. the per-push git URL), your home-directory paths and hostname, and
194
+ stack-frame locals are all stripped ([`src/lib/sentry-scrub.ts`](src/lib/sentry-scrub.ts)).
195
+ No IP address or account identity is attached. Only the crash, the command name,
196
+ and your CLI/Node/OS versions are sent.
197
+
198
+ Opt out any time:
199
+
200
+ ```bash
201
+ export GENEX_TELEMETRY=0 # our switch (on by default)
202
+ export DO_NOT_TRACK=1 # the cross-tool standard — any value disables it
203
+ ```
204
+
205
+ Reporting is also off whenever no DSN is baked into the build (the default in
206
+ local/source runs), so nothing is sent during development.
207
+
187
208
  ## How authorization works
188
209
 
189
210
  The CLI uses a loopback-redirect flow (the same pattern as `gh auth login` and
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/commands/init.ts
4
- import path8 from "path";
3
+ // src/instrument.ts
4
+ import * as Sentry from "@sentry/node";
5
5
 
6
6
  // src/config.ts
7
7
  import fs from "fs";
@@ -89,6 +89,107 @@ function isDir(p) {
89
89
  }
90
90
  }
91
91
 
92
+ // src/lib/sentry-scrub.ts
93
+ import os2 from "os";
94
+ var BEARER = /\bBearer\s+[A-Za-z0-9._~+/-]+=*/gi;
95
+ var URL_CRED = /(https?:\/\/)[^/@\s:]+(?::[^/@\s]*)?@/gi;
96
+ var TOKENISH_KV = /((?:token|secret|password|api[_-]?key|authorization)=)[^&#\s"']+/gi;
97
+ var SENSITIVE_KEY = /token|secret|password|api[_-]?key|authorization|bearer/i;
98
+ function scrubString(s) {
99
+ return s.replace(URL_CRED, "$1[redacted]@").replace(BEARER, "Bearer [redacted]").replace(TOKENISH_KV, "$1[redacted]");
100
+ }
101
+ function redactHome(s, home = os2.homedir()) {
102
+ if (!home) return s;
103
+ return s.split(home).join("~");
104
+ }
105
+ var clean = (s) => redactHome(scrubString(s));
106
+ function scrubEvent(event) {
107
+ delete event.user;
108
+ delete event.server_name;
109
+ if (typeof event.message === "string") event.message = clean(event.message);
110
+ for (const ex of event.exception?.values ?? []) {
111
+ if (typeof ex.value === "string") ex.value = clean(ex.value);
112
+ for (const f of ex.stacktrace?.frames ?? []) {
113
+ if (typeof f.filename === "string") f.filename = redactHome(f.filename);
114
+ if (typeof f.abs_path === "string") f.abs_path = redactHome(f.abs_path);
115
+ delete f.vars;
116
+ }
117
+ }
118
+ if (event.request && typeof event.request.url === "string") {
119
+ event.request.url = clean(event.request.url);
120
+ }
121
+ for (const b of event.breadcrumbs ?? []) {
122
+ if (typeof b.message === "string") b.message = clean(b.message);
123
+ const d = b.data;
124
+ if (d) {
125
+ for (const k of Object.keys(d)) {
126
+ if (typeof d[k] === "string") d[k] = clean(d[k]);
127
+ }
128
+ }
129
+ }
130
+ for (const bag of [event.extra, event.tags]) {
131
+ if (!bag) continue;
132
+ for (const k of Object.keys(bag)) {
133
+ if (SENSITIVE_KEY.test(k)) bag[k] = "[Filtered]";
134
+ else if (typeof bag[k] === "string") bag[k] = clean(bag[k]);
135
+ }
136
+ }
137
+ return event;
138
+ }
139
+
140
+ // src/instrument.ts
141
+ var GENEX_CLI_DSN = "https://43efc6f7d16c3e67cad6c60fa8175c20@o4511115493900288.ingest.us.sentry.io/4511706579599360";
142
+ function bakedDsn() {
143
+ return import.meta.url.includes("/dist/") ? GENEX_CLI_DSN : "";
144
+ }
145
+ function resolveDsn() {
146
+ return (process.env.GENEX_SENTRY_DSN || bakedDsn()).trim();
147
+ }
148
+ function isTruthy(v) {
149
+ return v !== void 0 && v.trim() !== "" && !/^(0|false|off|no)$/i.test(v.trim());
150
+ }
151
+ function telemetryDisabled() {
152
+ if (isTruthy(process.env.DO_NOT_TRACK)) return true;
153
+ if (process.env.GENEX_TELEMETRY !== void 0 && !isTruthy(process.env.GENEX_TELEMETRY)) return true;
154
+ if (isTruthy(process.env.GENEX_DISABLE_SENTRY)) return true;
155
+ return false;
156
+ }
157
+ var dsn = resolveDsn();
158
+ var sentryEnabled = !!dsn && !telemetryDisabled();
159
+ if (sentryEnabled) {
160
+ Sentry.init({
161
+ dsn,
162
+ // release aligns with the AG-757 `x-genex-cli-version` telemetry stream so
163
+ // the two correlate; environment is prod unless pointed at a non-default API.
164
+ release: `@genex-ai/cli-demo@${getCliVersion()}`,
165
+ environment: getApiUrl() === DEFAULT_API_URL ? "production" : "development",
166
+ // A short-lived CLI has no meaningful tracing workload — errors only.
167
+ tracesSampleRate: 0,
168
+ // No IP / user auto-collection (opposite of the server apps' `userInfo: true`).
169
+ dataCollection: { userInfo: false },
170
+ // Keep @sentry/node's default onUncaughtException + onUnhandledRejection
171
+ // integrations (auto-registered) — they catch the fire-and-forget async
172
+ // paths in lib/updates.ts / lib/deploy.ts that otherwise swallow errors.
173
+ beforeSend(event) {
174
+ scrubEvent(event);
175
+ return event;
176
+ }
177
+ });
178
+ }
179
+ async function flushSentry(timeoutMs = 2e3) {
180
+ if (!sentryEnabled) return;
181
+ try {
182
+ await Sentry.flush(timeoutMs);
183
+ } catch {
184
+ }
185
+ }
186
+
187
+ // src/index.ts
188
+ import * as Sentry2 from "@sentry/node";
189
+
190
+ // src/commands/init.ts
191
+ import path8 from "path";
192
+
92
193
  // src/lib/copy-templates.ts
93
194
  import fs2 from "fs/promises";
94
195
  import path2 from "path";
@@ -604,10 +705,10 @@ var structuredPrinted = /* @__PURE__ */ new WeakSet();
604
705
  function printedStructuredError(res) {
605
706
  return structuredPrinted.has(res);
606
707
  }
607
- async function apiFetch(url, init = {}) {
608
- const headers = new Headers(init.headers);
708
+ async function apiFetch(url, init2 = {}) {
709
+ const headers = new Headers(init2.headers);
609
710
  if (!headers.has(CLI_VERSION_HEADER)) headers.set(CLI_VERSION_HEADER, getCliVersion());
610
- const res = await fetch(url, { ...init, headers });
711
+ const res = await fetch(url, { ...init2, headers });
611
712
  if (res.status === 426) {
612
713
  try {
613
714
  const body = await res.clone().json();
@@ -1157,7 +1258,7 @@ async function listOwnSlugs(apiUrl, token, log) {
1157
1258
  import { spawn as spawn3 } from "child_process";
1158
1259
  import crypto3 from "crypto";
1159
1260
  import fs9 from "fs/promises";
1160
- import os2 from "os";
1261
+ import os3 from "os";
1161
1262
  import path10 from "path";
1162
1263
  function run(cmd, args, env) {
1163
1264
  return new Promise((resolve) => {
@@ -1354,7 +1455,7 @@ async function pushWorktree(cwd, pushUrl, managed, log) {
1354
1455
  log.error("Couldn't save your game's source \u2014 please try again.");
1355
1456
  return false;
1356
1457
  };
1357
- const gitDir = await fs9.mkdtemp(path10.join(os2.tmpdir(), "genex-source-"));
1458
+ const gitDir = await fs9.mkdtemp(path10.join(os3.tmpdir(), "genex-source-"));
1358
1459
  const base = { GIT_DIR: gitDir };
1359
1460
  const ident = {
1360
1461
  GIT_AUTHOR_NAME: "genex",
@@ -2264,7 +2365,12 @@ function formatMb(bytes) {
2264
2365
  }
2265
2366
 
2266
2367
  // src/commands/controller.ts
2267
- var CONTROLLER_KINDS = ["character", "car", "drone"];
2368
+ var CONTROLLER_KINDS = [
2369
+ "character",
2370
+ "car",
2371
+ "drone",
2372
+ "networked-physics"
2373
+ ];
2268
2374
  var SHARED = [
2269
2375
  "shared/math.ts",
2270
2376
  "shared/physics-world.ts",
@@ -2342,6 +2448,23 @@ var CONTROLLER_FILE_SETS = {
2342
2448
  `const drone = new DroneController({ world: physics.world, body, chassis, propellers, config: dronePresets["camera-drone"].config });`,
2343
2449
  `physics.onBeforeStep(() => { drone.setMovement(keyboard.getDroneMovement()); drone.update(); });`
2344
2450
  ]
2451
+ },
2452
+ "networked-physics": {
2453
+ code: [
2454
+ ...SHARED,
2455
+ "network/pose.ts",
2456
+ "network/networked-pushable.ts",
2457
+ "network/networked-vehicle.ts",
2458
+ "NETWORKING.md",
2459
+ NOTICE
2460
+ ],
2461
+ assets: [],
2462
+ skill: "genex-threejs-multiplayer",
2463
+ sketch: [
2464
+ `const box = new NetworkedPushable({ id: "box:1", room: () => room, body, object: mesh });`,
2465
+ `physics.onBeforeStep(() => box.update()); physics.onAfterStep(() => box.publish());`,
2466
+ `contacts.onChange((active) => box.setContact(active)); // retries held claims while contact persists`
2467
+ ]
2345
2468
  }
2346
2469
  };
2347
2470
  var CODE_DEST = path13.join("src", "controllers");
@@ -2356,7 +2479,7 @@ async function runController(opts) {
2356
2479
  if (!kind || !CONTROLLER_KINDS.includes(kind)) {
2357
2480
  log.error(
2358
2481
  `Missing or unknown controller type${kind ? ` "${kind}"` : ""}. Usage: ${c.cyan(
2359
- "genex controller <character|car|drone> [--force]"
2482
+ "genex controller <character|car|drone|networked-physics> [--force]"
2360
2483
  )} or ${c.cyan("genex controller anims <tag|clip \u2026>")}`
2361
2484
  );
2362
2485
  process.exitCode = 1;
@@ -2407,7 +2530,7 @@ async function runController(opts) {
2407
2530
  log.plain(c.bold("Next steps"));
2408
2531
  log.plain(
2409
2532
  ` 1. ${c.cyan(
2410
- kind === "character" ? "npm i @dimforge/rapier3d-compat @pixiv/three-vrm" : "npm i @dimforge/rapier3d-compat"
2533
+ kind === "character" ? "npm i @dimforge/rapier3d-compat @pixiv/three-vrm" : kind === "networked-physics" ? "npm i @dimforge/rapier3d-compat @genex-ai/multiplayer" : "npm i @dimforge/rapier3d-compat"
2411
2534
  )} (three is already in the scaffold).`
2412
2535
  );
2413
2536
  log.plain(` 2. Load the ${c.cyan(set.skill)} skill for wiring, presets, and tuning.`);
@@ -2540,7 +2663,8 @@ ${c.bold("Usage")}
2540
2663
  genex texture "<prompt>" [options] Generate a PBR texture into public/assets/textures.
2541
2664
  genex image "<prompt>" [options] Generate an image (PNG); prints a public asset URL.
2542
2665
  genex video "<prompt>" [options] Generate a video (mp4); prints a public asset URL.
2543
- genex controller <type> [--force] Install a physics controller (character|car|drone)
2666
+ genex controller <type> [--force] Install a physics controller
2667
+ (character|car|drone|networked-physics)
2544
2668
  into src/controllers (+ assets into public/assets).
2545
2669
  genex controller anims <sel \u2026> Download extra character animation clips by tag or
2546
2670
  exact name (sword, stealth, Celebration, \u2026) into
@@ -2619,6 +2743,8 @@ ${c.bold("Environment")}
2619
2743
  GENEX_API_URL Overrides the default API base URL.
2620
2744
  GENEX_COLYSEUS_URL Overrides the default multiplayer URL.
2621
2745
  GENEX_BROWSER Command used to open the browser (falls back to BROWSER).
2746
+ GENEX_TELEMETRY Set to 0 to disable anonymous crash reporting (Sentry).
2747
+ DO_NOT_TRACK Standard opt-out; any value disables crash reporting.
2622
2748
 
2623
2749
  ${c.bold("Examples")}
2624
2750
  genex init my-game
@@ -2637,6 +2763,7 @@ ${c.bold("Examples")}
2637
2763
  genex image "neon graffiti tag, spray-paint style" --transparent
2638
2764
  genex video "swirling neon plasma, seamless loop" --loop
2639
2765
  genex controller character
2766
+ genex controller networked-physics
2640
2767
  genex explore "grass"
2641
2768
  genex explore
2642
2769
  `;
@@ -2854,6 +2981,17 @@ async function main() {
2854
2981
  const updateLog = createLogger({ quiet: parsed.options.quiet });
2855
2982
  if (parsed.command !== "init") await syncSkills(updateLog);
2856
2983
  const updateCheck = startUpdateCheck();
2984
+ Sentry2.setTag("command", parsed.command);
2985
+ Sentry2.setTag("cli.version", getCliVersion());
2986
+ Sentry2.setTag("node.version", process.versions.node);
2987
+ Sentry2.setTag("os.platform", process.platform);
2988
+ Sentry2.setContext("runtime", {
2989
+ cliVersion: getCliVersion(),
2990
+ node: process.version,
2991
+ platform: process.platform,
2992
+ arch: process.arch,
2993
+ ci: !!process.env.CI
2994
+ });
2857
2995
  try {
2858
2996
  if (GEN_KINDS.has(parsed.command)) {
2859
2997
  await runGenerate(parsed.command, {
@@ -2891,10 +3029,13 @@ async function main() {
2891
3029
  }
2892
3030
  } finally {
2893
3031
  await reportUpdateNudges(updateCheck, updateLog);
3032
+ await flushSentry();
2894
3033
  }
2895
3034
  }
2896
- main().catch((err) => {
3035
+ main().catch(async (err) => {
3036
+ Sentry2.captureException(err);
2897
3037
  const log = createLogger();
2898
3038
  log.error(err instanceof Error ? err.message : String(err));
3039
+ await flushSentry();
2899
3040
  process.exitCode = 1;
2900
3041
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.38.0",
3
+ "version": "0.40.0",
4
4
  "description": "Set up your ~/.claude workspace, authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,8 +35,12 @@
35
35
  "publishConfig": {
36
36
  "access": "public"
37
37
  },
38
+ "dependencies": {
39
+ "@sentry/node": "^10.63.0"
40
+ },
38
41
  "devDependencies": {
39
42
  "@dimforge/rapier3d-compat": "^0.19.3",
43
+ "@genex-ai/multiplayer": "workspace:*",
40
44
  "@pixiv/three-vrm": "^3.5.4",
41
45
  "@types/three": "^0.185.0",
42
46
  "three": "^0.185.1",
@@ -0,0 +1,29 @@
1
+ # Networking these controllers
2
+
3
+ These controllers simulate **your own player only** (self-authoritative, zero input
4
+ latency). To show OTHER players' rigs in a multiplayer game, publish a small flat state on
5
+ a fixed tick and play it back on a visual-only remote rig — never instantiate a controller
6
+ or a Rapier body for a remote player.
7
+
8
+ Install `genex controller networked-physics` for confirmed-authority Rapier pushable and vehicle
9
+ state machines. They use one local fixed-step body: dynamic only for the confirmed owner and a
10
+ kinematic proxy driven directly from the SDK-smoothed remote state otherwise. Do not add a second
11
+ remote lerp or shadow physics simulation.
12
+
13
+ Use ordinary `set` for continuous movement. Use `me.snap` / `objects.snap` only for a deliberate
14
+ respawn, teleport, goal reset, or vehicle mode edge. Irreversible impulses, seats, reset feedback,
15
+ release, and removal must wait for the confirmed object-control result. The networked helpers watch
16
+ the relay-owned object `epoch` and direct-place Rapier followers on snaps, so a reset cannot create a
17
+ synthetic kinematic sweep through bystanders.
18
+
19
+ Vehicle seat rules the helper enforces: idle vehicles stay unowned, so a present owner IS the current
20
+ driver — `NetworkedVehicle.enter()` refuses an owned vehicle unless the game passes
21
+ `{ steal: true }`, and `onSeatLost` fires if the relay moves the seat without a clean `exit()`
22
+ (steal, orphan repair, host reset). When a driver disconnects, the relay parks the orphaned vehicle
23
+ on the host — the host should call `releaseOrphanIfHost()` (each vehicle, when it observes itself as
24
+ a non-driving owner) to snap the pose clean and return the vehicle to the idle-unowned state.
25
+
26
+ The complete recipe (what to publish per controller, remote playback, and genuine simultaneous
27
+ contested physics via the host) lives in the multiplayer skill:
28
+ `genex-threejs-multiplayer` → `references/host-physics.md`. Load that skill before writing
29
+ any networking code.
@@ -19,6 +19,14 @@ export type { ControllerUserData };
19
19
 
20
20
  const clamp = THREE.MathUtils.clamp;
21
21
 
22
+ function finiteVec(v: { x: number; y: number; z: number }): boolean {
23
+ return Number.isFinite(v.x) && Number.isFinite(v.y) && Number.isFinite(v.z);
24
+ }
25
+
26
+ function finiteQuat(q: { x: number; y: number; z: number; w: number }): boolean {
27
+ return finiteVec(q) && Number.isFinite(q.w) && Math.hypot(q.x, q.y, q.z, q.w) > 1e-6;
28
+ }
29
+
22
30
  /** Default platform mass-ratio falloff curve: flat 0 until half the character's
23
31
  * mass, then rising to full inheritance at equal-or-heavier platforms. */
24
32
  const DEFAULT_CURVE_DATA: CurveData = {
@@ -72,6 +80,12 @@ type ResolvedMovementInput = {
72
80
  /** Crouch input interpretation — see {@link CharacterControllerOptions.crouchMode}. */
73
81
  export type CrouchMode = "toggle" | "hold";
74
82
 
83
+ export interface CharacterRecoveryEvent {
84
+ reason: "non-finite" | "out-of-bounds";
85
+ position: { x: number; y: number; z: number };
86
+ linearVelocity: { x: number; y: number; z: number };
87
+ }
88
+
75
89
  /**
76
90
  * Options for {@link CharacterController}. Every value has a tuned default —
77
91
  * start from a preset in `./presets.ts` and only override what feels wrong.
@@ -97,6 +111,20 @@ export interface CharacterControllerOptions {
97
111
  density?: number;
98
112
  /** Allow the body to sleep when at rest. Default `true`. */
99
113
  canSleep?: boolean;
114
+ /** Continuous collision detection for high-speed prop/vehicle impacts. Default `true`. */
115
+ ccd?: boolean;
116
+ /**
117
+ * Optional absolute body-speed ceiling in m/s. Set it comfortably above intended run/jump/fall
118
+ * speeds; it is a last-resort external-impulse guard, not locomotion tuning.
119
+ */
120
+ maxExternalLinearSpeed?: number;
121
+ /** Optional game-owned arena/bounds predicate. Non-finite poses are rejected regardless. */
122
+ isPoseAllowed?: (position: Readonly<{ x: number; y: number; z: number }>) => boolean;
123
+ /**
124
+ * Called after an unsafe pose was atomically restored to the constructor spawn. Pair this with
125
+ * `physics.snapBodyInterpolation(body)`, camera reset, and `room.me.snap(...)` when networked.
126
+ */
127
+ onRecovery?: (event: CharacterRecoveryEvent) => void;
100
128
  /** Initial gravity scale while airborne and not falling. Default `1`. */
101
129
  gravityScale?: number;
102
130
  /**
@@ -314,6 +342,11 @@ export class CharacterController {
314
342
  private readonly counterMoveImpFactor: number;
315
343
  private readonly initialGravityScale: number;
316
344
  private readonly massRatioFallOffCurve: CurveLUT;
345
+ private readonly maxExternalLinearSpeed: number | undefined;
346
+ private readonly isPoseAllowed: CharacterControllerOptions["isPoseAllowed"];
347
+ private readonly onRecovery: CharacterControllerOptions["onRecovery"];
348
+ private readonly recoveryPosition = new THREE.Vector3();
349
+ private readonly recoveryRotation = new THREE.Quaternion();
317
350
 
318
351
  // ── input state ──
319
352
  private readonly movementState: ResolvedMovementInput = {
@@ -531,6 +564,9 @@ export class CharacterController {
531
564
  this.applyCounterMoveImp = options.applyCounterMoveImp ?? true;
532
565
  this.counterMoveImpFactor = options.counterMoveImpFactor ?? 1;
533
566
  this.initialGravityScale = options.gravityScale ?? 1;
567
+ this.maxExternalLinearSpeed = options.maxExternalLinearSpeed;
568
+ this.isPoseAllowed = options.isPoseAllowed;
569
+ this.onRecovery = options.onRecovery;
534
570
 
535
571
  const curveData = options.massRatioFallOffCurveData ?? DEFAULT_CURVE_DATA;
536
572
  this.massRatioFallOffCurve = bakeCurveLUT(curveData.points, curveData.samples ?? 50);
@@ -547,7 +583,10 @@ export class CharacterController {
547
583
  .setCanSleep(options.canSleep ?? true)
548
584
  .setGravityScale(this.initialGravityScale);
549
585
  this._body = world.createRigidBody(bodyDesc);
586
+ this._body.enableCcd(options.ccd ?? true);
550
587
  this._body.userData = options.userData ?? {};
588
+ this.recoveryPosition.set(position.x, position.y, position.z);
589
+ this.recoveryRotation.copy(rotation);
551
590
 
552
591
  // Capsule args order matches the JSX args: (halfHeight, radius).
553
592
  const colliderDesc = RAPIER.ColliderDesc.capsule(capsuleHalfHeight, this.capsuleRadius)
@@ -838,6 +877,27 @@ export class CharacterController {
838
877
  this.lastInputDir.set(0, 0, 1).applyQuaternion(this.unparkQuat);
839
878
  this.parked = false;
840
879
  this._body.wakeUp();
880
+ // Camera/gameplay getters read these caches on the same exit frame, before
881
+ // the next controller update. Keep body, root, and cached truth aligned.
882
+ this.updateCharacterInfo();
883
+ }
884
+
885
+ /**
886
+ * Atomically restore the body, visual root, velocities, and controller pose cache. The caller owns
887
+ * camera/interpolation/network discontinuity state; use `onRecovery` to update those in this frame.
888
+ */
889
+ recover(position = this.recoveryPosition, rotation = this.recoveryRotation): void {
890
+ const p = finiteVec(position) ? position : this.recoveryPosition;
891
+ const q = finiteQuat(rotation) ? rotation : this.recoveryRotation;
892
+ this._body.setTranslation(p, false);
893
+ this._body.setRotation(q, false);
894
+ this._body.setLinvel(this.fixedZero, false);
895
+ this._body.setAngvel(this.fixedZero, false);
896
+ this.root.position.copy(p);
897
+ this.root.quaternion.copy(q);
898
+ this.lastInputDir.set(0, 0, 1).applyQuaternion(q);
899
+ this.updateCharacterInfo();
900
+ this._body.wakeUp();
841
901
  }
842
902
 
843
903
  /**
@@ -851,6 +911,7 @@ export class CharacterController {
851
911
  // Skip the whole controller loop when disabled or parked
852
912
  if (!this.enabled || this.parked) return;
853
913
  const characterBody = this._body;
914
+ if (this.recoverUnsafePose()) return;
854
915
  let isSleeping = characterBody.isSleeping();
855
916
 
856
917
  // Correct frame rate difference
@@ -1028,6 +1089,35 @@ export class CharacterController {
1028
1089
  this.currentAngVelOnUp.copy(this.currentAngVel).projectOnVector(this.characterYAxis);
1029
1090
  }
1030
1091
 
1092
+ /** Recover before any impulses or publication can observe an unsafe pose. */
1093
+ private recoverUnsafePose(): boolean {
1094
+ const p = this._body.translation();
1095
+ const q = this._body.rotation();
1096
+ const v = this._body.linvel();
1097
+ const av = this._body.angvel();
1098
+ const finite = finiteVec(p) && finiteQuat(q) && finiteVec(v) && finiteVec(av);
1099
+ const allowed = finite && (this.isPoseAllowed?.(p) ?? true);
1100
+ if (!finite || !allowed) {
1101
+ const event: CharacterRecoveryEvent = {
1102
+ reason: finite ? "out-of-bounds" : "non-finite",
1103
+ position: { x: p.x, y: p.y, z: p.z },
1104
+ linearVelocity: { x: v.x, y: v.y, z: v.z },
1105
+ };
1106
+ this.recover();
1107
+ this.onRecovery?.(event);
1108
+ return true;
1109
+ }
1110
+ const max = this.maxExternalLinearSpeed;
1111
+ if (max !== undefined && Number.isFinite(max) && max > 0) {
1112
+ const speed = Math.hypot(v.x, v.y, v.z);
1113
+ if (speed > max) {
1114
+ const scale = max / speed;
1115
+ this._body.setLinvel({ x: v.x * scale, y: v.y * scale, z: v.z * scale }, true);
1116
+ }
1117
+ }
1118
+ return false;
1119
+ }
1120
+
1031
1121
  /**
1032
1122
  * Update gravity/upAxis direction and value (upstream l.499-513; the custom
1033
1123
  * gravity-field branch is dropped per the v1 port scope — world gravity may
@@ -356,6 +356,9 @@ export class DroneController {
356
356
  this.addPropeller(propellerOptions);
357
357
  }
358
358
  }
359
+ // Cache the supplied body's real pose immediately. Without this, getters
360
+ // and the chassis can remain at zero until the first awake physics step.
361
+ this.updateVehicleInfo();
359
362
  }
360
363
 
361
364
  // ---- per-frame ----
@@ -595,6 +598,11 @@ export class DroneController {
595
598
 
596
599
  // ---- internals ----
597
600
 
601
+ /** Refresh cached pose/velocity/axes immediately after an external body placement. */
602
+ syncFromBody(): void {
603
+ this.updateVehicleInfo();
604
+ }
605
+
598
606
  /** Update vehicle collider pos/vel/quat/axis from the rigid body. */
599
607
  private updateVehicleInfo(): void {
600
608
  const translation = this.bodyRef.translation();
@@ -68,6 +68,8 @@ export interface VehicleUnitLike extends FollowTargetLike {
68
68
  readonly bodyXAxis: THREE.Vector3;
69
69
  /** World-space body Z axis (live vector). */
70
70
  readonly bodyZAxis: THREE.Vector3;
71
+ /** Refresh cached pose/axes after an external body placement. */
72
+ syncFromBody(): void;
71
73
  }
72
74
 
73
75
  /**
@@ -479,10 +481,8 @@ export class EnterExitManager {
479
481
  * Port of the upstream `computeExitTransform` — exact math and order.
480
482
  * Writes `this.exitPos` / `this.exitRot`.
481
483
  *
482
- * Faithful non-guard: if the vehicle is flipped so bodyZAxis is parallel
483
- * to upAxis, projectOnPlane yields a near-zero vector and normalize()
484
- * produces NaN — upstream does not guard this either (realistic trigger:
485
- * exiting a nose-down drone along its up axis).
484
+ * A flipped/nose-down vehicle can make bodyZ parallel to up. Establish a
485
+ * finite fallback basis before normalizing so an exit can never create NaN.
486
486
  */
487
487
  private computeExitTransform(
488
488
  vehicle: VehicleUnitLike,
@@ -491,7 +491,16 @@ export class EnterExitManager {
491
491
  ): void {
492
492
  this.exitPos.copy(vehicle.currPos).addScaledVector(exitDirection, exitLength);
493
493
  const up = vehicle.upAxis;
494
- this.exitZAxis.copy(vehicle.bodyZAxis).projectOnPlane(up).normalize();
494
+ this.exitZAxis.copy(vehicle.bodyZAxis).projectOnPlane(up);
495
+ if (this.exitZAxis.lengthSq() < 1e-6) {
496
+ this.exitZAxis.copy(vehicle.bodyXAxis).projectOnPlane(up);
497
+ if (this.exitZAxis.lengthSq() < 1e-6) {
498
+ if (Math.abs(up.y) < 0.9) this.exitZAxis.set(0, 1, 0);
499
+ else this.exitZAxis.set(1, 0, 0);
500
+ this.exitZAxis.projectOnPlane(up);
501
+ }
502
+ }
503
+ this.exitZAxis.normalize();
495
504
  // Cross order matters: X = up x Z. Swapping mirrors the spawn basis.
496
505
  this.exitXAxis.crossVectors(up, this.exitZAxis);
497
506
  // makeBasis takes COLUMN vectors X, Y, Z.