Skip to content

Plugin Development Guide KO

Lafko edited this page Jul 9, 2026 · 14 revisions

← Home


플러그인 개발 가이드

POEFixer 플러그인은 런타임에 Plugins/<PluginName>/<PluginName>.dll 경로에서 로드되는 네이티브 C++ DLL입니다. 실시간 게임 상태를 읽고, ImGui 오버레이를 그리고, 자체 설정을 영구 저장하고, 호스트 이벤트를 구독합니다.


1. 개요

플러그인 SDK는 3계층 아키텍처로 구성되어 있습니다.

Plugin DLL  ───►  PluginSDK.h  (header-only C++ wrapper, owns std::string/vector/function)
                         │
                         ▼  inline function-pointer calls only
                  HostAbi  (pure-C ABI, POD structs only)
                         │
                         ▼  SEH-wrapped on the host side
   Host bridge: plugin_manager/bridge/Bridge_<Service>.cpp  (16 files)
                         │
                         ▼
                   GameClient + GameLibrary
  • 플러그인 작성자는 정확히 하나의 헤더만 포함하면 됩니다. POEFixer/plugin_sdk/PluginSDK.h.
  • 해당 헤더는 PluginSDK:: 네임스페이스 내의 모든 것을 선언하며, 그 아래에서 PluginAbi.h의 C ABI를 가져옵니다. 후자의 존재를 언급할 수는 있지만, 직접 들여다볼 일은 거의 없습니다.
  • 모든 std::* 컨테이너는 플러그인 DLL 내부에 존재합니다. 호스트 경계를 넘는 것은 POD뿐입니다. 즉, 특정 툴체인 버전으로 빌드된 플러그인이 호스트의 STL과 얽힐 일이 없습니다 — 공유되는 타입은 정수, 부동소수점, 포인터, 작은 구조체뿐입니다.

SDK 헤더 위치는 다음과 같습니다.

  • POEFixer/plugin_sdk/PluginSDK.h — 플러그인 작성자가 사용하는 C++ 래퍼.
  • POEFixer/plugin_sdk/PluginAbi.h — 그 아래의 순수 C ABI.

저장소에 함께 제공되는 참조 플러그인(문서처럼 읽어보세요): Plugins/ExamplePlugin/, Plugins/Radar/, Plugins/KillCount/, Plugins/NinjaPricer/.


2. Hello-world 플러그인

로드되어 호스트 로그에 메시지를 출력하는 최소한의 플러그인입니다.

#define PLUGIN_EXPORTS
#include "POEFixer/plugin_sdk/PluginSDK.h"

class HelloPlugin : public PluginSDK::Plugin {
public:
    const char* GetName() const override { return "Hello"; }
    void OnEnable(bool) override { ctx()->Log.Info("Hello, world"); }
};

extern "C" PLUGIN_API PluginSDK::Plugin* CreatePlugin()  { return new HelloPlugin(); }
extern "C" PLUGIN_API void               DestroyPlugin(PluginSDK::Plugin* p) { delete p; }

Plugins/Hello/Hello.dll로 빌드하고, 호스트를 재시작한 다음, Plugins 탭에서 활성화합니다.


3. 프로젝트 설정

Plugins/ExamplePlugin/ExamplePlugin.vcxproj를 표준 템플릿으로 사용하세요. 필수 설정은 다음과 같습니다.

  • Configuration type: DynamicLibrary
  • Platform toolset: v143 (Visual Studio 2022)
  • Character set: Unicode
  • Language standard: stdcpp20
  • Runtime library: MultiThreadedDLL (Release) / MultiThreadedDebugDLL (Debug). 호스트와 반드시 일치해야 합니다.
  • Preprocessor definitions: PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS
  • Additional include directories: $(SolutionDir)POEFixer
  • Output directory: $(SolutionDir)x64\Release\Plugins\<YourPlugin>\
  • Target name: 폴더 이름과 일치해야 합니다 (Plugins/MyPlugin/MyPlugin.dll)

호스트는 Plugins/ 내의 각 하위 폴더를 스캔하여 <FolderName>.dll을 찾습니다. DLL은 세 가지 심볼을 내보내야 합니다.

  • CreatePlugin — 팩토리; PluginSDK::Plugin*을 반환합니다.
  • DestroyPlugin — 소멸자; PluginSDK::Plugin*을 받습니다.
  • PluginSDK_AttachHostContext를 연결합니다. PluginSDK.h 내부에 정의되어 있으며, PLUGIN_EXPORTS가 설정되면 자동으로 생성됩니다.

권장 소스 레이아웃:

Plugins/YourPlugin/
  YourPlugin.vcxproj
  YourPlugin.vcxproj.filters
  src/
    YourPlugin.cpp        // class YourPlugin : public PluginSDK::Plugin
    YourPluginSettings.h  // Save()/Load() POCO
  config/                 // runtime-created by SaveSettings()
    settings.json

Directory()는 호스트 EXE 디렉터리를 기준으로 한 절대 UTF-8 경로를 반환합니다 — EXE 경로를 직접 앞에 붙이지 마세요. 디렉터리 문자열은 PluginSDK::Plugin 내부에 값으로 소유되므로, 호스트의 컨테이너 재할당이나 리로드 주기에 영향을 받지 않고 수명이 보장됩니다.

ImGui를 그리려면 <ClCompile>에도 다음을 추가하세요(호스트도 이들을 링크하지만, 플러그인 측 ImGui는 DLL별로 분리되어 있습니다).

..\..\POEFixer\imgui\imgui.cpp
..\..\POEFixer\imgui\imgui_draw.cpp
..\..\POEFixer\imgui\imgui_tables.cpp
..\..\POEFixer\imgui\imgui_widgets.cpp

OnEnable에서 호스트의 ImGui 컨텍스트에 연결합니다.

if (ctx()->ImGuiContext)
    ImGui::SetCurrentContext(static_cast<ImGuiContext*>(ctx()->ImGuiContext));

4. 수명주기 훅

PluginSDK::Plugin은 가상 베이스 클래스입니다. 플러그인에서 다음 항목들을 오버라이드하세요(대략 호출 순서대로 정리됨).

메서드 호출 시점 일반적인 용도
const char* GetName() const 생성 직후 한 번 플러그인 표시 이름 반환
void OnEnable(bool isGameAttached) 사용자가 플러그인을 활성화할 때(또는 영구 저장된 경우 시작 시) 설정 로드, 이벤트 구독, ImGui 컨텍스트 연결
void DrawSettings() 플러그인 설정 패널이 열려 있는 동안 매 프레임 설정용 ImGui 컨트롤
void DrawUI() 플러그인이 활성화된 동안 매 프레임 ImGui 오버레이 그리기 (게임 오버레이용으로는 ImGui::GetBackgroundDrawList() 사용)
bool WantsOverlay() const 매 프레임 폴링 호스트가 오버레이(클릭-스루) 모드이길 원하면 true 반환
void SaveSettings() 주기적(~5초) 및 비활성화 시 설정을 디스크에 영구 저장
void OnDisable() 사용자가 비활성화하거나 호스트 종료 시 리소스 해제, 이벤트 구독 해제

GetName만 필수이며, 나머지는 안전한 기본값이 있습니다.

호스트는 또한 CreatePlugin 직후 GetSDKVersion()을 호출하여(베이스에 정의되어 있으며 오버라이드하지 마세요) 플러그인과 호스트의 일치 여부를 확인합니다. 불일치 시 플러그인이 거부됩니다.


5. Context

ctx()const PluginSDK::Context*를 반환하며, 16개의 서비스를 집합한 것입니다.

struct Context {
    GameService       Game;        // snapshot, state flags, screen size
    EntitiesService   Entities;    // enumerate, find-by-id, watch
    ComponentsService Components;  // 21 component readers + collection enumerators
    InventoryService  Inventory;   // scan + iterate + per-item helpers
    UiService         Ui;          // tree walk, FindPanelByStringId, screen-rect
    RenderService     Render;      // WorldToScreen + isometric map projection
    TerrainService    Terrain;     // walkable grid (RAII), height, TGT locations
    MemoryService     Memory;      // direct memory primitives (last resort)
    LogService        Log;         // Debug/Info/Warn/Error
    EventsService     Events;      // Subscribe / Unsubscribe / On<X>
    OverlayService    Overlay;     // SetIncludeSleepingEntities / SetWantsOverlayInput
    FlasksService     Flasks;       // life/mana flasks + charms: charges, usable, active
    PricesService     Prices;       // poe2scout item prices (host-loaded once, shared)
    RuneshapeService  Runeshape;    // Expedition2Encounter devices + per-device rewards
    AtlasService      Atlas;        // endgame-atlas nodes / adjacency / Rite selection / weights
    SekhemaService    Sekhema;      // Trial-of-the-Sekhemas floor graph / choices / content FKs
    void* ImGuiContext;            // pass to ImGui::SetCurrentContext
    void* D3DDevice;               // ID3D11Device* for texture loading
};

각 서비스가 무엇에 사용되는지 한눈에 보기:

서비스 사용 목적
GameService 스냅샷, 상태 플래그, 화면 + 창 정보
EntitiesService 열거, id로 조회, watch 수명주기
ComponentsService 21개의 리더 + 4개의 열거자 + 약 10개의 편의 헬퍼
InventoryService 스캔, 열거, 아이템별 모드 읽기
UiService 트리 순회, FindPanelByStringId, ComputeScreenRect
RenderService WorldToScreen, GridTo{Large,Mini}Map, 좌표 변환
TerrainService 보행 가능 + 높이 그리드(RAII), TGT 위치
MemoryService RPM 기본 함수 — 더 상위 레벨의 호출이 맞지 않을 때만 사용
LogService Debug / Info / Warn / Error
EventsService Subscribe / Unsubscribe / On{Area,Frame,Attach,Detach}
OverlayService SetIncludeSleepingEntities / SetWantsOverlayInput — 맵 피커 친화적
FlasksService 생명/마나 플라스크 + 장신구 — 충전, Usable, Active, 사용당, 모드 수
PricesService LookupPrice / GetRates / GetStatus — 호스트가 로드한 poe2scout 가격, 모든 플러그인이 공유
RuneshapeService Runeshapes / Rewards — 해결된 Expedition2Encounter 장치 + 장치별 보상
AtlasService GetPanel / Nodes / Connections / Selection / GetLineSeed / Weights — 엔드게임 아틀라스 패널의 라이브 데이터
SekhemaService GetPanel / GetFloor / Rooms / Content / 방 플래그 읽기 — Trial of the Sekhemas 층 맵 데이터

ctx()는 호스트가 OnEnable을 호출하는 순간부터 OnDisable이 반환될 때까지 유효합니다. 핫 리로드나 DLL 언로드 경계를 넘어 ctx()를 캐시하지 마세요.


6. 게임 상태 읽기

ctx()->Game.GetSnapshot()은 값 타입의 Snapshot을 반환합니다 — 현재 프레임의 완전한 불변 뷰입니다. GetSnapshot()은 반환 전에 abi->entities.enumerate를 순회하여 snap.Entities를 채우므로, 비용은 근처 엔티티 수에 비례합니다. 프레임당 한 번 호출하고 재사용하세요.

PluginSDK::Snapshot snap = ctx()->Game.GetSnapshot();
if (snap.State != PluginSDK::GameState::InGame) return;

