diff --git a/docs/ios27/HANG-WATCHDOG.md b/docs/ios27/HANG-WATCHDOG.md new file mode 100644 index 000000000..6188471e6 --- /dev/null +++ b/docs/ios27/HANG-WATCHDOG.md @@ -0,0 +1,54 @@ +# iOS 27 on the Galaxy S9: a watchdog for hard hangs (9 November 2026; built, not yet run on a phone) + +On 9 November a boot of the S9 stopped dead 66 seconds in: no panic, the USB console gone, nothing in pstore. With `wdt=-1` on the command line +nothing ever brings such a phone back; it takes the key combination. This page says what the kernel has today and what a watchdog for that case +should be. It is built as described under "As built"; no phone has run it yet. + +## What `wdt=` is, and is not + +`wdt` is XNU's own argument, not a driver of the S9's watchdog timer: `wdt=-1` switches off the interrupt-masked and preemption-disabled time checks +(`osfmk/arm/arm_init.c`) and the panic when the root device does not appear (`iokit/bsddev/IOKitBSDInit.cpp`). Any other value arms those checks +again; none starts a hardware timer, because this kernel has no driver for the Exynos 9810's watchdog (`WDT_CLUSTER0`). `wdt=60` would therefore +not reset a hung phone, and would bring back debug panics the port's polled drivers trip. + +## What exists and can be reused + +`bsd/dev/herolte_ufs.c` already has the way out: `starlte_panic_to_recovery()` writes the panic log's head and the console's last lines into the +ramoops ring TWRP shows as `/sys/fs/pstore/pmsg-ramoops-0`, then resets through the PMU into recovery (`INFORM2`/`INFORM3`, `SWRESET`). It was +written for panics: it takes no lock, allocates nothing, and its two mappings are made at boot. `sweetreboot=N` uses the same reset after N seconds. + +## The proposal: `sweetwd=N` + +- **Who fires.** A timer call, so it runs from the timer interrupt and needs no thread to be scheduled: every 2 s it compares the time of the last + heartbeat with now; N seconds without one and it writes "hang watchdog: no heartbeat for N s" plus the console tail to the ramoops ring and resets + into recovery, as a panic does. A hang with interrupts still alive (the VM starved, a lock everyone waits for, the scheduler's run queues stuck) + is caught; a core dead with interrupts masked on every CPU is not (that needs the hardware timer, below). +- **Who beats.** Not a kernel thread alone: in the hang of the 9th the kernel may well have been running while user space was not. Two beats, both + required: a kernel thread at ordinary priority that wakes every second (the scheduler and timers work), and proof that user space makes progress: + the count of system calls and Mach traps taken from EL0 on any CPU must have moved since the last check (one counter bump in `sleh.c`'s system-call + path, per CPU, no lock). launchd alone wakes more than once in N = 60 s; a phone whose every process is stuck does not. +- **What it must not do.** Fire during a long legitimate stall: the first boot's shader compiles and the root's first mount are busy, not silent, so + the system-call beat covers them; the watchdog starts only once launchd has exec'ed. Off by default (`sweetwd=0`); a test boot uses 60. +- **Where it resets to.** Recovery, with the log, while the phone is a test device: the point is the evidence. For a phone in someone's hand a + second value (`sweetwd_boot=1`) would restart into the system instead (`INFORM3` for a normal boot). +- **Later, the hardware timer.** The same beat can pet `WDT_CLUSTER0` (a count-down register and a reset mask in the PMU). That covers the dead-core + case too, but it is a register sequence nobody here has run on this phone; it is a second step, tried first on the bench phone with a long count. + +## As built + +`wip/ios27-hang-watchdog` of the fork (`3c4be6ee`, on `bb6a6a4`): boot-argument `sweetwdog=SECONDS` (absent or 0: off; less than 60 is 60). The timer +call is armed from the watchdog's own thread and looks every 2 s. User space's sign of life is one variable (`sweet_wdog_mark`): the look sets it to 0, +the next system call or Mach trap from EL0 sets it to 1 (`handle_svc`: one read per call, at most one write per look and CPU; with the watchdog off it +is 1 for good and never written). The first system call arms the watchdog. On a miss the line "STARBITE: HANG WATCHDOG (sweetwdog=N): no sign of life +from the kernel's thread | user space; kernel thread A ms ago, user space B ms ago, up U s" and the console's tail go to the pmsg ring, then the PMU +reset into recovery. `kern.sweetwdog_test` provokes it without hanging anything (1: user space's signs are not counted, 2: the kernel thread stops +noting the time; from the USB console: `sysctl kern.sweetwdog_test=1`), and `kern.sweetwdog_kernel_age_ms` / `kern.sweetwdog_user_age_ms` read the ages +as the last look saw them. A restart into the system instead of recovery, and the hardware timer, are not built. + +About 150 lines in `herolte_ufs.c`, six in `sleh.c`. + +The same sign of life serves the loader (`bce1bead`, boot-argument `scionup=1`, off by default): at the first system call two words of the loader's +64-byte boot record at `0x91ffe000` are written (its sequence number copied, and "SNUP"), only when the record's magic is there, so the loader can +tell a boot that reached user space from one that did not. The loader also reads the watchdog's line from the pmsg ring: its first bytes stay +"STARBITE: HANG WATCHDOG (sweetwdog=". The test: `sweetwd=60` idle for ten minutes with no reset; then a deliberate hang (a +kernel thread that takes the VM's page-queue lock and sleeps, from a console verb) must end in TWRP within 62 s with the watchdog's line in pstore. diff --git a/docs/ios27/LIQUID-GLASS.md b/docs/ios27/LIQUID-GLASS.md new file mode 100644 index 000000000..3a0a35c6f --- /dev/null +++ b/docs/ios27/LIQUID-GLASS.md @@ -0,0 +1,165 @@ +# iOS 27 on the Galaxy S9: Liquid Glass on CoreAnimation's Metal renderer, and the frame cadence (9 November 2026) + +iOS 27 draws its glass materials with CoreAnimation's Metal renderer. On the S9 the default compositor is the OpenGL ES one ([GPU-PATH.md](GPU-PATH.md)), +which has no shader for several of them; the Metal one works ([METAL.md](METAL.md): `sbgl=0`, backboardd in `mtl2-clients`) but was much slower. This page +is where the time went, what was changed, and what each change measured on a phone. Everything new is off by default. + +All numbers are from one Galaxy S9 (the second test phone, `cpus=8`, the AOT-rewritten root of [AOT-REWRITE.md](AOT-REWRITE.md)), the scripted input of +`sweetspeed&4096` (sixteen home-screen swipes, then sixteen vertical drags), and the framebuffer's present-gap histogram (`sweetspeed&16384`, +`tools/ios27/perf/gaps.py`): **on time** is the share of frames shown one refresh after the frame before, **motion fps** is 60 x frames / refreshes over the +frames that came within three refreshes of the one before. One run each; none of it is a benchmark. + +## Where the Metal path lost its time + +1. **Every GPU job was synchronous.** PanVK reaches the kernel's StarbiteMali through `tools/ios27/mesa-ios/starbite-drm`. The copy the Metal bundle of 8 + November was built with predates that shim's asynchronous submits (the OpenGL stack has had them since the night run), so each `SUBMIT` ran its chain to + the end inside the call: 5,814 submits in ten seconds of swiping, 463 us each, 27 % of the render thread's wall time, on top of its own CPU time. Rebuilt + with the repository's shim (and a line on the console, `SBDRM[pid name]: async submits on`), PanVK asks for them. +2. **The kernel's "asynchronous" submit was only that for one chain.** It returned at once while the GPU was free; the next submit, and every wait, spun in + `delay()` under the GPU lock until the chain before was done (`STARLTE GPU ASYNC: ... next submit waited 4202 x avg 347 us` of 4,387 chains). A frame is a + dozen chains or more (a render pass is a vertex/tiler chain and a fragment chain, a blur is many passes), so the render thread still sat out nearly all of + the GPU's time on a core, and this frame's GPU work never overlapped the next frame's CPU work. A fence wait also held the client's lock, so a completion + thread blocked the render thread's next submit. **The fix is a queue** (the fork's `bsd/dev/starlte_gpu.c`, boot-arg `sweetspeed&262144`, as Scion's + iOS 16.5 Mali driver has it): a submit appends and returns, one kernel thread starts each chain in order and naps until it is done (50 us naps, boot-arg + `sgu_nap_us`), waiters sleep until their chain's number is done, and the waits no longer hold the client's lock. +3. **Whole-surface cache maintenance, twice a frame.** The Mali is not IO-coherent. The Metal frontend cleaned and invalidated (`DC CIVAC`) every IOSurface a + command buffer named at commit, and every written one again at completion: 25 GB in 183 s of that run, 1.3 ms a frame for the framebuffer alone, on the + render and completion threads. `tools/ios27/metal/patches/frontend-flush.diff` adds `SWEET_MTL2_FLUSH` (in `mtl2-env`; 0, the default, is as before): + `1` cleans a surface at commit only when it is new to the process or its IOSurface seed moved since the last clean (every CPU writer unlocks with a seed + bump); `2` also skips the invalidate after completion for a surface the GPU has written four times in a row with no seed change (the swap chain, + CoreAnimation's render targets: nothing reads them on the CPU), except every 64th time. The state lives on the IOSurface object, so a recycled surface + ID never inherits it. The kernel's GPU present did the same clean (6 MiB of `DC CVAC` a frame on the vsync thread); `sweetspeed&524288` does it only for a + surface that is new or whose seed moved. +4. **Start-up.** The first frame came 26 to 28 s after the kernel's first line, against 16 s with OpenGL ES: 5 to 6 s to create the device, and 8.3 s in 98 + pipeline creations (85 ms each) with the shader cache reporting 0 hits, 200 misses. Not fixed here (below). + +## The frame cadence + +With the queue, the framebuffer no longer shows "whatever the GPU has finished": a swap records the number of the last chain queued when it came, and the +vsync thread sleeps until that chain is done before it draws the frame to the panel (its own frame's chains, not the next frame's, which may already be +queued). A swap also waits for the chains of the swap before it, so **at most two frames are in flight**: the window server builds frame N+1 while the GPU +draws frame N, and cannot run further ahead (without that, a scene the GPU needs more than a refresh for would fill the queue's 256 chains and every touch +would be answered a dozen frames late). + +What a frame still pays after the queue: the present itself is a GPU job (the frame scaled from 828 x 1792 to the panel's 1440 x 2960, 2.3 to 2.4 ms, in +turn with the app's chains) drawn into the buffer the display controller is reading, so it can tear. **Next step (designed, not built):** the scan-out +service the 16.5 kernel uses (`drivers/display/dpu/hakios.exynos_dpu.c`, already built into the iOS 27 kernel, `dpu_probe=1` reads it): the window server's +display surfaces made of the DPU's pool (`hakios_scanout_carve`), VGF0 scanning out the very surface CoreAnimation drew (`hakios_scanout_show`, flipped at +the panel's TE, scaled by the DPP), nothing copied. The steps: (1) `SweetSurface.cpp` allocates backboardd's display-sized BGRA surfaces from the pool +(physically contiguous, below 4 GB, outside XNU's RAM, mapped write-combined; the Mali's page tables take them as they take any page); (2) +`SweetFramebuffer.cpp` attaches mode 2 (828 x 1792 at 3,328 bytes a row scaled to the panel) and, per swap, waits for the swap's chains and for the flip +before to be latched, then calls `hakios_scanout_show`; a surface is not drawn into again until the flip away from it is latched (two in the pool for +CoreAnimation, as on 16.5); (3) the 60 Hz timer follows the DECON's refresh count instead of free-running. Two things stand in the way on iOS 27 as booted +today: the extra-RAM ranges give `0xD0000000`-`0xD7FFFFFF` to the VM (`extra_ram_mask=127`), which holds the pool's first slot and the scan-out buffers, so +either that range is dropped (`extra_ram_mask=95`, 128 MB less) or the pool keeps to its three slots above `0xD8000000`; and every failure must fall back +to the present of today (the service already goes back to the boot loader's channel on a configuration error). + +## Measured + +| Boot | What | Swipes: on time, motion fps | Drags: on time, motion fps | First frame | Notes | +|---|---|---|---|---|---| +| g0 | OpenGL ES compositor, kernel as before | 69 %, 45.7 | 66 %, 42.1 | +15.8 s | the default; GPU 37 % busy while swiping | +| m1 | Metal compositor as it was (`sbgl=0`, bundle of 8 November) | 29 %, 33.9 | 28 %, 30.8 | +28.0 s | 5,814 synchronous submits / 10 s at 463 us | +| m2 | m1 + this bundle (PanVK asks for async submits, `SWEET_MTL2_FLUSH=2`), same kernel | 42 %, 36.6 | 34 %, 32.8 | +25.7 s | 96 % of submits still wait for the chain before | +| m3 | m2 + the kernel's chain queue, the present's clean on a seed change, two frames in flight (`sweetspeed&262144`, `&524288`) | 67 %, 44.5 | 64 %, 41.1 | +18.1 s | a submit costs 1 us; one GPU fault and reset (below) | +| m4 | m3 + the shim's timeline query waits for the queue, and a buffer is closed only after every queued chain | 66 %, 44.3 and 67 %, 44.6 | 64 %, 40.5 and 66 %, 41.7 | +21.9 s | two passes of the script, no GPU fault; a buffer close may now block (up to 90 ms seen) | + +So the Metal compositor, which draws the glass, is where the OpenGL ES one is (within two or three points of frames on time), from less than half of +it. What the two draw is not the same picture: at the same moment of the script the OpenGL ES compositor shows no dock platter, no folder glass, no search +pill and no panel behind the keyboard; the Metal one draws them all. + +**The fault of m3, and why the queue found it.** One chain of the run faulted in the GPU's MMU at address 0 and the kernel reset the GPU (nothing died). +PanVK's shim has no real sync objects: every fence is "signalled", every timeline "reached". That was true while a submit ran its chain to the end, and +nearly true with one chain in flight. With chains really queued, a thread that asked a queue's timeline for its value right after another thread's submit +was told "all reached", took chains that had not run yet for finished, and reused their memory under the GPU. The shim's timeline query now waits for the +chains queued so far before it answers (as its fence wait does), and the kernel closes a buffer only once every chain queued by then is done (a chain +reaches buffers its submit does not list). Honest sync objects (a chain number per fence and timeline point, so that a wait is for that chain and not for +the queue to drain, and a close can be deferred instead of waited for) are the next step. + +**The shader cache does work.** The 0 hits of m1 were a first boot of a new configuration (the cache's names include the device's profile; the extra-RAM +change since the bundle's last runs changed it). The boots after it: 121 hits and 32 misses, then 100 hits and no miss; pipeline creation fell from 8.3 s +to 1.5 s, the first frame from +28 s to about +20 s. What is left of the start is the device itself (5 to 6 s before the first GPU call). + +## On the first phone, and what a fuller home screen costs + +The owner's phone (a root with more on its home screen: widgets, a weather widget's renderer, a wallpaper poster) ran the same script on the kernel +that merges this line with the data-protection work (`rc1/ios27-gpu-keystore` `414af8e2`, tag `ios27-s9-rc1-20261109`), with the Metal compositor and +the cap below: 43 % of swipe frames on time (motion 37.4 fps) and 63 % of the drags, no GPU fault, no exit of backboardd or SpringBoard in 7.5 minutes, +and it is what that phone was left on. The bench phone's 66 % and this 43 % are the same code. The difference is in the frames: 35 chains a frame +there against 25 on the bench phone, at 0.42 to 0.48 ms a chain, so 14.7 ms of GPU time a frame against 12.2, before the 2.4 ms of the present. At +60 Hz that does not fit; the GPU, not the CPU, is what a heavy glass frame waits for now. A chain's 0.4 ms is mostly not drawing: every chain is started, +polled for (10 us looks, then 50 us naps; `sgu_spin_us` sets how long it looks) and flushed on its own, and a pass is two of them. What would shorten it, +none of it built: the fragment chain of one pass and the vertex/tiler chain of the next on the two job slots at once; the job interrupt instead of the +poll; fewer passes from CoreAnimation for the same picture. + +## A cap on the GPU's wired memory + +Every buffer StarbiteMali makes is wired kernel memory that nothing can page out, and a PanVK device wires 57 MiB when it is made (a 32 MiB tiler heap, +17 MiB of varyings, 8 MiB of TLS). `sweetspeed&134217728` (or `sbm_cap_mb=`, `sbm_client_cap_mb=`) bounds the sum: all clients together 35 % of the +memory, one client half of that (1029 and 514 MiB on a phone with 2940 MiB). A buffer beyond it is refused (the client sees an allocation failure) with +a line on the console. backboardd peaked at 182 MiB in the runs above, so the cap never fired; it is there for the day several Metal clients start at once. +Which processes get a Metal device at all is the list in `/private/var/local/mtl2-clients`; for the compositor it should hold backboardd and nothing else +unless a process needs one (SpringBoard's renderer service asserts without a device once posters are in use). + +## The start + +backboardd took 4 to 6 s to make its Metal device, nearly all of it before PanVK's first call to the kernel: `dlopen()` of the plug-in and of PanVK +(38 MB outside the shared cache) while every other daemon starts. chronod, started meanwhile, asks the render server for the displays, gives up and +aborts once (QuartzCore's `query_displays`), and runs when it is started again. The kernel's prewarm thread (`sweetspeed&128`) read the GL stack into the +page cache at mount; with `sbgl=0` on the command line it now reads the Metal plug-in and PanVK instead (`2db66861` of the fork's branch). Timed +since (below): no gain. + +## Later the same night: the close that waited, and frames that are on time but late + +- **A buffer close no longer waits.** The kernel's belt of m4 (a close waits for every chain queued by then) held the closing thread for as long as + the queue took to drain: up to 90 ms. The buffer is now only marked with the chain it has to outlive and is unmapped and freed at one of the client's + next calls (`af8ac0d0`). On the bench phone, with the full root of the release candidate: closes of 1 to 2 us, the longest 2.4 ms, no GPU fault, no + exit; 47 % of swipe frames on time on that fuller home screen (the first phone: 43 to 46 %), 95 % (56.8 fps) while a list scrolled in an app. +- **On time is not the same as soon.** In that 95 % run the queue held 60 chains on average and up to 199: about three frames, 55 ms between a chain's + submit and its start. The frames came one refresh apart, each of them late. "Two frames in flight" at the swap does not bound it (the window server + commits more than it swaps). `sgu_depth=N` (`2abb4031`; default 256, the whole queue, as before) makes a submit sleep when N chains wait, until half + of them are done. **Not yet run**: 48 (a frame or two) is the first value to try, against the touch-to-picture delay, not against frames on time. +- **Three knobs for a chain's cost, built and not yet run**, all off by default: `sgu_flushred=1` (`0537327e`: the Mali's flush reduction, as + Linux's Panfrost driver has it on this GPU: a chain carries the GPU's flush count from its submit and the cache flush at its start is left out + when one happened since, which for a queued chain is always), `sgu_spin_us=600` (the worker looks longer before its first nap) and `sgu_depth` + above. What to read from a run of each is the queue's own line: time per chain, time queued, depth. +- The prewarm of the Metal plug-in did not shorten the device's start (4.4 s again), and chronod's abort comes 4.6 s after the device exists, just + before the first frame: it waits for the render server's first display state, not for the device. Harmless (it is started again and runs). + +## Not done + +- **Honest sync objects in the shim** (above): today a wait is for the whole queue. +- **The device's start** (5 to 6 s), and the twelve no-copy buffers PanVK refuses (`HostMemory: NotRepresentable`; CoreAnimation copies instead): five a + boot here. +- The zero-copy scan-out above. +- The CPU side: backboardd uses 0.19 to 0.25 of a core while swiping on Metal against 0.13 on OpenGL ES (metal2vulkan's and PanVK's per-draw work). +- A chain's fixed cost (above), which is what a fuller home screen now waits for. + +## How to turn it on + +Kernel: the fork's `wip/ios27-gpu-queue` (on `bb6a6a4`; draft pull request 6 of Scion-Project/xnu). Command line: `sbgl=0` (with `sbmtl=1`) +and `sweetspeed` with 262144, 524288 and 134217728 added. Root: this bundle (`tools/ios27/metal/mk-bundle27.sh` applies `frontend-flush.diff`; PanVK built with the +repository's `starbite-drm`), `SWEET_MTL2_DEXR=1`, `SWEET_MTL2_FLUSH=2` and `STARBITE_DRM_LEAKS=0` in `/private/var/local/mtl2-env`, backboardd in +`/private/var/local/mtl2-clients`, and `/private/var/local/m2v-cache` (mode 0777) for the pipeline cache. Back to OpenGL ES: backboardd out of +`mtl2-clients` and no `sbgl=0`. + +## Open: Settings row icons under the Metal compositor (10 Nov, seen, not explained) + +On the RC1 full root with the Metal compositor (kernel q12, `SWEET_MTL2_FLUSH=2`) the top level of Settings draws some row icons right, +four as horizontal coloured stripes (Bluetooth, Appearance, Camera, Control Center) and eight not at all (Wi-Fi, Battery, Sounds, Focus, +Screen Time, Passcode, Emergency SOS, Privacy & Security). No picture of the same screen under the GL renderer was taken the same night. +Two suspects, each with a switch, neither run on a phone yet: + +- kernel: a surface over the app's own memory that could not be wrapped is a one-time copy; what the app draws afterwards never reaches + it. `sweetspeed` bit 8388608 copies again at use. +- frontend: modes 1 and 2 of `SWEET_MTL2_FLUSH` skip the commit-time clean of a surface whose seed did not move; a surface over client + memory is written with no lock, so its seed never moves. Control: mode 0. `frontend-flush.diff` now cleans at every commit any surface + with pixel format 0, a base that is not 16 KiB aligned, or under 1 MiB (unmeasured; cleaning more cannot draw wrong, an icon is 12 KiB). + +The console of that boot points at the frontend: the kernel wrapped the icons' memory (56x56, pixel format 0, 16 KiB surfaces made by +the render server) rather than copying it, with one copy in the whole boot; and one of those surfaces was empty when it was wrapped +(`0 of 12544 bytes set`), so its picture was written afterwards with no seed change. Cleaned once at its first commit and never again, +such a surface shows whatever cache lines happen to have been evicted: none (blank), some (runs of 16 pixels: stripes) or all (right). + +Test order: the rebuilt bundle with mode 2 first; if the icons are still wrong, mode 0 as the control, then the kernel bit alone, then +one GL picture. diff --git a/docs/ios27/METAL.md b/docs/ios27/METAL.md index 81fed511f..7f01c3e99 100644 --- a/docs/ios27/METAL.md +++ b/docs/ios27/METAL.md @@ -57,6 +57,8 @@ buffers were `NotRepresentable`. `patches/frontend-dexr.diff` is the answer trie formats (BGRA10_XR 552/553, BGR10_XR 554/555) of textures that live in device memory only are held as RGBA16Float, with the pipelines' attachments to match. **Not run** when this was written: that patch on a phone, and the frame rate of CoreAnimation on Metal against `sbca`'s OpenGL ES path. +What came of the frame rate question is in [LIQUID-GLASS.md](LIQUID-GLASS.md): where the Metal path lost its time, what was changed, and the runs. + ## Not done - The wallpaper (above), and the 12 `NotRepresentable` no-copy buffers. diff --git a/tools/ios27/mesa-ios/starbite-drm/starbite_drm.c b/tools/ios27/mesa-ios/starbite-drm/starbite_drm.c index 3af3eedee..1b1025f10 100644 --- a/tools/ios27/mesa-ios/starbite-drm/starbite_drm.c +++ b/tools/ios27/mesa-ios/starbite-drm/starbite_drm.c @@ -711,6 +711,17 @@ drmSyncobjQuery2(int fd, uint32_t *handles, uint64_t *points, uint32_t count, ui (void)fd; (void)handles; (void)flags; + /* + * "Every point is reached" has to be true when it is said. With asynchronous submits it is once the GPU is + * done with everything submitted so far, so wait for that first (the kernel's queue: a snapshot of the + * last chain queued now, not of chains queued later). Without the wait a caller that reads a queue's timeline + * value right after another thread's submit takes chains that have not run yet for finished and reuses their + * memory under the GPU (seen as an MMU fault at address 0 and a GPU reset once the kernel really queued chains). + */ + if (async_on) { + int64_t rel = INT64_MAX; + (void)call(SBM_WAIT_IDLE, &rel, sizeof(rel)); + } /* every point is reached: report the furthest any timeline could be */ for (uint32_t i = 0; i < count; i++) { points[i] = UINT64_MAX >> 1; diff --git a/tools/ios27/metal/mk-bundle27.sh b/tools/ios27/metal/mk-bundle27.sh index 999f78608..a05474587 100755 --- a/tools/ios27/metal/mk-bundle27.sh +++ b/tools/ios27/metal/mk-bundle27.sh @@ -10,9 +10,10 @@ BRIDGE=$SB/build/m2v-bridge/ios/libnative_metal_bridge.a PANVK=$W/build/mesa/build-vk26/src/panfrost/vulkan/libvulkan_panfrost.dylib B=$W/stage/StarbiteMetal.bundle rm -rf "$B" && mkdir -p "$B" $W/sym $W/stage -# the frontend and HotMetal2 are built from a copy with patches/frontend-dexr.diff and hotmetal2-diag.diff applied (NOPATCH=1: as they are in tools/ios27/metal2) +# the frontend and HotMetal2 are built from a copy with patches/frontend-dexr.diff, hotmetal2-diag.diff and frontend-flush.diff applied +# (NOPATCH=1: as they are in tools/ios27/metal2) rm -rf $W/src27 && mkdir -p $W/src27/tools/ios27 && cp -R tools/ios27/metal2 $W/src27/tools/ios27/ -[ -n "${NOPATCH:-}" ] || for p in frontend-dexr hotmetal2-diag; do patch -s -p1 -d $W/src27 < tools/ios27/metal/patches/$p.diff || exit 1; done +[ -n "${NOPATCH:-}" ] || for p in frontend-dexr hotmetal2-diag frontend-flush; do patch -s -p1 -d $W/src27 < tools/ios27/metal/patches/$p.diff || exit 1; done M2=$W/src27/tools/ios27/metal2 $CC -x objective-c++ -std=c++17 -fobjc-arc -O2 -g -Wall -Wno-deprecated-declarations -Wno-unused-function -bundle \ -o "$B/StarbiteMetal" $M2/HotMetal2.mm $M2/NativeBridgeMetalFrontend.mm -x none "$BRIDGE" \ diff --git a/tools/ios27/metal/patches/frontend-flush.diff b/tools/ios27/metal/patches/frontend-flush.diff new file mode 100644 index 000000000..356a1f0cd --- /dev/null +++ b/tools/ios27/metal/patches/frontend-flush.diff @@ -0,0 +1,94 @@ +--- a/tools/ios27/metal2/NativeBridgeMetalFrontend.mm 2026-11-09 20:51:46 ++++ b/tools/ios27/metal2/NativeBridgeMetalFrontend.mm 2026-11-09 20:51:46 +@@ -470,15 +470,72 @@ + // owners keep the mapping live through this phase. Maintain each exact CPU + // range once; no address identity or decision is carried into another phase + // or command, and distinct/partially overlapping ranges retain their calls. +-static void flushSurfaces(NSMutableSet *surfaces) { ++// ios27-fable: SWEET_MTL2_FLUSH (mtl2-env) chooses how much CPU-cache maintenance imported IOSurfaces get. The GPU is ++// not IO-coherent, so memory the CPU wrote must be cleaned before the GPU reads it and lines the CPU holds must be ++// invalidated after the GPU wrote. Doing both for every surface of every command buffer (mode 0, the default, as before) ++// walks ~6 MiB of DC CIVAC per full-screen surface, twice a frame for the framebuffer. ++// 1: at commit, a surface is cleaned only when it is new to this process or its IOSurface seed changed since the last ++// clean (every CPU writer unlocks with a seed bump); written surfaces are still invalidated at completion. ++// 2: 1, and a surface the GPU has written 4 times in a row with no seed change (the swap chain, CoreAnimation's ++// render targets: nobody reads them on the CPU) is invalidated at completion only every 64th time. ++// The state lives on the IOSurface object, so a recycled surface ID never inherits it. ++static int gFlushMode=-1; ++static std::atomic gFlushSkippedCommit,gFlushSkippedFinish,gFlushDoneCommit,gFlushDoneFinish; ++@interface NBSurfaceFlushState : NSObject { ++@public ++ uint32_t seed;uint32_t gpuWrites;bool cleaned; ++} ++@end ++@implementation NBSurfaceFlushState ++@end ++static char gFlushStateKey; ++static int flushMode() { ++ if(gFlushMode<0){const char *e=getenv("SWEET_MTL2_FLUSH");gFlushMode=(e && *e>='0' && *e<='2')?*e-'0':0;} ++ return gFlushMode; ++} ++// true: this surface needs its maintenance in this phase (finish: after a GPU write; else before the GPU uses it) ++static bool surfaceNeedsFlush(IOSurfaceRef surface,bool finish) { ++ const int mode=flushMode(); ++ if(mode==0)return true; ++ // A surface over a client's own memory (CoreAnimation wraps an image's bitmap that way: pixel format 0, any alignment) is ++ // written with no lock and so no seed bump, and it may be wrapped first and drawn into after: its seed says nothing. ++ // Such a surface, and any small one (an icon is 12 KiB: its clean costs nothing), is cleaned at every commit as in mode 0. ++ // (A guess at Settings' row icons in stripes on the first Metal builds with mode 2; cleaning more is never wrong.) ++ if(!finish && (IOSurfaceGetPixelFormat(surface)==0 || IOSurfaceGetAllocSize(surface)<(1u<<20) || ++ (reinterpret_cast(IOSurfaceGetBaseAddress(surface))&0x3fff)!=0)){gFlushDoneCommit++;return true;} ++ id object=(__bridge id)surface; ++ @synchronized(object) { ++ NBSurfaceFlushState *state=objc_getAssociatedObject(object,&gFlushStateKey); ++ if(!state){state=[NBSurfaceFlushState new];objc_setAssociatedObject(object,&gFlushStateKey,state,OBJC_ASSOCIATION_RETAIN_NONATOMIC);} ++ const uint32_t seed=IOSurfaceGetSeed(surface); ++ if(!finish) { ++ if(state->cleaned && state->seed==seed){gFlushSkippedCommit++;return false;} ++ if(state->cleaned)state->gpuWrites=0; // a CPU writer: no longer GPU-only ++ state->cleaned=true;state->seed=seed;gFlushDoneCommit++;return true; ++ } ++ if(state->seed!=seed){state->gpuWrites=0;gFlushDoneFinish++;return true;} ++ const uint32_t writes=++state->gpuWrites; ++ if(mode>=2 && writes>4 && (writes&63)!=0){gFlushSkippedFinish++;return false;} ++ gFlushDoneFinish++;return true; ++ } ++} ++static void flushSurfaces(NSMutableSet *surfaces,bool finish=false) { + if(!surfaces.count)return; + std::vector> ranges; + ranges.reserve(surfaces.count); + const size_t page=(size_t)getpagesize(); ++ NSMutableSet *seen=flushMode()?[NSMutableSet new]:nil; + for(id item in surfaces) { + IOSurfaceRef surface=[item isKindOfClass:[NBTexture class]]?[(NBTexture *)item surface]: + ([item isKindOfClass:[NBBuffer class]]?[(NBBuffer *)item surfaceOwner]:nullptr); + if(!surface)continue; ++ if(seen) { ++ // one decision per surface per phase: several textures/views of one surface must not count as several GPU writes ++ NSValue *key=[NSValue valueWithPointer:surface]; ++ if([seen containsObject:key])continue; ++ [seen addObject:key]; ++ if(!surfaceNeedsFlush(surface,finish))continue; ++ } + void *rawBase=IOSurfaceGetBaseAddress(surface); + const size_t allocation=IOSurfaceGetAllocSize(surface); + if(!rawBase || !allocation)continue; +@@ -1498,7 +1555,7 @@ + } + // After completion (or a refusal): invalidate the CPU's view of GPU-written imported surfaces, then completed handlers, once. + - (void)finish { +- if(!self.failedLocally)flushSurfaces(_writtenSurfaces); ++ if(!self.failedLocally)flushSurfaces(_writtenSurfaces,true); + // Actual GPU completion (or a local refusal that never submitted) is the + // release point. A retained completed command must not pin every old frame. + [_surfaces removeAllObjects];[_writtenSurfaces removeAllObjects];[_resourceOwners removeAllObjects]; +@@ -2745,6 +2802,8 @@ + row("J surface cache n=%llu bytes=%llu dup=%llu us=%llu max=%llu",(unsigned long long)gTimeSurfaceFlush.count.load(), + (unsigned long long)gSurfaceFlushBytes.load(),(unsigned long long)gSurfaceFlushDuplicates.load(), + (unsigned long long)gTimeSurfaceFlush.total.load(),(unsigned long long)gTimeSurfaceFlush.slowest.load()); ++ row("J flush mode=%d commit done=%llu skip=%llu finish done=%llu skip=%llu",flushMode(),(unsigned long long)gFlushDoneCommit.load(), ++ (unsigned long long)gFlushSkippedCommit.load(),(unsigned long long)gFlushDoneFinish.load(),(unsigned long long)gFlushSkippedFinish.load()); + { + uint64_t pbytes=0;size_t pcnt=0; + {std::lock_guard lock(gTexturePoolLock);pbytes=gTexturePoolBytes;pcnt=gTexturePool.size();} diff --git a/tools/ios27/rootrw/README.md b/tools/ios27/rootrw/README.md new file mode 100644 index 000000000..b4839cc03 --- /dev/null +++ b/tools/ios27/rootrw/README.md @@ -0,0 +1,64 @@ +# The root's own executables: the rewrite for ARMv8.0 cores (iOS 27 on the Galaxy S9) + +[docs/ios27/AOT-REWRITE.md](../../../docs/ios27/AOT-REWRITE.md) rewrites every instruction the Exynos M3 lacks in the dyld shared cache. What it leaves is +the root filesystem's Mach-O files outside the cache: 284 files of iOS 27.0.1 (24A446), 74,377 sites (LSE 73,300, SHA3 448, LDAPR 410, FP16 203; the +census of 9 November counted 46 FP16 words because it kept the mnemonic and dropped the operand, so `fcmp h0, ...` went into the wrong bucket). These tools +rewrite those files the same way, off the phone, and check the result. **Nothing made by them has been run on a phone when this was written** (9 November +2026): the layout, every byte and the frame contract are checked; that dyld loads a file with the appended segment is not. + +Not in the repository: the stub generators (`gen/rewrite-grow.py`, `fp16_stub.py`, `misc_stub.py`, `bf16_stub.py`, `classify.py` and the oracle word tables of +the cache rewrite: they are not published yet, see AOT-REWRITE.md), the list of files (`files284.txt`, from the census) and of course the binaries. The tools +expect them beside themselves (`gen/`, `ref/` for the untouched originals copied out of your own root image). + +| File | What | +|---|---| +| `macho-rewrite.py IN OUT [--mode A\|T\|auto] [--dry-run] [--report F]` | one Mach-O (thin or fat, every arm64/arm64e slice): every site becomes `B thunk`; the thunk and the bodies are the cache rewrite's (`STR x30, [sp, #-144]!`, `BL body`, `LDR x30, [sp], #144`, `B site+4`; one body per distinct word, the LSE ones with an A55 half and an M3 half); `LDAPR` becomes `LDAR` in place; sites inside `LC_DATA_IN_CODE` ranges are left | +| `run-all.py [--mode ..] [--stage D] [--out M]` | every listed file into `stage/` and `manifest.json` (path, SHA-256 before and after, mode, slices, sites by class, what is left and why, cdhash before and after) | +| `macho-check.py [--stage D] [--manifest M] [--only LIST]` | the checker, with a Mach-O parser of its own; it compares against `ref/` and fails on any deviation (below) | +| `negtest.sh` | seven tampered trees (a body byte, a site put back, a data byte, a signature byte, the segment's size, the chained fixups' segment count, and an untouched control): the checker must fail the six and pass the control | +| `install-rootrw.sh ROOTIMG [--only LIST] [--undo]` | writes the staged files into a raw HFS+ root image in place (`cat new > target`, so owner, mode, flags and extended attributes stay), keeps each original under `orig/` the first time, verifies SHA-256 after every write; `--undo` puts the originals back. It refuses an image outside its own directory unless `FORCE_ANY_IMAGE=1` | +| `verify-image.sh IMG stage\|ref` | every listed file inside an image against the staged tree or the originals | +| `mklists.py` | three lists for a staged roll-out: three leaf daemons; everything not held back; then the held-back ones (launchd, dyld, the interpose libraries, boot-critical daemons, the biggest files, kernel extensions), with the reason for each | + +## Where the stubs go + +- **T: the zero padding at the end of `__TEXT`** (after the last section, inside the segment's file range). No load command changes, the file keeps its size; the + only bytes that differ are the sites and the stubs. 252 of the 284 files fit. +- **A: a new segment `__STUBX`** (`LC_SEGMENT_64`, no sections, read and execute) **after the end of the file**, for the 31 files whose stubs do not fit (the big + ones). File offset = the old size rounded up to 16 KB, address = the end of the last segment rounded up to 16 KB; the load command is added last, so no + segment index changes. Where the header has no 72 free bytes, advisory load commands are dropped, only as far as needed (`LC_SOURCE_VERSION`, + `LC_LINKER_OPTIMIZATION_HINT`, `LC_DYLIB_CODE_SIGN_DRS`, `LC_DATA_IN_CODE`, `LC_FUNCTION_STARTS`; their data stays in `__LINKEDIT`). The chained fixups' + `seg_count` is raised by one without moving a byte of `__LINKEDIT` (free bytes after the table, or the table started four bytes earlier in the pad after the + 28-byte header). +- **The code signature is not touched** in either mode: the CodeDirectory, so the cdhash, the identifier and the entitlements, are byte for byte the original's. + Edited pages no longer match their page hashes and `__STUBX` lies beyond `codeLimit`. That is accepted by this kernel only as it is built and booted here: + a DEVELOPMENT kernel with `cs_integrity_skip=1` marks a page validated before it hashes it (`bsd/kern/ubc_subr.c`, `cs_validate_range` and `cs_validate_page`), + `cs_enforcement_disable=1` reports no violation otherwise, the "section outside code signature" test of `load_segment()` (`bsd/kern/mach_loader.c`) cannot fire + for a segment that starts after the signature (its subtraction is unsigned and wraps), and `F_ADDFILESIGS_RETURN` gives dyld the same covered end as before. A + RELEASE kernel would kill the process at the first edited page. +- Inserting the segment before `__LINKEDIT` and signing again was not built: it needs the same header room and the same chained-fixups change, and it changes the + cdhash for nothing this kernel asks for. + +One file cannot be rewritten (`/usr/lib/libBacktraceRecording.dylib`, 15 sites: no padding and 8 bytes short of header room), and one word is left on purpose +(a `CASPAL` encoding with an odd register pair in `fairplayd.H2`: unallocated, so data). + +## What the checker checks + +For each slice: header and load-command sizes; segments 16 KB aligned, inside the file, in order, with no two overlapping in the file or in memory; the existing +segments unchanged; the load commands = the original's minus the advisory ones dropped, plus `__STUBX` last; `otool -l` parses the file; the signature's load +command and blob unchanged and `codesign -dvvv` printing the same CDHash and Identifier; the chained fixups consistent, every old entry still where its segment +is; everything outside sites, load commands, the fixups header and the stub area identical to the reference; every changed word of an executable section a `B` +into the stub area, to exactly the generator's thunk, whose `BL` goes to exactly the body the generator makes for the **original** word (taken from the +reference); within +-128 MB; nothing else in the stub area; the frame contract of AOT-REWRITE.md for every distinct body (no store lands in the thunk's 144 +bytes); a second census (no word the generators handle is left, and no stub word needs more than ARMv8.0 + CRC except the A55 half of the LSE bodies). + +On 9 November: 283 files staged (252 T, 31 A), 73,951 sites to thunks and 410 `LDAPR` in place, 1,480,860 bytes of stubs, 1,745 distinct bodies with no +frame-contract violation, the checker green on the staged tree and on an all-A tree (273 files; 11 lack header room), `negtest.sh` as described, the installer +tried on a private copy of an image (install, a second run that changes nothing, undo, each verified). + +## Not known until it runs on a phone + +1. Whether dyld accepts mode A: a segment after `__LINKEDIT`, the dropped advisory load commands, the changed `seg_count` (for 21 slices the table starts four + bytes earlier). Mode T changes no structure; the first list and nearly all of the second are T. +2. Kernel extensions and DriverKit files go through other loaders (this kernel probably loads none of them). +3. There is no unwind information for the stub area. diff --git a/tools/ios27/rootrw/install-rootrw.sh b/tools/ios27/rootrw/install-rootrw.sh new file mode 100755 index 000000000..e8dae186e --- /dev/null +++ b/tools/ios27/rootrw/install-rootrw.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# install-rootrw.sh ROOTIMG [--only LISTFILE] [--undo] +# +# Writes the staged rewritten binaries (stage/, described by manifest.json) into a raw HFS+ root image, +# IN PLACE (`cat new > target`: same inode, so owner/mode/xattrs/flags stay), keeping the first-time original of +# every file under orig/; verifies sha256 after every write; syncs; detaches. +# --only LISTFILE only the root-relative paths listed (one per line, e.g. list-1.txt) +# --undo put orig/ back instead (every file that has an orig/ copy, or only those in LISTFILE) +# Safety: NEVER run this on the shared images. The image must live under this directory (a `cp -c` private copy, +# e.g. test.img) unless FORCE_ANY_IMAGE=1 is set in the environment. A target whose current sha256 is neither the +# manifest's sha256_before nor sha256_after (nor, for --undo, the orig copy) is left alone and reported (exit 1). +set -u +HERE="$(cd "$(dirname "$0")" && pwd)" +STAGE="${STAGE:-$HERE/stage}" +ORIG="${ORIG:-$HERE/orig}" +MANIFEST="${MANIFEST:-$HERE/manifest.json}" +MNT="${MNT:-$HERE/mnt-install}" +IMG=""; ONLY=""; UNDO=0 +while [ $# -gt 0 ]; do + case "$1" in + --only) ONLY="$2"; shift 2;; + --undo) UNDO=1; shift;; + -*) echo "unknown option $1" >&2; exit 2;; + *) IMG="$1"; shift;; + esac +done +[ -n "$IMG" ] && [ -f "$IMG" ] || { echo "usage: $0 ROOTIMG [--only LISTFILE] [--undo]" >&2; exit 2; } +IMG="$(cd "$(dirname "$IMG")" && pwd)/$(basename "$IMG")" +case "$IMG" in + "$HERE"/*) ;; + *) [ "${FORCE_ANY_IMAGE:-0}" = 1 ] || { echo "refusing: $IMG is not under $HERE (use a private cp -c copy)" >&2; exit 2; };; +esac +mkdir -p "$MNT" "$ORIG" +sha() { shasum -a 256 "$1" | cut -d' ' -f1; } +# list of (path, sha_before, sha_after) from the manifest, staged files only +LIST="$(mktemp "${TMPDIR:-/tmp}/rootrw.XXXXXX")" +python3 - "$MANIFEST" "$ONLY" > "$LIST" <<'EOF' +import json, sys +m = json.load(open(sys.argv[1])) +only = set(l.strip() for l in open(sys.argv[2]) if l.strip()) if sys.argv[2] else None +for p, e in sorted(m['files'].items()): + if e.get('status') != 'staged': + continue + if only is not None and p not in only: + continue + print('%s\t%s\t%s' % (p, e['sha256_before'], e['sha256_after'])) +EOF +N=$(wc -l < "$LIST" | tr -d ' ') +echo "targets: $N" +[ "$N" -gt 0 ] || { rm -f "$LIST"; echo "nothing to do"; exit 0; } +hdiutil attach -owners off -nobrowse -imagekey diskimage-class=CRawDiskImage -mountpoint "$MNT" "$IMG" > /dev/null 2>&1 \ + || { echo "attach failed: $IMG" >&2; rm -f "$LIST"; exit 1; } +RC=0; DONE=0; SKIP=0 +while IFS=$'\t' read -r P SB SA; do + T="$MNT$P"; S="$STAGE$P"; O="$ORIG$P" + if [ ! -f "$T" ]; then echo "MISSING in image: $P" >&2; RC=1; continue; fi + CUR="$(sha "$T")" + if [ "$UNDO" = 1 ]; then + [ -f "$O" ] || { SKIP=$((SKIP+1)); continue; } + OS="$(sha "$O")" + [ "$OS" = "$SB" ] || { echo "orig copy of $P does not match manifest sha256_before" >&2; RC=1; continue; } + [ "$CUR" = "$OS" ] && { SKIP=$((SKIP+1)); continue; } + [ "$CUR" = "$SA" ] || { echo "UNEXPECTED content in image for $P ($CUR): not touching" >&2; RC=1; continue; } + cat "$O" > "$T" || { echo "write failed $P" >&2; RC=1; continue; } + [ "$(sha "$T")" = "$OS" ] || { echo "VERIFY FAILED after restore: $P" >&2; RC=1; continue; } + DONE=$((DONE+1)); continue + fi + [ -f "$S" ] || { echo "no staged file for $P" >&2; RC=1; continue; } + [ "$(sha "$S")" = "$SA" ] || { echo "staged file $P does not match manifest sha256_after" >&2; RC=1; continue; } + if [ "$CUR" = "$SA" ]; then SKIP=$((SKIP+1)); continue; fi # already installed + [ "$CUR" = "$SB" ] || { echo "UNEXPECTED content in image for $P ($CUR): not touching" >&2; RC=1; continue; } + if [ ! -f "$O" ]; then # first time: keep the original + mkdir -p "$(dirname "$O")" + cp -p "$T" "$O" || { echo "orig copy failed $P" >&2; RC=1; continue; } + [ "$(sha "$O")" = "$SB" ] || { echo "orig copy verify failed $P" >&2; RC=1; rm -f "$O"; continue; } + fi + cat "$S" > "$T" || { echo "write failed $P" >&2; RC=1; continue; } + [ "$(sha "$T")" = "$SA" ] || { echo "VERIFY FAILED after write: $P" >&2; RC=1; continue; } + DONE=$((DONE+1)) +done < "$LIST" +sync +for i in 1 2 3; do hdiutil detach "$MNT" > /dev/null 2>&1 && break; sleep 2; done +hdiutil info | grep -q "$MNT" && { hdiutil detach -force "$MNT" > /dev/null 2>&1 || echo "WARNING: still attached at $MNT" >&2; } +rm -f "$LIST" +echo "done: $DONE written/restored, $SKIP already in place/skipped, rc=$RC" +exit $RC diff --git a/tools/ios27/rootrw/macho-check.py b/tools/ios27/rootrw/macho-check.py new file mode 100755 index 000000000..26ac07fd2 --- /dev/null +++ b/tools/ios27/rootrw/macho-check.py @@ -0,0 +1,631 @@ +#!/usr/bin/env python3 +"""macho-check.py [--stage DIR] [--ref DIR] [--manifest FILE] [--only LISTFILE] + +Off-phone checker for the staged tree produced by macho-rewrite.py / run-all.py. It does NOT import macho-rewrite.py: +it has its own Mach-O parser and re-derives every site by diffing the staged file against the untouched reference. +It reuses only the stub GENERATORS (gen/rewrite-grow.py gen_body / thunk_words / stub_words, misc_stub in-place table) +as the specification of what a body must be, plus llvm-objdump (cortex-a53 vs full feature set) as the instruction +oracle. Exit status 0 only if every check passes; every deviation prints a FAIL line. + +Checks per file/slice: + S1 structure: Mach-O header/LC sizes, segments 16 KB aligned, inside the file, vm ranges ascending and disjoint, + file ranges disjoint, fat slice table sane; `otool -l` parses; any added segment is __STUBX RX nsects 0. + S2 mode A: LC_CODE_SIGNATURE and the signature bytes unchanged, `codesign -dvvv` prints the SAME CDHash and Identifier, + chained-fixups seg_count == number of segments and every old entry preserved. + D everything outside {sites, load commands, chained-fixups header, appended bytes / T-mode stub bytes} is byte-identical + to the reference. + R every differing word of an executable section is `B thunk` (thunk in the stub region, exactly the thunk words, + BL body == generator output for the ORIGINAL word taken from the reference, B back to site+4), or a per-site + stub, or an in-place rewrite equal to the generator's; +-128 MB range; stub region bytes not used are zero; + in T mode the stub region was zero in the reference and lies outside every section. + F frame contract of every distinct shared body: it lowers SP itself before storing; no store lands in + [entry SP, entry SP+144). + C re-census: no in-scope (non OOS/UNDEF) non-ARMv8.0 word is left in any executable section except the sites + the manifest documents as left; every distinct stub-region word decodes on cortex-a53 except native LSE words + (the A55 half of dual bodies). +""" +import sys, os, struct, json, argparse, subprocess, re, collections, importlib.util, tempfile, hashlib +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +GEN = os.path.join(HERE, 'gen') +sys.path.insert(0, GEN) + + +def _load(name, path): + sp = importlib.util.spec_from_file_location(name, path) + m = importlib.util.module_from_spec(sp) + sp.loader.exec_module(m) + return m + + +rg = _load('rewrite_grow', os.path.join(GEN, 'rewrite-grow.py')) +import classify + +PAGE = 0x4000 +LIM = (1 << 27) - 0x10000 +OOS = ('OOS', 'UNDEF', 'SVE', 'OTHER', 'DC_other') +PRUNABLE = {0x2a, 0x2e, 0x2b, 0x29, 0x26} +fails = [] + + +def fail(m): + fails.append(m) + if len(fails) <= 200: + print('FAIL', m, flush=True) + + +# ------------------------------------------------------------------ own mini parser +def parse(m): + mg, cpu, sub, ft, nc, szc, fl = struct.unpack_from(' len(m): + raise ValueError('LC beyond file') + cmd, cs = struct.unpack_from(' 32 + szc: + raise ValueError('bad LC size') + lcs.append((cmd, o, cs)) + o += cs + if o != 32 + szc: + raise ValueError('sizeofcmds mismatch') + segs, secs = [], [] + for cmd, o, cs in lcs: + if cmd == 0x19: + name = m[o + 8:o + 24].split(b'\0')[0].decode() + vmaddr, vmsize, fo, fsz = struct.unpack_from('I', d[:4])[0] == 0xcafebabe: + n = struct.unpack('>I', d[4:8])[0] + return [struct.unpack('>IIIII', d[8 + 20 * i:28 + 20 * i]) for i in range(n)] + return None + + +def slices(d): + f = fat(d) + if f is None: + return [(struct.unpack_from(' len(m): + fail(f'{tag}: segment {s["name"]} file range beyond slice end') + if s['filesize'] > s['vmsize']: + fail(f'{tag}: segment {s["name"]} filesize > vmsize') + fr.append((s['fileoff'], s['fileoff'] + s['filesize'], s['name'])) + if s['vmsize'] and s['vmaddr'] < prev_end: + fail(f'{tag}: segment {s["name"]} overlaps/precedes previous vm range') + if s['vmsize']: + prev_end = max(prev_end, s['vmaddr'] + s['vmsize']) + fr.sort() + for a, b in zip(fr, fr[1:]): + if a[1] > b[0]: + fail(f'{tag}: file ranges of {a[2]} and {b[2]} overlap') + for sec in p['secs']: + sg = [s for s in segs if s['name'] == sec['seg']][0] + if sec['size'] and sec['off'] and not (sg['vmaddr'] <= sec['addr'] and sec['addr'] + sec['size'] <= sg['vmaddr'] + sg['vmsize']): + fail(f'{tag}: section {sec["name"]} outside its segment') + if sec['size'] and sec['off'] and sec['off'] + sec['size'] > len(m): + fail(f'{tag}: section {sec["name"]} beyond slice end') + return p + + +def codesig(m, p): + c = [x for x in p['lcs'] if x[0] == 0x1d] + if not c: + return None + off, sz = struct.unpack_from('= 2: + try: + w2m[int(q[0], 16)] = ' '.join(x.strip() for x in q[1:] if x.strip()) + except ValueError: + pass + unk = set() + for sub in ('root', 'cache'): + fn = os.path.join(GEN, 'oracle', sub, 'a53unk.txt') + if os.path.exists(fn): + unk.update(int(x, 16) for x in open(fn).read().split()) + unk_arr = np.array(sorted(unk), dtype=' original word (distinct bodies for frame check) + tot = collections.Counter() + nfile_mode = collections.Counter() + for fi, path in enumerate(files): + sp_, rp_ = a.stage + path, a.ref + path + if not os.path.exists(rp_): + fail(f'{path}: no reference file') + continue + dn, dr = open(sp_, 'rb').read(), open(rp_, 'rb').read() + doc = documented.get(path, {}) + if os.stat(sp_).st_mode & 0o7777 != os.stat(rp_).st_mode & 0o7777: + fail(f'{path}: mode bits differ from reference') + fn_, fr_ = fat(dn), fat(dr) + if (fn_ is None) != (fr_ is None) or (fn_ and len(fn_) != len(fr_)): + fail(f'{path}: fat layout kind changed') + continue + sn, sr = slices(dn), slices(dr) + if fn_: + end = 8 + 20 * len(fn_) + for (cpu, sub, off, size, al), (cpu0, sub0, off0, size0, al0) in zip(sn, sr): + if (cpu, sub, al) != (cpu0, sub0, al0): + fail(f'{path}: fat arch entry changed') + if off % (1 << al) or off < end or off + size > len(dn): + fail(f'{path}: fat slice off {off:#x} bad (align/overlap/EOF)') + end = off + size + if end != len(dn): + fail(f'{path}: trailing bytes after last fat slice') + if not a.skip_codesign and not otool_ok(sp_): + fail(f'{path}: otool -l does not parse') + file_changed = dn != dr + file_modes = set() + for si, ((cpu, sub, off, size, al), (cpu0, sub0, off0, size0, al0)) in enumerate(zip(sn, sr)): + tag = f'{path}[{si}]' + m, r = dn[off:off + size], dr[off0:off0 + size0] + if cpu != 0x0100000c: + if m != r: + fail(f'{tag}: non-arm64 slice modified') + continue + try: + pn, pr = check_structure(tag, m), check_structure(tag + '(ref)', r) + except ValueError as e: + fail(f'{tag}: structure: {e}') + continue + # ---- which mode? + nnew = [s for s in pn['segs'] if s['name'] == '__STUBX'] + old_names = [s['name'] for s in pr['segs']] + if len(pn['segs']) != len(pr['segs']) + len(nnew) or [s['name'] for s in pn['segs'] if s['name'] != '__STUBX'] != old_names: + fail(f'{tag}: segment list changed other than adding __STUBX') + if len(nnew) > 1: + fail(f'{tag}: more than one __STUBX') + continue + modeA = bool(nnew) + # old segments must be unchanged field by field + for s0, s1 in zip(pr['segs'], [s for s in pn['segs'] if s['name'] != '__STUBX']): + if s0 != s1: + fail(f'{tag}: existing segment {s0["name"]} changed') + if (pn['cpu'], pn['sub'], pn['ft']) != (pr['cpu'], pr['sub'], pr['ft']) or struct.unpack_from('= 0): + pass + # ---- stub region + if modeA: + st = nnew[0] + if st['maxprot'] != 5 or st['initprot'] != 5 or st['nsects'] != 0: + fail(f'{tag}: __STUBX not RX/nsects 0') + if st['vmaddr'] < max(s['vmaddr'] + s['vmsize'] for s in pr['segs']) or st['fileoff'] < len(r) or st['fileoff'] + st['filesize'] != len(m) or st['vmsize'] != st['filesize']: + fail(f'{tag}: __STUBX placement/size wrong') + sreg = (st['vmaddr'], st['vmaddr'] + st['vmsize'], st['fileoff'] - st['vmaddr']) + if len(m) < len(r) or m[len(r):st['fileoff']].strip(b'\0'): + fail(f'{tag}: gap before __STUBX not zero') + # code signature + cdhash + cr, cn = codesig(r, pr), codesig(m, pn) + if cr != cn: + fail(f'{tag}: LC_CODE_SIGNATURE changed') + elif cr and m[cr[0]:cr[0] + cr[1]] != r[cr[0]:cr[0] + cr[1]]: + fail(f'{tag}: signature blob bytes changed') + # chained fixups + fr_i, fn_i = fixups_info(r, pr), fixups_info(m, pn) + if fr_i: + if fn_i['segc'] != len(pn['segs']): + fail(f'{tag}: chained fixups seg_count {fn_i["segc"]} != segments {len(pn["segs"])}') + shift = 4 if fn_i['hdr'][1] == fr_i['hdr'][1] - 4 else 0 + if shift == 0 and fn_i['hdr'] != fr_i['hdr']: + fail(f'{tag}: chained fixups header changed') + if shift == 4 and (fn_i['hdr'][0], fn_i['hdr'][2:]) != (fr_i['hdr'][0], fr_i['hdr'][2:]): + fail(f'{tag}: chained fixups header changed beyond starts_offset') + for i, (o0, o1) in enumerate(zip(fr_i['offs'], fn_i['offs'])): + if o1 != (o0 + shift if o0 else 0): + fail(f'{tag}: chained fixups entry {i} {o0}->{o1}') + if fn_i['offs'][-1] != 0: + fail(f'{tag}: new chained fixups entry not 0') + fx_allowed = (fr_i['fo'], fr_i['base'] + 4 + 4 * fr_i['segc']) + # starts_in_segment payloads must be identical (they did not move) + for i, (o0, o1) in enumerate(zip(fr_i['offs'], fn_i['offs'])): + if o0: + q0 = fr_i['base'] + o0 + ssz = struct.unpack_from('> 26) == 0x05: # B + imm = w1 & 0x3FFFFFF + if imm & 0x2000000: + imm -= 1 << 26 + th = site + 4 * imm + if abs(th - site) >= LIM: + fail(f'{tag}: site {site:#x} thunk out of range') + continue + try: + tw = rd(th, 4) + except IndexError: + fail(f'{tag}: site {site:#x} B target {th:#x} not inside the stub region') + continue + if (tw[1] >> 26) == 0x25 and tw[0] == 0xF8000C00 | ((-144) & 0x1ff) << 12 | 31 << 5 | 30: # BL body + bimm = tw[1] & 0x3FFFFFF + if bimm & 0x2000000: + bimm -= 1 << 26 + body = th + 4 + 4 * bimm + if abs(body - th) >= LIM: + fail(f'{tag}: site {site:#x} body out of range') + continue + exp = rg.thunk_words(th, body, site) + if tw != exp: + fail(f'{tag}: thunk at {th:#x} words wrong') + continue + gw = gen_body(orig) + if gw is None: + fail(f'{tag}: site {site:#x} word {orig:08x} has no generated body (rewritten anyway)') + continue + try: + got = rd(body, len(gw)) + except IndexError: + fail(f'{tag}: body {body:#x} outside the stub region') + continue + if got != gw: + fail(f'{tag}: body at {body:#x} for {orig:08x} differs from generator') + continue + if gw[-1] != 0xD65F03C0: + fail(f'{tag}: body without RET') + used.append((th, th + 16, 'T')) + used.append((body, body + 4 * len(gw), 'B')) + bodies_all[tuple(gw)] = orig + stub_words_all.update(tw) + stub_words_all.update(gw) + tot['sites_thunk'] += 1 + else: + # per-site stub: stub words then B site+4 + sw = rg.stub_words(orig) if rg.lse_kind(orig) not in (None, 'ldapr') else None + if sw is None: + fail(f'{tag}: site {site:#x} target is not a thunk and no per-site stub exists') + continue + exp = list(sw) + [rg.b(site + 4 - (th + 4 * len(sw)))] + try: + got = rd(th, len(exp)) + except IndexError: + fail(f'{tag}: stub outside region') + continue + if got != exp: + fail(f'{tag}: per-site stub at {th:#x} differs from generator') + continue + used.append((th, th + 4 * len(exp), 'S')) + stub_words_all.update(got) + tot['sites_stubbed'] += 1 + else: + ex = inpl.get(orig) + if ex is None and (orig & 0x3FFFFC00) == 0x38BFC000 and (orig in unk_set): + ex = (orig & 0xC0000000) | 0x08DFFC00 | (orig & 0x3FF) # LDAPR -> LDAR, independent formula + if ex is None or ex != w1: + fail(f'{tag}: site {site:#x} {orig:08x}->{w1:08x} is neither B-thunk nor a generator in-place rewrite') + else: + tot['sites_inplace'] += 1 + if not (orig in set() or True): + pass + # the ORIGINAL word must be a word the oracle knows as in-scope + if not ((orig in handled_set) or (orig in inpl_set) or ((orig & 0x3FFFFC00) == 0x38BFC000 and (orig in unk_set))): + fail(f'{tag}: site {site:#x} original {orig:08x} is not a handled in-scope word') + # ---- stub region occupancy + used = sorted(set(used)) + for x, y in zip(used, used[1:]): + if x[1] > y[0]: + fail(f'{tag}: overlapping stub intervals {x} {y}') + lo = min(u[0] for u in used) if used else sreg[0] + hi = max(u[1] for u in used) if used else sreg[0] + if modeA: + lo, hi = sreg[0], sreg[1] + v = np.frombuffer(m, np.uint8, count=hi - lo, offset=lo + sreg[2]).copy() + msk = np.ones(len(v), bool) + for x0, x1, k in used: + msk[x0 - lo:x1 - lo] = False + if np.count_nonzero(v[msk]): + fail(f'{tag}: non-zero bytes in unused stub-region space') + if not modeA: + # T mode: region zero in reference, outside every section, only stub bytes differ + for x0, x1, k in used: + fo = x0 + sreg[2] + if r[fo:fo + (x1 - x0)].strip(b'\0'): + fail(f'{tag}: T-mode stub overwrote non-zero reference bytes at {x0:#x}') + for s in pr['secs']: + if s['addr'] < x1 and x0 < s['addr'] + s['size']: + fail(f'{tag}: T-mode stub inside section {s["name"]}') + allowed[fo:fo + (x1 - x0)] = True + if used: + pass + bad = np.flatnonzero(diffmask & ~allowed) + if len(bad): + fail(f'{tag}: {len(bad)} bytes differ from the reference outside sites/LCs/fixups/stubs, first at {bad[0]:#x}') + for md in file_modes: + nfile_mode[md] += 1 + if 'A' in file_modes and not a.skip_codesign: + c0, c1 = cs_id(rp_), cs_id(sp_) + if c0[0] is None or c0 != c1: + fail(f'{path}: codesign cdhash/identifier differ {c0} vs {c1}') + if fi % 40 == 0: + print('..', fi, '/', len(files), flush=True) + print('sites verified', dict(tot)) + print('files by mode', dict(nfile_mode)) + + # ---- F: frame contract over distinct shared bodies + bl = sorted(bodies_all.items()) + d = tempfile.mkdtemp(prefix='chk', dir=os.environ.get('TMPDIR', '/tmp')) + allw, owners = [], [] + for gw, orig in bl: + allw += list(gw) + [0x0000dead] + owners.append(orig) + np.array(allw, dtype=' 2 else '' + if ins == 'udf': + k += 1 + depth = 0 + lowered = False + continue + m_ = re.match(r'sub\s+sp, sp, #(0x[0-9a-f]+|\d+)', ins + ' ' + ops) + if m_: + depth += int(m_.group(1), 0) + lowered = True + continue + m_ = re.match(r'add\s+sp, sp, #(0x[0-9a-f]+|\d+)', ins + ' ' + ops) + if m_: + depth -= int(m_.group(1), 0) + continue + mm = re.search(r'\[sp(?:, #(-?(?:0x[0-9a-f]+|\d+)))?\](!)?(?:, #(-?(?:0x[0-9a-f]+|\d+)))?', ops) + if mm and (ins.startswith('st')): + off = int(mm.group(1), 0) if mm.group(1) else 0 + if mm.group(2): + depth -= off + off = 0 + rel = off - depth + width = 32 if ins.startswith('stp') and 'q' in ops.split(',')[0] else (16 if ins.startswith('stp') else (16 if ops.startswith('q') else 8)) + if rel + width > 0 and rel < 144: + nbad += 1 + if nbad <= 10: + print(' FRAME BAD', f'{owners[k]:08x}', ins, ops) + if mm.group(3): + depth -= int(mm.group(3), 0) + print('frame check: distinct bodies', len(owners), 'stores into the thunk frame/red zone:', nbad) + if nbad: + fail(f'{nbad} body stores land in [entry SP, +144)') + + # ---- C: decode every distinct stub-region word on cortex-a53 + ws = np.array(sorted(w for w in stub_words_all if w), dtype='' in l] + ufull = np.array([int(l.split(':')[1].split()[0], 16) for l in unkn], dtype='LDAR. +The stub generators are the deployed ones (gen/rewrite-grow.py gen_body/thunk_words, fp16_stub, misc_stub, bf16_stub). + +Where the thunks/bodies go: + A new RX segment __STUBX (LC_SEGMENT_64, nsects 0) appended AFTER the end of the file (file offset = old size + rounded to 16 KB, vmaddr = end of the last segment rounded to 16 KB). The code signature blob is not touched + (cdhash/identifier/entitlements unchanged); old pages that were edited, and the new pages beyond codeLimit, + are not covered by valid page hashes (tolerated: see README.md). Needs 72 bytes of header padding + (optionally freed by dropping advisory load commands), and the chained-fixups seg_count is kept equal to the + number of segments (see fix_chained). + T no structural change at all: the zero padding at the end of __TEXT (after the last section, inside the + segment's file range) holds thunks+bodies, like the shared-cache rewrite. Only for files whose need fits. + auto (default): T when it fits, else A. + B (insert before __LINKEDIT + re-sign) is NOT implemented (see README.md). +""" +import sys, os, struct, json, argparse, hashlib, importlib.util, collections +import numpy as np + +HERE = os.path.dirname(os.path.abspath(__file__)) +GEN = os.path.join(HERE, 'gen') +sys.path.insert(0, GEN) + + +def _load(name, path): + sp = importlib.util.spec_from_file_location(name, path) + m = importlib.util.module_from_spec(sp) + sp.loader.exec_module(m) + return m + + +rg = _load('rewrite_grow', os.path.join(GEN, 'rewrite-grow.py')) +import classify + +PAGE = 0x4000 +LC_SEGMENT_64 = 0x19 +LC_CODE_SIGNATURE = 0x1d +LC_UUID = 0x1b +LC_DATA_IN_CODE = 0x29 +LC_FUNCTION_STARTS = 0x26 +LC_SOURCE_VERSION = 0x2a +LC_DYLIB_CODE_SIGN_DRS = 0x2b +LC_LINKER_OPTIMIZATION_HINT = 0x2e +LC_CHAINED_FIXUPS = 0x80000034 +PRUNE_ORDER = [LC_SOURCE_VERSION, LC_LINKER_OPTIMIZATION_HINT, LC_DYLIB_CODE_SIGN_DRS, LC_DATA_IN_CODE, LC_FUNCTION_STARTS] +THUNK = 16 +LIM = (1 << 27) - 0x10000 +STUBX_LC = 72 + + +# --------------------------------------------------------------------------------------------------- +# word classification (shared with the deployed cache rewriter via rg) +_state = {} + + +def init(): + if _state: + return _state + rg.inscope_thunk_mask(np.zeros(1, '= 2: + try: + w2m[int(p[0], 16)] = ' '.join(x.strip() for x in p[1:] if x.strip()) + except ValueError: + pass + unk = set() + for sub in ('root', 'cache'): + fn = os.path.join(GEN, 'oracle', sub, 'a53unk.txt') + if os.path.exists(fn): + unk.update(int(x, 16) for x in open(fn).read().split()) + _state.update(handled=handled, inpl=inpl, inpl_keys=np.array(sorted(inpl), dtype=' LDAR (same size/Rn/Rt; acquire-consistent is stronger than acquire-RCpc)""" + if (w & 0x3FFFFC00) != 0x38BFC000: + return None + return (w & 0xC0000000) | 0x08DFFC00 | (w & 0x3FF) + + +def thunk_words(thunk, body, site): + return rg.thunk_words(thunk, body, site) + + +# --------------------------------------------------------------------------------------------------- +class Slice: + """parsed thin arm64 Mach-O slice (bytes)""" + + def __init__(self, m): + self.m = m + mg, self.cpu, self.sub, self.ft, self.ncmds, self.szc, self.flags = struct.unpack_from(' sites [(site_addr, foff, word, kind)], inplace [(foff, word, newword)], left [(addr, foff, word, cls)]""" + st = init() + sites, inpl, left = [], [], [] + dic = sl.dic_ranges() + for s in sl.exec_secs(): + n = s['size'] // 4 + w = np.frombuffer(sl.m, dtype='= lim: + return None + fo = seg['fileoff'] + (s0 - seg['vmaddr']) + v = np.frombuffer(sl.m, dtype=np.uint8, count=lim - s0, offset=fo) + nz = np.flatnonzero(v) + if len(nz): + s0 = (s0 + int(nz[-1]) + 64) & ~63 + return (s0, lim) if lim - s0 >= 64 else None + + +# --------------------------------------------------------------------------------------------------- +def fix_chained(m, nseg_new): + """Make LC_DYLD_CHAINED_FIXUPS' dyld_chained_starts_in_image.seg_count == nseg_new (= old + 1) without moving any + byte of __LINKEDIT: (1) use slack after the seg_info_offset[] array, else (2) put starts_in_image 4 bytes earlier + (into the 4 pad bytes between the 28-byte header and starts_offset=32) and re-base the entries. + Returns the method string; raises ValueError when neither works.""" + lcs = [] + o = 32 + ncmds = struct.unpack_from('= end_new and not any(m[base + 4 + 4 * segc:base + end_new]): + struct.pack_into('= 32 and so - 4 >= 28 and not any(m[fo + so - 4:fo + so]) and so >= 4 and nz and True: + nb = base - 4 + new = [segc + 1] + [(x + 4 if x else 0) for x in offs] + [0] + struct.pack_into('<%dI' % len(new), m, nb, *new) + struct.pack_into(' left, the rest -> counted as out of scope.""" + lf = [(hex(a), '%08x' % w, c, 'no free scratch register / unhandled form') for a, f, w, c in left] + oos = collections.Counter() + for a, f, w, c in left_u: + if c.startswith(OUT_OF_SCOPE): + oos[c] += 1 + else: + lf.append((hex(a), '%08x' % w, c, 'outside generator coverage')) + info['left'] = lf + info['out_of_scope'] = dict(oos) + + +def rewrite_slice(buf, mode='auto', prune=True, fixups=True): + """-> (new slice bytes, info dict)""" + init() + sl = Slice(buf) + info = dict(mode=None, cputype=sl.cpu, cpusubtype=sl.sub & 0xffffff, filetype=sl.ft) + sites, inpl, left_u = find_sites(sl) + info['sites_total'] = len(sites) + len(inpl) + info['inplace'] = len(inpl) + if not sites and not inpl: + info['mode'] = 'none' + set_left(info, [], left_u) + return bytes(buf), info + m = bytearray(buf) + # --- decide placement + region = text_pad_region(sl) + placed = None + if mode in ('T', 'auto') and region: + blob, recs, left = plan_stub(sites, region[0]) + if region[0] + len(blob) <= region[1]: + placed = ('T', region[0], blob, recs, left) + if placed is None: + if mode == 'T': + info['mode'] = 'failed' + info['why'] = 'no room in __TEXT padding' + return bytes(buf), info + # A + vmend = max(s['vmaddr'] + s['vmsize'] for s in sl.segs) + base = (vmend + PAGE - 1) & ~(PAGE - 1) + blob, recs, left = plan_stub(sites, base) + placed = ('A', base, blob, recs, left) + kind, base, blob, recs, left = placed + info['mode'] = kind + # distances + for site, foff, word, t, a, bd in recs: + assert abs(a - site) < LIM and abs(bd - a) < LIM and abs(bd - site) < LIM, ('range', hex(site)) + # --- patch sites / inplace + for site, foff, word, t, a, bd in recs: + assert struct.unpack_from(' o: + x[1] -= cs + sl.szc -= cs + sl.ncmds -= 1 + room += cs + dropped.append(cmd) + if room < STUBX_LC: + info['mode'] = 'failed' + info['why'] = 'no header room after pruning (%d bytes, need %d)' % (room, STUBX_LC) + return bytes(buf), info + if any(m[32 + sl.szc:32 + sl.szc + STUBX_LC]): + raise ValueError('header padding not zero') + size = (len(blob) + PAGE - 1) & ~(PAGE - 1) + fileoff = (len(m) + PAGE - 1) & ~(PAGE - 1) + nseg = len(sl.segs) + lc = struct.pack('I', d[:4])[0] + if mg in (0xcafebabe, 0xcafebabf): + n = struct.unpack('>I', d[4:8])[0] + assert mg == 0xcafebabe + out = [] + for i in range(n): + cpu, sub, off, size, al = struct.unpack('>IIIII', d[8 + 20 * i:28 + 20 * i]) + out.append((cpu, sub, off, size, al)) + return out + return None + + +def rewrite_file(d, mode='auto', **kw): + """d: file bytes -> (new bytes, [slice infos])""" + fs = fat_slices(d) + if fs is None: + if struct.unpack_from('II', 0xcafebabe, n)) + hdr_end = 8 + 20 * n + pos = hdr_end + table = bytearray() + body = bytearray() + for cpu, sub, nb, al in news: + a = 1 << al + off = (pos + a - 1) & ~(a - 1) + body += b'\0' * (off - pos) + body += nb + table += struct.pack('>IIIII', cpu, sub, off, len(nb), al) + pos = off + len(nb) + out += table + out += b'\0' * (hdr_end - len(out)) + out += body + return bytes(out), infos + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('inp') + ap.add_argument('out') + ap.add_argument('--mode', default='auto', choices=['A', 'T', 'auto', 'B']) + ap.add_argument('--dry-run', action='store_true') + ap.add_argument('--report') + ap.add_argument('--no-prune', action='store_true') + ap.add_argument('--no-fixups-fix', action='store_true') + a = ap.parse_args() + if a.mode == 'B': + sys.exit('mode B (insert before __LINKEDIT + re-sign) is not implemented; see README.md') + d = open(a.inp, 'rb').read() + mode = 'auto' if a.mode == 'auto' else a.mode + if mode == 'A': + # force A: pretend T never fits + global text_pad_region + text_pad_region = lambda sl: None + nb, infos = rewrite_file(d, mode, prune=not a.no_prune, fixups=not a.no_fixups_fix) + print(json.dumps(infos, indent=1, default=str)[:4000]) + if a.report: + json.dump(infos, open(a.report, 'w'), indent=1, default=str) + if not a.dry_run: + os.makedirs(os.path.dirname(os.path.abspath(a.out)), exist_ok=True) + open(a.out, 'wb').write(nb) + os.chmod(a.out, os.stat(a.inp).st_mode & 0o7777) + + +if __name__ == '__main__': + main() diff --git a/tools/ios27/rootrw/mklists.py b/tools/ios27/rootrw/mklists.py new file mode 100644 index 000000000..29da60ac4 --- /dev/null +++ b/tools/ios27/rootrw/mklists.py @@ -0,0 +1,51 @@ +import json, sys +man = json.load(open(sys.argv[1] if len(sys.argv) > 1 else 'manifest.json'))['files'] +staged = {p: e for p, e in man.items() if e['status'] == 'staged'} +L1 = ['/usr/libexec/aonsensed', '/usr/libexec/safetycheckd', '/System/Library/PrivateFrameworks/PhotosFace.framework/photosfaced'] +L1 = [p for p in staged if p.endswith(('/aonsensed', '/safetycheckd', '/photosfaced'))] +HOLD_EXACT = { + '/sbin/launchd': 'PID 1', + '/usr/lib/dyld': 'the dynamic linker, kernel-loaded into every process (A mode, 183 sites)', + '/usr/lib/system/introspection/libdispatch.dylib': 'dyld-adjacent: substituted for libdispatch via DYLD_LIBRARY_PATH', + '/usr/lib/libglInterpose.dylib': 'GL interpose library injected into GPU clients (9741 sites, A mode)', + '/private/preboot/Cryptexes/OS/usr/lib/libglInterpose.dylib': 'cryptex copy of libglInterpose', + '/usr/lib/libMetalMetricsInterpose.dylib': 'Metal interpose library injected into GPU clients', + '/private/preboot/Cryptexes/OS/usr/lib/libMetalMetricsInterpose.dylib': 'cryptex copy of libMetalMetricsInterpose', +} +HOLD_NAMES = {n: why for n, why in { + 'logd': 'unified logging daemon: every process talks to it, launchd waits on it', + 'trustd': 'Security trust evaluation: needed by nearly every networked/system process', + 'fseventsd': 'filesystem events: early boot daemon', + 'watchdogd': 'system watchdog (a crash loop can reboot the phone)', + 'thermalmonitord': 'thermal manager: boot critical on a real device', + 'syslogd': 'early boot logging', + 'mDNSResponder': 'DNS/network name resolution, needed by every network client', + 'wifid': 'Wi-Fi manager (the only link of this phone)', + 'bluetoothd': 'Bluetooth manager', + 'lockdownd': 'host pairing/lockdown service', + 'installd': 'app installation', + 'mobileactivationd': 'activation state: gates SpringBoard setup flow', + 'diskimagesiod': 'disk image I/O (A mode, 758 sites)', + 'diskimagespawner': 'disk image service launcher', + 'locationd': 'CoreLocation: 2717 sites (A mode), many clients', + 'CommCenter': 'telephony daemon: 26791 sites (A mode), biggest file', + 'pipelined': '2652 sites (A mode)', + 'MapsOfflineService': '3159 sites, 40 MB (A mode)', +}.items()} +hold = {} +for p in staged: + n = p.rsplit('/', 1)[1] + if p in HOLD_EXACT: hold[p] = HOLD_EXACT[p] + elif n in HOLD_NAMES and p.startswith(('/usr/libexec', '/usr/sbin', '/System/Library/Frameworks/CoreTelephony', '/System/Library/PrivateFrameworks/GeoServices')): hold[p] = HOLD_NAMES[n] + elif p.startswith(('/System/Library/Extensions/', '/System/Library/DriverExtensions/', '/System/DriverKit/')): + hold[p] = 'kernel-extension / DriverKit file: loaded by kxld/driverkitd, not by dyld (different loader)' +l2 = sorted(p for p in staged if p not in hold) +l3 = sorted(hold) +open('list-1.txt', 'w').write('\n'.join(sorted(L1)) + '\n') +open('list-2.txt', 'w').write('\n'.join(l2) + '\n') +open('list-3.txt', 'w').write('\n'.join(l3) + '\n') +json.dump(hold, open('list-3-reasons.json', 'w'), indent=1) +print('list-1', len(L1), [ (p, staged[p]['sites'], staged[p]['mode']) for p in L1]) +print('list-2', len(l2), 'sites', sum(staged[p]['sites'] for p in l2), 'modes', {m: sum(1 for p in l2 if staged[p]['mode'] == m) for m in ('T', 'A')}) +print('list-3', len(l3), 'sites', sum(staged[p]['sites'] for p in l3)) +for p in l3: print(' hold', staged[p]['sites'], staged[p]['mode'], p.rsplit('/',1)[1]) diff --git a/tools/ios27/rootrw/negtest.sh b/tools/ios27/rootrw/negtest.sh new file mode 100755 index 000000000..1ccd91dfc --- /dev/null +++ b/tools/ios27/rootrw/negtest.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# negative tests: the checker must FAIL on tampered staged files +cd "$(dirname "$0")" +PY=${PY:-python3} +T=/usr/libexec/safetycheckd # T mode +A=/usr/sbin/mDNSResponder # A mode +rm -rf neg && mkdir -p neg +mk() { rm -rf neg/s; mkdir -p neg/s$(dirname $1); cp -p stage$1 neg/s$1; } +run() { $PY macho-check.py --stage neg/s --only <(echo $1) 2>&1 | egrep "^FAIL|FAILS" | head -3; } +python3 - <<'PYEOF' +import struct,shutil,os +def flip(path, off, mask=0xff): + b=bytearray(open(path,'rb').read()); b[off]^=mask; open(path,'wb').write(b) +PYEOF +echo "== 1 control (untampered T)"; mk $T; run $T +echo "== 2 T: corrupt a byte of a body"; mk $T +$PY - "$T" <<'PYEOF' +import sys,struct,subprocess +p='neg/s'+sys.argv[1]; b=bytearray(open(p,'rb').read()) +# find first site B and its thunk's BL body: simplest: flip a byte in the stub region = last nonzero 64 bytes of TEXT padding: locate the thunk word STR x30 +import re +i=b.find(struct.pack('0 +# body follows somewhere before thunks; corrupt the word right after the thunk's 4 words +j=i-8; b[j]^=0x40; open(p,'wb').write(b) +PYEOF +run $T +echo "== 3 T: restore one site to the original (site left)"; mk $T +$PY - "$T" <<'PYEOF' +import sys +p='neg/s'+sys.argv[1]; r='ref'+sys.argv[1] +a=bytearray(open(p,'rb').read()); b=open(r,'rb').read() +d=[i for i in range(0,len(b)-3,4) if a[i:i+4]!=b[i:i+4] and i>0x4000] +a[d[0]:d[0]+4]=b[d[0]:d[0]+4]; open(p,'wb').write(a) +PYEOF +run $T +echo "== 4 T: change an unrelated byte (data)"; mk $T +$PY - "$T" <<'PYEOF' +import sys +p='neg/s'+sys.argv[1]; a=bytearray(open(p,'rb').read()); a[len(a)-5000]^=1; open(p,'wb').write(a) +PYEOF +run $T +echo "== 5 A: flip a byte of the code signature"; mk $A +$PY - "$A" <<'PYEOF' +import sys,struct +p='neg/s'+sys.argv[1]; a=bytearray(open(p,'rb').read()) +o=32 +n=struct.unpack_from(' stage/ + manifest.json +Runs macho-rewrite over every listed file (reference copies under ref/).""" +import sys, os, json, hashlib, importlib.util, subprocess, re, argparse, time, shutil + +HERE = os.path.dirname(os.path.abspath(__file__)) +sp = importlib.util.spec_from_file_location('macho_rewrite', os.path.join(HERE, 'macho-rewrite.py')) +mr = importlib.util.module_from_spec(sp) +sp.loader.exec_module(mr) + + +def cs(path): + r = subprocess.run(['codesign', '-dvvv', path], capture_output=True, text=True) + t = r.stderr + r.stdout + cd = re.search(r'^CDHash=(\S+)', t, re.M) + ident = re.search(r'^Identifier=(\S+)', t, re.M) + return (cd.group(1) if cd else None), (ident.group(1) if ident else None) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--list', default=os.path.join(HERE, 'files284.txt')) + ap.add_argument('--ref', default=os.path.join(HERE, 'ref')) + ap.add_argument('--stage', default=os.path.join(HERE, 'stage')) + ap.add_argument('--mode', default='auto', choices=['auto', 'A', 'T']) + ap.add_argument('--out', default=os.path.join(HERE, 'manifest.json')) + a = ap.parse_args() + if a.mode == 'A': + mr.text_pad_region = lambda sl: None + shutil.rmtree(a.stage, ignore_errors=True) + files = [l.strip() for l in open(a.list) if l.strip()] + man = {'tool': 'macho-rewrite.py', 'mode_arg': a.mode, 'files': {}} + tot = {'files': len(files), 'staged': 0, 'failed': 0, 'sites': 0, 'by_class': {}, 'left': 0, 'stub_bytes': 0, 'modes': {}} + t0 = time.time() + for i, p in enumerate(files): + src = a.ref + p + d = open(src, 'rb').read() + e = {'sha256_before': hashlib.sha256(d).hexdigest(), 'size_before': len(d)} + try: + nb, infos = mr.rewrite_file(d, a.mode) + except Exception as ex: + nb, infos = d, [{'mode': 'failed', 'why': 'exception %r' % ex}] + e['slices'] = infos + modes = [x['mode'] for x in infos if x['mode'] != 'skip'] + e['mode'] = '+'.join(sorted(set(modes))) if modes else 'skip' + e['sites'] = sum(x.get('sites_total', 0) for x in infos) + cl = {} + for x in infos: + for k, v in x.get('classes', {}).items(): + cl[k] = cl.get(k, 0) + v + e['sites_by_class'] = cl + e['left'] = sum(len(x.get('left', [])) for x in infos) + e['stub_bytes'] = sum(x.get('stub_bytes', 0) for x in infos) + if any(m == 'failed' for m in modes): + e['status'] = 'failed' + tot['failed'] += 1 + elif nb == d: + e['status'] = 'unchanged' + else: + e['status'] = 'staged' + dst = a.stage + p + os.makedirs(os.path.dirname(dst), exist_ok=True) + open(dst, 'wb').write(nb) + os.chmod(dst, os.stat(src).st_mode & 0o7777) + e['sha256_after'] = hashlib.sha256(nb).hexdigest() + e['size_after'] = len(nb) + e['cdhash_before'], e['identifier_before'] = cs(src) + e['cdhash_after'], e['identifier_after'] = cs(dst) + tot['staged'] += 1 + man['files'][p] = e + tot['sites'] += e['sites'] + tot['left'] += e['left'] + tot['stub_bytes'] += e['stub_bytes'] + for k, v in cl.items(): + tot['by_class'][k] = tot['by_class'].get(k, 0) + v + tot['modes'][e['mode']] = tot['modes'].get(e['mode'], 0) + 1 + if i % 20 == 0: + print(i, '/', len(files), p, e['mode'], e['sites'], '%.0fs' % (time.time() - t0), flush=True) + man['totals'] = tot + json.dump(man, open(a.out, 'w'), indent=1, default=str) + print(json.dumps(tot, indent=1)) + + +if __name__ == '__main__': + main() diff --git a/tools/ios27/rootrw/verify-image.sh b/tools/ios27/rootrw/verify-image.sh new file mode 100755 index 000000000..40f93f9c1 --- /dev/null +++ b/tools/ios27/rootrw/verify-image.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# verify-image.sh IMG WHICH(stage|ref): compare every file of list-1/2/3 in IMG against stage/ or ref/ (sha256, mode, flags) +cd "$(dirname "$0")"; IMG=$1; WHICH=$2; M=$PWD/mnt-verify; mkdir -p $M +hdiutil attach -owners off -nobrowse -imagekey diskimage-class=CRawDiskImage -mountpoint $M $IMG >/dev/null 2>&1 || { echo attach failed; exit 1; } +bad=0; n=0 +while IFS= read -r p; do + [ -f $WHICH$p ] || continue + n=$((n+1)) + a=$(shasum -a 256 < $M$p | cut -c1-64); b=$(shasum -a 256 < $WHICH$p | cut -c1-64) + [ "$a" = "$b" ] || { echo "SHA MISMATCH $p"; bad=$((bad+1)); } + sa=$(stat -f "%p %Sf" $M$p); sb=$(stat -f "%p %Sf" ref$p) + [ "$sa" = "$sb" ] || { echo "META $p: $sa vs $sb"; bad=$((bad+1)); } + xa=$(xattr -l $M$p 2>/dev/null | md5); xb=$(xattr -l ref$p 2>/dev/null | md5) + [ "$xa" = "$xb" ] || { echo "XATTR $p"; bad=$((bad+1)); } +done < <(cat list-1.txt list-2.txt list-3.txt) +echo "checked $n files against $WHICH: bad=$bad" +hdiutil detach $M >/dev/null 2>&1 || hdiutil detach -force $M >/dev/null 2>&1 +exit $bad diff --git a/tools/ios27/uirun/settings.ui b/tools/ios27/uirun/settings.ui new file mode 100644 index 000000000..ff4e22654 --- /dev/null +++ b/tools/ios27/uirun/settings.ui @@ -0,0 +1,16 @@ +# settings.ui : unlock, open Settings from the home screen by its icon, look at the top level, the scrolled list and one pane. +# The icon's place depends on the home screen: run smoke.ui first, find the Settings icon in 1-home.png, and pass its place: +# uirun.sh 2 -c "$(sed 's/@SX@/333/; s/@SY@/475/' settings.ui)" (thousandths of the screen) +home; wait 1.5; home; wait 2 +shot 1-home +tap @SX@ @SY@; wait 5 +shot 2-settings-top +swipe 500 750 500 350 400; wait 2 +shot 3-settings-scrolled +swipe 500 750 500 350 400; wait 2 +shot 4-settings-scrolled2 +tap 500 400; wait 4 +shot 5-a-pane +swipe 20 500 600 500 300; wait 2 +home; wait 2 +shot 6-home-again diff --git a/tools/ios27/uirun/smoke.ui b/tools/ios27/uirun/smoke.ui new file mode 100644 index 000000000..b6fd5c106 --- /dev/null +++ b/tools/ios27/uirun/smoke.ui @@ -0,0 +1,14 @@ +# smoke.ui : do the console verbs work on this kernel? (touch, button, shot) ~25 s +raw uptime +home; wait 1.5; home; wait 2 +shot 1-home +swipe 880 500 120 500; wait 2 +shot 2-page2 +swipe 120 500 880 500; wait 2 +swipe 930 3 930 500 350; wait 2 +shot 3-control-center +home; wait 2 +lock; wait 2 +shot 4-locked +home; wait 1.5; home; wait 2 +shot 5-unlocked diff --git a/tools/ios27/uirun/uirun.py b/tools/ios27/uirun/uirun.py new file mode 100755 index 000000000..341d1034c --- /dev/null +++ b/tools/ios27/uirun/uirun.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +"""uirun.py : drive the phone's UI from the Mac over the kernel's USB console, with nobody at the phone. + + uirun.py [--phone 1|2 | --port /dev/cu.usbmodemN] [--shots DIR] [--log FILE] SCRIPT run a script file + uirun.py [--phone ...] -c 'home; wait 2; tap 500 500; shot settings' or steps on the command line + +Needs a kernel with the console's touch and button verbs (the fork's wip/ios27-console-touch) and usb_gadget=5. With two +phones on one Mac the node is found by USB location (--phone; the LOC table below is one bench's), because both phones' +gadgets carry the same serial string; --port names the node directly. + +Script: one step a line ('#' starts a comment; ';' separates steps on one line). Coordinates are THOUSANDTHS of the +screen: 0 0 top left, 1000 1000 bottom right (so they do not depend on the canvas or the panel). + tap X Y a tap + swipe X1 Y1 X2 Y2 [MS] a swipe (default 300 ms) + home | lock | volup | voldown [MS] a button (80 ms; e.g. 'lock 1500' is a long press) + wait S seconds (decimals allowed) + shot [NAME] a picture of the panel (console verb "shot 2": every 2nd pixel, 720x1480) -> DIR/NN-NAME.png + raw TEXT any console line as it is (e.g. 'raw sysctl kern.hakios.brightness=200', 'raw uptime') + return end the run: the loader restarts the phone into TWRP +Common gestures (iPhone-X style UI on the S9's canvas): + unlock: home; wait 1; home Control Center: swipe 930 3 930 500 app switcher: swipe 500 995 500 600 600 + go home from an app: home scroll down a list: swipe 500 750 500 350 next home page: swipe 880 500 120 500 + +Who reads the port: the console is ONE stream. If a log reader already has the node open (a `cat` of the node into a +file, say), this tool only WRITES the verbs, and the pictures are cut out of that reader's file (the stream holds them framed: +"SHOT w h stride rgb888 bytes\\nEND\\n"): pass --log FILE (the reader's file) and they are extracted as they +arrive. With no reader on the node and no --log, this tool reads the port itself for the pictures (and prints the text). +Every step is logged with its time to DIR/steps.log. +""" +import argparse, os, re, struct, subprocess, sys, time, zlib + +LOC = {'1': 1048576, '2': 17825792} + +def node_for(loc): + out = subprocess.run(['ioreg', '-r', '-c', 'IOUSBHostDevice', '-l', '-w0'], capture_output=True, text=True).stdout + cur = None + for ln in out.splitlines(): + m = re.search(r'"locationID" = (\d+)', ln) + if m: cur = int(m.group(1)) + m = re.search(r'"IOCalloutDevice" = "(/dev/cu\.usbmodem[^"]*)"', ln) + if m and cur == loc: return m.group(1) + return None + +def png(path, w, h, stride, rgb): + rows = b''.join(b'\0' + rgb[y * stride: y * stride + w * 3] for y in range(h)) + def chunk(t, c): return struct.pack('>I', len(c)) + t + c + struct.pack('>I', zlib.crc32(t + c)) + open(path, 'wb').write(b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0)) + + chunk(b'IDAT', zlib.compress(rows, 6)) + chunk(b'IEND', b'')) + +HDR = re.compile(rb'SHOT (\d+) (\d+) (\d+) rgb888 (\d+)\n') + +def cut_shot(data, start): + """the first whole framed picture at or after `start` of `data`: (w, h, stride, pixels, end) or None""" + m = HDR.search(data, start) + if not m: return None + w, h, stride, n = (int(x) for x in m.groups()) + if len(data) < m.end() + n: return None + return w, h, stride, data[m.end(): m.end() + n], m.end() + n + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('--phone', default='2', choices=['1', '2']) + ap.add_argument('--port') + ap.add_argument('--shots', default='shots-' + time.strftime('%m%d-%H%M%S')) + ap.add_argument('--log', help="the file a log reader appends the console to (pictures are cut out of it)") + ap.add_argument('-c', dest='inline') + ap.add_argument('script', nargs='?') + a = ap.parse_args() + text = a.inline if a.inline else open(a.script).read() + steps = [s.strip() for ln in text.splitlines() for s in ln.split('#')[0].split(';') if s.strip()] + port = a.port or node_for(LOC[a.phone]) + if not port: sys.exit('uirun: phone %s has no console node (not booted into iOS, or no usb_gadget=5)' % a.phone) + os.makedirs(a.shots, exist_ok=True) + slog = open(os.path.join(a.shots, 'steps.log'), 'a') + def say(s): + line = '%.2f %s' % (time.time(), s); print(line, flush=True); slog.write(line + '\n'); slog.flush() + say('port %s, %d steps, pictures -> %s%s' % (port, len(steps), a.shots, ', cut from ' + a.log if a.log else '')) + reader = None + if not a.log: + # nobody else reads: take the port ourselves (raw, no echo), for the pictures + import termios, tty + reader = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + tty.setraw(reader) + t = termios.tcgetattr(reader); t[0] &= ~(termios.IXON | termios.IXOFF | termios.IXANY); t[3] &= ~(termios.ECHO | termios.ICANON | termios.ISIG) + termios.tcsetattr(reader, termios.TCSANOW, t) + def send(line): + if reader is not None: os.write(reader, (line + '\n').encode()) + else: + with open(port, 'wb', buffering=0) as f: f.write((line + '\n').encode()) + shot_no = 0 + for step in steps: + w = step.split() + verb, args = w[0].lower(), w[1:] + if verb == 'wait': + say('wait ' + args[0]); time.sleep(float(args[0])); continue + if verb == 'tap': + send('touch %s %s' % (args[0], args[1])); say(step); time.sleep(0.35) + elif verb == 'swipe': + ms = int(args[4]) if len(args) > 4 else 300 + send('touch %s %s %s %s %d' % (args[0], args[1], args[2], args[3], ms)); say(step); time.sleep(ms / 1000 + 0.35) + elif verb in ('home', 'lock', 'volup', 'voldown'): + name = {'volup': 'up', 'voldown': 'down'}.get(verb, verb) + ms = int(args[0]) if args else 80 + send('button %s %d' % (name, ms)); say(step); time.sleep(ms / 1000 + 0.3) + elif verb == 'raw': + send(' '.join(args)); say(step); time.sleep(0.3) + elif verb == 'return': + send('return'); say('return (the phone restarts into TWRP)'); break + elif verb == 'shot': + shot_no += 1 + name = '%02d-%s.png' % (shot_no, args[0] if args else 'shot') + path = os.path.join(a.shots, name) + if a.log: + at = os.path.getsize(a.log) if os.path.exists(a.log) else 0 + send('shot 2'); got = None; t0 = time.time() + while time.time() - t0 < 25 and not got: + time.sleep(0.5) + with open(a.log, 'rb') as f: + f.seek(at); got = cut_shot(f.read(), 0) + else: + buf = b''; send('shot 2'); got = None; t0 = time.time() + while time.time() - t0 < 25 and not got: + try: buf += os.read(reader, 1 << 16) + except BlockingIOError: time.sleep(0.02) + got = cut_shot(buf, 0) + if got: + png(path, got[0], got[1], got[2], got[3]); say('shot -> %s (%dx%d)' % (path, got[0], got[1])) + else: + say('shot %s FAILED: no framed picture within 25 s (does this kernel answer "shot"? is --log the right file?)' % name) + else: + say('unknown step: ' + step) + say('done') + +if __name__ == '__main__': + main() diff --git a/tools/ios27/uirun/wallpaper-picker.ui b/tools/ios27/uirun/wallpaper-picker.ui new file mode 100644 index 000000000..30cabe8b2 --- /dev/null +++ b/tools/ios27/uirun/wallpaper-picker.ui @@ -0,0 +1,27 @@ +# wallpaper-picker.ui : Settings -> Wallpaper -> Add New Wallpaper -> first suggestion -> Add -> Set as Wallpaper Pair, with pictures. +# Coordinates are thousandths of the screen and are GUESSES for iOS 27's layout on the 828x1792 canvas: run it once, look at the +# pictures, correct the taps. Start from a booted phone (lock screen or home). +home; wait 1.5; home; wait 2 +shot 01-home +# Settings: spotlight is the layout-independent way in: pull down, type is not possible (no keyboard verb), so tap the icon instead. +# Phone 2's r3 root had Settings on the home page at about (333, 475); the bp root's home differs: fix after the first picture. +tap 333 475; wait 4 +shot 02-settings +swipe 500 800 500 250 400; wait 1.5 +shot 03-settings-scrolled +# "Wallpaper" row (guess): tap it, then look +tap 300 560; wait 3 +shot 04-wallpaper +# "+ Add New Wallpaper" (guess: the blue button under the previews) +tap 500 640; wait 4 +shot 05-picker +tap 250 330; wait 4 +shot 06-chosen +# "Add" at the top right +tap 900 70; wait 3 +shot 07-add +# "Set as Wallpaper Pair" +tap 500 880; wait 4 +shot 08-set +home; wait 2 +shot 09-home-after