Skip to content

DistrictServerCSharp: .NET 8 port of the C++ district server (draft, refs #12) - #16

Draft
eaxiumnet wants to merge 9 commits into
burber:mainfrom
eaxiumnet:feature/csharp-districtserver-port
Draft

DistrictServerCSharp: .NET 8 port of the C++ district server (draft, refs #12)#16
eaxiumnet wants to merge 9 commits into
burber:mainfrom
eaxiumnet:feature/csharp-districtserver-port

Conversation

@eaxiumnet

Copy link
Copy Markdown
Contributor

Implements the C# port proposed in #12. Opened as a draft: the history is reviewable and every offline gate is green, but the one verification gap named in #12 — a live-client smoke test — is still open, and no maintainer feedback on the architecture has landed yet. Nothing here removes, rewrites, or competes with the C++ tree.

What this adds

DistrictServerCSharp/ — a .NET 8 reimplementation of DistrictServer.cpp (~14k lines) split by responsibility, plus two drift gates under Tools/. 42 files, +14,220 lines, zero lines deleted. DistrictServer/ (C++) is untouched and remains the protocol oracle and the rollback path.

Layer Contents
Bits/, Crypto/, Protocol/, Net/ UE3 bit primitives, XTEA/BTEA, packet parse/build, field decoders, UDP listener
Handshake/ AUTH → USES → WELCOME → JOIN → district-enter answer, channel sequencing, binary NMT control
Core/ reliable TX queue + ACK retirement/retry, transport state, packet diagnostics, lifecycle resets
Gri/, Streaming/, SpawnZone/ GRI match-start, level-streaming plan, spawn-zone markers + selection
Pawn/, Controller/ ACK-gated pawn bootstrap + possession, remote-pawn multiplayer + dead-peer lifecycle, controller field decode
Packages/ cooked-package net-index resolver (parses cooked .u directly)
WorldControl/ world registration + character handoffs
Drive/ in-process two-player end-to-end harness

Commits

Seven incremental commits, each building on the last, bottom-up so review can follow the wire layer before the services that use it:

06dcebc scaffold: project skeleton
4c9bd0b wire layer: bit primitives, XTEA/BTEA, packet parse/build, field decoders
52130de core services: handshake, reliable queue, channel sequences, transport state, diagnostics
67e69d9 subsystems: GRI match-start, streaming, spawn zones, pawn lifecycle, remote pawn, controller, resolver
c48183b drive harness: in-process two-player verification
a28cd8d docs/tools: PORTING.md function map + drift-gate and deploy-sync checkers
3c9fd1f net: the district UDP listener (completes the port)

Verification

Run against a pristine git archive export of the branch tip into an empty directory, so nothing untracked can contribute:

dotnet build -c Release     0 Warning(s)  0 Error(s)
--selftest                  SELF-TEST PASS
--drive                     DRIVE PASS

--drive milestones: registered at World Server → WELCOME → both account sequences complete → remote pawn opened in both directions → ClientNotifyTransferComplete.

--resolver-check PASS, run in a tree with a real cooked APBGame.u configured (it needs a local client path, so it is not part of the pristine-export run): parses the package and reproduces the live C++ net indices exactly — controller ordinal 12426, holdable 15985 → 47088, storage 25478 → 56581.

Port completeness is machine-checked rather than asserted. Tools/check_port_sync.py inventories every C++ function and fails if one has no C# home: DistrictServer.cpp 175/175 mapped, 0 not-ported, 0 unmapped. PORTING.md §7 lists the only intentional exclusions — dead Unreal.cpp (not in the C++ build, zero callers) and three zero-caller raw-float helpers whose behaviour is covered inline by BitConverter.SingleToUInt32Bits. Note that --all also reports ~68 doc gaps for the ApbUdp.cpp bit/parse primitives; those are ported wholesale as the Bits//Protocol/ layers and mapped at layer granularity, so the gate exits 0 for them by design. Only a non-zero port gap count means something is genuinely unported.

What is NOT proven

No live-client session has been run against this build. The C# port is what is currently deployed in my local stack (Social 6969 / Financial 6970 / Waterfront 6971, managed apphost, all three up since 13:31:41 today), but every district log it has produced so far is from --drive — the only AUTH identities present are the harness accounts 0000000001 and 0000000002, never a real client account. So the offline gates and the simulated two-player path are green; end-to-end behaviour against the real 1.1 client is untested on this branch. That test is what should move this out of draft, and I would rather state the gap than let green CI-style output imply coverage it does not have.

Relationship to the open C++ PRs

Three behavioural fixes were found while porting and apply to both trees. #13 lands them in the C++ oracle; they are native to the C# code here, so the two implementations stay wire-identical rather than diverging:

  • Per-endpoint channel sequences instead of a global counter.
  • Possession gate keyed by client endpoint (address, port) instead of account id. The C++ used a global account-id set that was never cleared, so a reconnect of the same account from a new socket had its district-enter ASK received but never answered — insert returned false — and the client parked at "Entering district" forever.
  • Registration reply framing on the world-control link.

#14 is the WorldServer half of the registration-lifecycle work and is independent of this branch.

Review notes

Happy to split this into smaller PRs, restructure the layering, or change anything about the approach — including keeping it on the fork indefinitely if a parallel implementation is not wanted in-tree. The commit boundaries are designed so any prefix of them is a coherent, buildable subset if you would prefer to take it in stages.

Refs #12.

🤖 Generated with Claude Code

eaxiumnet and others added 7 commits August 17, 2026 14:07
Adds the .NET 8 project file, entry point (Program.cs with --selftest /
--drive / --resolver-check modes), the configuration layer (DistrictConfig,
env + INI settings), the rotating logger, and the account model + registry.
This is the first commit of the wire-identical C# port of DistrictServer.cpp.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
… field decoders

Ports the ApbUdp.cpp wire layer: LSB-first BitReader/BitWriter, the XTEA
handshake cipher (ECB/CBC x endianness), the BTEA/XXTEA district cipher
(DistrictCrypto), the UE3 packet parser + all packet/field builders, the
controller/actor field decoders, and the TCP control-link Network class.
Includes the byte-identical self-test vectors (SelfTest.cs).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
…t state, diagnostics

Ports the DistrictServer.cpp core: the AUTH/USES/WELCOME/JOIN/ANS handshake
(HandshakeService), per-endpoint channel-sequence allocation, the reliable TX
queue with retry + ACK gating, binary NMT control, transport-state tracking
with keep-alive ACK throttling, the possession/lifecycle reset hooks, and the
packet diagnostics (SaveCapture + XTEA-mode forensics). Also the world
registration + character-handoff control link (WorldControl).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
…ycle, remote-pawn, controller, package resolver

Ports the district subsystems: GRI match-start replication, the level-streaming
plan, spawn-zone markers + selection, the ACK-gated pawn bootstrap + possession,
remote-pawn multiplayer with the dead-peer lifecycle, controller field decoding
(movement / 371 / visibility / customisation replicator dispatch), and the
cooked-package net-index resolver that parses cooked .u packages directly.
Also re-includes DistrictServerCSharp/Packages in .gitignore (the root
.gitignore's NuGet `packages/` pattern matched it case-insensitively).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
Ports the --drive harness: a fake WorldServer (scratch TCP) + two fake clients
(scratch UDP) that run the full join path AUTH -> WELCOME -> JOIN -> 371
spawn-zone -> district-enter answer -> pawn-open -> all 8 ACK-gated possession
stages, verify each player sees the other's remote pawn, and drive the
customisation replicator to ClientNotifyTransferComplete. Scratch ports only;
never touches the live instances.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
…kers

Adds PORTING.md (the authoritative C++ -> C# function map, §3/§7, verification,
deploy + rollback §6) and the two gates that keep the port honest:
check_port_sync.py inventories every C++ function and asserts it has a C# home
(exits 1 on any port gap), and check_deploy_sync.py compares the deployed
DistrictServer.dll sha in the three instance folders against the build output.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <noreply@codebuff.com>
Net/UdpListener.cs is the top-level receive loop, ported from
UdpListenerThread in DistrictServer.cpp. It was the one file still
missing from the port branch, and it is load-bearing: Program.cs
(Startup/Main) and Drive/DriveHarness.cs both construct it, so the
branch did not compile without it.

Responsibilities:

- Binds the configured UDP port and owns the recvfrom loop.
- Three-pass decrypt/parse mirroring the C++ order. The first AUTH
  bunch is plaintext; everything after is BTEA-encrypted with the
  account session key. Decrypt-then-parse runs FIRST for a keyed
  endpoint, because a ciphertext datagram can decode as a plausible
  plaintext packet by chance and would silently discard a real
  message.
- Constructs and wires the service graph (handshake, reliable queue,
  GRI startup, spawn zone, streaming, pawn lifecycle, controller
  feedback, remote pawn, binary control, transport state), including
  the two cycles that need post-construction injection:
  PawnLifecycleService.RemotePawnReplication and the Lifecycle reset
  delegates.
- Fixed dispatch order per received packet: AUTH, reliable-ACK
  retirement, post-possession unlock, hard-landing recovery,
  bMatchHasBegun, reliable retry, transport ACK, customisation
  replicator, controller actor feedback, possession gate, text
  control, binary control.
- Possession gate keyed by client endpoint (address, port) rather
  than account id. The C++ used a global account-id set that was
  never cleared, so a reconnect of the same account from a new
  socket had its district-enter ASK received but never answered
  (insert returned false) and the client parked at "Entering
  district" forever. A fresh connection now gets its own entry and
  re-runs the answer.
- WSAECONNRESET (dead-peer ICMP port-unreachable) is attributed to
  the last-pushed remote-pawn viewers for dead-peer cleanup and
  logged at most once per 10 s instead of once per send cycle.

Also ignore _drive_scratch/ for parity with _scratch_drive/; both
directory spellings have been used by local drive runs.

Verified in a clean worktree: dotnet build -c Release reports 0
warnings / 0 errors, and all three offline modes pass without a
client or a live stack --
  --selftest       SELF-TEST PASS (AUTH bunch round-trip, ACK,
                   challenge)
  --resolver-check RESOLVER-CHECK PASS (controller ordinal 12426,
                   holdable 15985, inventory 25478)
  --drive          DRIVE PASS (registered at World Server, WELCOME,
                   two-account sequences complete, remote pawn
                   opened both directions, ClientNotifyTransferComplete)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four are silent behaviour differences against the C++ oracle, not
compile errors. Verified against DistrictServer.cpp line by line.

1. HandshakeService.ProtectOutgoingPacket aliased the caller's buffer
   when the packet was already word-aligned and >= 8 bytes, so
   DistrictCrypto.Encrypt (in place) turned the caller's plaintext into
   ciphertext. The oracle takes its packet BY VALUE
   (DistrictServer.cpp:1412), so the caller's buffer is never touched
   there. ReliableQueue stores that same array as
   PendingServerReliable.ClearPacket (ReliableQueue.cs:50) and re-sends
   it on retry (:158), which then encrypted already-encrypted bytes.
   Deterministic for spawn-zone / HUD-marker labels, which this build
   never ACKs. Fix: copy unconditionally.

2. JOIN never cleared per-account controller feedback state, so a
   reconnect reusing the same account id inside one process kept the
   previous startup/marker barrier: field 372 sent no fresh marker and
   the player was never possessed. The oracle erases it on JOIN
   (DistrictServer.cpp:13137-13142). Lifecycle.ResetControllerFeedback
   was already wired (Lifecycle.cs:24, UdpListener.cs:83) but had no
   call site; this adds it.

3. ControllerFeedbackService's OnMarkersSent handler indexed
   _feedbackStates directly. Unlike std::map::operator[], the .NET
   indexer getter throws KeyNotFoundException -- straight out of the UDP
   receive loop -- when no controller field has been decoded yet. Fix 2
   makes that window reachable, so both ship together. Now goes through
   the existing GetOrCreateState.

4. The spawn-location latch was gated on IsActionDistrict and handled
   only the by-channel reference form. The oracle gates it on
   firstReceipt alone and accepts two forms: the bridge channel
   (action) and the static spawn-zone template NetIndex 72913/72914
   (Social) -- DistrictServer.cpp:12474-12506. On Social the latch
   therefore never ran, so PawnLifecycleService/RemotePawnReplication
   found no selected location and the pawn ignored the chosen zone.
   Ports TryGetSpawnZoneLocation (the oracle's Social branch, cpp:397)
   to DistrictConfig and restores the oracle's structure.

Also drops the dead DistrictCrypto.ProtectOutgoingPacket duplicate,
which had zero callers and diverged twice from the oracle (rejected
< 8 bytes the oracle zero-pads; its slice threw on unaligned input),
and repoints Tools/check_port_sync.py at the real port in
HandshakeService so the drift gate stops crediting the dead copy.

Divergence 4 was found by that gate once it pointed at the right
function: it now reports 160/160 mapped, zero doc gaps, zero port gaps.
Build is clean (0 warnings). Not yet exercised against a live client.

Refs burber#12

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@eaxiumnet

Copy link
Copy Markdown
Contributor Author

Pushed 75f7d5c: four C++/C# fidelity divergences, all silent behaviour
differences rather than compile errors. Each was checked against
DistrictServer.cpp directly.

# Divergence Oracle anchor Effect before the fix
1 ProtectOutgoingPacket aliased the caller's buffer instead of copying it :1412 (packet passed by value) DistrictCrypto.Encrypt is in place, so the caller's plaintext became ciphertext. ReliableQueue stored that array as ClearPacket and re-encrypted it on retry — deterministic for spawn-zone / HUD-marker labels, which this build never ACKs. First send was always correct, every resend was garbage.
2 JOIN never cleared per-account controller feedback state :13137-13142 A reconnect reusing the same account id kept the previous startup/marker barrier, so field 372 sent no fresh marker and the player was never possessed. Lifecycle.ResetControllerFeedback was already wired but had no call site.
3 OnMarkersSent used a raw Dictionary indexer n/a (C++→C# semantics) Unlike std::map::operator[], the .NET indexer getter throws KeyNotFoundException — straight out of the UDP receive loop. Fix 2 makes that window reachable, so the two had to ship together.
4 Spawn-location latch gated on IsActionDistrict, by-channel reference form only :12474-12506, helper at :397 The oracle gates the latch on firstReceipt alone and accepts two reference forms: the bridge channel (action) and the static spawn-zone template NetIndex 72913/72914 (Social). On Social the latch never ran at all, so PawnLifecycleService / RemotePawnReplication found no selected location and the pawn ignored the chosen zone.

Divergence 4 is worth calling out separately: it was not in the audit. It
surfaced only after Tools/check_port_sync.py was repointed at the real port.
The gate had ProtectOutgoingPacket mapped to a dead
DistrictCrypto.ProtectOutgoingPacket duplicate (zero callers, and it diverged
from the oracle twice on its own — rejecting < 8 bytes the oracle zero-pads,
and a slice that threw on unaligned input). That dead copy is gone and the
mapping now names HandshakeService. With the gate honest it reported
TryGetSpawnZoneLocation as a genuine port gap, which is divergence 4.

Gate now: 160/160 mapped, 0 doc gaps, 0 port gaps. Build clean, 0 warnings.

Not yet exercised against a live client — these are source-fidelity fixes
verified against the oracle and the drift gate, not observed client behaviour.
The two that most want a live check are 2 (rejoin without restarting the
district) and 4 (select a Social spawn zone and confirm the pawn lands on it
rather than a fallback).

Audit of the C++ oracle turned up three documentation claims that assert a
C++ counterpart which does not exist. None of them changes shipped
behaviour, but each one would have mislead the next person diffing the two
implementations.

1. BitWriter.WriteCompressedRotator said "Verified round-trip against the
   C++ (yaw 16384 reconstructs to 16384)". There is no C++ rotator writer to
   round-trip against: grep for "Rotator" across DistrictServer/ returns
   nothing. The oracle has WriteCompressedVector (ApbUdp.cpp:262) and a
   single AimRotation int READ (ApbUdp.cpp:2410), and neither side of the
   port has a rotator reader, so no round-trip harness exists anywhere.
   Relabelled PORT-ORIGINAL: the encoding is client-RE-derived and
   hand-checked only, not yet exercised against a live client. The encoding
   description itself is unchanged.

2. PORTING.md §3.8 listed BuildUnreliableActorVectorFieldPacket and
   BuildUnreliableActorRotatorFieldPacket in the C++ column. Both are
   phantoms. The oracle has exactly one unreliable field builder,
   BuildUnreliableActorFloatFieldPacket (ApbUdp.cpp:1221, called from
   DistrictServer.cpp:10753) — which had no row at all. Now mapped, with the
   vector/rotator builders marked as port additions for the remote-pawn path.

3. PORTING.md §7 item 2 listed BuildActorRotatorFieldPacket "(zero C++
   callers)" — also a phantom — and claimed the raw-float paths are "unused
   by the port". FloatToWireBits (:1828) and WriteFloatBits (:2185) both have
   live C++ callers (PRI stats :1867/:1873, non-compressed HUD-marker
   location :2264-:2266) and both ARE mirrored, inline via
   BitConverter.SingleToUInt32Bits at four C# call sites. Reworded to say so,
   and to explain that they stay in §7 only because there is no named C# home
   to point a §3 row at.

Removing the phantom from §7 makes the gate's own arithmetic add up: it
listed 8 not-ported entries but only ever matched 7. Gate after:

  default  MAPPED 160, NOT-PORTED 0, UNMAPPED 0 (0 doc, 0 port gaps)
  --all    MAPPED 161, NOT-PORTED 7, UNMAPPED 61 (61 doc, 0 port gaps)

MAPPED 160 -> 161 and doc gaps 62 -> 61 are the newly-documented float
builder; the stale "~68" figure in §7 is now the measured 61. Build clean
(0 warnings, 0 errors). Docs and comments only — no code paths touched.

Refs burber#12
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@eaxiumnet

Copy link
Copy Markdown
Contributor Author

Provenance audit: three doc claims asserting a C++ counterpart that does not exist

Follow-up to the fidelity pass above (048552e). While checking one comment I could not reproduce, I found three claims in this branch's own docs that point at C++ code which isn't there. All three are docs/comments — no code path changed, build clean.

# Claim Reality
1 BitWriter.WriteCompressedRotator: "Verified round-trip against the C++ (yaw 16384 reconstructs to 16384)" grep -r Rotator DistrictServer/nothing. The oracle has WriteCompressedVector (ApbUdp.cpp:262) and one AimRotation int READ (:2410). Neither side has a rotator reader, so there is no round-trip harness anywhere.
2 PORTING.md §3.8 C++ column: BuildUnreliableActorVectorFieldPacket, BuildUnreliableActorRotatorFieldPacket Both phantoms. The oracle has exactly one: BuildUnreliableActorFloatFieldPacket (ApbUdp.cpp:1221DistrictServer.cpp:10753) — which had no row at all.
3 PORTING.md §7.2: BuildActorRotatorFieldPacket "(zero C++ callers)" + "the raw-float paths are unused by the port" Phantom again; and FloatToWireBits (:1828) / WriteFloatBits (:2185) have live callers — PRI stats :1867/:1873, non-compressed HUD-marker location :2264-:2266. Both are mirrored, inline via BitConverter.SingleToUInt32Bits (PacketBuilders.cs:405, :583-:585, :918-:920, :1180-:1182).

Fixes: #1 relabelled PORT-ORIGINAL — encoding is client-RE-derived and hand-checked only, explicitly not yet exercised against a live client (the encoding description itself is untouched, it's the verification claim that was wrong). #2 the real builder is now mapped, with the vector/rotator ones marked as port additions for the remote-pawn path. #3 reworded to state the live callers and that they stay in §7 only because there's no named C# home for a §3 row.

Side effect worth noting: deleting the §7 phantom makes the gate's arithmetic add up — it had been listing 8 not-ported entries while only ever matching 7.

default  MAPPED 160, NOT-PORTED 0, UNMAPPED  0  (0 doc,  0 port gaps)
--all    MAPPED 161, NOT-PORTED 7, UNMAPPED 61  (61 doc, 0 port gaps)

MAPPED 160 → 161 and doc gaps 62 → 61 are the newly-documented float builder. The stale ~68 in §7 is now the measured 61.

Net effect of this and the previous commit: the port's own docs no longer claim verification that was never performed. The one place a live-client check would now buy the most is WriteCompressedRotator — it is the only wire encoding in the tree with no C++ counterpart and no reader to check itself against.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant