From b834a7098a998a6913f1f5bf053ce3b45fb7e186 Mon Sep 17 00:00:00 2001 From: Chris Izatt Date: Wed, 10 Jun 2026 11:59:49 +0100 Subject: [PATCH] =?UTF-8?q?lab:=20Milestone=202=20=E2=80=94=20SYT=20realis?= =?UTF-8?q?m:=20TxTimingModel,=20simulated=20timeline,=20P5=20verification?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the M2 scope per the lab README, with every constant taken from the DICE tree's Saffire-decompile ground truth (SYTGenerator.hpp, TxOutputPhaseLoop.hpp, tools/debug/tx_phase_loop_model.py): - Ports/ICycleTimeline: time as an explicit port (unwrapped 24.576 MHz ticks). Lab/SimulatedCycleTimeline synthesizes it from audio frame position (6 frames per cycle at 48 kHz; epoch + jitter knobs are test inputs, per the README's adversarial-schedule design). - Core/TxTimingModel: the frame->cycle->SYT unit under research. Seeds at now + presentation delay (4096), grafts the device's constant sub-cycle (phase - phase%3072 + 0x0B0, the reference-model formula), advances by the fixed 4096-tick blocking step in the 16-cycle (49152) SYT domain, and reports transmit-lead health: tight <=3071, accept <7620 (the real rejection threshold), gate >=7620, escalate >=12287 (advisory only). WHO DECIDES THE WIRE: not the model — cadence stays authoritative; callers Peek the SYT, the packetizer decides data/no-data, and only emitted data packets Commit (the model tracks, it never decides), mirroring TxOutputPhaseLoop's contract. - Verifier P5 (config-gated): data SYTs must step by exactly 4096 in the 16-cycle domain and sit on the graft lattice (offset = 0x0B0 mod 1024); data packets carrying 0xFFFF in a timing-valid run are violations. Self-tests prove each P5 counter fires exactly once per injected fault. - Controller: EnableLabTiming/PrepareLabPacketTimed/TimingCounters — the timed prepare path peeks, prepares, commits-on-data, and records lead health telemetry (seeds counted at peek: the seeding peek can land on a no-data cycle). - Lab/WriteEndTraceReplayer: allocation-free trace-text parser (sample_time host_time frames; comments; malformed lines counted, never absorbed) + Replay — the regression harness Milestone 3's captured trace will drop into. Scenario H replays a trace through the full timed pipeline. - Dext wiring: timing + P5 enabled in VirtualAudioDevice; the simulated bus rides the exposure cursor (the packet's projected transmit position, per Saffire FillFirewireBuffers); StopIO dump grows p5 and timing lines. Unit tests pin the contract exactly: seed SYT 0x10B0 at lead 3248, the +1,+1,+2 cycle pattern with offsets {0x0B0,0x4B0,0x8B0}, the 12-packet 16-cycle wrap, all five lead-health boundaries, graft-disabled mode, the monotonic guard, and re-arm/reseed. The timed pump soak holds P1-P5 at zero violations with the lead pinned at the seed value across ~65k data packets. Suite: 18010 checks, 0 failures (was 17440). All three targets build. Co-Authored-By: Claude Opus 4.8 (1M context) --- ADKVirtualAudioLab/Core/TxTimingModel.cpp | 125 +++++++++++++++ ADKVirtualAudioLab/Core/TxTimingModel.hpp | 100 ++++++++++++ .../Core/VirtualAudioDeviceController.cpp | 73 +++++++++ .../Core/VirtualAudioDeviceController.hpp | 34 +++++ .../Driver/VirtualAudioDevice.cpp | 37 ++++- .../Lab/SimulatedCycleTimeline.hpp | 52 +++++++ .../Lab/VerifyingSlotProvider.cpp | 44 ++++++ .../Lab/VerifyingSlotProvider.hpp | 19 ++- .../Lab/WriteEndTraceReplayer.hpp | 140 +++++++++++++++++ ADKVirtualAudioLab/Ports/ICycleTimeline.hpp | 23 +++ .../Tests/TxTimingModelTests.cpp | 143 ++++++++++++++++++ .../Tests/VerifierScenarioTests.cpp | 115 +++++++++++++- .../Tests/VerifyingSlotProviderTests.cpp | 129 +++++++++++++++- .../Tests/WriteEndTraceReplayerTests.cpp | 88 +++++++++++ ADKVirtualAudioLab/Tests/main.cpp | 4 + 15 files changed, 1113 insertions(+), 13 deletions(-) create mode 100644 ADKVirtualAudioLab/Core/TxTimingModel.cpp create mode 100644 ADKVirtualAudioLab/Core/TxTimingModel.hpp create mode 100644 ADKVirtualAudioLab/Lab/SimulatedCycleTimeline.hpp create mode 100644 ADKVirtualAudioLab/Lab/WriteEndTraceReplayer.hpp create mode 100644 ADKVirtualAudioLab/Ports/ICycleTimeline.hpp create mode 100644 ADKVirtualAudioLab/Tests/TxTimingModelTests.cpp create mode 100644 ADKVirtualAudioLab/Tests/WriteEndTraceReplayerTests.cpp diff --git a/ADKVirtualAudioLab/Core/TxTimingModel.cpp b/ADKVirtualAudioLab/Core/TxTimingModel.cpp new file mode 100644 index 00000000..dc1922ab --- /dev/null +++ b/ADKVirtualAudioLab/Core/TxTimingModel.cpp @@ -0,0 +1,125 @@ +#include "TxTimingModel.hpp" + +#include "../Protocols/Audio/IEC61883/Syt.hpp" + +namespace ASFW::Driver { + +// Design decisions (see TxTimingModel.hpp and the README, Milestone 2): +// +// 1. Seed-on-peek: the first PeekNextDataSyt after arming derives the phase +// from the timeline (transmit-cycle anchoring), so the anchor lands on the +// moment the first data packet is actually being stamped — mirroring +// SYTGenerator's armTransmitCycleAnchor/computeDataSYT split. +// 2. The graft follows the reference model literally: +// phase - (phase % kTicksPerCycle) + deviceSubCycleTicks. Because the +// graft replaces the sub-cycle offset, it can move the phase backward by +// up to one cycle; a bounded guard then re-adds whole cycles until the +// lead is positive again — the graft fixes the sub-cycle, the cycle count +// is free. +// 3. Commit is separate from Peek and advances exactly one packet step: +// cadence owns the wire, the model tracks emitted data packets only. +// 4. Lead health thresholds are compared the way the Saffire decompile +// behaves: accept is exclusive (< 7620 ships), tight and escalate are +// advisory bands, kLate covers a phase already in the past. + +using Protocols::Audio::IEC61883::SytFormatter; + +void TxTimingModel::Configure(const Config& config) noexcept { + config_ = config; + Reset(); +} + +const TxTimingModel::Config& TxTimingModel::GetConfig() const noexcept { + return config_; +} + +void TxTimingModel::Reset() noexcept { + phaseTicks_ = 0; + seeded_ = false; + anchorArmed_ = true; +} + +void TxTimingModel::ArmTransmitCycleAnchor() noexcept { + anchorArmed_ = true; +} + +bool TxTimingModel::IsSeeded() const noexcept { + return seeded_; +} + +TxTimingModel::Decision TxTimingModel::PeekNextDataSyt( + const Ports::ICycleTimeline& timeline) noexcept { + Decision decision{}; + const int64_t now = timeline.NowTicks(); + + if (anchorArmed_ || !seeded_) { + int64_t phase = now + config_.presentationDelayTicks; + if (config_.graftEnabled) { + phase = phase - (phase % kTicksPerCycle) + + static_cast(config_.deviceSubCycleTicks); + // The graft may have stepped back past "now"; whole cycles are + // free, the sub-cycle is not. Bounded by construction (the graft + // moves at most one cycle back). + while (phase <= now) { + phase += kTicksPerCycle; + } + } + phaseTicks_ = phase; + seeded_ = true; + anchorArmed_ = false; + decision.seededThisCall = true; + } + + decision.syt = SytForPhase(phaseTicks_); + decision.leadTicks = phaseTicks_ - now; + decision.health = HealthForLead(decision.leadTicks); + return decision; +} + +void TxTimingModel::CommitDataPacket() noexcept { + if (seeded_) { + phaseTicks_ += kPacketStepTicks; + } +} + +void TxTimingModel::NudgeOffsetTicks(int32_t deltaTicks) noexcept { + if (seeded_) { + phaseTicks_ += deltaTicks; + } +} + +int64_t TxTimingModel::OutputPhaseTicks() const noexcept { + return phaseTicks_; +} + +uint16_t TxTimingModel::SytForPhase(int64_t phaseTicks) const noexcept { + int64_t domainTick = phaseTicks % kSytDomainTicks; + if (domainTick < 0) { + domainTick += kSytDomainTicks; + } + const uint32_t cycle = static_cast(domainTick / kTicksPerCycle); + const uint32_t offset = static_cast(domainTick % kTicksPerCycle); + return SytFormatter::EncodeCycleOffset(cycle, offset); +} + +TxTimingModel::LeadHealth TxTimingModel::HealthForLead( + int64_t leadTicks) const noexcept { + if (!seeded_) { + return LeadHealth::kNotSeeded; + } + if (leadTicks < 0) { + return LeadHealth::kLate; + } + if (leadTicks <= config_.tightLeadTicks - 1) { + return LeadHealth::kTightWarn; + } + if (leadTicks < config_.acceptLeadTicks) { + return LeadHealth::kAccepted; + } + if (leadTicks < config_.escalateLeadTicks) { + return LeadHealth::kGate; + } + return LeadHealth::kEscalate; +} + +} // namespace ASFW::Driver diff --git a/ADKVirtualAudioLab/Core/TxTimingModel.hpp b/ADKVirtualAudioLab/Core/TxTimingModel.hpp new file mode 100644 index 00000000..b9e9eed0 --- /dev/null +++ b/ADKVirtualAudioLab/Core/TxTimingModel.hpp @@ -0,0 +1,100 @@ +#pragma once + +#include "../Ports/ICycleTimeline.hpp" + +#include + +namespace ASFW::Driver { + +// Frame -> cycle -> SYT mapping: the unit under research (README, target +// layout). Mirrors the contract of ASFW's Encoding::SYTGenerator and the +// authoritative reference model (tools/debug/tx_phase_loop_model.py in the +// DICE tree), constants from the Saffire decompile: +// +// - output phase is an UNWRAPPED int64 in the 24.576 MHz tick domain +// - seed: phase = now + presentation delay, then the constant device +// sub-cycle graft: phase = phase - (phase % 3072) + 0x0B0 +// (keep the cycle, replace the sub-cycle offset — Saffire Pro24's +// recovered constant) +// - data packets advance the phase by the fixed blocking step +// 8 frames x 512 ticks = 4096; no-data packets advance nothing +// - SYT = cycle[3:0] ‖ offset[11:0] of the phase in the 16-cycle +// (49152-tick) SYT domain +// - transmit-lead health (P5): lead = phase - now; warn at <= 3071 (under +// one cycle, near-underrun), healthy while < 7620, gate at >= 7620 (the +// device's actual rejection threshold), >= 12287 is log-escalation only +// +// WHO DECIDES THE WIRE: not this model. The cadence's DATA/NO-DATA pattern is +// the device-validated sequencing (see TxOutputPhaseLoop's contract in the +// DICE tree); lead health here is pure telemetry that may freely co-occur +// with any cadence outcome. Callers Peek the SYT a data packet would carry, +// let the packetizer decide the wire, then Commit only what was actually +// emitted — the model tracks, it never decides. +class TxTimingModel final { +public: + struct Config final { + int64_t presentationDelayTicks{4096}; // SYTGenerator's anchor lead + uint16_t deviceSubCycleTicks{0x0B0}; // Saffire Pro24 constant graft + bool graftEnabled{true}; + int64_t tightLeadTicks{3072}; // below: near-underrun warning + int64_t acceptLeadTicks{7620}; // at/above: device rejects (gate) + int64_t escalateLeadTicks{12287}; // advisory log-escalation only + }; + + enum class LeadHealth : uint8_t { + kNotSeeded = 0, + kAccepted, // tight < lead < accept: the safe operating range + kTightWarn, // 0 <= lead <= tight: shipping while thin + kLate, // lead < 0: presentation time already passed + kGate, // accept <= lead < escalate: device would reject + kEscalate, // lead >= escalate: drifted very far (advisory) + }; + + struct Decision final { + uint16_t syt{0xFFFF}; + int64_t leadTicks{0}; + LeadHealth health{LeadHealth::kNotSeeded}; + bool seededThisCall{false}; + }; + + static constexpr int64_t kTicksPerCycle = 3072; + static constexpr int64_t kTicksPerFrame48k = 512; + static constexpr int64_t kPacketStepTicks = 4096; // 8 frames x 512 + static constexpr int64_t kSytDomainTicks = 49152; // 16 cycles + + TxTimingModel() noexcept = default; + + void Configure(const Config& config) noexcept; + [[nodiscard]] const Config& GetConfig() const noexcept; + + // Un-seeds and re-arms the transmit anchor (call on stream start). + void Reset() noexcept; + + void ArmTransmitCycleAnchor() noexcept; + [[nodiscard]] bool IsSeeded() const noexcept; + + // The SYT the NEXT data packet would carry. Seeds from the timeline on + // the first call after arming; otherwise pure (no state advances until + // CommitDataPacket). Lead/health are measured against timeline now. + [[nodiscard]] Decision PeekNextDataSyt( + const Ports::ICycleTimeline& timeline) noexcept; + + // The packetizer actually emitted a data packet: advance one step. + void CommitDataPacket() noexcept; + + // Discipline rehearsal parity with SYTGenerator::nudgeOffsetTicks. + void NudgeOffsetTicks(int32_t deltaTicks) noexcept; + + [[nodiscard]] int64_t OutputPhaseTicks() const noexcept; + +private: + [[nodiscard]] uint16_t SytForPhase(int64_t phaseTicks) const noexcept; + [[nodiscard]] LeadHealth HealthForLead(int64_t leadTicks) const noexcept; + + Config config_{}; + int64_t phaseTicks_{0}; + bool seeded_{false}; + bool anchorArmed_{true}; +}; + +} // namespace ASFW::Driver diff --git a/ADKVirtualAudioLab/Core/VirtualAudioDeviceController.cpp b/ADKVirtualAudioLab/Core/VirtualAudioDeviceController.cpp index bdfc2150..811d1a1b 100644 --- a/ADKVirtualAudioLab/Core/VirtualAudioDeviceController.cpp +++ b/ADKVirtualAudioLab/Core/VirtualAudioDeviceController.cpp @@ -60,6 +60,11 @@ bool VirtualAudioDeviceController::ConfigureOutputStream( void VirtualAudioDeviceController::ResetTransportLab( uint8_t initialDbc, uint64_t initialAudioFrame) noexcept { txEngine_.ResetForStart(initialDbc, initialAudioFrame); + if (labTimingEnabled_) { + timingModel_.Reset(); + simulatedTimeline_.Reset(timelineEpochTicks_); + timingCounters_ = TxTimingLabCounters{}; + } } bool VirtualAudioDeviceController::PrepareLabPacket(uint32_t packetIndex, @@ -76,6 +81,74 @@ void VirtualAudioDeviceController::SubmitWriteEnd( audioIOPath_.HandleWriteEnd(output); } +void VirtualAudioDeviceController::EnableLabTiming( + const TxTimingModel::Config& config, int64_t timelineEpochTicks) noexcept { + timingModel_.Configure(config); + timelineEpochTicks_ = timelineEpochTicks; + simulatedTimeline_.Reset(timelineEpochTicks); + timingCounters_ = TxTimingLabCounters{}; + labTimingEnabled_ = true; +} + +void VirtualAudioDeviceController::DisableLabTiming() noexcept { + labTimingEnabled_ = false; +} + +bool VirtualAudioDeviceController::LabTimingEnabled() const noexcept { + return labTimingEnabled_; +} + +void VirtualAudioDeviceController::AdvanceLabTimelineToFrame( + uint64_t absoluteFrame) noexcept { + simulatedTimeline_.AdvanceToFrame(absoluteFrame); +} + +bool VirtualAudioDeviceController::PrepareLabPacketTimed( + uint32_t packetIndex) noexcept { + if (!labTimingEnabled_) { + return PrepareLabPacket(packetIndex, 0xFFFF, false); + } + + const auto decision = timingModel_.PeekNextDataSyt(simulatedTimeline_); + if (decision.seededThisCall) { + ++timingCounters_.seeds; // the seeding peek may land on a no-data cycle + } + if (!PrepareLabPacket(packetIndex, decision.syt, true)) { + return false; + } + + // Cadence decided the wire; the model tracks only what was emitted. + const auto* published = fakeSlotProvider_.PublishedPacket(packetIndex); + if (published != nullptr && published->packetIndex == packetIndex && + published->isData) { + timingModel_.CommitDataPacket(); + ++timingCounters_.dataPackets; + timingCounters_.lastLeadTicks = decision.leadTicks; + switch (decision.health) { + case TxTimingModel::LeadHealth::kTightWarn: + ++timingCounters_.tightWarn; + break; + case TxTimingModel::LeadHealth::kLate: + ++timingCounters_.late; + break; + case TxTimingModel::LeadHealth::kGate: + ++timingCounters_.gate; + break; + case TxTimingModel::LeadHealth::kEscalate: + ++timingCounters_.escalate; + break; + default: + break; + } + } + return true; +} + +const TxTimingLabCounters& +VirtualAudioDeviceController::TimingCounters() const noexcept { + return timingCounters_; +} + void VirtualAudioDeviceController::BindLabSlotProvider( Protocols::Audio::AMDTP::IAmdtpTxSlotProvider* provider) noexcept { txEngine_.BindSlotProvider( diff --git a/ADKVirtualAudioLab/Core/VirtualAudioDeviceController.hpp b/ADKVirtualAudioLab/Core/VirtualAudioDeviceController.hpp index 76dd5001..f1c73e51 100644 --- a/ADKVirtualAudioLab/Core/VirtualAudioDeviceController.hpp +++ b/ADKVirtualAudioLab/Core/VirtualAudioDeviceController.hpp @@ -1,15 +1,29 @@ #pragma once #include "../Lab/FakeIsochTxSlotProvider.hpp" +#include "../Lab/SimulatedCycleTimeline.hpp" #include "../Protocols/Audio/DICE/DiceProfileRegistry.hpp" #include "../Protocols/Audio/DICE/DiceTxStreamEngine.hpp" #include "../Protocols/Audio/DICE/Profiles/FocusriteSaffireProfile.hpp" #include "AudioIOPath.hpp" +#include "TxTimingModel.hpp" #include namespace ASFW::Driver { +// Lead-health bookkeeping for the timed prepare path (work-queue confined, +// like the rest of the controller's lab state). +struct TxTimingLabCounters final { + uint64_t dataPackets{0}; + uint64_t seeds{0}; + uint64_t tightWarn{0}; + uint64_t late{0}; + uint64_t gate{0}; + uint64_t escalate{0}; + int64_t lastLeadTicks{0}; +}; + class VirtualAudioDeviceController final { public: VirtualAudioDeviceController() noexcept = default; @@ -29,6 +43,20 @@ class VirtualAudioDeviceController final { uint16_t syt, bool timingValid) noexcept; + // Milestone 2 — SYT realism. EnableLabTiming binds a TxTimingModel and a + // SimulatedCycleTimeline (advanced by the caller from the frame cursor); + // PrepareLabPacketTimed then stamps real SYTs: peek the model, let the + // packetizer's cadence decide the wire, commit only if a data packet was + // actually published (the model tracks, it never decides). Lead health is + // recorded as telemetry per data packet. + void EnableLabTiming(const TxTimingModel::Config& config, + int64_t timelineEpochTicks = 0) noexcept; + void DisableLabTiming() noexcept; + [[nodiscard]] bool LabTimingEnabled() const noexcept; + void AdvanceLabTimelineToFrame(uint64_t absoluteFrame) noexcept; + bool PrepareLabPacketTimed(uint32_t packetIndex) noexcept; + [[nodiscard]] const TxTimingLabCounters& TimingCounters() const noexcept; + void SubmitWriteEnd(const Protocols::Audio::AMDTP::HostAudioBufferView& output) noexcept; // Step 6 seam: interpose a decorator (Verifying(Fake)) between the engine @@ -55,6 +83,12 @@ class VirtualAudioDeviceController final { Lab::FakeIsochTxSlotProvider fakeSlotProvider_{}; AudioIOPath audioIOPath_{}; + + bool labTimingEnabled_{false}; + int64_t timelineEpochTicks_{0}; + TxTimingModel timingModel_{}; + Lab::SimulatedCycleTimeline simulatedTimeline_{}; + TxTimingLabCounters timingCounters_{}; }; } // namespace ASFW::Driver \ No newline at end of file diff --git a/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp b/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp index 7cc93f5c..3db1290d 100644 --- a/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp +++ b/ADKVirtualAudioLab/Driver/VirtualAudioDevice.cpp @@ -140,10 +140,15 @@ bool VirtualAudioDevice::init(IOUserAudioDriver* in_driver, } // Step 6 instrument under real pacing: Verifying(Fake) for the whole run. + // P5 enabled — M2 timing stamps real SYTs on every data packet. ivars->diagSink = new ASFW::Lab::StickyCounterSink(); - ivars->verifier = new ASFW::Lab::VerifyingSlotProvider( - ivars->controller->FakeSlotProvider(), - ASFW::Lab::VerifyingSlotProvider::Config{true, 0x02, ivars->diagSink}); + { + ASFW::Lab::VerifyingSlotProvider::Config verifierConfig{}; + verifierConfig.diagSink = ivars->diagSink; + verifierConfig.p5Enabled = true; + ivars->verifier = new ASFW::Lab::VerifyingSlotProvider( + ivars->controller->FakeSlotProvider(), verifierConfig); + } ivars->controller->BindLabSlotProvider(ivars->verifier); // Pick Saffire for testing @@ -193,6 +198,11 @@ bool VirtualAudioDevice::init(IOUserAudioDriver* in_driver, ivars->controller->ConfigureOutputStream(kSampleRate, ivars->outputChannels, ivars->ringFrames); + // M2: SYT realism — the controller stamps real SYTs from TxTimingModel + // against the simulated timeline (which rides the exposure cursor: the + // packet's projected transmit position, per the Saffire model). + ivars->controller->EnableLabTiming(ASFW::Driver::TxTimingModel::Config{}); + // The ZTS heartbeat timer (armed in StartIO). IOTimerDispatchSource* timer = nullptr; if (IOTimerDispatchSource::Create(ivars->workQueue.get(), &timer) == kIOReturnSuccess) { @@ -317,8 +327,10 @@ static void PrepareCoverage(VirtualAudioDevice_IVars* ivars, uint64_t targetFram { uint32_t prepared = 0; while (ivars->exposedFrames < targetFrames && prepared < kMaxPreparePerCall) { - if (!ivars->controller->PrepareLabPacket(ivars->nextPacketIndex, 0xFFFF, - false)) { + // The simulated bus rides the exposure cursor (projected transmit + // position), so SYT lead stays at the grafted seed by construction. + ivars->controller->AdvanceLabTimelineToFrame(ivars->exposedFrames); + if (!ivars->controller->PrepareLabPacketTimed(ivars->nextPacketIndex)) { ++ivars->prepareFailures; return; } @@ -471,7 +483,7 @@ kern_return_t VirtualAudioDevice::StopIO(IOUserAudioStartStopFlags in_flags) IOLog("ADKLab[dump] verifier: violations=%llu p1_win=%llu p1_run=%llu " "p1_idx=%llu p2_dbc=%llu p3_bytes=%llu p3_q0=%llu p3_q1=%llu " - "p3_unacq=%llu p4_tile=%llu p4_cnt=%llu\n", + "p3_unacq=%llu p4_tile=%llu p4_cnt=%llu p5_step=%llu p5_graft=%llu\n", snapshot.TotalViolations(), snapshot.Value(VerifierCounterId::kP1CadenceWindowViolation), snapshot.Value(VerifierCounterId::kP1CadenceRunViolation), @@ -482,7 +494,18 @@ kern_return_t VirtualAudioDevice::StopIO(IOUserAudioStartStopFlags in_flags) snapshot.Value(VerifierCounterId::kP3CipQ1Violation), snapshot.Value(VerifierCounterId::kP3UnacquiredPublishViolation), snapshot.Value(VerifierCounterId::kP4FrameTilingViolation), - snapshot.Value(VerifierCounterId::kP4FrameCountViolation)); + snapshot.Value(VerifierCounterId::kP4FrameCountViolation), + snapshot.Value(VerifierCounterId::kP5SytStepViolation), + snapshot.Value(VerifierCounterId::kP5SytGraftViolation)); + + if (ivars->controller && ivars->controller->LabTimingEnabled()) { + const auto& timing = ivars->controller->TimingCounters(); + IOLog("ADKLab[dump] timing: data_syts=%llu seeds=%llu tight=%llu " + "late=%llu gate=%llu escalate=%llu last_lead=%lld\n", + timing.dataPackets, timing.seeds, timing.tightWarn, + timing.late, timing.gate, timing.escalate, + timing.lastLeadTicks); + } IOLog("ADKLab[dump] packets: published=%llu data=%llu nodata=%llu " "acquire_failures=%llu\n", diff --git a/ADKVirtualAudioLab/Lab/SimulatedCycleTimeline.hpp b/ADKVirtualAudioLab/Lab/SimulatedCycleTimeline.hpp new file mode 100644 index 00000000..40c54dd2 --- /dev/null +++ b/ADKVirtualAudioLab/Lab/SimulatedCycleTimeline.hpp @@ -0,0 +1,52 @@ +#pragma once + +#include "../Ports/ICycleTimeline.hpp" + +#include + +namespace ASFW::Lab { + +// Synthesizes bus time from audio sample position: at 48 kHz, 6 frames per +// 125 us cycle = 512 ticks per frame (see README, "Time is an explicit +// port"). The lab has no 8 kHz cycle timer and does not pretend to — the +// epoch offset positions runs anywhere in the bus second (e.g. just before a +// 7999->0 wrap), and AdvanceTicks injects adversarial jitter as a test input +// instead of something observed on hardware. +class SimulatedCycleTimeline final : public Ports::ICycleTimeline { +public: + static constexpr int64_t kTicksPerFrame48k = 512; + + explicit SimulatedCycleTimeline(int64_t epochTicks = 0) noexcept + : epochTicks_(epochTicks), nowTicks_(epochTicks) {} + + // Bus position tracks the audio frame cursor; monotonic by construction + // (a smaller frame never rewinds the timeline). + void AdvanceToFrame(uint64_t absoluteFrame) noexcept { + const int64_t candidate = + epochTicks_ + static_cast(absoluteFrame) * kTicksPerFrame48k; + if (candidate > nowTicks_) { + nowTicks_ = candidate; + } + } + + // Adversarial knob: jitter/skew injected by tests (positive only keeps + // the monotonic contract; pass small deltas). + void AdvanceTicks(int64_t deltaTicks) noexcept { + if (deltaTicks > 0) { + nowTicks_ += deltaTicks; + } + } + + void Reset(int64_t epochTicks = 0) noexcept { + epochTicks_ = epochTicks; + nowTicks_ = epochTicks; + } + + int64_t NowTicks() const noexcept override { return nowTicks_; } + +private: + int64_t epochTicks_{0}; + int64_t nowTicks_{0}; +}; + +} // namespace ASFW::Lab diff --git a/ADKVirtualAudioLab/Lab/VerifyingSlotProvider.cpp b/ADKVirtualAudioLab/Lab/VerifyingSlotProvider.cpp index 77b10751..6449b121 100644 --- a/ADKVirtualAudioLab/Lab/VerifyingSlotProvider.cpp +++ b/ADKVirtualAudioLab/Lab/VerifyingSlotProvider.cpp @@ -67,6 +67,8 @@ void VerifyingSlotProvider::Reset() noexcept { consecutiveNoData_ = 0; windowPackets_ = 0; windowDataPackets_ = 0; + p5PrevValid_ = false; + p5PrevDomainTick_ = 0; for (auto& counter : counters_) { counter.store(0, std::memory_order_relaxed); @@ -125,6 +127,9 @@ void VerifyingSlotProvider::PublishSlot(const PreparedTxPacket& packet) noexcept if (config_.blockingMode) { CheckCadence(packet); } + if (config_.p5Enabled) { + CheckSytDiscipline(packet); + } if (inner_ != nullptr) { inner_->PublishSlot(packet); @@ -264,6 +269,45 @@ void VerifyingSlotProvider::CheckFrameTiling( nextFrameValid_ = true; } +void VerifyingSlotProvider::CheckSytDiscipline( + const PreparedTxPacket& packet) noexcept { + // P5 (README, Milestone 2): data SYTs advance by exactly p5StepTicks in + // the 16-cycle (49152-tick) domain, with the sub-cycle offset pinned to + // the device graft lattice — at 48 kHz blocking (step 4096 = 3072+1024) + // the offset walks {graft, graft+1024, graft+2048} so offset mod 1024 + // stays equal to graft mod 1024. No-data SYT (0xFFFF) is enforced by P3. + if (!packet.isData) { + return; + } + + if (packet.syt == kSytNoInfo) { + // A timing-valid run must stamp real SYTs on data packets. + Violation(VerifierCounterId::kP5SytStepViolation, packet.packetIndex); + p5PrevValid_ = false; + return; + } + + const uint32_t cycle = (packet.syt >> 12) & 0xFu; + const uint32_t offset = packet.syt & 0xFFFu; + + if (offset >= 3072u || + (offset % 1024u) != (config_.p5GraftOffsetTicks % 1024u)) { + Violation(VerifierCounterId::kP5SytGraftViolation, packet.packetIndex); + } + + const uint32_t domainTick = cycle * 3072u + offset; + if (p5PrevValid_) { + const uint32_t expected = + (p5PrevDomainTick_ + config_.p5StepTicks) % 49152u; + if (domainTick != expected) { + Violation(VerifierCounterId::kP5SytStepViolation, + packet.packetIndex); + } + } + p5PrevDomainTick_ = domainTick; + p5PrevValid_ = true; +} + void VerifyingSlotProvider::CheckCadence(const PreparedTxPacket& packet) noexcept { // Run-length shape of the blocking 48 kHz N,D,D,D pattern: data runs of // at most 3, isolated no-data packets. diff --git a/ADKVirtualAudioLab/Lab/VerifyingSlotProvider.hpp b/ADKVirtualAudioLab/Lab/VerifyingSlotProvider.hpp index 6105764b..feb7b365 100644 --- a/ADKVirtualAudioLab/Lab/VerifyingSlotProvider.hpp +++ b/ADKVirtualAudioLab/Lab/VerifyingSlotProvider.hpp @@ -38,7 +38,11 @@ enum class VerifierCounterId : uint32_t { kP4FrameTilingViolation = 40, // firstAudioFrame != expected next frame kP4FrameCountViolation = 41, // data frame count inconsistent / no-data frames != 0 - kIdLimit = 42, + // P5 — SYT discipline (Milestone 2; checked only when Config::p5Enabled) + kP5SytStepViolation = 50, // data SYT not prev + step in the 16-cycle domain + kP5SytGraftViolation = 51, // sub-cycle offset off the device graft lattice + + kIdLimit = 52, }; struct VerifierSnapshot final { @@ -105,6 +109,14 @@ class VerifyingSlotProvider final bool blockingMode{true}; // enables P1 run-length + 8000-window checks uint8_t expectedDataFdf{0x02}; // AM824 | 48 kHz (the lab is 48 k-only) Ports::IDiagSink* diagSink{nullptr}; + + // P5 (Milestone 2): only meaningful for timing-valid runs — data + // packets must then carry real SYTs stepping by p5StepTicks in the + // 16-cycle domain, on the device's constant sub-cycle graft lattice + // (offset ≡ graft mod 1024 at 48 kHz blocking: 4096 % 3072 = 1024). + bool p5Enabled{false}; + uint16_t p5GraftOffsetTicks{0x0B0}; + uint32_t p5StepTicks{4096}; }; explicit VerifyingSlotProvider( @@ -150,6 +162,8 @@ class VerifyingSlotProvider final const Protocols::Audio::AMDTP::PreparedTxPacket& packet) noexcept; void CheckCadence( const Protocols::Audio::AMDTP::PreparedTxPacket& packet) noexcept; + void CheckSytDiscipline( + const Protocols::Audio::AMDTP::PreparedTxPacket& packet) noexcept; Protocols::Audio::AMDTP::IAmdtpTxSlotProvider* inner_{nullptr}; Config config_{}; @@ -178,6 +192,9 @@ class VerifyingSlotProvider final uint32_t windowPackets_{0}; uint32_t windowDataPackets_{0}; + bool p5PrevValid_{false}; + uint32_t p5PrevDomainTick_{0}; + std::atomic counters_[static_cast(VerifierCounterId::kIdLimit)]{}; std::atomic firstViolationValid_{false}; diff --git a/ADKVirtualAudioLab/Lab/WriteEndTraceReplayer.hpp b/ADKVirtualAudioLab/Lab/WriteEndTraceReplayer.hpp new file mode 100644 index 00000000..5f4d8895 --- /dev/null +++ b/ADKVirtualAudioLab/Lab/WriteEndTraceReplayer.hpp @@ -0,0 +1,140 @@ +#pragma once + +#include +#include + +namespace ASFW::Lab { + +// Trace replay closes the pacing gap (README, "Key design decisions"): +// record real (sample_time, host_time, frame_count) sequences once — from +// the lab dext, later from bench ASFW — then replay them forever on host so +// regression tests run against genuine coreaudiod scheduling. +// +// Trace text format, one WriteEnd per line: +// +// +// +// '#' starts a comment, blank lines are skipped, malformed lines are counted +// and skipped (never silently absorbed). Parsing is allocation-free into a +// caller-provided buffer so the replayer stays dext-safe like the rest of +// Lab/ (no iostream, no heap). + +struct WriteEndEvent final { + uint64_t sampleTime{0}; + uint64_t hostTime{0}; + uint32_t frameCount{0}; +}; + +struct TraceParseResult final { + size_t eventCount{0}; + size_t malformedLines{0}; + bool truncated{false}; // capacity exhausted before the text ended +}; + +class WriteEndTraceReplayer final { +public: + static TraceParseResult ParseTraceText(const char* text, size_t length, + WriteEndEvent* outEvents, + size_t capacity) noexcept { + TraceParseResult result{}; + size_t pos = 0; + while (pos < length) { + // Isolate the line [lineStart, lineEnd). + const size_t lineStart = pos; + while (pos < length && text[pos] != '\n') { + ++pos; + } + size_t lineEnd = pos; + if (pos < length) { + ++pos; // consume the newline + } + + // Strip comment and trailing/leading whitespace. + size_t cursor = lineStart; + size_t contentEnd = lineEnd; + for (size_t i = lineStart; i < lineEnd; ++i) { + if (text[i] == '#') { + contentEnd = i; + break; + } + } + while (cursor < contentEnd && IsSpace(text[cursor])) { + ++cursor; + } + while (contentEnd > cursor && IsSpace(text[contentEnd - 1])) { + --contentEnd; + } + if (cursor == contentEnd) { + continue; // blank or comment-only line + } + + WriteEndEvent event{}; + uint64_t frames = 0; + const bool ok = ParseU64(text, cursor, contentEnd, event.sampleTime) && + ParseU64(text, cursor, contentEnd, event.hostTime) && + ParseU64(text, cursor, contentEnd, frames) && + AllSpace(text, cursor, contentEnd) && + frames <= 0xFFFFFFFFull; + if (!ok) { + ++result.malformedLines; + continue; + } + event.frameCount = static_cast(frames); + + if (result.eventCount == capacity) { + result.truncated = true; + return result; + } + outEvents[result.eventCount++] = event; + } + return result; + } + + // Replays events through any callable taking (const WriteEndEvent&). + template + static void Replay(const WriteEndEvent* events, size_t count, + Callback&& callback) { + for (size_t i = 0; i < count; ++i) { + callback(events[i]); + } + } + +private: + static bool IsSpace(char c) noexcept { + return c == ' ' || c == '\t' || c == '\r'; + } + + static bool AllSpace(const char* text, size_t from, size_t to) noexcept { + for (size_t i = from; i < to; ++i) { + if (!IsSpace(text[i])) { + return false; + } + } + return true; + } + + // Parses one base-10 u64 starting at `cursor` (after skipping spaces), + // advancing `cursor` past it. Overflow-checked. + static bool ParseU64(const char* text, size_t& cursor, size_t end, + uint64_t& out) noexcept { + while (cursor < end && IsSpace(text[cursor])) { + ++cursor; + } + if (cursor == end || text[cursor] < '0' || text[cursor] > '9') { + return false; + } + uint64_t value = 0; + while (cursor < end && text[cursor] >= '0' && text[cursor] <= '9') { + const uint64_t digit = static_cast(text[cursor] - '0'); + if (value > (0xFFFFFFFFFFFFFFFFull - digit) / 10ull) { + return false; // overflow + } + value = value * 10ull + digit; + ++cursor; + } + out = value; + return true; + } +}; + +} // namespace ASFW::Lab diff --git a/ADKVirtualAudioLab/Ports/ICycleTimeline.hpp b/ADKVirtualAudioLab/Ports/ICycleTimeline.hpp new file mode 100644 index 00000000..c6e8b545 --- /dev/null +++ b/ADKVirtualAudioLab/Ports/ICycleTimeline.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +namespace ASFW::Ports { + +// Time as an explicit port (see README, Architecture / Ports). +// +// Production binds this to the recovered bus timeline (ASFW already maintains +// an unwrapped output phase in the 24.576 MHz tick domain); the lab binds +// Lab::SimulatedCycleTimeline, which synthesizes bus time from audio sample +// position (6 frames per 125 us cycle at 48 kHz). Unwrapped by contract — +// consumers do wrap arithmetic themselves (the SYT domain wraps at 16 cycles, +// the bus second at 8000). +class ICycleTimeline { +public: + virtual ~ICycleTimeline() = default; + + // Monotonically increasing, unwrapped bus time in 24.576 MHz ticks. + virtual int64_t NowTicks() const noexcept = 0; +}; + +} // namespace ASFW::Ports diff --git a/ADKVirtualAudioLab/Tests/TxTimingModelTests.cpp b/ADKVirtualAudioLab/Tests/TxTimingModelTests.cpp new file mode 100644 index 00000000..6df337a6 --- /dev/null +++ b/ADKVirtualAudioLab/Tests/TxTimingModelTests.cpp @@ -0,0 +1,143 @@ +#include "TestHarness.hpp" + +#include "../Core/TxTimingModel.hpp" +#include "../Lab/SimulatedCycleTimeline.hpp" + +// P5 rehearsal at the unit level: the Saffire SYT contract — seed + constant +// device sub-cycle graft (0x0B0), fixed 4096-tick step in the 16-cycle +// domain (the +1,+1,+2 cycle pattern at 48 kHz blocking), and the +// transmit-lead health bands (tight ≤ 3071 / accept < 7620 / gate ≥ 7620 / +// escalate ≥ 12287). Constants per SYTGenerator.hpp, TxOutputPhaseLoop.hpp, +// and tools/debug/tx_phase_loop_model.py in the DICE tree. + +namespace ASFW::LabTests { + +using Driver::TxTimingModel; +using Lab::SimulatedCycleTimeline; + +void RunTxTimingModelTests(TestContext& ctx) { + using LeadHealth = TxTimingModel::LeadHealth; + + // Seed + graft, exact values: now=0, delay 4096 → 4096-1024+0x0B0 = 3248 + // → SYT cycle 1, offset 0x0B0 → 0x10B0, lead 3248 (accepted). + { + SimulatedCycleTimeline timeline{0}; + TxTimingModel model{}; + model.Configure(TxTimingModel::Config{}); + + const auto first = model.PeekNextDataSyt(timeline); + CHECK(ctx, first.seededThisCall); + CHECK_EQ_U32(ctx, first.syt, 0x10B0); + CHECK_EQ_U64(ctx, static_cast(first.leadTicks), 3248); + CHECK(ctx, first.health == LeadHealth::kAccepted); + + // Peek is pure: same answer until a commit. + const auto again = model.PeekNextDataSyt(timeline); + CHECK(ctx, !again.seededThisCall); + CHECK_EQ_U32(ctx, again.syt, 0x10B0); + } + + // Step sequence: +4096 per committed data packet → offsets walk + // {0x0B0, 0x4B0, 0x8B0}, cycles advance +1,+1,+2; 12 packets close the + // 16-cycle (49152-tick) domain exactly. + { + SimulatedCycleTimeline timeline{0}; + TxTimingModel model{}; + model.Configure(TxTimingModel::Config{}); + + constexpr uint16_t kExpected[12] = { + 0x10B0, 0x24B0, 0x38B0, 0x50B0, 0x64B0, 0x78B0, + 0x90B0, 0xA4B0, 0xB8B0, 0xD0B0, 0xE4B0, 0xF8B0, + }; + for (int i = 0; i < 12; ++i) { + const auto decision = model.PeekNextDataSyt(timeline); + CHECK_EQ_U32(ctx, decision.syt, kExpected[i]); + model.CommitDataPacket(); + } + // Wrap: the 13th packet repeats the first SYT. + CHECK_EQ_U32(ctx, model.PeekNextDataSyt(timeline).syt, 0x10B0); + } + + // Lead-health bands at exact boundaries, driven via NudgeOffsetTicks. + { + SimulatedCycleTimeline timeline{0}; + TxTimingModel model{}; + model.Configure(TxTimingModel::Config{}); + + auto leadTo = [&](int64_t target) { + const auto current = model.PeekNextDataSyt(timeline); + model.NudgeOffsetTicks(static_cast(target - current.leadTicks)); + return model.PeekNextDataSyt(timeline); + }; + + CHECK(ctx, leadTo(3071).health == LeadHealth::kTightWarn); + CHECK(ctx, leadTo(3072).health == LeadHealth::kAccepted); + CHECK(ctx, leadTo(7619).health == LeadHealth::kAccepted); + CHECK(ctx, leadTo(7620).health == LeadHealth::kGate); + CHECK(ctx, leadTo(12286).health == LeadHealth::kGate); + CHECK(ctx, leadTo(12287).health == LeadHealth::kEscalate); + CHECK(ctx, leadTo(0).health == LeadHealth::kTightWarn); + CHECK(ctx, leadTo(-1).health == LeadHealth::kLate); + } + + // Graft disabled: phase = now + delay exactly (offset 1024 → 0x1400). + { + SimulatedCycleTimeline timeline{0}; + TxTimingModel model{}; + TxTimingModel::Config config{}; + config.graftEnabled = false; + model.Configure(config); + + const auto decision = model.PeekNextDataSyt(timeline); + CHECK_EQ_U32(ctx, decision.syt, 0x1400); + CHECK_EQ_U64(ctx, static_cast(decision.leadTicks), 4096); + } + + // Monotonic guard: when the graft would land at/behind "now", whole + // cycles are re-added until the lead is positive (sub-cycle preserved). + { + SimulatedCycleTimeline timeline{0}; + timeline.AdvanceTicks(3500); + TxTimingModel model{}; + TxTimingModel::Config config{}; + config.presentationDelayTicks = 100; // 3600 → graft 3248 ≤ now → 6320 + model.Configure(config); + + const auto decision = model.PeekNextDataSyt(timeline); + CHECK_EQ_U64(ctx, static_cast(model.OutputPhaseTicks()), 6320); + CHECK_EQ_U64(ctx, static_cast(decision.leadTicks), 2820); + CHECK_EQ_U32(ctx, decision.syt & 0xFFFu, 0x0B0); + CHECK(ctx, decision.health == LeadHealth::kTightWarn); + } + + // Re-arm: a fresh anchor reseeds from the current timeline position with + // the graft lattice intact. + { + SimulatedCycleTimeline timeline{0}; + TxTimingModel model{}; + model.Configure(TxTimingModel::Config{}); + + (void)model.PeekNextDataSyt(timeline); + model.CommitDataPacket(); + + timeline.AdvanceToFrame(48000); // one second later + model.ArmTransmitCycleAnchor(); + const auto reseeded = model.PeekNextDataSyt(timeline); + CHECK(ctx, reseeded.seededThisCall); + CHECK_EQ_U32(ctx, reseeded.syt & 0xFFFu, 0x0B0); + CHECK(ctx, reseeded.leadTicks > 0 && + reseeded.leadTicks < + TxTimingModel::Config{}.acceptLeadTicks); + } + + // Not seeded before the first peek. + { + TxTimingModel model{}; + model.Configure(TxTimingModel::Config{}); + CHECK(ctx, !model.IsSeeded()); + model.CommitDataPacket(); // no-op while unseeded + CHECK_EQ_U64(ctx, static_cast(model.OutputPhaseTicks()), 0); + } +} + +} // namespace ASFW::LabTests diff --git a/ADKVirtualAudioLab/Tests/VerifierScenarioTests.cpp b/ADKVirtualAudioLab/Tests/VerifierScenarioTests.cpp index 811c32ce..38f12f59 100644 --- a/ADKVirtualAudioLab/Tests/VerifierScenarioTests.cpp +++ b/ADKVirtualAudioLab/Tests/VerifierScenarioTests.cpp @@ -1,9 +1,13 @@ #include "TestHarness.hpp" +#include "../Core/TxTimingModel.hpp" #include "../Core/VirtualAudioDeviceController.hpp" #include "../Lab/VerifyingSlotProvider.hpp" +#include "../Lab/WriteEndTraceReplayer.hpp" #include "../Protocols/Audio/DICE/DiceTxStreamEngine.hpp" +#include + // Step 6 scenario pump (README, Milestone 1 exit criteria): WriteEnd-shaped // schedules driving controller → engine → Verifying(Fake). The regular // schedule must run >= 1e6 cycles with zero invariant violations; the @@ -41,24 +45,44 @@ struct LabPump final { uint64_t exposedFrames{0}; uint64_t prepareFailures{0}; + bool timed{false}; + LabPump() noexcept : verifier(controller.FakeSlotProvider()) {} - bool Init() noexcept { + bool Init(bool useTiming = false) noexcept { if (!controller.Initialize() || !controller.SelectProfile(kFocusriteIdentity) || !controller.ConfigureOutputStream(48000, kChannels, kRingFrames)) { return false; } controller.BindLabSlotProvider(&verifier); + if (useTiming) { + timed = true; + VerifyingSlotProvider::Config config{}; + config.p5Enabled = true; // M2: data packets must carry real SYTs + verifier.Configure(config); + controller.EnableLabTiming(Driver::TxTimingModel::Config{}); + } controller.ResetTransportLab(0, 0); verifier.Reset(); return true; } - // Prepare packets until the exposed frame timeline covers endFrame. + // Prepare packets until the exposed frame timeline covers endFrame. In + // timed mode the simulated bus rides the exposure cursor — the lab analog + // of stamping SYT against the packet's projected transmit cycle (the + // Saffire FillFirewireBuffers model), not against "now". void PrepareCoverage(uint64_t endFrame) noexcept { while (exposedFrames < endFrame) { - if (!controller.PrepareLabPacket(nextPacketIndex, 0xFFFF, false)) { + bool prepared; + if (timed) { + controller.AdvanceLabTimelineToFrame(exposedFrames); + prepared = controller.PrepareLabPacketTimed(nextPacketIndex); + } else { + prepared = + controller.PrepareLabPacket(nextPacketIndex, 0xFFFF, false); + } + if (!prepared) { ++prepareFailures; return; } @@ -296,6 +320,91 @@ void RunVerifierScenarioTests(TestContext& ctx) { } CheckAllGreen(ctx, pump); } + + // Scenario G — Milestone 2 timed soak: real SYTs on every data packet, + // P1–P5 all green, and the transmit lead pinned at the seed value (the + // bus rides the exposure cursor, so peek-to-commit is lockstep: lead + // stays exactly at the grafted seed, inside the accept window). + { + LabPump pump{}; + CHECK(ctx, pump.Init(true)); + + uint64_t sampleTime = 0; + for (int i = 0; i < 1000; ++i) { + pump.Callback(sampleTime, 512, 0.25f); + sampleTime += 512; + } + + CheckAllGreen(ctx, pump); + const auto& timing = pump.controller.TimingCounters(); + CHECK(ctx, timing.dataPackets > 60000); + CHECK_EQ_U64(ctx, timing.seeds, 1); + CHECK_EQ_U64(ctx, timing.tightWarn, 0); + CHECK_EQ_U64(ctx, timing.late, 0); + CHECK_EQ_U64(ctx, timing.gate, 0); + CHECK_EQ_U64(ctx, timing.escalate, 0); + CHECK_EQ_U64(ctx, static_cast(timing.lastLeadTicks), 3248); + + const auto& payload = pump.controller.PayloadCounters(); + CHECK_EQ_U64(ctx, payload.framesVisited.load(), + payload.framesWritten.load()); + } + + // Scenario H — trace-driven timed run: a recorded WriteEnd sequence + // replayed through the full pipeline (the regression shape Milestone 3's + // captured trace will use). + { + LabPump pump{}; + CHECK(ctx, pump.Init(true)); + + const char* trace = + "# sample_time host_time frames\n" + "0 1000 512\n" + "512 2000 512\n" + "1024 3000 480\n" + "1504 4000 512\n" + "2016 5000 53\n" + "2069 6000 512\n"; + Lab::WriteEndEvent events[8]{}; + const auto parsed = Lab::WriteEndTraceReplayer::ParseTraceText( + trace, std::strlen(trace), events, 8); + CHECK_EQ_U64(ctx, parsed.eventCount, 6); + CHECK_EQ_U64(ctx, parsed.malformedLines, 0); + + Lab::WriteEndTraceReplayer::Replay( + events, parsed.eventCount, [&](const Lab::WriteEndEvent& event) { + pump.Callback(event.sampleTime, event.frameCount, 0.25f); + }); + + CheckAllGreen(ctx, pump); + CHECK(ctx, pump.controller.TimingCounters().dataPackets > 0); + } + + // Scenario I — restart under timing: counters and the SYT anchor reset + // with the stream, then the rerun is green with a fresh single seed. + { + LabPump pump{}; + CHECK(ctx, pump.Init(true)); + + uint64_t sampleTime = 0; + for (int i = 0; i < 8; ++i) { + pump.Callback(sampleTime, 512, 0.25f); + sampleTime += 512; + } + CheckAllGreen(ctx, pump); + + pump.controller.ResetTransportLab(0, 0); + pump.verifier.Reset(); + pump.exposedFrames = 0; + + sampleTime = 0; + for (int i = 0; i < 8; ++i) { + pump.Callback(sampleTime, 512, 0.25f); + sampleTime += 512; + } + CheckAllGreen(ctx, pump); + CHECK_EQ_U64(ctx, pump.controller.TimingCounters().seeds, 1); + } } } // namespace ASFW::LabTests diff --git a/ADKVirtualAudioLab/Tests/VerifyingSlotProviderTests.cpp b/ADKVirtualAudioLab/Tests/VerifyingSlotProviderTests.cpp index c88240d4..c8e2bba5 100644 --- a/ADKVirtualAudioLab/Tests/VerifyingSlotProviderTests.cpp +++ b/ADKVirtualAudioLab/Tests/VerifyingSlotProviderTests.cpp @@ -63,7 +63,7 @@ struct GoldenDriver final { return false; } - const auto words = isData ? cip_.BuildData(dbc_, 0xFFFF) + const auto words = isData ? cip_.BuildData(dbc_, nextDataSyt) : cip_.BuildNoData(dbc_); WriteBE32(slot.bytes, words.q0); WriteBE32(slot.bytes + 4, words.q1); @@ -79,7 +79,7 @@ struct GoldenDriver final { packet.byteCount = 8 + payloadBytes; packet.isData = isData; packet.dbc = dbc_; - packet.syt = 0xFFFF; + packet.syt = isData ? nextDataSyt : 0xFFFF; packet.firstAudioFrame = frame_; packet.framesInPacket = isData ? kFramesPerData : 0; packet.dbs = kDbs; @@ -113,6 +113,20 @@ struct GoldenDriver final { uint8_t pending_{0}; uint8_t dbc_{0}; uint64_t frame_{0}; + uint16_t nextDataSyt{0xFFFF}; // P5 tests drive a real SYT sequence +}; + +// Sequenced SYT source for P5 self-tests: walks the 16-cycle domain by the +// blocking step, on the 0x0B0 graft lattice (mirrors TxTimingModel). +struct P5SytSequence final { + uint32_t domainTick{3248}; // cycle 1, offset 0x0B0 + + uint16_t Next() noexcept { + const uint16_t syt = static_cast( + ((domainTick / 3072u) << 12) | (domainTick % 3072u)); + domainTick = (domainTick + 4096u) % 49152u; + return syt; + } }; // Runs warmup, one corrupted emit, cooldown; checks the targeted counter @@ -320,6 +334,117 @@ void RunVerifyingSlotProviderTests(TestContext& ctx) { CHECK_EQ_U64(ctx, snapshot.TotalViolations(), 1); } + // P5 — clean sequenced run: real SYTs on the graft lattice, exact step. + { + FakeIsochTxSlotProvider fake{}; + VerifyingSlotProvider verifier{fake}; + VerifyingSlotProvider::Config config{}; + config.p5Enabled = true; + verifier.Configure(config); + + GoldenDriver driver{verifier}; + P5SytSequence sequence{}; + for (int i = 0; i < 200; ++i) { + const bool isData = (driver.pending_ + 6) >= 8; + if (isData) { + driver.nextDataSyt = sequence.Next(); + } + CHECK(ctx, driver.EmitAuto()); + } + CHECK_EQ_U64(ctx, verifier.Snapshot().TotalViolations(), 0); + } + + // P5 — one skipped step: exactly one step violation, then resync. + { + FakeIsochTxSlotProvider fake{}; + VerifyingSlotProvider verifier{fake}; + VerifyingSlotProvider::Config config{}; + config.p5Enabled = true; + verifier.Configure(config); + + GoldenDriver driver{verifier}; + P5SytSequence sequence{}; + for (int i = 0; i < 120; ++i) { + if (i == 60) { + (void)sequence.Next(); // the producer lost one step + } + const bool isData = (driver.pending_ + 6) >= 8; + if (isData) { + driver.nextDataSyt = sequence.Next(); + } + CHECK(ctx, driver.EmitAuto()); + } + const VerifierSnapshot snapshot = verifier.Snapshot(); + CHECK_EQ_U64(ctx, + snapshot.Value(VerifierCounterId::kP5SytStepViolation), 1); + CHECK_EQ_U64(ctx, snapshot.TotalViolations(), 1); + } + + // P5 — off-lattice first SYT: graft violation on the first data packet, + // plus exactly one step violation when the producer returns to the + // lattice (resync-after-violation keeps both isolated). + { + FakeIsochTxSlotProvider fake{}; + VerifyingSlotProvider verifier{fake}; + VerifyingSlotProvider::Config config{}; + config.p5Enabled = true; + verifier.Configure(config); + + GoldenDriver driver{verifier}; + P5SytSequence sequence{}; + bool corruptedFirst = false; + for (int i = 0; i < 80; ++i) { + const bool isData = (driver.pending_ + 6) >= 8; + if (isData) { + uint16_t syt = sequence.Next(); + if (!corruptedFirst) { + syt = static_cast(syt + 512); // off the lattice + corruptedFirst = true; + } + driver.nextDataSyt = syt; + } + CHECK(ctx, driver.EmitAuto()); + } + const VerifierSnapshot snapshot = verifier.Snapshot(); + CHECK_EQ_U64(ctx, + snapshot.Value(VerifierCounterId::kP5SytGraftViolation), 1); + CHECK_EQ_U64(ctx, + snapshot.Value(VerifierCounterId::kP5SytStepViolation), 1); + CHECK_EQ_U64(ctx, snapshot.TotalViolations(), 2); + } + + // P5 — a data packet carrying 0xFFFF in a timing-valid run. + { + FakeIsochTxSlotProvider fake{}; + VerifyingSlotProvider verifier{fake}; + VerifyingSlotProvider::Config config{}; + config.p5Enabled = true; + verifier.Configure(config); + + GoldenDriver driver{verifier}; + P5SytSequence sequence{}; + bool dropped = false; + int dataSeen = 0; + for (int i = 0; i < 80; ++i) { + const bool isData = (driver.pending_ + 6) >= 8; + if (isData) { + ++dataSeen; + if (dataSeen == 30 && !dropped) { + driver.nextDataSyt = 0xFFFF; // producer lost its clock + (void)sequence.Next(); // it still consumed the step + dropped = true; + } else { + driver.nextDataSyt = sequence.Next(); + } + } + CHECK(ctx, driver.EmitAuto()); + } + const VerifierSnapshot snapshot = verifier.Snapshot(); + CHECK_EQ_U64(ctx, + snapshot.Value(VerifierCounterId::kP5SytStepViolation), 1); + CHECK_EQ_U64(ctx, snapshot.TotalViolations(), 1); + } + // Reset() clears counters and re-arms the learned state. { FakeIsochTxSlotProvider fake{}; diff --git a/ADKVirtualAudioLab/Tests/WriteEndTraceReplayerTests.cpp b/ADKVirtualAudioLab/Tests/WriteEndTraceReplayerTests.cpp new file mode 100644 index 00000000..014038a5 --- /dev/null +++ b/ADKVirtualAudioLab/Tests/WriteEndTraceReplayerTests.cpp @@ -0,0 +1,88 @@ +#include "TestHarness.hpp" + +#include "../Lab/WriteEndTraceReplayer.hpp" + +#include + +namespace ASFW::LabTests { + +using Lab::TraceParseResult; +using Lab::WriteEndEvent; +using Lab::WriteEndTraceReplayer; + +void RunWriteEndTraceReplayerTests(TestContext& ctx) { + // Comments, blank lines, whitespace, and a trailing line without newline. + { + const char* trace = + "# WriteEnd trace: sample_time host_time frame_count\n" + "\n" + "0 1000000 512\n" + " 512 1256000 512 # inline comment\n" + "\t1024\t1512000\t480\n" + "1504 1768000 512"; + WriteEndEvent events[8]{}; + const TraceParseResult result = WriteEndTraceReplayer::ParseTraceText( + trace, std::strlen(trace), events, 8); + + CHECK_EQ_U64(ctx, result.eventCount, 4); + CHECK_EQ_U64(ctx, result.malformedLines, 0); + CHECK(ctx, !result.truncated); + CHECK_EQ_U64(ctx, events[0].sampleTime, 0); + CHECK_EQ_U64(ctx, events[0].hostTime, 1000000); + CHECK_EQ_U32(ctx, events[0].frameCount, 512); + CHECK_EQ_U64(ctx, events[2].sampleTime, 1024); + CHECK_EQ_U32(ctx, events[2].frameCount, 480); + CHECK_EQ_U64(ctx, events[3].sampleTime, 1504); + } + + // Malformed lines are counted and skipped, never silently absorbed. + { + const char* trace = + "0 100 512\n" + "not a line\n" + "1 2\n" // missing field + "512 200 512 junk\n" // trailing junk + "512 200 4294967296\n" // frame count > uint32 + "99999999999999999999 1 1\n" // u64 overflow + "1024 300 512\n"; + WriteEndEvent events[8]{}; + const TraceParseResult result = WriteEndTraceReplayer::ParseTraceText( + trace, std::strlen(trace), events, 8); + + CHECK_EQ_U64(ctx, result.eventCount, 2); + CHECK_EQ_U64(ctx, result.malformedLines, 5); + CHECK_EQ_U64(ctx, events[1].sampleTime, 1024); + } + + // Capacity exhaustion reports truncation instead of dropping silently. + { + const char* trace = "0 1 512\n512 2 512\n1024 3 512\n"; + WriteEndEvent events[2]{}; + const TraceParseResult result = WriteEndTraceReplayer::ParseTraceText( + trace, std::strlen(trace), events, 2); + + CHECK_EQ_U64(ctx, result.eventCount, 2); + CHECK(ctx, result.truncated); + } + + // Replay visits every event in order. + { + const char* trace = "0 1 512\n512 2 480\n992 3 512\n"; + WriteEndEvent events[4]{}; + const TraceParseResult result = WriteEndTraceReplayer::ParseTraceText( + trace, std::strlen(trace), events, 4); + CHECK_EQ_U64(ctx, result.eventCount, 3); + + uint64_t frames = 0; + uint64_t lastSample = 0; + WriteEndTraceReplayer::Replay(events, result.eventCount, + [&](const WriteEndEvent& event) { + frames += event.frameCount; + lastSample = event.sampleTime; + }); + CHECK_EQ_U64(ctx, frames, 1504); + CHECK_EQ_U64(ctx, lastSample, 992); + } +} + +} // namespace ASFW::LabTests diff --git a/ADKVirtualAudioLab/Tests/main.cpp b/ADKVirtualAudioLab/Tests/main.cpp index 64fdd148..bf56a920 100644 --- a/ADKVirtualAudioLab/Tests/main.cpp +++ b/ADKVirtualAudioLab/Tests/main.cpp @@ -15,6 +15,8 @@ void RunPayloadWriterTests(TestContext& ctx); void RunDiceTxEngineTests(TestContext& ctx); void RunVerifyingSlotProviderTests(TestContext& ctx); void RunVerifierScenarioTests(TestContext& ctx); +void RunTxTimingModelTests(TestContext& ctx); +void RunWriteEndTraceReplayerTests(TestContext& ctx); } // namespace ASFW::LabTests @@ -33,6 +35,8 @@ int main() { RunPayloadWriterTests(ctx); RunDiceTxEngineTests(ctx); RunVerifyingSlotProviderTests(ctx); + RunTxTimingModelTests(ctx); + RunWriteEndTraceReplayerTests(ctx); RunVerifierScenarioTests(ctx); std::printf("%d checks, %d failures\n", ctx.checks, ctx.failures);