Known Issues

← Design Decisions | Schema Errors → | Home


Networking

1.

Soul holds a persistent NetChannel member, but DispatchServerRPC / DispatchClientRPC unconditionally overwrite it from RPCContext on every call. The stored value is never read between dispatches.

Correct fix: Thread NetChannel through the dispatch call and have RPC thunks capture it from dispatch context. Eliminates GetNetChannel() and the stored member entirely.


2.

NetChannel stores a raw ConnectionInfo*. If NetConnectionManager::Connections (a std::vector) reallocates on a new connection, all outstanding NetChannel objects hold dangling pointers.

Correct fix: Stable storage for ConnectionInfo (e.g. index-based or pointer-stable pool), or NetChannel stores an index + generation instead of a raw pointer.


3.

In PIE, two ConnectionInfo entries share the same OwnerID — one bServerSide=true, one bClientInitiated=true. The function now accepts a requireServerSide flag to disambiguate.

Audit required: All call sites must pass the correct flag. Any new call site added without the flag will silently mutate the wrong leg.


4.

Every other send path goes through NetChannel::MakeHeader which stamps LastAckedClientFrame. SendPong was constructing the header by hand and omitting this field — fixed — but the pattern is a regression risk.

Correct fix: Make SendPong call MakeHeader like all other sends.


5. Heartbeat Ping reuses the clock-sync message type

TickReplication sends a NetMessageType::Ping to propagate ACKs during quiet frames. This conflates ACK heartbeats with RTT measurement pings.

Correct fix: A dedicated NetMessageType::Ack (header-only, no clock-sync semantics) would be cleaner.


6. Souls do not exist in standalone mode

The Soul creation path is gated behind the networking handshake. In standalone, no Souls are created — gameplay code that queries Souls finds nothing.

Correct design: Always create Souls — synthesise a local Soul per player during standalone World init, OwnerID from a local counter, no net session. This unifies the code path and enables local multiplayer without a divergent flow graph. Soul::GetNetChannel() must be null-safe when no CI exists.

Status: Not yet implemented. See Status & Roadmap.


7.

HighWaterFirstFrame was stuck at 1 for the entire session because ACK trimming was broken. The Store() loop is now clamped to LastConsumedFrame - Depth + 1 regardless of ACK state, bounding it to O(ring_depth). Once ACK trimming is verified stable in production, the belt-and-suspenders clamp can be removed if desired.


16. Networking without rollback has no reconciliation for the controlled entity

With rollback compiled out, corrections are written straight into the current frame and unacknowledged inputs are not replayed — rubber-banding proportional to latency. See Networking Without Rollback #1.


17. One lagging client stalls the whole server

AuthoritySim stalls the sim for everyone when any player exceeds MaxClientInputLead (default 16 frames), in every build mode. Correct for lockstep only. See Networking Without Rollback #2.


18. Late input is ignored when the server runs without rollback

Late input lands in the server's PlayerInputLog but only rollback acts on it. Correct fix: server-only rollback (the build flags are already independent; the server/client split needs wiring). See Rollback Netcode.


19. Clock sync runs once; no client time dilation

FrameOffset and InputLead are set at the handshake only. Clients that can't sustain 512Hz or that drift fall behind with no correction. See Networking Without Rollback #4.

Observed in PIE: one Owner can settle a frame short of the lead budget (lastReceived = frame - 17, lead 16), so the Authority stalls on that Owner about once per input packet for the whole session, adding jitter to every Echo.


20. No lag compensation

No server-side rewind for hit validation. Tied to the open remote-entity-representation design. See Networking Without Rollback #5–6.


21.

NetThread writes and the Logic thread reads without a lock; safe only while their phases don't overlap. Correct fix: per-log spinlock or lock-free handoff.


22. Discrete input events are not injected on the server

Only held-key state is injected (AuthorityNet.cpp TODO). Correct fix: inject per-frame discrete events from the input log.


23.

View yaw/pitch lived on PlayerConstruct and were integrated from per-frame mouse deltas: the Owner read the presentation buffer while the Authority read the injected sim delta, resimulation re-integrated them, and extrapolated input frames zero the mouse delta, so "forward" could drift apart permanently.

