feat(stdlib): Jobs, Promises & opt-in Sync concurrency (#14)
A layered concurrency library, safe by default. The recommended tier is Job.* / Promise.*: a Job is a future — Job.run(kind, arg) starts a cooperative background compute that advances each Job.pump(budget) and finishes after enough frames (heavy work spreads out instead of hitching), or Job.defer + Job.fulfill/fail/cancel drives one by hand. Poll with done/ok/failed/cancelled, read result/error, count outstanding work with Job.pending. Promise.all/race combine handle lists into a group job resolved on the main thread; Promise.count_done/all_done power a loading bar. The advanced, opt-in Sync.* tier (mutex/atomic/channel + cpu_count) is the "here be dragons" surface for engine-level message passing. The whole thing is a deterministic cooperative scheduler: results are collected on the main thread and a Job never touches the ECS world, so lockstep and replays stay bit-exact — same jobs + same budget reproduce byte-for-byte on every target, and a preemptive OS-thread backend can slot behind this same API later. Ludic has no closures, so a Job carries a compute kind + int arg (or a hand-driven defer) rather than fn()->…, and Promise progress is polled rather than chained through then. Written in Ludic and spliced on demand (like Regex/Dict/Numeric): a program that never mentions Job.*/Promise.*/Sync.* compiles byte-identically and the C-free bootstrap fixpoint is untouched. New: runtime/native/jobs.ludic, emit_ns_call dispatch, parse-time splice, examples/library/jobs.ludic (31 self-asserting checks), 33 docs pages + inventory, changeset. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
872f458cb2
commit
50ecb8472f
44 changed files with 25637 additions and 22806 deletions
3
changes/jobs-concurrency.md
Normal file
3
changes/jobs-concurrency.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
bump: minor
|
||||
type: feat
|
||||
Jobs, Promises & opt-in Sync — a layered concurrency library (#14). The safe default is `Job.*` / `Promise.*`: a `Job` is a future — start background work with `Job.run(kind, arg)` (a cooperative compute that advances a little each `Job.pump(budget)` and finishes after enough frames, so heavy work spreads out instead of hitching one), or drive one by hand with `Job.defer` + `Job.fulfill` / `Job.fail` / `Job.cancel`; poll it with `done`/`ok`/`failed`/`cancelled`, read `result`/`error`, and count outstanding work with `Job.pending`. `Promise.all`/`Promise.race` combine handle lists into a group job resolved on the main thread, and `Promise.count_done`/`Promise.all_done` give a loading bar its numerator and its ready check. The advanced, opt-in `Sync.*` tier — `mutex`/`lock`/`unlock`/`try_lock`, an atomic counter (`atomic`/`get`/`set`/`add`/`cas`) and a bounded int `channel` (`send`/`recv`/`can_recv`/`len`), plus `cpu_count` — is the "here be dragons" surface for engine-level message passing. The whole thing is a deterministic cooperative scheduler: results are collected on the main thread at a point you choose and a Job never touches the ECS world directly, so lockstep networking and replays stay bit-exact — the same jobs and the same budget reproduce byte-for-byte on every target, and a preemptive OS-thread backend can slot behind this same API later. Ludic has no first-class functions, so a Job carries a compute kind + int argument (or a hand-driven `defer`) rather than a closure, and Promise progress is polled rather than chained through a `then`. Written in Ludic and spliced on demand (like Regex/Dict/Numeric), so a program that never mentions `Job.*`/`Promise.*`/`Sync.*` compiles byte-identically and the C-free bootstrap fixpoint is untouched.
|
||||
7
docs/language/job/_section.md
Normal file
7
docs/language/job/_section.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: job
|
||||
title: Job
|
||||
order: 37
|
||||
---
|
||||
|
||||
Background work that stays out of the frame. A <code>Job</code> is a future — a handle to a result that lands later. Kick one off with <code>Job.run</code> (a background compute that advances a little each <code>Job.pump</code> and finishes after enough frames, so heavy work never hitches) or <code>Job.defer</code> (a future you resolve yourself with <code>Job.fulfill</code> / <code>Job.fail</code>). Poll it with <code>done</code> / <code>ok</code> / <code>failed</code> / <code>cancelled</code>, read <code>result</code> / <code>error</code>, and always collect on the main thread — a Job must never touch the ECS world directly. The scheduler is deterministic and cooperative, so the same jobs and the same budget reproduce byte-for-byte, every run and every target. Arguments are positional. Spliced in only when a program mentions <code>Job.*</code>.
|
||||
19
docs/language/job/job-cancel.md
Normal file
19
docs/language/job/job-cancel.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: job-cancel
|
||||
name: Job.cancel
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.cancel
|
||||
sig: Job.cancel(handle) -> void
|
||||
tip: Cancel a job before it finishes.
|
||||
order: 4
|
||||
ns: Job
|
||||
member: cancel
|
||||
---
|
||||
|
||||
Cancel a job before it finishes.
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.cancel(job)
|
||||
```
|
||||
20
docs/language/job/job-cancelled.md
Normal file
20
docs/language/job/job-cancelled.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: job-cancelled
|
||||
name: Job.cancelled
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.cancelled
|
||||
sig: Job.cancelled(handle) -> bool
|
||||
tip: Was the job cancelled?
|
||||
order: 9
|
||||
ns: Job
|
||||
member: cancelled
|
||||
---
|
||||
|
||||
Was the job cancelled?
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.cancel(job)
|
||||
let stopped = Job.cancelled(job)
|
||||
```
|
||||
19
docs/language/job/job-defer.md
Normal file
19
docs/language/job/job-defer.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: job-defer
|
||||
name: Job.defer
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.defer
|
||||
sig: Job.defer() -> Job
|
||||
tip: A future you resolve yourself later.
|
||||
order: 0
|
||||
ns: Job
|
||||
member: defer
|
||||
---
|
||||
|
||||
A future you resolve yourself later.
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.fulfill(job, 42)
|
||||
```
|
||||
20
docs/language/job/job-done.md
Normal file
20
docs/language/job/job-done.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: job-done
|
||||
name: Job.done
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.done
|
||||
sig: Job.done(handle) -> bool
|
||||
tip: Has the job resolved (any outcome)?
|
||||
order: 6
|
||||
ns: Job
|
||||
member: done
|
||||
---
|
||||
|
||||
Has the job resolved (any outcome)?
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.fulfill(job, 1)
|
||||
let finished = Job.done(job)
|
||||
```
|
||||
20
docs/language/job/job-error.md
Normal file
20
docs/language/job/job-error.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: job-error
|
||||
name: Job.error
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.error
|
||||
sig: Job.error(handle) -> int
|
||||
tip: The error code of a failed job.
|
||||
order: 11
|
||||
ns: Job
|
||||
member: error
|
||||
---
|
||||
|
||||
The error code of a failed job.
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.fail(job, 500)
|
||||
let code = Job.error(job)
|
||||
```
|
||||
19
docs/language/job/job-fail.md
Normal file
19
docs/language/job/job-fail.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: job-fail
|
||||
name: Job.fail
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.fail
|
||||
sig: Job.fail(handle, error) -> void
|
||||
tip: Resolve a pending job as failed.
|
||||
order: 3
|
||||
ns: Job
|
||||
member: fail
|
||||
---
|
||||
|
||||
Resolve a pending job as failed.
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.fail(job, 404)
|
||||
```
|
||||
20
docs/language/job/job-failed.md
Normal file
20
docs/language/job/job-failed.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: job-failed
|
||||
name: Job.failed
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.failed
|
||||
sig: Job.failed(handle) -> bool
|
||||
tip: Did the job fail?
|
||||
order: 8
|
||||
ns: Job
|
||||
member: failed
|
||||
---
|
||||
|
||||
Did the job fail?
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.fail(job, 9)
|
||||
let bad = Job.failed(job)
|
||||
```
|
||||
20
docs/language/job/job-free.md
Normal file
20
docs/language/job/job-free.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: job-free
|
||||
name: Job.free
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.free
|
||||
sig: Job.free(handle) -> void
|
||||
tip: Release a job slot back to the pool.
|
||||
order: 13
|
||||
ns: Job
|
||||
member: free
|
||||
---
|
||||
|
||||
Release a job slot back to the pool.
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.fulfill(job, 1)
|
||||
Job.free(job)
|
||||
```
|
||||
19
docs/language/job/job-fulfill.md
Normal file
19
docs/language/job/job-fulfill.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: job-fulfill
|
||||
name: Job.fulfill
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.fulfill
|
||||
sig: Job.fulfill(handle, value) -> void
|
||||
tip: Resolve a pending job with a value.
|
||||
order: 2
|
||||
ns: Job
|
||||
member: fulfill
|
||||
---
|
||||
|
||||
Resolve a pending job with a value.
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.fulfill(job, 7)
|
||||
```
|
||||
20
docs/language/job/job-ok.md
Normal file
20
docs/language/job/job-ok.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: job-ok
|
||||
name: Job.ok
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.ok
|
||||
sig: Job.ok(handle) -> bool
|
||||
tip: Did the job succeed?
|
||||
order: 7
|
||||
ns: Job
|
||||
member: ok
|
||||
---
|
||||
|
||||
Did the job succeed?
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.fulfill(job, 1)
|
||||
let good = Job.ok(job)
|
||||
```
|
||||
18
docs/language/job/job-pending.md
Normal file
18
docs/language/job/job-pending.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
id: job-pending
|
||||
name: Job.pending
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.pending
|
||||
sig: Job.pending() -> int
|
||||
tip: How many jobs are still unresolved.
|
||||
order: 12
|
||||
ns: Job
|
||||
member: pending
|
||||
---
|
||||
|
||||
How many jobs are still unresolved.
|
||||
|
||||
```ludic
|
||||
let left = Job.pending() # a ready-made loading-screen counter
|
||||
```
|
||||
19
docs/language/job/job-pump.md
Normal file
19
docs/language/job/job-pump.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: job-pump
|
||||
name: Job.pump
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.pump
|
||||
sig: Job.pump(budget) -> int
|
||||
tip: Advance background jobs; collect results.
|
||||
order: 5
|
||||
ns: Job
|
||||
member: pump
|
||||
---
|
||||
|
||||
Advance background jobs; collect results.
|
||||
|
||||
```ludic
|
||||
let job = Job.run(1, 1000)
|
||||
Job.pump(64) # spend up to 64 steps this frame (0 = finish all)
|
||||
```
|
||||
20
docs/language/job/job-result.md
Normal file
20
docs/language/job/job-result.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: job-result
|
||||
name: Job.result
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.result
|
||||
sig: Job.result(handle) -> int
|
||||
tip: The success value of a done job.
|
||||
order: 10
|
||||
ns: Job
|
||||
member: result
|
||||
---
|
||||
|
||||
The success value of a done job.
|
||||
|
||||
```ludic
|
||||
let job = Job.defer()
|
||||
Job.fulfill(job, 42)
|
||||
let value = Job.result(job)
|
||||
```
|
||||
19
docs/language/job/job-run.md
Normal file
19
docs/language/job/job-run.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: job-run
|
||||
name: Job.run
|
||||
category: job
|
||||
kind: namespace-method
|
||||
tokens: Job.run
|
||||
sig: Job.run(kind, arg) -> Job
|
||||
tip: Start a background compute job.
|
||||
order: 1
|
||||
ns: Job
|
||||
member: run
|
||||
---
|
||||
|
||||
Start a background compute job.
|
||||
|
||||
```ludic
|
||||
let job = Job.run(1, 100) # 1 = sum 1..arg, 2 = fib, 3 = count primes
|
||||
Job.pump(0)
|
||||
```
|
||||
7
docs/language/promise/_section.md
Normal file
7
docs/language/promise/_section.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: promise
|
||||
title: Promise
|
||||
order: 38
|
||||
---
|
||||
|
||||
Combine several <code>Job</code> futures and resolve the group on the main thread. <code>Promise.all</code> succeeds once every member has, <code>Promise.race</code> once the first does; both return an ordinary job handle you poll like any other. For a loading screen, <code>Promise.count_done</code> over the same handles is the bar's numerator and <code>len</code> the denominator, and <code>Promise.all_done</code> is the ready check. Ludic has no closures, so progress is polled rather than chained through a <code>then</code> callback. Build the handle list with <code>new []int</code> + <code>push</code>. Spliced in only when a program mentions <code>Promise.*</code>.
|
||||
21
docs/language/promise/promise-all.md
Normal file
21
docs/language/promise/promise-all.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: promise-all
|
||||
name: Promise.all
|
||||
category: promise
|
||||
kind: namespace-method
|
||||
tokens: Promise.all
|
||||
sig: Promise.all(handles) -> Job
|
||||
tip: Succeeds when every member succeeds.
|
||||
order: 0
|
||||
ns: Promise
|
||||
member: all
|
||||
---
|
||||
|
||||
Succeeds when every member succeeds.
|
||||
|
||||
```ludic
|
||||
let hs = new []int
|
||||
push(hs, a)
|
||||
push(hs, b)
|
||||
let loaded = Promise.all(hs)
|
||||
```
|
||||
21
docs/language/promise/promise-all_done.md
Normal file
21
docs/language/promise/promise-all_done.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: promise-all_done
|
||||
name: Promise.all_done
|
||||
category: promise
|
||||
kind: namespace-method
|
||||
tokens: Promise.all_done
|
||||
sig: Promise.all_done(handles) -> bool
|
||||
tip: Have all members resolved?
|
||||
order: 3
|
||||
ns: Promise
|
||||
member: all_done
|
||||
---
|
||||
|
||||
Have all members resolved?
|
||||
|
||||
```ludic
|
||||
let hs = new []int
|
||||
push(hs, a)
|
||||
push(hs, b)
|
||||
let ready = Promise.all_done(hs)
|
||||
```
|
||||
21
docs/language/promise/promise-count_done.md
Normal file
21
docs/language/promise/promise-count_done.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: promise-count_done
|
||||
name: Promise.count_done
|
||||
category: promise
|
||||
kind: namespace-method
|
||||
tokens: Promise.count_done
|
||||
sig: Promise.count_done(handles) -> int
|
||||
tip: How many members have resolved.
|
||||
order: 2
|
||||
ns: Promise
|
||||
member: count_done
|
||||
---
|
||||
|
||||
How many members have resolved.
|
||||
|
||||
```ludic
|
||||
let hs = new []int
|
||||
push(hs, a)
|
||||
push(hs, b)
|
||||
let progress = Promise.count_done(hs)
|
||||
```
|
||||
21
docs/language/promise/promise-race.md
Normal file
21
docs/language/promise/promise-race.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
---
|
||||
id: promise-race
|
||||
name: Promise.race
|
||||
category: promise
|
||||
kind: namespace-method
|
||||
tokens: Promise.race
|
||||
sig: Promise.race(handles) -> Job
|
||||
tip: Succeeds when the first member does.
|
||||
order: 1
|
||||
ns: Promise
|
||||
member: race
|
||||
---
|
||||
|
||||
Succeeds when the first member does.
|
||||
|
||||
```ludic
|
||||
let hs = new []int
|
||||
push(hs, a)
|
||||
push(hs, b)
|
||||
let first = Promise.race(hs)
|
||||
```
|
||||
7
docs/language/sync/_section.md
Normal file
7
docs/language/sync/_section.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
id: sync
|
||||
title: Sync
|
||||
order: 39
|
||||
---
|
||||
|
||||
The advanced, opt-in tier — <strong>here be dragons</strong>. Raw building blocks for engine-level systems that pass data around: a <code>mutex</code> (cooperative lock), an <code>atomic</code> counter (<code>get</code> / <code>set</code> / <code>add</code> / <code>cas</code>) and a bounded <code>channel</code> (<code>send</code> / <code>recv</code> / <code>can_recv</code> / <code>len</code>). On today's single-threaded deterministic runtime these are cooperative — correct, ordered, replayable and impossible to deadlock — and exist so message-passing code reads the same now as it will when a preemptive OS-thread backend lands behind this same API. Beginners never need this; reach for <code>Job.*</code> / <code>Promise.*</code> instead. Spliced in only when a program mentions <code>Sync.*</code>.
|
||||
19
docs/language/sync/sync-add.md
Normal file
19
docs/language/sync/sync-add.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: sync-add
|
||||
name: Sync.add
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.add
|
||||
sig: Sync.add(atomic, delta) -> int
|
||||
tip: Add to the counter; return the new value.
|
||||
order: 7
|
||||
ns: Sync
|
||||
member: add
|
||||
---
|
||||
|
||||
Add to the counter; return the new value.
|
||||
|
||||
```ludic
|
||||
let a = Sync.atomic()
|
||||
let total = Sync.add(a, 1)
|
||||
```
|
||||
20
docs/language/sync/sync-atomic.md
Normal file
20
docs/language/sync/sync-atomic.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: sync-atomic
|
||||
name: Sync.atomic
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.atomic
|
||||
sig: Sync.atomic() -> int
|
||||
tip: Create an atomic counter (starts at 0).
|
||||
order: 4
|
||||
ns: Sync
|
||||
member: atomic
|
||||
---
|
||||
|
||||
Create an atomic counter (starts at 0).
|
||||
|
||||
Returns an atomic-counter handle. Read it with <code>Sync.get</code>, write with <code>Sync.set</code>, accumulate with <code>Sync.add</code>, and swap conditionally with <code>Sync.cas</code>.
|
||||
|
||||
```ludic
|
||||
let a = Sync.atomic()
|
||||
```
|
||||
19
docs/language/sync/sync-can_recv.md
Normal file
19
docs/language/sync/sync-can_recv.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: sync-can_recv
|
||||
name: Sync.can_recv
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.can_recv
|
||||
sig: Sync.can_recv(channel) -> bool
|
||||
tip: Is there a value waiting?
|
||||
order: 12
|
||||
ns: Sync
|
||||
member: can_recv
|
||||
---
|
||||
|
||||
Is there a value waiting?
|
||||
|
||||
```ludic
|
||||
let ch = Sync.channel()
|
||||
let has = Sync.can_recv(ch)
|
||||
```
|
||||
19
docs/language/sync/sync-cas.md
Normal file
19
docs/language/sync/sync-cas.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: sync-cas
|
||||
name: Sync.cas
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.cas
|
||||
sig: Sync.cas(atomic, expect, next) -> bool
|
||||
tip: Compare-and-set the counter.
|
||||
order: 8
|
||||
ns: Sync
|
||||
member: cas
|
||||
---
|
||||
|
||||
Compare-and-set the counter.
|
||||
|
||||
```ludic
|
||||
let a = Sync.atomic()
|
||||
let swapped = Sync.cas(a, 0, 1)
|
||||
```
|
||||
20
docs/language/sync/sync-channel.md
Normal file
20
docs/language/sync/sync-channel.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: sync-channel
|
||||
name: Sync.channel
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.channel
|
||||
sig: Sync.channel() -> int
|
||||
tip: Create a bounded int FIFO channel.
|
||||
order: 9
|
||||
ns: Sync
|
||||
member: channel
|
||||
---
|
||||
|
||||
Create a bounded int FIFO channel.
|
||||
|
||||
Returns a channel handle — a fixed-capacity queue of ints for handing values between a producer and a consumer. Push with <code>Sync.send</code>, pull the oldest with <code>Sync.recv</code>, and check with <code>Sync.can_recv</code> / <code>Sync.len</code>.
|
||||
|
||||
```ludic
|
||||
let ch = Sync.channel()
|
||||
```
|
||||
18
docs/language/sync/sync-cpu_count.md
Normal file
18
docs/language/sync/sync-cpu_count.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
---
|
||||
id: sync-cpu_count
|
||||
name: Sync.cpu_count
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.cpu_count
|
||||
sig: Sync.cpu_count() -> int
|
||||
tip: Worker lanes available to the scheduler.
|
||||
order: 14
|
||||
ns: Sync
|
||||
member: cpu_count
|
||||
---
|
||||
|
||||
Worker lanes available to the scheduler.
|
||||
|
||||
```ludic
|
||||
let lanes = Sync.cpu_count()
|
||||
```
|
||||
19
docs/language/sync/sync-get.md
Normal file
19
docs/language/sync/sync-get.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: sync-get
|
||||
name: Sync.get
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.get
|
||||
sig: Sync.get(atomic) -> int
|
||||
tip: Read the counter.
|
||||
order: 5
|
||||
ns: Sync
|
||||
member: get
|
||||
---
|
||||
|
||||
Read the counter.
|
||||
|
||||
```ludic
|
||||
let a = Sync.atomic()
|
||||
let v = Sync.get(a)
|
||||
```
|
||||
19
docs/language/sync/sync-len.md
Normal file
19
docs/language/sync/sync-len.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: sync-len
|
||||
name: Sync.len
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.len
|
||||
sig: Sync.len(channel) -> int
|
||||
tip: How many values are queued.
|
||||
order: 13
|
||||
ns: Sync
|
||||
member: len
|
||||
---
|
||||
|
||||
How many values are queued.
|
||||
|
||||
```ludic
|
||||
let ch = Sync.channel()
|
||||
let n = Sync.len(ch)
|
||||
```
|
||||
19
docs/language/sync/sync-lock.md
Normal file
19
docs/language/sync/sync-lock.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: sync-lock
|
||||
name: Sync.lock
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.lock
|
||||
sig: Sync.lock(mutex) -> void
|
||||
tip: Take the lock.
|
||||
order: 1
|
||||
ns: Sync
|
||||
member: lock
|
||||
---
|
||||
|
||||
Take the lock.
|
||||
|
||||
```ludic
|
||||
let m = Sync.mutex()
|
||||
Sync.lock(m)
|
||||
```
|
||||
20
docs/language/sync/sync-mutex.md
Normal file
20
docs/language/sync/sync-mutex.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: sync-mutex
|
||||
name: Sync.mutex
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.mutex
|
||||
sig: Sync.mutex() -> int
|
||||
tip: Create a cooperative lock.
|
||||
order: 0
|
||||
ns: Sync
|
||||
member: mutex
|
||||
---
|
||||
|
||||
Create a cooperative lock.
|
||||
|
||||
Creates a mutex handle for guarding a critical section. On the deterministic single-threaded runtime it never blocks — pair <code>Sync.lock</code> / <code>Sync.unlock</code> around the section, or probe with <code>Sync.try_lock</code>.
|
||||
|
||||
```ludic
|
||||
let m = Sync.mutex()
|
||||
```
|
||||
20
docs/language/sync/sync-recv.md
Normal file
20
docs/language/sync/sync-recv.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: sync-recv
|
||||
name: Sync.recv
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.recv
|
||||
sig: Sync.recv(channel) -> int
|
||||
tip: Dequeue the oldest value.
|
||||
order: 11
|
||||
ns: Sync
|
||||
member: recv
|
||||
---
|
||||
|
||||
Dequeue the oldest value.
|
||||
|
||||
```ludic
|
||||
let ch = Sync.channel()
|
||||
Sync.send(ch, 42)
|
||||
let v = Sync.recv(ch)
|
||||
```
|
||||
19
docs/language/sync/sync-send.md
Normal file
19
docs/language/sync/sync-send.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: sync-send
|
||||
name: Sync.send
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.send
|
||||
sig: Sync.send(channel, value) -> bool
|
||||
tip: Enqueue a value (false if full).
|
||||
order: 10
|
||||
ns: Sync
|
||||
member: send
|
||||
---
|
||||
|
||||
Enqueue a value (false if full).
|
||||
|
||||
```ludic
|
||||
let ch = Sync.channel()
|
||||
let sent = Sync.send(ch, 42)
|
||||
```
|
||||
19
docs/language/sync/sync-set.md
Normal file
19
docs/language/sync/sync-set.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: sync-set
|
||||
name: Sync.set
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.set
|
||||
sig: Sync.set(atomic, value) -> void
|
||||
tip: Store a value in the counter.
|
||||
order: 6
|
||||
ns: Sync
|
||||
member: set
|
||||
---
|
||||
|
||||
Store a value in the counter.
|
||||
|
||||
```ludic
|
||||
let a = Sync.atomic()
|
||||
Sync.set(a, 10)
|
||||
```
|
||||
19
docs/language/sync/sync-try_lock.md
Normal file
19
docs/language/sync/sync-try_lock.md
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
---
|
||||
id: sync-try_lock
|
||||
name: Sync.try_lock
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.try_lock
|
||||
sig: Sync.try_lock(mutex) -> bool
|
||||
tip: Take the lock only if it is free.
|
||||
order: 3
|
||||
ns: Sync
|
||||
member: try_lock
|
||||
---
|
||||
|
||||
Take the lock only if it is free.
|
||||
|
||||
```ludic
|
||||
let m = Sync.mutex()
|
||||
let got = Sync.try_lock(m)
|
||||
```
|
||||
20
docs/language/sync/sync-unlock.md
Normal file
20
docs/language/sync/sync-unlock.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
---
|
||||
id: sync-unlock
|
||||
name: Sync.unlock
|
||||
category: sync
|
||||
kind: namespace-method
|
||||
tokens: Sync.unlock
|
||||
sig: Sync.unlock(mutex) -> void
|
||||
tip: Release the lock.
|
||||
order: 2
|
||||
ns: Sync
|
||||
member: unlock
|
||||
---
|
||||
|
||||
Release the lock.
|
||||
|
||||
```ludic
|
||||
let m = Sync.mutex()
|
||||
Sync.lock(m)
|
||||
Sync.unlock(m)
|
||||
```
|
||||
113
examples/library/jobs.ludic
Normal file
113
examples/library/jobs.ludic
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
# jobs.ludic — Job.* / Promise.* (safe, deterministic) + Sync.* (advanced,
|
||||
# opt-in). Each assertion that holds prints its number, so a full run prints:
|
||||
# 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31
|
||||
# One cooperative scheduler backs it all (see runtime/native/jobs.ludic): a
|
||||
# background Job runs a little each Job.pump and its result is collected on the
|
||||
# main thread — no hitches, no locks in game code, and byte-identical every run.
|
||||
program Concurrency {
|
||||
entry {
|
||||
# --- Job: background compute, collected on the main thread ---
|
||||
# kinds: 1 = sum 1..n, 2 = fib(n), 3 = count primes <= n
|
||||
let sum = Job.run(1, 100) # 1 + 2 + ... + 100 = 5050
|
||||
Job.pump(0) # budget 0 = run every job to completion
|
||||
if Job.ok(sum) and Job.result(sum) == 5050 { print(1) }
|
||||
|
||||
let fib = Job.run(2, 10) # fib(10) = 55
|
||||
Job.pump(0)
|
||||
if Job.result(fib) == 55 { print(2) }
|
||||
|
||||
let primes = Job.run(3, 20) # primes <= 20: 2 3 5 7 11 13 17 19 -> 8
|
||||
Job.pump(0)
|
||||
if Job.result(primes) == 8 { print(3) }
|
||||
|
||||
# cooperative: a job finishes only after enough total budget has been spent,
|
||||
# so heavy work is spread across frames instead of hitching one.
|
||||
let slow = Job.run(1, 10) # needs 10 steps; 1+..+10 = 55
|
||||
Job.pump(3) # 3 of 10
|
||||
if not Job.done(slow) { print(4) }
|
||||
Job.pump(3) # 6 of 10
|
||||
if not Job.done(slow) { print(5) }
|
||||
Job.pump(100) # finishes this frame
|
||||
if Job.done(slow) and Job.result(slow) == 55 { print(6) }
|
||||
|
||||
# hand-driven future: defer now, fulfill later (no closures needed)
|
||||
let f = Job.defer()
|
||||
if not Job.done(f) { print(7) }
|
||||
Job.fulfill(f, 42)
|
||||
if Job.ok(f) and Job.result(f) == 42 { print(8) }
|
||||
|
||||
# error path: a job can fail with a code
|
||||
let e = Job.defer()
|
||||
Job.fail(e, 9)
|
||||
if Job.failed(e) and Job.error(e) == 9 { print(9) }
|
||||
|
||||
# cancel path
|
||||
let c = Job.defer()
|
||||
Job.cancel(c)
|
||||
if Job.cancelled(c) and Job.done(c) and not Job.ok(c) { print(10) }
|
||||
|
||||
# --- Promise: combine futures, resolve on the main thread ---
|
||||
let a1 = Job.defer()
|
||||
let a2 = Job.defer()
|
||||
let a3 = Job.defer()
|
||||
let all = new []int
|
||||
push(all, a1)
|
||||
push(all, a2)
|
||||
push(all, a3)
|
||||
let grp = Promise.all(all)
|
||||
if not Job.done(grp) { print(11) }
|
||||
if Promise.count_done(all) == 0 { print(12) } # loading bar: 0 / 3
|
||||
Job.fulfill(a1, 1)
|
||||
Job.fulfill(a2, 2)
|
||||
if Promise.count_done(all) == 2 { print(13) } # 2 / 3
|
||||
if not Promise.all_done(all) { print(14) }
|
||||
Job.fulfill(a3, 3)
|
||||
if Promise.all_done(all) { print(15) } # 3 / 3
|
||||
if Job.ok(grp) and Job.result(grp) == 3 { print(16) }
|
||||
|
||||
# race: the first member to succeed wins; its handle is the result
|
||||
let b1 = Job.defer()
|
||||
let b2 = Job.defer()
|
||||
let two = new []int
|
||||
push(two, b1)
|
||||
push(two, b2)
|
||||
let winner = Promise.race(two)
|
||||
Job.fulfill(b2, 77)
|
||||
if Job.ok(winner) and Job.result(winner) == b2 { print(17) }
|
||||
|
||||
# Promise.all fails once the set settles with a failure
|
||||
let d1 = Job.defer()
|
||||
let d2 = Job.defer()
|
||||
let dd = new []int
|
||||
push(dd, d1)
|
||||
push(dd, d2)
|
||||
let dgrp = Promise.all(dd)
|
||||
Job.fulfill(d1, 1)
|
||||
Job.fail(d2, 5)
|
||||
if Job.failed(dgrp) { print(18) }
|
||||
|
||||
# --- Sync: the advanced, opt-in tier (cooperative + deterministic today) ---
|
||||
let m = Sync.mutex()
|
||||
Sync.lock(m)
|
||||
if not Sync.try_lock(m) { print(19) } # already held
|
||||
Sync.unlock(m)
|
||||
if Sync.try_lock(m) { print(20) } # now free
|
||||
|
||||
let at = Sync.atomic()
|
||||
if Sync.add(at, 5) == 5 { print(21) }
|
||||
if Sync.add(at, 3) == 8 { print(22) }
|
||||
if Sync.cas(at, 8, 100) { print(23) } # 8 -> 100
|
||||
if Sync.get(at) == 100 { print(24) }
|
||||
if not Sync.cas(at, 8, 0) { print(25) } # stale expect, no swap
|
||||
|
||||
let ch = Sync.channel()
|
||||
if Sync.send(ch, 10) { print(26) }
|
||||
Sync.send(ch, 20)
|
||||
Sync.send(ch, 30)
|
||||
if Sync.len(ch) == 3 { print(27) }
|
||||
if Sync.recv(ch) == 10 { print(28) } # FIFO order
|
||||
if Sync.recv(ch) == 20 { print(29) }
|
||||
if Sync.can_recv(ch) { print(30) } # one left (30)
|
||||
if Sync.cpu_count() >= 1 { print(31) }
|
||||
}
|
||||
}
|
||||
537
runtime/native/jobs.ludic
Normal file
537
runtime/native/jobs.ludic
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
# ============================================================================
|
||||
# jobs.ludic — Jobs, Promises & opt-in Sync, in Ludic (#14).
|
||||
#
|
||||
# Concurrency that keeps the *simple* thing simple. Ludic's simulation is
|
||||
# single-threaded and deterministic on purpose — the ECS schedule, lockstep
|
||||
# networking and replays all depend on it — so this library is layered:
|
||||
#
|
||||
# 1. Job.* / Promise.* (the safe default): kick off work, keep the frame
|
||||
# moving, and collect the result *on the main thread* at a point you choose
|
||||
# (Job.pump). No shared mutable state, no locks in game code.
|
||||
# 2. Sync.* (advanced, opt-in — "here be dragons"): mutex, atomic
|
||||
# counters and channels for engine-level systems that pass messages around.
|
||||
#
|
||||
# The whole thing is a deterministic *cooperative* scheduler: a background Job
|
||||
# runs a little each Job.pump(budget) and finishes after enough frames, so a
|
||||
# heavy computation (terrain, pathfinding pre-bake, a checksum) is spread out
|
||||
# instead of hitching one frame. Same jobs + same total budget over time ->
|
||||
# byte-identical results and completion order, every run and every target
|
||||
# (native and wasm alike). That determinism is why it is cooperative rather than
|
||||
# a preemptive OS-thread pool: a real-thread backend can slot in behind this same
|
||||
# API later without changing a line of game code, and the safe tier keeps its
|
||||
# hard rule either way — collect results on the main thread; never let a Job
|
||||
# reach into the ECS world itself.
|
||||
#
|
||||
# Ludic has no first-class functions, so a Job carries a small compute *kind* +
|
||||
# an integer argument (or you drive a hand-made future with Job.defer +
|
||||
# Job.fulfill) rather than a closure, and Promise progress is polled
|
||||
# (Promise.count_done) rather than chained through a callback. ludicc splices
|
||||
# this file into any program that mentions Job.* / Promise.* / Sync.*; it is
|
||||
# self-contained (only compiler intrinsics), so a plain tool works like a game.
|
||||
# The namespaces (emit_call.ludic) alias each method to a function below.
|
||||
# ============================================================================
|
||||
|
||||
const JOB_SLOTS: int = 128 # concurrent jobs (handles are 1-based slot ids)
|
||||
const JOB_MAXMEM: int = 64 # members a single Promise group can hold
|
||||
|
||||
# job state (the terminal states are DONE / FAILED / CANCELLED)
|
||||
const J_FREE: int = 0 # slot not allocated
|
||||
const J_PENDING: int = 1 # allocated, not yet resolved
|
||||
const J_DONE: int = 2 # resolved successfully -> result
|
||||
const J_FAILED: int = 3 # resolved with an error -> error
|
||||
const J_CANCELLED: int = 4 # cancelled before it finished
|
||||
|
||||
# job kind (what a PENDING slot is)
|
||||
const JK_DEFER: int = 0 # a hand-driven future (Job.fulfill / Job.fail)
|
||||
const JK_SUM: int = 1 # compute: 1 + 2 + ... + arg
|
||||
const JK_FIB: int = 2 # compute: the arg-th Fibonacci number
|
||||
const JK_PRIMES: int = 3 # compute: how many primes are <= arg
|
||||
const JK_ALL: int = 4 # Promise group: succeeds when every member does
|
||||
const JK_RACE: int = 5 # Promise group: succeeds when the first member does
|
||||
|
||||
var jb_ready: bool = false
|
||||
var jb_state: words = null # J_*
|
||||
var jb_kind: words = null # JK_*
|
||||
var jb_result: words = null # the success value
|
||||
var jb_error: words = null # the failure code
|
||||
var jb_arg: words = null # compute input n
|
||||
var jb_i: words = null # compute progress counter
|
||||
var jb_acc: words = null # compute accumulator
|
||||
var jb_acc2: words = null # compute second accumulator (Fibonacci)
|
||||
var jb_nmem: words = null # group member count
|
||||
var jb_mem: words = null # flat [JOB_SLOTS * JOB_MAXMEM] of member handles
|
||||
|
||||
function jb_init() -> void {
|
||||
if jb_ready { return }
|
||||
jb_state = words(JOB_SLOTS); fill(jb_state, 0, JOB_SLOTS * 4)
|
||||
jb_kind = words(JOB_SLOTS); fill(jb_kind, 0, JOB_SLOTS * 4)
|
||||
jb_result = words(JOB_SLOTS); fill(jb_result, 0, JOB_SLOTS * 4)
|
||||
jb_error = words(JOB_SLOTS); fill(jb_error, 0, JOB_SLOTS * 4)
|
||||
jb_arg = words(JOB_SLOTS); fill(jb_arg, 0, JOB_SLOTS * 4)
|
||||
jb_i = words(JOB_SLOTS); fill(jb_i, 0, JOB_SLOTS * 4)
|
||||
jb_acc = words(JOB_SLOTS); fill(jb_acc, 0, JOB_SLOTS * 4)
|
||||
jb_acc2 = words(JOB_SLOTS); fill(jb_acc2, 0, JOB_SLOTS * 4)
|
||||
jb_nmem = words(JOB_SLOTS); fill(jb_nmem, 0, JOB_SLOTS * 4)
|
||||
jb_mem = words(JOB_SLOTS * JOB_MAXMEM); fill(jb_mem, 0, JOB_SLOTS * JOB_MAXMEM * 4)
|
||||
jb_ready = true
|
||||
}
|
||||
|
||||
# claim a free slot as PENDING with the given kind; returns a 1-based handle, or
|
||||
# 0 if the table is full.
|
||||
function jb_alloc(kind: int) -> int {
|
||||
jb_init()
|
||||
var i = 0
|
||||
while i < JOB_SLOTS {
|
||||
if jb_state[i] == J_FREE {
|
||||
jb_state[i] = J_PENDING
|
||||
jb_kind[i] = kind
|
||||
jb_result[i] = 0; jb_error[i] = 0
|
||||
jb_arg[i] = 0; jb_i[i] = 0; jb_acc[i] = 0; jb_acc2[i] = 0
|
||||
jb_nmem[i] = 0
|
||||
return i + 1
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function jb_valid(h: int) -> bool {
|
||||
jb_init()
|
||||
if (h < 1) or (h > JOB_SLOTS) { return false }
|
||||
return jb_state[h - 1] != J_FREE
|
||||
}
|
||||
|
||||
# ---- the safe tier: futures ------------------------------------------------
|
||||
|
||||
# A hand-driven future: PENDING until you call Job.fulfill / Job.fail on it.
|
||||
function job_defer() -> int { return jb_alloc(JK_DEFER) }
|
||||
|
||||
# Kick off a background compute job (kind = JK_SUM / JK_FIB / JK_PRIMES). It runs
|
||||
# a little each Job.pump and resolves when it finishes. `arg` is its input.
|
||||
function job_run(kind: int, arg: int) -> int {
|
||||
let h = jb_alloc(kind)
|
||||
if h == 0 { return 0 }
|
||||
let s = h - 1
|
||||
jb_arg[s] = arg
|
||||
if kind == JK_FIB { jb_acc[s] = 0; jb_acc2[s] = 1 } # fib(0)=0, fib(1)=1
|
||||
return h
|
||||
}
|
||||
|
||||
# Resolve a pending job successfully with `value` (no-op once resolved).
|
||||
function job_fulfill(h: int, value: int) -> void {
|
||||
if not jb_valid(h) { return }
|
||||
let s = h - 1
|
||||
if jb_state[s] != J_PENDING { return }
|
||||
jb_state[s] = J_DONE
|
||||
jb_result[s] = value
|
||||
}
|
||||
|
||||
# Resolve a pending job as failed with error code `err` (no-op once resolved).
|
||||
function job_fail(h: int, err: int) -> void {
|
||||
if not jb_valid(h) { return }
|
||||
let s = h - 1
|
||||
if jb_state[s] != J_PENDING { return }
|
||||
jb_state[s] = J_FAILED
|
||||
jb_error[s] = err
|
||||
}
|
||||
|
||||
# Cancel a pending job (no-op if it already resolved).
|
||||
function job_cancel(h: int) -> void {
|
||||
if not jb_valid(h) { return }
|
||||
let s = h - 1
|
||||
if jb_state[s] == J_PENDING { jb_state[s] = J_CANCELLED }
|
||||
}
|
||||
|
||||
# Recompute a group job (JK_ALL / JK_RACE) from its members. A no-op unless the
|
||||
# slot is a still-PENDING group. This is what "resolve on the main thread" means:
|
||||
# a Promise settles only when you look at it (done/ok/...) or pump.
|
||||
function jb_refresh_group(s: int) -> void {
|
||||
if jb_state[s] != J_PENDING { return }
|
||||
let k = jb_kind[s]
|
||||
if (k != JK_ALL) and (k != JK_RACE) { return }
|
||||
let n = jb_nmem[s]
|
||||
let base = s * JOB_MAXMEM
|
||||
var i = 0
|
||||
var settled = 0 # members in a terminal state
|
||||
var ok = 0 # members that succeeded
|
||||
var first_ok = 0 # winning handle for RACE
|
||||
while i < n {
|
||||
let mh = jb_mem[base + i]
|
||||
if jb_valid(mh) {
|
||||
let ms = mh - 1
|
||||
let mst = jb_state[ms]
|
||||
if mst != J_PENDING {
|
||||
settled = settled + 1
|
||||
if mst == J_DONE {
|
||||
ok = ok + 1
|
||||
if first_ok == 0 { first_ok = mh }
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settled = settled + 1 # a freed/invalid member counts as settled-failed
|
||||
}
|
||||
i = i + 1
|
||||
}
|
||||
if k == JK_ALL {
|
||||
if ok == n { jb_state[s] = J_DONE; jb_result[s] = n }
|
||||
else { if settled == n { jb_state[s] = J_FAILED; jb_error[s] = n - ok } }
|
||||
} else {
|
||||
if first_ok != 0 { jb_state[s] = J_DONE; jb_result[s] = first_ok }
|
||||
else { if settled == n { jb_state[s] = J_FAILED; jb_error[s] = n } }
|
||||
}
|
||||
}
|
||||
|
||||
# resolved in any terminal state?
|
||||
function job_done(h: int) -> bool {
|
||||
if not jb_valid(h) { return false }
|
||||
jb_refresh_group(h - 1)
|
||||
return jb_state[h - 1] != J_PENDING
|
||||
}
|
||||
|
||||
function job_ok(h: int) -> bool {
|
||||
if not jb_valid(h) { return false }
|
||||
jb_refresh_group(h - 1)
|
||||
return jb_state[h - 1] == J_DONE
|
||||
}
|
||||
|
||||
function job_failed(h: int) -> bool {
|
||||
if not jb_valid(h) { return false }
|
||||
jb_refresh_group(h - 1)
|
||||
return jb_state[h - 1] == J_FAILED
|
||||
}
|
||||
|
||||
function job_cancelled(h: int) -> bool {
|
||||
if not jb_valid(h) { return false }
|
||||
return jb_state[h - 1] == J_CANCELLED
|
||||
}
|
||||
|
||||
# the success value (0 unless the job is done-ok)
|
||||
function job_result(h: int) -> int {
|
||||
if not jb_valid(h) { return 0 }
|
||||
jb_refresh_group(h - 1)
|
||||
if jb_state[h - 1] != J_DONE { return 0 }
|
||||
return jb_result[h - 1]
|
||||
}
|
||||
|
||||
# the failure code (0 unless the job failed)
|
||||
function job_error(h: int) -> int {
|
||||
if not jb_valid(h) { return 0 }
|
||||
jb_refresh_group(h - 1)
|
||||
if jb_state[h - 1] != J_FAILED { return 0 }
|
||||
return jb_error[h - 1]
|
||||
}
|
||||
|
||||
# how many jobs are still pending (a ready-made loading-screen denominator).
|
||||
function job_pending() -> int {
|
||||
jb_init()
|
||||
var n = 0
|
||||
var i = 0
|
||||
while i < JOB_SLOTS {
|
||||
if jb_state[i] == J_PENDING { n = n + 1 }
|
||||
i = i + 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
# release a slot back to the pool.
|
||||
function job_free(h: int) -> void {
|
||||
if not jb_valid(h) { return }
|
||||
jb_state[h - 1] = J_FREE
|
||||
}
|
||||
|
||||
# advance one compute job by a single step; returns 1 if it just finished.
|
||||
function jb_step(s: int) -> int {
|
||||
let k = jb_kind[s]
|
||||
let n = jb_arg[s]
|
||||
var i = jb_i[s]
|
||||
if k == JK_SUM {
|
||||
jb_acc[s] = jb_acc[s] + (i + 1)
|
||||
i = i + 1
|
||||
jb_i[s] = i
|
||||
if i >= n { jb_state[s] = J_DONE; jb_result[s] = jb_acc[s]; return 1 }
|
||||
return 0
|
||||
}
|
||||
if k == JK_FIB {
|
||||
if i >= n { jb_state[s] = J_DONE; jb_result[s] = jb_acc[s]; return 1 }
|
||||
let t = jb_acc[s] + jb_acc2[s]
|
||||
jb_acc[s] = jb_acc2[s]
|
||||
jb_acc2[s] = t
|
||||
i = i + 1
|
||||
jb_i[s] = i
|
||||
if i >= n { jb_state[s] = J_DONE; jb_result[s] = jb_acc[s]; return 1 }
|
||||
return 0
|
||||
}
|
||||
if k == JK_PRIMES {
|
||||
if jb_is_prime(i) { jb_acc[s] = jb_acc[s] + 1 }
|
||||
i = i + 1
|
||||
jb_i[s] = i
|
||||
if i > n { jb_state[s] = J_DONE; jb_result[s] = jb_acc[s]; return 1 }
|
||||
return 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function jb_is_prime(v: int) -> bool {
|
||||
if v < 2 { return false }
|
||||
var d = 2
|
||||
while d * d <= v {
|
||||
if v - (v / d) * d == 0 { return false }
|
||||
d = d + 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
# Advance every pending compute job, spending up to `budget` steps in total, and
|
||||
# resolve any Promise groups. Call it once per frame (or wherever you want the
|
||||
# results to land). Returns how many jobs finished during this call. `budget` <=
|
||||
# 0 means "run every compute job to completion right now".
|
||||
function job_pump(budget: int) -> int {
|
||||
jb_init()
|
||||
var completed = 0
|
||||
var spent = 0
|
||||
var s = 0
|
||||
while s < JOB_SLOTS {
|
||||
let k = jb_kind[s]
|
||||
let compute = (k == JK_SUM) or (k == JK_FIB) or (k == JK_PRIMES)
|
||||
while (jb_state[s] == J_PENDING) and compute {
|
||||
if (budget > 0) and (spent >= budget) { s = JOB_SLOTS + 1; break }
|
||||
let fin = jb_step(s)
|
||||
spent = spent + 1
|
||||
if fin == 1 { completed = completed + 1 }
|
||||
}
|
||||
s = s + 1
|
||||
}
|
||||
# settle groups after the compute jobs advanced this frame.
|
||||
s = 0
|
||||
while s < JOB_SLOTS {
|
||||
if jb_state[s] == J_PENDING {
|
||||
let k = jb_kind[s]
|
||||
if (k == JK_ALL) or (k == JK_RACE) {
|
||||
jb_refresh_group(s)
|
||||
if jb_state[s] != J_PENDING { completed = completed + 1 }
|
||||
}
|
||||
}
|
||||
s = s + 1
|
||||
}
|
||||
return completed
|
||||
}
|
||||
|
||||
# ---- Promise combinators (over a []int of job handles) ---------------------
|
||||
|
||||
# store up to JOB_MAXMEM handles as the members of group slot `s`.
|
||||
function jb_set_members(s: int, handles: []int) -> void {
|
||||
var n = len(handles)
|
||||
if n > JOB_MAXMEM { n = JOB_MAXMEM }
|
||||
let base = s * JOB_MAXMEM
|
||||
var i = 0
|
||||
while i < n { jb_mem[base + i] = handles[i]; i = i + 1 }
|
||||
jb_nmem[s] = n
|
||||
}
|
||||
|
||||
# A promise that succeeds once every member has succeeded, and fails as soon as
|
||||
# the whole set has settled with at least one non-success. Returns a job handle.
|
||||
function prom_all(handles: []int) -> int {
|
||||
let h = jb_alloc(JK_ALL)
|
||||
if h == 0 { return 0 }
|
||||
jb_set_members(h - 1, handles)
|
||||
jb_refresh_group(h - 1)
|
||||
return h
|
||||
}
|
||||
|
||||
# A promise that succeeds as soon as the first member succeeds (its handle is the
|
||||
# result), and fails only if every member settles without success.
|
||||
function prom_race(handles: []int) -> int {
|
||||
let h = jb_alloc(JK_RACE)
|
||||
if h == 0 { return 0 }
|
||||
jb_set_members(h - 1, handles)
|
||||
jb_refresh_group(h - 1)
|
||||
return h
|
||||
}
|
||||
|
||||
# how many of `handles` have resolved (any terminal state) — a loading bar's
|
||||
# numerator; pair with len(handles) for the denominator.
|
||||
function prom_count_done(handles: []int) -> int {
|
||||
var n = 0
|
||||
var i = 0
|
||||
while i < len(handles) {
|
||||
if job_done(handles[i]) { n = n + 1 }
|
||||
i = i + 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
function prom_all_done(handles: []int) -> bool {
|
||||
var i = 0
|
||||
while i < len(handles) {
|
||||
if not job_done(handles[i]) { return false }
|
||||
i = i + 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
# ============================================================================
|
||||
# Sync.* — the advanced, opt-in tier. HERE BE DRAGONS.
|
||||
#
|
||||
# These are the raw building blocks — a lock, an atomic counter, a channel — for
|
||||
# engine-level systems that hand data between producers and consumers. On today's
|
||||
# single-threaded, deterministic runtime they are cooperative: correct, ordered
|
||||
# and replayable, and impossible to deadlock (there is one thread). They exist so
|
||||
# a message-passing system reads the same in game code now as it will when a
|
||||
# preemptive OS-thread backend lands behind this same API. Beginners never need
|
||||
# to touch this — reach for Job.* / Promise.* instead.
|
||||
# ============================================================================
|
||||
|
||||
const SYNC_MUTEX: int = 32
|
||||
const SYNC_ATOMIC: int = 64
|
||||
const SYNC_CHAN: int = 32
|
||||
const CHAN_CAP: int = 64 # capacity of each channel's ring buffer
|
||||
|
||||
var sy_ready: bool = false
|
||||
var mx_used: words = null
|
||||
var mx_held: words = null
|
||||
var at_used: words = null
|
||||
var at_val: words = null
|
||||
var ch_used: words = null
|
||||
var ch_head: words = null
|
||||
var ch_count: words = null
|
||||
var ch_buf: words = null # flat [SYNC_CHAN * CHAN_CAP]
|
||||
|
||||
function sy_init() -> void {
|
||||
if sy_ready { return }
|
||||
mx_used = words(SYNC_MUTEX); fill(mx_used, 0, SYNC_MUTEX * 4)
|
||||
mx_held = words(SYNC_MUTEX); fill(mx_held, 0, SYNC_MUTEX * 4)
|
||||
at_used = words(SYNC_ATOMIC); fill(at_used, 0, SYNC_ATOMIC * 4)
|
||||
at_val = words(SYNC_ATOMIC); fill(at_val, 0, SYNC_ATOMIC * 4)
|
||||
ch_used = words(SYNC_CHAN); fill(ch_used, 0, SYNC_CHAN * 4)
|
||||
ch_head = words(SYNC_CHAN); fill(ch_head, 0, SYNC_CHAN * 4)
|
||||
ch_count = words(SYNC_CHAN); fill(ch_count, 0, SYNC_CHAN * 4)
|
||||
ch_buf = words(SYNC_CHAN * CHAN_CAP); fill(ch_buf, 0, SYNC_CHAN * CHAN_CAP * 4)
|
||||
sy_ready = true
|
||||
}
|
||||
|
||||
# ---- mutex (a cooperative lock) --------------------------------------------
|
||||
function sync_mutex() -> int {
|
||||
sy_init()
|
||||
var i = 0
|
||||
while i < SYNC_MUTEX {
|
||||
if mx_used[i] == 0 { mx_used[i] = 1; mx_held[i] = 0; return i + 1 }
|
||||
i = i + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function sync_lock(m: int) -> void {
|
||||
sy_init()
|
||||
if (m < 1) or (m > SYNC_MUTEX) { return }
|
||||
mx_held[m - 1] = 1
|
||||
}
|
||||
|
||||
function sync_unlock(m: int) -> void {
|
||||
sy_init()
|
||||
if (m < 1) or (m > SYNC_MUTEX) { return }
|
||||
mx_held[m - 1] = 0
|
||||
}
|
||||
|
||||
# take the lock only if it is free; returns whether it was taken.
|
||||
function sync_try_lock(m: int) -> bool {
|
||||
sy_init()
|
||||
if (m < 1) or (m > SYNC_MUTEX) { return false }
|
||||
if mx_held[m - 1] != 0 { return false }
|
||||
mx_held[m - 1] = 1
|
||||
return true
|
||||
}
|
||||
|
||||
# ---- atomic counter --------------------------------------------------------
|
||||
function sync_atomic() -> int {
|
||||
sy_init()
|
||||
var i = 0
|
||||
while i < SYNC_ATOMIC {
|
||||
if at_used[i] == 0 { at_used[i] = 1; at_val[i] = 0; return i + 1 }
|
||||
i = i + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function sync_get(a: int) -> int {
|
||||
sy_init()
|
||||
if (a < 1) or (a > SYNC_ATOMIC) { return 0 }
|
||||
return at_val[a - 1]
|
||||
}
|
||||
|
||||
function sync_set(a: int, v: int) -> void {
|
||||
sy_init()
|
||||
if (a < 1) or (a > SYNC_ATOMIC) { return }
|
||||
at_val[a - 1] = v
|
||||
}
|
||||
|
||||
# add `delta` and return the new value.
|
||||
function sync_add(a: int, delta: int) -> int {
|
||||
sy_init()
|
||||
if (a < 1) or (a > SYNC_ATOMIC) { return 0 }
|
||||
at_val[a - 1] = at_val[a - 1] + delta
|
||||
return at_val[a - 1]
|
||||
}
|
||||
|
||||
# compare-and-set: if the value equals `expect`, store `next` and return true.
|
||||
function sync_cas(a: int, expect: int, next: int) -> bool {
|
||||
sy_init()
|
||||
if (a < 1) or (a > SYNC_ATOMIC) { return false }
|
||||
if at_val[a - 1] != expect { return false }
|
||||
at_val[a - 1] = next
|
||||
return true
|
||||
}
|
||||
|
||||
# ---- channel (a bounded int FIFO) ------------------------------------------
|
||||
function sync_channel() -> int {
|
||||
sy_init()
|
||||
var i = 0
|
||||
while i < SYNC_CHAN {
|
||||
if ch_used[i] == 0 { ch_used[i] = 1; ch_head[i] = 0; ch_count[i] = 0; return i + 1 }
|
||||
i = i + 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
# enqueue `v`; returns false if the channel is full.
|
||||
function sync_send(c: int, v: int) -> bool {
|
||||
sy_init()
|
||||
if (c < 1) or (c > SYNC_CHAN) { return false }
|
||||
let s = c - 1
|
||||
if ch_count[s] >= CHAN_CAP { return false }
|
||||
let pos = ch_head[s] + ch_count[s]
|
||||
var idx = pos
|
||||
if idx >= CHAN_CAP { idx = idx - CHAN_CAP }
|
||||
ch_buf[s * CHAN_CAP + idx] = v
|
||||
ch_count[s] = ch_count[s] + 1
|
||||
return true
|
||||
}
|
||||
|
||||
# dequeue the oldest value; returns 0 on an empty channel (guard with can_recv).
|
||||
function sync_recv(c: int) -> int {
|
||||
sy_init()
|
||||
if (c < 1) or (c > SYNC_CHAN) { return 0 }
|
||||
let s = c - 1
|
||||
if ch_count[s] == 0 { return 0 }
|
||||
let v = ch_buf[s * CHAN_CAP + ch_head[s]]
|
||||
var nh = ch_head[s] + 1
|
||||
if nh >= CHAN_CAP { nh = 0 }
|
||||
ch_head[s] = nh
|
||||
ch_count[s] = ch_count[s] - 1
|
||||
return v
|
||||
}
|
||||
|
||||
function sync_can_recv(c: int) -> bool {
|
||||
sy_init()
|
||||
if (c < 1) or (c > SYNC_CHAN) { return false }
|
||||
return ch_count[c - 1] > 0
|
||||
}
|
||||
|
||||
function sync_len(c: int) -> int {
|
||||
sy_init()
|
||||
if (c < 1) or (c > SYNC_CHAN) { return 0 }
|
||||
return ch_count[c - 1]
|
||||
}
|
||||
|
||||
# worker lanes available to the scheduler. One today (the deterministic main
|
||||
# thread); a future OS-thread backend would report the real core count here.
|
||||
function sync_cpu_count() -> int { return 1 }
|
||||
|
|
@ -389,6 +389,49 @@ function emit_ns_call(ns: pointer, meth: pointer, e: Node) -> Val {
|
|||
if (meth == "clear") { bare = "set_clear"; push(labels, "s") }
|
||||
if (meth == "members") { bare = "set_members"; push(labels, "s") }
|
||||
}
|
||||
# Job.* / Promise.* / Sync.* -> the concurrency runtime (runtime/native/jobs.ludic,
|
||||
# spliced on demand). Ordinary Ludic functions, so the generic call path keeps
|
||||
# their return types (int / bool). The safe tier (Job/Promise) is a deterministic
|
||||
# cooperative scheduler; Sync.* is the advanced, opt-in message-passing tier. #14.
|
||||
if (ns == "Job") {
|
||||
if (meth == "defer") { bare = "job_defer" }
|
||||
if (meth == "run") { bare = "job_run"; push(labels, "kind"); push(labels, "arg") }
|
||||
if (meth == "fulfill") { bare = "job_fulfill"; push(labels, "handle"); push(labels, "value") }
|
||||
if (meth == "fail") { bare = "job_fail"; push(labels, "handle"); push(labels, "error") }
|
||||
if (meth == "cancel") { bare = "job_cancel"; push(labels, "handle") }
|
||||
if (meth == "pump") { bare = "job_pump"; push(labels, "budget") }
|
||||
if (meth == "done") { bare = "job_done"; push(labels, "handle") }
|
||||
if (meth == "ok") { bare = "job_ok"; push(labels, "handle") }
|
||||
if (meth == "failed") { bare = "job_failed"; push(labels, "handle") }
|
||||
if (meth == "cancelled") { bare = "job_cancelled"; push(labels, "handle") }
|
||||
if (meth == "result") { bare = "job_result"; push(labels, "handle") }
|
||||
if (meth == "error") { bare = "job_error"; push(labels, "handle") }
|
||||
if (meth == "pending") { bare = "job_pending" }
|
||||
if (meth == "free") { bare = "job_free"; push(labels, "handle") }
|
||||
}
|
||||
if (ns == "Promise") {
|
||||
if (meth == "all") { bare = "prom_all"; push(labels, "handles") }
|
||||
if (meth == "race") { bare = "prom_race"; push(labels, "handles") }
|
||||
if (meth == "count_done") { bare = "prom_count_done"; push(labels, "handles") }
|
||||
if (meth == "all_done") { bare = "prom_all_done"; push(labels, "handles") }
|
||||
}
|
||||
if (ns == "Sync") {
|
||||
if (meth == "mutex") { bare = "sync_mutex" }
|
||||
if (meth == "lock") { bare = "sync_lock"; push(labels, "mutex") }
|
||||
if (meth == "unlock") { bare = "sync_unlock"; push(labels, "mutex") }
|
||||
if (meth == "try_lock") { bare = "sync_try_lock"; push(labels, "mutex") }
|
||||
if (meth == "atomic") { bare = "sync_atomic" }
|
||||
if (meth == "get") { bare = "sync_get"; push(labels, "atomic") }
|
||||
if (meth == "set") { bare = "sync_set"; push(labels, "atomic"); push(labels, "value") }
|
||||
if (meth == "add") { bare = "sync_add"; push(labels, "atomic"); push(labels, "delta") }
|
||||
if (meth == "cas") { bare = "sync_cas"; push(labels, "atomic"); push(labels, "expect"); push(labels, "next") }
|
||||
if (meth == "channel") { bare = "sync_channel" }
|
||||
if (meth == "send") { bare = "sync_send"; push(labels, "channel"); push(labels, "value") }
|
||||
if (meth == "recv") { bare = "sync_recv"; push(labels, "channel") }
|
||||
if (meth == "can_recv") { bare = "sync_can_recv"; push(labels, "channel") }
|
||||
if (meth == "len") { bare = "sync_len"; push(labels, "channel") }
|
||||
if (meth == "cpu_count") { bare = "sync_cpu_count" }
|
||||
}
|
||||
# Huge.* / Angle.* / Percent.* -> the numeric runtime (runtime/native/numeric.ludic,
|
||||
# spliced on demand). Ordinary Ludic functions, so the generic call path keeps
|
||||
# their return types (Huge / fixed / int / bool).
|
||||
|
|
|
|||
|
|
@ -178,6 +178,7 @@ function p_postfix() -> Node {
|
|||
if e.a.kind == E_ID and (e.a.s == "BigInt" or e.a.s == "Decimal") { g_uses_bignum = true } # splice the bignum runtime on demand
|
||||
if e.a.kind == E_ID and (e.a.s == "Dict" or e.a.s == "Set") { g_uses_dict = true } # splice the hash-table runtime on demand
|
||||
if e.a.kind == E_ID and (e.a.s == "Huge" or e.a.s == "Angle" or e.a.s == "Percent") { g_uses_numeric = true } # splice the huge/angle/percent runtime on demand
|
||||
if e.a.kind == E_ID and (e.a.s == "Job" or e.a.s == "Promise" or e.a.s == "Sync") { g_uses_jobs = true } # splice the jobs/promise/sync runtime on demand
|
||||
if e.a.kind == E_ID and e.a.s == "Query" { g_uses_query = true } # splice the ECS spatial-query runtime on demand
|
||||
if e.a.kind == E_ID and e.a.s == "Reflect" { g_uses_reflect = true } # force-emit the reflection ABI (Reflect.* reads the world schema)
|
||||
if e.a.kind == E_ID and e.a.s == "Light" { g_uses_light = true } # splice the 2D light-accumulation pass on demand
|
||||
|
|
@ -427,6 +428,7 @@ var g_uses_regex: bool = false # a program mentioned Regex.* -> splice the re
|
|||
var g_uses_bignum: bool = false # a program mentioned BigInt.*/Decimal.* -> splice the bignum runtime
|
||||
var g_uses_dict: bool = false # a program mentioned Dict.*/Set.* -> splice the hash-table runtime
|
||||
var g_uses_numeric: bool = false # a program mentioned Huge.*/Angle.*/Percent.* -> splice the numeric runtime
|
||||
var g_uses_jobs: bool = false # a program mentioned Job.*/Promise.*/Sync.* -> splice the concurrency runtime
|
||||
var g_uses_query: bool = false # a program mentioned Query.* -> splice the query runtime + reflection ABI
|
||||
var g_uses_reflect: bool = false # a program mentioned Reflect.* -> force-emit the reflection ABI
|
||||
var g_uses_light: bool = false # a program mentioned Light.* -> splice the 2D light pass
|
||||
|
|
@ -626,6 +628,14 @@ function maybe_splice_runtime() -> void {
|
|||
do_import("runtime/native/numeric.ludic")
|
||||
cur_dir = saved
|
||||
}
|
||||
# any program that uses Job.*/Promise.*/Sync.* gets the concurrency runtime
|
||||
# spliced in (it is self-contained — only compiler intrinsics — so a plain tool
|
||||
# works too). A deterministic cooperative scheduler; see runtime/native/jobs.ludic.
|
||||
if g_uses_jobs {
|
||||
cur_dir = ""
|
||||
do_import("runtime/native/jobs.ludic")
|
||||
cur_dir = saved
|
||||
}
|
||||
# any program that uses Query.* gets the ECS spatial-query helpers spliced in;
|
||||
# they read entity state through the reflection ABI (emit_decl force-emits it
|
||||
# for a Query program even when it declares no events).
|
||||
|
|
@ -743,6 +753,7 @@ function parse_program() -> void {
|
|||
g_uses_bignum = false
|
||||
g_uses_dict = false
|
||||
g_uses_numeric = false
|
||||
g_uses_jobs = false
|
||||
g_uses_query = false
|
||||
g_uses_reflect = false
|
||||
g_uses_esys = false
|
||||
|
|
|
|||
47030
selfhost/ludicc.seed.ll
47030
selfhost/ludicc.seed.ll
File diff suppressed because it is too large
Load diff
|
|
@ -437,6 +437,45 @@
|
|||
"set-clear",
|
||||
"set-members"
|
||||
],
|
||||
"job": [
|
||||
"job-defer",
|
||||
"job-run",
|
||||
"job-fulfill",
|
||||
"job-fail",
|
||||
"job-cancel",
|
||||
"job-pump",
|
||||
"job-done",
|
||||
"job-ok",
|
||||
"job-failed",
|
||||
"job-cancelled",
|
||||
"job-result",
|
||||
"job-error",
|
||||
"job-pending",
|
||||
"job-free"
|
||||
],
|
||||
"promise": [
|
||||
"promise-all",
|
||||
"promise-race",
|
||||
"promise-count_done",
|
||||
"promise-all_done"
|
||||
],
|
||||
"sync": [
|
||||
"sync-mutex",
|
||||
"sync-lock",
|
||||
"sync-unlock",
|
||||
"sync-try_lock",
|
||||
"sync-atomic",
|
||||
"sync-get",
|
||||
"sync-set",
|
||||
"sync-add",
|
||||
"sync-cas",
|
||||
"sync-channel",
|
||||
"sync-send",
|
||||
"sync-recv",
|
||||
"sync-can_recv",
|
||||
"sync-len",
|
||||
"sync-cpu_count"
|
||||
],
|
||||
"huge": [
|
||||
"huge-from",
|
||||
"huge-add",
|
||||
|
|
|
|||
|
|
@ -196,6 +196,7 @@ function cmd_test() -> int {
|
|||
feat_case("library/bignum", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16", "bignum.ludic (BigInt arbitrary-precision + Decimal exact base-10 money)")
|
||||
feat_case("library/containers", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14", "containers.ludic (Dict string-keyed hash map + Set string set)")
|
||||
feat_case("library/numeric", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20", "numeric.ludic (Huge idle big-numbers + Angle wrapping radians + Percent clamped [0,1])")
|
||||
feat_case("library/jobs", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31", "jobs.ludic (Job background compute/defer/fulfill/cancel + Promise all/race/progress + Sync mutex/atomic/channel; issue #14)")
|
||||
feat_case("library/optionresult", "", "1 2 3 4 5 6 7 8 9 10 11 12", "optionresult.ludic (option some/none + result ok/err/try safety types)")
|
||||
feat_case("library/regex", "", "1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18", "regex.ludic (Regex match/find/groups/classes/quantifiers/replace + linear-time safety)")
|
||||
feat_case("library/grid", "", "1 2 3 4 5 6 7 8 9 10 11 12 13", "grid.ludic (Grid line/flood/line_of_sight + A* pathfinding over the tilemap)")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue