Skip to content

PacketTeam#skipTeamInfo reads 26.2 team packet fields in the wrong order #24

Description

@AgoraPluribusMichael

When using Replay Mod v2.6.27 for Minecraft 26.2, the replays were failing to save in my world that contained teams with non-empty team prefixes.

Here's a video of me replicating the bug in single player: link

I fed the jar and the Minecraft crash report to Claude Opus 4.8 and it identified and fixed the bug! Here's my Claude chat if you're curious.

Summary

PacketTeam.skipTeamInfo walks the fields of the clientbound set_player_team packet in the pre-26.2 order. In 26.2 the fields after the display name were reordered: prefix and suffix moved up ahead of nametag visibility and collision rule. The existing 26.2 branch already accounts for the color becoming optional and the flags byte moving to the end, but not for the two components moving.

The result is that the reader desyncs partway through the packet and then tries to parse arbitrary bytes as NBT. In my case it threw an exception while reading the suffix, aborting the replay save entirely.

Environment

Minecraft 26.2 (protocol 776)
ReplayMod 26.2-2.6.27
Fabric Loader / API 0.19.3 / 0.155.2+26.2
Java 25.0.1 (Microsoft)
OS Windows 11

Symptom

Recording saves fine right up until post-processing, then:

---- Minecraft Crash Report ----
Description: Saving replay file

java.io.IOException: Failed to create tag.
	at com.replaymod.replaystudio.protocol.Packet$Reader.readText(Packet.java:277)
	at com.replaymod.replaystudio.protocol.packets.PacketTeam.skipTeamInfo(PacketTeam.java:104)
	at com.replaymod.replaystudio.protocol.packets.PacketTeam.getPlayers(PacketTeam.java:63)
	at com.replaymod.replaystudio.filter.SquashFilter.onPacket(SquashFilter.java:508)
	at com.replaymod.editor.gui.MarkerProcessor.apply(MarkerProcessor.java:218)
	at com.replaymod.recording.packet.PacketListener.lambda$channelInactive$1(PacketListener.java:314)
Caused by: com.github.steveice10.opennbt.tag.TagCreateException: Could not find tag with ID "114".
	at com.github.steveice10.opennbt.tag.TagRegistry.createInstance(TagRegistry.java:112)

PacketTeam.java:104 is the second readText() in skipTeamInfo, i.e. the suffix. Tag ID 114 is the ASCII byte r, so the reader is sitting inside a string payload rather than at a tag boundary.

Root cause

Current parse order in skipTeamInfo versus the actual 26.2 wire order:

# skipTeamInfo expects 26.2 actually sends
1 display name (component) display name (component)
2 nametag visibility (VarInt) prefix (component)
3 collision rule (VarInt) suffix (component)
4 color (bool + VarInt) nametag visibility (VarInt)
5 prefix (component) collision rule (VarInt)
6 suffix (component) color (bool + VarInt)
7 flags (byte) flags (byte)

This is verifiable without leaving the shipped jar. Protocol26_1To26_2 from the bundled ViaVersion registers a replaceClientbound handler for ClientboundPackets26_1.SET_PLAYER_TEAM that does, for actions 0 and 2:

  1. passthrough the team name and action byte
  2. passthroughAndProcess the display name
  3. read the flags byte and three VarInts (visibility, collision, color) without writing them
  4. passthroughAndProcess the prefix, then the suffix
  5. write visibility, collision, BOOL_OPTIONAL_VAR_INT color (null when the value is >= 16), and finally the flags byte

Since passthrough emits in call order and the intervening fields are consumed but written later, the emitted 26.2 layout is exactly the right-hand column above.

Reproduction

You can replicate this bug by joining a world that contains at least one team with a non-empty team prefix or suffix, manually starting and stopping the recording, then disconnecting from the world.

  1. Create a 26.2 Superflat world in Single Player, and ensure allow commands is on
  2. Create a team by running the command /team add TestTeam
  3. Modify the team prefix by running the command /team modify TestTeam prefix TEST
  4. Save and quit
  5. Ensure automatic recording is off
  6. Join the world again (the server sends a CREATE for every existing team on join)
  7. Start the recording manually rather than letting it auto-record on join. This places an _RM_START_CUT marker at timestamp 0, so the join-time team packets fall inside the region that gets squashed.
  8. Press Stop, then Save and Quit.
  9. The save fails during MarkerProcessor.apply.

Worth noting for anyone trying to reproduce: markers are required. SquashFilter is the only caller of PacketTeam, and MarkerProcessor is the only caller of SquashFilter, so with no _RM_ markers in the replay nothing ever parses the packet and the save succeeds. Only Action.CREATE reaches skipTeamInfogetPlayers returns early for UPDATE, and add/remove-player packets go straight to the name list.

Depending on the byte content the desync does not always throw; an empty prefix and suffix can slip through the misread and yield a silently wrong player list instead of a crash. I have not confirmed that case, but it seems worth checking, since it would mean squashed replays are being corrupted quietly. createTeam shares skipTeamInfo to compute the template length, so it is affected the same way.

Patch

Full disclosure: this section with the bug fix was generated by Claude. Although the .class file it generated using the following code worked when I dragged it into the .jar with 7-Zip, please test the code first to ensure it compiles and fixes the bug

Still present on master as of this writing. Isolating 26.2 into its own branch keeps the legacy path untouched:

--- a/src/main/java/com/replaymod/replaystudio/protocol/packets/PacketTeam.java
+++ b/src/main/java/com/replaymod/replaystudio/protocol/packets/PacketTeam.java
@@ -79,13 +79,24 @@
 
     private static void skipTeamInfo(Packet packet, Packet.Reader in) throws IOException {
         in.readText(); // display name
+        if (packet.atLeast(ProtocolVersion.v26_2)) {
+            // 26.2 reordered everything after the display name: prefix and suffix moved up
+            // directly behind it, the color became optional and the flags byte moved to the end.
+            in.readText(); // prefix
+            in.readText(); // suffix
+            in.readVarInt(); // name tag visibility
+            in.readVarInt(); // collision rule
+            if (in.readBoolean()) {
+                in.readVarInt(); // color
+            }
+            in.readByte(); // flags
+            return;
+        }
         if (!packet.atLeast(ProtocolVersion.v1_13)) {
             in.readString(); // prefix
             in.readString(); // suffix
         }
-        if (packet.olderThan(ProtocolVersion.v26_2)) {
-            in.readByte(); // flags
-        }
+        in.readByte(); // flags
         if (packet.atLeast(ProtocolVersion.v1_8)) {
             if (packet.atLeast(ProtocolVersion.v1_21_5)) {
                 in.readVarInt(); // name tag visibility
@@ -97,18 +108,13 @@
                 }
             }
             if (packet.atLeast(ProtocolVersion.v1_13)) {
-                if (packet.olderThan(ProtocolVersion.v26_2) || in.readBoolean()) {
-                    in.readVarInt(); // color
-                }
+                in.readVarInt(); // color
                 in.readText(); // prefix
                 in.readText(); // suffix
             } else {
                 in.readByte(); // color
             }
         }
-        if (packet.atLeast(ProtocolVersion.v26_2)) {
-            in.readByte(); // flags
-        }
     }
 
     public static Packet createTeam(Packet template, Collection<String> players) throws IOException {

I have been running this patched into my local jar and it saves correctly on a world with several teams with non-empty prefixes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions