Capturing the patcher reference snapshot
Historical. Everything below describes a facility that lived in the Python patchers —
--emit-records,VPHONE_PATCH_RECORDS,cfw_records.py— and left with them whenscripts/patchers/was removed. It is kept because the port was built against the records it describes, and because shipping code still cites its findings (CFWJetsam.swiftquotes the non-idempotency note). To run any of it, recoverscripts/patchers/from git history at78cbeeaand stand up a Python environment outside this repository — there is no Python here any more and nothing left to activate, so the.venv/bin/python3invocations below are transcripts, not instructions. The parity evidence itself is no longer re-derived at test time: it is frozen into theFrozenReference/*Goldenenums inTests/FirmwarePatcherTests/.
Migration plan P1.0, the step marked 不可跳过. These patches rewrite pages TXM hashes, so a wrong byte in the Swift port is a boot panic, not a failing test. Capture the reference off a real install first; port against it second.
Capture is a pure observer — a patch run with it on produces a byte-identical output file to one with it off. Turning it on during a real install is safe.
The one thing it will do to a run is stop it. If the input it is asked to
describe cannot yield an honest record, the patcher raises CaptureError before
its write rather than storing a false one. That never fires on the capture this
page describes — one clean pass over pristine inputs — and what it catches is
re-running a capture into a root that already holds one.
The whole capture, in one environment variable
That is it. The variable survives the installer's sudo -E re-exec and its
inner env call, and every patcher — invoked through cfw.py, run as its own
script, or called from Swift — picks it up on its own. No shell edit, no flag
per call site.
The install runs as root, so the capture lands root-owned. Afterwards:
For a single patcher outside an install, the flag does the same thing:
--emit-records is accepted by every cfw.py subcommand and by
cfw_patch_build_version.py, cfw_patch_post_restore_dt.py and
campo_mach_lookup_exceptions.py. The root defaults to
ipsws/patch_refactor_input if you leave it off.
Because the value is optional, a <root> written separately from the flag has
to look like a path — a / in it, a leading ~, or a directory that already
exists — and may not be a subcommand name. So
cfw.py --emit-records patch-seputil <bin> reads as the bare flag plus a
subcommand, not as a request to capture into ./patch-seputil/. For a root that
is a bare new name, use --emit-records=<root>.
Two runs cover the matrix
cfw_install.sh reads ProductVersion off the mounted SystemOS and branches on
it, so no single install touches all 19 patchers. VARIANT=exp on an iOS 26
base and on an iOS 27 base is the whole matrix — exp chains cfw_install.sh,
so it is a superset of regular, and dev adds nothing exp does not already have.
dsc_maxslide self-gates to a no-op on a base whose cache already fits. To
capture it on a non-27 base anyway, run vphone-cli cfw patch-dsc-maxslide <dyld dir> --force by hand; cfw install never forces it.
Capture the same variant on more iOS builds than these two — the reference is
per build, and a patcher that silently finds nothing on a new build is exactly
what plan risk R7 is about. Give each build its own root. A root holds one
copy of each input under raw_payloads/, and PatchComparisonTests.swift
replays the port against that one copy and asserts the record count matches, so
a root can only describe one build.
Re-running a capture
Records from several runs merge into one <group>.json, because one install
legitimately runs a patcher over several files — the SystemOS and AppOS
cryptexes both have a launchd.plist. Records for the same site collapse; the
site is (patchID, component, fileOffset, source path), not the bytes.
The site key used to end in the source basename, with this same paragraph given as the reason — which is self-refuting: if both cryptexes hold a file called
launchd.plist, the basename is exactly what fails to tell them apart. It silently dropped the second cryptex's record, so a correct port would then failcomparePatchRecords()on count (2 vs 1). Use the full path. The keying is deliberately on the site rather than the bytes because two of these patchers are not idempotent and a re-run lands a genuinely new record at a different offset — see the re-capture guards below.
What does not work is running a capture twice over the same tree, and the capture now refuses it instead of quietly producing a wrong reference:
The reason it cannot be allowed to proceed is that the capture reads
originalBytes off the file on disk. On a second pass the patcher reads its own
output back and calls it the original, and two patchers are not idempotent —
patch_launchd_cache_loader and patch_launchd_jetsam each find a second,
different site on an already-patched binary. Those land as brand-new records at
offsets the pristine image never had patched, and comparePatchRecords()
asserts on the record count, so a correct Swift port then fails against the
reference. A record where originalBytes == patchedBytes is refused outright
for the same reason: it asserts something false about the pristine bytes, and a
port that writes nothing satisfies it.
To redo a capture, delete the root (or capture into a new one) and re-extract the inputs.
The non-idempotency itself is a separate, pre-existing bug in
cfw_patch_cache_loader.py and cfw_patch_jetsam.py — it reproduces with
capture off. Neither patcher checks whether the site it found is already
patched, so on a second pass the first candidate no longer matches the shape
they search for and the scan walks on to the next one:
patch_launchd_cache_loaderNOPs0xC7C, then on a re-run_find_nearby_branchskips thenopit wrote and returns the next conditional branch after the samebl(0xC84).patch_launchd_jetsamrewrites the conditional at0xFA98tob, which drops out ofcond_mnemonics.cfw_patch_jetsam.py:117-136scans backward keeping the farthest-back match, so losing0xFA98makes it fall back to a later candidate, nearer the xref (0xFAB0>0xFA98) — not an earlier one.
The fix in both is the same shape and does not need new anchors, but it has to
live inside the scan, not after it. Checking the picked site is useless here:
on a re-run neither patcher picks the previously patched site. Decoded from the
real twice-patched binaries, run 2 picks 0xC84 holding a live cbnz x0, #0xca4
(the nop is back at 0xC7C), and 0xFAB0 holding a live b.ne #0xfaec (the
b is back at 0xFA98). So: _find_nearby_branch must treat a nop at a
candidate position as already-patched, and cfw_patch_jetsam.py's backward loop
must treat an unconditional b into a return block as already-patched — both
0xFA98 and 0xFAB0 branch to the same 0xfaec, so the target test still
applies. Both would then report no-op on a re-run, the way patch_watchdogd and
the DSC patchers already do.
Where it lands
_capture_log.jsonl carries capture_root and env_var_set because argv
alone does not answer how capture was turned on: cfw.py strips
--emit-records out of sys.argv[:] before dispatch, so its subcommands log an
argv with no flag in it, while cfw_patch_build_version.py,
cfw_patch_post_restore_dt.py and campo_mach_lookup_exceptions.py strip a
local argv and do log the flag. VPHONE_PATCH_RECORDS shows in neither.
The directory names are the convention
Tests/FirmwarePatcherTests/PatchComparisonTests.swift already reads from.
ipsws/ is gitignored, so none of this is committed.
The DSC is ~7 GiB and is not copied. What raw_payloads/dsc_pages/ holds is
the 16 KiB code-signature page around each patch, before and after — enough for
P1.1's slot-hash cross-check, not enough to re-run a DSC patcher's finder.
That one needs the real cache on the machine.
Is the capture complete?
It lists all 19 required patchers, prints the record count for each, names the
ones still missing, and exits non-zero until every one is captured — or until
every captured record can actually fail a wrong port. Pass a root as an argument
if it is not ipsws/patch_refactor_input.
A patcher showing MISSING after an install means one of three things: that
iOS base gated it off (the table above), its input was absent, or it found
nothing — and the third is the one to chase, in _capture_log.jsonl and the
install log.
The record format
Each object in <patcher>.json decodes, with no translation layer, as both:
FirmwarePatcher.PatchRecord—patchID,component,fileOffset,virtualAddress,originalBytes,patchedBytes,beforeDisasm,afterDisasm,patchDescription;Dataas base64, exactly asCodableencodes it.JSONDecoder().decode([PatchRecord].self, from:)works as-is.- the
ReferencePatchstruct inPatchComparisonTests.swift—file_offset,patch_bytes(hex),patch_size,description,component. Same values, its spelling.
Plus provenance both decoders ignore: orig_bytes, orig_size, patched_size,
va, file, orig_sha256, patch_sha256, inline_bytes, comparable,
file_sha256_before, file_sha256_after, payload_before, payload_after.
The two file_sha256_* are whole-file hashes: they say which raw_payloads/
entry a record belongs to, and they are what the re-capture guard matches a
later run's input against.
One record per sub-patch: each of the camera patcher's short-circuited symbols,
each hv_vmm_dsc cstring site, each iomfb_force_kern trampoline, and each
code-signature slot the re-attestation rewrites, all named individually.
Records that change a file's length
A patch that resizes the file has no common offset space to diff, so it needs a
recorder that knows the format. inject-dylib has one. insert_dylib strips
launchd's code signature and reflows __LINKEDIT — the file comes out 27 KiB
shorter — but the edit a Swift port has to reproduce is small, bounded, and
sits at offsets that do not move, so that is what gets recorded:
Five records with real bytes at real offsets, where there used to be one with
empty bytes at offset 0 that a port writing nothing at all still matched. What
is deliberately not graded is the __LINKEDIT reflow past the command region:
that is the signature strip, not the load-command write, and the payload pair is
there to replay it.
Records that cannot fail
"comparable": false (and a patchID ending .opaque) means the change was
too big to inline, over 256 KiB — patch_bytes is empty and patch_size is 0,
so the Swift comparison cannot fail against it. It is a pointer at the
payload_before / payload_after pair, not a check. records-status marks the
group [!] and exits non-zero, because a group like that looks captured while
grading nothing; the fix is a structural recorder for that format, the way
record_macho_lc_edit is the one for Mach-O.
Records the patcher did not declare
A record whose patchID ends in .undeclared.0x<offset> is a byte the patcher
changed without declaring it. That is not an error in the capture; it is the
capture telling you the patcher's own account of itself has drifted from what it
writes. Read it before porting that patcher.
Known defects in this capture, as of 2026-09-23
An independent verifier reproduced each of these with a harness. The doc errors above are already corrected; these are the ones still in the code. Read them before you trust a reference this produces.
-
_dedupe_key()drops a legitimate record (cfw_records.py:1146). It keys on the source basename, so one install patching both the SystemOS and AppOSlaunchd.plistproduces two records with the identical site key('inject_daemons.launch_daemons', 'launchd.plist', 0, 'launchd.plist');flush()keeps the first and warns away the second. Expected 2, got 1. A correct Swift port then failscomparePatchRecords()on count. Use the fullfilepath. This was introduced by the fix for the bytes-keyed version of the same bug, from the other side. -
_assert_pristine_inputis caller-dependent, not structural (cfw_records.py). Net (i) keys on the source path, and holds today only becauseTEMP_DIRis the fixed$VM_DIR/.cfw_temp(cfw_install.sh:55,cfw_install_exp.sh:171). Randomise that temp dir and every legitimate re-install becomes an "unfamiliar input" warning, while a chained already-patched input becomes an accepted one. It is also not on the DSC path at all:cfw_dsc_chunks.py:258,cfw_dsc_codesign.py:304andcfw_patch_dsc_maxslide.py:87reachrecord/record_span, which never call it. -
The degenerate-record guard has a hole (
cfw_records.py:490)._assert_not_degenerateruns onlyif inline and original == patched, so a256 KiB whole-file rewrite to identical content still stores a record whose
originalBytesequal itspatchedBytes, as a.opaquerecord. Contained (comparable: false, warning,records-statusexits 1), but the rule stated above — a degenerate record is an error, not a stored fact — is not what the code enforces. -
take_cli_flag'sreservedfix coverscfw.pyonly.cfw_patch_build_version.py:95,cfw_patch_post_restore_dt.py:328andcampo_mach_lookup_exceptions.py:64call it withoutreserved, and their positional is a path, so_looks_like_rootalways swallows it:cfw_patch_build_version.py --emit-records <plist> 23B85setsroot=<plist>, prints usage and exits 2.reservedcannot fix that — a path cannot be excluded by name — so the containment is to make the separated value illegal, i.e. accept--emit-records=<root>only. -
Nothing in the repo exercises
cfw_records.py.find tests -name '*.py'is empty, the Makefile has no pytest target, andtests/test_dropbear_plist.pywas deleted on this branch. Every guard described in this document — the dedupe key, both_assert_pristine_inputnets,_assert_not_degenerate,record_macho_lc_edit— can be deleted and nothing goes red. Whatever replaces this file in Swift must land with tests that do go red.