@downdraft/cli 0.0.0 → 0.1.1

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 (84) hide show
  1. package/README.md +11 -0
  2. package/package.json +36 -3
  3. package/scripts/default-icon.png +0 -0
  4. package/scripts/package-desktop.mjs +931 -0
  5. package/scripts/package-mobile.mjs +822 -0
  6. package/scripts/package-native.mjs +638 -0
  7. package/src/args.ts +333 -0
  8. package/src/assets.ts +523 -0
  9. package/src/blob-store-s3.ts +145 -0
  10. package/src/build-games.ts +45 -0
  11. package/src/build.ts +40 -0
  12. package/src/bun-env.d.ts +6 -0
  13. package/src/debug.ts +56 -0
  14. package/src/dev.ts +231 -0
  15. package/src/dist.ts +36 -0
  16. package/src/export.ts +38 -0
  17. package/src/index.ts +131 -0
  18. package/src/list-games.ts +37 -0
  19. package/src/mcp.ts +320 -0
  20. package/src/new.ts +63 -0
  21. package/src/paths.ts +101 -0
  22. package/src/plugin-command.ts +214 -0
  23. package/src/process-utils.ts +179 -0
  24. package/src/release.ts +432 -0
  25. package/src/scaffold-plugin.ts +280 -0
  26. package/src/scaffold.ts +207 -0
  27. package/src/test.ts +187 -0
  28. package/src/usage.ts +256 -0
  29. package/templates/_shared/ai-companion/devin-config.eta +11 -0
  30. package/templates/_shared/ai-companion/engine-prompt.eta +155 -0
  31. package/templates/_shared/ai-companion/logging-rule.eta +3 -0
  32. package/templates/_shared/ai-companion/loops-rule.eta +3 -0
  33. package/templates/_shared/partials/config-json-fields.eta +3 -0
  34. package/templates/_shared/partials/dev-deps.eta +8 -0
  35. package/templates/_shared/partials/package-json-fields.eta +12 -0
  36. package/templates/_shared/partials/tsconfig.eta +16 -0
  37. package/templates/_shared/partials/vscode-extensions.eta +11 -0
  38. package/templates/_shared/partials/vscode-settings.eta +29 -0
  39. package/templates/_shared/partials/vscode-tasks.eta +93 -0
  40. package/templates/full/.gitignore.eta +5 -0
  41. package/templates/full/.vscode/extensions.json.eta +1 -0
  42. package/templates/full/.vscode/settings.json.eta +1 -0
  43. package/templates/full/.vscode/tasks.json.eta +1 -0
  44. package/templates/full/README.md.eta +45 -0
  45. package/templates/full/assets/shaders/.eta +0 -0
  46. package/templates/full/downdraft.config.json.eta +10 -0
  47. package/templates/full/package.json.eta +9 -0
  48. package/templates/full/src/entities/.eta +0 -0
  49. package/templates/full/src/game-module.ts.eta +30 -0
  50. package/templates/full/src/native-entry.ts.eta +14 -0
  51. package/templates/full/src/sim-host.ts.eta +19 -0
  52. package/templates/full/src/sim-worker.ts.eta +99 -0
  53. package/templates/full/src/systems/.eta +0 -0
  54. package/templates/full/tsconfig.json.eta +1 -0
  55. package/templates/gamemodule/README.md.eta +32 -0
  56. package/templates/gamemodule/downdraft.config.json.eta +3 -0
  57. package/templates/gamemodule/package.json.eta +9 -0
  58. package/templates/gamemodule/src/game-module.ts.eta +63 -0
  59. package/templates/gamemodule/src/native-entry.ts.eta +14 -0
  60. package/templates/gamemodule/src/sim-host.ts.eta +25 -0
  61. package/templates/gamemodule/src/sim-worker.ts.eta +24 -0
  62. package/templates/gamemodule/tsconfig.json.eta +1 -0
  63. package/templates/minimal/.gitignore.eta +5 -0
  64. package/templates/minimal/.vscode/extensions.json.eta +1 -0
  65. package/templates/minimal/.vscode/settings.json.eta +1 -0
  66. package/templates/minimal/.vscode/tasks.json.eta +1 -0
  67. package/templates/minimal/README.md.eta +32 -0
  68. package/templates/minimal/downdraft.config.json.eta +3 -0
  69. package/templates/minimal/package.json.eta +9 -0
  70. package/templates/minimal/src/game-module.ts.eta +44 -0
  71. package/templates/minimal/src/native-entry.ts.eta +14 -0
  72. package/templates/minimal/tsconfig.json.eta +1 -0
  73. package/templates/physics/.gitignore.eta +5 -0
  74. package/templates/physics/.vscode/extensions.json.eta +1 -0
  75. package/templates/physics/.vscode/settings.json.eta +1 -0
  76. package/templates/physics/.vscode/tasks.json.eta +1 -0
  77. package/templates/physics/README.md.eta +36 -0
  78. package/templates/physics/downdraft.config.json.eta +6 -0
  79. package/templates/physics/package.json.eta +9 -0
  80. package/templates/physics/src/game-module.ts.eta +29 -0
  81. package/templates/physics/src/native-entry.ts.eta +14 -0
  82. package/templates/physics/src/sim-host.ts.eta +19 -0
  83. package/templates/physics/src/sim-worker.ts.eta +155 -0
  84. package/templates/physics/tsconfig.json.eta +1 -0
@@ -0,0 +1,822 @@
1
+ // ============================================================================
2
+ // package-mobile.mjs — package a game's native entry into an Android APK.
3
+ //
4
+ // bun packages/cli/scripts/package-mobile.mjs [--abi=arm64-v8a|x86_64|all]
5
+ // [--mode=dev|prod] [--app-id=<id>] [--app-name=<name>] [--version=<v>]
6
+ // [--min-sdk=30] [--install] [--launch] [--mcp] <entry.ts> <out.apk>
7
+ //
8
+ // Produces a genuinely native APK — no WebView, no Capacitor:
9
+ // lib/<abi>/libdowndraft_android.so shell (winit loop + shim ABI exports)
10
+ // lib/<abi>/libnode.so embedded Node (packages/node-mobile)
11
+ // lib/<abi>/libdowndraft_*.so optional engine cdylibs
12
+ // assets/bundle/index.js ESM entry shim
13
+ // assets/bundle/<repo-rel>/*.mjs bundled game + worker modules
14
+ // assets/bundle/node_modules/koffi (+ @koromix/koffi-android-*) FFI
15
+ // assets/bundle/dd-assets/ ?url/new URL/import.meta.glob payloads
16
+ // assets/bundle/manifest.txt extraction manifest for the shell
17
+ //
18
+ // Runtime contract (must match packages/android-shell/src/lib.rs):
19
+ // shell extracts assets/bundle/ → internalDataPath/bundle/, sets
20
+ // DOWNDRAFT_BUNDLE_DIR to that dir, then node::Start("index.js").
21
+ // import.meta.dir/.url rewrites resolve under $DOWNDRAFT_BUNDLE_DIR/
22
+ // dd-assets/…; Worker() spawns resolve real extracted files.
23
+ //
24
+ // APK assembly uses Android build-tools directly (aapt2 → zip jniLibs →
25
+ // zipalign → apksigner debug sign) — the app is hasCode=false so no dex or
26
+ // Gradle is required. packages/android-shell/gradle/ is the equivalent
27
+ // Android Studio skeleton.
28
+ //
29
+ // Mirrors the bundling rules in package-native.mjs — keep the regexes and
30
+ // rewrite semantics in sync.
31
+ // ============================================================================
32
+
33
+ import { spawnSync } from "node:child_process";
34
+ import { createHash } from "node:crypto";
35
+ import {
36
+ copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync,
37
+ readdirSync, renameSync, statSync, writeFileSync,
38
+ } from "node:fs";
39
+ import { tmpdir } from "node:os";
40
+ import { basename, dirname, join, relative, resolve } from "node:path";
41
+ import { fileURLToPath } from "node:url";
42
+
43
+ // ── args ──
44
+
45
+ const argv = process.argv.slice(2);
46
+ const flagValue = (name) =>
47
+ argv.find((a) => a.startsWith(`--${name}=`))?.slice(name.length + 3);
48
+ const abiArg = flagValue("abi") ?? "arm64-v8a";
49
+ const modeArg = flagValue("mode") ?? "prod";
50
+ const doInstall = argv.includes("--install") || argv.includes("--launch");
51
+ const doLaunch = argv.includes("--launch");
52
+ const retainMcp = argv.includes("--mcp");
53
+ const minSdk = flagValue("min-sdk") ?? "30";
54
+ // --node-flavor=lite selects build/dist/android-lite/<abi>/libnode.so
55
+ // (NODEJS_MOBILE_FLAVOR=lite from build-android.mjs: no intl/inspector/
56
+ // sqlite/amaro — ~20MB smaller binary, slightly lower PSS).
57
+ const nodeFlavor = flagValue("node-flavor") ?? "full";
58
+ // Optional engine cdylibs stage only when the bundle references their soname
59
+ // ("downdraft_<lib>" token). --libs= forces inclusion, --exclude-libs=
60
+ // forces exclusion (pair with --env=DOWNDRAFT_NO_GAMEPAD=1 so nothing tries
61
+ // to dlopen a lib that isn't shipped).
62
+ const forceLibs = (flagValue("libs") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
63
+ const excludeLibs = (flagValue("exclude-libs") ?? "").split(",").map((s) => s.trim()).filter(Boolean);
64
+ // Packaged .so's are stripped by default (debug info never maps — pure APK
65
+ // size). --no-strip keeps symbols for on-device native debugging.
66
+ const doStrip = !argv.includes("--no-strip");
67
+ // --env KEY=VALUE (repeatable) → dd-env.txt in the bundle; the shell
68
+ // setenv()s each before node::Start so process.env sees them.
69
+ const envPairs = argv.filter((a) => a.startsWith("--env=")).map((a) => a.slice(6));
70
+ const positional = argv.filter((a) => !a.startsWith("--"));
71
+ const [entry, outfileArg] = positional;
72
+ if (!entry || !outfileArg) {
73
+ console.error("usage: bun package-mobile.mjs [--abi=...] [--mode=...] [--app-id=...] [--app-name=...] [--version=...] [--min-sdk=...] [--node-flavor=full|lite] [--libs=a,b] [--exclude-libs=a,b] [--no-strip] [--env KEY=VAL]... [--install|--launch] [--mcp] <entry.ts> <out.apk>");
74
+ process.exit(1);
75
+ }
76
+
77
+ const ABIS = { "arm64-v8a": "aarch64-linux-android", "x86_64": "x86_64-linux-android" };
78
+ const KOFFI_PKGS = { "arm64-v8a": "@koromix/koffi-android-arm64", "x86_64": "@koromix/koffi-android-x64" };
79
+ const abis = abiArg === "all" ? Object.keys(ABIS) : abiArg.split(",").map((s) => s.trim());
80
+ for (const a of abis) {
81
+ if (!ABIS[a]) throw new Error(`unknown --abi "${a}" — expected: ${Object.keys(ABIS).join(", ")}, all`);
82
+ }
83
+
84
+ const repoRoot = process.cwd();
85
+ const scriptDir = dirname(fileURLToPath(import.meta.url));
86
+ const monorepoRoot = resolve(scriptDir, "../../..");
87
+ const gameDir = dirname(dirname(resolve(entry)));
88
+
89
+ // Branding: flags → game package.json → defaults.
90
+ let pkg = {};
91
+ try { pkg = JSON.parse(readFileSync(join(gameDir, "package.json"), "utf-8")); } catch { /* standalone */ }
92
+ const titleize = (s) => String(s).replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
93
+ // Android package ids must be dot-separated Java identifiers — hyphens are
94
+ // rejected by aapt2 ("not a valid Android package name").
95
+ const sanitizeAppId = (id) =>
96
+ id.split(".").map((seg) => {
97
+ let s = seg.replace(/[^a-zA-Z0-9_]/g, "_");
98
+ if (!/^[a-zA-Z_]/.test(s)) s = "_" + s;
99
+ return s;
100
+ }).join(".");
101
+ const rawAppId = flagValue("app-id") ?? pkg.build?.appId ?? `com.downdraft.${basename(gameDir).replace(/[^a-zA-Z0-9]/g, "")}`;
102
+ const appId = sanitizeAppId(rawAppId);
103
+ if (appId !== rawAppId) console.warn(`[package-mobile] app-id "${rawAppId}" sanitized → "${appId}"`);
104
+ const appName = flagValue("app-name") ?? pkg.productName ?? pkg.build?.productName ?? titleize(pkg.name ?? basename(gameDir));
105
+ const versionName = flagValue("version") ?? pkg.version ?? "0.0.1";
106
+ const verParts = (versionName.match(/\d+/g) ?? ["0"]).map(Number);
107
+ const versionCode = Math.max(1, (verParts[0] ?? 0) * 10000 + (verParts[1] ?? 0) * 100 + (verParts[2] ?? 0));
108
+
109
+ // ── Android SDK / build-tools discovery ──
110
+
111
+ const sdkRoot =
112
+ process.env.ANDROID_HOME ?? process.env.ANDROID_SDK_ROOT ??
113
+ join(process.env.HOME ?? "", "Android/Sdk");
114
+ if (!existsSync(sdkRoot)) throw new Error("Android SDK not found — set ANDROID_HOME");
115
+ const btRoot = join(sdkRoot, "build-tools");
116
+ const btVer = readdirSync(btRoot).sort().pop();
117
+ const bt = join(btRoot, btVer);
118
+ const platformDir = readdirSync(join(sdkRoot, "platforms")).sort().pop();
119
+ const androidJar = join(sdkRoot, "platforms", platformDir, "android.jar");
120
+ for (const tool of ["aapt2", "zipalign", "apksigner"]) {
121
+ if (!existsSync(join(bt, tool))) throw new Error(`missing build-tool ${tool} in ${bt}`);
122
+ }
123
+ const adb = join(sdkRoot, "platform-tools", "adb");
124
+
125
+ const run = (cmd, args, opts = {}) => {
126
+ const r = spawnSync(cmd, args, { stdio: "inherit", ...opts });
127
+ if (r.status !== 0) throw new Error(`${cmd} ${args[0]}… failed (exit ${r.status})`);
128
+ return r;
129
+ };
130
+
131
+ // ── Bundle (mirrors package-native.mjs rules; paths rebase to the
132
+ // extracted bundle dir via DOWNDRAFT_BUNDLE_DIR) ──
133
+
134
+ const QUERY_RE = /\?(raw|url|json)$/;
135
+ const WORKER_RE = /new\s+Worker\(\s*new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/g;
136
+ // Rewrite variant — additionally consumes new URL's closing paren so the
137
+ // Worker's own arg list (options object, close paren) stays balanced.
138
+ const WORKER_URL_RE = /new\s+Worker\(\s*new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)\s*(?:\.href|\.toString\(\s*\))?\s*/g;
139
+ const ASSET_URL_RE = /new\s+URL\(\s*["'`]([^"'`]+)["'`]\s*,\s*import\.meta\.url\s*\)/g;
140
+ const IMPORT_META = /(?:\(\s*import\.meta\s+as\s+any\s*\)|import\.meta(?:\s+as\s+any)?)/;
141
+ const GLOB_RE = new RegExp(IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1`, "g");
142
+ // Full eager call — captured so module globs can be expanded into static
143
+ // imports at package time. The FS glob shim returns file:// URLs only;
144
+ // an eager module glob's side effects (e.g. registerTest()) can never
145
+ // run through it — the modules must be bundled and evaluated.
146
+ const GLOB_EAGER_RE = new RegExp(
147
+ IMPORT_META.source + String.raw`\.glob\(\s*(["'\`])([^"'\`]+)\1\s*,\s*(\{[^)]*\beager\s*:\s*true[^)]*\})\s*\)`, "g");
148
+ const STAGE_GLOB_RE = new RegExp(
149
+ `(?:${IMPORT_META.source}\\.glob|_glob|globAssets)\\(\\s*(["'\`])([^"'\`]+)\\1|createGlob\\([^)]*\\)\\(\\s*(["'\`])([^"'\`]+)\\3`, "g");
150
+ const STAGE_READ_RE = new RegExp(
151
+ String.raw`(?:resolve|join)\(\s*(?:__dirname|${IMPORT_META.source}\.(?:dir|dirname|url))\s*,\s*(["'\`])([^"'\`]+)\1`, "g");
152
+ const META_DIR_RE = new RegExp(IMPORT_META.source + String.raw`\.(?:dir|dirname)\b`, "g");
153
+ const META_URL_RE = new RegExp(IMPORT_META.source + String.raw`\.url\b`, "g");
154
+ const REQUIRE_GLOB_RE = /require\(\s*["'`]@downdraft\/engine\/platform\/glob-polyfill["'`]\s*\)/g;
155
+ const GLOB_CALLEE_RE = new RegExp(IMPORT_META.source + String.raw`\.glob`);
156
+ const SCRIPT_EXT_RE = /\.(ts|tsx|js|jsx|mts|cts|cjs)$/i; // .mjs staged as an asset (execArgv loaders)
157
+ const DYN_IMPORT_RE = /import\(\s*([a-zA-Z_$][\w$]*)\s*\)/g;
158
+
159
+ function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
160
+
161
+ function resolveSpec(spec, importer) {
162
+ try {
163
+ return spec.startsWith(".") || spec.startsWith("/")
164
+ ? resolve(dirname(importer), spec)
165
+ : Bun.resolveSync(spec, dirname(importer));
166
+ } catch { return null; }
167
+ }
168
+
169
+ /** Repo-relative bundle path for a module, with .mjs extension. Bun's
170
+ * `[dir]` naming escapes out-of-root `..` segments as `_.._` — the staged
171
+ * tree contains `_.._/_.._/packages/...`, so emit the same encoding or
172
+ * spawned Worker paths resolve outside the extracted bundle. */
173
+ function bundlePath(absPath) {
174
+ return relative(repoRoot, absPath).replace(/\\/g, "/").replace(/\.[jt]sx?$/, ".mjs")
175
+ .split("/").map((s) => (s === ".." ? "_.._" : s)).join("/");
176
+ }
177
+
178
+ function assetRel(absPath) {
179
+ const rel = relative(repoRoot, absPath).replace(/\\/g, "/");
180
+ if (!rel.startsWith("..")) return rel;
181
+ const h = createHash("sha1").update(dirname(absPath)).digest("hex").slice(0, 8);
182
+ return `external/${h}/${basename(absPath)}`;
183
+ }
184
+
185
+ function walkDir(dir, results = []) {
186
+ let entries;
187
+ try { entries = readdirSync(dir); } catch { return results; }
188
+ for (const e of entries) {
189
+ const full = `${dir}/${e}`;
190
+ let st;
191
+ try { st = statSync(full); } catch { continue; }
192
+ if (st.isDirectory()) walkDir(full, results);
193
+ else results.push(full);
194
+ }
195
+ return results;
196
+ }
197
+
198
+ // Mirrors globToRegex in packages/engine/core/src/platform/glob-polyfill.ts
199
+ // — keep in sync.
200
+ function globToRegex(pattern) {
201
+ const regex = pattern
202
+ .replace(/[.+^$()|[\]\\]/g, "\\$&")
203
+ .replace(/\*\*\//g, "\x00")
204
+ .replace(/\*\*/g, "\x01")
205
+ .replace(/\*/g, "[^/]*")
206
+ .replace(/\{([^}]+)\}/g, (_, group) => `(${group.replace(/,/g, "|")})`)
207
+ .replace(/\x00/g, "(?:.*/)?")
208
+ .replace(/\x01/g, ".*");
209
+ return new RegExp(`^${regex}$`, "i");
210
+ }
211
+
212
+ /** Expand an import.meta.glob pattern against the real FS at package time. */
213
+ function expandGlob(fromDir, pattern) {
214
+ const rootParts = [];
215
+ for (const p of pattern.split("/")) {
216
+ if (p.includes("*") || p.includes("{")) break;
217
+ rootParts.push(p);
218
+ }
219
+ const searchDir = resolve(fromDir, ...rootParts);
220
+ if (!existsSync(searchDir) || !statSync(searchDir).isDirectory()) return [];
221
+ const matchRegex = globToRegex(pattern.replace(/^\.\//, ""));
222
+ return walkDir(searchDir)
223
+ .map((f) => relative(fromDir, f).replace(/\\/g, "/"))
224
+ .filter((rel) => matchRegex.test(rel) || matchRegex.test(`./${rel}`))
225
+ .sort();
226
+ }
227
+
228
+ const workerEntries = new Set();
229
+ const dynImports = new Map();
230
+ const assetFiles = new Set();
231
+
232
+ const makePlugin = (collectWorkers) => ({
233
+ name: "downdraft-mobile-build",
234
+ setup(build) {
235
+ const resolveImport = (spec, importer) => {
236
+ const base = spec.startsWith(".") || spec.startsWith("/")
237
+ ? resolve(dirname(importer), spec)
238
+ : Bun.resolveSync(spec, dirname(importer));
239
+ return existsSync(base) ? base : (existsSync(`${base}.ts`) ? `${base}.ts` : base);
240
+ };
241
+ build.onResolve({ filter: /\?(raw|url|json)$/ }, (args) => {
242
+ const clean = args.path.replace(QUERY_RE, "");
243
+ const kind = args.path.match(QUERY_RE)[1];
244
+ const resolved = resolveImport(clean, args.importer);
245
+ if (resolved.endsWith(".ts")) return { path: resolved };
246
+ return { path: resolved, namespace: `dd-${kind}` };
247
+ });
248
+ build.onLoad({ filter: /.*/, namespace: "dd-raw" }, (args) => ({
249
+ contents: `export default ${JSON.stringify(readFileSync(args.path, "utf-8"))};`,
250
+ loader: "js",
251
+ }));
252
+ build.onLoad({ filter: /.*/, namespace: "dd-json" }, (args) => ({
253
+ contents: `export default ${JSON.stringify(JSON.parse(readFileSync(args.path, "utf-8")))};`,
254
+ loader: "js",
255
+ }));
256
+ build.onLoad({ filter: /.*/, namespace: "dd-url" }, (args) => {
257
+ assetFiles.add(args.path);
258
+ if (collectWorkers) {
259
+ return { contents: `export default ${JSON.stringify(args.path)};`, loader: "js" };
260
+ }
261
+ const rel = assetRel(args.path);
262
+ return {
263
+ contents:
264
+ `import { pathToFileURL as __ddP2F } from "node:url";` +
265
+ `export default __ddP2F(process.env.DOWNDRAFT_BUNDLE_DIR + "/dd-assets/" + ${JSON.stringify(rel)}).href;`,
266
+ loader: "js",
267
+ };
268
+ });
269
+ ["wgsl", "glsl"].forEach((ext) => {
270
+ build.onResolve({ filter: new RegExp(`\\.${ext}$`) }, (args) => {
271
+ const resolved = resolveImport(args.path, args.importer);
272
+ if (resolved.endsWith(".ts")) return { path: resolved };
273
+ return { path: resolved, namespace: "dd-raw" };
274
+ });
275
+ });
276
+ build.onResolve({ filter: /\.css$/ }, (args) => {
277
+ if (args.path.startsWith("dd-css:")) {
278
+ return { path: args.path.slice("dd-css:".length), namespace: "dd-css" };
279
+ }
280
+ let path;
281
+ try { path = resolveImport(args.path, args.importer); }
282
+ catch { path = resolve(dirname(args.importer), args.path); }
283
+ return { path, namespace: "dd-css" };
284
+ });
285
+ build.onLoad({ filter: /.*/, namespace: "dd-css" }, () => ({
286
+ contents: `export default "";`, loader: "js",
287
+ }));
288
+ build.onLoad({ filter: /\.[jt]sx?$/ }, (args) => {
289
+ const src = readFileSync(args.path, "utf-8");
290
+ const modDir = dirname(args.path);
291
+ const modRel = assetRel(args.path);
292
+ const modBase = basename(modRel).replace(/\.[jt]sx?$/, ".js");
293
+ const stagedDir = dirname(modRel);
294
+
295
+ for (const m of src.matchAll(WORKER_RE)) {
296
+ const p = resolve(modDir, m[1]);
297
+ if (existsSync(p)) workerEntries.add(p);
298
+ }
299
+ for (const m of src.matchAll(ASSET_URL_RE)) {
300
+ // Scripts are bundled; .mjs loaders (worker-bootstrap, wgsl-loader)
301
+ // are spawned via execArgv and must exist as real files — stage them.
302
+ if (SCRIPT_EXT_RE.test(m[1])) continue;
303
+ const p = resolve(modDir, m[1]);
304
+ if (existsSync(p) && statSync(p).isFile()) assetFiles.add(p);
305
+ }
306
+ for (const m of src.matchAll(DYN_IMPORT_RE)) {
307
+ const ident = m[1];
308
+ const bind = src.match(new RegExp("(?:const|let|var)\\s+" + escapeRe(ident) + "\\s*=\\s*([\"'`])([^\"'`]+)\\1"));
309
+ if (!bind) continue;
310
+ const spec = bind[2];
311
+ if (!spec || spec.startsWith("node:") || spec.startsWith("bun:")) continue;
312
+ const abs = resolveSpec(spec, args.path);
313
+ if (!abs || !existsSync(abs)) continue;
314
+ workerEntries.add(abs);
315
+ dynImports.set(`${args.path}:${ident}`, { ident, spec, abs });
316
+ }
317
+ for (const m of src.matchAll(STAGE_READ_RE)) {
318
+ if (SCRIPT_EXT_RE.test(m[2])) continue;
319
+ const p = resolve(modDir, m[2]);
320
+ if (existsSync(p) && statSync(p).isFile()) assetFiles.add(p);
321
+ }
322
+ for (const m of src.matchAll(STAGE_GLOB_RE)) {
323
+ const pattern = m[2] ?? m[4];
324
+ if (!pattern) continue;
325
+ const rootParts = [];
326
+ for (const p of pattern.split("/")) {
327
+ if (p.includes("*") || p.includes("{")) break;
328
+ rootParts.push(p);
329
+ }
330
+ const searchDir = resolve(modDir, ...rootParts);
331
+ if (existsSync(searchDir) && statSync(searchDir).isDirectory()) {
332
+ for (const f of walkDir(searchDir)) assetFiles.add(f);
333
+ }
334
+ }
335
+
336
+ const loader = { ts: "ts", tsx: "tsx", js: "js", jsx: "jsx", mts: "ts" }[args.path.split(".").pop()] || "ts";
337
+ let needsGlob = false;
338
+
339
+ // 0. Eager module globs (no `query:`) → static imports generated
340
+ // at package time. The FS glob shim returns file:// URLs — it
341
+ // can't execute module side effects — so an eager glob's
342
+ // registrations (registerTest(), etc.) must run inside the
343
+ // bundle. Runs in the scan pass too so the worker fixpoint
344
+ // reaches workers declared by eagerly-globbed modules. Eager
345
+ // globs WITH `query:` resolve to asset payloads — the FS
346
+ // shim's file:// URLs are already the right value shape.
347
+ const eagerDecls = [];
348
+ let eagerSeq = 0;
349
+ let out = src.replace(GLOB_EAGER_RE, (m, _q, pattern, opts) => {
350
+ if (/\bquery\s*:/.test(opts)) {
351
+ needsGlob = true;
352
+ return m.replace(GLOB_CALLEE_RE, "__ddGlob");
353
+ }
354
+ const spec = /\bimport\s*:\s*["']([A-Za-z_$][\w$]*)["']/.exec(opts)?.[1];
355
+ const name = `__ddEager${eagerSeq++}`;
356
+ const entries = expandGlob(modDir, pattern).map((f, i) => {
357
+ const rel = f.startsWith(".") ? f : "./" + f;
358
+ const ident = `${name}$${i}`;
359
+ eagerDecls.push(`import * as ${ident} from ${JSON.stringify(rel)};`);
360
+ return `${JSON.stringify(rel)}: ${spec ? `${ident}.${spec}` : ident}`;
361
+ });
362
+ eagerDecls.push(`const ${name} = {${entries.length ? "\n " + entries.join(",\n ") + ",\n" : ""}};`);
363
+ console.log(` eager glob ${pattern} → ${entries.length} module(s) [${relative(repoRoot, args.path)}]`);
364
+ return name;
365
+ });
366
+ const prologue = eagerDecls.length ? eagerDecls.join("\n") + "\n" : "";
367
+
368
+ if (collectWorkers) return prologue ? { contents: prologue + out, loader } : undefined;
369
+
370
+ // 1. Worker URLs → absolute path inside the extracted bundle
371
+ // (worker_threads needs a real file on disk). WORKER_URL_RE eats
372
+ // new URL's close paren so a trailing `, { type: "module" })`
373
+ // remains Worker's second argument.
374
+ out = out.replace(WORKER_URL_RE, (m, spec) => {
375
+ const rel = bundlePath(resolve(modDir, spec));
376
+ return `new Worker(process.env.DOWNDRAFT_BUNDLE_DIR + "/${rel}"`;
377
+ });
378
+ // 1.5 new URL("<asset>", import.meta.url) → absolute file URL into the
379
+ // staged dd-assets tree. assetRel() gives the TARGET's own staged
380
+ // dir — resolving the spec textually against __ddModUrl breaks when
381
+ // the target lives under a different hashed dir (repo-external
382
+ // sources stage at external/<sha1(dir)>/, so "../ffi/x.mjs" would
383
+ // escape into external/ffi/).
384
+ out = out.replace(ASSET_URL_RE, (m, spec) => {
385
+ const p = resolve(modDir, spec);
386
+ if (!existsSync(p)) return m;
387
+ return `new URL("file://" + process.env.DOWNDRAFT_BUNDLE_DIR + "/dd-assets/" + ${JSON.stringify(assetRel(p))})`;
388
+ });
389
+ // 2. Variable-specifier dynamic imports → bundled module path.
390
+ for (const m of out.matchAll(DYN_IMPORT_RE)) {
391
+ const ident = m[1];
392
+ const info = dynImports.get(`${args.path}:${ident}`);
393
+ if (!info) continue;
394
+ const decl = new RegExp("((?:const|let|var)\\s+" + escapeRe(ident) + "\\s*=\\s*)([\"'`])" + escapeRe(info.spec) + "\\2");
395
+ out = out.replace(decl, `$1$2@@DD_BUNDLE@@/${bundlePath(info.abs)}$2`);
396
+ }
397
+ // 3. import.meta.glob → createGlob bound to the staged dir.
398
+ out = out.replace(GLOB_RE, (m) => { needsGlob = true; return m.replace(GLOB_CALLEE_RE, "__ddGlob"); });
399
+ if (REQUIRE_GLOB_RE.test(out)) {
400
+ needsGlob = true;
401
+ out = out.replace(REQUIRE_GLOB_RE, "({ createGlob: __ddCreateGlob })");
402
+ }
403
+ // 4. import.meta.dir/.url/__dirname → module's staged dir under
404
+ // dd-assets (real FS after extraction).
405
+ const metaOut = out
406
+ .replace(META_DIR_RE, "__ddModDir")
407
+ .replace(META_URL_RE, "__ddModUrl")
408
+ .replace(/(?<![.\w$])__dirname\b/g, "__ddModDir");
409
+ if (metaOut !== out || needsGlob) {
410
+ out =
411
+ `import { dirname as __ddDirname } from "node:path";\n` +
412
+ `import { pathToFileURL as __ddP2F } from "node:url";\n` +
413
+ `const __ddModDir = process.env.DOWNDRAFT_BUNDLE_DIR + "/dd-assets/" + ${JSON.stringify(stagedDir === "." ? "" : stagedDir + "/")}.replace(/\\/$/, "");\n` +
414
+ `const __ddModUrl = __ddP2F(__ddModDir + "/" + ${JSON.stringify(modBase)}).href;\n` +
415
+ (needsGlob
416
+ ? `import { createGlob as __ddCreateGlob } from "@downdraft/engine/platform/glob-polyfill";\n` +
417
+ // Lazy — eager construction runs at module-init time and can
418
+ // hit a TDZ on consts declared later in the same module
419
+ // (glob-polyfill.ts itself matches REQUIRE_GLOB_RE via a
420
+ // comment).
421
+ `const __ddGlob = (p, o) => __ddCreateGlob(__ddModDir)(p, o);\n`
422
+ : "") +
423
+ metaOut;
424
+ }
425
+ // @@DD_BUNDLE@@ sentinel → env path (post meta rewrite so META_URL
426
+ // doesn't touch it).
427
+ out = out.replaceAll("@@DD_BUNDLE@@", `" + process.env.DOWNDRAFT_BUNDLE_DIR + "`);
428
+ if (prologue) out = prologue + out;
429
+ return out === src ? undefined : { contents: out, loader };
430
+ });
431
+ },
432
+ });
433
+
434
+ const stageRoot = mkdtempSync(join(tmpdir(), "dd-android-"));
435
+ const bundleDir = join(stageRoot, "assets", "bundle");
436
+ mkdirSync(bundleDir, { recursive: true });
437
+
438
+ // Pass 1: discover worker entrypoints to a fixpoint.
439
+ const scanOutdir = mkdtempSync(join(tmpdir(), "dd-scan-"));
440
+ let prevCount = -1;
441
+ while (workerEntries.size !== prevCount) {
442
+ prevCount = workerEntries.size;
443
+ const scan = await Bun.build({
444
+ entrypoints: [resolve(entry), ...workerEntries],
445
+ target: "node",
446
+ plugins: [makePlugin(true)],
447
+ splitting: false,
448
+ sourcemap: "none",
449
+ minify: false,
450
+ define: retainMcp ? {} : { __DD_MCP_STRIP__: "true" },
451
+ outdir: scanOutdir,
452
+ });
453
+ if (!scan.success) {
454
+ scan.logs.forEach((msg) => { console.error(msg); });
455
+ process.exit(1);
456
+ }
457
+ }
458
+
459
+ const entrypoints = [resolve(entry), ...workerEntries];
460
+ if (workerEntries.size) {
461
+ console.log(`workers: ${[...workerEntries].map((p) => p.split("/").pop()).join(", ")}`);
462
+ }
463
+
464
+ const build = await Bun.build({
465
+ entrypoints,
466
+ // No `compile` — libnode executes the extracted ESM files.
467
+ target: "node",
468
+ root: repoRoot,
469
+ naming: { entry: "[dir]/[name].mjs" },
470
+ plugins: [makePlugin(false)],
471
+ splitting: false,
472
+ sourcemap: "none",
473
+ minify: modeArg === "prod",
474
+ define: retainMcp ? {} : { __DD_MCP_STRIP__: "true" },
475
+ external: ["koffi", "bun:ffi", "bun:test", "bun"],
476
+ outdir: bundleDir,
477
+ });
478
+ if (!build.success) {
479
+ build.logs.forEach((msg) => { console.error(msg); });
480
+ process.exit(1);
481
+ }
482
+
483
+ // Entry shim: the shell always runs bundle/index.js (CJS-safe dynamic
484
+ // import — no type:module package.json needed for the .mjs outputs).
485
+ const entryRel = bundlePath(resolve(entry));
486
+ writeFileSync(join(bundleDir, "index.js"),
487
+ `await import("./${entryRel}");\n`);
488
+ writeFileSync(join(bundleDir, "package.json"), JSON.stringify({ type: "module" }) + "\n");
489
+
490
+ // ── node_modules: koffi + the ABI-matched @koromix/koffi-android-* prebuild ──
491
+
492
+ const { createRequire } = await import("node:module");
493
+ const gameRequire = createRequire(resolve(entry));
494
+ // koffi's exports map doesn't expose ./package.json — resolve the entry.
495
+ let koffiRoot = null;
496
+ try { koffiRoot = dirname(gameRequire.resolve("koffi")); } catch { /* not resolvable */ }
497
+ if (!koffiRoot) {
498
+ console.warn("[package-mobile] koffi not resolvable — FFI will fail at runtime");
499
+ } else {
500
+ const nm = join(bundleDir, "node_modules");
501
+ cpSync(koffiRoot, join(nm, "koffi"), { recursive: true });
502
+ const koffiVer = JSON.parse(readFileSync(join(koffiRoot, "package.json"), "utf-8")).version;
503
+ for (const abi of abis) {
504
+ const pkgName = KOFFI_PKGS[abi];
505
+ // The @koromix prebuild pkgs carry no importable entry — probe the dir
506
+ // beside koffi (same layout koffi's loader expects), else fetch npm.
507
+ let src = join(dirname(koffiRoot), pkgName);
508
+ if (!existsSync(join(src, "package.json"))) src = null;
509
+ if (!src) {
510
+ const fetched = await fetchKoffiPrebuild(pkgName, koffiVer);
511
+ src = fetched;
512
+ }
513
+ if (!src) {
514
+ console.warn(`[package-mobile] ${pkgName}@${koffiVer} unavailable — FFI will fail on ${abi}`);
515
+ continue;
516
+ }
517
+ cpSync(src, join(nm, "@koromix", basename(pkgName)), { recursive: true });
518
+ }
519
+ }
520
+
521
+ /** Fetch @koromix/koffi-android-<abi>@<ver> from the npm registry → temp dir. */
522
+ async function fetchKoffiPrebuild(pkgName, ver) {
523
+ try {
524
+ const meta = await (await fetch(
525
+ `https://registry.npmjs.org/${pkgName.replace("/", "%2F")}`)).json();
526
+ const tarball = meta?.versions?.[ver]?.dist?.tarball ?? meta?.versions?.[meta["dist-tags"]?.latest]?.dist?.tarball;
527
+ if (!tarball) return null;
528
+ const dir = mkdtempSync(join(tmpdir(), "dd-koffi-"));
529
+ const tgz = join(dir, "pkg.tgz");
530
+ writeFileSync(tgz, Buffer.from(await (await fetch(tarball)).arrayBuffer()));
531
+ run("tar", ["-xzf", tgz, "-C", dir]);
532
+ return join(dir, "package");
533
+ } catch (e) {
534
+ console.warn(`[package-mobile] koffi prebuild fetch failed: ${e?.message ?? e}`);
535
+ return null;
536
+ }
537
+ }
538
+
539
+ // Follow relative imports inside staged script assets — execArgv loader
540
+ // files (wgsl-loader.mjs, worker-bootstrap.mjs) are staged raw, not
541
+ // bundled, so their own module deps never enter the graph otherwise.
542
+ {
543
+ const ASSET_IMPORT_RE = /(?:from|import|register)\s*\(?\s*(["'`])(\.[^"'`]+)\1/g;
544
+ let grew = true;
545
+ while (grew) {
546
+ grew = false;
547
+ for (const f of [...assetFiles]) {
548
+ if (!/\.(mjs|cjs|js)$/.test(f)) continue;
549
+ let src;
550
+ try { src = readFileSync(f, "utf-8"); } catch { continue; }
551
+ for (const m of src.matchAll(ASSET_IMPORT_RE)) {
552
+ const p = resolve(dirname(f), m[2]);
553
+ if (existsSync(p) && statSync(p).isFile() && !assetFiles.has(p)) {
554
+ assetFiles.add(p);
555
+ grew = true;
556
+ }
557
+ }
558
+ }
559
+ }
560
+ }
561
+
562
+ // ── dd-assets staging ──
563
+
564
+ const assetRoot = join(bundleDir, "dd-assets");
565
+ let stagedAssets = 0;
566
+ for (const f of assetFiles.values()) {
567
+ const dest = join(assetRoot, assetRel(f));
568
+ mkdirSync(dirname(dest), { recursive: true });
569
+ copyFileSync(f, dest);
570
+ stagedAssets++;
571
+ }
572
+
573
+ // dd-env.txt — optional process env for the JS thread (debug probes like
574
+ // DD_BENCH_TEST; Android has no launch-env channel).
575
+ if (envPairs.length) {
576
+ for (const p of envPairs) {
577
+ if (!/^[A-Za-z_][A-Za-z0-9_]*=/.test(p)) {
578
+ throw new Error(`--env "${p}" must be KEY=VALUE (KEY: [A-Za-z_][A-Za-z0-9_]*)`);
579
+ }
580
+ }
581
+ writeFileSync(join(bundleDir, "dd-env.txt"), envPairs.join("\n") + "\n");
582
+ }
583
+
584
+ // Game-native addons — convention: <game>/native/dist/android-<arch>/*.node
585
+ // are napi modules the game built itself. .node files aren't jniLibs (the OS
586
+ // only installs lib*.so), so they ride inside the extracted bundle instead:
587
+ // the shell unpacks them to a real path under DOWNDRAFT_BUNDLE_DIR/native/
588
+ // and require()/process.dlopen resolves napi_* against the RTLD_GLOBAL
589
+ // libnode (the -Wl,-z,global link flag exists for exactly this).
590
+ // Staged BEFORE manifest.txt so the extractor's file list covers them.
591
+ // (.so siblings are handled per-ABI in the native-libs section below.)
592
+ {
593
+ let staged = 0;
594
+ for (const abi of abis) {
595
+ const platDir = join(gameDir, "native", "dist",
596
+ `android-${abi.startsWith("arm64") ? "arm64" : "x64"}`);
597
+ if (!existsSync(platDir)) continue;
598
+ const dest = join(bundleDir, "native", `android-${abi.startsWith("arm64") ? "arm64" : "x64"}`);
599
+ for (const f of readdirSync(platDir)) {
600
+ if (!f.endsWith(".node")) continue;
601
+ mkdirSync(dest, { recursive: true });
602
+ copyFileSync(join(platDir, f), join(dest, f));
603
+ staged++;
604
+ }
605
+ }
606
+ if (staged) console.log(`[package-mobile] staged ${staged} game-native addon(s) into bundle/native/`);
607
+ }
608
+
609
+ // manifest.txt — the shell's extractor follows this verbatim (AAssetDir
610
+ // can't enumerate subdirectories). aapt2 silently drops asset paths with
611
+ // dot-prefixed segments (.bun, .gitignore), so each line is
612
+ // "asset_path\tdest_path": dot-segments are staged as "_<rest>" while the
613
+ // extractor restores the verbatim destination path.
614
+ function aaptSafePath(rel) {
615
+ return rel.split("/").map((s) => (s.startsWith(".") ? `_${s.slice(1)}` : s)).join("/");
616
+ }
617
+ {
618
+ const seen = new Map();
619
+ const lines = walkDir(bundleDir).map((f) => {
620
+ const dest = relative(bundleDir, f).replace(/\\/g, "/");
621
+ const asset = aaptSafePath(dest);
622
+ const prev = seen.get(asset);
623
+ if (prev && prev !== dest) throw new Error(`asset-path collision: "${dest}" vs "${prev}"`);
624
+ seen.set(asset, dest);
625
+ if (asset !== dest) {
626
+ const target = join(bundleDir, asset);
627
+ mkdirSync(dirname(target), { recursive: true });
628
+ renameSync(f, target);
629
+ }
630
+ return `${asset}\t${dest}`;
631
+ });
632
+ lines.push("manifest.txt\tmanifest.txt");
633
+ writeFileSync(join(bundleDir, "manifest.txt"), lines.sort().join("\n") + "\n");
634
+ }
635
+
636
+ // ── Native libs ──
637
+
638
+ const nativeStage = join(stageRoot, "lib");
639
+
640
+ // NDK discovery — needed for libc++_shared.so fallback and llvm-strip.
641
+ const ndkRoot = process.env.ANDROID_NDK_HOME ?? process.env.ANDROID_NDK ??
642
+ readdirSync(join(sdkRoot, "ndk")).map((v) => join(sdkRoot, "ndk", v))
643
+ .filter((d) => existsSync(join(d, "toolchains"))).sort().pop();
644
+ const stripTool = (() => {
645
+ if (!ndkRoot) return null;
646
+ const prebuiltDir = join(ndkRoot, "toolchains/llvm/prebuilt");
647
+ if (!existsSync(prebuiltDir)) return null;
648
+ for (const host of readdirSync(prebuiltDir)) {
649
+ const p = join(prebuiltDir, host, "bin", process.platform === "win32" ? "llvm-strip.exe" : "llvm-strip");
650
+ if (existsSync(p)) return p;
651
+ }
652
+ return null;
653
+ })();
654
+
655
+ // Which optional engine cdylibs the bundle references — scan staged .mjs
656
+ // for the crate's base token (dlopen paths derive sonames from
657
+ // "downdraft_<lib>"). Unreferenced libs cost only APK size (never dlopened
658
+ // → never mapped), but pruning them keeps packages honest per game.
659
+ const bundleText = walkDir(bundleDir)
660
+ .filter((f) => f.endsWith(".mjs") || f.endsWith(".js"))
661
+ .map((f) => readFileSync(f, "utf-8"))
662
+ .join("\n");
663
+
664
+ for (const abi of abis) {
665
+ const triple = ABIS[abi];
666
+ const libDir = join(nativeStage, abi);
667
+ mkdirSync(libDir, { recursive: true });
668
+
669
+ // libnode.so — node-mobile build tree or npm platform package. Resolved
670
+ // BEFORE build-native: the shell crate links against it (DT_NEEDED) via
671
+ // DOWNDRAFT_LIBNODE_DIR. --node-flavor=<f> looks in dist/android-<f>/ first.
672
+ const libnodeCandidates = [
673
+ nodeFlavor !== "full"
674
+ ? join(monorepoRoot, `packages/node-mobile/build/dist/android-${nodeFlavor}`, abi, "libnode.so")
675
+ : null,
676
+ join(monorepoRoot, "packages/node-mobile/build/dist/android", abi, "libnode.so"),
677
+ join(monorepoRoot, `packages/native-mobile-android-${abi.startsWith("arm64") ? "arm64" : "x64"}/lib/libnode.so`),
678
+ ].filter(Boolean);
679
+ let libnode = libnodeCandidates.find(existsSync);
680
+ if (!libnode) {
681
+ try { libnode = join(dirname(gameRequire.resolve(`@downdraft/native-mobile-android-${abi.startsWith("arm64") ? "arm64" : "x64"}/package.json`)), "lib/libnode.so"); } catch { /* absent */ }
682
+ }
683
+ if (!libnode || !existsSync(libnode)) {
684
+ throw new Error(`libnode.so for ${abi} not found — build via packages/node-mobile/scripts/build-android.mjs`);
685
+ }
686
+
687
+ // Ensure the engine crates are built for this target.
688
+ run("node", [join(monorepoRoot, "scripts/build-native.mjs"), `--target=${triple}`, "--profile=release"], {
689
+ cwd: monorepoRoot,
690
+ env: { ...process.env, DOWNDRAFT_LIBNODE_DIR: dirname(libnode) },
691
+ });
692
+
693
+ // libdowndraft_android.so — the shell (platform statics inside).
694
+ const shellSo = join(monorepoRoot, "packages/android-shell/dist", `android-${abi.startsWith("arm64") ? "arm64" : "x64"}`, "libdowndraft_android.so");
695
+ if (!existsSync(shellSo)) throw new Error(`shell lib missing: ${shellSo}`);
696
+ copyFileSync(shellSo, join(libDir, "libdowndraft_android.so"));
697
+
698
+ copyFileSync(libnode, join(libDir, "libnode.so"));
699
+
700
+ // libc++_shared.so — libnode.so NEEDs it (Rust crates static-link libc++
701
+ // and don't). Prefer the sibling staged by build-android.mjs — it comes
702
+ // from the same NDK that built libnode (a mismatched libc++ can lack
703
+ // symbols libnode references). Fall back to the NDK sysroot lib dir.
704
+ const cxxCandidates = [join(dirname(libnode), "libc++_shared.so")];
705
+ if (ndkRoot) {
706
+ cxxCandidates.push(join(ndkRoot,
707
+ "toolchains/llvm/prebuilt/linux-x86_64/sysroot/usr/lib", triple, "libc++_shared.so"));
708
+ }
709
+ const cxxShared = cxxCandidates.find(existsSync);
710
+ if (cxxShared) {
711
+ copyFileSync(cxxShared, join(libDir, "libc++_shared.so"));
712
+ } else {
713
+ console.warn("[package-mobile] libc++_shared.so not found — libnode will fail to load");
714
+ }
715
+
716
+ // Optional engine cdylibs — stage only what the bundle references (or
717
+ // --libs= forces). JS resolves them by soname via the app lib dir.
718
+ const crates = await import(fileURLToPath(new URL("../../../scripts/native-crates.mjs", import.meta.url)));
719
+ const nodePlat = `android-${abi.startsWith("arm64") ? "arm64" : "x64"}`;
720
+ for (const crate of crates.CRATES) {
721
+ if (crate.androidOnly || crate.noAndroid) continue;
722
+ const name = crate.lib.replace(/^downdraft_/, "");
723
+ if (excludeLibs.includes(name) || excludeLibs.includes(crate.lib)) continue;
724
+ const wanted = forceLibs.includes(name) || forceLibs.includes(crate.lib) ||
725
+ bundleText.includes(crate.lib);
726
+ if (!wanted) {
727
+ console.log(`[package-mobile] skipping ${crate.lib} — not referenced by bundle`);
728
+ continue;
729
+ }
730
+ const file = `lib${crate.lib}.so`;
731
+ const src = join(monorepoRoot, crate.dest, nodePlat, file);
732
+ if (existsSync(src)) copyFileSync(src, join(libDir, file));
733
+ }
734
+
735
+ // Game-native cdylibs — <game>/native/dist/android-<arch>/lib*.so ride in
736
+ // jniLibs like the engine crates: the OS installs them to the app's native
737
+ // lib dir, where koffi/Deno.dlopen resolves them by bare soname (same
738
+ // mechanism lib-paths.ts's android branch relies on).
739
+ const gamePlatDir = join(gameDir, "native", "dist", nodePlat);
740
+ if (existsSync(gamePlatDir)) {
741
+ for (const f of readdirSync(gamePlatDir)) {
742
+ if (f.startsWith("lib") && f.endsWith(".so")) {
743
+ copyFileSync(join(gamePlatDir, f), join(libDir, f));
744
+ console.log(`[package-mobile] staged game-native ${f} → lib/${abi}/`);
745
+ }
746
+ }
747
+ }
748
+
749
+ // Strip staged .so's — debug info and the symtab never map at runtime, so
750
+ // this is pure APK/install size (libnode alone sheds ~25MB). Crash
751
+ // symbolication still works: .dynsym and unwind info are preserved.
752
+ if (doStrip && stripTool) {
753
+ for (const f of readdirSync(libDir).filter((f) => f.endsWith(".so"))) {
754
+ try { run(stripTool, ["--strip-unneeded", join(libDir, f)]); } catch { /* keep unstripped */ }
755
+ }
756
+ }
757
+ }
758
+
759
+ // ── APK assembly ──
760
+
761
+ const manifestTpl = readFileSync(join(monorepoRoot, "packages/android-shell/AndroidManifest.xml"), "utf-8");
762
+ const manifest = manifestTpl
763
+ .replaceAll("@APP_ID@", appId)
764
+ .replaceAll("@APP_NAME@", appName.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll('"', "&quot;"))
765
+ .replaceAll("@VERSION_CODE@", String(versionCode))
766
+ .replaceAll("@VERSION_NAME@", versionName.replaceAll("&", "&amp;").replaceAll("<", "&lt;"))
767
+ .replaceAll("@MIN_SDK@", minSdk)
768
+ .replaceAll("@DEBUGGABLE@", modeArg === "dev" ? "true" : "false");
769
+ writeFileSync(join(stageRoot, "AndroidManifest.xml"), manifest);
770
+
771
+ const unsigned = join(stageRoot, "unsigned.apk");
772
+ const aligned = join(stageRoot, "aligned.apk");
773
+ const outfile = resolve(outfileArg);
774
+
775
+ run(join(bt, "aapt2"), [
776
+ "link", "-o", unsigned,
777
+ "-I", androidJar,
778
+ "--manifest", join(stageRoot, "AndroidManifest.xml"),
779
+ "-A", join(stageRoot, "assets"),
780
+ "--auto-add-overlay",
781
+ ]);
782
+
783
+ // jniLibs → lib/<abi>/*.so inside the APK (extractNativeLibs=true allows
784
+ // compressed storage — plain zip is correct here).
785
+ run("zip", ["-qr", unsigned, "lib"], { cwd: stageRoot });
786
+ run(join(bt, "zipalign"), ["-f", "-p", "4", unsigned, aligned]);
787
+
788
+ // Sign with the standard Android debug key unless a keystore is provided.
789
+ const keystore = flagValue("keystore") ?? join(process.env.HOME ?? "", ".android/debug.keystore");
790
+ const ksPass = flagValue("keystore-pass") ?? "android";
791
+ const ksAlias = flagValue("keystore-alias") ?? "androiddebugkey";
792
+ if (!existsSync(keystore)) {
793
+ console.log("[package-mobile] no debug keystore — generating one");
794
+ mkdirSync(dirname(keystore), { recursive: true });
795
+ run("keytool", [
796
+ "-genkeypair", "-v", "-keystore", keystore,
797
+ "-storepass", ksPass, "-alias", ksAlias, "-keypass", ksPass,
798
+ "-keyalg", "RSA", "-keysize", "2048", "-validity", "10000",
799
+ "-dname", "CN=Android Debug,O=Downdraft,C=US",
800
+ ]);
801
+ }
802
+ mkdirSync(dirname(outfile), { recursive: true });
803
+ run(join(bt, "apksigner"), [
804
+ "sign", "--ks", keystore, "--ks-pass", `pass:${ksPass}`,
805
+ "--ks-key-alias", ksAlias, "--key-pass", `pass:${ksPass}`,
806
+ "--out", outfile, aligned,
807
+ ]);
808
+
809
+ console.log(`\n[package-mobile] ${outfile}`);
810
+ console.log(` app: ${appName} (${appId}) v${versionName} (${versionCode})`);
811
+ console.log(` abis: ${abis.join(", ")}`);
812
+ console.log(` bundle: ${entrypoints.length} entries, ${stagedAssets} assets`);
813
+
814
+ // ── install / launch ──
815
+
816
+ if (doInstall) {
817
+ run(adb, ["install", "-r", outfile]);
818
+ }
819
+ if (doLaunch) {
820
+ run(adb, ["shell", "am", "start", "-n", `${appId}/android.app.NativeActivity`]);
821
+ console.log(`[package-mobile] launched — logs: adb logcat -s downdraft:D`);
822
+ }