From 6a0e111eeb96773605ca13e282ea53e6ebd858e7 Mon Sep 17 00:00:00 2001 From: Toni Seas <83423392+ToniSeas@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:45:32 -0600 Subject: [PATCH 1/5] Keep component pages to their own component Signed-off-by: Toni Seas <83423392+ToniSeas@users.noreply.github.com> --- STYLE_GUIDE.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md index 63c6f76..e9d3e19 100644 --- a/STYLE_GUIDE.md +++ b/STYLE_GUIDE.md @@ -59,6 +59,22 @@ reader hits in production with nowhere to look it up. considered) and link to it from everywhere it's relevant. Don't re-litigate it inline on every page that touches it. +### Document each component on its own page + +A page about a component states facts about that component. Name another component only when the +reader can't use this one without it. A link breaks the build when its target goes away, but a +sentence describing another component stays on the page, wrong, after that component changes. + +- Name what a component builds on, such as its base class or a type it calls, where the reader + needs it, and link to the section that documents it instead of restating it. +- Don't list the subclasses or users of a component, and don't use them as examples ("as `X` + does"). Overview pages and the sidebar show what's built on what. +- Don't describe the private members of another class. If a page needs a fact about another + component, add it to the page of that component and link to it. +- Exceptions: a type in the public API of the component (a parameter, a return value, a field it + exposes or composes), an overview or decision page whose subject is how components relate, and a + how-to naming the components its task uses. + ## Writing style: sound human, say only what's needed Docs written by an LLM have a recognizable smell, and readers trust content less once they From bfc70ff439c6e9e188ef89666ecf97844d72e286 Mon Sep 17 00:00:00 2001 From: Toni Seas <83423392+ToniSeas@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:46:28 -0600 Subject: [PATCH 2/5] Drop references to other components from the Process pages Signed-off-by: Toni Seas <83423392+ToniSeas@users.noreply.github.com> --- docs/core/core-concepts/process.md | 9 ++++----- .../players-tracking/auto-player-tracker.md | 17 +++++++++-------- docs/core/players-tracking/player-tracker.md | 8 ++------ .../players-tracking/ready-check-process.md | 3 +++ docs/core/players-tracking/ts-player.md | 7 +------ .../overlay/player-position-overlay.md | 15 ++++++--------- 6 files changed, 25 insertions(+), 34 deletions(-) diff --git a/docs/core/core-concepts/process.md b/docs/core/core-concepts/process.md index b5008c2..8d64afe 100644 --- a/docs/core/core-concepts/process.md +++ b/docs/core/core-concepts/process.md @@ -8,8 +8,7 @@ sidebar_position: 4 `Tsvrc.Core.Process` is the base class for networked work that one player runs at a time: it starts, runs, then stops or completes. That player is the owner, and another player takes over if -the owner leaves or is suspended. Its generated shadow is `TsProcess`. `PlayerTracker`, -`AutoPlayerTracker`, `ReadyCheckProcess`, `TsvrcTimer` and the `DataTransferer` chain all extend it. +the owner leaves or is suspended. Its generated shadow is `TsProcess`. ## Usage @@ -45,9 +44,9 @@ gameRound.CompleteProcess(); `OnProcessStarted`, sends the synced state and starts the [tick loop](#the-tick-loop). On any other player it sends `RequestStartProcess` to the owner and changes nothing locally. It never changes who owns the object. A subclass that should run on the player who starts it calls `SetProcessOwner` -first, as `TsvrcTimer.StartTimer` and `PlayerTracker.StartPlayerTracking` do. If two players start -such a subclass at the same time, both can take ownership and start a run before either sees the -other, and only one stays the owner, so start it from one player when that can happen. +before `StartProcess`. If two players start such a subclass at the same time, both can take +ownership and start a run before either sees the other, and only one stays the owner, so start it +from one player when that can happen. `StopProcess()` ends a run early and `CompleteProcess()` ends it successfully. The owner acts on them directly, and any other player sends `RequestStopProcess` or `RequestCompleteProcess` to the diff --git a/docs/core/players-tracking/auto-player-tracker.md b/docs/core/players-tracking/auto-player-tracker.md index ecf2408..12c4c36 100644 --- a/docs/core/players-tracking/auto-player-tracker.md +++ b/docs/core/players-tracking/auto-player-tracker.md @@ -9,8 +9,7 @@ sidebar_position: 3 `Tsvrc.Tracking.AutoPlayerTracker` is a [PlayerTracker](./player-tracker) that tracks every player in the instance, with no player list to manage. Its generated shadow is `TsAutoPlayerTracker`. See [Track a custom group of players](../how-to/tracking-a-group-of-players) for when to use it instead -of a plain `PlayerTracker`, or [Gate something on all players being -ready](../how-to/wiring-a-ready-check) for pairing it with `ReadyCheckProcess`. +of a plain `PlayerTracker`. ## Getting an instance @@ -21,16 +20,17 @@ on it. If you place the shipped `AutoPlayerTracker` prefab in your scene yoursel ## Usage -Call `StartAutoTracking()`. Like `StartPlayerTracking` on the base class, it makes the calling player -the owner first. `StartPlayerTracking` itself is overridden to do the same as `StartAutoTracking` -and ignores both of its parameters, since `AutoPlayerTracker` never uses the process update. +Call `StartAutoTracking()`. It makes the calling player the owner first, so tracking runs on the +player who starts it. `StartPlayerTracking` is overridden to do the same as `StartAutoTracking` and +ignores both of its parameters, since `AutoPlayerTracker` never uses the process update. `StopAutoTracking()` and `CompleteAutoTracking()` call `StopPlayerTracking` and `CompletePlayerTracking`. While tracking runs, the owner adds each player who joins in `OnPlayerJoined`. A join is skipped when the player reference [isn't valid](https://creators.vrchat.com/worlds/udon/players/#isvalid), since accessing an invalid reference has been reported to crash the behaviour. Players who leave or -are suspended are removed the same way as in `PlayerTracker`. Other players ignore `OnPlayerJoined`, +are suspended are [removed by the base class](./player-tracker#automatic-removal). Other players +ignore `OnPlayerJoined`, because only the owner changes the tracked set. ```csharp @@ -61,8 +61,9 @@ A player still joining at that moment fires `OnPlayerJoined` later, once trackin then. UdonSharp runs on the Unity main thread only, so no `OnPlayerJoined` can run between reading the list and the start of tracking, and no joining player is missed. -The first list leaves out players who are already suspended. `PlayerTracker` removes a player when -they become suspended, so a player who is suspended before tracking starts would never be removed. +The first list leaves out players who are already suspended. The base class [removes a +player](./player-tracker#automatic-removal) when they become suspended, so a player who is suspended +before tracking starts would never be removed. ## Events diff --git a/docs/core/players-tracking/player-tracker.md b/docs/core/players-tracking/player-tracker.md index e2379ba..57ea13f 100644 --- a/docs/core/players-tracking/player-tracker.md +++ b/docs/core/players-tracking/player-tracker.md @@ -8,9 +8,8 @@ sidebar_position: 2 `Tsvrc.Tracking.PlayerTracker` is a [Process](../core-concepts/process) that keeps a networked set of tracked players, managed by its owner and identified by [`TsPlayer`](./ts-player) ID strings -instead of live `VRCPlayerApi` references. Its generated shadow is `TsPlayerTracker`. It's the base -of `AutoPlayerTracker` and `ReadyCheckProcess`. See [Track a custom group of -players](../how-to/tracking-a-group-of-players) for a task-oriented walkthrough. +instead of live `VRCPlayerApi` references. Its generated shadow is `TsPlayerTracker`. See [Track a +custom group of players](../how-to/tracking-a-group-of-players) for a task-oriented walkthrough. ## Getting an instance @@ -39,9 +38,6 @@ public class SpectatorRoster : TsBehaviour } ``` -To track every player in the instance, use [`AutoPlayerTracker`](./auto-player-tracker) instead of -handling joins and leaves on top of `PlayerTracker`. - ## Starting and reading the tracked set `StartPlayerTracking(playerIds, useProcessUpdate)` starts the process with an initial set. A player diff --git a/docs/core/players-tracking/ready-check-process.md b/docs/core/players-tracking/ready-check-process.md index 8b4f089..47613b1 100644 --- a/docs/core/players-tracking/ready-check-process.md +++ b/docs/core/players-tracking/ready-check-process.md @@ -93,6 +93,9 @@ that player as the caller and fail the check above. ## Edge cases +- A check with no tracked players never completes on its own: completion needs at least one + tracked player, and every one of them ready. That holds for a check started with an empty list + and for one whose tracked players all left. Stop it with `StopReadyCheck()`. - A ready mark from a player who isn't tracked is rejected with a warning. `CheckAllPlayersReady` only looks at tracked players, so that mark could never affect completion. - `OnTrackingPlayersRemoved` can remove up to 79 players at once when a new owner takes over, one diff --git a/docs/core/players-tracking/ts-player.md b/docs/core/players-tracking/ts-player.md index b4ea4d5..7fbd50a 100644 --- a/docs/core/players-tracking/ts-player.md +++ b/docs/core/players-tracking/ts-player.md @@ -7,12 +7,7 @@ sidebar_position: 1 # TsPlayer `Tsvrc.Player.TsPlayer` is a static helper class for identifying and looking up players. -It depends on no other Core type. The tracking chain uses it -([`PlayerTracker`](./player-tracker), [`AutoPlayerTracker`](./auto-player-tracker), -[`ReadyCheckProcess`](./ready-check-process) and the data transfer built on them), as do the -overlay UI ([`PlayerPositionOverlay`](../ui-components/overlay/player-position-overlay), -[`PlayerMarkerRenderer`](../ui-components/overlay/player-marker-renderer)) and -[`PlayerColorAssigner`](./player-color-assigner). +It depends on no other Core type. ## Why a custom player ID exists diff --git a/docs/core/ui-components/overlay/player-position-overlay.md b/docs/core/ui-components/overlay/player-position-overlay.md index 416dc2a..579cdf6 100644 --- a/docs/core/ui-components/overlay/player-position-overlay.md +++ b/docs/core/ui-components/overlay/player-position-overlay.md @@ -7,7 +7,7 @@ sidebar_position: 1 # PlayerPositionOverlay `Tsvrc.UI.PlayerPositionOverlay` is a [PlayerTracker](../../players-tracking/player-tracker) that -periodically reports each tracked player's world position on a blinking show/hide cycle: +periodically reports the world position of each tracked player on a blinking show/hide cycle: think a radar sweep or a minimap that pulses rather than staying static. Its generated shadow is `TsPlayerPositionOverlay`. It's a pure backend: it never draws anything itself, only reports positions to a pluggable [`PlayerMarkerRenderer`](./player-marker-renderer). @@ -45,13 +45,12 @@ quaternion-to-Euler conversion) so a renderer that never uses heading never pays The local player is the one exception to "everything comes from the cache": it's re-read fresh at blink-show time from `Networking.LocalPlayer`, rather than through the cached -remote-tick snapshot, since the local player's own position is essentially free to read and +remote-tick snapshot, since the position of the local player costs almost nothing to read and benefits from being as current as possible. ## Stale-tick discarding -Both tick loops use `SendCustomEventDelayedSeconds`, which (like elsewhere in the codebase) -can't be canceled once scheduled. Each maintains its own counter, incremented on every +Both tick loops use `SendCustomEventDelayedSeconds`, which can't be canceled once scheduled. Each maintains its own counter, incremented on every schedule and decremented on every fire; a tick only actually runs its body if its counter has returned to zero, meaning no newer tick of the same kind is already queued behind it. This is what makes `StartOverlay`/`StopOverlay`/`OnTrackingDeserialization` restarting the loop @@ -65,8 +64,6 @@ sized to 82, the [worst-case absolute maximum instance size](https://wiki.vrchat.com/wiki/Special:MyLanguage/Instances) (80 is the VRChat configurable hard cap; the world author and instance creator can each still join on top of that), allocated once, lazily, on first use, and reused every tick rather than reallocated. -`_ResolveTrackedPlayers` is zero-allocation -by design: matching happens by comparing each tracked ID's parsed numeric suffix directly -against `VRCPlayerApi.playerId`, never by calling -[`TsPlayer.GetPlayerID`](../../players-tracking/ts-player) to build a comparison string per player. A -malformed tracked ID (no parseable numeric suffix) is skipped rather than causing an error. +`_ResolveTrackedPlayers` allocates nothing: it compares the number after the last `#` of each +tracked ID with `VRCPlayerApi.playerId`, without building an ID string per player. A malformed +tracked ID, with no number after a `#`, is skipped without an error. From 0e2197dfd7d797100391204a7feaf1fad80d6360 Mon Sep 17 00:00:00 2001 From: Toni Seas <83423392+ToniSeas@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:48:14 -0600 Subject: [PATCH 3/5] Point the data transfer layers at the chain overview Signed-off-by: Toni Seas <83423392+ToniSeas@users.noreply.github.com> --- .../transferring-data-between-clients.md | 2 +- .../data-transfer/chunked-transfer-session.md | 15 ++-- .../data-transfer/data-chunk-receiver.md | 20 ++--- .../data-transfer/data-chunker.md | 18 ++-- .../data-transfer/data-sender-receiver.md | 72 +++++++-------- .../data-transfer/data-sender.md | 90 +++++++++---------- docs/core/networking-data/overview.md | 22 ++--- 7 files changed, 111 insertions(+), 128 deletions(-) diff --git a/docs/core/how-to/transferring-data-between-clients.md b/docs/core/how-to/transferring-data-between-clients.md index 6a4d2c4..da61728 100644 --- a/docs/core/how-to/transferring-data-between-clients.md +++ b/docs/core/how-to/transferring-data-between-clients.md @@ -111,5 +111,5 @@ the exact guarantees (what "completed" means when the recipient list shrank, for VRChat caps individual network event payloads well below what most real payloads need, so a single call has to become a sequenced chunk transfer underneath. The -[chain overview](../networking-data/overview.md) explains why that's split across eight small +[chain overview](../networking-data/overview.md) explains why that's split across several small layers instead of one large class. diff --git a/docs/core/networking-data/data-transfer/chunked-transfer-session.md b/docs/core/networking-data/data-transfer/chunked-transfer-session.md index 784b80f..d94888d 100644 --- a/docs/core/networking-data/data-transfer/chunked-transfer-session.md +++ b/docs/core/networking-data/data-transfer/chunked-transfer-session.md @@ -9,9 +9,9 @@ sidebar_position: 2 `Tsvrc.DataTransfer.ChunkedTransferSession` sends a message split by [DataChunker](./data-chunker) one chunk at a time: each chunk runs its own [ready check](../../players-tracking/ready-check-process), in which every target player acknowledges it, -before the next chunk starts. It has no networking of its own, which `DataSender` adds on top. This -layer only owns the chunk sequence. You never call it directly. See -[DataTransferer](./data-transferer) for the type you use. +before the next chunk starts. It only owns the chunk sequence and sends nothing over the network +itself. It's one layer of [the data transfer chain](../data-transfer-overview), which names the type +to use. ## The sequence @@ -26,11 +26,10 @@ to broadcast the transitions: - `OnDataChunkSendRequested(dataChunk, chunkIndex, totalChunks, playerIds)`: fires for every chunk, and is where a subclass sends it over the network. -If every tracked player leaves or is suspended during a ready check, the check can't complete: -[`ReadyCheckProcess`](../../players-tracking/ready-check-process) doesn't treat an empty tracked -list as everyone being ready. `ChunkedTransferSession` overrides `OnProcessUpdate` to find a running -process with no tracked players and calls `CancelDataTransfer()`, so the transfer doesn't wait -forever. +If every tracked player leaves or is suspended during a ready check, [the check can't +complete](../../players-tracking/ready-check-process#edge-cases). `ChunkedTransferSession` +overrides `OnProcessUpdate` to find a running process with no tracked players and calls +`CancelDataTransfer()`, so the transfer doesn't wait forever. ## Between chunks diff --git a/docs/core/networking-data/data-transfer/data-chunk-receiver.md b/docs/core/networking-data/data-transfer/data-chunk-receiver.md index 2dd3bd5..0f77d48 100644 --- a/docs/core/networking-data/data-transfer/data-chunk-receiver.md +++ b/docs/core/networking-data/data-transfer/data-chunk-receiver.md @@ -10,8 +10,8 @@ sidebar_position: 4 stored, and acknowledged. It overrides `OnDataChunkSendRequested` from [DataSender](./data-sender) to broadcast each chunk via its own `[NetworkCallable]` method, `BroadcastDataChunkReceived`, which every instance player receives and which validates the -chunk before storing it. You never call it directly. See -[DataTransferer](./data-transferer) for the type a consumer actually uses. +chunk before storing it. It's one layer of [the data transfer chain](../data-transfer-overview), +which names the type to use. ## Validation, layer by layer @@ -30,8 +30,8 @@ flow, not just handling malformed input: - **Sender must match the expected process owner** — an internal `_expectedSenderPlayerId`, captured in `OnTransferStarted` from `Networking.LocalPlayer` (if the local instance is the process owner) or from `NetworkCalling.CallingPlayer` (if not), rejects a chunk from - anyone else. The owner's own broadcast bypasses this via the same inline-call flag pattern - used elsewhere in the chain (here, `_isSendingChunk`). + anyone else. The owner skips this check during its own broadcast, tracked by the + `_isSendingChunk` flag. - **`playerIds` must be non-null and include the local player** — otherwise the chunk isn't meant for this client and is ignored. - **`dataChunk` must be non-null** — `StringBuilder.Append(null)` is a silent no-op, so a @@ -54,11 +54,11 @@ flow, not just handling malformed input: ownership-transfer race). Without de-duplication a repeat would re-fire `OnChunkStored` and send a redundant acknowledgment. -## A second reason for the same rate limit +## Why the rate limit is 100 per second -[`DataSender`](./data-sender) explains why every broadcast in this family shares -`maxEventsPerSecond: 100`: event ordering. `BroadcastDataChunkReceived` has an additional, -independent reason of its own: VRChat [splits any event payload over 1 KB into internal 1 KB +`BroadcastDataChunkReceived` uses `maxEventsPerSecond: 100`, the same limit as the [broadcasts it +must stay in order with](./data-sender#why-the-rate-limit-is-100-per-second). It has a second reason +of its own: VRChat [splits any event payload over 1 KB into internal 1 KB packets](https://creators.vrchat.com/worlds/udon/networking/events/#parameter-size-limits-and-event-splitting), and each of those counts separately against the rate limit. A 2,500-character ASCII chunk is already close to 2,500 bytes (roughly three internal packets); a CJK chunk near the @@ -73,8 +73,8 @@ of about 11 KB/s, which ends up being the real limit on how fast a transfer can A validated chunk is stored at `_receivedChunks[chunkIndex - 1]`, `OnChunkStored` fires (subclass hook for updating public properties), and only then does `NotifyChunkReceived()` -run, which is just `SetReady()` under the hood, acknowledging via the same ready-check -mechanism the rest of the chain already uses. `OnChunkStored` firing *before* the +acknowledge the chunk by calling the inherited +[`SetReady()`](../../players-tracking/ready-check-process#usage). `OnChunkStored` firing *before* the acknowledgment matters on the owner specifically: `SetReady` can synchronously cascade into completing the whole ready check, so emitting the chunk-received signal first guarantees a chunk event is never observed to arrive after the completion event it made possible. diff --git a/docs/core/networking-data/data-transfer/data-chunker.md b/docs/core/networking-data/data-transfer/data-chunker.md index 7d83ca8..0b19a4b 100644 --- a/docs/core/networking-data/data-transfer/data-chunker.md +++ b/docs/core/networking-data/data-transfer/data-chunker.md @@ -7,11 +7,9 @@ sidebar_position: 1 # DataChunker `Tsvrc.DataTransfer.DataChunker` is a [ReadyCheckProcess](../../players-tracking/ready-check-process) -with no network or process state of its own: it only splits and validates string data, -purely as instance methods other classes in the chain call. It's the first link past -`ReadyCheckProcess` and exists to isolate the chunking math from everything that actually -moves chunks over the network. You never call it directly. See -[DataTransferer](./data-transferer) for the type a consumer actually uses. +that adds no network or process state: it splits and validates string data through `protected` +methods for subclasses, which keeps the chunking math apart from the code that sends chunks. It's +one layer of [the data transfer chain](../data-transfer-overview), which names the type to use. ## Public surface (all `protected`, for subclasses) @@ -24,14 +22,12 @@ moves chunks over the network. You never call it directly. See underlying math, exposed separately so a subclass can compute chunk count without materializing every chunk, or extract one chunk on demand. -## Why CHUNK_SIZE is 2500, specifically +## Why CHUNK_SIZE is 2500 -This is a real, checked constant, not a round number. VRChat caps the [total parameter size -of a single network +VRChat caps the [total parameter size of a single network event](https://creators.vrchat.com/worlds/udon/networking/events/#parameter-size-limits-and-event-splitting) -at 16 KB (16,384 bytes), and the chunk-delivery event (`BroadcastDataChunkReceived`, defined -further up the chain) carries four parameters: the chunk string, its index, the total count, -and the target player ID array. The chunk string dominates that budget, encoded as UTF-8, +at 16 KB (16,384 bytes), and the event that delivers a chunk carries four parameters: the chunk +string, its index, the total count, and the target player ID array. The chunk string dominates that budget, encoded as UTF-8, which means the true worst case isn't emoji (2 UTF-16 chars but only 4 UTF-8 bytes, i.e. 2 bytes/char) but CJK text in the U+0800-U+FFFF range, which costs 3 bytes per character. The player ID array adds its own cost: IDs are `displayName#playerId` strings, and a busy diff --git a/docs/core/networking-data/data-transfer/data-sender-receiver.md b/docs/core/networking-data/data-transfer/data-sender-receiver.md index 1c94181..4655f69 100644 --- a/docs/core/networking-data/data-transfer/data-sender-receiver.md +++ b/docs/core/networking-data/data-transfer/data-sender-receiver.md @@ -6,47 +6,43 @@ sidebar_position: 5 # DataSenderReceiver -`Tsvrc.DataTransfer.DataSenderReceiver` sits on top of -[DataChunkReceiver](./data-chunk-receiver) and is purely an ergonomics layer: it turns the -receiver's raw hooks (`OnTransferStarted`, `OnChunksAssembled`, `OnChunkStored`, ...) into a -clean, receiver-focused public API: `LastData`, `LastChunkIndex`, `LastTotalChunks`, and -four events (`OnDataReceptionStartedEvent`, `...StoppedEvent`, `...CompletedEvent`, -`OnDataChunkReceivedEvent`) with matching `protected virtual` hooks a subclass overrides -without ever touching the raw chunk arrays underneath. `OnDataReceptionStartedEvent` and -`...StoppedEvent` don't update any `Last*` property; `LastChunkIndex`/`LastTotalChunks` are -set before `OnDataChunkReceivedEvent` fires, and `LastData` is set before -`OnDataReceptionCompletedEvent` fires, so both are always safe to read from inside their -matching subscriber. - -## The same deferred-emission pattern, one layer up - -Just as `DataSender` defers its stop/complete broadcasts by a frame, `DataSenderReceiver` -defers its own `OnDataReceptionStopped`/`Completed` emission the same way and for the same -reason: at the point these fire, `ExecuteStop`/`ExecuteComplete` is still on the call stack, -so a subscriber that reacts by immediately starting a new transfer would corrupt the new -transfer's state if the emission happened synchronously. The assembled data itself is -staged in a private field (`_pendingCompletionData`) rather than written straight to -`LastData`, specifically so a new transfer starting inside that one-frame gap, which resets -receiver state, can't clobber it before the deferred emit reads it. `LastData` is only ever -assigned atomically with the deferred completion event actually firing. - -Both pending flags (`_pendingCompletion`, `_pendingStop`) are cleared by -`ResetReceiverState`, which runs when a new transfer starts. That's what suppresses a stale -emission for a transfer that's already been superseded: the deferred call still fires, finds -its flag already cleared, and returns without emitting anything. - -## What this layer adds vs. what it doesn't - -There's no new validation here; every correctness guarantee (sender verification, duplicate -suppression, chunk-count bounds) lives one layer down in `DataChunkReceiver` and isn't -repeated here. +`Tsvrc.DataTransfer.DataSenderReceiver` builds on [DataChunkReceiver](./data-chunk-receiver) and +only changes how reception is exposed. It turns the raw hooks of the receiver (`OnTransferStarted`, +`OnChunksAssembled`, `OnChunkStored` and others) into `LastData`, `LastChunkIndex`, +`LastTotalChunks` and four events, `OnDataReceptionStartedEvent`, `...StoppedEvent`, +`...CompletedEvent` and `OnDataChunkReceivedEvent`, each with a matching `protected virtual` hook. A +subclass overrides those hooks without touching the chunk arrays. It's one layer of [the data +transfer chain](../data-transfer-overview), which names the type to use. + +The started and stopped events don't update a `Last*` property. `LastChunkIndex` and +`LastTotalChunks` are set before `OnDataChunkReceivedEvent` fires, and `LastData` before +`OnDataReceptionCompletedEvent`, so a subscriber can read them inside its handler. + +## Deferred stop and complete events + +`DataSenderReceiver` emits `OnDataReceptionStopped` and `Completed` one frame late, [like the +transfer events of DataSender](./data-sender#deferred-stop-and-complete-events) and for the same +reason: they fire inside the stop or complete call, before [the cleanup of the +process](../../core-concepts/process#lifecycle), and a new transfer started from the event right +away would lose its state to that cleanup. + +The assembled data waits in the private `_pendingCompletionData` field instead of going straight to +`LastData`. A new transfer that starts within that frame resets the receiver state, and this keeps +it from clearing the data before the deferred event reads it. `LastData` is set in the same step +that fires the completion event. + +`ResetReceiverState`, which runs when a new transfer starts, clears both pending flags +(`_pendingCompletion` and `_pendingStop`). A deferred call for an older transfer still fires, finds +its flag cleared, and emits nothing. + +## Validation + +This layer adds no checks. Sender verification, duplicate suppression and the chunk-count bounds +are [done by DataChunkReceiver](./data-chunk-receiver#validation-layer-by-layer). ## Usage -Most consumers use [`DataTransferer`](./data-transferer) directly and never touch this layer. -Subclass `DataSenderReceiver` itself only if you want the sender/receiver plumbing without -the transfer-specific event names of `DataTransferer`. Override `OnDataReceptionCompleted` and -read `LastData`: +To build your own type on this layer, override `OnDataReceptionCompleted` and read `LastData`: ```csharp protected override void OnDataReceptionCompleted() diff --git a/docs/core/networking-data/data-transfer/data-sender.md b/docs/core/networking-data/data-transfer/data-sender.md index fe6b027..1e076d1 100644 --- a/docs/core/networking-data/data-transfer/data-sender.md +++ b/docs/core/networking-data/data-transfer/data-sender.md @@ -6,52 +6,44 @@ sidebar_position: 3 # DataSender -`Tsvrc.DataTransfer.DataSender` puts real networking under the sequencing hooks of -[ChunkedTransferSession](./chunked-transfer-session): it translates -`OnChunkSequenceStarted`/`Stopped`/`Completed` into `[NetworkCallable]` broadcasts to every -player in the instance, and exposes them as three events (`OnDataTransferStartedEvent`, -`...StoppedEvent`, `...CompletedEvent`) plus matching `protected virtual` hooks -(`OnTransferStarted`, `OnTransferStopped`, `OnTransferCompleted`; none of the three matching -events update any `Last*` property, unlike the reception-side events further up the chain). -You never call it directly. See [DataTransferer](./data-transferer) for the type a consumer -actually uses. - -## Owner-only broadcasts, verified per call - -Each `Notify*` broadcast method checks that the caller is genuinely the object's owner -before acting. Any player can invoke a [`[NetworkCallable]`](https://creators.vrchat.com/worlds/udon/networking/events/#legacy-events-and-security) -method directly, so without this a malicious call could reset every client's receiver state -(corrupting the expected-sender tracking the next layer relies on) and stall the transfer -for everyone. The check is skipped only during the owner's own inline broadcast (tracked by -the inherited `_isBroadcasting` flag), because `CallingPlayer` isn't reliable there. - -## Why the rate limit is 100/second, not the default - -All three broadcasts, and the chunk-delivery event defined one layer up, share a -`maxEventsPerSecond: 100` limit, the [maximum VRChat -allows](https://creators.vrchat.com/worlds/udon/networking/events/#rate-limiting). VRChat -only guarantees relative delivery ordering between two event types from the same sender -when *neither* exceeds its own rate limit: at the default rate, a rapid cancel-then-restart -could let a chunk event overtake the "transfer started" notification on a remote client, -which would see `_transferActive` still false when that chunk arrives, silently drop it, -and stall forever with no automatic recovery. Keeping every event in this family at the -same, generous rate is what keeps that ordering guarantee in force. - -## Deferred stop/complete emission - -The broadcast handlers behind these events, `NotifyTrackedPlayersDataTransferStopped`/ -`Completed`, call the `OnTransferStopped`/`OnTransferCompleted` hooks synchronously but -don't emit the public events yet. They set a pending flag first and schedule the actual -emission (`_EmitDataTransferStopped`/`_EmitDataTransferCompleted`) one frame later via -`SendCustomEventDelayedSeconds`. This matters because, on the owner, `ExecuteStop`/ -`ExecuteComplete` (from `Process`) are still on the call stack when the broadcast handler -runs: internal cleanup hasn't run yet. If a subscriber reacted to the event synchronously -by starting a new transfer, that new transfer's state would be corrupted by the -still-pending cleanup of the old one. Deferring the event by a frame lets cleanup finish -first. - -The pending flags exist specifically to handle a new transfer starting inside that one-frame -gap: `NotifyTrackedPlayersDataTransferStarted` clears both flags immediately, so if a -subscriber restarts the transfer before the deferred call fires, the stale stop/complete -event for the *old* transfer is suppressed rather than firing after the new one has already -begun. +`Tsvrc.DataTransfer.DataSender` adds networking to the sequence hooks of +[ChunkedTransferSession](./chunked-transfer-session): it turns `OnChunkSequenceStarted`, `Stopped` +and `Completed` into `[NetworkCallable]` broadcasts to every player in the instance. It exposes them +as three events (`OnDataTransferStartedEvent`, `...StoppedEvent` and `...CompletedEvent`) with +matching `protected virtual` hooks (`OnTransferStarted`, `OnTransferStopped` and +`OnTransferCompleted`). None of the three updates a `Last*` property. It's one layer of [the data +transfer chain](../data-transfer-overview), which names the type to use. + +## Owner-only broadcasts, checked per call + +Each `Notify*` broadcast method checks that the caller is the owner of the object before acting. +Any player can call a +[`[NetworkCallable]`](https://creators.vrchat.com/worlds/udon/networking/events/#legacy-events-and-security) +method directly, so without the check a forged call could fire the start, stop or complete events +on every client and stall a running transfer for everyone. The owner skips the check during its own +broadcast, tracked by the inherited `_isBroadcasting` flag, because `CallingPlayer` isn't reliable +there. + +## Why the rate limit is 100 per second + +The three broadcasts use `maxEventsPerSecond: 100`, the [maximum VRChat +allows](https://creators.vrchat.com/worlds/udon/networking/events/#rate-limiting). VRChat keeps the +order of events from one sender only while none of them hits its own rate limit. At the default +rate, a quick cancel and restart could let a later event of the transfer, such as a chunk, overtake +the start notification on another client. That client would drop the chunk and the transfer would +stall with nothing to recover it. A subclass that adds events to the transfer needs the same limit +to keep that order. + +## Deferred stop and complete events + +The broadcast handlers `NotifyTrackedPlayersDataTransferStopped` and `...Completed` call the +`OnTransferStopped` and `OnTransferCompleted` hooks right away, but emit the public events one frame +later: they set a pending flag and schedule `_EmitDataTransferStopped` or +`_EmitDataTransferCompleted` with `SendCustomEventDelayedSeconds`. On the owner, the handler runs +inside the stop or complete call, before [the cleanup of the process](../../core-concepts/process#lifecycle). +A subscriber that started a new transfer from the event right away would have the new state cleared +by that cleanup. Waiting a frame lets the cleanup finish first. + +`NotifyTrackedPlayersDataTransferStarted` clears both pending flags, so when a subscriber starts a +new transfer within that frame, the deferred stop or complete event of the old transfer doesn't fire +after the new one began. diff --git a/docs/core/networking-data/overview.md b/docs/core/networking-data/overview.md index 767e53b..16f58af 100644 --- a/docs/core/networking-data/overview.md +++ b/docs/core/networking-data/overview.md @@ -6,8 +6,8 @@ sidebar_position: 3 # The data transfer chain -`DataTransferer`, the type you actually use, is the last link in an eight-level -inheritance chain built on [Process](../core-concepts/process): +`DataTransferer`, the type you use, is the last link in an inheritance chain built on +[Process](../core-concepts/process): `Process → PlayerTracker → ReadyCheckProcess → DataChunker → ChunkedTransferSession → DataSender → DataChunkReceiver → DataSenderReceiver → DataTransferer`. Each layer adds one concern on top of the one below it, so this section documents them bottom-up in that same @@ -21,7 +21,7 @@ event](https://creators.vrchat.com/worlds/udon/networking/events/#parameter-size at 16 KB and [the overall outgoing Udon bandwidth](https://creators.vrchat.com/worlds/udon/networking/network-details/#bandwidth-limits) to about 11 KB/s, so sending an arbitrary string to a set of players means splitting it -into chunks, sending them one at a time, and confirming each one actually arrived before +into chunks, sending them one at a time, and confirming each one arrived before sending the next, all while players can join, leave, get suspended, or have ownership of the sending object change mid-transfer. Each layer in the chain exists to handle one slice of that: chunking the data, sequencing the chunks through a ready check, broadcasting @@ -34,22 +34,22 @@ Read these in order; each page assumes the ones before it: 1. [DataChunker](./data-transfer/data-chunker): splits and validates the raw string, no networking or process state of its own. -2. [ChunkedTransferSession](./data-transfer/chunked-transfer-session): sequences one chunk's - ready check after another. +2. [ChunkedTransferSession](./data-transfer/chunked-transfer-session): runs the ready check of + each chunk in turn. 3. [DataSender](./data-transfer/data-sender): broadcasts the sequencing transitions as networked lifecycle events. 4. [DataChunkReceiver](./data-transfer/data-chunk-receiver): validates, stores, and acknowledges each incoming chunk. -5. [DataSenderReceiver](./data-transfer/data-sender-receiver): wraps the receiver's raw - hooks into a clean `Last*`/event API. -6. [DataTransferer](./data-transfer/data-transferer): the type you actually use, adding only +5. [DataSenderReceiver](./data-transfer/data-sender-receiver): turns the raw hooks of the + receiver into `Last*` properties and events. +6. [DataTransferer](./data-transfer/data-transferer): the type you use, adding only transfer-focused event names on top. -## What you actually use +## What you use -Unless you're extending the chain itself, you only interact with `DataTransferer`: call +Unless you're extending the chain, you only use `DataTransferer`: call `TransferData(data, playerIds)`, subscribe to its events, read `LastData` when a transfer -completes. Everything on the intermediate pages exists to explain *why* `DataTransferer` +completes. The intermediate pages explain *why* `DataTransferer` behaves the way it does under failure conditions (a player leaving mid-transfer, an ownership handover between chunks, a malicious direct call to one of the network-callable methods), not to hand you a separate API surface at each layer. From 9543b4af5495553c6571141ab5b40f5f43943c26 Mon Sep 17 00:00:00 2001 From: Toni Seas <83423392+ToniSeas@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:49:25 -0600 Subject: [PATCH 4/5] Link to other components instead of describing them Signed-off-by: Toni Seas <83423392+ToniSeas@users.noreply.github.com> --- docs/core/explanations/sync-model.md | 10 ++-- docs/core/game-flow/ranked-game-session.md | 60 +++++++++------------- docs/core/how-to/wiring-a-ready-check.md | 26 +++++----- 3 files changed, 42 insertions(+), 54 deletions(-) diff --git a/docs/core/explanations/sync-model.md b/docs/core/explanations/sync-model.md index d1a0ba3..1e7a3cc 100644 --- a/docs/core/explanations/sync-model.md +++ b/docs/core/explanations/sync-model.md @@ -6,9 +6,8 @@ sidebar_position: 3 # Decision note: the shared-memory and sync model -Every Core type that replicates state, from [`TsvrcMemory`](../networking-data/tsvrc-memory) to -[`Process`](../core-concepts/process) and the types built on it such as -[`TsvrcTimer`](../networking-data/tsvrc-timer), uses the same two pieces: the VRChat manual sync mode +Every Core type that replicates state, such as [`TsvrcMemory`](../networking-data/tsvrc-memory) +and [`Process`](../core-concepts/process), uses the same two pieces: the VRChat manual sync mode (`BehaviourSyncMode.Manual`) and an explicit `RequestSerialization()` call after each change. This note explains why. @@ -57,6 +56,5 @@ The types differ in how a write from another player reaches the owner: - `TsvrcMemory` takes ownership for the local player before it writes a synced key. - `Process` never takes ownership by itself. A lifecycle call from another player is sent to the - owner over the network, and the owner applies it. -- Subclasses that should run on the player who starts them, such as `TsvrcTimer` and - `PlayerTracker`, take ownership with `SetProcessOwner` before starting. + owner over the network, and the owner applies it. A subclass that should run on the player who + starts it [takes ownership first](../core-concepts/process#lifecycle). diff --git a/docs/core/game-flow/ranked-game-session.md b/docs/core/game-flow/ranked-game-session.md index daa2d81..f50b571 100644 --- a/docs/core/game-flow/ranked-game-session.md +++ b/docs/core/game-flow/ranked-game-session.md @@ -9,14 +9,11 @@ sidebar_position: 2 `Tsvrc.Session.RankedGameSession` composes the rest of the tracking and timing stack into a full round-based game session: a lobby, a synchronized ready-check-gated start, an active game phase with completion tracking, and a configurable end condition. Its generated shadow -is `TsRankedGameSession`. It's one of the most dependent runtime types in the package: it's -built directly on [`PlayerTracker`](../players-tracking/player-tracker), -[`ReadyCheckProcess`](../players-tracking/ready-check-process), and -[`TsvrcTimer`](../networking-data/tsvrc-timer), so those three pages are real prerequisites, -not just related reading. It has no relationship to [`StateManager`](./state-manager) on the -page before this one; its own `CurrentState` is a plain field, not a `StateManager` -instance. See [Run a full timed round with a lobby](../how-to/running-a-timed-round) for -a task-oriented walkthrough. +is `TsRankedGameSession`. It's built from [`PlayerTracker`](../players-tracking/player-tracker), +[`ReadyCheckProcess`](../players-tracking/ready-check-process) and +[`TsvrcTimer`](../networking-data/tsvrc-timer) instances, listed under [What it's built +from](#what-its-built-from). See [Run a full timed round with a lobby](../how-to/running-a-timed-round) +for a task-oriented walkthrough. ## Getting an instance @@ -91,17 +88,15 @@ logs an error and does nothing if a session is already running (`CurrentState` i `Idle`). It takes an optional `additionalKnownPresentPlayerIds`: extra IDs unioned into that initial snapshot even if the synced state of `_lobbyTracker` hasn't caught up to them yet, since a caller's own local knowledge (say, a trigger volume's roster) can be ahead of what's -propagated over the network. Starting with an empty roster is rejected outright with an -error, rather than allowed to silently hang: `ReadyCheckProcess.CheckAllPlayersReady` never -auto-completes on a zero-length tracked list, so an empty-lobby start would otherwise leave -the session stuck in `Loading` forever with nothing able to complete it except a manual -`StopSession`. +propagated over the network. Starting with an empty roster logs an error and does nothing: +[a ready check with no players never +completes](../players-tracking/ready-check-process#edge-cases), so the session would otherwise +stay in `Loading` until a `StopSession` call. `AddLoadingParticipant` lets a player join an *already-running* ready check that they weren't part of the original snapshot: useful for a game that becomes eligible to more -players over time without waiting for the next round. It's a no-op outside `Loading` and for -a player already tracked, matching the underlying `AddTrackedPlayers` semantics it forwards -to. +players over time without waiting for the next round. It does nothing outside `Loading` or +for a player already tracked. Once `InGame`, `RemoveGamePlayer` drops a player from `_gameTracker` directly, for a player leaving the round early without leaving the instance (a respawn, an exit trigger); it's a @@ -118,31 +113,24 @@ Four independent, individually toggleable natural end conditions (`_endOnTimerComplete`, `_endOnAllGamePlayersLeft`, `_endOnAllPlayersCompleted`, `_stopOnEmptyLobbyDuringLoading`) plus an explicit `StopSession()` call. Each maps to its own `RankedGameSessionEndReason` value, readable via `LastEndReason` once the corresponding event -fires. `_stopOnEmptyLobbyDuringLoading` exists for the same underlying reason the empty-lobby -start guard does: `CheckAllPlayersReady` can't complete a check with nobody left tracked, so -losing every remaining player during `Loading` needs an explicit stop or the session hangs -there permanently, one phase earlier than the equivalent `_endOnAllGamePlayersLeft` check. +fires. `_stopOnEmptyLobbyDuringLoading` exists for the same reason as the empty-roster start +guard: a ready check whose players all left never completes, so without it the session would stay +in `Loading`. It's the `Loading` counterpart of `_endOnAllGamePlayersLeft`. **`StopSession` reaches every client during `Loading`, but not during `InGame`.** The two branches behave differently, and the difference matters if you're building a networked "force stop" button. -During `Loading`, `StopSession` calls `_readyCheck.StopReadyCheck()`, an ordinary `Process` -call: it forwards to the real owner if the caller isn't it, and the owner's stop already -broadcasts to every instance player, the same way every other `ReadyCheckProcess`/ -`PlayerTracker` state change does. `RankedGameSession` subscribes to that broadcast -(`OnReadyCheckStoppedEvent`), so every client's own bookkeeping updates on its own once it -arrives, with no extra work from you. - -During `InGame`, `StopSession` instead calls `_EndSession` directly: `CurrentState`, -`LastEndReason`, and `OnSessionStoppedEvent` are all set and fired immediately and -synchronously, on the calling client only, before `_StopSubProcesses()` even asks -`_gameTracker`/`_completedTracker`/`_timer` to stop (and each of those calls is itself -subject to the same owner-forwarding as any other `Process` call). Those three sub-processes -do eventually stop and broadcast that to every client once their real owner processes it, -but `RankedGameSession` isn't subscribed to any of *their* stop events, only to their -players-added/players-removed/timer-completed events, so nothing re-runs its own bookkeeping -on other clients when that stop arrives. **A client that didn't call `StopSession` itself +During `Loading`, `StopSession` calls `_readyCheck.StopReadyCheck()`. `RankedGameSession` +subscribes to `OnReadyCheckStoppedEvent`, which fires on every client when the ready check stops, +so every client updates its own state when that event arrives. + +During `InGame`, `StopSession` calls `_EndSession` directly instead: `CurrentState`, +`LastEndReason` and `OnSessionStoppedEvent` are set and fired right away, on the calling client +only, before `_StopSubProcesses()` asks `_gameTracker`, `_completedTracker` and `_timer` to stop. +`RankedGameSession` doesn't subscribe to the stop events of those three, only to their +players-added, players-removed and timer-completed events, so nothing updates its state on other +clients when they stop. **A client that didn't call `StopSession` itself during `InGame` never sees its own `CurrentState` return to `Idle`.** `RankedGameSession` carries no authorization or broadcast policy of its own (who's allowed diff --git a/docs/core/how-to/wiring-a-ready-check.md b/docs/core/how-to/wiring-a-ready-check.md index d4a32d9..f49601a 100644 --- a/docs/core/how-to/wiring-a-ready-check.md +++ b/docs/core/how-to/wiring-a-ready-check.md @@ -51,30 +51,33 @@ How to block an action until every present player has confirmed they're ready, u ``` 3. Wherever your own UI lets a player mark themselves ready, call - `_readyCheck.SetReady()` (or `SetReady(false)` to un-ready). Only the calling player's - own status can be changed this way; see the reference page for why. + `_readyCheck.SetReady()` (or `SetReady(false)` to take it back). It only changes the status of + the calling player, see [the reference + page](../players-tracking/ready-check-process#ownership-and-changes-to-the-ready-set) for why. ## Handling the roster changing mid-check -If a player can join or leave while the check is already running, restart it with the -current roster rather than trying to patch the running one: `ReadyCheckProcess` doesn't -support adding players to a check already in progress. +A player who leaves or is suspended is [removed from the check +automatically](../players-tracking/player-tracker#automatic-removal). To include a player who +joins while the check runs, add them to it. They start out not ready: ```csharp public void _OnRosterChanged() { if (_readyCheck.IsProcessRunning()) { - _readyCheck.StopReadyCheck(); + _readyCheck.AddTrackedPlayers(_roster.LastAddedPlayerIds); + return; } _readyCheck.StartReadyCheck(_roster.LastPlayerIds); } ``` -For a fixed, known set of players instead of "everyone currently in the instance," start -`PlayerTracker`/`ReadyCheckProcess` directly with that explicit ID list and skip -`AutoPlayerTracker` entirely. +Restarting the check with the current roster also works, but clears every ready mark. + +For a fixed, known set of players instead of everyone in the instance, start the ready check with +that ID list and skip `AutoPlayerTracker`. Declare each `[WirePool]` field exactly once in the whole project if you want one shared roster and ready check. [Pool slot counts are computed per @@ -86,8 +89,7 @@ field of the same type. ## Why this shape -`ReadyCheckProcess` only tracks readiness for players it's already tracking. See -[its reference page](../players-tracking/ready-check-process) for the ordering guarantees -around `SetReady` and what happens when the tracked set changes mid-check. For the "why +See the [`ReadyCheckProcess` reference page](../players-tracking/ready-check-process) for the +ordering of `SetReady` and what happens when the tracked set changes during a check. For the "why generated code, why not just call these directly at compile time" background, see [How Core fits together](../core-concepts/how-it-fits-together). From 6a85210a892f40b126fccb2717f834cebb418238 Mon Sep 17 00:00:00 2001 From: Toni Seas <83423392+ToniSeas@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:10:00 -0600 Subject: [PATCH 5/5] Cite only official VRChat docs Signed-off-by: Toni Seas <83423392+ToniSeas@users.noreply.github.com> --- docs/core/core-concepts/process.md | 6 ++---- docs/core/players-tracking/auto-player-tracker.md | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/docs/core/core-concepts/process.md b/docs/core/core-concepts/process.md index 8d64afe..5d8eab1 100644 --- a/docs/core/core-concepts/process.md +++ b/docs/core/core-concepts/process.md @@ -86,11 +86,9 @@ of its GameObject. Subclasses read and change it with three `protected` methods: A run keeps going when its owner can no longer drive it: -- When the owner leaves, [VRChat picks a new +- When the owner leaves, [VRChat assigns a new owner](https://creators.vrchat.com/worlds/udon/networking/ownership/#how-ownership-works). That - player takes over from `OnPlayerLeft` or `OnOwnershipTransferred`, whichever runs once it owns the - object. The two [can fire in either - order](https://feedback.vrchat.com/udon/p/order-of-onownershiptransferred-and-onplayerleft-when-another-player-leaves-the). + player takes over from `OnPlayerLeft` or `OnOwnershipTransferred`. - When the owner is [suspended](https://creators.vrchat.com/worlds/udon/players/#get-issuspended), their client stops running Udon and answering network events, so nothing would drive the run. Every client that sees the suspension in `OnPlayerSuspendChanged` calls `SetProcessOwner` for its diff --git a/docs/core/players-tracking/auto-player-tracker.md b/docs/core/players-tracking/auto-player-tracker.md index 12c4c36..105ff33 100644 --- a/docs/core/players-tracking/auto-player-tracker.md +++ b/docs/core/players-tracking/auto-player-tracker.md @@ -28,7 +28,7 @@ ignores both of its parameters, since `AutoPlayerTracker` never uses the process While tracking runs, the owner adds each player who joins in `OnPlayerJoined`. A join is skipped when the player reference [isn't valid](https://creators.vrchat.com/worlds/udon/players/#isvalid), -since accessing an invalid reference has been reported to crash the behaviour. Players who leave or +which VRChat says to check before using a player. Players who leave or are suspended are [removed by the base class](./player-tracker#automatic-removal). Other players ignore `OnPlayerJoined`, because only the owner changes the tracked set.