Add Papyrus sync scripts and proxy spawn refactor

Add new Papyrus scripts (CoSync.psc, CoSyncPlayer.psc, CoSyncQuest.psc) providing native bindings and quest/player proxy logic for networking: connection/session APIs, entity management, world/weather/workshop/companion sync, consumable/pipboy/door/power-armor events, and proxy animation/initialization handlers. Refactor the proxy actor controller (F4TProxyActorController.cpp) to support dynamic/runtime proxy spawning and improved lifecycle/visibility handling: change proxy base/form IDs to COPlayerProxy/CommonwealthOnline, add process-list actor lookup, spawn helpers, play-space/cell checks, 3D visibility refresh, ActorHandle usage, and spawn mutexes and globals for safer spawning. Update docs and setup (architecture.md, limitations.md, plugin/setup.md) to describe global dynamic spawn behavior, max runtime proxies (kMaxRuntimeProxyActors = 4), and validation checklist; update launch-two-fallout4.bat to show instance log locations. Misc: small tweaks to logging and debug flags to gate diagnostics.
This commit is contained in:
2026-06-07 20:31:51 +12:00
parent 21c8bf9593
commit cc4d8aac5a
10 changed files with 1645 additions and 190 deletions
+187
View File
@@ -0,0 +1,187 @@
Scriptname CoSync Native Hidden
; ==============================================================================
; This script must EXACTLY match the native functions in Papyrus_CoSync.cpp
; Signatures must match or functions won't bind!
; ==============================================================================
; ==============================================================================
; CONNECTION & SESSION MANAGEMENT
; ==============================================================================
int Function GetClientInstanceID() global native
Function SetClient(int instanceID) global native
bool Function IsConnected() global native
bool Function Connect(Actor player, ActorBase playerActorBase, string address, int port) global native
bool Function Disconnect() global native
Function Tick() global native
Function SyncWorld() global native
; ==============================================================================
; ENTITY MANAGEMENT
; ==============================================================================
int Function GetPlayerEntityID() global native
; C++ signature: NativeFunction1<StaticFunctionTag, SInt32, TESObjectREFR*>
int Function GetEntityID(ObjectReference ref) global native
; C++ signature: NativeFunction2<StaticFunctionTag, void, SInt32, TESObjectREFR*>
Function SetEntityRef(int entityID, ObjectReference ref) global native
bool Function IsEntityValid(int entityID) global native
; C++ signature: NativeFunction0<StaticFunctionTag, VMArray<SInt32>>
int[] Function GetAllEntityIDs() global native
; C++ signature: NativeFunction1<StaticFunctionTag, TESObjectREFR*, SInt32>
; IMPORTANT: Returns ObjectReference, NOT Actor!
ObjectReference Function GetActorByEntityID(int entityID) global native
; C++ signature: NativeFunction1<StaticFunctionTag, SInt32, TESObjectREFR*>
; IMPORTANT: Takes ObjectReference, NOT Actor!
int Function GetEntityIDForActor(ObjectReference akRef) global native
; ==============================================================================
; ENTITY POSITION & STATE
; ==============================================================================
float[] Function GetEntityPosition(int entityID) global native
Function SetEntityPosition(int entityID, float x, float y, float z) global native
Function SetEntVarNum(int entityID, string name, float value) global native
Function SetEntVarAnim(int entityID, string animState) global native
float Function GetEntVarNum(int entityID, string name) global native
string Function GetEntVarAnim(int entityID) global native
; ==============================================================================
; UTILITY FUNCTIONS
; ==============================================================================
ObjectReference[] Function GetRefsInCell(Cell cell) global native
float Function Atan2(float y, float x) global native
string Function GetWalkDir(float dX, float dY, float angleZ) global native
; C++ signature: NativeFunction1<StaticFunctionTag, PapyrusActionType, BSFixedString>
; PapyrusActionType is TESForm* in C++, so use Form in Papyrus
Form Function GetAction(string name) global native
Function Inspect(Form[] forms) global native
bool Function AnimLoops(string animState) global native
; ==============================================================================
; ACTOR CUSTOMIZATION
; ==============================================================================
Function CopyAppearance(ActorBase src, ActorBase dest) global native
Function CopyWornItems(Actor src, Actor dest) global native
; ==============================================================================
; COMBAT & ACTIONS
; ==============================================================================
Function PlayerHit(int hitter, int hittee, float damage) global native
Function PlayerFireWeapon() global native
Function TopicInfoBegin(Form info, ObjectReference speaker) global native
; ==============================================================================
; SYNCHRONIZATION
; ==============================================================================
ObjectReference[] Function GetEntitySyncRefs(bool clear) global native
float[] Function GetEntitySyncTransforms(bool clear) global native
; ==============================================================================
; NPC SPAWNING
; ==============================================================================
bool Function SpawnNpc(ObjectReference ref) global native
; ==============================================================================
; WORLD / WEATHER / WORKSHOP SYNC
; Called from CoSyncQuest.psc when game events fire.
; ==============================================================================
bool Function IsHost() global native
; Returns the weather formID locked from the host (0 = none / not connected).
; Called from the CoSyncQuest weather timer on the CLIENT to apply host weather
; via w.ForceActive(True) — avoids unreliable C++→Papyrus CallFunctionNoWait.
int Function GetPendingWeatherFormID() global native
; Pass Weather.GetCurrentWeather() cast as Form
Function NotifyWeatherChanged(Form akWeather) global native
; Workshop build: pass the workshop marker ref and the newly placed ref
Function NotifyWorkshopBuild(ObjectReference akWorkshop, ObjectReference akPlaced) global native
; Workshop destroy: pass the workshop marker ref and the scrapped ref
Function NotifyWorkshopDestroy(ObjectReference akWorkshop, ObjectReference akScrapped) global native
; Workshop power toggle: pass the power-switch ref and new powered state
Function NotifyWorkshopPower(ObjectReference akRef, bool isPowered) global native
; Workshop mode enter/exit — call with the actual workshop ref (fixes formID=0 from C++ side)
Function NotifyWorkshopMode(ObjectReference akWorkshop, bool isEntering) global native
; ==============================================================================
; QUEST / MAP MARKER SYNC
; ==============================================================================
Function NotifyQuestStage(Quest akQuest, int aiStage) global native
Function NotifyMapMarkerDiscovered(ObjectReference akMarker) global native
; ==============================================================================
; POWER ARMOR SYNC
; ==============================================================================
Function NotifyPowerArmor(Actor akActor, ObjectReference akPowerArmor, bool abEntering) global native
; ==============================================================================
; CONSUMABLE / MAGIC SYNC
; ==============================================================================
; Call when the local player uses an AlchemyItem (stimpaks, Rad-Away, etc.)
Function NotifyConsumableUsed(Form akItem) global native
; Call when a spell or persistent magic effect is applied to the local player
Function NotifySpellApplied(Form akSpell) global native
; ==============================================================================
; PIP-BOY SYNC
; ==============================================================================
; Call when the Pip-boy menu opens (abOpening=true) or closes (abOpening=false)
Function NotifyPipboyOpen(bool abOpening) global native
; ==============================================================================
; DOOR / ACTIVATABLE STATE SYNC
; ==============================================================================
; Returns true if akRef's base form is a DOOR (formType 32).
; FO4 Papyrus has no ObjectReference.GetFormType() — this native fills the gap.
bool Function IsDoor(ObjectReference akRef) global native
; Call when the local player activates a DOOR ref.
; aiOpenState = akDoorRef.GetOpenState() — 0=None, 1=Open, 2=Opening, 3=Closed, 4=Closing
; C++ applies a suppression guard so network-applied activations do not echo back.
Function NotifyActivate(ObjectReference akRef, int aiOpenState) global native
; ==============================================================================
; COMPANION SYNC
; ==============================================================================
; Call when the local player recruits a vanilla companion (detected via follower poll).
; C++ reads akCompanion's base form ID, checks for duplicate entity registration,
; then calls CoSyncCompanionSync::RequestCompanion so all peers spawn a proxy.
; NoWait — safe to call from timer without waiting for return.
Function NotifyCompanionRecruited(Actor akCompanion) global native
; Call when the local player dismisses a companion (follower poll sees it vanish).
; C++ looks up the registered entity ID for akCompanion and broadcasts COMP_DISMISS
; so all peers despawn the proxy.
; NoWait — safe to call from timer without waiting for return.
Function NotifyCompanionDismissed(Actor akCompanion) global native
@@ -0,0 +1,149 @@
; ==============================================================================
; CoSyncPlayer.psc
;
; Script attached to the CoSync remote-proxy Actor form in CreationKit.
;
; This script runs on EVERY spawned proxy actor (one per remote player).
; C++ (CoSyncPlayerManager) spawns a copy of the custom proxy NPC form via
; PlaceAtMe, then this OnInit fires and registers the actor with the C++ layer.
;
; Responsibilities:
; - Map this actor back to its network entity ID (reverse lookup via C++)
; - Provide helper functions for C++ to call via CallFunctionNoWait:
; InitProxy(entityID) — called by C++ after spawn, sets entityID
; SetProxyName(name) — sets a display string for debugging
; OnProxyDied() — called when entity death packet arrives
; - Handle appearance sync on first spawn (CopyAppearance from player snapshot)
;
; CreationKit setup:
; 1. Create a new NPC form (e.g. "CoSyncProxyNPC").
; Race: HumanRace (or the same race as the host player).
; Skeleton: DefaultMale.nif (gives standard weapon attach nodes).
; Behavior Graph: leave default — behavior graph injection happens in C++.
; 2. Attach this script to the CoSyncProxyNPC form via the Script panel.
; 3. The script has no CK-side Properties — all data arrives via C++ calls.
;
; NOTE: Weapon and armor sync are handled entirely in C++ (CoSyncWeaponSync /
; CoSyncOutfitSync) via BGSInventoryList polling. Papyrus here only manages
; initialization and death/cleanup.
; ==============================================================================
Scriptname CoSyncPlayer extends Actor
; ============================================================================
; State
; ============================================================================
int myEntityID = -1 ; Network entity ID (set by C++ via InitProxy)
string myUsername = "" ; Display name (set by C++ via SetProxyName)
bool isInitialized = false ; True once InitProxy has been called
; ============================================================================
; INITIALIZATION — called once by Papyrus VM when actor is created
; ============================================================================
Event OnInit()
; C++ calls InitProxy(entityID) via CallFunctionNoWait immediately after
; spawning. Papyrus event delivery is asynchronous, so InitProxy may arrive
; before or after OnInit. The isInitialized guard handles both orderings.
Debug.Trace("[CoSyncPlayer] OnInit — waiting for C++ InitProxy call")
EndEvent
; ============================================================================
; InitProxy — called by C++ (CallFunctionNoWait) right after PlaceAtMe
; ============================================================================
Function InitProxy(int entityID)
If isInitialized
; Duplicate call from a reconnect / re-spawn — update entityID only
myEntityID = entityID
Debug.Trace("[CoSyncPlayer] InitProxy re-init: entityID=" + entityID)
Return
EndIf
myEntityID = entityID
isInitialized = true
; ---- Disable vanilla AI so C++ can drive movement via anim vars ----
; EnableAI(false) is the FO4 Papyrus equivalent of "disable AI" on an actor.
; C++ also calls this after spawn, but doing it here provides a fallback
; in case the C++ call arrives out-of-order relative to OnInit.
self.EnableAI(False)
self.StopCombat()
; Disable VATS targeting — proxy should not be attackable via VATS
; (no SetValue available pre-NG; set via SetActorValue if needed)
Debug.Trace("[CoSyncPlayer] InitProxy complete: entityID=" + entityID)
EndFunction
; ============================================================================
; SetProxyName — called by C++ to label the proxy with the remote player's name
; ============================================================================
Function SetProxyName(string username)
myUsername = username
Debug.Trace("[CoSyncPlayer] SetProxyName: entityID=" + myEntityID + " name='" + username + "'")
EndFunction
; ============================================================================
; OnProxyDied — called by C++ when the remote entity's death packet arrives
; ============================================================================
Function OnProxyDied()
Debug.Trace("[CoSyncPlayer] OnProxyDied: entityID=" + myEntityID + " name='" + myUsername + "'")
; Trigger the actor's normal death so ragdoll / death animation plays
; KillSilent leaves no corpse marker visible to the local player
self.Kill(None)
EndFunction
; ============================================================================
; ANIMATION DISPATCH — called by C++ via CallFunctionNoWait
;
; All behavior graph events for this proxy go through here.
; Debug.SendAnimationEvent(self, name) is the reliable Papyrus path:
; - Validates actor state before dispatch (no crash on half-loaded actors)
; - Goes through full BSAnimationGraphManager dispatch (all graphs, correct one)
; - Appears in Papyrus trace logs for debugging
;
; C++ CallFunctionNoWait(actorRef, "SendAnimEvent", [eventName]) routes here
; because CoSyncPlayer.psc is attached to every proxy actor form.
; ============================================================================
; Dispatch a behavior graph event on this proxy actor.
; PlaySubGraphAnimation is the FO4 Papyrus equivalent of Skyrim's
; Debug.SendAnimationEvent — it dispatches a named behavior graph event
; on the calling actor's animation graph manager, validated by the engine.
Function SendAnimEvent(string asEventName)
self.PlaySubGraphAnimation(asEventName)
Debug.Trace("[CoSyncPlayer] SendAnimEvent: eid=" + myEntityID + " '" + asEventName + "'")
EndFunction
; Set behavior graph variable by name — used for one-time identity setup.
; Per-frame variables are still written by C++ LoadAnimationVariables (60fps).
Function SetAnimBool(string asVarName, bool abValue)
self.SetAnimationVariableBool(asVarName, abValue)
EndFunction
Function SetAnimFloat(string asVarName, float afValue)
self.SetAnimationVariableFloat(asVarName, afValue)
EndFunction
Function SetAnimInt(string asVarName, int aiValue)
self.SetAnimationVariableInt(asVarName, aiValue)
EndFunction
; ============================================================================
; Accessors — read-only state for external callers
; ============================================================================
int Function GetEntityID()
Return myEntityID
EndFunction
string Function GetProxyName()
Return myUsername
EndFunction
bool Function IsInitialized()
Return isInitialized
EndFunction
+559
View File
@@ -0,0 +1,559 @@
Scriptname CoSyncQuest extends Quest
; ==============================================================================
; CoSyncQuest — wires Papyrus game events to CoSync native sync functions.
; Animation control is handled entirely in C++ via behavior graph injection.
; ==============================================================================
; Timer IDs
int tickTimerID = 10
int updateTimerID = 20
int weatherTimerID = 30
int doorScanTimerID = 40
int companionTimerID = 50
; Properties
Actor Property playerRef Auto
ActorValue Property healthAV Auto
; Weather state (host-side change detection)
Weather lastWeather = None
; Power armor state — tracks whether the player is currently in PA so that
; Actor.OnGetUp can distinguish a PA exit from sitting in regular furniture.
bool bIsInPowerArmor = False
; Companion poll state — snapshot of followers from last poll cycle.
; Compared against Game.GetCurrentFollowers() each tick to detect
; recruit and dismiss events without requiring a dedicated event sink.
Actor[] trackedFollowers
; ==============================================================================
; INITIALIZATION
; ==============================================================================
; Shared setup — called from both OnInit (new game) and OnPlayerLoadGame (save load).
Function SetupEventHooks()
; ---- Workshop events -------------------------------------------------------
WorkshopParentScript wsParent = Game.GetFormFromFile(0xD62E, "Fallout4.esm") as WorkshopParentScript
If wsParent != None
RegisterForCustomEvent(wsParent, "WorkshopObjectBuilt")
RegisterForCustomEvent(wsParent, "WorkshopObjectDestroyed")
RegisterForCustomEvent(wsParent, "WorkshopEnterMenu")
Debug.Trace("[CoSyncQuest] Registered for WorkshopParent build/destroy/enter events")
Else
Debug.Trace("[CoSyncQuest] WARNING: WorkshopParent (0xD62E) not found")
EndIf
; Workshop exit: detect when WorkshopMenu HUD closes
RegisterForMenuOpenCloseEvent("WorkshopMenu")
; ---- Weather polling timer (cancel first to avoid duplicates) --------------
CancelTimer(weatherTimerID)
StartTimer(5.0, weatherTimerID)
Debug.Trace("[CoSyncQuest] Weather timer started")
; ---- Map marker discovery — register for player location changes ----------
; Only the host broadcasts discoveries; clients receive via MM| packet.
If CoSync.IsHost()
RegisterForRemoteEvent(playerRef, "OnLocationChange")
Debug.Trace("[CoSyncQuest] Registered for OnLocationChange (map sync)")
EndIf
; ---- Consumable use — fires when any item is equipped by the player -------
; Both host and client need this to broadcast their own consumable use.
RegisterForRemoteEvent(playerRef, "OnItemEquipped")
Debug.Trace("[CoSyncQuest] Registered for Actor.OnItemEquipped (consumable sync)")
; ---- Power armor enter / exit -------------------------------------------
; OnEnterPowerArmor / OnExitPowerArmor are F4SE extension events and cannot
; be used in vanilla Papyrus compilation. Instead we use Actor.OnSit /
; Actor.OnGetUp — power armor chassis is furniture the player sits in.
; IsInPowerArmor() on OnSit confirms it is a PA enter; a bool flag tracks
; state so OnGetUp can distinguish PA exit from regular furniture.
RegisterForRemoteEvent(playerRef, "OnSit")
RegisterForRemoteEvent(playerRef, "OnGetUp")
Debug.Trace("[CoSyncQuest] Registered for OnSit / OnGetUp (PA enter/exit detection)")
; ---- Pip-boy menu open/close ---------------------------------------------
; Already registered for WorkshopMenu above; extend to PipboyMenu.
RegisterForMenuOpenCloseEvent("PipboyMenu")
Debug.Trace("[CoSyncQuest] Registered for PipboyMenu open/close")
; ---- Door activation sync ------------------------------------------------
; Scan the player's current cell for DOOR refs and register OnActivate on
; each. The timer re-scans on cell transitions (every 10 s) to catch new
; doors after fast-travel or cell loads.
ScanCellDoors()
CancelTimer(doorScanTimerID)
StartTimer(10.0, doorScanTimerID)
Debug.Trace("[CoSyncQuest] Door scan started")
; ---- Companion recruit / dismiss poll ------------------------------------
; Game.GetCurrentFollowers() (F4SE) returns a live snapshot of all current
; followers. We diff against trackedFollowers each cycle to detect changes.
; 3-second interval balances responsiveness against Papyrus VM overhead.
CancelTimer(companionTimerID)
StartTimer(3.0, companionTimerID)
Debug.Trace("[CoSyncQuest] Companion poll started")
EndFunction
Event OnInit()
; Fires only once when the quest is first created (new game).
; For save loads, SetupEventHooks() is called via C++ NotifyGameLoaded().
Debug.Trace("[CoSyncQuest] OnInit")
SetupEventHooks()
EndEvent
; ==============================================================================
; TIMERS
; ==============================================================================
Event OnTimer(int aiTimerID)
If aiTimerID == tickTimerID
CoSync.Tick()
StartTimer(0.016, tickTimerID) ; ~60 fps
ElseIf aiTimerID == updateTimerID
int playerEntityID = CoSync.GetPlayerEntityID()
If CoSync.IsEntityValid(playerEntityID)
CoSync.SetEntVarNum(playerEntityID, "health", playerRef.GetValuePercentage(healthAV))
EndIf
StartTimer(0.1, updateTimerID) ; 10 Hz
ElseIf aiTimerID == weatherTimerID
If CoSync.IsHost()
; HOST: detect weather changes and broadcast to clients
Weather currentWeather = Weather.GetCurrentWeather()
If currentWeather != lastWeather
lastWeather = currentWeather
If currentWeather != None
CoSync.NotifyWeatherChanged(currentWeather as Form)
Debug.Trace("[CoSyncQuest] Weather changed -> NotifyWeatherChanged")
EndIf
EndIf
Else
; CLIENT: apply weather locked from host via native poll
; CoSync.GetPendingWeatherFormID() reads s_lockedWeatherFormID from C++
; (set when WX| packet is received). This avoids CallFunctionNoWait entirely.
Int pendingFormID = CoSync.GetPendingWeatherFormID()
If pendingFormID != 0
Weather w = Game.GetForm(pendingFormID) as Weather
If w != None
w.ForceActive(True)
Debug.Trace("[CoSyncQuest] CLIENT Applied host weather 0x" + pendingFormID)
Else
Debug.Trace("[CoSyncQuest] CLIENT host weather form 0x" + pendingFormID + " not found")
EndIf
Else
Debug.Trace("[CoSyncQuest] CLIENT no pending weather (GetPendingWeatherFormID=0)")
EndIf
EndIf
StartTimer(5.0, weatherTimerID)
ElseIf aiTimerID == doorScanTimerID
; Re-scan current cell for doors after potential cell transition.
; Registration is idempotent — calling RegisterForRemoteEvent on an
; already-registered ref is a no-op, so no unregister step needed.
ScanCellDoors()
StartTimer(10.0, doorScanTimerID)
ElseIf aiTimerID == companionTimerID
; Poll for companion recruit / dismiss changes.
; Only the local player's own followers matter — each player broadcasts
; their own companion events to the network.
PollCompanionChanges()
StartTimer(3.0, companionTimerID)
EndIf
EndEvent
; ==============================================================================
; WORKSHOP BUILD — fires when the local player places an object
; Arg layout: akArgs[0] = placed ObjectReference, akArgs[1] = WorkshopScript ref
; ==============================================================================
Event WorkshopParentScript.WorkshopObjectBuilt(WorkshopParentScript akSender, Var[] akArgs)
If !CoSync.IsHost()
Return
EndIf
If akArgs.Length < 2
Return
EndIf
ObjectReference placedRef = akArgs[0] as ObjectReference
ObjectReference workshopRef = akArgs[1] as ObjectReference
If workshopRef != None && placedRef != None
CoSync.NotifyWorkshopBuild(workshopRef, placedRef)
Debug.Trace("[CoSyncQuest] WorkshopObjectBuilt -> NotifyWorkshopBuild")
EndIf
EndEvent
; ==============================================================================
; WORKSHOP DESTROY — fires when the local player scraps an object
; Arg layout: akArgs[0] = scrapped ObjectReference, akArgs[1] = WorkshopScript ref
; ==============================================================================
Event WorkshopParentScript.WorkshopObjectDestroyed(WorkshopParentScript akSender, Var[] akArgs)
If !CoSync.IsHost()
Return
EndIf
If akArgs.Length < 2
Return
EndIf
ObjectReference scrappedRef = akArgs[0] as ObjectReference
ObjectReference workshopRef = akArgs[1] as ObjectReference
If workshopRef != None && scrappedRef != None
CoSync.NotifyWorkshopDestroy(workshopRef, scrappedRef)
Debug.Trace("[CoSyncQuest] WorkshopObjectDestroyed -> NotifyWorkshopDestroy")
EndIf
EndEvent
; ==============================================================================
; WORKSHOP MODE — enter via WorkshopEnterMenu, exit via OnMenuOpenCloseEvent
; akArgs[0]=None, akArgs[1]=workshop ObjectReference (the workbench)
; Using Papyrus events bypasses the broken C++ LookupREFRByHandle RVA.
; ==============================================================================
Event WorkshopParentScript.WorkshopEnterMenu(WorkshopParentScript akSender, Var[] akArgs)
If !CoSync.IsHost()
Return
EndIf
If akArgs.Length < 2
Return
EndIf
ObjectReference workshopRef = akArgs[1] as ObjectReference
If workshopRef != None
CoSync.NotifyWorkshopMode(workshopRef, true)
Debug.Trace("[CoSyncQuest] WorkshopEnterMenu -> NotifyWorkshopMode ENTER")
EndIf
EndEvent
Event OnMenuOpenCloseEvent(string asMenuName, bool abOpening)
If asMenuName == "WorkshopMenu"
If abOpening
Return ; handled by WorkshopEnterMenu (has the ref)
EndIf
If CoSync.IsHost()
CoSync.NotifyWorkshopMode(None, false)
Debug.Trace("[CoSyncQuest] WorkshopMenu closed -> NotifyWorkshopMode EXIT")
EndIf
ElseIf asMenuName == "PipboyMenu"
; Pip-boy open/close — sync to all clients so remote players can mirror
; the arm-raise animation and HUD state.
CoSync.NotifyPipboyOpen(abOpening)
Debug.Trace("[CoSyncQuest] PipboyMenu " + (abOpening as string) + " -> NotifyPipboyOpen")
EndIf
EndEvent
; ==============================================================================
; CONSUMABLE USE SYNC
; Fires when the local player equips any item. If it is an AlchemyItem
; (stimpak, Rad-Away, chem, etc.) we broadcast the use so remote actors
; can mirror the effect via EquipItem in C++.
; ==============================================================================
Event Actor.OnItemEquipped(Actor akActor, Form akBaseObject, ObjectReference akReference)
; Filter: only AlchemyItem (formType 46 = kFormType_ALCH)
If akBaseObject == None
Return
EndIf
If !(akBaseObject is Potion)
Return
EndIf
CoSync.NotifyConsumableUsed(akBaseObject)
Debug.Trace("[CoSyncQuest] OnItemEquipped(consumable) -> NotifyConsumableUsed 0x" + akBaseObject.GetFormID())
EndEvent
; ==============================================================================
; WEATHER FORCE — called from C++ on the client to apply host weather
; Receives the weather formID as an Int (from CoSyncTimeSync via CallFunctionNoWait).
; ==============================================================================
Function ForceHostWeather(Int weatherFormID)
Weather w = Game.GetForm(weatherFormID) as Weather
If w != None
w.ForceActive(True)
Debug.Trace("[CoSyncQuest] ForceHostWeather applied 0x" + weatherFormID)
Else
Debug.Trace("[CoSyncQuest] ForceHostWeather: form 0x" + weatherFormID + " not found or not Weather")
EndIf
EndFunction
; ==============================================================================
; TIMER CONTROL
; ==============================================================================
Function StartTimers()
StartTimer(0.016, tickTimerID)
StartTimer(0.1, updateTimerID)
CancelTimer(weatherTimerID)
StartTimer(5.0, weatherTimerID)
CancelTimer(doorScanTimerID)
StartTimer(10.0, doorScanTimerID)
CancelTimer(companionTimerID)
StartTimer(3.0, companionTimerID)
Debug.Trace("[CoSyncQuest] Timers started")
EndFunction
Function StopTimers()
CancelTimer(tickTimerID)
CancelTimer(updateTimerID)
CancelTimer(weatherTimerID)
CancelTimer(doorScanTimerID)
CancelTimer(companionTimerID)
EndFunction
; ==============================================================================
; REMOTE PLAYER SPAWN / DESPAWN
; Called from C++ (CoSyncPapyrusHelper) via CallFunctionNoWait after the
; actor has been spawned and registered in CoSyncEntityRegistry.
; SpawnRemotePlayer handles any Papyrus-side post-spawn setup (enable, init).
; DespawnRemotePlayer cleans up the proxy actor on disconnect or entity destroy.
; ==============================================================================
; ==============================================================================
; PROXY ANIMATION DISPATCH
; Called from C++ via CallFunctionNoWait(questRef, "SendProxyAnimEvent", [formID, event])
;
; Routes a behavior graph animation event to a proxy actor WITHOUT requiring
; CoSyncPlayer.psc to be attached to the NPC form in the ESP.
; Game.GetForm() retrieves the dynamic (0xFF...) actor from its runtime formID.
; proxy.PlaySubGraphAnimation() is the FO4 native for sending behavior graph events.
; ==============================================================================
Function SendProxyAnimEvent(int aiActorFormID, string asEventName)
Actor proxy = Game.GetForm(aiActorFormID) as Actor
If proxy == None
Return
EndIf
proxy.PlaySubGraphAnimation(asEventName)
Debug.Trace("[CoSyncQuest] SendProxyAnimEvent: formID=0x" + aiActorFormID + " event='" + asEventName + "'")
EndFunction
Function SpawnRemotePlayer(int entityID)
ObjectReference ref = CoSync.GetActorByEntityID(entityID)
If ref == None
Debug.Trace("[CoSyncQuest] SpawnRemotePlayer: no actor for entityID=" + entityID)
Return
EndIf
Actor remoteActor = ref as Actor
If remoteActor == None
Debug.Trace("[CoSyncQuest] SpawnRemotePlayer: ref is not an Actor for entityID=" + entityID)
Return
EndIf
; C++ placed the actor in the world — enable it here so it becomes visible.
; Passing false skips the fade-in (instant appear, matches network timing).
remoteActor.Enable(false)
Debug.Trace("[CoSyncQuest] SpawnRemotePlayer: enabled actor for entityID=" + entityID)
EndFunction
Function DespawnRemotePlayer(int entityID)
ObjectReference ref = CoSync.GetActorByEntityID(entityID)
If ref == None
Debug.Trace("[CoSyncQuest] DespawnRemotePlayer: no actor for entityID=" + entityID)
Return
EndIf
Actor remoteActor = ref as Actor
If remoteActor == None
Debug.Trace("[CoSyncQuest] DespawnRemotePlayer: ref not an Actor for entityID=" + entityID)
Return
EndIf
; Disable first (removes from render) then Delete (frees the ref).
; C++ removes from CoSyncEntityRegistry immediately after this call returns.
remoteActor.Disable(false)
remoteActor.Delete()
Debug.Trace("[CoSyncQuest] DespawnRemotePlayer: cleaned up actor for entityID=" + entityID)
EndFunction
; ==============================================================================
; DOOR CELL SCAN
; Iterates all refs in the player's current cell, registers OnActivate on
; every DOOR ref. Safe to call repeatedly — re-registration is idempotent.
; GetRefsInCell() is a CoSync native that wraps cell->objectList.
; GetType() returns the base form's formType integer (32 = kFormType_DOOR).
; ==============================================================================
Function ScanCellDoors()
Cell currentCell = Game.GetPlayer().GetParentCell()
If currentCell == None
Return
EndIf
ObjectReference[] refs = CoSync.GetRefsInCell(currentCell)
If refs == None
Return
EndIf
Int doorCount = 0
Int i = 0
While i < refs.Length
ObjectReference ref = refs[i]
If ref != None && CoSync.IsDoor(ref)
RegisterForRemoteEvent(ref, "OnActivate")
doorCount += 1
EndIf
i += 1
EndWhile
Debug.Trace("[CoSyncQuest] Door scan: " + doorCount + " door(s) registered in cell")
EndFunction
; ==============================================================================
; MAP MARKER DISCOVERY SYNC
; Fires whenever the player (or any tracked actor) moves to a new location.
; We check the new location's map marker — if it's visible and not yet seen,
; we broadcast it to all clients via CoSync.NotifyMapMarkerDiscovered.
; ==============================================================================
Event Actor.OnLocationChange(Actor akActor, Location akOldLoc, Location akNewLoc)
If !CoSync.IsHost()
Return
EndIf
If akNewLoc == None
Return
EndIf
; TODO: map marker discovery broadcast requires GetMapMarker() which is not
; available in vanilla Papyrus. The C++ receive path (CoSyncMapSync) is
; in place; a future update can hook this via a custom F4SE event or polling.
Debug.Trace("[CoSyncQuest] OnLocationChange (map sync stub)")
EndEvent
; ==============================================================================
; DOOR ACTIVATION SYNC
; Fires when any ref we registered via RegisterForRemoteEvent("OnActivate")
; is activated. akSender = the DOOR ref, akActionRef = who activated it.
; Only broadcasts when the local player is the activator — other activators
; (NPCs, scripts) are silently ignored to avoid spurious syncs.
; The C++ suppression guard (s_suppressed in CoSyncActivateSync.cpp) prevents
; this from echoing back when the activation came FROM the network.
; ==============================================================================
Event ObjectReference.OnActivate(ObjectReference akSender, ObjectReference akActionRef)
If akActionRef != Game.GetPlayer()
Return
EndIf
Int openState = akSender.GetOpenState()
CoSync.NotifyActivate(akSender, openState)
Debug.Trace("[CoSyncQuest] Door activated: ref=0x" + akSender.GetFormID() + " state=" + openState)
EndEvent
; ==============================================================================
; POWER ARMOR ENTER SYNC — via Actor.OnSit
; PA chassis is furniture; OnSit fires when the player sits in it.
; IsInPowerArmor() at the moment of OnSit == True confirms this is a PA enter.
; akFurniture is the chassis ObjectReference — same data C++ needs.
; ==============================================================================
Event Actor.OnSit(Actor akTarget, ObjectReference akFurniture)
If akTarget != playerRef
Return
EndIf
If !akTarget.IsInPowerArmor()
Return ; Regular furniture sit — not a PA enter, ignore
EndIf
If akFurniture == None
Debug.Trace("[CoSyncQuest] OnSit(PA): chassis ref is None")
Return
EndIf
bIsInPowerArmor = True
CoSync.NotifyPowerArmor(akTarget, akFurniture, True)
Debug.Trace("[CoSyncQuest] OnSit(PA enter) -> NotifyPowerArmor ENTER chassis=0x" + akFurniture.GetFormID())
EndEvent
; ==============================================================================
; POWER ARMOR EXIT SYNC — via Actor.OnGetUp
; OnGetUp fires when the player exits any furniture. bIsInPowerArmor guards
; against triggering on regular chairs — only fires after a confirmed PA enter.
; ==============================================================================
Event Actor.OnGetUp(Actor akTarget, ObjectReference akFurniture)
If akTarget != playerRef
Return
EndIf
If !bIsInPowerArmor
Return ; Was not in PA — regular furniture exit, ignore
EndIf
bIsInPowerArmor = False
If akFurniture == None
Debug.Trace("[CoSyncQuest] OnGetUp(PA exit): chassis ref is None")
Return
EndIf
CoSync.NotifyPowerArmor(akTarget, akFurniture, False)
Debug.Trace("[CoSyncQuest] OnGetUp(PA exit) -> NotifyPowerArmor EXIT chassis=0x" + akFurniture.GetFormID())
EndEvent
; ==============================================================================
; COMPANION POLL
; Diffs Game.GetCurrentFollowers() (F4SE) against the last known follower list.
; New entries → NotifyCompanionRecruited (C++ broadcasts a COMP_REQ)
; Lost entries → NotifyCompanionDismissed (C++ broadcasts COMP_DISMISS)
;
; NOTE: Game.GetCurrentFollowers() is provided by F4SE. If it returns None
; on a given tick (e.g., before the player fully loads), we skip that cycle
; and retry next tick to avoid false dismissals.
; ==============================================================================
Function PollCompanionChanges()
Actor[] currentFollowers = Game.GetCurrentFollowers()
; Skip the cycle if the follower list is unavailable — avoids spurious dismissals
; during cell transitions or save loads before the player is fully initialised.
If currentFollowers == None
Return
EndIf
; ---- Detect newly recruited companions ----------------------------------
; Any actor present in currentFollowers but absent from trackedFollowers
; has just been recruited and needs a network broadcast.
Int i = 0
While i < currentFollowers.Length
Actor akComp = currentFollowers[i]
If akComp != None
If !CompanionArrayContains(trackedFollowers, akComp)
CoSync.NotifyCompanionRecruited(akComp)
Debug.Trace("[CoSyncQuest] Companion recruited -> NotifyCompanionRecruited " + akComp.GetBaseObject().GetFormID())
EndIf
EndIf
i += 1
EndWhile
; ---- Detect dismissed companions ----------------------------------------
; Any actor present in trackedFollowers but absent from currentFollowers
; has just been dismissed.
If trackedFollowers != None
i = 0
While i < trackedFollowers.Length
Actor akComp = trackedFollowers[i]
If akComp != None
If !CompanionArrayContains(currentFollowers, akComp)
CoSync.NotifyCompanionDismissed(akComp)
Debug.Trace("[CoSyncQuest] Companion dismissed -> NotifyCompanionDismissed " + akComp.GetBaseObject().GetFormID())
EndIf
EndIf
i += 1
EndWhile
EndIf
; Update snapshot for next cycle
trackedFollowers = currentFollowers
EndFunction
; Linear search helper — returns True if akTarget appears in akArr.
; Used by PollCompanionChanges to diff follower snapshots.
Bool Function CompanionArrayContains(Actor[] akArr, Actor akTarget)
If akArr == None || akTarget == None
Return False
EndIf
Int i = 0
While i < akArr.Length
If akArr[i] == akTarget
Return True
EndIf
i += 1
EndWhile
Return False
EndFunction