Technical write-up: how PES 2021 talks to its servers

The network protocol of eFootball PES 2021 (1.01.00, Steam), reverse-engineered as far as login, the network test and Quick Match need it, as implemented by this project's server. Last update: 2026-11-05.

In plain words: the game connects to a "gate" server on TCP port 10000 and exchanges small messages written in MessagePack, each compressed, encrypted with Blowfish and sealed with a checksum. A few extra requests go over HTTP and HTTPS, and a STUN check measures your network. Matches themselves run directly between the two players. The main page tells the story; this page has the details for developers: byte layouts, keys, every command and the code addresses they were found at. Everything beyond Quick Match (relay, rooms, results, co-op, menus) is on How the online functions work.
LIVEverified against the real client STATICread from disassembly INFERREDreasoned / prior art UNKNOWNnot understood yet OPENnot built yet

Status

Redirect✔ 6 Konami hostnames → your server (answered by the mod inside the game)
Frame✔ XOR + MD5 (NclMio)
Body crypto✔ Blowfish-ECB, static 56-byte key
Payload✔ MessagePack commands
Gate → Account → Menu login✔ full chain, player created & persisted
Session key✔ base64 key in SET_CURRENTPLAYER, learned live
NAT test (eFootball modes)✔ STUN + GateInfo.php over HTTPS (the mod accepts the server's certificate for the 4 NTL names)
Server messages✔ news pop-up, box before Quick Match, restart warning, kick note
Matchmaking + P2P✔ paired, peers connected, both in the online Match Menu
Online match✔ kick-off, both halves, half-time, fouls/cards, clocks in sync
Deployment✔ one program for Linux (x64, arm64); server identity key created on first start

↑ Back to contents

Topology

PES2021.exe Proton 10 · Fox Engine fox::ncl NclMio client 1.01.00 the mod dinput8.dll inside the game 6 Konami names → server Gate tcp/10000 pes21-x64-gate.cs.konami.net · NclMio msgpack commands HTTP tcp/80 + 8080 · HTTPS tcp/443 friends' page + mod download · web API · GateInfo.php STUN / NTL udp/3478 ntl.service.konami.net · NAT type / public mapping Peer ↔ peer match · udp/7600 = relay stub direct UDP between the two games · never touches the server

Konami's game servers are gone: the gate and STUN server names no longer resolve (NXDOMAIN). The DRM-wrapped exe is never patched; everything is redirected at the resolver.

↑ Back to contents

Layer stack (gate connection)

TCPport 10000, the client sends first NclMio frameXOR 5b9f2e64 ( kind | len | seq | md5 | body ) LIVE Body cipherBlowfish-ECB, 56-byte static key (easy tier, seen live) · AES-256 enhanced tier (code only, never seen) LIVE Envelopeu32 data_len | u32 original_size | u8 zlib | data | pad to 8 LIVE MessagePackmap with msgid + rqid + fields LIVE CommandCMD_GET_SVRLIST · CMD_GET_URLLIST · CMD_AUTH_STEAM · CMD_LOGIN · … (435 names) LIVE

↑ Back to contents

Gate exchange

Client Local gate TCP connect (after resolver redirect) CMD_GET_SVRLIST · rqid n · svrtype GATE · client NETCLIENT · lang ANY kind 0x0060 · 152 bytes · patch 1.01.00 · dlc 1.00 result NOERR · server_num · svrlist [ svrtype, svraddr, svrport, svrgid, … ] then URL list, version, time, nation list … (see Login sequence)
Correction (2026-11-04). The 152-byte "hello" is not a key exchange. It is an ordinary CMD_GET_SVRLIST request, and the client speaks first. The RC4 / dlcp code found earlier is real, but it is a different link (see Not the gate). The old "reply must be 16 bytes" result was a side effect: our reply was decoded as a command, and a 16-byte body produced a garbage length that crashed the client's MessagePack reader.

↑ Back to contents

Login sequence (live)

  1. Player id (our mod 0.3.0+, not the game): before the game's first frame on each tcp/10000 connection the mod sends one line PESID1 <32 hex>\n (40 bytes), a hash of a random secret in pes2021-online.ini and the PC's MachineGuid. It does so only when /mod/hello answered version 2 or newer. Mod 0.4.0 asks v=3; a version-3 server gets PESID2 <32 hex> math1\n instead, meaning the game uses the mod's own sinf/powf/atan2f/qsort… (Microsoft's and Wine's differ in the last bits, which ended a Windows-vs-Proton match at kick-off with SYNC_FAILED / RMYN393), and matchmaking pairs only games with the same setting. Mod 0.5.0 adds coop1 (PESID2 <32 hex> math1 coop1\n) when its Online Co-op fix is in (two hooks on the game's 1.01.00 co-op code); co-op matchmaking pairs only such games. The server stores accounts by it (m:<id>) instead of the game's username, which comes from the Steam ID and is the same in every copy that doesn't run through Steam. The game's frames start with 0x5b (XOR), so the line can't be mistaken for one.
  2. GATE (tcp/10000): CMD_GET_SVRLIST → CMD_GET_URLLIST → CMD_GET_SVRVERSION → CMD_GET_SVRTIME → CMD_GET_EULA_NATION_LIST → CMD_GET_EULA_NATION_CODE → CMD_DISCONNECT logoutstat=MOV. A second gate session fetches CMD_GET_GLOBALINFOTITLELIST. LIVE
  3. First run only: Country/Region picker (from our nation list) and Terms of Use screen (documents opened via ShellExecute on URL-list entries EULA_*). Persisted locally once a full login succeeds. LIVE
  4. HTTP POST to the URL-list entry AUTH_STEAM: form msgid=CMD_AUTH_STEAM&rqid=0&urltype=AUTH_STEAM&steamid=…&package_type=DEFAULT; reply is a raw msgpack map (JSON is rejected with C_GKCC_0008_200). LIVE
  5. ACCOUNT (same socket, svrtype ACCOUNT): CMD_SEND_HEARTBEAT, CMD_GET_CHALLENGECODE, CMD_LOGIN {username, hash, platform PC, sku}, CMD_SEND_PSID, CMD_GET_PLAYER_DATA (has_data NO → "Create User Data" screen → CMD_CREATE_PLAYER {name}), CMD_SET_LANGUAGE, CMD_DISCONNECT MOV. LIVE
  6. MENU (new connection): CMD_LOGIN, CMD_SET_CURRENTPLAYER → reply carries key (base64) + key_size; the client switches its Blowfish key to base64-decode(key) for every later frame. Then CMD_GET_STORE_PRODUCT_LIST, CMD_GET_GP_ACHIEVEMENT, CMD_GET_STORE_TRANSACTION_STATE, heartbeats every 30 s. LIVE
  7. Live update: LiveDataSetFlow (0x1420ac3f0) POSTs CMD_GET_DLC_FILELIST (file_type LIVEDATA, region REGION_ALL) to the URL-list type FLOW. Without a FLOW URL it fails locally ("New data asset download unsuccessful", then logout FIN). Answering with empty lists and dlc_version "1.00" gives "No new Live Update available" and the client proceeds. LIVE
  8. Online main menu: CMD_GET_GLOBALINFOTITLELIST, CMD_GET_PRIVATEINFOTITLELIST, CMD_GET_DMM_PLAYERINFO, CMD_SEND_LOG. LIVE
  9. The client caches the URL list for the whole game session; URL-list changes need a fresh title login. LIVE

↑ Back to contents

Encoding rules the client enforces

RuleEvidence
Every list of objects is an array of msgpack-encoded blobs; the client runs its unpacker on each elementsvrlist loop 0x141d48a41 crashed on inline maps LIVE
A single nested object (e.g. pname, psn_user_id) is also a msgpack blobGET_PLAYER_DATA crash at 0x141d3fe49 on an empty string LIVE
Field types are strict: strings are copied as (ptr,len), so an int where a string is expected yields garbage (e.g. EULA versions → endless re-prompt)SVRVERSION parser 0x141d4af80 LIVE
Yes/No fields are strings "YES"/"NO"; privacy kinds are PRIVACY_POLICY etc..rdata tables STATIC
Server types: GATE, ACCOUNT, MENU, VDPCHAT — a missing MENU entry gives PCKS_0001.rdata 0x142897a18 LIVE

All 398 command response schemas (field names + STR/NUM/LIST/OBJ) are extracted in bulk by notes/re/extract_schemas.py from the Cmd…Result@commandautomake RTTI classes into server/pesserver/schemas.json.

↑ Back to contents

Wire format

NclMio frame LIVE STATIC

kindu16 BE
lenu16 BE
sequ32 BE
md5(header + body)16 B
bodylen B (≤ 0x410)

