1313 commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
16ed6e7c5c |
fix(render): keep authored surface translucency on composite textures
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The user reported wielded items subtly hiding particle effects, as if a translucent texture were missing. Root cause verified in source: the DAT authors a per-surface Translucency float, and the shared-atlas extraction honors it by baking (1 - Translucency) into the texture alpha (MeshExtractor). But a surface with an appearance override - ObjDesc subpalettes or texture changes, which wielded loot typically carries - routes through the per-instance composite paths instead (WbDrawDispatcher.ResolveTexture -> TextureCache GetOrUploadWithPaletteOverrideBindless / GetOrUploadWithOrigTextureOverrideBindless -> DecodeFromDats), and the textured decode there never saw the authored value: only the Base1Solid branch passed it (SurfaceDecoder.DecodeSolidColor); DecodeRenderSurface has no translucency input at all. Consequence: the part still classified translucent, still sorted in the RetailAlphaQueue, still drew with depth writes off - but with texture alpha = 1 it overwrote everything already composited behind it. Particles behind the part vanished; particles in front survived. The same GfxObj without overrides (atlas path) rendered correctly, which is why the loss was so selective and subtle. Fix: SurfaceDecoder.ApplyAuthoredTranslucency mirrors the atlas bake (in-place alpha scale, caller-owned buffers, Magenta sentinel guarded), and DecodeFromDats applies it behind an opt-in flag set by exactly the two world composite paths. The sky path stays unbaked (its shader applies the authored opacity separately - baking would double-apply, the AP-89 compounding class) and particle sheets stay unbaked (emitter-driven alpha, no authored-translucency consumer). Composite cache keys already include the surface id, so the baked alpha is cache-coherent. This closes an unregistered divergence (no register row existed; the fix restores parity with the shipped atlas mechanism, so none is added). Investigation evidence: equipped children and world objects share the same classification chain (ClassifyPackedBatches/GroupKey), so the gap was override-driven, not attachment-driven - a dropped item with the same ObjDesc was equally affected. Core SurfaceDecoder tests 22/22 (3 new); App Release suite 3,968 / 3 skips. Visual gate: a wielded item with authored-translucent parts must let its particle effects show through. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
bfba0ecf7f |
fix(ui): interactive window moves must survive the per-frame anchor layout; lock the dragbar cursor
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The dragbar port (
|
||
|
|
e4c99f54c0 |
feat(ui): port retail UIElement_Dragbar so authored drag strips move their windows
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
The combat bar and spell bar could not be moved at all: their window mounts Draggable=false (correct - retail never whole-surface-drags them) and the authored move mechanism was missing. Retail registers element class 2 as UIElement_Dragbar (Register @ 0x0046C840); a press inside it calls UIElement::StartMovement on its parent window (StartMouseMoving @ 0x0046C760) and release calls StopMovement (@ 0x0046C7C0). The combat/spell bar layout (LayoutDesc 0x21000073) authors exactly one such element - a 600 x 5 strip along the top edge, which is where the user expects the move cursor. The powerbar, vitals, indicators, radar, and examination layouts author dragbars too, so they all gain their retail handles from this one port. Our importer knew Type 2 by name but built it as a generic UiDatElement - ClickThrough decoration, so the strip never even claimed the pointer. Now: - UiElement.WindowMoveHandle marks an authored handle; the DAT factory sets it for Type-2 elements and opts them out of ClickThrough. - A left-press inside a handle subtree moves the handle's top-level window (the outer frame directly under the root - the mounted analogue of retail's dragbar parent) even when that window is not whole-surface Draggable. Edge-resize still wins; UiLocked still gates, matching the retail locked/fixed parent-flag check. - HoverWindowMove reports the handle so the window-move cursor shows over the strip - and only there - on non-Draggable windows. Four new tests: handle press moves a non-Draggable window and stops on release, hover shows the move cursor over the strip but not the body, UiLocked suppresses both, and the factory builds Type 2 as a pointer-claiming move handle. App Release suite 3,966 / 3 skips. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
67379d1f9a |
fix(ui): UiField wrapped-line cache coherent with the text at mouse-hit time
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
Fixes the crash the user hit twice today (captured in artifacts/coldeve-acceptance-20260729/crash-hunt.log): clicking into a multiline UiField - the examination window's inscription field - after the text had changed since the last draw threw an unhandled ArgumentOutOfRangeException from String.Substring and took the whole client down (UiField.MeasureRange <- HitChar <- OnEvent MouseDown). Root cause: _wrappedLines is a DRAW-side cache (rebuilt only in DrawMultiLine) consumed by the INPUT side (HitChar on MouseDown and drag-select MouseMove). Input events are pumped before the frame's draw, so a mutation (backspace, SetText, paste) followed by a click in the same pumped frame handed HitChar wrap lines describing the OLD, longer text; measuring those stale ranges ran past the end of the live string. Fix: text mutations now bump a version (the _text field became a private property so every existing mutation site participates without churn), the draw records which version its wrap lines describe, and HitChar proves coherence via EnsureWrappedLinesCurrent() - rebuilding with the last draw width when stale. Rebuilding rather than clamping keeps caret placement CORRECT against the live text, not merely non-throwing. Two inversion-sensitive regression tests reproduce the exact crash sequence (wrap long text, shrink without a draw, click); they throw without the HitChar coherence call. App tests 3,962 passed / 3 skipped (3,960 + 2 new). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0ccbb4e52c |
fix(interaction): port retail's wielded-item pickup rejection (Slice 4 F1)
Slice 4 made a remote character's wielded weapon selectable, which made the
pickup chain reachable end to end for the first time: SelectionPickUp on
another player's weapon captured identity, passed ValidatePickupTarget (which
checked only the Stuck flag and the small-item mask, and a MeleeWeapon clears
both), installed a real non-autonomous approach through
PlayerInteractionMovementSink, and then sent a pickup request the server
rejects. Retail does none of that.
ItemHolder::AttemptToPlaceInContainer @ 0x00588140 runs
AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0 first, at 0x00588173 --
ahead of container legality, auto-merge, the container walk, and the only
CM_Inventory::Event_PutItemInContainer emitter
(ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680). IsItemLegal's arm at
0x005872B7 rejects `!ACCWeenieObject::IsOwnedByPlayer(item) &&
item->pwd._location != 0` with one local
ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...), and
CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 then withdraws the waiting slot it
had published (SetWaitingState(obj, 0) + SendNotice_EndPendingInPlayer at
0x0055D918). No request, no movement. acdream had never ported that arm; it
was harmless while wielded children were unpickable and stopped being harmless
at
|
||
|
|
f6db964fd5 |
feat(interaction): Slice 4 - equipped-child world picking
A click on a remote character's wielded weapon reported nothing. The picker was already correct: RetailSelectionScene publishes every drawn part under its own live-entity server GUID and RetailWorldPicker returns the weapon as the polygon winner. The failure was downstream eligibility - WorldSelectionQuery required TryGetInteractionEligibleRecord, whose _visible set admits LiveEntityProjectionKind.World only, so the winning hit was discarded. Retail has no such gate. Render::GfxObjUnderSelectionRay @ 0x0054C740 accumulates each hit under the drawn part's own physics-object id (CPhysicsPart::get_physobj_id @ 0x0050D490), and CPhysicsPart::Draw @ 0x0050D7A0 admits any drawn part whose physobj id is nonzero. An equipped item is a first-class CPhysicsObj with its own id and part array (CPhysicsObj::add_child @ 0x0050F870 via CSetup::GetHoldingLocation @ 0x005213F0). There is no parent redirection and no wielded-specific rule, so a click on a wielded weapon returns THE WEAPON'S GUID. PositionState.WIELDED is distinct from IN_CONTAINER (acclient.h:6802), so container suppression never hid a wielded selection either. LiveEntityRuntime gains two scoped predicates: TryGetAttachedProjectedRecord (a current Attached projection that is spatially projected) and TryGetPickEligibleRecord (that arm plus today's World visible-set arm, with the same WorldEntity.Id staleness recheck). TryGetInteractionEligibleRecord and the _visible set are deliberately NOT widened - they feed radar, auto-target, sticky/MoveTo establishment, and CombatAttackTargetSource, and retail's radar has no wielded blips. A regression test asserts an attached child stays out of that set while picking admits it. Marker anchoring had the twin problem. SmartBox::GetObjectBoundingBox @ 0x00452E20 pushes the picked object's OWN m_position - which for a child is the frame CPhysicsObj::UpdateChild @ 0x00512D50 recomposes each tick as Frame::combine(parent part frame, holding frame) - and CPartArray::GetSelectionSphere @ 0x00518B80 scales the authored sphere by that object's own part-array scale. acdream stores the PARENT's root in the child projection's Position/Rotation because the child's MeshRefs are parent-relative, which put the vivid brackets at the wielder's feet. The composed child root is already published per frame to EntityEffectPoseRegistry by EquippedChildRenderController.PublishChildPose, so selection now borrows it through an injected Func<uint, Matrix4x4?> wired in LivePresentationComposition beside the existing selection-sphere hook. There is no parent fallback: a child with no published composed root has no live frame this tick and no sphere. Its part-array scale comes from the spawn record, the same source EquippedChildRenderController.TryRealize reads, because an Attached WorldEntity carries the parent-derived pose rather than its own ObjScale. The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 guards ItemHolder::UseObject with `found->pwd._wielderID != SmartBox::player_id` at 0x004E5BE9 while still selecting and flashing. Equipped-child picking makes that click reachable, so the gate ships with it as IWorldSelectionQuery.IsWieldedByPlayer. CPhysicsObj::SetLighting @ 0x00511A80 is non-recursive, so the pulse lights the clicked object's own part array only - clicking a weapon never flashes its wielder. That follows from routing the pulse identity through the same predicate. RetailWorldPicker, RetailSelectionScene, WbDrawDispatcher, and EquippedChildRenderController are untouched, as are all wire and physics paths. The slice REMOVES an undocumented deviation (Attached projections excluded from pick eligibility versus retail's part-id pick) and introduces none, so no retail-divergence-register row is owed in either direction. Gates: dotnet build green; AcDream.App.Tests 3,951 passed / 3 skipped; complete Release solution 9,783 passed / 5 skipped; tools\run-connected-world-lifecycle-gate.ps1 RESULT=PASS. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
f9c5e47e7f |
feat(net): N6 - ConnectResponse retransmit + fragment assembler eviction
Campaign N Slice N6, the final implementation slice.
ConnectResponse handshake retransmit:
- While the connection is unconfirmed, the Connect character-list pump
resends the IDENTICAL cleartext ConnectResponse (same sequence 1, same
cookie, the one encoded datagram - no new outbound state) on retail's
strict 0.333333333 s gate. Retail: ClientNet::ProcessConnection
@ 0x00545450, case cs_ConnectionRequestAcked @ 0x0054547B (the constant
load at 0x00545481; the mask-0x41 strictly-greater x87 test at
0x0054548C); ClientNet::SendConnectAck @ 0x005440F0 re-stamps
lastSentHandshake_ (0x00544102) and rebuilds the same cookie packet.
- Confirmation = the first checksum-valid post-negotiation packet whose
header lacks the ConnectRequest flag: retail's cs_ConnectionRequestAcked
-> cs_Connected edge (ClientNet::ProcessPacket @ 0x00545100, the 0x40000
exclusion at 0x0054514E, SetConnectionState(..., 5) at 0x00545160).
- The cadence rides the TransportClock (virtual-clock testable through
TransportClockSource); the Connect deadline stays wall-clock.
- ACE safety pinned against the N0 model: a duplicate while still
AuthConnectResponse re-routes idempotently through NetworkManager's
pre-route; after acceptance CheckState clause 2 drops it pre-CRC at
zero keystream cost.
- Pre-N6, one lost ConnectResponse was a hang to the Connect deadline;
the N5 decorator deliberately arms after this window, so nothing
covered it.
FragmentAssembler eviction (divergence register row AD-52):
- Partials evict 60 s after their last ACCEPTED fragment; the stamp
refreshes on every new fragment (retail's re-stamp rule,
ArrivedEphInfo::UpdateNetBlobID @ 0x0054AE00), so a merely-slow partial
can never age out - 60 s is a floor, not a tunable. Swept from
ReliableTransport.Sweep on retail's 5 s flush cadence
(Indicator::FlushTimedOutEphInfo @ 0x0054A3D0, the gate at 0x0054A3DC;
per-entry ArrivedEphInfo::fTimedOut @ 0x0054AE30). N4's RejectRetransmit
abandonment made an unrecoverable partial a REACHABLE permanent state;
the TTL reclaims it.
- A 64-entry completed-sequence ring drops late duplicate fragments of
already-completed messages instead of allocating a fresh partial that
can never complete (the completed-then-duplicate leak).
Fold-ins:
- N5 review LOW-5: NetProbeTests + LossyTransportDecoratorTests (the
static NetDiagnostics / Console.SetOut mutators) share one
DisableParallelization xunit collection so they never run alongside
classes constructing WorldSession.
- Campaign section 9: N6 ledger row recorded; N5 row verified carrying
|
||
|
|
4e290f00d8 |
feat(net): N5 - loss observability, lossy decorator, the connected loss gate
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> |
||
|
|
852a59e388 |
feat(net): N4 - client NAK emission + RejectRetransmit reclaim
Campaign N slice N4 completes the AckNakScheduler NAK branch and closes the ACE cleartext-reject keystream hazard - the slice that makes S2C loss actually RECOVER. NAK emission (SharedNet::EnqueueNaks @ 0x00543BD0): - One cleartext exact-flags RequestRetransmit per sweep behind the STRICT 0.6 s gate on the ONE shared timestamp (the x87 0x41-mask test at 0x00543C03 proceeds only on strictly-greater; the ack's gate stays >=). Never an ack in a NAK sweep; a NAK delays the next ack by 2.0 s and vice versa (landmine #7). - Body = u32 count + ids ascending, capped at 114 (ReceiverData::GetNaks @ 0x005490C0, cap 0x72; the m_cbData = 4*count+4 store at 0x00543C3E); header Sequence borrowed from highestIDSent_ without incrementing; cleartext or ACE ignores it (landmine #6, NetworkSession.cs:283-284) - and a NAK never refreshes ACE's 60 s timeout. - Control-header rule decided once for BOTH ack and NAK: Time = the interval id, Iteration = the session iteration, matching retail's shared header build (FlowQueue::TransmitNewPackets @ 0x00547A60, the stack build at 0x00547A84). ACE reads neither field inbound. - Gate ticks now round instead of truncate: 0.6 has no exact double form, and truncation opened the strict gate exactly AT the boundary. RejectRetransmit reclaim (divergence register AD-51, ACE adaptation): - ACE's RejectRetransmit consumes a FRESH sequence, cleartext, with NO keystream word, and is cached (ACE NetworkSession.cs:299-304, :722-725, :743-748) - the one place ACE breaks retail's gap-walk invariant that every missing id was word-bearing (retail cleartext always borrows live sequences). Unhandled, the gap walk parks a word for the reject's id and the inbound stream runs permanently one word ahead - the N2 desync class reintroduced through the reject path. - Fix: on a VALIDATED cleartext reject, InboundSequenceTracker removes the mis-park, shifts every later-drawn parked word down one position (per-word draw ordinals; ascending wrap-safe id <=> ascending draw order), and pools the excess word, consumed lowest-draw-order-first ahead of fresh ISAAC draws. Exact for any number of interleaved rejects in ANY arrival order - a plain reclaim FIFO is not: a reject arriving after a higher encrypted arrival crosses the parked chain, and two out-of-order rejects pool their excess words out of draw order (both orderings pinned by tests). - Reject BODY ids keep N2's discard: word-bearing server-side, consumed-in-place. The pool is provably empty against retail servers. N3 advisories folded (all five): honest transitional-state wording (the empty N3 NAK branch could silently disconnect a loopback session at ACE's 60 s timeout, witness [net-tick] acks/s=0), the ReceiverData::SharedInit @ 0x00548EF0 (from Init @ 0x00548FA0) citation, the FlowQueue::Empty pump-order wording (TransmitNaks -> TransmitAcks -> TransmitNewPackets with the interval increment LAST @ 0x00548A9D; our clock-first Sweep is cosmetic vs ACE), the Time/Iteration rule above, and the stale WorldSession budget-break comment rewritten to the sweep reality. Tests: 737 Core.Net green (14 new in NakEmissionTests + updated N3 pins): strict-gate boundary, shared timestamp both directions, NAK-xor-ack exclusivity, full wire-shape + 114-cap pins, model-served retransmission round trip, five tracker reclaim proofs, the 130 s virtual prune -> fresh-sequence reject system test (victim abandoned, later traffic decodes, pool drains to zero), 10 s long-loss survival (NAKs on the gate cadence, zero acks, heal inside the window), and the capstone soak: 2% seeded bidirectional loss x 10,000 messages -> zero message loss both ways, ACE crypto headroom 256 at convergence, every ledger drained (cache at the single watermark entry - retail's Flush prunes STRICTLY below the ack). Full solution Release: 9,758 passed / 5 skipped. Connected world-lifecycle gate PASS (logs/connected-world-gate-20260729-150238); canonical nine-stop soak PASS (logs/connected-r6-soak-20260729-150856). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
0265cc4236 |
feat(net): N3 - AckNakScheduler, retail 2.0s cumulative ack replaces per-packet acks
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> |
||
|
|
46d209d053 |
feat(net): N2 - inbound sequence-aligned ISAAC + NAK set
Campaign N Slice N2 (docs/plans/2026-07-29-network-transport-campaign.md S2.2) - the second fatal #260 fix: the inbound keystream now aligns to SEQUENCE order instead of arrival order. One lost S2C datagram no longer desyncs the inbound cipher permanently - the missing id's pre-drawn key parks in the NAK set, later packets keep decoding, and the retransmission decodes with the parked key. New src/AcDream.Core.Net/Transport/InboundSequenceTracker.cs - retail's ReceiverData inbound half, ported rule for rule: - Sanity window: drop when seq is wrap-safe newer than highestIDReceived_ + 0x7FFF (SharedNet::SeqIDSanityCheck @ 0x00543A20; the boundary itself is accepted). - Duplicate/late arrival (encrypted, at/below the watermark): NAK-set hit -> decrypt with the PARKED pre-drawn key; miss -> silent drop at ZERO keystream cost (SharedNet::ProcessNewSeqNum @ 0x00544690, the AVL::Remove branch) - the dup-word-burn and double-dispatch bugs close together. - Gap walk (SharedNet::ProcessNewestSeqNum @ 0x00541930): one inbound ISAAC word per missing id, drawn IN SEQUENCE ORDER BEFORE the arriving packet's own key (landmine #4), parked beside the id (ReceiverData::AddNakked @ 0x00549240, idempotent; id 0 skipped per retail's `if (esi_1 != 0)`). Cleartext walks to seq+1 - the borrowed id itself gets NAKed, so the real encrypted packet at that id can still decode later. - Verify-failure re-park: a sequenced encrypted checksum failure parks the consumed key back beside its id so the retransmission decodes (SharedNet::ProcessPacket @ 0x00544790 tail, AddNakked(seq, &key)). - Inbound RejectRetransmit -> silent NAK-set abandonment; parked keys discarded, alignment holds because the words were already drawn (SharedNet::HandleEmptyAck @ 0x005448F0). - NAK set = SortedDictionary<uint,uint> seq -> parked key; ascending raw-uint enumeration matches retail's AVL walk for N4's <=114-id NAK emission (ReceiverData::GetNaks @ 0x005490C0). PacketCodec split (campaign S4, retail's own factoring - the key is an optional in/out of ReceiverData::Decrypt): TryParseBorrowed is the pure parse + checksum-summand computation with NO keystream access anywhere; VerifyChecksum(header, headerHash, payloadHash, uint? key) compares the additive cleartext form (null) or headerHash + (key ^ payloadHash). TryDecodeBorrowed(datagram, IsaacRandom?) - the consume-before-compare site that WAS the bug - is deleted; the owned TryDecode stays (test-only). RejectRetransmit ids are now exposed on both decoders (borrowed RejectRetransmitBytes/Count like the Request pair; owned RejectRetransmits list); the bytes were always inside the hashed span, so parse-hash coverage is unchanged. WorldSession: ProcessDatagram head is now parse -> sequence-0 split (cleartext seq-0 = handshake/control, verified additively and processed as before; encrypted seq-0 dropped before any keystream access, like retail's ProcessPacket) -> tracker.Admit -> VerifyChecksum with the admission key -> failure re-park -> unchanged flag handling, N1 transport consumption, reflex ack, and fragment loop. The RejectRetransmit flag routes to the tracker beside the N1 NAK/ack consumption. The handshake Connect loop moved to parse + cleartext-verify (no tracker exists before ISAAC seeding; the ConnectRequest is cleartext seq 0). ReliableTransport now takes both Isaacs and exposes Inbound; the session's _inboundIsaac field is deleted. No production caller constructed the N1 ctor outside WorldSession, so no compatibility shape was kept. TransportStats gains InboundDupsDropped, InboundSanityDrops, ChecksumFailures, KeysParked (unconditional, like the N1 counters). Watermark init = 1 is an ACE adaptation, register row AD-50 (watermark INIT only, not a mechanism change; AD-49 stays reserved for the campaign S5 blob-layer deferral): retail zero-inits ReceiverData, but ACE never emits S2C sequence 1 - PacketSequence starts unprimed at uint.MaxValue, the cleartext ConnectRequest takes NextValue 0, and the first ENCRYPTED flush re-primes CurrentValue to 1 so the first encrypted sequenced packet is 2 (ACE NetworkSession.cs:716-717 resolving to UIntSequence(startingValue: 1), Sequence/UIntSequence.cs:9-13,30-41). A zero-init watermark would gap-walk the permanent id-1 hole: one spurious NAK, the first pre-drawn word mis-assigned to id 1, and the keystream off by one from the first encrypted packet onward. holtburger seeds the same value (crates/holtburger-session/src/session/api.rs:30, last_server_seq: 1), mirroring ACE's own C2S-side lastReceivedPacketSequence = 1 (NetworkSession.cs:57). The N0 model's dance is pinned by the clean-lifecycle conformance test: min encrypted S2C sequence == 2, zero NAKs, zero spurious drops. Tests (+14; Core.Net 702 -> 716): the decisive gap test (10,11,13,14 - 13 and 14 decode with fresh words while 12's key parks with KeysParked=1/NakCount=1, the late 12 decodes with the parked key, 15 takes the next fresh word - impossible pre-N2), zero-cost duplicate drop (shadow ISAAC position unchanged), re-park -> byte-identical retransmission decode, the cleartext borrowed-id rule, cleartext at the watermark (no NAK/key/watermark change), sanity boundary +0x7FFF accepted / +0x8000 dropped wrap-safe, skip-id-0 across the 32-bit wrap with ascending NAK enumeration, RejectRetransmit abandonment with alignment held, warm zero-alloc Admit; plus four real-WorldSession conformance runs against the N0 ACE double: clean lifecycle (zero NAKs at every stage), S2C loss of one packet of a Count=2 fragment set (later packets STILL decode - the N2 win; late byte-identical redelivery completes the split message intact), duplicate delivery dropped BEFORE dispatch, and the seq-0 tracker bypass. N3/N4 handoff notes are recorded in the campaign S9 N2 row: the interim per-packet reflex ack acks the arriving sequence even while a gap is parked (ACE prunes the lost id from its S2C cache before N4 could NAK it - message recovery needs N3's retail NAK-xor-ack sweep), and ACE's RejectRetransmit consumes a fresh CLEARTEXT sequence with no keystream word, an ACE-vs-retail wrinkle N4's design must resolve. Gates: dotnet build green; AcDream.Core.Net.Tests 716/716; full-solution Release 9,732 passed / 5 skipped / 0 failed; connected world-lifecycle gate vs local ACE RESULT=PASS (zero failures, one pre-existing expected world-edge landblock-miss warning); canonical nine-stop connected route RESULT=PASS. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
43e60a6971 |
feat(net): N1 - outbound sent-packet cache + resend on NAK
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).
|
||
|
|
e395861053 |
test(net): N0 fix-up - CheckState gate, bundle coalescing, two-phase terminate
Addresses the N0 review findings against commit
|
||
|
|
7e9134b4d1 |
test(net): N0 - ACE-behaviour double, virtual clock, lossy link
Campaign N slice N0 (docs/plans/2026-07-29-network-transport-campaign.md): the referee that slices N1-N5 are graded against, test-project only, zero production changes. - VirtualClock: Stopwatch-shaped deterministic time source (fixed 100 ns ticks) that N1 will inject behind the production TransportClock. - AceCryptoModel: verbatim port of ACE CryptoSystem Search/ConsumeKey over our IsaacRandom - 256-key window, parked-key set, Headroom/OrphanCount diagnostics (CryptoSystem.cs:8-49 cited per method). - AceSessionModel: transport-free ACE NetworkSession over raw datagrams, every rule cited to NetworkSession.cs - CRC-before-everything silent drop, cleartext-NAK early return (no timeout refresh, :283-308), 60 s timeout refresh (:329-331), exact-equality ack dedup exemption (:342-347), desired+2 NAK trigger with 1 s limit (:351-363), >window AbnormalSequenceReceived (:393-397), the :474-476 watermark hole, ack-value cache prune (:663-673), fragment gate (:532-543), seq>=2 caching (:730), Retransmission-flag resends with the ORIGINAL IssacXor (:675-686), RejectRetransmit, 2 s cleartext cumulative ack, 20 s TimeSync, EchoResponse, 120 s cache prune (:251-262). ACE's raw wrap-unsafe comparisons are modeled bug-for-bug, not fixed. - LossyLink: deterministic drop/reorder/seeded-loss fault injector, pure data structure. - FakeAceTransport: IWorldSessionTransport binding a REAL WorldSession to the model through the link, with the handshake scripted (ConnectRequest reusing the negotiation fixture layout, CharacterList, ServerReady, logoff confirmation) - genuine Connect/EnterWorld/Tick/Dispose with no sockets. - 19 new tests pin the double, including CleartextNonAckAdvancesWatermark_TheAceHole (the self-induced wedge behind scope rows TS-57/TS-58/AP-125), re-key = permanent orphan, unrequested-resend window burn, the 115-id NAK cap boundary, and a full no-socket session lifecycle with both ISAAC streams verified aligned end-to-end. Core.Net suite: 678 passed / 0 failed (659 existing + 19 new). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> |
||
|
|
39c1737bda |
feat(core): adopt retail's SoundType catalog; retire AC2D
SoundId was not a subset of retail's table, the way its comment claimed. It was an invention: 23 acdream-local names on acdream-local values, and the values were wrong in the way that matters. FootstepDefault = 0x02 is retail's Random. SwingSword = 0x10 is retail's Death2. Death = 0x60 is retail's Explode. Anyone who reached for one of those names to compare against a wire or dat value would have got a different sound. Nothing referenced any of them by name -- grep for `SoundId.` across src and tests returns nothing -- so this was a trap rather than a live defect, the same shape the enum campaign found in DamageType. All 22 invented names are deleted and retail's 205 replace them. Three oracles agree exactly, on every name and every value: retail acclient.h:4569 enum SoundType, ACE's Sound, and DatReaderWriter's Sound. The third matters most. AudioHookSink already resolves SoundTable lookups through DatReaderWriter.Enums.Sound, so that is the enum acdream actually reads at runtime; our catalog now agrees with the values already flowing through the dat path, and a conformance test pins the two so they cannot drift apart. On the "206 sounds" figure: retail's block holds 207 entries, being 205 sounds followed by NUM_SOUND_TYPES = 0xCD and FORCE_SoundType_32_BIT. The first is a count and the second a width pin. Counting the former is where 206 came from. Neither is a member here, matching how the campaign treated NUM_ATTACK_HEIGHTS and Num_HoldKeys -- a count is not a value the wire can carry. Behaviour is unchanged and could not be otherwise: the enum had no consumers. IAudioEngine's three SoundId overloads are no-op stubs and the live path takes wave ids and DatReaderWriter values. The user's separate report that sound is "not working that good" is a triggering, selection and attenuation question rather than a catalog one, and is filed as its own Bucket B row in the post-Vulkan intake. Also in this commit, by user decision: AC2D is retired as a reference. Its clone and directory are gone and it must not be re-cloned. Everything we took from it still stands and is written down -- the FSplitNESW terrain split constants, the 0xF61C movement packet layout, the finding that a client need not compute terrain Z itself -- so CLAUDE.md's reference list, its hierarchy table, and the architecture doc's protocol row now point at docs/research/2026-04-12-movement-deep-dive.md rather than erasing the history. The reference count drops from six to five. Core tests 3907 passed / 2 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f27ad9ee43 |
feat(core): adopt retail's full WeenieError code table
acdream carried 16 status codes, curated by hand out of the CMotionInterp and MoveToManager decompilation passes. The other 362 were unnamed, which made every one of them a cast site waiting to happen. This slice takes the whole table: 372 values under 378 names. The oracle set is finally complete. All six vendored reference repos were empty when the 2026-07-29 enum campaign ran, which is why it deferred this decision; they are re-cloned now, so ACE's WeenieError could be read directly instead of leaning on the UtilityBelt catalog alone. The two agree without a single conflict. ACE has 369 members, no internal value collisions. The catalog has 372, shares all 369 ACE names, and disagrees on none of their values. Its three extras -- IsNowOpenFellowship (0x050B), IsNowClosedFellowship (0x050C), LockedFellowshipCannotRecruit (0x0518) -- each turn up in ACE's separate WeenieErrorWithString enum with a `_` marking the interpolated name, so the catalog is just the less-split view of the same client enum. All three are adopted on agreement between two oracles, not on one. Retail cannot arbitrate any of this. acclient.h has no counterpart enum; its charError (26) is character-creation only. Recorded, not guessed around. Six values keep two names. acdream's NotGrounded, CrouchInCombatStance, SitInCombatStance, SleepInCombatStance, ChatEmoteOutsideNonCombat and ActionDepthExceeded are each anchored to a retail decompilation site, where ACE's names for those values are server-side coinages. Rather than pick, both are declared, acdream's first so ToString() is untouched. Behaviour is unchanged, and there is no way for it not to be: nothing in the tree branches on a WeenieError member. MotionInterpreter's switch is on a motion type and merely returns one of these; WeenieErrorText.For switches on a raw uint; the chat translation table WeenieErrorMessages is keyed on uint throughout, so naming a code does not make it render. The one site that moved is RemoteTeleportHook, where the (WeenieError)0x3Cu cast becomes the now-named WeenieError.ITeleported at the same value. Register row AP-15 is narrowed rather than retired. Its code-catalog caveat is superseded -- an unnamed code is no longer a way for it to bite -- but the sentences are still ACE's doc comments rather than retail's string_table.bin, and that part stands. The enum moved out of MotionInterpreter.cs into its own file at the same namespace. At 372 members it does not belong inside a physics class file. Core tests 3903 passed / 2 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
200f19ce47 |
test(app): put every strict-zero site on the probe (#250)
The first commit converted the four members the issue named and left the other sites alone, reasoning that none had been observed failing. A 20-run complete-solution baseline disproved that within minutes: run 2 LiveEntityRuntimeTests.AnimationView_HotSpatialTraversal… run 14 StaticRenderProjectionJournalTests.ActiveAnimatedSynchronization… run 18 StaticRenderProjectionJournalTests.ActiveAnimatedSynchronization… run 19 CurrentRenderSceneOracleTests.SurfaceOverrideFingerprint… Both new names are the same shape as the four — one warm call, then a thousand-iteration loop inside the measured window — and neither had been recorded anywhere. "Not observed failing" only ever meant "not yet observed", and leaving known-shape sites in place would have guaranteed the acceptance gate failed. Run 19 is the sharper lesson: the issue named `SurfaceOverrideFingerprint_DictionaryHotPathAllocatesNothing`, and the first commit converted a *different* test in that same file, so the actually-named member was still on the old shape. Matching by file was not matching by test. Every strict-zero site in the assembly is now on the probe — ten tests. Two came out stricter rather than merely steadier: `StaticRenderProjectionJournalTests` was measuring a synchronise whose journal does **not** coalesce. Repeating it grew the journal by 1,000 entries per call — 192,000 by the end of a probe run — so the steady state the test claimed to measure did not exist and the single-call window had been hiding it. Its step is now the whole frame cycle, synchronise *and* drain, which puts `DrainTo` inside the measured window for the first time and asserts the journal ends empty. `RetailInboundEventDispatcherTests` asserted a hard-coded 1,001 callbacks. It now counts its own dispatches and pins the callback count against that, so the assertion still proves the fast path ran the callback every time without being coupled to a loop bound that no longer exists. Left alone deliberately: the four sites asserting a tolerance rather than zero — `CellViewDedupTests` and `PortalProjectionTests`. Their ceilings already absorb this noise and none has flaked; changing a bound in either direction is a separate decision from fixing a measurement. Worth noting that `PortalProjectionTests`' ceiling exists explicitly to tolerate "a tiered-JIT/ArrayPool bookkeeping transition ... to the first measured batch", which is exactly what the probe removes, so it could probably be tightened to zero now — recorded in the issue rather than done here. Solution build 0 warnings / 0 errors; App suite 3,941 passed / 3 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
1d73ce524c |
test(app): measure the warmed path, not the path being warmed (#250)
The zero-allocation family failed about one full-suite run in three, on unchanged trees, and had been dismissed as inherent noise in `GC.GetAllocatedBytesForCurrentThread` three separate times. It is not noise. Reading the four members side by side, they share one root: **the measured window was never the warmed path.** UiDatFontTests 1 warm call, then a 10,000-iteration loop inline RenderFrameProductTests 8 warm calls, then a 1,000-iteration loop inline OracleTests 1 warm call, 1 measured call ArchRenderSceneTests warms Apply(registrations), measures Apply(updates) Two mechanisms come out of that table. A test method is JIT-compiled at tier 0 like anything else, and a long-running loop in tier-0 code gets replaced mid-flight by on-stack replacement — which compiles on the thread running the loop, so its bookkeeping is charged to the window being measured. That is the first two. And `ArchRenderSceneTests` warmed one arm of a switch and measured the other, so the measured call was the first ever into `ApplyUpdate` and paid that arm's JIT, type loads and static initialisation inside the window; `RenderFrameProductTests` warmed 8 times, below the tier-0 call-counting threshold of 30, so promotion was still pending when measurement began. That also explains the signature nobody could account for. Alone, the process is quiet and the runtime has finished before the assertion arrives. Alongside eight other test assemblies, tier-0 compilation never stops, the call-counting delay is re-armed continually, and the work slides into the window. Clean in isolation, failing under load, on a tree that changed nothing. `ZeroAllocationProbe` invokes the step many times before measuring anything, then measures windows that run the same already-warmed loop over the same already-taken path. Each window is a batch of 32 invocations and it reports the minimum across 4 of them. Both halves are load-bearing: the minimum is what excludes a one-time cost, and the batch is what keeps the assertion as strong as the loops it replaces — minimising over *single* invocations would report zero for a path that allocates every tenth call, which is a real regression made invisible. I had written it that way first and the apparatus test caught it. **The bound is untouched: exactly zero, no tolerance, no retry, no assertion relaxed.** `ZeroAllocationProbeTests` proves the apparatus can still fail — a step allocating every call reads above zero and does throw, a first-invocation cost reads as zero, a cost every tenth call is caught, and the one stated limit (the batch must cover the period) is pinned as a test rather than left as prose. Without those, a later edit could quietly make the whole family unfailable. Twelve further sites in this assembly still use the hand-rolled shape. None has been observed failing, and each needs its own repeatability analysis — several mutate state or consume monotonic sequences — so they are listed in the issue for adoption when next touched rather than converted blind at scale. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
ce9445b270 |
fix(render): the near plane is col3, not col4 + col3 (#248)
`FrustumPlanes.FromViewProjection` extracted the near plane with the Gribb-Hartmann form written for OpenGL's `[-1,1]` clip-space z range. Every acdream projection comes from `Matrix4x4.CreatePerspectiveFieldOfView` or `CreateOrthographic`, whose range is `[0,1]`. Under `[-1,1]` the near plane is the locus of `clip.z = -clip.w`, which is `col4 + col3`; under `[0,1]` it is `clip.z = 0`, which is `col3` alone. Concretely, the mismatch put the effective near threshold at `-n·f/(2f-n)` — about 0.5 m where the retail chase camera asks for 1.0 m. That error only ever kept geometry the true frustum would have dropped, never the reverse, which is why it produced no visible defect and was filed instead of hot-fixed during Campaign V. It is still wrong, and it is the same mistake that *was* visible in `PortalProjection`, where it culled the cell behind a doorway the camera stood close to. The far plane is `col4 - col3` under both conventions and is untouched. A test pins it anyway, so that a future edit to this function cannot drift it while nobody is looking. The acceptance criterion asked for a unit test pinning the extracted near distance to the camera's near value, and that is what landed: a theory over four near/far pairs asserting the plane is unit-length, faces down -Z, and stands off the eye by exactly `nearDistance`, plus a kept/dropped pair straddling it. The test was checked against the old formula before commit and fails all four cases there — it measures the fix rather than merely accompanying it. The other half of the acceptance criterion — unchanged culling in the offline pixel gate and the connected route — could not be run: #259 has Win32 surface creation failing machine-wide, so no gate that needs a window is available tonight. Recorded as outstanding rather than assumed. Solution build 0 errors; `AcDream.Core.Tests` 3,898 passed / 2 skipped / 3,900. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
22ae7944b6 |
merge(net): the wire-stack audit, and one reconciled #255
Brings `github/overnight/wire-audit` (` |
||
|
|
cd2f3feae2 |
merge(core): the enum verification campaign, onto the post-deletion tree
Brings `github/overnight/enums` (` |
||
|
|
7a0227c12e |
feat(render): Vulkan campaign V11 step 3 — drop the GL packages and shaders
Commit 2 deleted the GL rendering backend's implementations; this step removes the package references and shader vocabulary they leave behind, so nothing in the App project still spells Silk.NET.OpenGL. Silk.NET.OpenGL and Silk.NET.OpenGL.Extensions.ARB are dropped from AcDream.App.csproj. Chorizite.Core stays — the audit is NOT clean: its Render.Enums (TextureFormat, BufferUsage) and Lib.BoundingBox types are used directly and extensively across the Wb texture/mesh pipeline, independent of the deleted GL IUniformBuffer implementers the package comment used to cite. The stale comment is corrected in place. IMeshPipelineDevice.Gl is removed along with the GL? gl parameter threaded through WbMeshAdapter's four constructors, WorldRenderComposition's CreateMeshAdapter, and VulkanMeshPipelineDevice's Gl => null implementation — nothing read any of them once the legacy per-mesh upload bodies were gone (confirmed by grep: the sole non-doc-comment hit was a test assertion). While in WbMeshAdapter.Dispose(), found and fixed a real bug along the way: its teardown still pattern-matched the deleted GL GpuFrameFlightController to decide whether to wait for submitted work, which VulkanFrameFlightController replaced at slice V6a without this site being updated — so the wait had been silently dead on every Vulkan run since then. Retargeted to VulkanFrameFlightController, which carries the same WaitForSubmittedWork(). The GL pixel-format vocabulary (Silk.NET.OpenGL.PixelFormat/PixelType) that WorldTextureArray/TextureFormatExtensions/TextureAtlasManager used for upload validation is replaced by AcDream.Content's existing Silk.NET-free UploadPixelFormat/UploadPixelType enums (added at MP1a to keep the bake tool GL-free); two new members (Rgb, Red, Float) extend that enum with their GL ABI constants to cover the full vocabulary WorldTextureArray needs, since MP1a's original set only covered what the extractor itself emits. ObjectMeshManager's App-boundary cast `(Silk.NET.OpenGL.PixelFormat?)batch.UploadPixelFormat` becomes a direct pass-through now that both sides share the type. GpuBindingModel.StorageTextureTable (the GL-only binding=9 emulation of the Vulkan texture table) is deleted and StorageBindingCount drops from 10 to 9; the descriptor-set-layout code that builds from that count (VulkanPipelineLayouts, VulkanFrameBindings) is untouched and just allocates one fewer always-dummy-seeded, always-unused binding. Several fully dead GL-only classes came along for the ride, confirmed by zero construction sites: SilkFramebufferViewportTarget (NullFramebufferViewportTarget is the sole production IFramebufferViewportTarget), SilkRenderGlStateReader (NullRenderGlStateReader.Instance is the sole IRenderGlStateReader), RuntimeRenderFrameClearPhase (VulkanRenderFrameClearPhase is the sole IRenderFrameClearPhase, expressing the same atmosphere-clear logic as a pass load-op instead), and GpuFrameTimer plus FrameProfiler's GL-owning FrameBoundary(GL) overload and BeginGpuFrame/EndGpuFrame bracket (RecordGpuSample is the only GPU-timing path any backend uses now — the ACDREAM_WB_DIAG nested-query exclusion these existed for no longer applies, since WbDrawDispatcher's own diagnostic GPU sampling already moved to the device's Vulkan timer pool). GpuFrameFlightController itself stays (never constructed with a real fence API in production, but its retirement-ledger/serial-ring logic is backend-neutral and still covered by its own unit tests) — only its GL-specific parts (the public GL constructor overload, SilkGpuFenceApi) are deleted, since removing the whole class would mean restructuring the frozen Slice-8 composition shape's GpuFrameFlightController? threading, which is out of this commit's scope. TextureParameters.cs and BufferUsageExtensions.cs (zero callers each) are deleted outright. common.glsl is deleted: nothing in the actual Vulkan .spv build reads it. tools/ShaderCompiler/Program.cs compiles each .vert/.frag pair directly and tools/ShaderCompiler/VulkanGlslPreamble.cs injects its own complete self-contained preamble per file; common.glsl's textual concatenation was exclusively Shader.cs's GL-only mechanism, deleted at Commit 2. The five shader files that named it in comments (mesh_modern.vert, particle.vert, particle.frag, sky.frag, terrain_modern.frag) are corrected to point at VulkanGlslPreamble.cs instead. mesh.vert/mesh.frag — the pre-N.5 legacy shader pair the mandatory modern path already made unreachable, with zero C# consumers and no compiled .spv — are deleted too. Regenerated via tools/compile-shaders.ps1: 9/9 remaining shader pairs compile (previously 9/10, with mesh the sole failure — the VulkanShaderManifestTests doc comment's "nine of ten are not Vulkan-expressible" was already stale before this commit). Test fallout: dead-subject test methods/files are deleted rather than patched (TextRendererFailureSafetyTests.cs, ClipFrameUploadTests.cs, GpuResourceRetirementTransactionTests.cs's GL queue tests, one WorldRenderDiagnosticsTests source-order test, one RenderFrameResourceControllerTests clear-phase-order test); tests whose subject moved or was renamed are updated in place rather than deleted (GpuContractTests, VulkanCapabilityGateTests, MeshPipelineDeviceSeamTests' pinned seven-member surface now reads six, ParticleBindlessInstanceTests' cross-dialect check now covers the one surviving dialect, WbMeshAdapterTests' misleadingly-named null-gl test — gpuDevice was always the parameter that actually threw). Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors, with the Silk.NET.OpenGL/.Extensions.ARB package references physically removed from the csproj (not just unreferenced in code). Tests: full-solution `dotnet test` green across every project. Zero remaining `using Silk.NET.OpenGL` anywhere in src/ or tests/. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8a7a0837e1 |
feat(render): Vulkan campaign V11 step 2 — delete the OpenGL backend
Vulkan is the sole, user-signed-off backend (V10 landed) and step 1 already removed ImGui/Studio/DevTools. This step deletes the GL rendering backend itself: every Gpu/Gl/** implementation, the Wb ManagedGL*/GLHelpers/GLSLShader/GLStateScope/RenderStateCache/ BindlessSupport family, Shader/ShaderProgramConstruction/SamplerCache, RenderBootstrap, and RenderFrameGlStateController. GameWindow.cs's Run()/CreateGraphics()/CreateBackbufferReader()/ OnLoad() collapse to their Vulkan-only arm; GameWindowGraphics loses its OpenGlGameWindowGraphics subclass. RuntimeOptions.RenderBackend and RenderBackendKind (incl. the Gl member of GpuBackendKind) are gone — there is nothing left to select between. The five world-draw dual-arm renderers (WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer, ParticleRenderer, SkyRenderer) and the composition roots (WorldRenderComposition, HostInputCameraComposition, LivePresentationComposition, FrameRootComposition) collapse to their RHI-only arm. GL-only diagnostic properties with a live external reader (DynamicBufferCount and friends) simplify to a documented `=> 0`/no-op rather than disappearing, since the reader is out of this commit's scope. A few GL-flavored mechanisms turned out to be backend-neutral once isolated: GlConstructionCleanupLedger is renamed ResourceConstructionCleanupLedger (exception-chain walking has nothing to do with GL), and GlfwNativePlatformProbe moved out of the otherwise GL-only GraphicalCapabilityRecord.cs into GraphicalWindowBackendSelection.cs before the rest of that file was deleted. Test files with no surviving subject are deleted outright (GraphicalCapabilityRequirementsTests, ShaderProgramConstructionTests, PortalDepthShaderParityTests, TextureCacheBindlessTests, TextRendererFailureSafetyTests, ClipFrameUploadTests, every Gpu/Gl/*Tests, GlTextureOwnershipTests, RenderFrameGlStateControllerTests); others get their dead GL-only members trimmed while their live assertions stay (ClipFrameLayoutTests' MeshClipSsboBinding check now reads GpuBindingModel.StorageClipRegions, the same binding index under its new backend-neutral name; GpuResourceRetirementTransactionTests drops its OpenGLGraphicsDevice-subclassing test double and the two GL queue tests it existed for). EnvCellRendererTests' construction helper now builds a real ObjectMeshManager via VulkanMeshPipelineDevice instead of passing null through a null-forgiving operator, since the RHI constructor never tolerated a null mesh manager and the old GL constructor (which did) is gone. Deferred to the next two steps, deliberately not touched here: the Silk.NET.OpenGL/.Extensions.ARB package references, IMeshPipelineDevice.Gl (WbMeshAdapter's GL? threading stays in place), Chorizite.Core's stale csproj comment (the package itself is still load-bearing — TextureFormat and friends are used well beyond the deleted ManagedGLUniformBuffer), and the CI/gate scripts. Build: `dotnet build AcDream.slnx -c Release` — 0 warnings, 0 errors. Tests: full-solution `dotnet test` green across every project (App.Tests 3937/3940 + 3 skips, Core.Tests 3296/3298 + 2 skips, all others 100%); the 2 App.Tests names that flake under full-suite parallel execution (#250-family, documented pre-existing) pass in isolation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f57db35cec |
fix(net): xpSpent is a dword on the wire, and we were sending eight bytes
RaiseAttribute, RaiseVital, and RaiseSkill each wrote a 64-bit xpSpent, producing a 24-byte action where the server expects 20. ACE's GameActionRaiseAttribute and its Vital and Skill siblings read message.Payload.ReadUInt32(); holtburger's RaiseAttributeData declares xp_spent: u32 and advances the offset by four. Both oracles agree, and the four extra bytes were tail the server never reads. These three are live-wired, from the character sheet through the command router to SendRaiseAttribute, so this was shipping on every attribute, vital, and skill raise. It has not caused a visible failure because ACE reads the low dword and stops, and a single raise cost has never approached the dword ceiling. That is luck about value ranges, not correctness about layout. Worth noting the shape of the miss: the sibling builder BuildTrainSkill had already been corrected to a 20-byte, 32-bit credits field, and its test is even named U32CreditsNotU64. The same class of bug was found and fixed once in this file and the other three cases were left behind. The parameter stays ulong because the cost comes from 64-bit server XP tables several layers up in the App and Runtime command chain; narrowing that end to end is a separate change and is filed in the audit's open questions. Nothing is lost at the wire: a cost that does not fit in a dword was never expressible here. The existing test asserted the 24-byte shape and is corrected, joined by a theory that sweeps zero, one, a realistic cost, and uint.MaxValue across both remaining builders. Core.Net tests go 655 to 659. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f416c577d6 |
fix(net): stop dropping every transient string on a chat type that isn't sent
CommunicationTransientString (0x02EB) required a trailing u32 chat type after the message. The server does not send one. Because the string is padded to a four-byte boundary, the remaining length after reading it was always zero, the guard tripped, and the parser returned null for every transient string the server has ever sent. Not most. Every one. Three oracles agree there is no such field. ACE's GameEventCommunicationTransientString writes exactly one WriteString16L and stops. Retail's ClientCommunicationSystem::Handle_Communication__TransientString at 0x0057d460 takes a single PStringBase<char> argument. holtburger carries no type field for the event either. ParseTransient now returns the string. The wiring supplies chat type 0, which is ACE's ChatMessageType.Broadcast and which ACE's own LogTextTypeEnumMapper comment names "Default" — the honest stand-in for a message the server sends untyped. What retail's transient strings should actually look like is a rendering question and belongs with the chat colour work, not here. The existing round-trip test was itself appending the phantom trailing dword, which is exactly why the wrong guard looked correct for as long as it did. It is corrected to the real payload and joined by a case sweeping string lengths zero through four, so no future padding-residue assumption can hide here again. Core.Net tests go 654 to 655. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
6119364306 |
test(net): pin the transport flag word against ACE, all twenty-three bits
The wire-stack audit checked the transport layer by hand and found it clean: PacketHeader's seven fields match ACE's Pack order exactly, the optional-header sections are parsed in ACE's order, and PacketHeaderFlags is a twenty-three of twenty-three value match including the sparse gaps between 0x04 and 0x100 and between 0x00800000 and 0x01000000. Clean is worth freezing. These bits are not design choices; each one gates an optional-header section, so a single wrong value shifts every following section's offset and takes the packet checksum with it. The failure would not look like a wrong flag, it would look like a corrupt connection. The enum is small enough to pin exhaustively, so this transcribes ACE's declaration and asserts both directions: every ACE flag exists here with ACE's value, and we declare nothing ACE does not. Core.Net tests go 630 to 654. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
7e95c45ece |
fix(net): ranged speech carries a range float our parser was eating
HearSpeech decoded 0x02BB and 0x02BC with one layout. They do not share one. ACE's GameMessageHearRangedSpeech writes senderID, range, chatMessageType where GameMessageHearSpeech writes only senderID, chatMessageType, and holtburger's HearRangedSpeechData declares the same range: f32 that HearSpeechData lacks. Two oracles, no ambiguity. The consequence was quiet rather than loud. The tail is twelve bytes, our guard demanded eight, so nothing ever failed to parse. We read the guid correctly, then read range's float bits as the chat type and discarded the real one. A shout at range 60.0f arrived with a chat type of 0x42700000 instead of 0x0B. Nothing downstream consumes ChatType for local speech today, which is why this survived, but the record is public and any future consumer would have inherited garbage. TryParse now branches its tail size on the opcode and Parsed gains Range, which stays zero for local speech because there is no such field on that wire. The existing ChatTests ranged case was itself built on the misreading, constructing a local-shaped tail; it is corrected to the oracle layout and now asserts both range and chat type rather than only the ranged flag. New golden tests drive both opcodes through AceWireWriter in ACE's write order, covering empty strings, string lengths one through four so every residue of the four-byte padding rule is exercised, CP1252 accented names, and a regression pin asserting the chat type is not the range float's bits. A ranged body four bytes short is now rejected instead of silently decoded. Core.Net tests go 617 to 630, all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
d5b0765ea8 |
test(net): generate wire fixtures from ACE's own writer, not hand-typed hex
The golden-byte tests we had proved that a parser agreed with whoever typed the hex literal. That is a weaker claim than it looks: if the author misread the oracle, the test cements the misreading. This adds AceWireWriter, a line-for-line mirror of ACE's Extensions.cs writers, so a fixture is produced by the same algorithm the authoritative server uses. Each primitive cites the ACE line it ports, including the string16L padding rule whose comment in ACE reads "client expects string length to be a multiple of 4 including the 2 bytes for length". On top of that harness, two inbound families get field-exact coverage they had none of. VectorUpdate (0xF74E) is driven in GameMessageVectorUpdate.cs's write order and pinned at ACE's declared 36-byte length, with cases for the remote-jump +Z velocity, planar velocity plus yaw omega, rest, and all-negative components so a sign or field-order slip cannot pass. The two script-playback messages follow GameMessageScript.cs: PlayScriptId (0xF754) as guid plus script DID, and PlayEffect (0xF755) as guid, type, and a free intensity float. The NaN case documents the parser's deliberate choice to retain non-finite intensities for the resolver to reject rather than coercing them at parse time, which is behavior worth locking down. Core.Net tests go 600 to 617, all green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
3efa266a61 |
feat(core): name AmmoType, CombatUse, and ItemUseable
Three more fields acdream already pulls off the wire and then carries as bare numbers. AmmoType and MaterialType ride PublicWeenieDesc through CreateObject and land on ClientObject as ushort/uint; ItemUseable and CombatUse arrive as PropertyInt 16 and 51. Nothing named them, so every site that reasoned about them did it in hex. AmmoType (acclient.h:4221) and CombatUse (acclient.h:6523) are small and unsurprising. ItemUseable (acclient.h:6478) is neither: it is two 16-bit halves, low for where the used object must be and high for where its target must be, and retail names roughly thirty specific combinations rather than expecting callers to compose them. They are transcribed rather than composed because at least one is not the union it looks like - SOURCE_CONTAINED_TARGET_OBJSELF_OR_CONTAINED is 0x880008, where composing ObjSelf|Contained|(Contained shifted 16) gives 0x800088. A test asserts that specific non-equality so the shortcut cannot be reintroduced. ItemAppraisalTextFormatter's ammunition sentence now reads through AmmoType instead of matching 0x08/0x40/0x10/0x80/0x20/0x100 literals. The fold it performs - crystal and chorizite variants collapsing to their base arrow/bolt/atlatl kind - was already exactly right against retail's bit layout; this only gives it vocabulary. No behavior change, and the appraisal tests confirm it. Core tests 3,836 -> 3,894. Full suite 9,759 passed / 5 skipped, no failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
8ccaf72ae7 |
feat(core): adopt the retail members the equipment and physics enums were missing
With the two wrong enums corrected, the remaining wire-adjacent families diff cleanly against the retail header - same values everywhere they overlap, just fewer members on our side. This adopts the gaps. EquipMask gains retail's eleven INVENTORY_LOC composite slot groups (acclient.h: 3193). The 32 primitive slots were already exact and stay pinned by EquipMaskTests; what was missing were the groups the wire and the UI actually reason in - Armor, Jewelry, ReadySlot, Weapon, WeaponReadySlot, the wrist/finger/sigil pairs, and All. These are transcribed as literals, not derived, for the reason the previous commit documents at length. That transcription immediately earned itself. A type remark on EquipMask claimed retail's CLOTHING_LOC composite "also sets bit 31, 0x80000000, which is not a named INVENTORY_LOC primitive". It does not. CLOTHING_LOC is 0x080001FF: the nine wear slots plus bit 27, which is the perfectly well-named Cloak slot. No INVENTORY_LOC member touches bit 31 at all - ALL_LOC stops at bit 30. The remark is corrected and a test now asserts the actual decomposition. TransientStateFlags gains WaterContact (0x8) and CheckEthereal (0x100), the two retail bits acdream's transition never declared. Neither is produced or consumed yet; they are named so those slots cannot be quietly reused for an acdream-local flag and then collide. PhysicsStateFlags gains ReservedUnused1 (0x2) and ReservedUnused2 (0x2000), which retail declares as UNUSED1_PS/UNNUSED2_PS. Same reasoning: reserved is a fact worth recording. AttackHeight gains Undef = 0. The three real heights are 1-based and were already right; retail reserves 0 and the wire sends it, so it is now named instead of arriving as an undefined cast. The numeric values are unchanged, so this renames nothing at runtime. Also checked and found already correct, so left alone: ObjectInfoState (matches ObjectInfoEnum exactly, None being DEFAULT_OI), AttackType (every primitive plus both composites - Unarmed 0x19 and MultiStrike 0x79E0 - land on retail's literals), RadarBlipShape, RadarBehavior, MovementType, HoldKey, ParticleType, and PhysicsDescriptionFlag. AttackType is worth calling out because the campaign's extraction tooling reported it as a conflict; the tool reads one line per member and had truncated a multi-line composite. The enum was fine. RetailEnumConformanceTests grows tables for each of the above, each citing its acclient.h line. Core tests 3,785 -> 3,836. Full suite 9,701 passed / 5 skipped, no failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
f3e95a3ebd |
fix(core): correct DamageType's rotated bits and ItemType's shifted craft ladder
Two enums disagreed with the retail client, and both disagreements were the quiet kind - nothing read the wrong members, so nothing was visibly broken. They were traps armed for the first person to write a comparison against them. DamageType had its four drain/restore bits rotated. acdream assigned Nether/Mana/Health/Stamina to 0x80/0x100/0x200/0x400; retail's DAMAGE_TYPE (acclient.h:3788) assigns Health/Stamina/Mana/Nether. The ACE weenie corpus attests retail's order independently - 0x100 Stamina, 0x200 Mana, 0x400 Nether - and so does the vendored client-side enum catalog. Tellingly, both of acdream's live damage-type name tables, CombatChatTranslator.FormatDamageType (ported from holtburger) and ItemAppraisalTextFormatter.TryDamageTypeName, already used retail's order reading the raw wire uint directly. The enum was the only thing in the tree that was wrong. Retail's BASE_DAMAGE_TYPE (0x10000000) was also missing; CombatChatTranslator already knew about it. ItemType had two separate problems. The craft ladder was shifted one bit: CraftAlchemyIntermediate sat on 0x02000000, which retail leaves unused, and an invented CraftCookingIntermediate occupied 0x04000000, which is retail's real alchemy-intermediate bit. The weenie corpus attests 0x04000000 as Craft_Alchemy_Intermediate 235 times and contains no cooking-intermediate at all - there is no such item type. Separately, the composite masks were recomputed locally from the bits above them instead of transcribed, which is exactly how the ladder drifted in the first place. That made Weapon (retail 0x101, melee|missile) an exact alias of WeaponOrCaster (0x8101), and left Item at 0x830F where retail's TYPE_ITEM is 0x2DFBEF - a mask two orders of magnitude broader. The composites are now transcribed as literals with retail's value, not derived, and the five retail-only masks acdream never had (portal/lockable magic targets, the enchantable and redirectable targets, and the two vendor masks) come along. Note for the reader wondering why the campaign trusted retail over the catalog here: on CraftFletchingBase the catalog is the one that is wrong (it says 0x02000000; retail and acdream both say 0x01000000). No single oracle was assumed correct - retail's header decided, with the weenie corpus as the tiebreak. Behavior: no production code reads any changed member. The only reference in the tree is a test that wants a nonzero HookItemTypes and does not care which. So no branch changes and no wire behavior moves - but the values did change, which is why this is a fix commit and not a data commit. No divergence-register row: these were unintentional errors, now retired, not deviations we chose. RetailEnumConformanceTests pins both enums to the acclient.h tables, asserts acdream declares nothing retail does not, and calls out the two specific traps - that 0x02000000 stays unclaimed, and that Weapon and WeaponOrCaster are no longer the same value. Core tests 3,726 -> 3,785. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
251dd68a92 |
feat(core): give AC's seven property tables names, verified against two oracles
acdream has carried property IDs as bare uints since the beginning. The wire parsers read `u32 property` and hand it to a `Dictionary<uint, int>`, and every call site that cared re-derived the meaning from a comment - `EncumbranceVal` was spelled `private const uint EncumbranceValProperty = 5u` in two different files, `UiEffects` lived as "ACE enum value 18" in a doc comment, and `AetheriaBitfield` as "322 / 0x142". That is 864 pieces of vocabulary the codebase was expected to remember in prose. This adds the seven enums - PropertyInt, PropertyInt64, PropertyBool, PropertyFloat, PropertyString, PropertyDataId, PropertyInstanceId - under AcDream.Core.Properties. Every member is transcribed from an oracle; none is invented. Two independent sources were extracted and diffed against each other: the vendored client-side enum catalog at references/acclientlib/UtilityBelt.Common/Enums/Enums.cs (which names these tables IntId/BoolId/FloatId/...), and the 38,985-file ACE weenie export corpus at references/weenies/, whose every stat entry carries the numeric key beside the enum member name in its `_comment`. The corpus attests 408 of the 864 members directly. Across all seven tables the two oracles produced zero value conflicts, and the corpus contained no key the catalog was missing - the catalog is a strict superset of everything 38,985 weenies actually set. Three members disagree on spelling, never on value: the catalog says ObjectType/HookObjectType/MerchandiseObjectTypes where ACE says ItemType/HookItemType/MerchandiseItemTypes. acdream takes ACE's spelling, which is what the weenie corpus emits (37,329 attestations for ItemType alone) and what acdream's own ItemType enum already calls it. The catalog's alias is recorded on each member. This commit is vocabulary only - no parser reads these enums yet, so no branch changes and no wire behavior moves. The bundles stay `Dictionary<uint, ...>` precisely because an unknown key must still round-trip untouched; the enums describe the keys we know, they do not constrain the ones we receive. PropertyEnumConformanceTests pins the result: the full name/value table per family, the uint underlying type, no two members sharing a value, and a separate 408-case theory asserting each weenie-attested pairing individually. A hand edit to any enum now fails loudly instead of quietly mis-reading the wire. Core tests 3,297 -> 3,726. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
844cf092a1 |
feat(render): Campaign V slice V11 commit 1 - delete ImGui, Studio, and the DevTools frontend
The ImGui developer-tools stack (AcDream.UI.ImGui), UI Studio (src/AcDream.App/Studio), and the DevToolsFramePresenter/ SettingsDevToolsCompositionPhase ImGui composition machinery are removed. Vulkan never composed a DevTools frontend (DevToolsEnabled already forced false whenever the backend was Vulkan); this commit makes that permanent by deleting the only implementation rather than leaving a dead branch behind. What moved: Studio/SampleData.cs is a live production dependency (InteractionRetainedUiComposition's character-sheet fallback, plus three UI.Layout test files) - git mv'd to src/AcDream.App/UI/Layout/SampleData.cs, namespace AcDream.App.UI.Layout, and trimmed to the SampleCharacter API that is actually still called (BuildObjectTable/AddItem/AddEquipped/the item-guid and icon constants had zero callers left once the Studio fixture provider that used them was deleted). What survives as backend-neutral seams, per the tests that still exercise them: IDevToolsFrameLifecycle (moved into RenderFramePreparationController.cs, now always bound to null), IFramebufferDevToolsTarget/FramebufferDevToolsBinding in FramebufferResizeController.cs (its concrete DevToolsFramebufferTarget adapter is deleted), and IDevToolsGameplayCommands in GameplayInputCommandController.cs (DevToolsGameplayCommands becomes a documented no-op instead of forwarding to the deleted presenter). A follow-up re-homes Settings/Debug onto the retained UI through IPanelRenderer; until then keybind remapping falls back to editing keybinds.json. DevToolsEnabled is now `private const bool DevToolsEnabled = false`. RuntimeOptions.DevTools is unchanged and still reaches VulkanGraphicsContext for the optional debug-utils extensions; Program.cs now logs one line when ACDREAM_DEVTOOLS=1 explaining that the ImGui UI is gone and the flag is Vulkan-only now. Removed: AcDream.UI.ImGui (project + ImGui.NET/Silk.NET.OpenGL.Extensions.ImGui package refs), src/AcDream.App/Studio (minus SampleData.cs), DevToolsFramePresenter.cs and everything only it constructed (ISettingsDevToolsCompositionFactory, RetailSettingsDevToolsCompositionFactory, DevToolsCompositionOwner, IGameWindowSettingsDevToolsPublication, SettingsDevToolsOptionalDependencies, the "developer tools" shutdown-ledger stage and its DevTools-typed fields on IngressShutdownRoots/ RenderShutdownRoots), the ui-studio Program.cs verb, and the cimgui native manifest entries in GraphicalHostPlatformServices. GameWindow.cs's DevTools composition branch, its _vitalsVm/_debugVm/_devToolsComposition/ _devToolsFramePresenter/_devToolsCommandBus fields, and every settingsDevTools .DevTools?.* access across FrameRootComposition.cs/SessionPlayerComposition.cs are gone with it. Build green; complete Release solution suite 8,830 / 5 skips (App Tests 4,097/3 skips run standalone - one #250-family zero-allocation test flakes under the full parallel `dotnet test AcDream.slnx` run, a pre-existing, documented class unrelated to this change). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
122fe8a7e2 |
feat(render): Campaign V slice V10 — Vulkan becomes the default backend
THIS CUTOVER AWAITS THE USER'S VISUAL SIGN-OFF. It is not complete. Section 7
of the campaign plan names the V10 sign-off as the only required user stop
besides gate failures, and it has not been given. This commit flips the default
and runs the battery so that the sign-off has evidence in front of it.
ROLLBACK, one line: `git revert` of this commit. It restores the GL default,
the pre-V10 escape-hatch polarity and the gate scripts' inherited backend
together; nothing else has to move with it.
An unset, empty or unrecognised ACDREAM_RENDER_BACKEND now yields
RenderBackendKind.Vulkan. Only `gl` or `opengl`, case-insensitive, selects
OpenGL. The polarity of the typo case flipped with the default and on purpose:
before V10 an unrecognised token had to land on GL because Vulkan was dark and a
typo must never silently start a backend that cannot draw; after V10 it has to
land on Vulkan for the same reason read the other way, because GL is the backend
V11 deletes. `opengl` is honoured beside `gl` because an escape hatch exists to
be found.
Three gate scripts follow the flip. run-offline-pixel-gate.ps1 gains -Backend
(default vulkan) and now FORCES all four determinism levers — backend, day
group, world day fraction, sky phase — plus ACDREAM_MSAA_SAMPLES=0, instead of
inheriting any of them. run-repeat-connected-gate.ps1 and
run-connected-world-lifecycle-gate.ps1 CLEAR ACDREAM_RENDER_BACKEND rather than
setting it, so what they exercise is the process default and an ambient override
in a caller's shell cannot make a GL run wear the default's report.
TEST PIN UPDATED, flagged as required: RenderBackend_DefaultsToGl becomes
RenderBackend_DefaultsToVulkan, and RenderBackend_AnythingElseStaysOnGl splits
into RenderBackend_SelectsGlOnlyForTheEscapeHatchTokens and
RenderBackend_AnythingElseStaysOnVulkan. Five cases replace two. No other test
is touched, weakened or deleted.
AD-46's divergence-register row moves from "dormant until the V10 cutover" to
live, in this commit, per the same-commit register rule.
Battery, all on the new default:
complete Release suite 9,222 passed / 5 skipped / 0 failed (9 projects)
+5 against the pre-flip 9,217; the +5 are this
slice's own escape-hatch cases
#250 family, singly 4/4 pass (none failed in the whole-suite run)
repeat connected gate PASS 3/3 on both columns
world-lifecycle route PASS, 0 failures, both sessions graceful at exit 0
validation layer inserted at instance AND device level by the loader,
zero errors and zero warnings, real frame captured
GL escape hatch verified by two offline launches: 4.3.0 Core Profile
Context, bindless present, exit 0
Every connected launch in the battery reached Vulkan with no environment
variable set, which is the flip itself under test rather than an assertion
about it.
THE PIXEL GATE IS NOT MET, AND WAS NOT RELAXED. Vulkan against a GL-era capture
taken at this commit through the escape hatch, MSAA off and both clocks pinned:
1.099e-03 masked / 3.764e-02 whole-frame, against a 0.001 threshold. 97.9% of
the difference is in the treeline band, and the masked residual of 619 px — set
against a same-backend control of 10 px — sits entirely on the silhouettes of
distant alpha-blended scenery. That is AD-46's registered population; section
5.5.19 measured the same quantity at 497 px / 8.8e-04. Below the band the two
backends are photometrically identical: mean luminance differs by 0.01 of 255.
No baseline was regenerated and no mask or tolerance was widened.
Two instrument findings are recorded in section 5.5.23. The offline gate's sky
mask is still load-bearing — this slice tried retiring it on the reasoning that
V7's clock pins had made it obsolete, and the control refuted that: two launches
of the same binary still differ by 1,011 px on GL and 482 px on Vulkan, almost
all of it in the band. The default went back to 280 with the measurement written
into the script's help. And the repeat gate's desktop witness needs an
uncontested primary monitor: a first attempt reported 1/3, and the two failing
grabs turn out to be a web browser and Discord composited over the client rect,
not a blank frame — the client's Vulkan capture rendered in all six runs.
Nothing GL, ImGui or Studio is deleted. That is V11's scope and it is untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
c7861020e1 |
test(content): give the loader concurrency tests real concurrency
Fixing the two failures that were stopping portable-headless earlier let the job reach AcDream.Content.Tests for the first time on either operating system - the test loop exits on the first failing project, so the windows leg had never got past the apt step and the ubuntu leg had never got past Core.Net. Two RetailDatLoaderTests cases were waiting there, and they failed on both. Both assert on RawDatabase.MaxConcurrentReads after issuing two Task.Run reads that each block 40 ms in Thread.Sleep. A pair of pool work items is not a guarantee of two workers in flight: on a low-core or saturated pool the second queues behind the first, the reads run back to back, MaxConcurrentReads stays 1, and the assertion fails for a reason that has nothing to do with the loader. Pinning the suite to two CPUs on Ubuntu reproduces it 5 times in 6; Windows is clean 6 of 6 at sixteen cores, which is why nobody had seen it. The pairs now start with TaskCreationOptions.LongRunning on the default scheduler, which asks for a thread each. No assertion is changed - they still fail if the loader serialises. The two coalescing cases moved onto the same helper on purpose: two callers genuinely in flight is the situation coalescing exists for, and a sequential pair was only ever exercising a cache hit. Ten of ten clean under the same pin. Release build green. App tests 4,152 / 3 skipped. Content 124 / 124. Filed as #255. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a13cff884f |
ci(render): Campaign V slice V9 - the Vulkan gate runs on lavapipe
The first CI job in this project's history that renders a frame.
The whole row rests on a decision V6g already made and paid for. When
section 5.5.8 cut set 0 from ten dynamic storage descriptors to four, four
was not merely under the RX 9070 XT's eight - it is Vulkan's guaranteed
minimum, so no conformant device can fail the layout. That is what makes a
software-device row possible at all. Every other requirement was then
checked against Mesa's lvp_device.c rather than assumed, and all seventeen
features the gate demands are true on lavapipe - including
samplerAnisotropy, which V7 made load-bearing eight commits ago and which a
software rasterizer would have been entirely within its rights to decline.
Three things had to exist before the job could:
1. The harness could not stop. VulkanBringUpHost presents until its window
closes, which is right at a desk and impossible in CI, where nothing ever
closes a window. ACDREAM_VULKAN_PROBE_FRAMES gives it a budget; unset or
malformed is zero, which keeps the interactive behaviour, so no existing
invocation changes. The budget never cuts the capture short - the loop
stays open until the screenshot has been attempted - because a run whose
entire product is a PNG must not be able to exit green with an empty
artifact directory. The decision is a pure static method, tested without
a window or a driver.
2. tools/compile-shaders.ps1 was Windows-only and nobody had noticed,
because nothing had ever run it anywhere else. It built its paths from
embedded 'src\AcDream.App\...' literals; a backslash is a separator on
Windows and an ordinary filename character everywhere else, so on Linux
that is one long nonexistent file name.
3. The report's jq paths were invisible to the compiler. Renaming a record
property or swapping the enum converter would have left every test green
and turned CI red on someone else's branch days later, with a failure
that reads like a driver problem. VulkanCapabilityReportContractTests
pins the exact strings the job greps and pins its packed-version
arithmetic against VulkanApiVersion's own unpacking.
The job, eleven steps: install lavapipe and Xvfb; record vulkaninfo as
evidence; publish linux-x64; run the Gpu.Vk tests on a second operating
system; probe the gate under a 24-bit Xvfb screen (the default is 8-bit,
which leaves the X11 WSI without a usable visual) and assert an accepting
verdict on a Cpu device at API >= 1.3 with a clean active probe; assert the
captured PNG is a real frame by IHDR dimensions and byte count; re-run with
ACDREAM_VULKAN_FORCE_UNSUPPORTED=timelineSemaphore and assert exit 4 with an
actionable refusal; recompile the shaders and compare. Artifacts upload on
always(), so a red run ships its own diagnosis.
The .spv step is what ties the committed binaries to their sources. The
existing App test hashes GLSL against the manifest, which catches "edited a
shader, forgot to recompile"; nothing caught a stale or hand-edited .spv.
Verified on Windows before shipping: 19/19 artifacts byte-identical to a
fresh compile, zero drift.
No GL-versus-Vulkan pixel compare, for two independent reasons recorded in
section 5.5.20: linux-graphical asserts exit 4, so there is no left-hand
side, and the probe renders synthetic scenes rather than the DAT world CI
cannot have. The two jobs now say something sharper than a pixel diff would
have - on the same software Mesa stack, GL is refused and Vulkan is accepted
and draws. Physical Linux GPU and Wayland rows stay deferred on the Slice L
precedent; no hosted runner offers either.
Gates: Release build green, zero errors. App tests 4,152 / 3 skipped against
a 4,134 / 3 baseline at this branch's base (
|
||
|
|
1f25a60999 |
fix(diag): Campaign V slice V7 commit 2 - pin the world clock, because the route never did
THE ROUTE'S TIME PIN NEVER HELD, AND EVERY V7 NUMBER SO FAR WAS TAKEN THROUGH IT.
connected-backend-differential.route.txt opened by pressing
AcdreamCycleTimeOfDay three times, on the stated theory that the cycle walks
live -> 0.00 -> 0.25 -> 0.50 and lands on noon. The mechanism underneath is
WorldTimeService.SetDebugTime, and SyncFromServer clears it -- deliberately,
because that setter is the /time slash command and the command is meant to be a
look-at-dusk-for-a-moment affordance rather than a mode. There is even a test
pinning that behaviour: WorldTimeDebugTests.SyncFromServer_ClearsDebugOverride.
ACE sends TimeSync every few seconds. The clock was therefore un-pinned again
long before the route reached its first stop, on every run this campaign has
taken, including V6m's smoke pair.
The Dereth clock does not only move the sky. It moves the SUN, so it moves the
directional term of every lit surface in the scene.
MEASURED, rather than argued. A probe route captured each stop TWICE, 45 seconds
apart, in the same run on the same backend:
GL, Holtburg, capture 1 vs capture 2: 205,772 px 22.33%
Vulkan, Holtburg, capture 1 vs capture 2: 218,732 px 23.73%
GL, Facility Hub, capture 1 vs capture 2: 108,795 px 11.81%
Vulkan, Facility Hub, capture 1 vs capture 2: 130,206 px 14.13%
One backend, one stop, nothing moving, and a fifth of the frame changes while
you watch. No cross-backend number means anything against that noise floor, and
the cross-backend numbers taken during that probe run were duly absurd -- 56% at
Holtburg, where the two launches happened to be at different times of Dereth day.
THE FIX IS A PIN THAT OUTRANKS THE SERVER CLOCK AND SURVIVES SYNC.
WorldTimeService.PinnedDayFraction is a nullable day fraction that wins over both
Calendar.DayFraction(NowTicks) and SetDebugTime, and that SyncFromServer does not
touch. ACDREAM_WORLD_TIME -> RuntimeOptions.PinnedWorldDayFraction ->
WorldEnvironmentController, which writes it once: the Runtime environment owner
and its clock are session-scoped, so one write outlives every teleport and every
reveal generation. Values outside [0, 1) are REJECTED rather than clamped -- a
day fraction of 12.5 is a typo, and silently pinning the world at it would be
worse than ignoring it.
Unset is the default and every ordinary run. The calendar DATE still advances,
which is intentional: the date drives day-group selection, and ACDREAM_DAY_GROUP
already pins that. The differential gate forces the pin at 0.5 -- noon, which is
what the three presses were aiming at -- on both launches, and the route's
presses are deleted rather than left in as decoration.
This is instrument determinism on the footing of ACDREAM_DAY_GROUP and V7's
ACDREAM_SKY_PHASE_SECONDS, not a workaround: it is off by default, nothing in the
shipping client reads it, and the alternative was to keep measuring two backends
through a fifth of a frame of sunlight.
WHAT IT MOVED. The same three-stop route, same commit otherwise, before and after:
holtburg_town 9.05% -> 2.86% (83,438 -> 26,330 px)
facility_hub_interior 12.16% -> 0.78% (112,075 -> 7,176 px)
aerlinthe_island 23.09% -> 6.82% (212,824 -> 62,892 px)
The interior stop is the headline. V6m recorded it as a route defect on the
theory that the indoor spring-arm camera settles to different distances in two
runs; that theory is now refuted. The camera was fine. The interior was lit
differently because the sun had moved, and with the sun held still the stop drops
by a factor of 15 to 0.78% -- close enough to the 0.001 threshold that its
remaining population is worth naming rather than guessing at. No route change was
needed and none was made.
WHAT REMAINS, per the difference maps, all of it now attributable by eye:
the animated portal beside the Holtburg stop; distant scenery foliage; wandering
NPCs and a chimney smoke plume, which are animation and emitter phase; the vitals
readouts, whose stamina and mana genuinely regenerate at different rates across
two logins minutes apart; and, at Aerlinthe, a dense low-magnitude speckle in a
scene whose mean luminance is 28/255 -- half of its differing pixels are exactly
delta 3, one step over a tolerance that is absolute rather than relative.
Gates. Release build green. App tests 4,134 passed / 3 skipped (one new: the
day-fraction range check); AcDream.Core.Tests WorldTimeDebugTests 6/6, including
the two new ones that assert the pin survives a sync and outranks the transient
override. GL offline pixel gate against the pre-slice tree: 2.66e-05, 15 pixels
of 563,200, inside the documented 9-31 band -- GL did not move.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
ad5f8b68dc |
fix(render): Campaign V slice V7 commit 1 - world anisotropy, and the sky's second clock
Two changes, one measurement. The V6m smoke pair put GL versus Vulkan at Holtburg at 18.52% of the frame differing at tolerance 2 with MSAA off. The same stop on the same instrument now measures 9.05%, and the two populations these address are gone from the difference map rather than merely smaller. 1. THE WORLD ATLASES WERE SAMPLED WITHOUT ANISOTROPY ON VULKAN, AND WITH THE DEVICE MAXIMUM ON GL. RhiWorldTextureArray -- the backend-neutral shared object/material atlas, and the only IWorldTextureArray the Vulkan arm ever constructs -- registered its clamp and repeat slots with GpuSamplerDescription.WorldClamp/WorldRepeat as written, which carry MaxAnisotropy 1. The GL arm asks for the driver's own GL_MAX_TEXTURE_MAX_ANISOTROPY twice over: ManagedGLTextureArray sets GL_TEXTURE_MAX_ANISOTROPY on the image, and the two sampler objects its resident bindless handles are built from (OpenGLGraphicsDevice.WrapSampler/ClampSampler) set it again, which is the one that actually wins. V6i-2 knew it was asking for 1 and said so in a comment -- "the world arm that draws through these arrays is the next slice, and it is the one that can gate a filtering change visually." That slice was V6j, the gate is V7, and this is it. Retail settles the question rather than the GL arm settling it. RenderDeviceD3D::SetDefaultD3DStates (0x005a3800) loops all sixteen sampler stages and issues SetSamplerState(stage, 0xA, this->m_D3DCaps.MaxAnisotropy) at 0x005a4230. 0xA is D3DSAMP_MAXANISOTROPY and the argument is the device's reported cap, not a setting -- so "as much anisotropy as this device has" is retail's own rule, the GL arm is faithful to it, and asking for 1 diverged from retail as well as from the shipping backend. No divergence-register row is owed in either direction: this retires a Vulkan-only gap and lands on retail's value. The fix asks for a ceiling rather than reading a limit back, because the pinned RHI contract (plan section 3.3) carries no anisotropy field and is frozen. It does not need one: VulkanGpuSampler already clamps MaxAnisotropy to VkPhysicalDeviceLimits.maxSamplerAnisotropy, Vulkan guarantees that limit is at least 16 wherever the samplerAnisotropy feature is supported -- which this backend requires -- and 16 is where every desktop driver caps. The request and the GL arm's read therefore land on the same number. What it was worth, from the difference map at the same stop: the roof shingles of both Holtburg cottages, which had been dense hatching across the whole surface, and the stone courses of the near building are now black. Measured as high-frequency energy (mean absolute neighbour difference, GL versus Vulkan) the right-hand roof went from visibly blurred to a ratio of 0.999 and the wall to 1.023; every other textured region in the frame is between 0.99 and 1.02. Grazing-angle surfaces are where anisotropy is the whole difference, which is why a roof was the loudest thing in the frame. 2. THE SKY HAS TWO CLOCKS AND ONLY ONE OF THEM WAS PINNABLE. ACDREAM_DAY_GROUP and the route's AcdreamCycleTimeOfDay presses pin the Dereth date, which chooses the day group, the keyframe and the sun angle. The cloud sheet does not read that clock: SkyRenderer accumulates TexVelocityX/Y against DateTime.UtcNow minus its own construction time, by design, because retail's clouds drift with real time regardless of the date. Two launches minutes apart therefore cannot agree about where the clouds are no matter what the route does, and the V6m smoke measured the cost -- 89% of its 18.52% sat in the top 240 rows. ACDREAM_SKY_PHASE_SECONDS (RuntimeOptions.SkyAnimationPhaseSeconds -> SkyRenderer.AnimationPhaseSecondsOverride) replaces that elapsed-seconds value with a fixed one. Unset -- the default, and every ordinary run -- keeps the wall clock, so nothing a user or the offline gate sees changes. The differential gate forces it on both launches alongside MSAA and the day group; the offline gate keeps its top-280 mask, because a same-commit GL pair still has the sun to disagree about. This is instrument determinism on the same footing as ACDREAM_DAY_GROUP, not a workaround: it is one input to a UV offset, it is off by default, and no shipping path reads it. The alternative on the table was -MaskTopPixels, which would have permanently blinded the campaign's strictest instrument to the entire sky -- one of the five surfaces the offline gate already cannot see. Rows 0-32 of the Holtburg pair went from 23,090 differing pixels to 1,211, and what remains up there is roof and portal rather than cloud. WHAT THE SAME PAIR STILL SHOWS, unattributed and carried to the next commit: the distant treeline, the player and the NPCs, and the animated portal. The portal is phase and expected. The treeline is not filtering -- sharpness now matches within 5% and a shift search finds no sub-pixel offset -- and the two runs entered the world at different last-logout positions (0xC95B0001 versus 0x09040008), so the far-tier streaming history differed. That is the next thing to prove or refute. Gates. Release build green. App tests 4,133 passed / 3 skipped against the 4,132/3 baseline (one new: the sky-phase parse). GL offline pixel gate against the pre-change tree: 2.31e-05, 13 pixels of 563,200, inside the documented 9-31 band -- GL did not move. One offline Vulkan run with VK_LAYER_KHRONOS_validation proven inserted by the loader: zero validation errors, zero warnings. Full three-stop differential recorded at artifacts/v7-diff-c1. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a7529a975a |
test(app): serialize the classes sharing camera/render process globals (#252)
A full Release App run failed once at Issue181WallPressEquilibriumTests
.Diagnostic_WallPressedCamera_EyeWanderAndViewerCellStability. It passed in
isolation and did not recur across five further whole-suite runs, and the diff
under test touched only the world texture-creation stack -- nothing in camera,
visibility or physics. A cross-class parallelism race was the only plausible
mechanism, not a regression.
Ten App test classes share three process-global mutable statics, and xUnit runs
distinct test classes in parallel by default:
- CameraDiagnostics: AlignToSlope, CollideCamera, TranslationStiffness,
RotationStiffness, UseRetailChaseCamera. These are not merely written, they
are written AWAY from their defaults -- RetailChaseCameraTests sets
AlignToSlope and CollideCamera to false, and three classes set
UseRetailChaseCamera to false -- while RetailChaseCamera.Update,
CameraController.Active, CameraFrameController, WorldRenderFrameBuilder and
MouseLookController read them.
- RenderingDiagnostics.ProbeFlapEnabled, written by CornerFloodReplayTests
and Issue181WallPressEquilibriumTests.
- System.Console.Out, redirected by those same two classes to capture probe
output.
Every one of these classes already saved and restored in try/finally. That is
correct within a class and remains necessary, but it was never sufficient. A
finally bounds a mutation in TIME along its own thread; it cannot stop another
class from reading the static inside that window. Worse, two overlapping
save/restore pairs can interleave so the second restore writes back the FIRST
one's temporary value, leaving the global permanently wrong for the rest of the
run. The Console.Out case is the sharpest instance: an interleaved restore can
install a DISPOSED StringWriter as the process-wide Console.Out, which then
throws in unrelated tests. Serializing the sharers is what makes each class's
existing finally sufficient.
The fix is a marker CollectionDefinition applied to the ten sharing classes,
following the WorldEnvironmentControllerCollection precedent. No collection
fixture: several members are [Theory] cases that need different knob values per
case, so a fixture cannot own the save/restore without rewriting every member's
internals, and it would not help the read side at all. Because every member
references the same compile-time const for the collection name, the grouping
cannot silently drift via a typo.
Membership is deliberately narrow. It covers the eight writers plus two classes
that drive production code which READS a knob another member moves off its
default (HouseExitWalkReplayTests and CameraFrameControllerTests both run
RetailChaseCamera.Update and assert on the resulting eye). Classes that merely
construct a CameraController without a retail chase camera are NOT members --
their reads fall through the null branch and are insensitive.
No production code changed; no assertion was weakened, and no retry, sleep or
tolerance was added.
Verification. Base commit
|
||
|
|
59c6b2ae94 |
feat(render): Campaign V slice V6m commit 1 - portal space draws on Vulkan
PortalTunnelPresentation was the last raw-GL world-adjacent renderer. It now
draws on both arms, and the composition that used to hand the Vulkan arm a
portal-less teleport presentation is gone with it.
Nothing about the scene changed. Same synthetic DAT Setup resolved through the
same client-enum mapping, same 40 fps CSequence, same retail rotation cadence,
same distant light, drawn through the same already-dual-arm WbDrawDispatcher.
What forked is only where the draw is recorded:
* GL keeps its GLStateScope, its viewport/scissor/depth/cull/blend statements
and its depth-only glClear, untouched.
* The RHI arm opens a backbuffer pass of its own and publishes it on
IWorldPassScope for the span of the draw - the shape V6l gave the two
offscreen viewports, and required for the same reason: the dispatcher's RHI
arm borrows its pass rather than opening one. Publication comes after
BeginPass and before UploadRetailLight, because publishing resets the
frame-global sections and this scene wants its own light, not the world's.
The one substantive decision is the pass's COLOUR load op, and it is a Clear
rather than a Load. Retail preserves the colour target and only clears depth
(UIViewportObject::DrawContent @ 0x006950A5 -> Clear(4) = D3DCLEAR_ZBUFFER), and
so does the GL arm. A Vulkan pass cannot inherit an image the way a bound
framebuffer can: under MSAA the frame's world pass RESOLVES into the swapchain
image and stores DontCare into the multisampled scratch, so a second
multisampled pass declaring Load would load undefined contents - plan section
5.5.12 item 5, the same hazard that merged the clear into the world pass.
Re-clearing is exact rather than approximate because of an invariant the frame
graph already enforces. RenderFrameFoundation.PortalViewportVisible and this
scene's IsVisible are the same value, read once at the top of the frame, and
WorldSceneRenderer returns without drawing when it is set. So whenever portal
space draws, the backbuffer holds exactly the opaque black
SceneTool::BeginScene @ 0x0043DAD0 establishes and nothing else, and clearing to
that same black changes no pixel. The alternative - a single-sampled Load pass
over the resolved image - would have been both a silent MSAA divergence and
invalid, since the backbuffer's depth attachment is multisampled.
The pass takes IWorldPassScope.SampleCount, so WbDrawDispatcher's sample-count
pipeline variants (V6l) select the backbuffer set, and depth matches the
attachment.
CreateRequired becomes internal: its two new seams are internal RHI contracts
and composition is its only caller. The TYPE keeps its visibility - plan section
7.1 rule 3.
Gates. Release build green. App tests 4,132 / 3 skips against the 4,129
baseline (three new: the retail black constant, the RHI arm's composition
precondition, and the both-arms composition assertion). Complete Release suite
9,195 / 5; one AcDream.Content failure in the solution-wide run that passes
124/124 rerun alone - the documented rerun-singly flake class, not carried
forward as a claim. Strict GL offline pixel gate against
|
||
|
|
280f3b3fe9 |
fix(test): stop the streaming priority-apply tests reading a warm JIT
Five tests in StreamingControllerPriorityApplyTests passed only when a sibling ran first in the same process. Run alone, they failed on assertions about world-state residency and completion backlog: DungeonCollapseBeforePromotionBase (line 355), InFlightNearLoad_DemotedBeforeFirstCompletion (536), HardRecenter_RejectsOldOverlappingLoadAndUnloadGenerations (582), HardRecenter_DropsStaleOutboxThroughBoundedAdmission (626), and DeferredCompaction_ApplyFailureRetainsExactResult. The state a sibling supplied was not data. It was compiled code. StreamingController meters each Tick against a wall-clock ceiling and StreamingWorkBudgetOptions.Default allows 2 ms per frame; these tests took that default. A cold first Tick has to JIT the whole publication path, and the meter's own diagnostics measured it at 10.55 ms with LastLimit=Time and one yield at stage publication-spatial-commit. The frame's first operation is admitted unconditionally through ensureProgress, so applyTerrain ran and the terrain assertion passed; the very next reservation, the GpuWorldState spatial commit, was refused, so the landblock never became resident in that frame. Any sibling that publishes a landblock first (DuplicateNearCompletions, for instance) warms that path and the same Tick then fits inside 2 ms. Pairing the failing test with that sibling passed; pairing it with DestinationReservation_StaleGenerationCannotClearReplacement, which drains no completions and therefore JITs nothing, still failed. Yielding mid-publication and resuming next frame is correct production behavior and other tests in this file assert exactly that. The defect was the setup: these tests assert which results publish, in what order, and under which generation, yet left the elapsed-time dimension at a value that made every assertion a function of machine speed and test order. Every controller in the class now takes a budget whose time ceiling cannot bind, applied uniformly so the next test added here does not reacquire the dependency. Count and byte ceilings keep their real values, including the deliberately small MaxCompletionAdmissions of ForceReloadWindow_DiscardsBufferedCompletionsFromOldWindow and the MaxCompletionsPerFrame scaling of the two tests that use it, so the bounded-admission behavior under test is untouched. No assertion was relaxed and no production code changed. All fourteen tests in the class now pass individually and together; Core is 3295 passed / 2 skipped, and two consecutive full-solution Release runs are 8826 passed / 5 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
eced67d038 |
feat(render): Campaign V slice V6l commit 2 - the portal mask draws on Vulkan
Contract amendment 2 of three, and V4g's remaining half behind it. Plan section
5.5.16 defect 2: PortalDepthMaskRenderer's two-pass punch (#117) is built on
glStencilFunc/glStencilOp/glStencilMask, GpuPipelineDescription carried no
stencil state at all, and nothing else can express it - so the renderer stayed
raw GL, invisible to the Vulkan arm, and V4g's "stencil/depth-mask pipelines"
row could not be written.
The amendment splits the way core Vulkan 1.3 splits. The ENABLE and the
attachment intent are baked: GpuPipelineDescription.StencilTest, false by
default so no pipeline in the tree changed. The per-draw compare, three outcome
ops, reference and both masks are a GpuStencilState that the pipeline carries as
a DEFAULT and IGpuPassEncoder.SetStencil overrides - exactly the split cull
mode, front face and depth write already have, and exactly what
VK_DYNAMIC_STATE_STENCIL_OP/_COMPARE_MASK/_WRITE_MASK/_REFERENCE make dynamic.
The four stencil dynamic states are declared ONLY by a pipeline that tests
stencil: declaring a dynamic state obliges every draw with the pipeline to have
set it, so adding them unconditionally would make every existing pipeline depend
on a call none of them make. GpuStencilOp carries three values because the punch
uses three - Replace marks, Equal gates, Zero self-cleans - and a fourth would
be a facility with no consumer.
The arm. Three pipelines, not one, because depth COMPARE is not dynamic in the
contract and the punch's two passes differ in it: mark tests LEQUAL and writes
no depth, punch tests ALWAYS and writes, seal is ALWAYS + write with no stencil.
All three write no colour, which is what retail's "COLOR-INVISIBLE triangle fan"
means. The fan is expanded to a triangle LIST on the CPU - the contract has no
fan topology and Vulkan's is not portable - which is exact: triangle i is
(v0, v[i+1], v[i+2]), the same triangles in the same order.
portal_depth.{vert,frag} is a new committed shader pair, and this is the ONE
renderer in the campaign whose two arms do not share a source. Its clip planes
have to travel in the TerrainClip uniform block at binding 2, which is already
precisely this shape and already read by terrain_modern.vert and sky.vert - but
on GL that binding is held globally by ClipFrame for terrain, so a portal draw
that rebound it would leave every later terrain draw in the frame reading the
wrong region. The GL arm therefore keeps its inline program.
PortalDepthShaderParityTests is the tripwire: retail's far-Z constant
(0.99999988, from DrawPortalPolyInternal 0x0059bc90), #129's capped mark-bias
expression and the eight-half-plane loop are asserted to appear in both. Both
are deleted at V11. 9/10 shader pairs now compile to SPIR-V.
Two GL-side gaps closed while the state was being extended, both of section 7.1
rule 1's class rather than new work. GlAmbientCapabilityState now saves and
restores the stencil test, function, ops and both masks - the portal punch draws
mid-frame among renderers that are still raw GL and assume the test is off - and
the COLOUR MASK, which had no consumer until a colour-invisible pipeline existed
and whose absence would have blacked out every raw-GL renderer after such a
pass.
PortalTunnelPresentation was re-read and confirmed as V6k left it: it clears
depth and draws into the active viewport, binds no framebuffer of its own, and
needs no port for section 5.4's sake. It remains unported on the Vulkan arm -
the composition uses NullLocalPlayerTeleportPresentation there - which is an
absence on the V7 list, not a defect.
Gates. Release build green. App tests 4,129/3 skips; complete Release suite
9,192/5 (one solution-wide run reported a single App failure that did not
reproduce in two subsequent runs, solution-wide or alone - the documented
rerun-singly flake class). Strict GL offline pixel gate against
|
||
|
|
b1ad1d481b |
feat(render): Campaign V slice V6l commit 1 - particles draw on Vulkan
Contract amendment 1 of three, and V4e's content behind it. Plan section 5.5.16
recorded that both particle pipelines draw with per-instance VERTEX attributes
and that the pinned contract could express instanced DRAWING but not instanced
vertex INPUT: one stride, no divisor, one buffer at VertexInputRate.VERTEX. That
is what stopped V4e. This takes the reviewed option (i) - a second vertex
binding with a per-instance rate.
The amendment. GpuVertexLayout grows a per-binding notion (binding index,
stride, input rate) and GpuVertexAttribute names the binding it is fed from,
defaulting to 0; IGpuPassEncoder.BindVertexBuffer takes a binding index. Every
layout written before this slice keeps its exact meaning through
GpuVertexLayout.Interleaved, which is one vertex-rate binding 0 - and
GpuContractTests asserts that as a requirement rather than trusting it. Both
backends carry the rate natively and at no cost: VK_VERTEX_INPUT_RATE_INSTANCE
on the pipeline, glVertexAttribDivisor recorded once into the pipeline's VAO
where it survives every later attribute rebind.
GpuVertexFormat.UInt1 comes with it, and is necessary to it: particle.vert
declares `layout(location = 6) in uint aTextureIndex` and the amendment's whole
premise is that no shader is edited. Same kind-distinction UByte4UInt was added
for at V4d - GL needs glVertexAttribIPointer, Vulkan needs R32_UINT, and the
float path would reinterpret the value's bits rather than approximate them.
Options (ii) and (iii) were rejected on the record: all ten storage bindings are
spoken for and reusing binding 0 would have the GL particle draw clobber
WbDrawDispatcher's instance array mid-frame (section 5.5.8's hazard in its GL
form); CPU-expanding instances is 5x billboard bandwidth and does not scale to
mesh particles at all.
The arm. ParticleRenderer.Rhi.cs is a SECOND arm per section 5.5.6, not a
replacement - every GL statement in the sibling file is the one it always
issued. Five pipelines replace the imperative glBlendFunc switch (two billboard
blends, three mesh blends) because core Vulkan 1.3 does not make blend dynamic.
The per-flight VAO/VBO pool disappears because every ring allocation inside a
frame is already distinct memory that lives until the frame retires. The
binding-9 table is not bound at all - the device owns the table and the encoder
binds set 2. The pass is BORROWED from IWorldPassScope. Depth tests but does not
write, compare is Less and alpha-to-coverage is off, which is the ambient GL
state particles have always drawn under rather than a choice. Everything above
the submission seam - emitter iteration, retail distance ordering, the
deferred-alpha handoff, billboard axis construction, blend resolution - is the
same CPU code on both arms.
The first Vulkan particle frame threw rather than drew, which is the second
defect of the compiles-clean class this slice found by running:
TextureCache.AcquireParticleTexture is bindless-only, so the standalone particle
texture cache did not exist on a backend without GL. It exists on both arms now.
Everything about it that matters - sharing equivalent surfaces between emitter
owners, the bounded unowned LRU, retirement behind the frame-flight fence - is
already backend-neutral; only how one entry is created and destroyed differs,
which is what IStandaloneBindlessTextureBackend is for. The RHI arm creates the
image through IGpuDevice.CreateTexture with a real sampler and releases the
table slot before the image, which is the GL arm's order and for the same
reason. The composite cache stays GL-only: it serves entity appearance, not
particles.
The durability fix V6k earned. That slice found the sky declaring a 32-byte
stride against a 36-byte AcDream.Core.Terrain.Vertex - the record carries a
TerrainLayer no sky attribute names - and noted that every .Rhi.cs arm restates
a CPU record's footprint from memory while only sky had a test.
RhiVertexLayoutStrideTests is that test for the rest: world mesh, terrain, sky,
retained-UI sprite, debug line, and both particle bindings, each asserted
against the record or the producer's own float count, plus two sweeps over all
seven for attributes that reach past their stride or name an undeclared binding.
Four private layouts became internal to be assertable; nothing else about them
moved.
Gates. Release build green. App tests 4,121/3 skips (4,109 baseline plus three
contract tests and nine layout tests); complete Release suite 9,184/5. Strict GL
offline pixel gate against
|
||
|
|
22aa2edc65 |
feat(render): Campaign V slice V6k commit 1 - the sky draws on Vulkan
V4f's content, landed as a SECOND arm per section 5.5.6: GL keeps its raw world
path through to V10 and the RHI world path ships on Vulkan. Every GL statement in
SkyRenderer is the one it always issued; the encoder arm lives in SkyRenderer.Rhi.cs
and runs only when there is no GL context.
What it produces. ACDREAM_RENDER_BACKEND=vulkan renders the sky: the dome
quadrants, the horizon band, the cloud sheet and the fog gradient, in the same
place and the same colours as the GL capture of the same scene (within a few
units on the channels sampled, which is the day-fraction drift between two
launches). Section 5.5.15's first V7 defect - "the sky is flat fog" - is closed.
Three things differ from the GL arm, each because Vulkan bakes what GL sets. The
per-submesh blend function becomes two PIPELINES, additive for sun/moon/stars and
straight alpha for everything else, because core Vulkan 1.3 does not make blend
dynamic. The SkyParams block becomes a ring slice taken per draw rather than one
buffer rewritten per draw, because a descriptor's contents are read at execution
time, not record time. And the pass is borrowed from IWorldPassScope, because the
frame's one backbuffer pass resolves and a second pass could not load what it
left.
The sky is the first Vulkan consumer of set 1 binding 4. Section 5.5.8 recorded
that UniformSkyParams was missing from the uniform set layout and V6i-2 added it;
until now nothing had ever bound it.
The stride bug, which is the fourth of its class this campaign. The first Vulkan
sky frame drew the dome as a field of blue-white noise. The RHI vertex layout
declared a 32-byte stride - position, normal, texcoord, exactly what sky.vert
reads - while AcDream.Core.Terrain.Vertex is 36 bytes: it carries a fourth
member, TerrainLayer, that no sky attribute names and that the GL arm never
described to a glVertexAttribPointer but did count, because it says
sizeof(Vertex). Nothing else in the frame looked wrong, no validation rule was
violated, and the offline pixel gate masks the sky band, so only a side-by-side
capture found it. SkyVertexLayoutTests now asserts the REQUIREMENT - the stride
is the uploaded record's footprint - rather than today's number.
The last interim handle table is gone. V4t retired the private
GlBindlessHandleTable in WbDrawDispatcher, EnvCellRenderer, TerrainModernRenderer
and ParticleRenderer and deliberately left the sky's, because the sky is the one
world path that mints its own resident handles from TextureCache's raw GL texture
names rather than interning someone else's. It now registers those handles
through V4t's RegisterWorldTextureHandle seam instead, which is the same
mechanical change the other four took, and the class and its tests are deleted
because nothing else ever used them.
TextureCache gains RegisterWorldSurface(surfaceId, repeat), the sky's RHI texture
source: the same DecodeFromDats the GL path uses, created through
IGpuDevice.CreateTexture and paired with a real sampler object rather than baked
into a bindless handle. Keyed by (surface, wrap) for the same reason the GL arm
keys its handles that way - a table entry is a combined image sampler, so the
dome sampled CLAMP_TO_EDGE and a scrolling cloud sheet sampled REPEAT are two
entries over one decoded texture.
Gates. Release build green. App tests 4,109 passed / 3 skipped - the 4,112
baseline less the six GlBindlessHandleTable tests that went with the class, plus
three vertex-layout tests. Strict GL offline pixel gate against
|
||
|
|
f84eef3256 |
feat(render): Campaign V slice V6j commit 2 - Dereth draws on Vulkan
The three world renderers' submission arms, both pass executors, and the
composition that reaches them. This is the unit three predecessors stopped at.
What it produces. ACDREAM_RENDER_BACKEND=vulkan on the offline scene renders
terrain with blended textures and road overlays, the water edge, static world
meshes, procedural scenery, and the complete retained UI - the same frame the GL
pixel gate captures, from the same camera, minus the sky. artifacts/v6j-vk2.
The shape, and why it is not V4c's. Section 5.5.6 chose option (B) after NVIDIA
rendered the V4c binary 10/10 where AMD's GL stack did not: GL keeps its raw
world path through to V10 as a documented fork confined to the submission seam,
and the RHI world path ships on Vulkan. So V4c's and V4d-2's content returns as a
SECOND arm rather than a replacement. The GL arm issues the same GL statements in
the same order against the same objects; the encoder arm lives in three .Rhi.cs
partials and is entered by one branch per submission site.
Three differences from V4c, each because the tree moved under it. There is no
binding-9 texture table - V4t put the slot on the device and Vulkan binds set 2,
so the arm that used to intern bindless handles simply has nothing to do. The
pipelines carry the device's sample count rather than 1, because Vulkan requires
rasterizationSamples to match the pass and alpha-to-coverage is a no-op at one
sample. And no renderer opens a pass.
That last one is structural, not tidiness. Under MSAA the frame's one backbuffer
pass resolves into the swapchain image and stores DONT_CARE into the multisampled
scratch, so a second pass declaring Load would load undefined contents; the
backend also permits one open pass per frame. VulkanWorldScenePhase therefore
opens the pass, publishes the encoder on VulkanWorldPassScope for exactly the
span of the inner WorldSceneRenderer, and every renderer borrows it.
Three sections are frame-global on GL and cannot be on Vulkan: the SceneLighting
UBO, the per-cell clip regions, and the terrain clip block. GL binds each to a
global binding point and every consumer inherits it. Vulkan binds a descriptor
set per draw, and a renderer's own binds are what select the scope those sections
must land in - so their writers PUBLISH into WorldFrameSections and each renderer
binds them inside the pass, after its own binds. SceneLightingUboBinding's
per-flight-slot buffer pool disappears with it: a ring allocation is already
distinct memory that lives until the frame retires, which is the property the
pool existed to provide.
Both pass executors became backend-neutral rather than gaining twins. Everything
they do is delegation to a renderer except four concerns - the clip-frame
publication, the doorway scissor, gl_ClipDistance enablement, and retail's
interior depth clear - so those four move behind IWorldPassSurface and retail's
ordering, which is what these classes are actually for, is written once. The GL
implementation issues the statements the executors used to issue inline.
Clip distances are no-ops on the Vulkan arm, and that is safe rather than a
divergence: Vulkan activates every element the shader declares, and all three
world vertex shaders already write 1.0 into every slot past the active count.
The interior depth clear becomes vkCmdClearAttachments, reached through the scope
so the pinned contract stays frozen and the backend-only verb stays in the
backend. The hook for it was already committed at V6i-3 with a cref to a type
that did not exist yet; it exists now.
The collision-wireframe DebugLineRenderer is composed as null on the Vulkan arm.
DrawAndPublish flushes it INSIDE the world phase and it opens its own pass, which
the one-pass rule forbids. The toggle is DevTools-only and DevTools is not
composed there, so nothing is lost - composing it would throw on the first
wireframe frame rather than silently misdraw.
Two seams widened rather than invented. GameWindowGraphics answers whether the
backend has a world-pass seam, because the three composition phases that need it
already borrow that handle and "does this backend work that way" is what the type
exists to answer. And MeshSourceReady replaces the anyVao != 0 gate with the same
question in backend-neutral form - V6i-3 published HasStores for exactly this -
so the predicate evaluates identically on GL.
What is NOT here, and is expected. Sky and weather are still raw GL (V4f), so the
Vulkan frame's sky is the atmosphere fog clear. Particles (V4e), the paperdoll and
appraisal viewports and the portal depth mask (V4g) likewise. The executors
already accepted all of them as absent.
Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged
baseline; complete Release suite 9,175 / 5. Strict GL offline pixel gate against
|
||
|
|
81fe5e1b63 |
fix(render): Campaign V slice V6j commit 1 - the Vulkan winding needs no inversion
VulkanViewportMapping has inverted the front face since V6c, on the standard argument that rendering with a negative viewport height mirrors framebuffer space and therefore reverses triangle orientation. The world arm is the first consumer that culls anything, and it falsified the inversion twice over on one frame. Nothing exercised it before now. Every Vulkan consumer through V6i - TextRenderer, DebugLineRenderer and the bring-up scene - declares Cull = GpuCullMode.None, so the mapping had never decided a single fragment. That is why a wrong answer survived four slices and a validation-clean run: an unexercised path. What the world arm measured, on the same offline scene the GL pixel gate captures. Terrain is the one single-sided surface acdream draws - FrontFace(Ccw) plus Cull(Back), matching ACRender::landPolysDraw's per-triangle eye-side predicate - and under the inversion it vanished completely, 190 multi-draw commands issuing against 625 loaded landblocks with nothing on screen. Every closed building shell rendered inside-out in the same frame: the front wall culled and the interior beams visible through the gap, which is what a back-face-front cull looks like on geometry that is only nearly convex. Declaring the GL winding verbatim restores both at once - terrain draws single-sided from above, and the shells close. Two independent surfaces, one change, and the correction is the identity mapping. Recorded here rather than worked around in the renderers, because a renderer that compensates for its backend is exactly the shape this file exists to prevent: the contract says renderers speak GL and the backend translates, and the backend was translating wrongly. The viewport flip itself is untouched and still correct - it is what puts GL-authored geometry the right way up with no shader or matrix change. What goes is the claim that a winding inversion has to travel with it. The scissor's explicit flip is a separate correction with a separate justification and is likewise untouched. The test suite says so now rather than describing the old behaviour: the pass-through is asserted directly, and the exact-inverses test becomes a travels-alone test, so a later change that reintroduces the inversion fails here first and on any single-sided surface second. Gates. Release build green. App tests 4,112 passed / 3 skipped, the unchanged baseline. GL offline pixel gate unaffected by construction - this file has no GL arm - and measured with the world arm in commit 2. No divergence-register row: this corrects a backend translation error rather than introducing a deviation from retail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fe8abacfc6 |
feat(render): Campaign V slice V6i-3 commit 1 — the mesh pipeline's upload bodies cross the seam
V6i-2 cut IMeshPipelineDevice at the measured surface and proved the mesh
pipeline could be CONSTRUCTED without naming a backend. It said plainly what it
did not claim: "the mesh pipeline does not RUN on Vulkan. Its upload bodies are
still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer transfers."
This moves them, and gives the interface its second implementation.
GlobalMeshBuffer takes GL?. The two backing stores were already IGpuBuffer
(V4b); what still needed a context was the vertex array and the attribute
pointers, which have no RHI verb because Vulkan bakes vertex input into the
pipeline. So a backend with none builds the stores and nothing else, publishes
0 for VAO/VBO/IBO, and publishes VertexStore/IndexStore — the same buffers,
named the way a pass encoder binds them. HasStores is the backend-neutral form
of the VAO != 0 readiness test the raw-GL draw paths make. Two bodies fork on
the context and nothing else does: InitBuffers skips the vertex array, and
CommitMigration skips the rebind — on the encoder arm the field swap IS the
atomic publication, because the next pass reads whatever the field then holds.
The store deletion likewise splits: GL keeps its immediate DeleteRetired,
because the arena's own flight gate has already proven no submitted frame can
reference the store, while the other arm has no second deferral to skip and
Dispose is its retirement-queued release.
ObjectMeshManager's RequireGl narrowed to the LEGACY per-mesh upload. Its three
call sites were one modern-path constructor argument and two bodies whose every
GL statement sits inside `if (!_useModernRendering)`. The constructor now hands
the arena the nullable context; the two bodies resolve one lazily inside the
legacy branch. That branch is unreachable in every shipping configuration —
missing bindless or draw-parameters throws at startup under the N.5 ship
amendment — so the accessor survives as the guard on dead code rather than as a
blocker, and it is deleted with that code.
VulkanMeshPipelineDevice is the second implementation, and it is four
properties and two no-ops. Two things about it are worth stating rather than
leaving to be inferred. HasBindless and HasOpenGL43 answer TRUE: their names are
GL-shaped because the seam was cut from a GL device, but what they gate is the
MODERN path — one shared arena, table texture indexing, multi-draw indirect —
which Vulkan supplies unconditionally and the capability gate rejects a device
for lacking, so answering false would disable the only path that exists.
HasPendingWork answers false because the GL device's queue exists to defer work
onto the thread holding the context, and Vulkan resource work is recorded into
the frame's command buffer or routed through the retirement queue.
WbMeshAdapter selects between them once, in the one place the mesh pipeline
still names a backend. The GL arm is unchanged, including the queue-drain
guarantee its construction rollback asserts.
So composition builds the mesh pipeline on BOTH arms, and NullWbMeshAdapter is
deleted — it existed for exactly the gap this closes, and the landblock spawn
ledger now registers against the real adapter. Streaming's publication into GPU
state stops being a no-op there: the Vulkan run below builds real render data,
including the [up-null] zero-vertex caching path.
Gates. Release build green. App tests 4,112 passed / 3 skipped, against a 4,109
baseline plus the three added here. Strict GL offline pixel gate against
|
||
|
|
5b3d72a90c |
refactor(render): Campaign V slice V6i-2 commit 3 — the mesh pipeline stops naming a backend
Plan §5.5.10 recorded the blocker as a fact about types: "WbMeshAdapter owns an
OpenGLGraphicsDevice, so it is not constructible on Vulkan until slice V4t" —
which is the entire reason NullWbMeshAdapter exists. §5.5.12 item 6 then measured
how wide that dependency really is, and the answer is seven members out of a
760-line class: a GL context, the retirement queue, the shared instance VBO, and
two capability flags.
IMeshPipelineDevice is exactly that surface. OpenGLGraphicsDevice declares it and
every member already existed under a GL-specific name, so the shipping backend
executes not one changed statement — these are aliases, not behaviour.
Two casts moved, and they are what actually blocked construction:
- ObjectMeshManager downcast IGpuDevice to GlGpuDevice in its CONSTRUCTOR, so a
Vulkan-composed pipeline threw before running a statement. V4t put it there
because the class registered bindless handles itself; commit 2 moved that into
the array, leaving the field a pass-through for the raw-GL renderers' handle
table. The cast now lives on that one property and names the backend it was
composed against instead of reporting a failed cast.
- The atlas array factory is selected by IWorldTextureArrayFactory.For, which is
the one place the texture stack branches on a backend.
MeshPipelineDeviceSeamTests proves the decoupling rather than describing it: it
builds ObjectMeshManager against a device whose Gl is null, asserts it constructs,
asserts construction built no GL object, asserts the handle table refuses by name,
and asserts the factory picks the RHI arm. A reflection test pins the seam's
member set so a later slice cannot quietly widen it back out — the whole value
here is that it is narrow.
What this does NOT claim: the mesh pipeline does not RUN on Vulkan. Its upload
bodies are still raw GL — GlobalMeshBuffer, the VAO/IBO construction, the layer
transfers — and they now fail through one RequireGl() accessor that names the
slice that owns porting them, instead of failing at construction. WbMeshAdapter
still creates an OpenGLGraphicsDevice in its GL constructor, because there is no
second implementation to create yet. Those bodies are items 3–5 of §5.5.12's
remainder list, along with RetailPViewPassExecutor and the three world renderers'
submission arms.
§5.5.13 reports the whole of V6i-2 and the slice table gains its V6i row.
Gates: Release build; App tests 4,109 / 3 skips (the 4,086 baseline plus 23 across
the three commits); complete Release suite 9,172 / 5; strict GL offline pixel gate
vs
|
||
|
|
c8d0f70bbe |
feat(render): Campaign V slice V6i-2 commit 2 — world texture creation crosses to IGpuTexture
Plan §5.5.11 recorded what V4t deliberately left behind: it moved the table
ENTRY of every world texture to the device and kept CREATION with the caches,
because "creating world textures through IGpuTexture is real remaining work and
it belongs with the Vulkan world arm, which is the first thing that cannot use a
GL handle at all." §5.5.12 item 1 handed it forward and named the missing piece
exactly — "an ITextureArray implementation over IGpuTexture, not a codec",
because V6b's BlockCompressionCodec and BlockCompressionMipChain already supply
the BC chains. This is that work.
IWorldTextureArray is the seam, and the slot is what crosses it. Before this
commit ObjectMeshManager read BindlessWrapHandle/BindlessClampHandle off the
concrete GL array and interned them into the device table itself. A 64-bit
ARB_bindless_texture handle has no Vulkan spelling, so the array now answers the
question the caller was really asking — ResolveSlot(wrapping) — and each arm gets
there its own way: ManagedGLTextureArray makes the same idempotent interning call
one level down, and RhiWorldTextureArray returns a pair it registered at
construction. ReleaseTextureSlots replaces the snapshot dictionary the manager
kept for the same reason, and still runs only once physical retirement completes.
Which implementation exists is decided ONCE, by the IWorldTextureArrayFactory
composition builds — plan §3.1's no-runtime-fork rule. Everything above the seam
(capacity policy, slot allocation, ref counting, layer retirement, empty-atlas
eviction, and the whole of ObjectMeshManager's atlas policy) is written once and
branches on nothing.
Three things the RHI array does differently, each because the backends genuinely
differ rather than by choice: BC mip chains are CPU-built through
BlockCompressionMipChain, since Vulkan cannot blit into a compressed image, while
RGBA8 uses the device's blit; filtering lives in an immutable sampler rather than
a texture parameter, so both address modes are registered up front exactly as the
GL array holds two resident handles; and RGB8/A8/Rgba32f are refused at creation
with the reason named. A8 is the interesting refusal — the GL array serves it by
swizzling R into A, and a Vulkan swizzle lives in the image VIEW, which the pinned
GpuTextureDescription does not describe. A silent substitution would render wrong
and look like a shader bug.
TerrainAtlas gains the second construction path V6i drafted and reverted. The
decode is factored out and shared, so both arms read the same DATs, in the same
order, with the same resize-to-max policy; only the upload forks.
ICompositeTextureArrayBackend gains its RHI arm, which is four small methods
because that seam was already a seam.
The Vulkan arm is EXERCISED, not merely present. That is the whole reason the
V6i draft was reverted rather than landed — "built then reverted because nothing
exercised it" — and it is the same failure §5.5.12 measured twice in the
descriptor layouts. So the composition host now builds the real terrain atlas
through IGpuDevice.CreateTexture on the arm with no GL context, and creates and
releases one shared array of each format family plus one composite array at
startup. Creation only; nothing draws them. Releasing them in the same statement
covers one thing a retained bundle would not — that both slot pairs come back and
the images route through the retirement queue.
Gates: Release build; App tests 4,104 / 3 skips; strict GL offline pixel gate vs
|
||
|
|
f7344758f8 |
fix(render): Campaign V slice V6i-2 commit 1 — the terrain clip block reaches set 1
Plan §5.5.12 finding 2, measured on the committed SPIR-V rather than inferred:
terrain_modern.vert declared
layout(std140, binding = 2) uniform TerrainClip { ... }
with no ACDREAM_UBO_SET, so under the Vulkan dialect the block landed in set 0
binding 2 — which set 0's layout declares as a STORAGE buffer. Any terrain
pipeline built against the shared pipeline layout was therefore malformed.
Nothing had caught it: GL expands the macro to nothing and keeps its UBO and
SSBO namespaces separate, the shader compiled cleanly for both backends, and no
terrain pipeline has ever been created on Vulkan. sky.vert declares the SAME
block correctly and is the precedent, so this is a one-word omission, not a
numbering question.
spirv-dis on spv/terrain_modern.vert.spv, before and after:
before %372 = OpVariable %_ptr_Uniform__struct_370 Uniform
OpDecorate %372 DescriptorSet 0 / Binding 2
after OpDecorate %372 DescriptorSet 1 / Binding 2
with %_struct_370 = OpTypeStruct %int %_arr_v4float_uint_8 — TerrainClip's
{ int uTerrainClipCount; vec4 uTerrainClipPlanes[8]; } — in both.
The same commit closes §5.5.8's second recorded gap. Set 1's layout declared
only bindings 1 and 3, so it was missing BOTH the terrain clip block and
UniformSkyParams at binding 4, which sky.vert and sky.frag have compiled to
SPIR-V since V6e. Both are now declared, all four dynamic, which is half
Vulkan's guaranteed maxDescriptorSetUniformBuffersDynamic of 8 and is asserted
by the capability gate as before.
Membership and ORDER now come from one predicate — IsDeclaredUniformBinding —
that the layout, the descriptor writes and vkCmdBindDescriptorSets's
dynamic-offset array are all built from, the same shape V6g gave set 0. The
three had been restated separately, which is exactly how a fifth binding would
have gone wrong the same way.
Both gaps were found by hand, months apart, and neither could fail on the
shipping backend. VulkanShaderDescriptorContractTests reads the committed .spv
and asserts the partition instead: every uniform block at a declared set-1
binding, every storage block inside set 0's declared range, every sampled
resource in the one texture table. Checked out against the pre-fix .spv, two of
its four tests fail.
Gates: Release build; App tests 4,090 / 3 skips (4,086 baseline plus four);
strict GL offline pixel gate vs
|