diff --git a/CommonUtil/AppHook.cs b/CommonUtil/AppHook.cs index 69750fb..c687895 100644 --- a/CommonUtil/AppHook.cs +++ b/CommonUtil/AppHook.cs @@ -91,6 +91,34 @@ public void SetOptionInt(string key, int value) { //} } + public T GetOptionEnum(string name, T defaultValue) { + if (!mOptions.TryGetValue(name, out string? valueStr)) { + return defaultValue; + } + try { + object o = Enum.Parse(typeof(T), valueStr); + return (T)o; + } catch (ArgumentException ae) { + Debug.WriteLine("Failed to parse '" + valueStr + "' (enum " + typeof(T) + "): " + + ae.Message); + return defaultValue; + } + } + + public void SetOptionEnum(string name, T value) { + if (value == null) { + throw new NotImplementedException("Can't handle a null-valued enum type"); + } + string? newVal = Enum.GetName(typeof(T), value); + if (newVal == null) { + Debug.WriteLine("Unable to get enum name type=" + typeof(T) + " value=" + value); + return; + } + if (!mOptions.TryGetValue(name, out string? oldValue) || oldValue != newVal) { + mOptions[name] = newVal; + } + } + public void LogD(string msg) { Debug.WriteLine("MsgD: " + msg); diff --git a/CommonUtil/CassetteDecoder.cs b/CommonUtil/CassetteDecoder.cs index e541f4f..5cdf304 100644 --- a/CommonUtil/CassetteDecoder.cs +++ b/CommonUtil/CassetteDecoder.cs @@ -190,17 +190,18 @@ private CassetteDecoder(WAVFile waveFile, Algorithm alg) { /// Decodes a stream of audio samples into data chunks. /// /// Processed RIFF file with WAVE data (.wav). + /// If true, stop when the first good chunk is found. /// List of chunks, in the order in which they appear in the sound file. - public static List DecodeFile(WAVFile wavFile, Algorithm alg) { + public static List DecodeFile(WAVFile wavFile, Algorithm alg, bool firstOnly) { CassetteDecoder decoder = new CassetteDecoder(wavFile, alg); - decoder.Scan(); + decoder.Scan(firstOnly); return decoder.mChunks; } /// /// Scans the contents of the audio file, generating file chunks as it goes. /// - private void Scan() { + private void Scan(bool firstOnly) { Debug.Assert(mWavFile.FormatTag == WAVFile.WAVE_FORMAT_PCM); Debug.WriteLine("Scanning file: " + mWavFile.GetInfoString()); @@ -284,13 +285,20 @@ private void Scan() { chunk = new Chunk(fileData, (byte)readChecksum, checksum, bitAcc != 1, mDataStart, mDataEnd); } - Debug.WriteLine("READ: " + chunk.ToString()); + Debug.WriteLine("Found: " + chunk.ToString()); mChunks.Add(chunk); doInit = true; + if (firstOnly) { + break; + } } } curSampleIndex += count; + + if (firstOnly && mChunks.Count > 0) { + break; + } } switch (mState) { diff --git a/DiskArc/Arc/AudioRecording-notes.md b/DiskArc/Arc/AudioRecording-notes.md index b03214d..bd3c754 100644 --- a/DiskArc/Arc/AudioRecording-notes.md +++ b/DiskArc/Arc/AudioRecording-notes.md @@ -6,7 +6,7 @@ - CiderPress implementation (https://github.com/fadden/ciderpress/blob/master/app/CassetteDialog.cpp) -There is a substantial library of Apple II cassette tapes at +There is a substantial library of Apple II cassette tape recordings at https://brutaldeluxe.fr/projects/cassettes/. ## Background ## diff --git a/DiskArc/Arc/AudioRecording.cs b/DiskArc/Arc/AudioRecording.cs index 2b2a997..588a20b 100644 --- a/DiskArc/Arc/AudioRecording.cs +++ b/DiskArc/Arc/AudioRecording.cs @@ -98,7 +98,14 @@ IEnumerator IEnumerable.GetEnumerator() { public static bool TestKind(Stream stream, AppHook appHook) { stream.Position = 0; WAVFile? wav = WAVFile.Prepare(stream); - return (wav != null); + if (wav == null) { + return false; + } + // Scan for the first good entry. No need to process the entire file. + CassetteDecoder.Algorithm alg = appHook.GetOptionEnum(DAAppHook.AUDIO_REC_ALG, + CassetteDecoder.Algorithm.Zero); + List chunks = CassetteDecoder.DecodeFile(wav, alg, true); + return chunks.Count != 0; } /// @@ -123,12 +130,19 @@ public static AudioRecording OpenArchive(Stream stream, AppHook appHook) { stream.Position = 0; WAVFile? wav = WAVFile.Prepare(stream); if (wav == null) { - throw new NotSupportedException("Not a supported WAV file format"); + throw new NotSupportedException("not a supported WAV file format"); } AudioRecording archive = new AudioRecording(stream, wav, appHook); - List chunks = CassetteDecoder.DecodeFile(wav, + + // Allow the default algorithm to be overridden. + CassetteDecoder.Algorithm alg = appHook.GetOptionEnum(DAAppHook.AUDIO_REC_ALG, CassetteDecoder.Algorithm.Zero); - // TODO - analyze contents + List chunks = CassetteDecoder.DecodeFile(wav, alg, false); + if (chunks.Count == 0) { + // Doesn't appear to be an Apple II recording. + throw new NotSupportedException("no Apple II recordings found"); + } + archive.GenerateRecords(chunks); return archive; } @@ -155,6 +169,171 @@ protected virtual void Dispose(bool disposing) { IsValid = false; } + /// + /// Generates file records from the decoded audio data chunks. Files will be treated + /// as binary blobs unless we identify what looks like a paired entry. + /// + private void GenerateRecords(List chunks) { + // Generate info for all chunks, for debugging. + for (int i = 0; i < chunks.Count; i++) { + CassetteDecoder.Chunk chunk = chunks[i]; + Notes.AddI("#" + i + ": " + chunk.ToString()); + } + + for (int i = 0; i < chunks.Count; i++) { + CassetteDecoder.Chunk chunk = chunks[i]; + byte fileType; + if (i == chunks.Count - 1) { + // Last chunk. + fileType = FileAttribs.FILE_TYPE_NON; + } else if (chunk.Data.Length == 2) { + // Could be followed by Integer BASIC program or Applesoft shape table. + ushort len = RawData.GetU16LE(chunk.Data, 0); + if (CheckINT(len, chunks[i + 1])) { + fileType = FileAttribs.FILE_TYPE_INT; + } else if (CheckShape(len, chunks[i + 1])) { + fileType = FileAttribs.FILE_TYPE_BIN; + } else { + fileType = FileAttribs.FILE_TYPE_NON; + } + } else if (chunk.Data.Length == 3) { + // Could be followed by Applesoft BASIC program or stored array. + ushort len = RawData.GetU16LE(chunk.Data, 0); + byte xtra = chunk.Data[2]; + if (CheckBAS(len, xtra, chunks[i + 1])) { + fileType = FileAttribs.FILE_TYPE_BAS; + } else { + // TODO: find or create examples of VAR tapes, and figure out how to + // determine validity. + fileType = FileAttribs.FILE_TYPE_NON; + } + } else { + fileType = FileAttribs.FILE_TYPE_NON; + } + ushort auxType; + switch (fileType) { + case FileAttribs.FILE_TYPE_BIN: + auxType = 0x1000; + i++; + break; + case FileAttribs.FILE_TYPE_INT: + auxType = 0x0000; + i++; + break; + case FileAttribs.FILE_TYPE_BAS: + auxType = 0x0801; + i++; + break; + case FileAttribs.FILE_TYPE_VAR: + auxType = 0x0000; + i++; + break; + case FileAttribs.FILE_TYPE_NON: + default: + fileType = FileAttribs.FILE_TYPE_BIN; + auxType = 0x1000; + break; + } + AudioRecording_FileEntry newEntry = new AudioRecording_FileEntry(this, + "File" + RecordList.Count.ToString("D2"), fileType, auxType, chunks[i].Data); + if (chunk.CalcChecksum != 0x00 || chunk.BadEnd) { + newEntry.IsDubious = true; + } + RecordList.Add(newEntry); + } + } + + /// + /// Detects whether a chunk holds an Integer BASIC program. + /// + private static bool CheckINT(ushort len, CassetteDecoder.Chunk chunk) { + Debug.WriteLine("CheckINT: decLen=" + len + " chunkLen=" + chunk.Data.Length); + if (chunk.Data.Length < 4) { + // Too short to be BASIC. + return false; + } + + // Check to see if the start looks like the first line of a BASIC program. + byte lineLen = chunk.Data[0]; + int lineNum = RawData.GetU16LE(chunk.Data, 1); + if (lineNum > 32767 || lineLen > chunk.Data.Length || chunk.Data[lineLen - 1] != 0x01) { + // Doesn't look like INT. + return false; + } + return true; + } + + /// + /// Detects whether a chunk holds an Applesoft shape table. + /// This test is fairly weak, and should only be made after the INT test fails. + /// + private static bool CheckShape(ushort len, CassetteDecoder.Chunk chunk) { + // Shape tables are difficult to identify. Do a few simple tests. + if (chunk.Data.Length < 2) { + return false; + } + int shapeCount = chunk.Data[0]; + if (shapeCount == 0 || shapeCount * 2 >= chunk.Data.Length) { + // Empty table, or offset table exceeds length of file. + return false; + } + + // It's not totally wrong, it has a two-byte header, it's not INT, and we're + // going to call it BIN either way. Declare success. + return true; + } + + /// + /// Detects whether a chunk holds an Applesoft BASIC program. + /// + private static bool CheckBAS(ushort len, byte xtra, CassetteDecoder.Chunk chunk) { + // Declared length in header chunk should be one shorter than actual length. + Debug.WriteLine("CheckBAS: decLen=" + len + " chunkLen=" + chunk.Data.Length + + " xtra=$" + xtra.ToString("x2")); + + if (chunk.Data.Length < 4) { + // Too short to be valid or useful. + return false; + } + + // + // We do a quick test where we look at the first two lines of the program, and see + // if they look coherent. + // + // Start with trivial checks on the next-address pointer and line number. + // + ushort nextAddr1 = RawData.GetU16LE(chunk.Data, 0); + ushort lineNum1 = RawData.GetU16LE(chunk.Data, 2); + if (nextAddr1 == 0 || lineNum1 > 63999) { + // Empty or invalid. Assume not BAS. + return false; + } + // Find the end of the line. + int idx; + for (idx = 4; idx < chunk.Data.Length; idx++) { + if (chunk.Data[idx] == 0x00) { + break; + } + } + idx++; + if (idx + 2 == chunk.Data.Length) { + // At end of the file... is this a one-liner? + return false; // TODO - make a test case + } else if (idx + 4 < chunk.Data.Length) { + ushort nextAddr2 = RawData.GetU16LE(chunk.Data, idx); + ushort lineNum2 = RawData.GetU16LE(chunk.Data, idx + 2); + if (lineNum2 <= lineNum1 || lineNum2 > 63999) { + // Invalid line structure. + return false; + } + if (nextAddr2 <= nextAddr1) { + // Pointers moving backward. + return false; + } + } + return true; + } + // IArchive public ArcReadStream OpenPart(IFileEntry ientry, FilePart part) { if (!IsValid) { diff --git a/DiskArc/Arc/AudioRecording_FileEntry.cs b/DiskArc/Arc/AudioRecording_FileEntry.cs index 3ac7f9a..2cc274a 100644 --- a/DiskArc/Arc/AudioRecording_FileEntry.cs +++ b/DiskArc/Arc/AudioRecording_FileEntry.cs @@ -15,6 +15,7 @@ */ using System; using System.Collections; +using System.Collections.Generic; using System.Diagnostics; using System.Text; @@ -32,8 +33,8 @@ internal class AudioRecording_FileEntry : IFileEntry { // public bool IsValid { get { return Archive != null; } } - public bool IsDubious { get; private set; } - public bool IsDamaged { get; private set; } + public bool IsDubious { get; internal set; } + public bool IsDamaged => false; public bool IsDirectory => false; @@ -63,11 +64,11 @@ public byte[] RawFileName { public bool HasProDOSTypes => true; public byte FileType { - get => 0x00; // TODO + get => mFileType; set => throw new InvalidOperationException("Cannot modify object without transaction"); } public ushort AuxType { - get => 0x1234; // TODO + get => mAuxType; set => throw new InvalidOperationException("Cannot modify object without transaction"); } @@ -82,9 +83,9 @@ public byte Access { public DateTime CreateWhen { get => TimeStamp.NO_DATE; set { } } public DateTime ModWhen { get => TimeStamp.NO_DATE; set { } } - public long StorageSize => 1234; // TODO + public long StorageSize => mData.Length; // could show WAV length, not really useful - public long DataLength => 1234; // TODO + public long DataLength => mData.Length; public long RsrcLength => 0; @@ -103,7 +104,10 @@ IEnumerator IEnumerable.GetEnumerator() { // Implementation-specific fields. // - private string mFileName = "TODO"; // TODO + private string mFileName; + private byte mFileType; + private ushort mAuxType; + private byte[] mData; /// /// Reference to archive object. @@ -112,26 +116,47 @@ IEnumerator IEnumerable.GetEnumerator() { /// - /// Private constructor. + /// Internal constructor. /// - private AudioRecording_FileEntry(AudioRecording archive) { + internal AudioRecording_FileEntry(AudioRecording archive, + string fileName, byte fileType, ushort auxType, byte[] data) { Archive = archive; + mFileName = fileName; + mFileType = fileType; + mAuxType = auxType; + mData = data; } // IFileEntry public bool GetPartInfo(FilePart part, out long length, out long storageSize, out CompressionFormat format) { - // TODO - throw new NotImplementedException(); + switch (part) { + case FilePart.DataFork: + length = storageSize = mData.Length; + format = CompressionFormat.Uncompressed; + return true; + case FilePart.RsrcFork: + default: + length = -1; + storageSize = 0; + format = CompressionFormat.Unknown; + return false; + } } /// /// Creates an object for reading the contents of the entry out of the archive. /// internal ArcReadStream CreateReadStream(FilePart part) { - Debug.Assert(Archive.DataStream != null); - // TODO - throw new NotImplementedException(); + switch (part) { + case FilePart.DataFork: + return new ArcReadStream(Archive, mData.Length, null, + new MemoryStream(mData, false)); + case FilePart.RsrcFork: + throw new FileNotFoundException("No rsrc fork"); + default: + throw new FileNotFoundException("No part of type " + part); + } } // IFileEntry diff --git a/DiskArc/Arc/README.md b/DiskArc/Arc/README.md index 9c68757..26a8d49 100644 --- a/DiskArc/Arc/README.md +++ b/DiskArc/Arc/README.md @@ -17,6 +17,7 @@ Suggested procedure: - Start by defining place-holder implementations for both interfaces. - Write the code that scans the contents of the archive. - Add an entry to FileAnalyzer, so the archives are automatically detected. + - Update DAExtensions as appropriate. - Write "prefab" tests that read the contents of existing archives. Add the tests and some sample archives to the test set. - Write the code that extracts files and handles file attribute queries. Add tests. diff --git a/DiskArc/DAAppHook.cs b/DiskArc/DAAppHook.cs index 0266eab..2439319 100644 --- a/DiskArc/DAAppHook.cs +++ b/DiskArc/DAAppHook.cs @@ -36,5 +36,9 @@ public static class DAAppHook { // Root directory when running library tests. This is where "TestData" can be found. public const string LIB_TEST_ROOT = "lib-test-root"; + + // Audio cassette decoder algorithm. Ideally this would be passed in directly, but + // it's a semi-experimental facet of a rarely-used feature. + public const string AUDIO_REC_ALG = "audio-rec-alg"; } } diff --git a/DiskArc/Disk/README.md b/DiskArc/Disk/README.md index 547d7a6..08c9604 100644 --- a/DiskArc/Disk/README.md +++ b/DiskArc/Disk/README.md @@ -18,6 +18,7 @@ Suggested procedure: - Start with UnadornedSector or UnadornedNibble525 as appropriate. - Write TestKind(), OpenDisk(), and AnalyzeDisk(). - Add to FileAnalyzer's PrepareDiskImage(), ExtensionToKind(), TestKind(), sProbeKinds. + - Update DAExtensions as appropriate. - Add some sample files to the TestData directory. - Write a "prefab" test that opens all of the sample files and does trivial checks (see the existing tests for examples). diff --git a/DiskArc/FileAttribs.cs b/DiskArc/FileAttribs.cs index bbcc199..1777330 100644 --- a/DiskArc/FileAttribs.cs +++ b/DiskArc/FileAttribs.cs @@ -76,6 +76,7 @@ public sealed class FileAttribs { public const int FILE_TYPE_OS = 0xf9; public const int FILE_TYPE_INT = 0xfa; public const int FILE_TYPE_BAS = 0xfc; + public const int FILE_TYPE_VAR = 0xfd; public const int FILE_TYPE_REL = 0xfe; public const int FILE_TYPE_SYS = 0xff; diff --git a/FileConv/Code/Applesoft.cs b/FileConv/Code/Applesoft.cs index 600e9d0..cd54442 100644 --- a/FileConv/Code/Applesoft.cs +++ b/FileConv/Code/Applesoft.cs @@ -153,8 +153,11 @@ public override IConvOutput ConvertFile(Dictionary options) { ushort nextAddr = RawData.ReadU16LE(dataBuf, ref offset); if (nextAddr == 0) { if (length - offset > 1) { - // Allow one extra byte; seems to appear on some files. - output.Notes.AddW("File ended early; excess bytes: " + (length - offset)); + // Allow one extra byte; seems to appear on some files. Significant + // excess data could be a bug here or an embedded program + // (e.g. Penny Arcade cassette). + output.Notes.AddW("Applesoft program ended early; excess bytes: " + + (length - offset)); } break; } diff --git a/FileConv/Code/BASIC-notes.md b/FileConv/Code/BASIC-notes.md index 2edec92..ed91f6d 100644 --- a/FileConv/Code/BASIC-notes.md +++ b/FileConv/Code/BASIC-notes.md @@ -83,7 +83,7 @@ stored as text. A file is a series of lines. Each line is: ``` +$00 / 1: line length (including the length byte itself) -+$01 / 2: line number ++$01 / 2: line number (must be positive) +$03 /nn: series of variable-length bytecode values +$xx / 1: end-of-line token ($01) ``` diff --git a/cp2_wpf/EditAttributes.xaml.cs b/cp2_wpf/EditAttributes.xaml.cs index 53fab4a..0a00893 100644 --- a/cp2_wpf/EditAttributes.xaml.cs +++ b/cp2_wpf/EditAttributes.xaml.cs @@ -244,6 +244,10 @@ public string FileName { } private void CheckFileNameValidity(out bool isValid, out bool isUnique) { + if (IsAllReadOnly) { + isValid = isUnique = true; + return; + } isValid = mIsValidFunc(NewAttribs.FullPathName); isUnique = true; if (mArchiveOrFileSystem is IArchive) {