Skip to content

Plugin Development Guide DE

Lafko edited this page May 23, 2026 · 14 revisions

← Home


Plugin-Entwicklungshandbuch

POEFixer-Plugins sind native C++-DLLs, die zur Laufzeit aus Plugins/<PluginName>/<PluginName>.dll geladen werden. Sie lesen den Live-Spielzustand, zeichnen ImGui-Overlays, persistieren ihre eigenen Einstellungen und abonnieren Host-Ereignisse.


1. Überblick

Das Plugin-SDK besitzt eine dreischichtige Architektur:

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
  • Plugin-Autoren binden genau einen Header ein: POEFixer/plugin_sdk/PluginSDK.h.
  • Dieser Header deklariert alles im Namespace PluginSDK:: und zieht darunter das C-ABI aus PluginAbi.h. Dass Letzteres existiert, kann man erwähnen; ein Blick hinein ist fast nie nötig.
  • Alle std::*-Container leben innerhalb der Plugin-DLL. Nur POD-Typen überqueren die Hostgrenze. Damit verheddert sich ein mit einer anderen Toolchain-Version gebautes Plugin nicht im STL des Hosts – die einzigen geteilten Typen sind Ganzzahlen, Gleitkommawerte, Zeiger und kleine Structs.

Die SDK-Header finden Sie hier:

  • POEFixer/plugin_sdk/PluginSDK.h – der C++-Wrapper, den Plugin-Autoren verwenden.
  • POEFixer/plugin_sdk/PluginAbi.h – das reine C-ABI darunter.

Referenz-Plugins im Repository (lesen Sie diese als Dokumentation): Plugins/ExamplePlugin/, Plugins/Radar/, Plugins/KillCount/, Plugins/NinjaPricer/.


2. Hello-World-Plugin

Minimales Plugin, das geladen wird und eine Meldung in das Host-Log schreibt:

#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; }

Als Plugins/Hello/Hello.dll bauen, Host neu starten, im Tab Plugins aktivieren.


3. Projekt-Setup

Verwenden Sie Plugins/ExamplePlugin/ExamplePlugin.vcxproj als kanonische Vorlage. Die wesentlichen Einstellungen:

  • Konfigurationstyp: DynamicLibrary
  • Plattform-Toolset: v143 (Visual Studio 2022)
  • Zeichensatz: Unicode
  • Sprachstandard: stdcpp20
  • Laufzeitbibliothek: MultiThreadedDLL (Release) / MultiThreadedDebugDLL (Debug). MUSS mit dem Host übereinstimmen.
  • Präprozessor-Definitionen: PLUGIN_EXPORTS;NDEBUG;_WINDOWS;_USRDLL;_CRT_SECURE_NO_WARNINGS
  • Zusätzliche Include-Verzeichnisse: $(SolutionDir)POEFixer
  • Ausgabeverzeichnis: $(SolutionDir)x64\Release\Plugins\<YourPlugin>\
  • Zielname: muss mit dem Ordnernamen übereinstimmen (Plugins/MyPlugin/MyPlugin.dll)

Der Host scannt jeden Unterordner in Plugins/ und sucht nach <FolderName>.dll. Die DLL muss drei Symbole exportieren:

  • CreatePlugin – Factory; gibt PluginSDK::Plugin* zurück.
  • DestroyPlugin – Destruktor; nimmt PluginSDK::Plugin*.
  • PluginSDK_AttachHost – verdrahtet den Context. Wird für Sie innerhalb von PluginSDK.h definiert und automatisch emittiert, wenn PLUGIN_EXPORTS gesetzt ist.

Empfohlenes Quellcode-Layout:

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() liefert einen absoluten, UTF-8-codierten Pfad mit Wurzel im Host-EXE-Verzeichnis – stellen Sie den EXE-Pfad nicht selbst voran. Die Verzeichniszeichenkette wird per Wert innerhalb von PluginSDK::Plugin gehalten, sodass sie Container-Reallokationen und Reload-Zyklen des Hosts ohne Lebensdauerprobleme übersteht.

Wenn Sie ImGui zeichnen möchten, fügen Sie zusätzlich diese Dateien in <ClCompile> ein (der Host linkt sie ebenfalls, aber Plugin-seitiges ImGui ist pro-DLL):

..\..\POEFixer\imgui\imgui.cpp
..\..\POEFixer\imgui\imgui_draw.cpp
..\..\POEFixer\imgui\imgui_tables.cpp
..\..\POEFixer\imgui\imgui_widgets.cpp

In OnEnable an den ImGui-Context des Hosts anbinden:

