Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
2 changes: 1 addition & 1 deletion .github/workflows/windows-playable-build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ jobs:
upload-build-for-next-job: true

package-and-upload:
runs-on: windows-latest
runs-on: windows-2022
needs: build-windows
steps:
- name: Download artifact from the previous job
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/windows.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ jobs:
build:
strategy:
matrix:
os: [windows-latest]
os: [windows-2022]
arch: [x64]

runs-on: ${{ matrix.os }}
Expand Down
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
Loading
Loading