Benchmark-driven optimisation work for the Maple packet encryption, input, and output stack in OdinMS MapleStory encryption library.
Using reward-guided iterative optimization loops, guided by GPT-5.5 Pro and Claude Opus 4.8, to test whether the most obvious performance gains had already been exhausted in the library. To my surprise, there was still substantial low-hanging fruit. The resulting optimization pass delivered major speed-ups across encryption, memory/ GC, input handling, output handling, and the full send pipeline without changing encrypted output, packet structure, or IO semantics.
- Packet output via GenericLittleEndianWriter
- Packet input via GenericLittleEndianAccessor and ByteInputStream
- Maple custom encryption via MapleCustomEncryption (Shanda)
- Maple AES/OFB encryption via MapleAESOFB
- Packet handoff to MINA IoBuffer
- Full packet send pipeline allocation behaviour
This repo now keeps the optimized Maple packet crypto in both runtimes:
| Runtime | Source | Best use |
|---|---|---|
| Java | src/main/java/game/packets/MapleAESOFB.java, src/main/java/game/packets/MapleCustomEncryption.java |
OdinMS-style servers and the full packet codec benchmark in this repo. |
| C#/.NET | csharp/MapleCryptoLib/ |
MapleLib/Harepacker-style consumers that need the same ciphertext/IV/header behavior with .NET allocation reductions. |
The two versions share the same keeper ideas: table/direct byte rotations for custom encryption, AES ECB provider reuse inside the OFB-like stream, unrolled full-block XOR, cheaper packet header/length helpers, and correctness checks against fixed ciphertext vectors. The exact buffer layout differs where each runtime measured better: Java keeps the two-buffer Cipher.update(...) stream path, while C# uses one in-place AES feedback buffer.
Compared with the corrected baseline, the final keeper set produced the following improvements:
| Benchmark | Baseline best ops/s | Final avg ops/s | Improvement |
|---|---|---|---|
| writer primitives | 1,762,965 | 13,776,166 | 7.81x |
| writer buffer 1460 | 8,885,047 | 11,546,920 | 1.30x |
| accessor primitives | 22,960,743 | 23,921,469 | 1.04x |
| accessor buffer 1460 | 12,265,128 | 11,670,174 | 0.95x |
| custom encrypt 512 | 242,129 | 542,256 | 2.24x |
| custom decrypt 512 | 123,690 | 504,756 | 4.08x |
| custom mixed roundtrip | 30,951 | 68,912 | 2.23x |
| AES crypt 512 | 647,740 | 2,083,894 | 3.22x |
| AES crypt mixed | 174,562 | 527,807 | 3.02x |
| full send pipeline mixed | 44,446 | 123,392 | 2.78x |
| MINA handoff 1460 | 4,204,041 copy path | 10,822,299 view path | 2.57x |
| fused send pipeline, isolated | 114,488 unfused | 123,723 fused | 1.08x |
The C# implementation under csharp/MapleCryptoLib/ was measured separately with BenchmarkDotNet on .NET 10. The benchmark used the same packet sizes as the Java run and validated round trips, fixed SHA-256 ciphertext vectors, IV shuffle output, packet headers, whole-pattern byte multiplication, and mutable custom AES keys before timing.
Representative final results:
| Workload | Size | Baseline | Final | Speed-up | Allocation change |
|---|---|---|---|---|---|
| AES | 512 | 2.434 us | 1.474 us | 1.65x | 2,488 B -> 600 B |
| AES | 8,192 | 26.688 us | 14.897 us | 1.79x | 22,008 B -> 600 B |
| Custom encrypt | 512 | 306.799 us | 2.368 us | 129.6x | 0 B -> 0 B |
| Custom decrypt | 512 | 304.902 us | 2.409 us | 126.6x | 0 B -> 0 B |
| Full packet | 512 | 309.940 us | 3.838 us | 80.8x | 2,520 B -> 632 B |
| Full packet | 8,192 | 78.448 ms | 51.851 us | 1,513x | 22,072 B -> 632 B |
Small helper changes also mattered: client header generation improved from 3.131 ns to 1.929 ns, byte multiplication improved from 7.384 ns to 2.836 ns, and the corrected varying-input packet-length benchmark measured 0.575 ns without allocation. AES and full-packet allocation are now effectively constant with packet size because the per-packet AES stream no longer allocates per chunk.
Compact .NET iteration record for the 512-byte packet case:
| Iteration | Change | Result |
|---|---|---|
| 0 | Original baseline | 306.799 us encrypt, 304.902 us decrypt, 2.434 us AES, 309.940 us full packet |
| 1 | Constant-time rotations and direct indexing | Kept: custom encrypt/decrypt fell to low single-digit microseconds |
| 2 | One-shot span AES per block | Rejected: provider setup per block made AES 7.250 us |
| 3 | Reusable AES transform and two feedback buffers | Kept: AES 1.617 us, allocation 640 B |
| 4 | Header/key/multiply helpers | Kept, except slower IV rewrite |
| 5 | Lookup tables for all rotations | Partial keeper: decrypt improved, full packet 4.233 us |
| 6 | Direct fixed rotations for both directions | Rejected for decrypt regression |
| 7 | Direct fixed encrypt; lookup fixed decrypt | Kept: custom encrypt 2.365 us, decrypt 2.389 us |
| 8 | Unrolled AES IV fill and full-block XOR | Kept speed: AES 1.435 us, allocation addressed next |
| 9 | Cached trimmed mutable WZ key | Kept: avoids repeated key trimming while preserving mutable key behavior |
| 10 | Pooled AES feedback buffers | Rejected: lower allocation but slower shared-pool path |
| 11 | One in-place AES feedback buffer | Final keeper: AES 1.408 us, full packet 3.874 us, 600 / 632 B allocation |
Benchmark helper:
benchmarks/PacketCodecBenchmark.java
csharp/MapleCryptoBench
Run shape:
- warmup:
1000 msper benchmark - measurement:
2000 msper repeat - final run:
5 repeats - earlier iteration runs:
3 repeats - packet sizes:
32,128,512,1460,8192
The benchmark validates correctness before reporting throughput:
- custom encryption/decryption round trips
- AES/OFB round trips
encode8/decode8round trip- SHA-256 digest checks for known custom-encrypted and AES-encrypted 512-byte payloads
Detailed measured API-rewrite logs are kept out of the main README:
- Java packet-builder/API rewrite:
docs/deep-api-rewrite.md - C# PacketWriter/API rewrite:
docs/csharp-deep-api-rewrite.md
mvn -f .\pom.xml -DskipTests compile
$mina = "$env:USERPROFILE\.m2\repository\org\apache\mina\mina-core\2.2.9\mina-core-2.2.9.jar"
javac -cp ".\target\classes;$mina" -d '.\target\benchmark-classes' '.\benchmarks\PacketCodecBenchmark.java'
java -Xms1g -Xmx1g -cp ".\target\classes;.\target\benchmark-classes;$mina" PacketCodecBenchmark --repeats=5
dotnet run --project .\csharp\MapleCryptoBench\MapleCryptoBench.csproj -c Release -- --repeats=5mvn -f .\pom.xml test
dotnet build .\csharp\MapleCryptoLib.sln -c ReleaseExpected result:
BUILD SUCCESS
MaplePacketEncoder.java and MaplePacketDecoder.java are application code and
are not included in this library. They MUST follow the packet layout below.
MapleAESOFB.crypt(...) and MapleCustomEncryption.encryptData(...) preserve
the first four bytes as the Maple packet header. Using the former payload-only
integration will misalign the AES keystream and encryption will fail.
MaplePacketEncoder.java:
@Override
public void encode(final IoSession session, final Object message,
final ProtocolEncoderOutput out) throws Exception {
final Client client = (Client) session.getAttribute(Client.SOCKET_CLIENT_STATE_KEY);
if (client != null) {
final MapleAESOFB send_crypto = client.getSendCrypto();
out.write(IoBuffer.wrap(send_crypto.encryptPacket((byte[]) message)));
} else { // no client object created yet, send unencrypted (hello)
out.write(IoBuffer.wrap((byte[]) message));
}
}MaplePacketDecoder.java:
@Override
protected boolean doDecode(IoSession session, IoBuffer in,
ProtocolDecoderOutput out) throws Exception {
final DecoderState decoderState =
(DecoderState) session.getAttribute(DECODER_STATE_KEY);
final Client client =
(Client) session.getAttribute(Client.SOCKET_CLIENT_STATE_KEY);
if (client == null) {
return false;
}
client.getRecvPacketReentrantLock().lock();
try {
if (decoderState.packetlength == -1) {
if (in.remaining() < 4) {
return false;
}
final int packetHeader = in.getInt();
if (!client.getReceiveCrypto().checkPacket(packetHeader)) {
session.closeNow();
return false;
}
decoderState.packetlength = MapleAESOFB.getPacketLength(packetHeader);
}
if (in.remaining() < decoderState.packetlength) {
return false;
}
final byte encryptedPacket[] = new byte[decoderState.packetlength + 4];
in.get(encryptedPacket, 4, decoderState.packetlength);
decoderState.packetlength = -1;
client.getReceiveCrypto().crypt(encryptedPacket);
final byte packet[] = new byte[encryptedPacket.length - 4];
System.arraycopy(encryptedPacket, 4, packet, 0, packet.length);
MapleCustomEncryption.decryptData(packet);
out.write(packet);
} finally {
client.getRecvPacketReentrantLock().unlock();
}
return true;
}COutPacket.getIoBuffer() exposes a bounded, zero-copy view of the packet's
backing array for direct use with IoSession.write(...). Existing callers can
continue using getPacket(), which retains its snapshot/copy semantics.
Use the expected-size constructor to avoid growth copies while encoding:
COutPacket packet = new COutPacket(expectedSize);
// encode packet fields
session.write(packet.getIoBuffer());For encrypted packets built through COutPacket, reserve the four-byte Maple
header up front and hand off the encrypted bounded view:
COutPacket packet = COutPacket.forEncryptedPayload(expectedPayloadSize);
// encode payload fields
session.write(packet.encryptAndGetIoBuffer(sendCrypto));Treat getIoBuffer() as the terminal handoff; do not append to the packet after
obtaining the view. In the full five-repeat benchmark, the 1460-byte view path
averaged 10.89M ops/s versus 4.24M ops/s for getPacket() plus IoBuffer.wrap,
a 2.57x improvement that removes one full-packet allocation and copy.
Run only this benchmark with:
java -cp ".\target\classes;.\target\benchmark-classes;$env:USERPROFILE\.m2\repository\org\apache\mina\mina-core\2.2.9\mina-core-2.2.9.jar" PacketCodecBenchmark --handoff-only --repeats=5The C# library includes a MapleLib.PacketLib.PacketWriter based on
Harepacker-resurrected's packet builder. Existing callers can keep using
ToArray(), but encrypted sends should prefer the reserved-header path:
using MapleLib.MapleCryptoLib;
using MapleLib.PacketLib;
using PacketWriter packet = PacketWriter.CreateForEncryptedPayload(expectedPayloadSize);
// write payload fields
ArraySegment<byte> encrypted = packet.EncryptAndGetBuffer(sendCrypto, toClient: true);
socket.Send(encrypted.Array!, encrypted.Offset, encrypted.Count, SocketFlags.None);MapleCrypto.EncryptPacket(...) is the one-allocation array API. Use
PacketWriter.EncryptAndGetBuffer(...) when the send layer can consume an
ArraySegment<byte> and avoid the final snapshot.
GenericLittleEndianWriter was updated to reduce per-byte overhead:
encodeBuffer(byte[])writes the whole byte array toByteArrayOutputStreamin one call.writeZeroBytes(int)writes chunks from a static zero buffer instead of looping byte-by-byte.encode2,encode4, andencode8write directly to the backing stream instead of calling back throughencode1.
A per-writer scratch buffer for primitive writes was tested but rejected because it was slower and added mutable state.
ByteInputStream now exposes default bulk and primitive read methods:
readBytes
readShortLE
readShortBE
readIntLE
readIntBE
readLongLE
readLongBE
skip
ByteArrayByteStream overrides these methods with array-indexed implementations, reducing repeated EOF checks and repeated available() calls. This path matters most for decoded network packets because the packets are already resident in byte arrays.
InputStreamByteStream and RandomAccessByteStream also gained bulk readBytes and skip implementations.
GenericLittleEndianAccessor now delegates primitive, buffer, and skip operations to the underlying stream and includes the corrected 64-bit little-endian and big-endian decode behaviour.
MapleCustomEncryption was optimised by replacing repeated 8-bit rotate operations with lookup tables:
- static rotate tables replace repeated
BitTools.rollLeftandBitTools.rollRightcalls - fixed rows are cached for common rotations:
- left 3
- left 4
- right 3
- right 4
- byte overflow behaviour remains explicit with
& 0xFF
Two custom-encryption trials were rejected:
- splitting the six alternating passes into helper methods slowed direct encrypt/decrypt paths
- maintaining a rolling rotation index improved one narrow decrypt metric but lowered send-side encryption and full pipeline throughput
MapleAESOFB was optimised by reducing allocation and provider overhead in the AES stream path:
- cipher initialisation now uses explicit
AES/ECB/NoPadding - per-chunk IV expansion allocation was replaced with a reusable 16-byte IV repeat buffer
- repeated
doFinal()calls were replaced withcipher.update(...)into reusable 16-byte buffers - overlapping input/output blocks were avoided by using two stream buffers and swapping references
- the 16-byte XOR path for full AES blocks was unrolled
- a temporary 2-byte allocation in
checkPacketwas removed - the four fixed IV-mixing steps are unrolled
Rejected AES trials:
- reusing IV output storage and copying back was slower
- swapping reusable IV buffers was still slower than the simpler
getNewIvpath in this benchmark
MapleCustomEncryption.encryptData(...) and
MapleAESOFB.cryptPacketData(...) encrypt directly after the four-byte Maple
header. The final packet is allocated once, populated once, and encrypted in
place. This removes the temporary payload clone and the final payload copy.
byte[] encryptedPacket = aes.encryptPacket(payload);
session.write(IoBuffer.wrap(encryptedPacket));| Mixed send metric | Previous pipeline | Fused pipeline | Change |
|---|---|---|---|
| allocation | 4,193.6 bytes/op | 2,112.0 bytes/op | -49.6% |
| constrained-heap collections | 26 | 13 | -50.0% |
| constrained-heap GC time | 9 ms | 4 ms | -55.6% |
| constrained-heap throughput | 114,846 ops/s | 124,105 ops/s | +8.1% |
Allocation measurements use ThreadMXBean over 100,000 operations. GC results
use 500,000 operations with G1 and a fixed 128 MiB heap. Run them with
--allocations and --gc, respectively.
Throughput is reported in ops/s. Values are the best value per run unless marked as an average. The original baseline could not be timed because decode8 failed correctness verification. The first timed baseline is after the decode8 fix and before performance changes.
| Iteration | Change Set | writer primitives | writer buffer 1460 | accessor primitives | accessor buffer 1460 | custom encrypt 512 | custom decrypt 512 | custom mixed roundtrip | AES 512 | AES mixed | full send mixed | Decision |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | Original code | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | n/a | Failed decode8 correctness check |
| 1 | Corrected decode8 baseline |
1,762,965 | 8,885,047 | 22,960,743 | 12,265,128 | 242,129 | 123,690 | 30,951 | 647,740 | 174,562 | 44,446 | Baseline |
| 2 | Bulk writer/accessor, int rotate loops, AES no-padding/update | 4,297,839 | 12,231,786 | 22,965,983 | 11,936,601 | 285,163 | 296,451 | 37,472 | 1,273,430 | 309,872 | 50,286 | Kept |
| 3 | Split custom encryption passes into helpers | 14,398,635 | 12,392,189 | 22,987,977 | 9,646,730 | 183,706 | 199,256 | 34,312 | 1,269,528 | 320,274 | 55,487 | Rejected: custom encryption regressed |
| 4 | Rotate lookup tables | 15,699,882 | 12,568,966 | 23,076,169 | 12,434,889 | 465,705 | 499,040 | 60,826 | 1,271,329 | 320,046 | 84,984 | Kept |
| 5 | Cached fixed rotate rows | 4,342,138 | 12,672,491 | 23,089,996 | 12,545,444 | 575,985 | 516,775 | 70,723 | 1,274,671 | 319,211 | 102,862 | Kept |
| 6 | AES full-block XOR unroll | 15,298,989 | 12,481,396 | 23,118,202 | 12,408,478 | 576,163 | 512,753 | 70,652 | 1,331,646 | 331,034 | 104,766 | Kept |
| 7 | AES two-buffer update, no input/output overlap | 15,234,873 | 12,352,778 | 23,087,992 | 12,908,243 | 575,985 | 511,033 | 70,514 | 1,972,801 | 508,564 | 117,336 | Kept |
| 8 | Rolling variable rotate index | 15,141,215 | 12,515,496 | 23,095,689 | 12,454,377 | 539,937 | 527,322 | 67,844 | 1,935,755 | 508,714 | 108,257 | Rejected: send/full pipeline regressed |
| 9 | Writer scratch buffer | 14,107,189 | 12,224,080 | 23,030,837 | 12,446,687 | 576,629 | 513,039 | 70,492 | 1,983,265 | 508,199 | 117,554 | Rejected: writer path not better |
| 10 | Array-backed primitive stream reads | 15,184,209 | 12,333,979 | 24,792,198 | 12,517,142 | 575,304 | 539,696 | 72,146 | 1,920,267 | 475,394 | 117,381 | Kept |
| 11 | Reusable IV copy-back | 14,458,906 | 12,437,640 | 24,788,800 | 12,722,858 | 576,279 | 515,488 | 70,746 | 1,882,838 | 488,717 | 116,531 | Rejected: AES regressed |
| 12 | Reusable IV swap | 4,335,044 | 12,658,614 | 24,780,909 | 12,658,419 | 576,025 | 540,594 | 72,441 | 1,892,056 | 491,556 | 116,724 | Rejected: AES still below kept version |
| 13 | Final keeper set, 5-repeat average | 15,106,089 avg | 12,403,336 avg | 24,598,713 avg | 12,513,157 avg | 573,783 avg | 512,506 avg | 70,601 avg | 1,964,219 avg | 503,898 avg | 116,832 avg | Plateau |
| 14 | MINA zero-copy packet handoff, 5-repeat average | 14,420,211 avg | 11,794,384 avg | 22,241,918 avg | 10,213,962 avg | 508,082 avg | 507,131 avg | 69,744 avg | 1,892,671 avg | 504,479 avg | 116,423 avg | Kept: 1460-byte handoff increased from 5,128,690 to 9,954,966 avg ops/s (1.94x) |
| 15.1 | Allocation baseline | - | - | - | - | - | - | - | 1,959,458 avg | 503,151 avg | 113,363 avg | Baseline: 552.0 AES bytes/op, 2,105.6 AES-mixed bytes/op, 4,193.6 send bytes/op |
| 15.2 | Update IV in place | - | - | - | - | - | - | - | 1,874,614 avg | 488,416 avg | 112,410 avg | Rejected: removed 24 bytes per packet but regressed AES throughput |
| 15.3 | Store IV in AES scratch block | - | - | - | - | - | - | - | 1,945,197 avg | 504,723 avg | 114,742 avg | Rejected: fixed-heap AES baseline was 2,085,304 ops/s |
| 15.4 | Reuse two dedicated IV arrays | - | - | - | - | - | - | - | 1,952,151 avg | 493,152 avg | 104,754 avg | Rejected: full pipeline regressed materially |
| 15.5 | Unroll four IV-mixing calls | - | - | - | - | - | - | - | 2,094,050 avg | 545,261 avg | 114,691 avg | Kept: improved throughput without changing allocation |
| 15.6 | Generic offset-based encryption | - | - | - | - | 456,906 avg | - | - | 1,889,011 avg | - | 92,764 avg | Rejected: custom baseline was 561,294, AES baseline 2,104,799, and previous pipeline 91,882 ops/s |
| 15.7 | Fixed four-byte-header encryption loops | - | - | - | - | - | - | - | - | - | 124,826 avg | Kept: previous pipeline 114,287 ops/s; allocation reduced to 2,112.0 bytes/op |
| 15.8 | Public encryptPacket(payload) fused API |
- | - | - | - | - | - | - | - | - | 123,868 avg | Kept: previous pipeline 114,019 ops/s; API-level validation of fused path |
| 15.9 | Constrained 128 MiB G1 run, 500,000 sends | - | - | - | - | - | - | - | - | - | 124,105 avg | Kept: previous pipeline 114,846 ops/s, collections 26 -> 13, GC time 9 ms -> 4 ms |
| 15.10 | Final packet-aware API, 5-repeat full suite | 13,776,166 avg | 11,546,920 avg | 23,921,469 avg | 11,670,174 avg | 542,256 avg | 504,756 avg | 68,912 avg | 2,083,894 avg | 527,807 avg | 123,392 avg | Final full-suite results after encryptData() became packet-aware; isolated fused pipeline averaged 123,723 versus 114,488 unfused |
The throughput and allocation plateau was reached after iteration 15. Three IV-reuse layouts removed another 24 bytes per send but regressed AES throughput, so they were rejected. Generic offset encryption also halved temporary allocation but slowed the send path; fixed four-byte-header loops recovered the throughput and retained the allocation reduction.
The final keeper set is the fastest stable combination measured with exact output validation.
PacketCodecBenchmarkis a standalone helper rather than a Maven/JMH benchmark.ByteArrayByteStreamis the most important accessor path for decoded network packets in this codebase.- AES speed is still bounded by one AES provider call per 16-byte stream block.
- Further AES gains would likely require a different AES backend or larger architectural changes rather than small local edits.
This optimisation pass improved the Maple packet codec's practical send and encryption paths while preserving exact encrypted output. The largest gains came from direct primitive writes, custom encryption rotate lookup tables, AES/OFB buffer reuse, and safer array-backed primitive reads.