후킹

후킹은 기존 함수에 맞춤 함수 본문을 첨부할 수 있게 해주는 SML의 C‍+‍+ 전용 기능입니다.

모든 C/C‍+‍+ 기능 후킹 관련 내용은 #include "Patching/NativeHookManager.h"에서 찾을 수 있습니다. 모든 블루프린트 함수 후킹 관련 내용은 #include "Patching/BlueprintHookManager.h"에서 찾을 수 있습니다.

배경 정보

SML이 사용하는 후킹 시스템은 단순히 C / C‍+‍+ 함수 후킹입니다. 언리얼 엔진은 C‍+‍+로 작성되었습니다. 이 시스템은 이론적으로 모든 실행 파일에서 사용할 수 있지만, 메모리에서 함수의 위치를 알아야 하므로 어렵습니다. 일반적인 C‍+‍+ 실행 파일과 대부분의 UE 게임에서는 .exe 파일만 제공되며, 이는 함수의 위치에 대한 정보를 제공하지 않습니다. 따라서 이를 알아내려면 매우 뛰어난 역공학 기술이 필요하며, 게임이 업데이트되면 모든 것이 망가질 수 있습니다.

새티스팩토리의 차이점은 PDB를 제공한다는 것입니다. PDB는 일반적으로 충돌이 발생했을 때 호출 스택을 결정하는 데 사용되지만, 이름을 기반으로 함수 주소를 찾는 데도 사용할 수 있습니다.

이름을 기반으로 함수 주소를 찾는 것은 SML v1이 후킹을 구현하는 데 사용한 기술입니다. 문제는 많은 함수가 제거되거나 인라인화될 수 있다는 점입니다. 이는 C‍+‍+ 모딩을 일반적으로 더 어렵게 만들고, 후킹을 복잡하게 만듭니다. 메서드가 갑자기 사라지고 호출된 위치에 포함될 수 있기 때문입니다.

우리의 요청에 따라 커피 스테인은 업데이트 4에서 모듈식 빌드를 활성화했습니다. 이는 각 언리얼 엔진 모듈이 자체 dll로 되어 있음을 의미합니다. 따라서 모든 함수가 내보내지므로 주소를 링커에서 네이티브로 찾을 수 있으며, 어떤 함수도 인라인화될 수 없습니다. 모듈식 빌드는 네이티브 언리얼 엔진 모드 지원(즉, Mods 폴더에서 모드 플러그인 로드)을 가능하게 합니다.

C‍+‍+ 함수 후킹

C‍+‍+ 후킹, 네이티브 후킹이라고도 불리며, 소스 코드를 수정하지 않고 C‍+‍+로 구현된 게임 및 엔진 함수의 동작을 변경할 수 있습니다.

SML의 후킹 인터페이스는 각기 다른 3가지 방법으로 함수를 후킹할 수 있으며, 각 방법에는 두 가지 호출 순서 유형이 있습니다.

같은 함수에 여러 후킹이 첨부된 경우, 이러한 후킹은 등록된 순서대로 호출됩니다.

실제 함수가 호출되기 전에 호출되는 일반 후킹이 있습니다. 이 후킹을 통해 최종 함수 호출을 방지할 수 있으며 반환 값을 덮어쓸 수도 있습니다. 최종 함수 실행을 취소하면 다음 후킹이 호출되지 않도록 할 수 있습니다. 다른 후킹이 새티스팩토리에 의해 호출되지 않도록 방지할 수 있다는 점을 유의하십시오. 일반 후킹의 시그니처는 void(TCallScope<HookFuncSignature>&, hookFuncParams)입니다. 멤버 함수를 후킹하는 경우, this 포인터는 매개변수처럼 처리되며 첫 번째 매개변수로 사용됩니다. 최종 함수 실행을 취소하지 않거나 범위 객체를 호출하여 직접 실행하지 않는 한, 최종 함수는 후킹 함수가 반환된 후 암시적으로 호출됩니다.

호출 범위 객체를 통해 다음을 수행할 수 있습니다:

  • 최종 함수 실행 취소(후킹 함수가 void를 반환하는 경우).

    void hook(TCallScope<...>& Scope, class* exampleClass, int exampleArg) {
     Scope.Cancel();
    }
  • 후킹 내에서 다음 후킹 및 최종 함수를 호출합니다. 범위를 호출하려면 후킹 시그니처와 동일한 매개변수가 필요합니다.

    void hook(TCallScope<int(int)>& Scope, class* exampleClass, int exampleArg) {
        // 최종 함수 호출 전 작업
        int result = Scope(exampleClass, exampleArg); // 다음 후킹 호출(다음 후킹이 취소/덮어쓰지 않는 한 최종 함수가 호출될 수 있음)
        // 최종 함수 호출 후 작업
    }
  • 최종 호출 전 반환 값을 덮어쓸 수도 있습니다(최종 호출이 발생하지 않음).

    void hook(TCallScope<int(int)>& Scope, class* exampleClass, int exampleArg) {
        // 최종 함수가 호출될 수 있음
        Scope.Override(customReturnValue);
        // 최종 함수는 더 이상 호출되지 않음
    }

