BepInEx + HarmonyX로 Unity 게임 모드 만들기
게임을 하다보면 “이런 기능이 있었으면 좋겠다” 싶은 순간이 있죠.
최근 Rift of the NecroDancer를 플레이하면서 타격음이 늦게 들리는 문제를 겪었습니다. 이 게임은 사운드 엔진으로 FMOD를 사용하는데, FMOD는 ASIO 드라이버를 지원합니다.
ASIO 드라이버를 사용하면 사운드 지연을 크게 줄일 수 있지만, 아쉽게도 개발사에서 ASIO 옵션을 제공하지 않더라고요. 그래서 직접 ASIO를 활성화하는 모드를 만들어 보았습니다.
BepInEx + HarmonyX
BepInEx는 Unity 게임을 위한 모드 프레임워크입니다. Unity로 만든 게임에 원하는 기능을 쉽게 추가할 수 있어서 모딩 커뮤니티에서 널리 사용되고 있죠.
HarmonyX는 BepInEx에서 사용하는 .NET용 코드 패치 라이브러리입니다. 게임이 실행 중일 때 특정 메서드나 변수를 가로채서 우리가 원하는 대로 동작하도록 수정할 수 있습니다.
준비물
BepInEx 설치
- BepInEx 릴리즈 페이지에서 플랫폼에 맞는 버전을 다운로드합니다
- 압축을 풀고 게임 실행 파일(.exe)이 있는 폴더에 모든 파일을 복사합니다
BepInEx/config/BepInEx.cfg파일을 열어서Logging.ConsoleEnabled = true로 설정합니다 (디버깅용 콘솔창 활성화)- 게임을 한 번 실행시킵니다 (최초 실행 시 BepInEx가 필요한 폴더와 파일들을 자동으로 생성합니다)
- 게임을 종료한 후
BepInEx/LogOutput.log파일을 열어서 설치가 제대로 되었는지 확인합니다
프로젝트 설정
먼저 BepInEx 템플릿을 설치합니다:
dotnet new install BepInEx.Templates::2.0.0-be.4 --nuget-source https://nuget.bepinex.dev/v3/index.json 다음으로 게임의 Target Framework와 Unity 버전을 확인해야 합니다:
- Target Framework: 기본적으로
net45이고,netstandard.dll이 있으면netstandard2.0입니다 - Unity 버전:
*_Data/Managed/폴더에 있는UnityEngine.dll을 우클릭 → 속성 → 세부 정보 탭 → 제품 버전에서 확인할 수 있습니다
확인한 정보로 프로젝트를 생성합니다:
dotnet new bepincore -n <프로젝트명> -f <Target Framework> --unity-version <Unity 버전> 제 경우엔 다음과 같이 입력했습니다:
dotnet new bepincore -n RiftASIO -f net45 --unity-version 2021.3 FMOD 라이브러리를 사용해야 했기 때문에 .csproj 파일에 필요한 라이브러리들을 참조로 추가했습니다:
<ItemGroup>
...
<Reference Include="FMODUnity">
<HintPath>../RiftOfTheNecroDancer_Data/Managed/FMODUnity.dll</HintPath>
</Reference>
<Reference Include="netstandard">
<HintPath>../RiftOfTheNecroDancer_Data/Managed/netstandard.dll</HintPath>
<Private>false</Private>
</Reference>
</ItemGroup> 참고: 경로는 프로젝트 위치에 따라 다르니 본인 환경에 맞게 수정해 주세요.
BepInEx 플러그인 구현
Plugin.cs 파일을 열어보면 BaseUnityPlugin을 상속받는 클래스가 있습니다. 이 클래스는 Unity의 MonoBehaviour와 비슷한 역할을 해서, 게임이 시작될 때 인스턴스가 생성되고 Awake(), Start(), Update() 등의 메서드가 호출됩니다.
간단한 테스트로 Update 메서드에 키 입력을 감지하는 코드를 추가해봅시다:
private void Update()
{
if (Input.GetKeyDown(KeyCode.F5))
{
Logger.LogInfo("F5 Key Pressed");
}
} 빌드하고 테스트해보겠습니다:
dotnet build명령어로 프로젝트를 빌드합니다bin/Debug/net45/폴더에 생성된.dll파일을BepInEx/plugins/폴더에 복사합니다- 게임을 실행한 후 F5 키를 눌러보면 콘솔창에 “F5 Key Pressed”가 출력되는 것을 확인할 수 있습니다
트러블슈팅: 콘솔창은 떴는데 로그가 출력되지 않는다면
BepInEx/config/BepInEx.cfg에서HideManagerGameObject를true로 설정해보세요. 게임의 특정 씬에서 모든 GameObject가 파괴되면서 플러그인도 같이 파괴될 수 있습니다.
코드 분석 및 패치
HarmonyX의 진짜 힘은 게임 코드의 특정 메서드나 프로퍼티를 가로채서 우리가 원하는 대로 수정할 수 있다는 점입니다.
타겟 메서드 찾기
- DotPeek으로 게임 폴더의
*_Data/Managed/Assembly-CSharp.dll파일을 엽니다 - 수정하고 싶은 기능을 찾아서 namespace, class, method 이름을 확인합니다
- 해당 정보를 바탕으로 HarmonyX 패치를 작성합니다
패치 클래스 작성
아래 예시처럼 패치할 메서드의 정보를 HarmonyPatch 어트리뷰트로 지정합니다:
메서드 타입에는 다음과 같은 옵션들이 있습니다:
Normal, Getter, Setter, Constructor, StaticConstructor, Enumerator 제 경우엔 DSPBufferLength 프로퍼티의 값을 수정하고 싶었기 때문에 Getter를 사용했습니다.
Prefix vs Postfix
- Postfix: 원래 메서드가 실행된 후에 우리 코드가 실행됩니다. 메서드의 결과값을 수정할 때 주로 사용합니다
- Prefix: 원래 메서드가 실행되기 전에 우리 코드가 실행됩니다. 메서드 실행을 아예 건너뛰고 싶을 때 사용합니다
완성된 코드
지금까지 설명한 내용을 모두 적용한 전체 코드입니다. ASIO 드라이버 전환과 DSP 버퍼 크기 조정 기능이 포함되어 있습니다:
using BepInEx;
using BepInEx.Logging;
using UnityEngine;
using HarmonyLib;
namespace RiftASIO;
[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
public class Plugin : BaseUnityPlugin
{
internal static new ManualLogSource Logger;
public bool IsInitialized { get; private set; } = false;
internal static Plugin Instance;
public int bufferLength = 0;
private void Awake()
{
Instance = this;
Logger = base.Logger;
Logger.LogInfo($"Plugin {MyPluginInfo.PLUGIN_GUID} is loaded!");
bufferLength = Config.Bind("Audio", "DSPBufferLength", 0, "Override FMOD DSP buffer length. Set to 0 to use default.").Value;
Logger.LogInfo($"Configured DSPBufferLength: {bufferLength}");
var harmony = new Harmony(MyPluginInfo.PLUGIN_GUID);
harmony.PatchAll();
}
private void Update()
{
if (Input.GetKeyDown(KeyCode.F5))
{
Logger.LogInfo("Setting output to ASIO");
var coreSystem = FMODUnity.RuntimeManager.CoreSystem;
coreSystem.setOutput(FMOD.OUTPUTTYPE.ASIO);
}
else if (Input.GetKeyDown(KeyCode.F6))
{
Logger.LogInfo("Setting output to WASAPI");
var coreSystem = FMODUnity.RuntimeManager.CoreSystem;
coreSystem.setOutput(FMOD.OUTPUTTYPE.WASAPI);
}
else if (Input.GetKeyDown(KeyCode.F7))
{
var coreSystem = FMODUnity.RuntimeManager.CoreSystem;
coreSystem.getDSPBufferSize(out uint bufferLength, out int bufferCount);
Logger.LogInfo($"Initial DSP Buffer Size: Length={bufferLength}, Count={bufferCount}");
}
}
}
// HarmonyX 패치로 DSPBufferLength 프로퍼티 오버라이드
[HarmonyPatch]
public class DSPBufferLengthPatch
{
// Platform 클래스의 DSPBufferLength Getter 패치
[HarmonyPatch(typeof(FMODUnity.Platform), "DSPBufferLength", MethodType.Getter)]
[HarmonyPostfix]
static void OverrideDSPBufferLength(ref int __result)
{
int cfg = Plugin.Instance.bufferLength;
__result = cfg != 0 ? cfg : __result;
}
} 이 모드는 다음과 같이 동작합니다:
- F5: ASIO 드라이버로 전환
- F6: WASAPI 드라이버로 전환
- F7: 현재 DSP 버퍼 크기 정보 출력
DSP 버퍼 크기는 설정 파일에서 미리 지정할 수 있고, HarmonyX 패치를 통해 게임이 해당 값을 사용하도록 강제합니다.