Sync sky light (#9)

* Sync sky color and light position in multiplayer

Add ENTITY_SKY and ENTITY_LIGHT to the WorldEvent system so the host
controls sky color (hue/saturation via observatory sun/moon/palette
buttons) and light position (globe arrows) with the same
host-authoritative pattern used for plants and buildings. Non-host
players send requests to the host who applies and broadcasts. Sky/light
state is appended to the world snapshot so joining players get the
current values.

https://claude.ai/code/session_01X2cPVQEo7c92wpWA7QPPMG

* Clean up sky/light sync: remove debug logging, DRY apply logic, fix host routing

- Extract ApplySkyLightState helper to deduplicate sky/light apply code
  between RestoreSkyLightState and HandleWorldSnapshot
- Remove all SDL_Log debug calls and SDL_log.h includes
- Remove dead OnWorldEnabled method from WorldStateSync (replaced by
  OnHostChanged in OnSaveLoaded)
- Fix HandleSkyLightMutation host path: return FALSE to let local
  switch case proceed, instead of duplicating via ApplyWorldEvent
- Simplify isle.cpp HandleControl: split observatory cases into
  individual switch arms with single early-return multiplayer hook
- Add save load hooks to sync world state with multiplayer peers
- Fix player count to exclude local player without valid actor
- Support broadcast snapshots (targetPeerId=0) in relay server

---------
This commit is contained in:
foxtacles 2026-03-07 20:55:00 -08:00 committed by GitHub
parent 853e8981fa
commit eb6d2b8728
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 282 additions and 5 deletions

View File

@ -23,6 +23,11 @@ class RaceCar;
class SkateBoard;
class TowTrack;
namespace Multiplayer
{
class WorldStateSync;
}
// VTABLE: LEGO1 0x100d7028
// VTABLE: BETA10 0x101b9d40
// SIZE 0x26c
@ -164,6 +169,7 @@ class Isle : public LegoWorld {
void SwitchToInfocenter();
friend class Act1State;
friend class Multiplayer::WorldStateSync;
// SYNTHETIC: LEGO1 0x10030a30
// Isle::`scalar deleting destructor'

View File

@ -12,6 +12,7 @@
#include "dunebuggy.h"
#include "dunecar_actions.h"
#include "elevbott_actions.h"
#include "extensions/multiplayer.h"
#include "garage_actions.h"
#include "helicopter.h"
#include "histbook_actions.h"
@ -64,6 +65,8 @@
#include <assert.h>
#include <stdio.h>
using namespace Extensions;
DECOMP_SIZE_ASSERT(LegoGameState::Username, 0x0e)
DECOMP_SIZE_ASSERT(LegoGameState::ScoreItem, 0x2c)
DECOMP_SIZE_ASSERT(LegoGameState::History, 0x374)
@ -364,6 +367,8 @@ MxResult LegoGameState::DeleteState()
// FUNCTION: BETA10 0x10084329
MxResult LegoGameState::Load(MxULong p_slot)
{
Extension<MultiplayerExt>::Call(HandleBeforeSaveLoad);
MxResult result = FAILURE;
LegoFile storage;
MxVariableTable* variableTable = VariableTable();
@ -456,6 +461,8 @@ MxResult LegoGameState::Load(MxULong p_slot)
result = SUCCESS;
m_isDirty = FALSE;
Extension<MultiplayerExt>::Call(HandleSaveLoaded);
done:
if (result != SUCCESS) {
OmniError("Game state loading was not successful!", 0);

View File

@ -5,6 +5,7 @@
#include "bike.h"
#include "carrace.h"
#include "dunebuggy.h"
#include "extensions/multiplayer.h"
#include "extensions/siloader.h"
#include "helicopter.h"
#include "isle_actions.h"
@ -296,6 +297,10 @@ void Isle::ReadyWorld()
MxLong Isle::HandleControl(LegoControlManagerNotificationParam& p_param)
{
if (p_param.m_enabledChild == 1) {
if (Extension<MultiplayerExt>::Call(HandleSkyLightControl, (MxU32) p_param.m_clickedObjectId).value_or(FALSE)) {
return 1;
}
MxDSAction action;
switch (p_param.m_clickedObjectId) {

View File

@ -33,6 +33,10 @@ class MultiplayerExt {
// Returns TRUE if the click should be suppressed locally (non-host).
static MxBool HandleEntityNotify(LegoEntity* p_entity);
// Intercepts observatory sky/light controls for multiplayer routing.
// Returns TRUE if the local action should be suppressed (non-host).
static MxBool HandleSkyLightControl(MxU32 p_controlId);
// Handles clicks on entity-less ROIs (remote players, display actor overrides).
static MxBool HandleROIClick(LegoROI* p_rootROI, LegoEventNotificationParam& p_param);
@ -50,6 +54,12 @@ class MultiplayerExt {
// Returns TRUE if the name belongs to a multiplayer clone (entity-less ROI).
static MxBool IsClonedCharacter(const char* p_name);
// Called before a save file is loaded. Captures current sky/light state.
static void HandleBeforeSaveLoad();
// Called after a save file is loaded. Re-syncs world state with multiplayer peers.
static void HandleSaveLoaded();
// Returns true if the multiplayer connection was rejected (e.g. room full).
static MxBool CheckRejected();
@ -68,23 +78,29 @@ LEGO1_EXPORT bool IsMultiplayerRejected();
constexpr auto HandleCreate = &MultiplayerExt::HandleCreate;
constexpr auto HandleWorldEnable = &MultiplayerExt::HandleWorldEnable;
constexpr auto HandleEntityNotify = &MultiplayerExt::HandleEntityNotify;
constexpr auto HandleSkyLightControl = &MultiplayerExt::HandleSkyLightControl;
constexpr auto HandleROIClick = &MultiplayerExt::HandleROIClick;
constexpr auto HandleActorEnter = &MultiplayerExt::HandleActorEnter;
constexpr auto HandleActorExit = &MultiplayerExt::HandleActorExit;
constexpr auto HandleCamAnimEnd = &MultiplayerExt::HandleCamAnimEnd;
constexpr auto ShouldInvertMovement = &MultiplayerExt::ShouldInvertMovement;
constexpr auto IsClonedCharacter = &MultiplayerExt::IsClonedCharacter;
constexpr auto HandleBeforeSaveLoad = &MultiplayerExt::HandleBeforeSaveLoad;
constexpr auto HandleSaveLoaded = &MultiplayerExt::HandleSaveLoaded;
constexpr auto CheckRejected = &MultiplayerExt::CheckRejected;
#else
constexpr decltype(&MultiplayerExt::HandleCreate) HandleCreate = nullptr;
constexpr decltype(&MultiplayerExt::HandleWorldEnable) HandleWorldEnable = nullptr;
constexpr decltype(&MultiplayerExt::HandleEntityNotify) HandleEntityNotify = nullptr;
constexpr decltype(&MultiplayerExt::HandleSkyLightControl) HandleSkyLightControl = nullptr;
constexpr decltype(&MultiplayerExt::HandleROIClick) HandleROIClick = nullptr;
constexpr decltype(&MultiplayerExt::HandleActorEnter) HandleActorEnter = nullptr;
constexpr decltype(&MultiplayerExt::HandleActorExit) HandleActorExit = nullptr;
constexpr decltype(&MultiplayerExt::HandleCamAnimEnd) HandleCamAnimEnd = nullptr;
constexpr decltype(&MultiplayerExt::ShouldInvertMovement) ShouldInvertMovement = nullptr;
constexpr decltype(&MultiplayerExt::IsClonedCharacter) IsClonedCharacter = nullptr;
constexpr decltype(&MultiplayerExt::HandleBeforeSaveLoad) HandleBeforeSaveLoad = nullptr;
constexpr decltype(&MultiplayerExt::HandleSaveLoaded) HandleSaveLoaded = nullptr;
constexpr decltype(&MultiplayerExt::CheckRejected) CheckRejected = nullptr;
#endif

View File

@ -67,6 +67,8 @@ class NetworkManager : public MxCore {
void OnWorldEnabled(LegoWorld* p_world);
void OnWorldDisabled(LegoWorld* p_world);
void OnBeforeSaveLoad();
void OnSaveLoaded();
ThirdPersonCamera& GetThirdPersonCamera() { return m_thirdPersonCamera; }
@ -74,6 +76,10 @@ class NetworkManager : public MxCore {
// Returns TRUE if the mutation should be suppressed locally (non-host).
MxBool HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType);
// Called from multiplayer extension when a sky/light control is used.
// Returns TRUE if the local action should be suppressed (non-host).
MxBool HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType);
bool IsHost() const { return m_localPeerId != 0 && m_localPeerId == m_hostPeerId; }
uint32_t GetLocalPeerId() const { return m_localPeerId; }

View File

@ -40,7 +40,9 @@ enum VehicleType : int8_t {
// Entity types for world events
enum WorldEntityType : uint8_t {
ENTITY_PLANT = 0,
ENTITY_BUILDING = 1
ENTITY_BUILDING = 1,
ENTITY_SKY = 2,
ENTITY_LIGHT = 3
};
// Change types for world events (maps to Switch* methods on LegoEntity)
@ -53,6 +55,19 @@ enum WorldChangeType : uint8_t {
CHANGE_DECREMENT = 5
};
// Change types for ENTITY_SKY
enum SkyChangeType : uint8_t {
SKY_TOGGLE_COLOR = 0,
SKY_DAY = 1,
SKY_NIGHT = 2
};
// Change types for ENTITY_LIGHT
enum LightChangeType : uint8_t {
LIGHT_INCREMENT = 0,
LIGHT_DECREMENT = 1
};
#pragma pack(push, 1)
struct MessageHeader {

View File

@ -5,6 +5,7 @@
#include "mxtypes.h"
#include <cstdint>
#include <string>
#include <vector>
class LegoEntity;
@ -24,6 +25,15 @@ class WorldStateSync {
// Called when the host peer changes. Requests a snapshot if we're not host.
void OnHostChanged();
// Captures current sky/light state before a save load (for non-host restore).
void SaveSkyLightState();
// Restores previously saved sky/light state (non-host only, prevents flicker).
void RestoreSkyLightState();
// Sends a snapshot to a specific peer, or broadcasts to all if p_targetPeerId is 0.
void SendWorldSnapshotTo(uint32_t p_targetPeerId);
// Incoming message handlers (called from NetworkManager::ProcessIncomingPackets)
void HandleRequestSnapshot(const RequestSnapshotMsg& p_msg);
void HandleWorldSnapshot(const uint8_t* p_data, size_t p_length);
@ -34,7 +44,12 @@ class WorldStateSync {
// Returns TRUE if the mutation should be suppressed locally (non-host).
MxBool HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeType);
// Called from multiplayer extension when a sky/light control is used.
// Returns TRUE if the local action should be suppressed (non-host).
MxBool HandleSkyLightMutation(uint8_t p_entityType, uint8_t p_changeType);
private:
void ApplySkyLightState(const char* p_skyColor, int p_lightPos);
void SendSnapshotRequest();
void SendWorldSnapshot(uint32_t p_targetPeerId);
void BroadcastWorldEvent(uint8_t p_entityType, uint8_t p_changeType, uint8_t p_entityIndex);
@ -51,6 +66,10 @@ class WorldStateSync {
bool m_inIsleWorld;
bool m_snapshotRequested;
std::vector<WorldEventMsg> m_pendingWorldEvents;
// Saved sky/light state for non-host restore across save loads.
std::string m_savedSkyColor;
int m_savedLightPos;
};
} // namespace Multiplayer

View File

@ -4,6 +4,7 @@
#include "extensions/multiplayer/networkmanager.h"
#include "extensions/multiplayer/networktransport.h"
#include "extensions/multiplayer/protocol.h"
#include "isle_actions.h"
#include "islepathactor.h"
#include "legoactor.h"
#include "legoactors.h"
@ -213,6 +214,57 @@ MxBool MultiplayerExt::HandleEntityNotify(LegoEntity* p_entity)
return s_networkManager->HandleEntityMutation(p_entity, changeType);
}
MxBool MultiplayerExt::HandleSkyLightControl(MxU32 p_controlId)
{
if (!s_networkManager) {
return FALSE;
}
uint8_t entityType;
uint8_t changeType;
switch (p_controlId) {
case IsleScript::c_Observe_SkyColor_Ctl:
entityType = Multiplayer::ENTITY_SKY;
changeType = Multiplayer::SKY_TOGGLE_COLOR;
break;
case IsleScript::c_Observe_Sun_Ctl:
entityType = Multiplayer::ENTITY_SKY;
changeType = Multiplayer::SKY_DAY;
break;
case IsleScript::c_Observe_Moon_Ctl:
entityType = Multiplayer::ENTITY_SKY;
changeType = Multiplayer::SKY_NIGHT;
break;
case IsleScript::c_Observe_GlobeRArrow_Ctl:
entityType = Multiplayer::ENTITY_LIGHT;
changeType = Multiplayer::LIGHT_INCREMENT;
break;
case IsleScript::c_Observe_GlobeLArrow_Ctl:
entityType = Multiplayer::ENTITY_LIGHT;
changeType = Multiplayer::LIGHT_DECREMENT;
break;
default:
return FALSE;
}
return s_networkManager->HandleSkyLightMutation(entityType, changeType);
}
void MultiplayerExt::HandleBeforeSaveLoad()
{
if (s_networkManager) {
s_networkManager->OnBeforeSaveLoad();
}
}
void MultiplayerExt::HandleSaveLoaded()
{
if (s_networkManager) {
s_networkManager->OnSaveLoaded();
}
}
void MultiplayerExt::HandleActorEnter(IslePathActor* p_actor)
{
if (s_networkManager) {

View File

@ -192,11 +192,42 @@ void NetworkManager::OnWorldDisabled(LegoWorld* p_world)
}
}
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::ProcessPendingRequests()
{
if (m_pendingToggleThirdPerson.exchange(false, std::memory_order_relaxed)) {
@ -608,7 +639,15 @@ void NetworkManager::NotifyPlayerCountChanged()
int count = -1;
if (m_inIsleWorld) {
count = 1; // local player
count = 0;
// Only count the local player if they have a valid actor
// (players who enter Isle without selecting a save have no actor).
LegoPathActor* userActor = UserActor();
if (userActor && IsValidActorId(static_cast<LegoActor*>(userActor)->GetActorId())) {
count = 1;
}
for (auto& [peerId, player] : m_remotePlayers) {
if (player->GetWorldId() == (int8_t) LegoOmni::e_act1) {
count++;

View File

@ -178,7 +178,12 @@ export class GameRoom implements DurableObject {
msgType === MSG_WORLD_SNAPSHOT &&
data.length >= SNAPSHOT_MIN_SIZE
) {
this.sendToTarget(stamped);
const targetId = readTargetPeerId(stamped);
if (targetId === 0) {
this.broadcastExcept(stamped.buffer, peerId);
} else {
this.sendToTarget(stamped);
}
} else if (msgType === MSG_CUSTOMIZE) {
// Broadcast to all including sender so the clicker sees effects
// on the target's clone on their own screen.

View File

@ -1,15 +1,22 @@
#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 <SDL3/SDL_stdinc.h>
#include <cstdio>
#include <cstdlib>
#include <vector>
extern MxU8 g_counters[];
@ -33,10 +40,33 @@ void WorldStateSync::SendMessage(const T& p_msg)
WorldStateSync::WorldStateSync()
: m_transport(nullptr), m_localPeerId(0), m_sequence(0), m_isHost(false), m_inIsleWorld(false),
m_snapshotRequested(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 = 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];
sprintf(buf, "%d", p_lightPos);
VariableTable()->SetVariable("lightposition", buf);
}
void WorldStateSync::OnHostChanged()
{
if (!m_isHost) {
@ -46,6 +76,11 @@ void WorldStateSync::OnHostChanged()
}
}
void WorldStateSync::SendWorldSnapshotTo(uint32_t p_targetPeerId)
{
SendWorldSnapshot(p_targetPeerId);
}
void WorldStateSync::HandleRequestSnapshot(const RequestSnapshotMsg& p_msg)
{
if (!m_isHost) {
@ -74,6 +109,18 @@ void WorldStateSync::HandleWorldSnapshot(const uint8_t* p_data, size_t p_length)
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];
sprintf(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();
@ -163,6 +210,22 @@ MxBool WorldStateSync::HandleEntityMutation(LegoEntity* p_entity, MxU8 p_changeT
}
}
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;
}
}
// ---- Send helpers ----
void WorldStateSync::SendSnapshotRequest()
@ -181,7 +244,7 @@ void WorldStateSync::SendWorldSnapshot(uint32_t p_targetPeerId)
return;
}
// Serialize plant + building state (~1133 bytes max, use 4096 for safety).
// Serialize plant + building + sky/light state (~1149 bytes max, use 4096 for safety).
uint8_t stateBuffer[4096];
LegoMemory memory(stateBuffer, sizeof(stateBuffer));
@ -191,6 +254,20 @@ void WorldStateSync::SendWorldSnapshot(uint32_t p_targetPeerId)
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) {
sscanf(bgValue, "set %d %d %d", &skyH, &skyS, &skyV);
}
int lightPos = 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, m_localPeerId, m_sequence++};
msg.targetPeerId = p_targetPeerId;
@ -385,4 +462,34 @@ void WorldStateSync::ApplyWorldEvent(uint8_t p_entityType, uint8_t p_changeType,
}
}
}
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();
}
}
}
}