@downdraft/engine 0.2.0 → 0.3.1

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 (138) hide show
  1. package/app/src/renderer/bootstrap.ts +0 -6
  2. package/app/src/renderer/feature-log.ts +1 -9
  3. package/app/src/renderer/mcp-harness.ts +8 -11
  4. package/app/src/renderer/standard-automation-tools.ts +8 -145
  5. package/core/src/index.ts +2 -2
  6. package/core/src/module/host.ts +3 -6
  7. package/core/src/module/index.ts +1 -1
  8. package/core/src/module/module.ts +4 -47
  9. package/core/src/physics/interface.ts +5 -0
  10. package/core/src/platform/render-surface.ts +13 -0
  11. package/core/src/platform/rpc.ts +4 -1
  12. package/core/src/render/canvas-resize-watcher.ts +1 -10
  13. package/core/src/render/game-renderer.ts +164 -79
  14. package/core/src/render/index.ts +0 -2
  15. package/core/src/render/input-manager.ts +7 -7
  16. package/core/src/render/renderer-module-host.ts +2 -4
  17. package/core/src/sab/grid-buffer.ts +55 -2
  18. package/core/src/sab/record-buffer.ts +48 -5
  19. package/core/src/sab/sim-channel.ts +181 -17
  20. package/core/src/sab/slot-buffer.ts +55 -1
  21. package/core/src/sab/types.ts +11 -0
  22. package/core/src/telemetry/gpu-resource-tracker.ts +24 -0
  23. package/core/src/ui-font-scale.ts +5 -38
  24. package/core/src/util/index.ts +1 -0
  25. package/core/src/util/session-timer.ts +23 -0
  26. package/core/src/worker/base-worker-host.ts +2 -1
  27. package/core/src/worker/buffer-sync.ts +20 -13
  28. package/core/src/worker/rpc.ts +119 -5
  29. package/core/src/worker/sim-worker-base.ts +5 -2
  30. package/core/src/worker/task-pool.ts +4 -0
  31. package/dist/.dd-manifest.json +893 -908
  32. package/dist/app/src/renderer/bootstrap.js +0 -4
  33. package/dist/app/src/renderer/feature-log.js +1 -10
  34. package/dist/app/src/renderer/mcp-harness.js +8 -11
  35. package/dist/app/src/renderer/standard-automation-tools.js +6 -138
  36. package/dist/core/src/index.js +2 -2
  37. package/dist/core/src/module/host.js +3 -6
  38. package/dist/core/src/platform/rpc.js +2 -1
  39. package/dist/core/src/render/canvas-resize-watcher.js +1 -10
  40. package/dist/core/src/render/game-renderer.js +166 -74
  41. package/dist/core/src/render/index.js +0 -1
  42. package/dist/core/src/render/input-manager.js +7 -7
  43. package/dist/core/src/render/renderer-module-host.js +2 -4
  44. package/dist/core/src/sab/grid-buffer.js +53 -1
  45. package/dist/core/src/sab/record-buffer.js +49 -5
  46. package/dist/core/src/sab/sim-channel.js +173 -17
  47. package/dist/core/src/sab/slot-buffer.js +54 -1
  48. package/dist/core/src/telemetry/gpu-resource-tracker.js +26 -0
  49. package/dist/core/src/ui-font-scale.js +5 -38
  50. package/dist/core/src/util/index.js +1 -0
  51. package/dist/core/src/util/session-timer.js +22 -0
  52. package/dist/core/src/worker/base-worker-host.js +2 -1
  53. package/dist/core/src/worker/buffer-sync.js +16 -9
  54. package/dist/core/src/worker/rpc.js +105 -8
  55. package/dist/core/src/worker/sim-worker-base.js +5 -2
  56. package/dist/core/src/worker/task-pool.js +5 -0
  57. package/dist/libraries/blitz-ui/src/native-module.js +8 -1
  58. package/dist/libraries/devtools/src/backend.js +35 -9
  59. package/dist/libraries/devtools/src/collectors.js +73 -22
  60. package/dist/libraries/devtools/src/native-providers.js +0 -2
  61. package/dist/libraries/entities/src/model-renderer.js +87 -27
  62. package/dist/libraries/entities/src/shaders/model.wgsl +13 -4
  63. package/dist/libraries/networking/src/index.js +1 -0
  64. package/dist/libraries/networking/src/ws-server.js +387 -0
  65. package/dist/libraries/physics-rapier/src/api.js +15 -0
  66. package/dist/libraries/physics-rapier/src/backend.js +15 -0
  67. package/dist/libraries/physics-rapier/src/rapier-backend.js +5 -0
  68. package/dist/libraries/postfx/src/post-process-stack.js +1 -3
  69. package/dist/libraries/sand/src/materials.js +2 -32
  70. package/dist/libraries/sand/src/sand-world.js +0 -3
  71. package/dist/modules/devtools/src/api.js +8 -23
  72. package/dist/modules/devtools/src/blitz/fonts.js +75 -0
  73. package/dist/modules/devtools/src/blitz/host.js +124 -30
  74. package/dist/modules/devtools/src/blitz/panels/console.js +19 -7
  75. package/dist/modules/devtools/src/blitz/panels/elements.js +217 -28
  76. package/dist/modules/devtools/src/blitz/panels/gpu.js +5 -2
  77. package/dist/modules/devtools/src/blitz/panels/performance.js +26 -12
  78. package/dist/modules/devtools/src/blitz/panels/snapshot.js +31 -8
  79. package/dist/modules/devtools/src/blitz/panels/types.js +2 -1
  80. package/dist/modules/devtools/src/blitz/theme.js +26 -4
  81. package/dist/modules/devtools/src/data-bridge.js +0 -11
  82. package/dist/modules/devtools/src/index.js +0 -6
  83. package/dist/modules/devtools/src/init.js +8 -42
  84. package/dist/modules/devtools/src/scene-inspector.js +1 -4
  85. package/dist/modules/devtools/src/worker-sync.js +3 -11
  86. package/dist/modules/html-ui/src/doc-backend.js +52 -3
  87. package/dist/modules/html-ui/src/host.js +6 -3
  88. package/dist/test/src/vision-test.js +1 -3
  89. package/libraries/blitz-ui/native/Cargo.toml +6 -0
  90. package/libraries/blitz-ui/native-osr/Cargo.toml +3 -1
  91. package/libraries/blitz-ui/src/native-module.ts +8 -1
  92. package/libraries/devtools/src/backend.ts +38 -12
  93. package/libraries/devtools/src/collectors.ts +75 -22
  94. package/libraries/devtools/src/native-providers.ts +0 -2
  95. package/libraries/entities/src/model-renderer.ts +88 -27
  96. package/libraries/entities/src/shaders/model.wgsl +13 -4
  97. package/libraries/networking/src/index.ts +4 -0
  98. package/libraries/networking/src/ws-server.ts +397 -0
  99. package/libraries/physics-rapier/src/api.ts +15 -0
  100. package/libraries/physics-rapier/src/backend.ts +15 -0
  101. package/libraries/physics-rapier/src/rapier-backend.ts +6 -0
  102. package/libraries/postfx/src/post-process-stack.ts +1 -2
  103. package/libraries/sand/src/materials.ts +2 -32
  104. package/libraries/sand/src/sand-world.ts +0 -4
  105. package/modules/devtools/README.md +1 -3
  106. package/modules/devtools/src/api.ts +10 -34
  107. package/modules/devtools/src/blitz/fonts.ts +78 -0
  108. package/modules/devtools/src/blitz/host.ts +107 -31
  109. package/modules/devtools/src/blitz/panels/console.ts +20 -8
  110. package/modules/devtools/src/blitz/panels/elements.ts +212 -31
  111. package/modules/devtools/src/blitz/panels/gpu.ts +4 -2
  112. package/modules/devtools/src/blitz/panels/performance.ts +24 -12
  113. package/modules/devtools/src/blitz/panels/snapshot.ts +30 -8
  114. package/modules/devtools/src/blitz/panels/types.ts +2 -1
  115. package/modules/devtools/src/blitz/theme.ts +26 -4
  116. package/modules/devtools/src/data-bridge.ts +0 -16
  117. package/modules/devtools/src/index.ts +1 -11
  118. package/modules/devtools/src/init.ts +8 -51
  119. package/modules/devtools/src/scene-inspector.ts +2 -6
  120. package/modules/devtools/src/types.ts +5 -90
  121. package/modules/devtools/src/worker-sync.ts +3 -13
  122. package/modules/html-ui/src/doc-backend.ts +67 -4
  123. package/modules/html-ui/src/host.ts +11 -3
  124. package/modules/html-ui/src/protocol.ts +1 -1
  125. package/package.json +4 -5
  126. package/test/src/vision-test.ts +2 -4
  127. package/core/src/render/sab-canvas-overlay.ts +0 -122
  128. package/dist/core/src/render/sab-canvas-overlay.js +0 -83
  129. package/dist/modules/devtools/src/debug-overlay.js +0 -181
  130. package/dist/modules/devtools/src/doctor-panel.js +0 -163
  131. package/dist/modules/devtools/src/label-overlay.js +0 -129
  132. package/dist/modules/devtools/src/material-stats-panel.js +0 -171
  133. package/dist/modules/devtools/src/sim-stats-panel.js +0 -250
  134. package/modules/devtools/src/debug-overlay.ts +0 -238
  135. package/modules/devtools/src/doctor-panel.ts +0 -182
  136. package/modules/devtools/src/label-overlay.ts +0 -154
  137. package/modules/devtools/src/material-stats-panel.ts +0 -188
  138. package/modules/devtools/src/sim-stats-panel.ts +0 -272
