WIP: Filter animations by vehicle eligibility

Add vehicle-based filtering to the multiplayer ScenePlayer so that
animations requiring a specific vehicle (skateboard, bike, motorcycle)
are only offered when the performer is actually riding that vehicle.

- Add vehicleMask to CatalogEntry from AnimInfo::m_unk0x2a
- Three-state vehicle detection: on foot, on own vehicle, on foreign vehicle
- Filter performer animations by vehicle state in eligibility computation
- Spectator-only roles remain visible regardless of vehicle state
- Host validates vehicle state on interest and re-validates during countdown
- Cancel active sessions when local player's vehicle state changes

Includes temporary debug logging tagged TODO(vehicle-filter).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Semmler 2026-03-28 10:05:55 -07:00
parent 964013203a
commit aa9df3370b
No known key found for this signature in database
GPG Key ID: 086DAA1360BEEE5C
6 changed files with 252 additions and 6 deletions

View File

@ -36,6 +36,7 @@ struct CatalogEntry {
int16_t location; // -1 = anywhere, >= 0 = specific location
int8_t characterIndex; // Primary character index into g_characters[]
uint8_t modelCount; // Number of models in animation
uint8_t vehicleMask; // Bitmask of g_vehicles[] indices required (bit0=bikebd..bit6=board)
};
class Catalog {
@ -56,11 +57,13 @@ class Catalog {
static bool CanParticipateChar(const CatalogEntry* p_entry, int8_t p_charIndex);
// Check if a set of character indices can collectively trigger this animation.
// p_onVehicle: parallel array indicating if each player is riding their vehicle (nullable).
// p_filledPerformers: bitmask of which performer bits in performerMask are covered.
// p_spectatorFilled: whether a valid spectator was found among unassigned players.
bool CanTrigger(
const CatalogEntry* p_entry,
const int8_t* p_charIndices,
const uint8_t* p_onVehicle,
uint8_t p_count,
uint64_t* p_filledPerformers,
bool* p_spectatorFilled
@ -70,6 +73,19 @@ class Catalog {
// Does NOT check performer exclusion — caller must do that if needed.
static bool CheckSpectatorMask(const CatalogEntry* p_entry, int8_t p_charIndex);
// 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
};
// Check if a player's vehicle state is compatible with the animation's vehicle requirements.
static bool CheckVehicleEligibility(const CatalogEntry* p_entry, int8_t p_charIndex, uint8_t p_vehicleState);
// Determine the vehicle state for a character given their current ride vehicle ROI.
static VehicleState GetVehicleState(int8_t p_charIndex, class LegoROI* p_vehicleROI);
// Convert a display actor index to the g_characters[] index used by animations.
// Returns -1 if no match.
static int8_t DisplayActorToCharacterIndex(uint8_t p_displayActorIndex);

View File

@ -57,11 +57,14 @@ class Coordinator {
// Compute eligibility for animations at a location.
// p_locationChars: local player + remote players at the same location (for cam anims).
// p_proximityChars: local player + remote players within proximity (for NPC anims).
// p_locationVehicles/p_proximityVehicles: parallel bool arrays indicating vehicle riding state.
std::vector<EligibilityInfo> ComputeEligibility(
int16_t p_location,
const int8_t* p_locationChars,
const uint8_t* p_locationVehicles,
uint8_t p_locationCount,
const int8_t* p_proximityChars,
const uint8_t* p_proximityVehicles,
uint8_t p_proximityCount
) const;

View File

@ -145,6 +145,7 @@ class NetworkManager : public MxCore {
bool IsPeerAtLocation(uint32_t p_peerId, int16_t p_location) const;
bool GetPeerPosition(uint32_t p_peerId, float& p_x, float& p_z) const;
bool IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ) const;
uint8_t GetPeerVehicleState(uint32_t p_peerId, int8_t p_charIndex) const;
bool ValidateSessionLocations(uint16_t p_animIndex);
void ResetAnimationState();
@ -195,6 +196,8 @@ class NetworkManager : public MxCore {
bool m_showNameBubbles;
bool m_lastCameraEnabled;
uint8_t m_lastVehicleState;
bool m_vehicleFilterLogPending; // TODO(vehicle-filter): Remove after verification
bool m_wasInRestrictedArea;
// NPC animation playback

View File

@ -5,11 +5,36 @@
#include "legoactors.h"
#include "legoanimationmanager.h"
#include "misc.h"
#include "roi/legoroi.h"
#include <SDL3/SDL_stdinc.h>
using namespace Multiplayer::Animation;
// Static mapping of character index to g_vehicles[] index.
// Mirrors g_characters[].m_vehicleId for characters that own a vehicle.
static int8_t GetCharacterVehicleId(int8_t p_charIndex)
{
switch (p_charIndex) {
case 0:
return 6; // pepper -> board (skateboard)
case 3:
return 4; // nick -> motoni (motorcycle)
case 4:
return 5; // laura -> motola (motorcycle)
case 36:
return 2; // rd -> bikerd
case 37:
return 1; // pg -> bikepg
case 38:
return 0; // bd -> bikebd
case 39:
return 3; // sy -> bikesy
default:
return -1;
}
}
// Exact-match a model name against g_actorInfoInit[].m_name.
// The engine's LegoAnimationManager::GetCharacterIndex uses 2-char prefix matching,
// which causes false positives (e.g. "ladder" matching "laura"). We need exact
@ -77,6 +102,16 @@ 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] < 8) {
entry.vehicleMask |= (1 << m_animsBase[i].m_unk0x2a[k]);
}
}
// Categorize based on whether the animation has named character performers.
// g_actorInfoInit layout:
// 0-47: named characters (pepper through jk)
@ -178,6 +213,65 @@ bool Catalog::CheckSpectatorMask(const CatalogEntry* p_entry, int8_t p_charIndex
return p_entry->spectatorMask == ALL_CORE_ACTORS_MASK;
}
bool Catalog::CheckVehicleEligibility(const CatalogEntry* p_entry, int8_t p_charIndex, uint8_t p_vehicleState)
{
int8_t vehicleId = GetCharacterVehicleId(p_charIndex);
if (vehicleId < 0) {
return true; // Character has no vehicle — no constraint (Mama, Papa, NPCs)
}
bool animUsesVehicle = (p_entry->vehicleMask >> vehicleId) & 1;
switch (p_vehicleState) {
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 !animUsesVehicle; // Only animations that don't use this character's vehicle
}
}
// Vehicle category grouping (matches ScenePlayer::GetVehicleCategory)
static int8_t GetVehicleCategory(int8_t p_vehicleIdx)
{
if (p_vehicleIdx >= 0 && p_vehicleIdx <= 3) {
return 0; // bike
}
if (p_vehicleIdx >= 4 && p_vehicleIdx <= 5) {
return 1; // motorcycle
}
if (p_vehicleIdx == 6) {
return 2; // skateboard
}
return -1;
}
Catalog::VehicleState Catalog::GetVehicleState(int8_t p_charIndex, LegoROI* p_vehicleROI)
{
if (!p_vehicleROI || !p_vehicleROI->GetName()) {
return e_onFoot;
}
int8_t charVehicleId = GetCharacterVehicleId(p_charIndex);
if (charVehicleId < 0) {
return e_onFoot; // Character has no vehicle — treat any ride as irrelevant
}
MxU32 rideVehicleIdx;
if (!AnimationManager()->FindVehicle(p_vehicleROI->GetName(), rideVehicleIdx)) {
return e_onOtherVehicle; // Unknown vehicle — treat as foreign
}
// Compare by category — the ride system uses representative names (bikebd/motoni/board)
// that may differ from the character's specific vehicle index but share the same category.
if (GetVehicleCategory((int8_t) rideVehicleIdx) == GetVehicleCategory(charVehicleId)) {
return e_onOwnVehicle;
}
return e_onOtherVehicle;
}
bool Catalog::CanParticipateChar(const CatalogEntry* p_entry, int8_t p_charIndex)
{
if (p_charIndex < 0) {
@ -201,6 +295,7 @@ bool Catalog::CanParticipate(const CatalogEntry* p_entry, uint8_t p_displayActor
bool Catalog::CanTrigger(
const CatalogEntry* p_entry,
const int8_t* p_charIndices,
const uint8_t* p_onVehicle,
uint8_t p_count,
uint64_t* p_filledPerformers,
bool* p_spectatorFilled
@ -220,6 +315,10 @@ bool Catalog::CanTrigger(
uint64_t charBit = uint64_t(1) << charIndex;
if ((p_entry->performerMask & charBit) && !(*p_filledPerformers & charBit)) {
if (p_onVehicle && !CheckVehicleEligibility(p_entry, charIndex, p_onVehicle[i])) {
continue;
}
*p_filledPerformers |= charBit;
assignedAsPerformer[i] = true;
}

View File

@ -82,8 +82,10 @@ static void BuildSlots(
std::vector<EligibilityInfo> Coordinator::ComputeEligibility(
int16_t p_location,
const int8_t* p_locationChars,
const uint8_t* p_locationVehicles,
uint8_t p_locationCount,
const int8_t* p_proximityChars,
const uint8_t* p_proximityVehicles,
uint8_t p_proximityCount
) const
{
@ -101,9 +103,18 @@ std::vector<EligibilityInfo> Coordinator::ComputeEligibility(
continue;
}
// Vehicle eligibility: only filter if the local player would be a performer.
// Spectator-only roles remain visible so players on vehicles can still watch nearby scenes.
if ((entry->performerMask >> p_locationChars[0]) & 1) {
if (!Catalog::CheckVehicleEligibility(entry, p_locationChars[0], p_locationVehicles[0])) {
continue;
}
}
// NPC anims (location == -1): use proximity characters
// Cam anims (location >= 0): use location characters
const int8_t* chars = (entry->location == -1) ? p_proximityChars : p_locationChars;
const uint8_t* vehicles = (entry->location == -1) ? p_proximityVehicles : p_locationVehicles;
uint8_t count = (entry->location == -1) ? p_proximityCount : p_locationCount;
EligibilityInfo info;
@ -117,7 +128,7 @@ std::vector<EligibilityInfo> Coordinator::ComputeEligibility(
bool spectatorFilled = false;
if (atLoc) {
info.eligible = m_catalog->CanTrigger(entry, chars, count, &filledPerformers, &spectatorFilled);
info.eligible = m_catalog->CanTrigger(entry, chars, vehicles, count, &filledPerformers, &spectatorFilled);
}
else {
info.eligible = false;

View File

@ -20,6 +20,7 @@
#include "mxticklemanager.h"
#include "roi/legoroi.h"
#include <SDL3/SDL_log.h>
#include <SDL3/SDL_stdinc.h>
#include <SDL3/SDL_timer.h>
#include <algorithm>
@ -67,9 +68,10 @@ NetworkManager::NetworkManager()
m_inIsleWorld(false), m_registered(false), m_pendingToggleThirdPerson(false), m_pendingToggleNameBubbles(false),
m_pendingWalkAnim(-1), m_pendingIdleAnim(-1), m_pendingEmote(-1), m_pendingToggleAllowCustomize(false),
m_pendingAnimInterest(-1), m_pendingAnimCancel(false), m_localPendingAnimInterest(-1), m_showNameBubbles(true),
m_lastCameraEnabled(false), m_wasInRestrictedArea(false), m_animStateDirty(false), m_animInterestDirty(false),
m_lastAnimPushTime(0), m_connectionState(STATE_DISCONNECTED), m_wasRejected(false), m_reconnectAttempt(0),
m_reconnectDelay(0), m_nextReconnectTime(0)
m_lastCameraEnabled(false), m_lastVehicleState(0), m_vehicleFilterLogPending(false),
m_wasInRestrictedArea(false), m_animStateDirty(false),
m_animInterestDirty(false), m_lastAnimPushTime(0), m_connectionState(STATE_DISCONNECTED), m_wasRejected(false),
m_reconnectAttempt(0), m_reconnectDelay(0), m_nextReconnectTime(0)
{
}
@ -118,6 +120,31 @@ MxResult NetworkManager::Tickle()
}
}
// Detect vehicle state changes for animation eligibility refresh.
// Tracks three states: on foot, on own vehicle, on foreign vehicle.
int8_t localChar = Animation::Catalog::DisplayActorToCharacterIndex(cam->GetDisplayActorIndex());
uint8_t vehicleState = Animation::Catalog::GetVehicleState(localChar, cam->GetRideVehicleROI());
if (vehicleState != m_lastVehicleState) {
m_lastVehicleState = vehicleState;
m_animStateDirty = true;
m_vehicleFilterLogPending = true;
// Cancel active session if the current animation is no longer eligible.
// Only cancel if the local player is a performer — spectators aren't vehicle-constrained.
if (m_animCoordinator.GetState() != Animation::CoordinationState::e_idle) {
uint16_t currentAnim = m_animCoordinator.GetCurrentAnimIndex();
if (currentAnim != Animation::ANIM_INDEX_NONE) {
const Animation::CatalogEntry* entry = m_animCatalog.FindEntry(currentAnim);
if (entry && (entry->performerMask >> localChar) & 1) {
if (!Animation::Catalog::CheckVehicleEligibility(entry, localChar, vehicleState)) {
CancelLocalAnimInterest();
StopScenePlayback(currentAnim, false);
}
}
}
}
}
// Create local name bubble when display ROI becomes available
if (m_showNameBubbles && !m_localNameBubble && cam->GetDisplayROI()) {
char name[8];
@ -1323,6 +1350,29 @@ void NetworkManager::TickHostSessions()
}
}
// Auto-remove participants whose vehicle state no longer matches
if (entry && entry->vehicleMask) {
std::vector<uint32_t> toRemove;
for (const auto& slot : session->slots) {
if (slot.peerId != 0 && !slot.IsSpectator()) {
int8_t charIdx = slot.charIndex;
uint8_t onVehicle = GetPeerVehicleState(slot.peerId, charIdx);
if (!Animation::Catalog::CheckVehicleEligibility(entry, charIdx, onVehicle)) {
toRemove.push_back(slot.peerId);
}
}
}
for (uint32_t pid : toRemove) {
std::vector<uint16_t> changed;
m_animSessionHost.HandleCancel(pid, changed);
BroadcastChangedSessions(changed);
}
session = m_animSessionHost.FindSession(animIndex);
if (!session) {
continue;
}
}
bool allFilled = m_animSessionHost.AreAllSlotsFilled(animIndex);
bool coLocated = allFilled && ValidateSessionLocations(animIndex);
@ -1373,6 +1423,17 @@ void NetworkManager::HandleAnimInterest(uint32_t p_peerId, uint16_t p_animIndex,
}
}
// Validate vehicle eligibility if the joining player would be a performer
if (entry) {
int8_t charIndex = Animation::Catalog::DisplayActorToCharacterIndex(p_displayActorIndex);
if ((entry->performerMask >> charIndex) & 1) {
uint8_t onVehicle = GetPeerVehicleState(p_peerId, charIndex);
if (!Animation::Catalog::CheckVehicleEligibility(entry, charIndex, onVehicle)) {
return;
}
}
}
// For NPC anims: if all slots are full, remove far-away participants to make room
// for the new nearby player. This only fires when slots are exhausted — if there's
// an open slot, the new player just joins normally without disturbing anyone.
@ -1832,6 +1893,20 @@ bool NetworkManager::IsPeerNearby(uint32_t p_peerId, float p_refX, float p_refZ)
return (dx * dx + dz * dz) <= NPC_ANIM_NEARBY_RADIUS_SQ;
}
uint8_t NetworkManager::GetPeerVehicleState(uint32_t p_peerId, int8_t p_charIndex) const
{
if (p_peerId == m_localPeerId) {
ThirdPersonCamera::Controller* cam = GetCamera();
return cam ? Animation::Catalog::GetVehicleState(p_charIndex, cam->GetRideVehicleROI())
: Animation::Catalog::e_onFoot;
}
auto it = m_remotePlayers.find(p_peerId);
if (it == m_remotePlayers.end() || !it->second->IsSpawned()) {
return Animation::Catalog::e_onFoot;
}
return Animation::Catalog::GetVehicleState(p_charIndex, it->second->GetRideVehicleROI());
}
bool NetworkManager::ValidateSessionLocations(uint16_t p_animIndex)
{
const Animation::AnimSession* session = m_animSessionHost.FindSession(p_animIndex);
@ -2066,9 +2141,11 @@ void NetworkManager::PushAnimationState()
const float* localPos = userActor->GetROI()->GetWorldPosition();
float localX = localPos[0], localZ = localPos[2];
// Build proximity character indices (for NPC anims — position-based, not location-based)
// Build proximity character indices and vehicle state (for NPC anims — position-based, not location-based)
std::vector<int8_t> proximityCharIndices;
std::vector<uint8_t> proximityVehicleState;
proximityCharIndices.push_back(localCharIndex);
proximityVehicleState.push_back(Animation::Catalog::GetVehicleState(localCharIndex, cam->GetRideVehicleROI()));
for (const auto& [peerId, player] : m_remotePlayers) {
if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) {
@ -2082,6 +2159,7 @@ void NetworkManager::PushAnimationState()
if ((dx * dx + dz * dz) <= (Animation::NPC_ANIM_PROXIMITY * Animation::NPC_ANIM_PROXIMITY)) {
int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex());
proximityCharIndices.push_back(charIdx);
proximityVehicleState.push_back(Animation::Catalog::GetVehicleState(charIdx, player->GetRideVehicleROI()));
}
}
@ -2095,9 +2173,11 @@ void NetworkManager::PushAnimationState()
std::vector<int16_t> locationsToProcess = locations.empty() ? std::vector<int16_t>{int16_t(-1)} : locations;
for (int16_t loc : locationsToProcess) {
// Build per-location character indices (for cam anims at this location)
// Build per-location character indices and vehicle state (for cam anims at this location)
std::vector<int8_t> locationCharIndices;
std::vector<uint8_t> locationVehicleState;
locationCharIndices.push_back(localCharIndex);
locationVehicleState.push_back(Animation::Catalog::GetVehicleState(localCharIndex, cam->GetRideVehicleROI()));
for (const auto& [peerId, player] : m_remotePlayers) {
if (!player->IsSpawned() || !player->GetROI() || player->GetWorldId() != (int8_t) LegoOmni::e_act1) {
@ -2106,14 +2186,17 @@ void NetworkManager::PushAnimationState()
if (player->IsAtLocation(loc)) {
int8_t charIdx = Animation::Catalog::DisplayActorToCharacterIndex(player->GetDisplayActorIndex());
locationCharIndices.push_back(charIdx);
locationVehicleState.push_back(Animation::Catalog::GetVehicleState(charIdx, player->GetRideVehicleROI()));
}
}
auto locEligibility = m_animCoordinator.ComputeEligibility(
loc,
locationCharIndices.data(),
locationVehicleState.data(),
static_cast<uint8_t>(locationCharIndices.size()),
proximityCharIndices.data(),
proximityVehicleState.data(),
static_cast<uint8_t>(proximityCharIndices.size())
);
@ -2124,6 +2207,37 @@ void NetworkManager::PushAnimationState()
}
}
// TODO(vehicle-filter): Remove this logging block after verification
if (m_vehicleFilterLogPending) {
m_vehicleFilterLogPending = false;
uint8_t vehState = Animation::Catalog::GetVehicleState(localCharIndex, cam->GetRideVehicleROI());
const char* stateNames[] = {"onFoot", "onOwnVehicle", "onOtherVehicle"};
SDL_Log(
"[VehicleFilter] Vehicle state changed: char=%d state=%s — %zu eligible animations",
localCharIndex,
stateNames[vehState < 3 ? vehState : 0],
eligibility.size()
);
uint32_t vehicleAnimCount = 0;
for (const auto& info : eligibility) {
const AnimInfo* ai = m_animCatalog.GetAnimInfo(info.animIndex);
if (ai && info.entry && info.entry->vehicleMask) {
vehicleAnimCount++;
SDL_Log(
" [%u] %s (objId=%u loc=%d vmask=0x%02x)",
info.animIndex,
ai->m_name,
ai->m_objectId,
ai->m_location,
info.entry->vehicleMask
);
}
}
if (vehicleAnimCount == 0) {
SDL_Log(" (no vehicle animations in eligible set)");
}
}
// Build JSON
std::string json;
json.reserve(2048);