Inside N1 · Part 1

Meet N1: Apple’s wireless chip

On an M6 Mac mini, the Bluetooth section of system_profiler, macOS’s hardware report, names the chip behind the Mac’s wireless:

$ system_profiler SPBluetoothDataType | sed -n '/Bluetooth Controller:/,/Connected:/p' | grep -E 'Chipset|Transport'
          Chipset: Apple N1
          Transport: PCIe

N1 is the chip behind the Mac’s Wi-Fi, Bluetooth and Thread. Its firmware also carries code for jobs that some wireless chips leave to the operating system, such as joining a Wi-Fi network. In this post we work out which jobs N1’s firmware has code for, which macOS processes talk to the chip, and how their messages reach it.

Apple designed N1 itself and introduced it with the iPhone 17 lineup in September 2025 as the chip for Wi-Fi 7, Bluetooth 6 and Thread, a low-power mesh network for connected devices, and Apple’s deployment guide now lists it in iPhone, iPad and MacBook models. On our Mac the Wi-Fi section of system_profiler names it too, in a firmware banner ending in N1_b, and lists 802.11be, Wi-Fi 7, among the modes it supports:

$ system_profiler SPAirPortDataType | grep -E 'Card Type|Supported PHY'
          Card Type: chip id: 0x11 api 1.2 firmware [Rev 91.104.6 PrxB1 dF=0] phy [30.1.148.0], core80211 [343.104.8 N1_b ]
          Supported PHY Modes: 802.11 a/b/g/n/ac/ax/be

This is the first part of a series. We will build a model of the chip from the Mac’s files, logs and device listings. In the parts that follow we will test that model with real traffic, starting with a Bluetooth keyboard talking to our Mac.

Our Mac is an M6 Mac mini on macOS 27.0.1 (26A434). On an N1 Mac running that build, you should be able to reproduce the output of every command we show, apart from times and counts that change from run to run. We plan to publish a companion repository with the scripts and tools behind this series. If you find a mistake in our research, analysis or interpretation, please tell us at contact@almamatertech.com.

One chip, three functions

N1 is a separate chip from the Mac’s M6 processor, and the Transport line above says how the two are connected. PCIe is the same kind of link that connects an SSD or a graphics card. One PCIe device can appear to the computer as several independent devices, called functions, which the operating system finds and drives one by one. N1 appears as three. One function carries Wi-Fi, one carries both Bluetooth and Thread, and the third is for controlling the chip itself. macOS gives each function its own driver.

The Mac's M6 processor running macOS has three drivers, one for Wi-Fi, one for Bluetooth and Thread, and one for chip control. Lines from all three meet at one PCIe bridge, and a single PCIe link runs from it to N1. Inside N1 the link splits into three functions, Wi-Fi as function 1, Bluetooth and Thread as function 2, and chip control as function 0. macOS calls them alpha, beta and control.
The Mac’s three drivers at the top, N1’s three functions at the bottom, and the one PCIe link between them as the thick line. The functions are how N1 presents itself to macOS.

macOS names the functions after Centauri, its own name for the chip. The I/O Registry, the tree of devices and drivers that ioreg prints, shows all three under one PCIe bridge, the Mac’s end of the link. Each line’s details, such as IDs and timings, are cut at the …:

$ ioreg -p IODeviceTree -l -w0 | grep -E -- '-o (pci-bridge0|centauri-(control|alpha|beta))@.*IOPCIDevice'
    | | +-o pci-bridge0@0  <class IOPCIDevice, …>
    | | | +-o centauri-control@0  <class IOPCIDevice, …>
    | | | +-o centauri-alpha@0,1  <class IOPCIDevice, …>
    | | | +-o centauri-beta@0,2  <class IOPCIDevice, …>

The number after each @ is a PCIe address. All three are device 0, and the number after the comma is the function. Control, with a single number, is function 0. Alpha is Wi-Fi. Its driver’s settings list the Wi-Fi interfaces it creates, one for each way the chip can connect, such as STA for the ordinary connection to an access point:

$ plutil -p /System/Library/DriverExtensions/AppleCentauriAlpha.dext/Info.plist | grep -o '"AppleWLANInterface[A-Za-z]*"' | sort -u
"AppleWLANInterfaceAWDL"
"AppleWLANInterfaceHotspot"
"AppleWLANInterfaceNAN"
"AppleWLANInterfaceNANData"
"AppleWLANInterfaceSTA"

Beta is Bluetooth with Thread, and its driver is the one bluetoothd holds open, as the snapshot further down shows.

Thread shares a function with Bluetooth, and as we’ll see below, its radio code is built into the chip’s Bluetooth firmware. Sharing works because each kind of traffic gets its own channel. Inside a function, the Mac and the chip pass messages through queues, areas of memory both can reach, where one side leaves messages and the other picks them up in order. A channel is a set of these queues that a macOS service asks for by number, and Apple’s logs call channels interfaces. So Bluetooth’s messages and Thread’s radio frames travel on separate channels.

The Bluetooth and Thread function drawn as a frame, with macOS on the left and N1 on the right. The Bluetooth host, bluetoothd, reaches the Bluetooth controller over channels 64 to 71, drawn as one. The Thread service, threadradiod, reaches the Thread radio code inside the controller over its own channel, 72. Each channel is drawn as two queues, a row of slots, one carrying messages toward the chip and one back toward the Mac, with filled slots for messages waiting to be picked up.
One function, with Bluetooth’s seven channels drawn as one and Thread’s channel below them, each drawn as a queue each way between the macOS service and the chip. Filled slots are messages waiting to be picked up.

Each of the three drivers runs as a separate process outside the kernel, built with Apple’s DriverKit. The only N1-specific kernel extension we found is AppleCentauriManager:

$ ls /System/Library/DriverExtensions | grep Centauri
AppleCentauriAlpha.dext
AppleCentauriBeta.dext
AppleCentauriControl.dext
$ ls /System/Library/Extensions | grep -i centauri
AppleCentauriManager.kext

Above the drivers sit background services, which macOS calls daemons, a main one for each protocol and one for the chip as a whole. A snapshot of the I/O Registry, saved with ioreg -a -l -r -c IOUserClient, lists every open connection to a driver. We can count the ones to N1’s drivers, for each process that opened them, by parsing the snapshot with a few lines of Python. On our Mac:

  7 AirshipCentauriHelperInterfaceUserClient     bluetoothd
  1 AirshipCentauriHelperInterfaceUserClient     centaurid
  1 AirshipCentauriHelperTimesyncUserClient      bluetoothd
  1 AppleWLANWDUserClient                        airportd
  1 CentauriBetaUserClient                       bluetoothd
  1 CentauriControlUserClient                    centaurid
 13 IO80211APIUserClient                         airportd
  1 IO80211APIUserClient                         sharingd
  1 IO80211APIUserClient                         wifianalyticsd
  3 IO80211APIUserClient                         wifip2pd
  1 IO80211APIUserClient                         wifivelocityd

Each line is a count of connections, the kind of connection, and the process that opened it. airportd holds most of the Wi-Fi driver’s, bluetoothd the Bluetooth and Thread driver’s, and centaurid the control driver’s. No threadradiod appears, and the Thread section below says why. After a look at the firmware package the functions share, we take each in turn and show which macOS processes talk to it and which jobs its firmware has code for. The figure below summarizes what we found, one frame per function.

N1's three PCIe functions, each drawn as a frame, with macOS on the left and N1 on the right. Each arrow names the messages that cross, and on the macOS side every arrow goes through that function's driver. The N1 side lists the code each part contains, from file names in the firmware package and assert names in centaurid. In the Wi-Fi function, the Wi-Fi service, airportd, and the Wi-Fi driver exchange messages in Apple's own format with Wi-Fi firmware that has code for scanning, joining and roaming, encryption keys, a hotspot, direct links and keep-alives. In the Bluetooth and Thread function, one driver is shared. The Bluetooth host, made up of bluetoothd, other processes and that driver, talks HCI, Bluetooth's standard messages, to the Bluetooth controller, which has code for connections, scanning, frequency hopping and audio links. The Thread service, threadradiod, and the same driver talk Spinel, OpenThread's radio messages, to the Thread radio code inside the controller. In the chip control function, the chip service, centaurid, and the control driver exchange messages in Apple's own format with the control processor, whose code covers loading radio firmware, power, time sync, coexistence and crash data. Under each arrow is its channel number or, for Wi-Fi, own queues. HCI uses channels 64 to 71, Spinel channel 72 and the control function channel 1, all seen in our Mac's logs. Wi-Fi's driver sets up its own message queues with the chip.
Where the code for each job lives, one frame per PCIe function. Each macOS service reaches the chip through its function’s driver, and Bluetooth and Thread share one. The grey boxes list code named in the firmware package or, for Bluetooth and Thread, in the assert names centaurid keeps. The control processor’s jobs are read from strings. Under each arrow is its channel number, all seen in our Mac’s logs. Wi-Fi’s arrow is labelled own queues, because its driver sets up its own message queues with the chip. The sections below explain each frame.

Peeking inside ftab.bin

We can’t watch code run inside the chip, but we can read the firmware macOS sends it. It comes as one file, ftab.bin, 30.6 MB, in the Preboot volume, the small hidden volume macOS uses to start up. We copy it out and look at its first bytes with xxd:

$ find /System/Volumes/Preboot -maxdepth 6 -name ftab.bin 2>/dev/null
/System/Volumes/Preboot/<volume>/restore/Firmware/t2026macG2/Release/ftab.bin
$ cp /System/Volumes/Preboot/*/restore/Firmware/t2026mac*/Release/ftab.bin .
$ wc -c < ftab.bin
 30646220
$ xxd -s 0x20 -l 0x40 ftab.bin
00000020: 726b 6f73 6674 6162 5500 0000 0000 0000  rkosftabU.......
00000030: 726b 6f73 8005 0000 a08c 0d00 0000 0000  rkos............
00000040: 726b 6f6c 2092 0d00 a0ac 0c00 0000 0000  rkol ...........
00000050: 6163 6977 c03e 1a00 c0ec 2600 0000 0000  aciw.>....&.....

Those bytes are a table of contents. The first line holds the package’s signature, rkosftab, and the number of files it bundles, 55 in hex or 85. rkos reads as RTKit OS. RTKit is the operating system N1’s control processor runs, and the first file in the table, also named rkos, is that processor’s code. Each line after the first describes one file, with a four-letter name, where the file starts and how long it is, and each number is stored lowest byte first. The third file, aciw, starts at 0x1a3ec0 and is 0x26ecc0 bytes long, 2.55 MB.

Splitting the package means copying out each file’s bytes, from its start for its length. We can do that with a few lines of Python, by reading the number of files at byte 0x28, then each 16-byte line of the table from byte 0x30 on, and writing each file into a folder named ftab:

$ python3 - ftab.bin <<'EOF'
import os, struct, sys
data = open(sys.argv[1], 'rb').read()
count = struct.unpack_from('<I', data, 0x28)[0]
os.makedirs('ftab', exist_ok=True)
for i in range(count):
    name, start, size = struct.unpack_from('<4sII', data, 0x30 + 16 * i)
    open('ftab/' + name.decode(), 'wb').write(data[start:start + size])
print(count, 'files written to ftab/')
EOF
85 files written to ftab/

The ipsw tool has a parser for the same layout. Its command for Apple’s C1 modem firmware accepts packages in this format, prints their table and splits them. Its aciw line runs from our start, 0x1a3ec0, to our start plus our length, 0x412b80, and its split gives the same 85 files, byte for byte:

$ ipsw fw c1 --info ftab.bin | sed -n '1,4p;8p'
FTAB Header:
  Magic:   0x62617466736f6b72 (rkosftab)
  Version: 0
  Entries: 85
  aciw: 0x001a3ec0-0x00412b80 (2.6 MB)
$ ipsw fw c1 ftab.bin --output ipsw-out > /dev/null 2>&1
$ for f in ftab/*; do cmp -s $f ipsw-out/extracted/${f#ftab/}.bin && echo same; done | wc -l
      85

aciw carries the two version numbers in the Card Type line near the top, the firmware revision and the core80211 version:

$ strings -a ftab/aciw | grep -E 'Rev 91|Core80211'
Rev 91.104.6
Rev 91.104.6 Core80211:343.104.8 

So aciw is Wi-Fi code. It’s one of the package’s firmware images, the files that hold the chip’s code. Like code for Arm’s Cortex-M processors, most of the Wi-Fi and Bluetooth images start with a vector table, which holds the stack’s starting address and then the addresses where code starts, each an odd number:

$ xxd -l 8 ftab/w2lp
00000000: f0ff 0210 d50b 1000                      ........
$ xxd -l 16 ftab/aciw
00000000: 0000 0000 45f5 1600 59f6 1600 09f6 1600  ....E...Y.......

Read lowest byte first, w2lp, another Wi-Fi image, starts with the stack address 0x1002fff0 and then the code address 0x00100bd5. aciw has zero where a stack address would be, then odd code addresses such as 0x0016f545.

Apple’s build manifest for macOS 27.0.1 gives the files names. For our Mac’s board, it lists each one under a name, such as Wireless1,ACIWIFI, together with its SHA-384 hash, a fingerprint of the file’s exact bytes. So when we hash each of the 85 files and look the hash up, the ones the manifest lists get their names, 38 in all. The other 47, among them the radio settings tables and the assert lists, have no entry. We can download the manifest from Apple’s restore image with ipsw, which reads only that one file from Apple’s server:

$ ipsw extract --remote --pattern '^BuildManifest.plist$' --output . https://updates.cdn-apple.com/2026FallFCS/59241290-5d51-4ca8-9df4-31624b9a4eac/UniversalMac_27.0.1_26A434_Restore.ipsw > /dev/null 2>&1

We can then match each file to its name by hashing it and looking the hash up, with a few lines of Python. These are the files with Wi-Fi in their names, leaving out the separate manufacturing builds, which come up later:

aciw  Wireless1,ACIWIFI
scaw  Wireless1,ScanWifi
w2lp  Wireless1,Wifi2GLMAC
w2pm  Wireless1,Wifi2GLPhy, Wireless1,Wifi2GLPhyManuf
w2pp  Wireless1,Wifi2GLPhy, Wireless1,Wifi2GLPhyManuf
w2pt  Wireless1,Wifi2GLPhyTrim
w5lp  Wireless1,Wifi5GLMAC
w5pm  Wireless1,Wifi5GLPhy, Wireless1,Wifi5GLPhyManuf
w5pp  Wireless1,Wifi5GLPhy, Wireless1,Wifi5GLPhyManuf
w5pt  Wireless1,Wifi5GLPhyTrim
wfrd  Wireless1,WifiRegData
wfrp  Wireless1,WiFiRx
wftp  Wireless1,WiFiTx

Ten of them are images, and together they make up N1’s Wi-Fi firmware. w2pm and w5pm have the same hashes as w2pp and w5pp, so they’re the same images stored a second time under their manufacturing names. wfrd, Wi-Fi regulatory data, holds settings.

The names in Apple’s manifest, such as Wifi2GLMAC for w2lp, also explain the package’s four-letter tags. In the Wi-Fi tags, 2 and 5 give the band, l and p the part, and the last letter the kind of build. Apple’s names for w2pp, w2pm and w2pt show the three kinds, Wifi2GLPhy, Wifi2GLPhyManuf for manufacturing and Wifi2GLPhyTrim for trim. The lower MAC handles the timing of each frame on the air, and the PHY processes the radio signal. Trim most likely means calibration, as it usually does for chips. The descriptions of the lower MAC and PHY images are our own interpretation, the same as in the Wi-Fi asserts figure further down.

Tag What it is (name in Apple’s manifest)
rkos the control processor’s operating system (RTKitOS)
aciw the main Wi-Fi image (ACIWIFI)
acib the main Bluetooth image, with Thread’s radio code (ACIBT)
w2lp, w5lp Wi-Fi link timing, 2.4 and 5 GHz (Wifi2GLMAC, Wifi5GLMAC)
w2pp, w5pp Wi-Fi signal processing, 2.4 and 5 GHz (Wifi2GLPhy, Wifi5GLPhy)
wfrp, wftp Wi-Fi receive and transmit (WiFiRx, WiFiTx)
scaw Wi-Fi scanning (ScanWifi)
btse a second Bluetooth image (BTSecondary)
phyb, bt5p Bluetooth signal processing (PhyBlueTooth, PhyBlueTooth5G)
scab Bluetooth scanning (ScanBluetooth)
wfrd Wi-Fi regulatory data (WifiRegData)
_osl the chip’s loader, from its strings (no name)
_wfa, _wma the Wi-Fi assert lists (no name)

The “ACI” in aciw and acib also appears in the Wi-Fi driver’s WLANACIAdapter library and in ACIPC, in the names of the functions macOS uses to pass messages to the chip. The same build ships kernel extensions named for Apple Converged IPC, so ACIPC most likely stands for that:

$ ls /System/Library/Extensions | grep -i -E 'ConvergedIPC|ACIPC'
AFKACIPCKext.kext
AppleConvergedIPCOLYBTControl.kext

The Wi-Fi function

On the Mac’s side, the Wi-Fi driver presents the chip as the network interfaces listed in the first section. On our Mac the ordinary connection to an access point is en1, and apps’ Wi-Fi traffic passes through it like traffic on any other network interface. Other interfaces carry direct links between devices, such as Apple Wireless Direct Link (AWDL), which AirDrop builds on, and the Wi-Fi Alliance’s Wi-Fi Aware, which Apple’s code calls NAN. On our Mac those are awdl0 and nan0:

$ networksetup -listallhardwareports | grep -A1 'Hardware Port: Wi-Fi'
Hardware Port: Wi-Fi
Device: en1
$ ifconfig -l | tr ' ' '\n' | grep -E '^(awdl|nan)'
awdl0
nan0

The Wi-Fi service, airportd, holds the driver open and answers apps that use CoreWLAN, Apple’s Wi-Fi framework. Its launch settings register a service named for CoreWLAN:

$ plutil -p /System/Library/LaunchDaemons/com.apple.airportd.plist | grep -i corewlan
    "com.apple.corewlan-xpc" => true
The paths through the Wi-Fi function, from apps on the Mac down to the air, with traffic going both ways. On the left, network traffic from any app goes through the Mac's network interfaces, en1 for the ordinary connection to an access point, awdl0 for AWDL, which AirDrop uses, and nan0 for Wi-Fi Aware. On the right, Wi-Fi requests from apps using CoreWLAN go to the Wi-Fi service, airportd. Both paths meet at the Wi-Fi driver, which exchanges messages with N1's Wi-Fi firmware in Apple's own format. N1's radio sends and receives over the air, to access points and nearby devices.
Paths through the Wi-Fi function, with traffic going both ways. On the Mac, apps’ network traffic goes through network interfaces such as en1, and requests about Wi-Fi itself go through CoreWLAN to airportd. Both reach N1 through the Wi-Fi driver. On the other side, N1’s radio sends and receives over the air, to access points and nearby devices. The interface names are the ones our Mac lists.

Firmware is full of internal checks, called asserts, that stop it when something “impossible” happens. One of the 85 files, _wfa, lists the Wi-Fi firmware’s asserts, as its name suggests, wf for Wi-Fi and a for asserts. Its first lines show it’s a property list, the same format as an app’s Info.plist, and macOS’s plutil accepts it as one:

$ head -4 ftab/_wfa
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
$ plutil -lint ftab/_wfa
ftab/_wfa: OK

It has eight sections, one for each separately built part of the Wi-Fi firmware, and 9,251 asserts in all. Each entry gives an assert’s number and the source file, function and line it sits in:

$ python3 -c 'import plistlib; m = plistlib.load(open("ftab/_wfa", "rb")); print({s: len(c) for s, c in m.items()}, sum(map(len, m.values())))'
{'LMAC2G': 1688, 'LMAC5G': 1688, 'PHY2G': 548, 'PHY5G': 618, 'RX': 470, 'SCAN': 629, 'TX': 983, 'UMAC': 2627} 9251
$ plutil -extract UMAC.0x50de86 json -r -o - ftab/_wfa; echo
{
  "file" : "mlme.c",
  "function" : "SmIllegalEventInThisState",
  "line" : 1421,
  "title_size" : 0,
  "title_str" : "ASSERT: mlme.c-1421:SmIllegalEventInThisState"
}

In Apple’s manifest the other files whose names start with wf are Wi-Fi, such as wfrp, WiFiRx, and every one of the 9,251 entries has a title starting with ASSERT::

$ plutil -convert json -o - ftab/_wfa | grep -o '"title_str":"ASSERT:' | wc -l
    9251

Its 542 source file names appear in two files of the package, _wfa and a second list like it, _wma, and in none of the other 83, the Wi-Fi firmware included. We can check that by listing the names, then searching the other 83 files for each one as a whole name:

$ plutil -convert json -o - ftab/_wfa | grep -o '"file":"[^"]*"' | cut -d'"' -f4 | sort -u > names.txt
$ wc -l < names.txt
     542
$ python3 - <<'EOF'
import os, re
names = open('names.txt').read().split()
whole = re.compile(rb'(?<![\w.])(' + b'|'.join(re.escape(n.encode()) for n in names) + rb')(?![\w.])')
hits = [f for f in sorted(os.listdir('ftab')) if f not in ('_wfa', '_wma') and whole.search(open('ftab/' + f, 'rb').read())]
print(len(hits), 'of the other 83 files hold any of the names')
EOF
0 of the other 83 files hold any of the names

So the names reach us only through those two lists. They show which source files the firmware was built from, at least those with asserts in them. Here are a few of the 229 file names in the UMAC section, most likely the upper MAC, the part of Wi-Fi that manages connections:

$ plutil -extract UMAC json -o - ftab/_wfa | grep -o '"file":"[^"]*"' | cut -d'"' -f4 | sort -u > umac.txt
$ wc -l < umac.txt
     229
$ grep -x -E '(scan_controller|mlme|conn_mgr_roam|supplicant|key_generator|ap_authenticator|awdl_aw_scheduler|nan_dp_sm|keep_alive|arp_icmp_offload_ota|bonjour_offload_ota)\.c' umac.txt
ap_authenticator.c
arp_icmp_offload_ota.c
awdl_aw_scheduler.c
bonjour_offload_ota.c
conn_mgr_roam.c
keep_alive.c
key_generator.c
mlme.c
nan_dp_sm.c
scan_controller.c
supplicant.c

They name code for scanning for networks, joining one, roaming between access points, and the key exchange that sets up encryption, which is the job of a supplicant. mlme.c has the same name as the file where Linux’s own Wi-Fi code handles joining and leaving networks. Others cover running a hotspot, AWDL, and Wi-Fi Aware. keep_alive.c and the two offload files are named for keeping connections alive and for answering ARP and Bonjour requests, the messages other devices send to find a computer’s hardware address and services. The word offload in two of those names suggests work moved from the Mac to the chip. The other sections’ names point to link timing, signal processing, receiving, transmitting and scanning.

Horizontal bars for the eight sections of _wfa, the Wi-Fi firmware's list of asserts, on our Mac, 9,251 asserts in all, each with real file names from that section. UMAC, connections, 2,627 asserts, with scan_controller.c, supplicant.c and keep_alive.c. LMAC2G, link timing, 1,688, with link_manager.c, tx_manager.c and beacon_manager.c. LMAC5G, link timing, 1,688, built from the same source files. PHY2G, signal processing, 548, and PHY5G, signal processing, 618, both with phy_validation.c, calib_iip2.c and channel_tune.c. RX, receive, 470, with reorderMng_func.c and rx_wow.c. TX, transmit, 983, with vtdq_mgr.c and rings_mgr.c. SCAN, scan, 629, with link_interface_manager.c and packet_handler.c. The job labels are our own interpretation of the section names and files.
Each bar is one section of _wfa on our Mac, sized by its number of asserts, with three of the file names in it. The biggest section, UMAC, holds the code for connections. The job beside each section’s name is our own interpretation of its name and files.

So N1’s Wi-Fi firmware carries code for managing connections as well as for the radio.

On the Mac, the services on the other two functions ask for numbered channels with functions named airship_ch_interface_…, and airportd imports none of them. The Wi-Fi driver sets up its own queues with the chip and sends the firmware messages over them in Apple’s own undocumented format. Those messages go through a library named WLANACIAdapter, which imports 38 queue functions, named airship_acipc_…. The library behind the numbered channels, AirshipCentauriHelperServices, imports 51 from the same family, and it’s where the AirshipCentauriHelperInterfaceUserClient connections in the snapshot above come from. Both libraries are in DriverKit’s dyld shared cache, one large file that holds all of DriverKit’s system libraries, so we copy them out with ipsw, along with the driver’s Wi-Fi library, IO80211DriverKit:

$ for f in /usr/libexec/airportd /usr/sbin/bluetoothd /usr/libexec/centaurid /System/Library/PrivateFrameworks/CoreThreadRadio.framework/threadradiod; do echo "$(basename $f) $(nm -m $f | grep -c _airship_ch_interface)"; done
airportd 0
bluetoothd 33
centaurid 14
threadradiod 27
$ ipsw dyld extract /System/Volumes/Preboot/Cryptexes/OS/System/DriverKit/System/Library/dyld/dyld_shared_cache_arm64e WLANACIAdapter AirshipCentauriHelperServices IO80211DriverKit --output . > /dev/null 2>&1
$ nm -m WLANACIAdapter | grep -c _airship_acipc_
38
$ nm -m AirshipCentauriHelperServices | grep -c _airship_acipc_
51
$ strings -a AirshipCentauriHelperServices | grep -m1 InterfaceUserClient
AirshipCentauriHelperInterfaceUserClient

Whether macOS or N1 joins a network shows in where the code for its keys sits. Joining a network secured with WPA2 or WPA3 Personal takes authentication, association, and a handshake that derives the keys for the connection. With WPA3, authentication is itself a key exchange, called SAE. Both key derivations use fixed labels from the 802.11 standard, “Pairwise key expansion” for the handshake and “SAE Hunting and Pecking” for SAE, so whichever code derives the keys carries them. In the package, only the main Wi-Fi image and its manufacturing build do:

$ grep -a -l -E 'Pairwise key expansion|SAE Hunting and Pecking' ftab/*
ftab/aciw
ftab/awma

On the Mac, neither label is in airportd, in eapolclient, the program that logs in to enterprise networks, or in the driver’s two libraries. None of them imports the SAE functions in Apple’s crypto library either. One Wi-Fi program does, wifip2pd, which runs AWDL and Wi-Fi Aware, for pairing devices over Wi-Fi Aware, which Apple’s code calls NAN. In this loop, the first number is the labels found and the second the SAE functions imported:

$ for f in /usr/libexec/airportd /System/Library/SystemConfiguration/EAPOLController.bundle/Contents/Resources/eapolclient WLANACIAdapter IO80211DriverKit /usr/libexec/wifip2pd; do echo "$(basename $f) $(grep -a -c -E 'Pairwise key expansion|SAE Hunting and Pecking' $f) $(nm -m $f | grep -c ccsae)"; done
airportd 0 0
eapolclient 0 0
WLANACIAdapter 0 0
IO80211DriverKit 0 0
wifip2pd 0 26
$ strings -a /usr/libexec/wifip2pd | grep -m1 'NANPairing.*SAE'
_TtCVV7CoreP2P10NANPairing3SAEP33_4B94487B91F65D95BFC0BE9A4FCAE2417Context

What macOS does is pick the network and give the chip what it needs to join it. airportd decides which known network to join, which Apple calls auto-join. On an enterprise network, eapolclient runs the login, and its strings show it setting the resulting master session key, the MSK, and waiting for the handshake to complete. The driver hears from the firmware when authentication or association fails:

$ strings -a /usr/libexec/airportd | grep -m1 -o CWFAutoJoinManager
CWFAutoJoinManager
$ strings -a /System/Library/SystemConfiguration/EAPOLController.bundle/Contents/Resources/eapolclient | grep -x -E 'set_msk %d|4-way handshake complete'
set_msk %d
4-way handshake complete
$ strings -a IO80211DriverKit | grep -m1 'link down due to assoc fail'
[wcl] %s@%d:received firmware link event, link down due to assoc fail

So on our Mac, N1 runs the exchanges with the access point and derives the keys itself. macOS decides which network to join, and when, and handles the login on enterprise networks.

A sequence between macOS on the left and N1 on the right, for joining a Wi-Fi network, read from top to bottom. In macOS, airportd picks a known network and tells N1, through the Wi-Fi driver, to join it. N1 authenticates, with SAE on a WPA3 network, and associates. On an enterprise network, eapolclient in macOS then logs in and hands the resulting key to N1. N1 runs the 4-way handshake, which derives the connection's keys, and reports the result to the driver as a link event.
Each step of joining a Wi-Fi network, on the side that does it, from top to bottom. The login is for enterprise networks.

The Bluetooth and Thread function

Bluetooth

The Bluetooth Core Specification divides a Bluetooth system into a controller, which runs the radio and the lowest layers of each link, and a host, which runs the layers above it, such as the protocols apps use to exchange data with a device. The two talk over a standard interface, the host controller interface or HCI. In N1, the chip’s Bluetooth firmware is the controller.

On the Mac, the host side meets the chip in the Bluetooth service, bluetoothd, which holds the Bluetooth and Thread function open. Other Bluetooth processes, such as bluetoothaudiod, are part of the host side too, and apps reach Bluetooth through frameworks such as CoreBluetooth.

As macOS starts, bluetoothd asks for seven channels, 64 to 69 and 71:

$ log show --last boot --style compact --predicate 'process == "bluetoothd" AND eventMessage CONTAINS "open] interface_id="'
Timestamp               Ty Process[PID:TID]
2026-10-18 10:29:06.279 Df bluetoothd[164:b8a] (AirshipCentauriHelper) Airship [interface.cpp:275:open] interface_id=64, timeout=5000 ms
2026-10-18 10:29:06.282 Df bluetoothd[164:b8a] (AirshipCentauriHelper) Airship [interface.cpp:275:open] interface_id=65, timeout=5000 ms
2026-10-18 10:29:06.285 Df bluetoothd[164:b8a] (AirshipCentauriHelper) Airship [interface.cpp:275:open] interface_id=66, timeout=5000 ms
2026-10-18 10:29:06.288 Df bluetoothd[164:b8a] (AirshipCentauriHelper) Airship [interface.cpp:275:open] interface_id=67, timeout=5000 ms
2026-10-18 10:29:06.291 Df bluetoothd[164:b8a] (AirshipCentauriHelper) Airship [interface.cpp:275:open] interface_id=68, timeout=5000 ms
2026-10-18 10:29:06.294 Df bluetoothd[164:b8a] (AirshipCentauriHelper) Airship [interface.cpp:275:open] interface_id=69, timeout=5000 ms
2026-10-18 10:29:06.297 Df bluetoothd[164:b8a] (AirshipCentauriHelper) Airship [interface.cpp:275:open] interface_id=71, timeout=5000 ms

bluetoothd’s own code names them. A small function takes a channel number, looks it up in a table of eight names for 64 to 71, and returns unknown for any other number. dyld_info prints where each table entry points, and the strings at those addresses are the names:

$ otool -arch arm64e -tV /usr/sbin/bluetoothd | sed -n '/^00000001000ee868/,/^00000001000ee88c/p'
00000001000ee868	sub	w8, w0, #0x40
00000001000ee86c	cmp	w8, #0x7
00000001000ee870	b.hi	0x1000ee884
00000001000ee874	adrp	x9, 2624 ; 0x100b2e000
00000001000ee878	add	x9, x9, #0xad0
00000001000ee87c	ldr	x0, [x9, w8, uxtw #3]
00000001000ee880	ret
00000001000ee884	adrp	x0, 2168 ; 0x100966000
00000001000ee888	add	x0, x0, #0xf9e ; literal pool for: "unknown"
00000001000ee88c	ret
$ dyld_info -arch arm64e -fixups /usr/sbin/bluetoothd | grep -E '0x100B2E(AD|AE|AF|B0)[08] '
        __DATA_CONST    __const          0x100B2EAD0         rebase  0x10097E8AE
        __DATA_CONST    __const          0x100B2EAD8         rebase  0x10097E8FF
        __DATA_CONST    __const          0x100B2EAE0         rebase  0x10097E950
        __DATA_CONST    __const          0x100B2EAE8         rebase  0x10098554D
        __DATA_CONST    __const          0x100B2EAF0         rebase  0x100985550
        __DATA_CONST    __const          0x100B2EAF8         rebase  0x100985554
        __DATA_CONST    __const          0x100B2EB00         rebase  0x100966F9E
        __DATA_CONST    __const          0x100B2EB08         rebase  0x100985558
$ otool -arch arm64e -v -s __TEXT __cstring /usr/sbin/bluetoothd | grep -E '^0000000100(97e8ae|97e8ff|97e950|98554d|985550|985554|966f9e|985558) '
0000000100966f9e  unknown
000000010097e8ae  hci
000000010097e8ff  acl
000000010097e950  sco
000000010098554d  le
0000000100985550  lea
0000000100985554  lla
0000000100985558  lmp
$ strings -a /usr/sbin/bluetoothd | grep -m1 'LEA Audio'
No LEA Audio context

Going by those names, most likely each kind of Bluetooth traffic gets its own channel. Channel 64 is hci, for HCI’s commands and events. Channels 65, acl, and 66, sco, are named for Bluetooth Classic’s data and voice links, and 67, le, and 68, lea, for Low Energy’s data and LE Audio, which bluetoothd’s code calls LEA. Channel 71, lmp, is named for the link manager protocol, which controllers use with each other. We haven’t found what lla, on 69, stands for. The entry for 70 is unknown, so bluetoothd skips 70.

centaurid, the service that looks after the chip as a whole, keeps the names of the chip’s asserts, 1,353 in the Mac’s copy, and many of them name the parts of a Bluetooth controller. One belongs to the link manager, the part of a controller that sets up connections, and is named for a failure while handling the host’s command to create one:

$ strings -a /usr/libexec/centaurid | grep -c CAPTURE_POINT_ASSERT
1353
$ strings -a /usr/libexec/centaurid | grep LM_PROC_HCI_CREATE_CONNECTION_FAILED
CAPTURE_POINT_ASSERT_LM_PROC_HCI_CREATE_CONNECTION_FAILED

Others cover scanning for Bluetooth Low Energy (LE) devices, adaptive frequency hopping, which moves each link around the 2.4 GHz band to avoid busy channels, the controller’s part of LE Audio, and power control. One of each:

$ strings -a /usr/libexec/centaurid | grep -x -E 'CAPTURE_POINT_ASSERT_(BLE_SCAN_ZERO_WINDOW_OR_INTERVAL|AFH_LINK_MANAGEMENT_STATE_MACHINE_ILLIGALE_STATE|BLE_AUDIO_PARAMS_ALLOCATION_FAIL|LC_POWER_CONTROL_INVALID_POWER_INDEX_REQUEST)'
CAPTURE_POINT_ASSERT_BLE_AUDIO_PARAMS_ALLOCATION_FAIL
CAPTURE_POINT_ASSERT_BLE_SCAN_ZERO_WINDOW_OR_INTERVAL
CAPTURE_POINT_ASSERT_LC_POWER_CONTROL_INVALID_POWER_INDEX_REQUEST
CAPTURE_POINT_ASSERT_AFH_LINK_MANAGEMENT_STATE_MACHINE_ILLIGALE_STATE

The Bluetooth standard gives these jobs to the controller. Since centaurid names asserts for each of them, most likely their code sits in the chip, where the standard puts it.

On our Mac we can also watch the host and the controller exchange HCI messages, because bluetoothd can write that traffic to a trace file. We recorded a trace while restarting bluetoothd. A restarted bluetoothd resets the controller and sets it up again, so the trace opens with that start-up. bluetoothd first sends three of Apple’s own commands, which HCI allows a manufacturer to add, then resets the controller and asks what it is. The controller answers with Apple as its manufacturer. Its list of features includes Channel Sounding, which Bluetooth 6.0 added for measuring the distance between two devices, and bluetoothd then turns on the host’s support for it.

A sequence between bluetoothd on the left and the Bluetooth controller in N1 on the right, from the trace we recorded across a restart of bluetoothd, with times from the start of the trace. bluetoothd sends three Apple commands, 0xFC06 at 0.218 s, 0xFF0B at 0.228 s and 0xFE44 at 0.874 s, then Reset at 0.881 s and Read Local Version at 0.893 s. The controller's reply at 0.894 s names Apple as its manufacturer. From 0.894 to 0.904 s bluetoothd reads which commands and features the controller supports, and at 0.929 s it turns on host support for Channel Sounding. The controller's replies to the other commands are left out.
The start of our trace, with times from when it began. Each arrow is a command or a reply, and the controller’s replies to the other commands are left out.

We decoded the trace with tshark, Wireshark’s command-line tool, version 4.6.3. Here is the start of it, the controller’s version reply, and the Channel Sounding bits in its feature list. Each of the last three finds its packet by its contents, so they work on your own trace too. They pick the reply to Read Local Version (opcode 0x1001), the reply listing the controller’s LE features (0x2087), and the LE Set Host Feature command that turns on feature bit 47:

$ tshark -r trace.pklg -T fields -e frame.number -e frame.time_relative -e _ws.col.Info | sed -n 2,11p
2	0.218000000	Sent Vendor Command 0x0006 (opcode 0xFC06)
3	0.228000000	Rcvd Command Complete (Vendor Command 0x0006 [opcode 0xFC06])
4	0.228000000	Sent Vendor Command 0x030B (opcode 0xFF0B)
5	0.874000000	Rcvd Command Complete (Vendor Command 0x030B [opcode 0xFF0B])
6	0.874000000	Sent Vendor Command 0x0244 (opcode 0xFE44)
7	0.881000000	Rcvd Command Complete (Vendor Command 0x0244 [opcode 0xFE44])
8	0.881000000	Sent Reset
9	0.893000000	Rcvd Command Complete (Reset)
10	0.893000000	Sent Read Local Version Information
11	0.894000000	Rcvd Command Complete (Read Local Version Information)
$ tshark -r trace.pklg -V -Y 'bthci_evt.opcode == 0x1001' | grep -E 'HCI Version|Manufacturer Name'
    HCI Version: Unknown (0x11)
    Manufacturer Name: Apple, Inc. (0x004c)
$ tshark -r trace.pklg -V -Y 'bthci_evt.opcode == 0x2087' | grep -E 'Channel Sounding(: | \(Host)'
        .... .... .... .... .1.. .... .... .... .... .... .... .... .... .... .... .... = Channel Sounding: True
        .... .... .... .... 0... .... .... .... .... .... .... .... .... .... .... .... = Channel Sounding (Host support): False
$ tshark -r trace.pklg -V -Y 'bthci_cmd.feature_bit_number == 47' | grep 'Feature Bit'
    Feature Bit Number: Unknown (47)
    Feature Bit Value: enabled (0x01)

Wireshark labels the version number in the reply, and bit 47, which the feature list above shows as Channel Sounding host support, Unknown.

These commands switch on bluetoothd’s trace file, restart bluetoothd, which macOS starts again as soon as it stops, check in the log that tracing is on, and copy the trace out:

$ sudo defaults write /Library/Preferences/com.apple.MobileBluetooth.debug HCITraces '<dict><key>StackDebugEnabled</key><true/><key>HCILiveTraces</key><true/><key>HCIFileTraces</key><true/></dict>'
$ sudo killall bluetoothd
$ log show --last 15m --info --predicate 'process == "bluetoothd" AND eventMessage CONTAINS "fileHCI:disabled->enabled"'
Timestamp                       Thread     Type        Activity             PID    TTL  
2026-10-19 08:39:41.130118+0200 0x5bca     Default     0x0                  1733   0    bluetoothd: [com.apple.bluetooth:PacketLogger.HCI] Change HCI config settings to … liveHCI:disabled->enabled fileHCI:disabled->enabled …
$ sudo cp /var/root/Library/Logs/Bluetooth/bluetoothd-hci-latest.pklg trace.pklg

The trace holds the addresses of nearby devices, which is why we show only decoded lines from it. To switch tracing off again:

$ sudo defaults delete /Library/Preferences/com.apple.MobileBluetooth.debug HCITraces
$ sudo killall bluetoothd

In part 2 we will record a new trace the same way, of a whole exchange with a small Bluetooth board.

Thread

Thread is built on a low-power radio standard called IEEE 802.15.4. OpenThread, an open-source Thread implementation, offers a split much like Bluetooth’s. In its radio co-processor design, the host runs most of Thread, and the chip with the radio runs only a small layer that sends and receives frames. The two talk a protocol called Spinel.

N1’s code follows that design, with the radio co-processor built into the Bluetooth firmware. The Bluetooth firmware in our Mac’s package, acib, contains the string RCP 15.4 FW Version, RCP being OpenThread’s short name for a radio co-processor, and centaurid lists 67 asserts with Thread in their names:

$ strings -a ftab/acib | grep 'RCP 15.4'
RCP 15.4 FW Version
$ strings -a /usr/libexec/centaurid | grep CAPTURE_POINT_ASSERT | grep -c THREAD
67
$ strings -a /usr/libexec/centaurid | grep -E 'PROTOCOL_THREAD_((TX|RX)_POWER_UP_FAILED|DIAG_SWEEP_TX_FAILURE)'
CAPTURE_POINT_ASSERT_PROTOCOL_THREAD_DIAG_SWEEP_TX_FAILURE
CAPTURE_POINT_ASSERT_PROTOCOL_THREAD_TX_POWER_UP_FAILED
CAPTURE_POINT_ASSERT_PROTOCOL_THREAD_RX_POWER_UP_FAILED

On the Mac, the Thread service, threadradiod, is built to play the host’s part. Its log strings show code to create its own channel on the shared function and to read Spinel frames from it:

$ strings -a /System/Library/PrivateFrameworks/CoreThreadRadio.framework/threadradiod | grep -E 'Interface ID:|parse spinel frame'
%s: airship_ch_interface_create() created Successfully with Interface ID:%d ...
%s, failed to parse spinel frame !

The number it asks for is kept in the program’s data. The function that opens the channel, airship_open, hands a settings structure called thread_ctx to airship_open_rings. That function passes the 4 bytes at offset 0xc of the structure to airship_ch_interface_open as the channel number, its second argument (w1). The structure starts at 0x10053a368, so those bytes are the second word on the last line below, 0x48, which is 72:

$ objdump --macho --arch arm64e --no-show-raw-insn -d --dis-symname __ZN2ot5Posix13HdlcInterface12airship_openEv /System/Library/PrivateFrameworks/CoreThreadRadio.framework/threadradiod | grep -B2 'bl.*airship_open_rings'
10027a9d8:	adrp	x0, 704 ; 0x10053a000
10027a9dc:	add	x0, x0, #0x368
10027a9e0:	bl	__ZN2ot5Posix13HdlcInterface18airship_open_ringsEP18apple_pcie_ipc_ctx
$ nm -arch arm64e -m /System/Library/PrivateFrameworks/CoreThreadRadio.framework/threadradiod | grep 10053a368
000000010053a368 (__DATA,__data) non-external __ZL10thread_ctx
$ objdump --macho --arch arm64e --no-show-raw-insn -d --dis-symname __ZN2ot5Posix13HdlcInterface18airship_open_ringsEP18apple_pcie_ipc_ctx /System/Library/PrivateFrameworks/CoreThreadRadio.framework/threadradiod | grep -B3 '_airship_ch_interface_open$'
10027d1d4:	ldur	x8, [x29, #-0x20]
10027d1d8:	ldr	w1, [x8, #0xc]
10027d1dc:	mov	w2, #0x2710
10027d1e0:	bl	0x10043bd20 ; symbol stub for: _airship_ch_interface_open
$ otool -arch arm64e -s __DATA __data /System/Library/PrivateFrameworks/CoreThreadRadio.framework/threadradiod | grep ^000000010053a370
000000010053a370	00000000 00000048 00000000 00000000 

The I/O Registry snapshot in the first section lists no connection from threadradiod to the chip, and the logs we collected from that boot held no opening of channel 72. Most likely threadradiod wasn’t running. On its own, launchd, which starts the Mac’s daemons, starts it only when another program connects to its service, com.apple.wpantund.xpc. Its launch file has no RunAtLoad, which would start it at boot, and no KeepAlive, which would keep it running. EnablePressuredExit lets the system stop it when it’s idle and memory is short:

$ plutil -p /System/Library/LaunchDaemons/com.apple.threadradiod.plist
{
  "EnablePressuredExit" => true
  "EnableTransactions" => true
  "ExitTimeOut" => 3
  "Label" => "com.apple.threadradiod"
  "MachServices" => {
    "com.apple.wpantund.xpc" => true
  }
  "PosixSpawnType" => "Interactive"
  "ProgramArguments" => [
    0 => "/System/Library/PrivateFrameworks/CoreThreadRadio.framework/threadradiod"
  ]
}

Starting it by hand with sudo shows the channel open. The library threadradiod uses to reach the chip, AirshipCentauriHelper, logs the request for channel 72. The driver side logs it too, in AirshipCentauriHelperInterfaceUserClient, the kind of connection the snapshot in the first section counts, and macOS files that line under kernel:

$ sudo launchctl kickstart system/com.apple.threadradiod
$ log show --last boot --style compact --predicate '(process == "threadradiod" AND eventMessage CONTAINS "interface.cpp") OR (process == "kernel" AND eventMessage CONTAINS "interface index 72")'
Timestamp               Ty Process[PID:TID]
2026-10-18 17:04:22.162 Df threadradiod[3247:1f46f] (AirshipCentauriHelper) Airship [interface.cpp:1389:airship_ch_interface_create]
2026-10-18 17:04:22.163 Df threadradiod[3247:1f46f] (AirshipCentauriHelper) Airship [interface.cpp:275:open] interface_id=72, timeout=10000 ms
2026-10-18 17:04:22.164 Df kernel[0:87b6] () Airship [AirshipCentauriHelperInterfaceUserClient.cpp:518:dispatch_open] interface index 72
2026-10-18 17:04:22.167 Df threadradiod[3247:1f46f] (AirshipCentauriHelper) Airship [interface.cpp:568:open] complete

The timeout in the request, 10,000 ms, is the 0x2710 that airship_open_rings passes beside the channel number.

We saw the channel open. In part 2 we will look at the first Spinel messages on it, the ones threadradiod and the chip exchange as it starts, and compare them with Bluetooth’s start-up, since the two share a controller.

The control function

The third function is how macOS starts the chip and looks after it. Behind it is N1’s control processor, which runs RTKit, the operating system that runs on various helper processors in Apple’s chips, as the Linux kernel’s RTKit library, written by Asahi Linux contributors, describes it. N1’s control processor code, rkos, carries an RTKit version banner, and so do the main Wi-Fi and Bluetooth images, aciw and acib:

$ strings -a ftab/rkos | grep -o 'RTKit_release[.0-9a-z_-]*'
RTKit_release-3514.0.15.release
$ strings -a ftab/aciw | grep -o 'RTKit_release[.0-9a-z_-]*'
RTKit_release-3514.0.15.release
$ strings -a ftab/acib | grep -o 'RTKit_release[.0-9a-z_-]*'
RTKit_release-3514.0.15.release

Strings in the control processor’s code and in the chip’s loader, _osl, both from our Mac’s package, suggest what the control processor does. They mention starting the radio firmware, keeping its clock matched to the Mac’s through PTM, PCIe’s way of syncing clocks, crash logs, power statistics, and coexistence, the usual term for coordinating Wi-Fi and Bluetooth when both need the air:

$ strings -a ftab/rkos | grep -E '!init ptm|Crashlog for|FLR: could not clear coex boot stage for subsystem %x status'
!init ptm, st %#x
Crashlog for %s
FLR: could not clear coex boot stage for subsystem %x status %x
$ strings -a ftab/_osl | grep -E 'Loading and Starting|powerstats_read_all'
Loading and Starting %s
%s() cca_phm_powerstats_read_all() Error 0x%x ---

FLR is PCIe’s name for resetting a single function. The strings show that the code is there. How the control processor uses it is something only watching it run would show.

On the Mac’s side the control function belongs to centaurid, and here we have more than strings. Our Mac’s log shows centaurid starting the chip and then opening the control channel, channel 1.

What else is in the package

The package holds more than the images from the sections above. Less than half of it is those images. Almost half is manufacturing builds, the images with Manuf in Apple’s names for them, plus two Wi-Fi assert lists, _wfa and _wma. Six of the 13 manufacturing builds are separate images, with names of their own:

awma  Wireless1,ACIWiFiTxManuf
swfm  Wireless1,ScanWifiManuf
w2lm  Wireless1,Wifi2GLMACManuf
w5lm  Wireless1,Wifi5GLMACManuf
wfrm  Wireless1,WiFiRxManuf
wftm  Wireless1,WiFiTxManuf

The other seven are the five Bluetooth images and the two Wi-Fi PHY images again. In the manifest, each of them has the same hash under two names, one ending in Manuf, such as Wifi2GLPhyManuf, and one without, such as Wifi2GLPhy, and the package holds the same bytes twice:

$ for p in acib:acim btse:btsm phyb:pbtm bt5p:bt5m scab:sbtm w2pp:w2pm w5pp:w5pm; do cmp -s ftab/${p%:*} ftab/${p#*:} && echo identical; done | wc -l
       7

By the same pattern, _wma reads as Wi-Fi manufacturing asserts, since Apple’s names for w2lm, wftm and wfrm end in Manuf. The file names that only _wma has include factory and mfg_ code:

$ plutil -convert json -o - ftab/_wma | grep -o '"file":"[^"]*"' | cut -d'"' -f4 | sort -u > wma.txt
$ comm -13 names.txt wma.txt | grep -E '^(factory|mfg)'
factory_efuse_flavor_init.c
factory_otp_export.c
factory_otp_import.c
mfg_trim.c
mfg_trim_loopback.c

Most of the rest is tables of radio settings. wfrd, the Wi-Fi regulatory data in Apple’s manifest, names 30 of the 41 tables and holds the SHA-384 hash of all 41:

$ ls ftab | grep -c -E '^[PRSCW][0-9]{3}$'
41
$ comm -12 <(strings -a ftab/wfrd | grep -o -E '\b[PRSCW][0-9]{3}\b' | sort -u) <(ls ftab | grep -E '^[PRSCW][0-9]{3}$') | wc -l
      30
$ xxd -p ftab/wfrd | tr -d '\n' > wfrd.hex
$ for f in ftab/[PRSCW][0-9][0-9][0-9]; do grep -q $(shasum -a 384 $f | cut -c1-96) wfrd.hex && echo found; done | wc -l
      41

Going by its messages, the chip’s loader, _osl, picks among them. It reads the product variant, which it calls the SKU, and the front-end modules for 2.4 and 5 GHz, the amplifiers and switches between the radio and its antennas. It finds a regulatory table by a hardware ID and takes each file’s digest from a recipe. wfrd holds the tables’ names and digests, so most likely it’s that recipe:

$ strings -a ftab/_osl | grep -E 'using SKU|using FEM|regulatory table for HW|in recipe\.'
Couldn't find digest for %s in recipe. Using digest=0.
Found regulatory table for HW ID[0:63] 0x%llx: %s (%zu)
using SKU %s
using FEM 2G 0x%llx
using FEM 5G 0x%llx

All 16 P tables carry the label Regulatory Power Table, so most likely they hold the transmit power that each country’s rules allow:

$ ls ftab | grep -c '^P'
16
$ grep -a -l 'Regulatory Power Table' ftab/P* | wc -l
      16

We haven’t found what the other 25 tables hold. We can sort the 85 files into those groups by name, each file in exactly one group, and add up their sizes with a few lines of Python:

  5.9 MB  19%  Wi-Fi images
  5.3 MB  17%  Bluetooth and Thread images
  1.8 MB   6%  control processor and loader
  8.0 MB  26%  manufacturing builds
  6.7 MB  22%  two Wi-Fi assert lists
  2.4 MB   8%  radio settings tables
  0.6 MB   2%  other data
Horizontal bars for the members of the 30.6 MB ftab.bin on our Mac, grouped by role. The three functions' firmware images make up 42%, with Wi-Fi images 5.9 MB, Bluetooth and Thread images 5.3 MB, and the control processor and loader 1.8 MB. The rest are manufacturing builds 8.0 MB, two Wi-Fi assert lists 6.7 MB, radio settings tables 2.4 MB, and other data 0.6 MB.
The 85 files of ftab.bin on our Mac, grouped and sized. The first three bars are the firmware images for the three functions.

How the firmware reaches the chip

In the boot we logged, centaurid sent N1 its firmware, the package we split earlier. In its log further down, centaurid records the chip in a state it calls ROM before sending anything, and in one it calls Running once the firmware is in. So most likely the chip starts with only its boot ROM, the small program built into the chip that runs first.

The Preboot volume holds two copies of the package. The one we split is in restore. The other sits deeper, in boot, next to the global manifest we look at below, and the two are the same file:

$ shasum -a 256 /System/Volumes/Preboot/*/restore/Firmware/t2026mac*/Release/ftab.bin /System/Volumes/Preboot/*/boot/*/usr/standalone/firmware/Centauri/ftab.bin
65b0aff14e76e5680b2516126851416fac1e38622d20af2f2d832e0d443e0ec9  /System/Volumes/Preboot/<volume>/restore/Firmware/t2026macG2/Release/ftab.bin
65b0aff14e76e5680b2516126851416fac1e38622d20af2f2d832e0d443e0ec9  /System/Volumes/Preboot/<volume>/boot/<hash>/usr/standalone/firmware/Centauri/ftab.bin
A sequence between centaurid, the macOS service that looks after the chip, on the left and N1 on the right, for one logged boot of our Mac, read from top to bottom. N1 is in the state centaurid calls ROM, most likely running only its boot ROM. centaurid finds the place for a package personalized for this Mac empty, so it attaches the global manifest to the package and adds the Mac's calibration. It sends the result, 30.7 MB, to the boot ROM, sends it again in a second boot stage, and then opens the control channel, channel 1. The figure shows centaurid's side, and the checks on the manifest most likely happen inside the chip.
The boot we logged on our Mac, read from top to bottom. The boxes on the left are steps centaurid takes in macOS, and the arrows are what it sends over the control function. The figure shows centaurid’s side. The checks on the manifest most likely happen inside the chip.

centaurid first looked for a copy of the package personalized for this one Mac, in a fixed place in another hidden volume. Personalized is Apple’s word for firmware it signs for a single device by adding that device’s unique chip ID, so the copy works on that device alone. That place was empty, so it used the package and the global manifest. It added the manifest to the package itself, as the no manifest found in ftab and stitching global manifest lines below show. The global manifest is an Apple-signed list of the images the package may contain, written for this board as a whole. centaurid added the Mac’s factory calibration and sent the result to the boot ROM through a separate boot driver. In a second boot stage it sent the same image again. Only then did it open the control channel, channel 1, which the log calls CCHI, and ping the chip. The chip answered with its RTKit version, the same one as in rkos. centaurid closed that channel, opened it again for normal use, and marked the chip as running. These are its log lines for those steps, in our Mac’s current boot. The predicate leaves out a second copy of the first lines, logged by Apple’s firmware-update library:

$ log show --last boot --style compact --predicate 'process == "centaurid" AND subsystem != "com.apple.AppleConvergedFirmwareUpdater"' | grep -E 'chipStateDidChangeFrom|copyDefaultPersonalizedFirmwarePath|isPersonalizedFirmwarePresent|isUnsignedFirmwarePresent|no manifest found|stitching|found FDR|sendImage: (image size|successfully)|opening boot driver for secondary|opening CCHI|interface_id=|pingCheck: success|CCHIInterface::start'
2026-10-18 10:29:04.750 Df centaurid[101:a7c] [com.apple.centaurid:default] ChipManager::chipStateDidChangeFrom:to:: Unknown -> ROM
2026-10-18 10:29:04.752 Df centaurid[101:a7c] [com.apple.centaurid:default] ACFUBootInterface::copyDefaultPersonalizedFirmwarePath: result: /System/Volumes/iSCPreboot/SFR/current/Centauri//ftab.bin
2026-10-18 10:29:04.752 Df centaurid[101:a7c] [com.apple.centaurid:default] ACFUBootInterface::isPersonalizedFirmwarePresentInDefaultLocation: result: No
2026-10-18 10:29:04.752 Df centaurid[101:a7c] [com.apple.centaurid:default] ACFUBootInterface::isUnsignedFirmwarePresent: found unsigned firmware and global manifest
2026-10-18 10:29:04.765 E  centaurid[101:a7c] [com.apple.centaurid:default] ACFUFTABFile::copyManifest: no manifest found in ftab
2026-10-18 10:29:04.802 Df centaurid[101:a7c] [com.apple.centaurid:default] ACFUBootInterface::setGlobalManifestInFirmware: stitching global manifest
2026-10-18 10:29:04.854 Df centaurid[101:a7c] [com.apple.centaurid:default] CentauriFirmware::copyCalibrationData: found FDR calibration data: BWC3
2026-10-18 10:29:04.881 Df centaurid[101:a7c] [com.apple.centaurid:default] CentauriTransport::sendImage: image size 30690710 -> 30691328
2026-10-18 10:29:04.927 Df centaurid[101:a7c] [com.apple.centaurid:default] CentauriTransport::sendImage: successfully sent image: out code 0x00000001
2026-10-18 10:29:04.927 Df centaurid[101:a7c] [com.apple.centaurid:default] CentauriTransport::openBootDriver: opening boot driver for secondary boot stage
2026-10-18 10:29:05.348 Df centaurid[101:a7c] [com.apple.centaurid:default] CentauriTransport::sendImage: image size 30690710 -> 30691328
2026-10-18 10:29:06.289 Df centaurid[101:a7c] [com.apple.centaurid:default] CentauriTransport::sendImage: successfully sent image: out code 0x00000001
2026-10-18 10:29:06.295 Df centaurid[101:a7c] [com.apple.centaurid:default] CentauriTransport::openInterfaceDriver: opening CCHI interface driver
2026-10-18 10:29:06.295 Df centaurid[101:a7c] (AirshipCentauriHelper) Airship [interface.cpp:275:open] interface_id=1, timeout=15000 ms
2026-10-18 10:29:06.782 Df centaurid[101:a7c] [com.apple.centaurid:default] CentauriTransport::pingCheck: success: 'RTKit_release-3514.0.15.release RTKitOSPlatform-3431.0.25~002852, CentauriControlApp-b0-2.1.54~1130'
2026-10-18 10:29:06.787 Df centaurid[101:a7c] [com.apple.centaurid:default] CCHIInterface::start::
2026-10-18 10:29:06.787 Df centaurid[101:a7c] (AirshipCentauriHelper) Airship [interface.cpp:275:open] interface_id=1, timeout=5000 ms
2026-10-18 10:29:06.806 Df centaurid[101:a7c] [com.apple.centaurid:default] ChipManager::chipStateDidChangeFrom:to:: ROM -> Running

The global manifest sits beside the second copy of the package, with two variants marked dev and insecure, most likely for chips used in development. It names the chip it’s for, CHIP 8230, which is 0x2026, the t2026 in the package’s folder name. It’s signed with a certificate named for T2026 and lists 38 files, as many as the build manifest named. One of them is wfrd, with the hash of the same bytes we split, so through wfrd the signature also covers the 41 tables:

$ cp /System/Volumes/Preboot/*/boot/*/usr/standalone/firmware/Centauri/*.im4m .
$ ls *.im4m
centauri.j873gap.dev.im4m
centauri.j873gap.im4m
centauri.j873gap.insecure.im4m
$ ipsw img4 im4m info centauri.j873gap.im4m | grep -E '^      CHIP |Images:|Subject:'
      CHIP (ApChipID): 8230
  Images: 38
    Subject: T2026-SDOM1-TssLive-ManifestKey-Global-RevA-DC
$ ipsw img4 im4m info centauri.j873gap.im4m | grep -A1 '^    wfrd:'
    wfrd:
      DGST: 3c9a234a520b291cb23ab0955c0e7001b35d5f892be0e9420c5903122a36b704a27ee6a46c6f9d8b6f9d01f796566399
$ shasum -a 384 ftab/wfrd
3c9a234a520b291cb23ab0955c0e7001b35d5f892be0e9420c5903122a36b704a27ee6a46c6f9d8b6f9d01f796566399  ftab/wfrd

We found no code in centaurid, or in CentauriBooter, the library that builds what’s sent, that checks the manifest’s signature. centaurid imports none of Apple’s Img4 functions, which read and check manifests like this one, and CentauriBooter imports only two, which read a manifest’s fields:

$ dyld_info -imports /usr/libexec/centaurid | grep -c -i img4
0
$ dyld_info -imports /System/Library/PrivateFrameworks/CentauriBooter.framework/CentauriBooter | grep -i img4
      _Img4DecodeGetObjectPropertyData  (from libamsupport)
      _Img4DecodeInitManifest  (from libamsupport)

centaurid finds the manifest, stitches it in and sends it, and the checks are N1’s. Its loader, _osl, has messages for checking the manifest and each image’s digest:

$ strings -a ftab/_osl | grep -E 'Manifest (not valid|Hash Valid)|^Digest missing|VALIDATED'
Warning: Manifest not valid
ROM Manifest Hash Valid
Manifest Hash Validated but missing digest
Digest missing or check failed (%s)
FIRMWARE %s VALIDATED

ROM Manifest Hash Valid most likely reports a check by the boot ROM, which runs first.

The loader also has code for ASLR, address space layout randomization, which moves the radio firmware images to a random address as it loads them. An exploit for a memory-corruption bug in the firmware typically overwrites a pointer, such as a return address on the stack or a function pointer, with the address of code it wants to run. ASLR changes those addresses from one load to the next, so the exploit can’t hard-code them and first needs a second bug that leaks one. The loader logs each segment it adjusts for the new address, and the smallest and largest slide, the random offset an image moves by. The control processor’s code, rkos, has two settings for ASLR and logs the slide as well:

$ strings -a ftab/_osl | grep -E 'ASLR patching segment|aslr slide'
Maximum aslr slide: 0x%x
Minimum aslr slide: 0x%x
ASLR patching segment: %s
$ strings -a ftab/rkos | grep -E '^aslr_|ASLR Slide'
aslr_fw_mask
aslr_mode
ASLR Slide: 0x%016X

When Project Zero exploited the Broadcom Wi-Fi chip in the iPhone 7 in 2017, its firmware did “not employ ASLR”.

Further reading

Next: part 2, Speaking Bluetooth to N1.