From 4532de19f248f80edd346519ab668e7635f5d181 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Wed, 10 Jun 2026 11:38:54 +0100 Subject: [PATCH 1/2] =?UTF-8?q?lab:=20M3=20dext=20bring-up=20=E2=80=94=20Z?= =?UTF-8?q?TS=20timer=20chain,=20packet=20pump,=20verifier=20under=20real?= =?UTF-8?q?=20pacing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Milestone 3 dext clock model per the lab README: - IOTimerDispatchSource on the work queue fires once per ZTS period (512 frames @ 48k) in kIOTimerClockMachAbsoluteTime; each fire anchors UpdateCurrentZeroTimestamp(n*period, fire_time) with raw values — the deadline chain is nominal (derived from the period index via mach_timebase_info, drift-free), host times are actual fire times, the host smooths via the clock algorithm. No driver-side extrapolation. - The same fire exposes the next period's packets through the controller (the lab analog of an IT-ring refill interrupt): hardware requests data on its interrupt; WriteEnd only fills PCM into already-exposed packets. - Step 6 Verifying(Fake) + StickyCounterSink run for the whole IO session; StopIO dumps verifier counters, payload miss buckets, and the O/C instrumentation via IOLog (ADKLab[dump] prefix, never from the IO path). - O/C instrumentation: anchors before first WriteEnd (C1), WriteEnd shape stats — min/max io size, sample-time continuity, first-callback tuple (C3), io/timer fires after StopIO counted not crashed (O2, logged at free), other-op counter, prepare-failure counter. - SetTransportType(FireWire) — the contract ASFW will live under. - Output ring widened to 8 ZTS periods (4096 frames, matches the host scenario pump); a 1-period ring left the HAL zero headroom (C4 stays observable by varying kRingPeriods). - Info.plist: match on IOUserResources + IOResourceMatch IOKit (a hardware-free dext cannot match IOUserService as provider — it would never start); non-empty OSBundleUsageDescription. All APIs verified against the DriverKit 25.2 SDK headers (IOLib.h mach_timebase_info/mach_absolute_time, IOTimerDispatchSource.iig, IOUserAudioClockDevice.iig). Dext + host tool targets build; host suite 17440 checks, 0 failures. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Driver/VirtualAudioDevice.cpp | 387 +++++++++++++++++- .../Driver/VirtualAudioDevice.iig | 7 + ADKVirtualAudioLab/Info.plist | 6 +- 3 files changed, 377 insertions(+), 23 deletions(-) diff --git a/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp b/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp index 7a6d7f51..7cc93f5c 100644 --- a/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp +++ b/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp @@ -1,23 +1,108 @@ #include +#include #include #include #include +#include #include "VirtualAudioDevice.h" #include "../Core/VirtualAudioDeviceController.hpp" +#include "../Lab/StickyCounterSink.hpp" +#include "../Lab/VerifyingSlotProvider.hpp" using namespace ASFW::Driver; +// M3 dext clock model (see ../README.md, "Key design decisions" and +// Milestone 3): +// +// - The device is its own clock master. An IOTimerDispatchSource on the work +// queue fires once per ZTS period (512 frames at 48 kHz = 10.667 ms) in the +// kIOTimerClockMachAbsoluteTime timebase — the same clock CoreAudio host +// times use. Each fire anchors UpdateCurrentZeroTimestamp(n * period, +// fire_time) with RAW values: the deadline chain is nominal (computed from +// the period index, never from the previous fire), the host time is the +// actual fire time, and the host smooths via the clock algorithm. No +// driver-side extrapolation (the model RE'd from the Saffire kext). +// - The same timer fire exposes the next period's packets through the +// controller (PrepareLabPacket) — the lab's stand-in for the OHCI IT ring +// interrupt: "hardware" requests data on its interrupt; WriteEnd only fills +// PCM into already-exposed packets. +// - The Verifying(Fake) decorator from Step 6 sits between the engine and the +// fake ring for the whole run; StopIO dumps its sticky counters plus the +// O/C instrumentation via IOLog (never from the IO callback). +// - O2 instrumentation: the IO block keeps the SDK-documented raw ivars +// capture; ioRunning gates it and late fires are counted instead of +// crashing, so the lifecycle question is answered with a counter. + +namespace { + +constexpr uint32_t kSampleRate = 48000; +constexpr uint32_t kRingPeriods = 8; // ring = 8 ZTS periods (4096 frames) +constexpr uint64_t kZtsPeriodNsNumer = 32000000ull; // 512/48000 s = 32e6/3 ns +constexpr uint64_t kZtsPeriodNsDenom = 3ull; +constexpr uint32_t kMaxPreparePerCall = 512; // runaway guard for the pump + +struct LabTimebase final { + uint32_t numer{1}; + uint32_t denom{1}; + + uint64_t NsToTicks(uint64_t ns) const noexcept { + return (numer == denom) ? ns : (ns * denom) / numer; + } +}; + +// Nominal nanoseconds elapsed after n ZTS periods (exact thirds, no +// accumulated rounding: computed from n, not incrementally). +inline uint64_t NsForPeriodIndex(uint64_t n) noexcept { + return (n * kZtsPeriodNsNumer) / kZtsPeriodNsDenom; +} + +} // namespace + struct VirtualAudioDevice_IVars { OSSharedPtr driver; OSSharedPtr workQueue; OSSharedPtr outputStream; OSSharedPtr outputMemoryMap; + OSSharedPtr ztsTimer; + OSSharedPtr ztsTimerAction; VirtualAudioDeviceController* controller{nullptr}; + ASFW::Lab::VerifyingSlotProvider* verifier{nullptr}; + ASFW::Lab::StickyCounterSink* diagSink{nullptr}; uint32_t outputBytesPerFrame{0}; uint32_t outputChannels{0}; + uint32_t ringFrames{0}; + + LabTimebase timebase{}; + + // Clock chain state (work-queue confined). + uint64_t startHostTime{0}; + uint64_t periodIndex{0}; + + // Packet pump state (work-queue confined). + uint32_t nextPacketIndex{0}; + uint64_t exposedFrames{0}; + uint64_t prepareFailures{0}; + + // Lifecycle gate + O/C instrumentation (IO callback is a real-time + // thread: relaxed atomics only, no logging, no allocation). + std::atomic ioRunning{false}; + std::atomic anchorsPublished{0}; + std::atomic anchorsBeforeFirstWriteEnd{0}; + std::atomic writeEndCount{0}; + std::atomic framesDelivered{0}; + std::atomic minIoFrames{0xFFFFFFFFu}; + std::atomic maxIoFrames{0}; + std::atomic sampleTimeBreaks{0}; + std::atomic expectedNextSampleTime{0}; + std::atomic expectedSampleTimeValid{false}; + std::atomic firstWriteEndSampleTime{0}; + std::atomic firstWriteEndHostTime{0}; + std::atomic otherIoOperations{0}; + std::atomic ioAfterStop{0}; // O2: WriteEnd after StopIO + std::atomic timerAfterStop{0}; // O2: timer fire after StopIO }; bool VirtualAudioDevice::init(IOUserAudioDriver* in_driver, @@ -30,33 +115,51 @@ bool VirtualAudioDevice::init(IOUserAudioDriver* in_driver, if (!super::init(in_driver, in_supports_prewarming, in_device_uid, in_model_uid, in_manufacturer_uid, in_zero_timestamp_period)) { return false; } - + ivars = IONewZero(VirtualAudioDevice_IVars, 1); if (ivars == nullptr) { return false; } - + ivars->driver = OSSharedPtr(in_driver, OSRetain); ivars->workQueue = GetWorkQueue(); - + IOLog("VirtualAudioDevice: init\n"); - + + mach_timebase_info_data_t timebaseInfo{}; + if (mach_timebase_info(&timebaseInfo) == KERN_SUCCESS && + timebaseInfo.numer != 0 && timebaseInfo.denom != 0) { + ivars->timebase.numer = timebaseInfo.numer; + ivars->timebase.denom = timebaseInfo.denom; + } + ivars->controller = new VirtualAudioDeviceController(); if (!ivars->controller->Initialize()) { IOLog("VirtualAudioDevice: Failed to initialize controller\n"); return false; } + // Step 6 instrument under real pacing: Verifying(Fake) for the whole run. + ivars->diagSink = new ASFW::Lab::StickyCounterSink(); + ivars->verifier = new ASFW::Lab::VerifyingSlotProvider( + ivars->controller->FakeSlotProvider(), + ASFW::Lab::VerifyingSlotProvider::Config{true, 0x02, ivars->diagSink}); + ivars->controller->BindLabSlotProvider(ivars->verifier); + // Pick Saffire for testing ASFW::Protocols::Audio::DICE::DiceDeviceIdentity identity{}; identity.vendorId = 0x00130e; // Focusrite ivars->controller->SelectProfile(identity); + // The lab device reports as a FireWire-transport clock device — that is + // the contract ASFW will live under (AudioDriverKitTypes.h '1394'). + SetTransportType(IOUserAudioTransportType::FireWire); + // Set up a basic float32 output stream (2 channels, 48kHz) - double sampleRate = 48000.0; + double sampleRate = kSampleRate; SetAvailableSampleRates(&sampleRate, 1); SetSampleRate(sampleRate); - + IOUserAudioStreamBasicDescription format = { .mSampleRate = sampleRate, .mFormatID = IOUserAudioFormatID::LinearPCM, @@ -67,12 +170,17 @@ bool VirtualAudioDevice::init(IOUserAudioDriver* in_driver, .mChannelsPerFrame = 2, .mBitsPerChannel = 32 }; - + ivars->outputBytesPerFrame = format.mBytesPerFrame; ivars->outputChannels = format.mChannelsPerFrame; + // Ring = 8 ZTS periods. A one-period ring leaves the HAL zero headroom + // around the wrap anchor; 8 matches the host scenario pump (4096 frames). + // C4 (period vs IO buffer size coupling) stays observable by varying this. + ivars->ringFrames = kRingPeriods * in_zero_timestamp_period; + OSSharedPtr buffer; - uint32_t bufferSize = in_zero_timestamp_period * format.mBytesPerFrame; + uint32_t bufferSize = ivars->ringFrames * format.mBytesPerFrame; if (IOBufferMemoryDescriptor::Create(kIOMemoryDirectionInOut, bufferSize, 0, buffer.attach()) == kIOReturnSuccess) { ivars->outputStream = IOUserAudioStream::Create(in_driver, IOUserAudioStreamDirection::Output, buffer.get()); if (ivars->outputStream) { @@ -81,11 +189,29 @@ bool VirtualAudioDevice::init(IOUserAudioDriver* in_driver, AddStream(ivars->outputStream.get()); } } - - ivars->controller->ConfigureOutputStream(48000, 2, in_zero_timestamp_period); - + + ivars->controller->ConfigureOutputStream(kSampleRate, ivars->outputChannels, + ivars->ringFrames); + + // The ZTS heartbeat timer (armed in StartIO). + IOTimerDispatchSource* timer = nullptr; + if (IOTimerDispatchSource::Create(ivars->workQueue.get(), &timer) == kIOReturnSuccess) { + ivars->ztsTimer = OSSharedPtr(timer, OSNoRetain); + OSAction* action = nullptr; + if (CreateActionZtsTimerOccurred(0, &action) == kIOReturnSuccess) { + ivars->ztsTimerAction = OSSharedPtr(action, OSNoRetain); + ivars->ztsTimer->SetHandler(ivars->ztsTimerAction.get()); + } else { + IOLog("VirtualAudioDevice: failed to create ZTS timer action\n"); + return false; + } + } else { + IOLog("VirtualAudioDevice: failed to create ZTS timer\n"); + return false; + } + auto ivarsPtr = ivars; - + auto io_operation = ^kern_return_t(IOUserAudioObjectID in_device, IOUserAudioIOOperation in_io_operation, uint32_t in_io_buffer_frame_size, @@ -93,6 +219,40 @@ bool VirtualAudioDevice::init(IOUserAudioDriver* in_driver, uint64_t in_host_time) { if (in_io_operation == IOUserAudioIOOperationWriteEnd) { + if (!ivarsPtr->ioRunning.load(std::memory_order_relaxed)) { + ivarsPtr->ioAfterStop.fetch_add(1, std::memory_order_relaxed); + } + + // C3 shape instrumentation (RT-safe: counters only). + const uint64_t count = + ivarsPtr->writeEndCount.fetch_add(1, std::memory_order_relaxed); + if (count == 0) { + ivarsPtr->firstWriteEndSampleTime.store(in_sample_time, + std::memory_order_relaxed); + ivarsPtr->firstWriteEndHostTime.store(in_host_time, + std::memory_order_relaxed); + } + ivarsPtr->framesDelivered.fetch_add(in_io_buffer_frame_size, + std::memory_order_relaxed); + if (in_io_buffer_frame_size < + ivarsPtr->minIoFrames.load(std::memory_order_relaxed)) { + ivarsPtr->minIoFrames.store(in_io_buffer_frame_size, + std::memory_order_relaxed); + } + if (in_io_buffer_frame_size > + ivarsPtr->maxIoFrames.load(std::memory_order_relaxed)) { + ivarsPtr->maxIoFrames.store(in_io_buffer_frame_size, + std::memory_order_relaxed); + } + if (ivarsPtr->expectedSampleTimeValid.load(std::memory_order_relaxed) && + ivarsPtr->expectedNextSampleTime.load(std::memory_order_relaxed) != + in_sample_time) { + ivarsPtr->sampleTimeBreaks.fetch_add(1, std::memory_order_relaxed); + } + ivarsPtr->expectedNextSampleTime.store( + in_sample_time + in_io_buffer_frame_size, std::memory_order_relaxed); + ivarsPtr->expectedSampleTimeValid.store(true, std::memory_order_relaxed); + if (ivarsPtr->controller && ivarsPtr->outputMemoryMap) { float* floatBuffer = reinterpret_cast(ivarsPtr->outputMemoryMap->GetAddress() + ivarsPtr->outputMemoryMap->GetOffset()); uint32_t ringFrames = static_cast(ivarsPtr->outputMemoryMap->GetLength() / ivarsPtr->outputBytesPerFrame); @@ -108,18 +268,34 @@ bool VirtualAudioDevice::init(IOUserAudioDriver* in_driver, ivarsPtr->controller->SubmitWriteEnd(outputView); } + } else { + ivarsPtr->otherIoOperations.fetch_add(1, std::memory_order_relaxed); } return kIOReturnSuccess; }; - + SetIOOperationHandler(io_operation); - + return true; } void VirtualAudioDevice::free() { if (ivars != nullptr) { + // O2 answer at teardown: how many callbacks arrived after StopIO. + IOLog("ADKLab[free] io_after_stop=%llu timer_after_stop=%llu\n", + ivars->ioAfterStop.load(std::memory_order_relaxed), + ivars->timerAfterStop.load(std::memory_order_relaxed)); + + if (ivars->ztsTimer) { + ivars->ztsTimer->Cancel(^{}); + } + if (ivars->verifier) { + delete ivars->verifier; + } + if (ivars->diagSink) { + delete ivars->diagSink; + } if (ivars->controller) { delete ivars->controller; } @@ -127,40 +303,209 @@ void VirtualAudioDevice::free() ivars->workQueue.reset(); ivars->outputStream.reset(); ivars->outputMemoryMap.reset(); + ivars->ztsTimer.reset(); + ivars->ztsTimerAction.reset(); } IOSafeDeleteNULL(ivars, VirtualAudioDevice_IVars, 1); super::free(); } +// Expose packets until the timeline covers targetFrames (work-queue only). +// The lab analog of refilling the IT DMA ring: structurally valid packets +// (silence until audio arrives) published through Verifying(Fake). +static void PrepareCoverage(VirtualAudioDevice_IVars* ivars, uint64_t targetFrames) +{ + uint32_t prepared = 0; + while (ivars->exposedFrames < targetFrames && prepared < kMaxPreparePerCall) { + if (!ivars->controller->PrepareLabPacket(ivars->nextPacketIndex, 0xFFFF, + false)) { + ++ivars->prepareFailures; + return; + } + const auto* published = + ivars->controller->FakeSlotProvider().PublishedPacket( + ivars->nextPacketIndex); + if (published != nullptr && published->isData) { + ivars->exposedFrames += published->framesInPacket; + } + ++ivars->nextPacketIndex; + ++prepared; + } +} + +void VirtualAudioDevice::ZtsTimerOccurred_Impl(OSAction* action, uint64_t time) +{ + if (ivars == nullptr) { + return; + } + if (!ivars->ioRunning.load(std::memory_order_relaxed)) { + ivars->timerAfterStop.fetch_add(1, std::memory_order_relaxed); + return; // do not re-arm + } + + // Anchor with RAW values: nominal sample position, actual fire time. + const uint64_t sampleTime = ivars->periodIndex * GetZeroTimestampPeriod(); + UpdateCurrentZeroTimestamp(sampleTime, time); + ivars->anchorsPublished.fetch_add(1, std::memory_order_relaxed); + if (ivars->writeEndCount.load(std::memory_order_relaxed) == 0) { + ivars->anchorsBeforeFirstWriteEnd.fetch_add(1, std::memory_order_relaxed); + } + + // The "hardware" requests the next period's data: keep the exposed + // timeline one ring-wrap ahead of where the HAL will write. + PrepareCoverage(ivars, sampleTime + 2ull * GetZeroTimestampPeriod()); + + // Drift-free nominal chain: the next deadline comes from the period + // index, never from the (jittered) previous fire time. + ivars->periodIndex += 1; + const uint64_t deadline = + ivars->startHostTime + + ivars->timebase.NsToTicks(NsForPeriodIndex(ivars->periodIndex)); + const uint64_t leeway = ivars->timebase.NsToTicks(500000); // 0.5 ms + ivars->ztsTimer->WakeAtTime(kIOTimerClockMachAbsoluteTime, deadline, leeway); +} + kern_return_t VirtualAudioDevice::StartIO(IOUserAudioStartStopFlags in_flags) { IOLog("VirtualAudioDevice: StartIO\n"); - + __block kern_return_t kr = kIOReturnSuccess; ivars->workQueue->DispatchSync(^(){ kr = super::StartIO(in_flags); - if (kr == kIOReturnSuccess && ivars->outputStream) { + if (kr != kIOReturnSuccess) { + return; + } + if (ivars->outputStream) { auto buffer = ivars->outputStream->GetIOMemoryDescriptor(); if (buffer) { buffer->CreateMapping(0, 0, 0, 0, 0, ivars->outputMemoryMap.attach()); } - if (ivars->controller) { - ivars->controller->ResetTransportLab(0, 0); - } + } + if (ivars->controller) { + ivars->controller->ResetTransportLab(0, 0); + } + if (ivars->verifier) { + ivars->verifier->Reset(); + } + + // Reset pump + instrumentation for this run. + ivars->nextPacketIndex = 0; + ivars->exposedFrames = 0; + ivars->prepareFailures = 0; + ivars->periodIndex = 0; + ivars->anchorsPublished.store(0, std::memory_order_relaxed); + ivars->anchorsBeforeFirstWriteEnd.store(0, std::memory_order_relaxed); + ivars->writeEndCount.store(0, std::memory_order_relaxed); + ivars->framesDelivered.store(0, std::memory_order_relaxed); + ivars->minIoFrames.store(0xFFFFFFFFu, std::memory_order_relaxed); + ivars->maxIoFrames.store(0, std::memory_order_relaxed); + ivars->sampleTimeBreaks.store(0, std::memory_order_relaxed); + ivars->expectedSampleTimeValid.store(false, std::memory_order_relaxed); + ivars->otherIoOperations.store(0, std::memory_order_relaxed); + + // Seed the clock chain: anchor (0, now), pre-expose two periods, and + // arm the first wrap. C1 counts how many anchors precede the first + // WriteEnd the HAL ever delivers. + ivars->startHostTime = mach_absolute_time(); + UpdateCurrentZeroTimestamp(0, ivars->startHostTime); + ivars->anchorsPublished.fetch_add(1, std::memory_order_relaxed); + ivars->anchorsBeforeFirstWriteEnd.fetch_add(1, std::memory_order_relaxed); + + if (ivars->controller) { + PrepareCoverage(ivars, 2ull * GetZeroTimestampPeriod()); + } + + ivars->ioRunning.store(true, std::memory_order_relaxed); + ivars->periodIndex = 1; + const uint64_t deadline = + ivars->startHostTime + + ivars->timebase.NsToTicks(NsForPeriodIndex(1)); + const uint64_t leeway = ivars->timebase.NsToTicks(500000); + if (ivars->ztsTimer) { + ivars->ztsTimer->WakeAtTime(kIOTimerClockMachAbsoluteTime, deadline, + leeway); } }); - + return kr; } kern_return_t VirtualAudioDevice::StopIO(IOUserAudioStartStopFlags in_flags) { IOLog("VirtualAudioDevice: StopIO\n"); - + __block kern_return_t kr = kIOReturnSuccess; ivars->workQueue->DispatchSync(^(){ + ivars->ioRunning.store(false, std::memory_order_relaxed); + kr = super::StopIO(in_flags); ivars->outputMemoryMap.reset(); + + // ---- M3 dump (StopIO may take as long as necessary) ---- + const auto snapshot = ivars->verifier ? ivars->verifier->Snapshot() + : ASFW::Lab::VerifierSnapshot{}; + using ASFW::Lab::VerifierCounterId; + + IOLog("ADKLab[dump] zts: anchors=%llu before_first_io=%llu period=%u " + "ring_frames=%u prepare_failures=%llu\n", + ivars->anchorsPublished.load(std::memory_order_relaxed), + ivars->anchorsBeforeFirstWriteEnd.load(std::memory_order_relaxed), + GetZeroTimestampPeriod(), ivars->ringFrames, + ivars->prepareFailures); + + const uint64_t firstHost = + ivars->firstWriteEndHostTime.load(std::memory_order_relaxed); + IOLog("ADKLab[dump] writeend: count=%llu frames=%llu min=%u max=%u " + "sample_breaks=%llu first_sample=%llu first_host_delta=%lld " + "other_ops=%llu\n", + ivars->writeEndCount.load(std::memory_order_relaxed), + ivars->framesDelivered.load(std::memory_order_relaxed), + ivars->minIoFrames.load(std::memory_order_relaxed), + ivars->maxIoFrames.load(std::memory_order_relaxed), + ivars->sampleTimeBreaks.load(std::memory_order_relaxed), + ivars->firstWriteEndSampleTime.load(std::memory_order_relaxed), + (firstHost != 0) + ? (int64_t)(firstHost - ivars->startHostTime) + : (int64_t)0, + ivars->otherIoOperations.load(std::memory_order_relaxed)); + + IOLog("ADKLab[dump] verifier: violations=%llu p1_win=%llu p1_run=%llu " + "p1_idx=%llu p2_dbc=%llu p3_bytes=%llu p3_q0=%llu p3_q1=%llu " + "p3_unacq=%llu p4_tile=%llu p4_cnt=%llu\n", + snapshot.TotalViolations(), + snapshot.Value(VerifierCounterId::kP1CadenceWindowViolation), + snapshot.Value(VerifierCounterId::kP1CadenceRunViolation), + snapshot.Value(VerifierCounterId::kP1PacketIndexGapViolation), + snapshot.Value(VerifierCounterId::kP2DbcViolation), + snapshot.Value(VerifierCounterId::kP3ByteCountViolation), + snapshot.Value(VerifierCounterId::kP3CipQ0Violation), + snapshot.Value(VerifierCounterId::kP3CipQ1Violation), + snapshot.Value(VerifierCounterId::kP3UnacquiredPublishViolation), + snapshot.Value(VerifierCounterId::kP4FrameTilingViolation), + snapshot.Value(VerifierCounterId::kP4FrameCountViolation)); + + IOLog("ADKLab[dump] packets: published=%llu data=%llu nodata=%llu " + "acquire_failures=%llu\n", + snapshot.Value(VerifierCounterId::kPacketsPublished), + snapshot.Value(VerifierCounterId::kDataPackets), + snapshot.Value(VerifierCounterId::kNoDataPackets), + snapshot.Value(VerifierCounterId::kAcquireFailures)); + + if (snapshot.firstViolationValid) { + IOLog("ADKLab[dump] verifier_first: id=%u packet=%llu\n", + snapshot.firstViolationId, + snapshot.firstViolationPacketIndex); + } + + if (ivars->controller) { + const auto& payload = ivars->controller->PayloadCounters(); + IOLog("ADKLab[dump] payload: visited=%llu written=%llu " + "without_packet=%llu outside_packet=%llu\n", + payload.framesVisited.load(std::memory_order_relaxed), + payload.framesWritten.load(std::memory_order_relaxed), + payload.framesWithoutPacket.load(std::memory_order_relaxed), + payload.framesOutsidePacket.load(std::memory_order_relaxed)); + } }); return kr; diff --git a/ADKVirtualAudioLab/Driver/VirtualAudioDevice.iig b/ADKVirtualAudioLab/Driver/VirtualAudioDevice.iig index 66534569..378125f9 100644 --- a/ADKVirtualAudioLab/Driver/VirtualAudioDevice.iig +++ b/ADKVirtualAudioLab/Driver/VirtualAudioDevice.iig @@ -2,6 +2,7 @@ #define VirtualAudioDevice_h #include +#include #include #include #include @@ -32,6 +33,12 @@ public: OSObject* in_change_info) override LOCALONLY; virtual kern_return_t HandleChangeSampleRate(double in_sample_rate) override LOCALONLY; + + // M3: the dext clock model — a timer fires once per ZTS period, anchors + // UpdateCurrentZeroTimestamp with the raw fire time, and exposes the next + // period's packets (the lab's stand-in for a hardware ring interrupt). + virtual void ZtsTimerOccurred(OSAction* action, + uint64_t time) TYPE(IOTimerDispatchSource::TimerOccurred); }; #endif diff --git a/ADKVirtualAudioLab/Info.plist b/ADKVirtualAudioLab/Info.plist index 91edd0d8..7a6aa806 100644 --- a/ADKVirtualAudioLab/Info.plist +++ b/ADKVirtualAudioLab/Info.plist @@ -29,7 +29,9 @@ IOMatchCategory VirtualAudioDriver IOProviderClass - IOUserService + IOUserResources + IOResourceMatch + IOKit IOUserClass VirtualAudioDriver IOUserServerName @@ -37,6 +39,6 @@ OSBundleUsageDescription - + ADKVirtualAudioLab — hardware-free AudioDriverKit research dext for the ASFW audio path. From e239f6e182e5db61ac13b089b4dc83236433cd0d Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Wed, 10 Jun 2026 11:38:54 +0100 Subject: [PATCH 2/2] lab: add ADKLabHost activation app and bench runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ADKLabHost: minimal SwiftUI host that embeds the dext (xcodegen embeds driver-extension dependencies into Contents/Library/SystemExtensions) and submits OSSystemExtensionRequest activate/deactivate. The dext identifier is discovered from the embedded bundle at launch so signing lanes that override bundle ids need no code edits. - Entitlements files for both targets (system-extension install; driverkit + family.audio) — referenced by the signing lanes, signing itself stays OFF by default exactly as before. - BENCH.md: the M3 procedure — both signing lanes (ad-hoc SIP-off bench / provisioned SIP-on), activation, driving audio, reading the ADKLab[dump] output, and the counter -> O1-O3/C1-C4 mapping with the M3 exit criteria. Co-Authored-By: Claude Opus 4.8 (1M context) --- ADKVirtualAudioLab/BENCH.md | 113 ++++++++++++++++++ .../Driver/ADKVirtualAudioLab.entitlements | 10 ++ .../Host/ADKLabHost.entitlements | 8 ++ ADKVirtualAudioLab/Host/ADKLabHostApp.swift | 112 +++++++++++++++++ ADKVirtualAudioLab/Host/Info.plist | 26 ++++ ADKVirtualAudioLab/project.yml | 27 +++++ 6 files changed, 296 insertions(+) create mode 100644 ADKVirtualAudioLab/BENCH.md create mode 100644 ADKVirtualAudioLab/Driver/ADKVirtualAudioLab.entitlements create mode 100644 ADKVirtualAudioLab/Host/ADKLabHost.entitlements create mode 100644 ADKVirtualAudioLab/Host/ADKLabHostApp.swift create mode 100644 ADKVirtualAudioLab/Host/Info.plist diff --git a/ADKVirtualAudioLab/BENCH.md b/ADKVirtualAudioLab/BENCH.md new file mode 100644 index 00000000..2aebd7ed --- /dev/null +++ b/ADKVirtualAudioLab/BENCH.md @@ -0,0 +1,113 @@ +# ADKVirtualAudioLab — Milestone 3 bench runbook + +How to load the lab dext, drive it with real HAL pacing, and read back the +verifier + O/C instrumentation. The dext code answers the README's O1–O3 and +C1–C4 questions with counters; this file is the procedure around it. + +## What the M3 build does + +- `VirtualAudioDevice` is its own clock master: an `IOTimerDispatchSource` + fires once per ZTS period (512 frames ≈ 10.667 ms) on the mach-absolute + timebase, anchors `UpdateCurrentZeroTimestamp(n*512, fire_time)` with raw + values (nominal deadline chain, actual fire times — the host smooths), and + exposes the next period's packets (the stand-in for an IT-ring interrupt). +- The Step 6 `Verifying(Fake)` decorator runs for the whole IO session. +- `StopIO` dumps everything via `IOLog` with the `ADKLab[dump]` prefix. +- Output ring = 8 ZTS periods (4096 frames). Transport type reports FireWire. + +## Build + +```bash +xcodegen generate +xcodebuild -scheme ADKLabHost -derivedDataPath build/dd build # app + embedded dext +``` + +Unsigned by default (`CODE_SIGNING_ALLOWED: NO`) — loadable only after one of +the signing lanes below. + +### Lane A — ad-hoc, SIP-off bench (mrmidi's rig) + +Requires SIP/AMFI relaxed + `systemextensionsctl developer on`. + +```bash +xcodebuild -scheme ADKLabHost -derivedDataPath build/dd build \ + CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=YES CODE_SIGN_IDENTITY="-" +systemextensionsctl developer on # allows running from the build directory +``` + +### Lane B — real DriverKit entitlements, SIP-on (Chris's machine) + +Requires an Apple Development (or Developer ID) identity whose provisioning +profile carries `com.apple.developer.driverkit` + +`com.apple.developer.driverkit.family.audio`, with the bench machine's UDID in +the profile. The bundle ids must match what the profiles were issued for — +override them if they differ from the lab defaults: + +```bash +xcodebuild -scheme ADKLabHost -derivedDataPath build/dd build \ + CODE_SIGNING_ALLOWED=YES CODE_SIGNING_REQUIRED=YES \ + CODE_SIGN_IDENTITY="Apple Development" DEVELOPMENT_TEAM= \ + CODE_SIGN_STYLE=Manual \ + PROVISIONING_PROFILE_SPECIFIER= +``` + +(Per-target overrides are easiest from Xcode's Signing pane after +`xcodegen generate`; with SIP on, the app must also be moved to +`/Applications` before activation.) + +The dext entitlements file is `Driver/ADKVirtualAudioLab.entitlements`; the +host app's is `Host/ADKLabHost.entitlements` (system-extension install). + +## Run + +1. Launch `ADKLabHost.app`, click **Activate**, approve in System Settings → + General → Login Items & Extensions. +2. Capture logs in a second terminal **before** starting audio: + + ```bash + log stream --predicate 'sender CONTAINS "ADKVirtualAudioLab"' --style compact + ``` + +3. The virtual device ("VirtualADKAudioLabDevice", FireWire transport) appears + in Audio MIDI Setup. Play audio at it: + + ```bash + # simplest: set it as output in Audio MIDI Setup, then + afplay /System/Library/Sounds/Submarine.aiff + # or run minutes of pink noise from Music/Logic for a soak + ``` + +4. Stop playback (coreaudiod stops IO a moment later), or switch default + output away — `StopIO` fires and the `ADKLab[dump]` lines appear. +5. Deactivate from the app when done (or leave active for repeat runs — + each StartIO resets counters). + +## Reading the dump + +``` +ADKLab[dump] zts: anchors, before_first_io, period, ring_frames, prepare_failures +ADKLab[dump] writeend: count, frames, min/max io size, sample_breaks, first_sample, + first_host_delta (ticks from StartIO seed), other_ops +ADKLab[dump] verifier: violations + the P1..P4 breakdown (Step 6 ids) +ADKLab[dump] packets: published/data/nodata/acquire_failures +ADKLab[dump] payload: visited/written/without_packet/outside_packet +ADKLab[free] ...: io_after_stop / timer_after_stop (O2, logged at teardown) +``` + +## Mapping counters → README questions + +| Question | Where it lands | +|---|---| +| **C1** minimal IO trigger / anchors before first cycle | `zts: before_first_io`, `writeend: first_sample`, `first_host_delta` | +| **C2** ZTS tolerance (jitter/late/skip) | rerun with the timer chain perturbed; watch `sample_breaks` + audible glitches; flip `SetClockAlgorithm` Raw vs default | +| **C3** WriteEnd shape | `writeend: min/max`, `sample_breaks` (continuity), restart runs for sample-time reset behavior | +| **C4** period vs HAL buffer coupling | vary `kRingPeriods` / HAL IO size (Audio MIDI Setup), compare dumps | +| **O1** retain/teardown order | clean activate→deactivate cycles with no dext crash logs | +| **O2** callbacks after StopIO | `ADKLab[free] io_after_stop / timer_after_stop` | +| **O3** init-failure leak path | forced-failure experiment (edit init to fail after AddStream) | +| **M1 invariants under real pacing** | `verifier: violations == 0` over minutes of playback | + +**Milestone 3 exit:** zero verifier violations over minutes of real HAL +pacing; the O/C answers recorded back into `README.md`; a captured WriteEnd +trace added to the host regression suite (trace extraction is the planned +follow-up — current dump carries aggregates + first-callback tuple). diff --git a/ADKVirtualAudioLab/Driver/ADKVirtualAudioLab.entitlements b/ADKVirtualAudioLab/Driver/ADKVirtualAudioLab.entitlements new file mode 100644 index 00000000..30c9f4b8 --- /dev/null +++ b/ADKVirtualAudioLab/Driver/ADKVirtualAudioLab.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.driverkit + + com.apple.developer.driverkit.family.audio + + + diff --git a/ADKVirtualAudioLab/Host/ADKLabHost.entitlements b/ADKVirtualAudioLab/Host/ADKLabHost.entitlements new file mode 100644 index 00000000..8b98f922 --- /dev/null +++ b/ADKVirtualAudioLab/Host/ADKLabHost.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.system-extension.install + + + diff --git a/ADKVirtualAudioLab/Host/ADKLabHostApp.swift b/ADKVirtualAudioLab/Host/ADKLabHostApp.swift new file mode 100644 index 00000000..d7de2d42 --- /dev/null +++ b/ADKVirtualAudioLab/Host/ADKLabHostApp.swift @@ -0,0 +1,112 @@ +import SwiftUI +import SystemExtensions + +// Minimal activation host for the lab dext (Milestone 3). A DriverKit +// extension can only be activated by an app that embeds it in +// Contents/Library/SystemExtensions — this app does exactly that and nothing +// else. All observation happens via the dext's IOLog output: +// log stream --predicate 'sender == "net.mrmidi.ASFW.ADKVirtualAudioLab"' + +@main +struct ADKLabHostApp: App { + var body: some Scene { + WindowGroup { + ContentView() + .frame(minWidth: 420, minHeight: 220) + } + } +} + +struct ContentView: View { + @StateObject private var manager = ExtensionManager() + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("ADKVirtualAudioLab Host") + .font(.title2) + Text("Dext: \(ExtensionManager.dextIdentifier)") + .font(.caption) + .textSelection(.enabled) + + HStack(spacing: 12) { + Button("Activate") { manager.activate() } + Button("Deactivate") { manager.deactivate() } + } + + Text(manager.status) + .font(.callout) + .foregroundStyle(.secondary) + .textSelection(.enabled) + + Text("After activation, the virtual device appears in Audio MIDI Setup. Start playback at it, then stop — the dext dumps verifier and O/C counters at StopIO (see BENCH.md).") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(20) + } +} + +final class ExtensionManager: NSObject, ObservableObject, OSSystemExtensionRequestDelegate { + // Discovered from the embedded dext so signing lanes that override the + // bundle identifier (BENCH.md Lane B) keep working without code edits. + static let dextIdentifier: String = { + let dir = Bundle.main.bundleURL + .appendingPathComponent("Contents/Library/SystemExtensions") + if let items = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: nil) { + for url in items where url.pathExtension == "dext" { + if let identifier = Bundle(url: url)?.bundleIdentifier { + return identifier + } + } + } + return "net.mrmidi.ASFW.ADKVirtualAudioLab" + }() + + @Published var status = "Idle." + + func activate() { + submit(OSSystemExtensionRequest.activationRequest( + forExtensionWithIdentifier: Self.dextIdentifier, queue: .main)) + } + + func deactivate() { + submit(OSSystemExtensionRequest.deactivationRequest( + forExtensionWithIdentifier: Self.dextIdentifier, queue: .main)) + } + + private func submit(_ request: OSSystemExtensionRequest) { + request.delegate = self + OSSystemExtensionManager.shared.submitRequest(request) + status = "Request submitted…" + } + + func request(_ request: OSSystemExtensionRequest, + actionForReplacingExtension existing: OSSystemExtensionProperties, + withExtension ext: OSSystemExtensionProperties) + -> OSSystemExtensionRequest.ReplacementAction { + status = "Replacing \(existing.bundleShortVersion) with \(ext.bundleShortVersion)…" + return .replace + } + + func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { + status = "Needs approval in System Settings → General → Login Items & Extensions." + } + + func request(_ request: OSSystemExtensionRequest, + didFinishWithResult result: OSSystemExtensionRequest.Result) { + switch result { + case .completed: + status = "Completed. Check Audio MIDI Setup for the virtual device." + case .willCompleteAfterReboot: + status = "Will complete after reboot." + @unknown default: + status = "Finished with unknown result (\(result.rawValue))." + } + } + + func request(_ request: OSSystemExtensionRequest, + didFailWithError error: Error) { + status = "Failed: \(error.localizedDescription)" + } +} diff --git a/ADKVirtualAudioLab/Host/Info.plist b/ADKVirtualAudioLab/Host/Info.plist new file mode 100644 index 00000000..08af1f4f --- /dev/null +++ b/ADKVirtualAudioLab/Host/Info.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + ADKLab Host + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSPrincipalClass + NSApplication + + diff --git a/ADKVirtualAudioLab/project.yml b/ADKVirtualAudioLab/project.yml index 2e78de62..38068eca 100644 --- a/ADKVirtualAudioLab/project.yml +++ b/ADKVirtualAudioLab/project.yml @@ -35,3 +35,30 @@ targets: - path: Ports settings: CLANG_CXX_LANGUAGE_STANDARD: gnu++2b + # Milestone 3 activation host: embeds the dext and submits the + # OSSystemExtensionRequest. Signing stays off by default like the dext — + # see BENCH.md for the ad-hoc (SIP-off bench) and Developer-ID/provisioned + # (SIP-on) signing override lanes. + ADKLabHost: + type: application + platform: macOS + deploymentTarget: "15.0" + sources: + - path: Host + dependencies: + - target: ADKVirtualAudioLab + embed: true + info: + path: Host/Info.plist + properties: + CFBundleDisplayName: ADKLab Host + NSPrincipalClass: NSApplication + entitlements: + path: Host/ADKLabHost.entitlements + properties: + com.apple.developer.system-extension.install: true + settings: + CODE_SIGNING_ALLOWED: NO + CODE_SIGNING_REQUIRED: NO + CODE_SIGN_IDENTITY: "" + SWIFT_VERSION: "5.9"