Repository: gemrb/gemrb (C++)

Issue:
Crash on save game directory iteration
#### Bug description
In my IWD2 folder, I have around 60 save games that were created with gemrb, and thus I'm confident they are from the last gemrb-only playthrough, so I'm sure it's a regression.

Trying to start the game now fails half-way.

#### Screenshots
```
C:/MSYS2/mingw64/include/c++/15.2.0/bits/basic_string.h:1369: std::__cxx11::basic_string<_CharT, _Traits, _Alloc>::reference std::__cxx11::basic_string<_CharT, _Traits, _Alloc>::operator[](size_type) [with _CharT = char; _Traits = std::char
_traits<char>; _Alloc = std::allocator<char>; reference = char&; size_type = long long unsigned int]: Assertion '__pos <= size()' failed.

Thread 1 hit Breakpoint 1.2, 0x00007ffe6abff1e7 in msvcrt!abort () from C:\Windows\System32\msvcrt.dll
(gdb) bt
#0  0x00007ffe6abff1e7 in msvcrt!abort () from C:\Windows\System32\msvcrt.dll
#1  0x00007ffe3423ab7b in ?? () from C:\MSYS2\mingw64\bin\libstdc++-6.dll
#2  0x00007ffde93834d7 in std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator[] (this=0x226094d0, __pos=284) at C:/MSYS2/mingw64/include/c++/15.2.0/bits/basic_string.h:1369
#3  0x00007ffde92845f9 in dirent::operator= (this=0x226094d0, entryName=...) at D:/Sources/gemrb/gemrb/core/System/VFS.cpp:80
#4  0x00007ffde91d77c0 in readdir (dir=0x22609270) at D:/Sources/gemrb/gemrb/core/System/VFS.cpp:136
#5  0x00007ffde91d9058 in GemRB::DirectoryIterator::operator++ (this=0x5fc6b0) at D:/Sources/gemrb/gemrb/core/System/VFS.cpp:644
#6  0x00007ffde9184c3b in GemRB::SaveGameIterator::RescanSaveGames (this=0xe08450) at D:/Sources/gemrb/gemrb/core/SaveGameIterator.cpp:306
#7  0x00007ffde9184e10 in GemRB::SaveGameIterator::GetSaveGames (this=0xe08450) at D:/Sources/gemrb/gemrb/core/SaveGameIterator.cpp:320
#8  0x00007ffe2978004e in GemRB_GetSaveGames () at D:/Sources/gemrb/gemrb/plugins/GUIScript/GUIScript.cpp:4353
#9  0x00007ffe26113fcc in cfunction_call (func=0x905ce90, args=0x7ffe263e0d60 <_PyRuntime+82784>, kwargs=0x0) at ../Python-3.12.8/Objects/methodobject.c:548
#10 0x00007ffe260a335e in _PyObject_MakeTpCall (tstate=0x7ffe2643e6a0 <_PyRuntime+466080>, callable=0x905ce90, args=0x1f00f8, nargs=0, keywords=0x0) at ../Python-3.12.8/Objects/call.c:240
#11 0x00007ffe260a28c0 in _PyObject_VectorcallTstate (tstate=0x7ffe2643e6a0 <_PyRuntime+466080>, callable=0x905ce90, args=0x1f00f8, nargsf=9223372036854775808, kwnames=0x0) at ../Python-3.12.8/Include/internal/pycore_call.h:90
#12 0x00007ffe260a3707 in PyObject_Vectorcall (callable=0x905ce90, args=0x1f00f8, nargsf=9223372036854775808, kwnames=0x0) at ../Python-3.12.8/Objects/call.c:325
#13 0x00007ffe26222caa in _PyEval_EvalFrameDefault (tstate=0x7ffe2643e6a0 <_PyRuntime+466080>, frame=0x1f0020, throwflag=0) at Python/bytecodes.c:2715
#14 0x00007ffe2620a400 in _PyEval_EvalFrame (tstate=0x7ffe2643e6a0 <_PyRuntime+466080>, frame=0x1f0020, throwflag=0) at ../Python-3.12.8/Include/internal/pycore_ceval.h:89
#15 0x00007ffe2622d700 in _PyEval_Vector (tstate=0x7ffe2643e6a0 <_PyRuntime+466080>, func=0x904e210, locals=0x0, args=0x0, argcount=0, kwnames=0x0) at ../Python-3.12.8/Python/ceval.c:1683
#16 0x00007ffe260a3b20 in _PyFunction_Vectorcall (func=0x904e210, stack=0x0, nargsf=0, kwnames=0x0) at ../Python-3.12.8/Objects/call.c:419
#17 0x00007ffe260a28e2 in _PyObject_VectorcallTstate (tstate=0x7ffe2643e6a0 <_PyRuntime+466080>, callable=0x904e210, args=0x0, nargsf=0, kwnames=0x0) at ../Python-3.12.8/Include/internal/pycore_call.h:92
#18 0x00007ffe260a293e in _PyObject_CallNoArgsTstate (tstate=0x7ffe2643e6a0 <_PyRuntime+466080>, func=0x904e210) at ../Python-3.12.8/Include/internal/pycore_call.h:99
#19 0x00007ffe260a3cf3 in PyObject_CallObject (callable=0x904e210, args=0x0) at ../Python-3.12.8/Objects/call.c:469
#20 0x00007ffe297de4d9 in GemRB::CallObjectWrapper (function=0x904e210, args=0x0) at D:/Sources/gemrb/gemrb/plugins/GUIScript/PythonCallbacks.h:47
#21 0x00007ffe297a3e55 in GemRB::GUIScript::RunPyFunction (this=0xe0812f0, moduleName=0x5ff4e8 "Start", functionName=0x7ffde94550a1 <GemRB::IDT_SKILLPENALTY+3049> "OnLoad", pArgs=0x0, report_error=true)
    at D:/Sources/gemrb/gemrb/plugins/GUIScript/GUIScript.cpp:13291
#22 0x00007ffe297a3ccb in GemRB::GUIScript::RunPyFunction (this=0xe0812f0, Modulename=0x5ff4e8 "Start", FunctionName=0x7ffde94550a1 <GemRB::IDT_SKILLPENALTY+3049> "OnLoad", params=std::vector of length 0, capacity 0, report_error=true)
    at D:/Sources/gemrb/gemrb/plugins/GUIScript/GUIScript.cpp:13257
#23 0x00007ffe297a3919 in GemRB::GUIScript::RunFunction (this=0xe0812f0, Modulename=0x5ff4e8 "Start", FunctionName=0x7ffde94550a1 <GemRB::IDT_SKILLPENALTY+3049> "OnLoad", params=std::vector of length 0, capacity 0, report_error=true)
    at D:/Sources/gemrb/gemrb/plugins/GUIScript/GUIScript.cpp:13217
#24 0x00007ffde9187712 in GemRB::ScriptEngine::RunFunction (this=0xe0812f0, Modulename=0x5ff4e8 "Start", FunctionName=0x7ffde94550a1 <GemRB::IDT_SKILLPENALTY+3049> "OnLoad", report_error=true)
    at D:/Sources/gemrb/gemrb/core/ScriptEngine.cpp:55
#25 0x00007ffde9138f28 in GemRB::Interface::HandleFlags (this=0x5ff1d0) at D:/Sources/gemrb/gemrb/core/Interface.cpp:843
#26 0x00007ffde9139b1c in GemRB::Interface::Main (this=0x5ff1d0) at D:/Sources/gemrb/gemrb/core/Interface.cpp:992
#27 0x00007ff653a01651 in main (argc=1, argv=0xe07be0) at D:/Sources/gemrb/platforms/windows/GemRB.cpp:65
```