후킹이 호출되도록 하려면, 최종 함수가 호출되었는지 여부에 관계없이 "after" 후킹을 도입합니다. 이 후킹은 일반 후킹 호출 후 모두 호출되며, 매개변수와 결과 반환 값을 읽을 수만 있습니다. 즉, 최종 함수 호출에 영향을 줄 수 없습니다. 또한, TCallScope 객체를 사용하지 마십시오. 대신 후킹 시그니처의 첫 번째 매개변수는 반환 값이며, 그 뒤에 함수 호출 매개변수가 옵니다.

void hook(int returnValue, int exampleArg) {
    // 작업 수행
}

후킹의 2가지 유형

'후킹 유형’이란 함수에 후킹을 첨부하는 다양한 방법을 의미합니다. 각 첨부 방법은 내부적으로 다르게 작동하며, 후킹 유형 간의 주요 차이점을 주의 깊게 살펴보는 것이 중요합니다.

반환 값 및 매개변수 유형 등은 서로 관련이 없으며, 멤버 함수인지 여부와 관계없이 사용할 수 있습니다. 후킹 함수는 std::function이므로, std::function이 수락할 수 있는 모든 유형(예: 함수 포인터, 바인딩된 자리 표시자가 있는 함수 포인터, 람다 등)을 사용할 수 있습니다.

에디터 시간의 후킹 동작은 매우 예측할 수 없으며 충돌을 일으킬 수 있습니다. 따라서 후킹이 에디터 시간에 적용되지 않도록 해야 합니다. 그렇지 않으면 후킹 코드를 외부에서 편집할 때까지 언리얼 에디터를 열 수 없게 될 수 있습니다.

가장 편리한 방법은 SUBSCRIBE_ 매크로를 코드 내 if (!WITH_EDITOR) { …​ } 블록 안에 넣는 것입니다:

if (!WITH_EDITOR) {
    SUBSCRIBE_METHOD(SomeClass::SomeMethod, &Hook_SomeMethod);
}

#if !WITH_EDITOR#endif 지시문을 사용하는 것도 옵션이지만, 이는 빌드 시 오류를 숨기고 IDE를 혼란스럽게 하여 개발 및 디버깅을 약간 더 번거롭게 만듭니다.

유형: SUBSCRIBE_METHOD

SUBSCRIBE_METHOD 매크로는 후킹을 첨부하여 지정한 코드가 함수 실행 전에 호출되도록 합니다.

여러 모드가 동일한 함수에 구독된 경우, 후킹은 등록된 순서대로 호출됩니다.

사용법은 다음과 같습니다:

// 대상 클래스 SomeClass에서
public:
    int MemberFunction(int arg1);
    static void StaticFunction();

// 코드에서
#include "Patching/NativeHookManager.h"

void registerHooks() {
    SUBSCRIBE_METHOD(SomeClass::MemberFunction, [](auto& scope, SomeClass* self, int arg1) {
        // 멋진 작업 수행
    });

    SUBSCRIBE_METHOD(SomeClass::StaticFunction, [](auto& scope) {
        // 멋진 작업 수행
    });
}

오버로드된 함수를 후킹하면 컴파일러가 후킹하려는 정확한 심볼을 알 수 없기 때문에 의도한 대로 작동하지 않을 수 있습니다. 이를 위해 SUBSCRIBE_METHOD_MANUAL 매크로를 사용하여 후킹하려는 심볼을 명시적으로 설정할 수 있습니다.

유형: SUBSCRIBE_METHOD_VIRTUAL

SUBSCRIBE_METHOD_VIRTUAL 매크로는 주어진 클래스의 포인터로 전달된 함수에 후킹을 첨부합니다.

이 후킹은 주어진 클래스의 가상 테이블이 가리키는 함수만 수정합니다. 서브클래스에서 주어진 클래스의 가상 함수를 오버라이드하는 함수는 수정되지 않지만, 오버라이드 구현에서 후킹된 함수가 호출되면 후킹이 여전히 실행됩니다(즉, "super 호출"). 서브클래스의 오버라이드 구현이 "super 호출"을 하지 않는 경우, 해당 서브클래스를 별도로 후킹해야 합니다. 순수 가상 함수는 적절한 함수 본문이 없으므로 후킹할 수 없습니다.

