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.
Status
Topology
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.
Layer stack (gate connection)
Gate exchange
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.Login sequence (live)
- 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 inpes2021-online.iniand the PC's MachineGuid. It does so only when/mod/helloanswered version 2 or newer. Mod 0.4.0 asksv=3; a version-3 server getsPESID2 <32 hex> math1\ninstead, meaning the game uses the mod's ownsinf/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 addscoop1(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. - 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 - 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
- HTTP POST to the URL-list entry
AUTH_STEAM: formmsgid=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 - 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
- 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 - Live update: LiveDataSetFlow (0x1420ac3f0) POSTs
CMD_GET_DLC_FILELIST(file_type LIVEDATA, region REGION_ALL) to the URL-list typeFLOW. 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 - Online main menu: CMD_GET_GLOBALINFOTITLELIST, CMD_GET_PRIVATEINFOTITLELIST, CMD_GET_DMM_PLAYERINFO, CMD_SEND_LOG. LIVE
- The client caches the URL list for the whole game session; URL-list changes need a fresh title login. LIVE
Encoding rules the client enforces
| Rule | Evidence |
|---|---|
| Every list of objects is an array of msgpack-encoded blobs; the client runs its unpacker on each element | svrlist loop 0x141d48a41 crashed on inline maps LIVE |
A single nested object (e.g. pname, psn_user_id) is also a msgpack blob | GET_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.
Wire format
NclMio frame LIVE STATIC
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
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
| Kind | Decoder behaviour (0x1418566c0) | Seen |
|---|---|---|
| 0x0060, 0x0010 | easy tier: data_len, original_size, zlib flag, data | LIVE 0x0060 |
| 0x0070, 0x0020 | first fragment of a message over ~1015 bytes: total_len, chunk_len, original_size, zlib flag, chunk (details) | STATIC |
| 0x0071–72, 0x0021–22 | middle / last fragment: chunk_len, chunk | STATIC |
| 0x1060, 0x1070–72 | enhanced tier (only after a 32-byte session key): kind not XOR'd, IV, AES-256-CBC with SHA-256 (layout) | STATIC |
Crypto
| Item | Value | Where | Status |
|---|---|---|---|
| Frame XOR key | 5b 9f 2e 64 | .data 0x143515acc | LIVE |
| Integrity | MD5(header ∥ body) | frame bytes 8–23 | LIVE |
| 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 key | CMD_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 candidates | LIVE |
| Key holder | fox::ncl::NclDaemon (vt 0x142a4d6c0) | easy key +0xe8 / len +0x120 · AES key +0x124 / len +0x144 | STATIC |
Commands
Field names are read from the client's own parsers: each command's request and response keys sit together in .rdata.
| Command | Request | Response | Status |
|---|---|---|---|
| CMD_GET_SVRLIST | svrtype, client, timeout, lang, patch_version, dlc_version | result, server_num, svrlist[ svrtype, svraddr, svrport, svrgid, max_player_num, player_num ] | LIVE req |
| CMD_GET_SVRTIME | svrtype, client | result, date | LIVE |
| CMD_GET_SVRVERSION | svrtype, client, patch_version, dlc_version, livedata_version | result, version, patch_version, dlc_version, eula_version, eula_myclub_version, livedata_version, eula_privacy_* versions | LIVE |
| CMD_GET_URLLIST | svrtype, client, lang | result, url_num, ulist[ type, url ] | LIVE |
| CMD_AUTH_STEAM (HTTP form), CMD_LOGIN | steamid, package_type / username, hash, platform, sku | result (raw msgpack map) | LIVE |
| Matchmaking: CMD_START_MATCHING, CMD_WATCH_MATCHING … | see Matchmaking protocol and Matchmaking — live | LIVE | |
| CMD_GET_LOBBYLIST, CMD_JOIN_LOBBY, CMD_CREATEJOIN_ROOM, CMD_JOIN_ROOM_NTL | field names known from the parsers; not used by Quick Match, not implemented yet | UNKNOWN | |
Result codes: NOERR, ERR_NOTFOUND, ERR_LOGIN_FAILED, ERR_DIFFERENT_PATCHVERSION, ERR_SERVERNOTREADY, … Server types: GATE, ACCOUNT, MENU, VDPCHAT.
NAT test ("Testing network environment")
- Resolves
ntl.service.konami.net, sends 32-byte UDP probes to port 3478, opens TLS to port 443, and does UPnP discovery against the LAN router. LIVE - Its UDP socket is bound to the network interface, so it cannot reach 127.0.0.1 — the redirect target must be the machine's LAN address. LIVE
- Probe order: (1) 32-byte Binding Request with vendor attr
0xF000(8 bytes:05730000 00000002); (2) TLS to port 443 with SNIntl.service.konami.net(thenPOST /ntl/api/GateInfo.php, see below); (3) 64-byte Binding Requests every 5 s with vendor attrs0x9090(24 B, starts10000001) and0x9091(12 B,00010104 00000000 <counter>). The keepalives need more than a plain Binding Success (see next item). LIVE - A plain Binding Success (XOR-MAPPED + MAPPED, same tid) is accepted for the 0xF000 "MappingTestIA" probe. The 0x9090/0x9091 packets are post-test keepalives (RP_P2P_HEADER / RP_RELIABILITY); a new serial every 5 s means they are being answered. Don't send OTHER-ADDRESS unless a real alternate IP:port exists. LIVE
- GateInfo.php:
POST https://ntl.service.konami.net/ntl/api/GateInfo.php, User-AgentNTL HttpModule/0.1, bodyreq={"titleCode":"PES2021",…}. WinHTTP default certificate validation (no pinning). The mod setsWINHTTP_OPTION_SECURITY_FLAGS(ignore unknown CA / CN / date / usage) on requests whose connection was opened to one of the fourntl*.service.konami.netnames, and on nothing else. (The earlier join scripts instead trusted a private CA name-constrained to those four names.) Reply is text lines:STATUS: 200,API_STATUS: 0,LOG_ACTIVE: 0,SERVER_TIME: <unix>. LIVE
Matchmaking protocol (from the client's parsers)
| Command | Role | Reply fields |
|---|---|---|
| CMD_SEND_IPANDPORT_NTL | client reports its addresses | result |
| CMD_GET_IPANDPORT_NTL | fetch a player's addresses | pid, ntl_nat_type, host_addr, reflexive_addr, relayed_addr |
| CMD_GET_LOBBYLIST / CMD_JOIN_LOBBY | lobby | lobby_list[ blob{lobby_id, name, player_num, player_num_max, gamerelay_index} ] / gamerelay_web_url |
| CMD_START_MATCHING / CMD_CANCEL_MATCHING | queue | result |
| CMD_WATCH_MATCHING | "match found" — implemented as a long-poll | matching_state (FINISH, required), player_num, member[ blob{pid, pname, is_owner, side HOME/AWAY, matching_order, host_addr, …} ] |
| CMD_CREATEJOIN_ROOM / CMD_JOIN_ROOM_NTL | rooms | room_id / room_owner + peer addresses |
| CMD_GET_MATCHID / CMD_SEND_MATCHING_RESULT | match id / result report | match_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.
Matchmaking — live
- 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
- 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
- Both clients show "Matchmaking — Searching for an opponent" and never poll; the server paired them ("MATCH FOUND room 1"). LIVE
- 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 ismatching_state:FINISH→ match accepted; missing → ignored → 300 s timeout = errorEYRH763(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 - Game relay:
CMD_GET_GAMERELAYis an HTTP web-API call to URL-list typeGET_GAMERELAY(thenGET_TURN_SERVER_LISTas fallback). Missing type → instant local failureER_MATCHING_RETRY. We now serve all 53 URL types from the client's enum (0x14021ee70), short paths to fit one frame. LIVE - Peer signalling: clients send
CMD_SEND_SYNC_NOTICE / SEND_SYSTEM_NOTICE {pid_list, data}("1,", "END:pid=2;time=…"); the server must relay each asCMD_WATCH_SYNC_NOTICE / WATCH_SYSTEM_NOTICE {data}to the listed pids. With relay on, both reportCMD_SET_GAMERELAY_CHECK_RES {game_sycom P2P, error_code NOERR}and exchange UDP directly; server pushesCMD_WATCH_GAMERELAY_CHECK_RES {error_code NOERR}. LIVE - 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
- Sides: every WATCH_MATCHING member blob needs
side=HOME(owner) /AWAY. Without it the HOME team is never filled and team-data sync aborts withMMGC_0002(MultiplayCmdTeamDataSync.cpp line 2). LIVE - Team list:
CMD_GET_GAME_TEAM_LISTmust 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 - Server environment:
CMD_GET_SERVER_ENVtimeouts 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
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 sees | When the game asks | Reply that works |
|---|---|---|
| Online Information list (title + date) at sign-in, and a pop-up with title + body when reaching the main menu | CMD_GET_GLOBALINFOTITLELIST during sign-in and at the main menu; then CMD_GET_INFOBODY {announce_id} for each listed item | list: 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 Match | CMD_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 reason | the next CMD_GET_URGENT_ANNOUNCEMENT after they reconnect | as 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.
Match — live
- After MATCHMENU both clients Kick Off →
CMD_CHANGE_GAMEPHASEGAMELOAD → ENTRY → 1ST. Gameplay itself is peer-to-peer UDP between the clients; the server only sees phase reports and events. LIVE - 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 - 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 asCMD_ADD_SCORE{goal_side, goal_player_pid, assist_*, gtimer}; there is no CMD_ADD_GOAL in the client (details). LIVE - 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, JSONplay_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 - 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 - 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
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.
| Code | File | Meaning in practice |
|---|---|---|
| EYRH763 | OnlineModeTaskQuickMatching.cpp | no valid match push within 300 s (missing matching_state) |
| MMGC_0002 | MultiplayCmdTeamDataSync.cpp | team-data sync failed (member side missing) |
| VNBP_0001 / 0002 | OnlineSystemClient.cpp | connection to the gate lost: an error reply to a heartbeat (line 833) or a closed socket (line 864); the text never changes |
| QVHE1459 | GameModeMatchListenerMatch.cpp | match load timeout (server env timeouts were 0) |
Deployment
- Host (Linux):
run.shdownloads the newest program from the latest GitHub release (PyInstaller, built by.github/workflows/program.yml) and runspes2021-server install: a systemd user service, an apps-menu entry, then the admin page. Updates (checked against the release'sSHA256SUMSand test-run), restart and stop are on the admin page. No configuration:netsetup.pyfinds 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 forPES2021.exe(Steam libraries,~/Games) and their Wine prefixes, for the admin page's one-click install (on Windows, Steam's registry entry). LIVE - 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 - The mod (
server/mod/, built by the image's first stage with MinGW): adinput8.dllproxy (exports forwarded to the system DLL) that hooksws2_32getaddrinfo/GetAddrInfoW/GetAddrInfoExW/gethostbynamefor the 6 Konami names, andwinhttpWinHttpConnect/OpenRequest/SendRequestfor the NTL certificate exception, plusWinHttpCloseHandle(to forget closed handles) anduser32CreateWindowExW/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 viaWINEDLLOVERRIDES="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'suser.reg. LIVE - Server identity: an Ed25519 key in the data volume.
/mod/dinput8.dllwrites 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 - 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.
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.
| Item | Value |
|---|---|
| Link cipher | RC4 ("Arcfour"), KSA 0x14204c700, PRGA 0x14204cad0 |
| Static RC4 key | a977d392 db0e55b9 9d1641c2 d4f82739 |
| Key exchange | SimpleExchangeKeyExchanger: 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 |
Our server
| File | Role |
|---|---|
| server/pesserver/ncl.py | NclCodec: frame, Blowfish easy tier, envelope, zlib, msgpack |
| server/pesserver/pescmds.py | CMD_* 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.py | quick-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.py | TCP gate loop (handler crashes are caught and answered) · dispatch and session bookkeeping |
| server/pesserver/stun.py · httpgate.py | RFC 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.py | server identity (Ed25519 key), signed /mod/note and /mod/hello, writes settings into the mod DLL |
| server/pesserver/netsetup.py | LAN / public IP detection, UPnP port mapping, CGNAT detection, per-client advertised address, startup banner |
| server/pesserver/announce.py | news list and pop-up, box before Quick Match, restart warning, kick notes (state in announce.json) |
| server/pesserver/events.py · gamesetup.py | admin 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.py | the 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_…).
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.
How we found it
- Wine's own debug channels (
WINEDEBUG=+seh,+winsock) showed the client crashing inmemcpycalled from0x1418568bbright after reading our reply. - That function is the NclMio decoder: it builds a message object, decrypts the body, and switches on
kind. - The crypto factory
0x141856610allocates a 0x1050-byte object for the easy tier — the size of a Blowfish context. 0x14116eeb0installs a 56-byte key from.datawhen none is set.- Blowfish-ECB with that key turned the captured "hello" into readable MessagePack.
Key addresses (image base 0x140000000)
| Address | What |
|---|---|
| 0x1418548c0 | NclMio parse (XOR, header, md5) |
| 0x141855cc0 | NclEnhancedMio parse |
| 0x1418566c0 | message decode: crypto, kind switch, envelope fields |
| 0x141856610 | crypto factory (tier 0 Blowfish, tier 1 AES) |
| 0x14116eeb0 | installs the static easy key |
| 0x1434d61c0 | easy key (56 B) |
| 0x141d48430 | CMD_GET_SVRLIST response parser |
| vt 0x142a4d6c0 | fox::ncl::NclDaemon |
| vt 0x142a4f0a0 / 0x142a4f600 | NclMio / NclEnhancedMio |
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:
- Relay fallback: the game uses a standard TURN relay over TLS, but only when
CMD_GET_TURN_SERVER_LISTreturns servers. We return none, so there's no fallback yet. The UDP 7600 "game relay" is a different, Konami-only mode that Quick Match never uses. Details STATIC OPEN - Results: final scores are stored, and live goals now come from
CMD_ADD_SCORE(there is no CMD_ADD_GOAL). The rating verdict (CMD_WATCH_GAMEADDPOINT) is never pushed, so one player waits 20 s after each match. Details OPEN - Rooms for playing a chosen friend, co-op, divisions, myClub and events: protocol mapped, not implemented. OPEN
Changelog
- 2026-11-05 — Fixes from the function map, each confirmed in recorded traffic first: a host's game on the server's network reported its LAN IP as its public address, so remote friends now get the host's public (or Tailscale) address instead; peer notices go once per player (the game's list grows by one copy per match); live goals come from
CMD_ADD_SCORE, counted once; a message too long for one frame is refused on the admin page. - 2026-11-05 — The server can talk to players: news in the Online Information list and as a main-menu pop-up, a one-time box before Quick Match, the game's own "maintenance starting at HH:MM" warning before matchmaking, and a note with the reason after a Kick. All tested in the game and offered on the admin page. Details
- 2026-11-05 — Every online function mapped on a new page, How the online functions work: the TURN relay fallback, the match-session state machine, peer notices, the rating verdict push, divisions, Free-lobby rooms, co-op, spectating, CHECK_CHEAT checksums, in-game announcements, the fragment and AES frame formats, 186 error sites and a catalogue of all 433 commands. The schema extractor now finds 1,907 more reply fields.
- 2026-11-05 — Cloud servers install with a fixed public address (
PUBLIC_IPfrom ipify) and remote admin; re-running the installer keeps those settings. Mod: an address typed in the Shift box is fetched over plain HTTP, IP or name. Friends' page sends a hash-pinned CSP. - 2026-11-05 — The mod replaces the join commands: one
dinput8.dllper server, no admin rights or system changes; signed server identity; admin page download and one-click install on the host's own PC; the Cloudflare tunnel was removed again (the mod never needed a web link); admin page limited to the home network. - 2026-11-05 — Free public web address per server (Cloudflare quick tunnel,
https://<words>.trycloudflare.com) for the join page and scripts; join scripts receive the server's LAN + internet IPs and pick the one answering/statuswith the server id (sha1 of its CA). Password-protected admin page (/admin): online players, live matches by phase, saved results (pes_matches), league table, activity feed, kick. Note: the game logs in twice (ACCOUNT then MENU connection); a pairing can be abandoned with LEAVE_ROOM before kick-off and re-paired in a new room. - 2026-11-05 — No more Docker: the server ships as one program per platform (Windows, Linux, macOS), started with
run.ps1/run.sh, which always fetch the newest build. The mod sends the network test to 8080, which now speaks HTTP and HTTPS, so no root or port 443 is needed. Every build carries a version, and the admin page says when a newer one is out. Tested live: network test and login through the downloaded program without port 443. - 2026-11-04 — One-command hosting:
install.sh(installs Docker, runs the published image), zero-config networking (UPnP, public-IP/CGNAT detection, per-client advertised address), player setup served at/joinand/leave, CA written directly into the Proton prefix registry. Tested: installer → served join script on both prefixes → fresh users → network test → matched → kick-off. - 2026-11-04 — Docker end-to-end: fresh container, CA from
/ca.crtinstalled with the player script, new players created, matched and playing through the container. Found the bridge/NAT-test limitation; added the host-network compose file. - 2026-11-04 — Complete 90-minute online match between two clients, through full time, highlights, result reporting and back to the menu, then straight into a second pairing. Kick-off, both halves, half-time, fouls and cards, clocks in sync. Fixes: member
side, real team list, real SERVER_ENV timeouts, bare-NOERR defaults. Error-code decoder. Old RC4 code removed; Docker deployment. - 2026-11-04 — Two clients matched, P2P-connected and in the online Match Menu; Kick Off syncs both into GAMELOAD. Keys: matching_state FINISH push, all 53 URL types (GET_GAMERELAY), relay of SYNC/SYSTEM notices.
- 2026-11-04 — NAT test passes: GateInfo.php over TLS with a local CA trusted only in the game prefixes.
- 2026-11-04 — FLOW / CMD_GET_DLC_FILELIST unblocks online modes; two clients (two players) logged in concurrently; NAT-test probes captured; lobby/matchmaking handlers with long-poll WATCH_MATCHING (unit-tested).
- 2026-11-04 — Full login to the MENU server; player creation persisted in our DB; session key derivation (base64) learned live.
- 2026-11-04 — Bulk schema extraction for 398 commands; schema-typed defaults; replay + timeline tooling; virtual-desktop dev launcher.
- 2026-11-04 — Encoding rules: nested msgpack blobs for lists/objects, strict string types, server types GATE/ACCOUNT/MENU.
- 2026-11-04 — Gate protocol decoded. Blowfish-ECB with the static 56-byte key turns the client "hello" into MessagePack
CMD_GET_SVRLIST. New NclCodec + command handlers in the server. - 2026-11-04 — Wine
+sehtracing located the client's NclMio decoder at the crash site. - 2026-11-04 — Earlier RC4 "key exchange" interpretation of the gate withdrawn; dlcp material moved to its own section.
- 2026-11-04 — Live redirect working; real client requests reach our server.
- 2026-11-04 — Prior art mined (Nikow5's PES 2021 server work, MGSV/MGO3 server emulators).