GemRB version (check as many as you know apply)
- [x] master as of this issue
- [ ] 0.9.4
- [ ] 0.9.3
- [ ] 0.9.2



Discussion:
Hmm, I tried with a bunch of my saves of various ages and can't reproduce. But it is in the windows-specific readdir, so at least it seems to be limited to that ... I guess the character conversion fails (RecodedStringFromWideStringBytes).
It's also not a regression, some old versions of this era now crash, too. Maybe a bug on the Windows path that now gets caught by some new GCC v15.2 assertion.



Patch:
diff --git a/gemrb/core/SaveGameIterator.cpp b/gemrb/core/SaveGameIterator.cpp
index d5aa894ece..1a9c383f5a 100644
--- a/gemrb/core/SaveGameIterator.cpp
+++ b/gemrb/core/SaveGameIterator.cpp
@@ -132,17 +132,14 @@ SaveGame::SaveGame(path_t path, const path_t& name, const ResRef& prefix, std::s
 	static const auto DATE_FMT = FMT_STRING("{:%a %Od %b %T %EY}");
 	PortraitCount = pCount;
 	SaveID = saveID;
-	struct stat my_stat;
 	path_t nPath = PathJoinExt(Path, Prefix, "bmp");
-	memset(&my_stat, 0, sizeof(my_stat));
-	std::tm* time;
-	if (stat(nPath.c_str(), &my_stat)) {
-		Log(ERROR, "SaveGameIterator", "Stat call failed, using dummy time!");
-		time = std::localtime(nullptr);
+
+	auto time = FileModificationTime(nPath);
+	if (time != nullptr) {
+		Date = fmt::format(DATE_FMT, *time);
 	} else {
-		time = std::localtime(&my_stat.st_mtime);
+		Date = "n/a";
 	}
-	Date = fmt::format(DATE_FMT, *time);
 	manager.AddSource(Path, name, PLUGIN_RESOURCE_DIRECTORY);
 	Name = StringFromUtf8(name);
 }
diff --git a/gemrb/core/Strings/StringConversion.cpp b/gemrb/core/Strings/StringConversion.cpp
index 26651d53ed..1d43661848 100644
--- a/gemrb/core/Strings/StringConversion.cpp
+++ b/gemrb/core/Strings/StringConversion.cpp
@@ -133,6 +133,8 @@ std::string RecodedStringFromWideStringBytes(const char16_t* bytes, size_t bytes
 		return "";
 	}
 
+	buffer.resize(buffer.length() - outLenLeft);
+
 	return buffer;
 }
 
