dotframe 0.1.9 → 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/CHANGELOG.md +15 -0
- package/cli/commands/config.ts +21 -3
- package/cli/commands/docker.ts +36 -19
- package/cli/commands/doctor.ts +24 -3
- package/cli/commands/online.ts +37 -6
- package/cli/commands/ship.ts +57 -1
- package/cli/ios.ts +58 -11
- package/cli/lib.ts +29 -1
- package/cli/main.ts +19 -9
- package/cli/native.ts +21 -12
- package/native/df_audio.c +12 -0
- package/native/df_native.c +67 -10
- package/native/df_ws_apple.m +197 -0
- package/native/df_ws_win.c +231 -0
- package/native/ffi.macos.json +28 -29
- package/native/ffi.windows.json +30 -30
- package/package.json +1 -1
- package/scripts/build-native.sh +6 -2
- package/skill-data/core/SKILL.md +3 -1
- package/skill-data/export-web/SKILL.md +1 -0
- package/skill-data/ios/SKILL.md +6 -1
- package/skill-data/macos/SKILL.md +7 -0
- package/skill-data/netplay/SKILL.md +5 -1
- package/src/draw2d.ts +7 -0
- package/src/gpu.ts +3 -0
- package/src/native/backend.ts +8 -10
- package/src/native/ffi.ts +9 -5
- package/src/native/relay.ts +95 -0
- package/src/native/run.ts +1 -0
- package/src/probe-web.ts +7 -0
- package/src/probe.ts +3 -3
- package/src/ui.ts +191 -0
- package/src/web/run.ts +38 -10
- package/templates/_base/main.ios.ts +1 -1
- package/templates/_base/main.native.ts +27 -2
- package/templates/_base/main.web.ts +3 -1
- package/templates/_base/src/setup.ts +22 -7
- package/tools/gen-library-glue.ts +10 -4
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";
|
|
@@ -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,13 +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] [--seeds 1,2] [--out .dotframe/play] [--json]
|
|
78
|
+
play: `dotframe play --online [room] [--frames 600] [--seeds 1,2] [--native] [--out .dotframe/play] [--json]
|
|
78
79
|
|
|
79
80
|
Builds the web target, starts a local relay, and opens two agent-browser sessions on ?room=<room>&relay=...&mash=<seed>
|
|
80
81
|
(one seed per peer from --seeds).
|
|
81
82
|
Waits until both confirm --frames frames, compares their checksums every 30 frames, and saves a screenshot of each.
|
|
82
83
|
The web entry must honor ?room=, ?relay=, ?mash= and publish globalThis.__dotframe: createProbe() and createMasher()
|
|
83
|
-
from dotframe/src/probe do both (templates show it)
|
|
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).`,
|
|
84
86
|
build: `dotframe build <target> [--release] [--dry-run] [--json]
|
|
85
87
|
|
|
86
88
|
Runs targets.<target>.steps from dotframe.json, or for a native target ({"native": {"platform": "macos",
|
|
@@ -90,14 +92,15 @@ Failures keep the full output in .dotframe/logs and return its path. --release r
|
|
|
90
92
|
dotframe build web
|
|
91
93
|
dotframe build ios --release`,
|
|
92
94
|
deploy: `dotframe deploy <target> [--prod] [--dry-run] [--yes] [--json]
|
|
93
|
-
dotframe deploy init <target> --provider dokploy [--compose <id>] [--yes]
|
|
95
|
+
dotframe deploy init <target> --provider dokploy [--compose <id>] [--site <dir>] [--path /play/] [--yes]
|
|
94
96
|
|
|
95
97
|
provider vercel: uploads targets.<target>.out. provider dokploy: redeploys the compose stack (it builds from the
|
|
96
98
|
pushed branch), waits for the result, and on failure returns the build log. Without --yes it stops with
|
|
97
99
|
APPROVAL_REQUIRED (exit 2); show the --dry-run plan to a human first.
|
|
98
100
|
|
|
99
101
|
init writes deploy/Dockerfile.web (installs "requires" tools, builds with dotframe), deploy/Dockerfile.relay
|
|
100
|
-
(dotframe relay serve), deploy/nginx.conf (no-cache
|
|
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).
|
|
101
104
|
|
|
102
105
|
dotframe deploy web --prod --dry-run
|
|
103
106
|
dotframe deploy init web --provider dokploy`,
|
|
@@ -108,8 +111,11 @@ serve runs the netplay relay locally (PORT env or --port): it pairs two clients
|
|
|
108
111
|
messages, the protocol connectRelay in dotframe/src/netplay speaks. deploy redeploys the production relay
|
|
109
112
|
(dokploy) or deploys it to a region (fly), gated like deploy.`,
|
|
110
113
|
device: `dotframe device install <target> [--dry-run] [--yes]
|
|
114
|
+
dotframe device logs <target> [--json]
|
|
111
115
|
|
|
112
|
-
|
|
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).`,
|
|
113
119
|
vendor: `dotframe vendor <macos|windows> [--dry-run]
|
|
114
120
|
|
|
115
121
|
Downloads wgpu-native and builds SDL3 for native targets into DOTFRAME_VENDOR (default ~/.dotframe/vendor), which
|
|
@@ -123,7 +129,7 @@ symlinks; --docker builds the dokploy web image locally, as the server would.`,
|
|
|
123
129
|
dotframe config set <key> <json> [--dry-run]
|
|
124
130
|
|
|
125
131
|
Dotted keys. Values are JSON (strings need inner quotes). set rewrites dotframe.json with 2-space indent and short
|
|
126
|
-
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.
|
|
127
133
|
|
|
128
134
|
dotframe config set targets.web.deploy.scope '"my-team"'`,
|
|
129
135
|
new: `dotframe new <name> [--template fighter|platformer|blank] [--no-install] [--json]
|
|
@@ -171,6 +177,9 @@ const { values, positionals } = parseArgs({
|
|
|
171
177
|
docker: { type: "boolean" },
|
|
172
178
|
online: { type: "boolean" },
|
|
173
179
|
seeds: { type: "string" },
|
|
180
|
+
site: { type: "string" },
|
|
181
|
+
native: { type: "boolean" },
|
|
182
|
+
path: { type: "string" },
|
|
174
183
|
provider: { type: "string" },
|
|
175
184
|
compose: { type: "string" },
|
|
176
185
|
},
|
|
@@ -184,14 +193,14 @@ try {
|
|
|
184
193
|
if (values.version) console.log(pkg.version);
|
|
185
194
|
else if (command && values.help && COMMAND_HELP[command]) console.log(COMMAND_HELP[command]);
|
|
186
195
|
else if (!command || values.help) console.log(HELP);
|
|
187
|
-
else if (command === "play" && values.online === true) await playOnline(ctx, rest[0], { frames: v.frames, out: v.out, seeds: v.seeds });
|
|
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 });
|
|
188
197
|
else if (command === "sim") await sim(ctx, v);
|
|
189
198
|
else if (command === "snap") await snap(ctx, v);
|
|
190
199
|
else if (command === "replay" && rest[0] === "record") await record(ctx, rest[1], v);
|
|
191
200
|
else if (command === "replay" && rest[0] === "verify") await verify(ctx, rest.slice(1));
|
|
192
201
|
else if (command === "desync") await desync(ctx, v);
|
|
193
202
|
else if (command === "build") await build(ctx, rest[0], values.release === true);
|
|
194
|
-
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);
|
|
195
204
|
else if (command === "deploy") await deploy(ctx, rest[0], values.prod === true);
|
|
196
205
|
else if (command === "relay" && rest[0] === "deploy") await relay(ctx, v.region);
|
|
197
206
|
else if (command === "relay" && rest[0] === "serve") {
|
|
@@ -201,6 +210,7 @@ try {
|
|
|
201
210
|
await new Promise((): void => {});
|
|
202
211
|
}
|
|
203
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");
|
|
204
214
|
else if (command === "vendor") await vendor(ctx, rest[0]);
|
|
205
215
|
else if (command === "doctor") await doctor(ctx, values.fix === true, values.docker === true);
|
|
206
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
|
|
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
|
|
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
|
-
|
|
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,
|
|
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: "
|
|
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
|
-
|
|
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:
|
|
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
|
+
}
|
package/native/df_native.c
CHANGED
|
@@ -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
|
-
|
|
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
|
|
41
|
-
static
|
|
42
|
-
static
|
|
43
|
-
static
|
|
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 (
|
|
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
|
-
|
|
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++)
|
|
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
|
+
}
|