dotframe 0.1.8 → 0.2.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/cli/main.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { parseArgs } from "node:util";
3
3
  import pkg from "../package.json" with { type: "json" };
4
- import { build, deploy, device, relay, vendor } from "./commands/ship";
4
+ import { build, deploy, device, deviceLogs, relay, vendor } from "./commands/ship";
5
5
  import { doctor } from "./commands/doctor";
6
6
  import { deployInit } from "./commands/docker";
7
7
  import { configCmd } from "./commands/config";
@@ -21,7 +21,7 @@ Play (headless, deterministic)
21
21
  sim [--inputs f.jsonl | --mash <seed>] [--frames 600] [--seed 1] [--options json] [--every n]
22
22
  snap --frame <n> [--out frame.png] [--inputs f | --mash <seed>] [--seed 1] [--options json]
23
23
  replay record <file> | verify <file...>
24
- play --online [room] [--frames 600] two browsers, local relay, scripted match
24
+ play --online [room] [--frames 600] [--seeds 1,2] two browsers, local relay, scripted match
25
25
  desync [--latency 100ms] [--jitter 0ms] [--delay 2] [--frames 1800] [--renders 3]
26
26
 
27
27
  Build and ship
@@ -31,6 +31,7 @@ Build and ship
31
31
  relay serve [--port 8787] local netplay relay (same protocol as production)
32
32
  relay deploy [--region eze] gated
33
33
  device install <target> gated
34
+ device logs <target> newest crash report from the device, summarized
34
35
  vendor <macos|windows> SDL3 + wgpu-native for native builds (~/.dotframe/vendor)
35
36
  doctor [--fix] [--docker] toolchain, vendor, links, config, sim render and math
36
37
  config get [key] | set <key> <json>
@@ -74,11 +75,14 @@ Compares checksums every frame and the full inspect() state every --every frames
74
75
  Warns when rollbacks exceed the sim's rollbackWindow.
75
76
 
76
77
  dotframe desync --latency 474ms --jitter 40ms --mash 42 --json`,
77
- play: `dotframe play --online [room] [--frames 600] [--out .dotframe/play] [--json]
78
+ play: `dotframe play --online [room] [--frames 600] [--seeds 1,2] [--native] [--out .dotframe/play] [--json]
78
79
 
79
- Builds the web target, starts a local relay, and opens two agent-browser sessions on ?room=<room>&relay=...&mash=1|2.
80
+ Builds the web target, starts a local relay, and opens two agent-browser sessions on ?room=<room>&relay=...&mash=<seed>
81
+ (one seed per peer from --seeds).
80
82
  Waits until both confirm --frames frames, compares their checksums every 30 frames, and saves a screenshot of each.
81
- The web entry must honor ?room=, ?relay=, ?mash= and publish globalThis.__dotframe (templates do).`,
83
+ The web entry must honor ?room=, ?relay=, ?mash= and publish globalThis.__dotframe: createProbe() and createMasher()
84
+ from dotframe/src/probe do both (templates show it). --native makes peer 1 the macOS build, which reads DOTFRAME_ROOM,
85
+ DOTFRAME_RELAY, DOTFRAME_MASH and writes its probe to DOTFRAME_PROBE (templates' main.native.ts does).`,
82
86
  build: `dotframe build <target> [--release] [--dry-run] [--json]
83
87
 
84
88
  Runs targets.<target>.steps from dotframe.json, or for a native target ({"native": {"platform": "macos",
@@ -88,14 +92,15 @@ Failures keep the full output in .dotframe/logs and return its path. --release r
88
92
  dotframe build web
89
93
  dotframe build ios --release`,
90
94
  deploy: `dotframe deploy <target> [--prod] [--dry-run] [--yes] [--json]
91
- dotframe deploy init <target> --provider dokploy [--compose <id>] [--yes]
95
+ dotframe deploy init <target> --provider dokploy [--compose <id>] [--site <dir>] [--path /play/] [--yes]
92
96
 
93
97
  provider vercel: uploads targets.<target>.out. provider dokploy: redeploys the compose stack (it builds from the
94
98
  pushed branch), waits for the result, and on failure returns the build log. Without --yes it stops with
95
99
  APPROVAL_REQUIRED (exit 2); show the --dry-run plan to a human first.
96
100
 
97
101
  init writes deploy/Dockerfile.web (installs "requires" tools, builds with dotframe), deploy/Dockerfile.relay
98
- (dotframe relay serve), deploy/nginx.conf (no-cache index.html) and deploy/compose.yaml (web at /, relay at /relay).
102
+ (dotframe relay serve), deploy/nginx.conf (no-cache HTML) and deploy/compose.yaml (web at /, relay at /relay).
103
+ --site serves a static folder (a landing page) at / with the game at --path (default /play/ with a site).
99
104
 
100
105
  dotframe deploy web --prod --dry-run
101
106
  dotframe deploy init web --provider dokploy`,
@@ -106,8 +111,11 @@ serve runs the netplay relay locally (PORT env or --port): it pairs two clients
106
111
  messages, the protocol connectRelay in dotframe/src/netplay speaks. deploy redeploys the production relay
107
112
  (dokploy) or deploys it to a region (fly), gated like deploy.`,
108
113
  device: `dotframe device install <target> [--dry-run] [--yes]
114
+ dotframe device logs <target> [--json]
109
115
 
110
- Installs targets.<target>.app on targets.<target>.device with devicectl. Gated like deploy.`,
116
+ install puts the built app on targets.<target>.device with devicectl (gated like deploy). logs copies the newest
117
+ crash report of the app's process from the device into .dotframe/logs and prints the exception, termination and
118
+ the crashed thread's frames (read-only on the device).`,
111
119
  vendor: `dotframe vendor <macos|windows> [--dry-run]
112
120
 
113
121
  Downloads wgpu-native and builds SDL3 for native targets into DOTFRAME_VENDOR (default ~/.dotframe/vendor), which
@@ -121,7 +129,7 @@ symlinks; --docker builds the dokploy web image locally, as the server would.`,
121
129
  dotframe config set <key> <json> [--dry-run]
122
130
 
123
131
  Dotted keys. Values are JSON (strings need inner quotes). set rewrites dotframe.json with 2-space indent and short
124
- objects and arrays on one line.
132
+ objects and arrays on one line. vendor is per machine: dotframe config set vendor <dir> writes ~/.dotframe/config.json.
125
133
 
126
134
  dotframe config set targets.web.deploy.scope '"my-team"'`,
127
135
  new: `dotframe new <name> [--template fighter|platformer|blank] [--no-install] [--json]
@@ -168,6 +176,10 @@ const { values, positionals } = parseArgs({
168
176
  "through-over": { type: "boolean" },
169
177
  docker: { type: "boolean" },
170
178
  online: { type: "boolean" },
179
+ seeds: { type: "string" },
180
+ site: { type: "string" },
181
+ native: { type: "boolean" },
182
+ path: { type: "string" },
171
183
  provider: { type: "string" },
172
184
  compose: { type: "string" },
173
185
  },
@@ -181,14 +193,14 @@ try {
181
193
  if (values.version) console.log(pkg.version);
182
194
  else if (command && values.help && COMMAND_HELP[command]) console.log(COMMAND_HELP[command]);
183
195
  else if (!command || values.help) console.log(HELP);
184
- else if (command === "play" && values.online === true) await playOnline(ctx, rest[0], { frames: v.frames, out: v.out });
196
+ else if (command === "play" && values.online === true) await playOnline(ctx, rest[0], { frames: v.frames, out: v.out, seeds: v.seeds, native: values.native === true });
185
197
  else if (command === "sim") await sim(ctx, v);
186
198
  else if (command === "snap") await snap(ctx, v);
187
199
  else if (command === "replay" && rest[0] === "record") await record(ctx, rest[1], v);
188
200
  else if (command === "replay" && rest[0] === "verify") await verify(ctx, rest.slice(1));
189
201
  else if (command === "desync") await desync(ctx, v);
190
202
  else if (command === "build") await build(ctx, rest[0], values.release === true);
191
- else if (command === "deploy" && rest[0] === "init") await deployInit(ctx, rest[1], v.provider, v.compose);
203
+ else if (command === "deploy" && rest[0] === "init") await deployInit(ctx, rest[1], v.provider, v.compose, v.site, v.path);
192
204
  else if (command === "deploy") await deploy(ctx, rest[0], values.prod === true);
193
205
  else if (command === "relay" && rest[0] === "deploy") await relay(ctx, v.region);
194
206
  else if (command === "relay" && rest[0] === "serve") {
@@ -198,6 +210,7 @@ try {
198
210
  await new Promise((): void => {});
199
211
  }
200
212
  else if (command === "device" && rest[0] === "install") await device(ctx, rest[1] ?? "ios");
213
+ else if (command === "device" && rest[0] === "logs") await deviceLogs(ctx, rest[1] ?? "ios");
201
214
  else if (command === "vendor") await vendor(ctx, rest[0]);
202
215
  else if (command === "doctor") await doctor(ctx, values.fix === true, values.docker === true);
203
216
  else if (command === "config") await configCmd(ctx, rest);
package/cli/native.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, relative, resolve, sep } from "node:path";
4
- import { CliError, type Ctx, exec, home, print, type RunResult, which } from "./lib";
4
+ import { CliError, type Ctx, exec, home, loadUserConfig, print, type RunResult, which } from "./lib";
5
5
 
6
6
  // The engine this CLI belongs to: a git checkout or node_modules/dotframe.
7
7
  export const ENGINE = resolve(import.meta.dir, "..");
@@ -16,10 +16,12 @@ export interface NativeTarget {
16
16
  name?: string;
17
17
  }
18
18
 
19
- // Vendored SDL3 and wgpu-native: DOTFRAME_VENDOR, else a checkout's own vendor/ when it is populated, else a
20
- // per-user cache that survives reinstalling the package.
19
+ // Vendored SDL3 and wgpu-native: DOTFRAME_VENDOR, else `dotframe config set vendor <dir>`, else a checkout's own
20
+ // vendor/ when it is populated, else a per-user cache that survives reinstalling the package.
21
21
  export function vendorDir(platform: NativePlatform): string {
22
22
  if (process.env.DOTFRAME_VENDOR) return resolve(home(process.env.DOTFRAME_VENDOR));
23
+ const configured = loadUserConfig().vendor;
24
+ if (configured) return resolve(home(configured));
23
25
  const local = join(ENGINE, "vendor");
24
26
  if (existsSync(join(local, "wgpu", platform))) return local;
25
27
  return join(homedir(), ".dotframe", "vendor");
@@ -46,7 +48,7 @@ export function sdlLibrary(platform: NativePlatform): string {
46
48
  }
47
49
 
48
50
  export function vendorFix(platform: NativePlatform): string {
49
- return `dotframe vendor ${platform} (builds into ~/.dotframe/vendor), or point DOTFRAME_VENDOR at an existing vendor dir such as a dotframe checkout's vendor/`;
51
+ return `dotframe vendor ${platform} (builds into ~/.dotframe/vendor), or reuse an existing vendor dir such as a dotframe checkout's vendor/: dotframe config set vendor <dir> (once per machine) or DOTFRAME_VENDOR=<dir>`;
50
52
  }
51
53
 
52
54
  const SPECIFIER = /(?:from\s+|import\s*\(\s*|import\s+)(["'])([^"']+)\1/g;
@@ -149,17 +151,18 @@ export function stageGame(root: string, entry: string, tree: string, engineStage
149
151
  return join(tree, relative(base, realpathSync(root)));
150
152
  }
151
153
 
152
- export async function buildNative(ctx: Ctx, root: string, gameName: string, targetName: string, t: NativeTarget): Promise<{ binary: string; log: string; steps: RunResult[] }> {
154
+ // check: compile the TypeScript only (scriptc --emit ir, no C, no link), for doctor.
155
+ export async function buildNative(ctx: Ctx, root: string, gameName: string, targetName: string, t: NativeTarget, check = false): Promise<{ binary: string; log: string; steps: RunResult[] }> {
153
156
  const platform = t.platform;
154
157
  const skill = platform;
155
158
  const tools = platform === "macos" ? ["clang", "scriptc"] : ["zig", "scriptc"];
156
159
  for (const tool of tools) if (!which(tool)) throw new CliError("TOOL_MISSING", `${tool} not found`, tool === "scriptc" ? "npm i -g scriptc" : tool === "zig" ? "brew install zig" : "xcode-select --install", skill);
157
160
  const vendor = vendorStatus(platform);
158
- if (vendor.missing.length > 0) throw new CliError("VENDOR_MISSING", `native ${platform} needs SDL3 and wgpu-native in ${vendor.dir}; missing ${vendor.missing.join(", ")}`, vendorFix(platform), skill);
161
+ if (vendor.missing.length > 0 && !check) throw new CliError("VENDOR_MISSING", `native ${platform} needs SDL3 and wgpu-native in ${vendor.dir}; missing ${vendor.missing.join(", ")}`, vendorFix(platform), skill);
159
162
 
160
- const stage = join(root, ".dotframe", "native", targetName);
163
+ const stage = join(root, ".dotframe", check ? "check" : "native", targetName);
161
164
  const logDir = join(root, ".dotframe", "logs");
162
- const log = join(logDir, `build-${targetName}.log`);
165
+ const log = join(logDir, `${check ? "check" : "build"}-${targetName}.log`);
163
166
  const out = join(root, "dist", targetName);
164
167
  const binary = join(out, `${t.name ?? gameName}${platform === "windows" ? ".exe" : ""}`);
165
168
  if (!existsSync(join(root, t.entry))) throw new CliError("ENTRY_MISSING", `native entry ${t.entry} does not exist`, `add ${t.entry} (see the macos skill) or fix targets.${targetName}.native.entry`, skill);
@@ -179,12 +182,16 @@ export async function buildNative(ctx: Ctx, root: string, gameName: string, targ
179
182
  mkdirSync(lib, { recursive: true });
180
183
  const cc = platform === "macos" ? ["clang", "-O2", "-mmacosx-version-min=14.0"] : ["zig", "cc", "-target", "x86_64-windows-gnu", "-O2"];
181
184
  const ar = platform === "macos" ? ["ar", "rcs"] : ["zig", "ar", "rcs"];
185
+ // The WebSocket client is Objective-C on Apple platforms (NSURLSession) and WinHTTP on Windows.
186
+ const ws = platform === "macos" ? { source: "df_ws_apple.m", flags: ["-fobjc-arc"] } : { source: "df_ws_win.c", flags: [] as string[] };
182
187
  const steps = [
183
188
  ...["df_native", "df_audio"].map((unit) => ({ label: `cc ${unit}`, argv: [...cc, "-c", join(ENGINE, "native", `${unit}.c`), ...inc, "-o", join(lib, `${unit}.o`)] })),
184
- { label: "ar libdf_native", argv: [...ar, join(lib, "libdf_native.a"), join(lib, "df_native.o"), join(lib, "df_audio.o")] },
189
+ { label: "cc df_ws", argv: [...cc, ...ws.flags, "-c", join(ENGINE, "native", ws.source), ...inc, "-o", join(lib, "df_ws.o")] },
190
+ { label: "ar libdf_native", argv: [...ar, join(lib, "libdf_native.a"), join(lib, "df_native.o"), join(lib, "df_audio.o"), join(lib, "df_ws.o")] },
185
191
  ];
186
192
  const ffi = JSON.parse(readFileSync(join(ENGINE, "native", `ffi.${platform}.json`), "utf8")) as { libraries: string[] };
187
- ffi.libraries = [join(lib, "libdf_native.a"), join(vendor.dir, "build", `sdl-${platform}`, "libSDL3.a"), join(vendor.dir, "wgpu", platform, "lib", "libwgpu_native.a")];
193
+ // scriptc validates that listed libraries exist even when it only emits IR, and a check links nothing.
194
+ ffi.libraries = check ? [] : [join(lib, "libdf_native.a"), join(vendor.dir, sdlLibrary(platform)), join(vendor.dir, "wgpu", platform, "lib", "libwgpu_native.a")];
188
195
  writeFileSync(join(stage, "ffi.json"), JSON.stringify(ffi, null, 2));
189
196
  let env: Record<string, string> = {};
190
197
  if (platform === "windows") {
@@ -195,13 +202,15 @@ export async function buildNative(ctx: Ctx, root: string, gameName: string, targ
195
202
  }
196
203
  const scriptc = {
197
204
  label: "scriptc",
198
- argv: ["scriptc", "build", t.entry, "--ffi", join(stage, "ffi.json"), ...(platform === "windows" ? ["--windows-subsystem", "gui"] : []), "-o", binary],
205
+ argv: check
206
+ ? ["scriptc", "build", t.entry, "--ffi", join(stage, "ffi.json"), "--emit", "ir", "-o", join(stage, "check.ll")]
207
+ : ["scriptc", "build", t.entry, "--ffi", join(stage, "ffi.json"), ...(platform === "windows" ? ["--windows-subsystem", "gui"] : []), "-o", binary],
199
208
  cwd: gameStage,
200
209
  env,
201
210
  };
202
211
  const results: RunResult[] = [];
203
212
  const full: string[] = [];
204
- for (const step of [...steps, scriptc]) {
213
+ for (const step of check ? [scriptc] : [...steps, scriptc]) {
205
214
  if (!ctx.json) console.error(`> ${step.label}`);
206
215
  const r = await exec(ctx, root, step);
207
216
  full.push(`> ${step.label}: ${step.argv.join(" ")}\n${r.output}`);
package/native/df_audio.c CHANGED
@@ -264,3 +264,15 @@ void df_audio_close(void) {
264
264
  if (g_lock) SDL_DestroyMutex(g_lock);
265
265
  g_lock = NULL;
266
266
  }
267
+
268
+ // One host call for music and volume, because scriptc library mode (iOS) caps a library at 32 callbacks.
269
+ // op 0 plays track a (loop when b != 0) at volume c, 1 stops, 2 pauses (a != 0) or resumes, 3 sets the music volume
270
+ // to a, 4 sets the master volume to a. Returns df_music_play's result for op 0, else 0.
271
+ int32_t df_music(int32_t op, double a, double b, double c) {
272
+ if (op == 0) return df_music_play((int32_t)a, b != 0, c);
273
+ if (op == 1) df_music_stop();
274
+ else if (op == 2) df_music_pause(a != 0);
275
+ else if (op == 3) df_music_volume(a);
276
+ else if (op == 4) df_master_volume(a);
277
+ return 0;
278
+ }
@@ -2,6 +2,7 @@
2
2
  #include <SDL3/SDL.h>
3
3
  #include <stdint.h>
4
4
  #include <stdio.h>
5
+ #include <stdlib.h>
5
6
  #include <string.h>
6
7
  #include <webgpu/webgpu.h>
7
8
  #include <webgpu/wgpu.h>
@@ -17,8 +18,6 @@
17
18
 
18
19
  #define DF_MAX_PIPELINES 64
19
20
  #define DF_MAX_BUFFERS 1024
20
- #define DF_MAX_BIND_GROUPS 1024
21
- #define DF_MAX_TEXTURES 256
22
21
 
23
22
  enum { DF_USAGE_VERTEX = 1, DF_USAGE_INDEX = 2, DF_USAGE_UNIFORM = 4 };
24
23
  enum { DF_PIPELINE_DEPTH = 1, DF_PIPELINE_BLEND = 4 };
@@ -35,13 +34,29 @@ static WGPURenderPipeline g_pipelines[DF_MAX_PIPELINES];
35
34
  static int32_t g_pipeline_count;
36
35
  static WGPUBuffer g_buffers[DF_MAX_BUFFERS];
37
36
  static int32_t g_buffer_count;
38
- static WGPUBindGroup g_bind_groups[DF_MAX_BIND_GROUPS];
37
+ // Bind groups and textures grow as needed; ids are never reused, so a destroyed id never aliases a new resource.
38
+ static WGPUBindGroup *g_bind_groups;
39
+ // The texture a bind group samples, or -1, so destroying a texture also releases the groups that hold it.
40
+ static int32_t *g_bind_group_textures;
39
41
  static int32_t g_bind_group_count;
40
- static WGPUTexture g_textures[DF_MAX_TEXTURES];
41
- static WGPUTextureView g_texture_views[DF_MAX_TEXTURES];
42
- static int32_t g_texture_sizes[DF_MAX_TEXTURES][2];
43
- static uint8_t g_texture_smooth[DF_MAX_TEXTURES];
42
+ static int32_t g_bind_group_capacity;
43
+ static WGPUTexture *g_textures;
44
+ static WGPUTextureView *g_texture_views;
45
+ static int32_t (*g_texture_sizes)[2];
46
+ static uint8_t *g_texture_smooth;
44
47
  static int32_t g_texture_count;
48
+ static int32_t g_texture_capacity;
49
+
50
+ // Grows a parallel array to hold at least `need` elements; returns 0 when memory runs out.
51
+ static int grow(void **array, int32_t *capacity, int32_t need, size_t size) {
52
+ if (need <= *capacity) return 1;
53
+ int32_t next = *capacity > 0 ? *capacity : 64;
54
+ while (next < need) next *= 2;
55
+ void *grown = realloc(*array, (size_t)next * size);
56
+ if (!grown) return 0;
57
+ *array = grown;
58
+ return 1;
59
+ }
45
60
  static WGPUSampler g_sampler;
46
61
  static WGPUSampler g_sampler_linear;
47
62
  static WGPUTexture g_depth_texture;
@@ -292,7 +307,17 @@ void df_buffer_write(int32_t buffer, const uint8_t *data, size_t len) {
292
307
 
293
308
  // smooth selects linear filtering (fonts, photos) instead of nearest (pixel art).
294
309
  int32_t df_texture(int32_t width, int32_t height, const uint8_t *rgba, size_t len, uint8_t smooth) {
295
- if (g_texture_count >= DF_MAX_TEXTURES || width <= 0 || height <= 0) return -1;
310
+ if (width <= 0 || height <= 0) return -1;
311
+ int32_t capacity = g_texture_capacity;
312
+ int32_t need = g_texture_count + 1;
313
+ if (!grow((void **)&g_textures, &capacity, need, sizeof *g_textures)) return -4;
314
+ capacity = g_texture_capacity;
315
+ if (!grow((void **)&g_texture_views, &capacity, need, sizeof *g_texture_views)) return -4;
316
+ capacity = g_texture_capacity;
317
+ if (!grow((void **)&g_texture_sizes, &capacity, need, sizeof *g_texture_sizes)) return -4;
318
+ capacity = g_texture_capacity;
319
+ if (!grow((void **)&g_texture_smooth, &capacity, need, sizeof *g_texture_smooth)) return -4;
320
+ g_texture_capacity = capacity;
296
321
  if (len < (size_t)width * (size_t)height * 4) return -2;
297
322
  WGPUTextureDescriptor desc = WGPU_TEXTURE_DESCRIPTOR_INIT;
298
323
  desc.usage = WGPUTextureUsage_TextureBinding | WGPUTextureUsage_CopyDst;
@@ -324,6 +349,21 @@ int32_t df_image(const uint8_t *png, size_t len, uint8_t smooth) {
324
349
  return id;
325
350
  }
326
351
 
352
+ // Releases a texture and every bind group that samples it. The id stays retired.
353
+ void df_texture_destroy(int32_t texture) {
354
+ if (texture < 0 || texture >= g_texture_count || !g_textures[texture]) return;
355
+ for (int32_t i = 0; i < g_bind_group_count; i++) {
356
+ if (g_bind_group_textures[i] == texture && g_bind_groups[i]) {
357
+ wgpuBindGroupRelease(g_bind_groups[i]);
358
+ g_bind_groups[i] = NULL;
359
+ }
360
+ }
361
+ wgpuTextureViewRelease(g_texture_views[texture]);
362
+ wgpuTextureRelease(g_textures[texture]);
363
+ g_texture_views[texture] = NULL;
364
+ g_textures[texture] = NULL;
365
+ }
366
+
327
367
  // axis 0 width, 1 height.
328
368
  int32_t df_texture_size(int32_t texture, int32_t axis) {
329
369
  return texture >= 0 && texture < g_texture_count ? g_texture_sizes[texture][axis ? 1 : 0] : 0;
@@ -397,7 +437,13 @@ int32_t df_pipeline(const uint8_t *wgsl, size_t wgsl_len, uint32_t stride, const
397
437
  // Pass -1 for a resource the shader does not declare.
398
438
  int32_t df_bind(int32_t pipeline, int32_t buffer, int32_t texture) {
399
439
  if (pipeline < 0 || pipeline >= g_pipeline_count) return -1;
400
- if (g_bind_group_count >= DF_MAX_BIND_GROUPS) return -2;
440
+ int32_t capacity = g_bind_group_capacity;
441
+ if (!grow((void **)&g_bind_groups, &capacity, g_bind_group_count + 1, sizeof *g_bind_groups)) return -2;
442
+ capacity = g_bind_group_capacity;
443
+ if (!grow((void **)&g_bind_group_textures, &capacity, g_bind_group_count + 1, sizeof *g_bind_group_textures)) return -2;
444
+ g_bind_group_capacity = capacity;
445
+ // A destroyed texture cannot be bound again.
446
+ if (texture >= 0 && texture < g_texture_count && !g_textures[texture]) return -3;
401
447
  WGPUBindGroupEntry entries[3];
402
448
  size_t count = 0;
403
449
  if (buffer >= 0 && buffer < g_buffer_count) {
@@ -424,6 +470,7 @@ int32_t df_bind(int32_t pipeline, int32_t buffer, int32_t texture) {
424
470
  WGPUBindGroup group = wgpuDeviceCreateBindGroup(g_device, &desc);
425
471
  wgpuBindGroupLayoutRelease(desc.layout);
426
472
  g_bind_groups[g_bind_group_count] = group;
473
+ g_bind_group_textures[g_bind_group_count] = texture >= 0 && texture < g_texture_count ? texture : -1;
427
474
  return g_bind_group_count++;
428
475
  }
429
476
 
@@ -554,6 +601,8 @@ int32_t df_begin(double r, double g, double b, uint8_t use_depth) {
554
601
  void df_draw(int32_t pipeline, int32_t bind_group, int32_t vertex_buffer, int32_t index_buffer, uint32_t first,
555
602
  uint32_t count) {
556
603
  if (!g_frame_pass || pipeline < 0 || pipeline >= g_pipeline_count) return;
604
+ // A group released with its texture draws nothing rather than sampling freed memory.
605
+ if (bind_group >= 0 && bind_group < g_bind_group_count && !g_bind_groups[bind_group]) return;
557
606
  wgpuRenderPassEncoderSetPipeline(g_frame_pass, g_pipelines[pipeline]);
558
607
  if (bind_group >= 0 && bind_group < g_bind_group_count)
559
608
  wgpuRenderPassEncoderSetBindGroup(g_frame_pass, 0, g_bind_groups[bind_group], 0, NULL);
@@ -586,13 +635,21 @@ void df_audio_close(void);
586
635
 
587
636
  void df_close(void) {
588
637
  df_audio_close();
589
- for (int32_t i = 0; i < g_bind_group_count; i++) wgpuBindGroupRelease(g_bind_groups[i]);
638
+ for (int32_t i = 0; i < g_bind_group_count; i++)
639
+ if (g_bind_groups[i]) wgpuBindGroupRelease(g_bind_groups[i]);
640
+ free(g_bind_groups);
641
+ free(g_bind_group_textures);
590
642
  for (int32_t i = 0; i < g_buffer_count; i++)
591
643
  if (g_buffers[i]) wgpuBufferRelease(g_buffers[i]);
592
644
  for (int32_t i = 0; i < g_texture_count; i++) {
645
+ if (!g_textures[i]) continue;
593
646
  wgpuTextureViewRelease(g_texture_views[i]);
594
647
  wgpuTextureRelease(g_textures[i]);
595
648
  }
649
+ free(g_textures);
650
+ free(g_texture_views);
651
+ free(g_texture_sizes);
652
+ free(g_texture_smooth);
596
653
  if (g_sampler) wgpuSamplerRelease(g_sampler);
597
654
  if (g_sampler_linear) wgpuSamplerRelease(g_sampler_linear);
598
655
  for (int32_t i = 0; i < g_pipeline_count; i++) wgpuRenderPipelineRelease(g_pipelines[i]);
@@ -0,0 +1,197 @@
1
+ // WebSocket client for the native relay transport on macOS and iOS, over NSURLSessionWebSocketTask (TLS included).
2
+ // The game polls from its frame: open, state, send, then next/byte/pop to read a message one byte at a time, since
3
+ // scriptc library mode (iOS) cannot hand C a buffer to fill. Built with ARC.
4
+ #import <Foundation/Foundation.h>
5
+ #include <stdint.h>
6
+ #include <stdio.h>
7
+ #include <stdlib.h>
8
+ #if TARGET_OS_IPHONE
9
+ #import <UIKit/UIKit.h>
10
+ #endif
11
+ #include <SDL3/SDL.h>
12
+
13
+ enum { DF_WS_CONNECTING = 0, DF_WS_OPEN = 1, DF_WS_CLOSED = 2, DF_WS_ERROR = 3 };
14
+
15
+ // DF_WS_DEBUG=1 logs socket events and errors to stderr.
16
+ static void ws_log(NSString *format, ...) {
17
+ if (!getenv("DF_WS_DEBUG")) return;
18
+ va_list args;
19
+ va_start(args, format);
20
+ NSString *line = [[NSString alloc] initWithFormat:format arguments:args];
21
+ va_end(args);
22
+ fprintf(stderr, "df_ws: %s\n", line.UTF8String);
23
+ }
24
+
25
+ @interface DFSocket : NSObject <NSURLSessionWebSocketDelegate>
26
+ @property(nonatomic, strong) NSURLSession *session;
27
+ @property(nonatomic, strong) NSURLSessionWebSocketTask *task;
28
+ @property(nonatomic, strong) NSMutableArray<NSData *> *inbox;
29
+ @property(atomic) int32_t state;
30
+ @end
31
+
32
+ @implementation DFSocket
33
+ - (void)receive {
34
+ __weak DFSocket *weakSelf = self;
35
+ [self.task receiveMessageWithCompletionHandler:^(NSURLSessionWebSocketMessage *message, NSError *error) {
36
+ DFSocket *socket = weakSelf;
37
+ if (!socket) return;
38
+ if (error) {
39
+ ws_log(@"receive failed: %@", error);
40
+ if (socket.state != DF_WS_CLOSED) socket.state = DF_WS_ERROR;
41
+ return;
42
+ }
43
+ NSData *data = message.type == NSURLSessionWebSocketMessageTypeString ? [message.string dataUsingEncoding:NSUTF8StringEncoding] : message.data;
44
+ @synchronized(socket.inbox) {
45
+ [socket.inbox addObject:data ?: [NSData data]];
46
+ }
47
+ [socket receive];
48
+ }];
49
+ }
50
+ - (void)URLSession:(NSURLSession *)session webSocketTask:(NSURLSessionWebSocketTask *)webSocketTask didOpenWithProtocol:(NSString *)protocol {
51
+ ws_log(@"open");
52
+ self.state = DF_WS_OPEN;
53
+ }
54
+ - (void)URLSession:(NSURLSession *)session webSocketTask:(NSURLSessionWebSocketTask *)webSocketTask didCloseWithCode:(NSURLSessionWebSocketCloseCode)closeCode reason:(NSData *)reason {
55
+ self.state = DF_WS_CLOSED;
56
+ }
57
+ - (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
58
+ if (error) ws_log(@"task failed: %@", error);
59
+ if (error && self.state != DF_WS_CLOSED) self.state = DF_WS_ERROR;
60
+ }
61
+ @end
62
+
63
+ static NSMutableArray *g_sockets;
64
+
65
+ static DFSocket *socket_at(int32_t handle) {
66
+ if (!g_sockets || handle < 0 || handle >= (int32_t)g_sockets.count) return nil;
67
+ id entry = g_sockets[handle];
68
+ return entry == [NSNull null] ? nil : (DFSocket *)entry;
69
+ }
70
+
71
+ // Returns a socket id; poll df_ws_state until it is open (1), closed (2) or failed (3).
72
+ static int32_t df_ws_open(const uint8_t *url, size_t len) {
73
+ @autoreleasepool {
74
+ if (!g_sockets) g_sockets = [NSMutableArray array];
75
+ NSString *text = [[NSString alloc] initWithBytes:url length:len encoding:NSUTF8StringEncoding];
76
+ NSURL *target = text ? [NSURL URLWithString:text] : nil;
77
+ if (!target) return -1;
78
+ DFSocket *socket = [DFSocket new];
79
+ socket.inbox = [NSMutableArray array];
80
+ socket.state = DF_WS_CONNECTING;
81
+ socket.session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:socket delegateQueue:nil];
82
+ socket.task = [socket.session webSocketTaskWithURL:target];
83
+ ws_log(@"connecting to %@", target);
84
+ [socket.task resume];
85
+ [socket receive];
86
+ [g_sockets addObject:socket];
87
+ return (int32_t)g_sockets.count - 1;
88
+ }
89
+ }
90
+
91
+ static int32_t df_ws_state(int32_t handle) {
92
+ DFSocket *socket = socket_at(handle);
93
+ return socket ? socket.state : DF_WS_CLOSED;
94
+ }
95
+
96
+ // Sends one text frame. Returns 0, or -1 when the socket is not open.
97
+ static int32_t df_ws_send(int32_t handle, const uint8_t *data, size_t len) {
98
+ @autoreleasepool {
99
+ DFSocket *socket = socket_at(handle);
100
+ if (!socket || socket.state != DF_WS_OPEN) return -1;
101
+ NSString *text = [[NSString alloc] initWithBytes:data length:len encoding:NSUTF8StringEncoding];
102
+ if (!text) return -2;
103
+ __weak DFSocket *weakSocket = socket;
104
+ [socket.task sendMessage:[[NSURLSessionWebSocketMessage alloc] initWithString:text] completionHandler:^(NSError *error) {
105
+ if (error && weakSocket && weakSocket.state != DF_WS_CLOSED) weakSocket.state = DF_WS_ERROR;
106
+ }];
107
+ return 0;
108
+ }
109
+ }
110
+
111
+ // Length in bytes of the oldest unread message, or -1 when there is none.
112
+ static int32_t df_ws_next(int32_t handle) {
113
+ DFSocket *socket = socket_at(handle);
114
+ if (!socket) return -1;
115
+ @synchronized(socket.inbox) {
116
+ return socket.inbox.count > 0 ? (int32_t)socket.inbox[0].length : -1;
117
+ }
118
+ }
119
+
120
+ // Byte `index` of the oldest unread message, or -1 past its end.
121
+ static int32_t df_ws_byte(int32_t handle, int32_t index) {
122
+ DFSocket *socket = socket_at(handle);
123
+ if (!socket) return -1;
124
+ @synchronized(socket.inbox) {
125
+ if (socket.inbox.count == 0) return -1;
126
+ NSData *data = socket.inbox[0];
127
+ if (index < 0 || (NSUInteger)index >= data.length) return -1;
128
+ return ((const uint8_t *)data.bytes)[index];
129
+ }
130
+ }
131
+
132
+ // Drops the oldest unread message.
133
+ static void df_ws_pop(int32_t handle) {
134
+ DFSocket *socket = socket_at(handle);
135
+ if (!socket) return;
136
+ @synchronized(socket.inbox) {
137
+ if (socket.inbox.count > 0) [socket.inbox removeObjectAtIndex:0];
138
+ }
139
+ }
140
+
141
+ static void df_ws_close(int32_t handle) {
142
+ DFSocket *socket = socket_at(handle);
143
+ if (!socket) return;
144
+ socket.state = DF_WS_CLOSED;
145
+ [socket.task cancelWithCloseCode:NSURLSessionWebSocketCloseCodeNormalClosure reason:nil];
146
+ [socket.session invalidateAndCancel];
147
+ g_sockets[handle] = (id)[NSNull null];
148
+ }
149
+
150
+ // Shares text (a room link): the system share sheet on iOS, the clipboard on macOS. Returns 1 for the share sheet,
151
+ // 2 for the clipboard, 0 on failure.
152
+ static int32_t df_share(const uint8_t *text, size_t len) {
153
+ @autoreleasepool {
154
+ NSString *value = [[NSString alloc] initWithBytes:text length:len encoding:NSUTF8StringEncoding];
155
+ if (!value) return 0;
156
+ #if TARGET_OS_IPHONE
157
+ UIViewController *root = nil;
158
+ for (UIScene *scene in UIApplication.sharedApplication.connectedScenes) {
159
+ if ([scene isKindOfClass:UIWindowScene.class]) root = ((UIWindowScene *)scene).keyWindow.rootViewController;
160
+ }
161
+ if (!root) return SDL_SetClipboardText(value.UTF8String) ? 2 : 0;
162
+ UIActivityViewController *sheet = [[UIActivityViewController alloc] initWithActivityItems:@[ value ] applicationActivities:nil];
163
+ // iPad presents the sheet as a popover, which needs an anchor.
164
+ sheet.popoverPresentationController.sourceView = root.view;
165
+ sheet.popoverPresentationController.sourceRect = CGRectMake(root.view.bounds.size.width / 2, root.view.bounds.size.height / 2, 1, 1);
166
+ [root presentViewController:sheet animated:YES completion:nil];
167
+ return 1;
168
+ #else
169
+ return SDL_SetClipboardText(value.UTF8String) ? 2 : 0;
170
+ #endif
171
+ }
172
+ }
173
+
174
+ // The exported surface is two calls, because scriptc library mode (iOS) caps a library at 32 host callbacks.
175
+ // op 0 opens text as a URL (returns a socket), 1 sends text on handle, 2 shares text.
176
+ int32_t df_ws_text(int32_t op, int32_t handle, const uint8_t *text, size_t len) {
177
+ if (op == 0) return df_ws_open(text, len);
178
+ if (op == 1) return df_ws_send(handle, text, len);
179
+ if (op == 2) return df_share(text, len);
180
+ return -1;
181
+ }
182
+
183
+ // op 0 state, 1 length of the next message, 2 byte arg of it, 3 drop it, 4 close.
184
+ int32_t df_ws(int32_t op, int32_t handle, int32_t arg) {
185
+ if (op == 0) return df_ws_state(handle);
186
+ if (op == 1) return df_ws_next(handle);
187
+ if (op == 2) return df_ws_byte(handle, arg);
188
+ if (op == 3) {
189
+ df_ws_pop(handle);
190
+ return 0;
191
+ }
192
+ if (op == 4) {
193
+ df_ws_close(handle);
194
+ return 0;
195
+ }
196
+ return -1;
197
+ }