From 7ada753b58e610982558c15c92f1a45fe7c16594 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 22 Oct 2026 14:45:09 +0200 Subject: [PATCH 1/3] test(dice): add the FireStudio 2626 register dump from issue #189 The 2626 is the first captured DICE device with no catalog row: it publishes through the generic DICE fallback. Two streams each way (16 + 10 PCM, MIDI on stream 0), no EAP, and its firmware lists 8 clock source names, not 13. pydice accepted only 13; it now accepts 1 to 13. The fixture is the build-19 report from the issue (the build-33 report has the same registers in a newer format pydice does not parse yet). SessionShape can carry a real GUID, and with it the unit directory's Model_ID: the generic fallback checks the GUID's DICE layout, which the synthetic OUI-prefixed GUID does not follow. Co-Authored-By: Claude Opus 5.5 --- .../DICE/presonus-firestudio-2626.txt | 188 ++++++++++++++++++ tests/devices/SessionTestSupport.hpp | 3 + .../firestudio-2626__start-stop-48k.trace | 87 ++++++++ tests/support/DiceDeviceImages.inc | 37 +++- tools/pydice/pydice/protocol/dice_report.py | 7 +- tools/pydice/tests/test_dice_report.py | 6 +- 6 files changed, 324 insertions(+), 4 deletions(-) create mode 100644 documentation/fixtures/DICE/presonus-firestudio-2626.txt create mode 100644 tests/golden/dice/firestudio-2626__start-stop-48k.trace 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/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/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/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(): From 631a8aa560f46c75b29ecc91af3cefeae5e63973 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Thu, 22 Oct 2026 14:45:09 +0200 Subject: [PATCH 2/3] fix(dice): publish a device whose clock preparation is not confirmed Since 21e01580 (between builds 19 and 21) publication waits for clock preparation, and any failure there refused the device ("geometry load failed") with no retry until the next bus reset. Issue #189: a FireStudio 2626 published on build 19 and no longer appeared in CoreAudio; its build-33 dump shows our owner claim and CLOCK_ACCEPTED, then nothing. The preparation made fatal what the TCAT driver treats as advisory. PaeFireStudio RestartStreaming (0xd8e2), the driver this device shipped with, 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. 21e01580 itself describes the lock wait as advisory. Ours ended on one failed GLOBAL poll read, on no acceptance within 150 ms, and at the 3 s deadline. Now: - a failed GLOBAL poll read is retried until the deadline; a stale generation or a cancellation still ends the preparation; - 150 ms without CLOCK_ACCEPTED logs and goes on to wait for a settled STATUS, as before; - at the deadline the preparation logs the last GLOBAL state and completes, so the device is described and published. Failed writes, a foreign owner and an unsupported version still fail. Stream start keeps its own lock gate: an unlocked device is published but not enabled (clock-never-locks goldens: the start now runs and times out on that gate, ending with ENABLE=0 and no streams, as before). Which failure the 2626 hit is not known without its log; the tests cover each one on its captured registers. Tests: a failed poll read (busy, timeout) and an unconfirmed clock on the 2626 image complete; the 2626 is published through a failed poll read via the DICE adapter; an unlocked Venice F24 completes preparation and its start does not enable. All four fail on the old code, the adapter test with "geometry-load-failed". Co-Authored-By: Claude Opus 5.5 --- .../Protocols/DICE/TCAT/DICETcatProtocol.cpp | 36 ++- tests/devices/DiceFamilyAdapterTests.cpp | 32 +++ .../devices/DiceWireCharacterizationTests.cpp | 62 ++++- ...saffire-pro24-dsp__clock-never-locks.trace | 225 +++++++++++++++++ .../dice/venice-f24__clock-never-locks.trace | 228 ++++++++++++++++++ 5 files changed, 575 insertions(+), 8 deletions(-) diff --git a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp index 6b345e490..a7a1d92da 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); }); 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/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/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 From d1b80145d1760d6e616caf40cc17ff38018c2695 Mon Sep 17 00:00:00 2001 From: Aleksandr Shabelnikov Date: Fri, 23 Oct 2026 11:29:16 +0200 Subject: [PATCH 3/3] fix(dice): offer every CLOCK_CAPS rate on devices without EAP (#223) A DICE device without EAP describes its channel layout only for the current rate, so only the attach rate was ever offered (ZED-R16 in #223, FireStudio 2626 in #189). Follow the TCAT driver instead: announce every CLOCK_CAPS rate, set the clock, then read the layout (Saffire.kext performFormatChange -> RestartStreaming -> PopulateDeviceStruct -> CreateStreams). - A rate with an unread layout carries the current layout as a placeholder (RateFormation::layoutProvisional) for the HAL format list. - A host rate change to it reads the layout once the clock locks and installs only that measured layout; an unreadable one rolls back. - A placeholder never replaces a measured layout, also after a bus reset. - Capacity for a placeholder is reserved at the protocol limit, so a device attached at 2x can still switch to 1x. Co-Authored-By: Claude Opus 5.5 --- .../Audio/DriverKit/ASFWAudioDevice.cpp | 64 ++++++--- ASFWDriver/Audio/DriverKit/ASFWAudioNub.cpp | 7 +- .../DriverKit/Config/AudioDriverConfig.cpp | 2 + ASFWDriver/Audio/Model/ASFWAudioDevice.hpp | 1 + ASFWDriver/Audio/Model/AudioPropertyKeys.hpp | 2 + .../Audio/Model/DiscoveredRuntimeCaps.hpp | 14 ++ ASFWDriver/Audio/Model/RateConfiguration.hpp | 13 +- .../Protocols/DICE/Core/DiceRateFormats.hpp | 32 ++++- .../Protocols/DICE/TCAT/DICETcatProtocol.cpp | 23 +++- .../Protocols/DICE/TCAT/DICETcatProtocol.hpp | 4 +- .../Runtime/ResolvedAudioConfiguration.hpp | 18 ++- tests/audio/DiceMultirateValidationTests.cpp | 62 +++++++++ tests/audio/NubGeometryRoundTripTests.cpp | 20 +++ .../audio/ResolvedAudioConfigurationTests.cpp | 95 +++++++++++++ tests/devices/CMakeLists.txt | 12 ++ tests/devices/DiceNonEapRateTests.cpp | 126 ++++++++++++++++++ tests/support/SyntheticDiceImages.hpp | 23 ++++ 17 files changed, 486 insertions(+), 32 deletions(-) create mode 100644 tests/devices/DiceNonEapRateTests.cpp 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 a7a1d92da..f79a1e0a4 100644 --- a/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp +++ b/ASFWDriver/Audio/Protocols/DICE/TCAT/DICETcatProtocol.cpp @@ -492,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})); } @@ -1165,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/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/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/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