AArchX

32-bit Mac programs (m32)

macOS stopped running i386 Mach-O programs in Catalina, and Rosetta never ran them. m32 runs them under AArchX: an executable with an i386 slice and no x86_64 one is handed to m32_run instead of being refused, and runs in native mode against the Mac’s own arm64 frameworks.

make apis32                                   # i386 API databases from your SDK (and make apis, which m32 needs too)
make steam32                                  # the Steam bridge's interface database, for Steam games
tools/fetch_guest32_cxx.sh                    # only for programs built with GCC's libstdc++
tools/fetch_guest32_x11.sh                    # only for programs that link X11's fontconfig or freetype
./ocerz -native "Some Game.app/Contents/MacOS/Some Game"

The two fetch scripts need no Xcode tools: they unpack MacPorts’ universal packages as MacPorts lays them out, under runtime/guest32/opt/local, where m32 looks first for the /opt/local names the libraries give one another, and link the /usr/lib and /usr/X11R6/lib names games import to them; m32 reads the i386 slice of a universal binary itself. A release carries both scripts in tools/, and the installer and the Homebrew cask run them.

Playing a 32-bit game from Steam, step by step, is in 32-bit Mac games from Steam.

What runs

Batman: Arkham Asylum Game of the Year Edition (Feral, 2014; Unreal Engine 3, Objective-C 1, tcmalloc, Bink, FMOD Ex, OpenGL through Feral’s IndirectX) plays its title screen, its menus with the mouse, and gameplay with keyboard, mouse and sound in MacShack, where m32 comes from. On this tree its New Game opening runs at about 61 frames a second on an M4 Max. What got it there from 31: shifts by cl, MMX, shld and shrd, and the high-byte registers are translated in 32-bit code, an import stub calls its export from inside the block (emit_bridge_fastcall32), and small libc, CGL and OpenGL exports are called as leaves with no crossing at all (src/m32_leaf.c).

Started from Steam with ocerz -native %command% as its launch options, it reaches the user’s own Steam through the Steam bridge below. Feral’s options dialog, a legacy WebView, draws nothing under m32; the game starts after it, and its first run writes default settings and key bindings to Feral’s registry (~/Library/Application Support/Feral Interactive/<game>/Preferences Data) by itself.

Kega Fusion 3.63 (a UPX-packed Carbon program drawing through AGL) opens its window, and so does VLC 1.1.12’s 32-bit Intel build (Cocoa, with Sparkle and Growl).

AssaultCube 1.1.0.4 (2010; SDL 1.2, OpenGL, OpenAL, Ogg Vorbis) plays: its window, menus, maps and sound, and a bot match started from its console. SuperTuxKart 0.6.2a (2009; SDL 1.2, GLUT, its own OpenAL, Ogg music) runs, races started from its command line included, with its music and sound. Neverball and Neverputt 1.5.3 (SDL 1.2, SDL_ttf, OpenGL) run with their sound. Battle for Wesnoth 1.8.6 (2011; SDL 1.2 and its mixer, image, net and ttf libraries, Pango, cairo, GLib, Lua, Boost) opens its title screen and runs its test scenario. It finds none of the fonts it bundles, as its Mac build registers none, and falls back to the system’s. Teeworlds 0.5.2 (SDL 1.2) plays, a local server and client included.

Nexuiz 2.5.2 (2009; DarkPlaces on AGL, with GLSL) starts from its .app, whose executable is a shell script, loads a map and its bots play.

ScummVM 1.4.1 (2012; SDL 1.2 and Cocoa) runs Beneath a Steel Sky, which scummvm.org gives away: scummvm --path=<game> --no-fullscreen sky. Its Cocoa code was built by GCC without constant CFStrings, so its @"..." literals are Objective-C 1’s own 12-byte NSConstantStrings in __OBJC,__cstring_object, which m32 makes host strings as it does __cfstring ones. DOSBox 0.74 (2010; SDL 1.2) runs DOS programs: a batch file run with -c writes its files to a mounted folder and DOSBox exits when it does. Warzone 2100 2.3.9 (2011; SDL 1.2, OpenAL, PhysFS, QuesoGLC) opens its window and menus once tools/fetch_guest32_x11.sh has put the X11 libraries Mac OS X 10.5 and 10.6 shipped in /usr/X11R6/lib, fontconfig and freetype among them, as i386 slices of MacPorts’ universal builds; m32 points FONTCONFIG_FILE at the fonts.conf installed beside them, which names the Mac’s font folders. PhysFS allocates through CFAllocatorAllocate, which with a system allocator now hands out guest heap, as NSAllocateMemoryPages hands out window pages.

