Anvil
Architecture

Runtime composition

Connect scoped services through global registration and typed assembly bindings.

AnvilLauncher.builder() defers assembly until build(). LauncherAssembly then acquires shared services and creates the default scenario factory. The launcher supplies that factory directly to AnvilEngineBuilder, transfers assembly ownership to the engine, and installs caller extensions. An EngineExtension adds ScenarioExtension callbacks and transfers shared resources through EngineRegistration.own. Registration closes before the engine accepts scenarios.

The factory is a required construction dependency, independent of optional extensions. A bare AnvilEngineBuilder(factory) can build an engine without registering any extensions. The factory is borrowed: a custom assembly either retains its resources or transfers them through own. Failed launcher assembly releases acquired services; failed extension installation releases transferred resources in reverse order, with assembly services released last.

The default scenario factory belongs to launcher assembly. ScenarioFactory.create(scenario, observer) binds scoped services and returns a ScenarioContext before process startup. That context owns prepared resources, starts its process group, and closes players before finalizing processes. The engine retains it immediately and installs global attachments and setup once, after complete startup reaches readiness.

ScenarioEngine.prepare(...) exposes that owned context for individual controls. ScenarioEngine.start(...) prepares a context and completes its start() before returning. JUnit, the foreground runner, and the IDE session share these phases; individual controls retain one wired environment across server/proxy starts and stops.

Compose capabilities for each owner

Every installed ProtocolLibraryProvider can serve players. Player management selects a library and an exact release for each player: its declared protocolLibrary, else the scenario's or the engine's, else the installed library with the strongest support level for its Minecraft version. Launcher composition keeps one PlayerCapabilityRuntime per selected library, created on demand. It filters protocol-backed providers by their supportedLibraries(), which only capabilities using one library's own services declare, and skips those whose requiredProtocols() the library's ProtocolLibraryProvider.protocols() lacks, with that reason. A provider that cannot apply to a library yields to an applicable provider of the same capability, such as a library's own Session, instead of competing with it. Agent-backed process capability selection uses each process's declared platform, independently of player creation.

Shared capability composition validates dependencies, rejects competing providers for one capability type within an owner, and orders creation by declared predecessors. Before capabilities are created, neutral and protocol-backed player factories contribute to one validated player dependency graph. Filtering protocol adapters does not skip duplicate or dependency checks for the remaining providers. For a player whose native worker does not install a protocol-backed provider's capability, and for a worker-backed provider of a player without a worker, composition skips that provider and every provider depending on it, and records the reason for CapabilityUnavailableException. CapabilityPlayerComposerProvider composes players and the launcher binds process factories to their agents:

ProviderOwner and context
PlayerCapabilityProviderOne player, with identity/version, observations, declared dependencies, and cleanup
ProtocolPlayerCapabilityProviderOne player, with the shared context plus its protocol channel and the services of the player's protocol library
AgentProcessCapabilityProviderOne logical process, with its platform, request channel, and declared process dependencies

All capability providers use shared descriptor, dependency, and cleanup contracts from capability-api. Neutral player providers work with shared observations; they do not select a protocol library or receive its services. Protocol providers live in capability-player-api; the ProtocolChannel their context hands them, with player events, waits, and installed-capability discovery, and the native worker contracts belong to player-api. Agent-backed process factories live in capability-process-api and request a typed RequestChannel, which launcher bindings connect to an agent client. The protocol-backed player context extends the shared player context.

Embedded handlers consume agent-server-api; both agent role APIs share native operation descriptors from agent-api without depending on each other. Feature wiring derives capability channel descriptors from shared native operation metadata. Neither the capability nor agent API families expose the other's services, and assembly performs their typed binding.

RunningProcess implements CapabilityOwner<ProcessCapability>, so consumers retrieve capabilities directly from the process without creating a player. Global process contracts do not require agent APIs. A custom process implementation can supply capabilities through the same lookup contract.

