Skip to content

Plugin Development Guide TH

Lafko edited this page Jul 9, 2026 · 15 revisions

← Home


คู่มือการพัฒนาปลั๊กอิน

ปลั๊กอินของ POEFixer คือ DLL C++ แบบเนทีฟที่โหลดในขณะรันไทม์จาก Plugins/<PluginName>/<PluginName>.dll ปลั๊กอินสามารถอ่านสถานะเกมแบบเรียลไทม์ วาด ImGui overlay บันทึกการตั้งค่าของตัวเอง และสมัครรับเหตุการณ์จากโฮสต์


1. ภาพรวม

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/


2. ปลั๊กอิน Hello-world

ปลั๊กอินขั้นต่ำที่สามารถโหลดและพิมพ์ข้อความลงในล็อกของโฮสต์:

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

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

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

สร้างเป็น Plugins/Hello/Hello.dll รีสตาร์ทโฮสต์ และเปิดใช้จากแท็บ Plugins


3. การตั้งค่าโปรเจ็กต์

ใช้ Plugins/ExamplePlugin/ExamplePlugin.vcxproj เป็นเทมเพลตหลัก การตั้งค่าที่จำเป็น:

  • Configuration type: DynamicLibrary
  • Platform toolset: v143 (Visual Studio 2022)
  • Character set: Unicode
  • Language standard: stdcpp20
  • Runtime library: MultiThreadedDLL (Release) / MultiThreadedDebugDLL (Debug) ต้องตรงกับโฮสต์
  • Preprocessor definitions: PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS
  • Additional include directories: $(SolutionDir)POEFixer
  • Output directory: $(SolutionDir)x64\Release\Plugins\<YourPlugin>\
  • Target name: ต้องตรงกับชื่อโฟลเดอร์ (Plugins/MyPlugin/MyPlugin.dll)

โฮสต์จะสแกนแต่ละโฟลเดอร์ย่อยใน Plugins/ และมองหา <FolderName>.dll โดย DLL ต้อง 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));

4. Hook สำหรับวงจรชีวิต

PluginSDK::Plugin เป็น virtual base class ให้ override เมธอดเหล่านี้ในปลั๊กอินของคุณ (ตามลำดับการเรียกโดยประมาณ):

เมธอด เรียกเมื่อไร การใช้งานทั่วไป
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 เพื่อตรวจสอบว่าปลั๊กอินและโฮสต์เข้ากันได้ หากไม่ตรงกัน → ปลั๊กอินจะถูกปฏิเสธ


5. Context

ctx() คืนค่า const PluginSDK::Context* ซึ่งเป็นการรวมของบริการทั้ง 16:

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

ภาพรวมแบบเร็ว ๆ — แต่ละบริการมีไว้ทำอะไร:

บริการ ใช้เพื่ออะไร
GameService Snapshot, แฟล็กสถานะ, ข้อมูลหน้าจอและหน้าต่าง
EntitiesService แจกแจงเอนทิตี, ค้นหาด้วย id, เฝ้าดูวงจรชีวิต
ComponentsService ตัวอ่าน 21 ตัว + ตัวแจกแจง 4 ตัว + ตัวช่วยอำนวยความสะดวกอีก ~10 ตัว
InventoryService สแกน, แจกแจง, อ่าน mod ต่อไอเทม
UiService เดินต้นไม้, FindPanelByStringId, ComputeScreenRect
RenderService WorldToScreen, GridTo{Large,Mini}Map, การแปลงพิกัด
TerrainService ตาราง Walkable + ตาราง height (RAII), ตำแหน่ง TGT
MemoryService RPM primitive — ใช้เฉพาะเมื่อไม่มีการเรียกระดับสูงกว่าที่เหมาะสม
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


6. การอ่านสถานะของเกม

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.

7. การอ่าน Component

เอนทิตีเปิดเผย 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)

Field ของ ActiveSkill

EnumerateActiveSkills(actorAddr) คืนค่า ActiveSkill หนึ่งตัวต่อทักษะที่ได้รับหรือเสียบไว้ (granted/socketed) แต่ละตัวบน Actor ของเอนทิตี นอกเหนือจาก Name แล้ว struct นี้ยังบรรจุสถานะ cooldown และตัวอธิบาย gem-socket ที่ถูกถอดรหัสไว้แล้ว:

Field Meaning
Name ชื่อภายในของทักษะ
CurrentSize / TotalUses / UseStage ตัวนับ stage / การใช้งาน (แบบ raw)
CastType cast-type id แบบ raw
TotalCooldownMs ระยะเวลา cooldown เต็ม หน่วยมิลลิวินาที
CanBeUsed แฟล็กจากโฮสต์ที่บ่งชี้ว่า "ใช้งานได้ในตอนนี้"
MaxUses จำนวน charges ของ cooldown ที่ทักษะนี้มี (0 = ไม่ผูกกับ cooldown)
TotalActiveCooldowns จำนวน charges ที่กำลังติด cooldown อยู่ในขณะนี้ จำนวนการใช้ที่เหลือ = MaxUses - TotalActiveCooldowns เมื่อ MaxUses > 0
GrantedEffectsPerLevelAddr, ActiveSkillsDatAddr, GrantedEffectStatSetsPerLevelAddr, SkillDetailsAddr address ของ DAT-row แบบ raw — ส่งให้ ctx()->Memory.Read* เพื่อดึงข้อมูลทักษะเชิงลึกเพิ่มเติม
EquipmentInfoPacked word แบบ raw ที่อัดรวมข้อมูล gem/socket ไว้ (packed; ถอดรหัสเป็น Equipment ด้านล่าง)

ค่า word ที่ถูกอัดรวมนี้ถูกถอดรหัสไว้ให้แล้วใน skill.Equipment:

Equipment field Meaning
GemNameHash 16 บิตบน — hash บ่งชี้ตัวตนของ gem
InventorySlot ช่อง equipment ที่ gem นี้เสียบอยู่ (นับเริ่มจาก 1)
LinkIndex Index ของ link-group ภายในไอเทม
SocketIndex Index ของ socket ภายใน link-group นั้น
UnknownFlag / CanBeOnPlayerItem แฟล็กที่เหลือ (bit layout อยู่ใน 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 — ตำแหน่งที่ gem นี้เสียบอยู่
}

สถิติที่ประเมินต่อทักษะและ DPS (EnumerateSkillStats)

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 "ไม่มีสถิติ" ของเกม) หาชื่อสถิติได้ด้วยการ dump Stats.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 คืนค่า สองรายการต่อชื่อทักษะ (บริบทการประเมินที่ต่างกัน เช่น ชุดอาวุธที่ต่างกัน) — สอบถามทั้งสองรายการหากคุณกำลังตามหาสถิติตัวใดตัวหนึ่งโดยเฉพาะ

Field ของ Entity

ทุก Entity (รวมถึง snap.Player และสมาชิกของ snap.Entities) มี field ชุดเดียวกัน:

กลุ่ม ฟิลด์
อัตลักษณ์ 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, path ของ asset)
สถานะ 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> สำหรับการค้นหาแบบ one-shot และ GetPlayer() คืนผู้เล่นโลคัลเสมอ

ไอเทมบนพื้น (WorldItem container)

ไอเทมที่ถูกทิ้งลงบนพื้นจะปรากฏใน 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 ให้แบบโปร่งใส


8. Inventory

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);

Mod ของไอเทม: สอง API สองขอบเขต

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


9. UI Tree

ต้นไม้ 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 ย้ายไปแล้ว (0x4480x4C0) และ bridge ฝั่งโฮสต์ได้รับการแก้ไขให้ตรงกัน (สำหรับ StringId แบบตัวเลขที่ field-leaf ของ HUD ใน trial เรนเดอร์ค่าลงไป — เป็นคนละฟิลด์กับ GetText()SekhemaHelper อ่านผ่าน ctx()->Sekhema.GetUiStringId())


10. Render และการฉาย

มีตัวช่วยการฉายสามตัว ระบบพิกัดสองระบบ

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 สำหรับเรดาร์ที่ใช้งานได้ซึ่งสร้างขึ้นบนการเรียกเหล่านี้ทั้งหมด


11. ภูมิประเทศและตาราง Walkable

ตาราง 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
});

12. Event

สมัครรับ 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

หากปลั๊กอินของคุณเป็นเจ้าของ 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 สำหรับรูปแบบเต็ม


13. OverlayService — การจับ input และเอนทิตีที่กำลังหลับ

ctx()->Overlay เปิดเผย "แฟล็กการร้องขอ" ต่อปลั๊กอินสองตัวที่เปลี่ยนพฤติกรรมระดับ overlay ทั้งหมด โฮสต์จัดเก็บสถานะต่อปลั๊กอินโดยใช้ตัวชี้ this ของคุณเป็นคีย์ และรวมด้วย OR กับแฟล็กบิลต์อินของโฮสต์เอง (การมองเห็นเมนูหลัก, การล็อก AutoCraft, ตัวเลือก Add-Entity-from-Map บิลต์อินของโฮสต์, แฟล็กของปลั๊กอินอื่น ๆ ทุกตัว) ถูกล้างอัตโนมัติเมื่อปลั๊กอินถูกปิดใช้งาน/unload — ปลั๊กอินที่ crash หรือมีบั๊กไม่สามารถทำให้ overlay ติดค้างในสถานะแปลก ๆ อย่างถาวรได้

SetIncludeSleepingEntities

โดยค่าเริ่มต้น 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)

ต้นทุน: เพิ่มขึ้นประมาณ +5–15% ของ CPU snapshot ต่อเฟรมในขณะที่เปิดใช้งาน ปิดไว้ เว้นแต่จะจำเป็น

SetWantsOverlayInput

หน้าต่าง 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 ของคุณได้

ขอบเขต (สำคัญ):

  • ปุ่มเมาส์ (LMB/RMB): ใช่ — ถูกควบคุมโดยแฟล็กนี้
  • ตำแหน่งเมาส์ / โฮเวอร์: ใช้งานได้เสมอโดยไม่คำนึงถึง โฮเวอร์ tooltip ไม่ต้องการแฟล็กนี้
  • คีย์บอร์ด: ถึงปลั๊กอินเสมอผ่าน WindowProc ของโฮสต์ โดยไม่ขึ้นกับแฟล็กนี้ ImGui::IsKeyPressed(ImGuiKey_Escape) ทำงานได้ทั้งสองกรณี

ข้อควรระวังเรื่อง background draw list — อ่านนี้สำหรับปลั๊กอิน map-picker

หากคุณวาด 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();

หน้าต่างขนาดเล็กที่ปิดบังเพียงพื้นที่ตัวเลือกก็ทำงานในลักษณะเดียวกัน — โฮสต์ไม่สนใจขนาด มีเพียงว่ามีหน้าต่างอยู่ใต้เคอร์เซอร์

ตัวอย่างที่ใช้งานได้จริง — เพิ่ม POI จากแผนที่

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 เพื่อให้ปลั๊กอินอื่น ๆ และเกมยังคงตอบสนองในระหว่างนั้น
  • ความหน่วง: การเรียก Set จะอัปเดทแฟล็กแบบซิงโครนัส แต่การเปลี่ยนแปลงพฤติกรรมที่มีผลจะปรากฏในเฟรมโฮสต์ถัดไป (overlay input) หรือ tick ของ GameClient worker ถัดไป (sleeping entities) ต่ำกว่าเฟรม ไม่สังเกตได้
  • ทุกเมธอดปลอดภัย สำหรับเรียกจาก thread ใดก็ได้

14. Prices — การกำหนดราคาไอเทมที่โฮสต์โหลด

โฮสต์โหลดราคาตลาด ครั้งเดียวต่อเซสชัน จาก 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 เมื่อได้รับอัตราการแปลงพร้อมกับหมวดหมู่แรกอย่างน้อยแล้ว จนกว่าจะถึงจุดนั้น ให้แสดงสถานะ "กำลังโหลดราคา…" แทนที่จะเป็นศูนย์


15. อุปกรณ์ Runeshape

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


16. ข้อมูลพาเนล Atlas

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


17. ข้อมูล Trial ของ Sekhema

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)

18. การเก็บการตั้งค่า

หลักการ: <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 วินาที) และตอนปิดใช้งาน คุณไม่จำเป็นต้องเรียกเอง


19. การ Log

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 ถูกต้อง — แต่เมธอดอำนวยความสะดวกชัดเจนกว่า


20. Memory (สำหรับผู้ใช้ขั้นสูง)

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

หากคุณพบว่าตัวเองเรียกใช้สิ่งเหล่านี้บ่อย ๆ ให้ถามว่าข้อมูลที่คุณต้องการควรอยู่ในบริการระดับสูงกว่าหรือไม่


21. ความปลอดภัยของ Bridge / SEH

การเรียกข้าม 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 ทั้งหมด ทำงานต่อเฟรมให้ราคาถูก


22. ปัญหาที่พบบ่อย

รายการสั้น ๆ ของสิ่งที่ผู้เขียนปลั๊กอินมักพบเมื่อเริ่ม integrate ส่วนใหญ่ได้รับการบันทึก inline ด้านบนแล้ว รวบรวมที่นี่เป็น checklist

  1. OnAreaChange ถูก fire ก่อนที่ตาราง walkable จะถูก re-parse อย่ารีเฟรช WalkableGridHandle จาก event — ให้สำรวจทุกเฟรมใน DrawUI และสลับเมื่อ Data() เปลี่ยน (§11)
  2. Entity::Zone เป็น None เสมอสำหรับผู้เล่นโลคัล เป็นการจำแนกตามระยะห่างจากผู้เล่น ดังนั้นโดยนิยามผู้เล่นอยู่ที่ระยะศูนย์ อย่าแสดงในจอแสดงข้อมูลผู้เล่น
  3. Components.ReadMods() คืนเฉพาะ flag สรุป — ไม่มีรายการ mod สำหรับรายการ mod ต่อประเภท ให้เรียก Inventory.ReadItemMods(entityAddr) (§8)
  4. ไอเทมที่ตกบนพื้นอาจไม่มี EntitySubtype หากคุณกรองไอเทมในโลก ให้ใช้ EntityType == Item || EntityType == Chest แทนการเช็ค subtype ที่แคบกว่า
  5. Directory() คืน path แบบ absolute อย่า prepend ไดเรกทอรี EXE เอง — คุณจะได้ EXEDIR\EXEDIR\Plugins\X และการเขียน config จะตกลงนอกโฟลเดอร์ปลั๊กอิน
  6. ctx() คืน const Context* เมธอดที่เปลี่ยนแปลงเช่น EventsService::Subscribe ต้องใช้ const_cast นี่เป็นความตั้งใจ — บริการที่ไม่เปลี่ยนแปลงควรเป็นไปไม่ได้ที่จะเปลี่ยนโดยอุบัติเหตุ
  7. ImGui::SetCurrentContext เป็นต่อ DLL เรียกที่จุดเข้าทุกจุดที่วาด (OnEnable, DrawUI, DrawSettings) เพราะปลั๊กอิน DLL มี state ของ ImGui ของตัวเองโดย default
  8. ตัวช่วยอำนวยความสะดวกจะอ่าน component ใหม่ทุกครั้ง GetHealthPercent(addr) จะทำ ReadLife(addr) ใหม่ภายใน หากคุณมี struct Life จากการเรียกก่อนหน้านี้แล้ว ให้เข้าถึง field โดยตรงแทน

23. อ้างอิงด่วนสำหรับบริการ

สรุปบรรทัดเดียวของทุก public method ในทุกบริการ ใช้ส่วน prose ด้านบนสำหรับ type signature เต็มและหมายเหตุการใช้งาน

GameService

Method Returns Purpose
GetSnapshot() Snapshot มุมมองแบบเต็มต่อเฟรม รวมถึง Entities
GetState() GameState ค่า enum: InGame, Login, Loading, …
IsAttached() bool กระบวนการเกมเชื่อมต่ออยู่
IsInGame() bool State == InGame
IsForeground() bool หน้าต่างเกมมีโฟกัส
IsMenuVisible() bool เมนู ESC / การตั้งค่าเปิดอยู่
IsOverlayMode() bool โฮสต์อยู่ในโหมด overlay (click-through)
GetProcessId() DWORD PID ของเกม
GetGameWindow() HWND handle ของหน้าต่างเกม
GetScreenSize() ScreenSize {Width, Height} แบบ float
GetGold() int ตัวนับทองของตัวละคร (0 เมื่อไม่อยู่ในเกม)
GetAreaId() std::string id ดิบของ WorldArea ของโซนปัจจุบัน (มีหมายเลขชั้นของ Sekhemas อยู่ด้วย)
GetHiveblood(out) bool ทรัพยากรต้น Genesis (Hiveblood) → out; false บนโฮสต์รุ่นเก่า / ไม่อยู่ในเกม

EntitiesService

Method Returns Purpose
Enumerate(cb) เยี่ยมชมทุกเอนทิตีที่อยู่ใกล้เคียง (คืนค่า false เพื่อหยุด)
GetPlayer() Entity เอนทิตีผู้เล่นโลคัล
FindById(id) std::optional<Entity> ค้นหาด้วย id ของเอนทิตี
GetWorldItemInner(addr) std::optional<Entity> เอนทิตีไอเทมด้านในของ WorldItem container (ไอเทมบนพื้น)
Watch(id) ปักหมุดเอนทิตีเพื่อให้ component ของมันยังคงอ่านได้
Unwatch(id) ยกเลิกการเฝ้าดู
IsWatched(id) bool สถานะการเฝ้าดู
GetWatchedComponents(id) std::optional<ComponentAddresses> อ่าน component ที่ถูกปักหมุดไว้

ComponentsService

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 ตัว หนึ่งตัวต่อประเภท component
EnumerateBuffs(addr) std::vector<Buff> buff ที่กำลังทำงานอยู่บนเอนทิตี
EnumerateActiveSkills(addr) std::vector<ActiveSkill> ทักษะจาก component Actor (ดู §7 สำหรับตาราง field ของ ActiveSkill)
EnumerateSkillStats(skillDetailsAddr) std::vector<SkillStatEntry> ชุดสถิติที่ประเมินแล้วของทักษะเพียงหนึ่งเดียว ({SetIndex, StatId, Value}, StatId = แถวใน Stats.dat + 1) — set 0 คือบริบทปัจจุบัน รวมถึงค่า DPS ในแผงทักษะ (692, ×100); ดู §7
EnumerateStats(addr) std::vector<StatEntry> สถิติที่มาจากไอเทม + บัฟ
EnumerateItemMods(addr) std::vector<Mod> Mod ที่เข้าถึงได้จาก component Mods
ReadGroundEffect(entityAddr) GroundEffect ประเภท ground effect + รัศมีจากเอนทิตี VisibleServerGroundEffect — ส่ง address ของ ENTITY; จับคู่ด้วย TypeId (ShockedGround/IgnitedGround/…) แยกแยะ effect ที่ใช้ path เอนทิตีเดียวกัน
GetHealthPercent / GetEsPercent / GetManaPercent float ตัวช่วยอำนวยความสะดวกสำหรับค่าเปอร์เซ็นต์
IsAlive(addr) bool พลังชีวิต > 0
GetItemRarity(addr) int ค่า Rarity จาก component Mods
IsItemIdentified(addr) bool แฟล็ก Identified
GetStackCount(addr) int จำนวน stack ปัจจุบัน
IsChestOpened(addr) bool แฟล็กการเปิดหีบ
GetPlayerName(addr) std::string ชื่อผู้เล่นจาก component Player
GetWorldPosition(renderAddr, x, y, z) bool ตัวเข้าถึงอำนวยความสะดวกสำหรับพิกัดโลก

InventoryService

Method Returns Purpose
Scan(inventoryId) ทริกเกอร์การสแกนใหม่ฝั่งโฮสต์ (-1 = ทั้งหมด)
Get(inventoryId) Inventory inventory เดียว โดยมีไอเทมเติมไว้แล้ว
GetItems(inventoryId) std::vector<InventoryItem> เฉพาะไอเทม
GetAll() std::vector<Inventory> inventory ทั้งหมดที่สแกนแล้ว
GetName(inventoryId) const char* ชื่อที่แสดง ("Backpack", "Stash", …)
ReadItemRarity(addr) int Rarity ต่อเอนทิตี (แก้ไข WorldItem container ให้อัตโนมัติ)
ReadItemStackCount(addr) int stack ต่อเอนทิตี (แก้ไข WorldItem container ให้อัตโนมัติ)
ReadItemBaseTypeName(addr) std::string Base type (แก้ไข WorldItem container ให้อัตโนมัติ)
ReadItemUniqueName(addr) std::string ชื่อ unique (แก้ไข WorldItem container ให้อัตโนมัติ)
ReadItemPath(addr) std::string path แบบ Metadata/Items/... (แก้ไข WorldItem container ให้อัตโนมัติ)
ReadItemMods(addr) ItemMods แฟล็กสรุป + vector ของ mod แยกตามประเภท 5 ชนิด (แก้ไข WorldItem container ให้อัตโนมัติ)
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 อัตโนมัติ

UiService

Method Returns Purpose
Read(addr) UiElement field ของ element (rect, แฟล็ก, จำนวน children)
GetChildren(addr) std::vector<uintptr_t> address ของ child element
GetChildAt(addr, index) uintptr_t child ตัวเดียวตาม index
FollowPath(root, indices, count) uintptr_t เดินตาม index path ที่รู้จัก
IsVisible(addr) bool element อยู่บนหน้าจอ
GetStringId(addr) std::string ตัวระบุที่เสถียรฝั่งเกม
GetText(addr) std::string ข้อความที่แสดงผล
ComputeScreenRect(addr, x, y, w, h) bool rect พิกัดหน้าจอสุดท้าย
GetGameUiRoot() uintptr_t root ของ UI ในเกม
GetUiRoot() uintptr_t root ของ UI ระดับบนสุด
GetCullValue() int threshold การ cull UI ของโฮสต์
FindPanelByStringId(parent, stringId) uintptr_t ค้นหา descendant แบบเจาะจงเป้าหมาย

