프로그래밍을 하면서 스팀에서 설치된 게임의 경로를 알아내야 할 상황이 생겼습니다. 예를 들어 위쳐3 가 스팀으로 설치됐다면 위쳐3의 설치 경로를 인식해야 하는 상황입니다.
스팀에 설치된 게임의 경로를 인식하는 프로그래밍 적인 방법엔 아래와 같은 방법이 있습니다.
1. 프로그램 사용자에게 직접 게임의 경로 입력 받기
2. 스팀에 저장된 파일을 이용해서 자동으로 인식하기
(스팀에는 *.acf, *.vdf 와 같은 형태의 파일을 이용해 게임의 설치 경로를 저장하고 있습니다. 이 파일을 읽어내면 경로를 인식할 수 있습니다.)
3. 디스크 전체를 탐색해서 특정 프로그램을 찾기
3번은 아무래도 디스크 전체를 탐색해야 하기 때문에 너무 느릴것이기 때문에 배제하고
여기서 고려해볼만한 방법은 1번과 2번입니다.
1번의 경우에는 유저에게 경로를 직접 찾으라고 책임을 전가하기 때문에, 사용자 입장에선 매우 귀찮아지지만 폴더 선택창만 띄우면 되기 때문에 코드가 매우 간단해지고 가장 확실한 방법이라고 볼 수 있습니다.
2번의 경우에는 스팀이 설치 경로를 acf, vdf 확장자의 파일로 저장해두는데 이 파일을 파싱(분석)해서 바로 찾아내는 방법입니다. 사용자가 귀찮은 짓거리를 할필요도 없이 그냥 자동으로 경로를 인식해낼 수 있는 가장 이상적인 방법입니다.
하지만 acf와 vdf는 스팀에서 다루는 조금 특수한 파일이기 때문에 그냥 파싱하기엔 까다롭기에 코드가 복잡해진다는 단점이 있습니다.
저는 2번의 방법을 사용해 스팀 경로를 자동 인식하는 코드를 작성했습니다. 코드만 필요하신 분들은 바로 아래서 코드만 가져가시고 작동 원리가 궁금하신 분들은 아래 부분에 설명을 달아놓을 태니 스크롤을 내려서 확인해보세요.
코드
시작전에 폴더 구조는 위와 같습니다. 파일이 여러가지가 있는데 빨간색 네모로 쳐진 4가지 파일만 필요합니다. 나머지 파일은 제가 디버깅 하면서 생성한거라 생성 하실 필요 없습니다.
우선 VDF 파일 파싱을 위해 필요한 vdf 파서입니다. 이걸 받아서 main.py와 같은 경로에 놔둬주세요
그리고 main.py 파일과 module/module.py 파일을 생성하고 아래 코드를 복붙합니다.
그리고 main.py를 실행해보면 컴퓨터에 저장된 모든 스팀게임들의 경로를 얻어낼 수 있습니다.
# ./main.py
from pprint import pprint
from module.module import SteamPath
steam_path = SteamPath()
# print(steam_path.install_path)
# print(steam_path.library_path)
# print(steam_path.appinfo_path)
# print(steam_path.library_data)
# pprint(steam_path.app_info_dic)
pprint(steam_path.game_dir_data)
>>>
[{'app_id': '70',
'base_path': 'G:\\SteamLibrary',
'executable': 'hl.exe',
'full_path': 'G:\\SteamLibrary\\steamapps\\common\\Half-Life\\hl.exe',
'installdir': 'Half-Life'},
{'app_id': '4000',
'base_path': 'C:\\Program Files (x86)\\Steam',
'executable': 'hl2.exe',
'full_path': 'C:\\Program Files '
'(x86)\\Steam\\steamapps\\common\\GarrysMod\\hl2.exe',
'installdir': 'GarrysMod'},
{'app_id': '105600',
'base_path': 'G:\\SteamLibrary',
'executable': 'Terraria.exe',
'full_path': 'G:\\SteamLibrary\\steamapps\\common\\Terraria\\Terraria.exe',
'installdir': 'Terraria'},
...
# ./module/module.py
import json
import subprocess
import sys
from typing import Any
import winreg
import os
import vdf
# SteamPath 객체를 통해 스팀 경로를 관리한다.
# SteamPath 의 설계도 (Class)
class SteamPath:
# 전역적으로 사용할 상수들
steam_apps = "steamapps" # 스팀 설치 경로의 steamapps 폴더 이름
library_folders = "libraryfolders.vdf" # 스팀 설치 경로의 steamapps 폴더 안에 있는 libraryfolders.vdf 파일 이름
app_cache = "appcache" # 스팀 설치 경로의 appcache 폴더 이름
appinfo = "appinfo.vdf" # 스팀 설치 경로의 appcache 폴더 안에 있는 appinfo.vdf 파일 이름
app_manifest = "appmanifest_{0}.acf" # 스팀 설치 경로의 steamapps 폴더 안에 있는 acf 파일 이름
common = "common" # 스팀 설치 경로의 steamapps 폴더 안에 있는 common 폴더 이름
def __init__(self) -> None:
self.__install_path : str = self.__get_steam_path()
self.__library_path : str = os.path.join(self.__install_path, SteamPath.steam_apps, SteamPath.library_folders)
self.__appinfo_path : str = os.path.join(self.__install_path, SteamPath.app_cache, SteamPath.appinfo)
# libraryfolders.vdf를 파싱해서 설치된 스팀 게임들의 app 번호를 리스트로 반환한다
self.__library_data = self.parse_library_vdf(self.__library_path)
# 스팀 설치 경로 + appcache 안에 appinfo.vdf를 파싱한다
self.__app_info_dic = self.parse_appinfo_vdf(self.__appinfo_path, self.__library_data)
# library 데이터와 appinfo 데이터를 합쳐서 게임 디렉토리 경로를 만든다
self.__game_dir_data = self.get_game_dirs(self.__app_info_dic, self.__library_data)
# 스팀 설치 경로를 자동으로 읽어오는 함수
def __get_steam_path(self) -> str:
# 시스템이 32비트인지 64비트인지 확인
is_64bit = sys.maxsize > 2**32
# 시스템 아키텍처에 따라 올바른 레지스트리 경로 설정
if is_64bit:
registry_path = r"SOFTWARE\Wow6432Node\Valve\Steam"
else:
registry_path = r"SOFTWARE\Valve\Steam"
try:
# 레지스트리 키 열기
key = winreg.OpenKey(winreg.HKEY_LOCAL_MACHINE, registry_path, 0, winreg.KEY_READ)
install_path : str = ""
# InstallPath 값 읽기
install_path, _ = winreg.QueryValueEx(key, "InstallPath")
# 레지스트리 키 닫기
winreg.CloseKey(key)
return install_path
# 예외는 호출자쪽에 맡긴다.
except FileNotFoundError:
raise FileNotFoundError("It looks like Steam isn't installed.")
except Exception as e:
raise e
# 프로세스 실행하고 결과를 반환하는 함수
def __run_process(self, command: list[str]) -> str:
# command ex) ['ls', '-al']
# 명령어 실행 후, 결과를 output에 저장
# text = True : output을 text로 저장,
# encoding은 기본이 cp949라 문제 없이 저장할려면 utf-8로 설정
try:
output: str = subprocess.check_output(
command, stderr=subprocess.STDOUT, encoding="utf-8", text=True
)
except Exception as e:
raise Exception("Command execution failed", command, e)
return output
# libraryfolders.vdf 파일을 경로로 받아서 파싱하는 함수
def parse_library_vdf(self, vdf_file_path : str):
dic : dict = vdf.load(open(vdf_file_path, encoding="utf-8"))
inner : dict = dic['libraryfolders']
result : list[dict] = []
for key, value in inner.items():
base_path : str = value['path']
apps : list[str] = list(value['apps'])
result.append({"base_path" : base_path, "apps" : apps})
return result
# appinfo.vdf 파일을 딕셔너리로 파싱하는 함수
def parse_appinfo_vdf(self, appinfo_path : str, library_data : list) -> dict[Any, Any]:
# library_data 에서 apps 를 모두 가져온다
app_id_lst : list[str] = []
for element in library_data:
app_id_lst.extend(element['apps'])
result = self.__run_process(["VDFparse.exe", appinfo_path, *app_id_lst])
# result를 json 형식으로 변환
json_dict : dict = json.loads(result)
return json_dict
# app id 리스트를 받아서 acf 파일을 파싱하는 함수
# def parse_acf(self, steam_path : str, app_id_lst : list[str]) -> list[dict]:
# acf_lst : list[dict] = []
# for app_id in app_id_lst:
# acf_file_path = os.path.join(steam_path, SteamPath.steam_apps, SteamPath.app_manifest.format(app_id))
# # acf 파일이 없다면 그냥 넘어간다
# # (libraryfolders.vdf 파일은 스팀을 재시작해야 반영되기 때문에 게임을 삭제한 직후라면 acf 파일이 없을 수 있다.)
# if not os.path.isfile(acf_file_path):
# continue
# # 확장자는 acf 로 끝나지만 vdf 와 구조가 똑같기 때문에 vdf 라이브러리로 파싱 가능하다.
# dic : dict = vdf.load(open(acf_file_path, encoding="utf-8"))
# acf_lst.append(dic['AppState'])
# return acf_lst
def get_game_dirs(self, app_info_dic, library_data : list):
result = []
# 각 app_id 에 대해 경로를 매칭시키는 데이터를 생성
app_id_match = {}
for i, element in enumerate(library_data):
for app_id in element['apps']:
app_id_match[app_id] = element['base_path']
for element in app_info_dic['datasets']:
appinfo = element['data']['appinfo']
app_id = str(appinfo['appid'])
base_path = app_id_match[app_id]
config = appinfo['config']
executable = ""
installdir = config['installdir']
for key, value in config['launch'].items():
# 실행 경로의 경우 리눅스, 윈도우, 맥에 따라 다르나
# 해당 코드는 윈도우에서만 동작하기 때문에 윈도우 경로만 추출한다.
# 'config' 키가 존재하고, 'oslist' 키도 존재하면 해당 값을 가져오고, 그렇지 않으면 None을 반환
oslist = value.get('config', {}).get('oslist', None)
executable : str = value['executable']
if executable == "":
continue
if oslist == "windows" or "exe" in executable or "bat" in executable or "cmd" in executable:
full_path = os.path.join(base_path, SteamPath.steam_apps, SteamPath.common, installdir, executable)
# 실행 파일이 존재해야만 경로를 저장한다.
if os.path.isfile(full_path):
break
full_path = os.path.join(base_path, SteamPath.steam_apps, SteamPath.common, installdir, executable)
# result에 모든 데이터를 담는다
result.append({
"app_id" : app_id,
"base_path" : base_path,
"executable" : executable,
"installdir" : installdir,
"full_path" : full_path
})
return result
# 프로퍼티로 데이터 반환
@property
def install_path(self) -> str:
return self.__install_path
@property
def library_path(self) -> str:
return self.__library_path
@property
def appinfo_path(self) -> str:
return self.__appinfo_path
@property
def library_data(self) -> list:
return self.__library_data
@property
def app_info_dic(self) -> dict:
return self.__app_info_dic
@property
def game_dir_data(self) -> list:
return self.__game_dir_data
참 쉽죠? 사용할 때는 module.py 안에 있는 SteamPath 객체만 생성해서 그곳에 있는 프로퍼티 값(멤버 변수 값) 만 읽어서 사용하시면 됩니다.
원리 설명
이제 위 코드가 어떻게 스팀 게임들의 경로를 얻어내는지 알아봅시다. [출처]
내용이 상당히 깁니다. 궁금하신 분들만 읽어보시면 될 거 같습니다.
1. 우선 레지스트리 값을 읽어서 스팀 설치 경로를 찾는다
2. 스팀 설치 경로 근처에서 acf, libraryfolders.vdf, app manifest 파일 등을 읽는다.
스팀 게임들의 경로를 얻는 과정은 글로 작성하면 위와 같은 단순한 과정으로 진행됩니다.
스팀의 설치 경로는 윈도우를 기준으로 특정 레지스트리 위치에 저장되어 있고,
스팀에서 설치한 게임들의 경로는 acf, vdf 확장자를 가진 파일에 저장되게 됩니다.
레지스트리를 읽는건 파이썬의 winreg 라이브러리를 사용해서 읽으면 되고, acf / vdf 는 파이썬의 vdf 라이브러리와 외부 프로그램인 VDFParse 를 통해 읽으면 됩니다.
사실 acf나 vdf 라는 파일 확장자명 자체가 생소하실 탠데 이 파일이 어떤 파일인진 아래서 설명을 드리도록 하겠습니다.
32-bit: HKEY_LOCAL_MACHINE\SOFTWARE\Valve\Steam 에서 InstallPath값
64-bit: HKEY_LOCAL_MACHINE\SOFTWARE\Wow6432Node\Valve\Steam 에서 InstallPath값
일단 스팀 경로를 찾기 위해 레지스트리 값을 가져와야 합니다.
컴퓨터가 32비트 인지 64비트 인지에 따라 스팀의 경로가 다른 레지스트리 위치에 저장되어 있습니다.
파이썬을 활용해 컴퓨터 비트를 인식하고 이에 맞게 저 위치에 레지스트리 값을 읽으면 스팀 설치 경로를 한번에 찾아낼 수 있습니다.
위 레지스트리에서 읽은 값이 C:\Program Files (x86)\Steam 라고 가정해보겠습니다.
스팀 설치 경로 안에 steamapps 안에 libraryfolders.vdf 라는 파일이 있을겁니다.
경로로는 C:\Program Files (x86)\Steam\steamapps\libraryfolders.vdf 가 되겠네요.
"libraryfolders"
{
"0"
{
"path" "C:\\Program Files (x86)\\Steam"
"label" ""
"contentid" "3015527341336720557"
"totalsize" "0"
"update_clean_bytes_tally" "993544830"
"time_last_update_corruption" "1702723713"
"apps"
{
"appid" "4082472798"
"appid" "3620388834"
"appid" "364221086"
"appid" "5464658003"
"appid" "552892342"
"appid" "15345519558"
}
}
"1"
{
"path" "D:\\SteamLibrary"
"label" ""
"contentid" "7828486535712423775"
"totalsize" "500105736192"
"update_clean_bytes_tally" "77635373162"
"time_last_update_corruption" "0"
"apps"
{
"appid" "65248549223"
"appid" "62061995322"
}
}
...
이 파일은 텍스트 파일로 열어보면 각 드라이브의 어떤 경로에 스팀 라이브러리가 설치되어 있는지, 그리고 그 라이브러리 안에 어떤 프로그램이 설치되어 있는지 appid 로 표시되어 있습니다. (위 텍스트에서 appid 에 내가 설치한 스팀 게임들의 app id 가 적혀 있음)
앱이란 무엇입니까?스팀 문서
애플리케이션(또는 앱)은 Steam에서 제품을 나타내는 주요 표현입니다. 앱에는 일반적으로 자체 스토어 페이지와 커뮤니티 허브가 있으며 고객 라이브러리에 표시됩니다.
각 앱은 앱 ID 라는 고유 ID로 표시됩니다. 일반적으로 단일 제품은 여러 애플리케이션에 걸쳐 있지 않습니다.
출처 : https://steamdb.info/apps/
* 여기서 appid 는 대충 스팀에서 게임마다 부여해둔 고유 번호라고 이해하시면 됩니다.
즉 libraryfolders.vdf 파일을 읽어내면 스팀 라이브러리 폴더가 각각 어디인지, 그 라이브러리 폴더 안에 게임이 몇개나 설치되어 있는지, 그 게임의 이름이나 용량, 실행 파일 위치는 모르지만 그래도 appid는 뭔지 알 수 있다는 겁니다.
하지만 vdf 파일 구조를 보시면 아시다 싶이 우리가 자주 사용하는 json 과 닮아 있으나 콜론(:) 으로 key / value 를 구분하는 json 과 다르게 vdf는 탭이나 공백으로 데이터를 구분하고 있음을 알 수 있습니다.
참고로 vdf이외에 acf 파일도 있는데 이것도 vdf와는 구조가 동일했습니다. 둘의 차이가 정확히 뭔진 모르겠지만 vdf 파싱 라이브러리로 acf 도 똑같이 파싱됐습니다.
https://developer.valvesoftware.com/wiki/KeyValues
https://github.com/node-steam/vdf
대충 찾아보니 vdf는 스팀에서 사용하는 Valve's KeyValue format 라는 것인거 같은데.. 어쨌던 이런 파일이 있다고만 알아 둡시다.
https://github.com/leovp/steamfiles
파이썬을 기준으로는 vdf 라이브러리와 steamfiles 라이브러리를 통해 vdf/acf 파일을 파싱할 수 있습니다.
그러나 steamfiles는 업데이트가 너무 오래전에 끊겨서 pip 으로 제대로 설치도 안되고 설치를 했음에도 일부 vdf를 제대로 파싱해내지 못했습니다.
그러므로 일단 일반 파싱 용도로는 그냥 vdf 라이브러리를 쓰기로 했습니다.
이 다음으로 appinfo.vdf 와 appmanifest.acf 파일에 대해서도 알아보겠습니다.
"path" "C:\\Program Files (x86)\\Steam"
"apps"
{
"2000" "4082472798"
"4000" "3620388834"
"6000" "364221086"
}
아까도 봤듯이 libraryfolders.vdf 파일 안에는 각 스팀 라이브러리 폴더의 경로와 그 안에 설치된 게임들의 appid 가 적혀 있습니다. 만약에 프로그램이 3개 스팀을 통해 설치되어 있고 그것의 고유 번호가 각각 2000, 4000, 6000 이라면 위와 같이 됩니다.
그리고 저 위 path 쪽으로 가서 steamapps 폴더로 진입해보면 (C:\Program Files (x86)\Steam\steamapps 로 진입)
appmanifest_2000.acf, appmanifest_4000.acf, appmanifest_6000.acf 처럼 각각 그 appid 에 맞게 appmanifest 라는 acf 파일이 존재합니다.
"AppState"
{
"appid" "4000"
"universe" "1"
"LauncherPath" "C:\\Program Files (x86)\\Steam\\steam.exe"
"name" "Garry's Mod"
"StateFlags" "4"
"installdir" "GarrysMod"
"LastUpdated" "1705689908"
"SizeOnDisk" "4082472798"
...
appmanifest_4000.acf 파일을 예로 들어보자면 위와 같이 게임이 어떤 경위로 설치되어 있는지 대충 데이터를 얻어낼 수 있습니다. 역시 vdf 파일과 형태가 동일합니다.
여기서 모든 정보를 얻어내면 좋겠지만 아쉽게도 게임의 실행 경로는 아직 얻어낼 수 없었습니다.
스팀 설치 게임들의 심층적인 정보는 결론적으로 appinfo.vdf 라는 파일에서 얻어 낼 수 있었습니다.
appinfo.vdf 는 C:\Program Files (x86)\Steam\appcache\appinfo.vdf 위치에 존재합니다.
(DV c F3c 뉪Cg?
킄Im?큱?땬5쳛?1#u??볛??T죧퉱 appinfo appid public_only # Ii줱 FC??5혜OL/?펲잊飴M?G1퓼?v#QF鉗뫷??appinfo appid common name Steam Client type Config associations gameid extended beta_name_1 Steam Beta Update beta_contentlist_1 steamexe publicbeta beta_name_2 Steam Deck Stable beta_contentlist_2 steamexe steamdeck_stable beta_name_3 Steam Deck Beta beta_contentlist_3 steamexe steamdeck_publicbeta convar_allowguestpasses convar_bclientrecommendationsenabled convar_bsteam3limiteduserenable convar_serverbrowserpingsurveysubmitpct convar_voice_quality convar_library_sharing_account_max convar_@bstorev6nav convar_@bnewgamehelpsiteenabled convar_nclientbackgroundautoupdatetimespreadminutes h convar_nclientbackgroundautoupdatetargethour convar_nclientbackgroundautoupdatelessrecentlyplayedthresholdhours ? convar_nphonenotnowdays convar_bphonebannerenabled convar_benabletradeinvitebarinlaunchers convar_@fmindataratetoattempttwoconnectionsmbps 1.5 convar_@ccsclientmaxnumsocketsperhost convar_depotdeltapatches convar_@nclientservicemethodfordownloadlistpercent d convar_bdepotdeltapatchuseapi convar_unshaderhitcachegeneration convar_bshaderdepotnative convar_@bshaderclientgetbucketmanifestusewebapi convar_nclouduploadminintervalsec convar_@depotbuildernumhttpsocketstomds 0 convar_@depotbuildermaxparalleluploadchunks ` convar_asyncfileiomaxpendingwin32 convar_cappupdateworkingsetmb convar_library_asset_cache_version convar_@busewebmicrotxnpathinsteamclient convar_nclientbackgroundautoupdaterecentlyplayedthresholdhours H convar_@ncsjob404responselimit d convar_pwidverificationthreshold r?convar_depotdownloadprogresstimeout ? convar_@bscreenshotslegacycloudupload convar_@nscreenshotsavemaxwidth ' convar_@ncontentupdateautoverifyenable convar_@ncontentupdateautoverifycleanbytesthresholdmb convar_@ncloudclientusegetappfilechangelist convar_@nclientcloudmaxmbparalleluploads convar_ugc_query_default_cache_time , convar_ugc_query_max_get_details ? convar_asyncfileioalternatereadwrite convar_ncloudsyncintervalsec convar_@nclientdownloadenablehttp2relbranch convar_@nclientdownloadenablehttp2platformwindows convar_@nclientenablehttp2platformwindows convar_@nclientenablehttp2platformlinux convar_benablemhrcasyncfilereadlog convar_@brefreshdownloadsourcesfrequently convar_@watchdogthreadpercentreport
newsurl https://store.steampowered.com/news/ state eStateAvailable storefrontcdnurl https://steamstore-a.akamaihd.net storefronturl https://store.steampowered.com validoslist windows,macos alienwareoemid 8,9,10,11,15 manage_steamguard_useweb steamvrautoinstall traderegions rcis countries am az by ge kg kz md tj tm uz ua ru csa countries bz cr sv gt hn ni pa bs ec co ve pe cl uy py ar bo gy sr br mx try countries tr sea countries id my ph sg th vn p?V cyn countries cn J9V inr countries in J9V asia countries hk J9Vtw J9V zar countries za 꿁V mide countries sa 꿁Vae 꿁V steaminstaller dota2setup.exe : steamvivesetup.exe 켤 steamvivesetupbeta.exe 켤 steamwindowsmrinstaller.exe 250820,719950 windowsmraltspaceinstaller.exe 250820,719950,407060 steamvrtakehometest.exe 250820,820350,633750,887260,546560 steamvrsetup.exe 켤 steamsetupdpvr.exe 250820,762200 convar_@bcommunitymarketplacevisible convar_@benablesubscribedfilecache convar_rtime32earliestsubscribedfiletodownload 뻒튡 config steam_china_only steam_china_enable_duration_control client_cell_list 1 loc_name us_chicago 2 loc_name us_newyork 4 loc_name uk_london 5 loc_name de_frankfurt 7 loc_name ru_moscow 8 loc_name kr_seoul 9 loc_name tw_taiwan 10 loc_name us_sanjose 11 loc_name us_phoenix 12 loc_name us_miami 14 loc_name fr_paris 15 loc_name nl_amsterdam 16 loc_name ro_bucharest 20 loc_name ca_toronto 22 loc_name nz_auckland 25 loc_name br_sao_paulo 26 loc_name za_johannesburg 29 loc_name iceland 30 loc_name israel 31 loc_name us_seattle 32 loc_name jp_tokyo 33 loc_name cn_hongkong 34 loc_name th_bangkok 35 loc_name singapore 36 loc_name in_mumbai 37 loc_name it_rome 38 loc_name pl_warsaw 39 loc_name ru_yekaterinburg 40 loc_name es_madrid 41 loc_name dk_copenhagen 42 loc_name cz_prague 43 loc_name gr_athens 44 loc_name in_jakarta 45 loc_name ph_manila 46 loc_name cn_beijing steamchina 47 loc_name cn_shanghai steamchina 48 loc_name cn_chengdu steamchina 49 loc_name us_denver 50 loc_name us_atlanta 51 loc_name au_brisbane 52 loc_name au_sydney 53 loc_name au_melbourne 54 loc_name au_adelaide 55 loc_name au_perth 62 loc_name ru_novosibirsk 63 loc_name us_dc 64 loc_name us_la 65 loc_name us_dallas 66 loc_name se_stockholm 67 loc_name no_oslo 68 loc_name fi_helsinki 69 loc_name ie_dublin 70 loc_name cambodia 71 loc_name vietnam 72 loc_name my_kualalumpur 73 loc_name ua_kiev 74 loc_name us_sandiego 75 loc_name us_sacramento 76 loc_name us_minneapolis 77 loc_name us_stlouis 78 loc_name us_houston 79 loc_name us_detroit 80 loc_name us_pittsburgh 81 loc_name ca_montreal 82 loc_name us_boston 83 loc_name us_philadelphia 84 loc_name us_charlotte 85 loc_name uk_manchester 86 loc_name belgium 87 loc_name de_dusseldorf 88 loc_name switzerland 89 loc_name de_hamburg 90 loc_name de_berlin 91 loc_name de_munich 92 loc_name at_vienna 93 loc_name hu_budapest 94 loc_name ca_vancouver 95 loc_name us_columbus 96 loc_name fr_marseille 97 loc_name za_capetown 115 loc_name mx_mexicocity 116 loc_name ar_buenosaires 117 loc_name cl_santiago 118 loc_name pe_lima 119 loc_name co_bogota 124 loc_name tr_istanbul 125 loc_name eg_cairo 126 loc_name sa_riyadh 127 loc_name ua_dubai 128 loc_name pk_karachi 129 loc_name luxembourg 130 loc_name ng_lagos 131 loc_name ke_nairobi 132 loc_name ma_rabat 133 loc_name ca_edmonton 134 loc_name ca_calgary 135 loc_name ca_winnipeg 136 loc_name ca_ottawa 139 loc_name panama 140 loc_name puerto_rico 141 loc_name in_chennai 142 loc_name in_delhi 143 loc_name in_bangalore 144 loc_name in_hyderabad 145 loc_name in_kolkata 146 loc_name us_honolulu 147 loc_name us_anchorage 148 loc_name cn_guangzhou steamchina 149 loc_name ru_stpetersburg 150 loc_name ru_rostov 151 loc_name ru_kazan 152 loc_name ru_vladivostok 153 loc_name br_recife 154 loc_name br_brasilia 155 loc_name br_rio 156 loc_name br_portoalegre 157 loc_name ru_irkutsk 158 loc_name kazakhstan 159 loc_name cn_wuhan steamchina 160 loc_name cn_xian steamchina 161 loc_name mongolia 162 loc_name venezuela 163 loc_name ecuador 164 loc_name bolivia 165 loc_name belarus 166 loc_name cn_harbin steamchina 167 loc_name cn_kunming steamchina 168 loc_name cn_qingdao steamchina 169 loc_name cn_urumqi steamchina 170 loc_name cn_zhengzhou steamchina 171 loc_name cn_changsha steamchina 172 loc_name caucasus 173 loc_name central_asia 174 loc_name pacific_islands 175 loc_name se_malmo 176 loc_name se_goteborg 177 loc_name jp_sapporo 178 loc_name jp_sendai 179 loc_name jp_nagoya 180 loc_name jp_osaka 181 loc_name jp_fukuoka 182 loc_name kr_busan 183 loc_name pl_katowice 184 loc_name it_milan 185 loc_name portugal 186 loc_name es_barcelona 187 loc_name es_valencia 188 loc_name es_malaga 189 loc_name tr_ankara 190 loc_name tr_izmir 191 loc_name ua_odessa 192 loc_name ua_lviv 193 loc_name ua_kharkiv 194 loc_name bulgaria 195 loc_name croatia 196 loc_name lithuania 197 loc_name cn_suzhou steamchina 198 loc_name cn_hangzhou steamchina 199 loc_name cn_ningbo steamchina 200 loc_name cn_nanjing steamchina 201 loc_name cn_shenzhen steamchina 202 loc_name cn_dongguan steamchina 203 loc_name cn_tianjin steamchina 204 loc_name cn_chongqing steamchina 205 loc_name cn_shenyang steamchina 206 loc_name cn_dalian steamchina 207 loc_name us_austin 208 loc_name ph_cebu 209 loc_name ph_davao 210 loc_name ph_baguio 211 loc_name cn_nanning steamchina 212 loc_name canary_islands 213 loc_name maritius 214 loc_name reunion 215 loc_name kuwait 216 loc_name ua_sevastopol depots branches steam_china_only sc_schinese 餓끺릎?? ufs quota 嗽maxnumfiles
? F3c 땅曝g껰か?묵S汐?'?W9??a禍忠A?푩AB appinfo appid common name winui2 type Config gameid
? ?xe e뗝춻?,쾉鋼^ㄺ퍀`W?H_이Qㅐ깭킓??h[?appinfo appid
common clienticon f16b5951eed02e3c3516389031374e95268d9ce7 clienttga 44df3dcc3e8bbdc66a819c94e7bff95a3bf39e08 icon 6b0312cda02f5f777efa2f3318c307ff9acafbb5 logo af890f848dd606ac2fd4415de3c3f5e7a66fcb9f logo_small dc97d7c8ae3a417cbb09fed1dcfb3204b7a2766b metacritic_url pc/halflifecounterstrike name Counter-Strike oslist windows,macos,linux linuxclienticon fc45c2591d95f081ff1268ad856297430fbcf686 clienticns ef2d37a6d159f600fc5e4f80e1e2637403833856 type game content_descriptors 0 1 has_adult_content has_adult_content_violence steam_deck_compatibility category test_timestamp 춂tested_build_id 읝R tests 0 display token #SteamDeckVerified_TestResult_DefaultControllerConfigNotFullyFunctional 1 display token #SteamDeckVerified_TestResult_TextInputDoesNotAutomaticallyInvokesKeyboard 2 display token #SteamDeckVerified_TestResult_NativeResolutionNotSupported 3 display token #SteamDeckVerified_TestResult_InterfaceTextIsLegible 4 display token #SteamDeckVerified_TestResult_DefaultConfigurationIsPerformant configuration supported_input gamepad requires_manual_keyboard_invoke requires_non_controller_launcher_nav primary_player_is_controller_slot_0 non_deck_display_glyphs small_text requires_internet_for_setup requires_internet_for_singleplayer recommended_runtime proton-stable requires_h264 gamescope_frame_limiter_not_supported metacritic_name Counter-Strike small_capsule english capsule_231x87.jpg header_image english header.jpg library_assets library_capsule en library_hero en library_logo en logo_position pinned_position BottomLeft width_pct 43.23995127892814 height_pct 35.416666666666686 store_asset_mtime 2킱c associations 0 type developer name Valve 1 type publisher name Valve primary_genre genres 0 category category_1 category_8 category_36 category_37 category_49 category_45 category_46 supported_languages english supported true full_audio true french supported true full_audio true german supported true full_audio true italian supported true full_audio true spanish supported true full_audio true schinese supported true full_audio true tchinese supported true full_audio true koreana supported true full_audio true steam_release_date ?9metacritic_score X metacritic_fullurl https://www.metacritic.com/game/pc/counter-strike?ftag=MCD-06-10aaa1f community_hub_visible gameid
store_tags 0 1 2
이 파일을 메모장이나 Notepad++ 같은 텍스트 에디터로 열어보면 아까와 다르게 텍스트들이 마구 깨지는 현상을 발견할 수 있었습니다. 처음에 저도 매우 당황했는데요. 이는 해당 vdf 파일이 바이너리 파일(이진 파일) 이기 때문입니다.
텍스트로 읽어내면 안되고 이진법으로 읽어내야 합니다. 여기서부터 매우 골치가 아파졌는데요 ㅎ;
사용하던 파이썬의 vdf 라이브러리로는 텍스트 형태의 vdf는 잘 파싱해도 바이너리 형태의 vdf는 잘 파싱하지 못했기 때문입니다.
https://github.com/SteamDatabase/SteamAppInfo
appinfo.vdf
uint32 - MAGIC: 28 44 56 07
uint32 - UNIVERSE: 1
---- repeated app sections ----
uint32 - AppID
uint32 - size // until end of binary_vdf
uint32 - infoState // mostly 2, sometimes 1 (may indicate prerelease or no info)
uint32 - lastUpdated
uint64 - picsToken
20bytes - SHA1 // of text appinfo vdf, as seen in CMsgClientPICSProductInfoResponse.AppInfo.sha
uint32 - changeNumber
20bytes - SHA1 // of binary_vdf, added in December 2022
variable - binary_vdf
---- end of section ---------
uint32 - EOF: 0
위 깃허브 링크에서 확인해본 결과 appinfo 파일은 다음과 같은 바이너리 구조를 가지고 있었습니다.
처음 MAGIC 이라고 하는 일종의 구분자로 16진수 28 44 56 07로 시작하며 (컴퓨터 CPU는 리틀 엔디안이라고 해서 실제로 우리가 사용하는 표기법대로 읽으면 07 56 44 28 이 됩니다)
4바이트 크기로 1이 작성되어 있습니다.
그 아래부터는 app의 각종 정보가 반복되며 나타나다가 끝을 나타내는 End of File 부분은 4바이트로 0이 적혀 있습니다.
위와 같은 사실은 이진 파일을 볼 수 있는 바이너리 편집기인 hxd 로 더 잘 확인할 수 있습니다.
hxd로 vdf 파일을 열어보면 위와 같이 16진수 28 44 56 07로 시작하며 UNIVERSE 값인 1이 기록되어 있음을 확인할 수 있습니다. 사실 UNIVERSE가 뭘 의미하는건진 잘 모르겠습니다.
스팀 벨브 공식 홈페이지에 자세한 정보가 있는 거 같은데 그렇게 까지 찾아보고 싶진 않더라구요.
어쨌던 파이썬에서 vdf 바이너리 파일을 파싱해야 하는데 라이브러리도 마땅찮은게 없어서 그냥 외부 프로그램을 사용하기로 했습니다. 처음에 파이썬을 활용해서 바이너리 구조를 파싱해보려 했는데 ChatGPT의 도움이 있었음에도 실패했어요 퓨ㅠ
https://github.com/Grub4K/VDFparse
github에 어떤 분이 이미 C#으로 만들어 놨더라구요. 이걸 쓰면 바이너리던 텍스트던 쉽게 vdf 파싱이 됩니다. 해당 프로그램을 갖다 쓰기로 했습니다. 멀티 프로세스로 코딩하는건 부하가 커서 그다지 선호하지 않으나, 그래도 파싱을 파이썬으로 하는것보단 성능이 더 괜찮은 C# 프로그램에 의존하는게 좋을 거 같아서 선택했습니다.
어쨌던 위의 VDFParse를 통해 appinfo.vdf 파일을 읽어내면
"config": {
"installdir": "DJMAX RESPECT V",
"launch": {
"0": {
"executable": "DJMAX RESPECT V.exe",
"type": "default",
"config": {
"oslist": "windows",
"osarch": "64"
}
}
},
"steamcontrollertemplateindex": 4,
"steamdecktouchscreen": 5,
"steamconfigurator3rdpartynative": 3
"executable": "DJMAX RESPECT V.exe"
이런식으로 설치 파일의 경로를 얻어낼 수 있습니다. 해당 프로그램이 윈도우 뿐만이 아니라 리눅스 , 맥에서도 동작한다면 리눅스 / 맥의 실행 파일 위치까지 전부 기록되어 있습니다. 이외에도 엄청나게 많은 정보가 appinfo.vdf에 들어가 있습니다.
결론적으로 다양한 vdf 파일을 읽어내서 경로로 최종 정제한게 오늘 제공한 코드입니다. github에 올릴까 싶었는데 귀찮아서 아직 안올렸어요. 가져오는 시간은 측정해봤는데 제 컴퓨터 기준으로 0.06 초 정도 걸리더군요. 당연하지만 디스크 다 돌면서 게임 경로 인식하는것보다 비교도 안될 정도로 빠릅니다.
결론
- vdf랑 acf라는 특이한 포맷 파일에 대해 알아보았다. 유익한 시간이였음
- 파이썬으로 바이너리 읽는건 한 번 연구를 해봐야 될 거 같습니다.
- 스팀은 경로 관리를 레지스트리에 다 쳐박아 두고 할 줄 알았는데 acf/vdf 를 이용한 상당한 복잡한 구조로 관리를 하고 있었습니다.
'프로그래밍 > Python' 카테고리의 다른 글
[Python] 마우스 & 키보드 못쓰게 잠구기 (0) | 2024.07.25 |
---|---|
[Python] 파이썬으로 yt-dlp 호출해서 사용하기 (EMBEDDING YT-DLP) (0) | 2023.12.25 |
[Python] m4a 음원 파일에 앨범 아트 추가하기 (0) | 2023.12.12 |
[Python] 동영상 파일의 책갈피(Chapter, 챕터) 데이터 읽어오기 - 자막 싱크 조절 (0) | 2023.11.11 |
[Python] 현재 모니터 주사율 가져오기 & 변경 - Windows API 활용 (0) | 2023.06.24 |