Curved Spacetime Module System Specification

Version
0.1.0-build.97
Status
Normative, and violated: describes how the module system should work. Where the code disagrees, the code is wrong.
Licence
GPL-3.0-or-later

1. Introduction

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.

1.1 Versioning of this document

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 stageA new specification version is cut
Before 0.1.0 (current)per build
0.x.yper minor version
1.0.0 and laterper 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.

2. Conformance

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.

3. Terminology

engine
The singleton CurvedSpacetimeMainModuleEngine. It owns the configuration, the scene list, and the callback executor; it drives initialization, then runs callbacks.
loader
An implementation of CurvedSpacetimeLoader. It maps an entrypoint name and type to a list of entrypoint instances.
module
A unit of functionality with its own JPMS module, Gradle subproject, configuration file, and at least one entrypoint.
API module
A module that publishes an interface other modules implement or extend — for example curved-spacetime-render-module.
implementation module
A module that provides a concrete realisation of an API module — for example curved-spacetime-render-glfw-module.
module key
The module's directory name. For curved-spacetime-render-glfw-module the module key is curved-spacetime-render-glfw-module. Several other names derive from it.
main entrypoint
A module's implementation of ModuleInitializer, registered under the entrypoint name main.
dependent entrypoint
A class registered by module B under module A's dependent entrypoint name, by which B learns of A's main entrypoint.
callback
A MainCallback, or a SceneCallback bound to one scene: module code the engine runs on its callback thread once initialization is complete. See §10.

4. Module identity and naming

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.

DirectoryJPMS 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 nameFormExample for curved-spacetime-render-glfw-module
Configuration fileconfig/<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 groupthe JPMS module name io.codetoil.curved_spacetime.render.glfw

5. The loader interface

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.

6. Entrypoints

6.1 The main entrypoint

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.

6.2 Dependent entrypoints

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:

RoleName
Entrypoint namecurved_spacetime_render_module_dependent
Interface io.codetoil.curved_spacetime.render.entrypoint.CurvedSpacetimeRenderModuleDependentModuleInitializer
Implementation io.codetoil.curved_spacetime.render.glfw.CurvedSpacetimeRenderModuleDependentCurvedSpacetimeRenderGLFWModuleEntrypoint

7. Initialization protocol

7.1 Engine sequence

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.

7.2 Module sequence

R23. A module's onInitialize MUST, in order:

  1. set its logger's level from the engine's configuration, add the engine's console handler — getConsoleHandler() — to that logger, and stop the logger forwarding to its parent handlers;
  2. load its own configuration, and save it if isDirty() reports the load substituted defaults;
  3. if it declares dependencies, receive them per §7.3;
  4. invoke its own dependent entrypoints, passing itself.

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.

7.3 The dependency handshake

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.

8. Configuration

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

9. Quilt manifest

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" }
    ]
  }
}

10. Callbacks

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.

10.1 Lifecycle

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.

10.2 Failure and shutdown

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.

11. Conformance checklist

11.1 A conforming module

11.2 A conforming loader

11.3 The engine's callbacks