diff --git a/.gitignore b/.gitignore index 9161be0ee..1c1a7de2c 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,15 @@ vs2019 vs2022 vsxmake2019 vsxmake2022 +vsxmake* .xmake vs2019 +# Local working files (not shared) +CLAUDE.md +.claude/ +docs/superpowers/ + build Build Build/*.log diff --git a/Code/client/Events/ScriptAnimationEvent.h b/Code/client/Events/ScriptAnimationEvent.h index 514a42226..08823205a 100644 --- a/Code/client/Events/ScriptAnimationEvent.h +++ b/Code/client/Events/ScriptAnimationEvent.h @@ -12,6 +12,8 @@ struct ScriptAnimationEvent { } + // Local form id of the animating reference; ObjectService::OnScriptAnimationEvent + // translates it to a server id before it goes on the wire. uint32_t FormID; String Animation; String EventName; diff --git a/Code/client/Events/WaveCommandEvent.h b/Code/client/Events/WaveCommandEvent.h new file mode 100644 index 000000000..2d0ab81fe --- /dev/null +++ b/Code/client/Events/WaveCommandEvent.h @@ -0,0 +1,8 @@ +#pragma once + +/** + * @brief Dispatched when the local player uses the /wave chat command. + */ +struct WaveCommandEvent +{ +}; diff --git a/Code/client/Games/Misc/BSScript.cpp b/Code/client/Games/Misc/BSScript.cpp index 4eef585e3..01d7efd1d 100644 --- a/Code/client/Games/Misc/BSScript.cpp +++ b/Code/client/Games/Misc/BSScript.cpp @@ -9,6 +9,9 @@ #include #include #include +#include +#include +#include TP_THIS_FUNCTION(TRegisterPapyrusFunction, void, BSScript::IVirtualMachine, NativeFunction*); TP_THIS_FUNCTION(TBindEverythingToScript, void, BSScript::IVirtualMachine*); @@ -20,6 +23,23 @@ TBindEverythingToScript* RealBindEverythingToScript = nullptr; TSignaturesMatch* RealSignaturesMatch = nullptr; TCompareVariables* RealCompareVariables = nullptr; +// Papyrus global native: Debug.SendAnimationEvent(ObjectReference arRef, string asEventName). +// ABI matches the repo's PapyrusFunction<> convention: (VM*, stackId, this/tag, args...). +using TDebugSendAnimationEvent = void(BSScript::IVirtualMachine*, uint32_t, void*, TESObjectREFR*, BSFixedString*); +static TDebugSendAnimationEvent* RealDebugSendAnimationEvent = nullptr; + +static void HookDebugSendAnimationEvent(BSScript::IVirtualMachine* apVm, uint32_t aStackId, void* apTag, TESObjectREFR* apRef, BSFixedString* apEventName) +{ + const char* pcEventName = apEventName ? apEventName->AsAscii() : nullptr; + if (apRef && pcEventName && pcEventName[0] != '\0') + { + spdlog::debug("Debug.SendAnimationEvent captured: ref {:X}, event {}", apRef->formID, pcEventName); + World::Get().GetRunner().Trigger(ScriptAnimationEvent(apRef->formID, String{}, pcEventName)); + } + + RealDebugSendAnimationEvent(apVm, aStackId, apTag, apRef, apEventName); +} + void TP_MAKE_THISCALL(HookRegisterPapyrusFunction, BSScript::IVirtualMachine, NativeFunction* apFunction) { auto& runner = World::Get().GetRunner(); @@ -28,6 +48,13 @@ void TP_MAKE_THISCALL(HookRegisterPapyrusFunction, BSScript::IVirtualMachine, Na runner.Trigger(std::move(event)); + if (!RealDebugSendAnimationEvent && apFunction->functionAddress && !strcmp(apFunction->typeName.AsAscii(), "Debug") && !strcmp(apFunction->functionName.AsAscii(), "SendAnimationEvent")) + { + RealDebugSendAnimationEvent = reinterpret_cast(apFunction->functionAddress); + TP_HOOK_IMMEDIATE(&RealDebugSendAnimationEvent, HookDebugSendAnimationEvent); + spdlog::info("Hooked Debug.SendAnimationEvent at {}", apFunction->functionAddress); + } + TiltedPhoques::ThisCall(RealRegisterPapyrusFunction, apThis, apFunction); } diff --git a/Code/client/Games/Skyrim/ObScript.cpp b/Code/client/Games/Skyrim/ObScript.cpp new file mode 100644 index 000000000..25a4b2b03 --- /dev/null +++ b/Code/client/Games/Skyrim/ObScript.cpp @@ -0,0 +1,76 @@ +#include + +#include +#include + +#include + +#include + +// Bound from CommonLibSSE-NG SCRIPT_FUNCTION::LocateScriptCommand, which scans +// kScriptCommandsEnd (0x02E0) entries from the first script command. +static constexpr uint32_t kScriptCommandCount = 0x2E0; + +using TParseParameters = bool(const ObScriptParam* apParamInfo, ObScriptData* apScriptData, uint32_t& arOpcodeOffsetPtr, TESObjectREFR* apThisObj, TESObjectREFR* apContainingObj, Script* apScriptObj, ScriptLocals* apLocals, ...); + +static ObScriptCommand::TExecute* RealSendAnimationEventExecute = nullptr; +static ObScriptCommand* s_pSaeCommand = nullptr; // kept for a future unhook/restore path + +static bool HookSendAnimationEventExecute(const ObScriptParam* apParamInfo, ObScriptData* apScriptData, TESObjectREFR* apThisObj, TESObjectREFR* apContainingObj, Script* apScriptObj, ScriptLocals* apLocals, double& arResult, uint32_t& arOpcodeOffsetPtr) +{ + POINTER_SKYRIMSE(TParseParameters, s_parseParameters, 21910); + + // Parse a copy so the original execute below still sees the unconsumed offset. + uint32_t opcodeOffset = arOpcodeOffsetPtr; + char eventNameBuffer[512]{}; + + const bool cParsed = s_parseParameters.Get()(apParamInfo, apScriptData, opcodeOffset, apThisObj, apContainingObj, apScriptObj, apLocals, eventNameBuffer); + + const bool cResult = RealSendAnimationEventExecute(apParamInfo, apScriptData, apThisObj, apContainingObj, apScriptObj, apLocals, arResult, arOpcodeOffsetPtr); + + if (cResult && cParsed && apThisObj && eventNameBuffer[0] != '\0') + { + spdlog::debug("Console sae captured: ref {:X}, event {}", apThisObj->formID, eventNameBuffer); + World::Get().GetRunner().Trigger(ScriptAnimationEvent(apThisObj->formID, String{}, eventNameBuffer)); + } + + return cResult; +} + +static TiltedPhoques::Initializer s_obScriptHooks( + []() + { + // First script command, AE address-library id 361120 (CommonLibSSE-NG include/RE/Offsets.h:481). + // The sae command is a SCRIPT function ("SendAnimEvent"), not a console command — + // confirmed via the in-game help listing. + POINTER_SKYRIMSE(ObScriptCommand, s_firstScriptCommand, 361120); + + ObScriptCommand* pCommands = s_firstScriptCommand.Get(); + if (!pCommands) + { + spdlog::error("ObScript: script command table not found, sae sync disabled"); + return; + } + + for (uint32_t i = 0; i < kScriptCommandCount; ++i) + { + ObScriptCommand& command = pCommands[i]; + + if (command.pFunctionName && _stricmp(command.pFunctionName, "SendAnimEvent") == 0) + { + s_pSaeCommand = &command; + RealSendAnimationEventExecute = command.pExecuteFunction; + command.pExecuteFunction = HookSendAnimationEventExecute; + + spdlog::info("ObScript: hooked script command SendAnimEvent (sae)"); + return; + } + } + + spdlog::error("ObScript: SendAnimEvent script command not found, sae sync disabled"); + for (uint32_t i = 0; i < kScriptCommandCount; ++i) + { + const ObScriptCommand& command = pCommands[i]; + spdlog::debug("ObScript table [{}]: {} ({})", i, command.pFunctionName ? command.pFunctionName : "", command.pShortName ? command.pShortName : ""); + } + }); diff --git a/Code/client/Games/Skyrim/ObScript.h b/Code/client/Games/Skyrim/ObScript.h new file mode 100644 index 000000000..cdfcf9716 --- /dev/null +++ b/Code/client/Games/Skyrim/ObScript.h @@ -0,0 +1,53 @@ +#pragma once + +struct Script; +struct ScriptLocals; +struct TESObjectREFR; + +// Minimal layout of the engine's ObScript (console/script) command table entry. +// Cross-checked against CommonLibSSE-NG RE::SCRIPT_FUNCTION (include/RE/C/CommandTable.h). +struct ObScriptParam +{ + const char* pParamName; // 00 + uint32_t paramType; // 08 + bool optional; // 0C + uint8_t pad0D; // 0D + uint16_t pad0E; // 0E +}; + +static_assert(sizeof(ObScriptParam) == 0x10); + +struct ObScriptData +{ + uint16_t opcode; // 00 + uint16_t chunkSize; // 02 + uint16_t numParams; // 04 +}; + +static_assert(sizeof(ObScriptData) == 0x6); + +struct ObScriptCommand +{ + using TExecute = bool(const ObScriptParam* apParamInfo, ObScriptData* apScriptData, TESObjectREFR* apThisObj, TESObjectREFR* apContainingObj, Script* apScriptObj, ScriptLocals* apLocals, double& arResult, uint32_t& arOpcodeOffsetPtr); + + const char* pFunctionName; // 00 + const char* pShortName; // 08 + uint32_t output; // 10 + uint32_t pad14; // 14 + const char* pHelpString; // 18 + bool referenceFunction; // 20 + uint8_t pad21; // 21 + uint16_t numParams; // 22 + uint32_t pad24; // 24 + ObScriptParam* pParams; // 28 + TExecute* pExecuteFunction; // 30 + void* pCompileFunction; // 38 + void* pConditionFunction; // 40 + bool editorFilter; // 48 + bool invalidatesCellList; // 49 + uint16_t pad4A; // 4A + uint32_t pad4C; // 4C +}; + +static_assert(offsetof(ObScriptCommand, pExecuteFunction) == 0x30); +static_assert(sizeof(ObScriptCommand) == 0x50); diff --git a/Code/client/Services/Generic/ObjectService.cpp b/Code/client/Services/Generic/ObjectService.cpp index b78b68588..21cfa89ce 100644 --- a/Code/client/Services/Generic/ObjectService.cpp +++ b/Code/client/Services/Generic/ObjectService.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -37,6 +38,7 @@ ObjectService::ObjectService(World& aWorld, entt::dispatcher& aDispatcher, Trans m_assignObjectConnection = aDispatcher.sink().connect<&ObjectService::OnAssignObjectsResponse>(this); m_scriptAnimationConnection = aDispatcher.sink().connect<&ObjectService::OnScriptAnimationEvent>(this); m_scriptAnimationNotifyConnection = aDispatcher.sink().connect<&ObjectService::OnNotifyScriptAnimation>(this); + m_waveCommandConnection = aDispatcher.sink().connect<&ObjectService::OnWaveCommand>(this); EventDispatcherManager::Get()->activateEvent.RegisterSink(this); } @@ -378,34 +380,78 @@ void ObjectService::OnLockChangeNotify(const NotifyLockChange& acMessage) noexce pObject->LockChange(); } +// ScriptAnimationEvent carries a LOCAL form id; the wire format +// (ScriptAnimationRequest/NotifyScriptAnimation.FormID) carries a SERVER id. +// The translation happens here; refs without a server id are local-only. void ObjectService::OnScriptAnimationEvent(const ScriptAnimationEvent& acEvent) noexcept { + if (!m_transport.IsOnline()) + return; + + auto view = m_world.view(); + const auto it = std::find_if(view.begin(), view.end(), [view, id = acEvent.FormID](auto entity) { return view.get(entity).Id == id; }); + + if (it == view.end()) + { + spdlog::debug("{}: no synced entity for form id {:X}, event {}", __FUNCTION__, acEvent.FormID, acEvent.EventName.c_str()); + return; + } + + std::optional serverIdRes = Utils::GetServerId(*it); + if (!serverIdRes.has_value()) + return; + ScriptAnimationRequest request{}; - request.FormID = acEvent.FormID; + request.FormID = serverIdRes.value(); request.Animation = acEvent.Animation; request.EventName = acEvent.EventName; m_transport.Send(request); } -void ObjectService::OnNotifyScriptAnimation(const NotifyScriptAnimation& acMessage) noexcept +void ObjectService::OnWaveCommand(const WaveCommandEvent& acEvent) noexcept { - if (acMessage.FormID == 0) + if (!m_transport.IsOnline()) return; - auto* pForm = TESForm::GetById(acMessage.FormID); - auto* pObject = Cast(pForm); + auto view = m_world.view(); + const auto it = std::find_if(view.begin(), view.end(), [view](auto entity) { return view.get(entity).Id == 0x14; }); - if (!pObject) + if (it == view.end()) + { + spdlog::debug("{}: local player entity not found", __FUNCTION__); + return; + } + + std::optional serverIdRes = Utils::GetServerId(*it); + if (!serverIdRes.has_value()) { - spdlog::error("Failed to fetch notify script animation object, form id: {:X}", acMessage.FormID); + spdlog::debug("{}: local player has no server id yet", __FUNCTION__); return; } + ScriptAnimationRequest request{}; + request.FormID = serverIdRes.value(); + request.EventName = "IdleWave"; + + m_transport.Send(request); + + // The relay no longer echoes to the sender; play the wave locally. + BSFixedString eventName("IdleWave"); + PlayerCharacter::Get()->SendAnimationEvent(&eventName); +} + +void ObjectService::OnNotifyScriptAnimation(const NotifyScriptAnimation& acMessage) noexcept +{ + // FormID carries a server id; resolve it to whatever local form mirrors that entity + TESObjectREFR* pObject = Utils::GetByServerId(acMessage.FormID); + if (!pObject) + return; + BSFixedString eventName(acMessage.EventName.c_str()); if (acMessage.Animation == String{}) { - pObject->PlayAnimation(&eventName); + pObject->SendAnimationEvent(&eventName); } else { diff --git a/Code/client/Services/Generic/OverlayClient.cpp b/Code/client/Services/Generic/OverlayClient.cpp index 6e6b983f5..97d41430e 100644 --- a/Code/client/Services/Generic/OverlayClient.cpp +++ b/Code/client/Services/Generic/OverlayClient.cpp @@ -10,6 +10,7 @@ #include #include +#include #include @@ -51,6 +52,8 @@ bool OverlayClient::OnProcessMessageReceived(CefRefPtr browser, CefR ProcessChatMessage(eventArgs); else if (eventName == "setTime") ProcessSetTimeCommand(eventArgs); + else if (eventName == "wave") + ProcessWaveCommand(); else if (eventName == "launchParty") World::Get().GetPartyService().CreateParty(); else if (eventName == "leaveParty") @@ -137,6 +140,11 @@ void OverlayClient::ProcessSetTimeCommand(CefRefPtr aEventArgs) World::Get().GetDispatcher().trigger(SetTimeCommandEvent(hours, minutes, senderId)); } +void OverlayClient::ProcessWaveCommand() +{ + World::Get().GetRunner().Trigger(WaveCommandEvent()); +} + void OverlayClient::ProcessTeleportMessage(CefRefPtr aEventArgs) { TeleportRequest request{}; diff --git a/Code/client/Services/ObjectService.h b/Code/client/Services/ObjectService.h index 9adf8f39b..d2aff1a0b 100644 --- a/Code/client/Services/ObjectService.h +++ b/Code/client/Services/ObjectService.h @@ -13,6 +13,7 @@ struct LockChangeEvent; struct NotifyLockChange; struct CellChangeEvent; struct ScriptAnimationEvent; +struct WaveCommandEvent; struct AssignObjectsResponse; struct NotifyScriptAnimation; @@ -33,6 +34,7 @@ class ObjectService final : public BSTEventSink void OnLockChange(const LockChangeEvent&) noexcept; void OnLockChangeNotify(const NotifyLockChange&) noexcept; void OnScriptAnimationEvent(const ScriptAnimationEvent&) noexcept; + void OnWaveCommand(const WaveCommandEvent&) noexcept; void OnNotifyScriptAnimation(const NotifyScriptAnimation&) noexcept; BSTEventResult OnEvent(const TESActivateEvent*, const EventDispatcher*) override; @@ -51,4 +53,5 @@ class ObjectService final : public BSTEventSink entt::scoped_connection m_assignObjectConnection; entt::scoped_connection m_scriptAnimationConnection; entt::scoped_connection m_scriptAnimationNotifyConnection; + entt::scoped_connection m_waveCommandConnection; }; diff --git a/Code/client/Services/OverlayClient.h b/Code/client/Services/OverlayClient.h index b495b0e5f..7e9fde89e 100644 --- a/Code/client/Services/OverlayClient.h +++ b/Code/client/Services/OverlayClient.h @@ -27,6 +27,7 @@ struct OverlayClient : TiltedPhoques::OverlayClient void ProcessRevealPlayersMessage(); void ProcessChatMessage(CefRefPtr aEventArgs); void ProcessSetTimeCommand(CefRefPtr aEventArgs); + void ProcessWaveCommand(); void ProcessTeleportMessage(CefRefPtr aEventArgs); void ProcessToggleDebugUI(); void SetUIVisible(bool aVisible) noexcept; diff --git a/Code/server/Services/ObjectService.cpp b/Code/server/Services/ObjectService.cpp index 2bd7d154f..028c198c7 100644 --- a/Code/server/Services/ObjectService.cpp +++ b/Code/server/Services/ObjectService.cpp @@ -178,6 +178,9 @@ void ObjectService::OnScriptAnimationRequest(const PacketEventSend(message); } } diff --git a/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts b/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts index 50f2d4815..ec4c84a83 100644 --- a/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts +++ b/Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts @@ -113,6 +113,10 @@ export class SkyrimtogetherMock extends EventEmitter implements SkyrimTogether { this.sendMessage(MessageTypes.SYSTEM_MESSAGE, `Setting time to "${hours}:${minutes}"!`); } + wave(): void { + this.sendMessage(MessageTypes.SYSTEM_MESSAGE, 'You wave.'); + } + sendMessage(type: MessageTypes, message: string): void { if (this.connected) { this.emit('message', type, message, this.playerName); diff --git a/Code/skyrim_ui/src/app/services/chat/commands.ts b/Code/skyrim_ui/src/app/services/chat/commands.ts index f2101dafa..aa28325f3 100644 --- a/Code/skyrim_ui/src/app/services/chat/commands.ts +++ b/Code/skyrim_ui/src/app/services/chat/commands.ts @@ -18,12 +18,12 @@ export class CommandHandler { } private SetTime: Command = { - name: 'settime', + name: 'settime', executor: async (args) => { const cmds = [...this.commands.keys()].join(', '); if (args.length != 2) { this.chatService.pushSystemMessage( - 'COMPONENT.CHAT.SET_TIME_ARGUMENT_COUNT', + 'COMPONENT.CHAT.SET_TIME_ARGUMENT_COUNT', { cmds }, ); return; @@ -43,11 +43,19 @@ export class CommandHandler { }, } + private Wave: Command = { + name: 'wave', + executor: async () => { + skyrimtogether.wave(); + }, + } + private readonly commands = new Map(); public constructor(private readonly chatService: ChatService) { this.register(this.Help); this.register(this.SetTime); + this.register(this.Wave); } public readonly COMMAND_PREFIX = '/'; diff --git a/Code/skyrim_ui/src/typings.d.ts b/Code/skyrim_ui/src/typings.d.ts index 0104f5d0c..25492a5b3 100644 --- a/Code/skyrim_ui/src/typings.d.ts +++ b/Code/skyrim_ui/src/typings.d.ts @@ -378,6 +378,11 @@ interface SkyrimTogether { */ setTime(hours: number, minutes: number): void; + /** + * Ask the server to make the local player's character wave at everyone. + */ + wave(): void; + /** * Deactivate UI and release control. */ diff --git a/Code/tp_process/ProcessHandler.cpp b/Code/tp_process/ProcessHandler.cpp index f0d4ffcb0..b1ab17646 100644 --- a/Code/tp_process/ProcessHandler.cpp +++ b/Code/tp_process/ProcessHandler.cpp @@ -18,6 +18,7 @@ void ProcessHandler::OnContextCreated(CefRefPtr browser, CefRefPtrSetValue("revealPlayers", CefV8Value::CreateFunction("revealPlayers", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("sendMessage", CefV8Value::CreateFunction("sendMessage", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("setTime", CefV8Value::CreateFunction("setTime", m_pOverlayHandler),V8_PROPERTY_ATTRIBUTE_NONE); + m_pCoreObject->SetValue("wave", CefV8Value::CreateFunction("wave", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("deactivate", CefV8Value::CreateFunction("deactivate", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("launchParty", CefV8Value::CreateFunction("launchParty", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE); m_pCoreObject->SetValue("leaveParty", CefV8Value::CreateFunction("leaveParty", m_pOverlayHandler), V8_PROPERTY_ATTRIBUTE_NONE);