Ver Fonte

Merge branch 'main' into boot-delay

John Morio Sakaguchi há 2 anos atrás
pai
commit
b96f7770c8

+ 1 - 0
.github/workflows/firmware_build.yml

@@ -3,6 +3,7 @@ name: Build ZuluSCSI firmware
 on: 
   push:
   workflow_dispatch:
+  pull_request:
 
 jobs:
   build_firmware:

+ 22 - 1
README.md

@@ -7,7 +7,7 @@ ZuluSCSI uses raw hard drive image files, which are stored on a FAT32 or exFAT-f
 
 Examples of valid filenames:
 * `HD5.hda` or `HD5.img`: hard drive with SCSI ID 5
-* `HD20_512.hda`: hard drive with SCSI ID 2, LUN 0, block size 512
+* `HD20_512.hda`: hard drive with SCSI ID 2, LUN 0, block size 512. Currently, ZuluSCSI does not support multiple LUNs, only LUN 0.
 * `CD3.iso`: CD drive with SCSI ID 3
 
 In addition to the simplified filenames style above, the ZuluSCSI firmware also looks for images using the BlueSCSI-style "HDxy_512.hda" filename formatting.
@@ -107,6 +107,27 @@ ZuluSCSI RP2040 DIP switch settings are:
 - TERMINATION: Enable SCSI termination
 - BOOTLOADER: Enable built-in USB bootloader, this DIP switch MUST remain off during normal operation.
 
+Physical eject button for CDROM
+-------------------------------
+CD-ROM drives can be configured to eject when a physical button is pressed.
+If multiple image files are configured with `IMG0`..`IMG9` config settings, ejection will switch between them.
+Two separate buttons are supported and they can eject different drives.
+
+    [SCSI1]
+    Type=2 # CDROM drive
+    IMG0 = img0.iso
+    IMG1 = ...
+    EjectButton = 1
+
+On GD32-based ZuluSCSI models (V1.0 and V1.1), buttons are connected to J303 12-pin expansion header.
+Button 1 is connected between `PE5` and `GND`, and button 2 is connected between `PE6` and `GND`.
+Pin locations are also shown in [this image](docs/ZuluSCSI_v1_1_buttons.jpg).
+
+On RP2040-based ZuluSCSI models, buttons are connected to the I2C pins.
+Button 1 is connected between `SDA` and `GND` and button 2 is connected between `SCL` and `GND`.
+On full-size models, the pins are available on expansion header J303 ([image](docs/ZuluSCSI_RP2040_buttons.jpg)).
+On compact model, pins are available on 4-pin I2C header J305 ([image](docs/ZuluSCSI_RP2040_compact_buttons.jpg)).
+
 SCSI initiator mode
 -------------------
 The RP2040 model supports SCSI initiator mode for reading SCSI drives.

BIN
docs/ZuluSCSI_RP2040_buttons.jpg


BIN
docs/ZuluSCSI_RP2040_compact_buttons.jpg


BIN
docs/ZuluSCSI_v1_1_buttons.jpg


+ 81 - 4
lib/CUEParser/test/CUEParser_test.cpp

@@ -7,14 +7,14 @@
 #define TEST(x) \
     if (!(x)) { \
         fprintf(stderr, "\033[31;1mFAILED:\033[22;39m %s:%d %s\n", __FILE__, __LINE__, #x); \
-        status = 1; \
+        status = false; \
     } else { \
         printf("\033[32;1mOK:\033[22;39m %s\n", #x); \
     }
 
-int test_basics()
+bool test_basics()
 {
-    int status = 0;
+    bool status = true;
     const char *cue_sheet = R"(
 FILE "Image Name.bin" BINARY
   TRACK 01 MODE1/2048
@@ -33,6 +33,7 @@ FILE "Sound.wav" WAVE
 
     CUEParser parser(cue_sheet);
 
+    COMMENT("test_basics()");
     COMMENT("Test TRACK 01 (data)");
     const CUETrackInfo *track = parser.next_track();
     TEST(track != NULL);
@@ -108,8 +109,84 @@ FILE "Sound.wav" WAVE
     return status;
 }
 
+bool test_datatracks()
+{
+    bool status = true;
+    const char *cue_sheet = R"(
+FILE "beos-5.0.3-professional-gobe.bin" BINARY
+TRACK 01 MODE1/2352
+    INDEX 01 00:00:00
+TRACK 02 MODE1/2352
+    INDEX 01 10:48:58
+TRACK 03 MODE1/2352
+    INDEX 01 46:07:03
+    )";
+
+    CUEParser parser(cue_sheet);
+
+    COMMENT("test_datatracks()");
+    COMMENT("Test TRACK 01 (data)");
+    const CUETrackInfo *track = parser.next_track();
+    TEST(track != NULL);
+    if (track)
+    {
+        TEST(strcmp(track->filename, "beos-5.0.3-professional-gobe.bin") == 0);
+        TEST(track->file_mode == CUEFile_BINARY);
+        TEST(track->file_offset == 0);
+        TEST(track->track_number == 1);
+        TEST(track->track_mode == CUETrack_MODE1_2352);
+        TEST(track->sector_length == 2352);
+        TEST(track->unstored_pregap_length == 0);
+        TEST(track->data_start == 0);
+        TEST(track->track_start == 0);
+    }
+
+    COMMENT("Test TRACK 02 (data)");
+    track = parser.next_track();
+    TEST(track != NULL);
+    if (track)
+    {
+        TEST(track->file_mode == CUEFile_BINARY);
+        TEST(track->file_offset == 0x6D24560);
+        TEST(track->track_number == 2);
+        TEST(track->track_mode == CUETrack_MODE1_2352);
+        TEST(track->sector_length == 2352);
+        TEST(track->unstored_pregap_length == 0);
+        TEST(track->data_start == ((10 * 60) + 48) * 75 + 58);
+        TEST(track->track_start == ((10 * 60) + 48) * 75 + 58);
+    }
+
+    COMMENT("Test TRACK 03 (data)");
+    track = parser.next_track();
+    TEST(track != NULL);
+    if (track)
+    {
+        TEST(track->file_mode == CUEFile_BINARY);
+        TEST(track->file_offset == 0x1D17E780);
+        TEST(track->track_number == 3);
+        TEST(track->track_mode == CUETrack_MODE1_2352);
+        TEST(track->sector_length == 2352);
+        TEST(track->unstored_pregap_length == 0);
+        TEST(track->data_start == ((46 * 60) + 7) * 75 + 3);
+        TEST(track->track_start == ((46 * 60) + 7) * 75 + 3);
+    }
+
+    track = parser.next_track();
+    TEST(track == NULL);
+
+    return status;
+}
+
 
 int main()
 {
-    return test_basics();
+    if (test_basics() && test_datatracks())
+    {
+        return 0;
+    }
+    else
+    {
+        printf("Some tests failed\n");
+        return 1;
+    }
 }

+ 21 - 55
lib/SCSI2SD/src/firmware/mode.c

@@ -21,6 +21,7 @@
 #include "mode.h"
 #include "disk.h"
 #include "inquiry.h"
+#include "ZuluSCSI_mode.h"
 
 #include <string.h>
 
@@ -220,33 +221,6 @@ static const uint8_t ControlModePage[] =
 0x00, 0x00 // AEN holdoff period.
 };
 
-#ifdef ENABLE_AUDIO_OUTPUT
-static const uint8_t CDROMCDParametersPage[] =
-{
-0x0D, // page code
-0x06, // page length
-0x00, // reserved
-0x0D, // reserved, inactivity time 8 min
-0x00, 0x3C, // 60 seconds per MSF M unit
-0x00, 0x4B  // 75 frames per MSF S unit
-};
-
-static const uint8_t CDROMAudioControlParametersPage[] =
-{
-0x0E, // page code
-0x0E, // page length
-0x04, // 'Immed' bit set, 'SOTC' bit not set
-0x00, // reserved
-0x00, // reserved
-0x80, // 1 LBAs/sec multip
-0x00, 0x4B, // 75 LBAs/sec
-0x03, 0xFF, // output port 0 active, max volume
-0x03, 0xFF, // output port 1 active, max volume
-0x00, 0x00, // output port 2 inactive
-0x00, 0x00 // output port 3 inactive
-};
-#endif
-
 static const uint8_t SequentialDeviceConfigPage[] =
 {
 0x10, // page code
@@ -420,7 +394,8 @@ static void doModeSense(
 		}
 	}
 
