feat(ecs): #80 entity-pool stats (Pool.live/free/reserved/capacity)
Ludic's ECS is already pool-based — the allocator recycles freed entity slots through a freelist (L_alloc pops @L_freen before growing @L_entc), and component storage is fixed per-entity arrays, so spawn/despawn churn (bullet-hell/horde) does no per-spawn heap allocation and cannot fragment. Expose that with a Pool.* namespace so a game can watch reuse: Pool.live (alive now), Pool.free (recycled slots waiting), Pool.reserved (high-water — stays flat across a steady spawn/despawn loop, proving reuse not reallocation), Pool.capacity (the fixed cap). Zero-cost inline reads of the existing counters. Example pool.ludic proves the key property: after despawn+respawn, Pool.reserved() stays 3 (freed slot reused) — prints 0 0 3 3 0 2 1 3 0 3 1. 4 docs pages. Full suite 118/0, goldens byte-identical, fixpoint holds. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
f2cb3cd7e8
commit
347352cc4c
10 changed files with 25580 additions and 25382 deletions
3
changes/entity-pool-stats.md
Normal file
3
changes/entity-pool-stats.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
Entity-pool stats (#80). Ludic's ECS is already pool-based: the allocator recycles freed entity slots through a freelist (a `despawn`ed slot is reused by the next `spawn` before any new slot is taken), and component storage is fixed per-entity arrays — so spawning and despawning many entities per frame (bullet-hell / horde) does **no per-spawn heap allocation** and cannot fragment. Exposes that with a `Pool.*` namespace so a game can watch the reuse and budget against the cap: `Pool.live()` (entities alive now), `Pool.free()` (freed slots waiting to be reused), `Pool.reserved()` (high-water — slots ever allocated; stays flat across a steady spawn/despawn loop, the proof that slots are pooled not reallocated), and `Pool.capacity()` (the fixed entity cap). Zero-cost — they read the existing allocator counters inline. Example: `examples/library/pool.ludic`.
|
||||
7
docs/language/pool/_section.md
Normal file
7
docs/language/pool/_section.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: pool
|
||||
title: Pool
|
||||
order: 35
|
||||
---
|
||||
|
||||
Entity-pool statistics. Ludic's ECS is already pool-based: the allocator recycles freed entity slots through a freelist (a <a href="../structure/kw-model"><code>despawn</code></a>ed slot is reused by the next <code>spawn</code> before any new slot is taken), and component storage is fixed per-entity arrays — so spawning and despawning many entities per frame does no per-spawn heap allocation and cannot fragment. <a href="pool-live"><code>Pool.live</code></a> / <a href="pool-free"><code>Pool.free</code></a> / <a href="pool-reserved"><code>Pool.reserved</code></a> / <a href="pool-capacity"><code>Pool.capacity</code></a> read those counters so a bullet-hell or horde game can watch reuse and budget against the cap.
|
||||
14
docs/language/pool/pool-capacity.md
Normal file
14
docs/language/pool/pool-capacity.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
id: pool-capacity
|
||||
name: Pool.capacity
|
||||
category: pool
|
||||
kind: namespace-method
|
||||
tokens: Pool.capacity
|
||||
sig: Pool.capacity() -> int
|
||||
tip: The maximum number of entities.
|
||||
order: 4
|
||||
ns: Pool
|
||||
member: capacity
|
||||
---
|
||||
|
||||
Returns the maximum number of live entities (the fixed entity-array size). Budget spawns against it — a horde/bullet-hell game keeps <a href="pool-live"><code>Pool.live</code></a> under <code>Pool.capacity()</code>.
|
||||
14
docs/language/pool/pool-free.md
Normal file
14
docs/language/pool/pool-free.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
id: pool-free
|
||||
name: Pool.free
|
||||
category: pool
|
||||
kind: namespace-method
|
||||
tokens: Pool.free
|
||||
sig: Pool.free() -> int
|
||||
tip: How many freed slots are waiting to be reused.
|
||||
order: 2
|
||||
ns: Pool
|
||||
member: free
|
||||
---
|
||||
|
||||
Returns how many freed entity slots are on the freelist, waiting to be reused by the next <code>spawn</code>. This is the pool depth — despawning bullets/enemies raises it, and the next wave draws them back down with no allocation.
|
||||
14
docs/language/pool/pool-live.md
Normal file
14
docs/language/pool/pool-live.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
id: pool-live
|
||||
name: Pool.live
|
||||
category: pool
|
||||
kind: namespace-method
|
||||
tokens: Pool.live
|
||||
sig: Pool.live() -> int
|
||||
tip: How many entities are currently alive.
|
||||
order: 1
|
||||
ns: Pool
|
||||
member: live
|
||||
---
|
||||
|
||||
Returns the number of entities currently alive (reserved minus free). Despawning drops it and spawning raises it; a recycled slot does not change <a href="pool-reserved"><code>Pool.reserved</code></a>.
|
||||
14
docs/language/pool/pool-reserved.md
Normal file
14
docs/language/pool/pool-reserved.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
---
|
||||
id: pool-reserved
|
||||
name: Pool.reserved
|
||||
category: pool
|
||||
kind: namespace-method
|
||||
tokens: Pool.reserved
|
||||
sig: Pool.reserved() -> int
|
||||
tip: High-water: how many slots have ever been allocated.
|
||||
order: 3
|
||||
ns: Pool
|
||||
member: reserved
|
||||
---
|
||||
|
||||
Returns the high-water mark — how many entity slots have ever been allocated. It only grows when a <code>spawn</code> finds the freelist empty; a spawn that reuses a freed slot leaves it unchanged, so a steady-state spawn/despawn loop keeps <code>reserved</code> flat (the proof that slots are pooled, not reallocated).
|
||||
39
examples/library/pool.ludic
Normal file
39
examples/library/pool.ludic
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# pool.ludic — entity pooling stats (#80). Ludic's ECS already recycles freed
|
||||
# entity slots through a freelist (a despawned slot is reused by the next spawn
|
||||
# before any new slot is taken), so bullet-hell / horde spawn+despawn does no
|
||||
# per-spawn allocation and cannot fragment. Pool.* exposes the counters so a game
|
||||
# can watch the reuse. The key proof: after despawn + respawn, Pool.reserved()
|
||||
# does NOT grow — the freed slot was reused, not reallocated.
|
||||
#
|
||||
# Deterministic; a full run prints: 0 0 3 3 0 2 1 3 0 3 1
|
||||
program PoolDemo {
|
||||
property Mob { hp: int = 0 }
|
||||
model M { Mob }
|
||||
|
||||
function bi(b: bool) -> int { if b { return 1 }; return 0 }
|
||||
|
||||
entry {
|
||||
print(Pool.reserved()) # 0 — nothing allocated yet
|
||||
print(Pool.live()) # 0
|
||||
|
||||
spawn M { Mob { hp: 1 } }
|
||||
spawn M { Mob { hp: 2 } }
|
||||
spawn M { Mob { hp: 3 } }
|
||||
print(Pool.reserved()) # 3
|
||||
print(Pool.live()) # 3
|
||||
print(Pool.free()) # 0 — no freed slots yet
|
||||
|
||||
let pm = World.prop_id("Mob")
|
||||
let e = World.query_next(pm, 0)
|
||||
despawn e
|
||||
print(Pool.live()) # 2
|
||||
print(Pool.free()) # 1 — one slot recycled onto the freelist
|
||||
|
||||
spawn M { Mob { hp: 4 } } # reuses the freed slot
|
||||
print(Pool.reserved()) # 3 — NO new slot allocated (pooled, not reallocated)
|
||||
print(Pool.free()) # 0 — the freelist slot was drawn back down
|
||||
print(Pool.live()) # 3
|
||||
|
||||
print(bi(Pool.capacity() >= 3)) # 1 — there is a fixed entity cap to budget against
|
||||
}
|
||||
}
|
||||
|
|
@ -148,6 +148,21 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
|
|||
if is_clock_ns(meth) { return emit_clock_ns(meth, e) }
|
||||
perr(`unknown builtin Clock.{meth}`)
|
||||
}
|
||||
# #80 — entity pool stats. The ECS allocator already recycles freed entity slots
|
||||
# through a freelist (L_alloc pops @L_freen before growing @L_entc), and component
|
||||
# storage is fixed per-entity arrays — so there is no per-spawn heap allocation or
|
||||
# fragmentation. Pool.* just reads those counters so a game can watch reuse.
|
||||
if (ns == "Pool") {
|
||||
if (meth == "capacity") { return val(itoa(MAX_ENT), "int") } # max entities
|
||||
if (meth == "reserved") { return val(emit_bind("load i32, ptr @L_entc"), "int") } # slots ever allocated (high-water)
|
||||
if (meth == "free") { return val(emit_bind("load i32, ptr @L_freen"), "int") } # recycled slots ready for reuse
|
||||
if (meth == "live") { # currently alive = reserved - free
|
||||
let ec = emit_bind("load i32, ptr @L_entc")
|
||||
let fr = emit_bind("load i32, ptr @L_freen")
|
||||
return val(emit_bind(`sub i32 {ec}, {fr}`), "int")
|
||||
}
|
||||
perr(`unknown builtin Pool.{meth}`)
|
||||
}
|
||||
var bare: pointer = null
|
||||
let labels = new []pointer
|
||||
if (ns == "Screen") {
|
||||
|
|
|
|||
50841
selfhost/ludicc.seed.ll
50841
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
|
|
@ -243,6 +243,7 @@ function cmd_test() -> int {
|
|||
feat_case("library/anim_sugar", "", "4 8 2 1 0 100 100 0 0 1 20 20 30 0 1", "anim_sugar.ludic (Anim.clip/play/on_frame/fired + Motion.to + fluent Tween.to/chain/delay/parallel handles; issue #48)")
|
||||
feat_case("library/namespace_block", "", "15 42 100", "namespace_block.ludic (#76 namespace Name { export/internal function } block — declares the namespace once, controls the public surface)")
|
||||
feat_case("library/preload", "", "3 0 0 0 1 33 66 1 100 1", "preload.ludic (#82 Assets.enqueue/pump/progress/ready — incremental asset preload for a loading scene)")
|
||||
feat_case("library/pool", "", "0 0 3 3 0 2 1 3 0 3 1", "pool.ludic (#80 Pool.live/free/reserved/capacity — the ECS freelist recycles despawned slots, reserved stays flat)")
|
||||
feat_case("library/atlas", "", "16 16 32 48 1 1 1 16 1", "atlas.ludic (#81 Sprite.sheet/cell/cell_span/define/named + Assets.image/get — namespaced spritesheet/atlas with multi-cell sprites)")
|
||||
feat_case("library/camera_zoom", "", "1 0 0 1 1", "camera_zoom.ludic (Camera.zoom deterministic Q16.16 render-time zoom about the screen centre, verified by pixel readback; issue #78)")
|
||||
feat_case("library/clear_color", "q", "1 1", "clear_color.ludic (@ClearColor: the Render phase auto-clears to the declared colour + auto-presents, no Screen.clear/show in the handler; issue #86)")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue