diff --git a/addons/resource.language.en_gb/resources/strings.po b/addons/resource.language.en_gb/resources/strings.po index e6fb91133d25c..6ea46523c4331 100644 --- a/addons/resource.language.en_gb/resources/strings.po +++ b/addons/resource.language.en_gb/resources/strings.po @@ -19557,7 +19557,25 @@ msgctxt "#35307" msgid "Subset completed" msgstr "" -#empty strings from id 35313 to 35330 +#empty strings from id 35313 to 35319 + +#. Label of the Cheats button in the game OSD, heading of the cheats dialog, and +#. of the cheats group in settings +msgctxt "#35320" +msgid "Cheats" +msgstr "" + +#. Label of the setting that points at the folder holding cheat files +msgctxt "#35321" +msgid "Cheats folder" +msgstr "" + +#. Help text for the cheats folder setting +msgctxt "#35322" +msgid "Set a custom folder containing libretro cheats, or install cheats in the Add-on Manager." +msgstr "" + +#empty strings from id 35323 to 35330 #. Label of the game OSD button that opens the leaderboards #: xbmc/games/dialogs/osd/DialogGameLeaderboards.cpp diff --git a/addons/skin.estuary/media/osd/fullscreen/buttons/cheats.png b/addons/skin.estuary/media/osd/fullscreen/buttons/cheats.png new file mode 100644 index 0000000000000..25d577e774337 Binary files /dev/null and b/addons/skin.estuary/media/osd/fullscreen/buttons/cheats.png differ diff --git a/addons/skin.estuary/xml/DialogGameCheats.xml b/addons/skin.estuary/xml/DialogGameCheats.xml new file mode 100644 index 0000000000000..ca42b856b5fcb --- /dev/null +++ b/addons/skin.estuary/xml/DialogGameCheats.xml @@ -0,0 +1,123 @@ + + + 5 + Animation_DialogPopupOpenClose + + + 105 + 50% + 1520 + Animation_DialogPopupVisible + !Window.IsVisible(sliderdialog) + + + + + + + + 10 + 80 + 1200 + 790 + buttons/dialogbutton-nofo.png + + + control area + 29 + 100 + 1160 + 750 + 60 + 9000 + 60 + 5 + 5 + + + Default Button + DefaultSettingButton + + + Default RadioButton + DefaultSettingButton + + + Default SpinControlex + DefaultSettingButton + + + separator image + 3 + dialogs/separator-grey.png + + + DefaultSettingButton + + + Default Slider + DefaultSettingButton + 800 + + + Default Label + DefaultSettingLabel + + + 1210 + 92 + vertical + 300 + 280 + -10 + 5 + 5 + + + + + + + + + + + + + + + Heading above what is switched on + 1210 + 386 + 300 + 40 + font14 + white + center + center + + + Explanation of the focused cheat, and what is switched on + 1210 + 436 + 300 + 394 + font13 + grey + center + true + + + 1189 + 100 + 12 + 750 + vertical + 5 + 9000 + WindowOpen + WindowClose + + + + diff --git a/addons/skin.estuary/xml/GameOSD.xml b/addons/skin.estuary/xml/GameOSD.xml index 6efb397d38fb0..2d56b511a20d5 100644 --- a/addons/skin.estuary/xml/GameOSD.xml +++ b/addons/skin.estuary/xml/GameOSD.xml @@ -14,7 +14,8 @@ !Window.IsActive(InGameSaves) + !Window.IsActive(GameAgents) + !Window.IsActive(GameDiscManager) + - !Window.IsActive(GameAchievements) + !Window.IsActive(GameAchievements) + + !Window.IsActive(GameCheats) Visible_Fade @@ -72,410 +73,110 @@ - !System.GetBool(gamesgeneral.showosdhelp) + !RetroPlayer.SupportsEject + !System.GetBool(gamesgeneral.showosdhelp) 50% - 640 + 1040 50% 700 VisibleChange - - - - - - - - 80 - - 560 - vertical - - - 8 - 8 - 64 - 64 - ListItem.Icon - - - 80 - 600 - 80 - ListItem.Label - font13 - left - center - - - 20 - 600 - 80 - ListItem.Label2 - font13 - right - center - grey - - - - - 8 - 8 - 64 - 64 - ListItem.Icon - - - 80 - 600 - 80 - ListItem.Label - font13 - left - center - - - 20 - 600 - 80 - ListItem.Label2 - font13 - right - center - grey - - - - - lists/focus.png - Control.HasFocus(1103) - - - 8 - 8 - 64 - 64 - ListItem.Icon - - - 80 - 600 - 80 - ListItem.Label - font13 - left - center - - - 20 - 600 - 80 - ListItem.Label2 - font13 - right - center - grey - - - - - lists/focus.png - Control.HasFocus(1103) - - - 8 - 8 - 64 - 64 - ListItem.Icon - - - 80 - 600 - 80 - ListItem.Label - font13 - left - center - - - 20 - 600 - 80 - ListItem.Label2 - font13 - right - center - grey - - - - - Pause / Resume button - - $FEATURE[select,game.controller.snes] + $FEATURE[x,game.controller.default] - osd/fullscreen/buttons/play.png - Play - - - Save / Load button - - osd/fullscreen/buttons/saves.png - ActivateWindow(InGameSaves) - - - Players button - - osd/fullscreen/buttons/agent.png - ActivateWindow(GameAgents) - - - - Achievements button. Shown unconditionally: the dialog says - whether the player needs to sign in or the game has no - achievement set, which is clearer than hiding the button. - - - $INFO[RetroPlayer.AchievementsProgress] - osd/fullscreen/buttons/rating.png - ActivateWindow(GameAchievements) - - - Reset button - - osd/fullscreen/buttons/reset.png - PlayerControl(Reset) - - - Stop button - - $FEATURE[select,game.controller.snes] + $FEATURE[start,game.controller.default] - osd/fullscreen/buttons/stop.png - Stop - - - Settings button - - osd/fullscreen/buttons/settings.png - SetProperty(settingsdialog_content,games,home) - SetProperty(settingsdialog_header,$LOCALIZE[5],home) - ActivateWindow(1101) - - - 2200 - - - -10 - 0 - 12 - vertical - + + -2000 + -2000 + 6000 + 6000 + + + Action(close) - - - !System.GetBool(gamesgeneral.showosdhelp) + RetroPlayer.SupportsEject - 50% - 720 - 50% - 700 - VisibleChange - - - - - - - - 80 - - 640 - vertical - - - 8 - 8 - 64 - 64 - ListItem.Icon - - - 80 - 600 - 80 - ListItem.Label - font13 - left - center - - - 20 - 600 - 80 - ListItem.Label2 - font13 - right - center - grey - - - - - 8 - 8 - 64 - 64 - ListItem.Icon - - - 80 - 600 - 80 - ListItem.Label - font13 - left - center - - - 20 - 600 - 80 - ListItem.Label2 - font13 - right - center - grey - - - - - lists/focus.png - Control.HasFocus(1103) - - - 8 - 8 - 64 - 64 - ListItem.Icon - - - 80 - 600 - 80 - ListItem.Label - font13 - left - center - - - 20 - 600 - 80 - ListItem.Label2 - font13 - right - center - grey - - - - - lists/focus.png - Control.HasFocus(1103) - - - 8 - 8 - 64 - 64 - ListItem.Icon - - - 80 - 600 - 80 - ListItem.Label - font13 - left - center - - - 20 - 600 - 80 - ListItem.Label2 - font13 - right - center - grey - - - - - Pause / Resume button - - $FEATURE[select,game.controller.snes] + $FEATURE[x,game.controller.default] - osd/fullscreen/buttons/play.png - Play - - - Save / Load button - - osd/fullscreen/buttons/saves.png - ActivateWindow(InGameSaves) - - - Disc Manager button - - DefaultSets.png - ActivateWindow(GameDiscManager) - - - Players button - - osd/fullscreen/buttons/agent.png - ActivateWindow(GameAgents) - - - - Achievements button. Shown unconditionally: the dialog says - whether the player needs to sign in or the game has no - achievement set, which is clearer than hiding the button. - - - $INFO[RetroPlayer.AchievementsProgress] - osd/fullscreen/buttons/rating.png - ActivateWindow(GameAchievements) - - - Reset button - - osd/fullscreen/buttons/reset.png - PlayerControl(Reset) - - - Stop button - - $FEATURE[select,game.controller.snes] + $FEATURE[start,game.controller.default] - osd/fullscreen/buttons/stop.png - Stop - - - Settings button - - osd/fullscreen/buttons/settings.png - SetProperty(settingsdialog_content,games,home) - SetProperty(settingsdialog_header,$LOCALIZE[5],home) - ActivateWindow(1101) - - - 2201 + + 700 + 1040 + vertical + center + 0 + + + 700 + 80 + + 700 + 80 + colors/white.png + + + + + + + - - -10 - 0 - 12 + + 700 + auto vertical + 0 + + + + + + Play + + + + + + ActivateWindow(InGameSaves) + + + + + + RetroPlayer.SupportsEject + ActivateWindow(GameDiscManager) + + + + + + ActivateWindow(GameAgents) + + + + + + + Always show Achievements; its dialog explains sign-in requirements and missing achievement sets. + ActivateWindow(GameAchievements) + + + + + + RetroPlayer.HasCheats + ActivateWindow(GameCheats) + + + + + + PlayerControl(Reset) + + + + + + + Stop + + + + + + SetProperty(settingsdialog_content,games,home) + SetProperty(settingsdialog_header,$LOCALIZE[5],home) + ActivateWindow(1101) + diff --git a/addons/skin.estuary/xml/Includes.xml b/addons/skin.estuary/xml/Includes.xml index 08077154d98ad..26c2b07618612 100644 --- a/addons/skin.estuary/xml/Includes.xml +++ b/addons/skin.estuary/xml/Includes.xml @@ -893,6 +893,14 @@ Action(close) + + + + + + + + $PARAM[width] $PARAM[height] diff --git a/addons/skin.estuary/xml/Includes_Games.xml b/addons/skin.estuary/xml/Includes_Games.xml index 10350208d702c..36c7a5e60170d 100644 --- a/addons/skin.estuary/xml/Includes_Games.xml +++ b/addons/skin.estuary/xml/Includes_Games.xml @@ -1,5 +1,55 @@ + + + + + 700 + 80 + + $PARAM[label2] + + lists/focus.png + + + + + + 0 + 700 + 0 + Control.IsVisible($PARAM[id]) + + 8 + -72 + 64 + 64 + $PARAM[icon] + + + 80 + -80 + 600 + 80 + + font13 + left + center + + + 20 + -80 + 600 + 80 + + font13 + right + center + grey + + + + 50% diff --git a/cmake/treedata/common/games.txt b/cmake/treedata/common/games.txt index 492d9ea6095cd..139738af52112 100644 --- a/cmake/treedata/common/games.txt +++ b/cmake/treedata/common/games.txt @@ -1,11 +1,13 @@ xbmc/games games xbmc/games/addons games/addons +xbmc/games/addons/cheats games/addons/cheats xbmc/games/addons/cheevos games/addons/cheevos xbmc/games/addons/disc games/addons/disc xbmc/games/addons/input games/addons/input xbmc/games/addons/streams games/addons/streams xbmc/games/agents/input games/agents/input xbmc/games/agents/windows games/agents/windows +xbmc/games/cheats games/cheats xbmc/games/controllers games/controllers xbmc/games/controllers/dialogs games/controllers/dialogs xbmc/games/controllers/guicontrols games/controllers/guicontrols diff --git a/system/settings/settings.xml b/system/settings/settings.xml index 9ca5219a7ae86..ae2af717aa2e4 100755 --- a/system/settings/settings.xml +++ b/system/settings/settings.xml @@ -2810,6 +2810,18 @@ + + + 3 + + + true + + + 35321 + + + diff --git a/xbmc/GUIInfoManager.cpp b/xbmc/GUIInfoManager.cpp index 1bb7a991fdf75..a03794489762e 100644 --- a/xbmc/GUIInfoManager.cpp +++ b/xbmc/GUIInfoManager.cpp @@ -4504,6 +4504,16 @@ constexpr std::array videoplayer = {{ /// @skinning_v22 **[New Boolean Condition]** \link RetroPlayer_AchievementsLoggedIn `RetroPlayer.AchievementsLoggedIn`\endlink ///

/// } +/// \table_row3{ `RetroPlayer.HasCheats`, +/// \anchor RetroPlayer_HasCheats +/// _boolean_, +/// @return **True** if the cheats dialog has something to show for the +/// currently-playing game\, either cheats that were found or the offer to +/// fetch the add-on carrying them\, **False** otherwise. +///


+/// @skinning_v22 **[New Boolean Condition]** \link RetroPlayer_HasCheats `RetroPlayer.HasCheats`\endlink +///

+/// } /// \table_row3{ `RetroPlayer.AchievementsProgress`, /// \anchor RetroPlayer_AchievementsProgress /// _string_, @@ -4602,7 +4612,7 @@ constexpr std::array videoplayer = {{ /// /// ----------------------------------------------------------------------------- // clang-format off -constexpr std::array retroplayer = {{ +constexpr std::array retroplayer = {{ {"title", RETROPLAYER_TITLE}, {"platform", RETROPLAYER_PLATFORM}, {"genres", RETROPLAYER_GENRES}, @@ -4614,6 +4624,7 @@ constexpr std::array retroplayer = {{ {"gameclientplatforms", RETROPLAYER_GAME_CLIENT_PLATFORMS}, {"richpresence", RETROPLAYER_RICH_PRESENCE}, {"achievementsloggedin", RETROPLAYER_ACHIEVEMENTS_LOGGED_IN}, + {"hascheats", RETROPLAYER_HAS_CHEATS}, {"achievementsprogress", RETROPLAYER_ACHIEVEMENTS_PROGRESS}, {"achievementschallengetitle", RETROPLAYER_ACHIEVEMENTS_CHALLENGE_TITLE}, {"achievementschallengebadge", RETROPLAYER_ACHIEVEMENTS_CHALLENGE_BADGE}, diff --git a/xbmc/games/GameServices.h b/xbmc/games/GameServices.h index 1a21d0242533a..187a924d3b620 100644 --- a/xbmc/games/GameServices.h +++ b/xbmc/games/GameServices.h @@ -87,6 +87,7 @@ class CGameServices CAchievementRuntime& AchievementRuntime() { return *m_achievementRuntime; } + CGameSettings& GameSettings() { return *m_gameSettings; } RETRO::CGUIGameRenderManager& GameRenderManager() { return m_gameRenderManager; } diff --git a/xbmc/games/GameUtils.cpp b/xbmc/games/GameUtils.cpp index 8cabbc7a40824..57be90ce7c16b 100644 --- a/xbmc/games/GameUtils.cpp +++ b/xbmc/games/GameUtils.cpp @@ -17,6 +17,8 @@ #include "addons/AddonManager.h" #include "addons/BinaryAddonCache.h" #include "addons/addoninfo/AddonType.h" +#include "cores/RetroPlayer/guibridge/GUIGameRenderManager.h" +#include "cores/RetroPlayer/guibridge/GUIGameSettingsHandle.h" #include "cores/RetroPlayer/savestates/ISavestate.h" #include "cores/RetroPlayer/savestates/SavestateDatabase.h" #include "dialogs/GUIDialogOK.h" @@ -516,3 +518,23 @@ void CGameUtils::LoadInstallableAddons() CServiceBroker::GetAddonMgr().GetInstallableAddons(m_installableGameAddons, AddonType::GAMEDLL); } } + +GameClientPtr CGameUtils::GetPlayingGameClient() +{ + auto gameSettingsHandle = CServiceBroker::GetGameRenderManager().RegisterGameSettingsDialog(); + if (!gameSettingsHandle) + return {}; + + // A handle is given out whether or not a game is playing, and says so with an + // empty id rather than by being null + const std::string gameClientId = gameSettingsHandle->GameClientID(); + if (gameClientId.empty()) + return {}; + + ADDON::AddonPtr addon; + if (!CServiceBroker::GetAddonMgr().GetAddon(gameClientId, addon, ADDON::AddonType::GAMEDLL, + ADDON::OnlyEnabled::CHOICE_YES)) + return {}; + + return std::static_pointer_cast(addon); +} diff --git a/xbmc/games/GameUtils.h b/xbmc/games/GameUtils.h index 4c9b0a3d5c11c..9b919ae311d3f 100644 --- a/xbmc/games/GameUtils.h +++ b/xbmc/games/GameUtils.h @@ -62,6 +62,16 @@ class CGameUtils */ static bool ChooseAndSetDefaultGameClient(const CFileItem& item); + /*! + * \brief The game client playing right now, if a game is playing + * + * For callers outside the player that need the client itself rather than + * what it is playing, such as the OSD. + * + * \return The client, or empty if no game is playing + */ + static GameClientPtr GetPlayingGameClient(); + /*! * \brief Check if the file extension is supported by an add-on in * a local or remote repository diff --git a/xbmc/games/addons/GameClient.cpp b/xbmc/games/addons/GameClient.cpp index 3b58160a28c38..b677cf06d0ffd 100644 --- a/xbmc/games/addons/GameClient.cpp +++ b/xbmc/games/addons/GameClient.cpp @@ -22,6 +22,7 @@ #include "filesystem/Directory.h" #include "filesystem/SpecialProtocol.h" #include "games/GameServices.h" +#include "games/addons/cheats/GameClientCheats.h" #include "games/addons/cheevos/GameClientCheevos.h" #include "games/addons/disc/GameClientDiscs.h" #include "games/addons/input/GameClientInput.h" @@ -300,6 +301,9 @@ bool CGameClient::OpenFile(const CFileItem& file, return false; } + // After the game is up, so a cheat is applied to something that can take it + Cheats().Load(path); + return true; } @@ -535,6 +539,9 @@ void CGameClient::CloseFile() if (m_bIsPlaying) { + // While the client is still up, so the cheats can be switched off on it + Cheats().Clear(); + m_inGameSaves->Save(); m_inGameSaves.reset(); @@ -719,6 +726,39 @@ bool CGameClient::SerializeAchievementState(std::vector& data) return false; } +bool CGameClient::SetCheat(unsigned int index, bool enabled, const std::string& code) +{ + std::unique_lock lock(m_critSection); + + try + { + return LogError(m_ifc.game->toAddon->SetCheat(m_ifc.game, index, enabled, code.c_str()), + "SetCheat()"); + } + catch (...) + { + LogException("SetCheat()"); + } + + return false; +} + +bool CGameClient::CheatReset() +{ + std::unique_lock lock(m_critSection); + + try + { + return LogError(m_ifc.game->toAddon->CheatReset(m_ifc.game), "CheatReset()"); + } + catch (...) + { + LogException("CheatReset()"); + } + + return false; +} + bool CGameClient::DeserializeAchievements(const uint8_t* data, size_t size) { // An empty payload is forwarded, not refused: it tells the client the diff --git a/xbmc/games/addons/GameClient.h b/xbmc/games/addons/GameClient.h index 205a0feb127bd..16e8704c73575 100644 --- a/xbmc/games/addons/GameClient.h +++ b/xbmc/games/addons/GameClient.h @@ -34,6 +34,7 @@ class IStreamManager; namespace GAME { +class CGameClientCheats; class CGameClientCheevos; class CGameClientInGameSaves; class CGameClientInput; @@ -125,6 +126,7 @@ class CGameClient : public ADDON::CAddonDll, ~CGameClient() override; // Game subsystems (const) + const CGameClientCheats& Cheats() const { return *m_subsystems.Cheats; } const CGameClientCheevos& Cheevos() const { return *m_subsystems.Cheevos; } const CGameClientDiscs& Discs() const { return *m_subsystems.Discs; } const CGameClientInput& Input() const { return *m_subsystems.Input; } @@ -132,6 +134,7 @@ class CGameClient : public ADDON::CAddonDll, const CGameClientStreams& Streams() const { return *m_subsystems.Streams; } // Game subsystems (mutable) + CGameClientCheats& Cheats() { return *m_subsystems.Cheats; } CGameClientCheevos& Cheevos() { return *m_subsystems.Cheevos; } CGameClientDiscs& Discs() { return *m_subsystems.Discs; } CGameClientInput& Input() { return *m_subsystems.Input; } @@ -224,6 +227,18 @@ class CGameClient : public ADDON::CAddonDll, */ bool DeserializeAchievements(const uint8_t* data, size_t size); + /*! + * \brief Hand the client a cheat to apply, or take one away + * + * \param index The slot the code occupies, which is how it is turned off again + * \param enabled Whether the code should be applied + * \param code The code, in whatever form the emulated system uses + */ + bool SetCheat(unsigned int index, bool enabled, const std::string& code); + + //! \brief Drop every cheat the client is holding + bool CheatReset(); + // Implementation of IHwFramebufferCallback void HardwareContextReset() override; diff --git a/xbmc/games/addons/GameClientSubsystem.cpp b/xbmc/games/addons/GameClientSubsystem.cpp index e7b5b1d39d217..82f1ffd8bd0ff 100644 --- a/xbmc/games/addons/GameClientSubsystem.cpp +++ b/xbmc/games/addons/GameClientSubsystem.cpp @@ -11,6 +11,7 @@ #include "GameClient.h" #include "GameClientProperties.h" #include "addons/kodi-dev-kit/include/kodi/addon-instance/Game.h" +#include "games/addons/cheats/GameClientCheats.h" #include "games/addons/cheevos/GameClientCheevos.h" #include "games/addons/disc/GameClientDiscs.h" #include "games/addons/input/GameClientInput.h" @@ -38,6 +39,7 @@ GameClientSubsystems CGameClientSubsystem::CreateSubsystems(CGameClient& gameCli { GameClientSubsystems subsystems = {}; + subsystems.Cheats = std::make_unique(gameClient, gameStruct, clientAccess); subsystems.Cheevos = std::make_unique(gameClient, gameStruct, clientAccess); subsystems.Discs = std::make_unique(gameClient, gameStruct, clientAccess); subsystems.Input = std::make_unique(gameClient, gameStruct, clientAccess); @@ -57,6 +59,11 @@ void CGameClientSubsystem::DestroySubsystems(GameClientSubsystems& subsystems) subsystems.Streams.reset(); } +CGameClientCheats& CGameClientSubsystem::Cheats() const +{ + return m_gameClient.Cheats(); +} + CGameClientCheevos& CGameClientSubsystem::Cheevos() const { return m_gameClient.Cheevos(); diff --git a/xbmc/games/addons/GameClientSubsystem.h b/xbmc/games/addons/GameClientSubsystem.h index b326ab3a867b5..1dac653336aee 100644 --- a/xbmc/games/addons/GameClientSubsystem.h +++ b/xbmc/games/addons/GameClientSubsystem.h @@ -18,6 +18,7 @@ namespace KODI namespace GAME { class CGameClient; +class CGameClientCheats; class CGameClientCheevos; class CGameClientDiscs; class CGameClientInput; @@ -26,6 +27,7 @@ class CGameClientStreams; struct GameClientSubsystems { + std::unique_ptr Cheats; std::unique_ptr Cheevos; std::unique_ptr Discs; std::unique_ptr Input; @@ -70,6 +72,7 @@ class CGameClientSubsystem protected: // Subsystems + CGameClientCheats& Cheats() const; CGameClientCheevos& Cheevos() const; CGameClientDiscs& Discs() const; CGameClientInput& Input() const; diff --git a/xbmc/games/addons/cheats/CMakeLists.txt b/xbmc/games/addons/cheats/CMakeLists.txt new file mode 100644 index 0000000000000..7ce2955b62981 --- /dev/null +++ b/xbmc/games/addons/cheats/CMakeLists.txt @@ -0,0 +1,5 @@ +set(SOURCES GameClientCheats.cpp) + +set(HEADERS GameClientCheats.h) + +core_add_library(gamecheats) diff --git a/xbmc/games/addons/cheats/GameClientCheats.cpp b/xbmc/games/addons/cheats/GameClientCheats.cpp new file mode 100644 index 0000000000000..95af5c4455ce2 --- /dev/null +++ b/xbmc/games/addons/cheats/GameClientCheats.cpp @@ -0,0 +1,327 @@ +/* + * Copyright (C) 2026 Team Kodi + * This file is part of Kodi - https://kodi.tv + * + * SPDX-License-Identifier: GPL-2.0-or-later + * See LICENSES/README.md for more information. + */ + +#include "GameClientCheats.h" + +#include "FileItem.h" +#include "FileItemList.h" +#include "ServiceBroker.h" +#include "URL.h" +#include "addons/AddonInstaller.h" +#include "addons/AddonManager.h" +#include "addons/GameResource.h" +#include "addons/addoninfo/AddonType.h" +#include "filesystem/Directory.h" +#include "filesystem/File.h" +#include "games/addons/GameClient.h" +#include "settings/Settings.h" +#include "settings/SettingsComponent.h" +#include "threads/CriticalSection.h" +#include "utils/URIUtils.h" +#include "utils/log.h" + +using namespace KODI::GAME; + +namespace +{ +constexpr auto SETTING_GAMES_CHEATS_PATH = "gamesgeneral.cheatspath"; +constexpr auto CHEAT_EXTENSION = ".cht"; + +//! The add-on carrying the libretro cheat database, one zip per system +constexpr auto CHEATS_ADDON = "resource.games.cheats.libretro"; + +//! \brief What to search one level down, given a folder of cheats +//! +//! Real subfolders, as the libretro cheat database is published, and the inside +//! of any zip, as an add-on ships one per system rather than tens of thousands +//! of loose files. Kodi reads an archive through the same calls either way. +std::vector SystemFolders(const std::string& cheatsFolder) +{ + std::vector folders; + + CFileItemList entries; + if (!XFILE::CDirectory::GetDirectory(cheatsFolder, entries, "", XFILE::DIR_FLAG_NO_FILE_DIRS)) + return folders; + + for (int i = 0; i < entries.Size(); ++i) + { + const CFileItemPtr& entry = entries[i]; + + if (entry->IsFolder()) + folders.emplace_back(entry->GetPath()); + else if (URIUtils::HasExtension(entry->GetPath(), ".zip")) + folders.emplace_back(URIUtils::CreateArchivePath("zip", CURL(entry->GetPath())).Get()); + } + + return folders; +} + +//! \brief Look for a cheat file beside the folder, then one level inside it +//! +//! The libretro cheat database is published a folder per system, so the +//! setting can point at the database itself or at a folder of loose files. +CCheatPack FindCheats(const std::string& cheatsFolder, const std::string& fileName) +{ + const std::string direct = URIUtils::AddFileToFolder(cheatsFolder, fileName); + if (XFILE::CFile::Exists(direct)) + return CCheatPack::Load(direct); + + // A database laid out one folder per system can hold the same game name + // under more than one console, and nothing here says which is meant. Sending + // another console's codes is worse than sending none. + std::string match; + for (const std::string& system : SystemFolders(cheatsFolder)) + { + const std::string path = URIUtils::AddFileToFolder(system, fileName); + if (!XFILE::CFile::Exists(path)) + continue; + + if (!match.empty()) + { + CLog::Log(LOGDEBUG, "CGameClientCheats: \"{}\" is in more than one system, using none", + fileName); + return {}; + } + + match = path; + } + + if (!match.empty()) + return CCheatPack::Load(match); + + return {}; +} + +//! \brief Every folder a cheat file might be in, in the order they are asked +//! +//! The player's own folder comes first: somebody who has pointed the setting at +//! a database of their own meant it to be used. Behind it come the installed +//! game resource add-ons, which is how cheats are shipped and updated through +//! the repository without anyone downloading anything by hand. +std::vector CheatSources() +{ + std::vector sources; + + const auto settings = CServiceBroker::GetSettingsComponent()->GetSettings(); + const std::string folder = settings->GetString(SETTING_GAMES_CHEATS_PATH); + if (!folder.empty()) + sources.emplace_back(folder); + + ADDON::VECADDONS addons; + if (CServiceBroker::GetAddonMgr().GetAddons(addons, ADDON::AddonType::RESOURCE_GAMES)) + { + for (const ADDON::AddonPtr& addon : addons) + { + const auto resource = std::static_pointer_cast(addon); + sources.emplace_back(resource->GetFullPath("")); + } + } + + return sources; +} +} // namespace + +CGameClientCheats::CGameClientCheats(CGameClient& gameClient, + AddonInstance_Game& addonStruct, + CCriticalSection& clientAccess) + : CGameClientSubsystem(gameClient, addonStruct, clientAccess) +{ +} + +CGameClientCheats::~CGameClientCheats() = default; + +void CGameClientCheats::Load(const std::string& gamePath) +{ + std::unique_lock clientLock(m_clientAccess); + + // Asked here rather than when a cheat is applied, which is too late to + // decide whether to offer any. Resetting an untouched game changes nothing. + const bool clientTakesCheats = m_gameClient.CheatReset(); + + { + std::lock_guard lock(m_mutex); + m_clientTakesCheats = clientTakesCheats; + m_gamePath = gamePath; + LoadPack(); + } + + Apply(); +} + +void CGameClientCheats::LoadPack() +{ + m_pack = CCheatPack(); + m_enabled.clear(); + + if (m_gamePath.empty()) + return; + + // The cheat file is named after the game, which is how the libretro cheat + // database is published + std::string name = URIUtils::GetFileName(m_gamePath); + URIUtils::RemoveExtension(name); + + for (const std::string& source : CheatSources()) + { + m_pack = FindCheats(source, name + CHEAT_EXTENSION); + if (!m_pack.IsEmpty()) + break; + } + + if (m_pack.IsEmpty()) + return; + + m_enabled.reserve(m_pack.Cheats().size()); + for (const Cheat& cheat : m_pack.Cheats()) + m_enabled.push_back(cheat.enabled); + + CLog::Log(LOGINFO, "CGameClientCheats: {} cheat(s) for \"{}\"", m_pack.Cheats().size(), name); +} + +void CGameClientCheats::Clear() +{ + std::unique_lock clientLock(m_clientAccess); + + { + std::lock_guard lock(m_mutex); + m_pack = CCheatPack(); + m_enabled.clear(); + // Also the game and what the client said about it: the same client can go + // on to open a standalone title, which never looks a game path up, and + // would otherwise be offered the last game's cheats + m_gamePath.clear(); + m_clientTakesCheats = false; + } + + if (m_gameClient.IsPlaying()) + m_gameClient.CheatReset(); +} + +bool CGameClientCheats::HasCheats() const +{ + std::lock_guard lock(m_mutex); + return m_clientTakesCheats && !m_pack.IsEmpty(); +} + +bool CGameClientCheats::CanOfferCheats() const +{ + { + std::lock_guard lock(m_mutex); + if (!m_clientTakesCheats) + return false; + if (!m_pack.IsEmpty()) + return true; + } + + // Nothing for this game, but the player has never been offered the database + return CanInstallCheats(); +} + +bool CGameClientCheats::CanInstallCheats() +{ + ADDON::CAddonMgr& addons = CServiceBroker::GetAddonMgr(); + + if (addons.IsAddonInstalled(CHEATS_ADDON)) + return addons.IsAddonDisabled(CHEATS_ADDON); + + // By id rather than by listing the repositories: the OSD asks this every + // time the cheats button's visibility is evaluated + ADDON::AddonPtr addon; + return addons.FindInstallableById(CHEATS_ADDON, addon); +} + +bool CGameClientCheats::InstallCheats() +{ + ADDON::CAddonMgr& addons = CServiceBroker::GetAddonMgr(); + + bool ready = false; + if (addons.IsAddonDisabled(CHEATS_ADDON)) + { + ready = addons.EnableAddon(CHEATS_ADDON); + } + else if (!addons.IsAddonInstalled(CHEATS_ADDON)) + { + ADDON::AddonPtr addon; + ready = ADDON::CAddonInstaller::GetInstance().InstallModal( + CHEATS_ADDON, addon, ADDON::InstallModalPrompt::CHOICE_NO); + } + + if (!ready) + return false; + + { + std::unique_lock clientLock(m_clientAccess); + + { + std::lock_guard lock(m_mutex); + LoadPack(); + } + + Apply(); + } + + return HasCheats(); +} + +std::vector CGameClientCheats::GetCheats() const +{ + std::lock_guard lock(m_mutex); + + std::vector cheats = m_pack.Cheats(); + for (size_t i = 0; i < cheats.size() && i < m_enabled.size(); ++i) + cheats[i].enabled = m_enabled[i]; + + return cheats; +} + +void CGameClientCheats::SetEnabled(unsigned int index, bool enabled) +{ + std::unique_lock clientLock(m_clientAccess); + + { + std::lock_guard lock(m_mutex); + + if (index >= m_enabled.size()) + return; + + m_enabled[index] = enabled; + } + + Apply(); +} + +void CGameClientCheats::Apply() +{ + if (!m_gameClient.IsPlaying()) + return; + + std::vector codes; + { + std::lock_guard lock(m_mutex); + + // Only the ones switched on are sent. Cores are not obliged to honour the + // enabled flag and several ignore it outright, applying whatever they are + // handed -- fceumm adds every code it is given -- so a cheat that is off + // has to be left out rather than sent as disabled. + const std::vector& cheats = m_pack.Cheats(); + for (size_t i = 0; i < cheats.size() && i < m_enabled.size(); ++i) + { + if (m_enabled[i]) + codes.emplace_back(cheats[i].code); + } + } + + // A cheat is identified by the slot it was given, so the set is sent whole + // rather than one code at a time: switching one off means the ones after it + // would otherwise answer to the wrong index. + m_gameClient.CheatReset(); + + unsigned int slot = 0; + for (const std::string& code : codes) + m_gameClient.SetCheat(slot++, true, code); +} diff --git a/xbmc/games/addons/cheats/GameClientCheats.h b/xbmc/games/addons/cheats/GameClientCheats.h new file mode 100644 index 0000000000000..7e692bcd8a58a --- /dev/null +++ b/xbmc/games/addons/cheats/GameClientCheats.h @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2026 Team Kodi + * This file is part of Kodi - https://kodi.tv + * + * SPDX-License-Identifier: GPL-2.0-or-later + * See LICENSES/README.md for more information. + */ + +#pragma once + +#include "games/addons/GameClientSubsystem.h" +#include "games/cheats/CheatPack.h" + +#include +#include +#include + +namespace KODI::GAME +{ +/*! + * \ingroup games + * + * \brief The cheats available to the game this client is playing + * + * Owned by the client, so the codes go to the client that was asked for them + * and are gone once it is. + */ +class CGameClientCheats : protected CGameClientSubsystem +{ +public: + CGameClientCheats(CGameClient& gameClient, + AddonInstance_Game& addonStruct, + CCriticalSection& clientAccess); + ~CGameClientCheats() override; + + /*! + * \brief Look for cheats for a game and hold what is found + * + * A game with no cheat file, or a cheats folder that has not been set, ends + * up with nothing, which is how the OSD knows not to offer them. + */ + void Load(const std::string& gamePath); + + //! \brief Forget the cheats and switch off any that were applied + void Clear(); + + //! \brief True while the game being played has cheats to offer + bool HasCheats() const; + + /*! + * \brief True when the cheats dialog is worth opening + * + * Either there are cheats, or the add-on that carries them can still be + * fetched, which is the only way the player would find out it exists. + */ + bool CanOfferCheats() const; + + /*! + * \brief True while the cheat add-on is missing or switched off + * + * \sa InstallCheats() + */ + static bool CanInstallCheats(); + + /*! + * \brief Fetch or re-enable the cheat add-on and look again for this game + * + * Blocks on the download, so it belongs on a job thread. + * + * \return True if cheats were found for the game afterwards + */ + bool InstallCheats(); + + //! \brief The cheats found for this game, and whether each is switched on + std::vector GetCheats() const; + + /*! + * \brief Switch one cheat on or off + * + * The whole set is re-sent to the client afterwards. + */ + void SetEnabled(unsigned int index, bool enabled); + +private: + /*! + * \brief Send the cheats that are switched on to the client + * + * Call with the client's lock held and m_mutex free. Every path here takes + * the two in that order, which is the order a game being closed already + * holds them in. + */ + void Apply(); + + //! \brief Look up the cheats for the game being played. Call under m_mutex. + void LoadPack(); + + mutable std::mutex m_mutex; + + //! The game the cheats were looked up for, so fetching the add-on can + //! look again without being told which game is playing + std::string m_gamePath; + CCheatPack m_pack; + std::vector m_enabled; + + //! Whether the client took the cheat calls. The operations are optional, and + //! one that returns GAME_ERROR_NOT_IMPLEMENTED can never be cheated at. + bool m_clientTakesCheats{false}; +}; +} // namespace KODI::GAME diff --git a/xbmc/games/cheats/CMakeLists.txt b/xbmc/games/cheats/CMakeLists.txt new file mode 100644 index 0000000000000..37dfd422517b0 --- /dev/null +++ b/xbmc/games/cheats/CMakeLists.txt @@ -0,0 +1,5 @@ +set(SOURCES CheatPack.cpp) + +set(HEADERS CheatPack.h) + +core_add_library(games_cheats) diff --git a/xbmc/games/cheats/CheatPack.cpp b/xbmc/games/cheats/CheatPack.cpp new file mode 100644 index 0000000000000..064919a0eb3ff --- /dev/null +++ b/xbmc/games/cheats/CheatPack.cpp @@ -0,0 +1,160 @@ +/* + * Copyright (C) 2026 Team Kodi + * This file is part of Kodi - https://kodi.tv + * + * SPDX-License-Identifier: GPL-2.0-or-later + * See LICENSES/README.md for more information. + */ + +#include "CheatPack.h" + +#include "URL.h" +#include "filesystem/File.h" +#include "utils/CharsetConverter.h" +#include "utils/HTMLUtil.h" +#include "utils/StringUtils.h" +#include "utils/log.h" + +#include +#include +#include +#include + +using namespace KODI::GAME; + +namespace +{ +//! Cheat files run to a few megabytes at the top end. Read past what will be +//! offered, so a large file gives its first cheats rather than none at all. +constexpr int64_t MAX_CHEAT_FILE_SIZE = 16 * 1024 * 1024; + +//! The most cheats to take from one file. A list longer than this is not +//! readable, and carrying it costs frames and memory for rows nobody reaches. +constexpr unsigned int MAX_CHEATS = 1024; + +//! \brief Resolve the HTML entities a cheat file's text is written with +//! +//! Descriptions come from web sources and carry the entities that came with +//! them, which would otherwise be shown as written: "A" for a quoted +//! button, & between two words. +std::string DecodeEntities(const std::string& text) +{ + if (text.find('&') == std::string::npos) + return text; + + std::wstring wide; + g_charsetConverter.utf8ToW(text, wide, false); + + std::wstring decoded; + HTML::CHTMLUtil::ConvertHTMLToW(wide, decoded); + + std::string result; + g_charsetConverter.wToUTF8(decoded, result); + return result; +} + +std::string Unquote(std::string value) +{ + StringUtils::Trim(value); + if (value.size() >= 2 && value.front() == '"' && value.back() == '"') + value = value.substr(1, value.size() - 2); + return value; +} +} // namespace + +CCheatPack CCheatPack::Load(const std::string& path) +{ + CCheatPack pack; + + XFILE::CFile file; + if (!file.Open(path)) + return pack; + + const int64_t length = file.GetLength(); + if (length <= 0 || length > MAX_CHEAT_FILE_SIZE) + { + CLog::Log(LOGERROR, "CCheatPack: refusing \"{}\", size {} bytes", CURL::GetRedacted(path), + length); + return pack; + } + + std::vector buffer(static_cast(length)); + const ssize_t read = file.Read(buffer.data(), buffer.size()); + if (read < 0 || static_cast(read) != buffer.size()) + { + CLog::Log(LOGERROR, "CCheatPack: read {} of {} bytes from \"{}\"", read, buffer.size(), + CURL::GetRedacted(path)); + return pack; + } + + // Gather every key first. The file is not required to declare a cheat's + // fields together, or in order. + std::map keys; + for (const std::string& line : + StringUtils::Split(std::string(buffer.begin(), buffer.end()), "\n")) + { + const size_t separator = line.find('='); + if (separator == std::string::npos) + continue; + + std::string key = line.substr(0, separator); + StringUtils::Trim(key); + if (key.empty() || key.front() == '#') + continue; + + keys[key] = Unquote(line.substr(separator + 1)); + } + + const auto count = keys.find("cheats"); + if (count == keys.end()) + return pack; + + const unsigned int declared = + static_cast(std::strtoul(count->second.c_str(), nullptr, 10)); + if (declared == 0) + return pack; + + unsigned int skipped = 0; + for (unsigned int index = 0; index < std::min(declared, MAX_CHEATS); ++index) + { + const std::string prefix = StringUtils::Format("cheat{}_", index); + + const auto code = keys.find(prefix + "code"); + if (code == keys.end() || code->second.empty()) + { + // RetroArch also writes cheats as an address and a value for its own + // engine to poke. Those have no code to hand a core, so they are counted + // and left out rather than offered as cheats that do nothing. + ++skipped; + continue; + } + + Cheat cheat; + cheat.code = code->second; + + const auto description = keys.find(prefix + "desc"); + cheat.description = + description != keys.end() ? DecodeEntities(description->second) : ""; + + const auto longDescription = keys.find(prefix + "long_desc"); + if (longDescription != keys.end()) + cheat.longDescription = DecodeEntities(longDescription->second); + + const auto enabled = keys.find(prefix + "enable"); + cheat.enabled = enabled != keys.end() && StringUtils::EqualsNoCase(enabled->second, "true"); + + pack.m_cheats.emplace_back(std::move(cheat)); + } + + CLog::Log(LOGDEBUG, "CCheatPack: \"{}\" declares {} cheat(s), {} usable", CURL::GetRedacted(path), + declared, pack.m_cheats.size()); + + if (declared > MAX_CHEATS) + CLog::Log(LOGINFO, "CCheatPack: only the first {} of {} cheats are offered", MAX_CHEATS, + declared); + + if (skipped > 0) + CLog::Log(LOGDEBUG, "CCheatPack: {} cheat(s) have no code for the core to apply", skipped); + + return pack; +} diff --git a/xbmc/games/cheats/CheatPack.h b/xbmc/games/cheats/CheatPack.h new file mode 100644 index 0000000000000..6f2c812c5e51c --- /dev/null +++ b/xbmc/games/cheats/CheatPack.h @@ -0,0 +1,71 @@ +/* + * Copyright (C) 2026 Team Kodi + * This file is part of Kodi - https://kodi.tv + * + * SPDX-License-Identifier: GPL-2.0-or-later + * See LICENSES/README.md for more information. + */ + +#pragma once + +#include +#include + +namespace KODI::GAME +{ +/*! + * \ingroup games + * + * \brief One cheat out of a cheat file + */ +struct Cheat +{ + //! What the cheat does, as the file describes it + std::string description; + + //! The code, in whatever form the emulated system uses. Kodi does not read + //! it: the game client hands it to the core, which is the only thing that + //! knows how to decode it. + std::string code; + + //! A fuller explanation, where the file carries one. Cheat files often put + //! a sentence of instructions here and keep the description a short name. + std::string longDescription; + + //! Whether the file shipped it switched on + bool enabled{false}; +}; + +/*! + * \ingroup games + * + * \brief The cheats libretro's cheat files declare for a game + * + * The format is the one RetroArch writes and the libretro cheat database is + * published in: a count, then a numbered block per cheat. + * + * cheats = 2 + * + * cheat0_desc = "Infinite lives" + * cheat0_code = "SXIOPO" + * cheat0_enable = false + */ +class CCheatPack +{ +public: + /*! + * \brief Read the cheats out of a cheat file + * + * A file that cannot be read, or that declares no cheats Kodi can pass on, + * gives back an empty pack rather than an error: a missing or unusable cheat + * file is an ordinary thing, not a fault. + */ + static CCheatPack Load(const std::string& path); + + const std::vector& Cheats() const { return m_cheats; } + bool IsEmpty() const { return m_cheats.empty(); } + +private: + std::vector m_cheats; +}; +} // namespace KODI::GAME diff --git a/xbmc/games/dialogs/osd/CMakeLists.txt b/xbmc/games/dialogs/osd/CMakeLists.txt index fbe8f28f38a63..4b3fd26729374 100644 --- a/xbmc/games/dialogs/osd/CMakeLists.txt +++ b/xbmc/games/dialogs/osd/CMakeLists.txt @@ -1,6 +1,7 @@ set(SOURCES DialogGameIndicators.cpp DialogGameAchievements.cpp DialogGameAdvancedSettings.cpp + DialogGameCheats.cpp DialogGameLeaderboards.cpp DialogGameLeaderboardEntries.cpp LeaderboardUtils.cpp @@ -18,6 +19,7 @@ set(SOURCES DialogGameIndicators.cpp set(HEADERS DialogGameIndicators.h DialogGameAchievements.h DialogGameAdvancedSettings.h + DialogGameCheats.h DialogGameLeaderboards.h DialogGameLeaderboardEntries.h LeaderboardUtils.h diff --git a/xbmc/games/dialogs/osd/DialogGameCheats.cpp b/xbmc/games/dialogs/osd/DialogGameCheats.cpp new file mode 100644 index 0000000000000..b2f56293e7b5e --- /dev/null +++ b/xbmc/games/dialogs/osd/DialogGameCheats.cpp @@ -0,0 +1,268 @@ +/* + * Copyright (C) 2026 Team Kodi + * This file is part of Kodi - https://kodi.tv + * + * SPDX-License-Identifier: GPL-2.0-or-later + * See LICENSES/README.md for more information. + */ + +#include "DialogGameCheats.h" + +#include "GUIUserMessages.h" +#include "ServiceBroker.h" +#include "games/GameUtils.h" +#include "games/addons/GameClient.h" +#include "games/addons/cheats/GameClientCheats.h" +#include "guilib/GUIComponent.h" +#include "guilib/GUIMacros.h" +#include "guilib/GUIMessage.h" +#include "guilib/GUIWindowManager.h" +#include "guilib/WindowIDs.h" +#include "jobs/JobManager.h" +#include "resources/LocalizeStrings.h" +#include "resources/ResourcesComponent.h" +#include "settings/lib/Setting.h" +#include "settings/windows/GUIControlSettings.h" +#include "utils/StringUtils.h" + +#include +#include +#include +#include +#include +#include + +using namespace KODI::GAME; + +namespace +{ +constexpr auto SETTING_CHEAT_PREFIX = "cheat"; +constexpr auto SETTING_GET_MORE = "getmore"; + +//! "Get more..." +constexpr int HEADING_GET_MORE = 21452; + +//! "Cheats" +constexpr int HEADING_CHEATS = 35320; + +//! "Enabled" +constexpr int HEADING_ENABLED = 305; + +//! The label above the list of what is switched on, a size larger than the +//! names below it, which a single control could not do: the text markup +//! carries no size of its own +constexpr int CONTROL_ENABLED_HEADING = 6001; + +//! Cheat files are written by hand and a description is sometimes a sentence +//! of instructions rather than a name. Past this the text runs under the switch. +constexpr size_t MAX_LABEL_LENGTH = 55; + +//! Cuts a label to a width the dialog can show. Counted in characters and cut +//! on a character boundary: a byte count both cuts short text that happens to +//! be multibyte and can split a character into invalid UTF-8. +std::string TruncateCharacters(const std::string& text, size_t characters) +{ + size_t pos = 0; + for (size_t seen = 0; seen < characters && pos < text.size(); ++seen) + { + // Step over the character's continuation bytes, which are 10xxxxxx and do + // not begin one, the same way utf8_strlen counts them + do + ++pos; + while (pos < text.size() && (static_cast(text[pos]) & 0xC0) == 0x80); + } + + return text.substr(0, pos); +} + +std::string SettingId(size_t index) +{ + return StringUtils::Format("{}{}", SETTING_CHEAT_PREFIX, index); +} +} // namespace + +CDialogGameCheats::CDialogGameCheats() + : CGUIDialogSettingsManualBase(WINDOW_DIALOG_GAME_CHEATS, "DialogGameCheats.xml") +{ +} + +CDialogGameCheats::~CDialogGameCheats() = default; + +void CDialogGameCheats::SetupView() +{ + CGUIDialogSettingsManualBase::SetupView(); + + SetHeading(HEADING_CHEATS); + SET_CONTROL_HIDDEN(CONTROL_SETTINGS_OKAY_BUTTON); + SET_CONTROL_HIDDEN(CONTROL_SETTINGS_CUSTOM_BUTTON); + SET_CONTROL_LABEL(CONTROL_SETTINGS_CANCEL_BUTTON, 15067); // "Close" +} + +std::string CDialogGameCheats::GetSettingsLabel(const std::shared_ptr& setting) +{ + const auto label = m_labels.find(setting->GetId()); + if (label != m_labels.end()) + return label->second; + + return CGUIDialogSettingsManualBase::GetSettingsLabel(setting); +} + +std::string CDialogGameCheats::EnabledSummary() const +{ + const GameClientPtr gameClient = CGameUtils::GetPlayingGameClient(); + if (!gameClient) + return ""; + + std::vector names; + for (const Cheat& cheat : gameClient->Cheats().GetCheats()) + { + if (cheat.enabled) + names.emplace_back(cheat.description); + } + + if (names.empty()) + return ""; + + // Centred in a narrow column, so the names stand alone under the heading + // rather than carrying bullets that would sit raggedly against them + return StringUtils::Join(names, "[CR]"); +} + +void CDialogGameCheats::SetDescription(const CVariant& label) +{ + // What is switched on is listed under the focused cheat's explanation, so + // the player can see it without scrolling the list back + const std::string summary = EnabledSummary(); + + SET_CONTROL_LABEL(CONTROL_ENABLED_HEADING, + summary.empty() + ? "" + : CServiceBroker::GetResourcesComponent().GetLocalizeStrings().Get( + HEADING_ENABLED)); + + const BaseSettingControlPtr control = GetSettingControl(m_iSetting); + if (control != nullptr && control->GetSetting() != nullptr) + { + const auto description = m_descriptions.find(control->GetSetting()->GetId()); + if (description != m_descriptions.end()) + { + CGUIDialogSettingsManualBase::SetDescription( + CVariant{summary.empty() ? description->second + : description->second + "[CR][CR]" + summary}); + return; + } + + // A cheat the file said nothing more about: the summary alone rather than + // the heading the settings framework falls back to + if (m_labels.find(control->GetSetting()->GetId()) != m_labels.end()) + { + CGUIDialogSettingsManualBase::SetDescription(CVariant{summary}); + return; + } + } + + CGUIDialogSettingsManualBase::SetDescription(label); +} + +void CDialogGameCheats::InitializeSettings() +{ + CGUIDialogSettingsManualBase::InitializeSettings(); + + const std::shared_ptr category = AddCategory("gamecheats", HEADING_CHEATS); + if (category == nullptr) + return; + + const std::shared_ptr group = AddGroup(category); + if (group == nullptr) + return; + + m_labels.clear(); + m_descriptions.clear(); + + const GameClientPtr gameClient = CGameUtils::GetPlayingGameClient(); + if (!gameClient) + return; + + const std::vector cheats = gameClient->Cheats().GetCheats(); + for (size_t index = 0; index < cheats.size(); ++index) + { + const Cheat& cheat = cheats[index]; + const std::string id = SettingId(index); + + // A cheat file is allowed to leave a cheat unnamed, and a switch with no + // label cannot be told apart from the ones around it + std::string label = !cheat.description.empty() ? cheat.description : cheat.code; + if (StringUtils::utf8_strlen(label) > MAX_LABEL_LENGTH) + label = TruncateCharacters(label, MAX_LABEL_LENGTH - 1) + "\u2026"; + + m_labels[id] = std::move(label); + + if (!cheat.longDescription.empty()) + m_descriptions[id] = cheat.longDescription; + else if (StringUtils::utf8_strlen(cheat.description) > MAX_LABEL_LENGTH) + m_descriptions[id] = cheat.description; + + // GetSettingsLabel() supplies the real label; a setting still has to be + // given a string ID to be created at all + AddToggle(group, id, HEADING_CHEATS, SettingLevel::Basic, cheat.enabled); + } + + // The player has no other way of learning the cheat database exists + if (CGameClientCheats::CanInstallCheats()) + AddButton(group, SETTING_GET_MORE, HEADING_GET_MORE, SettingLevel::Basic); +} + +void CDialogGameCheats::OnSettingAction(const std::shared_ptr& setting) +{ + CGUIDialogSettingsManualBase::OnSettingAction(setting); + + if (setting->GetId() != SETTING_GET_MORE) + return; + + const std::shared_ptr jobManager = CServiceBroker::GetJobManager(); + if (!jobManager) + return; + + // The download blocks, and the dialog is rebuilt on the GUI thread once the + // add-on is in place and the game has been looked up again. + // + // An update rather than an init: the player can close the dialog while the + // download runs, and initialising a closed one marks it active without the + // window manager knowing, after which it never opens again. + jobManager->Submit( + []() + { + const GameClientPtr gameClient = CGameUtils::GetPlayingGameClient(); + if (gameClient) + gameClient->Cheats().InstallCheats(); + + CGUIMessage message(GUI_MSG_UPDATE, WINDOW_DIALOG_GAME_CHEATS, + WINDOW_DIALOG_GAME_CHEATS); + CServiceBroker::GetGUI()->GetWindowManager().SendThreadMessage(message, + WINDOW_DIALOG_GAME_CHEATS); + }); +} + +void CDialogGameCheats::OnSettingChanged(const std::shared_ptr& setting) +{ + CGUIDialogSettingsManualBase::OnSettingChanged(setting); + + const std::string& id = setting->GetId(); + if (!StringUtils::StartsWith(id, SETTING_CHEAT_PREFIX)) + return; + + const std::string index = id.substr(std::strlen(SETTING_CHEAT_PREFIX)); + if (index.empty() || !StringUtils::IsNaturalNumber(index)) + return; + + const GameClientPtr gameClient = CGameUtils::GetPlayingGameClient(); + if (!gameClient) + return; + + gameClient->Cheats().SetEnabled( + static_cast(std::stoul(index)), + std::static_pointer_cast(setting)->GetValue()); + + // The panel lists what is on, so it follows every toggle + SetDescription(CVariant{""}); +} diff --git a/xbmc/games/dialogs/osd/DialogGameCheats.h b/xbmc/games/dialogs/osd/DialogGameCheats.h new file mode 100644 index 0000000000000..097a0aae12d44 --- /dev/null +++ b/xbmc/games/dialogs/osd/DialogGameCheats.h @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2026 Team Kodi + * This file is part of Kodi - https://kodi.tv + * + * SPDX-License-Identifier: GPL-2.0-or-later + * See LICENSES/README.md for more information. + */ + +#pragma once + +#include "settings/dialogs/GUIDialogSettingsManualBase.h" +#include "utils/Variant.h" + +#include +#include + +namespace KODI::GAME +{ +/*! + * \ingroup games + * + * \brief The cheats found for the game being played + * + * One switch per cheat out of the game's cheat file. Built on the settings + * dialog so the switches are the ones the rest of Kodi uses, and so no skin + * has to know this dialog exists. + * + * Reachable while the game has cheats, and while the add-on that carries them + * can still be fetched, which is the only place that offer is made. + */ +class CDialogGameCheats : public CGUIDialogSettingsManualBase +{ +public: + CDialogGameCheats(); + ~CDialogGameCheats() override; + +protected: + // Implementation of ISettingCallback + void OnSettingChanged(const std::shared_ptr& setting) override; + void OnSettingAction(const std::shared_ptr& setting) override; + + // Implementation of CGUIDialogSettingsBase + void SetupView() override; + bool Save() override { return true; } + std::string GetSettingsLabel(const std::shared_ptr& setting) override; + void SetDescription(const CVariant& label) override; + + // Implementation of CGUIDialogSettingsManualBase + void InitializeSettings() override; + +private: + //! \brief What is switched on, for the panel beside the list + std::string EnabledSummary() const; + + //! The name each switch should carry, by setting id + std::map m_labels; + + //! What to say about a cheat while it is the one focused, by setting id + std::map m_descriptions; +}; +} // namespace KODI::GAME diff --git a/xbmc/games/dialogs/osd/DialogGameOSD.cpp b/xbmc/games/dialogs/osd/DialogGameOSD.cpp index 4458083dc0bfe..b3ec4653fe5c7 100644 --- a/xbmc/games/dialogs/osd/DialogGameOSD.cpp +++ b/xbmc/games/dialogs/osd/DialogGameOSD.cpp @@ -12,6 +12,8 @@ #include "ServiceBroker.h" #include "games/GameServices.h" #include "games/GameSettings.h" +#include "guilib/GUIControlGroupList.h" +#include "guilib/GUIImage.h" #include "guilib/WindowIDs.h" #include "input/actions/Action.h" #include "input/actions/ActionIDs.h" @@ -19,6 +21,12 @@ using namespace KODI; using namespace GAME; +namespace +{ +constexpr int CONTROL_MENU = 1103; +constexpr int CONTROL_BACKGROUND = 2001; +} // namespace + CDialogGameOSD::CDialogGameOSD() : CGUIDialog(WINDOW_DIALOG_GAME_OSD, "GameOSD.xml"), m_helpDialog(std::make_unique(*this)) @@ -56,6 +64,21 @@ bool CDialogGameOSD::OnAction(const CAction& action) return CGUIDialog::OnAction(action); } +void CDialogGameOSD::Process(unsigned int currentTime, CDirtyRegionList& dirtyregions) +{ + const auto* menu = dynamic_cast(GetControl(CONTROL_MENU)); + auto* background = dynamic_cast(GetControl(CONTROL_BACKGROUND)); + if (menu && background && background->GetParentControl()) + { + // Match the cached menu height used by its parent grouplist for centering. + const float height = background->GetParentControl()->GetHeight() + menu->GetHeight(); + if (background->GetHeight() != height) + background->SetHeight(height); + } + + CGUIDialog::Process(currentTime, dirtyregions); +} + void CDialogGameOSD::OnInitWindow() { // Init parent class diff --git a/xbmc/games/dialogs/osd/DialogGameOSD.h b/xbmc/games/dialogs/osd/DialogGameOSD.h index 06dd1694fd82f..8e5ca2bc3cd25 100644 --- a/xbmc/games/dialogs/osd/DialogGameOSD.h +++ b/xbmc/games/dialogs/osd/DialogGameOSD.h @@ -30,6 +30,7 @@ class CDialogGameOSD : public CGUIDialog // Implementation of CGUIControl via CGUIDialog bool OnAction(const CAction& action) override; + void Process(unsigned int currentTime, CDirtyRegionList& dirtyregions) override; // Implementation of CGUIWindow via CGUIDialog void OnDeinitWindow(int nextWindowID) override; diff --git a/xbmc/guilib/GUIWindowManager.cpp b/xbmc/guilib/GUIWindowManager.cpp index a33b682f9bccd..cc224f4018637 100644 --- a/xbmc/guilib/GUIWindowManager.cpp +++ b/xbmc/guilib/GUIWindowManager.cpp @@ -151,6 +151,7 @@ #include "games/dialogs/disc/DialogGameDiscManager.h" #include "games/dialogs/osd/DialogGameAchievements.h" #include "games/dialogs/osd/DialogGameAdvancedSettings.h" +#include "games/dialogs/osd/DialogGameCheats.h" #include "games/dialogs/osd/DialogGameIndicators.h" #include "games/dialogs/osd/DialogGameLeaderboardEntries.h" #include "games/dialogs/osd/DialogGameLeaderboards.h" @@ -362,6 +363,7 @@ void CGUIWindowManager::CreateWindows() Add(new GAME::CDialogGameLeaderboards); Add(new GAME::CDialogGameLeaderboardEntries); Add(new GAME::CDialogGameIndicators); + Add(new GAME::CDialogGameCheats); Add(new RETRO::CGameWindowFullScreen); } @@ -493,6 +495,7 @@ bool CGUIWindowManager::DestroyWindows() DestroyWindow(WINDOW_DIALOG_GAME_LEADERBOARD_ENTRIES); DestroyWindow(WINDOW_DIALOG_GAME_INDICATORS); DestroyWindow(WINDOW_DIALOG_GAME_ACHIEVEMENTS); + DestroyWindow(WINDOW_DIALOG_GAME_CHEATS); DestroyWindow(WINDOW_FULLSCREEN_GAME); Remove(WINDOW_SETTINGS_SERVICE); diff --git a/xbmc/guilib/WindowIDs.h b/xbmc/guilib/WindowIDs.h index 885745eac6d01..22fd757b57ef1 100644 --- a/xbmc/guilib/WindowIDs.h +++ b/xbmc/guilib/WindowIDs.h @@ -166,6 +166,7 @@ #define WINDOW_DIALOG_GAME_LEADERBOARDS 10836 #define WINDOW_DIALOG_GAME_LEADERBOARD_ENTRIES 10837 #define WINDOW_DIALOG_GAME_INDICATORS 10838 +#define WINDOW_DIALOG_GAME_CHEATS 10839 //#define WINDOW_VIRTUAL_KEYBOARD 11000 // WINDOW_ID's from 11100 to 11199 reserved for Skins diff --git a/xbmc/guilib/guiinfo/GUIInfoLabels.h b/xbmc/guilib/guiinfo/GUIInfoLabels.h index ab7b0b846c71d..a7ec364473f7e 100644 --- a/xbmc/guilib/guiinfo/GUIInfoLabels.h +++ b/xbmc/guilib/guiinfo/GUIInfoLabels.h @@ -362,6 +362,7 @@ constexpr uint32_t RETROPLAYER_ACHIEVEMENTS_INDICATOR_TITLE = 1719; constexpr uint32_t RETROPLAYER_ACHIEVEMENTS_INDICATOR_BADGE = 1720; constexpr uint32_t RETROPLAYER_ACHIEVEMENTS_INDICATOR_PROGRESS = 1721; constexpr uint32_t RETROPLAYER_ACHIEVEMENTS_INDICATOR_PERCENT = 1722; +constexpr uint32_t RETROPLAYER_HAS_CHEATS = 1723; constexpr uint32_t RETROPLAYER_SUPPORTS_EJECT = 1700; constexpr uint32_t RETROPLAYER_DISC_EJECTED = 1701; constexpr uint32_t RETROPLAYER_DISC_LABEL = 1702; diff --git a/xbmc/guilib/guiinfo/GamesGUIInfo.cpp b/xbmc/guilib/guiinfo/GamesGUIInfo.cpp index 400e61bde3b6a..7b9ae9896c178 100644 --- a/xbmc/guilib/guiinfo/GamesGUIInfo.cpp +++ b/xbmc/guilib/guiinfo/GamesGUIInfo.cpp @@ -22,6 +22,8 @@ #include "games/GameServices.h" #include "games/GameSettings.h" #include "games/addons/GameClient.h" +#include "games/GameUtils.h" +#include "games/addons/cheats/GameClientCheats.h" #include "games/tags/GameInfoTag.h" #include "guilib/GUIComponent.h" #include "guilib/guiinfo/GUIInfo.h" @@ -362,6 +364,12 @@ bool CGamesGUIInfo::GetBool(bool& value, value = CServiceBroker::GetGameServices().GameSettings().GetAchievementsLoggedIn(); return true; } + case RETROPLAYER_HAS_CHEATS: + { + const GameClientPtr gameClient = CGameUtils::GetPlayingGameClient(); + value = gameClient && gameClient->Cheats().CanOfferCheats(); + return true; + } default: break; } diff --git a/xbmc/input/WindowTranslator.cpp b/xbmc/input/WindowTranslator.cpp index ff01445390fca..e8031b1a651ae 100644 --- a/xbmc/input/WindowTranslator.cpp +++ b/xbmc/input/WindowTranslator.cpp @@ -180,6 +180,7 @@ const CWindowTranslator::WindowMapByName CWindowTranslator::WindowMappingByName {"gameindicators", WINDOW_DIALOG_GAME_INDICATORS}, {"gameleaderboards", WINDOW_DIALOG_GAME_LEADERBOARDS}, {"gameleaderboardentries", WINDOW_DIALOG_GAME_LEADERBOARD_ENTRIES}, + {"gamecheats", WINDOW_DIALOG_GAME_CHEATS}, {"dialogselectvideo", WINDOW_DIALOG_SELECT_VIDEO_STREAM}, {"dialogselectaudio", WINDOW_DIALOG_SELECT_AUDIO_STREAM}, {"dialogselectsubtitle", WINDOW_DIALOG_SELECT_SUBTITLE_STREAM},