Compare commits
19
Commits
preprod
...
f0343bc1ea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0343bc1ea | ||
|
|
ba9976c5b6 | ||
|
|
fb73038901 | ||
|
|
629e8a4c31 | ||
|
|
1691af4be6 | ||
|
|
b1386bef56 | ||
|
|
71aca5e3d0 | ||
|
|
4d2280f8ab | ||
|
|
1caa46f846 | ||
|
|
9092dda950 | ||
|
|
6ccad507a8 | ||
|
|
f4d1757840 | ||
|
|
9749828af8 | ||
|
|
57a45d91df | ||
|
|
c6b2330e1d | ||
|
|
9737316dbc | ||
|
|
a4e006f84f | ||
|
|
3cdf8bc7e2 | ||
|
|
67f3095896 |
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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 2–3 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.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# 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.
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -7,10 +8,10 @@ namespace Prospect.Server.Game;
|
|||||||
internal static class Program
|
internal static class Program
|
||||||
{
|
{
|
||||||
private const float TickRate = (1000.0f / 60.0f) / 1000.0f;
|
private const float TickRate = (1000.0f / 60.0f) / 1000.0f;
|
||||||
|
|
||||||
private static readonly ILogger Logger = Log.ForContext(typeof(Program));
|
private static readonly ILogger Logger = Log.ForContext(typeof(Program));
|
||||||
private static readonly PeriodicTimer Tick = new PeriodicTimer(TimeSpan.FromSeconds(TickRate));
|
private static readonly PeriodicTimer Tick = new PeriodicTimer(TimeSpan.FromSeconds(TickRate));
|
||||||
|
|
||||||
public static async Task Main()
|
public static async Task Main()
|
||||||
{
|
{
|
||||||
Console.CancelKeyPress += (_, e) =>
|
Console.CancelKeyPress += (_, e) =>
|
||||||
@@ -18,37 +19,64 @@ internal static class Program
|
|||||||
Tick.Dispose();
|
Tick.Dispose();
|
||||||
e.Cancel = true;
|
e.Cancel = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.MinimumLevel.Verbose()
|
.MinimumLevel.Verbose()
|
||||||
.Enrich.FromLogContext()
|
.Enrich.FromLogContext()
|
||||||
.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");
|
|
||||||
|
|
||||||
// Prospect:
|
// The Cycle: Frontier authoritative game server.
|
||||||
// Map: /Game/Maps/MP/Station/Station_P
|
// We start on the station map (the simplest shared space to get two players spawned
|
||||||
// GameMode: /Script/Prospect/YGameMode_Station
|
// 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 worldUrl = new FUrl
|
var map = Environment.GetEnvironmentVariable("PROSPECT_MAP") ?? "/Game/Maps/MP/Station/Station_P";
|
||||||
{
|
var gameMode = Environment.GetEnvironmentVariable("PROSPECT_GAMEMODE") ?? "/Script/Prospect/YGameMode_Station";
|
||||||
Map = "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap"
|
var port = int.TryParse(Environment.GetEnvironmentVariable("PROSPECT_PORT"), out var p) ? p : 7777;
|
||||||
};
|
|
||||||
|
Logger.Information("Starting Prospect.Server.Game — map={Map} gameMode={GameMode} port={Port}", map, gameMode, port);
|
||||||
|
|
||||||
|
var worldUrl = new FUrl { Map = map, Port = port };
|
||||||
|
worldUrl.Options.Add($"game={gameMode}");
|
||||||
|
|
||||||
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Logger.Information("Shutting down");
|
Logger.Information("Shutting down");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,19 +129,22 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
return SequenceNumber.Diff(notificationData.Seq, _inSeq);
|
return SequenceNumber.Diff(notificationData.Seq, _inSeq);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -185,13 +185,20 @@ 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.
|
||||||
packet.Pos += _magicHeader.Length;
|
packet.Pos += _magicHeader.Length;
|
||||||
}
|
}
|
||||||
|
|
||||||
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,9 +399,10 @@ 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;
|
||||||
|
|
||||||
if (bHandshakePacket)
|
if (bHandshakePacket)
|
||||||
{
|
{
|
||||||
var bRestartHandshake = false;
|
var bRestartHandshake = false;
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -267,6 +267,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>
|
||||||
@@ -498,7 +500,7 @@ public abstract class UNetConnection : UPlayer
|
|||||||
|
|
||||||
var resetReaderMark = new FBitReaderMark(reader);
|
var resetReaderMark = new FBitReaderMark(reader);
|
||||||
var channelsToClose = new List<FChannelCloseInfo>();
|
var channelsToClose = new List<FChannelCloseInfo>();
|
||||||
|
|
||||||
if (_bInternalAck)
|
if (_bInternalAck)
|
||||||
{
|
{
|
||||||
++InPacketId;
|
++InPacketId;
|
||||||
@@ -515,6 +517,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 +555,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 +679,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 +1116,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)
|
||||||
|
|||||||
@@ -286,7 +286,29 @@ 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.
|
||||||
|
Logger.Warning("Client requires DTLS-PSK encryption (EncryptionToken={Token}); server-side DTLS not implemented — connection will be dropped by client", encryptionToken);
|
||||||
|
connection.SendChallengeControlMessage();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -434,13 +456,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();
|
||||||
|
|||||||
Reference in New Issue
Block a user