Fix: absolute view angles travel in sim input (InputSnapshot::ViewYaw/ViewPitch, InputDeltaFlags::HasView), are recorded and replayed by rollback (TemporalFrameHeader::InputViewYaw/Pitch), and extrapolated frames repeat the last known facing. Gameplay reads facing only from sim input; the Owner writes new angles outside resimulation. The simulated facing is stored in the Temporal CControlRotation component, so it rolls back and replicates to Echoes.


ECS / Memory

8.

BuildFieldArrayTable moved out-of-line to Archetype.cpp; queries cache->GetFrameStride() directly.


9.

GetWriteFramePtr(void*) and GetReadFramePtr(void*) added to ComponentCacheBase; all call sites updated.


10. Reflection system relies on static initialisation order

TNX_REGISTER_COMPONENT, TNX_TEMPORAL_FIELDS, TNX_REGISTER_SCHEMA etc. are driven by static constructors. Cross-TU ordering is undefined in C++. Currently works because all registrations resolve before TrinyxEngine::Initialize(), but this is fragile.

Correct fix: A dedicated precompile step (like UBT) or an explicit registration call per module. See Determinism for the planned MetaRegistry design.

IDs depend on first-use order (2026-09-30). ClassID and ComponentTypeID come from global counters on first use (EntityView::StaticClassID, ComponentView, ScriptRegistry), so the same type can get a different ID in the editor, a game client, and a dedicated server, or between builds. EntityNetManifest::ClassType carries the class ID on the wire. Scenes are safe (they save type and component names).

Direction: stable IDs derived from the reflected type name (hash, hard error on collision at registration), and a baked reflection manifest for releases that freezes class/component IDs and field layouts across patches, the same model as the baked asset manifest.


31. Local handle indices are never recycled at runtime

Registry::ConfirmLocalRecycles() (moves freed local handle indices from pending to free) is only called by tests. In a running game every destroyed entity's local index stays pending forever, so the local index space only grows. Safe (no ABA), but a slow leak, and the safety window it was meant to enforce is undefined.

Direction: confirm on a defined horizon — the history ring depth in rollback builds (the same gate the editor needs for time travel), a fixed frame delay otherwise.


33. Slabs commit their full capacity up front, and several loops scan full capacity

TemporalComponentCache allocates each slab with _aligned_malloc, which commits capacity × ring depth immediately: Testbed (MaxCachedEntities = 500000, TemporalFrameCount = 128) commits about 11.3 GB for the Temporal slab alone, and every PIE world (server and each client) commits its own. Untouched pages are not resident (PropagateFrameData copies only live partition ranges), so this costs commit charge rather than RAM — but on a 32 GB machine a presubmit (Testbed run plus parallel compiles) reached the commit limit (2026-09-30). Separately, some loops walk full capacity instead of the live partition ranges: the dirty-bit clear in PropagateFrame, SweepAliveFlagsToActive, the renderer's dirty scan over MAX_RENDERABLE_ENTITIES, and the full-copy slab upload.

Correct fix, in this order:

  1. Clamp every full-capacity scan to the live partition ranges (render, dual + phys, logic) — work that scales with live entities, not capacity.
  2. Then reserve the slab (VirtualAlloc(MEM_RESERVE) / mmap(PROT_NONE)) and commit as partitions grow, in large chunks, ahead of need on a worker so growth never stalls the spawn path. Addresses stay fixed, so access patterns and steady-state speed are unchanged; commit then scales with live entities × ring depth. Step 1 is a prerequisite — with reserved-only pages, a full-capacity scan faults instead of reading zeros.

Measured 2026-10-07 (Testbed at rest, one trivial test): 10.9 GB committed headless and 12.3 GB windowed, and nearly all of it resident (10.7 GB and 11.2 GB working set), so the pages are being touched: this costs RAM, not only commit charge. A Testbed binary from before the GG-UI rendering work measures the same, with an identical 11.26 GB Temporal slab. The rollback self-test (ExecuteRollbackTest) copies both slabs to restore them afterwards; it used to keep those copies until shutdown, which held a Testbed run at 23 GB from Rollback_Determinism onwards. It now frees them when the test ends, so the 23 GB is a peak of a few seconds. A project sized to what it uses is small: the resilience demo (32,000 entities) commits 1.7 GB.


