Dynamic proxy spawning, velocity & state sync

Implements Phase 4 dynamic proxy spawning and actor state synchronization. Adds a dynamic proxy pool (g_dynamicProxyPool), SpawnDynamicProxyActor and GetOrSpawnDynamicProxy to spawn/reuse actors via Player::PlaceAtMe, and updates slot resolution to prefer dynamic proxies with a pre-placed pool fallback. Verifies/integrates existing character-controller velocity injection and clamps speed, and applies remote actor state flags to proxies before animation sync. Enhanced logging for spawn, velocity and state transitions. Changes in plugin/src/F4TProxyActorController.cpp and updated dev log (docs/dev-log.md).
This commit is contained in:
2026-06-03 17:01:41 +12:00
parent a6b2505242
commit e14b91d94e
2 changed files with 233 additions and 5 deletions
+124 -5
View File
@@ -53,10 +53,14 @@ namespace
// This ensures proxies have proper AI, character controllers, and animation systems.
// Max players = number of pre-placed proxies in the world.
constexpr bool kUsePlaceAtMeBackedRuntimeProxySpawn = false; // Use pre-placed proxies
// Pre-placed proxies we've discovered and can reuse
static std::vector<RE::ObjectRefHandle> g_availableProxyPool;
static bool g_proxyPoolInitialized = false;
// Phase 4: Dynamic spawn pools (maps playerId → actor handle)
static std::unordered_map<std::uint32_t, RE::ObjectRefHandle> g_dynamicProxyPool;
static bool g_dynamicProxyPoolEnabled = true; // Enable dynamic spawning for Phase 4 testing
// Forward declaration
bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::PlayerCharacter& a_player);
@@ -2185,6 +2189,100 @@ namespace
}
}
// Phase 4.1: Dynamic proxy spawning (replacement for pre-placed pool model)
// Spawns a new actor instance and manages its lifecycle
RE::Actor* SpawnDynamicProxyActor(
const RE::PlayerCharacter& a_player,
std::uint32_t a_remotePlayerId)
{
if (!g_dynamicProxyPoolEnabled) {
return nullptr;
}
// Get proxy base actor (use custom proxy or Codsworth for testing)
RE::TESNPC* proxyBase = nullptr;
// First try to find custom proxy base in ESP
const auto* dataHandler = RE::TESDataHandler::GetSingleton();
if (!dataHandler) {
LogWarningWithLocalPlayerPrefix(
"SpawnDynamicProxyActor: Cannot spawn proxy; data handler unavailable.");
return nullptr;
}
// Try to load custom proxy base (F4T_RemotePlayerProxy)
const auto customFile = dataHandler->LookupLoadedModByName(kTestPluginName);
if (customFile && customFile->compileIndex != 0xFF) {
const auto customBaseFormId = (customFile->compileIndex << 24) | kProxyBaseActorLocalFormId;
proxyBase = RE::TESForm::LookupByID<RE::TESNPC>(customBaseFormId);
}
// Fallback to Codsworth if custom proxy not available
if (!proxyBase) {
proxyBase = RE::TESForm::LookupByID<RE::TESNPC>(kDebugPlaceAtMeActorBaseFormId);
}
if (!proxyBase) {
LogWarningWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: Cannot spawn proxy for remote player {}; proxy base actor not found.",
a_remotePlayerId));
return nullptr;
}
// Spawn the actor via PlaceAtMe
const auto spawnedRef = a_player.PlaceAtMe(proxyBase, 1, false, true);
if (!spawnedRef) {
LogWarningWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: PlaceAtMe failed for remote player {} with base {:08X}.",
a_remotePlayerId,
proxyBase->GetFormID()));
return nullptr;
}
auto* spawnedActor = spawnedRef->As<RE::Actor>();
if (!spawnedActor) {
LogWarningWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: PlaceAtMe returned non-actor ref for remote player {}.",
a_remotePlayerId));
return nullptr;
}
// Store in dynamic pool for reuse
g_dynamicProxyPool[a_remotePlayerId] = spawnedActor->GetHandle();
LogInfoWithLocalPlayerPrefix(std::format(
"SpawnDynamicProxyActor: Spawned proxy actor {:08X} for remote player {} with base {:08X}.",
spawnedActor->GetFormID(),
a_remotePlayerId,
proxyBase->GetFormID()));
return spawnedActor;
}
// Phase 4.1: Retrieve or spawn proxy for a remote player
// First tries dynamic pool, then spawns if needed
RE::Actor* GetOrSpawnDynamicProxy(
const RE::PlayerCharacter& a_player,
std::uint32_t a_remotePlayerId)
{
// Check if already have a proxy in pool for this remote player
const auto it = g_dynamicProxyPool.find(a_remotePlayerId);
if (it != g_dynamicProxyPool.end()) {
const auto proxyRef = it->second.get();
if (auto actor = proxyRef ? proxyRef->As<RE::Actor>() : nullptr) {
// Verify proxy is still valid
if (actor->GetNPC() && !actor->IsDead()) {
return actor;
}
}
// Remove dead/invalid handle from pool
g_dynamicProxyPool.erase(it);
}
// Spawn new proxy if not available
return SpawnDynamicProxyActor(a_player, a_remotePlayerId);
}
bool IsProxyPoolActorAssigned(const RE::Actor& a_actor)
{
for (const auto& [_, slot] : g_proxySlots) {
@@ -4306,11 +4404,23 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
const RE::PlayerCharacter& a_player)
{
if (!a_slot.proxyHandle) {
// Try to grab a proxy from the pool
// Phase 4: Try dynamic spawn first (new model)
if (g_dynamicProxyPoolEnabled) {
if (auto* dynamicProxy = GetOrSpawnDynamicProxy(const_cast<RE::PlayerCharacter&>(a_player), a_slot.remotePlayerId)) {
a_slot.proxyHandle = dynamicProxy->GetHandle();
LogInfoWithLocalPlayerPrefix(std::format(
"Assigned dynamically spawned proxy {:08X} to remote player {}",
dynamicProxy->GetFormID(),
a_slot.remotePlayerId));
return dynamicProxy;
}
}
// Fallback: Try to grab a proxy from the pre-placed pool
if (auto* pooledProxy = TryGetProxyFromPool()) {
a_slot.proxyHandle = pooledProxy->GetHandle();
LogInfoWithLocalPlayerPrefix(std::format(
"Assigned proxy from pool {:08X} to remote player {}",
"Assigned proxy from pre-placed pool {:08X} to remote player {}",
pooledProxy->GetFormID(),
a_slot.remotePlayerId));
} else {
@@ -4750,6 +4860,13 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
const auto shouldSnap = ShouldSnapRemoteMovement(a_remotePlayer.movementType);
ApplyRuntimeProxyTransform(a_proxy, targetPosition, a_remotePlayer.angleZ, a_remotePlayer, shouldSnap, &a_slot);
// Phase 4.3: Apply actor state flags before animation sync
// This signals the animation graph FSM about combat, sneaking, etc.
if (a_remotePlayer.actorStateFlags1 != 0 || a_remotePlayer.actorStateFlags2 != 0) {
a_proxy.actorState.flags1 = a_remotePlayer.actorStateFlags1;
a_proxy.actorState.flags2 = a_remotePlayer.actorStateFlags2;
}
a_slot.lifecycleState = ProxyLifecycleState::kActive;
a_slot.holdReason.reset();
a_slot.heldSinceTime = {};
@@ -4778,13 +4895,15 @@ bool IsConsolePlaceAtMeCandidateActor(const RE::Actor& a_actor, const RE::Player
if (!a_slot.remoteStateMovedLogged) {
LogInfoWithLocalPlayerPrefix(std::format(
"Moved runtime proxy actor {:08X} from remote player {} state for the first time: X={:.2f}, Y={:.2f}, Z={:.2f}, AngleZ={:.2f}.",
"Moved runtime proxy actor {:08X} from remote player {} state for the first time: X={:.2f}, Y={:.2f}, Z={:.2f}, AngleZ={:.2f}, flags1={:08X}, flags2={:08X}.",
a_proxy.GetFormID(),
a_remotePlayer.playerId,
a_remotePlayer.x,
a_remotePlayer.y,
a_remotePlayer.z,
a_remotePlayer.angleZ));
a_remotePlayer.angleZ,
a_remotePlayer.actorStateFlags1,
a_remotePlayer.actorStateFlags2));
a_slot.remoteStateMovedLogged = true;
}