Skip to content
KelvinlbyPublic

About

Paper MC plugin for server-side packet recording.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Packet Recorder

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

Build and run

./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.

Multi-version support

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

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.

Dependencies

  • PacketEvents (com.github.retrooper:packetevents-spigot) - netty channel access and the packet id tables. Packed into the plugin jar, unrelocated, together with adventure-nbt and examination, 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 URLClassLoader whose 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 links com.viaversion.viaversion.api.Via directly; from the library classloader that throws NoClassDefFoundError on 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 a join-classpath dependency, so that is where it has to live. ViaCompatibility re-checks this at startup and degrades loudly rather than silently if a future packaging change breaks it.

    Paper's own adventure-api and adventure-key are deliberately not packed: Component and Key cross 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 by RecorderLoader (Paper's MavenLibraryResolver). 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

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.

How it works

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.

When a recording starts

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.

The .mcrec format

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.

Conventions

  • Fixed-width integers are big-endian and unsigned unless stated.
  • varint / varlong are unsigned LEB128: seven payload bits per byte, low byte first, with 0x80 set on every byte except the last. A varint is at most 5 bytes, a varlong at 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).

Directory layout

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.

Segment file

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

Block

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.

Block payload

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.

Timing

  • 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 deltaMicros of 0; its absolute time is the block's firstOffsetMicros.
  • Every subsequent record adds its deltaMicros to 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.
  • sessionStartEpochMillis in 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.

Trailer

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.

Compression

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.

Header JSON

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.

session.json

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.

How to read a segment

A conforming decoder is deliberately forgiving at the tail:

  1. Verify the 7-byte magic. Read headerLength, then parse the header JSON.
  2. 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 than compressedLength payload 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.
  3. Treat every unclean stop as ordinary end-of-stream and keep everything decoded so far. That is the whole crash-recovery story: a kill -9 costs 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.

Worked example

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.

Guarantees and non-guarantees

  • 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.

Commands

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.

Configuration

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.

Layout

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.

Checking it works

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.

Caveats

  • Recording every packet for every player is high volume; chunk and entity traffic dominate. Watch /recorder status and tune recording.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 dump reports them; recording.flush.on-overflow: disconnect trades the player's connection for a complete recording instead.

About

Paper MC plugin for server-side packet recording.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages