From 197a57b28bee0ad372c799f388488e37f6f7ef0d Mon Sep 17 00:00:00 2001 From: Ukendio Date: Tue, 9 Dec 2025 20:34:05 +0100 Subject: [PATCH 01/11] Fix docs --- how_to/100_cleanup_traits.luau | 2 +- how_to/111_signals.luau | 0 src/jecs.luau | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100755 how_to/111_signals.luau diff --git a/how_to/100_cleanup_traits.luau b/how_to/100_cleanup_traits.luau index 861e7e7..9c29ebe 100755 --- a/how_to/100_cleanup_traits.luau +++ b/how_to/100_cleanup_traits.luau @@ -71,7 +71,7 @@ print(world:has(e1, T1)) Cascading deletion, dangerous. ]] -world:add(T2, pair(jecs.OnDelete, jecs.Remove)) +world:add(T2, pair(jecs.OnDelete, jecs.Delete)) local e2 = world:entity() world:add(e2, T2) diff --git a/how_to/111_signals.luau b/how_to/111_signals.luau new file mode 100755 index 0000000..e69de29 diff --git a/src/jecs.luau b/src/jecs.luau index 51cedbf..d739517 100755 --- a/src/jecs.luau +++ b/src/jecs.luau @@ -247,7 +247,7 @@ export type World = { --- Gets the target of an relationship. For example, when a user calls --- `world:target(id, ChildOf(parent), 0)`, you will obtain the parent entity. target: (self: World, id: Entity, relation: ecs_entity_t, index: number?) -> Entity?, - --- Deletes an entity and all it's related components and relationships. + --- Deletes an entity and all its related components and relationships. delete: (self: World, id: Entity) -> (), --- Adds a component to the entity with no value From b3d3a2bcdd5e9712acdd5bfcf846ccf92e9c2467 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Tue, 9 Dec 2025 20:34:05 +0100 Subject: [PATCH 02/11] Fix types --- src/jecs.luau | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/src/jecs.luau b/src/jecs.luau index d739517..4e4b266 100755 --- a/src/jecs.luau +++ b/src/jecs.luau @@ -29,7 +29,7 @@ export type QueryInner = { filter_with: { Component }, filter_without: { Component }, next: () -> (Entity, ...any), - world: World, + -- world: World, } type function ecs_entity_t(ty: type) @@ -72,7 +72,7 @@ type function ecs_id_t(first: type, second: type) return p end -export type Entity = { __T: T } +export type Entity = { __T: T } export type Id = { __T: T } export type Pair = ecs_pair_t, Entity> export type Component = { __T: T } @@ -82,15 +82,12 @@ export type Item = (self: Query) -> (Entity, T...) export type Iter = (query: Query) -> () -> (Entity, T...) export type CachedIter = (query: CachedQuery) -> () -> (Entity, T...) -type TypePack = { - __phantomdata: () -> (T...) -} +type TypePack = (T...) -> never export type CachedQuery = typeof(setmetatable( {} :: { iter: CachedIter, - archetypes: (self: CachedQuery) -> { Archetype }, - cached: (self: CachedQuery) -> CachedQuery, + archetypes: (CachedQuery) -> { Archetype }, has: (CachedQuery, Entity) -> boolean, ids: { Id }, filter_with: { Id }?, @@ -108,8 +105,8 @@ export type Query = typeof(setmetatable( iter: Iter, with: ((Query, ...Component) -> Query), without: ((Query, ...Component) -> Query), - archetypes: (self: Query) -> { Archetype }, - cached: (self: Query) -> CachedQuery, + archetypes: (Query) -> { Archetype }, + cached: (Query) -> CachedQuery, has: (Query, Entity) -> boolean, ids: { Id }, filter_with: { Id }?, @@ -246,14 +243,14 @@ export type World = { component: (self: World) -> Entity, --- Gets the target of an relationship. For example, when a user calls --- `world:target(id, ChildOf(parent), 0)`, you will obtain the parent entity. - target: (self: World, id: Entity, relation: ecs_entity_t, index: number?) -> Entity?, - --- Deletes an entity and all its related components and relationships. + target: (self: World, id: Entity, relation: ecs_entity_t, index: number?) -> Entity?, + --- Deletes an entity and all it's related components and relationships. delete: (self: World, id: Entity) -> (), --- Adds a component to the entity with no value add: ( self: World, - id: ecs_entity_t, + id: ecs_entity_t>, component: Component ) -> (), @@ -267,10 +264,10 @@ export type World = { --- Removes a component from the given entity remove: (self: World, id: Entity, component: Component) -> (), --- Retrieves the value of up to 4 components. These values may be nil. - get: & ((World, Entity, Component) -> a?) - & ((World, Entity, Component, Component) -> (a?, b?)) - & ((World, Entity, Component, Component, Component) -> (a?, b?, c?)) - & ((World, Entity, Component, Component, Component, Component) -> (a?, b?, c?, d?)), + get: & ((World, Entity | number, Component) -> a?) + & ((World, Entity | number, Component, Component) -> (a?, b?)) + & ((World, Entity | number, Component, Component, Component) -> (a?, b?, c?)) + & ((World, Entity | number, Component, Component, Component, Component) -> (a?, b?, c?, d?)), --- Returns whether the entity has the ID. has: ((World, Entity, Component) -> boolean) @@ -1704,7 +1701,7 @@ local function query_cached(query: QueryInner) local compatible_archetypes = archetypes :: { Archetype } - local world = query.world + local world = (query :: { world: World }).world -- Only need one observer for EcsArchetypeCreate and EcsArchetypeDelete respectively -- because the event will be emitted for all components of that Archetype. local observable = world.observable @@ -2128,7 +2125,7 @@ local function query_cached(query: QueryInner) end local function query_has(query: QueryInner, entity: i53) - local world = query.world :: world + local world = (query::any).world :: world local r = entity_index_try_get(world.entity_index, entity) if not r then return false @@ -3457,7 +3454,7 @@ return { archetype_append_to_records = archetype_append_to_records, id_record_ensure = id_record_ensure :: (World, Component) -> ComponentRecord, component_record = id_record_get :: (World, Component) -> ComponentRecord?, - record = ecs_entity_record :: (World, Entity) -> Record, + record = ecs_entity_record :: (World, Entity) -> Record, archetype_create = archetype_create :: (World, { Component }, string) -> Archetype, archetype_ensure = archetype_ensure :: (World, { Component }) -> Archetype, From fbc4f0f3aa0b0f5358f416e23ff10151eb5ea2fc Mon Sep 17 00:00:00 2001 From: maeriil <104389763+maeriil@users.noreply.github.com> Date: Mon, 1 Dec 2025 14:21:40 -0500 Subject: [PATCH 03/11] Add types for (Cached)Query.has (#286) * ts type update * changed to hard tabs * reverted back to old formatting for some * more fixes * only keep query has --- src/jecs.d.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/jecs.d.ts b/src/jecs.d.ts index d2cb20d..0d306a5 100755 --- a/src/jecs.d.ts +++ b/src/jecs.d.ts @@ -77,6 +77,8 @@ export type CachedQuery = { * @returns An array of archetypes of the query */ archetypes(): Archetype[]; + + has(entity: Entity): boolean; } & Iter; export type Query = { @@ -111,6 +113,8 @@ export type Query = { * @returns An array of archetypes of the query */ archetypes(): Archetype[]; + + has(entity: Entity): boolean; } & Iter; export class World { From df454c75a3338fbf606aa2faf787f5dcaaed1643 Mon Sep 17 00:00:00 2001 From: m10 <165406716+mrkboy10@users.noreply.github.com> Date: Sun, 7 Dec 2025 14:10:57 -0500 Subject: [PATCH 04/11] Add deleted flag to removed handler typings (#288) * update types * change "StatefulHook" to "HookWithData" and "StatelessHook" to "HookWithDeleted" * Update deleted flag type --- src/jecs.d.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/jecs.d.ts b/src/jecs.d.ts index 0d306a5..11e7068 100755 --- a/src/jecs.d.ts +++ b/src/jecs.d.ts @@ -172,8 +172,8 @@ export class World { * @param hook The hook to install. * @param value The hook callback. */ - set(component: Entity, hook: StatefulHook, value: (e: Entity, id: Id, data: T) => void): void; - set(component: Entity, hook: StatelessHook, value: (e: Entity, id: Id) => void): void; + set(component: Entity, hook: HookWithData, value: (e: Entity, id: Id, data: T) => void): void; + set(component: Entity, hook: HookWithDeleted, value: (e: Entity, id: Id, deleted?: true) => void): void; /** * Assigns a value to a component on the given entity. * @param entity The target entity. @@ -277,7 +277,7 @@ export class World { added(component: Entity, listener: (e: Entity, id: Id, value: T) => void): () => void; changed(component: Entity, listener: (e: Entity, id: Id, value: T) => void): () => void; - removed(component: Entity, listener: (e: Entity, id: Id) => void): () => void; + removed(component: Entity, listener: (e: Entity, id: Id, deleted?: true) => void): () => void; } export function world(): World; @@ -323,16 +323,16 @@ export function pair_second(world: World, p: Pair): Entity; export function ECS_PAIR_FIRST(pair: Pair): number; export function ECS_PAIR_SECOND(pair: Pair): number; -type StatefulHook = Entity<(e: Entity, id: Id, data: T) => void> & { - readonly __nominal_StatefulHook: unique symbol; +type HookWithData = Entity<(e: Entity, id: Id, data: T) => void> & { + readonly __nominal_HookWithData: unique symbol; }; -type StatelessHook = Entity<(e: Entity, id: Id) => void> & { - readonly __nominal_StatelessHook: unique symbol; +type HookWithDeleted = Entity<(e: Entity, id: Id, deleted?: true) => void> & { + readonly __nominal_HookWithDeleted: unique symbol; }; -export declare const OnAdd: StatefulHook; -export declare const OnRemove: StatelessHook; -export declare const OnChange: StatefulHook; +export declare const OnAdd: HookWithData; +export declare const OnRemove: HookWithDeleted; +export declare const OnChange: HookWithData; export declare const ChildOf: Tag; export declare const Component: Tag; export declare const Wildcard: Entity; From 007097b79170cb0cf94e90c8d2e1fca0e983f26b Mon Sep 17 00:00:00 2001 From: Madison Date: Wed, 10 Dec 2025 12:14:57 -0800 Subject: [PATCH 05/11] Correct Cleanup example: Change OnSet to OnAdd (#289) * Correct Cleanup example: Change OnSet to OnAdd `jecs.OnSet` no longer exists. This should be `jecs.OnAdd` instead, which does exist. * Clarify OnAdd hook in cleanup.luau Add comment to clarify OnAdd hook behavior --- examples/hooks/cleanup.luau | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/hooks/cleanup.luau b/examples/hooks/cleanup.luau index 9088ad2..d0c91bd 100755 --- a/examples/hooks/cleanup.luau +++ b/examples/hooks/cleanup.luau @@ -13,8 +13,9 @@ world:set(Model, jecs.OnRemove, function(entity) model:Destroy() end) -world:set(Model, jecs.OnSet, function(entity, model) - -- OnSet is invoked after the data has been assigned. +world:set(Model, jecs.OnAdd, function(entity, id, model) + -- OnAdd is invoked after the data has been assigned. + -- This hook only fires the first time the component is added. -- It also returns the data for faster access. -- There may be some logic to do some side effects on reassignments model:SetAttribute("entityId", entity) From 4db44476a978c86e1be5bc82c286b3aa4123741d Mon Sep 17 00:00:00 2001 From: Ukendio Date: Tue, 9 Dec 2025 20:34:05 +0100 Subject: [PATCH 06/11] Only delete archetypes when completely invalidated --- src/jecs.luau | 29 ++++++++++----------- test/tests.luau | 67 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 14 deletions(-) diff --git a/src/jecs.luau b/src/jecs.luau index 4e4b266..25dec1c 100755 --- a/src/jecs.luau +++ b/src/jecs.luau @@ -3084,17 +3084,6 @@ local function world_new() local archetype = record.archetype - if archetype then - for _, id in archetype.types do - local idr = component_index[id] - local on_remove = idr.on_remove - if on_remove then - on_remove(entity, id, true) - end - end - archetype_delete(world, record.archetype, record.row) - end - local component_index = world.component_index local archetypes = world.archetypes local tgt = ECS_PAIR(EcsWildcard, entity) @@ -3163,6 +3152,7 @@ local function world_new() local idr_t_archetype = archetypes[archetype_id] local idr_t_types = idr_t_archetype.types local entities = idr_t_archetype.entities + local will_delete_archetype = false for _, id in idr_t_types do if not ECS_IS_PAIR(id) then @@ -3173,6 +3163,7 @@ local function world_new() if object ~= entity then continue end + will_delete_archetype = true local id_record = component_index[id] local flags = id_record.flags local flags_delete_mask = bit32.btest(flags, ECS_ID_DELETE) @@ -3197,10 +3188,10 @@ local function world_new() end end end - end - for archetype_id in archetype_ids do - archetype_destroy(world, archetypes[archetype_id]) + if will_delete_archetype then + archetype_destroy(world, idr_t_archetype) + end end end @@ -3252,6 +3243,16 @@ local function world_new() end end + if archetype then + for _, id in archetype.types do + local cr = component_index[id] + local on_remove = cr.on_remove + if on_remove then + on_remove(entity, id, true) + end + end + archetype_delete(world, record.archetype, record.row) + end local dense = record.dense local i_swap = entity_index.alive_count diff --git a/test/tests.luau b/test/tests.luau index a552b93..13d8c60 100755 --- a/test/tests.luau +++ b/test/tests.luau @@ -24,6 +24,73 @@ type Id = jecs.Id local entity_visualiser = require("@modules/entity_visualiser") local dwi = entity_visualiser.stringify +FOCUS() +TEST("reproduce idr_t nil archetype bug", function() + local world = jecs.world() + + local cts = { + Humanoid = world:component(), + Animator = world:component(), + VelocitizeAnimationWeight = world:component(), + } + + local char = world:entity() + + -- REMOVING ONE OF THESE THESE OFFSETS i BY +1 + world:set(char, cts.Humanoid, 0) + world:set(char, cts.Animator, 0) + -- + + world:added(cts.Humanoid, function() end) -- REMOVING THIS OFFSETS i BY +1 TOO + world:removed(cts.Animator, function(entity, id) + local r = jecs.record(world, entity) + local src = r.archetype + + --REMOVING THIS jecs.archetype_traverse_remove CALL STOPS IT FROM HAPPENING + local dst = src and jecs.archetype_traverse_remove(world, id, src) + end) + + local batches = 10 + local batchSize = 20 + + local trackedEntities: { [number]: { parentId: number? } } = {} + + for batch = 1, batches do + for i = 1, batchSize do + local root = world:entity() + world:add(root, jecs.pair(jecs.ChildOf, char)) + + -- Removing animator from trackEntity1 causes it to stop happening + local trackEntity1 = world:entity() + world:set(trackEntity1, cts.Animator, 0) + world:add(trackEntity1, jecs.pair(jecs.ChildOf, root)) + trackedEntities[trackEntity1] = { parentId = root } + + -- Removing animator from trackEntity2 causes it to happen less frequently + local trackEntity2 = world:entity() + world:set(trackEntity2, cts.Animator, 0) + world:add(trackEntity2, jecs.pair(jecs.ChildOf, root)) + trackedEntities[trackEntity2] = { parentId = root } + + -- Removing this, but keeping Animator on the other 2 causes it to stop happening + world:set(trackEntity1, cts.VelocitizeAnimationWeight, 0) + + world:delete(root) + + for entityId, info in trackedEntities do + if world:contains(entityId) and not world:parent(entityId :: any) then + print(`bugged entity found: {entityId}`) + print(`original parent: {info.parentId}`) + print(`batch = {batch}, i = {i}`) + print("==========================================") + trackedEntities[entityId] = nil + world:deletee(entityId) + end + end + end + end +end) + TEST("Ensure archetype edges get cleaned", function() local A = jecs.component() local B = jecs.component() From 81792fe31455fd70fd6018723a7943833c8cdfee Mon Sep 17 00:00:00 2001 From: Ukendio Date: Tue, 9 Dec 2025 20:34:05 +0100 Subject: [PATCH 07/11] Revert to example --- test/benches/visual/query.bench.luau | 164 ++++++++++++++++++--------- 1 file changed, 109 insertions(+), 55 deletions(-) diff --git a/test/benches/visual/query.bench.luau b/test/benches/visual/query.bench.luau index cfe2c37..f1df0da 100755 --- a/test/benches/visual/query.bench.luau +++ b/test/benches/visual/query.bench.luau @@ -2,11 +2,32 @@ --!native local ReplicatedStorage = game:GetService("ReplicatedStorage") +local Matter = require(ReplicatedStorage.DevPackages.Matter) +local ecr = require(ReplicatedStorage.DevPackages.ecr) +local newWorld = Matter.World.new() local jecs = require(ReplicatedStorage.Lib:Clone()) -local chrono = require(ReplicatedStorage.chronoecs:Clone()) +local mirror = require(ReplicatedStorage.mirror:Clone()) +local mcs = mirror.world() local ecs = jecs.world() -local ccs = chrono.new() + +local A1 = Matter.component() +local A2 = Matter.component() +local A3 = Matter.component() +local A4 = Matter.component() +local A5 = Matter.component() +local A6 = Matter.component() +local A7 = Matter.component() +local A8 = Matter.component() + +local B1 = ecr.component() +local B2 = ecr.component() +local B3 = ecr.component() +local B4 = ecr.component() +local B5 = ecr.component() +local B6 = ecr.component() +local B7 = ecr.component() +local B8 = ecr.component() local D1 = ecs:component() local D2 = ecs:component() @@ -17,74 +38,107 @@ local D6 = ecs:component() local D7 = ecs:component() local D8 = ecs:component() -local E1 = ccs:component() -local E2 = ccs:component() -local E3 = ccs:component() -local E4 = ccs:component() -local E5 = ccs:component() -local E6 = ccs:component() -local E7 = ccs:component() -local E8 = ccs:component() +local E1 = mcs:component() +local E2 = mcs:component() +local E3 = mcs:component() +local E4 = mcs:component() +local E5 = mcs:component() +local E6 = mcs:component() +local E7 = mcs:component() +local E8 = mcs:component() -local d_components = {} -local e_components = {} - -for i = 1, 150 do - ecs:component() - ccs:component() -end +local registry2 = ecr.registry() local function flip() - return math.random() >= 0.5 + return math.random() >= 0.25 end local N = 2 ^ 16 - 2 +local archetypes = {} +local hm = 0 for i = 1, N do + local id = registry2.create() + local combination = "" + local n = newWorld:spawn() local entity = ecs:entity() - local m = ccs:entity() + local m = mcs:entity() if flip() then - ecs:set(entity, D1, true) - ccs:add(m, E1) - ccs:set(m, E1, true) + registry2:set(id, B1, { value = true }) + ecs:set(entity, D1, { value = true }) + newWorld:insert(n, A1({ value = true })) + mcs:set(m, E1, { value = 2 }) end if flip() then - ecs:set(entity, D2, true) - ccs:add(m, E2) - ccs:set(m, E2, true) + combination ..= "B" + registry2:set(id, B2, { value = true }) + ecs:set(entity, D2, { value = true }) + mcs:set(m, E2, { value = 2 }) + newWorld:insert(n, A2({ value = true })) end if flip() then - ecs:set(entity, D3, true) - ccs:add(m, E3) - ccs:set(m, E3, true) + combination ..= "C" + registry2:set(id, B3, { value = true }) + ecs:set(entity, D3, { value = true }) + mcs:set(m, E3, { value = 2 }) + newWorld:insert(n, A3({ value = true })) end if flip() then - ecs:set(entity, D4, true) - ccs:add(m, E4) - ccs:set(m, E4, true) + combination ..= "D" + registry2:set(id, B4, { value = true }) + ecs:set(entity, D4, { value = true }) + mcs:set(m, E4, { value = 2 }) + + newWorld:insert(n, A4({ value = true })) end if flip() then - ecs:set(entity, D5, true) - ccs:add(m, E4) - ccs:set(m, E5, true) + combination ..= "E" + registry2:set(id, B5, { value = true }) + ecs:set(entity, D5, { value = true }) + mcs:set(m, E5, { value = 2 }) + + newWorld:insert(n, A5({ value = true })) end if flip() then - ecs:set(entity, D6, true) - ccs:add(m, E6) - ccs:set(m, E6, true) + combination ..= "F" + registry2:set(id, B6, { value = true }) + ecs:set(entity, D6, { value = true }) + mcs:set(m, E6, { value = 2 }) + newWorld:insert(n, A6({ value = true })) end if flip() then - ecs:set(entity, D7, true) - ccs:add(m, E7) - ccs:set(m, E7, true) + combination ..= "G" + registry2:set(id, B7, { value = true }) + ecs:set(entity, D7, { value = true }) + mcs:set(m, E7, { value = 2 }) + newWorld:insert(n, A7({ value = true })) end if flip() then - ccs:add(m, E8) - ecs:set(entity, D8, true) - ccs:set(m, E8, true) + combination ..= "H" + registry2:set(id, B8, { value = true }) + newWorld:insert(n, A8({ value = true })) + ecs:set(entity, D8, { value = true }) + mcs:set(m, E8, { value = 2 }) end + + if combination:find("BCDF") then + if not archetypes[combination] then + print(combination) + end + hm += 1 + end + archetypes[combination] = true end +print("TEST", hm) + +local count = 0 + +for _, archetype in ecs:query(D2, D4, D6, D8):archetypes() do + count += #archetype.entities +end + +print(count) return { ParameterGenerator = function() @@ -92,21 +146,21 @@ return { end, Functions = { - -- Matter = function() - -- for entityId, firstComponent in newWorld:query(A2, A4, A6, A8) do - -- end - -- end, - - -- ECR = function() - -- for entityId, firstComponent in registry2:view(B2, B4, B6, B8) do - -- end - -- end, - -- - chrono = function() - for entityId, firstComponent in ccs:view(E2, E4, E6, E8) do + Matter = function() + for entityId, firstComponent in newWorld:query(A2, A4, A6, A8) do end end, + ECR = function() + for entityId, firstComponent in registry2:view(B2, B4, B6, B8) do + end + end, + + -- Mirror = function() + -- for entityId, firstComponent in mcs:query(E2, E4, E6, E8) do + -- end + -- end, + Jecs = function() for entityId, firstComponent in ecs:query(D2, D4, D6, D8) do end From f543c0646259dcba5801cf201bb059b46e1e3da9 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 21 Dec 2025 17:04:15 +0100 Subject: [PATCH 08/11] Fix invalidated archetype before invoking onremove hooks --- src/jecs.luau | 21 +++++++++++---------- test/tests.luau | 23 ++++++++++++++++++++++- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/src/jecs.luau b/src/jecs.luau index 25dec1c..108950c 100755 --- a/src/jecs.luau +++ b/src/jecs.luau @@ -3083,6 +3083,16 @@ local function world_new() end local archetype = record.archetype + if archetype then + for _, id in archetype.types do + local cr = component_index[id] + local on_remove = cr.on_remove + if on_remove then + on_remove(entity, id, true) + end + end + archetype_delete(world, record.archetype, record.row) + end local component_index = world.component_index local archetypes = world.archetypes @@ -3243,16 +3253,7 @@ local function world_new() end end - if archetype then - for _, id in archetype.types do - local cr = component_index[id] - local on_remove = cr.on_remove - if on_remove then - on_remove(entity, id, true) - end - end - archetype_delete(world, record.archetype, record.row) - end + local dense = record.dense local i_swap = entity_index.alive_count diff --git a/test/tests.luau b/test/tests.luau index 13d8c60..a7897d3 100755 --- a/test/tests.luau +++ b/test/tests.luau @@ -24,7 +24,28 @@ type Id = jecs.Id local entity_visualiser = require("@modules/entity_visualiser") local dwi = entity_visualiser.stringify -FOCUS() +TEST("deleting t1's archetype before invoking its onremove hooks", function() + local pair = jecs.pair + local world = jecs.world() + local rel = world:component() + + local t1 = world:entity() + local t2 = world:entity() + + --[[ + weirdly enough if i do this (only when adding childof relation after adding (rel, t2) to t1) it does not error. Probably a red herring + + world:add(t2, pair(rel, t1)) + world:add(t1, pair(rel, t2)) + world:add(t2, pair(jecs.ChildOf, t1)) + --]] + + -- this causes world:delete to error + world:add(t2, pair(jecs.ChildOf, t1)) + world:add(t1, pair(rel, t2)) + + world:delete(t1) +end) TEST("reproduce idr_t nil archetype bug", function() local world = jecs.world() From 3ebb2334daeb4b8e7049c97ec6356750dff57050 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 21 Dec 2025 17:55:12 +0100 Subject: [PATCH 09/11] Should be able to delete every archetype that is iterated --- src/jecs.luau | 36 ++++++++++++++++++++++++++++-------- test/tests.luau | 4 +++- 2 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/jecs.luau b/src/jecs.luau index 108950c..2045474 100755 --- a/src/jecs.luau +++ b/src/jecs.luau @@ -3084,6 +3084,9 @@ local function world_new() local archetype = record.archetype if archetype then + -- NOTE(marcus): It is important to remove the data and invoke + -- the hooks before the archetype and certain component records are + -- invalidated or else it will have a nasty runtime error. for _, id in archetype.types do local cr = component_index[id] local on_remove = cr.on_remove @@ -3103,6 +3106,29 @@ local function world_new() local idr = component_index[entity] local idr_r = component_index[rel] + --[[ + It is important to note that `world_delete` uses a depth-first + traversal that prunes the children of the entity before their + parents. archetypes can be destroyed and removed from component + records while we're still iterating over those records. The + recursive nature of this function entails that archetype ids can be + removed from component records (idr_t.records, idr.records and + idr_r.records) while that collection is still being iterated over. + If we try to look up an archetype by ID after it has been destroyed, + we get nil. This is hard to debug because the removal happens deep + in the opaque call stack. Essentially the entry is removed on a + first come first serve basis. + + The solution is to hold onto the borrowed references of the + archetypes in the lexical scope and make assumptions that the data + itself is not invalidated until `archetype_destroy` is called on it. + This is done since it is the only efficient way of doing it, however + we must veer on the edge of incorrectness. This is mostly acceptable + because it is not generally observable in the user-land but it has + caused subtle when something goes wrong. + + - Marcus + ]] if idr then local flags = idr.flags if (bit32.btest(flags, ECS_ID_DELETE) == true) then @@ -3162,7 +3188,6 @@ local function world_new() local idr_t_archetype = archetypes[archetype_id] local idr_t_types = idr_t_archetype.types local entities = idr_t_archetype.entities - local will_delete_archetype = false for _, id in idr_t_types do if not ECS_IS_PAIR(id) then @@ -3173,7 +3198,6 @@ local function world_new() if object ~= entity then continue end - will_delete_archetype = true local id_record = component_index[id] local flags = id_record.flags local flags_delete_mask = bit32.btest(flags, ECS_ID_DELETE) @@ -3199,9 +3223,7 @@ local function world_new() end end - if will_delete_archetype then - archetype_destroy(world, idr_t_archetype) - end + archetype_destroy(world, idr_t_archetype) end end @@ -3245,10 +3267,8 @@ local function world_new() local r = entity_index_try_get_unsafe(e) :: record inner_entity_move(e, r, node) end - end - for archetype_id in archetype_ids do - archetype_destroy(world, archetypes[archetype_id]) + archetype_destroy(world, idr_r_archetype) end end end diff --git a/test/tests.luau b/test/tests.luau index a7897d3..b4d6c55 100755 --- a/test/tests.luau +++ b/test/tests.luau @@ -96,7 +96,9 @@ TEST("reproduce idr_t nil archetype bug", function() -- Removing this, but keeping Animator on the other 2 causes it to stop happening world:set(trackEntity1, cts.VelocitizeAnimationWeight, 0) + print("delete root") world:delete(root) + print("---") for entityId, info in trackedEntities do if world:contains(entityId) and not world:parent(entityId :: any) then @@ -105,7 +107,7 @@ TEST("reproduce idr_t nil archetype bug", function() print(`batch = {batch}, i = {i}`) print("==========================================") trackedEntities[entityId] = nil - world:deletee(entityId) + world:delete(entityId) end end end From aa63051db3eef6f883dfdbe3e7c65ff4423dfd05 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 21 Dec 2025 19:21:42 +0100 Subject: [PATCH 10/11] Optimize idr_r removal by 35% --- src/jecs.luau | 15 +++++++++++-- test/tests.luau | 57 +++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 6 deletions(-) diff --git a/src/jecs.luau b/src/jecs.luau index 2045474..a3faf79 100755 --- a/src/jecs.luau +++ b/src/jecs.luau @@ -3246,22 +3246,33 @@ local function world_new() local records = idr_r.records for archetype_id in archetype_ids do local idr_r_archetype = archetypes[archetype_id] - local node = idr_r_archetype + -- local node = idr_r_archetype local entities = idr_r_archetype.entities local tr = records[archetype_id] local tr_count = counts[archetype_id] local idr_r_types = idr_r_archetype.types + local dst = table.clone(idr_r_types) for i = tr, tr + tr_count - 1 do local id = idr_r_types[i] - node = archetype_traverse_remove(world, id, node) + local at = table.find(dst, id) + if at then + table.remove(dst, at) + end + -- node = archetype_traverse_remove(world, id, node) local on_remove = component_index[id].on_remove if on_remove then + -- NOTE(marcus): Since hooks can move the entities + -- assumptions about which archetype it jumps to is + -- diminished. We assume that people who delete + -- relation will never have hooks on them. for _, entity in entities do on_remove(entity, id) end end end + local node = archetype_ensure(world, dst) + for i = #entities, 1, -1 do local e = entities[i] local r = entity_index_try_get_unsafe(e) :: record diff --git a/test/tests.luau b/test/tests.luau index b4d6c55..5133342 100755 --- a/test/tests.luau +++ b/test/tests.luau @@ -24,6 +24,59 @@ type Id = jecs.Id local entity_visualiser = require("@modules/entity_visualiser") local dwi = entity_visualiser.stringify +TEST("optimize idr_r removal", function() + + local pair = jecs.pair + local world = jecs.world() + local rel = world:component() + local A = world:component() + local B = world:component() + + local t1 = world:entity() + local t2 = world:entity() + + local entities = {} :: { jecs.Entity } + + for i = 1, 10 do + + local e1 = world:entity() + local e2 = world:entity() + + world:set(e1, A, true) + world:set(e2, A, true) + world:add(e1, pair(B, t1)) + world:add(e1, pair(B, t2)) + world:add(e2, pair(B, t1)) + world:add(e2, pair(B, t2)) + + table.insert(entities, e1) + table.insert(entities, e2) + end + + local e1 = world:entity() + local e2 = world:entity() + + table.insert(entities, e1) + table.insert(entities, e2) + + world:set(e1, A, true) + world:set(e2, A, true) + world:add(e1, pair(B, t1)) + world:add(e1, pair(B, t2)) + world:add(e2, pair(B, t1)) + world:add(e2, pair(B, t2)) + + BENCH("delete B", function() + world:delete(B) + end) + + for _, e in entities do + CHECK(world:has(e, A)) + CHECK(not world:target(e, B)) + CHECK(not world:target(e, B)) + end + +end) TEST("deleting t1's archetype before invoking its onremove hooks", function() local pair = jecs.pair local world = jecs.world() @@ -96,10 +149,6 @@ TEST("reproduce idr_t nil archetype bug", function() -- Removing this, but keeping Animator on the other 2 causes it to stop happening world:set(trackEntity1, cts.VelocitizeAnimationWeight, 0) - print("delete root") - world:delete(root) - print("---") - for entityId, info in trackedEntities do if world:contains(entityId) and not world:parent(entityId :: any) then print(`bugged entity found: {entityId}`) From e4d0fb447de3e82b0a63fd78aeed2edfb627a284 Mon Sep 17 00:00:00 2001 From: Ukendio Date: Sun, 21 Dec 2025 20:30:55 +0100 Subject: [PATCH 11/11] Handle recursive race condition --- src/jecs.luau | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/jecs.luau b/src/jecs.luau index a3faf79..fef3e92 100755 --- a/src/jecs.luau +++ b/src/jecs.luau @@ -3119,13 +3119,12 @@ local function world_new() in the opaque call stack. Essentially the entry is removed on a first come first serve basis. - The solution is to hold onto the borrowed references of the - archetypes in the lexical scope and make assumptions that the data - itself is not invalidated until `archetype_destroy` is called on it. - This is done since it is the only efficient way of doing it, however - we must veer on the edge of incorrectness. This is mostly acceptable - because it is not generally observable in the user-land but it has - caused subtle when something goes wrong. + The solution is to separate processing from cleanup. We first iterate + over the archetypes to process entities (move them, call hooks, etc.), + but do not destroy the archetypes yet. Then we iterate again to destroy + the archetypes, but check if they still exist (archetypes[archetype_id] + is not nil) before destroying. This handles the case where recursive + world_delete calls have already destroyed some archetypes. - Marcus ]] @@ -3223,7 +3222,12 @@ local function world_new() end end - archetype_destroy(world, idr_t_archetype) + end + for archetype_id in archetype_ids do + local idr_t_archetype = archetypes[archetype_id] + if idr_t_archetype then + archetype_destroy(world, idr_t_archetype) + end end end