Add Papyrus sync scripts and proxy spawn refactor

Add new Papyrus scripts (CoSync.psc, CoSyncPlayer.psc, CoSyncQuest.psc) providing native bindings and quest/player proxy logic for networking: connection/session APIs, entity management, world/weather/workshop/companion sync, consumable/pipboy/door/power-armor events, and proxy animation/initialization handlers. Refactor the proxy actor controller (F4TProxyActorController.cpp) to support dynamic/runtime proxy spawning and improved lifecycle/visibility handling: change proxy base/form IDs to COPlayerProxy/CommonwealthOnline, add process-list actor lookup, spawn helpers, play-space/cell checks, 3D visibility refresh, ActorHandle usage, and spawn mutexes and globals for safer spawning. Update docs and setup (architecture.md, limitations.md, plugin/setup.md) to describe global dynamic spawn behavior, max runtime proxies (kMaxRuntimeProxyActors = 4), and validation checklist; update launch-two-fallout4.bat to show instance log locations. Misc: small tweaks to logging and debug flags to gate diagnostics.
This commit is contained in:
2026-06-07 20:31:51 +12:00
parent 21c8bf9593
commit cc4d8aac5a
10 changed files with 1645 additions and 190 deletions
+187
View File
@@ -0,0 +1,187 @@
Scriptname CoSync Native Hidden
; ==============================================================================
; This script must EXACTLY match the native functions in Papyrus_CoSync.cpp
; Signatures must match or functions won't bind!
; ==============================================================================
; ==============================================================================
; CONNECTION & SESSION MANAGEMENT
; ==============================================================================
int Function GetClientInstanceID() global native
Function SetClient(int instanceID) global native
bool Function IsConnected() global native
bool Function Connect(Actor player, ActorBase playerActorBase, string address, int port) global native
bool Function Disconnect() global native
Function Tick() global native
Function SyncWorld() global native
; ==============================================================================
; ENTITY MANAGEMENT
; ==============================================================================
int Function GetPlayerEntityID() global native
; C++ signature: NativeFunction1<StaticFunctionTag, SInt32, TESObjectREFR*>
int Function GetEntityID(ObjectReference ref) global native
; C++ signature: NativeFunction2<StaticFunctionTag, void, SInt32, TESObjectREFR*>
Function SetEntityRef(int entityID, ObjectReference ref) global native
bool Function IsEntityValid(int entityID) global native
; C++ signature: NativeFunction0<StaticFunctionTag, VMArray<SInt32>>
int[] Function GetAllEntityIDs() global native
; C++ signature: NativeFunction1<StaticFunctionTag, TESObjectREFR*, SInt32>
; IMPORTANT: Returns ObjectReference, NOT Actor!
ObjectReference Function GetActorByEntityID(int entityID) global native
; C++ signature: NativeFunction1<StaticFunctionTag, SInt32, TESObjectREFR*>
; IMPORTANT: Takes ObjectReference, NOT Actor!
int Function GetEntityIDForActor(ObjectReference akRef) global native
; ==============================================================================
; ENTITY POSITION & STATE
; ==============================================================================
float[] Function GetEntityPosition(int entityID) global native
Function SetEntityPosition(int entityID, float x, float y, float z) global native
Function SetEntVarNum(int entityID, string name, float value) global native
Function SetEntVarAnim(int entityID, string animState) global native
float Function GetEntVarNum(int entityID, string name) global native
string Function GetEntVarAnim(int entityID) global native
; ==============================================================================
; UTILITY FUNCTIONS
; ==============================================================================
ObjectReference[] Function GetRefsInCell(Cell cell) global native
float Function Atan2(float y, float x) global native
string Function GetWalkDir(float dX, float dY, float angleZ) global native
; C++ signature: NativeFunction1<StaticFunctionTag, PapyrusActionType, BSFixedString>
; PapyrusActionType is TESForm* in C++, so use Form in Papyrus
Form Function GetAction(string name) global native
Function Inspect(Form[] forms) global native
bool Function AnimLoops(string animState) global native
; ==============================================================================
; ACTOR CUSTOMIZATION
; ==============================================================================
Function CopyAppearance(ActorBase src, ActorBase dest) global native
Function CopyWornItems(Actor src, Actor dest) global native
; ==============================================================================
; COMBAT & ACTIONS
; ==============================================================================
Function PlayerHit(int hitter, int hittee, float damage) global native
Function PlayerFireWeapon() global native
Function TopicInfoBegin(Form info, ObjectReference speaker) global native
; ==============================================================================
; SYNCHRONIZATION
; ==============================================================================
ObjectReference[] Function GetEntitySyncRefs(bool clear) global native
float[] Function GetEntitySyncTransforms(bool clear) global native
; ==============================================================================
; NPC SPAWNING
; ==============================================================================
bool Function SpawnNpc(ObjectReference ref) global native
; ==============================================================================
; WORLD / WEATHER / WORKSHOP SYNC
; Called from CoSyncQuest.psc when game events fire.
; ==============================================================================
bool Function IsHost() global native
; Returns the weather formID locked from the host (0 = none / not connected).
; Called from the CoSyncQuest weather timer on the CLIENT to apply host weather
; via w.ForceActive(True) — avoids unreliable C++→Papyrus CallFunctionNoWait.
int Function GetPendingWeatherFormID() global native
; Pass Weather.GetCurrentWeather() cast as Form
Function NotifyWeatherChanged(Form akWeather) global native
; Workshop build: pass the workshop marker ref and the newly placed ref
Function NotifyWorkshopBuild(ObjectReference akWorkshop, ObjectReference akPlaced) global native
; Workshop destroy: pass the workshop marker ref and the scrapped ref
Function NotifyWorkshopDestroy(ObjectReference akWorkshop, ObjectReference akScrapped) global native
; Workshop power toggle: pass the power-switch ref and new powered state
Function NotifyWorkshopPower(ObjectReference akRef, bool isPowered) global native
; Workshop mode enter/exit — call with the actual workshop ref (fixes formID=0 from C++ side)
Function NotifyWorkshopMode(ObjectReference akWorkshop, bool isEntering) global native
; ==============================================================================
; QUEST / MAP MARKER SYNC
; ==============================================================================
Function NotifyQuestStage(Quest akQuest, int aiStage) global native
Function NotifyMapMarkerDiscovered(ObjectReference akMarker) global native
; ==============================================================================
; POWER ARMOR SYNC
; ==============================================================================
Function NotifyPowerArmor(Actor akActor, ObjectReference akPowerArmor, bool abEntering) global native
; ==============================================================================
; CONSUMABLE / MAGIC SYNC
; ==============================================================================
; Call when the local player uses an AlchemyItem (stimpaks, Rad-Away, etc.)
Function NotifyConsumableUsed(Form akItem) global native
; Call when a spell or persistent magic effect is applied to the local player
Function NotifySpellApplied(Form akSpell) global native
; ==============================================================================
; PIP-BOY SYNC
; ==============================================================================
; Call when the Pip-boy menu opens (abOpening=true) or closes (abOpening=false)
Function NotifyPipboyOpen(bool abOpening) global native
; ==============================================================================
; DOOR / ACTIVATABLE STATE SYNC
; ==============================================================================
; Returns true if akRef's base form is a DOOR (formType 32).
; FO4 Papyrus has no ObjectReference.GetFormType() — this native fills the gap.
bool Function IsDoor(ObjectReference akRef) global native
; Call when the local player activates a DOOR ref.
; aiOpenState = akDoorRef.GetOpenState() — 0=None, 1=Open, 2=Opening, 3=Closed, 4=Closing
; C++ applies a suppression guard so network-applied activations do not echo back.
Function NotifyActivate(ObjectReference akRef, int aiOpenState) global native
; ==============================================================================
; COMPANION SYNC
; ==============================================================================
; Call when the local player recruits a vanilla companion (detected via follower poll).
; C++ reads akCompanion's base form ID, checks for duplicate entity registration,
; then calls CoSyncCompanionSync::RequestCompanion so all peers spawn a proxy.
; NoWait — safe to call from timer without waiting for return.
Function NotifyCompanionRecruited(Actor akCompanion) global native
; Call when the local player dismisses a companion (follower poll sees it vanish).
; C++ looks up the registered entity ID for akCompanion and broadcasts COMP_DISMISS
; so all peers despawn the proxy.
; NoWait — safe to call from timer without waiting for return.
Function NotifyCompanionDismissed(Actor akCompanion) global native
@@ -0,0 +1,149 @@
; ==============================================================================
; CoSyncPlayer.psc
;
; Script attached to the CoSync remote-proxy Actor form in CreationKit.
;
; This script runs on EVERY spawned proxy actor (one per remote player).
; C++ (CoSyncPlayerManager) spawns a copy of the custom proxy NPC form via
; PlaceAtMe, then this OnInit fires and registers the actor with the C++ layer.
;
; Responsibilities:
; - Map this actor back to its network entity ID (reverse lookup via C++)
; - Provide helper functions for C++ to call via CallFunctionNoWait:
; InitProxy(entityID) — called by C++ after spawn, sets entityID
; SetProxyName(name) — sets a display string for debugging
; OnProxyDied() — called when entity death packet arrives
; - Handle appearance sync on first spawn (CopyAppearance from player snapshot)
;
; CreationKit setup:
; 1. Create a new NPC form (e.g. "CoSyncProxyNPC").
; Race: HumanRace (or the same race as the host player).
; Skeleton: DefaultMale.nif (gives standard weapon attach nodes).
; Behavior Graph: leave default — behavior graph injection happens in C++.
; 2. Attach this script to the CoSyncProxyNPC form via the Script panel.
; 3. The script has no CK-side Properties — all data arrives via C++ calls.
;
; NOTE: Weapon and armor sync are handled entirely in C++ (CoSyncWeaponSync /
; CoSyncOutfitSync) via BGSInventoryList polling. Papyrus here only manages
; initialization and death/cleanup.
; ==============================================================================
Scriptname CoSyncPlayer extends Actor
; ============================================================================
; State
; ============================================================================
int myEntityID = -1 ; Network entity ID (set by C++ via InitProxy)
string myUsername = "" ; Display name (set by C++ via SetProxyName)
bool isInitialized = false ; True once InitProxy has been called
; ============================================================================
; INITIALIZATION — called once by Papyrus VM when actor is created
; ============================================================================
Event OnInit()
; C++ calls InitProxy(entityID) via CallFunctionNoWait immediately after
; spawning. Papyrus event delivery is asynchronous, so InitProxy may arrive
; before or after OnInit. The isInitialized guard handles both orderings.
Debug.Trace("[CoSyncPlayer] OnInit — waiting for C++ InitProxy call")
EndEvent
; ============================================================================
; InitProxy — called by C++ (CallFunctionNoWait) right after PlaceAtMe
; ============================================================================
Function InitProxy(int entityID)
If isInitialized
; Duplicate call from a reconnect / re-spawn — update entityID only
myEntityID = entityID
Debug.Trace("[CoSyncPlayer] InitProxy re-init: entityID=" + entityID)
Return
EndIf
myEntityID = entityID
isInitialized = true
; ---- Disable vanilla AI so C++ can drive movement via anim vars ----
; EnableAI(false) is the FO4 Papyrus equivalent of "disable AI" on an actor.
; C++ also calls this after spawn, but doing it here provides a fallback
; in case the C++ call arrives out-of-order relative to OnInit.
self.EnableAI(False)
self.StopCombat()
; Disable VATS targeting — proxy should not be attackable via VATS
; (no SetValue available pre-NG; set via SetActorValue if needed)
Debug.Trace("[CoSyncPlayer] InitProxy complete: entityID=" + entityID)
EndFunction
; ============================================================================
; SetProxyName — called by C++ to label the proxy with the remote player's name
; ============================================================================
Function SetProxyName(string username)
myUsername = username
Debug.Trace("[CoSyncPlayer] SetProxyName: entityID=" + myEntityID + " name='" + username + "'")
EndFunction
; ============================================================================
; OnProxyDied — called by C++ when the remote entity's death packet arrives
; ============================================================================
Function OnProxyDied()
Debug.Trace("[CoSyncPlayer] OnProxyDied: entityID=" + myEntityID + " name='" + myUsername + "'")
; Trigger the actor's normal death so ragdoll / death animation plays
; KillSilent leaves no corpse marker visible to the local player
self.Kill(None)
EndFunction
; ============================================================================
; ANIMATION DISPATCH — called by C++ via CallFunctionNoWait
;
; All behavior graph events for this proxy go through here.
; Debug.SendAnimationEvent(self, name) is the reliable Papyrus path:
; - Validates actor state before dispatch (no crash on half-loaded actors)
; - Goes through full BSAnimationGraphManager dispatch (all graphs, correct one)
; - Appears in Papyrus trace logs for debugging
;
; C++ CallFunctionNoWait(actorRef, "SendAnimEvent", [eventName]) routes here
; because CoSyncPlayer.psc is attached to every proxy actor form.
; ============================================================================
; Dispatch a behavior graph event on this proxy actor.
; PlaySubGraphAnimation is the FO4 Papyrus equivalent of Skyrim's
; Debug.SendAnimationEvent — it dispatches a named behavior graph event
; on the calling actor's animation graph manager, validated by the engine.
Function SendAnimEvent(string asEventName)
self.PlaySubGraphAnimation(asEventName)
Debug.Trace("[CoSyncPlayer] SendAnimEvent: eid=" + myEntityID + " '" + asEventName + "'")
EndFunction
; Set behavior graph variable by name — used for one-time identity setup.
; Per-frame variables are still written by C++ LoadAnimationVariables (60fps).
Function SetAnimBool(string asVarName, bool abValue)
self.SetAnimationVariableBool(asVarName, abValue)
EndFunction
Function SetAnimFloat(string asVarName, float afValue)
self.SetAnimationVariableFloat(asVarName, afValue)
EndFunction
Function SetAnimInt(string asVarName, int aiValue)
self.SetAnimationVariableInt(asVarName, aiValue)
EndFunction
; ============================================================================
; Accessors — read-only state for external callers
; ============================================================================
int Function GetEntityID()
Return myEntityID
EndFunction
string Function GetProxyName()
Return myUsername
EndFunction
bool Function IsInitialized()
Return isInitialized
EndFunction
+559
View File
@@ -0,0 +1,559 @@
Scriptname CoSyncQuest extends Quest
; ==============================================================================
; CoSyncQuest — wires Papyrus game events to CoSync native sync functions.
; Animation control is handled entirely in C++ via behavior graph injection.
; ==============================================================================
; Timer IDs
int tickTimerID = 10
int updateTimerID = 20
int weatherTimerID = 30
int doorScanTimerID = 40
int companionTimerID = 50
; Properties
Actor Property playerRef Auto
ActorValue Property healthAV Auto
; Weather state (host-side change detection)
Weather lastWeather = None
; Power armor state — tracks whether the player is currently in PA so that
; Actor.OnGetUp can distinguish a PA exit from sitting in regular furniture.
bool bIsInPowerArmor = False
; Companion poll state — snapshot of followers from last poll cycle.
; Compared against Game.GetCurrentFollowers() each tick to detect
; recruit and dismiss events without requiring a dedicated event sink.
Actor[] trackedFollowers
; ==============================================================================
; INITIALIZATION
; ==============================================================================
; Shared setup — called from both OnInit (new game) and OnPlayerLoadGame (save load).
Function SetupEventHooks()
; ---- Workshop events -------------------------------------------------------
WorkshopParentScript wsParent = Game.GetFormFromFile(0xD62E, "Fallout4.esm") as WorkshopParentScript
If wsParent != None
RegisterForCustomEvent(wsParent, "WorkshopObjectBuilt")
RegisterForCustomEvent(wsParent, "WorkshopObjectDestroyed")
RegisterForCustomEvent(wsParent, "WorkshopEnterMenu")
Debug.Trace("[CoSyncQuest] Registered for WorkshopParent build/destroy/enter events")
Else
Debug.Trace("[CoSyncQuest] WARNING: WorkshopParent (0xD62E) not found")
EndIf
; Workshop exit: detect when WorkshopMenu HUD closes
RegisterForMenuOpenCloseEvent("WorkshopMenu")
; ---- Weather polling timer (cancel first to avoid duplicates) --------------
CancelTimer(weatherTimerID)
StartTimer(5.0, weatherTimerID)
Debug.Trace("[CoSyncQuest] Weather timer started")
; ---- Map marker discovery — register for player location changes ----------
; Only the host broadcasts discoveries; clients receive via MM| packet.
If CoSync.IsHost()
RegisterForRemoteEvent(playerRef, "OnLocationChange")
Debug.Trace("[CoSyncQuest] Registered for OnLocationChange (map sync)")
EndIf
; ---- Consumable use — fires when any item is equipped by the player -------
; Both host and client need this to broadcast their own consumable use.
RegisterForRemoteEvent(playerRef, "OnItemEquipped")
Debug.Trace("[CoSyncQuest] Registered for Actor.OnItemEquipped (consumable sync)")
; ---- Power armor enter / exit -------------------------------------------
; OnEnterPowerArmor / OnExitPowerArmor are F4SE extension events and cannot
; be used in vanilla Papyrus compilation. Instead we use Actor.OnSit /
; Actor.OnGetUp — power armor chassis is furniture the player sits in.
; IsInPowerArmor() on OnSit confirms it is a PA enter; a bool flag tracks
; state so OnGetUp can distinguish PA exit from regular furniture.
RegisterForRemoteEvent(playerRef, "OnSit")
RegisterForRemoteEvent(playerRef, "OnGetUp")
Debug.Trace("[CoSyncQuest] Registered for OnSit / OnGetUp (PA enter/exit detection)")
; ---- Pip-boy menu open/close ---------------------------------------------
; Already registered for WorkshopMenu above; extend to PipboyMenu.
RegisterForMenuOpenCloseEvent("PipboyMenu")
Debug.Trace("[CoSyncQuest] Registered for PipboyMenu open/close")
; ---- Door activation sync ------------------------------------------------
; Scan the player's current cell for DOOR refs and register OnActivate on
; each. The timer re-scans on cell transitions (every 10 s) to catch new
; doors after fast-travel or cell loads.
ScanCellDoors()
CancelTimer(doorScanTimerID)
StartTimer(10.0, doorScanTimerID)
Debug.Trace("[CoSyncQuest] Door scan started")
; ---- Companion recruit / dismiss poll ------------------------------------
; Game.GetCurrentFollowers() (F4SE) returns a live snapshot of all current
; followers. We diff against trackedFollowers each cycle to detect changes.
; 3-second interval balances responsiveness against Papyrus VM overhead.
CancelTimer(companionTimerID)
StartTimer(3.0, companionTimerID)
Debug.Trace("[CoSyncQuest] Companion poll started")
EndFunction
Event OnInit()
; Fires only once when the quest is first created (new game).
; For save loads, SetupEventHooks() is called via C++ NotifyGameLoaded().
Debug.Trace("[CoSyncQuest] OnInit")
SetupEventHooks()
EndEvent
; ==============================================================================
; TIMERS
; ==============================================================================
Event OnTimer(int aiTimerID)
If aiTimerID == tickTimerID
CoSync.Tick()
StartTimer(0.016, tickTimerID) ; ~60 fps
ElseIf aiTimerID == updateTimerID
int playerEntityID = CoSync.GetPlayerEntityID()
If CoSync.IsEntityValid(playerEntityID)
CoSync.SetEntVarNum(playerEntityID, "health", playerRef.GetValuePercentage(healthAV))
EndIf
StartTimer(0.1, updateTimerID) ; 10 Hz
ElseIf aiTimerID == weatherTimerID
If CoSync.IsHost()
; HOST: detect weather changes and broadcast to clients
Weather currentWeather = Weather.GetCurrentWeather()
If currentWeather != lastWeather
lastWeather = currentWeather
If currentWeather != None
CoSync.NotifyWeatherChanged(currentWeather as Form)
Debug.Trace("[CoSyncQuest] Weather changed -> NotifyWeatherChanged")
EndIf
EndIf
Else
; CLIENT: apply weather locked from host via native poll
; CoSync.GetPendingWeatherFormID() reads s_lockedWeatherFormID from C++
; (set when WX| packet is received). This avoids CallFunctionNoWait entirely.
Int pendingFormID = CoSync.GetPendingWeatherFormID()
If pendingFormID != 0
Weather w = Game.GetForm(pendingFormID) as Weather
If w != None
w.ForceActive(True)
Debug.Trace("[CoSyncQuest] CLIENT Applied host weather 0x" + pendingFormID)
Else
Debug.Trace("[CoSyncQuest] CLIENT host weather form 0x" + pendingFormID + " not found")
EndIf
Else
Debug.Trace("[CoSyncQuest] CLIENT no pending weather (GetPendingWeatherFormID=0)")
EndIf
EndIf
StartTimer(5.0, weatherTimerID)
ElseIf aiTimerID == doorScanTimerID
; Re-scan current cell for doors after potential cell transition.
; Registration is idempotent — calling RegisterForRemoteEvent on an
; already-registered ref is a no-op, so no unregister step needed.
ScanCellDoors()
StartTimer(10.0, doorScanTimerID)
ElseIf aiTimerID == companionTimerID
; Poll for companion recruit / dismiss changes.
; Only the local player's own followers matter — each player broadcasts
; their own companion events to the network.
PollCompanionChanges()
StartTimer(3.0, companionTimerID)
EndIf
EndEvent
; ==============================================================================
; WORKSHOP BUILD — fires when the local player places an object
; Arg layout: akArgs[0] = placed ObjectReference, akArgs[1] = WorkshopScript ref
; ==============================================================================
Event WorkshopParentScript.WorkshopObjectBuilt(WorkshopParentScript akSender, Var[] akArgs)
If !CoSync.IsHost()
Return
EndIf
If akArgs.Length < 2
Return
EndIf
ObjectReference placedRef = akArgs[0] as ObjectReference
ObjectReference workshopRef = akArgs[1] as ObjectReference
If workshopRef != None && placedRef != None
CoSync.NotifyWorkshopBuild(workshopRef, placedRef)
Debug.Trace("[CoSyncQuest] WorkshopObjectBuilt -> NotifyWorkshopBuild")
EndIf
EndEvent
; ==============================================================================
; WORKSHOP DESTROY — fires when the local player scraps an object
; Arg layout: akArgs[0] = scrapped ObjectReference, akArgs[1] = WorkshopScript ref
; ==============================================================================
Event WorkshopParentScript.WorkshopObjectDestroyed(WorkshopParentScript akSender, Var[] akArgs)
If !CoSync.IsHost()
Return
EndIf
If akArgs.Length < 2
Return
EndIf
ObjectReference scrappedRef = akArgs[0] as ObjectReference
ObjectReference workshopRef = akArgs[1] as ObjectReference
If workshopRef != None && scrappedRef != None
CoSync.NotifyWorkshopDestroy(workshopRef, scrappedRef)
Debug.Trace("[CoSyncQuest] WorkshopObjectDestroyed -> NotifyWorkshopDestroy")
EndIf
EndEvent
; ==============================================================================
; WORKSHOP MODE — enter via WorkshopEnterMenu, exit via OnMenuOpenCloseEvent
; akArgs[0]=None, akArgs[1]=workshop ObjectReference (the workbench)
; Using Papyrus events bypasses the broken C++ LookupREFRByHandle RVA.
; ==============================================================================
Event WorkshopParentScript.WorkshopEnterMenu(WorkshopParentScript akSender, Var[] akArgs)
If !CoSync.IsHost()
Return
EndIf
If akArgs.Length < 2
Return
EndIf
ObjectReference workshopRef = akArgs[1] as ObjectReference
If workshopRef != None
CoSync.NotifyWorkshopMode(workshopRef, true)
Debug.Trace("[CoSyncQuest] WorkshopEnterMenu -> NotifyWorkshopMode ENTER")
EndIf
EndEvent
Event OnMenuOpenCloseEvent(string asMenuName, bool abOpening)
If asMenuName == "WorkshopMenu"
If abOpening
Return ; handled by WorkshopEnterMenu (has the ref)
EndIf
If CoSync.IsHost()
CoSync.NotifyWorkshopMode(None, false)
Debug.Trace("[CoSyncQuest] WorkshopMenu closed -> NotifyWorkshopMode EXIT")
EndIf
ElseIf asMenuName == "PipboyMenu"
; Pip-boy open/close — sync to all clients so remote players can mirror
; the arm-raise animation and HUD state.
CoSync.NotifyPipboyOpen(abOpening)
Debug.Trace("[CoSyncQuest] PipboyMenu " + (abOpening as string) + " -> NotifyPipboyOpen")
EndIf
EndEvent
; ==============================================================================
; CONSUMABLE USE SYNC
; Fires when the local player equips any item. If it is an AlchemyItem
; (stimpak, Rad-Away, chem, etc.) we broadcast the use so remote actors
; can mirror the effect via EquipItem in C++.
; ==============================================================================
Event Actor.OnItemEquipped(Actor akActor, Form akBaseObject, ObjectReference akReference)
; Filter: only AlchemyItem (formType 46 = kFormType_ALCH)
If akBaseObject == None
Return
EndIf
If !(akBaseObject is Potion)
Return
EndIf
CoSync.NotifyConsumableUsed(akBaseObject)
Debug.Trace("[CoSyncQuest] OnItemEquipped(consumable) -> NotifyConsumableUsed 0x" + akBaseObject.GetFormID())
EndEvent
; ==============================================================================
; WEATHER FORCE — called from C++ on the client to apply host weather
; Receives the weather formID as an Int (from CoSyncTimeSync via CallFunctionNoWait).
; ==============================================================================
Function ForceHostWeather(Int weatherFormID)
Weather w = Game.GetForm(weatherFormID) as Weather
If w != None
w.ForceActive(True)
Debug.Trace("[CoSyncQuest] ForceHostWeather applied 0x" + weatherFormID)
Else
Debug.Trace("[CoSyncQuest] ForceHostWeather: form 0x" + weatherFormID + " not found or not Weather")
EndIf
EndFunction
; ==============================================================================
; TIMER CONTROL
; ==============================================================================
Function StartTimers()
StartTimer(0.016, tickTimerID)
StartTimer(0.1, updateTimerID)
CancelTimer(weatherTimerID)
StartTimer(5.0, weatherTimerID)
CancelTimer(doorScanTimerID)
StartTimer(10.0, doorScanTimerID)
CancelTimer(companionTimerID)
StartTimer(3.0, companionTimerID)
Debug.Trace("[CoSyncQuest] Timers started")
EndFunction
Function StopTimers()
CancelTimer(tickTimerID)
CancelTimer(updateTimerID)
CancelTimer(weatherTimerID)
CancelTimer(doorScanTimerID)
CancelTimer(companionTimerID)
EndFunction
; ==============================================================================
; REMOTE PLAYER SPAWN / DESPAWN
; Called from C++ (CoSyncPapyrusHelper) via CallFunctionNoWait after the
; actor has been spawned and registered in CoSyncEntityRegistry.
; SpawnRemotePlayer handles any Papyrus-side post-spawn setup (enable, init).
; DespawnRemotePlayer cleans up the proxy actor on disconnect or entity destroy.
; ==============================================================================
; ==============================================================================
; PROXY ANIMATION DISPATCH
; Called from C++ via CallFunctionNoWait(questRef, "SendProxyAnimEvent", [formID, event])
;
; Routes a behavior graph animation event to a proxy actor WITHOUT requiring
; CoSyncPlayer.psc to be attached to the NPC form in the ESP.
; Game.GetForm() retrieves the dynamic (0xFF...) actor from its runtime formID.
; proxy.PlaySubGraphAnimation() is the FO4 native for sending behavior graph events.
; ==============================================================================
Function SendProxyAnimEvent(int aiActorFormID, string asEventName)
Actor proxy = Game.GetForm(aiActorFormID) as Actor
If proxy == None
Return
EndIf
proxy.PlaySubGraphAnimation(asEventName)
Debug.Trace("[CoSyncQuest] SendProxyAnimEvent: formID=0x" + aiActorFormID + " event='" + asEventName + "'")
EndFunction
Function SpawnRemotePlayer(int entityID)
ObjectReference ref = CoSync.GetActorByEntityID(entityID)
If ref == None
Debug.Trace("[CoSyncQuest] SpawnRemotePlayer: no actor for entityID=" + entityID)
Return
EndIf
Actor remoteActor = ref as Actor
If remoteActor == None
Debug.Trace("[CoSyncQuest] SpawnRemotePlayer: ref is not an Actor for entityID=" + entityID)
Return
EndIf
; C++ placed the actor in the world — enable it here so it becomes visible.
; Passing false skips the fade-in (instant appear, matches network timing).
remoteActor.Enable(false)
Debug.Trace("[CoSyncQuest] SpawnRemotePlayer: enabled actor for entityID=" + entityID)
EndFunction
Function DespawnRemotePlayer(int entityID)
ObjectReference ref = CoSync.GetActorByEntityID(entityID)
If ref == None
Debug.Trace("[CoSyncQuest] DespawnRemotePlayer: no actor for entityID=" + entityID)
Return
EndIf
Actor remoteActor = ref as Actor
If remoteActor == None
Debug.Trace("[CoSyncQuest] DespawnRemotePlayer: ref not an Actor for entityID=" + entityID)
Return
EndIf
; Disable first (removes from render) then Delete (frees the ref).
; C++ removes from CoSyncEntityRegistry immediately after this call returns.
remoteActor.Disable(false)
remoteActor.Delete()
Debug.Trace("[CoSyncQuest] DespawnRemotePlayer: cleaned up actor for entityID=" + entityID)
EndFunction
; ==============================================================================
; DOOR CELL SCAN
; Iterates all refs in the player's current cell, registers OnActivate on
; every DOOR ref. Safe to call repeatedly — re-registration is idempotent.
; GetRefsInCell() is a CoSync native that wraps cell->objectList.
; GetType() returns the base form's formType integer (32 = kFormType_DOOR).
; ==============================================================================
Function ScanCellDoors()
Cell currentCell = Game.GetPlayer().GetParentCell()
If currentCell == None
Return
EndIf
ObjectReference[] refs = CoSync.GetRefsInCell(currentCell)
If refs == None
Return
EndIf
Int doorCount = 0
Int i = 0
While i < refs.Length
ObjectReference ref = refs[i]
If ref != None && CoSync.IsDoor(ref)
RegisterForRemoteEvent(ref, "OnActivate")
doorCount += 1
EndIf
i += 1
EndWhile
Debug.Trace("[CoSyncQuest] Door scan: " + doorCount + " door(s) registered in cell")
EndFunction
; ==============================================================================
; MAP MARKER DISCOVERY SYNC
; Fires whenever the player (or any tracked actor) moves to a new location.
; We check the new location's map marker — if it's visible and not yet seen,
; we broadcast it to all clients via CoSync.NotifyMapMarkerDiscovered.
; ==============================================================================
Event Actor.OnLocationChange(Actor akActor, Location akOldLoc, Location akNewLoc)
If !CoSync.IsHost()
Return
EndIf
If akNewLoc == None
Return
EndIf
; TODO: map marker discovery broadcast requires GetMapMarker() which is not
; available in vanilla Papyrus. The C++ receive path (CoSyncMapSync) is
; in place; a future update can hook this via a custom F4SE event or polling.
Debug.Trace("[CoSyncQuest] OnLocationChange (map sync stub)")
EndEvent
; ==============================================================================
; DOOR ACTIVATION SYNC
; Fires when any ref we registered via RegisterForRemoteEvent("OnActivate")
; is activated. akSender = the DOOR ref, akActionRef = who activated it.
; Only broadcasts when the local player is the activator — other activators
; (NPCs, scripts) are silently ignored to avoid spurious syncs.
; The C++ suppression guard (s_suppressed in CoSyncActivateSync.cpp) prevents
; this from echoing back when the activation came FROM the network.
; ==============================================================================
Event ObjectReference.OnActivate(ObjectReference akSender, ObjectReference akActionRef)
If akActionRef != Game.GetPlayer()
Return
EndIf
Int openState = akSender.GetOpenState()
CoSync.NotifyActivate(akSender, openState)
Debug.Trace("[CoSyncQuest] Door activated: ref=0x" + akSender.GetFormID() + " state=" + openState)
EndEvent
; ==============================================================================
; POWER ARMOR ENTER SYNC — via Actor.OnSit
; PA chassis is furniture; OnSit fires when the player sits in it.
; IsInPowerArmor() at the moment of OnSit == True confirms this is a PA enter.
; akFurniture is the chassis ObjectReference — same data C++ needs.
; ==============================================================================
Event Actor.OnSit(Actor akTarget, ObjectReference akFurniture)
If akTarget != playerRef
Return
EndIf
If !akTarget.IsInPowerArmor()
Return ; Regular furniture sit — not a PA enter, ignore
EndIf
If akFurniture == None
Debug.Trace("[CoSyncQuest] OnSit(PA): chassis ref is None")
Return
EndIf
bIsInPowerArmor = True
CoSync.NotifyPowerArmor(akTarget, akFurniture, True)
Debug.Trace("[CoSyncQuest] OnSit(PA enter) -> NotifyPowerArmor ENTER chassis=0x" + akFurniture.GetFormID())
EndEvent
; ==============================================================================
; POWER ARMOR EXIT SYNC — via Actor.OnGetUp
; OnGetUp fires when the player exits any furniture. bIsInPowerArmor guards
; against triggering on regular chairs — only fires after a confirmed PA enter.
; ==============================================================================
Event Actor.OnGetUp(Actor akTarget, ObjectReference akFurniture)
If akTarget != playerRef
Return
EndIf
If !bIsInPowerArmor
Return ; Was not in PA — regular furniture exit, ignore
EndIf
bIsInPowerArmor = False
If akFurniture == None
Debug.Trace("[CoSyncQuest] OnGetUp(PA exit): chassis ref is None")
Return
EndIf
CoSync.NotifyPowerArmor(akTarget, akFurniture, False)
Debug.Trace("[CoSyncQuest] OnGetUp(PA exit) -> NotifyPowerArmor EXIT chassis=0x" + akFurniture.GetFormID())
EndEvent
; ==============================================================================
; COMPANION POLL
; Diffs Game.GetCurrentFollowers() (F4SE) against the last known follower list.
; New entries → NotifyCompanionRecruited (C++ broadcasts a COMP_REQ)
; Lost entries → NotifyCompanionDismissed (C++ broadcasts COMP_DISMISS)
;
; NOTE: Game.GetCurrentFollowers() is provided by F4SE. If it returns None
; on a given tick (e.g., before the player fully loads), we skip that cycle
; and retry next tick to avoid false dismissals.
; ==============================================================================
Function PollCompanionChanges()
Actor[] currentFollowers = Game.GetCurrentFollowers()
; Skip the cycle if the follower list is unavailable — avoids spurious dismissals
; during cell transitions or save loads before the player is fully initialised.
If currentFollowers == None
Return
EndIf
; ---- Detect newly recruited companions ----------------------------------
; Any actor present in currentFollowers but absent from trackedFollowers
; has just been recruited and needs a network broadcast.
Int i = 0
While i < currentFollowers.Length
Actor akComp = currentFollowers[i]
If akComp != None
If !CompanionArrayContains(trackedFollowers, akComp)
CoSync.NotifyCompanionRecruited(akComp)
Debug.Trace("[CoSyncQuest] Companion recruited -> NotifyCompanionRecruited " + akComp.GetBaseObject().GetFormID())
EndIf
EndIf
i += 1
EndWhile
; ---- Detect dismissed companions ----------------------------------------
; Any actor present in trackedFollowers but absent from currentFollowers
; has just been dismissed.
If trackedFollowers != None
i = 0
While i < trackedFollowers.Length
Actor akComp = trackedFollowers[i]
If akComp != None
If !CompanionArrayContains(currentFollowers, akComp)
CoSync.NotifyCompanionDismissed(akComp)
Debug.Trace("[CoSyncQuest] Companion dismissed -> NotifyCompanionDismissed " + akComp.GetBaseObject().GetFormID())
EndIf
EndIf
i += 1
EndWhile
EndIf
; Update snapshot for next cycle
trackedFollowers = currentFollowers
EndFunction
; Linear search helper — returns True if akTarget appears in akArr.
; Used by PollCompanionChanges to diff follower snapshots.
Bool Function CompanionArrayContains(Actor[] akArr, Actor akTarget)
If akArr == None || akTarget == None
Return False
EndIf
Int i = 0
While i < akArr.Length
If akArr[i] == akTarget
Return True
EndIf
i += 1
EndWhile
Return False
EndFunction
+11 -6
View File
@@ -111,13 +111,18 @@ both send and receive paths:
candidate isolation and settle, then promotes the settled actor into the candidate isolation and settle, then promotes the settled actor into the
existing single-proxy movement pipeline. existing single-proxy movement pipeline.
The Fallout 4 plugin now dynamically spawns one runtime proxy actor per The Fallout 4 plugin dynamically spawns one runtime proxy actor per represented
represented remote `playerId` in the test cell, up to a small Stage 4 test cap. remote `playerId` in the local player's current loaded cell, up to a small cap
(`kMaxRuntimeProxyActors = 4`). The proxy controller runs in any cell when global
mode is enabled; dynamic spawn is the default path. Pre-placed Creation Kit
proxy refs and the single placed fallback remain available behind compile-time
flags for regression testing in `F4TTestCell01`.
When the local player changes cells, stale proxy actors in the prior cell are
released and fresh proxies are spawned for co-located remotes in the new cell.
Disconnected held slots can be reassigned to later server `playerId` values after Disconnected held slots can be reassigned to later server `playerId` values after
the grace period, but slots held for connected players outside the cell are not the grace period, but slots held for connected players outside the cell are not
reused. reused.
The placed proxy reference remains as a single fallback path if runtime spawning
fails for one selected remote player.
Proxy animation sync applies curated graph variables from remote movement state: Proxy animation sync applies curated graph variables from remote movement state:
@@ -143,8 +148,8 @@ The native plugin is responsible for:
- Receiving server packets on a background thread - Receiving server packets on a background thread
- Storing assigned and remote `playerId` state internally - Storing assigned and remote `playerId` state internally
- Moving runtime proxy actors from per-`playerId` remote-player state on the - Moving runtime proxy actors from per-`playerId` remote-player state on the
game thread, preferring runtime slots and falling back to the placed reference game thread via dynamic spawn in the local player's current cell (pre-placed
for one selected player only if runtime spawning fails CK pool and placed fallback available behind compile-time regression flags)
- Holding only the affected proxy actor at a hidden in-cell position when its - Holding only the affected proxy actor at a hidden in-cell position when its
remote player disconnects or leaves the same cell remote player disconnects or leaves the same cell
- Assigning each runtime proxy slot a unique hidden holding position and - Assigning each runtime proxy slot a unique hidden holding position and
+4 -1
View File
@@ -6,7 +6,8 @@ Fallout 4 was designed as a single-player game. Commonwealth Online is therefore
The first prototype will likely require: The first prototype will likely require:
- All players to remain in the same test cell - Remote players to be in the same cell/worldspace as the local player to be visible (proxies are not shown across unloaded cells)
- `Fallout4Together_Test.esp` loaded for the `F4T_RemotePlayerProxy` actor base
- A clean Fallout 4 test profile - A clean Fallout 4 test profile
- A dedicated test save - A dedicated test save
- No large gameplay mods - No large gameplay mods
@@ -15,6 +16,8 @@ The first prototype will likely require:
- No VATS synchronization - No VATS synchronization
- No dialogue synchronization - No dialogue synchronization
Up to four concurrent remote proxies are supported per client (`kMaxRuntimeProxyActors`).
## Systems Not Supported Early ## Systems Not Supported Early
### Quests ### Quests
+6
View File
@@ -54,6 +54,12 @@ if not "%LAUNCH_ERROR%"=="0" (
) )
echo Done. echo Done.
echo.
echo Instance 1 log:
echo "%USERPROFILE%\Documents\My Games\Fallout4\F4SE\CommonwealthOnline.log"
echo Instance 2 log ^(Sandboxie^):
echo "C:\Sandbox\%USERNAME%\%SANDBOX_NAME%\user\current\Documents\My Games\Fallout4\F4SE\CommonwealthOnline.log"
echo Look for "Assigned server playerId: 2" in the instance 2 log to confirm that client loaded the plugin.
endlocal endlocal
exit /b 0 exit /b 0
+16 -8
View File
@@ -115,14 +115,22 @@ Use this checklist for the first plugin test:
* [ ] Plugin log shows `[LocalPlayerId=N] Assigned server playerId: N` * [ ] Plugin log shows `[LocalPlayerId=N] Assigned server playerId: N`
* [ ] Plugin logs prefixed remote player updates from another connected client * [ ] Plugin logs prefixed remote player updates from another connected client
* [ ] Plugin logs prefixed remote player removal after a disconnect * [ ] Plugin logs prefixed remote player removal after a disconnect
* [ ] `Fallout4Together_Test.esp` is enabled * [ ] `CommonwealthOnline.esp` is enabled (provides `COPlayerProxy` actor base, local FormID `001733`)
* [ ] `coc F4TTestCell01` places the local player in the test cell * [ ] With two Fallout 4 instances in the same cell, each instance spawns a dynamic
* [ ] `F4TProxyRemotePlayer01REF` resolves in the test cell proxy at the other player's networked position
* [ ] With two Fallout 4 instances in `F4TTestCell01`, each instance moves its * [ ] Optional regression: `coc F4TTestCell01` with `kEnablePrePlacedProxyPool` enabled
local proxy to the other player's networked position * [ ] Optional regression: `F4TProxyRemotePlayer01REF` resolves when placed fallback flag is on
### Global dynamic spawn validation
* [ ] Two clients in the same vanilla interior (e.g. Sanctuary house) see each other's proxies
* [ ] Two clients in the Commonwealth exterior in the same loaded area see proxies
* [ ] Remote player in a different cell: proxy held hidden, not visible
* [ ] Local player walks into remote player's cell: proxy spawns at remote position
* [ ] Local player leaves a cell with active proxies: no orphaned actors remain in prior cell
## Notes ## Notes
Remote actor spawning is intentionally not part of the current prototype. The Remote actors are dynamically spawned at runtime in the local player's current
receive loop stores plain remote-player state, and the game-thread proxy actor cell. The game-thread proxy actor controller consumes a copied remote-player
controller consumes a copied snapshot to move the one placed test proxy actor. snapshot to spawn, move, and hold proxy actors per remote `playerId`.
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -53,6 +53,7 @@ namespace
}; };
constexpr auto kTransformSendInterval = 100ms; constexpr auto kTransformSendInterval = 100ms;
constexpr auto kIdleTransformHeartbeatInterval = 2s;
constexpr auto kTransformLogInterval = 1s; constexpr auto kTransformLogInterval = 1s;
constexpr auto kTransformSendPositionThreshold = 3.0F; constexpr auto kTransformSendPositionThreshold = 3.0F;
constexpr auto kTransformSendRotationThreshold = 0.02F; constexpr auto kTransformSendRotationThreshold = 0.02F;
@@ -524,13 +525,16 @@ namespace
const auto isSpecialMovement = movementType != std::string_view{ "normal" }; const auto isSpecialMovement = movementType != std::string_view{ "normal" };
const auto hasMeaningfulTransformDelta = HasMeaningfulTransformDelta(lastSentTransform, currentTransform); const auto hasMeaningfulTransformDelta = HasMeaningfulTransformDelta(lastSentTransform, currentTransform);
const auto hasMovementStateChanged = HasMovementStateChanged(lastSentMovementState, networkMovementState); const auto hasMovementStateChanged = HasMovementStateChanged(lastSentMovementState, networkMovementState);
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged) { const auto idleHeartbeatDue = (currentTime - lastSendTime) >= kIdleTransformHeartbeatInterval;
if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged && !idleHeartbeatDue) {
lastDebugSampleTransform = currentTransform; lastDebugSampleTransform = currentTransform;
lastDebugSampleTime = currentTime; lastDebugSampleTime = currentTime;
return; return;
} }
if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) { if (!isSpecialMovement && !hasMeaningfulTransformDelta && !hasMovementStateChanged) {
// Idle heartbeat: keep remote clients updated even when both players are standing still.
} else if (!isSpecialMovement && currentTime - lastSendTime < kTransformSendInterval) {
return; return;
} }
+13 -12
View File
@@ -24,13 +24,12 @@ remote `playerId`, and removes remote state when disconnect packets arrive.
`server/fake_client.py` remains a lightweight test receiver for the same packet `server/fake_client.py` remains a lightweight test receiver for the same packet
lifecycle. lifecycle.
The plugin's game-thread proxy actor controller can now read a copied snapshot The plugin's game-thread proxy actor controller reads a copied snapshot of this
of this remote-player state and move the single placed test proxy actor remote-player state and dynamically spawns/moves runtime proxy actors in the
`F4TProxyRemotePlayer01REF` in `F4TTestCell01`. The networking thread still does local player's current loaded cell. The networking thread still does not touch
not touch Fallout 4 actors or references. If no valid same-cell remote player is Fallout 4 actors or references. If a remote player is not in the same
available, the controller moves the placed proxy to a hidden holding position cell/worldspace, the controller holds that player's proxy at a hidden in-cell
inside the same test cell instead of leaving it frozen at the last represented position instead of leaving it at the last visible location.
position.
## Current Remote Player State Model ## Current Remote Player State Model
@@ -117,14 +116,16 @@ normal send interval and are sent immediately. The receiving plugin still stores
remote transform state on the networking thread and moves actors only from the remote transform state on the networking thread and moves actors only from the
game-thread proxy controller. game-thread proxy controller.
Runtime proxy actors are spawned per remote `playerId` in the test cell (see Runtime proxy actors are dynamically spawned per remote `playerId` in the local
architecture). Full animation graph replication is not implemented; only curated player's current loaded cell (see architecture). Remote players in a different
graph variable writes drive proxy locomotion visuals. cell are held at a hidden in-cell position until the local player enters their
cell. Full animation graph replication is not implemented; only curated graph
variable writes drive proxy locomotion visuals.
## Planned Later ## Planned Later
- Add one proxy actor per remote player after the single-proxy test is stable. - Increase concurrent proxy cap beyond four players per client
- Dynamically spawn remote actors only after placed-proxy movement is stable. - Cross-cell actor transfer without respawn (if respawn proves insufficient)
## Not Required Yet ## Not Required Yet