diff --git a/BG3Extender/BG3Extender.vcxproj b/BG3Extender/BG3Extender.vcxproj
index 5c516dfa8..3c6e92f07 100644
--- a/BG3Extender/BG3Extender.vcxproj
+++ b/BG3Extender/BG3Extender.vcxproj
@@ -282,6 +282,7 @@ $(SolutionDir)x64\Release\ResourceBundler.exe "$(ProjectDir)LuaScripts" "$(Proje
+
@@ -398,6 +399,7 @@ $(SolutionDir)x64\Release\ResourceBundler.exe "$(ProjectDir)LuaScripts" "$(Proje
+
NotUsing
@@ -500,6 +502,7 @@ $(SolutionDir)x64\Release\ResourceBundler.exe "$(ProjectDir)LuaScripts" "$(Proje
+
diff --git a/BG3Extender/BG3Extender.vcxproj.filters b/BG3Extender/BG3Extender.vcxproj.filters
index c5aa785d8..817c391f7 100644
--- a/BG3Extender/BG3Extender.vcxproj.filters
+++ b/BG3Extender/BG3Extender.vcxproj.filters
@@ -1,6 +1,9 @@
+
+ Lua\Client
+
Lua\Shared
@@ -448,6 +451,7 @@
+
@@ -809,6 +813,7 @@
GameDefinitions\Base
+
GameDefinitions
diff --git a/BG3Extender/GameDefinitions/Components/Camera.h b/BG3Extender/GameDefinitions/Components/Camera.h
index 0955018a4..4741c1651 100644
--- a/BG3Extender/GameDefinitions/Components/Camera.h
+++ b/BG3Extender/GameDefinitions/Components/Camera.h
@@ -218,6 +218,11 @@ END_SE()
BEGIN_NS(ecl)
+struct CameraSystem : public BaseSystem
+{
+ DEFINE_SYSTEM(ClientCamera, "ecl::CameraSystem")
+};
+
struct GameCameraBehavior : public BaseComponent
{
DEFINE_COMPONENT(GameCameraBehavior, "ecl::GameCameraBehavior")
@@ -300,6 +305,43 @@ struct GameCameraBehavior : public BaseComponent
HashSet field_208;
std::optional field_238;
std::optional field_248;
+
+ //# P_GETTER(CameraDefinition, GetCameraDefinition)
+ CameraGlobalSwitches* GetCameraDefinition() const;
+};
+
+// Partial native argument layouts used by camera update hooks. Only the
+// accessed fields are identified; these are not complete engine structures.
+struct [[bg3::hidden]] CameraUpdateTime
+{
+ uint8_t field_0[0x0c];
+ float DeltaTime;
+};
+
+struct [[bg3::hidden]] CameraUpdateComponentView
+{
+ uint8_t field_0[0x30];
+ GameCameraBehavior* Camera;
+ uint8_t field_38[0x38];
+ EntityHandle Entity;
+};
+
+struct [[bg3::hidden]] CameraZoomUpdateComponentView
+{
+ uint8_t field_0[0x10];
+ GameCameraBehavior* Camera;
+};
+
+struct [[bg3::hidden]] CameraInputComponentView
+{
+ uint8_t field_0[0x40];
+ GameCameraBehavior* Camera;
+};
+
+struct [[bg3::hidden]] CameraDefaultZoomComponentView
+{
+ uint8_t field_0[0x20];
+ GameCameraBehavior* Camera;
};
END_NS()
diff --git a/BG3Extender/GameDefinitions/Components/Camera.inl b/BG3Extender/GameDefinitions/Components/Camera.inl
new file mode 100644
index 000000000..89b6dae15
--- /dev/null
+++ b/BG3Extender/GameDefinitions/Components/Camera.inl
@@ -0,0 +1,11 @@
+#include
+
+BEGIN_NS(ecl)
+
+CameraGlobalSwitches* GameCameraBehavior::GetCameraDefinition() const
+{
+ auto getter = GetStaticSymbols().ecl__GameCameraBehavior__GetCameraDefinition;
+ return getter ? getter(this) : nullptr;
+}
+
+END_NS()
diff --git a/BG3Extender/GameDefinitions/Components/ExposedSystemTypes.inl b/BG3Extender/GameDefinitions/Components/ExposedSystemTypes.inl
index 85c411d39..5a0a28e01 100644
--- a/BG3Extender/GameDefinitions/Components/ExposedSystemTypes.inl
+++ b/BG3Extender/GameDefinitions/Components/ExposedSystemTypes.inl
@@ -78,6 +78,7 @@ T(ecl::EquipmentVisualsSystem)
T(ecl::VisualSystem)
T(ecl::CharacterIconRenderSystem)
T(ecl::CharacterManager)
+T(ecl::CameraSystem)
T(ecl::equipment::VisualsVisibilityStateSystem)
T(ecl::effect::HandlerSystem)
T(ecl::TimelineSystem)
diff --git a/BG3Extender/GameDefinitions/Enumerations/ECS.inl b/BG3Extender/GameDefinitions/Enumerations/ECS.inl
index 6d9e4984c..7f89b7923 100644
--- a/BG3Extender/GameDefinitions/Enumerations/ECS.inl
+++ b/BG3Extender/GameDefinitions/Enumerations/ECS.inl
@@ -81,6 +81,7 @@ BEGIN_ENUM(ExtSystemType, uint32_t)
E(ClientVisual)
E(ClientCharacterIconRender)
E(ClientCharacterManager)
+ E(ClientCamera)
E(ClientVisualsVisibilityState)
E(ClientEffectHandler)
E(ClientTimeline)
diff --git a/BG3Extender/GameDefinitions/GameDefinitionHelpers.cpp b/BG3Extender/GameDefinitions/GameDefinitionHelpers.cpp
index b136ddd50..845cd1224 100644
--- a/BG3Extender/GameDefinitions/GameDefinitionHelpers.cpp
+++ b/BG3Extender/GameDefinitions/GameDefinitionHelpers.cpp
@@ -9,6 +9,7 @@
#include
#include
#include
+#include
#include
#include
#include
diff --git a/BG3Extender/GameDefinitions/Symbols.h b/BG3Extender/GameDefinitions/Symbols.h
index 21217671e..2c9f33bd5 100644
--- a/BG3Extender/GameDefinitions/Symbols.h
+++ b/BG3Extender/GameDefinitions/Symbols.h
@@ -56,6 +56,13 @@ struct StaticSymbols : Noncopyable
esv::GameStateThreaded__GameStateWorker__DoWorkProc* esv__GameStateThreaded__GameStateWorker__DoWork{ nullptr };
ecl::GameStateMachine__UpdateProc* ecl__GameStateMachine__Update{ nullptr };
esv::GameStateMachine__UpdateProc* esv__GameStateMachine__Update{ nullptr };
+ ecl::CameraSystem__UpdateCameraProc* ecl__CameraSystem__UpdateCamera{ nullptr };
+ ecl::CameraSystem__UpdateCameraPitchProc* ecl__CameraSystem__UpdateCameraPitch{ nullptr };
+ ecl::CameraSystem__UpdateCameraZoomProc* ecl__CameraSystem__UpdateCameraZoom{ nullptr };
+ ecl::GameCameraBehavior__GetCameraDefinitionProc* ecl__GameCameraBehavior__GetCameraDefinition{ nullptr };
+ ecl::GameCameraBehavior__CalculatePitchProc* ecl__GameCameraBehavior__CalculatePitch{ nullptr };
+ ecl::CameraSystem__HandleInputProc* ecl__CameraSystem__HandleInput{ nullptr };
+ ecl::CameraSystem__SetDefaultZoomProc* ecl__CameraSystem__SetDefaultZoom{ nullptr };
App__LoadGraphicSettingsProc* App__LoadGraphicSettings{ nullptr };
input::InputManager** ls__gInputManager{ nullptr };
diff --git a/BG3Extender/GameHooks/BinaryMappings.xml b/BG3Extender/GameHooks/BinaryMappings.xml
index 3c863f048..e7a5ab109 100644
--- a/BG3Extender/GameHooks/BinaryMappings.xml
+++ b/BG3Extender/GameHooks/BinaryMappings.xml
@@ -179,6 +179,65 @@
+
+ @ref1 e8 ?? ?? ?? ?? // call ecl__CameraSystem__UpdateCamera
+ 48 8d 8d f8 04 00 00 // lea rcx, [rbp+4F8h]
+ e8 ?? ?? ?? ?? // call cleanup
+ e9 af fd ff ff // jmp camera loop
+
+
+
+
+ f3 0f 11 6d f0 // movss [rbp-10h], xmm5
+ e8 ?? ?? ?? ?? // call Normalize
+ f2 0f 10 45 e8 // movsd xmm0, [rbp-18h]
+ 8b 45 f0 // mov eax, [rbp-10h]
+ f2 0f 11 47 70 // movsd [rdi+70h], xmm0
+ 89 47 78 // mov [rdi+78h], eax
+ 4c 8b cb // mov r9, rbx
+ 4c 8b c7 // mov r8, rdi
+ 49 8b d6 // mov rdx, r14
+ 49 8b cf // mov rcx, r15
+ @ref1 e8 ?? ?? ?? ?? // call ecl__CameraSystem__UpdateCameraPitch
+ 48 8b 46 70 // mov rax, [rsi+70h]
+
+
+
+
+ @ref1 e8 ?? ?? ?? ?? // call ecl__CameraSystem__UpdateCameraZoom
+ 80 bf 54 02 00 00 00 // cmp byte ptr [rdi+254h], 0
+
+
+
+
+ 48 8b 05 ?? ?? ?? ?? // mov rax, cs:ls_GlobalSwitches
+ 80 b8 32 13 00 00 00 // cmp byte ptr [rax+1332h], 0
+ 74 07 // jz normal camera definition
+
+
+
+
+ @ref1 e8 ?? ?? ?? ?? // call ecl__GameCameraBehavior__CalculatePitch
+ 80 bf 4c 01 00 00 00 // cmp byte ptr [rdi+14Ch], 0
+
+
+
+
+ @ref1 e8 ?? ?? ?? ?? // call ecl__CameraSystem__HandleInput
+ 0f b7 08 // movzx ecx, word ptr [rax]
+ 66 89 0b // mov [rbx], cx
+ 80 3b 00 // cmp byte ptr [rbx], 0
+
+
+
+
+ @ref1 e8 ?? ?? ?? ?? // call ecl__CameraSystem__SetDefaultZoom
+ 48 8b cf // mov rcx, rdi
+ e8 ?? ?? ?? ?? // call camera mode update
+ e9 81 02 00 00 // jmp input handling continuation
+
+
+
48 83 be 00 03 00 00 00 // cmp qword ptr [rsi+300h], 0
74 08 // jz short loc_143693C93
diff --git a/BG3Extender/GameHooks/DataLibrariesBG3Game.cpp b/BG3Extender/GameHooks/DataLibrariesBG3Game.cpp
index 3af379dd1..584e180e4 100644
--- a/BG3Extender/GameHooks/DataLibrariesBG3Game.cpp
+++ b/BG3Extender/GameHooks/DataLibrariesBG3Game.cpp
@@ -149,6 +149,13 @@ namespace bg3se
SYM_OFF(esv__GameStateThreaded__GameStateWorker__DoWork);
SYM_OFF(ecl__GameStateMachine__Update);
SYM_OFF(esv__GameStateMachine__Update);
+ SYM_OFF(ecl__CameraSystem__UpdateCamera);
+ SYM_OFF(ecl__CameraSystem__UpdateCameraPitch);
+ SYM_OFF(ecl__CameraSystem__UpdateCameraZoom);
+ SYM_OFF(ecl__GameCameraBehavior__GetCameraDefinition);
+ SYM_OFF(ecl__GameCameraBehavior__CalculatePitch);
+ SYM_OFF(ecl__CameraSystem__HandleInput);
+ SYM_OFF(ecl__CameraSystem__SetDefaultZoom);
SYM_OFF(App__LoadGraphicSettings);
SYM_OFF(ls__gInputManager);
diff --git a/BG3Extender/GameHooks/EngineHooks.inl b/BG3Extender/GameHooks/EngineHooks.inl
index 00539c919..f9007ec83 100644
--- a/BG3Extender/GameHooks/EngineHooks.inl
+++ b/BG3Extender/GameHooks/EngineHooks.inl
@@ -14,6 +14,12 @@ HOOK_DEFN(esv__StatsSystem__ThrowDamageEvent, esv__StatsSystem__ThrowDamageEvent
HOOK_DEFN(eoc__AiGrid__FindPath, eoc__AiGrid__FindPath, AiGrid__FindPathProc)
HOOK_DEFN(ls__Visual__UpdateBlendshapeWeightsFromSkeleton, ls__Visual__UpdateBlendshapeWeightsFromSkeleton, Visual__UpdateBlendshapeWeightsFromSkeletonProc)
HOOK_DEFN(ui__DataContextProvider__ExecuteCommandQueues, ui__DataContextProvider__ExecuteCommandQueues, ui::DataContextProvider__ExecuteCommandQueuesProc)
+HOOK_DEFN(ecl__CameraSystem__UpdateCamera, ecl__CameraSystem__UpdateCamera, ecl::CameraSystem__UpdateCameraProc)
+HOOK_DEFN(ecl__CameraSystem__UpdateCameraPitch, ecl__CameraSystem__UpdateCameraPitch, ecl::CameraSystem__UpdateCameraPitchProc)
+HOOK_DEFN(ecl__CameraSystem__UpdateCameraZoom, ecl__CameraSystem__UpdateCameraZoom, ecl::CameraSystem__UpdateCameraZoomProc)
+HOOK_DEFN(ecl__GameCameraBehavior__CalculatePitch, ecl__GameCameraBehavior__CalculatePitch, ecl::GameCameraBehavior__CalculatePitchProc)
+HOOK_DEFN(ecl__CameraSystem__HandleInput, ecl__CameraSystem__HandleInput, ecl::CameraSystem__HandleInputProc)
+HOOK_DEFN(ecl__CameraSystem__SetDefaultZoom, ecl__CameraSystem__SetDefaultZoom, ecl::CameraSystem__SetDefaultZoomProc)
HOOK_DEFN(esv__ExecuteStatsFunctor_AttackTargetContext, esv__ExecuteStatsFunctor_AttackTargetContext, stats::ExecuteFunctorProc)
HOOK_DEFN(esv__ExecuteStatsFunctor_AttackPositionContext, esv__ExecuteStatsFunctor_AttackPositionContext, stats::ExecuteFunctorProc)
diff --git a/BG3Extender/GameHooks/EngineHooksFwdDecl.h b/BG3Extender/GameHooks/EngineHooksFwdDecl.h
index 7701fa24c..8e4d13455 100644
--- a/BG3Extender/GameHooks/EngineHooksFwdDecl.h
+++ b/BG3Extender/GameHooks/EngineHooksFwdDecl.h
@@ -16,6 +16,7 @@ struct AttackDesc;
struct FileReader;
struct AiGrid;
struct Visual;
+struct CameraGlobalSwitches;
using AiPathId = int32_t;
@@ -39,6 +40,12 @@ using TextureManager__UnloadTextureProc = bool (TextureManager* self, FixedStrin
END_SE()
+BEGIN_NS(input)
+
+struct InputEvent;
+
+END_NS()
+
BEGIN_NS(stats)
struct DealDamageFunctor;
@@ -112,11 +119,25 @@ END_NS()
BEGIN_NS(ecl)
+struct GameCameraBehavior;
+struct CameraUpdateTime;
+struct CameraUpdateComponentView;
+struct CameraZoomUpdateComponentView;
+struct CameraInputComponentView;
+struct CameraDefaultZoomComponentView;
+
using EoCClient__HandleErrorProc = void (EoCClient* self, TranslatedString const& message, bool exitGame, TranslatedString const& a4, uint8_t messageBoxLocalId);
using GameStateEventManager__ExecuteGameStateChangedEventProc = void (void* self, GameState fromState, GameState toState);
using GameStateThreaded__GameStateWorker__DoWorkProc = void (void* self);
using GameStateMachine__UpdateProc = void (void* self, GameTime* time);
+using CameraSystem__UpdateCameraProc = void (void* self, void* systemContext, CameraUpdateTime const* time, CameraUpdateComponentView* view);
+using CameraSystem__UpdateCameraPitchProc = void (void* self, void* entity, GameCameraBehavior* camera, CameraUpdateTime const* time);
+using CameraSystem__UpdateCameraZoomProc = void (void* self, void* entity, CameraZoomUpdateComponentView* view, CameraUpdateTime const* time);
+using GameCameraBehavior__GetCameraDefinitionProc = CameraGlobalSwitches* (GameCameraBehavior const* camera);
+using GameCameraBehavior__CalculatePitchProc = float (GameCameraBehavior* camera, bool ignoreAvoidance, bool selectMode);
+using CameraSystem__HandleInputProc = uint16_t* (void* self, uint16_t* result, CameraInputComponentView* view, input::InputEvent* inputEvent);
+using CameraSystem__SetDefaultZoomProc = void (CameraDefaultZoomComponentView* view);
END_NS()
diff --git a/BG3Extender/Lua/Client/CameraEvents.cpp b/BG3Extender/Lua/Client/CameraEvents.cpp
new file mode 100644
index 000000000..e4614820e
--- /dev/null
+++ b/BG3Extender/Lua/Client/CameraEvents.cpp
@@ -0,0 +1,182 @@
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+BEGIN_NS(ecl::lua)
+
+CameraEventHooks::CameraEventHooks()
+{
+ auto& hooks = gExtender->GetEngineHooks();
+ hooks.ecl__CameraSystem__UpdateCamera.SetPrePostHook(&CameraEventHooks::OnCameraUpdating, &CameraEventHooks::OnCameraUpdated, this);
+ hooks.ecl__CameraSystem__UpdateCameraPitch.SetPrePostHook(&CameraEventHooks::OnCameraPitchUpdating, &CameraEventHooks::OnCameraPitchUpdated, this);
+ hooks.ecl__CameraSystem__UpdateCameraZoom.SetPostHook(&CameraEventHooks::OnCameraZoomUpdated, this);
+ hooks.ecl__GameCameraBehavior__CalculatePitch.SetWrapper(&CameraEventHooks::OnCameraPitchCalculated, this);
+ hooks.ecl__CameraSystem__HandleInput.SetWrapper(&CameraEventHooks::OnCameraInput, this);
+ hooks.ecl__CameraSystem__SetDefaultZoom.SetPrePostHook(&CameraEventHooks::OnCameraZoomResetting, &CameraEventHooks::OnCameraZoomReset, this);
+}
+
+CameraEventHooks::~CameraEventHooks()
+{
+ auto& hooks = gExtender->GetEngineHooks();
+ hooks.ecl__CameraSystem__UpdateCamera.ClearHook();
+ hooks.ecl__CameraSystem__UpdateCameraPitch.ClearHook();
+ hooks.ecl__CameraSystem__UpdateCameraZoom.ClearHook();
+ hooks.ecl__GameCameraBehavior__CalculatePitch.ClearHook();
+ hooks.ecl__CameraSystem__HandleInput.ClearHook();
+ hooks.ecl__CameraSystem__SetDefaultZoom.ClearHook();
+}
+
+void CameraEventHooks::OnCameraUpdating(void* self, void* systemContext, ecl::CameraUpdateTime const* time, ecl::CameraUpdateComponentView* view)
+{
+ ContextGuardAnyThread context(ContextType::Client);
+ LuaClientPin lua(gExtender->GetClient().GetExtensionState());
+ if (lua) {
+ CameraUpdatingEvent evt{
+ .Entity = view->Entity,
+ .Camera = view->Camera,
+ .DeltaTime = time->DeltaTime
+ };
+ lua->ThrowEvent("CameraUpdating", evt, false, 0);
+ }
+}
+
+void CameraEventHooks::OnCameraUpdated(void* self, void* systemContext, ecl::CameraUpdateTime const* time, ecl::CameraUpdateComponentView* view)
+{
+ ContextGuardAnyThread context(ContextType::Client);
+ LuaClientPin lua(gExtender->GetClient().GetExtensionState());
+ if (lua) {
+ CameraUpdatedEvent evt{
+ .Entity = view->Entity,
+ .Camera = view->Camera,
+ .DeltaTime = time->DeltaTime
+ };
+ lua->ThrowEvent("CameraUpdated", evt, false, 0);
+ }
+}
+
+void CameraEventHooks::OnCameraPitchUpdating(void* self, void* entity, ecl::GameCameraBehavior* camera, ecl::CameraUpdateTime const* time)
+{
+ ContextGuardAnyThread context(ContextType::Client);
+ LuaClientPin lua(gExtender->GetClient().GetExtensionState());
+ if (lua) {
+ CameraPitchUpdatingEvent evt{
+ .Camera = camera,
+ .DeltaTime = time->DeltaTime
+ };
+ lua->ThrowEvent("CameraPitchUpdating", evt, false, 0);
+ }
+}
+
+void CameraEventHooks::OnCameraPitchUpdated(void* self, void* entity, ecl::GameCameraBehavior* camera, ecl::CameraUpdateTime const* time)
+{
+ ContextGuardAnyThread context(ContextType::Client);
+ LuaClientPin lua(gExtender->GetClient().GetExtensionState());
+ if (lua) {
+ CameraPitchUpdatedEvent evt{
+ .Camera = camera,
+ .DeltaTime = time->DeltaTime
+ };
+ lua->ThrowEvent("CameraPitchUpdated", evt, false, 0);
+ }
+}
+
+void CameraEventHooks::OnCameraZoomUpdated(void* self, void* entity, ecl::CameraZoomUpdateComponentView* view, ecl::CameraUpdateTime const* time)
+{
+ ContextGuardAnyThread context(ContextType::Client);
+ LuaClientPin lua(gExtender->GetClient().GetExtensionState());
+ if (lua) {
+ CameraZoomUpdatedEvent evt{
+ .Camera = view->Camera,
+ .DeltaTime = time->DeltaTime
+ };
+ lua->ThrowEvent("CameraZoomUpdated", evt, false, 0);
+ }
+}
+
+float CameraEventHooks::OnCameraPitchCalculated(ecl::GameCameraBehavior__CalculatePitchProc* next, ecl::GameCameraBehavior* camera, bool ignoreAvoidance, bool selectMode)
+{
+ auto pitch = next(camera, ignoreAvoidance, selectMode);
+ ContextGuardAnyThread context(ContextType::Client);
+ LuaClientPin lua(gExtender->GetClient().GetExtensionState());
+ if (lua) {
+ CameraPitchCalculatedEvent evt{
+ .Camera = camera,
+ .IgnoreAvoidance = ignoreAvoidance,
+ .SelectMode = selectMode,
+ .OriginalPitch = pitch,
+ .Pitch = pitch
+ };
+ auto result = lua->ThrowEvent("CameraPitchCalculated", evt, false, 0);
+ // Check for a finite number; otherwise return the original native pitch.
+ if (result == EventResult::Successful && (std::bit_cast(evt.Pitch) & 0x7f800000u) != 0x7f800000u) {
+ return evt.Pitch;
+ }
+ }
+
+ return pitch;
+}
+
+uint16_t* CameraEventHooks::OnCameraInput(ecl::CameraSystem__HandleInputProc* next, void* self, uint16_t* result, ecl::CameraInputComponentView* view, bg3se::input::InputEvent* input)
+{
+ {
+ ContextGuardAnyThread context(ContextType::Client);
+ LuaClientPin lua(gExtender->GetClient().GetExtensionState());
+ if (lua) {
+ CameraInputEvent evt{
+ .Camera = view->Camera,
+ .Input = input
+ };
+ if (lua->ThrowEvent("CameraInput", evt, true, 0) == EventResult::ActionPrevented) {
+ *result = evt.Result;
+ return result;
+ }
+ }
+ }
+
+ auto ret = next(self, result, view, input);
+
+ {
+ ContextGuardAnyThread context(ContextType::Client);
+ LuaClientPin lua(gExtender->GetClient().GetExtensionState());
+ if (lua) {
+ CameraInputHandledEvent evt{
+ .Camera = view->Camera,
+ .Input = input,
+ .Result = *ret
+ };
+ lua->ThrowEvent("CameraInputHandled", evt, false, 0);
+ }
+ }
+
+ return ret;
+}
+
+void CameraEventHooks::OnCameraZoomResetting(ecl::CameraDefaultZoomComponentView* view)
+{
+ ContextGuardAnyThread context(ContextType::Client);
+ LuaClientPin lua(gExtender->GetClient().GetExtensionState());
+ if (lua) {
+ CameraZoomResettingEvent evt{
+ .Camera = view->Camera
+ };
+ lua->ThrowEvent("CameraZoomResetting", evt, false, 0);
+ }
+}
+
+void CameraEventHooks::OnCameraZoomReset(ecl::CameraDefaultZoomComponentView* view)
+{
+ ContextGuardAnyThread context(ContextType::Client);
+ LuaClientPin lua(gExtender->GetClient().GetExtensionState());
+ if (lua) {
+ CameraZoomResetEvent evt{
+ .Camera = view->Camera
+ };
+ lua->ThrowEvent("CameraZoomReset", evt, false, 0);
+ }
+}
+
+END_NS()
diff --git a/BG3Extender/Lua/Client/CameraEvents.h b/BG3Extender/Lua/Client/CameraEvents.h
new file mode 100644
index 000000000..67032a332
--- /dev/null
+++ b/BG3Extender/Lua/Client/CameraEvents.h
@@ -0,0 +1,25 @@
+#pragma once
+
+#include
+
+BEGIN_NS(ecl::lua)
+
+class CameraEventHooks
+{
+public:
+ CameraEventHooks();
+ ~CameraEventHooks();
+
+private:
+ void OnCameraUpdating(void* self, void* systemContext, ecl::CameraUpdateTime const* time, ecl::CameraUpdateComponentView* view);
+ void OnCameraUpdated(void* self, void* systemContext, ecl::CameraUpdateTime const* time, ecl::CameraUpdateComponentView* view);
+ void OnCameraPitchUpdating(void* self, void* entity, ecl::GameCameraBehavior* camera, ecl::CameraUpdateTime const* time);
+ void OnCameraPitchUpdated(void* self, void* entity, ecl::GameCameraBehavior* camera, ecl::CameraUpdateTime const* time);
+ void OnCameraZoomUpdated(void* self, void* entity, ecl::CameraZoomUpdateComponentView* view, ecl::CameraUpdateTime const* time);
+ float OnCameraPitchCalculated(ecl::GameCameraBehavior__CalculatePitchProc* next, ecl::GameCameraBehavior* camera, bool ignoreAvoidance, bool selectMode);
+ uint16_t* OnCameraInput(ecl::CameraSystem__HandleInputProc* next, void* self, uint16_t* result, ecl::CameraInputComponentView* view, bg3se::input::InputEvent* input);
+ void OnCameraZoomResetting(ecl::CameraDefaultZoomComponentView* view);
+ void OnCameraZoomReset(ecl::CameraDefaultZoomComponentView* view);
+};
+
+END_NS()
diff --git a/BG3Extender/Lua/Client/ClientEvents.h b/BG3Extender/Lua/Client/ClientEvents.h
index 9d0d7513d..945845db7 100644
--- a/BG3Extender/Lua/Client/ClientEvents.h
+++ b/BG3Extender/Lua/Client/ClientEvents.h
@@ -1,5 +1,11 @@
#pragma once
+BEGIN_NS(ecl)
+
+struct GameCameraBehavior;
+
+END_NS()
+
BEGIN_NS(ecl::lua)
using namespace bg3se::lua;
@@ -57,4 +63,69 @@ struct ViewportResizedEvent : public lua::EventBase
int Height;
};
+struct CameraUpdatingEvent : public lua::EventBase
+{
+ [[bg3::readonly]] EntityHandle Entity;
+ [[bg3::readonly]] ecl::GameCameraBehavior* Camera;
+ [[bg3::readonly]] float DeltaTime;
+};
+
+struct CameraUpdatedEvent : public lua::EventBase
+{
+ [[bg3::readonly]] EntityHandle Entity;
+ [[bg3::readonly]] ecl::GameCameraBehavior* Camera;
+ [[bg3::readonly]] float DeltaTime;
+};
+
+struct CameraPitchUpdatedEvent : public lua::EventBase
+{
+ [[bg3::readonly]] ecl::GameCameraBehavior* Camera;
+ [[bg3::readonly]] float DeltaTime;
+};
+
+struct CameraZoomUpdatedEvent : public lua::EventBase
+{
+ [[bg3::readonly]] ecl::GameCameraBehavior* Camera;
+ [[bg3::readonly]] float DeltaTime;
+};
+
+struct CameraPitchUpdatingEvent : public lua::EventBase
+{
+ [[bg3::readonly]] ecl::GameCameraBehavior* Camera;
+ [[bg3::readonly]] float DeltaTime;
+};
+
+struct CameraPitchCalculatedEvent : public lua::EventBase
+{
+ [[bg3::readonly]] ecl::GameCameraBehavior* Camera;
+ [[bg3::readonly]] bool IgnoreAvoidance;
+ [[bg3::readonly]] bool SelectMode;
+ [[bg3::readonly]] float OriginalPitch;
+ float Pitch;
+};
+
+struct CameraInputEvent : public lua::EventBase
+{
+ [[bg3::readonly]] ecl::GameCameraBehavior* Camera;
+ [[bg3::readonly]] bg3se::input::InputEvent* Input;
+ uint16_t Result{ 0 };
+};
+
+struct CameraInputHandledEvent : public lua::EventBase
+{
+ [[bg3::readonly]] ecl::GameCameraBehavior* Camera;
+ [[bg3::readonly]] bg3se::input::InputEvent* Input;
+ [[bg3::readonly]] uint16_t Result;
+};
+
+struct CameraZoomResettingEvent : public lua::EventBase
+{
+ [[bg3::readonly]] ecl::GameCameraBehavior* Camera;
+};
+
+struct CameraZoomResetEvent : public lua::EventBase
+{
+ [[bg3::readonly]] ecl::GameCameraBehavior* Camera;
+};
+
END_NS()
diff --git a/BG3Extender/Lua/Client/LuaBindingClient.h b/BG3Extender/Lua/Client/LuaBindingClient.h
index 60badfadf..69b6bebd5 100644
--- a/BG3Extender/Lua/Client/LuaBindingClient.h
+++ b/BG3Extender/Lua/Client/LuaBindingClient.h
@@ -2,6 +2,7 @@
#include
#include
+#include
#include
#include
#include
@@ -60,6 +61,7 @@ class ClientState : public State
private:
ExtensionLibraryClient library_;
+ CameraEventHooks cameraHooks_;
ClientEntityReplicationEventHooks replicationHooks_;
UIEventHooks uiEvents_;
DeferredUIEvents deferredUIEvents_;
diff --git a/BG3Extender/LuaScripts/BuiltinLibraryClient.lua b/BG3Extender/LuaScripts/BuiltinLibraryClient.lua
index 55262fb37..bb1a3f87f 100644
--- a/BG3Extender/LuaScripts/BuiltinLibraryClient.lua
+++ b/BG3Extender/LuaScripts/BuiltinLibraryClient.lua
@@ -6,7 +6,17 @@ _I._PublishedEvents = {
"MouseWheelInput",
"ControllerAxisInput",
"ControllerButtonInput",
- "ViewportResized"
+ "ViewportResized",
+ "CameraUpdating",
+ "CameraUpdated",
+ "CameraPitchUpdating",
+ "CameraPitchCalculated",
+ "CameraPitchUpdated",
+ "CameraZoomUpdated",
+ "CameraInput",
+ "CameraInputHandled",
+ "CameraZoomResetting",
+ "CameraZoomReset"
}
Ext.IsClient = function ()
diff --git a/Docs/API.md b/Docs/API.md
index 62b40de1a..86c175ddb 100644
--- a/Docs/API.md
+++ b/Docs/API.md
@@ -54,6 +54,7 @@
- [Level](#level)
- [Math Library](#math)
- [Engine Events](#engine-events)
+- [Camera](#camera)
## Getting Started
@@ -1934,3 +1935,47 @@ You can listen to SE and engine events with `Ext.Events.:Subscribe(f
| `ResetCompleted` | Thrown when `Ext.Debug.Reset()` or `reset` console command completes on the client or server.
Indicates that the Lua state was reloaded. |
| `GameStateChanged` | Indicates that the server/client game state changed (e.g., loading save, paused, main menu, etc.). |
| `Tick` | Thrown after each game engine tick on both the client and the server.
Server logic runs at ~30hz, so this event is thrown roughly every 33ms.
Helper: `Ext.OnNextTick(fun)` registers a handler that is only called on the next tick and is unregistered afterwards. |
+
+## Camera
+
+Client-only. Camera events use the standard [SE event system](#lua-events). `Ext.Events.CameraUpdated:Subscribe(callback, options)` returns a handler ID; call `Ext.Events.CameraUpdated:Unsubscribe(id)` on the same event to remove it. The standard `Priority` and `Once` options apply. Subscriptions are cleared when the client Lua state is destroyed.
+
+Callbacks run synchronously in priority order, with registration order preserved for equal priorities. Explicit subscription additions and removals during dispatch take effect after the outermost dispatch of that event finishes. `StopPropagation()` skips later callbacks for that invocation; it does not prevent the native operation.
+
+```lua
+local id = Ext.Events.CameraPitchCalculated:Subscribe(function(e)
+ e.Pitch = 0.5
+end)
+Ext.Events.CameraPitchCalculated:Unsubscribe(id)
+```
+
+| Event | Description |
+|-------|-------------|
+| `CameraUpdating` | Client-only. Thrown before a gameplay camera is updated. |
+| `CameraUpdated` | Client-only. Thrown after a gameplay camera is updated. |
+| `CameraPitchUpdating` | Client-only. Thrown before the camera pitch update. |
+| `CameraPitchUpdated` | Client-only. Thrown after the camera pitch update. |
+| `CameraPitchCalculated` | Client-only. Allows overriding a calculated camera pitch. |
+| `CameraZoomUpdated` | Client-only. Thrown after zoom and collision-distance updates, before final placement. |
+| `CameraInput` | Client-only. Allows modifying or preventing camera input handling. |
+| `CameraInputHandled` | Client-only. Thrown after camera input handling. |
+| `CameraZoomResetting` | Client-only. Thrown before the default zoom is applied. |
+| `CameraZoomReset` | Client-only. Thrown after the default zoom is applied. |
+
+All camera events expose `Camera` (`ecl.GameCameraBehavior`). Its fields can be modified, but the reference cannot be replaced. Event objects and their references are only valid during the callback.
+
+`CameraUpdating` and `CameraUpdated` also expose read-only `Entity`, the entity that owns the camera component.
+
+`CameraUpdating`, `CameraUpdated`, `CameraPitchUpdating`, `CameraPitchUpdated`, and `CameraZoomUpdated` also expose read-only `DeltaTime` in seconds. These events cannot be prevented. They apply to individual cameras, not the whole camera system.
+
+`CameraPitchCalculated` exposes read-only `IgnoreAvoidance`, `SelectMode`, and `OriginalPitch`, and writable `Pitch`. Set `Pitch` to replace the calculated value. Nonfinite values are ignored. This event also runs during target and collision calculations, outside the pitch update.
+
+`CameraInput` exposes `Input` (`input.InputEvent`) and writable `Result` (unsigned 16-bit integer, initially zero). Changes to `Input` are passed to the camera handler. Calling `PreventAction()` skips the handler and returns `Result`. Zero allows fallback input handling; one suppresses it at the camera dispatch boundary.
+
+`CameraInputHandled` exposes the same `Camera` and `Input`, plus read-only `Result` returned by the handler. It is not thrown when `CameraInput` prevents handling.
+
+`CameraZoomResetting` and `CameraZoomReset` cannot be prevented. They run before and after the default-zoom routine, respectively.
+
+### GameCameraBehavior.CameraDefinition
+
+Read-only property returning the `CameraGlobalSwitches` selected for this camera on each access. The returned object's fields are writable. Available through `entity.GameCameraBehavior.CameraDefinition` independently of subscriptions, and through `e.Camera.CameraDefinition` in a callback. Returns `nil` if the native getter is unavailable.