17 September 2011

Reading CD-Text with VB.Net (2)

I mentioned, in the first part, that there are two control codes that you can send to get the CD-Text bytes back. We’ve used IOCTL_CDROM_READ_TOC_EX and now it’s the turn of the slightly trickier IOCTL_SCSI_PASS_THROUGH_DIRECT. This one lets us send any message we like to the device. To learn about the messages that you can send, you need to get your hands on a copy of the standard specification that defines them all. Hardware manufacturers are supposed to conform to the standard so that all the devices understand the same commands.

The standards of interest are the ones for scsi devices. You have to pay for access to the latest standards, and it looks like access to the ftp for draft standards is now restricted. The old standards are available on the internet in various places though. I still have my copies of mmc3r10g.pdf and spc2r20.pdf. It has some information that will come in handy later about the format of the bytes we have been retrieving. You can find yet more information on this format by looking at open source projects such as cdrdao, the original patent application by Sony and various other projects. The real standard is the Red Book (IEC 60908), but that costs £200 to access.

Anyway, we have to call DeviceIoControl with this other command code. We send it a structure that will contain a cdb (command data block) along with a buffer to contain the bytes that are coming back, and another buffer for so called sense-information. Sense information is helpful as it contains error information – and there are many things that can go wrong – tray out, no cd, DVD inserted, drive not ready, etc. This is probably the only reason to use this message over the other one – you can get back the sense information. It’s also useful to know how to send any command as there are a lot of them in the specification, and they don’t all have an equivalent “friendly” IOCTL code.

The command we are sending is the READ TOC command, the cdb is just 10 bytes in size. It contains one byte for the command code (&H43). Then a byte we can ignore. Then there’s a byte that holds the format. This is the same thing as the format we send with IOCTL_READ_TOC_EX – we can specify different values to get different TOC information. Again, we want to set this to 5 to retrieve CD-Text (see the spec). After that there are reserved, and inapplicable bytes (given the format). At the end there are two bytes to say how big our buffer is, and finally a control byte.

The cdb and a pointer to the databuffer go into a SCSI_PASS_THROUGH_DIRECT structure. This structure is then placed inside another structure that also holds the sense buffer. SCSI_PASS_THROUGH_DIRECT looks like this:

<StructLayout(LayoutKind.Sequential)>
Friend Structure SCSI_PASS_THROUGH_DIRECT                           ' x86   x64
    Public Length As Short                                          '  0      0
    Public ScsiStatus As Byte                                       '  2      2
    Public PathId As Byte                                           '  3      3
    Public TargetId As Byte                                         '  4      4
    Public Lun As Byte                                              '  5      5
    Public CdbLength As Byte                                        '  6      6
    Public SenseInfoLength As Byte                                  '  7      7
    Public DataIn As CommandDirection                               '  8      8
    Public DataTransferLength As Integer                            ' 12     12 
    Public TimeOutValue As Integer                                  ' 16     16
    Public DataBuffer As IntPtr                                     ' 20     24
    Public SenseInfoOffset As Integer                               ' 24     32
    <MarshalAs(UnmanagedType.ByValArray, SizeConst:=16)>
    Public Cdb() As Byte                                            ' 28     36
    '                                                        Size     44     56
    Public Shared Function GetSize() As Integer
        Return Marshal.SizeOf(GetType(SCSI_PASS_THROUGH_DIRECT))
    End Function
 
End Structure

I’ve got the offsets/sizes that I calculated in C in the comments. You can see that the IntPtr field is larger on x64. Here the cdb array is marshalled as a ByValArray which tells the marshaller to stick the bytes directly into the memory of the structure. The alternative is for the bytes to live elsewhere and for the field in the structure to be a pointer to them. We have to fill in this structure, which means we have to know what the PathId, TargetId and Lun of the CD drive are. We can get those with IOCTL_SCSI_GET_ADDRESS. The SenseInfoLength is 32. The SenseInfoOffset can be calculated at runtime using Marshal.OffsetOf to get the offset of the sense buffer in the outer structure (see attached project) – it will vary on x86 and x64.

As before, we’ll call once with a data buffer of size 2. On return, our data buffer will contain the size of the data (excluding the field that holds the size itself). So we can then allocate precisely as much memory as is required to hold the whole CD-Text.

Option Strict On
Option Explicit On
 
Imports CdTextIoCtl.NativeMethods
Imports Microsoft.Win32.SafeHandles
Imports System.ComponentModel
Imports System.IO
Imports System.Runtime.InteropServices
Imports System.Text
 