사용법은 다음과 같습니다:

// 대상 부모 클래스 SomeClass에서
public:
    virtual int MemberFunction(int arg1);

// 후킹하지 않으려는 자식 클래스 SomeChild에서
// class SomeChild : public SomeClass
public:
    virtual int MemberFunction(int arg1) override;

// 코드에서
#include "Patching/NativeHookManager.h"

void registerHooks() {
    SomeClass* SampleObject = GetMutableDefault<SomeClass>(); // UObject 파생 클래스의 경우, SUBSCRIBE_UOBJECT_METHOD를 대신 사용
    SUBSCRIBE_METHOD_VIRTUAL(SomeClass::MemberFunction, SampleObject, [](auto& scope, SomeClass* self, int arg1) {
        // 멋진 작업 수행
    });

    SomeClass parent;
    parent->MemberFunction(0); // 후킹이 호출됨
    SomeChild c;
    c->MemberFunction(1); // 후킹이 호출되지 않음
}

특수 사례

후킹하려는 함수의 유형과 수행하려는 작업에 따라 일부 조정이 필요할 수 있습니다.

Const 함수

const 함수를 후킹할 때는 "self" 포인터 앞에 const를 추가해야 합니다.

Const 여부 형식

비-Const

(auto& scope, SomeClass* self)

Const

(auto& scope, const SomeClass* self)

후킹 AFTER

"after" 후킹의 경우, 매크로 이름에 _AFTER 접미사를 추가합니다.

후킹 함수 시그니처가 이에 따라 변경되며 더 이상 "scope"가 필요하지 않습니다.

다음 예제는 비-가상 함수에 대한 것입니다. 가상 함수의 경우, SUBSCRIBE_METHOD_VIRTUAL_AFTERSUBSCRIBE_METHOD_AFTER 대신 사용합니다.

반환 여부 매개변수 여부 형식

SUBSCRIBE_METHOD_AFTER(SomeClass::MemberFunction, [](SomeClass* self))

✔️

SUBSCRIBE_METHOD_AFTER(SomeClass::MemberFunction, [](auto returnValue, SomeClass* self))

✔️

SUBSCRIBE_METHOD_AFTER(SomeClass::MemberFunction, [](SomeClass* self, int arg1, int arg2))

✔️

✔️

SUBSCRIBE_METHOD_AFTER(SomeClass::MemberFunction, [](auto returnValue, SomeClass* self, int arg1, int arg2))

FORCEINLINE 함수

FORCEINLINE 함수는 후킹할 수 없습니다.

UFUNCTIONs

함수가 UFUNCTION인지 여부는 후킹 가능 여부에 영향을 미치지 않습니다.

후킹 해제

후킹 해제 기능은 광범위하게 테스트되지 않았습니다. 발생한 문제를 디스코드에서 보고해 주십시오.

매크로는 UNSUBSCRIBE_METHOD 또는 UNSUBSCRIBE_UOBJECT_METHOD 매크로와 함께 사용할 수 있는 델리게이트를 반환하여 함수 구독을 취소할 수 있습니다.

블루프린트 함수 후킹

블루프린트 함수 후킹은 블루프린트 UFunction의 명령을 변경하여 함수 실행의 특정 지점에서 후킹이 호출되도록 합니다.

네이티브 후킹과 마찬가지로 함수 실행 전후에 후킹할 수 있습니다. 네이티브 후킹과 달리, 원래 명령 인덱스를 알고 있다면 함수의 최상위 문에서 후킹할 수도 있습니다(이는 함수를 디컴파일해야 합니다. SML의 BlueprintHookManager.cpp에서 DEBUG_BLUEPRINT_HOOKING을 참조하여 명령의 JSON 덤프를 얻는 방법을 확인하십시오).

일부 블루프린트(예: UI 블루프린트)는 전용 서버 빌드에 존재하지 않습니다. 모드가 전용 서버에서 이러한 블루프린트를 후킹하려고 하면 서버가 충돌합니다. 이 경우 후킹을 건너뛰려면 전역 함수 IsRunningDedicatedServer()를 사용할 수 있습니다.

블루프린트 함수를 후킹한 후에는 새티스팩토리를 완전히 종료하지 않고는 후킹을 해제할 수 있는 방법이 현재 없습니다. 이 때문에, 게임 시작 시 모든 블루프린트 함수 후킹을 설치하는 루트 UGameInstanceModule을 생성하는 것이 좋습니다. 일반적으로 DispatchLifecycleEvent가 처음 호출될 때 설치합니다.

