chore(unreal): Unreal target IOS (#14291) 10821b0399

Co-authored-by: Jonathon Copeland <jcopela4@gmail.com>
diff --git a/.rive_head b/.rive_head
index e67461e..53ab4fe 100644
--- a/.rive_head
+++ b/.rive_head
@@ -1 +1 @@
-603235d26914a14ab897e696c446f4354782b6fa
+10821b03994f98cf1fc3d67f663db0d41431998f
diff --git a/tests/check_golds.sh b/tests/check_golds.sh
index 37e8064..e1c3bd9 100755
--- a/tests/check_golds.sh
+++ b/tests/check_golds.sh
@@ -53,6 +53,14 @@
             fi
             shift
         ;;
+        -ui)
+            TARGET="unreal_ios"
+            # msaa is what we target on android. atomics needs pixel shader
+            # UAVs, which mobile handles badly, and it hangs on adreno.
+            DEFAULT_BACKEND=metalatomic
+            ARGS="$ARGS --no-rebuild"
+            shift
+        ;;
         -ua)
             TARGET="unreal_android"
             # msaa is what we target on android. atomics needs pixel shader
diff --git a/tests/deploy_tests.py b/tests/deploy_tests.py
index d9d2ed5..91413cf 100644
--- a/tests/deploy_tests.py
+++ b/tests/deploy_tests.py
@@ -112,7 +112,7 @@
 parser.add_argument("-t", "--target",
                     default="host",
                     choices=["host", "android", "ios", "iossim", "unreal",
-                             "unreal_android", "webbrowser", "webserver",
+                             "unreal_android", "unreal_ios", "webbrowser", "webserver",
                              "webbrowserandroid", "webserverandroid", 'console'],
                     help="which platform to run on")
 parser.add_argument("-a", "--android-arch",
@@ -548,9 +548,18 @@
         unreal_exe_path = os.path.join(dirname, *UNREAL_HOST_PACKAGE)
         return [unreal_exe_path] + unreal_tool_args(toolname) + \
                unreal_engine_args(args.target, args.backend) + \
-               ["-ResX=1280", "-ResY=720"] + unreal_offscreen_args(toolname) + \
+               unreal_resolution_args(toolname) + unreal_offscreen_args(toolname) + \
                cmd[1:]
 
+    if args.target == "unreal_ios":
+        print("\nDeploying %s on unreal ios (udid=%s, ios_version=%i)..." %
+              (toolname, args.ios_udid, target_info["ios_version"]))
+        return ["xcrun", "devicectl", "device", "process", "launch", "--console",
+                "--device", args.ios_udid, unreal_ios_bundle_id(), "--"] + \
+               unreal_tool_args(toolname) + \
+               unreal_engine_args(args.target, args.backend) + \
+               unreal_offscreen_args(toolname) + cmd[1:]
+
     if args.target == "unreal_android":
         tool_args = ' '.join(unreal_tool_args(toolname) +
                              unreal_engine_args(args.target, args.backend) + cmd[1:])
@@ -724,6 +733,7 @@
 UNREAL_TARGET_PLATFORMS = {
     "unreal": UNREAL_HOST_PLATFORM,
     "unreal_android": "Android",
+    "unreal_ios": "iOS",
 }
 
 # One map serves every tool: which tool runs is chosen by "-rivetool=<name>",
@@ -739,6 +749,9 @@
     # something you watch, so it keeps its window.
     return [] if toolname == "player" else ["-RenderOffScreen"]
 
+def unreal_resolution_args(toolname):
+    return [] if toolname == "player" else ["-ResX=1280", "-ResY=720"]
+
 UNREAL_RHI_SWITCHES = {
     "d3d": "-dx11",
     "d3d12": "-dx12",
@@ -757,12 +770,14 @@
               ["vk"] if platform.system() == "Linux" else
               ["d3d", "d3d12", "vk"],
     "unreal_android": ["vk"],
+    "unreal_ios": ["metal"],
 }
 
 DEFAULT_UNREAL_BACKENDS = {
     "unreal": "metal" if platform.system() == "Darwin" else
               "vk" if platform.system() == "Linux" else "d3d12",
     "unreal_android": "vk",
+    "unreal_ios": "metal",
 }
 
 def split_unreal_backend(name):
@@ -845,10 +860,43 @@
     table package_project.py keeps is public and has no entry for one."""
     return ["--platform", UNREAL_TARGET_PLATFORMS[args.target]]
 
+def ios_device_version(udid):
+    listing = subprocess.check_output(["xcrun", "xctrace", "list", "devices"]).decode()
+    for line in listing.splitlines():
+        if udid not in line:
+            continue
+        version = re.search(r"\(([0-9]+)\.[0-9.]+\)", line)
+        if version:
+            return int(version.group(1))
+    return None
+
+def unreal_ios_bundle():
+    stage_dir = os.path.join(args.builddir, "IOS")
+    bundles = sorted(glob.glob(os.path.join(stage_dir, "*.app")))
+    if not bundles:
+        ipas = sorted(glob.glob(os.path.join(stage_dir, "*.ipa")))
+        raise RuntimeError(
+            "no .app in %s; devicectl and the bundle id both need the .app "
+            "directory that package_project.py archives%s"
+            % (stage_dir,
+               ", but only these .ipa archives are there: %s" %
+               ", ".join(os.path.basename(i) for i in ipas) if ipas else ""))
+    return bundles[0]
+
+def unreal_ios_bundle_id():
+    bundle = unreal_ios_bundle()
+    plist = os.path.join(bundle, "Info.plist")
+    return subprocess.check_output(
+        ["plutil", "-extract", "CFBundleIdentifier", "raw", "-o", "-", plist]
+    ).decode().strip()
+
 def install_unreal_package():
     """Put the packaged build on the device. Nothing to do where the launch
     command reaches it from the host."""
-    pass
+    if args.target != "unreal_ios":
+        return
+    subprocess.check_call(["xcrun", "devicectl", "device", "install", "app",
+                           "--device", args.ios_udid, unreal_ios_bundle()])
 
 def package_unreal_project():
     # No engine path -> assume the project is already packaged (legacy behavior).
@@ -863,7 +911,6 @@
                            "--output", os.path.abspath(args.builddir),
                            "--config", unreal_client_config(),
                            "--no-rive-build"] + unreal_package_platform_args())
-    install_unreal_package()
 
 def main():
     # Parse skipped tests. These only apply to a whole-corpus sweep: gms or
@@ -921,7 +968,7 @@
         args.remote = True # Since we can't do port forwarding in iOS, it always has to be remote.
         if not args.ios_udid:
             args.ios_udid = "booted"
-    elif args.target == 'unreal' or args.target == 'unreal_android':
+    elif args.target in ('unreal', 'unreal_android', 'unreal_ios'):
          # currently, unreal needs to run only one job at a time for goldens and gms to work
         args.jobs_per_tool = 1
         if args.builddir == None:
@@ -929,6 +976,25 @@
         if args.backend == None:
             args.backend = DEFAULT_UNREAL_BACKENDS[args.target]
         unreal_engine_args(args.target, args.backend)
+        if args.target == 'unreal_ios':
+            args.remote = True
+            if not args.ios_udid:
+                udids = subprocess.check_output(["idevice_id", "-l"]).decode().split()
+                if len(udids) != 1:
+                    print("expected exactly one attached iOS device, found %d; "
+                          "pass --ios_udid" % len(udids))
+                    return -1
+                args.ios_udid = udids[0]
+            ios_version = ios_device_version(args.ios_udid)
+            if ios_version is None:
+                print("no device matching --ios_udid=%s in 'xcrun xctrace list devices'"
+                      % args.ios_udid)
+                return -1
+            if ios_version < 17:
+                print("--target=unreal_ios launches through devicectl, which needs "
+                      "iOS 17 or newer; this device reports iOS %d" % ios_version)
+                return -1
+            target_info["ios_version"] = ios_version
     elif args.target.startswith("web"):
         args.jobs_per_tool = 1
         if args.builddir == None:
@@ -1082,6 +1148,8 @@
                 subprocess.check_call(["ios-deploy", "--bundle",
                                        "ios_tests/build/Debug-iphoneos/rive_ios_tests.app"])
             print()
+        elif "unreal" in args.target or args.target == "console":
+            install_unreal_package()
         elif args.target == "iossim":
             # Install the ios_tests wrapper app on the simulator.
             subprocess.check_call(["xcrun", "simctl", "install", args.ios_udid,