From ee1b7df96d2f8575f39bc5a218a7dc08ec932eb6 Mon Sep 17 00:00:00 2001 From: Garrett Brown Date: Thu, 10 Sep 2026 12:13:05 -0700 Subject: [PATCH 1/2] [Estuary] Refactor game OSD menu layout Replace the duplicated Disc and non-Disc layouts with reusable buttons in a centered grouplist. Visible controls determine the menu height together with its header. Draw one continuous background to avoid row seams at fractional GUI scales. Size the optional background from the header and menu controls, as the context menu does for its dynamic background. Preserve existing actions, help mode, and navigation. Remove the obsolete list and scrollbar machinery. --- addons/skin.estuary/xml/GameOSD.xml | 487 ++++----------------- addons/skin.estuary/xml/Includes.xml | 8 + addons/skin.estuary/xml/Includes_Games.xml | 50 +++ xbmc/games/dialogs/osd/DialogGameOSD.cpp | 23 + xbmc/games/dialogs/osd/DialogGameOSD.h | 1 + 5 files changed, 172 insertions(+), 397 deletions(-) diff --git a/addons/skin.estuary/xml/GameOSD.xml b/addons/skin.estuary/xml/GameOSD.xml index 6efb397d38fb0..a5c9d56a96201 100644 --- a/addons/skin.estuary/xml/GameOSD.xml +++ b/addons/skin.estuary/xml/GameOSD.xml @@ -72,410 +72,103 @@ - !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) + + + + + + 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/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; From a5c53c05aa2abfc64bb03d573a5cce7b643209fe Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 10 Sep 2026 19:53:21 +0100 Subject: [PATCH 2/2] RetroPlayer: Add a cheat engine Cheats are read from libretro .cht files named after the game and offered as toggles in a dialog one level below the game OSD. The row only appears for a client that implements the cheat entry points. Only the cheats that are switched on are sent, and they are sent whole after a reset, because a cheat is identified by the slot it was given and several cores ignore the enabled flag they are handed. Files are looked for in the folder the player points the setting at, and in every game resource add-on they have installed. Cheat files are data, so an add-on carrying them needs no binary of its own and installs and updates through the repository like anything else. The setting is asked first and stays either way, for a database of somebody's own or a system no add-on covers. A cheat add-on ships one zip per system rather than tens of thousands of loose files, so an archive is searched like a folder. Kodi reads it through the same calls either way. The dialog offers to fetch that add-on when it is missing or switched off, the way the video filter offers shader presets, since the player has no other way of learning the database exists. That is also why the OSD row now shows while the offer stands rather than only once cheats have been found, and why the folder setting has moved to expert: it is the escape hatch now, not the way in. The cheats belong to the game client, as a subsystem beside its achievements, input and streams. The pack and the switches are that client's, and go when it does. Nothing global holds a pointer back to a client, so nothing here assumes only one can ever be loaded. The client's own lock is taken before the cheat state's on every path, which is the order a game being closed already holds them in, and the client cannot be torn down between the codes of a batch because closing it takes that same lock. Descriptions are written with the HTML entities that came with them from the web, so they are resolved through Kodi's own table rather than shown as written: a quoted button arrives as "A", and 428 of the database's 28,308 files carry one. The dialog keeps its own copy of the settings layout so the column beside the list can say what is switched on, which the shared one has no control for. The player would otherwise have to scroll back through several hundred rows to see what they had turned on. --- .../resources/strings.po | 20 +- .../media/osd/fullscreen/buttons/cheats.png | Bin 0 -> 1553 bytes addons/skin.estuary/xml/DialogGameCheats.xml | 123 +++++++ addons/skin.estuary/xml/GameOSD.xml | 10 +- cmake/treedata/common/games.txt | 2 + system/settings/settings.xml | 12 + xbmc/GUIInfoManager.cpp | 13 +- xbmc/games/GameServices.h | 1 + xbmc/games/GameUtils.cpp | 22 ++ xbmc/games/GameUtils.h | 10 + xbmc/games/addons/GameClient.cpp | 40 +++ xbmc/games/addons/GameClient.h | 15 + xbmc/games/addons/GameClientSubsystem.cpp | 7 + xbmc/games/addons/GameClientSubsystem.h | 3 + xbmc/games/addons/cheats/CMakeLists.txt | 5 + xbmc/games/addons/cheats/GameClientCheats.cpp | 327 ++++++++++++++++++ xbmc/games/addons/cheats/GameClientCheats.h | 109 ++++++ xbmc/games/cheats/CMakeLists.txt | 5 + xbmc/games/cheats/CheatPack.cpp | 160 +++++++++ xbmc/games/cheats/CheatPack.h | 71 ++++ xbmc/games/dialogs/osd/CMakeLists.txt | 2 + xbmc/games/dialogs/osd/DialogGameCheats.cpp | 268 ++++++++++++++ xbmc/games/dialogs/osd/DialogGameCheats.h | 61 ++++ xbmc/guilib/GUIWindowManager.cpp | 3 + xbmc/guilib/WindowIDs.h | 1 + xbmc/guilib/guiinfo/GUIInfoLabels.h | 1 + xbmc/guilib/guiinfo/GamesGUIInfo.cpp | 8 + xbmc/input/WindowTranslator.cpp | 1 + 28 files changed, 1297 insertions(+), 3 deletions(-) create mode 100644 addons/skin.estuary/media/osd/fullscreen/buttons/cheats.png create mode 100644 addons/skin.estuary/xml/DialogGameCheats.xml create mode 100644 xbmc/games/addons/cheats/CMakeLists.txt create mode 100644 xbmc/games/addons/cheats/GameClientCheats.cpp create mode 100644 xbmc/games/addons/cheats/GameClientCheats.h create mode 100644 xbmc/games/cheats/CMakeLists.txt create mode 100644 xbmc/games/cheats/CheatPack.cpp create mode 100644 xbmc/games/cheats/CheatPack.h create mode 100644 xbmc/games/dialogs/osd/DialogGameCheats.cpp create mode 100644 xbmc/games/dialogs/osd/DialogGameCheats.h 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 0000000000000000000000000000000000000000..25d577e774337cb8a898dde2e80f52ab0e88405d GIT binary patch literal 1553 zcma)+c{tMz0LOorvtiE0(?iNl$eo*)Y>utX!t0gDy>g8AMLD)e?rTXh)@ZIANg{I0 zU6d=!85LO>lBdA@&t(=H(Fg@t5=000n%!)(zAr4N}=7^<3pgvesez1w=A5KHA-lx@?h#06iwtuo?{{6i$Jc}8%yKr@`qboKV z-@-UC?L8kEMIMbLGt5RKN8GIryGTh~IshLHfC4<;0GRL_pyM7Q%Vo(EURd-Qtf988 zM^Gof!N&0lXYS|`aGKsh(o2JAySfKl^GKx@zq1@`%6J#Rt>0$jXOdvikgITA;pF23 zoi}-sbfj$ML}ozM1$T4eKM@Sy<~cNd^UdFG7lkyxYY=?b z%2j%I^;>Hq2s3~7-$@eqplx_(Mq|xk?~<}|`Hp?M$} z?{*#5SG#)w8lAvp#jP6kH+|c=pO4XXL8D6l z$~UE#h80^Hkkb5P#hM5pYYkD#wiuf3)@?Hu+W9zE5G$$!#nE~<1o8gNyq(TOo1h9R z0$#KAgdCUgE0wBc=aTv>)NHxBU{a~NnFVNuCp$~yV{Q7}%H$Q3^_!*v{c<|zT@Ckw zH2j5>suqw_afH+ud>eIe`)Pe#T9Ela>#^Q-*A-Te;+-zdO5ko7GJngjoUku9sco|4pQqUJ z$*6MokfbLr|Gu_?DvJkV6zcZ$q~r}8(y^WLK&?YHJC~XzlaXWSNNkq0Al!4ob~#Q| zfcv$lKy_nwaC9Yk0u<&2`5d}k*~}K^0f+O*)_a_pLX^OYz8S4gM$oY_hSrfV!uNol1J32jXgYL#PH2z zdi6QMC}xO1m}(2HwSd~4*-)!)F+(hHkNo+0V8yty)s4c*mED_7V@`1Ey2hi{qoqw{?pI0tJ?Or5o@Kzj=`V&nT?9 Y#dOM*oVF*Ds=$%pb_m;g8}Gz_0mb^;2mk;8 literal 0 HcmV?d00001 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 a5c9d56a96201..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 @@ -148,6 +149,13 @@ Always show Achievements; its dialog explains sign-in requirements and missing achievement sets. ActivateWindow(GameAchievements) + + + + + RetroPlayer.HasCheats + ActivateWindow(GameCheats) + 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/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},