본문으로 바로가기

파일의 IT 블로그

  1. Home
  2. 프로그래밍/Python
  3. [Python] 스팀 게임 설치 경로 알아내기 - 스팀 VDF / ACF 파일 구조 분석

[Python] 스팀 게임 설치 경로 알아내기 - 스팀 VDF / ACF 파일 구조 분석

· 댓글개 · KRFile

 

프로그래밍을 하면서 스팀에서 설치된 게임의 경로를 알아내야 할 상황이 생겼습니다. 예를 들어 위쳐3 가 스팀으로 설치됐다면 위쳐3의 설치 경로를 인식해야 하는 상황입니다.

 

스팀에 설치된 게임의 경로를 인식하는 프로그래밍 적인 방법엔 아래와 같은 방법이 있습니다.

 

1. 프로그램 사용자에게 직접 게임의 경로 입력 받기

2. 스팀에 저장된 파일을 이용해서 자동으로 인식하기

(스팀에는 *.acf, *.vdf 와 같은 형태의 파일을 이용해 게임의 설치 경로를 저장하고 있습니다. 이 파일을 읽어내면 경로를 인식할 수 있습니다.)

3. 디스크 전체를 탐색해서 특정 프로그램을 찾기

 

3번은 아무래도 디스크 전체를 탐색해야 하기 때문에 너무 느릴것이기 때문에 배제하고

여기서 고려해볼만한 방법은 1번과 2번입니다.

 

1번의 경우에는 유저에게 경로를 직접 찾으라고 책임을 전가하기 때문에, 사용자 입장에선 매우 귀찮아지지만 폴더 선택창만 띄우면 되기 때문에 코드가 매우 간단해지고 가장 확실한 방법이라고 볼 수 있습니다.

 

2번의 경우에는 스팀이 설치 경로를 acf, vdf 확장자의 파일로 저장해두는데 이 파일을 파싱(분석)해서 바로 찾아내는 방법입니다. 사용자가 귀찮은 짓거리를 할필요도 없이 그냥 자동으로 경로를 인식해낼 수 있는 가장 이상적인 방법입니다.

하지만 acf와 vdf는 스팀에서 다루는 조금 특수한 파일이기 때문에 그냥 파싱하기엔 까다롭기에 코드가 복잡해진다는 단점이 있습니다.

 

저는 2번의 방법을 사용해 스팀 경로를 자동 인식하는 코드를 작성했습니다. 코드만 필요하신 분들은 바로 아래서 코드만 가져가시고 작동 원리가 궁금하신 분들은 아래 부분에 설명을 달아놓을 태니 스크롤을 내려서 확인해보세요.

 

코드

시작전에 폴더 구조는 위와 같습니다. 파일이 여러가지가 있는데 빨간색 네모로 쳐진 4가지 파일만 필요합니다. 나머지 파일은 제가 디버깅 하면서 생성한거라 생성 하실 필요 없습니다.

 

VDFparse.exe
4.84MB

 

우선 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

 

KeyValues - Valve Developer Community

The KeyValues format is used in the Source engine to store meta data for resources, scripts, materials, VGUI elements, and more...[Clarify] File Format The KeyValues.h header in the Source SDK code defines the file format in the following manner About KeyV

developer.valvesoftware.com

https://github.com/node-steam/vdf

 

GitHub - node-steam/vdf: Module to convert Valve's KeyValue format to JSON and back

Module to convert Valve's KeyValue format to JSON and back - GitHub - node-steam/vdf: Module to convert Valve's KeyValue format to JSON and back

github.com

대충 찾아보니 vdf는 스팀에서 사용하는 Valve's KeyValue format 라는 것인거 같은데.. 어쨌던 이런 파일이 있다고만 알아 둡시다. 

 

https://pypi.org/project/vdf/

 

vdf

Library for working with Valve's VDF text format

pypi.org

https://github.com/leovp/steamfiles

 

GitHub - leovp/steamfiles: Python library for parsing the most common Steam file formats.

Python library for parsing the most common Steam file formats. - GitHub - leovp/steamfiles: Python library for parsing the most common Steam file formats.

github.com

 

파이썬을 기준으로는 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

 

GitHub - SteamDatabase/SteamAppInfo: Parser for appinfo.vdf and packageinfo.vdf files used by the Steam client

Parser for appinfo.vdf and packageinfo.vdf files used by the Steam client - GitHub - SteamDatabase/SteamAppInfo: Parser for appinfo.vdf and packageinfo.vdf files used by the Steam client

github.com

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 - Grub4K/VDFparse: Converts binary valve data files into json

Converts binary valve data files into json. Contribute to Grub4K/VDFparse development by creating an account on GitHub.

github.com

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 를 이용한 상당한 복잡한 구조로 관리를 하고 있었습니다.

SNS 공유하기
💬 댓글 개
이모티콘창 닫기
울음
안녕
감사해요
당황
피폐

이모티콘을 클릭하면 댓글창에 입력됩니다.