Campaign N Slice N1 (docs/plans/2026-07-29-network-transport-campaign.md
S2.1) - the direct #260 fix: every sent reliable packet is now cached and
re-emitted, header-rebuilt, when ACE NAKs a client-sequence gap. One lost
C2S datagram no longer voids every subsequent action for the session's
lifetime.
New src/AcDream.Core.Net/Transport/:
- TransportClock: injectable monotonic source + retail's 0.5 s interval
counter (ClientFlowQueue::IncrementLocalInterval @ 0x00547F10, tail
`intervalID_ += elapsed`; the same function's ~3 s TimeSync/Echo cadence
stays deferred per TS-58).
- SequenceMath: wrap-safe IsNewer/Max (TimeStampUtils::lhs_newer
@ 0x00543890, reduced to the signed-difference form).
- SentPacketStore: FIFO of ArrayPool-rented wire buffers; Add asserts
optionalLength == 0 (NetPacket::RemoveDisposableOptionalHeaders
@ 0x00549510 pinned as a no-op under standalone-control); FlushOlderThan
pops strictly-older wrap-safe (SentPacketStore::AddSentPacket
@ 0x0054AB00, Flush @ 0x0054ACD0).
- OutboundFlowQueue: owns the outbound ISAAC, highestIDSent (starts 1,
pre-increment, wrap 0xFFFFFFFF->1, never 0), the fragment sequence, the
store, the wrap-safe sorted dedup pending-resend list
(FlowQueue::EnqueueAcks @ 0x005488E0), and the flushNum_ ack watermark.
Cache commit happens AFTER a successful send
(FlowQueue::TransmitNewPackets @ 0x00547A60, commit site 0x00547C85).
NAK ids[0] folds into the watermark as retail's implicit cumulative ack
(RecipientData::ProcessNaks @ 0x00547010). A resend rebuilds ONLY the
20-byte header: flags Retransmission|EncryptedChecksum (|BlobFragments
with fragments), Time = current interval id, Sequence/Id/Iteration/
DataSize verbatim, checksum = fresh header hash + stored sealed checksum
(FlowQueue::TransmitAcks @ 0x005485B0, DequeueAck @ 0x005472F0). The
original ISAAC key rides inside the sealed value - no new keystream word
is ever drawn (CryptoSystem::EncryptData @ 0x0065FF40 non-null-key
path; landmines #1/#2). Resend only on explicit NAK (landmine #3).
- ReliableTransport: composition + Sweep() (interval clock, resends,
prune). The AckNakScheduler joins in N3/N4; ack behavior is untouched
this slice.
- TransportStats: unconditional counters (ResendsSent,
NakRequestsReceived, UncachedNakIds, AcksConsumed) + CacheDepth.
PacketCodec.FinalizeInPlace gains an overload returning (isaacKeyUsed,
sealedChecksum) where sealedChecksum is the pre-header-hash value -
payloadHash cleartext, isaacKey ^ payloadHash encrypted (retail
NetPacket::checksum_). The old signature forwards; encode bytes are
unchanged. Decode is untouched.
WorldSession integration is minimal: the transport is constructed at
ISAAC-seeding time (first reliable packet keeps sequence 2 / fragment 1,
byte-identical to pre-N1); SendGameMessage delegates (probe fseq/pseq now
read the transport); SendAck's borrowed sequence reads HighestIdSent
(identical value, behavior EXACTLY as-is this slice); ProcessDatagram
consumes RequestRetransmit + AckSequence BEFORE the unchanged reflex ack;
the sweep runs at the end of Tick() after the budget break AND inside
both blocking handshake pump loops (Connect step 4, EnterWorld
ServerReady - landmine #8), gated on _transportNegotiated; Dispose
returns the rented cache buffers.
Bookkeeping: TS-57 filed in the divergence register (uncached NAK ids
dropped silently + counted instead of retail's RejectRetransmit - ACE
no-ops the reject and the standalone unsequenced form would trip ACE's
watermark hole); TS-27 narrowed to the inbound direction in the same
commit; the stale WorldSession class-doc gap list corrected.
N0 fold-ins from the re-review: AceSessionModel.ProcessFragment split
into ACE's two literal branches (existing-buffer checks Complete,
NetworkSession.cs:495-507; new-buffer constructs + adds + TryAdds WITHOUT
checking Complete, :509-518), and the zero-count-fragment test now pins
the parked dead buffer (PartialFragmentBufferCount 0 -> 1). e3958610
recorded in the campaign ledger's N0 row.
Tests: 15 new in Transport/OutboundReliableTransportTests.cs - store
FIFO/strict/wrap-safe flush with rent/return balance via a counting
pool, interval-clock start/advance/wrap, resend header shape (flags
exactly 3 or 7, Time = interval, verbatim fields, checksum identity,
bit-identical body), resend-consumes-no-ISAAC-word, uncached-NAK
counting, ids[0] watermark fold + strict prune, wrap-safe ack max,
conformance resend verifying under AceCryptoModel with the ORIGINAL
parked key (Headroom 256, zero orphans, ordering restored), an
end-to-end FakeAceTransport lossy run (10 game actions, C2S #5 dropped,
all 10 dispatched in order, exactly one resend, session alive), and
zero-alloc steady-state SendGameMessage.
Gates: dotnet build green; AcDream.Core.Net.Tests 702/702; full-solution
Release 9,723 passed / 5 skipped / 0 failed; connected world-lifecycle
gate vs local ACE RESULT=PASS (0 failures, both sessions exit 0; one
pre-existing expected world-edge landblock-miss warning).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
15 KiB
Campaign N — Retail-Faithful Network Transport
Status: ACTIVE (approved 2026-07-29). Slices N0–N6.
acdream cannot survive a single lost UDP packet in either direction. This
campaign ports retail's reliable-transport mechanism (the Sept 2013 named
decomp is the oracle; ACE and holtburger are cross-checks) so live-server
sessions survive real packet loss. Root-cause record: docs/ISSUES.md #260.
Read this before touching src/AcDream.Core.Net/.
1. Why (the two fatal bugs)
- Outbound (the #260 wedge): no sent-packet cache, no resend. ACE NAKs a
client-sequence gap with
RequestRetransmit; we parse the list and consume it nowhere. One lost C2S datagram → ACE buffers everything after the gap forever → all actions void, position updates void → ACE stops streaming new areas (the #256 "invisible portals") while the session stays alive on cleartext acks. Reproduced twice on Coldeve 2026-07-29 with theACDREAM_PROBE_NETprobe (artifacts/coldeve-probe-20260729/, local). - Inbound (found at design time):
PacketCodec.TryDecodeBorrowedconsumes the inbound ISAAC word BEFORE comparing. One lost S2C packet shifts the keystream permanently; every later encrypted packet fails checksum and burns another word. Inbound silent forever.
Loopback ACE never drops packets — every historical gate was structurally blind to both bugs. Slice N5's loss gate removes that blindness permanently.
2. The retail mechanism (port target)
Owners: inbound = ReceiverData (per-connection, in SharedNet::receivers_);
outbound = RecipientData + ClientFlowQueue + SentPacketStore (under
PacketController). Both pumped once per frame from Client::UseTime @ 0x00411C40 (receive: SharedNet::UseTime @ 0x00542450; send:
PacketController::UseTime @ 0x005410D0).
2.1 Outbound resend
- Every reliable packet (fragments aboard) is cached AFTER a successful send
(
FlowQueue::TransmitNewPackets @ 0x00547A60→SentPacketStore:: AddSentPacket @ 0x0054AB00), with disposable optional headers stripped first (NetPacket::RemoveDisposableOptionalHeaders @ 0x00549510). Under our standalone-control design (§4) reliable packets never carry optional headers, so the strip is a provable no-op — assertoptionalLength == 0inSentPacketStore.Add. - Server
RequestRetransmit(0x1000) → merge-insert ids wrap-safe sorted (FlowQueue::EnqueueAcks @ 0x005488E0);ids[0]doubles as an implicit cumulative ack (RecipientData::ProcessNaks @ 0x00547010). Ids no longer cached → retail answersRejectRetransmit; we drop silently (row TS-57). - Resend (
FlowQueue::TransmitAcks @ 0x005485B0/DequeueAck @ 0x005472F0): re-emit with a REBUILT 20-byte header — flagsRetransmission|EncryptedChecksum(=3; |BlobFragments=7 with fragments),Time= the CURRENT interval id,Sequence/DataSizeverbatim, checksum = fresh header hash + stored sealed checksum. Body bytes untouched; the original ISAAC key is reused (CryptoSystem:: EncryptData @ 0x0065FF40takes the key as an optional in/out — null draws, non-null reuses). Never a new keystream word. AckSequence(0x4000) inbound → wrap-safe max watermark → prune strictly older (SentPacketStore::Flush @ 0x0054ACD0, pops from FIFO head whileseqNum < watermark). No timer-based resend exists — resend only on explicit NAK.- Sequence allocation:
highestIDSent_starts 1;++; wrap 0xFFFFFFFF → 1 (never 0). Unsequenced packets reuse the current watermark, cleartext, no ISAAC word. - Pump order per frame (
FlowQueue::Empty @ 0x00548A20): NAKs → retransmits → new packets.
2.2 Inbound sequencing + ISAAC discipline
No transport reorder buffer — packets process on arrival
(SharedNet::ProcessPacket @ 0x00544790 → ProcessNewSeqNum @ 0x00544690):
SeqIDSanityCheck @ 0x00543A20: drop if seq is newer thanhighestIDReceived_ + 0x7FFF(wrap-safe).- Encrypted + not newer than the watermark = duplicate/late arrival: remove from the NAK set — hit → decrypt with the parked pre-drawn key; miss → drop silently at zero keystream cost.
- Newer than the watermark → gap walk (
ProcessNewestSeqNum @ 0x00541930): for each missing id (skip 0)AddNakked(id, null)— pre-draws one inbound ISAAC word per missing id, in sequence order, BEFORE the arriving packet's own key, parking it beside the id (ReceiverData::AddNakked @ 0x00549240, idempotent). Cleartext packets walk toseq+1(they borrow an already-delivered sequence). Then watermark = seq. - Encrypted packets then decrypt with the parked key or the next drawn word.
- Parse failure on a sequenced packet → re-park its consumed key
(
AddNakked(seq, &key)), so the retransmission decodes. - Inbound
RejectRetransmit(SharedNet::HandleEmptyAck @ 0x005448F0) → remove ids from the NAK set, silent abandonment.
This keeps the inbound keystream aligned to SEQUENCE order, not arrival order — the invariant the whole inbound port hangs on.
2.3 Ack/NAK sweep
Per-frame per-connection sweep (ClientNet::ProcessConnection @ 0x00545450),
mutually exclusive on ONE shared timestamp (ReceiverData::timeStamp_):
- NAK set non-empty →
RequestRetransmit: ≤114 ids ascending, cleartext, 0.6 s gate (SharedNet::EnqueueNaks @ 0x00543BD0,ReceiverData::GetNaks @ 0x005490C0), and NO ack this sweep. - Else → one cumulative
AckSequencecarryinghighestIDReceived_, unsequenced + cleartext, 2.0 s gate (SharedNet::EnqueuePak @ 0x00543B10— the ONLY 0x4000 construction site in the binary; retail never acks per-packet).
2.4 Keepalive/time/flow (mostly deferred — §5)
TimeSync (8-byte double) + EchoRequest (4-byte float) every ~3 s
(ClientFlowQueue::IncrementLocalInterval @ 0x00547F10); inbound TimeSync
adopts the server clock; Flow report sent on remote-interval advance;
inbound Flow ignored, no throttle (WireRoomLeft is a folded return 1 —
verified); dead link at 140 s without inbound data (self-stall guarded) with
referral auto-reconnect; 0.5 s interval clock feeds ProtoHeader::interval_
(our PacketHeader.Time).
2.5 Constants
464 max payload after the 20-byte header · 448 fragment payload · 114 NAK
list cap · +0x7FFF sanity window · 0.5 s interval tick · 2.0 s ack gate ·
0.6 s NAK gate · ~3 s TimeSync/Echo · 140 s dead link · 10 s disconnect
drain · 0xBADD70DD checksum placeholder · sequence init 1, wrap
0xFFFFFFFF→1 · 0.333 s ConnectResponse handshake resend.
3. ACE constraints (Coldeve runs ACE; references in the MAIN repo checkout)
| Constraint | Source | Consequence |
|---|---|---|
NAKs client gaps at expected+2, 1 s limit, arrival-driven ONLY |
NetworkSession.cs:351-363 | after one C2S loss two more sends must arrive before ACE NAKs; a quiet client is never NAKed |
| Crypto search window 256 keys; re-key orphans permanently | CryptoSystem.cs:30-49 | NEVER re-key a resend; NEVER resend an already-accepted packet |
Watermark advances on ANY packet with flags ≠ exactly AckSequence |
NetworkSession.cs:474-476 | standalone unsequenced control packets other than exact-AckSequence/exact-RequestRetransmit can skip a REAL packet forever (the self-induced wedge) |
| Honours only CLEARTEXT NAKs; encrypted NAKs silently ignored | NetworkSession.cs:283-284 | no NAK piggybacking; NAKs never refresh ACE's 60 s timeout |
Ack-only dedup exemption requires flags == AckSequence exactly |
NetworkSession.cs:342-343 | equality check, not HasFlag |
| ACE never proactively resends S2C; S2C cache prunes at 120 s | NetworkSession.cs:675-708, :251-262 | the client MUST NAK or inbound stalls; old NAKs get RejectRetransmit |
| C2S fragment sequence strictly contiguous or dispatch stalls silently | NetworkSession.cs:532-543 | packet-level retransmission heals it automatically |
| ACE sends TimeSync/20 s, cumulative ack/2 s, EchoResponse on request; NO disconnect/error packets ever | NetworkSession.cs:207-216, grep | every transport death is silence |
| Rejects AckSequence/TimeSync/EchoRequest/Flow during AuthLoginRequest | Session.cs:101-102 | no sweep before negotiation completes |
Inbound Header.Time ignored |
grep | our interval field is cosmetic against ACE — safe |
4. Design
src/AcDream.Core.Net/Transport/ — retail's split adapted to one login + one
world connection on one frame thread. TransportClock (injectable monotonic
source + 0.5 s interval counter), SequenceMath (IsNewer(a,b) => unchecked((int)(a-b)) > 0), SentPacketStore (FIFO of rented wire buffers),
OutboundFlowQueue (outbound ISAAC, HighestIdSent, fragment seq, pending
resends, NAK/ack consumption), InboundSequenceTracker (inbound ISAAC,
HighestIdReceived, NAK set SortedDictionary<uint,uint> seq→parked key),
AckNakScheduler (ONE shared timestamp; per sweep NAK xor ack),
ReliableTransport (composition + Sweep()), TransportStats
(unconditional counters; printing probe-gated).
Cache entry: { Sequence, rented Buffer (header+body), BodyLength, SealedChecksum (= payloadHash ^ isaacKey — retail NetPacket::checksum_), IsaacKey, HasFragments }. Resend rewrites ONLY the header per §2.1;
PacketCodec.FinalizeInPlace gains an overload returning
(isaacKeyUsed, sealedChecksum); the old signature forwards.
Inbound: codec splits pure-parse (TryParseBorrowed — no keystream) from
VerifyChecksum(header, headerHash, payloadHash, uint? isaacKey); the
tracker owns every key decision (retail's own factoring — EncryptData's
optional key parameter). TryDecodeBorrowed(datagram, IsaacRandom?) is
deleted; owned TryDecode stays (test-only).
WorldSession keeps its entire ~60-method public surface; the two
SendGameMessage overloads delegate to the transport; GameActionCapture
is untouched. Sweep() runs at the end of Tick() (after the budget break)
AND inside the blocking handshake pump loops (the EnterWorld flood arrives
before Tick ever runs), gated on _transportNegotiated. Cache is
unbounded like retail (ACE acks every 2 s; steady state is tens of entries);
cache=N in [net-tick] is the watchdog, not a silent cap.
5. Scope deferrals (divergence-register rows, filed in-slice)
- TS-57 no outbound RejectRetransmit — ACE no-ops it; the standalone unsequenced form trips the watermark hole (§3 row 3).
- AP-125 standalone control packets only, no
CoalesceDatapiggyback — piggybacked NAKs become encrypted, which ACE ignores. - TS-58 no TimeSync/EchoRequest keepalive — standalone-unsafe against the watermark hole; our 2 s ack already refreshes ACE's 60 s timeout.
- TS-59 no Flow report — ACE parses and has no handler.
- AD-49 blob-layer ephemeral ordering stamps not ported — provably a
no-op against ACE (fragment
Idis constant 0x80000000 and the stamp table keys on ACE's per-message-unique fragment sequence, soFragIsObsoleteEmphemeral @ 0x0054A450can never fire). - TS-60 no 140 s dead-link/referral auto-reconnect — LinkStatus exposes the input; reconnect is Runtime's, its own campaign.
- TS-61 UDP send-failure burns the sequence+key (retail retries from the queue head) — effectively unreachable.
- AP-126 one monotonic clock for all gates (retail's cur/local split immaterial to the gates we port).
- Multi-fragment outbound (>448 B) stays unimplemented (nothing sends >448; existing behavior, keep its row current).
6. Slices
| # | Slice | Size | Parallel? | Review | Gate |
|---|---|---|---|---|---|
| N0 | ACE-behaviour test double + virtual clock + lossy link | M | yes (test-only) | Opus | unit |
| N1 | Outbound cache + resend on NAK (the #260 fix) | L | no | Fable | unit + local ACE lifecycle |
| N2 | Inbound sequence-aligned ISAAC + NAK set | L | no | Fable | unit + lifecycle + nine-stop |
| N3 | AckNakScheduler + 2.0 s cumulative ack | M | no | Opus | unit + lifecycle + nine-stop |
| N4 | Client NAK emission + RejectRetransmit consumption | M | no | Opus | unit + local ACE + loss gate |
| N5 | Observability + LossyTransportDecorator + connected loss gate | M | partly | Opus | unit + loss gate |
| N6 | Optional: ConnectResponse 0.333 s retransmit; assembler TTL | S | yes | Opus | unit + local ACE |
N1–N5 all touch WorldSession.cs: strictly sequential, ONE agent at a time.
Implementers Sonnet; a redo escalates to Opus; every diff reviewed by the
Review-column model before commit. Key per-slice test specs are in the
approved plan (~/.claude/plans/mac-os-is-not-robust-sphinx.md) and travel
verbatim in each implementer prompt.
7. Landmines (verbatim in every implementer prompt)
- Resends are NOT byte-identical — flags gain
Retransmission,Timeadvances, the header hash MUST be recomputed; checksum = new header hash- stored sealed checksum. A verbatim resend fails ACE's CRC silently.
- Never draw a new ISAAC word on a resend — one re-key permanently orphans a key in ACE's 256-entry window.
- Never resend unrequested — a duplicate of an accepted packet burns up to the whole window.
- The inbound gap-walk draws parked keys BEFORE the arriving packet's own key, in sequence order — reversed, the stream is off by the gap size forever.
- Ack flags are an EQUALITY check (
Flags == AckSequence), never HasFlag. - NAKs are cleartext (
Flags == RequestRetransmitexactly) or ACE ignores them; NAKs do not refresh ACE's timeout. - The 0.6 s NAK gate and 2.0 s ack gate share ONE timestamp — a NAK delays the next ack and vice versa; never both in one sweep.
- The sweep must also run inside the blocking handshake pump loops — the
EnterWorld CreateObject flood precedes the first
Tick(). - ACE/holtburger reference sources live in the MAIN repo checkout
(
C:\Users\erikn\source\repos\acdream\references\), not in worktrees.
8. Verification ladder
- Per slice:
dotnet build+ fulldotnet test -c Release+ slice gate + one commit (retail symbol+address citations) + model review + revert SHA recorded here. - N1–N5:
tools/run-connected-world-lifecycle-gate.ps1vs local ACE; N2/N3 add the canonical nine-stop route. - From N4:
tools/run-connected-loss-gate.ps1atACDREAM_NET_DROP_PCT=2vs local ACE — passes only with NON-ZERO resend/NAK counters. - Final: one user Coldeve session past the 15-minute wedge horizon with
portal churn, after N5.
[net-tick] nak-in/sbecomes the first direct measure of real C2S loss the project has had. - Rollback: one commit per slice,
git revert; no runtime kill switch.
9. Slice ledger
| Slice | Status | Commit | Notes |
|---|---|---|---|
| N0 | complete | 7e9134b4 + e3958610 |
ACE-behaviour double + virtual clock + lossy link; the review fix-up added the Session.CheckState inbound gate, faithful SendBundle coalescing/splitting, two-phase termination, an ACE-loose C2S fragment parse, and ACE's MessageBuffer edge cases. 687 Core.Net tests green. N1 folded in the re-review's ProcessFragment two-branch split (existing-buffer checks Complete; new-buffer parks without checking — the zero-count buffer stays parked). |
| N1 | complete | (this slice's commit; SHA recorded at N2 kickoff) | Outbound sent-packet cache + resend on NAK (Transport/: TransportClock, SequenceMath, SentPacketStore, OutboundFlowQueue, ReliableTransport, TransportStats); PacketCodec.FinalizeInPlace sealed-checksum overload; WorldSession sweep in Tick + both handshake pump loops; TS-57 filed, TS-27 narrowed to inbound-only. |
| N2 | pending | — | |
| N3 | pending | — | |
| N4 | pending | — | |
| N5 | pending | — | |
| N6 | pending | — |