The whole frame is XOR'd with the repeating key 5b 9f 2e 64 (constant at 0x143515acc). The md5 covers the plaintext header and the body before XOR. Parser: 0x1418548c0.

Messages larger than one frame continue in the frames that follow; the server reassembles them (seen when entering myClub, about 1.7 KB). Exact layout of the continuation frames INFERRED: the server accepts either plain continuation or a repeated 9-byte header, and logs which one the client used.

Decrypted body LIVE

data_lenu32 BE
original_sizeu32 BE
zlibu8 0/1
datamsgpack or zlib(msgpack)
padto 8 B
real capture, decrypted:
00000074 00000074 00 | 88 a5 "msgid" af "CMD_GET_SVRLIST" a4 "rqid" 01 a7 "svrtype" a4 "GATE"
                       a6 "client" a9 "NETCLIENT" a7 "timeout" 00 a4 "lang" a3 "ANY"
                       ad "patch_version" a7 "1.01.00" ab "dlc_version" a4 "1.00" | 00 00 00

Frame kinds STATIC

KindDecoder behaviour (0x1418566c0)Seen
0x0060, 0x0010easy tier: data_len, original_size, zlib flag, dataLIVE 0x0060
0x0070, 0x0020first fragment of a message over ~1015 bytes: total_len, chunk_len, original_size, zlib flag, chunk (details)STATIC
0x0071–72, 0x0021–22middle / last fragment: chunk_len, chunkSTATIC
0x1060, 0x1070–72enhanced tier (only after a 32-byte session key): kind not XOR'd, IV, AES-256-CBC with SHA-256 (layout)STATIC

↑ Back to contents

Crypto

ItemValueWhereStatus
Frame XOR key5b 9f 2e 64.data 0x143515accLIVE
IntegrityMD5(header ∥ body)frame bytes 8–23LIVE
Easy key (Blowfish-ECB)d8890af0 66c96b40 d701aefc 436ff9fe c9899816 7a74483d 3914730c 5c01c03c e28e86e5 89c4a185 f8540651 d2eca36b 5c1a40ee c5e9daae.data 0x1434d61c0 (56 B), installed by 0x14116eeb0 when no key is set. First 16 bytes = MGSV's static key.LIVE
Session keyCMD_SET_CURRENTPLAYER key = base64; decoded bytes become the Blowfish key (key_size 32 would switch to AES-256)installed by the SET_CURRENTPLAYER handler 0x1410f9ca0 (CMD_RECONNECT: 0x141233ee0); derivation confirmed live by the server trying candidatesLIVE
Key holderfox::ncl::NclDaemon (vt 0x142a4d6c0)easy key +0xe8 / len +0x120 · AES key +0x124 / len +0x144STATIC

↑ Back to contents

Commands

Field names are read from the client's own parsers: each command's request and response keys sit together in .rdata.

