dotframe 0.1.2 → 0.1.4

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,33 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.4
4
+
5
+ Fixes from rounds 2 and 3 of the Craft Ones dogfood.
6
+
7
+ - 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.
8
+ - `dotframe vendor <macos|windows>` puts SDL3 and wgpu-native in `~/.dotframe/vendor` (or `DOTFRAME_VENDOR`), outside `node_modules`. `doctor` checks it per native target.
9
+ - 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.
10
+ - `doctor` `sim:render` fails when render changes simulation state (checksum or any `inspect()` field), not only when it draws nothing.
11
+ - `sim` and `replay` take `--through-over` to keep stepping after the match ends.
12
+ - `snap` sets the viewport after opening the page and fails with `SNAP_SIZE` when the screenshot does not match the window.
13
+ - 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`.
14
+
15
+ ## 0.1.3
16
+
17
+ Fixes from the Craft Ones port dogfood.
18
+
19
+ - Numeric and duration flags are validated: `--latency abc`, `--frames abc` and friends fail with `BAD_ARG` instead of a false green.
20
+ - `snap` requires `--frame` and builds in a temp directory, never in the repo.
21
+ - `new` skips `git init` inside an existing repo, copies template subfolders, and names the real cause when bun's minimum release age blocks a fresh dotframe.
22
+ - `deploy --dry-run` counts every file in the build, not the top-level entries.
23
+ - `dev` serves `index.html` with `Cache-Control: no-cache`, like production.
24
+ - Templates: content-hashed web build with a no-cache `vercel.json`, `tsconfig.json` with `@webgpu/types`, and a declared `rollbackWindow`.
25
+ - Engine: `src/web/run.ts` typechecks on TypeScript 5.9 and with Bun's types loaded.
26
+ - `doctor` fails when the deploy scope is a template placeholder, and when `render()` draws nothing with a stub Draw2D (which makes `desync` blind).
27
+ - `desync` warns when rollbacks exceed the sim's new optional `rollbackWindow`.
28
+ - Per-command help (`dotframe <command> --help`); `config set` keeps short objects and arrays on one line.
29
+ - Skills: render purity with a stub Draw2D and the forced-red check (core), rollback window (netplay), SVG rasterizing (assets).
30
+
3
31
  ## 0.1.2
4
32
 
5
33
  - `npx skills add crafter-games/dotframe` installs a `dotframe` discovery skill that points agents at `dotframe skills get core`. The guides served by the CLI moved to `skill-data/`, so they are not installed as separate skills.
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { CONFIG_FILE, CliError, type Ctx, loadConfig, print } from "../lib";
3
+ import { CONFIG_FILE, CliError, type Ctx, formatJson, loadConfig, print } from "../lib";
4
4
 
5
5
  // Dotted keys: targets.web.deploy.project
6
6
  export async function configCmd(ctx: Ctx, args: string[]): Promise<void> {
@@ -30,6 +30,6 @@ export async function configCmd(ctx: Ctx, args: string[]): Promise<void> {
30
30
  print(ctx, { dryRun: true, key, value: parsed }, (): string => `would set ${key} = ${JSON.stringify(parsed)}`);
31
31
  return;
32
32
  }
33
- writeFileSync(file, `${JSON.stringify(raw, null, 2)}\n`);
33
+ writeFileSync(file, `${formatJson(raw)}\n`);
34
34
  print(ctx, { key, value: parsed }, (): string => `${key} = ${JSON.stringify(parsed)}`);
35
35
  }
@@ -1,7 +1,10 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readlinkSync, symlinkSync, unlinkSync } from "node:fs";
2
2
  import { dirname, resolve } from "node:path";
3
3
  import { type Config, type Ctx, findRoot, home, loadConfig, print, which } from "../lib";
4
- import { loadSim } from "../simkit";
4
+ import type { Draw2D } from "../../src/draw2d";
5
+ import type { Sim } from "../../src/sim";
6
+ import { vendorFix, vendorStatus } from "../native";
7
+ import { firstDifference, flatten, headlessRun, loadSim } from "../simkit";
5
8
 
6
9
  interface Check {
7
10
  check: string;
@@ -33,10 +36,23 @@ export async function doctor(ctx: Ctx, fix: boolean): Promise<void> {
33
36
  try {
34
37
  const sim = await loadSim(config);
35
38
  checks.push({ check: "sim", ok: true, detail: `${config.sim} (${sim.players} players)`, fix: "", skill: "core" });
39
+ checks.push(await renderCheck(sim, config.root));
36
40
  } catch (error) {
37
41
  checks.push({ check: "sim", ok: false, detail: error instanceof Error ? error.message : "failed to load", fix: "dotframe skills get core (Sim contract)", skill: "core" });
38
42
  }
39
43
  }
44
+ for (const [name, t] of Object.entries(config.targets)) {
45
+ if (t.deploy?.scope === "your-vercel-team") {
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" });
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
+ }
40
56
  const targets = Object.keys(config.targets);
41
57
  if (targets.some((t: string): boolean => t === "web" || t === "discord")) {
42
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"));
@@ -69,3 +85,45 @@ export async function doctor(ctx: Ctx, fix: boolean): Promise<void> {
69
85
  );
70
86
  if (failed.length > 0) process.exit(1);
71
87
  }
88
+
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).
92
+ async function renderCheck(sim: Sim, root: string): Promise<Check> {
93
+ const base = { check: "sim:render", skill: "netplay" };
94
+ const run = await headlessRun(sim, root);
95
+ if (!run.render) return { ...base, ok: false, detail: "the sim has no render(); desync cannot check render purity", fix: "add render: (draw) => ... to the SimRun", skill: "core" };
96
+ let calls = 0;
97
+ const draw = new Proxy({} as Draw2D, {
98
+ get: (_t: Draw2D, key: string | symbol): unknown => {
99
+ if (key === "measureText") return (): { width: number } => ({ width: 10 });
100
+ if (key === "getGlobalAlpha") return (): number => 1;
101
+ return (): void => {
102
+ calls += 1;
103
+ };
104
+ },
105
+ });
106
+ run.start(1, { ...sim.options });
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: "" };
129
+ }
@@ -1,4 +1,4 @@
1
- import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, watch, writeFileSync } from "node:fs";
1
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, watch, writeFileSync } from "node:fs";
2
2
  import { join, resolve } from "node:path";
3
3
  import { CliError, type Ctx, exec, loadConfig, print, runSteps, target } from "../lib";
4
4
 
@@ -11,7 +11,9 @@ export async function create(ctx: Ctx, name: string | undefined, template: strin
11
11
  const dir = resolve(process.cwd(), name);
12
12
  if (existsSync(dir)) throw new CliError("EXISTS", `${dir} already exists`, "pick another name or delete it", "game-design");
13
13
  const files: [string, string][] = [
14
- ...readdirSync(join(TEMPLATES, "_base")).map((f: string): [string, string] => [join(TEMPLATES, "_base", f), f === "gitignore" ? ".gitignore" : f]),
14
+ ...(readdirSync(join(TEMPLATES, "_base"), { recursive: true }) as string[])
15
+ .filter((f: string): boolean => statSync(join(TEMPLATES, "_base", f)).isFile())
16
+ .map((f: string): [string, string] => [join(TEMPLATES, "_base", f), f === "gitignore" ? ".gitignore" : f]),
15
17
  [join(TEMPLATES, template, "game.ts"), "src/game.ts"],
16
18
  ];
17
19
  if (ctx.dryRun) {
@@ -25,15 +27,25 @@ export async function create(ctx: Ctx, name: string | undefined, template: strin
25
27
  mkdirSync(join(dir, ".agents/skills/dotframe"), { recursive: true });
26
28
  cpSync(resolve(import.meta.dir, "../../skills/dotframe/SKILL.md"), join(dir, ".agents/skills/dotframe/SKILL.md"));
27
29
  mkdirSync(join(dir, "replays"), { recursive: true });
30
+ // Inside an existing repo (porting a game on a branch), the new folder is part of that repo.
31
+ const insideRepo = (await exec({ ...ctx, json: true }, process.cwd(), { label: "git check", argv: ["git", "rev-parse", "--is-inside-work-tree"] })).code === 0;
28
32
  const steps = [
29
- { label: "git init", argv: ["git", "init", "-q"] },
33
+ ...(insideRepo ? [] : [{ label: "git init", argv: ["git", "init", "-q"] }]),
30
34
  ...(install ? [{ label: "install", argv: ["bun", "install"] }] : []),
31
35
  ];
32
36
  for (const step of steps) {
33
37
  const r = await exec(ctx, dir, step);
34
- if (r.code !== 0) throw new CliError("STEP_FAILED", `${step.label}: ${r.tail}`, "on a machine with a private registry, run bun install --registry https://registry.npmjs.org", "game-design");
38
+ if (r.code !== 0) {
39
+ // bun's minimum release age hides versions published in the last N seconds, including a fresh dotframe.
40
+ const fix = /minimum.?release.?age|minimumReleaseAge/i.test(r.tail)
41
+ ? `cd ${name} && bun install --minimum-release-age 0`
42
+ : /registry|401|403|ENOTFOUND/i.test(r.tail)
43
+ ? `cd ${name} && bun install --registry https://registry.npmjs.org`
44
+ : `cd ${name} && bun install, then read its output`;
45
+ throw new CliError("STEP_FAILED", `${step.label}: ${r.tail}`, fix, "game-design");
46
+ }
35
47
  }
