dotframe 0.1.3 → 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 +12 -0
- package/cli/commands/doctor.ts +34 -8
- package/cli/commands/new.ts +2 -2
- package/cli/commands/play.ts +9 -6
- package/cli/commands/ship.ts +33 -1
- package/cli/commands/snap.ts +24 -3
- package/cli/lib.ts +22 -7
- package/cli/main.ts +17 -7
- package/cli/native.ts +152 -0
- package/package.json +3 -2
- package/scripts/vendor.sh +4 -2
- package/skill-data/core/SKILL.md +6 -0
- package/skill-data/core/references/errors.md +5 -1
- package/skill-data/macos/SKILL.md +34 -5
- package/skill-data/netplay/SKILL.md +2 -0
- package/templates/_base/dotframe.json +3 -1
- package/templates/_base/main.native.ts +6 -0
- package/templates/_base/main.web.ts +3 -25
- package/templates/_base/src/setup.ts +29 -0
- package/templates/_base/tsconfig.json +2 -1
- package/templates/fighter/game.ts +9 -1
- package/templates/platformer/game.ts +3 -7
- package/vendor/toolchain/zig-ar.sh +2 -0
- package/vendor/toolchain/zig-cc.sh +2 -0
- package/vendor/toolchain/zig-cxx.sh +2 -0
- package/vendor/toolchain/zig-ranlib.sh +2 -0
- package/vendor/toolchain/zig-rc.sh +2 -0
- package/vendor/toolchain/zig-windows.cmake +7 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
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
|
+
|
|
3
15
|
## 0.1.3
|
|
4
16
|
|
|
5
17
|
Fixes from the Craft Ones port dogfood.
|
package/cli/commands/doctor.ts
CHANGED
|
@@ -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 {
|
|
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.
|
|
82
|
-
//
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
}
|
package/cli/commands/new.ts
CHANGED
|
@@ -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
|
);
|
package/cli/commands/play.ts
CHANGED
|
@@ -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
|
}
|
package/cli/commands/ship.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/cli/commands/snap.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
132
|
-
|
|
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)
|
|
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
|
-
|
|
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.
|
|
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
|
|
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,152 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, relative, resolve } 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 SKIP = new Set(["node_modules", ".git", ".dotframe", "dist", "build", ".vercel"]);
|
|
48
|
+
|
|
49
|
+
function copyTs(from: string, to: string): number {
|
|
50
|
+
let n = 0;
|
|
51
|
+
for (const name of readdirSync(from)) {
|
|
52
|
+
if (SKIP.has(name)) continue;
|
|
53
|
+
const src = join(from, name);
|
|
54
|
+
if (statSync(src).isDirectory()) n += copyTs(src, join(to, name));
|
|
55
|
+
else if (name.endsWith(".ts") || name.endsWith(".json")) {
|
|
56
|
+
mkdirSync(to, { recursive: true });
|
|
57
|
+
cpSync(src, join(to, name));
|
|
58
|
+
n += 1;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return n;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// scriptc's static build takes relative imports only, and treats anything under node_modules as package code
|
|
65
|
+
// for its dynamic engine. Staging copies the engine and the game side by side and rewrites "dotframe/..."
|
|
66
|
+
// imports to relative paths.
|
|
67
|
+
function rewriteImports(dir: string, engineRoot: string): void {
|
|
68
|
+
for (const name of readdirSync(dir)) {
|
|
69
|
+
const path = join(dir, name);
|
|
70
|
+
if (statSync(path).isDirectory()) {
|
|
71
|
+
rewriteImports(path, engineRoot);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (!name.endsWith(".ts")) continue;
|
|
75
|
+
const text = readFileSync(path, "utf8");
|
|
76
|
+
const next = text.replace(/(from\s+|import\s*\(\s*)(["'])dotframe\/([^"']+)\2/g, (_m: string, head: string, q: string, rest: string): string => {
|
|
77
|
+
let rel = relative(dirname(path), join(engineRoot, rest));
|
|
78
|
+
if (!rel.startsWith(".")) rel = `./${rel}`;
|
|
79
|
+
return `${head}${q}${rel}${q}`;
|
|
80
|
+
});
|
|
81
|
+
if (next !== text) writeFileSync(path, next);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function buildNative(ctx: Ctx, root: string, gameName: string, targetName: string, t: NativeTarget): Promise<{ binary: string; log: string; steps: RunResult[] }> {
|
|
86
|
+
const platform = t.platform;
|
|
87
|
+
const skill = platform;
|
|
88
|
+
const tools = platform === "macos" ? ["clang", "scriptc"] : ["zig", "scriptc"];
|
|
89
|
+
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);
|
|
90
|
+
const vendor = vendorStatus(platform);
|
|
91
|
+
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);
|
|
92
|
+
|
|
93
|
+
const stage = join(root, ".dotframe", "native", targetName);
|
|
94
|
+
const logDir = join(root, ".dotframe", "logs");
|
|
95
|
+
const log = join(logDir, `build-${targetName}.log`);
|
|
96
|
+
const out = join(root, "dist", targetName);
|
|
97
|
+
const binary = join(out, `${t.name ?? gameName}${platform === "windows" ? ".exe" : ""}`);
|
|
98
|
+
if (ctx.dryRun) {
|
|
99
|
+
print(ctx, { dryRun: true, platform, stage, vendor: vendor.dir, binary }, (): string => `would stage the engine and game in ${stage} and build ${binary}`);
|
|
100
|
+
return { binary, log, steps: [] };
|
|
101
|
+
}
|
|
102
|
+
rmSync(stage, { recursive: true, force: true });
|
|
103
|
+
mkdirSync(logDir, { recursive: true });
|
|
104
|
+
mkdirSync(out, { recursive: true });
|
|
105
|
+
const engineStage = join(stage, "dotframe");
|
|
106
|
+
cpSync(join(ENGINE, "src"), join(engineStage, "src"), { recursive: true });
|
|
107
|
+
const gameStage = join(stage, "game");
|
|
108
|
+
copyTs(root, gameStage);
|
|
109
|
+
rewriteImports(gameStage, engineStage);
|
|
110
|
+
|
|
111
|
+
const inc = [`-I${join(vendor.dir, vendor.sdl ?? "", "include")}`, `-I${join(vendor.dir, "wgpu", platform, "include")}`];
|
|
112
|
+
const lib = join(stage, "lib");
|
|
113
|
+
mkdirSync(lib, { recursive: true });
|
|
114
|
+
const cc = platform === "macos" ? ["clang", "-O2", "-mmacosx-version-min=14.0"] : ["zig", "cc", "-target", "x86_64-windows-gnu", "-O2"];
|
|
115
|
+
const ar = platform === "macos" ? ["ar", "rcs"] : ["zig", "ar", "rcs"];
|
|
116
|
+
const steps = [
|
|
117
|
+
...["df_native", "df_audio"].map((unit) => ({ label: `cc ${unit}`, argv: [...cc, "-c", join(ENGINE, "native", `${unit}.c`), ...inc, "-o", join(lib, `${unit}.o`)] })),
|
|
118
|
+
{ label: "ar libdf_native", argv: [...ar, join(lib, "libdf_native.a"), join(lib, "df_native.o"), join(lib, "df_audio.o")] },
|
|
119
|
+
];
|
|
120
|
+
const ffi = JSON.parse(readFileSync(join(ENGINE, "native", `ffi.${platform}.json`), "utf8")) as { libraries: string[] };
|
|
121
|
+
ffi.libraries = [join(lib, "libdf_native.a"), join(vendor.dir, "build", `sdl-${platform}`, "libSDL3.a"), join(vendor.dir, "wgpu", platform, "lib", "libwgpu_native.a")];
|
|
122
|
+
writeFileSync(join(stage, "ffi.json"), JSON.stringify(ffi, null, 2));
|
|
123
|
+
const entry = join(gameStage, t.entry);
|
|
124
|
+
if (!existsSync(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);
|
|
125
|
+
let env: Record<string, string> = {};
|
|
126
|
+
if (platform === "windows") {
|
|
127
|
+
// A devDependency of dotframe, so an npm install of dotframe does not bring it; the game can.
|
|
128
|
+
const pack = [ENGINE, root].map((dir: string): string => join(dir, "node_modules", "@scriptc", "runtime-win32-x64-msvc")).find((p: string): boolean => existsSync(p));
|
|
129
|
+
if (!pack) throw new CliError("TOOL_MISSING", "the scriptc Windows runtime pack is not installed", "bun add -d @scriptc/runtime-win32-x64-msvc", skill);
|
|
130
|
+
env = { SCRIPTC_TARGET: "x86_64-windows-gnu", SCRIPTC_RUNTIME_PACK: pack };
|
|
131
|
+
}
|
|
132
|
+
const scriptc = {
|
|
133
|
+
label: "scriptc",
|
|
134
|
+
argv: ["scriptc", "build", t.entry, "--ffi", join(stage, "ffi.json"), ...(platform === "windows" ? ["--windows-subsystem", "gui"] : []), "-o", binary],
|
|
135
|
+
cwd: gameStage,
|
|
136
|
+
env,
|
|
137
|
+
};
|
|
138
|
+
const results: RunResult[] = [];
|
|
139
|
+
const full: string[] = [];
|
|
140
|
+
for (const step of [...steps, scriptc]) {
|
|
141
|
+
if (!ctx.json) console.error(`> ${step.label}`);
|
|
142
|
+
const r = await exec(ctx, root, step);
|
|
143
|
+
full.push(`> ${step.label}: ${step.argv.join(" ")}\n${r.output}`);
|
|
144
|
+
writeFileSync(log, full.join("\n"));
|
|
145
|
+
results.push(r);
|
|
146
|
+
if (r.code !== 0) {
|
|
147
|
+
const count = r.output.match(/(\d+) errors?/)?.[1];
|
|
148
|
+
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);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { binary, log, steps: results };
|
|
152
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dotframe",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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
|
|
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=$
|
|
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
|
package/skill-data/core/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 the game's `.ts` and `.json` files into `.dotframe/native/<target>/`, rewrites `dotframe/...` 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 {
|
|
2
|
+
import { WINDOW } from "./src/game";
|
|
3
|
+
import { createSetup } from "./src/setup";
|
|
7
4
|
|
|
8
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
|
47
|
-
|
|
48
|
-
|
|
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,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")
|