From 5c5c68bd848a4c6b4f58dc4cc8b40bb43985e5c4 Mon Sep 17 00:00:00 2001 From: Jamiras Date: Wed, 26 Aug 2026 18:48:16 -0600 Subject: [PATCH 1/2] store last badge directory in XXX-User.txt --- src/devkit/data/models/GameAssets.cpp | 15 ++++++++ src/devkit/data/models/LocalBadgesModel.cpp | 2 + src/devkit/data/models/LocalBadgesModel.hh | 15 ++++++++ src/devkit/services/IFileSystem.hh | 5 +++ src/devkit/util/Tokenizer.cpp | 14 +++++++ src/devkit/util/Tokenizer.hh | 7 ++++ src/services/impl/WindowsFileSystem.cpp | 9 +++++ src/services/impl/WindowsFileSystem.hh | 1 + src/ui/viewmodels/AssetEditorViewModel.cpp | 8 ++++ tests/devkit/data/models/GameAssets_Tests.cpp | 5 ++- tests/devkit/services/mocks/MockFileSystem.hh | 9 +++++ tests/devkit/util/Tokenizer_Tests.cpp | 19 ++++++++++ .../viewmodels/AssetEditorViewModel_Tests.cpp | 37 +++++++++++++++++++ 13 files changed, 145 insertions(+), 1 deletion(-) diff --git a/src/devkit/data/models/GameAssets.cpp b/src/devkit/data/models/GameAssets.cpp index 7349b982..9bfb51b6 100644 --- a/src/devkit/data/models/GameAssets.cpp +++ b/src/devkit/data/models/GameAssets.cpp @@ -326,6 +326,14 @@ void GameAssets::ReloadAssets(const std::vector void Commit(const std::wstring& sPreviousBadgeName, const std::wstring& sNewBadgeName); + /// + /// The for the last directory from which a badge was selected. + /// + static const StringModelProperty LastDirectoryProperty; + + /// + /// Gets the last directory from which a badge was selected. + /// + const std::wstring& GetLastDirectory() const { return GetValue(LastDirectoryProperty); } + + /// + /// Sets the last directory from which a badge was selected. + /// + void SetLastDirectory(const std::wstring& sValue) { SetValue(LastDirectoryProperty, sValue); } + private: struct BadgeReferenceCount { diff --git a/src/devkit/services/IFileSystem.hh b/src/devkit/services/IFileSystem.hh index 62bb379a..89430ec6 100644 --- a/src/devkit/services/IFileSystem.hh +++ b/src/devkit/services/IFileSystem.hh @@ -100,6 +100,11 @@ public: /// Will create the file if it doesn't already exist. virtual std::unique_ptr AppendTextFile(const std::wstring& sPath) const = 0; + /// + /// Gets the directory portion of a path. + /// + virtual std::wstring GetDirectory(const std::wstring& sPath) const = 0; + /// /// Gets the filename portion of a path. /// diff --git a/src/devkit/util/Tokenizer.cpp b/src/devkit/util/Tokenizer.cpp index 0c3d37be..500dcbc7 100644 --- a/src/devkit/util/Tokenizer.cpp +++ b/src/devkit/util/Tokenizer.cpp @@ -3,6 +3,20 @@ namespace ra { namespace util { +bool Tokenizer::Match(const std::string_view sText) noexcept +{ + if (m_nPosition + sText.length() <= m_sString.length()) + { + if (m_sString.compare(m_nPosition, sText.length(), sText) == 0) + { + m_nPosition += sText.length(); + return true; + } + } + + return false; +} + std::string Tokenizer::ReadQuotedString() { std::string sString; diff --git a/src/devkit/util/Tokenizer.hh b/src/devkit/util/Tokenizer.hh index 9f182300..05885a00 100644 --- a/src/devkit/util/Tokenizer.hh +++ b/src/devkit/util/Tokenizer.hh @@ -63,6 +63,13 @@ public: ++m_nPosition; } + /// + /// Attempts to match the specified string. + /// + /// true if the next set of characters matches the provided text. + /// Consumes the text if matched. + bool Match(const std::string_view sText) noexcept; + /// /// Advances the cursor to the next occurrence of the specified character, or the end of the string if no /// occurrences are found and returns a string containing all of the characters advanced over. diff --git a/src/services/impl/WindowsFileSystem.cpp b/src/services/impl/WindowsFileSystem.cpp index 8e8c7adb..6f1a210b 100644 --- a/src/services/impl/WindowsFileSystem.cpp +++ b/src/services/impl/WindowsFileSystem.cpp @@ -38,6 +38,15 @@ const std::wstring& WindowsFileSystem::MakeAbsolute(std::wstring& sBuffer, const return sBuffer; } +std::wstring WindowsFileSystem::GetDirectory(const std::wstring& sPath) const +{ + const auto nIndex = sPath.find_last_of(L"/\\"); + if (nIndex != std::string::npos) + return std::wstring(sPath, 0, nIndex); + + return sPath; +} + std::wstring WindowsFileSystem::GetFileName(const std::wstring& sPath) const { const auto nIndex = sPath.find_last_of(L"/\\"); diff --git a/src/services/impl/WindowsFileSystem.hh b/src/services/impl/WindowsFileSystem.hh index 134d49cf..cdcfe88a 100644 --- a/src/services/impl/WindowsFileSystem.hh +++ b/src/services/impl/WindowsFileSystem.hh @@ -34,6 +34,7 @@ public: std::unique_ptr CreateTextFile(const std::wstring& sPath) const override; std::unique_ptr AppendTextFile(const std::wstring& sPath) const override; + std::wstring GetDirectory(const std::wstring& sPath) const override; std::wstring GetFileName(const std::wstring& sPath) const override; std::wstring GetExtension(const std::wstring& sPath) const override; std::wstring RemoveExtension(const std::wstring& sPath) const override; diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp index 6ee66a52..17109b52 100644 --- a/src/ui/viewmodels/AssetEditorViewModel.cpp +++ b/src/ui/viewmodels/AssetEditorViewModel.cpp @@ -1,5 +1,7 @@ #include "AssetEditorViewModel.hh" +#include "data\context\GameContext.hh" + #include "data\models\AchievementModel.hh" #include "data\models\LeaderboardModel.hh" @@ -103,14 +105,20 @@ AssetEditorViewModel::~AssetEditorViewModel() void AssetEditorViewModel::SelectBadgeFile() { + auto& pLocalBadges = ra::services::ServiceLocator::GetMutable().LocalBadges(); ui::viewmodels::FileDialogViewModel vmFile; vmFile.AddFileType(L"Image Files", L"*.png;*.gif;*.jpg;*.jpeg"); vmFile.SetDefaultExtension(L"png"); + vmFile.SetInitialDirectory(pLocalBadges.GetLastDirectory()); if (vmFile.ShowOpenFileDialog(*this) != DialogResult::OK) return; const auto& pFileName = vmFile.GetFileName(); auto& pFileSystemService = ra::services::ServiceLocator::GetMutable(); + + const auto pDirectory = pFileSystemService.GetDirectory(pFileName); + pLocalBadges.SetLastDirectory(pDirectory); + const auto pFile = pFileSystemService.OpenTextFile(pFileName); if (!pFile) { diff --git a/tests/devkit/data/models/GameAssets_Tests.cpp b/tests/devkit/data/models/GameAssets_Tests.cpp index ac1a1dac..700300a4 100644 --- a/tests/devkit/data/models/GameAssets_Tests.cpp +++ b/tests/devkit/data/models/GameAssets_Tests.cpp @@ -251,6 +251,7 @@ TEST_CLASS(GameAssets_Tests) // gameAssets is not the same instance as mockGameContext.Assets, so manually update the badge reference count auto& pLocalBadges = gameAssets.mockGameContext.LocalBadges(); + pLocalBadges.SetLastDirectory(L"C:\\Games\\Images"); pLocalBadges.AddReference(L"local\\22-ABCDE.png", false); // simulate the pre-commit of the achievement by converting the reference from uncommitted to committed @@ -262,6 +263,7 @@ TEST_CLASS(GameAssets_Tests) gameAssets.ReloadAsset(AssetType::Achievement, GameAssets::FirstLocalId); const auto& sExpected = ra::util::String::Printf("0.0.0.0\nGame Title\n" + "BadgeDir=C:\\Games\\Images\n" "%u:\"1=1\":Temp:Temp::::Authl:10:::::\"local\\\\22-ABCDE.png\"\n", GameAssets::FirstLocalId); Assert::AreEqual(sExpected, gameAssets.GetUserFile()); } @@ -455,7 +457,7 @@ TEST_CLASS(GameAssets_Tests) { GameAssetsHarness gameAssets; gameAssets.mockGameContext.SetGameId(22); - gameAssets.MockUserFileContents( + gameAssets.MockUserFileContents("BadgeDir=C:\\BadgeImages\n" "111000001:\"0xH2345=0\":Test2:::::User:0:0:0:::local\\22-A.png\n"); gameAssets.ReloadAllAssets(); @@ -467,6 +469,7 @@ TEST_CLASS(GameAssets_Tests) const auto& pLocalBadges = gameAssets.mockGameContext.LocalBadges(); Assert::AreEqual(1, pLocalBadges.GetReferenceCount(L"local\\22-A.png", true)); + Assert::AreEqual(std::wstring(L"C:\\BadgeImages"), pLocalBadges.GetLastDirectory()); } TEST_METHOD(TestMergeLocalAssetsTwoLocalAchievementsWithSubsetInfo) diff --git a/tests/devkit/services/mocks/MockFileSystem.hh b/tests/devkit/services/mocks/MockFileSystem.hh index 0df5422c..0c9159a1 100644 --- a/tests/devkit/services/mocks/MockFileSystem.hh +++ b/tests/devkit/services/mocks/MockFileSystem.hh @@ -189,6 +189,15 @@ public: return std::unique_ptr(pWriter.release()); } + std::wstring GetDirectory(const std::wstring& sPath) const override + { + const auto nIndex = sPath.find_last_of(L"/\\"); + if (nIndex != std::string::npos) + return std::wstring(sPath, 0, nIndex); + + return sPath; + } + std::wstring GetFileName(const std::wstring& sPath) const override { const auto nIndex = sPath.find_last_of(L"/\\"); diff --git a/tests/devkit/util/Tokenizer_Tests.cpp b/tests/devkit/util/Tokenizer_Tests.cpp index 5c315151..42ed6a03 100644 --- a/tests/devkit/util/Tokenizer_Tests.cpp +++ b/tests/devkit/util/Tokenizer_Tests.cpp @@ -155,6 +155,25 @@ TEST_CLASS(Tokenizer_Tests) Assert::IsFalse(tokenizer.Consume('d')); Assert::IsFalse(tokenizer.Consume('\0')); } + + TEST_METHOD(TestMatch) + { + std::string input("abcdefgh"); + Tokenizer tokenizer(input); + + Assert::IsFalse(tokenizer.Match("bcd")); // first character is 'a' + Assert::IsTrue(tokenizer.Match("abc")); + Assert::AreEqual('d', tokenizer.PeekChar()); + Assert::IsFalse(tokenizer.Match("abc")); + Assert::IsFalse(tokenizer.Match("den")); + Assert::IsTrue(tokenizer.Match("def")); + Assert::AreEqual('g', tokenizer.PeekChar()); + Assert::IsFalse(tokenizer.Match("ghi")); + Assert::IsTrue(tokenizer.Match("gh")); + Assert::IsTrue(tokenizer.EndOfString()); + Assert::IsFalse(tokenizer.Match("i")); + Assert::IsTrue(tokenizer.Match("")); + } }; diff --git a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp index 2ca25ce0..46748894 100644 --- a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp +++ b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp @@ -2411,6 +2411,43 @@ TEST_CLASS(AssetEditorViewModel_Tests) Assert::AreEqual(std::wstring(L"File does not appear to be a valid jpg image."), sMessage); } + TEST_METHOD(TestSelectBadgeFileRemembersLastDirectory) + { + AssetEditorViewModelHarness editor; + AchievementModel achievement; + editor.LoadAsset(&achievement); + + editor.mockGameContext.LocalBadges().SetLastDirectory(L"C:\\Badges"); + + bool bDialogSeen = false; + editor.mockDesktop.ExpectWindow( + [&bDialogSeen](ra::ui::viewmodels::FileDialogViewModel& vmFileDialog) + { + bDialogSeen = true; + + Assert::AreEqual(std::wstring(L"C:\\Badges"), vmFileDialog.GetInitialDirectory()); + + vmFileDialog.SetFileName(L"C:\\Badges\\Game1\\image.png"); + return DialogResult::OK; + }); + + std::wstring sMessage; + editor.mockDesktop.ExpectWindow( + [&sMessage](ra::ui::viewmodels::MessageBoxViewModel& vmMessage) + { + sMessage = vmMessage.GetMessage(); + return DialogResult::OK; + }); + + const std::string sFileContents("\x89PNG\x0D\x0A\x1A\x0D\x00\x00\x00\x0DIHDR", 16); + editor.mockFileSystem.MockFile(L"C:\\image.png", sFileContents); + + editor.SelectBadgeFile(); + + Assert::AreEqual(std::wstring(L"C:\\Badges\\Game1"), editor.mockGameContext.LocalBadges().GetLastDirectory()); + Assert::IsTrue(bDialogSeen); + } + TEST_METHOD(TestChangingGroupUpdatesDebugHighlights) { AssetEditorViewModelHarness editor; From 36f3489529fc6aa9acf7a02312c65641a58e796d Mon Sep 17 00:00:00 2001 From: Jamiras Date: Thu, 27 Aug 2026 21:32:16 -0600 Subject: [PATCH 2/2] flush user file after updating BadgeDir --- src/ui/viewmodels/AssetEditorViewModel.cpp | 13 ++++++++++--- tests/devkit/data/util/TriggerValidation_Tests.cpp | 6 ++++++ tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp | 10 ++++++++++ 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/src/ui/viewmodels/AssetEditorViewModel.cpp b/src/ui/viewmodels/AssetEditorViewModel.cpp index 17109b52..58396f10 100644 --- a/src/ui/viewmodels/AssetEditorViewModel.cpp +++ b/src/ui/viewmodels/AssetEditorViewModel.cpp @@ -106,18 +106,25 @@ AssetEditorViewModel::~AssetEditorViewModel() void AssetEditorViewModel::SelectBadgeFile() { auto& pLocalBadges = ra::services::ServiceLocator::GetMutable().LocalBadges(); + const auto sOldLastDirectory = pLocalBadges.GetLastDirectory(); ui::viewmodels::FileDialogViewModel vmFile; vmFile.AddFileType(L"Image Files", L"*.png;*.gif;*.jpg;*.jpeg"); vmFile.SetDefaultExtension(L"png"); - vmFile.SetInitialDirectory(pLocalBadges.GetLastDirectory()); + vmFile.SetInitialDirectory(sOldLastDirectory); if (vmFile.ShowOpenFileDialog(*this) != DialogResult::OK) return; const auto& pFileName = vmFile.GetFileName(); auto& pFileSystemService = ra::services::ServiceLocator::GetMutable(); - const auto pDirectory = pFileSystemService.GetDirectory(pFileName); - pLocalBadges.SetLastDirectory(pDirectory); + const auto sNewLastDirectory = pFileSystemService.GetDirectory(pFileName); + if (sNewLastDirectory != sOldLastDirectory) + { + pLocalBadges.SetLastDirectory(sNewLastDirectory); + std::vector vAssetsToSave; + vAssetsToSave.push_back(&pLocalBadges); + ra::services::ServiceLocator::GetMutable().Assets().SaveAssets(vAssetsToSave); + } const auto pFile = pFileSystemService.OpenTextFile(pFileName); if (!pFile) diff --git a/tests/devkit/data/util/TriggerValidation_Tests.cpp b/tests/devkit/data/util/TriggerValidation_Tests.cpp index 070f9632..c2d4045a 100644 --- a/tests/devkit/data/util/TriggerValidation_Tests.cpp +++ b/tests/devkit/data/util/TriggerValidation_Tests.cpp @@ -236,6 +236,12 @@ TEST_CLASS(TriggerValidation_Tests) // chained value note AssertValidation("A:0xH0008_1=6", L""); // 1 is not an address so it shouldn't matter that it doesn't have a note } + + TEST_METHOD(TestRecallConstantAcrossAlts) + { + AssertValidation("SQ:0xX00aa4968=4_K:11160468_I:{recall}_N:0xX00000000!=0_M:0=1.3." + "SK:11160468_I:{recall}_N:0xX00000000!=0_R:0=1", L""); + } }; } // namespace tests diff --git a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp index 46748894..897c5cf0 100644 --- a/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp +++ b/tests/ui/viewmodels/AssetEditorViewModel_Tests.cpp @@ -11,11 +11,14 @@ #include "tests\data\DataAsserts.hh" #include "tests\ui\viewmodels\TriggerConditionAsserts.hh" +#include "tests\devkit\context\mocks\MockDevKitContext.hh" #include "tests\devkit\context\mocks\MockEmulatorMemoryContext.hh" #include "tests\devkit\context\mocks\MockRcClient.hh" #include "tests\devkit\context\mocks\MockUserContext.hh" #include "tests\devkit\services\mocks\MockClock.hh" #include "tests\devkit\services\mocks\MockFileSystem.hh" +#include "tests\devkit\services\mocks\MockLocalStorage.hh" +#include "tests\devkit\services\mocks\MockLogger.hh" #include "tests\devkit\services\mocks\MockThreadPool.hh" #include "tests\devkit\testutil\AchievementAsserts.hh" #include "tests\devkit\testutil\AssetAsserts.hh" @@ -72,6 +75,7 @@ TEST_CLASS(AssetEditorViewModel_Tests) AssetEditorViewModelHarness(AssetEditorViewModelHarness&&) noexcept = delete; AssetEditorViewModelHarness& operator=(AssetEditorViewModelHarness&&) noexcept = delete; + ra::context::mocks::MockDevKitContext mockDevKitContext; ra::context::mocks::MockEmulatorMemoryContext mockEmulatorMemoryContext; ra::context::mocks::MockRcClient mockRcClient; ra::context::mocks::MockUserContext mockUserContext; @@ -79,6 +83,8 @@ TEST_CLASS(AssetEditorViewModel_Tests) ra::services::mocks::MockClock mockClock; ra::services::mocks::MockConfiguration mockConfiguration; ra::services::mocks::MockFileSystem mockFileSystem; + ra::services::mocks::MockLocalStorage mockLocalStorage; + ra::services::mocks::MockLogger mockLogger; ra::data::context::mocks::MockEmulatorContext mockEmulatorContext; ra::data::context::mocks::MockGameContext mockGameContext; ra::ui::mocks::MockDesktop mockDesktop; @@ -2417,6 +2423,7 @@ TEST_CLASS(AssetEditorViewModel_Tests) AchievementModel achievement; editor.LoadAsset(&achievement); + editor.mockGameContext.SetActiveGameId(1); editor.mockGameContext.LocalBadges().SetLastDirectory(L"C:\\Badges"); bool bDialogSeen = false; @@ -2446,6 +2453,9 @@ TEST_CLASS(AssetEditorViewModel_Tests) Assert::AreEqual(std::wstring(L"C:\\Badges\\Game1"), editor.mockGameContext.LocalBadges().GetLastDirectory()); Assert::IsTrue(bDialogSeen); + + const auto pUserFile = editor.mockLocalStorage.GetStoredData(ra::services::StorageItemType::UserAchievements, L"1"); + Assert::AreEqual(std::string("0.0.0.0\nGame Title\nBadgeDir=C:\\Badges\\Game1\n"), pUserFile); } TEST_METHOD(TestChangingGroupUpdatesDebugHighlights)