공정 엔지니어의 AI 엔지니어로의 성장 기록

비전공자의 바이브 코딩/일상 & 기초

디아블로2 봇 만들기 #1— (맵핵구현)D2R 게임 메모리 구조 분석

ai-process-engineer 2026. 6. 3. 17:30

 

디아블로2 봇 만들기 #1 — D2R 게임 메모리 구조 분석과 패턴 스캔

게임 화면을 건드리지 않고도 몬스터 위치, 워프포인트, 보스를 실시간으로 알 수 있다면? D2R(Diablo II: Resurrected)은 모든 게임 데이터를 프로세스 메모리에 올려두고 있고, Windows API를 통해 그 데이터를 읽는 것은 완전히 가능하다. 이 글에서는 코드 주입이나 치트 없이 순수하게 메모리를 읽는 방식으로 맵핵의 첫 번째 단계, 즉 게임 데이터 구조를 역공학하는 과정을 정리했다.

※ 이 프로젝트는 학습 목적의 역공학 실습이다. 메모리 쓰기나 코드 주입은 일절 없고, 오직 읽기만 한다. 오픈소스 참고 프로젝트: D2RMH (soarqin, MIT 라이선스).

01 맵핵이란 무엇인가

디아블로의 맵핵은 게임 화면 위에 투명 오버레이를 띄워서 몬스터 위치, 워프포인트, 보스, 퀘스트 오브젝트를 실시간으로 표시하는 도구다. 핵심은 게임 코드를 전혀 수정하지 않는다는 점이다. 게임이 스스로 메모리에 올려놓은 데이터를 바깥에서 읽을 뿐이다.

핵심 원칙 — 이 프로젝트는 게임 프로세스에 코드를 주입하거나 메모리를 쓰지 않는다. Windows ReadProcessMemory API로 D2R.exe의 메모리를 읽기만 한다. 스팀 오버레이, Discord 오버레이가 동작하는 방식과 동일한 철학이다.

이 시리즈 3편이 끝나면 다음과 같은 결과물이 완성된다:

  • 실시간 몬스터 마커 일반, 챔피언, 유니크, 보스를 색상과 모양으로 구분해 화면에 표시
  • 워프포인트 · 출구 · 포탈 감지 방문하지 않은 방의 오브젝트까지 PresetUnit을 통해 미리 파악
  • 좌하단 미니맵 현재 Area 전체 레이아웃을 축소해 실시간으로 표시

02 왜 다른 프로세스의 메모리를 읽을 수 있는가

Windows의 모든 프로세스는 독립된 가상 주소 공간을 가진다. 기본적으로 프로세스 간 메모리 접근은 차단되어 있지만, 관리자 권한을 가진 프로세스ReadProcessMemory API를 통해 다른 프로세스의 메모리를 읽을 수 있다. D2R.exe는 일반 권한으로 실행되기 때문에, 읽으려는 도구가 더 높은 권한을 가지면 된다.

관리자 권한
Python 스크립트
ReadProcessMemory
D2R.exe
가상 메모리
게임 데이터
읽기 완료

Python에서는 pymem 라이브러리가 이 과정을 깔끔하게 추상화해준다. 프로세스 이름만 넘기면 핸들 획득부터 메모리 읽기까지 처리된다.

Python · pymem 기본 사용
import pymem

# D2R 프로세스에 붙기 (관리자 권한으로 실행해야 함)
pm = pymem.Pymem("D2R.exe")

# 특정 주소에서 바이트 읽기
data = pm.read_bytes(address, size)

# uint64 읽기 (포인터 추적에 사용)
ptr = pm.read_longlong(address)

03 어디서 읽어야 하는가 — 패턴 스캔

문제가 하나 있다. D2R은 실행할 때마다 데이터가 다른 메모리 주소에 로드된다(ASLR). 어제 0x1A2B3C4D에 있던 HashTable이 오늘은 전혀 다른 주소에 있다. 주소를 하드코딩하는 방식은 동작하지 않는다.

