This README has not been read or edited by a human yet. Until it has, this project cannot cut its first release.
Mach-O surgery for hosts too old to have any. Builds with the stock 10.9 clang, no dependencies, and edits binaries produced by toolchains fifteen years newer.
| tool | job |
|---|---|
patch_macho |
rewrite chained fixups as LC_DYLD_INFO_ONLY, so 10.9's dyld can load the image |
change_dylib |
edit LC_LOAD_DYLIB / LC_RPATH — change, delete, add, insert, re-export — with library-ordinal renumbering; -strip-lc; -grow |
add_version_min |
append LC_VERSION_MIN_MACOSX |
fix_macho |
change install names and strip build version, including in fat binaries |
rename_segment |
__DATA_CONST → __DATA, so 10.9's libobjc finds the metadata |
retag_swift_classes |
move the is-Swift tag from the stable-ABI bit to the legacy one |
-
src/— the shared toolkit library (machorewritecore): image parsing, ULEB, ordinals, fat-arch validation, export-trie rebuild,__LINKEDITbumping, header growth, LC-kind tables, the atomic-write helper, the dylib/rpath load-command rewriter, theLC_VERSION_MIN_MACOSXappender, the segment rename, and the Swift class-record retag. -
compat/— these six tools' entry points. They predatemachorewriteand keep their original names becauseinstall.shfetches some of them by name. All six are now/bin/shwrappers that print themachorewriteequivalent of what they were asked to do and then do it throughmachorewrite, somachorewriteis the only Mach-O rewriting binary this repo ships andcompat/contains no C at all.fix_machowas the last holdout: wrapping it changes what it does in five ways, and those changes were adopted deliberately rather than papered over —compat/README.mdstates each with its reason. Also here:translate.sh, the old-grammar-to-machorewritetranslator the wrappers source, andmachorewrite-compat.sh, the machinery they share. Seecompat/README.md.Packaging note: the six wrappers need
machorewrite,machorewrite-compat.shandmachorewrite-translate.shinstalled beside them. Anything that fetchespatch_macho,change_dyliboradd_version_minby name now has three more files to fetch.compat/README.mdsays what that means formavericksforever.com/claude/install.sh, which has not been told. -
cli/—machorewrite, the multi-verb CLI built onsrc/. -
tests/— everythingctestruns, plus the fixtures it reads.
cmake -S . -B build && cmake --build build && ctest --test-dir buildNeeds shipyard, the family's
shared CMake helpers — install it once and it self-registers, so find_package
finds it with no CMAKE_PREFIX_PATH:
cmake -S ../mavericks-shipyard -B /tmp/sy -DCMAKE_INSTALL_PREFIX="$HOME/.local"
cmake --install /tmp/syPresets pick the build mode:
cmake --preset native # on 10.9, with its own clang
cmake --preset cross # on a modern host, against the pinned 10.9 SDKEvery tool is gated by shipyard's compat guard, which fails the build if a binary declares a floor above 10.9 or links a symbol 10.9 lacks. That matters more here than elsewhere in the family: these are the tools that make other binaries loadable on 10.9, so they had better load there themselves.
There is no 10.9 runner in CI, so a cross-build has to be shown equivalent to a native one rather than assumed to be. Comparing the tool binaries is the wrong test — a 2014 clang and a 2026 one will never emit the same bytes.
These tools are deterministic file transformers, so what they produce is
the invariant worth pinning. tests/characterize.sh runs the whole pipeline
over a committed fixture and compares the output digest against
tests/EXPECTED; ctest runs it. Build natively and cross, and the digests
must match.
Two properties make that runnable in CI: the pipeline is deterministic (verified — same input, same flags, identical output), and a cross-built tool is x86_64 with a 10.9 floor, which still runs on a modern host, so the runner can execute what it just built.
Known gap: the committed fixture is a 10.9-built binary, so it has no chained
fixups and does not exercise patch_macho's conversion — the heaviest transform
in the pipeline. A fixture from a modern toolchain should be added; 10.9's clang
cannot emit one.
On these binaries, 10.9's install_name_tool refuses to open the file:
install_name_tool: file not in an order that can be processed (dyld_info out of place)
It rebuilds __LINKEDIT and expects the 2013-era ordering of its pieces; modern
linkers emit a different order, so it bails before touching anything.
The distinction runs deeper than convenience. install_name_tool rewrites the
file; these tools never move a byte of data, editing only within existing
header padding. That is why -strip-lc and -grow exist, and why a replacement
path that is too long is an error here and a non-event with Apple's tool.
machorewrite FILE OUT takes its statements on stdin, and it is the only way to
modify a binary: FILE is opened read-only and never touched, and the result
goes to OUT. (verify FILE and info FILE are read-only and take no OUT.) An OUT that names FILE — the same path, a
symlink to it, or a hard link to it — is refused before any work is done.
machorewrite --capabilities' output positional=2 never-writes-input line tells
a caller to expect this shape rather than assume it.
A successful write gives OUT FILE's permission bits, FILE's owner
(best-effort — changing owner needs privilege), and every extended attribute
FILE carries (quarantine and the like), then renames a temp file onto
OUT: OUT ends up either its previous content or the whole new file, never
a partial one, and a symlink at OUT is followed to its target rather than
replaced.
The six compat/ wrappers still look like they edit in place, the way the
retired C tools did: each writes to a temp file beside FILE and moves it
over FILE once the run succeeds. A FILE with more than one hard link is
refused up front instead — moving the temp over one name would leave every
other name for that inode on the old content, and there is no atomic way to
update every name for an inode at once.
-grow makes header room by lowering the image base, which invalidates every
structure storing an offset from that base: the LC_FUNCTION_STARTS leading
delta, __TEXT,__init_offsets, the export trie, LC_DATA_IN_CODE, and compact
unwind. All five are re-based. Anything unrecognised — an unclassified load
command, an unknown section type — is refused rather than grown past.
Two independent checks back that up:
-
mg_verifyproves every base-relative structure resolves to the same address after the grow as before. A handler that never ran, ran twice, or ran with the wrong delta all look the same to it: a moved address. -
mg_plausibleasks a different question of the finished file — do initializers and unwind entries still land on an addressLC_FUNCTION_STARTSlists? It needs no "before" image, so it can be run on a file nothing kept a snapshot of. It runs when there is something for it to re-check: the image carries anLC_FUNCTION_STARTSand the run disturbed the base-relative values those offsets are (src/relations.h'smrel_verify_applies— one expression, shared by every gate site). A grow is such a run, andmg_grow_headerruns this check on every image it grows before handing it back.What that leaves out is worth stating plainly, because it is easy to read a guarantee into the paragraph above. An edit that fits the existing header pad — a
dylib,rpath,load-commandorsegmentstatement — disturbs nothing this gate examines, so it is not refused for a property its input already had. Inside one run,fixups set classic(and atarget 10.9that expands to it) does disturb the image base, and such a run is refused rather than written. Across thepatch_macho→add_version_min→change_dylibchain it is not: each tool is its own process and carries no record of what the previous one disturbed, so thechange_dylibstep no longer re-checks the conversionpatch_machoperformed.src/rewrite.c's comment at the gate has the reasoning and the measurement.What no caller can do is switch off a gate that applies: what decides is the image and the operations, never an environment variable.
MACHO_NO_VERIFY=1is what remains of an older, caller-controlled opt-out, and on amachorewriteverb run it no longer changes anything — the one gate that consults it is reached only by a rewrite that grew the header, andmg_grow_headerhas already run the same check on that image unconditionally.
That gate exists because every defect ever found in this code has been a silent success: the tool reported OK and the binary died in the loader — or worse, didn't.
install.sh-style porting runs several rewrites in sequence — strip a load
command, then repoint a handful of dylibs — each of which used to be its own
invocation and its own full write of the file. machorewrite takes a script
naming every statement instead, applies them all to one in-memory copy, and
writes once:
printf 'load-command delete uuid\n' | machorewrite FILE OUT # statements on stdin
machorewrite FILE OUT < script # a script that lives in a fileThere is no second spelling. machorewrite edit FILE OUT SCRIPT was one until
the edit verb was deleted; the shell's < does what its SCRIPT argument
did, and the design's claim that one form is the only way to change a binary is
worth more than saving a caller four characters.
FILE is only read, and OUT must not be it — the same file twice, or a
symlink or hard link to it, is refused before the script is even read. A
successful run always leaves OUT there, even when no statement changed
anything: OUT is the answer. To see what a script would do without disturbing
anything, give it a scratch OUT — that is the same run, and the result is a
file you can inspect rather than a prediction.
It takes no flags at all — its two arguments are FILE and OUT, in that
order. There is no -- to end flag parsing, so a FILE whose real name starts
with -- is refused as an unknown flag; reference it through a path that
doesn't, e.g. ./--name. One leading dash is a file name there, as it was for
every historical tool. Not for OUT, though: an OUT beginning with - is
refused and says so, because OUT is a file this command creates, so a
flag-looking one is a mistake rather than a name. A FILE named like one of the
two surviving verbs (verify, info) is spelled ./verify, for the same
reason.
Nothing is written unless every statement succeeded. The whole script is
parsed before FILE is opened at all, so a typo in the last line of a long
script costs nothing. Each statement then runs against the image in memory, in
the order written; if any statement is refused, OUT is not written and FILE
is exactly as it was found. The finished image is verified after the last
statement and before the write — whenever the run disturbed anything that check
covers, and with no opt-out when it applies — and only then written, once. A run
that disturbed nothing it checks says so instead: nothing this run disturbed is re-checked.
Every run logs, on stderr, what it did — there is no quiet mode, so there
is no flag. A tool whose job is to make edits nobody can see afterwards
should not have an option to say nothing about them, and anyone who wants
silence has 2>/dev/null, which needs no cooperation from machorewrite. The
report is each statement as it starts; beneath it, indented, the follow-up
work it did that its line does not name — for dylib insert and dylib delete the ordinal renumbering (the command inserted or removed and its
ordinal, the old-to-new map, and how many nlist entries and
SET_DYLIB_ORDINAL opcodes changed), for fixups set classic the
conversion's figures (rebases and binds emitted, the bytes of opcodes and the
bytes appended, the commands stripped, how far __LINKEDIT grew) or that an
already-classic image passed through, for swift-abi set legacy how many
class records it retagged or that there was nothing to retag, for
version-min set the LC_VERSION_MIN_MACOSX it appended, and for target 10.9 its whole expansion, line by line, each with the finding that produced
it — or that this binary already targets 10.9; then FILE: verified (or
FILE: nothing this run disturbed is re-checked, when the run moved nothing the
check looks at) and OUT: written (N bytes).
Stderr, not stdout, and that division is load-bearing: the operations' own progress lines go to stdout, where the compat wrappers' callers have always read them, so the report can be unconditional without changing a byte of what any wrapper prints.
On a fat file, each slice is accounted for too. slice NAME: before an
edited slice's statements and slice NAME: verified after; slice NAME: not selected by arch; passed through unchanged, slice NAME: 32-bit; passed through unchanged or slice NAME: not a 64-bit Mach-O; passed through unchanged for the rest — whether a slice is a 64-bit Mach-O is decided by its
own bytes, never by the cputype its fat_arch entry declares; and, once the slices are laid out again,
slice NAME: moved from offset 0x… to 0x… for any slice an earlier slice's
growth moved.
The refusal line on stderr and the exit code are what tell you whether the
file was written. The operations still print their own progress to stdout
as each statement runs — FILE: updated (sizeofcmds=...) and the like — but
during an edit run such a line describes the image in memory, not the file. A
run refused at a later statement, or at verification, writes nothing, even
after printing it. On a fat file the refusal line names the slice too — or,
for a statement's own miss (see fatal-warnings, below), says it matched
nothing in any selected slice.
The write never touches FILE. machorewrite takes FILE OUT and writes
only OUT, by way of a temp file and a rename —
see "machorewrite never writes its input", above, for what that guarantees. So
whether FILE is writable is not a question it asks either; a read-only
(0444) FILE in a writable directory is read just fine, and the run exits
0.
One statement per line. Fields are whitespace-separated, with single- and
double-quote grouping and backslash escapes — shell word rules. # begins a
comment only at the start of an unquoted field, matching shell: a#b is the
literal field a#b, not a followed by a comment. Blank lines are ignored.
A CR (or any other control byte except tab) anywhere in a line is a parse
error, not a silently-accepted character — so a script saved with CRLF line
endings will not parse; use LF.
load-command delete KIND uuid | codesig | source-version
| build-version | code-sign-drs
segment rename OLD NEW
version-min set 10.9
swift-abi set legacy
fixups set classic
dylib replace OLD NEW
dylib append PATH
dylib insert PATH
dylib delete PATH
dylib reexport PATH
rpath replace OLD NEW
rpath delete PATH
rpath append PATH
rpath insert PATH
These statements are the whole mutating surface. machorewrite used to carry a
second spelling of them — a CLI verb per operation, machorewrite dylib FILE OUT -replace A B beside the line dylib replace A B — and the two had different
semantics: a verb applied all its operations in one pass against the original
image, while statements apply in sequence, each seeing what the one before
left. Maintaining both is what the verbs cost, so they are gone.
grow went with them. It enlarged the header pad by an exact byte count, which
no statement expresses; allow-grow, below, is the directive that lets a
dylib, rpath or version-min set statement grow the pad as a side effect,
which is a different thing from naming a count.
There is one more line, target 10.9, which is neither of those: see "The
target statement", below.
Statements run one at a time, in the order written, so each insert goes to
the front of the image as the statement before it left it: the lines
dylib insert A then dylib insert B leave B at ordinal 1 and A at ordinal 2.
rpath insert works the same way, so dyld searches B before A.
Three, and each must precede every operation in the script — a directive after any operation, not only the one it would have governed, is a parse error:
arch NAME apply the script only to the slice named NAME (lipo's
names: x86_64, x86_64h, arm64, arm64e, i386); repeatable.
Without it, every 64-bit slice of a fat file is edited
allow-grow permission to enlarge the header pad by lowering the image
base if new load commands do not fit; opt-in, and refused
by default. MH_EXECUTE + MH_PIE only -- the image-base
trick needs a __PAGEZERO and no absolute relocations to fix.
Covers dylib, rpath and version-min set; not fixups set
classic -- nothing grows while the image still has chained
fixups, so put fixups set classic first
fatal-warnings an operation that matched nothing refuses the whole run
(exit 1, nothing written) instead of only being reported
target 10.9
Every other statement means the same thing for every input — dylib replace A B may match nothing, but what it asks for is fixed. target 10.9 asks a
different question of every binary and answers it differently: it is the
intent level, arriving as a named line rather than as hidden behaviour.
It expands, where it is written, into statements the language already has — the ones this binary actually needs — and those run in its place:
| detected | expands to |
|---|---|
LC_DYLD_CHAINED_FIXUPS present |
fixups set classic |
LC_BUILD_VERSION present |
load-command delete build-version |
no LC_VERSION_MIN_MACOSX |
version-min set 10.9 |
__DATA_CONST carrying __objc_* sections |
segment rename __DATA_CONST __DATA |
| class records carrying the stable-ABI Swift tag | swift-abi set legacy |
Each detection is exact rather than a guess: a load command is present or it
is not, a section name begins with __objc_ or it does not, a tag bit is set
or it is not. Never dylib or rpath work — no tool can guess which stub
dylib you meant, and that is the dominant real workload, so a profile stops
where the guessing would start.
Position is not cosmetic, which is why this is a statement and not a
flag. fixups set classic rewrites __LINKEDIT and strips load commands,
which changes the header pad available to every dylib replace after it, and
nothing reorders your statements — the script is the plan. Where you write
the line is where the expansion lands:
allow-grow
target 10.9
dylib replace /System/Library/Frameworks/Metal.framework/Versions/A/Metal @loader_path/libMetalStub.dylib
rpath insert @loader_path/../Frameworks
The report lists the expansion line by line, with the finding that produced each, since the same line does different things to different binaries:
target 10.9
fixups set classic (LC_DYLD_CHAINED_FIXUPS present)
version-min set 10.9 (no LC_VERSION_MIN_MACOSX)
and says nothing to do: this binary already targets 10.9 when the
expansion is empty.
The rest of the rules:
- One
targetper script. A second is a parse error. - An unknown target is a refusal.
target 10.10errors, naming what this build does know, rather than silently doing 10.9's work. - The directives still govern the expansion —
allow-growreaches a derivedversion-min set 10.9exactly as it reaches one you wrote — and if a derived statement is refused, the refusal names thetargetline, which is the line you wrote. targetnever counts as unmatched underfatal-warnings, and neither does anything it derived: "this binary already targets 10.9 correctly" is a correct answer for a profile, unlike for an explicit operation. (It happens for real:fixups set classicstripsLC_BUILD_VERSIONitself, so theload-command delete build-versionthe same expansion derived finds nothing left to do.)- Writing
target 10.9and an explicit statement it would have derived makes the explicit one redundant, andfatal-warningswill flag it. That is right, and is documented rather than special-cased.
What install.sh does today as three tools and three full writes of a ~200MB
binary:
patch_macho "$REAL" "$T"
add_version_min "$T"
change_dylib "$T" -strip-lc uuid -strip-lc codesig \
-change "/usr/lib/libSystem.B.dylib" "@loader_path/../S.dylib" \
-change "/usr/lib/libicucore.A.dylib" "@loader_path/../I.dylib" \
-change "/usr/lib/libc++.1.dylib" "@loader_path/../c++.1.dylib"becomes one script, claude.edits:
# Claude Code -> 10.9
fixups set classic
version-min set 10.9
load-command delete uuid
load-command delete codesig
dylib replace /usr/lib/libSystem.B.dylib @loader_path/../S.dylib
dylib replace /usr/lib/libicucore.A.dylib @loader_path/../I.dylib
dylib replace /usr/lib/libc++.1.dylib @loader_path/../c++.1.dylib
and one invocation:
machorewrite "$REAL" "$T" < claude.edits- A fat (universal) file is edited slice by slice, and kept whole. With
no
archdirective every 64-bit slice is edited and everything else passes through; witharchdirectives, exactly the named slices. Whether a slice is a 64-bit Mach-O is read from the slice's own bytes, not from thecputypeitsfat_archentry declares — a container where the two disagree is malformed, and the bytes are the thing being edited. Naming a slice the file lacks, or one that is not a 64-bit Mach-O, is refused. Nothing ever drops a slice — thin a file withlipoif you want one. A 64-bit fat container (fat_arch_64) is refused. allow-growreachesdylib,rpathandversion-min set— the statements whose load commands can outgrow the header pad — and only on a 64-bit PIE executable. It does not reachfixups set classic: growth refuses an image that still has chained fixups. Nor does the conversion need it: it removes whichever ofLC_DYLD_EXPORTS_TRIE,LC_DYLD_CHAINED_FIXUPSandLC_BUILD_VERSIONare present before adding its 48-byteLC_DYLD_INFO_ONLY, so on a modern chained binary, which carries all three, it frees at least 56 bytes before using 48. On a chained image nothing can grow untilfixups set classichas run; put it first.segment renameandload-command deletenever need it, since neither adds bytes to the load commands.fatal-warningscovers the statements that can match nothing:load-command delete(no command of that kind),dylib replace/delete/ reexportandrpath replace/delete(no command naming that path), andsegment rename(no segment of that name).appendandinsertalways act, and the threesetstatements treat "already so" as success, so none of those can miss. Neither cantarget, nor anything its expansion derived (see "Thetargetstatement", above). On a fat file, a statement has matched if it matched in any selected slice.MACHO_NO_VERIFYchanges nothing about a script run. The check that runs after the last statement, before the single write, has never consulted it and still does not. Adylib/rpath/load-commandstatement also runs the same per-step plausibility check the shared rewriter runs (see "Prove it or refuse" above); that per-step check is the one place in the tools that reads the variable, and it now applies only to a step that grew the header — whichmg_grow_headerhas already checked, unconditionally, on the grown image. What decides whether a check runs is the image and the operations, never the caller: a run that disturbed nothing it examines skips it, and nothing a caller can set will suppress one that applies.
- Not yet a drop-in replacement for
insert_dylibon 32-bit input, refused deliberately. Seedocs/prior-art.md. - This repo is its own upstream: the tools are not a port of somebody else's
project.
UPSTREAM_VERSIONis still the family's file, but the version isX.Y.Zwith no-mavericks.Nsuffix — there is no external thing to repackage, so there is no repackaging axis to carry. No Renovate customManager watches it either, because nothing external releases it. SeeINGREDIENTS.md.
Extracted with full history from
Wowfunhappy/Mavericks-Porting-Resources.
patch_macho and fix_macho are substantially Wowfunhappy's; header growth, the
re-basers, ordinal renumbering and verification are Amitai Schleier's. The commit
log is the accurate record. Details, including what was extracted and from where:
PROVENANCE.md.
Four commits in that history also touched files that stayed behind, so their messages mention unrelated work. The changes are correct; only the messages are wider than their diffs.