isle-portable/extensions/src/multiplayer/networkmanager.cpp
Christian Semmler 0c986aff2a
Add multi-world animation support (ACT2/ACT3)
Extend the multiplayer animation system to support animations from all
three game worlds (ACT1, ACT2, ACT3) while playing in the Isle world.

Catalog: Parse DTA files directly for all worlds instead of borrowing
from LegoAnimationManager. World-encoded animIndex (top 2 bits = world
slot) provides globally unique IDs without wire protocol changes.

Loader: Support multiple SI files (isle.si, act2main.si, act3.si) with
lazy opening and composite (worldId, objectId) cache keys.

WDB: Load missing model LODs from WORLD.WDB for all worlds during
catalog refresh, using LegoPartPresenter for parts and
LegoModelPresenter for compound models (ray, chptr).

Protocol: AnimCompleteMsg now carries animIndex instead of objectId.

Also fix pre-existing bugs:
- PhonemePlayer UAF when multiple tracks target the same ROI
- ModelDbModel buffer overflow on word-aligned strlcpy reads
- SIReader UBSan violation on uninitialized filetype enum

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:07:37 -07:00

2452 lines
69 KiB
C++

#include "extensions/multiplayer/networkmanager.h"
#include "actions/isle_actions.h"
#include "extensions/common/arearestriction.h"
#include "extensions/common/charactercustomizer.h"
#include "extensions/common/charactertables.h"
#include "extensions/multiplayer/namebubblerenderer.h"
#include "extensions/thirdpersoncamera.h"
#include "extensions/thirdpersoncamera/controller.h"
#include "legoactor.h"
#include "legoanimationmanager.h"
#include "legocachsound.h"
#include "legocarbuild.h"
#include "legocharactermanager.h"
#include "legoextraactor.h"
#include "legogamestate.h"
#include "legomain.h"
#include "legopathactor.h"
#include "legopathcontroller.h"
#include "legoworld.h"
#include "misc.h"
#include "mxmisc.h"
#include "mxticklemanager.h"
#include "roi/legoroi.h"
#include <SDL3/SDL_stdinc.h>
#include <SDL3/SDL_timer.h>
#include <algorithm>
#include <set>
#include <vector>
using namespace Extensions;
using namespace Multiplayer;
using Common::DetectVehicleType;
using Common::IsMultiPartEmote;
using Common::IsRestrictedArea;
using Common::WORLD_NOT_VISIBLE;
// Slightly larger than NPC_ANIM_PROXIMITY to catch transitions
static constexpr float NPC_ANIM_NEARBY_RADIUS_SQ =
(Animation::NPC_ANIM_PROXIMITY + 5.0f) * (Animation::NPC_ANIM_PROXIMITY + 5.0f);
static const char* IDLE_ANIM_STATE_JSON =
"{\"locations\":[],\"state\":0,\"currentAnimIndex\":65535,\"pendingInterest\":-1,\"animations\":[]}";
static void ExtractSlotPeerIds(const AnimUpdateMsg& p_msg, uint32_t p_out[8])
{
for (uint8_t i = 0; i < 8; i++) {
p_out[i] = (i < p_msg.slotCount) ? p_msg.slots[i].peerId : 0;
}
}
template <typename T>
void NetworkManager::SendMessage(const T& p_msg)
{
SendFixedMessage(m_transport, p_msg);
}
NetworkManager::NetworkManager()
: m_transport(nullptr), m_callbacks(nullptr), m_localNameBubble(nullptr), m_localPeerId(0), m_hostPeerId(0),
m_sequence(0), m_lastBroadcastTime(0), m_lastValidActorId(0), m_localAllowRemoteCustomize(true),
m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false),
m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_pendingToggleAllowCustomize(false),
m_pendingAnimInterest(-1), m_pendingAnimCancel(false), m_localPendingAnimInterest(-1), m_showNameBubbles(true),
m_lastCameraEnabled(false), m_lastVehicleState(0), m_wasInRestrictedArea(false), m_animStateDirty(false),
m_animInterestDirty(false), m_lastAnimPushTime(0), m_connectionState(STATE_DISCONNECTED), m_wasRejected(false),
m_reconnectAttempt(0), m_reconnectDelay(0), m_nextReconnectTime(0), m_hornTemplates{}, m_activeHorns()
{
m_animLoader.SetSIReader(&m_siReader);
}
NetworkManager::~NetworkManager()
{
Shutdown();
}
static ThirdPersonCamera::Controller* GetCamera()
{
return ThirdPersonCameraExt::GetCamera();
}
MxResult NetworkManager::Tickle()
{
ProcessPendingRequests();
CheckConnectionState();
EnforceDisableNPCs();
// Detect camera state changes for platform notification
ThirdPersonCamera::Controller* cam = GetCamera();
if (cam) {
bool cameraEnabled = cam->IsEnabled();
if (cameraEnabled != m_lastCameraEnabled) {
m_lastCameraEnabled = cameraEnabled;
m_animStateDirty = true;
NotifyThirdPersonChanged(cameraEnabled);
// Cancel animation when camera is disabled (vehicle entry, restricted area, etc.)
if (!cameraEnabled && m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) {
uint16_t localAnim = m_animCoordinator.GetCurrentAnimIndex();
CancelLocalAnimInterest();
if (localAnim != Animation::ANIM_INDEX_NONE) {
StopScenePlayback(localAnim, false);
}
}
if (m_localNameBubble) {
if (!cameraEnabled) {
m_localNameBubble->SetVisible(false);
}
else if (m_showNameBubbles) {
m_localNameBubble->SetVisible(true);
}
}
}
// Detect vehicle state changes for animation eligibility refresh.
// Tracks three states: on foot, on own vehicle, on foreign vehicle.
int8_t localChar = Animation::Catalog::DisplayActorToCharacterIndex(cam->GetDisplayActorIndex());
uint8_t vehicleState = Animation::Catalog::GetVehicleState(localChar, cam->GetRideVehicleROI());
if (vehicleState != m_lastVehicleState) {
m_lastVehicleState = vehicleState;
m_animStateDirty = true;
// Cancel active session if the current animation is no longer eligible.
// Only cancel if the local player is a performer — spectators aren't vehicle-constrained.
if (m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) {
uint16_t currentAnim = m_animCoordinator.GetCurrentAnimIndex();
if (currentAnim != Animation::ANIM_INDEX_NONE) {
const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(currentAnim);
if (entry && (entry->performerMask >> localChar) & 1) {
if (!Animation::Catalog::CheckVehicleEligibility(entry, localChar, vehicleState)) {
CancelLocalAnimInterest();
StopScenePlayback(currentAnim, false);
}
}
}
}
}
// Create local name bubble when display ROI becomes available
if (m_showNameBubbles && !m_localNameBubble && cam->GetDisplayROI()) {
char name[USERNAME_BUFFER_SIZE];
EncodeUsername(name);
m_localNameBubble = new NameBubbleRenderer();
m_localNameBubble->Create(name);
}
// Update local name bubble position
if (m_localNameBubble) {
LegoROI* bubbleROI = cam->GetDisplayROI();
// In large vehicles the display ROI is frozen; use the actual actor ROI instead
if (cam->IsInVehicle() && !cam->IsActive()) {
LegoPathActor* userActor = UserActor();
if (userActor) {
bubbleROI = userActor->GetROI();
}
}
if (bubbleROI) {
m_localNameBubble->Update(bubbleROI);
}
}
}
// Update local player location proximity
if (m_inIsleWorld) {
LegoPathActor* userActor = UserActor();
if (userActor && userActor->GetROI()) {
const float* pos = userActor->GetROI()->GetWorldPosition();
if (m_locationProximity.Update(pos[0], pos[2])) {
m_animStateDirty = true;
Animation::CoordinationState oldState = m_animCoordinator.GetState();
m_animCoordinator.OnLocationChanged(m_locationProximity.GetLocations(), &m_animCatalog);
// Location change cleared interest — send cancel to host
if (oldState != Animation::CoordinationState::e_idle &&
m_animCoordinator.GetState() == Animation::CoordinationState::e_idle) {
if (IsHost()) {
HandleAnimCancel(m_localPeerId);
}
else if (IsConnected()) {
AnimCancelMsg cancelMsg{};
cancelMsg.header = {MSG_ANIM_CANCEL, 0, m_localPeerId, m_sequence++, TARGET_HOST};
SendMessage(cancelMsg);
}
m_localPendingAnimInterest = -1;
}
}
}
if (IsHost()) {
TickHostSessions();
}
else if (m_animCoordinator.GetState() == Animation::CoordinationState::e_countdown) {
m_animStateDirty = true;
}
}
if (!m_transport) {
return SUCCESS;
}
uint32_t now = SDL_GetTicks();
// Broadcast before receiving so the Send proxy lets the main thread
// process WebSocket events before we drain the queue.
if (m_transport->IsConnected() && (now - m_lastBroadcastTime) >= BROADCAST_INTERVAL_MS) {
BroadcastLocalState();
m_lastBroadcastTime = now;
}
ProcessIncomingPackets();
UpdateRemotePlayers(Common::FIXED_TICK_DELTA);
TickAnimation();
// Re-read time; ProcessIncomingPackets may have advanced SDL_GetTicks.
uint32_t timeoutNow = SDL_GetTicks();
std::vector<uint32_t> timedOut;
for (auto& [peerId, player] : m_remotePlayers) {
uint32_t lastUpdate = player->GetLastUpdateTime();
if (timeoutNow >= lastUpdate && (timeoutNow - lastUpdate) > TIMEOUT_MS) {
timedOut.push_back(peerId);
}
}
for (uint32_t peerId : timedOut) {
RemoveRemotePlayer(peerId);
}
// Push animation state to frontend if dirty (throttled)
if (m_animStateDirty && m_inIsleWorld && m_callbacks) {
uint32_t pushNow = SDL_GetTicks();
bool cooldownExpired = (pushNow - m_lastAnimPushTime) >= ANIM_PUSH_COOLDOWN_MS;
if (cooldownExpired || m_animInterestDirty) {
m_animStateDirty = false;
m_animInterestDirty = false;
m_lastAnimPushTime = pushNow;
PushAnimationState();
}
}
return SUCCESS;
}
void NetworkManager::Initialize(NetworkTransport* p_transport, PlatformCallbacks* p_callbacks)
{
m_transport = p_transport;
m_callbacks = p_callbacks;
m_worldSync.SetTransport(p_transport);
}
void NetworkManager::HandleCreate()
{
if (!m_registered) {
TickleManager()->RegisterClient(this, 10);
m_registered = true;
}
}
void NetworkManager::Shutdown()
{
if (m_transport) {
Disconnect();
if (m_registered) {
TickleManager()->UnregisterClient(this);
m_registered = false;
}
m_transport = nullptr;
m_worldSync.SetTransport(nullptr);
}
CleanupHornSounds();
delete m_localNameBubble;
m_localNameBubble = nullptr;
RemoveAllRemotePlayers();
}
void NetworkManager::Connect(const char* p_roomId)
{
if (m_transport) {
m_roomId = p_roomId;
m_transport->Connect(p_roomId);
m_connectionState = STATE_CONNECTED;
}
}
void NetworkManager::Disconnect()
{
m_connectionState = STATE_DISCONNECTED;
if (m_transport) {
m_transport->Disconnect();
}
RemoveAllRemotePlayers();
ResetAnimationState();
}
bool NetworkManager::IsConnected() const
{
return m_transport && m_transport->IsConnected();
}
bool NetworkManager::WasRejected() const
{
return m_wasRejected;
}
void NetworkManager::ResetAnimationState()
{
m_animCoordinator.Reset();
m_animSessionHost.Reset();
m_localPendingAnimInterest = -1;
m_pendingAnimInterest.store(-1, std::memory_order_relaxed);
m_pendingAnimCancel.store(false, std::memory_order_relaxed);
m_animStateDirty = true;
}
void NetworkManager::BroadcastChangedSessions(const std::vector<uint16_t>& p_changedAnims)
{
for (uint16_t idx : p_changedAnims) {
BroadcastAnimUpdate(idx);
}
m_animStateDirty = true;
}
void NetworkManager::CancelLocalAnimInterest()
{
m_animCoordinator.ClearInterest();
m_localPendingAnimInterest = -1;
if (IsHost()) {
HandleAnimCancel(m_localPeerId);
}
else if (IsConnected()) {
AnimCancelMsg msg{};
msg.header = {MSG_ANIM_CANCEL, 0, m_localPeerId, m_sequence++, TARGET_HOST};
SendMessage(msg);
}
m_animStateDirty = true;
m_animInterestDirty = true;
}
void NetworkManager::StopAnimation()
{
ResetAnimationState();
StopAllPlayback();
}
void NetworkManager::OnWorldEnabled(LegoWorld* p_world)
{
if (!p_world) {
return;
}
if (p_world->GetWorldId() == LegoOmni::e_act1) {
m_inIsleWorld = true;
m_wasInRestrictedArea = IsRestrictedArea(GameState()->m_currentArea);
m_worldSync.SetInIsleWorld(true);
for (auto& [peerId, player] : m_remotePlayers) {
if (player->IsSpawned()) {
player->ReAddToScene();
}
else {
player->Spawn(p_world);
if (player->GetROI()) {
m_roiToPlayer[player->GetROI()] = player.get();
}
}
if (player->IsSpawned() && player->GetWorldId() == (int8_t) LegoOmni::e_act1) {
player->SetVisible(true);
player->SetNameBubbleVisible(m_showNameBubbles);
}
}
NotifyPlayerCountChanged();
EnforceDisableNPCs();
// Refresh animation catalog from DTA files for all supported worlds
m_animCatalog.Refresh();
m_animCoordinator.SetCatalog(&m_animCatalog);
m_animSessionHost.SetCatalog(&m_animCatalog);
m_locationProximity.Reset();
PreloadHornSounds();
}
}
void NetworkManager::OnWorldDisabled(LegoWorld* p_world)
{
if (!p_world) {
return;
}
if (p_world->GetWorldId() == LegoOmni::e_act1) {
m_inIsleWorld = false;
m_wasInRestrictedArea = false;
m_worldSync.SetInIsleWorld(false);
CleanupHornSounds();
// Stop animation before ROIs are destroyed (calls ResetAnimationState)
StopAnimation();
m_animStateDirty = false; // override: we push explicit empty JSON below
m_locationProximity.Reset();
if (m_callbacks) {
m_callbacks->OnAnimationsAvailable(
"{\"location\":-1,\"state\":0,\"currentAnimIndex\":65535,\"pendingInterest\":-1,\"animations\":[]}"
);
}
// Destroy local name bubble (ROI is about to be destroyed)
if (m_localNameBubble) {
m_localNameBubble->Destroy();
delete m_localNameBubble;
m_localNameBubble = nullptr;
}
for (auto& [peerId, player] : m_remotePlayers) {
player->SetVisible(false);
player->SetNameBubbleVisible(false);
}
NotifyPlayerCountChanged();
}
}
void NetworkManager::OnBeforeSaveLoad()
{
if (m_transport && m_transport->IsConnected() && !IsHost()) {
m_worldSync.SaveSkyLightState();
}
}
void NetworkManager::OnSaveLoaded()
{
if (!m_transport || !m_transport->IsConnected()) {
return;
}
// After a save file load, the local plant/building/sky/light state comes
// from the save file and may diverge from the host's state.
// Host broadcasts to all peers (targetPeerId=0); non-host restores the
// pre-load sky/light to avoid visual flicker, then requests a fresh snapshot.
if (IsHost()) {
m_worldSync.SendWorldSnapshotTo(0);
}
else {
m_worldSync.RestoreSkyLightState();
m_worldSync.OnHostChanged();
}
}
MxBool NetworkManager::HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType)
{
return m_worldSync.HandleEntityMutation(p_entity, p_changeType);
}
MxBool NetworkManager::HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType)
{
return m_worldSync.HandleSkyLightMutation(p_entityType, p_changeType);
}
void NetworkManager::EnforceDisableNPCs()
{
LegoAnimationManager* am = AnimationManager();
if (!am) {
return;
}
am->m_numAllowedExtras = 0;
am->m_enableCamAnims = FALSE;
am->m_unk0x400 = FALSE;
// Suppress build exit camera animations (triggered via FUN_10060dc0,
// which bypasses the m_enableCamAnims check)
static const char* buildStateNames[] =
{"LegoCopterBuildState", "LegoDuneCarBuildState", "LegoJetskiBuildState", "LegoRaceCarBuildState"};
for (const char* name : buildStateNames) {
LegoVehicleBuildState* state = (LegoVehicleBuildState*) GameState()->GetState(name);
if (state != NULL) {
state->m_playedExitScript = TRUE;
}
}
// Purge all extras including ambient NPCs (mama, papa, brickster)
// that are spawned by camera path triggers via FUN_10064380.
// PurgeExtra(TRUE) deliberately skips mama/papa, so we purge manually.
for (MxS32 i = 0; i < (MxS32) sizeOfArray(am->m_extras); i++) {
if (am->m_extras[i].m_roi != NULL) {
LegoPathActor* actor = CharacterManager()->GetExtraActor(am->m_extras[i].m_roi->GetName());
if (actor != NULL && actor->GetController() != NULL) {
actor->GetController()->RemoveActor(actor);
actor->SetController(NULL);
}
CharacterManager()->ReleaseActor(am->m_extras[i].m_roi);
am->m_extras[i].m_roi = NULL;
am->m_extras[i].m_characterId = -1;
am->m_unk0x414--;
}
}
}
void NetworkManager::CheckConnectionState()
{
if (!m_transport || m_connectionState == STATE_DISCONNECTED) {
return;
}
if (m_connectionState == STATE_CONNECTED) {
if (!m_transport->WasDisconnected()) {
return;
}
if (m_transport->WasRejected()) {
// Room full on initial connect - flag for game loop to exit
m_wasRejected = true;
m_connectionState = STATE_DISCONNECTED;
if (m_callbacks) {
m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_REJECTED);
}
return;
}
// Connection lost - enter reconnection
m_connectionState = STATE_RECONNECTING;
RemoveAllRemotePlayers();
m_reconnectAttempt = 0;
m_reconnectDelay = RECONNECT_INITIAL_DELAY_MS;
m_nextReconnectTime = SDL_GetTicks() + m_reconnectDelay;
if (m_callbacks) {
m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_RECONNECTING);
}
return;
}
// STATE_RECONNECTING
if (m_transport->IsConnected()) {
ResetStateAfterReconnect();
m_connectionState = STATE_CONNECTED;
if (m_callbacks) {
m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_CONNECTED);
}
return;
}
uint32_t now = SDL_GetTicks();
if (now < m_nextReconnectTime) {
return;
}
if (m_reconnectAttempt >= RECONNECT_MAX_ATTEMPTS) {
// Give up - stay alive but without multiplayer
m_connectionState = STATE_DISCONNECTED;
if (m_callbacks) {
m_callbacks->OnConnectionStatusChanged(CONNECTION_STATUS_FAILED);
}
return;
}
AttemptReconnect();
}
void NetworkManager::AttemptReconnect()
{
m_reconnectAttempt++;
m_transport->Disconnect();
m_transport->Connect(m_roomId.c_str());
m_reconnectDelay = SDL_min(m_reconnectDelay * 2, RECONNECT_MAX_DELAY_MS);
m_nextReconnectTime = SDL_GetTicks() + m_reconnectDelay;
}
void NetworkManager::ResetStateAfterReconnect()
{
m_localPeerId = 0;
m_hostPeerId = 0;
m_sequence = 0;
m_lastBroadcastTime = 0;
m_worldSync.ResetForReconnect();
ResetAnimationState();
}
void NetworkManager::ProcessPendingRequests()
{
ThirdPersonCamera::Controller* cam = GetCamera();
// Camera-dependent requests: only consume when cam is available so
// the request survives until the camera exists.
if (cam) {
if (m_pendingToggleThirdPerson.exchange(false, std::memory_order_relaxed)) {
if (cam->IsEnabled()) {
if (m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) {
uint16_t localAnim = m_animCoordinator.GetCurrentAnimIndex();
CancelLocalAnimInterest();
if (localAnim != Animation::ANIM_INDEX_NONE) {
StopScenePlayback(localAnim, false);
}
}
cam->Disable();
NotifyThirdPersonChanged(false);
}
else {
cam->Enable();
NotifyThirdPersonChanged(true);
}
}
int walkAnim = m_pendingWalkAnim.exchange(-1, std::memory_order_relaxed);
if (walkAnim >= 0) {
SetWalkAnimation(static_cast<uint8_t>(walkAnim));
}
int idleAnim = m_pendingIdleAnim.exchange(-1, std::memory_order_relaxed);
if (idleAnim >= 0) {
SetIdleAnimation(static_cast<uint8_t>(idleAnim));
}
int emote = m_pendingEmote.exchange(-1, std::memory_order_relaxed);
if (emote >= 0) {
SendEmote(static_cast<uint8_t>(emote));
}
}
int32_t animInterest = m_pendingAnimInterest.exchange(-1, std::memory_order_relaxed);
if (animInterest >= 0) {
// Discard during countdown or playback — player is committed
Animation::CoordinationState coordState = m_animCoordinator.GetState();
bool canChangeInterest =
(coordState == Animation::CoordinationState::e_idle ||
coordState == Animation::CoordinationState::e_interested);
if (canChangeInterest) {
uint16_t animIndex = static_cast<uint16_t>(animInterest);
m_animCoordinator.SetInterest(animIndex);
m_localPendingAnimInterest = animInterest;
if (IsHost()) {
uint8_t displayActorIndex = 0;
ThirdPersonCamera::Controller* animCam = GetCamera();
if (animCam) {
displayActorIndex = animCam->GetDisplayActorIndex();
}
HandleAnimInterest(m_localPeerId, animIndex, displayActorIndex);
// If slot assignment failed, clear optimistic interest
if (!m_animCoordinator.IsLocalPlayerInSession(animIndex)) {
m_animCoordinator.ClearInterest();
m_localPendingAnimInterest = -1;
}
}
else if (IsConnected()) {
AnimInterestMsg msg{};
msg.header = {MSG_ANIM_INTEREST, 0, m_localPeerId, m_sequence++, TARGET_HOST};
msg.animIndex = animIndex;
ThirdPersonCamera::Controller* animCam = GetCamera();
msg.displayActorIndex = animCam ? animCam->GetDisplayActorIndex() : 0;
SendMessage(msg);
}
m_animStateDirty = true;
m_animInterestDirty = true;
}
}
if (m_pendingAnimCancel.exchange(false, std::memory_order_relaxed)) {
CancelLocalAnimInterest();
}
if (m_pendingToggleAllowCustomize.exchange(false, std::memory_order_relaxed)) {
m_localAllowRemoteCustomize = !m_localAllowRemoteCustomize;
NotifyAllowCustomizeChanged(m_localAllowRemoteCustomize);
}
if (m_pendingToggleNameBubbles.exchange(false, std::memory_order_relaxed)) {
m_showNameBubbles = !m_showNameBubbles;
for (auto& [peerId, player] : m_remotePlayers) {
player->SetNameBubbleVisible(m_showNameBubbles);
}
if (m_localNameBubble) {
m_localNameBubble->SetVisible(m_showNameBubbles);
}
NotifyNameBubblesChanged(m_showNameBubbles);
}
}
void NetworkManager::BroadcastLocalState()
{
if (!m_transport) {
return;
}
LegoPathActor* userActor = UserActor();
LegoWorld* currentWorld = CurrentWorld();
if (!userActor || !currentWorld) {
return;
}
LegoROI* roi = userActor->GetROI();
if (!roi) {
return;
}
const float* pos = roi->GetWorldPosition();
const float* dir = roi->GetWorldDirection();
const float* up = roi->GetWorldUp();
float speed = userActor->GetWorldSpeed();
uint8_t actorId = static_cast<LegoActor*>(userActor)->GetActorId();
if (IsValidActorId(actorId)) {
m_lastValidActorId = actorId;
}
else {
actorId = m_lastValidActorId;
}
if (!IsValidActorId(actorId)) {
return;
}
ThirdPersonCamera::Controller* cam = GetCamera();
bool inRestrictedArea = IsRestrictedArea(GameState()->m_currentArea);
if (m_inIsleWorld && m_wasInRestrictedArea != inRestrictedArea) {
m_wasInRestrictedArea = inRestrictedArea;
NotifyPlayerCountChanged();
}
PlayerStateMsg msg{};
msg.header = {MSG_STATE, 0, m_localPeerId, m_sequence++, TARGET_BROADCAST};
msg.actorId = actorId;
msg.worldId = inRestrictedArea ? WORLD_NOT_VISIBLE : (int8_t) currentWorld->GetWorldId();
msg.vehicleType = DetectVehicleType(userActor);
SDL_memcpy(msg.position, pos, sizeof(msg.position));
SDL_memcpy(msg.direction, dir, sizeof(msg.direction));
SDL_memcpy(msg.up, up, sizeof(msg.up));
msg.speed = speed;
EncodeUsername(msg.name);
if (cam) {
msg.walkAnimId = cam->GetWalkAnimId();
msg.idleAnimId = cam->GetIdleAnimId();
msg.displayActorIndex = cam->GetDisplayActorIndex();
cam->GetCustomizeState().Pack(msg.customizeData);
// Encode multi-part emote frozen state
int8_t frozenId = cam->GetFrozenEmoteId();
if (frozenId >= 0) {
msg.customizeFlags |= CUSTOMIZE_FLAG_FROZEN;
msg.customizeFlags |= (frozenId & CUSTOMIZE_FLAG_FROZEN_EMOTE_MASK) << CUSTOMIZE_FLAG_FROZEN_EMOTE_SHIFT;
}
// Zero speed when in any phase of a multi-part emote or animation playback
if (cam->IsInMultiPartEmote() || cam->IsAnimPlaying()) {
msg.speed = 0.0f;
}
}
msg.customizeFlags |= m_localAllowRemoteCustomize ? CUSTOMIZE_FLAG_ALLOW_REMOTE : 0x00;
SendMessage(msg);
}
void NetworkManager::ProcessIncomingPackets()
{
if (!m_transport) {
return;
}
m_transport->Receive([this](const uint8_t* data, size_t length) {
uint8_t msgType = ParseMessageType(data, length);
switch (msgType) {
case MSG_ASSIGN_ID: {
if (length >= 5) {
uint32_t assignedId;
SDL_memcpy(&assignedId, data + 1, sizeof(uint32_t));
m_localPeerId = assignedId;
m_worldSync.SetLocalPeerId(assignedId);
m_animCoordinator.SetLocalPeerId(assignedId);
LegoAnimationManager::configureLegoAnimationManager(0);
if (AnimationManager()) {
AnimationManager()->m_maxAllowedExtras = 0;
AnimationManager()->m_numAllowedExtras = 0;
}
EnforceDisableNPCs();
}
break;
}
case MSG_HOST_ASSIGN: {
HostAssignMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_HOST_ASSIGN) {
HandleHostAssign(msg);
}
break;
}
case MSG_LEAVE: {
PlayerLeaveMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_LEAVE) {
HandleLeave(msg);
}
break;
}
case MSG_STATE: {
PlayerStateMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_STATE) {
HandleState(msg);
}
break;
}
case MSG_REQUEST_SNAPSHOT: {
RequestSnapshotMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_REQUEST_SNAPSHOT) {
m_worldSync.HandleRequestSnapshot(msg);
}
break;
}
case MSG_WORLD_SNAPSHOT: {
m_worldSync.HandleWorldSnapshot(data, length);
break;
}
case MSG_WORLD_EVENT: {
WorldEventMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_WORLD_EVENT) {
m_worldSync.HandleWorldEvent(msg);
}
break;
}
case MSG_WORLD_EVENT_REQUEST: {
WorldEventRequestMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_WORLD_EVENT_REQUEST) {
m_worldSync.HandleWorldEventRequest(msg);
}
break;
}
case MSG_EMOTE: {
EmoteMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_EMOTE) {
HandleEmote(msg);
}
break;
}
case MSG_HORN: {
HornMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_HORN) {
HandleHorn(msg);
}
break;
}
case MSG_CUSTOMIZE: {
CustomizeMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_CUSTOMIZE) {
HandleCustomize(msg);
}
break;
}
case MSG_ANIM_INTEREST: {
AnimInterestMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_INTEREST) {
HandleAnimInterest(msg.header.peerId, msg.animIndex, msg.displayActorIndex);
}
break;
}
case MSG_ANIM_CANCEL: {
AnimCancelMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_CANCEL) {
HandleAnimCancel(msg.header.peerId);
}
break;
}
case MSG_ANIM_UPDATE: {
AnimUpdateMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_UPDATE) {
HandleAnimUpdate(msg);
}
break;
}
case MSG_ANIM_START: {
AnimStartMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_START) {
HandleAnimStart(msg);
}
break;
}
case MSG_ANIM_COMPLETE: {
AnimCompleteMsg msg;
if (DeserializeMsg(data, length, msg) && msg.header.type == MSG_ANIM_COMPLETE) {
HandleAnimComplete(msg);
}
break;
}
default:
break;
}
});
}
void NetworkManager::UpdateRemotePlayers(float p_deltaTime)
{
float radius = m_locationProximity.GetRadius();
const auto& localLocs = m_locationProximity.GetLocations();
bool anyInIsle = false;
for (auto& [peerId, player] : m_remotePlayers) {
player->Tick(p_deltaTime);
// Derive locations from remote player's current position
// Skip players not in the isle world — their position is stale
if (player->IsSpawned() && player->GetROI() && player->GetWorldId() == (int8_t) LegoOmni::e_act1) {
anyInIsle = true;
auto oldLocs = player->GetLocations();
const float* pos = player->GetROI()->GetWorldPosition();
auto newLocs = Animation::LocationProximity::ComputeAll(pos[0], pos[2], radius);
player->SetLocations(std::move(newLocs));
if (oldLocs != player->GetLocations()) {
// Dirty if remote's locations changed and any overlap with local player's locations
for (int16_t loc : localLocs) {
if (player->IsAtLocation(loc) || std::find(oldLocs.begin(), oldLocs.end(), loc) != oldLocs.end()) {
m_animStateDirty = true;
break;
}
}
}
}
}
// Keep pushing while remote players are in the isle world so proximity-based
// eligibility and session display stay up to date as players move around
if (anyInIsle) {
m_animStateDirty = true;
}
}
RemotePlayer* NetworkManager::CreateAndSpawnPlayer(uint32_t p_peerId, uint8_t p_actorId, uint8_t p_displayActorIndex)
{
auto player = std::make_unique<RemotePlayer>(p_peerId, p_actorId, p_displayActorIndex);
if (m_inIsleWorld) {
LegoWorld* world = CurrentWorld();
if (world && world->GetWorldId() == LegoOmni::e_act1) {
player->Spawn(world);
}
}
RemotePlayer* ptr = player.get();
m_remotePlayers[p_peerId] = std::move(player);
if (ptr->GetROI()) {
m_roiToPlayer[ptr->GetROI()] = ptr;
}
return ptr;
}
void NetworkManager::HandleLeave(const PlayerLeaveMsg& p_msg)
{
RemoveRemotePlayer(p_msg.header.peerId);
}
void NetworkManager::HandleState(const PlayerStateMsg& p_msg)
{
uint32_t peerId = p_msg.header.peerId;
auto it = m_remotePlayers.find(peerId);
if (it == m_remotePlayers.end()) {
if (!IsValidActorId(p_msg.actorId)) {
return;
}
CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex);
NotifyPlayerCountChanged();
it = m_remotePlayers.find(peerId);
// Send existing session state so the new player sees active sessions
if (IsHost()) {
for (const auto& [animIndex, session] : m_animSessionHost.GetSessions()) {
SendAnimUpdateToPlayer(animIndex, peerId);
}
}
}
// Respawn only if display actor changed (not on actorId change)
if (it->second->GetDisplayActorIndex() != p_msg.displayActorIndex) {
if (it->second->GetROI()) {
m_roiToPlayer.erase(it->second->GetROI());
}
it->second->Despawn();
m_remotePlayers.erase(it);
CreateAndSpawnPlayer(peerId, p_msg.actorId, p_msg.displayActorIndex);
it = m_remotePlayers.find(peerId);
m_animStateDirty = true;
if (IsHost()) {
std::vector<uint16_t> changedAnims;
if (m_animSessionHost.HandlePlayerRemoved(peerId, changedAnims)) {
BroadcastChangedSessions(changedAnims);
}
}
}
else if (IsValidActorId(p_msg.actorId)) {
it->second->SetActorId(p_msg.actorId); // Update for future use, no visual change
}
int8_t oldWorldId = it->second->GetWorldId();
it->second->UpdateFromNetwork(p_msg);
bool bothInIsle = m_inIsleWorld && (p_msg.worldId == (int8_t) LegoOmni::e_act1);
if (it->second->IsSpawned()) {
it->second->SetVisible(bothInIsle);
it->second->SetNameBubbleVisible(bothInIsle && m_showNameBubbles);
}
bool wasInIsle = (oldWorldId == (int8_t) LegoOmni::e_act1);
bool nowInIsle = (p_msg.worldId == (int8_t) LegoOmni::e_act1);
if (m_inIsleWorld && wasInIsle != nowInIsle) {
NotifyPlayerCountChanged();
m_animStateDirty = true;
// Player left the isle world — remove from animation sessions
if (wasInIsle && !nowInIsle && IsHost()) {
std::vector<uint16_t> changedAnims;
if (m_animSessionHost.HandlePlayerRemoved(peerId, changedAnims)) {
BroadcastChangedSessions(changedAnims);
}
}
}
}
void NetworkManager::HandleHostAssign(const HostAssignMsg& p_msg)
{
uint32_t oldHost = m_hostPeerId;
m_hostPeerId = p_msg.hostPeerId;
m_worldSync.SetHost(IsHost());
if (oldHost != m_hostPeerId) {
if (!IsHost()) {
m_worldSync.OnHostChanged();
}
// Reset coordination on actual host change, not initial assignment.
// Initial assignment (oldHost==0) may race with session updates from the host.
if (oldHost != 0) {
ResetAnimationState();
}
}
}
void NetworkManager::SetWalkAnimation(uint8_t p_walkAnimId)
{
ThirdPersonCamera::Controller* cam = GetCamera();
if (cam && p_walkAnimId < Common::g_walkAnimCount) {
cam->SetWalkAnimId(p_walkAnimId);
}
}
void NetworkManager::SetIdleAnimation(uint8_t p_idleAnimId)
{
ThirdPersonCamera::Controller* cam = GetCamera();
if (cam && p_idleAnimId < Common::g_idleAnimCount) {
cam->SetIdleAnimId(p_idleAnimId);
}
}
void NetworkManager::SendEmote(uint8_t p_emoteId)
{
if (p_emoteId >= Common::g_emoteAnimCount) {
return;
}
ThirdPersonCamera::Controller* cam = GetCamera();
if (!cam) {
return;
}
// Multi-part emotes require 3rd person camera to be active (they need the display clone).
// In 1st person mode, skip them entirely to avoid broadcasting an emote the local player can't play.
if (!cam->IsActive() && IsMultiPartEmote(p_emoteId)) {
return;
}
cam->TriggerEmote(p_emoteId);
EmoteMsg msg{};
msg.header = {MSG_EMOTE, 0, m_localPeerId, m_sequence++, TARGET_BROADCAST};
msg.emoteId = p_emoteId;
SendMessage(msg);
}
void NetworkManager::HandleEmote(const EmoteMsg& p_msg)
{
uint32_t peerId = p_msg.header.peerId;
auto it = m_remotePlayers.find(peerId);
if (it != m_remotePlayers.end()) {
it->second->TriggerEmote(p_msg.emoteId);
}
}
void NetworkManager::SendHorn(int8_t p_vehicleType)
{
if (!IsConnected() || !m_inIsleWorld) {
return;
}
HornMsg msg{};
msg.header = {MSG_HORN, 0, m_localPeerId, m_sequence++, TARGET_BROADCAST};
msg.vehicleType = static_cast<uint8_t>(p_vehicleType);
SendMessage(msg);
}
// Vehicle type and dashboard composite ID for each horn-capable vehicle
struct HornVehicleInfo {
int8_t vehicleType;
uint32_t dashboardObjectId;
};
static const HornVehicleInfo g_hornVehicles[] = {
{VEHICLE_BIKE, IsleScript::c_BikeDashboard},
{VEHICLE_AMBULANCE, IsleScript::c_AmbulanceDashboard},
{VEHICLE_TOWTRACK, IsleScript::c_TowTrackDashboard},
{VEHICLE_DUNEBUGGY, IsleScript::c_DuneCarDashboard},
};
void NetworkManager::HandleHorn(const HornMsg& p_msg)
{
// Sweep finished horn sounds
for (auto it = m_activeHorns.begin(); it != m_activeHorns.end();) {
if (!ma_sound_is_playing((*it)->m_cacheSound)) {
(*it)->Stop();
delete *it;
it = m_activeHorns.erase(it);
}
else {
++it;
}
}
uint32_t peerId = p_msg.header.peerId;
auto it = m_remotePlayers.find(peerId);
if (it == m_remotePlayers.end()) {
return;
}
// Find horn template for this vehicle type
int templateIdx = -1;
for (int i = 0; i < HORN_VEHICLE_COUNT; i++) {
if (g_hornVehicles[i].vehicleType == static_cast<int8_t>(p_msg.vehicleType)) {
templateIdx = i;
break;
}
}
if (templateIdx < 0 || !m_hornTemplates[templateIdx]) {
return;
}
LegoCacheSound* horn = m_hornTemplates[templateIdx]->Clone();
if (horn) {
ma_sound_set_doppler_factor(horn->m_cacheSound, 0);
horn->Play(it->second->GetUniqueName(), FALSE);
m_activeHorns.push_back(horn);
}
}
void NetworkManager::PreloadHornSounds()
{
for (int i = 0; i < HORN_VEHICLE_COUNT; i++) {
m_hornTemplates[i] = nullptr;
AudioTrack* track = m_siReader.ExtractFirstAudio(g_hornVehicles[i].dashboardObjectId);
if (!track) {
continue;
}
LegoCacheSound* sound = new LegoCacheSound();
MxString mediaSrcPath(track->mediaSrcPath.c_str());
MxWavePresenter::WaveFormat format = track->format;
if (sound->Create(format, mediaSrcPath, track->volume, track->pcmData, track->pcmDataSize) == SUCCESS) {
ma_sound_set_doppler_factor(sound->m_cacheSound, 0);
m_hornTemplates[i] = sound;
}
else {
delete sound;
}
delete[] track->pcmData;
delete track;
}
}
void NetworkManager::CleanupHornSounds()
{
for (auto* horn : m_activeHorns) {
horn->Stop();
delete horn;
}
m_activeHorns.clear();
for (int i = 0; i < HORN_VEHICLE_COUNT; i++) {
delete m_hornTemplates[i];
m_hornTemplates[i] = nullptr;
}
}
void NetworkManager::RemoveRemotePlayer(uint32_t p_peerId)
{
auto it = m_remotePlayers.find(p_peerId);
if (it != m_remotePlayers.end()) {
const auto& localLocs = m_locationProximity.GetLocations();
for (int16_t loc : it->second->GetLocations()) {
if (std::find(localLocs.begin(), localLocs.end(), loc) != localLocs.end()) {
m_animStateDirty = true;
break;
}
}
if (it->second->GetROI()) {
m_roiToPlayer.erase(it->second->GetROI());
}
it->second->Despawn();
m_remotePlayers.erase(it);
NotifyPlayerCountChanged();
if (IsHost()) {
std::vector<uint16_t> changedAnims;
if (m_animSessionHost.HandlePlayerRemoved(p_peerId, changedAnims)) {
BroadcastChangedSessions(changedAnims);
}
}
}
}
void NetworkManager::RemoveAllRemotePlayers()
{
for (auto& [peerId, player] : m_remotePlayers) {
player->Despawn();
}
m_remotePlayers.clear();
m_roiToPlayer.clear();
m_animStateDirty = true;
NotifyPlayerCountChanged();
}
void NetworkManager::NotifyPlayerCountChanged()
{
if (!m_callbacks) {
return;
}
int count = -1;
if (m_inIsleWorld) {
count = 0;
// Only count the local player if they have a valid actor and
// are not in a restricted overlay area (elevator, observatory, etc.).
if (!IsRestrictedArea(GameState()->m_currentArea)) {
LegoPathActor* userActor = UserActor();
if (userActor && IsValidActorId(static_cast<LegoActor*>(userActor)->GetActorId())) {
count = 1;
}
else if (IsValidActorId(GameState()->GetActorId())) {
count = 1;
}
}
for (auto& [peerId, player] : m_remotePlayers) {
if (player->GetWorldId() == (int8_t) LegoOmni::e_act1) {
count++;
}
}
}
m_callbacks->OnPlayerCountChanged(count);
}
void NetworkManager::NotifyThirdPersonChanged(bool p_enabled)
{
if (!m_callbacks) {
return;
}
m_callbacks->OnThirdPersonChanged(p_enabled);
}
void NetworkManager::NotifyNameBubblesChanged(bool p_enabled)
{
if (!m_callbacks) {
return;
}
m_callbacks->OnNameBubblesChanged(p_enabled);
}
void NetworkManager::NotifyAllowCustomizeChanged(bool p_enabled)
{
if (!m_callbacks) {
return;
}
m_callbacks->OnAllowCustomizeChanged(p_enabled);
}
RemotePlayer* NetworkManager::FindPlayerByROI(LegoROI* roi) const
{
auto it = m_roiToPlayer.find(roi);
if (it != m_roiToPlayer.end()) {
return it->second;
}
return nullptr;
}
bool NetworkManager::IsClonedCharacter(const char* p_name) const
{
// Check remote player clones
for (auto& it : m_remotePlayers) {
if (!SDL_strcasecmp(it.second->GetUniqueName(), p_name)) {
return true;
}
}
return false;
}
void NetworkManager::SendCustomize(uint32_t p_targetPeerId, uint8_t p_changeType, uint8_t p_partIndex)
{
CustomizeMsg msg{};
msg.header = {MSG_CUSTOMIZE, 0, m_localPeerId, m_sequence++, TARGET_BROADCAST_ALL};
msg.targetPeerId = p_targetPeerId;
msg.changeType = p_changeType;
msg.partIndex = p_partIndex;
SendMessage(msg);
}
void NetworkManager::StopScenePlayback(uint16_t p_animIndex, bool p_unlockRemotes)
{
auto it = m_playingAnims.find(p_animIndex);
if (it == m_playingAnims.end()) {
return;
}
// Save before Stop() which resets the flag
bool wasObserver = it->second->IsObserverMode();
if (it->second->IsPlaying()) {
it->second->Stop();
}
if (p_unlockRemotes) {
UnlockRemotesForAnim(p_animIndex);
}
// Release camera if local player was a participant (not observer) in this animation
if (!wasObserver) {
ThirdPersonCamera::Controller* cam = GetCamera();
if (cam) {
cam->SetAnimPlaying(false);
}
}
m_playingAnims.erase(it);
}
void NetworkManager::StopAllPlayback()
{
for (auto& [animIndex, scenePlayer] : m_playingAnims) {
if (scenePlayer->IsPlaying()) {
scenePlayer->Stop();
}
}
m_playingAnims.clear();
for (auto& [peerId, player] : m_remotePlayers) {
player->ForceUnlockAnimation();
}
ThirdPersonCamera::Controller* cam = GetCamera();
if (cam) {
cam->SetAnimPlaying(false);
}
}
void NetworkManager::UnlockRemotesForAnim(uint16_t p_animIndex)
{
for (auto& [peerId, player] : m_remotePlayers) {
player->UnlockFromAnimation(p_animIndex);
}
}
void NetworkManager::TickAnimation()
{
if (m_playingAnims.empty()) {
return;
}
// Collect completed animations with their observer mode (Tick/Stop resets the flag)
std::vector<std::pair<uint16_t, bool>> completed;
for (auto& [animIndex, scenePlayer] : m_playingAnims) {
if (!scenePlayer->IsPlaying()) {
completed.push_back({animIndex, scenePlayer->IsObserverMode()});
continue;
}
bool wasObserver = scenePlayer->IsObserverMode();
scenePlayer->Tick();
if (!scenePlayer->IsPlaying()) {
completed.push_back({animIndex, wasObserver});
}
}
for (auto& [animIndex, wasObserver] : completed) {
UnlockRemotesForAnim(animIndex);
// Release camera if local player was a participant (not observer)
if (!wasObserver) {
ThirdPersonCamera::Controller* cam = GetCamera();
if (cam) {
cam->SetAnimPlaying(false);
}
m_animCoordinator.ResetLocalState();
m_animCoordinator.RemoveSession(animIndex);
}
if (IsHost()) {
BroadcastAnimComplete(animIndex); // Must fire before EraseSession destroys participant data
m_animSessionHost.EraseSession(animIndex);
BroadcastAnimUpdate(animIndex); // Broadcast cleared state
}
m_playingAnims.erase(animIndex);
m_animStateDirty = true;
m_animInterestDirty = true;
}
}
void NetworkManager::TickHostSessions()
{
// Check co-location for all sessions: start/revert countdown as needed.
// For cam anims, also auto-remove players who left the required location.
// Use a snapshot of keys since we may modify sessions during iteration.
std::vector<uint16_t> sessionKeys;
for (const auto& [animIndex, session] : m_animSessionHost.GetSessions()) {
sessionKeys.push_back(animIndex);
}
for (uint16_t animIndex : sessionKeys) {
const Animation::AnimSession* session = m_animSessionHost.FindSession(animIndex);
if (!session || session->state == Animation::CoordinationState::e_playing) {
continue;
}
// For cam anims: auto-remove players who left the required location
const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(animIndex);
if (entry && entry->location >= 0) {
std::vector<uint32_t> toRemove;
for (const auto& slot : session->slots) {
if (slot.peerId != 0 && !IsPeerAtLocation(slot.peerId, entry->location)) {
toRemove.push_back(slot.peerId);
}
}
for (uint32_t pid : toRemove) {
std::vector<uint16_t> changed;
m_animSessionHost.HandleCancel(pid, changed);
BroadcastChangedSessions(changed);
}
session = m_animSessionHost.FindSession(animIndex);
if (!session) {
continue;
}
}
// Auto-remove participants whose vehicle state no longer matches
if (entry && entry->vehicleMask) {
std::vector<uint32_t> toRemove;
for (const auto& slot : session->slots) {
if (slot.peerId != 0 && !slot.IsSpectator()) {
int8_t charIdx = slot.charIndex;
uint8_t onVehicle = GetPeerVehicleState(slot.peerId, charIdx);
if (!Animation::Catalog::CheckVehicleEligibility(entry, charIdx, onVehicle)) {
toRemove.push_back(slot.peerId);
}
}
}
for (uint32_t pid : toRemove) {
std::vector<uint16_t> changed;
m_animSessionHost.HandleCancel(pid, changed);
BroadcastChangedSessions(changed);
}
session = m_animSessionHost.FindSession(animIndex);
if (!session) {
continue;
}
}
bool allFilled = m_animSessionHost.AreAllSlotsFilled(animIndex);
bool coLocated = allFilled && ValidateSessionLocations(animIndex);
if (session->state == Animation::CoordinationState::e_interested && coLocated) {
m_animSessionHost.StartCountdown(animIndex);
if (m_animCoordinator.IsLocalPlayerInSession(animIndex)) {
const Animation::CatalogEntry* ce = m_animCatalog.FindEntry(animIndex);
const AnimInfo* ai = ce ? m_animCatalog.GetAnimInfo(animIndex) : nullptr;
if (ai) {
m_animLoader.PreloadAsync(ce->worldId, ai->m_objectId);
}
}
BroadcastAnimUpdate(animIndex);
m_animStateDirty = true;
}
else if (session->state == Animation::CoordinationState::e_countdown && !coLocated) {
m_animSessionHost.RevertCountdown(animIndex);
BroadcastAnimUpdate(animIndex);
m_animStateDirty = true;
}
}
// Check countdown expiry — multiple animations may be ready simultaneously
std::vector<uint16_t> readyAnims = m_animSessionHost.Tick(SDL_GetTicks());
for (uint16_t readyAnim : readyAnims) {
BroadcastAnimStart(readyAnim);
HandleAnimStartLocally(readyAnim, m_animCoordinator.IsLocalPlayerInSession(readyAnim));
}
// During countdown, push state every tick so countdownMs reaches the frontend
if (m_animSessionHost.HasCountdownSession()) {
m_animStateDirty = true;
}
}
void NetworkManager::HandleAnimInterest(uint32_t p_peerId, uint16_t p_animIndex, uint8_t p_displayActorIndex)
{
if (!IsHost()) {
return;
}
// For location-bound animations, player must be at that location
const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex);
if (entry && entry->location >= 0) {
if (!IsPeerAtLocation(p_peerId, entry->location)) {
return;
}
}
// Validate vehicle eligibility if the joining player would be a performer
if (entry) {
int8_t charIndex = Animation::Catalog::DisplayActorToCharacterIndex(p_displayActorIndex);
if ((entry->performerMask >> charIndex) & 1) {
uint8_t onVehicle = GetPeerVehicleState(p_peerId, charIndex);
if (!Animation::Catalog::CheckVehicleEligibility(entry, charIndex, onVehicle)) {
return;
}
}
}
// For NPC anims: if all slots are full, remove far-away participants to make room
// for the new nearby player. This only fires when slots are exhausted — if there's
// an open slot, the new player just joins normally without disturbing anyone.
if (entry && entry->location == -1 && m_animSessionHost.AreAllSlotsFilled(p_animIndex)) {
float newX, newZ;
if (GetPeerPosition(p_peerId, newX, newZ)) {
const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex);
if (session) {
std::vector<uint32_t> stale;
for (const auto& slot : session->slots) {
if (slot.peerId != 0 && slot.peerId != p_peerId && !IsPeerNearby(slot.peerId, newX, newZ)) {
stale.push_back(slot.peerId);
}
}
for (uint32_t pid : stale) {
std::vector<uint16_t> changed;
m_animSessionHost.HandleCancel(pid, changed);
BroadcastChangedSessions(changed);
}
}
}
}
std::vector<uint16_t> changedAnims;
if (m_animSessionHost.HandleInterest(p_peerId, p_animIndex, p_displayActorIndex, changedAnims)) {
BroadcastChangedSessions(changedAnims);
m_animInterestDirty = true;
}
}
void NetworkManager::HandleAnimCancel(uint32_t p_peerId)
{
if (!IsHost()) {
return;
}
uint16_t localAnimBefore = m_animCoordinator.GetCurrentAnimIndex();
Animation::CoordinationState oldState = m_animCoordinator.GetState();
std::vector<uint16_t> changedAnims;
if (m_animSessionHost.HandleCancel(p_peerId, changedAnims)) {
BroadcastChangedSessions(changedAnims);
m_animInterestDirty = true;
}
// Stop local player's animation if their session was erased
if (oldState == Animation::CoordinationState::e_playing &&
m_animCoordinator.GetState() == Animation::CoordinationState::e_idle &&
localAnimBefore != Animation::ANIM_INDEX_NONE) {
StopScenePlayback(localAnimBefore, true);
}
// Stop observer-mode playback for any erased playing sessions
for (uint16_t animIndex : changedAnims) {
if (animIndex != localAnimBefore && m_playingAnims.count(animIndex)) {
StopScenePlayback(animIndex, true);
}
}
}
void NetworkManager::HandleAnimUpdate(const AnimUpdateMsg& p_msg)
{
if (IsHost()) {
return; // Host already updated its own state
}
uint16_t localAnimBefore = m_animCoordinator.GetCurrentAnimIndex();
Animation::CoordinationState oldState = m_animCoordinator.GetState();
uint32_t slots[8];
ExtractSlotPeerIds(p_msg, slots);
m_animCoordinator.ApplySessionUpdate(p_msg.animIndex, p_msg.state, p_msg.countdownMs, slots, p_msg.slotCount);
if (p_msg.state == static_cast<uint8_t>(Animation::CoordinationState::e_countdown)) {
const Animation::CatalogEntry* ce = m_animCatalog.FindEntry(p_msg.animIndex);
const AnimInfo* ai = ce ? m_animCatalog.GetAnimInfo(p_msg.animIndex) : nullptr;
if (ai) {
m_animLoader.PreloadAsync(ce->worldId, ai->m_objectId);
}
}
// If local player's pending interest matches, clear it (host has responded)
if (m_localPendingAnimInterest >= 0 && static_cast<uint16_t>(m_localPendingAnimInterest) == p_msg.animIndex) {
m_localPendingAnimInterest = -1;
}
// Stop local player's animation if their session was cleared
if (oldState == Animation::CoordinationState::e_playing &&
m_animCoordinator.GetState() == Animation::CoordinationState::e_idle &&
localAnimBefore != Animation::ANIM_INDEX_NONE) {
StopScenePlayback(localAnimBefore, true);
}
// Stop observer playback when the observed session is cleared
if (m_playingAnims.count(p_msg.animIndex) && p_msg.state == 0) {
StopScenePlayback(p_msg.animIndex, true);
}
m_animStateDirty = true;
m_animInterestDirty = true;
}
void NetworkManager::HandleAnimStart(const AnimStartMsg& p_msg)
{
if (IsHost()) {
return; // Host handles locally in BroadcastAnimStart
}
m_animCoordinator.ApplyAnimStart(p_msg.animIndex);
HandleAnimStartLocally(p_msg.animIndex, m_animCoordinator.IsLocalPlayerInSession(p_msg.animIndex));
m_animStateDirty = true;
m_animInterestDirty = true;
}
void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localInSession)
{
auto abortSession = [&]() {
// Observers must not abort the authoritative session — only participants may do that
if (p_localInSession) {
if (IsHost()) {
m_animSessionHost.EraseSession(p_animIndex);
BroadcastAnimUpdate(p_animIndex);
}
m_animCoordinator.ResetLocalState();
}
m_animStateDirty = true;
};
const AnimInfo* animInfo = m_animCatalog.GetAnimInfo(p_animIndex);
if (!animInfo) {
abortSession();
return;
}
const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex);
if (!entry) {
abortSession();
return;
}
ThirdPersonCamera::Controller* cam = GetCamera();
if (p_localInSession && (!cam || !cam->GetDisplayROI())) {
abortSession();
return;
}
const Animation::SessionView* view = m_animCoordinator.GetSessionView(p_animIndex);
std::vector<int8_t> slotChars = Animation::SessionHost::ComputeSlotCharIndices(entry);
bool observerMode = !p_localInSession;
// Build participants: local player first (if participating), then remotes
int8_t localCharIndex = -1;
std::vector<Animation::ParticipantROI> participants;
if (view) {
uint8_t count = view->slotCount < (uint8_t) slotChars.size() ? view->slotCount : (uint8_t) slotChars.size();
for (uint8_t i = 0; i < count; i++) {
uint32_t peerId = view->peerSlots[i];
if (peerId == 0) {
continue;
}
if (peerId == m_localPeerId) {
localCharIndex = slotChars[i];
continue;
}
auto it = m_remotePlayers.find(peerId);
if (it == m_remotePlayers.end() || !it->second->GetROI()) {
continue;
}
Animation::ParticipantROI rp;
rp.roi = it->second->GetROI();
rp.vehicleROI = it->second->GetRideVehicleROI();
rp.charIndex = slotChars[i];
participants.push_back(rp);
// Lock performers to prevent network updates from fighting animation
if (!rp.IsSpectator()) {
it->second->LockForAnimation(p_animIndex);
}
}
}
// Insert local player at index 0 only when participating
if (!observerMode) {
Animation::ParticipantROI local;
local.roi = cam->GetDisplayROI();
local.vehicleROI = cam->GetRideVehicleROI();
local.charIndex = localCharIndex;
participants.insert(participants.begin(), local);
}
if (participants.empty()) {
abortSession();
return;
}
auto scenePlayer = std::make_unique<Animation::ScenePlayer>();
scenePlayer->SetLoader(&m_animLoader);
if (!observerMode) {
bool localIsPerformer = (localCharIndex >= 0);
cam->SetAnimPlaying(true, localIsPerformer, [this, p_animIndex]() {
auto it = m_playingAnims.find(p_animIndex);
if (it != m_playingAnims.end()) {
it->second->Stop();
}
});
}
scenePlayer->Play(
animInfo,
entry->worldId,
entry->category,
participants.data(),
(uint8_t) participants.size(),
observerMode
);
if (!scenePlayer->IsPlaying()) {
if (!observerMode) {
cam->SetAnimPlaying(false);
}
UnlockRemotesForAnim(p_animIndex);
abortSession();
return;
}
m_playingAnims[p_animIndex] = std::move(scenePlayer);
m_localPendingAnimInterest = -1;
m_animStateDirty = true;
}
AnimUpdateMsg NetworkManager::BuildAnimUpdateMsg(uint16_t p_animIndex, uint32_t p_target)
{
AnimUpdateMsg msg{};
msg.header = {MSG_ANIM_UPDATE, 0, m_localPeerId, m_sequence++, p_target};
msg.animIndex = p_animIndex;
const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex);
if (session) {
msg.state = static_cast<uint8_t>(session->state);
msg.countdownMs = Animation::SessionHost::ComputeCountdownMs(*session, SDL_GetTicks());
msg.slotCount = static_cast<uint8_t>(session->slots.size() < 8 ? session->slots.size() : 8);
for (uint8_t i = 0; i < msg.slotCount; i++) {
msg.slots[i].peerId = session->slots[i].peerId;
}
}
// else: zero-initialized = cleared state
return msg;
}
void NetworkManager::BroadcastAnimUpdate(uint16_t p_animIndex)
{
AnimUpdateMsg msg = BuildAnimUpdateMsg(p_animIndex, TARGET_BROADCAST);
SendMessage(msg);
// Also update local coordinator
uint32_t slots[8];
ExtractSlotPeerIds(msg, slots);
m_animCoordinator.ApplySessionUpdate(msg.animIndex, msg.state, msg.countdownMs, slots, msg.slotCount);
}
void NetworkManager::SendAnimUpdateToPlayer(uint16_t p_animIndex, uint32_t p_targetPeerId)
{
SendMessage(BuildAnimUpdateMsg(p_animIndex, p_targetPeerId));
}
void NetworkManager::BroadcastAnimStart(uint16_t p_animIndex)
{
AnimStartMsg msg{};
msg.header = {MSG_ANIM_START, 0, m_localPeerId, m_sequence++, TARGET_BROADCAST};
msg.animIndex = p_animIndex;
SendMessage(msg);
// Also update local coordinator
m_animCoordinator.ApplyAnimStart(p_animIndex);
}
void NetworkManager::BroadcastAnimComplete(uint16_t p_animIndex)
{
const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex);
if (!session) {
return;
}
const AnimInfo* animInfo = m_animCatalog.GetAnimInfo(p_animIndex);
if (!animInfo) {
return;
}
AnimCompleteMsg msg{};
msg.header = {MSG_ANIM_COMPLETE, 0, m_localPeerId, m_sequence++, TARGET_BROADCAST};
msg.eventId = (static_cast<uint64_t>(SDL_rand_bits()) << 32) | static_cast<uint64_t>(SDL_rand_bits());
msg.animIndex = p_animIndex;
msg.participantCount = 0;
char localName[8];
EncodeUsername(localName);
for (const auto& slot : session->slots) {
if (slot.peerId == 0 || msg.participantCount >= 8) {
continue;
}
AnimCompletionParticipant& p = msg.participants[msg.participantCount];
p.peerId = slot.peerId;
if (slot.IsSpectator()) {
// Resolve spectator's actual character from their display actor
if (slot.peerId == m_localPeerId) {
ThirdPersonCamera::Controller* cam = GetCamera();
p.charIndex = cam ? Animation::Catalog::DisplayActorToCharacterIndex(cam->GetDisplayActorIndex()) : -1;
}
else {
auto it = m_remotePlayers.find(slot.peerId);
p.charIndex = it != m_remotePlayers.end()
? Animation::Catalog::DisplayActorToCharacterIndex(it->second->GetDisplayActorIndex())
: -1;
}
}
else {
p.charIndex = slot.charIndex;
}
if (slot.peerId == m_localPeerId) {
SDL_memcpy(p.displayName, localName, sizeof(p.displayName));
}
else {
auto it = m_remotePlayers.find(slot.peerId);
if (it != m_remotePlayers.end()) {
SDL_memcpy(p.displayName, it->second->GetDisplayName(), sizeof(p.displayName));
}
else {
p.displayName[0] = '\0';
}
}
msg.participantCount++;
}
SendMessage(msg);
// Also handle locally on the host (message sent to TARGET_BROADCAST excludes sender)
HandleAnimComplete(msg);
}
void NetworkManager::HandleAnimComplete(const AnimCompleteMsg& p_msg)
{
// Only fire callback for actual participants, not observers
int localIdx = -1;
for (uint8_t i = 0; i < p_msg.participantCount; i++) {
if (p_msg.participants[i].peerId == m_localPeerId) {
localIdx = i;
break;
}
}
if (localIdx < 0 || !m_callbacks) {
return;
}
// Build JSON for frontend
char eventIdHex[17];
SDL_snprintf(
eventIdHex,
sizeof(eventIdHex),
"%08x%08x",
static_cast<uint32_t>(p_msg.eventId >> 32),
static_cast<uint32_t>(p_msg.eventId & 0xFFFFFFFF)
);
std::string json = "{\"eventId\":\"";
json += eventIdHex;
json += "\",\"animIndex\":";
json += std::to_string(p_msg.animIndex);
json += ",\"participants\":[";
// Emit local player first so frontend can rely on participants[0] being self
bool first = true;
auto appendParticipant = [&](uint8_t i) {
if (!first) {
json += ',';
}
first = false;
const AnimCompletionParticipant& p = p_msg.participants[i];
// Ensure null-termination safety for displayName
char name[USERNAME_BUFFER_SIZE];
SDL_memcpy(name, p.displayName, sizeof(name));
name[USERNAME_BUFFER_SIZE - 1] = '\0';
json += "{\"charIndex\":";
json += std::to_string(static_cast<int>(p.charIndex));
json += ",\"displayName\":\"";
json += name;
json += "\"}";
};
appendParticipant(static_cast<uint8_t>(localIdx));
for (uint8_t i = 0; i < p_msg.participantCount; i++) {
if (i != static_cast<uint8_t>(localIdx)) {
appendParticipant(i);
}
}
json += "]}";
m_callbacks->OnAnimationCompleted(json.c_str());
}
bool NetworkManager::IsPeerAtLocation(uint32_t p_peerId, int16_t p_location) const
{
if (p_peerId == m_localPeerId) {
return m_locationProximity.IsAtLocation(p_location);
}
auto it = m_remotePlayers.find(p_peerId);
if (it != m_remotePlayers.end()) {
return it->second->IsAtLocation(p_location);
}
return false;
}
bool NetworkManager::GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const
{
if (p_peerId == m_localPeerId) {
LegoPathActor* userActor = UserActor();
if (userActor && userActor->GetROI()) {
const float* pos = userActor->GetROI()->GetWorldPosition();
p_x = pos[0];
p_z = pos[2];
return true;
}
return false;
}
auto it = m_remotePlayers.find(p_peerId);
if (it != m_remotePlayers.end() && it->second->IsSpawned() && it->second->GetROI()) {
const float* pos = it->second->GetROI()->GetWorldPosition();
p_x = pos[0];
p_z = pos[2];
return true;
}
return false;
}
bool NetworkManager::IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const
{
if (p_peerId == 0) {
return false;
}
if (p_peerId == m_localPeerId) {
return true;
}
auto it = m_remotePlayers.find(p_peerId);
if (it == m_remotePlayers.end() || !it->second->IsSpawned() || !it->second->GetROI() ||
it->second->GetWorldId() != (int8_t) LegoOmni::e_act1) {
return false;
}
const float* pos = it->second->GetROI()->GetWorldPosition();
float dx = pos[0] - p_refX;
float dz = pos[2] - p_refZ;
return (dx * dx + dz * dz) <= NPC_ANIM_NEARBY_RADIUS_SQ;
}
uint8_t NetworkManager::GetPeerVehicleState(uint32_t p_peerId, int8_t p_charIndex) const
{
if (p_peerId == m_localPeerId) {
ThirdPersonCamera::Controller* cam = GetCamera();
return cam ? Animation::Catalog::GetVehicleState(p_charIndex, cam->GetRideVehicleROI())
: Animation::Catalog::e_onFoot;
}
auto it = m_remotePlayers.find(p_peerId);
if (it == m_remotePlayers.end() || !it->second->IsSpawned()) {
return Animation::Catalog::e_onFoot;
}
return Animation::Catalog::GetVehicleState(p_charIndex, it->second->GetRideVehicleROI());
}
bool NetworkManager::ValidateSessionLocations(uint16_t p_animIndex)
{
const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex);
if (!session) {
return false;
}
const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(p_animIndex);
if (!entry) {
return false;
}
if (entry->location >= 0) {
// Cam anim: all participants must be at the specific location
for (const auto& slot : session->slots) {
if (slot.peerId == 0) {
continue;
}
if (!IsPeerAtLocation(slot.peerId, entry->location)) {
return false;
}
}
return true;
}
// NPC anim: all participants must be within NPC_ANIM_PROXIMITY of each other
float firstX = 0, firstZ = 0;
bool hasFirst = false;
for (const auto& slot : session->slots) {
if (slot.peerId == 0) {
continue;
}
float px, pz;
if (!GetPeerPosition(slot.peerId, px, pz)) {
continue; // Position unknown — don't block
}
if (!hasFirst) {
firstX = px;
firstZ = pz;
hasFirst = true;
}
else {
float dx = px - firstX;
float dz = pz - firstZ;
if ((dx * dx + dz * dz) > (Animation::NPC_ANIM_PROXIMITY * Animation::NPC_ANIM_PROXIMITY)) {
return false;
}
}
}
return true;
}
void NetworkManager::HandleCustomize(const CustomizeMsg& p_msg)
{
uint32_t targetPeerId = p_msg.targetPeerId;
// Check if the target is a remote player on this client.
// Only play effects here -- do NOT modify the remote player's customize state.
// State changes come exclusively through UpdateFromNetwork (from the target's
// authoritative PlayerStateMsg), which prevents flip-flop from stale state messages.
// Note: sound/mood feedback uses the old state (before the authoritative update arrives),
// so the played sound may lag one step behind. This is an accepted tradeoff.
auto it = m_remotePlayers.find(targetPeerId);
if (it != m_remotePlayers.end()) {
if (it->second->GetROI()) {
Common::CharacterCustomizer::PlayClickSound(
it->second->GetROI(),
it->second->GetCustomizeState(),
p_msg.changeType == CHANGE_MOOD
);
if (!it->second->IsMoving() && !it->second->IsInMultiPartEmote() && !it->second->IsAnimationLocked()) {
it->second->StopClickAnimation();
MxU32 clickAnimId = Common::CharacterCustomizer::PlayClickAnimation(
it->second->GetROI(),
it->second->GetCustomizeState()
);
it->second->SetClickAnimObjectId(clickAnimId);
}
}
return;
}
// Check if the target is the local player
if (targetPeerId == m_localPeerId) {
// Reject remote customization if not allowed
if (p_msg.header.peerId != m_localPeerId && !m_localAllowRemoteCustomize) {
return;
}
ThirdPersonCamera::Controller* cam = GetCamera();
if (!cam) {
return;
}
// ApplyCustomizeChange handles null display ROI (advances state without visual)
cam->ApplyCustomizeChange(p_msg.changeType, p_msg.partIndex);
// Use display ROI for effects in 3rd person, native ROI in 1st person
LegoROI* effectROI = cam->GetDisplayROI();
if (!effectROI && UserActor()) {
effectROI = UserActor()->GetROI();
}
if (effectROI) {
Common::CharacterCustomizer::PlayClickSound(
effectROI,
cam->GetCustomizeState(),
p_msg.changeType == CHANGE_MOOD
);
// Only play click animation in 3rd person (not during multi-part emote or animation playback)
if (cam->GetDisplayROI() && !cam->IsInVehicle() && !cam->IsInMultiPartEmote() && !cam->IsAnimPlaying()) {
cam->StopClickAnimation();
MxU32 clickAnimId =
Common::CharacterCustomizer::PlayClickAnimation(cam->GetDisplayROI(), cam->GetCustomizeState());
cam->SetClickAnimObjectId(clickAnimId);
}
}
}
}
// Helper: append a JSON-escaped string value (assumes no control chars in input)
static void JsonAppendString(std::string& p_out, const char* p_str)
{
p_out += '"';
p_out += p_str;
p_out += '"';
}
static void BuildAnimationJson(
std::string& p_json,
const Animation::EligibilityInfo& p_info,
const AnimInfo* p_animInfo,
uint8_t p_sessionState,
uint16_t p_countdownMs,
bool p_localInSession,
int8_t p_localCharIndex
)
{
p_json += "{\"animIndex\":";
p_json += std::to_string(p_info.animIndex);
p_json += ",\"name\":";
JsonAppendString(p_json, p_animInfo->m_name ? p_animInfo->m_name : "");
p_json += ",\"category\":";
p_json += std::to_string(static_cast<uint8_t>(p_info.entry->category));
p_json += ",\"eligible\":";
p_json += p_info.eligible ? "true" : "false";
p_json += ",\"atLocation\":";
p_json += p_info.atLocation ? "true" : "false";
p_json += ",\"sessionState\":";
p_json += std::to_string(p_sessionState);
p_json += ",\"countdownMs\":";
p_json += std::to_string(p_countdownMs);
p_json += ",\"localInSession\":";
p_json += p_localInSession ? "true" : "false";
// canJoin: local player could fill an unfilled slot (checked via bitmasks)
bool canJoin = false;
if (!p_localInSession && p_sessionState >= 1 && p_localCharIndex >= 0) {
uint64_t localBit = uint64_t(1) << p_localCharIndex;
if ((p_info.entry->performerMask & localBit)) {
// Find this performer's slot index and check if unfilled
uint8_t slotIdx = 0;
for (int8_t bit = 0; bit < p_localCharIndex; bit++) {
if (p_info.entry->performerMask & (uint64_t(1) << bit)) {
slotIdx++;
}
}
if (slotIdx < p_info.slots.size() && !p_info.slots[slotIdx].filled) {
canJoin = true;
}
}
else {
// Check spectator slot (last slot): unfilled and player is eligible
if (!p_info.slots.empty() && !p_info.slots.back().filled &&
Animation::Catalog::CanParticipateChar(p_info.entry, p_localCharIndex)) {
canJoin = true;
}
}
}
p_json += ",\"canJoin\":";
p_json += canJoin ? "true" : "false";
p_json += ",\"slots\":[";
for (size_t s = 0; s < p_info.slots.size(); s++) {
const auto& slot = p_info.slots[s];
if (s > 0) {
p_json += ',';
}
p_json += "{\"names\":[";
for (size_t n = 0; n < slot.names.size(); n++) {
if (n > 0) {
p_json += ',';
}
JsonAppendString(p_json, slot.names[n]);
}
p_json += "],\"filled\":";
p_json += slot.filled ? "true" : "false";
p_json += '}';
}
p_json += "]}";
}
void NetworkManager::PushAnimationState()
{
ThirdPersonCamera::Controller* cam = GetCamera();
if (!cam || !cam->GetDisplayROI()) {
// Camera unavailable — push idle state so the frontend clears any countdown/session UI
if (m_callbacks) {
m_callbacks->OnAnimationsAvailable(IDLE_ANIM_STATE_JSON);
}
return;
}
const auto& locations = m_locationProximity.GetLocations();
uint8_t displayActorIndex = cam->GetDisplayActorIndex();
int8_t localCharIndex = Animation::Catalog::DisplayActorToCharacterIndex(displayActorIndex);
LegoPathActor* userActor = UserActor();
if (!userActor || !userActor->GetROI()) {
if (m_callbacks) {
m_callbacks->OnAnimationsAvailable(IDLE_ANIM_STATE_JSON);
}
return;
}
const float* localPos = userActor->GetROI()->GetWorldPosition();
float localX = localPos[0], localZ = localPos[2];
uint8_t localVehicleState = Animation::Catalog::GetVehicleState(localCharIndex, cam->GetRideVehicleROI());
// Build proximity character indices and vehicle state (for NPC anims — position-based, not location-based)
std::vector<int8_t> proximityCharIndices;
std::vector<uint8_t> proximityVehicleState;
proximityCharIndices.push_back(localCharIndex);
proximityVehicleState.push_back(localVehicleState);
for (const auto& [peerId, player] : m_remotePlayers) {
if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) {
continue;
}
// Exact NPC_ANIM_PROXIMITY radius for triggering eligibility
// (tighter than IsPeerNearby's NPC_ANIM_NEARBY_RADIUS_SQ used for session visibility)
const float* rpos = player->GetROI()->GetWorldPosition();
float dx = rpos[0] - localX;
float dz = rpos[2] - localZ;
if ((dx * dx + dz * dz) <= (Animation::NPC_ANIM_PROXIMITY * Animation::NPC_ANIM_PROXIMITY)) {
int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex());
proximityCharIndices.push_back(charIdx);
proximityVehicleState.push_back(Animation::Catalog::GetVehicleState(charIdx, player->GetRideVehicleROI()));
}
}
// Compute eligibility across all overlapping locations.
// Each call returns NPC anims + cam anims for that specific location.
// NPC anims are identical across calls (same proximityChars), so we deduplicate by animIndex.
std::vector<Animation::EligibilityInfo> eligibility;
std::set<uint16_t> seenAnimIndices;
// If at no location, still process once with -1 to get NPC anims
std::vector<int16_t> locationsToProcess = locations.empty() ? std::vector<int16_t>{int16_t(-1)} : locations;
for (int16_t loc : locationsToProcess) {
// Build per-location character indices and vehicle state (for cam anims at this location)
std::vector<int8_t> locationCharIndices;
std::vector<uint8_t> locationVehicleState;
locationCharIndices.push_back(localCharIndex);
locationVehicleState.push_back(localVehicleState);
for (const auto& [peerId, player] : m_remotePlayers) {
if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) {
continue;
}
if (player->IsAtLocation(loc)) {
int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex());
locationCharIndices.push_back(charIdx);
locationVehicleState.push_back(Animation::Catalog::GetVehicleState(charIdx, player->GetRideVehicleROI())
);
}
}
auto locEligibility = m_animCoordinator.ComputeEligibility(
loc,
locationCharIndices.data(),
locationVehicleState.data(),
static_cast<uint8_t>(locationCharIndices.size()),
proximityCharIndices.data(),
proximityVehicleState.data(),
static_cast<uint8_t>(proximityCharIndices.size())
);
for (auto& info : locEligibility) {
if (seenAnimIndices.insert(info.animIndex).second) {
eligibility.push_back(std::move(info));
}
}
}
// Build JSON
std::string json;
json.reserve(2048);
json += "{\"locations\":[";
for (size_t i = 0; i < locations.size(); i++) {
if (i > 0) {
json += ',';
}
json += std::to_string(locations[i]);
}
json += "],\"state\":";
json += std::to_string(static_cast<uint8_t>(m_animCoordinator.GetState()));
json += ",\"currentAnimIndex\":";
json += std::to_string(m_animCoordinator.GetCurrentAnimIndex());
json += ",\"pendingInterest\":";
json += std::to_string(m_localPendingAnimInterest);
json += ",\"animations\":[";
bool firstAnim = true;
for (size_t i = 0; i < eligibility.size(); i++) {
const auto& info = eligibility[i];
const AnimInfo* animInfo = m_animCatalog.GetAnimInfo(info.animIndex);
if (!animInfo) {
continue;
}
if (!firstAnim) {
json += ',';
}
firstAnim = false;
// Session state: host computes live countdown, clients derive from countdownEndTime
uint8_t sessionState = 0;
uint16_t countdownMs = 0;
if (IsHost()) {
const Animation::AnimSession* hostSession = m_animSessionHost.FindSession(info.animIndex);
if (hostSession) {
sessionState = static_cast<uint8_t>(hostSession->state);
countdownMs = Animation::SessionHost::ComputeCountdownMs(*hostSession, SDL_GetTicks());
}
}
else {
const Animation::SessionView* sv = m_animCoordinator.GetSessionView(info.animIndex);
if (sv) {
sessionState = static_cast<uint8_t>(sv->state);
if (sv->state == Animation::CoordinationState::e_countdown && sv->countdownEndTime > 0) {
uint32_t now = SDL_GetTicks();
countdownMs = (now < sv->countdownEndTime) ? static_cast<uint16_t>(sv->countdownEndTime - now) : 0;
}
else {
countdownMs = sv->countdownMs;
}
}
}
bool localInSession = m_animCoordinator.IsLocalPlayerInSession(info.animIndex);
// Suppress session display if local player is not in the session and no
// session participant is nearby — prevents stale "Join!" for far-away sessions
if (sessionState > 0 && !localInSession) {
bool anyParticipantNearby = false;
if (IsHost()) {
const Animation::AnimSession* hs = m_animSessionHost.FindSession(info.animIndex);
if (hs) {
for (const auto& slot : hs->slots) {
if (IsPeerNearby(slot.peerId, localX, localZ)) {
anyParticipantNearby = true;
break;
}
}
}
}
else {
const Animation::SessionView* ssv = m_animCoordinator.GetSessionView(info.animIndex);
if (ssv) {
for (uint8_t s = 0; s < ssv->slotCount && !anyParticipantNearby; s++) {
if (IsPeerNearby(ssv->peerSlots[s], localX, localZ)) {
anyParticipantNearby = true;
}
}
}
}
if (!anyParticipantNearby) {
sessionState = 0;
countdownMs = 0;
}
}
BuildAnimationJson(json, info, animInfo, sessionState, countdownMs, localInSession, localCharIndex);
}
json += "]}";
if (m_callbacks) {
m_callbacks->OnAnimationsAvailable(json.c_str());
}
}