Neutralize runtime proxy actors and docs
Add a safe neutralization pass for Stage 4 runtime proxy actors and document the change. Introduces NeutralizeProxyActor and supporting helpers (ProxyNeutralizationReason, GetProxyNeutralizationReasonLabel, FormatPosition, IsProxyMovementPackageType, IsProxyFleeOrAlarmPackageType) plus kProxyNeutralizationInterval and a compile-time gate kEnableProxyAIMovementIntentSuppression (default=false). Adds SuppressProxyAIMovementIntent (default-disabled due to a crash observed during testing) which attempts safe package/process interruption using only confirmed CommonLibF4 APIs (EndInterruptPackage, InitiateDoNothingPackage, SetAvoidanceDisabled, command/process handle clears) and extensive throttled logs and TODOs for further unsafe changes. Neutralization is invoked after spawn promotion, on slot reassignment, during active maintenance, and while holding. Updates plugin logic to call neutralization at these points. Updates docs/architecture.md and docs/dev-log.md with the design, testing notes, and rationale (including the safety gate and next steps). Builds succeed; manual in-game validations are still pending.
This commit is contained in:
@@ -5,7 +5,10 @@
|
||||
#include "F4TNetworking.h"
|
||||
#include "F4TRemotePlayerState.h"
|
||||
|
||||
#include "RE/A/ActorValue.h"
|
||||
#include "RE/C/COMMAND_TYPE.h"
|
||||
#include "RE/C/Console.h"
|
||||
#include "RE/P/PTYPE.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <charconv>
|
||||
@@ -66,6 +69,8 @@ namespace
|
||||
constexpr auto kWarningLogInterval = 5s;
|
||||
constexpr auto kProxyMovementDiagnosticLogInterval = 2s;
|
||||
constexpr auto kRuntimeProxyVisibilityDiagnosticLogInterval = 2s;
|
||||
constexpr auto kProxyNeutralizationInterval = 2s;
|
||||
constexpr bool kEnableProxyAIMovementIntentSuppression = false;
|
||||
constexpr auto kNotInTestCellLogInterval = 30s;
|
||||
|
||||
using RemotePlayer = F4T::RemotePlayerState::RemotePlayerState;
|
||||
@@ -128,6 +133,14 @@ namespace
|
||||
kSnap
|
||||
};
|
||||
|
||||
enum class ProxyNeutralizationReason
|
||||
{
|
||||
kSpawnPromoted,
|
||||
kSlotReassigned,
|
||||
kActiveMaintenance,
|
||||
kHolding
|
||||
};
|
||||
|
||||
struct RemotePlayerSelection
|
||||
{
|
||||
std::optional<RemotePlayer> selectedPlayer;
|
||||
@@ -349,6 +362,22 @@ namespace
|
||||
|
||||
void ResetRemoteMovementStateTracking();
|
||||
|
||||
const char* GetProxyNeutralizationReasonLabel(ProxyNeutralizationReason a_reason)
|
||||
{
|
||||
switch (a_reason) {
|
||||
case ProxyNeutralizationReason::kSpawnPromoted:
|
||||
return "spawn_promoted";
|
||||
case ProxyNeutralizationReason::kSlotReassigned:
|
||||
return "slot_reassigned";
|
||||
case ProxyNeutralizationReason::kActiveMaintenance:
|
||||
return "active_maintenance";
|
||||
case ProxyNeutralizationReason::kHolding:
|
||||
return "holding";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
std::string FormatPosition(const RE::NiPoint3& a_position)
|
||||
{
|
||||
return std::format("({:.2f}, {:.2f}, {:.2f})", a_position.x, a_position.y, a_position.z);
|
||||
@@ -362,6 +391,295 @@ namespace
|
||||
return std::sqrt((deltaX * deltaX) + (deltaY * deltaY) + (deltaZ * deltaZ));
|
||||
}
|
||||
|
||||
bool IsProxyMovementPackageType(RE::PTYPE a_type)
|
||||
{
|
||||
switch (a_type) {
|
||||
case RE::PTYPE::kFleeNonCombat:
|
||||
case RE::PTYPE::kAlarm:
|
||||
case RE::PTYPE::kFlee:
|
||||
case RE::PTYPE::kSearchForAttacker:
|
||||
case RE::PTYPE::kAvoidReference:
|
||||
case RE::PTYPE::kBumpReaction:
|
||||
case RE::PTYPE::kMovementBlocked:
|
||||
case RE::PTYPE::kFollow:
|
||||
case RE::PTYPE::kTravel:
|
||||
case RE::PTYPE::kPatrol:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
bool IsProxyFleeOrAlarmPackageType(RE::PTYPE a_type)
|
||||
{
|
||||
return a_type == RE::PTYPE::kFleeNonCombat ||
|
||||
a_type == RE::PTYPE::kAlarm ||
|
||||
a_type == RE::PTYPE::kFlee ||
|
||||
a_type == RE::PTYPE::kSearchForAttacker ||
|
||||
a_type == RE::PTYPE::kAvoidReference ||
|
||||
a_type == RE::PTYPE::kBumpReaction;
|
||||
}
|
||||
|
||||
void SuppressProxyAIMovementIntent(
|
||||
ProxyActorSlot& a_slot,
|
||||
RE::Actor& a_proxy,
|
||||
const RE::PlayerCharacter& a_player,
|
||||
ProxyNeutralizationReason a_reason,
|
||||
bool a_forcePackageClear)
|
||||
{
|
||||
if constexpr (!kEnableProxyAIMovementIntentSuppression) {
|
||||
(void)a_slot;
|
||||
(void)a_proxy;
|
||||
(void)a_player;
|
||||
(void)a_reason;
|
||||
(void)a_forcePackageClear;
|
||||
return;
|
||||
}
|
||||
|
||||
if (std::addressof(a_proxy) == std::addressof(a_player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto reason = GetProxyNeutralizationReasonLabel(a_reason);
|
||||
const auto actorId = a_proxy.GetFormID();
|
||||
const auto playerId = a_slot.remotePlayerId;
|
||||
const auto pathingActive = a_proxy.IsPathing();
|
||||
const auto pathValid = a_proxy.IsPathValid();
|
||||
const auto pathComplete = a_proxy.IsPathingComplete();
|
||||
const auto pathingIntentObserved = pathingActive || (pathValid && !pathComplete);
|
||||
|
||||
auto processTargetsCleared = false;
|
||||
auto commandCleared = false;
|
||||
auto movementPackageObserved = false;
|
||||
auto fleeOrAlarmPackageObserved = false;
|
||||
auto packageTypeForLog = -1;
|
||||
|
||||
if (auto* process = a_proxy.currentProcess) {
|
||||
auto clearHandle = [&](auto& a_handle) {
|
||||
if (!a_handle) {
|
||||
return false;
|
||||
}
|
||||
|
||||
a_handle.reset();
|
||||
return true;
|
||||
};
|
||||
|
||||
processTargetsCleared |= clearHandle(process->target);
|
||||
processTargetsCleared |= clearHandle(process->followTarget);
|
||||
processTargetsCleared |= clearHandle(process->genericLocation);
|
||||
processTargetsCleared |= clearHandle(process->genericSecondLocation);
|
||||
processTargetsCleared |= clearHandle(process->patrolLocation);
|
||||
processTargetsCleared |= clearHandle(process->currentPackage.target);
|
||||
if (process->acquireObject) {
|
||||
process->acquireObject = nullptr;
|
||||
processTargetsCleared = true;
|
||||
}
|
||||
if (process->savedAcquireObject) {
|
||||
process->savedAcquireObject = nullptr;
|
||||
processTargetsCleared = true;
|
||||
}
|
||||
|
||||
const auto commandType = process->GetCommandType();
|
||||
if (commandType != RE::COMMAND_TYPE::kNone) {
|
||||
process->SetCommandType(RE::COMMAND_TYPE::kNone);
|
||||
commandCleared = true;
|
||||
}
|
||||
|
||||
if (const auto* currentPackage = process->currentPackage.package) {
|
||||
const auto packageType = static_cast<RE::PTYPE>(currentPackage->data.packType);
|
||||
packageTypeForLog = static_cast<int>(currentPackage->data.packType);
|
||||
movementPackageObserved = IsProxyMovementPackageType(packageType);
|
||||
fleeOrAlarmPackageObserved = IsProxyFleeOrAlarmPackageType(packageType);
|
||||
}
|
||||
}
|
||||
|
||||
const auto shouldClearPackage =
|
||||
a_forcePackageClear ||
|
||||
pathingIntentObserved ||
|
||||
processTargetsCleared ||
|
||||
commandCleared ||
|
||||
movementPackageObserved;
|
||||
|
||||
if (!shouldClearPackage) {
|
||||
return;
|
||||
}
|
||||
|
||||
a_proxy.SetAvoidanceDisabled(true);
|
||||
a_proxy.EndInterruptPackage(false);
|
||||
a_proxy.InitiateDoNothingPackage();
|
||||
a_proxy.ClearAttackStates();
|
||||
|
||||
LogThrottledInfo(
|
||||
"runtime_proxy_ai_movement_intent_suppressed_" + std::to_string(playerId) + "_" + std::to_string(actorId),
|
||||
std::format(
|
||||
"Runtime proxy actor {:08X} for remote player {} had AI movement/pathing intent suppressed. reason={}, pathingActive={}, pathValid={}, pathComplete={}, processTargetsCleared={}, commandCleared={}, movementPackageObserved={}, packageType={}.",
|
||||
actorId,
|
||||
playerId,
|
||||
reason,
|
||||
pathingActive,
|
||||
pathValid,
|
||||
pathComplete,
|
||||
processTargetsCleared,
|
||||
commandCleared,
|
||||
movementPackageObserved,
|
||||
packageTypeForLog));
|
||||
|
||||
LogThrottledInfo(
|
||||
"runtime_proxy_package_clear_attempted_" + std::to_string(playerId) + "_" + std::to_string(actorId),
|
||||
std::format(
|
||||
"Runtime proxy actor {:08X} for remote player {} attempted safe package/pathing clear via EndInterruptPackage(false) and InitiateDoNothingPackage(). reason={}.",
|
||||
actorId,
|
||||
playerId,
|
||||
reason));
|
||||
|
||||
if (fleeOrAlarmPackageObserved) {
|
||||
LogThrottledInfo(
|
||||
"runtime_proxy_flee_alarm_suppressed_" + std::to_string(playerId) + "_" + std::to_string(actorId),
|
||||
std::format(
|
||||
"Runtime proxy actor {:08X} for remote player {} had flee/alarm/bump package movement suppressed. reason={}, packageType={}.",
|
||||
actorId,
|
||||
playerId,
|
||||
reason,
|
||||
packageTypeForLog));
|
||||
}
|
||||
|
||||
if ((a_proxy.movementController || a_proxy.actorMover) &&
|
||||
ShouldLog("runtime_proxy_no_safe_movement_controller_clear_" + std::to_string(playerId) + "_" + std::to_string(actorId), kNotInTestCellLogInterval)) {
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Runtime proxy actor {:08X} for remote player {} has movement controller/actor mover state, but no safe CommonLibF4 API is exposed here to clear a path destination directly. Using package/process suppression only.",
|
||||
actorId,
|
||||
playerId));
|
||||
}
|
||||
|
||||
// TODO: Keep this default-disabled until the crash introduced by the first
|
||||
// AI movement intent suppression patch is isolated. Re-enable pieces one at
|
||||
// a time in-game, starting with read-only diagnostics, before writing
|
||||
// currentProcess fields or interrupting packages again.
|
||||
// TODO: Avoid direct writes to currentPackage.currentProcedureIndex,
|
||||
// preferredSpeed, movementController internals, or actorMover internals until
|
||||
// a safe Fallout 4/CommonLibF4 path-cancel API is validated.
|
||||
// TODO: If walk-away animation persists, investigate a future animation-layer
|
||||
// reset that preserves remote-driven idle/locomotion/sneak/jump/weapon sync.
|
||||
}
|
||||
|
||||
void NeutralizeProxyActor(
|
||||
ProxyActorSlot& a_slot,
|
||||
RE::Actor& a_proxy,
|
||||
const RE::PlayerCharacter& a_player,
|
||||
ProxyNeutralizationReason a_reason)
|
||||
{
|
||||
if (std::addressof(a_proxy) == std::addressof(a_player)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const auto reason = GetProxyNeutralizationReasonLabel(a_reason);
|
||||
const auto actorId = a_proxy.GetFormID();
|
||||
const auto playerId = a_slot.remotePlayerId;
|
||||
auto combatObserved = false;
|
||||
|
||||
if (a_proxy.IsInCombat()) {
|
||||
combatObserved = true;
|
||||
a_proxy.StopCombat();
|
||||
a_proxy.ClearAttackStates();
|
||||
LogThrottledInfo(
|
||||
"runtime_proxy_combat_stopped_" + std::to_string(playerId) + "_" + std::to_string(actorId),
|
||||
std::format(
|
||||
"Runtime proxy actor {:08X} for remote player {} entered combat and was neutralized. reason={}.",
|
||||
actorId,
|
||||
playerId,
|
||||
reason));
|
||||
}
|
||||
|
||||
if constexpr (kEnableProxyAIMovementIntentSuppression) {
|
||||
SuppressProxyAIMovementIntent(a_slot, a_proxy, a_player, a_reason, combatObserved);
|
||||
}
|
||||
|
||||
if (a_proxy.currentCombatTarget) {
|
||||
const auto targetHandle = a_proxy.currentCombatTarget.get_handle();
|
||||
a_proxy.currentCombatTarget.reset();
|
||||
a_proxy.ClearAttackStates();
|
||||
if constexpr (kEnableProxyAIMovementIntentSuppression) {
|
||||
SuppressProxyAIMovementIntent(a_slot, a_proxy, a_player, a_reason, true);
|
||||
}
|
||||
LogThrottledInfo(
|
||||
"runtime_proxy_combat_target_cleared_" + std::to_string(playerId) + "_" + std::to_string(actorId),
|
||||
std::format(
|
||||
"Runtime proxy actor {:08X} for remote player {} had combat target handle {:08X} cleared. reason={}.",
|
||||
actorId,
|
||||
playerId,
|
||||
targetHandle,
|
||||
reason));
|
||||
}
|
||||
|
||||
auto hostileStateReset = false;
|
||||
if (a_proxy.HasBeenAttacked()) {
|
||||
a_proxy.SetBeenAttacked(false);
|
||||
hostileStateReset = true;
|
||||
}
|
||||
if (a_proxy.boolFlags.any(RE::Actor::BOOL_FLAGS::kAngryWithPlayer, RE::Actor::BOOL_FLAGS::kAttackOnSight)) {
|
||||
a_proxy.boolFlags.reset(RE::Actor::BOOL_FLAGS::kAngryWithPlayer, RE::Actor::BOOL_FLAGS::kAttackOnSight);
|
||||
hostileStateReset = true;
|
||||
}
|
||||
if (a_proxy.boolFlags.none(RE::Actor::BOOL_FLAGS::kAttackingDisabled)) {
|
||||
a_proxy.boolFlags.set(RE::Actor::BOOL_FLAGS::kAttackingDisabled);
|
||||
hostileStateReset = true;
|
||||
}
|
||||
|
||||
if (auto* actorValues = RE::ActorValue::GetSingleton()) {
|
||||
auto resetActorValue = [&](RE::ActorValueInfo* a_valueInfo, float a_value) {
|
||||
if (!a_valueInfo) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (a_proxy.GetActorValue(*a_valueInfo) == a_value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
a_proxy.SetActorValue(*a_valueInfo, a_value);
|
||||
return true;
|
||||
};
|
||||
|
||||
hostileStateReset |= resetActorValue(actorValues->aggression, 0.0F);
|
||||
hostileStateReset |= resetActorValue(actorValues->confidence, 0.0F);
|
||||
hostileStateReset |= resetActorValue(actorValues->assistance, 0.0F);
|
||||
}
|
||||
|
||||
if (hostileStateReset) {
|
||||
LogThrottledInfo(
|
||||
"runtime_proxy_hostility_reset_" + std::to_string(playerId) + "_" + std::to_string(actorId),
|
||||
std::format(
|
||||
"Runtime proxy actor {:08X} for remote player {} had hostility/aggression state reset. reason={}.",
|
||||
actorId,
|
||||
playerId,
|
||||
reason));
|
||||
}
|
||||
|
||||
if (auto* process = a_proxy.currentProcess) {
|
||||
auto processSuppressed = false;
|
||||
if (!process->ignoringCombat) {
|
||||
process->ignoringCombat = true;
|
||||
processSuppressed = true;
|
||||
}
|
||||
if (!process->endAlarmOnActor) {
|
||||
process->endAlarmOnActor = true;
|
||||
processSuppressed = true;
|
||||
}
|
||||
|
||||
if (processSuppressed) {
|
||||
LogThrottledInfo(
|
||||
"runtime_proxy_ai_process_suppressed_" + std::to_string(playerId) + "_" + std::to_string(actorId),
|
||||
std::format(
|
||||
"Runtime proxy actor {:08X} for remote player {} had safe AI/process combat intervention suppressed. reason={}.",
|
||||
actorId,
|
||||
playerId,
|
||||
reason));
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Investigate collision/stagger suppression separately. This pass only
|
||||
// clears persistent combat/hostility state and avoids hide/delete/disable APIs.
|
||||
}
|
||||
|
||||
const char* GetActiveProxySourceForLog(ActiveProxyKind a_kind)
|
||||
{
|
||||
switch (a_kind) {
|
||||
@@ -2827,6 +3145,9 @@ namespace
|
||||
g_runtimeProxySpawnAttemptPlayerId.reset();
|
||||
}
|
||||
|
||||
if (auto* player = RE::PlayerCharacter::GetSingleton()) {
|
||||
NeutralizeProxyActor(a_slot, a_actor, *player, ProxyNeutralizationReason::kSpawnPromoted);
|
||||
}
|
||||
LogInfoWithLocalPlayerPrefix(std::format(
|
||||
"Stage 4 runtime proxy spawned for remote player {}: actor={:08X}, handle={:08X}, source={}.",
|
||||
a_slot.remotePlayerId,
|
||||
@@ -3731,11 +4052,19 @@ namespace
|
||||
{
|
||||
if (a_slot.lifecycleState == a_reason && a_slot.holdReason == a_reason) {
|
||||
CorrectHeldSlotPositionIfNeeded(a_slot, a_proxy);
|
||||
if (ShouldLog("runtime_proxy_holding_neutralization_check_" + std::to_string(a_slot.remotePlayerId), kProxyNeutralizationInterval)) {
|
||||
if (auto* player = RE::PlayerCharacter::GetSingleton()) {
|
||||
NeutralizeProxyActor(a_slot, a_proxy, *player, ProxyNeutralizationReason::kHolding);
|
||||
}
|
||||
}
|
||||
LogProxyStillHeld(a_reason, a_slot.remotePlayerId);
|
||||
return;
|
||||
}
|
||||
|
||||
a_proxy.SetPosition(GetSlotHoldingPosition(a_slot), true);
|
||||
if (auto* player = RE::PlayerCharacter::GetSingleton()) {
|
||||
NeutralizeProxyActor(a_slot, a_proxy, *player, ProxyNeutralizationReason::kHolding);
|
||||
}
|
||||
a_slot.lifecycleState = a_reason;
|
||||
a_slot.holdReason = a_reason;
|
||||
ResetSlotMovementState(a_slot);
|
||||
@@ -4010,6 +4339,7 @@ namespace
|
||||
a_remotePlayer.playerId));
|
||||
|
||||
if (auto* proxy = TryResolveSlotProxy(slot, a_parentCell, a_player)) {
|
||||
NeutralizeProxyActor(slot, *proxy, a_player, ProxyNeutralizationReason::kSlotReassigned);
|
||||
RestoreSlotProxyForRemotePlayer(slot, *proxy, a_remotePlayer, a_player, a_sameCellPassed);
|
||||
}
|
||||
|
||||
@@ -4075,6 +4405,10 @@ namespace
|
||||
}
|
||||
}
|
||||
|
||||
if (ShouldLog("runtime_proxy_active_neutralization_check_" + std::to_string(a_slot.remotePlayerId), kProxyNeutralizationInterval)) {
|
||||
NeutralizeProxyActor(a_slot, *proxy, a_player, ProxyNeutralizationReason::kActiveMaintenance);
|
||||
}
|
||||
|
||||
if (!a_slot.restoredForCurrentState ||
|
||||
a_slot.lifecycleState != ProxyLifecycleState::kActive ||
|
||||
ShouldSnapRemoteMovement(a_remotePlayer.movementType)) {
|
||||
|
||||
Reference in New Issue
Block a user