Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,21 @@ src/generated/resources/
# Logs
*.log

# IDE — IntelliJ
# IDE - IntelliJ
.idea/
*.iml
*.ipr
*.iws
out/

# IDE — Eclipse
# IDE - Eclipse
.eclipse/
.classpath
.project
.settings/
bin/

# IDE — VS Code
# IDE - VS Code
.vscode/

# OS
Expand Down
32 changes: 16 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co

## Project Overview

FrameShift is a NeoForge 1.21.1 server-side mod for loading and pasting schematics without causing server lag or crashes. Performance safety over speed — the system must degrade gracefully rather than freeze or crash the server.
FrameShift is a NeoForge 1.21.1 server-side mod for loading and pasting schematics without causing server lag or crashes. Performance safety over speed - the system must degrade gracefully rather than freeze or crash the server.

Author: BENZOOgataga (benzoogataga.com) — Mod ID: `frameshift` — Group: `com.benzoogataga.frameshift`
Author: BENZOOgataga (benzoogataga.com) - Mod ID: `frameshift` - Group: `com.benzoogataga.frameshift`

## Working With This User

The project owner is a Java/modding beginner — do not assume familiarity with NeoForge APIs, Gradle, or Minecraft internals. When explaining or implementing anything:
The project owner is a Java/modding beginner - do not assume familiarity with NeoForge APIs, Gradle, or Minecraft internals. When explaining or implementing anything:
- Prefer clear, readable code over clever abstractions
- Add a one-line comment to every non-obvious method or field
- Explain NeoForge concepts (event buses, mod containers, config specs, etc.) in plain language when they come up
Expand All @@ -28,7 +28,7 @@ On Windows, `./gradlew` works in Git Bash. Use `gradlew.bat` in CMD/PowerShell.

## Toolchain (Non-Negotiable Versions)

These versions were arrived at after resolving real compatibility conflicts — do not upgrade without careful testing:
These versions were arrived at after resolving real compatibility conflicts - do not upgrade without careful testing:

| Tool | Version | Reason |
|------|---------|--------|
Expand All @@ -39,33 +39,33 @@ These versions were arrived at after resolving real compatibility conflicts —

## Known Build Quirks

**ASM version conflict** — NeoForge's bootstraplauncher crashes if different ASM versions appear on the module path vs classpath. `build.gradle` forces all `org.ow2.asm:*` to `9.8` via `resolutionStrategy`. Do not remove this block.
**ASM version conflict** - NeoForge's bootstraplauncher crashes if different ASM versions appear on the module path vs classpath. `build.gradle` forces all `org.ow2.asm:*` to `9.8` via `resolutionStrategy`. Do not remove this block.

**Mod descriptor filename** — NeoForge 1.21+ uses `META-INF/neoforge.mods.toml`. The old `META-INF/mods.toml` name causes a "for Minecraft Forge or older NeoForge" load error. Do not rename it back.
**Mod descriptor filename** - NeoForge 1.21+ uses `META-INF/neoforge.mods.toml`. The old `META-INF/mods.toml` name causes a "for Minecraft Forge or older NeoForge" load error. Do not rename it back.

**processResources** — Uses `def replaceProperties` (not `var`) for Groovy compatibility. The `expand()` call substitutes `${...}` placeholders in `neoforge.mods.toml` at build time. `${file.jarVersion}` is a NeoForge runtime token and must NOT be in that map — use `${mod_version}` instead.
**processResources** - Uses `def replaceProperties` (not `var`) for Groovy compatibility. The `expand()` call substitutes `${...}` placeholders in `neoforge.mods.toml` at build time. `${file.jarVersion}` is a NeoForge runtime token and must NOT be in that map - use `${mod_version}` instead.

**Gradle wrapper args** — The `gradlew` script uses the `set --` + `xargs` pattern to forward CLI arguments to `GradleWrapperMain`. The simplified single-`eval` form does not pass arguments and silently runs the default `help` task instead.
**Gradle wrapper args** - The `gradlew` script uses the `set --` + `xargs` pattern to forward CLI arguments to `GradleWrapperMain`. The simplified single-`eval` form does not pass arguments and silently runs the default `help` task instead.

**NeoForge event subscriptions** — The `@Mod` constructor receives `IEventBus modEventBus` (for mod lifecycle events) and `ModContainer modContainer` (for config registration). Config is registered via `modContainer.registerConfig(ModConfig.Type.SERVER, spec)`, not via `ModLoadingContext`.
**NeoForge event subscriptions** - The `@Mod` constructor receives `IEventBus modEventBus` (for mod lifecycle events) and `ModContainer modContainer` (for config registration). Config is registered via `modContainer.registerConfig(ModConfig.Type.SERVER, spec)`, not via `ModLoadingContext`.

**Runs DSL** — In NeoGradle 7.0.165, use `argument`/`arguments` (not the deprecated `programArgument`/`programArguments`).
**Runs DSL** - In NeoGradle 7.0.165, use `argument`/`arguments` (not the deprecated `programArgument`/`programArguments`).

## Package Structure

```
com.benzoogataga.frameshift/
├── FrameShift.java @Mod entry point — wires event buses and config
├── FrameShift.java @Mod entry point - wires event buses and config
├── command/
│ └── SchemCommand.java /schem command tree (TODO)
├── schematic/
│ ├── SchematicData.java parsed file holder (dimensions, blocks, BEs, entities)
│ └── SchematicLoader.java async file parser — returns CompletableFuture<SchematicData> (TODO)
│ └── SchematicLoader.java async file parser - returns CompletableFuture<SchematicData> (TODO)
Comment thread
BENZOOgataga marked this conversation as resolved.
├── job/
│ ├── SchematicPasteJob.java job model: state enum + 3 ArrayDeque queues
│ └── JobManager.java static registry, enforces maxConcurrentJobs
├── tick/
│ └── TickHandler.java @SubscribeEvent ServerTickEvent.Post — drains queues (TODO)
│ └── TickHandler.java @SubscribeEvent ServerTickEvent.Post - drains queues (TODO)
├── config/
│ └── FrameShiftConfig.java all ModConfigSpec entries, registered SERVER type
└── chunk/
Expand Down Expand Up @@ -101,8 +101,8 @@ com.benzoogataga.frameshift/
- `maxMillisPerTick = 8`

### Supported Formats
1. `.schem` (Sponge — primary)
2. `.schematic` (legacy — optional)
1. `.schem` (Sponge - primary)
2. `.schematic` (legacy - optional)

Both require full block state, block entity (NBT), and optional entity support.

Expand All @@ -120,7 +120,7 @@ schematics/
|---------|---------|
| `/schem list` | List available schematics with dimensions, block count, file size |
| `/schem info <name>` | Show dimensions, volume, block entities, estimated paste time |
| `/schem paste <name> [x y z]` | Paste — flags: `--ignore-air`, `--include-entities`, `--include-block-entities`, `--rotation`, `--mirror` |
| `/schem paste <name> [x y z]` | Paste - flags: `--ignore-air`, `--include-entities`, `--include-block-entities`, `--rotation`, `--mirror` |
| `/schem status` | Progress, blocks placed/remaining, ETA |
| `/schem cancel/pause/resume <jobId>` | Job lifecycle control |
| `/schem reload` | Reload config |
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@

A NeoForge 1.21.1 server-side mod for loading and pasting schematics without causing lag spikes or crashes.

**Performance safety over speed** — the system degrades gracefully under load instead of freezing the server.
**Performance safety over speed** - the system degrades gracefully under load instead of freezing the server.

---

## Features

- Progressive block placement — schematics are pasted over many ticks, never all at once
- Adaptive throttling — automatically slows down when the server is struggling (based on MSPT)
- Job control — pause, resume, and cancel pastes mid-way through
- Full NBT support — block entities (chests, signs, spawners) and entities are preserved
- Configurable limits — tune blocks-per-tick, time budget, concurrent jobs, and more
- Progressive block placement - schematics are pasted over many ticks, never all at once
- Adaptive throttling - automatically slows down when the server is struggling (based on MSPT)
- Job control - pause, resume, and cancel pastes mid-way through
- Full NBT support - block entities (chests, signs, spawners) and entities are preserved
- Configurable limits - tune blocks-per-tick, time budget, concurrent jobs, and more

## Supported Formats

Expand Down
32 changes: 16 additions & 16 deletions docs/superpowers/plans/2026-04-24-schematic-parsing-list-info.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ test {
./gradlew test --tests "com.nonexistent.*"
```

Expected: task runs and exits with "No tests found" (or 0 failures) — not a build error.
Expected: task runs and exits with "No tests found" (or 0 failures) - not a build error.

---

