@genex-ai/cli-demo 1.36.5-dev.858 → 1.36.6-dev.861

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2149,6 +2149,401 @@ async function countOutcomes(kind, cwd = process.cwd()) {
2149
2149
  import fs13 from "fs/promises";
2150
2150
  import path14 from "path";
2151
2151
 
2152
+ // ../../packages/mobile-scan/src/image-dims.ts
2153
+ function u32be(b, o) {
2154
+ return (b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]) >>> 0;
2155
+ }
2156
+ function u32le(b, o) {
2157
+ return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
2158
+ }
2159
+ function u16be(b, o) {
2160
+ return b[o] << 8 | b[o + 1];
2161
+ }
2162
+ function u16le(b, o) {
2163
+ return b[o] | b[o + 1] << 8;
2164
+ }
2165
+ function isPng(b) {
2166
+ return b.length > 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71 && b[4] === 13 && b[5] === 10 && b[6] === 26 && b[7] === 10;
2167
+ }
2168
+ var KTX2_MAGIC = [171, 75, 84, 88, 32, 50, 48, 187, 13, 10, 26, 10];
2169
+ function parseImageDims(bytes) {
2170
+ if (bytes.length < 30) return null;
2171
+ if (isPng(bytes)) {
2172
+ return { width: u32be(bytes, 16), height: u32be(bytes, 20) };
2173
+ }
2174
+ if (bytes[0] === 255 && bytes[1] === 216) {
2175
+ let i = 2;
2176
+ while (i + 9 < bytes.length) {
2177
+ if (bytes[i] !== 255) {
2178
+ i++;
2179
+ continue;
2180
+ }
2181
+ const marker = bytes[i + 1];
2182
+ if (marker === 255) {
2183
+ i++;
2184
+ continue;
2185
+ }
2186
+ if (marker === 216 || marker >= 208 && marker <= 217) {
2187
+ i += 2;
2188
+ continue;
2189
+ }
2190
+ const isSof = marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207;
2191
+ if (isSof) {
2192
+ return { width: u16be(bytes, i + 7), height: u16be(bytes, i + 5) };
2193
+ }
2194
+ i += 2 + u16be(bytes, i + 2);
2195
+ }
2196
+ return null;
2197
+ }
2198
+ if (bytes[0] === 82 && // RIFF….WEBP
2199
+ bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) {
2200
+ const fourcc = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
2201
+ if (fourcc === "VP8X" && bytes.length >= 30) {
2202
+ const w = 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16);
2203
+ const h = 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16);
2204
+ return { width: w, height: h };
2205
+ }
2206
+ if (fourcc === "VP8 " && bytes.length >= 30) {
2207
+ return { width: u16le(bytes, 26) & 16383, height: u16le(bytes, 28) & 16383 };
2208
+ }
2209
+ if (fourcc === "VP8L" && bytes.length >= 25 && bytes[20] === 47) {
2210
+ const b0 = bytes[21];
2211
+ const b1 = bytes[22];
2212
+ const b2 = bytes[23];
2213
+ const b3 = bytes[24];
2214
+ const width = 1 + ((b1 & 63) << 8 | b0);
2215
+ const height = 1 + ((b3 & 15) << 10 | b2 << 2 | (b1 & 192) >> 6);
2216
+ return { width, height };
2217
+ }
2218
+ return null;
2219
+ }
2220
+ if (KTX2_MAGIC.every((v, idx) => bytes[idx] === v)) {
2221
+ return { width: u32le(bytes, 20), height: u32le(bytes, 24), ktx2: true };
2222
+ }
2223
+ return null;
2224
+ }
2225
+
2226
+ // ../../packages/mobile-scan/src/glb.ts
2227
+ function u32le2(b, o) {
2228
+ return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
2229
+ }
2230
+ function scanGlb(bytes) {
2231
+ if (bytes.length < 20 || u32le2(bytes, 0) !== 1179937895) return null;
2232
+ try {
2233
+ const jsonLen = u32le2(bytes, 12);
2234
+ if (u32le2(bytes, 16) !== 1313821514) return null;
2235
+ const json = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLen)));
2236
+ let bin = null;
2237
+ const binHeader = 20 + jsonLen;
2238
+ if (binHeader + 8 <= bytes.length && u32le2(bytes, binHeader + 4) === 5130562) {
2239
+ bin = bytes.subarray(binHeader + 8, binHeader + 8 + u32le2(bytes, binHeader));
2240
+ }
2241
+ const images = [];
2242
+ for (const img of json.images ?? []) {
2243
+ if (img.bufferView == null || !bin) continue;
2244
+ const view = json.bufferViews?.[img.bufferView];
2245
+ if (!view?.byteLength) continue;
2246
+ const slice = bin.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength);
2247
+ const dims = parseImageDims(slice);
2248
+ if (dims) images.push({ ...dims, mimeType: img.mimeType, bytes: view.byteLength });
2249
+ }
2250
+ let triangles = 0;
2251
+ for (const mesh of json.meshes ?? []) {
2252
+ for (const prim of mesh.primitives ?? []) {
2253
+ const accessor = prim.indices != null ? json.accessors?.[prim.indices] : json.accessors?.[prim.attributes?.POSITION ?? -1];
2254
+ if (accessor?.count) triangles += Math.floor(accessor.count / 3);
2255
+ }
2256
+ }
2257
+ return {
2258
+ triangles,
2259
+ binBytes: bin?.byteLength ?? 0,
2260
+ images,
2261
+ extensions: json.extensionsUsed ?? []
2262
+ };
2263
+ } catch {
2264
+ return null;
2265
+ }
2266
+ }
2267
+
2268
+ // ../../packages/mobile-scan/src/scan.ts
2269
+ var GENEX_GENERATION_URL_RE = /https:\/\/[A-Za-z0-9.-]+\/generations\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._@-]+)/g;
2270
+ var SKYBOX_ROLE = "skybox-equirect";
2271
+ var MOBILE_RUNG_SUFFIX_RE = /@(\d+)$/;
2272
+ var MOBILE_BUDGET_MB = { broad: 300, modern: 700 };
2273
+ var KNOWN_CAPABILITIES = ["microphone", "camera", "geolocation"];
2274
+ var CAPABILITY_LITERALS = {
2275
+ microphone: ["__GENEX_VOICE__", "__GENEX_CAP_MICROPHONE__"],
2276
+ camera: ["__GENEX_CAP_CAMERA__"],
2277
+ geolocation: ["__GENEX_CAP_GEOLOCATION__"]
2278
+ };
2279
+ var CONSTRAINTS_MAX_CHARS = 4e3;
2280
+ var MEDIA_CALL = "getUserMedia";
2281
+ var KEY_START = /[A-Za-z_$]/;
2282
+ var KEY_PART = /[\w$]/;
2283
+ var SPACE = /\s/;
2284
+ function mediaRequestsInText(text) {
2285
+ const out = [];
2286
+ let from = 0;
2287
+ for (let at = text.indexOf(MEDIA_CALL); at !== -1; at = text.indexOf(MEDIA_CALL, from)) {
2288
+ from = at + MEDIA_CALL.length;
2289
+ if (at > 0 && KEY_PART.test(text[at - 1])) continue;
2290
+ const paren = skipSpace(text, from, text.length);
2291
+ if (text[paren] !== "(") continue;
2292
+ const open = skipSpace(text, paren + 1, text.length);
2293
+ if (text[open] !== "{") {
2294
+ out.push({ index: at, audio: false, video: false, read: false });
2295
+ continue;
2296
+ }
2297
+ const { stop: stop2, ...constraints } = readConstraints(text, open);
2298
+ out.push({ index: at, ...constraints });
2299
+ from = Math.max(from, stop2);
2300
+ }
2301
+ return out;
2302
+ }
2303
+ function skipSpace(text, from, end) {
2304
+ let at = from;
2305
+ while (at < end && SPACE.test(text[at])) at++;
2306
+ return at;
2307
+ }
2308
+ function indexWithin(text, needle, from, end) {
2309
+ for (let at = from; at + needle.length <= end; at++) {
2310
+ if (text.startsWith(needle, at)) return at;
2311
+ }
2312
+ return -1;
2313
+ }
2314
+ function readConstraints(text, open) {
2315
+ const end = Math.min(text.length, open + 1 + CONSTRAINTS_MAX_CHARS);
2316
+ const seen = { audio: false, video: false, read: true };
2317
+ const afterKey = (key, at) => {
2318
+ const colon = skipSpace(text, at, end);
2319
+ if (text[colon] !== ":") {
2320
+ seen.read = false;
2321
+ return;
2322
+ }
2323
+ if (key !== "audio" && key !== "video") return;
2324
+ const value = skipSpace(text, colon + 1, end);
2325
+ if (text[value] === "{" || text.startsWith("!0", value) || /^true\b/.test(text.slice(value, value + 5))) {
2326
+ seen[key] = true;
2327
+ } else if (!text.startsWith("!1", value) && !/^false\b/.test(text.slice(value, value + 6))) {
2328
+ seen.read = false;
2329
+ }
2330
+ };
2331
+ let depth = 1;
2332
+ let keyNext = true;
2333
+ for (let i = open + 1; i < end; i++) {
2334
+ const ch = text[i];
2335
+ if (ch === '"' || ch === "'" || ch === "`") {
2336
+ let close = i + 1;
2337
+ while (close < end && text[close] !== ch) close += text[close] === "\\" ? 2 : 1;
2338
+ if (close >= end) break;
2339
+ if (depth === 1 && keyNext) {
2340
+ keyNext = false;
2341
+ afterKey(text.slice(i + 1, close), close + 1);
2342
+ }
2343
+ i = close;
2344
+ } else if (ch === "/" && (text[i + 1] === "/" || text[i + 1] === "*")) {
2345
+ const line = text[i + 1] === "/";
2346
+ const close = indexWithin(text, line ? "\n" : "*/", i + 2, end);
2347
+ if (close === -1) break;
2348
+ i = line ? close : close + 1;
2349
+ } else if (ch === "{" || ch === "[" || ch === "(") {
2350
+ depth++;
2351
+ } else if (ch === "}" || ch === "]" || ch === ")") {
2352
+ if (--depth === 0) return { ...seen, stop: i + 1 };
2353
+ } else if (depth === 1 && ch === ",") {
2354
+ keyNext = true;
2355
+ } else if (depth === 1 && keyNext && !SPACE.test(ch)) {
2356
+ keyNext = false;
2357
+ if (!KEY_START.test(ch)) {
2358
+ seen.read = false;
2359
+ continue;
2360
+ }
2361
+ let stop2 = i + 1;
2362
+ while (stop2 < end && KEY_PART.test(text[stop2])) stop2++;
2363
+ afterKey(text.slice(i, stop2), stop2);
2364
+ i = stop2 - 1;
2365
+ }
2366
+ }
2367
+ return { ...seen, read: false, stop: end };
2368
+ }
2369
+ var CAPABILITY_PATTERNS = {
2370
+ camera: [/\bfacingMode\b/],
2371
+ geolocation: [
2372
+ /\bnavigator\??\.geolocation\b/,
2373
+ /\.geolocation\??\.(?:getCurrentPosition|watchPosition)\b/
2374
+ ]
2375
+ };
2376
+ function capabilitiesInText(text) {
2377
+ const opensCamera = mediaRequestsInText(text).some((request) => request.video);
2378
+ return KNOWN_CAPABILITIES.filter(
2379
+ (capability) => CAPABILITY_LITERALS[capability].some((literal) => text.includes(literal)) || (CAPABILITY_PATTERNS[capability] ?? []).some((pattern) => pattern.test(text)) || capability === "camera" && opensCamera
2380
+ );
2381
+ }
2382
+ function knownCapabilities(input) {
2383
+ if (!Array.isArray(input)) return [];
2384
+ return KNOWN_CAPABILITIES.filter((capability) => input.includes(capability));
2385
+ }
2386
+ var TEXT_EXT = /\.(js|mjs|cjs|html|css|json|txt)$/i;
2387
+ var CAPABILITY_FILE_RE = /\.(js|mjs|cjs|html)$/i;
2388
+ var TEXT_HEAD_BYTES = 8 * 1024 * 1024;
2389
+ var CAPABILITY_WINDOW_BYTES = TEXT_HEAD_BYTES;
2390
+ var CAPABILITY_OVERLAP_BYTES = 64 * 1024;
2391
+ var IMAGE_EXT = /\.(png|jpe?g|webp|ktx2)$/i;
2392
+ var VIDEO_EXT = /\.(mp4|webm|mov)$/i;
2393
+ var AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
2394
+ var GLB_EXT = /\.(glb|gltf)$/i;
2395
+ function scanBundle(files) {
2396
+ const images = [];
2397
+ const glbs = [];
2398
+ const videos = [];
2399
+ const audio = [];
2400
+ const urls = /* @__PURE__ */ new Set();
2401
+ const declared = /* @__PURE__ */ new Set();
2402
+ let capabilitiesIncomplete = false;
2403
+ const decoder = new TextDecoder();
2404
+ let text = "";
2405
+ let totalBytes = 0;
2406
+ for (const f of files) {
2407
+ totalBytes += f.bytes.byteLength;
2408
+ if (IMAGE_EXT.test(f.relPath)) {
2409
+ const dims = parseImageDims(f.bytes);
2410
+ if (dims) images.push({ path: f.relPath, bytes: f.bytes.byteLength, ...dims });
2411
+ continue;
2412
+ }
2413
+ if (GLB_EXT.test(f.relPath)) {
2414
+ glbs.push({ path: f.relPath, bytes: f.bytes.byteLength, scan: scanGlb(f.bytes) });
2415
+ continue;
2416
+ }
2417
+ if (VIDEO_EXT.test(f.relPath)) {
2418
+ videos.push({ path: f.relPath, bytes: f.bytes.byteLength });
2419
+ continue;
2420
+ }
2421
+ if (AUDIO_EXT.test(f.relPath)) {
2422
+ audio.push({ path: f.relPath, bytes: f.bytes.byteLength });
2423
+ continue;
2424
+ }
2425
+ if (TEXT_EXT.test(f.relPath)) {
2426
+ const head = decoder.decode(f.bytes.subarray(0, TEXT_HEAD_BYTES));
2427
+ text += head;
2428
+ text += "\n";
2429
+ if (!CAPABILITY_FILE_RE.test(f.relPath)) continue;
2430
+ try {
2431
+ for (const capability of capabilitiesInText(head)) declared.add(capability);
2432
+ for (let start = CAPABILITY_WINDOW_BYTES - CAPABILITY_OVERLAP_BYTES; start < f.bytes.byteLength - CAPABILITY_OVERLAP_BYTES && declared.size < KNOWN_CAPABILITIES.length; start += CAPABILITY_WINDOW_BYTES) {
2433
+ const window = f.bytes.subarray(start, start + CAPABILITY_WINDOW_BYTES + CAPABILITY_OVERLAP_BYTES);
2434
+ for (const capability of capabilitiesInText(decoder.decode(window))) declared.add(capability);
2435
+ }
2436
+ } catch {
2437
+ capabilitiesIncomplete = true;
2438
+ }
2439
+ }
2440
+ }
2441
+ for (const m of text.matchAll(GENEX_GENERATION_URL_RE)) urls.add(m[0]);
2442
+ const capabilities = KNOWN_CAPABILITIES.filter((capability) => declared.has(capability));
2443
+ const dprMatch = text.match(/setPixelRatio\(\s*Math\.min\([^,)]+,\s*([\d.]+)\s*\)/);
2444
+ const markers = {
2445
+ rendererBackend: text.includes("WebGPURenderer") || text.includes("getContext('webgpu')") || text.includes('"webgpu"') ? "webgpu" : "webgl",
2446
+ postStack: /EffectComposer|postprocessing|PostProcessing|RenderPipeline/.test(text),
2447
+ dprCapLiteral: dprMatch?.[1] ? Number(dprMatch[1]) : null,
2448
+ contextLossHandler: text.includes("webglcontextlost") || text.includes("device.lost"),
2449
+ ktx2Loader: text.includes("KTX2Loader") || text.includes(".ktx2"),
2450
+ touchHints: /joystick/i.test(text) || text.includes("touchstart") && text.includes("touch-action"),
2451
+ adaptiveQuality: text.includes("__GENEX_QUALITY__"),
2452
+ modelRungs: text.includes("__GENEX_MODEL_RUNGS__"),
2453
+ voice: capabilities.includes("microphone")
2454
+ };
2455
+ return {
2456
+ images,
2457
+ glbs,
2458
+ videos,
2459
+ audio,
2460
+ genexAssetUrls: [...urls],
2461
+ markers,
2462
+ capabilities,
2463
+ capabilitiesIncomplete,
2464
+ totalBytes,
2465
+ fileCount: files.length
2466
+ };
2467
+ }
2468
+
2469
+ // ../../packages/mobile-scan/src/estimate.ts
2470
+ var MB = 1024 * 1024;
2471
+ var CSS_W = 430;
2472
+ var CSS_H = 932;
2473
+ var WORST_SKYBOX_BYTES = 8192 * 4096 * 4 * 1.33;
2474
+ var WORST_IMAGE_BYTES = 2048 * 2048 * 4 * 1.33;
2475
+ var KTX2_DISCOUNT = 6;
2476
+ function textureBytes(w, h, ktx2) {
2477
+ const raw = w * h * 4 * 1.33;
2478
+ return ktx2 ? raw / KTX2_DISCOUNT : raw;
2479
+ }
2480
+ function estimateVram(scan, externalDims) {
2481
+ let bundled = 0;
2482
+ let maxDim = 0;
2483
+ for (const img of scan.images) {
2484
+ bundled += textureBytes(img.width, img.height, img.ktx2);
2485
+ maxDim = Math.max(maxDim, img.width, img.height);
2486
+ }
2487
+ let glbTex = 0;
2488
+ let geometry = 0;
2489
+ for (const glb of scan.glbs) {
2490
+ if (!glb.scan) {
2491
+ geometry += glb.bytes;
2492
+ continue;
2493
+ }
2494
+ const ktx2 = glb.scan.extensions.some((e) => e.includes("KHR_texture_basisu"));
2495
+ for (const img of glb.scan.images) {
2496
+ glbTex += textureBytes(img.width, img.height, img.ktx2 || ktx2);
2497
+ maxDim = Math.max(maxDim, img.width, img.height);
2498
+ }
2499
+ geometry += Math.max(0, glb.scan.binBytes - glb.scan.images.reduce((s, i) => s + i.bytes, 0));
2500
+ }
2501
+ let external = 0;
2502
+ const unresolved = [];
2503
+ for (const url of scan.genexAssetUrls) {
2504
+ const role = url.split("/").pop() ?? "";
2505
+ if (MOBILE_RUNG_SUFFIX_RE.test(role)) continue;
2506
+ const dims = externalDims.get(url);
2507
+ if (dims) {
2508
+ const ktx2 = dims.ktx2 || dims.ktx2Available === true && scan.markers.ktx2Loader;
2509
+ if (dims.glb) {
2510
+ external += textureBytes(dims.width, dims.height, ktx2) + (dims.bytes ?? 0);
2511
+ maxDim = Math.max(maxDim, dims.width);
2512
+ } else {
2513
+ external += textureBytes(dims.width, dims.height, ktx2);
2514
+ maxDim = Math.max(maxDim, dims.width, dims.height);
2515
+ }
2516
+ } else {
2517
+ unresolved.push(url);
2518
+ external += role === SKYBOX_ROLE ? WORST_SKYBOX_BYTES : WORST_IMAGE_BYTES;
2519
+ maxDim = Math.max(maxDim, role === SKYBOX_ROLE ? 8192 : 2048);
2520
+ }
2521
+ }
2522
+ const dpr = Math.min(scan.markers.dprCapLiteral ?? 2, 3);
2523
+ const surface = CSS_W * CSS_H * dpr * dpr * 4;
2524
+ const framebuffers = surface * (3 + (scan.markers.postStack ? 4 : 0));
2525
+ const shadow = 2048 * 2048 * 4;
2526
+ const total = bundled + glbTex + external + framebuffers + shadow + geometry;
2527
+ return {
2528
+ estVramMb: Math.round(total / MB),
2529
+ breakdown: {
2530
+ bundledTexturesMb: Math.round(bundled / MB),
2531
+ glbTexturesMb: Math.round(glbTex / MB),
2532
+ externalTexturesMb: Math.round(external / MB),
2533
+ framebuffersMb: Math.round(framebuffers / MB),
2534
+ shadowDefaultMb: Math.round(shadow / MB),
2535
+ geometryMb: Math.round(geometry / MB)
2536
+ },
2537
+ unresolvedExternals: unresolved,
2538
+ textureMaxDim: maxDim
2539
+ };
2540
+ }
2541
+ function tierFor(estVramMb) {
2542
+ if (estVramMb < MOBILE_BUDGET_MB.broad) return "mobile-broad";
2543
+ if (estVramMb < MOBILE_BUDGET_MB.modern) return "mobile-modern";
2544
+ return "over-budget";
2545
+ }
2546
+
2152
2547
  // src/lib/download-assets.ts