Armagetron Advanced 0.2.8.3 (SDL 1.2) runs (-w for a window) and saves its settings. Apple’s GCC 4 put an ofstream’s basic_filebuf on an 8-byte boundary, as Apple’s libstdc++.6.dylib did, which puts the stream’s basic_ios 4 bytes later than in GCC 15’s libstdc++. The game builds its streams inline at Apple’s offsets, so m32 gives the runtime’s libstdc++ Apple’s layout for ofstream and fstream: the offsets in their vtables, and the members that use the offsets directly (src/m32_fstream.c).

Chocolate Doom 1.7.0 (2012; SDL 1.2) plays Freedoom: -iwad freedoom1.wad -window -width 640 -height 480 -timedemo demo1 runs its demos at about 350 frames a second. Enigma 1.01 (2011; SDL 1.2, C++) opens its menus and its 1,060 levels. Pingus 0.7.6 (2011; SDL 1.2, Boost) plays its levels with the libstdc++ and libgcc_s it carries, built by GCC rather than Apple: their exceptions unwind through m32’s unwinder too. LinCity-NG 2.0 (2009; SDL 1.2, PhysFS, libxml2) runs in a window (-w); its Mac build links libiconv, libz, libstdc++, libgcc_s and libSystem from its own Frameworks folder, which does not hold them, and m32 falls back to the leaf names in /usr/lib as dyld does. Asked for full screen, SDL 1.2 needs CGDisplayBestModeForParameters to find the mode exactly; today’s CoreGraphics never reports an exact match, so m32 chooses among the display’s modes itself (src/m32_cg.c).

Cube 2: Sauerbraten (Justice Edition, 2011; SDL 1.2, OpenGL with GLSL shaders) runs its i386 slice: sauerbraten -nolauncher -t -w800 -h600 -q<home> with a script such as -x"map complex; addbot 50" loads a map and its bots play, at about 65 frames a second in a window. SDL_image reads the images that come from an SDL_RWops through CGDataProviderCreate, Mac OS X 10.4’s callback provider, which m32 builds from today’s sequential one.

Adium 1.4.5 (2011; Cocoa and Objective-C 1 across twenty bundled frameworks: AIUtilities, AutoHyperlinks, Sparkle, Growl, libpurple and GLib) opens its setup assistant, its contact list and Sparkle’s update prompt.

How it works

Tests

make check-m32 runs the programs in tests/m32 against their .expected output; add OCERZ_NOJIT=1 for the interpreter alone. The programs are committed in tests/m32/bin, because linking i386 needs Xcode 26’s ld-classic (CLT 27’s linker dropped i386): M32_REBUILD=1 rebuilds them with tests/m32/build.sh, using M32_DEVELOPER_DIR (default /Applications/Xcode.app). The SDK has no i386 libraries any more, so build.sh links against stub .tbd files made from the SDK’s own lists; they stay in the untracked tests/m32/sdk. cxx and cxxexc need runtime/guest32 and are skipped without it. tests/m32/dbcheck.sh spot checks a generated database. steambridge runs the Steam bridge against tests/m32/steamfake, an arm64 stand-in for Steam’s client, and is skipped without make steam32.

Switches

