본문 바로가기

캡스톤 디자인/정보보호 프로젝트실습

[팰월드 안티치트 프로젝트] #9-1 전용서버 메모리 분석 - ESP

본 글은 졸업작품(안티치트 시스템 개발)을 위한 학술 목적의 보안 연구입니다. 치트의 동작 원리를 분석하여 탐지 및 방어 방안을 설계하는 데 활용합니다.

 

📌 이전 글(#8)에서는 제작 시스템을 분석했다. SDK에서 발견한 UPalDebugSetting 디버그 플래그가 Shipping 빌드에서 컴파일 아웃되어 CDO 변조가 실패했고, HW 브레이크포인트로 StackCount 쓰기 코드를 추적하여 코드 인젝션으로 재료 무소비를 달성했다.

이번 글에서는 ESP(Extra Sensory Perception)를 분석한다. 지금까지의 치트(#4~#8)는 모두 서버 메모리를 쓰거나 함수를 호출하는 것이었다. ESP는 완전히 다르다. 메모리를 읽기만 한다. 서버에 존재하는 모든 엔티티의 위치, 타입, 상태를 읽어서 화면에 표시하는 것이다. 값을 변경하지 않으므로, 지금까지 분석한 탐지 방법(값 범위 검증, 무결성 검사)이 전혀 통하지 않는다.

 

이 글에서 다루는 내용

- ESP의 정의와 이전 치트들과의 근본적 차이
- SDK에서 Actor 열거 구조(UWorld → PersistentLevel → ActorArray) 분석
- CE에서 Actor 배열 접근 및 위치/클래스명 읽기
- IDA에서 FNamePool 발견 및 FName 디코딩
- 전체 Actor 클래스 분포 확인 및 ESP 필터 기준 확정
- esp_scan.lua 통합 스크립트로 ESP 완성

분석 환경

항목  내용
서버 PalServer-Win64-Shipping-Cmd.exe (Steam Dedicated Server)
클라이언트 Palworld v0.7.2.87654 (Steam)
SDK 덤프 Dumper-7
동적 분석 Cheat Engine 7.6
정적 분석 IDA Pro 9.0
접속 환경 로컬 전용서버 (127.0.0.1:8211)

 

1단계: SDK에서 Actor 배열 탐색

[1] Actor 배열 검색

ESP는 서버 월드에 존재하는 모든 엔티티(플레이어, 팰, NPC 등)의 위치를 읽어야 한다. 이전 블로그(#4~#8)에서는 GameState → PlayerArray를 사용했지만, 이 경로는 플레이어만 열거한다. ESP에는 모든 Actor가 필요하다. 

SDK의 Engine_classes.hpp에서 Actors를 검색한다. 모든 Actor를 담고 있는 배열이 어딘가에 정의되어 있을 것이기 때문이다.
검색 결과 중 ULevel 클래스 안에 Actors라는 멤버가 있다

class TArray<class AActor*>		Actors;		// 0x0098(0x0010)

 

[2] ULevel 접근 경로 확인
Actors 배열이 ULevel 안에 있다는 건 알았다. 그러면 ULevel은 어디서 접근할 수 있는가? SDK에서 ULevel*을 검색하여 ULevel 포인터를 멤버로 가지고 있는 클래스를 찾는다.

 

여러 클래스가 ULevel 포인터를 가지고 있지만, 이 중 UWorld는 이전 블로그에서 이미 GWorld(Base + 0x882DA60)로 접근 할 수 있다는 것을 확인한 클래스이다. UWorld는 게임 월드 전체를 나타내는 객체이다. UE5에서는 월드 안에 레벨이 여러 개 있을 수 있는데, 그중 항상 존재하는 메인 레벨이 PersistentLevel이다. 모든 Actor가 이 레벨에 소속되어 있다.

이전 블로그에서는 같은 UWorld에서 GameState(+0x158) 방향으로 따라가서 플레이어를 찾았다. 이번에는PersistentLevel(+0x30) 방향으로 따라가서 모든 Actor를 가져오는 것이다.

[3] 포인터 체인 연결

GWorld (Base + 0x882DA60) → UWorld + 0x30 → PersistentLevel (ULevel*) → ULevel + 0x98 → Actors.Data (TArray<AActor*>) + 0xA0 → Actors.Count (int32)
이전 경로와 비교:
이전 (#4~#8): GWorld → GameState(+0x158) → PlayerArray → 플레이어만
ESP (#9):       GWorld → PersistentLevel(+0x30) → Actors   → 모든 엔티티

 

[4] Actor 위치 읽기 — 이전 분석 재활용

Actor 목록을 가져오는 체인은 완성되었다. 이제 각 Actor의 위치(X, Y, Z)를 읽어야 한다. 이전 블로그(#7 텔레포트)에서 K2_SetActorLocation 함수를 분석할 때 이미 위치 오프셋을 확보했다.

  AActor + 0x198 → RootComponent (USceneComponent*)
    RootComponent + 0x128 → X (double, 8바이트)
    RootComponent + 0x130 → Y (double, 8바이트)
    RootComponent + 0x138 → Z (double, 8바이트)

텔레포트에서는 이 오프셋에 값을 써서 위치를 변경했다. ESP에서는 같은 오프셋에서 값을 읽어서 위치를 가져오면 된다. 새로 찾을 필요 없이, 이전 분석 결과를 그대로 재활용할 수 있다.

 

2단계: CE에서 포인터 체인 검증

SDK에서 찾은 오프셋이 실제로 동작하는지 확인한다. CE에서 PalServer에 어태치한 후 Lua Engine에서 다음을 실행한다.

  local base = getAddress("PalServer-Win64-Shipping-Cmd.exe") 
  print(string.format("Server Base: 0x%X", base))

  local uworld = readQword(base + 0x882DA60)
  print(string.format("UWorld: 0x%X", uworld))

  local level = readQword(uworld + 0x30)
  print(string.format("PersistentLevel: 0x%X", level))

  local actorData = readQword(level + 0x98)
  local actorCount = readInteger(level + 0xA0)
  print(string.format("Actors.Data: 0x%X", actorData))
  print(string.format("Actors.Count: %d", actorCount))

서버 베이스 주소에서 시작하여 GWorld → UWorld → PersistentLevel → Actors 순서로 포인터를 따라가며, 최종적으로 Actor 배열의 주소와 개수를 읽는다. 실행 결과 2825개의 Actor가 확인되었으므로, SDK에서 찾은 오프셋이 실제 서버 메모리에서 정상적으로 동작하는 것을 검증할 수 있다.

 

Actor 배열에 접근할 수 있게 되었으니, 배열의 Actor[0]~[9]에서 위치를 읽어본다. 이전 블로그(#7 텔레포트)에서 확보한 RootComponent → RelativeLocation 오프셋을 사용한다. 

  local base = getAddress("PalServer-Win64-Shipping-Cmd.exe")
  local uworld = readQword(base + 0x882DA60)
  local level = readQword(uworld + 0x30)
  local actorData = readQword(level + 0x98)

  for i = 0, 9 do
    local actor = readQword(actorData + i * 8)
    local rc = readQword(actor + 0x198)
    if rc and rc ~= 0 then
      local x = readDouble(rc + 0x128)
      local y = readDouble(rc + 0x130)
      local z = readDouble(rc + 0x138)
      print(string.format("Actor[%d]: X=%.0f Y=%.0f Z=%.0f", i, x, y, z))
    else
      print(string.format("Actor[%d]: RootComponent = NULL", i))
    end
  end

Actor[0]처럼 좌표가 0, 0, 0인 것은 위치가 없는 논리적 객체(GameMode 등)이고, 나머지는 월드에 실제로 존재하는 오브젝트이다. 좌표는 읽히지만 이것이 플레이어인지, 팰인지, 건물인지는 아직 알 수 없다.

Actor 배열에서 좌표는 읽혔지만, 이것이 플레이어인지 팰인지 건물인지 구분할 수 없다. Actor의 클래스명을 알 수 있다면 타입을 구분할 수 있을 것이다. SDK에서 GetClass, ClassName 등을 검색해봤지만, Actor의 클래스명을 직접 읽는 방법은 SDK 덤프에 포함되어 있지 않았다. 이것은 UE5 엔진 내부 구조에 해당하기 때문이다.

SDK만으로는 한계가 있으므로, IDA에서 실제 바이너리를 디컴파일하여 Actor의 클래스명을 읽는 방법을 찾아본다.

 

3단계: IDA에서 Actor 클래스 식별 구조 분석

SDK에서 Actor의 클래스명을 읽는 방법을 찾지 못했으므로, IDA에서 실제 바이너리를 디컴파일하여 추적한다.

 

[1] FName 관련 함수 검색

Actor의 클래스명은 UE5에서 FName이라는 인덱스로 저장되어 있다. 이 인덱스를 문자열로 변환하려면 FNamePool이라는 메모리 풀의 주소가 필요하다. 하지만 PalServer는 Shipping 빌드이기 때문에 함수 이름(심볼)이 모두 제거되어 있다. 함수 이름으로는 검색할 수 없으므로, 바이너리에 남아있는 에러 문자열을 단서로 역추적한다.
UE5 엔진은 FName 처리 시 이름이 너무 길면 ERROR_NAME_SIZE_EXCEEDED라는 에러를 출력한다. 이 문자열은 FName 관련 함수에서만 사용되므로, 이것을 검색하면 해당 함수를 찾을 수 있다.

 

Strings 창(F12)을 열고 ERROR_NAME_SIZE_EXCEEDED를 검색한다.

 

해당 문자열을 참조하는 함수(sub_1430398C0)를 더블클릭하여 이동한 뒤 F5로 디컴파일한다.

 

[2] FNamePool 주소 발견

디컴파일된 함수의 코드를 분석해보면

if ( v7 >= 0x400 )                          // 48줄: 이름 길이 체크
{
    *&v22 = "ERROR_NAME_SIZE_EXCEEDED";    // 51줄: 에러 → LABEL_33 → 함수 종료
     goto LABEL_34;
}
LABEL_11:                                  // 58줄: 여기로 오면 정상 케이스
&stru_148631CC0;                           // 61줄: 이 구조체 접근
sub_14304A990(v12, &v22, &v23);            // 64줄: 이름 처리

 

 

stru_148631CC0이 FNamePool 이라는 근거는 아래와 같다.

1. ERROR_NAME_SIZE_EXCEEDED는 UE5에서 FName 처리 시에만 사용하는 에러 문자열이므로, 이 함수는 FName 처리 함수이다
2. 이름 길이가 정상 범위일 때 접근하는 유일한 데이터 구조가 stru_148631CC0이다
3. FName 처리 함수에서 이름 데이터를 저장/조회하는 구조체는 FNamePool이다
4. 따라서 stru_148631CC0 = FNamePool

 

IDA에서 발견한 절대 주소(0x148631CC0)에서 IDA 베이스(0x140000000)를 빼면 RVA 오프셋 0x08631CC0이 나온다. 실제 서버에서는 Base + 0x08631CC0으로 FNamePool에 접근할 수 있다.

 

[3] FNamePool로 Actor 이름 읽기

IDA에서 찾은 FNamePool 주소(Base + 0x08631CC0)를 사용하여, 2단계에서 좌표만 읽었던 Actor[0]~[9]의 클래스명을 확인한다.

  local base = getAddress("PalServer-Win64-Shipping-Cmd.exe")
  local uworld = readQword(base + 0x882DA60)
  local level = readQword(uworld + 0x30)
  local actorData = readQword(level + 0x98)
  local poolBase = base + 0x08631CC0

  for i = 0, 9 do
    local actor = readQword(actorData + i * 8)
    local uclass = readQword(actor + 0x10)
    local nameIdx = readInteger(uclass + 0x18)
    local blockIdx = (nameIdx >> 16) & 0xFFFF
    local entryIdx = nameIdx & 0xFFFF
    local blockPtr = readQword(poolBase + 0x10 + blockIdx * 8)
    local entryAddr = blockPtr + entryIdx * 2
    local header = readSmallInteger(entryAddr)
    local len = (header >> 6) & 0x3FF
    local name = readString(entryAddr + 2, len)
    print(string.format("Actor[%d]: %s", i, name))
  end

 

이 스크립트는 크게 3단계로 동작한다

  1단계: Actor 포인터 가져오기
  GWorld → PersistentLevel → Actor 배열에서 Actor[0]~[9]를 꺼낸다.
  이전 단계에서 검증한 포인터 체인을 그대로 사용한다.
  
  2단계: 이름 번호표 꺼내기
  Actor + 0x10 → UClass 포인터, UClass + 0x18 → FName 인덱스(정수)를 읽는다.
  이 +0x10, +0x18은 SDK의 Assertions.inl에 정의된 UObject 레이아웃에서 온 오프셋이다.
  
  3단계: 번호표 → 문자열 변환
  FNamePool(Base + 0x08631CC0)에서 FName 인덱스를 실제 클래스명 문자열로 디코딩한다.
  예를 들어 정수 번호표가 "BP_Player_C" 같은 문자열로 변환된다.

배열 앞쪽(Actor[0]~[9])은 월드 구성 요소가 대부분이다. 플레이어나 팰 같은 게임 엔티티는 배열 뒤쪽에 위치한다. 이제 Actor가 무엇인지 구분할 수 있다. 하지만 Actor[0]~[9]는 배열 앞쪽의 일부일 뿐이다. 전체 배열에는 어떤 클래스들이 있는지 확인해야 한다.

[4] 전체 Actor 클래스 분포 확인
전체 Actor 배열을 순회하며 클래스명을 집계하는 스크립트를 실행한다

local base = getAddress("PalServer-Win64-Shipping-Cmd.exe")
  local uworld = readQword(base + 0x882DA60)
  local level = readQword(uworld + 0x30)                                                                                                                                                                                       
  local actorData = readQword(level + 0x98)
  local actorCnt = readInteger(level + 0xA0)

  local function decodeFName(nameIdx)
    local poolBase = base + 0x08631CC0
    local blockPtr = readQword(poolBase + 0x10 + ((nameIdx >> 16) & 0xFFFF) * 8)
    if not blockPtr or blockPtr == 0 then return "?" end
    local entry = blockPtr + (nameIdx & 0xFFFF) * 2
    local len = (readSmallInteger(entry) >> 6) & 0x3FF
    local s = ""
    for i = 0, len - 1 do
      local b = readBytes(entry + 2 + i, 1)
      if type(b) == "table" then b = b[1] end
      s = s .. string.char(b)
    end
    return s
  end

  local counts = {}
  for i = 0, actorCnt - 1 do
    local a = readQword(actorData + i * 8)
    if a and a ~= 0 then
      local c = readQword(a + 0x10)
      if c and c ~= 0 then
        local n = decodeFName(readInteger(c + 0x18))
        counts[n] = (counts[n] or 0) + 1
      end
    end
  end

  local s = {}
  for n, c in pairs(counts) do s[#s+1] = {n=n, c=c} end
  table.sort(s, function(a,b) return a.c > b.c end)
  print(string.format("Total: %d | Unique: %d", actorCnt, #s))
  for _, v in ipairs(s) do print(string.format("%4d  %s", v.c, v.n)) end

실행 결과, 2825개 Actor에서 약 200종의 고유 클래스가 발견되었다. 2825개 중 대부분은 환경 오브젝트이다. UE5에서는 지형, 조명, 사운드, 트리거 박스까지 전부 Actor이기 때문이다. ESP에서 관심 있는 플레이어, 팰, NPC는 극히 일부이다. 이 중에서 관심 대상만 걸러내야 한다.

 

[5] "Pal" 필터의 함정
가장 먼저 떠오르는 방법은 클래스명에 "Pal"이 포함된 것만 필터링하는 것이다. 하지만 결과를 보면 55종이 잡히고, 대부분 실제 팰이 아니다

392  BP_PalBotBuilderLocation_C     ← 건축 마커 (팰 아님)
  9  BP_PalAmbientSoundArea_SeaSide_C ← 사운드 영역 (팰 아님)
  9  BP_PalRegionTriggerBox_C       ← 트리거 박스 (팰 아님)
  5  BP_PalBlockingVolume_C         ← 충돌 볼륨 (팰 아님)
  1  BP_PalGamemode_C               ← 게임모드 (팰 아님)
  6  BP_ChickenPal_C                ← 실제 팰 ✓

 

팰월드가 자체 클래스에 전부 Pal 접두사를 붙이기 때문이다. 반대로, BP_PinkCat_C(핑크캣), BP_SheepBall_C(양모볼)처럼 "Pal"이 이름에 없는 실제 팰도 있어서 빠진다. 단순히 "Pal" 포함 여부로 필터링하면 안 된다. 접두사 기반의 정확한 분류가 필요하다.

[6] ESP 필터 기준 확정
전체 스캔 결과를 바탕으로, 클래스명의 접두사로 ESP 표시 대상을 분류한다

local base = getAddress("PalServer-Win64-Shipping-Cmd.exe")
  local uworld = readQword(base + 0x882DA60)
  local level = readQword(uworld + 0x30)                                                                                                                                                                                       
  local actorData = readQword(level + 0x98)
  local actorCnt = readInteger(level + 0xA0)

  local function decodeFName(nameIdx)
    local poolBase = base + 0x08631CC0
    local blockPtr = readQword(poolBase + 0x10 + ((nameIdx >> 16) & 0xFFFF) * 8)
    if not blockPtr or blockPtr == 0 then return "?" end
    local entry = blockPtr + (nameIdx & 0xFFFF) * 2
    local len = (readSmallInteger(entry) >> 6) & 0x3FF
    local s = ""
    for i = 0, len - 1 do
      local b = readBytes(entry + 2 + i, 1)
      if type(b) == "table" then b = b[1] end
      s = s .. string.char(b)
    end
    return s
  end

  local function classify(name)
    if string.find(name, "BP_Player_") then return "PLAYER" end
    if string.find(name, "BP_NPC_") then return "NPC" end
    if string.find(name, "BP_MonsterAIController_Wild") then return "WILD_PAL" end
    if string.find(name, "BP_MonsterAIController_Otomo") then return "ALLY_PAL" end
    if string.find(name, "BP_MonsterAIController_BaseCamp") then return "ALLY_PAL" end
    return nil
  end

  local typeCounts = {}
  for i = 0, actorCnt - 1 do
    local a = readQword(actorData + i * 8)
    if a and a ~= 0 then
      local c = readQword(a + 0x10)
      if c and c ~= 0 then
        local name = decodeFName(readInteger(c + 0x18))
        local t = classify(name)
        if t then
          typeCounts[t] = typeCounts[t] or {}
          typeCounts[t][name] = (typeCounts[t][name] or 0) + 1
        end
      end
    end
  end

  for t, classes in pairs(typeCounts) do
    local total = 0
    for _, cnt in pairs(classes) do total = total + cnt end
    print(string.format("[%s] %d개", t, total))
    for name, cnt in pairs(classes) do
      print(string.format("  %3d  %s", cnt, name))
    end
  end
BP_Player_                       → PLAYER (다른 플레이어)
BP_NPC_                          → NPC (상인, 사냥꾼)
BP_MonsterAIController_Wild      → WILD_PAL (야생 팰 AI)
BP_MonsterAIController_Otomo     → ALLY_PAL (아군 팰 AI)
BP_MonsterAIController_BaseCamp  → ALLY_PAL (거점 팰 AI)
그 외                             → OTHER (환경 오브젝트, ESP에서 제외)

이 기준으로 전체 Actor를 필터링하면, 2825개 중 ESP에 의미 있는 엔티티만 추출할 수 있다.

 

4단계 : 1~3단계 통합 스크립트 실행

지금까지 분석한 모든 것을 하나의 스크립트로 합친다

1. 내 위치 가져오기 (GameState → PlayerArray → Pawn)
2. 전체 Actor 열거 (PersistentLevel → Actors)
3. 각 Actor의 클래스명 읽기 (UClass → FName → FNamePool)
4. 클래스명으로 타입 분류 (PLAYER/NPC/WILD_PAL/ALLY_PAL, OTHER는 제외)
5. 내 위치 기준 거리계산 후 가까운 순으로 정렬
6. 결과 출력
-- =============================================================
-- ESP Scan Script (Actor Enumeration + Classification)
-- Usage: CE에서 PalServer 어태치 후 Lua Engine에서 실행
-- =============================================================

-- ▶ 설정 (검증된 오프셋)
local OFFSET_GWORLD           = 0x882DA60
local OFFSET_GAMESTATE        = 0x158
local OFFSET_PLAYERARRAY      = 0x2A8
local OFFSET_PLAYERARRAY_CNT  = 0x2B0
local OFFSET_PLAYERSTATE_PAWN = 0x308
local OFFSET_OWNER            = 0x140
local OFFSET_ROOTCOMPONENT    = 0x198
local OFFSET_RELATIVELOCATION = 0x128
local OFFSET_PERSISTENTLEVEL  = 0x30
local OFFSET_LEVEL_ACTORS     = 0x98
local OFFSET_LEVEL_ACTORCOUNT = 0xA0
local OFFSET_UCLASS           = 0x10
local OFFSET_UCLASS_FNAME     = 0x18
local OFFSET_FNAMEPOOL        = 0x08631CC0

-- ▶ ESP 설정
local ESP_MAX_DISPLAY = 100
local ESP_MAX_ACTORS  = 20000

-- =============================================================
-- 내부 함수
-- =============================================================

local function getBase()
  local b = getAddress("PalServer-Win64-Shipping-Cmd.exe")
  if b == 0 then
    print("[ERROR] PalServer not attached")
    return nil
  end
  return b
end

-- FName 디코딩: FNamePool에서 클래스명 읽기
local function decodeFName(base, nameIdx)
  local blockIdx = (nameIdx >> 16) & 0xFFFF
  local entryIdx = nameIdx & 0xFFFF

  -- FNamePool.Blocks[blockIdx]
  local poolBase = base + OFFSET_FNAMEPOOL
  local blockPtr = readQword(poolBase + 0x10 + blockIdx * 8)
  if not blockPtr or blockPtr == 0 then return nil end

  -- 각 엔트리는 2바이트 헤더 + 문자열, 엔트리 크기 = 2 + len
  -- 엔트리 오프셋 = entryIdx * 2 (stride = 2바이트 단위)
  local entryAddr = blockPtr + entryIdx * 2

  -- 헤더: 2바이트 little-endian, 길이 = header >> 6
  local header = readSmallInteger(entryAddr)
  if not header then return nil end
  local len = (header >> 6) & 0x3FF
  if len <= 0 or len > 256 then return nil end

  -- ASCII 문자열 읽기
  local chars = {}
  for i = 0, len - 1 do
    local byte = readBytes(entryAddr + 2 + i, 1)
    if not byte then return nil end
    if type(byte) == "table" then byte = byte[1] end
    if byte == 0 then break end
    if byte < 32 or byte >= 127 then break end
    chars[#chars + 1] = string.char(byte)
  end

  if #chars == 0 then return nil end
  return table.concat(chars)
end

-- 로컬 플레이어 위치 가져오기
local function getLocalPlayerPos(base)
  local uworld = readQword(base + OFFSET_GWORLD)
  if not uworld or uworld == 0 then print("[ERROR] UWorld=0"); return nil end

  local gs = readQword(uworld + OFFSET_GAMESTATE)
  if not gs or gs == 0 then print("[ERROR] GameState=0"); return nil end

  local arrData = readQword(gs + OFFSET_PLAYERARRAY)
  if not arrData or arrData == 0 then print("[ERROR] PlayerArray=0"); return nil end

  local ps = readQword(arrData)
  if not ps or ps == 0 then print("[ERROR] PlayerState[0]=0"); return nil end

  local pawn = readQword(ps + OFFSET_PLAYERSTATE_PAWN)
  if not pawn or pawn == 0 then
    -- fallback: Owner → PC → Pawn
    local pc = readQword(ps + OFFSET_OWNER)
    if not pc or pc == 0 then print("[ERROR] PlayerController=0"); return nil end
    pawn = readQword(pc + 0x2D0)
    if not pawn or pawn == 0 then print("[ERROR] Pawn=0"); return nil end
  end

  local rc = readQword(pawn + OFFSET_ROOTCOMPONENT)
  if not rc or rc == 0 then print("[ERROR] RootComponent=0"); return nil end

  local x = readDouble(rc + OFFSET_RELATIVELOCATION)
  local y = readDouble(rc + OFFSET_RELATIVELOCATION + 8)
  local z = readDouble(rc + OFFSET_RELATIVELOCATION + 16)
  return x, y, z, pawn
end

-- PersistentLevel에서 전체 Actor 열거
local function enumAllActors(base)
  local uworld = readQword(base + OFFSET_GWORLD)
  if not uworld or uworld == 0 then return {}, 0 end

  local level = readQword(uworld + OFFSET_PERSISTENTLEVEL)
  if not level or level == 0 then print("[ERROR] PersistentLevel=0"); return {}, 0 end

  local actorData = readQword(level + OFFSET_LEVEL_ACTORS)
  local actorCount = readInteger(level + OFFSET_LEVEL_ACTORCOUNT)

  if not actorData or actorData == 0 then print("[ERROR] ActorArray.Data=0"); return {}, 0 end
  if not actorCount or actorCount <= 0 then print("[ERROR] ActorCount=0"); return {}, 0 end

  local total = actorCount
  if actorCount > ESP_MAX_ACTORS then actorCount = ESP_MAX_ACTORS end

  local actors = {}
  for i = 0, actorCount - 1 do
    local actor = readQword(actorData + i * 8)
    if actor and actor ~= 0 then
      actors[#actors + 1] = actor
    end
  end

  return actors, total
end

-- Actor 위치 읽기 (double x3)
local function getActorPos(actor)
  local rc = readQword(actor + OFFSET_ROOTCOMPONENT)
  if not rc or rc == 0 then return nil, nil, nil end
  local x = readDouble(rc + OFFSET_RELATIVELOCATION)
  local y = readDouble(rc + OFFSET_RELATIVELOCATION + 8)
  local z = readDouble(rc + OFFSET_RELATIVELOCATION + 16)
  return x, y, z
end

-- Actor의 UClass → FName → 클래스명
local function getActorClassName(base, actor)
  local uclass = readQword(actor + OFFSET_UCLASS)
  if not uclass or uclass == 0 then return nil end

  local nameIdx = readInteger(uclass + OFFSET_UCLASS_FNAME)
  if not nameIdx or nameIdx == 0 then return nil end

  return decodeFName(base, nameIdx)
end

-- 클래스명으로 타입 분류
local function classifyActor(className)
  if not className then return "OTHER" end

  -- 플레이어
  if string.find(className, "BP_Player_") then return "PLAYER" end

  -- NPC
  if string.find(className, "BP_NPC_") then return "NPC" end

  -- 야생 팰 (AIController)
  if string.find(className, "BP_MonsterAIController_Wild") then return "WILD_PAL" end

  -- 아군 팰 (AIController)
  if string.find(className, "BP_MonsterAIController_Otomo") then return "ALLY_PAL" end

  -- 팰 캐릭터 (일반)
  if string.find(className, "Pal") and string.find(className, "_C") then return "PAL" end

  return "OTHER"
end

-- 3D 거리 계산
local function calcDistance(x1, y1, z1, x2, y2, z2)
  return math.sqrt((x2 - x1)^2 + (y2 - y1)^2 + (z2 - z1)^2)
end

-- =============================================================
-- 메인 스캔 실행
-- =============================================================

local base = getBase()
if not base then return end

local myX, myY, myZ, myPawn = getLocalPlayerPos(base)
if not myX then return end

local actors, totalCount = enumAllActors(base)
if #actors == 0 then
  print("[ERROR] No actors found")
  return
end

-- 전체 Actor 순회 + 필터 + 분류
local entities = {}

for _, actor in ipairs(actors) do
  -- 자기 자신 제외
  if actor == myPawn then goto continue end

  -- RootComponent가 있는 Actor만
  local ax, ay, az = getActorPos(actor)
  if not ax then goto continue end

  -- 좌표 유효성 (비정상 좌표 필터)
  if ax ~= ax or ay ~= ay or az ~= az then goto continue end  -- NaN 체크
  if math.abs(ax) > 1e10 or math.abs(ay) > 1e10 then goto continue end

  -- 클래스명으로 타입 분류
  local className = getActorClassName(base, actor)
  local eType = classifyActor(className)

  -- PLAYER, PAL, NPC 관련만 수집 (OTHER 제외)
  if eType == "OTHER" then goto continue end

  local dist = calcDistance(myX, myY, myZ, ax, ay, az)

  entities[#entities + 1] = {
    addr = actor,
    x = ax, y = ay, z = az,
    dist = dist,
    type = eType,
    class = className or "Unknown"
  }

  ::continue::
end

-- 거리순 정렬
table.sort(entities, function(a, b) return a.dist < b.dist end)

-- =============================================================
-- 결과 출력
-- =============================================================

print("=============================================")
print("  Palworld ESP Scanner v1.0")
print("=============================================")
print(string.format("  My Position: X=%.0f Y=%.0f Z=%.0f", myX, myY, myZ))
print(string.format("  Total Actors: %d | Filtered: %d", totalCount, #entities))
print("---------------------------------------------")

local displayCount = math.min(#entities, ESP_MAX_DISPLAY)
for i = 1, displayCount do
  local e = entities[i]
  local tag = string.format("%-10s", "[" .. e.type .. "]")
  print(string.format("  %s dist=%-8.0f %-35s X=%.0f Y=%.0f Z=%.0f",
    tag, e.dist, e.class, e.x, e.y, e.z))
end

if #entities > ESP_MAX_DISPLAY then
  print(string.format("  ... and %d more entities", #entities - ESP_MAX_DISPLAY))
end

print("=============================================")

지금까지 SDK에서 Actor 배열을 찾고, IDA에서 FNamePool을 발견하고, CE에서 검증하는 과정을 거쳐 ESP의 핵심인 "서버 월드의 모든 엔티티를 열거하고 분류하는 것"까지 완성했다. 내 위치 기준으로 주변 플레이어, NPC, 야생 팰, 아군 팰을 거리순으로 보여준다. 메모리를 읽기만 했을 뿐, 어떤 값도 변경하지 않았다.

 

다음 글에서는 이 텍스트 ESP를 2D 미니맵 GUI로 확장하고, PalCharacter의 내부 구조(HP, 레벨, 아군/야생 구분)를 IDA로 디컴파일하여 고급 ESP를 구현한다. 그리고 "읽기만 하는 치트를 어떻게 탐지할 것인가"라는 안티치트 핵심 질문에 답한다.