CommandRequestResponseStatus
CMD_GET_SVRLISTsvrtype, client, timeout, lang, patch_version, dlc_versionresult, server_num, svrlist[ svrtype, svraddr, svrport, svrgid, max_player_num, player_num ]LIVE req
CMD_GET_SVRTIMEsvrtype, clientresult, dateLIVE
CMD_GET_SVRVERSIONsvrtype, client, patch_version, dlc_version, livedata_versionresult, version, patch_version, dlc_version, eula_version, eula_myclub_version, livedata_version, eula_privacy_* versionsLIVE
CMD_GET_URLLISTsvrtype, client, langresult, url_num, ulist[ type, url ]LIVE
CMD_AUTH_STEAM (HTTP form), CMD_LOGINsteamid, package_type / username, hash, platform, skuresult (raw msgpack map)LIVE
Matchmaking: CMD_START_MATCHING, CMD_WATCH_MATCHING …see Matchmaking protocol and Matchmaking — liveLIVE
CMD_GET_LOBBYLIST, CMD_JOIN_LOBBY, CMD_CREATEJOIN_ROOM, CMD_JOIN_ROOM_NTLfield names known from the parsers; not used by Quick Match, not implemented yetUNKNOWN

Result codes: NOERR, ERR_NOTFOUND, ERR_LOGIN_FAILED, ERR_DIFFERENT_PATCHVERSION, ERR_SERVERNOTREADY, … Server types: GATE, ACCOUNT, MENU, VDPCHAT.

↑ Back to contents

NAT test ("Testing network environment")

↑ Back to contents

