@@ -1718,6 +1718,312 @@ async def list_installed_games(self) -> dict:
17181718 decky .logger .error (str (e ))
17191719 return {'status' : 'error' , 'message' : str (e )}
17201720
1721+ def _parse_shortcuts_vdf (self , filepath : str ) -> list :
1722+ """Parse Steam's binary shortcuts.vdf file and return a list of shortcut entries.
1723+
1724+ Binary VDF format:
1725+ - 0x00 = nested object start (key is null-terminated string)
1726+ - 0x01 = string value (key and value are null-terminated)
1727+ - 0x02 = int32 value (key is null-terminated, value is 4 bytes LE)
1728+ - 0x08 = end of object
1729+ """
1730+ entries = []
1731+ try :
1732+ with open (filepath , 'rb' ) as f :
1733+ data = f .read ()
1734+
1735+ pos = 0
1736+ size = len (data )
1737+
1738+ def read_string (p ):
1739+ end = data .index (b'\x00 ' , p )
1740+ return data [p :end ].decode ('utf-8' , errors = 'replace' ), end + 1
1741+
1742+ def parse_object (p ):
1743+ obj = {}
1744+ while p < size :
1745+ type_byte = data [p ]
1746+ p += 1
1747+
1748+ if type_byte == 0x08 : # End of object
1749+ break
1750+
1751+ # Read key name
1752+ key , p = read_string (p )
1753+
1754+ if type_byte == 0x00 : # Nested object
1755+ value , p = parse_object (p )
1756+ obj [key ] = value
1757+ elif type_byte == 0x01 : # String value
1758+ value , p = read_string (p )
1759+ obj [key ] = value
1760+ elif type_byte == 0x02 : # Int32 value
1761+ value = int .from_bytes (data [p : p + 4 ], byteorder = 'little' , signed = True )
1762+ p += 4
1763+ obj [key ] = value
1764+ else :
1765+ # Unknown type, skip
1766+ break
1767+
1768+ return obj , p
1769+
1770+ # Skip root object header: 0x00 + "shortcuts" + 0x00
1771+ if data [pos ] == 0x00 :
1772+ pos += 1
1773+ _ , pos = read_string (pos )
1774+
1775+ # Parse each shortcut entry
1776+ while pos < size :
1777+ type_byte = data [pos ]
1778+ if type_byte == 0x08 : # End of root object
1779+ break
1780+ if type_byte == 0x00 :
1781+ pos += 1
1782+ _ , pos = read_string (pos ) # Skip entry index key (e.g. "0", "1")
1783+ entry , pos = parse_object (pos )
1784+ entries .append (entry )
1785+ else :
1786+ break
1787+
1788+ except Exception as e :
1789+ decky .logger .error (f'Error parsing shortcuts.vdf at { filepath } : { e !s} ' )
1790+
1791+ return entries
1792+
1793+ async def list_non_steam_games (self ) -> dict :
1794+ """List non-Steam game shortcuts added via 'Add a Non-Steam Game' in Steam."""
1795+ try :
1796+ steam_root = Path (decky .HOME ) / '.steam' / 'steam'
1797+ userdata_path = steam_root / 'userdata'
1798+
1799+ if not userdata_path .exists ():
1800+ return {'status' : 'error' , 'message' : 'Steam userdata directory not found' }
1801+
1802+ games = []
1803+ seen_ids = set ()
1804+
1805+ # Iterate all user profiles
1806+ for user_dir in userdata_path .iterdir ():
1807+ if not user_dir .is_dir ():
1808+ continue
1809+
1810+ shortcuts_file = user_dir / 'config' / 'shortcuts.vdf'
1811+ if not shortcuts_file .exists ():
1812+ continue
1813+
1814+ entries = self ._parse_shortcuts_vdf (str (shortcuts_file ))
1815+ for entry in entries :
1816+ app_name = entry .get ('appname' , entry .get ('AppName' , '' ))
1817+ exe = entry .get ('exe' , entry .get ('Exe' , '' ))
1818+ start_dir = entry .get ('StartDir' , '' )
1819+ app_id = entry .get ('appid' , entry .get ('AppId' , 0 ))
1820+
1821+ if not app_name or not exe :
1822+ continue
1823+
1824+ # Convert signed appid to unsigned for deduplication
1825+ unsigned_id = app_id & 0xFFFFFFFF if isinstance (app_id , int ) else app_id
1826+
1827+ if unsigned_id in seen_ids :
1828+ continue
1829+ seen_ids .add (unsigned_id )
1830+
1831+ # Strip surrounding quotes from exe and start_dir
1832+ exe = exe .strip ('"' )
1833+ start_dir = start_dir .strip ('"' )
1834+
1835+ # Skip non-exe shortcuts (scripts, flatpaks, emulators, launchers)
1836+ if not exe .lower ().endswith ('.exe' ):
1837+ continue
1838+
1839+ games .append (
1840+ {
1841+ 'name' : app_name ,
1842+ 'exe' : exe ,
1843+ 'start_dir' : start_dir ,
1844+ 'appid' : str (unsigned_id ),
1845+ }
1846+ )
1847+
1848+ # Sort alphabetically
1849+ games .sort (key = lambda g : g ['name' ].lower ())
1850+
1851+ return {'status' : 'success' , 'games' : games }
1852+
1853+ except Exception as e :
1854+ decky .logger .error (f'Error listing non-Steam games: { e !s} ' )
1855+ return {'status' : 'error' , 'message' : str (e )}
1856+
1857+ async def find_non_steam_game_executable_path (
1858+ self , exe_path : str , start_dir : str , game_name : str
1859+ ) -> dict :
1860+ """Find executable paths for a non-Steam game shortcut.
1861+
1862+ If the shortcut's exe points to a .exe file, scan its directory for alternatives.
1863+ """
1864+ try :
1865+ decky .logger .info (
1866+ f'Finding executable for non-Steam game: { game_name } (exe={ exe_path } , start_dir={ start_dir } )'
1867+ )
1868+
1869+ # Check cache first
1870+ cache_key = f'nonsteam_{ exe_path } _{ game_name } '
1871+ if cache_key in self .executable_cache :
1872+ cached_result = self .executable_cache [cache_key ]
1873+ if time .time () - cached_result .get ('timestamp' , 0 ) < 3600 :
1874+ decky .logger .info (f'Using cached result for { game_name } ' )
1875+ return cached_result
1876+
1877+ # Determine the search directory
1878+ search_dir = None
1879+
1880+ if os .path .exists (exe_path ):
1881+ # The shortcut directly points to a .exe - use its directory
1882+ search_dir = os .path .dirname (exe_path )
1883+ elif start_dir and os .path .isdir (start_dir ):
1884+ search_dir = start_dir
1885+
1886+ if not search_dir or not os .path .isdir (search_dir ):
1887+ # Can't find a valid directory to scan
1888+ if os .path .exists (exe_path ):
1889+ # At least we have the exe itself
1890+ result = {
1891+ 'status' : 'success' ,
1892+ 'non_steam_detection_result' : {
1893+ 'status' : 'success' ,
1894+ 'method' : 'shortcut_exe' ,
1895+ 'executable_path' : exe_path ,
1896+ 'directory_path' : os .path .dirname (exe_path ),
1897+ 'filename' : os .path .basename (exe_path ),
1898+ 'all_executables' : [
1899+ {
1900+ 'path' : exe_path ,
1901+ 'directory_path' : os .path .dirname (exe_path ),
1902+ 'filename' : os .path .basename (exe_path ),
1903+ 'relative_path' : os .path .basename (exe_path ),
1904+ 'size' : os .path .getsize (exe_path ),
1905+ 'size_mb' : round (os .path .getsize (exe_path ) / (1024 * 1024 ), 1 ),
1906+ 'score' : 100 ,
1907+ }
1908+ ],
1909+ 'confidence' : 'high' ,
1910+ },
1911+ 'recommended_method' : 'shortcut_exe' ,
1912+ 'timestamp' : time .time (),
1913+ }
1914+ self .executable_cache [cache_key ] = result
1915+ return result
1916+
1917+ return {
1918+ 'status' : 'error' ,
1919+ 'method' : 'non_steam_detection' ,
1920+ 'message' : f'Could not find a valid game directory to scan. Exe: { exe_path } , StartDir: { start_dir } ' ,
1921+ }
1922+
1923+ # Walk the directory tree and find all .exe files
1924+ all_executables = []
1925+ decky .logger .info (f'Scanning directory: { search_dir } ' )
1926+
1927+ for root , _dirs , files in os .walk (search_dir ):
1928+ for file in files :
1929+ if file .lower ().endswith ('.exe' ):
1930+ full_path = os .path .join (root , file )
1931+ try :
1932+ file_size = os .path .getsize (full_path )
1933+ rel_path = os .path .relpath (full_path , search_dir )
1934+ all_executables .append (
1935+ {
1936+ 'path' : full_path ,
1937+ 'directory_path' : os .path .dirname (full_path ),
1938+ 'relative_path' : rel_path ,
1939+ 'filename' : file ,
1940+ 'size' : file_size ,
1941+ 'size_mb' : round (file_size / (1024 * 1024 ), 1 ),
1942+ }
1943+ )
1944+ except Exception as e :
1945+ decky .logger .warning (f'Error getting size for { full_path } : { e !s} ' )
1946+
1947+ if not all_executables :
1948+ return {
1949+ 'status' : 'error' ,
1950+ 'method' : 'non_steam_detection' ,
1951+ 'message' : f'No .exe files found in { search_dir } ' ,
1952+ }
1953+
1954+ decky .logger .info (f'Found { len (all_executables )} executables' )
1955+
1956+ # Score executables using the Heroic scorer (it's more generic than Steam's)
1957+ scored_executables = []
1958+ for exe_info in all_executables :
1959+ score = score_heroic_executable (exe_info , game_name , search_dir , decky .logger )
1960+
1961+ # Bonus if this exe matches the shortcut's exe path
1962+ if os .path .normpath (exe_info ['path' ]) == os .path .normpath (exe_path ):
1963+ score += 50
1964+ decky .logger .info (
1965+ f'Bonus +50 for matching shortcut exe: { exe_info ["filename" ]} '
1966+ )
1967+
1968+ if score > 0 :
1969+ scored_executables .append ({** exe_info , 'score' : score })
1970+
1971+ if not scored_executables :
1972+ # Fallback: include all with any score
1973+ for exe_info in all_executables :
1974+ score = score_heroic_executable (exe_info , game_name , search_dir , decky .logger )
1975+ if os .path .normpath (exe_info ['path' ]) == os .path .normpath (exe_path ):
1976+ score += 50
1977+ scored_executables .append ({** exe_info , 'score' : score })
1978+
1979+ scored_executables .sort (key = lambda x : x ['score' ], reverse = True )
1980+ top_executables = scored_executables [:5 ]
1981+ best_executable = top_executables [0 ]
1982+
1983+ decky .logger .info (f'Top executables for { game_name } :' )
1984+ for i , exe in enumerate (top_executables ):
1985+ decky .logger .info (
1986+ f' { i + 1 } . { exe ["filename" ]} (score: { exe ["score" ]} ) at { exe ["relative_path" ]} '
1987+ )
1988+
1989+ result = {
1990+ 'status' : 'success' ,
1991+ 'non_steam_detection_result' : {
1992+ 'status' : 'success' ,
1993+ 'method' : 'non_steam_detection' ,
1994+ 'executable_path' : best_executable ['path' ],
1995+ 'directory_path' : best_executable ['directory_path' ],
1996+ 'filename' : best_executable ['filename' ],
1997+ 'all_executables' : top_executables ,
1998+ 'confidence' : 'high' if best_executable ['score' ] > 70 else 'medium' ,
1999+ },
2000+ 'recommended_method' : 'non_steam_detection' ,
2001+ 'timestamp' : time .time (),
2002+ }
2003+
2004+ self .executable_cache [cache_key ] = result
2005+ return result
2006+
2007+ except Exception as e :
2008+ decky .logger .error (f'Non-Steam executable detection error: { e !s} ' )
2009+ return {'status' : 'error' , 'method' : 'non_steam_detection' , 'message' : str (e )}
2010+
2011+ async def install_reshade_for_non_steam_game (
2012+ self , game_path : str , dll_override : str = 'dxgi' , selected_executable_path : str = ''
2013+ ) -> dict :
2014+ """Install ReShade for a non-Steam game. Delegates to the Heroic installer since the logic is identical."""
2015+ decky .logger .info (
2016+ f'Installing ReShade for non-Steam game at: { game_path } (exe: { selected_executable_path } )'
2017+ )
2018+ return await self .install_reshade_for_heroic_game (
2019+ game_path , dll_override , selected_executable_path
2020+ )
2021+
2022+ async def uninstall_reshade_for_non_steam_game (self , game_path : str ) -> dict :
2023+ """Uninstall ReShade from a non-Steam game. Delegates to the Heroic uninstaller."""
2024+ decky .logger .info (f'Uninstalling ReShade from non-Steam game at: { game_path } ' )
2025+ return await self .uninstall_reshade_for_heroic_game (game_path )
2026+
17212027 async def find_heroic_games (self ) -> dict :
17222028 """Find games installed through Heroic Launcher using the config file"""
17232029 try :
0 commit comments