if (ctx()->ImGuiContext)
    ImGui::SetCurrentContext(static_cast<ImGuiContext*>(ctx()->ImGuiContext));

4. Lebenszyklus-Hooks

PluginSDK::Plugin ist eine virtuelle Basisklasse. Überschreiben Sie diese Methoden in Ihrem Plugin (ungefähr in Aufrufreihenfolge):

Methode Wird aufgerufen Typische Verwendung
const char* GetName() const Einmal, direkt nach der Konstruktion Den Anzeigenamen Ihres Plugins zurückgeben
void OnEnable(bool isGameAttached) Wenn der Benutzer das Plugin aktiviert (oder beim Start, falls persistiert) Einstellungen laden, Events abonnieren, ImGui-Context anbinden
void DrawSettings() Jeden Frame, solange das Einstellungs-Panel des Plugins geöffnet ist ImGui-Bedienelemente für Ihre Konfiguration
void DrawUI() Jeden Frame, solange das Plugin aktiviert ist ImGui-Overlay zeichnen (für ein Spiel-Overlay ImGui::GetBackgroundDrawList() verwenden)
bool WantsOverlay() const Wird jeden Frame abgefragt true zurückgeben, wenn der Host im Overlay-Modus (klickbar durch) laufen soll
void SaveSettings() Periodisch (~5 s) und beim Deaktivieren Konfiguration auf die Festplatte schreiben
void OnDisable() Wenn der Benutzer deaktiviert oder beim Herunterfahren des Hosts Ressourcen freigeben, Event-Abos kündigen

Nur GetName ist verpflichtend; alle übrigen besitzen sichere Defaults.

Der Host ruft außerdem GetSDKVersion() (auf der Basisklasse definiert, NICHT überschreiben) unmittelbar nach CreatePlugin auf, um zu prüfen, dass Plugin und Host übereinstimmen. Bei einer Diskrepanz → Plugin abgelehnt.


5. Der Context

ctx() liefert const PluginSDK::Context*, ein Aggregat aus 10 Services:

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

Auf einen Blick – wofür jeder Service zuständig ist:

Service Wann Sie ihn greifen
GameService Snapshot, Zustands-Flags, Bildschirm- und Fensterinformationen
EntitiesService Enumerieren, Lookup per ID, Lifecycle beobachten
ComponentsService 21 Reader + 4 Enumeratoren + ~10 Komfort-Helfer
InventoryService Scannen, enumerieren, Mod-Reads pro Item
UiService Baum durchlaufen, FindPanelByStringId, ComputeScreenRect
RenderService WorldToScreen, GridTo{Large,Mini}Map, Transforms
TerrainService Begehbarkeits- und Höhen-Grids (RAII), TGT-Standorte
MemoryService RPM-Primitive – nur wenn kein höherer Aufruf passt
LogService Debug / Info / Warn / Error
EventsService Subscribe / Unsubscribe / On{Area,Frame,Attach,Detach}

ctx() ist gültig ab dem Moment, in dem der Host OnEnable aufruft, bis OnDisable zurückkehrt. Cachen Sie ctx() nicht über Hot-Reloads oder DLL-Entladegrenzen hinweg.


6. Spielzustand lesen

ctx()->Game.GetSnapshot() liefert einen wertbasierten Snapshot – eine vollständige, unveränderliche Sicht auf den aktuellen Frame. GetSnapshot() durchläuft abi->entities.enumerate und befüllt snap.Entities, bevor zurückgekehrt wird, sodass die Kosten mit der Anzahl naher Entities skalieren. Rufen Sie es einmal pro Frame auf und verwenden Sie es wieder.

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

Was der Snapshot direkt mitliefert (keine weiteren Service-Aufrufe nötig):

  • Zustand und Flags: State, IsAttached, IsWindowValid, GameWindowForeground, IsTown, IsHideout, IsPaused, IsSkillTreeVisible.
  • Gebiet: CurrentAreaName, CurrentAreaHash, CurrentAreaLevel, AreaChangeCounter.
  • Welt: Player (vollständiges Entity), Entities (vollständiger std::vector<Entity>), Vitals, LargeMap, MiniMap, WorldToScreenMatrix[16].
  • Fenster: ScreenWidth, ScreenHeight, ProcessId, GameWindow, LastUpdateTime, WorldToGridConvertor.

Was nicht im Snapshot enthalten ist – über Services abrufen: Inventarinhalt (InventoryService), Buffs (ComponentsService::EnumerateBuffs), Mod-Listen pro Item (InventoryService::ReadItemMods), UI-Panels (UiService).

Günstige Helfer, wenn Sie keinen vollen Snapshot benötigen:

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. Komponenten lesen

Entities legen ihre Komponenten über entity.Components offen – ein ComponentAddresses-Struct aus uintptr_t-Adressen. Übergeben Sie jede Adresse an das passende ComponentsService::Read*, um einen wertbasierten Snapshot zu erhalten.

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

Es gibt 21 Komponenten-Reader: ReadLife, ReadRender, ReadPositioned, ReadTargetable, ReadChest, ReadShrine, ReadStack, ReadCharges, ReadPlayer, ReadAnimated, ReadTransitionable, ReadTriggerableBlockage, ReadMinimapIcon, ReadStateMachine, ReadBase, ReadMods, ReadStats, ReadBuffs, ReadActor, ReadNpc, ReadDiesAfterTime.

ComponentAddresses selbst hält 24 Slots: die 21 oben genannten plus drei Marker (Buffs, WorldItem, AreaTransition) und OMP (host-intern). Buffs ist ein Präsenz-Marker – die eigentliche Buff-Liste kommt von EnumerateBuffs. WorldItem / AreaTransition sind Entity-Typ-Marker, keine echten Komponenten. Alle Slots haben passende HasX()-Prädikate auf ComponentAddresses.

Sammlungsbasierte Reader für Komponenten mit variabel großen Daten:

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>

Komfort-Helfer (Einmalaufruf – sie rufen intern Read* für Sie auf):

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)) { ... }

Ein Valid-Flag auf jedem zurückgegebenen Struct erlaubt es Ihnen, „Komponentenadresse war 0 / Lesen fehlgeschlagen“ ohne Exceptions zu behandeln. Wenn Sie das übergeordnete Struct (Life, Mods, …) bereits haben, greifen Sie direkt auf dessen Felder zu, anstatt den Helfer erneut aufzurufen – der Helfer liest die Komponente jedes Mal neu.

Entity-Felder

Jede Entity (einschließlich snap.Player und Elementen von snap.Entities) trägt denselben Satz an Feldern:

Gruppe Felder
Identität Id, Address, EntityDetailsAddress, RenderComponentAddress, IsValid
Klassifikation EntityType, EntitySubtype, EntityState, Rarity, Reaction, Zone (NearbyZone: InnerCircle≈60 / OuterCircle≈120 / Far)
Position GridPositionX, GridPositionY, TerrainHeight, WorldX/Y/Z, ModelBoundsZ
Schnelle Vitalwerte CurrentHP, MaxHP, CurrentES, MaxES (vermeidet einen ReadLife, wenn Sie nur die Summen brauchen)
Strings Path (std::wstring, Metadata/...), PlayerName (std::wstring), TgtPath (std::string, Asset-Pfad)
Zustand IsSleeping, IsChestOpened
Komponenten Components (ComponentAddresses-Sub-Struct)

Eine bestimmte Entity beobachten

Wenn Sie eine Entity über mehrere Frames hinweg verfolgen müssen (z. B. eine Truhe, die der Spieler gerade öffnet) und nicht jeden Frame die gesamte Entity-Liste durchscannen möchten, registrieren Sie einen 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) liefert ein std::optional<Entity> für Einmal-Lookups, und GetPlayer() gibt immer den lokalen Spieler zurück.

Bodengegenstände (WorldItem-Container)

Auf den Boden geworfene Items erscheinen in snap.Entities als EntityType::Item-Entities am Pfad Metadata/MiscellaneousObjects/WorldItem. Dies sind Container-Entities — sie tragen Mods / Base / Stack / Sockets nicht direkt. Die eigentliche Item-Entity liegt eine Indirektion weiter.

Um die innere Item-Entity als regulären Entity-Snapshot zu erhalten, verwenden Sie 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 ist nur bei echten WorldItem-Containern erfolgreich — der Aufruf mit einer Inventar-Item-Adresse liefert std::nullopt. Wenn Sie dieselbe Datenform wie bei Inventar-Items wünschen (ohne manuelles Durchwandern der Komponenten), löst die Inventory.ReadItem*-Familie aus dem nächsten Abschnitt WorldItem-Container transparent auf.


8. Inventar

ctx()->Inventory.Scan(inventoryId) löst auf der Host-Seite einen erneuten Scan aus. Verwenden Sie -1, um alle Inventare zu scannen.

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

Jedes Inventory legt zudem ein Grid-Struct offen, das beschreibt, wo das Inventar auf dem Bildschirm gezeichnet wird:

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