-	if (pageCode == 0x03 || pageCode == 0x3F)
+	if ((pageCode == 0x03 || pageCode == 0x3F) &&
+		(scsiDev.target->cfg->deviceType != S2S_CFG_OPTICAL))
 	{
 		pageFound = 1;
 		pageIn(pc, idx, FormatDevicePage, sizeof(FormatDevicePage));
@@ -445,7 +420,8 @@ static void doModeSense(
 		idx += sizeof(FormatDevicePage);
 	}
 
-	if (pageCode == 0x04 || pageCode == 0x3F)
+	if ((pageCode == 0x04 || pageCode == 0x3F) &&
+		(scsiDev.target->cfg->deviceType != S2S_CFG_OPTICAL))
 	{
 		pageFound = 1;
 		if ((scsiDev.compatMode >= COMPAT_SCSI2))
@@ -523,31 +499,8 @@ static void doModeSense(
 		idx += sizeof(ControlModePage);
 	}
 
-#ifdef ENABLE_AUDIO_OUTPUT
-	if ((scsiDev.target->cfg->deviceType == S2S_CFG_OPTICAL)
-		&& (pageCode == 0x0D || pageCode == 0x3F))
-	{
-		pageFound = 1;
-		pageIn(
-			pc,
-			idx,
-			CDROMCDParametersPage,
-			sizeof(CDROMCDParametersPage));
-		idx += sizeof(CDROMCDParametersPage);
-	}
-
-	if ((scsiDev.target->cfg->deviceType == S2S_CFG_OPTICAL)
-		&& (pageCode == 0x0E || pageCode == 0x3F))
-	{
-		pageFound = 1;
-		pageIn(
-			pc,
-			idx,
-			CDROMAudioControlParametersPage,
-			sizeof(CDROMAudioControlParametersPage));
-		idx += sizeof(CDROMAudioControlParametersPage);
-	}
-#endif
+	idx += modeSenseCDDevicePage(pc, idx, pageCode, &pageFound);
+	idx += modeSenseCDAudioControlPage(pc, idx, pageCode, &pageFound);
 
 	if ((scsiDev.target->cfg->deviceType == S2S_CFG_SEQUENTIAL) &&
 		(pageCode == 0x10 || pageCode == 0x3F))
@@ -561,6 +514,8 @@ static void doModeSense(
 		idx += sizeof(SequentialDeviceConfigPage);
 	}
 
+	idx += modeSenseCDCapabilitiesPage(pc, idx, pageCode, &pageFound);
+
 	if ((
 			(scsiDev.target->cfg->quirks == S2S_CFG_QUIRKS_APPLE) ||
 			(idx + sizeof(AppleVendorPage) <= allocLength)
@@ -670,10 +625,16 @@ static void doModeSelect(void)
 
 		while (idx < scsiDev.dataLen)
 		{
+			// Change from SCSI2SD: if code page is 0x0 (vendor-specific) it
+			// will not follow the normal page mode format and cannot be
+			// parsed, but isn't necessarily an error. Instead, just treat it
+			// as an 'end of data' field and allow normal command completion.
+			int pageCode = scsiDev.data[idx] & 0x3F;
+			if (pageCode == 0) goto out;
+
 			int pageLen = scsiDev.data[idx + 1];
 			if (idx + 2 + pageLen > scsiDev.dataLen) goto bad;
 
-			int pageCode = scsiDev.data[idx] & 0x3F;
 			switch (pageCode)
 			{
 			case 0x03: // Format Device Page
@@ -699,6 +660,11 @@ static void doModeSelect(void)
 				}
 			}
 			break;
+			case 0x0E: // CD audio control page
+			{
+				if (!modeSelectCDAudioControlPage(pageLen, idx)) goto bad;
+			}
+			break;
 			//default:
 
 				// Easiest to just ignore for now. We'll get here when changing

+ 25 - 1
lib/ZuluSCSI_platform_GD32F205/ZuluSCSI_platform.cpp

@@ -218,6 +218,10 @@ void platform_init()
     gpio_bit_set(LED_PORT, LED_PINS);
     gpio_init(LED_PORT, GPIO_MODE_OUT_PP, GPIO_OSPEED_2MHZ, LED_PINS);
 
+    // Ejection buttons
+    gpio_init(EJECT_1_PORT, GPIO_MODE_IPU, 0, EJECT_1_PIN);
+    gpio_init(EJECT_2_PORT, GPIO_MODE_IPU, 0, EJECT_2_PIN);
+
     // SWO trace pin on PB3
     gpio_init(GPIOB, GPIO_MODE_AF_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_3);
 }
@@ -495,7 +499,27 @@ void platform_poll()
 
 uint8_t platform_get_buttons()
 {
-    return 0;
+    // Buttons are active low: internal pull-up is enabled,
+    // and when button is pressed the pin goes low.
+    uint8_t buttons = 0;
+    if (!gpio_input_bit_get(EJECT_1_PORT, EJECT_1_PIN))   buttons |= 1;
+    if (!gpio_input_bit_get(EJECT_2_PORT, EJECT_2_PIN))   buttons |= 2;
+
+    // Simple debouncing logic: handle button releases after 100 ms delay.
+    static uint32_t debounce;
+    static uint8_t buttons_debounced = 0;
+
+    if (buttons != 0)
+    {
+        buttons_debounced = buttons;
+        debounce = millis();
+    }
+    else if ((uint32_t)(millis() - debounce) > 100)
+    {
+        buttons_debounced = 0;
+    }
+
+    return buttons_debounced;
 }
 
 /***********************/

+ 8 - 0
lib/ZuluSCSI_platform_GD32F205/ZuluSCSI_v1_0_gpio.h

@@ -138,3 +138,11 @@
 #define LED_PINS     (LED_I_PIN | LED_E_PIN)
 #define LED_ON()     gpio_bit_reset(LED_PORT, LED_PINS)
 #define LED_OFF()    gpio_bit_set(LED_PORT, LED_PINS)
+
+// Ejection buttons are available on expansion header J303.
+// PE5 = channel 1, PE6 = channel 2
+// Connect button between GPIO and GND pin.
+#define EJECT_1_PORT    GPIOE
+#define EJECT_1_PIN     GPIO_PIN_5
+#define EJECT_2_PORT    GPIOE
+#define EJECT_2_PIN     GPIO_PIN_6

+ 9 - 1
lib/ZuluSCSI_platform_GD32F205/ZuluSCSI_v1_1_gpio.h

@@ -182,4 +182,12 @@
 #define LED_E_PIN    GPIO_PIN_5
 #define LED_PINS     (LED_I_PIN | LED_E_PIN)
 #define LED_ON()     gpio_bit_reset(LED_PORT, LED_PINS)
-#define LED_OFF()    gpio_bit_set(LED_PORT, LED_PINS)
+#define LED_OFF()    gpio_bit_set(LED_PORT, LED_PINS)
+
+// Ejection buttons are available on expansion header J303.
+// PE5 = channel 1, PE6 = channel 2
+// Connect button between GPIO and GND pin.
+#define EJECT_1_PORT    GPIOE
+#define EJECT_1_PIN     GPIO_PIN_5
+#define EJECT_2_PORT    GPIOE
+#define EJECT_2_PIN     GPIO_PIN_6

+ 24 - 6
lib/ZuluSCSI_platform_RP2040/ZuluSCSI_platform.cpp

@@ -621,14 +621,32 @@ void platform_poll()
 
 uint8_t platform_get_buttons()
 {
-#ifdef ENABLE_AUDIO_OUTPUT
-    uint8_t pins = 0x00;
+    uint8_t buttons = 0;
+
+#if defined(ENABLE_AUDIO_OUTPUT)
     // pulled to VCC via resistor, sinking when pressed
-    if (!gpio_get(GPIO_EXP_SPARE)) pins |= 0x01;
-    return pins;
-#else
-    return 0;
+    if (!gpio_get(GPIO_EXP_SPARE)) buttons |= 1;
+#elif defined(GPIO_I2C_SDA)
+    // SDA = button 1, SCL = button 2
+    if (!gpio_get(GPIO_I2C_SDA)) buttons |= 1;
+    if (!gpio_get(GPIO_I2C_SCL)) buttons |= 2;
 #endif
+
+    // Simple debouncing logic: handle button releases after 100 ms delay.
+    static uint32_t debounce;
+    static uint8_t buttons_debounced = 0;
+
+    if (buttons != 0)
+    {
+        buttons_debounced = buttons;
+        debounce = millis();
+    }
+    else if ((uint32_t)(millis() - debounce) > 100)
+    {
+        buttons_debounced = 0;
+    }
+
+    return buttons_debounced;
 }
 
 /*****************************************/

+ 57 - 16
lib/ZuluSCSI_platform_RP2040/audio.cpp

@@ -140,6 +140,16 @@ static uint32_t fleft;
 // historical playback status information
 static audio_status_code audio_last_status[8] = {ASC_NO_STATUS};
 
+// volume information for targets
+static volatile uint16_t volumes[8] = {
+    DEFAULT_VOLUME_LEVEL, DEFAULT_VOLUME_LEVEL, DEFAULT_VOLUME_LEVEL, DEFAULT_VOLUME_LEVEL,
+    DEFAULT_VOLUME_LEVEL, DEFAULT_VOLUME_LEVEL, DEFAULT_VOLUME_LEVEL, DEFAULT_VOLUME_LEVEL
+};
+static volatile uint16_t channels[8] = {
+    AUDIO_CHANNEL_ENABLE_MASK, AUDIO_CHANNEL_ENABLE_MASK, AUDIO_CHANNEL_ENABLE_MASK, AUDIO_CHANNEL_ENABLE_MASK,
+    AUDIO_CHANNEL_ENABLE_MASK, AUDIO_CHANNEL_ENABLE_MASK, AUDIO_CHANNEL_ENABLE_MASK, AUDIO_CHANNEL_ENABLE_MASK
+};
+
 // mechanism for cleanly stopping DMA units
 static volatile bool audio_stopping = false;
 
@@ -158,31 +168,46 @@ static uint8_t invert = 0; // biphase encode help: set if last wire bit was '1'
  * output.
  */
 static void snd_encode(uint8_t* samples, uint16_t* wire_patterns, uint16_t len, uint8_t swap) {
+    uint16_t wvol = volumes[audio_owner & 7];
+    uint8_t lvol = ((wvol >> 8) + (wvol & 0xFF)) >> 1; // average of both values
+    // limit maximum volume; with my DACs I've had persistent issues
+    // with signal clipping when sending data in the highest bit position
+    lvol = lvol >> 2;
+    uint8_t rvol = lvol;
+    // enable or disable based on the channel information for both output
+    // ports, where the high byte and mask control the right channel, and
+    // the low control the left channel
+    uint16_t chn = channels[audio_owner & 7] & AUDIO_CHANNEL_ENABLE_MASK;
+    if (!(chn >> 8)) rvol = 0;
+    if (!(chn & 0xFF)) lvol = 0;
+
     uint16_t widx = 0;
     for (uint16_t i = 0; i < len; i += 2) {
         uint32_t sample = 0;
         uint8_t parity = 0;
         if (samples != NULL) {
+            int32_t rsamp;
             if (swap) {
-                sample = samples[i + 1] + (samples[i] << 8);
+                rsamp = (int16_t)(samples[i + 1] + (samples[i] << 8));
+            } else {
+                rsamp = (int16_t)(samples[i] + (samples[i + 1] << 8));
+            }
+            // linear scale to requested audio value
+            if (i & 2) {
+                rsamp *= rvol;
             } else {
-                sample = samples[i] + (samples[i + 1] << 8);
+                rsamp *= lvol;
             }
-            // determine parity, simplified to one lookup via an XOR
-            parity = (sample >> 8) ^ sample;
+            // use 20 bits of value only, which allows ignoring the lowest 8
+            // bits during biphase conversion (after including sample shift)
+            sample = ((uint32_t)rsamp) & 0xFFFFF0;
+
+            // determine parity, simplified to one lookup via XOR
+            parity = ((sample >> 16) ^ (sample >> 8)) ^ sample;
             parity = snd_parity[parity];
 
-            /*
-             * Shift sample into the correct bit positions of the sub-frame. This
-             * would normally be << 12, but with my DACs I've had persistent issues
-             * with signal clipping when sending data in the highest bit position.
-             */
-            sample = sample << 11;
-            if (sample & 0x04000000) {
-                // handle two's complement
-                sample |= 0x08000000;
-                parity++;
-            }
+            // shift sample into the correct bit positions of the sub-frame.
+            sample = sample << 4;
         }
 
         // if needed, establish even parity with P bit
@@ -202,7 +227,7 @@ static void snd_encode(uint8_t* samples, uint16_t* wire_patterns, uint16_t len,
         if (invert) wp = ~wp;
         invert = wp & 1;
         wire_patterns[widx++] = wp;
-        // next 8 bits (only high 4 have data)
+        // next 8 bits
         wp = biphase[(uint8_t) (sample >> 8)];
         if (invert) wp = ~wp;
         invert = wp & 1;
@@ -544,4 +569,20 @@ audio_status_code audio_get_status_code(uint8_t id) {
     return tmp;
 }
 
+uint16_t audio_get_volume(uint8_t id) {
+    return volumes[id & 7];
+}
+
+void audio_set_volume(uint8_t id, uint16_t vol) {
+    volumes[id & 7] = vol;
+}
+
+uint16_t audio_get_channel(uint8_t id) {
+    return channels[id & 7];
+}
+
+void audio_set_channel(uint8_t id, uint16_t chn) {
+    channels[id & 7] = chn;
+}
+
 #endif // ENABLE_AUDIO_OUTPUT

+ 5 - 4
platformio.ini

@@ -40,8 +40,7 @@ lib_deps =
     SCSI2SD
     CUEParser
 upload_protocol = stlink
-platform_packages = 
-    toolchain-gccarmnoneeabi@1.60301.0
+platform_packages = platformio/toolchain-gccarmnoneeabi@1.100301.220327
     framework-spl-gd32@https://github.com/CommunityGD32Cores/gd32-pio-spl-package.git
 extra_scripts = src/build_bootloader.py
 debug_build_flags = -Os -ggdb -g3
@@ -79,10 +78,11 @@ build_flags =
 
 ; ZuluSCSI RP2040 hardware platform, based on the Raspberry Pi foundation RP2040 microcontroller
 [env:ZuluSCSI_RP2040]
-platform = raspberrypi@1.8.0
+platform = raspberrypi@1.9.0
 framework = arduino
 board = ZuluSCSI_RP2040
 extra_scripts = src/build_bootloader.py
+platform_packages = platformio/toolchain-gccarmnoneeabi@1.100301.220327
 board_build.ldscript = lib/ZuluSCSI_platform_RP2040/rp2040.ld
 ldscript_bootloader = lib/ZuluSCSI_platform_RP2040/rp2040_btldr.ld
 lib_deps =
@@ -107,11 +107,12 @@ extends = env:ZuluSCSI_RP2040
 build_flags =
     ${env:ZuluSCSI_RP2040.build_flags}
     -DENABLE_AUDIO_OUTPUT
+    -DLOGBUFSIZE=8192
 
 ; Variant of RP2040 platform, based on Raspberry Pico board and a carrier PCB
 ; Differs in pinout from ZuluSCSI_RP2040 platform, but shares most of the code.
 [env:ZuluSCSI_BS2]
-platform = raspberrypi@1.8.0
+platform = raspberrypi@1.9.0
 framework = arduino
 board = ZuluSCSI_RP2040
 extra_scripts = src/build_bootloader.py

+ 4 - 39
src/ZuluSCSI.cpp

@@ -374,51 +374,16 @@ bool findHDDImages()
       bool is_tp = (tolower(name[0]) == 't' && tolower(name[1]) == 'p');
       if (is_hd || is_cd || is_fd || is_mo || is_re || is_tp)
       {
-        // Check file extension
-        // We accept anything except known compressed files
-        bool is_compressed = false;
-        const char *extension = strrchr(name, '.');
-        if (extension)
-        {
-          const char *archive_exts[] = {
-            ".tar", ".tgz", ".gz", ".bz2", ".tbz2", ".xz", ".zst", ".z",
-            ".zip", ".zipx", ".rar", ".lzh", ".lha", ".lzo", ".lz4", ".arj",
-            ".dmg", ".hqx", ".cpt", ".7z", ".s7z",
-            NULL
-          };
-
-          for (int i = 0; archive_exts[i]; i++)
-          {
-            if (strcasecmp(extension, archive_exts[i]) == 0)
-            {
-              is_compressed = true;
-              break;
-            }
-          }
-        }
-
-        if (is_compressed)
-        {
-          logmsg("-- Ignoring compressed file ", name);
-          continue;
-        }
-
-        if (strcasecmp(extension, ".cue") == 0)
-        {
-          continue; // .cue will be handled with corresponding .bin
-        }
-
         // Check if the image should be loaded to microcontroller flash ROM drive
         bool is_romdrive = false;
+        const char *extension = strrchr(name, '.');
         if (extension && strcasecmp(extension, ".rom") == 0)
         {
           is_romdrive = true;
         }
-        else if (extension && strcasecmp(extension, ".rom_loaded") == 0)
-        {
-          // Already loaded ROM drive, ignore the image
-          continue;
-        }
+
+        // skip file if the name indicates it is not a valid image container
+        if (!is_romdrive && !scsiDiskFilenameValid(name)) continue;
 
         // Defaults for Hard Disks
         int id  = 1; // 0 and 3 are common in Macs for physical HD and CD, so avoid them.

+ 58 - 0
src/ZuluSCSI_audio.h

@@ -24,6 +24,24 @@
 #include <stdint.h>
 #include "ImageBackingStore.h"
 
+/*
+ * Starting volume level for audio output, with 0 being muted and 255 being
+ * max volume. SCSI-2 says this should be 25% of maximum by default, MMC-1
+ * says 100%. Testing shows this tends to be obnoxious at high volumes, so
+ * go with SCSI-2.
+ *
+ * This implementation uses the high byte for output port 1 and the low byte
+ * for port 0. The two values are averaged to determine final volume level.
+ */
+#define DEFAULT_VOLUME_LEVEL 0x3F3F
+/*
+ * Defines the 'enable' masks for the two audio output ports of each device.
+ * If this mask is matched with audio_get_channel() the relevant port will
+ * have audio output to it, otherwise it will be muted, regardless of the
+ * volume level.
+ */
+#define AUDIO_CHANNEL_ENABLE_MASK 0x0201
+
 /*
  * Status codes for audio playback, matching the SCSI 'audio status codes'.
  *
@@ -86,3 +104,43 @@ void audio_stop(uint8_t id);
  * \return      The matching audio status code.
  */
 audio_status_code audio_get_status_code(uint8_t id);
+
+/**
+ * Gets the current volume level for a target. This is a pair of 8-bit values
+ * ranging from 0-255 that are averaged together to determine the final output
+ * level, where 0 is muted and 255 is maximum volume. The high byte corresponds
+ * to 0x0E channel 1 and the low byte to 0x0E channel 0. See the spec's mode
+ * page documentation for more details.
+ *
+ * \param id    SCSI ID to provide volume for.
+ * \return      The matching volume level.
+ */
+uint16_t audio_get_volume(uint8_t id);
+
+/**
+ * Sets the volume level for a target, as above. See 0x0E mode page for more.
+ *
+ * \param id    SCSI ID to set volume for.
+ * \param vol   The new volume level.
+ */
+void audio_set_volume(uint8_t id, uint16_t vol);
+
+/**
+ * Gets the 0x0E channel information for both audio ports. The high byte
+ * corresponds to port 1 and the low byte to port 0. If the bits defined in
+ * AUDIO_CHANNEL_ENABLE_MASK are not set for the respective ports, that
+ * output will be muted, regardless of volume set.
+ *
+ * \param id    SCSI ID to provide channel information for.
+ * \return      The channel information.
+ */
+uint16_t audio_get_channel(uint8_t id);
+
+/**
+ * Sets the 0x0E channel information for a target, as above. See 0x0E mode
+ * page for more.
+ *
+ * \param id    SCSI ID to set channel information for.
+ * \param chn   The new channel information.
+ */
+void audio_set_channel(uint8_t id, uint16_t chn);

+ 389 - 17
src/ZuluSCSI_cdrom.cpp

@@ -186,6 +186,38 @@ static const uint8_t DiscInformation[] =
     0x00,   // 33: number of opc tables
 };
 
+static const uint8_t TrackInformation[] =
+{
+    0x00,   //  0: data length, MSB
+    0x1A,   //  1: data length, LSB
+    0x01,   //  2: track number
+    0x01,   //  3: session number
+    0x00,   //  4: reserved
+    0x04,   //  5: track mode and flags
+    0x8F,   //  6: data mode and flags
+    0x00,   //  7: nwa_v
+    0x00,   //  8: track start address (MSB)
+    0x00,   //  9: .
+    0x00,   // 10: .
+    0x00,   // 11: track start address (LSB)
+    0xFF,   // 12: next writable address (MSB)
+    0xFF,   // 13: .
+    0xFF,   // 14: .
+    0xFF,   // 15: next writable address (LSB)
+    0x00,   // 16: free blocks (MSB)
+    0x00,   // 17: .
+    0x00,   // 18: .
+    0x00,   // 19: free blocks (LSB)
+    0x00,   // 20: fixed packet size (MSB)
+    0x00,   // 21: .
+    0x00,   // 22: .
+    0x00,   // 23: fixed packet size (LSB)
+    0x00,   // 24: track size (MSB)
+    0x00,   // 25: .
+    0x00,   // 26: .
+    0x00,   // 27: track size (LSB)
+};
+
 // Convert logical block address to CD-ROM time
 static void LBA2MSF(int32_t LBA, uint8_t* MSF, bool relative)
 {
@@ -229,7 +261,7 @@ static uint32_t getLeadOutLBA(const CUETrackInfo* lasttrack)
         image_config_t &img = *(image_config_t*)scsiDev.target->cfg;
         uint32_t lastTrackBlocks = (img.file.size() - lasttrack->file_offset)
                 / lasttrack->sector_length;
-        return lasttrack->track_start + lastTrackBlocks + 1;
+        return lasttrack->track_start + lastTrackBlocks;
     }
     else
     {
@@ -384,6 +416,39 @@ void doReadDiscInformationSimple(uint16_t allocationLength)
     scsiDev.phase = DATA_IN;
 }
 
+void doReadTrackInformationSimple(bool track, uint32_t lba, uint16_t allocationLength)
+{
+    uint32_t len = sizeof(TrackInformation);
+    memcpy(scsiDev.data, TrackInformation, len);
+
+    uint32_t capacity = getScsiCapacity(
+            scsiDev.target->cfg->sdSectorStart,
+            scsiDev.target->liveCfg.bytesPerSector,
+            scsiDev.target->cfg->scsiSectors);
+    if (!track && lba >= capacity)
+    {
+        scsiDev.status = CHECK_CONDITION;
+        scsiDev.target->sense.code = ILLEGAL_REQUEST;
+        scsiDev.target->sense.asc = INVALID_FIELD_IN_CDB;
+        scsiDev.phase = STATUS;
+    }
+    else
+    {
+        // update track size
+        scsiDev.data[24] = capacity >> 24;
+        scsiDev.data[25] = capacity >> 16;
+        scsiDev.data[26] = capacity >> 8;
+        scsiDev.data[27] = capacity;
+
+        if (len > allocationLength)
+        {
+            len = allocationLength;
+        }
+        scsiDev.dataLen = len;
+        scsiDev.phase = DATA_IN;
+    }
+}
+
 /*********************************/
 /* TOC generation from cue sheet */
 /*********************************/
@@ -765,6 +830,279 @@ void doReadDiscInformation(uint16_t allocationLength)
     scsiDev.phase = DATA_IN;
 }
 
+void doReadTrackInformation(bool track, uint32_t lba, uint16_t allocationLength)
+{
+    image_config_t &img = *(image_config_t*)scsiDev.target->cfg;
+    CUEParser parser;
+    if (!loadCueSheet(img, parser))
+    {
+        // No CUE sheet, use hardcoded data
+        return doReadTrackInformationSimple(track, lba, allocationLength);
+    }
+
+    // Take the hardcoded header as base
+    uint32_t len = sizeof(TrackInformation);
+    memcpy(scsiDev.data, TrackInformation, len);
+
+    // Step through the tracks until the one requested is found
+    // Result will be placed in mtrack for later use if found
+    bool trackfound = false;
+    uint32_t tracklen = 0;
+    CUETrackInfo mtrack = {0};
+    const CUETrackInfo *trackinfo;
+    while ((trackinfo = parser.next_track()) != NULL)
+    {
+        if (mtrack.track_number != 0) // skip 1st track, just store later
+        {
+            if ((track && lba == mtrack.track_number)
+                || (!track && lba < trackinfo->data_start))
+            {
+                trackfound = true;
+                tracklen = trackinfo->data_start - mtrack.data_start;
+                break;
+            }
+        }
+        mtrack = *trackinfo;
+    }
+    // try the last track as a final attempt if no match found beforehand
+    if (!trackfound)
+    {
+        uint32_t lastLba = getLeadOutLBA(&mtrack);
+        if ((track && lba == mtrack.track_number)
+            || (!track && lba < lastLba))
+        {
+            trackfound = true;
+            tracklen = lastLba - mtrack.data_start;
+        }
+    }
+
+    // bail out if no match found
+    if (!trackfound)
+    {
+        scsiDev.status = CHECK_CONDITION;
+        scsiDev.target->sense.code = ILLEGAL_REQUEST;
+        scsiDev.target->sense.asc = INVALID_FIELD_IN_CDB;
+        scsiDev.phase = STATUS;
+        return;
+    }
+
+    // rewrite relevant bytes, starting with track number
+    scsiDev.data[3] = mtrack.track_number;
+
+    // track mode
+    if (mtrack.track_mode == CUETrack_AUDIO)
+    {
+        scsiDev.data[5] = 0x00;
+    }
+
+    // track start
+    uint32_t start = mtrack.data_start;
+    scsiDev.data[8] = start >> 24;
+    scsiDev.data[9] = start >> 16;
+    scsiDev.data[10] = start >> 8;
+    scsiDev.data[11] = start;
+
+    // track size
+    scsiDev.data[24] = tracklen >> 24;
+    scsiDev.data[25] = tracklen >> 16;
+    scsiDev.data[26] = tracklen >> 8;
+    scsiDev.data[27] = tracklen;
+
+    dbgmsg("------ Reporting track ", mtrack.track_number, ", start ", start,
+            ", length ", tracklen);
+    if (len > allocationLength)
+    {
+        len = allocationLength;
+    }
+    scsiDev.dataLen = len;
+    scsiDev.phase = DATA_IN;
+}
+
+void doGetConfiguration(uint8_t rt, uint16_t startFeature, uint16_t allocationLength)
+{
+    // rt = 0 is all features, rt = 1 is current features,
+    // rt = 2 only startFeature, others reserved
+    if (rt > 2)
+    {
+        scsiDev.status = CHECK_CONDITION;
+        scsiDev.target->sense.code = ILLEGAL_REQUEST;
+        scsiDev.target->sense.asc = INVALID_FIELD_IN_CDB;
+        scsiDev.phase = STATUS;
+        return;
+    }
+
+    image_config_t &img = *(image_config_t*)scsiDev.target->cfg;
+
+    // write feature header
+    uint32_t len = 8; // length bytes set at end of call
+    scsiDev.data[4] = 0; // reserved
+    scsiDev.data[5] = 0; // reserved
+    if (!img.ejected)
+    {
+        // disk in drive, current profile is CD-ROM
+        scsiDev.data[6] = 0x00;
+        scsiDev.data[7] = 0x08;
+    }
+    else
+    {
+        // no disk, report no current profile
+        scsiDev.data[6] = 0;
+        scsiDev.data[7] = 0;
+    }
+
+    // profile list (0)
+    if ((rt == 2 && 0 == startFeature)
+        || (rt == 1 && startFeature <= 0)
+        || (rt == 0 && startFeature <= 0))
+    {
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x03; // ver 0, persist=1,current=1
+        scsiDev.data[len++] = 8; // 2 more
+        // CD-ROM profile
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x08;
+        scsiDev.data[len++] = (img.ejected) ? 0x00 : 0x01;
+        scsiDev.data[len++] = 0;
+        // removable disk profile
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x02;
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0;
+    }
+
+    // core feature (1)
+    if ((rt == 2 && startFeature == 1)
+        || (rt == 1 && startFeature <= 1)
+        || (rt == 0 && startFeature <= 1))
+    {
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x01;
+        scsiDev.data[len++] = 0x0B; // ver 2, persist=1,current=1
+        scsiDev.data[len++] = 8;
+        // physical interface standard (SCSI)
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x01;
+        scsiDev.data[len++] = 0x03; // support INQ2 and DBE
+        scsiDev.data[len++] = 0;
+        scsiDev.data[len++] = 0;
+        scsiDev.data[len++] = 0;
+    }
+
+    // morphing feature (2)
+    if ((rt == 2 && startFeature == 2)
+        || (rt == 1 && startFeature <= 2)
+        || (rt == 0 && startFeature <= 2))
+    {
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x02;
+        scsiDev.data[len++] = 0x07; // ver 1, persist=1,current=1
+        scsiDev.data[len++] = 4;
+        scsiDev.data[len++] = 0x02; // OCEvent=1,async=0
+        scsiDev.data[len++] = 0;
+        scsiDev.data[len++] = 0;
+        scsiDev.data[len++] = 0;
+    }
+
+    // removable medium feature (3)
+    if ((rt == 2 && startFeature == 3)
+        || (rt == 1 && startFeature <= 3)
+        || (rt == 0 && startFeature <= 3))
+    {
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x03;
+        scsiDev.data[len++] = 0x03; // ver 0, persist=1,current=1
+        scsiDev.data[len++] = 4;
+        scsiDev.data[len++] = 0x28; // matches 0x2A mode page version
+        scsiDev.data[len++] = 0;
+        scsiDev.data[len++] = 0;
+        scsiDev.data[len++] = 0;
+    }
+
+    // random readable feature (0x10, 16)
+    if ((rt == 2 && startFeature == 16)
+        || (rt == 1 && startFeature <= 16 && !img.ejected)
+        || (rt == 0 && startFeature <= 16))
+    {
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x10;
+        // ver 0, persist=0,current=drive state
+        scsiDev.data[len++] = (img.ejected) ? 0x00 : 0x01;
+        scsiDev.data[len++] = 8;
+        scsiDev.data[len++] = 0x00; // 2048 (MSB)
+        scsiDev.data[len++] = 0x00; // .
+        scsiDev.data[len++] = 0x08; // .
+        scsiDev.data[len++] = 0x00; // 2048 (LSB)
+        scsiDev.data[len++] = 0x00;
+        // one block min when disk in drive only
+        scsiDev.data[len++] = (img.ejected) ? 0x00 : 0x01;
+        scsiDev.data[len++] = 0x00; // no support for PP error correction (TODO?)
+        scsiDev.data[len++] = 0;
+    }
+
+    // multi-read feature (0x1D, 29)
+    if ((rt == 2 && startFeature == 29)
+        || (rt == 1 && startFeature <= 29 && !img.ejected)
+        || (rt == 0 && startFeature <= 29))
+    {
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x1D;
+        // ver 0, persist=0,current=drive state
+        scsiDev.data[len++] = (img.ejected) ? 0x00 : 0x01;
+        scsiDev.data[len++] = 0;
+    }
+
+    // CD read feature (0x1E, 30)
+    if ((rt == 2 && startFeature == 30)
+        || (rt == 1 && startFeature <= 30 && !img.ejected)
+        || (rt == 0 && startFeature <= 30))
+    {
+        scsiDev.data[len++] = 0x00;
+        scsiDev.data[len++] = 0x1E;
+        // ver 2, persist=0,current=drive state
+        scsiDev.data[len++] = (img.ejected) ? 0x08 : 0x09;
+        scsiDev.data[len++] = 4;
+        scsiDev.data[len++] = 0x00; // dap=0,c2=0,cd-text=0
+        scsiDev.data[len++] = 0;
+        scsiDev.data[len++] = 0;
+        scsiDev.data[len++] = 0;
+    }
+
+#ifdef ENABLE_AUDIO_OUTPUT
+    // CD audio feature (0x103, 259)
+    if ((rt == 2 && startFeature == 259)
+        || (rt == 1 && startFeature <= 259 && !img.ejected)
+        || (rt == 0 && startFeature <= 259))
+    {
+        scsiDev.data[len++] = 0x01;
+        scsiDev.data[len++] = 0x03;
+        // ver 1, persist=0,current=drive state
+        scsiDev.data[len++] = (img.ejected) ? 0x04 : 0x05;
+        scsiDev.data[len++] = 4;
+        scsiDev.data[len++] = 0x03; // scan=0,scm=1,sv=1
+        scsiDev.data[len++] = 0;
+        scsiDev.data[len++] = 0x01; // 256 volume levels
+        scsiDev.data[len++] = 0x00; // .
+    }
+#endif
+
+    // finally, rewrite data length to match
+    uint32_t dlen = len - 8;
+    scsiDev.data[0] = dlen >> 24;
+    scsiDev.data[1] = dlen >> 16;
+    scsiDev.data[2] = dlen >> 8;
+    scsiDev.data[3] = dlen;
+
+    if (len > allocationLength)
+    {
+        len = allocationLength;
+    }
+    scsiDev.dataLen = len;
+    scsiDev.phase = DATA_IN;
+}
+
 /****************************************/
 /* CUE sheet check at image load time   */
 /****************************************/
@@ -817,9 +1155,22 @@ void cdromPerformEject(image_config_t &img)
     // terminate audio playback if active on this target (MMC-1 Annex C)
     audio_stop(target);
 #endif
-    dbgmsg("------ CDROM open tray on ID ", (int)target);
-    img.ejected = true;
-    img.cdrom_events = 3; // Media removal
+    if (!img.ejected)
+    {
+        dbgmsg("------ CDROM open tray on ID ", (int)target);
+        img.ejected = true;
+        img.cdrom_events = 3; // Media removal
+    }
+    else
+    {
+        dbgmsg("------ CDROM close tray on ID ", (int)target);
+        if (!cdromSwitchNextImage(img))
+        {
+            // Reinsert the single image
+            img.ejected = false;
+            img.cdrom_events = 2; // New media
+        }
+    }
 }
 
 // Reinsert any ejected CDROMs on reboot
@@ -829,7 +1180,8 @@ void cdromReinsertFirstImage(image_config_t &img)
     {
         // Multiple images for this drive, force restart from first one
         dbgmsg("---- Restarting from first CD-ROM image");
-        img.image_index = 9;
+        img.image_index = IMAGE_INDEX_MAX;
+        img.current_image[0] = '\0';
         cdromSwitchNextImage(img);
     }
     else if (img.ejected)
@@ -845,14 +1197,9 @@ void cdromReinsertFirstImage(image_config_t &img)
 bool cdromSwitchNextImage(image_config_t &img)
 {
     // Check if we have a next image to load, so that drive is closed next time the host asks.
-    img.image_index++;
     char filename[MAX_FILE_PATH];
     int target_idx = img.scsiId & 7;
-    if (!scsiDiskGetImageNameFromConfig(img, filename, sizeof(filename)))
-    {
-        img.image_index = 0;
-        scsiDiskGetImageNameFromConfig(img, filename, sizeof(filename));
-    }
+    scsiDiskGetNextImageName(img, filename, sizeof(filename));
 
 #ifdef ENABLE_AUDIO_OUTPUT
     // if in progress for this device, terminate audio playback immediately (Annex C)
@@ -904,7 +1251,7 @@ static void doGetEventStatusNotification(bool immed)
         scsiDev.phase = DATA_IN;
         img.cdrom_events = 0;
 
-        if (img.ejected)
+        if (img.ejected && img.reinsert_after_eject)
         {
             // We are now reporting to host that the drive is open.
             // Simulate a "close" for next time the host polls.
@@ -1492,6 +1839,10 @@ extern "C" int scsiCDRomCommand()
     uint8_t command = scsiDev.cdb[0];
     if (command == 0x1B)
     {
+#if ENABLE_AUDIO_OUTPUT
+        // terminate audio playback if active on this target (MMC-1 Annex C)
+        audio_stop(img.scsiId & 7);
+#endif
         if ((scsiDev.cdb[4] & 2))
         {
             // CD-ROM load & eject
@@ -1507,11 +1858,6 @@ extern "C" int scsiCDRomCommand()
                 cdromPerformEject(img);
             }
         }
-        else
-        {
-            // flow through to disk handler
-            commandHandled = 0;
-        }
     }
     else if (command == 0x25)
     {
@@ -1597,6 +1943,18 @@ extern "C" int scsiCDRomCommand()
             scsiDev.cdb[8];
         doReadHeader(MSF, lba, allocationLength);
     }
+    else if (command == 0x46)
+    {
+        // GET CONFIGURATION
+        uint8_t rt = (scsiDev.cdb[1] & 0x03);
+        uint16_t startFeature =
+            (((uint16_t) scsiDev.cdb[2]) << 8) +
+            scsiDev.cdb[3];
+        uint16_t allocationLength =
+            (((uint32_t) scsiDev.cdb[7]) << 8) +
+            scsiDev.cdb[8];
+        doGetConfiguration(rt, startFeature, allocationLength);
+    }
     else if (command == 0x51)
     {
         uint16_t allocationLength =
@@ -1604,6 +1962,19 @@ extern "C" int scsiCDRomCommand()
             scsiDev.cdb[8];
         doReadDiscInformation(allocationLength);
     }
+    else if (command == 0x52)
+    {
+        // READ TRACK INFORMATION
+        bool track = (scsiDev.cdb[1] & 0x01);
+        uint32_t lba = (((uint32_t) scsiDev.cdb[2]) << 24) +
+            (((uint32_t) scsiDev.cdb[3]) << 16) +
+            (((uint32_t) scsiDev.cdb[4]) << 8) +
+            scsiDev.cdb[5];
+        uint16_t allocationLength =
+            (((uint32_t) scsiDev.cdb[7]) << 8) +
+            scsiDev.cdb[8];
+        doReadTrackInformation(track, lba, allocationLength);
+    }
     else if (command == 0x4A)
     {
         // Get event status notifications (media change notifications)
@@ -1724,6 +2095,7 @@ extern "C" int scsiCDRomCommand()
             (((uint32_t) scsiDev.cdb[2]) << 8) +
             scsiDev.cdb[3];
         uint32_t blocks = scsiDev.cdb[4];
+        if (blocks == 0) blocks = 256;
 
         doReadCD(lba, blocks, 0, 0x10, 0, true);
     }

+ 3 - 0
src/ZuluSCSI_config.h

@@ -56,6 +56,9 @@
 #define HDIMG_BLK_POS 5                 // Position to embed block size numbers
 #define MAX_FILE_PATH 64                // Maximum file name length
 
+// Image definition options
+#define IMAGE_INDEX_MAX 9               // Maximum number of 'IMG0' style statements parsed
+
 // SCSI config
 #define NUM_SCSIID  8          // Maximum number of supported SCSI-IDs (The minimum is 0)
 #define NUM_SCSILUN 1          // Maximum number of LUNs supported     (Currently has to be 1)

+ 222 - 17
src/ZuluSCSI_disk.cpp

@@ -174,6 +174,8 @@ void scsiDiskCloseSDCardImages()
         {
             g_DiskImages[i].file.close();
         }
+
+        g_DiskImages[i].cuesheetfile.close();
     }
 }
 
@@ -326,6 +328,7 @@ static void setDefaultDriveInfo(int target_idx)
 bool scsiDiskOpenHDDImage(int target_idx, const char *filename, int scsi_id, int scsi_lun, int blocksize, S2S_CFG_TYPE type)
 {
     image_config_t &img = g_DiskImages[target_idx];
+    img.cuesheetfile.close();
     img.file = ImageBackingStore(filename, blocksize);
 
     if (img.file.isOpen())
@@ -422,8 +425,11 @@ bool scsiDiskOpenHDDImage(int target_idx, const char *filename, int scsi_id, int
 
         return true;
     }
-
-    return false;
+    else
+    {
+        logmsg("---- Failed to load image '", filename, "', ignoring");
+        return false;
+    }
 }
 
 static void checkDiskGeometryDivisible(image_config_t &img)
@@ -443,6 +449,43 @@ static void checkDiskGeometryDivisible(image_config_t &img)
     }
 }
 
+bool scsiDiskFilenameValid(const char* name)
+{
+    // Check file extension
+    const char *extension = strrchr(name, '.');
+    if (extension)
+    {
+        const char *ignore_exts[] = {
+            ".rom_loaded", ".cue",
+            NULL
+        };
+        const char *archive_exts[] = {
+            ".tar", ".tgz", ".gz", ".bz2", ".tbz2", ".xz", ".zst", ".z",
+            ".zip", ".zipx", ".rar", ".lzh", ".lha", ".lzo", ".lz4", ".arj",
+            ".dmg", ".hqx", ".cpt", ".7z", ".s7z",
+            NULL
+        };
+
+        for (int i = 0; ignore_exts[i]; i++)
+        {
+            if (strcasecmp(extension, ignore_exts[i]) == 0)
+            {
+                // ignore these without log message
+                return false;
+            }
+        }
+        for (int i = 0; archive_exts[i]; i++)
+        {
+            if (strcasecmp(extension, archive_exts[i]) == 0)
+            {
+                logmsg("-- Ignoring compressed file ", name);
+                return false;
+            }
+        }
+    }
+    return true;
+}
+
 // Set target configuration to default values
 static void scsiDiskConfigDefaults(int target_idx)
 {
@@ -478,6 +521,7 @@ static void scsiDiskLoadConfig(int target_idx, const char *section)
     img.rightAlignStrings = ini_getbool(section, "RightAlignStrings", 0, CONFIGFILE);
     img.prefetchbytes = ini_getl(section, "PrefetchBytes", img.prefetchbytes, CONFIGFILE);
     img.reinsert_on_inquiry = ini_getbool(section, "ReinsertCDOnInquiry", 1, CONFIGFILE);
+    img.reinsert_after_eject = ini_getbool(section, "ReinsertAfterEject", 1, CONFIGFILE);
     img.ejectButton = ini_getl(section, "EjectButton", 0, CONFIGFILE);
 
     char tmp[32];
@@ -496,21 +540,171 @@ static void scsiDiskLoadConfig(int target_idx, const char *section)
     memset(tmp, 0, sizeof(tmp));
     ini_gets(section, "Serial", "", tmp, sizeof(tmp), CONFIGFILE);
     if (tmp[0]) memcpy(img.serial, tmp, sizeof(img.serial));
+
+    if (strlen(section) == 5 && strncmp(section, "SCSI", 4) == 0) // allow within target [SCSIx] blocks only
+    {
+        ini_gets(section, "ImgDir", "", tmp, sizeof(tmp), CONFIGFILE);
+        if (tmp[0])
+        {
+            logmsg("-- SCSI", target_idx, " using image directory \'", tmp, "'");
+            img.image_directory = true;
+        }
+    }
+}
+
+// Finds filename with the lowest lexical order _after_ the given filename in
+// the given folder. If there is no file after the given one, or if there is
+// no current file, this will return the lowest filename encountered.
+static int findNextImageAfter(image_config_t &img,
+        const char* dirname, const char* filename,
+        char* buf, size_t buflen)
+{
+    FsFile dir;
+    if (dirname[0] == '\0')
+    {
+        logmsg("Image directory name invalid for ID", (img.scsiId & 7));
+        return 0;
+    }
+    if (!dir.open(dirname))
+    {
+        logmsg("Image directory '", dirname, "' couldn't be opened");
+    }
+    if (!dir.isDir())
+    {
+        logmsg("Can't find images in '", dirname, "', not a directory");
+        dir.close();
+        return 0;
+    }
+
+    char first_name[MAX_FILE_PATH] = {'\0'};
+    char candidate_name[MAX_FILE_PATH] = {'\0'};
+    FsFile file;
+    while (file.openNext(&dir, O_RDONLY))
+    {
+        if (file.isDir()) continue;
+        if (!file.getName(buf, MAX_FILE_PATH))
+        {
+            logmsg("Image directory '", dirname, "'had invalid file");
+            continue;
+        }
+        if (!scsiDiskFilenameValid(buf)) continue;
+
+        // keep track of the first item to allow wrapping
+        // without having to iterate again
+        if (first_name[0] == '\0' || strcasecmp(buf, first_name) < 0)
+        {
+            strncpy(first_name, buf, sizeof(first_name));
+        }
+
+        // discard if no selected name, or if candidate is before (or is) selected
+        if (filename[0] == '\0' || strcasecmp(buf, filename) <= 0) continue;
+
+        // if we got this far and the candidate is either 1) not set, or 2) is a
+        // lower item than what has been encountered thus far, it is the best choice
+        if (candidate_name[0] == '\0' || strcasecmp(buf, candidate_name) < 0)
+        {
+            strncpy(candidate_name, buf, sizeof(candidate_name));
+        }
+    }
+
+    if (candidate_name[0] != '\0')
+    {
+        img.image_index++;
+        strncpy(img.current_image, candidate_name, sizeof(img.current_image));
+        strncpy(buf, candidate_name, buflen);
+        return strlen(candidate_name);
+    }
+    else if (first_name[0] != '\0')
+    {
+        img.image_index = 0;
+        strncpy(img.current_image, first_name, sizeof(img.current_image));
+        strncpy(buf, first_name, buflen);
+        return strlen(first_name);
+    }
+    else
+    {
+        logmsg("Image directory '", dirname, "' was empty");
+        return 0;
+    }
 }
 
-// Check if image file name is overridden in config
-bool scsiDiskGetImageNameFromConfig(image_config_t &img, char *buf, size_t buflen)
+int scsiDiskGetNextImageName(image_config_t &img, char *buf, size_t buflen)
 {
     int target_idx = img.scsiId & 7;
 
     char section[6] = "SCSI0";
     section[4] = '0' + target_idx;
 
-    char key[5] = "IMG0";
-    key[3] = '0' + img.image_index;
+    // sanity check: is provided buffer is long enough to store a filename?
+    assert(buflen >= MAX_FILE_PATH);
 
-    ini_gets(section, key, "", buf, buflen, CONFIGFILE);
-    return buf[0] != '\0';
+    if (img.image_directory)
+    {
+        // image directory was found during startup
+        char dirname[MAX_FILE_PATH];
+        char key[] = "ImgDir";
+        int dirlen = ini_gets(section, key, "", dirname, sizeof(dirname), CONFIGFILE);
+        if (!dirlen)
+        {
+            // If image_directory set but ImageDir is not, could be used to
+            // indicate an image directory configured via folder structure.
+            // Not implemented, so treat this as equivalent to missing ImageDir
+            return 0;
+        }
+
+        // find the next filename
+        char nextname[MAX_FILE_PATH];
+        int nextlen = findNextImageAfter(img, dirname, img.current_image, nextname, sizeof(nextname));
+
+        if (nextlen == 0)
+        {
+            logmsg("Image directory was empty for ID", target_idx);
+            return 0;
+        }
+        else if (buflen < nextlen + dirlen + 2)
+        {
+            logmsg("Directory '", dirname, "' and file '", nextname, "' exceed allowed length");
+            return 0;
+        }
+        else
+        {
+            // construct a return value
+            strncpy(buf, dirname, buflen);
+            if (buf[strlen(buf) - 1] != '/') strcat(buf, "/");
+            strcat(buf, nextname);
+            return dirlen + nextlen;
+        }
+    }
+    else
+    {
+        img.image_index++;
+        if (img.image_index > IMAGE_INDEX_MAX)
+        {
+            img.image_index = 0;
+        }
+
+        char key[5] = "IMG0";
+        key[3] = '0' + img.image_index;
+
+        int ret = ini_gets(section, key, "", buf, buflen, CONFIGFILE);
+        if (buf[0] != '\0')
+        {
+            return ret;
+        }
+        else if (img.image_index > 0)
+        {
+            // there may be more than one image but we've ran out of new ones
+            // wrap back to the first image
+            img.image_index = IMAGE_INDEX_MAX;
+            return scsiDiskGetNextImageName(img, buf, buflen);
+        }
+        else
+        {
+            // images are not defined in config
+            img.image_index = IMAGE_INDEX_MAX;
+            return 0;
+        }
+    }
 }
 
 void scsiDiskLoadConfig(int target_idx)
@@ -530,10 +724,11 @@ void scsiDiskLoadConfig(int target_idx)
     // Check if we have image specified by name
     char filename[MAX_FILE_PATH];
     image_config_t &img = g_DiskImages[target_idx];
-    if (scsiDiskGetImageNameFromConfig(img, filename, sizeof(filename)))
+    img.image_index = IMAGE_INDEX_MAX;
+    if (scsiDiskGetNextImageName(img, filename, sizeof(filename)))
     {
         int blocksize = (img.deviceType == S2S_CFG_OPTICAL) ? 2048 : 512;
-        logmsg("-- Opening ", filename, " for id:", target_idx, ", specified in " CONFIGFILE);
+        logmsg("-- Opening '", filename, "' for id:", target_idx, ", specified in " CONFIGFILE);
         scsiDiskOpenHDDImage(target_idx, filename, target_idx, 0, blocksize);
     }
 }
