Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions Code/client/Events/ScriptAnimationEvent.h
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 8 additions & 0 deletions Code/client/Events/WaveCommandEvent.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#pragma once

/**
* @brief Dispatched when the local player uses the /wave chat command.
*/
struct WaveCommandEvent
{
};
27 changes: 27 additions & 0 deletions Code/client/Games/Misc/BSScript.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
#include <PlayerCharacter.h>
#include <Games/ActorExtension.h>
#include <Games/PapyrusFunctions.h>
#include <Games/References.h>
#include <Misc/BSFixedString.h>
#include <Events/ScriptAnimationEvent.h>

TP_THIS_FUNCTION(TRegisterPapyrusFunction, void, BSScript::IVirtualMachine, NativeFunction*);
TP_THIS_FUNCTION(TBindEverythingToScript, void, BSScript::IVirtualMachine*);
Expand All @@ -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();
Expand All @@ -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<TDebugSendAnimationEvent*>(apFunction->functionAddress);
TP_HOOK_IMMEDIATE(&RealDebugSendAnimationEvent, HookDebugSendAnimationEvent);
spdlog::info("Hooked Debug.SendAnimationEvent at {}", apFunction->functionAddress);
}

TiltedPhoques::ThisCall(RealRegisterPapyrusFunction, apThis, apFunction);
}

Expand Down
76 changes: 76 additions & 0 deletions Code/client/Games/Skyrim/ObScript.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#include <TiltedOnlinePCH.h>

#include <Games/Skyrim/ObScript.h>
#include <Games/References.h>

#include <Events/ScriptAnimationEvent.h>

#include <World.h>

// 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 : "<null>", command.pShortName ? command.pShortName : "<null>");
}
});
53 changes: 53 additions & 0 deletions Code/client/Games/Skyrim/ObScript.h
Original file line number Diff line number Diff line change
@@ -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);
62 changes: 54 additions & 8 deletions Code/client/Services/Generic/ObjectService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <Events/ActivateEvent.h>
#include <Events/LockChangeEvent.h>
#include <Events/ScriptAnimationEvent.h>
#include <Events/WaveCommandEvent.h>
#include <Messages/ServerTimeSettings.h>
#include <Messages/AssignObjectsRequest.h>
#include <Messages/AssignObjectsResponse.h>
Expand Down Expand Up @@ -37,6 +38,7 @@ ObjectService::ObjectService(World& aWorld, entt::dispatcher& aDispatcher, Trans
m_assignObjectConnection = aDispatcher.sink<AssignObjectsResponse>().connect<&ObjectService::OnAssignObjectsResponse>(this);
m_scriptAnimationConnection = aDispatcher.sink<ScriptAnimationEvent>().connect<&ObjectService::OnScriptAnimationEvent>(this);
m_scriptAnimationNotifyConnection = aDispatcher.sink<NotifyScriptAnimation>().connect<&ObjectService::OnNotifyScriptAnimation>(this);
m_waveCommandConnection = aDispatcher.sink<WaveCommandEvent>().connect<&ObjectService::OnWaveCommand>(this);

EventDispatcherManager::Get()->activateEvent.RegisterSink(this);
}
Expand Down Expand Up @@ -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<FormIdComponent>();
const auto it = std::find_if(view.begin(), view.end(), [view, id = acEvent.FormID](auto entity) { return view.get<FormIdComponent>(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<uint32_t> 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<TESObjectREFR>(pForm);
auto view = m_world.view<FormIdComponent>();
const auto it = std::find_if(view.begin(), view.end(), [view](auto entity) { return view.get<FormIdComponent>(entity).Id == 0x14; });

if (!pObject)
if (it == view.end())
{
spdlog::debug("{}: local player entity not found", __FUNCTION__);
return;
}

std::optional<uint32_t> 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<TESObjectREFR>(acMessage.FormID);
if (!pObject)
return;

BSFixedString eventName(acMessage.EventName.c_str());
if (acMessage.Animation == String{})
{
pObject->PlayAnimation(&eventName);
pObject->SendAnimationEvent(&eventName);
}
else
{
Expand Down
8 changes: 8 additions & 0 deletions Code/client/Services/Generic/OverlayClient.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
#include <Messages/TeleportRequest.h>

#include <Events/SetTimeCommandEvent.h>
#include <Events/WaveCommandEvent.h>

#include <World.h>

Expand Down Expand Up @@ -51,6 +52,8 @@ bool OverlayClient::OnProcessMessageReceived(CefRefPtr<CefBrowser> 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")
Expand Down Expand Up @@ -137,6 +140,11 @@ void OverlayClient::ProcessSetTimeCommand(CefRefPtr<CefListValue> aEventArgs)
World::Get().GetDispatcher().trigger(SetTimeCommandEvent(hours, minutes, senderId));
}

void OverlayClient::ProcessWaveCommand()
{
World::Get().GetRunner().Trigger(WaveCommandEvent());
}

void OverlayClient::ProcessTeleportMessage(CefRefPtr<CefListValue> aEventArgs)
{
TeleportRequest request{};
Expand Down
3 changes: 3 additions & 0 deletions Code/client/Services/ObjectService.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct LockChangeEvent;
struct NotifyLockChange;
struct CellChangeEvent;
struct ScriptAnimationEvent;
struct WaveCommandEvent;
struct AssignObjectsResponse;
struct NotifyScriptAnimation;

Expand All @@ -33,6 +34,7 @@ class ObjectService final : public BSTEventSink<TESActivateEvent>
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<TESActivateEvent>*) override;
Expand All @@ -51,4 +53,5 @@ class ObjectService final : public BSTEventSink<TESActivateEvent>
entt::scoped_connection m_assignObjectConnection;
entt::scoped_connection m_scriptAnimationConnection;
entt::scoped_connection m_scriptAnimationNotifyConnection;
entt::scoped_connection m_waveCommandConnection;
};
1 change: 1 addition & 0 deletions Code/client/Services/OverlayClient.h
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct OverlayClient : TiltedPhoques::OverlayClient
void ProcessRevealPlayersMessage();
void ProcessChatMessage(CefRefPtr<CefListValue> aEventArgs);
void ProcessSetTimeCommand(CefRefPtr<CefListValue> aEventArgs);
void ProcessWaveCommand();
void ProcessTeleportMessage(CefRefPtr<CefListValue> aEventArgs);
void ProcessToggleDebugUI();
void SetUIVisible(bool aVisible) noexcept;
Expand Down
3 changes: 3 additions & 0 deletions Code/server/Services/ObjectService.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ void ObjectService::OnScriptAnimationRequest(const PacketEvent<ScriptAnimationRe

for (Player* pPlayer : m_world.GetPlayerManager())
{
if (pPlayer == acMessage.pPlayer)
continue;

pPlayer->Send(message);
}
}
4 changes: 4 additions & 0 deletions Code/skyrim_ui/src/app/mock/skyrimtogether.mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading