diff --git a/src/common-tests/CMakeLists.txt b/src/common-tests/CMakeLists.txt
index 6502fe81d..4809e4eba 100644
--- a/src/common-tests/CMakeLists.txt
+++ b/src/common-tests/CMakeLists.txt
@@ -2,6 +2,7 @@ add_executable(common-tests
   bitutils_tests.cpp
   event_tests.cpp
   file_system_tests.cpp
+  path_tests.cpp
   rectangle_tests.cpp
 )
 
diff --git a/src/common-tests/common-tests.vcxproj b/src/common-tests/common-tests.vcxproj
index 6f9860091..de6b79b22 100644
--- a/src/common-tests/common-tests.vcxproj
+++ b/src/common-tests/common-tests.vcxproj
@@ -6,6 +6,7 @@
     
     
     
+    
     
   
   
@@ -19,9 +20,7 @@
   
     {EA2B9C7A-B8CC-42F9-879B-191A98680C10}
   
-
   
-
   
   
     
diff --git a/src/common-tests/common-tests.vcxproj.filters b/src/common-tests/common-tests.vcxproj.filters
index 225e130ff..a7bbe5e6a 100644
--- a/src/common-tests/common-tests.vcxproj.filters
+++ b/src/common-tests/common-tests.vcxproj.filters
@@ -6,5 +6,6 @@
     
     
     
+    
   
 
\ No newline at end of file
diff --git a/src/common-tests/file_system_tests.cpp b/src/common-tests/file_system_tests.cpp
index bfa8176eb..ce997418a 100644
--- a/src/common-tests/file_system_tests.cpp
+++ b/src/common-tests/file_system_tests.cpp
@@ -1,25 +1,3 @@
 #include "common/file_system.h"
 #include 
 
