Curved Spacetime is assembled at runtime from independently built modules. A loader discovers those modules, hands their entrypoints to the engine, and the engine drives them through a fixed initialization protocol. This document specifies that arrangement: the names a module must adopt, the interfaces it must implement, the order in which the engine calls it, the handshake by which one module obtains a reference to another, and, once initialization is complete, the lifecycle of the callbacks a module registers.
It is written so that a third party can implement a conforming module, or an alternative
conforming loader, without reading the engine's source. Two loaders exist today and are cited
throughout as reference implementations: CurvedSpacetimeLoaderClosedLoader, which
resolves entrypoints from static tables compiled into the image, and
CurvedSpacetimeLoaderQuiltLoader, which delegates to Quilt Loader.
The simulation model — spacetimes, metrics, worldlines, and what a
SceneCallback is expected to compute — is deliberately out of scope.
That API has not been settled, and will be specified separately once it is.
This specification is published per version of Curved Spacetime, at
specs/<version>/, alongside the API documentation. The cadence tracks the
project's own SemVer 2.0.0 maturity:
| Project stage | A new specification version is cut |
|---|---|
Before 0.1.0 (current) | per build |
0.x.y | per minor version |
1.0.0 and later | per major version |
The project is currently in the pre-0.1.0 stage, so each release build publishes its
own copy of this document. 0.1.0 will be released once the project is functional,
even if incomplete; from that point the cadence drops to per minor version.
The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL in this document are to be interpreted as described in RFC 2119.
Requirements are numbered Rn and are individually linkable.
A conforming module is a JPMS module that satisfies every requirement in §4, §6, §8, and, when loaded by a Quilt-based loader, §9, along with the module requirements of §10: R49 and R50.
A conforming loader is an implementation of
io.codetoil.curved_spacetime.loader.CurvedSpacetimeLoader that satisfies every
requirement in §5 and §7.
The remaining requirements of §10, R42–R48, bind the engine. CurvedSpacetimeMainModuleEngine is the
reference engine.
A conforming loader is not required to use Quilt or to discover modules dynamically. Static resolution is explicitly permitted; see R7.
CurvedSpacetimeMainModuleEngine. It owns the configuration, the scene list, and
the callback executor; it drives initialization, then runs callbacks.CurvedSpacetimeLoader. It maps an entrypoint name and type
to a list of entrypoint instances.curved-spacetime-render-module.curved-spacetime-render-glfw-module.curved-spacetime-render-glfw-module the module key is
curved-spacetime-render-glfw-module. Several other names derive from it.ModuleInitializer, registered under the entrypoint
name main.MainCallback, or a SceneCallback bound to one scene: module code
the engine runs on its callback thread once initialization is complete. See
§10.R1. A module's directory name MUST match
the associated module key, and the module key MUST end in
-module.
R2. An implementation module's JPMS module name MUST be its
API module's name extended by exactly one segment naming the implementation. The segment MUST use
snake_case where the implementation name is compound.
| Directory | JPMS module |
|---|---|
curved-spacetime-render-module |
io.codetoil.curved_spacetime.render |
curved-spacetime-render-glfw-module |
io.codetoil.curved_spacetime.render.glfw |
curved-spacetime-render-vulkan-module |
io.codetoil.curved_spacetime.render.vulkan |
curved-spacetime-render-vulkan-glfw-module |
io.codetoil.curved_spacetime.render.vulkan_glfw |
R3. A module MUST include an associated dependent entrypoint.
R4. A module MUST export its own package and its .entrypoint
subpackage.
R5. Names derived from the module key MUST be formed as
follows, where key is the module key and key_ is the module key with every
- replaced by _:
| Derived name | Form | Example for curved-spacetime-render-glfw-module |
|---|---|---|
| Configuration file | config/<key>.config |
config/curved-spacetime-render-glfw-module.config |
| Dependent entrypoint name | <key_>_dependent |
curved_spacetime_render_glfw_module_dependent |
| Quilt mod id | <key> |
curved-spacetime-render-glfw-module |
| Quilt group | the JPMS module name | io.codetoil.curved_spacetime.render.glfw |
public interface CurvedSpacetimeLoader
{
void prepareModInit(Path path, Object engine);
<E> List<E> getEntrypoints(String name, Class<E> moduleInitializerClass);
<E> void invokeEntrypoints(String name, Class<E> moduleInitializerClass,
Consumer<? super E> moduleInitializerConsumer);
Object getEngine();
}
R6. prepareModInit MUST be called by the engine
exactly once, before any entrypoint is invoked, with the run directory and the engine instance.
A loader MUST retain the engine reference such that getEngine() returns it
thereafter.
R7. getEntrypoints MUST return every registered
entrypoint for the given name whose runtime type is assignable to
moduleInitializerClass. A loader MAY resolve these statically; it is not required to
scan the module path.
R8. getEntrypoints MUST return an empty list,
not null, when a recognised entrypoint name has no registrations.
R9. getEntrypoints SHOULD throw
IllegalArgumentException for an entrypoint name the loader does not recognise, and
the message SHOULD identify the name and the requested class. Silently returning an empty list
for an unknown name hides module wiring mistakes and is NOT RECOMMENDED.
R10. invokeEntrypoints MUST apply the consumer
to every entrypoint getEntrypoints would return for the same arguments. It MUST NOT
impose an ordering, and callers MUST NOT depend on one.
R11. A loader MUST return the same instance for a given
entrypoint across repeated getEntrypoints calls within one run. The handshake in
§7 depends on identity: a dependent locates its own main entrypoint by
filtering this list, and transfers to the queue owned by that exact object.
public interface ModuleInitializer
{
void onInitialize();
ModuleConfig getConfig();
Logger getLogger();
TransferQueue<ModuleInitializer> getDependencyModuleTransferQueue();
}
R12. Every module MUST register exactly one main entrypoint
under the name main, implementing ModuleInitializer.
R13. The main entrypoint class SHOULD be named
<X>ModuleEntrypoint, where <X> is the module key in
PascalCase with -module dropped.
R14. getLogger MUST return the same
Logger instance for the lifetime of the entrypoint, and that logger MUST be the one
the module writes its diagnostics to.
R15. getDependencyModuleTransferQueue MUST
return the same TransferQueue instance for the lifetime of the entrypoint. A fresh
or defensively copied queue breaks the handshake.
R16. getConfig MUST return the module's
configuration once onInitialize has loaded it. It MAY return null
before that point, and callers MUST NOT invoke it before the owning module has initialized.
public interface ModuleDependentModuleInitializer<E extends ModuleInitializer>
{
void onInitialize(E moduleEntrypoint);
}
R17. A module MUST publish an interface named
<Module>ModuleDependentModuleInitializer in its.entrypoint subpackage, extending
ModuleDependentModuleInitializer<<Module>ModuleEntrypoint>.
R18. A module B that depends on module A MUST register a class implementing A's dependent interface under A's dependent entrypoint name, as derived in R5.
R19. That class SHOULD be named
<Module A>ModuleDependent<Module B>ModuleEntrypoint.
For curved-spacetime-render-glfw-module depending on
curved-spacetime-render-module:
| Role | Name |
|---|---|
| Entrypoint name | curved_spacetime_render_module_dependent |
| Interface | io.codetoil.curved_spacetime.render.entrypoint.CurvedSpacetimeRenderModuleDependentModuleInitializer |
| Implementation | io.codetoil.curved_spacetime.render.glfw.CurvedSpacetimeRenderModuleDependentCurvedSpacetimeRenderGLFWModuleEntrypoint |
R20. The engine MUST, in order: load its own configuration;
call prepareModInit; then invoke every main entrypoint's
onInitialize.
R21. The engine MUST invoke main entrypoints
concurrently, and MUST NOT assume any completion order.
R22. If any entrypoint terminates abnormally, the engine MUST propagate that failure rather than continuing with a partially initialized module set.
R23. A module's onInitialize MUST, in order:
getConsoleHandler() — to that logger, and stop the logger forwarding to its
parent handlers;isDirty() reports the load
substituted defaults;The engine's console handler is set to the configured level. Under the JDK's
default logging configuration the root logger's handler is at INFO, so a logger
whose level alone is set loses every record below INFO, and one that keeps its
parent handlers beside the engine's emits every record at INFO and above
twice.
R24. A module MUST NOT touch another module's state before completing step 3. Until the handshake returns, no ordering guarantee exists.
A module does not look up its dependencies. It is handed them, through a
TransferQueue it owns, by dependent entrypoints running on other threads.
When module A reaches step 4, it invokes every entrypoint registered under
<a_key>_dependent, passing its own main entrypoint. Module B's
implementation of that interface locates B's main entrypoint through the loader and
transfers A into B's queue:
@Override
public void onInitialize(CurvedSpacetimeRenderModuleEntrypoint curvedSpacetimeRenderModuleEntrypoint)
{
try
{
CurvedSpacetimeMainModuleEngine.getInstance().getCurvedSpacetimeLoader()
.getEntrypoints("main", ModuleInitializer.class).stream()
.filter(CurvedSpacetimeRenderGLFWModuleEntrypoint.class::isInstance)
.findFirst().orElseThrow()
.getDependencyModuleTransferQueue().transfer(curvedSpacetimeRenderModuleEntrypoint);
} catch (InterruptedException e)
{
throw new RuntimeException(e);
}
}
Meanwhile B is blocked in step 3, taking one element per declared dependency and sorting them by type:
protected void receiveDependenciesFromTransferQueue() throws InterruptedException
{
for (int i = 0; i < DEPENDENCY_COUNT; i++)
{
ModuleInitializer moduleInitializer = this.dependencyModuleTransferQueue.take();
if (moduleInitializer instanceof CurvedSpacetimeRenderModuleEntrypoint curvedSpacetimeRenderModuleEntrypoint)
{
this.curvedSpacetimeRenderModuleEntrypoint = curvedSpacetimeRenderModuleEntrypoint;
}
// … one branch per declared dependency
}
}
R25. A dependent entrypoint MUST transfer the entrypoint
it was given into the queue returned by its own module's
getDependencyModuleTransferQueue().
R26. A module MUST consume exactly as many elements from its
queue as it has declared dependencies — no more, no fewer. Taking too few strands a producer
blocked in transfer; taking too many blocks the consumer forever.
R27. A module MUST classify received elements by runtime type, and MUST NOT rely on arrival order. The transfers originate on independent threads.
R28. The executor the engine uses to invoke entrypoints MUST
be effectively unbounded — it MUST be able to run every entrypoint of a given name concurrently.
Both TransferQueue.transfer and TransferQueue.take block until the
other side arrives, so a producer and its consumer MUST be runnable at the same time. A bounded
pool deadlocks as soon as its threads are occupied by consumers whose producers are still
queued.
In the reference engine this requirement is satisfied by
Executors.newCachedThreadPool(), and the documentation of
callDependents records why that pool must stay unbounded. It is stated
here as a requirement because substituting a fixed pool is an easy and catastrophic
change.
R29. An implementation SHOULD bound the handshake in time and fail with a diagnostic naming the unsatisfied dependency, rather than blocking indefinitely. A missing or miscounted dependency otherwise manifests as a silent hang at startup with no indication of which module is waiting.
R30. A module SHOULD derive its dependency count from its declared dependencies rather than hard-coding a literal, so that adding or removing a dependency cannot desynchronise the count from reality.
public interface ModuleConfig
{
ModuleConfig load() throws IOException;
void save() throws IOException;
boolean isDirty();
}
R31. A module's configuration MUST be stored at
config/<module key>.config, relative to the run directory, in
java.util.Properties text format.
R32. load() MUST return the receiver, so that
construction and loading compose as
new XModuleConfig(logger).load().
R33. load() MUST NOT fail when the file is
absent. It MUST substitute defaults for every key and set the dirty flag.
R34. For each key that is missing or fails to parse,
load() MUST: log a warning identifying the key, the offending value where one
exists, and the accepted range; substitute the documented default; and set the dirty flag.
R35. isDirty() MUST report whether the
configuration differs from what is on disk. Loading having substituted a default is the
usual cause, but any later change to a value MUST be reflected too, and
save() MUST clear it. The caller is expected to persist a configuration that
does not match its file:
this.config = new XModuleConfig(this.logger).load();
if (this.config.isDirty()) this.config.save();
R36. save() MUST create the
config/ directory if it does not exist, MUST write every key it knows — not only
those that changed — and MUST clear the dirty flag on success.
R37. A configuration file MUST be self-describing after a
save(): a user who deletes it MUST get a complete, commented file back on the next
run.
A saved configuration:
#Config for the Curved Spacetime Main Module.
#Thu Jul 09 12:45:03 EDT 2026
fps=60
logger_level=INFO
R38. Every module MUST ship
quilt.mod.json on its resource path, with schema_version 1.
R39. quilt_loader.id MUST equal the module's
directory name and quilt_loader.group MUST equal its JPMS module name, per
R5.
R40. The entrypoints object MUST contain a
main key naming the module's main entrypoint class,
and one key per dependency, named per R5 and naming the corresponding dependent entrypoint
class.
R41. depends MUST list every module named in
entrypoints, plus java. Theentrypoints map
and the depends array MUST agree; a dependent entrypoint
without a matching dependency is a conformance error.
{
"schema_version": 1,
"quilt_loader": {
"group": "io.codetoil.curved_spacetime.render.glfw",
"id": "curved-spacetime-render-glfw-module",
"version": "0.1.0-SNAPSHOT",
"entrypoints": {
"main": "io.codetoil.curved_spacetime.render.glfw.CurvedSpacetimeRenderGLFWModuleEntrypoint",
"curved_spacetime_render_module_dependent":
"io.codetoil.curved_spacetime.render.glfw.CurvedSpacetimeRenderModuleDependentCurvedSpacetimeRenderGLFWModuleEntrypoint"
},
"depends": [
{ "id": "curved-spacetime-main-module", "versions": ">=0.1.0-SNAPSHOT" },
{ "id": "curved-spacetime-render-module","versions": ">=0.1.0-SNAPSHOT" },
{ "id": "java", "versions": ">=25" }
]
}
}
Once initialization is complete, the engine runs module code through callbacks. A module
registers a MainCallback for work independent of any scene, or a generator from
which the engine makes one SceneCallback per scene. The engine calls each
callback's init once, then its loop repeatedly, at the rate the main
configuration's fps key sets, and its clean when the engine stops.
What a callback computes is out of scope (§1); this section fixes
when it is called, on which thread, and what a failure does.
// CurvedSpacetimeMainModuleEngine
public CompletableFuture<Void> registerMainCallback(MainCallback mainCallback);
public CompletableFuture<Void> registerSceneCallbackGenerator(
Function<Scene, SceneCallback> sceneCallbackGenerator);
public void clean();
public abstract class MainCallback
{
public abstract void init();
public abstract void loop();
public abstract void clean();
}
SceneCallback declares the same three methods, and is bound to one
Scene when the generator constructs it.
R42. The engine MUST call every callback's
init, loop, and clean from a single thread, the
callback thread. A callback MAY therefore hold mutable state without synchronising it
against other callbacks.
R43. The engine MUST call a callback's init
exactly once, and before the first call to its loop.
R44. The engine MUST apply a registered generator exactly once to every scene: each scene that exists when the generator is registered, and each scene registered afterwards. Every callback so produced is subject to R42 and R43.
R45. registerMainCallback and
registerSceneCallbackGenerator MUST return a future that completes once the
init calls the registration causes have finished: normally if every one returned,
exceptionally if one threw.
R46. If a callback's init or
loop, or a generator applied under R44, throws, the engine MUST
log the throwable, with its stack trace, at SEVERE, before it releases anything. It
MUST then stop as by clean (R47). It MUST NOT discard the
throwable, and MUST NOT go on calling the remaining callbacks as though nothing had failed.
Logging comes first so that the failure is on record even if the teardown it
triggers fails as well. A throwable that is caught and dropped, captured in a
Future nobody reads, or thrown out of a periodic task — whose executor then
suppresses every later run without a word — presents as a hang rather than an error.
R47. The engine's clean MUST stop the
callback loop without interrupting the callback thread; MUST call clean on every
registered callback, including one whose init threw, and deregister it, so that no
callback is cleaned twice; and MUST then shut down the callback executor. It MUST clean every
scene callback before any main callback, and main callbacks in the reverse of the order in
which their init was called. A second call MUST be a no-op. Called from a
callback's loop, it ends the current tick: the engine MUST NOT call
loop on any callback once clean has been called, and MUST NOT report
the call as a failure under R46. Only the callback thread calls it; see
R50.
The order is reverse dependency order. A scene callback is built from what main callbacks provide — a renderer's swap chain and surface come from the Vulkan module's device and instance — and native APIs require an object's children to be destroyed before the object itself.
R48. Once clean has been called, the engine
MUST ignore every registration whose init has not started, including one made
before the call: it MUST NOT call the callback's init, and the registration's
future MUST complete normally, immediately for a registration made after the call. It SHOULD
log that the registration was ignored.
R49. A callback's clean MUST tolerate being
called after its own init threw part-way, releasing only what init
actually acquired. Under R46 and R47, a failed
init is followed by clean.
R50. A module MAY call the engine's clean to
stop it, and MUST do so from the callback thread, as a render module's window does from its
loop when it closes. A module that needs to stop the engine from another thread
registers a main callback that calls clean from its loop.
Otherwise a callback's clean would run on that thread, against
R42, while the callback's loop may still be running.
<key>; JPMS name nested under its API's
(R1–R2).entrypoint subpackage
(R4)main entrypoint implementing ModuleInitializer
(R12)Logger and TransferQueue instances
(R14, R15)onInitialize follows the four-step order (R23)config/<key>.config, defaults on absence, dirty-then-save
(R31–R37)quilt.mod.json whose entrypoints and depends agree
(R38–R41)clean survives a failed init
(R49)prepareModInit (R6)null
(R7, R8)main entrypoints concurrently, on an effectively unbounded executor
(R21, R28)init, loop, and clean on one callback thread
(R42)init exactly once, before the first loop; every generator applied
to every scene (R43, R44)init
(R45)SEVERE before any teardown, then the engine stops
(R46)clean stops without interrupting, cleans each callback once — scene callbacks
first, main callbacks in reverse order — shuts the executor down, and is idempotent; no
loop after it, and calling it from loop is not a failure
(R47)init has not started when clean is called
are ignored, queued ones included (R48)