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