Expand Down Expand Up @@ -104,7 +104,7 @@ class SchematicFormatTest {
./gradlew test --tests "com.benzoogataga.frameshift.schematic.SchematicFormatTest"
```

Expected: compilation failure — `SchematicFormat` doesn't exist yet.
Expected: compilation failure - `SchematicFormat` doesn't exist yet.

- [ ] **Step 3: Create `SchematicFormat.java`**

Expand Down Expand Up @@ -199,7 +199,7 @@ class SchematicMetadataTest {
./gradlew test --tests "com.benzoogataga.frameshift.schematic.SchematicMetadataTest"
```

Expected: compilation error — `SchematicMetadata` doesn't exist.
Expected: compilation error - `SchematicMetadata` doesn't exist.

- [ ] **Step 3: Create `SchematicMetadata.java`**

Expand Down Expand Up @@ -412,7 +412,7 @@ package com.benzoogataga.frameshift.schematic;

import java.io.IOException;

// Streaming iterator over block entries in a schematic — used by future paste implementation
// Streaming iterator over block entries in a schematic - used by future paste implementation
public interface BlockStream extends AutoCloseable {
boolean hasNext() throws IOException;
SchematicBlockEntry next() throws IOException;
Expand All @@ -430,13 +430,13 @@ import java.nio.file.Path;

// Contract that every schematic format parser must implement
public interface SchematicReader {
// Returns true if this reader can handle the given file (based on extension — no I/O)
// Returns true if this reader can handle the given file (based on extension - no I/O)
boolean supports(Path file);

// Reads only the metadata (dimensions, counts) — no block data
// Reads only the metadata (dimensions, counts) - no block data
SchematicMetadata readMetadata(Path file) throws IOException;

// Opens a block-by-block stream for paste — not implemented until Milestone 3
// Opens a block-by-block stream for paste - not implemented until Milestone 3
BlockStream openBlockStream(Path file, SchematicReadOptions options) throws IOException;
}
```
Expand Down Expand Up @@ -551,7 +551,7 @@ public class SpongeSchematicReader implements SchematicReader {

@Override
public boolean supports(Path file) {
// No file I/O — just check the extension
// No file I/O - just check the extension
return file.getFileName().toString().endsWith(".schem");
}

Expand All @@ -567,7 +567,7 @@ public class SpongeSchematicReader implements SchematicReader {
+ " bytes (limit: " + maxCompressedBytes + ")");
}

// TODO: replace with bounded streaming — NbtIo.readCompressed loads the full NBT tree
// TODO: replace with bounded streaming - NbtIo.readCompressed loads the full NBT tree
CompoundTag tag = NbtIo.readCompressed(file, NbtAccounter.unlimitedHeap());

if (!tag.contains("Version", Tag.TAG_INT)) {
Expand Down Expand Up @@ -623,7 +623,7 @@ public class SpongeSchematicReader implements SchematicReader {
// Entities are at root in both v2 and v3
int entityCount = tag.getList("Entities", Tag.TAG_COMPOUND).size();

// Release the entire NBT tree — we only needed the header
// Release the entire NBT tree - we only needed the header
tag = null;

return new SchematicMetadata(
Expand Down Expand Up @@ -797,7 +797,7 @@ public final class SchematicLoader {
}

if (collected.size() > effectiveLimit) {
// We have one extra entry — that's proof there's a next page
// We have one extra entry - that's proof there's a next page
hasMore = true;
break outer;
}
Expand Down Expand Up @@ -868,7 +868,7 @@ import net.neoforged.neoforge.event.RegisterCommandsEvent;

public class SchemCommand {

// entry point — called once per server start with the active loader instance
// entry point - called once per server start with the active loader instance
public static void register(RegisterCommandsEvent event, SchematicLoader loader) {
// TODO in Task 10: build the /schem command tree using Brigadier
}
Expand Down Expand Up @@ -915,7 +915,7 @@ public class FrameShift {
NeoForge.EVENT_BUS.addListener(this::onServerStopping);
NeoForge.EVENT_BUS.addListener(this::onRegisterCommands);

LOGGER.info("FrameShift loaded — Yet Another Schematics Manager");
LOGGER.info("FrameShift loaded - Yet Another Schematics Manager");
}

private void onServerAboutToStart(ServerAboutToStartEvent event) {
Expand Down Expand Up @@ -1044,7 +1044,7 @@ public class SchemCommand {
}

if (result.nextCursor != null) {
String hint = "More results — use /schem list " + result.nextCursor;
String hint = "More results - use /schem list " + result.nextCursor;
source.sendSuccess(() -> Component.literal(hint), false);
}

Expand All @@ -1060,7 +1060,7 @@ public class SchemCommand {
// Reject names containing path separators to prevent directory traversal
if (name.contains("/") || name.contains("\\") || name.contains("..")) {
source.sendFailure(Component.literal(
"Invalid schematic name — path separators are not allowed."));
"Invalid schematic name - path separators are not allowed."));
return 0;
}

Expand Down Expand Up @@ -1231,7 +1231,7 @@ Entities: 0
/schem info ../config/frameshift-server
```

Expected: error message `Invalid schematic name — path separators are not allowed.`
Expected: error message `Invalid schematic name - path separators are not allowed.`

- [ ] **Step 7: Test missing schematic**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@

**Date:** 2026-04-24
**Milestone:** 1 (Parsing layer) + 2 (Read-only commands)
**Status:** Approved — ready for implementation
**Status:** Approved - ready for implementation

---

## Context

FrameShift needs to read schematic files from disk and expose them to server operators. This milestone implements:
1. A parsing abstraction layer (reader interface + Sponge implementation)
2. Metadata-only reads (dimensions, format, file size — no block data)
2. Metadata-only reads (dimensions, format, file size - no block data)
3. `/schem list` and `/schem info` commands backed by async I/O

Full block streaming (for paste) is out of scope here. The interfaces are defined now so paste can slot in without redesign.
Expand Down Expand Up @@ -107,7 +107,7 @@ Returns `true` if the filename ends with `.schem`. No file I/O.

### `readMetadata(Path file)`

**Temporary implementation — marked with `// TODO: replace with bounded streaming`**
**Temporary implementation - marked with `// TODO: replace with bounded streaming`**

Steps:
1. Assert file exists and is readable → `IOException` if not
Expand All @@ -129,8 +129,8 @@ Steps:
9. `tag.getList("Entities", ...).size()` → `entityCount`
- For both v2 and v3: `Entities` is at root (not inside `Blocks`)
- `BlockEntities` is also at root in both versions
10. `nonAirBlocks` = `-1` (requires full block data parse — not done here)
11. Drop reference to root `CompoundTag` — must not be retained
10. `nonAirBlocks` = `-1` (requires full block data parse - not done here)
11. Drop reference to root `CompoundTag` - must not be retained
12. Return `SchematicMetadata`

**What is never touched:** `Palette`, `BlockData`, `Blocks.Data`, individual `BlockEntity` NBT contents.
Expand Down Expand Up @@ -164,13 +164,13 @@ this.readers = List.of(new SpongeSchematicReader(
));
this.ioExecutor = Executors.newFixedThreadPool(config.metadataIoThreads.get());
```
`SpongeSchematicReader` receives only the two numeric limits it needs — it does not depend on the full config class.
`SpongeSchematicReader` receives only the two numeric limits it needs - it does not depend on the full config class.

### `shutdown()`
Called on `ServerStoppingEvent`. Calls `ioExecutor.shutdown()`.

### `findReader(Path file)`
Returns `Optional<SchematicReader>` — first reader where `supports(file)` is true.
Returns `Optional<SchematicReader>` - first reader where `supports(file)` is true.

### `readMetadataAsync(Path file)`
```java
Expand All @@ -184,13 +184,13 @@ public CompletableFuture<SchematicListResult> listAsync(Path serverRoot, int lim
```

- Resolves each entry in `config.schematicDirectories` relative to `serverRoot`
- **Shallow traversal only** — `Files.list(dir)` (one level), no recursion
- **Shallow traversal only** - `Files.list(dir)` (one level), no recursion
- Skips directories that do not exist (no error)
- Skips files where `findReader` returns empty (`skipped++`)
- Calls `readMetadata` for each candidate; on failure logs warning and increments `failed`
- **Rate-limited logging:** logs first 5 failures verbosely, then summarises the rest as `"... N more files failed, see debug log"`
- Files within each directory are sorted alphabetically by filename stem before pagination
- Cursor is the filename stem of the last returned entry — `listAsync` resumes from the first entry strictly after that name (alphabetical, case-insensitive)
- Cursor is the filename stem of the last returned entry - `listAsync` resumes from the first entry strictly after that name (alphabetical, case-insensitive)
- Returns at most `limit` entries (bounded by `config.maxListResults`)
- Runs entirely on `ioExecutor`

Expand Down Expand Up @@ -228,7 +228,7 @@ These are read at `SchematicLoader` construction time. Changes require a server
### `/schem list [cursor]`

- Permission: level 2 (OP)
- `cursor`: optional `StringArgumentType.word()` — filename stem of last seen entry
- `cursor`: optional `StringArgumentType.word()` - filename stem of last seen entry
- Dispatches `loader.listAsync(serverRoot, maxListResults, cursor)` on IO executor
- Result returned to server thread via `.thenAcceptAsync(result -> { ... }, server::execute)`
- Errors returned to server thread via:
Expand All @@ -240,13 +240,13 @@ These are read at `SchematicLoader` construction time. Changes require a server
```
- Return values: `1` on success, `0` on failure
- Output format per entry: `name WxHxL filesize`
- Footer: if `nextCursor != null`, shows `"More results — use /schem list <cursor>"` with the cursor value
- Footer: if `nextCursor != null`, shows `"More results - use /schem list <cursor>"` with the cursor value
- Shows skipped/failed counts if non-zero

### `/schem info <name>`

- Permission: level 2 (OP)
- `<name>`: `StringArgumentType.greedyString()` — filename stem (no extension), searched across configured directories in order
- `<name>`: `StringArgumentType.greedyString()` - filename stem (no extension), searched across configured directories in order
- If name contains path separators, reject with a clear error
- Resolves to first matching file found across directories
- Dispatches `loader.readMetadataAsync(path)` on IO executor
Expand Down
Loading