RenderService

Method Returns Purpose
WorldToScreen(wx, wy, wz, sx, sy) bool การฉายแบบ Perspective
GridToLargeMap(gx, gy, worldZ, sx, sy) bool ฉายไปยัง overlay ของแผนที่ใหญ่
GridToMiniMap(gx, gy, worldZ, sx, sy) bool ฉายไปยัง minimap
GetLargeMapTransform() MapTransform transform ที่คูณไว้ล่วงหน้าสำหรับการคำนวณแบบ batch
GetMiniMapTransform() MapTransform เหมือนกัน แต่สำหรับ minimap

TerrainService

Method Returns Purpose
GetWalkableGrid() WalkableGridHandle handle แบบ RAII ไปยัง bitmap walkable ที่ใช้ 4 บิตต่อ tile
GetHeightGrid() HeightGridHandle handle แบบ RAII ไปยังความสูงภูมิประเทศต่อ tile
IsWalkable(gx, gy) bool predicate สำหรับ tile เดียว
GetTerrainHeight(gx, gy) float ค่า Z ในพิกัดโลก
GetWorldToGridConvertor() float ตัวคูณแปลงจากโลก → grid
EnumerateTgtLocations(cb) เยี่ยมชมทุก instance ของ TGT ในพื้นที่ปัจจุบัน

MemoryService

Method Returns Purpose
Read(addr, buf, size) bool RPM แบบดิบ
ReadString(addr) std::string narrow string ที่จบด้วย null
ReadWString(addr) std::wstring wide string ที่จบด้วย null
ReadStdWString(addr) std::wstring อ่าน container std::wstring ฝั่งเกม (รองรับ SSO)
ReadStdVector(addr, elemSize, maxElems) std::vector<uint8_t> byte แบบดิบ; reinterpret เป็น type ของคุณเอง
GetBaseAddress() uintptr_t base ของ module เกม
GetModuleSize() uintptr_t ขนาดของ module เกม
GetPatternAddress(name) uintptr_t ค้นหา pattern ตามชื่อ

LogService

Method Purpose
Debug / Info / Warn / Error(msg) emit ที่ level ที่ตรงกัน
Log(level, msg) string level แบบกำหนดเอง

EventsService

Method Returns Purpose
Subscribe(kind, cb) Token dispatch แบบทั่วไป
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) Token ตัวช่วย subscribe แบบบรรทัดเดียว
Unsubscribe(token) ปล่อยด้วยมือ (destructor จะปล่อยอัตโนมัติอยู่แล้ว)

OverlayService

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)

FlasksService

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

PricesService

Method Returns Purpose
LookupPrice(name) PriceResult ค้นหาราคา fuzzy ฝั่งโฮสต์ตามชื่อที่แสดง (found, chaos, divine, exalt, category)
GetRates() PriceRates อัตราแปลง Divine / Exalted → Chaos
GetStatus() PriceStatus ตัวควบคุม loaded + จำนวนต่อหมวดหมู่ (catsOk / catsPending / catsFailed)

RuneshapeService

Method Returns Purpose
Runeshapes() std::vector<Runeshape> อุปกรณ์ Expedition2Encounter ที่แก้ไขแล้วทั้งหมด (id, color, anchor, bestIndex)
Rewards(entityId) std::vector<RuneshapeReward> slot รางวัลต่ออุปกรณ์ โดยแต่ละ slot ถูกตีราคาผ่าน Prices service

AtlasService

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 ชื่อที่แสดงของโหนด ("" เมื่อยังแก้ไขไม่ได้)

SekhemaService

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)

24. การกำหนดเวอร์ชัน

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;
    }
    // ...
}

25. ปลั๊กอินตัวอย่าง

ปลั๊กอินสี่ตัวใน repo ถูกออกแบบให้อ่านเป็นเอกสารประกอบ:

  • Plugins/ExamplePlugin/ — การแสดงตัวอย่างที่ครอบคลุมกว้าง ปลั๊กอินเดียวที่สัมผัสเกือบทุกบริการ จัดระเบียบเป็นไฟล์ย่อย examples/Example*.h 11 ไฟล์ (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 จริง


← Home

Clone this wiki locally