ProcessCapabilityRuntime.discover selects the agent-backed factories that support each process's platform; the launcher's ProcessComposition only tells it which planned processes run an agent and supplies their request channels. Capability-owned adapters feed ProcessCapabilityRuntime, which validates each process graph and retains one logical ProcessCapabilities owner. Launcher assembly supplies that core CapabilityOwner as an execution input. Managed process handles delegate capability lookup to the owner directly; Launcher preparation initializes each owner from PreparedLaunch.started() after agent attachment. CapabilityProcessGroup finalizes the logical owners before the processes; execution operations and handles stay with managed execution.

Capabilities initialize after their process first reaches readiness and survive generation replacement. Execution assigns the immutable RunningProcess.executionId() UUID, so the observer, lookup, and tooling share one authoritative execution identity. The agent client reconnects to the new generation, and requests during disconnection report unavailability. Closing the process capability owner makes lookups fail and availability checks return false.

Agent directory entries identify registered process clients, including processes that have not started. AgentConnection.available() reports whether a connection is attached and locally open; it does not probe remote health or guarantee a later request succeeds. Requests made without an open connection throw AgentUnavailableException, which a capability's request channel reports as ChannelUnavailableException; communication and remote operation failures stay visible as they are.

Every platform agent provides a few general facts once, so capabilities do not each reimplement them: ping for readiness, command for the platform console, and player-events, the players' joins and leaves. Platform agents report the events to the PlayerEvents of their endpoint, which keeps a bounded number of them for one process generation and says whether the process is a proxy. Launcher assembly reads them through ScenarioPlatforms: one ProcessPlatform per process, which numbers the events itself, ends the stays a restarted generation left open, and leaves out what a retained process reported before the scenario began. Process capabilities reach their own process's platform through ProcessCapabilityContext.platform(); its events() are that process's events only. Player capabilities get no event feed: what a process knows is read from that process.

Built-in capabilities are owned by whoever can normally do or know the thing. What a client can do or see belongs to the simulated player; what a server or proxy can do or know belongs to the running process, and a proxy counts as server side. The implementation never decides it. What a server or proxy knows about the players on it therefore belongs to the built-in Players process capability, not to core and not to the player. Its agent-backed provider reads platform().events() of its own process and finds a simulated player by the UUIDs of SimulatedPlayer.grantedProfiles(), every profile the client was granted at its logins. It does not depend on Session or the protocol capability API. Core keeps only what the client knows itself: the requested and granted profiles. The built-in Dispatch capability runs its commands through platform().execute(...). External protocol libraries may expose their own stable SDK service interfaces through the protocol player's explicit extension hook.

Anything else a capability needs from a platform goes through an agent extension. An agent-backed provider can declare the extension JARs its capability needs through agentExtensions(): platform IDs and a class each JAR contains. Launcher assembly collects them from every installed provider, finds each JAR on the runtime class path as it finds platform agents, and platform planning adds them to the workspace of every process of those platforms under plugins/anvil-agent-extensions.

Bind preparation to execution

Platform planning resolves Java requirement/source precedence, process roles, startup dependencies, and game endpoint exposure in ProcessPlan. Launcher assembly maps those values to execution inputs. JavaSelection groups requirement and source at engine, scenario, and process levels. Planning fills omitted members independently, then JavaRequirementPlanner reads the provider's version data (PlatformProvider.versionData()), selects one exact LTS feature version from the applicable Java row raised to the agent's declared minimum Java, adds a maximum bypass property as a planned JVM argument when needed, and applies the support policy. The resolved selection passes through ProcessPlan and execution's ProcessRequest; Java provisioning and Docker image mapping resolve exactly that feature version. Custom planners must supply a requirement with a feature version; its source may remain unspecified. They also supply proxy, publishGame, and ordered dependencies. NetworkPolicy carries declared bind address, LAN permission, exposure, and access constraints into execution. Provider-translated process endpoints remain separate runtime values.

