mechty

Tomodachi NXNATIVE

Oct 30th, 2026
47
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
text 20.43 KB | None | 0 0
  1. name: Tomodachi-Native - IPA Builder
  2.  
  3. on:
  4. workflow_dispatch:
  5. inputs:
  6. profile_id:
  7. description: "Title ID del juego (por defecto Tomodachi: 010051F0207B2000)"
  8. required: false
  9. type: string
  10. default: "010051F0207B2000"
  11. runner:
  12. description: "Runner de GitHub (macos-latest = Xcode 26, recomendado; xcode-27 = preview con Xcode 27)"
  13. required: false
  14. type: string
  15. default: "macos-latest"
  16.  
  17. permissions:
  18. contents: read
  19.  
  20. jobs:
  21. build:
  22. runs-on: ${{ inputs.runner || 'macos-latest' }}
  23. timeout-minutes: 360
  24.  
  25. steps:
  26. - name: Checkout tomodachi-native
  27. uses: actions/checkout@v4
  28. with:
  29. repository: kuosuko/tomodachi-native
  30. ref: 5587d1bd8e311b5508ed4e8d5310796523d06ed6
  31.  
  32. # La fuente usa APIs del SDK iOS 27.1 (UIHingeInteraction, UIViewReservedRegion).
  33. # Verifica que el Xcode activo las declare.
  34. - name: Check Xcode / iOS SDK
  35. shell: bash
  36. run: |
  37. echo "=== Xcode activo ==="
  38. xcode-select -p
  39. xcodebuild -version
  40. xcrun --sdk iphoneos --show-sdk-version || true
  41. echo "=== Xcodes instalados ==="
  42. ls -d /Applications/Xcode*.app 2>/dev/null || true
  43.  
  44. SDK="$(xcrun --sdk iphoneos --show-sdk-path)"
  45. if grep -rqs "UIHingeInteraction" "$SDK/System/Library/Frameworks/UIKit.framework/Headers"; then
  46. echo "HAS_HINGE_SDK=true" >> "$GITHUB_ENV"
  47. echo "OK: el SDK activo declara UIHingeInteraction."
  48. else
  49. echo "HAS_HINGE_SDK=false" >> "$GITHUB_ENV"
  50. echo "::warning::El SDK activo NO declara UIHingeInteraction; se aplicara el parche de respaldo (se desactivan las llamadas a esas APIs)."
  51. fi
  52.  
  53. # Las librerias de shaders se compilan con 'xcrun metal'. En imagenes Xcode 27
  54. # el Metal Toolchain puede faltar.
  55. - name: Ensure Metal toolchain
  56. shell: bash
  57. run: |
  58. if ! xcrun -sdk macosx metal --version; then
  59. echo "Descargando Metal Toolchain..."
  60. xcodebuild -downloadComponent MetalToolchain
  61. xcrun -sdk macosx metal --version
  62. fi
  63.  
  64. # Parche de RESPALDO: solo si el SDK no tiene las APIs del iPhone Duo.
  65. # Comenta lineas con esas APIs y cierra las llaves que abra; si las llaves
  66. # no cuadran, aborta en segundos.
  67. - name: Fallback patch - disable iOS 27.1 foldable APIs
  68. if: env.HAS_HINGE_SDK != 'true'
  69. shell: bash
  70. run: |
  71. python3 - <<'PY'
  72. import re
  73. from pathlib import Path
  74.  
  75. TOKENS = ("UIViewReservedRegion", "UIHinge", "reservedRegion", "hingeChanged")
  76. KEYWORDS = {"if", "for", "while", "switch", "return", "else", "do", "sizeof"}
  77. DECL = [
  78. re.compile(r"\(\^\s*(\w+)\s*\)"),
  79. re.compile(r"\b(\w+)\s*=\s*\^"),
  80. re.compile(r"^\s*(?:static\s+|inline\s+)*[A-Za-z_][\w \*<>]*?[ \*](\w+)\s*\([^;]*\)\s*\{?\s*$"),
  81. ]
  82.  
  83. def declared_names(ls):
  84. names = set()
  85. for l in ls:
  86. for pat in DECL:
  87. for n in pat.findall(l):
  88. if n not in KEYWORDS:
  89. names.add(n)
  90. return names
  91.  
  92. def delta(ls):
  93. return sum(l.count("{") - l.count("}") for l in ls)
  94.  
  95. def patch(path):
  96. lines = path.read_text(encoding="utf-8").splitlines()
  97. hits = [i for i, l in enumerate(lines)
  98. if any(t in l for t in TOKENS) and not l.lstrip().startswith("//")]
  99. if not hits:
  100. return 0
  101. orig = delta(lines)
  102. commented = set()
  103. for i in hits:
  104. if i in commented:
  105. continue
  106. group = set()
  107. k = i - 1
  108. while k >= 0 and not lines[k].strip():
  109. k -= 1
  110. if k >= 0:
  111. prev = lines[k].strip()
  112. if prev.endswith("{") and prev.startswith(("if", "else", "@available")):
  113. group.add(k)
  114. j = i
  115. group.add(j)
  116. while j + 1 < len(lines) and not lines[j].rstrip().endswith((";", "{", "}")):
  117. j += 1
  118. group.add(j)
  119. pending = delta([lines[x] for x in group])
  120. while pending > 0 and j + 1 < len(lines):
  121. j += 1
  122. group.add(j)
  123. pending += lines[j].count("{") - lines[j].count("}")
  124. commented |= group
  125. new = delta([l for i, l in enumerate(lines) if i not in commented])
  126. if new != orig:
  127. print(f"--- contexto de {path} ---")
  128. for i in sorted(hits):
  129. for n in range(max(0, i - 3), min(len(lines), i + 8)):
  130. print(f"{n+1}: {lines[n]}")
  131. print("...")
  132. raise SystemExit(f"{path}: llaves desbalanceadas tras el parche ({orig} -> {new}). Abortando.")
  133. out = [("// " + l) if i in commented else l for i, l in enumerate(lines)]
  134.  
  135. # Si se comento la definicion de un helper/bloque (p.ej. "hit") que el
  136. # codigo restante sigue llamando, se define como macro que devuelve 0.
  137. active = [l for i, l in enumerate(lines) if i not in commented]
  138. active_text = "\n".join(active)
  139. gone = declared_names([lines[i] for i in commented]) - declared_names(active)
  140. stubs = [n for n in sorted(gone) if re.search(r"\b" + re.escape(n) + r"\s*\(", active_text)]
  141. if stubs:
  142. at = 0
  143. for i, l in enumerate(out):
  144. if re.match(r"\s*#\s*(import|include)\b", l):
  145. at = i + 1
  146. macros = ["/* dm fallback: helper desactivado */"] + [f"#define {n}(...) 0" for n in stubs]
  147. out[at:at] = macros
  148. print(f"{path}: macros de respaldo para {stubs}")
  149.  
  150. path.write_text("\n".join(out) + "\n", encoding="utf-8")
  151. print(f"{path}: {len(commented)} lineas comentadas")
  152. return len(commented)
  153.  
  154. total = 0
  155. for f in sorted(Path("dm/ios/Sources").rglob("*.m")) + sorted(Path("dm/ios/Sources").rglob("*.mm")):
  156. total += patch(f)
  157. print(f"Total: {total} lineas desactivadas")
  158. PY
  159. git --no-pager diff --stat || true
  160. echo "=== Diff completo del parche ==="
  161. git --no-pager diff -U2 -- dm/ios/Sources || true
  162.  
  163. - name: Set up Python
  164. uses: actions/setup-python@v5
  165. with:
  166. python-version: "3.13"
  167.  
  168. # El fork necesita .NET 10 (nx_extract con LibHac y el traductor de shaders)
  169. - name: Set up .NET SDK
  170. uses: actions/setup-dotnet@v4
  171. with:
  172. dotnet-version: "10.0.x"
  173.  
  174. - name: Install dependencies
  175. shell: bash
  176. run: |
  177. set -euo pipefail
  178. brew install xcodegen zstd spirv-cross unar
  179. python -m pip install --upgrade pip
  180. python -m pip install lz4 capstone fonttools backports.zstd gdown
  181.  
  182. - name: Download game files and keys
  183. env:
  184. GAME_URL: ${{ secrets.Game_URL }}
  185. UPDATE_URL: ${{ secrets.Update_URL }}
  186. DLC_URL: ${{ secrets.DLC_URL }}
  187. KEYS_URL: ${{ secrets.Keys_URL }}
  188. shell: bash
  189. run: |
  190. set -euo pipefail
  191. WORK="$RUNNER_TEMP/nxnative"
  192. mkdir -p "$WORK"
  193.  
  194. download_file() {
  195. local url="$1"
  196. local output="$2"
  197.  
  198. if [[ -z "${url:-}" ]]; then
  199. echo "::error::A required download URL is empty."
  200. exit 1
  201. fi
  202.  
  203. if [[ "$url" =~ drive\.google\.com ]]; then
  204. local file_id
  205. file_id="$(python - "$url" <<'PY'
  206. import re
  207. import sys
  208. from urllib.parse import urlparse, parse_qs
  209.  
  210. url = sys.argv[1].strip()
  211. parsed = urlparse(url)
  212. query = parse_qs(parsed.query)
  213. file_id = None
  214.  
  215. if parsed.netloc.lower() in ("drive.google.com", "www.drive.google.com"):
  216. match = re.search(r"/file/d/([^/]+)", parsed.path)
  217. if match:
  218. file_id = match.group(1)
  219. elif query.get("id"):
  220. file_id = query["id"][0]
  221.  
  222. if not file_id and re.fullmatch(r"[-A-Za-z0-9_]{20,}", url):
  223. file_id = url
  224.  
  225. if not file_id:
  226. raise SystemExit("Could not extract a Google Drive file ID.")
  227.  
  228. print(file_id)
  229. PY
  230. )"
  231. echo "Downloading $(basename "$output") via gdown..."
  232. gdown "$file_id" -O "$output"
  233. else
  234. if [[ "$url" =~ dropbox\.com ]]; then
  235. url="${url/\?dl=0/?dl=1}"
  236. url="${url/&dl=0/&dl=1}"
  237. if [[ ! "$url" =~ dl=1 ]]; then
  238. url="${url}?dl=1"
  239. fi
  240. fi
  241.  
  242. echo "Downloading $(basename "$output") via direct link..."
  243. curl -L -f --retry 3 --retry-delay 5 -o "$output" "$url"
  244. fi
  245.  
  246. if [[ ! -s "$output" ]]; then
  247. echo "::error::Downloaded file is empty."
  248. exit 1
  249. fi
  250.  
  251. file "$output"
  252. }
  253.  
  254. download_file "$GAME_URL" "$WORK/game.download"
  255. download_file "$KEYS_URL" "$WORK/prod.keys"
  256.  
  257. if [[ -n "${UPDATE_URL:-}" ]]; then
  258. download_file "$UPDATE_URL" "$WORK/update.download"
  259. fi
  260.  
  261. if [[ -n "${DLC_URL:-}" ]]; then
  262. download_file "$DLC_URL" "$WORK/dlc.download"
  263. fi
  264.  
  265. chmod 600 "$WORK/prod.keys"
  266.  
  267. - name: Restore compiled shaders cache
  268. uses: actions/cache/restore@v4
  269. with:
  270. path: ${{ runner.temp }}/GameFiles/*_build/shader
  271. key: shader-cache-${{ inputs.profile_id || '010051F0207B2000' }}
  272.  
  273. # Solo prepara la carpeta con los NSP/XCI. La extraccion real la hace
  274. # build_ipa_from_nsp.py (con LibHac), no hactool.
  275. - name: Prepare game package folder
  276. env:
  277. PROFILE_OVERRIDE: ${{ inputs.profile_id }}
  278. shell: bash
  279. run: |
  280. set -euo pipefail
  281. PROFILE_ID="${PROFILE_OVERRIDE:-010051F0207B2000}"
  282. PROFILE_ID="$(echo "$PROFILE_ID" | tr '[:lower:]' '[:upper:]')"
  283. WORK="$RUNNER_TEMP/nxnative"
  284. GAME_DIR="$RUNNER_TEMP/GameFiles"
  285. mkdir -p "$GAME_DIR"
  286.  
  287. python - "$WORK" "$GAME_DIR" "$PROFILE_ID" <<'PY'
  288. import re
  289. import shutil
  290. import struct
  291. import subprocess
  292. import sys
  293. from pathlib import Path
  294.  
  295. work = Path(sys.argv[1])
  296. game_dir = Path(sys.argv[2])
  297. profile_id = sys.argv[3]
  298. package_extensions = {".nsp", ".xci"}
  299. archive_signatures = (
  300. b"Rar!\x1a\x07\x00",
  301. b"Rar!\x1a\x07\x01\x00",
  302. b"PK\x03\x04",
  303. b"7z\xbc\xaf\x27\x1c",
  304. )
  305.  
  306. def xci_to_nsp(xci, nsp):
  307. with xci.open("rb") as f:
  308. def rd(off, n):
  309. f.seek(off)
  310. return f.read(n)
  311.  
  312. base = next((b for b in (0x0, 0x1000) if rd(b + 0x100, 4) == b"HEAD"), None)
  313. if base is None:
  314. raise SystemExit(f"{xci.name}: not a valid XCI (no HEAD header).")
  315. root = base + (struct.unpack("<I", rd(base + 0x130, 4))[0] * 0x200)
  316. if rd(root, 4) != b"HFS0":
  317. root = base + 0xF000
  318.  
  319. def hfs0(off):
  320. h = rd(off, 0x10)
  321. if h[:4] != b"HFS0":
  322. raise SystemExit(f"{xci.name}: invalid HFS0 partition at 0x{off:X}.")
  323. count, strsz = struct.unpack_from("<II", h, 4)
  324. ents = rd(off + 0x10, count * 0x40)
  325. strs = rd(off + 0x10 + count * 0x40, strsz)
  326. data = off + 0x10 + count * 0x40 + strsz
  327. out = []
  328. for i in range(count):
  329. o, s, n = struct.unpack_from("<QQI", ents, i * 0x40)
  330. out.append((strs[n:strs.index(b"\0", n)].decode(), data + o, s))
  331. return out
  332.  
  333. parts = {name: off for name, off, _ in hfs0(root)}
  334. if "secure" not in parts:
  335. raise SystemExit(f"{xci.name}: no 'secure' partition found.")
  336. files = hfs0(parts["secure"])
  337. if not files:
  338. raise SystemExit(f"{xci.name}: the 'secure' partition is empty.")
  339.  
  340. names, name_offs = b"", []
  341. for name, _, _ in files:
  342. name_offs.append(len(names))
  343. names += name.encode() + b"\0"
  344. names += b"\0" * ((-(0x10 + len(files) * 0x18 + len(names))) % 0x20)
  345.  
  346. entries, pos = b"", 0
  347. for (_, _, size), no in zip(files, name_offs):
  348. entries += struct.pack("<QQII", pos, size, no, 0)
  349. pos += size
  350.  
  351. with nsp.open("wb") as out:
  352. out.write(b"PFS0" + struct.pack("<III", len(files), len(names), 0))
  353. out.write(entries + names)
  354. for name, off, size in files:
  355. f.seek(off)
  356. left = size
  357. while left:
  358. chunk = f.read(min(left, 8 * 1024 * 1024))
  359. if not chunk:
  360. raise SystemExit(f"{xci.name}: truncated file (is the download complete?).")
  361. out.write(chunk)
  362. left -= len(chunk)
  363. print(f"Converted {xci.name} -> {nsp.name}: {len(files)} files, {nsp.stat().st_size:,} bytes")
  364.  
  365. def final_name(label, original_name, ext):
  366. if re.search(r"\[[0-9A-Fa-f]{16}\]", original_name):
  367. return original_name
  368. if label == "game":
  369. return f"Tomodachi Life [{profile_id}][v0]{ext}"
  370. if label == "update":
  371. upd_id = f"{int(profile_id, 16) + 0x800:016X}"
  372. return f"Tomodachi Life [{upd_id}][v65536]{ext}"
  373. return f"{label}{ext}"
  374.  
  375. def add(source, label):
  376. if source.suffix.lower() == ".xci":
  377. nsp = source.with_suffix(".nsp")
  378. xci_to_nsp(source, nsp)
  379. source.unlink()
  380. source = nsp
  381. target = game_dir / final_name(label, source.name, source.suffix.lower())
  382. if target.exists():
  383. raise SystemExit(f"Duplicate package filename: {target.name}")
  384. shutil.move(str(source), str(target))
  385. print(f"Added {label}: {target.name} ({target.stat().st_size:,} bytes)")
  386.  
  387. def process(label):
  388. source = work / f"{label}.download"
  389. if not source.exists():
  390. print(f"No {label} download; skipping.")
  391. return
  392.  
  393. with source.open("rb") as f:
  394. sig = f.read(8)
  395. f.seek(0x100)
  396. xci_sig = f.read(4)
  397. f.seek(0x1100)
  398. xci_sig2 = f.read(4)
  399.  
  400. if any(sig.startswith(m) for m in archive_signatures):
  401. ext_dir = work / f"extracted-{label}"
  402. ext_dir.mkdir(parents=True, exist_ok=True)
  403. subprocess.run(["unar", "-o", str(ext_dir), str(source)], check=True)
  404. packages = sorted(
  405. p for p in ext_dir.rglob("*")
  406. if p.is_file() and p.suffix.lower() in package_extensions
  407. )
  408. if not packages:
  409. raise SystemExit(f"No NSP/XCI found inside the {label} archive.")
  410. for p in packages:
  411. add(p, label)
  412. elif sig[:4] == b"PFS0":
  413. nsp = source.with_name(f"{label}.nsp")
  414. source.rename(nsp)
  415. add(nsp, label)
  416. elif b"HEAD" in (xci_sig, xci_sig2):
  417. xci = source.with_name(f"{label}.xci")
  418. source.rename(xci)
  419. add(xci, label)
  420. else:
  421. raise SystemExit(
  422. f"{label}: not an archive, an NSP or an XCI. "
  423. "Check the download link (Google Drive/Dropbox may have returned an HTML page)."
  424. )
  425.  
  426. process("game")
  427. process("update")
  428. process("dlc")
  429.  
  430. packages = sorted(
  431. p for p in game_dir.iterdir()
  432. if p.is_file() and p.suffix.lower() in package_extensions
  433. )
  434. if not packages:
  435. raise SystemExit("No game packages were prepared.")
  436.  
  437. print("\n=== Prepared game packages ===")
  438. for p in packages:
  439. print(f"{p.name}: {p.stat().st_size:,} bytes")
  440. PY
  441.  
  442. test -s "$WORK/prod.keys"
  443.  
  444. - name: Set shader profile and update toolchain header
  445. env:
  446. PROFILE_OVERRIDE: ${{ inputs.profile_id }}
  447. shell: bash
  448. run: |
  449. set -euo pipefail
  450. PROFILE_ID="${PROFILE_OVERRIDE:-010051F0207B2000}"
  451. PROFILE_ID="$(echo "$PROFILE_ID" | tr '[:lower:]' '[:upper:]')"
  452.  
  453. if [[ ! "$PROFILE_ID" =~ ^[0-9A-F]{16}$ ]]; then
  454. echo "::error::Profile ID must be exactly 16 hexadecimal characters."
  455. exit 1
  456. fi
  457.  
  458. echo "DM_GAME=$PROFILE_ID" >> "$GITHUB_ENV"
  459. echo "GAME=$PROFILE_ID" >> "$GITHUB_ENV"
  460. export DM_GAME="$PROFILE_ID"
  461.  
  462. python dm/gfx/tools/dmsh_chain.py --update-lock
  463. python dm/gfx/tools/dmsh_chain.py --header
  464.  
  465. # Este script hace TODO: extraer, reescribir modulos (patch_nso.py),
  466. # separar shaders, compilar librerias iOS y llamar a build_ipa.sh.
  467. - name: Build unsigned IPA
  468. shell: bash
  469. run: |
  470. set -euo pipefail
  471. WORK="$RUNNER_TEMP/nxnative"
  472. GAME_DIR="$RUNNER_TEMP/GameFiles"
  473. OUTPUT="$RUNNER_TEMP/Tomodachi-Native.ipa"
  474.  
  475. python scripts/build_ipa_from_nsp.py \
  476. "$GAME_DIR" \
  477. -k "$WORK/prod.keys" \
  478. -o "$OUTPUT" \
  479. --app-name "Tomodachi Life"
  480.  
  481. if [[ ! -s "$OUTPUT" ]]; then
  482. echo "::error::IPA build did not produce an output file."
  483. exit 1
  484. fi
  485.  
  486. echo "IPA successfully created: $OUTPUT"
  487. ls -lh "$OUTPUT"
  488.  
  489. - name: Save compiled shaders cache
  490. uses: actions/cache/save@v4
  491. if: always()
  492. with:
  493. path: ${{ runner.temp }}/GameFiles/*_build/shader
  494. key: shader-cache-${{ inputs.profile_id || '010051F0207B2000' }}
  495.  
  496. - name: Upload IPA
  497. if: success()
  498. uses: actions/upload-artifact@v4
  499. with:
  500. name: Tomodachi-Native-IPA
  501. path: ${{ runner.temp }}/Tomodachi-Native.ipa
  502. if-no-files-found: error
  503. retention-days: 7
  504.  
  505. # Ahora tambien sube el log completo de xcodebuild (build.log)
  506. - name: Upload build logs if available
  507. if: always()
  508. uses: actions/upload-artifact@v4
  509. with:
  510. name: Tomodachi-Build-Logs
  511. path: |
  512. ${{ runner.temp }}/GameFiles/*_build/logs/
  513. ${{ runner.temp }}/GameFiles/*_build/ipa/build/ios/pkg/build.log
  514. ${{ runner.temp }}/GameFiles/*_survey/
  515. if-no-files-found: ignore
  516. retention-days: 7
  517.  
  518. - name: Clean up downloaded files and keys
  519. if: always()
  520. shell: bash
  521. run: |
  522. rm -rf "$RUNNER_TEMP/nxnative"
  523. rm -rf "$RUNNER_TEMP/GameFiles"/*_build/extract
  524. rm -rf "$RUNNER_TEMP/GameFiles"/*.nsp
  525. rm -rf "$RUNNER_TEMP/GameFiles"/*.xci
Add Comment
Please, Sign In to add comment