diff --git a/Builder/Android/app/src/main/AndroidManifest.xml b/Builder/Android/app/src/main/AndroidManifest.xml
index 339b0af..26fd249 100644
--- a/Builder/Android/app/src/main/AndroidManifest.xml
+++ b/Builder/Android/app/src/main/AndroidManifest.xml
@@ -104,6 +104,17 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Builder/Linux/xm8.desktop b/Builder/Linux/xm8.desktop
index e0c1e42..0b09c71 100644
--- a/Builder/Linux/xm8.desktop
+++ b/Builder/Linux/xm8.desktop
@@ -4,6 +4,7 @@ GenericName=XM8
Comment=PC-8801MA Emulator
Comment[ja]=PC-8801MA エミュレーター
Exec=xm8
+MimeType=application/x-m3u;audio/x-mpegurl;application/vnd.apple.mpegurl;
Icon=xm8
Terminal=false
Type=Application
diff --git a/Builder/macOS/Info.plist b/Builder/macOS/Info.plist
index 03c784d..0671c5e 100644
--- a/Builder/macOS/Info.plist
+++ b/Builder/macOS/Info.plist
@@ -26,6 +26,14 @@
????
NSHighResolutionCapable
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeNameXM8 Disk and Playlist
+ CFBundleTypeRoleViewer
+ CFBundleTypeExtensionsd88m3um3u8
+
+
CSResourcesFileMapped
NSHumanReadableCopyright
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 181125d..93fc432 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -236,6 +236,8 @@ option(ENABLE_PACKAGING "Enable CPack packaging" ON)
target_link_libraries(${BIN_TARGET} PRIVATE XM8::SDL xbrz)
if(APPLE)
target_link_libraries(${BIN_TARGET} PRIVATE "-framework Foundation")
+ find_library(XM8_ICONV_LIBRARY iconv REQUIRED)
+ target_link_libraries(${BIN_TARGET} PRIVATE ${XM8_ICONV_LIBRARY})
endif()
if(BUILD_TESTING)
@@ -245,6 +247,9 @@ if(BUILD_TESTING)
Source/UI/m3u.cpp
)
target_include_directories(clidisk_test PRIVATE Source/UI)
+ if(APPLE)
+ target_link_libraries(clidisk_test PRIVATE ${XM8_ICONV_LIBRARY})
+ endif()
add_test(NAME clidisk_test COMMAND clidisk_test)
add_executable(d88probe_test
@@ -406,6 +411,9 @@ if(BUILD_TESTING)
$
)
target_link_libraries(ra_dependency_test PRIVATE xm8_ra_dependencies)
+ if(APPLE)
+ target_link_libraries(ra_dependency_test PRIVATE ${XM8_ICONV_LIBRARY})
+ endif()
target_include_directories(ra_dependency_test PRIVATE Source/RA Source/UI)
target_compile_definitions(ra_dependency_test PRIVATE
XM8_ENABLE_RETROACHIEVEMENTS=1)
diff --git a/Source/UI/app.cpp b/Source/UI/app.cpp
index 287179b..284e8bc 100644
--- a/Source/UI/app.cpp
+++ b/Source/UI/app.cpp
@@ -43,6 +43,7 @@
#include "diskmgr.h"
#include "tapemgr.h"
#include "clidisk.h"
+#include "m3u.h"
#ifdef XM8_ENABLE_RETROACHIEVEMENTS
#include "ra_media_change_policy.h"
#include "ra_build_info.h"
@@ -1110,6 +1111,38 @@ bool App::OpenDiskPairFromMenu(const std::string& path, bool *drive2_open,
return true;
}
+bool App::OpenDiskSpecsFromMenu(const std::vector& specs,
+ std::string *error, bool close_drive2)
+{
+ struct Snapshot { bool open; std::string path; int bank; } snapshots[MAX_DRIVE];
+ int banks;
+ if (specs.empty() || specs.size() > MAX_DRIVE) {
+ *error = "invalid disk selection";
+ return false;
+ }
+ for (const DiskSpec& spec : specs)
+ if (!ProbeDisk(spec, &banks, error)) return false;
+ for (int drive = 0; drive < MAX_DRIVE; ++drive) {
+ snapshots[drive].open = diskmgr[drive]->IsOpen();
+ if (snapshots[drive].open) {
+ snapshots[drive].path = diskmgr[drive]->GetPath();
+ snapshots[drive].bank = diskmgr[drive]->GetBank();
+ }
+ }
+ auto restore = [this, &snapshots]() {
+ for (int drive = 0; drive < MAX_DRIVE; ++drive) {
+ if (snapshots[drive].open) diskmgr[drive]->Open(snapshots[drive].path.c_str(), snapshots[drive].bank);
+ else diskmgr[drive]->Close();
+ }
+ };
+ for (const DiskSpec& spec : specs) {
+ if (!OpenDiskFromUser(spec, error)) { restore(); return false; }
+ }
+ if (close_drive2) diskmgr[1]->Close();
+ RememberDiskOpenDir(specs.front().path.c_str());
+ return true;
+}
+
#ifdef XM8_ENABLE_RETROACHIEVEMENTS
//
// ResolveDiskForRaMode()
@@ -4207,12 +4240,20 @@ bool App::OpenDroppedDisk(const char *path, std::string *error)
int bank;
};
Snapshot snapshots[MAX_DRIVE];
- DiskSpec first = {path, 0, 0};
+ std::vector playlist_specs;
+ if (IsM3UPath(path) && !LoadPlaylistDiskSpecs(path, 0, MAX_DRIVE,
+ &playlist_specs, error)) return false;
+ DiskSpec first = playlist_specs.empty() ? DiskSpec{path, 0, 0} : playlist_specs[0];
int banks;
if (ProbeDisk(first, &banks, error) == false) {
return false;
}
+ if (!playlist_specs.empty()) {
+ for (const DiskSpec& spec : playlist_specs) {
+ if (ProbeDisk(spec, &banks, error) == false) return false;
+ }
+ }
for (int drive=0; driveIsOpen();
if (snapshots[drive].open) {
@@ -4236,6 +4277,15 @@ bool App::OpenDroppedDisk(const char *path, std::string *error)
restore();
return false;
}
+ if (!playlist_specs.empty()) {
+ if (playlist_specs.size() > 1 &&
+ !OpenDiskFromUser(playlist_specs[1], error)) {
+ restore();
+ return false;
+ }
+ if (playlist_specs.size() == 1) diskmgr[1]->Close();
+ return true;
+ }
if (banks > 1) {
DiskSpec second = {path, 1, 1};
if (OpenDiskFromUser(second, error) == false) {
diff --git a/Source/UI/app.h b/Source/UI/app.h
index 22a4ae7..c3b48b8 100644
--- a/Source/UI/app.h
+++ b/Source/UI/app.h
@@ -146,6 +146,8 @@ class App
bool OpenDiskPairFromMenu(const std::string& path, bool *drive2_open,
std::string *error);
// open bank 0/1 as one transaction
+ bool OpenDiskSpecsFromMenu(const std::vector& specs,
+ std::string *error, bool close_drive2 = false);
const char* GetTapeDir();
// get tape dir
void Reset();
diff --git a/Source/UI/clidisk.cpp b/Source/UI/clidisk.cpp
index 23f6513..82358e3 100644
--- a/Source/UI/clidisk.cpp
+++ b/Source/UI/clidisk.cpp
@@ -44,7 +44,7 @@ bool ParseBank(const std::string& value, int *bank)
return true;
}
-bool ParseDiskSpec(const std::string& argument, int drive, DiskSpec *spec,
+bool ParseDiskSpecInternal(const std::string& argument, int drive, DiskSpec *spec,
std::string *error)
{
const std::string::size_type slash = argument.find_last_of("/\\");
@@ -120,17 +120,31 @@ bool ParseClock(const std::string& value, CliClockMode *clock)
return true;
}
-bool IsM3U(const std::string& path)
-{
- if (path.length() < 4) {
- return false;
- }
+} // namespace
- std::string ext = ToUpper(path.substr(path.length() - 4));
- return ext == ".M3U";
+bool ParseDiskSpec(const std::string& argument, int drive, DiskSpec *spec,
+ std::string *error)
+{
+ return ParseDiskSpecInternal(argument, drive, spec, error);
}
-} // namespace
+bool LoadPlaylistDiskSpecs(const std::string& path, int first_drive,
+ size_t maximum, std::vector *specs, std::string *error)
+{
+ if (specs == nullptr || error == nullptr) return false;
+ M3UResult playlist = LoadM3U(path);
+ if (!playlist.success) { *error = playlist.error; return false; }
+ specs->clear();
+ for (const std::string& entry : playlist.entries) {
+ if (specs->size() >= maximum) break;
+ DiskSpec spec;
+ if (!ParseDiskSpec(entry, first_drive + static_cast(specs->size()),
+ &spec, error)) return false;
+ specs->push_back(spec);
+ }
+ if (specs->empty()) { *error = "playlist contains no disk images: " + path; return false; }
+ return true;
+}
CliOptions ParseCommandLine(int argc, char *argv[])
{
@@ -198,33 +212,12 @@ CliOptions ParseCommandLine(int argc, char *argv[])
return Error("too many disk images; maximum is 2");
}
- if (IsM3U(argument)) {
-
- M3UResult playlist = LoadM3U(argument);
-
- if (!playlist.success) {
- return Error(playlist.error);
- }
-
- for (const auto& entry : playlist.entries) {
-
- if (options.disks.size() >=2) {
- break;
- }
-
- DiskSpec spec;
- std::string error;
-
- if (!ParseDiskSpec(entry,
- static_cast(options.disks.size()),
- &spec,
- &error)) {
- return Error(error);
- }
-
- options.disks.push_back(spec);
- }
-
+ if (IsM3UPath(argument)) {
+ std::vector specs;
+ std::string error;
+ if (!LoadPlaylistDiskSpecs(argument, static_cast(options.disks.size()),
+ 2 - options.disks.size(), &specs, &error)) return Error(error);
+ options.disks.insert(options.disks.end(), specs.begin(), specs.end());
} else {
DiskSpec spec;
diff --git a/Source/UI/clidisk.h b/Source/UI/clidisk.h
index d3d794a..8ce078d 100644
--- a/Source/UI/clidisk.h
+++ b/Source/UI/clidisk.h
@@ -71,5 +71,9 @@ struct CliOptions {
CliOptions ParseCommandLine(int argc, char *argv[]);
const char* GetCommandLineHelp();
+bool ParseDiskSpec(const std::string& argument, int drive, DiskSpec *spec,
+ std::string *error);
+bool LoadPlaylistDiskSpecs(const std::string& path, int first_drive,
+ size_t maximum, std::vector *specs, std::string *error);
#endif
diff --git a/Source/UI/m3u.cpp b/Source/UI/m3u.cpp
index ab5d547..67d4bf4 100644
--- a/Source/UI/m3u.cpp
+++ b/Source/UI/m3u.cpp
@@ -1,11 +1,22 @@
#include "m3u.h"
#include
+#include
+#include
#ifdef _WIN32
#include
+#include
#endif
#include
#include
+#if defined(__APPLE__) || (defined(__linux__) && !defined(__ANDROID__))
+#include
+#endif
+#ifdef __ANDROID__
+#include "os.h"
+#include "common.h"
+#include "converter.h"
+#endif
namespace {
@@ -20,6 +31,135 @@ std::string Trim(const std::string& value)
return value.substr(first, last - first + 1);
}
+bool IsValidUtf8(const std::string& value)
+{
+ for (size_t i = 0; i < value.size();) {
+ const unsigned char c = static_cast(value[i]);
+ size_t count = 0;
+ if (c < 0x80) count = 1;
+ else if (c >= 0xc2 && c <= 0xdf) count = 2;
+ else if (c >= 0xe0 && c <= 0xef) count = 3;
+ else if (c >= 0xf0 && c <= 0xf4) count = 4;
+ else return false;
+ if (i + count > value.size()) return false;
+ for (size_t j = 1; j < count; ++j)
+ if ((static_cast(value[i + j]) & 0xc0) != 0x80) return false;
+ i += count;
+ }
+ return true;
+}
+
+void AppendUtf8(std::string *out, uint32_t codepoint)
+{
+ if (codepoint <= 0x7f) out->push_back(static_cast(codepoint));
+ else if (codepoint <= 0x7ff) {
+ out->push_back(static_cast(0xc0 | (codepoint >> 6)));
+ out->push_back(static_cast(0x80 | (codepoint & 0x3f)));
+ } else if (codepoint <= 0xffff) {
+ out->push_back(static_cast(0xe0 | (codepoint >> 12)));
+ out->push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3f)));
+ out->push_back(static_cast(0x80 | (codepoint & 0x3f)));
+ } else if (codepoint <= 0x10ffff) {
+ out->push_back(static_cast(0xf0 | (codepoint >> 18)));
+ out->push_back(static_cast(0x80 | ((codepoint >> 12) & 0x3f)));
+ out->push_back(static_cast(0x80 | ((codepoint >> 6) & 0x3f)));
+ out->push_back(static_cast(0x80 | (codepoint & 0x3f)));
+ }
+}
+
+bool DecodeUtf16(const std::string& bytes, bool little_endian, std::string *out)
+{
+ if ((bytes.size() & 1) != 0) return false;
+ out->clear();
+ for (size_t i = 0; i < bytes.size(); i += 2) {
+ const unsigned char a = static_cast(bytes[i]);
+ const unsigned char b = static_cast(bytes[i + 1]);
+ uint32_t value = little_endian ? a | (b << 8) : (a << 8) | b;
+ if (value >= 0xd800 && value <= 0xdbff) {
+ if (i + 3 >= bytes.size()) return false;
+ const unsigned char c = static_cast(bytes[i + 2]);
+ const unsigned char d = static_cast(bytes[i + 3]);
+ const uint32_t low = little_endian ? c | (d << 8) : (c << 8) | d;
+ if (low < 0xdc00 || low > 0xdfff) return false;
+ value = 0x10000 + ((value - 0xd800) << 10) + (low - 0xdc00);
+ i += 2;
+ } else if (value >= 0xdc00 && value <= 0xdfff) return false;
+ AppendUtf8(out, value);
+ }
+ return true;
+}
+
+bool DecodeShiftJis(const std::string& bytes, std::string *out)
+{
+#ifdef _WIN32
+ const int wide_length = MultiByteToWideChar(932, MB_ERR_INVALID_CHARS,
+ bytes.data(), static_cast(bytes.size()), NULL, 0);
+ if (wide_length == 0) return false;
+ std::vector wide(wide_length);
+ if (MultiByteToWideChar(932, MB_ERR_INVALID_CHARS, bytes.data(),
+ static_cast(bytes.size()), wide.data(), wide_length) == 0) return false;
+ const int utf8_length = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS,
+ wide.data(), wide_length, NULL, 0, NULL, NULL);
+ if (utf8_length == 0) return false;
+ out->resize(utf8_length);
+ return WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, wide.data(),
+ wide_length, &(*out)[0], utf8_length, NULL, NULL) != 0;
+#elif defined(__ANDROID__)
+ // Android's NDK does not provide iconv. Reuse XM8's CP932 table, which
+ // is already used for the application's Japanese file-name conversion.
+ std::string terminated = bytes;
+ terminated.push_back('\0');
+ std::vector converted(bytes.size() * 3 + 1);
+ Converter converter;
+ converter.SjisToUtf(terminated.c_str(), converted.data());
+ *out = converted.data();
+ return true;
+#elif defined(__APPLE__) || defined(__linux__)
+ iconv_t converter = iconv_open("UTF-8", "CP932");
+ if (converter == reinterpret_cast(-1)) return false;
+ std::vector converted(bytes.size() * 4 + 4);
+ char *input = const_cast(bytes.data());
+ size_t input_size = bytes.size();
+ char *output = converted.data();
+ size_t output_size = converted.size();
+ const size_t result = iconv(converter, &input, &input_size, &output, &output_size);
+ iconv_close(converter);
+ if (result == static_cast(-1) || input_size != 0) return false;
+ out->assign(converted.data(), static_cast(output - converted.data()));
+ return true;
+#else
+ (void)bytes; (void)out;
+ return false;
+#endif
+}
+
+bool DecodePlaylist(const std::string& path, const std::string& bytes,
+ std::string *text, std::string *error)
+{
+ if (bytes.size() >= 3 && bytes.compare(0, 3, "\xef\xbb\xbf") == 0) {
+ *text = bytes.substr(3);
+ if (IsValidUtf8(*text)) return true;
+ *error = "invalid playlist text encoding: " + path;
+ return false;
+ }
+ if (bytes.size() >= 2 && bytes.compare(0, 2, "\xff\xfe") == 0) {
+ if (DecodeUtf16(bytes.substr(2), true, text)) return true;
+ *error = "invalid playlist text encoding: " + path;
+ return false;
+ }
+ if (bytes.size() >= 2 && bytes.compare(0, 2, "\xfe\xff") == 0) {
+ if (DecodeUtf16(bytes.substr(2), false, text)) return true;
+ *error = "invalid playlist text encoding: " + path;
+ return false;
+ }
+ if (IsValidUtf8(bytes)) { *text = bytes; return true; }
+ if (IsM3UPath(path) && path.size() >= 4 &&
+ std::tolower(static_cast(path[path.size() - 1])) != '8' &&
+ DecodeShiftJis(bytes, text)) return true;
+ *error = "invalid playlist text encoding: " + path;
+ return false;
+}
+
std::string ParentDirectory(const std::string& path)
{
const std::string::size_type slash = path.find_last_of("/\\");
@@ -51,6 +191,14 @@ std::string ResolveM3UEntry(const std::string& baseDir,
} // namespace
+bool IsM3UPath(const std::string& path)
+{
+ const auto dot = path.find_last_of('.');
+ if (dot == std::string::npos) return false;
+ std::string extension = path.substr(dot);
+ for (char& ch : extension) ch = static_cast(std::tolower(static_cast(ch)));
+ return extension == ".m3u" || extension == ".m3u8";
+}
M3UResult LoadM3U(const std::string& path)
{
@@ -58,20 +206,27 @@ M3UResult LoadM3U(const std::string& path)
result.success = false;
#ifdef _WIN32
- std::ifstream file(std::filesystem::u8path(path));
+ std::ifstream file(std::filesystem::u8path(path), std::ios::binary);
#else
- std::ifstream file(path);
+ std::ifstream file(path, std::ios::binary);
#endif
if (!file.is_open()) {
result.error = "unable to open m3u: " + path;
return result;
}
- // 👇 get base directory ONCE
+ const std::string bytes((std::istreambuf_iterator(file)),
+ std::istreambuf_iterator());
+ std::string text;
+ if (!DecodePlaylist(path, bytes, &text, &result.error)) return result;
+
const std::string baseDir = ParentDirectory(path);
- std::string line;
- while (std::getline(file, line)) {
+ size_t line_start = 0;
+ while (line_start <= text.size()) {
+ const size_t line_end = text.find('\n', line_start);
+ std::string line = text.substr(line_start, line_end - line_start);
+ line_start = line_end == std::string::npos ? text.size() + 1 : line_end + 1;
line = Trim(line);
if (line.empty()) {
diff --git a/Source/UI/m3u.h b/Source/UI/m3u.h
index b6b31ec..268c3d7 100644
--- a/Source/UI/m3u.h
+++ b/Source/UI/m3u.h
@@ -9,4 +9,5 @@ struct M3UResult {
std::vector entries;
};
+bool IsM3UPath(const std::string& path);
M3UResult LoadM3U(const std::string& path);
diff --git a/Source/UI/menu.cpp b/Source/UI/menu.cpp
index 74dd936..e2f6b97 100644
--- a/Source/UI/menu.cpp
+++ b/Source/UI/menu.cpp
@@ -30,6 +30,9 @@
#endif // __ANDROID__
#include "menu.h"
+#include "clidisk.h"
+#include "m3u.h"
+
#include
#ifdef XM8_ENABLE_RETROACHIEVEMENTS
@@ -1667,7 +1670,7 @@ void Menu::EnterVmKey(int id)
// EnterFile()
// enter file menu
//
-void Menu::EnterFile()
+void Menu::EnterFile()
{
const char *name;
int id;
@@ -1706,8 +1709,32 @@ void Menu::EnterFile()
if (matched == true) {
list->SetFocus(id_focus);
}
- file_expect[0] = '\0';
-}
+ file_expect[0] = '\0';
+}
+
+void Menu::EnterPlaylist(const char *path)
+{
+ std::string error;
+ playlist_entries.clear();
+ if (!LoadPlaylistDiskSpecs(path, 0,
+ MENU_PLAYLIST_ENTRY_MAX - MENU_PLAYLIST_ENTRY_MIN + 1,
+ &playlist_entries, &error)) {
+ platform->MsgBox(NULL, error.c_str());
+ EnterFile();
+ return;
+ }
+ playlist_drive = file_id == MENU_DRIVE2_OPEN ? 1 : 0;
+ playlist_first = -1;
+ const bool pair = file_id == MENU_DRIVE1_BOTH || file_id == MENU_DRIVE2_BOTH;
+ list->SetTitle(pair ? "<< Playlist: select Drive 1 >>" : "<< Playlist >>",
+ MENU_PLAYLIST);
+ for (size_t i = 0; i < playlist_entries.size(); ++i) {
+ const std::string& entry = playlist_entries[i].path;
+ const size_t slash = entry.find_last_of("/\\");
+ list->AddButton(entry.substr(slash == std::string::npos ? 0 : slash + 1).c_str(),
+ MENU_PLAYLIST_ENTRY_MIN + static_cast(i));
+ }
+}
//
// EnterJoyTest()
@@ -1729,9 +1756,13 @@ void Menu::EnterJoyTest()
// Command()
// command
//
-void Menu::Command(bool down, int id)
-{
- // back
+void Menu::Command(bool down, int id)
+{
+ if (id >= MENU_PLAYLIST_ENTRY_MIN && id <= MENU_PLAYLIST_ENTRY_MAX) {
+ if (down == false) CmdPlaylist(id);
+ return;
+ }
+ // back
if (id == MENU_BACK) {
if (down == false) {
CmdBack();
@@ -2057,8 +2088,8 @@ void Menu::CmdBack()
EnterVideo(MENU_VIDEO_SCALEFILTER);
break;
- // file menu
- case MENU_FILE:
+ // file menu
+ case MENU_FILE:
switch (file_id) {
// from drive1 menu
case MENU_DRIVE1_OPEN:
@@ -2625,6 +2656,10 @@ void Menu::CmdRa(int id)
app->OpenRaWebsite();
break;
+ case MENU_PLAYLIST:
+ EnterFile();
+ break;
+
default:
break;
}
@@ -3539,11 +3574,28 @@ void Menu::CmdFile(int id)
}
// normal file
- if (platform->MakePath(file_target, name, false) == false) {
- return;
- }
-
- // tape ?
+ if (platform->MakePath(file_target, name, false) == false) {
+ return;
+ }
+ if (IsM3UPath(file_target)) {
+ if (file_id == MENU_DRIVE1_BOTH || file_id == MENU_DRIVE2_BOTH) {
+ std::vector specs;
+ std::string error;
+ if (!LoadPlaylistDiskSpecs(file_target, 0, MAX_DRIVE, &specs, &error) ||
+ !app->OpenDiskSpecsFromMenu(specs, &error, specs.size() == 1)) {
+ platform->MsgBox(NULL, error.c_str());
+ return;
+ }
+ EnterDrive1(MENU_DRIVE1_BANK0);
+ return;
+ }
+ if (file_id == MENU_DRIVE1_OPEN || file_id == MENU_DRIVE2_OPEN) {
+ EnterPlaylist(file_target);
+ return;
+ }
+ }
+
+ // tape ?
if ((file_id == MENU_CMT_PLAY) || (file_id == MENU_CMT_REC)) {
// eject tape
tapemgr->Eject();
@@ -3610,10 +3662,50 @@ void Menu::CmdFile(int id)
default:
break;
}
-}
-
-//
-// MakeExpect()
+}
+
+void Menu::CmdPlaylist(int id)
+{
+ const size_t index = static_cast(id - MENU_PLAYLIST_ENTRY_MIN);
+ if (index >= playlist_entries.size()) return;
+ const bool pair = file_id == MENU_DRIVE1_BOTH || file_id == MENU_DRIVE2_BOTH;
+ if (!pair) {
+ DiskSpec spec = playlist_entries[index];
+ spec.drive = playlist_drive;
+ std::string error;
+ if (!app->OpenDiskFromMenu(spec, &error)) {
+ platform->MsgBox(NULL, error.c_str());
+ return;
+ }
+ if (playlist_drive == 0) EnterDrive1(MENU_DRIVE1_BANK0);
+ else EnterDrive2(MENU_DRIVE2_BANK0);
+ return;
+ }
+ if (playlist_first < 0) {
+ playlist_first = static_cast(index);
+ list->SetTitle("<< Playlist: select Drive 2 >>", MENU_PLAYLIST);
+ for (size_t entry_index = 0; entry_index < playlist_entries.size(); ++entry_index) {
+ const std::string& entry = playlist_entries[entry_index].path;
+ const size_t slash = entry.find_last_of("/\\");
+ list->AddButton(entry.substr(slash == std::string::npos ? 0 : slash + 1).c_str(),
+ MENU_PLAYLIST_ENTRY_MIN + static_cast(entry_index));
+ }
+ return;
+ }
+ DiskSpec first = playlist_entries[playlist_first];
+ DiskSpec second = playlist_entries[index];
+ first.drive = 0;
+ second.drive = 1;
+ std::string error;
+ if (!app->OpenDiskSpecsFromMenu({first, second}, &error)) {
+ platform->MsgBox(NULL, error.c_str());
+ return;
+ }
+ EnterDrive1(MENU_DRIVE1_BANK0);
+}
+
+//
+// MakeExpect()
// make file_exepct[]
//
void Menu::MakeExpect(const char *name)
diff --git a/Source/UI/menu.h b/Source/UI/menu.h
index e9c60df..29bd20d 100644
--- a/Source/UI/menu.h
+++ b/Source/UI/menu.h
@@ -8,7 +8,10 @@
// [ menu driver ]
//
-#ifdef SDL
+#ifdef SDL
+
+#include
+#include "clidisk.h"
#ifndef MENU_H
#define MENU_H
@@ -91,8 +94,10 @@ class Menu
// enter vmkey menu
void EnterScalingFilter();
// enter scaling filter menu
- void EnterFile();
- // enter file menu
+ void EnterFile();
+ // enter file menu
+ void EnterPlaylist(const char *path);
+ void CmdPlaylist(int id);
void EnterJoyTest();
// enter joytest menu
void Command(bool down, int id);
@@ -203,6 +208,9 @@ class Menu
// parent joymap id
bool ra_state_menu;
// state menu was opened from RetroAchievements
+ std::vector playlist_entries;
+ int playlist_drive;
+ int playlist_first;
static const int vmkey_table[62 * 2];
// MENU_VMKEY table
static const Uint32 joytest_table[15 * 2];
diff --git a/Source/UI/menuid.h b/Source/UI/menuid.h
index 42016e3..b7807b1 100644
--- a/Source/UI/menuid.h
+++ b/Source/UI/menuid.h
@@ -78,8 +78,9 @@
// RetroAchievements achievements menu
#define MENU_RA_ACHIEVEMENT_DETAIL 28
// RetroAchievements achievement detail menu
-#define MENU_RA_LEADERBOARDS_VIEW 29
- // RetroAchievements leaderboards menu
+#define MENU_RA_LEADERBOARDS_VIEW 29
+ // RetroAchievements leaderboards menu
+#define MENU_PLAYLIST 30
//
// main menu
//
@@ -812,8 +813,10 @@
//
// file menu
//
-#define MENU_FILE_MIN 10000
- // minimum
+#define MENU_FILE_MIN 10000
+ // minimum
+#define MENU_PLAYLIST_ENTRY_MIN 11000
+#define MENU_PLAYLIST_ENTRY_MAX 11999
#endif // MENUID_H
diff --git a/Source/UI/platform.cpp b/Source/UI/platform.cpp
index 6564ac6..86dd755 100644
--- a/Source/UI/platform.cpp
+++ b/Source/UI/platform.cpp
@@ -57,12 +57,16 @@
static bool IsSupportedFile(char *path)
{
- char d88[] = ".d88";
+ char d88[] = ".d88";
+ char m3u[] = ".m3u";
+ char m3u8[] = ".m3u8";
char cmt[] = ".cmt";
char t88[] = ".t88";
char n80[] = ".n80";
- return check_file_extension(path, d88) ||
+ return check_file_extension(path, d88) ||
+ check_file_extension(path, m3u) ||
+ check_file_extension(path, m3u8) ||
check_file_extension(path, cmt) ||
check_file_extension(path, t88) ||
check_file_extension(path, n80);
@@ -395,7 +399,9 @@ const char* Platform::FindNext(Uint32 *info)
}
// supported ?
- if (check_file_extension(find_name, _T(".d88")) == false &&
+ if (check_file_extension(find_name, _T(".d88")) == false &&
+ check_file_extension(find_name, _T(".m3u")) == false &&
+ check_file_extension(find_name, _T(".m3u8")) == false &&
check_file_extension(find_name, _T(".cmt")) == false &&
check_file_extension(find_name, _T(".t88")) == false &&
check_file_extension(find_name, _T(".n80")) == false) {
diff --git a/Tests/clidisk_test.cpp b/Tests/clidisk_test.cpp
index 5a8ed1f..5e55bfe 100644
--- a/Tests/clidisk_test.cpp
+++ b/Tests/clidisk_test.cpp
@@ -1,4 +1,5 @@
#include "clidisk.h"
+#include "m3u.h"
#include
#include
@@ -49,6 +50,14 @@ bool WriteTextFile(const std::string& path, const std::string& content)
return file.good();
}
+bool WriteBinaryFile(const std::string& path, const std::string& content)
+{
+ std::ofstream file(path, std::ios::binary);
+ if (!file.is_open()) return false;
+ file.write(content.data(), static_cast(content.size()));
+ return file.good();
+}
+
} // namespace
int main()
@@ -129,6 +138,38 @@ int main()
std::remove(playlist.c_str());
}
CheckError({"xm8", "./clidisk_test_missing.m3u"}, "missing m3u");
+ {
+ const std::string m3u8 = "./clidisk_test_playlist.m3u8";
+ Check(WriteBinaryFile(m3u8, "\xef\xbb\xbfutf8.d88\n"), "write UTF-8 BOM m3u8");
+ CliOptions options = Parse({"xm8", m3u8.c_str()});
+ Check(options.action == CliAction::Run && options.disks.size() == 1 &&
+ options.disks[0].path == "./utf8.d88", "m3u8 UTF-8 BOM");
+ std::remove(m3u8.c_str());
+ }
+ {
+ const std::string utf16 = "./clidisk_test_utf16.m3u";
+ const char utf16_bytes[] = "\xff\xfe" "u\0t\0f\0" "1\0" ".\0d\0" "8\0" "8\0\n\0";
+ Check(WriteBinaryFile(utf16, std::string(utf16_bytes, sizeof(utf16_bytes) - 1)),
+ "write UTF-16 m3u");
+ M3UResult playlist = LoadM3U(utf16);
+ Check(playlist.success && playlist.entries.size() == 1 &&
+ playlist.entries[0] == "./utf1.d88", "m3u UTF-16 LE");
+ std::remove(utf16.c_str());
+ }
+ {
+ const std::string sjis = "./clidisk_test_sjis.m3u";
+ Check(WriteBinaryFile(sjis, "\x82\xa0.d88\n"), "write Shift-JIS m3u");
+ M3UResult playlist = LoadM3U(sjis);
+ Check(playlist.success && playlist.entries.size() == 1 &&
+ playlist.entries[0] == "./\xe3\x81\x82.d88", "m3u Shift-JIS fallback");
+ std::remove(sjis.c_str());
+ }
+ {
+ const std::string invalid = "./clidisk_test_invalid.m3u8";
+ Check(WriteBinaryFile(invalid, "\x82\xa0.d88\n"), "write invalid m3u8");
+ Check(!LoadM3U(invalid).success, "m3u8 rejects Shift-JIS fallback");
+ std::remove(invalid.c_str());
+ }
for (const char *value : {"V1S", "v1h", "V2", "n"}) {
Check(Parse({"xm8", "--system", value}).action == CliAction::Run,
"valid system");