Um ein einzelnes Inventar per ID zu greifen (liefert dasselbe Struct mit bereits gefüllten Items):

PluginSDK::Inventory backpack = ctx()->Inventory.Get(/*inventoryId=*/0);

Oder, wenn Sie nur den Item-Vektor ohne das umschließende Struct wollen:

std::vector<PluginSDK::InventoryItem> items = ctx()->Inventory.GetItems(0);

Item-Mods: zwei APIs, zwei Reichweiten

ComponentsService::ReadMods(addr) liefert nur zusammenfassende Flags (IsCorrupted, IsRelic, IsSplit, IsMirrored, IsSynthesised, IsIdentified, Rarity, ItemLevel, RequiredLevel, CraftedModCount). Die einzelnen Mod-Listen pro Art sind nicht enthalten.

Für das vollständige Bild (Zusammenfassung + Mod-Listen) verwenden Sie 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)  { ... }

Weitere direkte Reads pro Entity (günstiger als ein erneuter Scan, wenn Sie bereits eine Item-Adresse halten):

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

Bodengegenstände über die Inventory-API. Alle sieben Inventory.ReadItem*-Reads oben (und ReadItemMods) akzeptieren SOWOHL Inventar-Item-Adressen ALS AUCH WorldItem-Container-Adressen. Container-Adressen werden vor dem Lesen automatisch zum inneren Item aufgelöst, sodass derselbe Plugin-Code-Pfad sowohl für Items in Taschen als auch für Items auf dem Boden funktioniert:

// `addr` kann entweder eine Inventar-Item-Adresse oder ein WorldItem-Container sein.
PluginSDK::ItemMods im = ctx()->Inventory.ReadItemMods(addr);
int          rarity   = ctx()->Inventory.ReadItemRarity(addr);
std::string  baseName = ctx()->Inventory.ReadItemBaseTypeName(addr);

Wenn Sie die Komponenten-Adressen des inneren Items direkt benötigen (z. B. um ctx()->Components.ReadStack(...) aufzurufen oder Sockets zu durchlaufen), verwenden Sie stattdessen Entities.GetWorldItemInner aus Abschnitt 7.


9. UI-Baum

Der UI-Baum des Spiels wird als uintptr_t-Element-Adressen offengelegt. Starten Sie an einer Wurzel, durchlaufen Sie die Kinder und lesen Sie Elementfelder.

Der saubere Weg, ein bekanntes Panel über seine StringId zu finden:

uintptr_t gameUiRoot = ctx()->Ui.GetGameUiRoot();
uintptr_t invPanel   = ctx()->Ui.FindPanelByStringId(gameUiRoot, "Inventory");
if (invPanel && ctx()->Ui.IsVisible(invPanel)) {
    // panel is on-screen
}

Manuelles Durchlaufen des Baums, wenn Sie die StringId nicht kennen:

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-Werte sind stabile spielseitige Identifikatoren; bevorzugen Sie sie gegenüber fest codierten Pfaden, wann immer es welche gibt.


10. Rendering und Projektion

Drei Projektions-Helfer, zwei Koordinatensysteme.

Perspektivisch (3D-Welt → Bildschirm) – dieselbe Projektion, mit der das Spiel Dinge in der Welt zeichnet. Gut für Namensschilder, Debug-Marker, Zielindikatoren:

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

Isometrisch (Grid → Minimap) – für radarartige Overlays, die auf der großen oder kleinen Karte gezeichnet werden. Diese respektieren Zoom, Pan und Rotation der sichtbaren Karte:

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)

Für gebündelte Mathematik (Funktionsaufrufe pro Entity vermeiden) holen Sie die Transformation einmal und führen die Projektion inline durch:

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.

Siehe Plugins/Radar/src/Radar.cpp für ein funktionierendes Radar, das vollständig auf diesen Aufrufen aufbaut.


11. Terrain und begehbares Grid

