-
Notifications
You must be signed in to change notification settings - Fork 0
Plugin Development Guide TH
ปลั๊กอินของ POEFixer คือ DLL C++ แบบเนทีฟที่โหลดในขณะรันไทม์จาก Plugins/<PluginName>/<PluginName>.dll ปลั๊กอินสามารถอ่านสถานะเกมแบบเรียลไทม์ วาด ImGui overlay บันทึกการตั้งค่าของตัวเอง และสมัครรับเหตุการณ์จากโฮสต์
SDK ของปลั๊กอินมีสถาปัตยกรรมสามชั้น:
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
- ผู้พัฒนาปลั๊กอินจะ include เฮดเดอร์เพียงไฟล์เดียว:
POEFixer/plugin_sdk/PluginSDK.h - เฮดเดอร์นั้นประกาศทุกอย่างใน namespace
PluginSDK::และดึง C ABI จากPluginAbi.hมาให้ภายใน คุณสามารถพูดถึงการมีอยู่ของไฟล์หลังได้ แต่แทบไม่จำเป็นต้องเปิดดู - คอนเทนเนอร์
std::*ทั้งหมดอยู่ภายในปลั๊กอิน DLL เท่านั้น มีเพียง POD เท่านั้นที่ข้ามขอบเขตโฮสต์ ซึ่งหมายความว่าปลั๊กอินที่สร้างด้วย toolchain คนละเวอร์ชันจะไม่ปะปนกับ STL ของโฮสต์ — มีเพียง integer, float, pointer และ struct ขนาดเล็กเท่านั้นที่ใช้ร่วมกัน
เฮดเดอร์ของ SDK อยู่ที่:
-
POEFixer/plugin_sdk/PluginSDK.h— wrapper C++ ที่ผู้เขียนปลั๊กอินใช้ -
POEFixer/plugin_sdk/PluginAbi.h— C ABI ล้วน ๆ ที่อยู่ด้านล่าง
ปลั๊กอินอ้างอิงที่มาพร้อมกับ repo (อ่านได้ในฐานะเอกสารประกอบ): 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 ต้อง export สัญลักษณ์สามรายการ:
-
CreatePlugin— โรงงานสร้าง คืนค่าเป็นPluginSDK::Plugin* -
DestroyPlugin— destructor รับPluginSDK::Plugin* -
PluginSDK_AttachHost— เชื่อมContextเข้ากับปลั๊กอิน ถูกประกาศไว้ในPluginSDK.hและ emit ให้อัตโนมัติเมื่อกำหนด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() คืนค่า path แบบ absolute เข้ารหัส UTF-8 ที่ชี้ไปยังไดเรกทอรีของ EXE โฮสต์ — ไม่ต้อง prepend path ของ EXE เอง สตริงไดเรกทอรีถูกเก็บแบบ by-value ภายใน PluginSDK::Plugin จึงคงอยู่ผ่านการ realloc คอนเทนเนอร์ของโฮสต์และวงจรการ reload ได้โดยไม่ต้องกังวลเรื่อง lifetime
หากต้องการวาด 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 context ของโฮสต์:
if (ctx()->ImGuiContext)
ImGui::SetCurrentContext(static_cast<ImGuiContext*>(ctx()->ImGuiContext));PluginSDK::Plugin เป็น virtual base class ให้ override เมธอดเหล่านี้ในปลั๊กอินของคุณ (ตามลำดับการเรียกโดยประมาณ):
| Method | Called when | Typical use |
|---|---|---|
const char* GetName() const |
ครั้งเดียวหลังการสร้างวัตถุ | คืนชื่อแสดงของปลั๊กอิน |
void OnEnable(bool isGameAttached) |
เมื่อผู้ใช้เปิดใช้งานปลั๊กอิน (หรือตอนเริ่มต้นหากบันทึกไว้) | โหลดการตั้งค่า สมัครรับ event เชื่อมต่อ ImGui context |
void DrawSettings() |
ทุกเฟรมที่แผงการตั้งค่าของปลั๊กอินเปิดอยู่ | ตัวควบคุม ImGui สำหรับการกำหนดค่า |
void DrawUI() |
ทุกเฟรมที่ปลั๊กอินเปิดใช้งาน | วาด overlay ImGui (ใช้ ImGui::GetBackgroundDrawList() สำหรับ overlay ในเกม) |
bool WantsOverlay() const |
สำรวจทุกเฟรม | คืน true ถ้าต้องการให้โฮสต์อยู่ในโหมด overlay (click-through) |
void SaveSettings() |
เป็นระยะ (~5 วินาที) และตอนปิดใช้งาน | บันทึกการกำหนดค่าลงดิสก์ |
void OnDisable() |
เมื่อผู้ใช้ปิดใช้งาน หรือตอนโฮสต์ shutdown | ปล่อยทรัพยากร ยกเลิกการรับ event |
มีเพียง GetName เท่านั้นที่จำเป็น ส่วนที่เหลือมีค่า default ที่ปลอดภัย
โฮสต์ยังเรียก GetSDKVersion() (ประกาศไว้ใน base class อย่า override) ทันทีหลัง CreatePlugin เพื่อตรวจสอบว่าปลั๊กอินและโฮสต์เข้ากันได้ หากไม่ตรงกัน → ปลั๊กอินจะถูกปฏิเสธ
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
};ภาพรวมแบบเร็ว ๆ — แต่ละบริการมีไว้ทำอะไร:
| Service | What you reach for it |
|---|---|
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}
|
OverlayService |
SetIncludeSleepingEntities / SetWantsOverlayInput — เป็นมิตรกับ map-picker |
FlasksService |
ขวด life/mana + charm — charges, Usable, Active, การใช้ต่อครั้ง, จำนวน mod |
PricesService |
LookupPrice / GetRates / GetStatus — ราคา poe2scout ที่โหลดโดยโฮสต์ แชร์กับทุกปลั๊กอิน |
RuneshapeService |
Runeshapes / Rewards — อุปกรณ์ Expedition2Encounter ที่แก้ไขแล้ว + รางวัลต่ออุปกรณ์ |
AtlasService |
GetPanel / Nodes / Connections / Selection / GetLineSeed / Weights — ข้อมูลสดของพาเนล endgame atlas |
SekhemaService |
GetPanel / GetFloor / Rooms / Content / การอ่าน flag ของห้อง — ข้อมูลแผนที่ชั้นของ Trial of the Sekhemas |
ctx() ใช้งานได้ตั้งแต่วินาทีที่โฮสต์เรียก OnEnable จนกระทั่ง OnDisable คืนค่ากลับ ห้าม cache ctx() ข้ามการ hot-reload หรือขอบเขตการ unload DLL
ctx()->Game.GetSnapshot() คืนค่า Snapshot แบบ value type — มุมมองที่สมบูรณ์และไม่เปลี่ยนแปลงของเฟรมปัจจุบัน 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ข้อมูลที่ snapshot นำมาด้วยโดยตรง (ไม่ต้องเรียกบริการเพิ่ม):
- State + flags:
State,IsAttached,IsWindowValid,GameWindowForeground,IsTown,IsHideout,IsPaused,IsSkillTreeVisible - Area:
CurrentAreaName,CurrentAreaHash,CurrentAreaLevel,AreaChangeCounter - World:
Player(Entityแบบเต็ม),Entities(std::vector<Entity>แบบเต็ม),Vitals,LargeMap,MiniMap,WorldToScreenMatrix[16] - Window:
ScreenWidth,ScreenHeight,ProcessId,GameWindow,LastUpdateTime,WorldToGridConvertor
สิ่งที่ ไม่ได้ อยู่ใน snapshot — ต้องดึงผ่านบริการ: เนื้อหา inventory (InventoryService), buff (ComponentsService::EnumerateBuffs), รายการ mod ต่อไอเทม (InventoryService::ReadItemMods), แผง UI (UiService)
ตัวช่วยราคาถูกเมื่อคุณไม่ต้องการ snapshot แบบเต็ม:
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) — เป็นการอ่านแบบ host-tail ที่ส่งผ่าน GameService (ตระกูลเดียวกับ 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.เอนทิตีเปิดเผย component ของตนผ่าน entity.Components ซึ่งเป็น struct ComponentAddresses ที่บรรจุ address แบบ uintptr_t ส่งแต่ละ address ให้ ComponentsService::Read* ที่สอดคล้องเพื่อรับ snapshot แบบ value type
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");
}
}มีตัวอ่าน component ทั้งหมด 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 เป็นตัวบ่งชี้การมีอยู่ — รายการ buff จริง ๆ มาจาก EnumerateBuffs ส่วน WorldItem / AreaTransition เป็นตัวบ่งชี้ประเภทเอนทิตีมากกว่าเป็น component จริง ทุกช่องมี predicate HasX() ที่ตรงกันใน ComponentAddresses
ตัวอ่านแบบ collection สำหรับ component ที่มีข้อมูลขนาดผันแปร:
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>ตัวช่วยอำนวยความสะดวก (one-shot — เรียก 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)) { ... }ทุก struct ที่คืนค่ามี flag Valid ช่วยให้คุณจัดการกรณี "address ของ component เป็น 0 / อ่านล้มเหลว" ได้โดยไม่ต้องใช้ exception หากคุณมี struct หลักอยู่แล้ว (Life, Mods, ...) ให้เข้าถึง field โดยตรงแทนการเรียกตัวช่วยซ้ำ — ตัวช่วยจะอ่าน component ใหม่ทุกครั้ง
การแยกแยะ ground effect — ground effect หลายตัวใช้ path เอนทิตีเดียวกันคือ Metadata/Effects/Spells/ground_effects/VisibleServerGroundEffect ดังนั้น path เพียงอย่างเดียวไม่สามารถแยกแยะ Shocked Ground จาก Burning Ground ได้ ReadGroundEffect แก้ไข component GroundEffect ของเอนทิตีและแถว groundeffects.datc64 ของมัน ส่ง address ของเอนทิตี (component GroundEffect ไม่ได้อยู่ใน Components ดังนั้นโฮสต์จะแก้ไขให้คุณ — ตามแบบแผนเดียวกับ ReadPathfinding) จากนั้นจับคู่ด้วย TypeId ซึ่งเป็น key ที่เสถียรและไม่ขึ้นกับ patch:
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") {
// ไฮไลต์ตามการตั้งค่าของคุณ (สี/alpha ตาม ge.TypeId)
}
}struct GroundEffect:
| Field | Meaning |
|---|---|
Valid |
false หากเอนทิตีไม่มี component GroundEffect หรืออ่านล้มเหลว |
TypeId |
Id ของ groundeffecttypes — key ที่เสถียรสำหรับจับคู่ (เช่น ShockedGround) |
Radius |
รัศมีของ effect ในหน่วยโลก; 0 เมื่อ variant นั้นไม่ได้กำหนดค่า |
EndEffect |
พฤติกรรมการสิ้นสุด: fadeout / close / end
|
BuffVisual1 |
Id ของ buffvisuals (เช่น ground_fire_burn_white); ว่างเปล่าหากไม่ได้กำหนด |
BuffVisual2 |
Name ของ buffdefinitions (เช่น ground_tar_gold); ว่างเปล่าหากไม่ได้กำหนด |
AoFile |
พาธ visual .ao/.aoc แรก; ว่างเปล่าหากไม่มี |
GroundEffectsRowAddr / GroundEffectTypesRowAddr
|
ตัวชี้ dat-row แบบ raw (เสถียรตลอดเซสชัน) สำหรับการอ้างอิงข้ามขั้นสูง |
ตำแหน่งโลกของ effect มาจากเอนทิตีเอง (Entity.WorldX/Y/Z หรือ component Render/Positioned) จึงไม่ถูกทำซ้ำใน struct ReadGroundEffect อ่านใหม่ทุกครั้งที่เรียก ดังนั้นควร cache ผลลัพธ์ตามช่วงเวลาการสแกน จะคืน GroundEffect ที่ไม่ valid บนโฮสต์ที่สร้างก่อน API นี้ (อยู่ในส่วนท้ายแบบ append-only ของ SDK v6 และถูกตรวจสอบ null)
EnumerateSkillStats(skillDetailsAddr) เปิดเผย container สถิติที่เกมประเมินไว้เองสำหรับทักษะหนึ่งตัว — รวมถึงกลุ่มสถิติ DPS ที่แผงทักษะในเกมแสดงผล ส่ง ActiveSkill::SkillDetailsAddr จากผลลัพธ์ของ EnumerateActiveSkills ที่มาจากเฟรมเดียวกัน (address ของทักษะจะล้าสมัยข้ามเฟรม/การเปลี่ยนพื้นที่ — address ที่ล้าสมัยจะคืนค่า vector ว่างอย่างปลอดภัย เช่นเดียวกับโฮสต์ที่สร้างขึ้นก่อน API นี้)
SkillStatEntry แต่ละตัวที่คืนค่ามีรูปแบบ {SetIndex, StatId, Value}:
-
SetIndex 0คือชุดสถิติของบริบทปัจจุบันของทักษะ — มีอยู่ในทุกทักษะ คงอยู่ถาวร (รอดจากการปิดแผง) และเป็นแหล่งที่มาที่แท้จริงของบรรทัด DPS ในแผงทักษะ - ชุดถัดไปเป็นชุดสถิติแบบแยกตามส่วนของทักษะ (per-part) — สำหรับทักษะเรียกซัมมอน/สั่งการ สถิติฝั่งมินเนียนจะอยู่ตรงนี้
-
StatIdคือ แถวในStats.dat+ 1 (คีย์สถิติที่เอนจินใช้ตอนรันไทม์;0คือค่า sentinel "ไม่มีสถิติ" ของเกม) หาชื่อสถิติได้ด้วยการ dumpStats.dat -
Valueเป็นค่าint32แบบ raw; สถิติในกลุ่ม DPS หลายตัวเป็นแบบ fixed-point ×100
Id รันไทม์ที่มีประโยชน์:
| StatId | Stat (Stats.dat row + 1) | Scaling |
|---|---|---|
| 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 เป็นค่าเสมือน เอนจินคำนวณสถิติเหล่านี้ผ่าน callback (
DPS = rate/100 × avg damage) และเก็บผลลัพธ์ไว้เฉพาะบริบทที่เคยแสดงผลเท่านั้น ชุดของบริบทปัจจุบันเก็บค่าล่าสุดที่ตัวเกมเองประเมินไว้ ส่วนบริบทอื่น ๆ (แท็บ tooltip ของ infusion, พรีวิวตอนสลับชุดอาวุธ) จะถูกประเมินแบบชั่วคราวตอนโฮเวอร์เท่านั้น และไม่สามารถอ่านค่าแบบคงอยู่ถาวรได้ ด้วยเหตุนี้ค่าที่อ่านได้อาจตามหลัง tooltip สด ๆ อยู่สองสามเปอร์เซ็นต์บนมอนสเตอร์ที่มีบัฟดาเมจแบบไดนามิก — รายการทักษะและ tooltip ของเกมเองก็ไม่ตรงกันในลักษณะเดียวกัน -
DPS ของมินเนียนอยู่ที่ตัวมินเนียนเอง ชุดสถิติของทักษะซัมมอนเองอธิบายเฉพาะตัวซัมมอนเท่านั้น ส่วนตัวเลข "Basic Attack" ใน tooltip มาจาก
Actorของเอนทิตีมินเนียน — ให้สำรวจเอนทิตีทั้งหมด หามอนสเตอร์ที่เป็นมิตร แล้วเรียกEnumerateActiveSkills(minion.Components.Actor)→EnumerateSkillStats(...)บนทักษะโจมตีของมัน -
EnumerateActiveSkillsคืนค่า สองรายการต่อชื่อทักษะ (บริบทการประเมินที่ต่างกัน เช่น ชุดอาวุธที่ต่างกัน) — สอบถามทั้งสองรายการหากคุณกำลังตามหาสถิติตัวใดตัวหนึ่งโดยเฉพาะ
ทุก Entity (รวมถึง snap.Player และสมาชิกของ snap.Entities) มี field ชุดเดียวกัน:
| Group | Fields |
|---|---|
| Identity |
Id, Address, EntityDetailsAddress, RenderComponentAddress, IsValid
|
| Classification |
EntityType, EntitySubtype, EntityState, Rarity, Reaction, Zone (NearbyZone: InnerCircle≈60 / OuterCircle≈120 / Far) |
| Position |
GridPositionX, GridPositionY, TerrainHeight, WorldX/Y/Z, ModelBoundsZ
|
| Quick vitals |
CurrentHP, MaxHP, CurrentES, MaxES (avoids a ReadLife if you only need the totals) |
| Strings |
Path (std::wstring, Metadata/...), PlayerName (std::wstring), TgtPath (std::string, asset path) |
| State |
IsSleeping, IsChestOpened
|
| Components |
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> สำหรับการค้นหาแบบ one-shot และ GetPlayer() คืนผู้เล่นโลคัลเสมอ
ไอเทมที่ถูกทิ้งลงบนพื้นจะปรากฏใน snap.Entities เป็นเอนทิตี EntityType::Item ที่พาธ Metadata/MiscellaneousObjects/WorldItem ซึ่งเป็น เอนทิตี container — ไม่ได้บรรจุ Mods / Base / Stack / Sockets โดยตรง เอนทิตีไอเทมจริงอยู่ห่างออกไปอีกหนึ่ง indirection
เพื่อรับเอนทิตีไอเทมด้านในเป็น snapshot 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 container จริงเท่านั้น — การเรียกด้วย address ของไอเทมในอินเวนทอรี่จะคืน std::nullopt หากคุณต้องการรูปแบบข้อมูลแบบเดียวกับไอเทมในอินเวนทอรี่ (โดยไม่ต้องเดิน component เอง) กลุ่ม Inventory.ReadItem* ในหัวข้อถัดไปจะ resolve WorldItem container ให้แบบโปร่งใส
ctx()->Inventory.Scan(inventoryId) ทริกเกอร์การสแกนใหม่ฝั่งโฮสต์ ใช้ -1 เพื่อสแกน inventory ทั้งหมด
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 ยังเปิดเผย struct Grid ซึ่งอธิบายตำแหน่งที่ inventory ถูกวาดบนหน้าจอ:
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)
}หากต้องการดึง inventory เดี่ยวด้วย id (คืน struct เดิมที่ Items ถูกเติมไว้แล้ว):
PluginSDK::Inventory backpack = ctx()->Inventory.Get(/*inventoryId=*/0);หรือถ้าต้องการเพียง vector ของไอเทมโดยไม่ต้องการ struct ครอบ:
std::vector<PluginSDK::InventoryItem> items = ctx()->Inventory.GetItems(0);ComponentsService::ReadMods(addr) คืนเฉพาะ flag สรุป (IsCorrupted, IsRelic, IsSplit, IsMirrored, IsSynthesised, IsIdentified, Rarity, ItemLevel, RequiredLevel, CraftedModCount) ไม่ได้ บรรจุรายการ mod ต่อประเภท
สำหรับภาพรวมทั้งหมด (สรุป + รายการ mod) ให้ใช้ 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) { ... }การอ่านโดยตรงต่อเอนทิตีอื่น ๆ (ถูกกว่าการสแกนใหม่เมื่อคุณมี address ของไอเทมอยู่แล้ว):
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);ไอเทมบนพื้นผ่าน inventory API การอ่าน Inventory.ReadItem* ทั้งเจ็ดด้านบน (และ ReadItemMods) รับ ทั้ง address ของไอเทมในอินเวนทอรี่ และ address ของ WorldItem container address ของ container จะถูก resolve เป็นไอเทมด้านในก่อนอ่านโดยอัตโนมัติ ดังนั้นโค้ดปลั๊กอินชุดเดียวกันใช้ได้ทั้งกับไอเทมในกระเป๋าและไอเทมบนพื้น:
// `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);หากคุณต้องการ address ของ component ภายในไอเทมโดยตรง (เช่น เพื่อเรียก ctx()->Components.ReadStack(...) หรือเดิน socket) ให้ใช้ Entities.GetWorldItemInner จากหัวข้อ 7 แทน
ข้อความ mod แบบในเกม + สถิติพื้นฐาน / สถิติรวม (v6, 2026-06-24). แปลง stat key ใด ๆ ให้เป็นข้อความเดียวกับที่ tooltip ในเกมแสดง และอ่านค่าป้องกันพื้นฐานของไอเทมรวมถึงคุณสมบัติ map/waystone แบบรวม:
// แสดง mod ในแบบเดียวกับที่เกมแสดง เช่น "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 คือค่าพื้นฐานของไอเทม Valid == false เมื่อไอเทม
// ไม่มี Armour component (สกุลเงิน, เพชร, เครื่องประดับ, waystone, ...)
PluginSDK::ItemBaseStats bs = ctx()->Inventory.ReadItemBaseStats(item.Address);
if (bs.Valid) { /* bs.EnergyShield, bs.Ward, bs.Armour, bs.Evasion */ }
// สถิติรวมที่ใช้ stat id เป็น key — เช่น Item Rarity ของ waystone (8205),
// Pack Size (8206), Monster Rarity (8207), Monster Effectiveness (8208),
// Waystone Drop Chance (8209)
for (const auto& [statId, value] : ctx()->Inventory.ReadItemAggregatedStats(item.Address)) {
// map statId -> label ด้วยตัวเอง; ค่าเป็น signed percentage
}FormatStat ใช้ชุดคำอธิบาย stat .csd ของโฮสต์ (ดาวน์โหลดในการใช้งานครั้งแรก) ดังนั้นจะคืนค่าสตริงว่างจนกว่าข้อมูลนั้นจะพร้อม — ให้ใช้ field Mod แบบ raw เป็น fallback แทน ReadItemBaseStats / ReadItemAggregatedStats รับทั้ง address ของไอเทมในอินเวนทอรี่ และ address ของ WorldItem container
ต้นไม้ UI ของเกมถูกเปิดเผยเป็น address ของ element แบบ uintptr_t เริ่มจาก root เดิน child ไป และอ่าน field ของ element
วิธีที่สะอาดในการค้นหาแผงที่รู้จักจาก StringId:
uintptr_t gameUiRoot = ctx()->Ui.GetGameUiRoot();
uintptr_t invPanel = ctx()->Ui.FindPanelByStringId(gameUiRoot, "Inventory");
if (invPanel && ctx()->Ui.IsVisible(invPanel)) {
// panel is on-screen
}การเดินต้นไม้ด้วยมือเมื่อคุณไม่รู้ StringId:
uintptr_t root = ctx()->Ui.GetUiRoot();
PluginSDK::UiElement e = ctx()->Ui.Read(root);
ctx()->Log.Info(("children=" + std::to_string(e.ChildCount)).c_str());
for (uintptr_t child : ctx()->Ui.GetChildren(root)) {
std::string sid = ctx()->Ui.GetStringId(child);
if (sid == "InventoriesPanel") { /* found it */ }
}
// Or use a known index path:
int path[] = { 5, 1, 2, 0 };
uintptr_t logInButton = ctx()->Ui.FollowPath(root, path, 4);
// Compute screen-space rect (post-scale, post-transform):
float x, y, w, h;
if (ctx()->Ui.ComputeScreenRect(invPanel, x, y, w, h)) {
// draw an overlay box at (x,y,w,h)
}
// Get displayed text:
std::string label = ctx()->Ui.GetText(child);
int cull = ctx()->Ui.GetCullValue(); // host's UI cull thresholdค่า StringId เป็นตัวระบุที่เสถียรฝั่งเกม ควรใช้แทน path แบบ hardcode เมื่อมีให้ใช้
หมายเหตุ 0.5.x
Ui.GetStringId()คืนค่า identifier ที่ถูกต้องบนไคลเอนต์ปัจจุบัน (0.5.x) — offset ของฟิลด์StringIdของ element ย้ายไปแล้ว (0x448→0x4C0) และ bridge ฝั่งโฮสต์ได้รับการแก้ไขให้ตรงกัน (สำหรับ StringId แบบตัวเลขที่ field-leaf ของ HUD ใน trial เรนเดอร์ค่าลงไป — เป็นคนละฟิลด์กับGetText()—SekhemaHelperอ่านผ่านctx()->Sekhema.GetUiStringId())
มีตัวช่วยการฉายสามตัว ระบบพิกัดสองระบบ
Perspective (โลก 3D → หน้าจอ) — เป็น projection แบบเดียวกับที่เกมใช้วาดสิ่งของในโลก เหมาะสำหรับ nameplate, debug marker, ตัวบ่งชี้เป้าหมาย:
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));
}Isometric (grid → minimap) — สำหรับ overlay แบบเรดาร์ที่วาดบนแผนที่ใหญ่หรือ minimap ตัวเหล่านี้เคารพการซูม การ pan และการหมุนของแผนที่ที่มองเห็น:
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)สำหรับการคำนวณแบบ batched (ข้ามการเรียกฟังก์ชันต่อเอนทิตี) ให้คว้า transform ครั้งเดียวและทำ projection แบบ inline:
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 สำหรับเรดาร์ที่ใช้งานได้ซึ่งสร้างขึ้นบนการเรียกเหล่านี้ทั้งหมด
ตาราง walkable คือ bitmap แบบ 4-bit ต่อ tile ที่ระบุว่าผู้เล่นสามารถเหยียบเซลล์ภูมิประเทศใดได้บ้าง โฮสต์จะอัปเดตทุกครั้งที่มีการเปลี่ยนพื้นที่ ปลั๊กอินจะได้รับ handle ที่เสถียรซึ่งคงอยู่จนกว่าปลั๊กอินจะปล่อยมัน (ผ่าน 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 หนึ่งค่าต่อ tile (Data() เป็น const float* พร้อม ElementCount() และ SizeBytes())
อย่าสมัครรับ OnAreaChange เพื่อรีเฟรช handle event จะถูก fire เมื่อ worker ของโฮสต์ตรวจพบการเปลี่ยนพื้นที่ แต่ตาราง walkable ใหม่อาจยังไม่ถูก parse — คุณจะถือ pointer เก่าค้างไว้หนึ่งหรือสองเฟรม แทนที่จะเป็นเช่นนั้น ให้สำรวจทุกเฟรมใน DrawUI:
auto current = ctx()->Terrain.GetWalkableGrid();
if (current.Data() != m_walkable.Data()) {
m_walkable = std::move(current); // swap when the host re-parses
}วิธีนี้ราคาถูก (เรียก ABI หนึ่งครั้ง + เปรียบเทียบ pointer หนึ่งครั้ง) ดูเวอร์ชันโปรดักชันใน 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
});สมัครรับ event ที่ปล่อยมาจากโฮสต์ แต่ละ Subscribe จะคืน Token ที่คุณสามารถนำไปส่งให้ Unsubscribe ในภายหลัง destructor ของ EventsService (ทำงานเมื่อปลั๊กอินถูกปิดใช้งานหรือ unload) จะปล่อย token ที่ค้างอยู่ทั้งหมดอัตโนมัติ ดังนั้นจึงไม่ จำเป็น ต้อง unsubscribe ด้วยมือ แต่เป็นการปฏิบัติที่ดี
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);
}
};ประเภทของ event มีสี่ชนิดคือ AreaChange, Frame, GameAttached, GameDetached นอกจากนี้ยังมี Subscribe(EventKind, callback) ทั่วไปหากคุณต้องการสร้างตาราง dispatch เอง
const_cast จำเป็นเพราะ Events มีการเปลี่ยนแปลงตาราง token ภายใน base class คืน const Context* เพื่อป้องกันการแก้ไขบริการอื่น ๆ โดยไม่ได้ตั้งใจ
หากปลั๊กอินของคุณเป็นเจ้าของ subscription หลายตัว รูปแบบของ ExamplePlugin เป็นวิธีที่สะอาดในการให้การเปิด/ปิดสมมาตรกัน — รวม token และตัวนับเข้าไว้ใน state struct เดียวและส่งผ่านทุกอย่างผ่านคู่ 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 เปิดเผย "แฟล็กการร้องขอ" ต่อปลั๊กอินสองตัวที่เปลี่ยนพฤติกรรมระดับ overlay ทั้งหมด โฮสต์จัดเก็บสถานะต่อปลั๊กอินโดยใช้ตัวชี้ this ของคุณเป็นคีย์ และรวมด้วย OR กับแฟล็กบิลต์อินของโฮสต์เอง (การมองเห็นเมนูหลัก, การล็อก AutoCraft, ตัวเลือก Add-Entity-from-Map บิลต์อินของโฮสต์, แฟล็กของปลั๊กอินอื่น ๆ ทุกตัว) ถูกล้างอัตโนมัติเมื่อปลั๊กอินถูกปิดใช้งาน/unload — ปลั๊กอินที่ crash หรือมีบั๊กไม่สามารถทำให้ overlay ติดค้างในสถานะแปลก ๆ อย่างถาวรได้
โดยค่าเริ่มต้น EntitiesService.Enumerate และ Snapshot.Entities จะซ่อนเอนทิตีที่มี EntityState::Useless (ตัวกรอง "หลับ" ของโฮสต์ที่ตัดมอนสเตอร์/NPC/หีบที่อยู่ไกลออกไปและไม่ได้ใช้งาน) ซึ่งช่วยให้ค่าใช้จ่าย snapshot ต่อเฟรมอยู่ในขอบเขตที่กำหนด — พื้นที่ทั่วไปมีเอนทิตี Useless หลายร้อยตัวที่ปลั๊กอินไม่สนใจ
สำหรับ UI ตัวเลือกแผนที่และผู้ดู debug ที่ต้องการ entity pool เต็มของพื้นที่ (เพื่อให้ผู้ใช้คลิกเอนทิตีที่ยังไม่ได้ใช้งาน) ให้ปิดตัวกรอง:
ctx()->Overlay.SetIncludeSleepingEntities(true);
// ตอนนี้ ctx()->Entities.Enumerate จะเห็นเอนทิตี Useless ด้วย
// Entity::IsSleeping จะระบุโดยเฉพาะเจาะจงว่าเอนทิตีนั้นมาจาก
// คอลเลกชัน SleepingEntities แยกต่างหากของโฮสต์ (มุมฉากกับ EntityState::Useless)Cost: เพิ่มขึ้นประมาณ +5–15% ของ CPU snapshot ต่อเฟรมในขณะที่เปิดใช้งาน ปิดไว้ เว้นแต่จะจำเป็น
หน้าต่าง overlay โดยปกติจะเป็น click-through (WS_EX_TRANSPARENT): ทุกการคลิกเมาส์จะผ่านไปถึงเกมด้านล่างโดยตรง นี่เป็นค่าเริ่มต้นที่เหมาะสมสำหรับ overlay แบบอ่านอย่างเดียว (radar, health bar, ตัวแสดง DPS) — ผู้เล่นยังคงเล่นได้โดยไม่สังเกตเห็น overlay
ในขณะที่ปลั๊กอินของคุณต้องการให้ผู้ใช้ คลิกบางอย่างภายใน overlay — ยืนยัน popup, เลือกเอนทิตีบนแผนที่, ลาก marker — ค่าเริ่มต้นนั้นจะพังลง SetWantsOverlayInput(true) ขอให้โฮสต์เริ่มจับการคลิกเมาส์ตรงที่หน้าต่าง ImGui ของคุณปิดบัง:
ctx()->Overlay.SetWantsOverlayInput(true);
// ...
// เมื่อเสร็จสิ้น (ผู้ใช้เลือก, ปิด popup, กด Escape):
ctx()->Overlay.SetWantsOverlayInput(false);ลอจิกต่อเฟรมของโฮสต์จะจับการคลิก เฉพาะ ที่เคอร์เซอร์อยู่เหนือหน้าต่าง ImGui ที่มองเห็นได้ — ที่อื่น click-through จะยังคงอยู่เพื่อให้ผู้เล่นยังคงเคลื่อนที่/โจมตี/เก็บไอเทมรอบ popup ของคุณได้
Scope (important):
- Mouse buttons (LMB/RMB): ใช่ — ถูกควบคุมโดยแฟล็กนี้
- Mouse position / hover: ใช้งานได้เสมอโดยไม่คำนึงถึง โฮเวอร์ tooltip ไม่ต้องการแฟล็กนี้
-
Keyboard: ถึงปลั๊กอินเสมอผ่าน WindowProc ของโฮสต์ โดยไม่ขึ้นกับแฟล็กนี้
ImGui::IsKeyPressed(ImGuiKey_Escape)ทำงานได้ทั้งสองกรณี
หากคุณวาด marker ที่คลิกได้ผ่าน ImGui::GetBackgroundDrawList() (ทั่วไปสำหรับ overlay radar/large-map) background draw list ไม่มีหน้าต่าง ImGui รองรับ การทดสอบ hit ของโฮสต์จะเดินผ่าน ctx->Windows ไม่พบอะไรใต้เคอร์เซอร์ และเปิดใช้งาน click-through ใหม่ — marker ของคุณมองเห็นได้แต่คลิกไม่ได้
วิธีแก้ไข: เปิดหน้าต่าง ImGui จริงที่ปิดบังพื้นที่ตัวเลือกของคุณและใส่ ImGui::InvisibleButton ภายใน หน้าต่างคือสิ่งที่โฮสต์ทดสอบ hit; 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();
// วาด marker ผ่าน 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);
// สลับด้วย hotkey Keyboard ถึงปลั๊กอินโดยไม่คำนึงถึงสถานะการจับ
// ดังนั้นสิ่งนี้ทำงานได้แม้ว่า overlay จะเป็น click-through
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;
// ... picker UI (ดู Background-draw-list caveat ด้านบนสำหรับรูปแบบ
// ImGui::Begin + InvisibleButton)
}
void OnDisable() override {
// เพื่อความปลอดภัย โฮสต์ยังล้างแฟล็กเมื่อปิดใช้งาน แต่
// การล้างอย่างชัดเจนช่วยให้สถานะสอดคล้องกัน หากเฟรมซิงโครนัสใด
// ทำงานระหว่าง OnDisable กับการจองสมุดบัญชีของ PluginManager
ctx()->Overlay.SetIncludeSleepingEntities(false);
ctx()->Overlay.SetWantsOverlayInput (false);
}
};- แฟล็กทั้งสองเป็น idempotent — การเรียก
Set(true)สองครั้งติดต่อกันจะไม่นับเพิ่มซ้ำซ้อน - ทั้งสองถูก รวมด้วย OR กับสถานะของโฮสต์เองและแฟล็กของปลั๊กอินอื่น ๆ ทุกตัว ปลั๊กอินหลายตัวในโหมด picker พร้อมกันอยู่ร่วมกันได้ดี
- โฮสต์ ล้างอัตโนมัติ แฟล็กทั้งหมดของปลั๊กอินเมื่อปลั๊กอินถูกปิดใช้งาน (ผ่านแท็บ Plugins, crash-disable, หรือ shutdown) การ crash กลางเฟรมจะไม่ทำให้ overlay ติดค้างในโหมดจับอย่างถาวร — แต่ปลั๊กอินที่มีพฤติกรรมดียังคงจับคู่การเรียก on/off เพื่อให้ปลั๊กอินอื่น ๆ และเกมยังคงตอบสนองในระหว่างนั้น
- Latency: การเรียก Set จะอัปเดทแฟล็กแบบซิงโครนัส แต่การเปลี่ยนแปลงพฤติกรรมที่มีผลจะปรากฏในเฟรมโฮสต์ถัดไป (overlay input) หรือ tick ของ GameClient worker ถัดไป (sleeping entities) ต่ำกว่าเฟรม ไม่สังเกตได้
- ทุกเมธอดปลอดภัย สำหรับเรียกจาก thread ใดก็ได้
โฮสต์โหลดราคาตลาด ครั้งเดียวต่อเซสชัน จาก poe2scout บน background thread และเปิดเผยผ่าน ctx()->Prices ให้ทุกปลั๊กอิน ปลั๊กอิน ไม่ ดึงราคาเอง — มีฐานข้อมูลราคาที่แชร์กันเพียงชุดเดียวที่อยู่เบื้องหลัง radar บิลต์อิน, overlay ของโฮสต์, และปลั๊กอินทั้งหมด ดังนั้น API จึงถูกเรียกครั้งเดียวแทนที่จะเป็นครั้งเดียวต่อผู้บริโภค
- ลีก ราคาถูกเลือกโดยผู้ใช้ใน Configuration → Settings (ค่าเริ่มต้น Runes of Aldur) และถูกคงไว้ฝั่งโฮสต์ ปลั๊กอินจะอ่านลีกที่ผู้ใช้เลือกเสมอ ไม่ได้เลือกเอง
- การโหลดเป็น ยิงครั้งเดียวพร้อม backoff ต่อหมวดหมู่ (ลองซ้ำหลังจาก 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,
// …, หรือหมวดหมู่ unique-item) มีประโยชน์สำหรับแยกแยะ currency กับ unique
}LookupPrice รับ ชื่อที่แสดง ของไอเทม (ชื่อ currency, ชื่อ unique, หรือ base type) และทำการจับคู่แบบ fuzzy ฝั่งโฮสต์ในทุกหมวดหมู่ที่โหลด การไม่พบจะคืน found == false พร้อมราคาเป็นศูนย์ทั้งหมด
PriceResult field |
Type | Meaning |
|---|---|---|
found |
bool |
พบราคาที่ตรงกัน |
chaos |
float |
ราคาใน Chaos Orbs (หน่วยมาตรฐาน) |
divine |
float |
ราคาเดียวกันในหน่วย Divine Orbs |
exalt |
float |
ราคาเดียวกันในหน่วย Exalted Orbs |
category |
std::string |
หมวดหมู่ poe2scout ที่ตรงกัน (currency / fragments / runes / … / หมวดหมู่ unique) |
PluginSDK::PriceRates r = ctx()->Prices.GetRates(); // divineInChaos, exaltedInChaos
PluginSDK::PriceStatus s = ctx()->Prices.GetStatus();
if (!s.loaded) {
// ยังไม่พร้อม (กำลังโหลด หรือทุกหมวดหมู่ล้มเหลว)
// s.catsOk / s.catsPending / s.catsFailed แสดงว่า loader ดำเนินไปได้ไกลแค่ไหน
}GetStatus().loaded คือเกตที่ต้องตรวจสอบก่อนแสดงราคา — จะเป็น true เมื่อได้รับอัตราการแปลงพร้อมกับหมวดหมู่แรกอย่างน้อยแล้ว จนกว่าจะถึงจุดนั้น ให้แสดงสถานะ "กำลังโหลดราคา…" แทนที่จะเป็นศูนย์
ctx()->Runeshape เปิดเผยอุปกรณ์ Expedition2Encounter ("Runeshape") ที่โฮสต์ได้แก้ไขแล้วในพื้นที่ปัจจุบัน พร้อมกับรางวัลที่สูตรแต่ละสูตรจะมอบให้ โฮสต์ทำการเดินห่วงโซ่อุปกรณ์และจับคู่สูตรแบบออฟไลน์ — ปลั๊กอินของคุณเพียงแค่อ่านผลลัพธ์ นี่คือสิ่งที่ขับเคลื่อนแท็กรางวัลของ radar ในตัวและหน้าต่าง Runeshape ของ NinjaPricer — ปลั๊กอินจากบุคคลที่สามสามารถแสดงผลข้อมูลเดียวกันได้
for (const PluginSDK::Runeshape& rs : ctx()->Runeshape.Runeshapes()) {
// rs.color ให้สีที่แตกต่างกันแก่อุปกรณ์แต่ละตัว (ใช้สำหรับการจัดกลุ่ม/การแรเงา)
// rs.bestIndex คือ index ของรางวัลที่ราคาสูงสุด (หรือ -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 field |
Type | Meaning |
|---|---|---|
entityId |
uint64_t |
id ของเอนทิตีอุปกรณ์ — ส่งให้ Rewards()
|
color |
uint32_t |
Packed RGBA เสถียรต่ออุปกรณ์ (สำหรับการจัดกลุ่ม / การแรเงา) |
isUnique |
bool |
อุปกรณ์มีสูตรไอเทม unique |
holeCount |
int |
จำนวนช่องหินรูนบน anchor |
anchorName |
std::string |
ชื่อหินรูน anchor |
rewardCount |
int |
จำนวนช่องรางวัล |
bestIndex |
int |
Index ของรางวัลที่มี totalChaos สูงสุด หรือ -1
|
propagatingSlots |
std::vector<int> |
index ช่องสล็อตหินรูนที่หินรูนจะ ส่งต่อ ไปยัง remnant ถัดไป (carryover ใน 0.5.4); ปกติ 1 ช่อง บางครั้ง 2 |
RuneshapeReward field |
Type | Meaning |
|---|---|---|
name |
std::string |
ชื่อไอเทมรางวัล |
count |
int |
จำนวนที่ได้รับ |
unitChaos |
float |
ราคา Chaos ต่อหน่วย (จาก Prices service) |
totalChaos |
float |
unitChaos × count |
priced |
bool |
พบราคาสำหรับรางวัลนี้ |
propagatingRunes |
std::string |
หินรูนที่ช่องสล็อตที่ส่งต่อของสูตรนี้ — สิ่งที่จะ carryover หากคุณทำสูตรนี้สำเร็จ เช่น "Power" หรือ "Cold, Time"; ว่างเปล่าหากสูตรไม่ครอบคลุมช่องนั้น |
propagatingCount |
int |
จำนวนหินรูนที่ส่งต่อสำหรับรางวัลนี้ |
propagatingHasRare |
bool |
มีหินรูนส่งต่อที่เป็น rare ("สีม่วง"/มีค่า) |
ราคารางวัลมาจากฐานข้อมูล ctx()->Prices เดียวกัน ดังนั้นรางวัลที่ไม่มีราคา (priced == false) ส่วนใหญ่หมายความว่าราคายังโหลดไม่เสร็จ หรือไอเทมไม่ได้แสดงอยู่ใน poe2scout
Rune propagation (0.5.4). แต่ละ remnant จะสุ่มเลือกหนึ่ง ช่องสล็อต ที่หินรูนจะ ส่งต่อ ไปยัง remnant ถัดไป (ในเกม: ไฮไลต์มงกุฎสีทองในรายการ Runeshape Combinations) Runeshape::propagatingSlots คือรายการช่องสล็อต raw; เนื่องจากเป็น ตำแหน่ง ของช่องสล็อต หินรูนที่ส่งต่อจึงแตกต่างกันในแต่ละสูตร ดังนั้น RuneshapeReward::propagatingRunes จึงแก้ไขให้ต่อรางวัล นี่คือสิ่งที่ขับเคลื่อนจุดสล็อตสีเหลืองและเครื่องหมายต่อรางวัลของ NinjaPricer
ctx()->Atlas เปิดเผยพาเนล endgame atlas แบบสด — โหนดแผนที่, ความเชื่อมโยงต่อ anchor, การเลือก Rite ปัจจุบัน และน้ำหนัก eligibility ดิบ — อ่านฝั่งโฮสต์ผ่าน offset ของ atlas ใน GameLibrary นี่คือสิ่งที่ขับเคลื่อน overlay ของ atlas ในตัวและปลั๊กอินอ้างอิง ForetoldRewards ทุกอย่างเริ่มจาก GetPanel(): 0 หมายความว่า UI ของ atlas ยังไม่ถูกสร้าง (ไม่อยู่ในเกม / พาเนลปิดอยู่)
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| Method | Returns | Purpose |
|---|---|---|
GetPanel() |
uintptr_t |
ที่อยู่ของพาเนล atlas; 0 = ไม่มีพาเนล |
Nodes(detail) |
std::vector<AtlasNode> |
โหนด atlas ทั้งหมด (gridX/Y, uiAddress, marker, flags, biome, mapState); ค่า detail เริ่มต้นจะข้ามชื่อ — แก้ไขผ่าน GetNodeName()
|
Connections() |
std::vector<AtlasConnection> |
ความเชื่อมโยงต่อ anchor (x/y + neighbors สูงสุด 5 ตัว); ขึ้นกับ viewport |
Selection(which) |
std::vector<AtlasGridPoint> |
which=0 แผนที่ของเส้น Rite ที่เปิดเผยแล้ว, which=1 anchor ที่เลือก (ตามลำดับการเลือก) |
GetLineSeed() |
uint32_t |
seed การเลือกรางวัลของ Rite; 0 = ไม่มีเส้น Rite / ไม่มีพาเนล |
Weights() |
std::vector<AtlasWeight> |
แถวน้ำหนัก eligibility ดิบ (key, value) |
GetNodeName(uiAddress) |
std::string |
ชื่อที่แสดงของโหนดจาก uiAddress ("" เมื่อยังแก้ไขไม่ได้) |
AtlasServiceAbi แบบ by-value ถูกแช่แข็งแล้ว (มีสมาชิก HostAbi ตัวอื่นถูกต่อท้ายหลังจากมัน) ดังนั้นการอ่าน atlas ใหม่ในอนาคตต้องลงเป็นฟังก์ชัน tail ใหม่ของ HostAbi เท่านั้น — ห้ามเป็นสมาชิกใหม่ของ AtlasServiceAbi
ctx()->Sekhema เปิดเผยข้อมูลแผนที่ชั้นของ Trial of the Sekhemas — กราฟห้องแบบคงที่, ตัวเลือกของรอบเล่นปัจจุบัน และแถว FK เนื้อหาของแต่ละห้อง — รวมถึงการอ่าน flag ของ StateMachine ที่ห้อง trial ต้องใช้ การเรียกกราฟรับที่อยู่ UI ของพาเนล trial อย่างชัดเจน: เริ่มจาก GetPanel() (การแก้ไขด้วย index ลูกโดยตรงของโฮสต์) หรือรัน BFS ต้นไม้ UI ของคุณเอง โดยกรองล่วงหน้าด้วย ProbeFloor() ที่ต้นทุนต่ำ นี่คือสิ่งที่ขับเคลื่อนปลั๊กอินอ้างอิง 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
}| Method | Returns | Purpose |
|---|---|---|
GetPanel() |
uintptr_t |
พาเนล trial ที่โฮสต์แก้ไขให้; 0 = ไม่มี / โหมดคอนโทรลเลอร์ / ไม่อยู่ในเกม |
ProbeFloor(uiAddress) |
int |
จำนวนเลเยอร์ของ FloorData ที่ uiAddress, 0 = ไม่ใช่ชั้นของ trial (ตัวกรองล่วงหน้า BFS ต้นทุนต่ำ) |
GetFloor(panel) |
SekhemaFloor |
ส่วนหัวของชั้น: layerCount, roomCounts, choices (index ที่เลือกต่อเลเยอร์, 0xFF=ไม่มี), counter
|
Rooms(panel) |
std::vector<SekhemaRoom> |
กราฟห้องแบบคงที่ตามลำดับ (layer, index); แต่ละห้องมี connections ไปยังเลเยอร์ถัดไป |
Content(panel) |
std::vector<SekhemaContentEntry> |
แถว FK เนื้อหาต่อห้อง แก้ไขเป็น rowId / rowName (dispatch ตาม tablePath) |
GetRoomUsedFlag(sm) |
int |
flag ใช้แล้ว/ปิดแล้วของ StateMachine: 1=ใช้แล้ว, 0=ยังใช้งาน, -1=อ่านไม่ได้ |
GetStateMachineValue(sm, i, out) |
bool |
ค่า shared-state หนึ่งค่า (8 ไบต์ต่อรายการ define_shared_state ตามลำดับการประกาศ) |
GetUiStringId(uiAddress) |
std::string |
StringId ของ UI element เป็น UTF-8 — ฟิลด์ตัวเลขที่ field-leaf ของ HUD ใน trial เรนเดอร์ค่าลงไป (ไม่ใช่ Ui.GetText) |
หลักการ: <plugin directory>/config/settings.json Directory() คืน path แบบ absolute 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()); }สำหรับข้อมูลที่มีโครงสร้าง (object ที่ซ้อนกัน, array) ให้นำ JSON library จริงมาใส่ในโฟลเดอร์ปลั๊กอินของคุณ โฮสต์ไม่ได้บังคับให้เลือก
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");ทั้งสี่ระดับจะถูกส่งต่อไปยัง logger หลักของโฮสต์ ข้อความจะปรากฏในแท็บ Logs ของโฮสต์และในไฟล์ log บนดิสก์ จัดรูปแบบข้อความเองก่อนเรียก โฮสต์ไม่รับ printf-style varargs
ภายในเมธอดอำนวยความสะดวกจะปล่อยสตริง "Debug", "Info", "Warning" และ "Error" (Warn map ไปยัง "Warning") bridge ของโฮสต์ทำการเทียบแบบ case-insensitive ดังนั้นปลั๊กอินที่เรียก Log("warn", "msg") ก็ยัง route ถูกต้อง — แต่เมธอดอำนวยความสะดวกชัดเจนกว่า
Memory primitive โดยตรง ควรใช้บริการระดับสูงก่อน หากเป็นไปได้ — บริการเหล่านั้นเข้าใจ offset จัดการการเปลี่ยนแปลง ABI และปลอดภัยจาก SEH การอ่าน memory โดยตรงเหมาะสมเฉพาะเมื่อไม่มีการเรียกระดับสูงสำหรับสิ่งที่คุณต้องการ
// 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 ฝั่งโฮสต์ ปลั๊กอินที่มีปัญหาซึ่ง dereference pointer เก่า, หารด้วยศูนย์ หรือเกิด fault ภายใน SDK call จะได้รับ error ที่ถูก log — กระบวนการโฮสต์ ไม่ crash, เกมยังคงทำงาน และผู้ใช้ยังคงใช้ปลั๊กอินอื่นได้
นั่นไม่ได้แปลว่าปลั๊กอินสามารถเขียนแบบสะเพร่า SEH จับอาการ ไม่ได้จับสาเหตุ หากปลั๊กอินของคุณเกิด fault ทุกเฟรม ผู้ใช้จะเห็น error log จำนวนมหาศาลและข้อมูลของคุณจะใช้งานไม่ได้จริง ๆ ให้จัดการ null return จาก SDK call ตรวจสอบ flag Valid ในข้อมูล component และอย่า dereference address แบบ uintptr_t โดยตรง — ส่งผ่าน ComponentsService / Ui / Memory call ที่ wrap RPM ไว้ให้แล้ว
โฮสต์รับมือกับปลั๊กอินที่มีบั๊กได้ แต่รับมือกับปลั๊กอิน DLL ที่ค้างไม่ได้ — DrawSettings ที่ใช้เวลา 100ms จะ block UI thread ทั้งหมด ทำงานต่อเฟรมให้ราคาถูก
รายการสั้น ๆ ของสิ่งที่ผู้เขียนปลั๊กอินมักพบเมื่อเริ่ม integrate ส่วนใหญ่ได้รับการบันทึก inline ด้านบนแล้ว รวบรวมที่นี่เป็น checklist
-
OnAreaChangeถูก fire ก่อนที่ตาราง walkable จะถูก re-parse อย่ารีเฟรชWalkableGridHandleจาก event — ให้สำรวจทุกเฟรมในDrawUIและสลับเมื่อData()เปลี่ยน (§11) -
Entity::Zoneเป็นNoneเสมอสำหรับผู้เล่นโลคัล เป็นการจำแนกตามระยะห่างจากผู้เล่น ดังนั้นโดยนิยามผู้เล่นอยู่ที่ระยะศูนย์ อย่าแสดงในจอแสดงข้อมูลผู้เล่น -
Components.ReadMods()คืนเฉพาะ flag สรุป — ไม่มีรายการ mod สำหรับรายการ mod ต่อประเภท ให้เรียกInventory.ReadItemMods(entityAddr)(§8) -
ไอเทมที่ตกบนพื้นอาจไม่มี
EntitySubtypeหากคุณกรองไอเทมในโลก ให้ใช้EntityType == Item || EntityType == Chestแทนการเช็ค subtype ที่แคบกว่า -
Directory()คืน path แบบ absolute อย่า prepend ไดเรกทอรี EXE เอง — คุณจะได้EXEDIR\EXEDIR\Plugins\Xและการเขียน config จะตกลงนอกโฟลเดอร์ปลั๊กอิน -
ctx()คืนconst Context*เมธอดที่เปลี่ยนแปลงเช่นEventsService::Subscribeต้องใช้const_castนี่เป็นความตั้งใจ — บริการที่ไม่เปลี่ยนแปลงควรเป็นไปไม่ได้ที่จะเปลี่ยนโดยอุบัติเหตุ -
ImGui::SetCurrentContextเป็นต่อ DLL เรียกที่จุดเข้าทุกจุดที่วาด (OnEnable,DrawUI,DrawSettings) เพราะปลั๊กอิน DLL มี state ของ ImGui ของตัวเองโดย default -
ตัวช่วยอำนวยความสะดวกจะอ่าน component ใหม่ทุกครั้ง
GetHealthPercent(addr)จะทำReadLife(addr)ใหม่ภายใน หากคุณมี structLifeจากการเรียกก่อนหน้านี้แล้ว ให้เข้าถึง field โดยตรงแทน
สรุปบรรทัดเดียวของทุก public method ในทุกบริการ ใช้ส่วน prose ด้านบนสำหรับ type signature เต็มและหมายเหตุการใช้งาน
| Method | Returns | Purpose |
|---|---|---|
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 |
GetGold() |
int |
ตัวนับทองของตัวละคร (0 เมื่อไม่อยู่ในเกม) |
GetAreaId() |
std::string |
id ดิบของ WorldArea ของโซนปัจจุบัน (มีหมายเลขชั้นของ Sekhemas อยู่ด้วย) |
GetHiveblood(out) |
bool |
ทรัพยากรต้น Genesis (Hiveblood) → out; false บนโฮสต์รุ่นเก่า / ไม่อยู่ในเกม |
| Method | Returns | Purpose |
|---|---|---|
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 |
| Method | Returns | Purpose |
|---|---|---|
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 |
EnumerateSkillStats(skillDetailsAddr) |
std::vector<SkillStatEntry> |
ชุดสถิติที่ประเมินแล้วของทักษะเพียงหนึ่งเดียว ({SetIndex, StatId, Value}, StatId = แถวใน Stats.dat + 1) — set 0 คือบริบทปัจจุบัน รวมถึงค่า DPS ในแผงทักษะ (692, ×100); ดู §7 |
EnumerateStats(addr) |
std::vector<StatEntry> |
Items + buffs sourced stats |
EnumerateItemMods(addr) |
std::vector<Mod> |
Mods reachable from a Mods component |
ReadGroundEffect(entityAddr) |
GroundEffect |
ประเภท ground effect + รัศมีจากเอนทิตี VisibleServerGroundEffect — ส่ง address ของ ENTITY; จับคู่ด้วย TypeId (ShockedGround/IgnitedGround/…) แยกแยะ effect ที่ใช้ path เอนทิตีเดียวกัน |
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 |
| Method | Returns | Purpose |
|---|---|---|
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 |
ข้อความแบบในเกมสำหรับ stat key + ค่า ผ่าน formatter .csd ของโฮสต์; คืนค่าว่างจนกว่าคำอธิบายจะโหลด |
ReadItemBaseStats(addr) |
ItemBaseStats |
ค่าป้องกันพื้นฐาน (Energy Shield ที่คำนวณแล้ว; Ward/Armour/Evasion พื้นฐาน); Valid เป็น false เมื่อไม่มี Armour component; resolve WorldItem container อัตโนมัติ |
ReadItemAggregatedStats(addr) |
std::vector<std::pair<int,int>> |
{statId, value} รวม (waystone Item Rarity 8205 / Pack Size 8206 / Monster Rarity 8207 / Monster Effectiveness 8208 / Waystone Drop Chance 8209); resolve WorldItem container อัตโนมัติ |
| Method | Returns | Purpose |
|---|---|---|
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 |
| Method | Returns | Purpose |
|---|---|---|
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 |
| Method | Returns | Purpose |
|---|---|---|
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 |
| Method | Returns | Purpose |
|---|---|---|
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 |
| Method | Purpose |
|---|---|
Debug / Info / Warn / Error(msg) |
Emit at the corresponding level |
Log(level, msg) |
Custom level string |
| Method | Returns | Purpose |
|---|---|---|
Subscribe(kind, cb) |
Token |
Generic dispatch |
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) |
Token |
One-line subscribe helpers |
Unsubscribe(token) |
— | Manual release (destructor auto-releases anyway) |
| Method | Returns | Purpose |
|---|---|---|
SetIncludeSleepingEntities(enable) |
— | เลือกรับ entity ที่มีสถานะ EntityState::Useless ใน EntitiesService.Enumerate
|
SetWantsOverlayInput(enable) |
— | ขอให้ overlay รับ mouse click แทนที่จะเป็น click-through |
ทั้งคู่เป็น idempotent ต่อปลั๊กอิน รวมกันด้วย OR กับโฮสต์และปลั๊กอินอื่น ๆ และล้างอัตโนมัติเมื่อ Disable/Unload ดูส่วนที่ 13 สำหรับรูปแบบเต็ม (รวมถึงข้อควรระวัง background-draw-list สำหรับปลั๊กอิน map-picker)
| Method | Returns | Purpose |
|---|---|---|
GetFlask(slot) |
std::optional<Flask> |
ขวด life/mana ตาม belt slot (0=life, 1=mana); nullopt หากอยู่นอกช่วงหรือไม่ได้อยู่ในเกม |
GetCharm(slot) |
std::optional<Charm> |
Charm ตาม belt slot (0..2); nullopt หากอยู่นอกช่วงหรือไม่ได้อยู่ในเกม |
AllFlasks() |
std::vector<Flask> |
ทุก flask slot รวมถึงที่ว่าง (รายการ FlaskSlotCount()) |
AllCharms() |
std::vector<Charm> |
ทุก charm slot รวมถึงที่ว่าง (รายการ CharmSlotCount()) |
FlaskSlotCount() |
int32_t |
จำนวน flask slot (2 ใน POE2) |
CharmSlotCount() |
int32_t |
จำนวน charm slot (3 ใน POE2) |
ดูส่วนที่ 8 ("Flasks & charms") สำหรับตาราง field ของ Flask / Charm และข้อจำกัดของ PerUseEffective
| Method | Returns | Purpose |
|---|---|---|
LookupPrice(name) |
PriceResult |
ค้นหาราคา fuzzy ฝั่งโฮสต์ตามชื่อที่แสดง (found, chaos, divine, exalt, category) |
GetRates() |
PriceRates |
อัตราแปลง Divine / Exalted → Chaos |
GetStatus() |
PriceStatus |
ตัวควบคุม loaded + จำนวนต่อหมวดหมู่ (catsOk / catsPending / catsFailed) |
| Method | Returns | Purpose |
|---|---|---|
Runeshapes() |
std::vector<Runeshape> |
อุปกรณ์ Expedition2Encounter ที่แก้ไขแล้วทั้งหมด (id, color, anchor, bestIndex) |
Rewards(entityId) |
std::vector<RuneshapeReward> |
slot รางวัลต่ออุปกรณ์ โดยแต่ละ slot ถูกตีราคาผ่าน Prices service |
| Method | Returns | Purpose |
|---|---|---|
GetPanel() |
uintptr_t |
ที่อยู่ของพาเนล atlas; 0 = ไม่มี |
Nodes(detail) |
std::vector<AtlasNode> |
โหนด atlas ทั้งหมด (พิกัดกริด, marker, mapState) |
Connections() |
std::vector<AtlasConnection> |
ความเชื่อมโยงต่อ anchor (ขึ้นกับ viewport) |
Selection(which) |
std::vector<AtlasGridPoint> |
0 = แผนที่ของเส้น Rite ที่เปิดเผยแล้ว, 1 = anchor ที่เลือก |
GetLineSeed() |
uint32_t |
seed การเลือกรางวัลของ Rite (0 = ไม่มี) |
Weights() |
std::vector<AtlasWeight> |
แถวน้ำหนัก eligibility ดิบ (key, value) |
GetNodeName(uiAddress) |
std::string |
ชื่อที่แสดงของโหนด ("" เมื่อยังแก้ไขไม่ได้) |
| Method | Returns | Purpose |
|---|---|---|
GetPanel() |
uintptr_t |
พาเนล trial ที่โฮสต์แก้ไขให้; 0 = ไม่มี |
ProbeFloor(uiAddress) |
int |
จำนวนเลเยอร์ของ FloorData (0 = ไม่ใช่ชั้นของ trial); ตัวกรองล่วงหน้าต้นทุนต่ำ |
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 |
StringId ของ UI element เป็น UTF-8 (ฟิลด์ตัวเลขของ HUD) |
PluginAbi.h กำหนด:
constexpr int PLUGIN_SDK_VERSION = 6;ในเวลาโหลด โฮสต์จะเรียก plugin->GetSDKVersion() และเปรียบเทียบกับ PLUGIN_SDK_VERSION ของตัวเอง หากไม่ตรงกัน → โฮสต์จะ log คำเตือนและปฏิเสธการโหลดปลั๊กอิน
โฮสต์ยังตรวจสอบ HostAbi::version และ HostAbi::size_bytes ภายใน PluginSDK_AttachHost (ประกาศแบบ inline ใน PluginSDK.h เมื่อกำหนด PLUGIN_EXPORTS) หาก field ใดไม่ตรงกับสิ่งที่ปลั๊กอินถูกสร้างด้วย ctx() จะใช้งานไม่ได้ accessor ของ base class HostCompatible() จะรายงานเป็นเท็จในกรณีนั้น และปลั๊กอินที่สุภาพควรปฏิเสธที่จะทำงาน:
void OnEnable(bool) override {
if (!HostCompatible()) {
ctx()->Log.Error("Host ABI mismatch — disable plugin");
return;
}
// ...
}ปลั๊กอินสี่ตัวใน repo ถูกออกแบบให้อ่านเป็นเอกสารประกอบ:
-
Plugins/ExamplePlugin/— การแสดงตัวอย่างที่ครอบคลุมกว้าง ปลั๊กอินเดียวที่สัมผัสเกือบทุกบริการ จัดระเบียบเป็นไฟล์ย่อยexamples/Example*.h11 ไฟล์ (Area & Vitals, Buffs, Entities, Inventory, Memory, UI Explorer, Component Reader, Render, Terrain, Events, Log) บวกกับแบนเนอร์สรุปความครอบคลุม อ่านเมื่อต้องการเห็นการ ใช้ บริการในบริบท -
Plugins/Radar/— ตัวอย่างที่เน้นการใช้งานจริง ~200 บรรทัด overlay แบบเรดาร์ที่สร้างขึ้นบน public SDK ทั้งหมด — ไม่มี offset ไม่มีการอ่าน memory ดิบ render แผนที่ walkable พร้อมจุดต่อเอนทิตีผ่านRender.GridToLargeMapอ่านเมื่อต้องการดูโค้ด ขั้นต่ำ สำหรับผลลัพธ์เฉพาะ -
Plugins/KillCount/— ตัวติดตามการสังหาร/หีบ/ความตาย SQLite + sprite atlas + state ต่อพื้นที่ แสดงวิธีการจัดส่งการ persist, ไฟล์ข้อมูลแบบ vendored และ overlay ทั้งหมดใน DLL เดียว -
Plugins/NinjaPricer/— overlay ราคาของ poe.ninja HTTP fetch (Exchange API) + inventory scan + การตั้งราคาต่อไอเทม แสดงโค้ดเครือข่าย การรับข้อมูลจากบุคคลที่สาม และการ iterate inventory ใน workflow จริง