해결책 — D2R.exe 실행 코드 내에는 버전이 바뀌어도 변하지 않는 "특정 바이트 패턴"이 존재한다. 이 패턴을 메모리에서 찾으면, 그 위치로부터 고정된 오프셋으로 핵심 데이터 주소를 계산할 수 있다. 이것이 패턴 스캔(Pattern Scan)이다.

패턴 스캔 예시 — HashTable 주소 찾기

HashTable 패턴 48 03 C7 49 8B 8C C6을 D2R.exe 메모리에서 찾으면, 그 오프셋 +7 위치에 HashTable의 실제 주소가 저장되어 있다. 버전에 따라 일부 바이트가 바뀔 수 있으므로, 변동 가능한 바이트 위치를 와일드카드 마스크(0x00)로 지정해 무시한다.

Python · 패턴 스캔 핵심 로직
def _search_pattern(mem: bytes, pattern: bytes, mask: bytes) -> int:
    # mem: 스캔할 메모리 덩어리
    # mask: 0xFF = 정확히 일치, 0x00 = 이 바이트 무시(와일드카드)
    plen = len(pattern)
    for i in range(len(mem) - plen):
        found = True
        for j in range(plen):
            if (mem[i+j] & mask[j]) != (pattern[j] & mask[j]):
                found = False
                break
        if found:
            return i  # 패턴 발견 위치 반환
    return -1

스캔 범위는 전체 메모리가 아니다. VirtualQueryEx읽기 가능한 메모리 영역만 선별해서 스캔한다. 보호된 페이지(실행 전용, 접근 불가)에 접근하면 프로세스가 크래시나기 때문이다. 실제로는 D2R.exe 모듈 영역만 스캔해도 모든 패턴을 찾을 수 있다.

04 D2R의 핵심 데이터 구조

D2R의 내부를 들여다보면 세 가지 핵심 구조체가 있다. 포인터 체인을 따라가는 과정은 "화살표를 따라가는 보물찾기"와 같다. 각 구조체는 다음 구조체의 주소를 가리키는 포인터를 들고 있다.

4-1. UnitAny — 게임 내 모든 '존재'의 기본 구조체

디아블로 세계의 모든 존재 — 플레이어, 몬스터, 오브젝트, 아이템 — 는 동일한 UnitAny 구조체로 표현된다. 오프셋 0x00에 있는 UNIT_TYPE 필드 하나로 종류를 구분한다.

OffsetField설명
0x00UNIT_TYPE유닛 종류 (0=플레이어, 1=몬스터, 2=오브젝트, 4=아이템)
0x04UNIT_TXT_FILE_NO텍스트 데이터 파일 번호 — 몬스터/오브젝트 종류 식별자
0x08UNIT_ID게임 내 고유 ID
0x38UNIT_PATH_PTR위치 정보 포인터 (DynamicPath / StaticPath)
0x150UNIT_NEXT_PTR다음 유닛 포인터 — 링크드 리스트 연결

핵심 필드만 소개했지만, 실제 구조체는 훨씬 많은 필드를 가진다. 나머지 오프셋(스탯, MonsterData, StatList 포인터 등)은 코드 전체를 참조하면 된다.

4-2. HashTable — 유닛 검색 인덱스

D2R은 게임 내 모든 유닛을 128개의 버킷으로 나누어 저장한다. 플레이어, 몬스터, 오브젝트, 아이템 각각 별도의 HashTable이 존재하며, 각 버킷은 UnitAny 링크드 리스트의 시작 포인터를 가진다.

HashTable [버킷 0] UnitAny UnitAny NULL [버킷 1] NULL [버킷 2] UnitAny NULL ... [버킷 127] UnitAny UnitAny UnitAny NULL

128개 버킷을 순회하면서 각 버킷의 링크드 리스트를 끝까지 따라가면 현재 게임에 존재하는 모든 유닛을 수집할 수 있다. UNIT_NEXT_PTR(0x150)이 NULL이 될 때까지 계속 따라가면 된다.

Python · HashTable 순회
def _read_unit_hash_table(self, addr: int, callback):
    for i in range(128):  # 128개 버킷 순회
        ptr = self._read_uint64(addr + i * 8)
        while ptr:  # 링크드 리스트 끝까지
            callback(ptr)  # 각 UnitAny 처리
            ptr = self._read_uint64(ptr + 0x150)  # UNIT_NEXT_PTR

4-3. 위치 정보 — DynamicPath vs StaticPath

유닛 종류에 따라 위치 정보를 읽는 구조체가 다르다. 몬스터와 플레이어는 실시간으로 움직이므로 DynamicPath를, 오브젝트와 아이템은 고정 위치이므로 StaticPath를 사용한다.

구분대상X 오프셋Y 오프셋타입
DynamicPath몬스터, 플레이어PATH_POS_X (0x02)PATH_POS_Y (0x06)uint16
StaticPath오브젝트, 아이템STATIC_POS_X (0x10)STATIC_POS_Y (0x14)uint32

좌표 단위는 서브타일(sub-tile)이다. 타일 1개 = 서브타일 5개이므로, 화면 좌표로 변환할 때 이 단위를 고려해야 한다.

4-4. Room 구조체 — 던전의 방

D2R의 던전은 사전 정의된 "방(Room)"의 조합으로 생성된다. 계층 구조는 Room1(런타임 방)Room2(맵 레이아웃 방)이며, Room2에 위치, 크기, Level 포인터가 저장되어 있다.

Level └→ Room2 (POS_X, POS_Y, SIZE_X, SIZE_Y) └→ Level Area ID (현재 구역 번호) └→ Room2.Next Room2.Next ... (전체 방 순회)

Room2들을 전부 순회하면 방문 여부와 관계없이 던전의 전체 지도를 파악할 수 있다. 이것이 PresetUnit 감지의 기반이 되는데, 자세한 내용은 3편에서 다룬다.

05 전체 데이터 읽기 흐름

실제 구현의 업데이트 사이클은 초기화(connect)반복 갱신(update, 50ms마다) 두 단계로 나뉜다.

Python · 업데이트 사이클 구조
connect()
  └─ pymem으로 D2R.exe 프로세스 열기
  └─ VirtualQueryEx로 전체 메모리 영역 열거
  └─ 패턴 스캔 → HashTable 주소, UI 주소 확정

update()  # 50ms마다 반복
  └─ read_player_unit()     → 플레이어 위치, 현재 Area ID
  └─ read_rooms()           → 전체 방 구조 + PresetUnit 수집
  └─ read_unit_hash_table() → 몬스터, 오브젝트, 아이템 수집

패턴 스캔은 초기화 시 한 번만 수행한다. 이후 50ms 루프에서는 이미 확정된 HashTable 주소를 기반으로 빠르게 데이터를 읽는다. D2R 업데이트로 오프셋이 바뀌면 패턴 스캔만 다시 검증하면 된다.

06 주의사항과 한계

  • 읽기 전용 이 프로젝트는 메모리를 쓰거나 코드를 주입하지 않는다. ReadProcessMemory만 사용하므로 게임 상태를 변경하지 않는다.
  • 버전 의존성 D2R 업데이트 시 구조체 오프셋(0x38, 0x150 등)이 바뀔 수 있다. 패턴 스캔으로 주소는 자동 추적되지만, 오프셋 상수는 수동 검증이 필요하다.
  • 오픈소스 참고 구조체 오프셋과 ID 테이블은 D2RMH (soarqin, MIT 라이선스) 프로젝트를 참고해 검증했다. 처음부터 완전히 독자 역공학하는 것은 수개월이 걸리는 작업이다.
Summary

D2R의 게임 데이터는 Windows ReadProcessMemory API로 읽을 수 있다. ASLR 문제는 패턴 스캔으로 해결하고, UnitAny → HashTable → Room 구조체 계층을 포인터 체인으로 따라가면 게임 내 모든 유닛 정보를 수집할 수 있다. 다음 편에서는 이 데이터를 게임 화면 위에 실시간으로 표시하는 투명 오버레이 창(PyQt6 + Win32)을 구현한다.