ctx()->Log.Info(snap.CurrentAreaName.c_str());
if (snap.IsTown || snap.IsHideout) return;  // safe area

// snap.Vitals.HPPercent, snap.Vitals.MaxES, snap.Vitals.IsPaused
// snap.Player.GridPositionX, snap.Player.Path (wstring), snap.Player.Components
// snap.Entities is a std::vector<Entity> — every nearby entity, fully populated
// snap.LargeMap / snap.MiniMap — visibility + projection inputs
// snap.AreaChangeCounter — increments each portal transition

스냅샷이 직접 담고 있는 것(추가 서비스 호출이 필요 없음):

  • 상태 + 플래그: State, IsAttached, IsWindowValid, GameWindowForeground, IsTown, IsHideout, IsPaused, IsSkillTreeVisible.
  • 영역: CurrentAreaName, CurrentAreaHash, CurrentAreaLevel, AreaChangeCounter.
  • 월드: Player (전체 Entity), Entities (전체 std::vector<Entity>), Vitals, LargeMap, MiniMap, WorldToScreenMatrix[16].
  • 윈도우: ScreenWidth, ScreenHeight, ProcessId, GameWindow, LastUpdateTime, WorldToGridConvertor.

스냅샷에 없는 것 — 서비스를 통해 가져오세요: 인벤토리 내용물(InventoryService), 버프(ComponentsService::EnumerateBuffs), 아이템별 모드 목록(InventoryService::ReadItemMods), UI 패널(UiService).

전체 스냅샷이 필요하지 않을 때의 가벼운 헬퍼들:

if (ctx()->Game.IsInGame())       { ... }
if (ctx()->Game.IsForeground())   { ... }   // game window focused
if (ctx()->Game.IsOverlayMode())  { ... }   // host is in overlay (click-through)
if (ctx()->Game.IsMenuVisible())  { ... }   // ESC menu, settings, etc.
auto sz = ctx()->Game.GetScreenSize();       // ScreenSize { Width, Height } floats
HWND hw = ctx()->Game.GetGameWindow();
DWORD pid = ctx()->Game.GetProcessId();
PluginSDK::GameState st = ctx()->Game.GetState();

GetHiveblood는 Genesis 트리(Hiveblood) 리소스 카운터를 읽습니다 — GameService를 통해 라우팅되는 host-tail 읽기입니다(GetGold / GetAreaId와 같은 계열):

int32_t hiveblood = 0;
if (ctx()->Game.GetHiveblood(hiveblood)) {
    // in a Genesis map: hiveblood holds the current resource count
}
// Returns false (leaving the out param untouched) when not in game, the chain
// is broken, or the host predates the tail function — always gate on the return.

7. 컴포넌트 읽기

엔티티는 entity.Components를 통해 컴포넌트를 노출합니다 — uintptr_t 주소들로 이루어진 ComponentAddresses 구조체입니다. 각 주소를 해당하는 ComponentsService::Read*에 전달하여 값 타입 스냅샷을 얻습니다.

for (const auto& e : snap.Entities) {
    if (!e.Components.HasLife()) continue;
    PluginSDK::Life life = ctx()->Components.ReadLife(e.Components.Life);
    if (life.Valid && life.Health.Current > 0) {
        ctx()->Log.Info("alive monster");
    }
}

21개의 컴포넌트 리더가 있습니다: ReadLife, ReadRender, ReadPositioned, ReadTargetable, ReadChest, ReadShrine, ReadStack, ReadCharges, ReadPlayer, ReadAnimated, ReadTransitionable, ReadTriggerableBlockage, ReadMinimapIcon, ReadStateMachine, ReadBase, ReadMods, ReadStats, ReadBuffs, ReadActor, ReadNpc, ReadDiesAfterTime.

ComponentAddresses 자체는 24개의 슬롯을 가집니다: 위의 21개에 세 개의 마커(Buffs, WorldItem, AreaTransition)와 OMP(호스트 내부용)가 더해진 것입니다. Buffs는 존재 여부 마커입니다 — 실제 버프 목록은 EnumerateBuffs에서 가져옵니다. WorldItem / AreaTransition은 실제 컴포넌트가 아니라 엔티티 유형 마커입니다. 모든 슬롯에는 ComponentAddresses 상의 일치하는 HasX() 술어가 있습니다.

가변 크기 데이터를 가지는 컴포넌트용 컬렉션 스타일 리더:

auto buffs  = ctx()->Components.EnumerateBuffs(e.Components.Buffs);            // std::vector<Buff>
auto skills = ctx()->Components.EnumerateActiveSkills(e.Components.Actor);     // std::vector<ActiveSkill>
auto stats  = ctx()->Components.EnumerateStats(e.Components.Stats);            // std::vector<StatEntry>
auto mods   = ctx()->Components.EnumerateItemMods(e.Components.Mods);          // std::vector<Mod>

편의용 헬퍼(원샷 — 내부적으로 Read*를 호출합니다):

float hpPct  = ctx()->Components.GetHealthPercent(e.Components.Life);
bool  alive  = ctx()->Components.IsAlive(e.Components.Life);
float esPct  = ctx()->Components.GetEsPercent(e.Components.Life);
float mpPct  = ctx()->Components.GetManaPercent(e.Components.Life);
int   rarity = ctx()->Components.GetItemRarity(e.Components.Mods);
bool  ident  = ctx()->Components.IsItemIdentified(e.Components.Mods);
int   stack  = ctx()->Components.GetStackCount(e.Components.Stack);
bool  open   = ctx()->Components.IsChestOpened(e.Components.Chest);
std::string name = ctx()->Components.GetPlayerName(e.Components.Player);
float wx, wy, wz;
if (ctx()->Components.GetWorldPosition(e.Components.Render, wx, wy, wz)) { ... }

반환되는 모든 구조체에 Valid 플래그가 있어 예외 없이 "컴포넌트 주소가 0이었거나 / 읽기 실패" 상황을 처리할 수 있습니다. 이미 상위 구조체(Life, Mods, …)를 가지고 있다면 헬퍼를 다시 호출하지 말고 그 필드에 직접 접근하세요 — 헬퍼는 매번 컴포넌트를 다시 읽습니다.

지면 효과 구분 — 많은 지면 효과는 단일 엔티티 경로 Metadata/Effects/Spells/ground_effects/VisibleServerGroundEffect를 공유하므로, 경로만으로는 감전된 땅과 불타는 땅을 구분할 수 없습니다. ReadGroundEffect는 엔티티의 GroundEffect 컴포넌트와 groundeffects.datc64 행을 해결합니다. 엔티티 주소를 전달하세요(GroundEffect 컴포넌트는 Components에 없으므로, 호스트가 대신 해결해줍니다 — ReadPathfinding과 동일한 규칙), 그런 다음 패치에 영향받지 않는 안정적인 키인 TypeId로 매칭하세요:

for (const auto& e : snap.Entities) {
    if (e.Path != L"Metadata/Effects/Spells/ground_effects/VisibleServerGroundEffect") continue;
    PluginSDK::GroundEffect ge = ctx()->Components.ReadGroundEffect(e.Address);
    if (!ge.Valid) continue;
    // ge.TypeId -> "ShockedGround" / "IgnitedGround" / "CausticCloud" / "ChilledGround" / ...
    // ge.Radius -> 월드 단위; (e.WorldX, e.WorldY, e.WorldZ)에 이 반경으로 원을 그립니다
    if (ge.TypeId == "ShockedGround") {
        // 설정에 따라 강조 표시 (ge.TypeId로 색상/알파 지정)
    }
}

GroundEffect 구조체:

필드 의미
Valid 엔티티에 GroundEffect 컴포넌트가 없거나 읽기에 실패하면 false
TypeId groundeffecttypes Id — 매칭할 안정적인 키 (예: ShockedGround)
Radius 월드 단위 효과 반경; 변형이 미설정이면 0
EndEffect 종료 동작: fadeout / close / end
BuffVisual1 buffvisuals Id (예: ground_fire_burn_white); 미설정이면 빈 값
BuffVisual2 buffdefinitions Name (예: ground_tar_gold); 미설정이면 빈 값
AoFile 첫 번째 .ao/.aoc 비주얼 경로; 없으면 빈 값
GroundEffectsRowAddr / GroundEffectTypesRowAddr 원시 dat-행 포인터 (세션 안정적), 고급 크로스-레퍼런스용

효과의 월드 위치는 엔티티 자체(Entity.WorldX/Y/Z 또는 Render/Positioned 컴포넌트)에서 가져오므로 구조체에 중복되지 않습니다. ReadGroundEffect는 호출할 때마다 다시 읽으므로, 스캔 간격마다 결과를 캐시하세요. 이 API가 도입되기 전에 빌드된 호스트에서는 유효하지 않은 GroundEffect를 반환합니다(SDK v6 추가 전용 테일에 있으며 null 체크됨).

ActiveSkill 필드

EnumerateActiveSkills(actorAddr)는 엔티티의 Actor에 부여되거나 소켓된 스킬마다 하나의 ActiveSkill을 반환합니다. Name 외에도, 이 구조체는 쿨다운 상태와 디코딩된 젬-소켓 디스크립터를 담고 있습니다:

필드 의미
Name 스킬 내부 이름
CurrentSize / TotalUses / UseStage 스테이지 / 사용 카운터 (원시 값)
CastType 원시 시전 타입 id
TotalCooldownMs 전체 쿨다운 지속시간, 밀리초
CanBeUsed 호스트의 "지금 사용 가능" 플래그
MaxUses 스킬이 보유한 쿨다운 충전 수 (0 = 쿨다운에 묶이지 않음)
TotalActiveCooldowns 현재 쿨다운 중인 충전 수. MaxUses > 0일 때 남은 사용 횟수 = MaxUses - TotalActiveCooldowns.
GrantedEffectsPerLevelAddr, ActiveSkillsDatAddr, GrantedEffectStatSetsPerLevelAddr, SkillDetailsAddr 원시 DAT-행 주소 — 더 깊은 스킬 데이터를 위해 ctx()->Memory.Read*에 전달하세요.
EquipmentInfoPacked 원시 패킹된 젬/소켓 워드 (아래 Equipment로 디코딩됨).

패킹된 워드는 skill.Equipment로 미리 디코딩되어 제공됩니다:

Equipment 필드 의미
GemNameHash 상위 16비트 — 젬 식별 해시
InventorySlot 젬이 위치한 1-기반 장비 슬롯
LinkIndex 아이템 내 링크-그룹 인덱스
SocketIndex 링크 그룹 내 소켓 인덱스
UnknownFlag / CanBeOnPlayerItem 잔여 플래그 (비트 레이아웃은 PluginSDK.h 참고)
auto skills = ctx()->Components.EnumerateActiveSkills(e.Components.Actor);
for (const auto& s : skills) {
    if (s.MaxUses > 0) {
        int remaining = s.MaxUses - s.TotalActiveCooldowns;
        ctx()->Log.Info((s.Name + ": " + std::to_string(remaining) +
                         "/" + std::to_string(s.MaxUses) + " charges").c_str());
    }
    // s.Equipment.LinkIndex / s.Equipment.SocketIndex — where the gem sits
}

스킬별 평가된 스탯 & DPS (EnumerateSkillStats)

