What a module is
CT-Main is a bare host. It scans a modules folder on every start, reads each jar's ct.module.json, sorts the modules by the ids they require, and loads each one in its own class loader. A loaded module can register views, publish services, and provide commands; a module whose requirements are missing is skipped with a reason, and CT-Main keeps running with the rest.
The jar has to do two different jobs, and that is the part most people get wrong:
- As a module it is loaded by CT-Main, which already provides the platform API classes. The module jar should not contain them.
- As a file it is run by a person, straight after download, with nothing else on the classpath. It has to install itself, so it needs a
Main-Classand a copy of the installer code inside.
Both jobs are satisfied by the packaging shown below: the module classes stay theirs, and only the small installer package from the platform is baked in.
1. Project layout
One folder per module. This example builds a module with the id hello that adds a view to CT-Main.
ct-module-hello/
├── pom.xml
└── src/main/
├── java/com/example/hello/
│ ├── HelloModule.java
│ └── HelloPanel.java
└── resources/ct.module.json
2. The pom
The module compiles against ct-main for the platform API. The shade plugin packs exactly two things into the finished jar: the module's own classes, and the com.ct.main.moduleinstall package from ct-main — everything else from the platform, and every transitive dependency, is filtered out. The manifest transformer sets the entry point that makes the file runnable.
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>ct-module-hello</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<ct.version>4.3.0</ct.version>
</properties>
<dependencies>
<dependency>
<groupId>com.ct</groupId>
<artifactId>ct-main</artifactId>
<version>${ct.version}</version>
<exclusions>
<exclusion>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<artifactSet>
<includes><include>com.ct:ct-main</include></includes>
</artifactSet>
<filters>
<filter>
<artifact>com.ct:ct-main</artifact>
<includes><include>com/ct/main/moduleinstall/**</include></includes>
</filter>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.ct.main.moduleinstall.ModuleInstaller</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Why the exclusion? Without it, Gson rides in transitively from ct-main and every module jar grows by a few hundred kilobytes of a library the host already provides. Add the library back with compile scope only if your module must run outside the host, the way the ctloader module does inside the game process.
Coming from the CT source tree? The parent pom.xml already carries this configuration for every ct-module-* module; a new module in the reactor only declares the maven-shade-plugin and inherits it.
3. ct.module.json
The manifest is what CT-Main reads before it loads anything. It lives at the root of the jar.
{
"schemaVersion": 1,
"id": "hello",
"version": "1.0.0",
"name": "Hello Module",
"description": "Adds a hello view to CT-Main.",
"requires": ["theme"],
"entrypoint": "com.example.hello.HelloModule"
}
| Field | Required | Meaning |
|---|---|---|
schemaVersion | yes | Manifest format version; 1. |
id | yes | Module identity. Lowercase, matches [a-z][a-z0-9_-]{0,63}, unique in the modules folder. |
version | no | Your build's version. The installer uses it for the installed file name and to replace older copies; it falls back to the file name. |
name | no | Display name in the console and the Modules view. Defaults to the id. |
description | no | One line shown next to the module. |
requires | no | Module ids that must load first. The module is skipped, with a reason, when one is missing or failed. |
entrypoint | yes | Fully qualified class implementing com.ct.main.api.CtModule with a public no-argument constructor. |
4. The module class
CtModule.initialize is called once, after every module it requires has loaded. Everything the host offers arrives through ModuleContext.
package com.example.hello;
import com.ct.main.api.CtModule;
import com.ct.main.api.ModuleContext;
public final class HelloModule implements CtModule {
@Override
public void initialize(ModuleContext context) {
context.registerView("Hello", new HelloPanel(context));
context.console().writeLine("Hello module ready.");
}
}
HelloPanel is an ordinary JComponent. If you require the theme module you can look its service up and use the CT widgets; otherwise a plain panel keeps your module independent.
package com.example.hello;
import com.ct.main.api.ModuleContext;
import java.awt.BorderLayout;
import javax.swing.JLabel;
import javax.swing.JPanel;
public final class HelloPanel extends JPanel {
public HelloPanel(ModuleContext context) {
setLayout(new BorderLayout());
add(new JLabel("Hello from " + context.mainVersion()), BorderLayout.CENTER);
}
}
The whole API in one line each: registerView(title, component) adds a sidebar entry; console().writeLine(text) writes to the console view; publishService(type, instance) and service(type) connect modules to each other; accounts() reads the selected account; applicationDirectory(), modulesDirectory(), and the settings module's store give you paths and settings; implement CommandProvider as well to answer a ct command line verb. The API page has the full reference.
5. Build the runnable jar
mvn package
In the CT source tree use the wrapper instead — ./mvnw package — and the jar appears at ct-module-hello/target/ct-module-hello-1.0.0.jar. Check what you produced:
unzip -l target/ct-module-hello-1.0.0.jar | grep -E 'ct.module.json|moduleinstall|com/example'
unzip -p target/ct-module-hello-1.0.0.jar META-INF/MANIFEST.MF | grep Main-Class
You want exactly three kinds of entry: your own classes, ct.module.json, and the com/ct/main/moduleinstall/ classes — with Main-Class: com.ct.main.moduleinstall.ModuleInstaller in the manifest. If you see com/ct/main/api or com/google/gson in there, the filters in the pom are not applied.
6. Run it — the module installs itself
Run the jar with no arguments. It copies itself into the modules folder CT-Main uses and prints what it found:
java -jar target/ct-module-hello-1.0.0.jar
CT module installer
module Hello Module (hello 1.0.0)
source /home/you/ct-module-hello/target/ct-module-hello-1.0.0.jar
modules /home/you/.ct-client/modules (recorded by CT-Main)
installed ct-module-hello-1.0.0.jar (24118 bytes)
Modules in the folder (5): hello, installer, settings, theme, versions
Every module this one requires is installed.
Start CT-Main and it loads this module automatically:
java -jar CT-Main-4.3.0.jar
The folder is resolved in this order, and the choice is printed with the result:
- 1
--modules-dir <path>Explicit always wins.
- 2
CT_MODULES_DIRThe environment variable, for scripted installs.
- 3
A
modulesfolder next to the jar or next to a CT-Main jarSo a module downloaded into the same folder as CT-Main lands where that launcher reads.
- 4
The folder CT-Main recorded
Every CT-Main start writes the folder it used to
~/.ct-client/last-modules-dir. This is what makes a module downloaded to any other folder still land in your launcher's folder. - 5
~/.ct-client/modulesThe default when CT-Main has never run.
Other options: --print reports the target folder without writing, --remove uninstalls the module, --launch starts the CT-Main jar it found, --no-gui never opens the result window, --help lists everything.
On a desktop, running the jar with no console — a double-click — shows the same report in a window instead of printing it.
Or drop it onto CT-Main
CT-Main accepts module jars as arguments, which is exactly what a file manager sends when you drag files onto it. Drop one module jar, several at once, or a folder holding them onto CT-Main-4.3.0.jar and every module found is installed into the folder that copy of CT-Main reads, before it scans for modules — so the window that opens already has them:
java -jar CT-Main-4.3.0.jar target/ct-module-hello-1.0.0.jar
Installing dropped modules into /home/you/.ct-client/modules
installed ct-module-hello-1.0.0.jar — Hello Module (hello 1.0.0) (24118 bytes)
Installed 1 module(s).
...
Loaded 2 module(s), skipped 0.
module hello 1.0.0 (Hello Module)
Paths that are not module jars, and paths that do not exist, are reported and skipped; a path that is a folder is searched for module jars. If a launcher is already open on the same modules folder, the dropped copy installs the modules, reports what it did, and stops — close that window and start CT-Main again to use them.
7. What CT-Main gains
Start CT-Main again — or, if you dropped the jar onto it, watch the window that just opened. Nothing else to configure:
java -jar CT-Main-4.3.0.jar
CT-Main 4.3.0 — modules folder: /home/you/.ct-client/modules
Loaded 6 module(s), skipped 0.
module cli 1.0.0 (Command Line)
module hello 1.0.0 (Hello Module)
...
- The console lists the module with its id, version, and name.
- The sidebar gets one entry per registered view — in this example a Hello tab.
- The Modules view lists it with its description, and shows the modules folder path with an open-folder button.
- If the module publishes a service, every module loaded after it can look that service up by type.
- If it provides a
CommandProvider,java -jar CT-Main.jar <command>answers to it, and it is listed when no module handles the typed command.
Nothing else to register. Installing the jar is the whole integration. Add a module jar and the feature appears; delete it with --remove and the feature is gone on the next start, with everything else untouched.
8. Verify it without a display
Two smoke flags make a module testable on a build machine. The first loads the modules and prints the report, including skip reasons; the second renders every registered view offscreen to PNG files, so you can look at your view without opening a window.
java -Dct.smoke=modules -jar CT-Main-4.3.0.jar
java -Dct.smoke.render=/tmp/views.png -jar CT-Main-4.3.0.jar
Point either one at your test folder with --modules-dir <path> to check a build before installing it.
9. Without Maven
A module jar is a zip with a manifest. The slow way works too — compile against a CT-Main jar, unpack the installer package from it, and write the manifest yourself:
javac --release 21 -cp CT-Main-4.3.0.jar -d classes $(find src/main/java -name '*.java')
mkdir -p staging
cp -r classes/. staging/
cp src/main/resources/ct.module.json staging/
cd staging && unzip -o ../CT-Main-4.3.0.jar 'com/ct/main/moduleinstall/*' && cd ..
jar --create --file ct-module-hello-1.0.0.jar \
--main-class com.ct.main.moduleinstall.ModuleInstaller \
-C staging .
Keep com/ct/main/api out of the staging folder: the host supplies it, and a stale copy inside a module jar is a good way to get a NoSuchMethodError after a platform update.
10. When it does not load
| Console says | Cause and fix |
|---|---|
skipped hello: requires missing module 'theme' | A required id is not installed. Run that module's jar once, or drop requires if the dependency is optional. |
skipped hello: duplicate module id; keeping ct-module-hello-1.0.0.jar … | Two jars carry the same id. Delete the older one, or run the newer jar: the installer removes the older copy for you. |
Module jar is missing ct.module.json | The manifest did not make it into the jar. Check that it is under src/main/resources, at the root of the output. |
Module manifest is invalid / Invalid module id | The JSON is malformed, or the id breaks the lowercase rule. Ids are [a-z][a-z0-9_-]{0,63}. |
Module entrypoint must implement com.ct.main.api.CtModule | entrypoint names the wrong class, or the class does not implement CtModule. |
skipped hello: initialization failed: … | The constructor or initialize threw. The message carries the exception; fix it and start CT-Main again. |
no main manifest attribute when running the jar | The jar is not runnable: the manifest transformer is missing from the pom, or the shade execution did not run. |
11. Publishing your module
A module needs no registry. To make it downloadable from this site, put the jar in HTML/downloads/modules/ under the name ct-module-<id>-<version>.jar and add it to the module catalog in HTML/assets/site.js:
{ id: "hello", name: "Hello Module", requires: ["theme"],
description: "Adds a hello view to CT-Main." }
The catalog table, the download links, the module count, and the search index entry all come from that one array. Someone else then installs your feature exactly the way this page describes: download, run once, restart CT-Main.