dotframe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/LICENSE +21 -0
  3. package/README.md +57 -0
  4. package/assets/audio/hit.mp3 +0 -0
  5. package/assets/audio/loop.mp3 +0 -0
  6. package/assets/crafter.png +0 -0
  7. package/assets/fonts/archivo-black.json +133 -0
  8. package/assets/fonts/archivo-black.png +0 -0
  9. package/assets/fonts/bangers.json +133 -0
  10. package/assets/fonts/bangers.png +0 -0
  11. package/assets/suzanne.glb +0 -0
  12. package/cli/commands/config.ts +35 -0
  13. package/cli/commands/doctor.ts +71 -0
  14. package/cli/commands/new.ts +73 -0
  15. package/cli/commands/play.ts +244 -0
  16. package/cli/commands/ship.ts +94 -0
  17. package/cli/commands/skills.ts +66 -0
  18. package/cli/commands/snap.ts +88 -0
  19. package/cli/lib.ts +148 -0
  20. package/cli/main.ts +95 -0
  21. package/cli/simkit.ts +152 -0
  22. package/cli/tsconfig.json +8 -0
  23. package/native/df_audio.c +266 -0
  24. package/native/df_native.c +605 -0
  25. package/native/ffi.macos.json +318 -0
  26. package/native/ffi.windows.json +322 -0
  27. package/native/third_party/dr_mp3.h +5430 -0
  28. package/native/third_party/stb_image.h +7988 -0
  29. package/native/third_party/stb_image_write.h +1724 -0
  30. package/native/third_party/stb_truetype.h +5079 -0
  31. package/package.json +47 -0
  32. package/scripts/build-native.sh +33 -0
  33. package/scripts/build-web.sh +10 -0
  34. package/scripts/vendor.sh +36 -0
  35. package/skills/assets/SKILL.md +11 -0
  36. package/skills/core/SKILL.md +51 -0
  37. package/skills/core/references/errors.md +22 -0
  38. package/skills/discord/SKILL.md +12 -0
  39. package/skills/export-web/SKILL.md +20 -0
  40. package/skills/game-design/SKILL.md +27 -0
  41. package/skills/game-design/references/sim-walkthrough.md +32 -0
  42. package/skills/ios/SKILL.md +19 -0
  43. package/skills/macos/SKILL.md +16 -0
  44. package/skills/netplay/SKILL.md +34 -0
  45. package/skills/relay/SKILL.md +18 -0
  46. package/src/audio.ts +19 -0
  47. package/src/draw2d.ts +901 -0
  48. package/src/ecs.ts +30 -0
  49. package/src/gltf.ts +82 -0
  50. package/src/gpu.ts +86 -0
  51. package/src/input.ts +215 -0
  52. package/src/math.ts +113 -0
  53. package/src/native/backend.ts +142 -0
  54. package/src/native/ffi.ts +42 -0
  55. package/src/native/library.ts +65 -0
  56. package/src/native/run.ts +88 -0
  57. package/src/physics.ts +57 -0
  58. package/src/platform.ts +12 -0
  59. package/src/raster2d.ts +348 -0
  60. package/src/render.ts +132 -0
  61. package/src/shapes.ts +37 -0
  62. package/src/sim.ts +56 -0
  63. package/src/storage.ts +6 -0
  64. package/src/web/run.ts +338 -0
  65. package/templates/_base/AGENTS.md +7 -0
  66. package/templates/_base/dotframe.json +15 -0
  67. package/templates/_base/gitignore +5 -0
  68. package/templates/_base/index.html +14 -0
  69. package/templates/_base/main.web.ts +27 -0
  70. package/templates/_base/package.json +12 -0
  71. package/templates/_base/sim.ts +31 -0
  72. package/templates/blank/game.ts +64 -0
  73. package/templates/fighter/game.ts +131 -0
  74. package/templates/platformer/game.ts +120 -0
  75. package/tools/check-audio.ts +57 -0
  76. package/tools/gen-library-glue.ts +83 -0
  77. package/tools/make-sprite.ts +92 -0
