ClientPlayNetworking v1 raw-bytes bridge — design notes
Exact contracts (verified against real jars)
OLD (≤1.20.1 fabric-api v1) — channel + raw FriendlyByteBuf
ClientPlayNetworking.registerGlobalReceiver(ResourceLocation, PlayChannelHandler) -> booleanClientPlayNetworking.registerReceiver(ResourceLocation, PlayChannelHandler) -> booleanClientPlayNetworking.unregisterGlobalReceiver(ResourceLocation) -> PlayChannelHandlerClientPlayNetworking.send(ResourceLocation, FriendlyByteBuf)ClientPlayNetworking.getSender() -> PacketSenderClientPlayNetworking.canSend(ResourceLocation) -> booleanClientPlayNetworking.getSendable() -> Set<ResourceLocation>- SAM
PlayChannelHandler.receive(Minecraft, ClientPacketListener, FriendlyByteBuf, PacketSender)- intermediary: receive(Lnet/minecraft/class_310;Lnet/minecraft/class_634;Lnet/minecraft/class_2540;L…/PacketSender;)V
NEW (26.1.2 fabric-api) — typed CustomPacketPayload
ClientPlayNetworking.registerGlobalReceiver(CustomPacketPayload$Type, PlayPayloadHandler) -> booleanClientPlayNetworking.send(CustomPacketPayload)ClientPlayNetworking.getSender() -> PacketSender(UNCHANGED — keep)- SAM
PlayPayloadHandler.receive(CustomPacketPayload, Context) PayloadTypeRegistry.playC2S()/playS2C() -> PayloadTypeRegistry<RegistryFriendlyByteBuf>.register(CustomPacketPayload$Type<T>, StreamCodec<? super RegistryFriendlyByteBuf, T>) -> Entry<T>
Context.player(),Context.responseSender()(PacketSender) — for the receive callback- MC types: CustomPacketPayload=class_8710, $Type=class_8710$class_9154, StreamCodec=class_9139, RegistryFriendlyByteBuf=class_9129, FriendlyByteBuf=class_2540, ResourceLocation=class_2960
Bridge architecture
Synthetic classes in com/retromod/generated/legacynet/:
- RawPayload implements CustomPacketPayload — carries
ResourceLocation channelId+byte[] data.type()returns aCustomPacketPayload$Typebuilt from channelId (cached per-id).- Needs a
Typeinstance per channel id (Type wraps a ResourceLocation).
- RawPayloadCodec implements StreamCodec<RegistryFriendlyByteBuf, RawPayload> for a given id.
- encode: write the stored bytes into the buf.
- decode: read all remaining readable bytes into byte[], wrap in RawPayload(id, bytes).
- ClientPlayNetworkingBridge (static methods matching OLD signatures):
registerGlobalReceiver(id, oldHandler):- lazily
PayloadTypeRegistry.playС2S().register(typeOf(id), codecOf(id))AND playS2C (receive side is S2C) ClientPlayNetworking.registerGlobalReceiver(typeOf(id), newHandler)where newHandler is a synthetic PlayPayloadHandler that, on receive(payload, ctx): builds a FriendlyByteBuf from payload.data, then calls oldHandler.receive(Minecraft.getInstance(), ctx.player().connection, buf, ctx.responseSender()).
- lazily
send(id, buf): read all bytes from buf, new RawPayload(id, bytes), ClientPlayNetworking.send(payload).getSender(): delegate to ClientPlayNetworking.getSender().canSend(id): ClientPlayNetworking.canSend(typeOf(id)).getSendable(): return empty set (or delegate).
- Keep OLD
PlayChannelHandleras a synthetic interface (its SAM is referenced by mod lambdas via invokedynamic — the lambda’s implemented interface must still exist with the SAM signature, so mods’(mc, listener, buf, sender) -> {...}lambdas continue to resolve). The bridge accepts this interface.
Redirects
- class redirect: old PlayChannelHandler -> synthetic PlayChannelHandler (keep SAM identical).
- method redirects: ClientPlayNetworking.registerGlobalReceiver/registerReceiver/send/canSend/getSendable (the ResourceLocation-keyed overloads) -> ClientPlayNetworkingBridge static methods. getSender() is UNCHANGED in 26.1 — do NOT redirect it.
VERIFIED intermediary signatures (from real 26.1.2 client jar + fabric-api 0.145.4)
- CustomPacketPayload =
class_8710; itstype()=method_56479()Lnet/minecraft/class_9154; - CustomPacketPayload$Type =
class_8710$class_9154; ctor =<init>(Lnet/minecraft/class_2960;)V(record wrapping a ResourceLocation) - StreamCodec =
class_9139; RegistryFriendlyByteBuf =class_9129; FriendlyByteBuf =class_2540- ⚠️ STILL MISSING: StreamCodec’s
encode/decodeSAM intermediary method names. javap was corruption-blocked this session. Mojang names aredecode(B)->Vandencode(B,V)->void; runjavap -p class_9139from the real 26.1.2 jar to get themethod_*names BEFORE codegen. A wrong name here = VerifyError at class load (worse than today’s soft-fail) — this is the gating unknown.
- ⚠️ STILL MISSING: StreamCodec’s
- Fabric PayloadTypeRegistry: statics
serverboundPlay()/clientboundPlay()→register(Type, StreamCodec) - Fabric ClientPlayNetworking:
registerGlobalReceiver(Type, PlayPayloadHandler);send(CustomPacketPayload);getSender()UNCHANGED (do not redirect) - Fabric PlayPayloadHandler SAM:
receive(T, Context); Context:client(),player(),responseSender()
STATIC VERIFICATION (done — AppleSkin 1.17.1, transformed via CLI)
Transformed appleskin-fabric-mc1.17.1-2.5.1.jar (sole-blocked on PlayChannelHandler) and disassembled squeek/appleskin/network/ClientSyncHandler:
registerGlobalReceiver(Identifier, PlayChannelHandler)→INVOKESTATIC ClientPlayNetworkingV1Bridge.registerGlobalReceiver(Object,Object)Z✓getSender()→ left asClientPlayNetworking.getSender()(unchanged in 26.1) ✓- lambda
invokedynamic→ returnsLegacyPlayChannelHandler(the kept synthetic SAM) ✓ - bootstrap samMethodType on the CLI path is intermediary
(class_310,class_634,class_2540,PacketSender)V; the synthetic SAM is Mojang(Minecraft,ClientPacketListener,FriendlyByteBuf,PacketSender)V. These MATCH at runtime: on a 26.1 host the intermediary→Mojang class-redirects (class_310→Minecraft, …) are active, so ClassRemapper rewrites the bootstrap MethodType args to Mojang — converging with the synthetic interface. (CLI mismatch is benign: the audit checks class resolution only; real Fabric users get the runtime remap path.) - This is why the synthetic SAM MUST be Mojang-typed (raw-injected, not re-remapped).
STILL UNVERIFIED (needs live launch): the reflective bridge BEHAVIOR — PayloadTypeRegistry register success/timing, the Proxy codec encode/decode round-trip, and receive dispatch into the old handler. Static analysis cannot exercise these.
Implementation approach decision
Prefer reflection (extend the existing embedded/NetworkingShim.java scaffold) over raw ASM codegen for the bridge logic — NetworkingShim already does reflective new-API discovery. The ONLY pieces that must be synthetic bytecode are: (1) the kept PlayChannelHandler SAM interface (mod lambdas target it via invokedynamic, so it must exist with the exact 4-arg receive descriptor), and (2) a RawPayload implementing CustomPacketPayload — but even RawPayload can be a java.lang.reflect.Proxy over the interface if type() returning a cached Type works through Proxy (verify: CustomPacketPayload is an interface in 26.1 — yes — so Proxy is viable and avoids hand-writing a codec class in ASM).
The hard parts / risks
- Registration timing: PayloadTypeRegistry.register MUST run before play phase. Old mods call registerGlobalReceiver in their ClientModInitializer — that’s early enough. OK for the common case.
- C2S vs S2C: a client receiver listens to S2C; a client sender sends C2S. Must register BOTH the S2C codec (so inbound decodes) and C2S codec (so outbound encodes). Register both on first touch of id.
- Writing real logic in raw ASM is huge & error-prone. Better: write ClientPlayNetworkingBridge, RawPayload, RawPayloadCodec as REAL Java compiled against a generated MC stub, OR via reflection. MUST verify against an actual 26.1 launch — can’t be confidence-checked statically.
- Mods using the TYPED overload (registerGlobalReceiver(PacketType, PlayPacketHandler) + FabricPacket) are a SEPARATE removed API (FabricPacket) — out of scope for THIS bridge; handle separately.