From d44f52d81e2f3e60cb7fd0077a01ad2bac0ad554 Mon Sep 17 00:00:00 2001 From: Claudius Date: Mon, 9 Nov 2026 22:34:55 +0000 Subject: [PATCH 1/8] ios-27: Liquid Glass on the Metal compositor at the OpenGL ES one's frame rate (docs/ios27/LIQUID-GLASS.md), the frontend's flush policy, the shim's truthful timeline query, and the root executables' rewriter docs/ios27/LIQUID-GLASS.md: where CoreAnimation's Metal renderer lost its time on the S9 (synchronous GPU submits, a kernel 'async' that spun for all but one chain, whole-surface cache maintenance twice a frame), the changes, and five runs on a phone (frames on time 29 % -> 67 %; OpenGL ES 69 %). tools/ios27/metal/patches/frontend-flush.diff: SWEET_MTL2_FLUSH (0 as before, 1 clean on a seed change, 2 and no invalidate for GPU-only surfaces). tools/ios27/mesa-ios/starbite-drm: a timeline query waits for the chains queued so far before it says 'reached'. tools/ios27/rootrw: the Mach-O rewriter, checker and installer for the root's own executables (method and tools; not run on a phone yet). The kernel side is the fork's wip/ios27-gpu-queue (Scion-Project/xnu pull request 6); the profile's fork_commit is not moved here. --- docs/ios27/LIQUID-GLASS.md | 100 +++ docs/ios27/METAL.md | 2 + .../mesa-ios/starbite-drm/starbite_drm.c | 11 + tools/ios27/metal/mk-bundle27.sh | 5 +- tools/ios27/metal/patches/frontend-flush.diff | 88 +++ tools/ios27/rootrw/README.md | 64 ++ tools/ios27/rootrw/install-rootrw.sh | 86 +++ tools/ios27/rootrw/macho-check.py | 631 ++++++++++++++++++ tools/ios27/rootrw/macho-rewrite.py | 514 ++++++++++++++ tools/ios27/rootrw/mklists.py | 51 ++ tools/ios27/rootrw/negtest.sh | 83 +++ tools/ios27/rootrw/run-all.py | 85 +++ tools/ios27/rootrw/verify-image.sh | 18 + 13 files changed, 1736 insertions(+), 2 deletions(-) create mode 100644 docs/ios27/LIQUID-GLASS.md create mode 100644 tools/ios27/metal/patches/frontend-flush.diff create mode 100644 tools/ios27/rootrw/README.md create mode 100755 tools/ios27/rootrw/install-rootrw.sh create mode 100644 tools/ios27/rootrw/macho-check.py create mode 100644 tools/ios27/rootrw/macho-rewrite.py create mode 100644 tools/ios27/rootrw/mklists.py create mode 100755 tools/ios27/rootrw/negtest.sh create mode 100644 tools/ios27/rootrw/run-all.py create mode 100755 tools/ios27/rootrw/verify-image.sh diff --git a/docs/ios27/LIQUID-GLASS.md b/docs/ios27/LIQUID-GLASS.md new file mode 100644 index 000000000..0a7aea90e --- /dev/null +++ b/docs/ios27/LIQUID-GLASS.md @@ -0,0 +1,100 @@ +# 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). + +## Not done + +- **Honest sync objects in the shim** (above): today a wait is for the whole queue, and a buffer close waits too. +- **A cap on the GPU's wired memory.** Every PanVK device wires 57 MiB when it is made (a 32 MiB tiler heap, 17 MiB of varyings, 8 MiB of TLS) and every + GPU buffer is wired kernel memory: backboardd alone holds 285 MiB while swiping, and nothing bounds the sum over processes. +- **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). +- `chronod` aborts once at start in every Metal run (it did not in the OpenGL ES run); not looked at. + +## How to turn it on + +Kernel: the fork's `wip/ios27-gpu-queue` (four commits on `bb6a6a4`; draft pull request 6 of Scion-Project/xnu). Command line: `sbgl=0` (with `sbmtl=1`) +and `sweetspeed` with 262144 and 524288 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`. 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..271dbd6e0 --- /dev/null +++ b/tools/ios27/metal/patches/frontend-flush.diff @@ -0,0 +1,88 @@ +--- 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,66 @@ + // 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; ++ 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 +1549,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 +2796,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 100644 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 From 2b49d812ac0c8798eac029c40e5c66c246eb467b Mon Sep 17 00:00:00 2001 From: Claudius Date: Mon, 9 Nov 2026 23:06:37 +0000 Subject: [PATCH 2/8] ios-27: docs/ios27/HANG-WATCHDOG.md (a proposal), tools/ios27/uirun (drive the UI over the USB console), LIQUID-GLASS.md's last runs --- docs/ios27/HANG-WATCHDOG.md | 38 ++++++++ tools/ios27/uirun/uirun.py | 135 ++++++++++++++++++++++++++ tools/ios27/uirun/wallpaper-picker.ui | 27 ++++++ 3 files changed, 200 insertions(+) create mode 100644 docs/ios27/HANG-WATCHDOG.md create mode 100755 tools/ios27/uirun/uirun.py create mode 100644 tools/ios27/uirun/wallpaper-picker.ui diff --git a/docs/ios27/HANG-WATCHDOG.md b/docs/ios27/HANG-WATCHDOG.md new file mode 100644 index 000000000..e33fa6ef9 --- /dev/null +++ b/docs/ios27/HANG-WATCHDOG.md @@ -0,0 +1,38 @@ +# iOS 27 on the Galaxy S9: a watchdog for hard hangs (proposal, 9 November 2026; not built) + +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. Nothing here is built. + +## 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. + +About 60 lines in `herolte_ufs.c` and a counter in `sleh.c`. 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/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 From 306aafb79244a03b3972895d289d7017a1b60a89 Mon Sep 17 00:00:00 2001 From: Claudius Date: Mon, 9 Nov 2026 23:14:19 +0000 Subject: [PATCH 3/8] ios-27 docs: Liquid Glass on the first phone (RC1), the GPU memory cap, the start; the hang watchdog as built --- docs/ios27/HANG-WATCHDOG.md | 17 ++++++++++++++--- docs/ios27/LIQUID-GLASS.md | 37 ++++++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 8 deletions(-) diff --git a/docs/ios27/HANG-WATCHDOG.md b/docs/ios27/HANG-WATCHDOG.md index e33fa6ef9..057703add 100644 --- a/docs/ios27/HANG-WATCHDOG.md +++ b/docs/ios27/HANG-WATCHDOG.md @@ -1,8 +1,8 @@ -# iOS 27 on the Galaxy S9: a watchdog for hard hangs (proposal, 9 November 2026; not built) +# 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. Nothing here is built. +should be. It is built as described under "As built"; no phone has run it yet. ## What `wdt=` is, and is not @@ -34,5 +34,16 @@ written for panics: it takes no lock, allocates nothing, and its two mappings ar - **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. -About 60 lines in `herolte_ufs.c` and a counter in `sleh.c`. The test: `sweetwd=60` idle for ten minutes with no reset; then a deliberate hang (a +## 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 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 index 0a7aea90e..4e53a0b16 100644 --- a/docs/ios27/LIQUID-GLASS.md +++ b/docs/ios27/LIQUID-GLASS.md @@ -80,21 +80,48 @@ the queue to drain, and a close can be deferred instead of waited for) are the n 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). **Not yet +timed on a phone.** + ## Not done - **Honest sync objects in the shim** (above): today a wait is for the whole queue, and a buffer close waits too. -- **A cap on the GPU's wired memory.** Every PanVK device wires 57 MiB when it is made (a 32 MiB tiler heap, 17 MiB of varyings, 8 MiB of TLS) and every - GPU buffer is wired kernel memory: backboardd alone holds 285 MiB while swiping, and nothing bounds the sum over processes. - **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). -- `chronod` aborts once at start in every Metal run (it did not in the OpenGL ES run); not looked at. +- 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` (four commits on `bb6a6a4`; draft pull request 6 of Scion-Project/xnu). Command line: `sbgl=0` (with `sbmtl=1`) -and `sweetspeed` with 262144 and 524288 added. Root: this bundle (`tools/ios27/metal/mk-bundle27.sh` applies `frontend-flush.diff`; PanVK built with the +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`. From e4c2611907415a9e43ec27cf0265b1d78ad56b43 Mon Sep 17 00:00:00 2001 From: Claudius Date: Mon, 9 Nov 2026 23:58:13 +0000 Subject: [PATCH 4/8] ios-27 docs: the deferred buffer close, frames that are on time but late (sgu_depth), the watchdog's second use; uirun's smoke and settings scripts --- docs/ios27/HANG-WATCHDOG.md | 7 ++++++- docs/ios27/LIQUID-GLASS.md | 19 ++++++++++++++++--- tools/ios27/uirun/settings.ui | 16 ++++++++++++++++ tools/ios27/uirun/smoke.ui | 14 ++++++++++++++ 4 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 tools/ios27/uirun/settings.ui create mode 100644 tools/ios27/uirun/smoke.ui diff --git a/docs/ios27/HANG-WATCHDOG.md b/docs/ios27/HANG-WATCHDOG.md index 057703add..6188471e6 100644 --- a/docs/ios27/HANG-WATCHDOG.md +++ b/docs/ios27/HANG-WATCHDOG.md @@ -45,5 +45,10 @@ reset into recovery. `kern.sweetwdog_test` provokes it without hanging anything 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 test: `sweetwd=60` idle for ten minutes with no reset; then a deliberate hang (a +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 index 4e53a0b16..1cb53aabb 100644 --- a/docs/ios27/LIQUID-GLASS.md +++ b/docs/ios27/LIQUID-GLASS.md @@ -106,12 +106,25 @@ unless a process needs one (SpringBoard's renderer service asserts without a dev 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). **Not yet -timed on a phone.** +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. +- 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, and a buffer close waits too. +- **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. 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 From 8d60222bbdb21d8d6a03f8169bb1c042adc1f518 Mon Sep 17 00:00:00 2001 From: Claudius Date: Tue, 10 Nov 2026 00:03:35 +0000 Subject: [PATCH 5/8] ios-27 docs: the three knobs for a chain's cost (flush reduction, spin, depth) --- docs/ios27/LIQUID-GLASS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/ios27/LIQUID-GLASS.md b/docs/ios27/LIQUID-GLASS.md index 1cb53aabb..bde29adb0 100644 --- a/docs/ios27/LIQUID-GLASS.md +++ b/docs/ios27/LIQUID-GLASS.md @@ -119,6 +119,10 @@ since (below): no gain. 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). From c7d0ee5da89ea56bbcd3d371ed0cdfb9169d2eb3 Mon Sep 17 00:00:00 2001 From: Claudius Date: Tue, 10 Nov 2026 00:23:21 +0000 Subject: [PATCH 6/8] ios-27 metal: clean client-memory and small surfaces at every commit; the Settings row-icon fault as seen (cause open) Under SWEET_MTL2_FLUSH=1/2 a surface whose seed has not moved is not cleaned again at commit. A surface over a client's own memory is written without a lock, so its seed says nothing. Such surfaces (pixel format 0, base not 16 KiB aligned) and any under 1 MiB are now cleaned at every commit, as in mode 0. Not run on a phone: built only. The doc records the fault seen on the RC1 full root (stripes and blanks among Settings' row icons), the two suspects and the test order. --- docs/ios27/LIQUID-GLASS.md | 15 +++++++++++++++ tools/ios27/metal/patches/frontend-flush.diff | 12 +++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/docs/ios27/LIQUID-GLASS.md b/docs/ios27/LIQUID-GLASS.md index bde29adb0..d0c5d1b66 100644 --- a/docs/ios27/LIQUID-GLASS.md +++ b/docs/ios27/LIQUID-GLASS.md @@ -142,3 +142,18 @@ and `sweetspeed` with 262144, 524288 and 134217728 added. Root: this bundle (`to 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). + +Test order: reference picture, the kernel bit alone, mode 0 alone, then the rebuilt bundle with mode 2, then one GL picture. diff --git a/tools/ios27/metal/patches/frontend-flush.diff b/tools/ios27/metal/patches/frontend-flush.diff index 271dbd6e0..356a1f0cd 100644 --- a/tools/ios27/metal/patches/frontend-flush.diff +++ b/tools/ios27/metal/patches/frontend-flush.diff @@ -1,6 +1,6 @@ --- 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,66 @@ +@@ -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. @@ -32,6 +32,12 @@ +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); @@ -68,7 +74,7 @@ void *rawBase=IOSurfaceGetBaseAddress(surface); const size_t allocation=IOSurfaceGetAllocSize(surface); if(!rawBase || !allocation)continue; -@@ -1498,7 +1549,7 @@ +@@ -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 { @@ -77,7 +83,7 @@ // 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 +2796,8 @@ +@@ -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()); From a0836fd41d38b511497878f0262891562430a87b Mon Sep 17 00:00:00 2001 From: Claudius Date: Tue, 10 Nov 2026 00:26:36 +0000 Subject: [PATCH 7/8] ios-27 docs: the Settings icon fault, what the boot's console says (wrapped, not copied; one surface empty when wrapped) --- docs/ios27/LIQUID-GLASS.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/ios27/LIQUID-GLASS.md b/docs/ios27/LIQUID-GLASS.md index d0c5d1b66..3a0a35c6f 100644 --- a/docs/ios27/LIQUID-GLASS.md +++ b/docs/ios27/LIQUID-GLASS.md @@ -156,4 +156,10 @@ Two suspects, each with a switch, neither run on a phone yet: 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). -Test order: reference picture, the kernel bit alone, mode 0 alone, then the rebuilt bundle with mode 2, then one GL picture. +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. From 89a08be90edf15c1ebdca0c1db8b3da94328fe99 Mon Sep 17 00:00:00 2001 From: Claudius Date: Thu, 12 Nov 2026 17:41:33 +0000 Subject: [PATCH 8/8] Scripts that start with #! are executable in git (the three of tools/ios27/rootrw) --- tools/ios27/rootrw/macho-check.py | 0 tools/ios27/rootrw/macho-rewrite.py | 0 tools/ios27/rootrw/run-all.py | 0 3 files changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 tools/ios27/rootrw/macho-check.py mode change 100644 => 100755 tools/ios27/rootrw/macho-rewrite.py mode change 100644 => 100755 tools/ios27/rootrw/run-all.py diff --git a/tools/ios27/rootrw/macho-check.py b/tools/ios27/rootrw/macho-check.py old mode 100644 new mode 100755 diff --git a/tools/ios27/rootrw/macho-rewrite.py b/tools/ios27/rootrw/macho-rewrite.py old mode 100644 new mode 100755 diff --git a/tools/ios27/rootrw/run-all.py b/tools/ios27/rootrw/run-all.py old mode 100644 new mode 100755