isle-portable/extensions/src/multiplayer/animation/sceneplayer.cpp
Christian Semmler ac4cd6b979
Fix crash when performer's child ROIs are left dangling in ScenePlayer
NotifyROIDestroyed now walks the parent chain to also invalidate child
ROIs of the destroyed performer (head, limbs, etc.) that were placed
into the roiMap by BuildROIMap. The ancestor walk happens once; all
other fields are cleaned with simple pointer equality.
2026-04-03 20:42:41 -07:00

676 lines
18 KiB
C++

#include "extensions/multiplayer/animation/sceneplayer.h"
#include "3dmanager/lego3dmanager.h"
#include "anim/legoanim.h"
#include "extensions/common/animutils.h"
#include "extensions/common/charactercloner.h"
#include "extensions/multiplayer/mputils.h"
#include "legoactors.h"
#include "legoanimationmanager.h"
#include "legocameracontroller.h"
#include "legocharactermanager.h"
#include "legovideomanager.h"
#include "legoworld.h"
#include "misc.h"
#include "misc/legotree.h"
#include "mxbackgroundaudiomanager.h"
#include "mxgeometry/mxgeometry3d.h"
#include "realtime/realtime.h"
#include "roi/legoroi.h"
#include "viewmanager/viewmanager.h"
#include <SDL3/SDL_stdinc.h>
#include <SDL3/SDL_timer.h>
#include <algorithm>
#include <cmath>
#include <deque>
#include <functional>
#include <vector>
using namespace Multiplayer::Animation;
namespace AnimUtils = Extensions::Common::AnimUtils;
using Extensions::Common::CharacterCloner;
static bool MatchesCharacter(const std::string& p_actorName, int8_t p_charIndex)
{
if (p_charIndex < 0 || p_charIndex >= (int8_t) sizeOfArray(g_actorInfoInit)) {
return false;
}
return !SDL_strcasecmp(p_actorName.c_str(), g_actorInfoInit[p_charIndex].m_name);
}
ScenePlayer::ScenePlayer()
: m_loader(nullptr), m_playing(false), m_rebaseComputed(false), m_startTime(0), m_currentData(nullptr),
m_category(e_npcAnim), m_animRootROI(nullptr), m_vehicleROI(nullptr), m_hiddenVehicleROI(nullptr),
m_roiMap(nullptr), m_roiMapSize(0), m_hasCamAnim(false), m_observerMode(false), m_hideOnStop(false)
{
}
ScenePlayer::~ScenePlayer()
{
if (m_playing) {
Stop();
}
}
void ScenePlayer::SetupROIs(const AnimInfo* p_animInfo)
{
LegoU32 numActors = m_currentData->anim->GetNumActors();
std::vector<LegoROI*> createdROIs;
std::vector<AnimUtils::ROIAlias> aliases;
std::deque<std::string> aliasNames;
std::vector<bool> participantMatched(m_participants.size(), false);
// Register an alias mapping an animation actor name to an ROI whose actual
// name differs (e.g. a participant's unique name, or a cloned scene ROI).
auto addAlias = [&](const std::string& p_name, LegoROI* p_roi) {
aliasNames.push_back(p_name);
aliases.push_back({aliasNames.back().c_str(), p_roi});
m_actorAliases.push_back({p_name, p_roi});
};
// Create a prop ROI from a registered LOD name. Returns nullptr if the
// LOD isn't in the ViewLODListManager.
auto createProp = [&](const std::string& p_name, const char* p_lodName) -> LegoROI* {
char uniqueName[64];
SDL_snprintf(uniqueName, sizeof(uniqueName), "npc_prop_%s", p_name.c_str());
LegoROI* roi = CharacterManager()->CreateAutoROI(uniqueName, p_lodName, FALSE);
if (roi) {
roi->SetName(p_name.c_str());
createdROIs.push_back(roi);
}
return roi;
};
// Clone a scene ROI by name. Creates an independent deep copy (shared LOD
// geometry via refcount) with a unique name and an alias for the ROI map.
auto cloneSceneROI = [&](const std::string& p_name) -> LegoROI* {
const CompoundObject& sceneROIs = VideoManager()->Get3DManager()->GetLego3DView()->GetViewManager()->GetROIs();
for (CompoundObject::const_iterator it = sceneROIs.begin(); it != sceneROIs.end(); it++) {
LegoROI* source = (LegoROI*) *it;
if (!source->GetName() || SDL_strcasecmp(source->GetName(), p_name.c_str())) {
continue;
}
static uint32_t s_counter = 0;
char uniqueName[64];
SDL_snprintf(uniqueName, sizeof(uniqueName), "npc_scene_%s_%u", p_name.c_str(), s_counter++);
LegoROI* clone = Multiplayer::DeepCloneROI(source, uniqueName);
if (clone) {
clone->SetVisibility(FALSE);
VideoManager()->Get3DManager()->Add(*clone);
m_clonedSceneROIs.push_back(clone);
addAlias(p_name, clone);
}
return clone;
}
return nullptr;
};
for (LegoU32 i = 0; i < numActors; i++) {
const char* actorName = m_currentData->anim->GetActorName(i);
LegoU32 actorType = m_currentData->anim->GetActorType(i);
if (!actorName || *actorName == '\0') {
continue;
}
const char* lookupName = (*actorName == '*') ? actorName + 1 : actorName;
std::string lowered(lookupName);
std::transform(lowered.begin(), lowered.end(), lowered.begin(), ::tolower);
if (actorType == LegoAnimActorEntry::e_managedLegoActor) {
// Character actor: match to a participant or clone as NPC
bool matched = false;
for (size_t p = 0; p < m_participants.size(); p++) {
if (participantMatched[p] || m_participants[p].IsSpectator()) {
continue;
}
if (MatchesCharacter(lowered, m_participants[p].charIndex)) {
participantMatched[p] = true;
matched = true;
addAlias(lowered, m_participants[p].roi);
break;
}
}
if (!matched) {
char uniqueName[64];
SDL_snprintf(uniqueName, sizeof(uniqueName), "npc_char_%s", lowered.c_str());
LegoROI* roi = CharacterCloner::Clone(CharacterManager(), uniqueName, lowered.c_str());
if (roi) {
roi->SetName(lowered.c_str());
VideoManager()->Get3DManager()->Add(*roi);
createdROIs.push_back(roi);
}
}
}
else if (actorType == LegoAnimActorEntry::e_managedInvisibleRoiTrimmed || actorType == LegoAnimActorEntry::e_sceneRoi1 || actorType == LegoAnimActorEntry::e_sceneRoi2) {
createProp(lowered, Multiplayer::TrimLODSuffix(lowered).c_str());
}
else if (actorType == LegoAnimActorEntry::e_managedInvisibleRoi) {
createProp(lowered, lowered.c_str());
}
else {
// Type 0/1: scene actor, vehicle, or prop
LegoROI* roi = nullptr;
// Check if this is a vehicle actor via ModelInfo flag
bool isVehicleActor = false;
for (uint8_t m = 0; m < p_animInfo->m_modelCount; m++) {
if (p_animInfo->m_models[m].m_name &&
!SDL_strcasecmp(lowered.c_str(), p_animInfo->m_models[m].m_name) &&
p_animInfo->m_models[m].m_unk0x2c) {
isVehicleActor = true;
break;
}
}
// Try matching a participant's vehicle by category
if (isVehicleActor && !m_vehicleROI) {
MxU32 animVehicleIdx;
if (AnimationManager()->FindVehicle(lowered.c_str(), animVehicleIdx)) {
for (size_t p = 0; p < m_participants.size(); p++) {
if (!m_participants[p].vehicleROI) {
continue;
}
MxU32 perfVehicleIdx;
if (AnimationManager()->FindVehicle(m_participants[p].vehicleROI->GetName(), perfVehicleIdx)) {
if (Catalog::GetVehicleCategory((int8_t) animVehicleIdx) ==
Catalog::GetVehicleCategory((int8_t) perfVehicleIdx)) {
m_vehicleROI = m_participants[p].vehicleROI;
addAlias(lowered, m_vehicleROI);
roi = m_vehicleROI;
break;
}
}
}
}
}
// Try creating from a registered LOD
if (!roi) {
roi = createProp(lowered, Multiplayer::TrimLODSuffix(lowered).c_str());
}
// Fallback: clone an existing scene ROI (for models like BIRD
// whose LOD data is embedded in the world, not registered separately)
if (!roi) {
roi = cloneSceneROI(lowered);
}
// Final fallback: borrow local player's vehicle via alias
if (!roi && m_participants[0].vehicleROI && !m_vehicleROI) {
m_vehicleROI = m_participants[0].vehicleROI;
addAlias(lowered, m_vehicleROI);
}
}
}
m_propROIs = std::move(createdROIs);
// Find root ROI: first non-spectator participant matched to an animation actor
LegoROI* rootROI = nullptr;
for (size_t p = 0; p < m_participants.size(); p++) {
if (!m_participants[p].IsSpectator() && participantMatched[p]) {
rootROI = m_participants[p].roi;
break;
}
}
if (!rootROI && !m_participants.empty()) {
rootROI = m_participants[0].roi;
}
if (!rootROI) {
return;
}
m_animRootROI = rootROI;
// Collect extra ROIs (other matched participants + props + vehicle)
std::vector<LegoROI*> extras;
for (size_t p = 0; p < m_participants.size(); p++) {
if (m_participants[p].roi != rootROI && participantMatched[p]) {
extras.push_back(m_participants[p].roi);
}
}
for (auto* propROI : m_propROIs) {
extras.push_back(propROI);
}
for (auto* clonedROI : m_clonedSceneROIs) {
extras.push_back(clonedROI);
}
if (m_vehicleROI) {
extras.push_back(m_vehicleROI);
}
delete[] m_roiMap;
m_roiMap = nullptr;
m_roiMapSize = 0;
AnimUtils::BuildROIMap(
m_currentData->anim,
rootROI,
extras.empty() ? nullptr : extras.data(),
(int) extras.size(),
m_roiMap,
m_roiMapSize,
aliases.empty() ? nullptr : aliases.data(),
(int) aliases.size()
);
}
void ScenePlayer::Play(
const AnimInfo* p_animInfo,
int8_t p_worldId,
AnimCategory p_category,
const ParticipantROI* p_participants,
uint8_t p_participantCount,
bool p_observerMode
)
{
if (m_playing) {
Stop();
}
if (p_participantCount == 0 || !p_participants[0].roi || !p_animInfo) {
return;
}
SceneAnimData* data = m_loader->EnsureCached(p_worldId, p_animInfo->m_objectId);
if (!data || !data->anim) {
return;
}
m_currentData = data;
m_category = p_category;
m_hideOnStop = data->hideOnStop;
m_observerMode = p_observerMode;
// Build participant list with saved transforms for restoration
for (uint8_t i = 0; i < p_participantCount; i++) {
ParticipantROI participant;
participant.roi = p_participants[i].roi;
participant.vehicleROI = p_participants[i].vehicleROI;
participant.savedTransform = p_participants[i].roi->GetLocal2World();
participant.savedName = p_participants[i].roi->GetName();
participant.charIndex = p_participants[i].charIndex;
m_participants.push_back(participant);
}
SetupROIs(p_animInfo);
if (!m_roiMap) {
m_currentData = nullptr;
m_participants.clear();
return;
}
ResolvePtAtCamROIs();
m_phonemePlayer.Init(data->phonemeTracks, m_roiMap, m_roiMapSize, m_actorAliases);
m_audioPlayer.Init(data->audioTracks);
// Observers and spectators don't get camera control — they watch the animation from their own viewpoint
m_hasCamAnim =
(!m_observerMode && !m_participants[0].IsSpectator() && m_category == e_camAnim &&
m_currentData->anim->GetCamAnim() != nullptr);
if (m_category == e_camAnim && !m_observerMode && !m_participants[0].IsSpectator()) {
// Hide the player's ride vehicle — it would remain visible at the
// pre-animation position while the player is teleported
LegoROI* localVehicle = m_participants[0].vehicleROI;
if (localVehicle && localVehicle != m_vehicleROI) {
localVehicle->SetVisibility(FALSE);
m_hiddenVehicleROI = localVehicle;
}
}
m_startTime = 0;
m_playing = true;
BackgroundAudioManager()->LowerVolume();
}
void ScenePlayer::ComputeRebaseMatrix()
{
if (!m_animRootROI) {
m_rebaseMatrix.SetIdentity();
m_rebaseComputed = true;
return;
}
// Use the root performer's saved position as the rebase anchor
MxMatrix targetTransform;
targetTransform.SetIdentity();
for (const auto& p : m_participants) {
if (p.roi == m_animRootROI) {
targetTransform = p.savedTransform;
break;
}
}
// Find the root ROI's world transform at time 0 by walking the animation tree
std::function<bool(LegoTreeNode*, MxMatrix&)> findOrigin = [&](LegoTreeNode* node, MxMatrix& parentWorld) -> bool {
LegoAnimNodeData* data = (LegoAnimNodeData*) node->GetData();
MxU32 roiIdx = data ? data->GetROIIndex() : 0;
MxMatrix localMat;
LegoROI::CreateLocalTransform(data, 0, localMat);
MxMatrix worldMat;
worldMat.Product(localMat, parentWorld);
if (roiIdx != 0 && m_roiMap[roiIdx] == m_animRootROI) {
m_animPose0 = worldMat;
return true;
}
for (LegoU32 i = 0; i < node->GetNumChildren(); i++) {
if (findOrigin(node->GetChild(i), worldMat)) {
return true;
}
}
return false;
};
MxMatrix identity;
identity.SetIdentity();
findOrigin(m_currentData->anim->GetRoot(), identity);
// Inverse of animPose0 (rigid body: transpose rotation, negate translated position)
MxMatrix invAnimPose0;
invAnimPose0.SetIdentity();
for (int r = 0; r < 3; r++) {
for (int c = 0; c < 3; c++) {
invAnimPose0[r][c] = m_animPose0[c][r];
}
}
for (int r = 0; r < 3; r++) {
invAnimPose0[3][r] =
-(invAnimPose0[0][r] * m_animPose0[3][0] + invAnimPose0[1][r] * m_animPose0[3][1] +
invAnimPose0[2][r] * m_animPose0[3][2]);
}
m_rebaseMatrix.Product(invAnimPose0, targetTransform);
m_rebaseComputed = true;
}
void ScenePlayer::ResolvePtAtCamROIs()
{
m_ptAtCamROIs.clear();
if (!m_currentData || m_currentData->ptAtCamNames.empty() || !m_roiMap) {
return;
}
for (const auto& name : m_currentData->ptAtCamNames) {
for (MxU32 i = 1; i < m_roiMapSize; i++) {
if (m_roiMap[i] && m_roiMap[i]->GetName() && !SDL_strcasecmp(name.c_str(), m_roiMap[i]->GetName())) {
m_ptAtCamROIs.push_back(m_roiMap[i]);
break;
}
}
}
}
void ScenePlayer::ApplyPtAtCam()
{
if (m_ptAtCamROIs.empty()) {
return;
}
LegoWorld* world = CurrentWorld();
if (!world || !world->GetCameraController()) {
return;
}
// Same math as LegoAnimPresenter::PutFrame
for (LegoROI* roi : m_ptAtCamROIs) {
if (!roi) {
continue;
}
MxMatrix mat(roi->GetLocal2World());
Vector3 pos(mat[0]);
Vector3 dir(mat[1]);
Vector3 up(mat[2]);
Vector3 und(mat[3]);
float possqr = sqrt(pos.LenSquared());
float dirsqr = sqrt(dir.LenSquared());
float upsqr = sqrt(up.LenSquared());
up = und;
up -= world->GetCameraController()->GetWorldLocation();
dir /= dirsqr;
pos.EqualsCross(dir, up);
pos.Unitize();
up.EqualsCross(pos, dir);
pos *= possqr;
dir *= dirsqr;
up *= upsqr;
roi->SetLocal2World(mat);
roi->WrappedUpdateWorldData();
}
}
void ScenePlayer::Tick()
{
if (!m_playing || !m_currentData || m_participants.empty()) {
return;
}
if (m_startTime == 0) {
m_startTime = SDL_GetTicks();
}
if (m_category == e_npcAnim && m_roiMap) {
AnimUtils::EnsureROIMapVisibility(m_roiMap, m_roiMapSize);
}
float elapsed = (float) (SDL_GetTicks() - m_startTime);
if (elapsed >= m_currentData->duration) {
Stop();
return;
}
// 1. Skeletal animation
if (m_currentData->anim && m_roiMap) {
if (!m_rebaseComputed) {
if (m_category == e_camAnim) {
// cam_anims use the action transform directly (keyframes are in world space)
if (m_currentData->actionTransform.valid) {
Mx3DPointFloat loc(
m_currentData->actionTransform.location[0],
m_currentData->actionTransform.location[1],
m_currentData->actionTransform.location[2]
);
Mx3DPointFloat dir(
m_currentData->actionTransform.direction[0],
m_currentData->actionTransform.direction[1],
m_currentData->actionTransform.direction[2]
);
Mx3DPointFloat up(
m_currentData->actionTransform.up[0],
m_currentData->actionTransform.up[1],
m_currentData->actionTransform.up[2]
);
CalcLocalTransform(loc, dir, up, m_rebaseMatrix);
}
else {
m_rebaseMatrix.SetIdentity();
}
m_rebaseComputed = true;
}
else {
ComputeRebaseMatrix();
}
}
AnimUtils::ApplyTree(m_currentData->anim, m_rebaseMatrix, (LegoTime) elapsed, m_roiMap);
}
// 2. Camera animation (cam_anim only)
if (m_hasCamAnim) {
MxMatrix camTransform(m_rebaseMatrix);
m_currentData->anim->GetCamAnim()->CalculateCameraTransform((LegoFloat) elapsed, camTransform);
LegoWorld* world = CurrentWorld();
if (world && world->GetCameraController()) {
world->GetCameraController()->TransformPointOfView(camTransform, FALSE);
}
}
// 3. PTATCAM post-processing
ApplyPtAtCam();
// 4. Audio
const char* audioROIName = m_animRootROI ? m_animRootROI->GetName() : nullptr;
m_audioPlayer.Tick(elapsed, audioROIName);
// 5. Phoneme frames
m_phonemePlayer.Tick(elapsed, m_currentData->phonemeTracks);
}
void ScenePlayer::Stop()
{
if (!m_playing) {
return;
}
m_audioPlayer.Cleanup();
m_phonemePlayer.Cleanup();
if (m_hideOnStop && m_roiMap) {
for (MxU32 i = 1; i < m_roiMapSize; i++) {
if (m_roiMap[i]) {
m_roiMap[i]->SetVisibility(FALSE);
}
}
}
if (m_hiddenVehicleROI) {
m_hiddenVehicleROI->SetVisibility(TRUE);
m_hiddenVehicleROI = nullptr;
}
CleanupProps();
m_vehicleROI = nullptr;
delete[] m_roiMap;
m_roiMap = nullptr;
m_roiMapSize = 0;
for (auto& p : m_participants) {
if (p.roi) {
p.roi->WrappedSetLocal2WorldWithWorldDataUpdate(p.savedTransform);
p.roi->SetVisibility(TRUE);
}
}
m_participants.clear();
BackgroundAudioManager()->RaiseVolume();
m_ptAtCamROIs.clear();
m_actorAliases.clear();
m_playing = false;
m_rebaseComputed = false;
m_currentData = nullptr;
m_animRootROI = nullptr;
m_hasCamAnim = false;
m_observerMode = false;
m_startTime = 0;
m_hideOnStop = false;
}
void ScenePlayer::NotifyROIDestroyed(LegoROI* p_roi)
{
if (!m_playing || !p_roi) {
return;
}
// Walk the m_roiMap once to find p_roi and all its descendants (child ROIs
// are destroyed together with their parent). Collect them so every other
// field can be cleaned with simple pointer equality — the ancestor walk
// happens in exactly one place.
std::vector<LegoROI*> destroyed;
destroyed.push_back(p_roi);
if (m_roiMap) {
for (MxU32 i = 0; i < m_roiMapSize; i++) {
if (!m_roiMap[i]) {
continue;
}
for (OrientableROI* cur = m_roiMap[i]; cur != nullptr; cur = cur->GetParentROI()) {
if (cur == p_roi) {
if (m_roiMap[i] != p_roi) {
destroyed.push_back(m_roiMap[i]);
}
m_roiMap[i] = nullptr;
break;
}
}
}
}
auto isDestroyed = [&destroyed](LegoROI* roi) {
for (LegoROI* d : destroyed) {
if (roi == d) {
return true;
}
}
return false;
};
for (auto& p : m_participants) {
if (p.roi && isDestroyed(p.roi)) {
p.roi = nullptr;
}
if (p.vehicleROI && isDestroyed(p.vehicleROI)) {
p.vehicleROI = nullptr;
}
}
for (auto& roi : m_ptAtCamROIs) {
if (roi && isDestroyed(roi)) {
roi = nullptr;
}
}
if (m_animRootROI && isDestroyed(m_animRootROI)) {
m_animRootROI = nullptr;
}
if (m_vehicleROI && isDestroyed(m_vehicleROI)) {
m_vehicleROI = nullptr;
}
for (LegoROI* d : destroyed) {
m_phonemePlayer.NotifyROIDestroyed(d);
}
}
void ScenePlayer::CleanupProps()
{
for (auto* propROI : m_propROIs) {
if (propROI) {
CharacterManager()->ReleaseAutoROI(propROI);
}
}
m_propROIs.clear();
for (auto* clonedROI : m_clonedSceneROIs) {
if (clonedROI) {
VideoManager()->Get3DManager()->Remove(*clonedROI);
delete clonedROI;
}
}
m_clonedSceneROIs.clear();
}