From f7d6b1288b4a4d157b3ebfa8ea6bb9fd02a446aa Mon Sep 17 00:00:00 2001 From: Stephan Terning <704431+stephanterning@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:19:19 +0200 Subject: [PATCH 1/2] Add guarded ZED-R16 fallback for DICE rate formats --- .../Protocols/DICE/Core/DiceRateFormats.hpp | 56 ++++++++++- .../Protocols/DICE/TCAT/DICETcatProtocol.cpp | 2 +- tests/audio/DiceMultirateValidationTests.cpp | 95 +++++++++++++++++++ 3 files changed, 147 insertions(+), 6 deletions(-) diff --git a/ASFWDriver/Audio/Protocols/DICE/Core/DiceRateFormats.hpp b/ASFWDriver/Audio/Protocols/DICE/Core/DiceRateFormats.hpp index 0defc0842..f3a86805e 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 @@ -77,18 +78,63 @@ DiceObservedFormat(const AudioStreamRuntimeCaps& caps) { return format; } +// ZED-R16 measured stream layouts; see issue #223. Capture MIDI is stream 0. +// These formats remain subject to the existing hardware-validation policy. +// Linux dice-stream.c:609-672 keeps unknown devices limited to the observed +// mode. This exception is identity- and geometry-gated; it never scales counts. +[[nodiscard]] inline std::optional +ZedR16MeasuredFormats(uint64_t guid, const AudioStreamRuntimeCaps& caps) { + // The DICE resolver already checks the unit identity. Check the GUID's + // OUI/category/product here as well; leave the serial number unrestricted. + // GUID layout: Linux dice.c:34-68 (check_dice_category). + if ((guid >> 32U) != 0x0004c404U || (static_cast(guid) >> 22U) != 0) + return std::nullopt; + if (caps.sampleRateHz != 44100 && caps.sampleRateHz != 48000 && caps.sampleRateHz != 88200 && + caps.sampleRateHz != 96000) + return std::nullopt; + const auto observed = DiceObservedFormat(caps); + const auto mode = DiceRateMode(caps.sampleRateHz); + if (!observed || !mode) + return std::nullopt; + DiceRateFormats measured{}; + measured[0] = DiceModeFormat{{{16, 16, 0, {}, 0}, {10, 10, 0, {}, 0}}, + {{16, 17, 1, {}, 1}, {10, 10, 0, {}, 0}}}; + measured[1] = DiceModeFormat{{{16, 16, 0, {}, 0}, {2, 2, 0, {}, 0}}, + {{16, 17, 1, {}, 1}, {2, 2, 0, {}, 0}}}; + const auto matches = [](const auto& observedStreams, const auto& measuredStreams) { + return std::ranges::equal( + observedStreams, measuredStreams, + [](const auto& observedStream, const auto& measuredStream) { + return observedStream.pcmChannels == measuredStream.pcmChannels && + observedStream.dataBlockSize == measuredStream.dataBlockSize && + observedStream.midiPortCount == measuredStream.midiPortCount; + }); + }; + if (!matches(observed->playback, measured[*mode]->playback) || + !matches(observed->capture, measured[*mode]->capture)) + return std::nullopt; + return measured; +} + /// The per-mode formats a device publishes. When the EAP read succeeded its /// 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. +/// describe another mode (:635-643). Without EAP use measured ZED-R16 modes +/// only when identity and live geometry match; otherwise only the observed +/// mode is known (Linux's fallback). +/// No clock probing or guessed scaling. [[nodiscard]] inline DiceRateFormats SelectPublishedFormats(bool eapRead, DiceRateFormats eap, - const AudioStreamRuntimeCaps& caps) { - if (eapRead) return eap; + const AudioStreamRuntimeCaps& caps, + uint64_t guid = 0) { + if (eapRead) + return eap; + if (const auto measured = ZedR16MeasuredFormats(guid, caps)) + return *measured; DiceRateFormats observedOnly{}; const auto mode = DiceRateMode(caps.sampleRateHz); const auto observed = DiceObservedFormat(caps); - if (mode && observed) observedOnly[*mode] = *observed; + if (mode && observed) + observedOnly[*mode] = *observed; return observedOnly; } diff --git a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp index 6b345e490..beab6a4ce 100644 --- a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp +++ b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp @@ -1142,7 +1142,7 @@ void DICETcatProtocol::EnsureRuntimeCapsLoaded(VoidCallback callback) { // (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); + formats, caps, guid_); auto formations = DiceFormations(caps.deviceRateMask, formats, runtimePolicy_.exposeDeviceToHostToCoreAudio); PublishRateFormations( diff --git a/tests/audio/DiceMultirateValidationTests.cpp b/tests/audio/DiceMultirateValidationTests.cpp index 6cceacffd..2386f4833 100644 --- a/tests/audio/DiceMultirateValidationTests.cpp +++ b/tests/audio/DiceMultirateValidationTests.cpp @@ -60,3 +60,98 @@ TEST(DiceMultirateValidation, EapFormatsAreNotOverwrittenByStaleRegisters) { EXPECT_EQ(withoutEap[1]->capture[0].pcmChannels, 16U); EXPECT_FALSE(withoutEap[2].has_value()); } + +namespace { +constexpr uint64_t kZedGuid = 0x0004c404000186a1ULL; +ASFW::Audio::AudioStreamRuntimeCaps ZedCaps(uint32_t rate) { + ASFW::Audio::AudioStreamRuntimeCaps caps{}; + caps.sampleRateHz = rate; + caps.deviceRateMask = 0x1e; + caps.deviceToHostStreamCount = caps.hostToDeviceStreamCount = 2; + const auto second = static_cast(rate > 48000 ? 2 : 10); + caps.deviceToHostStreams[0] = {.pcmChannels = 16, .am824Slots = 17, .midiPorts = 1}; + caps.hostToDeviceStreams[0] = {.pcmChannels = 16, .am824Slots = 16, .midiPorts = 0}; + caps.deviceToHostStreams[1] = {.pcmChannels = second, .am824Slots = second, .midiPorts = 0}; + caps.hostToDeviceStreams[1] = caps.deviceToHostStreams[1]; + return caps; +} +} // namespace + +// ZED-R16 format-selection regression fixtures for issue #223. +TEST(DiceMultirateValidation, ZedMeasuredModesResolveFromLowAndHighRateAttachment) { + using namespace ASFW::Audio; + for (const auto attachedRate : {44100U, 48000U, 88200U, 96000U}) { + const auto caps = ZedCaps(attachedRate); + const auto modes = DICE::SelectPublishedFormats(false, {}, caps, kZedGuid); + const auto formations = DICE::DiceFormations(caps.deviceRateMask, modes); + ASSERT_EQ(formations.size(), 4U); + const auto allocation = Runtime::MaximumFormationAllocation(formations, {49152, 26, 26, 0}); + ASSERT_TRUE(allocation); + for (const auto& formation : formations) { + EXPECT_FALSE(formation.hardwareValidated); + ASSERT_EQ(formation.capture.size(), 2U); + ASSERT_EQ(formation.playback.size(), 2U); + EXPECT_EQ(formation.capture[0].dataBlockSize, 17U); + EXPECT_EQ(formation.capture[0].midiPortCount, 1U); + EXPECT_EQ(formation.playback[0].dataBlockSize, 16U); + EXPECT_EQ(formation.playback[0].midiPortCount, 0U); + const auto config = Runtime::ResolveAudioConfiguration( + formation.sampleRateHz, formations, {64, 64, 128, 128}, *allocation, 1, + Runtime::ConfigurationValidationPolicy::HardwareBatch); + ASSERT_TRUE(config) << formation.sampleRateHz; + const auto expected = formation.sampleRateHz > 48000 ? 18U : 26U; + EXPECT_EQ(config->captureChannels, expected); + EXPECT_EQ(config->playbackChannels, expected); + } + EXPECT_EQ(DICE::DiceFormations(0x06, modes).size(), 2U); + EXPECT_EQ(DICE::DiceFormations(0x60, modes).size(), 0U); + } +} + +TEST(DiceMultirateValidation, ZedFallbackRejectsOtherIdentityAndUnmeasuredGeometry) { + using namespace ASFW::Audio; + auto caps = ZedCaps(48000); + for (const auto guid : + {0ULL, kZedGuid ^ (1ULL << 40), kZedGuid ^ (1ULL << 32), kZedGuid ^ (1ULL << 22)}) + EXPECT_FALSE(DICE::ZedR16MeasuredFormats(guid, caps)); + EXPECT_TRUE(DICE::ZedR16MeasuredFormats(kZedGuid + 1, caps)); + caps.hostToDeviceStreams[0].am824Slots = 17; + EXPECT_FALSE(DICE::ZedR16MeasuredFormats(kZedGuid, caps)); + caps = ZedCaps(48000); + caps.hostToDeviceStreams[1].pcmChannels = 8; + caps.hostToDeviceStreams[1].am824Slots = 8; + EXPECT_FALSE(DICE::ZedR16MeasuredFormats(kZedGuid, caps)); + caps = ZedCaps(48000); + caps.deviceToHostStreams[0].midiPorts = 2; + EXPECT_FALSE(DICE::ZedR16MeasuredFormats(kZedGuid, caps)); + caps = ZedCaps(48000); + caps.sampleRateHz = 96000; // stale low-rate descriptors must not authorize the table + EXPECT_FALSE(DICE::ZedR16MeasuredFormats(kZedGuid, caps)); + caps = ZedCaps(32000); // not a supported ZED attachment state + EXPECT_FALSE(DICE::ZedR16MeasuredFormats(kZedGuid, caps)); + caps = ZedCaps(48000); + caps.hostToDeviceStreamCount = 1; + EXPECT_FALSE(DICE::ZedR16MeasuredFormats(kZedGuid, caps)); +} + +TEST(DiceMultirateValidation, ZedNeverOverridesSuccessfulEapEvenWhenEmpty) { + using namespace ASFW::Audio; + const auto caps = ZedCaps(48000); + DICE::DiceRateFormats eap{}; + EXPECT_FALSE(DICE::SelectPublishedFormats(true, eap, caps, kZedGuid)[0]); + eap[1] = DICE::DiceModeFormat{{{8, 8, 0, {}, 0}}, {{8, 8, 0, {}, 0}}}; + const auto modes = DICE::SelectPublishedFormats(true, eap, caps, kZedGuid); + EXPECT_FALSE(modes[0]); + ASSERT_TRUE(modes[1]); + EXPECT_EQ(modes[1]->capture[0].pcmChannels, 8U); +} + +TEST(DiceMultirateValidation, NonZedIdentityKeepsObservedModeOnly) { + using namespace ASFW::Audio; + const auto caps = ZedCaps(48000); + const auto modes = DICE::SelectPublishedFormats(false, {}, caps, kZedGuid ^ (1ULL << 22)); + ASSERT_TRUE(modes[0]); + EXPECT_FALSE(modes[1]); + EXPECT_FALSE(modes[2]); + EXPECT_EQ(DICE::DiceFormations(caps.deviceRateMask, modes).size(), 2U); +} From 0ed1ea5bc7cafa50013a68f8468bf096b4a91b5e Mon Sep 17 00:00:00 2001 From: Stephan Terning <704431+stephanterning@users.noreply.github.com> Date: Thu, 22 Oct 2026 22:24:18 +0200 Subject: [PATCH 2/2] Trim comments and preserve existing formatting --- .../Protocols/DICE/Core/DiceRateFormats.hpp | 21 ++++++------------- tests/audio/DiceMultirateValidationTests.cpp | 5 ++--- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/ASFWDriver/Audio/Protocols/DICE/Core/DiceRateFormats.hpp b/ASFWDriver/Audio/Protocols/DICE/Core/DiceRateFormats.hpp index f3a86805e..e8200cd8f 100644 --- a/ASFWDriver/Audio/Protocols/DICE/Core/DiceRateFormats.hpp +++ b/ASFWDriver/Audio/Protocols/DICE/Core/DiceRateFormats.hpp @@ -78,15 +78,10 @@ DiceObservedFormat(const AudioStreamRuntimeCaps& caps) { return format; } -// ZED-R16 measured stream layouts; see issue #223. Capture MIDI is stream 0. -// These formats remain subject to the existing hardware-validation policy. -// Linux dice-stream.c:609-672 keeps unknown devices limited to the observed -// mode. This exception is identity- and geometry-gated; it never scales counts. +// Measured ZED-R16 layouts (#223); require matching identity and live geometry. [[nodiscard]] inline std::optional ZedR16MeasuredFormats(uint64_t guid, const AudioStreamRuntimeCaps& caps) { - // The DICE resolver already checks the unit identity. Check the GUID's - // OUI/category/product here as well; leave the serial number unrestricted. - // GUID layout: Linux dice.c:34-68 (check_dice_category). + // Match OUI/category/product, excluding serial (Linux dice.c:34-68). if ((guid >> 32U) != 0x0004c404U || (static_cast(guid) >> 22U) != 0) return std::nullopt; if (caps.sampleRateHz != 44100 && caps.sampleRateHz != 48000 && caps.sampleRateHz != 88200 && @@ -119,22 +114,18 @@ ZedR16MeasuredFormats(uint64_t guid, const AudioStreamRuntimeCaps& caps) { /// The per-mode formats a device publishes. When the EAP read succeeded its /// 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 use measured ZED-R16 modes -/// only when identity and live geometry match; otherwise only the observed -/// mode is known (Linux's fallback). -/// No clock probing or guessed scaling. +/// describe another mode (:635-643). Without EAP use matching ZED-R16 layouts +/// or only the observed mode; no clock probing or guessed scaling. [[nodiscard]] inline DiceRateFormats SelectPublishedFormats(bool eapRead, DiceRateFormats eap, const AudioStreamRuntimeCaps& caps, uint64_t guid = 0) { - if (eapRead) - return eap; + if (eapRead) return eap; if (const auto measured = ZedR16MeasuredFormats(guid, caps)) return *measured; DiceRateFormats observedOnly{}; const auto mode = DiceRateMode(caps.sampleRateHz); const auto observed = DiceObservedFormat(caps); - if (mode && observed) - observedOnly[*mode] = *observed; + if (mode && observed) observedOnly[*mode] = *observed; return observedOnly; } diff --git a/tests/audio/DiceMultirateValidationTests.cpp b/tests/audio/DiceMultirateValidationTests.cpp index 2386f4833..47fc1c181 100644 --- a/tests/audio/DiceMultirateValidationTests.cpp +++ b/tests/audio/DiceMultirateValidationTests.cpp @@ -77,7 +77,6 @@ ASFW::Audio::AudioStreamRuntimeCaps ZedCaps(uint32_t rate) { } } // namespace -// ZED-R16 format-selection regression fixtures for issue #223. TEST(DiceMultirateValidation, ZedMeasuredModesResolveFromLowAndHighRateAttachment) { using namespace ASFW::Audio; for (const auto attachedRate : {44100U, 48000U, 88200U, 96000U}) { @@ -125,9 +124,9 @@ TEST(DiceMultirateValidation, ZedFallbackRejectsOtherIdentityAndUnmeasuredGeomet caps.deviceToHostStreams[0].midiPorts = 2; EXPECT_FALSE(DICE::ZedR16MeasuredFormats(kZedGuid, caps)); caps = ZedCaps(48000); - caps.sampleRateHz = 96000; // stale low-rate descriptors must not authorize the table + caps.sampleRateHz = 96000; // stale low-rate descriptors EXPECT_FALSE(DICE::ZedR16MeasuredFormats(kZedGuid, caps)); - caps = ZedCaps(32000); // not a supported ZED attachment state + caps = ZedCaps(32000); // unsupported attachment rate EXPECT_FALSE(DICE::ZedR16MeasuredFormats(kZedGuid, caps)); caps = ZedCaps(48000); caps.hostToDeviceStreamCount = 1;