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>
This commit is contained in:
Christian Semmler 2026-03-28 18:07:37 -07:00
parent e61f47abb2
commit 0c986aff2a
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
12 changed files with 589 additions and 105 deletions

View File

@ -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

View File

@ -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;
}

View File

@ -4,7 +4,6 @@
#include <map>
#include <vector>
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<int8_t> 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;
@ -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<CatalogEntry> m_entries;
std::map<int16_t, std::vector<size_t>> m_locationIndex; // location ID → indices into m_entries
AnimInfo* m_animsBase;
uint16_t m_animCount;
std::vector<WorldAnimData> m_worldData;
std::vector<LegoROI*> m_modelROIs; // keep model ROIs alive to preserve LOD refcounts
};
} // namespace Multiplayer::Animation

View File

@ -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<uint32_t, SceneAnimData> 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<int8_t, SIHandle> m_extraSI; // SI handles for non-act1 worlds
std::map<uint64_t, SceneAnimData> m_cache; // keyed by CacheKey(worldId, objectId)
MxCriticalSection m_cacheCS;
PreloadThread* m_preloadThread;
int8_t m_preloadWorldId;
uint32_t m_preloadObjectId;
std::atomic<bool> m_preloadDone;
};

View File

@ -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,

View File

@ -210,7 +210,7 @@ struct AnimCompletionParticipant {
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)
uint16_t animIndex; // World-encoded animation index (globally unique key)
uint8_t participantCount;
AnimCompletionParticipant participants[8];
};

View File

@ -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 <SDL3/SDL_iostream.h>
#include <SDL3/SDL_log.h>
#include <SDL3/SDL_stdinc.h>
using namespace Multiplayer::Animation;
@ -61,40 +73,161 @@ std::vector<int8_t> 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,7 +253,10 @@ 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;
// 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) {
@ -130,8 +264,12 @@ void Catalog::Refresh(LegoAnimationManager* p_am)
entry.location = 11; // Hospital
}
entry.category = e_camAnim;
overridden = true;
}
else if (!hasNamedPerformer) {
}
if (!overridden) {
if (!hasNamedPerformer) {
entry.category = e_otherAnim;
}
else if (entry.location == -1) {
@ -140,6 +278,7 @@ void Catalog::Refresh(LegoAnimationManager* p_am)
else {
entry.category = e_camAnim;
}
}
size_t idx = m_entries.size();
m_entries.push_back(entry);
@ -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) {
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)
@ -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);
}

View File

@ -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<si::Object*>(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<si::Object*>(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));
}
}

View File

@ -47,7 +47,8 @@ void PhonemePlayer::Init(
const std::vector<std::pair<std::string, LegoROI*>>& 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);
}

View File

@ -22,8 +22,8 @@
#include <SDL3/SDL_timer.h>
#include <algorithm>
#include <cmath>
#include <functional>
#include <deque>
#include <functional>
#include <vector>
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;
}

View File

@ -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());
// 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<uint8_t>(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<uint64_t>(SDL_rand_bits()) << 32) | static_cast<uint64_t>(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<uint8_t>(p_info.entry->category));
p_json += ",\"eligible\":";

View File

@ -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<si::Object*>(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;