후킹 함수 시그니처는 void(FBlueprintHookHelper&)입니다.

이 FBlueprintHookHelper 구조체는 다음을 수행할 수 있는 방법을 제공합니다:

  • 컨텍스트 객체(함수가 실행되는 블루프린트 인스턴스)에 접근합니다.

  • 컨텍스트의 변수, 후킹된 블루프린트 함수의 로컬 변수(입력 변수 포함), 함수의 출력 변수를 읽고/쓰는 기능을 제공합니다.

  • 후킹된 지점에서 함수 실행의 끝으로 건너뜁니다(이 지점의 모든 후킹이 이 점프 전에 실행됩니다).

블루프린트 후킹을 첨부하려면 후킹하려는 함수가 포함된 블루프린트 클래스에 대한 참조가 필요합니다. LoadClass를 사용하여 C‍+‍+ 전용 방법으로 이를 수행할 수 있지만, 리소스 경로를 하드코딩해야 하므로 권장되지 않습니다. 대신, 이러한 유형을 UGameInstanceModule의 멤버 변수로 추가한 다음 언리얼 에디터에서 선택기를 사용하여 할당해야 합니다.

다음은 후킹을 위한 BPW_MapMenu 클래스 참조를 얻는 예제입니다(이 위젯은 새티스팩토리의 지도 화면에서 모든 지도 마커를 나열하는 왼쪽 메뉴입니다):

먼저, 위젯의 네이티브 부모 클래스를 결정합니다. 이를 빠르게 확인하는 방법은 언리얼 에디터의 콘텐츠 브라우저에서 후킹하려는 블루프린트를 찾아 마우스를 올려 네이티브 부모 클래스 줄을 찾는 것입니다:

BPW_MapMenu 위에 마우스 올리기

다음으로, C‍+‍+ 지원 루트 게임 인스턴스 모듈에 TSoftClassPtr 속성을 정의합니다. 후킹하려는 블루프린트 클래스의 네이티브 부모 클래스를 제네릭 유형으로 사용합니다. 이를 EditAnywhere UPROPERTY로 만들어 언리얼 에디터에서 사용할 수 있도록 합니다. 여러 항목을 후킹할 계획이라면 속성을 구성하는 데 도움이 되도록 Category 이름을 선택적으로 할당하십시오.

    UPROPERTY(EditAnywhere, Category = "UI Widget Types")
    TSoftClassPtr<UFGUserWidget> BPW_MapMenuClass;

다음으로, 에디터를 닫고 프로젝트를 Development Editor로 다시 빌드합니다. 클래스 및 필드 구조를 변경했기 때문입니다. 빌드가 완료되면 에디터를 다시 엽니다.

모드에 블루프린트 루트 인스턴스 모듈이 아직 없는 경우, 모드에 C‍+‍+ 루트 인스턴스 모듈 클래스를 기본 클래스로 사용하는 새 블루프린트를 추가하여 만듭니다. 모드에 기존 블루프린트 구현 루트 인스턴스 모듈이 있는 경우, 이를 C‍+‍+ 클래스로 재부모하거나, 대신 서브모듈을 사용합니다(각 유형의 루트 모듈은 하나만 있을 수 있습니다).

어쨌든, 언리얼 에디터에서 루트 인스턴스 모듈 블루프린트를 엽니다. 모듈 블루프린트의 세부 정보 섹션에서 사용한 카테고리 아래의 적절한 행을 찾아 드롭다운을 클릭하고 유형을 찾아 선택합니다:

BPW_MapMenu 선택됨

이제 클래스가 후킹을 위해 모듈에서 사용할 수 있습니다.

클래스가 게임이 필요로 하기 전에 완전히 로드되지 않을 수 있습니다. 모드 초기화 시 각 TSoftClassPtr에서 LoadSynchronous를 호출하여 로드되었는지 확인합니다.

이제 블루프린트 클래스에 대한 참조가 있으므로 함수 후킹을 수행할 수 있습니다. 후킹하려는 블루프린트 함수의 이름을 아직 모르는 경우, 이는 언리얼 에디터에서 블루프린트를 열고 그래프 보기로 이동한 다음 내 블루프린트 탭 아래의 FUNCTIONS 아코디언을 확인하여 찾을 수 있습니다:

BPW_MapMenu 함수

이제 실제 후킹을 C‍+‍+로 생성할 수 있습니다. 적절한 포함 파일이 있는지 확인합니다:

#include "Patching/BlueprintHookManager.h"
#include "Patching/BlueprintHookHelper.h"

다음과 같이 UBlueprintHookManager에 대한 참조를 얻습니다:

UBlueprintHookManager* hookManager = GEngine->GetEngineSubsystem<UBlueprintHookManager>();

시작 시 매우 일찍 UBlueprintHookManager를 얻으려고 하면 게임이 충돌합니다. UGameInstanceModule에서 DispatchLifecycleEvent가 호출될 때까지 사용할 수 있습니다. DispatchLifecycleEvent는 게임이 초기화될 때 세 가지 다른 단계 값으로 세 번 호출된다는 점을 기억하십시오. 이 단계 중 하나에서만 후크를 생성해야 합니다(ELlifecyclePhase::CONSTRUCTION이 괜찮을 것입니다).

후킹은 다음과 같이 HookBlueprintFunction을 호출하여 생성할 수 있습니다:

hookManager->HookBlueprintFunction(
    BPW_MapMenuClass->FindFunctionByName(TEXT("AddActorRepresentationToMenu")), // 함수 이름을 잘못 입력하면 충돌합니다.
    [](FBlueprintHookHelper& helper) {
        // 후킹 코드
    },
    EPredefinedHookOffset::Start );
    // EPredefinedHookOffset::Start는 함수가 실행되기 직전에 후킹합니다.
    // 함수가 반환되기 직전에 후킹하려면 EPredefinedHookOffset::Return을 사용합니다.

함수의 거의 임의의 지점에서 후킹을 생성할 수 있지만, EPredefinedHookOffset 대신 명령이 실행될 위치의 정수 오프셋을 전달하여 후킹을 생성할 수 있습니다. 정확히 무엇을 하고 왜 하는지 알고 있는 경우에만 이 작업을 수행하십시오!

FBlueprintHookHelper는 블루프린트 또는 함수 실행 상태를 수정하려는 경우 필요한 모든 기능을 제공합니다. 변수의 값을 가져오거나 설정하려면 다음 중 하나를 사용하십시오:

// 후킹된 블루프린트의 변수(위 예제에서는 BPW_MapMenu의 멤버 변수)를 읽고/쓰는 경우
TSharedRef<FBlueprintHookVariableHelper_Context> contextHelper = helper.GetContextVariableHelper();

// 함수의 입력 변수 및 함수 실행에 사용하는 로컬 변수를 읽고/쓰는 경우(변수 이름을 알고 있어야 함)
TSharedRef<FBlueprintHookVariableHelper_Local> localHelper = helper.GetLocalVariableHelper();

// 함수의 출력 변수를 읽고/쓰는 경우
TSharedRef<FBlueprintHookVariableHelper_Out> outHelper = helper.GetOutVariableHelper();

Get*VariableHelper 메서드의 헤더 주석을 확인하여 어떤 상황에서 어떤 헬퍼를 사용해야 하는지 알아보세요. 다음은 간단한 예제입니다:

TSharedRef<FBlueprintHookVariableHelper_Local> localHelper = helper.GetLocalVariableHelper();
ERepresentationType* representationType = localHelper->GetEnumVariablePtr<ERepresentationType>(TEXT("representationType"));
int* intValuePtr = localHelper->GetVariablePtr<FIntProperty>(TEXT("someIntValue"));
*intValuePtr = 42; // 반환된 포인터를 사용하여 변수에 값을 쓸 수 있습니다.

보호/비공개 함수 후킹

후킹하려는 함수가 특정 클래스에 보호되거나 비공개된 경우, friend 선언을 사용해야 합니다.

이는 클래스에서만 후킹할 수 있음을 의미합니다. 전역 범위에서는 불가능합니다.

예를 들어, MyMod라는 네임스페이스에 MyWatcher라는 클래스가 있고, AFGPlayerController 클래스의 EnterChatMessage 함수를 후킹하려는 경우를 가정해 보겠습니다.

권장되는 방법은 접근 변환기를 사용하는 것입니다. AccessTransformers.ini 파일에 다음 항목을 생성합니다:

Friend=(Class="AFGPlayerController", FriendClass="MyWatcher")

또는 헤더 파일을 직접 편집할 수도 있습니다. 이는 접근 변환기 페이지에서 자세히 설명된 이유로 권장되지 않습니다. 먼저 FGPlayerController.h 헤더를 편집하고 다음 코드 블록을 추가합니다:

namespace MyMod
{
    class MyWatcher;
}

그런 다음 클래스 자체에 friend 선언을 추가해야 합니다. 결과적으로 다음과 같이 보일 것입니다:

...

class FACTORYGAME_API AFGPlayerController : public AFGPlayerControllerBase
{
    GENERATED_BODY()
public:
    friend MyMod::MyWatcher;

...
}