34. A destroyed replicated entity is never reclaimed without a ReplicationSystem

Registry::ProcessDeferredDestructions leaves a tombstoned entity that carries TemporalFlagBits::Replicated for the ReplicationSystem to confirm, whenever ReplicationActive is set — and that is a compile-time constant, true in every network-enabled build. In a build or a session with no ReplicationSystem (an Owner-model game build running alone) nothing ever confirms it: the entity stops being drawn and simulated, but its slot, its handles, and its place in the tombstone list are kept, and the list is walked again every frame. Found by rebuilding the resilience demo's pyramid in a loop: the entity count climbed until physics ran out of room. The demo no longer marks its cubes replicated.

Direction: ReplicationActive should say whether a ReplicationSystem is attached to this world right now, set when one is and cleared when it goes. It belongs with the run-time role in Host, Join, and Solo in One Build.


35. A simulation that falls too far behind never gets the time back

The Brain owes real time whatever it has not yet simulated, and catches up by running extra fixed steps. Two limits in LogicThread::ThreadMain cap that: the debt is clamped at 250 ms (MaxAccumulatedTime, and MaxDt for a single long loop), and at most eight steps run per loop (MaxPhysSubSteps). Time beyond the clamp is dropped, for good. So a stall longer than 250 ms, or a Brain that is too slow for long enough, leaves the simulation permanently behind the wall clock: five seconds of real time is no longer five seconds of simulated time, and two machines under different load end up at different simulated times. The stress tests record this as the policy (Stress_BrainHitch: a 600 ms stall loses about 350 ms; Stress_BrainOverrun: a Brain at a third of its rate stays there).

Everything stepped by the Brain shares it, including the fallback free camera (ProcessVizInput runs once per fixed step with the fixed step time), which is why the camera slows and freezes with the simulation.

Direction: time owed is kept, and repaid by a set of mechanisms a game turns on and tunes — pacing, cheaper catch-up frames, a variable step where rollback is not in use, resync, a shared slowdown — with dropping time as the last resort past a cutoff of seconds rather than a quarter of one. See Simulation Time.


Editor

The editor submits FieldEdit commands through the World queue (WorldBase::SubmitFieldEdit); the Brain applies them (Registry::ApplyFieldEdit) and commits them as a frame. The Details panel, gizmo, and undo/redo no longer write slab memory.


FieldEditCommand holds entities by handle with changed-field deltas; UndoHistory groups them into transactions, 1000 deep. Deleting retires the entity (Registry::Retire: record, slot, and handle stay reserved) and undo revives the same entity, so older history still resolves. Retired entities are destroyed when the history discards the delete.


14. ImGui multi-viewport windows are driven from the render thread

Detached editor windows vanished or stopped responding because ImGui's platform windows were created and updated on the Encoder thread while SDL events are pumped on Sentinel. Multi-viewport support was disabled (f095d3b).

Correct fix: All OS windows created and pumped on Sentinel. See GG-UI.


The PIE net layer (PIENetThread, NetConnectionManager, client handlers, and the PIE flows it ticks) is driven by Sentinel, but StartPIE/StopPIE ran on the Encoder thread (ImGui): stopping PIE could invalidate the connection list under an in-flight OwnerNet::ExecuteInputSend job (debug iterator assert). PIE networking setup and teardown now run on Sentinel through TrinyxEngine::RunOnSentinel (lock-free handshake between Sentinel ticks), and teardown waits for in-flight input sends first; the Encoder keeps only the render-side work.


Rendering

11. Default identity quaternion not enforced on

A zero quaternion (RotQW=0) is mathematically invalid and produces degenerate rendering. Spawned entities that don't explicitly set rotation will render incorrectly.

Correct fix: Initialise RotQW=1.0f in CTransform's default state, or assert in the scatter shader.


15. Interpolation blends across entity discontinuities

scatter.slang interpolates between the previous and current frame at the same EntityCacheIndex with one global alpha. Defrag relocation, spawning into a reused slot, and teleports blend between unrelated states for one frame.

Correct fix: Frame snap plus a per-entity Discontinuity flag that persists until the render thread acknowledges it. See Edit Timeline.


25.