Public Class CdTextRetriever
 
    Public Shared Function GetCdText(driveInfo As DriveInfo) As Object
        Dim cdText As Object = Nothing ' don't know what the return will look like yet.
        Dim devicePath As String = String.Format("\\.\{0}:", driveInfo.Name(0))
        Const FILE_ATTRIBUTE_NORMAL As UInteger = &H80
        Const GENERIC_READ As UInteger = &H80000000UI
        Const GENERIC_WRITE As UInteger = &H40000000
        Const FILE_SHARE_READ As UInteger = 1
        Const FILE_SHARE_WRITE As UInteger = 2
        Const OPEN_EXISTING As UInteger = 3
        Using hDevice As SafeFileHandle = NativeMethods.CreateFile(devicePath,
                GENERIC_READ Or GENERIC_WRITE,
                FILE_SHARE_READ Or FILE_SHARE_WRITE,
                IntPtr.Zero, OPEN_EXISTING,
                FILE_ATTRIBUTE_NORMAL, IntPtr.Zero)
            If hDevice.IsInvalid OrElse hDevice.IsClosed Then Throw New Win32Exception
            ReadCdText(hDevice)
        End Using
        Return cdText
    End Function
 
 
    Private Shared Sub ReadCdText(hDevice As SafeFileHandle)
        Dim address As SCSI_ADDRESS = GetScsiAddress(hDevice)
        ' like in the other program...get the length
        Dim bytes(1) As Byte
        GetCdTextBytes(hDevice, address, bytes)
        ' again we add 2 for the length parameter itself so it's length - 1 + 2 to size the array
        bytes = New Byte((CType(bytes(0), Integer) << 8) Or bytes(1) + 1) {}
        GetCdTextBytes(hDevice, address, bytes)
        Dim sb As New StringBuilder
        For i As Integer = 4 To bytes.Length - 19 Step 18
            For j As Integer = 0 To 17
                sb.Append(bytes(i + j).ToString("X2"))
                If i = 3 OrElse i = 16 Then sb.Append("  ")
            Next
            sb.AppendLine()
        Next
        My.Computer.FileSystem.WriteAllText(IO.Path.Combine(My.Computer.FileSystem.SpecialDirectories.MyDocuments, IO.Path.GetRandomFileName), sb.ToString, False)
    End Sub
 
    Private Shared Function GetScsiAddress(hDevice As SafeFileHandle) As SCSI_ADDRESS
        Dim address As New SCSI_ADDRESS
        Dim bytesReturned As Integer
        Const IOCTL_SCSI_GET_ADDRESS As Integer = &H41018
        Dim result As Boolean = NativeMethods.DeviceIoControl(hDevice,
                IOCTL_SCSI_GET_ADDRESS, IntPtr.Zero, 0, address,
                SCSI_ADDRESS.GetSize, bytesReturned, IntPtr.Zero)
        If result = False Then Throw New Win32Exception
    End Function
 
    Private Shared Sub GetCdTextBytes(hDevice As SafeFileHandle, address As SCSI_ADDRESS, bytes() As Byte)
        Dim bytesReturned As Integer
        Dim sptdwb As New SCSI_PASS_THROUGH_DIRECT_WITH_BUFFER
        Dim pinnedBytesHandle As GCHandle = GCHandle.Alloc(bytes, GCHandleType.Pinned)
        sptdwb.ScsiPassThroughDirect.cdb = New Byte(15) {} ' The cdb as described in the standard
        sptdwb.SenseBuffer = New Byte(31) {}
        sptdwb.ScsiPassThroughDirect.Length = CShort(SCSI_PASS_THROUGH_DIRECT.GetSize)
        sptdwb.ScsiPassThroughDirect.CdbLength = 10
        sptdwb.ScsiPassThroughDirect.DataIn = CommandDirection.SCSI_IOCTL_DATA_IN
        sptdwb.ScsiPassThroughDirect.DataTransferLength = bytes.Length
        sptdwb.ScsiPassThroughDirect.DataBuffer = pinnedBytesHandle.AddrOfPinnedObject
        sptdwb.ScsiPassThroughDirect.TimeOutValue = 10
        sptdwb.ScsiPassThroughDirect.SenseInfoOffset = Marshal.OffsetOf(GetType(SCSI_PASS_THROUGH_DIRECT_WITH_BUFFER), "SenseBuffer").ToInt32
        sptdwb.ScsiPassThroughDirect.SenseInfoLength = 24
 
        sptdwb.ScsiPassThroughDirect.PathId = address.PathId
        sptdwb.ScsiPassThroughDirect.TargetId = address.TargetId
        sptdwb.ScsiPassThroughDirect.Lun = address.Lun
        ' CDB is set with the READ_TOC command
        sptdwb.ScsiPassThroughDirect.cdb(0) = &H43
        sptdwb.ScsiPassThroughDirect.cdb(2) = 5
        sptdwb.ScsiPassThroughDirect.cdb(7) = CByte((bytes.Length >> 8) And &HFF)
        sptdwb.ScsiPassThroughDirect.cdb(8) = CByte(bytes.Length And &HFF)
        Try
            Const IOCTL_SCSI_PASS_THROUGH_DIRECT As Integer = &H4D014
            Dim result As Boolean = NativeMethods.DeviceIoControl(hDevice,
               IOCTL_SCSI_PASS_THROUGH_DIRECT,
               sptdwb,
               Marshal.SizeOf(sptdwb),
               sptdwb,
               Marshal.SizeOf(sptdwb),
               bytesReturned,
               IntPtr.Zero)
            If result = False Then Throw New Win32Exception
        Finally
            pinnedBytesHandle.Free()
        End Try
    End Sub
 
End Class

So, that’s another way to read the same bytes. I’ve told this one to save a file with a dump of the bytes into MyDocuments. It splits the data into lines with 18 bytes on each. It starts at byte 4 to skip the length and two other bytes which appear before the Pack Data. Pack Data contains 12 bytes of information each with a header describing the information and a 2 byte CRC code at the end. Most of the information will be ascii text, although some packs don’t contain text. The header contains a pack type code which has things like Title, Songwriter, etc. It also has values to identify the track number that the data is for. It’s quite a simple format, but tricky to decode without a precise definition of the format! These projects are a bit naff – I’ve not used VS2010 much and it’s messing up my solution with 2 projects in. I can’t seem to get it so that you can switch easily between x86 and AnyCpu builds, so it ends up in mixed mode. You can just start a new empty solution and add the relevant bits if it wont build and run.

Next time I’ll decode the bytes, check the crcs and the sense information. I’ve found that occasionally I get some corrupt data coming in the first time it reads – hopefully the CRC will fail and I can retry.

No comments:

Post a Comment