Variable Effect
OCERZ_M32LOG=imports,calls,images,objc,throws,glstat imports as they resolve, every crossing, image loads, Objective-C sends, C++ throws and host exceptions raised into the guest, OpenGL calls and frames per 10 s
OCERZ_M32_KEEP_GOING=1 run with unresolved imports, which then return 0
OCERZ_M32_HEAP_MB=<n> the guest heap’s first size
OCERZ_M32_MUTE=1 the CoreAudio render callback outputs silence
OCERZ_GUEST32_ROOT=<dir> where guest system libraries are looked for, instead of runtime/guest32
OCERZ_M32LOG=steam the Steam bridge: the client it opened, each interface it bridged, every call to a method it cannot cross, and each callback it hands the game
OCERZ_M32LOG=dlsym each dlsym that found nothing, with its caller
OCERZ_M32LOG=render each audio unit render callback the host makes, its frames and bytes
OCERZ_M32_HUD=<overlay dylib> an FPS overlay that exports fake_dlsym (simpleoverlay) draws on each frame the game presents, through CGLFlushDrawable or -[NSOpenGLContext flushBuffer]
OCERZ_NO_M32_STEAM=1 a steamclient.dylib with no i386 slice is left to the ordinary loader, which refuses it
OCERZ_NO_M32_CARBON_WRAP=1 a window an i386 program creates is made as it asked, without kWindowCompositingAttribute, which 64-bit HIToolbox refuses
OCERZ_NO_M32_UPP=1 the New...UPP and Dispose...UPP calls stay unresolved
OCERZ_NO_M32_EVENT_PARAMS=1 Carbon event parameters cross in their raw layouts
OCERZ_NO_M32_FLOAT_ARRAYS=1 CoreGraphics’ CGFloat arrays cross as the guest’s floats
OCERZ_NO_M32_LUCIDA=1 +[NSFont systemFontOfSize:] and its siblings answer today’s system font
OCERZ_NO_M32_KVC=1 a paired class answers no key-value coding from guest instance variables, and nib outlets with no setter stay unset
OCERZ_NO_M32_REAP=1 a twin outlives its host object
OCERZ_M32_SENDLOG=<part>[\|<part>...] each message to a host object whose selector contains a part: its arguments (a string’s characters too) and result, then every message the objects it returned receive
OCERZ_NO_M32_IMAGE_NAME=1 class_getImageName knows no image for a paired class
OCERZ_NO_M32_PACKED=1 HIToolbox’s theme structures cross as the guest’s bytes
OCERZ_NO_M32_SYSTEM_OPENAL=1 a program’s own OpenAL library is loaded instead of the system’s
OCERZ_NO_M32_DLSYM_NULL=1 dlsym hands out the trap of a function m32 cannot call, which answers 0
OCERZ_NO_M32_APPLE_FSTREAM=1 the runtime’s libstdc++ keeps GCC’s layout of ofstream and fstream instead of Apple’s
OCERZ_NO_M32_SIGNALS=1 a guest’s signal handlers are recorded only, never run, and the host’s default action stays
OCERZ_NO_M32_FAULT_HANDLERS=1 a fault in i386 code ends in ocerz’s crash report instead of the program’s own handler (SDL’s parachute, say)
OCERZ_M32_HOST_PAGES=1 i386 programs are told the host’s 16 KB pages instead of 4 KB
OCERZ_NO_M32_ENCODING_CACHE=1 each message to a host object looks its i386 method encoding up again instead of keeping it per class and selector
OCERZ_M32_NO_SELFLOAD=1 start a program with no libraries and no dyld at its entry like any other, instead of as one that loads itself
OCERZ_NO_M32_FASTCALL=1 imports leave the block for the trap instead of crossing from inside it
OCERZ_NO_M32_LEAF=1 leaf exports cross like any other import
OCERZ_NO_M32_TRAP_BLOCKS=1 a call through a function pointer to an export goes through the run loop, as before
OCERZ_NO_HIGH8_ARITH=1 instructions on ah, ch, dh and bh go to the interpreter again (64-bit code too; docs/reference.md)
OCERZ_NO_SHIFT_MEM_IMM=1 shifts of memory by an immediate go to the interpreter again (64-bit code too)

Limits

Much of Carbon’s window, menu, control and event API is declared for 32-bit code alone yet still exported, and working, in Apple silicon’s HIToolbox. tools/sdkgen gives each such function an arm64 declaration made from its i386 one, so it crosses like any other (Carbon’s database crosses 847 functions where it crossed 364). Windows an i386 program creates get kWindowCompositingAttribute, the only kind 64-bit HIToolbox makes.