@@ -559,18 +754,25 @@ image_config_t &scsiDiskGetImageConfig(int target_idx)
 
 static void diskEjectAction(uint8_t buttonId)
 {
-    logmsg("Eject button pressed for channel ", buttonId);
+    bool found = false;
     for (uint8_t i = 0; i < S2S_MAX_TARGETS; i++)
     {
-        image_config_t img = g_DiskImages[i];
+        image_config_t &img = g_DiskImages[i];
         if (img.ejectButton == buttonId)
         {
             if (img.deviceType == S2S_CFG_OPTICAL)
             {
+                found = true;
+                logmsg("Eject button ", (int)buttonId, " pressed, passing to CD drive SCSI", (int)i);
                 cdromPerformEject(img);
             }
         }
     }
+
+    if (!found)
+    {
+        logmsg("Eject button ", (int)buttonId, " pressed, but no drives with EjectButton=", (int)buttonId, " setting found!");
+    }
 }
 
 uint8_t diskEjectButtonUpdate(bool immediate)
@@ -897,9 +1099,12 @@ static int doTestUnitReady()
         scsiDev.target->sense.asc = MEDIUM_NOT_PRESENT;
         scsiDev.phase = STATUS;
 
-        // We are now reporting to host that the drive is open.
-        // Simulate a "close" for next time the host polls.
-        cdromSwitchNextImage(img);
+        if (img.reinsert_after_eject)
+        {
+            // We are now reporting to host that the drive is open.
+            // Simulate a "close" for next time the host polls.
+            cdromSwitchNextImage(img);
+        }
     }
     else if (unlikely(!(blockDev.state & DISK_PRESENT)))
     {
@@ -1275,7 +1480,7 @@ void scsiDiskStartRead(uint32_t lba, uint32_t blocks)
 
         if (transfer.currentBlock == transfer.blocks)
         {
-            while (!scsiIsWriteFinished(NULL))
+            while (!scsiIsWriteFinished(NULL) && !scsiDev.resetFlag)
             {
                 platform_poll();
                 diskEjectButtonUpdate(false);
@@ -1455,7 +1660,7 @@ static void diskDataIn()
         }
 #endif
 
-        while (!scsiIsWriteFinished(NULL))
+        while (!scsiIsWriteFinished(NULL) && !scsiDev.resetFlag)
         {
             platform_poll();
             diskEjectButtonUpdate(false);

+ 22 - 5
src/ZuluSCSI_disk.h

@@ -32,6 +32,7 @@
 #include <scsi2sd.h>
 #include <scsiPhy.h>
 #include "ImageBackingStore.h"
+#include "ZuluSCSI_config.h"
 
 extern "C" {
 #include <disk.h>
@@ -42,12 +43,17 @@ extern "C" {
 // Extended configuration stored alongside the normal SCSI2SD target information
 struct image_config_t: public S2S_TargetCfg
 {
+    // There should be only one global instance of this struct per device, so disallow copy constructor.
+    image_config_t() = default;
+    image_config_t(const image_config_t&) = delete;
+
     ImageBackingStore file;
 
     // For CD-ROM drive ejection
     bool ejected;
     uint8_t cdrom_events;
-    bool reinsert_on_inquiry;
+    bool reinsert_on_inquiry; // Reinsert on Inquiry command (to reinsert automatically after boot)
+    bool reinsert_after_eject; // Reinsert next image after ejection
 
     // selects a physical button channel that will cause an eject action
     // default option of '0' disables this functionality
@@ -56,8 +62,13 @@ struct image_config_t: public S2S_TargetCfg
     // For tape drive emulation, current position in blocks
     uint32_t tape_pos;
 
+    // True if there is a subdirectory of images for this target
+    bool image_directory;
+    // the name of the currently mounted image in a dynamic image directory
+    char current_image[MAX_FILE_PATH];
     // Index of image, for when image on-the-fly switching is used for CD drives
-    int image_index;
+    // This is also used for dynamic directories to track how many images have been seen
+    uint8_t image_index;
 
     // Cue sheet file for CD-ROM images
     FsFile cuesheetfile;
@@ -88,6 +99,10 @@ void scsiDiskCloseSDCardImages();
 bool scsiDiskOpenHDDImage(int target_idx, const char *filename, int scsi_id, int scsi_lun, int blocksize, S2S_CFG_TYPE type = S2S_CFG_FIXED);
 void scsiDiskLoadConfig(int target_idx);
 
+// Checks if a filename extension is appropriate for further processing as a disk image.
+// The current implementation does not check the the filename prefix for validity.
+bool scsiDiskFilenameValid(const char* name);
+
 // Clear the ROM drive header from flash
 bool scsiDiskClearRomDrive();
 // Program ROM drive and rename image file
@@ -100,9 +115,11 @@ bool scsiDiskActivateRomDrive();
 // Returns true if there is at least one image active
 bool scsiDiskCheckAnyImagesConfigured();
 
-// Check if image file name is overridden in config,
-// including image index for multi-image CD-ROM emulation
-bool scsiDiskGetImageNameFromConfig(image_config_t &img, char *buf, size_t buflen);
+// Gets the next image filename for the target, if configured for multiple
+// images. As a side effect this advances image tracking to the next image.
+// Returns the length of the new image filename, or 0 if the target is not
+// configured for multiple images.
+int scsiDiskGetNextImageName(image_config_t &img, char *buf, size_t buflen);
 
 // Get pointer to extended image configuration based on target idx
 image_config_t &scsiDiskGetImageConfig(int target_idx);

+ 220 - 0
src/ZuluSCSI_mode.cpp

@@ -0,0 +1,220 @@
+/**
+ * Copyright (C) 2013 Michael McMaster <michael@codesrc.com>
+ * Copyright (C) 2014 Doug Brown <doug@downtowndougbrown.com>
+ * Copyright (C) 2019 Landon Rodgers <g.landon.rodgers@gmail.com>
+ * ZuluSCSI™ - Copyright (c) 2023 Rabbit Hole Computing™
+ *
+ * ZuluSCSI™ firmware is licensed under the GPL version 3 or any later version. 
+ *
+ * https://www.gnu.org/licenses/gpl-3.0.html
+ * ----
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. 
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details. 
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+**/
+
+#include <stdint.h>
+#include <string.h>
+
+#ifdef ENABLE_AUDIO_OUTPUT
+#include "ZuluSCSI_audio.h"
+#endif
+#include "ZuluSCSI_cdrom.h"
+#include "ZuluSCSI_log.h"
+
+extern "C" {
+#include "ZuluSCSI_mode.h"
+}
+
+static const uint8_t CDROMCDParametersPage[] =
+{
+0x0D, // page code
+0x06, // page length
+0x00, // reserved
+0x0D, // reserved, inactivity time 8 min
+0x00, 0x3C, // 60 seconds per MSF M unit
+0x00, 0x4B  // 75 frames per MSF S unit
+};
+
+#ifdef ENABLE_AUDIO_OUTPUT
+static const uint8_t CDROMAudioControlParametersPage[] =
+{
+0x0E, // page code
+0x0E, // page length
+0x04, // 'Immed' bit set, 'SOTC' bit not set
+0x00, // reserved
+0x00, // reserved
+0x80, // 1 LBAs/sec multip
+0x00, 0x4B, // 75 LBAs/sec
+0x01, 0xFF, // output port 0 active, max volume
+0x02, 0xFF, // output port 1 active, max volume
+0x00, 0x00, // output port 2 inactive
+0x00, 0x00 // output port 3 inactive
+};
+#endif
+
+// 0x2A CD-ROM Capabilities and Mechanical Status Page
+// This seems to have been standardized in MMC-1 but was de-facto present in
+// earlier SCSI-2 drives. The below mirrors one of those earlier SCSI-2
+// implementations, being is slightly shorter than the spec format but
+// otherwise returning identical information within the same bytes.
+static const uint8_t CDROMCapabilitiesPage[] =
+{
+0x2A, // page code
+0x0E, // page length
+0x00, // CD-R/RW reading not supported
+0x00, // CD-R/RW writing not supported
+#ifdef ENABLE_AUDIO_OUTPUT
+0x01, // byte 4: audio play supported
+#else
+0x00, // byte 4: no features supported
+#endif
+0x03, // byte 5: CD-DA ok with accurate streaming, no other features
+0x28, // byte 6: tray loader, ejection ok, but prevent/allow not supported
+#ifdef ENABLE_AUDIO_OUTPUT
+0x03, // byte 7: separate channel mute and volumes
+#else
+0x00, // byte 7: no features supported
+#endif
+0x05, 0x62, // max read speed, state (8X, ~1378KB/s)
+#ifdef ENABLE_AUDIO_OUTPUT
+0x01, 0x00,  // 256 volume levels supported
+#else
+0x00, 0x00,  // no volume levels supported
+#endif
+0x00, 0x40, // read buffer (64KB)
+0x05, 0x62, // current read speed, matching max speed
+};
+
+static void pageIn(int pc, int dataIdx, const uint8_t* pageData, int pageLen)
+{
+    memcpy(&scsiDev.data[dataIdx], pageData, pageLen);
+
+    if (pc == 0x01) // Mask out (un)changable values
+    {
+        memset(&scsiDev.data[dataIdx+2], 0, pageLen - 2);
+    }
+}
+
+extern "C"
+int modeSenseCDDevicePage(int pc, int idx, int pageCode, int* pageFound)
+{
+    if ((scsiDev.target->cfg->deviceType == S2S_CFG_OPTICAL)
+        && (pageCode == 0x0D || pageCode == 0x3F))
+    {
+        *pageFound = 1;
+        pageIn(
+            pc,
+            idx,
+            CDROMCDParametersPage,
+            sizeof(CDROMCDParametersPage));
+        return sizeof(CDROMCDParametersPage);
+    }
+    else
+    {
+        return 0;
+    }
+}
+
+extern "C"
+int modeSenseCDAudioControlPage(int pc, int idx, int pageCode, int* pageFound)
+{
+#ifdef ENABLE_AUDIO_OUTPUT
+    if ((scsiDev.target->cfg->deviceType == S2S_CFG_OPTICAL)
+        && (pageCode == 0x0E || pageCode == 0x3F))
+    {
+        *pageFound = 1;
+        pageIn(
+            pc,
+            idx,
+            CDROMAudioControlParametersPage,
+            sizeof(CDROMAudioControlParametersPage));
+        if (pc == 0x00)
+        {
+            // report current port assignments and volume level
+            uint16_t chn = audio_get_channel(scsiDev.target->targetId);
+            uint16_t vol = audio_get_volume(scsiDev.target->targetId);
+            scsiDev.data[idx+8] = chn & 0xFF;
+            scsiDev.data[idx+9] = vol & 0xFF;
+            scsiDev.data[idx+10] = chn >> 8;
+            scsiDev.data[idx+11] = vol >> 8;
+        }
+        else if (pc == 0x01)
+        {
+            // report bits that can be set
+            scsiDev.data[idx+8] = 0xFF;
+            scsiDev.data[idx+9] = 0xFF;
+            scsiDev.data[idx+10] = 0xFF;
+            scsiDev.data[idx+11] = 0xFF;
+        }
+        else
+        {
+            // report defaults for 0x02
+            // also report same for 0x03, though we are actually supposed
+            // to terminate with CHECK CONDITION and SAVING PARAMETERS NOT SUPPORTED
+            scsiDev.data[idx+8] = AUDIO_CHANNEL_ENABLE_MASK & 0xFF;
+            scsiDev.data[idx+9] = DEFAULT_VOLUME_LEVEL & 0xFF;
+            scsiDev.data[idx+10] = AUDIO_CHANNEL_ENABLE_MASK >> 8;
+            scsiDev.data[idx+11] = DEFAULT_VOLUME_LEVEL >> 8;
+        }
+        return sizeof(CDROMAudioControlParametersPage);
+    }
+    else
+    {
+        return 0;
+    }
+#else
+    return 0;
+#endif
+}
+
+int modeSenseCDCapabilitiesPage(int pc, int idx, int pageCode, int* pageFound)
+{
+    if ((scsiDev.target->cfg->deviceType == S2S_CFG_OPTICAL)
+        && (pageCode == 0x2A || pageCode == 0x3F))
+    {
+        *pageFound = 1;
+        pageIn(
+            pc,
+            idx,
+            CDROMCapabilitiesPage,
+            sizeof(CDROMCapabilitiesPage));
+        return sizeof(CDROMCapabilitiesPage);
+    }
+    else
+    {
+        return 0;
+    }
+}
+
+extern "C"
+int modeSelectCDAudioControlPage(int pageLen, int idx)
+{
+#ifdef ENABLE_AUDIO_OUTPUT
+    if (scsiDev.target->cfg->deviceType == S2S_CFG_OPTICAL)
+    {
+        if (pageLen != 0x0E) return 0;
+        uint16_t chn = (scsiDev.data[idx+10] << 8) + scsiDev.data[idx+8];
+        uint16_t vol = (scsiDev.data[idx+11] << 8) + scsiDev.data[idx+9];
+        dbgmsg("------ CD audio control page channels (", chn, "), volume (", vol, ")");
+        audio_set_channel(scsiDev.target->targetId, chn);
+        audio_set_volume(scsiDev.target->targetId, vol);
+        return 1;
+    }
+    else
+    {
+        return 0;
+    }
+#else
+    return 0;
+#endif
+}

+ 28 - 0
src/ZuluSCSI_mode.h

@@ -0,0 +1,28 @@
+/**
+ * ZuluSCSI™ - Copyright (c) 2023 Rabbit Hole Computing™
+ *
+ * ZuluSCSI™ firmware is licensed under the GPL version 3 or any later version. 
+ *
+ * https://www.gnu.org/licenses/gpl-3.0.html
+ * ----
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version. 
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details. 
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <https://www.gnu.org/licenses/>.
+**/
+
+#pragma once
+
+int modeSenseCDDevicePage(int pc, int idx, int pageCode, int* pageFound);
+int modeSenseCDAudioControlPage(int pc, int idx, int pageCode, int* pageFound);
+int modeSenseCDCapabilitiesPage(int pc, int idx, int pageCode, int* pageFound);
+
+int modeSelectCDAudioControlPage(int pageLen, int idx);

+ 2 - 0
zuluscsi.ini

@@ -43,6 +43,8 @@
 #RightAlignStrings = 0 # Right-align SCSI vendor / product strings, defaults on if Quirks = 1
 #PrefetchBytes = 8192 # Maximum number of bytes to prefetch after a read request, 0 to disable
 #ReinsertCDOnInquiry = 1 # Reinsert any ejected CD-ROM image on Inquiry command
+#ReinsertAfterEject = 1 # Reinsert next CD image after eject, if multiple images configured.
+#EjectButton = 0 # Enable eject by button 1 or 2, or set 0 to disable
 
 # Settings can be overridden for individual devices.
 #[SCSI2]