Matchmaking protocol (from the client's parsers)

CommandRoleReply fields
CMD_SEND_IPANDPORT_NTLclient reports its addressesresult
CMD_GET_IPANDPORT_NTLfetch a player's addressespid, ntl_nat_type, host_addr, reflexive_addr, relayed_addr
CMD_GET_LOBBYLIST / CMD_JOIN_LOBBYlobbylobby_list[ blob{lobby_id, name, player_num, player_num_max, gamerelay_index} ] / gamerelay_web_url
CMD_START_MATCHING / CMD_CANCEL_MATCHINGqueueresult
CMD_WATCH_MATCHING"match found" — implemented as a long-pollmatching_state (FINISH, required), player_num, member[ blob{pid, pname, is_owner, side HOME/AWAY, matching_order, host_addr, …} ]
CMD_CREATEJOIN_ROOM / CMD_JOIN_ROOM_NTLroomsroom_id / room_owner + peer addresses
CMD_GET_MATCHID / CMD_SEND_MATCHING_RESULTmatch id / result reportmatch_id, upload / result

Address blobs are {address_family, sa_data} with sa_data a raw Windows sockaddr payload carried in a msgpack string (non-UTF-8 bytes) — the server must decode strings leniently.

↑ Back to contents

Matchmaking — live

  1. eFootball → NAT test → live-update check → CMD_SEND_IPANDPORT_NTL (host_addr blob with raw sockaddr) → CMD_GET_GAME_TEAM_LIST, CMD_GET_SERVER_ENV, CMD_CHECK_CHEAT, CMD_GET_URGENT_ANNOUNCEMENT (online_mode QUICK), CMD_GET_MANNER_WARN. LIVE
  2. Select Team → CMD_GET_QUICKMATCHOPTION → "Matchmaking Settings" → Go to Matchmaking → CMD_GET_MAINTENANCE_INFO, CMD_START_MATCHING {matching_type OC_QUICK, has_guestplayer, guest_info}. LIVE
  3. Both clients show "Matchmaking — Searching for an opponent" and never poll; the server paired them ("MATCH FOUND room 1"). LIVE
  4. Server push CMD_WATCH_MATCHING (kind 0x60, seq 1, rqid 0): pushes are routed by msgid only to watches the client registered (0x141239840 / 0x1412395f0). The key field is matching_state: FINISH → match accepted; missing → ignored → 300 s timeout = error EYRH763 (line 763 of the quick-match task). Members: blobs {pid, pname, is_owner YES/NO, matching_order, host/reflexive/relayed_addr, ntl_nat_type}. LIVE
  5. Game relay: CMD_GET_GAMERELAY is an HTTP web-API call to URL-list type GET_GAMERELAY (then GET_TURN_SERVER_LIST as fallback). Missing type → instant local failure ER_MATCHING_RETRY. We now serve all 53 URL types from the client's enum (0x14021ee70), short paths to fit one frame. LIVE
  6. Peer signalling: clients send CMD_SEND_SYNC_NOTICE / SEND_SYSTEM_NOTICE {pid_list, data} ("1,", "END:pid=2;time=…"); the server must relay each as CMD_WATCH_SYNC_NOTICE / WATCH_SYSTEM_NOTICE {data} to the listed pids. With relay on, both report CMD_SET_GAMERELAY_CHECK_RES {game_sycom P2P, error_code NOERR} and exchange UDP directly; server pushes CMD_WATCH_GAMERELAY_CHECK_RES {error_code NOERR}. LIVE
  7. Then CMD_SEND_MATCHING_RESULT {member, vs_member, game_sycom P2P}, CMD_SET_GAMEMEMBER, CMD_SET_GAMEMEMBERENV {envlist pid/side/sideLeader}, CMD_GET_MATCHID, CMD_CHANGE_GAMEPHASE TEAMSELECT → MATCHMENU (online pre-match menu, shared countdown) → both players Kick Off → both GAMELOAD. LIVE
  8. Sides: every WATCH_MATCHING member blob needs side = HOME (owner) / AWAY. Without it the HOME team is never filled and team-data sync aborts with MMGC_0002 (MultiplayCmdTeamDataSync.cpp line 2). LIVE
  9. Team list: CMD_GET_GAME_TEAM_LIST must return the selectable clubs as blobs {team_id, team_class, division_point}; ids are the game's internal DB ids (100 Man Utd, 101 Arsenal, 108 Barcelona, 114 PSG, 116 Ajax, 120 Juventus, 127 Bayern, 131 Celtic). Empty list → no team to pick. LIVE
  10. Server environment: CMD_GET_SERVER_ENV timeouts must be real values (load_timeout 180, long_outofplay 120, init_not_end 240, cmd_lack 180 …). Zero-filled defaults made the client abort instantly with LOAD_TIMEOUT (QVHE1459, GameModeMatchListenerMatch.cpp) and then crash while tearing down match::Online. Lesson: an unhandled command gets a bare {result: NOERR} so the client keeps its own defaults — never zero-filled fields. LIVE

↑ Back to contents

Messages on players' screens — live

Four ways for the server to put its own words in the game, each tested on 2026-11-05 and offered on the admin page (card "Message to players", and Kick). Flags take "YES"/"NO"; "TRUE" is silently ignored. Code: server/pesserver/announce.py.

What the player seesWhen the game asksReply that works
Online Information list (title + date) at sign-in, and a pop-up with title + body when reaching the main menuCMD_GET_GLOBALINFOTITLELIST during sign-in and at the main menu; then CMD_GET_INFOBODY {announce_id} for each listed itemlist: announce_info_list = blobs {announce_id, announce_type "NO_BANNER_INFO", title, date, popup_type "EVERYTIME", priority, …} + annnounce_info_list_num (three n's). Body: announce_info = blob {announce_basic_info: blob{same item}, body, link_url "", banner_file_info: blob{filename, hash_str}, …}. Newlines in body show as line breaks. Without a body the pop-up opens empty.
A box with title, body and OK before Quick MatchCMD_GET_URGENT_ANNOUNCEMENT {online_mode QUICK, announcement_type PRE_MAIN} every time an online mode is entered{is_announcement "YES", announcement_data: blob{announcement_id, online_mode, announcement_type (echoed), read_check "NO", title, body}}. The game shows it every time, so the server sends each message once per player.
The game's own text: "We will be undertaking maintenance work on our servers starting at HH:MM today… Are you sure that you want to start a match now?" (Yes / No; No goes back)CMD_GET_MAINTENANCE_INFO on "Go to Matchmaking"{maintenance_warn "YES", maintenance_time <unix seconds>}; the time is shown in each player's local time. "TRUE" or a free-text warning shows nothing.
Kick: a note "You were disconnected" with the host's reasonthe next CMD_GET_URGENT_ANNOUNCEMENT after they reconnectas above, queued for that player only. An error code doesn't help: answering the kicked game's next heartbeat with ERR_ALREADYLOGGEDIN or ERR_SERVERNOTREADY still shows only "Connection to server lost. CODE:VNBP_0001" (OnlineSystemClient.cpp line 833; a closed socket gives VNBP_0002, line 864).

Testing reply shapes without rebuilding: put overrides.json in the server's data folder, mapping a command name to the reply to send instead ({"__blob__": {…}} becomes one msgpack blob, {"__blobs__": […]} a list of blobs). It's read on every request; delete it when done.

↑ Back to contents

Match — live

  1. After MATCHMENU both clients Kick Off → CMD_CHANGE_GAMEPHASE GAMELOAD → ENTRY → 1ST. Gameplay itself is peer-to-peer UDP between the clients; the server only sees phase reports and events. LIVE
  2. Phases reported (each with gtimer, the game clock): DATASYNC, TEAMSELECT, MATCHMENU, GAMELOAD, ENTRY, 1ST, 1ST_15MIN, 1ST_30MIN, BREAK1 (half-time), 2ND, 2ND_15MIN, 2ND_30MIN, then the end of the match. A bare NOERR reply to each is enough. LIVE
  3. Events during play: CMD_ADD_FOUL (fouls, yellow cards); both clients report the same events, and their clocks stay in sync. LIVE Goals are reported as CMD_ADD_SCORE {goal_side, goal_player_pid, assist_*, gtimer}; there is no CMD_ADD_GOAL in the client (details). LIVE
  4. Full time: both clients send CMD_CHANGE_GAMEPHASE {phase END, gtimer 94107} (90 min + stoppage), then show the highlights reel and match records. Post-match reporting: CMD_SET_PLAYDATA_MATCH (HTTP, URL type PLAYDATA_MATCH, JSON play_data), CMD_SEND_STATS_DATA {match_id, stats[] per side}, CMD_SEND_GAME_RESULT {game_phase END, side, home/away_score, home/away_team_id, abnormalend_reason NOERR, …}, CMD_REQ_GAMEADDPOINT, CMD_LEAVE_ROOM. A bare NOERR to each returns both players to the menu; a new Go to Matchmaking pairs them again straight away. Verified with a complete 90-minute match on 2026-11-04 (18:20–18:52, Arsenal v Arsenal 0–0). LIVE
  5. The player who leaves the post-match screens second reports DISCONN_P2P / GNCX_0003 (OnlineModeMultiplay.cpp): the peer link has already closed. The result is still recorded normally. LIVE
  6. Abnormal end: CMD_SEND_ABNORMALEND {reason, error_code}. Seen reasons: NO_OPERATION (a player gave no input for about 70 s (live, 2026-11-05); this is the game's AFK rule), DISCONN_P2P (peer link lost), LOAD_TIMEOUT. With real server-environment timeouts, both games returned to the menu without crashing (the LOAD_TIMEOUT crash in the matchmaking section happened only with zero timeouts). LIVE

↑ Back to contents

Error codes

On-screen codes such as EYRH763 or MMGC_0002_0_… encode the source file and line: the four letters are a hash of the .cpp basename (characters bucketed by index mod 4, each bucket summed mod 26 + 'A'), followed by the line number. notes/re/errcode.py matches the letters against every source-file name in the binary's strings.

CodeFileMeaning in practice
EYRH763OnlineModeTaskQuickMatching.cppno valid match push within 300 s (missing matching_state)
MMGC_0002MultiplayCmdTeamDataSync.cppteam-data sync failed (member side missing)
VNBP_0001 / 0002OnlineSystemClient.cppconnection to the gate lost: an error reply to a heartbeat (line 833) or a closed socket (line 864); the text never changes
QVHE1459GameModeMatchListenerMatch.cppmatch load timeout (server env timeouts were 0)

↑ Back to contents

Deployment

  1. Host (Linux): run.sh downloads the newest program from the latest GitHub release (PyInstaller, built by .github/workflows/program.yml) and runs pes2021-server install: a systemd user service, an apps-menu entry, then the admin page. Updates (checked against the release's SHA256SUMS and test-run), restart and stop are on the admin page. No configuration: netsetup.py finds the LAN IP, asks the router for its external IP and opens TCP 80/443/8080/10000 + UDP 3478/7600 over UPnP IGD (renewed every 30 min, removed on stop), checks the public IP against a what-is-my-IP service to detect CGNAT, and prints the admin page address and password. It also looks for PES2021.exe (Steam libraries, ~/Games) and their Wine prefixes, for the admin page's one-click install (on Windows, Steam's registry entry). LIVE
  2. Per-client address: SVRLIST / URLLIST / GAMERELAY carry the server address. Without a pinned PUBLIC_IP, a client from a non-global address (LAN, Tailscale 100.64/10, loopback) gets the local address its TCP connection reached (sockname); LIVE An internet client gets the public IP, so one server can serve LAN and internet players at once (no internet client tested yet). INFERRED
  3. The mod (server/mod/, built by the image's first stage with MinGW): a dinput8.dll proxy (exports forwarded to the system DLL) that hooks ws2_32 getaddrinfo / GetAddrInfoW / GetAddrInfoExW / gethostbyname for the 6 Konami names, and winhttp WinHttpConnect / OpenRequest / SendRequest for the NTL certificate exception, plus WinHttpCloseHandle (to forget closed handles) and user32 CreateWindowExW / CreateWindowExA (to start the server lookup, or the Shift box, when the game's first window opens). Hooks are MinHook detours in the system DLLs; the game's own code is not touched. Loaded under Proton via WINEDLLOVERRIDES="dinput8=n,b" or the per-program registry override [Software\\Wine\\AppDefaults\\PES2021.exe\\DllOverrides] "dinput8"="native,builtin", which the admin page writes into the prefix's user.reg. LIVE
  4. Server identity: an Ed25519 key in the data volume. /mod/dinput8.dll writes the public key and the server's addresses into a 2 KB reserved section of the DLL (.pescfg). Before using an address the mod GETs /mod/hello?n=<random nonce> and checks the signature (Monocypher); to find the server again it fetches a signed /mod/note (time + addresses) and ignores notes not signed by that key or older than the last one seen. Tested in the game: fresh download, dead web link plus wrong IP, forged note rejected. LIVE
  5. The server still keeps the private CA (name-constrained to the four NTL host names) for players set up with the older join scripts (/join, /ca.crt); the mod doesn't use it.

↑ Back to contents

Not the gate: kps::dlcp

Real, statically mapped, but not used on the gate connection. Most likely the peer-to-peer match link INFERRED. Quick Match doesn't need it: matches work without the server touching this link.

ItemValue
Link cipherRC4 ("Arcfour"), KSA 0x14204c700, PRGA 0x14204cad0
Static RC4 keya977d392 db0e55b9 9d1641c2 d4f82739
Key exchangeSimpleExchangeKeyExchanger: reply_verify 0x142053f90 checks only that the RC4-decrypted reply is 16 bytes; the 16 bytes become the session key (0x142053e10)
Record[type | RFC 1071 checksum | payload], decoder 0x142052be0, checksum 0x142052e90

↑ Back to contents

Our server

FileRole
server/pesserver/ncl.pyNclCodec: frame, Blowfish easy tier, envelope, zlib, msgpack
server/pesserver/pescmds.pyCMD_* handlers for gate / account / menu (server list, URL list, EULA, login, player data, team list, server env, session key); unknown commands get a bare NOERR and are logged
server/pesserver/pesmatch.pyquick-match queue and pairing, WATCH_MATCHING long-poll/push with sides, game relay info, relay-check result, peer notice relay, match id
server/pesserver/gate.py · service.pyTCP gate loop (handler crashes are caught and answered) · dispatch and session bookkeeping
server/pesserver/stun.py · httpgate.pyRFC 5389 binding · HTTP 8080 web-API, HTTPS GateInfo.php on 8080 too (TLS told apart by the first byte; the mod sends the network test there) and on 443 when allowed, /ca.crt
server/pesserver/identity.pyserver identity (Ed25519 key), signed /mod/note and /mod/hello, writes settings into the mod DLL
server/pesserver/netsetup.pyLAN / public IP detection, UPnP port mapping, CGNAT detection, per-client advertised address, startup banner
server/pesserver/announce.pynews list and pop-up, box before Quick Match, restart warning, kick notes (state in announce.json)
server/pesserver/events.py · gamesetup.pyadmin page data (online, live phases, results, league, activity) · "The server computer" mod install into mounted game folders
server/mod/the mod: dinput8.dll proxy, MinHook hooks, Monocypher signature check; built by mod/build.sh (MinGW); the network test goes to port 8080
run.sh · server/packaging/ · pesserver/hostctl.pythe install line (Linux): fetches the newest program and sets it up as a systemd user service with an apps-menu entry; updates, restart and stop from the admin page; PyInstaller build and smoke test

Run: python3 -m pesserver (the program's host mode), or python3 -m pesserver serve for development (ncl is the default codec). Every request is logged decoded (RX … CMD_…).

↑ Back to contents

How it was tested

Every finding marked LIVE was checked against the game's original 1.01.00 executable, run directly under Proton on Linux (a run started through the Steam client is still pending). A recorded log of real client requests is replayed through the server's handlers offline and each reply is validated against the reply formats extracted from the game (server/test_ncl.py; the published repo replays the captured first frames in notes/re/captures, the full recorded log stays local). End-to-end runs drove two copies of the game on one Linux PC through login, the network test, matchmaking and complete 90-minute matches. The local test harness itself is not part of the published repo.

↑ Back to contents

How we found it

  1. Wine's own debug channels (WINEDEBUG=+seh,+winsock) showed the client crashing in memcpy called from 0x1418568bb right after reading our reply.
  2. That function is the NclMio decoder: it builds a message object, decrypts the body, and switches on kind.
  3. The crypto factory 0x141856610 allocates a 0x1050-byte object for the easy tier — the size of a Blowfish context.
  4. 0x14116eeb0 installs a 56-byte key from .data when none is set.
  5. Blowfish-ECB with that key turned the captured "hello" into readable MessagePack.

↑ Back to contents

Key addresses (image base 0x140000000)

AddressWhat
0x1418548c0NclMio parse (XOR, header, md5)
0x141855cc0NclEnhancedMio parse
0x1418566c0message decode: crypto, kind switch, envelope fields
0x141856610crypto factory (tier 0 Blowfish, tier 1 AES)
0x14116eeb0installs the static easy key
0x1434d61c0easy key (56 B)
0x141d48430CMD_GET_SVRLIST response parser
vt 0x142a4d6c0fox::ncl::NclDaemon
vt 0x142a4f0a0 / 0x142a4f600NclMio / NclEnhancedMio

↑ Back to contents

Open questions

Every remaining online function is now mapped from the game's code on a separate page, How the online functions work: what each command does, what the server must send, all 433 commands and all error codes. What's left to build:

↑ Back to contents

Changelog

↑ Back to contents