From 8168eeab82501c6a67f6d436cd533b7d41072321 Mon Sep 17 00:00:00 2001 From: Dennis van 't Hul Date: Sun, 30 Aug 2026 22:02:56 +0200 Subject: [PATCH] feat(plugins): Add a plugin hook framework for observer overlays Adds a host-side plugin loader and a C ABI so an observer overlay can live outside the engine as a DLL. The engine loads every *.goplugin.dll under plugins\ at startup; each plugin opts into the hook categories it wants and receives events through an ABI that never exposes engine types. With no plugin present nothing changes: every call site is a guarded call whose condition is an inlined !empty() on a static vector. PluginABI.h is the host/plugin contract - pure C, no STL and no engine types, so a plugin compiles against that one header. It is versioned (GO_PLUGIN_ABI_VERSION 5) with a structSize guard, and a mismatch fails the load rather than risk misreading the table. Callbacks carry information about other players, so the whole framework is gated on the local player being an observer or dead - the same condition InGameUI::drawObserverStats uses, and one that also holds during replay playback. While that gate is closed no callback is delivered and GO_Plugin_Tick is not called, so a match participant cannot gain an information advantage from a plugin. No existing engine function is restructured. Every touch point is an inserted guarded call, so this diff removes no upstream line. Two additions are worth a reviewer's attention: - View::worldToScreenTriReturnAllowFarClip(), a new virtual with a non-pure default so existing View subclasses are unaffected. worldToScreenTriReturn rejects anything past the far clip plane, but CameraClass::Project has already written a valid perspective-divided position by then - only OUTSIDE_NEAR_CLIP zeroes its output - and an off-screen edge indicator needs it. It is deliberately standalone rather than sharing a body with the existing function, which is therefore untouched. A downcast from the plugin framework was rejected because ViewDummy is installed as TheTacticalView in headless builds. - InGameUI::drawPluginText2D and drawPluginText2DScaled, on InGameUI only because m_messageFont, m_messagePointSize and m_messageBold are protected with no getters. The rect and line primitives, which only forward to TheDisplay, live in the plugin framework instead. Co-Authored-By: Claude Opus 5 --- Core/GameEngine/Include/GameClient/View.h | 4 + .../GameClient/MessageStream/CommandXlat.cpp | 18 + .../GameClient/MessageStream/WindowXlat.cpp | 70 + .../Include/W3DDevice/GameClient/W3DView.h | 1 + .../Source/W3DDevice/GameClient/W3DView.cpp | 35 + GeneralsMD/Code/GameEngine/CMakeLists.txt | 3 + .../GameEngine/Include/GameClient/InGameUI.h | 6 + .../GeneralsOnline/Plugins/PluginABI.h | 433 +++++ .../GeneralsOnline/Plugins/PluginManager.h | 90 + .../GameEngine/Source/Common/GameEngine.cpp | 10 + .../GameEngine/Source/GameClient/InGameUI.cpp | 64 + .../GameLogic/Object/Body/ActiveBody.cpp | 41 + .../SpecialPower/SpecialPowerModule.cpp | 16 + .../Object/Update/ProductionUpdate.cpp | 82 + .../GeneralsOnline/Plugins/PluginManager.cpp | 1557 +++++++++++++++++ .../W3DDevice/GameClient/W3DDisplay.cpp | 29 + .../Libraries/Source/WWVegas/WW3D2/render2d.h | 5 + 17 files changed, 2464 insertions(+) create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/Plugins/PluginABI.h create mode 100644 GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/Plugins/PluginManager.h create mode 100644 GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/Plugins/PluginManager.cpp diff --git a/Core/GameEngine/Include/GameClient/View.h b/Core/GameEngine/Include/GameClient/View.h index 33ab4edd7fa..6bdeab72501 100644 --- a/Core/GameEngine/Include/GameClient/View.h +++ b/Core/GameEngine/Include/GameClient/View.h @@ -247,6 +247,10 @@ class View : public Snapshot Bool worldToScreen( const Coord3D *w, ICoord2D *s ) { return worldToScreenTriReturn( w, s ) == WTS_INSIDE_FRUSTUM; } ///< Transform world coordinate "w" into screen coordinate "s" virtual WorldToScreenReturn worldToScreenTriReturn(const Coord3D *w, ICoord2D *s ) = 0; ///< Like worldToScreen(), but with a more informative return value + /// Like worldToScreenTriReturn(), but a point beyond the far clip plane still projects instead of + /// returning WTS_INVALID, so an off-screen indicator can still be placed for it. Such a point is + /// reported as WTS_OUTSIDE_FRUSTUM even when its x/y land on screen. + virtual WorldToScreenReturn worldToScreenTriReturnAllowFarClip(const Coord3D *w, ICoord2D *s ) { return worldToScreenTriReturn( w, s ); } /// Transform screen point to the viewed world position on the 3D terrain. Returns true when intersection exists. virtual Bool screenToTerrain( const ICoord2D *screen, Coord3D *world ) = 0; diff --git a/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp b/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp index ab8a8c5380b..be7917c7344 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/CommandXlat.cpp @@ -60,6 +60,7 @@ #include "GameClient/GameClient.h" #include "GameClient/GameWindowManager.h" #include "GameClient/GameText.h" +#include "GameClient/Keyboard.h" #include "GameClient/ParticleSys.h" #include "GameClient/GUICallbacks.h" #include "GameClient/Shell.h" @@ -89,6 +90,9 @@ #include "GameNetwork/GameInfo.h" #include "GameNetwork/GameSpyOverlay.h" #include "GameNetwork/GameSpy/BuddyThread.h" +#if defined(GENERALS_ONLINE) +#include "GameNetwork/GeneralsOnline/Plugins/PluginManager.h" +#endif #include "WW3D2/ww3d.h" #include "../OnlineServices_Init.h" @@ -3934,6 +3938,20 @@ GameMessageDisposition CommandTranslator::translateGameMessage(const GameMessage disp = DESTROY_MESSAGE; } + // Forwarded even when the engine already handled this key; plugin hotkeys are the + // plugin's own choice and are not expected to collide with F11/F10/F5/INS. + if (GOPluginManager::HasRenderHooks()) + { + uint32_t modifierFlags = 0; + if (TheKeyboard != nullptr) + { + if (TheKeyboard->isCtrl()) modifierFlags |= 1; + if (TheKeyboard->isShift()) modifierFlags |= 2; + if (TheKeyboard->isAlt()) modifierFlags |= 4; + } + GOPluginManager::DispatchRawKeyUp((uint32_t)key, modifierFlags); + } + break; } #endif diff --git a/Core/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp b/Core/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp index 79553645b43..38324cd7c99 100644 --- a/Core/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp +++ b/Core/GameEngine/Source/GameClient/MessageStream/WindowXlat.cpp @@ -55,6 +55,10 @@ #include "GameClient/WindowXlat.h" #include "GameClient/Shell.h" #include "GameClient/Display.h" +#include "GameClient/Keyboard.h" +#if defined(GENERALS_ONLINE) +#include "GameNetwork/GeneralsOnline/Plugins/PluginManager.h" +#endif // DEFINES //////////////////////////////////////////////////////////////////// @@ -172,6 +176,72 @@ GameMessageDisposition WindowTranslator::translateGameMessage(const GameMessage Bool forceKeepMessage = FALSE; WinInputReturnCode returnCode = WIN_INPUT_NOT_USED; +#if defined(GENERALS_ONLINE) + // Deliberately ahead of the mouse-lock early return below. + if (GOPluginManager::HasRenderHooks()) + { + // buttonIndex stays -1 for anything that is not a click. + Int buttonIndex = -1; + Bool buttonDown = FALSE; + Bool isMouseMove = FALSE; + switch (msg->getType()) + { + case GameMessage::MSG_RAW_MOUSE_POSITION: + isMouseMove = TRUE; + break; + case GameMessage::MSG_RAW_MOUSE_LEFT_BUTTON_DOWN: + case GameMessage::MSG_RAW_MOUSE_LEFT_DOUBLE_CLICK: + buttonIndex = 0; buttonDown = TRUE; + break; + case GameMessage::MSG_RAW_MOUSE_LEFT_BUTTON_UP: + buttonIndex = 0; + break; + case GameMessage::MSG_RAW_MOUSE_MIDDLE_BUTTON_DOWN: + case GameMessage::MSG_RAW_MOUSE_MIDDLE_DOUBLE_CLICK: + buttonIndex = 1; buttonDown = TRUE; + break; + case GameMessage::MSG_RAW_MOUSE_MIDDLE_BUTTON_UP: + buttonIndex = 1; + break; + case GameMessage::MSG_RAW_MOUSE_RIGHT_BUTTON_DOWN: + case GameMessage::MSG_RAW_MOUSE_RIGHT_DOUBLE_CLICK: + buttonIndex = 2; buttonDown = TRUE; + break; + case GameMessage::MSG_RAW_MOUSE_RIGHT_BUTTON_UP: + buttonIndex = 2; + break; + default: + break; + } + + // Only the messages above carry a cursor position in argument 0. + if (isMouseMove || buttonIndex >= 0) + { + const ICoord2D& pos = msg->getArgument(0)->pixel; + if (isMouseMove) + { + GOPluginManager::DispatchMouseMove((int32_t)pos.x, (int32_t)pos.y); + } + else + { + // Bit order as documented by GORenderCallbacks. + uint32_t modifierFlags = 0; + if (TheKeyboard != nullptr) + { + if (TheKeyboard->isCtrl()) modifierFlags |= 1; + if (TheKeyboard->isShift()) modifierFlags |= 2; + if (TheKeyboard->isAlt()) modifierFlags |= 4; + } + + if (buttonDown) + GOPluginManager::DispatchMouseButtonDown((uint8_t)buttonIndex, (int32_t)pos.x, (int32_t)pos.y, modifierFlags); + else + GOPluginManager::DispatchMouseButtonUp((uint8_t)buttonIndex, (int32_t)pos.x, (int32_t)pos.y, modifierFlags); + } + } + } +#endif + if (TheTacticalView && TheTacticalView->isMouseLocked()) { //Kris: Aug 15, 2003 diff --git a/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DView.h b/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DView.h index d37e41e86e8..b82b3f80433 100644 --- a/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DView.h +++ b/Core/GameEngineDevice/Include/W3DDevice/GameClient/W3DView.h @@ -224,6 +224,7 @@ class W3DView : public View, public SubsystemInterface virtual void setFieldOfView( Real angle ) override; ///< Set the horizontal field of view angle virtual WorldToScreenReturn worldToScreenTriReturn( const Coord3D *w, ICoord2D *s ) override; ///< Transform world coordinate "w" into screen coordinate "s" + virtual WorldToScreenReturn worldToScreenTriReturnAllowFarClip( const Coord3D *w, ICoord2D *s ) override; ///< As above, but a point beyond the far clip plane still projects virtual Bool screenToTerrain( const ICoord2D *screen, Coord3D *world ) override; virtual PlaneClass::IntersectionResType screenToWorldAtZ( const ICoord2D *screen, Coord3D *world, Real z ) override; diff --git a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp index 5a784375675..fbe3c08ece9 100644 --- a/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp +++ b/Core/GameEngineDevice/Source/W3DDevice/GameClient/W3DView.cpp @@ -2395,6 +2395,41 @@ View::WorldToScreenReturn W3DView::worldToScreenTriReturn( const Coord3D *w, ICo return WTS_INVALID; } +//------------------------------------------------------------------------------------------------- +/** As worldToScreenTriReturn, but a point beyond the far clip plane still yields a usable screen + position. CameraClass::Project only zeroes its output for OUTSIDE_NEAR_CLIP; past the far plane + it has already written the perspective-divided position before it returns. */ +//------------------------------------------------------------------------------------------------- +View::WorldToScreenReturn W3DView::worldToScreenTriReturnAllowFarClip( const Coord3D *w, ICoord2D *s ) +{ + // sanity + if( w == nullptr || s == nullptr || m_3DCamera == nullptr ) + return WTS_INVALID; + + Vector3 world; + Vector3 screen; + + world.Set( w->x, w->y, w->z ); + enum CameraClass::ProjectionResType projection = m_3DCamera->Project( screen, world ); + if( projection == CameraClass::OUTSIDE_NEAR_CLIP ) + { + s->x = 0; + s->y = 0; + return WTS_INVALID; + } + + W3DLogicalScreenToPixelScreen( screen.X, screen.Y, + &s->x, &s->y, + getWidth(), getHeight()); + s->x += m_originX; //convert viewport coordinates to full screen coordinates + s->y += m_originY; + + if( projection != CameraClass::INSIDE_FRUSTUM ) + return WTS_OUTSIDE_FRUSTUM; + + return WTS_INSIDE_FRUSTUM; +} + //------------------------------------------------------------------------------------------------- /** all the drawables in the view, that fall within the 2D screen region * will call the callback function. The number of drawables that passed diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index f49819dc3f4..2cadbd9a1f7 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -1160,6 +1160,8 @@ set(GAMEENGINE_SRC Include/GameNetwork/GeneralsOnline/HTTP/HTTPRequest.h Include/GameNetwork/GeneralsOnline/NextGenTransport.h Include/GameNetwork/GeneralsOnline/PluginInterfaces.h + Include/GameNetwork/GeneralsOnline/Plugins/PluginABI.h + Include/GameNetwork/GeneralsOnline/Plugins/PluginManager.h Include/GameNetwork/GeneralsOnline/Vendor/ValveNetworkingSockets/steam/isteamnetworkingmessages.h Include/GameNetwork/GeneralsOnline/Vendor/ValveNetworkingSockets/steam/isteamnetworkingsockets.h Include/GameNetwork/GeneralsOnline/Vendor/ValveNetworkingSockets/steam/isteamnetworkingutils.h @@ -1202,6 +1204,7 @@ set(GAMEENGINE_SRC Source/GameNetwork/GeneralsOnline/NextGenTransport.cpp Source/GameNetwork/GeneralsOnline/NetworkBitstream.cpp Source/GameNetwork/GeneralsOnline/PluginInterfaces.cpp + Source/GameNetwork/GeneralsOnline/Plugins/PluginManager.cpp ) if(RTS_GAMEMEMORY_ENABLE) diff --git a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h index af61c9b0355..66bb9674064 100644 --- a/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h +++ b/GeneralsMD/Code/GameEngine/Include/GameClient/InGameUI.h @@ -485,6 +485,12 @@ class InGameUI : public SubsystemInterface, public Snapshot virtual void postDraw(); ///< Logic which needs to occur after the UI renders virtual void postWindowDraw(); ///< Logic which needs to occur after the WindowManager has repainted the menus + // Text primitives backing GOPluginHostAPI (see PluginABI.h); here rather than in the plugin + // framework because they need the message font members below. Only ever called from within + // DispatchDrawOverlay(), i.e. during postWindowDraw() where 2D drawing is already set up. + void drawPluginText2D(Int x, Int y, const char* asciiText, Color color); + void drawPluginText2DScaled(Int x, Int y, const char* asciiText, Color color, Real sizeScale, Bool bold); + /// Ingame video playback virtual void playMovie(const AsciiString& movieName); virtual void stopMovie(); diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/Plugins/PluginABI.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/Plugins/PluginABI.h new file mode 100644 index 00000000000..c45e6592c9a --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/Plugins/PluginABI.h @@ -0,0 +1,433 @@ +#pragma once + +// The contract between the game client (host) and any plugin DLL. Must stay pure C - POD structs, +// function pointers and primitives only, no STL or engine types - so a plugin compiles against it +// without engine headers and both sides always agree on layout. The host loads any number of +// plugins; each opts into hook categories at GO_Plugin_Initialize via the table it is handed. + +#include + +#ifdef _WIN32 +#define GO_PLUGIN_API extern "C" __declspec(dllexport) +#define GO_PLUGIN_IMPORT extern "C" __declspec(dllimport) +#else +#define GO_PLUGIN_API extern "C" +#define GO_PLUGIN_IMPORT extern "C" +#endif + +// Bump when the layout of any struct below changes. GOPluginInfo::abiVersion and +// GOPluginHostAPI::abiVersion are checked against this on load; a mismatch fails the plugin load +// rather than risk silently misreading a function-pointer table with a different layout. +#define GO_PLUGIN_ABI_VERSION 5 + +// Compile-time layout check, spelled without static_assert/_Static_assert so this header still +// compiles as C89 as well as C++. Both sides fail to build the moment a struct below lays out +// differently from what this contract says, which is the one failure GO_PLUGIN_ABI_VERSION cannot +// catch. The expected sizes are the 32-bit (x86) ones - host and plugin are both Win32 builds. +#define GO_ABI_ASSERT_CONCAT_(a, b) a##b +#define GO_ABI_ASSERT_CONCAT(a, b) GO_ABI_ASSERT_CONCAT_(a, b) +#define GO_ABI_ASSERT_LAYOUT(cond) typedef char GO_ABI_ASSERT_CONCAT(goAbiLayoutAssert_, __LINE__)[(cond) ? 1 : -1] + +// Hook categories. GOPluginInfo::hookCategories is informational only; actual use requires the +// matching register* call during GO_Plugin_Initialize. Only the categories plugins consume are +// exposed, keeping the surface the engine must guarantee small. Carried as uint32_t, never as the +// enum type - the enum has no base-type specifier. +enum EGOPluginHookCategory +{ + GO_HOOK_NONE = 0, + GO_HOOK_GAMEPLAY_EVENTS = 1 << 0, // unit/upgrade/power/building gameplay events + GO_HOOK_RENDER = 1 << 1, // per-frame overlay draw + raw hotkey passthrough +}; + +// Production/power/building events. Payloads are plain data rather than engine pointers, since +// they cross a DLL boundary and must not depend on the host's C++ class layout. +struct GOUnitEvent +{ + uint32_t playerIndex; + const char* templateName; // ThingTemplate name, e.g. "AmericaVehicleCrusader" + uint32_t producerObjectId; // producing building's ObjectID (0 if unavailable) + float percentComplete; // 0..100, valid for queued/completed; ignored for cancelled + int32_t productionID; // engine ProductionID, stable per queue entry until completion/cancel + float producerPositionX; // producing building's world position at the moment of this event + float producerPositionY; + float producerPositionZ; +}; +GO_ABI_ASSERT_LAYOUT(sizeof(GOUnitEvent) == 32); + +struct GOUpgradeEvent +{ + uint32_t playerIndex; + const char* templateName; // UpgradeTemplate name + uint32_t producerObjectId; + float percentComplete; + float producerPositionX; + float producerPositionY; + float producerPositionZ; +}; +GO_ABI_ASSERT_LAYOUT(sizeof(GOUpgradeEvent) == 28); + +struct GOBuildingEvent +{ + uint32_t objectId; // the destroyed production structure's ObjectID + uint32_t playerIndex; + float positionX; // world position at the moment of destruction + float positionY; + float positionZ; +}; +GO_ABI_ASSERT_LAYOUT(sizeof(GOBuildingEvent) == 20); + +struct GOSpecialPowerEvent +{ + uint32_t playerIndex; + const char* powerTemplateName; + float locationX; + float locationY; + float locationZ; + float rechargeTimeSeconds; // static reload duration for this power template (0 if unknown) +}; +GO_ABI_ASSERT_LAYOUT(sizeof(GOSpecialPowerEvent) == 24); + +// Shared payload for onObjectDamaged and onObjectHealed; which callback fired implies the +// direction. See plans\plugin-framework\design-notes.md for where it is raised and why. +// All four-byte members come first so the three flags pack contiguously at the end. +struct GOCombatEvent +{ + uint32_t objectId; // the object whose health changed + uint32_t sourceObjectId; // attacker/healer ObjectID (0 if unavailable, e.g. environmental damage) + uint32_t playerIndex; // owner of objectId + int32_t amount; // always positive; onObjectDamaged vs onObjectHealed implies the sign + float positionX; // objectId's world position at the moment of this event + float positionY; + float positionZ; + uint8_t isBuilding; // KINDOF_STRUCTURE at the moment of this event + uint8_t isUnit; // KINDOF_INFANTRY || KINDOF_VEHICLE - deliberately not "!isBuilding" + uint8_t isFlame; // DAMAGE_FLAME, a continuous per-frame tick source (onObjectDamaged only) +}; +GO_ABI_ASSERT_LAYOUT(sizeof(GOCombatEvent) == 32); + +struct GOGameplayEventCallbacks +{ + void (*onUnitQueued)(const GOUnitEvent* ev); + void (*onUnitCancelled)(const GOUnitEvent* ev); + void (*onUnitCompleted)(const GOUnitEvent* ev); + void (*onUpgradeQueued)(const GOUpgradeEvent* ev); + void (*onUpgradeCancelled)(const GOUpgradeEvent* ev); + void (*onUpgradeCompleted)(const GOUpgradeEvent* ev); + // Production structures only - the hook lives in ProductionUpdate, so a destroyed power plant + // or defensive structure does not raise it. Enough to retire a building's queue entries. + void (*onBuildingDestroyed)(const GOBuildingEvent* ev); + void (*onSpecialPowerTriggered)(const GOSpecialPowerEvent* ev); + void (*onObjectDamaged)(const GOCombatEvent* ev); + void (*onObjectHealed)(const GOCombatEvent* ev); +}; + +// One entry in a player's general-power roster (see getPlayerGeneralPowers). templateName points +// into engine-owned static storage and is valid for the duration of the call. +struct GOGeneralPowerInfo +{ + const char* templateName; // SpecialPowerTemplate name, e.g. "AmericaSuperweaponParticleCannon" + uint32_t rechargeFrames; // full reload duration in logic frames (0 = recharges instantly) + uint32_t framesUntilReady; // logic frames until it can be triggered again (0 = ready now) + uint32_t buildingObjectId; // ObjectID of the building carrying this power's module (0 if none) +}; +GO_ABI_ASSERT_LAYOUT(sizeof(GOGeneralPowerInfo) == 16); + +struct GOContainedObjectInfo +{ + uint32_t objectId; + const char* templateName; + uint32_t playerIndex; +}; +GO_ABI_ASSERT_LAYOUT(sizeof(GOContainedObjectInfo) == 12); + +// Per-frame overlay draw plus raw input passthrough. onDrawOverlay runs once a frame from +// InGameUI's draw path, in 2D screen space with the HUD's ortho projection already active, so a +// plugin can issue the host's 2D primitives without touching 3D state. +struct GORenderCallbacks +{ + void (*onDrawOverlay)(); + // scanCode is a DirectInput scan code (DIK_*, e.g. DIK_F9 == 0x43), which is what the engine + // itself keys off - NOT a Windows virtual-key code. Comparing against VK_* silently never + // matches. modifierFlags: bit0 = CTRL, bit1 = SHIFT, bit2 = ALT. + void (*onRawKeyUp)(uint32_t scanCode, uint32_t modifierFlags); + + // Side-channel notification like onRawKeyUp: never gates or consumes the input. Fires more often + // than any other hook here, so keep handlers cheap. Screen-space pixels, as drawRect2D. + void (*onMouseMove)(int32_t x, int32_t y); + // Mouse-button passthrough for clickable plugin UI. Same side-channel + // discipline as onMouseMove. buttonIndex: 0 = left, 1 = middle, 2 = right. modifierFlags: bit0 = + // CTRL, bit1 = SHIFT, bit2 = ALT. The mouse-button cases exist so a plugin can act on clicks on + // its own drawn UI (e.g. jump the viewport to what was clicked). + void (*onMouseButtonDown)(uint8_t buttonIndex, int32_t x, int32_t y, uint32_t modifierFlags); + void (*onMouseButtonUp)(uint8_t buttonIndex, int32_t x, int32_t y, uint32_t modifierFlags); +}; + +// Production-building purposes a plugin might treat differently. Deliberately closed: the five +// there was a need to distinguish, not a general taxonomy. Returned as uint8_t, never as the enum. +enum EGOBuildingCategory +{ + GO_BUILDING_CATEGORY_NONE = 0, // not a building, or a building outside the categories below + GO_BUILDING_CATEGORY_COMMAND_CENTER = 1, + GO_BUILDING_CATEGORY_WAR_FACTORY = 2, + GO_BUILDING_CATEGORY_BARRACKS = 3, + GO_BUILDING_CATEGORY_AIRFIELD = 4, + GO_BUILDING_CATEGORY_SUPPLY_STASH = 5, +}; + +// Handed to the plugin at GO_Plugin_Initialize. Registration functions are independent and +// optional, and may each be called more than once. LIFETIME: the pointer has process lifetime, so a +// plugin may retain it - but copying the struct by value costs nothing and does not rely on that. +struct GOPluginHostAPI +{ + uint32_t abiVersion; + + // sizeof(GOPluginHostAPI) as the host compiled it, at a fixed offset so a plugin can read it + // before touching anything further down the table. A plugin must refuse to initialize when this + // is smaller than its own sizeof - see plans\plugin-framework\design-notes.md. + uint32_t structSize; + + void (*log)(const char* msg); + + void (*registerGameplayEventHooks)(const GOGameplayEventCallbacks* cb); + void (*registerRenderHooks)(const GORenderCallbacks* cb); + + // --- Player roster queries. Pure queries, no side effects. playerIndex matches the + // GOUnitEvent/GOUpgradeEvent/GOBuildingEvent/GOSpecialPowerEvent field of the same name. Return + // 0 / empty string / false if playerIndex doesn't currently resolve to an active player (no + // match currently loaded, index out of range, observer slot, etc). --- + uint32_t (*getPlayerColor)(uint32_t playerIndex); // colorARGB, same packing as drawText2D/drawRect2D + + // Active, non-observer participants; returns how many were written, never more than maxCount. + // Excludes the neutral/civilian player, and the values are the same playerIndex the event + // structs carry. Returns 0 when no match is loaded. + uint32_t (*getActivePlayers)(uint32_t* outPlayerIndices, uint32_t maxCount); + + // TRUE if the local client is spectating rather than playing (dead counts as spectating; replay + // playback is always true). A plugin showing other players' information MUST gate on this: not + // doing so exposes it to a live participant, which is a cheat vector, not a display bug. The host + // also enforces it - while false, no callbacks are delivered and GO_Plugin_Tick is not called. + uint8_t (*isLocalPlayerObserver)(); + + // General powers the player owns: required science plus a live object carrying the module, so a + // superweapon only counts once its building exists. The event hooks fire only on use, so this is + // the only way to show owned-but-unused powers. Cooldowns come from the module's own clock and + // stay correct across pause and fast-forward. Returns the number written, at most maxCount. + uint32_t (*getPlayerGeneralPowers)(uint32_t playerIndex, GOGeneralPowerInfo* outPowers, uint32_t maxCount); + + // --- Icon drawing. Looks up the named template server-side (where the engine's ThingTemplate/ + // UpgradeTemplate/CommandButton data safely lives) and draws its button art; the plugin never + // needs the Image/ThingTemplate/CommandButton types themselves. Does nothing if + // the name doesn't resolve. --- + + // Draws a unit or player-upgrade's button icon. Tries a ThingTemplate lookup by templateName + // first, then an UpgradeTemplate lookup if that fails - matches GOUnitEvent::templateName or + // GOUpgradeEvent::templateName respectively, so you don't need to track which kind it was. + void (*drawTemplateIcon2D)(const char* templateName, int32_t x, int32_t y, int32_t width, int32_t height); + + // Draws a special power's button icon for the given player (a power's icon is defined on the + // CommandButton that exposes it, which is per-faction - hence needing playerIndex, unlike + // drawTemplateIcon2D). Matches GOSpecialPowerEvent::powerTemplateName. + void (*drawPowerIcon2D)(uint32_t playerIndex, const char* powerTemplateName, int32_t x, int32_t y, int32_t width, int32_t height); + + // Minimal 2D draw primitives for render-hook plugins, screen-space pixels, top-left origin. + // Valid only from onDrawOverlay. Text is 7-bit ASCII: the host widens it byte by byte, so any + // multi-byte sequence renders as one garbage character per byte. + void (*drawText2D)(int32_t x, int32_t y, const char* asciiText, uint32_t colorARGB); + void (*drawRect2D)(int32_t x, int32_t y, int32_t width, int32_t height, uint32_t colorARGB, uint8_t filled); + + // Like drawText2D, but sizeScale multiplies the host's own message font point size (1.0 matches + // drawText2D exactly, values outside the host's supported range are clamped) and bold is + // explicit. See plans\plugin-framework\design-notes.md for why this is a separate function. + void (*drawText2DScaled)(int32_t x, int32_t y, const char* asciiText, uint32_t colorARGB, float sizeScale, uint8_t bold); + + // Current render target size in pixels, same coordinate space as drawText2D/drawRect2D/ + // drawTemplateIcon2D/drawPowerIcon2D/onMouseMove. Lets a render-hook plugin anchor drawn UI to + // a screen edge (bottom/right) instead of only a fixed top-left-relative offset. Safe to call + // from onDrawOverlay every frame. + void (*getScreenSize)(int32_t* outWidth, int32_t* outHeight); + + // Simulation clock, for ageing anything recorded from the events - wall-clock time drifts against + // a paused, delayed or fast-forwarded simulation. Return 0 when no game is running. + uint32_t (*getLogicFrame)(); + uint32_t (*getLogicFramesPerSecond)(); + + // Live production progress, read from the engine's ProductionEntry on demand. The event structs + // carry percentComplete only as of the moment they fire - essentially always 0 for a queued + // event - and nothing reports progress changing. Return 0..100, or -1 if the entry is gone. + float (*getUnitProductionProgress)(uint32_t producerObjectId, int32_t productionID); + float (*getUpgradeProductionProgress)(uint32_t producerObjectId, const char* upgradeTemplateName); + + // --- World-space anchoring. GOUnitEvent/GOUpgradeEvent/GOBuildingEvent carry world + // positions, but there was no way to turn one into a screen position, so world-anchored UI was + // unreachable from a plugin. worldToScreen returns 0 (and leaves the outputs untouched) when + // the point is outside the view frustum. --- + uint8_t (*worldToScreen)(float worldX, float worldY, float worldZ, int32_t* outX, int32_t* outY); + + // Screen-space bounding box of a live object, derived from its world position and bounding + // radius - for sizing/placing UI relative to the thing it belongs to, rather than guessing a + // scale from the screen resolution. Returns 0 if the object no longer exists or is off screen. + uint8_t (*getObjectScreenBounds)(uint32_t objectId, int32_t* outX, int32_t* outY, int32_t* outWidth, int32_t* outHeight); + + // Same point the engine projects its own health bar onto (Object::getHealthBoxPosition: + // top-of-model + 10 world units + the object's healthBoxOffset), projected via worldToScreen. + // Returns 0 if the object no longer exists or the point is off screen. + uint8_t (*getObjectHealthBarScreenPosition)(uint32_t objectId, int32_t* outX, int32_t* outY); + + // Moves the observer's viewport to a world position, by the same camera path the engine's own + // observer look-at actions use - for jumping to where a gameplay event happened. + void (*teleportViewportTo)(float worldX, float worldY, float worldZ); + + // The engine's own radial "pie" fill, as the command bar uses for build progress and recharge. + // percent is 0..100; drawRectClock2D fills the elapsed wedge, the other the remaining one. + void (*drawRectClock2D)(int32_t x, int32_t y, int32_t width, int32_t height, int32_t percent, uint32_t colorARGB); + void (*drawRemainingRectClock2D)(int32_t x, int32_t y, int32_t width, int32_t height, int32_t percent, uint32_t colorARGB); + + // D3D8 handles for a plugin with its own UI backend, opaque so no engine types cross the + // boundary. The device is the engine's own live IDirect3DDevice8, not a copy: a plugin MUST save + // and restore every state it touches. The engine assumes its state survives and cannot recover + // from a dirty device. + void* (*getD3DDevice8)(); + void* (*getGameWindow)(); + uint32_t (*enumeratePlayerObjects)(uint32_t playerIndex, + void (*callback)(uint32_t objectId, float posX, float posY, float posZ, void* userData), + void* userData); + uint32_t (*getContainedObjects)(uint32_t containerObjectId, + GOContainedObjectInfo* outObjects, uint32_t maxCount); + + // Target tracking: an object's current attack/move target position (current victim's position + // when attacking an object, otherwise the AI state machine's goal position). Returns 0 when the + // object is idle or has no target. isObjectAirborne reports Object::isAirborneTarget. + uint8_t (*getObjectTargetPosition)(uint32_t objectId, float* outTargetX, float* outTargetY, float* outTargetZ); + uint8_t (*isObjectAirborne)(uint32_t objectId); + + // 1 for a KINDOF_VEHICLE. Identifies an upgrade queued on an already-built vehicle, which + // getObjectBuildingCategory cannot - a vehicle always reports GO_BUILDING_CATEGORY_NONE. + uint8_t (*isObjectVehicle)(uint32_t objectId); + + // Like worldToScreen, but clamps an off-viewport point to just inside the screen edge instead of + // dropping it; returns 0 only when no projection is possible at all. For anything that should + // survive as an edge indicator when its world anchor pans off-screen. + uint8_t (*worldToScreenClamped)(float worldX, float worldY, float worldZ, int32_t* outX, int32_t* outY); + + // Which of the five categories in EGOBuildingCategory a producing building belongs to (or + // GO_BUILDING_CATEGORY_NONE if it isn't a building, or isn't one of the five). For per-category + // UI treatment - e.g. a different queue-panel offset for airfields vs war factories. + uint8_t (*getObjectBuildingCategory)(uint32_t objectId); + + // Player card queries. Pure, same playerIndex semantics as getPlayerColor, returning 0 / empty + // string / -1 when it does not resolve. Each string-returning function owns one buffer, so only + // its own most recent return value is live - copy it out immediately. Not thread-safe; the whole + // ABI runs on the engine's single thread. + + // Player display name. Returns empty string if the player slot is not active. + const char* (*getPlayerName)(uint32_t playerIndex); + + // Faction template name (e.g. "America", "China", "GLA", or sub-faction variants like + // "AmericaAirForce"). Returns empty string on failure. + const char* (*getPlayerFactionTemplate)(uint32_t playerIndex); + + // Current credits (money). Returns 0 if unavailable. + uint32_t (*getPlayerMoney)(uint32_t playerIndex); + + // General rank (0-based: 0 == the player's first rank) and skill-point progress toward the + // next rank. outCurrentXP is the player's cumulative skill points; outNextXP is the total + // skill points required for the next rank (0 at max rank - there is no "next"). Returns -1 if + // playerIndex doesn't resolve or the player has no rank yet (rank 0/uninitialized). + int32_t (*getPlayerRank)(uint32_t playerIndex, uint32_t* outCurrentXP, uint32_t* outNextXP); + + // Power state: outPowerGenerated / outPowerDrain, in the engine's own energy units. Returns 1 + // if power data is available, 0 otherwise (outputs left untouched). + uint8_t (*getPlayerPowerState)(uint32_t playerIndex, uint32_t* outPowerGenerated, uint32_t* outPowerDrain); + + // Count of the player's live construction units - USA/China Dozer, GLA Worker - identified by + // the shared DozerAIInterface (AIUpdateInterface::getDozerAIInterface() != nullptr) rather than + // a per-faction template/KindOf list, so it stays correct across all three factions uniformly. + // Returns 0 if playerIndex doesn't resolve. + uint32_t (*getPlayerBuilderCount)(uint32_t playerIndex); + + // Supply units actually ferrying right now, not merely idle or empty-moving - backed by + // SupplyTruckAIInterface::isCurrentlyFerryingSupplies(). + uint32_t (*getPlayerActiveGathererCount)(uint32_t playerIndex); + + // Cumulative gross money earned this match, not net of spending - the score screen's own figure. + // Deliberately the raw counter rather than a rate, so a plugin picks its own averaging window. + uint32_t (*getPlayerTotalMoneyEarned)(uint32_t playerIndex); + + // Display name for a template ("AmericaInfantryRifle" -> "Ranger"), via the same lookup the + // game's own tooltips use. Empty string if it does not resolve. + const char* (*getTemplateDisplayName)(const char* templateName); + + // A sample template name from the same qualifying checks as getPlayerBuilderCount / + // getPlayerActiveGathererCount, so a plugin can draw the player's actual builder icon rather + // than guess a per-faction template. Empty string if no qualifying unit exists. + const char* (*getPlayerBuilderTemplateName)(uint32_t playerIndex); + + // Per-type builder breakdown, which the summed count and the single sample name above cannot + // give - neither tells a native Dozer from a captured GLA Worker. Writes up to maxCount parallel + // entries and returns how many distinct templates were found. outNames is call-lifetime only. + uint32_t (*getPlayerBuilderTemplateCounts)(uint32_t playerIndex, const char** outNames, uint32_t* outCounts, uint32_t maxCount); + + // The gatherer counterpart of getPlayerBuilderTemplateName above, sampling one of the player's + // currently supply-ferrying units instead. Same return and storage discipline. + const char* (*getPlayerGathererTemplateName)(uint32_t playerIndex); + + // Lobby-configured alliance/team number (GameSlot::getTeamNumber - the same value the skirmish/ + // lobby "Team" dropdown sets, and what the game's own alliance/enemy checks are based on), NOT a + // display-position or player-count index. Returns -1 if playerIndex doesn't resolve or the + // player has no team assigned (free-for-all / no alliance). + int32_t (*getPlayerTeamNumber)(uint32_t playerIndex); + + // Every real participant including defeated and resigned ones: filters on isPlayerObserver() but + // not isPlayerActive(), so the roster does not shrink or reindex when someone dies. Use this + // rather than getActivePlayers for roster tracking. + uint32_t (*getMatchPlayers)(uint32_t* outPlayerIndices, uint32_t maxCount); + + // 1 if the player is no longer playing (dead or resigned - !Player::isPlayerActive()), 0 if + // they're still an active match participant or playerIndex doesn't resolve. Covers both causes + // of "no longer playing" with one boolean rather than requiring a plugin to distinguish them. + uint8_t (*getPlayerIsDefeated)(uint32_t playerIndex); + + // The engine's per-user data directory, trailing backslash included - where options.ini, Replays + // and Maps live, and so where a plugin's user-owned files belong. The plugin's own folder is + // under Program Files and is not writable. Process lifetime, never null; empty means unresolved. + const char* (*getUserDataPath)(); + + // One straight line as a single rotated quad (Render2DClass::Add_Line), same coordinate space and + // colour packing as drawRect2D. drawRect2D is axis-aligned, so faking a diagonal through it costs + // a quad every few pixels; this costs one whatever the length. + void (*drawLine2D)(int32_t x1, int32_t y1, int32_t x2, int32_t y2, float thickness, uint32_t colorARGB); + + // 1 for a KINDOF_PROJECTILE. enumeratePlayerObjects walks everything a player owns, and a missile + // or bomb in flight owns a target like any attacker does, so target-tracking overlays draw a line + // for every shot unless they filter these out. + uint8_t (*isObjectProjectile)(uint32_t objectId); + + // The id of the object this one is inside (Object::getContainedBy), or 0 when it is not contained. + // enumeratePlayerObjects returns garrisoned occupants alongside everything else, and each of them + // carries its own target from the same world position, so a full building emits one identical line + // per occupant. Collapse on this id to draw the container once. + uint32_t (*getObjectContainerId)(uint32_t objectId); +}; + +struct GOPluginInfo +{ + uint32_t abiVersion; + const char* name; + const char* version; + uint32_t hookCategories; // bitmask of EGOPluginHookCategory, informational only +}; +GO_ABI_ASSERT_LAYOUT(sizeof(GOPluginInfo) == 16); + +// ------------------------------------------------------------------------------------------------ +// Fixed export names every plugin DLL must implement, resolved via GetProcAddress so the DLL +// interface is plain C - no link-time coupling between host and plugin. +// ------------------------------------------------------------------------------------------------ +typedef void (*GOPluginGetInfoFunc)(GOPluginInfo* outInfo); +typedef bool (*GOPluginInitializeFunc)(const GOPluginHostAPI* hostAPI); +typedef void (*GOPluginShutdownFunc)(); +typedef void (*GOPluginTickFunc)(); // optional; may be null + +#define GO_PLUGIN_EXPORT_GETINFO_NAME "GO_Plugin_GetInfo" +#define GO_PLUGIN_EXPORT_INITIALIZE_NAME "GO_Plugin_Initialize" +#define GO_PLUGIN_EXPORT_SHUTDOWN_NAME "GO_Plugin_Shutdown" +#define GO_PLUGIN_EXPORT_TICK_NAME "GO_Plugin_Tick" diff --git a/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/Plugins/PluginManager.h b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/Plugins/PluginManager.h new file mode 100644 index 00000000000..dc463bed4a3 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/GameNetwork/GeneralsOnline/Plugins/PluginManager.h @@ -0,0 +1,90 @@ +#pragma once + +// Host-side loader and dispatcher for the plugin ABI (see PluginABI.h). Engine call sites call the +// Dispatch* functions below and this fans each one out to every plugin registered for that +// category, so the call sites stay plugin-agnostic. + +#include "GameNetwork/GeneralsOnline/Plugins/PluginABI.h" +#include +#include +#include + +class GOPluginManager +{ +public: + // Loads every *.goplugin.dll found (non-recursive) in directoryPath. Safe to call once at + // startup; failures to load an individual plugin are logged and skipped, not fatal. + static void LoadPluginsFromDirectory(const char* directoryPath); + + static bool LoadPlugin(const char* dllPath); + static void UnloadAll(); + + // Calls GO_Plugin_Tick on every plugin that exported it. Call once per frame from + // GameEngine::update(), unconditionally (not gated behind GameLogic pause state). + static void Tick(); + + // TRUE when the local client is spectating rather than playing - observer, dead, or replay + // playback. Every Dispatch* and Tick() is gated on it, so a plugin never receives gameplay data + // during a live match: that would be an information advantage for a participant. + static bool IsLocalPlayerObserver(); + + // ---- IGameplayEventHooks dispatch (called from ProductionUpdate/SpecialPowerModule/etc) ---- + static bool HasGameplayEventHooks() { return !s_gameplayEventHooks.empty(); } + static void DispatchUnitQueued(const GOUnitEvent& ev); + static void DispatchUnitCancelled(const GOUnitEvent& ev); + static void DispatchUnitCompleted(const GOUnitEvent& ev); + static void DispatchUpgradeQueued(const GOUpgradeEvent& ev); + static void DispatchUpgradeCancelled(const GOUpgradeEvent& ev); + static void DispatchUpgradeCompleted(const GOUpgradeEvent& ev); + static void DispatchBuildingDestroyed(const GOBuildingEvent& ev); + static void DispatchSpecialPowerTriggered(const GOSpecialPowerEvent& ev); + static void DispatchObjectDamaged(const GOCombatEvent& ev); + static void DispatchObjectHealed(const GOCombatEvent& ev); + + // ---- IRenderHooks dispatch (called from InGameUI / CommandXlat / WindowXlat) ---- + static bool HasRenderHooks() { return !s_renderHooks.empty(); } + static void DispatchDrawOverlay(); + static void DispatchRawKeyUp(uint32_t scanCode, uint32_t modifierFlags); + static void DispatchMouseMove(int32_t x, int32_t y); + static void DispatchMouseButtonDown(uint8_t buttonIndex, int32_t x, int32_t y, uint32_t modifierFlags); + static void DispatchMouseButtonUp(uint8_t buttonIndex, int32_t x, int32_t y, uint32_t modifierFlags); + + // Registration callbacks, invoked from GOPluginHostAPI function pointers during plugin + // Initialize. Public because the free functions backing the host API function-pointer table + // (in PluginManager.cpp) call them; not intended to be called directly by engine code. + static void RegisterGameplayEventHooks(const GOGameplayEventCallbacks* cb); + static void RegisterRenderHooks(const GORenderCallbacks* cb); + static void Log(const char* msg); + + // Native-handle seam: this class is device-independent, so GameEngineDevice supplies the D3D + // device and window handle at display init - see plans\plugin-framework\design-notes.md. + // Both stay null in a headless or non-W3D build, as do the matching host API entries. + typedef void* (*NativeHandleProvider)(); + static void SetNativeHandleProviders(NativeHandleProvider d3dDevice8, NativeHandleProvider gameWindow); + static void* GetD3DDevice8(); + static void* GetGameWindow(); + +private: + struct LoadedPlugin + { + HMODULE module; + std::string path; + GOPluginInfo info; + std::string name; // owns GOPluginInfo::name's lifetime on the host side + std::string version; + GOPluginShutdownFunc shutdown; + GOPluginTickFunc tick; + }; + + static std::vector s_plugins; + static std::vector s_gameplayEventHooks; + static std::vector s_renderHooks; + + static NativeHandleProvider s_d3dDevice8Provider; + static NativeHandleProvider s_gameWindowProvider; + + static GOPluginHostAPI BuildHostAPI(); + + // The one table every plugin is handed. Process lifetime - see the definition. + static const GOPluginHostAPI& GetHostAPI(); +}; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp index d39ea9bee76..89bbd785ec0 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GameEngine.cpp @@ -114,6 +114,7 @@ #include "../OnlineServices_Init.h" #include "GameNetwork/GeneralsOnline/DiscordRichPresence.h" #include "GameNetwork/GeneralsOnline/OnlineServices_LobbyInterface.h" +#include "GameNetwork/GeneralsOnline/Plugins/PluginManager.h" #include "GameNetwork/GameSpyOverlay.h" #include #include "WW3D2/ww3d.h" @@ -310,6 +311,8 @@ GameEngine::GameEngine() //------------------------------------------------------------------------------------------------- GameEngine::~GameEngine() { + GOPluginManager::UnloadAll(); + delete m_discordRichPresence; m_discordRichPresence = nullptr; @@ -834,6 +837,9 @@ void GameEngine::init() HideControlBar(); + // One folder per plugin under the plugins directory; a failed load is logged, never fatal. + GOPluginManager::LoadPluginsFromDirectory("plugins"); + m_discordRichPresence = new GeneralsOnlineDiscordRPC(); m_discordRichPresence->Initialize(); } @@ -991,6 +997,10 @@ void GameEngine::update() TheAudio->UPDATE(); TheGameClient->UPDATE(); + + // Deliberately outside the GameLogic pause gate, so plugin polling survives a pause. + GOPluginManager::Tick(); + TheMessageStream->propagateMessages(); if (TheNetwork != nullptr) diff --git a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp index 899f0254005..65f802d7f44 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameClient/InGameUI.cpp @@ -90,6 +90,7 @@ #include "GameNetwork/GameInfo.h" #include "GameNetwork/NetworkInterface.h" +#include "GameNetwork/GeneralsOnline/Plugins/PluginManager.h" #include "Common/UnitTimings.h" //Contains the DO_UNIT_TIMINGS define jba. @@ -3880,6 +3881,69 @@ void InGameUI::postWindowDraw() if (m_observerNotificationPointSize > 0) drawObserverNotifications(hudOffsetX, hudOffsetY); + + // Last, so plugins share the screen-space 2D context the draw* calls above set up. + if (GOPluginManager::HasRenderHooks()) + GOPluginManager::DispatchDrawOverlay(); +} + +//------------------------------------------------------------------------------------------------- +/** Backs GOPluginHostAPI::drawText2D, in the same font as InGameUI's own messages. */ +//------------------------------------------------------------------------------------------------- +void InGameUI::drawPluginText2D(Int x, Int y, const char* asciiText, Color color) +{ + if (asciiText == nullptr || asciiText[0] == '\0' || TheDisplayStringManager == nullptr || TheFontLibrary == nullptr) + return; + + DisplayString* displayString = TheDisplayStringManager->newDisplayString(); + if (displayString == nullptr) + return; + + displayString->setFont(TheFontLibrary->getFont(m_messageFont, + TheGlobalLanguageData ? TheGlobalLanguageData->adjustFontSize(m_messagePointSize) : m_messagePointSize, m_messageBold)); + + UnicodeString text; + text.translate(AsciiString(asciiText)); + displayString->setText(text); + + displayString->draw(x, y, color, GameMakeColor(0, 0, 0, 255)); + + TheDisplayStringManager->freeDisplayString(displayString); +} + +//------------------------------------------------------------------------------------------------- +/** Backs GOPluginHostAPI::drawText2DScaled: as above, at a scaled point size. */ +//------------------------------------------------------------------------------------------------- +void InGameUI::drawPluginText2DScaled(Int x, Int y, const char* asciiText, Color color, Real sizeScale, Bool bold) +{ + if (asciiText == nullptr || asciiText[0] == '\0' || TheDisplayStringManager == nullptr || TheFontLibrary == nullptr) + return; + + // FontLibrary::getFont returns null outside this range, so clamp before asking. + const Int minPointSize = 1; + const Int maxPointSize = 100; + const Int baseSize = TheGlobalLanguageData ? TheGlobalLanguageData->adjustFontSize(m_messagePointSize) : m_messagePointSize; + Int effectiveSize = (Int)((Real)baseSize * sizeScale + 0.5f); + if (effectiveSize < minPointSize) effectiveSize = minPointSize; + if (effectiveSize > maxPointSize) effectiveSize = maxPointSize; + + GameFont* font = TheFontLibrary->getFont(m_messageFont, effectiveSize, bold); + if (font == nullptr) + return; + + DisplayString* displayString = TheDisplayStringManager->newDisplayString(); + if (displayString == nullptr) + return; + + displayString->setFont(font); + + UnicodeString text; + text.translate(AsciiString(asciiText)); + displayString->setText(text); + + displayString->draw(x, y, color, GameMakeColor(0, 0, 0, 255)); + + TheDisplayStringManager->freeDisplayString(displayString); } //------------------------------------------------------------------------------------------------- diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Body/ActiveBody.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Body/ActiveBody.cpp index 21ce7bd81ae..122b09883d7 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Body/ActiveBody.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Body/ActiveBody.cpp @@ -59,6 +59,7 @@ #include "GameLogic/Module/ContainModule.h" #include "GameLogic/Module/DamageModule.h" #include "GameLogic/Module/DieModule.h" +#include "GameNetwork/GeneralsOnline/Plugins/PluginManager.h" @@ -619,6 +620,28 @@ void ActiveBody::attemptDamage( DamageInfo *damageInfo ) d->onDamage( damageInfo ); } + + // !alreadyHandled matters: the kill-pilot, kill-garrisoned, status and subdual paths above + // skip internalChangeHealth, so m_prevHealth/m_currentHealth still hold the previous call's + // values and m_actualDamageClipped is that call's number. Without this the plugin gets a + // second, phantom event repeating damage it was already told about. + if (!alreadyHandled && GOPluginManager::HasGameplayEventHooks()) + { + GOCombatEvent ev = {}; + ev.objectId = (uint32_t)obj->getID(); + ev.sourceObjectId = (damageInfo->in.m_sourceID != INVALID_ID) ? (uint32_t)damageInfo->in.m_sourceID : 0; + Player* owner = obj->getControllingPlayer(); + ev.playerIndex = (owner != nullptr) ? (uint32_t)owner->getPlayerIndex() : 0; + ev.amount = (int32_t)(damageInfo->out.m_actualDamageClipped + 0.5f); + ev.isBuilding = obj->isKindOf(KINDOF_STRUCTURE) ? 1 : 0; + ev.isUnit = (obj->isKindOf(KINDOF_INFANTRY) || obj->isKindOf(KINDOF_VEHICLE)) ? 1 : 0; + ev.isFlame = (damageInfo->in.m_damageType == DAMAGE_FLAME) ? 1 : 0; + const Coord3D* pos = obj->getPosition(); + ev.positionX = (pos != nullptr) ? pos->x : 0.0f; + ev.positionY = (pos != nullptr) ? pos->y : 0.0f; + ev.positionZ = (pos != nullptr) ? pos->z : 0.0f; + GOPluginManager::DispatchObjectDamaged(ev); + } } if (m_curDamageState != oldState) @@ -858,6 +881,24 @@ void ActiveBody::attemptHealing( DamageInfo *damageInfo ) d->onHealing( damageInfo ); } + + if (GOPluginManager::HasGameplayEventHooks()) + { + GOCombatEvent ev = {}; + ev.objectId = (uint32_t)obj->getID(); + ev.sourceObjectId = (damageInfo->in.m_sourceID != INVALID_ID) ? (uint32_t)damageInfo->in.m_sourceID : 0; + Player* owner = obj->getControllingPlayer(); + ev.playerIndex = (owner != nullptr) ? (uint32_t)owner->getPlayerIndex() : 0; + // m_actualDamageClipped is negative for healing; the ABI wants a positive amount. + ev.amount = (int32_t)(-damageInfo->out.m_actualDamageClipped + 0.5f); + ev.isBuilding = obj->isKindOf(KINDOF_STRUCTURE) ? 1 : 0; + ev.isUnit = (obj->isKindOf(KINDOF_INFANTRY) || obj->isKindOf(KINDOF_VEHICLE)) ? 1 : 0; + const Coord3D* pos = obj->getPosition(); + ev.positionX = (pos != nullptr) ? pos->x : 0.0f; + ev.positionY = (pos != nullptr) ? pos->y : 0.0f; + ev.positionZ = (pos != nullptr) ? pos->z : 0.0f; + GOPluginManager::DispatchObjectHealed(ev); + } } if (m_curDamageState != oldState) diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp index 34d3ba6a126..d4f049063f3 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/SpecialPower/SpecialPowerModule.cpp @@ -54,6 +54,8 @@ #include "GameClient/InGameUI.h" #include "GameClient/ControlBar.h" +#include "GameNetwork/GeneralsOnline/Plugins/PluginManager.h" + //------------------------------------------------------------------------------------------------- @@ -542,6 +544,20 @@ void SpecialPowerModule::markSpecialPowerTriggered( const Coord3D *location ) //------------------------------------------------------------------------------------------------- void SpecialPowerModule::aboutToDoSpecialPower( const Coord3D *location ) { + if (GOPluginManager::HasGameplayEventHooks()) + { + Player* player = getObject()->getControllingPlayer(); + GOSpecialPowerEvent ev = {}; + ev.playerIndex = (player != nullptr) ? (uint32_t)player->getPlayerIndex() : 0; + ev.powerTemplateName = getSpecialPowerModuleData()->m_specialPowerTemplate->getName().str(); + ev.locationX = (location != nullptr) ? location->x : getObject()->getPosition()->x; + ev.locationY = (location != nullptr) ? location->y : getObject()->getPosition()->y; + ev.locationZ = (location != nullptr) ? location->z : getObject()->getPosition()->z; + UnsignedInt reloadFrames = getSpecialPowerModuleData()->m_specialPowerTemplate->getReloadTime(); + ev.rechargeTimeSeconds = (reloadFrames > 0) ? ((float)reloadFrames / (float)LOGICFRAMES_PER_SECOND) : 0.0f; + GOPluginManager::DispatchSpecialPowerTriggered(ev); + } + // Tell the scripting engine! TheScriptEngine->notifyOfTriggeredSpecialPower( getObject()->getControllingPlayer()->getPlayerIndex(), diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp index e540ac948d9..27da696fc52 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/Object/Update/ProductionUpdate.cpp @@ -55,9 +55,41 @@ #include "GameLogic/Object.h" #include "GameLogic/ScriptEngine.h" +#include "GameNetwork/GeneralsOnline/Plugins/PluginManager.h" + // PUBLIC ///////////////////////////////////////////////////////////////////////////////////////// +// C-ABI event payloads for the GOPluginManager::Dispatch* calls below. +static GOUnitEvent buildUnitEvent(Player* player, const ThingTemplate* unitType, Object* producer, Real percentComplete, ProductionID productionID) +{ + GOUnitEvent ev = {}; + ev.playerIndex = (player != nullptr) ? (uint32_t)player->getPlayerIndex() : 0; + ev.templateName = (unitType != nullptr) ? unitType->getName().str() : ""; + ev.producerObjectId = (producer != nullptr) ? (uint32_t)producer->getID() : 0; + ev.percentComplete = (float)percentComplete; + ev.productionID = (int32_t)productionID; + const Coord3D* pos = (producer != nullptr) ? producer->getPosition() : nullptr; + ev.producerPositionX = (pos != nullptr) ? pos->x : 0.0f; + ev.producerPositionY = (pos != nullptr) ? pos->y : 0.0f; + ev.producerPositionZ = (pos != nullptr) ? pos->z : 0.0f; + return ev; +} + +static GOUpgradeEvent buildUpgradeEvent(Player* player, const UpgradeTemplate* upgrade, Object* producer, Real percentComplete) +{ + GOUpgradeEvent ev = {}; + ev.playerIndex = (player != nullptr) ? (uint32_t)player->getPlayerIndex() : 0; + ev.templateName = (upgrade != nullptr) ? upgrade->getUpgradeName().str() : ""; + ev.producerObjectId = (producer != nullptr) ? (uint32_t)producer->getID() : 0; + ev.percentComplete = (float)percentComplete; + const Coord3D* pos = (producer != nullptr) ? producer->getPosition() : nullptr; + ev.producerPositionX = (pos != nullptr) ? pos->x : 0.0f; + ev.producerPositionY = (pos != nullptr) ? pos->y : 0.0f; + ev.producerPositionZ = (pos != nullptr) ? pos->z : 0.0f; + return ev; +} + static const ModelConditionFlagType theOpeningFlags[DOOR_COUNT_MAX] = { MODELCONDITION_DOOR_1_OPENING, @@ -311,6 +343,12 @@ Bool ProductionUpdate::queueUpgrade( const UpgradeTemplate *upgrade ) // tie to the end of the production queue addToProductionQueue( production ); + if (GOPluginManager::HasGameplayEventHooks()) + { + GOUpgradeEvent ev = buildUpgradeEvent(player, upgrade, getObject(), production->getPercentComplete()); + GOPluginManager::DispatchUpgradeQueued(ev); + } + // add this upgrade as in progress in the player player->addUpgrade( upgrade, UPGRADE_STATUS_IN_PRODUCTION ); @@ -359,6 +397,12 @@ void ProductionUpdate::cancelUpgrade( const UpgradeTemplate *upgrade ) Money *money = player->getMoney(); money->deposit( production->m_upgradeToResearch->calcCostToBuild( player ), TRUE, FALSE ); + if (GOPluginManager::HasGameplayEventHooks()) + { + GOUpgradeEvent ev = buildUpgradeEvent(player, production->m_upgradeToResearch, getObject(), production->getPercentComplete()); + GOPluginManager::DispatchUpgradeCancelled(ev); + } + // remove this production from the queue removeFromProductionQueue( production ); @@ -451,6 +495,12 @@ Bool ProductionUpdate::queueCreateUnit( const ThingTemplate *unitType, Productio // tie to the end of the production queue addToProductionQueue( production ); + if (GOPluginManager::HasGameplayEventHooks()) + { + GOUnitEvent ev = buildUnitEvent(player, unitType, getObject(), production->getPercentComplete(), productionID); + GOPluginManager::DispatchUnitQueued(ev); + } + return TRUE; // unit queued } @@ -475,6 +525,12 @@ void ProductionUpdate::cancelUnitCreate( ProductionID productionID ) Money *money = player->getMoney(); money->deposit( production->m_objectToProduce->calcCostToBuild( player ), TRUE, FALSE ); + if (GOPluginManager::HasGameplayEventHooks()) + { + GOUnitEvent ev = buildUnitEvent(player, production->m_objectToProduce, getObject(), production->getPercentComplete(), production->getProductionID()); + GOPluginManager::DispatchUnitCancelled(ev); + } + // remove from queue list removeFromProductionQueue( production ); @@ -852,6 +908,12 @@ UpdateSleepTime ProductionUpdate::update() creationBuilding->getControllingPlayer()->getAcademyStats()->recordProduction( newObj, creationBuilding ); + if (GOPluginManager::HasGameplayEventHooks()) + { + GOUnitEvent ev = buildUnitEvent(creationBuilding->getControllingPlayer(), production->m_objectToProduce, creationBuilding, 100.0f, production->getProductionID()); + GOPluginManager::DispatchUnitCompleted(ev); + } + //We created one guy, but we may want to do more so we should stay in this node of production. // This is last so the voice check can easily check for "first" guy production->oneProductionSuccessful(); @@ -967,6 +1029,12 @@ UpdateSleepTime ProductionUpdate::update() } } + if (GOPluginManager::HasGameplayEventHooks()) + { + GOUpgradeEvent ev = buildUpgradeEvent(player, upgrade, us, 100.0f); + GOPluginManager::DispatchUpgradeCompleted(ev); + } + // remove this production entry so we can go on to the next removeFromProductionQueue( production ); @@ -1126,6 +1194,20 @@ UnsignedInt ProductionUpdate::countUnitTypeInQueue( const ThingTemplate *unitTyp // ------------------------------------------------------------------------------------------------ void ProductionUpdate::onDie( const DamageInfo *damageInfo ) { + if (GOPluginManager::HasGameplayEventHooks()) + { + Object* us = getObject(); + GOBuildingEvent ev = {}; + ev.objectId = (us != nullptr) ? (uint32_t)us->getID() : 0; + Player* player = (us != nullptr) ? us->getControllingPlayer() : nullptr; + ev.playerIndex = (player != nullptr) ? (uint32_t)player->getPlayerIndex() : 0; + const Coord3D* pos = (us != nullptr) ? us->getPosition() : nullptr; + ev.positionX = (pos != nullptr) ? pos->x : 0.0f; + ev.positionY = (pos != nullptr) ? pos->y : 0.0f; + ev.positionZ = (pos != nullptr) ? pos->z : 0.0f; + GOPluginManager::DispatchBuildingDestroyed(ev); + } + // we need to cancel all of our production on death cancelAndRefundAllProduction(); } diff --git a/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/Plugins/PluginManager.cpp b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/Plugins/PluginManager.cpp new file mode 100644 index 00000000000..1bed9f8cb63 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/GameNetwork/GeneralsOnline/Plugins/PluginManager.cpp @@ -0,0 +1,1557 @@ +#include "GameNetwork/GeneralsOnline/Plugins/PluginManager.h" +#include "GameNetwork/GeneralsOnline/NGMP_include.h" +#include "Common/GlobalData.h" +#include "Common/NameKeyGenerator.h" +#include "Common/Player.h" +#include "Common/PlayerList.h" +#include "Common/PlayerTemplate.h" +#include "Common/Science.h" +#include "Common/SpecialPower.h" +#include "Common/ThingTemplate.h" +#include "Common/ThingFactory.h" +#include "Common/Upgrade.h" +#include "Common/Geometry.h" +#include "GameClient/InGameUI.h" +#include "GameClient/ControlBar.h" +#include "GameClient/Display.h" +#include "GameClient/View.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Object.h" +#include "GameLogic/Module/AIUpdate.h" +#include "GameLogic/Module/SpecialPowerModule.h" +#include "GameLogic/Module/ProductionUpdate.h" +#include "GameLogic/Module/ContainModule.h" +#include "GameLogic/Module/SupplyTruckAIUpdate.h" +#include "GameNetwork/NetworkDefs.h" // MAX_SLOTS +#include "GameNetwork/GameInfo.h" // GameSlot::getTeamNumber + +#include +#include // INT_MAX +#include +#include + +std::vector GOPluginManager::s_plugins; +std::vector GOPluginManager::s_gameplayEventHooks; +std::vector GOPluginManager::s_renderHooks; +GOPluginManager::NativeHandleProvider GOPluginManager::s_d3dDevice8Provider = nullptr; +GOPluginManager::NativeHandleProvider GOPluginManager::s_gameWindowProvider = nullptr; + +// The free functions the host API table points at. All dispatch is gated on +// IsLocalPlayerObserver(), so plugins only ever run for observers. +namespace +{ + void HostAPI_Log(const char* msg) + { + GOPluginManager::Log(msg); + } + + void HostAPI_RegisterGameplayEventHooks(const GOGameplayEventCallbacks* cb) + { + GOPluginManager::RegisterGameplayEventHooks(cb); + } + + void HostAPI_RegisterRenderHooks(const GORenderCallbacks* cb) + { + GOPluginManager::RegisterRenderHooks(cb); + } + + // ---- Player roster queries. Matches playerIndex against Player::getPlayerIndex(), not list + // position - those aren't guaranteed to be the same thing. ---- + + Player* FindPlayerByIndex(uint32_t playerIndex) + { + if (ThePlayerList == nullptr) + return nullptr; + Int count = ThePlayerList->getPlayerCount(); + for (Int i = 0; i < count; ++i) + { + Player* p = ThePlayerList->getNthPlayer(i); + if (p != nullptr && (uint32_t)p->getPlayerIndex() == playerIndex) + return p; + } + return nullptr; + } + + uint32_t HostAPI_GetPlayerColor(uint32_t playerIndex) + { + Player* p = FindPlayerByIndex(playerIndex); + return (p != nullptr) ? (uint32_t)p->getPlayerColor() : 0; + } + + // Walks the engine's player-slot name keys; the callback returns false to stop early. Not free + // per call - see plans\plugin-framework\design-notes.md. + typedef bool (*SlotPlayerCallback)(Int slot, Player* player, void* userData); + + void ForEachSlotPlayer(SlotPlayerCallback callback, void* userData) + { + if (callback == nullptr || ThePlayerList == nullptr || TheNameKeyGenerator == nullptr) + return; + + for (Int slot = 0; slot < MAX_SLOTS; ++slot) + { + AsciiString nameKeyStr; + nameKeyStr.format("player%d", slot); + + Player* p = ThePlayerList->findPlayerWithNameKey(TheNameKeyGenerator->nameToKey(nameKeyStr)); + if (p == nullptr) + continue; + if (!callback(slot, p, userData)) + return; + } + } + + struct SlotRosterContext + { + uint32_t* outPlayerIndices; + uint32_t maxCount; + uint32_t count; + bool activeOnly; // false keeps defeated/resigned players in the roster + }; + + bool CollectSlotPlayer(Int slot, Player* player, void* userData) + { + SlotRosterContext* ctx = (SlotRosterContext*)userData; + if (player->isPlayerObserver()) + return true; + if (ctx->activeOnly && !player->isPlayerActive()) + return true; + + ctx->outPlayerIndices[ctx->count++] = (uint32_t)player->getPlayerIndex(); + return ctx->count < ctx->maxCount; + } + + uint32_t HostAPI_GetActivePlayers(uint32_t* outPlayerIndices, uint32_t maxCount) + { + if (outPlayerIndices == nullptr || maxCount == 0) + return 0; + + SlotRosterContext ctx = { outPlayerIndices, maxCount, 0, true }; + ForEachSlotPlayer(CollectSlotPlayer, &ctx); + return ctx.count; + } + + // Mirrors the same check the engine's own observer-only UI uses (e.g. + // InGameUI's observer stats/notifications gating) - dead also counts as observing, since a dead + // player becomes spectator-like. Plugins that display information about other players must gate + // on this. + uint8_t HostAPI_IsLocalPlayerObserver() + { + return GOPluginManager::IsLocalPlayerObserver() ? 1 : 0; + } + + // General-power roster. A power counts as owned when the player has the required science and at + // least one live object carries its module - the same test the engine's own observer UI uses. + + struct PowerModuleFind + { + const SpecialPowerTemplate* powerTemplate; + SpecialPowerModuleInterface* module; + Object* object; // the STRUCTURE carrying the module, for GOGeneralPowerInfo::buildingObjectId + }; + + // Prefers a structure as the anchor but accepts any carrier - see + // plans\plugin-framework\design-notes.md. + static void FindPowerModule(Object* obj, void* userData) + { + if (obj == nullptr) + return; + PowerModuleFind* find = (PowerModuleFind*)userData; + // Step 1: a structure is the best possible anchor, so stop once one has been found. + if (find->object != nullptr) + return; + + SpecialPowerModuleInterface* module = obj->getSpecialPowerModule(find->powerTemplate); + if (module == nullptr) + return; + + // Step 2: a structure wins outright and also becomes the reported building. + if (obj->isKindOf(KINDOF_STRUCTURE)) + { + find->module = module; + find->object = obj; + return; + } + + // Step 3: otherwise keep the first carrier of any kind, with no building to anchor to. + if (find->module == nullptr) + find->module = module; + } + + uint32_t HostAPI_GetPlayerGeneralPowers(uint32_t playerIndex, GOGeneralPowerInfo* outPowers, uint32_t maxCount) + { + if (outPowers == nullptr || maxCount == 0) + return 0; + if (ThePlayerList == nullptr || TheControlBar == nullptr) + return 0; + + Player* player = FindPlayerByIndex(playerIndex); + if (player == nullptr) + return 0; + + const PlayerTemplate* pt = player->getPlayerTemplate(); + if (pt == nullptr) + return 0; + AsciiString cmdSetName = pt->getSpecialPowerShortcutCommandSet(); + if (cmdSetName.isEmpty()) + return 0; + + const CommandSet* cmdSet = TheControlBar->findCommandSet(cmdSetName); + if (cmdSet == nullptr) + return 0; + + uint32_t count = 0; + for (Int i = 0; i < MAX_COMMANDS_PER_SET && count < maxCount; ++i) + { + const CommandButton* btn = cmdSet->getCommandButton(i); + if (btn == nullptr) + continue; + const SpecialPowerTemplate* sp = btn->getSpecialPowerTemplate(); + if (sp == nullptr) + continue; + + ScienceType required = sp->getRequiredScience(); + if (required != SCIENCE_INVALID && !player->hasScience(required)) + continue; + + PowerModuleFind find; + find.powerTemplate = sp; + find.module = nullptr; + find.object = nullptr; + player->iterateObjects(FindPowerModule, &find); + if (find.module == nullptr) + continue; + + GOGeneralPowerInfo& info = outPowers[count]; + info.templateName = sp->getName().str(); + info.rechargeFrames = sp->getReloadTime(); + info.buildingObjectId = (find.object != nullptr) ? (uint32_t)find.object->getID() : 0; + const UnsignedInt readyFrame = find.module->getReadyFrame(); + const UnsignedInt now = (TheGameLogic != nullptr) ? (UnsignedInt)TheGameLogic->getFrame() : 0; + info.framesUntilReady = (readyFrame > now) ? (readyFrame - now) : 0; + ++count; + } + return count; + } + + // ---- Icon drawing. Only valid to call from within a GORenderCallbacks::onDrawOverlay + // callback - same 2D-context requirement as drawText2D/drawRect2D. ---- + + void HostAPI_DrawTemplateIcon2D(const char* templateName, int32_t x, int32_t y, int32_t width, int32_t height) + { + if (templateName == nullptr || TheDisplay == nullptr) + return; + + const Image* img = nullptr; + if (TheThingFactory != nullptr) + { + const ThingTemplate* tmpl = TheThingFactory->findTemplate(AsciiString(templateName), FALSE); + if (tmpl != nullptr) + img = tmpl->getButtonImage(); + } + if (img == nullptr && TheUpgradeCenter != nullptr) + { + const UpgradeTemplate* upgrade = TheUpgradeCenter->findUpgrade(templateName); + if (upgrade != nullptr) + img = upgrade->getButtonImage(); + } + if (img != nullptr) + TheDisplay->drawImage(img, x, y, x + width, y + height); + } + + void HostAPI_DrawPowerIcon2D(uint32_t playerIndex, const char* powerTemplateName, int32_t x, int32_t y, int32_t width, int32_t height) + { + if (powerTemplateName == nullptr || TheDisplay == nullptr || TheControlBar == nullptr) + return; + + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return; + + const PlayerTemplate* pt = p->getPlayerTemplate(); + if (pt == nullptr) + return; + + AsciiString cmdSetName = pt->getSpecialPowerShortcutCommandSet(); + if (cmdSetName.isEmpty()) + return; + + const CommandSet* cmdSet = TheControlBar->findCommandSet(cmdSetName); + if (cmdSet == nullptr) + return; + + for (Int i = 0; i < MAX_COMMANDS_PER_SET; ++i) + { + const CommandButton* btn = cmdSet->getCommandButton(i); + if (btn == nullptr || btn->getSpecialPowerTemplate() == nullptr) + continue; + if (strcmp(btn->getSpecialPowerTemplate()->getName().str(), powerTemplateName) != 0) + continue; + + const Image* img = btn->getButtonImage(); + if (img != nullptr) + TheDisplay->drawImage(img, x, y, x + width, y + height); + return; + } + } + + void HostAPI_DrawText2D(int32_t x, int32_t y, const char* asciiText, uint32_t colorARGB) + { + if (TheInGameUI != nullptr) + TheInGameUI->drawPluginText2D((Int)x, (Int)y, asciiText, (Color)colorARGB); + } + + void HostAPI_DrawText2DScaled(int32_t x, int32_t y, const char* asciiText, uint32_t colorARGB, float sizeScale, uint8_t bold) + { + if (TheInGameUI != nullptr) + TheInGameUI->drawPluginText2DScaled((Int)x, (Int)y, asciiText, (Color)colorARGB, (Real)sizeScale, bold != 0); + } + + void HostAPI_DrawRect2D(int32_t x, int32_t y, int32_t width, int32_t height, uint32_t colorARGB, uint8_t filled) + { + if (TheDisplay == nullptr) + return; + + if (filled != 0) + TheDisplay->drawFillRect((Int)x, (Int)y, (Int)width, (Int)height, (Color)colorARGB); + else + TheDisplay->drawOpenRect((Int)x, (Int)y, (Int)width, (Int)height, 1.0f, (Color)colorARGB); + } + + // One rotated quad however long the line is, unlike DrawRect2D which is axis-aligned. + void HostAPI_DrawLine2D(int32_t x1, int32_t y1, int32_t x2, int32_t y2, float thickness, uint32_t colorARGB) + { + if (TheDisplay == nullptr) + return; + + TheDisplay->drawLine((Int)x1, (Int)y1, (Int)x2, (Int)y2, (Real)thickness, (Color)colorARGB); + } + + void HostAPI_GetScreenSize(int32_t* outWidth, int32_t* outHeight) + { + UnsignedInt w = (TheDisplay != nullptr) ? TheDisplay->getWidth() : 0; + UnsignedInt h = (TheDisplay != nullptr) ? TheDisplay->getHeight() : 0; + if (outWidth != nullptr) + *outWidth = (int32_t)w; + if (outHeight != nullptr) + *outHeight = (int32_t)h; + } + + // ---- Simulation clock. ---- + + uint32_t HostAPI_GetLogicFrame() + { + return (TheGameLogic != nullptr) ? (uint32_t)TheGameLogic->getFrame() : 0; + } + + uint32_t HostAPI_GetLogicFramesPerSecond() + { + return (uint32_t)LOGICFRAMES_PER_SECOND; + } + + // ---- Live production progress. Reads the producer's own queue, so the value is whatever the + // simulation currently believes rather than a plugin-side reconstruction. ---- + + ProductionUpdateInterface* FindProducer(uint32_t producerObjectId) + { + if (TheGameLogic == nullptr || producerObjectId == 0) + return nullptr; + Object* producer = TheGameLogic->findObjectByID((ObjectID)producerObjectId); + return (producer != nullptr) ? producer->getProductionUpdateInterface() : nullptr; + } + + float HostAPI_GetUnitProductionProgress(uint32_t producerObjectId, int32_t productionID) + { + ProductionUpdateInterface* production = FindProducer(producerObjectId); + if (production == nullptr) + return -1.0f; + + for (const ProductionEntry* entry = production->firstProduction(); entry != nullptr; entry = production->nextProduction(entry)) + { + if (entry->getProductionType() == PRODUCTION_UNIT && (int32_t)entry->getProductionID() == productionID) + return (float)entry->getPercentComplete(); + } + return -1.0f; + } + + float HostAPI_GetUpgradeProductionProgress(uint32_t producerObjectId, const char* upgradeTemplateName) + { + if (upgradeTemplateName == nullptr) + return -1.0f; + + ProductionUpdateInterface* production = FindProducer(producerObjectId); + if (production == nullptr) + return -1.0f; + + for (const ProductionEntry* entry = production->firstProduction(); entry != nullptr; entry = production->nextProduction(entry)) + { + if (entry->getProductionType() != PRODUCTION_UPGRADE) + continue; + const UpgradeTemplate* upgrade = entry->getProductionUpgrade(); + if (upgrade != nullptr && strcmp(upgrade->getUpgradeName().str(), upgradeTemplateName) == 0) + return (float)entry->getPercentComplete(); + } + return -1.0f; + } + + // ---- World-space anchoring. ---- + + uint8_t HostAPI_WorldToScreen(float worldX, float worldY, float worldZ, int32_t* outX, int32_t* outY) + { + if (TheTacticalView == nullptr) + return 0; + + Coord3D world; + world.x = worldX; + world.y = worldY; + world.z = worldZ; + + ICoord2D screen; + if (!TheTacticalView->worldToScreen(&world, &screen)) + return 0; + + if (outX != nullptr) + *outX = (int32_t)screen.x; + if (outY != nullptr) + *outY = (int32_t)screen.y; + return 1; + } + + // Edge inset for clamped off-screen indicators, as a fraction of view height so it scales with + // resolution (0.0333 is the original 24 pixels at 720p). + const float kEdgeIndicatorMarginFraction = 0.0333f; + + // Like HostAPI_WorldToScreen, but clamps an off-screen point to the view edge instead of + // dropping it - see plans\plugin-framework\design-notes.md. + uint8_t HostAPI_WorldToScreenClamped(float worldX, float worldY, float worldZ, int32_t* outX, int32_t* outY) + { + if (TheTacticalView == nullptr) + return 0; + + Coord3D world; + world.x = worldX; + world.y = worldY; + world.z = worldZ; + + // Step 1: project, accepting a point beyond the far clip plane as well as an off-frustum one. + ICoord2D screen; + const View::WorldToScreenReturn result = TheTacticalView->worldToScreenTriReturnAllowFarClip(&world, &screen); + if (result == View::WTS_INVALID) + return 0; + + if (result == View::WTS_OUTSIDE_FRUSTUM) + { + // Step 2: clamp against the tactical view's own rectangle, which is not always the + // whole display - the projected position is already in full-display coordinates. + Int originX = 0; + Int originY = 0; + TheTacticalView->getOrigin(&originX, &originY); + const float viewW = (float)TheTacticalView->getWidth(); + const float viewH = (float)TheTacticalView->getHeight(); + const float margin = viewH * kEdgeIndicatorMarginFraction; + if (viewW > margin * 2.0f && viewH > margin * 2.0f) + { + // Step 3: clamp along the ray from the view's centre to the raw (possibly far + // off-screen) projected point, so the indicator sits on the nearest edge. + const float cx = (float)originX + viewW * 0.5f; + const float cy = (float)originY + viewH * 0.5f; + float dx = (float)screen.x - cx; + float dy = (float)screen.y - cy; + const float halfW = viewW * 0.5f - margin; + const float halfH = viewH * 0.5f - margin; + const float absDx = (dx < 0.0f) ? -dx : dx; + const float absDy = (dy < 0.0f) ? -dy : dy; + float scale = 1.0f; + if (absDx > halfW && absDx > 0.0f) + scale = halfW / absDx; + if (absDy > halfH && absDy > 0.0f) + { + const float scaleY = halfH / absDy; + if (scaleY < scale) + scale = scaleY; + } + if (scale < 1.0f) + { + dx *= scale; + dy *= scale; + } + screen.x = (Int)(cx + dx); + screen.y = (Int)(cy + dy); + } + } + + if (outX != nullptr) + *outX = (int32_t)screen.x; + if (outY != nullptr) + *outY = (int32_t)screen.y; + return 1; + } + + // Classifies by the same KindOf flags the engine's own faction-agnostic code uses, so no + // per-faction cases are needed - see plans\plugin-framework\design-notes.md. + uint8_t HostAPI_GetObjectBuildingCategory(uint32_t objectId) + { + if (TheGameLogic == nullptr || objectId == 0) + return GO_BUILDING_CATEGORY_NONE; + + Object* obj = TheGameLogic->findObjectByID((ObjectID)objectId); + if (obj == nullptr || !obj->isKindOf(KINDOF_STRUCTURE)) + return GO_BUILDING_CATEGORY_NONE; + + if (obj->isKindOf(KINDOF_COMMANDCENTER)) + return GO_BUILDING_CATEGORY_COMMAND_CENTER; + if (obj->isKindOf(KINDOF_FS_WARFACTORY)) + return GO_BUILDING_CATEGORY_WAR_FACTORY; + if (obj->isKindOf(KINDOF_FS_BARRACKS)) + return GO_BUILDING_CATEGORY_BARRACKS; + if (obj->isKindOf(KINDOF_FS_AIRFIELD)) + return GO_BUILDING_CATEGORY_AIRFIELD; + if (obj->isKindOf(KINDOF_FS_SUPPLY_CENTER)) + return GO_BUILDING_CATEGORY_SUPPLY_STASH; + return GO_BUILDING_CATEGORY_NONE; + } + + uint8_t HostAPI_GetObjectScreenBounds(uint32_t objectId, int32_t* outX, int32_t* outY, int32_t* outWidth, int32_t* outHeight) + { + if (TheGameLogic == nullptr || TheTacticalView == nullptr || objectId == 0) + return 0; + + Object* obj = TheGameLogic->findObjectByID((ObjectID)objectId); + if (obj == nullptr) + return 0; + + const Coord3D* pos = obj->getPosition(); + if (pos == nullptr) + return 0; + + ICoord2D centre; + if (!TheTacticalView->worldToScreen(pos, ¢re)) + return 0; + + // Project a point one bounding radius to the side of the object as well; the horizontal + // distance between the two projections is the object's on-screen size, which already + // accounts for camera zoom and pitch without exposing any camera state to the plugin. + const Real radius = obj->getGeometryInfo().getBoundingCircleRadius(); + Coord3D edgeWorld = *pos; + edgeWorld.x += radius; + + ICoord2D edge; + Int halfWidth = 0; + if (TheTacticalView->worldToScreen(&edgeWorld, &edge)) + halfWidth = abs(edge.x - centre.x); + if (halfWidth <= 0) + halfWidth = 1; + + if (outX != nullptr) + *outX = (int32_t)(centre.x - halfWidth); + if (outY != nullptr) + *outY = (int32_t)(centre.y - halfWidth); + if (outWidth != nullptr) + *outWidth = (int32_t)(halfWidth * 2); + if (outHeight != nullptr) + *outHeight = (int32_t)(halfWidth * 2); + return 1; + } + + // Projects the same point the engine draws its own health bar at - see plans\plugin-framework\design-notes.md. + uint8_t HostAPI_GetObjectHealthBarScreenPosition(uint32_t objectId, int32_t* outX, int32_t* outY) + { + if (TheGameLogic == nullptr || TheTacticalView == nullptr || objectId == 0) + return 0; + + Object* obj = TheGameLogic->findObjectByID((ObjectID)objectId); + if (obj == nullptr) + return 0; + + Coord3D pos; + obj->getHealthBoxPosition(pos); + + ICoord2D screen; + if (!TheTacticalView->worldToScreen(&pos, &screen)) + return 0; + + if (outX != nullptr) + *outX = (int32_t)screen.x; + if (outY != nullptr) + *outY = (int32_t)screen.y; + return 1; + } + + // Camera control. Same look-at path the engine's own observer actions + // use, so a plugin can jump the viewport to where a gameplay event happened when the user + // clicks on it. + + void HostAPI_TeleportViewportTo(float worldX, float worldY, float worldZ) + { + if (TheTacticalView == nullptr) + return; + Coord3D target; + target.x = worldX; + target.y = worldY; + target.z = worldZ; + TheTacticalView->userLookAt(&target); + } + + // ---- Clock-wedge draw primitives. Same 2D-context requirement as drawText2D/drawRect2D. ---- + + void HostAPI_DrawRectClock2D(int32_t x, int32_t y, int32_t width, int32_t height, int32_t percent, uint32_t colorARGB) + { + if (TheDisplay != nullptr) + TheDisplay->drawRectClock((Int)x, (Int)y, (Int)width, (Int)height, (Int)percent, (UnsignedInt)colorARGB); + } + + void HostAPI_DrawRemainingRectClock2D(int32_t x, int32_t y, int32_t width, int32_t height, int32_t percent, uint32_t colorARGB) + { + if (TheDisplay != nullptr) + TheDisplay->drawRemainingRectClock((Int)x, (Int)y, (Int)width, (Int)height, (Int)percent, (UnsignedInt)colorARGB); + } + + // Both handles come from the device layer through GOPluginManager's provider seam. + + void* HostAPI_GetD3DDevice8() + { + return GOPluginManager::GetD3DDevice8(); + } + + void* HostAPI_GetGameWindow() + { + return GOPluginManager::GetGameWindow(); + } + + struct EnumerateObjectsContext + { + void (*callback)(uint32_t, float, float, float, void*); + void* userData; + uint32_t count; + }; + + void EnumerateObjectsCallback(Object* object, void* userData) + { + EnumerateObjectsContext* context = (EnumerateObjectsContext*)userData; + if (object == nullptr || context->callback == nullptr) + return; + const Coord3D* position = object->getPosition(); + if (position == nullptr) + return; + context->callback((uint32_t)object->getID(), position->x, position->y, position->z, context->userData); + ++context->count; + } + + uint32_t HostAPI_EnumeratePlayerObjects(uint32_t playerIndex, + void (*callback)(uint32_t, float, float, float, void*), void* userData) + { + Player* player = FindPlayerByIndex(playerIndex); + if (player == nullptr || callback == nullptr) + return 0; + EnumerateObjectsContext context = { callback, userData, 0 }; + player->iterateObjects(EnumerateObjectsCallback, &context); + return context.count; + } + + uint32_t HostAPI_GetContainedObjects(uint32_t containerObjectId, + GOContainedObjectInfo* outObjects, uint32_t maxCount) + { + if (TheGameLogic == nullptr || containerObjectId == 0 || outObjects == nullptr || maxCount == 0) + return 0; + Object* container = TheGameLogic->findObjectByID((ObjectID)containerObjectId); + if (container == nullptr || container->getContain() == nullptr) + return 0; + const ContainedItemsList* items = container->getContain()->getContainedItemsList(); + if (items == nullptr) + return 0; + uint32_t count = 0; + for (ContainedItemsList::const_iterator it = items->begin(); it != items->end() && count < maxCount; ++it) + { + Object* object = *it; + if (object == nullptr || object->getTemplate() == nullptr) + continue; + GOContainedObjectInfo& info = outObjects[count++]; + info.objectId = (uint32_t)object->getID(); + info.templateName = object->getTemplate()->getName().str(); + info.playerIndex = object->getControllingPlayer() != nullptr + ? (uint32_t)object->getControllingPlayer()->getPlayerIndex() : 0; + } + return count; + } + + uint8_t HostAPI_GetObjectTargetPosition(uint32_t objectId, float* outX, float* outY, float* outZ) + { + if (TheGameLogic == nullptr || objectId == 0) + return 0; + + Object* obj = TheGameLogic->findObjectByID((ObjectID)objectId); + if (obj == nullptr) + return 0; + + AIUpdateInterface* ai = obj->getAIUpdateInterface(); + if (ai == nullptr) + return 0; + + // Attacking a specific object: point at that object's current position. + Object* victim = ai->getCurrentVictim(); + if (victim != nullptr) + { + const Coord3D* pos = victim->getPosition(); + if (pos != nullptr) + { + if (outX != nullptr) *outX = pos->x; + if (outY != nullptr) *outY = pos->y; + if (outZ != nullptr) *outZ = pos->z; + return 1; + } + } + + // A goal position only counts for an explicit attack order, never a move-to or guard - see + // plans\plugin-framework\design-notes.md. + const StateID state = ai->getCurrentStateID(); + if (state != AI_ATTACK_MOVE_TO && state != AI_ATTACK_POSITION) + return 0; + + const Coord3D* goal = ai->getGoalPosition(); + if (goal == nullptr) + return 0; + + if (outX != nullptr) *outX = goal->x; + if (outY != nullptr) *outY = goal->y; + if (outZ != nullptr) *outZ = goal->z; + return 1; + } + + uint8_t HostAPI_IsObjectAirborne(uint32_t objectId) + { + if (TheGameLogic == nullptr || objectId == 0) + return 0; + Object* obj = TheGameLogic->findObjectByID((ObjectID)objectId); + return (obj != nullptr && obj->isAirborneTarget()) ? 1 : 0; + } + + uint8_t HostAPI_IsObjectVehicle(uint32_t objectId) + { + if (TheGameLogic == nullptr || objectId == 0) + return 0; + Object* obj = TheGameLogic->findObjectByID((ObjectID)objectId); + return (obj != nullptr && obj->isKindOf(KINDOF_VEHICLE)) ? 1 : 0; + } + + uint8_t HostAPI_IsObjectProjectile(uint32_t objectId) + { + if (TheGameLogic == nullptr || objectId == 0) + return 0; + Object* obj = TheGameLogic->findObjectByID((ObjectID)objectId); + return (obj != nullptr && obj->isKindOf(KINDOF_PROJECTILE)) ? 1 : 0; + } + + uint32_t HostAPI_GetObjectContainerId(uint32_t objectId) + { + if (TheGameLogic == nullptr || objectId == 0) + return 0; + Object* obj = TheGameLogic->findObjectByID((ObjectID)objectId); + if (obj == nullptr) + return 0; + const Object* container = obj->getContainedBy(); + return (container != nullptr) ? (uint32_t)container->getID() : 0; + } + + // ---- Player card queries (Plan 5). Same FindPlayerByIndex resolution as every other + // per-player query above. ---- + + // Each of the string-returning queries below owns one distinct function-local buffer, so only + // its own most recent return value is live - see plans\plugin-framework\design-notes.md. + const char* HostAPI_GetPlayerName(uint32_t playerIndex) + { + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return ""; + // getPlayerDisplayName() is a UnicodeString; the ABI only carries UTF-8/ASCII. + static AsciiString s_playerName; + s_playerName.translate(p->getPlayerDisplayName()); + return s_playerName.str(); + } + + const char* HostAPI_GetPlayerFactionTemplate(uint32_t playerIndex) + { + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return ""; + // getSide() is the template's "Side" INI field ("AmericaLaserGeneral"), not + // getPlayerTemplate()->getName(), which carries an unreadable "Faction" prefix. + return p->getSide().str(); + } + + uint32_t HostAPI_GetPlayerMoney(uint32_t playerIndex) + { + Player* p = FindPlayerByIndex(playerIndex); + return (p != nullptr) ? (uint32_t)p->getMoney()->countMoney() : 0; + } + + // Both skill-point figures are absolute totals, so they are already a progress bar's numerator + // and denominator - see plans\plugin-framework\design-notes.md. + int32_t HostAPI_GetPlayerRank(uint32_t playerIndex, uint32_t* outCurrentXP, uint32_t* outNextXP) + { + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return -1; + const Int rankLevel = p->getRankLevel(); + if (rankLevel <= 0) + return -1; + if (outCurrentXP != nullptr) + *outCurrentXP = (uint32_t)p->getSkillPoints(); + if (outNextXP != nullptr) + { + // At the rank cap there is no next rank, and the threshold is INT_MAX - report 0 + // rather than a meaningless huge denominator. + const Int levelUp = p->getSkillPointsLevelUp(); + *outNextXP = (levelUp >= INT_MAX) ? 0 : (uint32_t)levelUp; + } + return (int32_t)(rankLevel - 1); + } + + // getEnergy(), like getMoney() and getScoreKeeper(), returns the address of a Player member and + // is never null once the Player itself resolved, so none of the three is null-checked. + uint8_t HostAPI_GetPlayerPowerState(uint32_t playerIndex, uint32_t* outPowerGenerated, uint32_t* outPowerDrain) + { + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return 0; + const Energy* energy = p->getEnergy(); + if (outPowerGenerated != nullptr) + *outPowerGenerated = (uint32_t)energy->getProduction(); + if (outPowerDrain != nullptr) + *outPowerDrain = (uint32_t)energy->getConsumption(); + return 1; + } + + struct CountContext { uint32_t count; }; + + // A builder is anything carrying a DozerAIInterface, which covers all three factions - see + // plans\plugin-framework\design-notes.md. + void CountBuilderCallback(Object* obj, void* userData) + { + if (obj == nullptr) + return; + AIUpdateInterface* ai = obj->getAIUpdateInterface(); + if (ai != nullptr && ai->getDozerAIInterface() != nullptr) + ((CountContext*)userData)->count++; + } + + uint32_t HostAPI_GetPlayerBuilderCount(uint32_t playerIndex) + { + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return 0; + CountContext ctx = { 0 }; + p->iterateObjects(CountBuilderCallback, &ctx); + return ctx.count; + } + + // Counts units actually working the supply line right now, not every supply unit - see + // plans\plugin-framework\design-notes.md. + void CountActiveGathererCallback(Object* obj, void* userData) + { + if (obj == nullptr) + return; + AIUpdateInterface* ai = obj->getAIUpdateInterface(); + if (ai == nullptr) + return; + SupplyTruckAIInterface* supplyAI = ai->getSupplyTruckAIInterface(); + if (supplyAI != nullptr && supplyAI->isCurrentlyFerryingSupplies()) + ((CountContext*)userData)->count++; + } + + uint32_t HostAPI_GetPlayerActiveGathererCount(uint32_t playerIndex) + { + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return 0; + CountContext ctx = { 0 }; + p->iterateObjects(CountActiveGathererCallback, &ctx); + return ctx.count; + } + + // The score screen's cumulative figure, deliberately raw rather than a rate - see plans\plugin-framework\design-notes.md. + uint32_t HostAPI_GetPlayerTotalMoneyEarned(uint32_t playerIndex) + { + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return 0; + return (uint32_t)p->getScoreKeeper()->getTotalMoneyEarned(); + } + + // Looks up by raw template name rather than a live object, so it also resolves a template a + // plugin only knows the name of (e.g. GOContainedObjectInfo::templateName from a garrison query). + const char* HostAPI_GetTemplateDisplayName(const char* templateName) + { + if (templateName == nullptr || templateName[0] == '\0' || TheThingFactory == nullptr) + return ""; + const ThingTemplate* tt = TheThingFactory->findTemplate(AsciiString(templateName)); + if (tt == nullptr) + return ""; + static AsciiString s_templateDisplayName; + s_templateDisplayName.translate(tt->getDisplayName()); + return s_templateDisplayName.str(); + } + + struct SampleTemplateContext { AsciiString templateName; bool found; }; + + // Same qualifying check as CountBuilderCallback, but stops recording once one match is found - + // this only needs a representative icon, not an exact count. + void SampleBuilderTemplateCallback(Object* obj, void* userData) + { + SampleTemplateContext* ctx = (SampleTemplateContext*)userData; + if (ctx->found || obj == nullptr) + return; + AIUpdateInterface* ai = obj->getAIUpdateInterface(); + if (ai != nullptr && ai->getDozerAIInterface() != nullptr && obj->getTemplate() != nullptr) + { + ctx->templateName = obj->getTemplate()->getName(); + ctx->found = true; + } + } + + // Same qualifying check as CountActiveGathererCallback (isCurrentlyFerryingSupplies, not merely + // "is a supply unit"), so the sampled icon matches what getPlayerActiveGathererCount is counting. + void SampleGathererTemplateCallback(Object* obj, void* userData) + { + SampleTemplateContext* ctx = (SampleTemplateContext*)userData; + if (ctx->found || obj == nullptr) + return; + AIUpdateInterface* ai = obj->getAIUpdateInterface(); + if (ai == nullptr) + return; + SupplyTruckAIInterface* supplyAI = ai->getSupplyTruckAIInterface(); + if (supplyAI != nullptr && supplyAI->isCurrentlyFerryingSupplies() && obj->getTemplate() != nullptr) + { + ctx->templateName = obj->getTemplate()->getName(); + ctx->found = true; + } + } + + const char* HostAPI_GetPlayerBuilderTemplateName(uint32_t playerIndex) + { + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return ""; + SampleTemplateContext ctx = {}; + p->iterateObjects(SampleBuilderTemplateCallback, &ctx); + static AsciiString s_builderTemplateName; + s_builderTemplateName = ctx.templateName; + return s_builderTemplateName.str(); + } + + // The base game has three distinct DozerAIInterface-carrying templates (USA Dozer, China Dozer, + // GLA Worker); the extra headroom is for mods that add their own - see + // plans\plugin-framework\design-notes.md. + const uint32_t kMaxBuilderTypeBuckets = 8; + + struct BuilderTypeBucket { AsciiString name; uint32_t count; }; + struct BuilderTypeCountContext + { + BuilderTypeBucket buckets[kMaxBuilderTypeBuckets]; + uint32_t bucketCount = 0; + }; + + // As CountBuilderCallback, but keyed by template so a captured Worker is not collapsed into a + // native Dozer - see plans\plugin-framework\design-notes.md. + void CountBuilderTypeCallback(Object* obj, void* userData) + { + if (obj == nullptr) + return; + AIUpdateInterface* ai = obj->getAIUpdateInterface(); + if (ai == nullptr || ai->getDozerAIInterface() == nullptr) + return; + const ThingTemplate* tt = obj->getTemplate(); + if (tt == nullptr) + return; + + BuilderTypeCountContext* ctx = (BuilderTypeCountContext*)userData; + const AsciiString& name = tt->getName(); + for (uint32_t i = 0; i < ctx->bucketCount; ++i) + { + if (ctx->buckets[i].name == name) + { + ctx->buckets[i].count++; + return; + } + } + if (ctx->bucketCount < kMaxBuilderTypeBuckets) + { + ctx->buckets[ctx->bucketCount].name = name; + ctx->buckets[ctx->bucketCount].count = 1; + ctx->bucketCount++; + } + } + + // Per-type breakdown of the player's live builder units - see PluginABI.h for the contract. + uint32_t HostAPI_GetPlayerBuilderTemplateCounts(uint32_t playerIndex, const char** outNames, uint32_t* outCounts, uint32_t maxCount) + { + if (outNames == nullptr || outCounts == nullptr || maxCount == 0) + return 0; + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return 0; + + BuilderTypeCountContext ctx; + p->iterateObjects(CountBuilderTypeCallback, &ctx); + + // This function's own backing storage for the returned pointers. + static AsciiString s_builderTemplateNames[kMaxBuilderTypeBuckets]; + const uint32_t n = (ctx.bucketCount < maxCount) ? ctx.bucketCount : maxCount; + for (uint32_t i = 0; i < n; ++i) + { + s_builderTemplateNames[i] = ctx.buckets[i].name; + outNames[i] = s_builderTemplateNames[i].str(); + outCounts[i] = ctx.buckets[i].count; + } + return n; + } + + const char* HostAPI_GetPlayerGathererTemplateName(uint32_t playerIndex) + { + Player* p = FindPlayerByIndex(playerIndex); + if (p == nullptr) + return ""; + SampleTemplateContext ctx = {}; + p->iterateObjects(SampleGathererTemplateCallback, &ctx); + static AsciiString s_gathererTemplateName; + s_gathererTemplateName = ctx.templateName; + return s_gathererTemplateName.str(); + } + + struct SlotLookupContext { uint32_t playerIndex; Int slot; }; + + bool MatchSlotByPlayerIndex(Int slot, Player* player, void* userData) + { + SlotLookupContext* ctx = (SlotLookupContext*)userData; + if ((uint32_t)player->getPlayerIndex() != ctx->playerIndex) + return true; + ctx->slot = slot; + return false; + } + + // playerIndex (Player::getPlayerIndex()) and the lobby slot index GameSlot is keyed by are two + // different numbering spaces, so the slot has to be found first and then looked up in TheGameInfo. + int32_t HostAPI_GetPlayerTeamNumber(uint32_t playerIndex) + { + if (TheGameInfo == nullptr) + return -1; + + SlotLookupContext ctx = { playerIndex, -1 }; + ForEachSlotPlayer(MatchSlotByPlayerIndex, &ctx); + if (ctx.slot < 0) + return -1; + + const GameSlot* gameSlot = TheGameInfo->getConstSlot(ctx.slot); + return (gameSlot != nullptr) ? gameSlot->getTeamNumber() : -1; + } + + // As HostAPI_GetActivePlayers but without the isPlayerActive() filter, so the roster is stable + // for the whole match - see plans\plugin-framework\design-notes.md. + uint32_t HostAPI_GetMatchPlayers(uint32_t* outPlayerIndices, uint32_t maxCount) + { + if (outPlayerIndices == nullptr || maxCount == 0) + return 0; + + SlotRosterContext ctx = { outPlayerIndices, maxCount, 0, false }; + ForEachSlotPlayer(CollectSlotPlayer, &ctx); + return ctx.count; + } + + uint8_t HostAPI_GetPlayerIsDefeated(uint32_t playerIndex) + { + Player* p = FindPlayerByIndex(playerIndex); + return (p != nullptr && !p->isPlayerActive()) ? 1 : 0; + } + + const char* HostAPI_GetUserDataPath() + { + // Cached in a function-local static because the ABI promises a pointer that stays valid for + // the process lifetime, while getPath_UserData() hands back a temporary. + static std::string s_userDataPath; + if (s_userDataPath.empty() && TheGlobalData != nullptr) + s_userDataPath = TheGlobalData->getPath_UserData().str(); + return s_userDataPath.c_str(); + } +} // anonymous namespace + +// ------------------------------------------------------------------------------------------------ + +void GOPluginManager::Log(const char* msg) +{ + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Plugin] %s", msg); +} + +// Called by GameEngineDevice once its display is up, before any plugin is loaded. +void GOPluginManager::SetNativeHandleProviders(NativeHandleProvider d3dDevice8, NativeHandleProvider gameWindow) +{ + s_d3dDevice8Provider = d3dDevice8; + s_gameWindowProvider = gameWindow; +} + +void* GOPluginManager::GetD3DDevice8() +{ + return (s_d3dDevice8Provider != nullptr) ? s_d3dDevice8Provider() : nullptr; +} + +void* GOPluginManager::GetGameWindow() +{ + return (s_gameWindowProvider != nullptr) ? s_gameWindowProvider() : nullptr; +} + +GOPluginHostAPI GOPluginManager::BuildHostAPI() +{ + GOPluginHostAPI api = {}; + api.abiVersion = GO_PLUGIN_ABI_VERSION; + api.structSize = (uint32_t)sizeof(GOPluginHostAPI); + api.log = HostAPI_Log; + api.registerGameplayEventHooks = HostAPI_RegisterGameplayEventHooks; + api.registerRenderHooks = HostAPI_RegisterRenderHooks; + api.getPlayerColor = HostAPI_GetPlayerColor; + api.getActivePlayers = HostAPI_GetActivePlayers; + api.isLocalPlayerObserver = HostAPI_IsLocalPlayerObserver; + api.getPlayerGeneralPowers = HostAPI_GetPlayerGeneralPowers; + api.drawTemplateIcon2D = HostAPI_DrawTemplateIcon2D; + api.drawPowerIcon2D = HostAPI_DrawPowerIcon2D; + api.drawText2D = HostAPI_DrawText2D; + api.drawText2DScaled = HostAPI_DrawText2DScaled; + api.drawRect2D = HostAPI_DrawRect2D; + api.getScreenSize = HostAPI_GetScreenSize; + api.getLogicFrame = HostAPI_GetLogicFrame; + api.getLogicFramesPerSecond = HostAPI_GetLogicFramesPerSecond; + api.getUnitProductionProgress = HostAPI_GetUnitProductionProgress; + api.getUpgradeProductionProgress = HostAPI_GetUpgradeProductionProgress; + api.worldToScreen = HostAPI_WorldToScreen; + api.worldToScreenClamped = HostAPI_WorldToScreenClamped; + api.getObjectBuildingCategory = HostAPI_GetObjectBuildingCategory; + api.getObjectScreenBounds = HostAPI_GetObjectScreenBounds; + api.getObjectHealthBarScreenPosition = HostAPI_GetObjectHealthBarScreenPosition; + api.teleportViewportTo = HostAPI_TeleportViewportTo; + api.drawRectClock2D = HostAPI_DrawRectClock2D; + api.drawRemainingRectClock2D = HostAPI_DrawRemainingRectClock2D; + api.getD3DDevice8 = HostAPI_GetD3DDevice8; + api.getGameWindow = HostAPI_GetGameWindow; + api.enumeratePlayerObjects = HostAPI_EnumeratePlayerObjects; + api.getContainedObjects = HostAPI_GetContainedObjects; + api.getObjectTargetPosition = HostAPI_GetObjectTargetPosition; + api.isObjectAirborne = HostAPI_IsObjectAirborne; + api.isObjectVehicle = HostAPI_IsObjectVehicle; + api.getPlayerName = HostAPI_GetPlayerName; + api.getPlayerFactionTemplate = HostAPI_GetPlayerFactionTemplate; + api.getPlayerMoney = HostAPI_GetPlayerMoney; + api.getPlayerRank = HostAPI_GetPlayerRank; + api.getPlayerPowerState = HostAPI_GetPlayerPowerState; + api.getPlayerBuilderCount = HostAPI_GetPlayerBuilderCount; + api.getPlayerActiveGathererCount = HostAPI_GetPlayerActiveGathererCount; + api.getPlayerTotalMoneyEarned = HostAPI_GetPlayerTotalMoneyEarned; + api.getTemplateDisplayName = HostAPI_GetTemplateDisplayName; + api.getPlayerBuilderTemplateName = HostAPI_GetPlayerBuilderTemplateName; + api.getPlayerBuilderTemplateCounts = HostAPI_GetPlayerBuilderTemplateCounts; + api.getPlayerGathererTemplateName = HostAPI_GetPlayerGathererTemplateName; + api.getPlayerTeamNumber = HostAPI_GetPlayerTeamNumber; + api.getMatchPlayers = HostAPI_GetMatchPlayers; + api.getPlayerIsDefeated = HostAPI_GetPlayerIsDefeated; + api.getUserDataPath = HostAPI_GetUserDataPath; + api.drawLine2D = HostAPI_DrawLine2D; + api.isObjectProjectile = HostAPI_IsObjectProjectile; + api.getObjectContainerId = HostAPI_GetObjectContainerId; + return api; +} + +// The one table handed to every plugin. A function-local static because plugins retain the pointer +// indefinitely, so it must not be a temporary; every entry is a free function, so one instance is +// correct for all of them. +const GOPluginHostAPI& GOPluginManager::GetHostAPI() +{ + static const GOPluginHostAPI s_hostAPI = BuildHostAPI(); + return s_hostAPI; +} + +void GOPluginManager::RegisterGameplayEventHooks(const GOGameplayEventCallbacks* cb) +{ + if (cb != nullptr) + s_gameplayEventHooks.push_back(*cb); +} + +void GOPluginManager::RegisterRenderHooks(const GORenderCallbacks* cb) +{ + if (cb != nullptr) + s_renderHooks.push_back(*cb); +} + +// Scans for a single flat "key": "value" string field rather than +// pulling the full json.hpp parser into this file for two optional log-line fields. Not a general +// JSON parser - no nested objects/arrays, only \" and \\ are unescaped - but the manifest below is +// purely informational, so anything this doesn't understand just yields an empty value. +static std::string ExtractJsonStringField(const std::string& json, const char* key) +{ + const std::string quotedKey = std::string("\"") + key + "\""; + size_t pos = json.find(quotedKey); + if (pos == std::string::npos) + return std::string(); + + pos = json.find(':', pos + quotedKey.size()); + if (pos == std::string::npos) + return std::string(); + ++pos; + + while (pos < json.size() && isspace((unsigned char)json[pos])) + ++pos; + if (pos >= json.size() || json[pos] != '"') + return std::string(); + ++pos; + + std::string value; + while (pos < json.size() && json[pos] != '"') + { + if (json[pos] == '\\' && pos + 1 < json.size()) + ++pos; + value += json[pos]; + ++pos; + } + return value; +} + +// Optional sidecar manifest (foo.goplugin.dll -> foo.goplugin.json) with author info for the load +// log. Purely informational - GOPluginInfo stays authoritative, so a missing or malformed manifest +// never refuses a plugin. Returns a log fragment, or "". +static std::string ReadManifestSummary(const char* dllPath) +{ + std::string path(dllPath); + const size_t dot = path.rfind(".dll"); + if (dot == std::string::npos) + return std::string(); + path.replace(dot, 4, ".json"); + + FILE* f = fopen(path.c_str(), "rb"); + if (f == nullptr) + return std::string(); + + std::string text; + char buffer[512]; + size_t got; + while ((got = fread(buffer, 1, sizeof(buffer), f)) > 0) + text.append(buffer, got); + fclose(f); + + const std::string author = ExtractJsonStringField(text, "plugin_author"); + const std::string website = ExtractJsonStringField(text, "website"); + if (author.empty() && website.empty()) + return std::string(); + + std::string summary = " [by " + (author.empty() ? std::string("unknown") : author); + if (!website.empty()) + summary += ", " + website; + return summary + "]"; +} + +bool GOPluginManager::LoadPlugin(const char* dllPath) +{ + if (dllPath == nullptr) + return false; + + HMODULE hModule = LoadLibraryA(dllPath); + if (hModule == nullptr) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Plugin] Failed to load %s (err=%u)", dllPath, GetLastError()); + return false; + } + + GOPluginGetInfoFunc fnGetInfo = (GOPluginGetInfoFunc)GetProcAddress(hModule, GO_PLUGIN_EXPORT_GETINFO_NAME); + GOPluginInitializeFunc fnInitialize = (GOPluginInitializeFunc)GetProcAddress(hModule, GO_PLUGIN_EXPORT_INITIALIZE_NAME); + GOPluginShutdownFunc fnShutdown = (GOPluginShutdownFunc)GetProcAddress(hModule, GO_PLUGIN_EXPORT_SHUTDOWN_NAME); + GOPluginTickFunc fnTick = (GOPluginTickFunc)GetProcAddress(hModule, GO_PLUGIN_EXPORT_TICK_NAME); // optional + + if (fnGetInfo == nullptr || fnInitialize == nullptr || fnShutdown == nullptr) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Plugin] %s is missing required exports (GetInfo/Initialize/Shutdown)", dllPath); + FreeLibrary(hModule); + return false; + } + + GOPluginInfo info = {}; + fnGetInfo(&info); + + if (info.abiVersion != GO_PLUGIN_ABI_VERSION) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Plugin] %s ABI version mismatch (plugin=%u, host=%u)", + dllPath, info.abiVersion, (uint32_t)GO_PLUGIN_ABI_VERSION); + FreeLibrary(hModule); + return false; + } + + LoadedPlugin loaded; + loaded.module = hModule; + loaded.path = dllPath; + loaded.name = (info.name != nullptr) ? info.name : "(unnamed)"; + loaded.version = (info.version != nullptr) ? info.version : "(unknown)"; + loaded.shutdown = fnShutdown; + loaded.tick = fnTick; + loaded.info = info; + + // A plugin may register hooks during Initialize() and then still return false. Snapshot both + // vector sizes and roll back on failure, or those callbacks keep pointing into the DLL we are + // about to FreeLibrary() and the next dispatch calls through freed memory. + const size_t gameplayHooksBefore = s_gameplayEventHooks.size(); + const size_t renderHooksBefore = s_renderHooks.size(); + if (!fnInitialize(&GetHostAPI())) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Plugin] %s Initialize() returned failure", dllPath); + s_gameplayEventHooks.resize(gameplayHooksBefore); + s_renderHooks.resize(renderHooksBefore); + FreeLibrary(hModule); + return false; + } + + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Plugin] Loaded %s v%s (%s) hooks=0x%X%s", + loaded.name.c_str(), loaded.version.c_str(), dllPath, info.hookCategories, + ReadManifestSummary(dllPath).c_str()); + + s_plugins.push_back(loaded); + return true; +} + +// Loads every *.goplugin.dll directly inside pluginDir. Returns how many loaded. +static int LoadPluginsInFolder(const std::string& pluginDir) +{ + const std::string searchPattern = pluginDir + "\\*.goplugin.dll"; + + WIN32_FIND_DATAA findData; + HANDLE hFind = FindFirstFileA(searchPattern.c_str(), &findData); + if (hFind == INVALID_HANDLE_VALUE) + return 0; + + int loaded = 0; + do + { + if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + continue; + + const std::string fullPath = pluginDir + "\\" + findData.cFileName; + if (GOPluginManager::LoadPlugin(fullPath.c_str())) + ++loaded; + } + while (FindNextFileA(hFind, &findData) != 0); + + FindClose(hFind); + return loaded; +} + +void GOPluginManager::LoadPluginsFromDirectory(const char* directoryPath) +{ + if (directoryPath == nullptr) + return; + + const std::string root(directoryPath); + + // One folder per plugin, holding its DLL plus an optional .json manifest, so each plugin's + // files (DLL, manifest, any of its own data) stay together and the plugins directory does not + // become a pile of loose DLLs. + int loaded = 0; + int folders = 0; + + WIN32_FIND_DATAA findData; + HANDLE hFind = FindFirstFileA((root + "\\*").c_str(), &findData); + if (hFind != INVALID_HANDLE_VALUE) + { + do + { + if ((findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) == 0) + continue; + if (strcmp(findData.cFileName, ".") == 0 || strcmp(findData.cFileName, "..") == 0) + continue; + + ++folders; + loaded += LoadPluginsInFolder(root + "\\" + findData.cFileName); + } + while (FindNextFileA(hFind, &findData) != 0); + + FindClose(hFind); + } + + // A DLL dropped loose in plugins\ is not picked up. Say so loudly rather than silently doing + // nothing: a plugin that never runs looks exactly like a plugin that runs and draws nothing, + // and telling those apart cost a full round of testing once already. + WIN32_FIND_DATAA strayData; + HANDLE hStray = FindFirstFileA((root + "\\*.goplugin.dll").c_str(), &strayData); + if (hStray != INVALID_HANDLE_VALUE) + { + do + { + if (strayData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) + continue; + + NetworkLog(ELogVerbosity::LOG_RELEASE, + "[Plugin] IGNORED %s\\%s - plugins live in their own folder. Move it to %s\\\\%s", + root.c_str(), strayData.cFileName, root.c_str(), strayData.cFileName); + } + while (FindNextFileA(hStray, &strayData) != 0); + + FindClose(hStray); + } + + if (loaded == 0) + { + NetworkLog(ELogVerbosity::LOG_RELEASE, "[Plugin] No plugins loaded (%s, %d folder(s) scanned)", + root.c_str(), folders); + } +} + +void GOPluginManager::UnloadAll() +{ + for (auto it = s_plugins.rbegin(); it != s_plugins.rend(); ++it) + { + if (it->shutdown != nullptr) + it->shutdown(); + if (it->module != nullptr) + FreeLibrary(it->module); + } + + s_plugins.clear(); + s_gameplayEventHooks.clear(); + s_renderHooks.clear(); +} + +// One gate for the whole framework: callbacks carry other players' queues, powers and buildings, so +// none of it may reach a plugin while the local client is a participant. Same condition the engine's +// own observer-only UI uses; replay playback passes, its local player being the observer. +bool GOPluginManager::IsLocalPlayerObserver() +{ + if (ThePlayerList == nullptr) + return false; + Player* localPlayer = ThePlayerList->getLocalPlayer(); + return (localPlayer != nullptr && (localPlayer->isPlayerObserver() || localPlayer->isPlayerDead())) ? true : false; +} + +void GOPluginManager::Tick() +{ + if (!IsLocalPlayerObserver()) + return; + + for (LoadedPlugin& plugin : s_plugins) + { + if (plugin.tick != nullptr) + plugin.tick(); + } +} + +// ---- IGameplayEventHooks dispatch. All no-ops unless IsLocalPlayerObserver() - a match +// participant never receives plugin callbacks. ---- +void GOPluginManager::DispatchUnitQueued(const GOUnitEvent& ev) +{ + if (!IsLocalPlayerObserver()) + return; + for (GOGameplayEventCallbacks& cb : s_gameplayEventHooks) + if (cb.onUnitQueued != nullptr) cb.onUnitQueued(&ev); +} + +void GOPluginManager::DispatchUnitCancelled(const GOUnitEvent& ev) +{ + if (!IsLocalPlayerObserver()) + return; + for (GOGameplayEventCallbacks& cb : s_gameplayEventHooks) + if (cb.onUnitCancelled != nullptr) cb.onUnitCancelled(&ev); +} + +void GOPluginManager::DispatchUnitCompleted(const GOUnitEvent& ev) +{ + if (!IsLocalPlayerObserver()) + return; + for (GOGameplayEventCallbacks& cb : s_gameplayEventHooks) + if (cb.onUnitCompleted != nullptr) cb.onUnitCompleted(&ev); +} + +void GOPluginManager::DispatchUpgradeQueued(const GOUpgradeEvent& ev) +{ + if (!IsLocalPlayerObserver()) + return; + for (GOGameplayEventCallbacks& cb : s_gameplayEventHooks) + if (cb.onUpgradeQueued != nullptr) cb.onUpgradeQueued(&ev); +} + +void GOPluginManager::DispatchUpgradeCancelled(const GOUpgradeEvent& ev) +{ + if (!IsLocalPlayerObserver()) + return; + for (GOGameplayEventCallbacks& cb : s_gameplayEventHooks) + if (cb.onUpgradeCancelled != nullptr) cb.onUpgradeCancelled(&ev); +} + +void GOPluginManager::DispatchUpgradeCompleted(const GOUpgradeEvent& ev) +{ + if (!IsLocalPlayerObserver()) + return; + for (GOGameplayEventCallbacks& cb : s_gameplayEventHooks) + if (cb.onUpgradeCompleted != nullptr) cb.onUpgradeCompleted(&ev); +} + +void GOPluginManager::DispatchBuildingDestroyed(const GOBuildingEvent& ev) +{ + if (!IsLocalPlayerObserver()) + return; + for (GOGameplayEventCallbacks& cb : s_gameplayEventHooks) + if (cb.onBuildingDestroyed != nullptr) cb.onBuildingDestroyed(&ev); +} + +void GOPluginManager::DispatchSpecialPowerTriggered(const GOSpecialPowerEvent& ev) +{ + if (!IsLocalPlayerObserver()) + return; + for (GOGameplayEventCallbacks& cb : s_gameplayEventHooks) + if (cb.onSpecialPowerTriggered != nullptr) cb.onSpecialPowerTriggered(&ev); +} + +void GOPluginManager::DispatchObjectDamaged(const GOCombatEvent& ev) +{ + if (!IsLocalPlayerObserver()) + return; + for (GOGameplayEventCallbacks& cb : s_gameplayEventHooks) + if (cb.onObjectDamaged != nullptr) cb.onObjectDamaged(&ev); +} + +void GOPluginManager::DispatchObjectHealed(const GOCombatEvent& ev) +{ + if (!IsLocalPlayerObserver()) + return; + for (GOGameplayEventCallbacks& cb : s_gameplayEventHooks) + if (cb.onObjectHealed != nullptr) cb.onObjectHealed(&ev); +} + +// ---- IRenderHooks dispatch. Same observer gate as the gameplay events. ---- + +void GOPluginManager::DispatchDrawOverlay() +{ + if (!IsLocalPlayerObserver()) + return; + for (GORenderCallbacks& cb : s_renderHooks) + if (cb.onDrawOverlay != nullptr) cb.onDrawOverlay(); +} + +void GOPluginManager::DispatchRawKeyUp(uint32_t scanCode, uint32_t modifierFlags) +{ + if (!IsLocalPlayerObserver()) + return; + for (GORenderCallbacks& cb : s_renderHooks) + if (cb.onRawKeyUp != nullptr) cb.onRawKeyUp(scanCode, modifierFlags); +} + +void GOPluginManager::DispatchMouseMove(int32_t x, int32_t y) +{ + if (!IsLocalPlayerObserver()) + return; + for (GORenderCallbacks& cb : s_renderHooks) + if (cb.onMouseMove != nullptr) cb.onMouseMove(x, y); +} + +void GOPluginManager::DispatchMouseButtonDown(uint8_t buttonIndex, int32_t x, int32_t y, uint32_t modifierFlags) +{ + if (!IsLocalPlayerObserver()) + return; + for (GORenderCallbacks& cb : s_renderHooks) + if (cb.onMouseButtonDown != nullptr) cb.onMouseButtonDown(buttonIndex, x, y, modifierFlags); +} + +void GOPluginManager::DispatchMouseButtonUp(uint8_t buttonIndex, int32_t x, int32_t y, uint32_t modifierFlags) +{ + if (!IsLocalPlayerObserver()) + return; + for (GORenderCallbacks& cb : s_renderHooks) + if (cb.onMouseButtonUp != nullptr) cb.onMouseButtonUp(buttonIndex, x, y, modifierFlags); +} diff --git a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp index 3fd458ff3b0..29e4604f802 100644 --- a/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp +++ b/GeneralsMD/Code/GameEngineDevice/Source/W3DDevice/GameClient/W3DDisplay.cpp @@ -111,9 +111,23 @@ static void drawFramerateBar(); #include "WinMain.h" +#include "WW3D2/dx8wrapper.h" +#include "GameNetwork/GeneralsOnline/Plugins/PluginManager.h" + // DEFINE AND ENUMS /////////////////////////////////////////////////////////// +// GameEngine must not include WW3D2 or WinMain.h, so the plugin framework is handed these instead. +static void* GetD3DDevice8ForPlugins() +{ + return (void*)DX8Wrapper::_Get_D3D_Device8(); +} + +static void* GetGameWindowForPlugins() +{ + return (void*)ApplicationHWnd; +} + #define no_SAMPLE_DYNAMIC_LIGHT 1 #ifdef SAMPLE_DYNAMIC_LIGHT static W3DDynamicLight * theDynamicLight = nullptr; @@ -636,6 +650,19 @@ void W3DDisplay::setup2DRenderState(TextureClass *tex, DrawImageMode mode, Bool { if (m_isBatching) { + // Render2DClass fills two dynamic buffers whose counts are unsigned short, so one batch may + // hold at most 65535 of either. Flush before the next primitive can cross that, which costs + // one extra draw call and loses nothing. Must precede the same-state early-out below, since + // an overflow is reached by exactly the run of same-state draws that early-out serves. + const int MAX_BATCH_ELEMENTS = 65535; + const int WORST_CASE_ELEMENTS_PER_DRAW = 64; // drawOpenRect is 4 lines: 16 verts, 24 indices + if (!m_batchNeedsInit && m_2DRender && + (m_2DRender->Get_Index_Count() > MAX_BATCH_ELEMENTS - WORST_CASE_ELEMENTS_PER_DRAW || + m_2DRender->Get_Vertex_Count() > MAX_BATCH_ELEMENTS - WORST_CASE_ELEMENTS_PER_DRAW)) + { + onFlush(); + } + if (!m_batchNeedsInit && m_batchTexture == tex && m_batchMode == mode && m_batchGrayscale == grayscale) { return; @@ -972,6 +999,8 @@ void W3DDisplay::init() DX8WebBrowser::Initialize(); } + GOPluginManager::SetNativeHandleProviders(GetD3DDevice8ForPlugins, GetGameWindowForPlugins); + // we're now online m_initialized = true; if( TheGlobalData->m_displayDebug ) diff --git a/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/render2d.h b/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/render2d.h index fd1b400b79d..e22f06c7d5d 100644 --- a/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/render2d.h +++ b/GeneralsMD/Code/Libraries/Source/WWVegas/WW3D2/render2d.h @@ -153,6 +153,11 @@ class Render2DClass // Color access DynamicVectorClass & Get_Color_Array () { return Colors; } + // Current batch size. Both dynamic buffers Render() fills are indexed by unsigned short, so a + // caller that batches across many primitives must flush before either count exceeds 65535. + int Get_Vertex_Count() const { return Vertices.Count(); } + int Get_Index_Count() const { return Indices.Count(); } + // statics to access the Screen Resolution in Pixels static void Set_Screen_Resolution( const RectClass & screen ); static const RectClass & Get_Screen_Resolution() { return ScreenResolution; }