[ English | 한국어 ]
A headless game client implementing the Unreal Engine 5 network protocol in pure Python. Connects to a UE5 Lyra Starter Game dedicated server and handles the full connection flow from handshake through login to actor replication.
- Requirements
- Quick Start
- Connection Flow
- Project Structure
- Protocol Details
- Data Files
- Example Log
- Extensions
- Limitations
- License
- Python 3.10+
- UE5 Lyra Starter Game build — download from Releases:
LyraServer.7z— dedicated serverLyraGame.7z— game client
Extract LyraServer.7z and run the dedicated server:
LyraServer.exe /ShooterMaps/Maps/L_Expanse -log -port=7777 -nosteamcd client
python client.py # default: 127.0.0.1:7777
python client.py --ip 192.168.0.10 # remote server
python client.py --port 7778 # change portA client_YYYYMMDD_HHMMSS.log file is automatically created, recording all sent/received packets and parsing results.
Press Ctrl+C for graceful shutdown. Sends a Disconnect packet to the server before closing the socket.
sequenceDiagram
participant C as Client
participant S as Server
Note over C,S: UDP Handshake
C->>S: Initial
S-->>C: Challenge (cookie)
C->>S: Response (cookie echo)
S-->>C: ACK (ClientID + seq seed)
Note over C,S: NMT Exchange
C->>S: NMT_Hello
S-->>C: NMT_Challenge
C->>S: NMT_Login
S-->>C: NMT_Welcome
C->>S: NMT_Netspeed
C->>S: NMT_Join
S-->>C: NMT_Join
Note over C,S: Replication
S-->>C: Actor Spawn + Properties
C->>S: ACK / Keepalive
Lyra/
├── client/ # client source
│ ├── client.py # Main entry point
│ ├── app_config.py # LOCAL_NETWORK_VERSION, ONLINE_SUBSYSTEM_TYPE
│ ├── constants.py # Protocol constants (sequence, channel, engine version, etc.)
│ │
│ ├── core/ # Core utilities
│ │ ├── log.py # File logger
│ │ └── names/ # UE5 FName system
│ │ ├── ename.py # EName enum (hardcoded indices 0~1001+)
│ │ └── fname.py # FName pool — string ↔ index mapping
│ │
│ ├── serialization/ # Bit-level serialization
│ │ ├── bit_reader.py # FBitReader — LSB-first bit reading
│ │ ├── bit_writer.py # FBitWriter — LSB-first bit writing
│ │ └── bit_util.py # Bit manipulation utils
│ │
│ └── net/ # Network protocol implementation
│ ├── connection.py # NetConnection — packet send/receive state machine
│ ├── net_serialization.py # UE5 type serialization (Vector, Rotator, GUID, etc.)
│ ├── types.py # FVector, FRotator data classes
│ ├── error_reporter.py # Parse error reporter
│ ├── packet_id_range.py # Packet ID range tracking
│ │
│ ├── handlers/ # Packet handler chain
│ │ ├── stateless_connect.py # StatelessConnect — handshake + 6-bit prefix
│ │ └── aesgcm.py # AES-GCM (disabled)
│ │
│ ├── reliability/ # Reliability layer
│ │ ├── packet_notify.py # FNetPacketNotify — 32-bit packet header (seq/ack)
│ │ ├── sequence_number.py # 14-bit wrapping sequence number
│ │ └── sequence_history.py # 256-bit receive history bitmask
│ │
│ ├── packets/ # Packet and bunch definitions
│ │ ├── in_bunch.py # FInBunch — incoming bunch (extends FBitReader)
│ │ ├── out_bunch.py # FOutBunch — outgoing bunch (extends FBitWriter)
│ │ └── control/ # NMT (Net Control Message) types
│ │ ├── __init__.py # NetControlMessageType enum, NMT namespace
│ │ ├── hello.py # NMT_Hello — client version send
│ │ ├── welcome.py # NMT_Welcome — map/gamemode receive
│ │ ├── login.py # NMT_Login — player authentication
│ │ ├── join.py # NMT_Join — game join
│ │ ├── netspeed.py # NMT_Netspeed — bandwidth setting
│ │ ├── failure.py # NMT_Failure — connection rejected
│ │ └── closereason.py # NMT_CloseReason — connection close reason
│ │
│ ├── channels/ # Channel system
│ │ ├── channel_registry.py # Channel type registry (Control, Actor, Voice)
│ │ ├── channel_types.py # Channel type enumeration
│ │ ├── base_channel.py # Base channel — partial bunch assembly, reliable sequences
│ │ ├── voice_channel.py # Voice channel (disabled)
│ │ ├── control/
│ │ │ ├── channel.py # Control channel — NMT message dispatch
│ │ │ └── core_handlers.py # Challenge→Login, Welcome→Netspeed+Join, etc.
│ │ └── actor/
│ │ ├── channel.py # Actor channel — spawn, replication, RPC
│ │ └── handlers/
│ │ └── class_path.py # Class path based spawn processor
│ │
│ ├── replication/ # Property replication system
│ │ ├── spawn_bunch.py # Spawn bunch parsing (GUID, location, rotation, scale)
│ │ ├── content_block.py # Content block iterator (including subobjects)
│ │ ├── rep_layout.py # RepLayout — per-class property defs + deserialization
│ │ ├── rep_handle_map.py # Handle→property mapping, struct serializers
│ │ ├── types.py # PropertyType enum, PropertyDef, RepLayoutTemplate
│ │ ├── custom_delta/
│ │ │ └── base.py # Custom delta handler base/registry
│ │ ├── templates/
│ │ │ └── game_state.py # GameState server time sync callback
│ │ └── data/
│ │ └── rep_layout.json # Per-class property defs (UE5_RepLayout_Extractor)
│ │
│ ├── guid/ # Network GUID management
│ │ ├── package_map_client.py # NetGUIDCache — GUID ↔ path mapping
│ │ ├── net_field_export.py # Field name export tracking
│ │ ├── static_field_mapping.py # Per-class field index mapping
│ │ └── data/
│ │ └── max_values.json # Per-class SerializeInt max (UE5_ClassNetCache_Extractor)
│ │
│ ├── identity/ # Player ID system
│ │ ├── unique_net_id.py # FUniqueNetId — platform ID (NULL/STEAM/EOS, etc.)
│ │ └── unique_net_id_repl.py # FUniqueNetIdRepl — serialization/deserialization
│ │
│ ├── state/ # Per-connection state management
│ │ ├── session_state.py # Session state (login params, player ID)
│ │ └── game_state.py # Game state (server time)
│ │
│ └── rpc/ # RPC system
│ └── base.py # RPCBase + RPCRegistry
│
├── example/ # Example output
│ └── client_20260224_004909.log # Actual connection session log
│
└── .gitignore
All network data is serialized at the bit level. Follows LSB-first order within each byte (bit 0 = 0x01, bit 7 = 0x80).
| Function | Description |
|---|---|
SerializeInt(value, max) |
Variable length. Bit count determined by value range |
WriteIntWrapped(value, max) |
Fixed length. ceil(log2(max)) bits. Identical to SerializeInt when max is power of 2 |
SerializeIntPacked |
Variable length. 7-bit chunks + 1-bit continuation flag |
SerializeBits(data, bits) |
Raw bit copy of fixed bit count |
Structure of UDP payloads exchanged between server/client:
[handler_prefix] [packet_header] [bunch_0] [bunch_1] ... [inner_term] ← Handler.Outgoing() → [outer_term] [zero_pad]
Prefix prepended by StatelessConnectHandlerComponent to all data packets:
[CachedGlobalNetTravelCount: 2 bits] [CachedClientID: 3 bits] [bHandshakePacket: 1 bit(=0)]
Handshake packets (bHandshakePacket=1) use a separate format, distinct from data packets.
Two 1-bit terminators are required for packet end detection:
- Inner terminator — written before
Handler→Outgoing()call in_finalize_send_buffer(). Marks end of FlushNet payload. - Outer terminator — written after
Handler→Outgoing()call. Marks end of handler processing result.
Receiver strips in reverse order:
received_raw_packet(): strip outer terminator (FBitUtil.strip_trailing_one)handler.Incoming(): strip handler prefix, extract datareceived_raw_packet(): strip inner terminator (FBitUtil.strip_trailing_one)received_packet(): begin actual packet processing
Missing inner terminator causes ZeroLastByte fault → server disconnects.
Packet header managed by FNetPacketNotify:
[Seq: 14 bits] [AckedSeq: 14 bits] [HistoryWordCount-1: 4 bits]
- Seq: this packet's sequence number (0~16383, wrapping)
- AckedSeq: last acknowledged remote packet sequence
- HistoryWordCount: number of 32-bit words of receive history bitmask that follows (1~8)
After the header, HistoryWordCount × 32 bits of receive history follow. Each bit indicates received(1) or lost(0) for past packets.
Optional JitterClockTime info may follow the header (v14+):
[bHasPacketInfo: 1 bit] → [JitterClockTimeMS: SerializeInt(1024)] [bHasServerFrameTime: 1 bit] → [ServerFrameTime: 8 bits]
One or more bunches follow the packet header. Each bunch is a data unit for a specific channel.
[bControl: 1]
└ if 1: [bOpen: 1] [bClose: 1]
└ if bClose: [CloseReason: SerializeInt(15)]
[bIsReplicationPaused: 1]
[bReliable: 1]
[ChIndex: UInt32Packed]
[bHasPackageMapExports: 1]
[bHasMustBeMappedGUIDs: 1]
[bPartial: 1]
└ if 1: [bPartialInitial: 1] [bPartialCustomExportsFinal: 1] [bPartialFinal: 1]
[bReliable → ChSequence: WriteIntWrapped(MAX_CHSEQUENCE=1024)] ← 10-bit fixed, per-channel independent
[bOpen or bReliable → ChannelName: [bHardcoded: 1] → [PackedIndex] or [FString + Number]]
[PayloadBitCount: SerializeInt(MAX_BUNCH_DATA_BITS)]
[Payload: PayloadBitCount bits]
- Reliable: per-channel independent sequence 0~1023. Wrapping comparison via
MakeRelative(half=512, mod=1024). - Unreliable Partial: uses packet sequence as bunch sequence.
- Unreliable Non-Partial: sequence 0 (ordering not required).
Large data is split across multiple bunches:
bPartialInitial=1: start new partial bunch, initialize buffer- Middle fragments: append data to existing buffer (byte alignment validation)
bPartialFinal=1: last fragment, assembly complete → process completed bunch
All fragments must share the same Reliable/Unreliable attribute and have consecutive sequences.
Exchanges NMT (Net Control Message) messages:
| Step | Direction | Message | Content |
|---|---|---|---|
| 1 | C→S | NMT_Hello |
LocalNetworkVersion, EncryptionToken, RuntimeFeatures |
| 2 | S→C | NMT_Challenge |
Challenge string |
| 3 | C→S | NMT_Login |
Challenge response, URL(?Name=Player), FUniqueNetIdRepl, platform name |
| 4 | S→C | NMT_Welcome |
Map path, game mode class, redirect URL |
| 5 | C→S | NMT_Netspeed |
Bandwidth declaration (default 1,200,000) |
| 6 | C→S | NMT_Join |
Join request |
| 7 | S→C | NMT_Join |
Join accepted → actor replication begins |
Server-opened channel. Handles actor spawning and property replication.
Spawn bunch (bOpen=1):
[bHasMustBeMappedGUIDs → MustBeMappedGUIDs: uint16 count + GUID[]]
[ActorGUID: NetworkGUID]
[ArchetypeGUID: NetworkGUID]
[LevelGUID: NetworkGUID]
[Location: SpawnQuantizedVector]
[Rotation: CompressedRotation]
[Scale: Vector]
[Velocity: Vector]
→ followed by ContentBlocks for initial properties
Property update (bOpen=0):
Iterates update blocks via ContentBlock iterator:
ContentBlock:
[bHasRepLayout: 1] [bIsActor: 1]
└ if bIsActor: properties of this actor itself
└ else:
[ObjectGUID: InternalLoadObject]
[bStablyNamed: 1]
└ if 0:
[v30+ → bIsDestroy: 1 → DeleteFlag]
[ClassGUID: InternalLoadObject]
[v18+ → bActorIsOuter: 1 → OuterGUID]
[PayloadBits: UInt32Packed]
[Payload: PayloadBits bits]
Inside payload:
- RepLayout properties (
bHasRepLayout=1): read handle viaReadUInt32Packed, look upPropertyDeffor the class inrep_layout.json, invoke type-specific deserializer - Dynamic fields (remaining bits after RepLayout): read field index via
SerializeInt(field_max+1), bit count viaUInt32Packed, map usingmax_values.jsonfor RPC/CustomDelta processing
4-way handshake for UDP connection establishment:
sequenceDiagram
participant C as Client
participant S as Server
C->>S: Initial
Note right of C: TravelCount(2b) ClientID(3b)<br/>bHandshake=1 PacketType=Initial<br/>LocalNetworkVersion RuntimeFeatures<br/>SecretId=0 Timestamp=0 Cookie=0×20
S-->>C: Challenge
Note left of S: PacketType=Challenge<br/>Server LocalNetworkVersion<br/>SecretId Timestamp Cookie(20B)
C->>S: Response
Note right of C: PacketType=Response<br/>Echo SecretId+Timestamp+Cookie
S-->>C: Ack
Note left of S: PacketType=Ack<br/>CachedClientID assigned<br/>Cookie[0:2] → InSeq seed<br/>Cookie[2:4] → OutSeq seed
Note over C,S: NetConnection created, data packets begin
When the receiver gets a packet:
- Read
Seq,AckedSeq,Historyfrom packet header - Use
AckedSeqto determine delivery success/failure of sender's packets (referencing History bitmask) - Use
Seqto determine if new packet (Seq > InSeqmeans new) - After bunch processing, call
ack_seq(Seq)ornak_seq(Seq) - Include updated
AckedSeqandHistoryin next outgoing packet header
If reliable bunches arrive out of order, they are buffered in the in_rec dictionary and processed in order once the missing sequence arrives.
JSON files in data/ directories are auto-extracted from the UE5 project. To apply to other projects, regenerate with the tools below:
| File | Generation Tool |
|---|---|
rep_layout.json |
UE5_RepLayout_Extractor |
max_values.json |
UE5_ClassNetCache_Extractor |
example/client_20260224_004909.log contains the full log of an actual connection session:
[->] Init (48) a00102000098e40f32...
[<-] Challenge (50) a00182000098e40f32...
[->] Challenge Response (51) a00102810098e40f32...
[<-] Challenge Ack (51) a00182810098e40f32...
[INFO] CachedClientID: 0
[INFO] InSeq: 8965, OutSeq: 4611
[->] NMT_Hello (31) 00108c0312000000c0...
============================================================
Connected! Listening...
============================================================
[<-] Server (32) 0008480523000000c0...
[->] Response (52) 00108c041200000040...
...
[REPLAYOUT] Auto-registered LyraPlayerState (32 defs, 32 total handles)
[REPLAYOUT] LyraPlayerState OK handles=[5, 13, 14, ...] props={RemoteRole=1, ...}
[REPLAYOUT] B_Hero_ShooterMannequin_C OK handles=[...] props={RemoteRole=1,
ReplicatedMovement={'Location': FVector(2919.94, -1122.85, -443.96), ...}, ...}
from net.channels.actor.channel import ActorChannel
def on_player_spawn(spawn_data, connection):
print(f"Player spawned: {spawn_data.class_name}")
ActorChannel.register_spawn_processor("B_Hero_ShooterMannequin_C", on_player_spawn)from net.rpc.base import RPCRegistry
class MyRPCHandler(RPCBase):
def parse(self, reader):
# Deserialize RPC parameters
pass
RPCRegistry.register("MyFunction", MyRPCHandler)# Add new file under net/replication/templates/
from net.replication.types import RepLayoutTemplate
def on_update(props, connection):
if 'Health' in props:
print(f"Health changed: {props['Health']}")
TEMPLATES = [
RepLayoutTemplate(
match=lambda name: 'HealthComponent' in name,
on_update=on_update,
),
]- Receive-only: client input, movement, and RPC sending are not implemented
- No encryption: cannot connect to servers with AES-GCM/DTLS enabled
- Some structs unsupported:
GameplayAbilitySpecContainer,ActiveGameplayEffectsContainer,GameplayTagStackContainerand other GAS-related complex struct deserializers are missing - Single connection: concurrent multi-connection is not supported
This project is created for educational and research purposes. It is a reference implementation for understanding and learning the UE5 network protocol.
Commercial use is prohibited. This code may not be used in for-profit products, services, or revenue-generating activities.