Managed execution receives an ExecutionPlan containing locations, readiness rules, resource budgets, and dependencies. Its separate ExecutionPreparation boundary prepares files once the complete topology has been allocated. The returned PreparedProcess lives across restarts; PreparedLaunch supplies each generation's command and attachment lifecycle.

Launcher bindings apply workspace and platform policies through their scoped APIs, create fresh agent credentials for each launch, and attach connections after readiness. Managed execution calls those lifecycle boundaries and releases targets before prepared inputs are finalized. It has no cache, Java-provisioning, workspace, platform-provider, or agent API dependency.

Other bridges follow the same direction: artifact storage uses cache coordination, Java acquisition uses verified artifact resolution, and protocol runtime resolution requests exact pinned worker artifacts. The consumer's API describes the required operation. Its provider and policy remain in the owning family.

Keep native worker behavior scoped

The MCProtocol library reads its releases from mcprotocol-releases.toml, resolves each selected release's pinned runtime closure into <cache>/protocol/mcprotocol/<release version>/ and starts one isolated worker per release. A release with an unpinned artifact cannot be launched. Preparation prints a warning for each server whose default players would use it, so a scenario whose players all choose another library still runs. A player that selects such a release is refused when it is created. The worker shell never links against MCProtocolLib: the release's client segment implements the mcprotocol-api client port and owns login, client information, teleport and disconnect packets. Feature wiring implements player-api's WorkerExtension directly; the worker discovers it. Its PlayerBindingContext supplies SDK/session access for the existing native player and, through adapter(Class), the release-specific port implementation of the segment selected for the worker's release. Native implementations own the actual packets, state, and listeners; feature APIs keep domain models free of transport types.

OperationRegistry registers a typed handler for an OperationDescriptor. Every library's worker runs extensions through the player family's worker kits: player-worker's WorkerExtensions enforces binding-time registration, unique namespaced operation IDs, unavailability and cleanup, NativeSessionBindings follows session generations, and protocol-java-worker's JavaPacketDispatcher is the player's JavaProtocol over the library's JavaConnection. The MCProtocol worker adds only its client, segments and transport: WorkerMessageCodec owns request decoding, response type checks, and encoding on both ends of the worker's transport, so the host's ProtocolChannel and the worker's handlers operate on typed values.

Each binding owns one player's state. Native listeners attach before login and are replaced with their connection generation. The binding survives reconnects until player destruction; old native session handles retain their original identity. Callbacks check their captured generation before updating observations, including callbacks already in flight during listener removal.

The worker class path is the release's runtime closure followed by the host class path. Of the host entries that carry segment properties, only the selected segment of each owner stays: the one with the greatest start version not above the release key. A kept host entry that carries netty, MCProtocolLib, CloudburstMC or adventure classes is refused, because those come only from the closure. The worker verifies each selected segment's linkage manifest against the loaded release, including the access, static or instance, and class or interface requirements the build recorded; a client segment that does not link fails the worker, and an adapter of any other segment that does not link is unavailable, so the capability asking for it is reported unavailable with the first failing member. JSON envelopes, host request transport, and dispatch implementations stay private. The library sends online credentials to workers only through private stdin; task inputs and diagnostics never contain them.

Package at the outer boundary

The launcher packages default engine and service implementations, including the host-side agent-client. Platform providers and protocol libraries remain explicit runtime additions. Platform-agent assemblies package agent-server for their target platforms, and feature wiring bundles install their selected capability implementations.

The bundle convention merges service files and shades the declared embedded configuration. Public API artifacts keep one shared class identity. Keep plain and shaded filenames distinct and publish the intended artifact once. Resolve installed agents from exact classpath artifacts.

External agent operations load through a process-owned extension class loader. The endpoint closes that loader during shutdown and failed startup. Host capabilities borrow scenario-owned request channels; they do not own the endpoint or its class loader.

Run discovery tests with the independently packaged fixture, then compile a separate consumer against published artifacts. Worker or agent changes also require exercising their real process boundary. See Testing Anvil.

On this page