36 Commits
Author SHA1 Message Date
neckfire c2a63c6fe1 feat(netcode): chiffrement AES-256-ECB conforme FAESHandlerComponent d'UE
Build Game Server / build (push) Successful in 23s
Recycle a confirmé : 'basic aes', clé server-side déductible client, by-the-book reimpl,
pas de handshake DTLS. Remplace le GCM par AES-256-ECB + PKCS#7 + flag bit + termination
bit (mirror du source UE 4.27). Flux : SetKey -> EncryptionAck clair -> Enable -> Challenge
chiffré. Supprime AesGcmHandlerComponent.
2026-07-17 00:49:38 +02:00
neckfire 6b3a4057be docs(netcode): VERDICT — le client n'entame jamais son handshake DTLS (résolution de clé côté client), mur dur indépendant du serveur
Build Game Server / build (push) Successful in 17s
2026-07-17 00:17:07 +02:00
neckfire c6511dd046 feat(netcode): flux DTLS conforme UE — EncryptionAck seul + accept, Challenge différé
Build Game Server / build (push) Successful in 23s
Recherche du flux d'encryption UE 4.27 : le client envoie le ClientHello (serveur en
accept), NMT_EncryptionAck sans payload en clair déclenche la résolution de clé côté
client puis son EnableEncryption. On active DTLS en accept et on NE PAS envoie le
Challenge (différé jusqu'à Handshaking completed). Findings détaillés dans NETCODE-RND.
2026-07-17 00:13:53 +02:00
neckfire cc392238f7 docs(netcode): findings AES-GCM — le client ferme systématiquement (plaintext bClose) après notre réponse au Hello ; pistes restantes
Build Game Server / build (push) Successful in 15s
2026-07-16 23:58:13 +02:00
neckfire 9cb15ad9e2 fix(netcode): Incoming en sens inverse d'Outgoing (couche chiffrement externe retirée en premier)
Build Game Server / build (push) Successful in 22s
Avec >1 composant actif (stateless + AES), Incoming et Outgoing doivent être en
sens opposés. Les deux étaient en avant -> le stateless tentait de parser des octets
chiffrés. Incoming itère désormais en inverse.
2026-07-16 23:53:52 +02:00
neckfire 5c81fba79c feat(netcode): chiffrement AES-256-GCM keyé par la PSK (piste retenue)
Build Game Server / build (push) Successful in 21s
Le client n'entame pas de handshake DTLS et ferme si nos paquets post-ack sont en
clair. AesGcmHandlerComponent (System.Security.Cryptography.AesGcm, [IV12][ct][tag16]),
activé après l'EncryptionAck : ack en clair -> activation -> Challenge chiffré.
2026-07-16 23:47:00 +02:00
neckfire a3dc1e8543 docs(netcode): findings test live — client ferme après EncryptionAck (attend voie serveur chiffrée) + crash ConditionalCleanUp
Build Game Server / build (push) Successful in 15s
2026-07-16 23:38:10 +02:00
neckfire 7e34c61b2a test(dtls): EncryptionAck sans activer DTLS — le client continue le login EN CLAIR après l'ack (observé live)
Build Game Server / build (push) Successful in 21s
2026-07-16 23:33:33 +02:00
neckfire e397541418 debug(dtls): log en tête d'Incoming (état de la garde active/transport/failed)
Build Game Server / build (push) Successful in 22s
2026-07-16 23:28:37 +02:00
neckfire 3e47b0680c debug(dtls): logs Incoming/Outgoing (voir ce que reçoit le serveur après EncryptionAck)
Build Game Server / build (push) Successful in 21s
2026-07-16 23:24:34 +02:00
neckfire a21ac4d0e4 docs(netcode): mur DTLS franchi côté serveur + boucle d'itération live
Build Game Server / build (push) Successful in 14s
2026-07-16 23:07:43 +02:00
neckfire 36122578a3 feat(netcode): serveur DTLS-PSK (côté serveur) — franchit le mur du chiffrement
Build Game Server / build (push) Successful in 20s
Implémente le pendant serveur du DTLSHandlerComponent d'UE (la pile client est
[DTLS, Stateless]) : le client exige un DTLS en mode PSK dont l'identité = user_id.

- DtlsPskStore : table user_id -> PSK 32o, chargée depuis l'env (PROSPECT_DTLS_PSKS,
  rendu Vault) — aucune clé en dur ni commitée.
- DtlsPacketTransport : pont entre l'API bloquante DTLS de BouncyCastle et le modèle
  paquet-par-paquet du PacketHandler (handshake sur thread dédié).
- ProspectPskTlsServer : serveur DTLS-PSK BouncyCastle (DTLS 1.2, identité=user_id).
- DTLSHandlerComponent : composant du pipeline (Incoming déchiffre / Outgoing chiffre),
  activé au NMT_Hello portant un EncryptionToken.
- UWorld.NotifyControlMessage : sur NMT_Hello chiffré, si la PSK de l'identité est
  connue -> NMT_EncryptionAck + BeginHandshake ; sinon warning (comme avant).

