Developers
9 TOP-LEVEL ITEMSCustom Data Generators
From the developer's point of view, DataGenerator is the runtime object behind a generator script and also the direct worker that performs data generation.
Croparia IF ships three built-in generator types:
DataGenerator(default)AggregatedGeneratorLangGenerator
This page focuses on how to add another generator type of your own.
1. Create the generator class
Custom generator types must extend DataGenerator:
public class MyDataGenerator extends DataGenerator {
private final Template content;
public MyDataGenerator(
boolean enabled, boolean startup, List<Identifier> whitelist,
Template path, DgRegistry<? extends DgEntry> registry,
Template content, Template template
) {
super(enabled, startup, whitelist, path, registry, template);
this.content = content;
}
public Template getContent() {
return this.content;
}
}The parent DataGenerator already provides most of the common fields:
enabled- whether the generator is active at all
generate(PackHandler)checks this first and skips the whole generator if it isfalse
startup- whether generation may run before the dedicated server is fully started
- when
false, generation waits untilCropariaIf.isServerStarted()becomestrue
whitelist- restricts which entries are traversed
- when empty, the full
registryis scanned; otherwise the listedIdentifiervalues are queried directly
path- the output path template
getPath(DgEntry)resolves it into the final relative path
registry- the entry set used by this generator
generate(PackHandler)iterates from here
template- the default content template
- for a plain
DataGenerator, this is also the final per-file content template
If your generator needs extra fields of its own, such as aggregated content or special secondary templates, add them on the subclass the same way content is added above.
2. Customize the generation flow
The default generation flow in DataGenerator is fairly small:
generate(PackHandler pack)- check
enabled - check
startupagainst the current server state - decide whether to walk the whole
registryor only thewhitelist - call
generate(DgEntry entry, PackHandler pack)for every entry
The most common extension points are:
protected void generate(DgEntry entry, PackHandler pack)- the core per-entry generation logic
- the default implementation writes
getPath(entry)andgetTemplate(entry)straight intoPackHandler.cache(...) - override this when you need aggregation, pre-processing, or conditional skipping
public String getTemplate(DgEntry entry)- by default this is
this.getTemplate().parse(entry) - override it if the real content template is not taken directly from the parent
templatefield
- by default this is
public void onGenerated(PackHandler pack)- called by
PackHandler.onGenerated()after all generators finish their normalgenerate(...)phase - a good place to merge cached intermediate results into final text
- called by
public void onDumped(PackHandler pack)- called after cached data has already been written to the file system
- a good place for logging, cleanup, or post-processing
If your generator still follows the pattern "one entry becomes one file," overriding generate(DgEntry entry, PackHandler pack) is often enough.
If your generator needs to collect several entries first and output later, onGenerated(PackHandler pack) becomes the key hook.
Generated pack cache
When a generator submits data to the pack handler, the handler caches it first. Only after all generators finish does it flush those results to files.
Before the full generation pass is dumped, generators are allowed to inspect and modify that cached state. This is how AggregatedGenerator implements data aggregation.
public class AggregatedGenerator extends DataGenerator {
@Override
protected void generate(DgEntry entry, PackHandler pack) {
String path = this.getPath(entry);
@SuppressWarnings("unchecked")
Collection<Object> cache = pack.occupy(this, path).map(value -> {
if (value instanceof Collection<?> collection) {
return (Collection<Object>) collection;
} else {
return null;
}
}).orElseGet(() -> pack.cache(path, new ArrayList<>(), this));
cache.add(this.getContent(entry));
}
@Override
public void onGenerated(PackHandler handler) {
List<PackCacheEntry<?>> caches = List.copyOf(handler.getAll(this));
for (PackCacheEntry<?> entry : caches) {
StringBuilder builder = new StringBuilder();
if (entry.value() instanceof Collection<?> collection) {
for (Object s : collection) {
builder.append(s).append(",\n");
}
}
String content = builder.isEmpty() ? "" : builder.substring(0, builder.length() - 2);
handler.cache(entry.path(), this.getTemplate().parse(content, CONTENT_PLACEHOLDER), this);
}
}
// ...
}This behavior is controlled by PackCache. In practice, these methods matter most:
cache(path, value, owner)- write a cache entry by path
- if that path already exists, the old value is overwritten and ownership is transferred to the new
owner
occupy(querier, path)- read the cached value for one path
- if that path previously belonged to a different generator, ownership transfers to the current
querier AggregatedGeneratoruses this to take over collection caches for shared output paths
getAll(querier)- return every cache entry currently owned by the generator
- especially useful during
onGenerated(...)when you want to rebuild final outputs in bulk
It helps to think of PackCache as a path-keyed workspace with ownership tracking.
For a normal generator, you usually just call cache(...) once. For an aggregated generator, it behaves much more like a temporary work area that can be reclaimed and rewritten.
3. Register it
You need a MapCodec<MyDataGenerator> so the pack handler knows how to turn a parsed generator script into your runtime generator object.
To avoid rewriting the entire parent codec, the common pattern is to use Croparia IF's CodecUtil.extend(...):
public class MyDataGenerator extends DataGenerator {
public static final MapCodec<MyDataGenerator> CODEC = CodecUtil.extend(
DataGenerator.CODEC,
Template.CODEC.fieldOf("content").forGetter(MyDataGenerator::getContent),
(base, content) -> new MyDataGenerator(
base.isEnabled(), base.isStartup(), base.getWhitelist(),
base.getPath(), base.getRegistry(), content, base.getTemplate()
)
);
public static final Identifier TYPE = Identifier.of("modid:my_data_generator");
@Override
public Identifier getType() {
return TYPE;
}
// ...
}This means:
- first reuse
DataGenerator.CODECfor all inherited fields - then append the subclass-specific
contentfield - finally rebuild the new object from the parsed base data plus the extra field
If your subclass adds no new fields, a plain xmap(...) over DataGenerator.CODEC may already be enough. CodecUtil.extend(...) is most helpful when you really are extending the data shape.
Once you have the codec, register it:
static {
DataGenerator.register(Identifier.of("modid:my_data_generator"), CODEC);
}Then the script can use it directly:
type = "modid:my_data_generator"
# ...If your generator also depends on extra field access, the next supporting pieces are usually a matching placeholder resolver and one or more generator entries.