VM: macOS-Sequoia (i-2-2657-VM, UUID 00b13b9f-35ad-4d0c-a451-b1ffb3782e0d)
Host: ringleader (192.168.46.145)
Disk (active layer): fac18bc1-3fb7-4d2c-b6c2-6e6043cfd71a
Disk (base): 3e4a6d36-83a3-43ab-84d7-34d2c6fd1dad
VNC listens on 192.168.46.10 (cloudbr0), not the physical NIC. SSH tunnel required:
ssh -i ~/.ssh/ringleader_key -L 5912:192.168.46.10:5912 rhettsaunders@192.168.46.145 -NThen connect VNC to localhost:5912, password: BGnNR3Rr
Port may change after each VM restart. Check with:
sudo ss -tlnp | grep qemuthen match viasudo virsh dumpxml i-2-2657-VM | grep "graphics type"
Always use CloudStack API — never virsh directly (causes state desync):
# Stop
sudo cmk stop virtualmachine id=00b13b9f-35ad-4d0c-a451-b1ffb3782e0d forced=true
# Start
sudo cmk start virtualmachine id=00b13b9f-35ad-4d0c-a451-b1ffb3782e0dmacOS 12.3+ stores libSystem.B.dylib inside an OS Cryptex DMG on the Preboot
APFS volume. On KVM, the cryptex signature check fails — launchd panics on every
boot with Library not loaded: /usr/lib/libSystem.B.dylib. macOS appeared to show
a login screen (EFI framebuffer) but was actually dead.
Fix: Add amfi_get_out_of_my_way=1 to OpenCore boot-args. This tells AMFI
to skip signature verification so the cryptex mounts and launchd starts cleanly.
Three separate root causes all present simultaneously:
a) DisableIoMapper was False
Without DisableIoMapper=True in OpenCore Kernel quirks, Apple's IOMapper blocks
USB DMA on KVM hosts that don't expose a real IOMMU. USB controller initializes
but devices are never enumerated.
b) Wrong kexts — UTBDefault is a placeholder only
UTBDefault.kext uses IOPCIClassMatch to attach USBToolBox to XHCI controllers,
but USBToolBox then requires a separately generated UTBMap.kext with the actual
port mapping. Without it, no ports are mapped and USB HID never loads. Fix: replace
with USBInjectAll.kext which auto-enumerates QEMU XHCI ports without manual mapping.
c) Missing QEMU global flags
Without these two flags, USB devices behind PCIe root ports are invisible to macOS:
-global nec-usb-xhci.msi=off— prevents MSI assertion crash in QEMU USB stack-global ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off— required for macOS to enumerate USB through PCIe bridges on q35
These are injected via the CloudStack hook at /etc/cloudstack/agent/hooks/macos-transform.py.
d) VoodooPS2Controller conflicts with USB HID
Remove it. QEMU's sendkey injects into PS/2 (i8042), not USB — so sendkey not
working in macOS is expected and not a diagnostic signal when using USB input.
| # | Kext | Purpose |
|---|---|---|
| 1 | Lilu.kext |
Dependency for patching kexts |
| 2 | USBInjectAll.kext |
Auto-maps QEMU XHCI ports |
| 3 | VirtualSMC.kext |
Apple SMC emulation |
| 4 | MCEReporterDisabler.kext |
Suppresses MCA error spam |
Disabled (do not re-enable):
VoodooPS2Controller— conflicts with USB HID, PS/2 not usedUTBDefault/USBToolBox— placeholder only, needs UTBMap (not generated)USBPorts.kext— targets nec-xhci device ID (0x01941033), wrong for qemu-xhciWhateverGreen.kext— unnecessary for vmware-svga virtual GPU, causes black screenCryptexFixup.kext— conflicts withamfi_get_out_of_my_way=1
keepsyms=1 -v amfi_get_out_of_my_way=1
DisableIoMapper: True ← critical for USB on KVM without real IOMMU
DisableLinkeditJettison: True
PanicNoKextDump: True
PowerTimeoutKernelPanic: True
ProvideCurrentCpuInfo: True
XhciPortLimit: False ← must stay False (causes crash on macOS 11.3+)
Transforms CloudStack libvirt XML before QEMU starts. Does:
- CPU →
host-passthrough(required for macOS) - KVM hidden →
<kvm><hidden state='on'/></kvm>(hides hypervisor from macOS) - SMBIOS →
iMac19,1Apple branding - Injects
qemu-xhciat PCIbus=0x03 slot=0x00(explicit address mandatory) - Injects
<input type='keyboard' bus='usb'/>(CloudStack only adds tablet) - Injects
isa-applesmcwith OSK string viaqemu:commandline - Injects
-global nec-usb-xhci.msi=off← keyboard/mouse fix - Injects
-global ICH9-LPC.acpi-pci-hotplug-with-bridge-support=off← keyboard/mouse fix
VM must be stopped first. Always mount the active layer, never the base:
sudo qemu-nbd -c /dev/nbd2 /export/primary/fac18bc1-3fb7-4d2c-b6c2-6e6043cfd71a
sleep 2
sudo fsck.fat -a /dev/nbd2p1 # fix dirty bit — always required after unclean unmount
sudo mount /dev/nbd2p1 /tmp/efi
# ... edit /tmp/efi/EFI/OC/config.plist, Kexts/, ACPI/ ...
sudo sync
sudo umount /tmp/efi
sudo qemu-nbd --disconnect /dev/nbd2For APFS system volume (macOS logs, diagnostics):
sudo /tmp/apfs-fuse/build/apfs-fuse -o allow_other /dev/nbd2p2 /tmp/macos
# Preboot volume (cryptex, EFI boot files):
sudo /tmp/apfs-fuse/build/apfs-fuse -v 1 /dev/nbd2p2 /tmp/macos_prebootapfs-fuse binary lives at
/tmp/apfs-fuse/build/apfs-fuse— rebuilt from source each session since /tmp is cleared on reboot.
LESSONS_LEARNED.md — full troubleshooting history, dead ends, and explanations
If the OVMF NVRAM is ever corrupted or reset, restore from backup:
sudo cp /home/rhettsaunders/macos-vm-config/OVMF_VARS_WORKING.fd \
/var/lib/libvirt/qemu/nvram/00b13b9f-35ad-4d0c-a451-b1ffb3782e0d.fd
sudo chmod 600 /var/lib/libvirt/qemu/nvram/00b13b9f-35ad-4d0c-a451-b1ffb3782e0d.fdIf backup is lost, recreate with uefivars:
pip3 install uefivars
NVRAM=/var/lib/libvirt/qemu/nvram/00b13b9f-35ad-4d0c-a451-b1ffb3782e0d.fd
sudo cp /usr/share/OVMF/OVMF_VARS_4M.fd $NVRAM
sudo uefivars -i edk2 -o json -I $NVRAM > /tmp/nvram.json
python3 -c "
import json, struct
with open('/tmp/nvram.json') as f: data = json.load(f)
data['variables'].append({'guid':'7235c51c-0c80-4cab-87ac-3b084a6304b1','name':'Setup','attr':7,'data':struct.pack('<II',1920,1080).hex()})
with open('/tmp/nvram_fixed.json','w') as f: json.dump(data,f)
"
sudo uefivars -i json -o edk2 -I /tmp/nvram_fixed.json -O $NVRAMCRITICAL: cmk update virtualmachine details[0].X=Y REPLACES the entire details map.
Always include ALL 8 required details in every update call:
sudo cmk update virtualmachine id=00b13b9f-35ad-4d0c-a451-b1ffb3782e0d \
"details[0].boot.mode=LEGACY" \
"details[0].dataDiskController=sata" \
"details[0].firmware=UEFI" \
"details[0].kvm.guest.os.machine.type=pc-q35-noble" \
"details[0].nicAdapter=Virtio" \
"details[0].rootDiskController=sata" \
"details[0].smc.present=true" \
"details[0].video.hardware=vmvga"| File | Purpose |
|---|---|
macos-transform.py |
Working hook script (deployed to /etc/cloudstack/agent/hooks/) |
OVMF_VARS_WORKING.fd |
Working NVRAM backup with 1920x1080 resolution set |
LESSONS_LEARNED.md |
Full troubleshooting history and configuration notes |
README.md |
Quick reference (this file) |
libvirt-vm-xml-transformer.groovy |
CloudStack Groovy hook (UEFI/Q35/macOS) |
libvirt-vm-xml-transformer.sh |
Shell wrapper (routes macOS VM to Python) |
macos_q35.xml |
Reference libvirt XML |
macos_transform_input.xml |
Captured CloudStack XML input |
USBToolBox.kext/ |
USB controller driver (archive — replaced by USBInjectAll) |
UTBDefault.kext/ |
Codeless kext (archive — replaced by USBInjectAll) |
VoodooPS2Controller.kext/ |
PS/2 driver (archive — removed, conflicts with USB HID) |
With ShowPicker=False, OpenCore boots the FIRST entry it finds, which is the EFI
partition (not macOS). This causes a black screen or UEFI shell on every boot.
Set Misc -> Security -> ScanPolicy to a bitmask that allows APFS + SATA but
excludes OC_SCAN_ALLOW_FS_ESP (0x400):
ScanPolicy = 0x10103 = 65795
0x1 OC_SCAN_FILE_SYSTEM_LOCK (restrict to defined FS)
0x2 OC_SCAN_DEVICE_LOCK (restrict to defined device types)
0x100 OC_SCAN_ALLOW_FS_APFS
0x10000 OC_SCAN_ALLOW_DEVICE_SATA
With this, OpenCore never sees the EFI partition as bootable — macOS is the only entry and boots directly.
ShowPicker: False
Timeout: 0
ScanPolicy: 65795
PollAppleHotKeys: True (hold Option key to get picker for troubleshooting)
AllowSetDefault: True
| Bit | Value | Meaning |
|---|---|---|
| 0 | 0x1 | FS lock — restrict to defined filesystems |
| 1 | 0x2 | Device lock — restrict to defined device types |
| 8 | 0x100 | Allow APFS |
| 9 | 0x200 | Allow HFS+ |
| 10 | 0x400 | Allow ESP (EFI System Partition) — EXCLUDE THIS |
| 16 | 0x10000 | Allow SATA |
| 19 | 0x80000 | Allow NVMe |
| 21 | 0x200000 | Allow USB |
NEVER use uefivars export/import to change resolution — it corrupts the NVRAM.
Instead, binary-patch the 8-byte resolution struct directly:
# Example: change 1920x1080 to 1440x900
import struct
data = open(NVRAM_PATH, "rb").read()
old = struct.pack("<II", 1920, 1080) # 80 07 00 00 38 04 00 00
new = struct.pack("<II", 1440, 900) # A0 05 00 00 84 03 00 00
idx = data.find(old)
if idx >= 0:
data = data[:idx] + new + data[idx+8:]
open(NVRAM_PATH, "wb").write(data)| VRAM | Result |
|---|---|
| 16MB (default) | Works, no wallpaper |
| 64MB | Works, wallpaper renders |
| 128MB | Works |
| 512MB | Works (current) |
| 1GB | Black screen — too large for vmware-svga |
- vCPUs: 8 (Large Instance, Xeon Gold 6132)
- RAM: 16GB
- VRAM: 512MB
- Resolution: 1280x768
- Service Offering: e24a4e1e-1a3e-4571-bbd5-7aab3f694633 (Large Instance)
When changing service offerings (CPU count), update the hook's topology:
"<topology sockets='1' dies='1' cores='8' threads='1'/>"Mismatch causes "Unable to create deployment" error.