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:
@@ -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
|
||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user