diff --git a/gemrb/core/System/VFS.cpp b/gemrb/core/System/VFS.cpp
index b4e524af5d..48b4431b29 100644
--- a/gemrb/core/System/VFS.cpp
+++ b/gemrb/core/System/VFS.cpp
@@ -74,7 +74,7 @@ struct dirent {
 
 	dirent& operator=(std::string&& entryName)
 	{
-		auto cutOff = entryName.length();
+		auto cutOff = std::min(entryName.length(), static_cast<size_t>(_MAX_PATH - 1));
 		buffer = std::move(entryName);
 		buffer.resize(_MAX_PATH);
 		buffer[cutOff] = 0;
@@ -244,6 +244,60 @@ bool FileExists(const path_t& path)
 	return true;
 }
 
+std::tm* FileModificationTime(const path_t& path)
+{
+#ifdef WIN32
+	static std::tm timestamp;
+
+	auto buffer = StringFromUtf8(path.c_str());
+	auto wideChars = reinterpret_cast<const wchar_t*>(buffer.c_str());
+
+	auto file =
+		CreateFile(
+			wideChars,
+			GENERIC_READ,
+			FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
+			nullptr,
+			OPEN_EXISTING,
+			FILE_ATTRIBUTE_NORMAL,
+			nullptr);
+
+	if (file == INVALID_HANDLE_VALUE) {
+		return nullptr;
+	}
+
+	FILETIME moditicationTime;
+	auto result = GetFileTime(file, nullptr, nullptr, &moditicationTime);
+	CloseHandle(file);
+	if (result == 0) {
+		return nullptr;
+	}
+
+	SYSTEMTIME systemTime, localTime;
+	FileTimeToSystemTime(&moditicationTime, &systemTime);
+	SystemTimeToTzSpecificLocalTime(nullptr, &systemTime, &localTime);
+
+	timestamp.tm_sec = localTime.wSecond;
+	timestamp.tm_min = localTime.wMinute;
+	timestamp.tm_hour = localTime.wHour;
+	timestamp.tm_wday = localTime.wDayOfWeek;
+	timestamp.tm_mday = localTime.wDay;
+	timestamp.tm_mon = localTime.wMonth - 1;
+	timestamp.tm_year = localTime.wYear - 1900;
+
+	return &timestamp;
+#else
+	struct stat statStruct;
+	memset(&statStruct, 0, sizeof(statStruct));
+
+	if (stat(path.c_str(), &statStruct)) {
+		return nullptr;
+	} else {
+		return std::localtime(&statStruct.st_mtime);
+	}
+#endif
+}
+
 void PathAppend(path_t& target, const path_t& name)
 {
 	if (name.empty()) {
diff --git a/gemrb/core/System/VFS.h b/gemrb/core/System/VFS.h
index 0fac319ac2..8df6fbd9dc 100644
--- a/gemrb/core/System/VFS.h
+++ b/gemrb/core/System/VFS.h
@@ -78,6 +78,7 @@ GEM_EXPORT path_t& ResolveFilePath(path_t& FilePath);
 
 GEM_EXPORT bool DirExists(const path_t& path);
 GEM_EXPORT bool FileExists(const path_t& path);
+GEM_EXPORT std::tm* FileModificationTime(const path_t& path);
 
 // when case sensitivity is enabled dir will be transformed to fit the case of the actual items composing the path
 GEM_EXPORT path_t& ResolveCase(path_t& dir);


Test patch:
diff --git a/gemrb/tests/core/Strings/Test_String.cpp b/gemrb/tests/core/Strings/Test_String.cpp
index 0e4b628e5b..7e7e2775ca 100644
--- a/gemrb/tests/core/Strings/Test_String.cpp
+++ b/gemrb/tests/core/Strings/Test_String.cpp
@@ -18,6 +18,7 @@
  */
 
 #include "Strings/String.h"
+#include "Strings/StringConversion.h"
 
 #include <gtest/gtest.h>
 
@@ -325,4 +326,12 @@ TEST(StringTest, StringToUpper)
 #endif
 }
 
+TEST(StringTest, RecodedStringFromWideStringBytes)
+{
+	std::u16string original { u"abc" };
+	auto utf8 = RecodedStringFromWideStringBytes(reinterpret_cast<const char16_t*>(original.c_str()), 6, "UTF-8");
+	EXPECT_EQ(3, utf8.length());
+	EXPECT_EQ("abc", utf8);
+}
+
 }
diff --git a/gemrb/tests/core/System/Test_VFS.cpp b/gemrb/tests/core/System/Test_VFS.cpp
index e01ad9caea..7bdf267dca 100644
--- a/gemrb/tests/core/System/Test_VFS.cpp
+++ b/gemrb/tests/core/System/Test_VFS.cpp
@@ -236,6 +236,14 @@ TEST(VFSTest, UnlinkFile)
 	EXPECT_FALSE(FileExists(umlautFilePath));
 }
 
+TEST(VFSTest, FileModificationTime)
+{
+	auto baseDir = PathJoin("tests", "resources", "VFS", "encoding");
+	EXPECT_NE(nullptr, FileModificationTime(PathJoin(baseDir, "file.txt")));
+	EXPECT_NE(nullptr, FileModificationTime(PathJoin(baseDir, "file_äöü.txt")));
+	EXPECT_EQ(nullptr, FileModificationTime("na"));
+}
+
 path_t getTempPath()
 {
 #ifdef WIN32