Das begehbare Grid ist eine 4-Bit-pro-Kachel-Bitmap, die angibt, welche Terrain-Zellen der Spieler betreten kann. Der Host aktualisiert es bei jedem Gebietswechsel; Plugins erhalten ein stabiles Handle, das überlebt, bis das Plugin es freigibt (via 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 spiegelt die gleiche Form, hält aber ein float pro Kachel (Data() ist const float*, ergänzt um ElementCount() und SizeBytes()).

Abonnieren Sie OnAreaChange nicht, um das Handle zu aktualisieren. Das Event feuert, wenn der Host-Worker einen Gebietswechsel erkennt, aber das neue begehbare Grid ist möglicherweise noch nicht geparst – Sie würden für ein oder zwei Frames einen veralteten Zeiger halten. Pollen Sie stattdessen pro Frame in DrawUI:

auto current = ctx()->Terrain.GetWalkableGrid();
if (current.Data() != m_walkable.Data()) {
    m_walkable = std::move(current);   // swap when the host re-parses
}

Das ist günstig (ein ABI-Aufruf plus ein Zeiger-Vergleich). Siehe Plugins/Radar/src/Radar.cpp für die produktive Version.

Weitere Terrain-Accessoren:

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

Abonnieren Sie vom Host emittierte Ereignisse. Jedes Subscribe liefert ein Token, das Sie später an Unsubscribe übergeben können. Der Destruktor von EventsService (ausgelöst beim Deaktivieren oder Entladen des Plugins) gibt alles noch Ausstehende automatisch frei – Sie müssen also nicht zwingend manuell abbestellen, es ist aber höflich.

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

Die vier Event-Arten sind AreaChange, Frame, GameAttached, GameDetached. Es gibt zudem ein generisches Subscribe(EventKind, callback), falls Sie lieber eine Dispatch-Tabelle aufbauen.

Der const_cast ist erforderlich, weil Events seine interne Token-Map mutiert. Die Basisklasse liefert const Context*, um versehentliches Mutieren anderer Services zu erschweren.

Abonnements gruppieren

Wenn Ihr Plugin mehrere Abonnements besitzt, ist das Muster aus ExamplePlugin eine saubere Methode, Aktivieren und Deaktivieren symmetrisch zu halten – bündeln Sie Tokens und Zähler in ein einziges Status-Struct und leiten Sie alles über ein einziges SubscribeAll/UnsubscribeAll-Paar:

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

Siehe Plugins/ExamplePlugin/examples/ExampleEvents.h für das vollständige Muster.


13. Einstellungen persistieren

Konvention: <plugin directory>/config/settings.json. Directory() liefert den absoluten UTF-8-Pfad zu Ihrem Plugin-Ordner.

Für triviale Einstellungen funktioniert ein selbstgeschriebener JSON-Writer einwandfrei und hält die DLL selbstgenügsam. Siehe Plugins/Radar/src/RadarSettings.h für ein funktionierendes Beispiel. Das Grundgerüst:

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

Für strukturierte Daten (verschachtelte Objekte, Arrays) binden Sie eine echte JSON-Bibliothek in Ihrem Plugin-Ordner ein. Der Host gibt keine Wahl vor.

SaveSettings wird periodisch (~5 s) und beim Deaktivieren aufgerufen; Sie müssen es nicht selbst aufrufen.


14. Logging

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

Alle vier Ebenen werden in den zentralen Logger des Hosts geleitet. Meldungen erscheinen im Logs-Tab des Hosts und in der Logdatei auf der Festplatte. Formatieren Sie selbst vor dem Aufruf; der Host akzeptiert keine printf-artigen Variadic-Argumente.

Intern emittieren die Komfortmethoden die Strings "Debug", "Info", "Warning" und "Error" (Warn wird auf "Warning" abgebildet). Die Host-Bridge führt einen Vergleich ohne Berücksichtigung der Groß-/Kleinschreibung durch, sodass ein Plugin, das Log("warn", "msg") aufruft, ebenfalls korrekt geleitet wird – die Komfortmethoden sind aber klarer.


15. Memory (Power-User)

Direkte Speicher-Primitive. Bevorzugen Sie die High-Level-Services, wo immer möglich – sie kennen Offsets, behandeln ABI-Änderungen und sind SEH-sicher. Direkte Memory-Reads sind nur dann angemessen, wenn es keinen höheren Aufruf für das gibt, was Sie benötigen.

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

Wenn Sie sich öfter dabei ertappen, dass Sie zu diesen Aufrufen greifen, fragen Sie sich, ob die Daten, die Sie brauchen, eigentlich in die höheren Services gehören.


16. Bridge / SEH-Sicherheit

Jeder DLL-übergreifende Aufruf zwischen Host und Plugin läuft auf der Host-Seite innerhalb eines __try / __except-Blocks. Ein sich fehlverhaltendes Plugin, das einen veralteten Zeiger dereferenziert, durch Null teilt oder anderweitig innerhalb eines SDK-Aufrufs faultet, bekommt einen geloggten Fehler – der Host-Prozess stürzt nicht ab, das Spiel läuft weiter und der Benutzer kann andere Plugins weiter benutzen.

Das heißt nicht, dass Plugins schludrig sein dürfen. SEH fängt das Symptom, nicht die Ursache. Wenn Ihr Plugin in jedem Frame Faults wirft, sieht der Benutzer eine Flut von Fehlerlogs und Ihre Daten sind faktisch unverfügbar. Behandeln Sie Null-Rückgaben aus SDK-Aufrufen, prüfen Sie Valid-Flags auf Komponentendaten und dereferenzieren Sie uintptr_t-Adressen nicht direkt – führen Sie sie durch die ComponentsService / Ui / Memory-Aufrufe, die RPM bereits korrekt einwickeln.

Der Host kommt mit einem fehlerhaften Plugin zurecht. Mit einer hängenden Plugin-DLL nicht – ein DrawSettings, das 100 ms braucht, blockiert den gesamten UI-Thread. Halten Sie die Arbeit pro Frame günstig.


17. Häufige Fallstricke

Eine kurze Liste mit Dingen, auf die Plugin-Autoren bei der ersten Integration stoßen. Das meiste davon ist oben inline dokumentiert; hier als Checkliste gesammelt.

  1. OnAreaChange feuert, bevor das begehbare Grid neu geparst ist. Aktualisieren Sie WalkableGridHandle nicht aus dem Event – pollen Sie pro Frame in DrawUI und tauschen Sie, wenn sich Data() ändert. (§11)
  2. Entity::Zone ist für den lokalen Spieler immer None. Es ist eine Entfernungsklassifikation relativ zum Spieler, also liegt der Spieler per Definition bei Entfernung null. Zeigen Sie es nicht in Spieler-Info-Anzeigen.
  3. Components.ReadMods() liefert nur zusammenfassende Flags – keine Mod-Listen. Für Mod-Listen pro Art rufen Sie Inventory.ReadItemMods(entityAddr) auf. (§8)
  4. Auf den Boden gefallene Items können kein EntitySubtype haben. Wenn Sie nach Items in der Welt filtern, bevorzugen Sie EntityType == Item || EntityType == Chest gegenüber einer engeren Subtype-Prüfung.
  5. Directory() liefert einen absoluten Pfad. Stellen Sie das EXE-Verzeichnis nicht selbst voran – Sie bekommen EXEDIR\EXEDIR\Plugins\X und Ihre Konfigurations-Writes landen außerhalb des Plugin-Ordners.
  6. ctx() liefert const Context*. Mutierende Methoden wie EventsService::Subscribe erfordern einen const_cast. Das ist Absicht – Services, die nicht mutieren, sollen sich auch nicht versehentlich mutieren lassen.
  7. ImGui::SetCurrentContext ist pro DLL. Rufen Sie es an jedem Einstiegspunkt auf, der zeichnet (OnEnable, DrawUI, DrawSettings), weil die Plugin-DLL standardmäßig ihren eigenen ImGui-Zustand besitzt.
  8. Die Komfort-Helfer lesen die Komponente bei jedem Aufruf neu. GetHealthPercent(addr) führt intern ein frisches ReadLife(addr) aus. Wenn Sie das Life-Struct aus einem früheren Aufruf bereits halten, greifen Sie stattdessen direkt auf dessen Felder zu.

18. Service-Schnellreferenz

Einzeilige Zusammenfassung jeder öffentlichen Methode jedes Services. Verwenden Sie die Fließtext-Abschnitte oben für die vollständigen Typsignaturen und Verwendungshinweise.

GameService

Methode Liefert Zweck
GetSnapshot() Snapshot Vollständige Sicht pro Frame, einschließlich Entities
GetState() GameState Enum: InGame, Login, Loading, …
IsAttached() bool Spielprozess angebunden
IsInGame() bool State == InGame
IsForeground() bool Spielfenster hat den Fokus
IsMenuVisible() bool ESC-Menü / Einstellungen offen
IsOverlayMode() bool Host ist im Overlay (klickbar durch)
GetProcessId() DWORD PID des Spiels
GetGameWindow() HWND Handle des Spielfensters
GetScreenSize() ScreenSize {Width, Height} als Floats

EntitiesService

Methode Liefert Zweck
Enumerate(cb) Jede nahe Entity besuchen (false zum Stoppen zurückgeben)
GetPlayer() Entity Die lokale Spieler-Entity
FindById(id) std::optional<Entity> Lookup per Entity-ID
GetWorldItemInner(addr) std::optional<Entity> Innere Item-Entity für einen WorldItem-Container (Bodengegenstände)
Watch(id) Eine Entity anheften, damit ihre Komponenten lesbar bleiben
Unwatch(id) Einen Watch freigeben
IsWatched(id) bool Watch-Zustand
GetWatchedComponents(id) std::optional<ComponentAddresses> Angeheftete Komponenten lesen

ComponentsService

Methode Liefert Zweck
ReadLife / ReadRender / ReadPositioned / ReadTargetable / ReadChest / ReadShrine / ReadStack / ReadCharges / ReadPlayer / ReadAnimated / ReadTransitionable / ReadTriggerableBlockage / ReadMinimapIcon / ReadStateMachine / ReadBase / ReadMods / ReadStats / ReadBuffs / ReadActor / ReadNpc / ReadDiesAfterTime Komponenten-Struct 21 Reader, einer pro Komponententyp
EnumerateBuffs(addr) std::vector<Buff> Aktive Buffs auf der Entity
EnumerateActiveSkills(addr) std::vector<ActiveSkill> Skills aus einer Actor-Komponente
EnumerateStats(addr) std::vector<StatEntry> Aus Items + Buffs bezogene Stats
EnumerateItemMods(addr) std::vector<Mod> Über eine Mods-Komponente erreichbare Mods
GetHealthPercent / GetEsPercent / GetManaPercent float Komfort-Prozent-Helfer
IsAlive(addr) bool Gesundheit > 0
GetItemRarity(addr) int Seltenheit aus einer Mods-Komponente
IsItemIdentified(addr) bool Identifiziert-Flag
GetStackCount(addr) int Aktueller Stapelzähler
IsChestOpened(addr) bool Truhen-Offen-Flag
GetPlayerName(addr) std::string Spielername aus einer Player-Komponente
GetWorldPosition(renderAddr, x, y, z) bool Komfortzugriff auf Weltkoordinaten

InventoryService

Methode Liefert Zweck
Scan(inventoryId) Host-seitigen Rescan auslösen (-1 = alle)
Get(inventoryId) Inventory Ein Inventar, Items bereits gefüllt
GetItems(inventoryId) std::vector<InventoryItem> Nur Items
GetAll() std::vector<Inventory> Alle gescannten Inventare
GetName(inventoryId) const char* Anzeigename ("Backpack", "Stash", …)
ReadItemRarity(addr) int Seltenheit pro Entity (löst WorldItem-Container automatisch auf)
ReadItemStackCount(addr) int Stapel pro Entity (löst WorldItem-Container automatisch auf)
ReadItemBaseTypeName(addr) std::string Basistyp, löst WorldItem-Container automatisch auf
ReadItemUniqueName(addr) std::string Unique-Name, löst WorldItem-Container automatisch auf
ReadItemPath(addr) std::string Metadata/Items/...-Pfad, löst WorldItem-Container automatisch auf
ReadItemMods(addr) ItemMods Zusammenfassungs-Flags + 5 Mod-Vektoren pro Art, löst WorldItem-Container automatisch auf

UiService

Methode Liefert Zweck
Read(addr) UiElement Element-Felder (Rechteck, Flags, Kinderanzahl)
GetChildren(addr) std::vector<uintptr_t> Adressen der Kindelemente
GetChildAt(addr, index) uintptr_t Einzelnes Kind per Index
FollowPath(root, indices, count) uintptr_t Einen bekannten Indexpfad durchlaufen
IsVisible(addr) bool Element ist auf dem Bildschirm
GetStringId(addr) std::string Stabiler spielseitiger Identifikator
GetText(addr) std::string Gerenderter Text
ComputeScreenRect(addr, x, y, w, h) bool Finales Rechteck im Bildschirmraum
GetGameUiRoot() uintptr_t Wurzel der In-Game-UI
GetUiRoot() uintptr_t Oberste UI-Wurzel
GetCullValue() int UI-Cull-Schwellwert des Hosts
FindPanelByStringId(parent, stringId) uintptr_t Gezieltes Nachfahren-Lookup

RenderService

Methode Liefert Zweck
WorldToScreen(wx, wy, wz, sx, sy) bool Perspektivische Projektion
GridToLargeMap(gx, gy, worldZ, sx, sy) bool Projektion auf das Large-Map-Overlay
GridToMiniMap(gx, gy, worldZ, sx, sy) bool Projektion auf die Minimap
GetLargeMapTransform() MapTransform Vorberechnete Transformation für gebündelte Mathematik
GetMiniMapTransform() MapTransform Dasselbe, für die Minimap

TerrainService

Methode Liefert Zweck
GetWalkableGrid() WalkableGridHandle RAII-Handle auf die 4-Bit-pro-Kachel-Begehbarkeits-Bitmap
GetHeightGrid() HeightGridHandle RAII-Handle auf die Terrain-Höhen pro Kachel
IsWalkable(gx, gy) bool Einzelkachel-Prädikat
GetTerrainHeight(gx, gy) float Z-Wert im Weltraum
GetWorldToGridConvertor() float Umrechnungsfaktor Welt → Grid
EnumerateTgtLocations(cb) Jede TGT-Instanz im aktuellen Gebiet besuchen

MemoryService

Methode Liefert Zweck
Read(addr, buf, size) bool Roher RPM
ReadString(addr) std::string Nullterminierter schmaler String
ReadWString(addr) std::wstring Nullterminierter Wide-String
ReadStdWString(addr) std::wstring Liest einen spielseitigen std::wstring-Container (mit SSO-Unterstützung)
ReadStdVector(addr, elemSize, maxElems) std::vector<uint8_t> Rohbytes; reinterpretieren als Ihr Typ
GetBaseAddress() uintptr_t Basis des Spielmoduls
GetModuleSize() uintptr_t Größe des Spielmoduls
GetPatternAddress(name) uintptr_t Lookup eines benannten Patterns

LogService

Methode Zweck
Debug / Info / Warn / Error(msg) Auf der entsprechenden Ebene emittieren
Log(level, msg) Beliebige Level-Zeichenkette

EventsService

Methode Liefert Zweck
Subscribe(kind, cb) Token Generischer Dispatch
OnAreaChange / OnFrame / OnGameAttached / OnGameDetached(cb) Token Einzeilen-Abonnement-Helfer
Unsubscribe(token) Manuelle Freigabe (Destruktor gibt sowieso automatisch frei)

19. Versionierung

PluginAbi.h definiert:

constexpr int PLUGIN_SDK_VERSION = 6;

Beim Laden ruft der Host plugin->GetSDKVersion() auf und vergleicht es mit seinem eigenen PLUGIN_SDK_VERSION. Bei einer Diskrepanz → der Host loggt eine Warnung und lehnt das Laden des Plugins ab.

Der Host prüft außerdem HostAbi::version und HostAbi::size_bytes innerhalb von PluginSDK_AttachHost (durch PluginSDK.h inline definiert, wenn PLUGIN_EXPORTS gesetzt ist). Stimmt eines der Felder nicht mit dem überein, gegen das das Plugin gebaut wurde, ist ctx() funktionslos. Der Basisklassen-Accessor HostCompatible() meldet in diesem Fall false, und jedes Plugin, das sich höflich verhalten will, sollte den Dienst verweigern:

void OnEnable(bool) override {
    if (!HostCompatible()) {
        ctx()->Log.Error("Host ABI mismatch — disable plugin");
        return;
    }
    // ...
}

20. Beispiel-Plugins

Vier Plugins im Repository sind dafür gedacht, als Dokumentation gelesen zu werden:

  • Plugins/ExamplePlugin/ – Breitenschau über die gesamte Oberfläche. Ein Plugin, das nahezu jeden Service berührt, organisiert in 11 examples/Example*.h-Unterdateien (Area & Vitals, Buffs, Entities, Inventory, Memory, UI Explorer, Component Reader, Render, Terrain, Events, Log) plus ein Abdeckungs-Übersichts-Banner. Lesen Sie dies, wenn Sie sehen möchten, wie ein Service in seinem Kontext verwendet wird.

  • Plugins/Radar/ – fokussiertes Praxisbeispiel. ~200 Zeilen. Ein Radar-Overlay, das vollständig auf dem öffentlichen SDK aufbaut – keine Offsets, keine rohen Memory-Reads. Zeichnet die begehbare Karte sowie Punkte pro Entity über Render.GridToLargeMap. Lesen Sie dies, wenn Sie den minimalen Code für ein bestimmtes Ergebnis sehen möchten.

  • Plugins/KillCount/ – Tracker für Kills, Truhen und Tode. SQLite + Sprite-Atlas + Zustand pro Gebiet. Zeigt, wie man Persistenz, mitgelieferte Datendateien und ein Overlay in einer einzigen DLL ausliefert.

  • Plugins/NinjaPricer/ – poe.ninja-Preis-Overlay. HTTP-Abruf (Exchange API) + Inventar-Scan + Preisermittlung pro Item. Zeigt Netzwerkcode, Ingestion von Drittanbieter-Daten und Inventar-Iteration in einem realen Arbeitsablauf.


← Home

Clone this wiki locally