diff --git a/LEGO1/lego/legoomni/include/legomodelpresenter.h b/LEGO1/lego/legoomni/include/legomodelpresenter.h index 950bdb7e..f7793ab1 100644 --- a/LEGO1/lego/legoomni/include/legomodelpresenter.h +++ b/LEGO1/lego/legoomni/include/legomodelpresenter.h @@ -1,6 +1,7 @@ #ifndef LEGOMODELPRESENTER_H #define LEGOMODELPRESENTER_H +#include "extensions/fwd.h" #include "lego1_export.h" #include "mxvideopresenter.h" @@ -62,6 +63,8 @@ class LegoModelPresenter : public MxVideoPresenter { void Destroy(MxBool p_fromDestructor); private: + friend class Multiplayer::Animation::Catalog; + LegoROI* m_roi; // 0x64 MxBool m_addedToView; // 0x68 diff --git a/LEGO1/modeldb/modeldb.cpp b/LEGO1/modeldb/modeldb.cpp index 919e8685..e72cc4a6 100644 --- a/LEGO1/modeldb/modeldb.cpp +++ b/LEGO1/modeldb/modeldb.cpp @@ -23,7 +23,7 @@ MxResult ModelDbModel::Read(SDL_IOStream* p_file) return FAILURE; } - m_modelName = new char[len]; + m_modelName = new char[((len + 3) & ~3u)]; if (SDL_ReadIO(p_file, m_modelName, len) != len) { return FAILURE; } @@ -38,7 +38,7 @@ MxResult ModelDbModel::Read(SDL_IOStream* p_file) return FAILURE; } - m_presenterName = new char[len]; + m_presenterName = new char[((len + 3) & ~3u)]; if (SDL_ReadIO(p_file, m_presenterName, len) != len) { return FAILURE; } diff --git a/extensions/include/extensions/multiplayer/animation/catalog.h b/extensions/include/extensions/multiplayer/animation/catalog.h index c7584aa1..20f55615 100644 --- a/extensions/include/extensions/multiplayer/animation/catalog.h +++ b/extensions/include/extensions/multiplayer/animation/catalog.h @@ -4,7 +4,6 @@ #include #include -class LegoAnimationManager; class LegoROI; struct AnimInfo; @@ -26,11 +25,35 @@ static const uint8_t ALL_CORE_ACTORS_MASK = (1 << CORE_CHARACTER_COUNT) - 1; // Sentinel value for "no animation selected" static const uint16_t ANIM_INDEX_NONE = 0xFFFF; +// World slot constants for animIndex encoding (top 2 bits) +static const uint8_t WORLD_SLOT_ACT1 = 0; +static const uint8_t WORLD_SLOT_ACT2 = 1; +static const uint8_t WORLD_SLOT_ACT3 = 2; + +// Compose a globally unique animIndex from a world slot and a local index within that world's AnimInfo array. +static constexpr uint16_t WorldAnimIndex(uint8_t p_worldSlot, uint16_t p_localIndex) +{ + return (uint16_t(p_worldSlot) << 14) | (p_localIndex & 0x3FFF); +} + +// Extract the world slot (0-2) from a world-encoded animIndex. +static constexpr uint8_t GetWorldSlot(uint16_t p_animIndex) +{ + return p_animIndex >> 14; +} + +// Extract the local index (0-16383) from a world-encoded animIndex. +static constexpr uint16_t GetLocalIndex(uint16_t p_animIndex) +{ + return p_animIndex & 0x3FFF; +} + // Extract the character indices from a performer bitmask. std::vector GetPerformerIndices(uint64_t p_performerMask); struct CatalogEntry { - uint16_t animIndex; // Index into LegoAnimationManager::m_anims[] + uint16_t animIndex; // World-encoded index: top 2 bits = world slot, bottom 14 = local index + int8_t worldId; // LegoOmni::World enum value for this animation's source world AnimCategory category; uint8_t spectatorMask; // Which core actors can trigger (bit0=Pepper..bit4=Laura) uint64_t performerMask; // Bitmask of g_actorInfoInit[] indices that appear as character models @@ -41,7 +64,10 @@ struct CatalogEntry { class Catalog { public: - void Refresh(LegoAnimationManager* p_am); + ~Catalog(); + + // Parse DTA files for all supported worlds and build the catalog. + void Refresh(); const AnimInfo* GetAnimInfo(uint16_t p_animIndex) const; const CatalogEntry* FindEntry(uint16_t p_animIndex) const; @@ -75,9 +101,9 @@ class Catalog { // Vehicle riding state for eligibility checks. enum VehicleState : uint8_t { - e_onFoot = 0, // Not riding anything - e_onOwnVehicle = 1, // Riding character's own vehicle (e.g. Pepper on skateboard) - e_onOtherVehicle = 2 // Riding a vehicle that isn't the character's own + e_onFoot = 0, // Not riding anything + e_onOwnVehicle = 1, // Riding character's own vehicle (e.g. Pepper on skateboard) + e_onOtherVehicle = 2 // Riding a vehicle that isn't the character's own }; // Check if a player's vehicle state is compatible with the animation's vehicle requirements. @@ -94,11 +120,29 @@ class Catalog { // Returns -1 if no match. static int8_t DisplayActorToCharacterIndex(uint8_t p_displayActorIndex); + // Map a LegoOmni::World enum value to a world slot index (0-2). + // Returns 0xFF if the world is not supported. + static uint8_t WorldIdToSlot(int8_t p_worldId); + private: + struct WorldAnimData { + int8_t worldId; + uint8_t worldSlot; + AnimInfo* anims; + uint16_t animCount; + }; + + bool ParseDTAFile(int8_t p_worldId, AnimInfo*& p_outAnims, uint16_t& p_outCount); + void BuildEntries(const WorldAnimData& p_world); + void LoadWorldParts(); + void Cleanup(); + + static void FreeAnimInfo(AnimInfo* p_anims, uint16_t p_count); + std::vector m_entries; std::map> m_locationIndex; // location ID → indices into m_entries - AnimInfo* m_animsBase; - uint16_t m_animCount; + std::vector m_worldData; + std::vector m_modelROIs; // keep model ROIs alive to preserve LOD refcounts }; } // namespace Multiplayer::Animation diff --git a/extensions/include/extensions/multiplayer/animation/loader.h b/extensions/include/extensions/multiplayer/animation/loader.h index 31f0e13b..8bc06388 100644 --- a/extensions/include/extensions/multiplayer/animation/loader.h +++ b/extensions/include/extensions/multiplayer/animation/loader.h @@ -61,8 +61,8 @@ struct SceneAnimData { void ReleaseTracks(); }; -// Loads animation data from ISLE.SI on demand. -// Delegates SI file access to a SIReader instance. +// Loads animation data from SI files on demand. +// Supports multiple worlds' SI files (isle.si, act2main.si, act3.si). class Loader { public: Loader(); @@ -70,30 +70,51 @@ class Loader { void SetSIReader(SIReader* p_reader) { m_reader = p_reader; } - SceneAnimData* EnsureCached(uint32_t p_objectId); - void PreloadAsync(uint32_t p_objectId); + SceneAnimData* EnsureCached(int8_t p_worldId, uint32_t p_objectId); + void PreloadAsync(int8_t p_worldId, uint32_t p_objectId); + + // Get the SI file path for a world. Returns nullptr if unsupported. + static const char* GetSIPath(int8_t p_worldId); private: class PreloadThread : public MxThread { public: - PreloadThread(Loader* p_loader, uint32_t p_objectId); + PreloadThread(Loader* p_loader, int8_t p_worldId, uint32_t p_objectId); MxResult Run() override; private: Loader* m_loader; + int8_t m_worldId; uint32_t m_objectId; }; + // SI file handle for non-act1 worlds (act1 uses the external SIReader). + struct SIHandle { + si::File* file; + si::Interleaf* interleaf; + bool ready; + }; + + bool OpenWorldSI(int8_t p_worldId); + bool ReadWorldObject(int8_t p_worldId, uint32_t p_objectId, si::Object*& p_outObj); + static bool ParseAnimationChild(si::Object* p_child, SceneAnimData& p_data); static bool ParsePhonemeChild(si::Object* p_child, SceneAnimData& p_data); static bool ParseComposite(si::Object* p_composite, SceneAnimData& p_data); void CleanupPreloadThread(); - SIReader* m_reader; - std::map m_cache; + static uint64_t CacheKey(int8_t p_worldId, uint32_t p_objectId) + { + return (uint64_t((uint8_t) p_worldId) << 32) | p_objectId; + } + + SIReader* m_reader; // external reader for isle.si (act1) + std::map m_extraSI; // SI handles for non-act1 worlds + std::map m_cache; // keyed by CacheKey(worldId, objectId) MxCriticalSection m_cacheCS; PreloadThread* m_preloadThread; + int8_t m_preloadWorldId; uint32_t m_preloadObjectId; std::atomic m_preloadDone; }; diff --git a/extensions/include/extensions/multiplayer/animation/sceneplayer.h b/extensions/include/extensions/multiplayer/animation/sceneplayer.h index 0bc8beef..aaa1bbde 100644 --- a/extensions/include/extensions/multiplayer/animation/sceneplayer.h +++ b/extensions/include/extensions/multiplayer/animation/sceneplayer.h @@ -37,6 +37,7 @@ class ScenePlayer { // When p_observerMode is true, participants are only remote performers (no local player). void Play( const AnimInfo* p_animInfo, + int8_t p_worldId, AnimCategory p_category, const ParticipantROI* p_participants, uint8_t p_participantCount, diff --git a/extensions/include/extensions/multiplayer/protocol.h b/extensions/include/extensions/multiplayer/protocol.h index 7ae53b4b..b64897d7 100644 --- a/extensions/include/extensions/multiplayer/protocol.h +++ b/extensions/include/extensions/multiplayer/protocol.h @@ -101,12 +101,12 @@ struct PlayerStateMsg { float direction[3]; float up[3]; float speed; - uint8_t walkAnimId; // Index into walk animation table (0 = default) - uint8_t idleAnimId; // Index into idle animation table (0 = default) + uint8_t walkAnimId; // Index into walk animation table (0 = default) + uint8_t idleAnimId; // Index into idle animation table (0 = default) char name[USERNAME_BUFFER_SIZE]; // Player display name (7 chars + null terminator) - uint8_t displayActorIndex; // Index into g_actorInfoInit (0-65) - uint8_t customizeData[5]; // Packed CustomizeState - uint8_t customizeFlags; // Bit 0 = allowRemoteCustomize + uint8_t displayActorIndex; // Index into g_actorInfoInit (0-65) + uint8_t customizeData[5]; // Packed CustomizeState + uint8_t customizeFlags; // Bit 0 = allowRemoteCustomize }; // Server -> all: announces which peer is the host @@ -202,15 +202,15 @@ struct AnimStartMsg { // Per-participant data in AnimCompleteMsg struct AnimCompletionParticipant { uint32_t peerId; - int8_t charIndex; // Participant's character (g_actorInfoInit index) + int8_t charIndex; // Participant's character (g_actorInfoInit index) char displayName[USERNAME_BUFFER_SIZE]; // 7 chars + null }; // Host -> All: animation completed successfully (natural completion only, not cancellation) struct AnimCompleteMsg { MessageHeader header; - uint64_t eventId; // Random 64-bit ID unique to this completion event - uint32_t objectId; // SI file object ID (stable, used as frontend key) + uint64_t eventId; // Random 64-bit ID unique to this completion event + uint16_t animIndex; // World-encoded animation index (globally unique key) uint8_t participantCount; AnimCompletionParticipant participants[8]; }; diff --git a/extensions/src/multiplayer/animation/catalog.cpp b/extensions/src/multiplayer/animation/catalog.cpp index c1aff2b7..7da296eb 100644 --- a/extensions/src/multiplayer/animation/catalog.cpp +++ b/extensions/src/multiplayer/animation/catalog.cpp @@ -2,11 +2,23 @@ #include "actions/isle_actions.h" #include "decomp.h" +#include "extensions/common/pathutils.h" #include "legoactors.h" #include "legoanimationmanager.h" +#include "legomain.h" +#include "legomodelpresenter.h" +#include "legopartpresenter.h" +#include "legovideomanager.h" #include "misc.h" +#include "misc/legostorage.h" +#include "modeldb/modeldb.h" +#include "mxdsaction.h" +#include "mxdschunk.h" #include "roi/legoroi.h" +#include "viewmanager/viewlodlist.h" +#include +#include #include using namespace Multiplayer::Animation; @@ -61,40 +73,161 @@ std::vector Multiplayer::Animation::GetPerformerIndices(uint64_t p_perfo return indices; } -void Catalog::Refresh(LegoAnimationManager* p_am) +uint8_t Catalog::WorldIdToSlot(int8_t p_worldId) +{ + switch (p_worldId) { + case LegoOmni::e_act1: + return WORLD_SLOT_ACT1; + case LegoOmni::e_act2: + return WORLD_SLOT_ACT2; + case LegoOmni::e_act3: + return WORLD_SLOT_ACT3; + default: + return 0xFF; + } +} + +Catalog::~Catalog() +{ + Cleanup(); +} + +void Catalog::Cleanup() { m_entries.clear(); m_locationIndex.clear(); - m_animsBase = nullptr; - m_animCount = 0; - if (!p_am) { + for (auto& wd : m_worldData) { + FreeAnimInfo(wd.anims, wd.animCount); + } + m_worldData.clear(); + + for (auto* roi : m_modelROIs) { + VideoManager()->Get3DManager()->Remove(*roi); + delete roi; + } + m_modelROIs.clear(); +} + +void Catalog::FreeAnimInfo(AnimInfo* p_anims, uint16_t p_count) +{ + if (!p_anims) { return; } - m_animCount = p_am->m_animCount; - m_animsBase = p_am->m_anims; + for (uint16_t i = 0; i < p_count; i++) { + delete[] p_anims[i].m_name; + if (p_anims[i].m_models) { + for (uint8_t j = 0; j < p_anims[i].m_modelCount; j++) { + delete[] p_anims[i].m_models[j].m_name; + } + delete[] p_anims[i].m_models; + } + } + delete[] p_anims; +} - if (!m_animsBase || m_animCount == 0) { +bool Catalog::ParseDTAFile(int8_t p_worldId, AnimInfo*& p_outAnims, uint16_t& p_outCount) +{ + p_outAnims = nullptr; + p_outCount = 0; + + const char* worldName = Lego()->GetWorldName((LegoOmni::World) p_worldId); + if (!worldName) { + return false; + } + + char relativePath[128]; + SDL_snprintf(relativePath, sizeof(relativePath), "\\lego\\data\\%sinf.dta", worldName); + + MxString path; + if (!Extensions::Common::ResolveGamePath(relativePath, path)) { + return false; + } + + LegoFile storage; + if (storage.Open(path.GetData(), LegoStorage::c_read) != SUCCESS) { + return false; + } + + MxU32 version; + if (storage.Read(&version, sizeof(MxU32)) != SUCCESS) { + return false; + } + + if (version != 3) { + SDL_Log("DTA version mismatch for world %s: expected 3, got %u", worldName, version); + return false; + } + + MxU16 animCount; + if (storage.Read(&animCount, sizeof(MxU16)) != SUCCESS) { + return false; + } + + if (animCount == 0) { + return false; + } + + AnimInfo* anims = new AnimInfo[animCount]; + SDL_memset(anims, 0, animCount * sizeof(AnimInfo)); + + for (uint16_t i = 0; i < animCount; i++) { + if (AnimationManager()->ReadAnimInfo(&storage, &anims[i]) != SUCCESS) { + goto fail; + } + + // Compute derived fields (mirrors LoadWorldInfo logic) + anims[i].m_characterIndex = -1; + anims[i].m_unk0x29 = FALSE; + for (int k = 0; k < 3; k++) { + anims[i].m_unk0x2a[k] = -1; + } + + // Compute vehicle indices from model names + int vehicleCount = 0; + for (uint8_t m = 0; m < anims[i].m_modelCount && vehicleCount < 3; m++) { + MxU32 vehicleIdx; + if (AnimationManager()->FindVehicle(anims[i].m_models[m].m_name, vehicleIdx) && + anims[i].m_models[m].m_unk0x2c) { + anims[i].m_unk0x2a[vehicleCount++] = (MxS8) vehicleIdx; + } + } + } + + p_outAnims = anims; + p_outCount = animCount; + return true; + +fail: + FreeAnimInfo(anims, animCount); + return false; +} + +void Catalog::BuildEntries(const WorldAnimData& p_world) +{ + if (!p_world.anims || p_world.animCount == 0) { return; } - for (uint16_t i = 0; i < m_animCount; i++) { - if (!m_animsBase[i].m_name || m_animsBase[i].m_objectId == 0) { + for (uint16_t i = 0; i < p_world.animCount; i++) { + const AnimInfo& animInfo = p_world.anims[i]; + if (!animInfo.m_name || animInfo.m_objectId == 0) { continue; } CatalogEntry entry; - entry.animIndex = i; - entry.spectatorMask = m_animsBase[i].m_unk0x0c; - entry.location = m_animsBase[i].m_location; - entry.modelCount = m_animsBase[i].m_modelCount; + entry.animIndex = WorldAnimIndex(p_world.worldSlot, i); + entry.worldId = p_world.worldId; + entry.spectatorMask = animInfo.m_unk0x0c; + entry.location = animInfo.m_location; + entry.modelCount = animInfo.m_modelCount; // Compute performerMask by matching models against g_actorInfoInit[].m_name entry.performerMask = 0; for (uint8_t m = 0; m < entry.modelCount; m++) { - if (m_animsBase[i].m_models && m_animsBase[i].m_models[m].m_name) { - int8_t charIdx = GetCharacterIndex(m_animsBase[i].m_models[m].m_name); + if (animInfo.m_models && animInfo.m_models[m].m_name) { + int8_t charIdx = GetCharacterIndex(animInfo.m_models[m].m_name); if (charIdx >= 0) { entry.performerMask |= (uint64_t(1) << charIdx); } @@ -102,12 +235,10 @@ void Catalog::Refresh(LegoAnimationManager* p_am) } // Compute vehicleMask from the pre-populated vehicle list (m_unk0x2a). - // Each entry is a g_vehicles[] index set during LoadWorldInfo for models - // with m_unk0x2c=1 that match a known vehicle name. entry.vehicleMask = 0; for (int k = 0; k < 3; k++) { - if (m_animsBase[i].m_unk0x2a[k] >= 0 && m_animsBase[i].m_unk0x2a[k] < (int8_t) sizeOfArray(g_vehicles)) { - entry.vehicleMask |= (1 << m_animsBase[i].m_unk0x2a[k]); + if (animInfo.m_unk0x2a[k] >= 0 && animInfo.m_unk0x2a[k] < (int8_t) sizeOfArray(g_vehicles)) { + entry.vehicleMask |= (1 << animInfo.m_unk0x2a[k]); } } @@ -122,23 +253,31 @@ void Catalog::Refresh(LegoAnimationManager* p_am) // Manual overrides for prop-only animations that have no character // performers but are valid scene animations with spectator-only slots. - MxU32 objectId = m_animsBase[i].m_objectId; - if (objectId == IsleScript::c_snsx31sh_RunAnim || objectId == IsleScript::c_fpz166p1_RunAnim || - objectId == IsleScript::c_nic002pr_RunAnim || objectId == IsleScript::c_nic003pr_RunAnim || - objectId == IsleScript::c_nic004pr_RunAnim || objectId == IsleScript::c_prp101pr_RunAnim) { - if (objectId == IsleScript::c_prp101pr_RunAnim) { - entry.location = 11; // Hospital + // These are Isle-specific (ACT1) object IDs. + bool overridden = false; + if (p_world.worldId == LegoOmni::e_act1) { + MxU32 objectId = animInfo.m_objectId; + if (objectId == IsleScript::c_snsx31sh_RunAnim || objectId == IsleScript::c_fpz166p1_RunAnim || + objectId == IsleScript::c_nic002pr_RunAnim || objectId == IsleScript::c_nic003pr_RunAnim || + objectId == IsleScript::c_nic004pr_RunAnim || objectId == IsleScript::c_prp101pr_RunAnim) { + if (objectId == IsleScript::c_prp101pr_RunAnim) { + entry.location = 11; // Hospital + } + entry.category = e_camAnim; + overridden = true; } - entry.category = e_camAnim; } - else if (!hasNamedPerformer) { - entry.category = e_otherAnim; - } - else if (entry.location == -1) { - entry.category = e_npcAnim; - } - else { - entry.category = e_camAnim; + + if (!overridden) { + if (!hasNamedPerformer) { + entry.category = e_otherAnim; + } + else if (entry.location == -1) { + entry.category = e_npcAnim; + } + else { + entry.category = e_camAnim; + } } size_t idx = m_entries.size(); @@ -149,12 +288,56 @@ void Catalog::Refresh(LegoAnimationManager* p_am) } } +void Catalog::Refresh() +{ + Cleanup(); + + static const int8_t worldIds[] = { + (int8_t) LegoOmni::e_act1, + (int8_t) LegoOmni::e_act2, + (int8_t) LegoOmni::e_act3, + }; + + for (int w = 0; w < (int) sizeOfArray(worldIds); w++) { + int8_t worldId = worldIds[w]; + uint8_t slot = WorldIdToSlot(worldId); + if (slot == 0xFF) { + continue; + } + + AnimInfo* anims = nullptr; + uint16_t count = 0; + if (!ParseDTAFile(worldId, anims, count)) { + continue; + } + + WorldAnimData wd; + wd.worldId = worldId; + wd.worldSlot = slot; + wd.anims = anims; + wd.animCount = count; + m_worldData.push_back(wd); + + BuildEntries(wd); + } + + LoadWorldParts(); +} + const AnimInfo* Catalog::GetAnimInfo(uint16_t p_animIndex) const { - if (!m_animsBase || p_animIndex >= m_animCount) { - return nullptr; + uint8_t slot = GetWorldSlot(p_animIndex); + uint16_t localIndex = GetLocalIndex(p_animIndex); + + for (const auto& wd : m_worldData) { + if (wd.worldSlot == slot) { + if (localIndex < wd.animCount) { + return &wd.anims[localIndex]; + } + return nullptr; + } } - return &m_animsBase[p_animIndex]; + return nullptr; } int8_t Catalog::DisplayActorToCharacterIndex(uint8_t p_displayActorIndex) @@ -225,8 +408,8 @@ bool Catalog::CheckVehicleEligibility(const CatalogEntry* p_entry, int8_t p_char case e_onOwnVehicle: return animUsesVehicle; // Only animations that use this character's vehicle case e_onOtherVehicle: - return false; // On a foreign vehicle — no animations eligible - default: // e_onFoot + return false; // On a foreign vehicle — no animations eligible + default: // e_onFoot return !animUsesVehicle; // Only animations that don't use this character's vehicle } } @@ -339,3 +522,112 @@ bool Catalog::CanTrigger( return allPerformersCovered && *p_spectatorFilled; } + +void Catalog::LoadWorldParts() +{ + MxString wdbPath; + if (!Extensions::Common::ResolveGamePath("\\lego\\data\\world.wdb", wdbPath)) { + return; + } + + SDL_IOStream* wdbFile = SDL_IOFromFile(wdbPath.GetData(), "rb"); + if (!wdbFile) { + return; + } + + ModelDbWorld* worlds = nullptr; + MxS32 numWorlds = 0; + ReadModelDbWorlds(wdbFile, worlds, numWorlds); + + if (!worlds || numWorlds == 0) { + SDL_CloseIO(wdbFile); + return; + } + + // Skip the global textures + parts section (same offset cached by the game) + // We need to read it if the game hasn't already (g_wdbSkipGlobalPartsOffset == 0), + // but the game always loads before us, so just skip past it. + // The game's LoadWorld() sets g_wdbSkipGlobalPartsOffset after reading globals. + + for (MxS32 i = 0; i < numWorlds; i++) { + // Load parts from all worlds (skip check: Lookup returns non-null if already registered) + ModelDbPartListCursor cursor(worlds[i].m_partList); + ModelDbPart* part; + + while (cursor.Next(part)) { + ViewLODList* existing = GetViewLODListManager()->Lookup(part->m_roiName.GetData()); + if (existing) { + existing->Release(); + continue; + } + + MxU8* buff = new MxU8[part->m_partDataLength]; + SDL_SeekIO(wdbFile, part->m_partDataOffset, SDL_IO_SEEK_SET); + if (SDL_ReadIO(wdbFile, buff, part->m_partDataLength) != part->m_partDataLength) { + delete[] buff; + continue; + } + + MxDSChunk chunk; + chunk.SetLength(part->m_partDataLength); + chunk.SetData(buff); + + LegoPartPresenter partPresenter; + if (partPresenter.Read(chunk) == SUCCESS) { + partPresenter.Store(); + } + + delete[] buff; + } + + // Load models whose LODs aren't registered yet + for (MxS32 j = 0; j < worlds[i].m_numModels; j++) { + ModelDbModel& model = worlds[i].m_models[j]; + if (!model.m_modelName) { + continue; + } + + // Only load models that aren't already available as LODs + char loweredName[256]; + SDL_strlcpy(loweredName, model.m_modelName, sizeof(loweredName)); + SDL_strlwr(loweredName); + + ViewLODList* existing = GetViewLODListManager()->Lookup(loweredName); + if (existing) { + existing->Release(); + continue; + } + + MxU8* buff = new MxU8[model.m_modelDataLength]; + SDL_SeekIO(wdbFile, model.m_modelDataOffset, SDL_IO_SEEK_SET); + if (SDL_ReadIO(wdbFile, buff, model.m_modelDataLength) != model.m_modelDataLength) { + delete[] buff; + continue; + } + + MxDSChunk chunk; + chunk.SetLength(model.m_modelDataLength); + chunk.SetData(buff); + + // Use friend access to LegoModelPresenter's private CreateROI + m_roi + LegoModelPresenter modelPresenter; + MxDSAction action; + modelPresenter.SetAction(&action); + + if (modelPresenter.CreateROI(&chunk) == SUCCESS && modelPresenter.m_roi) { + // Add to 3D scene (hidden) so ScenePlayer::cloneSceneROI can find it + modelPresenter.m_roi->SetVisibility(FALSE); + VideoManager()->Get3DManager()->Add(*modelPresenter.m_roi); + + // Steal the ROI to keep it alive (Destroy() just nulls m_roi) + m_modelROIs.push_back(modelPresenter.m_roi); + modelPresenter.m_roi = nullptr; + } + + delete[] buff; + } + } + + FreeModelDbWorlds(worlds, numWorlds); + SDL_CloseIO(wdbFile); +} diff --git a/extensions/src/multiplayer/animation/loader.cpp b/extensions/src/multiplayer/animation/loader.cpp index e8f1b360..95afd5c4 100644 --- a/extensions/src/multiplayer/animation/loader.cpp +++ b/extensions/src/multiplayer/animation/loader.cpp @@ -2,6 +2,7 @@ #include "anim/legoanim.h" #include "flic.h" +#include "legomain.h" #include "misc/legostorage.h" #include "mxautolock.h" @@ -92,13 +93,98 @@ SceneAnimData& SceneAnimData::operator=(SceneAnimData&& p_other) noexcept return *this; } -Loader::Loader() : m_reader(nullptr), m_preloadThread(nullptr), m_preloadObjectId(0), m_preloadDone(false) +Loader::Loader() + : m_reader(nullptr), m_preloadThread(nullptr), m_preloadWorldId(0), m_preloadObjectId(0), m_preloadDone(false) { } Loader::~Loader() { CleanupPreloadThread(); + + for (auto& pair : m_extraSI) { + delete pair.second.interleaf; + delete pair.second.file; + } +} + +const char* Loader::GetSIPath(int8_t p_worldId) +{ + switch (p_worldId) { + case LegoOmni::e_act1: + return "\\lego\\scripts\\isle\\isle.si"; + case LegoOmni::e_act2: + return "\\lego\\scripts\\act2\\act2main.si"; + case LegoOmni::e_act3: + return "\\lego\\scripts\\act3\\act3.si"; + default: + return nullptr; + } +} + +bool Loader::OpenWorldSI(int8_t p_worldId) +{ + // Act1 uses the external SIReader + if (p_worldId == LegoOmni::e_act1) { + return m_reader && m_reader->Open(); + } + + auto it = m_extraSI.find(p_worldId); + if (it != m_extraSI.end() && it->second.ready) { + return true; + } + + const char* siPath = GetSIPath(p_worldId); + if (!siPath) { + return false; + } + + SIHandle handle = {nullptr, nullptr, false}; + if (!SIReader::OpenHeaderOnly(siPath, handle.file, handle.interleaf)) { + return false; + } + + handle.ready = true; + m_extraSI[p_worldId] = handle; + return true; +} + +bool Loader::ReadWorldObject(int8_t p_worldId, uint32_t p_objectId, si::Object*& p_outObj) +{ + p_outObj = nullptr; + + if (p_worldId == LegoOmni::e_act1) { + // Act1: use external SIReader + if (!m_reader || !m_reader->ReadObject(p_objectId)) { + return false; + } + p_outObj = m_reader->GetObject(p_objectId); + return p_outObj != nullptr; + } + + auto it = m_extraSI.find(p_worldId); + if (it == m_extraSI.end() || !it->second.ready) { + return false; + } + + si::Interleaf* interleaf = it->second.interleaf; + si::File* file = it->second.file; + + size_t childCount = interleaf->GetChildCount(); + if (p_objectId >= childCount) { + return false; + } + + si::Object* obj = static_cast(interleaf->GetChildAt(p_objectId)); + if (obj->type() == si::MxOb::Null) { + if (interleaf->ReadObject(file, p_objectId) != si::Interleaf::ERROR_SUCCESS) { + return false; + } + obj = static_cast(interleaf->GetChildAt(p_objectId)); + } + + p_outObj = obj; + return true; } bool Loader::ParseAnimationChild(si::Object* p_child, SceneAnimData& p_data) @@ -233,38 +319,36 @@ bool Loader::ParseComposite(si::Object* p_composite, SceneAnimData& p_data) return hasAnim; } -SceneAnimData* Loader::EnsureCached(uint32_t p_objectId) +SceneAnimData* Loader::EnsureCached(int8_t p_worldId, uint32_t p_objectId) { + uint64_t key = CacheKey(p_worldId, p_objectId); + { AUTOLOCK(m_cacheCS); - auto it = m_cache.find(p_objectId); + auto it = m_cache.find(key); if (it != m_cache.end()) { return &it->second; } } // If a preload is in progress for this object, wait for it to finish - if (m_preloadThread && m_preloadObjectId == p_objectId) { + if (m_preloadThread && m_preloadWorldId == p_worldId && m_preloadObjectId == p_objectId) { CleanupPreloadThread(); AUTOLOCK(m_cacheCS); - auto it = m_cache.find(p_objectId); + auto it = m_cache.find(key); if (it != m_cache.end()) { return &it->second; } // Preload failed — fall through to synchronous load } - if (!m_reader || !m_reader->Open()) { + if (!OpenWorldSI(p_worldId)) { return nullptr; } - if (!m_reader->ReadObject(p_objectId)) { - return nullptr; - } - - si::Object* composite = m_reader->GetObject(p_objectId); - if (!composite) { + si::Object* composite = nullptr; + if (!ReadWorldObject(p_worldId, p_objectId, composite)) { return nullptr; } @@ -274,7 +358,7 @@ SceneAnimData* Loader::EnsureCached(uint32_t p_objectId) } AUTOLOCK(m_cacheCS); - auto result = m_cache.emplace(p_objectId, std::move(data)); + auto result = m_cache.emplace(key, std::move(data)); return &result.first->second; } @@ -286,37 +370,47 @@ void Loader::CleanupPreloadThread() } } -void Loader::PreloadAsync(uint32_t p_objectId) +void Loader::PreloadAsync(int8_t p_worldId, uint32_t p_objectId) { + uint64_t key = CacheKey(p_worldId, p_objectId); + { AUTOLOCK(m_cacheCS); - if (m_cache.find(p_objectId) != m_cache.end()) { + if (m_cache.find(key) != m_cache.end()) { return; } } - if (m_preloadThread && m_preloadObjectId == p_objectId && !m_preloadDone) { + if (m_preloadThread && m_preloadWorldId == p_worldId && m_preloadObjectId == p_objectId && !m_preloadDone) { return; } CleanupPreloadThread(); + m_preloadWorldId = p_worldId; m_preloadObjectId = p_objectId; m_preloadDone = false; - m_preloadThread = new PreloadThread(this, p_objectId); + m_preloadThread = new PreloadThread(this, p_worldId, p_objectId); m_preloadThread->Start(0x1000, 0); } -Loader::PreloadThread::PreloadThread(Loader* p_loader, uint32_t p_objectId) : m_loader(p_loader), m_objectId(p_objectId) +Loader::PreloadThread::PreloadThread(Loader* p_loader, int8_t p_worldId, uint32_t p_objectId) + : m_loader(p_loader), m_worldId(p_worldId), m_objectId(p_objectId) { } MxResult Loader::PreloadThread::Run() { + const char* siPath = GetSIPath(m_worldId); + if (!siPath) { + m_loader->m_preloadDone = true; + return MxThread::Run(); + } + si::File* siFile = nullptr; si::Interleaf* interleaf = nullptr; - if (!SIReader::OpenHeaderOnly("\\lego\\scripts\\isle\\isle.si", siFile, interleaf)) { + if (!SIReader::OpenHeaderOnly(siPath, siFile, interleaf)) { m_loader->m_preloadDone = true; return MxThread::Run(); } @@ -327,8 +421,9 @@ MxResult Loader::PreloadThread::Run() SceneAnimData data; if (ParseComposite(composite, data)) { + uint64_t key = CacheKey(m_worldId, m_objectId); AUTOLOCK(m_loader->m_cacheCS); - m_loader->m_cache.emplace(m_objectId, std::move(data)); + m_loader->m_cache.emplace(key, std::move(data)); } } diff --git a/extensions/src/multiplayer/animation/phonemeplayer.cpp b/extensions/src/multiplayer/animation/phonemeplayer.cpp index 4597ca16..d6f44e6b 100644 --- a/extensions/src/multiplayer/animation/phonemeplayer.cpp +++ b/extensions/src/multiplayer/animation/phonemeplayer.cpp @@ -47,7 +47,8 @@ void PhonemePlayer::Init( const std::vector>& p_actorAliases ) { - for (auto& track : p_tracks) { + for (size_t trackIdx = 0; trackIdx < p_tracks.size(); trackIdx++) { + auto& track = p_tracks[trackIdx]; PhonemeState state; state.targetROI = nullptr; state.originalTexture = nullptr; @@ -63,6 +64,25 @@ void PhonemePlayer::Init( } state.targetROI = targetROI; + // If a previous track already set up a cached texture for this ROI, reuse it. + // Otherwise the second track's "original" would be the first track's cached texture, + // causing a use-after-free during cleanup. + PhonemeState* existing = nullptr; + for (size_t j = 0; j < m_states.size(); j++) { + if (m_states[j].targetROI == targetROI && m_states[j].cachedTexture) { + existing = &m_states[j]; + break; + } + } + + if (existing) { + state.cachedTexture = existing->cachedTexture; + state.bitmap = new MxBitmap(); + state.bitmap->SetSize(track.width, track.height, NULL, FALSE); + m_states.push_back(state); + continue; + } + LegoROI* head = targetROI->FindChildROI("head", targetROI); if (!head) { m_states.push_back(state); @@ -173,11 +193,13 @@ void PhonemePlayer::Cleanup() for (size_t i = 0; i < m_states.size(); i++) { auto& state = m_states[i]; + // Only the state that owns the original texture (i.e. performed the initial setup) + // should restore and erase. Other states sharing the same cachedTexture are secondary. if (state.targetROI && state.originalTexture) { CharacterManager()->SetHeadTexture(state.targetROI, state.originalTexture); } - if (state.cachedTexture) { + if (state.originalTexture && state.cachedTexture) { TextureContainer()->EraseCached(state.cachedTexture); } diff --git a/extensions/src/multiplayer/animation/sceneplayer.cpp b/extensions/src/multiplayer/animation/sceneplayer.cpp index f2d913e2..9e717dbc 100644 --- a/extensions/src/multiplayer/animation/sceneplayer.cpp +++ b/extensions/src/multiplayer/animation/sceneplayer.cpp @@ -22,8 +22,8 @@ #include #include #include -#include #include +#include #include using namespace Multiplayer::Animation; @@ -267,6 +267,7 @@ void ScenePlayer::SetupROIs(const AnimInfo* p_animInfo) void ScenePlayer::Play( const AnimInfo* p_animInfo, + int8_t p_worldId, AnimCategory p_category, const ParticipantROI* p_participants, uint8_t p_participantCount, @@ -281,7 +282,7 @@ void ScenePlayer::Play( return; } - SceneAnimData* data = m_loader->EnsureCached(p_animInfo->m_objectId); + SceneAnimData* data = m_loader->EnsureCached(p_worldId, p_animInfo->m_objectId); if (!data || !data->anim) { return; } diff --git a/extensions/src/multiplayer/networkmanager.cpp b/extensions/src/multiplayer/networkmanager.cpp index 0a7ea518..14471145 100644 --- a/extensions/src/multiplayer/networkmanager.cpp +++ b/extensions/src/multiplayer/networkmanager.cpp @@ -381,12 +381,10 @@ void NetworkManager::OnWorldEnabled(LegoWorld* p_world) NotifyPlayerCountChanged(); EnforceDisableNPCs(); - // Refresh animation catalog from the animation manager - if (AnimationManager()) { - m_animCatalog.Refresh(AnimationManager()); - m_animCoordinator.SetCatalog(&m_animCatalog); - m_animSessionHost.SetCatalog(&m_animCatalog); - } + // 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(); @@ -1515,9 +1513,10 @@ void NetworkManager::TickHostSessions() m_animSessionHost.StartCountdown(animIndex); if (m_animCoordinator.IsLocalPlayerInSession(animIndex)) { - const AnimInfo* ai = m_animCatalog.GetAnimInfo(animIndex); + const Animation::CatalogEntry* ce = m_animCatalog.FindEntry(animIndex); + const AnimInfo* ai = ce ? m_animCatalog.GetAnimInfo(animIndex) : nullptr; if (ai) { - m_animLoader.PreloadAsync(ai->m_objectId); + m_animLoader.PreloadAsync(ce->worldId, ai->m_objectId); } } @@ -1644,9 +1643,10 @@ void NetworkManager::HandleAnimUpdate(const AnimUpdateMsg& p_msg) m_animCoordinator.ApplySessionUpdate(p_msg.animIndex, p_msg.state, p_msg.countdownMs, slots, p_msg.slotCount); if (p_msg.state == static_cast(Animation::CoordinationState::e_countdown)) { - const AnimInfo* ai = m_animCatalog.GetAnimInfo(p_msg.animIndex); + 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(ai->m_objectId); + m_animLoader.PreloadAsync(ce->worldId, ai->m_objectId); } } @@ -1783,7 +1783,14 @@ void NetworkManager::HandleAnimStartLocally(uint16_t p_animIndex, bool p_localIn }); } - scenePlayer->Play(animInfo, entry->category, participants.data(), (uint8_t) participants.size(), observerMode); + scenePlayer->Play( + animInfo, + entry->worldId, + entry->category, + participants.data(), + (uint8_t) participants.size(), + observerMode + ); if (!scenePlayer->IsPlaying()) { if (!observerMode) { @@ -1860,7 +1867,7 @@ void NetworkManager::BroadcastAnimComplete(uint16_t p_animIndex) AnimCompleteMsg msg{}; msg.header = {MSG_ANIM_COMPLETE, 0, m_localPeerId, m_sequence++, TARGET_BROADCAST}; msg.eventId = (static_cast(SDL_rand_bits()) << 32) | static_cast(SDL_rand_bits()); - msg.objectId = animInfo->m_objectId; + msg.animIndex = p_animIndex; msg.participantCount = 0; char localName[8]; @@ -1940,8 +1947,8 @@ void NetworkManager::HandleAnimComplete(const AnimCompleteMsg& p_msg) std::string json = "{\"eventId\":\""; json += eventIdHex; - json += "\",\"objectId\":"; - json += std::to_string(p_msg.objectId); + json += "\",\"animIndex\":"; + json += std::to_string(p_msg.animIndex); json += ",\"participants\":["; // Emit local player first so frontend can rely on participants[0] being self @@ -2189,8 +2196,6 @@ static void BuildAnimationJson( p_json += std::to_string(p_info.animIndex); p_json += ",\"name\":"; JsonAppendString(p_json, p_animInfo->m_name ? p_animInfo->m_name : ""); - p_json += ",\"objectId\":"; - p_json += std::to_string(p_animInfo->m_objectId); p_json += ",\"category\":"; p_json += std::to_string(static_cast(p_info.entry->category)); p_json += ",\"eligible\":"; diff --git a/extensions/src/multiplayer/sireader.cpp b/extensions/src/multiplayer/sireader.cpp index c0f1c70e..d5635a2e 100644 --- a/extensions/src/multiplayer/sireader.cpp +++ b/extensions/src/multiplayer/sireader.cpp @@ -149,7 +149,7 @@ AudioTrack* SIReader::ExtractFirstAudio(uint32_t p_objectId) for (size_t i = 0; i < composite->GetChildCount(); i++) { si::Object* child = static_cast(composite->GetChildAt(i)); - if (child->filetype() == si::MxOb::WAV) { + if (child->presenter_.find("MxWavePresenter") != std::string::npos) { AudioTrack* track = new AudioTrack(); if (ExtractAudioTrack(child, *track)) { return track;