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