EnumerateSkillStats(skillDetailsAddr)는 하나의 스킬에 대해 게임 자체가 평가한 스탯 컨테이너를 노출합니다 — 게임 내 스킬 패널이 보여주는 DPS 계열도 포함됩니다. 동일 프레임EnumerateActiveSkills 결과에서 얻은 ActiveSkill::SkillDetailsAddr를 전달하세요 (스킬 주소는 프레임/지역 전환 사이에 오래된 값이 됩니다 — 오래된 주소는 안전하게 빈 벡터를 반환하며, 이 API 이전에 빌드된 호스트 역시 마찬가지입니다).

반환되는 각 SkillStatEntry{SetIndex, StatId, Value}입니다:

  • SetIndex 0은 스킬의 현재 컨텍스트 스탯 세트입니다 — 모든 스킬에 존재하며, 지속적이고(패널을 닫아도 유지됨), 스킬 패널 DPS 라인의 정확한 소스입니다.
  • 이후의 세트들은 스킬의 파트별 스탯 세트입니다 — 소환/커맨드 스킬의 경우 미니언 측 스탯이 여기에 있습니다.
  • StatIdStats.dat 행 인덱스 + 1입니다 (엔진의 런타임 스탯 키; 0은 게임의 "스탯 없음" 센티널입니다). 이름은 Stats.dat를 덤프하여 확인하세요.
  • Value는 원시 int32입니다. 많은 DPS 계열 스탯은 ×100 고정소수점입니다.

유용한 런타임 id:

StatId 스탯 (Stats.dat 행 + 1) 스케일링
691 hundred_times_attacks_per_second ×100
692 hundred_times_damage_per_second ×100
695 hundred_times_casts_per_second ×100
1982 / 1983 hundred_times_average_damage_per_hit / ..._per_skill_use ×100
694 base_spell_cast_time_ms ms
2079 skill_show_average_damage_instead_of_dps flag
auto skills = ctx()->Components.EnumerateActiveSkills(snap.Player.Components.Actor);
for (const auto& s : skills) {
    for (const auto& st : ctx()->Components.EnumerateSkillStats(s.SkillDetailsAddr)) {
        if (st.StatId == 692) {  // hundred_times_damage_per_second
            ctx()->Log.Info((s.Name + " DPS: " +
                             std::to_string(st.Value / 100.0)).c_str());
        }
    }
}

알아두어야 할 주의사항:

  • DPS 계열은 가상(virtual)입니다. 엔진은 콜백을 통해 이 스탯들을 계산하며(DPS = rate/100 × avg damage), 표시했던 컨텍스트에 대해서만 결과를 저장합니다. 현재 컨텍스트 세트는 게임 자체가 마지막으로 평가한 값을 담고 있습니다. 대체 컨텍스트(주입 툴팁 탭, 무기 교체 미리보기)는 호버 시 일시적으로만 평가되며 지속적으로 읽을 수 없습니다. 따라서 동적 피해 버프를 가진 몬스터에서는 값이 실시간 툴팁보다 몇 퍼센트 뒤처질 수 있습니다 — 게임 자체의 스킬 목록과 툴팁도 서로 동일하게 어긋납니다.
  • 미니언 DPS는 미니언에 있습니다. 소환 스킬 자체의 세트는 소환수만 설명합니다. 툴팁의 "기본 공격" 수치는 미니언 엔티티의 Actor에서 나옵니다 — 엔티티를 열거해 우호적인 몬스터를 찾은 다음, 그 공격 스킬에 대해 EnumerateActiveSkills(minion.Components.Actor)EnumerateSkillStats(...)를 사용하세요.
  • EnumerateActiveSkills스킬 이름당 두 개의 항목을 반환합니다 (다른 평가 컨텍스트, 예: 무기 세트) — 특정 스탯을 찾고 있다면 둘 다 조회하세요.

엔티티 필드

모든 Entity(snap.Playersnap.Entities의 멤버 포함)는 동일한 필드 집합을 가집니다.

그룹 필드
식별 Id, Address, EntityDetailsAddress, RenderComponentAddress, IsValid
분류 EntityType, EntitySubtype, EntityState, Rarity, Reaction, Zone (NearbyZone: InnerCircle≈60 / OuterCircle≈120 / Far)
위치 GridPositionX, GridPositionY, TerrainHeight, WorldX/Y/Z, ModelBoundsZ
빠른 생명력 수치 CurrentHP, MaxHP, CurrentES, MaxES (합계만 필요한 경우 ReadLife를 피할 수 있음)
문자열 Path (std::wstring, Metadata/...), PlayerName (std::wstring), TgtPath (std::string, asset path)
상태 IsSleeping, IsChestOpened
컴포넌트 Components (ComponentAddresses sub-struct)

특정 엔티티 감시

프레임을 가로질러 한 엔티티(예: 플레이어가 여는 상자)를 추적해야 하고, 매 프레임 전체 엔티티 목록을 스캔하고 싶지 않다면 watch를 등록하세요.

ctx()->Entities.Watch(entityId);
// ...later:
if (auto opt = ctx()->Entities.GetWatchedComponents(entityId)) {
    PluginSDK::ComponentAddresses comps = *opt;
    PluginSDK::Life l = ctx()->Components.ReadLife(comps.Life);
}
bool active = ctx()->Entities.IsWatched(entityId);
ctx()->Entities.Unwatch(entityId);

FindById(id)는 원샷 조회를 위해 std::optional<Entity>를 반환하며, GetPlayer()는 항상 로컬 플레이어를 반환합니다.

바닥 아이템 (WorldItem 컨테이너)

바닥에 떨어진 아이템은 snap.EntitiesMetadata/MiscellaneousObjects/WorldItem 경로의 EntityType::Item 엔티티로 나타납니다. 이들은 컨테이너 엔티티입니다 — Mods / Base / Stack / Sockets을 직접 가지고 있지 않습니다. 실제 아이템 엔티티는 한 단계의 간접 참조 뒤에 있습니다.

내부 아이템 엔티티를 일반 Entity 스냅샷으로 얻으려면 Entities.GetWorldItemInner를 사용하세요:

for (const auto& e : snap.Entities) {
    if (e.EntityType != PluginSDK::EntityType::Item) continue;
    auto inner = ctx()->Entities.GetWorldItemInner(e.Address);
    if (!inner) continue;  // mid-spawn, retry next frame
    // inner->Path        — "Metadata/Items/Armours/Gloves/..."
    // inner->Components  — Mods / Base / Stack / Sockets / etc.
    PluginSDK::Mods mods = ctx()->Components.ReadMods(inner->Components.Mods);
    int iLvl   = mods.ItemLevel;
    int rarity = mods.Rarity;
}

GetWorldItemInner는 실제 WorldItem 컨테이너에서만 성공합니다 — 인벤토리 아이템 주소로 호출하면 std::nullopt를 반환합니다. 인벤토리 아이템과 동일한 데이터 형태를 원한다면(수동으로 컴포넌트를 탐색하지 않고), 다음 섹션의 Inventory.ReadItem* 계열이 WorldItem 컨테이너를 투명하게 자동 해결합니다.


8. 인벤토리

ctx()->Inventory.Scan(inventoryId)는 호스트 측 재스캔을 트리거합니다. 모든 인벤토리를 스캔하려면 -1을 사용하세요.

ctx()->Inventory.Scan(-1);
std::vector<PluginSDK::Inventory> all = ctx()->Inventory.GetAll();

for (const auto& inv : all) {
    const char* name = ctx()->Inventory.GetName(inv.InventoryId);
    ctx()->Log.Info(name);
    for (const auto& item : inv.Items) {
        ctx()->Log.Info(item.BaseTypeName.c_str());
        // item.SlotX, item.SlotY, item.Width, item.Height (grid metrics)
        // item.Rarity, item.ItemLevel, item.RequiredLevel, item.CraftedModCount
        // item.IsIdentified, item.IsCorrupted, item.IsCurrency
        // item.Path (Metadata/Items/...), item.BaseTypeName, item.UniqueName
        // item.Address — entity address for direct lookups below
    }
}

Inventory는 또한 인벤토리가 화면에 그려지는 위치를 설명하는 Grid 구조체를 노출합니다.

if (inv.Grid.Valid) {
    float originX = inv.Grid.GridScreenX;
    float originY = inv.Grid.GridScreenY;
    float cell    = inv.Grid.CellSize;
    // Slot (x, y) screen-space top-left = (originX + x*cell, originY + y*cell)
}

id로 단일 인벤토리를 가져오려면(이미 채워진 Items와 함께 동일한 구조체를 반환):

PluginSDK::Inventory backpack = ctx()->Inventory.Get(/*inventoryId=*/0);

또는 래핑 구조체 없이 아이템 벡터만 원한다면:

std::vector<PluginSDK::InventoryItem> items = ctx()->Inventory.GetItems(0);

아이템 모드: 두 API, 두 범위

ComponentsService::ReadMods(addr)는 요약 플래그만 반환합니다(IsCorrupted, IsRelic, IsSplit, IsMirrored, IsSynthesised, IsIdentified, Rarity, ItemLevel, RequiredLevel, CraftedModCount). 종류별 모드 목록은 포함되지 않습니다.

전체 그림(요약 + 모드 목록)을 위해서는 InventoryService::ReadItemMods(entityAddr)를 사용하세요.

PluginSDK::ItemMods im = ctx()->Inventory.ReadItemMods(item.Address);
if (!im.Valid) return;
// Same summary fields as the Mods component, plus:
for (const auto& m : im.ImplicitMods)  { ... }   // std::vector<Mod>
for (const auto& m : im.ExplicitMods)  { ... }
for (const auto& m : im.EnchantMods)   { ... }
for (const auto& m : im.HellscapeMods) { ... }
for (const auto& m : im.CrucibleMods)  { ... }

이미 아이템 주소를 보유한 경우(재스캔보다 저렴한) 기타 엔티티별 직접 읽기:

int          rarity = ctx()->Inventory.ReadItemRarity(item.Address);
int          stack  = ctx()->Inventory.ReadItemStackCount(item.Address);
std::string  base   = ctx()->Inventory.ReadItemBaseTypeName(item.Address);
std::string  uniq   = ctx()->Inventory.ReadItemUniqueName(item.Address);
std::string  path   = ctx()->Inventory.ReadItemPath(item.Address);

인벤토리 API를 통한 바닥 아이템. 위의 일곱 가지 Inventory.ReadItem* 읽기 (그리고 ReadItemMods)는 인벤토리 아이템 주소와 WorldItem 컨테이너 주소를 모두 받습니다. 컨테이너 주소는 읽기 전에 자동으로 내부 아이템으로 해결되므로, 동일한 플러그인 코드 경로가 가방 안의 아이템과 바닥의 아이템 모두에 대해 작동합니다:

// `addr` may be either an inventory item address or a WorldItem container.
PluginSDK::ItemMods im = ctx()->Inventory.ReadItemMods(addr);
int          rarity   = ctx()->Inventory.ReadItemRarity(addr);
std::string  baseName = ctx()->Inventory.ReadItemBaseTypeName(addr);

내부 아이템의 컴포넌트 주소가 직접 필요한 경우 (예: ctx()->Components.ReadStack(...)을 호출하거나 소켓을 탐색하는 경우), 대신 섹션 7의 Entities.GetWorldItemInner를 사용하세요.

