Skip to content

Plugin Development Guide TH

Lafko edited this page May 22, 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  (10 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 เมธอดเหล่านี้ในปลั๊กอินของคุณ (ตามลำดับการเรียกโดยประมาณ):

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


5. Context

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

struct Context {
    GameService       Game;        // snapshot, state flags, screen size
    EntitiesService   Entities;    // enumerate, find-by-id, watch
    ComponentsService Components;  // 21 component readers + collection enumerators
    InventoryService  Inventory;   // scan + iterate + per-item helpers
    UiService         Ui;          // tree walk, FindPanelByStringId, screen-rect
    RenderService     Render;      // WorldToScreen + isometric map projection
    TerrainService    Terrain;     // walkable grid (RAII), height, TGT locations
    MemoryService     Memory;      // direct memory primitives (last resort)
    LogService        Log;         // Debug/Info/Warn/Error
    EventsService     Events;      // Subscribe / Unsubscribe / On<X>
    void* ImGuiContext;            // pass to ImGui::SetCurrentContext
    void* D3DDevice;               // ID3D11Device* for texture loading
};

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

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}

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

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 ใหม่ทุกครั้ง

Field ของ Entity

ทุก 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() คืนผู้เล่นโลคัลเสมอ


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

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 เมื่อมีให้ใช้


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. การเก็บการตั้งค่า

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


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


15. 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

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


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


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

รายการสั้น ๆ ของสิ่งที่ผู้เขียนปลั๊กอินมักพบเมื่อเริ่ม 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 โดยตรงแทน

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

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

GameService

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

EntitiesService

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

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 readers, one per component type
EnumerateBuffs(addr) std::vector<Buff> Active buffs on the entity
EnumerateActiveSkills(addr) std::vector<ActiveSkill> Skills from an Actor component
EnumerateStats(addr) std::vector<StatEntry> Items + buffs sourced stats
EnumerateItemMods(addr) std::vector<Mod> Mods reachable from a Mods component
GetHealthPercent / GetEsPercent / GetManaPercent float Convenience % helpers
IsAlive(addr) bool Health > 0
GetItemRarity(addr) int Rarity from a Mods component
IsItemIdentified(addr) bool Identified flag
GetStackCount(addr) int Current stack count
IsChestOpened(addr) bool Chest open flag
GetPlayerName(addr) std::string Player name from a Player component
GetWorldPosition(renderAddr, x, y, z) bool Convenience accessor for world coords

InventoryService

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
ReadItemStackCount(addr) int Per-entity stack
ReadItemBaseTypeName(addr) std::string Base type ("Gemcutter's Prism", …)
ReadItemUniqueName(addr) std::string Unique name (if applicable)
ReadItemPath(addr) std::string Metadata/Items/... path
ReadItemMods(addr) ItemMods Summary flags + 5 per-kind mod vectors

UiService

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

RenderService

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

TerrainService

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

MemoryService

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

LogService

Method Purpose
Debug / Info / Warn / Error(msg) Emit at the corresponding level
Log(level, msg) Custom level string

EventsService

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)

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

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

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

ปลั๊กอินสี่ตัวใน 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