diff --git a/ASFWDriver/Audio/DriverKit/ASFWAudioDevice.cpp b/ASFWDriver/Audio/DriverKit/ASFWAudioDevice.cpp index 655b0cd94..95a00d714 100644 --- a/ASFWDriver/Audio/DriverKit/ASFWAudioDevice.cpp +++ b/ASFWDriver/Audio/DriverKit/ASFWAudioDevice.cpp @@ -1103,6 +1103,29 @@ ResolveRate(ASFWAudioDriver_IVars& driver, uint32_t rate, uint64_t revision, .resolved = std::make_shared(*resolved)}; } +// Take the nub's catalog after it measured `rateHz` from the device's +// registers (CopyObservedRateCatalog). The device must be locked at the rate. +[[nodiscard]] kern_return_t AdoptObservedRateCatalog(ASFWAudioDriver_IVars& driver, uint32_t rateHz) { + OSDictionary* rawProperties = nullptr; + const auto status = driver.device.audioNub->CopyObservedRateCatalog(rateHz, &rawProperties); + const auto properties = OSSharedPtr(rawProperties, OSNoRetain); + if (status != kIOReturnSuccess) return status; + ASFW::Isoch::Audio::ParsedAudioDriverConfig catalog{}; + ASFW::Isoch::Audio::InitializeAudioDriverConfigDefaults(catalog); + ASFW::Isoch::Audio::ParseAudioDriverConfigFromProperties(properties.get(), catalog); + if (catalog.guid != driver.device.guid || + catalog.rateRouteIncarnation != driver.device.rateRouteIncarnation || + std::ranges::find(catalog.rateFormationCandidates, rateHz, + &ASFW::Audio::Runtime::RateFormation::sampleRateHz) == catalog.rateFormationCandidates.end()) + return kIOReturnAborted; + driver.device.rateFormationCandidates = std::move(catalog.rateFormationCandidates); + driver.device.sampleRateCount = catalog.sampleRateCount; + std::ranges::copy(std::span{catalog.sampleRates, catalog.sampleRateCount}, driver.device.sampleRates); + driver.device.rateRouteEpoch = catalog.rateRouteEpoch; + driver.device.rateBusGeneration = catalog.rateBusGeneration; + return kIOReturnSuccess; +} + void InstallDriverFormation(ASFWAudioDriver_IVars& driver, const ASFW::Audio::Runtime::ResolvedAudioConfiguration& resolved) { auto& state = driver.device; @@ -1493,24 +1516,8 @@ kern_return_t ASFWAudioDevice::RequestExternalRateResync(uint32_t nominalRateHz) !std::holds_alternative(ivars->rateMachine.state); IOLockUnlock(ivars->rateLock); if (pending) return kIOReturnBusy; - OSDictionary* rawProperties = nullptr; - const auto status = driverIvars.device.audioNub->CopyObservedRateCatalog(nominalRateHz, &rawProperties); - const auto properties = OSSharedPtr(rawProperties, OSNoRetain); + const auto status = AdoptObservedRateCatalog(driverIvars, nominalRateHz); if (status != kIOReturnSuccess) return status; - ASFW::Isoch::Audio::ParsedAudioDriverConfig catalog{}; - ASFW::Isoch::Audio::InitializeAudioDriverConfigDefaults(catalog); - ASFW::Isoch::Audio::ParseAudioDriverConfigFromProperties(properties.get(), catalog); - if (catalog.guid != driverIvars.device.guid || - catalog.rateRouteIncarnation != driverIvars.device.rateRouteIncarnation || - std::ranges::find(catalog.rateFormationCandidates, nominalRateHz, - &ASFW::Audio::Runtime::RateFormation::sampleRateHz) == catalog.rateFormationCandidates.end()) - return kIOReturnAborted; - driverIvars.device.rateFormationCandidates = std::move(catalog.rateFormationCandidates); - driverIvars.device.sampleRateCount = catalog.sampleRateCount; - std::ranges::copy(std::span{catalog.sampleRates, catalog.sampleRateCount}, - driverIvars.device.sampleRates); - driverIvars.device.rateRouteEpoch = catalog.rateRouteEpoch; - driverIvars.device.rateBusGeneration = catalog.rateBusGeneration; } if (!driverIvars.device.rateFormationCandidates.empty()) return StageSampleRate(nominalRateHz, true); @@ -1652,12 +1659,33 @@ kern_return_t ASFWAudioDevice::PerformDeviceConfigurationChange( return status == kIOReturnSuccess ? kIOReturnNotReady : status; } auto commitConfiguration = transaction.candidate; - const bool requestedConfirmed = readback == kIOReturnSuccess && confirmed(transaction.candidate.sampleRate); + bool requestedConfirmed = readback == kIOReturnSuccess && confirmed(transaction.candidate.sampleRate); bool otherConfirmed = false; if (!requestedConfirmed && readback == kIOReturnSuccess && input && (output == input || output == 0)) { const auto actual = ResolveRate(driver, input, transaction.prior.revision + 1); if (actual) { commitConfiguration = *actual; otherConfirmed = true; } } + // A provisional rate's layout is read now, with the clock locked at it, + // as TCAT's RestartStreaming reads TX/RX after CLOCK_ACCEPTED. The + // measured formation is never the staged placeholder, so the device + // confirmed another configuration than the candidate. + if ((requestedConfirmed || otherConfirmed) && + commitConfiguration.resolved->formation.layoutProvisional) { + const uint32_t rate = commitConfiguration.sampleRate; + const auto adopted = AdoptObservedRateCatalog(driver, rate); + std::expected measured = std::unexpected(adopted); + if (adopted == kIOReturnSuccess) measured = ResolveRate(driver, rate, transaction.prior.revision + 1); + const bool learned = measured && !measured->resolved->formation.layoutProvisional; + if (learned) commitConfiguration = *measured; + else status = measured ? kIOReturnNotReady : measured.error(); + otherConfirmed = learned; + requestedConfirmed = false; + ASFW_LOG(Audio, "[RateTxn] phase=learn token=%llu gen=%u rate=%u result=0x%x (%{public}s) in=%u out=%u", + transaction.identity.token, generation, rate, learned ? kIOReturnSuccess : status, + ASFW::Logging::IOReturnName(learned ? kIOReturnSuccess : status), + learned ? commitConfiguration.resolved->captureChannels : 0U, + learned ? commitConfiguration.resolved->playbackChannels : 0U); + } ASFW_LOG(Audio, "[RateTxn] phase=confirm kind=%{public}s token=%llu gen=%u requested=%u out=%u in=%u kr=0x%x (%{public}s)", requestedConfirmed ? "requested" : otherConfirmed ? "other" : "unknown", transaction.identity.token, generation, transaction.candidate.sampleRate, output, input, readback, ASFW::Logging::IOReturnName(readback)); diff --git a/ASFWDriver/Audio/DriverKit/ASFWAudioNub.cpp b/ASFWDriver/Audio/DriverKit/ASFWAudioNub.cpp index 0ea6900f3..378730072 100644 --- a/ASFWDriver/Audio/DriverKit/ASFWAudioNub.cpp +++ b/ASFWDriver/Audio/DriverKit/ASFWAudioNub.cpp @@ -1015,9 +1015,7 @@ kern_return_t IMPL(ASFWAudioNub, ReadRateClockState) { [owner](auto done) { owner->ReadRateObservation(std::move(done)); }, 5000, kIOReturnTimeout); if (result.status != kIOReturnSuccess) return result.status; if (!binding.registry->IsCurrent(*route)) return kIOReturnAborted; - const auto actual = ASFW::Audio::Model::WithRateFormation(config, result.value.caps.sampleRateHz); - const bool known = result.value.clockConfirmed && actual && - ASFW::Audio::RuntimeCapsMatchConfiguration(*actual, result.value.caps); + const bool known = ASFW::Audio::ObservationSettlesRate(config, result.value); *outIncarnation = route->deviceIncarnation; *outRouteEpoch = route->routeEpoch; *outBusGeneration = route->generation.value; @@ -1144,6 +1142,9 @@ kern_return_t IMPL(ASFWAudioNub, InstallRateFormation) { if (!binding.registry->IsCurrent(route)) return kIOReturnAborted; ASFW::Audio::Model::ASFWAudioDevice prior{}; if (!endpoint->CopyConfig(prior)) return kIOReturnNotReady; + // Only a layout read from the device is installed; the window replaces a + // placeholder through CopyObservedRateCatalog first. + if (ASFW::Audio::Model::LayoutIsProvisional(prior, sampleRateHz)) return kIOReturnNotReady; const auto next = ASFW::Audio::Model::WithRateFormation(prior, sampleRateHz); if (!next) return kIOReturnUnsupported; if (prior.usesRateFormations && (!ivars->rateHardwareObservation || diff --git a/ASFWDriver/Audio/DriverKit/Config/AudioDriverConfig.cpp b/ASFWDriver/Audio/DriverKit/Config/AudioDriverConfig.cpp index adb02998f..0ad43f825 100644 --- a/ASFWDriver/Audio/DriverKit/Config/AudioDriverConfig.cpp +++ b/ASFWDriver/Audio/DriverKit/Config/AudioDriverConfig.cpp @@ -269,6 +269,8 @@ void ParseAudioDriverConfigFromProperties(OSDictionary* properties, ? ::ASFW::Encoding::StreamMode::kBlocking : ::ASFW::Encoding::StreamMode::kNonBlocking; formation.protocolSupported = protocol->unsigned32BitValue() != 0; formation.hardwareValidated = validated->unsigned32BitValue() != 0; + if (auto* provisional = OSDynamicCast(OSNumber, entry->getObject(Keys::kFormationLayoutProvisional))) + formation.layoutProvisional = provisional->unsigned32BitValue() != 0; if (auto* packed = OSDynamicCast(OSNumber, entry->getObject("ASFWMotuPackedPcm"))) formation.packedPcm = packed->unsigned32BitValue() != 0; if (auto* value = OSDynamicCast(OSNumber, entry->getObject("ASFWMotuCaptureMessageChunks"))) diff --git a/ASFWDriver/Audio/Model/ASFWAudioDevice.hpp b/ASFWDriver/Audio/Model/ASFWAudioDevice.hpp index d1cc780db..e8c67ff41 100644 --- a/ASFWDriver/Audio/Model/ASFWAudioDevice.hpp +++ b/ASFWDriver/Audio/Model/ASFWAudioDevice.hpp @@ -221,6 +221,7 @@ struct ASFWAudioDevice { formation.mode == Encoding::StreamMode::kBlocking ? 1U : 0U) || !add(PropertyKeys::kFormationProtocolSupported, formation.protocolSupported) || !add(PropertyKeys::kFormationHardwareValidated, formation.hardwareValidated) || + !add(PropertyKeys::kFormationLayoutProvisional, formation.layoutProvisional) || !add("ASFWMotuPackedPcm", formation.packedPcm) || !add("ASFWMotuCaptureMessageChunks", formation.packedCaptureMessageChunks) || !add("ASFWMotuPlaybackMessageChunks", formation.packedPlaybackMessageChunks) || diff --git a/ASFWDriver/Audio/Model/AudioPropertyKeys.hpp b/ASFWDriver/Audio/Model/AudioPropertyKeys.hpp index 42d8f2d2a..206c255a0 100644 --- a/ASFWDriver/Audio/Model/AudioPropertyKeys.hpp +++ b/ASFWDriver/Audio/Model/AudioPropertyKeys.hpp @@ -53,6 +53,8 @@ inline constexpr const char* kRateFormations = "ASFWRateFormations"; inline constexpr const char* kUsesRateFormations = "ASFWDiceRateFormations"; inline constexpr const char* kFormationProtocolSupported = "ProtocolSupported"; inline constexpr const char* kFormationHardwareValidated = "HardwareValidated"; +// Optional: absent means the formation's layout was read from the device. +inline constexpr const char* kFormationLayoutProvisional = "LayoutProvisional"; // Keys within one stream entry. inline constexpr const char* kStreamPcmChannels = "PCM"; diff --git a/ASFWDriver/Audio/Model/DiscoveredRuntimeCaps.hpp b/ASFWDriver/Audio/Model/DiscoveredRuntimeCaps.hpp index c93cd2595..b95169549 100644 --- a/ASFWDriver/Audio/Model/DiscoveredRuntimeCaps.hpp +++ b/ASFWDriver/Audio/Model/DiscoveredRuntimeCaps.hpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: Apache-2.0 #pragma once #include "ASFWAudioDevice.hpp" +#include "RateConfiguration.hpp" #include "../Protocols/AudioTypes.hpp" #include #include @@ -32,6 +33,19 @@ namespace ASFW::Audio { matches(config.captureStreams, caps.deviceToHostStreamCount, caps.deviceToHostStreams); } +// Whether a hardware observation settles its rate for this endpoint: the clock +// is confirmed at a rate the catalog offers, and the registers match that +// rate's formation. A provisional formation has no layout to match yet; the +// registers read now are its layout (WithObservedRateCatalog). +[[nodiscard]] inline bool ObservationSettlesRate(const Model::ASFWAudioDevice& config, + const RateHardwareObservation& observed) noexcept { + if (!observed.clockConfirmed) return false; + const uint32_t rate = observed.caps.sampleRateHz; + if (Model::LayoutIsProvisional(config, rate)) return true; + const auto actual = Model::WithRateFormation(config, rate); + return actual && RuntimeCapsMatchConfiguration(*actual, observed.caps); +} + [[nodiscard]] inline std::optional DiscoveredCaps( const Model::ASFWAudioDevice& config) noexcept { if (config.playbackStreams.size() != 1 || config.captureStreams.size() != 1 || diff --git a/ASFWDriver/Audio/Model/RateConfiguration.hpp b/ASFWDriver/Audio/Model/RateConfiguration.hpp index de12821b0..fa81e8107 100644 --- a/ASFWDriver/Audio/Model/RateConfiguration.hpp +++ b/ASFWDriver/Audio/Model/RateConfiguration.hpp @@ -4,6 +4,13 @@ namespace ASFW::Audio::Model { +// The device supports this rate, but its layout is still a placeholder. +[[nodiscard]] inline bool LayoutIsProvisional(const ASFWAudioDevice& config, uint32_t rateHz) noexcept { + const auto formation = std::ranges::find(config.rateFormationCandidates, rateHz, + &Runtime::RateFormation::sampleRateHz); + return formation != config.rateFormationCandidates.end() && formation->layoutProvisional; +} + // Resolve from retained descriptor formations, never by scaling channel counts. // This is a value copy; callers install it only with both directions quiesced. [[nodiscard]] inline std::expected @@ -44,15 +51,19 @@ WithRateFormation(const ASFWAudioDevice& prior, uint32_t rateHz) { // Add one measured formation without changing the committed live format. // A known formation must agree: stale registers must not overwrite an EAP -// catalog. The caller supplies a stable, route-checked hardware observation. +// catalog. A provisional one is a placeholder and takes the measured layout. +// The caller supplies a stable, route-checked hardware observation. [[nodiscard]] inline std::expected WithObservedRateCatalog(const ASFWAudioDevice& prior, const Runtime::RateFormation& observed) { + if (observed.layoutProvisional) return std::unexpected(Runtime::ConfigurationError::InvalidFormation); auto next = prior; const auto existing = std::ranges::find(next.rateFormationCandidates, observed.sampleRateHz, &Runtime::RateFormation::sampleRateHz); if (existing == next.rateFormationCandidates.end()) { next.rateFormationCandidates.push_back(observed); std::ranges::sort(next.rateFormationCandidates, {}, &Runtime::RateFormation::sampleRateHz); + } else if (existing->layoutProvisional) { + *existing = observed; } const auto selected = WithRateFormation(next, observed.sampleRateHz); if (!selected) return std::unexpected(selected.error()); diff --git a/ASFWDriver/Audio/Protocols/DICE/Core/DiceRateFormats.hpp b/ASFWDriver/Audio/Protocols/DICE/Core/DiceRateFormats.hpp index 0defc0842..8cda61124 100644 --- a/ASFWDriver/Audio/Protocols/DICE/Core/DiceRateFormats.hpp +++ b/ASFWDriver/Audio/Protocols/DICE/Core/DiceRateFormats.hpp @@ -6,6 +6,7 @@ #include "../../../Runtime/ResolvedAudioConfiguration.hpp" #include "../../../../Common/WireFormat.hpp" +#include #include #include #include @@ -81,7 +82,8 @@ DiceObservedFormat(const AudioStreamRuntimeCaps& caps) { /// formats stand for every mode and the stream registers are not consulted /// (Linux dice-stream.c:618-621): registers read before a clock select can /// describe another mode (:635-643). Without EAP only the observed mode is -/// known (Linux's fallback); no clock probing, no guessed scaling. +/// known here; the other rates are learned when the host selects them +/// (WithProvisionalRates). No probing at attach, no guessed scaling. [[nodiscard]] inline DiceRateFormats SelectPublishedFormats(bool eapRead, DiceRateFormats eap, const AudioStreamRuntimeCaps& caps) { if (eapRead) return eap; @@ -127,6 +129,34 @@ DiceFormations(uint32_t mask, const DiceRateFormats& formats, bool exposeCapture return result; } +/// Without EAP the registers describe only the current rate's mode, yet the +/// device runs every rate in CLOCK_CAPS. TCAT's kexts offer all of them and +/// read the layout after switching: Saffire.kext 4.3.0 performFormatChange +/// (0x5028) checks the rate against CLOCK_CAPS and requests a restart; +/// RestartStreaming (0xdb02) writes CLOCK_SELECT, waits for CLOCK_ACCEPTED, +/// reads TX/RX (PopulateDeviceStruct) and rebuilds the streams (CreateStreams). +/// Each such rate here carries the current rate's layout as a placeholder for +/// the HAL format list, marked layoutProvisional; the rate transaction reads +/// the real layout before anything is installed. +[[nodiscard]] inline std::vector +WithProvisionalRates(uint32_t mask, std::vector measured, + uint32_t currentRateHz) { + const auto current = std::ranges::find(measured, currentRateHz, &Runtime::RateFormation::sampleRateHz); + if (current == measured.end()) return measured; + const auto placeholder = *current; + for (const auto& rate : kDiceRateTable) { + if (!(mask & rate.capsBit) || std::ranges::find(measured, rate.hz, + &Runtime::RateFormation::sampleRateHz) != measured.end()) continue; + auto formation = placeholder; + formation.sampleRateHz = rate.hz; + formation.hardwareValidated = false; + formation.layoutProvisional = true; + measured.push_back(std::move(formation)); + } + std::ranges::sort(measured, {}, &Runtime::RateFormation::sampleRateHz); + return measured; +} + // One observed rate is evidence for that rate only, even when the capability // register describes just the current mode (#190). [[nodiscard]] inline std::optional diff --git a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp index 6b345e490..f79a1e0a4 100644 --- a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp +++ b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp @@ -271,25 +271,47 @@ void DICETcatProtocol::InitialClockWrite(const std::shared_ptr& state) { + // Both waits after the CLOCK_SELECT write are advisory, as in the TCAT + // driver (PaeFireStudio RestartStreaming 0xd8e2): it waits up to 150 ms + // for CLOCK_ACCEPTED and up to 1 s for lock, keeps waiting through a + // failed STATUS read, logs a timeout and creates the streams anyway. Only + // a failed write stops it. Stream start keeps its own lock gate. // Events arriving during a GLOBAL read overtake its snapshot: consume // them, but require another read before accepting that older locked state. state->notifications |= notifications_.Consume(); diceReader_.ReadGlobalStateFull(sections_, [this, state](IOReturn status, GlobalState global) { if (!state->active.load(std::memory_order_acquire)) return; if (!InitialClockCurrent(state)) return; - if (status != kIOReturnSuccess) { FinishInitialClock(state, status); return; } + const uint64_t now = preparationScheduler_->NowNs(); + const bool expired = now - state->startedNs >= 3'000'000'000ULL; + if (status != kIOReturnSuccess) { + // A bus reset or a cancellation ends this preparation; the new + // generation prepares again. + if (status == kIOReturnOffline || status == kIOReturnAborted) { + FinishInitialClock(state, status); return; + } + if (expired) { + ASFW_LOG(DICE, "[DiceInit] clock unconfirmed guid=%016llx: GLOBAL read kr=0x%x at deadline; " + "continuing", guid_, status); + FinishInitialClock(state, kIOReturnSuccess); return; + } + ScheduleInitialClockPoll(state, 10, false); + return; + } const uint32_t late = notifications_.Consume(); // GLOBAL can retain flags from before this write. Only newly set // register bits or fresh mailbox writes belong to this transition. state->notifications |= (global.notification & ~state->priorGlobalNotifications) | late; - const uint64_t now = preparationScheduler_->NowNs(); const bool atTarget = global.clockSelect == state->clock.clockSelect && NominalRateHz(global.status) == state->clock.sampleRateHz && global.sampleRate == state->clock.sampleRateHz; const bool ready = atTarget && (!runtimePolicy_.requireSourceLockBeforeStreamEnable || IsSourceLocked(global.status)); if (!state->accepted && ((state->notifications & Notify::kClockAccepted) || - (now - state->startedNs >= 150'000'000ULL && ready))) { + now - state->startedNs >= 150'000'000ULL)) { + if (!(state->notifications & Notify::kClockAccepted)) { + ASFW_LOG(DICE, "[DiceInit] no CLOCK_ACCEPTED within 150 ms guid=%016llx; continuing", guid_); + } state->accepted = true; state->acceptedNs = now; } @@ -300,9 +322,11 @@ void DICETcatProtocol::InitialClockPoll(const std::shared_ptraccepted && now - state->startedNs >= 150'000'000ULL) || - now - state->startedNs >= 3'000'000'000ULL) { - FinishInitialClock(state, kIOReturnTimeout); return; + if (expired) { + ASFW_LOG(DICE, "[DiceInit] clock unconfirmed guid=%016llx: select=0x%08x status=0x%08x " + "rate=%u notify=0x%x; continuing", guid_, global.clockSelect, global.status, + global.sampleRate, state->notifications); + FinishInitialClock(state, kIOReturnSuccess); return; } ScheduleInitialClockPoll(state, 10, false); }); @@ -468,9 +492,16 @@ void DICETcatProtocol::ReadRateObservation( if (stable && locked) { const auto measured = DiceObservedRateFormation(caps, runtimePolicy_.exposeDeviceToHostToCoreAudio); + // Learn a rate never seen, or read the layout of a + // provisional one now that the clock is locked at it. const auto known = RateFormations(); - if (measured && (!known || std::ranges::find(*known, measured->sampleRateHz, - &Runtime::RateFormation::sampleRateHz) == known->end())) + const bool unmeasured = [&] { + if (!known) return true; + const auto existing = std::ranges::find(*known, caps.sampleRateHz, + &Runtime::RateFormation::sampleRateHz); + return existing == known->end() || existing->layoutProvisional; + }(); + if (measured && unmeasured) PublishRateFormations(std::make_shared>( std::vector{*measured})); } @@ -1141,10 +1172,18 @@ void DICETcatProtocol::EnsureRuntimeCapsLoaded(VoidCallback callback) { // locked at 88.2 kHz with its 48 kHz layout // (2026-10-08) used to overwrite the EAP 2x entry // with 16 inputs, and every 2x switch rolled back. - formats = SelectPublishedFormats(status == kIOReturnSuccess, - formats, caps); + const bool eapRead = status == kIOReturnSuccess; + ASFW_LOG(DICE, "DICETcatProtocol: rate formats from %{public}s " + "(EAP read 0x%x (%{public}s)) rate=%u caps=0x%02x", + eapRead ? "EAP" : "stream registers", status, + ASFW::Logging::IOReturnName(status), caps.sampleRateHz, + caps.deviceRateMask); + formats = SelectPublishedFormats(eapRead, formats, caps); auto formations = DiceFormations(caps.deviceRateMask, formats, runtimePolicy_.exposeDeviceToHostToCoreAudio); + if (!eapRead) + formations = WithProvisionalRates(caps.deviceRateMask, + std::move(formations), caps.sampleRateHz); PublishRateFormations( std::make_shared>( std::move(formations))); diff --git a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.hpp b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.hpp index 7c23db938..070a18a68 100644 --- a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.hpp +++ b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.hpp @@ -306,8 +306,10 @@ class DICETcatProtocol final : public Audio::IDeviceProtocol, for (const auto& formation : *snapshot) { const auto existing = std::ranges::find(retained, formation.sampleRateHz, &Runtime::RateFormation::sampleRateHz); + // A placeholder never replaces a layout read from the device: a + // re-describe after a reset rebuilds them for every rate. if (existing == retained.end()) retained.push_back(formation); - else *existing = formation; + else if (!formation.layoutProvisional || existing->layoutProvisional) *existing = formation; } std::ranges::sort(retained, {}, &Runtime::RateFormation::sampleRateHz); rateFormations_ = std::make_shared>(std::move(retained)); diff --git a/ASFWDriver/Audio/Runtime/ResolvedAudioConfiguration.hpp b/ASFWDriver/Audio/Runtime/ResolvedAudioConfiguration.hpp index 61d4a1865..58cef0b1f 100644 --- a/ASFWDriver/Audio/Runtime/ResolvedAudioConfiguration.hpp +++ b/ASFWDriver/Audio/Runtime/ResolvedAudioConfiguration.hpp @@ -41,6 +41,11 @@ struct RateFormation final { bool packedPcm{false}; // MOTU: 3-byte chunks, not PCM quadlet slots. uint32_t packedCaptureMessageChunks{2}; uint32_t packedPlaybackMessageChunks{2}; + // The device supports this rate but its layout has not been read yet: the + // streams are a placeholder for the HAL format list only. A rate change + // reads the real layout once the clock is locked at the rate, and only a + // measured formation is ever installed or streamed. + bool layoutProvisional{false}; friend bool operator==(const RateFormation&, const RateFormation&) = default; }; @@ -168,12 +173,17 @@ MaximumFormationAllocation(std::span formations, probePolicy, ceiling, 0, ConfigurationValidationPolicy::HardwareBatch); if (!resolved) return std::unexpected(resolved.error()); result.frameCapacity = std::max(result.frameCapacity, resolved->timing.frameRingFrames); - result.playbackChannelCapacity = std::max(result.playbackChannelCapacity, resolved->playbackChannels); - result.captureChannelCapacity = std::max(result.captureChannelCapacity, resolved->captureChannels); + // A provisional layout can be larger than its placeholder (a device + // attached at 96 kHz shows the 2x layout for 48 kHz too), so reserve + // the protocol limit. The mappings are never replaced afterwards. + const auto playback = formation.layoutProvisional ? limit : resolved->playbackChannels; + const auto capture = formation.layoutProvisional ? limit : resolved->captureChannels; + result.playbackChannelCapacity = std::max(result.playbackChannelCapacity, playback); + result.captureChannelCapacity = std::max(result.captureChannelCapacity, capture); for (const auto& direction : {std::span(formation.playback), std::span(formation.capture)}) for (const auto& stream : direction) - result.maxPacketBytes = std::max(result.maxPacketBytes, - 8U + resolved->maxPacketFrames * stream.dataBlockSize * 4U); + result.maxPacketBytes = std::max(result.maxPacketBytes, 8U + resolved->maxPacketFrames * 4U * + (formation.layoutProvisional ? Encoding::kMaxAmdtpDbs : stream.dataBlockSize)); } return result; } diff --git a/documentation/fixtures/DICE/presonus-firestudio-2626.txt b/documentation/fixtures/DICE/presonus-firestudio-2626.txt new file mode 100644 index 000000000..121e40373 --- /dev/null +++ b/documentation/fixtures/DICE/presonus-firestudio-2626.txt @@ -0,0 +1,188 @@ +ASFW DICE DEVICE REPORT +======================= +Generated: 2026-10-11T09:59:02Z +Report app: 0.5.0 (build 19) +Driver: 0.5.0 (6b3162e on release/arm64-dext-at-ring-rme-clock, dirty) built 2026-10-10T10:56:44Z +Rate mode: low (32-48k) + +This is a read-only dump of the device's DICE register spaces. +Paste it whole into the issue; do not trim sections. + +IDENTITY +-------- +GUID: 0x000A92040210A2EF +Vendor: PreSonus +Model: FIRESTUDIO +Node / gen: 0 / 52 +TCAT vendor: 0x000A92 +TCAT category: 0x04 (standard DICE) +TCAT product: 0x008 +TCAT serial: 1090287 + +SECTION TABLES (offsets and sizes in quadlets) +----------------------------------------------- +general space @ 0xFFFFE0000000 + global offset=0x0000A (0x000028 B) size=0x0005A (360 B) + tx offset=0x00064 (0x000190 B) size=0x0008E (568 B) + rx offset=0x000F2 (0x0003C8 B) size=0x0011A (1128 B) + ext_sync offset=0x0020C (0x000830 B) size=0x00004 (16 B) + unused2 offset=0x00000 (0x000000 B) size=0x00000 (0 B) + +extension space @ 0xFFFFE0200000 + + +GLOBAL +-------- +OWNER = 0xFFFF000000000000 (no owner) +NOTIFICATION = 0x00000000 - +NICK_NAME = 'FIRESTUDIO' +CLOCK_SELECT = 0x0000020C source=12 (internal) rate=2 (48000) +ENABLE = 0x00000000 streaming=no +STATUS = 0x00000201 locked=yes nominal=48000 +EXTENDED_STATUS = 0x00000000 + locked : - + slipped : - + NOTE: slip bits are read-to-clear and fluctuate without notification; + this is an instantaneous sample, not a stable value. +SAMPLE_RATE = 48000 Hz (measured) +VERSION = 0x01000400 (1.0.4.0) +CLOCK_CAPABILITIES = 0x13FF001E + rates : 44100 48000 88200 96000 + sources : aes1 aes2 aes3 aes4 aes_any adat tdif wc arx1 arx2 internal +CLOCK_SOURCE_NAMES: + 0 aes1 'SPDIF' + 1 aes2 'ADAT' + 2 aes3 'Word Clock' + 3 aes4 'Unused' + 4 aes_any 'Unused' + 5 adat 'Unused' + 6 tdif 'Unused' + 7 wc 'Internal' + +TX STREAMS (device transmits -> host capture) +---------------------------------------------- +NUMBER = 2 SIZE = 70 quadlets (280 bytes) + + [stream 0] + ISOCHRONOUS = -1 (disabled) + PCM channels = 16 + MIDI ports = 1 + SPEED = S400 + AC3_CAPS = + NAMES: + 0 'Mic/Inst 1' + 1 'Mic/Inst 2' + 2 'Mic/Line 3' + 3 'Mic/Line 4' + 4 'Mic/Line 5' + 5 'Mic/Line 6' + 6 'Mic/Line 7' + 7 'Mic/Line 8' + 8 'ADAT 1' + 9 'ADAT 2' + 10 'ADAT 3' + 11 'ADAT 4' + 12 'ADAT 5' + 13 'ADAT 6' + 14 'ADAT 7' + 15 'ADAT 8' + + [stream 1] + ISOCHRONOUS = -1 (disabled) + PCM channels = 10 + MIDI ports = 0 + SPEED = S400 + AC3_CAPS = + NAMES: + 0 'ADAT 9' + 1 'ADAT 10' + 2 'ADAT 11' + 3 'ADAT 12' + 4 'ADAT 13' + 5 'ADAT 14' + 6 'ADAT 15' + 7 'ADAT 16' + 8 'SPDIF L' + 9 'SPDIF R' + +RX STREAMS (device receives <- host playback) +---------------------------------------------- +NUMBER = 2 SIZE = 70 quadlets (280 bytes) + + [stream 0] + ISOCHRONOUS = -1 (disabled) + SEQ_START = 0 + PCM channels = 16 + MIDI ports = 1 + AC3_CAPS = + NAMES: + 0 'DAW Out 1 (L)' + 1 'DAW Out 2 (R)' + 2 'DAW Out 3 (LS)' + 3 'DAW Out 4 (RS)' + 4 'DAW Out 5 (C)' + 5 'DAW Out 6 (Sub)' + 6 'DAW Out 7' + 7 'DAW Out 8' + 8 'DAW Out 9' + 9 'DAW Out 10' + 10 'DAW Out 11' + 11 'DAW Out 12' + 12 'DAW Out 13' + 13 'DAW Out 14' + 14 'DAW Out 15' + 15 'DAW Out 16' + + [stream 1] + ISOCHRONOUS = -1 (disabled) + SEQ_START = 0 + PCM channels = 10 + MIDI ports = 0 + AC3_CAPS = + NAMES: + 0 'ADAT 9' + 1 'ADAT 10' + 2 'ADAT 11' + 3 'ADAT 12' + 4 'ADAT 13' + 5 'ADAT 14' + 6 'ADAT 15' + 7 'ADAT 16' + 8 'DAW Out 17' + 9 'DAW Out 18' + +EXT_SYNC +-------- +CLOCK_SOURCE = 12 (internal) +LOCKED = yes +RATE = 2 (48000) +ADAT_USER_DATA = no-data + +EAP CAPABILITIES +---------------- + + +EAP STREAM FORMAT STAGING AREA (written, then LOADed — not the live config) +---------------------------------------------------------------------------- + + +EAP ROUTER STAGING AREA (written, then LOADed — not the live config) +--------------------------------------------------------------------- + + +EAP CURRENT ROUTER — low (32-48k) (0 entries) [ACTIVE] +-------------------------------------------------------- + + +EAP CURRENT ROUTER — middle (88.2-96k) (0 entries) +--------------------------------------------------- + + +EAP CURRENT ROUTER — high (176.4-192k) (0 entries) +--------------------------------------------------- + + +NOTES +-------- +- RX section is allocated for 4 stream block(s) but NUMBER reports 2. +- No extended application (EAP) space at 0xFFFFE0200000. diff --git a/tests/audio/DiceMultirateValidationTests.cpp b/tests/audio/DiceMultirateValidationTests.cpp index 6cceacffd..86a09060f 100644 --- a/tests/audio/DiceMultirateValidationTests.cpp +++ b/tests/audio/DiceMultirateValidationTests.cpp @@ -60,3 +60,65 @@ TEST(DiceMultirateValidation, EapFormatsAreNotOverwrittenByStaleRegisters) { EXPECT_EQ(withoutEap[1]->capture[0].pcmChannels, 16U); EXPECT_FALSE(withoutEap[2].has_value()); } + +namespace { +// FireStudio 2626 (issue #189 dump): no EAP, 16+MIDI and 10 channels each +// way at 48 kHz, CLOCK_CAPS 44.1-96 kHz. +ASFW::Audio::AudioStreamRuntimeCaps Firestudio2626Caps(uint32_t rate, uint16_t second) { + ASFW::Audio::AudioStreamRuntimeCaps caps{}; + caps.sampleRateHz = rate; + caps.deviceRateMask = 0x1e; + caps.deviceToHostStreamCount = caps.hostToDeviceStreamCount = 2; + caps.deviceToHostStreams[0] = caps.hostToDeviceStreams[0] = {.pcmChannels = 16, .am824Slots = 17, .midiPorts = 1}; + caps.deviceToHostStreams[1] = caps.hostToDeviceStreams[1] = {.pcmChannels = second, .am824Slots = second}; + return caps; +} + +std::vector NonEapCatalog(const ASFW::Audio::AudioStreamRuntimeCaps& caps) { + using namespace ASFW::Audio::DICE; + return WithProvisionalRates(caps.deviceRateMask, + DiceFormations(caps.deviceRateMask, SelectPublishedFormats(false, {}, caps)), caps.sampleRateHz); +} +} // namespace + +// Saffire.kext 4.3.0 announces every CLOCK_CAPS rate with the current mode's +// channel count and reads the real layout after switching (performFormatChange +// 0x5028, RestartStreaming 0xdb02). Without EAP the other mode is a +// placeholder: announced, never inferred. +TEST(DiceMultirateValidation, NonEapDeviceAnnouncesEveryClockCapsRate) { + using namespace ASFW::Audio; + const auto catalog = NonEapCatalog(Firestudio2626Caps(48000, 10)); + ASSERT_EQ(catalog.size(), 4U); + const std::array rates{44100, 48000, 88200, 96000}; + for (size_t i = 0; i < rates.size(); ++i) { + EXPECT_EQ(catalog[i].sampleRateHz, rates[i]); + EXPECT_EQ(catalog[i].layoutProvisional, rates[i] > 48000) << rates[i]; + EXPECT_FALSE(catalog[i].hardwareValidated); + EXPECT_EQ(catalog[i].capture, catalog[1].capture) << "the placeholder is the current layout"; + } + EXPECT_TRUE(Runtime::RateEnabled(catalog[3], 48000, true)); + const auto allocation = Runtime::MaximumFormationAllocation(catalog, {24576, 26, 26, 0}); + ASSERT_TRUE(allocation); + EXPECT_EQ(allocation->captureChannelCapacity, ASFW::Encoding::kMaxPcmChannels); +} + +TEST(DiceMultirateValidation, AttachedAtTwoXTheLowRatesArePlaceholders) { + const auto catalog = NonEapCatalog(Firestudio2626Caps(96000, 2)); + ASSERT_EQ(catalog.size(), 4U); + EXPECT_TRUE(catalog[0].layoutProvisional); + EXPECT_TRUE(catalog[1].layoutProvisional); + EXPECT_FALSE(catalog[2].layoutProvisional); + EXPECT_FALSE(catalog[3].layoutProvisional); + EXPECT_EQ(catalog[1].capture[1].pcmChannels, 2U) << "the 2x layout stands in until 48 kHz is read"; +} + +TEST(DiceMultirateValidation, NoPlaceholderWithoutAKnownCurrentLayout) { + using namespace ASFW::Audio::DICE; + auto caps = Firestudio2626Caps(48000, 10); + caps.hostToDeviceStreams[0].am824Slots = 3; // not a coherent register layout + EXPECT_TRUE(NonEapCatalog(caps).empty()); + caps = Firestudio2626Caps(48000, 10); + const auto measured = DiceFormations(0x06, SelectPublishedFormats(false, {}, caps)); + // 48 kHz absent from the mask: nothing to stand in for the other rates. + EXPECT_EQ(WithProvisionalRates(0x18, measured, 32000).size(), measured.size()); +} diff --git a/tests/audio/NubGeometryRoundTripTests.cpp b/tests/audio/NubGeometryRoundTripTests.cpp index fc9a79557..93928e3c6 100644 --- a/tests/audio/NubGeometryRoundTripTests.cpp +++ b/tests/audio/NubGeometryRoundTripTests.cpp @@ -384,3 +384,23 @@ TEST(NubGeometryRoundTrip, MotuHostAndWireWidthsSurviveTheCrossingSeparately) { EXPECT_EQ(parsed.rateFormationCandidates[0].capture[0].packedPcmChunks,18U); EXPECT_EQ(parsed.rateFormationCandidates[0].playback[0].packedPcmChunks,14U); } + +TEST(NubGeometryRoundTrip, ProvisionalLayoutFlagSurvivesTheCrossing) { + auto device = MakeVeniceF24(); + ASFW::Audio::Runtime::RateFormation base{}; + base.sampleRateHz = 48000; + base.protocolSupported = true; + base.playback = {{2, 3, 1, {}}}; + base.capture = base.playback; + auto placeholder = base; + placeholder.sampleRateHz = 96000; + placeholder.layoutProvisional = true; + device.rateFormationCandidates = {base, placeholder}; + bool published = false; + const auto parsed = RoundTrip(device, published); + ASSERT_TRUE(published); + ASSERT_EQ(parsed.rateFormationCandidates.size(), 2U); + EXPECT_FALSE(parsed.rateFormationCandidates[0].layoutProvisional); + EXPECT_TRUE(parsed.rateFormationCandidates[1].layoutProvisional); + EXPECT_EQ(parsed.rateFormationCandidates, device.rateFormationCandidates); +} diff --git a/tests/audio/ResolvedAudioConfigurationTests.cpp b/tests/audio/ResolvedAudioConfigurationTests.cpp index 8e5adb415..cf38ee408 100644 --- a/tests/audio/ResolvedAudioConfigurationTests.cpp +++ b/tests/audio/ResolvedAudioConfigurationTests.cpp @@ -304,3 +304,98 @@ TEST(ResolvedAudioConfigurationTests, PackedPaddingDoesNotAllocateExtraHostChann EXPECT_FALSE(ResolveAudioConfiguration(48000,{&formation,1},policy, {49152,14,14,4104},0,ConfigurationValidationPolicy::HardwareBatch)); } + +// A DICE device without EAP announces every CLOCK_CAPS rate before it has run +// at it; such a rate carries a placeholder layout (layoutProvisional) until a +// rate change reads the device's registers at that rate. +namespace { +RateFormation Provisional(uint32_t rate, uint32_t channels) { + auto formation = Formation(rate, channels); + formation.hardwareValidated = false; + formation.layoutProvisional = true; + return formation; +} + +ASFW::Audio::RateHardwareObservation Observed(uint32_t rate, uint32_t channels, bool clockConfirmed = true) { + ASFW::Audio::RateHardwareObservation observed{}; + auto& caps = observed.caps; + caps.sampleRateHz = rate; + caps.hostInputPcmChannels = caps.hostOutputPcmChannels = channels; + caps.hostToDeviceStreamCount = caps.deviceToHostStreamCount = 1; + caps.hostToDeviceStreams[0] = caps.deviceToHostStreams[0] = { + .pcmChannels = static_cast(channels), + .am824Slots = static_cast(channels + 1), .midiPorts = 1}; + observed.clockConfirmed = clockConfirmed; + return observed; +} + +ASFW::Audio::Model::ASFWAudioDevice CommittedAt48kWithProvisional96k() { + ASFW::Audio::Model::ASFWAudioDevice prior{}; + prior.currentSampleRate = 48000; + prior.sampleRates = {48000, 96000}; + prior.rateFormationCandidates = {Formation(48000, 16), Provisional(96000, 16)}; + return *ASFW::Audio::Model::WithRateFormation(prior, 48000); +} +} // namespace + +TEST(ResolvedAudioConfigurationTests, ObservedLayoutReplacesOnlyAPlaceholder) { + using namespace ASFW::Audio; + const auto committed = CommittedAt48kWithProvisional96k(); + EXPECT_TRUE(Model::LayoutIsProvisional(committed, 96000)); + EXPECT_FALSE(Model::LayoutIsProvisional(committed, 48000)); + EXPECT_FALSE(Model::LayoutIsProvisional(committed, 44100)); + + const auto learned = Model::WithObservedRateCatalog(committed, Formation(96000, 12)); + ASSERT_TRUE(learned); + EXPECT_FALSE(Model::LayoutIsProvisional(*learned, 96000)); + EXPECT_EQ(learned->currentSampleRate, 48000U) << "learning a layout does not change the live format"; + EXPECT_EQ(learned->inputChannelCount, 16U); + EXPECT_EQ(learned->rateFormationCandidates.size(), 2U); + const auto at96 = Model::WithRateFormation(*learned, 96000); + ASSERT_TRUE(at96); + EXPECT_EQ(at96->inputChannelCount, 12U); + EXPECT_EQ(at96->outputChannelCount, 12U); + + // Once measured, the layout is known geometry: stale registers cannot + // overwrite it, and a placeholder is never an observation. + EXPECT_FALSE(Model::WithObservedRateCatalog(*learned, Formation(96000, 16))); + EXPECT_FALSE(Model::WithObservedRateCatalog(committed, Provisional(96000, 12))); +} + +TEST(ResolvedAudioConfigurationTests, ProvisionalRateIsSettledByItsLockedClockOnly) { + using namespace ASFW::Audio; + const auto committed = CommittedAt48kWithProvisional96k(); + // The placeholder says 16; the device reads 12 at 96 kHz. A confirmed clock + // settles the rate, and the 12 are what the window installs. + EXPECT_TRUE(ObservationSettlesRate(committed, Observed(96000, 12))); + EXPECT_FALSE(ObservationSettlesRate(committed, Observed(96000, 12, false))); + // A measured rate still has to match its formation (Pro 24 DSP, 2026-10-08: + // stale registers after a clock change). + EXPECT_TRUE(ObservationSettlesRate(committed, Observed(48000, 16))); + EXPECT_FALSE(ObservationSettlesRate(committed, Observed(48000, 12))); + EXPECT_FALSE(ObservationSettlesRate(committed, Observed(88200, 16))) << "not in the catalog"; + const auto learned = Model::WithObservedRateCatalog(committed, Formation(96000, 12)); + ASSERT_TRUE(learned); + EXPECT_TRUE(ObservationSettlesRate(*learned, Observed(96000, 12))); + EXPECT_FALSE(ObservationSettlesRate(*learned, Observed(96000, 16))); +} + +TEST(ResolvedAudioConfigurationTests, ProvisionalRateReservesTheProtocolLimit) { + const std::vector measured{Formation(48000, 18)}; + const auto known = MaximumFormationAllocation(measured, {24576, 0, 0, 0}); + ASSERT_TRUE(known); + EXPECT_EQ(known->playbackChannelCapacity, 18U); + EXPECT_EQ(known->captureChannelCapacity, 18U); + // Attached at 96 kHz with 18 channels, 48 kHz may need more: the mappings + // are reserved once and never replaced, so reserve what a layout can be. + const std::vector withPlaceholder{Provisional(48000, 18), Formation(96000, 18)}; + const auto reserved = MaximumFormationAllocation(withPlaceholder, {24576, 0, 0, 0}); + ASSERT_TRUE(reserved); + EXPECT_EQ(reserved->playbackChannelCapacity, ASFW::Encoding::kMaxPcmChannels); + EXPECT_EQ(reserved->captureChannelCapacity, ASFW::Encoding::kMaxPcmChannels); + EXPECT_GT(reserved->maxPacketBytes, known->maxPacketBytes); + const auto resolved = ResolveAudioConfiguration(48000, withPlaceholder, policy, *reserved, 1, + ConfigurationValidationPolicy::HardwareBatch); + ASSERT_TRUE(resolved); + EXPECT_EQ(resolved->captureChannels, 18U) << "the HAL still sees the placeholder"; +} diff --git a/tests/devices/CMakeLists.txt b/tests/devices/CMakeLists.txt index 10b063b73..1808de5b6 100644 --- a/tests/devices/CMakeLists.txt +++ b/tests/devices/CMakeLists.txt @@ -540,6 +540,18 @@ add_device_test(DiceMultirateClockTests "${ASFW_DRIVER_DIR}/Discovery/DeviceRegistry.cpp") target_compile_definitions(DiceMultirateClockTests PRIVATE ASFW_DICE_MULTIRATE_VALIDATION=1) +# Rates of a DICE without EAP: announced from CLOCK_CAPS, layout read after the +# switch. The flag matches every Xcode build. +add_device_test(DiceNonEapRateTests + DiceNonEapRateTests.cpp + "${ASFW_DRIVER_DIR}/Audio/Protocols/DICE/Core/DICETransaction.cpp" + "${ASFW_DRIVER_DIR}/Audio/Protocols/DICE/Core/DiceDeviceIo.cpp" + "${ASFW_DRIVER_DIR}/Audio/Protocols/DICE/Core/DiceNotificationRouter.cpp" + "${ASFW_DRIVER_DIR}/Audio/Protocols/DICE/Core/DiceFamilyDriver.cpp" + "${ASFW_DRIVER_DIR}/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp" + "${ASFW_DRIVER_DIR}/Discovery/DeviceRegistry.cpp") +target_compile_definitions(DiceNonEapRateTests PRIVATE ASFW_DICE_MULTIRATE_VALIDATION=1) + # RME Fireface adapter on the audio device host. add_device_test(RmeFamilyAdapterTests RmeFamilyAdapterTests.cpp diff --git a/tests/devices/DiceFamilyAdapterTests.cpp b/tests/devices/DiceFamilyAdapterTests.cpp index e6433cae9..b962321f6 100644 --- a/tests/devices/DiceFamilyAdapterTests.cpp +++ b/tests/devices/DiceFamilyAdapterTests.cpp @@ -483,3 +483,35 @@ TEST(DescribeGeometryTest, GeometryReadFailureIsRefusedAsGeometryLoadFailed) { } } // namespace + +// Issue #189: FireStudio 2626 (vendor 0x000A92, model 0x08) has no catalog +// row, so it is published through the generic DICE fallback: two streams each +// way (16 + 10 PCM), no EAP. It published on build 19 and not after clock +// preparation began gating publication, where one GLOBAL read failing while +// the device applied the clock ended the preparation and refused the device. +TEST(DescribeFireStudio2626Test, PublishedThroughAFailedClockPollRead) { + const SessionShape shape{"firestudio-2626", 0x000a92, 0x000008, 0x000a92, + ASFW::Testing::Session::kDiceUnitVersion, + &ASFW::Testing::DICE::DiceDeviceImages::kFirestudio2626, false, + 0x000A92040210A2EFULL}; + SessionRig rig(shape); + rig.bus.SetClockSelectWriteHandler([&rig] { + rig.bus.Device().RaiseNotification(ASFW::Audio::DICE::Notify::kClockAccepted); + rig.bus.FailNext(ASFW::Testing::OpKind::Read, 0xE0000028U, + ASFW::Async::AsyncStatus::kBusyRetryExhausted); + }); + DiceFamilyAdapter adapter{rig.notifications}; + const auto in = MakeInput(rig); + ASSERT_NE(in.policy, nullptr) << "no audio policy: the generic DICE fallback refused the identity"; + const auto result = Describe(adapter, in, rig.timer); + ASSERT_TRUE(result.has_value()); + const auto* device = std::get_if(&*result); + if (device == nullptr) { + const auto* refusal = std::get_if(&*result); + FAIL() << "refused: " << (refusal ? refusal->reason : "(note)"); + } + EXPECT_EQ(device->inputChannelCount, 26U); + EXPECT_EQ(device->outputChannelCount, 26U); + EXPECT_EQ(device->captureStreams.size(), 2U); + EXPECT_EQ(device->playbackStreams.size(), 2U); +} diff --git a/tests/devices/DiceNonEapRateTests.cpp b/tests/devices/DiceNonEapRateTests.cpp new file mode 100644 index 000000000..916a66857 --- /dev/null +++ b/tests/devices/DiceNonEapRateTests.cpp @@ -0,0 +1,126 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 ASFireWire Project +// +// DiceNonEapRateTests.cpp - rates of a DICE device without EAP. +// +// Built with ASFW_DICE_MULTIRATE_VALIDATION=1, as every Xcode build is +// (project.yml), so 2x and 4x clocks are programmable. + +#include + +#include "DICEDuplexTestSupport.hpp" +#include "FakeDiceWaitClock.hpp" +#include "FakeTimerScheduler.hpp" +#include "SyntheticDiceImages.hpp" + +#include "Audio/Protocols/DICE/TCAT/DICETcatProtocol.hpp" +#include "Audio/Protocols/Duplex/FamilyDriver.hpp" +#include "Audio/Runtime/RateValidation.hpp" + +#include +#include +#include +#include + +namespace { + +using namespace ASFW::Testing::DICE; + +// The real TCAT protocol over a simulated device. +struct ProtocolRig { + explicit ProtocolRig(const DiceDeviceImage& image) : bus(image) { + bus.Device().ResetToIdle(); + protocol = std::make_unique( + bus, bus, routeState.registry, routeState.route, nullptr, waitClock, ¬ifications); + EXPECT_EQ(protocol->Initialize(), kIOReturnSuccess); + family = protocol->AsFamilyDriver(); + EXPECT_NE(family, nullptr); + family->SetTeardownCancelToken(&cancel); + bus.RouteNotificationsTo(notifications); + } + + // Start an async stage and advance virtual time until it completes. + template + IOReturn Run(const char*, Start&& start) { + std::optional result; + start([&result](IOReturn status) { result = status; }); + for (uint32_t ticks = 0; !result && ticks < 400 && timer.PendingCount() != 0; ++ticks) + timer.Advance(10'000'000ULL); + return result.value_or(kIOReturnTimeout); + } + + RecordingFireWireBus bus; + RouteState routeState; + ASFW::Testing::FakeTimerScheduler timer; + ASFW::Testing::FakeDiceWaitClock waitClock{timer}; + ASFW::Audio::DICE::DiceNotificationRouter notifications{routeState.registry}; + std::atomic cancel{false}; + std::unique_ptr protocol; + ASFW::Audio::FamilyDriver* family{nullptr}; +}; + +} // namespace + +static_assert(ASFW::Audio::Runtime::kDiceHardwareBatch); + +// A DICE without EAP describes its current mode only, so every CLOCK_CAPS rate +// is announced and a host-selected rate's layout is read once the clock is +// locked there, as TCAT's RestartStreaming does (Saffire.kext 4.3.0 0xdb02). +// A re-describe after a bus reset never puts a placeholder back over it. +TEST(DiceNonEapRates, HostSelectedRateLayoutIsReadAfterTheSwitch) { + using ASFW::Audio::Runtime::RateFormation; + ProtocolRig rig(SyntheticDiceImages::kFirestudio2626RegistersFollowMode); + const auto formation = [&](uint32_t rate) { + RateFormation result{}; + if (const auto catalog = rig.protocol->RateFormations()) { + const auto found = std::ranges::find(*catalog, rate, &RateFormation::sampleRateHz); + if (found != catalog->end()) result = *found; + } + EXPECT_EQ(result.sampleRateHz, rate) << "not in the catalog"; + return result; + }; + const auto capture = [](const RateFormation& f) { + uint32_t total = 0; + for (const auto& stream : f.capture) total += stream.pcmChannels; + return total; + }; + ASSERT_EQ(rig.Run("initial", [&](auto done) { rig.protocol->EnsureRuntimeStreamGeometry(done); }), + kIOReturnSuccess); + ASSERT_EQ(rig.protocol->RateFormations()->size(), 4U); + EXPECT_FALSE(formation(48000).layoutProvisional); + EXPECT_TRUE(formation(96000).layoutProvisional); + EXPECT_EQ(capture(formation(96000)), 26U) << "placeholder: the 48 kHz layout"; + + // The rate window's ApplyRate programs the clock; its readback observes it. + const auto applied = rig.family->ApplyClockIdle({.sampleRateHz = 96000}); + ASSERT_TRUE(applied) << applied.error(); + ASSERT_EQ(rig.Run("observe", [&](auto done) { + rig.protocol->ReadRateObservation([done](IOReturn status, auto observed) { + EXPECT_TRUE(observed.clockConfirmed); + EXPECT_EQ(observed.caps.sampleRateHz, 96000U); + EXPECT_EQ(observed.caps.hostInputPcmChannels, 18U); + done(status); + }); + }), kIOReturnSuccess); + EXPECT_FALSE(formation(96000).layoutProvisional); + EXPECT_EQ(capture(formation(96000)), 18U); + EXPECT_TRUE(formation(88200).layoutProvisional) << "one observed rate is evidence for that rate only"; + EXPECT_EQ(capture(formation(48000)), 26U); + + // Bus reset: the protocol re-describes the device at the new generation. + rig.bus.BusReset(); + ASFW::Discovery::ConfigROM rom{}; + rom.bib.guid = rig.routeState.route.guid; + rom.gen = rig.bus.GetGeneration(); + rom.nodeId = rig.routeState.route.nodeId; + (void)rig.routeState.registry.UpsertFromROM(rom, ASFW::Discovery::LinkPolicy{}); + const auto next = rig.routeState.registry.CurrentRoute(rom.bib.guid); + ASSERT_TRUE(next); + rig.protocol->UpdateRuntimeContext(*next, nullptr); + ASSERT_EQ(rig.Run("re-describe", [&](auto done) { rig.protocol->EnsureRuntimeStreamGeometry(done); }), + kIOReturnSuccess); + EXPECT_FALSE(formation(48000).layoutProvisional); + EXPECT_EQ(capture(formation(48000)), 26U); + EXPECT_FALSE(formation(96000).layoutProvisional); + EXPECT_EQ(capture(formation(96000)), 18U); +} diff --git a/tests/devices/DiceWireCharacterizationTests.cpp b/tests/devices/DiceWireCharacterizationTests.cpp index a056cbb96..d4c60fdbf 100644 --- a/tests/devices/DiceWireCharacterizationTests.cpp +++ b/tests/devices/DiceWireCharacterizationTests.cpp @@ -368,17 +368,25 @@ TEST(DiceInitialPreparation, StaleGlobalAcceptanceDoesNotBypassTheAcceptanceWait EXPECT_EQ(rig.waitClock.Sleeps(), 0U); } -TEST(DiceInitialPreparation, UnlockedClockTimesOutWithoutEnablingStreams) { +// The lock wait is advisory for publication (TCAT RestartStreaming 0xd8e2 +// logs a lock timeout and creates the streams); the stream start keeps its +// own lock gate and does not enable an unlocked device. +TEST(DiceInitialPreparation, UnlockedClockPublishesButTheStartDoesNotEnable) { DiceRig rig(DiceDeviceImages::kVeniceF24, {.clockResponse = DiceClockResponse::kAcceptNeverLock}); std::optional result; rig.protocol->EnsureRuntimeStreamGeometry([&](IOReturn status) { result = status; }); rig.timer.Advance(3'400'000'000ULL); ASSERT_TRUE(result.has_value()); - EXPECT_EQ(*result, kIOReturnTimeout); + EXPECT_EQ(*result, kIOReturnSuccess); EXPECT_EQ(rig.bus.Enable(), 0U); EXPECT_EQ(rig.waitClock.Sleeps(), 0U); EXPECT_EQ(rig.timer.PendingCount(), 0U); + + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(rig.protocol->GetRuntimeAudioStreamCaps(caps)); + EXPECT_FALSE(rig.family->Configure(rig.ChannelsFor(caps), {.sampleRateHz = caps.sampleRateHz})); + EXPECT_EQ(rig.bus.Enable(), 0U); } TEST(DiceInitialPreparation, StaleRouteCancelsWithoutTouchingTheNewGeneration) { @@ -703,3 +711,53 @@ INSTANTIATE_TEST_SUITE_P(Recorded, DiceWireScenarios, KeyName); } // namespace + + +// Issue #189: a FireStudio 2626 (no EAP, two streams each way) published on +// build 19 and not after the clock preparation began gating publication. The +// TCAT driver treats the waits after CLOCK_SELECT as advisory +// (PaeFireStudio RestartStreaming 0xd8e2), so a GLOBAL read that fails while +// the device applies the clock must not end the preparation. +TEST(DiceInitialPreparation, FailedGlobalPollReadKeepsWaiting) { + using ASFW::Async::AsyncStatus; + for (const auto status : {AsyncStatus::kBusyRetryExhausted, AsyncStatus::kTimeout}) { + SCOPED_TRACE(ASFW::Async::ToString(status)); + DiceRig rig(DiceDeviceImages::kFirestudio2626); + rig.bus.SetClockSelectWriteHandler([&rig, status] { + rig.bus.Device().RaiseNotification(NotifyBits::kClockAccepted); + rig.bus.FailNext(OpKind::Read, 0xE0000028U, status); + }); + std::optional result; + rig.protocol->EnsureRuntimeStreamGeometry([&](IOReturn s) { result = s; }); + rig.timer.Advance(500'000'000ULL); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, kIOReturnSuccess); + AudioStreamRuntimeCaps caps{}; + ASSERT_TRUE(rig.protocol->GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(caps.hostInputPcmChannels, 26U); + EXPECT_EQ(caps.hostOutputPcmChannels, 26U); + EXPECT_EQ(caps.deviceToHostStreamCount, 2U); + EXPECT_EQ(caps.hostToDeviceStreamCount, 2U); + EXPECT_EQ(rig.bus.Enable(), 0U); + } +} + +// Same reference: a device that never confirms the clock, by notice or by a +// settled STATUS, is still described after the deadline. +TEST(DiceInitialPreparation, UnconfirmedClockStillDescribesTheDevice) { + DiceRig rig(DiceDeviceImages::kFirestudio2626); + rig.bus.SetClockSelectWriteHandler([&rig] { + rig.bus.Device().SetAchievedClock(ClockRateIndex::k48000, false); + }); + std::optional result; + rig.protocol->EnsureRuntimeStreamGeometry([&](IOReturn s) { result = s; }); + rig.timer.Advance(3'000'000'000ULL); + EXPECT_FALSE(result.has_value()) << "the wait still runs to its deadline"; + rig.timer.Advance(600'000'000ULL); + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(*result, kIOReturnSuccess); + AudioStreamRuntimeCaps caps{}; + EXPECT_TRUE(rig.protocol->GetRuntimeAudioStreamCaps(caps)); + EXPECT_EQ(rig.bus.Enable(), 0U); + EXPECT_EQ(rig.timer.PendingCount(), 0U); +} diff --git a/tests/devices/SessionTestSupport.hpp b/tests/devices/SessionTestSupport.hpp index 9d24c793d..91a66a306 100644 --- a/tests/devices/SessionTestSupport.hpp +++ b/tests/devices/SessionTestSupport.hpp @@ -471,6 +471,7 @@ struct SessionShape { uint32_t unitVersion; const ::ASFW::Testing::DICE::DiceDeviceImage* diceImage{nullptr}; // null: scripted device bool spro24Dsp{false}; + uint64_t guid{0}; // 0: synthetic OUI-prefixed GUID }; inline constexpr uint32_t kDiceUnitVersion = 0x000001; @@ -480,6 +481,7 @@ inline constexpr uint32_t kAvcUnitVersion = 0x010001; // The GUID carries the vendor's OUI in its top 24 bits, as a real one does; // the catalog cross-checks it. inline constexpr uint64_t SessionGuid(const SessionShape& shape) noexcept { + if (shape.guid != 0) return shape.guid; return (uint64_t{shape.vendorId} << 40) | 0x0004713ULL; } @@ -499,6 +501,7 @@ inline ConfigROM MakeSessionRom(const SessionShape& shape, Generation gen) { unit.offsetQuadlets = 5; unit.unitSpecId = shape.unitSpecifier; unit.unitSwVersion = shape.unitVersion; + if (shape.guid != 0) unit.modelId = shape.modelId; // a captured DICE unit directory carries it rom.unitDirectories.push_back(unit); return rom; } diff --git a/tests/golden/dice/firestudio-2626__start-stop-48k.trace b/tests/golden/dice/firestudio-2626__start-stop-48k.trace new file mode 100644 index 000000000..1ba27a729 --- /dev/null +++ b/tests/golden/dice/firestudio-2626__start-stop-48k.trace @@ -0,0 +1,87 @@ +## EnsureRuntimeStreamGeometry +R ffff.e0000000 40 @s400 +R ffff.e0000028 360 @s400 +L ffff.e0000028 ffff000000000000 -> ffc0000100000000 = ffff000000000000 @s400 +R ffff.e0000028 360 @s400 +W ffff.e0000078 00000000 @s400 +W ffff.e0000074 0000020c @s400 +# notify 0x20 +R ffff.e0000028 360 @s400 +R ffff.e0000028 104 @s400 +R ffff.e0000190 512 @s400 +R ffff.e0000390 56 @s400 +R ffff.e00003c8 512 @s400 +R ffff.e00005c8 512 @s400 +R ffff.e00007c8 104 @s400 +R ffff.e0200000 72 @s400 +# waited 400ms +## -> 0x00000000 +## PrepareDuplex +R ffff.e0000000 40 @s400 +R ffff.e0000028 360 @s400 +W ffff.e0000078 00000000 @s400 +W ffff.e0000074 0000020c @s400 +# notify 0x20 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000190 4 @s400 +R ffff.e00003c8 4 @s400 +R ffff.e0000194 4 @s400 +R ffff.e0000198 4 @s400 +R ffff.e000019c 4 @s400 +R ffff.e00001a0 4 @s400 +R ffff.e00001a4 4 @s400 +R ffff.e00001a8 256 @s400 +R ffff.e00003cc 4 @s400 +R ffff.e00003d0 4 @s400 +R ffff.e00003dc 4 @s400 +R ffff.e00003d4 4 @s400 +R ffff.e00003d8 4 @s400 +R ffff.e00003e0 256 @s400 +R ffff.e0000028 104 @s400 +R ffff.e0000190 512 @s400 +R ffff.e0000390 56 @s400 +R ffff.e00003c8 512 @s400 +R ffff.e00005c8 512 @s400 +R ffff.e00007c8 104 @s400 +## -> 0x00000000 +## ProgramRx +R ffff.e00003cc 4 @s400 +W ffff.e00003d0 00000000 @s400 +W ffff.e00003d4 00000000 @s400 +W ffff.e00004e8 00000003 @s400 +W ffff.e00004ec 00000000 @s400 +## -> 0x00000000 +## ProgramTxAndEnableDuplex +R ffff.e0000194 4 @s400 +W ffff.e0000198 00000001 @s400 +W ffff.e00001a4 00000002 @s400 +W ffff.e00002b0 00000002 @s400 +W ffff.e00002bc 00000002 @s400 +W ffff.e0000078 00000001 @s400 +## -> 0x00000000 +## ConfirmDuplexStart +R ffff.e000007c 4 @s400 +R ffff.e0000030 4 @s400 +R ffff.e0000080 4 @s400 +R ffff.e0000028 104 @s400 +R ffff.e0000190 512 @s400 +R ffff.e0000390 56 @s400 +R ffff.e00003c8 512 @s400 +R ffff.e00005c8 512 @s400 +R ffff.e00007c8 104 @s400 +## -> 0x00000000 +## StopDuplex +W ffff.e0000078 00000000 @s400 +R ffff.e0000194 4 @s400 +W ffff.e0000198 ffffffff @s400 +W ffff.e00001a4 00000002 @s400 +W ffff.e00002b0 ffffffff @s400 +W ffff.e00002bc 00000002 @s400 +R ffff.e00003cc 4 @s400 +W ffff.e00003d0 ffffffff @s400 +W ffff.e00003d4 00000000 @s400 +W ffff.e00004e8 ffffffff @s400 +W ffff.e00004ec 00000000 @s400 +## -> 0x00000000 +== owner=ffc0000100000000 enable=0 tx=[-1,-1] rx=[-1,-1] diff --git a/tests/golden/dice/saffire-pro24-dsp__clock-never-locks.trace b/tests/golden/dice/saffire-pro24-dsp__clock-never-locks.trace index 18b907dbc..67883a9b6 100644 --- a/tests/golden/dice/saffire-pro24-dsp__clock-never-locks.trace +++ b/tests/golden/dice/saffire-pro24-dsp__clock-never-locks.trace @@ -308,7 +308,232 @@ R ffff.e0000028 380 @s400 R ffff.e0000028 380 @s400 R ffff.e0000028 380 @s400 R ffff.e0000028 380 @s400 +R ffff.e0000028 104 @s400 +R ffff.e00001a4 512 @s400 +R ffff.e00003a4 56 @s400 +R ffff.e00003dc 512 @s400 +R ffff.e00005dc 512 @s400 +R ffff.e00007dc 104 @s400 +R ffff.e0200000 72 @s400 # waited 3400ms +## -> 0x00000000 +## PrepareDuplex +R ffff.e0000000 40 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0200000 72 @s400 +W ffff.e0000078 00000000 @s400 +W ffff.e0000074 0000020c @s400 +# notify 0x20 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +R ffff.e0000028 380 @s400 +W ffff.e0000078 00000000 @s400 +R ffff.e00001a8 4 @s400 +W ffff.e00001ac ffffffff @s400 +W ffff.e00001b8 00000002 @s400 +R ffff.e00003e0 4 @s400 +W ffff.e00003e4 ffffffff @s400 +W ffff.e00003e8 00000000 @s400 +# waited 2000ms ## -> 0xe00002d6 ## StopDuplex ## -> 0x00000000 diff --git a/tests/golden/dice/venice-f24__clock-never-locks.trace b/tests/golden/dice/venice-f24__clock-never-locks.trace index cda265487..81728b348 100644 --- a/tests/golden/dice/venice-f24__clock-never-locks.trace +++ b/tests/golden/dice/venice-f24__clock-never-locks.trace @@ -307,7 +307,235 @@ R ffff.e0000028 360 @s400 R ffff.e0000028 360 @s400 R ffff.e0000028 360 @s400 R ffff.e0000028 360 @s400 +R ffff.e0000028 104 @s400 +R ffff.e0000190 512 @s400 +R ffff.e0000390 56 @s400 +R ffff.e00003c8 512 @s400 +R ffff.e00005c8 512 @s400 +R ffff.e00007c8 104 @s400 +R ffff.e0200000 72 @s400 # waited 3400ms +## -> 0x00000000 +## PrepareDuplex +R ffff.e0000000 40 @s400 +R ffff.e0000028 360 @s400 +W ffff.e0000078 00000000 @s400 +W ffff.e0000074 0000020c @s400 +# notify 0x20 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +R ffff.e0000028 360 @s400 +W ffff.e0000078 00000000 @s400 +R ffff.e0000194 4 @s400 +W ffff.e0000198 ffffffff @s400 +W ffff.e00001a4 00000002 @s400 +W ffff.e00002b0 ffffffff @s400 +W ffff.e00002bc 00000002 @s400 +R ffff.e00003cc 4 @s400 +W ffff.e00003d0 ffffffff @s400 +W ffff.e00003d4 00000000 @s400 +W ffff.e00004e8 ffffffff @s400 +W ffff.e00004ec 00000000 @s400 +# waited 2000ms ## -> 0xe00002d6 ## StopDuplex ## -> 0x00000000 diff --git a/tests/support/DiceDeviceImages.inc b/tests/support/DiceDeviceImages.inc index 9b01d875d..88267a34c 100644 --- a/tests/support/DiceDeviceImages.inc +++ b/tests/support/DiceDeviceImages.inc @@ -242,7 +242,41 @@ inline constexpr DiceDeviceImage kStudiolive3242{ .rateModes = {}, }; -inline constexpr std::array kAll{{ +// PreSonus FIRESTUDIO — presonus-firestudio-2626.txt +inline constexpr DiceDeviceImage kFirestudio2626{ + .key = "firestudio-2626", + .reportedModel = "PreSonus FIRESTUDIO", + .source = "presonus-firestudio-2626.txt", + .guid = 0x000A92040210A2EFULL, + .globalSection = {.offsetQuadlets = 10U, .sizeQuadlets = 90U}, + .txSection = {.offsetQuadlets = 100U, .sizeQuadlets = 142U}, + .rxSection = {.offsetQuadlets = 242U, .sizeQuadlets = 282U}, + .extSyncSection = {.offsetQuadlets = 524U, .sizeQuadlets = 4U}, + .hasExtension = false, + .owner = 0xFFFF000000000000ULL, + .notification = 0x00000000U, + .nickname = "FIRESTUDIO", + .clockSelect = 0x0000020CU, + .enable = 0U, + .status = 0x00000201U, + .extStatus = 0x00000000U, + .sampleRate = 48000U, + .version = 0x01000400U, + .clockCaps = 0x13FF001EU, + .clockSourceNames = "SPDIF\\ADAT\\Word Clock\\Unused\\Unused\\Unused\\Unused\\Internal\\\\", + .txEntryQuadlets = 70U, + .rxEntryQuadlets = 70U, + .txCount = 2U, + .tx = {{{.iso = -1, .pcm = 16U, .midi = 1U, .speedOrSeqStart = 2U, .names = "Mic/Inst 1\\Mic/Inst 2\\Mic/Line 3\\Mic/Line 4\\Mic/Line 5\\Mic/Line 6\\Mic/Line 7\\Mic/Line 8\\ADAT 1\\ADAT 2\\ADAT 3\\ADAT 4\\ADAT 5\\ADAT 6\\ADAT 7\\ADAT 8\\\\"}, {.iso = -1, .pcm = 10U, .midi = 0U, .speedOrSeqStart = 2U, .names = "ADAT 9\\ADAT 10\\ADAT 11\\ADAT 12\\ADAT 13\\ADAT 14\\ADAT 15\\ADAT 16\\SPDIF L\\SPDIF R\\\\"}}}, + .rxCount = 2U, + .rx = {{{.iso = -1, .pcm = 16U, .midi = 1U, .speedOrSeqStart = 0U, .names = "DAW Out 1 (L)\\DAW Out 2 (R)\\DAW Out 3 (LS)\\DAW Out 4 (RS)\\DAW Out 5 (C)\\DAW Out 6 (Sub)\\DAW Out 7\\DAW Out 8\\DAW Out 9\\DAW Out 10\\DAW Out 11\\DAW Out 12\\DAW Out 13\\DAW Out 14\\DAW Out 15\\DAW Out 16\\\\"}, {.iso = -1, .pcm = 10U, .midi = 0U, .speedOrSeqStart = 0U, .names = "ADAT 9\\ADAT 10\\ADAT 11\\ADAT 12\\ADAT 13\\ADAT 14\\ADAT 15\\ADAT 16\\DAW Out 17\\DAW Out 18\\\\"}}}, + .hasExtSync = true, + .extSync = {.clockSource = 12U, .locked = 1U, .rateIndex = 2U, .adatUserData = 0x10U}, + .hasRateModeFormats = false, + .rateModes = {}, +}; + +inline constexpr std::array kAll{{ &kSaffirePro24Dsp, &kVeniceF24, &kVeniceF32, @@ -250,6 +284,7 @@ inline constexpr std::array kAll{{ &kFirestudioProject, &kMultimix, &kStudiolive3242, + &kFirestudio2626, }}; } // namespace DiceDeviceImages diff --git a/tests/support/SyntheticDiceImages.hpp b/tests/support/SyntheticDiceImages.hpp index edcdc5398..7407d368a 100644 --- a/tests/support/SyntheticDiceImages.hpp +++ b/tests/support/SyntheticDiceImages.hpp @@ -25,4 +25,27 @@ constexpr DiceDeviceImage MakeMultimixTwoPlayback() { } inline constexpr DiceDeviceImage kMultimixTwoPlayback = MakeMultimixTwoPlayback(); +// The recorded FireStudio 2626 (no EAP) with stream registers that follow the +// rate mode, as a DICE's TX/RX registers describe its current mode only. No +// dump shows the 2626 at 2x: the 2x layout here (16+MIDI and 2 each way) is +// invented to differ from 1x, so a test can tell which mode it read. +constexpr DiceDeviceImage MakeFirestudio2626RegistersFollowMode() { + DiceDeviceImage image = DiceDeviceImages::kFirestudio2626; + image.key = "firestudio-2626-registers-follow-mode"; + image.hasRateModeFormats = true; + for (auto* direction : {&image.rateModes[0].tx, &image.rateModes[0].rx}) { + direction->count = 2; + direction->streams[0] = {.pcm = 16, .midi = 1}; + direction->streams[1] = {.pcm = 10, .midi = 0}; + } + for (auto* direction : {&image.rateModes[1].tx, &image.rateModes[1].rx}) { + direction->count = 2; + direction->streams[0] = {.pcm = 16, .midi = 1}; + direction->streams[1] = {.pcm = 2, .midi = 0}; + } + image.rateModes[2] = image.rateModes[1]; + return image; +} +inline constexpr DiceDeviceImage kFirestudio2626RegistersFollowMode = MakeFirestudio2626RegistersFollowMode(); + } // namespace ASFW::Testing::DICE::SyntheticDiceImages diff --git a/tools/pydice/pydice/protocol/dice_report.py b/tools/pydice/pydice/protocol/dice_report.py index 1368e4d58..4c77214c5 100644 --- a/tools/pydice/pydice/protocol/dice_report.py +++ b/tools/pydice/pydice/protocol/dice_report.py @@ -330,8 +330,9 @@ def _validate(image: DiceDeviceImage) -> None: raise ValueError(f"{where}: no TX or RX streams") if len(image.tx_streams) > MAX_STREAMS or len(image.rx_streams) > MAX_STREAMS: raise ValueError(f"{where}: more than {MAX_STREAMS} streams in one direction") - if len(image.clock_source_names) != 13: - raise ValueError(f"{where}: expected 13 clock source names, " + # 13 sources at most; some firmware lists fewer (FireStudio 2626: 8). + if not 0 < len(image.clock_source_names) <= 13: + raise ValueError(f"{where}: expected 1 to 13 clock source names, " f"got {len(image.clock_source_names)}") @@ -458,6 +459,8 @@ def render_dice_images_cpp(images: list[tuple[str, DiceDeviceImage]]) -> str: ("multimix", "documentation/fixtures/DICE/alesismultimix.txt"), # Issue #190: 48 PCM capture and 34 PCM playback in one stream each. ("studiolive-3242", "documentation/fixtures/DICE/presonus3242.txt"), + # Issue #189: 16+10 PCM each way in two streams, no EAP. + ("firestudio-2626", "documentation/fixtures/DICE/presonus-firestudio-2626.txt"), ) diff --git a/tools/pydice/tests/test_dice_report.py b/tools/pydice/tests/test_dice_report.py index eb53d0d09..570f4101f 100644 --- a/tools/pydice/tests/test_dice_report.py +++ b/tools/pydice/tests/test_dice_report.py @@ -31,8 +31,12 @@ def _load(key: str): "studiolive-2442": (0x000A9204049204CB, 0x13000006, [(16, 0), (16, 0)], [(16, 0), (10, 0)]), "multimix": (0x00059504000005FE, 0x11000006, [(12, 0), (2, 0)], [(2, 0)]), "studiolive-3242": (0x000A9204050839AE, 0x10000004, [(48, 0)], [(34, 0)]), + "firestudio-2626": (0x000A92040210A2EF, 0x13FF001E, [(16, 1), (10, 0)], [(16, 1), (10, 0)]), } +# The FireStudio 2626 firmware lists 8 clock source names, not 13. +CLOCK_SOURCE_NAME_COUNT = {"firestudio-2626": 8} + @pytest.mark.parametrize("key", sorted(EXPECTED)) def test_geometry_matches_hand_transcription(key): @@ -47,7 +51,7 @@ def test_geometry_matches_hand_transcription(key): assert len(stream.names) == stream.pcm assert image.tx_entry_quadlets == 70 assert image.rx_entry_quadlets == 70 - assert len(image.clock_source_names) == 13 + assert len(image.clock_source_names) == CLOCK_SOURCE_NAME_COUNT.get(key, 13) def test_saffire_section_table_is_shifted_by_its_larger_global():