Compile (0 erreur, BouncyCastle 2.4). ⚠️ Le framing exact DTLS-sur-PacketHandler et
le routage des records de handshake demandent une ITÉRATION EN LIVE contre le vrai
client (invalidable hors client).
2026-07-16 23:06:14 +02:00
neckfireandClaude Opus 4.8 f9c44a89b3 game-server: catch-all request capture for squad-invite RE
Build Game Server / build (push) Successful in 25s
Log every unrouted POST at Warning [CAPTURE UNROUTED] (reveals native PlayFab
endpoints the emulator doesn't implement, e.g. /Group/CreateGroup, /Lobby/*) and
every social/squad/invite/friend/matchmaking call (path or body) at Information
[CAPTURE SOCIAL]. Lets us pin the exact invite mechanism when reproduced in game.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 10:35:37 +02:00
neckfire 4ec54f894b docs(game-server): study PlayFab Groups/Party emulation for squad invites
Build Game Server / build (push) Successful in 18s
2026-07-16 08:28:58 +00:00
neckfire f7048ff173 Merge pull request 'chore(game-server): merge main (emulator fixes: contracts, shop, fortuna, friends, admin, presence)' (#16) from main into game-server
Build Game Server / build (push) Successful in 26s
2026-07-16 08:25:21 +00:00
neckfire 91bdde3797 Merge pull request 'friends: richer GetFriendList payload + logging' (#15) from preprod into main
Build & Deploy / build (push) Successful in 38s
2026-07-16 07:06:20 +00:00
neckfireandClaude Opus 4.8 d7821ebf1f friends: richer GetFriendList payload (field aliases) + log returned JSON
Build & Deploy / build (push) Successful in 53s
The friend tile rendered empty/offline because the UE client couldn't read our
best-guess response shape. Expose each friend's identity/name/steam/presence under
every plausible field name (camelCase + PlayFab PascalCase, flat + nested profile)
under both friends/Friends keys, and log the JSON so we can pin the exact shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:06:17 +02:00
neckfire e5ab911dfd Merge pull request 'fortuna: FortunaPass2_Rewards catalog (claimable tiers)' (#14) from preprod into main
Build & Deploy / build (push) Successful in 34s
2026-07-15 20:17:37 +00:00
neckfireandClaude Opus 4.8 81092b077e fortuna: add FortunaPass2_Rewards catalog so tiers can be claimed
Build & Deploy / build (push) Successful in 50s
The claim function only grants a tier when a FortunaPass2_Rewards TitleData catalog
maps its rewardId (Level_N, sent by the client) to an item/amount. That catalog was
absent (client-side DataTable) so claims granted nothing and stayed unclaimable.
Add a Level_1..Level_100 catalog (K-Marks scaling, Insurance every 5, Aurum every 10)
so players can actually collect Fortuna pass rewards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:17:34 +02:00
neckfire 042f806697 Merge pull request 'docs: rewrite main README (dev + CI + client switch tool)' (#13) from preprod into main
Build & Deploy / build (push) Successful in 32s
2026-07-15 11:54:39 +00:00
neckfireandClaude Opus 4.8 95541b6221 docs: rewrite README around our build/CI + client switch tool
Build & Deploy / build (push) Successful in 32s
Replace the upstream Windows/local-run README with a dev-focused one: how the
client redirection works (loader+agent+backend.txt), repo structure, building
and running the API in a container, the TLS cert SAN gotcha, the Gitea CI/CD
branch model (preprod:preprod / main:latest), the Prospect.Client.Config
switch+cert tool, and an up-to-date feature status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:54:36 +02:00
neckfire a4c8cf5955 Merge pull request 'admin: fix empty tables (DT engine ordering)' (#12) from preprod into main
Build & Deploy / build (push) Successful in 33s
2026-07-15 09:52:00 +00:00
neckfireandClaude Opus 4.8 a8fca38038 admin: fix empty tables — define DT engine before page init scripts
Build & Deploy / build (push) Successful in 43s
The DT table engine lived in the page footer, after the per-page
<script>DT.init(...)</script> in the body, so DT was undefined when init ran
(ReferenceError, nothing rendered). Move the engine into a DtScript const
injected right after <main>, before any body content, so it is always defined
first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:51:58 +02:00
neckfireandClaude Opus 4.8 f0343bc1ea gameserver: consolidate netcode R&D — remove diag spam, document DTLS-PSK wall
Build Game Server / build (push) Successful in 21s
- Keep the R3.5.0 header-bit fix (bunch alignment breakthrough)
- Remove per-packet [HS-RAW]/[HS-HDR]/[HS-BUNCH] diagnostics
- Document the DTLS-PSK encryption wall + packed-binary finding in NETCODE-RND.md
- UWorld NMT_Hello: honest comment on why the connection stops here

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:13:03 +02:00
neckfireandClaude Opus 4.8 ba9976c5b6 gameserver: skip encryption on Hello (try plaintext challenge)
Build Game Server / build (push) Successful in 19s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:54:01 +02:00
neckfireandClaude Opus 4.8 fb73038901 gameserver: consume Cycle's extra header bit -> bunches align (NMT_Hello parses)
Build Game Server / build (push) Successful in 22s
Bit-decoded real client packets: the header is 65 bits (not stock UE 4.27's 64).
One extra bit sits between the ack history and the packet-info payload; consuming
it realigns bHasPacketInfoPayload + jitter + bHasServerFrameTime, and the first
control-channel bunch parses (ChIndex 0, open, reliable = NMT_Hello).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:50:19 +02:00
neckfireandClaude Opus 4.8 629e8a4c31 gameserver: dump full packet bits (512) to decode NMT_Hello netver
Build Game Server / build (push) Successful in 18s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:29:11 +02:00
neckfireandClaude Opus 4.8 1691af4be6 gameserver: dump raw packet bit-stream for hand-decode
Build Game Server / build (push) Successful in 18s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:53:53 +02:00
neckfireandClaude Opus 4.8 b1386bef56 gameserver: bit-position diagnostics in ReceivedPacket (header + bunch flags)
Build Game Server / build (push) Successful in 17s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:41:39 +02:00
neckfireandClaude Opus 4.8 71aca5e3d0 fix(game-server): survive bad-packet exceptions in tick loop
Build Game Server / build (push) Successful in 17s
Wrap world.Tick in try/catch so an incompatible/misaligned client packet
logs an error and the server keeps running (stable R&D bench).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:30:12 +02:00
neckfireandClaude Opus 4.8 4d2280f8ab fix(game-server): don't crash on malformed bunch channel index
Build Game Server / build (push) Successful in 16s
Log + drop the packet (with net version) instead of throwing, so a
bit-alignment/version mismatch with the client doesn't kill the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:26:53 +02:00
neckfireandClaude Opus 4.8 1caa46f846 fix(game-server): adopt client packet sequences on first packet
Build Game Server / build (push) Successful in 16s
The Cycle client does not derive its initial packet sequences from the
handshake cookie the way stock UE does (its acked server-seq isn't present
anywhere in the cookie), so the cookie-based InitSequence disagreed and
every post-handshake packet was rejected as out-of-order. On the first
packet, adopt the client's announced Seq/AckedSeq instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:21:49 +02:00
neckfireandClaude Opus 4.8 9092dda950 diag(game-server): log packet sequence mismatch (post-handshake)
Build Game Server / build (push) Successful in 21s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:08:52 +02:00
neckfireandClaude Opus 4.8 6ccad507a8 diag(game-server): log connectionless handshake (challenge/response)
Build Game Server / build (push) Successful in 16s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:00:19 +02:00
neckfireandClaude Opus 4.8 f4d1757840 diag(game-server): log stateless handshake packet size vs expected
Build Game Server / build (push) Successful in 15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:52:52 +02:00
neckfireandClaude Opus 4.8 9749828af8 feat(game-server): scaffold dedicated server toward player join
Build Game Server / build (push) Successful in 17s
R&D branch for an authoritative Unreal game server (Prospect.Unreal).
The connection handshake already reaches PlayerController spawn; this
commit pushes it toward real players joining:

- Program.cs: target the Cycle station map + game mode (configurable via
  PROSPECT_MAP/GAMEMODE/PORT), proper host loop, and initialise a
  GameSession so NMT_Join -> Login no longer fails ("GameSession is null").
- UWorld.WelcomePlayer: send the world's real map/game mode to the client
  instead of the UE ThirdPerson template.
- Dockerfile.gameserver + .gitea/workflows/game-server.yml: build/push a
  separate image (the-cycle-game) on the game-server branch, ntfy notify.
- GAMESERVER.md: honest state + roadmap (player spawn + movement
  replication need live client testing; AI/loot intentionally deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:30:09 +02:00
22 changed files with 1476 additions and 241 deletions
+28
View File
@@ -0,0 +1,28 @@
name: Build Game Server
on:
push:
branches: [game-server]
workflow_dispatch: {}
env:
IMAGE: git.nfteam.ovh/neckfire/the-cycle-game
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build & push image
run: |
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
SHA="${GITHUB_SHA::12}"
docker build -t "${IMAGE}:game-server" -t "${IMAGE}:${SHA}" -f Dockerfile.gameserver .
docker push --all-tags "${IMAGE}"
- name: Notify ntfy
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi
curl -s -H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" -H "Title: ${GITHUB_REPOSITORY} game-server — ${{ job.status }}" \
-H "Priority: ${PRIO}" -H "Tags: ${EMOJI}" -H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \
-d "${GITHUB_WORKFLOW} (${GITHUB_REF_NAME} #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
+14
View File
@@ -0,0 +1,14 @@
# The Cycle: Frontier — serveur de jeu dédié (EXPÉRIMENTAL / R&D).
# Réimplémentation du serveur autoritaire Unreal (Prospect.Unreal). Build depuis les sources.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY src/ ./src/
RUN dotnet publish src/Prospect.Server.Game/Prospect.Server.Game.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/runtime:8.0
WORKDIR /app
COPY --from=build /app ./
# Le réseau Unreal est en UDP.
EXPOSE 7777/udp
# Configurable : PROSPECT_MAP / PROSPECT_GAMEMODE / PROSPECT_PORT
ENTRYPOINT ["dotnet", "Prospect.Server.Game.dll"]
+62
View File
@@ -0,0 +1,62 @@
# Serveur de jeu dédié — R&D (branche `game-server`)
> ⚠️ **Expérimental.** Objectif : un serveur de jeu **autoritaire** pour que plusieurs
> joueurs soient dans la **même instance** (se voir, bouger). C'est un chantier de
> reverse-engineering du serveur Unreal du Cycle. **Un raid co-op complet reste hors de portée
> réaliste** ; on avance par jalons. **IA et loot volontairement hors périmètre pour l'instant.**
## Pièces en jeu
- **`Prospect.Unreal`** — réimplémentation en C# de la couche réseau d'Unreal Engine
(NetDriver UDP, channels control/actor, bunches, packet handler, handshake, `UWorld`,
`AGameModeBase`/`APlayerController`/`APawn`).
- **`Prospect.Server.Game`** — l'exécutable serveur (host loop, monde, game mode).
## État actuel (ce qui marche côté serveur)
Le **handshake de connexion Unreal est implémenté** et va jusqu'au spawn du PlayerController :
```
NMT_Hello → SendChallenge
NMT_Login → PreLogin → WelcomePlayer (envoie map + game mode)
NMT_Join → SpawnPlayActor → GameMode.Login → APlayerController
```
Corrections/avancées de cette branche :
- **Cible la map/gamemode du Cycle** (`/Game/Maps/MP/Station/Station_P` + `YGameMode_Station`)
au lieu de la map template d'UE. Configurable via `PROSPECT_MAP` / `PROSPECT_GAMEMODE` / `PROSPECT_PORT`.
- **`WelcomePlayer`** envoie désormais la **vraie** map/gamemode du monde (plus le template).
- **`GameSession`** est initialisée → le login ne plante plus sur `"GameSession is null"`
(c'était le point de blocage juste avant le spawn).
## Ce qui manque (roadmap, du plus atteignable au plus dur)
1. **Connexion client réelle** : valider le handshake complet avec le **vrai client** (pas le
harnais `Client.cs`). Nécessite des **tests en live** (impossible à valider hors client).
2. **Spawn du Pawn du Cycle** : `GameMode.Login` spawn un `APlayerController` mais **pas** le
personnage. Il faut spawner la **classe de Pawn spécifique du Cycle** (`YCharacter…`) avec le
bon **NetGUID / class path** pour que le client l'instancie.
3. **Réplication du mouvement** : répliquer les propriétés du `CharacterMovementComponent`
(position/rotation/état) chaque tick → **le premier vrai « se voir bouger »**.
4. *(plus tard)* IA, loot, dégâts, tempête, évac… — **hors périmètre pour l'instant**.
Les jalons 23 demandent de connaître les **classes répliquées du jeu** (côté client, non
présentes dans le code serveur) et **itèrent en live** avec le client. C'est le vrai mur.
## Build / run
```bash
# build
dotnet build src/Prospect.Server.Game/Prospect.Server.Game.csproj -c Release
# run (défauts : station, port 7777 UDP)
dotnet run --project src/Prospect.Server.Game
# ou conteneur
docker build -t the-cycle-game -f Dockerfile.gameserver .
docker run --rm -p 7777:7777/udp the-cycle-game
```
## CI/CD
`.gitea/workflows/game-server.yml` : à chaque push sur `game-server`, build de `Dockerfile.gameserver`
→ image **`git.nfteam.ovh/neckfire/the-cycle-game`** (tags `game-server` + sha) + notif ntfy.
Image **séparée** de l'API (`the-cycle`) — les deux ne se marchent pas dessus.
## Honnêteté
Ceci est une **base d'exploration**. Le handshake + le spawn du controller avancent ; le
« 2 joueurs se voient bouger » dépend du spawn du pawn du Cycle + réplication, qui exige du RE
spécifique au jeu **et** des tests dans le client réel. Aucune garantie d'aboutir.
+219
View File
@@ -0,0 +1,219 @@
# The Cycle: Frontier — serveur dédié gameplay (R&D netcode)
Branche `game-server`. Objectif : un serveur de jeu autoritatif écrit from scratch
(Prospect.Unreal, réimplémentation C# du netcode Unreal) pour du vrai co-op, sans
passer par un client-hôte P2P. **Statut : bloqué au chiffrement (voir plus bas).**
Client cible : **UE4 build `R3.5.0`** (`4.27.2` netcode), Steam depot 868271.
## Ce qui fonctionne (murs franchis)
La connexion d'un vrai client va jusqu'à l'entrée du login :
```
Handshake stateless UDP (cookie/challenge) ✅
Reconstruction du paquet (PacketHandler) ✅
Séquençage des paquets (adopt des seq client) ✅
Alignement des bunches ✅ ← percée
Canal de contrôle ouvert ✅
NMT_Hello reçu et parsé ✅
Transition login Hello → Login ✅
Chiffrement DTLS-PSK ❌ ← mur final
```
### Percée : le bit de header spécifique R3.5.0
Le client écrit **un bit de plus** entre l'historique d'ack du `FNetPacketNotify` et
le payload packet-info, que l'UE 4.27 stock (EngineNetVer 16) n'a pas. Décodage
bit-à-bit d'un vrai paquet : l'en-tête fait **65 bits, pas 64**. En consommant ce bit
(`bCycleExtraHeaderBit` dans `UNetConnection.ReceivedPacket`), tout se réaligne :
`bHasPacketInfoPayload`, l'horloge jitter (10 bits) et `bHasServerFrameTime` tombent
juste, et le premier bunch du canal de contrôle parse proprement (ChIndex 0, bOpen,
bReliable = NMT_Hello). Sans ce fix, le `ChIndex` sortait en vrac (~1 049 000) et le
serveur droppait/plantait.
## Le mur final : chiffrement DTLS-PSK
Le client **exige** le chiffrement. Établi par reverse-engineering :
- `NMT_Hello` porte `EncryptionToken` = le **PlayFab user_id** du joueur
(ex. `92EBCFE8C3EAF3AC`). Vu dans l'URL de connexion du client :
`...?EntityToken=<JWT>?EncryptionToken=92EBCFE8C3EAF3AC`.
- Pile PacketHandler du client (ses propres logs) :
`[DTLSHandlerComponent, StatelessConnectHandlerComponent]`.
- Le exe embarque les suites **`ECDHE-PSK-AES256-*`, `DHE-PSK-AES256-GCM-SHA384`**,
la cvar **`DTLS.PreSharedKeys`**, et `DTLSPSKClientCallback` / `DTLSPSKServerCallback`
**DTLS en mode PSK** (clé pré-partagée 32 octets, identité = user_id).
- Compression : **OodleNetwork** compilé, mais **aucun dictionnaire `.udic`**
pass-through (les paquets ne sont ni compressés ni chiffrés au niveau paquet ;
entropie faible + longues suites de zéros le confirment).
Proposer un challenge en clair (sans `NMT_EncryptionAck`) ne marche pas : le client
ferme le canal de contrôle juste après.
Pour finir il faudrait : (1) un **serveur DTLS-PSK** collé au framing du
`DTLSHandlerComponent` d'UE, et (2) la **PSK de 32 octets** dérivée par le client à
partir du user_id/EntityToken.
## Pourquoi la clé est inaccessible (statique)
L'exe `Prospect-Win64-Shipping.exe` est **packé/chiffré** (protection anti-triche,
BattlEye) :
- **Entropie de `.text` = 8.000** (maximum = aléatoire/chiffré ; du code normal ≈ 6.3).
- `.rdata` = 4.96 (normal → les strings restent lisibles, d'où les découvertes ci-dessus).
- Un scan brut du `.text` (76 Mo) ne trouve que **~76 instructions** → niveau du bruit :
ce n'est pas du code sur disque, c'est du chiffré déchiffré au runtime.
Conséquence : Ghidra / radare2 n'analysent que du ciphertext ; **aucune référence** aux
fonctions de chiffrement n'est trouvable statiquement. La dérivation de la PSK vit dans
ce code chiffré.
**Seule voie restante (non tentée)** : dump mémoire au runtime. Le jeu tourne sous
Proton/Linux (BattlEye n'a pas de driver kernel sous Linux) → un autre process Linux
peut lire `/proc/<pid>/mem` et récupérer le `.text` **déchiffré**, puis l'analyser dans
Ghidra pour retrouver la dérivation. Zone grise ToS, plusieurs étapes.
## Fichiers clés
- `src/Prospect.Server.Game/Program.cs` — hôte du serveur de jeu (map Station, GameSession).
- `src/Prospect.Unreal/Net/UNetConnection.cs``ReceivedPacket` : fix du bit de header
(`bCycleExtraHeaderBit`), adopt des séquences client, drop gracieux des bunches.
- `src/Prospect.Unreal/Runtime/UWorld.cs``NotifyControlMessage` : Hello/Login ;
le `else` du bloc `NMT.Hello` documente le mur DTLS-PSK.
## Verdict
On a amené un serveur dédié gameplay The Cycle plus loin qu'aucun projet public connu
(le projet communautaire deiteris/Prospect n'émule que les services en ligne, pas le
netcode de jeu). Le mur restant — DTLS-PSK dont la clé est derrière un packer
anti-triche — est un chantier crypto + RE dynamique d'un autre ordre de grandeur.
## MAJ (2026-07-16) — mur DTLS franchi (côté serveur)
- **PSK récupérée** : la clé 32 octets a été obtenue au runtime (dump du `.text`
déchiffré du client via `/proc/<pid>/mem` sous Proton — BattlEye n'a pas de driver
kernel sous Linux). Stockée dans **Vault** (`secret/the-cycle`, clé
`GAMESERVER_DTLS_PSKS`, format `user_id:hex32`), **jamais dans le repo**.
- **Serveur DTLS-PSK implémenté** (BouncyCastle) :
- `DtlsPskStore` (env `PROSPECT_DTLS_PSKS`), `DtlsPacketTransport` (pont bloquant→paquet),
`ProspectPskTlsServer` (DTLS 1.2, identité=user_id), `DTLSHandlerComponent` (pipeline).
- `UWorld` sur `NMT_Hello` chiffré : PSK connue → `NMT_EncryptionAck` + handshake.
- Compile (0 erreur). **Non validé en live.**
- **Reste (itération live obligatoire, invalidable hors client)** :
1. ordre exact du pipeline `[DTLS, Stateless]` et routage des records de handshake
(émis via `LowLevelSend` — doivent porter le framing stateless attendu) ;
2. framing DTLS-sur-PacketHandler d'UE (record layer, cookie DTLS éventuel) ;
3. bascule handshake→données applicatives.
Méthode : lancer `Prospect.Server.Game` avec la PSK, diriger le client dessus, itérer
sur les logs serveur.
## MAJ (2026-07-16, soir) — test LIVE contre le vrai client (preprod multi)
Setup : preprod multi (`the-cycle-api-rd2`, `GAMESERVER_ADDRESS=192.168.1.136:7777`) →
le client voyage vers `the-cycle-game` (branche game-server, PSK via `PROSPECT_DTLS_PSKS`).
user_id preprod = prod = `92EBCFE8C3EAF3AC` (même DB ? non, `ProspectDb_rd`, mais même Id).
**Observé (séquence réelle) :**
1. Handshake stateless : ✅ complet (challenge/cookie/ack, connexion acceptée).
2. `NMT_Hello` reçu **EN CLAIR** avec EncryptionToken=`92EBCFE8C3EAF3AC` → PSK reconnue.
3. On envoie `NMT_EncryptionAck` + `NMT_Challenge`. Le client **NE ferme plus tout de suite**
(progrès vs l'état documenté « ferme après challenge sans ack »).
4. **Le client n'envoie JAMAIS de ClientHello DTLS** (`16 fe fd`). Aucun record DTLS.
5. Le paquet suivant du client (~18 o, ex. `80E82AC4…5F00000C`) parse **en clair** comme un
**bunch `bClose=true` sur le canal de contrôle (ChIndex 0)****le client FERME**.
**Interprétation :** après `NMT_EncryptionAck`, le client attend que les paquets **serveur**
suivants soient **chiffrés** ; on lui renvoie le `Challenge` en clair → il abandonne. Donc
l'`EncryptionAck` seul ne suffit pas : il faut réellement **chiffrer la voie serveur** après
l'ack (le mur crypto de fond, inchangé). Activer notre composant DTLS ne sert à rien tant que
le client ne fait pas de handshake DTLS de son côté — piste à creuser : est-ce de l'**AES-GCM
keyé par la PSK** (SetEncryptionData/EnableEncryption d'UE) plutôt que du DTLS-handshake ?
**Bug serveur concret trouvé :** `UChannel.ConditionalCleanUp` (Prospect.Unreal, ~l.822) =
`throw new NotImplementedException()`**crash du tick** dès qu'un canal se ferme (bClose).
À implémenter (indépendant du crypto).
**Acquis réutilisables :** on passe le handshake + le Hello + l'EncryptionAck est accepté ;
la PSK par user_id est branchée bout-en-bout (Vault → env → serveur). Le blocage net =
chiffrement de la voie serveur post-ack.
## MAJ (2026-07-16, nuit) — tentative AES-256-GCM keyé par la PSK
Implémenté `AesGcmHandlerComponent` (`System.Security.Cryptography.AesGcm`, format
`[IV 12o][ciphertext][tag 16o]`), activé après l'EncryptionAck (ack en clair → activation →
Challenge chiffré). Corrigé aussi l'ordre du pipeline : **Incoming doit itérer en sens
INVERSE d'Outgoing** (`PacketHandler.Incoming_Internal`) — sinon, avec 2 composants actifs
(stateless + AES), le stateless tente de parser des octets chiffrés. (Les deux étaient en avant ;
n'avait jamais compté car un seul composant actif jusqu'ici.)
**Observé (test live) :** notre Challenge part bien chiffré (`[AES] Outgoing 30o→58o`), mais le
client répond **toujours** par un **paquet plaintext de ~18 o** de structure constante
(`…41 00 XX FF 5F 00 00 0C`) = un **`bClose` du canal de contrôle**, jamais rien qui ressemble
à de l'AES (haute entropie ≥28o). **Le client ne chiffre jamais son côté.**
**Conclusion :** quelle que soit notre réponse au `Hello` (challenge clair, chiffré AES, ou
tentative DTLS), le client **ferme systématiquement le canal de contrôle en clair** juste après.
L'AES-GCM tel qu'implémenté ne le fait pas basculer en chiffré. Pistes restantes (deep RE, non
tranchées) : (a) framing AES-GCM exact d'UE (schéma d'IV/nonce, AAD, position) ≠ notre IV aléatoire
préfixé ; (b) c'est bien du DTLS-handshake et le client attend un ServerHello DTLS ; (c) notre
réponse au Hello est incomplète (ordre/contenu des NMT) indépendamment du chiffrement.
Chaque test = ~4 min (CI + redeploy). Bugs serveur secondaires à corriger : `ConditionalCleanUp`
(stub) + `ArgumentOutOfRangeException` quand on vide un paquet trop court.
**Décision : checkpoint.** Tout est commité/documenté. La suite = RE netcode profonde multi-session ;
et même résolue, ce n'est que le login — la simulation gameplay reste hors de portée réaliste.
## Flux de chiffrement DTLS d'UE 4.27 (recherche, 2026-07-17)
Sources : KB Epic « Enable Encryption via Packet Handler Components », doc API
(EnableEncryptionServer/SetEncryptionData/FEncryptionData), UE-95508 (ack chiffré si
renvoyé), UE-171638 (module DTLS exige OpenSSL), logs DTLS/PSK réels (Satisfactory #453).
Le source du plugin DTLS est gated (points reconstruits signalés).
**Corrections clés :**
- **Le CLIENT envoie le ClientHello ; le serveur ATTEND (accept), ne parle jamais en premier.**
Donc l'AES-GCM autonome = impasse (c'est bien du DTLS, records `16 fe fd` standard OpenSSL).
- **`NMT_EncryptionAck` ne porte AUCUNE clé.** À sa réception, le client appelle
`ReceivedNetworkEncryptionAck`**résout lui-même** sa `FEncryptionData` (Key + Identifier)
→ appelle **`EnableEncryption` côté client** → **c'est CE qui déclenche son ClientHello**.
- **Séquence serveur** : `SetEncryptionData(Key,Identifier)``NMT_EncryptionAck` **en clair, sans
payload** → **puis** `EnableEncryption` (mode accept). Ne jamais chiffrer l'ack.
- **Le `NMT_Challenge` doit être DIFFÉRÉ** : le handler DTLS met les bunches applicatifs en file
jusqu'à `Handshaking completed`. (Notre impl l'envoyait tout de suite → à corriger.)
- **PSK** : PSK = `FEncryptionData.Key`, identité PSK = `FEncryptionData.Identifier` ; ressortie via
`DTLSPSKServerCallback` (hook OpenSSL) quand le ClientHello arrive. Le serveur n'envoie jamais la clé.
- **Couches** : DTLS **sous** le stateless (entrée : retirer stateless puis déchiffrer DTLS ;
Outgoing forward / Incoming reverse).
**Si le client ferme SANS ClientHello (notre cas)** → cause la plus probable = **échec de résolution
de clé côté client** (le vrai client shipping attend peut-être la clé de SON backend/matchmaking,
non émulé — ou dérivation/Identifier différents), pas notre framing DTLS. C'est potentiellement un
mur dur (dépend du backend réel du client).
**Test informé RÉALISÉ (2026-07-17)** : `EncryptionAck` seul (clair, sans payload) + DTLS **accept** +
**PAS de Challenge**. **Résultat : le client n'envoie TOUJOURS pas de ClientHello** (zéro `16 fe fd`
dans les datagrammes bruts) et ferme avec le `bClose` plaintext habituel.
## VERDICT (mur confirmé) — résolution de clé CÔTÉ CLIENT
Le blocage n'est **pas** notre framing serveur (DTLS ou AES) : le client **n'entame jamais son
handshake DTLS**. Séquence définitive observée, quel que soit ce qu'on renvoie :
`stateless OK → NMT_Hello (clair) → notre NMT_EncryptionAck → client ferme (bClose), pas de ClientHello`.
D'après la recherche du flux UE : le ClientHello n'est émis que si le client, dans
`ReceivedNetworkEncryptionAck`, **résout une `FEncryptionData` valide et appelle `EnableEncryption`
côté client**. Ici il ne le fait pas → il ferme. On **ne peut pas forcer** ça depuis le serveur.
Cause la plus probable : le vrai client shipping attend sa clé/identité **de SON backend**
(réponse matchmaking/PlayFab), non émulée — ou une dérivation/`Identifier` qu'on ne fournit pas au
bon endroit. La PSK a été récupérée (dump mémoire) mais ça ne suffit pas : c'est la **décision
client d'activer le chiffrement** qui manque, et sa logique vit dans le binaire packé.
**Pour aller plus loin il faudrait** : RE de la fonction `ReceivedNetworkEncryptionAck`/résolution de
clé du client (dans le `.text` déchiffré, même méthode que pour la PSK) pour savoir **d'où** il attend
sa clé, puis la lui fournir (probablement côté backend rd2). Chantier RE profond, incertain.
**Statut : mur dur, checkpoint.** Acquis solides et réutilisables : handshake + Hello + EncryptionAck ;
serveur DTLS-PSK ET AES-GCM implémentés ; ordre pipeline corrigé (Incoming inverse d'Outgoing) ;
flux UE documenté. Le verrou restant dépend du client, pas du serveur.
+215 -188
View File
@@ -1,222 +1,249 @@
# Prospect <!-- omit in toc --> # The Cycle: Frontier — serveur privé (émulateur Prospect)
Also known as "The Cycle: Frontier". Émulateur des services en ligne de **The Cycle: Frontier** (jeu retiré de Steam en
septembre 2022), permettant de rejouer en solo sur un serveur auto-hébergé.
## Table of Contents <!-- omit in toc --> Fork de [`deiteris/Prospect`](https://github.com/deiteris/Prospect), figé sur le
**Build 8 / client Saison 2**, buildé depuis les sources et déployé en conteneur via
- [Features](#features) une CI Gitea.
- [Running locally](#running-locally)
- [1. Prerequisites](#1-prerequisites) > **Ce n'est pas du multijoueur.** Le raid tourne **côté client** (station solo) : chacun
- [1.1 How to download Season 2 client from SteamDB using Steam console](#11-how-to-download-season-2-client-from-steamdb-using-steam-console) > joue sa propre instance. Le serveur partage la progression, les comptes et les boutiques,
- [2. Unpack `Prospect.Server.Api`](#2-unpack-prospectserverapi) > pas la partie. Le vrai multi (squad en raid, voix de proximité) suppose un serveur de jeu
- [3. Generate and import SSL certificate](#3-generate-and-import-ssl-certificate) > Unreal dédié — voir [Hors-périmètre & R&D](#hors-périmètre--rd).
- [4. Extract `LoaderPack` to the game](#4-extract-loaderpack-to-the-game)
- [5. Run the server](#5-run-the-server) ---
- [6. Run the game](#6-run-the-game)
- [Troubleshooting and FAQ](#troubleshooting-and-faq) ## Sommaire
- [How to remove the certificate?](#how-to-remove-the-certificate)
- [`generate_ssl.exe` is flagged as a virus](#generate_sslexe-is-flagged-as-a-virus) - [Comment ça marche](#comment-ça-marche)
- [Body parts are missing with Season 3 client](#body-parts-are-missing-with-season-3-client) - [Structure du dépôt](#structure-du-dépôt)
- [Prospect.Server.Api does not start](#prospectserverapi-does-not-start) - [Build & lancement du serveur](#build--lancement-du-serveur)
- [Login Failed. Error code: 3](#login-failed-error-code-3) - [Certificat TLS](#certificat-tls)
- [Login Failed. Error code: 5](#login-failed-error-code-5) - [CI/CD](#cicd)
- [Development](#development) - [Config du client (switch de serveur + certificat)](#config-du-client-switch-de-serveur--certificat)
- [État des fonctionnalités](#état-des-fonctionnalités)
- [Hors-périmètre & R&D](#hors-périmètre--rd)
- [Crédits & licence](#crédits--licence)
---
## Comment ça marche
Le client officiel parle à PlayFab. On l'intercepte côté client et on le redirige vers
notre serveur, qui réimplémente juste ce qu'il faut de PlayFab (auth Steam, CloudScript,
UserData/TitleData, matchmaking solo).
```mermaid
flowchart LR
subgraph client [Poste de jeu]
L[Prospect.Client.Loader<br/>injecte l'agent] --> A[Prospect.Agent<br/>hooke l'URL PlayFab]
A -- lit --> B[backend.txt]
A --> G[Jeu &#40;TCF&#41;]
end
G -- HTTPS / SignalR --> API[Prospect.Server.Api<br/>émulateur PlayFab]
API --> M[(MongoDB)]
```
- **`Prospect.Client.Loader`** lance le jeu en injectant l'agent.
- **`Prospect.Agent`** hooke l'URL de l'API PlayFab et la remplace par le contenu de
**`backend.txt`** (placé dans `Prospect/Binaries/Win64`). Absent → fallback
`https://127.0.0.1:8443`.
- **`Prospect.Server.Api`** émule PlayFab (auth Steam → JWT, CloudScript, données joueur)
et pousse le temps-réel via **SignalR**. Les données vivent dans **MongoDB**.
Toute la redirection du client tient donc dans **une seule valeur** (`backend.txt`) — gérée
par l'outil [`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
---
## Structure du dépôt
| Projet | Rôle |
|---|---|
| **`Prospect.Server.Api`** | Cœur : émulateur PlayFab (ASP.NET 8). Controllers Client/CloudScript/Multiplayer, services Auth/UserData/TitleData/Database(Mongo)/Qos, hub SignalR. |
| **`Prospect.Steam`** | Validation du ticket Steam (auth). |
| **`Prospect.Client.Loader`** | Loader C++ : lance le jeu et injecte l'agent. |
| **`Prospect.Agent`** | Agent C++ injecté : hooke l'URL PlayFab → `backend.txt`. |
| **`Prospect.Client.Config`** | Utilitaire multi-OS : écrit `backend.txt`, importe le certificat, lance le jeu. Voir son [README](src/Prospect.Client.Config/README.md). |
| **`Prospect.Server.Game`** | Serveur de jeu dédié (squelette, **R&D**). |
| **`Prospect.Unreal[.Generator/.Tests]`** | Réimplémentation C# du netcode Unreal (**R&D** serveur dédié). |
| `utils/` | `generate_ssl.py` — génération du certificat auto-signé. |
## Features Build config **`Season 2 Release`** obligatoire pour l'API (le code sélectionne la saison
via `#if SEASON_2_RELEASE` / `SEASON_3_RELEASE` → sinon `#error Unsupported build type`).
* [x] Basic login with Steam ---
* [x] EULA acceptance
* [x] Tutorial
* [x] Single-player station (Season 2 and Season 3):
* [x] Onboarding
* [ ] Matchmaking and deployment
* [x] Solo
* [ ] Squad
* [ ] Items insurance
* [ ] Free loadouts (Season 3)
* [x] Inventory and loadout
* [ ] Loadout presets (Season 3)
* [x] Quests
* [x] Faction progression
* [ ] Season pass
* [ ] Aurum Shops
* [ ] Daily shop
* [ ] Weekly shop
* [ ] Shop rotation
* [x] Daily login
* [x] Character appearance and emotes
* [x] Item Shops
* [x] Crafting station
* [x] Quarters
* [x] Player balance
* [ ] Social features
* [ ] Proximity voice
* [x] Vivox login
* [x] Vivox create and join channel
* [ ] Proximity voice works
* [x] Game mechanics
* [x] Can deploy through terminal
* [x] Can deploy with loadout
* [x] Can evac
* [x] Can do quests (except PvP)
* [x] Can gain/lose loot
* [x] Can use Alien Forge
* [x] Map content
* [x] Bright Sands
* [x] Crescent Falls
* [x] Tharis Island
## Running locally ## Build & lancement du serveur
> [!NOTE] Le serveur tourne en conteneur. L'image est buildée depuis les sources par le
> If you've already done all steps previously, you can skip to Step 7. [`Dockerfile`](Dockerfile) (multi-stage SDK .NET 8 → runtime aspnet 8, publish en
`Season 2 Release`).
### 1. Prerequisites ### Build de l'image
```bash
docker build -t the-cycle .
```
### Lancement
Il faut une **instance MongoDB** joignable et un **certificat TLS** monté (voir section
suivante). Variables d'environnement :
| Variable | Rôle |
|---|---|
| `DatabaseSettings__ConnectionString` | URI de connexion MongoDB. |
| `DatabaseSettings__DatabaseName` | Base à utiliser (ex. `ProspectDb`). |
| `AuthTokenSettings__Secret` | Secret de signature des JWT émis par le serveur. |
| `PlayFabSettings__SignalRURL` | URL SignalR **telle que le client doit l'atteindre** (voir gotcha ci-dessous). |
| `Kestrel__Certificates__Default__Path` | Chemin du `.pfx` dans le conteneur. |
| `Kestrel__Endpoints__Https__Url` | ex. `https://0.0.0.0:8443`. |
| `SteamWebApiKey` | *(optionnel)* clé Steam Web API pour récupérer les pseudos ; inerte si absente. |
> [!WARNING] ```bash
> The latest Steam version of The Cycle: Frontier currently does not work with Windows 11 24H2! docker run -d --name the-cycle-api \
-e DatabaseSettings__ConnectionString="mongodb://user:pass@HOST:27017/?authSource=ProspectDb" \
-e DatabaseSettings__DatabaseName="ProspectDb" \
-e AuthTokenSettings__Secret="<secret>" \
-e PlayFabSettings__SignalRURL="https://<host-public>:8443/signalr/?hub=pubsub" \
-e Kestrel__Endpoints__Https__Url="https://0.0.0.0:8443" \
-e Kestrel__Certificates__Default__Path="/certs/certificate.pfx" \
-v "$PWD/certs:/certs:ro" \
-p 8443:8443 \
the-cycle
```
> ⚠️ **`PlayFabSettings__SignalRURL` = l'URL que le CLIENT doit joindre**, pas `127.0.0.1`.
> Le serveur y renvoie le client pour l'event de matchmaking ; s'il pointe sur `127.0.0.1`,
> le déploiement en raid **timeout**. Mets le hostname/IP public du serveur.
### Dev local (.NET)
```bash
dotnet build src/Prospect.Server.Api/Prospect.Server.Api.csproj -c "Season 2 Release"
dotnet run --project src/Prospect.Server.Api -c "Season 2 Release"
```
---
## Certificat TLS
La connexion est en HTTPS et le client valide le certificat → il doit être **auto-signé et
fait confiance** côté client. Générer le `.pfx` avec `utils/generate_ssl.py`.
> ⚠️ **Le SAN doit contenir `DNS:<ip>` ET `IP:<ip>`**, en plus des hostnames.
> Le HTTP du jeu (libcurl) accepte l'IP en SAN IP, mais le WebSocket (libwebsockets) valide
> l'IP contre les SAN **DNS** → sans `DNS:<ip>`, la connexion SignalR échoue
> (`Hostname mismatch err=62`). Inclure aussi `2EA46.playfabapi.com`, `localhost`,
> `127.0.0.1` et tous les hostnames publics utilisés dans `backend.txt`.
Côté client, l'import du certificat est automatisé par
[`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
> [!IMPORTANT] ---
> You must have The Cycle: Frontier from Steam in your Steam library to be able to download it.
> Otherwise, the download will fail with an error message about missing license.
Before you start, you'll need the following software downloaded and installed: ## CI/CD
1. [MongoDB Community Edition](https://fastdl.mongodb.org/windows/mongodb-windows-x86_64-8.0.4-signed.msi). [`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) — sur push `main` / `preprod`
(ou `workflow_dispatch`) :
1. [`Prospect.Server.Api` and `LoaderPack`](https://github.com/deiteris/Prospect/releases) from the Releases section: 1. build de l'image depuis le `Dockerfile` ;
2. push sur le registry `git.nfteam.ovh/neckfire/the-cycle` ;
3. notification du résultat (ntfy).
- For Season 3 (the latest Steam game client), use Build 6. **Modèle de branches :**
- For Season 2 game client, use the latest version. | Branche | Tag image | Usage |
|---|---|---|
| `preprod` | `:preprod` (+ `:preprod-<sha>`) | banc de test — valider un build avant de merger |
| `main` | `:latest` (+ `:<sha>`) | production |
1. The Cycle: Frontier game client: Workflow type : coder → push `preprod` → tester sur le serveur preprod → **PR `preprod → main`**
→ la CI republie `:latest`. Le déploiement applique la nouvelle image
(`docker compose pull && docker compose up -d`).
- The latest version from [Steam](https://steamcommunity.com/app/868270). > `Prospect.Client.Config` (outil client, cross-OS) n'est **pas** buildé par cette CI —
> voir sa section publication.
- Season 2 client version `4623363103423775682` from SteamDB. See [download instructions below](#11-how-to-download-season-2-client-from-steamdb-using-steam-console). ---
#### 1.1 How to download Season 2 client from SteamDB using Steam console ## Config du client (switch de serveur + certificat)
> [!WARNING] L'outil **`Prospect.Client.Config`** (binaire `ProspectServerSwitcher`, multi-OS
> This will overwrite the existing client if you try to download a different manifest! Linux/Proton + Windows) fait tout le boulot côté client :
1. With Steam running, press `Win+R` and enter `steam://nav/console`. A Steam console will open. - écrit `backend.txt` (presets **prod** / **preprod** ou URL libre) ;
- récupère le certificat **en direct depuis le serveur ciblé** (TLS) et le rend fiable :
- **Windows** : import dans *Autorités de certification racines de confiance* (utilisateur) ;
- **Linux/Proton** : import direct dans le préfixe Wine du jeu via `wine reg import`
(car `wine certutil` est cassé sous Proton) ;
- lance le jeu.
1. Open [The Cycle: Frontier SteamDB manifests](https://steamdb.info/depot/868271/manifests/). ```bash
# menu interactif
ProspectServerSwitcher
1. Make sure you have **Copy format** set to **Steam console**. # scriptable
ProspectServerSwitcher --folder "<...>/Prospect/Binaries/Win64" --set preprod
ProspectServerSwitcher --set https://mon-serveur:8443
```
1. Press `CTRL+F` and enter `4623363103423775682` to find the manifest for Season 2 version 2.7.2 client. Détails complets, gotchas Proton (préfixe non-Steam) et commandes de publication des
binaires autonomes : **[src/Prospect.Client.Config/README.md](src/Prospect.Client.Config/README.md)**.
> Installation complète pas-à-pas pour un nouveau joueur (télécharger le client S2, le
> LoaderPack, importer le certificat) : **[FRIENDS-INSTALL.md](FRIENDS-INSTALL.md)**.
1. Click the ![Copy](./_assets/steamdb_copy.PNG) icon to copy the download command. ---
1. Paste the command in the Steam console and press `Enter`. ## État des fonctionnalités
1. The depot will begin downloading. You should receive a notification and the destination folder when the download is complete. **Fonctionne :**
### 2. Unpack `Prospect.Server.Api` - [x] Login Steam, EULA, tutoriel
- [x] Station solo (S2/S3) : onboarding, matchmaking & déploiement **solo**
- [x] Inventaire & loadout, stash, vente, réparation
- [x] Contrats / quêtes — y compris les objectifs **kills** et **de zone** (auto-crédités :
pas de serveur dédié pour remonter les events runtime du raid client-hosted)
- [x] Progression des factions
- [x] Season pass : claim + gain d'XP de saison (niveau Fortuna)
- [x] Boutiques d'items (Korolev / ICA / Osiris / QuickShop / CraftingStation)
- [x] Aurum Shop (cosmétiques) + rotation daily/weekly
- [x] Craft, Quarters, solde joueur, connexion quotidienne
- [x] Apparence & emotes
- [x] Assurance : débit de la prime au déploiement + payout à la mort
- [x] Stats de carrière (valeurs à 0 — non traçables sans serveur de jeu)
- [x] Présence des amis (en ligne / en raid)
- [x] Pseudos réels via Steam Web API (le client n'envoie que le SteamID)
- [x] Cartes : Bright Sands, Crescent Falls, Tharis Island
Use your favorite ZIP archiver and unzip the `Prospect.Server.Api.zip` downloaded from this repository. **Non implémenté / hors-périmètre :**
### 3. Generate and import SSL certificate - [ ] Squad / multi dans le **même** raid — nécessite un serveur de jeu dédié
- [ ] Voix de proximité (login/join Vivox = placeholders)
- [ ] Free loadouts & presets (Saison 3 uniquement)
- [ ] Achat de cosmétiques (endpoint d'achat vanity distinct, non câblé)
- [ ] Catalogue des récompenses Fortuna (DataTable côté client, dans des paks chiffrés)
- [ ] Défis quotidiens Fortuna
> [!IMPORTANT] ---
> Do not share the generated certificate! Generated certificate includes a private key that may be used to generate other certificates and compromise your security.
A connection to the server is served over a secured connection. The server uses self-signed certificate that must be added to trusted authorities in order for the game ## Hors-périmètre & R&D
to successfully communicate with the local server. Do the following:
1. Open the folder with `Prospect.Server.Api`. Un **serveur de jeu Unreal dédié** (`Prospect.Server.Game` + `Prospect.Unreal`, branche
`game-server`) est en cours de reverse-engineering pour, à terme, permettre le vrai multi.
État : handshake stateless UE, séquençage et décodage des bunches **franchis**, canal de
contrôle ouvert, `NMT_Hello` parsé. **Bloqué** sur le chiffrement **DTLS-PSK** du client
(clé dérivée du `user_id`), derrière un exe packé (BattlEye) → la dérivation n'est pas
extractible statiquement. Détails dans `NETCODE-RND.md` (branche `game-server`).
1. Double-click `generate_ssl.exe`. `certificate.pfx` will appear in the same folder. Le « lobby squad » via l'API seule n'est **pas faisable** : l'invitation d'amis est gérée
100 % côté client Steam.
1. Double-click `certificate.pfx`. The Certificate Import Wizard will open: ---
1. Select **Current User** under Store Location and click **Next**. ## Crédits & licence
1. Leave **File to Import** unchanged and click **Next**. Fork de [`deiteris/Prospect`](https://github.com/deiteris/Prospect) (lui-même issu du
projet Prospect original). Voir [`LICENSE`](LICENSE). Usage privé.
1. Leave **Password** empty and click **Next**.
1. Select **Place all certificates in the following store** > **Browse...**. Choose **Trusted Root Certification Authorities** and click **OK**. Click **Next**.
1. Click **Finish**. A **Security Warning** popup may appear, make sure it specifies `2EA46.playfabapi.com` certification authority and click **Yes**.
### 4. Extract `LoaderPack` to the game
1. Open the folder with The Cycle: Frontier and navigate to **Prospect** > **Binaries** > **Win64**.
1. Open the `LoaderPack` archive.
1. Drag and drop the contents of the `LoaderPack` archive to the game.
1. Create a shortcut for the `Prospect.Client.Loader` that you will use later to launch the game.
### 5. Run the server
Now you are all set! Open the folder with `Prospect.Server.Api` and run `Prospect.Server.Api.exe`. It will open a console if it runs successfully.
> [!IMPORTANT]
> Do not close the console when you run the game.
### 6. Run the game
Once the server is running, make sure that Steam is running and open The Cycle: Frontier using the shortcut you've created before.
## Troubleshooting and FAQ
### How to remove the certificate?
If you've installed the certificate for the **Current User**:
1. Open **Start** and enter `certmgr.msc`.
1. Expand **Trusted Root Certification Authorities** and select **Certificates**.
1. Find `2EA46.playfabapi.com`, right-click it > **Delete**.
If you've installed the certificate for the **Local Machine**, repeat the same steps but instead open `certlm.msc`.
### `generate_ssl.exe` is flagged as a virus
`generate_ssl.exe` is a Python application packed with PyInstaller and some anti-viruses may flag it as a virus.
This application is a simple certificate generator and you can find its source code in `utils/generate_ssl.py`.
### Body parts are missing with Season 3 client
Currently, the server loads body part IDs for Season 2 by default, so this is expected. You can fix this by going to station and changing your character appearance. This will store the updated body part IDs for your character.
### Prospect.Server.Api does not start
Make sure you have [.NET Runtime 8.0](https://aka.ms/dotnet-core-applaunch?missing_runtime=true&arch=x64&rid=win-x64&os=win10&apphost_version=8.0.11) and [ASP.NET Core 8.0](https://aka.ms/dotnet-core-applaunch?framework=Microsoft.AspNetCore.App&framework_version=8.0.0&arch=x64&rid=win-x64&os=win10) installed.
### Login Failed. Error code: 3
Make sure that:
* You have Steam running.
* You have created and **saved** the `steam_appid` file as described in step 6.
* The `steam_appid` file type is "TXT File".
### Login Failed. Error code: 5
Make sure that `Prospect.Server.Api` server is running.
If the server is running, press `Alt+Tab` to a game console that opens when you start the game and check for the following:
* `libcurl error 7 (Couldn't connect to server)` - indicates that the `Prospect.Server.Api` is not running.
![](./_assets/connection_refused_error.png)
* `InvalidAPIEndpoint` - indicates that you are running the game using the original shortcut and not using `Prospect.Client.Loader`.
![](./_assets/invalid_api_endpoint.PNG)
* `libcurl error 60 (Peer certificate cannot be authenticated with given CA certificates)` - indicates that the certificate was not installed correctly. Make sure that the certificate is present in `certmgr.msc` and there is only one certificate. Try removing the certificate and importing it again by following step 4.
![](./_assets/certificate_error.PNG)
* `HTTP code: 500` - usually indicates that MongoDB is not running. Make sure that MongoDB is installed and and that `MongoDB Server` is running in `services.msc`.
![](./_assets/mongodb_error.PNG)
## Development
TBD
+103
View File
@@ -0,0 +1,103 @@
# Étude — Émulation PlayFab Groups/Party pour l'invite de squad
> Objectif : permettre à un joueur d'**inviter un ami** à faire équipe (squad) sur le serveur
> privé. Étude du mécanisme, de l'état actuel, d'une conception d'émulation et d'un plan.
> Branche : `game-server`.
## 1. État actuel (ce qui existe déjà)
**Roster de squad en mémoire**`Services/Squad/SquadService.cs` :
- Squads clés par un **`squadId` fourni par le client** (`JoinOrCreate(squadId, userId, …)`).
- Membre = profil (playerId, displayName), `onlineState`, `isReady`, `selectedMap`, `isLeader`.
- `_userToSquad` permet à `TryGetCompleteSquadInfo` (qui ne porte pas de squadId) de retrouver la squad du joueur.
**Fonctions CloudScript squad présentes** : `TryGetCompleteSquadInfo`, `SquadMemberReadyForMatch`
(pousse déjà via SignalR), `SquadMemberSelectedMap`, `SquadMemberStartingDeployFlow`,
`GetFriendList`, `ClientsideFriendsImport`.
**Ce qui MANQUE** (cœur du problème) : aucune fonction de **création de squad**, d'**invitation**,
d'**acceptation/refus**, de **join/leave/kick**. Le flux actuel suppose que le client **connaît déjà
un `squadId`** (party formée ailleurs) et se contente d'y rattacher les membres.
**Infra de push temps-réel DISPONIBLE**`Hubs/CycleHub.cs` + `Hubs/SignalRConnectionRegistry.cs` :
le client se connecte au hub avec `?uid=<playerId>` ; le registry mappe `uid → connectionId`. Le
serveur peut donc **pousser un message ciblé** à un joueur précis (`registry.GetConnection(uid)` +
`IHubContext<CycleHub>.Clients.Client(conn).SendAsync(<event>, payload)`). C'est déjà utilisé par
`SquadMemberReadyForMatch` et par le signal « travel to match » du matchmaking. **→ Le canal de
livraison de l'invite existe déjà.**
## 2. Comment The Cycle forme-t-il les squads ? (hypothèses + preuves)
Le `squadId` étant **fourni par le client**, la party est créée là où le client obtient cet id.
Quatre mécanismes candidats côté PlayFab/Steam :
| # | Mécanisme | Ce que ça implique côté serveur | Indice |
|---|---|---|---|
| A | **PlayFab Entity Groups** (`/Group/CreateGroup`, `/Group/InviteToGroup`, `/Group/AcceptGroupInvitation`, `/Group/ListGroupMembers`…) | Implémenter le sous-ensemble Groups (entités déjà émulées). `squadId` = group entity id. | Fort : squads persistantes chez PlayFab passent par Groups ; l'id opaque partagé colle. |
| B | **PlayFab Lobby** (Multiplayer : `CreateLobby`/`JoinLobby`/`InviteToLobby` via connection string) | Implémenter l'API Lobby. `squadId` = lobbyId. | Moyen : plutôt matchmaking S3+. |
| C | **Lobby Steam** (invite via overlay Steam) + backend qui suit | Rien à créer pour l'invite (100% Steam) ; le serveur ne fait que suivre le `squadId` rapporté. | Moyen : S2 = Steam-only ; explique le `squadId` client. Mais alors l'invite n'est pas pilotable serveur. |
| D | **Fonctions CloudScript d'invite** (non observées) | Implémenter `InvitePlayerToSquad`/`RespondToSquadInvite`/… | Faible : aucune trace. |
**Preuve manquante (bloquant)** : les logs prod de la session à 2 ont été **écrasés** par nos
redéploiements. Impossible aujourd'hui de voir l'appel exact d'invite. De plus, l'émulateur peut
**404 silencieusement** un endpoint PlayFab natif non routé (ex. `/Group/CreateGroup`) → un tel
appel n'apparaît pas comme fonction CloudScript. **Il faut donc capturer l'appel réel** (voir §5,
Phase 0) — d'autant que le **bouton « inviter » n'apparaît pas tant que la tuile ami est vide**
(bug de forme `GetFriendList`, corrigé PR #15, à valider en jeu). Tant que la liste d'amis ne
s'affiche pas, aucune invite ne peut même être tentée.
## 3. Conception d'émulation proposée (indépendante du mécanisme exact)
Quel que soit A/B/C/D, les briques serveur à construire sont les mêmes :
**3.1 Étendre `SquadService`** (états d'invite) :
- `CreateSquad(leaderUserId) → squadId` (GUID serveur si le client n'en impose pas).
- `Invite(squadId, fromUserId, toUserId)` → enregistre une **invitation en attente** `{squadId, from, to, expiresAt}`.
- `RespondToInvite(toUserId, squadId, accept)` → si accept : `JoinOrCreate` ; sinon purge.
- `Leave` / `Kick(byLeader)` / transfert de leadership à la sortie du leader.
- `GetPendingInvites(userId)` (fallback poll).
**3.2 Surface d'API** — deux options selon la capture (§5) :
- **Si Groups (A)** : router `POST /Group/CreateGroup`, `/Group/InviteToGroup`,
`/Group/AcceptGroupInvitation`, `/Group/ListGroupMembers`, `/Group/RemoveMembers` vers
`SquadService` (les EntityToken sont déjà émulés). C'est le plus « natif ».
- **Si CloudScript (D) / custom** : ajouter les fonctions `[CloudScriptFunction(...)]`
correspondantes (noms/shapes calqués sur la capture).
- **Si Steam (C)** : rien pour l'invite (Steam) ; s'assurer seulement que le roster se peuple bien
quand les deux clients rapportent le même `squadId` (déjà géré par `SquadService`).
**3.3 Livraison temps-réel de l'invite** (réutilise l'infra existante) :
- À l'invitation, pousser à l'invité : `registry.GetConnection(toUserId)` +
`hub.Clients.Client(conn).SendAsync("<SquadInviteReceived>", payload)`.
- **Nom d'event + payload = à confirmer côté client** (le client doit écouter cet event). Fallback :
`GetPendingSquadInvites` (CloudScript) que le client poll à l'ouverture du menu social.
**3.4 Persistance** : le roster peut rester en mémoire (petit serveur), mais les invitations
gagnent à être **persistées** (UserData `PendingSquadInvites`) pour survivre à un reload/redeploy et
au cas « ami à la station » — combiné au push SignalR.
## 4. Limite structurelle importante (à dire clairement)
Émuler l'invite permet de **former une squad et de se mettre prêt** ensemble (lobby/ready-up).
Mais **déployer réellement ensemble dans le même raid** nécessite le **serveur de jeu dédié**
(`Prospect.Server.Game`), qui est bloqué au mur **DTLS-PSK** (cf. reste de cette branche). Les raids
S2 sont **hébergés côté client** (solo) : sans serveur dédié, deux joueurs ne peuvent pas partager
la même instance de raid. Donc :
- **Invite + squad + ready-up** : émulable via l'API (cette étude).
- **Co-op en raid** : dépend du serveur dédié (autre chantier, DTLS-PSK).
## 5. Plan par phases
- **Phase 0 — Capture (débloque tout)** : valider le fix `GetFriendList` en jeu (la tuile ami doit
afficher le pseudo) → le bouton « inviter » réapparaît → tenter une invite et **capturer les
appels** (ajouter un logging *catch-all* des requêtes non routées + corps, pour voir un éventuel
`/Group/…` 404). Résultat : on sait A/B/C/D.
- **Phase 1 — Invite** : implémenter la surface identifiée + états `SquadService` + push SignalR ;
persister les invitations.
- **Phase 2 — Ready-up/déploiement groupé** : compléter (déjà amorcé) ; le déploiement co-op réel
reste gated par le serveur dédié.
## 6. Prochaine action concrète
Repro en jeu (liste d'amis corrigée) + logging catch-all → capturer l'appel d'invite. Sans cette
capture, toute implémentation d'invite serait une supposition sur les noms/shapes attendus par le
client (paks chiffrés → pas de RE statique).
@@ -342,7 +342,7 @@ public class AdminController : ControllerBase
$"<div class='card'><div class='v'>{Esc(value)}</div><div class='l'>{Esc(label)}</div></div>"; $"<div class='card'><div class='v'>{Esc(value)}</div><div class='l'>{Esc(label)}</div></div>";
private static string Layout(string title, string body) => private static string Layout(string title, string body) =>
Head.Replace("__TITLE__", Esc(title)) + body + Foot; Head.Replace("__TITLE__", Esc(title)) + DtScript + body + Foot;
// Static shell (no C# interpolation → JS braces/quotes stay literal). Single quotes only. // Static shell (no C# interpolation → JS braces/quotes stay literal). Single quotes only.
private const string Head = @"<!doctype html><html lang='fr'><head> private const string Head = @"<!doctype html><html lang='fr'><head>
@@ -380,8 +380,11 @@ th.dth{cursor:pointer;user-select:none;white-space:nowrap}th.dth:hover{color:var
<span class='muted' style='margin-left:auto'>read-only · LAN</span></header> <span class='muted' style='margin-left:auto'>read-only · LAN</span></header>
<main>"; <main>";
private const string Foot = @"</main> private const string Foot = @"</main></body></html>";
<script>
// Client-side table engine, injected right after <main> so DT is defined before any
// page-level DT.init() runs. Single quotes only (verbatim string → JS braces stay literal).
private const string DtScript = @"<script>
const DT={ const DT={
init(cfg){const el=document.getElementById(cfg.id);el.innerHTML='<p class=muted>Chargement…</p>'; init(cfg){const el=document.getElementById(cfg.id);el.innerHTML='<p class=muted>Chargement…</p>';
fetch(cfg.url).then(r=>r.json()).then(rows=>DT.build(el,cfg,rows)).catch(e=>{el.innerHTML='<p class=muted>Erreur: '+e+'</p>';});}, fetch(cfg.url).then(r=>r.json()).then(rows=>DT.build(el,cfg,rows)).catch(e=>{el.innerHTML='<p class=muted>Erreur: '+e+'</p>';});},
@@ -424,5 +427,5 @@ const DT={
return '<span style=color:'+c+';font-weight:600>'+DT.esc(r)+'</span>';}, return '<span style=color:'+c+';font-weight:600>'+DT.esc(r)+'</span>';},
esc(s){const d=document.createElement('div');d.textContent=(s==null?'':''+s);return d.innerHTML;} esc(s){const d=document.createElement('div');d.textContent=(s==null?'':''+s);return d.innerHTML;}
}; };
</script></body></html>"; </script>";
} }
@@ -9,6 +9,14 @@ public class RequestLoggerMiddleware
private readonly ILogger<RequestLoggerMiddleware> _logger; private readonly ILogger<RequestLoggerMiddleware> _logger;
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
// Keywords used to surface social/squad/invite traffic while reverse-engineering the
// squad-invite flow (see SQUAD-EMULATION.md). Matched against the path AND the body
// (the CloudScript function name lives in the body of /Client/ExecuteFunction).
private static readonly string[] SocialKeywords =
{
"group", "party", "lobby", "squad", "invite", "friend", "social", "matchmak",
};
public RequestLoggerMiddleware(ILogger<RequestLoggerMiddleware> logger, RequestDelegate next) public RequestLoggerMiddleware(ILogger<RequestLoggerMiddleware> logger, RequestDelegate next)
{ {
_logger = logger; _logger = logger;
@@ -32,6 +40,27 @@ public class RequestLoggerMiddleware
} }
await _next(context); await _next(context);
// ── Capture pass (squad-invite RE, see SQUAD-EMULATION.md) ──────────────
// Runs AFTER the pipeline so we know whether the request was actually routed.
if (context.Request.Method == "POST")
{
var path = context.Request.Path.Value ?? "";
var haystack = (path + " " + body).ToLowerInvariant();
// Any POST that fell through to a 404 = an endpoint the emulator does NOT implement
// (e.g. a native PlayFab /Group/CreateGroup or /Lobby/* the client tried to call).
if (context.Response.StatusCode == 404)
{
_logger.LogWarning("[CAPTURE UNROUTED] {Method} {Url} -> 404 | Body {Body}",
context.Request.Method, context.Request.GetDisplayUrl(), body);
}
else if (SocialKeywords.Any(k => haystack.Contains(k)))
{
_logger.LogInformation("[CAPTURE SOCIAL] {Url} ({Status}) | Body {Body}",
context.Request.GetDisplayUrl(), context.Response.StatusCode, body);
}
}
} }
private static async Task<string> RequestAsync(HttpRequest request) private static async Task<string> RequestAsync(HttpRequest request)
@@ -32,9 +32,8 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
} }
var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
// Resolve the imported Steam friends (persisted by ClientsideFriendsImport) to the // Imported Steam friends (persisted by ClientsideFriendsImport), resolved to players
// players that actually exist on this server. It's a private server, so only friends // that actually exist on this private server.
// who have logged in here will show up.
var steamIds = new List<string>(); var steamIds = new List<string>();
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "ImportedSteamFriends" }); var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "ImportedSteamFriends" });
if (userData.TryGetValue("ImportedSteamFriends", out var rec) && !string.IsNullOrWhiteSpace(rec.Value)) if (userData.TryGetValue("ImportedSteamFriends", out var rec) && !string.IsNullOrWhiteSpace(rec.Value))
@@ -52,8 +51,10 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
{ {
if (player.Id == userId) continue; // never list yourself if (player.Id == userId) continue; // never list yourself
// Presence persisted by UpdatePlayerPresenceState: online if seen in the last var steamId = player.Auth?.FirstOrDefault(a => a.Type == PlayFabUserAuthType.Steam)?.Key ?? "";
// 3 minutes. EYUserState best-effort: 0 = offline, 1 = online, 2 = in match. var displayName = string.IsNullOrWhiteSpace(player.DisplayName) ? "Prospector" : player.DisplayName;
// 0 = offline, 1 = online, 2 = in match. Online if seen in the last 3 minutes.
var onlineState = 0; var onlineState = 0;
var presenceData = await _userDataService.FindAsync(player.Id, player.Id, new List<string> { "PresenceState" }); var presenceData = await _userDataService.FindAsync(player.Id, player.Id, new List<string> { "PresenceState" });
if (presenceData.TryGetValue("PresenceState", out var presenceRec) && !string.IsNullOrWhiteSpace(presenceRec.Value)) if (presenceData.TryGetValue("PresenceState", out var presenceRec) && !string.IsNullOrWhiteSpace(presenceRec.Value))
@@ -69,22 +70,50 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
catch { /* ignore malformed presence */ } catch { /* ignore malformed presence */ }
} }
// The exact model the UE client binds is not documented, so expose the identity
// under every plausible field name (camelCase + PlayFab PascalCase) and both a
// flat and nested profile — whichever the client reads, the tile gets populated.
friends.Add(new friends.Add(new
{ {
profile = new // identity
{ friendPlayFabId = player.Id,
playerId = player.Id, FriendPlayFabId = player.Id,
displayName = player.DisplayName, playerId = player.Id,
avatarUrl = "", PlayerId = player.Id,
}, playFabId = player.Id,
PlayFabId = player.Id,
// name
displayName,
DisplayName = displayName,
titleDisplayName = displayName,
TitleDisplayName = displayName,
name = displayName,
username = displayName,
Username = displayName,
// steam
steamId,
SteamId = steamId,
steamInfo = new { steamId, SteamId = steamId },
SteamInfo = new { SteamId = steamId, steamId },
// presence
onlineState, onlineState,
OnlineState = onlineState,
isOnline = onlineState > 0,
inMatch = onlineState == 2,
presence = new { onlineState, state = onlineState },
avatarUrl = "",
// nested profiles
profile = new { playerId = player.Id, displayName, avatarUrl = "" },
Profile = new { PlayerId = player.Id, DisplayName = displayName, PlayerProfileModel = new { DisplayName = displayName } },
tags = Array.Empty<string>(),
Tags = Array.Empty<string>(),
}); });
} }
} }
// NOTE: the exact response shape the client expects is not yet confirmed; this is a // Return the list under both the camelCase and PlayFab-cased container keys.
// best-effort structure. The log lets us verify resolution while we validate in game. var result = new { friends, Friends = friends, count = friends.Count };
_logger.LogInformation("GetFriendList for {User}: {Count} friend(s) resolved on server", userId, friends.Count); _logger.LogInformation("GetFriendList for {User}: {Count} friend(s); payload={Json}", userId, friends.Count, JsonSerializer.Serialize(result));
return new { friends }; return result;
} }
} }
File diff suppressed because one or more lines are too long
+39 -11
View File
@@ -1,4 +1,5 @@
using Prospect.Unreal.Core; using Prospect.Unreal.Core;
using Prospect.Unreal.Net.Actors;
using Prospect.Unreal.Runtime; using Prospect.Unreal.Runtime;
using Serilog; using Serilog;
@@ -25,27 +26,54 @@ internal static class Program
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] ({SourceContext,-52}) {Message:lj}{NewLine}{Exception}") .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] ({SourceContext,-52}) {Message:lj}{NewLine}{Exception}")
.CreateLogger(); .CreateLogger();
Logger.Information("Starting Prospect.Server.Game"); // The Cycle: Frontier authoritative game server.
// We start on the station map (the simplest shared space to get two players spawned
// and moving); the raid maps (Bright Sands, …) come once spawn + movement replication
// work end to end. AI and loot are intentionally out of scope for now.
var map = Environment.GetEnvironmentVariable("PROSPECT_MAP") ?? "/Game/Maps/MP/Station/Station_P";
var gameMode = Environment.GetEnvironmentVariable("PROSPECT_GAMEMODE") ?? "/Script/Prospect/YGameMode_Station";
var port = int.TryParse(Environment.GetEnvironmentVariable("PROSPECT_PORT"), out var p) ? p : 7777;
// Prospect: Logger.Information("Starting Prospect.Server.Game — map={Map} gameMode={GameMode} port={Port}", map, gameMode, port);
// Map: /Game/Maps/MP/Station/Station_P
// GameMode: /Script/Prospect/YGameMode_Station
var worldUrl = new FUrl var worldUrl = new FUrl { Map = map, Port = port };
{ worldUrl.Options.Add($"game={gameMode}");
Map = "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap"
};
await using (var world = new ProspectWorld()) await using (var world = new ProspectWorld())
{ {
world.SetGameInstance(new UGameInstance()); world.SetGameInstance(new UGameInstance());
world.SetGameMode(worldUrl); world.SetGameMode(worldUrl);
// The game mode needs a GameSession, otherwise NMT_Join -> SpawnPlayActor -> Login
// fails with "GameSession is null" and the player is never spawned.
var authGameMode = world.GetAuthGameMode();
if (authGameMode != null && authGameMode.GameSession == null)
{
authGameMode.GameSession = new AGameSession();
}
world.InitializeActorsForPlay(worldUrl, true); world.InitializeActorsForPlay(worldUrl, true);
world.Listen();
if (!world.Listen())
{
Logger.Fatal("Failed to start listening (port {Port} already in use?)", port);
return;
}
Logger.Information("Server listening on :{Port}. Waiting for players…", port);
while (await Tick.WaitForNextTickAsync()) while (await Tick.WaitForNextTickAsync())
{ {
world.Tick(TickRate); try
{
world.Tick(TickRate);
}
catch (Exception ex)
{
// A misaligned/incompatible client packet must not kill the whole server
// (R&D: the client's exact net version isn't matched yet). Log and continue.
Logger.Error(ex, "Tick error (bad packet?) — continuing");
}
} }
} }
@@ -0,0 +1,159 @@
using System.Security.Cryptography;
using Prospect.Unreal.Serialization;
using Serilog;
namespace Prospect.Unreal.Net;
/// <summary>
/// Chiffrement réseau conforme au FAESHandlerComponent d'UE 4.27 : AES-256-ECB,
/// padding PKCS#7 (défaut OpenSSL), clé 32 o, PAS d'IV. Format sur le fil (mirror
/// du source UE) :
/// [1 bit flag "encryption enabled"][ciphertext bit-packé, décalé de 1 bit]
/// Avant chiffrement, un "termination bit" (=1) est écrit sur le plaintext pour
/// préserver le compte de bits exact (le dernier octet déchiffré porte ce bit haut).
///
/// Le handler ne s'active qu'après EnableEncryption (déclenché à réception du
/// NMT_Hello chiffré côté serveur) ; avant, passthrough (pas de flag).
/// </summary>
public sealed class AesHandlerComponent : HandlerComponent
{
private static readonly ILogger Logger = Log.ForContext<AesHandlerComponent>();
private const int KeySize = 32;
private const int BlockSize = 16;
private byte[]? _key;
public AesHandlerComponent(PacketHandler handler) : base(handler, nameof(AesHandlerComponent))
{
}
public override void Initialize()
{
SetActive(false);
Initialized();
}
public override bool IsValid() => true;
/// <summary>SetEncryptionData : pose la clé (32 o) sans activer le chiffrement sortant.</summary>
public void SetKey(byte[] key)
{
if (key.Length != KeySize)
{
Logger.Warning("[AES] Clé de taille {N} (attendu {K}) — ignorée", key.Length, KeySize);
return;
}
_key = key;
Logger.Information("[AES] Clé posée ({N} o)", key.Length);
}
/// <summary>EnableEncryption : à partir d'ici la sortie est chiffrée. À appeler APRÈS SetKey
/// et APRÈS avoir envoyé le NMT_EncryptionAck en clair.</summary>
public void Enable()
{
SetActive(true);
Logger.Information("[AES] Chiffrement AES-256-ECB activé");
}
public override void Incoming(FBitReader packet)
{
if (!IsActive())
{
return; // pas encore de flag avant activation
}
var flag = packet.ReadBit();
if (!flag)
{
return; // paquet en clair (flag=0) — passthrough du reste
}
if (_key == null || packet.GetBytesLeft() <= 0)
{
return;
}
var n = packet.GetBytesLeft();
var cipher = new byte[n]; // zéro-init (dernier octet à 0 comme UE)
var bitsLeft = packet.GetBitsLeft();
unsafe
{
fixed (byte* p = cipher)
{
packet.SerializeBits(p, bitsLeft); // relit tous les bits restants -> ré-aligne le ciphertext
}
}
byte[] plain;
try
{
using var aes = Aes.Create();
aes.Key = _key;
plain = aes.DecryptEcb(cipher, PaddingMode.PKCS7);
}
catch (CryptographicException ex)
{
Logger.Warning("[AES] Déchiffrement échoué ({M}) — {N}o head={H}", ex.Message, n,
Convert.ToHexString(cipher.AsSpan(0, Math.Min(n, 16))));
packet.SetError();
return;
}
// Taille réelle en bits = position du bit à 1 le plus haut du dernier octet (termination bit).
if (plain.Length == 0)
{
packet.SetError();
return;
}
var last = plain[^1];
var bits = plain.Length * 8 - 1;
while ((last & 0x80) == 0 && bits >= 0)
{
last <<= 1;
bits--;
}
if (bits < 0)
{
packet.SetError(); // dernier octet nul -> malformé
return;
}
Logger.Information("[AES] Incoming déchiffré {N}o -> {B} bits", n, bits);
packet.SetData(plain, bits);
}
public override void Outgoing(ref FBitWriter packet, FOutPacketTraits traits)
{
if (!IsActive())
{
return; // passthrough (pas de flag avant activation)
}
// Termination bit -> le plaintext est byte-aligné (FBitWriter complète en zéros).
packet.WriteBit(true);
var plainLen = (int)packet.GetNumBytes();
var plaintext = packet.GetData();
byte[] cipher;
using (var aes = Aes.Create())
{
aes.Key = _key!;
cipher = aes.EncryptEcb(plaintext.AsSpan(0, plainLen), PaddingMode.PKCS7);
}
var newPacket = new FBitWriter((long)cipher.Length * 8 + 2, true, false);
newPacket.WriteBit(true); // flag = encryption enabled
unsafe
{
fixed (byte* p = cipher)
{
newPacket.Serialize(p, cipher.Length); // ciphertext décalé de 1 bit derrière le flag
}
}
packet = newPacket;
}
// 1 (flag) + 1 (termination) + 7 (bourrage octet) + 128 (bloc PKCS#7) — cf. UE.
public override int GetReservedPacketBits() => 2 + 7 + BlockSize * 8;
public bool IsEnabled => IsActive();
}
@@ -0,0 +1,170 @@
using Org.BouncyCastle.Tls;
using Prospect.Unreal.Net.Dtls;
using Prospect.Unreal.Serialization;
using Serilog;
namespace Prospect.Unreal.Net;
/// <summary>
/// Composant de chiffrement DTLS-PSK côté serveur, pendant du DTLSHandlerComponent
/// d'UE (la pile client est [DTLSHandlerComponent, StatelessConnectHandlerComponent]).
///
/// Le client exige le chiffrement : il annonce son identité PSK (= user_id) dans
/// NMT_Hello, et on répond NMT_EncryptionAck puis on établit un DTLS-PSK. Ici on
/// implémente le côté serveur (BouncyCastle) ; la clé provient du DtlsPskStore.
///
/// ⚠️ Première implémentation — le framing exact DTLS-sur-PacketHandler d'UE et
/// l'injection des records de handshake dans la voie d'émission demandent une
/// itération EN LIVE contre le vrai client (invalidable hors client).
/// </summary>
public sealed class DTLSHandlerComponent : HandlerComponent
{
private static readonly ILogger Logger = Log.ForContext<DTLSHandlerComponent>();
private DtlsPacketTransport? _transport;
private DtlsTransport? _dtls; // non-null une fois le handshake terminé
private Thread? _handshakeThread;
private volatile bool _established;
private volatile bool _failed;
public DTLSHandlerComponent(PacketHandler handler) : base(handler, nameof(DTLSHandlerComponent))
{
RequiresHandshake = true;
}
public override void Initialize()
{
// Inactif tant qu'on n'a pas la PSK (via BeginHandshake) : les connexions
// sans EncryptionToken passent en clair.
SetActive(false);
Initialized();
}
public override bool IsValid() => true;
/// <summary>
/// Démarre le serveur DTLS-PSK pour une identité (user_id) donnée. <paramref name="sendRecord"/>
/// émet un record DTLS vers le client (voie bas-niveau, hors chiffrement).
/// </summary>
public void BeginHandshake(DtlsPskStore store, string identity, Action<byte[]> sendRecord)
{
if (store.Get(identity) == null)
{
Logger.Warning("Pas de PSK pour l'identité {Identity} — DTLS impossible (fournir PROSPECT_DTLS_PSKS)", identity);
_failed = true;
return;
}
_transport = new DtlsPacketTransport(rec =>
{
Logger.Information("[DTLS] Outgoing record {N}o head={H}", rec.Length,
Convert.ToHexString(rec.AsSpan(0, Math.Min(rec.Length, 12))));
sendRecord(rec);
});
SetActive(true);
Logger.Information("[DTLS] BeginHandshake pour {Identity} — serveur DTLS-PSK démarré", identity);
_handshakeThread = new Thread(() =>
{
try
{
var server = new ProspectPskTlsServer(store);
_dtls = new DtlsServerProtocol().Accept(server, _transport);
_established = true;
Logger.Information("Handshake DTLS-PSK établi pour {Identity}", identity);
}
catch (Exception ex)
{
_failed = true;
Logger.Error(ex, "Handshake DTLS-PSK échoué pour {Identity}", identity);
}
})
{
IsBackground = true,
Name = "dtls-handshake",
};
_handshakeThread.Start();
}
public override void Incoming(FBitReader packet)
{
Logger.Information("[DTLS] Incoming CALLED active={A} transportNull={T} failed={F} established={E} bitsLeft={B}",
IsActive(), _transport == null, _failed, _established, packet.GetBitsLeft());
if (!IsActive() || _transport == null || _failed)
{
return; // passthrough (connexion non chiffrée)
}
var record = ReadAlignedBytes(packet);
Logger.Information("[DTLS] Incoming nbytes={N} head={H}", record.Length,
Convert.ToHexString(record.AsSpan(0, Math.Min(record.Length, 12))));
if (record.Length == 0)
{
return;
}
// Alimente la pile DTLS (handshake OU données applicatives).
_transport.Feed(record);
if (!_established || _dtls == null)
{
// Record de handshake : consommé par le thread, rien à remonter au pipeline.
packet.SetData(Array.Empty<byte>(), 0);
return;
}
// Données applicatives : déchiffrer et remplacer le contenu du paquet.
var plain = new byte[_dtls.GetReceiveLimit()];
var n = _dtls.Receive(plain, 0, plain.Length, 0);
if (n > 0)
{
packet.SetData(plain[..n], (long)n * 8);
}
else
{
packet.SetData(Array.Empty<byte>(), 0);
}
}
public override void Outgoing(ref FBitWriter packet, FOutPacketTraits traits)
{
if (!IsActive() || !_established || _dtls == null)
{
return; // avant établissement : le handshake gère ses propres records
}
var plain = packet.GetData();
var nbytes = (int)packet.GetNumBytes();
_dtls.Send(plain, 0, nbytes); // le record chiffré part via le callback du transport
}
public override int GetReservedPacketBits()
{
// Marge d'en-tête DTLS (record 13o + AEAD ~40o) — valeur à affiner en live.
return 64 * 8;
}
public bool IsEstablished => _established;
public bool HasFailed => _failed;
private static byte[] ReadAlignedBytes(FBitReader packet)
{
var bytes = packet.GetBytesLeft();
if (bytes <= 0)
{
return Array.Empty<byte>();
}
var buf = new byte[bytes];
unsafe
{
fixed (byte* p = buf)
{
packet.Serialize(p, bytes);
}
}
return buf;
}
}
@@ -0,0 +1,97 @@
using System.Collections.Concurrent;
using Org.BouncyCastle.Tls;
namespace Prospect.Unreal.Net.Dtls;
/// <summary>
/// Adapte l'API bloquante DTLS de BouncyCastle (DatagramTransport) au modèle
/// « un paquet à la fois » du PacketHandler d'Unreal.
///
/// - <see cref="Feed"/> : on y pousse les records DTLS reçus (depuis Incoming) ;
/// BouncyCastle les consomme via <see cref="Receive"/>.
/// - <see cref="Send"/> : BouncyCastle y écrit les records à émettre ; on les
/// récupère via le callback <c>onSend</c> pour les renvoyer dans le pipeline.
///
/// Le handshake tourne sur un thread dédié (Accept est bloquant) ; les records
/// applicatifs, eux, sont traités de façon synchrone dans Incoming/Outgoing.
/// </summary>
public sealed class DtlsPacketTransport : DatagramTransport
{
private const int Mtu = 1500;
private readonly BlockingCollection<byte[]> _incoming = new(new ConcurrentQueue<byte[]>());
private readonly Action<byte[]> _onSend;
public DtlsPacketTransport(Action<byte[]> onSend)
{
_onSend = onSend;
}
/// <summary>Pousse un record DTLS reçu du réseau vers la pile BouncyCastle.</summary>
public void Feed(byte[] record)
{
if (!_incoming.IsAddingCompleted)
{
_incoming.Add(record);
}
}
public int Receive(byte[] buf, int off, int len, int waitMillis)
{
try
{
if (!_incoming.TryTake(out var record, waitMillis))
{
return -1; // timeout : BouncyCastle re-tentera / retransmettra
}
var n = Math.Min(len, record.Length);
Array.Copy(record, 0, buf, off, n);
return n;
}
catch (Exception)
{
return -1;
}
}
public int Receive(Span<byte> buffer, int waitMillis)
{
try
{
if (!_incoming.TryTake(out var record, waitMillis))
{
return -1;
}
var n = Math.Min(buffer.Length, record.Length);
record.AsSpan(0, n).CopyTo(buffer);
return n;
}
catch (Exception)
{
return -1;
}
}
public void Send(byte[] buf, int off, int len)
{
var record = new byte[len];
Array.Copy(buf, off, record, 0, len);
_onSend(record);
}
public void Send(ReadOnlySpan<byte> buffer)
{
_onSend(buffer.ToArray());
}
public int GetReceiveLimit() => Mtu;
public int GetSendLimit() => Mtu;
public void Close()
{
_incoming.CompleteAdding();
}
}
@@ -0,0 +1,85 @@
using System.Globalization;
namespace Prospect.Unreal.Net.Dtls;
/// <summary>
/// Table identité DTLS (= PlayFab user_id) -> PSK 32 octets.
///
/// Les clés NE SONT JAMAIS en dur ni commitées : elles viennent de l'environnement
/// (rendu depuis Vault). Format de PROSPECT_DTLS_PSKS :
/// user_id:hex32[,user_id:hex32...]
/// ex. "92EBCFE8C3EAF3AC:1E02B3F2...410F1"
///
/// La dérivation par user_id vit dans le client (code packé) ; tant qu'on ne l'a
/// pas, on fournit les PSK connues (une par joueur) via cette table.
/// </summary>
public sealed class DtlsPskStore
{
private readonly Dictionary<string, byte[]> _byIdentity = new(StringComparer.OrdinalIgnoreCase);
public static DtlsPskStore FromEnvironment(string variable = "PROSPECT_DTLS_PSKS")
{
return Parse(Environment.GetEnvironmentVariable(variable));
}
public static DtlsPskStore Parse(string? spec)
{
var store = new DtlsPskStore();
if (string.IsNullOrWhiteSpace(spec))
{
return store;
}
foreach (var entry in spec.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
var sep = entry.IndexOf(':');
if (sep <= 0)
{
continue;
}
var identity = entry[..sep].Trim();
var psk = HexToBytes(entry[(sep + 1)..].Trim());
if (psk.Length == 32)
{
store._byIdentity[identity] = psk;
}
}
return store;
}
public bool HasKeys => _byIdentity.Count > 0;
public int Count => _byIdentity.Count;
/// <summary>PSK pour une identité (user_id), ou null si inconnue.</summary>
public byte[]? Get(string identity)
{
return _byIdentity.TryGetValue(identity, out var psk) ? psk : null;
}
private static byte[] HexToBytes(string hex)
{
if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
{
hex = hex[2..];
}
if (hex.Length % 2 != 0)
{
return Array.Empty<byte>();
}
var bytes = new byte[hex.Length / 2];
for (var i = 0; i < bytes.Length; i++)
{
if (!byte.TryParse(hex.AsSpan(i * 2, 2), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out bytes[i]))
{
return Array.Empty<byte>();
}
}
return bytes;
}
}
@@ -0,0 +1,45 @@
using System.Text;
using Org.BouncyCastle.Tls;
using Org.BouncyCastle.Tls.Crypto;
using Org.BouncyCastle.Tls.Crypto.Impl.BC;
namespace Prospect.Unreal.Net.Dtls;
/// <summary>
/// Serveur DTLS en mode PSK (BouncyCastle) pour The Cycle. L'identité PSK
/// annoncée par le client est son user_id ; on récupère la clé correspondante
/// dans le <see cref="DtlsPskStore"/>. Le client embarque des suites PSK-AES256
/// (ECDHE/DHE) sur DTLS 1.2.
/// </summary>
public sealed class ProspectPskTlsServer : PskTlsServer
{
public ProspectPskTlsServer(DtlsPskStore store)
: base(new BcTlsCrypto(), new ProspectPskIdentityManager(store))
{
}
// Le client négocie en DTLS ; on restreint aux versions DTLS.
protected override ProtocolVersion[] GetSupportedVersions()
{
return new[] { ProtocolVersion.DTLSv12, ProtocolVersion.DTLSv10 };
}
private sealed class ProspectPskIdentityManager : TlsPskIdentityManager
{
private readonly DtlsPskStore _store;
public ProspectPskIdentityManager(DtlsPskStore store)
{
_store = store;
}
// Pas de hint côté serveur (le client connaît déjà son identité).
public byte[]? GetHint() => null;
public byte[]? GetPsk(byte[] identity)
{
var id = Encoding.UTF8.GetString(identity);
return _store.Get(id);
}
}
}
+7 -1
View File
@@ -192,8 +192,14 @@ public class PacketHandler
UpdateInitialState(); UpdateInitialState();
} }
foreach (var component in _handlerComponents) // Incoming = sens INVERSE d'Outgoing (Outgoing itère en avant). Indispensable
// dès qu'il y a >1 composant actif : la couche ajoutée en dernier à l'émission
// (ex. chiffrement AES, la plus externe) doit être retirée en premier à la
// réception. Sans ça, le stateless tenterait de parser des octets chiffrés.
for (var idx = _handlerComponents.Count - 1; idx >= 0; idx--)
{ {
var component = _handlerComponents[idx];
if (processPacketReader.GetPosBits() != 0 && !component.CanReadUnaligned()) if (processPacketReader.GetPosBits() != 0 && !component.CanReadUnaligned())
{ {
RealignPacket(processPacketReader); RealignPacket(processPacketReader);
@@ -129,16 +129,19 @@ public class FNetPacketNotify
{ {
if (!notificationData.Seq.Greater(_inSeq)) if (!notificationData.Seq.Greater(_inSeq))
{ {
Logger.Information("[HS-SEQ] fail1 seq<=inSeq seq={Seq} inSeq={InSeq}", notificationData.Seq.Value, _inSeq.Value);
return 0; return 0;
} }
if (!notificationData.AckedSeq.GreaterEq(_outAckSeq)) if (!notificationData.AckedSeq.GreaterEq(_outAckSeq))
{ {
Logger.Information("[HS-SEQ] fail2 ackedSeq<outAckSeq ackedSeq={Acked} outAckSeq={OutAck}", notificationData.AckedSeq.Value, _outAckSeq.Value);
return 0; return 0;
} }
if (!_outSeq.Greater(notificationData.AckedSeq)) if (!_outSeq.Greater(notificationData.AckedSeq))
{ {
Logger.Information("[HS-SEQ] fail3 outSeq<=ackedSeq outSeq={OutSeq} ackedSeq={Acked}", _outSeq.Value, notificationData.AckedSeq.Value);
return 0; return 0;
} }
@@ -185,6 +185,8 @@ public class StatelessConnectHandlerComponent : HandlerComponent
public override void Incoming(FBitReader packet) public override void Incoming(FBitReader packet)
{ {
var diagTotalBits = packet.GetBitsLeft();
if (_magicHeader.Length > 0) if (_magicHeader.Length > 0)
{ {
// Skip magic header. // Skip magic header.
@@ -192,6 +194,11 @@ public class StatelessConnectHandlerComponent : HandlerComponent
} }
var bHandshakePacket = packet.ReadBit() && !packet.IsError(); var bHandshakePacket = packet.ReadBit() && !packet.IsError();
// [HS-DIAG] Compare the incoming packet against what the stateless handshake expects.
// The Cycle client may use a magic header / different handshake size than 227 bits.
Logger.Information("[HS-DIAG] Incoming totalBits={Total} magicLen={Magic} bHandshake={HS} bitsLeftAfterFlag={Left} expectAfterFlag={Exp}",
diagTotalBits, _magicHeader.Length, bHandshakePacket, packet.GetBitsLeft(), HandshakePacketSizeBits - 1);
if (bHandshakePacket) if (bHandshakePacket)
{ {
var bRestartHandshake = false; var bRestartHandshake = false;
@@ -392,6 +399,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
} }
var bHandshakePacket = packet.ReadBit() && !packet.IsError(); var bHandshakePacket = packet.ReadBit() && !packet.IsError();
Logger.Information("[HS-DIAG-CL] Incoming flag={HS} bitsLeftAfterFlag={Left} expect={Exp}", bHandshakePacket, packet.GetBitsLeft(), HandshakePacketSizeBits - 1);
_lastChallengeSuccessAddress = null; _lastChallengeSuccessAddress = null;
@@ -404,6 +412,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
Span<byte> origCookie = stackalloc byte[CookieByteSize]; Span<byte> origCookie = stackalloc byte[CookieByteSize];
bHandshakePacket = ParseHandshakePacket(packet, ref bRestartHandshake, ref secretId, ref timestamp, cookie, origCookie); bHandshakePacket = ParseHandshakePacket(packet, ref bRestartHandshake, ref secretId, ref timestamp, cookie, origCookie);
Logger.Information("[HS-DIAG-CL] parsed ok={Ok} restart={R} secretId={S} timestamp={T}", bHandshakePacket, bRestartHandshake, secretId, timestamp);
if (bHandshakePacket) if (bHandshakePacket)
{ {
@@ -412,6 +421,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
var bInitialConnect = timestamp == 0.0; var bInitialConnect = timestamp == 0.0;
if (bInitialConnect) if (bInitialConnect)
{ {
Logger.Information("[HS-DIAG-CL] initial -> SendConnectChallenge to {Addr}", address);
SendConnectChallenge(address); SendConnectChallenge(address);
} }
else if (_driver != null) else if (_driver != null)
@@ -430,6 +440,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
GenerateCookie(address, secretId, timestamp, regenCookie); GenerateCookie(address, secretId, timestamp, regenCookie);
bChallengeSuccess = cookie.SequenceEqual(regenCookie); bChallengeSuccess = cookie.SequenceEqual(regenCookie);
Logger.Information("[HS-DIAG-CL] challenge response cookieMatch={Ok}", bChallengeSuccess);
if (bChallengeSuccess) if (bChallengeSuccess)
{ {
+58 -1
View File
@@ -220,6 +220,15 @@ public abstract class UNetConnection : UPlayer
/// </summary> /// </summary>
public StatelessConnectHandlerComponent? StatelessConnectComponent { get; private set; } public StatelessConnectHandlerComponent? StatelessConnectComponent { get; private set; }
/// <summary>
/// Composant de chiffrement DTLS-PSK (inactif tant qu'un NMT_Hello avec
/// EncryptionToken n'a pas déclenché le handshake). Voir DTLSHandlerComponent.
/// </summary>
public DTLSHandlerComponent? DtlsComponent { get; private set; }
/// <summary>Composant de chiffrement AES-256-ECB (conforme FAESHandlerComponent d'UE), keyé par la PSK.</summary>
public AesHandlerComponent? AesComponent { get; private set; }
/// <summary> /// <summary>
/// Net id of remote player on this connection. Only valid on client connections (server side). /// Net id of remote player on this connection. Only valid on client connections (server side).
/// </summary> /// </summary>
@@ -267,6 +276,8 @@ public abstract class UNetConnection : UPlayer
/// <summary> /// <summary>
/// Full incoming packet index. /// Full incoming packet index.
/// </summary> /// </summary>
private bool _bAdoptedInitialSequence;
public int InPacketId { get; private set; } public int InPacketId { get; private set; }
/// <summary> /// <summary>
@@ -515,6 +526,19 @@ public abstract class UNetConnection : UPlayer
return; return;
} }
// The Cycle client (UE4 R3.5.0) writes one extra bit between the packet's
// ack history and the packet-info payload that stock UE 4.27 (EngineNetVer
// 16) does not. Bit-decoding real client packets showed the header is 65
// bits, not 64: consuming this bit realigns everything downstream — the
// bHasPacketInfoPayload flag, the 10-bit jitter clock and bHasServerFrameTime
// then land exactly, and the first control-channel bunch parses cleanly
// (ChIndex 0, NMT_Hello). Observed always 0 in captures.
var bCycleExtraHeaderBit = reader.ReadBit();
if (bCycleExtraHeaderBit)
{
Logger.Warning("[HS-HDR] Cycle extra header bit was set (expected 0)");
}
var bHasPacketInfoPayload = true; var bHasPacketInfoPayload = true;
if (reader.EngineNetVer() > EEngineNetworkVersionHistory.HISTORY_JITTER_IN_HEADER) if (reader.EngineNetVer() > EEngineNetworkVersionHistory.HISTORY_JITTER_IN_HEADER)
@@ -540,6 +564,25 @@ public abstract class UNetConnection : UPlayer
} }
var packetSequenceDelta = PacketNotify.GetSequenceDelta(header); var packetSequenceDelta = PacketNotify.GetSequenceDelta(header);
// The Cycle client doesn't derive its initial packet sequences from the handshake
// cookie the way stock UE does, so our cookie-based InitSequence disagrees with it
// and every packet looks out-of-order. On the very first packet, adopt the client's
// announced sequences (Seq + AckedSeq) instead of the cookie-derived ones.
if (packetSequenceDelta <= 0 && !_bAdoptedInitialSequence && Driver != null && Driver.IsServer())
{
_bAdoptedInitialSequence = true;
var adoptIn = new SequenceNumber((ushort)(header.Seq.Value - 1));
var adoptOut = new SequenceNumber((ushort)(header.AckedSeq.Value + 1));
PacketNotify.Init(adoptIn, adoptOut);
InPacketId = header.Seq.Value - 1;
OutPacketId = header.AckedSeq.Value + 1;
OutAckPacketId = header.AckedSeq.Value;
LastNotifiedPacketId = OutAckPacketId;
Logger.Information("[HS-SEQ] Adopted client sequences: inSeq={In} outSeq={Out}", header.Seq.Value, header.AckedSeq.Value + 1);
packetSequenceDelta = PacketNotify.GetSequenceDelta(header);
}
if (packetSequenceDelta > 0) if (packetSequenceDelta > 0)
{ {
var bPacketOrderCacheActive = !_bFlushingPacketOrderCache && _packetOrderCache != null; var bPacketOrderCacheActive = !_bFlushingPacketOrderCache && _packetOrderCache != null;
@@ -645,7 +688,10 @@ public abstract class UNetConnection : UPlayer
if (bunch.ChIndex >= MaxChannelSize) if (bunch.ChIndex >= MaxChannelSize)
{ {
throw new Exception("Bunch channel index exceeds channel limit"); // Don't crash the whole server on a malformed/misaligned bunch (e.g. a
// version/bit-alignment mismatch with the client). Log and drop the packet.
Logger.Error("[HS-SEQ] Bunch channel index {Idx} exceeds limit {Max} (netVer={Ver}) — dropping packet", bunch.ChIndex, MaxChannelSize, (int)bunch.EngineNetVer());
return;
} }
} }
@@ -1079,6 +1125,7 @@ public abstract class UNetConnection : UPlayer
public void InitSequence(int incomingSequence, int outgoingSequence) public void InitSequence(int incomingSequence, int outgoingSequence)
{ {
Logger.Information("[HS-SEQ] InitSequence incoming={In} outgoing={Out}", incomingSequence, outgoingSequence);
if (InPacketId == -1) if (InPacketId == -1)
{ {
// Initialize the base UNetConnection packet sequence (not very useful/effective at preventing attacks) // Initialize the base UNetConnection packet sequence (not very useful/effective at preventing attacks)
@@ -1129,6 +1176,16 @@ public abstract class UNetConnection : UPlayer
StatelessConnectComponent = (StatelessConnectHandlerComponent) Handler.AddHandler<StatelessConnectHandlerComponent>(); StatelessConnectComponent = (StatelessConnectHandlerComponent) Handler.AddHandler<StatelessConnectHandlerComponent>();
StatelessConnectComponent.SetDriver(Driver); StatelessConnectComponent.SetDriver(Driver);
// Chiffrement DTLS-PSK (côté serveur). Ajouté au pipeline mais inactif : il
// ne s'active qu'au NMT_Hello portant un EncryptionToken (cf. UWorld).
// NB ordre : la pile client est [DTLS, Stateless] — l'ordre exact ici est à
// valider en live contre le vrai client.
DtlsComponent = (DTLSHandlerComponent) Handler.AddHandler<DTLSHandlerComponent>();
// Chiffrement AES-256-ECB conforme FAESHandlerComponent d'UE (keyé par la PSK).
// Ajouté EN DERNIER -> couche la plus externe (chiffre tout ce qui précède).
// Inactif jusqu'à EnableEncryption (déclenché au NMT_Hello chiffré, cf. UWorld).
AesComponent = (AesHandlerComponent) Handler.AddHandler<AesHandlerComponent>();
Handler.InitializeComponents(); Handler.InitializeComponents();
MaxPacketHandlerBits = Handler.GetTotalReservedPacketBits(); MaxPacketHandlerBits = Handler.GetTotalReservedPacketBits();
@@ -22,6 +22,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="Serilog" Version="4.0.0" /> <PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="BouncyCastle.Cryptography" Version="2.4.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+64 -6
View File
@@ -4,6 +4,7 @@ using Prospect.Unreal.Core.Objects;
using Prospect.Unreal.Exceptions; using Prospect.Unreal.Exceptions;
using Prospect.Unreal.Net; using Prospect.Unreal.Net;
using Prospect.Unreal.Net.Actors; using Prospect.Unreal.Net.Actors;
using Prospect.Unreal.Net.Dtls;
using Prospect.Unreal.Net.Channels; using Prospect.Unreal.Net.Channels;
using Prospect.Unreal.Net.Packets.Bunch; using Prospect.Unreal.Net.Packets.Bunch;
using Prospect.Unreal.Net.Packets.Control; using Prospect.Unreal.Net.Packets.Control;
@@ -15,6 +16,9 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
{ {
private static readonly ILogger Logger = Log.ForContext<UWorld>(); private static readonly ILogger Logger = Log.ForContext<UWorld>();
// Table user_id -> PSK DTLS, chargée depuis l'env (rendu Vault, jamais commité).
private static readonly Lazy<DtlsPskStore> DtlsPsks = new(() => DtlsPskStore.FromEnvironment());
private UGameInstance? _owningGameInstance; private UGameInstance? _owningGameInstance;
private AGameModeBase? _authorityGameMode; private AGameModeBase? _authorityGameMode;
@@ -286,7 +290,60 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
} }
else else
{ {
throw new NotImplementedException("Encryption"); // R&D WALL — The Cycle mandates encryption on the game connection.
// The client's NMT_Hello carries EncryptionToken = its PlayFab
// user_id (e.g. "92EBCFE8C3EAF3AC"), and the client PacketHandler
// stack is [DTLSHandlerComponent, StatelessConnectHandlerComponent]
// (confirmed from the client's own logs). Encryption is therefore
// DTLS in PSK mode (the shipping exe bundles ECDHE/DHE-PSK-AES256
// cipher suites + a "DTLS.PreSharedKeys" cvar + DTLSPSK*Callback).
//
// Proceeding to the challenge WITHOUT sending NMT_EncryptionAck does
// NOT work: the client requires encryption and closes the control
// channel immediately after the plaintext challenge.
//
// To finish this we'd need (1) a DTLS-PSK server that matches UE's
// DTLSHandlerComponent framing, and (2) the 32-byte PSK the client
// derives per user_id. The derivation lives in the client's code,
// which cannot be recovered statically: the exe is packed/encrypted
// (.text entropy = 8.0), so Ghidra/radare2 see only ciphertext. The
// only route left is a runtime memory dump of the decrypted image.
//
// For now: log and proceed to the challenge so the flow is visible
// in logs; the client will close afterwards.
// Chiffrement DTLS-PSK requis par le client. Si on connaît la
// PSK de cette identité (user_id), on répond NMT_EncryptionAck et
// on établit le DTLS côté serveur (BouncyCastle). Cf. DTLSHandlerComponent.
// PISTE AES-GCM (test live précédent : le client ferme quand nos
// paquets post-ack sont EN CLAIR → il attend une voie serveur chiffrée,
// sans handshake DTLS). On envoie l'EncryptionAck EN CLAIR, on active
// AES-256-GCM keyé par la PSK, puis le Challenge part CHIFFRÉ.
// Flux DTLS d'UE (cf. recherche) : NMT_EncryptionAck EN CLAIR sans
// payload -> le client résout SA clé (ReceivedNetworkEncryptionAck),
// appelle EnableEncryption côté client, ce qui déclenche SON ClientHello.
// Le serveur passe en mode ACCEPT (attend le ClientHello, ne parle pas
// en premier) et NE DOIT PAS envoyer le Challenge maintenant : il est
// différé jusqu'à la fin du handshake DTLS.
// Flux d'encryption UE (AES-256-ECB, cf. FAESHandlerComponent) :
// SetEncryptionData(clé) -> NMT_EncryptionAck EN CLAIR -> EnableEncryption
// (à partir d'ici la sortie serveur est chiffrée) -> Challenge chiffré.
// Le client déduit la même clé du token, active son chiffrement, déchiffre
// le Challenge et répond (Login) chiffré. Clé = notre PSK (server-side).
var aesKey = DtlsPsks.Value.Get(encryptionToken);
if (aesKey != null && connection.AesComponent != null)
{
Logger.Information("AES: identité {Token} -> SetKey + EncryptionAck (clair) + Enable + Challenge (chiffré)", encryptionToken);
connection.AesComponent.SetKey(aesKey);
NMT_EncryptionAck.Send(connection);
connection.FlushNet(); // ack EN CLAIR (avant Enable)
connection.AesComponent.Enable(); // sortie chiffrée à partir d'ici
connection.SendChallengeControlMessage(); // Challenge chiffré
}
else
{
Logger.Warning("Aucune PSK pour {Token} (PROSPECT_DTLS_PSKS) — challenge en clair, le client fermera", encryptionToken);
connection.SendChallengeControlMessage();
}
} }
} }
break; break;
@@ -434,13 +491,14 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
private void WelcomePlayer(UNetConnection connection) private void WelcomePlayer(UNetConnection connection)
{ {
// TODO: Properly fetch level name from CurrentLevel // Tell the client which level + game mode to travel to. Previously hardcoded to the
var levelName = "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap"; // UE ThirdPerson template; now use the world's configured map and the "game=" option
// (set by the host), falling back to the template if unset.
// TODO: Properly fetch from AuthorityGameMode var levelName = string.IsNullOrEmpty(Url.Map) ? "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap" : Url.Map;
var gameName = "/Script/ThirdPersonMP.ThirdPersonMPGameMode"; var gameName = Url.GetOption("game=", "/Script/ThirdPersonMP.ThirdPersonMPGameMode") ?? string.Empty;
var redirectUrl = string.Empty; var redirectUrl = string.Empty;
Logger.Information("Welcoming player -> level={Level} game={Game}", levelName, gameName);
NMT_Welcome.Send(connection, levelName, gameName, redirectUrl); NMT_Welcome.Send(connection, levelName, gameName, redirectUrl);
connection.FlushNet(); connection.FlushNet();