@genex-ai/cli-demo 0.69.0-dev.175 → 0.71.0-dev.186

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/dist/index.js +365 -14
  2. package/package.json +2 -1
  3. package/templates/controllers/character/vrm/vrm-loader.ts +74 -11
  4. package/templates/controllers/quality/governor.ts +147 -0
  5. package/templates/controllers/quality/pick-asset.ts +57 -0
  6. package/templates/controllers/quality/tier.ts +170 -0
  7. package/templates/skills/genex-ai-hud/SKILL.md +8 -2
  8. package/templates/skills/genex-ai-menu/SKILL.md +14 -2
  9. package/templates/skills/genex-ai-skybox/SKILL.md +14 -3
  10. package/templates/skills/genex-threejs-adaptive-quality/SKILL.md +141 -0
  11. package/templates/skills/genex-threejs-adaptive-quality/references/adaptive-quality.md +105 -0
  12. package/templates/skills/genex-threejs-bloom/SKILL.md +4 -1
  13. package/templates/skills/genex-threejs-bloom/references/bloom.md +1 -1
  14. package/templates/skills/genex-threejs-embed-auth/SKILL.md +4 -1
  15. package/templates/skills/genex-threejs-game-ui/SKILL.md +56 -24
  16. package/templates/skills/genex-threejs-game-ui/references/style-capsules.md +2 -1
  17. package/templates/skills/genex-threejs-image-pipeline/SKILL.md +5 -0
  18. package/templates/skills/genex-threejs-image-pipeline/references/image-pipeline.md +1 -1
  19. package/templates/skills/genex-threejs-lighting-design/SKILL.md +5 -1
  20. package/templates/skills/genex-threejs-multiplayer/SKILL.md +7 -1
  21. package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +6 -3
  22. package/templates/skills/genex-threejs-physics-rapier/references/colliders-from-assets.md +1 -0
  23. package/templates/skills/genex-threejs-screen-space-ambient-occlusion/references/ambient-occlusion.md +1 -1
  24. package/templates/skills/genex-threejs-shadow-systems/SKILL.md +6 -0
  25. package/templates/skills/genex-threejs-shadow-systems/references/shadow-systems.md +1 -1
  26. package/templates/skills/genex-threejs-skill-router/SKILL.md +14 -1
  27. package/templates/skills/genex-threejs-skill-router/references/routing-map.md +20 -7
  28. package/templates/skills/genex-threejs-spectral-ocean/references/spectral-ocean.md +1 -1
  29. package/templates/skills/genex-threejs-touch-controls/SKILL.md +7 -0
  30. package/templates/skills/genex-threejs-water-optics/references/water-optics.md +1 -1
package/dist/index.js CHANGED
@@ -1088,22 +1088,27 @@ var DEFAULT_DASHBOARD_ORIGIN = new URL(DEFAULT_AUTH_URL).origin;
1088
1088
  function renderGenexConfig() {
1089
1089
  return `// src/genex.config.ts \u2014 written by \`genex init\`. DO NOT hardcode URLs here.
1090
1090
  //
1091
- // One build runs on BOTH stands: the serving hostname picks the stack at runtime
1092
- // (\`.auras.cc\` -> dev, else prod), so promoting a game is a copy, never a rebuild.
1091
+ // Build once, run anywhere. The games host injects the serving environment into
1092
+ // window.__GENEX__ (a small inline script in the no-cache page, ahead of this
1093
+ // bundle), so ONE build is correct on every stand \u2014 promoting a game is a copy,
1094
+ // never a rebuild, and an immutable bundle can never pin the wrong environment.
1093
1095
  // Vite env still overrides, but is loaded ONLY by \`npm run dev\`:
1094
1096
  // .env -> VITE_GENEX_SLUG (this game's identity; committed)
1095
1097
  // .env.development.local -> local-stack URL overrides (dev mode ONLY; gitignored)
1096
- const IS_DEV =
1097
- typeof location !== "undefined" &&
1098
- location.hostname.endsWith(".auras.cc");
1098
+ const injected: { apiUrl?: string; dashboardOrigins?: string[] } =
1099
+ (typeof window !== "undefined" && (window as { __GENEX__?: unknown }).__GENEX__) as
1100
+ | { apiUrl?: string; dashboardOrigins?: string[] }
1101
+ | undefined ?? {};
1099
1102
  export const GENEX = {
1100
1103
  slug: import.meta.env.VITE_GENEX_SLUG as string,
1101
1104
  apiUrl:
1102
1105
  (import.meta.env.VITE_GENEX_API_URL as string | undefined) ??
1103
- (IS_DEV ? "${STANDS.dev.api}" : "${STANDS.prod.api}"),
1106
+ injected.apiUrl ??
1107
+ "${STANDS.prod.api}",
1104
1108
  dashboardOrigins: (
1105
1109
  (import.meta.env.VITE_GENEX_DASHBOARD_ORIGINS as string | undefined) ??
1106
- (IS_DEV ? "${STANDS.dev.dashboard}" : "${STANDS.prod.dashboard}")
1110
+ injected.dashboardOrigins?.join(",") ??
1111
+ "${STANDS.prod.dashboard}"
1107
1112
  ).split(","),
1108
1113
  } as const;
1109
1114
  `;
@@ -1492,6 +1497,261 @@ import crypto3 from "crypto";
1492
1497
  import fs9 from "fs/promises";
1493
1498
  import os4 from "os";
1494
1499
  import path10 from "path";
1500
+
1501
+ // ../../packages/mobile-scan/src/image-dims.ts
1502
+ function u32be(b, o) {
1503
+ return (b[o] << 24 | b[o + 1] << 16 | b[o + 2] << 8 | b[o + 3]) >>> 0;
1504
+ }
1505
+ function u32le(b, o) {
1506
+ return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
1507
+ }
1508
+ function u16be(b, o) {
1509
+ return b[o] << 8 | b[o + 1];
1510
+ }
1511
+ function u16le(b, o) {
1512
+ return b[o] | b[o + 1] << 8;
1513
+ }
1514
+ function isPng(b) {
1515
+ 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;
1516
+ }
1517
+ var KTX2_MAGIC = [171, 75, 84, 88, 32, 50, 48, 187, 13, 10, 26, 10];
1518
+ function parseImageDims(bytes) {
1519
+ if (bytes.length < 30) return null;
1520
+ if (isPng(bytes)) {
1521
+ return { width: u32be(bytes, 16), height: u32be(bytes, 20) };
1522
+ }
1523
+ if (bytes[0] === 255 && bytes[1] === 216) {
1524
+ let i = 2;
1525
+ while (i + 9 < bytes.length) {
1526
+ if (bytes[i] !== 255) {
1527
+ i++;
1528
+ continue;
1529
+ }
1530
+ const marker = bytes[i + 1];
1531
+ if (marker === 255) {
1532
+ i++;
1533
+ continue;
1534
+ }
1535
+ if (marker === 216 || marker >= 208 && marker <= 217) {
1536
+ i += 2;
1537
+ continue;
1538
+ }
1539
+ const isSof = marker >= 192 && marker <= 195 || marker >= 197 && marker <= 199 || marker >= 201 && marker <= 203 || marker >= 205 && marker <= 207;
1540
+ if (isSof) {
1541
+ return { width: u16be(bytes, i + 7), height: u16be(bytes, i + 5) };
1542
+ }
1543
+ i += 2 + u16be(bytes, i + 2);
1544
+ }
1545
+ return null;
1546
+ }
1547
+ if (bytes[0] === 82 && // RIFF….WEBP
1548
+ bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) {
1549
+ const fourcc = String.fromCharCode(bytes[12], bytes[13], bytes[14], bytes[15]);
1550
+ if (fourcc === "VP8X" && bytes.length >= 30) {
1551
+ const w = 1 + (bytes[24] | bytes[25] << 8 | bytes[26] << 16);
1552
+ const h = 1 + (bytes[27] | bytes[28] << 8 | bytes[29] << 16);
1553
+ return { width: w, height: h };
1554
+ }
1555
+ if (fourcc === "VP8 " && bytes.length >= 30) {
1556
+ return { width: u16le(bytes, 26) & 16383, height: u16le(bytes, 28) & 16383 };
1557
+ }
1558
+ if (fourcc === "VP8L" && bytes.length >= 25 && bytes[20] === 47) {
1559
+ const b0 = bytes[21];
1560
+ const b1 = bytes[22];
1561
+ const b2 = bytes[23];
1562
+ const b3 = bytes[24];
1563
+ const width = 1 + ((b1 & 63) << 8 | b0);
1564
+ const height = 1 + ((b3 & 15) << 10 | b2 << 2 | (b1 & 192) >> 6);
1565
+ return { width, height };
1566
+ }
1567
+ return null;
1568
+ }
1569
+ if (KTX2_MAGIC.every((v, idx) => bytes[idx] === v)) {
1570
+ return { width: u32le(bytes, 20), height: u32le(bytes, 24), ktx2: true };
1571
+ }
1572
+ return null;
1573
+ }
1574
+
1575
+ // ../../packages/mobile-scan/src/glb.ts
1576
+ function u32le2(b, o) {
1577
+ return (b[o] | b[o + 1] << 8 | b[o + 2] << 16 | b[o + 3] << 24) >>> 0;
1578
+ }
1579
+ function scanGlb(bytes) {
1580
+ if (bytes.length < 20 || u32le2(bytes, 0) !== 1179937895) return null;
1581
+ try {
1582
+ const jsonLen = u32le2(bytes, 12);
1583
+ if (u32le2(bytes, 16) !== 1313821514) return null;
1584
+ const json = JSON.parse(new TextDecoder().decode(bytes.subarray(20, 20 + jsonLen)));
1585
+ let bin = null;
1586
+ const binHeader = 20 + jsonLen;
1587
+ if (binHeader + 8 <= bytes.length && u32le2(bytes, binHeader + 4) === 5130562) {
1588
+ bin = bytes.subarray(binHeader + 8, binHeader + 8 + u32le2(bytes, binHeader));
1589
+ }
1590
+ const images = [];
1591
+ for (const img of json.images ?? []) {
1592
+ if (img.bufferView == null || !bin) continue;
1593
+ const view = json.bufferViews?.[img.bufferView];
1594
+ if (!view?.byteLength) continue;
1595
+ const slice = bin.subarray(view.byteOffset ?? 0, (view.byteOffset ?? 0) + view.byteLength);
1596
+ const dims = parseImageDims(slice);
1597
+ if (dims) images.push({ ...dims, mimeType: img.mimeType, bytes: view.byteLength });
1598
+ }
1599
+ let triangles = 0;
1600
+ for (const mesh of json.meshes ?? []) {
1601
+ for (const prim of mesh.primitives ?? []) {
1602
+ const accessor = prim.indices != null ? json.accessors?.[prim.indices] : json.accessors?.[prim.attributes?.POSITION ?? -1];
1603
+ if (accessor?.count) triangles += Math.floor(accessor.count / 3);
1604
+ }
1605
+ }
1606
+ return {
1607
+ triangles,
1608
+ binBytes: bin?.byteLength ?? 0,
1609
+ images,
1610
+ extensions: json.extensionsUsed ?? []
1611
+ };
1612
+ } catch {
1613
+ return null;
1614
+ }
1615
+ }
1616
+
1617
+ // ../../packages/mobile-scan/src/scan.ts
1618
+ var GENEX_GENERATION_URL_RE = /https:\/\/assets\.genex\.technology\/generations\/([A-Za-z0-9._-]+)\/([A-Za-z0-9._@-]+)/g;
1619
+ var SKYBOX_ROLE = "skybox-equirect";
1620
+ var MOBILE_RUNG_SUFFIX_RE = /@(\d+)$/;
1621
+ var MOBILE_BUDGET_MB = { broad: 300, modern: 700 };
1622
+ var TEXT_EXT = /\.(js|mjs|cjs|html|css|json|txt)$/i;
1623
+ var IMAGE_EXT = /\.(png|jpe?g|webp|ktx2)$/i;
1624
+ var VIDEO_EXT = /\.(mp4|webm|mov)$/i;
1625
+ var AUDIO_EXT = /\.(mp3|ogg|wav|m4a)$/i;
1626
+ var GLB_EXT = /\.(glb|gltf)$/i;
1627
+ function scanBundle(files) {
1628
+ const images = [];
1629
+ const glbs = [];
1630
+ const videos = [];
1631
+ const audio = [];
1632
+ const urls = /* @__PURE__ */ new Set();
1633
+ let text = "";
1634
+ let totalBytes = 0;
1635
+ for (const f of files) {
1636
+ totalBytes += f.bytes.byteLength;
1637
+ if (IMAGE_EXT.test(f.relPath)) {
1638
+ const dims = parseImageDims(f.bytes);
1639
+ if (dims) images.push({ path: f.relPath, bytes: f.bytes.byteLength, ...dims });
1640
+ continue;
1641
+ }
1642
+ if (GLB_EXT.test(f.relPath)) {
1643
+ glbs.push({ path: f.relPath, bytes: f.bytes.byteLength, scan: scanGlb(f.bytes) });
1644
+ continue;
1645
+ }
1646
+ if (VIDEO_EXT.test(f.relPath)) {
1647
+ videos.push({ path: f.relPath, bytes: f.bytes.byteLength });
1648
+ continue;
1649
+ }
1650
+ if (AUDIO_EXT.test(f.relPath)) {
1651
+ audio.push({ path: f.relPath, bytes: f.bytes.byteLength });
1652
+ continue;
1653
+ }
1654
+ if (TEXT_EXT.test(f.relPath)) {
1655
+ text += new TextDecoder().decode(f.bytes.subarray(0, 8 * 1024 * 1024));
1656
+ text += "\n";
1657
+ }
1658
+ }
1659
+ for (const m of text.matchAll(GENEX_GENERATION_URL_RE)) urls.add(m[0]);
1660
+ const dprMatch = text.match(/setPixelRatio\(\s*Math\.min\([^,)]+,\s*([\d.]+)\s*\)/);
1661
+ const markers = {
1662
+ rendererBackend: text.includes("WebGPURenderer") || text.includes("getContext('webgpu')") || text.includes('"webgpu"') ? "webgpu" : "webgl",
1663
+ postStack: /EffectComposer|postprocessing|PostProcessing|RenderPipeline/.test(text),
1664
+ dprCapLiteral: dprMatch?.[1] ? Number(dprMatch[1]) : null,
1665
+ contextLossHandler: text.includes("webglcontextlost") || text.includes("device.lost"),
1666
+ ktx2Loader: text.includes("KTX2Loader") || text.includes(".ktx2"),
1667
+ touchHints: /joystick/i.test(text) || text.includes("touchstart") && text.includes("touch-action"),
1668
+ adaptiveQuality: text.includes("__GENEX_QUALITY__")
1669
+ };
1670
+ return {
1671
+ images,
1672
+ glbs,
1673
+ videos,
1674
+ audio,
1675
+ genexAssetUrls: [...urls],
1676
+ markers,
1677
+ totalBytes,
1678
+ fileCount: files.length
1679
+ };
1680
+ }
1681
+
1682
+ // ../../packages/mobile-scan/src/estimate.ts
1683
+ var MB = 1024 * 1024;
1684
+ var CSS_W = 430;
1685
+ var CSS_H = 932;
1686
+ var WORST_SKYBOX_BYTES = 8192 * 4096 * 4 * 1.33;
1687
+ var WORST_IMAGE_BYTES = 2048 * 2048 * 4 * 1.33;
1688
+ var KTX2_DISCOUNT = 6;
1689
+ function textureBytes(w, h, ktx2) {
1690
+ const raw = w * h * 4 * 1.33;
1691
+ return ktx2 ? raw / KTX2_DISCOUNT : raw;
1692
+ }
1693
+ function estimateVram(scan, externalDims) {
1694
+ let bundled = 0;
1695
+ let maxDim = 0;
1696
+ for (const img of scan.images) {
1697
+ bundled += textureBytes(img.width, img.height, img.ktx2);
1698
+ maxDim = Math.max(maxDim, img.width, img.height);
1699
+ }
1700
+ let glbTex = 0;
1701
+ let geometry = 0;
1702
+ for (const glb of scan.glbs) {
1703
+ if (!glb.scan) {
1704
+ geometry += glb.bytes;
1705
+ continue;
1706
+ }
1707
+ const ktx2 = glb.scan.extensions.some((e) => e.includes("KHR_texture_basisu"));
1708
+ for (const img of glb.scan.images) {
1709
+ glbTex += textureBytes(img.width, img.height, img.ktx2 || ktx2);
1710
+ maxDim = Math.max(maxDim, img.width, img.height);
1711
+ }
1712
+ geometry += Math.max(0, glb.scan.binBytes - glb.scan.images.reduce((s, i) => s + i.bytes, 0));
1713
+ }
1714
+ let external = 0;
1715
+ const unresolved = [];
1716
+ for (const url of scan.genexAssetUrls) {
1717
+ const role = url.split("/").pop() ?? "";
1718
+ if (MOBILE_RUNG_SUFFIX_RE.test(role)) continue;
1719
+ const dims = externalDims.get(url);
1720
+ if (dims) {
1721
+ external += textureBytes(dims.width, dims.height, dims.ktx2);
1722
+ maxDim = Math.max(maxDim, dims.width, dims.height);
1723
+ } else {
1724
+ unresolved.push(url);
1725
+ external += role === SKYBOX_ROLE ? WORST_SKYBOX_BYTES : WORST_IMAGE_BYTES;
1726
+ maxDim = Math.max(maxDim, role === SKYBOX_ROLE ? 8192 : 2048);
1727
+ }
1728
+ }
1729
+ const dpr = Math.min(scan.markers.dprCapLiteral ?? 2, 3);
1730
+ const surface = CSS_W * CSS_H * dpr * dpr * 4;
1731
+ const framebuffers = surface * (3 + (scan.markers.postStack ? 4 : 0));
1732
+ const shadow = 2048 * 2048 * 4;
1733
+ const total = bundled + glbTex + external + framebuffers + shadow + geometry;
1734
+ return {
1735
+ estVramMb: Math.round(total / MB),
1736
+ breakdown: {
1737
+ bundledTexturesMb: Math.round(bundled / MB),
1738
+ glbTexturesMb: Math.round(glbTex / MB),
1739
+ externalTexturesMb: Math.round(external / MB),
1740
+ framebuffersMb: Math.round(framebuffers / MB),
1741
+ shadowDefaultMb: Math.round(shadow / MB),
1742
+ geometryMb: Math.round(geometry / MB)
1743
+ },
1744
+ unresolvedExternals: unresolved,
1745
+ textureMaxDim: maxDim
1746
+ };
1747
+ }
1748
+ function tierFor(estVramMb) {
1749
+ if (estVramMb < MOBILE_BUDGET_MB.broad) return "mobile-broad";
1750
+ if (estVramMb < MOBILE_BUDGET_MB.modern) return "mobile-modern";
1751
+ return "over-budget";
1752
+ }
1753
+
1754
+ // src/lib/deploy.ts
1495
1755
  function run(cmd, args, env) {
1496
1756
  return new Promise((resolve) => {
1497
1757
  let child;
@@ -1509,6 +1769,48 @@ function run(cmd, args, env) {
1509
1769
  child.on("close", (code2) => resolve({ code: code2 ?? -1, out, err }));
1510
1770
  });
1511
1771
  }
1772
+ function printMobilePreflight(files, log) {
1773
+ try {
1774
+ const scan = scanBundle(files.map((f) => ({ relPath: f.relPath, bytes: f.bytes })));
1775
+ const externalDims = /* @__PURE__ */ new Map();
1776
+ for (const url of scan.genexAssetUrls) {
1777
+ const role = url.split("/").pop() ?? "";
1778
+ if (scan.markers.adaptiveQuality && role === SKYBOX_ROLE) {
1779
+ externalDims.set(url, { width: 2048, height: 1024 });
1780
+ } else {
1781
+ externalDims.set(url, null);
1782
+ }
1783
+ }
1784
+ const est = estimateVram(scan, externalDims);
1785
+ const tier = tierFor(est.estVramMb);
1786
+ 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}.`;
1787
+ if (tier === "mobile-broad") {
1788
+ log.dim(` ${line}`);
1789
+ return;
1790
+ }
1791
+ log.warn(line);
1792
+ for (const url of est.unresolvedExternals) {
1793
+ const role = url.split("/").pop() ?? "";
1794
+ if (role === SKYBOX_ROLE) {
1795
+ log.warn(
1796
+ ` Skybox loaded full-size (${url.slice(0, 80)}\u2026): ~178 MB decoded on phones. Load it through pickAsset (genex-threejs-adaptive-quality) so phones get the @2048 rung.`
1797
+ );
1798
+ }
1799
+ }
1800
+ if (est.textureMaxDim > 2048) {
1801
+ log.warn(` Largest texture is ${est.textureMaxDim}px \u2014 phones want \u22642048 (props \u22641024).`);
1802
+ }
1803
+ if (scan.markers.postStack && scan.markers.dprCapLiteral == null) {
1804
+ log.warn(
1805
+ " Post stack detected with no devicePixelRatio cap \u2014 cap at 1.5 on phones (genex-threejs-adaptive-quality)."
1806
+ );
1807
+ }
1808
+ if (!scan.markers.contextLossHandler) {
1809
+ log.warn(" No context-loss handler found \u2014 one GPU reset shows a dead canvas on phones.");
1810
+ }
1811
+ } catch {
1812
+ }
1813
+ }
1512
1814
  var EXCLUDE_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".genex", "dist"]);
1513
1815
  function isSecretEnvFile(name) {
1514
1816
  return name === ".env" || name.startsWith(".env.") && name !== ".env.example";
@@ -1539,6 +1841,7 @@ async function deployGame(ctx, opts, log) {
1539
1841
  log.error(`No index.html in ${rel} \u2014 a game needs one to serve. Nothing deployed.`);
1540
1842
  return false;
1541
1843
  }
1844
+ printMobilePreflight(files, log);
1542
1845
  const commit = contentCommit(files);
1543
1846
  log.step("Requesting an upload token\u2026");
1544
1847
  const grant = await getUploadToken(ctx, commit, log);
@@ -1682,7 +1985,8 @@ async function callPublish(ctx, commit, opts, log) {
1682
1985
  commit,
1683
1986
  matchmaking: opts.matchmaking ?? null,
1684
1987
  ...opts.embedSdkVersion ? { embedSdkVersion: opts.embedSdkVersion } : {},
1685
- ...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {}
1988
+ ...opts.multiplayer !== void 0 ? { multiplayer: opts.multiplayer } : {},
1989
+ ...opts.mobileControls !== void 0 ? { mobileControls: opts.mobileControls } : {}
1686
1990
  })
1687
1991
  });
1688
1992
  } catch (err) {
@@ -2028,6 +2332,32 @@ async function detectMatchmaking(log, cwd = process.cwd()) {
2028
2332
  }
2029
2333
  return null;
2030
2334
  }
2335
+ var TOUCH_KIT_MARKERS = /controllers\/touch\/|controllers\/character\/touch-joystick|TouchJoystick|VirtualButton|DragZone|RotateOverlay/;
2336
+ async function detectMobileControls(cwd = process.cwd()) {
2337
+ try {
2338
+ const raw = await fs11.readFile(path12.join(cwd, "package.json"), "utf8");
2339
+ const pkg = JSON.parse(raw);
2340
+ if (pkg.genex?.mobileControls === true) return true;
2341
+ } catch {
2342
+ }
2343
+ const srcDir = path12.join(cwd, "src");
2344
+ let entries;
2345
+ try {
2346
+ entries = await fs11.readdir(srcDir, { recursive: true });
2347
+ } catch {
2348
+ return false;
2349
+ }
2350
+ for (const rel of entries) {
2351
+ if (rel.includes("node_modules")) continue;
2352
+ if (!/\.(ts|js|mts|mjs|tsx|jsx)$/.test(rel)) continue;
2353
+ try {
2354
+ const content = await fs11.readFile(path12.join(srcDir, rel), "utf8");
2355
+ if (TOUCH_KIT_MARKERS.test(content)) return true;
2356
+ } catch {
2357
+ }
2358
+ }
2359
+ return false;
2360
+ }
2031
2361
  var GAME_STATE_CALLS = /savePlayerState|saveWorldState|submitScore/;
2032
2362
  async function detectGameStateUsage(cwd = process.cwd()) {
2033
2363
  const srcDir = path12.join(cwd, "src");
@@ -2212,6 +2542,7 @@ async function detectFeatures(log, cwd = process.cwd()) {
2212
2542
  multiplayer: await detectMultiplayer(cwd),
2213
2543
  matchmaking: await detectMatchmaking(log, cwd),
2214
2544
  gameStateUsed: await detectGameStateUsage(cwd),
2545
+ mobileControls: await detectMobileControls(cwd),
2215
2546
  uiPhases: await detectUiPhases(cwd),
2216
2547
  surfaces: await detectSurfaceScan(cwd),
2217
2548
  generations: await detectGenerationAudit(cwd)
@@ -2355,7 +2686,8 @@ async function runPublish(opts) {
2355
2686
  noBuild: opts.noBuild,
2356
2687
  matchmaking: detections.matchmaking,
2357
2688
  embedSdkVersion: detections.embedSdkVersion,
2358
- multiplayer: detections.multiplayer
2689
+ multiplayer: detections.multiplayer,
2690
+ mobileControls: detections.mobileControls
2359
2691
  },
2360
2692
  log
2361
2693
  );
@@ -2387,6 +2719,7 @@ async function runPublish(opts) {
2387
2719
  body.multiplayer = detections.multiplayer;
2388
2720
  body.matchmaking = detections.matchmaking ?? null;
2389
2721
  body.gameStateUsed = detections.gameStateUsed;
2722
+ body.mobileControls = detections.mobileControls;
2390
2723
  res = await apiFetch(`${apiUrl}/api/projects/${meta.id}/publish`, {
2391
2724
  method: "POST",
2392
2725
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
@@ -2440,7 +2773,8 @@ async function runPreview(opts) {
2440
2773
  noBuild: opts.noBuild,
2441
2774
  matchmaking: detections.matchmaking,
2442
2775
  embedSdkVersion: detections.embedSdkVersion,
2443
- multiplayer: detections.multiplayer
2776
+ multiplayer: detections.multiplayer,
2777
+ mobileControls: detections.mobileControls
2444
2778
  },
2445
2779
  log
2446
2780
  );
@@ -12689,7 +13023,8 @@ var CONTROLLER_KINDS = [
12689
13023
  "car",
12690
13024
  "drone",
12691
13025
  "touch",
12692
- "networked-physics"
13026
+ "networked-physics",
13027
+ "quality"
12693
13028
  ];
12694
13029
  var SHARED = [
12695
13030
  "shared/math.ts",
@@ -12782,6 +13117,17 @@ var CONTROLLER_FILE_SETS = {
12782
13117
  `physics.onBeforeStep(() => { drone.setMovement(keyboard.getDroneMovement()); drone.update(); });`
12783
13118
  ]
12784
13119
  },
13120
+ quality: {
13121
+ code: ["quality/tier.ts", "quality/governor.ts", "quality/pick-asset.ts"],
13122
+ assets: [],
13123
+ skill: "genex-threejs-adaptive-quality",
13124
+ sketch: [
13125
+ `const tier = detectTier(); // phone-low | phone | desktop \u2014 manual Quality setting wins`,
13126
+ `renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.dprCap)); // antialias: tier.antialias at creation`,
13127
+ `const gov = new QualityGovernor(tier, { setDprScale: (m) => renderer.setPixelRatio(Math.min(window.devicePixelRatio, tier.dprCap * m)) }, renderer);`,
13128
+ `// per frame: gov.frame(deltaMs); skybox: loadTextureWithFallback(SKYBOX_URL, tier, (u) => loader.loadAsync(u))`
13129
+ ]
13130
+ },
12785
13131
  touch: {
12786
13132
  code: [...TOUCH_KIT, NOTICE],
12787
13133
  assets: [],
@@ -13673,7 +14019,10 @@ async function runExplore(opts) {
13673
14019
  const meta = [
13674
14020
  p.categories.join(", "),
13675
14021
  p.license ?? "",
13676
- p.sourceAuthor ? `originally by ${p.sourceAuthor}` : ""
14022
+ p.sourceAuthor ? `originally by ${p.sourceAuthor}` : "",
14023
+ // Reuse signal for mobile-targeted games: cloning a desktop-only/heavy
14024
+ // system into a phone game imports its memory profile too.
14025
+ p.mobileReadiness && p.mobileReadiness.state !== "unknown" ? `[mobile: ${p.mobileReadiness.state}]` : ""
13677
14026
  ].filter(Boolean).join(" \xB7 ");
13678
14027
  log.plain(`${c.bold(`${i + 1}. ${p.title}`)} \u2014 ${meta}`);
13679
14028
  if (p.description) log.plain(` ${p.description.slice(0, 160)}`);
@@ -14666,11 +15015,13 @@ ${c.bold("Usage")}
14666
15015
  enqueued (done/running/queued/failed) \u2014 a single
14667
15016
  API refresh, never blocks, never bills.
14668
15017
  genex controller <type> [--force] Install a bundled controller
14669
- (character|car|drone|touch|networked-physics)
15018
+ (character|car|drone|touch|networked-physics|quality)
14670
15019
  into src/controllers (+ assets into public/assets).
14671
15020
  "touch" = the standalone mobile touch kit (joystick,
14672
15021
  buttons, drag zone, rotate overlay) for games
14673
- without a physics controller.
15022
+ without a physics controller. "quality" = the
15023
+ adaptive-quality kit (device tier, runtime governor,
15024
+ per-tier asset rungs) every game wires at boot.
14674
15025
  Character defaults to the player's VRM + UAL pack.
14675
15026
  genex controller character --character <id>
14676
15027
  Install the Meshy-native controller variant.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@genex-ai/cli-demo",
3
- "version": "0.69.0-dev.175",
3
+ "version": "0.71.0-dev.186",
4
4
  "description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,6 +42,7 @@
42
42
  "devDependencies": {
43
43
  "@dimforge/rapier3d-compat": "^0.19.3",
44
44
  "@genex/meshy-animation-catalog": "workspace:*",
45
+ "@genex/mobile-scan": "workspace:*",
45
46
  "@genex-ai/multiplayer": "workspace:*",
46
47
  "@pixiv/three-vrm": "^3.5.4",
47
48
  "@types/pngjs": "^6.0.5",
@@ -4,9 +4,17 @@
4
4
  // orientation, and runs the standard perf cleanup — so the rest of the
5
5
  // controller treats every avatar (VRM 0.x or 1.0) identically.
6
6
  //
7
+ // Mobile-readiness: everyone in a Genex room renders the SAME avatar file, so
8
+ // this module caches per URL — the file downloads once (loadVrm), and remote
9
+ // players should use loadVrmClone, which shares one set of GPU geometry +
10
+ // textures across N remotes (~1 avatar of VRAM instead of N). Before this,
11
+ // each remote re-downloaded AND re-parsed the VRM into duplicate GPU memory —
12
+ // a real jetsam contributor at high player counts.
13
+ //
7
14
  // Needs `npm i @pixiv/three-vrm` (peer of three, which the scaffold already has).
8
15
  import * as THREE from "three";
9
16
  import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
17
+ import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";
10
18
  import { VRMLoaderPlugin, VRMUtils } from "@pixiv/three-vrm";
11
19
  import type { VRM } from "@pixiv/three-vrm";
12
20
 
@@ -17,28 +25,83 @@ export interface LoadedVrm {
17
25
  vrm: VRM;
18
26
  }
19
27
 
20
- /**
21
- * Load a `.vrm` and return its scene + VRM instance, ready to animate.
22
- *
23
- * `VRMUtils.rotateVRM0` bakes the VRM 0.x 180° flip into BOTH the model and its
24
- * humanoid rig, so the avatar faces -Z (three's forward, same as every other
25
- * model) and the UAL retargeter (vrm-retarget.ts) needs no per-version handling.
26
- */
27
- export async function loadVrm(url: string): Promise<LoadedVrm> {
28
+ // One network fetch + decode per URL, however many avatars spawn.
29
+ const bufferCache = new Map<string, Promise<ArrayBuffer>>();
30
+ // One PARSED base VRM per URL — the clone source for remote players.
31
+ const baseCache = new Map<string, Promise<LoadedVrm>>();
32
+
33
+ function fetchVrmBuffer(url: string): Promise<ArrayBuffer> {
34
+ let cached = bufferCache.get(url);
35
+ if (!cached) {
36
+ cached = fetch(url).then((r) => {
37
+ if (!r.ok) throw new Error(`VRM fetch failed: ${r.status} ${url}`);
38
+ return r.arrayBuffer();
39
+ });
40
+ cached.catch(() => bufferCache.delete(url)); // failed fetches retry next call
41
+ bufferCache.set(url, cached);
42
+ }
43
+ return cached;
44
+ }
45
+
46
+ async function parseVrm(url: string): Promise<LoadedVrm> {
28
47
  const loader = new GLTFLoader();
29
48
  loader.register((parser) => new VRMLoaderPlugin(parser));
30
49
 
31
- const gltf = await loader.loadAsync(url);
50
+ const buffer = await fetchVrmBuffer(url);
51
+ const gltf = await loader.parseAsync(buffer.slice(0), url);
32
52
  const vrm = gltf.userData.vrm as VRM;
33
53
 
34
54
  VRMUtils.rotateVRM0(vrm);
35
55
  VRMUtils.removeUnnecessaryVertices(vrm.scene);
36
56
  VRMUtils.combineSkeletons(vrm.scene);
37
57
 
38
- // Skinned avatars can pop out at glancing camera angles otherwise.
58
+ // Skinned meshes can pop out at glancing camera angles (animated bounds) —
59
+ // but ONLY skinned meshes need the exemption. Blanket-disabling culling on
60
+ // every node made ALL loaded avatars draw every frame regardless of
61
+ // visibility, a per-remote GPU-time tax on phones.
39
62
  vrm.scene.traverse((obj) => {
40
- obj.frustumCulled = false;
63
+ if ((obj as THREE.SkinnedMesh).isSkinnedMesh) obj.frustumCulled = false;
41
64
  });
42
65
 
43
66
  return { scene: vrm.scene, vrm };
44
67
  }
68
+
69
+ /**
70
+ * Load a `.vrm` and return its scene + VRM instance, ready to animate.
71
+ * Full-fidelity (spring bones, humanoid) — the LOCAL player's avatar, or any
72
+ * avatar you retarget clips onto. The file itself is fetched once per URL.
73
+ *
74
+ * `VRMUtils.rotateVRM0` bakes the VRM 0.x 180° flip into BOTH the model and its
75
+ * humanoid rig, so the avatar faces -Z (three's forward, same as every other
76
+ * model) and the UAL retargeter (vrm-retarget.ts) needs no per-version handling.
77
+ */
78
+ export async function loadVrm(url: string): Promise<LoadedVrm> {
79
+ return parseVrm(url);
80
+ }
81
+
82
+ /**
83
+ * Visual-only clone for REMOTE players: parses the URL once (cached), then
84
+ * returns `SkeletonUtils.clone` of the base — its own bones and mixer target,
85
+ * but SHARED geometry, materials, and textures (N remotes ≈ 1 avatar of GPU
86
+ * memory). Trade-off, on purpose: clones carry no VRM instance, so spring
87
+ * bones (hair/cloth sway) don't simulate on remotes — retarget animation clips
88
+ * ONCE on the base (`(await loadVrmClone.base(url)).vrm`, or your local
89
+ * loadVrm result when it's the same file) and play them on each clone's own
90
+ * `THREE.AnimationMixer`; track names match by construction.
91
+ */
92
+ export async function loadVrmClone(url: string): Promise<{ scene: THREE.Group }> {
93
+ const base = await loadVrmCloneBase(url);
94
+ return { scene: SkeletonUtils.clone(base.scene) as THREE.Group };
95
+ }
96
+
97
+ /** The shared parsed base behind loadVrmClone — retarget clips against ITS vrm. */
98
+ export function loadVrmCloneBase(url: string): Promise<LoadedVrm> {
99
+ let cached = baseCache.get(url);
100
+ if (!cached) {
101
+ cached = parseVrm(url);
102
+ cached.catch(() => baseCache.delete(url));
103
+ baseCache.set(url, cached);
104
+ }
105
+ return cached;
106
+ }
107
+ loadVrmClone.base = loadVrmCloneBase;