Retain spawn replication through admission and nearby crate use

This commit is contained in:
2026-09-20 18:00:34 +02:00
parent e8d472c6ff
commit 173faae6d4
8 changed files with 193 additions and 24 deletions
@@ -14,8 +14,9 @@ modded class SCR_SpawnRequestComponent
protected bool m_ERRScenePreloadRequired;
protected bool m_ERRSceneObserver;
protected vector m_ERRScenePosition;
protected bool m_ERRSceneReleased;
protected bool m_ERRSceneFinalRequested;
protected bool m_ERRSceneVerified;
protected ref ERR_SpawnSceneLease m_ERRSceneLease;
protected ref array<IEntity> m_ERRSceneRoots = {};
protected ref array<IEntity> m_ERRSceneStockOwners = {};
protected ref array<IEntity> m_ERRSceneStockItems = {};
@@ -31,17 +32,14 @@ modded class SCR_SpawnRequestComponent
protected void ERR_StreamScene(IEntity entity, bool normalStreaming)
{
if (!entity || !GetPlayerController()) return;
RplIdentity identity = GetPlayerController().GetRplIdentity();
if (!identity.IsValid() || identity == RplIdentity.Local()) return;
RplComponent component = RplComponent.Cast(entity.FindComponent(RplComponent));
if (component) component.EnableStreamingConNode(identity, normalStreaming);
ERR_SpawnSceneLease.Stream(GetPlayerController(), entity, normalStreaming);
}
void ERR_BeginSpawnScene(array<IEntity> entities, bool valid, vector position)
{
if (!Replication.IsServer()) return;
ERR_EndSpawnScene();
if (m_ERRSceneLease) m_ERRSceneLease.Stop();
m_ERRScenePosition = position;
// Native StartSpawnPreload normally installs this observer. Keep streaming
// the requested position while we defer asset preload until inventory arrives.
@@ -87,7 +85,7 @@ modded class SCR_SpawnRequestComponent
m_ERRSceneEntities.Clear();
m_ERRSceneToken++;
m_ERRSceneAcknowledged = false;
m_ERRSceneReleased = false;
m_ERRSceneFinalRequested = false;
m_ERRSceneVerified = false;
m_ERRScenePreloadRequired = true;
m_ERRSceneInventoryPoll = GetGame().GetWorld().GetWorldTime() + 500;
@@ -141,24 +139,32 @@ modded class SCR_SpawnRequestComponent
{
return ERR_SpawnSceneReady() && m_ERRSceneVerified;
}
// Called only after native preload completes. Recheck under normal streaming,
// with the observer still at the spawn point, before handing over control.
bool ERR_VerifySceneRelease()
// Called only after native preload completes. Retain the scene through the
// final check and possession; normal streaming cannot yet keep every node.
bool ERR_VerifyLoadedScene()
{
if (m_ERRSceneTimedOut) return true;
if (!ERR_SpawnSceneReady() || m_ERRScenePreloadRequired || IsPreloading()) return false;
if (!m_ERRSceneReleased)
if (!m_ERRSceneFinalRequested)
{
ObserversSystem observers = GetObserversSystem();
if (observers && GetPlayerController())
observers.InsertObserverMP(GetPlayerController().GetRplIdentity(), m_ERRScenePosition[0], m_ERRScenePosition[2], null);
foreach (IEntity entity : m_ERRSceneEntities) ERR_StreamScene(entity, true);
m_ERRSceneReleased = true;
PrintFormat("Reintroduced [spawn scene]: player=%1 token=%2 preload finished; verifying storage after stream release", GetPlayerId(), m_ERRSceneToken);
m_ERRSceneFinalRequested = true;
PrintFormat("Reintroduced [spawn scene]: player=%1 token=%2 preload finished; verifying held storage", GetPlayerId(), m_ERRSceneToken);
Rpc(ERR_ConfirmClientSpawnScene, m_ERRSceneToken);
}
return m_ERRSceneVerified;
}
void ERR_RetainSpawnScene(IEntity actor)
{
if (!Replication.IsServer() || !ERR_SpawnSceneVerified() || !actor || !GetPlayerController()) return;
if (!m_ERRSceneLease) m_ERRSceneLease = new ERR_SpawnSceneLease();
m_ERRSceneLease.Start(GetPlayerController(), actor, m_ERRScenePosition, m_ERRSceneEntities);
PrintFormat("Reintroduced [spawn scene]: player=%1 retaining %2 objects until leaving the spawn area", GetPlayerId(), m_ERRSceneEntities.Count());
// Transfer ownership of the streaming hold; request cleanup must not undo it.
m_ERRSceneEntities.Clear();
}
bool ERR_SpawnSceneMayFinish()
{
if (!m_ERRScenePending) return true;
@@ -187,7 +193,7 @@ modded class SCR_SpawnRequestComponent
m_ERRSceneAcknowledged = false;
m_ERRSceneTimedOut = false;
m_ERRScenePreloadRequired = false;
m_ERRSceneReleased = false;
m_ERRSceneFinalRequested = false;
m_ERRSceneVerified = false;
}
@@ -256,7 +262,12 @@ modded class SCR_SpawnRequestComponent
foreach (RplId id : m_ERRClientSceneNodes)
{
RplComponent component = RplComponent.Cast(Replication.FindItem(id));
if (!component || !component.GetEntity()) { m_ERRClientSceneStableSince = -1; return; }
if (!component || !component.GetEntity())
{
ERR_ReportStockWait(id, RplId.Invalid(), -1);
m_ERRClientSceneStableSince = -1;
return;
}
}
if (!ERR_ClientStockReady()) { m_ERRClientSceneStableSince = -1; return; }
if (m_ERRClientSceneFinal)
@@ -277,7 +288,7 @@ modded class SCR_SpawnRequestComponent
if (!Replication.IsServer() || !m_ERRScenePending || m_ERRSceneTimedOut || token != m_ERRSceneToken) return;
if (finalCheck)
{
if (!m_ERRSceneReleased || !m_ERRSceneAcknowledged) return;
if (!m_ERRSceneFinalRequested || !m_ERRSceneAcknowledged) return;
m_ERRSceneVerified = true;
}
else m_ERRSceneAcknowledged = true;
@@ -314,5 +325,6 @@ modded class SCR_SpawnRequestComponent
if (!GetGame()) return;
ERR_ClearClientSpawnScene();
ERR_EndSpawnScene();
if (m_ERRSceneLease) m_ERRSceneLease.Stop();
}
}
@@ -57,7 +57,7 @@ modded class SCR_FreeSpawnHandlerComponent
if (!requestComponent.ERR_SpawnSceneMayFinish()) return false;
if (requestComponent.ERR_TakeScenePreload()) super.OnFinalizeBegin_S(requestComponent, data, entity);
if (!super.CanFinalize_S(requestComponent, data, entity)) return false;
return requestComponent.ERR_VerifySceneRelease();
return requestComponent.ERR_VerifyLoadedScene();
}
return super.CanFinalize_S(requestComponent, data, entity);
}
@@ -104,6 +104,7 @@ modded class SCR_FreeSpawnHandlerComponent
return SCR_ESpawnResult.SPAWN_NOT_ALLOWED;
}
SCR_ESpawnResult result = super.FinalizeRequest_S(requestComponent, data, entity);
if (result == SCR_ESpawnResult.OK) requestComponent.ERR_RetainSpawnScene(entity);
requestComponent.ERR_EndSpawnScene();
if (result != SCR_ESpawnResult.OK) ERR_RejectOwnedActor(requestComponent, entity, data);
return result;
@@ -0,0 +1,46 @@
// Keep the bounded spawn snapshot relevant through possession and nearby crate
// use. Releasing it before possession can stream out runtime inventory nodes.
class ERR_SpawnSceneLease
{
protected SCR_PlayerController m_Controller;
protected IEntity m_Actor;
protected vector m_Position;
protected ref array<IEntity> m_Entities = {};
static void Stream(SCR_PlayerController controller, IEntity entity, bool normalStreaming)
{
if (!controller || !entity) return;
RplIdentity identity = controller.GetRplIdentity();
if (!identity.IsValid() || identity == RplIdentity.Local()) return;
RplComponent component = RplComponent.Cast(entity.FindComponent(RplComponent));
if (component) component.EnableStreamingConNode(identity, normalStreaming);
}
void Start(SCR_PlayerController controller, IEntity actor, vector position, array<IEntity> entities)
{
Stop();
m_Controller = controller;
m_Actor = actor;
m_Position = position;
m_Entities.Copy(entities);
GetGame().GetCallqueue().CallLater(Check, 500, true);
}
protected void Check()
{
ERR_GameMode mode = ERR_GameMode.Get();
if (!mode || !mode.Active() || !m_Controller || !m_Actor || m_Controller.GetControlledEntity() != m_Actor || vector.DistanceXZ(m_Actor.GetOrigin(), m_Position) > 120)
Stop();
}
void Stop()
{
if (GetGame()) GetGame().GetCallqueue().Remove(Check);
foreach (IEntity entity : m_Entities) Stream(m_Controller, entity, true);
m_Entities.Clear();
m_Controller = null;
m_Actor = null;
}
void ~ERR_SpawnSceneLease() { Stop(); }
}
+34
View File
@@ -1,5 +1,39 @@
# Mission preparation presentation
## Retain the scene through possession (0.0.9 candidate)
Published 0.0.8 failed on RKS #2: the client repeatedly acknowledged all 50
objects and 28 storage items, but never acknowledged the final check. The
server completed native preload, released the streaming hold, then timed out
and retried every 60 seconds. This repeated for at least five attempts. The
client's 17:48:37 session confirms the 0.0.7 -> 0.0.8 update, and the installed
PAK contains the expected second-check implementation. The first-stage inventory
check was succeeding; increasing the wait would not resolve this failure.
The candidate removes the premature release. It retains the bounded snapshot
through preload, final acknowledgement and possession. On successful assignment,
the request transfers its held nodes to one player-scoped `ERR_SpawnSceneLease`.
Normal request cleanup no longer releases them. The lease checks every 500ms and
restores normal streaming when the player leaves the 120m spawn area, loses or
changes their controlled actor, disconnects, or the mission ends. Beginning a
new admission or destroying the request also stops the lease. Failed admissions
still release their own nodes. No extra spawn delay was added. Missing-node
waits now produce a rate-limited diagnostic rather than returning silently.
`tests/test_spawn_scene_lease.py` executes the production handoff and lease
lifetime, including remaining near the crate, crossing the distance boundary,
controller loss, replacement admission and teardown. The native prisoner-kit
fixture completed: first readiness at 17:56:41.153, final readiness at
17:56:43.295, followed by transfer of the 50-object hold to the admitted player.
The original runner retains a transient exiting-process cleanup classification
failure. Results are in
`/tmp/erri-spawn-lease-kit-20260920/logs/logs_2026-09-20_17-55-46`.
The fixture passed 171 assertions with no script/runtime errors; four existing
RHS/WCS localization errors remain. A separate follow-up verified the test prefix
idle and addon sources unchanged, without rewriting the original runner result.
All repository checks passed with the new lease regression included.
Dedicated acceptance still requires publishing and testing 0.0.9.
## Storage slots and post-preload check (0.0.8 candidate)
The user tested published 0.0.7 on RKS #2 and still opened an empty prison crate
+3
View File
@@ -0,0 +1,3 @@
Fix the startup retry loop introduced in 0.0.8. Keep the spawn area's objects and inventory items replicated through the final readiness check and player possession, and while that player remains near the starting crate. Restore normal streaming when the player leaves the area or their session ends.
Retain the actual storage-slot checks and add diagnostics for missing replicated objects. No extra loading delay is added.
+7 -6
View File
@@ -11,7 +11,7 @@ methods = '\n'.join(body(source, key) for key in keys)
methods = methods.replace('protected ', '').replace('Replication.IsServer()', 'Replication::IsServer()')
methods = methods.replace('ERR_GameMode.Get()', 'Mode::Get()')
methods = methods.replace('RplComponent component = RplComponent.Cast(Replication.FindItem(id));', 'RplComponent* component = Replication::FindItem(id);')
methods = methods.replace('component.GetEntity()', 'component->GetEntity()')
methods = methods.replace('component.GetEntity()', 'component->GetEntity()').replace('RplId.Invalid()', '-1')
methods = methods.replace('foreach (RplId id :', 'for (RplId id :')
methods = methods.replace('GetGame().GetWorld().GetWorldTime()', 'game.now')
methods = methods.replace('GetGame().GetCallqueue().Remove(ERR_CheckClientSpawnScene)', 'game.removes++')
@@ -31,8 +31,9 @@ template<typename... T>void PrintFormat(const char*,T...){}
struct Probe{
int m_ERRSceneToken=4,m_ERRClientSceneToken=4,acks=0,lastAck=0;
bool m_ERRScenePending=false,m_ERRSceneAcknowledged=false,m_ERRSceneTimedOut=false,m_ERRClientScenePending=false;
bool m_ERRSceneReleased=false,m_ERRSceneVerified=false,m_ERRClientSceneFinal=false,lastFinal=false;float m_ERRClientSceneStableSince=-1;
bool m_ERRSceneFinalRequested=false,m_ERRSceneVerified=false,m_ERRClientSceneFinal=false,lastFinal=false;float m_ERRClientSceneStableSince=-1;
float m_ERRSceneDeadline=1000,m_ERRSceneStartedAt=0;Nodes m_ERRClientSceneNodes,m_ERRClientStockOwners,m_ERRClientStockItems;
void ERR_ReportStockWait(int,int,int){}
bool stockReady=true;bool ERR_ClientStockReady(){return stockReady;}void ERR_RefreshSceneInventory(){}
int GetPlayerId(){return 1;}void SendAck(int token,bool finalCheck){acks++;lastAck=token;lastFinal=finalCheck;}
'''+methods+r'''
@@ -59,7 +60,7 @@ int main(){
Probe handshake;handshake.m_ERRScenePending=true;
handshake.ERR_AcknowledgeSpawnScene(4,true);assert(!handshake.ERR_SpawnSceneVerified());
handshake.ERR_AcknowledgeSpawnScene(4,false);handshake.ERR_AcknowledgeSpawnScene(4,true);assert(!handshake.ERR_SpawnSceneVerified());
handshake.m_ERRSceneReleased=true;handshake.ERR_AcknowledgeSpawnScene(3,true);assert(!handshake.ERR_SpawnSceneVerified());
handshake.m_ERRSceneFinalRequested=true;handshake.ERR_AcknowledgeSpawnScene(3,true);assert(!handshake.ERR_SpawnSceneVerified());
handshake.ERR_AcknowledgeSpawnScene(4,true);assert(handshake.ERR_SpawnSceneVerified());
Probe finalTimeout;finalTimeout.m_ERRScenePending=true;finalTimeout.m_ERRSceneAcknowledged=true;
finalTimeout.ERR_SpawnSceneMayFinish();assert(finalTimeout.m_ERRSceneTimedOut&&!finalTimeout.ERR_SpawnSceneVerified());
@@ -89,7 +90,7 @@ for old, new in [('override ', ''), ('SCR_SpawnRequestComponent requestComponent
run(r'''
#include <cassert>
enum class SCR_ESpawnResult{OK,SPAWN_NOT_ALLOWED,CANNOT_ASSIGN};
struct Request{bool ready=false,mayFinish=false,verified=false;bool ERR_SpawnSceneVerified(){return ready&&verified;}bool ERR_VerifySceneRelease(){return !ready||verified;}int ends=0;bool ERR_SpawnSceneReady(){return ready;}bool ERR_SpawnSceneMayFinish(){return mayFinish;}bool preloadRequired=true;bool ERR_TakeScenePreload(){if(!ready||!preloadRequired)return false;preloadRequired=false;return true;}void ERR_EndSpawnScene(){ends++;}};
struct Request{bool ready=false,mayFinish=false,verified=false;bool ERR_SpawnSceneVerified(){return ready&&verified;}bool ERR_VerifyLoadedScene(){return !ready||verified;}int ends=0,retained=0;void ERR_RetainSpawnScene(void*){retained++;}bool ERR_SpawnSceneReady(){return ready;}bool ERR_SpawnSceneMayFinish(){return mayFinish;}bool preloadRequired=true;bool ERR_TakeScenePreload(){if(!ready||!preloadRequired)return false;preloadRequired=false;return true;}void ERR_EndSpawnScene(){ends++;}};
struct Data{};struct Actor{};struct Ticket{};
struct Mode{static bool current;static bool Get(){return current;}};bool Mode::current=true;
struct Logic{Ticket ticket;bool hasTicket=true,allowed=true;Ticket* TakeSpawnTicket(Request*,Data*,Actor*){return hasTicket?&ticket:nullptr;}bool MayFinalizeSpawn(Ticket*){return allowed;}};
@@ -108,7 +109,7 @@ int main(){
h.preloadReady=true;assert(!h.CanFinalize_S(&request,&data,&actor));request.verified=true;assert(h.CanFinalize_S(&request,&data,&actor)&&h.preloadStarts==1);
h.storage.allowed=false;
assert(h.FinalizeRequest_S(&request,&data,&actor)==SCR_ESpawnResult::SPAWN_NOT_ALLOWED&&h.assignments==0&&h.rejects==2);
h.storage.allowed=true;assert(h.FinalizeRequest_S(&request,&data,&actor)==SCR_ESpawnResult::OK&&h.assignments==1&&request.ends==3);
h.storage.allowed=true;assert(h.FinalizeRequest_S(&request,&data,&actor)==SCR_ESpawnResult::OK&&h.assignments==1&&request.ends==3&&request.retained==1);
h.storage.hasTicket=false;assert(h.FinalizeRequest_S(&request,&data,&actor)==SCR_ESpawnResult::SPAWN_NOT_ALLOWED&&h.assignments==1&&h.rejects==2);
Mode::current=false;h.logic=nullptr;assert(h.FinalizeRequest_S(&request,&data,&actor)==SCR_ESpawnResult::OK&&h.assignments==2);
}
@@ -127,7 +128,7 @@ struct Items:std::vector<int>{using std::vector<int>::vector;void Clear(){clear(
struct Controller{int GetControlledEntity(){return 42;}};
struct Probe{
Items m_ERRSceneEntities{1,2},m_ERRSceneRoots,m_ERRSceneStockOwners,m_ERRSceneStockItems;
bool m_ERRSceneReleased=true,m_ERRSceneVerified=true;bool m_ERRSceneObserver=true,m_ERRScenePending=true,m_ERRSceneAcknowledged=true,m_ERRSceneTimedOut=true,m_ERRScenePreloadRequired=true;
bool m_ERRSceneFinalRequested=true,m_ERRSceneVerified=true;bool m_ERRSceneObserver=true,m_ERRScenePending=true,m_ERRSceneAcknowledged=true,m_ERRSceneTimedOut=true,m_ERRScenePreloadRequired=true;
int m_ERRSceneToken=7,restored=0,cancelled=0,observed=0;Controller controller;bool connected=true;
Controller* GetPlayerController(){return connected?&controller:nullptr;}
void ERR_StreamScene(int,bool normal){assert(normal);restored++;}
+71
View File
@@ -0,0 +1,71 @@
"""Exercise production retention lifetime and the post-preload handoff."""
from policy_harness import ROOT, body, run
source = ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnSceneLease.c'
methods = '\n'.join(body(source, key) for key in ('void Start(', 'protected void Check(', 'void Stop('))
for old, new in [
('protected ', ''), ('SCR_PlayerController controller', 'Controller* controller'),
('IEntity actor', 'Actor* actor'), ('vector position', 'float position'),
('array<IEntity> entities', 'Items entities'), ('ERR_GameMode mode = ERR_GameMode.Get()', 'Mode* mode = currentMode'),
('mode.Active()', 'mode->active'), ('m_Controller.GetControlledEntity()', 'm_Controller->actor'),
('vector.DistanceXZ(m_Actor.GetOrigin(), m_Position)', 'std::abs(m_Actor->position - m_Position)'),
('GetGame().GetCallqueue().CallLater(Check, 500, true)', 'starts++'),
('if (GetGame()) GetGame().GetCallqueue().Remove(Check)', 'removes++'),
('foreach (IEntity entity :', 'for (int entity :'), ('null', 'nullptr')]:
methods = methods.replace(old, new)
run(r'''
#include <cassert>
#include <cmath>
#include <vector>
struct Items:std::vector<int>{using std::vector<int>::vector;void Clear(){clear();}void Copy(Items other){assign(other.begin(),other.end());}};
struct Actor{float position=0;};struct Controller{Actor* actor=nullptr;};
struct Mode{bool active=true;}mode;Mode* currentMode=&mode;
struct Lease{
Controller* m_Controller=nullptr;Actor* m_Actor=nullptr;float m_Position=0;Items m_Entities;
int starts=0,removes=0,released=0;void Stream(Controller*,int,bool normal){assert(normal);released++;}
'''+methods+r'''
};
int main(){
Actor actor,other;Controller controller{&actor};Lease lease;
lease.Start(&controller,&actor,0,{11,12,13});
for(int i=0;i<1000;i++){lease.Check();}assert(lease.released==0&&lease.m_Entities.size()==3);
actor.position=120;lease.Check();assert(lease.released==0);
actor.position=121;lease.Check();assert(lease.released==3&&lease.m_Entities.empty());
lease.Stop();assert(lease.released==3);
actor.position=0;lease.Start(&controller,&actor,0,{21});controller.actor=&other;lease.Check();assert(lease.released==4);
controller.actor=&actor;lease.Start(&controller,&actor,0,{22});currentMode=nullptr;lease.Check();assert(lease.released==5);currentMode=&mode;
lease.Start(&controller,&actor,0,{23});mode.active=false;lease.Check();assert(lease.released==6);mode.active=true;
lease.Start(&controller,&actor,0,{24});lease.m_Controller=nullptr;lease.Check();assert(lease.m_Entities.empty());
lease.Start(&controller,&actor,0,{25});lease.Start(&controller,&actor,0,{26});assert(lease.released==8);lease.Stop();assert(lease.released==9);
}
''')
scene = ROOT/'addons/core/Scripts/Game/ERR/00_Core/ERR_SpawnClientScene.c'
verify = body(scene, 'bool ERR_VerifyLoadedScene(')
for old, new in [('ObserversSystem observers', 'Observers* observers'),
('observers.InsertObserverMP', 'observers->InsertObserverMP'),
('GetPlayerController().GetRplIdentity()', '1'), ('null', 'nullptr'),
('Rpc(ERR_ConfirmClientSpawnScene, m_ERRSceneToken)', 'confirms++')]:
verify = verify.replace(old, new)
run(r'''
#include <cassert>
template<typename... T>void PrintFormat(const char*,T...){}
struct Observers{int calls=0;void InsertObserverMP(int,float,float,void*){calls++;}};
struct Probe{
bool m_ERRSceneTimedOut=false,m_ERRScenePreloadRequired=true,m_ERRSceneFinalRequested=false,m_ERRSceneVerified=false,ready=false,preloading=false;
int m_ERRSceneToken=1,confirms=0;float m_ERRScenePosition[3]={1,0,2};Observers observer;
bool ERR_SpawnSceneReady(){return ready;}bool IsPreloading(){return preloading;}
Observers* GetObserversSystem(){return &observer;}bool GetPlayerController(){return true;}int GetPlayerId(){return 1;}
// Releasing nodes here would break dedicated final readiness: intentionally
// no streaming mutator in this adapter. The production method must retain them.
'''+verify+r'''
};
int main(){
Probe p;assert(!p.ERR_VerifyLoadedScene());p.ready=true;assert(!p.ERR_VerifyLoadedScene());
p.m_ERRScenePreloadRequired=false;p.preloading=true;assert(!p.ERR_VerifyLoadedScene());p.preloading=false;
assert(!p.ERR_VerifyLoadedScene()&&p.confirms==1&&p.observer.calls==1);
assert(!p.ERR_VerifyLoadedScene()&&p.confirms==1);p.m_ERRSceneVerified=true;assert(p.ERR_VerifyLoadedScene());
Probe timeout;timeout.m_ERRSceneTimedOut=true;assert(timeout.ERR_VerifyLoadedScene()&&timeout.confirms==0);
}
''')
print('PASS: spawn scene remains held through final acknowledgement and nearby use; leaving, reassignment, disconnect and teardown release it')
+1
View File
@@ -11,6 +11,7 @@ def check():
subprocess.run([sys.executable,str(ROOT/'tests/test_prefab_identity.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_spawn_scene.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_spawn_scene_inventory.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_spawn_scene_lease.py')],check=True)
subprocess.run([sys.executable,str(ROOT/'tests/test_prison_ai.py')],check=True)
ids={};graph={};resources={};paths={};owners={}
for p in sorted((ROOT/'addons').glob('*/addon.gproj')):