Campaign N Slice N5 (docs/plans/2026-07-29-network-transport-campaign.md
section 8 rung 3): the permanent removal of the loopback blindness that let
#260 ship. Local ACE never drops a datagram, so every historical connected
gate was structurally incapable of exercising the N1-N4 recovery machinery;
from this slice on, tools/run-connected-loss-gate.ps1 runs the standard
lifecycle route through deterministic seeded loss and passes only on proven
non-zero recovery.
Observability:
- [net-tick] gains resend/s nak-out/s nak-in/s rej-in/s dup-drop/s parked/s
reclaim/s cache= nakset= - TransportStats window deltas mirroring the
acks/s cumulative-delta pattern, plus the two instantaneous depths (the
unbounded-like-retail sent-packet cache watchdog and the inbound NAK set).
TransportStats gains RejectsReceived (inbound RejectRetransmit packets).
Counters increment unconditionally; every string is behind
NetDiagnostics.ProbeNet (Code Structure Rule 5).
- WorldSession.Dispose emits one cumulative [net-final] totals line so the
loss gate asserts exact counters instead of reconstructing them from
rounded per-second rates.
- LinkStatusSnapshot.PacketLossPercentage is deliberately NOT wired: filed
#261 - retail's CLinkStatusAverages formula
(LinkStatusHolder::GetPacketLossPercentage @ 0x00411370) must be located
first; inventing a ratio is forbidden.
N4-review F3 fold-in:
- Fresh reliable sends stamp Header.Iteration = the session iteration
through the same shared retail header build already cited for Time (N3)
and the N4 control packets: FlowQueue::TransmitNewPackets @ 0x00547A60,
the stack build at 0x00547A84/0x00547AA8. The control-header rule now
holds across all three send shapes (fresh reliable, ack, NAK). ACE reads
neither Time nor Iteration inbound (campaign section 3) - wire-safe, and
resends keep the stamp verbatim per the N1 rebuild rule.
Loss injection (Transport/LossyTransportDecorator):
- IWorldSessionTransport wrapper with deterministic seeded per-direction
loss. Config via NetDiagnostics typed env properties read once:
ACDREAM_NET_DROP_PCT (0 = off = default), ACDREAM_NET_DROP_SEED (default
1), ACDREAM_NET_DROP_DIR (out|in|both, default both).
- Arming gate: NOTHING drops in either direction until the decorator has
FORWARDED the first ENCRYPTED outbound datagram - parse-free check on
length > 20 with EncryptedChecksum set in the LE flags word at bytes
4..8. The cleartext handshake always survives and the arming datagram is
never a casualty; handshake-loss testing belongs to N6's ConnectResponse
0.333 s retransmit.
- Structurally absent at 0%: WrapIfConfigured returns the raw transport -
WorldSession's default factory is the only production seam and a normal
run never constructs the decorator.
Root-cause fix the gate immediately exposed:
- The logoff-confirmation wait in Dispose processed inbound datagrams but
never pumped the transport, so a lost S2C logoff confirmation was
gap-detected but its healing NAK never went out. Retail's pump
(Client::UseTime @ 0x00411C40 -> PacketController::UseTime @ 0x005410D0)
runs until LogOffServer; the wait now sweeps per processed datagram,
making the logoff wait the third covered blocking pump (after Tick and
the handshake loops). A lost C2S logoff REQUEST remains unrecoverable by
ACE design (arrival-driven NAK; a quiet client is never NAKed - campaign
section 3 row 1), recorded in the gate header.
Gates:
- tools/run-connected-loss-gate.ps1 (-DropPct 2 -Seed 1): PASS vs local
ACE - the first automated observation of packet loss in project history.
Decorator ledger: dropped out=3 in=10 of forwarded out=183 in=496.
[net-final] resends=2 nak-in=2 nak-out=6 rej-in=0 acks-out=114
acks-in=119 dup-drop=0 sanity-drop=0 cksum-fail=0 parked=9 reclaimed=0
uncached-nak=0 cache=1 nakset=0. Every injected loss healed: both
ACE-driven C2S resend recovery (nak-in=2 -> resends=2) and client-driven
S2C NAK recovery (parked=9 -> nak-out=6) fired on a real connected
route, all six checkpoints validated, graceful logout confirmed, ACE
recorded the transport Disconnect.
- tools/run-connected-world-lifecycle-gate.ps1 (decorator absent): PASS -
zero behavior change on the no-loss baseline; the gate now defensively
clears the drop env vars.
- Core.Net Release: 747/747 (737 + 10 N5: decorator determinism/direction/
arming/structural-absence/env parsing, the 5% seeded WorldSession lossy
lifecycle with zero message loss both ways + ACE Headroom 256, the
[net-tick] field pins, the Iteration stamps).
- Full solution Release: 9,763 passed / 5 skipped / 0 failed.
Test-fixture note: FakeAceTransport gains AutoAdvanceOnBlockingReceive so
virtual time can move during the blocking Connect()/EnterWorld() pumps -
with the clock frozen there, a dropped handshake-window datagram could
never be NAK-healed (a fixture artifact, not a transport property).
Campaign section 9 ledger row added (SHA recorded at N6 kickoff).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Campaign N slice N3. Retail never acks per packet: SharedNet::EnqueuePak
@ 0x00543B10 is the binary's only AckSequence (0x4000) construction site,
gated at >= 2.0 s on ReceiverData::timeStamp_ (@ +0x10), armed at
connection birth by ReceiverData::Init @ 0x00548EF0, and arbitrated
NAK-xor-ack per sweep by ClientNet::ProcessConnection @ 0x00545450
(m_SeqIDsWeNAKed non-empty -> EnqueueNaks, else EnqueuePak;
SharedNet::EnqueueNaks @ 0x00543BD0 shares the SAME timestamp -
campaign landmine #7).
- New Transport/AckNakScheduler: owns the one shared timestamp; a
non-empty NAK set suppresses the ack (N4 emits RequestRetransmit in
that branch; in N3 it emits nothing - a documented transitional state,
safe for exactly one slice on loopback), else ONE cleartext exact-flags
AckSequence carrying the tracker's HighestIdReceived, header sequence
borrowed from HighestIdSent without incrementing, 4-byte LE body.
Flags are an EQUALITY, never an OR (landmine #5 - ACE's dedup
exemption NetworkSession.cs:342-343 and watermark-skip :474-476 both
require the exact value).
- ReliableTransport.Sweep pump order per FlowQueue::Empty @ 0x00548A20:
interval clock, NAK/ack arbitration, pending resends, prune. The sweep
already runs in Tick and both handshake pump loops (landmine #8), so
cumulative acks flow during the character-list/enter-world floods at
ACE's own ~2 s cadence.
- WorldSession: the Phase 4.9 per-packet reflex ack in ProcessDatagram
and SendAck are DELETED; the [net-tick] acks/s probe now reads
Stats.AcksSent; new internal TransportClockSource seam drives the
2.0 s gate on virtual time in the conformance suite.
- N1 Fable-review advisory retired (Time-stamp fold-in): fresh reliable
sends now stamp Header.Time = the current interval id, matching retail
FlowQueue::TransmitNewPackets @ 0x00547A60 (header build at
0x00547A84); resends already re-stamped. ACE never reads inbound
Header.Time, so the wire stays compatible.
Tests: 723 Core.Net (7 new) - gate cadence + watermark-at-emission,
flags-equality pin + model acceptance at the reused sequence without a
watermark advance, NAK suppression and resume after the gap clears, a
50-packet CreateObject flood collapsing to ONE ack, the quiet-session
keepalive property across a 120 s virtual horizon (the reflex ack's
keepalive role, replaced and proven against ACE's 60 s TimeoutDeadline),
the Time fold-in, and a full FakeAceTransport lifecycle with zero
CRC/state/duplicate drops. Full solution Release: 9,744 passed /
5 skipped / 0 failed. Connected world-lifecycle gate PASS (capped +
uncapped-reconnect, graceful exits, 0 failures); canonical nine-stop
route PASS (0 failures).
Campaign section 9 N3 row updated (complete; SHA recorded at N4
kickoff).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>