Add COVault109 solo-cell handling

Implement solo-cell logic to suppress transform packet sends and remote proxy representation while the local player is in COVault109, allowing first-time character creation to remain visually single-player. One entry location update is sent so other clients can hold stale proxies before suppression begins. Added F4TSoloCell.h helper for load-order-independent solo cell recognition by editor ID and cached FormID.
This commit is contained in:
2026-06-29 14:46:11 +12:00
parent aefeb8b51f
commit b505477e08
8 changed files with 153 additions and 16 deletions
+1
View File
@@ -10,6 +10,7 @@ For testing notes, milestone summaries, known issues, and next steps, see [`docs
## [Unreleased] ## [Unreleased]
### Added ### Added
- `COVault109` solo-cell handling in the plugin so first-time character-creation space suppresses outgoing transform packets and remote proxy representation.
- `compile-papyrus.bat` to compile active Papyrus sources from `creation-kit\scripts\source` into `creation-kit\scripts\compiled` using the Fallout 4 Creation Kit Papyrus compiler, resolving the Fallout 4 install with the same path workflow as `deploy-all.bat`, using saved `.fallout4-path` values without re-prompting during builds, and skipping reference-only `CoSync*.psc` files. - `compile-papyrus.bat` to compile active Papyrus sources from `creation-kit\scripts\source` into `creation-kit\scripts\compiled` using the Fallout 4 Creation Kit Papyrus compiler, resolving the Fallout 4 install with the same path workflow as `deploy-all.bat`, using saved `.fallout4-path` values without re-prompting during builds, and skipping reference-only `CoSync*.psc` files.
- `build-all.bat` now compiles Papyrus scripts before staging the mod package, and still stages the repo build folder for inspection when Papyrus compilation fails. - `build-all.bat` now compiles Papyrus scripts before staging the mod package, and still stages the repo build folder for inspection when Papyrus compilation fails.
- `deploy-all.bat` now resolves the Fallout 4 path, compiles Papyrus scripts, stages the repo build folder, and then deploys from `build\Fallout 4\Data` into the Fallout 4 `Data` folder. - `deploy-all.bat` now resolves the Fallout 4 path, compiles Papyrus scripts, stages the repo build folder, and then deploys from `build\Fallout 4\Data` into the Fallout 4 `Data` folder.
+23 -15
View File
@@ -37,23 +37,29 @@ both send and receive paths:
`serverWorldState` to all clients for admin weather (`fw`) and time `serverWorldState` to all clients for admin weather (`fw`) and time
(`set gamehour to HHmm`) commands. Promoted hosts continue with their (`set gamehour to HHmm`) commands. Promoted hosts continue with their
already-synced local time and weather so remaining clients should not see a jump. already-synced local time and weather so remaining clients should not see a jump.
12. On the game-thread update path, the proxy controller reads a copied 12. The plugin treats `COVault109` as a solo cell for first-time character
creation. On entry it can send one location update so other clients hold any
stale proxy, then outgoing transform packets are suppressed and remote proxy
representation is skipped while the local player remains inside. Looks and
SPECIAL setup therefore stay visually single-player. The relay protocol is
unchanged; the rule is enforced client-side by the plugin.
13. On the game-thread update path, the proxy controller reads a copied
remote-player snapshot before attempting any actor work. The snapshot is remote-player snapshot before attempting any actor work. The snapshot is
sorted by `playerId`, and only remote players with valid transform data in sorted by `playerId`, and only remote players with valid transform data in
the same cell are eligible for runtime proxy representation. the same cell are eligible for runtime proxy representation.
13. For each eligible remote `playerId`, the controller maintains a 14. For each eligible remote `playerId`, the controller maintains a
`remotePlayerId -> runtime proxy slot` mapping. Each slot stores its `remotePlayerId -> runtime proxy slot` mapping. Each slot stores its
`ObjectRefHandle`, lifecycle state, PlaceAtMe-backed spawn/candidate state, `ObjectRefHandle`, lifecycle state, PlaceAtMe-backed spawn/candidate state,
movement smoothing state, and data-only animation debug state. Stage 4 caps runtime movement smoothing state, and data-only animation debug state. Stage 4 caps runtime
proxy slots at four actors for testing. proxy slots at four actors for testing.
14. Runtime proxies are demand-driven. A slot executes the Stage 3.7 15. Runtime proxies are demand-driven. A slot executes the Stage 3.7
PlaceAtMe-backed spawn flow only when that remote player needs a same-cell PlaceAtMe-backed spawn flow only when that remote player needs a same-cell
representation, and only one PlaceAtMe spawn attempt may run at a time so representation, and only one PlaceAtMe spawn attempt may run at a time so
candidate isolation remains unambiguous. Stage 4 captures the remote candidate isolation remains unambiguous. Stage 4 captures the remote
player's current valid transform before spawning and moves the isolated player's current valid transform before spawning and moves the isolated
runtime actor to that transform immediately, so normal remote representation runtime actor to that transform immediately, so normal remote representation
does not visually start at the local player offset. does not visually start at the local player offset.
15. Runtime proxies receive a small game-thread neutralization pass when a 16. Runtime proxies receive a small game-thread neutralization pass when a
PlaceAtMe-backed actor is promoted, when a reusable slot is reassigned, while PlaceAtMe-backed actor is promoted, when a reusable slot is reassigned, while
active on a throttle, and when moved or kept in holding. The pass only uses active on a throttle, and when moved or kept in holding. The pass only uses
confirmed CommonLibF4 actor APIs/fields to stop combat, clear combat target confirmed CommonLibF4 actor APIs/fields to stop combat, clear combat target
@@ -63,60 +69,60 @@ both send and receive paths:
alarm, bump, or package movement intent is observed. It does not disable, alarm, bump, or package movement intent is observed. It does not disable,
hide, delete, despawn, alpha-fade, or force animation graph events on the hide, delete, despawn, alpha-fade, or force animation graph events on the
actor. actor.
16. Proxy visual updates are independent per slot. Normal movement is smoothed 17. Proxy visual updates are independent per slot. Normal movement is smoothed
toward that players latest target, while `cell_change`, `worldspace_change`, toward that players latest target, while `cell_change`, `worldspace_change`,
and `teleport` snap directly. Movement and animation-related remote state is and `teleport` snap directly. Movement and animation-related remote state is
still observed and logged only; no visual crouch/sneak, jump, weapon drawn, still observed and logged only; no visual crouch/sneak, jump, weapon drawn,
locomotion, animation graph, or actor-state application is attempted. locomotion, animation graph, or actor-state application is attempted.
17. When a remote player leaves the cell or disconnects, only that players 18. When a remote player leaves the cell or disconnects, only that players
runtime proxy is moved to the hidden holding position inside runtime proxy is moved to the hidden holding position inside
`F4TTestCell01`. Runtime actors are not disabled or deleted. Connected `F4TTestCell01`. Runtime actors are not disabled or deleted. Connected
players who leave the cell keep their slot reserved, while disconnected players who leave the cell keep their slot reserved, while disconnected
held slots become reusable only after the configured grace period. Runtime held slots become reusable only after the configured grace period. Runtime
slots use per-slot holding positions with spacing and a game-thread slots use per-slot holding positions with spacing and a game-thread
correction check so held actors do not stack or drift back into view. correction check so held actors do not stack or drift back into view.
18. The placed fallback reference `F4TProxyRemotePlayer01REF` remains a single 19. The placed fallback reference `F4TProxyRemotePlayer01REF` remains a single
backup representation. If runtime spawning fails for one deterministic backup representation. If runtime spawning fails for one deterministic
selected remote player, the fallback can represent that one player only; it selected remote player, the fallback can represent that one player only; it
is not used for multiple remote players. is not used for multiple remote players.
19. As part of the staged runtime proxy actor manager work, the same game-thread 20. As part of the staged runtime proxy actor manager work, the same game-thread
proxy controller also logs whether `Fallout4Together_Test.esp` is loaded and proxy controller also logs whether `Fallout4Together_Test.esp` is loaded and
whether the actor base `F4T_RemotePlayerProxy` resolves by editor ID. whether the actor base `F4T_RemotePlayerProxy` resolves by editor ID.
20. Stage 3.1 adds a temporary diagnostic layer around the single runtime proxy: 21. Stage 3.1 adds a temporary diagnostic layer around the single runtime proxy:
post-spawn visibility data, loaded-3D pointer checks where safe, console post-spawn visibility data, loaded-3D pointer checks where safe, console
helper logs, active source logs, and throttled post-movement diagnostics. helper logs, active source logs, and throttled post-movement diagnostics.
The diagnostic mode can force the runtime proxy near the local player to The diagnostic mode can force the runtime proxy near the local player to
prove whether the created actor is visibly renderable. prove whether the created actor is visibly renderable.
21. Stage 3.2 adds a temporary near-player visibility hold for that same single 22. Stage 3.2 adds a temporary near-player visibility hold for that same single
runtime proxy. During the hold, remote state is still selected and validated, runtime proxy. During the hold, remote state is still selected and validated,
but runtime proxy restore and remote movement are deferred long enough to but runtime proxy restore and remote movement are deferred long enough to
test whether a runtime-created actor ref can load visible 3D while kept near test whether a runtime-created actor ref can load visible 3D while kept near
the local player. the local player.
22. Stage 3.3 adds a temporary vanilla actor-base diagnostic layer. It attempts 23. Stage 3.3 adds a temporary vanilla actor-base diagnostic layer. It attempts
to resolve Codsworth as a visible vanilla `TESNPC` candidate, then passes to resolve Codsworth as a visible vanilla `TESNPC` candidate, then passes
that actor base into the same runtime spawn, handle validation, near-player that actor base into the same runtime spawn, handle validation, near-player
hold, and visibility diagnostic path. This isolates custom actor-base issues hold, and visibility diagnostic path. This isolates custom actor-base issues
from runtime-spawn/init issues without changing the final architecture. from runtime-spawn/init issues without changing the final architecture.
23. Stage 3.4 adds a temporary absolute FormID and existing-reference diagnostic 24. Stage 3.4 adds a temporary absolute FormID and existing-reference diagnostic
layer. It compares Codsworth base-form runtime spawning against moving the layer. It compares Codsworth base-form runtime spawning against moving the
existing placed Codsworth reference near the local player as a visibility existing placed Codsworth reference near the local player as a visibility
control. This separates runtime actor creation/init problems from vanilla control. This separates runtime actor creation/init problems from vanilla
actor-base or placed-reference movement problems and does not change the actor-base or placed-reference movement problems and does not change the
final architecture. final architecture.
24. Stage 3.5 adds a temporary PlaceAtMe-equivalent diagnostic after manual 25. Stage 3.5 adds a temporary PlaceAtMe-equivalent diagnostic after manual
console validation proved `player.placeatme 000179FF 1` can spawn visible console validation proved `player.placeatme 000179FF 1` can spawn visible
Codsworth in `F4TTestCell01`. The plugin executes that console placement path Codsworth in `F4TTestCell01`. The plugin executes that console placement path
from the local player, resolves the newly created Codsworth actor near the from the local player, resolves the newly created Codsworth actor near the
player, and holds/logs it separately from the `CreateReferenceAtLocation` player, and holds/logs it separately from the `CreateReferenceAtLocation`
runtime proxy path. runtime proxy path.
25. Stage 3.6 refines that temporary PlaceAtMe diagnostic layer. It snapshots 26. Stage 3.6 refines that temporary PlaceAtMe diagnostic layer. It snapshots
known Codsworth refs before executing `player.placeatme 000179FF 1`, accepts known Codsworth refs before executing `player.placeatme 000179FF 1`, accepts
only a newly isolated Codsworth ref for the primary diagnostic, and preserves only a newly isolated Codsworth ref for the primary diagnostic, and preserves
that actor at the original console-spawned location during a short settle that actor at the original console-spawned location during a short settle
window. This tests whether the actor becomes visible naturally or requires a window. This tests whether the actor becomes visible naturally or requires a
render/process refresh such as the manually observed pause-menu or alt-tab render/process refresh such as the manually observed pause-menu or alt-tab
behavior. Stage 3.6 does not change the final architecture. behavior. Stage 3.6 does not change the final architecture.
26. Stage 3.7 promotes the working PlaceAtMe-style spawn pattern into the 27. Stage 3.7 promotes the working PlaceAtMe-style spawn pattern into the
preferred single runtime proxy spawn path. The proxy controller uses the preferred single runtime proxy spawn path. The proxy controller uses the
resolved runtime FormID for `F4T_RemotePlayerProxy` when available, falls resolved runtime FormID for `F4T_RemotePlayerProxy` when available, falls
back to Codsworth only for diagnostics, defers active proxy selection during back to Codsworth only for diagnostics, defers active proxy selection during
@@ -155,6 +161,8 @@ The native plugin is responsible for:
- Loading into Fallout 4 - Loading into Fallout 4
- Reading local player state - Reading local player state
- Sending local player data to the server - Sending local player data to the server
- Suppressing local transform sends and remote proxy representation while the
local player is in solo cells such as `COVault109`
- Adding basic data-only movement state to transform packets - Adding basic data-only movement state to transform packets
- Throttling normal transform sends separately from readable local movement logs - Throttling normal transform sends separately from readable local movement logs
- Receiving server packets on a background thread - Receiving server packets on a background thread
+35
View File
@@ -9,6 +9,41 @@ failed experiments, successful tests, and next steps.
--- ---
## 2026-06-29 - `COVault109` Solo Cell
### Summary
Made the first-time `COVault109` starting cell single-player from the multiplayer sync perspective so character customization and SPECIAL setup are not disturbed by remote player proxies.
### Files Changed
- `plugin/include/F4TSoloCell.h`
- `plugin/src/main.cpp`
- `plugin/src/F4TProxyActorController.cpp`
- `changelog.md`
- `docs/dev-log.md`
- `docs/architecture.md`
- `protocol/player-sync.md`
### Details
- Added a shared solo-cell helper that recognizes `COVault109` by editor ID and cached runtime FormID, avoiding load-order-dependent hardcoded IDs.
- Sends one entry location update for `COVault109`, then suppresses outgoing local transform packets while the local player remains inside so other clients can hold stale proxies and stop receiving fresh position updates for a player in character creation.
- Suppressed remote proxy representation while the local player is inside `COVault109`; existing proxy slots are held instead of being moved or restored in the starting cell.
- Kept the Python relay and transform packet structure unchanged.
### Testing
- Not run in-game in this session.
- Expected result: two connected clients can both be in `COVault109` without seeing runtime proxies in that cell.
- Expected result: leaving `COVault109` resumes transform sending and normal same-cell proxy behavior outside the solo cell.
### Known Issues
- The solo-cell list is currently code-defined and contains only `COVault109`.
### Next Steps
- Run two Fallout 4 instances through first-time character creation and confirm no proxy actors appear in `COVault109`.
- Check `CommonwealthOnline.log` for solo-cell suppression/resume messages.
- Verify normal transform and proxy sync still works after leaving `COVault109`.
---
## 2026-06-28 - Papyrus Build Integration ## 2026-06-28 - Papyrus Build Integration
### Summary ### Summary
+40
View File
@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <optional>
#include <string_view>
namespace F4T::SoloCell
{
inline constexpr auto kVault109EditorId = "COVault109";
inline std::optional<std::uint32_t>& GetVault109CellIdCache()
{
static std::optional<std::uint32_t> s_vault109CellId;
return s_vault109CellId;
}
inline bool HasEditorId(const RE::TESObjectCELL& a_cell, std::string_view a_editorId)
{
const auto* cellEditorId = a_cell.GetFormEditorID();
return cellEditorId && a_editorId == cellEditorId;
}
inline bool IsSoloCell(const RE::TESObjectCELL& a_cell)
{
if (HasEditorId(a_cell, kVault109EditorId)) {
return true;
}
auto& vault109CellId = GetVault109CellIdCache();
if (!vault109CellId) {
const auto* vault109Cell = RE::TESForm::GetFormByEditorID<RE::TESObjectCELL>(
RE::BSFixedString{ kVault109EditorId });
if (vault109Cell) {
vault109CellId = vault109Cell->GetFormID();
}
}
return vault109CellId && a_cell.GetFormID() == *vault109CellId;
}
}
+8
View File
@@ -9,6 +9,7 @@
#include "F4RemoteActionComponent.h" #include "F4RemoteActionComponent.h"
#include "F4TInterpolationSystem.h" #include "F4TInterpolationSystem.h"
#include "F4TProxyGameAPI.h" #include "F4TProxyGameAPI.h"
#include "F4TSoloCell.h"
#include "RE/A/ACTOR_STANCE.h" #include "RE/A/ACTOR_STANCE.h"
#include "RE/A/ActorValue.h" #include "RE/A/ActorValue.h"
@@ -1700,6 +1701,13 @@ namespace
const RE::PlayerCharacter& a_player, const RE::PlayerCharacter& a_player,
const RE::TESObjectCELL& a_parentCell) const RE::TESObjectCELL& a_parentCell)
{ {
if (F4T::SoloCell::IsSoloCell(a_parentCell)) {
LogThrottledInfo(
"solo_cell_proxy_suppressed",
"COVault109 is a solo cell; remote proxy representation is suppressed while local player is inside.");
return false;
}
const auto remoteCellId = ParseHexFormId(a_remotePlayer.cellId); const auto remoteCellId = ParseHexFormId(a_remotePlayer.cellId);
if (!remoteCellId) { if (!remoteCellId) {
LogThrottledWarning( LogThrottledWarning(
+37 -1
View File
@@ -6,6 +6,7 @@
#include "F4TProxyActorController.h" #include "F4TProxyActorController.h"
#include "F4TProxyAnimationSync.h" #include "F4TProxyAnimationSync.h"
#include "F4TProxyPuppet.h" #include "F4TProxyPuppet.h"
#include "F4TSoloCell.h"
#include "F4TWorldStateSync.h" #include "F4TWorldStateSync.h"
#include "RE/A/ACTOR_STANCE.h" #include "RE/A/ACTOR_STANCE.h"
@@ -34,6 +35,7 @@ namespace
std::uint32_t cellId; std::uint32_t cellId;
bool hasWorldspaceId; bool hasWorldspaceId;
std::uint32_t worldspaceId; std::uint32_t worldspaceId;
bool isSoloCell;
}; };
struct PlayerMovementState struct PlayerMovementState
@@ -133,13 +135,15 @@ namespace
false, false,
0, 0,
false, false,
0 0,
false
}; };
const auto* parentCell = a_player.GetParentCell(); const auto* parentCell = a_player.GetParentCell();
if (parentCell) { if (parentCell) {
location.hasCellId = true; location.hasCellId = true;
location.cellId = parentCell->GetFormID(); location.cellId = parentCell->GetFormID();
location.isSoloCell = F4T::SoloCell::IsSoloCell(*parentCell);
} }
if (a_player.cachedWorldspace) { if (a_player.cachedWorldspace) {
@@ -480,6 +484,7 @@ namespace
static auto lastDebugSampleTime = std::chrono::steady_clock::time_point{}; static auto lastDebugSampleTime = std::chrono::steady_clock::time_point{};
static auto debugMovingHoldUntil = std::chrono::steady_clock::time_point{}; static auto debugMovingHoldUntil = std::chrono::steady_clock::time_point{};
static auto networkMovingHoldUntil = std::chrono::steady_clock::time_point{}; static auto networkMovingHoldUntil = std::chrono::steady_clock::time_point{};
static bool soloCellTransformSuppressed = false;
const auto currentTransform = GetPlayerTransform(*player); const auto currentTransform = GetPlayerTransform(*player);
const auto currentLocation = GetPlayerLocation(*player); const auto currentLocation = GetPlayerLocation(*player);
@@ -501,6 +506,29 @@ namespace
debugMovementState.weaponDrawn, debugMovementState.weaponDrawn,
debugMovementState.movementSpeed, debugMovementState.movementSpeed,
currentTime); currentTime);
if (currentLocation.isSoloCell && soloCellTransformSuppressed) {
lastSentTransform = currentTransform;
lastDebugSampleTransform = currentTransform;
lastKnownLocation = currentLocation;
lastSentMovementState = debugMovementState;
lastSendTime = currentTime;
lastLogTime = currentTime;
lastDebugSampleTime = currentTime;
hasLastTransform = true;
return;
}
if (currentLocation.isSoloCell) {
LogInfoWithLocalPlayerPrefix("COVault109 is a solo cell; sending entry location update before suppressing further transforms.");
}
if (soloCellTransformSuppressed) {
LogInfoWithLocalPlayerPrefix("Local player left solo cell; transform packet sending resumed.");
soloCellTransformSuppressed = false;
}
if (!hasLastTransform) { if (!hasLastTransform) {
lastSentTransform = currentTransform; lastSentTransform = currentTransform;
lastDebugSampleTransform = currentTransform; lastDebugSampleTransform = currentTransform;
@@ -538,6 +566,9 @@ namespace
std::format(", worldspaceId={:08X}", currentLocation.worldspaceId) : std::format(", worldspaceId={:08X}", currentLocation.worldspaceId) :
std::string{})); std::string{}));
} }
if (currentLocation.isSoloCell) {
soloCellTransformSuppressed = true;
}
return; return;
} }
@@ -627,6 +658,11 @@ namespace
LogPlayerTransform(currentTransform); LogPlayerTransform(currentTransform);
lastLogTime = currentTime; lastLogTime = currentTime;
} }
}
if (currentLocation.isSoloCell) {
soloCellTransformSuppressed = true;
} }
lastDebugSampleTransform = currentTransform; lastDebugSampleTransform = currentTransform;
+9
View File
@@ -31,6 +31,13 @@ Fallout 4 actors or references. If a remote player is not in the same
cell/worldspace, the controller holds that player's proxy at a hidden in-cell cell/worldspace, the controller holds that player's proxy at a hidden in-cell
position instead of leaving it at the last visible location. position instead of leaving it at the last visible location.
`COVault109` is treated as a solo cell for the first-time character-creation
flow. On entry the plugin can send one location update so other clients hold any
stale proxy, then it suppresses outgoing transform packets while the local
player remains inside and the proxy controller skips remote representation. This
is a client-side visibility/isolation rule and does not change the transform
packet schema.
## Current Remote Player State Model ## Current Remote Player State Model
The plugin and fake client store remote players by `playerId`. The plugin and fake client store remote players by `playerId`.
@@ -97,6 +104,8 @@ player exists.
resuming smoothing. resuming smoothing.
- Transform packets include movement state data (`isMoving`, `movementSpeed`, - Transform packets include movement state data (`isMoving`, `movementSpeed`,
`isSprinting`, `isSneaking`, `isJumping`, `weaponDrawn`). `isSprinting`, `isSneaking`, `isJumping`, `weaponDrawn`).
- The plugin suppresses transform sends and proxy representation in the
`COVault109` solo cell.
- The game-thread proxy controller applies confirmed Havok animation graph - The game-thread proxy controller applies confirmed Havok animation graph
variables to runtime proxy actors from that remote state (locomotion, sneak, variables to runtime proxy actors from that remote state (locomotion, sneak,
jump, weapon drawn). Transform position/heading sync is unchanged. jump, weapon drawn). Transform position/heading sync is unchanged.