dotframe 0.1.3 → 0.1.5

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5
4
+
5
+ - Native builds follow the entry's import graph instead of copying the game folder: relative imports that leave the game root and workspace packages that resolve to source are staged with their relative layout and rewritten to relative paths, so ports inside monorepos compile.
6
+
7
+ ## 0.1.4
8
+
9
+ Fixes from rounds 2 and 3 of the Craft Ones dogfood.
10
+
11
+ - Native builds work from the npm package. `build` on a target with `"native": {"platform", "entry"}` stages the engine and the game in `.dotframe/native/<target>`, rewrites `dotframe/...` imports to relative paths, and writes `dist/<target>/<name>`. Templates ship `main.native.ts`, `src/setup.ts`, and macos and windows targets, and compile on both.
12
+ - `dotframe vendor <macos|windows>` puts SDL3 and wgpu-native in `~/.dotframe/vendor` (or `DOTFRAME_VENDOR`), outside `node_modules`. `doctor` checks it per native target.
13
+ - Failed build steps keep the full output in `.dotframe/logs/` and return its path as `error.log`, with the compiler's error count in the message.
14
+ - `doctor` `sim:render` fails when render changes simulation state (checksum or any `inspect()` field), not only when it draws nothing.
15
+ - `sim` and `replay` take `--through-over` to keep stepping after the match ends.
16
+ - `snap` sets the viewport after opening the page and fails with `SNAP_SIZE` when the screenshot does not match the window.
17
+ - Skills: the macos guide covers native targets, vendor, logs, and scriptc-compatible code; core covers `--through-over` and agent-browser sessions; netplay covers `--renders 0`.
18
+
3
19
  ## 0.1.3
4
20
 
5
21
  Fixes from the Craft Ones port dogfood.
@@ -3,7 +3,8 @@ import { dirname, resolve } from "node:path";
3
3
  import { type Config, type Ctx, findRoot, home, loadConfig, print, which } from "../lib";
4
4
  import type { Draw2D } from "../../src/draw2d";
5
5
  import type { Sim } from "../../src/sim";
6
- import { headlessRun, loadSim } from "../simkit";
6
+ import { vendorFix, vendorStatus } from "../native";
7
+ import { firstDifference, flatten, headlessRun, loadSim } from "../simkit";
7
8
 
8
9
  interface Check {
9
10
  check: string;
@@ -45,6 +46,13 @@ export async function doctor(ctx: Ctx, fix: boolean): Promise<void> {
45
46
  checks.push({ check: `deploy:${name}`, ok: false, detail: "deploy scope is still the template placeholder", fix: `dotframe config set targets.${name}.deploy.scope '"<vercel team>"'`, skill: "export-web" });
46
47
  }
47
48
  }
49
+ for (const [name, t] of Object.entries(config.targets)) {
50
+ if (!t.native) continue;
51
+ const v = vendorStatus(t.native.platform);
52
+ checks.push({ check: `vendor:${name}`, ok: v.missing.length === 0, detail: v.missing.length === 0 ? `${v.dir} (${v.sdl})` : `missing in ${v.dir}: ${v.missing.join(", ")}`, fix: v.missing.length === 0 ? "" : vendorFix(t.native.platform), skill: t.native.platform });
53
+ checks.push(tool("scriptc", "native builds", "npm i -g scriptc", t.native.platform));
54
+ if (t.native.platform === "windows") checks.push(tool("zig", "windows cross builds", "brew install zig", "macos"));
55
+ }
48
56
  const targets = Object.keys(config.targets);
49
57
  if (targets.some((t: string): boolean => t === "web" || t === "discord")) {
50
58
  checks.push(tool("vercel", "deploy web", "npm i -g vercel", "export-web"), tool("agent-browser", "dotframe snap", "npm i -g agent-browser && agent-browser install", "core"));
@@ -78,8 +86,9 @@ export async function doctor(ctx: Ctx, fix: boolean): Promise<void> {
78
86
  if (failed.length > 0) process.exit(1);
79
87
  }
80
88
 
81
- // desync proves rendering is pure by calling render() with a stub Draw2D. A render that draws nothing in that
82
- // case (for example, one that waits for a real renderer) makes the check pass without checking anything.
89
+ // desync proves rendering is pure by calling render() with a stub Draw2D. Two failure modes are checked here,
90
+ // instantly: a render that draws nothing with the stub (desync's check would be blind), and a render that
91
+ // changes simulation state (checksum or, with inspect(), any field).
83
92
  async function renderCheck(sim: Sim, root: string): Promise<Check> {
84
93
  const base = { check: "sim:render", skill: "netplay" };
85
94
  const run = await headlessRun(sim, root);
@@ -95,9 +104,26 @@ async function renderCheck(sim: Sim, root: string): Promise<Check> {
95
104
  },
96
105
  });
97
106
  run.start(1, { ...sim.options });
98
- for (let f = 0; f < 30; f++) run.step(new Array(sim.players).fill(sim.neutral));
99
- run.render(draw);
100
- return calls > 0
101
- ? { ...base, ok: true, detail: `render() drew ${calls} calls with a stub Draw2D`, fix: "" }
102
- : { ...base, ok: false, detail: "render() made no draw calls with a stub Draw2D, so desync never exercises it", fix: "render with whatever Draw2D it is given; do not skip when platform.draw is missing" };
107
+ let next = 1;
108
+ const random = (): number => {
109
+ next = (Math.imul(next, 1103515245) + 12345) >>> 0;
110
+ return next / 4294967296;
111
+ };
112
+ // Mashed inputs, so effects, projectiles, and HUD state exist when render runs.
113
+ let impure = "";
114
+ for (let f = 0; f < 3000 && impure === "" && !run.over(); f++) {
115
+ run.step(Array.from({ length: sim.players }, (): number => sim.random(random)));
116
+ if (f % 10 !== 0) continue;
117
+ const sum = run.checksum();
118
+ const before = run.inspect ? flatten(run.inspect()) : null;
119
+ run.render(draw);
120
+ if (run.checksum() !== sum) impure = `frame ${f}: checksum changed`;
121
+ else if (before && run.inspect) {
122
+ const diff = firstDifference(before, flatten(run.inspect()));
123
+ if (diff) impure = `frame ${f}: ${diff}`;
124
+ }
125
+ }
126
+ if (calls === 0) return { ...base, ok: false, detail: "render() made no draw calls with a stub Draw2D, so desync never exercises it", fix: "render with whatever Draw2D it is given; do not skip when platform.draw is missing" };
127
+ if (impure) return { ...base, ok: false, detail: `render() changed simulation state at ${impure}`, fix: "move that write into step(); render must only read", skill: "netplay" };
128
+ return { ...base, ok: true, detail: `render() drew ${calls} calls with a stub Draw2D and left state unchanged${run.inspect ? "" : " (checksum only; add inspect() for every field)"}`, fix: "" };
103
129
  }
@@ -54,7 +54,7 @@ export async function dev(ctx: Ctx, port: number): Promise<void> {
54
54
  const t = target(config, "web");
55
55
  if (!t.out) throw new CliError("NO_OUT", "the web target needs an out dir", "", "export-web");
56
56
  const out = resolve(config.root, t.out);
57
- await runSteps(ctx, config.root, t.steps, "export-web");
57
+ await runSteps(ctx, config.root, t.steps ?? [], "export-web");
58
58
  const server = Bun.serve({
59
59
  port,
60
60
  fetch: (req: Request): Response => {
@@ -72,7 +72,7 @@ export async function dev(ctx: Ctx, port: number): Promise<void> {
72
72
  if (!file || /(^|\/)(node_modules|dist|\.dotframe|\.git)\//.test(`${file}/`) || file.startsWith(t.out ?? "dist")) return;
73
73
  if (pending) clearTimeout(pending);
74
74
  pending = setTimeout((): void => {
75
- runSteps(ctx, config.root, t.steps, "export-web").then(
75
+ runSteps(ctx, config.root, t.steps ?? [], "export-web").then(
76
76
  (): void => console.error(`rebuilt (${file})`),
77
77
  (e: unknown): void => console.error(e instanceof Error ? e.message : e),
78
78
  );
@@ -12,6 +12,8 @@ export interface PlayArgs {
12
12
  seed?: string;
13
13
  options?: string;
14
14
  every?: string;
15
+ // Keep stepping after over(), for rematch and results flows.
16
+ throughOver?: boolean;
15
17
  }
16
18
 
17
19
  interface Played {
@@ -23,11 +25,11 @@ interface Played {
23
25
  ms: number;
24
26
  }
25
27
 
26
- function play(run: SimRun, source: InputSource, frames: number, every: number): Played {
28
+ function play(run: SimRun, source: InputSource, frames: number, every: number, throughOver = false): Played {
27
29
  const t0 = performance.now();
28
30
  const trace: { frame: number; checksum: number }[] = [];
29
31
  let f = 0;
30
- for (; f < frames && !run.over(); f++) {
32
+ for (; f < frames && (throughOver || !run.over()); f++) {
31
33
  run.step(source.at(f));
32
34
  if (every > 0 && (f + 1) % every === 0) trace.push({ frame: f + 1, checksum: run.checksum() });
33
35
  }
@@ -47,7 +49,7 @@ async function setup(args: PlayArgs): Promise<{ sim: Sim; run: SimRun; source: I
47
49
 
48
50
  export async function sim(ctx: Ctx, args: PlayArgs): Promise<void> {
49
51
  const { run, source, seed } = await setup(args);
50
- const r = play(run, source, num("frames", args.frames, 600, 1), num("every", args.every, 0));
52
+ const r = play(run, source, num("frames", args.frames, 600, 1), num("every", args.every, 0), args.throughOver === true);
51
53
  print(ctx, { seed, inputs: source.describe, ...r }, (): string =>
52
54
  [`${r.frames} frames in ${r.ms} ms (${source.describe}, seed ${seed})${r.over ? ", match over" : ""}`, `checksum ${r.checksum}`, JSON.stringify(r.state, null, 2)].join("\n"),
53
55
  );
@@ -60,6 +62,7 @@ interface Replay {
60
62
  // Encoded inputs as change points.
61
63
  inputs: { frame: number; inputs: number[] }[];
62
64
  frames: number;
65
+ throughOver?: boolean;
63
66
  checksums: { frame: number; checksum: number }[];
64
67
  final: number;
65
68
  }
@@ -68,14 +71,14 @@ export async function record(ctx: Ctx, file: string, args: PlayArgs): Promise<vo
68
71
  if (!file) throw new CliError("MISSING_ARG", "replay record needs an output file", "dotframe replay record replays/smoke.json --mash 7 --frames 1800");
69
72
  const { run, source, seed, options } = await setup(args);
70
73
  const frames = num("frames", args.frames, 1800, 1);
71
- const r = play(run, source, frames, 60);
74
+ const r = play(run, source, frames, 60, args.throughOver === true);
72
75
  const inputs: Replay["inputs"] = [];
73
76
  for (let f = 0; f < r.frames; f++) {
74
77
  const cur = source.at(f);
75
78
  const last = inputs[inputs.length - 1];
76
79
  if (!last || last.inputs.some((v: number, i: number): boolean => v !== cur[i])) inputs.push({ frame: f, inputs: cur.slice() });
77
80
  }
78
- const replay: Replay = { version: 1, seed, options, inputs, frames: r.frames, checksums: r.trace, final: r.checksum };
81
+ const replay: Replay = { version: 1, seed, options, inputs, frames: r.frames, ...(args.throughOver ? { throughOver: true } : {}), checksums: r.trace, final: r.checksum };
79
82
  writeFileSync(resolve(process.cwd(), file), `${JSON.stringify(replay)}\n`);
80
83
  print(ctx, { file, frames: r.frames, final: r.checksum, checkpoints: r.trace.length }, (): string => `recorded ${r.frames} frames to ${file} (final checksum ${r.checksum})`);
81
84
  }
@@ -102,7 +105,7 @@ export async function verify(ctx: Ctx, files: string[]): Promise<void> {
102
105
  return cur;
103
106
  },
104
107
  };
105
- const r = play(run, source, replay.frames, 60);
108
+ const r = play(run, source, replay.frames, 60, replay.throughOver === true);
106
109
  const mismatch = replay.checksums.find((c, i): boolean => r.trace[i]?.checksum !== c.checksum);
107
110
  results.push({ file, ok: !mismatch && r.checksum === replay.final, firstMismatch: mismatch ? mismatch.frame : r.checksum === replay.final ? null : r.frames, expected: replay.final, got: r.checksum });
108
111
  }
@@ -1,5 +1,6 @@
1
1
  import { existsSync, readdirSync, statSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
+ import { buildNative, ENGINE, type NativePlatform, vendorDir, vendorStatus } from "../native";
3
4
  import { CliError, type Command, type Ctx, exec, gate, loadConfig, print, runSteps, target, which } from "../lib";
4
5
 
5
6
  const skillFor = (t: string): string => (t === "web" ? "export-web" : t);
@@ -24,7 +25,14 @@ export async function build(ctx: Ctx, name: string | undefined, release: boolean
24
25
  const blocked = licenseBlockers(config.root, config.assets?.localOnly ?? []);
25
26
  if (blocked.length > 0) throw new CliError("ASSETS_LOCAL_ONLY", `release build would ship assets marked local-only: ${blocked.join(", ")}`, "replace them with licensed assets, then remove them from assets.localOnly", "assets");
26
27
  }
27
- const steps = await runSteps(ctx, config.root, t.steps, skillFor(name));
28
+ if (t.native) {
29
+ const r = await buildNative(ctx, config.root, config.name, name, t.native);
30
+ if (ctx.dryRun) return;
31
+ print(ctx, { target: name, steps: r.steps.map(({ output: _o, ...s }) => s), artifact: r.binary, log: r.log }, (): string => `artifact: ${r.binary}\nlog: ${r.log}`);
32
+ return;
33
+ }
34
+ if (!t.steps) throw new CliError("BAD_CONFIG", `target "${name}" needs "steps" or "native"`, "see dotframe skills get macos", skillFor(name));
35
+ const steps = (await runSteps(ctx, config.root, t.steps, skillFor(name))).map(({ output: _o, ...s }) => s);
28
36
  const artifact = t.out ?? t.app;
29
37
  print(ctx, { target: name, dryRun: ctx.dryRun, steps, artifact: artifact ? resolve(config.root, artifact) : null }, (): string =>
30
38
  [...steps.map((s): string => (ctx.dryRun ? s.tail : `ok ${s.label} (${s.ms} ms)`)), artifact ? `artifact: ${artifact}` : ""].filter(Boolean).join("\n"),
@@ -96,3 +104,27 @@ export async function device(ctx: Ctx, name: string): Promise<void> {
96
104
  if (r.code !== 0) throw new CliError("INSTALL_FAILED", r.tail, "unlock the phone, trust this Mac, enable Developer Mode", "ios");
97
105
  print(ctx, { device: t.device, app, ms: r.ms }, (): string => `installed ${app} on ${t.device}`);
98
106
  }
107
+
108
+ // Downloads wgpu-native and builds SDL3 into the vendor dir (default ~/.dotframe/vendor). Local only, slow once.
109
+ export async function vendor(ctx: Ctx, platform: string | undefined): Promise<void> {
110
+ if (platform !== "macos" && platform !== "windows") throw new CliError("MISSING_ARG", "vendor needs macos or windows", "dotframe vendor macos", "macos");
111
+ const p = platform as NativePlatform;
112
+ const dir = vendorDir(p);
113
+ const before = vendorStatus(p);
114
+ if (before.missing.length === 0) {
115
+ print(ctx, { platform: p, dir, ready: true }, (): string => `vendor for ${p} is ready in ${dir}`);
116
+ return;
117
+ }
118
+ const command = { label: `vendor ${p}`, argv: ["sh", join(ENGINE, "scripts", "vendor.sh"), p], env: { DOTFRAME_VENDOR: dir } };
119
+ if (ctx.dryRun) {
120
+ print(ctx, { dryRun: true, platform: p, dir, missing: before.missing }, (): string => `would download and build ${before.missing.join(", ")} into ${dir}`);
121
+ return;
122
+ }
123
+ for (const tool of ["curl", "cmake", "unzip", ...(p === "windows" ? ["zig"] : [])]) {
124
+ if (!which(tool)) throw new CliError("TOOL_MISSING", `${tool} not found`, `brew install ${tool}`, "macos");
125
+ }
126
+ await runSteps(ctx, ENGINE, [command], "macos");
127
+ const after = vendorStatus(p);
128
+ if (after.missing.length > 0) throw new CliError("VENDOR_MISSING", `vendor.sh finished but ${after.missing.join(", ")} is still missing`, "", "macos");
129
+ print(ctx, { platform: p, dir, ready: true }, (): string => `vendor for ${p} ready in ${dir}`);
130
+ }
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { tmpdir } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { CliError, type Ctx, exec, loadConfig, num, print, which } from "../lib";
@@ -67,9 +67,10 @@ run(sim.window, (p) => {
67
67
  });
68
68
  const ab = (...a: string[]) => exec({ ...ctx, json: true }, config.root, { label: `agent-browser ${a[0]}`, argv: ["agent-browser", "--session", "dotframe-snap", ...a] });
69
69
  try {
70
- await ab("set", "viewport", String(sim.window.width), String(sim.window.height));
71
70
  const opened = await ab("open", `http://localhost:${server.port}/`);
72
71
  if (opened.code !== 0) throw new CliError("BROWSER_FAILED", opened.tail, "agent-browser install");
72
+ // After open: agent-browser's daemon is shared, and a viewport set before open can be lost to another session.
73
+ await ab("set", "viewport", String(sim.window.width), String(sim.window.height));
73
74
  // Loading plus stepping can take a while for long inputs; poll for the marker.
74
75
  let result: { frame?: number; checksum?: number; state?: unknown; error?: string } | null = null;
75
76
  for (let i = 0; i < 120 && !result; i++) {
@@ -80,8 +81,17 @@ run(sim.window, (p) => {
80
81
  }
81
82
  if (!result) throw new CliError("SNAP_TIMEOUT", "the page never reported ready after 60 s", "open the page with agent-browser --headed and read the console; WebGPU may be unavailable", "core");
82
83
  if (result.error) throw new CliError("SNAP_PAGE_ERROR", result.error, "WebGPU unavailable? try AGENT_BROWSER_ARGS=--enable-unsafe-webgpu", "core");
83
- const shot = await ab("screenshot", out);
84
+ let shot = await ab("screenshot", out);
84
85
  if (shot.code !== 0) throw new CliError("BROWSER_FAILED", shot.tail);
86
+ let size = pngSize(out);
87
+ if (!matches(size, sim.window)) {
88
+ await ab("set", "viewport", String(sim.window.width), String(sim.window.height));
89
+ shot = await ab("screenshot", out);
90
+ size = pngSize(out);
91
+ }
92
+ if (!matches(size, sim.window)) {
93
+ throw new CliError("SNAP_SIZE", `screenshot is ${size.width}x${size.height}, expected ${sim.window.width}x${sim.window.height} (or a device-pixel multiple)`, "close other agent-browser sessions and retry; check agent-browser session list", "core");
94
+ }
85
95
  print(ctx, { out, ...result }, (): string => `frame ${result?.frame} -> ${out} (checksum ${result?.checksum})`);
86
96
  } finally {
87
97
  await ab("close");
@@ -89,3 +99,14 @@ run(sim.window, (p) => {
89
99
  rmSync(work, { recursive: true, force: true });
90
100
  }
91
101
  }
102
+
103
+ // Width and height from a PNG's IHDR chunk.
104
+ function pngSize(path: string): { width: number; height: number } {
105
+ const head = readFileSync(path).subarray(16, 24);
106
+ return { width: head.readUInt32BE(0), height: head.readUInt32BE(4) };
107
+ }
108
+
109
+ function matches(size: { width: number; height: number }, window: { width: number; height: number }): boolean {
110
+ const scale = size.width / window.width;
111
+ return Number.isInteger(scale) && scale >= 1 && size.height === window.height * scale;
112
+ }
package/cli/lib.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
 
4
4
  export class CliError extends Error {
@@ -8,6 +8,8 @@ export class CliError extends Error {
8
8
  readonly fix = "",
9
9
  readonly skill = "core",
10
10
  readonly exit = 1,
11
+ // Full output of a failed step, when it was too long for the message.
12
+ readonly log = "",
11
13
  ) {
12
14
  super(message);
13
15
  }
@@ -26,10 +28,11 @@ export function print(ctx: Ctx, data: unknown, human: () => string): void {
26
28
 
27
29
  export function fail(ctx: Ctx, error: unknown): never {
28
30
  const e = error instanceof CliError ? error : new CliError("INTERNAL", error instanceof Error ? error.message : String(error));
29
- if (ctx.json) console.log(JSON.stringify({ ok: false, error: { code: e.code, message: e.message, fix: e.fix, skill: e.skill } }));
31
+ if (ctx.json) console.log(JSON.stringify({ ok: false, error: { code: e.code, message: e.message, fix: e.fix, skill: e.skill, ...(e.log ? { log: e.log } : {}) } }));
30
32
  else {
31
33
  console.error(`error ${e.code}: ${e.message}`);
32
34
  if (e.fix) console.error(`fix: ${e.fix}`);
35
+ if (e.log) console.error(`log: ${e.log}`);
33
36
  console.error(`guide: dotframe skills get ${e.skill}`);
34
37
  }
35
38
  process.exit(e.exit);
@@ -48,7 +51,9 @@ export interface Command {
48
51
  }
49
52
 
50
53
  export interface Target {
51
- steps: Command[];
54
+ steps?: Command[];
55
+ // Native targets built by the CLI itself (staged engine, vendored SDL3 and wgpu-native).
56
+ native?: import("./native").NativeTarget;
52
57
  out?: string;
53
58
  app?: string;
54
59
  device?: string;
@@ -106,6 +111,8 @@ export interface RunResult {
106
111
  code: number;
107
112
  ms: number;
108
113
  tail: string;
114
+ // Everything the command printed; kept out of --json output.
115
+ output: string;
109
116
  }
110
117
 
111
118
  // Runs a command to completion. Output streams to stderr in human mode and is captured (tail kept) in JSON mode.
@@ -128,21 +135,29 @@ export async function exec(ctx: Ctx, root: string, command: Command): Promise<Ru
128
135
  };
129
136
  await Promise.all([pump(proc.stdout), pump(proc.stderr)]);
130
137
  const code = await proc.exited;
131
- const tail = chunks.join("").split("\n").slice(-20).join("\n").trim();
132
- return { label: command.label, code, ms: Math.round(performance.now() - t0), tail };
138
+ const output = chunks.join("");
139
+ const tail = output.split("\n").slice(-20).join("\n").trim();
140
+ return { label: command.label, code, ms: Math.round(performance.now() - t0), tail, output };
133
141
  }
134
142
 
135
143
  export async function runSteps(ctx: Ctx, root: string, steps: Command[], skill: string): Promise<RunResult[]> {
136
144
  const results: RunResult[] = [];
137
145
  for (const step of steps) {
138
146
  if (ctx.dryRun) {
139
- results.push({ label: step.label, code: 0, ms: 0, tail: `would run: ${step.argv.join(" ")} (cwd ${step.cwd ?? "."})` });
147
+ results.push({ label: step.label, code: 0, ms: 0, tail: `would run: ${step.argv.join(" ")} (cwd ${step.cwd ?? "."})`, output: "" });
140
148
  continue;
141
149
  }
142
150
  if (!ctx.json) console.error(`> ${step.label}`);
143
151
  const r = await exec(ctx, root, step);
144
152
  results.push(r);
145
- if (r.code !== 0) throw new CliError("STEP_FAILED", `step "${step.label}" exited ${r.code}\n${r.tail}`, "read the output above; dotframe doctor checks the toolchain", skill);
153
+ if (r.code !== 0) {
154
+ // The message keeps the tail; the whole output goes to a log an agent can read.
155
+ const log = join(root, ".dotframe", "logs", `${step.label.replace(/[^a-z0-9]+/gi, "-")}.log`);
156
+ mkdirSync(dirname(log), { recursive: true });
157
+ writeFileSync(log, r.output);
158
+ const count = r.output.match(/(\d+) errors?/)?.[1];
159
+ throw new CliError("STEP_FAILED", `step "${step.label}" exited ${r.code}${count ? ` with ${count} errors` : ""}; full output in ${log}\n${r.tail}`, `read ${log}; dotframe doctor checks the toolchain`, skill, 1, log);
160
+ }
146
161
  }
147
162
  return results;
148
163
  }
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 } from "./commands/ship";
4
+ import { build, deploy, device, relay, vendor } from "./commands/ship";
5
5
  import { doctor } from "./commands/doctor";
6
6
  import { configCmd } from "./commands/config";
7
7
  import { desync, record, sim, verify } from "./commands/play";
@@ -25,7 +25,8 @@ Build and ship
25
25
  deploy <target> [--prod] gated: --yes, preview with --dry-run
26
26
  relay deploy [--region eze] gated
27
27
  device install <target> gated
28
- doctor [--fix] toolchain, links, config, signing
28
+ vendor <macos|windows> SDL3 + wgpu-native for native builds (~/.dotframe/vendor)
29
+ doctor [--fix] toolchain, vendor, links, config, sim render
29
30
  config get [key] | set <key> <json>
30
31
 
31
32
  Project
@@ -38,9 +39,10 @@ Global: --json --yes --dry-run --help --version`;
38
39
  const INPUTS = `Inputs: --mash <seed> (random players) or --inputs f.jsonl (one [p0, p1] per frame, or {"frame": n, "inputs": [...]} held until the next line). --seed <n> seeds the sim, --options '<json>' overrides match options.`;
39
40
 
40
41
  const COMMAND_HELP: Record<string, string> = {
41
- sim: `dotframe sim [--mash <seed> | --inputs f.jsonl] [--frames 600] [--seed 1] [--options json] [--every n] [--json]
42
+ sim: `dotframe sim [--mash <seed> | --inputs f.jsonl] [--frames 600] [--seed 1] [--options json] [--every n] [--through-over] [--json]
42
43
 
43
- Runs the game headless and prints the final state and checksum. --every n adds a checksum trace.
44
+ Runs the game headless and prints the final state and checksum. It stops when the match is over unless
45
+ --through-over (rematch and results flows). --every n adds a checksum trace.
44
46
  ${INPUTS}
45
47
 
46
48
  dotframe sim --mash 7 --frames 600 --json
@@ -51,7 +53,7 @@ Steps the sim to frame n in a real browser (WebGPU, through agent-browser) and s
51
53
  ${INPUTS}
52
54
 
53
55
  dotframe snap --frame 300 --mash 7 --out /tmp/f300.png`,
54
- replay: `dotframe replay record <file> [--mash <seed> | --inputs f.jsonl] [--frames 1800] [--seed 1] [--options json]
56
+ replay: `dotframe replay record <file> [--mash <seed> | --inputs f.jsonl] [--frames 1800] [--seed 1] [--options json] [--through-over]
55
57
  dotframe replay verify <file...>
56
58
 
57
59
  record stores seed, options, inputs, and a checksum every 60 frames. verify replays them and exits 1 with the
@@ -68,7 +70,9 @@ Warns when rollbacks exceed the sim's rollbackWindow.
68
70
  dotframe desync --latency 474ms --jitter 40ms --mash 42 --json`,
69
71
  build: `dotframe build <target> [--release] [--dry-run] [--json]
70
72
 
71
- Runs targets.<target>.steps from dotframe.json. --release refuses assets listed in assets.localOnly.
73
+ Runs targets.<target>.steps from dotframe.json, or for a native target ({"native": {"platform": "macos",
74
+ "entry": "main.native.ts"}}) stages the engine and game in .dotframe/native/<target> and writes dist/<target>/<name>.
75
+ Failures keep the full output in .dotframe/logs and return its path. --release refuses assets.localOnly.
72
76
 
73
77
  dotframe build web
74
78
  dotframe build ios --release`,
@@ -84,6 +88,10 @@ Redeploys the netplay relay (dokploy) or deploys it to a region (fly). Gated lik
84
88
  device: `dotframe device install <target> [--dry-run] [--yes]
85
89
 
86
90
  Installs targets.<target>.app on targets.<target>.device with devicectl. Gated like deploy.`,
91
+ vendor: `dotframe vendor <macos|windows> [--dry-run]
92
+
93
+ Downloads wgpu-native and builds SDL3 for native targets into DOTFRAME_VENDOR (default ~/.dotframe/vendor), which
94
+ survives reinstalling dotframe and is shared by every game.`,
87
95
  doctor: `dotframe doctor [--fix] [--json]
88
96
 
89
97
  Checks tools, dotframe.json, the sim (loads, renders with a stub Draw2D), placeholders, and vendor links.
@@ -136,11 +144,12 @@ const { values, positionals } = parseArgs({
136
144
  template: { type: "string" },
137
145
  port: { type: "string" },
138
146
  "no-install": { type: "boolean" },
147
+ "through-over": { type: "boolean" },
139
148
  },
140
149
  });
141
150
 
142
151
  const ctx: Ctx = { json: values.json === true, yes: values.yes === true, dryRun: values["dry-run"] === true };
143
- const v = values as Record<string, string | undefined>;
152
+ const v = { ...(values as Record<string, string | undefined>), throughOver: values["through-over"] === true } as Record<string, string | undefined> & { throughOver: boolean };
144
153
  const [command, ...rest] = positionals;
145
154
 
146
155
  try {
@@ -156,6 +165,7 @@ try {
156
165
  else if (command === "deploy") await deploy(ctx, rest[0], values.prod === true);
157
166
  else if (command === "relay" && rest[0] === "deploy") await relay(ctx, v.region);
158
167
  else if (command === "device" && rest[0] === "install") await device(ctx, rest[1] ?? "ios");
168
+ else if (command === "vendor") await vendor(ctx, rest[0]);
159
169
  else if (command === "doctor") await doctor(ctx, values.fix === true);
160
170
  else if (command === "config") await configCmd(ctx, rest);
161
171
  else if (command === "skills") await skills(ctx, rest, values.full === true, values.all === true);
package/cli/native.ts ADDED
@@ -0,0 +1,211 @@
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
+ import { CliError, type Ctx, exec, home, print, type RunResult, which } from "./lib";
5
+
6
+ // The engine this CLI belongs to: a git checkout or node_modules/dotframe.
7
+ export const ENGINE = resolve(import.meta.dir, "..");
8
+
9
+ export type NativePlatform = "macos" | "windows";
10
+
11
+ export interface NativeTarget {
12
+ platform: NativePlatform;
13
+ // Entry module, relative to the game root.
14
+ entry: string;
15
+ // Binary name; defaults to the game name.
16
+ name?: string;
17
+ }
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.
21
+ export function vendorDir(platform: NativePlatform): string {
22
+ if (process.env.DOTFRAME_VENDOR) return resolve(home(process.env.DOTFRAME_VENDOR));
23
+ const local = join(ENGINE, "vendor");
24
+ if (existsSync(join(local, "wgpu", platform))) return local;
25
+ return join(homedir(), ".dotframe", "vendor");
26
+ }
27
+
28
+ export interface VendorStatus {
29
+ dir: string;
30
+ missing: string[];
31
+ sdl: string | null;
32
+ }
33
+
34
+ export function vendorStatus(platform: NativePlatform): VendorStatus {
35
+ const dir = vendorDir(platform);
36
+ const sdl = existsSync(dir) ? (readdirSync(dir).find((d: string): boolean => d.startsWith("SDL3-")) ?? null) : null;
37
+ const need = [`wgpu/${platform}/lib/libwgpu_native.a`, `build/sdl-${platform}/libSDL3.a`];
38
+ const missing = need.filter((p: string): boolean => !existsSync(join(dir, p)));
39
+ if (!sdl) missing.push("SDL3-<version> (headers)");
40
+ return { dir, missing, sdl };
41
+ }
42
+
43
+ export function vendorFix(platform: NativePlatform): string {
44
+ return `dotframe vendor ${platform}`;
45
+ }
46
+
47
+ const SPECIFIER = /(?:from\s+|import\s*\(\s*|import\s+)(["'])([^"']+)\1/g;
48
+ const EXTENSIONS = ["", ".ts", ".tsx", ".json", "/index.ts", "/index.tsx"];
49
+
50
+ function resolveFile(base: string): string | null {
51
+ const candidates = [base.replace(/\.js$/, ".ts"), base];
52
+ for (const c of candidates) for (const ext of EXTENSIONS) if (existsSync(c + ext) && statSync(c + ext).isFile()) return realpathSync(c + ext);
53
+ return null;
54
+ }
55
+
56
+ // A bare specifier that resolves, through node_modules, to TypeScript source outside node_modules: a workspace
57
+ // package (bun and npm link those). Published packages stay packages.
58
+ function resolveWorkspace(specifier: string, fromDir: string): string | null {
59
+ const parts = specifier.split("/");
60
+ const name = specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
61
+ const sub = parts.slice(name.split("/").length).join("/");
62
+ for (let dir = fromDir; ; dir = dirname(dir)) {
63
+ const pkgDir = join(dir, "node_modules", name);
64
+ if (existsSync(pkgDir)) {
65
+ const real = realpathSync(pkgDir);
66
+ if (real.split(sep).includes("node_modules")) return null;
67
+ if (sub) return resolveFile(join(real, sub));
68
+ const pkg = JSON.parse(readFileSync(join(real, "package.json"), "utf8")) as Record<string, unknown>;
69
+ const exp = pkg.exports as Record<string, unknown> | string | undefined;
70
+ const dot = typeof exp === "string" ? exp : (exp?.["."] as Record<string, string> | string | undefined);
71
+ const entry = typeof dot === "string" ? dot : (dot?.import ?? dot?.default ?? dot?.types ?? (pkg.module as string) ?? (pkg.main as string) ?? "index.ts");
72
+ return resolveFile(join(real, entry));
73
+ }
74
+ if (dirname(dir) === dir) return null;
75
+ }
76
+ }
77
+
78
+ interface Graph {
79
+ files: string[];
80
+ // Per file, the specifiers to rewrite and the absolute file each one points at.
81
+ links: Map<string, Map<string, string>>;
82
+ }
83
+
84
+ // Every source file the entry reaches: relative imports (including ones that leave the game root) and workspace
85
+ // packages. "dotframe/..." is the engine, staged separately.
86
+ function importGraph(entry: string): Graph {
87
+ const files: string[] = [];
88
+ const links = new Map<string, Map<string, string>>();
89
+ const queue = [realpathSync(entry)];
90
+ const seen = new Set(queue);
91
+ while (queue.length > 0) {
92
+ const file = queue.shift() as string;
93
+ files.push(file);
94
+ if (!/\.tsx?$/.test(file)) continue;
95
+ const map = new Map<string, string>();
96
+ for (const m of readFileSync(file, "utf8").matchAll(SPECIFIER)) {
97
+ const spec = m[2];
98
+ if (spec.startsWith("dotframe/") || spec.startsWith("node:")) continue;
99
+ const target = spec.startsWith(".") ? resolveFile(resolve(dirname(file), spec)) : resolveWorkspace(spec, dirname(file));
100
+ if (!target) continue;
101
+ if (!spec.startsWith(".")) map.set(spec, target);
102
+ if (!seen.has(target)) {
103
+ seen.add(target);
104
+ queue.push(target);
105
+ }
106
+ }
107
+ links.set(file, map);
108
+ }
109
+ return { files, links };
110
+ }
111
+
112
+ function commonDir(paths: string[]): string {
113
+ let base = dirname(paths[0]);
114
+ for (const p of paths) while (!(p + sep).startsWith(base === sep ? sep : base + sep)) base = dirname(base);
115
+ return base;
116
+ }
117
+
118
+ function rel(fromFile: string, to: string): string {
119
+ const r = relative(dirname(fromFile), to);
120
+ return r.startsWith(".") ? r : `./${r}`;
121
+ }
122
+
123
+ // scriptc's static build takes relative imports only, and treats anything under node_modules as package code for
124
+ // its dynamic engine. Staging copies the engine and every reached game file side by side, keeping their relative
125
+ // layout, and rewrites "dotframe/..." and workspace imports to relative paths.
126
+ function stageGame(root: string, entry: string, tree: string, engineStage: string): string {
127
+ const graph = importGraph(join(root, entry));
128
+ const base = commonDir([realpathSync(root) + sep + "x", ...graph.files]);
129
+ const staged = (file: string): string => join(tree, relative(base, file));
130
+ for (const file of graph.files) {
131
+ const to = staged(file);
132
+ mkdirSync(dirname(to), { recursive: true });
133
+ let text = readFileSync(file, "utf8");
134
+ if (/\.tsx?$/.test(file)) {
135
+ const map = graph.links.get(file) ?? new Map<string, string>();
136
+ text = text.replace(SPECIFIER, (whole: string, q: string, spec: string): string => {
137
+ if (spec.startsWith("dotframe/")) return whole.replace(`${q}${spec}${q}`, `${q}${rel(to, join(engineStage, spec.slice("dotframe/".length)))}${q}`);
138
+ const target = map.get(spec);
139
+ return target ? whole.replace(`${q}${spec}${q}`, `${q}${rel(to, staged(target)).replace(/\.tsx?$/, "")}${q}`) : whole;
140
+ });
141
+ }
142
+ writeFileSync(to, text);
143
+ }
144
+ return join(tree, relative(base, realpathSync(root)));
145
+ }
146
+
147
+ export async function buildNative(ctx: Ctx, root: string, gameName: string, targetName: string, t: NativeTarget): Promise<{ binary: string; log: string; steps: RunResult[] }> {
148
+ const platform = t.platform;
149
+ const skill = platform;
150
+ const tools = platform === "macos" ? ["clang", "scriptc"] : ["zig", "scriptc"];
151
+ 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);
152
+ const vendor = vendorStatus(platform);
153
+ 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);
154
+
155
+ const stage = join(root, ".dotframe", "native", targetName);
156
+ const logDir = join(root, ".dotframe", "logs");
157
+ const log = join(logDir, `build-${targetName}.log`);
158
+ const out = join(root, "dist", targetName);
159
+ const binary = join(out, `${t.name ?? gameName}${platform === "windows" ? ".exe" : ""}`);
160
+ 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);
161
+ if (ctx.dryRun) {
162
+ print(ctx, { dryRun: true, platform, stage, vendor: vendor.dir, binary }, (): string => `would stage the engine and game in ${stage} and build ${binary}`);
163
+ return { binary, log, steps: [] };
164
+ }
165
+ rmSync(stage, { recursive: true, force: true });
166
+ mkdirSync(logDir, { recursive: true });
167
+ mkdirSync(out, { recursive: true });
168
+ const engineStage = join(stage, "dotframe");
169
+ cpSync(join(ENGINE, "src"), join(engineStage, "src"), { recursive: true });
170
+ const gameStage = stageGame(root, t.entry, join(stage, "game"), engineStage);
171
+
172
+ const inc = [`-I${join(vendor.dir, vendor.sdl ?? "", "include")}`, `-I${join(vendor.dir, "wgpu", platform, "include")}`];
173
+ const lib = join(stage, "lib");
174
+ mkdirSync(lib, { recursive: true });
175
+ const cc = platform === "macos" ? ["clang", "-O2", "-mmacosx-version-min=14.0"] : ["zig", "cc", "-target", "x86_64-windows-gnu", "-O2"];
176
+ const ar = platform === "macos" ? ["ar", "rcs"] : ["zig", "ar", "rcs"];
177
+ const steps = [
178
+ ...["df_native", "df_audio"].map((unit) => ({ label: `cc ${unit}`, argv: [...cc, "-c", join(ENGINE, "native", `${unit}.c`), ...inc, "-o", join(lib, `${unit}.o`)] })),
179
+ { label: "ar libdf_native", argv: [...ar, join(lib, "libdf_native.a"), join(lib, "df_native.o"), join(lib, "df_audio.o")] },
180
+ ];
181
+ const ffi = JSON.parse(readFileSync(join(ENGINE, "native", `ffi.${platform}.json`), "utf8")) as { libraries: string[] };
182
+ ffi.libraries = [join(lib, "libdf_native.a"), join(vendor.dir, "build", `sdl-${platform}`, "libSDL3.a"), join(vendor.dir, "wgpu", platform, "lib", "libwgpu_native.a")];
183
+ writeFileSync(join(stage, "ffi.json"), JSON.stringify(ffi, null, 2));
184
+ let env: Record<string, string> = {};
185
+ if (platform === "windows") {
186
+ // A devDependency of dotframe, so an npm install of dotframe does not bring it; the game can.
187
+ const pack = [ENGINE, root].map((dir: string): string => join(dir, "node_modules", "@scriptc", "runtime-win32-x64-msvc")).find((p: string): boolean => existsSync(p));
188
+ if (!pack) throw new CliError("TOOL_MISSING", "the scriptc Windows runtime pack is not installed", "bun add -d @scriptc/runtime-win32-x64-msvc", skill);
189
+ env = { SCRIPTC_TARGET: "x86_64-windows-gnu", SCRIPTC_RUNTIME_PACK: pack };
190
+ }
191
+ const scriptc = {
192
+ label: "scriptc",
193
+ argv: ["scriptc", "build", t.entry, "--ffi", join(stage, "ffi.json"), ...(platform === "windows" ? ["--windows-subsystem", "gui"] : []), "-o", binary],
194
+ cwd: gameStage,
195
+ env,
196
+ };
197
+ const results: RunResult[] = [];
198
+ const full: string[] = [];
199
+ for (const step of [...steps, scriptc]) {
200
+ if (!ctx.json) console.error(`> ${step.label}`);
201
+ const r = await exec(ctx, root, step);
202
+ full.push(`> ${step.label}: ${step.argv.join(" ")}\n${r.output}`);
203
+ writeFileSync(log, full.join("\n"));
204
+ results.push(r);
205
+ if (r.code !== 0) {
206
+ const count = r.output.match(/(\d+) errors?/)?.[1];
207
+ throw new CliError("STEP_FAILED", `step "${step.label}" exited ${r.code}${count ? ` with ${count} errors` : ""}; full output in ${log}\n${r.tail}`, `read ${log}`, skill, 1, log);
208
+ }
209
+ }
210
+ return { binary, log, steps: results };
211
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dotframe",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "TS-first game engine and agent-first CLI: one codebase for web, Discord, iOS and native binaries",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,7 +25,8 @@
25
25
  "README.md",
26
26
  "CHANGELOG.md",
27
27
  "LICENSE",
28
- "skills"
28
+ "skills",
29
+ "vendor/toolchain"
29
30
  ],
30
31
  "repository": {
31
32
  "type": "git",
package/scripts/vendor.sh CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/bin/sh
2
2
  # Downloads wgpu-native prebuilts and builds SDL3 static for a target. Usage: scripts/vendor.sh <macos|windows>
3
+ # DOTFRAME_VENDOR picks the directory (the dotframe CLI uses ~/.dotframe/vendor); default is this checkout's vendor/.
3
4
  set -e
4
5
  target=$1
5
6
  root=$(cd "$(dirname "$0")/.." && pwd)
6
7
  wgpu_version=v29.0.1.1
7
8
  sdl_version=3.4.16
8
- vendor="$root/vendor"
9
+ vendor=${DOTFRAME_VENDOR:-$root/vendor}
10
+ toolchain="$root/vendor/toolchain"
9
11
  mkdir -p "$vendor/build"
10
12
 
11
13
  case $target in
@@ -28,7 +30,7 @@ fi
28
30
  if [ ! -f "$vendor/build/sdl-$target/libSDL3.a" ]; then
29
31
  case $target in
30
32
  macos) extra="-DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=14.0" ;;
31
- windows) extra="-DCMAKE_TOOLCHAIN_FILE=$vendor/toolchain/zig-windows.cmake" ;;
33
+ windows) extra="-DCMAKE_TOOLCHAIN_FILE=$toolchain/zig-windows.cmake" ;;
32
34
  esac
33
35
  cmake -S "$vendor/SDL3-$sdl_version" -B "$vendor/build/sdl-$target" -DCMAKE_BUILD_TYPE=Release \
34
36
  -DSDL_SHARED=OFF -DSDL_STATIC=ON -DSDL_TEST_LIBRARY=OFF $extra
@@ -17,6 +17,12 @@ dotframe is a TypeScript game engine. One codebase runs on the web (WebGPU), in
17
17
 
18
18
  Never claim a visual change works from the JSON alone. Snap it and look.
19
19
 
20
+ `sim` and `replay` stop when `over()` turns true. Pass `--through-over` to keep stepping into results and rematch flows.
21
+
22
+ ## agent-browser
23
+
24
+ `snap` drives agent-browser in its own session (`dotframe-snap`), writes to an absolute path, and checks the PNG size. When you drive agent-browser yourself, always pass `--session <name>` and absolute output paths: one daemon serves the whole machine, so a relative `screenshot out.png` lands in whatever directory the daemon started in (often another repo), and viewports can leak between sessions.
25
+
20
26
  ## Inputs
21
27
 
22
28
  - `--mash <seed>`: random mashing players, new input every 6 frames. Good default for smoke tests.
@@ -8,7 +8,7 @@
8
8
  | INPUTS_MISSING / BAD_INPUTS | Inputs file missing or a line has the wrong shape | One JSON array or `{frame, inputs}` per line, one input per player |
9
9
  | BAD_OPTIONS | `--options` is not a JSON object | Quote it: `--options '{"stocks": 1}'` |
10
10
  | UNKNOWN_TARGET | Target not in dotframe.json | Use a listed target or add one |
11
- | STEP_FAILED | A build step exited non-zero | Read the step output; `dotframe doctor` |
11
+ | STEP_FAILED | A build step exited non-zero | Read the file in `log` (full output); `dotframe doctor` |
12
12
  | ASSETS_LOCAL_ONLY | A release would ship unlicensed assets | See `assets` |
13
13
  | APPROVAL_REQUIRED (exit 2) | External action without `--yes` | Show the `--dry-run` plan to the human |
14
14
  | DEPLOY_SOURCE_DIR | Deploy dir looks like source (repo root, .git, dotframe.json) | Point `out` at the build folder |
@@ -17,6 +17,10 @@
17
17
  | DEPLOY_FAILED / INSTALL_FAILED | Provider command failed | Message carries the provider output |
18
18
  | TOOL_MISSING | A required CLI is not installed | `dotframe doctor` lists the install command |
19
19
  | SNAP_TIMEOUT / SNAP_PAGE_ERROR / BROWSER_FAILED | The snap page did not report ready | Usually no WebGPU in the browser; see message |
20
+ | BAD_ARG / MISSING_ARG | A flag value is not a valid number or duration, or a required flag is missing | Use the value in `fix` |
21
+ | VENDOR_MISSING | Native build without SDL3 or wgpu-native | `dotframe vendor <platform>` |
22
+ | ENTRY_MISSING | The native entry module does not exist | Add `main.native.ts` or fix `native.entry` |
23
+ | SNAP_SIZE | The screenshot size does not match the game window | Close other agent-browser sessions and retry |
20
24
  | UNSUPPORTED | The command does not apply (e.g. desync on a 1-player sim) | |
21
25
  | REPLAY_MISSING | Replay file not found | `dotframe replay record` |
22
26
  | UNKNOWN_SKILL / UNKNOWN_TEMPLATE / UNKNOWN_COMMAND | Typo | Read the `fix` list |
@@ -1,16 +1,45 @@
1
1
  ---
2
2
  name: macos
3
- description: Build and run native macOS (and Windows) binaries of a dotframe game with scriptc. Use when compiling natively, benchmarking, or debugging the native backend.
3
+ description: Build and run native macOS (and Windows) binaries of a dotframe game with scriptc. Use when compiling natively, setting up the vendored SDL3 and wgpu-native, reading a native build failure, benchmarking, or debugging the native backend.
4
4
  ---
5
5
  # macos
6
6
 
7
7
  ```sh
8
- dotframe build macos --json
8
+ dotframe vendor macos # once per machine: wgpu-native + SDL3 into ~/.dotframe/vendor (about 1 minute)
9
+ dotframe doctor # vendor:<target>, scriptc, zig for windows
10
+ dotframe build macos --json # dist/macos/<name>
11
+ ./dist/macos/<name> # run from the game root: asset paths are relative to it
9
12
  ```
10
13
 
11
- The step runs dotframe's `scripts/build-native.sh macos <entry> <name>`: it compiles the C shim (SDL3 + wgpu-native) and the game with scriptc into one binary with no JavaScript engine. Windows cross-builds with zig (`build-native.sh windows`).
14
+ ## Native targets
15
+
16
+ A target with `"native"` is built by the CLI itself:
17
+
18
+ ```json
19
+ "macos": { "native": { "platform": "macos", "entry": "main.native.ts" } },
20
+ "windows": { "native": { "platform": "windows", "entry": "main.native.ts" } }
21
+ ```
22
+
23
+ The CLI copies the engine (`src/`) and every file the entry reaches into `.dotframe/native/<target>/`: relative imports (including ones that leave the game folder, as in a monorepo port importing `../../packages/shared/src`) and workspace packages that resolve to source (`@my/shared` linked by bun or npm workspaces). It keeps their relative layout, rewrites `dotframe/...` and workspace imports to relative paths, compiles the C shim against the vendored SDL3 and wgpu-native, and runs scriptc. This is why it works from `node_modules`: scriptc treats code under `node_modules` as package code for its dynamic engine and takes only relative imports in a static build, so building in place fails.
24
+
25
+ - Vendor lives in `DOTFRAME_VENDOR`, else a dotframe git checkout's `vendor/` when populated, else `~/.dotframe/vendor`. It survives reinstalling dotframe and is shared by every game.
26
+ - Output goes to the game's `dist/<target>/`, never into `node_modules`.
27
+ - On failure, `error.log` (and the message) points at `.dotframe/logs/build-<target>.log` with the full compiler output. Read it; the message only carries the tail.
28
+ - Windows cross-builds with zig (`brew install zig`), needs `dotframe vendor windows`, and needs scriptc's Windows runtime pack in the game (`bun add -d @scriptc/runtime-win32-x64-msvc`, about 63 MB, so templates leave it out).
29
+ - A target with `"steps"` instead (like Crafter Smash, which vendors dotframe as a submodule) runs its own scripts.
30
+
31
+ ## Code that compiles natively
32
+
33
+ scriptc compiles a subset of TypeScript. What tripped the templates:
34
+
35
+ - No `Object.assign` on an existing object: assign fields one by one.
36
+ - Object spread needs every field to have an earlier source: `{ ...c, taken: false }` over a mapped literal fails; build the records explicitly.
37
+ - Structural types become record copies, so engine backends are plain objects of functions, not classes behind interfaces.
38
+ - Keep native-only code in `main.native.ts`; share the rest (see the templates' `src/setup.ts`).
39
+
40
+ Run `dotframe build macos` early and after each feature: one scriptc error is a quick fix, ninety are a port.
41
+
42
+ ## Other notes
12
43
 
13
- - Games may need env vars at run time (Crafter Smash: `CHARS=railly,anthony SMASH_ROOT=<repo> DOTFRAME=<repo>/vendor/dotframe`).
14
44
  - Benchmarks mean nothing while other processes load the machine. Check `top` first and say what else was running.
15
45
  - Hidden or occluded windows skip frames and can spin a core. Keep test windows visible.
16
- - scriptc compiles a subset of TypeScript: structural types become record copies, so engine backends are plain objects of functions, not classes behind interfaces.
@@ -18,6 +18,8 @@ It runs a reference simulation and two peers over a deterministic simulated link
18
18
  - `firstStateDifference`: `{frame, path}` of the first field that differs at a checkpoint (`--every 30`). Catches state the checksum does not cover. Needs `inspect()` in the sim.
19
19
  - `rollbacks`, `maxRollbackFrames`: how hard the link worked. With `rollbackWindow` in the sim, `warnings` says when a link needs deeper rollbacks than the game allows (real play would stall).
20
20
 
21
+ Long runs: rendering on the stub costs whatever the game's render costs. `--renders 0` speeds desync up but turns off the render purity half of the check; use it for rollback depth or latency sweeps, and keep at least one run with renders. `dotframe doctor` also checks render purity on its own, in about a second.
22
+
21
23
  Run several seeds and a high latency (474 ms is what a transatlantic relay measured). A pass on one seed proves little.
22
24
 
23
25
  ## Reading a failure
@@ -6,7 +6,9 @@
6
6
  "steps": [{ "label": "bundle (hashed)", "argv": ["bun", "scripts/build-web.ts"] }],
7
7
  "out": "dist/web",
8
8
  "deploy": { "provider": "vercel", "project": "__NAME__", "scope": "__SCOPE__" }
9
- }
9
+ },
10
+ "macos": { "native": { "platform": "macos", "entry": "main.native.ts" } },
11
+ "windows": { "native": { "platform": "windows", "entry": "main.native.ts" } }
10
12
  },
11
13
  "assets": { "localOnly": [] }
12
14
  }
@@ -0,0 +1,6 @@
1
+ // Native entry for dotframe build macos|windows. The CLI stages it with the engine and compiles it with scriptc.
2
+ import { run } from "dotframe/src/native/run";
3
+ import { WINDOW } from "./src/game";
4
+ import { createSetup } from "./src/setup";
5
+
6
+ await run(WINDOW, createSetup());
@@ -1,27 +1,5 @@
1
- import { createDraw2D } from "dotframe/src/draw2d";
2
- import type { Frame } from "dotframe/src/gpu";
3
- import { Key } from "dotframe/src/input";
4
- import type { Platform } from "dotframe/src/platform";
5
1
  import { run } from "dotframe/src/web/run";
6
- import { createGame, keyInput, PLAYERS, render, step, WINDOW } from "./src/game";
2
+ import { WINDOW } from "./src/game";
3
+ import { createSetup } from "./src/setup";
7
4
 
8
- const STEP = 1 / 60;
9
-
10
- await run(WINDOW, ({ gpu, input }: Platform): Frame => {
11
- const draw = createDraw2D(gpu, WINDOW.width, WINDOW.height);
12
- const game = createGame(Math.floor(Math.random() * 1e9));
13
- let simulated = -1;
14
- return (time: number): boolean => {
15
- if (simulated < 0) simulated = time;
16
- // Fixed 60 Hz steps, so the browser plays exactly what dotframe sim simulates.
17
- for (let n = 0; simulated + STEP <= time && n < 5; n++) {
18
- const keys = [keyInput(input, [Key.A, Key.D, Key.W, Key.J]), keyInput(input, [Key.Left, Key.Right, Key.Up, Key.L])];
19
- step(game, keys.slice(0, PLAYERS));
20
- simulated += STEP;
21
- }
22
- draw.begin();
23
- render(game, draw);
24
- draw.end({ r: 0, g: 0, b: 0 });
25
- return true;
26
- };
27
- });
5
+ await run(WINDOW, createSetup());
@@ -0,0 +1,29 @@
1
+ // Shared by main.web.ts and main.native.ts: fixed 60 Hz steps from the keyboard, then one render per frame.
2
+ import { createDraw2D } from "dotframe/src/draw2d";
3
+ import type { Frame, Setup } from "dotframe/src/gpu";
4
+ import { Key } from "dotframe/src/input";
5
+ import type { Platform } from "dotframe/src/platform";
6
+ import { createGame, keyInput, PLAYERS, render, step, WINDOW } from "./game";
7
+
8
+ const STEP = 1 / 60;
9
+
10
+ export function createSetup(): Setup {
11
+ return ({ gpu, input }: Platform): Frame => {
12
+ const draw = createDraw2D(gpu, WINDOW.width, WINDOW.height);
13
+ const game = createGame(Math.floor(Math.random() * 1e9));
14
+ let simulated = -1;
15
+ return (time: number): boolean => {
16
+ if (simulated < 0) simulated = time;
17
+ // Fixed steps, so every target plays exactly what dotframe sim simulates.
18
+ for (let n = 0; simulated + STEP <= time && n < 5; n++) {
19
+ const keys = [keyInput(input, [Key.A, Key.D, Key.W, Key.J]), keyInput(input, [Key.Left, Key.Right, Key.Up, Key.L])];
20
+ step(game, keys.slice(0, PLAYERS));
21
+ simulated += STEP;
22
+ }
23
+ draw.begin();
24
+ render(game, draw);
25
+ draw.end({ r: 0, g: 0, b: 0 });
26
+ return true;
27
+ };
28
+ };
29
+ }
@@ -9,5 +9,6 @@
9
9
  "noEmit": true,
10
10
  "skipLibCheck": true
11
11
  },
12
- "include": ["*.ts", "src", "scripts"]
12
+ "include": ["*.ts", "src", "scripts"],
13
+ "exclude": ["main.native.ts"]
13
14
  }
@@ -88,7 +88,15 @@ export function step(game: Game, inputs: number[]): void {
88
88
  if (f.y >= GROUND) f.vy = 0;
89
89
  if (f.x < -100 || f.x > WINDOW.width + 100) {
90
90
  f.stocks -= 1;
91
- Object.assign(f, { ...fighter(WINDOW.width / 2, f.facing), stocks: f.stocks });
91
+ // Field by field: scriptc compiles records, not Object.assign.
92
+ f.x = WINDOW.width / 2;
93
+ f.y = GROUND;
94
+ f.vx = 0;
95
+ f.vy = 0;
96
+ f.damage = 0;
97
+ f.cooldown = 0;
98
+ f.stun = 0;
99
+ f.held = 0;
92
100
  if (f.stocks === 0) game.winner = 1 - i;
93
101
  }
94
102
  });
@@ -43,13 +43,9 @@ export interface Game {
43
43
  }
44
44
 
45
45
  export function createGame(_seed: number): Game {
46
- const coins = [
47
- { x: 220, y: 370 },
48
- { x: 470, y: 290 },
49
- { x: 720, y: 210 },
50
- { x: 480, y: 120 },
51
- { x: 880, y: 470 },
52
- ].map((c) => ({ ...c, taken: false }));
46
+ const spots = [220, 370, 470, 290, 720, 210, 480, 120, 880, 470];
47
+ const coins: { x: number; y: number; taken: boolean }[] = [];
48
+ for (let i = 0; i < spots.length; i += 2) coins.push({ x: spots[i], y: spots[i + 1], taken: false });
53
49
  return { frame: 0, x: 60, y: 500 - SIZE, vx: 0, vy: 0, onGround: true, held: 0, coins, winner: -1 };
54
50
  }
55
51
 
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ exec zig ar "$@"
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ exec zig cc -target x86_64-windows-gnu "$@"
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ exec zig c++ -target x86_64-windows-gnu "$@"
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ exec zig ranlib "$@"
@@ -0,0 +1,2 @@
1
+ #!/bin/sh
2
+ exec zig rc "$@"
@@ -0,0 +1,7 @@
1
+ set(CMAKE_SYSTEM_NAME Windows)
2
+ set(CMAKE_SYSTEM_PROCESSOR x86_64)
3
+ set(CMAKE_C_COMPILER "${CMAKE_CURRENT_LIST_DIR}/zig-cc.sh")
4
+ set(CMAKE_CXX_COMPILER "${CMAKE_CURRENT_LIST_DIR}/zig-cxx.sh")
5
+ set(CMAKE_AR "${CMAKE_CURRENT_LIST_DIR}/zig-ar.sh")
6
+ set(CMAKE_RANLIB "${CMAKE_CURRENT_LIST_DIR}/zig-ranlib.sh")
7
+ set(CMAKE_RC_COMPILER "${CMAKE_CURRENT_LIST_DIR}/zig-rc.sh")