-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development Guide KO
POEFixer 플러그인은 런타임에 Plugins/<PluginName>/<PluginName>.dll 경로에서 로드되는 네이티브 C++ DLL입니다. 실시간 게임 상태를 읽고, ImGui 오버레이를 그리고, 자체 설정을 영구 저장하고, 호스트 이벤트를 구독합니다.
플러그인 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 (10 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/.
로드되어 호스트 로그에 메시지를 출력하는 최소한의 플러그인입니다.
#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 탭에서 활성화합니다.
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_AttachHost—Context를 연결합니다.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));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()을 호출하여(베이스에 정의되어 있으며 오버라이드하지 마세요) 플러그인과 호스트의 일치 여부를 확인합니다. 불일치 시 플러그인이 거부됩니다.
ctx()는 const PluginSDK::Context*를 반환하며, 10개의 서비스를 집합한 것입니다.
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>
void* ImGuiContext; // pass to ImGui::SetCurrentContext
void* D3DDevice; // ID3D11Device* for texture loading
};각 서비스가 무엇에 사용되는지 한눈에 보기:
| 서비스 | 사용 목적 |
|---|---|
GameService |
Snapshot, state flags, screen + window info |
EntitiesService |
Enumerate, find-by-id, watch lifecycle |
ComponentsService |
21 readers + 4 enumerators + ~10 convenience helpers |
InventoryService |
Scan, enumerate, per-item mod reads |
UiService |
Tree walk, FindPanelByStringId, ComputeScreenRect
|
RenderService |
WorldToScreen, GridTo{Large,Mini}Map, transforms |
TerrainService |
Walkable + height grids (RAII), TGT locations |
MemoryService |
RPM primitives — only when no higher-level call fits |
LogService |
Debug / Info / Warn / Error
|
EventsService |
Subscribe / Unsubscribe / On{Area,Frame,Attach,Detach}
|
ctx()는 호스트가 OnEnable을 호출하는 순간부터 OnDisable이 반환될 때까지 유효합니다. 핫 리로드나 DLL 언로드 경계를 넘어 ctx()를 캐시하지 마세요.
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();엔티티는 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, …)를 가지고 있다면 헬퍼를 다시 호출하지 말고 그 필드에 직접 접근하세요 — 헬퍼는 매번 컴포넌트를 다시 읽습니다.
모든 Entity(snap.Player와 snap.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
|
| 빠른 vitals |
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()는 항상 로컬 플레이어를 반환합니다.
바닥에 떨어진 아이템은 snap.Entities에 Metadata/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 컨테이너를 투명하게 자동 해결합니다.
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);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 컨테이너 주소를 받습니다.
게임의 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 thresholdStringId 값은 게임 측에서 안정적인 식별자이며, 존재한다면 하드코딩된 경로보다 선호하세요.
세 가지 투영 헬퍼, 두 가지 좌표계.
원근 (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를 참고하세요.
보행 가능 그리드는 플레이어가 밟을 수 있는 지형 셀을 나타내는 타일당 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
});호스트가 발생시키는 이벤트를 구독합니다. 각 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를 참고하세요.
ctx()->Overlay는 오버레이 전체 동작을 변경하는 플러그인별 두 가지 "요청 플래그"를 노출합니다. 호스트는 this 포인터로 키링된 플러그인별 상태를 저장하고, 호스트의 자체 내장 플래그(메인 메뉴 가시성, AutoCraft 잠금, 호스트 내장 맵에서 엔티티 추가 피커, 다른 모든 플러그인의 요청)와 OR-집계합니다. 플러그인 비활성화/언로드 시 자동으로 지워집니다 — 충돌하거나 버그가 있는 플러그인이 오버레이를 이상한 상태로 영구적으로 고착시킬 수 없습니다.
기본적으로 EntitiesService.Enumerate와 Snapshot.Entities는 EntityState::Useless 상태의 엔티티를 숨깁니다(호스트의 "수면" 필터는 멀리 떨어진 휴면 몬스터/NPC/상자를 제외합니다). 이를 통해 프레임당 스냅샷 비용을 제한합니다 — 일반적인 지역에는 플러그인이 신경 쓰지 않는 수백 개의 Useless 엔티티가 있습니다.
지역의 전체 엔티티 풀이 필요한 맵 피커 UI 및 디버그 뷰어(사용자가 아직 활성화되지 않은 엔티티를 클릭할 수 있어야 하는 경우)에서는 필터를 끄세요:
ctx()->Overlay.SetIncludeSleepingEntities(true);
// 이제 ctx()->Entities.Enumerate도 Useless 엔티티를 볼 수 있습니다.
// Entity::IsSleeping은 호스트의 별도 SleepingEntities 컬렉션에서
// 온 것들을 특별히 표시합니다 (EntityState::Useless와 직교).비용: 활성화 중 프레임당 스냅샷 CPU 약 +5–15%. 필요하지 않으면 끄세요.
오버레이 창은 기본적으로 클릭-스루 (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();피커 영역만 덮는 작은 창도 동일하게 작동합니다 — 호스트는 크기가 아니라 커서 아래에 창이 있는지만 확인합니다.
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 워커 틱(수면 엔티티)에 나타납니다. 서브-프레임으로 관찰할 수 없습니다.
- 모든 메서드는 어느 스레드에서나 안전하게 호출할 수 있습니다.
호스트는 백그라운드 스레드에서 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 대신 "가격 로딩 중..." 상태를 렌더링하세요.
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의 노란 슬롯 점과 보상별 마커를 구동합니다.
규칙: <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초) 및 비활성화 시 호출됩니다; 직접 호출할 필요는 없습니다.
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")를 호출하는 플러그인도 올바르게 라우팅됩니다 — 하지만 편의 메서드가 더 명확합니다.
직접 메모리 프리미티브입니다. 가능하면 상위 레벨 서비스를 우선 사용하세요 — 이들은 오프셋을 이해하고, 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이들에 자주 손을 뻗는 자신을 발견한다면, 필요한 데이터가 상위 레벨 서비스에 속해야 하는지 자문하세요.
호스트와 플러그인 간의 모든 DLL 간 호출은 호스트 측에서 __try / __except 블록 내에서 실행됩니다. SDK 호출 내에서 오래된 포인터를 역참조하거나, 0으로 나누거나, 그 외 다른 방식으로 폴트를 일으키는 잘못된 플러그인은 로그된 오류를 받습니다 — 호스트 프로세스는 충돌하지 않으며, 게임은 계속 실행되고, 사용자는 다른 플러그인을 계속 사용할 수 있습니다.
그렇다고 플러그인이 부주의해도 된다는 의미는 아닙니다. SEH는 증상을 잡지, 원인을 잡지 않습니다. 플러그인이 매 프레임 폴트를 일으키면 사용자는 오류 로그의 홍수를 보게 되고, 데이터는 사실상 사용 불가능합니다. SDK 호출에서 null 반환을 처리하고, 컴포넌트 데이터의 Valid 플래그를 확인하고, uintptr_t 주소를 직접 역참조하지 마세요 — 이미 RPM을 적절히 감싸는 ComponentsService / Ui / Memory 호출을 통해 전달하세요.
호스트는 버그가 있는 플러그인은 다룰 수 있습니다. 하지만 멈춘 플러그인 DLL은 다룰 수 없습니다 — 100ms가 걸리는 DrawSettings는 전체 UI 스레드를 차단합니다. 프레임당 작업을 가볍게 유지하세요.
플러그인 작성자가 처음 통합할 때 부딪히는 짧은 목록입니다. 대부분 위에서 인라인으로 문서화되어 있지만, 여기에 체크리스트로 모았습니다.
-
OnAreaChange는 보행 가능 그리드가 재파싱되기 전에 발생합니다. 이벤트에서WalkableGridHandle을 새로 고치지 마세요 —DrawUI에서 프레임마다 폴링하고Data()가 변경되면 교체하세요. (§11) -
Entity::Zone은 로컬 플레이어에 대해 항상None입니다. 이는 플레이어로부터의 거리 분류이므로, 정의상 플레이어는 거리 0에 있습니다. 플레이어 정보 표시에 노출하지 마세요. -
Components.ReadMods()는 요약 플래그만 반환합니다 — 모드 목록 없음. 종류별 모드 목록은Inventory.ReadItemMods(entityAddr)를 호출하세요. (§8) -
땅에 드롭된 아이템은
EntitySubtype이 없을 수 있습니다. 월드의 아이템을 필터링한다면, 더 좁은 subtype 체크보다EntityType == Item || EntityType == Chest를 선호하세요. -
Directory()는 절대 경로를 반환합니다. EXE 디렉터리를 직접 앞에 붙이지 마세요 —EXEDIR\EXEDIR\Plugins\X가 되어 설정 파일이 플러그인 폴더 밖에 저장됩니다. -
ctx()는const Context*를 반환합니다.EventsService::Subscribe같은 변경 메서드는const_cast가 필요합니다. 이는 의도적입니다 — 변경하지 않는 서비스는 실수로 변경할 수 없어야 합니다. -
ImGui::SetCurrentContext는 DLL별로 호출해야 합니다. 플러그인 DLL은 기본적으로 자체 ImGui 상태를 가지므로, 그리기를 하는 모든 진입점(OnEnable,DrawUI,DrawSettings)에서 호출하세요. -
편의 헬퍼는 호출할 때마다 컴포넌트를 다시 읽습니다.
GetHealthPercent(addr)는 내부적으로 새로운ReadLife(addr)를 수행합니다. 이전 호출에서 이미Life구조체를 가지고 있다면, 그 필드에 직접 접근하세요.
모든 서비스의 모든 공개 메서드에 대한 한 줄 요약입니다. 전체 타입 시그니처와 사용 노트는 위의 산문 섹션을 참고하세요.
| 메서드 | 반환 | 목적 |
|---|---|---|
GetSnapshot() |
Snapshot |
Full per-frame view, including Entities
|
GetState() |
GameState |
Enum: InGame, Login, Loading, … |
IsAttached() |
bool |
Game process attached |
IsInGame() |
bool |
State == InGame |
IsForeground() |
bool |
Game window has focus |
IsMenuVisible() |
bool |
ESC menu / settings open |
IsOverlayMode() |
bool |
Host is in overlay (click-through) |
GetProcessId() |
DWORD |
Game PID |
GetGameWindow() |
HWND |
Game window handle |
GetScreenSize() |
ScreenSize |
{Width, Height} floats |
| 메서드 | 반환 | 목적 |
|---|---|---|
Enumerate(cb) |
— | Visit every nearby entity (return false to stop) |
GetPlayer() |
Entity |
The local player entity |
FindById(id) |
std::optional<Entity> |
Lookup by entity id |
GetWorldItemInner(addr) |
std::optional<Entity> |
Inner item entity for a WorldItem container (ground items) |
Watch(id) |
— | Pin an entity so its components stay readable |
Unwatch(id) |
— | Release a watch |
IsWatched(id) |
bool |
Watch state |
GetWatchedComponents(id) |
std::optional<ComponentAddresses> |
Read pinned components |
| 메서드 | 반환 | 목적 |
|---|---|---|
ReadLife / ReadRender / ReadPositioned / ReadTargetable / ReadChest / ReadShrine / ReadStack / ReadCharges / ReadPlayer / ReadAnimated / ReadTransitionable / ReadTriggerableBlockage / ReadMinimapIcon / ReadStateMachine / ReadBase / ReadMods / ReadStats / ReadBuffs / ReadActor / ReadNpc / ReadDiesAfterTime |
Component struct | 21 readers, one per component type |
EnumerateBuffs(addr) |
std::vector<Buff> |
Active buffs on the entity |
EnumerateActiveSkills(addr) |
std::vector<ActiveSkill> |
Skills from an Actor component |
EnumerateStats(addr) |
std::vector<StatEntry> |
Items + buffs sourced stats |
EnumerateItemMods(addr) |
std::vector<Mod> |
Mods reachable from a Mods component |
GetHealthPercent / GetEsPercent / GetManaPercent |
float |
Convenience % helpers |
IsAlive(addr) |
bool |
Health > 0 |
GetItemRarity(addr) |
int |
Rarity from a Mods component |
IsItemIdentified(addr) |
bool |
Identified flag |
GetStackCount(addr) |
int |
Current stack count |
IsChestOpened(addr) |
bool |
Chest open flag |
GetPlayerName(addr) |
std::string |
Player name from a Player component |
GetWorldPosition(renderAddr, x, y, z) |
bool |
Convenience accessor for world coords |
| 메서드 | 반환 | 목적 |
|---|---|---|
Scan(inventoryId) |
— | Trigger a host-side rescan (-1 = all) |
Get(inventoryId) |
Inventory |
One inventory, items already populated |
GetItems(inventoryId) |
std::vector<InventoryItem> |
Items only |
GetAll() |
std::vector<Inventory> |
All scanned inventories |
GetName(inventoryId) |
const char* |
Display name ("Backpack", "Stash", …) |
ReadItemRarity(addr) |
int |
Per-entity rarity (auto-resolves WorldItem containers) |
ReadItemStackCount(addr) |
int |
Per-entity stack (auto-resolves WorldItem containers) |
ReadItemBaseTypeName(addr) |
std::string |
Base type, auto-resolves WorldItem containers |
ReadItemUniqueName(addr) |
std::string |
Unique name, auto-resolves WorldItem containers |
ReadItemPath(addr) |
std::string |
Metadata/Items/... path, auto-resolves WorldItem containers |
ReadItemMods(addr) |
ItemMods |
Summary flags + 5 per-kind mod vectors, auto-resolves WorldItem containers |
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 컨테이너 자동 해결 |
| 메서드 | 반환 | 목적 |
|---|---|---|
Read(addr) |
UiElement |
Element fields (rect, flags, children count) |
GetChildren(addr) |
std::vector<uintptr_t> |
Child element addresses |
GetChildAt(addr, index) |
uintptr_t |
Single child by index |
FollowPath(root, indices, count) |
uintptr_t |
Walk a known index path |
IsVisible(addr) |
bool |
Element is on-screen |
GetStringId(addr) |
std::string |
Game-side stable identifier |
GetText(addr) |
std::string |
Rendered text |
ComputeScreenRect(addr, x, y, w, h) |
bool |
Final screen-space rect |
GetGameUiRoot() |
uintptr_t |
Root of the in-game UI |
GetUiRoot() |
uintptr_t |
Top-level UI root |
GetCullValue() |
int |
Host's UI cull threshold |
FindPanelByStringId(parent, stringId) |
uintptr_t |
Targeted descendant lookup |
| 메서드 | 반환 | 목적 |
|---|---|---|
WorldToScreen(wx, wy, wz, sx, sy) |
bool |
Perspective projection |
GridToLargeMap(gx, gy, worldZ, sx, sy) |
bool |
Project to the large map overlay |
GridToMiniMap(gx, gy, worldZ, sx, sy) |
bool |
Project to the minimap |
GetLargeMapTransform() |
MapTransform |
Pre-multiplied transform for batched math |
GetMiniMapTransform() |
MapTransform |
Same, for the minimap |
| 메서드 | 반환 | 목적 |
|---|---|---|
GetWalkableGrid() |
WalkableGridHandle |
RAII handle to the 4-bit-per-tile walkable bitmap |
GetHeightGrid() |
HeightGridHandle |
RAII handle to the per-tile terrain heights |
IsWalkable(gx, gy) |
bool |
Single-tile predicate |
GetTerrainHeight(gx, gy) |
float |
World-space Z |
GetWorldToGridConvertor() |
float |
World → grid conversion factor |
EnumerateTgtLocations(cb) |
— | Visit every TGT instance in the current area |
| 메서드 | 반환 | 목적 |
|---|---|---|
Read(addr, buf, size) |
bool |
Raw RPM |
ReadString(addr) |
std::string |
Null-terminated narrow string |
ReadWString(addr) |
std::wstring |
Null-terminated wide string |
ReadStdWString(addr) |
std::wstring |
Reads a game-side std::wstring container (handles SSO) |
ReadStdVector(addr, elemSize, maxElems) |
std::vector<uint8_t> |
Raw bytes; reinterpret as your type |
GetBaseAddress() |
uintptr_t |
Game module base |
GetModuleSize() |
uintptr_t |
Game module size |
GetPatternAddress(name) |
uintptr_t |
Named pattern lookup |
| 메서드 | 목적 |
|---|---|
Debug / Info / Warn / Error(msg) |
Emit at the corresponding level |
Log(level, msg) |
Custom level string |
| 메서드 | 반환 | 목적 |
|---|---|---|
Subscribe(kind, cb) |
Token |
Generic dispatch |
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) |
Token |
One-line subscribe helpers |
Unsubscribe(token) |
— | Manual release (destructor auto-releases anyway) |
PluginAbi.h는 다음을 정의합니다.
constexpr int PLUGIN_SDK_VERSION = 6;로드 시 호스트는 plugin->GetSDKVersion()을 호출하여 자신의 PLUGIN_SDK_VERSION과 비교합니다. 불일치 시 호스트는 경고를 로그하고 플러그인 로드를 거부합니다.
호스트는 또한 PluginSDK_AttachHost 내부에서 HostAbi::version과 HostAbi::size_bytes를 확인합니다(PLUGIN_EXPORTS가 설정된 경우 PluginSDK.h에서 인라인으로 정의됨). 두 필드 중 하나라도 플러그인이 빌드된 것과 일치하지 않으면, ctx()는 동작하지 않습니다. 베이스 클래스 접근자 HostCompatible()은 이 경우 false를 반환하며, 예의 바른 플러그인이라면 동작을 거부해야 합니다.
void OnEnable(bool) override {
if (!HostCompatible()) {
ctx()->Log.Error("Host ABI mismatch — disable plugin");
return;
}
// ...
}저장소의 네 가지 플러그인은 문서처럼 읽을 수 있도록 설계되었습니다.
-
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) + 인벤토리 스캔 + 아이템별 가격. 실제 워크플로우에서 네트워크 코드, 서드 파티 데이터 수집, 인벤토리 순회를 보여줍니다.