2153
2548
  import fs12 from "fs/promises";
2154
2549
  import path13 from "path";
@@ -2345,7 +2740,54 @@ function splitArgs(text) {
2345
2740
  }
2346
2741
  var lineOf = (content, index) => content.slice(0, index).split("\n").length;
2347
2742
  function blankComments(raw) {
2348
- return raw.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " ")).replace(/(^|[^:])\/\/[^\n]*/gm, (m, p1) => p1 + " ".repeat(m.length - p1.length));
2743
+ const out = [];
2744
+ let kept = 0;
2745
+ const blank = (from, to) => {
2746
+ out.push(raw.slice(kept, from), raw.slice(from, to).replace(/[^\n]/g, " "));
2747
+ kept = to;
2748
+ };
2749
+ const templates = [];
2750
+ let depth = 0;
2751
+ let i = 0;
2752
+ while (i < raw.length) {
2753
+ const ch = raw[i];
2754
+ if (ch === '"' || ch === "'") {
2755
+ i++;
2756
+ while (i < raw.length && raw[i] !== ch && raw[i] !== "\n") i += raw[i] === "\\" ? 2 : 1;
2757
+ i++;
2758
+ } else if (ch === "`" || ch === "}" && templates.at(-1) === depth) {
2759
+ if (ch === "}") templates.pop();
2760
+ i++;
2761
+ while (i < raw.length && raw[i] !== "`" && !(raw[i] === "$" && raw[i + 1] === "{")) {
2762
+ i += raw[i] === "\\" ? 2 : 1;
2763
+ }
2764
+ if (raw[i] === "$") {
2765
+ templates.push(depth);
2766
+ i += 2;
2767
+ } else {
2768
+ i++;
2769
+ }
2770
+ } else if (ch === "{") {
2771
+ depth++;
2772
+ i++;
2773
+ } else if (ch === "}") {
2774
+ depth--;
2775
+ i++;
2776
+ } else if (ch === "/" && raw[i + 1] === "*" && raw.indexOf("*/", i + 2) !== -1) {
2777
+ const end = raw.indexOf("*/", i + 2) + 2;
2778
+ blank(i, end);
2779
+ i = end;
2780
+ } else if (ch === "/" && raw[i + 1] === "/" && raw[i - 1] !== ":") {
2781
+ const eol = raw.indexOf("\n", i);
2782
+ const end = eol === -1 ? raw.length : eol;
2783
+ blank(i, end);
2784
+ i = end;
2785
+ } else {
2786
+ i++;
2787
+ }
2788
+ }
2789
+ out.push(raw.slice(kept));
2790
+ return out.join("");
2349
2791
  }