게임 내 스타일 모드 텍스트 + 기본 / 집계 스탯 (v6, 2026-06-24). 스탯 키를 게임 내 툴팁에 표시되는 것과 동일한 텍스트로 포맷하고, 아이템의 기본 방어 수치와 집계된 지도/결계석 속성을 읽습니다.

// 게임이 표시하는 방식으로 모드를 렌더링합니다 ("19% increased Monster Damage").
for (const auto& m : im.ExplicitMods) {
    std::string text = ctx()->Inventory.FormatStat(m.StatKey, m.Value0, m.Value1);
    if (!text.empty()) { /* `text`를 그립니다 */ }
}

// 아이템 기본 방어 수치. EnergyShield는 게임 내 (계산된) 수치이며;
// Ward/Armour/Evasion은 아이템의 기본 수치입니다. Armour 컴포넌트가 없는 아이템
// (통화, 젬, 장신구, 결계석 등)에서는 Valid == false입니다.
PluginSDK::ItemBaseStats bs = ctx()->Inventory.ReadItemBaseStats(item.Address);
if (bs.Valid) { /* bs.EnergyShield, bs.Ward, bs.Armour, bs.Evasion */ }

// 스탯 id로 키가 지정된 집계 스탯 — 예: 결계석의 아이템 희귀도 (8205),
// 무리 크기 (8206), 몬스터 희귀도 (8207), 몬스터 효과도 (8208),
// 결계석 드롭 확률 (8209).
for (const auto& [statId, value] : ctx()->Inventory.ReadItemAggregatedStats(item.Address)) {
    // statId를 라벨로 직접 매핑하세요; 값은 부호 있는 백분율입니다
}

FormatStat은 호스트의 .csd 스탯 설명 세트를 사용합니다(처음 사용 시 다운로드됨). 해당 데이터가 준비되기 전까지는 빈 문자열을 반환하므로 — 원시 Mod 필드로 폴백하세요. ReadItemBaseStats / ReadItemAggregatedStats는 모두 인벤토리 아이템 주소와 WorldItem 컨테이너 주소를 받습니다.


9. UI 트리

게임의 UI 트리는 uintptr_t 요소 주소로 노출됩니다. 루트에서 시작하여 자식을 순회하고 요소 필드를 읽습니다.

StringId로 알려진 패널을 찾는 깔끔한 방법:

uintptr_t gameUiRoot = ctx()->Ui.GetGameUiRoot();
uintptr_t invPanel   = ctx()->Ui.FindPanelByStringId(gameUiRoot, "Inventory");
if (invPanel && ctx()->Ui.IsVisible(invPanel)) {
    // panel is on-screen
}

StringId를 모를 때의 수동 트리 순회:

uintptr_t root = ctx()->Ui.GetUiRoot();
PluginSDK::UiElement e = ctx()->Ui.Read(root);
ctx()->Log.Info(("children=" + std::to_string(e.ChildCount)).c_str());

for (uintptr_t child : ctx()->Ui.GetChildren(root)) {
    std::string sid = ctx()->Ui.GetStringId(child);
    if (sid == "InventoriesPanel") { /* found it */ }
}

// Or use a known index path:
int path[] = { 5, 1, 2, 0 };
uintptr_t logInButton = ctx()->Ui.FollowPath(root, path, 4);

// Compute screen-space rect (post-scale, post-transform):
float x, y, w, h;
if (ctx()->Ui.ComputeScreenRect(invPanel, x, y, w, h)) {
    // draw an overlay box at (x,y,w,h)
}

// Get displayed text:
std::string label = ctx()->Ui.GetText(child);

int cull = ctx()->Ui.GetCullValue();  // host's UI cull threshold

StringId 값은 게임 측에서 안정적인 식별자이며, 존재한다면 하드코딩된 경로보다 선호하세요.

0.5.x 참고. Ui.GetStringId()는 현행(0.5.x) 클라이언트에서 올바른 식별자를 반환합니다 — 요소의 StringId 필드 오프셋이 이동했고(0x4480x4C0) 호스트 브리지도 이에 맞게 수정되었습니다. (트라이얼 HUD의 필드 리프가 값을 렌더링하는 숫자 StringId — GetText()와는 다른 필드 — 는 SekhemaHelperctx()->Sekhema.GetUiStringId()로 읽습니다.)


10. 렌더링 및 투영

세 가지 투영 헬퍼, 두 가지 좌표계.

원근 (3D world → screen) — 게임이 월드에 사물을 그리는 데 사용하는 동일한 투영. 이름표, 디버그 마커, 타겟 표시기에 적합합니다.

float sx, sy;
if (ctx()->Render.WorldToScreen(e.WorldX, e.WorldY, e.WorldZ, sx, sy)) {
    ImGui::GetBackgroundDrawList()->AddCircleFilled({sx, sy}, 4.f, IM_COL32(255,0,0,255));
}

아이소메트릭 (grid → minimap) — 대형 또는 미니맵에 그려지는 레이더 스타일 오버레이용. 표시되는 맵의 줌, 팬, 회전을 존중합니다.

if (!snap.LargeMap.IsVisible) return;
for (const auto& e : snap.Entities) {
    float sx, sy;
    if (ctx()->Render.GridToLargeMap(e.GridPositionX, e.GridPositionY, e.TerrainHeight, sx, sy)) {
        ImGui::GetBackgroundDrawList()->AddCircleFilled({sx, sy}, 4.f, color);
    }
}
// Mirror: ctx()->Render.GridToMiniMap(gx, gy, worldZ, sx, sy)

일괄 수학을 위해(엔티티당 함수 호출 생략) 트랜스폼을 한 번 가져와 인라인으로 투영합니다.

PluginSDK::MapTransform t = ctx()->Render.GetLargeMapTransform();
if (t.IsVisible) {
    // dx = gx - t.PlayerGridX; dy = gy - t.PlayerGridY;
    // sx = t.CenterX + (dx - dy) * t.ScaleX;
    // sy = t.CenterY + (worldZ * worldToGrid - (dx + dy)) * t.ScaleY;
}
// And ctx()->Render.GetMiniMapTransform() for the minimap.

이 호출들로만 구축된 레이더의 작동 예시는 Plugins/Radar/src/Radar.cpp를 참고하세요.


11. 지형 및 보행 가능 그리드

보행 가능 그리드는 플레이어가 밟을 수 있는 지형 셀을 나타내는 타일당 4비트 비트맵입니다. 호스트는 각 영역 변경 시 이를 업데이트하며, 플러그인은 (RAII를 통해) 플러그인이 해제할 때까지 유지되는 안정적인 핸들을 받습니다.

PluginSDK::WalkableGridHandle h = ctx()->Terrain.GetWalkableGrid();
if (h.Valid()) {
    const uint8_t* data       = h.Data();
    const int      w          = h.Width();
    const int      height     = h.Height();
    const size_t   sizeBytes  = h.SizeBytes();   // (w * height) / 2
    // POE2 packs two cells per byte:
    //   gx & 1 == 0  →  low nibble  (data[gy * (w/2) + gx/2] & 0x0F)
    //   gx & 1 == 1  →  high nibble ((data[gy * (w/2) + gx/2] >> 4) & 0x0F)
    // Non-zero nibble = walkable.
    //
    // Always bound your byte index against sizeBytes — the host enforces an
    // atomic snapshot, but defense-in-depth has caught at least one real bug.
}
// h is RAII — destructor releases the host reference automatically.

HeightGridHandle은 동일한 형태를 따르지만 타일당 하나의 float를 보유합니다(Data()const float*이며, ElementCount()SizeBytes()가 추가됨).

핸들을 새로 고치기 위해 OnAreaChange를 구독하지 마세요. 이벤트는 호스트 워커가 영역 변경을 감지할 때 발생하지만, 새 보행 가능 그리드가 아직 파싱되지 않았을 수 있습니다 — 한두 프레임 동안 오래된 포인터를 보유하게 됩니다. 대신 DrawUI에서 프레임마다 폴링하세요.

auto current = ctx()->Terrain.GetWalkableGrid();
if (current.Data() != m_walkable.Data()) {
    m_walkable = std::move(current);   // swap when the host re-parses
}

저렴한 작업입니다(ABI 호출 1개 + 포인터 비교 1개). 프로덕션 버전은 Plugins/Radar/src/Radar.cpp를 참고하세요.

기타 지형 접근자:

bool  ok        = ctx()->Terrain.IsWalkable(gx, gy);
float worldZ    = ctx()->Terrain.GetTerrainHeight(gx, gy);
float worldToG  = ctx()->Terrain.GetWorldToGridConvertor();

ctx()->Terrain.EnumerateTgtLocations([](const PluginSDK::TgtLocation& loc) {
    // loc.Path, loc.TileX, loc.TileY, loc.X, loc.Y
    return true;  // continue
});

12. 이벤트

호스트가 발생시키는 이벤트를 구독합니다. 각 Subscribe는 나중에 Unsubscribe로 전달할 수 있는 Token을 반환합니다. EventsService 소멸자(플러그인이 비활성화되거나 언로드될 때 호출됨)는 미해제 상태로 남은 모든 것을 자동으로 해제합니다 — 따라서 수동으로 구독 해제할 필요는 엄밀히 없지만, 그렇게 하는 것이 예의입니다.

class MyPlugin : public PluginSDK::Plugin {
    PluginSDK::EventsService::Token m_areaTok{};
    PluginSDK::EventsService::Token m_frameTok{};

public:
    void OnEnable(bool) override {
        auto& ev = const_cast<PluginSDK::EventsService&>(ctx()->Events);

        m_areaTok = ev.OnAreaChange([this]{
            ctx()->Log.Info("area changed");
        });

        m_frameTok = ev.OnFrame([this]{
            // called every frame; keep work cheap
        });

        ev.OnGameAttached([this]{ ctx()->Log.Info("game attached"); });
        ev.OnGameDetached([this]{ ctx()->Log.Info("game detached"); });
    }

    void OnDisable() override {
        auto& ev = const_cast<PluginSDK::EventsService&>(ctx()->Events);
        ev.Unsubscribe(m_areaTok);
        ev.Unsubscribe(m_frameTok);
    }
};

네 가지 이벤트 종류는 AreaChange, Frame, GameAttached, GameDetached입니다. 디스패치 테이블을 구축하고 싶다면 일반 Subscribe(EventKind, callback)도 있습니다.

const_cast가 필요한 이유는 Events가 내부 토큰 맵을 변경하기 때문입니다. 베이스 클래스는 다른 서비스를 실수로 변경하지 못하도록 const Context*를 반환합니다.

구독 그룹화

플러그인이 여러 구독을 소유한다면, ExamplePlugin 패턴은 활성화/비활성화 대칭을 깔끔하게 유지하는 좋은 방법입니다 — 토큰과 카운터를 단일 상태 구조체로 묶고 모든 것을 하나의 SubscribeAll/UnsubscribeAll 쌍을 통해 라우팅하세요.

struct EventsDemoState {
    std::atomic<int> frameCount{0}, areaChangeCount{0};
    PluginSDK::EventsService::Token frameTok{}, areaTok{};
    bool subscribed = false;
};

