Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- name: Tomodachi-Native - IPA Builder
- on:
- workflow_dispatch:
- inputs:
- profile_id:
- description: "Title ID del juego (por defecto Tomodachi: 010051F0207B2000)"
- required: false
- type: string
- default: "010051F0207B2000"
- runner:
- description: "Runner de GitHub (macos-latest = Xcode 26, recomendado; xcode-27 = preview con Xcode 27)"
- required: false
- type: string
- default: "macos-latest"
- permissions:
- contents: read
- jobs:
- build:
- runs-on: ${{ inputs.runner || 'macos-latest' }}
- timeout-minutes: 360
- steps:
- - name: Checkout tomodachi-native
- uses: actions/checkout@v4
- with:
- repository: kuosuko/tomodachi-native
- ref: 5587d1bd8e311b5508ed4e8d5310796523d06ed6
- # La fuente usa APIs del SDK iOS 27.1 (UIHingeInteraction, UIViewReservedRegion).
- # Verifica que el Xcode activo las declare.
- - name: Check Xcode / iOS SDK
- shell: bash
- run: |
- echo "=== Xcode activo ==="
- xcode-select -p
- xcodebuild -version
- xcrun --sdk iphoneos --show-sdk-version || true
- echo "=== Xcodes instalados ==="
- ls -d /Applications/Xcode*.app 2>/dev/null || true
- SDK="$(xcrun --sdk iphoneos --show-sdk-path)"
- if grep -rqs "UIHingeInteraction" "$SDK/System/Library/Frameworks/UIKit.framework/Headers"; then
- echo "HAS_HINGE_SDK=true" >> "$GITHUB_ENV"
- echo "OK: el SDK activo declara UIHingeInteraction."
- else
- echo "HAS_HINGE_SDK=false" >> "$GITHUB_ENV"
- echo "::warning::El SDK activo NO declara UIHingeInteraction; se aplicara el parche de respaldo (se desactivan las llamadas a esas APIs)."
- fi
- # Las librerias de shaders se compilan con 'xcrun metal'. En imagenes Xcode 27
- # el Metal Toolchain puede faltar.
- - name: Ensure Metal toolchain
- shell: bash
- run: |
- if ! xcrun -sdk macosx metal --version; then
- echo "Descargando Metal Toolchain..."
- xcodebuild -downloadComponent MetalToolchain
- xcrun -sdk macosx metal --version
- fi
- # Parche de RESPALDO: solo si el SDK no tiene las APIs del iPhone Duo.
- # Comenta lineas con esas APIs y cierra las llaves que abra; si las llaves
- # no cuadran, aborta en segundos.
- - name: Fallback patch - disable iOS 27.1 foldable APIs
- if: env.HAS_HINGE_SDK != 'true'
- shell: bash
- run: |
- python3 - <<'PY'
- import re
- from pathlib import Path
- TOKENS = ("UIViewReservedRegion", "UIHinge", "reservedRegion", "hingeChanged")
- KEYWORDS = {"if", "for", "while", "switch", "return", "else", "do", "sizeof"}
- DECL = [
- re.compile(r"\(\^\s*(\w+)\s*\)"),
- re.compile(r"\b(\w+)\s*=\s*\^"),
- re.compile(r"^\s*(?:static\s+|inline\s+)*[A-Za-z_][\w \*<>]*?[ \*](\w+)\s*\([^;]*\)\s*\{?\s*$"),
- ]
- def declared_names(ls):
- names = set()
- for l in ls:
- for pat in DECL:
- for n in pat.findall(l):
- if n not in KEYWORDS:
- names.add(n)
- return names
- def delta(ls):
- return sum(l.count("{") - l.count("}") for l in ls)
- def patch(path):
- lines = path.read_text(encoding="utf-8").splitlines()
- hits = [i for i, l in enumerate(lines)
- if any(t in l for t in TOKENS) and not l.lstrip().startswith("//")]
- if not hits:
- return 0
- orig = delta(lines)
- commented = set()
- for i in hits:
- if i in commented:
- continue
- group = set()
- k = i - 1
- while k >= 0 and not lines[k].strip():
- k -= 1
- if k >= 0:
- prev = lines[k].strip()
- if prev.endswith("{") and prev.startswith(("if", "else", "@available")):
- group.add(k)
- j = i
- group.add(j)
- while j + 1 < len(lines) and not lines[j].rstrip().endswith((";", "{", "}")):
- j += 1
- group.add(j)
- pending = delta([lines[x] for x in group])
- while pending > 0 and j + 1 < len(lines):
- j += 1
- group.add(j)
- pending += lines[j].count("{") - lines[j].count("}")
- commented |= group
- new = delta([l for i, l in enumerate(lines) if i not in commented])
- if new != orig:
- print(f"--- contexto de {path} ---")
- for i in sorted(hits):
- for n in range(max(0, i - 3), min(len(lines), i + 8)):
- print(f"{n+1}: {lines[n]}")
- print("...")
- raise SystemExit(f"{path}: llaves desbalanceadas tras el parche ({orig} -> {new}). Abortando.")
- out = [("// " + l) if i in commented else l for i, l in enumerate(lines)]
- # Si se comento la definicion de un helper/bloque (p.ej. "hit") que el
- # codigo restante sigue llamando, se define como macro que devuelve 0.
- active = [l for i, l in enumerate(lines) if i not in commented]
- active_text = "\n".join(active)
- gone = declared_names([lines[i] for i in commented]) - declared_names(active)
- stubs = [n for n in sorted(gone) if re.search(r"\b" + re.escape(n) + r"\s*\(", active_text)]
- if stubs:
- at = 0
- for i, l in enumerate(out):
- if re.match(r"\s*#\s*(import|include)\b", l):
- at = i + 1
- macros = ["/* dm fallback: helper desactivado */"] + [f"#define {n}(...) 0" for n in stubs]
- out[at:at] = macros
- print(f"{path}: macros de respaldo para {stubs}")
- path.write_text("\n".join(out) + "\n", encoding="utf-8")
- print(f"{path}: {len(commented)} lineas comentadas")
- return len(commented)
- total = 0
- for f in sorted(Path("dm/ios/Sources").rglob("*.m")) + sorted(Path("dm/ios/Sources").rglob("*.mm")):
- total += patch(f)
- print(f"Total: {total} lineas desactivadas")
- PY
- git --no-pager diff --stat || true
- echo "=== Diff completo del parche ==="
- git --no-pager diff -U2 -- dm/ios/Sources || true
- - name: Set up Python
- uses: actions/setup-python@v5
- with:
- python-version: "3.13"
- # El fork necesita .NET 10 (nx_extract con LibHac y el traductor de shaders)
- - name: Set up .NET SDK
- uses: actions/setup-dotnet@v4
- with:
- dotnet-version: "10.0.x"
- - name: Install dependencies
- shell: bash
- run: |
- set -euo pipefail
- brew install xcodegen zstd spirv-cross unar
- python -m pip install --upgrade pip
- python -m pip install lz4 capstone fonttools backports.zstd gdown
- - name: Download game files and keys
- env:
- GAME_URL: ${{ secrets.Game_URL }}
- UPDATE_URL: ${{ secrets.Update_URL }}
- DLC_URL: ${{ secrets.DLC_URL }}
- KEYS_URL: ${{ secrets.Keys_URL }}
- shell: bash
- run: |
- set -euo pipefail
- WORK="$RUNNER_TEMP/nxnative"
- mkdir -p "$WORK"
- download_file() {
- local url="$1"
- local output="$2"
- if [[ -z "${url:-}" ]]; then
- echo "::error::A required download URL is empty."
- exit 1
- fi
- if [[ "$url" =~ drive\.google\.com ]]; then
- local file_id
- file_id="$(python - "$url" <<'PY'
- import re
- import sys
- from urllib.parse import urlparse, parse_qs
- url = sys.argv[1].strip()
- parsed = urlparse(url)
- query = parse_qs(parsed.query)
- file_id = None
- if parsed.netloc.lower() in ("drive.google.com", "www.drive.google.com"):
- match = re.search(r"/file/d/([^/]+)", parsed.path)
- if match:
- file_id = match.group(1)
- elif query.get("id"):
- file_id = query["id"][0]
- if not file_id and re.fullmatch(r"[-A-Za-z0-9_]{20,}", url):
- file_id = url
- if not file_id:
- raise SystemExit("Could not extract a Google Drive file ID.")
- print(file_id)
- PY
- )"
- echo "Downloading $(basename "$output") via gdown..."
- gdown "$file_id" -O "$output"
- else
- if [[ "$url" =~ dropbox\.com ]]; then
- url="${url/\?dl=0/?dl=1}"
- url="${url/&dl=0/&dl=1}"
- if [[ ! "$url" =~ dl=1 ]]; then
- url="${url}?dl=1"
- fi
- fi
- echo "Downloading $(basename "$output") via direct link..."
- curl -L -f --retry 3 --retry-delay 5 -o "$output" "$url"
- fi
- if [[ ! -s "$output" ]]; then
- echo "::error::Downloaded file is empty."
- exit 1
- fi
- file "$output"
- }
- download_file "$GAME_URL" "$WORK/game.download"
- download_file "$KEYS_URL" "$WORK/prod.keys"
- if [[ -n "${UPDATE_URL:-}" ]]; then
- download_file "$UPDATE_URL" "$WORK/update.download"
- fi
- if [[ -n "${DLC_URL:-}" ]]; then
- download_file "$DLC_URL" "$WORK/dlc.download"
- fi
- chmod 600 "$WORK/prod.keys"
- - name: Restore compiled shaders cache
- uses: actions/cache/restore@v4
- with:
- path: ${{ runner.temp }}/GameFiles/*_build/shader
- key: shader-cache-${{ inputs.profile_id || '010051F0207B2000' }}
- # Solo prepara la carpeta con los NSP/XCI. La extraccion real la hace
- # build_ipa_from_nsp.py (con LibHac), no hactool.
- - name: Prepare game package folder
- env:
- PROFILE_OVERRIDE: ${{ inputs.profile_id }}
- shell: bash
- run: |
- set -euo pipefail
- PROFILE_ID="${PROFILE_OVERRIDE:-010051F0207B2000}"
- PROFILE_ID="$(echo "$PROFILE_ID" | tr '[:lower:]' '[:upper:]')"
- WORK="$RUNNER_TEMP/nxnative"
- GAME_DIR="$RUNNER_TEMP/GameFiles"
- mkdir -p "$GAME_DIR"
- python - "$WORK" "$GAME_DIR" "$PROFILE_ID" <<'PY'
- import re
- import shutil
- import struct
- import subprocess
- import sys
- from pathlib import Path
- work = Path(sys.argv[1])
- game_dir = Path(sys.argv[2])
- profile_id = sys.argv[3]
- package_extensions = {".nsp", ".xci"}
- archive_signatures = (
- b"Rar!\x1a\x07\x00",
- b"Rar!\x1a\x07\x01\x00",
- b"PK\x03\x04",
- b"7z\xbc\xaf\x27\x1c",
- )
- def xci_to_nsp(xci, nsp):
- with xci.open("rb") as f:
- def rd(off, n):
- f.seek(off)
- return f.read(n)
- base = next((b for b in (0x0, 0x1000) if rd(b + 0x100, 4) == b"HEAD"), None)
- if base is None:
- raise SystemExit(f"{xci.name}: not a valid XCI (no HEAD header).")
- root = base + (struct.unpack("<I", rd(base + 0x130, 4))[0] * 0x200)
- if rd(root, 4) != b"HFS0":
- root = base + 0xF000
- def hfs0(off):
- h = rd(off, 0x10)
- if h[:4] != b"HFS0":
- raise SystemExit(f"{xci.name}: invalid HFS0 partition at 0x{off:X}.")
- count, strsz = struct.unpack_from("<II", h, 4)
- ents = rd(off + 0x10, count * 0x40)
- strs = rd(off + 0x10 + count * 0x40, strsz)
- data = off + 0x10 + count * 0x40 + strsz
- out = []
- for i in range(count):
- o, s, n = struct.unpack_from("<QQI", ents, i * 0x40)
- out.append((strs[n:strs.index(b"\0", n)].decode(), data + o, s))
- return out
- parts = {name: off for name, off, _ in hfs0(root)}
- if "secure" not in parts:
- raise SystemExit(f"{xci.name}: no 'secure' partition found.")
- files = hfs0(parts["secure"])
- if not files:
- raise SystemExit(f"{xci.name}: the 'secure' partition is empty.")
- names, name_offs = b"", []
- for name, _, _ in files:
- name_offs.append(len(names))
- names += name.encode() + b"\0"
- names += b"\0" * ((-(0x10 + len(files) * 0x18 + len(names))) % 0x20)
- entries, pos = b"", 0
- for (_, _, size), no in zip(files, name_offs):
- entries += struct.pack("<QQII", pos, size, no, 0)
- pos += size
- with nsp.open("wb") as out:
- out.write(b"PFS0" + struct.pack("<III", len(files), len(names), 0))
- out.write(entries + names)
- for name, off, size in files:
- f.seek(off)
- left = size
- while left:
- chunk = f.read(min(left, 8 * 1024 * 1024))
- if not chunk:
- raise SystemExit(f"{xci.name}: truncated file (is the download complete?).")
- out.write(chunk)
- left -= len(chunk)
- print(f"Converted {xci.name} -> {nsp.name}: {len(files)} files, {nsp.stat().st_size:,} bytes")
- def final_name(label, original_name, ext):
- if re.search(r"\[[0-9A-Fa-f]{16}\]", original_name):
- return original_name
- if label == "game":
- return f"Tomodachi Life [{profile_id}][v0]{ext}"
- if label == "update":
- upd_id = f"{int(profile_id, 16) + 0x800:016X}"
- return f"Tomodachi Life [{upd_id}][v65536]{ext}"
- return f"{label}{ext}"
- def add(source, label):
- if source.suffix.lower() == ".xci":
- nsp = source.with_suffix(".nsp")
- xci_to_nsp(source, nsp)
- source.unlink()
- source = nsp
- target = game_dir / final_name(label, source.name, source.suffix.lower())
- if target.exists():
- raise SystemExit(f"Duplicate package filename: {target.name}")
- shutil.move(str(source), str(target))
- print(f"Added {label}: {target.name} ({target.stat().st_size:,} bytes)")
- def process(label):
- source = work / f"{label}.download"
- if not source.exists():
- print(f"No {label} download; skipping.")
- return
- with source.open("rb") as f:
- sig = f.read(8)
- f.seek(0x100)
- xci_sig = f.read(4)
- f.seek(0x1100)
- xci_sig2 = f.read(4)
- if any(sig.startswith(m) for m in archive_signatures):
- ext_dir = work / f"extracted-{label}"
- ext_dir.mkdir(parents=True, exist_ok=True)
- subprocess.run(["unar", "-o", str(ext_dir), str(source)], check=True)
- packages = sorted(
- p for p in ext_dir.rglob("*")
- if p.is_file() and p.suffix.lower() in package_extensions
- )
- if not packages:
- raise SystemExit(f"No NSP/XCI found inside the {label} archive.")
- for p in packages:
- add(p, label)
- elif sig[:4] == b"PFS0":
- nsp = source.with_name(f"{label}.nsp")
- source.rename(nsp)
- add(nsp, label)
- elif b"HEAD" in (xci_sig, xci_sig2):
- xci = source.with_name(f"{label}.xci")
- source.rename(xci)
- add(xci, label)
- else:
- raise SystemExit(
- f"{label}: not an archive, an NSP or an XCI. "
- "Check the download link (Google Drive/Dropbox may have returned an HTML page)."
- )
- process("game")
- process("update")
- process("dlc")
- packages = sorted(
- p for p in game_dir.iterdir()
- if p.is_file() and p.suffix.lower() in package_extensions
- )
- if not packages:
- raise SystemExit("No game packages were prepared.")
- print("\n=== Prepared game packages ===")
- for p in packages:
- print(f"{p.name}: {p.stat().st_size:,} bytes")
- PY
- test -s "$WORK/prod.keys"
- - name: Set shader profile and update toolchain header
- env:
- PROFILE_OVERRIDE: ${{ inputs.profile_id }}
- shell: bash
- run: |
- set -euo pipefail
- PROFILE_ID="${PROFILE_OVERRIDE:-010051F0207B2000}"
- PROFILE_ID="$(echo "$PROFILE_ID" | tr '[:lower:]' '[:upper:]')"
- if [[ ! "$PROFILE_ID" =~ ^[0-9A-F]{16}$ ]]; then
- echo "::error::Profile ID must be exactly 16 hexadecimal characters."
- exit 1
- fi
- echo "DM_GAME=$PROFILE_ID" >> "$GITHUB_ENV"
- echo "GAME=$PROFILE_ID" >> "$GITHUB_ENV"
- export DM_GAME="$PROFILE_ID"
- python dm/gfx/tools/dmsh_chain.py --update-lock
- python dm/gfx/tools/dmsh_chain.py --header
- # Este script hace TODO: extraer, reescribir modulos (patch_nso.py),
- # separar shaders, compilar librerias iOS y llamar a build_ipa.sh.
- - name: Build unsigned IPA
- shell: bash
- run: |
- set -euo pipefail
- WORK="$RUNNER_TEMP/nxnative"
- GAME_DIR="$RUNNER_TEMP/GameFiles"
- OUTPUT="$RUNNER_TEMP/Tomodachi-Native.ipa"
- python scripts/build_ipa_from_nsp.py \
- "$GAME_DIR" \
- -k "$WORK/prod.keys" \
- -o "$OUTPUT" \
- --app-name "Tomodachi Life"
- if [[ ! -s "$OUTPUT" ]]; then
- echo "::error::IPA build did not produce an output file."
- exit 1
- fi
- echo "IPA successfully created: $OUTPUT"
- ls -lh "$OUTPUT"
- - name: Save compiled shaders cache
- uses: actions/cache/save@v4
- if: always()
- with:
- path: ${{ runner.temp }}/GameFiles/*_build/shader
- key: shader-cache-${{ inputs.profile_id || '010051F0207B2000' }}
- - name: Upload IPA
- if: success()
- uses: actions/upload-artifact@v4
- with:
- name: Tomodachi-Native-IPA
- path: ${{ runner.temp }}/Tomodachi-Native.ipa
- if-no-files-found: error
- retention-days: 7
- # Ahora tambien sube el log completo de xcodebuild (build.log)
- - name: Upload build logs if available
- if: always()
- uses: actions/upload-artifact@v4
- with:
- name: Tomodachi-Build-Logs
- path: |
- ${{ runner.temp }}/GameFiles/*_build/logs/
- ${{ runner.temp }}/GameFiles/*_build/ipa/build/ios/pkg/build.log
- ${{ runner.temp }}/GameFiles/*_survey/
- if-no-files-found: ignore
- retention-days: 7
- - name: Clean up downloaded files and keys
- if: always()
- shell: bash
- run: |
- rm -rf "$RUNNER_TEMP/nxnative"
- rm -rf "$RUNNER_TEMP/GameFiles"/*_build/extract
- rm -rf "$RUNNER_TEMP/GameFiles"/*.nsp
- rm -rf "$RUNNER_TEMP/GameFiles"/*.xci
Add Comment
Please, Sign In to add comment