-TEST(FileSystem, IsAbsolutePath)
-{
-#ifdef _WIN32
-  ASSERT_TRUE(FileSystem::IsAbsolutePath("C:\\"));
-  ASSERT_TRUE(FileSystem::IsAbsolutePath("C:\\Path"));
-  ASSERT_TRUE(FileSystem::IsAbsolutePath("C:\\Path\\Subdirectory"));
-  ASSERT_TRUE(FileSystem::IsAbsolutePath("C:/"));
-  ASSERT_TRUE(FileSystem::IsAbsolutePath("C:/Path"));
-  ASSERT_TRUE(FileSystem::IsAbsolutePath("C:/Path/Subdirectory"));
-  ASSERT_FALSE(FileSystem::IsAbsolutePath(""));
-  ASSERT_FALSE(FileSystem::IsAbsolutePath("C:"));
-  ASSERT_FALSE(FileSystem::IsAbsolutePath("Path"));
-  ASSERT_FALSE(FileSystem::IsAbsolutePath("Path/Subdirectory"));
-#else
-  ASSERT_TRUE(FileSystem::IsAbsolutePath("/"));
-  ASSERT_TRUE(FileSystem::IsAbsolutePath("/path"));
-  ASSERT_TRUE(FileSystem::IsAbsolutePath("/path/subdirectory"));
-  ASSERT_FALSE(FileSystem::IsAbsolutePath(""));
-  ASSERT_FALSE(FileSystem::IsAbsolutePath("path"));
-  ASSERT_FALSE(FileSystem::IsAbsolutePath("path/subdirectory"));
-#endif
-}
diff --git a/src/common-tests/path_tests.cpp b/src/common-tests/path_tests.cpp
new file mode 100644
index 000000000..0f68bb4f6
--- /dev/null
+++ b/src/common-tests/path_tests.cpp
@@ -0,0 +1,225 @@
+#include "common/path.h"
+#include "common/types.h"
+#include 
+
+TEST(FileSystem, ToNativePath)
+{
+  ASSERT_EQ(Path::ToNativePath(""), "");
+
+#ifdef _WIN32
+  ASSERT_EQ(Path::ToNativePath("foo"), "foo");
+  ASSERT_EQ(Path::ToNativePath("foo\\"), "foo");
+  ASSERT_EQ(Path::ToNativePath("foo\\\\bar"), "foo\\bar");
+  ASSERT_EQ(Path::ToNativePath("foo\\bar"), "foo\\bar");
+  ASSERT_EQ(Path::ToNativePath("foo\\bar\\baz"), "foo\\bar\\baz");
+  ASSERT_EQ(Path::ToNativePath("foo\\bar/baz"), "foo\\bar\\baz");
+  ASSERT_EQ(Path::ToNativePath("foo/bar/baz"), "foo\\bar\\baz");
+  ASSERT_EQ(Path::ToNativePath("foo/🙃bar/b🙃az"), "foo\\🙃bar\\b🙃az");
+  ASSERT_EQ(Path::ToNativePath("\\\\foo\\bar\\baz"), "\\\\foo\\bar\\baz");
+#else
+  ASSERT_EQ(Path::ToNativePath("foo"), "foo");
+  ASSERT_EQ(Path::ToNativePath("foo/"), "foo");
+  ASSERT_EQ(Path::ToNativePath("foo//bar"), "foo/bar");
+  ASSERT_EQ(Path::ToNativePath("foo/bar"), "foo/bar");
+  ASSERT_EQ(Path::ToNativePath("foo/bar/baz"), "foo/bar/baz");
+  ASSERT_EQ(Path::ToNativePath("/foo/bar/baz"), "/foo/bar/baz");
+#endif
+}
+
+TEST(FileSystem, IsAbsolute)
+{
+  ASSERT_FALSE(Path::IsAbsolute(""));
+  ASSERT_FALSE(Path::IsAbsolute("foo"));
+  ASSERT_FALSE(Path::IsAbsolute("foo/bar"));
+  ASSERT_FALSE(Path::IsAbsolute("foo/b🙃ar"));
+#ifdef _WIN32
+  ASSERT_TRUE(Path::IsAbsolute("C:\\foo/bar"));
+  ASSERT_TRUE(Path::IsAbsolute("C://foo\\bar"));
+  ASSERT_FALSE(Path::IsAbsolute("\\foo/bar"));
+  ASSERT_TRUE(Path::IsAbsolute("\\\\foo\\bar\\baz"));
+  ASSERT_TRUE(Path::IsAbsolute("C:\\"));
+  ASSERT_TRUE(Path::IsAbsolute("C:\\Path"));
+  ASSERT_TRUE(Path::IsAbsolute("C:\\Path\\Subdirectory"));
+  ASSERT_TRUE(Path::IsAbsolute("C:/"));
+  ASSERT_TRUE(Path::IsAbsolute("C:/Path"));
+  ASSERT_TRUE(Path::IsAbsolute("C:/Path/Subdirectory"));
+  ASSERT_FALSE(Path::IsAbsolute(""));
+  ASSERT_FALSE(Path::IsAbsolute("C:"));
+  ASSERT_FALSE(Path::IsAbsolute("Path"));
+  ASSERT_FALSE(Path::IsAbsolute("Path/Subdirectory"));
+#else
+  ASSERT_TRUE(Path::IsAbsolute("/foo/bar"));
+  ASSERT_TRUE(Path::IsAbsolute("/"));
+  ASSERT_TRUE(Path::IsAbsolute("/path"));
+  ASSERT_TRUE(Path::IsAbsolute("/path/subdirectory"));
+  ASSERT_FALSE(Path::IsAbsolute(""));
+  ASSERT_FALSE(Path::IsAbsolute("path"));
+  ASSERT_FALSE(Path::IsAbsolute("path/subdirectory"));
+#endif
+}
+
+TEST(FileSystem, Canonicalize)
+{
+  ASSERT_EQ(Path::Canonicalize(""), Path::ToNativePath(""));
+  ASSERT_EQ(Path::Canonicalize("foo/bar/../baz"), Path::ToNativePath("foo/baz"));
+  ASSERT_EQ(Path::Canonicalize("foo/bar/./baz"), Path::ToNativePath("foo/bar/baz"));
+  ASSERT_EQ(Path::Canonicalize("foo/./bar/./baz"), Path::ToNativePath("foo/bar/baz"));
+  ASSERT_EQ(Path::Canonicalize("foo/bar/../baz/../foo"), Path::ToNativePath("foo/foo"));
+  ASSERT_EQ(Path::Canonicalize("foo/bar/../baz/./foo"), Path::ToNativePath("foo/baz/foo"));
+  ASSERT_EQ(Path::Canonicalize("./foo"), Path::ToNativePath("foo"));
+  ASSERT_EQ(Path::Canonicalize("../foo"), Path::ToNativePath("../foo"));
+  ASSERT_EQ(Path::Canonicalize("foo/b🙃ar/../b🙃az/./foo"), Path::ToNativePath("foo/b🙃az/foo"));
+  ASSERT_EQ(
+    Path::Canonicalize(
+      "ŻąłóРстуぬねのはen🍪⟑η∏☉ⴤℹ︎∩₲ ₱⟑♰⫳🐱/b🙃az/../foℹ︎o"),
+    Path::ToNativePath("ŻąłóРстуぬねのはen🍪⟑η∏☉ⴤℹ︎∩₲ ₱⟑♰⫳🐱/foℹ︎o"));
+#ifdef _WIN32
+  ASSERT_EQ(Path::Canonicalize("C:\\foo\\bar\\..\\baz\\.\\foo"), "C:\\foo\\baz\\foo");
+  ASSERT_EQ(Path::Canonicalize("C:/foo\\bar\\..\\baz\\.\\foo"), "C:\\foo\\baz\\foo");
+  ASSERT_EQ(Path::Canonicalize("foo\\bar\\..\\baz\\.\\foo"), "foo\\baz\\foo");
+  ASSERT_EQ(Path::Canonicalize("foo\\bar/..\\baz/.\\foo"), "foo\\baz\\foo");
+  ASSERT_EQ(Path::Canonicalize("\\\\foo\\bar\\baz/..\\foo"), "\\\\foo\\bar\\foo");
+#else
+  ASSERT_EQ(Path::Canonicalize("/foo/bar/../baz/./foo"), "/foo/baz/foo");
+#endif
+}
+
+TEST(FileSystem, Combine)
+{
+  ASSERT_EQ(Path::Combine("", ""), Path::ToNativePath(""));
+  ASSERT_EQ(Path::Combine("foo", "bar"), Path::ToNativePath("foo/bar"));
+  ASSERT_EQ(Path::Combine("foo/bar", "baz"), Path::ToNativePath("foo/bar/baz"));
+  ASSERT_EQ(Path::Combine("foo/bar", "../baz"), Path::ToNativePath("foo/bar/../baz"));
+  ASSERT_EQ(Path::Combine("foo/bar/", "/baz/"), Path::ToNativePath("foo/bar/baz"));
+  ASSERT_EQ(Path::Combine("foo//bar", "baz/"), Path::ToNativePath("foo/bar/baz"));
+  ASSERT_EQ(Path::Combine("foo//ba🙃r", "b🙃az/"), Path::ToNativePath("foo/ba🙃r/b🙃az"));
+#ifdef _WIN32
+  ASSERT_EQ(Path::Combine("C:\\foo\\bar", "baz"), "C:\\foo\\bar\\baz");
+  ASSERT_EQ(Path::Combine("\\\\server\\foo\\bar", "baz"), "\\\\server\\foo\\bar\\baz");
+  ASSERT_EQ(Path::Combine("foo\\bar", "baz"), "foo\\bar\\baz");
+  ASSERT_EQ(Path::Combine("foo\\bar\\", "baz"), "foo\\bar\\baz");
+  ASSERT_EQ(Path::Combine("foo/bar\\", "\\baz"), "foo\\bar\\baz");
+  ASSERT_EQ(Path::Combine("\\\\foo\\bar", "baz"), "\\\\foo\\bar\\baz");
+#else
+  ASSERT_EQ(Path::Combine("/foo/bar", "baz"), "/foo/bar/baz");
+#endif
+}
+
+TEST(FileSystem, AppendDirectory)
+{
+  ASSERT_EQ(Path::AppendDirectory("foo/bar", "baz"), Path::ToNativePath("foo/baz/bar"));
+  ASSERT_EQ(Path::AppendDirectory("", "baz"), Path::ToNativePath("baz"));
+  ASSERT_EQ(Path::AppendDirectory("", ""), Path::ToNativePath(""));
+  ASSERT_EQ(Path::AppendDirectory("foo/bar", "🙃"), Path::ToNativePath("foo/🙃/bar"));
+#ifdef _WIN32
+  ASSERT_EQ(Path::AppendDirectory("foo\\bar", "baz"), "foo\\baz\\bar");
+  ASSERT_EQ(Path::AppendDirectory("\\\\foo\\bar", "baz"), "\\\\foo\\baz\\bar");
+#else
+  ASSERT_EQ(Path::AppendDirectory("/foo/bar", "baz"), "/foo/baz/bar");
+#endif
+}
+
+TEST(FileSystem, MakeRelative)
+{
+  ASSERT_EQ(Path::MakeRelative("", ""), Path::ToNativePath(""));
+  ASSERT_EQ(Path::MakeRelative("foo", ""), Path::ToNativePath("foo"));
+  ASSERT_EQ(Path::MakeRelative("", "foo"), Path::ToNativePath(""));
+  ASSERT_EQ(Path::MakeRelative("foo", "bar"), Path::ToNativePath("foo"));
+
+#ifdef _WIN32
+#define A "C:\\"
+#else
+#define A "/"
+#endif
+
+  ASSERT_EQ(Path::MakeRelative(A "foo", A "bar"), Path::ToNativePath("../foo"));
+  ASSERT_EQ(Path::MakeRelative(A "foo/bar", A "foo"), Path::ToNativePath("bar"));
+  ASSERT_EQ(Path::MakeRelative(A "foo/bar", A "foo/baz"), Path::ToNativePath("../bar"));
+  ASSERT_EQ(Path::MakeRelative(A "foo/b🙃ar", A "foo/b🙃az"), Path::ToNativePath("../b🙃ar"));
+  ASSERT_EQ(Path::MakeRelative(A "f🙃oo/b🙃ar", A "f🙃oo/b🙃az"), Path::ToNativePath("../b🙃ar"));
+  ASSERT_EQ(
+    Path::MakeRelative(A "ŻąłóРстуぬねのはen🍪⟑η∏☉ⴤℹ︎∩₲ ₱⟑♰⫳🐱/b🙃ar",
+                       A "ŻąłóРстуぬねのはen🍪⟑η∏☉ⴤℹ︎∩₲ ₱⟑♰⫳🐱/b🙃az"),
+    Path::ToNativePath("../b🙃ar"));
+
+#undef A
+
+#ifdef _WIN32
+  ASSERT_EQ(Path::MakeRelative("\\\\foo\\bar\\baz\\foo", "\\\\foo\\bar\\baz"), "foo");
+  ASSERT_EQ(Path::MakeRelative("\\\\foo\\bar\\foo", "\\\\foo\\bar\\baz"), "..\\foo");
+  ASSERT_EQ(Path::MakeRelative("\\\\foo\\bar\\foo", "\\\\other\\bar\\foo"), "\\\\foo\\bar\\foo");
+#endif
+}
+
+TEST(FileSystem, GetExtension)
+{
+  ASSERT_EQ(Path::GetExtension("foo"), "");
+  ASSERT_EQ(Path::GetExtension("foo.txt"), "txt");
+  ASSERT_EQ(Path::GetExtension("foo.t🙃t"), "t🙃t");
+  ASSERT_EQ(Path::GetExtension("foo."), "");
+  ASSERT_EQ(Path::GetExtension("a/b/foo.txt"), "txt");
+  ASSERT_EQ(Path::GetExtension("a/b/foo"), "");
+}
+
+TEST(FileSystem, GetFileName)
+{
+  ASSERT_EQ(Path::GetFileName(""), "");
+  ASSERT_EQ(Path::GetFileName("foo"), "foo");
+  ASSERT_EQ(Path::GetFileName("foo.txt"), "foo.txt");
+  ASSERT_EQ(Path::GetFileName("foo"), "foo");
+  ASSERT_EQ(Path::GetFileName("foo/bar/."), ".");
+  ASSERT_EQ(Path::GetFileName("foo/bar/baz"), "baz");
+  ASSERT_EQ(Path::GetFileName("foo/bar/baz.txt"), "baz.txt");
+#ifdef _WIN32
+  ASSERT_EQ(Path::GetFileName("foo/bar\\baz"), "baz");
+  ASSERT_EQ(Path::GetFileName("foo\\bar\\baz.txt"), "baz.txt");
+#endif
+}
+
+TEST(FileSystem, GetFileTitle)
+{
+  ASSERT_EQ(Path::GetFileTitle(""), "");
+  ASSERT_EQ(Path::GetFileTitle("foo"), "foo");
+  ASSERT_EQ(Path::GetFileTitle("foo.txt"), "foo");
+  ASSERT_EQ(Path::GetFileTitle("foo/bar/."), "");
+  ASSERT_EQ(Path::GetFileTitle("foo/bar/baz"), "baz");
+  ASSERT_EQ(Path::GetFileTitle("foo/bar/baz.txt"), "baz");
+#ifdef _WIN32
+  ASSERT_EQ(Path::GetFileTitle("foo/bar\\baz"), "baz");
+  ASSERT_EQ(Path::GetFileTitle("foo\\bar\\baz.txt"), "baz");
+#endif
+}
+
+TEST(FileSystem, GetDirectory)
+{
+  ASSERT_EQ(Path::GetDirectory(""), "");
+  ASSERT_EQ(Path::GetDirectory("foo"), "");
+  ASSERT_EQ(Path::GetDirectory("foo.txt"), "");
+  ASSERT_EQ(Path::GetDirectory("foo/bar/."), "foo/bar");
+  ASSERT_EQ(Path::GetDirectory("foo/bar/baz"), "foo/bar");
+  ASSERT_EQ(Path::GetDirectory("foo/bar/baz.txt"), "foo/bar");
+#ifdef _WIN32
+  ASSERT_EQ(Path::GetDirectory("foo\\bar\\baz"), "foo\\bar");
+  ASSERT_EQ(Path::GetDirectory("foo\\bar/baz.txt"), "foo\\bar");
+#endif
+}
+
+TEST(FileSystem, ChangeFileName)
+{
+  ASSERT_EQ(Path::ChangeFileName("", ""), Path::ToNativePath(""));
+  ASSERT_EQ(Path::ChangeFileName("", "bar"), Path::ToNativePath("bar"));
+  ASSERT_EQ(Path::ChangeFileName("bar", ""), Path::ToNativePath(""));
+  ASSERT_EQ(Path::ChangeFileName("foo/bar", ""), Path::ToNativePath("foo"));
+  ASSERT_EQ(Path::ChangeFileName("foo/", "bar"), Path::ToNativePath("foo/bar"));
+  ASSERT_EQ(Path::ChangeFileName("foo/bar", "baz"), Path::ToNativePath("foo/baz"));
+  ASSERT_EQ(Path::ChangeFileName("foo//bar", "baz"), Path::ToNativePath("foo/baz"));
+  ASSERT_EQ(Path::ChangeFileName("foo//bar.txt", "baz.txt"), Path::ToNativePath("foo/baz.txt"));
+  ASSERT_EQ(Path::ChangeFileName("foo//ba🙃r.txt", "ba🙃z.txt"), Path::ToNativePath("foo/ba🙃z.txt"));
+#ifdef _WIN32
+  ASSERT_EQ(Path::ChangeFileName("foo/bar", "baz"), "foo\\baz");
+  ASSERT_EQ(Path::ChangeFileName("foo//bar\\foo", "baz"), "foo\\bar\\baz");
+  ASSERT_EQ(Path::ChangeFileName("\\\\foo\\bar\\foo", "baz"), "\\\\foo\\bar\\baz");
+#else
+  ASSERT_EQ(Path::ChangeFileName("/foo/bar", "baz"), "/foo/baz");
+#endif
+}
\ No newline at end of file
diff --git a/src/common/CMakeLists.txt b/src/common/CMakeLists.txt
index e4a337309..e98cf1be2 100644
--- a/src/common/CMakeLists.txt
+++ b/src/common/CMakeLists.txt
@@ -69,6 +69,7 @@ add_library(common
   memory_arena.h
   page_fault_handler.cpp
   page_fault_handler.h
+  path.h
   platform.h
   pbp_types.h
   progress_callback.cpp
@@ -87,8 +88,6 @@ add_library(common
   thirdparty/thread_pool.h
   timer.cpp
   timer.h
-  timestamp.cpp
-  timestamp.h
   types.h
   vulkan/builders.cpp
   vulkan/builders.h
diff --git a/src/common/cd_image.cpp b/src/common/cd_image.cpp
index 1efd3ce9d..e3376e8cc 100644
--- a/src/common/cd_image.cpp
+++ b/src/common/cd_image.cpp
@@ -2,6 +2,7 @@
 #include "assert.h"
 #include "file_system.h"
 #include "log.h"
+#include "path.h"
 #include "string_util.h"
 #include 
 Log_SetChannel(CDImage);
@@ -293,7 +294,7 @@ std::string CDImage::GetMetadata(const std::string_view& type) const
   if (type == "title")
   {
     const std::string display_name(FileSystem::GetDisplayNameFromPath(m_filename));
-    result = FileSystem::StripExtension(display_name);
+    result = Path::StripExtension(display_name);
   }
 
   return result;
diff --git a/src/common/cd_image_chd.cpp b/src/common/cd_image_chd.cpp
index b87b5dabb..5cd39d36c 100644
--- a/src/common/cd_image_chd.cpp
+++ b/src/common/cd_image_chd.cpp
@@ -303,7 +303,7 @@ bool CDImageCHD::HasNonStandardSubchannel() const
 
 CDImage::PrecacheResult CDImageCHD::Precache(ProgressCallback* progress)
 {
-  const std::string_view title(FileSystem::GetFileNameFromPath(m_filename));
+  const std::string_view title(FileSystem::GetDisplayNameFromPath(m_filename));
   progress->SetFormattedStatusText("Precaching %.*s...", static_cast(title.size()), title.data());
   progress->SetProgressRange(100);
 
diff --git a/src/common/cd_image_cue.cpp b/src/common/cd_image_cue.cpp
index 8c7865b71..f72cc2ef8 100644
--- a/src/common/cd_image_cue.cpp
+++ b/src/common/cd_image_cue.cpp
@@ -5,6 +5,7 @@
 #include "error.h"
 #include "file_system.h"
 #include "log.h"
+#include "path.h"
 #include 
 #include 
 #include 
@@ -88,15 +89,14 @@ bool CDImageCueSheet::OpenAndParse(const char* filename, Common::Error* error)
     }
     if (track_file_index == m_files.size())
     {
-      const std::string track_full_filename(!FileSystem::IsAbsolutePath(track_filename) ?
-                                              FileSystem::BuildRelativePath(m_filename, track_filename) :
-                                              track_filename);
+      const std::string track_full_filename(
+        !Path::IsAbsolute(track_filename) ? Path::BuildRelativePath(m_filename, track_filename) : track_filename);
       std::FILE* track_fp = FileSystem::OpenCFile(track_full_filename.c_str(), "rb");
       if (!track_fp && track_file_index == 0)
       {
         // many users have bad cuesheets, or they're renamed the files without updating the cuesheet.
         // so, try searching for a bin with the same name as the cue, but only for the first referenced file.
-        const std::string alternative_filename(FileSystem::ReplaceExtension(filename, "bin"));
+        const std::string alternative_filename(Path::ReplaceExtension(filename, "bin"));
         track_fp = FileSystem::OpenCFile(alternative_filename.c_str(), "rb");
         if (track_fp)
         {
diff --git a/src/common/cd_image_m3u.cpp b/src/common/cd_image_m3u.cpp
index 9e0afbbb8..900f758b5 100644
--- a/src/common/cd_image_m3u.cpp
+++ b/src/common/cd_image_m3u.cpp
@@ -4,10 +4,11 @@
 #include "error.h"
 #include "file_system.h"
 #include "log.h"
+#include "path.h"
 #include 
 #include 
-#include 
 #include