2350
2792
  var IDENTITY_AWAIT = /\bawait\s+(waitForPlayer|waitForAuth)\s*\(/;
2351
2793
  var RENDERER_CTOR = /\bnew\s+(?:THREE\.)?(?:WebGLRenderer|WebGPURenderer)\s*\(/;
@@ -2406,8 +2848,10 @@ async function detectEmbedBoot(cwd = process.cwd()) {
2406
2848
  }
2407
2849
  var CONTROL_HEADS = /* @__PURE__ */ new Set(["if", "for", "while", "switch", "catch", "with", "await"]);
2408
2850
  function isFunctionBrace(tail) {
2409
- const t = tail.trimEnd();
2851
+ let t = tail.trimEnd();
2410
2852
  if (t.endsWith("=>")) return true;
2853
+ const typed = /^([\s\S]*\))[ \t]*:[ \t]*[^\s;=?()][^;=?()\n]*$/.exec(t);
2854
+ if (typed) t = typed[1];
2411
2855
  if (!t.endsWith(")")) return false;
2412
2856
  let depth = 0;
2413
2857
  let i = t.length - 1;
@@ -2618,6 +3062,199 @@ async function detectGenerationAudit(cwd = process.cwd()) {
2618
3062
  musicNoSlider: audited.some((e) => e.kind === "music" && e.status === "completed") && !/type\s*=\s*["'`]range["'`]/.test(haystack)
2619
3063
  };
2620
3064
  }
3065
+ async function readOwnSource(cwd) {
3066
+ const out = [];
3067
+ const page = await fs13.readFile(path14.join(cwd, "index.html"), "utf8").catch(() => "");
3068
+ if (page) {
3069
+ out.push({
3070
+ rel: "index.html",
3071
+ content: page.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "))
3072
+ });
3073
+ }
3074
+ const srcDir = path14.join(cwd, "src");
3075
+ let entries;
3076
+ try {
3077
+ entries = await fs13.readdir(srcDir, { recursive: true });
3078
+ } catch {
3079
+ return out;
3080
+ }
3081
+ for (const nativeRel of entries.sort()) {
3082
+ if (nativeRel.includes("node_modules")) continue;
3083
+ if (nativeRel.split(path14.sep)[0] === "controllers") continue;
3084
+ if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(nativeRel)) continue;
3085
+ const raw = await fs13.readFile(path14.join(srcDir, nativeRel), "utf8").catch(() => "");
3086
+ if (!raw) continue;
3087
+ out.push({ rel: nativeRel.split(path14.sep).join("/"), content: blankComments(raw) });
3088
+ }
3089
+ return out;
3090
+ }
3091
+ async function readManifestCapabilities(cwd) {
3092
+ try {
3093
+ const pkg = JSON.parse(await fs13.readFile(path14.join(cwd, "package.json"), "utf8"));
3094
+ const raw = pkg.genex?.capabilities;
3095
+ if (raw === void 0 || raw === null) return { entries: [], notAList: false };
3096
+ return Array.isArray(raw) ? { entries: raw, notAList: false } : { entries: [], notAList: true };
3097
+ } catch {
3098
+ return { entries: [], notAList: false };
3099
+ }
3100
+ }
3101
+ var SOURCE_MICROPHONE = [
3102
+ /\.voice\s*\??\.\s*join\s*\(\s*(?:\)|\{)/,
3103
+ /\b(?:webkit)?SpeechRecognition\b/
3104
+ ];
3105
+ function opensMicrophone(content) {
3106
+ return SOURCE_MICROPHONE.some((evidence) => evidence.test(content)) || mediaRequestsInText(content).some((request) => request.audio);
3107
+ }
3108
+ async function detectCapabilities(cwd = process.cwd()) {
3109
+ const found = [...(await readManifestCapabilities(cwd)).entries];
3110
+ for (const { content } of await readOwnSource(cwd)) {
3111
+ found.push(...capabilitiesInText(content));
3112
+ if (opensMicrophone(content)) found.push("microphone");
3113
+ }
3114
+ return knownCapabilities(found);
3115
+ }
3116
+ var SHARE_CALL = /\bnavigator\s*\??\.\s*share\s*\(/g;
3117
+ var CLIPBOARD_USE = /\bnavigator\s*\??\.\s*clipboard\b/g;
3118
+ var WAKE_LOCK_USE = /\bnavigator\s*\??\.\s*wakeLock\b/g;
3119
+ var NEW_WINDOW = /\bwindow\s*\??\.\s*open\s*\(|["'`]_blank["'`]/g;
3120
+ var DOWNLOAD_ASSIGN = /\.download\s*=(?!=)/g;
3121
+ var DOWNLOAD_ATTR = /<a\b[^>]*\sdownload\b|\bsetAttribute\(\s*["'`]download["'`]/g;
3122
+ var MAKES_A_LINK = /\bcreateElement\(\s*["'`]a["'`]\s*\)|\bcreateObjectURL\b|\btoDataURL\b|\btoBlob\b|<a\b/;
3123
+ var SERVICE_WORKER = /\bserviceWorker\s*\??\.\s*register\s*\(/g;
3124
+ var MOTION_USE = /\b(?:DeviceOrientationEvent|DeviceMotionEvent)\b|["'`]device(?:orientation(?:absolute)?|motion)["'`]|\bondevice(?:orientation(?:absolute)?|motion)\b/g;
3125
+ var HASH_READ = /(?<![\w$.])(?:(?:window|document|globalThis|self)\s*\.\s*)?location\s*\.\s*hash\b(?!\s*(?:=(?!=)|\+=))/g;
3126
+ var DIALOG_CALL = /(?<![\w$.])((?:window|globalThis|self)\s*\.\s*)?(alert|confirm|prompt)\(/g;
3127
+ var MEMBER_HEAD = /^(?:(?:export|declare|abstract|async|public|private|protected|static|readonly|override|get|set)\s+|\*\s*)*$/;
3128
+ function afterCloseParen(content, from) {
3129
+ let depth = 1;
3130
+ let i = from;
3131
+ while (i < content.length && depth > 0) {
3132
+ const ch = content[i];
3133
+ if (ch === "(") depth++;
3134
+ else if (ch === ")") depth--;
3135
+ i++;
3136
+ }
3137
+ return i;
3138
+ }
3139
+ function dialogCalls(content) {
3140
+ const out = [];
3141
+ for (const m of content.matchAll(DIALOG_CALL)) {
3142
+ const index = m.index;
3143
+ const name = m[2];
3144
+ if (!m[1]) {
3145
+ const own = new RegExp(
3146
+ [
3147
+ `\\b(?:function\\*?|const|let|var|class)\\s+${name}\\b`,
3148
+ `\\bimport\\s+${name}\\s+from\\b`,
3149
+ // `{ prompt, toast }`, `(text, confirm)`, `(confirm: () => void)`,
3150
+ // `{ prompt: text }`: a named import, a destructuring, a parameter.
3151
+ `[{,(]\\s*${name}\\s*(?:[,}):]|=(?!=))`
3152
+ ].join("|")
3153
+ );
3154
+ if (own.test(content)) continue;
3155
+ const head = content.slice(content.lastIndexOf("\n", index) + 1, index).trimStart();
3156
+ const next = content.slice(afterCloseParen(content, index + m[0].length)).trimStart()[0];
3157
+ if (MEMBER_HEAD.test(head) && (next === "{" || next === ":")) continue;
3158
+ }
3159
+ out.push({ index, name });
3160
+ }
3161
+ return out;
3162
+ }
3163
+ function inBracelessArrow(content, index) {
3164
+ let start = index;
3165
+ while (start > 0 && !";{}".includes(content[start - 1])) start--;
3166
+ const span = content.slice(start, index);
3167
+ const arrow = span.lastIndexOf("=>");
3168
+ if (arrow === -1) return false;
3169
+ let depth = 0;
3170
+ let begun = false;
3171
+ for (const ch of span.slice(arrow + 2)) {
3172
+ if (ch === "(" || ch === "[") depth++;
3173
+ else if (ch === ")" || ch === "]") depth--;
3174
+ if (ch === "\n") {
3175
+ if (begun && depth <= 0) return false;
3176
+ } else if (ch.trim()) {
3177
+ begun = true;
3178
+ }
3179
+ }
3180
+ return true;
3181
+ }
3182
+ function hashReadsBeforeInit(content) {
3183
+ const reads = [...content.matchAll(HASH_READ)].map((m) => m.index);
3184
+ if (reads.length === 0) return [];
3185
+ const inits = [...content.matchAll(new RegExp(INIT_EMBED_CALL.source, "g"))].map((m) => ({
3186
+ index: m.index,
3187
+ flowEnd: enclosingFlowEnd(content, m.index)
3188
+ }));
3189
+ let depth = 0;
3190
+ let scanned = 0;
3191
+ return reads.filter((at) => {
3192
+ for (; scanned < at; scanned++) {
3193
+ const ch = content[scanned];
3194
+ if (ch === "{") depth++;
3195
+ else if (ch === "}") depth--;
3196
+ }
3197
+ if (depth <= 0) {
3198
+ return !inBracelessArrow(content, at) && !inits.some((init2) => init2.index < at);
3199
+ }
3200
+ const flowEnd = enclosingFlowEnd(content, at);
3201
+ return flowEnd !== content.length && inits.some((init2) => init2.flowEnd === flowEnd && init2.index > at);
3202
+ });
3203
+ }
3204
+ async function detectShellApis(cwd = process.cwd()) {
3205
+ const manifest = await readManifestCapabilities(cwd);
3206
+ const found = {
3207
+ share: [],
3208
+ clipboard: [],
3209
+ wakeLock: [],
3210
+ newWindow: [],
3211
+ download: [],
3212
+ dialogs: [],
3213
+ serviceWorker: [],
3214
+ motionNoPermission: [],
3215
+ hashBeforeInit: [],
3216
+ capture: [],
3217
+ manifestCapabilities: knownCapabilities(manifest.entries),
3218
+ unknownCapabilities: manifest.entries.filter((entry) => !KNOWN_CAPABILITIES.includes(entry)).map((entry) => {
3219
+ const quoted = JSON.stringify(entry).replace(
3220
+ /[^\x20-\x7e]/g,
3221
+ (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`
3222
+ );
3223
+ return quoted.length > 40 ? `${quoted.slice(0, 40)}\u2026` : quoted;
3224
+ }),
3225
+ capabilitiesNotAList: manifest.notAList
3226
+ };
3227
+ let asksMotionPermission = false;
3228
+ for (const file of await readOwnSource(cwd)) {
3229
+ const { rel, content } = file;
3230
+ const add = (into, index, detail) => {
3231
+ const where = `${rel}:${lineOf(content, index)}`;
3232
+ if (!into.some((d) => d.where === where)) into.push({ where, detail });
3233
+ };
3234
+ for (const m of content.matchAll(SHARE_CALL)) add(found.share, m.index, "navigator.share");
3235
+ for (const m of content.matchAll(CLIPBOARD_USE)) add(found.clipboard, m.index, "navigator.clipboard");
3236
+ for (const m of content.matchAll(WAKE_LOCK_USE)) add(found.wakeLock, m.index, "navigator.wakeLock");
3237
+ for (const m of content.matchAll(NEW_WINDOW)) {
3238
+ add(found.newWindow, m.index, m[0].includes("open") ? "window.open" : 'target "_blank"');
3239
+ }
3240
+ if (MAKES_A_LINK.test(content)) {
3241
+ for (const m of content.matchAll(DOWNLOAD_ASSIGN)) add(found.download, m.index, "a.download");
3242
+ }
3243
+ for (const m of content.matchAll(DOWNLOAD_ATTR)) add(found.download, m.index, "a download link");
3244
+ for (const call of dialogCalls(content)) add(found.dialogs, call.index, `${call.name}()`);
3245
+ for (const m of content.matchAll(SERVICE_WORKER)) {
3246
+ add(found.serviceWorker, m.index, "serviceWorker.register");
3247
+ }
3248
+ for (const m of content.matchAll(MOTION_USE)) add(found.motionNoPermission, m.index, "device motion");
3249
+ if (/\brequestPermission\b/.test(content)) asksMotionPermission = true;
3250
+ for (const at of hashReadsBeforeInit(content)) add(found.hashBeforeInit, at, "location.hash");
3251
+ for (const request of mediaRequestsInText(content)) {
3252
+ if (!request.read) add(found.capture, request.index, "getUserMedia");
3253
+ }
3254
+ }
3255
+ if (asksMotionPermission) found.motionNoPermission = [];
3256
+ return found;
3257
+ }
2621
3258
  async function detectFeatures(log, cwd = process.cwd()) {
2622
3259
  return {
2623
3260
  embedSdkVersion: await detectEmbedSdkVersion(cwd),
@@ -2627,7 +3264,9 @@ async function detectFeatures(log, cwd = process.cwd()) {
2627
3264
  mobileControls: await detectMobileControls(cwd),
2628
3265
  surfaces: await detectSurfaceScan(cwd),
2629
3266
  generations: await detectGenerationAudit(cwd),
2630
- embedBoot: await detectEmbedBoot(cwd)
3267
+ embedBoot: await detectEmbedBoot(cwd),
3268
+ capabilities: await detectCapabilities(cwd),
3269
+ shellApis: await detectShellApis(cwd)
2631
3270
  };
2632
3271
  }
2633
3272
  function advisoryNudges(log, d) {
@@ -2641,14 +3280,98 @@ function advisoryNudges(log, d) {
2641
3280
  "Multiplayer will NOT work: @genex-ai/multiplayer needs the embed-sdk token (getColyseusAuth) \u2014 the relay rejects tokenless joins. Wire genex-threejs-embed-auth first."
2642
3281
  );
2643
3282
  }
2644
- if (d.matchmaking && !d.multiplayer) {
3283
+ if (d.matchmaking && !d.multiplayer) {
3284
+ log.warn(
3285
+ "package.json declares genex.matchmaking but @genex-ai/multiplayer is not installed \u2014 matchmaking cannot work. Install the SDK and use matchmake() (see genex-threejs-multiplayer)."
3286
+ );
3287
+ }
3288
+ embedBootNudges(log, d);
3289
+ shellNudges(log, d);
3290
+ surfaceNudges(log, d.surfaces);
3291
+ generationNudges(log, d.generations);
3292
+ }
3293
+ function places(found, withDetail = false) {
3294
+ const shown = found.slice(0, 4).map((d) => withDetail ? `${d.where} (${d.detail})` : d.where);
3295
+ const rest = found.length - shown.length;
3296
+ return shown.join(", ") + (rest > 0 ? ` (+${rest} more)` : "");
3297
+ }
3298
+ function shellNudges(log, d) {
3299
+ const s = d.shellApis;
3300
+ const helper = "from @genex-ai/embed-sdk";
3301
+ let namedHelper = false;
3302
+ if (s.capabilitiesNotAList) {
3303
+ log.warn(
3304
+ `package.json \`genex.capabilities\` is not a list, so it declares nothing. Write the names in a list: "genex": { "capabilities": ["camera"] } \u2014 the names are ${KNOWN_CAPABILITIES.join(", ")}.`
3305
+ );
3306
+ }
3307
+ if (s.unknownCapabilities.length) {
3308
+ log.warn(
3309
+ `package.json \`genex.capabilities\` lists ${s.unknownCapabilities.slice(0, 6).join(", ")}, which Genex does not know, so it gives the game nothing. The names are ${KNOWN_CAPABILITIES.join(", ")}, spelled exactly so.`
3310
+ );
3311
+ }
3312
+ const mediaDeclared = ["microphone", "camera"].filter((name) => d.capabilities.includes(name));
3313
+ if (s.capture.length && mediaDeclared.length < 2 && !s.manifestCapabilities.some((name) => name === "microphone" || name === "camera")) {
3314
+ const declared = mediaDeclared.length ? `only the ${mediaDeclared[0]} is declared for this build` : "nothing in this game declares the microphone or the camera";
3315
+ log.warn(
3316
+ `${places(s.capture)} calls getUserMedia, and ${declared}: the constraints are not written out at the call, so the scan cannot tell which it opens. On the game's Genex page an undeclared microphone or camera is refused without the player being asked, while the same build works on its own link. Say which the game uses in package.json: "genex": { "capabilities": ["microphone"] } (or "camera", or both).`
3317
+ );
3318
+ }
3319
+ if (s.share.length) {
3320
+ namedHelper = true;
3321
+ log.warn(
3322
+ `${places(s.share)} calls navigator.share. It shares whatever the game hands it, and on the game's Genex page \`location.href\` is the bare game address, never the page players should be sent to; some shells have no share sheet at all. share({ text, params }) ${helper} builds the link to the game's page itself, opens the sheet where there is one and copies the link where there is not. Call it inside the click or tap.`
3323
+ );
3324
+ }
3325
+ if (s.clipboard.length) {
3326
+ namedHelper = true;
3327
+ log.warn(
3328
+ `${places(s.clipboard)} uses navigator.clipboard. The game's Genex page gives a framed game clipboard writing and never reading, and a write is still refused without a tap behind it, or in a frame on a page that does not pass it on. copyText(text) ${helper} tries it and falls back to the older copy a frame is allowed after a tap; call it inside the click. To read text, let the player paste into an <input>.`
3329
+ );
3330
+ }
3331
+ if (s.wakeLock.length) {
3332
+ namedHelper = true;
3333
+ log.warn(
3334
+ `${places(s.wakeLock)} uses navigator.wakeLock. The game's Genex page gives it to every framed game, but the browser drops the lock every time the tab is hidden and some browsers have none to give. keepAwake(true) ${helper} takes the lock again when the player comes back, says so where there is none, and answers ok in the Genex app, where the screen already stays on.`
3335
+ );
3336
+ }
3337
+ if (s.newWindow.length) {
3338
+ namedHelper = true;
3339
+ log.warn(
3340
+ `${places(s.newWindow, true)} opens a new window. The game's Genex page gives a game no new windows and the Genex app blocks links out of a game, so there the click does nothing. openExternal(url) ${helper}, called inside the click, is the one call for every shell: on the game's page Genex asks the player and opens the link, on the game's own link it is a new tab, and where links out are not available it answers \`unsupported\` \u2014 show the address instead.`
3341
+ );
3342
+ }
3343
+ if (s.download.length) {
3344
+ namedHelper = true;
3345
+ log.warn(
3346
+ `${places(s.download)} saves a file through a download link. The game's Genex page drops a download started inside a game, with no error. saveFile(blob, name) ${helper}, called inside the click, saves an image, a video, JSON or plain text wherever a shell can: on the game's page Genex asks the player first, and where saving is not available it answers \`unsupported\`.`
3347
+ );
3348
+ }
3349
+ if (s.hashBeforeInit.length && d.embedSdkVersion) {
3350
+ namedHelper = true;
3351
+ log.warn(
3352
+ `${places(s.hashBeforeInit)} reads location.hash where it can run before initEmbed(). On the game's own link the address bar still holds Genex's sign-in fragment at that point, and initEmbed() puts the game's own fragment back without a hashchange event, so that first read is wrong and nothing corrects it. Read the link with getEntry() ${helper}: getEntry().hash and getEntry().params answer the same in every shell, whenever they are asked.`
3353
+ );
3354
+ }
3355
+ if (namedHelper) {
3356
+ log.dim(
3357
+ " share, copyText, keepAwake, openExternal, saveFile and getEntry ship in the current @genex-ai/embed-sdk. If an import is missing: npm i @genex-ai/embed-sdk@latest"
3358
+ );
3359
+ }
3360
+ if (s.dialogs.length) {
3361
+ log.warn(
3362
+ `${places(s.dialogs, true)} calls a browser dialog. The game's Genex page shows none for a game: alert() does nothing, confirm() answers false and prompt() answers null, with no error, while the same build works on its own link. Draw the dialog in the game's own interface (genex-threejs-game-ui).`
3363
+ );
3364
+ }
3365
+ if (s.motionNoPermission.length) {
3366
+ log.warn(
3367
+ `${places(s.motionNoPermission)} uses device tilt or motion, and nothing in the game calls requestPermission(). An iPhone sends no motion events until the player allows them, and it asks only when DeviceOrientationEvent.requestPermission() is called from a tap inside the game. Ask in the Start tap, treat a refusal or no events at all as "no sensor", and keep the on-screen controls as the fallback (genex-threejs-touch-controls).`
3368
+ );
3369
+ }
3370
+ if (s.serviceWorker.length) {
2645
3371
  log.warn(
2646
- "package.json declares genex.matchmaking but @genex-ai/multiplayer is not installed \u2014 matchmaking cannot work. Install the SDK and use matchmake() (see genex-threejs-multiplayer)."
3372
+ `${places(s.serviceWorker)} registers a service worker. A returning player's browser then serves the copy it kept: an older build after an update, or a game that has since been taken down, and no deploy reaches it. A Genex game does not register one.`
2647
3373
  );
2648
3374
  }
2649
- embedBootNudges(log, d);
2650
- surfaceNudges(log, d.surfaces);
2651
- generationNudges(log, d.generations);
2652
3375
  }
2653
3376
  function embedBootNudges(log, d) {
2654
3377
  const boot = d.embedBoot;
@@ -2868,403 +3591,142 @@ function auditGenerationPlan(input) {
2868
3591
  if (design.trim().length === 0) return [];
2869
3592
  const assetRows = sectionRows(design, /^##\s+Assets\b/i);
2870
3593
  const proceduralRows = sectionRows(design, /^##\s+Procedural assets\b/i);
2871
- const rows = [
2872
- ...assetRows.map(classify),
2873
- ...proceduralRows.map((c2) => {
2874
- const r = classify(c2);
2875
- return r === null ? null : { ...r, lane: null, procedural: true };
2876
- })
2877
- ].filter((r) => r !== null);
2878
- const declaredLanes = /* @__PURE__ */ new Set();
2879
- for (const m of design.matchAll(LANE_TOKEN)) {
2880
- const lane = m[1] ? LANE_OF_SKILL[m[1]] : void 0;
2881
- if (lane) declaredLanes.add(lane);
2882
- }
2883
- const lanesRun = new Set(
2884
- ledger.filter((e) => e.status === "completed").map((e) => LANE_OF_KIND[e.kind] ?? e.kind)
2885
- );
2886
- const lanesPending = new Set(
2887
- ledger.filter((e) => e.status === "queued").map((e) => LANE_OF_KIND[e.kind] ?? e.kind).filter((lane) => !lanesRun.has(lane))
2888
- );
2889
- const warnings = [];
2890
- const noneMarker = GENERATION_NONE.exec(design);
2891
- const explained = noneMarker !== null && isFilledReason(noneMarker[1]);
2892
- const reconciled = /* @__PURE__ */ new Set();
2893
- for (const row of rows) {
2894
- const reason = /\bReason:\s*(.+)/i.exec(row.detail)?.[1];
2895
- if (row.procedural) {
2896
- if (/\b(replaced|substitut\w*|fallback|instead of)\b/i.test(row.detail) && !explained && !isFilledReason(reason)) {
2897
- warnings.push(`DESIGN.md substitutes procedural work for ${row.name} without a recorded reason. Record why this choice fits (including budget limits or declined generation); no purchase is required.`);
2898
- }
2899
- continue;
2900
- }
2901
- const tokens2 = new Set(row.detail.split(/[^a-zA-Z0-9_-]+/));
2902
- const explicit = ledger.filter((entry) => tokens2.has(entry.id));
2903
- const exact = explicit.length ? explicit : ledger.filter((entry) => entry.prompt.trim().toLowerCase() === row.name.toLowerCase() && (LANE_OF_KIND[entry.kind] ?? entry.kind) === row.lane);
2904
- if (!exact.length) continue;
2905
- reconciled.add(row);
2906
- for (const entry of exact) {
2907
- if (entry.status === "queued") warnings.push(`DESIGN.md asset ${row.name} (${entry.id}) is pending. Check the existing request; do not enqueue a duplicate.`);
2908
- else if (entry.status === "failed") warnings.push(`DESIGN.md asset ${row.name} (${entry.id}) failed. Record a recovery or substitution decision; budget limits and declined generation are valid reasons.`);
2909
- else if (input.unreferencedIds?.has(entry.id)) warnings.push(`DESIGN.md asset ${row.name} (${entry.id}) completed but its output is not referenced by the source audit. Check delivery/integration or record why it was omitted. A reference alone does not prove visible use.`);
2910
- }
2911
- }
2912
- const owed = /* @__PURE__ */ new Map();
2913
- for (const row of rows) {
2914
- if (row.procedural || row.lane === null || reconciled.has(row)) continue;
2915
- if (row.status !== "planned" && row.status !== "generating") continue;
2916
- if (lanesRun.has(row.lane)) {
2917
- if (rows.filter((other) => other.lane === row.lane).length > 1) warnings.push(`DESIGN.md asset ${row.name} has no matched generation id. Other ${row.lane} work completed, but that does not establish this asset's delivery. Record its id or the substitution reason.`);
2918
- continue;
2919
- }
2920
- if (lanesPending.has(row.lane)) {
2921
- warnings.push(`DESIGN.md plans ${row.name}; its ${row.lane} lane has pending work. Check the existing request and record its id; do not enqueue a duplicate.`);
2922
- continue;
2923
- }
2924
- if (row.lane === "skybox") continue;
2925
- const held = owed.get(row.lane) ?? [];
2926
- held.push(row.status === "generating" ? `${row.name} (says generating, not in this project's ledger)` : row.name);
2927
- owed.set(row.lane, held);
2928
- }
2929
- for (const lane of declaredLanes) {
2930
- if (lanesRun.has(lane) || lanesPending.has(lane) || owed.has(lane) || lane === "skybox") continue;
2931
- if (!rows.some((r) => r.lane === lane)) owed.set(lane, ["declared as this subsystem's lane"]);
2932
- }
2933
- for (const [lane, names2] of owed) {
2934
- const shown = names2.slice(0, 3).join(", ") + (names2.length > 3 ? `, +${names2.length - 3} more` : "");
2935
- warnings.push(
2936
- `DESIGN.md plans generated ${lane} work (${shown}) and this project has no ${lane} generation that landed \u2014 the plan says so and the generation ledger has nothing completed. Generate it (${LANE_COMMAND[lane] ?? `npx genex ${lane}`} "<prompt>" --no-wait), or change the row to say what happens instead and why.`
2937
- );
2938
- }
2939
- const paidRows = rows.filter((r) => !r.procedural);
2940
- const codeRows = rows.filter((r) => r.procedural);
2941
- if (paidRows.length === 0 && codeRows.length >= 1 && ledger.length === 0 && !explained) {
2942
- warnings.push(
2943
- "DESIGN.md's asset plan is all code-built rows and this project has never run a generation. What the player sees must read as the thing it is (build contract, law 22): a coloured box or a capsule is not a finished building, person, or prop. Generate what the request names and what the player looks at up close (npx genex model / texture / music \u2026 --no-wait), or write a `Generation: none \u2014` line beside the table ending in your own sentence about why code alone reaches the bar in THIS game. " + (noneMarker ? "The line is there but its reason is still the unfilled template \u2014 replace it with the actual reason." : "")
2944
- );
2945
- }
2946
- return warnings;
2947
- }
2948
-
2949
- // src/lib/deploy.ts
2950
- import crypto2 from "crypto";
2951
- import fs17 from "fs/promises";
2952
- import os5 from "os";
2953
- import path17 from "path";
2954
-
2955
- // src/lib/source-size.ts
2956
- import fs14 from "fs/promises";
2957
- import path15 from "path";
2958
- var FALLBACK_SOURCE_LIMITS = {
2959
- maxTotalBytes: 2 * 1024 * 1024 * 1024,
2960
- maxFiles: 5e3
2961
- };
2962
- function isOversized(p, limits) {
2963
- return p.bytes >= limits.maxTotalBytes || p.files >= limits.maxFiles;
2964
- }
2965
- function formatBytes2(n) {
2966
- if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
2967
- if (n >= 1024 ** 2) return `${Math.round(n / 1024 ** 2)} MB`;
2968
- if (n >= 1024) return `${Math.round(n / 1024)} KB`;
2969
- return `${n} B`;
2970
- }
2971
- function groupByDir(entries, limit = 5) {
2972
- const acc = /* @__PURE__ */ new Map();
2973
- for (const [file, bytes] of entries) {
2974
- const dirParts = file.split("/").slice(0, -1).slice(0, 2);
2975
- const dir = dirParts.length > 0 ? dirParts.join("/") : "(root)";
2976
- const row = acc.get(dir) ?? { dir, files: 0, bytes: 0 };
2977
- row.files += 1;
2978
- row.bytes += bytes;
2979
- acc.set(dir, row);
2980
- }
2981
- return [...acc.values()].sort((a, b) => b.bytes - a.bytes).slice(0, limit);
2982
- }
2983
- function oversizedLines(p, limits) {
2984
- return [
2985
- `This is ${formatBytes2(p.bytes)} across ${p.files} files, past the ${formatBytes2(limits.maxTotalBytes)} / ${limits.maxFiles} file limit on this account.`,
2986
- "Heaviest directories:",
2987
- ...p.top.map((d) => ` ${formatBytes2(d.bytes).padStart(8)} ${String(d.files).padStart(6)} files ${d.dir}`),
2988
- "Screenshots, traces and reports made while building are not part of the game \u2014",
2989
- "put them in .genex/scratch/ (already ignored), or add the folders above to .gitignore.",
2990
- "If this really is the game, ask an admin to raise the source limit on your account."
2991
- ];
2992
- }
2993
- async function measurePayload(cwd, staged) {
2994
- const entries = [];
2995
- let bytes = 0;
2996
- for (const file of staged) {
2997
- let size = 0;
2998
- try {
2999
- size = (await fs14.stat(path15.join(cwd, file))).size;
3000
- } catch {
3001
- size = 0;
3002
- }
3003
- bytes += size;
3004
- entries.push([file, size]);
3005
- }
3006
- return { files: staged.length, bytes, top: groupByDir(entries) };
3007
- }
3008
-
3009
- // ../../packages/mobile-scan/src/image-dims.ts
3010
- function u32be(b, o) {
3011
- return (b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]) >>> 0;
3012
- }
3013
- function u32le(b, o) {
3014
- return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
3015
- }
3016
- function u16be(b, o) {
3017
- return b[o] << 8 | b[o + 1];
3018
- }
3019
- function u16le(b, o) {
3020
- return b[o] | b[o + 1] << 8;
3021
- }
3022
- function isPng(b) {
3023
- return b.length > 24 && b[0] === 137 && b[1] === 80 && b[2] === 78 && b[3] === 71 && b[4] === 13 && b[5] === 10 && b[6] === 26 && b[7] === 10;
3024
- }
3025
- var KTX2_MAGIC = [171, 75, 84, 88, 32, 50, 48, 187, 13, 10, 26, 10];
3026
- function parseImageDims(bytes) {
3027
- if (bytes.length < 30) return null;
3028
- if (isPng(bytes)) {
3029
- return { width: u32be(bytes, 16), height: u32be(bytes, 20) };
3030
- }
3031
- if (bytes[0] === 255 && bytes[1] === 216) {
3032
- let i = 2;
3033
- while (i + 9 < bytes.length) {
3034
- if (bytes[i] !== 255) {
3035
- i++;
3036
- continue;
3037
- }
3038
- const marker = bytes[i + 1];
3039
- if (marker === 255) {
3040
- i++;
3041
- continue;
3042
- }
3043
- if (marker === 216 || marker >= 208 && marker <= 217) {
3044
- i += 2;
3045
- continue;
3046
- }
3047
- const isSof = marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207;
3048
- if (isSof) {
3049
- return { width: u16be(bytes, i + 7), height: u16be(bytes, i + 5) };
3050
- }
3051
- i += 2 + u16be(bytes, i + 2);
3052
- }
3053
- return null;
3054
- }
3055
- if (bytes[0] === 82 && // RIFF….WEBP
3056
- bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) {
3057
- const fourcc = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
3058
- if (fourcc === "VP8X" && bytes.length >= 30) {
3059
- const w = 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16);
3060
- const h = 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16);
3061
- return { width: w, height: h };
3062
- }
3063
- if (fourcc === "VP8 " && bytes.length >= 30) {
3064
- return { width: u16le(bytes, 26) & 16383, height: u16le(bytes, 28) & 16383 };
3065
- }
3066
- if (fourcc === "VP8L" && bytes.length >= 25 && bytes[20] === 47) {
3067
- const b0 = bytes[21];
3068
- const b1 = bytes[22];
3069
- const b2 = bytes[23];
3070
- const b3 = bytes[24];
3071
- const width = 1 + ((b1 & 63) << 8 | b0);
3072
- const height = 1 + ((b3 & 15) << 10 | b2 << 2 | (b1 & 192) >> 6);
3073
- return { width, height };
3074
- }
3075
- return null;
3076
- }
3077
- if (KTX2_MAGIC.every((v, idx) => bytes[idx] === v)) {
3078
- return { width: u32le(bytes, 20), height: u32le(bytes, 24), ktx2: true };
3079
- }
3080
- return null;
3081
- }
3082
-
3083
- // ../../packages/mobile-scan/src/glb.ts
3084
- function u32le2(b, o) {
3085
- return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
3086
- }
3087
- function scanGlb(bytes) {
3088
- if (bytes.length < 20 || u32le2(bytes, 0) !== 1179937895) return null;
3089
- try {
3090
- const jsonLen = u32le2(bytes, 12);
3091
- if (u32le2(bytes, 16) !== 1313821514) return null;
3092
- const json = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLen)));
3093
- let bin = null;
3094
- const binHeader = 20 + jsonLen;
3095
- if (binHeader + 8 <= bytes.length && u32le2(bytes, binHeader + 4) === 5130562) {
3096
- bin = bytes.subarray(binHeader + 8, binHeader + 8 + u32le2(bytes, binHeader));
3097
- }
3098
- const images = [];
3099
- for (const img of json.images ?? []) {
3100
- if (img.bufferView == null || !bin) continue;
3101
- const view = json.bufferViews?.[img.bufferView];
3102
- if (!view?.byteLength) continue;
3103
- const slice = bin.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength);
3104
- const dims = parseImageDims(slice);
3105
- if (dims) images.push({ ...dims, mimeType: img.mimeType, bytes: view.byteLength });
3106
- }
3107
- let triangles = 0;
3108
- for (const mesh of json.meshes ?? []) {
3109
- for (const prim of mesh.primitives ?? []) {
3110
- const accessor = prim.indices != null ? json.accessors?.[prim.indices] : json.accessors?.[prim.attributes?.POSITION ?? -1];
3111
- if (accessor?.count) triangles += Math.floor(accessor.count / 3);
3112
- }
3113
- }
3114
- return {
3115
- triangles,
3116
- binBytes: bin?.byteLength ?? 0,
3117
- images,
3118
- extensions: json.extensionsUsed ?? []
3119
- };
3120
- } catch {
3121
- return null;
3594
+ const rows = [
3595
+ ...assetRows.map(classify),
3596
+ ...proceduralRows.map((c2) => {
3597
+ const r = classify(c2);
3598
+ return r === null ? null : { ...r, lane: null, procedural: true };
3599
+ })
3600
+ ].filter((r) => r !== null);
3601
+ const declaredLanes = /* @__PURE__ */ new Set();
3602
+ for (const m of design.matchAll(LANE_TOKEN)) {
3603
+ const lane = m[1] ? LANE_OF_SKILL[m[1]] : void 0;
3604
+ if (lane) declaredLanes.add(lane);
3122
3605
  }
3123
- }
3124
-
3125
- // ../../packages/mobile-scan/src/scan.ts
3126
- var GENEX_GENERATION_URL_RE = /https:\/\/[A-Za-z0-9.-]+\/generations\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._@-]+)/g;
3127
- var SKYBOX_ROLE = "skybox-equirect";
3128
- var MOBILE_RUNG_SUFFIX_RE = /@(\d+)$/;
3129
- var MOBILE_BUDGET_MB = { broad: 300, modern: 700 };
3130
- var TEXT_EXT = /\.(js|mjs|cjs|html|css|json|txt)$/i;
3131
- var IMAGE_EXT = /\.(png|jpe?g|webp|ktx2)$/i;
3132
- var VIDEO_EXT = /\.(mp4|webm|mov)$/i;
3133
- var AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
3134
- var GLB_EXT = /\.(glb|gltf)$/i;
3135
- function scanBundle(files) {
3136
- const images = [];
3137
- const glbs = [];
3138
- const videos = [];
3139
- const audio = [];
3140
- const urls = /* @__PURE__ */ new Set();
3141
- let text = "";
3142
- let totalBytes = 0;
3143
- for (const f of files) {
3144
- totalBytes += f.bytes.byteLength;
3145
- if (IMAGE_EXT.test(f.relPath)) {
3146
- const dims = parseImageDims(f.bytes);
3147
- if (dims) images.push({ path: f.relPath, bytes: f.bytes.byteLength, ...dims });
3606
+ const lanesRun = new Set(
3607
+ ledger.filter((e) => e.status === "completed").map((e) => LANE_OF_KIND[e.kind] ?? e.kind)
3608
+ );
3609
+ const lanesPending = new Set(
3610
+ ledger.filter((e) => e.status === "queued").map((e) => LANE_OF_KIND[e.kind] ?? e.kind).filter((lane) => !lanesRun.has(lane))
3611
+ );
3612
+ const warnings = [];
3613
+ const noneMarker = GENERATION_NONE.exec(design);
3614
+ const explained = noneMarker !== null && isFilledReason(noneMarker[1]);
3615
+ const reconciled = /* @__PURE__ */ new Set();
3616
+ for (const row of rows) {
3617
+ const reason = /\bReason:\s*(.+)/i.exec(row.detail)?.[1];
3618
+ if (row.procedural) {
3619
+ if (/\b(replaced|substitut\w*|fallback|instead of)\b/i.test(row.detail) && !explained && !isFilledReason(reason)) {
3620
+ warnings.push(`DESIGN.md substitutes procedural work for ${row.name} without a recorded reason. Record why this choice fits (including budget limits or declined generation); no purchase is required.`);
3621
+ }
3148
3622
  continue;
3149
3623
  }
3150
- if (GLB_EXT.test(f.relPath)) {
3151
- glbs.push({ path: f.relPath, bytes: f.bytes.byteLength, scan: scanGlb(f.bytes) });
3152
- continue;
3624
+ const tokens2 = new Set(row.detail.split(/[^a-zA-Z0-9_-]+/));
3625
+ const explicit = ledger.filter((entry) => tokens2.has(entry.id));
3626
+ const exact = explicit.length ? explicit : ledger.filter((entry) => entry.prompt.trim().toLowerCase() === row.name.toLowerCase() && (LANE_OF_KIND[entry.kind] ?? entry.kind) === row.lane);
3627
+ if (!exact.length) continue;
3628
+ reconciled.add(row);
3629
+ for (const entry of exact) {
3630
+ if (entry.status === "queued") warnings.push(`DESIGN.md asset ${row.name} (${entry.id}) is pending. Check the existing request; do not enqueue a duplicate.`);
3631
+ else if (entry.status === "failed") warnings.push(`DESIGN.md asset ${row.name} (${entry.id}) failed. Record a recovery or substitution decision; budget limits and declined generation are valid reasons.`);
3632
+ else if (input.unreferencedIds?.has(entry.id)) warnings.push(`DESIGN.md asset ${row.name} (${entry.id}) completed but its output is not referenced by the source audit. Check delivery/integration or record why it was omitted. A reference alone does not prove visible use.`);
3153
3633
  }
3154
- if (VIDEO_EXT.test(f.relPath)) {
3155
- videos.push({ path: f.relPath, bytes: f.bytes.byteLength });
3634
+ }
3635
+ const owed = /* @__PURE__ */ new Map();
3636
+ for (const row of rows) {
3637
+ if (row.procedural || row.lane === null || reconciled.has(row)) continue;
3638
+ if (row.status !== "planned" && row.status !== "generating") continue;
3639
+ if (lanesRun.has(row.lane)) {
3640
+ if (rows.filter((other) => other.lane === row.lane).length > 1) warnings.push(`DESIGN.md asset ${row.name} has no matched generation id. Other ${row.lane} work completed, but that does not establish this asset's delivery. Record its id or the substitution reason.`);
3156
3641
  continue;
3157
3642
  }
3158
- if (AUDIO_EXT.test(f.relPath)) {
3159
- audio.push({ path: f.relPath, bytes: f.bytes.byteLength });
3643
+ if (lanesPending.has(row.lane)) {
3644
+ warnings.push(`DESIGN.md plans ${row.name}; its ${row.lane} lane has pending work. Check the existing request and record its id; do not enqueue a duplicate.`);
3160
3645
  continue;
3161
3646
  }
3162
- if (TEXT_EXT.test(f.relPath)) {
3163
- text += new TextDecoder().decode(f.bytes.subarray(0, 8 * 1024 * 1024));
3164
- text += "\n";
3165
- }
3647
+ if (row.lane === "skybox") continue;
3648
+ const held = owed.get(row.lane) ?? [];
3649
+ held.push(row.status === "generating" ? `${row.name} (says generating, not in this project's ledger)` : row.name);
3650
+ owed.set(row.lane, held);
3166
3651
  }
3167
- for (const m of text.matchAll(GENEX_GENERATION_URL_RE)) urls.add(m[0]);
3168
- const dprMatch = text.match(/setPixelRatio\(\s*Math\.min\([^,)]+,\s*([\d.]+)\s*\)/);
3169
- const markers = {
3170
- rendererBackend: text.includes("WebGPURenderer") || text.includes("getContext('webgpu')") || text.includes('"webgpu"') ? "webgpu" : "webgl",
3171
- postStack: /EffectComposer|postprocessing|PostProcessing|RenderPipeline/.test(text),
3172
- dprCapLiteral: dprMatch?.[1] ? Number(dprMatch[1]) : null,
3173
- contextLossHandler: text.includes("webglcontextlost") || text.includes("device.lost"),
3174
- ktx2Loader: text.includes("KTX2Loader") || text.includes(".ktx2"),
3175
- touchHints: /joystick/i.test(text) || text.includes("touchstart") && text.includes("touch-action"),
3176
- adaptiveQuality: text.includes("__GENEX_QUALITY__"),
3177
- modelRungs: text.includes("__GENEX_MODEL_RUNGS__"),
3178
- voice: text.includes("__GENEX_VOICE__")
3179
- };
3180
- return {
3181
- images,
3182
- glbs,
3183
- videos,
3184
- audio,
3185
- genexAssetUrls: [...urls],
3186
- markers,
3187
- totalBytes,
3188
- fileCount: files.length
3189
- };
3652
+ for (const lane of declaredLanes) {
3653
+ if (lanesRun.has(lane) || lanesPending.has(lane) || owed.has(lane) || lane === "skybox") continue;
3654
+ if (!rows.some((r) => r.lane === lane)) owed.set(lane, ["declared as this subsystem's lane"]);
3655
+ }
3656
+ for (const [lane, names2] of owed) {
3657
+ const shown = names2.slice(0, 3).join(", ") + (names2.length > 3 ? `, +${names2.length - 3} more` : "");
3658
+ warnings.push(
3659
+ `DESIGN.md plans generated ${lane} work (${shown}) and this project has no ${lane} generation that landed \u2014 the plan says so and the generation ledger has nothing completed. Generate it (${LANE_COMMAND[lane] ?? `npx genex ${lane}`} "<prompt>" --no-wait), or change the row to say what happens instead and why.`
3660
+ );
3661
+ }
3662
+ const paidRows = rows.filter((r) => !r.procedural);
3663
+ const codeRows = rows.filter((r) => r.procedural);
3664
+ if (paidRows.length === 0 && codeRows.length >= 1 && ledger.length === 0 && !explained) {
3665
+ warnings.push(
3666
+ "DESIGN.md's asset plan is all code-built rows and this project has never run a generation. What the player sees must read as the thing it is (build contract, law 22): a coloured box or a capsule is not a finished building, person, or prop. Generate what the request names and what the player looks at up close (npx genex model / texture / music \u2026 --no-wait), or write a `Generation: none \u2014` line beside the table ending in your own sentence about why code alone reaches the bar in THIS game. " + (noneMarker ? "The line is there but its reason is still the unfilled template \u2014 replace it with the actual reason." : "")
3667
+ );
3668
+ }
3669
+ return warnings;
3190
3670
  }
3191
3671
 
3192
- // ../../packages/mobile-scan/src/estimate.ts
3193
- var MB = 1024 * 1024;
3194
- var CSS_W = 430;
3195
- var CSS_H = 932;
3196
- var WORST_SKYBOX_BYTES = 8192 * 4096 * 4 * 1.33;
3197
- var WORST_IMAGE_BYTES = 2048 * 2048 * 4 * 1.33;
3198
- var KTX2_DISCOUNT = 6;
3199
- function textureBytes(w, h, ktx2) {
3200
- const raw = w * h * 4 * 1.33;
3201
- return ktx2 ? raw / KTX2_DISCOUNT : raw;
3672
+ // src/lib/deploy.ts
3673
+ import crypto2 from "crypto";
3674
+ import fs17 from "fs/promises";
3675
+ import os5 from "os";
3676
+ import path17 from "path";
3677
+
3678
+ // src/lib/source-size.ts
3679
+ import fs14 from "fs/promises";
3680
+ import path15 from "path";
3681
+ var FALLBACK_SOURCE_LIMITS = {
3682
+ maxTotalBytes: 2 * 1024 * 1024 * 1024,
3683
+ maxFiles: 5e3
3684
+ };
3685
+ function isOversized(p, limits) {
3686
+ return p.bytes >= limits.maxTotalBytes || p.files >= limits.maxFiles;
3202
3687
  }
3203
- function estimateVram(scan, externalDims) {
3204
- let bundled = 0;
3205
- let maxDim = 0;
3206
- for (const img of scan.images) {
3207
- bundled += textureBytes(img.width, img.height, img.ktx2);
3208
- maxDim = Math.max(maxDim, img.width, img.height);
3209
- }
3210
- let glbTex = 0;
3211
- let geometry = 0;
3212
- for (const glb of scan.glbs) {
3213
- if (!glb.scan) {
3214
- geometry += glb.bytes;
3215
- continue;
3216
- }
3217
- const ktx2 = glb.scan.extensions.some((e) => e.includes("KHR_texture_basisu"));
3218
- for (const img of glb.scan.images) {
3219
- glbTex += textureBytes(img.width, img.height, img.ktx2 || ktx2);
3220
- maxDim = Math.max(maxDim, img.width, img.height);
3221
- }
3222
- geometry += Math.max(0, glb.scan.binBytes - glb.scan.images.reduce((s, i) => s + i.bytes, 0));
3688
+ function formatBytes2(n) {
3689
+ if (n >= 1024 ** 3) return `${(n / 1024 ** 3).toFixed(1)} GB`;
3690
+ if (n >= 1024 ** 2) return `${Math.round(n / 1024 ** 2)} MB`;
3691
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`;
3692
+ return `${n} B`;
3693
+ }
3694
+ function groupByDir(entries, limit = 5) {
3695
+ const acc = /* @__PURE__ */ new Map();
3696
+ for (const [file, bytes] of entries) {
3697
+ const dirParts = file.split("/").slice(0, -1).slice(0, 2);
3698
+ const dir = dirParts.length > 0 ? dirParts.join("/") : "(root)";
3699
+ const row = acc.get(dir) ?? { dir, files: 0, bytes: 0 };
3700
+ row.files += 1;
3701
+ row.bytes += bytes;
3702
+ acc.set(dir, row);
3223
3703
  }
3224
- let external = 0;
3225
- const unresolved = [];
3226
- for (const url of scan.genexAssetUrls) {
3227
- const role = url.split("/").pop() ?? "";
3228
- if (MOBILE_RUNG_SUFFIX_RE.test(role)) continue;
3229
- const dims = externalDims.get(url);
3230
- if (dims) {
3231
- const ktx2 = dims.ktx2 || dims.ktx2Available === true && scan.markers.ktx2Loader;
3232
- if (dims.glb) {
3233
- external += textureBytes(dims.width, dims.height, ktx2) + (dims.bytes ?? 0);
3234
- maxDim = Math.max(maxDim, dims.width);
3235
- } else {
3236
- external += textureBytes(dims.width, dims.height, ktx2);
3237
- maxDim = Math.max(maxDim, dims.width, dims.height);
3238
- }
3239
- } else {
3240
- unresolved.push(url);
3241
- external += role === SKYBOX_ROLE ? WORST_SKYBOX_BYTES : WORST_IMAGE_BYTES;
3242
- maxDim = Math.max(maxDim, role === SKYBOX_ROLE ? 8192 : 2048);
3704
+ return [...acc.values()].sort((a, b) => b.bytes - a.bytes).slice(0, limit);
3705
+ }
3706
+ function oversizedLines(p, limits) {
3707
+ return [
3708
+ `This is ${formatBytes2(p.bytes)} across ${p.files} files, past the ${formatBytes2(limits.maxTotalBytes)} / ${limits.maxFiles} file limit on this account.`,
3709
+ "Heaviest directories:",
3710
+ ...p.top.map((d) => ` ${formatBytes2(d.bytes).padStart(8)} ${String(d.files).padStart(6)} files ${d.dir}`),
3711
+ "Screenshots, traces and reports made while building are not part of the game \u2014",
3712
+ "put them in .genex/scratch/ (already ignored), or add the folders above to .gitignore.",
3713
+ "If this really is the game, ask an admin to raise the source limit on your account."
3714
+ ];
3715
+ }
3716
+ async function measurePayload(cwd, staged) {
3717
+ const entries = [];
3718
+ let bytes = 0;
3719
+ for (const file of staged) {
3720
+ let size = 0;
3721
+ try {
3722
+ size = (await fs14.stat(path15.join(cwd, file))).size;
3723
+ } catch {
3724
+ size = 0;
3243
3725
  }
3726
+ bytes += size;
3727
+ entries.push([file, size]);
3244
3728
  }
3245
- const dpr = Math.min(scan.markers.dprCapLiteral ?? 2, 3);
3246
- const surface = CSS_W * CSS_H * dpr * dpr * 4;
3247
- const framebuffers = surface * (3 + (scan.markers.postStack ? 4 : 0));
3248
- const shadow = 2048 * 2048 * 4;
3249
- const total = bundled + glbTex + external + framebuffers + shadow + geometry;
3250
- return {
3251
- estVramMb: Math.round(total / MB),
3252
- breakdown: {
3253
- bundledTexturesMb: Math.round(bundled / MB),
3254
- glbTexturesMb: Math.round(glbTex / MB),
3255
- externalTexturesMb: Math.round(external / MB),
3256
- framebuffersMb: Math.round(framebuffers / MB),
3257
- shadowDefaultMb: Math.round(shadow / MB),
3258
- geometryMb: Math.round(geometry / MB)
3259
- },
3260
- unresolvedExternals: unresolved,
3261
- textureMaxDim: maxDim
3262
- };
3263
- }
3264
- function tierFor(estVramMb) {
3265
- if (estVramMb < MOBILE_BUDGET_MB.broad) return "mobile-broad";
3266
- if (estVramMb < MOBILE_BUDGET_MB.modern) return "mobile-modern";
3267
- return "over-budget";
3729
+ return { files: staged.length, bytes, top: groupByDir(entries) };
3268
3730
  }
3269
3731
 
3270
3732
  // src/commands/ui.ts
@@ -5093,9 +5555,25 @@ async function uiAudit(opts, log) {
5093
5555
  }
5094
5556
 
5095
5557
  // src/lib/deploy.ts
5558
+ function goLiveCapabilities(declared, scanned) {
5559
+ const own = knownCapabilities(declared ?? []);
5560
+ if (scanned === void 0) return own.length > 0 ? own : void 0;
5561
+ return knownCapabilities([...own, ...scanned]);
5562
+ }
5563
+ function printCapabilityPreflight(capabilities, log) {
5564
+ if (capabilities === void 0) return;
5565
+ if (capabilities.length > 0) {
5566
+ log.info(`On its Genex page this build is given: ${capabilities.join(", ")}.`);
5567
+ } else {
5568
+ log.dim(
5569
+ ` On its Genex page this build is given nothing that needs a permission (${KNOWN_CAPABILITIES.join(", ")}).`
5570
+ );
5571
+ }
5572
+ }
5096
5573
  function printMobilePreflight(files, log) {
5574
+ let scan = null;
5097
5575
  try {
5098
- const scan = scanBundle(files.map((f) => ({ relPath: f.relPath, bytes: f.bytes })));
5576
+ scan = scanBundle(files.map((f) => ({ relPath: f.relPath, bytes: f.bytes })));
5099
5577
  const externalDims = /* @__PURE__ */ new Map();
5100
5578
  for (const url of scan.genexAssetUrls) {
5101
5579
  const role = url.split("/").pop() ?? "";
@@ -5112,7 +5590,7 @@ function printMobilePreflight(files, log) {
5112
5590
  const line = `Mobile preflight: ~${est.estVramMb} MB est. GPU memory on phones (budget: <${MOBILE_BUDGET_MB.broad} MB broad, <${MOBILE_BUDGET_MB.modern} MB modern) \u2014 ${tier}.`;
5113
5591
  if (tier === "mobile-broad") {
5114
5592
  log.dim(` ${line}`);
5115
- return;
5593
+ return scan;
5116
5594
  }
5117
5595
  log.warn(line);
5118
5596
  for (const url of est.unresolvedExternals) {
@@ -5140,6 +5618,7 @@ function printMobilePreflight(files, log) {
5140
5618
  }
5141
5619
  } catch {
5142
5620
  }
5621
+ return scan;
5143
5622
  }
5144
5623
  async function printUiAuditPreflight(log) {
5145
5624
  try {
@@ -5246,7 +5725,9 @@ async function deployGame(ctx, opts, log) {
5246
5725
  if (lfsPointers.length > 8) log.dim(` \u2026and ${lfsPointers.length - 8} more`);
5247
5726
  return false;
5248
5727
  }
5249
- printMobilePreflight(files, log);
5728
+ const scan = printMobilePreflight(files, log);
5729
+ const capabilities = scan?.capabilitiesIncomplete ? goLiveCapabilities([...opts.capabilities ?? [], ...scan.capabilities], void 0) : goLiveCapabilities(opts.capabilities, scan?.capabilities);
5730
+ printCapabilityPreflight(capabilities, log);
5250
5731
  await printUiAuditPreflight(log);
5251
5732
  if (await workspaceMode(cwd) === "game") await printPipelineStatePreflight(log);
5252
5733
  const commit = contentCommit(files);
@@ -5293,7 +5774,7 @@ async function deployGame(ctx, opts, log) {
5293
5774
  );
5294
5775
  if (pushed !== true) return false;
5295
5776
  log.step("Publishing\u2026");
5296
- const published = await callPublish(ctx, commit, opts, log, sourceCommitSha);
5777
+ const published = await callPublish(ctx, commit, opts, log, sourceCommitSha, capabilities);
5297
5778
  if (!published) return false;
5298
5779
  if (opts.channel === "staging") {
5299
5780
  const tree = await sourceTreeHash(cwd);
@@ -5515,7 +5996,7 @@ async function uploadPartWithRetry(base, encoded, auth, uploadId, partNumber, by
5515
5996
  await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? 500 : 2e3));
5516
5997
  }
5517
5998
  }
5518
- async function callPublish(ctx, commit, opts, log, sourceCommitSha) {
5999
+ async function callPublish(ctx, commit, opts, log, sourceCommitSha, capabilities) {
5519
6000
  let res;
5520
6001
  try {
5521
6002
  res = await apiFetch(`${ctx.apiUrl}/api/games/${ctx.projectId}/publish`, {
@@ -5531,6 +6012,11 @@ async function callPublish(ctx, commit, opts, log, sourceCommitSha) {
5531
6012
  ...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
5532
6013
  ...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {},
5533
6014
  ...opts.mobileControls !== void 0 ? { mobileControls: opts.mobileControls } : {},
6015
+ // The build's declared capabilities, on both channels: a preview's list
6016
+ // is what the owner's own preview frame is given, and a promotion
6017
+ // carries it to production with the build. An API from before the
6018
+ // field strips it, so the two can deploy in either order.
6019
+ ...capabilities !== void 0 ? { capabilities } : {},
5534
6020
  // Recorded into the release history and acted on by nothing. Absent when
5535
6021
  // the source push produced no commit (a user's own repo, or a failure
5536
6022
  // that already stopped the deploy), which the server accepts as null.
@@ -6147,6 +6633,9 @@ async function runPreview(opts) {
6147
6633
  // declared preset doesn't silently run the default (null clears a removed
6148
6634
  // config), embedSdkVersion/multiplayer so the dashboard's Publish button can
6149
6635
  // list the draft without wrongly treating it as a pre-embed-auth bundle.
6636
+ // Capabilities ride the preview for a different reason: the staging build's
6637
+ // own list is what the owner's preview frame is given, so a microphone or
6638
+ // camera can be tried before anything is published.
6150
6639
  {
6151
6640
  // Preview goes to the STAGING channel — the whole point of the release
6152
6641
  // cycle. Before channels this same call replaced the build every player
@@ -6157,7 +6646,8 @@ async function runPreview(opts) {
6157
6646
  matchmaking: detections.matchmaking,
6158
6647
  embedSdkVersion: detections.embedSdkVersion,
6159
6648
  multiplayer: detections.multiplayer,
6160
- mobileControls: detections.mobileControls
6649
+ mobileControls: detections.mobileControls,
6650
+ capabilities: detections.capabilities
6161
6651
  },
6162
6652
  log
6163
6653
  );
@@ -7968,7 +8458,10 @@ async function runPublish(opts) {
7968
8458
  matchmaking: detections.matchmaking,
7969
8459
  embedSdkVersion: detections.embedSdkVersion,
7970
8460
  multiplayer: detections.multiplayer,
7971
- mobileControls: detections.mobileControls
8461
+ mobileControls: detections.mobileControls,
8462
+ // Declared on the staging go-live; the promotion below copies that
8463
+ // build's own list to production with it.
8464
+ capabilities: detections.capabilities
7972
8465
  },
7973
8466
  log
7974
8467
  );