A replicated spawn keyed its replay at a frame its rollback never reached (clamped to the level floor, or skipped beyond the ring), and applied-now activations were keyed at the Authority's stamp frame instead of the frame they were written into. Whichever client rolled back across them lost the effect. See Server Events.


26.

Layer-filter tables and Jolt's global registration were file-static and torn down by the first world to shut down, crashing the remaining PIE worlds on Stop. Tables are now per JoltPhysics; global registration is process-lifetime.


27. Rollback resim diverges Jolt state when the window ends on a physics step

Rollback_Determinism fails with Jolt Physics: DIVERGED (first difference at byte 30) whenever the self-test fires at FrameNumber % PhysicsDivizor == 0, i.e. a 9-frame resim whose last frame is a physics step boundary. ECS fields still match. Reproduces 100% on the pre-2026-09-30 code too, by adding && (logic.FrameNumber % logic.PhysicsDivizor) == 0 to the self-test trigger in RollbackSim::ProcessRollback. Other phases match, which is why the test passes most runs. Suspect an off-by-one between the ground-truth Jolt capture and the last resimulated physics step.

Gate loosened (2026-09-30): Rollback_Determinism now passes on exact ECS fields alone; Jolt divergence is logged as a warning that points here. Jolt is not fully deterministic across a resim despite the cross-platform determinism flags, and the engine's guarantee is exact ECS rollback. Restore the Jolt check (&& joltMatch in RollbackSim's self-test) once this is fixed.


28.

DispatchDeltaCorrectionJobs diffs each client against LastAckedSimFrame % ringSize, but nothing writes the field, so every delta is computed against ring slot 0 (an arbitrary recent frame). Deltas carry absolute values for changed fields, so state still converges, but a field that changed since the client's real baseline and happens to equal the slot-0 value is omitted until the next heartbeat correction.

Correct fix: advance it from the client's acknowledged frame (the same ack that advances LastAckedClientFrame).


29. Input packets are larger than they need to be

Each InputFrameDelta packet (128 Hz) carries the window's first frame as a full NetInputFrame (220 bytes: 64-byte key bitfield, float mouse deltas and view angles, 8 event slots, a 64-byte command block), and resends every unacknowledged frame until the Authority acks it. Changed held-key state costs the whole 64-byte scancode bitfield.

Direction: key state as a bitmask of bound actions instead of all 512 scancodes; quantized view angles and mouse deltas; the first frame delta-encoded against the last acknowledged frame rather than sent whole; a tighter resend window. InputWindowCodec is the single place to change the encoding.


30. Idle editor still busy-waits on two threads

With the edit world paused, the Brain and job workers now sleep, but Sentinel (TrinyxEngine::WaitForTiming, 1000 Hz input poll) and the render loop still spin, about two cores in an idle editor. Measured 2026-09-30: 9.4 cores before the event-driven Brain, 2.0 after.

Direction: sleep-based pacing when there is no deadline to hit (paused, unfocused, or no input), and a render loop that waits on present/frame events instead of polling.


24.

QuatFromYawPitch rotated by +yaw about +Y, rendering forward as (-sin yaw, 0, -cos yaw), while mouse input, PlayerConstruct movement, the third-person offset, the Brain free-fly, and EditorCamera all treat positive yaw as a right turn with forward (sin yaw, 0, -cos yaw). Mouse look rendered inverted and "forward" pointed off-view. QuatFromYawPitch now rotates by -yaw, so the rendered basis is the movement basis.


Planned Fixes (Next Phase)

  • Replication reliability — client entities appear then vanish; root cause in the ClientRepState / activation pipeline. Partially addressed: fb84783 fixed clients not seeing their own entity (dirty marking); the appear-then-vanish case needs re-verification.
  • Animation — IK, retargeting, and animation graph tooling (core animation is implemented; see Status & Roadmap)
  • Phase 0 tentative despawn — per-frame TentativeDestroys ring buffer for rollback-safe entity death
  • Frustum culling — SIMD 6-plane test, GPU-side predicate enhancement
  • State-sorted rendering — 64-bit sort keys, GPU radix sort after scatter
  • Standalone Soul synthesis — synthesise a local Soul per player in FlowManager for offline play (Known Issue #6)