diff --git a/source/frontends/libretro/README.md b/source/frontends/libretro/README.md index 0db74a3fa..4be3d068a 100644 --- a/source/frontends/libretro/README.md +++ b/source/frontends/libretro/README.md @@ -47,6 +47,71 @@ In order to have a better experience with the keyboard, one should probably enab Easiest way to run from the ``build`` folder: ``retroarch -L source/frontends/libretro/applewin_libretro.so ../bin/MASTER.DSK`` -It supports playlists files `.m3u` (see https://docs.libretro.com/library/vice/#m3u-and-disk-control alttough not all options are implemented). - The core can be statically linked in Linux and MSYS2, pass `-DSTATIC_LINKING=ON` to `cmake`, or disable networking `-DENABLE_NETWORKING=OFF`. + +## M3U Playlists and Disk Control + +The core cupports libretro [Disk Control Interface](https://docs.libretro.com/guides/disc-swapping/), allowing disk swaps from the frontend UI (the drive is selectable via a core option). + +### M3U format + +A `.m3u` file lists disk images, one per line. Paths are relative to the M3U file's directory, similar to [VICE](https://docs.libretro.com/library/vice/#m3u-and-disk-control). + +``` +Ultima V - Disk A.dsk +Ultima V - Disk B.dsk +``` + +### Supported directives + +| Directive | Description | +| --------- | ----------- | +| `#` | Comment (line is ignored) | +| `#SAVEDISK:` | Creates a writable blank disk in the save directory | +| `#LABEL:` | Sets the display label for the next file entry | +| `#EXTINF:` | Standard M3U extended info - label follows the first comma | +| `\|` (pipe in filename) | `path.dsk\|Label` sets a display label for that entry | + +Label priority: `#LABEL:` / `#EXTINF:` override pipe labels. + +#### `#SAVEDISK:` + +Adds a writable save disk to the playlist. The disk image is created (as a blank `.dsk`) in the save directory on first use and reused on subsequent launches. + +``` +Game - Side A.dsk +Game - Side B.dsk +#SAVEDISK:Character +#SAVEDISK: +``` + +The label after `#SAVEDISK:` is optional. If omitted, an automatic index is used. The resulting label shown in the frontend is always prefixed with "Save Disk ". + +Note: the created disk is unformatted (all zeros). Most Apple II games that request a save disk will format it themselves. + +#### Pipe-delimited labels + +``` +Game - Side A (Disk 1 of 3).dsk|Side A +Game - Side B (Disk 2 of 3).dsk|Side B +``` + +The text after `|` becomes the display label in the frontend's disk control UI instead of the filename stem. + +### MultiDrive + +When enabled, the second disk in the playlist is automatically inserted into DRIVE_2 at load time. This is useful for games that expect both drives populated (e.g. Ultima, Wizardry). + +MultiDrive is triggered by either: +- Naming the M3U file with `(MD)` in the stem, e.g. `Ultima V (MD).m3u` +- Enabling the **Floppy MultiDrive** core option + +Save disks (`#SAVEDISK:`) are never auto-inserted into DRIVE_2. + +### Core options + +| Option | Values | Description | +| ------ | ------ | ----------- | +| Disk Control Drive | Drive 1, 2 | Active drive for the Disk Control Interface. | +| Playlist Start Disk | First, Previous | Whether to start from disk 1 or resume from the previously used disk | +| Floppy MultiDrive | Disabled, Enabled | Auto-insert second disk into DRIVE_2 for all playlists | diff --git a/source/frontends/libretro/diskcontrol.cpp b/source/frontends/libretro/diskcontrol.cpp index 2622b9e2b..596d8a48d 100644 --- a/source/frontends/libretro/diskcontrol.cpp +++ b/source/frontends/libretro/diskcontrol.cpp @@ -15,8 +15,11 @@ namespace { const std::string M3U_COMMENT("#"); + const std::string M3U_LABEL("#LABEL:"); + const std::string M3U_EXTINF("#EXTINF:"); const std::string M3U_SAVEDISK("#SAVEDISK:"); - const std::string M3U_SAVEDISK_LABEL("Save Disk "); + + const std::string SAVEDISK_LABEL("Save Disk "); bool startsWith(const std::string &value, const std::string &prefix) { @@ -65,8 +68,8 @@ namespace ra2 { DiskControl::DiskControl() - : myEjected(true) - , myIndex(0) + : myEjected{true, true} + , myIndex{0, 0} { } @@ -75,6 +78,11 @@ namespace ra2 return myCurrentDiskFolder; } + Drive_e DiskControl::activeDrive() const + { + return ra2::getDiskControlDrive(); + } + void DiskControl::storeCurrentDiskFolder(const std::string &path) { // a bit of a workaround to a save state issue, where the disk folder is lost @@ -83,19 +91,19 @@ namespace ra2 myCurrentDiskFolder = filePath.parent_path().string(); } - bool DiskControl::insertDisk(const std::string &path) + bool DiskControl::insertDisk(const Drive_e drive, const std::string &path) { const bool writeProtected = IMAGE_FORCE_WRITE_PROTECTED; const bool createIfNecessary = IMAGE_DONT_CREATE; - if (insertFloppyDisk(path, writeProtected, createIfNecessary)) + if (insertFloppyDisk(drive, path, writeProtected, createIfNecessary)) { - myIndex = 0; + myIndex[drive] = 0; myImages.clear(); const std::filesystem::path filePath(path); myImages.push_back({filePath.string(), filePath.stem().string(), writeProtected, createIfNecessary}); - myEjected = false; + myEjected[drive] = false; return true; } @@ -117,11 +125,13 @@ namespace ra2 const std::string playlistStem = playlistPath.stem().string(); std::string line; + std::string pendingLabel; while (std::getline(playlist, line)) { // should we trim initial spaces? if (startsWith(line, M3U_SAVEDISK)) { + pendingLabel.clear(); const size_t index = myImages.size() + 1; const std::string filename = StrFormat("%s.save%" SIZE_T_FMT ".dsk", playlistStem.c_str(), index); @@ -134,12 +144,26 @@ namespace ra2 } // Always prefix with "Save Disk" - const std::string label = M3U_SAVEDISK_LABEL + labelSuffix; + const std::string label = SAVEDISK_LABEL + labelSuffix; const std::filesystem::path imagePath = savePath / filename; // TODO: this disk is NOT formatted - myImages.push_back({imagePath.string(), label, IMAGE_USE_FILES_WRITE_PROTECT_STATUS, IMAGE_CREATE}); + myImages.push_back( + {imagePath.string(), label, IMAGE_USE_FILES_WRITE_PROTECT_STATUS, IMAGE_CREATE, true}); + } + else if (startsWith(line, M3U_LABEL)) + { + pendingLabel = line.substr(M3U_LABEL.size()); + } + else if (startsWith(line, M3U_EXTINF)) + { + // #EXTINF: standard - label follows the first comma + const size_t comma = line.find(',', M3U_EXTINF.size()); + if (comma != std::string::npos) + { + pendingLabel = line.substr(comma + 1); + } } else if (!startsWith(line, M3U_COMMENT)) { @@ -147,16 +171,23 @@ namespace ra2 std::string label; getLabelAndPath(line, imagePath, label); + // #LABEL: or #EXTINF: override pipe label + if (!pendingLabel.empty()) + { + label = pendingLabel; + pendingLabel.clear(); + } + if (imagePath.is_relative()) { imagePath = parent / imagePath; } - myImages.push_back({imagePath.string(), label, IMAGE_FORCE_WRITE_PROTECTED, IMAGE_DONT_CREATE}); + myImages.push_back({imagePath.string(), label, IMAGE_FORCE_WRITE_PROTECTED, IMAGE_DONT_CREATE, false}); } } // insert the first image by default - myIndex = 0; + myIndex[DRIVE_1] = 0; const PlaylistStartDisk playlistStartDisk = getPlaylistStartDisk(); @@ -166,7 +197,7 @@ namespace ra2 if (!ourInitialPath.empty() && ourInitialIndex < myImages.size() && myImages[ourInitialIndex].path == ourInitialPath) { - myIndex = ourInitialIndex; + myIndex[DRIVE_1] = ourInitialIndex; // do we need to reset for next time? ourInitialPath.clear(); ourInitialIndex = 0; @@ -174,20 +205,41 @@ namespace ra2 } // this is safe even if myImages is empty - myEjected = true; - return setEjectedState(false); + myEjected[DRIVE_1] = true; + const bool inserted = setEjectedStateForDrive(DRIVE_1, false); + + // MultiDrive support: if M3U filename constains "(MD") or option is enabled, insert second disk into DRIVE_2 + // Only when starting from disk 0 to avoid conflicts with Previous resume + // Skip save disks - those are for manual swap only + if (inserted && myIndex[DRIVE_1] == 0 && myImages.size() > 1 && + (playlistStem.find("(MD") != std::string::npos || ra2::getFloppyMultiDrive())) + { + const auto &image = myImages[1]; + if (!image.isSaveDisk) + { + insertFloppyDisk(DRIVE_2, image.path, image.writeProtected, image.createIfNecessary); + myEjected[DRIVE_2] = false; + myIndex[DRIVE_2] = 1; + } + } + + return inserted; } - bool DiskControl::insertFloppyDisk(const std::string &path, const bool writeProtected, bool const createIfNecessary) + bool DiskControl::insertFloppyDisk( + const Drive_e drive, const std::string &path, const bool writeProtected, bool const createIfNecessary) { CardManager &cardManager = GetCardMgr(); Disk2InterfaceCard *disk2Card = dynamic_cast(cardManager.GetObj(SLOT6)); if (disk2Card) { - const ImageError_e error = disk2Card->InsertDisk(DRIVE_1, path, writeProtected, createIfNecessary); + const ImageError_e error = disk2Card->InsertDisk(drive, path, writeProtected, createIfNecessary); + const bool result = (error == eIMAGE_ERROR_NONE); + + ra2::log_cb(RETRO_LOG_INFO, "Insert into drive %d: %s -> %d\n", drive + 1, path.c_str(), result); - if (error == eIMAGE_ERROR_NONE) + if (result) { storeCurrentDiskFolder(path); return true; @@ -217,12 +269,19 @@ namespace ra2 bool DiskControl::getEjectedState() const { - return myEjected; + const Drive_e drive = activeDrive(); + return myEjected[drive]; } bool DiskControl::setEjectedState(bool ejected) { - if (myEjected == ejected) + const Drive_e drive = activeDrive(); + return setEjectedStateForDrive(drive, ejected); + } + + bool DiskControl::setEjectedStateForDrive(const Drive_e drive, bool ejected) + { + if (myEjected[drive] == ejected) { return true; } @@ -231,21 +290,30 @@ namespace ra2 Disk2InterfaceCard *disk2Card = dynamic_cast(cardManager.GetObj(SLOT6)); bool result = false; - if (disk2Card && myIndex < myImages.size()) + if (disk2Card && myIndex[drive] < myImages.size()) { if (ejected) { - disk2Card->EjectDisk(DRIVE_1); + disk2Card->EjectDisk(drive); result = true; - myEjected = ejected; + myEjected[drive] = ejected; } else { + // if the other dirve has the same disk, eject it first to avoid conflicts + const Drive_e otherDrive = (drive == DRIVE_1) ? DRIVE_2 : DRIVE_1; + if (!myEjected[otherDrive] && (myIndex[otherDrive] == myIndex[drive])) + { + disk2Card->EjectDisk(otherDrive); + myEjected[otherDrive] = true; + ra2::log_cb( + RETRO_LOG_INFO, "Ejecting drive %d to avoid conflict with drive %d\n", otherDrive + 1, + drive + 1); + } + const auto &image = myImages[myIndex[drive]]; // inserted - result = insertFloppyDisk( - myImages[myIndex].path, myImages[myIndex].writeProtected, myImages[myIndex].createIfNecessary); - myEjected = !result; - ra2::log_cb(RETRO_LOG_INFO, "Insert new disk: %s -> %d\n", myImages[myIndex].path.c_str(), result); + result = insertFloppyDisk(drive, image.path, image.writeProtected, image.createIfNecessary); + myEjected[drive] = !result; } } @@ -254,14 +322,16 @@ namespace ra2 size_t DiskControl::getImageIndex() const { - return myIndex; + const Drive_e drive = activeDrive(); + return myIndex[drive]; } bool DiskControl::setImageIndex(size_t index) { - if (myEjected) + const Drive_e drive = activeDrive(); + if (myEjected[drive]) { - myIndex = index; + myIndex[drive] = index; return true; } else @@ -277,7 +347,8 @@ namespace ra2 bool DiskControl::replaceImageIndex(size_t index, const std::string &path) { - if (myEjected && myIndex < myImages.size()) + const Drive_e drive = activeDrive(); + if (myEjected[drive] && myIndex[drive] < myImages.size()) { const std::filesystem::path filePath(path); @@ -295,16 +366,17 @@ namespace ra2 bool DiskControl::removeImageIndex(size_t index) { - if (myEjected && myIndex < myImages.size()) + const Drive_e drive = activeDrive(); + if (myEjected[drive] && myIndex[drive] < myImages.size()) { myImages.erase(myImages.begin() + index); - if (myImages.empty() || myIndex == index) + if (myImages.empty() || myIndex[drive] == index) { - myIndex = myImages.size(); + myIndex[drive] = myImages.size(); } - else if (myIndex > index) + else if (myIndex[drive] > index) { - --myIndex; + --myIndex[drive]; } return true; } @@ -338,7 +410,18 @@ namespace ra2 { if (index < myImages.size()) { - strncpy(label, myImages[index].label.c_str(), len); + const Drive_e drive = activeDrive(); + const Drive_e otherDrive = (drive == DRIVE_1) ? DRIVE_2 : DRIVE_1; + + std::string displayLabel = "S6D" + std::to_string(drive + 1) + " - " + myImages[index].label; + + // Mark the label with an asterisk if the same disk is inserted in the other drive + if (!myEjected[otherDrive] && myIndex[otherDrive] == index) + { + displayLabel += " <*>"; + } + + strncpy(label, displayLabel.c_str(), len); label[len - 1] = 0; return true; } @@ -351,8 +434,11 @@ namespace ra2 void DiskControl::serialise(Buffer &buffer) const { writeString(buffer, myCurrentDiskFolder); - buffer.get() = myEjected; - buffer.get() = myIndex; + for (int d = 0; d < NUM_DRIVES; ++d) + { + buffer.get() = myEjected[d]; + buffer.get() = myIndex[d]; + } buffer.get() = myImages.size(); for (DiskInfo const &image : myImages) @@ -361,14 +447,18 @@ namespace ra2 writeString(buffer, image.label); buffer.get() = image.writeProtected; buffer.get() = image.createIfNecessary; + buffer.get() = image.isSaveDisk; } } void DiskControl::deserialise(Buffer &buffer) { readString(buffer, myCurrentDiskFolder); - myEjected = buffer.get(); - myIndex = buffer.get(); + for (int d = 0; d < NUM_DRIVES; ++d) + { + myEjected[d] = buffer.get(); + myIndex[d] = buffer.get(); + } size_t const numberOfImages = buffer.get(); myImages.clear(); myImages.resize(numberOfImages); @@ -379,6 +469,7 @@ namespace ra2 readString(buffer, image.label); image.writeProtected = buffer.get(); image.createIfNecessary = buffer.get(); + image.isSaveDisk = buffer.get(); } } diff --git a/source/frontends/libretro/diskcontrol.h b/source/frontends/libretro/diskcontrol.h index 915510d51..dc64cea18 100644 --- a/source/frontends/libretro/diskcontrol.h +++ b/source/frontends/libretro/diskcontrol.h @@ -22,6 +22,7 @@ namespace ra2 std::string label; bool writeProtected = IMAGE_FORCE_WRITE_PROTECTED; bool createIfNecessary = IMAGE_DONT_CREATE; + bool isSaveDisk = false; }; class DiskControl @@ -43,7 +44,7 @@ namespace ra2 bool addImageIndex(); // these 2 functions update the images for the Disc Control Interface - bool insertDisk(const std::string &path); + bool insertDisk(const Drive_e drive, const std::string &path); bool insertPlaylist(const std::string &path); bool getImagePath(unsigned index, char *path, size_t len) const; @@ -57,11 +58,16 @@ namespace ra2 private: std::vector myImages; - bool myEjected; - size_t myIndex; + // per-drive state for multi-drive support + bool myEjected[NUM_DRIVES]; + size_t myIndex[NUM_DRIVES]; + std::string myCurrentDiskFolder; - bool insertFloppyDisk(const std::string &path, const bool writeProtected, bool const createIfNecessary); + Drive_e activeDrive() const; + bool setEjectedStateForDrive(const Drive_e drive, bool state); + bool insertFloppyDisk( + const Drive_e drive, const std::string &path, const bool writeProtected, bool const createIfNecessary); bool insertHardDisk(const std::string &path); void storeCurrentDiskFolder(const std::string &path); diff --git a/source/frontends/libretro/libretro.cpp b/source/frontends/libretro/libretro.cpp index feacdaee9..41034275f 100644 --- a/source/frontends/libretro/libretro.cpp +++ b/source/frontends/libretro/libretro.cpp @@ -35,33 +35,39 @@ namespace bool retro_set_eject_state(bool ejected) { + ourGame->updateVariables(); ra2::log_cb(RETRO_LOG_INFO, "RA2: %s (%d)\n", __FUNCTION__, ejected); return ourGame->getDiskControl().setEjectedState(ejected); } bool retro_get_eject_state() { + ourGame->updateVariables(); return ourGame->getDiskControl().getEjectedState(); } unsigned retro_get_image_index() { + ourGame->updateVariables(); return ourGame->getDiskControl().getImageIndex(); } bool retro_set_image_index(unsigned index) { + ourGame->updateVariables(); ra2::log_cb(RETRO_LOG_INFO, "RA2: %s (%d)\n", __FUNCTION__, index); return ourGame->getDiskControl().setImageIndex(index); } unsigned retro_get_num_images() { + ourGame->updateVariables(); return ourGame->getDiskControl().getNumImages(); } bool retro_replace_image_index(unsigned index, const struct retro_game_info *info) { + ourGame->updateVariables(); ra2::log_cb(RETRO_LOG_INFO, "RA2: %s (%s)\n", __FUNCTION__, info->path); if (info->path) { @@ -75,6 +81,7 @@ namespace bool retro_add_image_index() { + ourGame->updateVariables(); ra2::log_cb(RETRO_LOG_INFO, "RA2: %s\n", __FUNCTION__); return ourGame->getDiskControl().addImageIndex(); } @@ -88,12 +95,14 @@ namespace bool retro_get_image_path(unsigned index, char *path, size_t len) { + ourGame->updateVariables(); ra2::log_cb(RETRO_LOG_INFO, "RA2: %s (%d)\n", __FUNCTION__, index); return ourGame->getDiskControl().getImagePath(index, path, len); } bool retro_get_image_label(unsigned index, char *label, size_t len) { + ourGame->updateVariables(); ra2::log_cb(RETRO_LOG_INFO, "RA2: %s (%d)\n", __FUNCTION__, index); return ourGame->getDiskControl().getImageLabel(index, label, len); } @@ -341,7 +350,7 @@ bool retro_load_game(const retro_game_info *info) } else { - ok = game->getDiskControl().insertDisk(gamePath); + ok = game->getDiskControl().insertDisk(DRIVE_1, gamePath); } ra2::log_cb(RETRO_LOG_INFO, "Game path: %s -> %d\n", info->path, ok); } diff --git a/source/frontends/libretro/retroregistry.cpp b/source/frontends/libretro/retroregistry.cpp index 88a451f49..d79172648 100644 --- a/source/frontends/libretro/retroregistry.cpp +++ b/source/frontends/libretro/retroregistry.cpp @@ -24,14 +24,18 @@ namespace const char *REG_RA2 = "ra2"; const char *REGVALUE_KEYBOARD_TYPE = "Keyboard type"; const char *REGVALUE_PLAYLIST_START = "Playlist start"; + const char *REGVALUE_FLOPPY_MULTI_DRIVE = "Floppy multidrive"; + const char *REGVALUE_DISK_CONTROL_DRIVE = "Disk control drive"; const char *REGVALUE_MOUSE_SPEED_00 = "Mouse speed"; const char *CATEGORY_SYSTEM = "system"; const char *CATEGORY_INPUT = "input"; const char *CATEGORY_RETROPAD_MAPPING = "retropad"; + const char *CATEGORY_DISK_CONTROL = "disk_control"; retro_core_option_v2_category ourOptionCatsUS[] = { {CATEGORY_SYSTEM, "System", "Configure system options."}, + {CATEGORY_DISK_CONTROL, "Disk Control", "Configure disk control options."}, {CATEGORY_INPUT, "Input", "Configure input options."}, {CATEGORY_RETROPAD_MAPPING, "RetroPad Mapping", "Configure RetroPad mapping options."}, {nullptr, nullptr, nullptr}, @@ -293,11 +297,24 @@ namespace REG_CONFIG, REGVALUE_VIDEO_REFRESH_RATE, // reset required }, + { + { + "disk_control_drive", + "Disk Control Drive", + CATEGORY_DISK_CONTROL, + { + {"Drive 1", DRIVE_1}, + {"Drive 2", DRIVE_2}, + }, + }, + REG_RA2, + REGVALUE_DISK_CONTROL_DRIVE, + }, { { "playlist_start", "Playlist Start Disk", - CATEGORY_SYSTEM, + CATEGORY_DISK_CONTROL, { {"First", static_cast(ra2::PlaylistStartDisk::First)}, {"Previous", static_cast(ra2::PlaylistStartDisk::Previous)}, @@ -306,6 +323,19 @@ namespace REG_RA2, REGVALUE_PLAYLIST_START, }, + { + { + "floppy_multidrive", + "Floppy MultiDrive", + CATEGORY_DISK_CONTROL, + { + {"disabled", 0}, + {"enabled", 1}, + }, + }, + REG_RA2, + REGVALUE_FLOPPY_MULTI_DRIVE, + }, { { "keyboard_type", @@ -561,6 +591,13 @@ namespace ra2 return registry; } + bool getFloppyMultiDrive() + { + uint32_t value = 0; + RegLoadValue(REG_RA2, REGVALUE_FLOPPY_MULTI_DRIVE, true, &value); + return value != 0; + } + KeyboardType getKeyboardEmulationType() { uint32_t value = static_cast(KeyboardType::ASCII); @@ -575,6 +612,13 @@ namespace ra2 return static_cast(value); } + Drive_e getDiskControlDrive() + { + uint32_t value = DRIVE_1; + RegLoadValue(REG_RA2, REGVALUE_DISK_CONTROL_DRIVE, true, &value); + return (value == DRIVE_2) ? DRIVE_2 : DRIVE_1; + } + double getMouseSpeed() { uint32_t value = 100; diff --git a/source/frontends/libretro/retroregistry.h b/source/frontends/libretro/retroregistry.h index 7fda4f41c..2ed227c54 100644 --- a/source/frontends/libretro/retroregistry.h +++ b/source/frontends/libretro/retroregistry.h @@ -23,6 +23,8 @@ namespace ra2 KeyboardType getKeyboardEmulationType(); PlaylistStartDisk getPlaylistStartDisk(); + bool getFloppyMultiDrive(); + Drive_e getDiskControlDrive(); double getMouseSpeed(); bool is280Lines();