diff --git a/src/core/CMakeLists.txt b/src/core/CMakeLists.txt
index 496620b76..9d013c292 100644
--- a/src/core/CMakeLists.txt
+++ b/src/core/CMakeLists.txt
@@ -19,6 +19,8 @@ add_library(core
digital_controller.h
dma.cpp
dma.h
+ game_list.cpp
+ game_list.h
gpu.cpp
gpu.h
gpu_commands.cpp
diff --git a/src/core/core.vcxproj b/src/core/core.vcxproj
index a280ac044..34e57cd3e 100644
--- a/src/core/core.vcxproj
+++ b/src/core/core.vcxproj
@@ -48,6 +48,7 @@
+
@@ -80,6 +81,7 @@
+
diff --git a/src/core/core.vcxproj.filters b/src/core/core.vcxproj.filters
index ff73ce0d0..9cfdd6eed 100644
--- a/src/core/core.vcxproj.filters
+++ b/src/core/core.vcxproj.filters
@@ -33,6 +33,7 @@
+
@@ -69,6 +70,7 @@
+
diff --git a/src/core/game_list.cpp b/src/core/game_list.cpp
new file mode 100644
index 000000000..560e6a773
--- /dev/null
+++ b/src/core/game_list.cpp
@@ -0,0 +1,185 @@
+#include "game_list.h"
+#include "YBaseLib/CString.h"
+#include "YBaseLib/FileSystem.h"
+#include "YBaseLib/Log.h"
+#include "common/cd_image.h"
+#include "common/iso_reader.h"
+#include
+#include
+#include
+Log_SetChannel(GameList);
+
+GameList::GameList() = default;
+
+GameList::~GameList() = default;
+
+std::string GameList::GetGameCodeForPath(const char* image_path)
+{
+ std::unique_ptr cdi = CDImage::Open(image_path);
+ if (!cdi)
+ return {};
+
+ return GetGameCodeForImage(cdi.get());
+}
+
+std::string GameList::GetGameCodeForImage(CDImage* cdi)
+{
+ ISOReader iso;
+ if (!iso.Open(cdi, 1))
+ return {};
+
+ // Read SYSTEM.CNF
+ std::vector system_cnf_data;
+ if (!iso.ReadFile("SYSTEM.CNF", &system_cnf_data))
+ return {};
+
+ // Parse lines
+ std::vector> lines;
+ std::pair current_line;
+ bool reading_value = false;
+ for (size_t pos = 0; pos < system_cnf_data.size(); pos++)
+ {
+ const char ch = static_cast(system_cnf_data[pos]);
+ if (ch == '\r' || ch == '\n')
+ {
+ if (!current_line.first.empty())
+ {
+ lines.push_back(std::move(current_line));
+ current_line = {};
+ reading_value = false;
+ }
+ }
+ else if (ch == ' ')
+ {
+ continue;
+ }
+ else if (ch == '=' && !reading_value)
+ {
+ reading_value = true;
+ }
+ else
+ {
+ if (reading_value)
+ current_line.second.push_back(ch);
+ else
+ current_line.first.push_back(ch);
+ }
+ }
+
+ if (!current_line.first.empty())
+ lines.push_back(std::move(current_line));
+
+ // Find the BOOT line
+ auto iter =
+ std::find_if(lines.begin(), lines.end(), [](const auto& it) { return Y_stricmp(it.first.c_str(), "boot") == 0; });
+ if (iter == lines.end())
+ return {};
+
+ // cdrom:\SCES_123.45;1
+ std::string code = iter->second;
+ std::string::size_type pos = code.rfind('\\');
+ if (pos == std::string::npos)
+ return {};
+ code.erase(0, pos + 1);
+ pos = code.find(';');
+ if (pos == std::string::npos)
+ return {};
+
+ code.erase(pos);
+
+ // SCES_123.45 -> SCES-12345
+ for (pos = 0; pos < code.size();)
+ {
+ if (code[pos] == '_')
+ code[pos++] = '-';
+ else if (code[pos] == '.')
+ code.erase(pos, 1);
+ else
+ pos++;
+ }
+
+ return code;
+}
+
+std::optional GameList::GetRegionForCode(std::string_view code)
+{
+ std::string prefix;
+ for (size_t pos = 0; pos < code.length(); pos++)
+ {
+ const int ch = std::tolower(code[pos]);
+ if (ch < 'a' || ch > 'z')
+ break;
+
+ prefix.push_back(static_cast(ch));
+ }
+
+ // TODO: PAPX?
+
+ if (prefix == "sces" || prefix == "sced" || prefix == "sles" || prefix == "sled")
+ return ConsoleRegion::PAL;
+ else if (prefix == "scps" || prefix == "scpd" || prefix == "slps" || prefix == "slpd")
+ return ConsoleRegion::NTSC_J;
+ else if (prefix == "scus" || prefix == "slus")
+ return ConsoleRegion::NTSC_U;
+ else
+ return std::nullopt;
+}
+
+void GameList::AddDirectory(const char* path, bool recursive)
+{
+ ScanDirectory(path, recursive);
+}
+
+bool GameList::GetGameListEntry(const char* path, GameListEntry* entry)
+{
+ std::unique_ptr cdi = CDImage::Open(path);
+ if (!cdi)
+ return false;
+
+ std::string game_code = GetGameCodeForImage(cdi.get());
+
+ entry->path = path;
+ entry->title = game_code;
+ entry->code = game_code;
+ entry->region = GetRegionForCode(game_code).value_or(ConsoleRegion::NTSC_U);
+ return true;
+}
+
+void GameList::ScanDirectory(const char* path, bool recursive)
+{
+ Log_DevPrintf("Scanning %s%s", path, recursive ? " (recursively)" : "");
+
+ FileSystem::FindResultsArray files;
+ FileSystem::FindFiles(path, "*", FILESYSTEM_FIND_FILES | FILESYSTEM_FIND_RECURSIVE, &files);
+
+ GameListEntry entry;
+ for (const FILESYSTEM_FIND_DATA& ffd : files)
+ {
+ Log_DebugPrintf("Trying '%s'...", ffd.FileName);
+
+ // if this is a .bin, check if we have a .cue. if there is one, skip it
+ const char* extension = std::strrchr(ffd.FileName, '.');
+ if (extension && Y_stricmp(extension, ".bin") == 0)
+ {
+#if 0
+ std::string temp(ffd.FileName, extension - ffd.FileName);
+ temp += ".cue";
+ if (std::any_of(files.begin(), files.end(),
+ [&temp](const FILESYSTEM_FIND_DATA& it) { return Y_stricmp(it.FileName, temp.c_str()) == 0; }))
+ {
+ Log_DebugPrintf("Skipping due to '%s' existing", temp.c_str());
+ continue;
+ }
+#else
+ continue;
+#endif
+ }
+
+ // try opening the image
+ if (GetGameListEntry(ffd.FileName, &entry))
+ {
+ m_entries.push_back(std::move(entry));
+ entry = {};
+ }
+ }
+}
diff --git a/src/core/game_list.h b/src/core/game_list.h
new file mode 100644
index 000000000..5957127b2
--- /dev/null
+++ b/src/core/game_list.h
@@ -0,0 +1,36 @@
+#pragma once
+#include "types.h"
+#include
+#include
+#include
+#include
+
+class CDImage;
+
+class GameList
+{
+public:
+ GameList();
+ ~GameList();
+
+ static std::string GetGameCodeForImage(CDImage* cdi);
+ static std::string GetGameCodeForPath(const char* image_path);
+ static std::optional GetRegionForCode(std::string_view code);
+
+ void AddDirectory(const char* path, bool recursive);
+
+private:
+ struct GameListEntry
+ {
+ std::string path;
+ std::string code;
+ std::string title;
+ ConsoleRegion region;
+ };
+
+ bool GetGameListEntry(const char* path, GameListEntry* entry);
+
+ void ScanDirectory(const char* path, bool recursive);
+
+ std::vector m_entries;
+};