From a6abdee9152c8fb732a3d6c1d5a643aa707ade7c Mon Sep 17 00:00:00 2001 From: Brian Hoffman Date: Fri, 12 Jun 2026 12:15:01 -0400 Subject: [PATCH 1/3] fix(async): route FCP quadlet-write responses to FCPResponseRouter Short AV/C responses (e.g. the 4-byte ACCEPTED a camcorder sends for a tape transport command) arrive as quadlet writes (tCode 0x0) to the FCP response register, not block writes. FcpLocalHandler only claimed block writes, so these responses fell through and every short AV/C command timed out on the response path. Handle kTcodeWriteQuad by rebuilding wire byte order from the host-order quadlet value and routing through the same path as block writes. The handler still self-filters by destination offset, so non-FCP quadlet writes continue to fall through to DICE/SBP-2. Verified on hardware: tape subunit PLAY/STOP/WIND against a Panasonic MiniDV camcorder now complete with ACCEPTED instead of timing out. Co-authored-by: Cursor --- ASFWDriver/Service/LocalRequestWiring.cpp | 40 ++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/ASFWDriver/Service/LocalRequestWiring.cpp b/ASFWDriver/Service/LocalRequestWiring.cpp index 1b88ae08..885ecfcb 100644 --- a/ASFWDriver/Service/LocalRequestWiring.cpp +++ b/ASFWDriver/Service/LocalRequestWiring.cpp @@ -26,7 +26,9 @@ #include "../Protocols/Ports/FireWireRxPort.hpp" #include "../Protocols/SBP2/AddressSpaceManager.hpp" +#include #include +#include namespace ASFW::Service { @@ -79,20 +81,51 @@ class CSRLocalHandler final : public ILocalAddressHandler { ASFW::Bus::CSRResponder* csr_; }; -// --- FCP: AV/C command/response block writes ----------------------------------- +// --- FCP: AV/C command/response writes ------------------------------------------ +// Both block writes and quadlet writes must be handled: short AV/C responses +// (e.g. the 4-byte ACCEPTED a camcorder returns for a tape transport command) +// arrive as quadlet writes (tCode 0x0) to the FCP response register. Dropping +// them makes every short AV/C command time out on the response path. class FcpLocalHandler final : public ILocalAddressHandler { public: explicit FcpLocalHandler(ASFW::Protocols::AVC::FCPResponseRouter* fcp) noexcept : fcp_(fcp) {} [[nodiscard]] const char* Name() const noexcept override { return "FCP"; } [[nodiscard]] LocalRequestResult HandleLocalRequest(const LocalRequestContext& ctx) override { - if (fcp_ == nullptr || ctx.tCode != AReq::kTcodeWriteBlock || ctx.writePayload.empty()) { + if (fcp_ == nullptr) { return LocalRequestResult::NotMine(); } + + if (ctx.tCode == AReq::kTcodeWriteBlock) { + if (ctx.writePayload.empty()) { + return LocalRequestResult::NotMine(); + } + return Route(ctx, ctx.writePayload); + } + + if (ctx.tCode == AReq::kTcodeWriteQuad) { + // ctx.writePayload points at the little-endian AR header storage; + // rebuild wire (big-endian) byte order from the host-order value + // so the FCP frame reads as [ctype, subunit, opcode, operand]. + const std::array frame{ + static_cast(ctx.quadletData >> 24), + static_cast(ctx.quadletData >> 16), + static_cast(ctx.quadletData >> 8), + static_cast(ctx.quadletData), + }; + return Route(ctx, std::span(frame.data(), frame.size())); + } + + return LocalRequestResult::NotMine(); + } + +private: + [[nodiscard]] LocalRequestResult Route(const LocalRequestContext& ctx, + std::span payload) { const ASFW::Protocols::Ports::BlockWriteRequestView request{ .sourceID = ctx.sourceID, .destOffset = ctx.destOffset, - .payload = ctx.writePayload, + .payload = payload, }; const auto disposition = fcp_->RouteBlockWrite(request); if (disposition == ASFW::Protocols::Ports::BlockWriteDisposition::kAddressError) { @@ -101,7 +134,6 @@ class FcpLocalHandler final : public ILocalAddressHandler { return LocalRequestResult::Write(ResponseCode::Complete); } -private: ASFW::Protocols::AVC::FCPResponseRouter* fcp_; }; From 2fa9b10f08b73f20a73586b2084d90334cb04759 Mon Sep 17 00:00:00 2001 From: Brian Hoffman Date: Fri, 12 Jun 2026 12:15:01 -0400 Subject: [PATCH 2/3] feat(isoch): add DV (IEC 61883-2) capture path with app-side .dv writer Adds a minimal DV capture tap to the IR receive path so MiniDV camcorder video can be captured to a raw .dv file: - DVCaptureSink (driver): hooks the existing per-packet callback in IsochReceiveContext::Poll(), filters CIP FMT=0x00, and writes raw 480-byte DIF chunks into a shared memory SPSC ring (~3.9MB, drop-on-full). Handles both SPH=0 streams (consumer camcorders, dv1394-style: timestamp in CIP.SYT, plain 480-byte blocks) and SPH=1 (per-block source packet header). - IsochService: StartDVCapture/StopDVCapture start IR on a given channel with no direct-audio binding source; the per-packet callback is installed before Start() to avoid racing Poll(). StartReceive gains an optional packet callback parameter (default unchanged). - User client: selectors 50/51 (start/stop DV capture) and CopyClientMemoryForType type 1 to map the DIF ring into the app. - App: new "DV Capture" view with AV/C tape transport controls (PLAY/STOP/REWIND via existing raw FCP path), channel selection (default 63), live stats (frames, drops, CIP diagnostics), and a frame-aware .dv writer that only emits exact-size frames so a glitched packet costs one frame instead of misaligning the file. Verified on hardware (Panasonic MiniDV over OHCI FireWire 400): 32s NTSC capture, 971/973 clean frames, output plays in QuickTime and remuxes losslessly with ffmpeg -c copy. (Validation was performed with this change set on main; re-validation on DICE pending.) Known limitations (intentionally minimal first pass): shares the single IR context with audio receive (mutually exclusive, guarded with kIOReturnBusy), no CMP connection management (listens on the broadcast channel), no DBC continuity tracking in the driver. Co-authored-by: Cursor --- ASFW/ASFWDriverConnector.swift | 3 + ASFW/DriverConnector+DVCapture.swift | 196 ++++++++++ ASFW/Views/DVCaptureView.swift | 365 ++++++++++++++++++ ASFW/Views/ModernContentView.swift | 4 + ASFWDriver/ASFWDriver.cpp | 30 ++ ASFWDriver/ASFWDriver.iig | 6 + ASFWDriver/Isoch/IsochService.cpp | 107 ++++- ASFWDriver/Isoch/IsochService.hpp | 32 +- ASFWDriver/Isoch/Receive/DVCaptureSink.hpp | 176 +++++++++ .../UserClient/Core/ASFWDriverUserClient.cpp | 20 +- .../UserClient/Core/ASFWDriverUserClient.iig | 4 + .../UserClient/Handlers/IsochHandler.cpp | 21 + .../UserClient/Handlers/IsochHandler.hpp | 4 + 13 files changed, 961 insertions(+), 7 deletions(-) create mode 100644 ASFW/DriverConnector+DVCapture.swift create mode 100644 ASFW/Views/DVCaptureView.swift create mode 100644 ASFWDriver/Isoch/Receive/DVCaptureSink.hpp diff --git a/ASFW/ASFWDriverConnector.swift b/ASFW/ASFWDriverConnector.swift index be052687..056d49c6 100644 --- a/ASFW/ASFWDriverConnector.swift +++ b/ASFW/ASFWDriverConnector.swift @@ -53,6 +53,9 @@ final class ASFWDriverConnector: ObservableObject { case setIsochVerbosity = 40 case asyncBlockRead = 44 case asyncBlockWrite = 45 + // DV capture (raw DIF stream via shared ring, memory type 1) + case startDVCapture = 50 + case stopDVCapture = 51 } // MARK: - Re-exported Models diff --git a/ASFW/DriverConnector+DVCapture.swift b/ASFW/DriverConnector+DVCapture.swift new file mode 100644 index 00000000..02b3f32f --- /dev/null +++ b/ASFW/DriverConnector+DVCapture.swift @@ -0,0 +1,196 @@ +// +// DriverConnector+DVCapture.swift +// ASFW +// +// DV (IEC 61883-2) capture control and shared DIF ring access. +// +// The driver fills a shared-memory SPSC ring with raw 480-byte DIF chunks +// (one DV source packet each). This file maps the ring (memory type 1) and +// consumes records; the view layer concatenates them into a .dv file. +// +// Ring layout is ABI with ASFWDriver/Isoch/Receive/DVCaptureSink.hpp: +// +0 magic 'ASDV' (0x41534456) +// +4 version (u16) = 1 +// +8 numRecords (u32) +// +12 recordBytes (u32) = 480 +// +16 dataOffsetBytes (u32) = 192 +// +24 packetsSeen (u32) +// +28 dvSourcePackets (u32) +// +32 nonDvPackets (u32) +// +36 overruns (u32) +// +64 writeIndex (u32, driver-owned, free-running records) +// +128 readIndex (u32, app-owned, free-running records) +// +192 record data (numRecords × 480 bytes) +// + +import Foundation +import IOKit + +// MARK: - DV Ring Stats + +struct DVCaptureStats { + var packetsSeen: UInt32 = 0 + var dvSourcePackets: UInt32 = 0 + var nonDvPackets: UInt32 = 0 + var overruns: UInt32 = 0 + var lastRejectLen: UInt32 = 0 + var lastRejectQ0: UInt32 = 0 + var lastRejectQ1: UInt32 = 0 + var lastXferStatus: UInt32 = 0 +} + +// MARK: - Mapped Ring + +/// Consumer-side view of the shared DV ring. Single consumer only. +final class DVCaptureRing { + static let magic: UInt32 = 0x41534456 // 'ASDV' + static let recordBytes = 480 + + private let base: UnsafeMutableRawPointer + private let mappedAddress: mach_vm_address_t + private let connection: io_connect_t + private let numRecords: UInt32 + private let dataOffset: Int + + private init(base: UnsafeMutableRawPointer, + mappedAddress: mach_vm_address_t, + connection: io_connect_t, + numRecords: UInt32, + dataOffset: Int) { + self.base = base + self.mappedAddress = mappedAddress + self.connection = connection + self.numRecords = numRecords + self.dataOffset = dataOffset + } + + static func map(connection: io_connect_t) -> DVCaptureRing? { + var address: mach_vm_address_t = 0 + var length: mach_vm_size_t = 0 + let options = UInt32(kIOMapAnywhere | kIOMapDefaultCache) + let kr = IOConnectMapMemory64(connection, 1, mach_task_self_, &address, &length, options) + guard kr == KERN_SUCCESS, let pointer = UnsafeMutableRawPointer(bitPattern: UInt(address)) else { + return nil + } + + let magic = pointer.load(fromByteOffset: 0, as: UInt32.self) + let numRecords = pointer.load(fromByteOffset: 8, as: UInt32.self) + let recBytes = pointer.load(fromByteOffset: 12, as: UInt32.self) + let dataOffset = pointer.load(fromByteOffset: 16, as: UInt32.self) + + guard magic == DVCaptureRing.magic, + recBytes == UInt32(DVCaptureRing.recordBytes), + numRecords > 0, + UInt64(dataOffset) + UInt64(numRecords) * UInt64(recBytes) <= UInt64(length) else { + IOConnectUnmapMemory64(connection, 1, mach_task_self_, address) + return nil + } + + return DVCaptureRing(base: pointer, + mappedAddress: address, + connection: connection, + numRecords: numRecords, + dataOffset: Int(dataOffset)) + } + + func unmap() { + IOConnectUnmapMemory64(connection, 1, mach_task_self_, mappedAddress) + } + + /// Drain all available records, invoking the handler with each 480-byte chunk. + /// Returns the number of records consumed. + /// + /// Note on ordering: the driver release-stores writeIndex after the memcpy. + /// We poll at tens of milliseconds, so observed records were published long + /// before we read them; explicit acquire barriers are intentionally omitted. + @discardableResult + func drain(_ handler: (UnsafeRawBufferPointer) -> Void) -> Int { + let w = base.load(fromByteOffset: 64, as: UInt32.self) + var r = base.load(fromByteOffset: 128, as: UInt32.self) + var consumed = 0 + + while r != w { + let idx = Int(r % numRecords) + let chunk = UnsafeRawBufferPointer( + start: base + dataOffset + idx * DVCaptureRing.recordBytes, + count: DVCaptureRing.recordBytes + ) + handler(chunk) + r &+= 1 + consumed += 1 + } + + if consumed > 0 { + base.storeBytes(of: r, toByteOffset: 128, as: UInt32.self) + } + return consumed + } + + var stats: DVCaptureStats { + DVCaptureStats( + packetsSeen: base.load(fromByteOffset: 24, as: UInt32.self), + dvSourcePackets: base.load(fromByteOffset: 28, as: UInt32.self), + nonDvPackets: base.load(fromByteOffset: 32, as: UInt32.self), + overruns: base.load(fromByteOffset: 36, as: UInt32.self), + lastRejectLen: base.load(fromByteOffset: 40, as: UInt32.self), + lastRejectQ0: base.load(fromByteOffset: 44, as: UInt32.self), + lastRejectQ1: base.load(fromByteOffset: 48, as: UInt32.self), + lastXferStatus: base.load(fromByteOffset: 52, as: UInt32.self) + ) + } +} + +// MARK: - Driver Connector Extension + +extension ASFWDriverConnector { + + /// Start DV capture on the given isoch channel (camcorders broadcast on 63). + func startDVCapture(channel: UInt8) -> Bool { + guard isConnected, connection != 0 else { return false } + + var input: [UInt64] = [UInt64(channel)] + let kr = IOConnectCallScalarMethod( + connection, + Method.startDVCapture.rawValue, + &input, 1, + nil, nil + ) + + if kr != KERN_SUCCESS { + log("startDVCapture failed: \(interpretIOReturn(kr))", level: .error) + return false + } + log("Started DV capture on channel \(channel)", level: .info) + return true + } + + /// Stop DV capture. + func stopDVCapture() -> Bool { + guard isConnected, connection != 0 else { return false } + + let kr = IOConnectCallScalarMethod( + connection, + Method.stopDVCapture.rawValue, + nil, 0, + nil, nil + ) + + if kr != KERN_SUCCESS { + log("stopDVCapture failed: \(interpretIOReturn(kr))", level: .error) + return false + } + log("Stopped DV capture", level: .info) + return true + } + + /// Map the shared DV ring. Call after startDVCapture succeeds. + func mapDVCaptureRing() -> DVCaptureRing? { + guard isConnected, connection != 0 else { return nil } + guard let ring = DVCaptureRing.map(connection: connection) else { + log("mapDVCaptureRing: mapping failed", level: .error) + return nil + } + log("Mapped DV capture ring", level: .info) + return ring + } +} diff --git a/ASFW/Views/DVCaptureView.swift b/ASFW/Views/DVCaptureView.swift new file mode 100644 index 00000000..6c1b7ed3 --- /dev/null +++ b/ASFW/Views/DVCaptureView.swift @@ -0,0 +1,365 @@ +// +// DVCaptureView.swift +// ASFW +// +// Capture DV (MiniDV camcorder) video to a raw .dv file. +// +// Flow: AV/C tape transport (PLAY) via raw FCP → driver receives the isoch +// stream and fills the shared DIF ring → this view drains the ring and +// appends chunks to a .dv file. Raw DV files open directly in QuickTime, +// iMovie, FFmpeg, DaVinci Resolve, etc. +// + +import SwiftUI +import AppKit +import Combine + +// MARK: - Capture Controller + +@MainActor +final class DVCaptureController: ObservableObject { + @Published var isCapturing = false + @Published var bytesWritten: UInt64 = 0 + @Published var framesSeen: Int = 0 + @Published var droppedFrames: Int = 0 + @Published var stats = DVCaptureStats() + @Published var systemLabel = "—" + @Published var lastError: String? + + private var ring: DVCaptureRing? + private var fileHandle: FileHandle? + private var captureTask: Task? + + // Frame accumulator: only exact-size frames are written, so a duplicated + // or lost packet corrupts one frame instead of misaligning the whole file + // (DV demuxers read fixed 120000/144000-byte records with no resync). + private var currentFrame = Data() + private var inFrame = false + private var expectedFrameBytes = 120_000 + + func start(connector: ASFWDriverConnector, channel: UInt8, url: URL) { + guard !isCapturing else { return } + lastError = nil + + guard FileManager.default.createFile(atPath: url.path, contents: nil), + let handle = try? FileHandle(forWritingTo: url) else { + lastError = "Could not create \(url.lastPathComponent)" + return + } + + guard connector.startDVCapture(channel: channel) else { + lastError = connector.lastError ?? "startDVCapture failed (is audio receive running?)" + try? handle.close() + return + } + + guard let mappedRing = connector.mapDVCaptureRing() else { + lastError = "Failed to map DV ring" + _ = connector.stopDVCapture() + try? handle.close() + return + } + + fileHandle = handle + ring = mappedRing + bytesWritten = 0 + framesSeen = 0 + droppedFrames = 0 + currentFrame.removeAll() + inFrame = false + systemLabel = "—" + isCapturing = true + + captureTask = Task { [weak self] in + while !Task.isCancelled { + self?.tick() + try? await Task.sleep(nanoseconds: 33_000_000) // ~30 Hz + } + } + } + + func stop(connector: ASFWDriverConnector) { + guard isCapturing else { return } + + captureTask?.cancel() + captureTask = nil + + // Final drain so the tail of the stream lands in the file. + tick() + flushCurrentFrame() + + _ = connector.stopDVCapture() + ring?.unmap() + ring = nil + try? fileHandle?.close() + fileHandle = nil + isCapturing = false + } + + private func tick() { + guard let ring else { return } + + ring.drain { chunk in + // Frame header chunk: first DIF block is the header section + // (bytes 0x1F 0x07 0x00). + let isFrameStart = chunk[0] == 0x1F && chunk[1] == 0x07 && chunk[2] == 0x00 + if isFrameStart { + flushCurrentFrame() + inFrame = true + let isPAL = (chunk[3] & 0x80) != 0 + expectedFrameBytes = isPAL ? 144_000 : 120_000 + if systemLabel == "—" { + systemLabel = isPAL ? "PAL (625/50)" : "NTSC (525/60)" + } + currentFrame.removeAll(keepingCapacity: true) + } + guard inFrame else { return } + currentFrame.append(contentsOf: chunk) + } + + stats = ring.stats + } + + private func flushCurrentFrame() { + guard inFrame, !currentFrame.isEmpty else { return } + if currentFrame.count == expectedFrameBytes, let fileHandle { + fileHandle.write(currentFrame) + bytesWritten += UInt64(currentFrame.count) + framesSeen += 1 + } else { + droppedFrames += 1 + } + } +} + +// MARK: - View + +struct DVCaptureView: View { + @ObservedObject var viewModel: DebugViewModel + @StateObject private var controller = DVCaptureController() + + @State private var avcUnits: [ASFWDriverConnector.AVCUnitInfo] = [] + @State private var selectedUnitGUID: UInt64? + @State private var channelText = "63" + @State private var outputURL: URL? + @State private var transportBusy = false + @State private var transportStatus: String? + + var body: some View { + Form { + statusSection + camcorderSection + captureSection + } + .formStyle(.grouped) + .navigationTitle("DV Capture") + .onAppear { refreshUnits() } + } + + // MARK: Sections + + private var statusSection: some View { + Section("Status") { + if viewModel.isConnected { + Label("Connected to driver", systemImage: "checkmark.circle.fill") + .foregroundColor(.green) + } else { + Label("Driver not connected", systemImage: "xmark.circle.fill") + .foregroundColor(.red) + } + if let error = controller.lastError { + Label(error, systemImage: "exclamationmark.triangle.fill") + .foregroundColor(.red) + } + } + } + + private var camcorderSection: some View { + Section("Camcorder Transport (AV/C Tape Subunit)") { + if avcUnits.isEmpty { + HStack { + Text("No AV/C units found") + .foregroundColor(.secondary) + Button("Refresh") { refreshUnits() } + } + } else { + Picker("Unit", selection: $selectedUnitGUID) { + ForEach(avcUnits) { unit in + Text("GUID: \(unit.guidHex) (Node: \(unit.nodeIDHex))") + .tag(unit.guid as UInt64?) + } + } + } + + HStack(spacing: 12) { + Button { + sendTransport(opcode: 0xC4, operand: 0x65, label: "Rewind") + } label: { + Label("Rewind", systemImage: "backward.fill") + } + + Button { + sendTransport(opcode: 0xC3, operand: 0x75, label: "Play") + } label: { + Label("Play", systemImage: "play.fill") + } + + Button { + sendTransport(opcode: 0xC4, operand: 0x60, label: "Stop") + } label: { + Label("Stop", systemImage: "stop.fill") + } + } + .disabled(transportBusy || selectedUnitGUID == nil || !viewModel.isConnected) + + if let status = transportStatus { + Text(status) + .font(.caption) + .foregroundColor(.secondary) + } + } + } + + private var captureSection: some View { + Section("Capture") { + HStack { + TextField("Channel", text: $channelText) + .frame(width: 60) + Text("Isoch channel (camcorders broadcast on 63)") + .font(.caption) + .foregroundColor(.secondary) + } + .disabled(controller.isCapturing) + + HStack { + Button("Choose Output File…") { chooseOutputFile() } + .disabled(controller.isCapturing) + Text(outputURL?.lastPathComponent ?? "No file selected") + .foregroundColor(.secondary) + } + + if controller.isCapturing { + Button { + controller.stop(connector: viewModel.connector) + } label: { + Label("Stop Capture", systemImage: "stop.circle.fill") + } + .tint(.red) + } else { + Button { + startCapture() + } label: { + Label("Start Capture", systemImage: "record.circle") + } + .disabled(!viewModel.isConnected || outputURL == nil) + } + + captureStats + } + } + + private var captureStats: some View { + Group { + LabeledContent("System", value: controller.systemLabel) + LabeledContent("Frames", value: "\(controller.framesSeen)") + LabeledContent("Dropped frames", value: "\(controller.droppedFrames)") + .foregroundColor(controller.droppedFrames > 0 ? .orange : .primary) + LabeledContent("Written", value: formatBytes(controller.bytesWritten)) + LabeledContent("Isoch packets seen", value: "\(controller.stats.packetsSeen)") + LabeledContent("DV source packets", value: "\(controller.stats.dvSourcePackets)") + LabeledContent("Non-DV packets", value: "\(controller.stats.nonDvPackets)") + LabeledContent("Ring overruns", value: "\(controller.stats.overruns)") + .foregroundColor(controller.stats.overruns > 0 ? .orange : .primary) + if controller.stats.nonDvPackets > 0 { + LabeledContent("Last rejected", + value: String(format: "len=%u q0=%08X q1=%08X", + controller.stats.lastRejectLen, + controller.stats.lastRejectQ0, + controller.stats.lastRejectQ1)) + .foregroundColor(.orange) + LabeledContent("Last xferStatus", + value: String(format: "%04X (evt=0x%02X)", + controller.stats.lastXferStatus, + controller.stats.lastXferStatus & 0x1F)) + .foregroundColor(.orange) + } + } + .font(.callout) + } + + // MARK: Actions + + private func refreshUnits() { + guard viewModel.isConnected else { return } + + DispatchQueue.global(qos: .userInitiated).async { + let units = viewModel.connector.getAVCUnits() ?? [] + DispatchQueue.main.async { + self.avcUnits = units + if self.selectedUnitGUID == nil, let first = units.first { + self.selectedUnitGUID = first.guid + } + } + } + } + + private func sendTransport(opcode: UInt8, operand: UInt8, label: String) { + guard let guid = selectedUnitGUID else { return } + + // CONTROL, tape subunit (type 0x04, id 0), opcode, operand. + // Exactly 4 bytes - transport opcodes take a single operand and some + // camcorders reject frames with extra padding operands. + let frame = Data([0x00, 0x20, opcode, operand]) + + transportBusy = true + transportStatus = "Sending \(label)…" + + DispatchQueue.global(qos: .userInitiated).async { + let response = viewModel.connector.sendRawFCPCommand(guid: guid, frame: frame) + DispatchQueue.main.async { + self.transportBusy = false + if let response, response.count >= 1 { + let code = response[0] + // AV/C response codes: 0x09 ACCEPTED, 0x08 NOT IMPLEMENTED, 0x0A REJECTED + let codeLabel: String + switch code { + case 0x09: codeLabel = "ACCEPTED" + case 0x0A: codeLabel = "REJECTED" + case 0x08: codeLabel = "NOT IMPLEMENTED" + case 0x0C: codeLabel = "IMPLEMENTED/STABLE" + default: codeLabel = String(format: "0x%02X", code) + } + self.transportStatus = "\(label): \(codeLabel)" + } else { + // Timeout is common (some camcorders act on the command but + // the response path misses it) - check if the tape moved. + self.transportStatus = "\(label): no FCP response (command may still have worked)" + } + } + } + } + + private func chooseOutputFile() { + let panel = NSSavePanel() + panel.nameFieldStringValue = "capture.dv" + panel.canCreateDirectories = true + if panel.runModal() == .OK { + outputURL = panel.url + } + } + + private func startCapture() { + guard let url = outputURL else { return } + let channel = UInt8(channelText) ?? 63 + controller.start(connector: viewModel.connector, + channel: min(channel, 63), + url: url) + } + + private func formatBytes(_ bytes: UInt64) -> String { + if bytes < 1_000_000 { + return String(format: "%.0f KB", Double(bytes) / 1000) + } + return String(format: "%.1f MB", Double(bytes) / 1_000_000) + } +} diff --git a/ASFW/Views/ModernContentView.swift b/ASFW/Views/ModernContentView.swift index b622e364..c7d02b9c 100644 --- a/ASFW/Views/ModernContentView.swift +++ b/ASFW/Views/ModernContentView.swift @@ -42,6 +42,7 @@ struct ModernContentView: View { case topology = "Topology & Self-ID" case romExplorer = "ROM Explorer" case metrics = "Isoch Metrics" + case dvCapture = "DV Capture" case busReset = "Bus Reset History" case logs = "System Logs" case loggingSettings = "Logging Settings" @@ -64,6 +65,7 @@ struct ModernContentView: View { case .topology: return "network" case .romExplorer: return "memorychip" case .metrics: return "chart.bar.xaxis" + case .dvCapture: return "video.fill" case .busReset: return "bolt.horizontal.circle" case .logs: return "doc.text" case .loggingSettings: return "slider.horizontal.3" @@ -111,6 +113,8 @@ struct ModernContentView: View { ROMExplorerView(viewModel: romExplorerVM) case .metrics: MetricsView(connector: debugVM.connector) + case .dvCapture: + DVCaptureView(viewModel: debugVM) case .busReset: BusResetHistoryView(viewModel: debugVM) case .logs: diff --git a/ASFWDriver/ASFWDriver.cpp b/ASFWDriver/ASFWDriver.cpp index e0f5d756..5fc167d1 100644 --- a/ASFWDriver/ASFWDriver.cpp +++ b/ASFWDriver/ASFWDriver.cpp @@ -762,6 +762,36 @@ void* ASFWDriver::GetIsochReceiveContext() const { return ivars->context->isoch.ReceiveContext(); } +// ============================================================================= +// MARK: - DV Capture (no audio nub required) +// ============================================================================= + +kern_return_t ASFWDriver::StartDVCapture(uint8_t channel) { + if (!ivars || !ivars->context) { + return kIOReturnNotReady; + } + auto& ctx = *ivars->context; + if (!ctx.deps.hardware) { + ASFW_LOG(Controller, "[Isoch] ❌ StartDVCapture: hardware not ready"); + return kIOReturnNotReady; + } + return ctx.isoch.StartDVCapture(channel, *ctx.deps.hardware); +} + +kern_return_t ASFWDriver::StopDVCapture() { + if (!ivars || !ivars->context) { + return kIOReturnNotReady; + } + return ivars->context->isoch.StopDVCapture(); +} + +kern_return_t ASFWDriver::CopyDVCaptureMemory(uint64_t* options, IOMemoryDescriptor** memory) const { + if (!ivars || !ivars->context) { + return kIOReturnNotReady; + } + return ivars->context->isoch.CopyDVCaptureMemory(options, memory); +} + // ============================================================================= // MARK: - Isochronous Transmit // ============================================================================= diff --git a/ASFWDriver/ASFWDriver.iig b/ASFWDriver/ASFWDriver.iig index 091c617c..af94954b 100644 --- a/ASFWDriver/ASFWDriver.iig +++ b/ASFWDriver/ASFWDriver.iig @@ -73,6 +73,12 @@ public: kern_return_t StartIsochReceive(uint8_t channel, uint32_t wireFormatRaw, uint32_t am824Slots) LOCALONLY; kern_return_t StopIsochReceive() LOCALONLY; void* GetIsochReceiveContext() const LOCALONLY; + + // DV capture (raw DIF stream to shared ring, no audio nub required) + kern_return_t StartDVCapture(uint8_t channel) LOCALONLY; + kern_return_t StopDVCapture() LOCALONLY; + kern_return_t CopyDVCaptureMemory(uint64_t* options, + IOMemoryDescriptor** memory) const LOCALONLY; // Isochronous Transmit Control kern_return_t StartIsochTransmit(uint8_t channel) LOCALONLY; diff --git a/ASFWDriver/Isoch/IsochService.cpp b/ASFWDriver/Isoch/IsochService.cpp index 18343b92..820a1ebf 100644 --- a/ASFWDriver/Isoch/IsochService.cpp +++ b/ASFWDriver/Isoch/IsochService.cpp @@ -18,7 +18,8 @@ kern_return_t IsochService::StartReceive(uint8_t channel, HardwareInterface& hardware, ASFW::Audio::Runtime::IDirectAudioBindingSource* bindingSource, ASFW::Encoding::AudioWireFormat wireFormat, - uint32_t am824Slots) { + uint32_t am824Slots, + ASFW::Isoch::IsochReceiveCallback packetCallback) { if (!isochReceiveContext_) { ASFW::Isoch::Memory::IsochMemoryConfig config; config.numDescriptors = ASFW::Isoch::IsochReceiveContext::kNumDescriptors; @@ -55,6 +56,10 @@ kern_return_t IsochService::StartReceive(uint8_t channel, return kr; } + // Install (or clear) the per-packet callback before Start so Poll never + // races a std::function assignment. + isochReceiveContext_->SetCallback(std::move(packetCallback)); + ASFW_LOG(Isoch, "IsochService: Starting IR on channel %u (Direct-Only)", channel); const kern_return_t startKr = isochReceiveContext_->Start(); return startKr; @@ -64,7 +69,107 @@ kern_return_t IsochService::StopReceive() { if (isochReceiveContext_) { isochReceiveContext_->Stop(); isochReceiveContext_->SetDirectAudioBindingSource(nullptr); + // Safe after Stop(): Poll no longer runs, so no callback is in flight. + isochReceiveContext_->SetCallback(nullptr); + } + + if (dvCaptureActive_) { + dvSink_.Detach(); + dvRing_.Reset(); + dvCaptureActive_ = false; + ASFW_LOG(Isoch, "IsochService: DV capture stopped"); + } + + return kIOReturnSuccess; +} + +// ============================================================================ +// DV capture (minimal IEC 61883-2 tap; see Receive/DVCaptureSink.hpp) +// ============================================================================ + +kern_return_t IsochService::StartDVCapture(uint8_t channel, HardwareInterface& hardware) { + if (dvCaptureActive_) { + ASFW_LOG(Isoch, "IsochService: DV capture already active; StartDVCapture is idempotent"); + return kIOReturnSuccess; + } + + if (isochReceiveContext_ && + isochReceiveContext_->GetState() == ASFW::Isoch::IRPolicy::State::Running) { + ASFW_LOG(Isoch, "IsochService: StartDVCapture blocked: IR context busy (audio receive running)"); + return kIOReturnBusy; + } + + // ~3.9MB ring = 8192 DIF chunks ≈ 1.1s of DV at 3.6MB/s. + constexpr uint32_t kDVRingRecords = 8192; + const uint64_t ringBytes = ASFW::Isoch::Rx::DVCaptureSink::RequiredBytes(kDVRingRecords); + + IOBufferMemoryDescriptor* memRaw = nullptr; + kern_return_t kr = IOBufferMemoryDescriptor::Create(kIOMemoryDirectionOutIn, + ringBytes, 64, &memRaw); + if (kr != kIOReturnSuccess || !memRaw) { + ASFW_LOG(Isoch, "IsochService: StartDVCapture ring allocation failed: 0x%x", kr); + return kr ? kr : kIOReturnNoMemory; + } + kr = memRaw->SetLength(ringBytes); + if (kr != kIOReturnSuccess) { + memRaw->release(); + return kr; + } + + dvRing_.Reset(); + dvRing_.memory = Common::AdoptRetained(memRaw); + dvRing_.bytes = ringBytes; + + kr = Common::CreateSharedMapping(dvRing_.memory, dvRing_.map); + if (kr != kIOReturnSuccess) { + dvRing_.Reset(); + return kr; + } + + if (!dvSink_.InitializeAndAttach(dvRing_.BaseAddress(), ringBytes, kDVRingRecords)) { + ASFW_LOG(Isoch, "IsochService: StartDVCapture sink init failed"); + dvRing_.Reset(); + return kIOReturnInternalError; + } + + auto* sink = &dvSink_; + kr = StartReceive(channel, hardware, /*bindingSource=*/nullptr, + ASFW::Encoding::AudioWireFormat::kAM824, /*am824Slots=*/0, + [sink](std::span data, uint32_t status, + uint64_t /*timestamp*/) { + sink->OnPacket(data.data(), data.size(), status); + }); + if (kr != kIOReturnSuccess) { + dvSink_.Detach(); + dvRing_.Reset(); + return kr; + } + + dvCaptureActive_ = true; + ASFW_LOG(Isoch, "IsochService: DV capture started on channel %u (ring=%llu bytes)", + channel, ringBytes); + return kIOReturnSuccess; +} + +kern_return_t IsochService::StopDVCapture() { + if (!dvCaptureActive_) { + return kIOReturnSuccess; + } + return StopReceive(); +} + +kern_return_t IsochService::CopyDVCaptureMemory(uint64_t* options, IOMemoryDescriptor** memory) const { + if (!memory) { + return kIOReturnBadArgument; + } + if (!dvCaptureActive_ || !dvRing_.memory) { + return kIOReturnNotReady; + } + if (options) { + *options = 0; } + dvRing_.memory->retain(); + *memory = dvRing_.memory.get(); return kIOReturnSuccess; } diff --git a/ASFWDriver/Isoch/IsochService.hpp b/ASFWDriver/Isoch/IsochService.hpp index 321ef534..a3457cca 100644 --- a/ASFWDriver/Isoch/IsochService.hpp +++ b/ASFWDriver/Isoch/IsochService.hpp @@ -13,6 +13,7 @@ #endif #include "IsochReceiveContext.hpp" +#include "Receive/DVCaptureSink.hpp" #include "Transmit/IsochTransmitContext.hpp" #include "../Common/DriverKitOwnership.hpp" @@ -41,10 +42,18 @@ class IsochService { HardwareInterface& hardware, ASFW::Audio::Runtime::IDirectAudioBindingSource* bindingSource, ASFW::Encoding::AudioWireFormat wireFormat = ASFW::Encoding::AudioWireFormat::kAM824, - uint32_t am824Slots = 0); + uint32_t am824Slots = 0, + ASFW::Isoch::IsochReceiveCallback packetCallback = nullptr); kern_return_t StopReceive(); + // Minimal DV (IEC 61883-2) capture tap: starts IR on the given channel with + // no audio binding and streams raw DIF chunks into a shared ring the app + // maps via CopyClientMemoryForType(type=1). + kern_return_t StartDVCapture(uint8_t channel, HardwareInterface& hardware); + kern_return_t StopDVCapture(); + kern_return_t CopyDVCaptureMemory(uint64_t* options, IOMemoryDescriptor** memory) const; + kern_return_t StartTransmit(uint8_t channel, HardwareInterface& hardware, uint8_t sid); @@ -107,6 +116,27 @@ class IsochService { OSSharedPtr isochReceiveContext_; std::unique_ptr isochTransmitContext_; + // DV capture shared ring (see Receive/DVCaptureSink.hpp) + struct DVRingMapping { + OSSharedPtr memory{}; + OSSharedPtr map{}; + uint64_t bytes{0}; + + void Reset() noexcept { + map.reset(); + memory.reset(); + bytes = 0; + } + + [[nodiscard]] void* BaseAddress() const noexcept { + return map ? reinterpret_cast(static_cast(map->GetAddress())) : nullptr; + } + }; + + DVRingMapping dvRing_{}; + ASFW::Isoch::Rx::DVCaptureSink dvSink_{}; + bool dvCaptureActive_{false}; + OSSharedPtr txPayloadSlab_{nullptr}; OSSharedPtr txMetadataRing_{nullptr}; OSSharedPtr txControlBlock_{nullptr}; diff --git a/ASFWDriver/Isoch/Receive/DVCaptureSink.hpp b/ASFWDriver/Isoch/Receive/DVCaptureSink.hpp new file mode 100644 index 00000000..b1ae7f3d --- /dev/null +++ b/ASFWDriver/Isoch/Receive/DVCaptureSink.hpp @@ -0,0 +1,176 @@ +// DVCaptureSink.hpp +// ASFW - Minimal DV (IEC 61883-2) capture tap for the IR context. +// +// Filters isoch packets for CIP FMT=0x00 (DVCR), strips the 8-byte driver +// prefix, 8-byte CIP header and 4-byte source packet header, and writes raw +// 480-byte DIF chunks into a shared-memory SPSC ring consumed by the ASFW app +// (which concatenates them into a .dv file). +// +// Producer: IsochReceiveContext::Poll() packet callback (driver process) +// Consumer: ASFW app via CopyClientMemoryForType(type=1) + IOConnectMapMemory64 +// +// NOTE: This is a pragmatic capture path, not a full DV pipeline. There is no +// frame reassembly here; the app gates output on the first DIF frame-header +// chunk (0x1F 0x07 0x00) and appends chunks in arrival order. Packet loss +// shows up as a visual glitch in the captured frame, which DV players tolerate. + +#pragma once + +#include +#include +#include +#include + +#include "../../Audio/Wire/CIP/CIPHeader.hpp" + +namespace ASFW::Isoch::Rx { + +// Shared-memory layout. Offsets are fixed and mirrored in the Swift app +// (DriverConnector+DVCapture.swift) - do not reorder fields. +struct DVRingHeader { + uint32_t magic; // +0 'ASDV' + uint16_t version; // +4 + uint16_t reserved0; // +6 + uint32_t numRecords; // +8 capacity in 480-byte records + uint32_t recordBytes; // +12 always 480 + uint32_t dataOffsetBytes; // +16 offset of record 0 from base + uint32_t reserved1; // +20 + + // Diagnostics (single writer: driver Poll thread; app reads racily, fine) + uint32_t packetsSeen; // +24 all isoch packets observed + uint32_t dvSourcePackets; // +28 DV source packets extracted + uint32_t nonDvPackets; // +32 packets rejected (bad CIP / wrong FMT) + uint32_t overruns; // +36 records dropped because ring was full + + // Last rejected packet snapshot (raw bytes as read from the buffer, so the + // app can show what the wire actually looks like when the filter misses). + uint32_t lastRejectLen; // +40 + uint32_t lastRejectQ0; // +44 raw quadlet at payload+8 (no byteswap) + uint32_t lastRejectQ1; // +48 raw quadlet at payload+12 (no byteswap) + uint32_t lastXferStatus; // +52 descriptor xferStatus (event code in bits 4:0) + + uint8_t pad0[8]; // +56..63 + + // Free-running SPSC indices in records (not bytes). + std::atomic writeIndex; // +64 driver-owned + uint8_t pad1[60]; + std::atomic readIndex; // +128 app-owned + uint8_t pad2[60]; // ..191 +}; + +static_assert(sizeof(DVRingHeader) == 192, "DVRingHeader layout is ABI with the app"); +static_assert(offsetof(DVRingHeader, writeIndex) == 64, "writeIndex offset is ABI"); +static_assert(offsetof(DVRingHeader, readIndex) == 128, "readIndex offset is ABI"); + +class DVCaptureSink { +public: + static constexpr uint32_t kMagic = 0x41534456; // 'ASDV' + static constexpr uint16_t kVersion = 1; + static constexpr uint32_t kRecordBytes = 480; // one DV source packet (6 DIF blocks) + static constexpr size_t kDriverPrefixBytes = 8; // timestamp + isoch header quadlets + static constexpr size_t kCipHeaderBytes = 8; + static constexpr size_t kSphBytes = 4; + static constexpr size_t kWireBlockBytes = kSphBytes + kRecordBytes; // 484 + + [[nodiscard]] static uint64_t RequiredBytes(uint32_t numRecords) noexcept { + return sizeof(DVRingHeader) + uint64_t(numRecords) * kRecordBytes; + } + + // Creator-side: initialize the ring header in shared memory and attach. + [[nodiscard]] bool InitializeAndAttach(void* base, uint64_t bytes, uint32_t numRecords) noexcept { + Detach(); + if (!base || numRecords == 0 || bytes < RequiredBytes(numRecords)) { + return false; + } + + std::memset(base, 0, size_t(RequiredBytes(numRecords))); + + auto* hdr = reinterpret_cast(base); + hdr->magic = kMagic; + hdr->version = kVersion; + hdr->numRecords = numRecords; + hdr->recordBytes = kRecordBytes; + hdr->dataOffsetBytes = sizeof(DVRingHeader); + hdr->writeIndex.store(0, std::memory_order_relaxed); + hdr->readIndex.store(0, std::memory_order_relaxed); + std::atomic_thread_fence(std::memory_order_release); + + hdr_ = hdr; + data_ = reinterpret_cast(base) + hdr->dataOffsetBytes; + numRecords_ = numRecords; + return true; + } + + void Detach() noexcept { + hdr_ = nullptr; + data_ = nullptr; + numRecords_ = 0; + } + + [[nodiscard]] bool IsAttached() const noexcept { return hdr_ != nullptr; } + + // Called per completed isoch packet. payload includes the 8-byte driver + // prefix ([0-3] timestamp quadlet, [4-7] isoch header) ahead of the CIP. + void OnPacket(const uint8_t* payload, size_t length, uint32_t xferStatus) noexcept { + if (!hdr_ || !payload) { + return; + } + hdr_->packetsSeen++; + hdr_->lastXferStatus = xferStatus; + + if (length < kDriverPrefixBytes + kCipHeaderBytes) { + hdr_->nonDvPackets++; + hdr_->lastRejectLen = static_cast(length); + return; // runt / no CIP - not even an empty packet + } + + uint32_t q0 = 0; + uint32_t q1 = 0; + std::memcpy(&q0, payload + kDriverPrefixBytes, sizeof(q0)); + std::memcpy(&q1, payload + kDriverPrefixBytes + 4, sizeof(q1)); + + const auto cip = CIPHeader::Decode(q0, q1); + if (!cip || cip->format != 0x00) { + hdr_->nonDvPackets++; + hdr_->lastRejectLen = static_cast(length); + hdr_->lastRejectQ0 = q0; + hdr_->lastRejectQ1 = q1; + return; + } + + // Consumer DV camcorders (Linux dv1394-compatible) send SPH=0: the + // payload after the CIP is plain 480-byte DIF blocks, timestamp lives + // in CIP.SYT. Devices that set SPH=1 prefix each block with a 4-byte + // source packet header instead. + const size_t skip = cip->sourcePacketHeader ? kSphBytes : 0; + const size_t blockBytes = kRecordBytes + skip; + + const size_t dataBytes = length - kDriverPrefixBytes - kCipHeaderBytes; + const size_t blocks = dataBytes / blockBytes; // 0 for empty (CIP-only) packets + + for (size_t i = 0; i < blocks; ++i) { + const uint8_t* dif = payload + kDriverPrefixBytes + kCipHeaderBytes + + (i * blockBytes) + skip; + WriteRecord(dif); + } + hdr_->dvSourcePackets += static_cast(blocks); + } + +private: + void WriteRecord(const uint8_t* src) noexcept { + const uint32_t w = hdr_->writeIndex.load(std::memory_order_relaxed); + const uint32_t r = hdr_->readIndex.load(std::memory_order_acquire); + if (uint32_t(w - r) >= numRecords_) { + hdr_->overruns++; + return; + } + std::memcpy(data_ + size_t(w % numRecords_) * kRecordBytes, src, kRecordBytes); + hdr_->writeIndex.store(w + 1, std::memory_order_release); + } + + DVRingHeader* hdr_{nullptr}; + uint8_t* data_{nullptr}; + uint32_t numRecords_{0}; +}; + +} // namespace ASFW::Isoch::Rx diff --git a/ASFWDriver/UserClient/Core/ASFWDriverUserClient.cpp b/ASFWDriver/UserClient/Core/ASFWDriverUserClient.cpp index 2f9458f6..533b17bb 100644 --- a/ASFWDriver/UserClient/Core/ASFWDriverUserClient.cpp +++ b/ASFWDriver/UserClient/Core/ASFWDriverUserClient.cpp @@ -81,6 +81,10 @@ enum { // Isoch Transmit Control (IT DMA allocation only - no CMP) kMethodStartIsochTransmit = 36, kMethodStopIsochTransmit = 37, + + // DV capture (raw DIF stream via shared ring, memory type 1) + kMethodStartDVCapture = 50, + kMethodStopDVCapture = 51, }; namespace { @@ -349,6 +353,10 @@ MethodDispatchResult DispatchIsochMethods(ASFW::UserClient::UserClientRuntimeSta return runtimeState.Isoch().StartIsochTransmit(arguments); case kMethodStopIsochTransmit: return runtimeState.Isoch().StopIsochTransmit(arguments); + case kMethodStartDVCapture: + return runtimeState.Isoch().StartDVCapture(arguments); + case kMethodStopDVCapture: + return runtimeState.Isoch().StopDVCapture(arguments); default: return std::nullopt; } @@ -712,12 +720,14 @@ kern_return_t IMPL(ASFWDriverUserClient, CopyClientMemoryForType) { return kIOReturnNotReady; } - // Only support kSharedStatusMemoryType = 0 - if (type != 0) { - return kIOReturnUnsupported; + // Type 0: shared status memory. Type 1: DV capture ring. + if (type == 0) { + return ivars->driver->CopySharedStatusMemory(options, memory); } - - return ivars->driver->CopySharedStatusMemory(options, memory); + if (type == 1) { + return ivars->driver->CopyDVCaptureMemory(options, memory); + } + return kIOReturnUnsupported; } // Note: GetDiscoveredDevices is handled in ExternalMethod (selector 16), no stub needed diff --git a/ASFWDriver/UserClient/Core/ASFWDriverUserClient.iig b/ASFWDriver/UserClient/Core/ASFWDriverUserClient.iig index 4c24a9e3..fd8c2d4b 100644 --- a/ASFWDriver/UserClient/Core/ASFWDriverUserClient.iig +++ b/ASFWDriver/UserClient/Core/ASFWDriverUserClient.iig @@ -71,6 +71,10 @@ public: // Isocho Stream Control kMethodStartIsochReceive = 32, kMethodStopIsochReceive = 33, + + // DV capture (raw DIF stream via shared ring, memory type 1) + kMethodStartDVCapture = 50, + kMethodStopDVCapture = 51, }; kern_return_t diff --git a/ASFWDriver/UserClient/Handlers/IsochHandler.cpp b/ASFWDriver/UserClient/Handlers/IsochHandler.cpp index 48649469..89b66e58 100644 --- a/ASFWDriver/UserClient/Handlers/IsochHandler.cpp +++ b/ASFWDriver/UserClient/Handlers/IsochHandler.cpp @@ -297,6 +297,27 @@ kern_return_t IsochHandler::StopIsochReceive(IOUserClientMethodArguments* args) return driver_->StopIsochReceive(); } +// ============================================================================ +// DV Capture Control +// ============================================================================ + +kern_return_t IsochHandler::StartDVCapture(IOUserClientMethodArguments* args) { + // Arguments: [0] = channel (DV camcorders broadcast on 63 by default) + if (args->scalarInputCount < 1) + return kIOReturnBadArgument; + const uint64_t channel = args->scalarInput[0]; + if (channel > 63) + return kIOReturnBadArgument; + + ASFW_LOG(UserClient, "StartDVCapture called for channel %llu", channel); + return driver_->StartDVCapture(static_cast(channel)); +} + +kern_return_t IsochHandler::StopDVCapture(IOUserClientMethodArguments* args) { + ASFW_LOG(UserClient, "StopDVCapture called"); + return driver_->StopDVCapture(); +} + // ============================================================================ // Isoch Metrics // ============================================================================ diff --git a/ASFWDriver/UserClient/Handlers/IsochHandler.hpp b/ASFWDriver/UserClient/Handlers/IsochHandler.hpp index 2dd19667..f9b109a9 100644 --- a/ASFWDriver/UserClient/Handlers/IsochHandler.hpp +++ b/ASFWDriver/UserClient/Handlers/IsochHandler.hpp @@ -33,6 +33,10 @@ class IsochHandler { // Isoch Streaming Control kern_return_t StartIsochReceive(IOUserClientMethodArguments* args); kern_return_t StopIsochReceive(IOUserClientMethodArguments* args); + + // DV Capture Control + kern_return_t StartDVCapture(IOUserClientMethodArguments* args); + kern_return_t StopDVCapture(IOUserClientMethodArguments* args); // Isoch Metrics kern_return_t GetIsochRxMetrics(IOUserClientMethodArguments* args); From 63a4a02debd6fb3a8f97de43b20a311d9d787166 Mon Sep 17 00:00:00 2001 From: Brian Hoffman Date: Fri, 12 Jun 2026 12:19:00 -0400 Subject: [PATCH 3/3] refactor(dv): harden DV parsing against Apple AVCVideoServices reference Cross-checked the capture path against Apple's AVCVideoServices-42 DVReceiver/DVFramer (FireWire SDK 26 reference implementation): - Frame-start detection now uses Apple's masked comparison ((first two DIF bytes & 0xE0FC) == 0x0004, i.e. SCT=header and Dseq=0) instead of an exact 0x1F 0x07 0x00 match; the unmasked bits are reserved/arbitrary and vary between devices. - The sink rejects packets whose CIP.DBS is not 120 quadlets (SD-DVCR) rather than mis-slicing other DV variants (e.g. DVCPRO50) into 480-byte records, and validates the payload is a whole number of blocks (Apple checks expected packet size the same way). Apple's receiver also confirms the SPH=0 consumer-DV payload layout (plain DBS-sized DIF blocks after the CIP, no per-block source packet header) that the previous commit implemented empirically. Co-authored-by: Cursor --- ASFW/Views/DVCaptureView.swift | 8 ++++-- ASFWDriver/Isoch/Receive/DVCaptureSink.hpp | 33 ++++++++++++++++++---- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/ASFW/Views/DVCaptureView.swift b/ASFW/Views/DVCaptureView.swift index 6c1b7ed3..73a0f5b5 100644 --- a/ASFW/Views/DVCaptureView.swift +++ b/ASFW/Views/DVCaptureView.swift @@ -100,9 +100,11 @@ final class DVCaptureController: ObservableObject { guard let ring else { return } ring.drain { chunk in - // Frame header chunk: first DIF block is the header section - // (bytes 0x1F 0x07 0x00). - let isFrameStart = chunk[0] == 0x1F && chunk[1] == 0x07 && chunk[2] == 0x00 + // Frame start: first DIF block is a header-section block (SCT=0) + // with sequence number 0. Masked comparison per Apple's + // AVCVideoServices DVReceiver ((u16 & 0xE0FC) == 0x0004) - the + // unmasked bits are reserved/arbitrary and vary between devices. + let isFrameStart = (chunk[0] & 0xE0) == 0x00 && (chunk[1] & 0xFC) == 0x04 if isFrameStart { flushCurrentFrame() inFrame = true diff --git a/ASFWDriver/Isoch/Receive/DVCaptureSink.hpp b/ASFWDriver/Isoch/Receive/DVCaptureSink.hpp index b1ae7f3d..3ea19ded 100644 --- a/ASFWDriver/Isoch/Receive/DVCaptureSink.hpp +++ b/ASFWDriver/Isoch/Receive/DVCaptureSink.hpp @@ -138,14 +138,37 @@ class DVCaptureSink { return; } - // Consumer DV camcorders (Linux dv1394-compatible) send SPH=0: the - // payload after the CIP is plain 480-byte DIF blocks, timestamp lives - // in CIP.SYT. Devices that set SPH=1 prefix each block with a 4-byte - // source packet header instead. + const size_t dataBytes = length - kDriverPrefixBytes - kCipHeaderBytes; + + // SD-DVCR carries DBS=120 quadlets (480 bytes per block); the ring + // records are fixed at that size. Reject other DV variants (e.g. + // DVCPRO50) instead of mis-slicing them. Empty (CIP-only) packets + // pass through with zero blocks regardless. + if (dataBytes > 0 && cip->dataBlockSize != kRecordBytes / 4) { + hdr_->nonDvPackets++; + hdr_->lastRejectLen = static_cast(length); + hdr_->lastRejectQ0 = q0; + hdr_->lastRejectQ1 = q1; + return; + } + + // Consumer DV camcorders (Linux dv1394-compatible, matching Apple's + // AVCVideoServices DVReceiver) send SPH=0: the payload after the CIP + // is plain 480-byte DIF blocks, timestamp lives in CIP.SYT. Devices + // that set SPH=1 prefix each block with a 4-byte source packet header. const size_t skip = cip->sourcePacketHeader ? kSphBytes : 0; const size_t blockBytes = kRecordBytes + skip; - const size_t dataBytes = length - kDriverPrefixBytes - kCipHeaderBytes; + // Sanity: payload must be a whole number of blocks (Apple validates + // expected packet size the same way; truncated DMA = corrupt packet). + if (dataBytes % blockBytes != 0) { + hdr_->nonDvPackets++; + hdr_->lastRejectLen = static_cast(length); + hdr_->lastRejectQ0 = q0; + hdr_->lastRejectQ1 = q1; + return; + } + const size_t blocks = dataBytes / blockBytes; // 0 for empty (CIP-only) packets for (size_t i = 0; i < blocks; ++i) {