EXQT EXQT
cover

BepInEx + HarmonyX로 Unity 게임 모드 만들기

2025-09-21

게임을 하다보면 “이런 기능이 있었으면 좋겠다” 싶은 순간이 있죠.

최근 Rift of the NecroDancer를 플레이하면서 타격음이 늦게 들리는 문제를 겪었습니다. 이 게임은 사운드 엔진으로 FMOD를 사용하는데, FMOD는 ASIO 드라이버를 지원합니다.

ASIO 드라이버를 사용하면 사운드 지연을 크게 줄일 수 있지만, 아쉽게도 개발사에서 ASIO 옵션을 제공하지 않더라고요. 그래서 직접 ASIO를 활성화하는 모드를 만들어 보았습니다.

BepInEx + HarmonyX

BepInEx는 Unity 게임을 위한 모드 프레임워크입니다. Unity로 만든 게임에 원하는 기능을 쉽게 추가할 수 있어서 모딩 커뮤니티에서 널리 사용되고 있죠.

HarmonyX는 BepInEx에서 사용하는 .NET용 코드 패치 라이브러리입니다. 게임이 실행 중일 때 특정 메서드나 변수를 가로채서 우리가 원하는 대로 동작하도록 수정할 수 있습니다.

준비물

  1. C# 코드 빌드를 위해 .NET SDK
  2. IDE (Visual Studio / Rider / VS Code 등)
  3. DotPeek / ILSpy 등 어셈블리 뷰어

BepInEx 설치

  1. BepInEx 릴리즈 페이지에서 플랫폼에 맞는 버전을 다운로드합니다
  2. 압축을 풀고 게임 실행 파일(.exe)이 있는 폴더에 모든 파일을 복사합니다
  3. BepInEx/config/BepInEx.cfg 파일을 열어서 Logging.ConsoleEnabled = true로 설정합니다 (디버깅용 콘솔창 활성화)
  4. 게임을 한 번 실행시킵니다 (최초 실행 시 BepInEx가 필요한 폴더와 파일들을 자동으로 생성합니다)
  5. 게임을 종료한 후 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");
    }
}

빌드하고 테스트해보겠습니다:

  1. dotnet build 명령어로 프로젝트를 빌드합니다
  2. bin/Debug/net45/ 폴더에 생성된 .dll 파일을 BepInEx/plugins/ 폴더에 복사합니다
  3. 게임을 실행한 후 F5 키를 눌러보면 콘솔창에 “F5 Key Pressed”가 출력되는 것을 확인할 수 있습니다

트러블슈팅: 콘솔창은 떴는데 로그가 출력되지 않는다면 BepInEx/config/BepInEx.cfg에서 HideManagerGameObjecttrue로 설정해보세요. 게임의 특정 씬에서 모든 GameObject가 파괴되면서 플러그인도 같이 파괴될 수 있습니다.

코드 분석 및 패치

HarmonyX의 진짜 힘은 게임 코드의 특정 메서드나 프로퍼티를 가로채서 우리가 원하는 대로 수정할 수 있다는 점입니다.

타겟 메서드 찾기

  1. DotPeek으로 게임 폴더의 *_Data/Managed/Assembly-CSharp.dll 파일을 엽니다
  2. 수정하고 싶은 기능을 찾아서 namespace, class, method 이름을 확인합니다
  3. 해당 정보를 바탕으로 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 패치를 통해 게임이 해당 값을 사용하도록 강제합니다.