isle-portable/extensions/src/multiplayer/worldstatesync.cpp
Christian Semmler e57164d345
Fix use-after-free crash and misaligned protocol access
Stop active ScenePlayer animation in OnActorEnter/OnActorExit before
modifying ride animation state — the ScenePlayer may still hold a
reference to the ride vehicle ROI that ClearRideAnimation frees.
Deactivate() and OnWorldDisabled() already had this guard.

Add alignment padding to MessageHeader (13→14 bytes) so uint16_t fields
in packed protocol structs no longer sit at odd offsets (UBSan violation).
Breaking wire format change — all clients and relay must update together.

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

499 lines
13 KiB
C++

#include "extensions/multiplayer/worldstatesync.h"
#include "isle.h"
#include "legobuildingmanager.h"
#include "legoentity.h"
#include "legogamestate.h"
#include "legomain.h"
#include "legoplantmanager.h"
#include "legoplants.h"
#include "legoutils.h"
#include "legoworld.h"
#include "misc.h"
#include "misc/legostorage.h"
#include "mxmisc.h"
#include "mxvariable.h"
#include "mxvariabletable.h"
#include <SDL3/SDL_stdinc.h>
#include <vector>
extern MxU8 g_counters[];
extern MxU8 g_buildingInfoDownshift[];
using namespace Multiplayer;
template <typename T>
void WorldStateSync::SendMessage(const T& p_msg)
{
if (!m_transport || !m_transport->IsConnected()) {
return;
}
uint8_t buf[sizeof(T)];
size_t len = SerializeMsg(buf, sizeof(buf), p_msg);
if (len > 0) {
m_transport->Send(buf, len);
}
}
WorldStateSync::WorldStateSync()
: m_transport(nullptr), m_localPeerId(0), m_sequence(0), m_isHost(false), m_inIsleWorld(false),
m_snapshotRequested(false), m_savedLightPos(2)
{
}
void WorldStateSync::SaveSkyLightState()
{
const char* bgValue = GameState()->GetBackgroundColor()->GetValue()->GetData();
m_savedSkyColor = bgValue ? bgValue : "set 56 54 68";
m_savedLightPos = SDL_atoi(VariableTable()->GetVariable("lightposition"));
}
void WorldStateSync::RestoreSkyLightState()
{
ApplySkyLightState(m_savedSkyColor.c_str(), m_savedLightPos);
}
void WorldStateSync::ApplySkyLightState(const char* p_skyColor, int p_lightPos)
{
GameState()->GetBackgroundColor()->SetValue(p_skyColor);
GameState()->GetBackgroundColor()->SetLightColor();
SetLightPosition(p_lightPos);
char buf[32];
SDL_snprintf(buf, sizeof(buf), "%d", p_lightPos);
VariableTable()->SetVariable("lightposition", buf);
}
void WorldStateSync::ResetForReconnect()
{
m_localPeerId = 0;
m_sequence = 0;
m_isHost = false;
m_snapshotRequested = false;
m_pendingWorldEvents.clear();
}
void WorldStateSync::OnHostChanged()
{
if (!m_isHost) {
m_snapshotRequested = false;
m_pendingWorldEvents.clear();
SendSnapshotRequest();
}
}
void WorldStateSync::SendWorldSnapshotTo(uint32_t p_targetPeerId)
{
SendWorldSnapshot(p_targetPeerId);
}
void WorldStateSync::HandleRequestSnapshot(const RequestSnapshotMsg& p_msg)
{
if (!m_isHost) {
return;
}
SendWorldSnapshot(p_msg.header.peerId);
}
void WorldStateSync::HandleWorldSnapshot(const uint8_t* p_data, size_t p_length)
{
WorldSnapshotMsg header;
if (!DeserializeMsg(p_data, p_length, header) || header.header.type != MSG_WORLD_SNAPSHOT) {
return;
}
if (p_length < sizeof(WorldSnapshotMsg) + header.dataLength) {
return;
}
const uint8_t* snapshotData = p_data + sizeof(WorldSnapshotMsg);
// Apply the snapshot via LegoMemory.
LegoMemory memory((void*) snapshotData, header.dataLength);
PlantManager()->Read(&memory);
BuildingManager()->Read(&memory);
// Read sky/light state appended after plant + building data.
LegoU32 memPos;
memory.GetPosition(memPos);
const uint8_t* extraData = snapshotData + memPos;
size_t remaining = header.dataLength - memPos;
if (remaining >= 4) {
char skyBuffer[32];
SDL_snprintf(skyBuffer, sizeof(skyBuffer), "set %d %d %d", extraData[0], extraData[1], extraData[2]);
ApplySkyLightState(skyBuffer, extraData[3]);
}
// Read() updates data arrays but not entity positions; reload to refresh.
if (m_inIsleWorld) {
LegoWorld* world = CurrentWorld();
if (world && world->GetWorldId() == LegoOmni::e_act1) {
PlantManager()->Reset(LegoOmni::e_act1);
PlantManager()->LoadWorldInfo(LegoOmni::e_act1);
BuildingManager()->Reset();
BuildingManager()->LoadWorldInfo();
}
}
// Replay events queued while snapshot was in flight.
for (const auto& evt : m_pendingWorldEvents) {
ApplyWorldEvent(evt.entityType, evt.changeType, evt.entityIndex);
}
m_pendingWorldEvents.clear();
m_snapshotRequested = false;
}
void WorldStateSync::HandleWorldEvent(const WorldEventMsg& p_msg)
{
if (m_snapshotRequested) {
m_pendingWorldEvents.push_back(p_msg);
return;
}
ApplyWorldEvent(p_msg.entityType, p_msg.changeType, p_msg.entityIndex);
}
void WorldStateSync::HandleWorldEventRequest(const WorldEventRequestMsg& p_msg)
{
if (!m_isHost) {
return;
}
ApplyWorldEvent(p_msg.entityType, p_msg.changeType, p_msg.entityIndex);
BroadcastWorldEvent(p_msg.entityType, p_msg.changeType, p_msg.entityIndex);
}
template <typename TInfo>
static int FindEntityIndex(TInfo* p_infoArray, MxS32 p_count, LegoEntity* p_entity)
{
for (MxS32 i = 0; i < p_count; i++) {
if (p_infoArray[i].m_entity == p_entity) {
return i;
}
}
return -1;
}
MxBool WorldStateSync::HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType)
{
if (!m_transport || !m_transport->IsConnected()) {
return FALSE;
}
uint8_t entityType;
int idx;
if (p_entity->GetType() == LegoEntity::e_plant) {
entityType = ENTITY_PLANT;
MxS32 count;
LegoPlantInfo* info = PlantManager()->GetInfoArray(count);
idx = FindEntityIndex(info, count, p_entity);
}
else if (p_entity->GetType() == LegoEntity::e_building) {
entityType = ENTITY_BUILDING;
MxS32 count;
LegoBuildingInfo* info = BuildingManager()->GetInfoArray(count);
idx = FindEntityIndex(info, count, p_entity);
}
else {
return FALSE;
}
if (idx < 0) {
return FALSE;
}
if (m_isHost) {
BroadcastWorldEvent(entityType, p_changeType, (uint8_t) idx);
return FALSE;
}
else {
SendWorldEventRequest(entityType, p_changeType, (uint8_t) idx);
return TRUE;
}
}
MxBool WorldStateSync::HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType)
{
if (!m_transport || !m_transport->IsConnected()) {
return FALSE;
}
if (m_isHost) {
BroadcastWorldEvent(p_entityType, p_changeType, 0);
return FALSE;
}
else {
SendWorldEventRequest(p_entityType, p_changeType, 0);
return TRUE;
}
}
void WorldStateSync::SendSnapshotRequest()
{
RequestSnapshotMsg msg{};
msg.header = {MSG_REQUEST_SNAPSHOT, 0, m_localPeerId, m_sequence++, TARGET_HOST};
SendMessage(msg);
m_snapshotRequested = true;
m_pendingWorldEvents.clear();
}
void WorldStateSync::SendWorldSnapshot(uint32_t p_targetPeerId)
{
if (!m_transport || !m_transport->IsConnected()) {
return;
}
// Serialize plant + building + sky/light state (~1149 bytes max, use 4096 for safety).
uint8_t stateBuffer[4096];
LegoMemory memory(stateBuffer, sizeof(stateBuffer));
PlantManager()->Write(&memory);
BuildingManager()->Write(&memory);
LegoU32 dataLength;
memory.GetPosition(dataLength);
// Append sky color HSV (parse from "set H S V" string) and light position.
int skyH = 56, skyS = 54, skyV = 68; // defaults matching "set 56 54 68"
const char* bgValue = GameState()->GetBackgroundColor()->GetValue()->GetData();
if (bgValue) {
SDL_sscanf(bgValue, "set %d %d %d", &skyH, &skyS, &skyV);
}
int lightPos = SDL_atoi(VariableTable()->GetVariable("lightposition"));
stateBuffer[dataLength++] = (uint8_t) skyH;
stateBuffer[dataLength++] = (uint8_t) skyS;
stateBuffer[dataLength++] = (uint8_t) skyV;
stateBuffer[dataLength++] = (uint8_t) lightPos;
WorldSnapshotMsg msg{};
msg.header = {MSG_WORLD_SNAPSHOT, 0, m_localPeerId, m_sequence++, p_targetPeerId};
msg.dataLength = (uint16_t) dataLength;
std::vector<uint8_t> msgBuf(sizeof(WorldSnapshotMsg) + dataLength);
SDL_memcpy(msgBuf.data(), &msg, sizeof(WorldSnapshotMsg));
SDL_memcpy(msgBuf.data() + sizeof(WorldSnapshotMsg), stateBuffer, dataLength);
m_transport->Send(msgBuf.data(), msgBuf.size());
}
void WorldStateSync::BroadcastWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex)
{
WorldEventMsg msg{};
msg.header = {MSG_WORLD_EVENT, 0, m_localPeerId, m_sequence++, TARGET_BROADCAST};
msg.entityType = p_entityType;
msg.changeType = p_changeType;
msg.entityIndex = p_entityIndex;
SendMessage(msg);
}
void WorldStateSync::SendWorldEventRequest(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex)
{
WorldEventRequestMsg msg{};
msg.header = {MSG_WORLD_EVENT_REQUEST, 0, m_localPeerId, m_sequence++, TARGET_HOST};
msg.entityType = p_entityType;
msg.changeType = p_changeType;
msg.entityIndex = p_entityIndex;
SendMessage(msg);
}
// Dispatch Switch*() calls shared by all entity types.
// Returns true if the change was handled, false for type-specific changes.
static bool DispatchEntitySwitch(LegoEntity* p_entity, uint8_t p_changeType)
{
switch (p_changeType) {
case CHANGE_VARIANT:
p_entity->SwitchVariant();
return true;
case CHANGE_SOUND:
p_entity->SwitchSound();
return true;
case CHANGE_MOVE:
p_entity->SwitchMove();
return true;
case CHANGE_MOOD:
p_entity->SwitchMood();
return true;
default:
return false;
}
}
void WorldStateSync::ApplyWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex)
{
if (p_entityType == ENTITY_PLANT) {
MxS32 numPlants;
LegoPlantInfo* plantInfo = PlantManager()->GetInfoArray(numPlants);
if (p_entityIndex >= numPlants) {
return;
}
LegoPlantInfo* info = &plantInfo[p_entityIndex];
if (info->m_entity != NULL) {
if (!DispatchEntitySwitch(info->m_entity, p_changeType)) {
if (p_changeType == CHANGE_COLOR) {
info->m_entity->SwitchColor(info->m_entity->GetROI());
}
else if (p_changeType == CHANGE_DECREMENT) {
PlantManager()->DecrementCounter(info->m_entity);
}
}
}
else {
switch (p_changeType) {
case CHANGE_VARIANT:
if (info->m_counter == -1) {
info->m_variant++;
if (info->m_variant > LegoPlantInfo::e_palm) {
info->m_variant = LegoPlantInfo::e_flower;
}
if (info->m_move != 0 && info->m_move >= (MxU32) LegoPlantManager::g_maxMove[info->m_variant]) {
info->m_move = LegoPlantManager::g_maxMove[info->m_variant] - 1;
}
}
break;
case CHANGE_SOUND:
info->m_sound++;
if (info->m_sound >= LegoPlantManager::g_maxSound) {
info->m_sound = 0;
}
break;
case CHANGE_MOVE:
info->m_move++;
if (info->m_move >= (MxU32) LegoPlantManager::g_maxMove[info->m_variant]) {
info->m_move = 0;
}
break;
case CHANGE_COLOR:
info->m_color++;
if (info->m_color > LegoPlantInfo::e_green) {
info->m_color = LegoPlantInfo::e_white;
}
break;
case CHANGE_MOOD:
info->m_mood++;
if (info->m_mood > 3) {
info->m_mood = 0;
}
break;
case CHANGE_DECREMENT: {
if (info->m_counter < 0) {
info->m_counter = g_counters[info->m_variant];
}
if (info->m_counter > 0) {
info->m_counter--;
if (info->m_counter == 1) {
info->m_counter = 0;
}
}
break;
}
}
}
}
else if (p_entityType == ENTITY_BUILDING) {
MxS32 numBuildings;
LegoBuildingInfo* buildingInfo = BuildingManager()->GetInfoArray(numBuildings);
if (p_entityIndex >= numBuildings) {
return;
}
LegoBuildingInfo* info = &buildingInfo[p_entityIndex];
if (info->m_entity != NULL) {
if (!DispatchEntitySwitch(info->m_entity, p_changeType)) {
if (p_changeType == CHANGE_COLOR) {
info->m_entity->SwitchColor(info->m_entity->GetROI());
}
else if (p_changeType == CHANGE_DECREMENT) {
BuildingManager()->DecrementCounter(info->m_entity);
}
}
}
else {
switch (p_changeType) {
case CHANGE_SOUND:
if (info->m_flags & LegoBuildingInfo::c_hasSounds) {
info->m_sound++;
if (info->m_sound >= LegoBuildingManager::g_maxSound) {
info->m_sound = 0;
}
}
break;
case CHANGE_MOVE:
if (info->m_flags & LegoBuildingInfo::c_hasMoves) {
info->m_move++;
if (info->m_move >= (MxU32) LegoBuildingManager::g_maxMove[p_entityIndex]) {
info->m_move = 0;
}
}
break;
case CHANGE_MOOD:
if (info->m_flags & LegoBuildingInfo::c_hasMoods) {
info->m_mood++;
if (info->m_mood > 3) {
info->m_mood = 0;
}
}
break;
case CHANGE_DECREMENT: {
if (info->m_counter < 0) {
info->m_counter = g_buildingInfoDownshift[p_entityIndex];
}
if (info->m_counter > 0) {
info->m_counter -= 2;
if (info->m_counter == 1) {
info->m_counter = 0;
}
}
break;
}
case CHANGE_VARIANT:
case CHANGE_COLOR:
// Variant switching is config-dependent, color N/A for buildings
break;
}
}
}
else if (p_entityType == ENTITY_SKY) {
switch (p_changeType) {
case SKY_TOGGLE_COLOR:
GameState()->GetBackgroundColor()->ToggleSkyColor();
break;
case SKY_DAY:
GameState()->GetBackgroundColor()->ToggleDayNight(TRUE);
break;
case SKY_NIGHT:
GameState()->GetBackgroundColor()->ToggleDayNight(FALSE);
break;
}
}
else if (p_entityType == ENTITY_LIGHT) {
switch (p_changeType) {
case LIGHT_INCREMENT:
UpdateLightPosition(1);
break;
case LIGHT_DECREMENT:
UpdateLightPosition(-1);
break;
}
if (m_inIsleWorld) {
LegoWorld* world = CurrentWorld();
if (world && world->GetWorldId() == LegoOmni::e_act1) {
((Isle*) world)->UpdateGlobe();
}
}
}
}