void SubscribeAll(const PluginSDK::Context* ctx, EventsDemoState& s) {
    auto& ev = const_cast<PluginSDK::EventsService&>(ctx->Events);
    s.frameTok = ev.OnFrame      ([&s]{ s.frameCount.fetch_add(1); });
    s.areaTok  = ev.OnAreaChange ([&s]{ s.areaChangeCount.fetch_add(1); });
    s.subscribed = true;
}

void UnsubscribeAll(const PluginSDK::Context* ctx, EventsDemoState& s) {
    auto& ev = const_cast<PluginSDK::EventsService&>(ctx->Events);
    ev.Unsubscribe(s.frameTok);
    ev.Unsubscribe(s.areaTok);
    s.frameTok = {}; s.areaTok = {};
    s.subscribed = false;
}

전체 패턴은 Plugins/ExamplePlugin/examples/ExampleEvents.h를 참고하세요.


13. OverlayService — 입력 캡처 및 수면 엔티티

ctx()->Overlay는 오버레이 전체 동작을 변경하는 플러그인별 두 가지 "요청 플래그"를 노출합니다. 호스트는 this 포인터로 키링된 플러그인별 상태를 저장하고, 호스트의 자체 내장 플래그(메인 메뉴 가시성, AutoCraft 잠금, 호스트 내장 맵에서 엔티티 추가 피커, 다른 모든 플러그인의 요청)와 OR-집계합니다. 플러그인 비활성화/언로드 시 자동으로 지워집니다 — 충돌하거나 버그가 있는 플러그인이 오버레이를 이상한 상태로 영구적으로 고착시킬 수 없습니다.

SetIncludeSleepingEntities

기본적으로 EntitiesService.EnumerateSnapshot.EntitiesEntityState::Useless 상태의 엔티티를 숨깁니다(호스트의 "수면" 필터는 멀리 떨어진 휴면 몬스터/NPC/상자를 제외합니다). 이를 통해 프레임당 스냅샷 비용을 제한합니다 — 일반적인 지역에는 플러그인이 신경 쓰지 않는 수백 개의 Useless 엔티티가 있습니다.

지역의 전체 엔티티 풀이 필요한 맵 피커 UI 및 디버그 뷰어(사용자가 아직 활성화되지 않은 엔티티를 클릭할 수 있어야 하는 경우)에서는 필터를 끄세요:

ctx()->Overlay.SetIncludeSleepingEntities(true);
// 이제 ctx()->Entities.Enumerate도 Useless 엔티티를 볼 수 있습니다.
// Entity::IsSleeping은 호스트의 별도 SleepingEntities 컬렉션에서
// 온 것들을 특별히 표시합니다 (EntityState::Useless와 직교).

비용: 활성화 중 프레임당 스냅샷 CPU 약 +5–15%. 필요하지 않으면 끄세요.

SetWantsOverlayInput

오버레이 창은 기본적으로 클릭-스루 (WS_EX_TRANSPARENT)입니다: 모든 마우스 클릭이 아래 게임으로 바로 전달됩니다. 이것은 읽기 전용 오버레이(레이더, 체력 바, DPS 표시)의 올바른 기본값입니다 — 플레이어는 오버레이를 알아채지 못한 채 계속 플레이할 수 있습니다.

플러그인이 사용자가 오버레이 내의 무언가를 클릭하기를 원하는 순간 — 팝업 확인, 맵에서 엔티티 선택, 마커 드래그 — 해당 기본값이 깨집니다. SetWantsOverlayInput(true)는 호스트에게 ImGui 창이 덮고 있는 곳에서 마우스 클릭을 받아들이기 시작하도록 요청합니다:

ctx()->Overlay.SetWantsOverlayInput(true);
// ...
// 완료 시 (사용자가 선택하거나, 팝업을 닫거나, Escape를 누를 때):
ctx()->Overlay.SetWantsOverlayInput(false);

호스트의 프레임별 로직은 커서가 보이는 ImGui 창 위에 있을 때만 클릭을 받아들입니다 — 그 외의 곳에서는 클릭-스루가 유지되어 플레이어가 팝업 주변에서 이동/공격/루팅을 계속할 수 있습니다.

범위 (중요):

  • 마우스 버튼 (LMB/RMB): 예 — 이 플래그로 제어됩니다.
  • 마우스 위치/호버: 이 플래그와 관계없이 항상 작동합니다. 호버 툴팁은 이 플래그가 필요 없습니다.
  • 키보드: 이 플래그와 독립적으로 호스트의 WindowProc을 통해 항상 플러그인에 전달됩니다. ImGui::IsKeyPressed(ImGuiKey_Escape)는 어느 쪽에서나 작동합니다.

배경 드로우 목록 주의사항 — 맵 피커 플러그인 필독

ImGui::GetBackgroundDrawList()를 통해 클릭 가능한 마커를 그린다면(레이더/대형 맵 오버레이에서 일반적), 배경 드로우 목록에는 ImGui 창 백킹이 없습니다. 호스트의 히트 테스트가 ctx->Windows를 순회하여 커서 아래에서 아무것도 찾지 못하고 클릭-스루를 다시 활성화합니다 — 마커는 보이지만 클릭할 수 없습니다.

수정 방법: 피커 영역을 덮는 실제 ImGui 창을 열고 그 안에 ImGui::InvisibleButton을 넣으세요. 창이 호스트가 히트 테스트하는 것이고, InvisibleButton이 클릭 감지를 위한 ImGui::IsItemClicked()를 제공합니다. 스케치:

auto snap = ctx()->Game.GetSnapshot();
ImVec2 screenSize{(float)snap.ScreenWidth, (float)snap.ScreenHeight};

ImGui::SetNextWindowPos({0, 0});
ImGui::SetNextWindowSize(screenSize);
ImGui::Begin("##picker", nullptr,
    ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoTitleBar |
    ImGuiWindowFlags_NoMove       | ImGuiWindowFlags_NoResize    |
    ImGuiWindowFlags_NoScrollbar);

ImGui::InvisibleButton("##picker_hit", screenSize);
bool clickedThisFrame = ImGui::IsItemClicked();

// ImGui::GetForegroundDrawList() (또는 GetWindowDrawList())를 통해 마커를 그립니다:
ImDrawList* dl = ImGui::GetForegroundDrawList();
ctx()->Terrain.EnumerateTgtLocations([&](const auto& tgt) {
    float sx, sy;
    if (ctx()->Render.GridToLargeMap(tgt.X, tgt.Y, 0.f, sx, sy)) {
        ImVec2 mp = ImGui::GetMousePos();
        bool hovered = std::hypot(mp.x - sx, mp.y - sy) < 12.f;
        dl->AddCircleFilled({sx, sy}, 8.f, hovered ? 0xFF00FFFF : 0xFFFFFF00);
        if (clickedThisFrame && hovered) {
            // 사용자가 이 POI를 선택함
        }
    }
    return true;
});
ImGui::End();

피커 영역만 덮는 작은 창도 동일하게 작동합니다 — 호스트는 크기가 아니라 커서 아래에 창이 있는지만 확인합니다.

실제 예제 — 맵에서 POI 추가

class RadarPlugin : public PluginSDK::Plugin {
    bool m_pickerMode = false;

    void DrawUI() override {
        if (!ctx()->Game.IsInGame()) return;
        ImGui::SetCurrentContext((ImGuiContext*)ctx()->ImGuiContext);

        // 단축키 토글. 키보드는 캡처 상태와 관계없이 플러그인에 전달되므로,
        // 오버레이가 클릭-스루일 때도 작동합니다.
        if (ImGui::IsKeyPressed(ImGuiKey_F8, /*repeat=*/false)) {
            m_pickerMode = !m_pickerMode;
            ctx()->Overlay.SetIncludeSleepingEntities(m_pickerMode);
            ctx()->Overlay.SetWantsOverlayInput     (m_pickerMode);
        }
        if (m_pickerMode && ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
            m_pickerMode = false;
            ctx()->Overlay.SetIncludeSleepingEntities(false);
            ctx()->Overlay.SetWantsOverlayInput     (false);
        }

        if (!m_pickerMode) return;
        // ... 피커 UI (ImGui::Begin + InvisibleButton 패턴은
        //     위의 배경 드로우 목록 주의사항을 참고하세요).
    }

    void OnDisable() override {
        // 이중 안전장치. 호스트도 비활성화 시 플래그를 지우지만,
        // 명시적인 정리는 OnDisable과 PluginManager 처리 사이에
        // 동기 프레임이 발생할 경우 상태를 일관되게 유지합니다.
        ctx()->Overlay.SetIncludeSleepingEntities(false);
        ctx()->Overlay.SetWantsOverlayInput     (false);
    }
};

수명주기 및 집계

  • 두 플래그 모두 멱등적입니다 — Set(true)를 연속으로 두 번 호출하면 두 번째는 no-op이며, 카운터가 두 배로 증가하지 않습니다.
  • 두 플래그 모두 호스트의 자체 상태 및 다른 모든 플러그인의 플래그와 OR-집계됩니다. 여러 플러그인이 동시에 피커 모드여도 공존할 수 있습니다.
  • 호스트는 플러그인이 비활성화될 때(Plugins 탭을 통해, 충돌로 인한 비활성화, 또는 종료) 플러그인의 모든 플래그를 자동으로 지웁니다. 프레임 중간의 충돌이 오버레이를 캡처 모드에 영구적으로 고착시키지 않습니다 — 하지만 잘 작동하는 플러그인은 여전히 켜기/끄기 호출을 짝지어서 그 사이에 다른 플러그인과 게임이 반응성을 유지할 수 있도록 합니다.
  • 레이턴시: Set 호출은 플래그를 동기적으로 업데이트하지만, 실질적인 동작 변경은 다음 호스트 프레임(오버레이 입력) 또는 다음 GameClient 워커 틱(수면 엔티티)에 나타납니다. 서브-프레임으로 관찰할 수 없습니다.
  • 모든 메서드는 어느 스레드에서나 안전하게 호출할 수 있습니다.

14. Prices — 호스트 로드 아이템 가격 정보

호스트는 백그라운드 스레드에서 poe2scout으로부터 세션당 한 번 시장 가격을 로드하고 ctx()->Prices를 통해 모든 플러그인에 제공합니다. 플러그인은 직접 가격을 가져오지 않습니다 — 내장 레이더, 호스트 오버레이, 모든 플러그인이 공유하는 단일 가격 데이터베이스가 있으므로, API는 소비자당 한 번이 아니라 한 번만 호출됩니다.

  • 가격 리그는 사용자가 구성 → 설정에서 선택(기본값은 Runes of Aldur)하고 호스트 측에서 영구 저장됩니다. 플러그인은 항상 사용자가 선택한 리그를 읽으며, 직접 선택하지 않습니다.
  • 로딩은 카테고리별 백오프를 적용하여 한 번만 실행됩니다(실패 시 1 → 5 → 10 → 20 → 30 → 60분 후 재시도, 이후 앱이 재시작될 때까지 중단). 주기적인 갱신이 없습니다 — 가격은 세션 전체에서 안정적입니다.
  • 모든 가격은 Chaos 단위입니다. 해당 단위로 표시하려면 GetRates()가 Divine/Exalted 환율을 제공합니다.

가격 조회

