A Paper server-side plugin that records every C2S and S2C packet, for every player, all the time, into a compact, crash-tolerant, self-describing format.
- Supported Minecraft versions: 1.21.11 and 26.2 - one jar, both servers
- Folia supported - regionised threading, verified on Folia 1.21.11
- Primary development target: 1.21.11
- Java: 21 baseline (see Multi-version support)
- Manifest:
src/main/resources/paper-plugin.yml
./gradlew build # -> build/libs/recorder-dev.jar
./gradlew build -Pversion=1.1.0 # -> build/libs/recorder-1.1.0.jar
./gradlew test # format round-trip, crash recovery, segment split, protocol table
./gradlew runServer # Paper 1.21.11 in run/
./gradlew runServer26_2 # Paper 26.2 in run-26.2/
./gradlew runFolia # Folia 1.21.11 in run-folia/First run stops at the Mojang EULA: set eula=true in that server's eula.txt.
Each version gets its own run directory. That is not tidiness - a world saved by a newer server
cannot be opened by an older one, so sharing run/ across versions breaks the older server.
One jar covers the whole range because it is compiled against the oldest supported API:
compileOnly("io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT") // baselineApi
options.release = 21 // baselineJava
The reverse cannot work. paper-api for 26.2 ships Java 25 class files, which a 1.21.11
server's Java 21 runtime refuses to load outright - so building against the newest API would lock
the jar to the newest server. Building against the oldest costs nothing, because Paper keeps its
plugin API backwards compatible.
The Gradle toolchain stays at Java 25 even though the plugin targets 21: it is what runs a Paper
26.2 test server. options.release = 21 is what governs the emitted bytecode, and it also makes
javac reject any Java 22+ API before it can reach a 1.21.11 server.
api-version in paper-plugin.yml is set from the same baseline, so it declares 1.21.11 and newer
Paper accepts it.
To add a version, put it in supportedMinecraftVersions in build.gradle.kts. A run task is
generated for it automatically, and runFolia tracks the primary version. If the new version is
older than the current baseline, move baselineApi and baselineJava down to match it and
rebuild - that is the only change that can break compilation.
Nothing in the plugin branches on version at compile time. ProtocolTable is built from
PacketEvents at runtime for the server packet protocol, and the config filters match on packet
names rather than ids. That is why one build serves every supported server. With ViaVersion, the
player's original client protocol is retained separately as session metadata.
folia-supported: true, and the same jar runs on Paper and Folia unchanged.
The recorder never had a main-thread assumption to lose. Capture runs on netty event loops, writing runs on its own thread, and neither touches the Bukkit API - that was already true before Folia was considered, and it is why regionised threading needed so little.
What did change is the places that touch a Player. Folia has no main thread and
Bukkit.getScheduler() throws UnsupportedOperationException there, so those go through
util/Schedulers, which uses the regionised scheduler API:
| Where | How |
|---|---|
| overflow disconnect | global region thread looks the player up, then that player's EntityScheduler kicks them |
| retention sweep | AsyncScheduler.runAtFixedRate, cancelled on disable |
There is nothing to branch on: plain Paper implements the same regionised scheduler API, so one code path is correct on both.
RecordingLocator caches its directory scan for 5 seconds. Tab completion calls it on every
keystroke, and on Folia that scan would otherwise block a region thread and lag every player in it.
Pruning bypasses the cache, since deleting files from a stale listing would be wrong.
PacketEvents ships its own FoliaCompatUtil, so the dependency is Folia-aware too.
-
PacketEvents (
com.github.retrooper:packetevents-spigot) - netty channel access and the packet id tables. Packed into the plugin jar, unrelocated, together withadventure-nbtandexamination, which Paper has but does not expose to a plugin classloader.It cannot be a Paper library. Paper puts a plugin's libraries in a
URLClassLoaderwhose parent is the server, so classes defined there never see another plugin. PacketEvents asks ViaVersion for a joining player's real protocol version from inside its netty decoder and linkscom.viaversion.viaversion.api.Viadirectly; from the library classloader that throwsNoClassDefFoundErroron the first handshake, and PacketEvents responds by closing the channel. The result is a server that accepts no connection at all - not even a server list ping - and logs nothing, because the connection is still in the handshake state where PacketEvents stays quiet. Only the plugin classloader reaches ajoin-classpathdependency, so that is where it has to live.ViaCompatibilityre-checks this at startup and degrades loudly rather than silently if a future packaging change breaks it.Paper's own
adventure-apiandadventure-keyare deliberately not packed:ComponentandKeycross the Bukkit boundary and there must be exactly one copy of them. -
zstd-jni (
com.github.luben:zstd-jni) - block compression, fetched at load time byRecorderLoader(Paper'sMavenLibraryResolver). Deliberately not relocated or repacked: its JNI entry points are named after its Java package, so either would break the native library.
Offline servers can put the zstd-jni jar into plugins/recorder/libs/; otherwise Maven resolves
it. Its version is generated from the same Gradle value used to compile, so the loader and compile
classpaths cannot drift independently.
ViaVersion and ViaBackwards are optional dependencies that load before the recorder. Capture sits behind their translation handlers, so the saved bytes always use the server's packet protocol no matter which version the player's client speaks. That is not a listener priority; it is netty pipeline position. On a 1.21.11 Paper server with ViaVersion installed the connection pipeline is:
inbound splitter ──► via-decoder ──► pe-decoder ──► decoder ──► packet_handler
translate CAPTURE deserialize
outbound encoder ──► pe-encoder ──► via-encoder ──► prepender
CAPTURE translate
Both capture points are on the server side of Via. Inbound, Via has already translated the client's
packet up to the server protocol before the recorder sees it; outbound, the recorder copies the
packet the server produced before Via rewrites it down to the client's protocol. It falls out of
injection order rather than luck: Via injects from the channel initializer during netty's
handlerAdded, PacketEvents injects on channelRegistered, and both insert immediately before the
vanilla codec - so the later one, PacketEvents, ends up between Via and the codec.
Consequently serverProtocolVersion, serverPacketVersion and protocolTable in the recording
header describe the server protocol, while clientProtocolVersion and clientVersion preserve
what the player actually joined with. One recording is therefore decodable against one protocol,
whatever mix of client versions the server was serving.
PacketEvents must be able to link ViaVersion's classes for this to work at all; see Dependencies.
netty event loop writer thread disk / archiver
──────────────── ───────────── ───────────────
PacketEvents MONITOR listener
wait for post-re-encode task
read final ByteBuf from index 0
stamp System.nanoTime() ──► drain queue
offer to session queue append record ──► block (compress + CRC) ──► 0000.mcrec
(never blocks) flush every 5s 0001.mcrec
split at 256MiB session.json
│
close on disconnect ──► bounded archiver ──► pack one .tar
The listener copies the post-processed server-facing packet bytes, never a decoded wrapper. Its post task runs after PacketEvents has applied wrapper changes and cancellation. When GrimAC is installed, the recorder attaches to Grim's relocated PacketEvents instance so Grim finishes first; otherwise it uses the recorder's own PacketEvents instance. Via translation is outside that boundary - see ViaVersion. No disk or CPU work happens on a connection's thread; the handoff is a lock-free queue, and a queue that fills drops packets and writes a gap record rather than stalling the player.
A recording only ever begins for a player who is freshly joining. It never begins partway through a connection, because a session that starts in the middle has no login, no configuration phase and no registry data, and its timeline is anchored to an arbitrary moment rather than to the connection.
Packets are captured from the handshake onward and held provisionally while the game mode is still unknown, so nothing is lost waiting for it. When the mode arrives at join, the whole buffered prefix is either committed or discarded:
| At join | Then |
|---|---|
| mode is recorded | the prefix is committed; the recording runs from the first packet of the connection |
| mode is not recorded | the prefix is discarded, and no recording starts for that connection even if the player switches mode later - they are recorded when they next reconnect |
Connection enforces this with a one-way latch: the first game-mode snapshot is the mode the
player joined in and leaves the start window open; any later change closes it for good. The window
is also closed for connections adopted at plugin enable, since a player who was already online is
not freshly joining either.
A switch out of a recorded mode ends the recording immediately: everything captured up to the switch is finalised and packed, and the session stops rather than lingering open until the player disconnects. Switching back does not resume it - the start window is already latched shut - so a connection that changes mode yields one recording, ending at the change.
A complete specification. Everything below is what the code in format/ actually writes; a decoder
built from this alone will read any recording this plugin produces.
- Fixed-width integers are big-endian and unsigned unless stated.
varint/varlongare unsigned LEB128: seven payload bits per byte, low byte first, with0x80set on every byte except the last. Avarintis at most 5 bytes, avarlongat most 10. Encoding uses an unsigned shift, so a negative value would occupy the full width; in practice every encoded value is non-negative.- Strings are UTF-8, length-prefixed by the field that precedes them. They are not NUL-terminated.
- Offsets in microseconds are relative to the session clock (see Timing).
A finished session is a single file - an uncompressed tar holding the index and the segment chain:
plugins/recorder/recordings/
├── 2026-08-25_14-25-30_Notch-069a79f4-550e8400-e29b-41d4-a716-446655440000.tar
│ ├── session.json index over the segment chain
│ ├── 0000.mcrec
│ └── 0001.mcrec
└── 2026-08-25_14-31-02_Herobrine-2f0c81aa-7c9e6679-7425-40de-944b-e07fc1f90ae7.part/
├── session.json
└── 0000.mcrec
Recording name: yyyy-MM-dd _ HH-mm-ss _ player name - first 8 characters of the player
UUID - full session UUID, in the server's local time zone, taken from the moment the session
started. The timestamp leads so a listing sorts chronologically; the session UUID prevents even
same-player reconnects in the same second from colliding. Characters outside [A-Za-z0-9_.-] in
the player name are replaced with _.
Commands take the name without the extension; /recorder dump also accepts it with .tar.
Segment file name: the zero-based segment index formatted as %04d, plus .mcrec. Indices are
contiguous.
An open session is written as loose files in a .part directory, so a server that is killed
mid-session still has every flushed block on disk. Packing happens once, when the session ends;
.part directories left by a crash are packed at the next startup. The tar is plain ustar with no
compression - the segments carry their own - so tar xf unpacks a recording anywhere.
| Offset | Size | Field |
|---|---|---|
| 0 | 7 | magic, MCREC1\n (4D 43 52 45 43 31 0A) |
| 7 | 4 | headerLength (u32) |
| 11 | headerLength |
headerJson, UTF-8 |
11 + headerLength |
… | zero or more blocks |
| … | … | one trailer, present only if the segment was closed cleanly |
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | marker 0xB1 |
| 1 | 4 | uncompressedLength (u32) |
| 5 | 4 | compressedLength (u32) |
| 9 | 4 | crc32 (u32) of the uncompressed payload |
| 13 | 8 | firstOffsetMicros (u64), absolute session time of this block's first record |
| 21 | compressedLength |
payload |
The block header is 21 bytes. crc32 is the standard zlib CRC-32 (java.util.zip.CRC32) computed
over the payload before compression. Both lengths are rejected above 256 MiB.
Blocks are independently decodable: each carries its own length, checksum and absolute start time, which is what lets a reader recover a file whose tail was lost.
The decompressed payload is a back-to-back sequence of records, filling exactly
uncompressedLength bytes. There is no record count; read until the payload is exhausted.
| Size | Field |
|---|---|
| 1 | flags (u8) |
| varlong | deltaMicros |
| varint | payloadLength |
payloadLength |
record payload |
flags bits:
| Mask | Meaning |
|---|---|
0x01 |
direction: 0 = S2C (server to client), 1 = C2S (client to server) |
0x0E |
protocol phase, shifted left by 1: 0 HANDSHAKING, 1 STATUS, 2 LOGIN, 3 CONFIGURATION, 4 PLAY |
0x10 |
gap marker (see below) |
0xE0 |
reserved, currently always zero |
For an ordinary record the payload is the server-facing bytes of one packet: a varint packet
id followed by the packet body, after decryption and before connection-level compression. With
ViaVersion/ViaBackwards these bytes are captured behind Via's translation handlers, so they always
use the server packet protocol rather than the joining player's protocol. Nothing is re-encoded by
the recorder.
For a gap record (0x10 set) the payload is instead varint droppedCount followed by
varlong droppedBytes, and carries no packet. It marks a point where the recorder's queue
overflowed and packets were discarded, so a reader can tell that the stream is not lossless there
rather than silently seeing a jump.
- The session clock starts at zero at the first packet captured for that session, and is measured
in microseconds from a monotonic source (
System.nanoTime() / 1000). - The first record of a block always has
deltaMicrosof 0; its absolute time is the block'sfirstOffsetMicros. - Every subsequent record adds its
deltaMicrosto the previous record's absolute time. - Times are non-decreasing within a segment, and the clock keeps running across a segment split, so a whole chain forms one continuous timeline.
sessionStartEpochMillisin the header anchors offset zero to wall-clock time.- Both directions share this one clock, so C2S and S2C records interleave in true observed order.
| Offset | Size | Field |
|---|---|---|
| 0 | 1 | marker 0xEE |
| 1 | 4 | blockCount (u32) |
| 5 | 8 | recordCount (u64) |
| 13 | 8 | lastOffsetMicros (u64) |
| 21 | 4 | nextSegmentLength (u32) |
| 25 | nextSegmentLength |
nextSegment file name, UTF-8, empty on the last segment |
| 25 + len | 8 | MCRECEND |
A segment with no trailer was not closed cleanly — almost always because the server died while recording. Its blocks are still valid and readable.
codecId in the header selects the payload codec:
| Id | Name | Payload encoding |
|---|---|---|
| 0 | none |
stored verbatim; compressedLength equals uncompressedLength |
| 1 | deflate |
a zlib stream (RFC 1950 — header plus Adler-32), not raw deflate |
| 2 | zstd |
one standard Zstandard frame |
The codec applies per block. Every block in a segment uses the codec named in that segment's header.
Serialised with Gson, so null fields are omitted; a decoder must treat any missing key as absent rather than an error, and should ignore keys it does not know.
| Field | Type | Meaning |
|---|---|---|
formatVersion |
int | container version, currently 1; refuse anything higher |
sessionId |
string | UUID identifying the session, shared by every segment |
playerUuid, playerName |
string | the recorded player |
protocolVersion |
int | protocol number of the saved packet bytes; compatibility alias for serverProtocolVersion |
serverProtocolVersion |
int | server packet protocol number used by every saved packet |
serverPacketVersion |
string | PacketEvents name for the saved packet protocol, e.g. V_1_21_11 |
clientProtocolVersion |
int | protocol number originally spoken by the joining player, before Via translation |
clientVersion |
string | PacketEvents name for the player's original protocol |
minecraftVersion |
string | server version, e.g. 1.21.11 |
serverBrand |
string | server brand and build |
recorderVersion |
string | plugin version that wrote the file |
segmentIndex |
int | zero-based index of this segment |
previousSegment |
string | file name of the preceding segment; absent on the first |
sessionStartEpochMillis |
long | wall-clock anchor for offset zero |
segmentStartMicros |
long | session time at which this segment was opened; informational, the authoritative first-record time is the first block's firstOffsetMicros |
codecId, codecName |
int, string | payload codec for every block in this segment |
blockSize |
int | target uncompressed block size in bytes; the writer clamps this to at least 4096, so a block may exceed a smaller configured value |
protocolTable |
object | packet id to name map, below |
protocolTable maps "<PHASE>/<DIRECTION>" to an object of decimal packet id (as a string) to
packet type name:
"protocolTable": {
"PLAY/S2C": { "38": "KEEP_ALIVE", "39": "CHUNK_DATA" },
"PLAY/C2S": { "26": "KEEP_ALIVE" }
}Phase is the enum name, direction is S2C or C2S, and combinations with no packets are omitted.
Names are PacketEvents type names, uppercased. This table is what makes a recording decodable
without a running server or a matching PacketEvents build.
Rewritten atomically as the session grows, so it is always internally consistent.
| Field | Type | Meaning |
|---|---|---|
sessionId, playerUuid, playerName |
string | as in the header |
protocolVersion, serverProtocolVersion, clientProtocolVersion |
int | as in the header |
serverPacketVersion, clientVersion |
string | as in the header |
minecraftVersion |
string | as in the header |
startedEpochMillis, endedEpochMillis |
long | wall-clock bounds; ended is 0 while open |
open |
boolean | true while being written; a crashed session stays true forever |
segments |
array | ordered, one entry per segment |
Each segments entry: index (int), file (string), bytes (long), firstMicros and
lastMicros (long, session clock), records (long), droppedPackets (long).
{
"sessionId": "3f2a...", "playerUuid": "069a79f4-...", "playerName": "Notch",
"protocolVersion": 776, "serverProtocolVersion": 776,
"serverPacketVersion": "V_1_21_11", "clientProtocolVersion": 765,
"clientVersion": "V_1_20_4", "minecraftVersion": "1.21.11",
"startedEpochMillis": 1756000000000, "endedEpochMillis": 1756000600000,
"open": false,
"segments": [
{ "index": 0, "file": "0000.mcrec", "bytes": 268435512,
"firstMicros": 0, "lastMicros": 412300500, "records": 918233, "droppedPackets": 0 },
{ "index": 1, "file": "0001.mcrec", "bytes": 13094422,
"firstMicros": 412300500, "lastMicros": 600120000, "records": 41022, "droppedPackets": 0 }
]
}bytes counts the segment file on disk, so it slightly exceeds the sum of its payloads. records
includes gap records.
A conforming decoder is deliberately forgiving at the tail:
- Verify the 7-byte magic. Read
headerLength, then parse the header JSON. - Loop, reading one marker byte:
- EOF — stop. The segment is unclean: no trailer, so the writer never closed it.
0xEE— parse the trailer. The segment is clean. Stop.0xB1— read the 20 remaining block-header bytes, then check the following in order and stop unclean on the first failure. Fewer than 20 bytes available. Either length negative or above 256 MiB. Fewer thancompressedLengthpayload bytes available. Decompression fails — catch the decompressor's error rather than letting it propagate; a torn or corrupt block is expected input, not an exceptional condition. The CRC of the decompressed payload does not match. If all pass, emit the block's records.- anything else — stop unclean; the file is corrupt from here.
- Treat every unclean stop as ordinary end-of-stream and keep everything decoded so far. That is
the whole crash-recovery story: a
kill -9costs at most the block that was still buffered.
To read a whole session, walk session.json's segments in order, or follow previousSegment and
the trailer's nextSegment. Concatenating their record streams yields one continuous timeline.
Both files live inside the session's .tar; nothing else has to be unpacked to read it, and any
tar library can hand you the members in place.
A real 521-byte segment holding two keep-alives, written with codec: none so the payload is
readable in place:
00000000 4d 43 52 45 43 31 0a magic "MCREC1\n"
00000007 00 00 01 a5 headerLength = 421
0000000b 7b 22 66 6f 72 ... 7d 7d 7d headerJson (421 bytes)
000001b0 b1 block marker
000001b1 00 00 00 19 uncompressedLength = 25
000001b5 00 00 00 19 compressedLength = 25 (none)
000001b9 76 91 3b 8d crc32 of the payload
000001bd 00 00 00 00 00 00 00 00 firstOffsetMicros = 0
000001c5 08 flags: PLAY(4)<<1, S2C
000001c6 00 deltaMicros = 0 -> t = 0us
000001c7 09 payloadLength = 9
000001c8 26 00 00 00 00 00 00 00 01 S2C KEEP_ALIVE, id 0x26, value 1
000001d1 09 flags: PLAY(4)<<1, C2S
000001d2 dc 0b deltaMicros = 1500 -> t = 1500us
000001d4 09 payloadLength = 9
000001d5 1a 00 00 00 00 00 00 00 01 C2S KEEP_ALIVE, id 0x1a, value 1
000001de ee trailer marker
000001df 00 00 00 01 blockCount = 1
000001e3 00 00 00 00 00 00 00 02 recordCount = 2
000001eb 00 00 00 00 00 00 05 dc lastOffsetMicros = 1500
000001f3 00 00 00 0a nextSegmentLength = 10
000001f7 30 30 30 31 2e 6d 63 72 65 63 "0001.mcrec"
00000201 4d 43 52 45 43 45 4e 44 "MCRECEND"
Note dc 0b decoding as 1500: 0xdc has the continuation bit set and contributes 0x5c = 92;
0x0b = 11 contributes 11 << 7 = 1408; 1408 + 92 = 1500.
- Records within a segment are in true observed order, and byte-identical to the wire.
- A segment may contain zero blocks, and the final block may be absent or torn at any time.
- A gap record means packets were lost at that point. Absence of gap records across a whole chain means the recording is lossless.
- A recording is only meaningful against the protocol version in its header. Nothing in the format translates between versions.
| Command | |
|---|---|
/recorder status |
live sessions, byte rates, queue depth, dropped packets |
/recorder list |
recorded sessions on disk |
/recorder dump <recording> [n] |
decode the first n records with packet names |
/recorder flush |
commit every open recording now |
/recorder reload |
re-read config.yml |
All of /recorder is gated on recorder.admin, which defaults to op. Brigadier hides commands
whose permission check fails, so a player without it sees "Unknown command" rather than a
permission error.
plugins/recorder/config.yml. Sizes accept B/KiB/MiB/GiB; durations accept
ms/s/m/h/d.
| Key | Default | |
|---|---|---|
recording.enabled |
true |
|
recording.directory |
recordings |
relative to plugins/recorder/, or an absolute path to put recordings on another disk |
recording.capture.s2c / .c2s |
true |
|
recording.capture.phases |
[CONFIGURATION, PLAY] |
add LOGIN/HANDSHAKING for protocol debugging |
recording.filters.mode |
none |
none (lossless) / allow / deny |
recording.filters.packets |
[] |
PacketEvents type names |
recording.filters.ignore-players |
[] |
names or UUIDs |
recording.filters.game-modes.<mode> |
true |
independent survival / creative / adventure / spectator toggles; see When a recording starts |
recording.max-file-size |
256MiB |
split threshold |
recording.max-segment-duration |
0 |
optional time-based split |
recording.compression.codec |
zstd |
zstd / deflate / none |
recording.compression.level |
3 |
clamped to the codec's own range |
recording.compression.block-size |
1MiB |
unit of CRC and crash recovery |
recording.flush.interval |
5s |
auto-save cadence |
recording.flush.fsync |
on-rotate |
always / on-rotate / never |
recording.flush.max-queued-bytes |
64MiB |
per player |
recording.flush.on-overflow |
drop |
drop (writes a gap record) / disconnect |
recording.retention.max-total-size |
20GiB |
0 disables |
recording.retention.max-age |
14d |
0 disables |
recording.retention.alert-threshold-percent |
90 |
percentage of max-total-size |
recording.retention.discord-webhook-url |
empty | Discord rich-embed alert; empty disables |
The Discord warning is sent once when usage crosses the threshold and re-arms after usage falls
below it. It is sent before automatic pruning begins. Capture, filter, enablement, overflow policy,
and retention changes are live after /recorder reload. Per-session storage settings such as
segment limits, block size, flush cadence, fsync, and queue size apply to new sessions. Codec and
directory changes need a restart.
dev.kelvin.recorder
├─ RecorderPlugin, RecorderLoader
├─ config/ config.yml -> immutable records, size and duration parsing
├─ capture/ PacketBackend (the only seam that knows PacketEvents), ProtocolTable, Connection,
│ GrimPacketBridge, ViaCompatibility
├─ format/ the .mcrec container: BlockWriter/BlockReader, codecs, segment chain reader, Tar
├─ store/ live sessions, the writer thread, segment rotation, packing, retention, lookup
├─ util/ Schedulers (Paper and Folia)
└─ command/ /recorder (Brigadier; Paper plugins ignore a `commands:` block)
Everything PacketEvents-specific sits behind PacketBackend, so a reflection backend could replace
it without touching the recorder.
op <yourname>
join, walk around, break a few blocks for ~30s
disconnect # finalizes the session and writes its trailer
/recorder list # your session, marked closed
/recorder dump <recording> 20 # named packets, with timings
/recorder dump is the step worth not skipping. If it prints real packet names with plausible
timings, capture and format are both sound.
- Recording every packet for every player is high volume; chunk and entity traffic dominate. Watch
/recorder statusand tunerecording.retention. - A recording is tied to the server packet protocol that produced it. The header records that version, and the embedded id table is what makes it decodable later.
- ViaVersion and ViaBackwards are optional dependencies; see ViaVersion for why the saved bytes are always on the server protocol.
- A gap record means backpressure forced drops, so that recording is not lossless.
/recorder dumpreports them;recording.flush.on-overflow: disconnecttrades the player's connection for a complete recording instead.