36
- print(ctx, { dir, template, next: [`cd ${name}`, "dotframe sim --mash 7 --json", "dotframe dev"] }, (): string => `created ${dir} (${template})\nnext: cd ${name} && dotframe sim --mash 7 && dotframe dev`);
48
+ print(ctx, { dir, template, gitInit: !insideRepo, next: [`cd ${name}`, "dotframe sim --mash 7 --json", "dotframe dev"] }, (): string => `created ${dir} (${template})\nnext: cd ${name} && dotframe sim --mash 7 && dotframe dev`);
37
49
  }
38
50
 
39
51
  // Builds the web target, serves it, and rebuilds when source changes. For humans; agents use sim and snap.
@@ -42,12 +54,16 @@ export async function dev(ctx: Ctx, port: number): Promise<void> {
42
54
  const t = target(config, "web");
43
55
  if (!t.out) throw new CliError("NO_OUT", "the web target needs an out dir", "", "export-web");
44
56
  const out = resolve(config.root, t.out);
45
- await runSteps(ctx, config.root, t.steps, "export-web");
57
+ await runSteps(ctx, config.root, t.steps ?? [], "export-web");
46
58
  const server = Bun.serve({
47
59
  port,
48
60
  fetch: (req: Request): Response => {
49
61
  const path = decodeURIComponent(new URL(req.url).pathname);
50
- return new Response(Bun.file(join(out, path === "/" ? "index.html" : path)));
62
+ const file = join(out, path === "/" ? "index.html" : path);
63
+ if (!existsSync(file)) return new Response("not found", { status: 404 });
64
+ // Same caching as production: the page is never cached, hashed bundles can be.
65
+ const headers = file.endsWith(".html") ? { "Cache-Control": "no-cache" } : undefined;
66
+ return new Response(Bun.file(file), { headers });
51
67
  },
52
68
  });
53
69
  console.error(`serving ${out} at http://localhost:${server.port}`);
@@ -56,7 +72,7 @@ export async function dev(ctx: Ctx, port: number): Promise<void> {
56
72
  if (!file || /(^|\/)(node_modules|dist|\.dotframe|\.git)\//.test(`${file}/`) || file.startsWith(t.out ?? "dist")) return;
57
73
  if (pending) clearTimeout(pending);
58
74
  pending = setTimeout((): void => {
59
- runSteps(ctx, config.root, t.steps, "export-web").then(
75
+ runSteps(ctx, config.root, t.steps ?? [], "export-web").then(
60
76
  (): void => console.error(`rebuilt (${file})`),
61
77
  (e: unknown): void => console.error(e instanceof Error ? e.message : e),
62
78
  );
@@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { resolve } from "node:path";
3
3
  import type { Draw2D } from "../../src/draw2d";
4
4
  import type { Sim, SimRun } from "../../src/sim";
5
- import { CliError, type Ctx, loadConfig, print } from "../lib";
5
+ import { CliError, type Ctx, frames60, loadConfig, num, print } from "../lib";
6
6
  import { firstDifference, flatten, headlessRun, type InputSource, inputSource, lcg, loadSim, parseOptions } from "../simkit";
7
7
 
8
8
  export interface PlayArgs {
@@ -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
  }
@@ -37,7 +39,7 @@ function play(run: SimRun, source: InputSource, frames: number, every: number):
37
39
  async function setup(args: PlayArgs): Promise<{ sim: Sim; run: SimRun; source: InputSource; seed: number; options: Record<string, unknown>; root: string }> {
38
40
  const config = loadConfig();
39
41
  const sim = await loadSim(config);
40
- const seed = Number(args.seed ?? "1");
42
+ const seed = num("seed", args.seed, 1);
41
43
  const options = parseOptions(sim, args.options);
42
44
  const source = inputSource(sim, config.root, args.inputs, args.mash);
43
45
  const run = await headlessRun(sim, config.root);
@@ -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, Number(args.frames ?? "600"), Number(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
  }
@@ -67,15 +70,15 @@ interface Replay {
67
70
  export async function record(ctx: Ctx, file: string, args: PlayArgs): Promise<void> {
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
- const frames = Number(args.frames ?? "1800");
71
- const r = play(run, source, frames, 60);
73
+ const frames = num("frames", args.frames, 1800, 1);
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
  }
@@ -135,13 +138,13 @@ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
135
138
  const config = loadConfig();
136
139
  const sim = await loadSim(config);
137
140
  if (sim.players !== 2) throw new CliError("UNSUPPORTED", "desync simulates two peers; this sim has " + sim.players + " players", "", "netplay");
138
- const frames = Number(args.frames ?? "1800");
139
- const seed = Number(args.seed ?? "1");
140
- const latency = Math.round(Number((args.latency ?? "100ms").replace("ms", "")) / (1000 / 60));
141
- const jitter = Math.round(Number((args.jitter ?? "0ms").replace("ms", "")) / (1000 / 60));
142
- const delay = Number(args.delay ?? "2");
143
- const renders = Number(args.renders ?? "3");
144
- const every = Number(args.every ?? "30");
141
+ const frames = num("frames", args.frames, 1800, 1);
142
+ const seed = num("seed", args.seed, 1);
143
+ const latency = frames60("latency", args.latency, 100);
144
+ const jitter = frames60("jitter", args.jitter, 0);
145
+ const delay = num("delay", args.delay, 2);
146
+ const renders = num("renders", args.renders, 3);
147
+ const every = num("every", args.every, 30, 1);
145
148
  const options = parseOptions(sim, args.options);
146
149
  const source = inputSource(sim, config.root, args.inputs, args.mash ?? (args.inputs ? undefined : "7"));
147
150
  // Input a player presses at frame f applies at frame f + delay, on both peers.
@@ -230,7 +233,13 @@ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
230
233
  maxRollbackFrames: p.maxDepth,
231
234
  }));
232
235
  const ok = report.every((r): boolean => r.firstDivergentFrame < 0 && r.firstStateDifference === null);
233
- print(ctx, { ok, frames, latencyFrames: latency, jitterFrames: jitter, delay, inputs: source.describe, peers: report }, (): string =>
236
+ const deepest = Math.max(...report.map((r): number => r.maxRollbackFrames));
237
+ const warnings: string[] = [];
238
+ if (sim.rollbackWindow !== undefined && deepest > sim.rollbackWindow) {
239
+ warnings.push(`this link needed ${deepest}-frame rollbacks but the game's window is ${sim.rollbackWindow}: real play would stall`);
240
+ }
241
+ if (!sim.rollbackWindow) warnings.push("the sim declares no rollbackWindow, so rollback depth is not checked against the game");
242
+ print(ctx, { ok, warnings, frames, latencyFrames: latency, jitterFrames: jitter, delay, inputs: source.describe, peers: report }, (): string =>
234
243
  [
235
244
  `${frames} frames, latency ${latency}f, jitter ${jitter}f, delay ${delay}f (${source.describe})`,
236
245
  ...report.map((r): string => {
@@ -238,6 +247,7 @@ export async function desync(ctx: Ctx, args: DesyncArgs): Promise<void> {
238
247
  return `peer ${r.peer}${r.rendersBetweenSteps ? " (renders)" : ""}: ${sync}, ${r.rollbacks} rollbacks, max ${r.maxRollbackFrames}f`;
239
248
  }),
240
249
  reference.inspect ? "" : "note: the sim has no inspect(), so only the checksum is compared",
250
+ ...warnings.map((w: string): string => `warning: ${w}`),
241
251
  ].filter(Boolean).join("\n"),
242
252
  );
243
253
  if (!ok) process.exit(1);
@@ -1,9 +1,14 @@
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);
6
7
 
8
+ function countFiles(dir: string): number {
9
+ return readdirSync(dir, { withFileTypes: true }).reduce((n, e) => n + (e.isDirectory() ? countFiles(join(dir, e.name)) : 1), 0);
10
+ }
11
+
7
12
  // Paths marked local-only (unlicensed for distribution) that would ship with a release build.
8
13
  function licenseBlockers(root: string, paths: string[]): string[] {
9
14
  return paths.filter((p: string): boolean => {
@@ -20,7 +25,14 @@ export async function build(ctx: Ctx, name: string | undefined, release: boolean
20
25
  const blocked = licenseBlockers(config.root, config.assets?.localOnly ?? []);
21
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");
22
27
  }
23
- 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);
24
36
  const artifact = t.out ?? t.app;
25
37
  print(ctx, { target: name, dryRun: ctx.dryRun, steps, artifact: artifact ? resolve(config.root, artifact) : null }, (): string =>
26
38
  [...steps.map((s): string => (ctx.dryRun ? s.tail : `ok ${s.label} (${s.ms} ms)`)), artifact ? `artifact: ${artifact}` : ""].filter(Boolean).join("\n"),
@@ -41,7 +53,7 @@ export async function deploy(ctx: Ctx, name: string | undefined, prod: boolean):
41
53
  if (!existsSync(join(out, "index.html"))) throw new CliError("NOT_BUILT", `${t.out}/index.html is missing`, `dotframe build ${name}`, skill);
42
54
  if (t.deploy.scope === "your-vercel-team") throw new CliError("CONFIG_PLACEHOLDER", `targets.${name}.deploy.scope is still the template placeholder`, `dotframe config set targets.${name}.deploy.scope '"<team>"'`, skill);
43
55
  const argv = ["vercel", "deploy", ...(prod ? ["--prod"] : []), "--yes", "--scope", t.deploy.scope, "--name", t.deploy.project];
44
- const plan = { target: name, provider: t.deploy.provider, project: `${t.deploy.scope}/${t.deploy.project}`, dir: out, production: prod, files: readdirSync(out).length, command: argv.join(" ") };
56
+ const plan = { target: name, provider: t.deploy.provider, project: `${t.deploy.scope}/${t.deploy.project}`, dir: out, production: prod, files: countFiles(out), command: argv.join(" ") };
45
57
  if (ctx.dryRun) {
46
58
  print(ctx, { dryRun: true, ...plan }, (): string => `would deploy ${out} to ${plan.project}${prod ? " (production)" : " (preview)"}\n ${plan.command}`);
47
59
  return;
@@ -92,3 +104,27 @@ export async function device(ctx: Ctx, name: string): Promise<void> {
92
104
  if (r.code !== 0) throw new CliError("INSTALL_FAILED", r.tail, "unlock the phone, trust this Mac, enable Developer Mode", "ios");
93
105
  print(ctx, { device: t.device, app, ms: r.ms }, (): string => `installed ${app} on ${t.device}`);
94
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,6 +1,7 @@
1
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
2
3
  import { join, resolve } from "node:path";
3
- import { CliError, type Ctx, exec, loadConfig, print, which } from "../lib";
4
+ import { CliError, type Ctx, exec, loadConfig, num, print, which } from "../lib";
4
5
  import { inputSource, loadSim, parseOptions, simPath } from "../simkit";
5
6
  import type { PlayArgs } from "./play";
6
7
 
@@ -11,16 +12,17 @@ const ENGINE = resolve(import.meta.dir, "../../src");
11
12
  export async function snap(ctx: Ctx, args: PlayArgs & { frame?: string; out?: string }): Promise<void> {
12
13
  const config = loadConfig();
13
14
  const sim = await loadSim(config);
14
- const frame = Number(args.frame ?? "0");
15
+ if (args.frame === undefined) throw new CliError("MISSING_ARG", "snap needs --frame <n>", "dotframe snap --frame 300 --mash 7");
16
+ const frame = num("frame", args.frame, 0);
15
17
  const out = resolve(process.cwd(), args.out ?? `snap-${frame}.png`);
16
- const seed = Number(args.seed ?? "1");
18
+ const seed = num("seed", args.seed, 1);
17
19
  const options = parseOptions(sim, args.options);
18
20
  const source = inputSource(sim, config.root, args.inputs, args.mash);
19
21
  const inputs = Array.from({ length: frame }, (_: unknown, f: number): number[] => source.at(f));
20
22
  if (!which("agent-browser")) throw new CliError("TOOL_MISSING", "agent-browser not found (snap drives a real browser)", "npm i -g agent-browser && agent-browser install");
21
23
 
22
- const work = join(config.root, ".dotframe", "snap");
23
- mkdirSync(work, { recursive: true });
24
+ // Outside the repo, so a snap never leaves files to commit.
25
+ const work = mkdtempSync(join(tmpdir(), "dotframe-snap-"));
24
26
  const entry = join(work, "entry.ts");
25
27
  writeFileSync(
26
28
  entry,
@@ -65,9 +67,10 @@ run(sim.window, (p) => {
65
67
  });
66
68
  const ab = (...a: string[]) => exec({ ...ctx, json: true }, config.root, { label: `agent-browser ${a[0]}`, argv: ["agent-browser", "--session", "dotframe-snap", ...a] });
67
69
  try {
68
- await ab("set", "viewport", String(sim.window.width), String(sim.window.height));
69
70
  const opened = await ab("open", `http://localhost:${server.port}/`);
70
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));
71
74
  // Loading plus stepping can take a while for long inputs; poll for the marker.
72
75
  let result: { frame?: number; checksum?: number; state?: unknown; error?: string } | null = null;
73
76
  for (let i = 0; i < 120 && !result; i++) {
@@ -78,11 +81,32 @@ run(sim.window, (p) => {
78
81
  }
79
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");
80
83
  if (result.error) throw new CliError("SNAP_PAGE_ERROR", result.error, "WebGPU unavailable? try AGENT_BROWSER_ARGS=--enable-unsafe-webgpu", "core");
81
- const shot = await ab("screenshot", out);
84
+ let shot = await ab("screenshot", out);
82
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
+ }
83
95
  print(ctx, { out, ...result }, (): string => `frame ${result?.frame} -> ${out} (checksum ${result?.checksum})`);
84
96
  } finally {
85
97
  await ab("close");
86
98
  server.stop(true);
99
+ rmSync(work, { recursive: true, force: true });
87
100
  }
88
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,57 @@ 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
  }
164
+
165
+ // Numeric flags: a bad value is an error, never a silent NaN that runs zero frames.
166
+ export function num(flag: string, raw: string | undefined, fallback: number, min = 0): number {
167
+ if (raw === undefined) return fallback;
168
+ const value = Number(raw);
169
+ if (raw.trim() === "" || !Number.isInteger(value) || value < min) {
170
+ throw new CliError("BAD_ARG", `--${flag} must be an integer >= ${min}, got "${raw}"`, `--${flag} ${Math.max(min, fallback)}`);
171
+ }
172
+ return value;
173
+ }
174
+
175
+ // Durations in milliseconds ("120ms" or "120"), converted to 60 Hz frames.
176
+ export function frames60(flag: string, raw: string | undefined, fallbackMs: number): number {
177
+ const text = raw ?? `${fallbackMs}ms`;
178
+ const match = text.trim().match(/^(\d+(?:\.\d+)?)(ms)?$/);
179
+ if (!match) throw new CliError("BAD_ARG", `--${flag} must be a duration like 120ms, got "${text}"`, `--${flag} ${fallbackMs}ms`);
180
+ return Math.round(Number(match[1]) / (1000 / 60));
181
+ }
182
+
183
+ // JSON with short objects and arrays kept on one line, so `config set` does not explode a hand-written file.
184
+ export function formatJson(value: unknown, indent = "", width = 100): string {
185
+ const inline = JSON.stringify(value, null, 1).replace(/\n\s*/g, " ").replace(/\[ /g, "[").replace(/ \]/g, "]").replace(/\{ /g, "{ ").replace(/ \}/g, " }");
186
+ if (value === null || typeof value !== "object" || indent.length + inline.length <= width) return inline;
187
+ const next = `${indent} `;
188
+ if (Array.isArray(value)) return `[\n${value.map((v) => next + formatJson(v, next, width)).join(",\n")}\n${indent}]`;
189
+ const entries = Object.entries(value as Record<string, unknown>);
190
+ return `{\n${entries.map(([k, v]) => `${next}${JSON.stringify(k)}: ${formatJson(v, next, width)}`).join(",\n")}\n${indent}}`;
191
+ }