Monorepo for Aesthetic.Computer
aesthetic.computer
1#define _GNU_SOURCE
2#include "js-bindings.h"
3#include "usb-midi.h"
4#include "recorder.h"
5#include <pthread.h>
6#include <stdio.h>
7#include <stdlib.h>
8#include <string.h>
9#include <time.h>
10#include <math.h>
11#include <dirent.h>
12#include <unistd.h>
13#include <sys/stat.h>
14#include <sys/statvfs.h>
15#include <sys/wait.h>
16#include <sys/mount.h>
17#include <sys/reboot.h>
18#include <linux/reboot.h>
19#include <fcntl.h>
20#include <errno.h>
21#include "qrcodegen.h"
22#include <alsa/asoundlib.h>
23
24// Defined in ac-native.c — logs to USB mount
25extern void ac_log(const char *fmt, ...);
26extern void ac_log_flush(void);
27extern void ac_log_pause(void);
28extern void ac_log_resume(void);
29extern void perf_flush(void);
30
31// Thread-local reference to current runtime (for C callbacks from JS)
32static __thread ACRuntime *current_rt = NULL;
33static __thread const char *current_phase = "boot";
34static int config_cache_dirty = 1; // reload /mnt/config.json when set
35
36// Forward declaration (defined later in this file)
37static int resolve_color_name(const char *name, ACColor *out);
38static JSValue js_usb_midi_note_on(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv);
39static JSValue js_usb_midi_note_off(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv);
40static JSValue js_usb_midi_all_notes_off(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv);
41
42// ============================================================
43// QuickJS Class IDs for 3D objects
44// ============================================================
45static JSClassID form_class_id = 0;
46static JSClassID painting_class_id = 0;
47
48// Form class finalizer
49static void js_form_finalizer(JSRuntime *rt, JSValue val) {
50 (void)rt;
51 ACForm *form = JS_GetOpaque(val, form_class_id);
52 if (form) {
53 free(form->positions);
54 free(form->colors);
55 free(form->tex_coords);
56 free(form);
57 }
58}
59
60static JSClassDef form_class_def = {
61 "Form",
62 .finalizer = js_form_finalizer,
63};
64
65// Painting class finalizer
66static void js_painting_finalizer(JSRuntime *rt, JSValue val) {
67 (void)rt;
68 ACFramebuffer *fb = JS_GetOpaque(val, painting_class_id);
69 if (!fb) return;
70 // Don't destroy runtime-owned nopaint buffers
71 if (current_rt && (fb == current_rt->nopaint_painting || fb == current_rt->nopaint_buffer))
72 return;
73 fb_destroy(fb);
74}
75
76static JSClassDef painting_class_def = {
77 "Painting",
78 .finalizer = js_painting_finalizer,
79};
80
81// ============================================================
82// Form constructor: new Form({type, positions, colors, texCoords}, {pos, rot, scale})
83// ============================================================
84
85static JSValue js_form_constructor(JSContext *ctx, JSValueConst new_target,
86 int argc, JSValueConst *argv) {
87 (void)new_target;
88 if (argc < 1) return JS_EXCEPTION;
89
90 ACForm *form = calloc(1, sizeof(ACForm));
91 if (!form) return JS_EXCEPTION;
92 form->scale = 1.0f;
93
94 // Parse geometry descriptor (first argument)
95 JSValue geom = argv[0];
96 JSValue type_val = JS_GetPropertyStr(ctx, geom, "type");
97 if (!JS_IsUndefined(type_val)) {
98 const char *type_str = JS_ToCString(ctx, type_val);
99 if (type_str && strcmp(type_str, "line") == 0)
100 form->type = FORM_TYPE_LINE;
101 else
102 form->type = FORM_TYPE_TRIANGLE;
103 if (type_str) JS_FreeCString(ctx, type_str);
104 }
105 JS_FreeValue(ctx, type_val);
106
107 // Parse positions array: [[x,y,z,w], ...]
108 JSValue pos_arr = JS_GetPropertyStr(ctx, geom, "positions");
109 if (JS_IsArray(ctx, pos_arr)) {
110 JSValue len_val = JS_GetPropertyStr(ctx, pos_arr, "length");
111 int32_t len = 0;
112 JS_ToInt32(ctx, &len, len_val);
113 JS_FreeValue(ctx, len_val);
114
115 form->vert_count = len;
116 form->positions = calloc(len * 4, sizeof(float));
117
118 for (int i = 0; i < len; i++) {
119 JSValue vert = JS_GetPropertyUint32(ctx, pos_arr, i);
120 for (int j = 0; j < 4; j++) {
121 JSValue comp = JS_GetPropertyUint32(ctx, vert, j);
122 double v = 0;
123 JS_ToFloat64(ctx, &v, comp);
124 form->positions[i * 4 + j] = (float)v;
125 JS_FreeValue(ctx, comp);
126 }
127 JS_FreeValue(ctx, vert);
128 }
129 }
130 JS_FreeValue(ctx, pos_arr);
131
132 // Parse colors array: [[r,g,b,a], ...]
133 JSValue col_arr = JS_GetPropertyStr(ctx, geom, "colors");
134 if (JS_IsArray(ctx, col_arr)) {
135 JSValue len_val = JS_GetPropertyStr(ctx, col_arr, "length");
136 int32_t len = 0;
137 JS_ToInt32(ctx, &len, len_val);
138 JS_FreeValue(ctx, len_val);
139
140 form->colors = calloc(len * 4, sizeof(float));
141 form->has_colors = 1;
142
143 for (int i = 0; i < len; i++) {
144 JSValue col = JS_GetPropertyUint32(ctx, col_arr, i);
145 for (int j = 0; j < 4; j++) {
146 JSValue comp = JS_GetPropertyUint32(ctx, col, j);
147 double v = 0;
148 JS_ToFloat64(ctx, &v, comp);
149 form->colors[i * 4 + j] = (float)v;
150 JS_FreeValue(ctx, comp);
151 }
152 JS_FreeValue(ctx, col);
153 }
154 }
155 JS_FreeValue(ctx, col_arr);
156
157 // Parse texCoords array: [[u,v], ...]
158 JSValue tc_arr = JS_GetPropertyStr(ctx, geom, "texCoords");
159 if (JS_IsArray(ctx, tc_arr)) {
160 JSValue len_val = JS_GetPropertyStr(ctx, tc_arr, "length");
161 int32_t len = 0;
162 JS_ToInt32(ctx, &len, len_val);
163 JS_FreeValue(ctx, len_val);
164
165 form->tex_coords = calloc(len * 2, sizeof(float));
166 for (int i = 0; i < len; i++) {
167 JSValue uv = JS_GetPropertyUint32(ctx, tc_arr, i);
168 for (int j = 0; j < 2; j++) {
169 JSValue comp = JS_GetPropertyUint32(ctx, uv, j);
170 double v = 0;
171 JS_ToFloat64(ctx, &v, comp);
172 form->tex_coords[i * 2 + j] = (float)v;
173 JS_FreeValue(ctx, comp);
174 }
175 JS_FreeValue(ctx, uv);
176 }
177 }
178 JS_FreeValue(ctx, tc_arr);
179
180 // Parse transform (second argument): {pos, rot, scale}
181 if (argc >= 2 && JS_IsObject(argv[1])) {
182 JSValue opts = argv[1];
183
184 JSValue pos = JS_GetPropertyStr(ctx, opts, "pos");
185 if (JS_IsArray(ctx, pos)) {
186 for (int i = 0; i < 3; i++) {
187 JSValue v = JS_GetPropertyUint32(ctx, pos, i);
188 double d = 0; JS_ToFloat64(ctx, &d, v);
189 form->position[i] = (float)d;
190 JS_FreeValue(ctx, v);
191 }
192 }
193 JS_FreeValue(ctx, pos);
194
195 JSValue rot = JS_GetPropertyStr(ctx, opts, "rot");
196 if (JS_IsArray(ctx, rot)) {
197 for (int i = 0; i < 3; i++) {
198 JSValue v = JS_GetPropertyUint32(ctx, rot, i);
199 double d = 0; JS_ToFloat64(ctx, &d, v);
200 form->rotation[i] = (float)d;
201 JS_FreeValue(ctx, v);
202 }
203 }
204 JS_FreeValue(ctx, rot);
205
206 JSValue sc = JS_GetPropertyStr(ctx, opts, "scale");
207 if (!JS_IsUndefined(sc)) {
208 double d = 1.0; JS_ToFloat64(ctx, &d, sc);
209 form->scale = (float)d;
210 }
211 JS_FreeValue(ctx, sc);
212 }
213
214 // Create JS object with opaque pointer
215 JSValue obj = JS_NewObjectClass(ctx, form_class_id);
216 JS_SetOpaque(obj, form);
217
218 // Expose position and rotation as JS arrays (live references)
219 JSValue js_pos = JS_NewArray(ctx);
220 for (int i = 0; i < 3; i++)
221 JS_SetPropertyUint32(ctx, js_pos, i, JS_NewFloat64(ctx, form->position[i]));
222 JS_SetPropertyStr(ctx, obj, "position", js_pos);
223
224 JSValue js_rot = JS_NewArray(ctx);
225 for (int i = 0; i < 3; i++)
226 JS_SetPropertyUint32(ctx, js_rot, i, JS_NewFloat64(ctx, form->rotation[i]));
227 JS_SetPropertyStr(ctx, obj, "rotation", js_rot);
228
229 JS_SetPropertyStr(ctx, obj, "noFade", JS_FALSE);
230
231 return obj;
232}
233
234// ============================================================
235// Chain API: .form(f) renders a 3D form with current ink + camera
236// ============================================================
237
238static JSValue js_chain_form(JSContext *ctx, JSValueConst this_val,
239 int argc, JSValueConst *argv) {
240 if (argc < 1) return JS_DupValue(ctx, this_val);
241
242 ACForm *form = JS_GetOpaque(argv[0], form_class_id);
243 if (!form) return JS_DupValue(ctx, this_val);
244
245 ACRuntime *rt = current_rt;
246 if (!rt) return JS_DupValue(ctx, this_val);
247
248 // Sync position/rotation from JS arrays back to C struct
249 JSValue js_pos = JS_GetPropertyStr(ctx, argv[0], "position");
250 if (JS_IsArray(ctx, js_pos)) {
251 for (int i = 0; i < 3; i++) {
252 JSValue v = JS_GetPropertyUint32(ctx, js_pos, i);
253 double d = 0; JS_ToFloat64(ctx, &d, v);
254 form->position[i] = (float)d;
255 JS_FreeValue(ctx, v);
256 }
257 }
258 JS_FreeValue(ctx, js_pos);
259
260 JSValue js_rot = JS_GetPropertyStr(ctx, argv[0], "rotation");
261 if (JS_IsArray(ctx, js_rot)) {
262 for (int i = 0; i < 3; i++) {
263 JSValue v = JS_GetPropertyUint32(ctx, js_rot, i);
264 double d = 0; JS_ToFloat64(ctx, &d, v);
265 form->rotation[i] = (float)d;
266 JS_FreeValue(ctx, v);
267 }
268 }
269 JS_FreeValue(ctx, js_rot);
270
271 // Sync noFade
272 JSValue nf = JS_GetPropertyStr(ctx, argv[0], "noFade");
273 form->no_fade = JS_ToBool(ctx, nf);
274 JS_FreeValue(ctx, nf);
275
276 // Sync texture (painting with opaque ACFramebuffer*)
277 JSValue tex = JS_GetPropertyStr(ctx, argv[0], "texture");
278 if (!JS_IsUndefined(tex) && !JS_IsNull(tex)) {
279 ACFramebuffer *tex_fb = JS_GetOpaque(tex, painting_class_id);
280 form->texture = tex_fb;
281 } else {
282 form->texture = NULL;
283 }
284 JS_FreeValue(ctx, tex);
285
286 // Ensure depth buffer exists
287 if (!rt->depth_buf) {
288 rt->depth_buf = depth_create(rt->graph->fb->width, rt->graph->fb->height,
289 rt->graph->fb->stride);
290 }
291
292 // Build view and projection matrices
293 mat4 view, proj;
294 camera3d_view_matrix(view, &rt->camera3d);
295 float aspect = (float)rt->graph->fb->width / (float)rt->graph->fb->height;
296 mat4_perspective(proj, 70.0f * (float)M_PI / 180.0f, aspect, 0.01f, 100.0f);
297
298 // Render
299 graph3d_render_form(rt->graph->fb, rt->depth_buf, form,
300 view, proj, rt->graph->ink, &rt->render_stats);
301
302 return JS_DupValue(ctx, this_val);
303}
304
305// Chain .ink() — sets ink color, returns chain for further chaining
306static JSValue js_chain_ink(JSContext *ctx, JSValueConst this_val,
307 int argc, JSValueConst *argv) {
308 ACGraph *g = current_rt->graph;
309 ACColor c = {255, 255, 255, 255};
310 if (argc >= 3) {
311 int r, gr, b;
312 JS_ToInt32(ctx, &r, argv[0]);
313 JS_ToInt32(ctx, &gr, argv[1]);
314 JS_ToInt32(ctx, &b, argv[2]);
315 c.r = (uint8_t)r; c.g = (uint8_t)gr; c.b = (uint8_t)b;
316 if (argc >= 4) { int a; JS_ToInt32(ctx, &a, argv[3]); c.a = (uint8_t)a; }
317 } else if (argc == 1) {
318 if (JS_IsString(argv[0])) {
319 const char *name = JS_ToCString(ctx, argv[0]);
320 if (!resolve_color_name(name, &c))
321 c = (ACColor){255, 255, 255, 255};
322 JS_FreeCString(ctx, name);
323 } else {
324 int v; if (JS_ToInt32(ctx, &v, argv[0]) == 0)
325 c = (ACColor){(uint8_t)v, (uint8_t)v, (uint8_t)v, 255};
326 }
327 }
328 graph_ink(g, c);
329 return JS_DupValue(ctx, this_val);
330}
331
332// Chain .write() — forward to existing write, return chain
333static JSValue js_write(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv);
334static JSValue js_chain_write(JSContext *ctx, JSValueConst this_val,
335 int argc, JSValueConst *argv) {
336 js_write(ctx, JS_UNDEFINED, argc, argv);
337 return JS_DupValue(ctx, this_val);
338}
339
340// ============================================================
341// penLock() — enables FPS camera mode
342// ============================================================
343
344static JSValue js_pen_lock(JSContext *ctx, JSValueConst this_val,
345 int argc, JSValueConst *argv) {
346 (void)this_val; (void)argc; (void)argv;
347 if (current_rt) {
348 current_rt->pen_locked = 1;
349 current_rt->fps_system_active = 1;
350 camera3d_init(¤t_rt->camera3d);
351 }
352 return JS_UNDEFINED;
353}
354
355// ============================================================
356// CUBEL geometry constant (wireframe cube, 12 lines = 24 vertices)
357// ============================================================
358
359static JSValue build_cubel(JSContext *ctx) {
360 // 12 edges of a unit cube from -1 to 1
361 static const float cube_lines[24][4] = {
362 // Bottom face
363 {-1,-1,-1,1}, { 1,-1,-1,1},
364 { 1,-1,-1,1}, { 1,-1, 1,1},
365 { 1,-1, 1,1}, {-1,-1, 1,1},
366 {-1,-1, 1,1}, {-1,-1,-1,1},
367 // Top face
368 {-1, 1,-1,1}, { 1, 1,-1,1},
369 { 1, 1,-1,1}, { 1, 1, 1,1},
370 { 1, 1, 1,1}, {-1, 1, 1,1},
371 {-1, 1, 1,1}, {-1, 1,-1,1},
372 // Vertical edges
373 {-1,-1,-1,1}, {-1, 1,-1,1},
374 { 1,-1,-1,1}, { 1, 1,-1,1},
375 { 1,-1, 1,1}, { 1, 1, 1,1},
376 {-1,-1, 1,1}, {-1, 1, 1,1},
377 };
378
379 JSValue geom = JS_NewObject(ctx);
380 JS_SetPropertyStr(ctx, geom, "type", JS_NewString(ctx, "line"));
381
382 JSValue positions = JS_NewArray(ctx);
383 for (int i = 0; i < 24; i++) {
384 JSValue vert = JS_NewArray(ctx);
385 for (int j = 0; j < 4; j++)
386 JS_SetPropertyUint32(ctx, vert, j, JS_NewFloat64(ctx, cube_lines[i][j]));
387 JS_SetPropertyUint32(ctx, positions, i, vert);
388 }
389 JS_SetPropertyStr(ctx, geom, "positions", positions);
390
391 return geom;
392}
393
394// QUAD geometry constant (2 triangles = 6 vertices with gradient colors)
395static JSValue build_quad(JSContext *ctx) {
396 static const float quad_pos[6][4] = {
397 {-1,-1,0,1}, {-1,1,0,1}, {1,1,0,1}, // tri 1
398 {-1,-1,0,1}, {1,1,0,1}, {1,-1,0,1}, // tri 2
399 };
400 static const float quad_col[6][4] = {
401 {1,0,0,1}, {0,1,0,1}, {0,0,1,1},
402 {1,0,0,1}, {0,0,1,1}, {1,1,0,1},
403 };
404
405 JSValue geom = JS_NewObject(ctx);
406 JS_SetPropertyStr(ctx, geom, "type", JS_NewString(ctx, "triangle"));
407
408 JSValue positions = JS_NewArray(ctx);
409 JSValue colors = JS_NewArray(ctx);
410 for (int i = 0; i < 6; i++) {
411 JSValue vert = JS_NewArray(ctx);
412 JSValue col = JS_NewArray(ctx);
413 for (int j = 0; j < 4; j++) {
414 JS_SetPropertyUint32(ctx, vert, j, JS_NewFloat64(ctx, quad_pos[i][j]));
415 JS_SetPropertyUint32(ctx, col, j, JS_NewFloat64(ctx, quad_col[i][j]));
416 }
417 JS_SetPropertyUint32(ctx, positions, i, vert);
418 JS_SetPropertyUint32(ctx, colors, i, col);
419 }
420 JS_SetPropertyStr(ctx, geom, "positions", positions);
421 JS_SetPropertyStr(ctx, geom, "colors", colors);
422
423 return geom;
424}
425
426// ============================================================
427// JS Native Functions — Graphics
428// ============================================================
429
430// Resolve a CSS color name string to an ACColor. Returns 1 on match, 0 if unknown.
431static int resolve_color_name(const char *name, ACColor *out) {
432 if (!name) return 0;
433 if (strcmp(name, "black") == 0) { *out = (ACColor){0, 0, 0, 255}; return 1; }
434 if (strcmp(name, "white") == 0) { *out = (ACColor){255, 255, 255, 255}; return 1; }
435 if (strcmp(name, "red") == 0) { *out = (ACColor){255, 0, 0, 255}; return 1; }
436 if (strcmp(name, "green") == 0) { *out = (ACColor){0, 128, 0, 255}; return 1; }
437 if (strcmp(name, "blue") == 0) { *out = (ACColor){0, 0, 255, 255}; return 1; }
438 if (strcmp(name, "yellow") == 0) { *out = (ACColor){255, 255, 0, 255}; return 1; }
439 if (strcmp(name, "cyan") == 0) { *out = (ACColor){0, 255, 255, 255}; return 1; }
440 if (strcmp(name, "magenta") == 0) { *out = (ACColor){255, 0, 255, 255}; return 1; }
441 if (strcmp(name, "gray") == 0) { *out = (ACColor){128, 128, 128, 255}; return 1; }
442 if (strcmp(name, "grey") == 0) { *out = (ACColor){128, 128, 128, 255}; return 1; }
443 if (strcmp(name, "orange") == 0) { *out = (ACColor){255, 165, 0, 255}; return 1; }
444 if (strcmp(name, "purple") == 0) { *out = (ACColor){128, 0, 128, 255}; return 1; }
445 if (strcmp(name, "pink") == 0) { *out = (ACColor){255, 192, 203, 255}; return 1; }
446 if (strcmp(name, "lime") == 0) { *out = (ACColor){0, 255, 0, 255}; return 1; }
447 if (strcmp(name, "brown") == 0) { *out = (ACColor){139, 69, 19, 255}; return 1; }
448 return 0;
449}
450
451static JSValue js_wipe(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
452 (void)this_val;
453 ACGraph *g = current_rt->graph;
454 ACColor c = {0, 0, 0, 255};
455 if (argc == 0) {
456 c = (ACColor){255, 255, 255, 255};
457 } else if (argc >= 3) {
458 int r, gr, b;
459 JS_ToInt32(ctx, &r, argv[0]);
460 JS_ToInt32(ctx, &gr, argv[1]);
461 JS_ToInt32(ctx, &b, argv[2]);
462 c = (ACColor){(uint8_t)r, (uint8_t)gr, (uint8_t)b, 255};
463 } else if (argc == 1) {
464 if (JS_IsString(argv[0])) {
465 const char *name = JS_ToCString(ctx, argv[0]);
466 if (!resolve_color_name(name, &c))
467 c = (ACColor){0, 0, 0, 255}; // unknown name → black
468 JS_FreeCString(ctx, name);
469 } else {
470 int v;
471 if (JS_ToInt32(ctx, &v, argv[0]) == 0) {
472 c = (ACColor){(uint8_t)v, (uint8_t)v, (uint8_t)v, 255};
473 }
474 }
475 }
476 graph_wipe(g, c);
477
478 // Clear depth buffer for new frame
479 if (current_rt && current_rt->depth_buf)
480 depth_clear(current_rt->depth_buf);
481 // Reset render stats
482 if (current_rt)
483 memset(¤t_rt->render_stats, 0, sizeof(ACRenderStats));
484
485 // Return chain object for .form()/.ink() chaining
486 JSValue global = JS_GetGlobalObject(ctx);
487 JSValue chain = JS_GetPropertyStr(ctx, global, "__paintApi");
488 JS_FreeValue(ctx, global);
489 return chain;
490}
491
492static JSValue js_ink(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
493 (void)this_val;
494 ACGraph *g = current_rt->graph;
495 ACColor c = {255, 255, 255, 255};
496 if (argc >= 3) {
497 int r, gr, b;
498 JS_ToInt32(ctx, &r, argv[0]);
499 JS_ToInt32(ctx, &gr, argv[1]);
500 JS_ToInt32(ctx, &b, argv[2]);
501 c.r = (uint8_t)r; c.g = (uint8_t)gr; c.b = (uint8_t)b;
502 if (argc >= 4) {
503 int a; JS_ToInt32(ctx, &a, argv[3]);
504 c.a = (uint8_t)a;
505 }
506 } else if (argc == 1) {
507 if (JS_IsString(argv[0])) {
508 const char *name = JS_ToCString(ctx, argv[0]);
509 if (!resolve_color_name(name, &c))
510 c = (ACColor){255, 255, 255, 255}; // unknown name → white
511 JS_FreeCString(ctx, name);
512 } else {
513 int v;
514 if (JS_ToInt32(ctx, &v, argv[0]) == 0) {
515 c = (ACColor){(uint8_t)v, (uint8_t)v, (uint8_t)v, 255};
516 }
517 }
518 }
519 graph_ink(g, c);
520
521 JSValue global = JS_GetGlobalObject(ctx);
522 JSValue paint_api = JS_GetPropertyStr(ctx, global, "__paintApi");
523 JS_FreeValue(ctx, global);
524 return paint_api;
525}
526
527static JSValue js_plot(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
528 (void)this_val;
529 if (argc < 2) return JS_UNDEFINED;
530 int x, y;
531 JS_ToInt32(ctx, &x, argv[0]);
532 JS_ToInt32(ctx, &y, argv[1]);
533 graph_plot(current_rt->graph, x, y);
534 return JS_UNDEFINED;
535}
536
537static JSValue js_line(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
538 (void)this_val;
539 if (argc < 4) return JS_UNDEFINED;
540 int x0, y0, x1, y1;
541 JS_ToInt32(ctx, &x0, argv[0]);
542 JS_ToInt32(ctx, &y0, argv[1]);
543 JS_ToInt32(ctx, &x1, argv[2]);
544 JS_ToInt32(ctx, &y1, argv[3]);
545 if (argc >= 5) {
546 int thickness;
547 JS_ToInt32(ctx, &thickness, argv[4]);
548 graph_line_thick(current_rt->graph, x0, y0, x1, y1, thickness);
549 } else {
550 graph_line(current_rt->graph, x0, y0, x1, y1);
551 }
552 return JS_UNDEFINED;
553}
554
555static JSValue js_box(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
556 (void)this_val;
557 if (argc < 4) return JS_UNDEFINED;
558 int x, y, w, h;
559 JS_ToInt32(ctx, &x, argv[0]);
560 JS_ToInt32(ctx, &y, argv[1]);
561 JS_ToInt32(ctx, &w, argv[2]);
562 JS_ToInt32(ctx, &h, argv[3]);
563
564 int filled = 1;
565 if (argc >= 5 && JS_IsString(argv[4])) {
566 const char *mode = JS_ToCString(ctx, argv[4]);
567 if (mode && strcmp(mode, "outline") == 0) filled = 0;
568 JS_FreeCString(ctx, mode);
569 }
570 graph_box(current_rt->graph, x, y, w, h, filled);
571
572 JSValue global = JS_GetGlobalObject(ctx);
573 JSValue paint_api = JS_GetPropertyStr(ctx, global, "__paintApi");
574 JS_FreeValue(ctx, global);
575 return paint_api;
576}
577
578static JSValue js_circle(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
579 (void)this_val;
580 if (argc < 3) return JS_UNDEFINED;
581 int cx, cy, r;
582 JS_ToInt32(ctx, &cx, argv[0]);
583 JS_ToInt32(ctx, &cy, argv[1]);
584 JS_ToInt32(ctx, &r, argv[2]);
585 int filled = (argc >= 4) ? JS_ToBool(ctx, argv[3]) : 0;
586 graph_circle(current_rt->graph, cx, cy, r, filled);
587 return JS_UNDEFINED;
588}
589
590static JSValue js_write(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
591 (void)this_val;
592 if (argc < 1) return JS_UNDEFINED;
593 const char *text = JS_ToCString(ctx, argv[0]);
594 if (!text) return JS_UNDEFINED;
595
596 int x = 0, y = 0, scale = 1;
597
598 if (argc >= 2) {
599 if (JS_IsObject(argv[1])) {
600 JSValue vx = JS_GetPropertyStr(ctx, argv[1], "x");
601 JSValue vy = JS_GetPropertyStr(ctx, argv[1], "y");
602 if (!JS_IsUndefined(vx)) JS_ToInt32(ctx, &x, vx);
603 if (!JS_IsUndefined(vy)) JS_ToInt32(ctx, &y, vy);
604
605 JSValue center = JS_GetPropertyStr(ctx, argv[1], "center");
606 if (JS_IsString(center)) {
607 const char *cv = JS_ToCString(ctx, center);
608 if (cv) {
609 int tw = font_measure(text, scale);
610 if (strchr(cv, 'x')) x = (current_rt->graph->fb->width - tw) / 2;
611 if (strchr(cv, 'y')) y = (current_rt->graph->fb->height - FONT_CHAR_H * scale) / 2;
612 JS_FreeCString(ctx, cv);
613 }
614 }
615 JS_FreeValue(ctx, center);
616
617 JSValue size = JS_GetPropertyStr(ctx, argv[1], "size");
618 if (!JS_IsUndefined(size)) JS_ToInt32(ctx, &scale, size);
619 JS_FreeValue(ctx, size);
620
621 JS_FreeValue(ctx, vx);
622 JS_FreeValue(ctx, vy);
623 }
624 }
625
626 // Check for font option: "matrix", "font_1"/"6x10", default is font_1 (6x10)
627 int font_id = FONT_6X10;
628 if (argc >= 2 && JS_IsObject(argv[1])) {
629 JSValue font_v = JS_GetPropertyStr(ctx, argv[1], "font");
630 if (JS_IsString(font_v)) {
631 const char *fname = JS_ToCString(ctx, font_v);
632 if (fname) {
633 if (strcmp(fname, "matrix") == 0) font_id = FONT_MATRIX;
634 else if (strcmp(fname, "font_1") == 0 || strcmp(fname, "6x10") == 0)
635 font_id = FONT_6X10;
636 JS_FreeCString(ctx, fname);
637 }
638 }
639 JS_FreeValue(ctx, font_v);
640
641 // Re-check center with correct font metrics for non-8x8 fonts
642 if (font_id != FONT_8X8) {
643 JSValue center2 = JS_GetPropertyStr(ctx, argv[1], "center");
644 if (JS_IsString(center2)) {
645 const char *cv = JS_ToCString(ctx, center2);
646 if (cv) {
647 int tw = (font_id == FONT_MATRIX)
648 ? font_measure_matrix(text, scale)
649 : font_measure_6x10(text, scale);
650 int th = (font_id == FONT_MATRIX)
651 ? 8 * scale // MatrixChunky8 ascent
652 : FONT_6X10_CHAR_H * scale;
653 if (strchr(cv, 'x')) x = (current_rt->graph->fb->width - tw) / 2;
654 if (strchr(cv, 'y')) y = (current_rt->graph->fb->height - th) / 2;
655 JS_FreeCString(ctx, cv);
656 }
657 }
658 JS_FreeValue(ctx, center2);
659 }
660 }
661
662 if (font_id == FONT_MATRIX)
663 font_draw_matrix(current_rt->graph, text, x, y, scale);
664 else if (font_id == FONT_6X10)
665 font_draw_6x10(current_rt->graph, text, x, y, scale);
666 else
667 font_draw(current_rt->graph, text, x, y, scale);
668 JS_FreeCString(ctx, text);
669 return JS_UNDEFINED;
670}
671
672static JSValue js_scroll(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
673 (void)this_val;
674 int dx = 0, dy = 0;
675 if (argc >= 1) JS_ToInt32(ctx, &dx, argv[0]);
676 if (argc >= 2) JS_ToInt32(ctx, &dy, argv[1]);
677 graph_scroll(current_rt->graph, dx, dy);
678 return JS_UNDEFINED;
679}
680
681static JSValue js_blur(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
682 (void)this_val;
683 int s = 1;
684 if (argc >= 1) JS_ToInt32(ctx, &s, argv[0]);
685 graph_blur(current_rt->graph, s);
686 return JS_UNDEFINED;
687}
688
689static JSValue js_zoom(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
690 (void)this_val;
691 double level = 1.0;
692 if (argc >= 1) JS_ToFloat64(ctx, &level, argv[0]);
693 graph_zoom(current_rt->graph, level);
694 return JS_UNDEFINED;
695}
696
697static JSValue js_contrast(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
698 (void)this_val;
699 double level = 1.0;
700 if (argc >= 1) JS_ToFloat64(ctx, &level, argv[0]);
701 graph_contrast(current_rt->graph, level);
702 return JS_UNDEFINED;
703}
704
705static JSValue js_spin(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
706 (void)this_val;
707 double angle = 0.0;
708 if (argc >= 1) JS_ToFloat64(ctx, &angle, argv[0]);
709 graph_spin(current_rt->graph, angle);
710 return JS_UNDEFINED;
711}
712
713// qr(text, x, y, scale) — render QR code at position
714static JSValue js_qr(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
715 (void)this_val;
716 if (!current_rt || argc < 3) return JS_UNDEFINED;
717 const char *text = JS_ToCString(ctx, argv[0]);
718 if (!text) return JS_UNDEFINED;
719 int x = 0, y = 0, scale = 2;
720 JS_ToInt32(ctx, &x, argv[1]);
721 JS_ToInt32(ctx, &y, argv[2]);
722 if (argc >= 4) JS_ToInt32(ctx, &scale, argv[3]);
723 graph_qr(current_rt->graph, text, x, y, scale);
724 JS_FreeCString(ctx, text);
725 return JS_UNDEFINED;
726}
727
728// ============================================================
729// JS Native Functions — System
730// ============================================================
731
732static JSValue js_console_log(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
733 (void)this_val;
734 // Build message string, then log via ac_log (writes to both stderr + log file)
735 char buf[512];
736 int pos = 0;
737 for (int i = 0; i < argc && pos < (int)sizeof(buf) - 2; i++) {
738 const char *s = JS_ToCString(ctx, argv[i]);
739 if (s) {
740 int n = snprintf(buf + pos, sizeof(buf) - pos, "%s%s", i ? " " : "", s);
741 if (n > 0) pos += n;
742 JS_FreeCString(ctx, s);
743 }
744 }
745 buf[pos] = '\0';
746 ac_log("[js] %s\n", buf);
747 return JS_UNDEFINED;
748}
749
750static JSValue js_performance_now(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
751 (void)this_val; (void)argc; (void)argv;
752 struct timespec ts;
753 clock_gettime(CLOCK_MONOTONIC, &ts);
754 double ms = ts.tv_sec * 1000.0 + ts.tv_nsec / 1000000.0;
755 return JS_NewFloat64(ctx, ms);
756}
757
758// No-op function for stubs
759static JSValue js_noop(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
760 (void)ctx; (void)this_val; (void)argc; (void)argv;
761 return JS_UNDEFINED;
762}
763
764// clock.time() — returns a JS Date object with the current system time
765static JSValue js_clock_time(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
766 (void)this_val; (void)argc; (void)argv;
767 struct timespec ts;
768 clock_gettime(CLOCK_REALTIME, &ts);
769 double ms = (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1000000.0;
770 JSValue global = JS_GetGlobalObject(ctx);
771 JSValue date_ctor = JS_GetPropertyStr(ctx, global, "Date");
772 JSValue ms_val = JS_NewFloat64(ctx, ms);
773 JSValue date = JS_CallConstructor(ctx, date_ctor, 1, &ms_val);
774 JS_FreeValue(ctx, ms_val);
775 JS_FreeValue(ctx, date_ctor);
776 JS_FreeValue(ctx, global);
777 return date;
778}
779
780// Returns a resolved promise with null
781static JSValue js_promise_null(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
782 (void)this_val; (void)argc; (void)argv;
783 // Use Promise.resolve(null)
784 JSValue global = JS_GetGlobalObject(ctx);
785 JSValue promise = JS_GetPropertyStr(ctx, global, "Promise");
786 JSValue resolve = JS_GetPropertyStr(ctx, promise, "resolve");
787 JSValue null_val = JS_NULL;
788 JSValue result = JS_Call(ctx, resolve, promise, 1, &null_val);
789 JS_FreeValue(ctx, resolve);
790 JS_FreeValue(ctx, promise);
791 JS_FreeValue(ctx, global);
792 return result;
793}
794
795// Returns a function that returns a no-op object with send()
796static JSValue js_net_udp(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
797 (void)this_val; (void)argc; (void)argv;
798 JSValue obj = JS_NewObject(ctx);
799 JS_SetPropertyStr(ctx, obj, "send", JS_NewCFunction(ctx, js_noop, "send", 2));
800 return obj;
801}
802
803// ============================================================
804// Sound Bindings
805// ============================================================
806
807static WaveType parse_wave_type(const char *type) {
808 if (!type) return WAVE_SINE;
809 if (strcmp(type, "sine") == 0) return WAVE_SINE;
810 if (strcmp(type, "triangle") == 0) return WAVE_TRIANGLE;
811 if (strcmp(type, "sawtooth") == 0) return WAVE_SAWTOOTH;
812 if (strcmp(type, "square") == 0) return WAVE_SQUARE;
813 if (strcmp(type, "noise-white") == 0 || strcmp(type, "noise") == 0) return WAVE_NOISE;
814 if (strcmp(type, "whistle") == 0 || strcmp(type, "ocarina") == 0 ||
815 strcmp(type, "flute") == 0 || strcmp(type, "skullwhistle") == 0 ||
816 strcmp(type, "skull-whistle") == 0) return WAVE_WHISTLE;
817 if (strncmp(type, "gun", 3) == 0) return WAVE_GUN;
818 // composite → treat as sine for now
819 return WAVE_SINE;
820}
821
822// Parse the preset suffix for a gun-* wave type string. Optional model
823// suffix `/classic` or `/physical` overrides the preset's default model.
824// Examples: "gun-pistol", "gun-pistol/classic", "gun-pistol/physical".
825// Returns GUN_PISTOL on parse failure. Pass `out_force_model` to receive
826// the override (-1 = use preset default, 0 = CLASSIC, 1 = PHYSICAL).
827static GunPreset parse_gun_preset(const char *type, int *out_force_model) {
828 if (out_force_model) *out_force_model = -1;
829 if (!type) return GUN_PISTOL;
830 if (strncmp(type, "gun", 3) != 0) return GUN_PISTOL;
831 const char *p = type + 3;
832 if (*p == '-' || *p == '_') p++;
833 if (!*p) return GUN_PISTOL;
834
835 // Split off /classic or /physical suffix into a temp buffer.
836 char name_buf[32];
837 const char *slash = strchr(p, '/');
838 if (slash && out_force_model) {
839 size_t n = (size_t)(slash - p);
840 if (n >= sizeof(name_buf)) n = sizeof(name_buf) - 1;
841 memcpy(name_buf, p, n);
842 name_buf[n] = '\0';
843 p = name_buf;
844 const char *m = slash + 1;
845 if (strcmp(m, "classic") == 0) *out_force_model = 0;
846 else if (strcmp(m, "physical") == 0 || strcmp(m, "phys") == 0) *out_force_model = 1;
847 }
848
849 if (strcmp(p, "pistol") == 0) return GUN_PISTOL;
850 if (strcmp(p, "rifle") == 0) return GUN_RIFLE;
851 if (strcmp(p, "shotgun") == 0) return GUN_SHOTGUN;
852 if (strcmp(p, "smg") == 0) return GUN_SMG;
853 if (strcmp(p, "suppressed") == 0 || strcmp(p, "silenced") == 0) return GUN_SUPPRESSED;
854 if (strcmp(p, "lmg") == 0 || strcmp(p, "mg") == 0) return GUN_LMG;
855 if (strcmp(p, "sniper") == 0) return GUN_SNIPER;
856 if (strcmp(p, "grenade") == 0) return GUN_GRENADE;
857 if (strcmp(p, "rpg") == 0 || strcmp(p, "rocket") == 0) return GUN_RPG;
858 if (strcmp(p, "reload") == 0) return GUN_RELOAD;
859 if (strcmp(p, "cock") == 0 || strcmp(p, "bolt") == 0) return GUN_COCK;
860 if (strcmp(p, "ricochet") == 0 || strcmp(p, "pew") == 0) return GUN_RICOCHET;
861 return GUN_PISTOL;
862}
863
864// synthObj.kill(fade) — method on synth return object
865static JSValue js_synth_obj_kill(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
866 ACAudio *audio = current_rt->audio;
867 if (!audio) return JS_UNDEFINED;
868
869 // Get id from 'this' object
870 JSValue id_v = JS_GetPropertyStr(ctx, this_val, "id");
871 double id_d = 0;
872 if (JS_IsNumber(id_v)) JS_ToFloat64(ctx, &id_d, id_v);
873 JS_FreeValue(ctx, id_v);
874 uint64_t id = (uint64_t)id_d;
875 if (id == 0) return JS_UNDEFINED;
876
877 double fade = 0.025;
878 if (argc >= 1 && JS_IsNumber(argv[0])) {
879 JS_ToFloat64(ctx, &fade, argv[0]);
880 }
881 audio_kill(audio, id, fade);
882 return JS_UNDEFINED;
883}
884
885// synthObj.update({volume, tone, pan}) — method on synth return object
886static JSValue js_synth_obj_update(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
887 ACAudio *audio = current_rt->audio;
888 if (!audio || argc < 1 || !JS_IsObject(argv[0])) return JS_UNDEFINED;
889
890 // Get id from 'this' object
891 JSValue id_v = JS_GetPropertyStr(ctx, this_val, "id");
892 double id_d = 0;
893 if (JS_IsNumber(id_v)) JS_ToFloat64(ctx, &id_d, id_v);
894 JS_FreeValue(ctx, id_v);
895 uint64_t id = (uint64_t)id_d;
896 if (id == 0) return JS_UNDEFINED;
897
898 double freq = -1, vol = -1, pan = -3;
899 JSValue v;
900 v = JS_GetPropertyStr(ctx, argv[0], "tone");
901 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &freq, v);
902 JS_FreeValue(ctx, v);
903 v = JS_GetPropertyStr(ctx, argv[0], "volume");
904 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &vol, v);
905 JS_FreeValue(ctx, v);
906 v = JS_GetPropertyStr(ctx, argv[0], "pan");
907 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &pan, v);
908 JS_FreeValue(ctx, v);
909
910 audio_update(audio, id, freq, vol, pan);
911 return JS_UNDEFINED;
912}
913
914// sound.synth({type, tone, duration, volume, attack, decay, pan})
915static JSValue js_synth(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
916 (void)this_val;
917 ACAudio *audio = current_rt->audio;
918 if (!audio || argc < 1 || !JS_IsObject(argv[0])) return JS_UNDEFINED;
919
920 JSValue opts = argv[0];
921
922 // Parse type. For "gun-*" strings we also extract the preset so the
923 // DWG synth can branch to audio_synth_gun below. Optional "/classic"
924 // or "/physical" suffix overrides the preset's default model.
925 WaveType wt = WAVE_SINE;
926 GunPreset gun_preset = GUN_PISTOL;
927 int gun_force_model = -1;
928 int is_gun = 0;
929 JSValue type_v = JS_GetPropertyStr(ctx, opts, "type");
930 if (JS_IsString(type_v)) {
931 const char *ts = JS_ToCString(ctx, type_v);
932 wt = parse_wave_type(ts);
933 if (wt == WAVE_GUN) {
934 gun_preset = parse_gun_preset(ts, &gun_force_model);
935 is_gun = 1;
936 }
937 JS_FreeCString(ctx, ts);
938 }
939 JS_FreeValue(ctx, type_v);
940
941 // Parse tone (can be number or string)
942 double freq = 440.0;
943 JSValue tone_v = JS_GetPropertyStr(ctx, opts, "tone");
944 if (JS_IsNumber(tone_v)) {
945 JS_ToFloat64(ctx, &freq, tone_v);
946 } else if (JS_IsString(tone_v)) {
947 const char *ts = JS_ToCString(ctx, tone_v);
948 freq = audio_note_to_freq(ts);
949 JS_FreeCString(ctx, ts);
950 }
951 JS_FreeValue(ctx, tone_v);
952
953 // Parse duration (number or "🔁" for infinity)
954 double duration = 0.1;
955 JSValue dur_v = JS_GetPropertyStr(ctx, opts, "duration");
956 if (JS_IsNumber(dur_v)) {
957 JS_ToFloat64(ctx, &duration, dur_v);
958 } else if (JS_IsString(dur_v)) {
959 duration = INFINITY;
960 }
961 JS_FreeValue(ctx, dur_v);
962
963 // Parse volume, attack, decay, pan
964 double volume = 1.0, attack = 0.005, decay = 0.1, pan = 0.0;
965 JSValue v;
966
967 v = JS_GetPropertyStr(ctx, opts, "volume");
968 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &volume, v);
969 JS_FreeValue(ctx, v);
970
971 v = JS_GetPropertyStr(ctx, opts, "attack");
972 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &attack, v);
973 JS_FreeValue(ctx, v);
974
975 v = JS_GetPropertyStr(ctx, opts, "decay");
976 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &decay, v);
977 JS_FreeValue(ctx, v);
978
979 v = JS_GetPropertyStr(ctx, opts, "pan");
980 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &pan, v);
981 JS_FreeValue(ctx, v);
982
983 // Create voice. Guns take a separate path so we can seed per-preset
984 // DWG parameters (bore length, body modes, etc).
985 uint64_t id;
986 if (is_gun) {
987 // Let `tone` (1.0 default) scale the combustion pressure so pads
988 // can express accent via velocity. A JS caller passing tone=1.0
989 // = unity pressure from the preset.
990 double pressure_scale = freq > 0 && freq < 5.0 ? freq : 1.0;
991 id = audio_synth_gun(audio, gun_preset, duration, volume, attack,
992 decay, pan, pressure_scale, gun_force_model);
993 fprintf(stderr, "[synth] gun preset=%d model=%d vol=%.2f dur=%.1f id=%lu\n",
994 gun_preset, gun_force_model, volume, duration, (unsigned long)id);
995 ac_log("[synth] gun preset=%d model=%d vol=%.2f dur=%.1f id=%lu\n",
996 gun_preset, gun_force_model, volume, duration, (unsigned long)id);
997 } else {
998 id = audio_synth(audio, wt, freq, duration, volume, attack, decay, pan);
999 fprintf(stderr, "[synth] type=%d freq=%.1f vol=%.2f dur=%.1f id=%lu\n",
1000 wt, freq, volume, duration, (unsigned long)id);
1001 ac_log("[synth] type=%d freq=%.1f vol=%.2f dur=%.1f id=%lu\n",
1002 wt, freq, volume, duration, (unsigned long)id);
1003 }
1004
1005 // For gun voices, an optional `params` object on opts passes per-shot
1006 // overrides into the C-side synth state (drag-to-edit on inspector
1007 // cards). Each numeric property maps to a gun_presets[] field key.
1008 // Applied immediately after synth init so the next audio thread tick
1009 // sees the patched values.
1010 if (is_gun && id) {
1011 JSValue params_v = JS_GetPropertyStr(ctx, opts, "params");
1012 if (JS_IsObject(params_v)) {
1013 JSPropertyEnum *props = NULL;
1014 uint32_t plen = 0;
1015 if (JS_GetOwnPropertyNames(ctx, &props, &plen, params_v,
1016 JS_GPN_STRING_MASK | JS_GPN_ENUM_ONLY) == 0) {
1017 for (uint32_t i = 0; i < plen; i++) {
1018 const char *k = JS_AtomToCString(ctx, props[i].atom);
1019 if (k) {
1020 JSValue pv = JS_GetProperty(ctx, params_v, props[i].atom);
1021 double pd;
1022 if (JS_IsNumber(pv) && JS_ToFloat64(ctx, &pd, pv) == 0) {
1023 audio_gun_voice_set_param(audio, id, k, pd);
1024 }
1025 JS_FreeValue(ctx, pv);
1026 JS_FreeCString(ctx, k);
1027 }
1028 JS_FreeAtom(ctx, props[i].atom);
1029 }
1030 js_free(ctx, props);
1031 }
1032 }
1033 JS_FreeValue(ctx, params_v);
1034 }
1035
1036 // Return sound object with kill(), update(), startedAt
1037 JSValue snd = JS_NewObject(ctx);
1038 JS_SetPropertyStr(ctx, snd, "id", JS_NewFloat64(ctx, (double)id));
1039 JS_SetPropertyStr(ctx, snd, "startedAt", JS_NewFloat64(ctx, audio->time));
1040
1041 // kill(fade) and update({volume,tone,pan}) methods
1042 JS_SetPropertyStr(ctx, snd, "kill", JS_NewCFunction(ctx, js_synth_obj_kill, "kill", 1));
1043 JS_SetPropertyStr(ctx, snd, "update", JS_NewCFunction(ctx, js_synth_obj_update, "update", 1));
1044
1045 return snd;
1046}
1047
1048// sound.kill(idOrObj, fade) — accepts raw id or synth/sample/replay object
1049static JSValue js_sound_kill(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1050 (void)this_val;
1051 ACAudio *audio = current_rt->audio;
1052 if (!audio || argc < 1) return JS_UNDEFINED;
1053
1054 double id_d = 0;
1055 // Accept either a number (raw id) or an object with .id property
1056 if (JS_IsNumber(argv[0])) {
1057 JS_ToFloat64(ctx, &id_d, argv[0]);
1058 } else if (JS_IsObject(argv[0])) {
1059 JSValue id_v = JS_GetPropertyStr(ctx, argv[0], "id");
1060 if (JS_IsNumber(id_v)) JS_ToFloat64(ctx, &id_d, id_v);
1061 JS_FreeValue(ctx, id_v);
1062 }
1063 uint64_t id = (uint64_t)id_d;
1064
1065 double fade = 0.025;
1066 if (argc >= 2 && JS_IsNumber(argv[1])) {
1067 JS_ToFloat64(ctx, &fade, argv[1]);
1068 }
1069
1070 // Check if it's a sample voice
1071 int is_sample = 0;
1072 int is_replay = 0;
1073 if (JS_IsObject(argv[0])) {
1074 JSValue is_v = JS_GetPropertyStr(ctx, argv[0], "isSample");
1075 is_sample = JS_ToBool(ctx, is_v);
1076 JS_FreeValue(ctx, is_v);
1077 is_v = JS_GetPropertyStr(ctx, argv[0], "isReplay");
1078 is_replay = JS_ToBool(ctx, is_v);
1079 JS_FreeValue(ctx, is_v);
1080 }
1081
1082 if (is_replay) {
1083 audio_replay_kill(audio, id, fade);
1084 } else if (is_sample) {
1085 audio_sample_kill(audio, id, fade);
1086 } else {
1087 audio_kill(audio, id, fade);
1088 }
1089 return JS_UNDEFINED;
1090}
1091
1092// sound.update(id, {tone, volume, pan})
1093static JSValue js_sound_update(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1094 (void)this_val;
1095 ACAudio *audio = current_rt->audio;
1096 if (!audio || argc < 2) return JS_UNDEFINED;
1097
1098 double id_d;
1099 JS_ToFloat64(ctx, &id_d, argv[0]);
1100 uint64_t id = (uint64_t)id_d;
1101
1102 double freq = -1, vol = -1, pan = -3;
1103 if (JS_IsObject(argv[1])) {
1104 JSValue v;
1105 v = JS_GetPropertyStr(ctx, argv[1], "tone");
1106 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &freq, v);
1107 JS_FreeValue(ctx, v);
1108
1109 v = JS_GetPropertyStr(ctx, argv[1], "volume");
1110 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &vol, v);
1111 JS_FreeValue(ctx, v);
1112
1113 v = JS_GetPropertyStr(ctx, argv[1], "pan");
1114 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &pan, v);
1115 JS_FreeValue(ctx, v);
1116 }
1117
1118 audio_update(audio, id, freq, vol, pan);
1119 return JS_UNDEFINED;
1120}
1121
1122// sound.freq(note) — convert note to Hz
1123static JSValue js_sound_freq(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1124 (void)this_val;
1125 if (argc < 1) return JS_NewFloat64(ctx, 440.0);
1126
1127 double f;
1128 if (JS_IsNumber(argv[0])) {
1129 JS_ToFloat64(ctx, &f, argv[0]);
1130 return JS_NewFloat64(ctx, f);
1131 }
1132
1133 const char *note = JS_ToCString(ctx, argv[0]);
1134 f = audio_note_to_freq(note);
1135 JS_FreeCString(ctx, note);
1136 return JS_NewFloat64(ctx, f);
1137}
1138
1139// sound.room.toggle()
1140static JSValue js_room_toggle(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1141 (void)this_val; (void)argc; (void)argv;
1142 if (current_rt->audio) audio_room_toggle(current_rt->audio);
1143 return JS_UNDEFINED;
1144}
1145
1146// sound.room.setMix(value)
1147static JSValue js_set_room_mix(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1148 (void)this_val;
1149 if (argc < 1 || !current_rt->audio) return JS_UNDEFINED;
1150 double v;
1151 JS_ToFloat64(ctx, &v, argv[0]);
1152 audio_set_room_mix(current_rt->audio, (float)v);
1153 return JS_UNDEFINED;
1154}
1155
1156// sound.fx.setMix(value) — dry/wet for entire FX chain
1157static JSValue js_set_fx_mix(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1158 (void)this_val;
1159 if (argc < 1 || !current_rt->audio) return JS_UNDEFINED;
1160 double v;
1161 JS_ToFloat64(ctx, &v, argv[0]);
1162 audio_set_fx_mix(current_rt->audio, (float)v);
1163 return JS_UNDEFINED;
1164}
1165
1166// sound.glitch.toggle()
1167static JSValue js_glitch_toggle(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1168 (void)this_val; (void)argc; (void)argv;
1169 if (current_rt->audio) audio_glitch_toggle(current_rt->audio);
1170 return JS_UNDEFINED;
1171}
1172
1173// sound.glitch.setMix(value)
1174static JSValue js_set_glitch_mix(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1175 (void)this_val;
1176 if (argc < 1 || !current_rt->audio) return JS_UNDEFINED;
1177 double v;
1178 JS_ToFloat64(ctx, &v, argv[0]);
1179 audio_set_glitch_mix(current_rt->audio, (float)v);
1180 return JS_UNDEFINED;
1181}
1182
1183// sound.microphone.open() — open device + start hot-mic thread
1184static JSValue js_mic_open(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1185 (void)this_val; (void)argc; (void)argv;
1186 if (!current_rt->audio) return JS_FALSE;
1187 int ok = audio_mic_open(current_rt->audio);
1188 ac_log("[js][mic] open -> %s\n", ok == 0 ? "ok" : "fail");
1189 return JS_NewBool(ctx, ok == 0);
1190}
1191
1192// sound.microphone.close() — stop hot-mic thread + close device
1193static JSValue js_mic_close(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1194 (void)this_val; (void)argc; (void)argv;
1195 if (!current_rt->audio) return JS_UNDEFINED;
1196 audio_mic_close(current_rt->audio);
1197 ac_log("[js][mic] close\n");
1198 return JS_UNDEFINED;
1199}
1200
1201// sound.microphone.rec() — start buffering (instant, device already open)
1202static JSValue js_mic_rec(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1203 (void)this_val; (void)argc; (void)argv;
1204 if (!current_rt->audio) return JS_FALSE;
1205 int ok = audio_mic_start(current_rt->audio);
1206 ac_log("[js][mic] rec -> %s\n", ok == 0 ? "ok" : "fail");
1207 return JS_NewBool(ctx, ok == 0);
1208}
1209
1210// sound.microphone.cut() — stop buffering, return sample length
1211static JSValue js_mic_cut(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1212 (void)this_val; (void)argc; (void)argv;
1213 if (!current_rt->audio) return JS_NewInt32(ctx, 0);
1214 int len = audio_mic_stop(current_rt->audio);
1215 ac_log("[js][mic] cut -> len=%d rate=%u err=%s\n",
1216 len,
1217 current_rt->audio->sample_rate,
1218 current_rt->audio->mic_last_error[0] ? current_rt->audio->mic_last_error : "(none)");
1219 // Persist sample to disk for next boot
1220 if (len > 0) {
1221 int saved = audio_sample_save(current_rt->audio, "/mnt/ac-sample.raw");
1222 ac_log("[js][mic] sample saved to /mnt/ac-sample.raw (%d samples)\n", saved);
1223 }
1224 return JS_NewInt32(ctx, len);
1225}
1226
1227// sound.microphone.recording — check if currently recording
1228static JSValue js_mic_recording(JSContext *ctx, JSValueConst this_val) {
1229 if (!current_rt->audio) return JS_FALSE;
1230 return JS_NewBool(ctx, current_rt->audio->recording);
1231}
1232
1233// sound.microphone.sampleLength — length of recorded sample
1234static JSValue js_mic_sample_length(JSContext *ctx, JSValueConst this_val) {
1235 if (!current_rt->audio) return JS_NewInt32(ctx, 0);
1236 return JS_NewInt32(ctx, current_rt->audio->sample_len);
1237}
1238
1239// sound.microphone.sampleRate — capture rate of recorded sample
1240static JSValue js_mic_sample_rate(JSContext *ctx, JSValueConst this_val) {
1241 if (!current_rt->audio) return JS_NewInt32(ctx, 0);
1242 return JS_NewInt32(ctx, current_rt->audio->sample_rate);
1243}
1244
1245// sampleObj.update({tone, base, volume, pan}) — update a playing sample voice
1246static JSValue js_sample_obj_update(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1247 ACAudio *audio = current_rt->audio;
1248 if (!audio || argc < 1 || !JS_IsObject(argv[0])) return JS_UNDEFINED;
1249
1250 JSValue id_v = JS_GetPropertyStr(ctx, this_val, "id");
1251 double id_d = 0;
1252 if (JS_IsNumber(id_v)) JS_ToFloat64(ctx, &id_d, id_v);
1253 JS_FreeValue(ctx, id_v);
1254 uint64_t id = (uint64_t)id_d;
1255 if (id == 0) return JS_UNDEFINED;
1256
1257 double freq = -1, base_freq = -1, vol = -1, pan = -3;
1258 JSValue v;
1259 v = JS_GetPropertyStr(ctx, argv[0], "tone");
1260 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &freq, v);
1261 JS_FreeValue(ctx, v);
1262 v = JS_GetPropertyStr(ctx, argv[0], "base");
1263 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &base_freq, v);
1264 JS_FreeValue(ctx, v);
1265 v = JS_GetPropertyStr(ctx, argv[0], "volume");
1266 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &vol, v);
1267 JS_FreeValue(ctx, v);
1268 v = JS_GetPropertyStr(ctx, argv[0], "pan");
1269 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &pan, v);
1270 JS_FreeValue(ctx, v);
1271
1272 if (freq > 0 && base_freq < 0) base_freq = 261.63; // default C4
1273 audio_sample_update(audio, id, freq, base_freq, vol, pan);
1274 return JS_UNDEFINED;
1275}
1276
1277// replayObj.update({tone, base, volume, pan}) — update the dedicated replay voice
1278static JSValue js_replay_obj_update(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1279 ACAudio *audio = current_rt->audio;
1280 if (!audio || argc < 1 || !JS_IsObject(argv[0])) return JS_UNDEFINED;
1281
1282 JSValue id_v = JS_GetPropertyStr(ctx, this_val, "id");
1283 double id_d = 0;
1284 if (JS_IsNumber(id_v)) JS_ToFloat64(ctx, &id_d, id_v);
1285 JS_FreeValue(ctx, id_v);
1286 uint64_t id = (uint64_t)id_d;
1287 if (id == 0) return JS_UNDEFINED;
1288
1289 double freq = -1, base_freq = -1, vol = -1, pan = -3;
1290 JSValue v;
1291 v = JS_GetPropertyStr(ctx, argv[0], "tone");
1292 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &freq, v);
1293 JS_FreeValue(ctx, v);
1294 v = JS_GetPropertyStr(ctx, argv[0], "base");
1295 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &base_freq, v);
1296 JS_FreeValue(ctx, v);
1297 v = JS_GetPropertyStr(ctx, argv[0], "volume");
1298 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &vol, v);
1299 JS_FreeValue(ctx, v);
1300 v = JS_GetPropertyStr(ctx, argv[0], "pan");
1301 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &pan, v);
1302 JS_FreeValue(ctx, v);
1303
1304 if (freq > 0 && base_freq < 0) base_freq = 261.63;
1305 audio_replay_update(audio, id, freq, base_freq, vol, pan);
1306 return JS_UNDEFINED;
1307}
1308
1309// sound.sample.play({tone, base, volume, pan}) — play recorded sample at pitch
1310static JSValue js_sample_play(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1311 (void)this_val;
1312 ACAudio *audio = current_rt->audio;
1313 if (!audio || argc < 1 || !JS_IsObject(argv[0])) return JS_UNDEFINED;
1314
1315 JSValue opts = argv[0];
1316 double freq = 261.63, base_freq = 261.63, volume = 0.7, pan = 0.0;
1317 JSValue v;
1318
1319 v = JS_GetPropertyStr(ctx, opts, "tone");
1320 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &freq, v);
1321 JS_FreeValue(ctx, v);
1322
1323 v = JS_GetPropertyStr(ctx, opts, "base");
1324 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &base_freq, v);
1325 JS_FreeValue(ctx, v);
1326
1327 v = JS_GetPropertyStr(ctx, opts, "volume");
1328 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &volume, v);
1329 JS_FreeValue(ctx, v);
1330
1331 v = JS_GetPropertyStr(ctx, opts, "pan");
1332 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &pan, v);
1333 JS_FreeValue(ctx, v);
1334
1335 int loop = 0;
1336 v = JS_GetPropertyStr(ctx, opts, "loop");
1337 if (JS_IsBool(v)) loop = JS_ToBool(ctx, v);
1338 JS_FreeValue(ctx, v);
1339
1340 uint64_t id = audio_sample_play(audio, freq, base_freq, volume, pan, loop);
1341 if (id == 0) {
1342 ac_log("[sample] play FAILED (sample_len=%d)\n", audio->sample_len);
1343 return JS_UNDEFINED;
1344 }
1345 ac_log("[sample] play OK id=%llu freq=%.1f vol=%.2f\n", (unsigned long long)id, freq, volume);
1346
1347 // Return object with id, update(), isSample
1348 JSValue snd = JS_NewObject(ctx);
1349 JS_SetPropertyStr(ctx, snd, "id", JS_NewFloat64(ctx, (double)id));
1350 JS_SetPropertyStr(ctx, snd, "isSample", JS_TRUE);
1351 JS_SetPropertyStr(ctx, snd, "update", JS_NewCFunction(ctx, js_sample_obj_update, "update", 1));
1352 return snd;
1353}
1354
1355// sound.replay.play({tone, base, volume, pan}) — play dedicated global replay buffer
1356static JSValue js_replay_play(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1357 (void)this_val;
1358 ACAudio *audio = current_rt->audio;
1359 if (!audio || argc < 1 || !JS_IsObject(argv[0])) return JS_UNDEFINED;
1360
1361 JSValue opts = argv[0];
1362 double freq = 261.63, base_freq = 261.63, volume = 0.7, pan = 0.0;
1363 JSValue v;
1364
1365 v = JS_GetPropertyStr(ctx, opts, "tone");
1366 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &freq, v);
1367 JS_FreeValue(ctx, v);
1368
1369 v = JS_GetPropertyStr(ctx, opts, "base");
1370 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &base_freq, v);
1371 JS_FreeValue(ctx, v);
1372
1373 v = JS_GetPropertyStr(ctx, opts, "volume");
1374 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &volume, v);
1375 JS_FreeValue(ctx, v);
1376
1377 v = JS_GetPropertyStr(ctx, opts, "pan");
1378 if (JS_IsNumber(v)) JS_ToFloat64(ctx, &pan, v);
1379 JS_FreeValue(ctx, v);
1380
1381 int loop = 0;
1382 v = JS_GetPropertyStr(ctx, opts, "loop");
1383 if (JS_IsBool(v)) loop = JS_ToBool(ctx, v);
1384 JS_FreeValue(ctx, v);
1385
1386 uint64_t id = audio_replay_play(audio, freq, base_freq, volume, pan, loop);
1387 if (id == 0) return JS_UNDEFINED;
1388
1389 JSValue snd = JS_NewObject(ctx);
1390 JS_SetPropertyStr(ctx, snd, "id", JS_NewFloat64(ctx, (double)id));
1391 JS_SetPropertyStr(ctx, snd, "isReplay", JS_TRUE);
1392 JS_SetPropertyStr(ctx, snd, "update", JS_NewCFunction(ctx, js_replay_obj_update, "update", 1));
1393 return snd;
1394}
1395
1396// sound.sample.kill(idOrObj, fade)
1397static JSValue js_sample_kill(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1398 (void)this_val;
1399 ACAudio *audio = current_rt->audio;
1400 if (!audio || argc < 1) return JS_UNDEFINED;
1401
1402 double id_d = 0;
1403 if (JS_IsNumber(argv[0])) {
1404 JS_ToFloat64(ctx, &id_d, argv[0]);
1405 } else if (JS_IsObject(argv[0])) {
1406 JSValue id_v = JS_GetPropertyStr(ctx, argv[0], "id");
1407 if (JS_IsNumber(id_v)) JS_ToFloat64(ctx, &id_d, id_v);
1408 JS_FreeValue(ctx, id_v);
1409 }
1410
1411 double fade = 0.02;
1412 if (argc >= 2 && JS_IsNumber(argv[1])) JS_ToFloat64(ctx, &fade, argv[1]);
1413
1414 audio_sample_kill(audio, (uint64_t)id_d, fade);
1415 return JS_UNDEFINED;
1416}
1417
1418// sound.replay.kill(idOrObj, fade)
1419static JSValue js_replay_kill(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1420 (void)this_val;
1421 ACAudio *audio = current_rt->audio;
1422 if (!audio || argc < 1) return JS_UNDEFINED;
1423
1424 double id_d = 0;
1425 if (JS_IsNumber(argv[0])) {
1426 JS_ToFloat64(ctx, &id_d, argv[0]);
1427 } else if (JS_IsObject(argv[0])) {
1428 JSValue id_v = JS_GetPropertyStr(ctx, argv[0], "id");
1429 if (JS_IsNumber(id_v)) JS_ToFloat64(ctx, &id_d, id_v);
1430 JS_FreeValue(ctx, id_v);
1431 }
1432
1433 double fade = 0.02;
1434 if (argc >= 2 && JS_IsNumber(argv[1])) JS_ToFloat64(ctx, &fade, argv[1]);
1435
1436 audio_replay_kill(audio, (uint64_t)id_d, fade);
1437 return JS_UNDEFINED;
1438}
1439
1440// sound.sample.getData() — returns Float32Array of current sample buffer
1441// Proper free callback for JS_NewArrayBuffer (3-arg signature, not plain free)
1442static void js_free_array_buffer(JSRuntime *rt, void *opaque, void *ptr) {
1443 (void)rt; (void)opaque;
1444 free(ptr);
1445}
1446
1447static JSValue js_sample_get_data(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1448 (void)this_val; (void)argc; (void)argv;
1449 ACAudio *audio = current_rt ? current_rt->audio : NULL;
1450 if (!audio || !audio->sample_buf || audio->sample_len <= 0) return JS_UNDEFINED;
1451
1452 // Lock to prevent sample_buf pointer swap during copy
1453 pthread_mutex_lock(&audio->lock);
1454 int len = audio->sample_len;
1455 if (len > audio->sample_max_len) len = audio->sample_max_len;
1456 if (len <= 0) { pthread_mutex_unlock(&audio->lock); return JS_UNDEFINED; }
1457
1458 size_t byte_len = (size_t)len * sizeof(float);
1459 float *copy = malloc(byte_len);
1460 if (!copy) { pthread_mutex_unlock(&audio->lock); return JS_UNDEFINED; }
1461 memcpy(copy, audio->sample_buf, byte_len);
1462 pthread_mutex_unlock(&audio->lock);
1463
1464 // Create ArrayBuffer from our copy
1465 JSValue ab = JS_NewArrayBuffer(ctx, (uint8_t *)copy, byte_len,
1466 js_free_array_buffer, NULL, 0);
1467 if (JS_IsException(ab)) { free(copy); return JS_UNDEFINED; }
1468
1469 // Create Float32Array view
1470 JSValue global = JS_GetGlobalObject(ctx);
1471 JSValue ctor = JS_GetPropertyStr(ctx, global, "Float32Array");
1472 JSValue f32 = JS_CallConstructor(ctx, ctor, 1, &ab);
1473 JS_FreeValue(ctx, ctor);
1474 JS_FreeValue(ctx, global);
1475 JS_FreeValue(ctx, ab);
1476 return f32;
1477}
1478
1479// sound.sample.saveTo(path) — save current sample to a file, returns sample count or -1
1480static JSValue js_sample_save_to(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1481 (void)this_val;
1482 if (!current_rt || !current_rt->audio || argc < 1) return JS_NewInt32(ctx, -1);
1483 const char *path = JS_ToCString(ctx, argv[0]);
1484 if (!path) return JS_NewInt32(ctx, -1);
1485 int result = audio_sample_save(current_rt->audio, path);
1486 ac_log("[sample] saveTo(%s) -> %d samples\n", path, result);
1487 JS_FreeCString(ctx, path);
1488 return JS_NewInt32(ctx, result);
1489}
1490
1491// sound.sample.loadFrom(path) — load sample from a file, returns sample count or -1
1492static JSValue js_sample_load_from(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1493 (void)this_val;
1494 if (!current_rt || !current_rt->audio || argc < 1) return JS_NewInt32(ctx, -1);
1495 const char *path = JS_ToCString(ctx, argv[0]);
1496 if (!path) return JS_NewInt32(ctx, -1);
1497 int result = audio_sample_load(current_rt->audio, path);
1498 ac_log("[sample] loadFrom(%s) -> %d samples\n", path, result);
1499 JS_FreeCString(ctx, path);
1500 return JS_NewInt32(ctx, result);
1501}
1502
1503// sound.sample.loadData(float32array, rate) — load sample data from JS array
1504static JSValue js_sample_load_data(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1505 (void)this_val;
1506 if (!current_rt) return JS_FALSE;
1507 ACAudio *audio = current_rt->audio;
1508 if (!audio || argc < 1) return JS_FALSE;
1509
1510 size_t byte_len = 0;
1511 size_t byte_off = 0;
1512 size_t bytes_per = 0;
1513 JSValue ab = JS_GetTypedArrayBuffer(ctx, argv[0], &byte_off, &byte_len, &bytes_per);
1514 if (JS_IsException(ab)) return JS_FALSE;
1515
1516 size_t ab_len = 0;
1517 uint8_t *ptr = JS_GetArrayBuffer(ctx, &ab_len, ab);
1518 if (!ptr) { JS_FreeValue(ctx, ab); return JS_FALSE; }
1519
1520 float *data = (float *)(ptr + byte_off);
1521 int len = (int)(byte_len / sizeof(float));
1522 ac_log("[sample] loadData: byte_len=%zu byte_off=%zu bytes_per=%zu ab_len=%zu len=%d\n",
1523 byte_len, byte_off, bytes_per, ab_len, len);
1524
1525 unsigned int rate = 48000;
1526 if (argc >= 2 && JS_IsNumber(argv[1])) {
1527 double r; JS_ToFloat64(ctx, &r, argv[1]);
1528 if (r > 0) rate = (unsigned int)r;
1529 }
1530
1531 audio_sample_load_data(audio, data, len, rate);
1532 JS_FreeValue(ctx, ab); // Free AFTER memcpy — ptr is into this buffer
1533 return JS_TRUE;
1534}
1535
1536// sound.replay.loadData(float32array, rate) — load data into dedicated replay buffer
1537static JSValue js_replay_load_data(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1538 (void)this_val;
1539 if (!current_rt) return JS_FALSE;
1540 ACAudio *audio = current_rt->audio;
1541 if (!audio || argc < 1) return JS_FALSE;
1542
1543 size_t byte_len = 0;
1544 size_t byte_off = 0;
1545 size_t bytes_per = 0;
1546 JSValue ab = JS_GetTypedArrayBuffer(ctx, argv[0], &byte_off, &byte_len, &bytes_per);
1547 if (JS_IsException(ab)) return JS_FALSE;
1548
1549 size_t ab_len = 0;
1550 uint8_t *ptr = JS_GetArrayBuffer(ctx, &ab_len, ab);
1551 if (!ptr) { JS_FreeValue(ctx, ab); return JS_FALSE; }
1552
1553 float *data = (float *)(ptr + byte_off);
1554 int len = (int)(byte_len / sizeof(float));
1555
1556 unsigned int rate = 48000;
1557 if (argc >= 2 && JS_IsNumber(argv[1])) {
1558 double r; JS_ToFloat64(ctx, &r, argv[1]);
1559 if (r > 0) rate = (unsigned int)r;
1560 }
1561
1562 audio_replay_load_data(audio, data, len, rate);
1563 JS_FreeValue(ctx, ab);
1564 return JS_TRUE;
1565}
1566
1567// sound.speaker.getRecentBuffer(seconds) -> { data: Float32Array, rate: number }
1568static JSValue js_speaker_get_recent_buffer(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1569 (void)this_val;
1570 ACAudio *audio = current_rt ? current_rt->audio : NULL;
1571 if (!audio || !audio->output_history_buf || audio->output_history_size <= 0) return JS_NULL;
1572
1573 double seconds = 1.0;
1574 if (argc >= 1 && JS_IsNumber(argv[0])) JS_ToFloat64(ctx, &seconds, argv[0]);
1575 if (seconds <= 0.0) return JS_NULL;
1576
1577 unsigned int rate_guess = audio->output_history_rate ? audio->output_history_rate : AUDIO_OUTPUT_HISTORY_RATE;
1578 int want_len = (int)(seconds * (double)rate_guess + 0.5);
1579 if (want_len < 1) want_len = 1;
1580 if (want_len > audio->output_history_size) want_len = audio->output_history_size;
1581
1582 float *copy = malloc((size_t)want_len * sizeof(float));
1583 if (!copy) return JS_NULL;
1584
1585 unsigned int actual_rate = 0;
1586 int len = audio_output_get_recent(audio, copy, want_len, &actual_rate);
1587 if (len <= 0 || actual_rate == 0) {
1588 free(copy);
1589 return JS_NULL;
1590 }
1591
1592 size_t byte_len = (size_t)len * sizeof(float);
1593 JSValue ab = JS_NewArrayBuffer(ctx, (uint8_t *)copy, byte_len,
1594 js_free_array_buffer, NULL, 0);
1595 if (JS_IsException(ab)) { free(copy); return JS_NULL; }
1596
1597 JSValue global = JS_GetGlobalObject(ctx);
1598 JSValue ctor = JS_GetPropertyStr(ctx, global, "Float32Array");
1599 JSValue f32 = JS_CallConstructor(ctx, ctor, 1, &ab);
1600 JS_FreeValue(ctx, ctor);
1601 JS_FreeValue(ctx, global);
1602 JS_FreeValue(ctx, ab);
1603 if (JS_IsException(f32)) return JS_NULL;
1604
1605 JSValue out = JS_NewObject(ctx);
1606 JS_SetPropertyStr(ctx, out, "data", f32);
1607 JS_SetPropertyStr(ctx, out, "rate", JS_NewInt32(ctx, (int)actual_rate));
1608 return out;
1609}
1610
1611// sound.tape.recording() -> bool
1612// Returns true while the tape recorder (recorder.c) is capturing.
1613// Pure read-only state getter for notepat UI.
1614static JSValue js_tape_recording(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1615 (void)this_val; (void)argc; (void)argv;
1616#ifdef HAVE_AVCODEC
1617 if (current_rt && current_rt->recorder) {
1618 return JS_NewBool(ctx, recorder_is_recording((ACRecorder *)current_rt->recorder));
1619 }
1620#endif
1621 return JS_NewBool(ctx, 0);
1622}
1623
1624// sound.tape.elapsed() -> number (seconds, 0 if not recording)
1625static JSValue js_tape_elapsed(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1626 (void)this_val; (void)argc; (void)argv;
1627#ifdef HAVE_AVCODEC
1628 if (current_rt && current_rt->recorder) {
1629 return JS_NewFloat64(ctx, recorder_elapsed((ACRecorder *)current_rt->recorder));
1630 }
1631#endif
1632 return JS_NewFloat64(ctx, 0.0);
1633}
1634
1635// sound.speak(text)
1636static JSValue js_speak(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1637 (void)this_val;
1638 if (argc < 1 || !current_rt->tts) return JS_UNDEFINED;
1639 const char *text = JS_ToCString(ctx, argv[0]);
1640 if (!text) return JS_UNDEFINED;
1641 tts_speak(current_rt->tts, text);
1642 JS_FreeCString(ctx, text);
1643 return JS_UNDEFINED;
1644}
1645
1646// sound.speakVoice(text, male) — speak with voice selection (0=female, 1=male)
1647static JSValue js_speak_voice(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1648 (void)this_val;
1649 if (argc < 2 || !current_rt->tts) return JS_UNDEFINED;
1650 const char *text = JS_ToCString(ctx, argv[0]);
1651 if (!text) return JS_UNDEFINED;
1652 int male = 0;
1653 JS_ToInt32(ctx, &male, argv[1]);
1654 tts_speak_voice(current_rt->tts, text, male);
1655 JS_FreeCString(ctx, text);
1656 return JS_UNDEFINED;
1657}
1658
1659// sound.speakCached(key) — instant playback of pre-rendered letter/key
1660static JSValue js_speak_cached(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1661 (void)this_val;
1662 if (argc < 1 || !current_rt->tts) return JS_UNDEFINED;
1663 const char *key = JS_ToCString(ctx, argv[0]);
1664 if (!key) return JS_UNDEFINED;
1665 tts_speak_cached(current_rt->tts, key);
1666 JS_FreeCString(ctx, key);
1667 return JS_UNDEFINED;
1668}
1669
1670// ============================================================
1671// Event System
1672// ============================================================
1673
1674static JSValue js_event_is(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1675 if (argc < 1) return JS_FALSE;
1676 const char *pattern = JS_ToCString(ctx, argv[0]);
1677 if (!pattern) return JS_FALSE;
1678
1679 JSValue type_val = JS_GetPropertyStr(ctx, this_val, "_type");
1680 JSValue key_val = JS_GetPropertyStr(ctx, this_val, "_key");
1681
1682 int type;
1683 JS_ToInt32(ctx, &type, type_val);
1684 const char *key = JS_ToCString(ctx, key_val);
1685
1686 int match = 0;
1687
1688 if (strcmp(pattern, "touch") == 0)
1689 match = (type == AC_EVENT_TOUCH);
1690 else if (strcmp(pattern, "lift") == 0)
1691 match = (type == AC_EVENT_LIFT);
1692 else if (strcmp(pattern, "draw") == 0)
1693 match = (type == AC_EVENT_DRAW);
1694 else if (strcmp(pattern, "keyboard:down") == 0)
1695 match = (type == AC_EVENT_KEYBOARD_DOWN);
1696 else if (strcmp(pattern, "keyboard:up") == 0)
1697 match = (type == AC_EVENT_KEYBOARD_UP);
1698 else if (strncmp(pattern, "keyboard:down:", 14) == 0)
1699 match = (type == AC_EVENT_KEYBOARD_DOWN && key && strcmp(key, pattern + 14) == 0);
1700 else if (strncmp(pattern, "keyboard:up:", 12) == 0)
1701 match = (type == AC_EVENT_KEYBOARD_UP && key && strcmp(key, pattern + 12) == 0);
1702 else if (strcmp(pattern, "reframed") == 0)
1703 match = 0; // no resize on bare metal
1704 else if (strncmp(pattern, "gamepad", 7) == 0)
1705 match = 0; // stub
1706 else if (strncmp(pattern, "midi:", 5) == 0)
1707 match = 0; // stub
1708 else if (strcmp(pattern, "compose") == 0)
1709 match = 0; // stub
1710
1711 JS_FreeCString(ctx, pattern);
1712 JS_FreeCString(ctx, key);
1713 JS_FreeValue(ctx, type_val);
1714 JS_FreeValue(ctx, key_val);
1715
1716 return JS_NewBool(ctx, match);
1717}
1718
1719static JSValue make_event_object(JSContext *ctx, ACEvent *ev) {
1720 JSValue obj = JS_NewObject(ctx);
1721 JS_SetPropertyStr(ctx, obj, "_type", JS_NewInt32(ctx, ev->type));
1722 JS_SetPropertyStr(ctx, obj, "_key", JS_NewString(ctx, ev->key_name));
1723 JS_SetPropertyStr(ctx, obj, "x", JS_NewInt32(ctx, ev->x));
1724 JS_SetPropertyStr(ctx, obj, "y", JS_NewInt32(ctx, ev->y));
1725 JS_SetPropertyStr(ctx, obj, "repeat", JS_NewBool(ctx, ev->repeat));
1726 JS_SetPropertyStr(ctx, obj, "key", JS_NewString(ctx, ev->key_name));
1727 JS_SetPropertyStr(ctx, obj, "code", JS_NewString(ctx, ev->key_name));
1728 // Velocity: 0-127 from analog pressure (0.0-1.0), default 127 for digital keys
1729 int velocity = ev->pressure > 0.001f ? (int)(ev->pressure * 127.0f) : 127;
1730 if (velocity < 1 && ev->type == AC_EVENT_KEYBOARD_DOWN) velocity = 1;
1731 JS_SetPropertyStr(ctx, obj, "velocity", JS_NewInt32(ctx, velocity));
1732 JS_SetPropertyStr(ctx, obj, "pressure", JS_NewFloat64(ctx, (double)ev->pressure));
1733 JS_SetPropertyStr(ctx, obj, "name", JS_NewString(ctx, ev->key_name));
1734
1735 // pointer sub-object
1736 JSValue pointer = JS_NewObject(ctx);
1737 JS_SetPropertyStr(ctx, pointer, "x", JS_NewInt32(ctx, ev->x));
1738 JS_SetPropertyStr(ctx, pointer, "y", JS_NewInt32(ctx, ev->y));
1739 JS_SetPropertyStr(ctx, obj, "pointer", pointer);
1740
1741 JS_SetPropertyStr(ctx, obj, "is", JS_NewCFunction(ctx, js_event_is, "is", 1));
1742
1743 return obj;
1744}
1745
1746// ============================================================
1747// num utilities
1748// ============================================================
1749
1750static JSValue js_num_clamp(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1751 (void)this_val;
1752 if (argc < 3) return JS_UNDEFINED;
1753 double v, mn, mx;
1754 JS_ToFloat64(ctx, &v, argv[0]);
1755 JS_ToFloat64(ctx, &mn, argv[1]);
1756 JS_ToFloat64(ctx, &mx, argv[2]);
1757 if (v < mn) v = mn;
1758 if (v > mx) v = mx;
1759 return JS_NewFloat64(ctx, v);
1760}
1761
1762static JSValue js_num_rand(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1763 (void)this_val; (void)argc; (void)argv;
1764 return JS_NewFloat64(ctx, (double)rand() / RAND_MAX);
1765}
1766
1767static JSValue js_num_randint(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
1768 (void)this_val;
1769 if (argc < 2) return JS_NewInt32(ctx, 0);
1770 int a, b;
1771 JS_ToInt32(ctx, &a, argv[0]);
1772 JS_ToInt32(ctx, &b, argv[1]);
1773 if (b <= a) return JS_NewInt32(ctx, a);
1774 return JS_NewInt32(ctx, a + rand() % (b - a + 1));
1775}
1776
1777// ============================================================
1778// Module loader for ES module imports
1779// ============================================================
1780
1781static JSModuleDef *js_module_loader(JSContext *ctx, const char *module_name, void *opaque) {
1782 (void)opaque;
1783
1784 // Check if it's a stub module
1785 const char *stub_src = NULL;
1786
1787 if (strstr(module_name, "chord-detection")) {
1788 stub_src = "export function detectChord() { return null; }";
1789 } else if (strstr(module_name, "gamepad-diagram")) {
1790 stub_src = "export function drawMiniControllerDiagram() {}";
1791 } else if (strstr(module_name, "pixel-sample")) {
1792 stub_src = "export function decodeBitmapToSample() { return null; }\n"
1793 "export function loadPaintingAsAudio() { return null; }";
1794 } else if (strstr(module_name, "nopaint")) {
1795 stub_src = "export function nopaint_generateColoredLabel() {}\n"
1796 "export function nopaint_boot() {}\n"
1797 "export function nopaint_act() {}\n"
1798 "export function nopaint_paint() {}\n"
1799 "export function nopaint_is(s) { return false; }\n"
1800 "export function nopaint_cancelStroke() {}\n"
1801 "export function nopaint_adjust() {}\n"
1802 "export function nopaint_handleColor(c, ink) { return ink(c); }\n"
1803 "export function nopaint_cleanupColor() {}\n"
1804 "export function nopaint_parseBrushParams(o) { return {color:[], mode:'fill', thickness:1}; }\n"
1805 "export function nopaint_renderPerfHUD() {}\n"
1806 "export function nopaint_triggerBakeFlash() {}";
1807 } else if (strstr(module_name, "color-highlighting")) {
1808 stub_src = "export function generateNopaintHUDLabel() { return ''; }\n"
1809 "export function colorizeColorName() { return ''; }";
1810 }
1811
1812 // Try to load the module from the filesystem
1813 char resolved[512] = {0};
1814
1815 if (!stub_src) {
1816 // Try multiple paths
1817 const char *search_paths[] = {
1818 module_name,
1819 NULL
1820 };
1821
1822 // If it's a relative path like "../lib/note-colors.mjs", resolve from /
1823 if (module_name[0] == '.' && module_name[1] == '.') {
1824 // "../lib/X" → "/lib/X"
1825 const char *p = module_name + 2;
1826 while (*p == '/') p++;
1827 if (*p == '.') { // "../../lib/X"
1828 p++;
1829 while (*p == '.') p++;
1830 while (*p == '/') p++;
1831 }
1832 snprintf(resolved, sizeof(resolved), "/%s", p);
1833 } else {
1834 snprintf(resolved, sizeof(resolved), "%s", module_name);
1835 }
1836
1837 FILE *f = fopen(resolved, "r");
1838 if (!f) {
1839 // Try /lib/ prefix
1840 snprintf(resolved, sizeof(resolved), "/lib/%s",
1841 strrchr(module_name, '/') ? strrchr(module_name, '/') + 1 : module_name);
1842 f = fopen(resolved, "r");
1843 }
1844 if (f) {
1845 fseek(f, 0, SEEK_END);
1846 long len = ftell(f);
1847 fseek(f, 0, SEEK_SET);
1848 char *src = malloc(len + 1);
1849 fread(src, 1, len, f);
1850 src[len] = '\0';
1851 fclose(f);
1852
1853 JSValue val = JS_Eval(ctx, src, len, module_name,
1854 JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
1855 free(src);
1856
1857 if (JS_IsException(val)) {
1858 JSValue exc = JS_GetException(ctx);
1859 const char *str = JS_ToCString(ctx, exc);
1860 fprintf(stderr, "[js] Module load error (%s): %s\n", module_name, str);
1861 JS_FreeCString(ctx, str);
1862 JS_FreeValue(ctx, exc);
1863 return NULL;
1864 }
1865
1866 JSModuleDef *m = (JSModuleDef *)JS_VALUE_GET_PTR(val);
1867 return m;
1868 }
1869
1870 // Module not found — create empty stub
1871 fprintf(stderr, "[js] Module not found, stubbing: %s\n", module_name);
1872 stub_src = "// stub";
1873 }
1874
1875 // Compile stub module
1876 JSValue val = JS_Eval(ctx, stub_src, strlen(stub_src), module_name,
1877 JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
1878 if (JS_IsException(val)) {
1879 JSValue exc = JS_GetException(ctx);
1880 const char *str = JS_ToCString(ctx, exc);
1881 fprintf(stderr, "[js] Stub module error (%s): %s\n", module_name, str);
1882 JS_FreeCString(ctx, str);
1883 JS_FreeValue(ctx, exc);
1884 return NULL;
1885 }
1886
1887 JSModuleDef *m = (JSModuleDef *)JS_VALUE_GET_PTR(val);
1888 return m;
1889}
1890
1891// ============================================================
1892// Runtime Init / Lifecycle
1893// ============================================================
1894
1895// JS init code evaluated before pieces — defines Button, Box, etc.
1896static const char *js_init_code =
1897 "globalThis.__Button = class Button {\n"
1898 " constructor(x, y, w, h) {\n"
1899 " if (typeof x === 'object' && x !== null) {\n"
1900 " this.box = { x: x.x || 0, y: x.y || 0, w: x.w || x.width || 0, h: x.h || x.height || 0 };\n"
1901 " } else {\n"
1902 " this.box = { x: x || 0, y: y || 0, w: w || 0, h: h || 0 };\n"
1903 " }\n"
1904 " this.down = false;\n"
1905 " this.over = false;\n"
1906 " this.disabled = false;\n"
1907 " this.multitouch = true;\n"
1908 " }\n"
1909 " get up() { return !this.down; }\n"
1910 " set up(v) { this.down = !v; }\n"
1911 " paint(fn) { if (!this.disabled && fn) fn(this); }\n"
1912 " act(e, callbacks, pens) {\n"
1913 " if (this.disabled) return;\n"
1914 " if (typeof callbacks === 'function') callbacks = { push: callbacks };\n"
1915 " this.actions = callbacks;\n"
1916 " }\n"
1917 "};\n"
1918 "globalThis.__Box = class Box {\n"
1919 " constructor(x, y, w, h) {\n"
1920 " if (typeof x === 'object') { this.x = x.x; this.y = x.y; this.w = x.w; this.h = x.h; }\n"
1921 " else { this.x = x || 0; this.y = y || 0; this.w = w || 0; this.h = h || 0; }\n"
1922 " }\n"
1923 " static from(obj) { return new __Box(obj); }\n"
1924 "};\n"
1925 "globalThis.Number.isFinite = globalThis.Number.isFinite || function(v) { return typeof v === 'number' && isFinite(v); };\n"
1926 // num.shiftRGB — lerp between two RGB arrays
1927 "globalThis.__shiftRGB = function(from, to, t, mode) {\n"
1928 " if (!from || !to) return from || [0,0,0];\n"
1929 " t = Math.max(0, Math.min(1, t || 0));\n"
1930 " return [\n"
1931 " Math.round(from[0] + (to[0] - from[0]) * t),\n"
1932 " Math.round(from[1] + (to[1] - from[1]) * t),\n"
1933 " Math.round(from[2] + (to[2] - from[2]) * t)\n"
1934 " ];\n"
1935 "};\n"
1936 // num.dist — Euclidean distance
1937 "globalThis.__dist = function(x1, y1, x2, y2) {\n"
1938 " const dx = x2 - x1, dy = y2 - y1;\n"
1939 " return Math.sqrt(dx*dx + dy*dy);\n"
1940 "};\n"
1941 // num.map — map value from one range to another
1942 "globalThis.__map = function(v, inMin, inMax, outMin, outMax) {\n"
1943 " return outMin + (v - inMin) * (outMax - outMin) / (inMax - inMin);\n"
1944 "};\n"
1945 // num.lerp
1946 "globalThis.__lerp = function(a, b, t) { return a + (b - a) * t; };\n"
1947 // num.parseColor — parse color from params array (e.g. ["purple"], ["255","0","0"], ["128"])
1948 "globalThis.__parseColor = function(params) {\n"
1949 " if (!params || params.length === 0) return [];\n"
1950 " var names = {red:[255,0,0],orange:[255,165,0],yellow:[255,255,0],green:[0,128,0],\n"
1951 " cyan:[0,255,255],blue:[0,0,255],purple:[128,0,128],magenta:[255,0,255],\n"
1952 " pink:[255,192,203],white:[255,255,255],gray:[128,128,128],grey:[128,128,128],\n"
1953 " black:[0,0,0],brown:[139,69,19]};\n"
1954 " var first = params[0];\n"
1955 " if (typeof first === 'string' && names[first.toLowerCase()]) {\n"
1956 " var c = names[first.toLowerCase()].slice();\n"
1957 " if (params.length >= 2) c.push(parseInt(params[1]) || 255);\n"
1958 " return c;\n"
1959 " }\n"
1960 " var nums = params.map(function(p){return parseInt(p)}).filter(function(n){return !isNaN(n)});\n"
1961 " return nums;\n"
1962 "};\n"
1963 // num.randIntArr — array of N random ints in [0, max]
1964 "globalThis.__randIntArr = function(max, len) {\n"
1965 " var a = []; for (var i = 0; i < len; i++) a.push(Math.floor(Math.random() * (max + 1)));\n"
1966 " return a;\n"
1967 "};\n"
1968 // num.timestamp — returns a timestamp string
1969 "globalThis.__timestamp = function() { return Date.now().toString(36); };\n"
1970 // nopaint_generateColoredLabel stub (native doesn't have HUD color highlighting)
1971 "globalThis.__nopaint_generateColoredLabel = function() {};\n"
1972 // Typeface constructor stub
1973 "globalThis.__Typeface = function Typeface(name) {\n"
1974 " this.name = name;\n"
1975 " this.loaded = false;\n"
1976 " this.load = function(preloadFn) { this.loaded = true; return this; };\n"
1977 " this.measure = function(text) { return { width: (text||'').length * 8, height: 8 }; };\n"
1978 "};\n"
1979 // ── Global theme system ──
1980 // Auto-switches dark/light based on LA time. All pieces use theme.fg, theme.bg, etc.
1981 // Supports named presets via __theme.apply(id) that persist via config.json.
1982 "globalThis.__theme = (function() {\n"
1983 " function getLAOffset() {\n"
1984 " var d = new Date(), m = d.getUTCMonth(), y = d.getUTCFullYear();\n"
1985 " if (m > 2 && m < 10) return 7;\n"
1986 " if (m < 2 || m > 10) return 8;\n"
1987 " if (m === 2) {\n"
1988 " var mar1 = new Date(y, 2, 1), ss = 8 + (7 - mar1.getDay()) % 7;\n"
1989 " return d.getUTCDate() > ss || (d.getUTCDate() === ss && d.getUTCHours() >= 10) ? 7 : 8;\n"
1990 " }\n"
1991 " var nov1 = new Date(y, 10, 1), fs = 1 + (7 - nov1.getDay()) % 7;\n"
1992 " return d.getUTCDate() < fs || (d.getUTCDate() === fs && d.getUTCHours() < 9) ? 7 : 8;\n"
1993 " }\n"
1994 " function getLAHour() {\n"
1995 " return (new Date().getUTCHours() - getLAOffset() + 24) % 24;\n"
1996 " }\n"
1997 " var t = { dark: true, _lastCheck: 0, _overrideId: null, _override: null };\n"
1998 " // Theme presets: each has dark and light color overrides\n"
1999 " t.presets = {\n"
2000 " serious: {\n"
2001 " label: 'serious', desc: 'black & white',\n"
2002 " dark: { bg:[0,0,0], bgAlt:[10,10,10], bgDim:[0,0,0],\n"
2003 " fg:255, fgDim:160, fgMute:90,\n"
2004 " bar:[15,15,15], border:[60,60,60],\n"
2005 " accent:[128,128,128], ok:[200,200,200], err:[255,100,100],\n"
2006 " warn:[200,200,100], link:[180,180,255],\n"
2007 " pad:[10,10,10], padSharp:[5,5,5], padLine:[40,40,40],\n"
2008 " cursor:[255,255,255] },\n"
2009 " light: { bg:[255,255,255], bgAlt:[245,245,245], bgDim:[235,235,235],\n"
2010 " fg:0, fgDim:80, fgMute:160,\n"
2011 " bar:[240,240,240], border:[180,180,180],\n"
2012 " accent:[100,100,100], ok:[40,40,40], err:[180,40,40],\n"
2013 " warn:[120,100,20], link:[40,40,180],\n"
2014 " pad:[245,245,245], padSharp:[230,230,230], padLine:[200,200,200],\n"
2015 " cursor:[0,0,0] }\n"
2016 " },\n"
2017 " neo: {\n"
2018 " label: 'neo', desc: 'lime & black',\n"
2019 " dark: { bg:[0,0,0], bgAlt:[5,10,5], bgDim:[0,0,0],\n"
2020 " fg:200, fgDim:120, fgMute:60,\n"
2021 " bar:[5,15,5], border:[0,80,0],\n"
2022 " accent:[0,200,80], ok:[0,255,0], err:[255,50,50],\n"
2023 " warn:[200,255,0], link:[0,180,255],\n"
2024 " pad:[5,10,5], padSharp:[0,5,0], padLine:[0,50,0],\n"
2025 " cursor:[0,255,80] },\n"
2026 " light: { bg:[220,255,220], bgAlt:[230,255,230], bgDim:[200,240,200],\n"
2027 " fg:10, fgDim:60, fgMute:120,\n"
2028 " bar:[200,240,200], border:[100,180,100],\n"
2029 " accent:[0,140,60], ok:[0,120,40], err:[180,30,30],\n"
2030 " warn:[120,140,0], link:[0,80,180],\n"
2031 " pad:[210,245,210], padSharp:[190,230,190], padLine:[140,200,140],\n"
2032 " cursor:[0,120,40] }\n"
2033 " },\n"
2034 " ember: {\n"
2035 " label: 'ember', desc: 'warm amber',\n"
2036 " dark: { bg:[20,12,8], bgAlt:[28,18,12], bgDim:[14,8,5],\n"
2037 " fg:220, fgDim:150, fgMute:90,\n"
2038 " bar:[35,20,12], border:[60,35,20],\n"
2039 " accent:[255,140,40], ok:[120,220,80], err:[255,70,50],\n"
2040 " warn:[255,200,60], link:[255,180,100],\n"
2041 " pad:[28,18,12], padSharp:[18,10,6], padLine:[55,35,22],\n"
2042 " cursor:[255,120,30] },\n"
2043 " light: { bg:[255,245,230], bgAlt:[255,250,240], bgDim:[245,235,218],\n"
2044 " fg:40, fgDim:90, fgMute:140,\n"
2045 " bar:[245,232,215], border:[210,190,160],\n"
2046 " accent:[200,100,20], ok:[40,140,50], err:[190,40,30],\n"
2047 " warn:[180,120,20], link:[180,90,20],\n"
2048 " pad:[250,240,225], padSharp:[238,225,208], padLine:[220,200,175],\n"
2049 " cursor:[200,90,15] }\n"
2050 " }\n"
2051 " };\n"
2052 " // Apply a named preset (or 'default' to clear override)\n"
2053 " t.apply = function(id) {\n"
2054 " if (!id || id === 'default') {\n"
2055 " t._overrideId = null;\n"
2056 " t._override = null;\n"
2057 " } else if (t.presets[id]) {\n"
2058 " t._overrideId = id;\n"
2059 " t._override = t.presets[id];\n"
2060 " }\n"
2061 " t._lastCheck = 0;\n"
2062 " t.update();\n"
2063 " };\n"
2064 " t.update = function() {\n"
2065 " var now = Date.now();\n"
2066 " if (now - t._lastCheck < 5000) return t;\n"
2067 " t._lastCheck = now;\n"
2068 " var h = getLAHour();\n"
2069 " t.dark = (t._forceDark !== undefined) ? !!t._forceDark : (h >= 20 || h < 7);\n"
2070 " t.hour = h;\n"
2071 " // Backgrounds\n"
2072 " t.bg = t.dark ? [20, 20, 25] : [240, 238, 232];\n"
2073 " t.bgAlt = t.dark ? [28, 28, 30] : [250, 248, 244];\n"
2074 " t.bgDim = t.dark ? [15, 15, 18] : [230, 228, 222];\n"
2075 " // Foregrounds\n"
2076 " t.fg = t.dark ? 220 : 40;\n"
2077 " t.fgDim = t.dark ? 140 : 100;\n"
2078 " t.fgMute = t.dark ? 80 : 150;\n"
2079 " // UI elements\n"
2080 " t.bar = t.dark ? [35, 20, 30] : [225, 220, 215];\n"
2081 " t.border = t.dark ? [55, 35, 45] : [200, 195, 190];\n"
2082 " t.accent = t.dark ? [200, 100, 140] : [180, 60, 120];\n"
2083 " t.ok = t.dark ? [80, 255, 120] : [30, 160, 60];\n"
2084 " t.err = t.dark ? [255, 85, 85] : [200, 40, 40];\n"
2085 " t.warn = t.dark ? [255, 200, 60] : [180, 120, 20];\n"
2086 " t.link = t.dark ? [120, 200, 255] : [40, 100, 200];\n"
2087 " // Pad colors (for notepat-style grids)\n"
2088 " t.pad = t.dark ? [28, 28, 30] : [250, 248, 244];\n"
2089 " t.padSharp = t.dark ? [18, 18, 20] : [235, 232, 228];\n"
2090 " t.padLine = t.dark ? [50, 50, 55] : [210, 205, 200];\n"
2091 " // Cursor\n"
2092 " t.cursor = t.dark ? [220, 80, 140] : [180, 50, 110];\n"
2093 " // Apply preset override if active\n"
2094 " if (t._override) {\n"
2095 " var m = t.dark ? t._override.dark : t._override.light;\n"
2096 " if (m) { for (var k in m) { t[k] = m[k]; } }\n"
2097 " }\n"
2098 " return t;\n"
2099 " };\n"
2100 " t.update();\n"
2101 " return t;\n"
2102 "})();\n"
2103 ;
2104
2105ACRuntime *js_init(ACGraph *graph, ACInput *input, ACAudio *audio, ACWifi *wifi, ACTts *tts) {
2106 ACRuntime *rt = calloc(1, sizeof(ACRuntime));
2107 if (!rt) return NULL;
2108
2109 rt->graph = graph;
2110 rt->input = input;
2111 rt->audio = audio;
2112 rt->wifi = wifi;
2113 rt->tts = tts;
2114 rt->ws = ws_create();
2115 rt->udp = udp_create();
2116 rt->boot_fn = JS_UNDEFINED;
2117 rt->paint_fn = JS_UNDEFINED;
2118 rt->act_fn = JS_UNDEFINED;
2119 rt->sim_fn = JS_UNDEFINED;
2120 rt->leave_fn = JS_UNDEFINED;
2121 rt->beat_fn = JS_UNDEFINED;
2122
2123 // Initialize 3D camera
2124 camera3d_init(&rt->camera3d);
2125
2126 rt->rt = JS_NewRuntime();
2127 rt->ctx = JS_NewContext(rt->rt);
2128
2129 // Register Form and Painting classes
2130 JS_NewClassID(&form_class_id);
2131 JS_NewClass(rt->rt, form_class_id, &form_class_def);
2132 JS_NewClassID(&painting_class_id);
2133 JS_NewClass(rt->rt, painting_class_id, &painting_class_def);
2134
2135 // Set module loader
2136 JS_SetModuleLoaderFunc(rt->rt, NULL, js_module_loader, NULL);
2137
2138 current_rt = rt;
2139
2140 JSContext *ctx = rt->ctx;
2141 JSValue global = JS_GetGlobalObject(ctx);
2142
2143 // Create the chainable paint API object (with 3D .form() and .ink() support)
2144 JSValue paint_api = JS_NewObject(ctx);
2145 JS_SetPropertyStr(ctx, paint_api, "box", JS_NewCFunction(ctx, js_box, "box", 5));
2146 JS_SetPropertyStr(ctx, paint_api, "line", JS_NewCFunction(ctx, js_line, "line", 4));
2147 JS_SetPropertyStr(ctx, paint_api, "circle", JS_NewCFunction(ctx, js_circle, "circle", 4));
2148 JS_SetPropertyStr(ctx, paint_api, "plot", JS_NewCFunction(ctx, js_plot, "plot", 2));
2149 JS_SetPropertyStr(ctx, paint_api, "write", JS_NewCFunction(ctx, js_chain_write, "write", 6));
2150 JS_SetPropertyStr(ctx, paint_api, "scroll", JS_NewCFunction(ctx, js_scroll, "scroll", 2));
2151 JS_SetPropertyStr(ctx, paint_api, "blur", JS_NewCFunction(ctx, js_blur, "blur", 1));
2152 JS_SetPropertyStr(ctx, paint_api, "zoom", JS_NewCFunction(ctx, js_zoom, "zoom", 1));
2153 JS_SetPropertyStr(ctx, paint_api, "contrast", JS_NewCFunction(ctx, js_contrast, "contrast", 1));
2154 JS_SetPropertyStr(ctx, paint_api, "spin", JS_NewCFunction(ctx, js_spin, "spin", 1));
2155 JS_SetPropertyStr(ctx, paint_api, "qr", JS_NewCFunction(ctx, js_qr, "qr", 4));
2156 // 3D chain methods
2157 JS_SetPropertyStr(ctx, paint_api, "form", JS_NewCFunction(ctx, js_chain_form, "form", 1));
2158 JS_SetPropertyStr(ctx, paint_api, "ink", JS_NewCFunction(ctx, js_chain_ink, "ink", 4));
2159 // pppline — polyline through array of {x,y} points (used by line.mjs for 1px strokes)
2160 {
2161 const char *pppline_src =
2162 "(function(pts) {\n"
2163 " if (!pts || pts.length < 2) return;\n"
2164 " for (var i = 0; i < pts.length - 1; i++)\n"
2165 " line(pts[i].x, pts[i].y, pts[i+1].x, pts[i+1].y);\n"
2166 "})";
2167 JSValue pppline_fn = JS_Eval(ctx, pppline_src, strlen(pppline_src), "<pppline>", JS_EVAL_TYPE_GLOBAL);
2168 JS_SetPropertyStr(ctx, paint_api, "pppline", pppline_fn);
2169 }
2170 JS_SetPropertyStr(ctx, global, "__paintApi", JS_DupValue(ctx, paint_api));
2171 JS_FreeValue(ctx, paint_api);
2172
2173 // Register top-level graphics functions
2174 JS_SetPropertyStr(ctx, global, "wipe", JS_NewCFunction(ctx, js_wipe, "wipe", 3));
2175 JS_SetPropertyStr(ctx, global, "ink", JS_NewCFunction(ctx, js_ink, "ink", 4));
2176 JS_SetPropertyStr(ctx, global, "line", JS_NewCFunction(ctx, js_line, "line", 5));
2177 JS_SetPropertyStr(ctx, global, "box", JS_NewCFunction(ctx, js_box, "box", 5));
2178 JS_SetPropertyStr(ctx, global, "circle", JS_NewCFunction(ctx, js_circle, "circle", 4));
2179 JS_SetPropertyStr(ctx, global, "qr", JS_NewCFunction(ctx, js_qr, "qr", 4));
2180 JS_SetPropertyStr(ctx, global, "plot", JS_NewCFunction(ctx, js_plot, "plot", 2));
2181 JS_SetPropertyStr(ctx, global, "write", JS_NewCFunction(ctx, js_write, "write", 6));
2182 JS_SetPropertyStr(ctx, global, "scroll", JS_NewCFunction(ctx, js_scroll, "scroll", 2));
2183 JS_SetPropertyStr(ctx, global, "blur", JS_NewCFunction(ctx, js_blur, "blur", 1));
2184 JS_SetPropertyStr(ctx, global, "zoom", JS_NewCFunction(ctx, js_zoom, "zoom", 1));
2185 JS_SetPropertyStr(ctx, global, "contrast", JS_NewCFunction(ctx, js_contrast, "contrast", 1));
2186 JS_SetPropertyStr(ctx, global, "spin", JS_NewCFunction(ctx, js_spin, "spin", 1));
2187
2188 // console.log
2189 JSValue console_obj = JS_NewObject(ctx);
2190 JS_SetPropertyStr(ctx, console_obj, "log", JS_NewCFunction(ctx, js_console_log, "log", 1));
2191 JS_SetPropertyStr(ctx, console_obj, "warn", JS_NewCFunction(ctx, js_console_log, "warn", 1));
2192 JS_SetPropertyStr(ctx, console_obj, "error", JS_NewCFunction(ctx, js_console_log, "error", 1));
2193 JS_SetPropertyStr(ctx, global, "console", console_obj);
2194
2195 // performance.now()
2196 {
2197 JSValue perf = JS_NewObject(ctx);
2198 JS_SetPropertyStr(ctx, perf, "now", JS_NewCFunction(ctx, js_performance_now, "now", 0));
2199 JS_SetPropertyStr(ctx, global, "performance", perf);
2200 }
2201
2202 // Run init JS code (Button, Box classes)
2203 JSValue init_result = JS_Eval(ctx, js_init_code, strlen(js_init_code), "<init>", JS_EVAL_TYPE_GLOBAL);
2204 if (JS_IsException(init_result)) {
2205 JSValue exc = JS_GetException(ctx);
2206 const char *str = JS_ToCString(ctx, exc);
2207 fprintf(stderr, "[js] Init code error: %s\n", str);
2208 JS_FreeCString(ctx, str);
2209 JS_FreeValue(ctx, exc);
2210 }
2211 JS_FreeValue(ctx, init_result);
2212
2213 // Browser API stubs for KidLisp evaluator compatibility
2214 static const char *browser_stubs =
2215 "if(typeof window==='undefined'){"
2216 " globalThis.window={"
2217 " location:{href:'',origin:'',hostname:'localhost',pathname:'/',search:'',hash:''},"
2218 " history:{pushState:function(){},replaceState:function(){}},"
2219 " matchMedia:function(){return{matches:false,addEventListener:function(){}}},"
2220 " addEventListener:function(){},"
2221 " postMessage:function(){},"
2222 " navigator:{userAgent:'ac-native'},"
2223 " document:{createElement:function(){return{style:{},getContext:function(){return{}},setAttribute:function(){}}}},"
2224 " origin:''"
2225 " };"
2226 " globalThis.document=window.document;"
2227 " globalThis.navigator=window.navigator;"
2228 " globalThis.localStorage={_s:{},getItem:function(k){return this._s[k]||null},setItem:function(k,v){this._s[k]=v},removeItem:function(k){delete this._s[k]}};"
2229 " globalThis.fetch=function(){return Promise.resolve({ok:false,json:function(){return Promise.resolve({})}})};"
2230 " globalThis.indexedDB=null;"
2231 " globalThis.requestAnimationFrame=function(cb){cb(performance.now());return 0};"
2232 " globalThis.cancelAnimationFrame=function(){};"
2233 " globalThis.Image=function(){this.src='';this.onload=null};"
2234 "}";
2235 JSValue stubs_result = JS_Eval(ctx, browser_stubs, strlen(browser_stubs), "<browser-stubs>", JS_EVAL_TYPE_GLOBAL);
2236 if (JS_IsException(stubs_result)) {
2237 JSValue exc = JS_GetException(ctx);
2238 const char *str = JS_ToCString(ctx, exc);
2239 fprintf(stderr, "[js] Browser stubs error: %s\n", str);
2240 JS_FreeCString(ctx, str);
2241 JS_FreeValue(ctx, exc);
2242 }
2243 JS_FreeValue(ctx, stubs_result);
2244
2245 // Load KidLisp evaluator bundle (IIFE → globalThis.KidLispModule)
2246 FILE *kl_f = fopen("/jslib/kidlisp-bundle.js", "r");
2247 if (kl_f) {
2248 fseek(kl_f, 0, SEEK_END);
2249 long kl_len = ftell(kl_f);
2250 fseek(kl_f, 0, SEEK_SET);
2251 char *kl_src = malloc(kl_len + 1);
2252 fread(kl_src, 1, kl_len, kl_f);
2253 kl_src[kl_len] = '\0';
2254 fclose(kl_f);
2255 JSValue kl_result = JS_Eval(ctx, kl_src, kl_len, "<kidlisp-bundle>", JS_EVAL_TYPE_GLOBAL);
2256 free(kl_src);
2257 if (JS_IsException(kl_result)) {
2258 JSValue exc = JS_GetException(ctx);
2259 const char *str = JS_ToCString(ctx, exc);
2260 fprintf(stderr, "[js] KidLisp bundle error: %s\n", str);
2261 JS_FreeCString(ctx, str);
2262 JS_FreeValue(ctx, exc);
2263 } else {
2264 fprintf(stderr, "[js] KidLisp evaluator loaded (%ld bytes)\n", kl_len);
2265 }
2266 JS_FreeValue(ctx, kl_result);
2267 } else {
2268 fprintf(stderr, "[js] KidLisp bundle not found at /jslib/kidlisp-bundle.js (optional)\n");
2269 }
2270
2271 // Load FPS system bundle (Camera + Dolly + CamDoll as globalThis.__FpsSystem)
2272 // so pieces that export `system = "fps"` (arena.mjs etc.) can be wrapped
2273 // synchronously in the piece-load shim without async module resolution.
2274 FILE *fps_f = fopen("/jslib/fps-system-bundle.js", "r");
2275 if (fps_f) {
2276 fseek(fps_f, 0, SEEK_END);
2277 long fps_len = ftell(fps_f);
2278 fseek(fps_f, 0, SEEK_SET);
2279 char *fps_src = malloc(fps_len + 1);
2280 fread(fps_src, 1, fps_len, fps_f);
2281 fps_src[fps_len] = '\0';
2282 fclose(fps_f);
2283 JSValue fps_result = JS_Eval(ctx, fps_src, fps_len, "<fps-system-bundle>", JS_EVAL_TYPE_GLOBAL);
2284 free(fps_src);
2285 if (JS_IsException(fps_result)) {
2286 JSValue exc = JS_GetException(ctx);
2287 const char *str = JS_ToCString(ctx, exc);
2288 fprintf(stderr, "[js] FPS bundle error: %s\n", str);
2289 JS_FreeCString(ctx, str);
2290 JS_FreeValue(ctx, exc);
2291 } else {
2292 fprintf(stderr, "[js] FPS system loaded (%ld bytes)\n", fps_len);
2293 }
2294 JS_FreeValue(ctx, fps_result);
2295 } else {
2296 fprintf(stderr, "[js] FPS bundle not found at /jslib/fps-system-bundle.js (optional)\n");
2297 }
2298
2299 JS_FreeValue(ctx, global);
2300 return rt;
2301}
2302
2303// painting(w, h, callback) — create a real off-screen framebuffer for textures
2304// Calls the callback with a paint API that draws into the off-screen buffer
2305static JSValue js_painting(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2306 (void)this_val;
2307 int w = 100, h = 100;
2308 if (argc >= 1) JS_ToInt32(ctx, &w, argv[0]);
2309 if (argc >= 2) JS_ToInt32(ctx, &h, argv[1]);
2310
2311 // Create a real off-screen framebuffer
2312 ACFramebuffer *tex_fb = fb_create(w, h);
2313 if (!tex_fb) return JS_EXCEPTION;
2314
2315 // Create JS painting object with opaque ACFramebuffer*
2316 JSValue painting = JS_NewObjectClass(ctx, painting_class_id);
2317 JS_SetOpaque(painting, tex_fb);
2318 JS_SetPropertyStr(ctx, painting, "width", JS_NewInt32(ctx, w));
2319 JS_SetPropertyStr(ctx, painting, "height", JS_NewInt32(ctx, h));
2320
2321 // Expose pixels as a Uint8Array view into the framebuffer memory.
2322 // Nopaint uses buffer.pixels[i] to adjust alpha during bake.
2323 // Note: pixel format is ARGB32 (native byte order).
2324 {
2325 int byte_len = tex_fb->stride * h * 4;
2326 JSValue ab = JS_NewArrayBuffer(ctx, (uint8_t *)tex_fb->pixels, byte_len,
2327 NULL, NULL, 0); // No free — painting finalizer handles the framebuffer
2328 // Construct Uint8Array from the ArrayBuffer via JS eval
2329 JSValue global = JS_GetGlobalObject(ctx);
2330 JSValue uint8_ctor = JS_GetPropertyStr(ctx, global, "Uint8Array");
2331 JSValue pixels = JS_CallConstructor(ctx, uint8_ctor, 1, &ab);
2332 JS_SetPropertyStr(ctx, painting, "pixels", pixels);
2333 JS_FreeValue(ctx, uint8_ctor);
2334 JS_FreeValue(ctx, global);
2335 JS_FreeValue(ctx, ab);
2336 }
2337
2338 // If callback provided, render into the off-screen buffer
2339 if (argc >= 3 && JS_IsFunction(ctx, argv[2])) {
2340 // Save current render target and switch to off-screen
2341 ACFramebuffer *saved_fb = current_rt->graph->fb;
2342 graph_page(current_rt->graph, tex_fb);
2343
2344 JSValue global = JS_GetGlobalObject(ctx);
2345 JSValue paint_api = JS_NewObject(ctx);
2346 JS_SetPropertyStr(ctx, paint_api, "wipe", JS_GetPropertyStr(ctx, global, "wipe"));
2347 JS_SetPropertyStr(ctx, paint_api, "ink", JS_GetPropertyStr(ctx, global, "ink"));
2348 JS_SetPropertyStr(ctx, paint_api, "box", JS_GetPropertyStr(ctx, global, "box"));
2349 JS_SetPropertyStr(ctx, paint_api, "line", JS_GetPropertyStr(ctx, global, "line"));
2350 JS_SetPropertyStr(ctx, paint_api, "circle", JS_GetPropertyStr(ctx, global, "circle"));
2351 JS_SetPropertyStr(ctx, paint_api, "plot", JS_GetPropertyStr(ctx, global, "plot"));
2352 JS_SetPropertyStr(ctx, paint_api, "write", JS_GetPropertyStr(ctx, global, "write"));
2353 JS_SetPropertyStr(ctx, paint_api, "kidlisp", JS_NewCFunction(ctx, js_noop, "kidlisp", 5));
2354 JS_FreeValue(ctx, global);
2355
2356 JSValue result = JS_Call(ctx, argv[2], JS_UNDEFINED, 1, &paint_api);
2357 JS_FreeValue(ctx, result);
2358 JS_FreeValue(ctx, paint_api);
2359
2360 // Restore previous render target
2361 graph_page(current_rt->graph, saved_fb);
2362 }
2363
2364 return painting;
2365}
2366
2367// page(painting) — switch render target to a painting buffer (or back to screen)
2368// page(painting) returns an object with wipe/ink/box/line/etc that draw into that buffer.
2369// In AC web, page() returns a chainable proxy. Here we just switch the graph target.
2370static JSValue js_page(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2371 (void)this_val;
2372 if (!current_rt || !current_rt->graph) return JS_UNDEFINED;
2373
2374 if (argc < 1 || JS_IsUndefined(argv[0]) || JS_IsNull(argv[0])) {
2375 // page() with no args or page(screen) — restore screen target
2376 graph_page(current_rt->graph, current_rt->graph->screen);
2377 } else {
2378 // page(painting) — switch to painting buffer
2379 ACFramebuffer *fb = JS_GetOpaque(argv[0], painting_class_id);
2380 if (fb) graph_page(current_rt->graph, fb);
2381 }
2382
2383 // Return a page proxy object with wipe() so callers can do page(buf).wipe(...)
2384 JSValue proxy = JS_NewObject(ctx);
2385 JSValue global = JS_GetGlobalObject(ctx);
2386 JS_SetPropertyStr(ctx, proxy, "wipe", JS_GetPropertyStr(ctx, global, "wipe"));
2387 JS_SetPropertyStr(ctx, proxy, "ink", JS_GetPropertyStr(ctx, global, "ink"));
2388 JS_SetPropertyStr(ctx, proxy, "box", JS_GetPropertyStr(ctx, global, "box"));
2389 JS_SetPropertyStr(ctx, proxy, "line", JS_GetPropertyStr(ctx, global, "line"));
2390 JS_SetPropertyStr(ctx, proxy, "circle", JS_GetPropertyStr(ctx, global, "circle"));
2391 JS_SetPropertyStr(ctx, proxy, "plot", JS_GetPropertyStr(ctx, global, "plot"));
2392 JS_SetPropertyStr(ctx, proxy, "write", JS_GetPropertyStr(ctx, global, "write"));
2393 JS_FreeValue(ctx, global);
2394 return proxy;
2395}
2396
2397// paste(painting, dx?, dy?) — alpha-composite a painting onto the current render target
2398static JSValue js_paste(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2399 (void)this_val;
2400 if (!current_rt || !current_rt->graph) return JS_UNDEFINED;
2401 if (argc < 1) return JS_UNDEFINED;
2402
2403 ACFramebuffer *src = JS_GetOpaque(argv[0], painting_class_id);
2404 if (!src) return JS_UNDEFINED;
2405
2406 int dx = 0, dy = 0;
2407 if (argc >= 2) JS_ToInt32(ctx, &dx, argv[1]);
2408 if (argc >= 3) JS_ToInt32(ctx, &dy, argv[2]);
2409
2410 graph_paste(current_rt->graph, src, dx, dy);
2411 return JS_UNDEFINED;
2412}
2413
2414// sound.bpm(val?) — get or set BPM, returns current value
2415static JSValue js_sound_bpm(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2416 (void)this_val;
2417 ACAudio *audio = current_rt->audio;
2418 if (!audio) return JS_NewFloat64(ctx, 120.0);
2419 if (argc >= 1 && JS_IsNumber(argv[0])) {
2420 double val;
2421 JS_ToFloat64(ctx, &val, argv[0]);
2422 if (val > 0) audio->bpm = val;
2423 }
2424 return JS_NewFloat64(ctx, audio->bpm);
2425}
2426
2427// --- DJ deck bindings ---
2428
2429static void blit_argb_nearest(ACFramebuffer *fb, const uint32_t *src,
2430 int sw, int sh, int dx, int dy, int dw, int dh) {
2431 if (!fb || !src || sw <= 0 || sh <= 0 || dw <= 0 || dh <= 0) return;
2432
2433 int x0 = dx < 0 ? 0 : dx;
2434 int y0 = dy < 0 ? 0 : dy;
2435 int x1 = dx + dw;
2436 int y1 = dy + dh;
2437 if (x1 > fb->width) x1 = fb->width;
2438 if (y1 > fb->height) y1 = fb->height;
2439 if (x0 >= x1 || y0 >= y1) return;
2440
2441 for (int y = y0; y < y1; y++) {
2442 int sy = (int)(((int64_t)(y - dy) * sh) / dh);
2443 if (sy < 0) sy = 0;
2444 if (sy >= sh) sy = sh - 1;
2445 const uint32_t *src_row = src + sy * sw;
2446 uint32_t *dst_row = fb->pixels + y * fb->stride;
2447 for (int x = x0; x < x1; x++) {
2448 int sx = (int)(((int64_t)(x - dx) * sw) / dw);
2449 if (sx < 0) sx = 0;
2450 if (sx >= sw) sx = sw - 1;
2451 uint32_t px = src_row[sx];
2452 if ((px >> 24) == 0) continue;
2453 dst_row[x] = px;
2454 }
2455 }
2456}
2457
2458// sound.deck.load(deck, path) -> true/false
2459static JSValue js_deck_load(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2460 (void)this_val;
2461 if (argc < 2 || !current_rt || !current_rt->audio) return JS_FALSE;
2462 int deck;
2463 JS_ToInt32(ctx, &deck, argv[0]);
2464 const char *path = JS_ToCString(ctx, argv[1]);
2465 if (!path) return JS_FALSE;
2466 int ret = audio_deck_load(current_rt->audio, deck, path);
2467 JS_FreeCString(ctx, path);
2468 return JS_NewBool(ctx, ret == 0);
2469}
2470
2471// sound.deck.play(deck)
2472static JSValue js_deck_play(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2473 (void)this_val;
2474 if (argc < 1 || !current_rt || !current_rt->audio) return JS_UNDEFINED;
2475 int deck; JS_ToInt32(ctx, &deck, argv[0]);
2476 audio_deck_play(current_rt->audio, deck);
2477 return JS_UNDEFINED;
2478}
2479
2480// sound.deck.pause(deck)
2481static JSValue js_deck_pause(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2482 (void)this_val;
2483 if (argc < 1 || !current_rt || !current_rt->audio) return JS_UNDEFINED;
2484 int deck; JS_ToInt32(ctx, &deck, argv[0]);
2485 audio_deck_pause(current_rt->audio, deck);
2486 return JS_UNDEFINED;
2487}
2488
2489// sound.deck.seek(deck, seconds)
2490static JSValue js_deck_seek(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2491 (void)this_val;
2492 if (argc < 2 || !current_rt || !current_rt->audio) return JS_UNDEFINED;
2493 int deck; JS_ToInt32(ctx, &deck, argv[0]);
2494 double sec; JS_ToFloat64(ctx, &sec, argv[1]);
2495 audio_deck_seek(current_rt->audio, deck, sec);
2496 return JS_UNDEFINED;
2497}
2498
2499// sound.deck.setSpeed(deck, speed)
2500static JSValue js_deck_set_speed(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2501 (void)this_val;
2502 if (argc < 2 || !current_rt || !current_rt->audio) return JS_UNDEFINED;
2503 int deck; JS_ToInt32(ctx, &deck, argv[0]);
2504 double spd; JS_ToFloat64(ctx, &spd, argv[1]);
2505 audio_deck_set_speed(current_rt->audio, deck, spd);
2506 return JS_UNDEFINED;
2507}
2508
2509// sound.deck.getPeaks(deck) — returns Float32Array of normalized peak amplitudes
2510static JSValue js_deck_get_peaks(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2511 (void)this_val;
2512 if (argc < 1 || !current_rt || !current_rt->audio) return JS_NULL;
2513 int deck; JS_ToInt32(ctx, &deck, argv[0]);
2514 if (deck < 0 || deck >= AUDIO_MAX_DECKS) return JS_NULL;
2515 ACDeck *dk = ¤t_rt->audio->decks[deck];
2516 if (!dk->decoder || !dk->decoder->peaks || dk->decoder->peak_count <= 0) return JS_NULL;
2517
2518 // Build a JS array from the peak data
2519 JSValue arr = JS_NewArray(ctx);
2520 for (int i = 0; i < dk->decoder->peak_count; i++) {
2521 JS_SetPropertyUint32(ctx, arr, i, JS_NewFloat64(ctx, dk->decoder->peaks[i]));
2522 }
2523 return arr;
2524}
2525
2526// sound.deck.prepareVideo(deck[, width, height, fps]) — decode a low-res preview strip
2527static JSValue js_deck_prepare_video(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2528 (void)this_val;
2529 if (argc < 1 || !current_rt || !current_rt->audio) return JS_FALSE;
2530 int deck = 0, width = 96, height = 54, fps = 12;
2531 JS_ToInt32(ctx, &deck, argv[0]);
2532 if (argc > 1) JS_ToInt32(ctx, &width, argv[1]);
2533 if (argc > 2) JS_ToInt32(ctx, &height, argv[2]);
2534 if (argc > 3) JS_ToInt32(ctx, &fps, argv[3]);
2535 if (deck < 0 || deck >= AUDIO_MAX_DECKS) return JS_FALSE;
2536 ACDeck *dk = ¤t_rt->audio->decks[deck];
2537 if (!dk->decoder) return JS_FALSE;
2538 int ret = deck_decoder_generate_video_preview(dk->decoder, width, height, fps);
2539 return JS_NewBool(ctx, ret > 0);
2540}
2541
2542// sound.deck.videoBlit(deck, x, y, w, h) — draw current preview frame into the framebuffer
2543static JSValue js_deck_video_blit(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2544 (void)this_val;
2545 if (argc < 5 || !current_rt || !current_rt->audio || !current_rt->graph || !current_rt->graph->fb)
2546 return JS_FALSE;
2547
2548 int deck = 0, x = 0, y = 0, w = 0, h = 0;
2549 JS_ToInt32(ctx, &deck, argv[0]);
2550 JS_ToInt32(ctx, &x, argv[1]);
2551 JS_ToInt32(ctx, &y, argv[2]);
2552 JS_ToInt32(ctx, &w, argv[3]);
2553 JS_ToInt32(ctx, &h, argv[4]);
2554 if (deck < 0 || deck >= AUDIO_MAX_DECKS) return JS_FALSE;
2555
2556 ACDeck *dk = ¤t_rt->audio->decks[deck];
2557 if (!dk->decoder || !dk->decoder->video_ready || !dk->decoder->video_frames ||
2558 dk->decoder->video_frame_count <= 0 || dk->decoder->video_width <= 0 ||
2559 dk->decoder->video_height <= 0 || dk->decoder->video_fps <= 0.0) {
2560 return JS_FALSE;
2561 }
2562
2563 if (w <= 0) w = dk->decoder->video_width;
2564 if (h <= 0) h = dk->decoder->video_height;
2565
2566 int idx = (int)floor(dk->decoder->position * dk->decoder->video_fps + 0.0001);
2567 if (idx < 0) idx = 0;
2568 if (idx >= dk->decoder->video_frame_count) idx = dk->decoder->video_frame_count - 1;
2569
2570 size_t pixels_per_frame = (size_t)dk->decoder->video_width * (size_t)dk->decoder->video_height;
2571 const uint32_t *src = dk->decoder->video_frames + ((size_t)idx * pixels_per_frame);
2572 blit_argb_nearest(current_rt->graph->fb, src,
2573 dk->decoder->video_width, dk->decoder->video_height,
2574 x, y, w, h);
2575 return JS_TRUE;
2576}
2577
2578// sound.deck.setVolume(deck, vol)
2579static JSValue js_deck_set_volume(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2580 (void)this_val;
2581 if (argc < 2 || !current_rt || !current_rt->audio) return JS_UNDEFINED;
2582 int deck; JS_ToInt32(ctx, &deck, argv[0]);
2583 double vol; JS_ToFloat64(ctx, &vol, argv[1]);
2584 audio_deck_set_volume(current_rt->audio, deck, (float)vol);
2585 return JS_UNDEFINED;
2586}
2587
2588// sound.deck.setCrossfader(value)
2589static JSValue js_deck_set_crossfader(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2590 (void)this_val;
2591 if (argc < 1 || !current_rt || !current_rt->audio) return JS_UNDEFINED;
2592 double val; JS_ToFloat64(ctx, &val, argv[0]);
2593 audio_deck_set_crossfader(current_rt->audio, (float)val);
2594 return JS_UNDEFINED;
2595}
2596
2597// sound.deck.setMasterVolume(value)
2598static JSValue js_deck_set_master_vol(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2599 (void)this_val;
2600 if (argc < 1 || !current_rt || !current_rt->audio) return JS_UNDEFINED;
2601 double val; JS_ToFloat64(ctx, &val, argv[0]);
2602 audio_deck_set_master_volume(current_rt->audio, (float)val);
2603 return JS_UNDEFINED;
2604}
2605
2606// Build the sound object for the API
2607static JSValue build_sound_obj(JSContext *ctx, ACRuntime *rt) {
2608 JSValue sound = JS_NewObject(ctx);
2609
2610 // Core synthesis functions
2611 JS_SetPropertyStr(ctx, sound, "synth", JS_NewCFunction(ctx, js_synth, "synth", 1));
2612 JS_SetPropertyStr(ctx, sound, "freq", JS_NewCFunction(ctx, js_sound_freq, "freq", 1));
2613 JS_SetPropertyStr(ctx, sound, "play", JS_NewCFunction(ctx, js_noop, "play", 2));
2614 JS_SetPropertyStr(ctx, sound, "kill", JS_NewCFunction(ctx, js_sound_kill, "kill", 2));
2615 JS_SetPropertyStr(ctx, sound, "update", JS_NewCFunction(ctx, js_sound_update, "update", 2));
2616 JS_SetPropertyStr(ctx, sound, "bpm", JS_NewCFunction(ctx, js_sound_bpm, "bpm", 1));
2617 JS_SetPropertyStr(ctx, sound, "time", JS_NewFloat64(ctx, rt->audio ? rt->audio->time : 0.0));
2618 JS_SetPropertyStr(ctx, sound, "registerSample", JS_NewCFunction(ctx, js_noop, "registerSample", 3));
2619
2620 // speaker sub-object
2621 JSValue speaker = JS_NewObject(ctx);
2622 JS_SetPropertyStr(ctx, speaker, "poll", JS_NewCFunction(ctx, js_noop, "poll", 0));
2623 JS_SetPropertyStr(ctx, speaker, "getRecentBuffer",
2624 JS_NewCFunction(ctx, js_speaker_get_recent_buffer, "getRecentBuffer", 1));
2625 JS_SetPropertyStr(ctx, speaker, "sampleRate",
2626 JS_NewInt32(ctx, rt->audio ? (int)rt->audio->actual_rate : AUDIO_SAMPLE_RATE));
2627
2628 // sound.tape — read-only tape recorder state. The actual start/stop
2629 // is driven by the PrintScreen key handler in ac-native.c (which
2630 // owns the MP4 file lifecycle, TTS announce, on-screen overlay, and
2631 // cloud upload). JS just observes.
2632 JSValue tape = JS_NewObject(ctx);
2633 JS_SetPropertyStr(ctx, tape, "recording",
2634 JS_NewCFunction(ctx, js_tape_recording, "recording", 0));
2635 JS_SetPropertyStr(ctx, tape, "elapsed",
2636 JS_NewCFunction(ctx, js_tape_elapsed, "elapsed", 0));
2637 JS_SetPropertyStr(ctx, sound, "tape", tape);
2638
2639 // waveforms (32 samples, left channel only — sufficient for visualizer)
2640 JSValue waveforms = JS_NewObject(ctx);
2641 JSValue wf_left = JS_NewArray(ctx);
2642 if (rt->audio) {
2643 for (int i = 0; i < 32; i++) {
2644 int idx = (rt->audio->waveform_pos - 32 + i + AUDIO_WAVEFORM_SIZE) % AUDIO_WAVEFORM_SIZE;
2645 JS_SetPropertyUint32(ctx, wf_left, i, JS_NewFloat64(ctx, rt->audio->waveform_left[idx]));
2646 }
2647 }
2648 JS_SetPropertyStr(ctx, waveforms, "left", wf_left);
2649 JS_SetPropertyStr(ctx, speaker, "waveforms", waveforms);
2650
2651 // amplitudes
2652 JSValue amplitudes = JS_NewObject(ctx);
2653 JS_SetPropertyStr(ctx, amplitudes, "left", JS_NewFloat64(ctx, rt->audio ? rt->audio->amplitude_left : 0.0));
2654 JS_SetPropertyStr(ctx, amplitudes, "right", JS_NewFloat64(ctx, rt->audio ? rt->audio->amplitude_right : 0.0));
2655 JS_SetPropertyStr(ctx, speaker, "amplitudes", amplitudes);
2656
2657 // frequencies (stub)
2658 JSValue frequencies = JS_NewObject(ctx);
2659 JS_SetPropertyStr(ctx, frequencies, "left", JS_NewArray(ctx));
2660 JS_SetPropertyStr(ctx, speaker, "frequencies", frequencies);
2661
2662 // beat detection (stub)
2663 JSValue beat = JS_NewObject(ctx);
2664 JS_SetPropertyStr(ctx, beat, "detected", JS_FALSE);
2665 JS_SetPropertyStr(ctx, speaker, "beat", beat);
2666
2667 // System volume (0-100)
2668 JS_SetPropertyStr(ctx, speaker, "systemVolume",
2669 JS_NewInt32(ctx, rt->audio ? rt->audio->system_volume : 100));
2670
2671 JS_SetPropertyStr(ctx, sound, "speaker", speaker);
2672
2673 // room
2674 JSValue room = JS_NewObject(ctx);
2675 JS_SetPropertyStr(ctx, room, "toggle", JS_NewCFunction(ctx, js_room_toggle, "toggle", 0));
2676 JS_SetPropertyStr(ctx, room, "setMix", JS_NewCFunction(ctx, js_set_room_mix, "setMix", 1));
2677 JS_SetPropertyStr(ctx, room, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->room_mix : 0.0));
2678 JS_SetPropertyStr(ctx, room, "set", JS_NewCFunction(ctx, js_noop, "set", 1));
2679 JS_SetPropertyStr(ctx, room, "get", JS_NewCFunction(ctx, js_noop, "get", 0));
2680 JS_SetPropertyStr(ctx, sound, "room", room);
2681
2682 // glitch
2683 JSValue glitch = JS_NewObject(ctx);
2684 JS_SetPropertyStr(ctx, glitch, "toggle", JS_NewCFunction(ctx, js_glitch_toggle, "toggle", 0));
2685 JS_SetPropertyStr(ctx, glitch, "setMix", JS_NewCFunction(ctx, js_set_glitch_mix, "setMix", 1));
2686 JS_SetPropertyStr(ctx, glitch, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->glitch_mix : 0.0));
2687 JS_SetPropertyStr(ctx, glitch, "set", JS_NewCFunction(ctx, js_set_glitch_mix, "set", 1));
2688 JS_SetPropertyStr(ctx, glitch, "get", JS_NewCFunction(ctx, js_noop, "get", 0));
2689 JS_SetPropertyStr(ctx, sound, "glitch", glitch);
2690
2691 // fx (dry/wet for entire FX chain)
2692 JSValue fx = JS_NewObject(ctx);
2693 JS_SetPropertyStr(ctx, fx, "setMix", JS_NewCFunction(ctx, js_set_fx_mix, "setMix", 1));
2694 JS_SetPropertyStr(ctx, fx, "mix", JS_NewFloat64(ctx, rt->audio ? rt->audio->fx_mix : 1.0));
2695 JS_SetPropertyStr(ctx, sound, "fx", fx);
2696
2697 // microphone
2698 JSValue mic = JS_NewObject(ctx);
2699 JS_SetPropertyStr(ctx, mic, "open", JS_NewCFunction(ctx, js_mic_open, "open", 0));
2700 JS_SetPropertyStr(ctx, mic, "close", JS_NewCFunction(ctx, js_mic_close, "close", 0));
2701 JS_SetPropertyStr(ctx, mic, "rec", JS_NewCFunction(ctx, js_mic_rec, "rec", 0));
2702 JS_SetPropertyStr(ctx, mic, "cut", JS_NewCFunction(ctx, js_mic_cut, "cut", 0));
2703 JS_SetPropertyStr(ctx, mic, "recording",
2704 JS_NewBool(ctx, rt->audio ? rt->audio->recording : 0));
2705 JS_SetPropertyStr(ctx, mic, "connected",
2706 JS_NewBool(ctx, rt->audio ? rt->audio->mic_connected : 0));
2707 JS_SetPropertyStr(ctx, mic, "hot",
2708 JS_NewBool(ctx, rt->audio ? rt->audio->mic_hot : 0));
2709 JS_SetPropertyStr(ctx, mic, "sampleLength",
2710 JS_NewInt32(ctx, rt->audio ? rt->audio->sample_len : 0));
2711 JS_SetPropertyStr(ctx, mic, "sampleRate",
2712 JS_NewInt32(ctx, rt->audio ? (int)rt->audio->sample_rate : 0));
2713 JS_SetPropertyStr(ctx, mic, "level",
2714 JS_NewFloat64(ctx, rt->audio ? rt->audio->mic_level : 0.0));
2715 JS_SetPropertyStr(ctx, mic, "lastChunk",
2716 JS_NewInt32(ctx, rt->audio ? rt->audio->mic_last_chunk : 0));
2717 JS_SetPropertyStr(ctx, mic, "device",
2718 JS_NewString(ctx, (rt->audio && rt->audio->mic_device[0]) ? rt->audio->mic_device : "none"));
2719 JS_SetPropertyStr(ctx, mic, "lastError",
2720 JS_NewString(ctx, (rt->audio && rt->audio->mic_last_error[0]) ? rt->audio->mic_last_error : ""));
2721 // Mic level is already exposed as mic.level (single float, no array overhead)
2722 JS_SetPropertyStr(ctx, sound, "microphone", mic);
2723
2724 // sample playback
2725 JSValue samp = JS_NewObject(ctx);
2726 JS_SetPropertyStr(ctx, samp, "play", JS_NewCFunction(ctx, js_sample_play, "play", 1));
2727 JS_SetPropertyStr(ctx, samp, "kill", JS_NewCFunction(ctx, js_sample_kill, "kill", 2));
2728 JS_SetPropertyStr(ctx, samp, "getData", JS_NewCFunction(ctx, js_sample_get_data, "getData", 0));
2729 JS_SetPropertyStr(ctx, samp, "loadData", JS_NewCFunction(ctx, js_sample_load_data, "loadData", 2));
2730 JS_SetPropertyStr(ctx, samp, "saveTo", JS_NewCFunction(ctx, js_sample_save_to, "saveTo", 1));
2731 JS_SetPropertyStr(ctx, samp, "loadFrom", JS_NewCFunction(ctx, js_sample_load_from, "loadFrom", 1));
2732 JS_SetPropertyStr(ctx, sound, "sample", samp);
2733
2734 // dedicated global replay voice/buffer
2735 JSValue replay = JS_NewObject(ctx);
2736 JS_SetPropertyStr(ctx, replay, "play", JS_NewCFunction(ctx, js_replay_play, "play", 1));
2737 JS_SetPropertyStr(ctx, replay, "kill", JS_NewCFunction(ctx, js_replay_kill, "kill", 2));
2738 JS_SetPropertyStr(ctx, replay, "loadData", JS_NewCFunction(ctx, js_replay_load_data, "loadData", 2));
2739 JS_SetPropertyStr(ctx, sound, "replay", replay);
2740
2741 // DJ deck
2742 JSValue deck_obj = JS_NewObject(ctx);
2743 JS_SetPropertyStr(ctx, deck_obj, "load", JS_NewCFunction(ctx, js_deck_load, "load", 2));
2744 JS_SetPropertyStr(ctx, deck_obj, "play", JS_NewCFunction(ctx, js_deck_play, "play", 1));
2745 JS_SetPropertyStr(ctx, deck_obj, "pause", JS_NewCFunction(ctx, js_deck_pause, "pause", 1));
2746 JS_SetPropertyStr(ctx, deck_obj, "seek", JS_NewCFunction(ctx, js_deck_seek, "seek", 2));
2747 JS_SetPropertyStr(ctx, deck_obj, "setSpeed", JS_NewCFunction(ctx, js_deck_set_speed, "setSpeed", 2));
2748 JS_SetPropertyStr(ctx, deck_obj, "setVolume", JS_NewCFunction(ctx, js_deck_set_volume, "setVolume", 2));
2749 JS_SetPropertyStr(ctx, deck_obj, "setCrossfader", JS_NewCFunction(ctx, js_deck_set_crossfader, "setCrossfader", 1));
2750 JS_SetPropertyStr(ctx, deck_obj, "setMasterVolume", JS_NewCFunction(ctx, js_deck_set_master_vol, "setMasterVolume", 1));
2751 JS_SetPropertyStr(ctx, deck_obj, "getPeaks", JS_NewCFunction(ctx, js_deck_get_peaks, "getPeaks", 1));
2752 JS_SetPropertyStr(ctx, deck_obj, "prepareVideo", JS_NewCFunction(ctx, js_deck_prepare_video, "prepareVideo", 4));
2753 JS_SetPropertyStr(ctx, deck_obj, "videoBlit", JS_NewCFunction(ctx, js_deck_video_blit, "videoBlit", 5));
2754
2755 // Deck state (read-only, rebuilt each frame)
2756 JSValue decks_arr = JS_NewArray(ctx);
2757 ACAudio *aud = rt->audio;
2758 for (int d = 0; d < AUDIO_MAX_DECKS; d++) {
2759 JSValue di = JS_NewObject(ctx);
2760 if (aud) {
2761 ACDeck *dk = &aud->decks[d];
2762 JS_SetPropertyStr(ctx, di, "loaded", JS_NewBool(ctx, dk->active));
2763 JS_SetPropertyStr(ctx, di, "playing", JS_NewBool(ctx, dk->playing));
2764 JS_SetPropertyStr(ctx, di, "volume", JS_NewFloat64(ctx, dk->volume));
2765 if (dk->decoder) {
2766 JS_SetPropertyStr(ctx, di, "position", JS_NewFloat64(ctx, dk->decoder->position));
2767 JS_SetPropertyStr(ctx, di, "duration", JS_NewFloat64(ctx, dk->decoder->duration));
2768 JS_SetPropertyStr(ctx, di, "speed", JS_NewFloat64(ctx, dk->decoder->speed));
2769 JS_SetPropertyStr(ctx, di, "title", JS_NewString(ctx, dk->decoder->title));
2770 JS_SetPropertyStr(ctx, di, "artist", JS_NewString(ctx, dk->decoder->artist));
2771 JS_SetPropertyStr(ctx, di, "finished", JS_NewBool(ctx, dk->decoder->finished));
2772 JS_SetPropertyStr(ctx, di, "error", JS_NewString(ctx, dk->decoder->error ? dk->decoder->error_msg : ""));
2773 JS_SetPropertyStr(ctx, di, "videoReady", JS_NewBool(ctx, dk->decoder->video_ready));
2774 JS_SetPropertyStr(ctx, di, "videoWidth", JS_NewInt32(ctx, dk->decoder->video_width));
2775 JS_SetPropertyStr(ctx, di, "videoHeight", JS_NewInt32(ctx, dk->decoder->video_height));
2776 JS_SetPropertyStr(ctx, di, "videoFps", JS_NewFloat64(ctx, dk->decoder->video_fps));
2777 JS_SetPropertyStr(ctx, di, "videoFrames", JS_NewInt32(ctx, dk->decoder->video_frame_count));
2778 } else {
2779 JS_SetPropertyStr(ctx, di, "position", JS_NewFloat64(ctx, 0));
2780 JS_SetPropertyStr(ctx, di, "duration", JS_NewFloat64(ctx, 0));
2781 JS_SetPropertyStr(ctx, di, "speed", JS_NewFloat64(ctx, 1.0));
2782 JS_SetPropertyStr(ctx, di, "title", JS_NewString(ctx, ""));
2783 JS_SetPropertyStr(ctx, di, "artist", JS_NewString(ctx, ""));
2784 JS_SetPropertyStr(ctx, di, "finished", JS_FALSE);
2785 JS_SetPropertyStr(ctx, di, "error", JS_NewString(ctx, ""));
2786 JS_SetPropertyStr(ctx, di, "videoReady", JS_FALSE);
2787 JS_SetPropertyStr(ctx, di, "videoWidth", JS_NewInt32(ctx, 0));
2788 JS_SetPropertyStr(ctx, di, "videoHeight", JS_NewInt32(ctx, 0));
2789 JS_SetPropertyStr(ctx, di, "videoFps", JS_NewFloat64(ctx, 0));
2790 JS_SetPropertyStr(ctx, di, "videoFrames", JS_NewInt32(ctx, 0));
2791 }
2792 }
2793 JS_SetPropertyUint32(ctx, decks_arr, d, di);
2794 }
2795 JS_SetPropertyStr(ctx, deck_obj, "decks", decks_arr);
2796 JS_SetPropertyStr(ctx, deck_obj, "crossfaderValue",
2797 JS_NewFloat64(ctx, aud ? aud->crossfader : 0.5));
2798 JS_SetPropertyStr(ctx, deck_obj, "masterVolume",
2799 JS_NewFloat64(ctx, aud ? aud->deck_master_volume : 0.8));
2800 JS_SetPropertyStr(ctx, sound, "deck", deck_obj);
2801
2802 // TTS
2803 JS_SetPropertyStr(ctx, sound, "speak", JS_NewCFunction(ctx, js_speak, "speak", 1));
2804 JS_SetPropertyStr(ctx, sound, "speakVoice", JS_NewCFunction(ctx, js_speak_voice, "speakVoice", 2));
2805 JS_SetPropertyStr(ctx, sound, "speakCached", JS_NewCFunction(ctx, js_speak_cached, "speakCached", 1));
2806
2807 // sound.paint (visualization helpers)
2808 JSValue sound_paint = JS_NewObject(ctx);
2809 JS_SetPropertyStr(ctx, sound_paint, "bars", JS_NewCFunction(ctx, js_noop, "bars", 10));
2810 JS_SetPropertyStr(ctx, sound_paint, "audioEngineBadge", JS_NewCFunction(ctx, js_noop, "audioEngineBadge", 5));
2811 JS_SetPropertyStr(ctx, sound, "paint", sound_paint);
2812
2813 // midi (stub)
2814 JSValue midi = JS_NewObject(ctx);
2815 JS_SetPropertyStr(ctx, midi, "connect", JS_NewCFunction(ctx, js_noop, "connect", 0));
2816 JS_SetPropertyStr(ctx, midi, "noteOn", JS_NewCFunction(ctx, js_usb_midi_note_on, "noteOn", 3));
2817 JS_SetPropertyStr(ctx, midi, "noteOff", JS_NewCFunction(ctx, js_usb_midi_note_off, "noteOff", 3));
2818 JS_SetPropertyStr(ctx, midi, "allNotesOff", JS_NewCFunction(ctx, js_usb_midi_all_notes_off, "allNotesOff", 1));
2819 JS_SetPropertyStr(ctx, sound, "midi", midi);
2820
2821 // daw (stub)
2822 JSValue daw = JS_NewObject(ctx);
2823 JS_SetPropertyStr(ctx, daw, "bpm", JS_UNDEFINED);
2824 JS_SetPropertyStr(ctx, sound, "daw", daw);
2825
2826 return sound;
2827}
2828
2829// Read a small sysfs file into a buffer, return length or -1
2830static int read_sysfs(const char *path, char *buf, int bufsize) {
2831 FILE *f = fopen(path, "r");
2832 if (!f) return -1;
2833 int len = (int)fread(buf, 1, bufsize - 1, f);
2834 fclose(f);
2835 if (len > 0 && buf[len-1] == '\n') len--;
2836 buf[len] = 0;
2837 return len;
2838}
2839
2840// ============================================================
2841// WiFi JS bindings
2842// ============================================================
2843
2844static JSValue js_wifi_scan(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2845 (void)this_val; (void)argc; (void)argv;
2846 if (current_rt->wifi) wifi_scan(current_rt->wifi);
2847 return JS_UNDEFINED;
2848}
2849
2850static JSValue js_wifi_connect(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2851 (void)this_val;
2852 if (!current_rt->wifi || argc < 1) return JS_UNDEFINED;
2853 const char *ssid = JS_ToCString(ctx, argv[0]);
2854 const char *pass = (argc >= 2 && JS_IsString(argv[1])) ? JS_ToCString(ctx, argv[1]) : NULL;
2855 if (ssid) wifi_connect(current_rt->wifi, ssid, pass);
2856 if (ssid) JS_FreeCString(ctx, ssid);
2857 if (pass) JS_FreeCString(ctx, pass);
2858 return JS_UNDEFINED;
2859}
2860
2861static JSValue js_wifi_disconnect(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2862 (void)this_val; (void)argc; (void)argv;
2863 if (current_rt->wifi) wifi_disconnect(current_rt->wifi);
2864 return JS_UNDEFINED;
2865}
2866
2867static JSValue build_wifi_obj(JSContext *ctx, ACWifi *wifi) {
2868 JSValue obj = JS_NewObject(ctx);
2869
2870 JS_SetPropertyStr(ctx, obj, "scan", JS_NewCFunction(ctx, js_wifi_scan, "scan", 0));
2871 JS_SetPropertyStr(ctx, obj, "connect", JS_NewCFunction(ctx, js_wifi_connect, "connect", 2));
2872 JS_SetPropertyStr(ctx, obj, "disconnect", JS_NewCFunction(ctx, js_wifi_disconnect, "disconnect", 0));
2873
2874 if (wifi) {
2875 // State is updated by the wifi worker thread — just read it here
2876 JS_SetPropertyStr(ctx, obj, "state", JS_NewInt32(ctx, wifi->state));
2877 JS_SetPropertyStr(ctx, obj, "status", JS_NewString(ctx, wifi->status_msg));
2878 JS_SetPropertyStr(ctx, obj, "connected", JS_NewBool(ctx, wifi->state == WIFI_STATE_CONNECTED));
2879 JS_SetPropertyStr(ctx, obj, "ssid", JS_NewString(ctx, wifi->connected_ssid));
2880 JS_SetPropertyStr(ctx, obj, "ip", JS_NewString(ctx, wifi->ip_address));
2881 JS_SetPropertyStr(ctx, obj, "iface", JS_NewString(ctx, wifi->iface));
2882 JS_SetPropertyStr(ctx, obj, "signal", JS_NewInt32(ctx, wifi->signal_strength));
2883
2884 // Networks array
2885 JSValue networks = JS_NewArray(ctx);
2886 for (int i = 0; i < wifi->network_count; i++) {
2887 JSValue net = JS_NewObject(ctx);
2888 JS_SetPropertyStr(ctx, net, "ssid", JS_NewString(ctx, wifi->networks[i].ssid));
2889 JS_SetPropertyStr(ctx, net, "signal", JS_NewInt32(ctx, wifi->networks[i].signal));
2890 JS_SetPropertyStr(ctx, net, "encrypted", JS_NewBool(ctx, wifi->networks[i].encrypted));
2891 JS_SetPropertyUint32(ctx, networks, i, net);
2892 }
2893 JS_SetPropertyStr(ctx, obj, "networks", networks);
2894
2895 // Log ring buffer (last 32 messages from wifi thread)
2896 JSValue logs = JS_NewArray(ctx);
2897 int total = wifi->log_count;
2898 int count = total < 32 ? total : 32;
2899 for (int i = 0; i < count; i++) {
2900 // Read in chronological order (oldest first)
2901 int idx = (total >= 32) ? ((total - 32 + i) % 32) : i;
2902 JS_SetPropertyUint32(ctx, logs, i,
2903 JS_NewString(ctx, wifi->log[idx]));
2904 }
2905 JS_SetPropertyStr(ctx, obj, "logs", logs);
2906 } else {
2907 extern int wifi_disabled;
2908 JS_SetPropertyStr(ctx, obj, "state", JS_NewInt32(ctx, WIFI_STATE_OFF));
2909 JS_SetPropertyStr(ctx, obj, "status",
2910 JS_NewString(ctx, wifi_disabled ? "disabled" : "no wifi"));
2911 JS_SetPropertyStr(ctx, obj, "disabled", JS_NewBool(ctx, wifi_disabled));
2912 JS_SetPropertyStr(ctx, obj, "connected", JS_FALSE);
2913 JS_SetPropertyStr(ctx, obj, "networks", JS_NewArray(ctx));
2914 }
2915
2916 return obj;
2917}
2918
2919// system.hdmi(r, g, b) — fill secondary display with solid color
2920static JSValue js_hdmi_fill(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2921 (void)this_val;
2922 if (!current_rt || !current_rt->hdmi || !current_rt->hdmi->active) return JS_UNDEFINED;
2923 if (argc < 3) return JS_UNDEFINED;
2924 int r, g, b;
2925 JS_ToInt32(ctx, &r, argv[0]);
2926 JS_ToInt32(ctx, &g, argv[1]);
2927 JS_ToInt32(ctx, &b, argv[2]);
2928 drm_secondary_fill(current_rt->hdmi, (uint8_t)r, (uint8_t)g, (uint8_t)b);
2929 return JS_UNDEFINED;
2930}
2931
2932// system.readFile(path, [lastNLines]) — read a file from disk, returns string or null
2933// If lastNLines is provided, returns only the last N lines.
2934static JSValue js_read_file(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2935 (void)this_val;
2936 if (argc < 1) return JS_NULL;
2937 const char *path = JS_ToCString(ctx, argv[0]);
2938 if (!path) return JS_NULL;
2939 int lastN = 0;
2940 if (argc >= 2) JS_ToInt32(ctx, &lastN, argv[1]);
2941 FILE *fp = fopen(path, "r");
2942 if (!fp) { JS_FreeCString(ctx, path); return JS_NULL; }
2943 fseek(fp, 0, SEEK_END);
2944 long sz = ftell(fp);
2945 if (sz <= 0) { fclose(fp); JS_FreeCString(ctx, path); return JS_NULL; }
2946 if (sz > 65536) {
2947 fseek(fp, -65536, SEEK_END);
2948 sz = 65536;
2949 } else {
2950 rewind(fp);
2951 }
2952 char *buf = malloc(sz + 1);
2953 if (!buf) { fclose(fp); JS_FreeCString(ctx, path); return JS_NULL; }
2954 long rd = (long)fread(buf, 1, sz, fp);
2955 buf[rd] = 0;
2956 fclose(fp);
2957 JS_FreeCString(ctx, path);
2958 if (lastN > 0 && rd > 0) {
2959 int nl_count = 0;
2960 char *p = buf + rd - 1;
2961 if (*p == '\n') p--;
2962 while (p > buf) {
2963 if (*p == '\n') { nl_count++; if (nl_count >= lastN) { p++; break; } }
2964 p--;
2965 }
2966 JSValue result = JS_NewString(ctx, p);
2967 free(buf);
2968 return result;
2969 }
2970 JSValue result = JS_NewStringLen(ctx, buf, rd);
2971 free(buf);
2972 return result;
2973}
2974
2975// system.writeFile(path, data) — write a string to disk, returns true/false
2976static JSValue js_write_file(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
2977 (void)this_val;
2978 if (argc < 2) return JS_FALSE;
2979 const char *path = JS_ToCString(ctx, argv[0]);
2980 const char *data = JS_ToCString(ctx, argv[1]);
2981 if (!path || !data) {
2982 if (path) JS_FreeCString(ctx, path);
2983 if (data) JS_FreeCString(ctx, data);
2984 return JS_FALSE;
2985 }
2986 FILE *fp = fopen(path, "w");
2987 int ok = 0;
2988 if (fp) {
2989 fputs(data, fp);
2990 fclose(fp);
2991 sync(); // flush to USB
2992 ok = 1;
2993 }
2994 JS_FreeCString(ctx, path);
2995 JS_FreeCString(ctx, data);
2996 return JS_NewBool(ctx, ok);
2997}
2998
2999// system.deleteFile(path) — unlink a file, returns true on success
3000static JSValue js_delete_file(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3001 (void)this_val;
3002 if (argc < 1) return JS_FALSE;
3003 const char *path = JS_ToCString(ctx, argv[0]);
3004 if (!path) return JS_FALSE;
3005 int ok = (unlink(path) == 0);
3006 if (ok) sync();
3007 JS_FreeCString(ctx, path);
3008 return JS_NewBool(ctx, ok);
3009}
3010
3011// system.diskInfo(path) — returns {total, free, available, blockSize, fstype}
3012// for the filesystem containing `path`, or null on error.
3013static JSValue js_disk_info(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3014 (void)this_val;
3015 if (argc < 1) return JS_NULL;
3016 const char *path = JS_ToCString(ctx, argv[0]);
3017 if (!path) return JS_NULL;
3018 struct statvfs vfs;
3019 int rc = statvfs(path, &vfs);
3020 JS_FreeCString(ctx, path);
3021 if (rc != 0) return JS_NULL;
3022 JSValue obj = JS_NewObject(ctx);
3023 uint64_t bs = (uint64_t)vfs.f_frsize ? (uint64_t)vfs.f_frsize : (uint64_t)vfs.f_bsize;
3024 JS_SetPropertyStr(ctx, obj, "total", JS_NewInt64(ctx, (int64_t)(vfs.f_blocks * bs)));
3025 JS_SetPropertyStr(ctx, obj, "free", JS_NewInt64(ctx, (int64_t)(vfs.f_bfree * bs)));
3026 JS_SetPropertyStr(ctx, obj, "available", JS_NewInt64(ctx, (int64_t)(vfs.f_bavail * bs)));
3027 JS_SetPropertyStr(ctx, obj, "blockSize", JS_NewInt64(ctx, (int64_t)bs));
3028 return obj;
3029}
3030
3031// system.blockDevices() — list /sys/block entries with size + removable + model.
3032// Returns array of {name, sizeBytes, removable, model, vendor} or null.
3033static JSValue js_block_devices(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3034 (void)this_val; (void)argc; (void)argv;
3035 DIR *dir = opendir("/sys/block");
3036 if (!dir) return JS_NULL;
3037 JSValue arr = JS_NewArray(ctx);
3038 struct dirent *ent;
3039 int idx = 0;
3040 while ((ent = readdir(dir)) != NULL) {
3041 if (ent->d_name[0] == '.') continue;
3042 // Skip loop/ram/dm pseudo devs
3043 if (strncmp(ent->d_name, "loop", 4) == 0) continue;
3044 if (strncmp(ent->d_name, "ram", 3) == 0) continue;
3045 if (strncmp(ent->d_name, "dm-", 3) == 0) continue;
3046 char p[256]; char buf[128];
3047 JSValue obj = JS_NewObject(ctx);
3048 JS_SetPropertyStr(ctx, obj, "name", JS_NewString(ctx, ent->d_name));
3049 // size (in 512-byte sectors)
3050 snprintf(p, sizeof(p), "/sys/block/%s/size", ent->d_name);
3051 FILE *f = fopen(p, "r"); long long sectors = 0;
3052 if (f) { fscanf(f, "%lld", §ors); fclose(f); }
3053 JS_SetPropertyStr(ctx, obj, "sizeBytes", JS_NewInt64(ctx, sectors * 512LL));
3054 // removable flag
3055 snprintf(p, sizeof(p), "/sys/block/%s/removable", ent->d_name);
3056 int removable = 0;
3057 f = fopen(p, "r");
3058 if (f) { fscanf(f, "%d", &removable); fclose(f); }
3059 JS_SetPropertyStr(ctx, obj, "removable", JS_NewBool(ctx, removable != 0));
3060 // vendor + model
3061 const char *fields[][2] = { {"vendor", "vendor"}, {"model", "model"}, {NULL, NULL} };
3062 for (int i = 0; fields[i][0]; i++) {
3063 snprintf(p, sizeof(p), "/sys/block/%s/device/%s", ent->d_name, fields[i][0]);
3064 f = fopen(p, "r");
3065 if (f) {
3066 if (fgets(buf, sizeof(buf), f)) {
3067 // trim trailing whitespace
3068 size_t L = strlen(buf);
3069 while (L > 0 && (buf[L-1] == '\n' || buf[L-1] == ' ' || buf[L-1] == '\t')) buf[--L] = 0;
3070 JS_SetPropertyStr(ctx, obj, fields[i][1], JS_NewString(ctx, buf));
3071 }
3072 fclose(f);
3073 }
3074 }
3075 JS_SetPropertyUint32(ctx, arr, idx++, obj);
3076 }
3077 closedir(dir);
3078 return arr;
3079}
3080
3081// system.listDir(path) — returns [{name, isDir, size}, ...] or null
3082static JSValue js_list_dir(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3083 (void)this_val;
3084 if (argc < 1) return JS_NULL;
3085 const char *path = JS_ToCString(ctx, argv[0]);
3086 if (!path) return JS_NULL;
3087
3088 DIR *dir = opendir(path);
3089 if (!dir) {
3090 JS_FreeCString(ctx, path);
3091 return JS_NULL;
3092 }
3093
3094 JSValue arr = JS_NewArray(ctx);
3095 struct dirent *ent;
3096 int idx = 0;
3097 while ((ent = readdir(dir)) != NULL) {
3098 // Skip . and ..
3099 if (ent->d_name[0] == '.' && (ent->d_name[1] == '\0' ||
3100 (ent->d_name[1] == '.' && ent->d_name[2] == '\0'))) continue;
3101
3102 JSValue obj = JS_NewObject(ctx);
3103 JS_SetPropertyStr(ctx, obj, "name", JS_NewString(ctx, ent->d_name));
3104
3105 // stat for size and type
3106 char full[1024];
3107 snprintf(full, sizeof(full), "%s/%s", path, ent->d_name);
3108 struct stat st;
3109 int is_dir = 0;
3110 int64_t size = 0;
3111 if (stat(full, &st) == 0) {
3112 is_dir = S_ISDIR(st.st_mode);
3113 size = st.st_size;
3114 }
3115 JS_SetPropertyStr(ctx, obj, "isDir", JS_NewBool(ctx, is_dir));
3116 JS_SetPropertyStr(ctx, obj, "size", JS_NewInt64(ctx, size));
3117
3118 JS_SetPropertyUint32(ctx, arr, idx++, obj);
3119 }
3120 closedir(dir);
3121 JS_FreeCString(ctx, path);
3122 return arr;
3123}
3124
3125// system.mountMusic() — non-blocking secondary USB probe.
3126// Schedules a background mount check for /media and immediately returns the
3127// last known mounted state. JS reads system.mountMusicMounted /
3128// system.mountMusicPending for cached state.
3129static pthread_mutex_t music_mount_mu = PTHREAD_MUTEX_INITIALIZER;
3130static volatile int music_mount_pending = 0;
3131static volatile int music_mount_state = 0;
3132static int music_mount_last_result = -1;
3133static char music_mount_last_skip_base[64] = "";
3134
3135static int probe_mount_music_once(void) {
3136
3137 mkdir("/media", 0755);
3138
3139 // Check /proc/mounts for any existing mount at /media, and capture the device.
3140 char media_dev[128] = {0};
3141 FILE *mf = fopen("/proc/mounts", "r");
3142 if (mf) {
3143 char line[512];
3144 while (fgets(line, sizeof(line), mf)) {
3145 char dev[128], target[128];
3146 if (sscanf(line, "%127s %127s", dev, target) == 2 && strcmp(target, "/media") == 0) {
3147 strncpy(media_dev, dev, sizeof(media_dev) - 1);
3148 break;
3149 }
3150 }
3151 fclose(mf);
3152 }
3153
3154 if (media_dev[0]) {
3155 // There's something mounted at /media. Verify the backing device is still
3156 // physically present (USB stick didn't get yanked).
3157 struct stat ds;
3158 int dev_alive = (stat(media_dev, &ds) == 0 && S_ISBLK(ds.st_mode));
3159 // Extra check: opendir succeeds on a live mount but may EIO on a stale one.
3160 int dir_ok = 0;
3161 if (dev_alive) {
3162 DIR *d = opendir("/media");
3163 if (d) { dir_ok = 1; closedir(d); }
3164 }
3165 if (dev_alive && dir_ok) {
3166 music_mount_last_result = 1;
3167 return 1; // legitimate, live mount
3168 }
3169 // Stale: device is gone or dir unreadable. Lazy-unmount so we can remount.
3170 ac_log("[dj] stale /media mount (dev=%s, alive=%d, dir=%d) — detaching\n",
3171 media_dev, dev_alive, dir_ok);
3172 if (umount2("/media", MNT_DETACH) != 0) {
3173 ac_log("[dj] umount /media failed: %s\n", strerror(errno));
3174 }
3175 }
3176
3177 // Find ALL mounted devices to skip (boot USB may have multiple partitions)
3178 char skip_bases[8][64];
3179 int skip_count = 0;
3180 FILE *fp = fopen("/proc/mounts", "r");
3181 if (fp) {
3182 char line[256];
3183 while (fgets(line, sizeof(line), fp) && skip_count < 8) {
3184 char mdev[64];
3185 if (sscanf(line, "%63s", mdev) == 1 && strncmp(mdev, "/dev/sd", 7) == 0) {
3186 // Strip partition number to get base
3187 char base[64];
3188 snprintf(base, sizeof(base), "%s", mdev);
3189 int len = strlen(base);
3190 while (len > 0 && base[len-1] >= '0' && base[len-1] <= '9') base[--len] = '\0';
3191 // Add if not already in skip list
3192 int found = 0;
3193 for (int i = 0; i < skip_count; i++)
3194 if (strcmp(skip_bases[i], base) == 0) { found = 1; break; }
3195 if (!found) {
3196 strncpy(skip_bases[skip_count++], base, 63);
3197 if (strcmp(music_mount_last_skip_base, base) != 0) {
3198 snprintf(music_mount_last_skip_base, sizeof(music_mount_last_skip_base), "%s", base);
3199 ac_log("[dj] skip boot device: %s\n", base);
3200 }
3201 }
3202 }
3203 }
3204 fclose(fp);
3205 }
3206
3207 // Try mounting partitions on non-boot USB devices
3208 const char *bases[] = { "/dev/sda", "/dev/sdb", "/dev/sdc", "/dev/sdd", NULL };
3209 for (int b = 0; bases[b]; b++) {
3210 int skip = 0;
3211 for (int i = 0; i < skip_count; i++)
3212 if (strcmp(bases[b], skip_bases[i]) == 0) { skip = 1; break; }
3213 if (skip) continue;
3214
3215 for (int p = 1; p <= 8; p++) {
3216 char dev[64];
3217 snprintf(dev, sizeof(dev), "%s%d", bases[b], p);
3218 struct stat ds;
3219 if (stat(dev, &ds) != 0) continue;
3220
3221 if (mount(dev, "/media", "vfat", MS_RDONLY, "iocharset=utf8") == 0) {
3222 ac_log("[dj] mounted %s at /media (vfat)\n", dev);
3223 music_mount_last_result = 1;
3224 return 1;
3225 }
3226 if (mount(dev, "/media", "exfat", MS_RDONLY, NULL) == 0) {
3227 ac_log("[dj] mounted %s at /media (exfat)\n", dev);
3228 music_mount_last_result = 1;
3229 return 1;
3230 }
3231 if (mount(dev, "/media", "ext4", MS_RDONLY, NULL) == 0) {
3232 ac_log("[dj] mounted %s at /media (ext4)\n", dev);
3233 music_mount_last_result = 1;
3234 return 1;
3235 }
3236 if (mount(dev, "/media", "ntfs3", MS_RDONLY, NULL) == 0) {
3237 ac_log("[dj] mounted %s at /media (ntfs)\n", dev);
3238 music_mount_last_result = 1;
3239 return 1;
3240 }
3241 }
3242 }
3243 if (music_mount_last_result != 0) {
3244 ac_log("[dj] no music USB found\n");
3245 music_mount_last_result = 0;
3246 }
3247 return 0;
3248}
3249
3250static void *music_mount_thread_fn(void *arg) {
3251 (void)arg;
3252 int mounted = probe_mount_music_once();
3253 pthread_mutex_lock(&music_mount_mu);
3254 music_mount_state = mounted ? 1 : 0;
3255 music_mount_pending = 0;
3256 pthread_mutex_unlock(&music_mount_mu);
3257 return NULL;
3258}
3259
3260static void request_music_mount_probe(void) {
3261 int should_spawn = 0;
3262 pthread_t tid;
3263
3264 pthread_mutex_lock(&music_mount_mu);
3265 if (!music_mount_pending) {
3266 music_mount_pending = 1;
3267 should_spawn = 1;
3268 }
3269 pthread_mutex_unlock(&music_mount_mu);
3270
3271 if (!should_spawn) return;
3272
3273 if (pthread_create(&tid, NULL, music_mount_thread_fn, NULL) != 0) {
3274 pthread_mutex_lock(&music_mount_mu);
3275 music_mount_pending = 0;
3276 pthread_mutex_unlock(&music_mount_mu);
3277 ac_log("[dj] mountMusic thread create failed\n");
3278 return;
3279 }
3280 pthread_detach(tid);
3281}
3282
3283static JSValue js_mount_music(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3284 (void)this_val; (void)argc; (void)argv;
3285 request_music_mount_probe();
3286 return JS_NewBool(ctx, music_mount_state);
3287}
3288
3289// ---------------------------------------------------------------------------
3290// system.ws — WebSocket client
3291// ---------------------------------------------------------------------------
3292
3293static JSValue js_ws_connect(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3294 (void)this_val;
3295 if (argc < 1 || !current_rt) return JS_UNDEFINED;
3296 const char *url = JS_ToCString(ctx, argv[0]);
3297 if (!url) return JS_UNDEFINED;
3298 ws_connect(current_rt->ws, url); // non-blocking: signals background thread
3299 JS_FreeCString(ctx, url);
3300 return JS_UNDEFINED;
3301}
3302
3303static JSValue js_ws_send(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3304 (void)this_val;
3305 if (argc < 1 || !current_rt || !current_rt->ws) return JS_UNDEFINED;
3306 const char *text = JS_ToCString(ctx, argv[0]);
3307 if (!text) return JS_UNDEFINED;
3308 ws_send(current_rt->ws, text);
3309 JS_FreeCString(ctx, text);
3310 return JS_UNDEFINED;
3311}
3312
3313static JSValue js_ws_close(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3314 (void)this_val; (void)argc; (void)argv;
3315 if (current_rt && current_rt->ws) ws_close(current_rt->ws);
3316 return JS_UNDEFINED;
3317}
3318
3319static JSValue build_ws_obj(JSContext *ctx, const char *phase) {
3320 JSValue ws_obj = JS_NewObject(ctx);
3321 JS_SetPropertyStr(ctx, ws_obj, "connect", JS_NewCFunction(ctx, js_ws_connect, "connect", 1));
3322 JS_SetPropertyStr(ctx, ws_obj, "send", JS_NewCFunction(ctx, js_ws_send, "send", 1));
3323 JS_SetPropertyStr(ctx, ws_obj, "close", JS_NewCFunction(ctx, js_ws_close, "close", 0));
3324
3325 ACWs *ws = current_rt ? current_rt->ws : NULL;
3326 if (ws) {
3327 // Only drain messages during "paint" phase — that's where JS processes them.
3328 // Other phases (act, sim) see connected/connecting but empty messages array,
3329 // preventing the queue from being consumed before paint() can read it.
3330 int drain = (strcmp(phase, "paint") == 0);
3331 pthread_mutex_lock(&ws->mu);
3332 int count = drain ? ws->msg_count : 0;
3333 if (drain) ws->msg_count = 0;
3334 int connected = ws->connected;
3335 int connecting = ws->connecting;
3336 // Copy only actual message bytes (not full 256KB buffer each) — heap alloc
3337 if (count > WS_MAX_MESSAGES) count = WS_MAX_MESSAGES;
3338 char *msgs[WS_MAX_MESSAGES];
3339 int msg_lens[WS_MAX_MESSAGES];
3340 for (int i = 0; i < count; i++) {
3341 int len = strnlen(ws->messages[i], WS_MAX_MSG_LEN - 1);
3342 msgs[i] = malloc(len + 1);
3343 if (msgs[i]) { memcpy(msgs[i], ws->messages[i], len); msgs[i][len] = 0; }
3344 msg_lens[i] = len;
3345 }
3346 pthread_mutex_unlock(&ws->mu);
3347
3348 JS_SetPropertyStr(ctx, ws_obj, "connected", JS_NewBool(ctx, connected));
3349 JS_SetPropertyStr(ctx, ws_obj, "connecting", JS_NewBool(ctx, connecting));
3350
3351 JSValue arr = JS_NewArray(ctx);
3352 for (int i = 0; i < count; i++) {
3353 if (msgs[i]) {
3354 JS_SetPropertyUint32(ctx, arr, (uint32_t)i, JS_NewStringLen(ctx, msgs[i], msg_lens[i]));
3355 free(msgs[i]);
3356 }
3357 }
3358 JS_SetPropertyStr(ctx, ws_obj, "messages", arr);
3359 } else {
3360 JS_SetPropertyStr(ctx, ws_obj, "connected", JS_FALSE);
3361 JS_SetPropertyStr(ctx, ws_obj, "connecting", JS_FALSE);
3362 JS_SetPropertyStr(ctx, ws_obj, "messages", JS_NewArray(ctx));
3363 }
3364 return ws_obj;
3365}
3366
3367// system.fetch(url) — async HTTP GET via curl, result polled via system.fetchResult
3368static JSValue js_fetch(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3369 (void)this_val;
3370 if (!current_rt || argc < 1) return JS_UNDEFINED;
3371 // Reject if a fetch is already in flight (single-slot)
3372 if (current_rt->fetch_pending) return JS_FALSE;
3373 const char *url = JS_ToCString(ctx, argv[0]);
3374 if (!url) return JS_UNDEFINED;
3375 unlink("/tmp/ac_fetch.json");
3376 unlink("/tmp/ac_fetch_rc");
3377 unlink("/tmp/ac_fetch_err");
3378 char cmd[2048];
3379 ac_log("[fetch] start: %s\n", url);
3380 snprintf(cmd, sizeof(cmd),
3381 "sh -c 'curl -fsSL --retry 2 --retry-delay 1 --connect-timeout 5 --max-time 12 "
3382 "--cacert /etc/pki/tls/certs/ca-bundle.crt "
3383 "--output /tmp/ac_fetch.json \"%s\" 2>/tmp/ac_fetch_err;"
3384 " echo $? > /tmp/ac_fetch_rc' &", url);
3385 system(cmd);
3386 current_rt->fetch_pending = 1;
3387 current_rt->fetch_result[0] = 0;
3388 current_rt->fetch_error[0] = 0;
3389 JS_FreeCString(ctx, url);
3390 return JS_TRUE;
3391}
3392
3393// system.fetchPost(url, body, headersJSON) — POST request with custom headers
3394// Uses the same single fetch slot as system.fetch.
3395// headersJSON is a JSON string like '{"Authorization":"Bearer ...","Content-Type":"application/json"}'
3396static JSValue js_fetch_post(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3397 (void)this_val;
3398 if (!current_rt || argc < 2) return JS_UNDEFINED;
3399 if (current_rt->fetch_pending) return JS_FALSE;
3400 const char *url = JS_ToCString(ctx, argv[0]);
3401 const char *body = JS_ToCString(ctx, argv[1]);
3402 const char *headers_json = (argc >= 3) ? JS_ToCString(ctx, argv[2]) : NULL;
3403 if (!url || !body) {
3404 JS_FreeCString(ctx, url);
3405 JS_FreeCString(ctx, body);
3406 if (headers_json) JS_FreeCString(ctx, headers_json);
3407 return JS_UNDEFINED;
3408 }
3409 unlink("/tmp/ac_fetch.json");
3410 unlink("/tmp/ac_fetch_rc");
3411 unlink("/tmp/ac_fetch_err");
3412
3413 // Write body to temp file to avoid shell escaping issues
3414 FILE *bf = fopen("/tmp/ac_fetch_body.json", "w");
3415 if (bf) { fputs(body, bf); fclose(bf); }
3416
3417 // Build header flags by parsing simple JSON {"key":"value",...}
3418 // We build a file of -H flags to avoid shell escaping nightmares
3419 FILE *hf = fopen("/tmp/ac_fetch_headers.txt", "w");
3420 if (hf) {
3421 // Always include Content-Type
3422 fprintf(hf, "-H\nContent-Type: application/json\n");
3423 if (headers_json) {
3424 // Simple JSON parser: find "key":"value" pairs
3425 const char *p = headers_json;
3426 while (*p) {
3427 const char *kq = strchr(p, '"');
3428 if (!kq) break;
3429 const char *ke = strchr(kq + 1, '"');
3430 if (!ke) break;
3431 const char *vq = strchr(ke + 1, '"');
3432 if (!vq) break;
3433 const char *ve = strchr(vq + 1, '"');
3434 if (!ve) break;
3435 int klen = (int)(ke - kq - 1);
3436 int vlen = (int)(ve - vq - 1);
3437 if (klen > 0 && klen < 256 && vlen > 0 && vlen < 2048) {
3438 fprintf(hf, "-H\n%.*s: %.*s\n", klen, kq + 1, vlen, vq + 1);
3439 }
3440 p = ve + 1;
3441 }
3442 }
3443 fclose(hf);
3444 }
3445
3446 ac_log("[fetchPost] start: %s (body %ld bytes)\n", url, (long)strlen(body));
3447 char cmd[2048];
3448 snprintf(cmd, sizeof(cmd),
3449 "sh -c 'curl -fsSL -X POST --retry 1 --connect-timeout 10 --max-time 120 "
3450 "--cacert /etc/pki/tls/certs/ca-bundle.crt "
3451 "-K /tmp/ac_fetch_headers.txt "
3452 "-d @/tmp/ac_fetch_body.json "
3453 "--output /tmp/ac_fetch.json \"%s\" 2>/tmp/ac_fetch_err;"
3454 " echo $? > /tmp/ac_fetch_rc' &", url);
3455 system(cmd);
3456 current_rt->fetch_pending = 1;
3457 current_rt->fetch_result[0] = 0;
3458 current_rt->fetch_error[0] = 0;
3459 JS_FreeCString(ctx, url);
3460 JS_FreeCString(ctx, body);
3461 if (headers_json) JS_FreeCString(ctx, headers_json);
3462 return JS_TRUE;
3463}
3464
3465// system.fetchCancel() — kill in-flight curl and free the fetch slot
3466static JSValue js_fetch_cancel(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3467 (void)this_val; (void)argc; (void)argv;
3468 if (!current_rt) return JS_UNDEFINED;
3469 if (current_rt->fetch_pending) {
3470 ac_log("[fetch] cancel: killing in-flight curl\n");
3471 // Kill any background curl for ac_fetch
3472 system("pkill -f 'curl.*ac_fetch' 2>/dev/null");
3473 unlink("/tmp/ac_fetch.json");
3474 unlink("/tmp/ac_fetch_rc");
3475 unlink("/tmp/ac_fetch_err");
3476 current_rt->fetch_pending = 0;
3477 current_rt->fetch_result[0] = 0;
3478 current_rt->fetch_error[0] = 0;
3479 }
3480 return JS_UNDEFINED;
3481}
3482
3483// ---------------------------------------------------------------------------
3484// system.scanQR() — Open camera and scan for QR codes in a background thread
3485// system.scanQRStop() — Stop scanning and close camera
3486// Poll system.qrPending / system.qrResult each frame.
3487// ---------------------------------------------------------------------------
3488
3489static void *qr_scan_thread(void *arg) {
3490 ACRuntime *rt = (ACRuntime *)arg;
3491 ACCamera *cam = &rt->camera;
3492
3493 if (camera_open(cam) < 0) {
3494 cam->scan_done = 1;
3495 rt->qr_thread_running = 0;
3496 return NULL;
3497 }
3498
3499 // Grab frames and scan until we find a QR code or are told to stop
3500 int max_attempts = 300; // ~10 seconds at 30fps
3501 for (int i = 0; i < max_attempts && rt->qr_scan_active; i++) {
3502 if (camera_grab(cam) == 0) {
3503 if (camera_scan_qr(cam) > 0) {
3504 cam->scan_done = 1;
3505 break;
3506 }
3507 }
3508 usleep(33000); // ~30fps
3509 }
3510
3511 if (!cam->scan_done) {
3512 cam->scan_done = 1;
3513 if (!cam->scan_result[0] && !cam->scan_error[0]) {
3514 snprintf(cam->scan_error, sizeof(cam->scan_error), "no QR code found");
3515 }
3516 }
3517
3518 camera_close(cam);
3519 rt->qr_scan_active = 0;
3520 rt->qr_thread_running = 0;
3521 return NULL;
3522}
3523
3524static JSValue js_scan_qr(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3525 (void)this_val; (void)argc; (void)argv;
3526 if (!current_rt) return JS_UNDEFINED;
3527
3528 // Already scanning?
3529 if (current_rt->qr_scan_active) return JS_FALSE;
3530
3531 // Reset state
3532 current_rt->camera.scan_result[0] = 0;
3533 current_rt->camera.scan_error[0] = 0;
3534 current_rt->camera.scan_done = 0;
3535 current_rt->qr_scan_active = 1;
3536 current_rt->qr_thread_running = 1;
3537
3538 if (pthread_create(¤t_rt->qr_thread, NULL, qr_scan_thread, current_rt) != 0) {
3539 current_rt->qr_scan_active = 0;
3540 current_rt->qr_thread_running = 0;
3541 snprintf(current_rt->camera.scan_error, sizeof(current_rt->camera.scan_error),
3542 "thread create failed");
3543 current_rt->camera.scan_done = 1;
3544 return JS_FALSE;
3545 }
3546 pthread_detach(current_rt->qr_thread);
3547
3548 ac_log("[qr] scan started\n");
3549 return JS_TRUE;
3550}
3551
3552static JSValue js_scan_qr_stop(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3553 (void)this_val; (void)argc; (void)argv; (void)ctx;
3554 if (!current_rt) return JS_UNDEFINED;
3555 current_rt->qr_scan_active = 0;
3556 ac_log("[qr] scan stopped\n");
3557 return JS_UNDEFINED;
3558}
3559
3560// cameraBlit(x, y, w, h) — render camera display buffer to graph framebuffer
3561static JSValue js_camera_blit(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3562 (void)this_val;
3563 if (!current_rt || !current_rt->graph) return JS_FALSE;
3564 ACCamera *cam = ¤t_rt->camera;
3565 if (!cam->display || !cam->display_ready) return JS_FALSE;
3566
3567 int dx = 0, dy = 0, dw = 0, dh = 0;
3568 if (argc >= 4) {
3569 JS_ToInt32(ctx, &dx, argv[0]);
3570 JS_ToInt32(ctx, &dy, argv[1]);
3571 JS_ToInt32(ctx, &dw, argv[2]);
3572 JS_ToInt32(ctx, &dh, argv[3]);
3573 } else {
3574 // Default: fit to screen
3575 dw = current_rt->graph->fb->width;
3576 dh = current_rt->graph->fb->height;
3577 }
3578 if (dw <= 0 || dh <= 0) return JS_FALSE;
3579
3580 // Lock and copy the display buffer to a local copy
3581 int cw = cam->width, ch = cam->height;
3582 int pixels = cw * ch;
3583 uint8_t *local = malloc(pixels);
3584 if (!local) return JS_FALSE;
3585
3586 pthread_mutex_lock(&cam->display_mu);
3587 memcpy(local, cam->display, pixels);
3588 pthread_mutex_unlock(&cam->display_mu);
3589
3590 // Blit grayscale to framebuffer with nearest-neighbor scaling
3591 ACFramebuffer *fb = current_rt->graph->fb;
3592 for (int py = 0; py < dh; py++) {
3593 int fy = dy + py;
3594 if (fy < 0 || fy >= fb->height) continue;
3595 int sy = py * ch / dh;
3596 if (sy >= ch) sy = ch - 1;
3597 for (int px = 0; px < dw; px++) {
3598 int fx = dx + px;
3599 if (fx < 0 || fx >= fb->width) continue;
3600 int sx = px * cw / dw;
3601 if (sx >= cw) sx = cw - 1;
3602 uint8_t g = local[sy * cw + sx];
3603 fb->pixels[fy * fb->stride + fx] = 0xFF000000u | ((uint32_t)g << 16) | ((uint32_t)g << 8) | g;
3604 }
3605 }
3606
3607 free(local);
3608 return JS_TRUE;
3609}
3610
3611// ---------------------------------------------------------------------------
3612// system.udp — Raw UDP fairy point co-presence
3613// ---------------------------------------------------------------------------
3614
3615static void sync_udp_identity(void) {
3616 extern char g_machine_id[64];
3617
3618 if (!current_rt || !current_rt->udp) return;
3619 udp_set_identity(
3620 current_rt->udp,
3621 current_rt->handle[0] ? current_rt->handle : "",
3622 g_machine_id[0] ? g_machine_id : "unknown"
3623 );
3624}
3625
3626static JSValue js_udp_connect(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3627 (void)this_val;
3628 if (!current_rt || !current_rt->udp) return JS_UNDEFINED;
3629 const char *host = argc > 0 ? JS_ToCString(ctx, argv[0]) : NULL;
3630 if (!host) host = "session-server.aesthetic.computer";
3631 int port = UDP_FAIRY_PORT;
3632 if (argc > 1) JS_ToInt32(ctx, &port, argv[1]);
3633 sync_udp_identity();
3634 udp_connect(current_rt->udp, host, port);
3635 if (argc > 0) JS_FreeCString(ctx, host);
3636 return JS_UNDEFINED;
3637}
3638
3639static JSValue js_udp_send_fairy(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3640 (void)this_val;
3641 if (!current_rt || !current_rt->udp || argc < 2) return JS_UNDEFINED;
3642 double x, y;
3643 JS_ToFloat64(ctx, &x, argv[0]);
3644 JS_ToFloat64(ctx, &y, argv[1]);
3645 udp_send_fairy(current_rt->udp, (float)x, (float)y);
3646 return JS_UNDEFINED;
3647}
3648
3649static JSValue js_udp_send_midi(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3650 (void)this_val;
3651 if (!current_rt || !current_rt->udp || argc < 4) return JS_UNDEFINED;
3652
3653 const char *event = JS_ToCString(ctx, argv[0]);
3654 int32_t note = 0;
3655 int32_t velocity = 0;
3656 int32_t channel = 0;
3657 const char *piece = argc > 4 ? JS_ToCString(ctx, argv[4]) : NULL;
3658
3659 JS_ToInt32(ctx, ¬e, argv[1]);
3660 JS_ToInt32(ctx, &velocity, argv[2]);
3661 JS_ToInt32(ctx, &channel, argv[3]);
3662
3663 if (!event) {
3664 if (piece) JS_FreeCString(ctx, piece);
3665 return JS_UNDEFINED;
3666 }
3667
3668 sync_udp_identity();
3669 udp_send_midi(
3670 current_rt->udp,
3671 event,
3672 (int)note,
3673 (int)velocity,
3674 (int)channel,
3675 piece ? piece : "notepat"
3676 );
3677
3678 JS_FreeCString(ctx, event);
3679 if (piece) JS_FreeCString(ctx, piece);
3680 return JS_UNDEFINED;
3681}
3682
3683static JSValue js_udp_send_midi_heartbeat(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3684 (void)this_val;
3685 if (!current_rt || !current_rt->udp) return JS_UNDEFINED;
3686
3687 const char *piece = argc > 0 ? JS_ToCString(ctx, argv[0]) : NULL;
3688 sync_udp_identity();
3689 udp_send_midi_heartbeat(current_rt->udp, piece ? piece : "notepat");
3690 if (piece) JS_FreeCString(ctx, piece);
3691 return JS_UNDEFINED;
3692}
3693
3694static JSValue build_udp_obj(JSContext *ctx, const char *phase) {
3695 JSValue obj = JS_NewObject(ctx);
3696 JS_SetPropertyStr(ctx, obj, "connect", JS_NewCFunction(ctx, js_udp_connect, "connect", 2));
3697 JS_SetPropertyStr(ctx, obj, "sendFairy", JS_NewCFunction(ctx, js_udp_send_fairy, "sendFairy", 2));
3698 JS_SetPropertyStr(ctx, obj, "sendMidi", JS_NewCFunction(ctx, js_udp_send_midi, "sendMidi", 5));
3699 JS_SetPropertyStr(ctx, obj, "sendMidiHeartbeat", JS_NewCFunction(ctx, js_udp_send_midi_heartbeat, "sendMidiHeartbeat", 1));
3700
3701 ACUdp *udp = current_rt ? current_rt->udp : NULL;
3702 if (udp) {
3703 sync_udp_identity();
3704 JS_SetPropertyStr(ctx, obj, "connected", JS_NewBool(ctx, udp->connected));
3705 JS_SetPropertyStr(ctx, obj, "handle",
3706 JS_NewString(ctx, current_rt && current_rt->handle[0] ? current_rt->handle : ""));
3707
3708 // Only deliver fairies during paint phase
3709 if (strcmp(phase, "paint") == 0) {
3710 UDPFairy fairies[UDP_MAX_FAIRIES];
3711 int count = udp_poll_fairies(udp, fairies, UDP_MAX_FAIRIES);
3712 JSValue arr = JS_NewArray(ctx);
3713 for (int i = 0; i < count; i++) {
3714 JSValue f = JS_NewObject(ctx);
3715 JS_SetPropertyStr(ctx, f, "x", JS_NewFloat64(ctx, fairies[i].x));
3716 JS_SetPropertyStr(ctx, f, "y", JS_NewFloat64(ctx, fairies[i].y));
3717 JS_SetPropertyUint32(ctx, arr, i, f);
3718 }
3719 JS_SetPropertyStr(ctx, obj, "fairies", arr);
3720 }
3721 }
3722 return obj;
3723}
3724
3725// system.fetchBinary(url, destPath[, expectedBytes])
3726// Non-blocking: runs curl in background; poll system.fetchBinaryProgress/Done/Ok each frame.
3727static JSValue js_fetch_binary(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
3728 (void)this_val;
3729 if (!current_rt || argc < 2) return JS_UNDEFINED;
3730 const char *url = JS_ToCString(ctx, argv[0]);
3731 const char *dest = JS_ToCString(ctx, argv[1]);
3732 if (!url || !dest) {
3733 JS_FreeCString(ctx, url);
3734 JS_FreeCString(ctx, dest);
3735 return JS_UNDEFINED;
3736 }
3737 long expected = 0;
3738 if (argc >= 3) {
3739 double v;
3740 JS_ToFloat64(ctx, &v, argv[2]);
3741 expected = (long)v;
3742 }
3743 ac_log("[fetchBinary] start: url=%s dest=%s expected=%ld\n", url, dest, expected);
3744 // Reset state
3745 current_rt->fetch_binary_pending = 1;
3746 current_rt->fetch_binary_done = 0;
3747 current_rt->fetch_binary_ok = 0;
3748 current_rt->fetch_binary_progress = 0.0f;
3749 current_rt->fetch_binary_expected = expected;
3750 strncpy(current_rt->fetch_binary_dest, dest, sizeof(current_rt->fetch_binary_dest) - 1);
3751 current_rt->fetch_binary_dest[sizeof(current_rt->fetch_binary_dest) - 1] = 0;
3752 // Remove stale files
3753 unlink("/tmp/ac_fb_rc");
3754 unlink("/tmp/ac_fb_err");
3755 unlink(dest);
3756 // Start curl in background
3757 char cmd[1024];
3758 snprintf(cmd, sizeof(cmd),
3759 "sh -c 'curl -fSL --retry 3 --retry-delay 1 --connect-timeout 8 --max-time 600 "
3760 "--cacert /etc/pki/tls/certs/ca-bundle.crt "
3761 "--output \"%s\" \"%s\" 2>/tmp/ac_fb_err;"
3762 " echo $? > /tmp/ac_fb_rc' &", dest, url);
3763 ac_log("[fetchBinary] cmd: curl -> %s\n", dest);
3764 system(cmd);
3765 JS_FreeCString(ctx, url);
3766 JS_FreeCString(ctx, dest);
3767 return JS_UNDEFINED;
3768}
3769
3770// Detect the EFI partition we booted from:
3771// - If /sys/block/sda/removable == 1 → USB → /dev/sda1
3772// - Else if /dev/nvme0n1p1 exists → NVMe internal → /dev/nvme0n1p1
3773// - Else if /dev/mmcblk0p1 exists → eMMC internal (Chromebooks) → /dev/mmcblk0p1
3774// - Fallback: /dev/sda1
3775static void detect_boot_device(char *out, size_t len) {
3776 char removable[8] = {0};
3777 if (read_sysfs("/sys/block/sda/removable", removable, sizeof(removable)) > 0
3778 && removable[0] == '1') {
3779 snprintf(out, len, "/dev/sda1");
3780 ac_log("[flash] boot device: USB (/dev/sda1, removable)");
3781 return;
3782 }
3783 if (access("/dev/nvme0n1p1", F_OK) == 0) {
3784 snprintf(out, len, "/dev/nvme0n1p1");
3785 ac_log("[flash] boot device: NVMe (/dev/nvme0n1p1)");
3786 return;
3787 }
3788 if (access("/dev/mmcblk0p1", F_OK) == 0) {
3789 snprintf(out, len, "/dev/mmcblk0p1");
3790 ac_log("[flash] boot device: eMMC (/dev/mmcblk0p1)");
3791 return;
3792 }
3793 // Fallback — try sda1 anyway
3794 snprintf(out, len, "/dev/sda1");
3795 ac_log("[flash] boot device: fallback (/dev/sda1)");
3796}
3797
3798// Pure C file copy (no shell needed)
3799static long flash_copy_file(const char *src, const char *dst) {
3800 FILE *in = fopen(src, "rb");
3801 if (!in) {
3802 ac_log("[flash] fopen src failed: %s errno=%d (%s)", src, errno, strerror(errno));
3803 return -1;
3804 }
3805 // Delete destination first, then sync to flush vfat metadata
3806 unlink(dst);
3807 sync();
3808 FILE *out = fopen(dst, "wb");
3809 if (!out) {
3810 ac_log("[flash] fopen dst failed: %s errno=%d (%s)", dst, errno, strerror(errno));
3811 fclose(in);
3812 return -1;
3813 }
3814 char buf[65536];
3815 long total = 0;
3816 size_t n;
3817 while ((n = fread(buf, 1, sizeof(buf), in)) > 0) {
3818 if (fwrite(buf, 1, n, out) != n) {
3819 ac_log("[flash] fwrite failed at offset %ld errno=%d (%s)", total, errno, strerror(errno));
3820 total = -1;
3821 break;
3822 }
3823 total += n;
3824 }
3825 // Flush stdio buffer, then fsync to force data+metadata to physical disk
3826 // (critical for vfat — without this, data stays in page cache and reboot loses it)
3827 if (total > 0) {
3828 fflush(out);
3829 if (fsync(fileno(out)) != 0) {
3830 ac_log("[flash] fsync failed: errno=%d (%s)", errno, strerror(errno));
3831 total = -1;
3832 }
3833 }
3834 fclose(out);
3835 fclose(in);
3836 return total;
3837}
3838
3839// Byte-for-byte verification: evict destination page cache, re-read from disk,
3840// compare against source (which lives in tmpfs and must NOT be evicted).
3841// Returns number of verified bytes, or -1 on mismatch/error.
3842static void flash_trace(const char *fmt, ...);
3843static void flash_trace_open(void);
3844static void flash_trace_close_and_archive(void);
3845static long flash_verify(const char *src, const char *dst) {
3846 // SIZE CHECK FIRST — cheap, diagnostic, catches the common failure
3847 // where the copy was short (disk full, sync incomplete, device removed
3848 // mid-write, etc.) without needing to read 272 MB twice.
3849 struct stat src_st, dst_st;
3850 if (stat(src, &src_st) != 0) {
3851 ac_log("[verify] stat src=%s failed errno=%d", src, errno);
3852 flash_trace("verify: stat src FAILED errno=%d", errno);
3853 return -1;
3854 }
3855 if (stat(dst, &dst_st) != 0) {
3856 ac_log("[verify] stat dst=%s failed errno=%d", dst, errno);
3857 flash_trace("verify: stat dst FAILED errno=%d", errno);
3858 return -1;
3859 }
3860 flash_trace("verify: src=%ld dst=%ld", (long)src_st.st_size, (long)dst_st.st_size);
3861 if (src_st.st_size != dst_st.st_size) {
3862 ac_log("[verify] SIZE MISMATCH: src=%ld dst=%ld (diff=%ld)",
3863 (long)src_st.st_size, (long)dst_st.st_size,
3864 (long)(src_st.st_size - dst_st.st_size));
3865 flash_trace("verify: SIZE MISMATCH src=%ld dst=%ld",
3866 (long)src_st.st_size, (long)dst_st.st_size);
3867 return -1;
3868 }
3869
3870 // Evict ONLY the destination file's page cache using posix_fadvise.
3871 // DO NOT use drop_caches — that nukes tmpfs pages and destroys the source!
3872 int dst_fd = open(dst, O_RDONLY);
3873 if (dst_fd >= 0) {
3874 posix_fadvise(dst_fd, 0, dst_st.st_size, POSIX_FADV_DONTNEED);
3875 ac_log("[verify] evicted dst page cache (%ld bytes)", (long)dst_st.st_size);
3876 flash_trace("verify: evicted page cache for dst");
3877 close(dst_fd);
3878 }
3879
3880 FILE *fa = fopen(src, "rb");
3881 FILE *fb = fopen(dst, "rb");
3882 if (!fa || !fb) {
3883 ac_log("[verify] fopen failed: src=%s dst=%s", fa ? "ok" : "FAIL", fb ? "ok" : "FAIL");
3884 flash_trace("verify: fopen src=%s dst=%s", fa ? "ok" : "FAIL", fb ? "ok" : "FAIL");
3885 if (fa) fclose(fa);
3886 if (fb) fclose(fb);
3887 return -1;
3888 }
3889
3890 char bufa[65536], bufb[65536];
3891 long verified = 0;
3892 size_t na, nb;
3893 while ((na = fread(bufa, 1, sizeof(bufa), fa)) > 0) {
3894 nb = fread(bufb, 1, sizeof(bufb), fb);
3895 if (na != nb || memcmp(bufa, bufb, na) != 0) {
3896 ac_log("[verify] MISMATCH at offset %ld (read %zu vs %zu)", verified, na, nb);
3897 flash_trace("verify: MISMATCH at offset=%ld src_read=%zu dst_read=%zu",
3898 verified, na, nb);
3899 fclose(fa);
3900 fclose(fb);
3901 return -1;
3902 }
3903 verified += (long)na;
3904 }
3905 // Make sure dst doesn't have extra bytes
3906 nb = fread(bufb, 1, 1, fb);
3907 if (nb != 0) {
3908 ac_log("[verify] dst has extra bytes after %ld", verified);
3909 flash_trace("verify: dst has extra bytes after %ld", verified);
3910 fclose(fa);
3911 fclose(fb);
3912 return -1;
3913 }
3914 fclose(fa);
3915 fclose(fb);
3916 ac_log("[verify] OK: %ld bytes match", verified);
3917 flash_trace("verify: OK %ld bytes match", verified);
3918 return verified;
3919}
3920
3921// Write a line to the flash telemetry ring buffer (thread-safe enough for single writer)
3922static void flash_tlog(ACRuntime *rt, const char *fmt, ...) {
3923 va_list ap;
3924 va_start(ap, fmt);
3925 int idx = rt->flash_log_count % 16;
3926 vsnprintf(rt->flash_log[idx], 128, fmt, ap);
3927 va_end(ap);
3928 __sync_fetch_and_add(&rt->flash_log_count, 1);
3929}
3930
3931// Append a timestamped line to /tmp/flash-trace.log, which lives in tmpfs so
3932// it can't be affected by the vfat partition we're flashing. On flash
3933// completion (success OR failure) this file is copied to /mnt/flash-last.log
3934// for post-mortem. This is INDEPENDENT of ac_log — the previous design
3935// paused ac_log across the entire flash and lost all diagnostic output when
3936// the flash thread hung or crashed. flash_trace NEVER pauses.
3937static FILE *flash_trace_fp = NULL;
3938static void flash_trace_open(void) {
3939 if (flash_trace_fp) { fclose(flash_trace_fp); flash_trace_fp = NULL; }
3940 // Truncate per-flash so we get a fresh log
3941 flash_trace_fp = fopen("/tmp/flash-trace.log", "w");
3942 if (flash_trace_fp) {
3943 fprintf(flash_trace_fp, "=== flash-trace %ld ===\n", (long)time(NULL));
3944 fflush(flash_trace_fp);
3945 }
3946}
3947static void flash_trace(const char *fmt, ...) {
3948 if (!flash_trace_fp) return;
3949 struct timespec ts;
3950 clock_gettime(CLOCK_MONOTONIC, &ts);
3951 fprintf(flash_trace_fp, "[%ld.%03ld] ", (long)ts.tv_sec, ts.tv_nsec / 1000000);
3952 va_list ap;
3953 va_start(ap, fmt);
3954 vfprintf(flash_trace_fp, fmt, ap);
3955 va_end(ap);
3956 if (fmt[0] && fmt[strlen(fmt) - 1] != '\n') fputc('\n', flash_trace_fp);
3957 fflush(flash_trace_fp);
3958 // No fsync — tmpfs doesn't need it and we want speed
3959}
3960static void flash_trace_close_and_archive(void) {
3961 if (flash_trace_fp) { fclose(flash_trace_fp); flash_trace_fp = NULL; }
3962 // Copy to /mnt/flash-last.log for post-mortem after flash completes.
3963 // If /mnt is mid-remount or busy, this might fail silently — that's OK,
3964 // the tmpfs copy is still available while the process lives.
3965 FILE *src = fopen("/tmp/flash-trace.log", "rb");
3966 if (!src) return;
3967 FILE *dst = fopen("/mnt/flash-last.log", "wb");
3968 if (dst) {
3969 char buf[4096];
3970 size_t n;
3971 while ((n = fread(buf, 1, sizeof(buf), src)) > 0) fwrite(buf, 1, n, dst);
3972 fflush(dst);
3973 fsync(fileno(dst));
3974 fclose(dst);
3975 }
3976 fclose(src);
3977}
3978
3979// Flash update background thread: mount EFI, copy vmlinuz, sync, umount
3980// All operations use C syscalls — no shell commands (cp/mkdir/mount not in initramfs PATH)
3981// NOTE: current_rt is __thread (thread-local); pass ACRuntime * via arg instead
3982static void *flash_thread_fn(void *arg) {
3983 ACRuntime *rt = (ACRuntime *)arg;
3984 if (!rt) return NULL;
3985 rt->flash_log_count = 0;
3986 rt->flash_dst[0] = '\0';
3987 rt->flash_same_device = 0;
3988
3989 // Open a dedicated trace log in tmpfs. This survives even if the main
3990 // log gets suspended / the vfat fs we're flashing goes read-only / the
3991 // flash thread hangs. At the end of the thread (success or failure) we
3992 // copy it to /mnt/flash-last.log for post-mortem on the next boot.
3993 flash_trace_open();
3994 flash_trace("flash_thread_fn start");
3995 flash_trace("src=%s device=%s", rt->flash_src, rt->flash_device);
3996
3997 ac_log("[flash] starting: src=%s device=%s", rt->flash_src, rt->flash_device);
3998 flash_tlog(rt, "src=%s", rt->flash_src);
3999 flash_tlog(rt, "device=%s", rt->flash_device);
4000
4001 // Check if device node exists
4002 {
4003 struct stat dev_st;
4004 if (stat(rt->flash_device, &dev_st) != 0) {
4005 ac_log("[flash] device %s does not exist (errno=%d %s)",
4006 rt->flash_device, errno, strerror(errno));
4007 flash_trace("device %s missing errno=%d", rt->flash_device, errno);
4008 flash_trace_close_and_archive();
4009 rt->flash_phase = 4;
4010 rt->flash_ok = 0;
4011 rt->flash_pending = 0;
4012 rt->flash_done = 1;
4013 return NULL;
4014 }
4015 ac_log("[flash] device %s exists (mode=0%o)", rt->flash_device, dev_st.st_mode);
4016 flash_trace("device exists mode=0%o", dev_st.st_mode);
4017 }
4018
4019 // Mount the EFI partition for flashing.
4020 // IMPORTANT: If the flash target is the same device already mounted at /mnt
4021 // (e.g. NVMe-to-NVMe OTA), we MUST use /mnt directly. Mounting the same
4022 // vfat block device twice causes FAT table corruption — each mount has its
4023 // own in-memory FAT, and umount of one will overwrite the other's changes.
4024 const char *efi_mount = NULL;
4025 int did_mount = 0;
4026
4027 // Check if flash target is already mounted at /mnt
4028 extern char log_dev[]; // set during setup_logging()
4029 int same_as_mnt = (log_dev[0] && strcmp(log_dev, rt->flash_device) == 0);
4030 rt->flash_same_device = same_as_mnt;
4031 flash_tlog(rt, "log_dev=%s same=%d", log_dev, same_as_mnt);
4032
4033 if (same_as_mnt) {
4034 // Same device as /mnt — use it directly, never double-mount
4035 if (access("/mnt/EFI/BOOT", F_OK) == 0 || access("/mnt/EFI", F_OK) == 0) {
4036 efi_mount = "/mnt";
4037 ac_log("[flash] flash target = boot device (%s), using /mnt directly", rt->flash_device);
4038 } else {
4039 ac_log("[flash] flash target = boot device but /mnt has no EFI dir");
4040 // Create EFI structure on /mnt
4041 mkdir("/mnt/EFI", 0755);
4042 mkdir("/mnt/EFI/BOOT", 0755);
4043 if (access("/mnt/EFI/BOOT", F_OK) == 0) {
4044 efi_mount = "/mnt";
4045 ac_log("[flash] created EFI/BOOT on /mnt");
4046 }
4047 }
4048 }
4049
4050 if (!efi_mount) {
4051 // Different device — mount fresh at /tmp/efi
4052 mkdir("/tmp/efi", 0755);
4053 umount("/tmp/efi"); // clean up any stale mount
4054
4055 const char *fstypes[] = {"vfat", "ext4", "ext2", NULL};
4056 for (int fi = 0; fstypes[fi] && !did_mount; fi++) {
4057 int mr = mount(rt->flash_device, "/tmp/efi", fstypes[fi], 0, NULL);
4058 if (mr == 0) {
4059 efi_mount = "/tmp/efi";
4060 did_mount = 1;
4061 ac_log("[flash] mounted %s at /tmp/efi (type=%s)", rt->flash_device, fstypes[fi]);
4062 } else {
4063 ac_log("[flash] mount %s as %s failed: errno=%d (%s)",
4064 rt->flash_device, fstypes[fi], errno, strerror(errno));
4065 }
4066 }
4067
4068 // If all mounts failed and this is NVMe, try formatting as vfat (fresh drive)
4069 if (!did_mount && strstr(rt->flash_device, "nvme")) {
4070 ac_log("[flash] NVMe mount failed — attempting mkfs.vfat on %s", rt->flash_device);
4071 char cmd[256];
4072 snprintf(cmd, sizeof(cmd), "mkfs.vfat -F 32 -n ACBOOT %s 2>&1", rt->flash_device);
4073 FILE *p = popen(cmd, "r");
4074 if (p) {
4075 char buf[256];
4076 while (fgets(buf, sizeof(buf), p)) ac_log("[flash] mkfs: %s", buf);
4077 int rc = pclose(p);
4078 ac_log("[flash] mkfs exit=%d", rc);
4079 if (rc == 0) {
4080 if (mount(rt->flash_device, "/tmp/efi", "vfat", 0, NULL) == 0) {
4081 efi_mount = "/tmp/efi";
4082 did_mount = 1;
4083 ac_log("[flash] mounted fresh vfat on %s", rt->flash_device);
4084 }
4085 }
4086 }
4087 }
4088
4089 // Last resort: check /mnt
4090 if (!did_mount) {
4091 if (access("/mnt/EFI/BOOT", F_OK) == 0) {
4092 efi_mount = "/mnt";
4093 ac_log("[flash] using existing /mnt mount (fresh mount failed)");
4094 flash_trace("falling back to existing /mnt mount");
4095 } else {
4096 ac_log("[flash] ABORT: mount %s failed and /mnt has no EFI/BOOT",
4097 rt->flash_device);
4098 flash_trace("ABORT mount %s failed and no /mnt/EFI/BOOT", rt->flash_device);
4099 flash_trace_close_and_archive();
4100 rt->flash_phase = 4;
4101 rt->flash_ok = 0;
4102 rt->flash_pending = 0;
4103 rt->flash_done = 1;
4104 return NULL;
4105 }
4106 }
4107 }
4108 flash_trace("efi_mount=%s did_mount=%d", efi_mount, did_mount);
4109 // Create EFI directory structure if it doesn't exist (fresh NVMe install)
4110 char efi_dir[512];
4111 snprintf(efi_dir, sizeof(efi_dir), "%s/EFI", efi_mount);
4112 mkdir(efi_dir, 0755);
4113 snprintf(efi_dir, sizeof(efi_dir), "%s/EFI/BOOT", efi_mount);
4114 mkdir(efi_dir, 0755);
4115 if (access(efi_dir, F_OK) != 0) {
4116 ac_log("[flash] ABORT: %s could not be created", efi_dir);
4117 flash_trace("ABORT could not create %s", efi_dir);
4118 flash_trace_close_and_archive();
4119 if (did_mount) umount("/tmp/efi");
4120 rt->flash_phase = 4;
4121 rt->flash_ok = 0;
4122 rt->flash_pending = 0;
4123 rt->flash_done = 1;
4124 return NULL;
4125 }
4126
4127 // If splash chainloader is present, kernel lives at KERNEL.EFI; else BOOTX64.EFI
4128 char dst[512];
4129 char kernel_path[512];
4130 snprintf(kernel_path, sizeof(kernel_path), "%s/EFI/BOOT/KERNEL.EFI", efi_mount);
4131 if (access(kernel_path, F_OK) == 0) {
4132 snprintf(dst, sizeof(dst), "%s", kernel_path);
4133 ac_log("[flash] chainloader detected, updating KERNEL.EFI");
4134 } else {
4135 snprintf(dst, sizeof(dst), "%s/EFI/BOOT/BOOTX64.EFI", efi_mount);
4136 ac_log("[flash] no chainloader, updating BOOTX64.EFI");
4137 }
4138 ac_log("[flash] destination: %s (efi_mount=%s, same_device=%d, did_mount=%d)",
4139 dst, efi_mount, same_as_mnt, did_mount);
4140 strncpy(rt->flash_dst, dst, sizeof(rt->flash_dst) - 1);
4141 rt->flash_dst[sizeof(rt->flash_dst) - 1] = '\0';
4142 flash_tlog(rt, "dst=%s mount=%s", dst, efi_mount);
4143
4144 // NOTE: we intentionally no longer ac_log_pause() the main log during
4145 // the flash. The old design closed /mnt/ac-native.log while writing to
4146 // the same vfat partition, which caused every flash failure to leave
4147 // zero diagnostic trail — if the thread hung or the mount went
4148 // read-only mid-write, ac_log_resume() never fired and the entire
4149 // session's logs after the pause were silently dropped. Instead we
4150 // write flash progress to flash_trace (tmpfs, can't be affected by the
4151 // partition we're flashing) AND best-effort to the main log. At the
4152 // end we archive the trace to /mnt/flash-last.log.
4153 int same_mount = same_as_mnt || !did_mount;
4154 flash_trace("same_mount=%d same_as_mnt=%d did_mount=%d", same_mount, same_as_mnt, did_mount);
4155
4156 // Pre-flight: validate source file exists and has reasonable size
4157 {
4158 struct stat src_st;
4159 if (stat(rt->flash_src, &src_st) != 0 || src_st.st_size < 1048576) {
4160 ac_log("[flash] ABORT: source %s invalid (size=%ld, errno=%d)",
4161 rt->flash_src, (long)(src_st.st_size), errno);
4162 flash_trace("ABORT source invalid size=%ld errno=%d",
4163 (long)(src_st.st_size), errno);
4164 flash_trace_close_and_archive();
4165 if (did_mount) umount("/tmp/efi");
4166 rt->flash_phase = 4;
4167 rt->flash_ok = 0;
4168 rt->flash_pending = 0;
4169 rt->flash_done = 1;
4170 return NULL;
4171 }
4172 ac_log("[flash] source validated: %ld bytes", (long)src_st.st_size);
4173 flash_trace("source validated size=%ld", (long)src_st.st_size);
4174 }
4175
4176 // Phase 1: Backup previous kernel, then write new one
4177 rt->flash_phase = 1;
4178
4179 // Keep previous version as .prev for rollback
4180 {
4181 char prev[512];
4182 snprintf(prev, sizeof(prev), "%s.prev", dst);
4183 if (access(dst, F_OK) == 0) {
4184 // Remove old .prev, rename current to .prev
4185 unlink(prev);
4186 if (rename(dst, prev) == 0) {
4187 ac_log("[flash] backed up previous kernel to %s", prev);
4188 flash_tlog(rt, "backup=%s", prev);
4189 } else {
4190 ac_log("[flash] backup rename failed (errno=%d), continuing anyway", errno);
4191 }
4192 }
4193 }
4194
4195 flash_tlog(rt, "writing %s -> %s", rt->flash_src, dst);
4196 long copied = flash_copy_file(rt->flash_src, dst);
4197 flash_tlog(rt, "wrote %ld bytes", copied);
4198
4199 // Also update Microsoft Boot Manager path (ThinkPad BIOS often boots this first)
4200 // Check available space first — don't write second copy if it won't fit.
4201 // FAT32 can't hardlink, so we need actual space for both copies.
4202 {
4203 char ms_dir[512], ms_dst[512];
4204 snprintf(ms_dir, sizeof(ms_dir), "%s/EFI/Microsoft", efi_mount);
4205 mkdir(ms_dir, 0755);
4206 snprintf(ms_dir, sizeof(ms_dir), "%s/EFI/Microsoft/Boot", efi_mount);
4207 mkdir(ms_dir, 0755);
4208 snprintf(ms_dst, sizeof(ms_dst), "%s/EFI/Microsoft/Boot/bootmgfw.efi", efi_mount);
4209
4210 // Check free space on partition before writing second copy
4211 struct statvfs vfs;
4212 long free_bytes = 0;
4213 if (statvfs(efi_mount, &vfs) == 0) {
4214 free_bytes = (long)vfs.f_bavail * (long)vfs.f_bsize;
4215 }
4216 long need_bytes = copied + (1024 * 1024); // need kernel size + 1MB margin
4217
4218 if (free_bytes >= need_bytes) {
4219 // Backup old MS boot path too
4220 char ms_prev[512];
4221 snprintf(ms_prev, sizeof(ms_prev), "%s.prev", ms_dst);
4222 unlink(ms_prev);
4223 if (access(ms_dst, F_OK) == 0) {
4224 rename(ms_dst, ms_prev); // safe: old version preserved
4225 }
4226 long ms_copied = flash_copy_file(rt->flash_src, ms_dst);
4227 if (ms_copied > 0) {
4228 unlink(ms_prev); // success: remove backup
4229 ac_log("[flash] also wrote Microsoft boot path: %ld bytes -> %s", ms_copied, ms_dst);
4230 flash_tlog(rt, "ms_boot=%ld", ms_copied);
4231 } else {
4232 // Write failed — restore backup
4233 if (access(ms_prev, F_OK) == 0) {
4234 rename(ms_prev, ms_dst);
4235 ac_log("[flash] MS boot write failed, restored previous");
4236 }
4237 }
4238 } else {
4239 ac_log("[flash] skipping MS boot path: %ldMB free < %ldMB needed",
4240 free_bytes / 1048576, need_bytes / 1048576);
4241 flash_tlog(rt, "ms_boot=skipped (space)");
4242 }
4243 }
4244
4245 // Optional initramfs copy — for OTA updates where the kernel is now
4246 // slim (Phase 2 de-embed) and must load `\initramfs.cpio.gz` from the
4247 // ESP root via its baked-in `initrd=` cmdline. Without this step, an
4248 // os.mjs OTA would leave the new kernel paired with the PREVIOUS
4249 // initramfs, which is a latent source of boot breakage whenever any
4250 // file inside initramfs changes.
4251 if (rt->flash_initramfs_src[0]) {
4252 char initramfs_dst[512];
4253 snprintf(initramfs_dst, sizeof(initramfs_dst), "%s/initramfs.cpio.gz", efi_mount);
4254 ac_log("[flash] writing initramfs: %s -> %s", rt->flash_initramfs_src, initramfs_dst);
4255 flash_tlog(rt, "initramfs: %s -> %s", rt->flash_initramfs_src, initramfs_dst);
4256 // Keep previous as .prev for rollback
4257 char initramfs_prev[512];
4258 snprintf(initramfs_prev, sizeof(initramfs_prev), "%s.prev", initramfs_dst);
4259 if (access(initramfs_dst, F_OK) == 0) {
4260 unlink(initramfs_prev);
4261 rename(initramfs_dst, initramfs_prev);
4262 }
4263 long initramfs_copied = flash_copy_file(rt->flash_initramfs_src, initramfs_dst);
4264 flash_tlog(rt, "initramfs wrote %ld bytes", initramfs_copied);
4265 if (initramfs_copied <= 0) {
4266 ac_log("[flash] initramfs copy FAILED (copied=%ld) — restoring .prev",
4267 initramfs_copied);
4268 if (access(initramfs_prev, F_OK) == 0) {
4269 unlink(initramfs_dst);
4270 rename(initramfs_prev, initramfs_dst);
4271 }
4272 // Non-fatal to the kernel write — but flag as failure since the
4273 // new kernel won't boot without a matching initramfs.
4274 flash_trace_close_and_archive();
4275 rt->flash_phase = 4;
4276 rt->flash_ok = 0;
4277 rt->flash_pending = 0;
4278 rt->flash_done = 1;
4279 return NULL;
4280 }
4281 }
4282
4283 // Phase 2: Syncing to disk — belt-and-suspenders for vfat
4284 rt->flash_phase = 2;
4285
4286 // Force page cache eviction of the written file so subsequent reads hit disk
4287 {
4288 int dst_fd = open(dst, O_RDONLY);
4289 if (dst_fd >= 0) {
4290 struct stat st;
4291 if (fstat(dst_fd, &st) == 0) {
4292 posix_fadvise(dst_fd, 0, st.st_size, POSIX_FADV_DONTNEED);
4293 ac_log("[flash] evicted written file from page cache (%ld bytes)", (long)st.st_size);
4294 }
4295 close(dst_fd);
4296 }
4297 }
4298
4299 // syncfs + multi-round sync with generous waits. USB flash controllers
4300 // can take SECONDS to flush 272 MB through vfat metadata, and the old
4301 // 500 ms window was frequently insufficient — we'd then try to verify
4302 // against data that still existed only in the kernel's dirty page
4303 // cache, so the eviction step exposed a mismatch with uncommitted
4304 // backing store bytes.
4305 int mnt_fd = open(efi_mount, O_RDONLY);
4306 if (mnt_fd >= 0) {
4307 int sr = syncfs(mnt_fd);
4308 if (sr != 0) {
4309 ac_log("[flash] syncfs failed: errno=%d (%s)", errno, strerror(errno));
4310 flash_trace("syncfs FAILED errno=%d", errno);
4311 } else {
4312 flash_trace("syncfs ok");
4313 }
4314 close(mnt_fd);
4315 } else {
4316 ac_log("[flash] WARNING: open(%s) for syncfs failed errno=%d", efi_mount, errno);
4317 flash_trace("open(efi_mount) for syncfs FAILED errno=%d", errno);
4318 }
4319 sync();
4320 flash_trace("sync #1 returned");
4321 // First wait — let the block layer start flushing
4322 usleep(1500000); // 1.5 s
4323 sync();
4324 flash_trace("sync #2 after 1.5s wait");
4325 // Second wait — USB flash write can be very slow for 272 MB
4326 usleep(1500000); // another 1.5 s = 3 s total
4327 sync();
4328 flash_trace("sync #3 after 3s total wait");
4329
4330 // Previously we remounted /mnt ro+rw to force vfat metadata flush. That
4331 // was fragile: it bypassed the VFS safely-unmount path, briefly made
4332 // /mnt read-only so any concurrent log write would fail, and on systems
4333 // with mount stacking (init.sh leaves nvme0n1p1 stacked under sda1)
4334 // the remount hit the wrong layer. syncfs + sync + 3 s of settle time
4335 // is more reliable.
4336 ac_log("[flash] sync complete");
4337 flash_trace("sync complete (3 rounds, 3s total wait)");
4338
4339 if (copied <= 0) {
4340 ac_log("[flash] copy failed (copied=%ld)", copied);
4341 flash_trace("copy FAILED copied=%ld", copied);
4342 flash_trace_close_and_archive();
4343 rt->flash_phase = 4;
4344 rt->flash_ok = 0;
4345 rt->flash_pending = 0;
4346 rt->flash_done = 1;
4347 return NULL;
4348 }
4349
4350 // Phase 3: Verify — read back from physical disk and compare byte-for-byte.
4351 // flash_verify now does a size-check first (fast, diagnostic) before
4352 // paying the cost of a 272 MB read+compare.
4353 rt->flash_phase = 3;
4354 flash_trace("verify starting (expecting %ld bytes)", copied);
4355 long verified = flash_verify(rt->flash_src, dst);
4356 flash_tlog(rt, "verify=%ld (expected=%ld)", verified, copied);
4357 flash_trace("verify returned: %ld", verified);
4358
4359 if (did_mount) {
4360 umount("/tmp/efi");
4361 ac_log("[flash] unmounted /tmp/efi");
4362 flash_trace("unmounted /tmp/efi");
4363 }
4364
4365 rt->flash_verified_bytes = verified;
4366 if (verified != copied) {
4367 ac_log("[flash] VERIFY FAILED: wrote %ld, verified %ld", copied, verified);
4368 flash_trace("VERIFY FAILED wrote=%ld verified=%ld", copied, verified);
4369 // CRITICAL: restore previous kernel so device remains bootable
4370 {
4371 char prev[512];
4372 snprintf(prev, sizeof(prev), "%s.prev", dst);
4373 if (access(prev, F_OK) == 0) {
4374 unlink(dst);
4375 if (rename(prev, dst) == 0) {
4376 ac_log("[flash] RESTORED previous kernel from %s — device still bootable", prev);
4377 flash_trace("RESTORED previous kernel from %s", prev);
4378 sync();
4379 } else {
4380 ac_log("[flash] CRITICAL: restore failed errno=%d", errno);
4381 flash_trace("CRITICAL restore FAILED errno=%d", errno);
4382 }
4383 } else {
4384 flash_trace("no .prev backup to restore (device may be unbootable)");
4385 }
4386 }
4387 flash_trace_close_and_archive();
4388 rt->flash_phase = 4;
4389 rt->flash_ok = 0;
4390 rt->flash_pending = 0;
4391 rt->flash_done = 1;
4392 return NULL;
4393 }
4394
4395 // Set UEFI boot order so firmware boots our kernel (not a stale vendor entry).
4396 // Many OEM firmwares (HP, Dell, Lenovo) have hardcoded boot entries pointing at
4397 // vendor-specific EFI paths. Without updating the boot order, the firmware may
4398 // boot an old kernel (e.g. from a previous OS install) instead of ours.
4399 {
4400 // Mount efivarfs if not already mounted (needed to write UEFI variables)
4401 struct stat evs;
4402 if (stat("/sys/firmware/efi/efivars", &evs) == 0) {
4403 // Try mounting (harmless if already mounted)
4404 mount("efivarfs", "/sys/firmware/efi/efivars", "efivarfs", 0, NULL);
4405
4406 // Extract disk device from partition path for efibootmgr --disk
4407 // e.g. /dev/sda1 -> /dev/sda, /dev/nvme0n1p1 -> /dev/nvme0n1
4408 char disk[64] = "", partnum[8] = "";
4409 strncpy(disk, rt->flash_device, sizeof(disk) - 1);
4410 char *p = disk + strlen(disk) - 1;
4411 // Walk back past the partition number
4412 while (p > disk && *p >= '0' && *p <= '9') p--;
4413 if (p > disk) {
4414 strncpy(partnum, p + 1, sizeof(partnum) - 1);
4415 // For NVMe: /dev/nvme0n1p1 -> strip 'p' before part number
4416 if (*p == 'p' && p > disk && *(p-1) >= '0' && *(p-1) <= '9')
4417 *p = '\0';
4418 else
4419 *(p + 1) = '\0';
4420 }
4421
4422 // Use efibootmgr if available (cleanest approach)
4423 char cmd[512];
4424 snprintf(cmd, sizeof(cmd),
4425 "efibootmgr -c -d %s -p %s -L 'AC Native OS' "
4426 "-l '\\EFI\\BOOT\\BOOTX64.EFI' 2>&1",
4427 disk, partnum[0] ? partnum : "1");
4428 ac_log("[flash] setting UEFI boot entry: %s", cmd);
4429 flash_tlog(rt, "efibootmgr: %s %s part=%s", disk, partnum, partnum[0] ? partnum : "1");
4430
4431 FILE *efip = popen(cmd, "r");
4432 if (efip) {
4433 char buf[256];
4434 while (fgets(buf, sizeof(buf), efip)) {
4435 // Trim newline
4436 char *nl = strchr(buf, '\n');
4437 if (nl) *nl = '\0';
4438 ac_log("[flash] efibootmgr: %s", buf);
4439 flash_tlog(rt, "efi: %s", buf);
4440 }
4441 int rc = pclose(efip);
4442 if (rc == 0) {
4443 ac_log("[flash] UEFI boot entry created successfully");
4444 flash_tlog(rt, "efi_boot=ok");
4445 } else {
4446 ac_log("[flash] efibootmgr exit=%d (boot entry may not be set)", rc);
4447 flash_tlog(rt, "efi_boot=fail(%d)", rc);
4448 }
4449 } else {
4450 ac_log("[flash] efibootmgr not available — UEFI boot order unchanged");
4451 flash_tlog(rt, "efi_boot=no_efibootmgr");
4452 }
4453 } else {
4454 ac_log("[flash] no EFI variables support — skipping boot order update");
4455 }
4456 }
4457
4458 // Remove downloaded file to free /tmp space
4459 unlink(rt->flash_src);
4460 ac_log("[flash] done: %ld bytes written, verified OK", copied);
4461 flash_trace("done: %ld bytes verified OK", copied);
4462 flash_trace_close_and_archive();
4463 rt->flash_phase = 4;
4464 rt->flash_ok = 1;
4465 rt->flash_pending = 0;
4466 rt->flash_done = 1;
4467 return NULL;
4468}
4469
4470// ─────────────────────────────────────────────────────────────────
4471// Audio diagnostic — list PCMs + play a short test tone on an arbitrary
4472// device. Used by the speaker.mjs piece to figure out which ALSA PCM
4473// actually produces sound on a given machine (vs wiring-dependent guesses
4474// like hw:0,0). The tone plays in a detached thread so the JS caller
4475// returns immediately; the piece UI stays responsive.
4476// ─────────────────────────────────────────────────────────────────
4477
4478static JSValue js_audio_list_pcms(JSContext *ctx, JSValueConst this_val,
4479 int argc, JSValueConst *argv) {
4480 (void)this_val; (void)argc; (void)argv;
4481 JSValue arr = JS_NewArray(ctx);
4482 int idx = 0;
4483 for (int c = 0; c < 4; c++) {
4484 for (int d = 0; d < 8; d++) {
4485 char path[80];
4486 snprintf(path, sizeof(path),
4487 "/proc/asound/card%d/pcm%dp/info", c, d);
4488 FILE *fp = fopen(path, "r");
4489 if (!fp) continue;
4490 char line[256], id_str[96] = "", name_str[96] = "";
4491 while (fgets(line, sizeof(line), fp)) {
4492 char *nl = strchr(line, '\n'); if (nl) *nl = 0;
4493 if (!strncmp(line, "id: ", 4))
4494 snprintf(id_str, sizeof(id_str), "%s", line + 4);
4495 else if (!strncmp(line, "name: ", 6))
4496 snprintf(name_str, sizeof(name_str), "%s", line + 6);
4497 }
4498 fclose(fp);
4499 char dev[16];
4500 snprintf(dev, sizeof(dev), "hw:%d,%d", c, d);
4501 JSValue obj = JS_NewObject(ctx);
4502 JS_SetPropertyStr(ctx, obj, "device", JS_NewString(ctx, dev));
4503 JS_SetPropertyStr(ctx, obj, "card", JS_NewInt32(ctx, c));
4504 JS_SetPropertyStr(ctx, obj, "num", JS_NewInt32(ctx, d));
4505 JS_SetPropertyStr(ctx, obj, "id", JS_NewString(ctx, id_str));
4506 JS_SetPropertyStr(ctx, obj, "name", JS_NewString(ctx, name_str));
4507 /* Tag the PCM that ac-native's main audio thread picked so the
4508 * UI can highlight it. */
4509 if (current_rt && current_rt->audio &&
4510 strcmp(current_rt->audio->audio_device, dev) == 0)
4511 JS_SetPropertyStr(ctx, obj, "active", JS_NewBool(ctx, 1));
4512 JS_SetPropertyUint32(ctx, arr, idx++, obj);
4513 }
4514 }
4515 return arr;
4516}
4517
4518struct audio_probe_args {
4519 char device[32];
4520 int freq_hz;
4521 int duration_ms;
4522 float volume;
4523};
4524
4525static void *audio_probe_thread(void *arg) {
4526 struct audio_probe_args *a = (struct audio_probe_args *)arg;
4527 snd_pcm_t *pcm = NULL;
4528 int err = snd_pcm_open(&pcm, a->device, SND_PCM_STREAM_PLAYBACK, 0);
4529 if (err < 0) {
4530 ac_log("[audio-probe] open %s failed: %s",
4531 a->device, snd_strerror(err));
4532 free(a);
4533 return NULL;
4534 }
4535 unsigned int rate = 48000;
4536 snd_pcm_uframes_t period = 480, buffer = 1920;
4537 err = snd_pcm_set_params(pcm,
4538 SND_PCM_FORMAT_S16_LE,
4539 SND_PCM_ACCESS_RW_INTERLEAVED,
4540 2, /* stereo */
4541 rate,
4542 1, /* soft_resample */
4543 100 * 1000); /* 100ms latency */
4544 if (err < 0) {
4545 ac_log("[audio-probe] set_params %s failed: %s",
4546 a->device, snd_strerror(err));
4547 snd_pcm_close(pcm);
4548 free(a);
4549 return NULL;
4550 }
4551 /* Synthesize + play a sine wave of a->freq_hz at a->volume for
4552 * a->duration_ms milliseconds. Stereo S16 LE. */
4553 int total_frames = (long long)rate * a->duration_ms / 1000;
4554 int16_t *buf = (int16_t *)malloc(period * 2 * sizeof(int16_t));
4555 if (!buf) { snd_pcm_close(pcm); free(a); return NULL; }
4556 double phase = 0.0;
4557 double step = 2.0 * 3.14159265358979 * (double)a->freq_hz / (double)rate;
4558 int32_t amp = (int32_t)(a->volume * 32760.0);
4559 if (amp < 0) amp = 0; if (amp > 32760) amp = 32760;
4560 int written = 0;
4561 ac_log("[audio-probe] %s %dHz %dms vol=%.2f", a->device, a->freq_hz,
4562 a->duration_ms, a->volume);
4563 while (written < total_frames) {
4564 int chunk = total_frames - written;
4565 if (chunk > (int)period) chunk = (int)period;
4566 for (int i = 0; i < chunk; i++) {
4567 int16_t s = (int16_t)(sin(phase) * amp);
4568 buf[i * 2] = s;
4569 buf[i * 2 + 1] = s;
4570 phase += step;
4571 if (phase > 6.283185307179586) phase -= 6.283185307179586;
4572 }
4573 snd_pcm_sframes_t w = snd_pcm_writei(pcm, buf, chunk);
4574 if (w < 0) w = snd_pcm_recover(pcm, w, 1);
4575 if (w < 0) { ac_log("[audio-probe] writei failed: %s",
4576 snd_strerror((int)w)); break; }
4577 written += (int)w;
4578 }
4579 snd_pcm_drain(pcm);
4580 snd_pcm_close(pcm);
4581 free(buf);
4582 ac_log("[audio-probe] %s done (%d frames)", a->device, written);
4583 free(a);
4584 return NULL;
4585}
4586
4587static JSValue js_audio_test_pcm(JSContext *ctx, JSValueConst this_val,
4588 int argc, JSValueConst *argv) {
4589 (void)this_val;
4590 if (argc < 1) return JS_FALSE;
4591 struct audio_probe_args *a = (struct audio_probe_args *)calloc(1, sizeof(*a));
4592 if (!a) return JS_FALSE;
4593 const char *dev = JS_ToCString(ctx, argv[0]);
4594 if (!dev) { free(a); return JS_FALSE; }
4595 strncpy(a->device, dev, sizeof(a->device) - 1);
4596 JS_FreeCString(ctx, dev);
4597 a->freq_hz = 440;
4598 a->duration_ms = 500;
4599 a->volume = 0.3f;
4600 if (argc >= 2) JS_ToInt32(ctx, &a->freq_hz, argv[1]);
4601 if (argc >= 3) JS_ToInt32(ctx, &a->duration_ms, argv[2]);
4602 if (argc >= 4) {
4603 double vol; JS_ToFloat64(ctx, &vol, argv[3]);
4604 a->volume = (float)vol;
4605 }
4606 pthread_t th;
4607 pthread_attr_t attr;
4608 pthread_attr_init(&attr);
4609 pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
4610 int pr = pthread_create(&th, &attr, audio_probe_thread, a);
4611 pthread_attr_destroy(&attr);
4612 if (pr != 0) { free(a); return JS_FALSE; }
4613 return JS_TRUE;
4614}
4615
4616// ─────────────────────────────────────────────────────────────────
4617// Firmware update thread — runs /bin/ac-firmware-install (bundled copy of
4618// system/public/install-firmware.sh) and streams its stdout into
4619// rt->fw_log. The script handles all the dangerous bits (backup via
4620// flashrom -r, CBFS swap via cbfstool, flashrom -w) — we just orchestrate
4621// + surface progress. Availability is gated by os.mjs before this runs.
4622// ─────────────────────────────────────────────────────────────────
4623static void fw_log_line(ACRuntime *rt, const char *line) {
4624 int idx = rt->fw_log_count % 32;
4625 strncpy(rt->fw_log[idx], line, sizeof(rt->fw_log[idx]) - 1);
4626 rt->fw_log[idx][sizeof(rt->fw_log[idx]) - 1] = 0;
4627 __sync_fetch_and_add(&rt->fw_log_count, 1);
4628 ac_log("[fw] %s", line);
4629}
4630
4631static void *fw_thread_fn(void *arg) {
4632 ACRuntime *rt = (ACRuntime *)arg;
4633 if (!rt) return NULL;
4634 rt->fw_log_count = 0;
4635 rt->fw_backup_path[0] = 0;
4636 if (access("/bin/ac-firmware-install", X_OK) != 0) {
4637 fw_log_line(rt, "ac-firmware-install not bundled in initramfs");
4638 rt->fw_ok = 0;
4639 rt->fw_pending = 0;
4640 rt->fw_done = 1;
4641 return NULL;
4642 }
4643 char cmd[512];
4644 snprintf(cmd, sizeof(cmd),
4645 "AC_CDN=https://aesthetic.computer "
4646 "SPLASH_URL=https://oven.aesthetic.computer/firmware/splash.bmp "
4647 "/bin/ac-firmware-install %s 2>&1",
4648 rt->fw_args[0] ? rt->fw_args : "");
4649 fw_log_line(rt, "starting ac-firmware-install");
4650 FILE *p = popen(cmd, "r");
4651 if (!p) {
4652 fw_log_line(rt, "popen failed");
4653 rt->fw_ok = 0;
4654 rt->fw_pending = 0;
4655 rt->fw_done = 1;
4656 return NULL;
4657 }
4658 char line[256];
4659 while (fgets(line, sizeof(line), p)) {
4660 char *nl = strchr(line, '\n');
4661 if (nl) *nl = 0;
4662 // Capture the backup path line so JS can show it for "copy to USB"
4663 const char *bk = strstr(line, "/tmp/firmware-backup-");
4664 if (bk) {
4665 int i = 0;
4666 while (bk[i] && bk[i] != ' ' && i < (int)sizeof(rt->fw_backup_path) - 1) {
4667 rt->fw_backup_path[i] = bk[i]; i++;
4668 }
4669 rt->fw_backup_path[i] = 0;
4670 }
4671 fw_log_line(rt, line);
4672 }
4673 int rc = pclose(p);
4674 rt->fw_ok = (WIFEXITED(rc) && WEXITSTATUS(rc) == 0) ? 1 : 0;
4675 char finish[64];
4676 snprintf(finish, sizeof(finish), "ac-firmware-install exit=%d",
4677 WIFEXITED(rc) ? WEXITSTATUS(rc) : -1);
4678 fw_log_line(rt, finish);
4679 rt->fw_pending = 0;
4680 rt->fw_done = 1;
4681 return NULL;
4682}
4683
4684// system.firmwareInstall([mode]) — mode: "install" (default), "dry-run",
4685// "restore". Returns immediately; poll system.firmware.{pending,done,ok,log}.
4686static JSValue js_firmware_install(JSContext *ctx, JSValueConst this_val,
4687 int argc, JSValueConst *argv) {
4688 (void)this_val;
4689 if (!current_rt) return JS_UNDEFINED;
4690 if (current_rt->fw_pending) {
4691 ac_log("[fw] refused: already running");
4692 return JS_NewBool(ctx, 0);
4693 }
4694 current_rt->fw_args[0] = 0;
4695 if (argc >= 1 && JS_IsString(argv[0])) {
4696 const char *mode = JS_ToCString(ctx, argv[0]);
4697 if (mode) {
4698 if (strcmp(mode, "dry-run") == 0) {
4699 strncpy(current_rt->fw_args, "--dry-run",
4700 sizeof(current_rt->fw_args) - 1);
4701 } else if (strcmp(mode, "restore") == 0) {
4702 strncpy(current_rt->fw_args, "--restore",
4703 sizeof(current_rt->fw_args) - 1);
4704 }
4705 JS_FreeCString(ctx, mode);
4706 }
4707 }
4708 current_rt->fw_pending = 1;
4709 current_rt->fw_done = 0;
4710 current_rt->fw_ok = 0;
4711 pthread_attr_t attr;
4712 pthread_attr_init(&attr);
4713 pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
4714 int pr = pthread_create(¤t_rt->fw_thread, &attr,
4715 fw_thread_fn, current_rt);
4716 pthread_attr_destroy(&attr);
4717 if (pr != 0) {
4718 current_rt->fw_pending = 0;
4719 current_rt->fw_done = 1;
4720 return JS_NewBool(ctx, 0);
4721 }
4722 return JS_NewBool(ctx, 1);
4723}
4724
4725// system.flashUpdate(srcPath[, devicePath[, initramfsSrcPath]])
4726// devicePath defaults to auto-detected boot device if omitted.
4727// initramfsSrcPath is optional — when provided, the flash thread also copies
4728// it to `<ESP>/initramfs.cpio.gz` (matching the kernel's baked-in
4729// `initrd=\initramfs.cpio.gz` cmdline). Required for OTA updates since the
4730// kernel no longer embeds initramfs (Phase 2 de-embed).
4731static JSValue js_flash_update(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4732 (void)this_val;
4733 if (!current_rt || argc < 1) { ac_log("[flash] flashUpdate called with no runtime/args\n"); return JS_UNDEFINED; }
4734 const char *src = JS_ToCString(ctx, argv[0]);
4735 if (!src) { ac_log("[flash] flashUpdate: null src arg\n"); return JS_UNDEFINED; }
4736 ac_log("[flash] flashUpdate called: src=%s\n", src);
4737 current_rt->flash_pending = 1;
4738 current_rt->flash_done = 0;
4739 current_rt->flash_ok = 0;
4740 strncpy(current_rt->flash_src, src, sizeof(current_rt->flash_src) - 1);
4741 current_rt->flash_src[sizeof(current_rt->flash_src) - 1] = 0;
4742 JS_FreeCString(ctx, src);
4743 current_rt->flash_initramfs_src[0] = '\0';
4744 // Device: use arg[1] if provided, else auto-detect
4745 if (argc >= 2 && !JS_IsUndefined(argv[1]) && !JS_IsNull(argv[1])) {
4746 const char *dev = JS_ToCString(ctx, argv[1]);
4747 if (dev) {
4748 strncpy(current_rt->flash_device, dev, sizeof(current_rt->flash_device) - 1);
4749 current_rt->flash_device[sizeof(current_rt->flash_device) - 1] = 0;
4750 JS_FreeCString(ctx, dev);
4751 }
4752 } else {
4753 detect_boot_device(current_rt->flash_device, sizeof(current_rt->flash_device));
4754 }
4755 // Optional initramfs source — arg[2]
4756 if (argc >= 3 && !JS_IsUndefined(argv[2]) && !JS_IsNull(argv[2])) {
4757 const char *init_src = JS_ToCString(ctx, argv[2]);
4758 if (init_src) {
4759 strncpy(current_rt->flash_initramfs_src, init_src,
4760 sizeof(current_rt->flash_initramfs_src) - 1);
4761 current_rt->flash_initramfs_src[sizeof(current_rt->flash_initramfs_src) - 1] = 0;
4762 ac_log("[flash] initramfs src=%s", init_src);
4763 JS_FreeCString(ctx, init_src);
4764 }
4765 }
4766 // Pass runtime as arg (current_rt is thread-local, unavailable in new thread)
4767 pthread_attr_t attr;
4768 pthread_attr_init(&attr);
4769 pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
4770 int pr = pthread_create(¤t_rt->flash_thread, &attr, flash_thread_fn, current_rt);
4771 pthread_attr_destroy(&attr);
4772 if (pr != 0) {
4773 ac_log("[flash] pthread_create failed (%d)", pr);
4774 current_rt->flash_ok = 0;
4775 current_rt->flash_pending = 0;
4776 current_rt->flash_done = 1;
4777 }
4778 return JS_UNDEFINED;
4779}
4780
4781// system.reboot() — triggers system reboot (direct syscall, no external binary needed)
4782// system.setPowerRole("port0", "source"|"sink") — swap USB-C power role
4783static JSValue js_set_power_role(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4784 (void)this_val;
4785 if (argc < 2) return JS_NewBool(ctx, 0);
4786 const char *port = JS_ToCString(ctx, argv[0]);
4787 const char *role = JS_ToCString(ctx, argv[1]);
4788 if (!port || !role) {
4789 if (port) JS_FreeCString(ctx, port);
4790 if (role) JS_FreeCString(ctx, role);
4791 return JS_NewBool(ctx, 0);
4792 }
4793 // Validate role
4794 if (strcmp(role, "source") != 0 && strcmp(role, "sink") != 0) {
4795 JS_FreeCString(ctx, port); JS_FreeCString(ctx, role);
4796 return JS_NewBool(ctx, 0);
4797 }
4798 // Validate port name (must be "portN")
4799 if (strncmp(port, "port", 4) != 0 || !port[4]) {
4800 JS_FreeCString(ctx, port); JS_FreeCString(ctx, role);
4801 return JS_NewBool(ctx, 0);
4802 }
4803 char path[256];
4804 snprintf(path, sizeof(path), "/sys/class/typec/%s/power_role", port);
4805 FILE *f = fopen(path, "w");
4806 int ok = 0;
4807 if (f) {
4808 ok = fprintf(f, "%s\n", role) > 0;
4809 fclose(f);
4810 ac_log("[typec] setPowerRole %s -> %s: %s", port, role, ok ? "ok" : "write failed");
4811 } else {
4812 ac_log("[typec] setPowerRole: cannot open %s: %s", path, strerror(errno));
4813 }
4814 JS_FreeCString(ctx, port);
4815 JS_FreeCString(ctx, role);
4816 return JS_NewBool(ctx, ok);
4817}
4818
4819static JSValue build_usb_midi_state_obj(JSContext *ctx) {
4820 ACUsbMidiState state;
4821 int has_state = usb_midi_read_state(&state);
4822 if (!has_state) {
4823 memset(&state, 0, sizeof(state));
4824 snprintf(state.reason, sizeof(state.reason), "uninitialized");
4825 }
4826
4827 JSValue obj = JS_NewObject(ctx);
4828 JS_SetPropertyStr(ctx, obj, "enabled", JS_NewBool(ctx, state.enabled));
4829 JS_SetPropertyStr(ctx, obj, "active", JS_NewBool(ctx, state.active));
4830 JS_SetPropertyStr(ctx, obj, "reason", JS_NewString(ctx, state.reason[0] ? state.reason : "unknown"));
4831 JS_SetPropertyStr(ctx, obj, "udc", JS_NewString(ctx, state.udc));
4832 JS_SetPropertyStr(ctx, obj, "port", JS_NewString(ctx, state.port));
4833 JS_SetPropertyStr(ctx, obj, "powerRole", JS_NewString(ctx, state.power_role));
4834 JS_SetPropertyStr(ctx, obj, "dataRole", JS_NewString(ctx, state.data_role));
4835 JS_SetPropertyStr(ctx, obj, "alsaDevice", JS_NewString(ctx, state.alsa_device));
4836 JS_SetPropertyStr(ctx, obj, "serial", JS_NewString(ctx, state.serial));
4837 return obj;
4838}
4839
4840static JSValue js_usb_midi_status(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4841 (void)this_val; (void)argc; (void)argv;
4842 return build_usb_midi_state_obj(ctx);
4843}
4844
4845static JSValue js_usb_midi_control(JSContext *ctx, const char *action) {
4846 usb_midi_close();
4847 if (access("/scripts/usb-midi-gadget.sh", X_OK) == 0) {
4848 char cmd[256];
4849 snprintf(cmd, sizeof(cmd), "/scripts/usb-midi-gadget.sh %s >/tmp/usb-midi-gadget.log 2>&1", action);
4850 int rc = system(cmd);
4851 ac_log("[usb-midi] control %s rc=%d", action, rc);
4852 } else {
4853 FILE *fp = fopen("/run/usb-midi.state", "w");
4854 if (fp) {
4855 fputs("enabled=0\nactive=0\nreason=missing-script\n", fp);
4856 fclose(fp);
4857 }
4858 ac_log("[usb-midi] control %s failed: missing /scripts/usb-midi-gadget.sh", action);
4859 }
4860 usb_midi_close();
4861 return build_usb_midi_state_obj(ctx);
4862}
4863
4864static JSValue js_usb_midi_enable(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4865 (void)this_val; (void)argc; (void)argv;
4866 return js_usb_midi_control(ctx, "up");
4867}
4868
4869static JSValue js_usb_midi_disable(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4870 (void)this_val; (void)argc; (void)argv;
4871 return js_usb_midi_control(ctx, "down");
4872}
4873
4874static JSValue js_usb_midi_refresh(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4875 (void)this_val; (void)argc; (void)argv;
4876 return js_usb_midi_control(ctx, "refresh");
4877}
4878
4879static JSValue js_usb_midi_note_on(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4880 (void)this_val;
4881 int32_t note = 60;
4882 int32_t velocity = 100;
4883 int32_t channel = 0;
4884 if (argc < 1 || JS_ToInt32(ctx, ¬e, argv[0])) return JS_FALSE;
4885 if (argc >= 2) JS_ToInt32(ctx, &velocity, argv[1]);
4886 if (argc >= 3) JS_ToInt32(ctx, &channel, argv[2]);
4887 return JS_NewBool(ctx, usb_midi_note_on(note, velocity, channel));
4888}
4889
4890static JSValue js_usb_midi_note_off(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4891 (void)this_val;
4892 int32_t note = 60;
4893 int32_t velocity = 0;
4894 int32_t channel = 0;
4895 if (argc < 1 || JS_ToInt32(ctx, ¬e, argv[0])) return JS_FALSE;
4896 if (argc >= 2) JS_ToInt32(ctx, &velocity, argv[1]);
4897 if (argc >= 3) JS_ToInt32(ctx, &channel, argv[2]);
4898 return JS_NewBool(ctx, usb_midi_note_off(note, velocity, channel));
4899}
4900
4901static JSValue js_usb_midi_all_notes_off(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4902 (void)this_val;
4903 int32_t channel = 0;
4904 if (argc >= 1) JS_ToInt32(ctx, &channel, argv[0]);
4905 return JS_NewBool(ctx, usb_midi_all_notes_off(channel));
4906}
4907
4908// Hardened: triple sync with delays, pre-reboot EFI file size sanity check,
4909// perf flush, log flush before rebooting.
4910static JSValue js_reboot(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4911 (void)ctx; (void)this_val; (void)argc; (void)argv;
4912 ac_log("[system] reboot requested");
4913
4914 // Pre-reboot sanity: verify EFI file exists and is >1MB (catch corrupted flash)
4915 {
4916 const char *efi_paths[] = {
4917 "/mnt/EFI/BOOT/BOOTX64.EFI",
4918 "/tmp/efi/EFI/BOOT/BOOTX64.EFI",
4919 NULL
4920 };
4921 int efi_ok = 0;
4922 for (int i = 0; efi_paths[i]; i++) {
4923 struct stat st;
4924 if (stat(efi_paths[i], &st) == 0 && st.st_size > 1048576) {
4925 ac_log("[reboot] EFI verified: %s (%ld bytes)", efi_paths[i], (long)st.st_size);
4926 efi_ok = 1;
4927 break;
4928 }
4929 }
4930 if (!efi_ok) {
4931 ac_log("[reboot] WARNING: no valid EFI file found on mount — rebooting anyway");
4932 }
4933 }
4934
4935 // Flush perf data before reboot
4936 perf_flush();
4937
4938 // Play shutdown chime and let audio drain
4939 if (current_rt && current_rt->audio) {
4940 audio_shutdown_sound(current_rt->audio);
4941 usleep(500000); // 500ms for chime to play
4942 }
4943
4944 // Flush log file
4945 ac_log("[reboot] syncing filesystems...");
4946 ac_log_flush();
4947
4948 // Triple sync with delays — vfat write-back needs time
4949 sync();
4950 usleep(500000);
4951 sync();
4952 usleep(500000);
4953 sync();
4954
4955 ac_log("[reboot] executing reboot syscall");
4956 ac_log_flush();
4957
4958 // Exit with code 2 — init script sees this as reboot request
4959 _exit(2);
4960 return JS_UNDEFINED;
4961}
4962
4963// system.poweroff() — signal main loop to run shutdown animation then exit
4964extern volatile int poweroff_requested;
4965static JSValue js_poweroff(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4966 (void)ctx; (void)this_val; (void)argc; (void)argv;
4967 ac_log("[system] poweroff requested via JS");
4968 poweroff_requested = 1; // main loop will run bye animation + exit(0)
4969 return JS_UNDEFINED;
4970}
4971
4972// ============================================================
4973// JS Native Functions — PTY Terminal Emulator
4974// ============================================================
4975
4976// system.pty.spawn(cmd, [args...], cols, rows) — spawn a process in a PTY
4977static JSValue js_pty_spawn(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
4978 (void)this_val;
4979 if (!current_rt || argc < 1) return JS_FALSE;
4980 if (current_rt->pty_active) {
4981 pty_destroy(¤t_rt->pty);
4982 current_rt->pty_active = 0;
4983 }
4984
4985 const char *cmd = JS_ToCString(ctx, argv[0]);
4986 if (!cmd) return JS_FALSE;
4987
4988 // Collect args from JS array (argv[1]) or use cmd as argv[0]
4989 char *child_argv[32] = {0};
4990 int nargs = 0;
4991 child_argv[nargs++] = (char *)cmd;
4992
4993 if (argc > 1 && JS_IsArray(ctx, argv[1])) {
4994 int len = 0;
4995 JSValue jlen = JS_GetPropertyStr(ctx, argv[1], "length");
4996 JS_ToInt32(ctx, &len, jlen);
4997 JS_FreeValue(ctx, jlen);
4998 for (int i = 0; i < len && nargs < 30; i++) {
4999 JSValue el = JS_GetPropertyUint32(ctx, argv[1], i);
5000 child_argv[nargs++] = (char *)JS_ToCString(ctx, el);
5001 JS_FreeValue(ctx, el);
5002 }
5003 }
5004 child_argv[nargs] = NULL;
5005
5006 int cols = 80, rows = 24;
5007 if (argc > 2) JS_ToInt32(ctx, &cols, argv[2]);
5008 if (argc > 3) JS_ToInt32(ctx, &rows, argv[3]);
5009
5010 int ok = pty_spawn(¤t_rt->pty, cols, rows, cmd, child_argv);
5011
5012 // Free ToCString results for args
5013 for (int i = 1; i < nargs; i++) {
5014 JS_FreeCString(ctx, child_argv[i]);
5015 }
5016 JS_FreeCString(ctx, cmd);
5017
5018 if (ok == 0) {
5019 current_rt->pty_active = 1;
5020 return JS_TRUE;
5021 }
5022 return JS_FALSE;
5023}
5024
5025// system.pty.write(str) — send input to PTY
5026static JSValue js_pty_write(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5027 (void)this_val;
5028 if (!current_rt || !current_rt->pty_active || argc < 1) return JS_UNDEFINED;
5029 size_t len;
5030 const char *data = JS_ToCStringLen(ctx, &len, argv[0]);
5031 if (!data) return JS_UNDEFINED;
5032 pty_write(¤t_rt->pty, data, (int)len);
5033 JS_FreeCString(ctx, data);
5034 return JS_UNDEFINED;
5035}
5036
5037// system.pty.resize(cols, rows) — resize PTY
5038static JSValue js_pty_resize(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5039 (void)this_val;
5040 if (!current_rt || !current_rt->pty_active || argc < 2) return JS_UNDEFINED;
5041 int cols, rows;
5042 JS_ToInt32(ctx, &cols, argv[0]);
5043 JS_ToInt32(ctx, &rows, argv[1]);
5044 pty_resize(¤t_rt->pty, cols, rows);
5045 return JS_UNDEFINED;
5046}
5047
5048// system.pty.kill() — kill the PTY child
5049static JSValue js_pty_kill(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5050 (void)this_val; (void)argc; (void)argv;
5051 if (!current_rt || !current_rt->pty_active) return JS_UNDEFINED;
5052 pty_destroy(¤t_rt->pty);
5053 current_rt->pty_active = 0;
5054 return JS_UNDEFINED;
5055}
5056
5057// JS Native Functions — PTY2 (second terminal for split-screen)
5058// ============================================================
5059
5060static JSValue js_pty2_spawn(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5061 (void)this_val;
5062 if (!current_rt || argc < 1) return JS_FALSE;
5063 if (current_rt->pty2_active) {
5064 pty_destroy(¤t_rt->pty2);
5065 current_rt->pty2_active = 0;
5066 }
5067 const char *cmd = JS_ToCString(ctx, argv[0]);
5068 if (!cmd) return JS_FALSE;
5069 char *child_argv[32] = {0};
5070 int nargs = 0;
5071 child_argv[nargs++] = (char *)cmd;
5072 if (argc > 1 && JS_IsArray(ctx, argv[1])) {
5073 int len = 0;
5074 JSValue jlen = JS_GetPropertyStr(ctx, argv[1], "length");
5075 JS_ToInt32(ctx, &len, jlen);
5076 JS_FreeValue(ctx, jlen);
5077 for (int i = 0; i < len && nargs < 30; i++) {
5078 JSValue el = JS_GetPropertyUint32(ctx, argv[1], i);
5079 child_argv[nargs++] = (char *)JS_ToCString(ctx, el);
5080 JS_FreeValue(ctx, el);
5081 }
5082 }
5083 child_argv[nargs] = NULL;
5084 int cols = 80, rows = 24;
5085 if (argc > 2) JS_ToInt32(ctx, &cols, argv[2]);
5086 if (argc > 3) JS_ToInt32(ctx, &rows, argv[3]);
5087 int ok = pty_spawn(¤t_rt->pty2, cols, rows, cmd, child_argv);
5088 for (int i = 1; i < nargs; i++) JS_FreeCString(ctx, child_argv[i]);
5089 JS_FreeCString(ctx, cmd);
5090 if (ok == 0) { current_rt->pty2_active = 1; return JS_TRUE; }
5091 return JS_FALSE;
5092}
5093
5094static JSValue js_pty2_write(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5095 (void)this_val;
5096 if (!current_rt || !current_rt->pty2_active || argc < 1) return JS_UNDEFINED;
5097 size_t len;
5098 const char *data = JS_ToCStringLen(ctx, &len, argv[0]);
5099 if (!data) return JS_UNDEFINED;
5100 pty_write(¤t_rt->pty2, data, (int)len);
5101 JS_FreeCString(ctx, data);
5102 return JS_UNDEFINED;
5103}
5104
5105static JSValue js_pty2_resize(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5106 (void)this_val;
5107 if (!current_rt || !current_rt->pty2_active || argc < 2) return JS_UNDEFINED;
5108 int cols, rows;
5109 JS_ToInt32(ctx, &cols, argv[0]);
5110 JS_ToInt32(ctx, &rows, argv[1]);
5111 pty_resize(¤t_rt->pty2, cols, rows);
5112 return JS_UNDEFINED;
5113}
5114
5115static JSValue js_pty2_kill(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5116 (void)this_val; (void)argc; (void)argv;
5117 if (!current_rt || !current_rt->pty2_active) return JS_UNDEFINED;
5118 pty_destroy(¤t_rt->pty2);
5119 current_rt->pty2_active = 0;
5120 return JS_UNDEFINED;
5121}
5122
5123// system.jump(pieceName) — request piece switch (handled in main loop)
5124// system.saveConfig(key, value) — merge a key-value pair into /mnt/config.json
5125static JSValue js_save_config(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5126 (void)this_val;
5127 if (argc < 2) return JS_FALSE;
5128 const char *key = JS_ToCString(ctx, argv[0]);
5129 const char *val = JS_ToCString(ctx, argv[1]);
5130 if (!key || !val) {
5131 JS_FreeCString(ctx, key);
5132 JS_FreeCString(ctx, val);
5133 return JS_FALSE;
5134 }
5135
5136 // Read existing config
5137 char buf[8192] = {0};
5138 FILE *f = fopen("/mnt/config.json", "r");
5139 if (f) {
5140 size_t n = fread(buf, 1, sizeof(buf) - 1, f);
5141 buf[n] = '\0';
5142 fclose(f);
5143 }
5144
5145 // Simple JSON merge: if key exists, replace its value; otherwise append
5146 // We build a new JSON string manually since we don't have a JSON library
5147 char out[8192] = {0};
5148 char needle[256];
5149 snprintf(needle, sizeof(needle), "\"%s\"", key);
5150
5151 // Detect if value is a bare literal (boolean/number) — don't quote it
5152 int is_bare = (strcmp(val, "true") == 0 || strcmp(val, "false") == 0 ||
5153 (val[0] >= '0' && val[0] <= '9') || val[0] == '-');
5154 const char *qL = is_bare ? "" : "\"";
5155 const char *qR = is_bare ? "" : "\"";
5156
5157 char *existing = strstr(buf, needle);
5158 if (existing && buf[0] == '{') {
5159 // Key exists — find value start (after ":") and end (next , or })
5160 char *colon = strchr(existing, ':');
5161 if (colon) {
5162 char *vstart = colon + 1;
5163 while (*vstart == ' ') vstart++;
5164 char *vend;
5165 if (*vstart == '"') {
5166 vend = strchr(vstart + 1, '"');
5167 if (vend) vend++; // past closing quote
5168 } else {
5169 vend = vstart;
5170 while (*vend && *vend != ',' && *vend != '}') vend++;
5171 }
5172 if (vend) {
5173 int prefix_len = (int)(vstart - buf);
5174 snprintf(out, sizeof(out), "%.*s%s%s%s%s",
5175 prefix_len, buf, qL, val, qR, vend);
5176 }
5177 }
5178 } else if (buf[0] == '{') {
5179 // Append new key before closing }
5180 char *closing = strrchr(buf, '}');
5181 if (closing) {
5182 int prefix_len = (int)(closing - buf);
5183 // Check if there's existing content (non-empty object)
5184 int has_content = 0;
5185 for (char *p = buf + 1; p < closing; p++) {
5186 if (*p != ' ' && *p != '\n' && *p != '\r' && *p != '\t') {
5187 has_content = 1; break;
5188 }
5189 }
5190 snprintf(out, sizeof(out), "%.*s%s\"%s\":%s%s%s}",
5191 prefix_len, buf, has_content ? "," : "", key, qL, val, qR);
5192 }
5193 } else {
5194 // No valid JSON — create new
5195 snprintf(out, sizeof(out), "{\"%s\":%s%s%s}", key, qL, val, qR);
5196 }
5197
5198 f = fopen("/mnt/config.json", "w");
5199 if (f) {
5200 fputs(out[0] ? out : buf, f);
5201 fflush(f);
5202 fsync(fileno(f));
5203 fclose(f);
5204 config_cache_dirty = 1;
5205 ac_log("[config] saved %s to /mnt/config.json\n", key);
5206 // Update runtime config if it's a known field
5207 if (current_rt && strcmp(key, "handle") == 0) {
5208 strncpy(current_rt->handle, val, sizeof(current_rt->handle) - 1);
5209 } else if (current_rt && strcmp(key, "piece") == 0) {
5210 strncpy(current_rt->piece, val, sizeof(current_rt->piece) - 1);
5211 } else if (strcmp(key, "voice") == 0) {
5212 extern int voice_off;
5213 voice_off = (strcmp(val, "off") == 0);
5214 ac_log("[config] voice_off = %d\n", voice_off);
5215 }
5216 } else {
5217 ac_log("[config] ERROR: cannot write /mnt/config.json\n");
5218 }
5219
5220 JS_FreeCString(ctx, key);
5221 JS_FreeCString(ctx, val);
5222 return JS_TRUE;
5223}
5224
5225// system.startSSH() — start dropbear SSH daemon (generates host key if needed)
5226static int ssh_started = 0;
5227static JSValue js_start_ssh(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5228 (void)this_val; (void)argc; (void)argv;
5229 if (ssh_started) return JS_NewBool(ctx, 1);
5230
5231 // Check if dropbear exists
5232 if (access("/usr/sbin/dropbear", X_OK) != 0 && access("/bin/dropbear", X_OK) != 0) {
5233 ac_log("[ssh] dropbear not found in initramfs\n");
5234 return JS_NewBool(ctx, 0);
5235 }
5236
5237 // Generate host key if needed
5238 if (access("/etc/dropbear/dropbear_ed25519_host_key", F_OK) != 0) {
5239 ac_log("[ssh] generating host key...\n");
5240 system("mkdir -p /etc/dropbear && "
5241 "dropbearkey -t ed25519 -f /etc/dropbear/dropbear_ed25519_host_key 2>/dev/null");
5242 }
5243
5244 // Start dropbear (no password auth, only key-based or allow all for now)
5245 // -R = generate keys if missing, -B = allow blank passwords (for root w/o passwd)
5246 // -p 22 = listen on port 22, -F = foreground (& to background)
5247 ac_log("[ssh] starting dropbear on port 22...\n");
5248 system("dropbear -R -B -p 22 -P /tmp/dropbear.pid 2>/tmp/dropbear.log &");
5249 ssh_started = 1;
5250 ac_log("[ssh] dropbear started\n");
5251 return JS_NewBool(ctx, 1);
5252}
5253
5254// system.execNode(script) — run Node.js with a script string, async
5255static JSValue js_exec_node(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5256 (void)this_val;
5257 if (!current_rt || argc < 1) return JS_UNDEFINED;
5258 if (current_rt->fetch_pending) return JS_FALSE; // reuse fetch slot for output
5259
5260 const char *script = JS_ToCString(ctx, argv[0]);
5261 if (!script) return JS_UNDEFINED;
5262
5263 // Write script to temp file
5264 FILE *sf = fopen("/tmp/ac_node_script.mjs", "w");
5265 if (sf) { fputs(script, sf); fclose(sf); }
5266
5267 ac_log("[node] executing script (%ld bytes)\n", (long)strlen(script));
5268 // Run node and capture output to /tmp/ac_fetch.json (reusing fetch result slot)
5269 unlink("/tmp/ac_fetch.json");
5270 unlink("/tmp/ac_fetch_rc");
5271 unlink("/tmp/ac_fetch_err");
5272 system("sh -c 'node /tmp/ac_node_script.mjs > /tmp/ac_fetch.json 2>/tmp/ac_fetch_err;"
5273 " echo $? > /tmp/ac_fetch_rc' &");
5274 current_rt->fetch_pending = 1;
5275 current_rt->fetch_result[0] = 0;
5276 current_rt->fetch_error[0] = 0;
5277
5278 JS_FreeCString(ctx, script);
5279 return JS_TRUE;
5280}
5281
5282// system.listPieces() — scan /pieces/*.mjs and /pieces/*.lisp, return array of piece names
5283static JSValue js_list_pieces(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5284 (void)this_val; (void)argc; (void)argv;
5285 JSValue arr = JS_NewArray(ctx);
5286 DIR *d = opendir("/pieces");
5287 if (d) {
5288 struct dirent *ent;
5289 uint32_t idx = 0;
5290 while ((ent = readdir(d)) != NULL) {
5291 if (ent->d_name[0] == '.') continue;
5292 // Check for .mjs
5293 char *dot = strstr(ent->d_name, ".mjs");
5294 if (dot && dot[4] == '\0') {
5295 char name[64];
5296 int len = (int)(dot - ent->d_name);
5297 if (len > 63) len = 63;
5298 memcpy(name, ent->d_name, len);
5299 name[len] = '\0';
5300 JS_SetPropertyUint32(ctx, arr, idx++, JS_NewString(ctx, name));
5301 continue;
5302 }
5303 // Check for .lisp — include extension so list.mjs can distinguish
5304 dot = strstr(ent->d_name, ".lisp");
5305 if (dot && dot[5] == '\0') {
5306 JS_SetPropertyUint32(ctx, arr, idx++, JS_NewString(ctx, ent->d_name));
5307 }
5308 }
5309 closedir(d);
5310 }
5311 return arr;
5312}
5313
5314// system.listPrinters() — detect USB printers via /dev/usb/lp* and sysfs
5315static JSValue js_list_printers(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5316 (void)this_val; (void)argc; (void)argv;
5317 JSValue arr = JS_NewArray(ctx);
5318 uint32_t idx = 0;
5319
5320 // Scan /dev/usb/lp* devices
5321 DIR *d = opendir("/dev/usb");
5322 if (d) {
5323 struct dirent *ent;
5324 while ((ent = readdir(d)) != NULL) {
5325 if (strncmp(ent->d_name, "lp", 2) != 0) continue;
5326 char devpath[64];
5327 snprintf(devpath, sizeof(devpath), "/dev/usb/%s", ent->d_name);
5328
5329 JSValue printer = JS_NewObject(ctx);
5330 JS_SetPropertyStr(ctx, printer, "device", JS_NewString(ctx, devpath));
5331 JS_SetPropertyStr(ctx, printer, "id", JS_NewString(ctx, ent->d_name));
5332
5333 // Try to find printer name via sysfs usbmisc
5334 char syspath[256], name[128] = {0}, vendor[128] = {0};
5335 snprintf(syspath, sizeof(syspath),
5336 "/sys/class/usbmisc/%s/device/../product", ent->d_name);
5337 FILE *fp = fopen(syspath, "r");
5338 if (fp) {
5339 if (fgets(name, sizeof(name), fp)) {
5340 char *nl = strchr(name, '\n');
5341 if (nl) *nl = 0;
5342 }
5343 fclose(fp);
5344 }
5345 snprintf(syspath, sizeof(syspath),
5346 "/sys/class/usbmisc/%s/device/../manufacturer", ent->d_name);
5347 fp = fopen(syspath, "r");
5348 if (fp) {
5349 if (fgets(vendor, sizeof(vendor), fp)) {
5350 char *nl = strchr(vendor, '\n');
5351 if (nl) *nl = 0;
5352 }
5353 fclose(fp);
5354 }
5355
5356 if (name[0])
5357 JS_SetPropertyStr(ctx, printer, "name", JS_NewString(ctx, name));
5358 else
5359 JS_SetPropertyStr(ctx, printer, "name",
5360 JS_NewString(ctx, ent->d_name));
5361 if (vendor[0])
5362 JS_SetPropertyStr(ctx, printer, "vendor",
5363 JS_NewString(ctx, vendor));
5364
5365 JS_SetPropertyUint32(ctx, arr, idx++, printer);
5366 }
5367 closedir(d);
5368 }
5369 return arr;
5370}
5371
5372// system.printRaw(devicePath, byteArray) — write raw bytes to printer device
5373// byteArray is a JS array of integers (0-255)
5374static JSValue js_print_raw(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5375 (void)this_val;
5376 if (argc < 2) return JS_FALSE;
5377 const char *devpath = JS_ToCString(ctx, argv[0]);
5378 if (!devpath) return JS_FALSE;
5379
5380 // Validate device path starts with /dev/usb/lp
5381 if (strncmp(devpath, "/dev/usb/lp", 11) != 0) {
5382 ac_log("[print] rejected non-printer path: %s\n", devpath);
5383 JS_FreeCString(ctx, devpath);
5384 return JS_FALSE;
5385 }
5386
5387 // Get array length
5388 JSValue lenVal = JS_GetPropertyStr(ctx, argv[1], "length");
5389 uint32_t len = 0;
5390 JS_ToUint32(ctx, &len, lenVal);
5391 JS_FreeValue(ctx, lenVal);
5392
5393 if (len == 0 || len > 65536) {
5394 ac_log("[print] invalid data length: %u\n", len);
5395 JS_FreeCString(ctx, devpath);
5396 return JS_FALSE;
5397 }
5398
5399 // Build byte buffer from JS array
5400 uint8_t *buf = malloc(len);
5401 if (!buf) {
5402 JS_FreeCString(ctx, devpath);
5403 return JS_FALSE;
5404 }
5405 for (uint32_t i = 0; i < len; i++) {
5406 JSValue v = JS_GetPropertyUint32(ctx, argv[1], i);
5407 int32_t b = 0;
5408 JS_ToInt32(ctx, &b, v);
5409 JS_FreeValue(ctx, v);
5410 buf[i] = (uint8_t)(b & 0xFF);
5411 }
5412
5413 // Open device and write
5414 FILE *fp = fopen(devpath, "wb");
5415 if (!fp) {
5416 ac_log("[print] failed to open %s: %s\n", devpath, strerror(errno));
5417 free(buf);
5418 JS_FreeCString(ctx, devpath);
5419 return JS_FALSE;
5420 }
5421
5422 size_t written = fwrite(buf, 1, len, fp);
5423 fflush(fp);
5424 fclose(fp);
5425 free(buf);
5426
5427 ac_log("[print] wrote %zu/%u bytes to %s\n", written, len, devpath);
5428 JS_FreeCString(ctx, devpath);
5429 return written == len ? JS_TRUE : JS_FALSE;
5430}
5431
5432static JSValue js_jump(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5433 (void)this_val;
5434 if (!current_rt || argc < 1) return JS_UNDEFINED;
5435 const char *name = JS_ToCString(ctx, argv[0]);
5436 if (!name) return JS_UNDEFINED;
5437 strncpy(current_rt->jump_target, name, sizeof(current_rt->jump_target) - 1);
5438 current_rt->jump_target[sizeof(current_rt->jump_target) - 1] = 0;
5439 current_rt->jump_requested = 1;
5440 ac_log("[system] jump requested: %s", name);
5441 JS_FreeCString(ctx, name);
5442 return JS_UNDEFINED;
5443}
5444
5445// system.volumeAdjust(delta) — +1 up, -1 down, 0 mute toggle
5446static JSValue js_volume_adjust(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5447 (void)this_val;
5448 if (!current_rt || !current_rt->audio || argc < 1) return JS_UNDEFINED;
5449 int delta = 0;
5450 JS_ToInt32(ctx, &delta, argv[0]);
5451 audio_volume_adjust(current_rt->audio, delta);
5452 return JS_UNDEFINED;
5453}
5454
5455// system.brightnessAdjust(delta) — +1 up, -1 down
5456static JSValue js_brightness_adjust(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5457 (void)this_val;
5458 if (argc < 1) return JS_UNDEFINED;
5459 int delta = 0;
5460 JS_ToInt32(ctx, &delta, argv[0]);
5461 // Read current backlight from sysfs
5462 DIR *bldir = opendir("/sys/class/backlight");
5463 if (!bldir) return JS_UNDEFINED;
5464 struct dirent *ent;
5465 while ((ent = readdir(bldir))) {
5466 if (ent->d_name[0] == '.') continue;
5467 char tmp[160];
5468 snprintf(tmp, sizeof(tmp), "/sys/class/backlight/%s/max_brightness", ent->d_name);
5469 FILE *f = fopen(tmp, "r");
5470 if (!f) continue;
5471 int bl_max = 100;
5472 fscanf(f, "%d", &bl_max);
5473 fclose(f);
5474 snprintf(tmp, sizeof(tmp), "/sys/class/backlight/%s/brightness", ent->d_name);
5475 f = fopen(tmp, "r");
5476 int cur = bl_max / 2;
5477 if (f) { fscanf(f, "%d", &cur); fclose(f); }
5478 int step = bl_max / 20; // 5%
5479 if (step < 1) step = 1;
5480 cur += delta * step;
5481 if (cur < 1) cur = 1;
5482 if (cur > bl_max) cur = bl_max;
5483 f = fopen(tmp, "w");
5484 if (f) { fprintf(f, "%d", cur); fclose(f); }
5485 break;
5486 }
5487 closedir(bldir);
5488 return JS_UNDEFINED;
5489}
5490
5491// system.openBrowser(url) — launch Firefox for OAuth login
5492// Under Wayland: fork+exec firefox as sibling Wayland client (cage composites it on top)
5493// Under DRM: releases DRM master, runs cage+firefox, reclaims on exit
5494// Returns true if browser exited normally.
5495static JSValue js_open_browser(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5496 (void)this_val;
5497 if (argc < 1 || !current_rt) return JS_FALSE;
5498 const char *url = JS_ToCString(ctx, argv[0]);
5499 if (!url) return JS_FALSE;
5500
5501 ac_log("[browser] Opening: %s", url);
5502 int rc = -1;
5503
5504#ifdef USE_WAYLAND
5505 if (getenv("WAYLAND_DISPLAY")) {
5506 // Under cage: just fork+exec Firefox as a sibling Wayland client
5507 // cage handles window stacking — Firefox appears on top, ac-native resumes when it exits
5508 pid_t pid = fork();
5509 if (pid == 0) {
5510 // Child process — exec firefox
5511 setenv("MOZ_ENABLE_WAYLAND", "1", 1);
5512 setenv("GDK_BACKEND", "wayland", 1);
5513 setenv("MOZ_DISABLE_CONTENT_SANDBOX", "1", 1);
5514 setenv("DBUS_SESSION_BUS_ADDRESS", "disabled:", 1);
5515 setenv("MOZ_DBUS_REMOTE", "0", 1);
5516 setenv("HOME", "/tmp", 1);
5517 setenv("LD_LIBRARY_PATH", "/lib64:/opt/firefox", 1);
5518 setenv("GRE_HOME", "/opt/firefox", 1);
5519 mkdir("/tmp/.mozilla", 0700);
5520 execlp("/opt/firefox/firefox-bin", "firefox-bin",
5521 "--kiosk", "--no-remote", "--new-instance", url, NULL);
5522 _exit(127);
5523 } else if (pid > 0) {
5524 // Parent — wait for Firefox to exit
5525 int status;
5526 waitpid(pid, &status, 0);
5527 rc = WIFEXITED(status) ? WEXITSTATUS(status) : -1;
5528 ac_log("[browser] Firefox exited: %d", rc);
5529 } else {
5530 ac_log("[browser] fork failed: %s", strerror(errno));
5531 }
5532 } else
5533#endif
5534 {
5535 // Legacy DRM handoff path
5536 extern int drm_release_master(void *display);
5537 extern int drm_acquire_master(void *display);
5538 extern void *g_display;
5539 if (g_display) {
5540 drm_release_master(g_display);
5541 ac_log("[browser] Released DRM master");
5542 }
5543
5544 char cmd[4096];
5545 mkdir("/tmp/xdg", 0700);
5546 mkdir("/tmp/.mozilla", 0700);
5547 snprintf(cmd, sizeof(cmd),
5548 "export HOME=/tmp && "
5549 "export XDG_RUNTIME_DIR=/tmp/xdg && "
5550 "export WLR_BACKENDS=drm && "
5551 "export WLR_SESSION=direct && "
5552 "export WLR_RENDERER=pixman && "
5553 "export LIBSEAT_BACKEND=noop && "
5554 "export LD_LIBRARY_PATH=/lib64:/opt/firefox && "
5555 "export LIBGL_ALWAYS_SOFTWARE=1 && "
5556 "export MOZ_ENABLE_WAYLAND=1 && "
5557 "export GDK_BACKEND=wayland && "
5558 "cage -s -- /opt/firefox/firefox-bin "
5559 "--kiosk --no-remote --new-instance '%s' "
5560 ">/mnt/cage.log 2>&1; echo \"exit=$?\" >>/mnt/cage.log",
5561 url);
5562 rc = system(cmd);
5563 ac_log("[browser] cage exited: %d", rc);
5564
5565 if (g_display) {
5566 drm_acquire_master(g_display);
5567 ac_log("[browser] Reclaimed DRM master");
5568 }
5569 }
5570
5571 JS_FreeCString(ctx, url);
5572 return JS_NewBool(ctx, rc == 0);
5573}
5574
5575// system.qrEncode(text) → { size: N, modules: [bool, bool, ...] }
5576// Encodes text as QR code using nayuki qrcodegen, returns module grid.
5577static JSValue js_qr_encode(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5578 (void)this_val;
5579 if (argc < 1) return JS_UNDEFINED;
5580 const char *text = JS_ToCString(ctx, argv[0]);
5581 if (!text) return JS_UNDEFINED;
5582
5583 // Buffers for qrcodegen (version 1-40, up to ~4296 chars at ECC LOW)
5584 uint8_t qrcode[qrcodegen_BUFFER_LEN_MAX];
5585 uint8_t tempBuf[qrcodegen_BUFFER_LEN_MAX];
5586
5587 bool ok = qrcodegen_encodeText(text, tempBuf, qrcode,
5588 qrcodegen_Ecc_LOW, qrcodegen_VERSION_MIN, qrcodegen_VERSION_MAX,
5589 qrcodegen_Mask_AUTO, true);
5590 JS_FreeCString(ctx, text);
5591
5592 if (!ok) return JS_UNDEFINED;
5593
5594 int size = qrcodegen_getSize(qrcode);
5595 JSValue result = JS_NewObject(ctx);
5596 JS_SetPropertyStr(ctx, result, "size", JS_NewInt32(ctx, size));
5597
5598 JSValue modules = JS_NewArray(ctx);
5599 for (int y = 0; y < size; y++) {
5600 for (int x = 0; x < size; x++) {
5601 JS_SetPropertyUint32(ctx, modules, (uint32_t)(y * size + x),
5602 JS_NewBool(ctx, qrcodegen_getModule(qrcode, x, y)));
5603 }
5604 }
5605 JS_SetPropertyStr(ctx, result, "modules", modules);
5606 return result;
5607}
5608
5609// nopaint.is(stateStr) — returns true if current nopaint state matches
5610static JSValue js_nopaint_is(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5611 (void)this_val;
5612 if (argc < 1 || !current_rt) return JS_FALSE;
5613 const char *query = JS_ToCString(ctx, argv[0]);
5614 if (!query) return JS_FALSE;
5615 int match = 0;
5616 if (strcmp(query, "painting") == 0) match = (current_rt->nopaint_state == 1);
5617 else if (strcmp(query, "idle") == 0) match = (current_rt->nopaint_state == 0);
5618 JS_FreeCString(ctx, query);
5619 return JS_NewBool(ctx, match);
5620}
5621
5622// nopaint.cancelStroke() — abort current stroke
5623static JSValue js_nopaint_cancel(JSContext *ctx, JSValueConst this_val, int argc, JSValueConst *argv) {
5624 (void)this_val; (void)argc; (void)argv;
5625 if (current_rt) { current_rt->nopaint_state = 0; current_rt->nopaint_needs_bake = 0; }
5626 return JS_UNDEFINED;
5627}
5628
5629static JSValue build_system_obj(JSContext *ctx) {
5630 JSValue sys = JS_NewObject(ctx);
5631
5632 // Battery info — scan /sys/class/power_supply/ for any battery
5633 char buf[64];
5634 int capacity = -1, power_now = 0, energy_now = 0;
5635 const char *status = "Unknown";
5636 char bat_path[128] = "";
5637
5638 // Try BAT0, BAT1, or scan directory
5639 const char *bat_names[] = {"BAT0", "BAT1", NULL};
5640 for (int i = 0; bat_names[i] && !bat_path[0]; i++) {
5641 char tmp[128];
5642 snprintf(tmp, sizeof(tmp), "/sys/class/power_supply/%s/capacity", bat_names[i]);
5643 if (read_sysfs(tmp, buf, sizeof(buf)) > 0) {
5644 snprintf(bat_path, sizeof(bat_path), "/sys/class/power_supply/%s", bat_names[i]);
5645 }
5646 }
5647 // Fallback: scan directory
5648 if (!bat_path[0]) {
5649 DIR *dir = opendir("/sys/class/power_supply");
5650 if (dir) {
5651 struct dirent *ent;
5652 while ((ent = readdir(dir)) && !bat_path[0]) {
5653 if (ent->d_name[0] == '.') continue;
5654 char tmp[160];
5655 snprintf(tmp, sizeof(tmp), "/sys/class/power_supply/%s/type", ent->d_name);
5656 if (read_sysfs(tmp, buf, sizeof(buf)) > 0 && strcmp(buf, "Battery") == 0) {
5657 snprintf(bat_path, sizeof(bat_path), "/sys/class/power_supply/%s", ent->d_name);
5658 }
5659 }
5660 closedir(dir);
5661 }
5662 }
5663
5664 if (bat_path[0]) {
5665 char tmp[160];
5666 snprintf(tmp, sizeof(tmp), "%s/capacity", bat_path);
5667 if (read_sysfs(tmp, buf, sizeof(buf)) > 0) capacity = atoi(buf);
5668 snprintf(tmp, sizeof(tmp), "%s/status", bat_path);
5669 if (read_sysfs(tmp, buf, sizeof(buf)) > 0)
5670 status = (strcmp(buf, "Charging") == 0) ? "Charging" :
5671 (strcmp(buf, "Full") == 0) ? "Full" :
5672 (strcmp(buf, "Discharging") == 0) ? "Discharging" : "Unknown";
5673 snprintf(tmp, sizeof(tmp), "%s/power_now", bat_path);
5674 if (read_sysfs(tmp, buf, sizeof(buf)) > 0) power_now = atoi(buf);
5675 snprintf(tmp, sizeof(tmp), "%s/energy_now", bat_path);
5676 if (read_sysfs(tmp, buf, sizeof(buf)) > 0) energy_now = atoi(buf);
5677 }
5678
5679 JSValue battery = JS_NewObject(ctx);
5680 JS_SetPropertyStr(ctx, battery, "percent", JS_NewInt32(ctx, capacity));
5681 JS_SetPropertyStr(ctx, battery, "charging", JS_NewBool(ctx, strcmp(status, "Charging") == 0));
5682 JS_SetPropertyStr(ctx, battery, "status", JS_NewString(ctx, status));
5683
5684 if (power_now > 0 && energy_now > 0 && strcmp(status, "Discharging") == 0) {
5685 double hours = (double)energy_now / (double)power_now;
5686 JS_SetPropertyStr(ctx, battery, "minutesLeft", JS_NewInt32(ctx, (int)(hours * 60)));
5687 } else {
5688 JS_SetPropertyStr(ctx, battery, "minutesLeft", JS_NewInt32(ctx, -1));
5689 }
5690
5691 JS_SetPropertyStr(ctx, sys, "battery", battery);
5692
5693 // Hardware info — system.hw (model, cpu, ram)
5694 {
5695 JSValue hw = JS_NewObject(ctx);
5696 char hbuf[256];
5697
5698 // Product name: /sys/class/dmi/id/product_name
5699 if (read_sysfs("/sys/class/dmi/id/product_name", hbuf, sizeof(hbuf)) > 0)
5700 JS_SetPropertyStr(ctx, hw, "model", JS_NewString(ctx, hbuf));
5701 else
5702 JS_SetPropertyStr(ctx, hw, "model", JS_NewString(ctx, "unknown"));
5703
5704 // Vendor: /sys/class/dmi/id/sys_vendor
5705 if (read_sysfs("/sys/class/dmi/id/sys_vendor", hbuf, sizeof(hbuf)) > 0)
5706 JS_SetPropertyStr(ctx, hw, "vendor", JS_NewString(ctx, hbuf));
5707 else
5708 JS_SetPropertyStr(ctx, hw, "vendor", JS_NewString(ctx, "unknown"));
5709
5710 // CPU model: first "model name" line from /proc/cpuinfo
5711 {
5712 FILE *cpuinfo = fopen("/proc/cpuinfo", "r");
5713 char cpuline[256] = {0};
5714 if (cpuinfo) {
5715 char line[512];
5716 while (fgets(line, sizeof(line), cpuinfo)) {
5717 if (strncmp(line, "model name", 10) == 0) {
5718 char *colon = strchr(line, ':');
5719 if (colon) {
5720 colon++;
5721 while (*colon == ' ') colon++;
5722 // Trim newline
5723 char *nl = strchr(colon, '\n');
5724 if (nl) *nl = 0;
5725 strncpy(cpuline, colon, sizeof(cpuline) - 1);
5726 }
5727 break;
5728 }
5729 }
5730 fclose(cpuinfo);
5731 }
5732 JS_SetPropertyStr(ctx, hw, "cpu", JS_NewString(ctx, cpuline[0] ? cpuline : "unknown"));
5733 }
5734
5735 // CPU cores: count "processor" lines in /proc/cpuinfo
5736 {
5737 FILE *cpuinfo = fopen("/proc/cpuinfo", "r");
5738 int cores = 0;
5739 if (cpuinfo) {
5740 char line[256];
5741 while (fgets(line, sizeof(line), cpuinfo)) {
5742 if (strncmp(line, "processor", 9) == 0) cores++;
5743 }
5744 fclose(cpuinfo);
5745 }
5746 JS_SetPropertyStr(ctx, hw, "cores", JS_NewInt32(ctx, cores));
5747 }
5748
5749 // RAM: total from /proc/meminfo (in MB)
5750 {
5751 FILE *meminfo = fopen("/proc/meminfo", "r");
5752 long total_kb = 0, avail_kb = 0;
5753 if (meminfo) {
5754 char line[256];
5755 while (fgets(line, sizeof(line), meminfo)) {
5756 if (strncmp(line, "MemTotal:", 9) == 0) {
5757 sscanf(line + 9, " %ld", &total_kb);
5758 } else if (strncmp(line, "MemAvailable:", 13) == 0) {
5759 sscanf(line + 13, " %ld", &avail_kb);
5760 }
5761 if (total_kb && avail_kb) break;
5762 }
5763 fclose(meminfo);
5764 }
5765 JS_SetPropertyStr(ctx, hw, "ramTotalMB", JS_NewInt32(ctx, (int)(total_kb / 1024)));
5766 JS_SetPropertyStr(ctx, hw, "ramAvailMB", JS_NewInt32(ctx, (int)(avail_kb / 1024)));
5767 }
5768
5769 // Process count from /proc (count numeric dirs)
5770 {
5771 int procs = 0;
5772 DIR *pd = opendir("/proc");
5773 if (pd) {
5774 struct dirent *pe;
5775 while ((pe = readdir(pd))) {
5776 if (pe->d_name[0] >= '1' && pe->d_name[0] <= '9') procs++;
5777 }
5778 closedir(pd);
5779 }
5780 JS_SetPropertyStr(ctx, hw, "processes", JS_NewInt32(ctx, procs));
5781 }
5782
5783 // Load average from /proc/loadavg
5784 {
5785 char labuf[128] = {0};
5786 FILE *la = fopen("/proc/loadavg", "r");
5787 if (la) {
5788 if (fgets(labuf, sizeof(labuf), la)) {
5789 // "0.12 0.34 0.56 1/42 1234"
5790 double l1 = 0, l5 = 0, l15 = 0;
5791 sscanf(labuf, "%lf %lf %lf", &l1, &l5, &l15);
5792 JS_SetPropertyStr(ctx, hw, "load1", JS_NewFloat64(ctx, l1));
5793 JS_SetPropertyStr(ctx, hw, "load5", JS_NewFloat64(ctx, l5));
5794 JS_SetPropertyStr(ctx, hw, "load15", JS_NewFloat64(ctx, l15));
5795 }
5796 fclose(la);
5797 }
5798 }
5799
5800 // CPU governor (power mode)
5801 if (read_sysfs("/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor", hbuf, sizeof(hbuf)) > 0) {
5802 JS_SetPropertyStr(ctx, hw, "governor", JS_NewString(ctx, hbuf));
5803 }
5804
5805 // Connected devices: scan /sys/class and /sys/bus for peripherals
5806 {
5807 JSValue devs = JS_NewArray(ctx);
5808 int di = 0;
5809
5810 // USB devices: scan /sys/bus/usb/devices/*/product
5811 {
5812 DIR *ud = opendir("/sys/bus/usb/devices");
5813 if (ud) {
5814 struct dirent *ue;
5815 while ((ue = readdir(ud))) {
5816 if (ue->d_name[0] == '.') continue;
5817 char pp[256], prod[128] = {0}, mfr[128] = {0};
5818 snprintf(pp, sizeof(pp), "/sys/bus/usb/devices/%s/product", ue->d_name);
5819 int got_prod = read_sysfs(pp, prod, sizeof(prod)) > 0;
5820 if (!got_prod) continue;
5821 snprintf(pp, sizeof(pp), "/sys/bus/usb/devices/%s/manufacturer", ue->d_name);
5822 read_sysfs(pp, mfr, sizeof(mfr));
5823 JSValue d = JS_NewObject(ctx);
5824 JS_SetPropertyStr(ctx, d, "type", JS_NewString(ctx, "usb"));
5825 JS_SetPropertyStr(ctx, d, "name", JS_NewString(ctx, prod));
5826 if (mfr[0]) JS_SetPropertyStr(ctx, d, "vendor", JS_NewString(ctx, mfr));
5827 JS_SetPropertyStr(ctx, d, "id", JS_NewString(ctx, ue->d_name));
5828 JS_SetPropertyUint32(ctx, devs, di++, d);
5829 }
5830 closedir(ud);
5831 }
5832 }
5833
5834 // Input (HID) devices: scan /sys/class/input/*/name
5835 {
5836 DIR *id = opendir("/sys/class/input");
5837 if (id) {
5838 struct dirent *ie;
5839 while ((ie = readdir(id))) {
5840 if (strncmp(ie->d_name, "event", 5) != 0) continue;
5841 char np[256], name[128] = {0};
5842 snprintf(np, sizeof(np), "/sys/class/input/%s/device/name", ie->d_name);
5843 if (read_sysfs(np, name, sizeof(name)) <= 0) continue;
5844 JSValue d = JS_NewObject(ctx);
5845 JS_SetPropertyStr(ctx, d, "type", JS_NewString(ctx, "input"));
5846 JS_SetPropertyStr(ctx, d, "name", JS_NewString(ctx, name));
5847 JS_SetPropertyStr(ctx, d, "id", JS_NewString(ctx, ie->d_name));
5848 JS_SetPropertyUint32(ctx, devs, di++, d);
5849 }
5850 closedir(id);
5851 }
5852 }
5853
5854 // Video devices (cameras): /sys/class/video4linux/*/name
5855 {
5856 DIR *vd = opendir("/sys/class/video4linux");
5857 if (vd) {
5858 struct dirent *ve;
5859 while ((ve = readdir(vd))) {
5860 if (ve->d_name[0] == '.') continue;
5861 char vp[256], vname[128] = {0};
5862 snprintf(vp, sizeof(vp), "/sys/class/video4linux/%s/name", ve->d_name);
5863 if (read_sysfs(vp, vname, sizeof(vname)) <= 0) continue;
5864 JSValue d = JS_NewObject(ctx);
5865 JS_SetPropertyStr(ctx, d, "type", JS_NewString(ctx, "camera"));
5866 JS_SetPropertyStr(ctx, d, "name", JS_NewString(ctx, vname));
5867 JS_SetPropertyStr(ctx, d, "id", JS_NewString(ctx, ve->d_name));
5868 JS_SetPropertyUint32(ctx, devs, di++, d);
5869 }
5870 closedir(vd);
5871 }
5872 }
5873
5874 // Sound cards: /proc/asound/cards
5875 {
5876 FILE *sc = fopen("/proc/asound/cards", "r");
5877 if (sc) {
5878 char sline[256];
5879 while (fgets(sline, sizeof(sline), sc)) {
5880 // Lines like " 0 [PCH ]: HDA-Intel - HDA Intel PCH"
5881 char *dash = strstr(sline, " - ");
5882 if (dash) {
5883 dash += 3;
5884 char *nl = strchr(dash, '\n');
5885 if (nl) *nl = 0;
5886 JSValue d = JS_NewObject(ctx);
5887 JS_SetPropertyStr(ctx, d, "type", JS_NewString(ctx, "audio"));
5888 JS_SetPropertyStr(ctx, d, "name", JS_NewString(ctx, dash));
5889 JS_SetPropertyUint32(ctx, devs, di++, d);
5890 }
5891 }
5892 fclose(sc);
5893 }
5894 }
5895
5896 // Block devices: /sys/block/*/device/model (drives, USB sticks)
5897 {
5898 DIR *bd = opendir("/sys/block");
5899 if (bd) {
5900 struct dirent *be;
5901 while ((be = readdir(bd))) {
5902 if (be->d_name[0] == '.') continue;
5903 if (strncmp(be->d_name, "loop", 4) == 0) continue;
5904 if (strncmp(be->d_name, "ram", 3) == 0) continue;
5905 char bp[256], model[128] = {0};
5906 snprintf(bp, sizeof(bp), "/sys/block/%s/device/model", be->d_name);
5907 read_sysfs(bp, model, sizeof(model));
5908 // Get size
5909 char sz[32] = {0};
5910 snprintf(bp, sizeof(bp), "/sys/block/%s/size", be->d_name);
5911 read_sysfs(bp, sz, sizeof(sz));
5912 long sectors = sz[0] ? atol(sz) : 0;
5913 int gb = (int)(sectors / 2 / 1024 / 1024); // 512-byte sectors -> GB
5914 // Removable?
5915 char rem[8] = {0};
5916 snprintf(bp, sizeof(bp), "/sys/block/%s/removable", be->d_name);
5917 read_sysfs(bp, rem, sizeof(rem));
5918 JSValue d = JS_NewObject(ctx);
5919 JS_SetPropertyStr(ctx, d, "type", JS_NewString(ctx, "disk"));
5920 JS_SetPropertyStr(ctx, d, "name", JS_NewString(ctx, model[0] ? model : be->d_name));
5921 JS_SetPropertyStr(ctx, d, "id", JS_NewString(ctx, be->d_name));
5922 JS_SetPropertyStr(ctx, d, "sizeGB", JS_NewInt32(ctx, gb));
5923 JS_SetPropertyStr(ctx, d, "removable", JS_NewBool(ctx, rem[0] == '1'));
5924 JS_SetPropertyUint32(ctx, devs, di++, d);
5925 }
5926 closedir(bd);
5927 }
5928 }
5929
5930 // DRM connectors (HDMI, DP, etc.): /sys/class/drm/card*-*/status
5931 {
5932 DIR *dd = opendir("/sys/class/drm");
5933 if (dd) {
5934 struct dirent *de;
5935 while ((de = readdir(dd))) {
5936 if (strncmp(de->d_name, "card", 4) != 0) continue;
5937 if (!strchr(de->d_name, '-')) continue; // skip "card0" itself
5938 char sp[256], st[32] = {0};
5939 snprintf(sp, sizeof(sp), "/sys/class/drm/%s/status", de->d_name);
5940 if (read_sysfs(sp, st, sizeof(st)) <= 0) continue;
5941 JSValue d = JS_NewObject(ctx);
5942 JS_SetPropertyStr(ctx, d, "type", JS_NewString(ctx, "display"));
5943 JS_SetPropertyStr(ctx, d, "name", JS_NewString(ctx, de->d_name));
5944 JS_SetPropertyStr(ctx, d, "connected", JS_NewBool(ctx, strcmp(st, "connected") == 0));
5945 JS_SetPropertyUint32(ctx, devs, di++, d);
5946 }
5947 closedir(dd);
5948 }
5949 }
5950
5951 JS_SetPropertyStr(ctx, hw, "devices", devs);
5952 }
5953
5954 // Build name (baked in at compile time)
5955#ifdef AC_BUILD_NAME
5956 JS_SetPropertyStr(ctx, hw, "buildName", JS_NewString(ctx, AC_BUILD_NAME));
5957#endif
5958#ifdef AC_GIT_HASH
5959 JS_SetPropertyStr(ctx, hw, "gitHash", JS_NewString(ctx, AC_GIT_HASH));
5960#endif
5961#ifdef AC_BUILD_TS
5962 JS_SetPropertyStr(ctx, hw, "buildTs", JS_NewString(ctx, AC_BUILD_TS));
5963#endif
5964
5965 // Display driver and GPU info
5966 {
5967 extern void *g_display;
5968 if (g_display) {
5969 const char *drv = drm_display_driver((ACDisplay *)g_display);
5970 JS_SetPropertyStr(ctx, hw, "displayDriver", JS_NewString(ctx, drv));
5971 } else {
5972 JS_SetPropertyStr(ctx, hw, "displayDriver", JS_NewString(ctx, "wayland"));
5973 }
5974 // Read GPU renderer from sysfs (Mesa exposes via DRI)
5975 char gpu_name[128] = "unknown";
5976 FILE *fp = popen("cat /sys/kernel/debug/dri/0/name 2>/dev/null || "
5977 "cat /sys/class/drm/card0/device/label 2>/dev/null || "
5978 "echo unknown", "r");
5979 if (fp) {
5980 if (fgets(gpu_name, sizeof(gpu_name), fp)) {
5981 // Strip trailing newline
5982 char *nl = strchr(gpu_name, '\n');
5983 if (nl) *nl = '\0';
5984 }
5985 pclose(fp);
5986 }
5987 JS_SetPropertyStr(ctx, hw, "gpu", JS_NewString(ctx, gpu_name));
5988 }
5989
5990 // Audio diagnostics
5991 if (current_rt->audio) {
5992 JS_SetPropertyStr(ctx, hw, "audioDevice",
5993 JS_NewString(ctx, current_rt->audio->audio_device));
5994 JS_SetPropertyStr(ctx, hw, "audioStatus",
5995 JS_NewString(ctx, current_rt->audio->audio_status));
5996 JS_SetPropertyStr(ctx, hw, "audioRetries",
5997 JS_NewInt32(ctx, current_rt->audio->audio_init_retries));
5998 JS_SetPropertyStr(ctx, hw, "audioRate",
5999 JS_NewInt32(ctx, (int)current_rt->audio->actual_rate));
6000 } else {
6001 JS_SetPropertyStr(ctx, hw, "audioStatus",
6002 JS_NewString(ctx, "no audio subsystem"));
6003 }
6004
6005 JS_SetPropertyStr(ctx, sys, "hw", hw);
6006 }
6007
6008 // Backlight brightness — scan /sys/class/backlight/
6009 int bl_cur = -1, bl_max = -1;
6010 {
6011 DIR *bldir = opendir("/sys/class/backlight");
6012 if (bldir) {
6013 struct dirent *ent;
6014 while ((ent = readdir(bldir))) {
6015 if (ent->d_name[0] == '.') continue;
6016 char tmp[160];
6017 snprintf(tmp, sizeof(tmp), "/sys/class/backlight/%s/max_brightness", ent->d_name);
6018 if (read_sysfs(tmp, buf, sizeof(buf)) > 0) {
6019 bl_max = atoi(buf);
6020 snprintf(tmp, sizeof(tmp), "/sys/class/backlight/%s/brightness", ent->d_name);
6021 if (read_sysfs(tmp, buf, sizeof(buf)) > 0) bl_cur = atoi(buf);
6022 break;
6023 }
6024 }
6025 closedir(bldir);
6026 }
6027 }
6028 if (bl_max > 0 && bl_cur >= 0) {
6029 JS_SetPropertyStr(ctx, sys, "brightness", JS_NewInt32(ctx, (bl_cur * 100) / bl_max));
6030 } else {
6031 JS_SetPropertyStr(ctx, sys, "brightness", JS_NewInt32(ctx, -1));
6032 }
6033
6034 // USB-C Type-C power role — scan /sys/class/typec/port*/power_role
6035 {
6036 JSValue typec = JS_NewArray(ctx);
6037 int ti = 0;
6038 DIR *tcdir = opendir("/sys/class/typec");
6039 if (tcdir) {
6040 struct dirent *te;
6041 while ((te = readdir(tcdir))) {
6042 if (strncmp(te->d_name, "port", 4) != 0) continue;
6043 // Skip partner/plug entries like "port0-partner"
6044 if (strchr(te->d_name + 4, '-')) continue;
6045 char tmp[256], rbuf[64] = {0};
6046 snprintf(tmp, sizeof(tmp), "/sys/class/typec/%s/power_role", te->d_name);
6047 if (read_sysfs(tmp, rbuf, sizeof(rbuf)) <= 0) continue;
6048 // rbuf is like "[source] sink" or "source [sink]"
6049 const char *current_role = "unknown";
6050 int can_swap = 0;
6051 if (strstr(rbuf, "[source]")) current_role = "source";
6052 else if (strstr(rbuf, "[sink]")) current_role = "sink";
6053 // If both roles appear, swap is supported
6054 if (strstr(rbuf, "source") && strstr(rbuf, "sink")) can_swap = 1;
6055 // Data role
6056 char drbuf[64] = {0};
6057 snprintf(tmp, sizeof(tmp), "/sys/class/typec/%s/data_role", te->d_name);
6058 read_sysfs(tmp, drbuf, sizeof(drbuf));
6059 const char *data_role = "unknown";
6060 if (strstr(drbuf, "[host]")) data_role = "host";
6061 else if (strstr(drbuf, "[device]")) data_role = "device";
6062
6063 JSValue port = JS_NewObject(ctx);
6064 JS_SetPropertyStr(ctx, port, "port", JS_NewString(ctx, te->d_name));
6065 JS_SetPropertyStr(ctx, port, "powerRole", JS_NewString(ctx, current_role));
6066 JS_SetPropertyStr(ctx, port, "dataRole", JS_NewString(ctx, data_role));
6067 JS_SetPropertyStr(ctx, port, "canSwap", JS_NewBool(ctx, can_swap));
6068 JS_SetPropertyUint32(ctx, typec, ti++, port);
6069 }
6070 closedir(tcdir);
6071 }
6072 JS_SetPropertyStr(ctx, sys, "typec", typec);
6073 }
6074
6075 // system.setPowerRole(port, role) — swap USB-C power role ("source" or "sink")
6076 JS_SetPropertyStr(ctx, sys, "setPowerRole", JS_NewCFunction(ctx, js_set_power_role, "setPowerRole", 2));
6077
6078 // Tablet mode (lid folded back on convertible laptops)
6079 JS_SetPropertyStr(ctx, sys, "tabletMode",
6080 JS_NewBool(ctx, current_rt && current_rt->input && current_rt->input->tablet_mode));
6081
6082 // HDMI secondary display
6083 int has_hdmi = (current_rt && current_rt->hdmi && current_rt->hdmi->active);
6084 JS_SetPropertyStr(ctx, sys, "hasHdmi", JS_NewBool(ctx, has_hdmi));
6085 JS_SetPropertyStr(ctx, sys, "hdmi", JS_NewCFunction(ctx, js_hdmi_fill, "hdmi", 3));
6086
6087 // WebSocket client — system.ws
6088 JS_SetPropertyStr(ctx, sys, "ws", build_ws_obj(ctx, current_phase));
6089
6090 // Raw UDP fairy co-presence — system.udp
6091 JS_SetPropertyStr(ctx, sys, "udp", build_udp_obj(ctx, current_phase));
6092
6093 // Machine identity — system.machineId
6094 {
6095 extern char g_machine_id[64];
6096 JS_SetPropertyStr(ctx, sys, "machineId",
6097 JS_NewString(ctx, g_machine_id[0] ? g_machine_id : "unknown"));
6098 }
6099
6100 // Remote reboot / poweroff — system.reboot() / system.poweroff()
6101 JS_SetPropertyStr(ctx, sys, "reboot", JS_NewCFunction(ctx, js_reboot, "reboot", 0));
6102 JS_SetPropertyStr(ctx, sys, "poweroff", JS_NewCFunction(ctx, js_poweroff, "poweroff", 0));
6103
6104 // USB MIDI gadget status + control
6105 {
6106 JSValue usb_midi = build_usb_midi_state_obj(ctx);
6107 JS_SetPropertyStr(ctx, usb_midi, "status", JS_NewCFunction(ctx, js_usb_midi_status, "status", 0));
6108 JS_SetPropertyStr(ctx, usb_midi, "enable", JS_NewCFunction(ctx, js_usb_midi_enable, "enable", 0));
6109 JS_SetPropertyStr(ctx, usb_midi, "disable", JS_NewCFunction(ctx, js_usb_midi_disable, "disable", 0));
6110 JS_SetPropertyStr(ctx, usb_midi, "refresh", JS_NewCFunction(ctx, js_usb_midi_refresh, "refresh", 0));
6111 JS_SetPropertyStr(ctx, usb_midi, "noteOn", JS_NewCFunction(ctx, js_usb_midi_note_on, "noteOn", 3));
6112 JS_SetPropertyStr(ctx, usb_midi, "noteOff", JS_NewCFunction(ctx, js_usb_midi_note_off, "noteOff", 3));
6113 JS_SetPropertyStr(ctx, usb_midi, "allNotesOff", JS_NewCFunction(ctx, js_usb_midi_all_notes_off, "allNotesOff", 1));
6114 JS_SetPropertyStr(ctx, sys, "usbMidi", usb_midi);
6115 }
6116
6117 // File I/O — system.readFile(path) / system.writeFile(path, data)
6118 JS_SetPropertyStr(ctx, sys, "readFile", JS_NewCFunction(ctx, js_read_file, "readFile", 1));
6119 JS_SetPropertyStr(ctx, sys, "writeFile", JS_NewCFunction(ctx, js_write_file, "writeFile", 2));
6120 JS_SetPropertyStr(ctx, sys, "deleteFile",JS_NewCFunction(ctx, js_delete_file,"deleteFile",1));
6121 JS_SetPropertyStr(ctx, sys, "listDir", JS_NewCFunction(ctx, js_list_dir, "listDir", 1));
6122 JS_SetPropertyStr(ctx, sys, "diskInfo", JS_NewCFunction(ctx, js_disk_info, "diskInfo", 1));
6123 JS_SetPropertyStr(ctx, sys, "blockDevices", JS_NewCFunction(ctx, js_block_devices, "blockDevices", 0));
6124 JS_SetPropertyStr(ctx, sys, "mountMusic", JS_NewCFunction(ctx, js_mount_music, "mountMusic", 0));
6125 JS_SetPropertyStr(ctx, sys, "mountMusicMounted", JS_NewBool(ctx, music_mount_state));
6126 JS_SetPropertyStr(ctx, sys, "mountMusicPending", JS_NewBool(ctx, music_mount_pending));
6127
6128 // Async HTTP fetch — system.fetch(url) / system.fetchCancel() / system.fetchResult / system.fetchPending
6129 JS_SetPropertyStr(ctx, sys, "fetch", JS_NewCFunction(ctx, js_fetch, "fetch", 1));
6130 JS_SetPropertyStr(ctx, sys, "fetchPost", JS_NewCFunction(ctx, js_fetch_post, "fetchPost", 3));
6131 JS_SetPropertyStr(ctx, sys, "fetchCancel", JS_NewCFunction(ctx, js_fetch_cancel, "fetchCancel", 0));
6132 JS_SetPropertyStr(ctx, sys, "fetchPending",
6133 JS_NewBool(ctx, current_rt ? current_rt->fetch_pending : 0));
6134 if (current_rt && current_rt->fetch_pending) {
6135 FILE *rc = fopen("/tmp/ac_fetch_rc", "r");
6136 if (rc) {
6137 int code = -1;
6138 fscanf(rc, "%d", &code);
6139 fclose(rc);
6140 unlink("/tmp/ac_fetch_rc");
6141 ac_log("[fetch] done: curl exit=%d\n", code);
6142 current_rt->fetch_result[0] = 0;
6143 current_rt->fetch_error[0] = 0;
6144 if (code == 0) {
6145 FILE *fp = fopen("/tmp/ac_fetch.json", "r");
6146 if (fp) {
6147 int n = (int)fread(current_rt->fetch_result,
6148 1, sizeof(current_rt->fetch_result) - 1, fp);
6149 fclose(fp);
6150 current_rt->fetch_result[n] = 0;
6151 unlink("/tmp/ac_fetch.json");
6152 } else {
6153 snprintf(current_rt->fetch_error, sizeof(current_rt->fetch_error),
6154 "request failed: missing response body");
6155 }
6156 unlink("/tmp/ac_fetch_err");
6157 } else {
6158 char emsg[160] = {0};
6159 FILE *ef = fopen("/tmp/ac_fetch_err", "r");
6160 if (ef) {
6161 int en = (int)fread(emsg, 1, sizeof(emsg) - 1, ef);
6162 fclose(ef);
6163 emsg[en] = 0;
6164 for (int i = 0; emsg[i]; i++) {
6165 if (emsg[i] == '\n' || emsg[i] == '\r' || emsg[i] == '\t') emsg[i] = ' ';
6166 }
6167 }
6168 unlink("/tmp/ac_fetch_err");
6169 ac_log("[fetch] error (%d): %s\n", code, emsg[0] ? emsg : "(no stderr)");
6170 if (emsg[0]) {
6171 snprintf(current_rt->fetch_error, sizeof(current_rt->fetch_error),
6172 "request failed (%d): %s", code, emsg);
6173 } else {
6174 snprintf(current_rt->fetch_error, sizeof(current_rt->fetch_error),
6175 "request failed (%d)", code);
6176 }
6177 }
6178 current_rt->fetch_pending = 0;
6179 }
6180 }
6181 // Deliver fetch result only during paint phase (where JS consumes it)
6182 // and clear after delivery (one-shot)
6183 if (current_rt && current_rt->fetch_result[0]
6184 && strcmp(current_phase, "paint") == 0) {
6185 JS_SetPropertyStr(ctx, sys, "fetchResult",
6186 JS_NewString(ctx, current_rt->fetch_result));
6187 current_rt->fetch_result[0] = 0;
6188 } else {
6189 JS_SetPropertyStr(ctx, sys, "fetchResult", JS_NULL);
6190 }
6191 // Fetch error is also one-shot, delivered during paint.
6192 if (current_rt && current_rt->fetch_error[0]
6193 && strcmp(current_phase, "paint") == 0) {
6194 JS_SetPropertyStr(ctx, sys, "fetchError",
6195 JS_NewString(ctx, current_rt->fetch_error));
6196 current_rt->fetch_error[0] = 0;
6197 } else {
6198 JS_SetPropertyStr(ctx, sys, "fetchError", JS_NULL);
6199 }
6200
6201 // QR camera scanning — system.scanQR() / system.scanQRStop()
6202 JS_SetPropertyStr(ctx, sys, "scanQR", JS_NewCFunction(ctx, js_scan_qr, "scanQR", 0));
6203 JS_SetPropertyStr(ctx, sys, "scanQRStop", JS_NewCFunction(ctx, js_scan_qr_stop, "scanQRStop", 0));
6204 JS_SetPropertyStr(ctx, sys, "cameraBlit", JS_NewCFunction(ctx, js_camera_blit, "cameraBlit", 4));
6205 JS_SetPropertyStr(ctx, sys, "qrPending",
6206 JS_NewBool(ctx, current_rt ? current_rt->qr_scan_active : 0));
6207 // Deliver QR result one-shot during sim phase
6208 if (current_rt && current_rt->camera.scan_done) {
6209 if (current_rt->camera.scan_result[0]) {
6210 JS_SetPropertyStr(ctx, sys, "qrResult",
6211 JS_NewString(ctx, current_rt->camera.scan_result));
6212 current_rt->camera.scan_result[0] = 0;
6213 } else if (current_rt->camera.scan_error[0]) {
6214 JS_SetPropertyStr(ctx, sys, "qrError",
6215 JS_NewString(ctx, current_rt->camera.scan_error));
6216 current_rt->camera.scan_error[0] = 0;
6217 } else {
6218 JS_SetPropertyStr(ctx, sys, "qrResult", JS_NULL);
6219 }
6220 current_rt->camera.scan_done = 0;
6221 } else {
6222 JS_SetPropertyStr(ctx, sys, "qrResult", JS_NULL);
6223 JS_SetPropertyStr(ctx, sys, "qrError", JS_NULL);
6224 }
6225
6226 // OS update version string — matches OTA format: "buildname githash-buildts"
6227#ifdef AC_BUILD_NAME
6228# ifdef AC_GIT_HASH
6229# ifdef AC_BUILD_TS
6230 JS_SetPropertyStr(ctx, sys, "version",
6231 JS_NewString(ctx, AC_BUILD_NAME " " AC_GIT_HASH "-" AC_BUILD_TS));
6232# else
6233 JS_SetPropertyStr(ctx, sys, "version",
6234 JS_NewString(ctx, AC_BUILD_NAME " " AC_GIT_HASH));
6235# endif
6236# else
6237 JS_SetPropertyStr(ctx, sys, "version", JS_NewString(ctx, AC_BUILD_NAME));
6238# endif
6239#elif defined(AC_GIT_HASH)
6240# ifdef AC_BUILD_TS
6241 JS_SetPropertyStr(ctx, sys, "version",
6242 JS_NewString(ctx, AC_GIT_HASH "-" AC_BUILD_TS));
6243# else
6244 JS_SetPropertyStr(ctx, sys, "version", JS_NewString(ctx, AC_GIT_HASH));
6245# endif
6246#else
6247 JS_SetPropertyStr(ctx, sys, "version", JS_NewString(ctx, "unknown"));
6248#endif
6249
6250 // Firmware capability probe — exposed as `system.firmware` so os.mjs can
6251 // gate the firmware-update panel on machines where flashing is actually
6252 // viable. Criteria, all of which must be true:
6253 // 1. /dev/mtd0 exists — kernel has a usable SPI-NOR driver bound to
6254 // the motherboard's flash chip (CONFIG_SPI_INTEL_PCI + CONFIG_MTD).
6255 // Absent this, flashrom's internal programmer can't open the chip.
6256 // 2. bios_vendor contains "coreboot" — the only firmware we support
6257 // reflashing is MrChromebox's coreboot+edk2 build. Stock OEM AMI /
6258 // Insyde firmware would either reject writes (SMM) or brick.
6259 // 3. Optional: a plausible Chromebook/supported-board identifier in
6260 // product_name, which lets us suggest a MrChromebox ROM URL.
6261 // This is an advisory flag; the actual flash is gated by a second probe
6262 // + `flashrom --probe` step inside the update thread.
6263 {
6264 JSValue firmware = JS_NewObject(ctx);
6265 int mtd_ok = (access("/dev/mtd0", F_OK) == 0);
6266 char bios_vendor[128] = "";
6267 char product_name[128] = "";
6268 char bios_version[128] = "";
6269 read_sysfs("/sys/class/dmi/id/bios_vendor", bios_vendor, sizeof(bios_vendor));
6270 read_sysfs("/sys/class/dmi/id/product_name", product_name, sizeof(product_name));
6271 read_sysfs("/sys/class/dmi/id/bios_version", bios_version, sizeof(bios_version));
6272 // Trim trailing newlines from sysfs reads
6273 for (char *p = bios_vendor; *p; p++) if (*p == '\n') { *p = 0; break; }
6274 for (char *p = product_name; *p; p++) if (*p == '\n') { *p = 0; break; }
6275 for (char *p = bios_version; *p; p++) if (*p == '\n') { *p = 0; break; }
6276 int coreboot =
6277 (strstr(bios_vendor, "coreboot") != NULL) ||
6278 (strstr(bios_version, "MrChromebox") != NULL);
6279 int available = mtd_ok && coreboot;
6280 JS_SetPropertyStr(ctx, firmware, "available", JS_NewBool(ctx, available));
6281 JS_SetPropertyStr(ctx, firmware, "mtdOk", JS_NewBool(ctx, mtd_ok));
6282 JS_SetPropertyStr(ctx, firmware, "coreboot", JS_NewBool(ctx, coreboot));
6283 JS_SetPropertyStr(ctx, firmware, "biosVendor", JS_NewString(ctx, bios_vendor));
6284 JS_SetPropertyStr(ctx, firmware, "biosVersion", JS_NewString(ctx, bios_version));
6285 JS_SetPropertyStr(ctx, firmware, "productName", JS_NewString(ctx, product_name));
6286 // Lowercase board name (first word of product_name) is the URL slug
6287 // MrChromebox uses: e.g. "Google Drawman/Drawcia" -> "drawcia"
6288 char board[64] = "";
6289 {
6290 const char *src = product_name;
6291 // Skip "Google " prefix if present
6292 if (strncmp(src, "Google ", 7) == 0) src += 7;
6293 // Copy up to '/' or ' ' or end, lowercasing
6294 int i = 0;
6295 while (*src && *src != '/' && *src != ' ' && i < (int)sizeof(board) - 1) {
6296 board[i++] = (*src >= 'A' && *src <= 'Z') ? (*src + 32) : *src;
6297 src++;
6298 }
6299 board[i] = 0;
6300 }
6301 JS_SetPropertyStr(ctx, firmware, "board", JS_NewString(ctx, board));
6302 // Runtime install state — live values updated by fw_thread_fn.
6303 if (current_rt) {
6304 JS_SetPropertyStr(ctx, firmware, "pending",
6305 JS_NewBool(ctx, current_rt->fw_pending));
6306 JS_SetPropertyStr(ctx, firmware, "done",
6307 JS_NewBool(ctx, current_rt->fw_done));
6308 JS_SetPropertyStr(ctx, firmware, "ok",
6309 JS_NewBool(ctx, current_rt->fw_ok));
6310 if (current_rt->fw_backup_path[0])
6311 JS_SetPropertyStr(ctx, firmware, "backupPath",
6312 JS_NewString(ctx, current_rt->fw_backup_path));
6313 JSValue log = JS_NewArray(ctx);
6314 int count = current_rt->fw_log_count;
6315 int show = count < 32 ? count : 32;
6316 int start = count - show;
6317 for (int i = 0; i < show; i++) {
6318 JS_SetPropertyUint32(ctx, log, i,
6319 JS_NewString(ctx, current_rt->fw_log[(start + i) % 32]));
6320 }
6321 JS_SetPropertyStr(ctx, firmware, "log", log);
6322 }
6323 // Action: system.firmware.install(mode?) — "install" | "dry-run" | "restore"
6324 JS_SetPropertyStr(ctx, firmware, "install",
6325 JS_NewCFunction(ctx, js_firmware_install, "install", 1));
6326 JS_SetPropertyStr(ctx, sys, "firmware", firmware);
6327 }
6328
6329 // Audio diagnostic API — speaker.mjs piece uses these to probe which
6330 // ALSA PCM actually produces sound on this machine. listPcms scans
6331 // /proc/asound; testPcm plays a short sine tone on the named device
6332 // in a detached thread. Both are read-only — no state change to the
6333 // main audio thread.
6334 {
6335 JSValue audio = JS_NewObject(ctx);
6336 JS_SetPropertyStr(ctx, audio, "listPcms",
6337 JS_NewCFunction(ctx, js_audio_list_pcms, "listPcms", 0));
6338 JS_SetPropertyStr(ctx, audio, "testPcm",
6339 JS_NewCFunction(ctx, js_audio_test_pcm, "testPcm", 4));
6340 /* Report which device ac-native's main audio thread is currently
6341 * using — helpful context when the user is probing alternatives. */
6342 if (current_rt && current_rt->audio &&
6343 current_rt->audio->audio_device[0]) {
6344 JS_SetPropertyStr(ctx, audio, "activeDevice",
6345 JS_NewString(ctx, current_rt->audio->audio_device));
6346 }
6347 JS_SetPropertyStr(ctx, sys, "audio", audio);
6348 }
6349
6350 // User config (handle, piece — read from /mnt/config.json at boot)
6351 {
6352 JSValue config = JS_NewObject(ctx);
6353 if (current_rt && current_rt->handle[0])
6354 JS_SetPropertyStr(ctx, config, "handle", JS_NewString(ctx, current_rt->handle));
6355 if (current_rt && current_rt->piece[0])
6356 JS_SetPropertyStr(ctx, config, "piece", JS_NewString(ctx, current_rt->piece));
6357 // Expose raw config JSON so pieces can read arbitrary fields
6358 {
6359 static char cfg_cache[8192] = {0};
6360 if (config_cache_dirty) {
6361 cfg_cache[0] = '\0';
6362 FILE *cf = fopen("/mnt/config.json", "r");
6363 if (cf) {
6364 size_t n = fread(cfg_cache, 1, sizeof(cfg_cache) - 1, cf);
6365 cfg_cache[n] = '\0';
6366 fclose(cf);
6367 }
6368 config_cache_dirty = 0;
6369 }
6370 if (cfg_cache[0])
6371 JS_SetPropertyStr(ctx, config, "raw", JS_NewString(ctx, cfg_cache));
6372 }
6373 JS_SetPropertyStr(ctx, sys, "config", config);
6374 }
6375
6376 // Boot device detection (cached — detect once, not every frame)
6377 {
6378 static char boot_dev_cache[64] = {0};
6379 if (!boot_dev_cache[0]) detect_boot_device(boot_dev_cache, sizeof(boot_dev_cache));
6380 JS_SetPropertyStr(ctx, sys, "bootDevice", JS_NewString(ctx, boot_dev_cache));
6381 }
6382
6383 // Flash targets: enumerate all devices that could receive an EFI flash
6384 {
6385 JSValue targets = JS_NewArray(ctx);
6386 int idx = 0;
6387 // Check USB (/dev/sda1)
6388 if (access("/dev/sda1", F_OK) == 0) {
6389 char rem[8] = {0};
6390 read_sysfs("/sys/block/sda/removable", rem, sizeof(rem));
6391 int is_removable = (rem[0] == '1');
6392 JSValue t = JS_NewObject(ctx);
6393 JS_SetPropertyStr(ctx, t, "device", JS_NewString(ctx, "/dev/sda1"));
6394 JS_SetPropertyStr(ctx, t, "label",
6395 JS_NewString(ctx, is_removable ? "USB" : "Disk (sda)"));
6396 JS_SetPropertyStr(ctx, t, "removable", JS_NewBool(ctx, is_removable));
6397 JS_SetPropertyUint32(ctx, targets, idx++, t);
6398 }
6399 // Check NVMe (/dev/nvme0n1p1)
6400 if (access("/dev/nvme0n1p1", F_OK) == 0) {
6401 JSValue t = JS_NewObject(ctx);
6402 JS_SetPropertyStr(ctx, t, "device", JS_NewString(ctx, "/dev/nvme0n1p1"));
6403 JS_SetPropertyStr(ctx, t, "label", JS_NewString(ctx, "Internal (NVMe)"));
6404 JS_SetPropertyStr(ctx, t, "removable", JS_NewBool(ctx, 0));
6405 JS_SetPropertyUint32(ctx, targets, idx++, t);
6406 }
6407 // Check eMMC (/dev/mmcblk0p1) — Chromebooks + some budget laptops use
6408 // eMMC instead of NVMe. Parent device is mmcblk0, partitions are
6409 // mmcblk0p1 etc. — same "p<N>" suffix scheme as NVMe.
6410 if (access("/dev/mmcblk0p1", F_OK) == 0) {
6411 JSValue t = JS_NewObject(ctx);
6412 JS_SetPropertyStr(ctx, t, "device", JS_NewString(ctx, "/dev/mmcblk0p1"));
6413 JS_SetPropertyStr(ctx, t, "label", JS_NewString(ctx, "Internal (eMMC)"));
6414 JS_SetPropertyStr(ctx, t, "removable", JS_NewBool(ctx, 0));
6415 JS_SetPropertyUint32(ctx, targets, idx++, t);
6416 }
6417 // Check sdb1 (second USB)
6418 if (access("/dev/sdb1", F_OK) == 0) {
6419 char rem[8] = {0};
6420 read_sysfs("/sys/block/sdb/removable", rem, sizeof(rem));
6421 JSValue t = JS_NewObject(ctx);
6422 JS_SetPropertyStr(ctx, t, "device", JS_NewString(ctx, "/dev/sdb1"));
6423 JS_SetPropertyStr(ctx, t, "label",
6424 JS_NewString(ctx, rem[0] == '1' ? "USB (sdb)" : "Disk (sdb)"));
6425 JS_SetPropertyStr(ctx, t, "removable", JS_NewBool(ctx, rem[0] == '1'));
6426 JS_SetPropertyUint32(ctx, targets, idx++, t);
6427 }
6428 JS_SetPropertyStr(ctx, sys, "flashTargets", targets);
6429 }
6430
6431 // Binary fetch for OS update
6432 JS_SetPropertyStr(ctx, sys, "fetchBinary",
6433 JS_NewCFunction(ctx, js_fetch_binary, "fetchBinary", 3));
6434 if (current_rt) {
6435 // Poll progress from file size
6436 if (current_rt->fetch_binary_pending && current_rt->fetch_binary_expected > 0
6437 && current_rt->fetch_binary_dest[0]) {
6438 struct stat fst;
6439 if (stat(current_rt->fetch_binary_dest, &fst) == 0) {
6440 float p = (float)fst.st_size / (float)current_rt->fetch_binary_expected;
6441 if (p > 1.0f) p = 1.0f;
6442 current_rt->fetch_binary_progress = p;
6443 }
6444 }
6445 // Check if curl finished
6446 if (current_rt->fetch_binary_pending) {
6447 FILE *fbrc = fopen("/tmp/ac_fb_rc", "r");
6448 if (fbrc) {
6449 int rc = -1;
6450 fscanf(fbrc, "%d", &rc);
6451 fclose(fbrc);
6452 unlink("/tmp/ac_fb_rc");
6453 if (rc != 0) {
6454 char emsg[160] = {0};
6455 FILE *ef = fopen("/tmp/ac_fb_err", "r");
6456 if (ef) {
6457 int en = (int)fread(emsg, 1, sizeof(emsg) - 1, ef);
6458 fclose(ef);
6459 emsg[en] = 0;
6460 for (int i = 0; emsg[i]; i++) {
6461 if (emsg[i] == '\n' || emsg[i] == '\r' || emsg[i] == '\t') emsg[i] = ' ';
6462 }
6463 }
6464 ac_log("[fetchBinary] error (%d): %s\n", rc, emsg[0] ? emsg : "(no stderr)");
6465 }
6466 unlink("/tmp/ac_fb_err");
6467 current_rt->fetch_binary_pending = 0;
6468 current_rt->fetch_binary_done = 1;
6469 current_rt->fetch_binary_ok = (rc == 0) ? 1 : 0;
6470 current_rt->fetch_binary_progress = (rc == 0) ? 1.0f : 0.0f;
6471 ac_log("[fetchBinary] complete: rc=%d ok=%d dest=%s\n",
6472 rc, current_rt->fetch_binary_ok, current_rt->fetch_binary_dest);
6473 }
6474 }
6475 JS_SetPropertyStr(ctx, sys, "fetchBinaryProgress",
6476 JS_NewFloat64(ctx, (double)current_rt->fetch_binary_progress));
6477 JS_SetPropertyStr(ctx, sys, "fetchBinaryDone",
6478 JS_NewBool(ctx, current_rt->fetch_binary_done));
6479 JS_SetPropertyStr(ctx, sys, "fetchBinaryOk",
6480 JS_NewBool(ctx, current_rt->fetch_binary_ok));
6481 // Keep done latched until next fetchBinary() call resets it.
6482 // Act/sim/paint run in separate calls; one-shot pulses can be lost.
6483 }
6484
6485 // Flash update
6486 JS_SetPropertyStr(ctx, sys, "flashUpdate",
6487 JS_NewCFunction(ctx, js_flash_update, "flashUpdate", 1));
6488 JS_SetPropertyStr(ctx, sys, "reboot",
6489 JS_NewCFunction(ctx, js_reboot, "reboot", 0));
6490 if (current_rt) {
6491 JS_SetPropertyStr(ctx, sys, "flashDone",
6492 JS_NewBool(ctx, current_rt->flash_done));
6493 JS_SetPropertyStr(ctx, sys, "flashOk",
6494 JS_NewBool(ctx, current_rt->flash_ok));
6495 // Phase: 0=idle 1=writing 2=syncing 3=verifying 4=done
6496 JS_SetPropertyStr(ctx, sys, "flashPhase",
6497 JS_NewInt32(ctx, current_rt->flash_phase));
6498 JS_SetPropertyStr(ctx, sys, "flashVerifiedBytes",
6499 JS_NewFloat64(ctx, (double)current_rt->flash_verified_bytes));
6500 // Flash telemetry: destination path, same-device flag, log lines
6501 if (current_rt->flash_dst[0])
6502 JS_SetPropertyStr(ctx, sys, "flashDst",
6503 JS_NewString(ctx, current_rt->flash_dst));
6504 JS_SetPropertyStr(ctx, sys, "flashSameDevice",
6505 JS_NewBool(ctx, current_rt->flash_same_device));
6506 // Flash log ring buffer
6507 {
6508 int count = current_rt->flash_log_count;
6509 int start = count > 16 ? count - 16 : 0;
6510 JSValue arr = JS_NewArray(ctx);
6511 for (int i = start; i < count; i++) {
6512 JS_SetPropertyUint32(ctx, arr, i - start,
6513 JS_NewString(ctx, current_rt->flash_log[i % 16]));
6514 }
6515 JS_SetPropertyStr(ctx, sys, "flashLog", arr);
6516 }
6517 }
6518
6519 // Config persistence
6520 JS_SetPropertyStr(ctx, sys, "saveConfig",
6521 JS_NewCFunction(ctx, js_save_config, "saveConfig", 2));
6522
6523 // SSH daemon
6524 JS_SetPropertyStr(ctx, sys, "startSSH",
6525 JS_NewCFunction(ctx, js_start_ssh, "startSSH", 0));
6526 JS_SetPropertyStr(ctx, sys, "sshStarted", JS_NewBool(ctx, ssh_started));
6527
6528 // Node.js execution
6529 JS_SetPropertyStr(ctx, sys, "execNode",
6530 JS_NewCFunction(ctx, js_exec_node, "execNode", 1));
6531
6532 // PTY terminal emulator
6533 {
6534 JSValue pty_obj = JS_NewObject(ctx);
6535 JS_SetPropertyStr(ctx, pty_obj, "spawn",
6536 JS_NewCFunction(ctx, js_pty_spawn, "spawn", 4));
6537 JS_SetPropertyStr(ctx, pty_obj, "write",
6538 JS_NewCFunction(ctx, js_pty_write, "write", 1));
6539 JS_SetPropertyStr(ctx, pty_obj, "resize",
6540 JS_NewCFunction(ctx, js_pty_resize, "resize", 2));
6541 JS_SetPropertyStr(ctx, pty_obj, "kill",
6542 JS_NewCFunction(ctx, js_pty_kill, "kill", 0));
6543 JS_SetPropertyStr(ctx, pty_obj, "active",
6544 JS_NewBool(ctx, current_rt->pty_active));
6545
6546 // Pump PTY output and expose grid (only during paint phase)
6547 if (current_rt->pty_active) {
6548 pty_pump(¤t_rt->pty);
6549 pty_check_alive(¤t_rt->pty);
6550
6551 if (!current_rt->pty.alive) {
6552 // Drain remaining output (child error messages, etc.) before closing
6553 pty_pump(¤t_rt->pty);
6554 JS_SetPropertyStr(ctx, pty_obj, "exitCode",
6555 JS_NewInt32(ctx, current_rt->pty.exit_code));
6556 pty_destroy(¤t_rt->pty);
6557 current_rt->pty_active = 0;
6558 }
6559
6560 JS_SetPropertyStr(ctx, pty_obj, "alive",
6561 JS_NewBool(ctx, current_rt->pty.alive));
6562 JS_SetPropertyStr(ctx, pty_obj, "cursorX",
6563 JS_NewInt32(ctx, current_rt->pty.cursor_x));
6564 JS_SetPropertyStr(ctx, pty_obj, "cursorY",
6565 JS_NewInt32(ctx, current_rt->pty.cursor_y));
6566 JS_SetPropertyStr(ctx, pty_obj, "cols",
6567 JS_NewInt32(ctx, current_rt->pty.cols));
6568 JS_SetPropertyStr(ctx, pty_obj, "rows",
6569 JS_NewInt32(ctx, current_rt->pty.rows));
6570 JS_SetPropertyStr(ctx, pty_obj, "dirty",
6571 JS_NewBool(ctx, current_rt->pty.grid_dirty));
6572 JS_SetPropertyStr(ctx, pty_obj, "cursorVisible",
6573 JS_NewBool(ctx, current_rt->pty.cursor_visible));
6574
6575 // Expose grid as flat array: [ch, fg, bg, bold, ...] per cell, row by row
6576 // Only send if dirty (perf optimization)
6577 if (current_rt->pty.grid_dirty) {
6578 ACPty *p = ¤t_rt->pty;
6579 JSValue grid = JS_NewArray(ctx);
6580 int idx = 0;
6581 for (int y = 0; y < p->rows; y++) {
6582 for (int x = 0; x < p->cols; x++) {
6583 ACPtyCell *c = &p->grid[y][x];
6584 // Pack cell: ch, fg, bg, bold, fg_r, fg_g, fg_b, bg_r, bg_g, bg_b
6585 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->ch));
6586 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->fg));
6587 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->bg));
6588 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->bold));
6589 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->fg_r));
6590 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->fg_g));
6591 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->fg_b));
6592 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->bg_r));
6593 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->bg_g));
6594 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->bg_b));
6595 }
6596 }
6597 JS_SetPropertyStr(ctx, pty_obj, "grid", grid);
6598 p->grid_dirty = 0;
6599 }
6600 } else {
6601 // PTY not active — expose last exit code for diagnostics
6602 JS_SetPropertyStr(ctx, pty_obj, "exitCode",
6603 JS_NewInt32(ctx, current_rt->pty.exit_code));
6604 }
6605
6606 JS_SetPropertyStr(ctx, sys, "pty", pty_obj);
6607 }
6608
6609 // PTY2 — second terminal emulator for split-screen
6610 {
6611 JSValue pty2_obj = JS_NewObject(ctx);
6612 JS_SetPropertyStr(ctx, pty2_obj, "spawn",
6613 JS_NewCFunction(ctx, js_pty2_spawn, "spawn", 4));
6614 JS_SetPropertyStr(ctx, pty2_obj, "write",
6615 JS_NewCFunction(ctx, js_pty2_write, "write", 1));
6616 JS_SetPropertyStr(ctx, pty2_obj, "resize",
6617 JS_NewCFunction(ctx, js_pty2_resize, "resize", 2));
6618 JS_SetPropertyStr(ctx, pty2_obj, "kill",
6619 JS_NewCFunction(ctx, js_pty2_kill, "kill", 0));
6620 JS_SetPropertyStr(ctx, pty2_obj, "active",
6621 JS_NewBool(ctx, current_rt->pty2_active));
6622
6623 if (current_rt->pty2_active) {
6624 pty_pump(¤t_rt->pty2);
6625 pty_check_alive(¤t_rt->pty2);
6626
6627 if (!current_rt->pty2.alive) {
6628 pty_pump(¤t_rt->pty2);
6629 JS_SetPropertyStr(ctx, pty2_obj, "exitCode",
6630 JS_NewInt32(ctx, current_rt->pty2.exit_code));
6631 pty_destroy(¤t_rt->pty2);
6632 current_rt->pty2_active = 0;
6633 }
6634
6635 JS_SetPropertyStr(ctx, pty2_obj, "alive",
6636 JS_NewBool(ctx, current_rt->pty2.alive));
6637 JS_SetPropertyStr(ctx, pty2_obj, "cursorX",
6638 JS_NewInt32(ctx, current_rt->pty2.cursor_x));
6639 JS_SetPropertyStr(ctx, pty2_obj, "cursorY",
6640 JS_NewInt32(ctx, current_rt->pty2.cursor_y));
6641 JS_SetPropertyStr(ctx, pty2_obj, "cols",
6642 JS_NewInt32(ctx, current_rt->pty2.cols));
6643 JS_SetPropertyStr(ctx, pty2_obj, "rows",
6644 JS_NewInt32(ctx, current_rt->pty2.rows));
6645 JS_SetPropertyStr(ctx, pty2_obj, "dirty",
6646 JS_NewBool(ctx, current_rt->pty2.grid_dirty));
6647 JS_SetPropertyStr(ctx, pty2_obj, "cursorVisible",
6648 JS_NewBool(ctx, current_rt->pty2.cursor_visible));
6649
6650 if (current_rt->pty2.grid_dirty) {
6651 ACPty *p = ¤t_rt->pty2;
6652 JSValue grid = JS_NewArray(ctx);
6653 int idx = 0;
6654 for (int y = 0; y < p->rows; y++) {
6655 for (int x = 0; x < p->cols; x++) {
6656 ACPtyCell *c = &p->grid[y][x];
6657 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->ch));
6658 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->fg));
6659 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->bg));
6660 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->bold));
6661 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->fg_r));
6662 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->fg_g));
6663 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->fg_b));
6664 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->bg_r));
6665 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->bg_g));
6666 JS_SetPropertyUint32(ctx, grid, idx++, JS_NewInt32(ctx, c->bg_b));
6667 }
6668 }
6669 JS_SetPropertyStr(ctx, pty2_obj, "grid", grid);
6670 p->grid_dirty = 0;
6671 }
6672 } else {
6673 JS_SetPropertyStr(ctx, pty2_obj, "exitCode",
6674 JS_NewInt32(ctx, current_rt->pty2.exit_code));
6675 }
6676
6677 JS_SetPropertyStr(ctx, sys, "pty2", pty2_obj);
6678 }
6679
6680 // Piece navigation
6681 JS_SetPropertyStr(ctx, sys, "jump",
6682 JS_NewCFunction(ctx, js_jump, "jump", 1));
6683
6684 // system.listPieces() — scan /pieces/*.mjs and return name array
6685 JS_SetPropertyStr(ctx, sys, "listPieces",
6686 JS_NewCFunction(ctx, js_list_pieces, "listPieces", 0));
6687
6688 // Printer detection and raw printing
6689 JS_SetPropertyStr(ctx, sys, "listPrinters",
6690 JS_NewCFunction(ctx, js_list_printers, "listPrinters", 0));
6691 JS_SetPropertyStr(ctx, sys, "printRaw",
6692 JS_NewCFunction(ctx, js_print_raw, "printRaw", 2));
6693
6694 // Volume and brightness control from JS
6695 JS_SetPropertyStr(ctx, sys, "volumeAdjust",
6696 JS_NewCFunction(ctx, js_volume_adjust, "volumeAdjust", 1));
6697 JS_SetPropertyStr(ctx, sys, "brightnessAdjust",
6698 JS_NewCFunction(ctx, js_brightness_adjust, "brightnessAdjust", 1));
6699 JS_SetPropertyStr(ctx, sys, "qrEncode",
6700 JS_NewCFunction(ctx, js_qr_encode, "qrEncode", 1));
6701 JS_SetPropertyStr(ctx, sys, "openBrowser",
6702 JS_NewCFunction(ctx, js_open_browser, "openBrowser", 1));
6703
6704 // Nopaint system — persistent painting canvas
6705 if (current_rt && strcmp(current_rt->system_mode, "nopaint") == 0) {
6706 // Create painting + buffer on first use
6707 if (!current_rt->nopaint_painting) {
6708 int w = current_rt->graph->screen->width;
6709 int h = current_rt->graph->screen->height;
6710 current_rt->nopaint_painting = fb_create(w, h);
6711 current_rt->nopaint_buffer = fb_create(w, h);
6712 // Fill painting with theme background
6713 fb_clear(current_rt->nopaint_painting, 0xFFF0EEE8); // light bg default
6714 fb_clear(current_rt->nopaint_buffer, 0x00000000); // transparent
6715 current_rt->nopaint_active = 1;
6716 ac_log("[nopaint] Created painting %dx%d\n", w, h);
6717 }
6718
6719 JSValue np = JS_NewObject(ctx);
6720
6721 // nopaint.is(state) — check nopaint state
6722 JS_SetPropertyStr(ctx, np, "is", JS_NewCFunction(ctx, js_nopaint_is, "is", 1));
6723 JS_SetPropertyStr(ctx, np, "cancelStroke", JS_NewCFunction(ctx, js_nopaint_cancel, "cancelStroke", 0));
6724
6725 // nopaint.brush — current brush position
6726 JSValue brush = JS_NewObject(ctx);
6727 JS_SetPropertyStr(ctx, brush, "x", JS_NewInt32(ctx, current_rt->nopaint_brush_x));
6728 JS_SetPropertyStr(ctx, brush, "y", JS_NewInt32(ctx, current_rt->nopaint_brush_y));
6729 JS_SetPropertyStr(ctx, np, "brush", brush);
6730
6731 // nopaint.buffer — temporary stroke overlay (as Painting object)
6732 JSValue buf_obj = JS_NewObjectClass(ctx, painting_class_id);
6733 JS_SetOpaque(buf_obj, current_rt->nopaint_buffer);
6734 JS_SetPropertyStr(ctx, buf_obj, "width", JS_NewInt32(ctx, current_rt->nopaint_buffer->width));
6735 JS_SetPropertyStr(ctx, buf_obj, "height", JS_NewInt32(ctx, current_rt->nopaint_buffer->height));
6736 JS_SetPropertyStr(ctx, np, "buffer", buf_obj);
6737
6738 // nopaint.needsBake
6739 JS_SetPropertyStr(ctx, np, "needsBake", JS_NewBool(ctx, current_rt->nopaint_needs_bake));
6740
6741 JS_SetPropertyStr(ctx, sys, "nopaint", np);
6742
6743 // system.painting — the persistent canvas (as Painting object)
6744 JSValue ptg_obj = JS_NewObjectClass(ctx, painting_class_id);
6745 JS_SetOpaque(ptg_obj, current_rt->nopaint_painting);
6746 JS_SetPropertyStr(ctx, ptg_obj, "width", JS_NewInt32(ctx, current_rt->nopaint_painting->width));
6747 JS_SetPropertyStr(ctx, ptg_obj, "height", JS_NewInt32(ctx, current_rt->nopaint_painting->height));
6748 JS_SetPropertyStr(ctx, sys, "painting", ptg_obj);
6749 } else if (current_rt && current_rt->nopaint_painting) {
6750 // Even non-nopaint pieces (like prompt) can access system.painting to show it
6751 JSValue ptg_obj = JS_NewObjectClass(ctx, painting_class_id);
6752 JS_SetOpaque(ptg_obj, current_rt->nopaint_painting);
6753 JS_SetPropertyStr(ctx, ptg_obj, "width", JS_NewInt32(ctx, current_rt->nopaint_painting->width));
6754 JS_SetPropertyStr(ctx, ptg_obj, "height", JS_NewInt32(ctx, current_rt->nopaint_painting->height));
6755 JS_SetPropertyStr(ctx, sys, "painting", ptg_obj);
6756 }
6757
6758 return sys;
6759}
6760
6761// Build the API object passed to lifecycle functions
6762static JSValue build_api(JSContext *ctx, ACRuntime *rt, const char *phase) {
6763 current_phase = phase;
6764 JSValue api = JS_NewObject(ctx);
6765 JSValue global = JS_GetGlobalObject(ctx);
6766
6767 // Graphics
6768 JS_SetPropertyStr(ctx, api, "wipe", JS_GetPropertyStr(ctx, global, "wipe"));
6769 JS_SetPropertyStr(ctx, api, "ink", JS_GetPropertyStr(ctx, global, "ink"));
6770 JS_SetPropertyStr(ctx, api, "line", JS_GetPropertyStr(ctx, global, "line"));
6771 JS_SetPropertyStr(ctx, api, "box", JS_GetPropertyStr(ctx, global, "box"));
6772 JS_SetPropertyStr(ctx, api, "circle", JS_GetPropertyStr(ctx, global, "circle"));
6773 JS_SetPropertyStr(ctx, api, "plot", JS_GetPropertyStr(ctx, global, "plot"));
6774 JS_SetPropertyStr(ctx, api, "write", JS_GetPropertyStr(ctx, global, "write"));
6775 JS_SetPropertyStr(ctx, api, "scroll", JS_GetPropertyStr(ctx, global, "scroll"));
6776 JS_SetPropertyStr(ctx, api, "blur", JS_GetPropertyStr(ctx, global, "blur"));
6777 JS_SetPropertyStr(ctx, api, "zoom", JS_GetPropertyStr(ctx, global, "zoom"));
6778 JS_SetPropertyStr(ctx, api, "contrast", JS_GetPropertyStr(ctx, global, "contrast"));
6779 JS_SetPropertyStr(ctx, api, "spin", JS_GetPropertyStr(ctx, global, "spin"));
6780 JS_SetPropertyStr(ctx, api, "qr", JS_GetPropertyStr(ctx, global, "qr"));
6781
6782 // screen
6783 {
6784 JSValue s = JS_NewObject(ctx);
6785 JS_SetPropertyStr(ctx, s, "width", JS_NewInt32(ctx, rt->graph->fb->width));
6786 JS_SetPropertyStr(ctx, s, "height", JS_NewInt32(ctx, rt->graph->fb->height));
6787 JS_SetPropertyStr(ctx, api, "screen", s);
6788 }
6789
6790 // Counters
6791 if (strcmp(phase, "paint") == 0) {
6792 JS_SetPropertyStr(ctx, api, "paintCount", JS_NewInt32(ctx, rt->paint_count));
6793 }
6794 if (strcmp(phase, "sim") == 0) {
6795 JS_SetPropertyStr(ctx, api, "simCount", JS_NewInt32(ctx, rt->sim_count));
6796 }
6797
6798 // Params (colon-separated args from system.jump, e.g. "chat:clock" → ["clock"])
6799 if (strcmp(phase, "boot") == 0 && rt->jump_param_count > 0) {
6800 JSValue params = JS_NewArray(ctx);
6801 for (int i = 0; i < rt->jump_param_count; i++) {
6802 JS_SetPropertyUint32(ctx, params, i, JS_NewString(ctx, rt->jump_params[i]));
6803 }
6804 JS_SetPropertyStr(ctx, api, "params", params);
6805 }
6806
6807 // needsPaint (noop — native always paints every frame)
6808 JS_SetPropertyStr(ctx, api, "needsPaint", JS_NewCFunction(ctx, js_noop, "needsPaint", 0));
6809
6810 // Sound
6811 JS_SetPropertyStr(ctx, api, "sound", build_sound_obj(ctx, rt));
6812
6813 // System (battery, etc)
6814 JS_SetPropertyStr(ctx, api, "system", build_system_obj(ctx));
6815
6816 // WiFi
6817 JS_SetPropertyStr(ctx, api, "wifi", build_wifi_obj(ctx, rt->wifi));
6818
6819 // Trackpad delta (per-frame relative movement)
6820 if (rt->input) {
6821 JSValue trackpad = JS_NewObject(ctx);
6822 JS_SetPropertyStr(ctx, trackpad, "dx", JS_NewInt32(ctx, rt->input->delta_x));
6823 JS_SetPropertyStr(ctx, trackpad, "dy", JS_NewInt32(ctx, rt->input->delta_y));
6824 JS_SetPropertyStr(ctx, api, "trackpad", trackpad);
6825
6826 // Analog key pressures — object mapping key names to 0.0-1.0 pressure
6827 if (rt->input->has_analog) {
6828 JSValue pressures = JS_NewObject(ctx);
6829 for (int i = 0; i < MAX_ANALOG_KEYS; i++) {
6830 if (rt->input->analog_keys[i].active) {
6831 const char *name = input_key_name(rt->input->analog_keys[i].key_code);
6832 if (name) {
6833 JS_SetPropertyStr(ctx, pressures, name,
6834 JS_NewFloat64(ctx, (double)rt->input->analog_keys[i].pressure));
6835 }
6836 }
6837 }
6838 JS_SetPropertyStr(ctx, api, "pressures", pressures);
6839 }
6840 }
6841
6842 // params, colon, query
6843 JS_SetPropertyStr(ctx, api, "params", JS_NewArray(ctx));
6844 JS_SetPropertyStr(ctx, api, "colon", JS_NewArray(ctx));
6845 JS_SetPropertyStr(ctx, api, "query", JS_NewObject(ctx));
6846
6847 // Form constructor
6848 JS_SetPropertyStr(ctx, api, "Form", JS_NewCFunction2(ctx, js_form_constructor, "Form", 2,
6849 JS_CFUNC_constructor, 0));
6850
6851 // CUBEL and QUAD geometry constants
6852 JS_SetPropertyStr(ctx, api, "CUBEL", build_cubel(ctx));
6853 JS_SetPropertyStr(ctx, api, "QUAD", build_quad(ctx));
6854
6855 // penLock
6856 JS_SetPropertyStr(ctx, api, "penLock", JS_NewCFunction(ctx, js_pen_lock, "penLock", 0));
6857
6858 // get (stub for texture loading — fps.mjs checks if get is available)
6859 JS_SetPropertyStr(ctx, api, "get", JS_NULL);
6860
6861 // setShowClippedWireframes, clearWireframeBuffer, drawBufferedWireframes,
6862 // getRenderStats — stubs/no-ops for fps.mjs debug panel
6863 JS_SetPropertyStr(ctx, api, "setShowClippedWireframes", JS_NewCFunction(ctx, js_noop, "setShowClippedWireframes", 1));
6864 JS_SetPropertyStr(ctx, api, "clearWireframeBuffer", JS_NewCFunction(ctx, js_noop, "clearWireframeBuffer", 0));
6865 JS_SetPropertyStr(ctx, api, "drawBufferedWireframes", JS_NewCFunction(ctx, js_noop, "drawBufferedWireframes", 0));
6866 JS_SetPropertyStr(ctx, api, "getRenderStats", JS_NewCFunction(ctx, js_noop, "getRenderStats", 0));
6867
6868 // system.fps — camera and render stats for debug panel
6869 {
6870 JSValue fps_obj = JS_NewObject(ctx);
6871
6872 // system.fps.renderStats
6873 JSValue rs = JS_NewObject(ctx);
6874 JS_SetPropertyStr(ctx, rs, "originalTriangles", JS_NewInt32(ctx, rt->render_stats.originalTriangles));
6875 JS_SetPropertyStr(ctx, rs, "clippedTriangles", JS_NewInt32(ctx, rt->render_stats.clippedTriangles));
6876 JS_SetPropertyStr(ctx, rs, "subdividedTriangles", JS_NewInt32(ctx, rt->render_stats.subdividedTriangles));
6877 JS_SetPropertyStr(ctx, rs, "trianglesRejected", JS_NewInt32(ctx, rt->render_stats.trianglesRejected));
6878 JS_SetPropertyStr(ctx, rs, "pixelsDrawn", JS_NewInt32(ctx, rt->render_stats.pixelsDrawn));
6879 JS_SetPropertyStr(ctx, rs, "wireframeSegmentsTotal", JS_NewInt32(ctx, rt->render_stats.wireframeSegmentsTotal));
6880 JS_SetPropertyStr(ctx, rs, "wireframeSegmentsTextured", JS_NewInt32(ctx, rt->render_stats.wireframeSegmentsTextured));
6881 JS_SetPropertyStr(ctx, rs, "wireframeSegmentsGradient", JS_NewInt32(ctx, rt->render_stats.wireframeSegmentsGradient));
6882 JS_SetPropertyStr(ctx, fps_obj, "renderStats", rs);
6883
6884 // system.fps.doll.cam — camera position/rotation for debug
6885 JSValue doll = JS_NewObject(ctx);
6886 JSValue cam = JS_NewObject(ctx);
6887 JS_SetPropertyStr(ctx, cam, "x", JS_NewFloat64(ctx, rt->camera3d.x));
6888 JS_SetPropertyStr(ctx, cam, "y", JS_NewFloat64(ctx, rt->camera3d.y));
6889 JS_SetPropertyStr(ctx, cam, "z", JS_NewFloat64(ctx, rt->camera3d.z));
6890 JS_SetPropertyStr(ctx, cam, "rotX", JS_NewFloat64(ctx, rt->camera3d.rotX));
6891 JS_SetPropertyStr(ctx, cam, "rotY", JS_NewFloat64(ctx, rt->camera3d.rotY));
6892 JS_SetPropertyStr(ctx, cam, "rotZ", JS_NewFloat64(ctx, rt->camera3d.rotZ));
6893 JS_SetPropertyStr(ctx, doll, "cam", cam);
6894 JS_SetPropertyStr(ctx, fps_obj, "doll", doll);
6895
6896 // Attach to system object on api
6897 JSValue sys = JS_GetPropertyStr(ctx, api, "system");
6898 if (!JS_IsUndefined(sys) && !JS_IsNull(sys))
6899 JS_SetPropertyStr(ctx, sys, "fps", fps_obj);
6900 else
6901 JS_FreeValue(ctx, fps_obj);
6902 JS_FreeValue(ctx, sys);
6903 }
6904
6905 // fps (no-op — kept for non-3D pieces)
6906 JS_SetPropertyStr(ctx, api, "fps", JS_NewCFunction(ctx, js_noop, "fps", 1));
6907
6908 // hud
6909 {
6910 JSValue hud = JS_NewObject(ctx);
6911 JS_SetPropertyStr(ctx, hud, "label", JS_NewCFunction(ctx, js_noop, "label", 3));
6912 JS_SetPropertyStr(ctx, hud, "superscript", JS_NewCFunction(ctx, js_noop, "superscript", 1));
6913 JS_SetPropertyStr(ctx, api, "hud", hud);
6914 }
6915
6916 // net
6917 {
6918 JSValue net = JS_NewObject(ctx);
6919 JS_SetPropertyStr(ctx, net, "udp", JS_NewCFunction(ctx, js_net_udp, "udp", 0));
6920 JS_SetPropertyStr(ctx, net, "preload", JS_NewCFunction(ctx, js_promise_null, "preload", 1));
6921 JS_SetPropertyStr(ctx, net, "pieces", JS_NewCFunction(ctx, js_promise_null, "pieces", 1));
6922 JS_SetPropertyStr(ctx, net, "rewrite", JS_NewCFunction(ctx, js_noop, "rewrite", 1));
6923 JS_SetPropertyStr(ctx, net, "log", JS_NewCFunction(ctx, js_noop, "log", 2));
6924 JS_SetPropertyStr(ctx, api, "net", net);
6925 }
6926
6927 // pen — current pointer position
6928 if (rt->input) {
6929 JSValue pen = JS_NewObject(ctx);
6930 JS_SetPropertyStr(ctx, pen, "x", JS_NewInt32(ctx, rt->input->pointer_x));
6931 JS_SetPropertyStr(ctx, pen, "y", JS_NewInt32(ctx, rt->input->pointer_y));
6932 JS_SetPropertyStr(ctx, pen, "down", JS_NewBool(ctx, rt->input->pointer_down));
6933 JS_SetPropertyStr(ctx, api, "pen", pen);
6934 }
6935
6936 // store
6937 {
6938 JSValue store = JS_NewObject(ctx);
6939 JS_SetPropertyStr(ctx, store, "retrieve", JS_NewCFunction(ctx, js_promise_null, "retrieve", 2));
6940 JS_SetPropertyStr(ctx, store, "persist", JS_NewCFunction(ctx, js_noop, "persist", 2));
6941 JS_SetPropertyStr(ctx, store, "delete", JS_NewCFunction(ctx, js_promise_null, "delete", 2));
6942 JS_SetPropertyStr(ctx, api, "store", store);
6943 }
6944
6945 // clock
6946 {
6947 JSValue clock = JS_NewObject(ctx);
6948 JS_SetPropertyStr(ctx, clock, "resync", JS_NewCFunction(ctx, js_noop, "resync", 0));
6949 JS_SetPropertyStr(ctx, clock, "time", JS_NewCFunction(ctx, js_clock_time, "time", 0));
6950 JS_SetPropertyStr(ctx, api, "clock", clock);
6951 }
6952
6953 // typeface (no-op function)
6954 JS_SetPropertyStr(ctx, api, "typeface", JS_NewCFunction(ctx, js_noop, "typeface", 1));
6955
6956 // painting(w, h, callback) — creates a stub painting object with width/height
6957 JS_SetPropertyStr(ctx, api, "painting", JS_NewCFunction(ctx, js_painting, "painting", 3));
6958
6959 // paste, page (real implementations), layer, sharpen (stubs)
6960 JS_SetPropertyStr(ctx, api, "paste", JS_NewCFunction(ctx, js_paste, "paste", 3));
6961 JS_SetPropertyStr(ctx, api, "page", JS_NewCFunction(ctx, js_page, "page", 1));
6962 JS_SetPropertyStr(ctx, api, "layer", JS_NewCFunction(ctx, js_noop, "layer", 1));
6963 JS_SetPropertyStr(ctx, api, "sharpen", JS_NewCFunction(ctx, js_noop, "sharpen", 1));
6964
6965 // num
6966 {
6967 JSValue num = JS_NewObject(ctx);
6968 JS_SetPropertyStr(ctx, num, "clamp", JS_NewCFunction(ctx, js_num_clamp, "clamp", 3));
6969 JS_SetPropertyStr(ctx, num, "rand", JS_NewCFunction(ctx, js_num_rand, "rand", 0));
6970 JS_SetPropertyStr(ctx, num, "randIntRange", JS_NewCFunction(ctx, js_num_randint, "randIntRange", 2));
6971
6972 // max/min — just reference Math.max/min
6973 JSValue math = JS_GetPropertyStr(ctx, global, "Math");
6974 JS_SetPropertyStr(ctx, num, "max", JS_GetPropertyStr(ctx, math, "max"));
6975 JS_SetPropertyStr(ctx, num, "min", JS_GetPropertyStr(ctx, math, "min"));
6976 JS_SetPropertyStr(ctx, num, "abs", JS_GetPropertyStr(ctx, math, "abs"));
6977 JS_SetPropertyStr(ctx, num, "floor", JS_GetPropertyStr(ctx, math, "floor"));
6978 JS_SetPropertyStr(ctx, num, "ceil", JS_GetPropertyStr(ctx, math, "ceil"));
6979 JS_SetPropertyStr(ctx, num, "round", JS_GetPropertyStr(ctx, math, "round"));
6980 JS_SetPropertyStr(ctx, num, "sign", JS_GetPropertyStr(ctx, math, "sign"));
6981 JS_FreeValue(ctx, math);
6982 JS_SetPropertyStr(ctx, num, "shiftRGB", JS_GetPropertyStr(ctx, global, "__shiftRGB"));
6983 JS_SetPropertyStr(ctx, num, "dist", JS_GetPropertyStr(ctx, global, "__dist"));
6984 JS_SetPropertyStr(ctx, num, "map", JS_GetPropertyStr(ctx, global, "__map"));
6985 JS_SetPropertyStr(ctx, num, "lerp", JS_GetPropertyStr(ctx, global, "__lerp"));
6986 JS_SetPropertyStr(ctx, num, "parseColor", JS_GetPropertyStr(ctx, global, "__parseColor"));
6987 JS_SetPropertyStr(ctx, num, "randIntArr", JS_GetPropertyStr(ctx, global, "__randIntArr"));
6988 JS_SetPropertyStr(ctx, num, "timestamp", JS_GetPropertyStr(ctx, global, "__timestamp"));
6989 JS_SetPropertyStr(ctx, api, "num", num);
6990 }
6991
6992 // help
6993 {
6994 JSValue help = JS_NewObject(ctx);
6995 JS_SetPropertyStr(ctx, help, "resampleArray", JS_NewCFunction(ctx, js_noop, "resampleArray", 2));
6996 JS_SetPropertyStr(ctx, api, "help", help);
6997 }
6998
6999 // pens
7000 JS_SetPropertyStr(ctx, api, "pens", JS_NewCFunction(ctx, js_noop, "pens", 0));
7001
7002 // ui
7003 {
7004 JSValue ui = JS_NewObject(ctx);
7005 JSValue btn_ctor = JS_GetPropertyStr(ctx, global, "__Button");
7006 JS_SetPropertyStr(ctx, ui, "Button", btn_ctor);
7007 JS_SetPropertyStr(ctx, api, "ui", ui);
7008 }
7009
7010 // api sub-object (meta-API)
7011 // notepat passes this nested `api` to helpers like buildWaveButton(api)
7012 // which destructure { screen, ui, typeface, geo, sound, num, ... } from it
7013 {
7014 JSValue api_meta = JS_NewObject(ctx);
7015 JS_SetPropertyStr(ctx, api_meta, "Typeface", JS_GetPropertyStr(ctx, global, "__Typeface"));
7016 JS_SetPropertyStr(ctx, api_meta, "beep", JS_NewCFunction(ctx, js_noop, "beep", 1));
7017 JS_SetPropertyStr(ctx, api_meta, "send", JS_NewCFunction(ctx, js_noop, "send", 1));
7018
7019 // api.text.box
7020 JSValue api_text = JS_NewObject(ctx);
7021 JS_SetPropertyStr(ctx, api_text, "box", JS_NewCFunction(ctx, js_noop, "box", 5));
7022 JS_SetPropertyStr(ctx, api_meta, "text", api_text);
7023
7024 // api.geo.Box
7025 JSValue api_geo = JS_NewObject(ctx);
7026 JS_SetPropertyStr(ctx, api_geo, "Box", JS_GetPropertyStr(ctx, global, "__Box"));
7027 JS_SetPropertyStr(ctx, api_meta, "geo", api_geo);
7028
7029 // Copy top-level API properties onto api_meta so helper functions
7030 // like buildWaveButton(api) can destructure { screen, ui, typeface, ... }
7031 JS_SetPropertyStr(ctx, api_meta, "screen", JS_GetPropertyStr(ctx, api, "screen"));
7032 JS_SetPropertyStr(ctx, api_meta, "ui", JS_GetPropertyStr(ctx, api, "ui"));
7033 JS_SetPropertyStr(ctx, api_meta, "typeface", JS_GetPropertyStr(ctx, api, "typeface"));
7034 JS_SetPropertyStr(ctx, api_meta, "sound", JS_GetPropertyStr(ctx, api, "sound"));
7035 JS_SetPropertyStr(ctx, api_meta, "num", JS_GetPropertyStr(ctx, api, "num"));
7036 JS_SetPropertyStr(ctx, api_meta, "hud", JS_GetPropertyStr(ctx, api, "hud"));
7037 JS_SetPropertyStr(ctx, api_meta, "net", JS_GetPropertyStr(ctx, api, "net"));
7038 JS_SetPropertyStr(ctx, api_meta, "store", JS_GetPropertyStr(ctx, api, "store"));
7039 JS_SetPropertyStr(ctx, api_meta, "help", JS_GetPropertyStr(ctx, api, "help"));
7040 JS_SetPropertyStr(ctx, api_meta, "painting", JS_GetPropertyStr(ctx, api, "painting"));
7041 JS_SetPropertyStr(ctx, api_meta, "paste", JS_GetPropertyStr(ctx, api, "paste"));
7042 JS_SetPropertyStr(ctx, api_meta, "page", JS_GetPropertyStr(ctx, api, "page"));
7043 JS_SetPropertyStr(ctx, api_meta, "layer", JS_GetPropertyStr(ctx, api, "layer"));
7044 JS_SetPropertyStr(ctx, api_meta, "pens", JS_GetPropertyStr(ctx, api, "pens"));
7045 JS_SetPropertyStr(ctx, api_meta, "params", JS_GetPropertyStr(ctx, api, "params"));
7046 JS_SetPropertyStr(ctx, api_meta, "colon", JS_GetPropertyStr(ctx, api, "colon"));
7047 JS_SetPropertyStr(ctx, api_meta, "wipe", JS_GetPropertyStr(ctx, api, "wipe"));
7048 JS_SetPropertyStr(ctx, api_meta, "ink", JS_GetPropertyStr(ctx, api, "ink"));
7049 JS_SetPropertyStr(ctx, api_meta, "box", JS_GetPropertyStr(ctx, api, "box"));
7050 JS_SetPropertyStr(ctx, api_meta, "line", JS_GetPropertyStr(ctx, api, "line"));
7051 JS_SetPropertyStr(ctx, api_meta, "circle", JS_GetPropertyStr(ctx, api, "circle"));
7052 JS_SetPropertyStr(ctx, api_meta, "plot", JS_GetPropertyStr(ctx, api, "plot"));
7053 JS_SetPropertyStr(ctx, api_meta, "write", JS_GetPropertyStr(ctx, api, "write"));
7054 JS_SetPropertyStr(ctx, api_meta, "scroll", JS_GetPropertyStr(ctx, api, "scroll"));
7055 JS_SetPropertyStr(ctx, api_meta, "blur", JS_GetPropertyStr(ctx, api, "blur"));
7056 JS_SetPropertyStr(ctx, api_meta, "zoom", JS_GetPropertyStr(ctx, api, "zoom"));
7057 JS_SetPropertyStr(ctx, api_meta, "contrast", JS_GetPropertyStr(ctx, api, "contrast"));
7058 JS_SetPropertyStr(ctx, api_meta, "spin", JS_GetPropertyStr(ctx, api, "spin"));
7059
7060 JS_SetPropertyStr(ctx, api, "api", api_meta);
7061 }
7062
7063 JS_FreeValue(ctx, global);
7064 return api;
7065}
7066
7067static int piece_load_counter = 0;
7068
7069int js_load_piece(ACRuntime *rt, const char *path) {
7070 JSContext *ctx = rt->ctx;
7071 current_rt = rt;
7072
7073 FILE *f = fopen(path, "r");
7074 if (!f) {
7075 fprintf(stderr, "[js] Cannot open %s\n", path);
7076 return -1;
7077 }
7078 fseek(f, 0, SEEK_END);
7079 long len = ftell(f);
7080 fseek(f, 0, SEEK_SET);
7081 char *src = malloc(len + 1);
7082 fread(src, 1, len, f);
7083 src[len] = '\0';
7084 fclose(f);
7085
7086 // Append globalThis export assignments so we can find lifecycle functions
7087 // after module evaluation (QuickJS modules don't expose exports publicly).
7088 // For pieces with `export const system = "fps"` (arena.mjs etc.) this
7089 // ALSO instantiates CamDoll from the preloaded FPS bundle and wraps
7090 // boot/sim/act/paint so `api.system.fps.doll` is live on every call
7091 // without the piece needing to know anything about the native runtime.
7092 const char *export_shim =
7093 "\n;if(typeof boot==='function')globalThis.boot=boot;"
7094 "if(typeof paint==='function')globalThis.paint=paint;"
7095 "if(typeof act==='function')globalThis.act=act;"
7096 "if(typeof sim==='function')globalThis.sim=sim;"
7097 "if(typeof leave==='function')globalThis.leave=leave;"
7098 "if(typeof beat==='function')globalThis.beat=beat;"
7099 "if(typeof configureAutopat==='function')globalThis.configureAutopat=configureAutopat;"
7100 "if(typeof system!=='undefined')globalThis.__pieceSystem=system;"
7101 "if(typeof fpsOpts!=='undefined')globalThis.__pieceFpsOpts=fpsOpts;"
7102 // FPS system wiring — runs only when piece opts in.
7103 "if(globalThis.__pieceSystem==='fps'&&globalThis.__FpsSystem){"
7104 "try{"
7105 "const FS=globalThis.__FpsSystem;"
7106 "const opts=globalThis.__pieceFpsOpts||{fov:80};"
7107 "const doll=new FS.CamDoll(FS.Camera,FS.Dolly,opts);"
7108 "globalThis.__fpsDoll=doll;"
7109 "const _b=globalThis.boot,_s=globalThis.sim,_a=globalThis.act,_p=globalThis.paint;"
7110 "const inject=(api)=>{if(api){if(!api.system)api.system={};api.system.fps={doll};}};"
7111 "globalThis.boot=(api)=>{inject(api);return _b?.(api);};"
7112 "globalThis.sim=(api)=>{inject(api);try{doll.sim?.();}catch(e){}return _s?.(api);};"
7113 "globalThis.act=(api)=>{inject(api);try{if(api?.event)doll.act?.(api.event);}catch(e){}return _a?.(api);};"
7114 "globalThis.paint=(api)=>{inject(api);return _p?.(api);};"
7115 "}catch(e){console.error('[fps] wire-up failed:',e.message);}"
7116 "}\n";
7117 size_t shim_len = strlen(export_shim);
7118 char *patched = malloc(len + shim_len + 1);
7119 memcpy(patched, src, len);
7120 memcpy(patched + len, export_shim, shim_len);
7121 patched[len + shim_len] = '\0';
7122 free(src);
7123 src = patched;
7124 len += shim_len;
7125
7126 // Use unique module name each load (QuickJS caches modules by name)
7127 char mod_name[300];
7128 snprintf(mod_name, sizeof(mod_name), "%s#%d", path, piece_load_counter++);
7129
7130 // Evaluate as module
7131 JSValue val = JS_Eval(ctx, src, len, mod_name, JS_EVAL_TYPE_MODULE | JS_EVAL_FLAG_COMPILE_ONLY);
7132 free(src);
7133
7134 if (JS_IsException(val)) {
7135 JSValue exc = JS_GetException(ctx);
7136 const char *str = JS_ToCString(ctx, exc);
7137 fprintf(stderr, "[js] Parse error: %s\n", str);
7138 JS_FreeCString(ctx, str);
7139 JS_FreeValue(ctx, exc);
7140 return -1;
7141 }
7142
7143 JSValue result = JS_EvalFunction(ctx, val);
7144 if (JS_IsException(result)) {
7145 JSValue exc = JS_GetException(ctx);
7146 const char *str = JS_ToCString(ctx, exc);
7147 fprintf(stderr, "[js] Module error: %s\n", str);
7148 JS_FreeCString(ctx, str);
7149 JS_FreeValue(ctx, exc);
7150 return -1;
7151 }
7152 JS_FreeValue(ctx, result);
7153
7154 // Get lifecycle functions from globalThis
7155 JSValue global = JS_GetGlobalObject(ctx);
7156 rt->boot_fn = JS_GetPropertyStr(ctx, global, "boot");
7157 rt->paint_fn = JS_GetPropertyStr(ctx, global, "paint");
7158 rt->act_fn = JS_GetPropertyStr(ctx, global, "act");
7159 rt->sim_fn = JS_GetPropertyStr(ctx, global, "sim");
7160 rt->leave_fn = JS_GetPropertyStr(ctx, global, "leave");
7161 rt->beat_fn = JS_GetPropertyStr(ctx, global, "beat");
7162
7163 // Capture piece's `export const system` value (e.g. "prompt", "fps", "nopaint")
7164 rt->system_mode[0] = '\0';
7165 JSValue sys_val = JS_GetPropertyStr(ctx, global, "__pieceSystem");
7166 if (JS_IsString(sys_val)) {
7167 const char *s = JS_ToCString(ctx, sys_val);
7168 if (s) {
7169 strncpy(rt->system_mode, s, sizeof(rt->system_mode) - 1);
7170 rt->system_mode[sizeof(rt->system_mode) - 1] = '\0';
7171 JS_FreeCString(ctx, s);
7172 }
7173 }
7174 JS_FreeValue(ctx, sys_val);
7175
7176 // Auto-detect FPS mode
7177 if (strcmp(rt->system_mode, "fps") == 0)
7178 rt->fps_system_active = 1;
7179 else
7180 rt->fps_system_active = 0;
7181
7182 JS_FreeValue(ctx, global);
7183
7184 fprintf(stderr, "[js] Loaded piece: %s (system=%s)\n", path,
7185 rt->system_mode[0] ? rt->system_mode : "(none)");
7186 fprintf(stderr, "[js] boot=%s paint=%s act=%s sim=%s beat=%s\n",
7187 JS_IsFunction(ctx, rt->boot_fn) ? "yes" : "no",
7188 JS_IsFunction(ctx, rt->paint_fn) ? "yes" : "no",
7189 JS_IsFunction(ctx, rt->act_fn) ? "yes" : "no",
7190 JS_IsFunction(ctx, rt->sim_fn) ? "yes" : "no",
7191 JS_IsFunction(ctx, rt->beat_fn) ? "yes" : "no");
7192
7193 return 0;
7194}
7195
7196void js_call_boot(ACRuntime *rt) {
7197 if (!JS_IsFunction(rt->ctx, rt->boot_fn)) return;
7198 current_rt = rt;
7199 JSValue api = build_api(rt->ctx, rt, "boot");
7200 JSValue result = JS_Call(rt->ctx, rt->boot_fn, JS_UNDEFINED, 1, &api);
7201 if (JS_IsException(result)) {
7202 JSValue exc = JS_GetException(rt->ctx);
7203 const char *str = JS_ToCString(rt->ctx, exc);
7204 JSValue stack = JS_GetPropertyStr(rt->ctx, exc, "stack");
7205 const char *stack_str = JS_ToCString(rt->ctx, stack);
7206 fprintf(stderr, "[js] boot() error: %s\n%s\n", str, stack_str ? stack_str : "");
7207 if (stack_str) JS_FreeCString(rt->ctx, stack_str);
7208 JS_FreeValue(rt->ctx, stack);
7209 JS_FreeCString(rt->ctx, str);
7210 JS_FreeValue(rt->ctx, exc);
7211 }
7212
7213 // If boot() returned a Promise (async function), we need to catch rejections
7214 if (JS_IsObject(result)) {
7215 // Check if it has a .catch method (Promise)
7216 JSValue catch_fn = JS_GetPropertyStr(rt->ctx, result, "catch");
7217 if (JS_IsFunction(rt->ctx, catch_fn)) {
7218 // Register an error handler: promise.catch(e => console.error("boot async error:", e))
7219 const char *catch_code =
7220 "(function(p) { p.catch(function(e) { console.error('[js] boot() async error:', e, e.stack || ''); }); })";
7221 JSValue catch_handler = JS_Eval(rt->ctx, catch_code, strlen(catch_code), "<catch>", JS_EVAL_TYPE_GLOBAL);
7222 if (JS_IsFunction(rt->ctx, catch_handler)) {
7223 JSValue catch_result = JS_Call(rt->ctx, catch_handler, JS_UNDEFINED, 1, &result);
7224 JS_FreeValue(rt->ctx, catch_result);
7225 }
7226 JS_FreeValue(rt->ctx, catch_handler);
7227 }
7228 JS_FreeValue(rt->ctx, catch_fn);
7229 }
7230
7231 JS_FreeValue(rt->ctx, result);
7232 JS_FreeValue(rt->ctx, api);
7233
7234 // Execute pending jobs (promises) — boot() is async, drain fully
7235 // Each await creates a new microtask; need multiple drain rounds
7236 JSContext *pctx;
7237 for (int round = 0; round < 100; round++) {
7238 int jobs = JS_ExecutePendingJob(rt->rt, &pctx);
7239 if (jobs <= 0) break;
7240 }
7241}
7242
7243// Helper: record a JS crash for the overlay
7244static void js_record_crash(ACRuntime *rt, const char *fn_name, const char *msg) {
7245 rt->crash_active = 1;
7246 rt->crash_count++;
7247 rt->crash_frame = 0;
7248 snprintf(rt->crash_msg, sizeof(rt->crash_msg), "%s(): %s", fn_name, msg ? msg : "unknown");
7249}
7250
7251void js_call_paint(ACRuntime *rt) {
7252 if (!JS_IsFunction(rt->ctx, rt->paint_fn)) return;
7253 current_rt = rt;
7254 rt->paint_count++;
7255
7256 // Nopaint: paste the persistent painting as background before piece paints
7257 if (rt->nopaint_active && rt->nopaint_painting) {
7258 graph_paste(rt->graph, rt->nopaint_painting, 0, 0);
7259 }
7260
7261 JSValue api = build_api(rt->ctx, rt, "paint");
7262 JSValue result = JS_Call(rt->ctx, rt->paint_fn, JS_UNDEFINED, 1, &api);
7263 if (JS_IsException(result)) {
7264 JSValue exc = JS_GetException(rt->ctx);
7265 const char *str = JS_ToCString(rt->ctx, exc);
7266 JSValue stack = JS_GetPropertyStr(rt->ctx, exc, "stack");
7267 const char *stack_str = JS_ToCString(rt->ctx, stack);
7268 fprintf(stderr, "[js] paint() error: %s\n%s\n", str, stack_str ? stack_str : "");
7269 js_record_crash(rt, "paint", str);
7270 if (stack_str) JS_FreeCString(rt->ctx, stack_str);
7271 JS_FreeValue(rt->ctx, stack);
7272 JS_FreeCString(rt->ctx, str);
7273 JS_FreeValue(rt->ctx, exc);
7274 }
7275 JS_FreeValue(rt->ctx, result);
7276 JS_FreeValue(rt->ctx, api);
7277
7278 // Nopaint: if bake was requested, composite buffer → painting and clear buffer
7279 if (rt->nopaint_active && rt->nopaint_needs_bake) {
7280 if (rt->nopaint_buffer && rt->nopaint_painting) {
7281 graph_paste(rt->graph, rt->nopaint_buffer, 0, 0); // show final stroke on screen
7282 // Bake: composite buffer onto persistent painting
7283 ACFramebuffer *saved = rt->graph->fb;
7284 graph_page(rt->graph, rt->nopaint_painting);
7285 graph_paste(rt->graph, rt->nopaint_buffer, 0, 0);
7286 graph_page(rt->graph, saved);
7287 // Clear buffer
7288 fb_clear(rt->nopaint_buffer, 0x00000000);
7289 }
7290 rt->nopaint_needs_bake = 0;
7291 }
7292}
7293
7294void js_call_act(ACRuntime *rt) {
7295 current_rt = rt;
7296
7297 // Track key held state for FPS camera (process even without act_fn)
7298 ACInput *input = rt->input;
7299 for (int i = 0; i < input->event_count; i++) {
7300 ACEvent *ev = &input->events[i];
7301 if (ev->type == AC_EVENT_KEYBOARD_DOWN && ev->key_code > 0 && ev->key_code < KEY_MAX)
7302 rt->keys_held[ev->key_code] = 1;
7303 else if (ev->type == AC_EVENT_KEYBOARD_UP && ev->key_code > 0 && ev->key_code < KEY_MAX)
7304 rt->keys_held[ev->key_code] = 0;
7305 }
7306
7307 // System-level Escape → jump to prompt (mirrors web bios behavior).
7308 // Skip if current piece IS prompt, or if piece uses system="world" or "prompt".
7309 if (strcmp(rt->system_mode, "prompt") != 0 &&
7310 strcmp(rt->system_mode, "world") != 0) {
7311 for (int i = 0; i < input->event_count; i++) {
7312 ACEvent *ev = &input->events[i];
7313 if (ev->type == AC_EVENT_KEYBOARD_DOWN && ev->key_code == KEY_ESC) {
7314 // Reset FPS camera state if active
7315 if (rt->pen_locked) {
7316 rt->pen_locked = 0;
7317 rt->fps_system_active = 0;
7318 }
7319 strncpy(rt->jump_target, "prompt", sizeof(rt->jump_target) - 1);
7320 rt->jump_target[sizeof(rt->jump_target) - 1] = '\0';
7321 rt->jump_requested = 1;
7322 rt->jump_param_count = 0;
7323 ac_log("[system] Escape → prompt\n");
7324 return; // Skip passing events to piece
7325 }
7326 }
7327 }
7328
7329 // Nopaint: update brush position and state from touch/mouse events
7330 if (rt->nopaint_active && strcmp(rt->system_mode, "nopaint") == 0) {
7331 for (int i = 0; i < input->event_count; i++) {
7332 ACEvent *ev = &input->events[i];
7333 if (ev->type == AC_EVENT_TOUCH) {
7334 rt->nopaint_state = 1; // painting
7335 rt->nopaint_brush_x = ev->x;
7336 rt->nopaint_brush_y = ev->y;
7337 rt->nopaint_needs_bake = 0;
7338 } else if (ev->type == AC_EVENT_DRAW) {
7339 if (rt->nopaint_state == 1) {
7340 rt->nopaint_brush_x = ev->x;
7341 rt->nopaint_brush_y = ev->y;
7342 }
7343 } else if (ev->type == AC_EVENT_LIFT) {
7344 if (rt->nopaint_state == 1) {
7345 rt->nopaint_state = 0; // idle
7346 rt->nopaint_needs_bake = 1;
7347 }
7348 }
7349 }
7350 }
7351
7352 if (!JS_IsFunction(rt->ctx, rt->act_fn)) return;
7353
7354 for (int i = 0; i < input->event_count; i++) {
7355 JSValue api = build_api(rt->ctx, rt, "act");
7356 JSValue event = make_event_object(rt->ctx, &input->events[i]);
7357 JS_SetPropertyStr(rt->ctx, api, "event", event);
7358
7359 JSValue result = JS_Call(rt->ctx, rt->act_fn, JS_UNDEFINED, 1, &api);
7360 if (JS_IsException(result)) {
7361 JSValue exc = JS_GetException(rt->ctx);
7362 const char *str = JS_ToCString(rt->ctx, exc);
7363 JSValue stack = JS_GetPropertyStr(rt->ctx, exc, "stack");
7364 const char *stack_str = JS_ToCString(rt->ctx, stack);
7365 fprintf(stderr, "[js] act() error: %s\n%s\n", str, stack_str ? stack_str : "");
7366 ac_log("[js] act() error: %s\n%s\n", str, stack_str ? stack_str : "");
7367 js_record_crash(rt, "act", str);
7368 if (stack_str) JS_FreeCString(rt->ctx, stack_str);
7369 JS_FreeValue(rt->ctx, stack);
7370 JS_FreeCString(rt->ctx, str);
7371 JS_FreeValue(rt->ctx, exc);
7372 }
7373 JS_FreeValue(rt->ctx, result);
7374 JS_FreeValue(rt->ctx, api);
7375 }
7376}
7377
7378void js_call_sim(ACRuntime *rt) {
7379 current_rt = rt;
7380
7381 // If the piece's FPS bundle installed a CamDoll (via the export
7382 // shim in js_load_piece), the doll's own sim() — invoked from the
7383 // wrapped piece sim() — is the camera authority. We just sync its
7384 // cam state back to rt->camera3d AFTER the piece sim runs (below).
7385 // Otherwise, fall back to the native WASD + trackpad driver.
7386 int have_doll = 0;
7387 {
7388 JSValue global = JS_GetGlobalObject(rt->ctx);
7389 JSValue doll = JS_GetPropertyStr(rt->ctx, global, "__fpsDoll");
7390 have_doll = !JS_IsUndefined(doll) && !JS_IsNull(doll);
7391 JS_FreeValue(rt->ctx, doll);
7392 JS_FreeValue(rt->ctx, global);
7393 }
7394 if (!have_doll && rt->pen_locked && rt->fps_system_active) {
7395 camera3d_update(&rt->camera3d,
7396 rt->keys_held[KEY_W],
7397 rt->keys_held[KEY_S],
7398 rt->keys_held[KEY_A],
7399 rt->keys_held[KEY_D],
7400 rt->keys_held[KEY_SPACE],
7401 rt->keys_held[KEY_LEFTSHIFT],
7402 (float)(rt->input ? rt->input->delta_x : 0),
7403 (float)(rt->input ? rt->input->delta_y : 0));
7404 // Consume trackpad delta
7405 if (rt->input) { rt->input->delta_x = 0; rt->input->delta_y = 0; }
7406 }
7407
7408 if (!JS_IsFunction(rt->ctx, rt->sim_fn)) return;
7409 rt->sim_count++;
7410 JSValue api = build_api(rt->ctx, rt, "sim");
7411 JSValue result = JS_Call(rt->ctx, rt->sim_fn, JS_UNDEFINED, 1, &api);
7412 if (JS_IsException(result)) {
7413 JSValue exc = JS_GetException(rt->ctx);
7414 const char *str = JS_ToCString(rt->ctx, exc);
7415 JSValue stack = JS_GetPropertyStr(rt->ctx, exc, "stack");
7416 const char *stack_str = JS_ToCString(rt->ctx, stack);
7417 fprintf(stderr, "[js] sim() error: %s\n%s\n", str, stack_str ? stack_str : "");
7418 js_record_crash(rt, "sim", str);
7419 if (stack_str) JS_FreeCString(rt->ctx, stack_str);
7420 JS_FreeValue(rt->ctx, stack);
7421 JS_FreeCString(rt->ctx, str);
7422 JS_FreeValue(rt->ctx, exc);
7423 }
7424 JS_FreeValue(rt->ctx, result);
7425 JS_FreeValue(rt->ctx, api);
7426
7427 // FPS camera sync — after the wrapped piece sim() runs (which
7428 // invoked doll.sim() via the shim), copy doll.cam.{x,y,z,rotX/Y/Z}
7429 // back into rt->camera3d so the next frame's graph3d rendering
7430 // uses the doll's camera. Only syncs when the doll is present.
7431 if (have_doll) {
7432 JSValue global = JS_GetGlobalObject(rt->ctx);
7433 JSValue doll = JS_GetPropertyStr(rt->ctx, global, "__fpsDoll");
7434 if (!JS_IsUndefined(doll) && !JS_IsNull(doll)) {
7435 JSValue cam = JS_GetPropertyStr(rt->ctx, doll, "cam");
7436 if (!JS_IsUndefined(cam) && !JS_IsNull(cam)) {
7437 double x, y, z, rx, ry, rz;
7438 JSValue v;
7439 v = JS_GetPropertyStr(rt->ctx, cam, "x");
7440 if (JS_ToFloat64(rt->ctx, &x, v) == 0) rt->camera3d.x = (float)x;
7441 JS_FreeValue(rt->ctx, v);
7442 v = JS_GetPropertyStr(rt->ctx, cam, "y");
7443 if (JS_ToFloat64(rt->ctx, &y, v) == 0) rt->camera3d.y = (float)y;
7444 JS_FreeValue(rt->ctx, v);
7445 v = JS_GetPropertyStr(rt->ctx, cam, "z");
7446 if (JS_ToFloat64(rt->ctx, &z, v) == 0) rt->camera3d.z = (float)z;
7447 JS_FreeValue(rt->ctx, v);
7448 v = JS_GetPropertyStr(rt->ctx, cam, "rotX");
7449 if (JS_ToFloat64(rt->ctx, &rx, v) == 0) rt->camera3d.rotX = (float)rx;
7450 JS_FreeValue(rt->ctx, v);
7451 v = JS_GetPropertyStr(rt->ctx, cam, "rotY");
7452 if (JS_ToFloat64(rt->ctx, &ry, v) == 0) rt->camera3d.rotY = (float)ry;
7453 JS_FreeValue(rt->ctx, v);
7454 v = JS_GetPropertyStr(rt->ctx, cam, "rotZ");
7455 if (JS_ToFloat64(rt->ctx, &rz, v) == 0) rt->camera3d.rotZ = (float)rz;
7456 JS_FreeValue(rt->ctx, v);
7457 }
7458 JS_FreeValue(rt->ctx, cam);
7459 }
7460 JS_FreeValue(rt->ctx, doll);
7461 JS_FreeValue(rt->ctx, global);
7462 }
7463
7464 // Execute pending jobs (promises)
7465 JSContext *pctx;
7466 while (JS_ExecutePendingJob(rt->rt, &pctx) > 0) {}
7467}
7468
7469void js_call_beat(ACRuntime *rt) {
7470 if (!JS_IsFunction(rt->ctx, rt->beat_fn)) return;
7471 current_rt = rt;
7472 JSValue api = build_api(rt->ctx, rt, "beat");
7473 JSValue result = JS_Call(rt->ctx, rt->beat_fn, JS_UNDEFINED, 1, &api);
7474 if (JS_IsException(result)) {
7475 JSValue exc = JS_GetException(rt->ctx);
7476 const char *str = JS_ToCString(rt->ctx, exc);
7477 fprintf(stderr, "[js] beat() error: %s\n", str);
7478 JS_FreeCString(rt->ctx, str);
7479 JS_FreeValue(rt->ctx, exc);
7480 }
7481 JS_FreeValue(rt->ctx, result);
7482 JS_FreeValue(rt->ctx, api);
7483}
7484
7485void js_call_leave(ACRuntime *rt) {
7486 if (!JS_IsFunction(rt->ctx, rt->leave_fn)) return;
7487 current_rt = rt;
7488 JSValue result = JS_Call(rt->ctx, rt->leave_fn, JS_UNDEFINED, 0, NULL);
7489 JS_FreeValue(rt->ctx, result);
7490}
7491
7492void js_destroy(ACRuntime *rt) {
7493 if (!rt) return;
7494 JS_FreeValue(rt->ctx, rt->boot_fn);
7495 JS_FreeValue(rt->ctx, rt->paint_fn);
7496 JS_FreeValue(rt->ctx, rt->act_fn);
7497 JS_FreeValue(rt->ctx, rt->sim_fn);
7498 JS_FreeValue(rt->ctx, rt->leave_fn);
7499 JS_FreeValue(rt->ctx, rt->beat_fn);
7500 ws_destroy(rt->ws);
7501 udp_destroy(rt->udp);
7502 depth_destroy(rt->depth_buf);
7503 JS_FreeContext(rt->ctx);
7504 JS_FreeRuntime(rt->rt);
7505 free(rt);
7506}