@@ -86,9 +86,11 @@ export class ModelRenderer {
86
86
  private reusableUniformsU32 = new Uint32Array(this.reusableUniforms.buffer);
87
87
  private textureLoadVersion = new Map<string, number>();
88
88
 
89
- // Skinning: a shared storage buffer of bone matrices (MAX_BONES * mat4),
90
- // updated per-frame via updateSkinMatrices. Bound at @group(1) for skinned
91
- // draws only.
89
+ // Skinning: a shared storage buffer of bone matrices (mat4 each), updated
90
+ // per-frame via updateSkinMatrices/updateSkinMatricesFor. Bound at @group(1)
91
+ // for skinned draws only. Each skinned nodeId owns a region of the buffer —
92
+ // the draw's `boneBase` uniform selects it so multiple skinned models can be
93
+ // animated independently in one frame.
92
94
  private skinMatrixBuffer: GPUBuffer | null = null;
93
95
  private skinBindGroupLayout: GPUBindGroupLayout | null = null;
94
96
  private skinBindGroup: GPUBindGroup | null = null;
@@ -96,6 +98,16 @@ export class ModelRenderer {
96
98
  private skinBindGroupSetThisFrame = false;
97
99
  /** Current bone capacity (number of mat4s the skin buffer can hold). Grows on demand. */
98
100
  private boneCapacity = ModelRenderer.INITIAL_BONE_CAPACITY;
101
+ /** nodeId → reserved bone region (offset+count in bones). */
102
+ private skinRegions = new Map<string, { offset: number; count: number }>();
103
+ /** nodeId → bone base index used by the skinning shader for that draw. */
104
+ private skinBaseByNode = new Map<string, number>();
105
+ /** Freed bone regions available for reuse (first-fit). */
106
+ private skinFreeList: Array<{ offset: number; count: number }> = [];
107
+ /** Next never-allocated bone index (high-water mark). */
108
+ private nextBoneOffset = 0;
109
+ /** Legacy caller region — updateSkinMatrices(m) without a nodeId. */
110
+ private static readonly DEFAULT_SKIN_NODE = "__skin_default";
99
111
 
100
112
  // Bindless deps
101
113
  private bindless: ModelRendererBindlessDeps | null = null;
@@ -822,6 +834,7 @@ export class ModelRenderer {
822
834
  }
823
835
 
824
836
  removeModel(nodeId: string): void {
837
+ this.releaseSkinRegion(nodeId);
825
838
  const resources = this.modelResources.get(nodeId);
826
839
  if (resources) {
827
840
  for (let i = 0; i < resources.length; i++) {
@@ -1082,43 +1095,85 @@ export class ModelRenderer {
1082
1095
  }
1083
1096
 
1084
1097
  /**
1085
- * Upload bone skin matrices for the current frame. The `matrices` buffer is
1086
- * a flat Float32Array of boneCount * 16 floats (column-major mat4s). It is
1087
- * copied into the shared skin matrix storage buffer. If the buffer is too
1088
- * small for the current skeleton, it is recreated (grown) automatically.
1089
- * Call once per frame before render() for skinned models.
1098
+ * Upload bone skin matrices for the current frame into the shared default
1099
+ * region. Kept for single-avatar callers — equivalent to
1100
+ * updateSkinMatricesFor("__skin_default", matrices).
1090
1101
  */
1091
1102
  updateSkinMatrices(matrices: Float32Array): void {
1103
+ this.updateSkinMatricesFor(ModelRenderer.DEFAULT_SKIN_NODE, matrices);
1104
+ }
1105
+
1106
+ /**
1107
+ * Upload bone skin matrices for one nodeId's reserved region. The region is
1108
+ * (re)allocated automatically — freed regions from removeModel() are reused
1109
+ * first-fit. Render() looks up the node's region offset and passes it to the
1110
+ * skinning shader via the `boneBase` uniform, so multiple skinned models
1111
+ * animate independently.
1112
+ */
1113
+ updateSkinMatricesFor(nodeId: string, matrices: Float32Array): void {
1092
1114
  if (!this.skinMatrixBuffer) return;
1093
1115
 
1094
1116
  const requiredBones = matrices.length / 16;
1095
- if (requiredBones > this.boneCapacity) {
1096
- // Grow the skin matrix buffer to fit the larger skeleton.
1097
- // Round up to the next power of 2 to reduce reallocations.
1098
- const newCapacity = Math.max(requiredBones, this.boneCapacity * 2);
1099
- this.skinMatrixBuffer.destroy();
1100
- this.skinMatrixBuffer = this.device.createBuffer({
1101
- size: newCapacity * 64,
1102
- usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1103
- });
1104
- this.boneCapacity = newCapacity;
1105
- // Recreate the bind group with the new buffer
1106
- if (this.skinBindGroupLayout) {
1107
- this.skinBindGroup = this.device.createBindGroup({
1108
- layout: this.skinBindGroupLayout,
1109
- entries: [{ binding: 0, resource: { buffer: this.skinMatrixBuffer } }],
1110
- });
1111
- }
1112
- this.skinBindGroupSetThisFrame = false; // force re-bind
1117
+ let region = this.skinRegions.get(nodeId);
1118
+ if (!region || region.count < requiredBones) {
1119
+ if (region) this.skinFreeList.push(region);
1120
+ region = this.allocSkinRegion(requiredBones);
1121
+ this.skinRegions.set(nodeId, region);
1113
1122
  }
1123
+ this.skinBaseByNode.set(nodeId, region.offset);
1114
1124
 
1115
1125
  this.device.queue.writeBuffer(
1116
1126
  this.skinMatrixBuffer,
1117
- 0,
1127
+ region.offset * 64,
1118
1128
  matrices as Float32Array<ArrayBuffer>,
1119
1129
  );
1120
1130
  }
1121
1131
 
1132
+ /** Allocate a bone region for `count` bones — free-list first, then bump
1133
+ * allocation; grows the shared buffer when the capacity is exceeded. */
1134
+ private allocSkinRegion(count: number): { offset: number; count: number } {
1135
+ for (let i = 0; i < this.skinFreeList.length; i++) {
1136
+ const f = this.skinFreeList[i];
1137
+ if (f.count >= count) {
1138
+ this.skinFreeList.splice(i, 1);
1139
+ return { offset: f.offset, count: f.count };
1140
+ }
1141
+ }
1142
+ const needed = this.nextBoneOffset + count;
1143
+ if (needed > this.boneCapacity) this.growSkinBuffer(needed);
1144
+ const region = { offset: this.nextBoneOffset, count };
1145
+ this.nextBoneOffset += count;
1146
+ return region;
1147
+ }
1148
+
1149
+ /** Recreate the skin matrix buffer at a larger capacity and rebind group(1).
1150
+ * Region offsets stay valid — callers rewrite their matrices next frame. */
1151
+ private growSkinBuffer(minBones: number): void {
1152
+ const newCapacity = Math.max(minBones, this.boneCapacity * 2);
1153
+ this.skinMatrixBuffer?.destroy();
1154
+ this.skinMatrixBuffer = this.device.createBuffer({
1155
+ size: newCapacity * 64,
1156
+ usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
1157
+ });
1158
+ this.boneCapacity = newCapacity;
1159
+ if (this.skinBindGroupLayout) {
1160
+ this.skinBindGroup = this.device.createBindGroup({
1161
+ layout: this.skinBindGroupLayout,
1162
+ entries: [{ binding: 0, resource: { buffer: this.skinMatrixBuffer } }],
1163
+ });
1164
+ }
1165
+ this.skinBindGroupSetThisFrame = false; // force re-bind
1166
+ }
1167
+
1168
+ /** Release a node's bone region back to the free list. */
1169
+ releaseSkinRegion(nodeId: string): void {
1170
+ const region = this.skinRegions.get(nodeId);
1171
+ if (!region) return;
1172
+ this.skinRegions.delete(nodeId);
1173
+ this.skinBaseByNode.delete(nodeId);
1174
+ this.skinFreeList.push(region);
1175
+ }
1176
+
1122
1177
  render(
1123
1178
  passEncoder: GPURenderPassEncoder,
1124
1179
  nodeId: string,
@@ -1221,6 +1276,12 @@ export class ModelRenderer {
1221
1276
  // fs_outline). Defaults: 0 width, black color.
1222
1277
  uniforms[39] = 0.0;
1223
1278
  uniforms[40] = 0.0; uniforms[41] = 0.0; uniforms[42] = 0.0;
1279
+ // boneBase (u32, slot 44) — index of this node's bone region inside the
1280
+ // shared skinMatrices storage buffer. Only read by vs_skinned.
1281
+ this.reusableUniformsU32[44] =
1282
+ this.skinBaseByNode.get(nodeId)
1283
+ ?? this.skinBaseByNode.get(ModelRenderer.DEFAULT_SKIN_NODE)
1284
+ ?? 0;
1224
1285
 
1225
1286
  this.device.queue.writeBuffer(
1226
1287
  this.uniformBuffer,
@@ -22,6 +22,14 @@ struct Uniforms {
22
22
  // Inverted-hull outline parameters (used by vs_outline / fs_outline only).
23
23
  outlineWidth: f32,
24
24
  outlineColor: vec3<f32>,
25
+ // Base index of this draw's bone region inside the shared skinMatrices
26
+ // storage buffer (group(1)). Written by ModelRenderer from the node's
27
+ // reserved region — lets multiple skinned models animate independently.
28
+ // Only read by vs_skinned.
29
+ boneBase: u32,
30
+ _padSkin0: u32,
31
+ _padSkin1: u32,
32
+ _padSkin2: u32,
25
33
  };
26
34
 
27
35
  @group(0) @binding(0) var<uniform> uniforms: Uniforms;
@@ -165,10 +173,11 @@ fn qrotate(q: vec4<f32>, v: vec3<f32>) -> vec3<f32> {
165
173
  // If all weights are zero (vertex has no bone assignment), return identity so
166
174
  // the vertex renders at its baked position instead of collapsing to the origin.
167
175
  fn skinMatrix(j: vec4<u32>, w: vec4<f32>) -> mat4x4<f32> {
168
- let m = skinMatrices[j.x] * w.x
169
- + skinMatrices[j.y] * w.y
170
- + skinMatrices[j.z] * w.z
171
- + skinMatrices[j.w] * w.w;
176
+ let base = uniforms.boneBase;
177
+ let m = skinMatrices[base + j.x] * w.x
178
+ + skinMatrices[base + j.y] * w.y
179
+ + skinMatrices[base + j.z] * w.z
180
+ + skinMatrices[base + j.w] * w.w;
172
181
  let wsum = w.x + w.y + w.z + w.w;
173
182
  if (wsum < 0.001) {
174
183
  return mat4x4<f32>(
@@ -43,3 +43,7 @@ export type { NetworkingLibConfig } from "./library";
43
43
 
44
44
  export { LanDiscovery, MdnsBrowser } from "./discovery";
45
45
  export type { LanBeacon, LanDiscoveryOptions, LanPeer, MdnsBrowseOptions, MdnsService } from "./discovery";
46
+
47
+ export { createWebSocketServer } from "./ws-server";
48
+ export type { WebSocketServerHandlers, WebSocketServerOptions, WsServer, WsServerConnection } from "./ws-server";
49
+
@@ -0,0 +1,397 @@
1
+ // ============================================================================
2
+ // ws-server.ts — minimal tri-runtime WebSocket server for host-authoritative
3
+ // LAN/WAN sessions. The networking library ships client transports
4
+ // (WebSocketTransport, WebRTCTransport) but had no listener — this fills the
5
+ // host side so a game instance can accept direct connections without any
6
+ // external service.
7
+ //
8
+ // Runtime selection is capability-based (see platform/runtime.ts):
9
+ // Bun → Bun.serve({ fetch, websocket })
10
+ // Deno → Deno.serve + Deno.upgradeWebSocket
11
+ // Node → node:http + a hand-rolled RFC6455 handshake/framer (pure JS,
12
+ // no native deps — also the fallback for any runtime with node:http)
13
+ //
14
+ // Semantics intentionally mirror the browser WebSocket surface: text frames
15
+ // arrive as `string`, binary frames as `Uint8Array`. Ping/pong/close and
16
+ // fragmented-frame reassembly are handled internally. Message cap matches
17
+ // WebSocketTransport's 16 MiB.
18
+ // ============================================================================
19
+
20
+ import { createLogger } from "@downdraft/engine/util/logger";
21
+
22
+ const log = createLogger();
23
+
24
+ const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
25
+ const MAX_MESSAGE_SIZE = 16 * 1024 * 1024;
26
+
27
+ /** One accepted client connection on a {@link WsServer}. */
28
+ export interface WsServerConnection {
29
+ /** Server-assigned connection id (monotonic per server). */
30
+ readonly id: number;
31
+ /** Remote peer "ip:port" or best-effort address string. */
32
+ readonly remoteAddress: string;
33
+ /** Send a text (string) or binary (Uint8Array/ArrayBuffer) frame. */
34
+ send(data: string | Uint8Array | ArrayBuffer): void;
35
+ /** Initiate the close handshake (default code 1000). */
36
+ close(code?: number, reason?: string): void;
37
+ }
38
+
39
+ export interface WebSocketServerHandlers {
40
+ /** A client completed the WS handshake. */
41
+ onConnection?(conn: WsServerConnection): void;
42
+ /** A complete (reassembled) message arrived. */
43
+ onMessage?(conn: WsServerConnection, data: Uint8Array | string): void;
44
+ /** The connection closed (cleanly or via error/timeout). */
45
+ onClose?(conn: WsServerConnection, code: number, reason: string): void;
46
+ }
47
+
48
+ export interface WebSocketServerOptions extends WebSocketServerHandlers {
49
+ /** TCP port to listen on. 0 = ephemeral (read `server.port` after start). */
50
+ port: number;
51
+ /** Bind address — default "0.0.0.0" (all interfaces, for LAN/WAN). */
52
+ host?: string;
53
+ }
54
+
55
+ export interface WsServer {
56
+ /** The port actually bound (differs from opts.port when 0 was passed). */
57
+ readonly port: number;
58
+ /** The bound hostname. */
59
+ readonly host: string;
60
+ /** All currently-open connections. */
61
+ connections(): WsServerConnection[];
62
+ /** Stop listening and close every connection. */
63
+ close(): Promise<void>;
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Shared plumbing
68
+ // ---------------------------------------------------------------------------
69
+
70
+ interface InternalConn extends WsServerConnection {
71
+ /** Runtime-specific raw socket/sendable handle. */
72
+ raw: unknown;
73
+ }
74
+
75
+ function makeServerBase(handlers: WebSocketServerHandlers) {
76
+ const conns = new Set<InternalConn>();
77
+ let nextId = 1;
78
+
79
+ const deliver = (conn: InternalConn, data: Uint8Array | string) => {
80
+ const size = typeof data === "string" ? data.length : data.byteLength;
81
+ if (size > MAX_MESSAGE_SIZE) { conn.close(1009, "message too large"); return; }
82
+ try { handlers.onMessage?.(conn, data); } catch (e) { log.error("WsServer", `onMessage threw: ${e}`); }
83
+ };
84
+
85
+ const drop = (conn: InternalConn, code: number, reason: string) => {
86
+ if (!conns.delete(conn)) return;
87
+ try { handlers.onClose?.(conn, code, reason); } catch (e) { log.error("WsServer", `onClose threw: ${e}`); }
88
+ };
89
+
90
+ return { conns, nextId: () => nextId++, deliver, drop };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Bun — Bun.serve with the `websocket` handler map
95
+ // ---------------------------------------------------------------------------
96
+
97
+ function startBunServer(opts: WebSocketServerOptions): Promise<WsServer> {
98
+ const Bun = (globalThis as any).Bun;
99
+ const base = makeServerBase(opts);
100
+
101
+ const server = (Bun.serve as (o: unknown) => { port: number; stop(force?: boolean): Promise<void> })({
102
+ port: opts.port,
103
+ hostname: opts.host ?? "0.0.0.0",
104
+ fetch(req: Request, srv: any) {
105
+ const ip = srv.requestIP(req);
106
+ const conn: InternalConn = {
107
+ id: base.nextId(),
108
+ remoteAddress: `${ip?.address ?? "?"}:${ip?.port ?? "?"}`,
109
+ raw: null,
110
+ send(data: string | Uint8Array | ArrayBuffer) { if (this.raw) (this.raw as any).send(data instanceof ArrayBuffer ? new Uint8Array(data) : data as never); },
111
+ close(code = 1000, reason = "") { if (this.raw) (this.raw as any).close(code, reason); },
112
+ };
113
+ return srv.upgrade(req, { data: { conn } }) ? undefined : new Response("WebSocket upgrade required", { status: 426 });
114
+ },
115
+ websocket: {
116
+ open(ws: any) {
117
+ const conn = ws.data.conn as InternalConn;
118
+ conn.raw = ws;
119
+ base.conns.add(conn);
120
+ try { opts.onConnection?.(conn); } catch (e) { log.error("WsServer", `onConnection threw: ${e}`); }
121
+ },
122
+ message(ws: any, msg: string | ArrayBuffer | Uint8Array) {
123
+ const conn = ws.data.conn as InternalConn;
124
+ base.deliver(conn, typeof msg === "string" ? msg : new Uint8Array(msg as ArrayBufferLike));
125
+ },
126
+ close(ws: any, code: number, reason: string) {
127
+ base.drop(ws.data.conn as InternalConn, code, reason);
128
+ },
129
+ },
130
+ });
131
+
132
+ return Promise.resolve({
133
+ port: server.port as number,
134
+ host: opts.host ?? "0.0.0.0",
135
+ connections: () => [...base.conns],
136
+ async close() {
137
+ [...base.conns].forEach((c) => c.close(1001, "server shutdown"));
138
+ await server.stop(true);
139
+ },
140
+ });
141
+ }
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // Deno — Deno.serve + Deno.upgradeWebSocket
145
+ // ---------------------------------------------------------------------------
146
+
147
+ function startDenoServer(opts: WebSocketServerOptions): Promise<WsServer> {
148
+ const Deno = (globalThis as any).Deno;
149
+ const base = makeServerBase(opts);
150
+
151
+ const server = Deno.serve(
152
+ { port: opts.port, hostname: opts.host ?? "0.0.0.0" },
153
+ (req: Request, info: { remoteAddr?: { hostname: string; port: number } }) => {
154
+ if (req.headers.get("upgrade")?.toLowerCase() !== "websocket") {
155
+ return new Response("WebSocket upgrade required", { status: 426 });
156
+ }
157
+ const { socket, response } = Deno.upgradeWebSocket(req);
158
+ const conn: InternalConn = {
159
+ id: base.nextId(),
160
+ remoteAddress: info?.remoteAddr ? `${info.remoteAddr.hostname}:${info.remoteAddr.port}` : "?",
161
+ raw: socket,
162
+ send(data: string | Uint8Array | ArrayBuffer) { try { socket.send(data as never); } catch { /* closing */ } },
163
+ close(code = 1000, reason = "") { try { socket.close(code, reason); } catch { /* already closed */ } },
164
+ };
165
+ socket.binaryType = "arraybuffer";
166
+ socket.onopen = () => {
167
+ base.conns.add(conn);
168
+ try { opts.onConnection?.(conn); } catch (e) { log.error("WsServer", `onConnection threw: ${e}`); }
169
+ };
170
+ socket.onmessage = (e: MessageEvent) => {
171
+ base.deliver(conn, typeof e.data === "string" ? e.data : new Uint8Array(e.data as ArrayBuffer));
172
+ };
173
+ socket.onclose = (e: CloseEvent) => base.drop(conn, e.code, e.reason);
174
+ socket.onerror = () => base.drop(conn, 1006, "socket error");
175
+ return response;
176
+ },
177
+ );
178
+
179
+ const bound = server.addr as { port: number; hostname: string };
180
+ return Promise.resolve({
181
+ port: bound.port,
182
+ host: bound.hostname,
183
+ connections: () => [...base.conns],
184
+ async close() {
185
+ [...base.conns].forEach((c) => c.close(1001, "server shutdown"));
186
+ await server.shutdown();
187
+ },
188
+ });
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // Node (and generic fallback) — node:http + hand-rolled RFC6455
193
+ // ---------------------------------------------------------------------------
194
+
195
+ /** Encode one server→client frame. Server frames are never masked. */
196
+ function encodeWsFrame(opcode: number, payload: Uint8Array): Uint8Array {
197
+ const len = payload.byteLength;
198
+ let header: Uint8Array;
199
+ if (len < 126) {
200
+ header = new Uint8Array(2);
201
+ header[1] = len;
202
+ } else if (len <= 0xffff) {
203
+ header = new Uint8Array(4);
204
+ header[1] = 126;
205
+ header[2] = len >>> 8; header[3] = len & 0xff;
206
+ } else {
207
+ header = new Uint8Array(10);
208
+ header[1] = 127;
209
+ const dv = new DataView(header.buffer);
210
+ dv.setUint32(2, Math.floor(len / 0x100000000));
211
+ dv.setUint32(6, len >>> 0);
212
+ }
213
+ header[0] = 0x80 | (opcode & 0x0f);
214
+ const out = new Uint8Array(header.length + len);
215
+ out.set(header, 0);
216
+ out.set(payload, header.length);
217
+ return out;
218
+ }
219
+
220
+ const OP_CONT = 0x0, OP_TEXT = 0x1, OP_BIN = 0x2, OP_CLOSE = 0x8, OP_PING = 0x9, OP_PONG = 0xa;
221
+
222
+ async function startNodeServer(opts: WebSocketServerOptions): Promise<WsServer> {
223
+ const http = await import("node:http");
224
+ const crypto = await import("node:crypto");
225
+ const base = makeServerBase(opts);
226
+
227
+ const httpServer = http.createServer((_req, res) => {
228
+ res.writeHead(426, { "Content-Type": "text/plain" });
229
+ res.end("WebSocket upgrade required");
230
+ });
231
+
232
+ httpServer.on("upgrade", (req: import("node:http").IncomingMessage, socket: import("node:net").Socket) => {
233
+ socket.setNoDelay(true);
234
+ const key = req.headers["sec-websocket-key"];
235
+ const upgrade = req.headers.upgrade?.toLowerCase();
236
+ if (upgrade !== "websocket" || typeof key !== "string") {
237
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n");
238
+ socket.destroy();
239
+ return;
240
+ }
241
+ const accept = crypto.createHash("sha1").update(key + WS_GUID).digest("base64");
242
+ socket.write(
243
+ "HTTP/1.1 101 Switching Protocols\r\n" +
244
+ "Upgrade: websocket\r\n" +
245
+ "Connection: Upgrade\r\n" +
246
+ `Sec-WebSocket-Accept: ${accept}\r\n\r\n`,
247
+ );
248
+
249
+ let closed = false;
250
+ const conn: InternalConn = {
251
+ id: base.nextId(),
252
+ remoteAddress: `${socket.remoteAddress ?? "?"}:${socket.remotePort ?? "?"}`,
253
+ raw: socket,
254
+ send(data: string | Uint8Array | ArrayBuffer) {
255
+ if (closed || socket.destroyed) return;
256
+ if (typeof data === "string") {
257
+ socket.write(Buffer.from(encodeWsFrame(OP_TEXT, Buffer.from(data, "utf8"))));
258
+ } else {
259
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
260
+ socket.write(Buffer.from(encodeWsFrame(OP_BIN, bytes)));
261
+ }
262
+ },
263
+ close(code = 1000, reason = "") {
264
+ if (closed) return;
265
+ closed = true;
266
+ const payload = new Uint8Array(2 + reason.length);
267
+ payload[0] = code >>> 8; payload[1] = code & 0xff;
268
+ payload.set(new TextEncoder().encode(reason), 2);
269
+ try {
270
+ socket.write(Buffer.from(encodeWsFrame(OP_CLOSE, payload)));
271
+ socket.end();
272
+ } catch { /* socket already gone */ }
273
+ },
274
+ };
275
+
276
+ // ── Incremental frame parser ──
277
+ let buf: Buffer = Buffer.alloc(0) as Buffer;
278
+ let fragOp = -1;
279
+ const frags: Buffer[] = [];
280
+
281
+ const finishMessage = (op: number, payload: Buffer) => {
282
+ if (payload.byteLength > MAX_MESSAGE_SIZE) { conn.close(1009, "message too large"); return; }
283
+ if (op === OP_TEXT) base.deliver(conn, payload.toString("utf8"));
284
+ else base.deliver(conn, new Uint8Array(payload.buffer, payload.byteOffset, payload.byteLength));
285
+ };
286
+
287
+ const handleFrame = (fin: boolean, op: number, payload: Buffer) => {
288
+ if (op === OP_CLOSE) {
289
+ const code = payload.byteLength >= 2 ? payload.readUInt16BE(0) : 1000;
290
+ const reason = payload.byteLength > 2 ? payload.subarray(2).toString("utf8") : "";
291
+ conn.close(code, reason);
292
+ base.drop(conn, code, reason);
293
+ return;
294
+ }
295
+ if (op === OP_PING) { if (!closed && !socket.destroyed) socket.write(Buffer.from(encodeWsFrame(OP_PONG, payload))); return; }
296
+ if (op === OP_PONG) return;
297
+ if (op === OP_CONT && fragOp < 0) { conn.close(1002, "unexpected continuation"); return; }
298
+ if ((op === OP_TEXT || op === OP_BIN) && fragOp >= 0) { conn.close(1002, "interleaved frame"); return; }
299
+
300
+ if (op !== OP_CONT) fragOp = op;
301
+ frags.push(payload);
302
+ if (!fin) return;
303
+ const full = Buffer.concat(frags);
304
+ frags.length = 0;
305
+ const finalOp = fragOp;
306
+ fragOp = -1;
307
+ finishMessage(finalOp, full);
308
+ };
309
+
310
+ socket.on("data", (chunk: Buffer) => {
311
+ buf = buf.byteLength ? Buffer.concat([buf, chunk]) : chunk;
312
+ // Parse as many complete frames as the buffer holds.
313
+ for (;;) {
314
+ if (buf.byteLength < 2) return;
315
+ const b0 = buf[0], b1 = buf[1];
316
+ const fin = (b0 & 0x80) !== 0;
317
+ const op = b0 & 0x0f;
318
+ const masked = (b1 & 0x80) !== 0;
319
+ let len = b1 & 0x7f;
320
+ let off = 2;
321
+ if (len === 126) {
322
+ if (buf.byteLength < off + 2) return;
323
+ len = buf.readUInt16BE(off); off += 2;
324
+ } else if (len === 127) {
325
+ if (buf.byteLength < off + 8) return;
326
+ const hi = buf.readUInt32BE(off), lo = buf.readUInt32BE(off + 4); off += 8;
327
+ if (hi > 0 || lo > MAX_MESSAGE_SIZE) { conn.close(1009, "message too large"); return; }
328
+ len = lo;
329
+ }
330
+ const maskLen = masked ? 4 : 0;
331
+ if (buf.byteLength < off + maskLen + len) return;
332
+ let payload = buf.subarray(off + maskLen, off + maskLen + len);
333
+ if (masked) {
334
+ const mask = buf.subarray(off, off + 4);
335
+ const unmasked = Buffer.allocUnsafe(payload.byteLength);
336
+ for (let i = 0; i < payload.byteLength; i++) unmasked[i] = payload[i] ^ mask[i & 3];
337
+ payload = unmasked;
338
+ } else {
339
+ payload = Buffer.from(payload); // detach — subarray aliases buf
340
+ }
341
+ buf = buf.subarray(off + maskLen + len);
342
+ handleFrame(fin, op, payload);
343
+ if (closed) return;
344
+ }
345
+ });
346
+
347
+ const onSocketEnd = () => base.drop(conn, 1006, "socket closed");
348
+ socket.on("close", onSocketEnd);
349
+ socket.on("error", onSocketEnd);
350
+
351
+ base.conns.add(conn);
352
+ try { opts.onConnection?.(conn); } catch (e) { log.error("WsServer", `onConnection threw: ${e}`); }
353
+ });
354
+
355
+ await new Promise<void>((resolve, reject) => {
356
+ httpServer.once("error", reject);
357
+ httpServer.listen(opts.port, opts.host ?? "0.0.0.0", () => {
358
+ httpServer.removeListener("error", reject);
359
+ resolve();
360
+ });
361
+ });
362
+
363
+ const addr = httpServer.address();
364
+ const boundPort = typeof addr === "object" && addr ? addr.port : opts.port;
365
+
366
+ return {
367
+ port: boundPort,
368
+ host: opts.host ?? "0.0.0.0",
369
+ connections: () => [...base.conns],
370
+ async close() {
371
+ [...base.conns].forEach((c) => c.close(1001, "server shutdown"));
372
+ await new Promise<void>((resolve) => httpServer.close(() => resolve()));
373
+ // httpServer.close waits for open sockets; force-destroy stragglers.
374
+ [...base.conns].forEach((c) => (c.raw as import("node:net").Socket).destroy());
375
+ base.conns.clear();
376
+ },
377
+ };
378
+ }
379
+
380
+ // ---------------------------------------------------------------------------
381
+ // Entry point
382
+ // ---------------------------------------------------------------------------
383
+
384
+ /**
385
+ * Start a WebSocket server. Resolves once the socket is bound and listening.
386
+ * Runtime is picked by capability: Bun.serve → Deno.serve → node:http.
387
+ */
388
+ export function createWebSocketServer(opts: WebSocketServerOptions): Promise<WsServer> {
389
+ const g = globalThis as Record<string, unknown>;
390
+ const bun = g.Bun as { serve?: unknown } | undefined;
391
+ if (typeof bun?.serve === "function") return startBunServer(opts);
392
+ const deno = g.Deno as { serve?: unknown; upgradeWebSocket?: unknown } | undefined;
393
+ if (typeof deno?.serve === "function" && typeof deno.upgradeWebSocket === "function") {
394
+ return startDenoServer(opts);
395
+ }
396
+ return startNodeServer(opts);
397
+ }
@@ -298,6 +298,21 @@ export class UniversalPhysicsAPI {
298
298
  this.backend.setIntegrationDt(realmId, dt);
299
299
  }
300
300
 
301
+ /**
302
+ * Change gravity across all realms (applies to near/mid/far). Best-effort:
303
+ * backends that can't mutate realm gravity omit `setRealmGravity`, in which
304
+ * case callers should fall back to per-body `gravityScale` in BodyDesc.
305
+ */
306
+ setGravity(gravity: [number, number, number]): void {
307
+ [RealmTier.Near, RealmTier.Mid, RealmTier.Far].forEach((tier) => {
308
+ try {
309
+ const realm = this.realmManager.getRealm(tier);
310
+ this.backend.setRealmGravity?.(realm.id, gravity);
311
+ } catch { /* realm tier not initialized */ }
312
+ });
313
+ this.config.gravity = gravity;
314
+ }
315
+
301
316
  // --- Realm ---
302
317
 
303
318
  getRealmTier(body: PhysicsBody): RealmTier {
@@ -472,6 +472,21 @@ export class RapierPhysicsBackend implements PhysicsBackend {
472
472
  }
473
473
  }
474
474
 
475
+ /**
476
+ * Change a live realm's gravity. Updates the stored realm config (the
477
+ * no-lib JS fallback stepper reads it) and forwards to the lib when it
478
+ * supports runtime gravity (the WASM Rapier world.gravity is mutable; the
479
+ * native FFI lib may not implement it — callers should treat this as
480
+ * best-effort).
481
+ */
482
+ setRealmGravity(realmId: number, gravity: [number, number, number]): void {
483
+ const realm = this.realms.get(realmId);
484
+ if (realm) realm.config.gravity = gravity;
485
+ if (this.lib && this.lib.setRealmGravity) {
486
+ this.lib.setRealmGravity(realmId, gravity);
487
+ }
488
+ }
489
+
475
490
  setSleepThresholds(realmId: number, linearThreshold: number, angularThreshold: number): void {
476
491
  if (this.lib) {
477
492
  this.lib.setSleepThresholds(realmId, linearThreshold, angularThreshold);
@@ -7,6 +7,8 @@ const log = createLogger();
7
7
  export interface PhysicsLib {
8
8
  createRealm(id: number, gravity: [number, number, number]): void;
9
9
  destroyRealm(id: number): void;
10
+ /** Change a live realm's gravity (Rapier `world.gravity` is mutable). */
11
+ setRealmGravity?(realmId: number, gravity: [number, number, number]): void;
10
12
  createBody(realmId: number, bodyId: number, desc: BodyDesc, entity: Entity): void;
11
13
  destroyBody(realmId: number, bodyId: number): void;
12
14
  setBodyType(realmId: number, bodyId: number, type: BodyType): void;
@@ -251,6 +253,10 @@ async function doLoadPhysicsLib(): Promise<PhysicsLib> {
251
253
  entityByBodyHandle.delete(id);
252
254
  bodyIdByHandle.delete(id);
253
255
  },
256
+ setRealmGravity(id, gravity) {
257
+ const world = realms.get(id);
258
+ if (world) world.gravity = { x: gravity[0], y: gravity[1], z: gravity[2] };
259
+ },
254
260
  createBody(realmId, bodyId, desc, entity) {
255
261
  const world = realms.get(realmId);
256
262
  if (!world) return;
@@ -546,8 +546,7 @@ export class PostProcessStack {
546
546
  private createGlyphAtlas(): void {
547
547
  const chars = " .:-=+*#%@";
548
548
  const gw = 16, gh = 16, cols = chars.length;
549
- const canvas = document.createElement("canvas");
550
- canvas.width = cols * gw; canvas.height = gh;
549
+ const canvas = new OffscreenCanvas(cols * gw, gh);
551
550
  const ctx = canvas.getContext("2d")!;
552
551
  ctx.fillStyle = "black"; ctx.fillRect(0, 0, canvas.width, canvas.height);
553
552
  ctx.fillStyle = "white"; ctx.font = `bold ${gh - 2}px monospace`;