PluginSDK::PriceResult p = ctx()->Prices.LookupPrice("Divine Orb");
if (p.found) {
    ctx()->Log.Info(("Divine Orb = " + std::to_string(p.chaos) + "c").c_str());
    // p.category — 매칭된 poe2scout 버킷 (currency, fragments, runes,
    // …, 또는 고유 아이템 카테고리). currency vs. unique 분기에 유용합니다.
}

LookupPrice는 아이템의 표시 이름(통화 이름, 고유 이름, 또는 기본 유형)을 받아 로드된 모든 카테고리에 걸쳐 퍼지 호스트 측 매칭을 수행합니다. 미스는 모든 가격이 0인 found == false를 반환합니다.

PriceResult 필드 타입 의미
found bool 가격이 매칭됨
chaos float Chaos Orb 기준 가격 (표준 단위)
divine float Divine Orb 기준 동일 가격
exalt float Exalted Orb 기준 동일 가격
category std::string 매칭된 poe2scout 카테고리 (currency / fragments / runes / … / 고유 카테고리)

환율 및 로드 상태

PluginSDK::PriceRates  r = ctx()->Prices.GetRates();   // divineInChaos, exaltedInChaos
PluginSDK::PriceStatus s = ctx()->Prices.GetStatus();
if (!s.loaded) {
    // 아직 준비되지 않음 (로딩 중이거나 모든 카테고리가 실패한 경우).
    // s.catsOk / s.catsPending / s.catsFailed는 로더가 얼마나 진행됐는지 보여줍니다.
}

GetStatus().loaded는 가격을 표시하기 전에 확인할 게이트입니다 — 환율 및 최소 첫 번째 카테고리가 도착했을 때만 true로 전환됩니다. 그 전까지는 0 대신 "가격 로딩 중..." 상태를 렌더링하세요.


15. Runeshape 장치

ctx()->Runeshape은 호스트가 현재 지역에서 해결한 Expedition2Encounter("Runeshape") 장치와 각 레시피가 부여할 보상을 노출합니다. 호스트가 장치 체인 탐색 및 오프라인 레시피 매칭을 수행하므로 플러그인은 결과만 읽으면 됩니다. 이것이 내장 레이더의 보상 태그와 NinjaPricer의 Runeshape 창을 구동합니다 — 서드파티 플러그인도 동일한 데이터를 렌더링할 수 있습니다.

for (const PluginSDK::Runeshape& rs : ctx()->Runeshape.Runeshapes()) {
    // rs.color는 각 장치에 고유한 색상을 부여합니다 (그룹화/색상 지정에 활용).
    // rs.bestIndex는 가장 높은 가격의 보상 인덱스입니다 (없으면 -1).
    for (const PluginSDK::RuneshapeReward& rw : ctx()->Runeshape.Rewards(rs.entityId)) {
        if (rw.priced)
            ctx()->Log.Info((rw.name + " x" + std::to_string(rw.count) +
                             " = " + std::to_string(rw.totalChaos) + "c").c_str());
        if (rw.propagatingCount > 0)   // 0.5.4: 다음 remnant로 이어지는 룬(들)
            ctx()->Log.Info(("  propagates: " + rw.propagatingRunes).c_str());
    }
}
Runeshape 필드 타입 의미
entityId uint64_t 장치 엔티티 id — Rewards()에 전달
color uint32_t 장치별 고정 패킹 RGBA (그룹화/색상 지정용)
isUnique bool 장치가 고유 아이템 레시피를 제공함
holeCount int 앵커의 룬 홀 수
anchorName std::string 앵커 룬 이름
rewardCount int 보상 슬롯 수
bestIndex int totalChaos가 가장 높은 보상의 인덱스, 없으면 -1
propagatingSlots std::vector<int> 룬이 다음 remnant로 전달되는 룬 홀 슬롯 인덱스(들) (0.5.4 이월); 보통 1, 가끔 2
RuneshapeReward 필드 타입 의미
name std::string 보상 아이템 이름
count int 지급 수량
unitChaos float 단위당 Chaos 가격 (Prices 서비스에서)
totalChaos float unitChaos × count
priced bool 이 보상의 가격을 찾음
propagatingRunes std::string 이 레시피의 전달 슬롯(들)에 있는 룬(들) — 레시피 완료 시 이월되는 것; 예: "Power" 또는 "Cold, Time"; 레시피가 해당 슬롯을 포함하지 않으면 빈 값
propagatingCount int 이 보상의 전달되는 룬 수
propagatingHasRare bool 전달되는 룬 중 하나 이상이 레어("보라색"/고가)임

보상 가격은 동일한 ctx()->Prices 데이터베이스에서 가져오므로, 가격 미확인 보상(priced == false)은 보통 가격이 아직 로드되지 않았거나 poe2scout에 아이템이 등록되지 않았음을 의미합니다.

룬 전달 (0.5.4). 각 remnant는 무작위로 하나의 룬 슬롯을 선택하며 해당 룬이 다음 remnant로 이월됩니다 (게임 내 Runeshape Combinations 목록의 금색 왕관 하이라이트). Runeshape::propagatingSlots는 원시 슬롯 목록입니다; 슬롯 위치이므로 이월되는 룬은 레시피마다 다르며, RuneshapeReward::propagatingRunes가 보상별로 이를 해결합니다. 이것이 NinjaPricer의 노란 슬롯 점과 보상별 마커를 구동합니다.


16. 아틀라스 패널 데이터

ctx()->Atlas는 라이브 엔드게임 아틀라스 패널을 노출합니다 — 맵 노드, 앵커별 인접 관계, 현재 Rite 선택, 원시 적격성 가중치 — 모두 GameLibrary의 아틀라스 오프셋을 통해 호스트 측에서 읽습니다. 이것이 내장 아틀라스 오버레이와 참조 플러그인 ForetoldRewards를 구동합니다. 모든 것은 GetPanel()에서 시작합니다: 0은 아틀라스 UI가 구성되지 않았음을 의미합니다(게임 밖 / 패널 닫힘).

if (!ctx()->Atlas.GetPanel()) return;                     // atlas UI absent
for (const PluginSDK::AtlasNode& n : ctx()->Atlas.Nodes()) {
    // n.gridX / n.gridY are stable per map; n.marker / n.mapState carry UI state.
    std::string name = ctx()->Atlas.GetNodeName(n.uiAddress);  // resolve lazily
}
uint32_t seed = ctx()->Atlas.GetLineSeed();               // 0 = no Rite line
메서드 반환 목적
GetPanel() uintptr_t 아틀라스 패널 주소; 0 = 패널 없음
Nodes(detail) std::vector<AtlasNode> 모든 아틀라스 노드(gridX/Y, uiAddress, marker, flags, biome, mapState); 기본 detail은 이름을 건너뜀 — GetNodeName()으로 해석
Connections() std::vector<AtlasConnection> 앵커별 인접 관계(x/y + 최대 5개의 neighbors); 뷰포트에 따라 달라짐
Selection(which) std::vector<AtlasGridPoint> which=0 공개된 Rite 라인 맵, which=1 선택된 앵커(선택 순서대로)
GetLineSeed() uint32_t Rite 보상 선택 시드; 0 = Rite 라인 없음 / 패널 없음
Weights() std::vector<AtlasWeight> 원시 적격성 가중치 행(key, value)
GetNodeName(uiAddress) std::string 노드의 uiAddress에 대한 표시 이름(해석 안 되면 "")

값으로 전달되는 AtlasServiceAbi는 동결되었습니다(그 뒤에 다른 HostAbi 멤버가 추가되었기 때문). 따라서 향후의 아틀라스 읽기는 반드시 새로운 HostAbi tail 함수로 추가해야 하며 — 절대 새로운 AtlasServiceAbi 멤버로 추가해서는 안 됩니다.


17. Sekhema 트라이얼 데이터

ctx()->SekhemaTrial of the Sekhemas 층 맵 데이터를 노출합니다 — 정적 방 그래프, 현재 런의 선택, 각 방의 콘텐츠 FK 행 — 그리고 트라이얼 방에 필요한 StateMachine 플래그 읽기도 제공합니다. 그래프 호출은 트라이얼 패널 UI 주소를 명시적으로 받습니다: GetPanel()(호스트의 직접적인 자식 인덱스 해석)에서 시작하거나, 저렴한 ProbeFloor()로 사전 필터링한 자체 UI 트리 BFS를 실행하세요. 이것이 참조 플러그인 SekhemaHelper를 구동합니다.

uintptr_t panel = ctx()->Sekhema.GetPanel();
if (!panel) return;                                       // not on a trial floor
PluginSDK::SekhemaFloor floor = ctx()->Sekhema.GetFloor(panel);
for (const PluginSDK::SekhemaRoom& r : ctx()->Sekhema.Rooms(panel)) {
    bool chosen = r.layer < (int)floor.choices.size() && floor.choices[r.layer] == r.index;
    // r.connections lists the room indices in the NEXT layer
}
메서드 반환 목적
GetPanel() uintptr_t 호스트가 해석한 트라이얼 패널; 0 = 없음 / 컨트롤러 모드 / 게임 밖
ProbeFloor(uiAddress) int uiAddress에 있는 FloorData의 레이어 수, 0 = 트라이얼 층이 아님(저렴한 BFS 사전 필터)
GetFloor(panel) SekhemaFloor 층 헤더: layerCount, roomCounts, choices(레이어별 선택 인덱스, 0xFF=없음), counter
Rooms(panel) std::vector<SekhemaRoom> (layer, index) 순서의 정적 방 그래프; 각 방은 다음 레이어로의 connections를 가짐
Content(panel) std::vector<SekhemaContentEntry> 방별 콘텐츠 FK 행을 rowId / rowName으로 해석한 것(tablePath로 디스패치)
GetRoomUsedFlag(sm) int StateMachine 사용됨/닫힘 플래그: 1=사용됨, 0=활성, -1=읽기 불가
GetStateMachineValue(sm, i, out) bool shared-state 값 하나(정의 순서로 define_shared_state 항목당 8바이트)
GetUiStringId(uiAddress) std::string UI 요소의 StringId를 UTF-8로 — 트라이얼 HUD 리프가 값을 렌더링하는 숫자 필드(Ui.GetText 아님)

18. 설정 영구 저장

규칙: <plugin directory>/config/settings.json. Directory()는 플러그인 폴더의 절대 UTF-8 경로를 반환합니다.

사소한 설정의 경우, 직접 작성한 JSON writer가 잘 작동하며 DLL을 자체 완결적으로 유지합니다. 작동 예시는 Plugins/Radar/src/RadarSettings.h를 참고하세요. 스켈레톤:

struct MySettings {
    bool  DrawEnabled = true;
    float Opacity     = 0.9f;

    void Save(const std::string& directory) const {
        std::filesystem::path p =
            std::filesystem::path(directory) / "config" / "settings.json";
        std::error_code ec;
        std::filesystem::create_directories(p.parent_path(), ec);
        std::ofstream out(p);
        if (!out.is_open()) return;
        out << "{\n";
        out << "  \"DrawEnabled\":" << (DrawEnabled ? "true" : "false") << ",\n";
        out << "  \"Opacity\":"     << Opacity << "\n";
        out << "}\n";
    }

    void Load(const std::string& directory) {
        std::filesystem::path p =
            std::filesystem::path(directory) / "config" / "settings.json";
        if (!std::filesystem::exists(p)) return;
        // ... parse ...
    }
};

// In your plugin:
void OnEnable(bool) override   { m_settings.Load(Directory()); }
void SaveSettings() override   { m_settings.Save(Directory()); }

구조화된 데이터(중첩 객체, 배열)의 경우 플러그인 폴더에 실제 JSON 라이브러리를 벤더링하세요. 호스트는 선택을 강제하지 않습니다.

SaveSettings는 주기적(~5초) 및 비활성화 시 호출됩니다; 직접 호출할 필요는 없습니다.


19. 로깅

ctx()->Log.Debug("verbose detail");
ctx()->Log.Info ("normal status");
ctx()->Log.Warn ("something unexpected");
ctx()->Log.Error("operation failed");
ctx()->Log.Log  ("custom-level", "message");

네 가지 레벨 모두 호스트의 중앙 로거로 라우팅됩니다. 메시지는 호스트의 Logs 탭과 디스크의 로그 파일에 표시됩니다. 호출 전에 직접 포맷하세요; 호스트는 printf 스타일 가변 인자를 받지 않습니다.

내부적으로 편의 메서드는 "Debug", "Info", "Warning", "Error" 문자열을 내보냅니다(Warn"Warning"에 매핑됨). 호스트 브리지는 대소문자를 구분하지 않고 매칭하므로 Log("warn", "msg")를 호출하는 플러그인도 올바르게 라우팅됩니다 — 하지만 편의 메서드가 더 명확합니다.


20. 메모리 (파워 유저용)

직접 메모리 프리미티브입니다. 가능하면 상위 레벨 서비스를 우선 사용하세요 — 이들은 오프셋을 이해하고, ABI 변경을 처리하며, SEH 안전합니다. 직접 메모리 읽기는 필요한 것에 대한 상위 레벨 호출이 없을 때만 적절합니다.

// Read a fixed-size value:
uint64_t value = 0;
ctx()->Memory.Read(addr, &value, sizeof(value));

// Read game strings (null-terminated, narrow or wide):
std::string  s  = ctx()->Memory.ReadString (strAddr);
std::wstring ws = ctx()->Memory.ReadWString(wstrAddr);

// Read a std::wstring container in the game's memory (handles SSO):
std::wstring inner = ctx()->Memory.ReadStdWString(containerAddr);

// Read a std::vector<T>; returns raw bytes you reinterpret_cast:
std::vector<uint8_t> raw = ctx()->Memory.ReadStdVector(vecAddr, sizeof(MyT), /*maxElems=*/1024);
const MyT* items = reinterpret_cast<const MyT*>(raw.data());
size_t count = raw.size() / sizeof(MyT);

// Module info:
uintptr_t base = ctx()->Memory.GetBaseAddress();
uintptr_t sz   = ctx()->Memory.GetModuleSize();
uintptr_t pat  = ctx()->Memory.GetPatternAddress("GameStates");  // resolves a named pattern

이들에 자주 손을 뻗는 자신을 발견한다면, 필요한 데이터가 상위 레벨 서비스에 속해야 하는지 자문하세요.


21. 브리지 / SEH 안전성

호스트와 플러그인 간의 모든 DLL 간 호출은 호스트 측에서 __try / __except 블록 내에서 실행됩니다. SDK 호출 내에서 오래된 포인터를 역참조하거나, 0으로 나누거나, 그 외 다른 방식으로 폴트를 일으키는 잘못된 플러그인은 로그된 오류를 받습니다 — 호스트 프로세스는 충돌하지 않으며, 게임은 계속 실행되고, 사용자는 다른 플러그인을 계속 사용할 수 있습니다.

그렇다고 플러그인이 부주의해도 된다는 의미는 아닙니다. SEH는 증상을 잡지, 원인을 잡지 않습니다. 플러그인이 매 프레임 폴트를 일으키면 사용자는 오류 로그의 홍수를 보게 되고, 데이터는 사실상 사용 불가능합니다. SDK 호출에서 null 반환을 처리하고, 컴포넌트 데이터의 Valid 플래그를 확인하고, uintptr_t 주소를 직접 역참조하지 마세요 — 이미 RPM을 적절히 감싸는 ComponentsService / Ui / Memory 호출을 통해 전달하세요.

호스트는 버그가 있는 플러그인은 다룰 수 있습니다. 하지만 멈춘 플러그인 DLL은 다룰 수 없습니다 — 100ms가 걸리는 DrawSettings는 전체 UI 스레드를 차단합니다. 프레임당 작업을 가볍게 유지하세요.


22. 흔한 함정

플러그인 작성자가 처음 통합할 때 부딪히는 짧은 목록입니다. 대부분 위에서 인라인으로 문서화되어 있지만, 여기에 체크리스트로 모았습니다.

  1. OnAreaChange는 보행 가능 그리드가 재파싱되기 전에 발생합니다. 이벤트에서 WalkableGridHandle을 새로 고치지 마세요 — DrawUI에서 프레임마다 폴링하고 Data()가 변경되면 교체하세요. (§11)
  2. Entity::Zone은 로컬 플레이어에 대해 항상 None입니다. 이는 플레이어로부터의 거리 분류이므로, 정의상 플레이어는 거리 0에 있습니다. 플레이어 정보 표시에 노출하지 마세요.
  3. Components.ReadMods()는 요약 플래그만 반환합니다 — 모드 목록 없음. 종류별 모드 목록은 Inventory.ReadItemMods(entityAddr)를 호출하세요. (§8)
  4. 땅에 드롭된 아이템은 EntitySubtype이 없을 수 있습니다. 월드의 아이템을 필터링한다면, 더 좁은 subtype 체크보다 EntityType == Item || EntityType == Chest를 선호하세요.
  5. Directory()는 절대 경로를 반환합니다. EXE 디렉터리를 직접 앞에 붙이지 마세요 — EXEDIR\EXEDIR\Plugins\X가 되어 설정 파일이 플러그인 폴더 밖에 저장됩니다.
  6. ctx()const Context*를 반환합니다. EventsService::Subscribe 같은 변경 메서드는 const_cast가 필요합니다. 이는 의도적입니다 — 변경하지 않는 서비스는 실수로 변경할 수 없어야 합니다.
  7. ImGui::SetCurrentContext는 DLL별로 호출해야 합니다. 플러그인 DLL은 기본적으로 자체 ImGui 상태를 가지므로, 그리기를 하는 모든 진입점(OnEnable, DrawUI, DrawSettings)에서 호출하세요.
  8. 편의 헬퍼는 호출할 때마다 컴포넌트를 다시 읽습니다. GetHealthPercent(addr)는 내부적으로 새로운 ReadLife(addr)를 수행합니다. 이전 호출에서 이미 Life 구조체를 가지고 있다면, 그 필드에 직접 접근하세요.

23. 서비스 빠른 참조

모든 서비스의 모든 공개 메서드에 대한 한 줄 요약입니다. 전체 타입 시그니처와 사용 노트는 위의 산문 섹션을 참고하세요.

GameService

메서드 반환 목적
GetSnapshot() Snapshot 프레임별 전체 뷰, Entities 포함
GetState() GameState 열거형: InGame, Login, Loading
IsAttached() bool 게임 프로세스가 연결됨
IsInGame() bool StateInGame인지 여부
IsForeground() bool 게임 창이 포커스를 가짐
IsMenuVisible() bool ESC 메뉴 / 설정 창이 열림
IsOverlayMode() bool 호스트가 오버레이(클릭-스루) 모드임
GetProcessId() DWORD 게임 PID
GetGameWindow() HWND 게임 창 핸들
GetScreenSize() ScreenSize {Width, Height} 부동소수점 값
GetGold() int 캐릭터 골드 카운터(게임 밖에서는 0)
GetAreaId() std::string 현재 존의 원시 WorldArea id(Sekhemas 층 번호를 담고 있음)
GetHiveblood(out) bool Genesis 트리(Hiveblood) 리소스 → out; 구버전 호스트 / 게임 밖에서는 false

EntitiesService

메서드 반환 목적
Enumerate(cb) 주변의 모든 엔티티를 방문 (중단하려면 false 반환)
GetPlayer() Entity 로컬 플레이어 엔티티
FindById(id) std::optional<Entity> id로 엔티티 조회
GetWorldItemInner(addr) std::optional<Entity> WorldItem 컨테이너의 내부 아이템 엔티티(바닥 아이템)
Watch(id) 엔티티를 고정해 컴포넌트를 계속 읽을 수 있게 함(watch 등록)
Unwatch(id) watch 해제
IsWatched(id) bool watch 상태
GetWatchedComponents(id) std::optional<ComponentAddresses> 고정된 컴포넌트 읽기

ComponentsService

메서드 반환 목적
ReadLife / ReadRender / ReadPositioned / ReadTargetable / ReadChest / ReadShrine / ReadStack / ReadCharges / ReadPlayer / ReadAnimated / ReadTransitionable / ReadTriggerableBlockage / ReadMinimapIcon / ReadStateMachine / ReadBase / ReadMods / ReadStats / ReadBuffs / ReadActor / ReadNpc / ReadDiesAfterTime Component struct 컴포넌트 타입당 하나씩, 총 21개의 리더
EnumerateBuffs(addr) std::vector<Buff> 엔티티에 적용된 활성 버프
EnumerateActiveSkills(addr) std::vector<ActiveSkill> Actor 컴포넌트로부터 얻는 스킬 목록 (ActiveSkill 필드 표는 §7 참고)
EnumerateSkillStats(skillDetailsAddr) std::vector<SkillStatEntry> 단 하나의 스킬에 대해 평가된 스탯 세트 ({SetIndex, StatId, Value}, StatId = Stats.dat 행 + 1) — 세트 0은 현재 컨텍스트이며 스킬 패널 DPS (692, ×100)를 포함; §7 참고
EnumerateStats(addr) std::vector<StatEntry> 아이템 + 버프에서 비롯된 스탯
EnumerateItemMods(addr) std::vector<Mod> Mods 컴포넌트로부터 도달 가능한 모드
ReadGroundEffect(entityAddr) GroundEffect VisibleServerGroundEffect 엔티티에서 지면 효과 유형 + 반경 — 엔티티 주소를 전달; TypeId로 매칭 (ShockedGround/IgnitedGround/…). 하나의 엔티티 경로를 공유하는 효과들을 구분함
GetHealthPercent / GetEsPercent / GetManaPercent float 편의용 퍼센트(%) 헬퍼
IsAlive(addr) bool Health가 0보다 큼
GetItemRarity(addr) int Mods 컴포넌트에서 가져온 희귀도
IsItemIdentified(addr) bool 감정 여부 플래그
GetStackCount(addr) int 현재 스택 수
IsChestOpened(addr) bool 상자 열림 플래그
GetPlayerName(addr) std::string Player 컴포넌트에서 가져온 플레이어 이름
GetWorldPosition(renderAddr, x, y, z) bool 월드 좌표용 편의 접근자

InventoryService

메서드 반환 목적
Scan(inventoryId) 호스트 측 재스캔 트리거 (-1 = 전체)
Get(inventoryId) Inventory 인벤토리 하나, 아이템이 이미 채워진 상태
GetItems(inventoryId) std::vector<InventoryItem> 아이템만
GetAll() std::vector<Inventory> 스캔된 모든 인벤토리
GetName(inventoryId) const char* 표시 이름 ("Backpack", "Stash" 등)
ReadItemRarity(addr) int 엔티티별 희귀도 (WorldItem 컨테이너 자동 해결)
ReadItemStackCount(addr) int 엔티티별 스택 수 (WorldItem 컨테이너 자동 해결)
ReadItemBaseTypeName(addr) std::string 기본 유형, WorldItem 컨테이너 자동 해결
ReadItemUniqueName(addr) std::string 고유 이름, WorldItem 컨테이너 자동 해결
ReadItemPath(addr) std::string Metadata/Items/... 경로, WorldItem 컨테이너 자동 해결
ReadItemMods(addr) ItemMods 요약 플래그 + 종류별 모드 벡터 5개, WorldItem 컨테이너 자동 해결
FormatStat(statKey, v0, v1) std::string 호스트 .csd 포매터를 통해 스탯 키 + 값(들)의 게임 내 스타일 텍스트로 변환; 설명이 로드되기 전까지는 빈 문자열 반환
ReadItemBaseStats(addr) ItemBaseStats 기본 방어 수치 (계산된 에너지 실드; 기본 Ward/Armour/Evasion); Armour 컴포넌트 없이는 Valid false; WorldItem 컨테이너 자동 해결
ReadItemAggregatedStats(addr) std::vector<std::pair<int,int>> 집계된 {statId, value} (결계석 아이템 희귀도 8205 / 무리 크기 8206 / 몬스터 희귀도 8207 / 몬스터 효과도 8208 / 결계석 드롭 확률 8209); WorldItem 컨테이너 자동 해결

UiService

메서드 반환 목적
Read(addr) UiElement 요소 필드 (rect, 플래그, 자식 수)
GetChildren(addr) std::vector<uintptr_t> 자식 요소 주소 목록
GetChildAt(addr, index) uintptr_t 인덱스로 단일 자식 조회
FollowPath(root, indices, count) uintptr_t 알려진 인덱스 경로를 따라가기
IsVisible(addr) bool 요소가 화면에 표시됨
GetStringId(addr) std::string 게임 측의 안정적인 식별자
GetText(addr) std::string 렌더링된 텍스트
ComputeScreenRect(addr, x, y, w, h) bool 최종 화면 공간 사각형
GetGameUiRoot() uintptr_t 인게임 UI의 루트
GetUiRoot() uintptr_t 최상위 UI 루트
GetCullValue() int 호스트의 UI 컬링 임계값
FindPanelByStringId(parent, stringId) uintptr_t 대상 자손 요소 조회

RenderService

메서드 반환 목적
WorldToScreen(wx, wy, wz, sx, sy) bool 원근 투영
GridToLargeMap(gx, gy, worldZ, sx, sy) bool 대형 맵 오버레이로 투영
GridToMiniMap(gx, gy, worldZ, sx, sy) bool 미니맵으로 투영
GetLargeMapTransform() MapTransform 일괄 계산용으로 미리 곱해진 변환
GetMiniMapTransform() MapTransform 동일하며, 미니맵용

TerrainService

메서드 반환 목적
GetWalkableGrid() WalkableGridHandle 타일당 4비트 보행 가능 비트맵에 대한 RAII 핸들
GetHeightGrid() HeightGridHandle 타일별 지형 높이에 대한 RAII 핸들
IsWalkable(gx, gy) bool 단일 타일 판정
GetTerrainHeight(gx, gy) float 월드 공간 Z 값
GetWorldToGridConvertor() float 월드 → 그리드 변환 계수
EnumerateTgtLocations(cb) 현재 지역의 모든 TGT 인스턴스 방문

MemoryService

메서드 반환 목적
Read(addr, buf, size) bool 원시 RPM
ReadString(addr) std::string 널(NUL)로 끝나는 narrow 문자열
ReadWString(addr) std::wstring 널(NUL)로 끝나는 wide 문자열
ReadStdWString(addr) std::wstring 게임 측 std::wstring 컨테이너를 읽음 (SSO 처리)
ReadStdVector(addr, elemSize, maxElems) std::vector<uint8_t> 원시 바이트; 원하는 타입으로 재해석(reinterpret_cast)
GetBaseAddress() uintptr_t 게임 모듈 베이스
GetModuleSize() uintptr_t 게임 모듈 크기
GetPatternAddress(name) uintptr_t 이름 기반 패턴 조회

LogService

메서드 목적
Debug / Info / Warn / Error(msg) 해당 레벨로 출력
Log(level, msg) 사용자 지정 레벨 문자열

EventsService

메서드 반환 목적
Subscribe(kind, cb) Token 범용 디스패치
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) Token 한 줄짜리 구독 헬퍼
Unsubscribe(token) 수동 해제 (소멸자가 어차피 자동으로 해제함)

OverlayService

메서드 반환 목적
SetIncludeSleepingEntities(enable) EntitiesService.Enumerate에서 EntityState::Useless 엔티티를 수신하도록 선택
SetWantsOverlayInput(enable) 오버레이가 클릭 통과 대신 마우스 클릭을 캡처하도록 요청

두 메서드 모두 멱등성이 있으며, 플러그인별로 적용되고, 호스트 + 다른 플러그인과 OR 집계되며, Disable/Unload 시 자동으로 초기화됩니다. 전체 패턴(맵 피커 플러그인의 백그라운드 드로우 리스트 주의사항 포함)은 섹션 13을 참고하세요.

FlasksService

메서드 반환 목적
GetFlask(slot) std::optional<Flask> 벨트 슬롯별 생명/마나 플라스크 (0=생명, 1=마나); 범위 초과 또는 게임 외 시 nullopt
GetCharm(slot) std::optional<Charm> 벨트 슬롯별 장신구 (0..2); 범위 초과 또는 게임 외 시 nullopt
AllFlasks() std::vector<Flask> 빈 슬롯 포함 모든 플라스크 슬롯 (FlaskSlotCount() 항목)
AllCharms() std::vector<Charm> 빈 슬롯 포함 모든 장신구 슬롯 (CharmSlotCount() 항목)
FlaskSlotCount() int32_t 플라스크 슬롯 수 (POE2에서는 2)
CharmSlotCount() int32_t 장신구 슬롯 수 (POE2에서는 3)

Flask / Charm 필드 테이블과 PerUseEffective 제한에 대해서는 섹션 8("플라스크 & 장신구")을 참고하세요.

PricesService

메서드 반환 목적
LookupPrice(name) PriceResult 표시 이름으로 호스트 측 가격 퍼지 조회 (found, chaos, divine, exalt, category)
GetRates() PriceRates 신성한 / 고귀한 → 카오스 변환율
GetStatus() PriceStatus loaded 게이트 + 카테고리별 카운트 (catsOk / catsPending / catsFailed)

RuneshapeService

메서드 반환 목적
Runeshapes() std::vector<Runeshape> 모든 해결된 Expedition2Encounter 장치 (id, color, anchor, bestIndex)
Rewards(entityId) std::vector<RuneshapeReward> 장치별 보상 슬롯, 각각 Prices 서비스를 통해 가격 책정

AtlasService

메서드 반환 목적
GetPanel() uintptr_t 아틀라스 패널 주소; 0 = 없음
Nodes(detail) std::vector<AtlasNode> 모든 아틀라스 노드(그리드 좌표, marker, mapState)
Connections() std::vector<AtlasConnection> 앵커별 인접 관계(뷰포트에 따라 달라짐)
Selection(which) std::vector<AtlasGridPoint> 0 = 공개된 Rite 라인 맵, 1 = 선택된 앵커
GetLineSeed() uint32_t Rite 보상 선택 시드(0 = 없음)
Weights() std::vector<AtlasWeight> 원시 적격성 가중치 행(key, value)
GetNodeName(uiAddress) std::string 노드 표시 이름(해석 안 되면 "")

SekhemaService

메서드 반환 목적
GetPanel() uintptr_t 호스트가 해석한 트라이얼 패널; 0 = 없음
ProbeFloor(uiAddress) int FloorData의 레이어 수(0 = 트라이얼 층이 아님); 저렴한 사전 필터
GetFloor(panel) SekhemaFloor 층 헤더(layerCount, roomCounts, choices, counter)
Rooms(panel) std::vector<SekhemaRoom> (layer, index) 순서의 정적 방 그래프
Content(panel) std::vector<SekhemaContentEntry> 방별 콘텐츠 FK 행(rowId / rowName)
GetRoomUsedFlag(sm) int 1=사용됨, 0=활성, -1=읽기 불가
GetStateMachineValue(sm, i, out) bool shared-state 값 하나(정의 순서)
GetUiStringId(uiAddress) std::string UI 요소의 StringId를 UTF-8로(숫자 HUD 필드)

24. 버전 관리

PluginAbi.h는 다음을 정의합니다.

constexpr int PLUGIN_SDK_VERSION = 6;

로드 시 호스트는 plugin->GetSDKVersion()을 호출하여 자신의 PLUGIN_SDK_VERSION과 비교합니다. 불일치 시 호스트는 경고를 로그하고 플러그인 로드를 거부합니다.

호스트는 또한 PluginSDK_AttachHost 내부에서 HostAbi::versionHostAbi::size_bytes를 확인합니다(PLUGIN_EXPORTS가 설정된 경우 PluginSDK.h에서 인라인으로 정의됨). 두 필드 중 하나라도 플러그인이 빌드된 것과 일치하지 않으면, ctx()는 동작하지 않습니다. 베이스 클래스 접근자 HostCompatible()은 이 경우 false를 반환하며, 예의 바른 플러그인이라면 동작을 거부해야 합니다.

void OnEnable(bool) override {
    if (!HostCompatible()) {
        ctx()->Log.Error("Host ABI mismatch — disable plugin");
        return;
    }
    // ...
}

25. 샘플 플러그인

저장소의 네 가지 플러그인은 문서처럼 읽을 수 있도록 설계되었습니다.

  • Plugins/ExamplePlugin/ — 광범위 표면 쇼케이스. 거의 모든 서비스를 다루는 단일 플러그인으로, 11개의 examples/Example*.h 하위 파일(Area & Vitals, Buffs, Entities, Inventory, Memory, UI Explorer, Component Reader, Render, Terrain, Events, Log)과 커버리지 요약 배너로 구성되어 있습니다. 서비스가 문맥 속에서 어떻게 사용되는지 보고 싶을 때 읽어보세요.

  • Plugins/Radar/ — 집중된 실제 예시. ~200줄. 공개 SDK만으로 구축된 레이더 오버레이 — 오프셋 없음, 원시 메모리 읽기 없음. Render.GridToLargeMap을 통해 보행 가능 맵과 엔티티별 점을 렌더링합니다. 특정 결과를 위한 최소 코드를 보고 싶을 때 읽어보세요.

  • Plugins/KillCount/ — 처치/상자/사망 추적기. SQLite + 스프라이트 아틀라스 + 영역별 상태. 영구 저장, 벤더링된 데이터 파일, 오버레이를 모두 하나의 DLL에 담는 방법을 보여줍니다.

  • Plugins/NinjaPricer/ — poe.ninja 가격 오버레이. HTTP fetch (Exchange API) + 인벤토리 스캔 + 아이템별 가격. 실제 워크플로우에서 네트워크 코드, 서드 파티 데이터 수집, 인벤토리 순회를 보여줍니다.


← Home

Clone this wiki locally