QuickDraw’s region, rectangle and point math and cursor visibility are still exported and cross (tools/sdkgen/headers/QuickDrawRetained.h declares them); m32 provides the rest on CoreGraphics (src/m32_qd.c). A window’s port draws into a bitmap in guest memory that the window’s content view shows, a GWorld is a bitmap over guest pixels (32-bit xRGB and 16-bit x555, big-endian as on Intel Macs), and pens, lines, rectangle and region fills, clipping, SetOrigin, CopyBits, QDBeginCGContext, the mouse, cursors and the graphics devices work. QuickDraw text is not drawn (the port keeps its font, size and style), transfer modes other than srcCopy copy, 8-bit and smaller GWorlds are refused with cDepthErr, and color cursors from resources show the arrow.

AGL still works on Apple silicon, without headers in any SDK: m32 crosses it by hand (src/m32_agl.c), and an AGLDrawable, a QuickDraw port, becomes its window (aglSetWindowRef) or its GWorld’s pixels (aglSetOffScreen). The New...UPP calls hand back their function and Dispose...UPP does nothing, as on Intel. Carbon event parameters whose i386 and arm64 layouts differ convert (WindowRef, ControlRef, RgnHandle and the other pointers, HIPoint, HIRect, HICommand, CFIndex), and a draw event’s GrafPtr, which 64-bit Carbon lacks, is the window’s QuickDraw port. EventRecords are copied between their 16-byte i386 and 20-byte arm64 layouts. CoreGraphics’ CGFloat arrays (colors, dashes, point and rectangle lists, gradients, CoreText’s advances) cross as doubles. Kega Fusion runs and opens its window.

The menu and control calls Apple silicon dropped are made from their HIView and menu successors (src/m32_controls.c): EnableMenuCommand, SetItemCmd, the hierarchical menu IDs, the menu bar handle, EmbedControl, control reference values, GetControlRegion, the 32-bit values, SetControlBounds, TestControl, FindControl, HandleControlClick and user panes, whose draw, hit-test, tracking, activate and focus procedures are called and whose QuickDraw drawing the pane shows. QuickDraw’s and the Font Manager’s font families (GetFNum, FMGetFontFamilyFromName, FMGetFontFromFontFamilyInstance, GetThemeFont) are CoreText’s, with the classic IDs (the system font Lucida Grande 0, Geneva 3, Helvetica 21 and the rest), and the Text Encoding Converter’s conversions widen their counts.

The rest of the 32-bit-only Carbon UI does not exist on Apple silicon and m32 does not provide it yet: of the control kinds, only push buttons, check boxes, static text and icons remain (radio and bevel buttons, sliders, tabs, pop-up buttons, edit text, scroll bars and the Data Browser are gone), nor do Navigation Services dialogs and MLTE. long double calls have no notation and stay unresolved. OpenCL is not bridged (clCreateContextFromType answers 0, and Batman does without it). A handler for an asynchronous signal (SIGINT, SIGTERM, SIGALRM, SIGCHLD and the like) runs at the program’s next call into a host library, so an SDL game asked to stop with SIGTERM or Ctrl-C quits as on a Mac and saves its settings. A handler for a fault (SIGSEGV, SIGBUS, SIGFPE, SIGILL, SIGTRAP) runs at the fault, in the frame the kernel builds for i386, and the context it changes is the one the program goes on with, as Mono turns a null access into a NullReferenceException.

WineHQ’s 32-bit Wine 5.0 for macOS, what a TransGaming Cider port carries inside, runs Windows programs: wine cmd /c "echo hi && ver" creates a prefix (services.exe, the setupapi install of wine.inf, explorer.exe) and prints Microsoft Windows 6.1.7601 in about four seconds, one second once the prefix exists. WINEDLLOVERRIDES="mscoree,mshtml=" skips the Wine Mono and Gecko install prompts a new prefix otherwise waits on. i386 programs are told 4 KB pages, as on the Intel Macs: told the host’s 16 KB, wineserver took every PE DLL with 4 KB sections for one to map flat and ntdll refused it. IOKit’s registry calls that name a plane or a class (io_name_t) are made as they stand (src/m32_iokit.c); Wine’s Mac driver climbs from its GPU to the IOPCIDevice above it with them.

Firefox 3.6.28 (i386) starts, finds its fonts and draws its browser window, but the window does not reach the screen: AppKit has it visible and the window server has it off screen. The same window settings made natively show, and the process has a second window server connection, opened when Gecko installs a handler on Carbon’s event monitor target; without that handler the window reached the screen in some runs and not in others.