@@ -0,0 +1,31 @@
1
+ // The dotframe CLI contract: dotframe sim, snap, replay, and desync drive the game through this module.
2
+ import { defineSim, type SimPlatform, type SimRun } from "dotframe/src/sim";
3
+ import { checksum, createGame, PLAYERS, encode, type Game, randomInput, render, snapshot, state, step, WINDOW } from "./src/game";
4
+
5
+ export default defineSim({
6
+ players: PLAYERS,
7
+ window: WINDOW,
8
+ options: {},
9
+ neutral: 0,
10
+ encode,
11
+ random: randomInput,
12
+ create: (_platform: SimPlatform): SimRun => {
13
+ let game: Game = createGame(1);
14
+ return {
15
+ ready: Promise.resolve(),
16
+ start: (seed: number): void => {
17
+ game = createGame(seed);
18
+ },
19
+ step: (inputs: number[]): void => step(game, inputs),
20
+ checksum: (): number => checksum(game),
21
+ state: (): unknown => state(game),
22
+ over: (): boolean => game.winner >= 0,
23
+ save: (): unknown => snapshot(game),
24
+ restore: (saved: unknown): void => {
25
+ Object.assign(game, snapshot(saved as Game));
26
+ },
27
+ inspect: (): unknown => game,
28
+ render: (draw) => render(game, draw),
29
+ };
30
+ },
31
+ });
@@ -0,0 +1,64 @@
1
+ // The smallest dotframe game: two squares. All simulation state lives in Game and changes only in step(), so
2
+ // dotframe can replay, snapshot, and roll it back. render() only reads it.
3
+ import type { Draw2D } from "dotframe/src/draw2d";
4
+ import type { Input } from "dotframe/src/input";
5
+
6
+ export const WINDOW = { width: 960, height: 540, title: "__NAME__" };
7
+ export const PLAYERS = 2;
8
+
9
+ export interface Game {
10
+ frame: number;
11
+ players: { x: number; y: number }[];
12
+ // Never ends; set it to a player index to finish the match.
13
+ winner: number;
14
+ }
15
+
16
+ export function createGame(_seed: number): Game {
17
+ return { frame: 0, players: [{ x: 300, y: 270 }, { x: 660, y: 270 }], winner: -1 };
18
+ }
19
+
20
+ // Bits: left, right, up, action.
21
+ export function encode(input: unknown): number {
22
+ const i = input as { left?: boolean; right?: boolean; up?: boolean; action?: boolean };
23
+ return (i.left ? 1 : 0) | (i.right ? 2 : 0) | (i.up ? 4 : 0) | (i.action ? 8 : 0);
24
+ }
25
+
26
+ export function keyInput(input: Input, keys: number[]): number {
27
+ return keys.reduce((bits: number, key: number, i: number): number => (input.down(key) ? bits | (1 << i) : bits), 0);
28
+ }
29
+
30
+ export function randomInput(next: () => number): number {
31
+ return Math.floor(next() * 16);
32
+ }
33
+
34
+ export function step(game: Game, inputs: number[]): void {
35
+ game.frame += 1;
36
+ game.players.forEach((p, i: number): void => {
37
+ const bits = inputs[i] ?? 0;
38
+ p.x = Math.max(20, Math.min(WINDOW.width - 20, p.x + (bits & 2 ? 4 : 0) - (bits & 1 ? 4 : 0)));
39
+ p.y = Math.max(20, Math.min(WINDOW.height - 20, p.y - (bits & 4 ? 4 : 0) + 1));
40
+ });
41
+ }
42
+
43
+ export function checksum(game: Game): number {
44
+ let h = 2166136261 ^ game.frame;
45
+ for (const p of game.players) h = Math.imul(Math.imul(h ^ p.x, 16777619) ^ p.y, 16777619);
46
+ return h >>> 0;
47
+ }
48
+
49
+ export function state(game: Game): unknown {
50
+ return game;
51
+ }
52
+
53
+ export function snapshot(game: Game): Game {
54
+ return structuredClone(game);
55
+ }
56
+
57
+ export function render(game: Game, draw: Draw2D): void {
58
+ draw.setFillStyle("#101018");
59
+ draw.fillRect(0, 0, WINDOW.width, WINDOW.height);
60
+ game.players.forEach((p, i: number): void => {
61
+ draw.setFillStyle(i === 0 ? "#ff5a5f" : "#3fa7ff");
62
+ draw.fillRect(p.x - 20, p.y - 20, 40, 40);
63
+ });
64
+ }
@@ -0,0 +1,131 @@
1
+ // A two-player box fighter. All simulation state lives in Game and changes only in step(), so dotframe can
2
+ // replay, snapshot, and roll it back. render() only reads it.
3
+ import type { Draw2D } from "dotframe/src/draw2d";
4
+ import type { Input } from "dotframe/src/input";
5
+
6
+ export const WINDOW = { width: 960, height: 540, title: "__NAME__" };
7
+ export const PLAYERS = 2;
8
+
9
+ const LEFT = 1;
10
+ const RIGHT = 2;
11
+ const JUMP = 4;
12
+ const ATTACK = 8;
13
+ const GROUND = 440;
14
+ const GRAVITY = 0.8;
15
+ const SPEED = 5;
16
+ const JUMP_SPEED = 15;
17
+ const STOCKS = 3;
18
+
19
+ export interface Fighter {
20
+ x: number;
21
+ y: number;
22
+ vx: number;
23
+ vy: number;
24
+ facing: number;
25
+ damage: number;
26
+ stocks: number;
27
+ cooldown: number;
28
+ stun: number;
29
+ held: number;
30
+ }
31
+
32
+ export interface Game {
33
+ frame: number;
34
+ seed: number;
35
+ fighters: Fighter[];
36
+ // Index of the winner, or -1 while the match runs.
37
+ winner: number;
38
+ }
39
+
40
+ const fighter = (x: number, facing: number): Fighter => ({ x, y: GROUND, vx: 0, vy: 0, facing, damage: 0, stocks: STOCKS, cooldown: 0, stun: 0, held: 0 });
41
+
42
+ export function createGame(seed: number): Game {
43
+ return { frame: 0, seed: seed >>> 0, fighters: [fighter(300, 1), fighter(660, -1)], winner: -1 };
44
+ }
45
+
46
+ export function encode(input: unknown): number {
47
+ const i = input as { left?: boolean; right?: boolean; jump?: boolean; attack?: boolean };
48
+ return (i.left ? LEFT : 0) | (i.right ? RIGHT : 0) | (i.jump ? JUMP : 0) | (i.attack ? ATTACK : 0);
49
+ }
50
+
51
+ export function keyInput(input: Input, keys: number[]): number {
52
+ return keys.reduce((bits: number, key: number, i: number): number => (input.down(key) ? bits | (1 << i) : bits), 0);
53
+ }
54
+
55
+ export function randomInput(next: () => number): number {
56
+ return (next() < 0.4 ? LEFT : next() < 0.6 ? RIGHT : 0) | (next() < 0.15 ? JUMP : 0) | (next() < 0.3 ? ATTACK : 0);
57
+ }
58
+
59
+ export function step(game: Game, inputs: number[]): void {
60
+ if (game.winner >= 0) return;
61
+ game.frame += 1;
62
+ game.fighters.forEach((f: Fighter, i: number): void => {
63
+ const other = game.fighters[1 - i];
64
+ const bits = inputs[i] ?? 0;
65
+ const pressed = bits & ~f.held;
66
+ f.held = bits;
67
+ if (f.stun > 0) f.stun -= 1;
68
+ else {
69
+ const move = (bits & RIGHT ? 1 : 0) - (bits & LEFT ? 1 : 0);
70
+ f.vx = move * SPEED;
71
+ if (move !== 0) f.facing = move;
72
+ if (pressed & JUMP && f.y >= GROUND) f.vy = -JUMP_SPEED;
73
+ if (pressed & ATTACK && f.cooldown === 0) {
74
+ f.cooldown = 20;
75
+ const dx = other.x - f.x;
76
+ if (Math.sign(dx) === f.facing && Math.abs(dx) < 70 && Math.abs(other.y - f.y) < 60) {
77
+ other.damage += 8;
78
+ other.vx = f.facing * (4 + other.damage * 0.12);
79
+ other.vy = -(5 + other.damage * 0.08);
80
+ other.stun = 15;
81
+ }
82
+ }
83
+ }
84
+ if (f.cooldown > 0) f.cooldown -= 1;
85
+ f.vy += GRAVITY;
86
+ f.x += f.vx;
87
+ f.y = Math.min(GROUND, f.y + f.vy);
88
+ if (f.y >= GROUND) f.vy = 0;
89
+ if (f.x < -100 || f.x > WINDOW.width + 100) {
90
+ f.stocks -= 1;
91
+ Object.assign(f, { ...fighter(WINDOW.width / 2, f.facing), stocks: f.stocks });
92
+ if (f.stocks === 0) game.winner = 1 - i;
93
+ }
94
+ });
95
+ }
96
+
97
+ export function checksum(game: Game): number {
98
+ let h = 2166136261 ^ game.frame;
99
+ for (const f of game.fighters) for (const v of [f.x, f.y, f.vx, f.vy, f.damage, f.stocks]) h = Math.imul(h ^ Math.round(v * 100), 16777619);
100
+ return h >>> 0;
101
+ }
102
+
103
+ export function state(game: Game): unknown {
104
+ return { frame: game.frame, winner: game.winner, fighters: game.fighters.map(({ x, y, damage, stocks }) => ({ x: Math.round(x), y: Math.round(y), damage, stocks })) };
105
+ }
106
+
107
+ export function snapshot(game: Game): Game {
108
+ return structuredClone(game);
109
+ }
110
+
111
+ const COLORS = ["#ff5a5f", "#3fa7ff"];
112
+
113
+ export function render(game: Game, draw: Draw2D): void {
114
+ draw.setFillStyle("#14121f");
115
+ draw.fillRect(0, 0, WINDOW.width, WINDOW.height);
116
+ draw.setFillStyle("#3a3654");
117
+ draw.fillRect(80, GROUND + 30, WINDOW.width - 160, 20);
118
+ game.fighters.forEach((f: Fighter, i: number): void => {
119
+ draw.setFillStyle(f.stun > 0 ? "#ffffff" : COLORS[i]);
120
+ draw.fillRect(f.x - 20, f.y - 30, 40, 60);
121
+ draw.setFillStyle("#ffffff");
122
+ draw.fillRect(f.x + f.facing * 14 - 4, f.y - 18, 8, 8);
123
+ // HUD: damage bar and stocks, no fonts needed.
124
+ const hx = i === 0 ? 40 : WINDOW.width - 240;
125
+ draw.setFillStyle("#2a2740");
126
+ draw.fillRect(hx, 30, 200, 12);
127
+ draw.setFillStyle(COLORS[i]);
128
+ draw.fillRect(hx, 30, Math.min(200, f.damage), 12);
129
+ for (let s = 0; s < f.stocks; s++) draw.fillRect(hx + s * 18, 50, 12, 12);
130
+ });
131
+ }
@@ -0,0 +1,120 @@
1
+ // A one-player platformer: run, jump, collect every coin. All simulation state lives in Game and changes only in
2
+ // step(), so dotframe can replay, snapshot, and roll it back. render() only reads it.
3
+ import type { Draw2D } from "dotframe/src/draw2d";
4
+ import type { Input } from "dotframe/src/input";
5
+
6
+ export const WINDOW = { width: 960, height: 540, title: "__NAME__" };
7
+ export const PLAYERS = 1;
8
+
9
+ const LEFT = 1;
10
+ const RIGHT = 2;
11
+ const JUMP = 4;
12
+ const GRAVITY = 0.7;
13
+ const SPEED = 4.5;
14
+ const JUMP_SPEED = 13;
15
+ const SIZE = 28;
16
+
17
+ interface Rect {
18
+ x: number;
19
+ y: number;
20
+ w: number;
21
+ h: number;
22
+ }
23
+
24
+ const PLATFORMS: Rect[] = [
25
+ { x: 0, y: 500, w: 960, h: 40 },
26
+ { x: 140, y: 400, w: 160, h: 16 },
27
+ { x: 380, y: 320, w: 180, h: 16 },
28
+ { x: 640, y: 240, w: 160, h: 16 },
29
+ { x: 420, y: 150, w: 120, h: 16 },
30
+ ];
31
+
32
+ export interface Game {
33
+ frame: number;
34
+ x: number;
35
+ y: number;
36
+ vx: number;
37
+ vy: number;
38
+ onGround: boolean;
39
+ held: number;
40
+ coins: { x: number; y: number; taken: boolean }[];
41
+ // 0 once every coin is taken, -1 while playing.
42
+ winner: number;
43
+ }
44
+
45
+ export function createGame(_seed: number): Game {
46
+ const coins = [
47
+ { x: 220, y: 370 },
48
+ { x: 470, y: 290 },
49
+ { x: 720, y: 210 },
50
+ { x: 480, y: 120 },
51
+ { x: 880, y: 470 },
52
+ ].map((c) => ({ ...c, taken: false }));
53
+ return { frame: 0, x: 60, y: 500 - SIZE, vx: 0, vy: 0, onGround: true, held: 0, coins, winner: -1 };
54
+ }
55
+
56
+ export function encode(input: unknown): number {
57
+ const i = input as { left?: boolean; right?: boolean; jump?: boolean };
58
+ return (i.left ? LEFT : 0) | (i.right ? RIGHT : 0) | (i.jump ? JUMP : 0);
59
+ }
60
+
61
+ export function keyInput(input: Input, keys: number[]): number {
62
+ return keys.reduce((bits: number, key: number, i: number): number => (input.down(key) ? bits | (1 << i) : bits), 0);
63
+ }
64
+
65
+ export function randomInput(next: () => number): number {
66
+ return (next() < 0.35 ? LEFT : next() < 0.75 ? RIGHT : 0) | (next() < 0.3 ? JUMP : 0);
67
+ }
68
+
69
+ export function step(game: Game, inputs: number[]): void {
70
+ if (game.winner >= 0) return;
71
+ game.frame += 1;
72
+ const bits = inputs[0] ?? 0;
73
+ const pressed = bits & ~game.held;
74
+ game.held = bits;
75
+ game.vx = ((bits & RIGHT ? 1 : 0) - (bits & LEFT ? 1 : 0)) * SPEED;
76
+ if (pressed & JUMP && game.onGround) game.vy = -JUMP_SPEED;
77
+ game.vy = Math.min(game.vy + GRAVITY, 16);
78
+ game.x = Math.max(0, Math.min(WINDOW.width - SIZE, game.x + game.vx));
79
+ const prevBottom = game.y + SIZE;
80
+ game.y += game.vy;
81
+ game.onGround = false;
82
+ // One-way platforms: land only when falling onto the top edge.
83
+ for (const p of PLATFORMS) {
84
+ const overX = game.x + SIZE > p.x && game.x < p.x + p.w;
85
+ if (overX && game.vy >= 0 && prevBottom <= p.y && game.y + SIZE >= p.y) {
86
+ game.y = p.y - SIZE;
87
+ game.vy = 0;
88
+ game.onGround = true;
89
+ }
90
+ }
91
+ for (const c of game.coins) {
92
+ if (!c.taken && Math.abs(game.x + SIZE / 2 - c.x) < 22 && Math.abs(game.y + SIZE / 2 - c.y) < 22) c.taken = true;
93
+ }
94
+ if (game.coins.every((c) => c.taken)) game.winner = 0;
95
+ }
96
+
97
+ export function checksum(game: Game): number {
98
+ let h = 2166136261 ^ game.frame;
99
+ for (const v of [game.x, game.y, game.vx, game.vy, ...game.coins.map((c) => (c.taken ? 1 : 0))]) h = Math.imul(h ^ Math.round(v * 100), 16777619);
100
+ return h >>> 0;
101
+ }
102
+
103
+ export function state(game: Game): unknown {
104
+ return { frame: game.frame, x: Math.round(game.x), y: Math.round(game.y), coins: game.coins.filter((c) => c.taken).length, of: game.coins.length, won: game.winner === 0 };
105
+ }
106
+
107
+ export function snapshot(game: Game): Game {
108
+ return structuredClone(game);
109
+ }
110
+
111
+ export function render(game: Game, draw: Draw2D): void {
112
+ draw.setFillStyle("#0f1a2b");
113
+ draw.fillRect(0, 0, WINDOW.width, WINDOW.height);
114
+ draw.setFillStyle("#3c5a7a");
115
+ for (const p of PLATFORMS) draw.fillRect(p.x, p.y, p.w, p.h);
116
+ draw.setFillStyle("#ffd23f");
117
+ for (const c of game.coins) if (!c.taken) draw.fillRect(c.x - 8, c.y - 8, 16, 16);
118
+ draw.setFillStyle(game.winner === 0 ? "#7cff8a" : "#ff5a5f");
119
+ draw.fillRect(game.x, game.y, SIZE, SIZE);
120
+ }
@@ -0,0 +1,57 @@
1
+ // Verifies the mix written by examples/audio-check through SDL's disk audio driver.
2
+ // Usage: bun tools/check-audio.ts <out.raw> (F32LE, stereo, 48 kHz)
3
+ const RATE = 48000;
4
+ const WINDOW = RATE / 20;
5
+
6
+ const bytes = new Uint8Array(await Bun.file(process.argv[2]).arrayBuffer());
7
+ const samples = new Float32Array(bytes.buffer, 0, Math.floor(bytes.byteLength / 4));
8
+
9
+ interface Window {
10
+ start: number;
11
+ rms: number;
12
+ crossings: number;
13
+ }
14
+
15
+ const windows: Window[] = [];
16
+ for (let frame = 0; frame + WINDOW <= samples.length / 2; frame += WINDOW) {
17
+ let sum = 0;
18
+ let crossings = 0;
19
+ for (let k = 0; k < WINDOW; k++) {
20
+ const value = samples[(frame + k) * 2];
21
+ sum += value * value;
22
+ if (k > 0 && samples[(frame + k - 1) * 2] < 0 !== value < 0) crossings++;
23
+ }
24
+ windows.push({ start: frame / RATE, rms: Math.sqrt(sum / WINDOW), crossings });
25
+ }
26
+
27
+ const loud = (w: Window): boolean => w.rms > 0.003;
28
+ // Segments of consecutive loud windows, in order. Device start latency shifts the timeline, so match by sequence.
29
+ const segments: Window[][] = [];
30
+ for (const w of windows) {
31
+ if (!loud(w)) continue;
32
+ const last = segments[segments.length - 1];
33
+ if (last && Math.abs(last[last.length - 1].start + WINDOW / RATE - w.start) < 1e-6) last.push(w);
34
+ else segments.push([w]);
35
+ }
36
+
37
+ const failures: string[] = [];
38
+ const describe = (segment: Window[]): string =>
39
+ `${segment[0].start.toFixed(2)}s-${(segment[segment.length - 1].start + 0.05).toFixed(2)}s`;
40
+ const steadyCrossings = (segment: Window[]): number[] => segment.slice(1, -1).map((w) => w.crossings);
41
+
42
+ if (segments.length !== 3) failures.push(`expected 3 sound segments (hit, tone, music), got ${segments.length}`);
43
+ const [hit, tone, music] = segments;
44
+ if (hit && (hit.length < 4 || hit.length > 7)) failures.push(`hit lasted ${describe(hit)}, expected about 0.25s`);
45
+ if (tone && !steadyCrossings(tone).every((c) => Math.abs(c - 88) <= 2)) {
46
+ failures.push(`tone is not 880 Hz: crossings ${steadyCrossings(tone).join(",")}`);
47
+ }
48
+ if (music && (music.length < 18 || music.length > 22)) failures.push(`music lasted ${describe(music)}, expected about 1s`);
49
+ if (music && Math.abs(music[2].crossings - 26) > 2) failures.push(`music does not start on 262 Hz: ${music[2].crossings}`);
50
+ if (windows.length > 0 && loud(windows[windows.length - 1])) failures.push("mix does not end in silence after stop");
51
+
52
+ for (const segment of segments) console.log(`segment ${describe(segment)} peak rms ${Math.max(...segment.map((w) => w.rms)).toFixed(4)}`);
53
+ if (failures.length > 0) {
54
+ for (const failure of failures) console.error(`FAIL ${failure}`);
55
+ process.exit(1);
56
+ }
57
+ console.log("audio mix OK: hit, 880 Hz tone, 262 Hz music, silence after stop");
@@ -0,0 +1,83 @@
1
+ // Generates what a scriptc library-mode host needs from an app spec and native/ffi.<target>.json:
2
+ // <out>/<name>.profile.json library profile: the app's exports plus every df* function as a callback channel
3
+ // <out>/<name>_glue.c C trampolines (scriptc calls callbacks as fn(ctx, args...)) and a registration function
4
+ // Usage: bun tools/gen-library-glue.ts <app.json> <out-dir> [ffi manifest]
5
+ // app.json: { "name": "smash", "entry": "app.ts" (relative to app.json), "exports": [{ "export": "frame", "params": ["f64"], "returns": "bool" }] }
6
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { dirname, join, resolve } from "node:path";
8
+
9
+ interface ManifestFunction {
10
+ name: string;
11
+ symbol: string;
12
+ params: string[];
13
+ returns: string;
14
+ }
15
+
16
+ interface AppExport {
17
+ export: string;
18
+ params: string[];
19
+ returns: string;
20
+ }
21
+
22
+ interface AppSpec {
23
+ name: string;
24
+ entry: string;
25
+ exports: AppExport[];
26
+ }
27
+
28
+ const [appPath, outDir, manifestPath = join(import.meta.dir, "..", "native", "ffi.macos.json")] = process.argv.slice(2);
29
+ const app = JSON.parse(readFileSync(appPath, "utf8")) as AppSpec;
30
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8")) as { functions: ManifestFunction[] };
31
+
32
+ // Profile callbacks accept scalars and borrowed string/bytes parameters, and return scalars or void.
33
+ const CALLBACK_PARAMS = new Set(["f64", "bool", "u8", "u32", "i32", "string", "bytes"]);
34
+ const CALLBACK_RETURNS = new Set(["f64", "bool", "u8", "u32", "i32", "void"]);
35
+ // The host drives the event loop and shutdown itself, so these stay out of the 32-channel callback budget.
36
+ const HOST_OWNED = new Set(["dfPoll", "dfClose"]);
37
+ const usable = manifest.functions.filter(
38
+ (f) => !HOST_OWNED.has(f.name) && f.params.every((p) => CALLBACK_PARAMS.has(p)) && CALLBACK_RETURNS.has(f.returns),
39
+ );
40
+ const skipped = manifest.functions.filter((f) => !usable.includes(f) && !HOST_OWNED.has(f.name)).map((f) => f.name);
41
+
42
+ const cType: Record<string, string> = { f64: "double", bool: "uint8_t", u8: "uint8_t", u32: "uint32_t", i32: "int32_t", void: "void" };
43
+ const cParams = (params: string[]): string[] =>
44
+ params.flatMap((p, i) => (p === "string" || p === "bytes" ? [`const uint8_t *a${i}`, `size_t a${i}_len`] : [`${cType[p]} a${i}`]));
45
+ const cArgs = (params: string[]): string[] => params.flatMap((p, i) => (p === "string" || p === "bytes" ? [`a${i}`, `a${i}_len`] : [`a${i}`]));
46
+
47
+ const prefix = `${app.name}_`;
48
+ const profile = {
49
+ profile_format: 1,
50
+ name: app.name,
51
+ entry: resolve(dirname(appPath), app.entry),
52
+ emission: "llvm",
53
+ abi: {
54
+ prefix,
55
+ init_symbol: `${prefix}runtime_init`,
56
+ sink_register_symbol: `${prefix}sink`,
57
+ callback_register_symbol: `${prefix}callback`,
58
+ },
59
+ callbacks: usable.map((f) => ({ name: f.name, params: f.params, returns: f.returns })),
60
+ exports: app.exports.map((e) => ({ export: e.export, symbol: `${prefix}${e.export}`, params: e.params, returns: e.returns })),
61
+ };
62
+
63
+ let glue = `// Generated by dotframe tools/gen-library-glue.ts. Connects ${app.name}'s library callbacks to the dotframe native shim.
64
+ #include <stddef.h>
65
+ #include <stdint.h>
66
+
67
+ int32_t ${prefix}callback(const char *name, void *fn, void *ctx);
68
+ `;
69
+ for (const f of usable) glue += `${cType[f.returns]} ${f.symbol}(${cParams(f.params).join(", ") || "void"});\n`;
70
+ glue += "\n";
71
+ for (const f of usable) {
72
+ const params = ["void *ctx", ...cParams(f.params)].join(", ");
73
+ const call = `${f.symbol}(${cArgs(f.params).join(", ")})`;
74
+ glue += `static ${cType[f.returns]} cb_${f.name}(${params}) {\n (void)ctx;\n ${f.returns === "void" ? `${call};` : `return ${call};`}\n}\n\n`;
75
+ }
76
+ glue += `// Registers every callback; returns the number that failed to register (0 on success).\nint32_t ${prefix}register_callbacks(void) {\n int32_t failed = 0;\n`;
77
+ for (const f of usable) glue += ` if (${prefix}callback("${f.name}", (void *)cb_${f.name}, NULL) != 0) failed += 1;\n`;
78
+ glue += " return failed;\n}\n";
79
+
80
+ mkdirSync(outDir, { recursive: true });
81
+ writeFileSync(join(outDir, `${app.name}.profile.json`), `${JSON.stringify(profile, null, 2)}\n`);
82
+ writeFileSync(join(outDir, `${app.name}_glue.c`), glue);
83
+ console.log(`${usable.length} callbacks${skipped.length ? `, skipped (unsupported in library mode): ${skipped.join(", ")}` : ""}`);
@@ -0,0 +1,92 @@
1
+ // Generates assets/crafter.png: a 16x16 two-frame pixel-art character sheet (32x16), original art.
2
+ import { deflateSync } from "node:zlib";
3
+
4
+ const art = [
5
+ [
6
+ "................",
7
+ ".....oooooo.....",
8
+ "....oooooooo....",
9
+ "....ossssssso...",
10
+ "....osskskkso...",
11
+ "....ossssssso...",
12
+ ".....ossssso....",
13
+ "......bbbb......",
14
+ "....bbbbbbbb....",
15
+ "...sbbbbbbbbs...",
16
+ "...sbbbbbbbbs...",
17
+ "....bbbbbbbb....",
18
+ "....ddd..ddd....",
19
+ "....ddd..ddd....",
20
+ "....kkk..kkk....",
21
+ "................",
22
+ ],
23
+ [
24
+ "................",
25
+ ".....oooooo.....",
26
+ "....oooooooo....",
27
+ "....ossssssso...",
28
+ "....osskskkso...",
29
+ "....ossssssso...",
30
+ ".....ossssso....",
31
+ "......bbbb......",
32
+ "..s.bbbbbbbb.s..",
33
+ "...sbbbbbbbbs...",
34
+ "....bbbbbbbb....",
35
+ "....bbbbbbbb....",
36
+ "...ddd....ddd...",
37
+ "..ddd......ddd..",
38
+ "..kkk......kkk..",
39
+ "................",
40
+ ],
41
+ ];
42
+ const palette: Record<string, number[]> = {
43
+ ".": [0, 0, 0, 0],
44
+ o: [249, 115, 22, 255],
45
+ s: [255, 214, 170, 255],
46
+ k: [20, 20, 30, 255],
47
+ b: [59, 130, 246, 255],
48
+ d: [40, 50, 90, 255],
49
+ };
50
+ const width = 32;
51
+ const height = 16;
52
+ const raw = new Uint8Array(height * (1 + width * 4));
53
+ for (let y = 0; y < height; y++) {
54
+ raw[y * (1 + width * 4)] = 0;
55
+ for (let x = 0; x < width; x++) {
56
+ const frame = art[Math.floor(x / 16)];
57
+ const color = palette[frame[y][x % 16]];
58
+ raw.set(color, y * (1 + width * 4) + 1 + x * 4);
59
+ }
60
+ }
61
+ const crcTable = new Uint32Array(256).map((_, n) => {
62
+ let c = n;
63
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
64
+ return c >>> 0;
65
+ });
66
+ const crc = (bytes: Uint8Array): number => {
67
+ let c = 0xffffffff;
68
+ for (const b of bytes) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
69
+ return (c ^ 0xffffffff) >>> 0;
70
+ };
71
+ const chunk = (type: string, data: Uint8Array): Uint8Array => {
72
+ const out = new Uint8Array(12 + data.length);
73
+ const view = new DataView(out.buffer);
74
+ view.setUint32(0, data.length);
75
+ out.set(new TextEncoder().encode(type), 4);
76
+ out.set(data, 8);
77
+ view.setUint32(8 + data.length, crc(out.subarray(4, 8 + data.length)));
78
+ return out;
79
+ };
80
+ const ihdr = new Uint8Array(13);
81
+ const ihdrView = new DataView(ihdr.buffer);
82
+ ihdrView.setUint32(0, width);
83
+ ihdrView.setUint32(4, height);
84
+ ihdr.set([8, 6, 0, 0, 0], 8);
85
+ const png = new Uint8Array([
86
+ ...[137, 80, 78, 71, 13, 10, 26, 10],
87
+ ...chunk("IHDR", ihdr),
88
+ ...chunk("IDAT", deflateSync(raw)),
89
+ ...chunk("IEND", new Uint8Array(0)),
90
+ ]);
91
+ await Bun.write(new URL("../assets/crafter.png", import.meta.url), png);
92
+ console.log(`assets/crafter.png ${png.length} bytes`);