--- a/addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c +++ b/addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c @@ -349,6 +349,17 @@ protected void ERR_OnObserverMonitorStarted() {} protected void ERR_UpdateObserverClient() { + if (!GetGame()) return; + if (this != GetGame().GetPlayerController()) + { + // A replaced controller may retain its timer until native destruction. + // Release only its presentation; never restore its old body camera. + GetGame().GetCallqueue().Remove(ERR_UpdateObserverClient); + m_ERRObserverMonitor = false; + m_ERRRecoveryBody = null; m_ERRRecoveryDeadline = -1; + ERR_CloseObserverCamera(); + return; + } ERR_GameMode mode = ERR_GameMode.Get(); ERR_UpdateRecoveryCamera(mode); bool observe = mode && ERR_ObserverRules.View(m_ERRObserverReason, mode.Active(), ERR_Actors.Alive(GetControlledEntity()), ERR_Actors.CanFight(GetControlledEntity())); --- a/tests/test_hint_delivery.py +++ b/tests/test_hint_delivery.py @@ -3,16 +3,16 @@ from pathlib import Path import os GUIDE=Path(os.environ.get('ERR_GUIDE_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_PlayerGuide.c')) -OBSERVER=ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c' +OBSERVER=Path(os.environ.get('ERR_OBSERVER_SOURCE', ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c')) def cpp(text): - text=text.replace('protected ', '').replace('override ', '').replace('super.OnControlledEntityChanged(from, to);', 'nativeChanges++;') + text=text.replace(' = null;', ' = nullptr;').replace('protected ', '').replace('override ', '').replace('super.OnControlledEntityChanged(from, to);', 'nativeChanges++;') for name in ('ERR_GameMode','ERR_Phase','ERR_ObserverReason','ERR_Actors','ERR_ObjectiveText','SCR_HintManagerComponent','ERR_ObserverGuidance','ERR_ObserverRules','Resource','ETransformMode','SCR_ManualCamera','SCR_RespawnSystemComponent','RplId'): text=text.replace(name+'.',name+'::') for typename in ('ERR_GameMode','SCR_HintManagerComponent','Resource','SCR_RespawnSystemComponent','CameraManager'): text=text.replace(typename+' ',typename+'* ') for variable in ('mode','hints','resource','respawn','manager'): text=text.replace(variable+'.',variable+'->') - text=text.replace('GetCallqueue().','GetCallqueue()->').replace('Remove(ERR_UpdateGuide)','Remove(this)').replace('CallLater(ERR_UpdateGuide, 1000, true)','CallLater(this, 1000, true)') + text=text.replace('GetCallqueue().','GetCallqueue()->').replace('Remove(ERR_UpdateGuide)','Remove(this)').replace('Remove(ERR_UpdateObserverClient)','Remove(this)').replace('CallLater(ERR_UpdateGuide, 1000, true)','CallLater(this, 1000, true)') text=text.replace('GetGame().','GetGame()->').replace('EntitySpawnParams params = new EntitySpawnParams();','EntitySpawnParams params;') return text methods=cpp(body(GUIDE,'protected void ERR_StartGuide(')).replace('ERR_StartGuide()', 'StartActual()')+'\n'+cpp(body(GUIDE,'override void OnControlledEntityChanged('))+'\n'+cpp(body(GUIDE,'protected void ERR_UpdateGuide('))+'\n'+cpp(body(OBSERVER,'protected void ERR_UpdateObserverClient(')) @@ -53,7 +53,7 @@ Controller(){game.local=this;} void ERR_UpdateRecoveryCamera(ERR_GameMode*){} int nativeChanges=0,starts=0;bool m_ERRGuideStarted=false;void ERR_StartGuide(){starts++;StartActual();} - int m_ERRGuidePhase=-1;string m_ERRGuideMessage; + int m_ERRGuidePhase=-1;string m_ERRGuideMessage;bool m_ERRObserverMonitor=false;Actor* m_ERRRecoveryBody=nullptr;float m_ERRRecoveryDeadline=-1; Actor* controlled=&actor;int m_ERRObserverReason=ERR_ObserverReason::NONE; SCR_ManualCamera* m_ERRObserverCamera=nullptr;Vec m_ERRObserverAnchor; int m_ERRObservedId=1;bool m_ERRObserverReady=false; --- /dev/null +++ b/tests/test_observer_ownership.py @@ -0,0 +1,104 @@ +"""Actual observer monitor/recovery/cleanup; native camera lifecycle is doubled.""" +import os,re +from pathlib import Path +from policy_harness import ROOT,body,run +p=Path(os.environ.get('ERR_OBSERVER_SOURCE',ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_Observer.c')) +def cpp(s): + s=s.replace('protected ','').replace('null','nullptr') + for n in ('ERR_GameMode','ERR_Actors','ERR_ObserverRules','ERR_ObserverGuidance','SCR_HintManagerComponent','SCR_RespawnSystemComponent','SCR_EditorManagerEntity','Resource','SCR_ManualCamera','RplId','ETransformMode','LogLevel','System'): + s=s.replace(n+'.',n+'::') + for n in ('ERR_GameMode','SCR_HintManagerComponent','SCR_RespawnSystemComponent','SCR_EditorManagerEntity','Resource','CameraManager','CameraBase'): + s=re.sub(r'\b'+n+r' (\w+)',n+r'* \1',s) + for n in ('mode','hints','respawn','editor','resource','manager','m_ERRObserverCamera'): + s=s.replace(n+'.',n+'->') + s=s.replace('GetGame().','GetGame()->').replace('GetWorld().','GetWorld()->').replace('GetCallqueue().','GetCallqueue()->') + s=s.replace('Remove(ERR_UpdateObserverClient)','Remove(this)').replace('CallLater(ERR_UpdateObserverClient, 250, true)','CallLater(this, 250, true)') + s=s.replace('EntitySpawnParams params = new EntitySpawnParams();','EntitySpawnParams params;') + return s +methods='\n'.join(cpp(body(p,n)) for n in ('void ERR_StartObserverMonitor(', 'protected void ERR_UpdateObserverClient(', 'protected void ERR_UpdateRecoveryCamera(', 'protected void ERR_CloseObserverCamera(')) +run(r'''#include +#include +#include +using string=std::string; +struct Actor {bool alive=true,conscious=true;};using IEntity=Actor*; +struct ERR_Actors {static bool Alive(IEntity a){return a&&a->alive;}static bool CanFight(IEntity a){return Alive(a)&&a->conscious;}}; +struct ERR_GameMode {bool active=true;static ERR_GameMode*current;static ERR_GameMode*Get(){return current;}bool Active(){return active;}} mode; +ERR_GameMode*ERR_GameMode::current=&mode; +struct ERR_ObserverRules {static bool View(int reason,bool active,bool alive,bool conscious){return active&&((reason==4&&alive&&!conscious)||(reason!=0&&!alive));}}; +struct ERR_ObserverGuidance {static bool Refresh(bool ready,int oldReason,int reason,bool oldAvailable,bool available){return !ready||oldReason!=reason||oldAvailable!=available;}static string Message(int,bool){return "spectating";}}; +struct Vec{Vec operator+(const char*)const{return {};}}; +struct EntitySpawnParams{int TransformMode=0;Vec Transform[4];};namespace ETransformMode{enum{WORLD};} +struct Resource{static Resource*Load(const char*){static Resource r;return &r;}bool IsValid(){return true;}}; +struct CameraBase{virtual ~CameraBase()=default;}; +struct CameraManager{CameraBase*current=nullptr;int sets=0;CameraBase*CurrentCamera(){return current;}void SetCamera(CameraBase*c){current=c;sets++;}} manager; +int terminated=0,deleted=0,explicitSwitch=0; +struct SCR_ManualCamera:CameraBase{ + CameraBase*previous=nullptr;bool ended=false; + static SCR_ManualCamera*Cast(SCR_ManualCamera*c){return c;} + void TrySwitchToControlledEntityCamera(){explicitSwitch++;} + void Terminate(){if(!ended){ended=true;terminated++;}} + // SDK SCR_ManualCamera destructor invokes SwitchToPreviousCamera, which + // changes only its own currently selected camera, never a foreign current one. + ~SCR_ManualCamera(){Terminate();if(manager.current==this&&previous)manager.SetCamera(previous);deleted++;} +}; +struct SCR_HintManagerComponent{int attempts=0;static SCR_HintManagerComponent*GetInstance(){static SCR_HintManagerComponent h;return &h;}bool ShowCustom(string,string,int){attempts++;return true;}}; +struct SCR_RespawnSystemComponent{int clears=0;static SCR_RespawnSystemComponent*GetInstance(){static SCR_RespawnSystemComponent r;return &r;}void DestroyLoadingPlaceholder(){clears++;}}; +struct SCR_EditorManagerEntity{bool opened=false;int closes=0;static SCR_EditorManagerEntity*GetInstance(){static SCR_EditorManagerEntity e;return &e;}bool IsOpened(){return opened;}void Close(bool){opened=false;closes++;}}; +struct RplId{static int Invalid(){return -1;}};namespace LogLevel{enum{WARNING};}void Print(const char*,int){} +namespace System{bool IsConsoleApp(){return false;}} +struct Queue{std::setowners;int added=0;void CallLater(void*owner,int,bool){owners.insert(owner);added++;}void Remove(void*owner){owners.erase(owner);}} queue; +struct World{float now=1;float GetWorldTime(){return now;}} world; +struct Game{void*local=nullptr;int spawns=0;void*GetPlayerController(){return local;}Queue*GetCallqueue(){return &queue;}World*GetWorld(){return &world;}CameraManager*GetCameraManager(){return &manager;} + SCR_ManualCamera*SpawnEntityPrefab(Resource*,World*,EntitySpawnParams){spawns++;auto*c=new SCR_ManualCamera();c->previous=manager.current;manager.SetCamera(c);return c;}} game; +bool gameAvailable=true;Game*GetGame(){return gameAvailable?&game:nullptr;} +struct Controller{ + IEntity actor=nullptr,m_ERRRecoveryBody=nullptr;float m_ERRRecoveryDeadline=-1; + bool m_ERRObserverMonitor=false,m_ERRObserverReady=false,m_ERRShownObserverAvailable=false; + int m_ERRObserverReason=0,m_ERRShownObserverReason=0,m_ERRObservedId=1,starts=0; + Vec m_ERRObserverAnchor;SCR_ManualCamera*m_ERRObserverCamera=nullptr;CameraBase*playerCamera=nullptr; + IEntity GetControlledEntity(){return actor;}CameraBase*GetPlayerCamera(){return playerCamera;} + void ERR_OnObserverMonitorStarted(){starts++;} +'''+methods+r''' +}; +int main(){ + Actor body;CameraBase formerBodyCamera,newBodyCamera; + Controller former,current;former.actor=&body;former.playerCamera=&formerBodyCamera; + game.local=&former;manager.current=&formerBodyCamera;former.ERR_StartObserverMonitor(); + body.conscious=false;former.m_ERRObserverReason=4;former.ERR_UpdateObserverClient(); + assert(former.m_ERRRecoveryBody==&body&&former.m_ERRObserverCamera&&queue.owners.count(&former)); + SCR_ManualCamera*oldOwned=former.m_ERRObserverCamera; + // New controller owns a separate spectator camera; old body wakes meanwhile. + current.playerCamera=&newBodyCamera;current.m_ERRObserverReason=1;game.local=¤t;current.ERR_StartObserverMonitor();current.ERR_UpdateObserverClient(); + SCR_ManualCamera*newOwned=current.m_ERRObserverCamera;assert(newOwned&&newOwned!=oldOwned&&manager.current==newOwned); + auto*editor=SCR_EditorManagerEntity::GetInstance();editor->opened=true;body.conscious=true; + int beforeClears=SCR_RespawnSystemComponent::GetInstance()->clears; + int beforeSets=manager.sets,beforeHints=SCR_HintManagerComponent::GetInstance()->attempts,beforeSpawns=game.spawns,beforeDeleted=deleted; + former.ERR_UpdateObserverClient(); + assert(manager.current==newOwned&&manager.sets==beforeSets&&editor->opened&&editor->closes==0); + assert(!former.m_ERRRecoveryBody&&former.m_ERRRecoveryDeadline==-1&&!former.m_ERRObserverMonitor&&!former.m_ERRObserverCamera); + assert(deleted==beforeDeleted+1&&terminated==deleted&&explicitSwitch==0); + assert(current.m_ERRObserverCamera==newOwned&&queue.owners.count(¤t)==1&&queue.owners.count(&former)==0); + assert(SCR_HintManagerComponent::GetInstance()->attempts==beforeHints&&game.spawns==beforeSpawns&&SCR_RespawnSystemComponent::GetInstance()->clears==beforeClears); + // Even a queued stale spectator callback cannot create a camera or a hint. + former.actor=nullptr;former.m_ERRObserverReason=1;former.ERR_UpdateObserverClient(); + assert(game.spawns==beforeSpawns&&manager.current==newOwned&&SCR_HintManagerComponent::GetInstance()->attempts==beforeHints); + // Start is still local-only; reacquisition restarts exactly one owned timer. + int added=queue.added;former.ERR_StartObserverMonitor();assert(queue.added==added); + game.local=&former;former.actor=&body;former.m_ERRObserverReason=0;former.ERR_StartObserverMonitor();former.ERR_StartObserverMonitor(); + assert(queue.added==added+1&&former.starts==2&&queue.owners.count(¤t)==1); + // Normal recovery still closes the editor and returns this controller's body. + body.conscious=false;former.ERR_UpdateObserverClient();assert(former.m_ERRRecoveryBody==&body); + body.conscious=true;former.ERR_UpdateObserverClient();assert(manager.current==&formerBodyCamera&&!editor->opened&&editor->closes==1&&!former.m_ERRRecoveryBody); + // Foreign current camera survives this controller's retained camera cleanup. + game.local=¤t;manager.current=&newBodyCamera;current.ERR_CloseObserverCamera();assert(manager.current==&newBodyCamera&&deleted==beforeDeleted+2); + // If the old owned camera is still current at replacement, its native + // destructor may restore its own previous camera; no new-controller camera is deleted. + Controller abandoned;abandoned.m_ERRObserverReason=1;game.local=&abandoned;manager.current=&newBodyCamera; + abandoned.ERR_StartObserverMonitor();abandoned.ERR_UpdateObserverClient();assert(manager.current==abandoned.m_ERRObserverCamera); + game.local=¤t;beforeSets=manager.sets;beforeDeleted=deleted;abandoned.ERR_UpdateObserverClient(); + assert(!abandoned.m_ERRObserverCamera&&manager.current==&newBodyCamera&&manager.sets==beforeSets+1&&deleted==beforeDeleted+1); + assert(queue.owners.count(&abandoned)==0&&queue.owners.count(¤t)==1); + gameAvailable=false;former.ERR_UpdateObserverClient();gameAvailable=true; +} +''') +print('PASS: actual observer monitor/recovery/cleanup rejects retained old controller, preserves new camera/editor/hints/timer, restarts once and retains normal recovery; native effects modeled from SDK, no runtime claim')