Initial commit: RackedLimbo 1.0.0

Auth-limbo + login-restore fix for Paper 1.21+. Bypasses the
AuthMe `teleportOnLogin` race (PaperMC/Paper#4085) by listening at
MONITOR priority, reading the player's saved quit-location from
AuthMe's SQLite DB, pinning the destination chunk via
addPluginChunkTicket, then chaining getChunkAtAsyncUrgently and
teleportAsync.

Bundles a void auth_limbo world via custom ChunkGenerator so the
plugin removes the need for Multiverse-Core for offline-mode auth flows.
This commit is contained in:
s8n-ru 2026-04-30 18:23:13 +01:00
commit 8cd92694e7
21 changed files with 1615 additions and 0 deletions

61
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View file

@ -0,0 +1,61 @@
---
name: Bug report
about: Report a problem with RackedLimbo
title: "[bug] "
labels: bug
assignees: ''
---
## Summary
A one-paragraph description of what is broken.
## Environment
| Field | Value |
|-------------------------|----------------------------------------|
| RackedLimbo version | e.g. 1.0.0 |
| Server software | Paper / Purpur / Folia |
| Server version | e.g. 1.21.11 build #142 |
| Java version | output of `java -version` |
| AuthMe variant | e.g. AuthMe-ReReloaded HaHaWTH b49 |
| OS | e.g. Debian 13, Windows 11, Docker |
| Other auth/world plugins| e.g. Multiverse-Core, Essentials, ... |
## Steps to reproduce
1. ...
2. ...
3. ...
## Expected behaviour
What you expected to happen.
## Actual behaviour
What actually happened. Include exact log lines.
## Logs
Paste the relevant section of `logs/latest.log` here. Search for
`RackedLimbo` to filter our log lines. If you are running with
`debug: true` in `config.yml`, please include those lines as well.
```
(paste logs)
```
## Configuration
If you have customised `plugins/RackedLimbo/config.yml`, paste the
relevant keys here.
```yaml
(paste config)
```
## Additional context
Anything else that might be relevant. Screenshots, stack traces,
related plugin versions, recent changes to the server.

46
.github/workflows/build.yml vendored Normal file
View file

@ -0,0 +1,46 @@
name: Build
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 21 (Temurin)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven
# The AuthMe jar is a system-scope dependency referenced by pom.xml
# but is not redistributed in this repo. Set the AUTHME_JAR_URL repo
# secret to a direct download URL for AuthMe-5.6.0-FORK-Universal.jar
# before this workflow can succeed. See lib/README.md for context.
- name: Fetch AuthMe jar
env:
AUTHME_JAR_URL: ${{ secrets.AUTHME_JAR_URL }}
run: |
if [ -z "$AUTHME_JAR_URL" ]; then
echo "::error::AUTHME_JAR_URL secret is not set. See lib/README.md."
exit 1
fi
curl -fsSL "$AUTHME_JAR_URL" -o lib/AuthMe-5.6.0-FORK-Universal.jar
ls -la lib/
- name: Build with Maven
run: mvn -B -ntp package
- name: Upload jar artifact
uses: actions/upload-artifact@v4
with:
name: RackedLimbo-jar
path: target/RackedLimbo-*.jar
if-no-files-found: error

44
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,44 @@
name: Release
on:
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up JDK 21 (Temurin)
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '21'
cache: maven
# See .github/workflows/build.yml for context on this secret.
- name: Fetch AuthMe jar
env:
AUTHME_JAR_URL: ${{ secrets.AUTHME_JAR_URL }}
run: |
if [ -z "$AUTHME_JAR_URL" ]; then
echo "::error::AUTHME_JAR_URL secret is not set. See lib/README.md."
exit 1
fi
curl -fsSL "$AUTHME_JAR_URL" -o lib/AuthMe-5.6.0-FORK-Universal.jar
- name: Build with Maven
run: mvn -B -ntp package
- name: Create GitHub release
uses: softprops/action-gh-release@v1
with:
files: target/RackedLimbo-*.jar
generate_release_notes: true
fail_on_unmatched_files: true

24
.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Build output
target/
*.class
# IDE
.idea/
*.iml
*.iws
*.ipr
.vscode/
.project
.classpath
.settings/
# Logs and OS
*.log
.DS_Store
Thumbs.db
# Maven wrapper (we don't ship one)
.mvn/
mvnw
mvnw.cmd
lib/AuthMe-*.jar

35
CHANGELOG.md Normal file
View file

@ -0,0 +1,35 @@
# Changelog
All notable changes to RackedLimbo are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-04-30
Initial public release.
### Added
- Auth-limbo void world manager. Creates and configures `auth_limbo` world
with no Multiverse-Core dependency.
- AuthMe `LoginEvent` listener at `MONITOR` priority. Reads saved quit
coordinates directly from `plugins/AuthMe/authme.db` (SQLite, read-only)
and forces a delayed `teleportAsync` to those coordinates after AuthMe's
own broken teleport runs.
- `AuthMeAsyncPreLoginEvent` listener for chunk preload. Pins the
destination chunk via `Chunk#addPluginChunkTicket` before login
completes to avoid the unloaded-chunk race documented in
[Paper #4085](https://github.com/PaperMC/Paper/issues/4085). Tickets
are released 5 seconds after the teleport completes.
- Optional 5x5 barrier platform at limbo spawn so unauth players can't
fall into the void.
- `/rackedlimbo reload` and `/rackedlimbo tp <player>` admin commands
gated on `rackedlimbo.admin`.
- Shaded SQLite JDBC driver (`org.xerial:sqlite-jdbc 3.46.1.3`,
relocated to `ru.racked.limbo.shaded.sqlite`) so the plugin reads
AuthMe's database without classpath collisions.
### Compatibility
- Paper / Purpur 1.21.11 (api-version `1.21`).
- Java 21.
- AuthMe-ReReloaded HaHaWTH fork b49 (`fr.xephi:authme:5.6.0-FORK-b49`)
as a hard dependency.

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 s8n-ru / racked.ru
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

162
README.md Normal file
View file

@ -0,0 +1,162 @@
# RackedLimbo
Auth-limbo + login-restore fix for Paper 1.21+.
[![Build](https://github.com/s8n-ru/racked-limbo/actions/workflows/build.yml/badge.svg)](https://github.com/s8n-ru/racked-limbo/actions/workflows/build.yml)
[![License: MIT](https://img.shields.io/badge/license-MIT-lightgrey.svg)](LICENSE)
[![Paper](https://img.shields.io/badge/Paper-1.21.11%2B-lightgrey.svg)](https://papermc.io/)
[![Java](https://img.shields.io/badge/Java-21%2B-lightgrey.svg)](https://adoptium.net/)
A small Paper plugin that fixes a chronic AuthMe-ReReloaded post-login
teleport bug and bundles a void auth-limbo world so you don't need
Multiverse-Core just to host one void world.
---
## The problem
After a player runs `/login`, AuthMe is supposed to teleport them back
to the coordinates they had when they last quit. In current
AuthMe-ReReloaded forks (verified on the HaHaWTH fork b49), this
restore-teleport often fails: players land at world spawn instead of
their saved location.
Root cause is a known Paper teleport-race documented at
[PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085).
AuthMe's `teleportOnLogin` flow calls `Player#teleportAsync` without
preloading the destination chunk first. The future resolves before the
chunk is loaded, Paper's safety logic then snaps the player back to the
nearest loaded position, and that position is world spawn.
We tried fixing this in AuthMe config. We tried removing Multiverse.
The bug kept reappearing. RackedLimbo is the long-term fix.
---
## What this plugin does
Two things, narrowly.
1. **Hosts a void `auth_limbo` world.** A custom `ChunkGenerator`
produces empty chunks, an optional 5x5 barrier platform sits under
spawn, and the world is configured with no daylight cycle, no
weather, no mob spawning, and no PvP. AuthMe can use it as its
pre-auth limbo without you installing Multiverse-Core.
2. **Overrides AuthMe's restore-teleport.** A `LoginEvent` listener at
`MONITOR` priority runs *after* AuthMe's own broken teleport, reads
the player's saved quit-location directly from
`plugins/AuthMe/authme.db`, pins the destination chunk via
`Chunk#addPluginChunkTicket`, then chains
`World#getChunkAtAsyncUrgently` into an authoritative
`Player#teleportAsync`. This is the canonical fix described in
[PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085)
and used by
[PaperLib's `AsyncTeleportPaper`](https://github.com/PaperMC/PaperLib).
That is the entire surface area. No password handling, no permissions
beyond admin commands, no register flow, no chat formatting. AuthMe owns
all of that.
---
## Install
1. Download `RackedLimbo-1.0.0.jar` from the
[Releases page](https://github.com/s8n-ru/racked-limbo/releases).
2. Drop it into your server's `plugins/` directory.
3. Restart the server (do not use `/reload`).
AuthMe-ReReloaded is a **hard dependency**. The plugin will refuse to
load without it.
For the `itzg/minecraft-server` Docker image, see
[`docs/installation.md`](docs/installation.md) for the `PLUGINS:`
environment variable form.
---
## Configuration
`plugins/RackedLimbo/config.yml` is created on first start. Defaults:
```yaml
limbo:
world: auth_limbo # Bukkit world name
spawn-x: 0.5
spawn-y: 128.0
spawn-z: 0.5
build-platform: true # 5x5 barrier under spawn
platform-y: 127
authme:
db-path: plugins/AuthMe/authme.db
teleport-delay-ticks: 10 # ticks to wait after LoginEvent
preload-chunks: true # forceload chunk before teleport
debug: false # verbose logging
```
Full reference in [`docs/configuration.md`](docs/configuration.md).
---
## Commands
| Command | Permission | Effect |
|----------------------------|---------------------|-----------------------------------------------------|
| `/rackedlimbo reload` | `rackedlimbo.admin` | Reload `config.yml`. |
| `/rackedlimbo tp <player>` | `rackedlimbo.admin` | Manually teleport a player to their saved location. |
Aliases: `/rlimbo`.
---
## Compatibility
| Component | Status | Notes |
|-----------------------------------|----------|----------------------------------------|
| Paper 1.21.11 | Yes | Primary target. |
| Purpur 1.21.11 | Yes | Same Paper API surface. |
| Folia | Unknown | Untested. Login event threading may differ. |
| AuthMe-ReReloaded (HaHaWTH b49) | Yes | Verified on production server. |
| AuthMe-ReReloaded other 5.x forks | Untested | Schema is the same, should work. |
| Multiverse-Core | Untested | Not required. Possible teleport-intercept conflict — see [`docs/compatibility.md`](docs/compatibility.md). |
---
## Build from source
```bash
mvn clean package
```
The shaded jar lands at `target/RackedLimbo-1.0.0.jar`. Requires Java
21+ and a Maven 3.9+ install. The `lib/AuthMe-5.6.0-FORK-Universal.jar`
in the repo is referenced as a `system`-scope dependency so the build
does not need any private repository credentials.
---
## Why not just use Multiverse-Core for the void world?
Multiverse-Core is a 2 MB plugin that does world creation, world
listing, dimension portals, world-specific permissions, world inventory
separation, and a dozen other things. It also intercepts teleports for
its own portal and respawn logic, which is exactly the contention point
we are trying to avoid here. RackedLimbo is ~400 lines of code and only
manages the one void world AuthMe needs. If you are already running
Multiverse for other reasons, you can ignore the limbo manager and only
benefit from the LoginEvent fix.
---
## License
MIT. See [`LICENSE`](LICENSE).
---
## Author
Built by [s8n-ru](https://github.com/s8n-ru) for [racked.ru](https://racked.ru/).

51
docs/compatibility.md Normal file
View file

@ -0,0 +1,51 @@
# Compatibility
## Tested matrix
| Component | Version | Status | Notes |
|-----------|---------|--------|-------|
| Paper | 1.21.11 | Verified | Primary target. Builds against `1.21.11-R0.1-SNAPSHOT`. |
| Purpur | 1.21.11 | Expected to work | Purpur is a Paper fork with a superset of the Paper API used here. Untested in CI but the relevant API surface (`teleportAsync`, `setChunkForceLoaded`, modern `ChunkGenerator`) is identical. |
| Folia | any | Untested | Folia's regionised threading model changes how `LoginEvent` is dispatched. The current `Bukkit.getScheduler().runTaskLater` calls would need to be reworked to use Folia's region scheduler. Do not run this plugin on Folia until it has been audited. |
| Java | 21 | Required | `pom.xml` targets Java 21. |
| Java | 22 / 23 / 24 | Expected to work | Source compatibility level is 21. No reflection or unsafe APIs are used. |
## Auth plugins
| Plugin | Version | Status | Notes |
|--------|---------|--------|-------|
| AuthMe-ReReloaded (HaHaWTH fork) | b49 | Verified | Hard dependency. The fork's SQLite schema is the one we read. |
| AuthMe-ReReloaded (other 5.x forks) | any | Expected to work | Other forks track the same upstream schema (`authme` table with `username, x, y, z, world, yaw, pitch`). The plugin will compile and load against any 5.x AuthMe binary because we depend on `fr.xephi.authme.events.LoginEvent` which has been stable since 5.0. |
| AuthMe-ReReloaded (6.x) | any | Untested | API likely the same; no reason to expect a break. |
| Original AuthMe (`fr.xephi:authme` 5.x) | any | Expected to work | Same Java package, same event class. |
| nLogin / FastLogin / SimpleAuth / others | n/a | Not supported | This plugin is hard-coupled to AuthMe events. It will refuse to load without `AuthMe` in `plugin.yml`. |
| MySQL-backed AuthMe | n/a | Not supported (yet) | The plugin reads `authme.db` directly via SQLite JDBC. If your AuthMe stores data in MySQL, the LoginEvent restore is a no-op. |
## Other plugins
| Plugin | Status | Notes |
|--------|--------|-------|
| Multiverse-Core | Untested | Multiverse intercepts teleports for portals and respawn. The two plugins should coexist because RackedLimbo runs at `MONITOR` priority and overrides whatever Multiverse does to the post-login location. If you only run Multiverse for the limbo world, you do not need it any more — RackedLimbo's `LimboWorldManager` covers that case. |
| EssentialsX | Untested | Essentials' spawn-on-join (`spawn-on-join` in `essentials.yml`) is the most common conflicting feature. With Essentials' default ordering, RackedLimbo's `MONITOR` listener still runs last and wins. If you see the spawn-on-join location winning, set `spawn-on-join: false`. |
| WorldGuard | Should not interfere | WorldGuard does not teleport on login. |
| BetterReload / PlugManX | Avoid | Hot-reloading any of (RackedLimbo, AuthMe, Paper itself) will leave dangling listeners. Always restart. |
## Database compatibility
The plugin opens `plugins/AuthMe/authme.db` with a fresh JDBC connection
per query. AuthMe also keeps a connection open to the same file. SQLite
supports multiple readers under WAL mode, which is AuthMe's default; we
never `PRAGMA journal_mode` ourselves, never write, and never wrap the
read in a transaction. There is no risk of corruption from this access
pattern.
If AuthMe is configured to use MySQL or PostgreSQL instead of SQLite,
this plugin will log a warning on first lookup and the LoginEvent
restore will be skipped. A MySQL backend is on the wishlist but not
implemented.
## Reporting compatibility results
If you run this plugin on a configuration not covered above, please
[open an issue](https://github.com/s8n-ru/racked-limbo/issues/new?template=bug_report.md)
or PR an update to this table.

142
docs/configuration.md Normal file
View file

@ -0,0 +1,142 @@
# Configuration reference
`plugins/RackedLimbo/config.yml` is created on first start with the
defaults below. Reload at runtime with `/rackedlimbo reload`.
```yaml
limbo:
world: auth_limbo
spawn-x: 0.5
spawn-y: 128.0
spawn-z: 0.5
build-platform: true
platform-y: 127
authme:
db-path: plugins/AuthMe/authme.db
teleport-delay-ticks: 10
preload-chunks: true
debug: false
```
## `limbo.*`
Settings for the void auth-limbo world. The plugin creates this world
on enable if it does not already exist.
### `limbo.world`
| | |
|---|---|
| Type | string |
| Default | `auth_limbo` |
The Bukkit world name. The world folder will live at the server data
root (`<world-name>/`). Changing this **after** the world has been
created will cause the plugin to create a second world with the new
name on next start; the old one is left on disk and you can delete it
manually.
### `limbo.spawn-x`, `limbo.spawn-y`, `limbo.spawn-z`
| | |
|---|---|
| Type | double |
| Defaults | `0.5`, `128.0`, `0.5` |
World spawn coordinates inside `auth_limbo`. The `.5` offsets put the
player on the centre of a block, which avoids fall-through edge cases
when standing on the barrier platform.
### `limbo.build-platform`
| | |
|---|---|
| Type | boolean |
| Default | `true` |
If true, the plugin builds a 5x5 patch of `BARRIER` blocks at
`platform-y` directly under spawn so unauth players cannot fall into
the void. Disable if you want to manage the platform yourself with a
schematic.
### `limbo.platform-y`
| | |
|---|---|
| Type | integer |
| Default | `127` |
Y-level of the barrier platform. Should be exactly one below `spawn-y`
so players land on top of the barriers.
## `authme.*`
Settings for the AuthMe integration.
### `authme.db-path`
| | |
|---|---|
| Type | string |
| Default | `plugins/AuthMe/authme.db` |
Path to AuthMe's SQLite database. Resolved relative to the server data
directory if not absolute. Only SQLite is supported — if your AuthMe
deployment uses MySQL, this plugin won't read your data and the
LoginEvent restore will be a no-op.
### `authme.teleport-delay-ticks`
| | |
|---|---|
| Type | long |
| Default | `10` (≈ 0.5 s at 20 TPS) |
How many ticks to wait after AuthMe's `LoginEvent` fires before issuing
our authoritative teleport. The delay exists so AuthMe's own (broken)
teleport runs first; we then overwrite the player position with the
correct one.
If you see players briefly appearing at world spawn before snapping to
their saved location, lower this value. If you see the saved-location
teleport fail because some other plugin teleports the player in
between, raise it.
### `authme.preload-chunks`
| | |
|---|---|
| Type | boolean |
| Default | `true` |
If true, the plugin listens to `AuthMeAsyncPreLoginEvent` and pins the
chunk at the saved quit-location with `Chunk#addPluginChunkTicket`
before the password check completes. This warms the chunk so the actual
`getChunkAtAsyncUrgently` + `teleportAsync` chain roughly 1 second
later does not race against an unloaded chunk
([PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085)).
The ticket is released ~5 seconds after the teleport completes — long
enough for the client to download the chunk before it can unload.
Disabling this is safe — the post-login `getChunkAtAsyncUrgently` call
will still load the chunk before teleporting — but you may see the
original teleport bug re-emerge under load if AuthMe's own teleport
fires before the chunk is ready.
## `debug`
| | |
|---|---|
| Type | boolean |
| Default | `false` |
If true, the plugin logs every chunk forceload, every teleport result,
and every "no saved location" decision at INFO level. Useful when
diagnosing a player report; noisy in production.
`limbo.world` cannot be changed without a full server restart — the
limbo world is created during `onEnable`. Other keys take effect on
`/rackedlimbo reload`.

142
docs/how-it-works.md Normal file
View file

@ -0,0 +1,142 @@
# How it works
A technical walkthrough of the bug RackedLimbo fixes and how the fix is
implemented.
## The bug
AuthMe-ReReloaded saves the player's last quit-location into its
`authme` SQLite table on `PlayerQuitEvent`. On `/login`, AuthMe calls
its internal `teleportOnLogin` flow, which builds a `Location` from the
DB row and invokes `Player#teleportAsync(savedLocation)`.
In current Paper builds, `teleportAsync` does **not** preload the
destination chunk before resolving the future. If the saved location is
in a chunk that is currently unloaded (which is the common case at
login — the player just connected, almost nothing is loaded yet), Paper
queues the chunk load asynchronously and returns a future that
"succeeds" once the player object's position field has been updated.
Several things can then race against the actual chunk arrival:
- Paper's `unknown-command-message`-style safety chunks get checked,
the destination still hasn't loaded, and the player gets snapped to
spawn.
- Other plugins (Multiverse-Core respawn, Essentials spawn-on-join,
generic safe-teleport guards) listen at the same priority and rewrite
the location.
- A `PlayerJoinEvent` listener in another plugin teleports the player
while the AuthMe teleport future is still in flight.
The end state is the same regardless of which racer wins: the player
is at world spawn instead of where they logged out. This is documented
upstream as [PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085).
## The fix
RackedLimbo intercepts the post-login flow at two points.
### 1. Pre-login: pin the chunk
`AuthMeAsyncPreLoginEvent` fires on an AuthMe worker thread before the
password is checked. We listen at `MONITOR` priority, look up the saved
quit-location in `authme.db`, and schedule a main-thread task that
calls `Chunk#addPluginChunkTicket(plugin)` on the chunk containing the
saved coordinates.
A plugin chunk ticket is the canonical Paper API for "keep this chunk
loaded for me until I release it". It does not survive a server restart
and it is per-plugin scoped, so we cannot accidentally clobber another
plugin's ticket.
This guarantees the chunk is loaded by the time AuthMe actually
authenticates the password ~1 second later, so the destination is no
longer unloaded when the teleport happens.
### 2. Post-login: authoritative teleport
`LoginEvent` fires after AuthMe authenticates and runs its own
(broken) restore-teleport. We listen at `MONITOR` priority — the
documented contract is that monitor listeners run *last* in the chain
([Bukkit Javadoc on EventPriority][priority]). So at the point our
handler runs, AuthMe has already done its thing and any other plugin
that listens at lower priority has too.
We schedule a main-thread task with a configurable delay
(`authme.teleport-delay-ticks`, default 10 ticks ≈ 0.5 s) that:
1. Re-reads the saved location from `authme.db` (cheap; SQLite, single
prepared statement).
2. Verifies the player is still online and the saved world is loaded.
3. Adds a plugin chunk ticket on the destination if one is not already
active from the pre-login phase.
4. Calls `World#getChunkAtAsyncUrgently(cx, cz)` and chains the
teleport onto its completion. This is the same pattern PaperLib's
`AsyncTeleportPaper` uses, and is the fix called out by Paper
maintainers in [PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085).
5. Inside that callback, calls `Player#teleportAsync(saved, TeleportCause.PLUGIN)`.
6. On the resulting future's success callback, schedules a second task
to release the chunk ticket 5 seconds later — long enough for the
client to download the chunk before it is allowed to unload.
If any future fails (chunk load timeout, player disconnect mid-teleport,
teleportAsync rejection), the ticket release runs anyway via the
`exceptionally` branches. Tickets cannot leak across server restarts.
## Why this is reliable
- The `addPluginChunkTicket` + `getChunkAtAsyncUrgently` +
`teleportAsync` chain is the canonical fix called out by Paper
maintainers in
[PaperMC/Paper#4085](https://github.com/PaperMC/Paper/issues/4085)
and is exactly what
[PaperLib's `AsyncTeleportPaper`](https://github.com/PaperMC/PaperLib)
does internally.
- `MONITOR` priority is contractually the last priority that runs.
Listening here means we are not in a race with other plugins that
might also try to teleport on login.
- We never modify AuthMe's database. All reads are through a
short-lived JDBC connection on a fresh `DriverManager#getConnection`,
so we cannot deadlock with AuthMe's own connection pool.
- The shaded SQLite driver
(`org.xerial:sqlite-jdbc:3.46.1.3`, relocated to
`ru.racked.limbo.shaded.sqlite`) means we do not depend on whatever
driver AuthMe ships with, and we cannot accidentally clobber it.
## Code map
All Java sources live under `src/main/java/ru/racked/limbo/`.
| File | Responsibility |
|------|----------------|
| `RackedLimbo.java` | `JavaPlugin` entry point. Loads config, builds the limbo world, registers the listener, exposes `/rackedlimbo` admin commands. |
| `AuthMeDatabase.java` | Read-only JDBC wrapper around AuthMe's SQLite DB. One method: `getQuitLocation(playerName)`. Returns a `Location` or null. |
| `LimboWorldManager.java` | Creates the `auth_limbo` world with `VoidGenerator`, sets game rules, builds the optional barrier platform. |
| `VoidGenerator.java` | Modern (1.17+) `ChunkGenerator` that produces empty chunks. Disables noise, surface, bedrock, caves, decorations, mobs, and structures. |
| `LoginListener.java` | The fix itself. Two `@EventHandler` methods on `MONITOR` priority for `AuthMeAsyncPreLoginEvent` (chunk preload) and `LoginEvent` (delayed authoritative teleport). |
## Threading model
- `AuthMeAsyncPreLoginEvent` fires on an AuthMe worker thread. Our
handler reads the DB on that thread, then schedules the actual
`setChunkForceLoaded` call on the main thread because Bukkit world
state mutation must be single-threaded.
- `LoginEvent` is dispatched async by AuthMe's HaHaWTH fork. Our
handler reads the DB on that thread, then uses
`Bukkit.getScheduler().runTaskLater` to bounce onto the main thread
for the teleport.
- `Player#teleportAsync` itself returns a `CompletableFuture<Boolean>`
whose completion runs on Paper's chunk-loading thread pool. We use
`thenAccept` for the success callback, which Paper handles correctly.
## What this plugin does not do
- It does not read or write AuthMe's password column. AuthMe owns
authentication entirely.
- It does not manage permissions, groups, or chat formatting.
- It does not handle `/register`, `/changepassword`, or any other
AuthMe command.
- It does not bypass AuthMe's own teleport — AuthMe still runs its
broken restore. We just run *after* it and overwrite the position.
[priority]: https://hub.spigotmc.org/javadocs/bukkit/org/bukkit/event/EventPriority.html

100
docs/installation.md Normal file
View file

@ -0,0 +1,100 @@
# Installation
## Prerequisites
| Component | Minimum | Notes |
|-----------|---------|-------|
| Server software | Paper 1.21.11 (or Purpur on the same MC version) | Folia is untested. |
| Java | 21 | Required by Paper 1.21+. |
| AuthMe-ReReloaded | any 5.x or 6.x fork | Verified on the HaHaWTH fork build 49 (`5.6.0-FORK-b49`). Hard dependency. |
## Bare-metal / VPS install
1. Download `RackedLimbo-X.Y.Z.jar` from the
[Releases page](https://github.com/s8n-ru/racked-limbo/releases/latest).
2. Drop the jar into your server's `plugins/` directory.
3. Restart the server. Do **not** use `/reload` — the listener wiring
does not survive a hot reload.
4. On first start the plugin will create:
- `plugins/RackedLimbo/config.yml`
- The `auth_limbo/` world directory at the server data root.
5. Tail the log and look for:
```
[RackedLimbo] Using AuthMe DB at: /path/to/plugins/AuthMe/authme.db
[RackedLimbo] Limbo world 'auth_limbo' already loaded.
[RackedLimbo] Enabled. Listening for AuthMe LoginEvent at MONITOR.
```
If you see "No saved location for ..." on a successful `/login`, that
user has no row in `authme.db` yet — log out, log back in, and the
row will be written by AuthMe on quit.
## Docker (`itzg/minecraft-server`)
This is the recommended distribution. Add the plugin via the auto-loader.
```yaml
services:
mc:
image: itzg/minecraft-server:java21
environment:
EULA: "TRUE"
TYPE: PAPER
VERSION: "1.21.11"
PLUGINS: |
https://github.com/s8n-ru/racked-limbo/releases/download/v1.0.0/RackedLimbo-1.0.0.jar
# Pin the version — itzg's auto-loader purges unrecognised jars on restart.
REMOVE_OLD_MODS: "TRUE"
REMOVE_OLD_MODS_INCLUDE: "*.jar"
REMOVE_OLD_MODS_EXCLUDE: "RackedLimbo*.jar,AuthMe*.jar,(other-plugins)*.jar"
volumes:
- ./data:/data
ports:
- "25565:25565"
```
Notes:
- Replace `v1.0.0` and `1.0.0` with the version you want to pin.
- `REMOVE_OLD_MODS_EXCLUDE` must list every jar you want kept; everything
else gets wiped on restart.
- AuthMe must also be in `PLUGINS` (or otherwise present in `plugins/`)
before this plugin loads — Paper enforces the `depend` ordering from
`plugin.yml`.
## Verifying the install
Run as an op:
```
/rackedlimbo
```
You should see:
```
RackedLimbo 1.0.0 - sub: reload | tp <player>
```
To confirm the DB read path is working, log out a test account, then run
`/rackedlimbo tp <name>` — the plugin should report the world and
coordinates it pulled from `authme.db`.
## Updating
1. Stop the server.
2. Replace the old jar with the new one.
3. Start the server.
`config.yml` is preserved across updates. Breaking config changes will
be flagged in `CHANGELOG.md` for the affected version.
## Uninstalling
1. Stop the server.
2. Delete `plugins/RackedLimbo-*.jar` and the `plugins/RackedLimbo/`
folder.
3. Optionally delete the `auth_limbo/` world directory if you no longer
want the void world. AuthMe will fall back to whatever
`Settings.RestrictionsSettings.unrestrictedWorlds` / spawn it was
using before.

0
lib/.gitkeep Normal file
View file

46
lib/README.md Normal file
View file

@ -0,0 +1,46 @@
# `lib/` — system-scope dependencies
This directory holds binary dependencies that are not available from a
public Maven repository and so are referenced as `system`-scope in
`pom.xml`.
## `AuthMe-5.6.0-FORK-Universal.jar`
The HaHaWTH fork of AuthMe-ReReloaded, build 49. Used at compile time
only — `system` scope means it is never bundled into the shaded plugin
jar.
This jar is not checked in to the public repository because:
1. It is not licensed by us — it ships under AuthMe-ReReloaded's GPL.
2. It is several MB; redistributing it bloats the repo for no benefit.
3. Any 5.x AuthMe-ReReloaded fork exposes the same `LoginEvent` and
`AuthMeAsyncPreLoginEvent` API and works as a drop-in compile target.
To build locally:
1. Download the AuthMe jar that matches the runtime you intend to
target. Verified against
[HaHaWTH/AuthMeReloaded](https://github.com/HaHaWTH/AuthMeReloaded)
build 49 (`AuthMe-5.6.0-FORK-Universal.jar`).
2. Drop it into this directory with the exact filename
`AuthMe-5.6.0-FORK-Universal.jar`.
3. Run `mvn clean package` from the repo root.
## CI builds
`.github/workflows/build.yml` and `.github/workflows/release.yml` both
include a `Fetch AuthMe jar` step that downloads the dependency before
running Maven. The download URL is read from the repository secret
`AUTHME_JAR_URL`. To make CI builds succeed:
1. Go to the repo's **Settings → Secrets and variables → Actions**.
2. Add a new repository secret named `AUTHME_JAR_URL`.
3. Set the value to a direct download URL for the AuthMe jar that
matches the version pinned in `pom.xml` (currently
`5.6.0-FORK-b49`). A GitHub release asset URL works.
Until that secret is set, the `Build` and `Release` workflows will fail
on the first run with `AUTHME_JAR_URL secret is not set`. This is the
expected gating — do not remove the check; it stops a broken jar from
shipping silently.

115
pom.xml Normal file
View file

@ -0,0 +1,115 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>ru.racked</groupId>
<artifactId>RackedLimbo</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>RackedLimbo</name>
<description>AuthMe-aware void limbo + reliable post-login teleport for Paper 1.21.11.</description>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<paper.api.version>1.21.11-R0.1-SNAPSHOT</paper.api.version>
</properties>
<repositories>
<repository>
<id>papermc</id>
<url>https://repo.papermc.io/repository/maven-public/</url>
</repository>
<repository>
<id>sonatype</id>
<url>https://oss.sonatype.org/content/groups/public/</url>
</repository>
</repositories>
<dependencies>
<!-- Paper API (provided by the server runtime) -->
<dependency>
<groupId>io.papermc.paper</groupId>
<artifactId>paper-api</artifactId>
<version>${paper.api.version}</version>
<scope>provided</scope>
</dependency>
<!-- AuthMe-ReReloaded (HaHaWTH fork b49) — provided by the server runtime.
We compile against the bundled jar and never shade it. -->
<dependency>
<groupId>fr.xephi</groupId>
<artifactId>authme</artifactId>
<version>5.6.0-FORK-b49</version>
<scope>system</scope>
<systemPath>${project.basedir}/lib/AuthMe-5.6.0-FORK-Universal.jar</systemPath>
</dependency>
<!-- SQLite JDBC driver — shaded into the plugin jar so we read AuthMe's authme.db -->
<dependency>
<groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId>
<version>3.46.1.3</version>
</dependency>
</dependencies>
<build>
<finalName>${project.name}-${project.version}</finalName>
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>
<plugins>
<plugin>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>21</release>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.3</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<createDependencyReducedPom>false</createDependencyReducedPom>
<minimizeJar>false</minimizeJar>
<relocations>
<relocation>
<pattern>org.sqlite</pattern>
<shadedPattern>ru.racked.limbo.shaded.sqlite</shadedPattern>
</relocation>
</relocations>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
<exclude>META-INF/MANIFEST.MF</exclude>
</excludes>
</filter>
</filters>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View file

@ -0,0 +1,97 @@
package ru.racked.limbo;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import java.io.File;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.logging.Level;
/**
* Read-only access to AuthMe's SQLite {@code authme} table.
*
* Schema (from AuthMe-ReReloaded fork b49):
* username VARCHAR PRIMARY KEY (lowercased real name)
* realname VARCHAR
* x DOUBLE, y DOUBLE, z DOUBLE
* world VARCHAR (default 'world')
* yaw FLOAT, pitch FLOAT
*
* We never write to this DB AuthMe owns it. We only read on login to
* find the player's last quit-location, then teleport them ourselves.
*/
public final class AuthMeDatabase {
private final RackedLimbo plugin;
private final File dbFile;
private final String url;
public AuthMeDatabase(RackedLimbo plugin, File dbFile) {
this.plugin = plugin;
this.dbFile = dbFile;
this.url = "jdbc:sqlite:" + dbFile.getAbsolutePath();
// Force-load shaded SQLite driver class so DriverManager finds it.
try {
Class.forName("ru.racked.limbo.shaded.sqlite.JDBC");
} catch (ClassNotFoundException e) {
plugin.getLogger().log(Level.SEVERE,
"[RackedLimbo] Shaded SQLite driver class missing — build is broken!", e);
}
}
/**
* Look up a player's saved quit location by exact name (case-insensitive).
* Returns null if no row, no world, or any error.
*/
public Location getQuitLocation(String playerName) {
if (playerName == null || playerName.isEmpty()) return null;
String lower = playerName.toLowerCase();
String sql = "SELECT x, y, z, world, yaw, pitch FROM authme WHERE LOWER(username) = ? LIMIT 1";
try (Connection con = DriverManager.getConnection(url);
PreparedStatement ps = con.prepareStatement(sql)) {
ps.setString(1, lower);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next()) {
if (plugin.debug()) {
plugin.getLogger().info("[RackedLimbo][debug] No DB row for " + playerName);
}
return null;
}
double x = rs.getDouble("x");
double y = rs.getDouble("y");
double z = rs.getDouble("z");
String worldName = rs.getString("world");
float yaw = rs.getFloat("yaw");
float pitch = rs.getFloat("pitch");
if (worldName == null || worldName.isEmpty()) worldName = "world";
World world = Bukkit.getWorld(worldName);
if (world == null) {
plugin.getLogger().warning("[RackedLimbo] World '" + worldName
+ "' from authme.db is not loaded — cannot restore "
+ playerName + ".");
return null;
}
return new Location(world, x, y, z, yaw, pitch);
}
} catch (SQLException e) {
plugin.getLogger().log(Level.WARNING,
"[RackedLimbo] Failed to read authme.db for " + playerName, e);
return null;
}
}
public void close() {
// No persistent connection DriverManager.getConnection() opens a new
// one per query. Nothing to close globally.
}
}

View file

@ -0,0 +1,92 @@
package ru.racked.limbo;
import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.WorldCreator;
import org.bukkit.block.Block;
import org.bukkit.configuration.file.FileConfiguration;
/**
* Creates and configures the {@code auth_limbo} world.
*
* Why a separate manager: AuthMe's pre-auth limbo logic is independent of
* Multiverse it only needs a Bukkit world to teleport unauth players into.
* We create that world ourselves with a void generator and a tiny barrier
* platform at spawn so falling unauth players don't take fall damage in void.
*/
public final class LimboWorldManager {
private final RackedLimbo plugin;
public LimboWorldManager(RackedLimbo plugin) {
this.plugin = plugin;
}
public void ensureLimbo() {
FileConfiguration cfg = plugin.getConfig();
String name = cfg.getString("limbo.world", "auth_limbo");
World world = Bukkit.getWorld(name);
if (world == null) {
plugin.getLogger().info("[RackedLimbo] Creating limbo world '" + name + "'...");
WorldCreator wc = new WorldCreator(name)
.environment(World.Environment.THE_END)
.generator(new VoidGenerator())
.generateStructures(false);
world = wc.createWorld();
if (world == null) {
plugin.getLogger().severe("[RackedLimbo] Failed to create limbo world!");
return;
}
} else {
plugin.getLogger().info("[RackedLimbo] Limbo world '" + name + "' already loaded.");
}
double sx = cfg.getDouble("limbo.spawn-x", 0.5);
double sy = cfg.getDouble("limbo.spawn-y", 128.0);
double sz = cfg.getDouble("limbo.spawn-z", 0.5);
world.setSpawnLocation((int) sx, (int) sy, (int) sz);
// World rules: no time progression, no weather, no mobs, no PvP.
world.setGameRule(GameRule.DO_DAYLIGHT_CYCLE, false);
world.setGameRule(GameRule.DO_WEATHER_CYCLE, false);
world.setGameRule(GameRule.DO_MOB_SPAWNING, false);
world.setGameRule(GameRule.DO_FIRE_TICK, false);
world.setPVP(false);
world.setKeepSpawnInMemory(true);
if (cfg.getBoolean("limbo.build-platform", true)) {
int py = cfg.getInt("limbo.platform-y", 127);
int cx = (int) sx;
int cz = (int) sz;
int built = 0;
for (int dx = -2; dx <= 2; dx++) {
for (int dz = -2; dz <= 2; dz++) {
Block b = world.getBlockAt(cx + dx, py, cz + dz);
if (b.getType() != Material.BARRIER) {
b.setType(Material.BARRIER, false);
built++;
}
}
}
if (built > 0) {
plugin.getLogger().info("[RackedLimbo] Built " + built + " barrier blocks at "
+ cx + "," + py + "," + cz + ".");
}
}
}
public Location spawn() {
FileConfiguration cfg = plugin.getConfig();
String name = cfg.getString("limbo.world", "auth_limbo");
World world = Bukkit.getWorld(name);
if (world == null) return null;
return new Location(world,
cfg.getDouble("limbo.spawn-x", 0.5),
cfg.getDouble("limbo.spawn-y", 128.0),
cfg.getDouble("limbo.spawn-z", 0.5));
}
}

View file

@ -0,0 +1,193 @@
package ru.racked.limbo;
import fr.xephi.authme.events.AuthMeAsyncPreLoginEvent;
import fr.xephi.authme.events.LoginEvent;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerTeleportEvent;
import java.util.HashSet;
import java.util.Set;
/**
* Listens for AuthMe's two relevant events:
*
* 1. {@link AuthMeAsyncPreLoginEvent} fired before AuthMe authenticates
* the password. We pin the destination chunk via a plugin chunk-ticket
* so it's fully loaded by the time the actual teleport runs.
*
* 2. {@link LoginEvent} fired AFTER AuthMe successfully authenticates
* and runs its own (often broken) post-login teleport. We listen at
* MONITOR priority so we are LAST in the chain, then fire an
* authoritative teleport that overrides whatever AuthMe / Paper safety
* checks did to the player's location.
*
* Threading:
* - AuthMeAsyncPreLoginEvent fires async (AuthMe worker thread).
* - LoginEvent in this fork fires on the main thread, but we treat it
* as if it could be async all chunk/teleport work goes through
* scheduler hops to the main thread.
* - {@code teleportAsync} and {@code getChunkAtAsyncUrgently} are
* thread-safe per Paper docs.
*
* Pattern (from Paper Issue #4085 + PaperLib AsyncTeleportPaper):
* addPluginChunkTicket(cx, cz)
* -> getChunkAtAsyncUrgently(cx, cz, true)
* .thenAccept(chunk ->
* player.teleportAsync(loc, PLUGIN)
* .thenAccept(ok -> removePluginChunkTicket(cx, cz) after 5s)
* );
*/
public final class LoginListener implements Listener {
private final RackedLimbo plugin;
private final AuthMeDatabase db;
/** Tracks active plugin-chunk-tickets so we don't double-add or fail to release. */
private final Set<String> activeTickets = new HashSet<>();
public LoginListener(RackedLimbo plugin, AuthMeDatabase db) {
this.plugin = plugin;
this.db = db;
}
/* ---------------- Pre-login: pin the chunk early ---------------- */
@EventHandler(priority = EventPriority.MONITOR)
public void onAsyncPreLogin(AuthMeAsyncPreLoginEvent event) {
if (!plugin.getConfig().getBoolean("authme.preload-chunks", true)) return;
Player player = event.getPlayer();
if (player == null) return;
final String name = player.getName();
final Location saved = db.getQuitLocation(name);
if (saved == null || saved.getWorld() == null) return;
final World world = saved.getWorld();
final int cx = saved.getBlockX() >> 4;
final int cz = saved.getBlockZ() >> 4;
Bukkit.getScheduler().runTask(plugin, () -> {
String key = world.getName() + ":" + cx + ":" + cz;
if (activeTickets.add(key)) {
try {
world.getChunkAt(cx, cz).addPluginChunkTicket(plugin);
if (plugin.debug()) {
plugin.getLogger().info("[RackedLimbo][debug] Chunk-ticket added "
+ key + " for " + name);
}
} catch (Throwable t) {
plugin.getLogger().warning("[RackedLimbo] addPluginChunkTicket failed for "
+ name + ": " + t.getMessage());
activeTickets.remove(key);
}
}
});
}
/* ---------------- Post-login: authoritative teleport ---------------- */
@EventHandler(priority = EventPriority.MONITOR)
public void onLogin(LoginEvent event) {
Player player = event.getPlayer();
if (player == null) return;
final String name = player.getName();
final Location saved = db.getQuitLocation(name);
if (saved == null) {
plugin.getLogger().info("[RackedLimbo] No saved location for "
+ name + " — leaving where AuthMe put them.");
return;
}
long delay = Math.max(0, plugin.getConfig().getLong("authme.teleport-delay-ticks", 10L));
Bukkit.getScheduler().runTaskLater(plugin, () -> doTeleport(player, name, saved), delay);
}
/* ---------------- Core teleport with chunk-prep ---------------- */
private void doTeleport(Player player, String name, Location saved) {
if (!player.isOnline()) {
plugin.getLogger().info("[RackedLimbo] " + name
+ " went offline before restore — skipping.");
return;
}
World world = saved.getWorld();
if (world == null) {
plugin.getLogger().warning("[RackedLimbo] Saved world for "
+ name + " is no longer loaded.");
return;
}
plugin.getLogger().info(String.format(
"[RackedLimbo] Restoring %s to %s(%.1f, %.1f, %.1f)",
name, world.getName(), saved.getX(), saved.getY(), saved.getZ()));
final int cx = saved.getBlockX() >> 4;
final int cz = saved.getBlockZ() >> 4;
final String key = world.getName() + ":" + cx + ":" + cz;
// Make sure the chunk is loaded before teleporting. Paper's
// teleportAsync alone has been observed to drop the player at
// world spawn briefly when the destination chunk isn't ready.
try {
if (activeTickets.add(key)) {
world.getChunkAt(cx, cz).addPluginChunkTicket(plugin);
}
} catch (Throwable t) {
// non-fatal fall through to chunk-load + teleport
}
world.getChunkAtAsyncUrgently(cx, cz).thenAccept((Chunk chunk) -> {
player.teleportAsync(saved, PlayerTeleportEvent.TeleportCause.PLUGIN)
.thenAccept(success -> {
if (Boolean.TRUE.equals(success)) {
if (plugin.debug()) {
plugin.getLogger().info("[RackedLimbo][debug] Teleport ok for " + name);
}
} else {
plugin.getLogger().warning("[RackedLimbo] teleportAsync returned false for "
+ name + " — Paper may have rejected the location.");
}
// Release the ticket 5s later gives the client time to
// download the chunk before we let it unload.
scheduleTicketRelease(world, cx, cz, key);
})
.exceptionally(ex -> {
plugin.getLogger().warning("[RackedLimbo] teleportAsync threw for "
+ name + ": " + ex.getMessage());
scheduleTicketRelease(world, cx, cz, key);
return null;
});
}).exceptionally(ex -> {
plugin.getLogger().warning("[RackedLimbo] getChunkAtAsyncUrgently threw for "
+ name + ": " + ex.getMessage());
scheduleTicketRelease(world, cx, cz, key);
return null;
});
}
private void scheduleTicketRelease(World world, int cx, int cz, String key) {
if (!activeTickets.contains(key)) return;
Bukkit.getScheduler().runTaskLater(plugin, () -> {
try {
world.getChunkAt(cx, cz).removePluginChunkTicket(plugin);
} catch (Throwable t) {
// best-effort
} finally {
activeTickets.remove(key);
if (plugin.debug()) {
plugin.getLogger().info("[RackedLimbo][debug] Chunk-ticket released " + key);
}
}
}, 20L * 5L);
}
}

View file

@ -0,0 +1,125 @@
package ru.racked.limbo;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.command.Command;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.file.FileConfiguration;
import org.bukkit.entity.Player;
import org.bukkit.plugin.java.JavaPlugin;
import java.io.File;
/**
* RackedLimbo companion plugin for AuthMe-ReReloaded.
*
* Two responsibilities:
* 1. Provide a void {@code auth_limbo} world (no Multiverse required).
* 2. Listen to {@link fr.xephi.authme.events.LoginEvent} at MONITOR priority,
* then teleport the player to their saved quit-location from authme.db
* AFTER AuthMe's own (broken) teleport has run.
*
* The DB is read directly via JDBC SQLite we never modify it.
*/
public final class RackedLimbo extends JavaPlugin {
private static RackedLimbo instance;
private LimboWorldManager limboManager;
private AuthMeDatabase authmeDb;
private LoginListener loginListener;
public static RackedLimbo getInstance() {
return instance;
}
@Override
public void onEnable() {
instance = this;
saveDefaultConfig();
FileConfiguration cfg = getConfig();
// Resolve AuthMe DB path. Server data dir is the JVM working dir under itzg.
String dbPathStr = cfg.getString("authme.db-path", "plugins/AuthMe/authme.db");
File dbFile = new File(dbPathStr);
if (!dbFile.isAbsolute()) {
dbFile = new File(getServer().getWorldContainer(), dbPathStr);
}
getLogger().info("[RackedLimbo] Using AuthMe DB at: " + dbFile.getAbsolutePath());
this.authmeDb = new AuthMeDatabase(this, dbFile);
// Build the limbo world before AuthMe gets a chance to teleport players there.
this.limboManager = new LimboWorldManager(this);
this.limboManager.ensureLimbo();
// Register listener LAST AuthMe is depend so it's already enabled.
this.loginListener = new LoginListener(this, authmeDb);
Bukkit.getPluginManager().registerEvents(loginListener, this);
getLogger().info("[RackedLimbo] Enabled. Listening for AuthMe LoginEvent at MONITOR.");
}
@Override
public void onDisable() {
if (authmeDb != null) authmeDb.close();
getLogger().info("[RackedLimbo] Disabled.");
}
public LimboWorldManager limbo() {
return limboManager;
}
public AuthMeDatabase db() {
return authmeDb;
}
public boolean debug() {
return getConfig().getBoolean("debug", false);
}
/* ---------------- /rackedlimbo admin command ---------------- */
@Override
public boolean onCommand(CommandSender sender, Command command, String label, String[] args) {
if (!command.getName().equalsIgnoreCase("rackedlimbo")) return false;
if (args.length == 0) {
sender.sendMessage("RackedLimbo " + getDescription().getVersion()
+ " — sub: reload | tp <player>");
return true;
}
switch (args[0].toLowerCase()) {
case "reload" -> {
reloadConfig();
sender.sendMessage("[RackedLimbo] config reloaded.");
return true;
}
case "tp" -> {
if (args.length < 2) {
sender.sendMessage("Usage: /rackedlimbo tp <player>");
return true;
}
Player target = Bukkit.getPlayerExact(args[1]);
if (target == null) {
sender.sendMessage("Player not online: " + args[1]);
return true;
}
Location saved = authmeDb.getQuitLocation(target.getName());
if (saved == null) {
sender.sendMessage("No saved location in authme.db for " + target.getName());
return true;
}
target.teleportAsync(saved);
sender.sendMessage("Teleported " + target.getName() + " to "
+ saved.getWorld().getName() + "("
+ (int) saved.getX() + "," + (int) saved.getY() + "," + (int) saved.getZ() + ")");
return true;
}
default -> {
sender.sendMessage("Unknown subcommand: " + args[0]);
return true;
}
}
}
}

View file

@ -0,0 +1,62 @@
package ru.racked.limbo;
import org.bukkit.generator.ChunkGenerator;
import org.bukkit.generator.WorldInfo;
import org.jetbrains.annotations.NotNull;
import java.util.Random;
/**
* Empty-chunk generator for the limbo world. Every chunk is air players will
* fall into the void unless we drop them onto a barrier platform at spawn.
*
* Uses Paper's modern generator API (1.17+) does NOT override the legacy
* {@code generateChunkData} so it works correctly with chunk caching.
*/
public final class VoidGenerator extends ChunkGenerator {
@Override
public void generateNoise(@NotNull WorldInfo worldInfo, @NotNull Random random,
int chunkX, int chunkZ, @NotNull ChunkData chunkData) {
// no-op: leave chunk as air
}
@Override
public void generateSurface(@NotNull WorldInfo worldInfo, @NotNull Random random,
int chunkX, int chunkZ, @NotNull ChunkData chunkData) {
// no-op
}
@Override
public void generateBedrock(@NotNull WorldInfo worldInfo, @NotNull Random random,
int chunkX, int chunkZ, @NotNull ChunkData chunkData) {
// no-op we don't even want a floor
}
@Override
public void generateCaves(@NotNull WorldInfo worldInfo, @NotNull Random random,
int chunkX, int chunkZ, @NotNull ChunkData chunkData) {
// no-op
}
@Override
public boolean shouldGenerateNoise() { return false; }
@Override
public boolean shouldGenerateSurface() { return false; }
@Override
public boolean shouldGenerateBedrock() { return false; }
@Override
public boolean shouldGenerateCaves() { return false; }
@Override
public boolean shouldGenerateDecorations() { return false; }
@Override
public boolean shouldGenerateMobs() { return false; }
@Override
public boolean shouldGenerateStructures() { return false; }
}

View file

@ -0,0 +1,28 @@
# RackedLimbo configuration
# Limbo world settings — pre-auth players are kept here.
limbo:
# Bukkit world name. Folder will be created at /data/<world-name>/.
world: auth_limbo
# Spawn coordinates inside the limbo world.
spawn-x: 0.5
spawn-y: 128.0
spawn-z: 0.5
# Build a 5x5 barrier platform under spawn so unauth players don't fall.
build-platform: true
platform-y: 127
# Where AuthMe's SQLite DB lives, relative to the server data dir.
# Default matches itzg/minecraft-server with a /data bind mount.
authme:
db-path: plugins/AuthMe/authme.db
# Tick delay between LoginEvent firing and us forcing the teleport.
# 10 ticks (~0.5s) lets AuthMe's own broken teleport finish first
# so our async teleport is the LAST one to run.
teleport-delay-ticks: 10
# Whether to forceload the chunk at the saved quit-loc 1s before login
# completes (via AuthMeAsyncPreLoginEvent), then unforce after teleport.
preload-chunks: true
# Set to true for verbose logs. False = only INFO on actual restores.
debug: false

View file

@ -0,0 +1,29 @@
name: RackedLimbo
main: ru.racked.limbo.RackedLimbo
version: ${project.version}
api-version: '1.21'
authors: [s8n / racked.ru]
description: AuthMe-aware void limbo + reliable post-login teleport.
website: https://racked.ru/
# Hard depend on AuthMe — we listen to fr.xephi.authme.events.LoginEvent
# so AuthMe must be present and load first.
depend:
- AuthMe
# We want to run AFTER VoidWorldGenerator (if present) so it can serve our
# limbo world with a vanilla void chunk generator if our own generator misses.
softdepend:
- VoidWorldGenerator
commands:
rackedlimbo:
description: RackedLimbo admin commands.
aliases: [rlimbo]
permission: rackedlimbo.admin
usage: /rackedlimbo <reload|tp <player>>
permissions:
rackedlimbo.admin:
description: Manage RackedLimbo at runtime.
default: op