-
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 (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/.
로드되어 호스트 로그에 메시지를 출력하는 최소한의 플러그인입니다.
#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*를 반환하며, 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()를 캐시하지 마세요.
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.엔티티는 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 체크됨).
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
}EnumerateSkillStats(skillDetailsAddr)는 하나의 스킬에 대해 게임 자체가 평가한 스탯 컨테이너를 노출합니다 — 게임 내 스킬 패널이 보여주는 DPS 계열도 포함됩니다. 동일 프레임의 EnumerateActiveSkills 결과에서 얻은 ActiveSkill::SkillDetailsAddr를 전달하세요 (스킬 주소는 프레임/지역 전환 사이에 오래된 값이 됩니다 — 오래된 주소는 안전하게 빈 벡터를 반환하며, 이 API 이전에 빌드된 호스트 역시 마찬가지입니다).
반환되는 각 SkillStatEntry는 {SetIndex, StatId, Value}입니다:
-
SetIndex 0은 스킬의 현재 컨텍스트 스탯 세트입니다 — 모든 스킬에 존재하며, 지속적이고(패널을 닫아도 유지됨), 스킬 패널 DPS 라인의 정확한 소스입니다. - 이후의 세트들은 스킬의 파트별 스탯 세트입니다 — 소환/커맨드 스킬의 경우 미니언 측 스탯이 여기에 있습니다.
-
StatId는 Stats.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.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
|
| 빠른 생명력 수치 |
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 값은 게임 측에서 안정적인 식별자이며, 존재한다면 하드코딩된 경로보다 선호하세요.
0.5.x 참고.
Ui.GetStringId()는 현행(0.5.x) 클라이언트에서 올바른 식별자를 반환합니다 — 요소의StringId필드 오프셋이 이동했고(0x448→0x4C0) 호스트 브리지도 이에 맞게 수정되었습니다. (트라이얼 HUD의 필드 리프가 값을 렌더링하는 숫자 StringId —GetText()와는 다른 필드 — 는SekhemaHelper가ctx()->Sekhema.GetUiStringId()로 읽습니다.)
세 가지 투영 헬퍼, 두 가지 좌표계.
원근 (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의 노란 슬롯 점과 보상별 마커를 구동합니다.
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 멤버로 추가해서는 안 됩니다.
ctx()->Sekhema는 Trial 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 아님) |
규칙: <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 |
프레임별 전체 뷰, Entities 포함 |
GetState() |
GameState |
열거형: InGame, Login, Loading 등 |
IsAttached() |
bool |
게임 프로세스가 연결됨 |
IsInGame() |
bool |
State가 InGame인지 여부 |
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
|
| 메서드 | 반환 | 목적 |
|---|---|---|
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> |
고정된 컴포넌트 읽기 |
| 메서드 | 반환 | 목적 |
|---|---|---|
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 |
월드 좌표용 편의 접근자 |
| 메서드 | 반환 | 목적 |
|---|---|---|
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 컨테이너 자동 해결 |
| 메서드 | 반환 | 목적 |
|---|---|---|
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 |
대상 자손 요소 조회 |
| 메서드 | 반환 | 목적 |
|---|---|---|
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 |
동일하며, 미니맵용 |
| 메서드 | 반환 | 목적 |
|---|---|---|
GetWalkableGrid() |
WalkableGridHandle |
타일당 4비트 보행 가능 비트맵에 대한 RAII 핸들 |
GetHeightGrid() |
HeightGridHandle |
타일별 지형 높이에 대한 RAII 핸들 |
IsWalkable(gx, gy) |
bool |
단일 타일 판정 |
GetTerrainHeight(gx, gy) |
float |
월드 공간 Z 값 |
GetWorldToGridConvertor() |
float |
월드 → 그리드 변환 계수 |
EnumerateTgtLocations(cb) |
— | 현재 지역의 모든 TGT 인스턴스 방문 |
| 메서드 | 반환 | 목적 |
|---|---|---|
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 |
이름 기반 패턴 조회 |
| 메서드 | 목적 |
|---|---|
Debug / Info / Warn / Error(msg) |
해당 레벨로 출력 |
Log(level, msg) |
사용자 지정 레벨 문자열 |
| 메서드 | 반환 | 목적 |
|---|---|---|
Subscribe(kind, cb) |
Token |
범용 디스패치 |
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) |
Token |
한 줄짜리 구독 헬퍼 |
Unsubscribe(token) |
— | 수동 해제 (소멸자가 어차피 자동으로 해제함) |
| 메서드 | 반환 | 목적 |
|---|---|---|
SetIncludeSleepingEntities(enable) |
— |
EntitiesService.Enumerate에서 EntityState::Useless 엔티티를 수신하도록 선택 |
SetWantsOverlayInput(enable) |
— | 오버레이가 클릭 통과 대신 마우스 클릭을 캡처하도록 요청 |
두 메서드 모두 멱등성이 있으며, 플러그인별로 적용되고, 호스트 + 다른 플러그인과 OR 집계되며, Disable/Unload 시 자동으로 초기화됩니다. 전체 패턴(맵 피커 플러그인의 백그라운드 드로우 리스트 주의사항 포함)은 섹션 13을 참고하세요.
| 메서드 | 반환 | 목적 |
|---|---|---|
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("플라스크 & 장신구")을 참고하세요.
| 메서드 | 반환 | 목적 |
|---|---|---|
LookupPrice(name) |
PriceResult |
표시 이름으로 호스트 측 가격 퍼지 조회 (found, chaos, divine, exalt, category) |
GetRates() |
PriceRates |
신성한 / 고귀한 → 카오스 변환율 |
GetStatus() |
PriceStatus |
loaded 게이트 + 카테고리별 카운트 (catsOk / catsPending / catsFailed) |
| 메서드 | 반환 | 목적 |
|---|---|---|
Runeshapes() |
std::vector<Runeshape> |
모든 해결된 Expedition2Encounter 장치 (id, color, anchor, bestIndex) |
Rewards(entityId) |
std::vector<RuneshapeReward> |
장치별 보상 슬롯, 각각 Prices 서비스를 통해 가격 책정 |
| 메서드 | 반환 | 목적 |
|---|---|---|
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 |
노드 표시 이름(해석 안 되면 "") |
| 메서드 | 반환 | 목적 |
|---|---|---|
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 필드) |
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) + 인벤토리 스캔 + 아이템별 가격. 실제 워크플로우에서 네트워크 코드, 서드 파티 데이터 수집, 인벤토리 순회를 보여줍니다.