From 4c6a3a53c6e8f8bbf6451560b65dc430454ceacd Mon Sep 17 00:00:00 2001 From: Toni Seas <83423392+ToniSeas@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:03:05 -0600 Subject: [PATCH 1/3] Rewrite the Process page for the new ownership model Signed-off-by: Toni Seas <83423392+ToniSeas@users.noreply.github.com> --- docs/core/core-concepts/process.md | 250 +++++++++++------------------ 1 file changed, 98 insertions(+), 152 deletions(-) diff --git a/docs/core/core-concepts/process.md b/docs/core/core-concepts/process.md index b74b58b..18cb0b3 100644 --- a/docs/core/core-concepts/process.md +++ b/docs/core/core-concepts/process.md @@ -6,18 +6,14 @@ sidebar_position: 4 # Process -`Tsvrc.Core.Process` is the base class for networked, owner-driven processes: something -with a start/stop/complete lifecycle that one player (the owner) drives, that survives the -owner leaving, and that optionally ticks on an interval. Its generated shadow is -`TsProcess`. It's the root of the longest inheritance chain in Core: `PlayerTracker`, -`AutoPlayerTracker`, `ReadyCheckProcess`, and the entire `DataTransferer` chain all build -on top of it, so its ownership and lifecycle model is worth understanding well before -those pages. +`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. ## Usage -Subclass the generated shadow, override the lifecycle hooks you need, and call -`StartProcess`/`CompleteProcess` from wherever the process should begin and end: +Subclass the generated shadow and override the hooks you need: ```csharp public class GameRound : TsProcess @@ -34,154 +30,104 @@ public class GameRound : TsProcess } ``` +Any player can call the lifecycle methods. The owner runs the call, and any other player sends it to +the owner over the network: + ```csharp gameRound.StartProcess(); -// ...later, from anywhere, owner or not: +// ...later, from any player: gameRound.CompleteProcess(); ``` -`OnOwnerAbandonedProcess` is the one hook that fires on someone other than the process -owner. Override it if the new owner needs to resume state the previous owner was -tracking locally, like a running timer or an in-progress countdown: only synced fields -survive the handoff automatically. - -None of this works until [`TsConstruct`](./tsvrc-behaviour#construction) has run on the -instance. `Process.TsStart()` (called by `TsConstruct`) is what caches the local player's -ID for ownership comparisons; skip construction and that cache stays at its default, -which makes `IsProcessOwner()` report `false` even for the actual owner. Registering -something as a Global, Construct, Pool, or Factory entry is what makes codegen call -`TsConstruct` for you; using an instance you wired up entirely by hand means calling it -yourself. - ## Lifecycle -Call `StartProcess()` to begin. It claims ownership for the local player if not already -owner, fires `OnProcessStarted()`, and schedules the tick loop. From there, either -`StopProcess()` or `CompleteProcess()` ends it. Both check whether the caller currently -has authority first: if not, the call is forwarded to the real owner over the network -instead of acting locally. `IsProcessRunning()` reports whether a process is currently -active. - -Override points, all `protected virtual`, all no-ops by default, and **all invoked only on -the process owner** except `OnOwnerAbandonedProcess`: - -| Hook | Fires when | -|---|---| -| `OnProcessStarted` | `StartProcess` succeeds | -| `OnProcessStopped` | `StopProcess` (or a forwarded `RequestStopProcess`) executes | -| `OnProcessCompleted` | `CompleteProcess` (or a forwarded `RequestCompleteProcess`) executes | -| `OnProcessCleanup(bool isCompleted)` | after stop or complete, once mandatory state is already reset; add subclass-specific cleanup here, no base call needed | -| `OnProcessUpdate` | every 0.5s while running, only if `StartProcess(useProcessUpdate: true)` was passed | -| `OnOwnerAbandonedProcess` | **on the new owner**, when ownership was taken over after the previous owner left or was suspended | - -## Ownership and authority - -A process has exactly one owner at a time, tracked two ways: a display-name-based ID -(`_ownerId`, used for `FindPlayerByID` lookups) and a numeric player ID -(`_ownerPlayerIdInt`). Comparing the numeric ID, not the string, is what -`IsProcessOwner()` actually does, since it avoids a string allocation. - -`StopProcess`/`CompleteProcess` treat two things as sufficient authority to act locally: -being the recognized process owner, or simply holding VRChat [object -ownership](https://creators.vrchat.com/worlds/udon/networking/ownership/) of the -GameObject (`Networking.IsOwner`, layered on top of Unity by the VRChat SDK, not a -Unity feature itself). That second check exists for a specific timing window: right -after the previous owner leaves, VRChat has already handed you object ownership, but -the `_ownerId` field hasn't caught up yet, since that only updates once a -deserialization packet arrives. - -Ownership recovery happens through three VRChat callbacks, layered to cover different -failure orderings: - -- **[`OnPlayerLeft`](https://udonsharp.docs.vrchat.com/events/#udon-player-events)** — if - the process is running and the leaving player was its owner *and* object ownership - already transferred to the local client, takes over immediately. -- **[`OnOwnershipTransferred`](https://udonsharp.docs.vrchat.com/events/#udon-networking-events)** - — a fallback for the case where `OnPlayerLeft` saw `IsOwner() == false` and did nothing - (a [known engine ordering - issue](https://feedback.vrchat.com/udon/p/order-of-onownershiptransferred-and-onplayerleft-when-another-player-leaves-the) - where `OnOwnershipTransferred` can fire after `OnPlayerLeft` instead of before it). - Also handles a suspended owner: if the named owner is gone or suspended and this client - now holds object ownership, it takes over here instead. -- **[`OnPlayerSuspendChanged`](https://creators.vrchat.com/worlds/udon/graph/event-nodes/#onplayersuspendchanged)** - — a suspended player can't run Udon code or receive network events, so a process they - own would stall forever. Every non-suspended client reacts to this event by requesting - ownership; VRChat picks one winner, and that winner's `OnOwnershipTransferred` then - completes the takeover. - -[`OnDeserialization`](https://udonsharp.docs.vrchat.com/events/#udon-networking-events) -adds a fourth recovery path, for manual sync's own failure mode. Because -[`RequestSerialization`](https://udonsharp.docs.vrchat.com/vrchat-api/#methods-5) packets -are queued rather than instantaneous, one from a just-departed or just-suspended owner -can arrive *after* a takeover already happened, overwriting `_ownerId` back to the old, -now-invalid owner. `OnDeserialization` detects this (local client holds object -ownership, the process is running, but the named owner is gone or suspended) and reasserts -ownership. It also unconditionally restarts the tick loop whenever this client is the -owner and the loop isn't currently running: a general safety net for "the loop went -silent and nothing else caught it." - -## The tick loop and auto-resync - -`StartProcess` schedules a self-rescheduling loop via -[`SendCustomEventDelayedSeconds`](https://udonsharp.docs.vrchat.com/vrchat-api/#methods-5), -not `Update()`. That's deliberate: Unity [never delivers -`Update()`](https://docs.unity3d.com/ScriptReference/GameObject.SetActive.html) to an -inactive GameObject or a disabled component, but a process needs to keep ticking even if -its GameObject is hidden. The loop runs unconditionally for every running, owned process -on a 0.5-second cadence, and only calls `OnProcessUpdate` if `useProcessUpdate: true` was -passed to `StartProcess`. - -Independent of that, the loop re-broadcasts the object's full synced state via -`RequestSerialization` every 5 seconds while running: the auto-resync heartbeat. This -exists because [`[NetworkCallable]`](https://creators.vrchat.com/worlds/udon/networking/events/) -broadcasts (see `RequestStopProcess`/`RequestCompleteProcess` below) aren't -delivery-confirmed. A client that missed one self-heals within a few seconds via the -next resync, with no acknowledgement or retry logic needed anywhere. - -The loop guards against stale scheduled calls with a real-time deadline -(`_nextTickDueAtRealTime`): `SendCustomEventDelayedSeconds` can't be canceled once -scheduled, so stopping and immediately restarting a process in the same frame can leave -an old loop's already-in-flight call pending. That call fires before the new deadline -and is discarded without rescheduling, so the stale chain dies on its own rather than -retrying. - -## RequestStopProcess and RequestCompleteProcess - -These are the +`StartProcess(useProcessUpdate)` starts a run on the owner. It marks the process running, calls +`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. + +`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 +owner. `IsProcessRunning()` reports whether a run is active. Starting a process that's already +running, or stopping or completing one that isn't, logs a warning and does nothing. + +The hooks are all `protected virtual` and empty by default: + +| Hook | Runs on | When | +|---|---|---| +| `OnProcessStarted` | Owner | A run starts, before its first packet goes out, so synced fields set here are in that packet | +| `OnProcessStopped` | Owner | `StopProcess` ends the run | +| `OnProcessCompleted` | Owner | `CompleteProcess` ends the run | +| `OnProcessCleanup(bool isCompleted)` | Owner | After either of the two above, before the cleanup packet goes out. Clear the synced state of your subclass here | +| `OnProcessUpdate` | Owner | About every 0.5 seconds while running, if the run started with `useProcessUpdate: true` | +| `OnBecameProcessOwner` | New owner | The local player becomes the owner while a run is going, see [Taking over a run](#taking-over-a-run) | + +`OnProcessCleanup` is skipped when a new run starts from inside `OnProcessStopped` or +`OnProcessCompleted`, because it would clear the state the new run just set. That happens when a +subclass broadcasts its stop with `SendCustomNetworkEvent(All, ...)`, which [runs on the sender +right away](https://creators.vrchat.com/worlds/udon/networking/events/#event-targeting), and a +listener starts the process again. + +## Ownership + +The owner of a process is the [VRChat owner](https://creators.vrchat.com/worlds/udon/networking/ownership/) +of its GameObject. Subclasses read and change it with three `protected` methods: + +- `IsProcessOwner()` checks whether the local player is the owner. +- `IsProcessOwnedBy(player)` checks whether `player` is the owner. +- `SetProcessOwner(newOwner)` calls `Networking.SetOwner` and sends nothing itself. Don't write + synced fields right after it: the old owner [has to acknowledge the + transfer](https://udonsharp.docs.vrchat.com/networking-tips-&-tricks/#known-issues) first, which + can take a few seconds. + +## Taking over a run + +A run keeps going when its owner can no longer drive it: + +- When the owner leaves, [VRChat picks 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). +- 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 + own player, and the one that ends up owning the object takes over. + +Taking over calls `OnBecameProcessOwner`, then restarts the tick loop. It happens once per handoff, +even when both callbacks see it. It also happens for a handoff made on purpose, such as a subclass +calling `SetProcessOwner` during a run. Only synced fields reach the new owner, so override +`OnBecameProcessOwner` to rebuild anything the previous owner kept in unsynced fields. + +## The tick loop + +While a process runs, its owner runs a loop scheduled with +[`SendCustomEventDelayedSeconds`](https://udonsharp.docs.vrchat.com/vrchat-api/#methods-5) every 0.5 +seconds. Each tick calls `OnProcessUpdate` if the run asked for it. Every 5 seconds it also calls +`RequestSerialization`, which sends every synced field again, those of subclasses included, so a +client that missed a network event still gets the current synced state within a few seconds. + +The loop stops when the run ends or the local player stops being the owner, including when +`OnProcessUpdate` ends the run. If the owner receives a packet while the +process runs and the loop isn't running, `OnDeserialization` starts it again. + +## The request methods + +`RequestStartProcess(bool useProcessUpdate)`, `RequestStopProcess(int requestedGeneration)` and +`RequestCompleteProcess(int requestedGeneration)` are the [`[NetworkCallable]`](https://creators.vrchat.com/worlds/udon/networking/events/#legacy-events-and-security) -targets a non-owner's `StopProcess`/`CompleteProcess` call forwards to, each -rate-limited to one call per second. Two things worth knowing before relying on them -directly: - -- VRChat has no way to restrict who calls a `[NetworkCallable]` method: any player can - invoke these directly. The owner-or-object-owner guard is the only check Core - applies; any further authorization is the subclass's responsibility. -- There's no per-run generation token. Calls beyond the rate limit are [queued, not - dropped](https://creators.vrchat.com/worlds/udon/networking/events/#rate-limiting), - and can arrive up to roughly a second late. That means a stale call arriving after - the same owner has already stopped and started a brand new run is indistinguishable - from a legitimate call for that new run, and incorrectly stops or completes it. If - your world stops and restarts a process in quick succession, coordinate that at a - level above `Process` itself. - -## Known races, by design - -Two races are documented on the methods themselves rather than solved internally, -because solving them requires a policy decision `Process` can't make on its own: - -- **`StartProcess`** — if two clients call it before either's `RequestSerialization` - packet arrives, both locally pass the "not already running" guard and both fire - `OnProcessStarted`. One packet eventually wins via `OnDeserialization`, leaving the - other client with a locally running process the network has already discarded. - Protect against this at a higher level: for example, only ever call `StartProcess` - from the instance master, or through a coordinated network event. -- **Reentrant stop-then-start** — if a listener reacts to - `OnProcessStopped`/`OnProcessCompleted` by synchronously starting a new process, - possible because [`SendCustomNetworkEvent(All, ...)` fires inline on the - sender](https://creators.vrchat.com/worlds/udon/networking/events/#event-targeting), - the internal cleanup step detects that `_isRunning` is already `true` again by the - time it would run. It then skips clearing the owner/update fields and skips the - `OnProcessCleanup` hook entirely, since both would otherwise clobber the new - process's just-set state. This is handled correctly internally. It's listed here - because it explains why `OnProcessCleanup` won't always fire after every - stop/complete call if a listener restarts the process inline. +methods a non-owner call sends to the owner. They're public, so any player in the instance can call +them directly, and the owner checks each one: + +- The local player must be the owner, and the process must be stopped for a start or running for a + stop or complete. +- Stop and complete carry the run number the caller saw. Each start increases the synced run + number, and the owner rejects a request whose number doesn't match the current run. +- Each method is rate-limited to one call per second. VRChat [queues calls over that + rate](https://creators.vrchat.com/worlds/udon/networking/events/#rate-limiting) instead of + dropping them, so a stop sent during one run can arrive after the next one started. The run number + is what rejects it. + +A rejected request logs a warning on the owner and does nothing else. Who may start or stop a +process beyond that is up to the subclass. From c9f3302c3db5bbfcc25098e110073b690fa8907c Mon Sep 17 00:00:00 2001 From: Toni Seas <83423392+ToniSeas@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:07:17 -0600 Subject: [PATCH 2/3] Update the Process subclass pages for 0.1.3 Signed-off-by: Toni Seas <83423392+ToniSeas@users.noreply.github.com> --- docs/core/core-concepts/process.md | 4 +- docs/core/how-to/showing-player-positions.md | 20 +- .../how-to/tracking-a-group-of-players.md | 21 +- .../data-transfer/chunked-transfer-session.md | 146 ++++++------- .../data-transfer/data-transferer.md | 66 +++--- docs/core/networking-data/tsvrc-timer.md | 203 ++++++++---------- .../players-tracking/auto-player-tracker.md | 99 ++++----- docs/core/players-tracking/player-tracker.md | 144 ++++++------- .../players-tracking/ready-check-process.md | 162 ++++++-------- 9 files changed, 374 insertions(+), 491 deletions(-) diff --git a/docs/core/core-concepts/process.md b/docs/core/core-concepts/process.md index 18cb0b3..b5008c2 100644 --- a/docs/core/core-concepts/process.md +++ b/docs/core/core-concepts/process.md @@ -45,7 +45,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. +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. `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/how-to/showing-player-positions.md b/docs/core/how-to/showing-player-positions.md index e5fcede..c12fec1 100644 --- a/docs/core/how-to/showing-player-positions.md +++ b/docs/core/how-to/showing-player-positions.md @@ -13,12 +13,10 @@ How to render a live minimap-style overlay of tracked players, using ## Steps -1. Add a `PlayerPositionOverlay` to your scene and register it as a construct. It's a - [`PlayerTracker`](../players-tracking/player-tracker) underneath, so it needs - `TsConstruct` to run before anything on it works. The renderer doesn't: neither - `PlayerMarkerRenderer` nor `RasterPlayerMarkerRenderer` overrides `TsStart` or touches - `_ts`, so it's safe to just drop one into the scene and assign it, no registration - needed. +1. Add a `PlayerPositionOverlay` to your scene and register it as a construct, so codegen calls + `TsConstruct` on it at startup. The renderer needs no registration: neither + `PlayerMarkerRenderer` nor `RasterPlayerMarkerRenderer` uses `TsStart` or `_ts`, so drop one + into the scene and assign it. 2. Subclass `RasterPlayerMarkerRenderer` to decide what a marker looks like: ```csharp @@ -33,7 +31,7 @@ How to render a live minimap-style overlay of tracked players, using } ``` -3. Wire the renderer's output texture to a `RawImage` in your UI, then configure and start +3. Wire the output texture of the renderer to a `RawImage` in your UI, then configure and start the overlay: ```csharp @@ -52,8 +50,8 @@ How to render a live minimap-style overlay of tracked players, using } ``` -4. Add or remove players while it runs with the overlay's own (inherited) tracking methods, - `_overlay.AddTrackedPlayers(playerIds)`/`RemoveTrackedPlayers(playerIds)`. The renderer +4. Add or remove players while it runs with the tracking methods the overlay inherits, + `_overlay.AddTrackedPlayers(playerIds)` and `RemoveTrackedPlayers(playerIds)`. The renderer picks up the change on its next tick with no extra wiring. If only a subset of players should ever appear (not everyone in the instance), start the @@ -77,8 +75,8 @@ public override void DrawMarker(Color32[] pixelBuffer, int textureWidth, int tex If you're not drawing to a texture at all (driving 3D world-space marker objects instead, for example), implement [`PlayerMarkerRenderer`](../ui-components/overlay/player-marker-renderer) -directly rather than its raster subclass. `PlayerPositionOverlay` only ever talks to the -base class's two methods. +directly instead of its raster subclass. `PlayerPositionOverlay` only calls the two methods of the +base class. ## Why this shape diff --git a/docs/core/how-to/tracking-a-group-of-players.md b/docs/core/how-to/tracking-a-group-of-players.md index 307e73d..25c4f61 100644 --- a/docs/core/how-to/tracking-a-group-of-players.md +++ b/docs/core/how-to/tracking-a-group-of-players.md @@ -12,11 +12,9 @@ instance", using [`PlayerTracker`](../players-tracking/player-tracker) directly ## When to use which -Reach for `AutoPlayerTracker` first if the roster really is "every player currently in -the instance": it needs no add/remove calls of your own. Use a bare `PlayerTracker` -instead when the roster is a subset you decide, players entering a specific area, -opting into a queue, being invited to a team, since `AutoPlayerTracker` always tracks -everyone and can't be told to track less. +Use `AutoPlayerTracker` when the roster is every player in the instance: it needs no add or remove +calls of your own. Use a plain `PlayerTracker` when the roster is a subset you decide, such as +players in an area, in a queue or on a team, since `AutoPlayerTracker` always tracks everyone. ## Steps @@ -58,13 +56,12 @@ everyone and can't be told to track less. A player who leaves the instance or gets suspended is removed automatically; you never need to handle that yourself. -3. Read the roster from `LastPlayerIds` inside any `OnTracking*` callback. It's a synced, - owner-managed set: a non-owner's `AddTrackedPlayers`/`RemoveTrackedPlayers` calls - forward to the current owner over the network rather than applying locally. +3. Read the roster from `LastPlayerIds` inside any `OnTracking*` callback. The owner manages the + set: `AddTrackedPlayers` and `RemoveTrackedPlayers` called by another player go to the owner + over the network. ## Why this shape -See the [`PlayerTracker` reference page](../players-tracking/player-tracker) for the full -event table, exactly when a `Notify*` broadcast fires versus when `LastPlayerIds` itself -updates, and the three separate paths that remove a player automatically (leaving, -suspending, or the tracker's own owner abandoning it). +See the [`PlayerTracker` reference page](../players-tracking/player-tracker) for the events, when a +`Notify*` broadcast fires compared to when `LastPlayerIds` updates, and the three cases that remove +a player automatically: leaving, being suspended, or a new owner taking over the tracker. 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 4955db6..784b80f 100644 --- a/docs/core/networking-data/data-transfer/chunked-transfer-session.md +++ b/docs/core/networking-data/data-transfer/chunked-transfer-session.md @@ -6,85 +6,73 @@ sidebar_position: 2 # ChunkedTransferSession -`Tsvrc.DataTransfer.ChunkedTransferSession` sequences delivery of a -[DataChunker](./data-chunker)-split message: each chunk runs its own -[ready check](../../players-tracking/ready-check-process) (every target player must acknowledge it) -before the next chunk starts. It has no networking of its own yet: that's added by -`DataSender` above it. This layer only owns the chunk-by-chunk sequencing state machine. You -never call it directly. See [DataTransferer](./data-transferer) for the type a consumer -actually uses. - -## The sequencing loop - -`TransferData(data, playerIds)` stores the data and starts a ready check over the target -players. Each completed ready check (`OnProcessCompleted`) either starts the next chunk's -ready check or, on the last chunk, fires `OnChunkSequenceCompleted`. Four `protected virtual` -hooks mark the transitions a subclass overrides to broadcast lifecycle events: -`OnChunkSequenceStarted` (chunk index 1 about to send), `OnChunkSequenceStopped` (cancelled, -or all targets departed), `OnChunkSequenceCompleted` (last chunk's ready check completed), -and `OnDataChunkSendRequested(dataChunk, chunkIndex, totalChunks, playerIds)` (fired for -every chunk in sequence: this is the actual "send this chunk" signal a subclass hooks to do -networking). - -If every tracked player leaves or gets suspended while a chunk's ready check is active, the -check itself can't auto-complete: the completion poll in -[`ReadyCheckProcess`](../../players-tracking/ready-check-process) returns early on an empty tracked list rather than treating "nobody -left to wait on" as "everyone's ready." `ChunkedTransferSession` overrides `OnProcessUpdate` -to detect exactly that (an active process with zero tracked players) and calls -`CancelDataTransfer()` itself, so a transfer with no remaining targets doesn't stall -forever waiting for a ready check that can never complete on its own. - -## The inter-chunk gap - -Between chunks, the underlying `Process` is briefly not running: the "stopped" state of a -`ReadyCheckProcess` doesn't distinguish "the whole transfer is done" from "this chunk's check -finished, the next one hasn't started yet." `ChunkedTransferSession` tracks that window -itself with an internal, synced `_pendingNextChunk` flag, deferring the actual start of the -next ready check to the following frame (`SendCustomEventDelayedSeconds(...,0f)`) so the -previous chunk's tick loop fully exits first. Synced, so a new owner (see below) can detect -being mid-gap rather than mistaking it for "no transfer active." - -At the start of the next ready check, the target player list is re-filtered against -currently active, non-suspended players. `OnPlayerLeft`/`OnPlayerSuspendChanged` don't fire -this filtering themselves between chunks, since both guard on `IsProcessRunning()`, which is -false during the gap. - -## Ownership handover mid-transfer - -Transfer state (`_dataChunks`, chunk indices) is deliberately **not** synced: only the -owner needs it, and keeping it off the wire means the remaining message text is never -re-sent as a side effect of the `Process` [periodic -auto-resync](../../core-concepts/process#the-tick-loop-and-auto-resync), which re-broadcasts -every synced field every 5 seconds while a process runs. That means a new owner, promoted -after the old one leaves, starts with empty chunk state no matter where in the transfer the -old owner was. `OnOwnerAbandonedProcess` handles this by stopping any in-flight ready check -outright (a false "all chunks equal, transfer complete" read on zeroed indices would -otherwise be possible) and, if the handover happened during the inter-chunk gap, -broadcasting the stopped event so receivers clean up rather than waiting forever for a -chunk that will never come. `OnDeserialization` adds two more narrow-race recoveries for -stale-packet orderings specific to this handover: a late "still in the gap" packet arriving -after the takeover already resolved it, and a "zombie" state where a stale packet -resurrects `_isRunning=true` with no actual chunk data behind it. Both are documented in -detail in the source's own inline comments if you're debugging a handover edge case -specifically. +`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. + +## The sequence + +`TransferData(data, playerIds)` stores the data and starts a ready check over the target players. +Each completed ready check (`OnProcessCompleted`) starts the ready check of the next chunk, or, after +the last chunk, fires `OnChunkSequenceCompleted`. A subclass overrides four `protected virtual` hooks +to broadcast the transitions: + +- `OnChunkSequenceStarted`: chunk 1 is about to go out. +- `OnChunkSequenceStopped`: the transfer was cancelled, or every target left. +- `OnChunkSequenceCompleted`: the ready check of the last chunk completed. +- `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. + +## Between chunks + +Between two chunks, the underlying `Process` isn't running, and a stopped `ReadyCheckProcess` looks +the same whether the transfer ended or the next chunk hasn't started yet. `ChunkedTransferSession` +marks that gap with the synced `_pendingNextChunk` flag, and starts the next ready check one frame +later (`SendCustomEventDelayedSeconds(..., 0f)`) so the tick loop of the previous chunk ends first. +The flag is synced so a new owner can tell it took over in the gap. + +When the next ready check starts, the list of targets is filtered down to players who are present +and not suspended. `OnPlayerLeft` and `OnPlayerSuspendChanged` don't remove anyone during the gap, +since both check `IsProcessRunning()`. + +## A new owner during a transfer + +Transfer state (`_dataChunks` and the chunk indices) isn't synced. Only the owner needs it, and +keeping it off the network means the [periodic resync](../../core-concepts/process#the-tick-loop) +never sends the rest of the message again. A new owner therefore starts with no chunk state. + +`OnBecameProcessOwner` handles that. If a ready check is running, it stops it, since a check on +zeroed indices could otherwise read as a completed transfer. If the handover happened in the gap +between chunks, it broadcasts the stopped event so receivers clean up instead of waiting for a chunk +that won't come. + +`OnDeserialization` handles two late packets from the previous owner. A packet that says the +transfer is between chunks, arriving after the new owner already handled the gap, is turned into a +stop. A packet that marks the process running again with no chunk data behind it stops the ready +check. The comments in the source describe both orderings. ## Cancellation -`CancelDataTransfer()` is safe to call at any point, including mid-gap, and is a no-op if no -transfer is active. Its behavior depends on exactly where the transfer currently is: during -the gap it resets state and fires the stopped hook immediately; in the narrow window after a -chunk's ready check has completed but cleanup hasn't run yet, it can't act immediately -(`StopReadyCheck` would no-op there) so it sets an internal flag that the next -`OnProcessCleanup` reads to abort instead of advancing to the next chunk. That same window -also covers the moment right after the *last* chunk's ready check completes: cancelling -there is a no-op too, since the transfer is already finishing on its own by that point. - -## Edge case worth knowing - -`CanAcceptTrackedPlayerAdditions` is overridden here to reject adding tracked players -mid-transfer (once `_currentChunkIndex > 0`, spanning both in-flight chunks and inter-chunk -gaps). A player added after the first chunk already broadcast would fail every subsequent -chunk's own player-list check, be unable to ever call `SetReady`, and stall the ready check -permanently. Rejecting the addition outright, with a warning, is safer than letting that -happen silently. Removing a tracked player mid-transfer has no equivalent restriction: that's -the ordinary departure path, already handled by the mechanisms above. +`CancelDataTransfer()` can be called at any point, including between chunks, and does nothing when +no transfer is active. Between chunks it resets the state and fires the stopped hook right away. +After a ready check completed but before its cleanup ran, `StopReadyCheck` would do nothing, so it +sets an internal flag that the next `OnProcessCleanup` reads to stop instead of starting the next +chunk. After the ready check of the last chunk, cancelling does nothing, since the transfer is +already completing. + +## Adding players during a transfer + +`CanAcceptTrackedPlayerAdditions` rejects new tracked players with a warning once +`_currentChunkIndex > 0`, which covers chunks in flight and the gaps between them. A player added +after the first chunk would fail the player-list check of every later chunk, could never call +`SetReady`, and would block the ready check forever. Removing a tracked player during a transfer is +allowed, since that's how departures are handled. diff --git a/docs/core/networking-data/data-transfer/data-transferer.md b/docs/core/networking-data/data-transfer/data-transferer.md index 944f5c6..52e49a7 100644 --- a/docs/core/networking-data/data-transfer/data-transferer.md +++ b/docs/core/networking-data/data-transfer/data-transferer.md @@ -6,41 +6,32 @@ sidebar_position: 6 # DataTransferer -`Tsvrc.DataTransfer.DataTransferer` is the end of the chain and the type you actually use. -Its generated shadow is `TsDataTransferer`. Everything it does is inherited from -[DataChunker](./data-chunker) through [DataSenderReceiver](./data-sender-receiver). Read -those pages first if you haven't; this page only covers what `DataTransferer` itself adds. -See [Send a string payload to other players](../../how-to/transferring-data-between-clients) -for a task-oriented walkthrough. +`Tsvrc.DataTransfer.DataTransferer` is the last link of the chain and the type you use. Its +generated shadow is `TsDataTransferer`. Everything it does comes from [DataChunker](./data-chunker) +through [DataSenderReceiver](./data-sender-receiver), and this page covers only what +`DataTransferer` adds. See [Send a string payload to other +players](../../how-to/transferring-data-between-clients) for a task-oriented walkthrough. ## What it adds -Four public event constants that simply forward the reception-focused `DataSenderReceiver` -events under transfer-focused names (`OnTransferStartedEvent`, `OnTransferStoppedEvent`, -`OnTransferCompletedEvent`, `OnTransferChunkEvent`), plus one small piece of defensive -history: `DataTransferer` still overrides `OnOwnerAbandonedProcess`, but the override's body -is now just a call to `base`. The base chain -(`ChunkedTransferSession.OnOwnerAbandonedProcess`, inherited unchanged through `DataSender`, `DataChunkReceiver`, and -`DataSenderReceiver`) already calls `StopReadyCheck()` and fully cleans up transfer state -during a handover. The override exists to carry a comment warning against re-adding an -earlier version's extra `CancelDataTransfer()` call here, which would run against an -already-stopped process and log a spurious warning. +Four public event constants that emit the reception events of `DataSenderReceiver` under transfer +names: `OnTransferStartedEvent`, `OnTransferStoppedEvent`, `OnTransferCompletedEvent` and +`OnTransferChunkEvent`. ## Getting an instance -`DataTransferer` ships as one of [the Core default pool -entries](../../explanations/builtin-registrations) (`TsBuiltinConfig`). +`DataTransferer` is one of the [builtin pool entries](../../explanations/builtin-registrations), so a +`[WirePool]` field of this type resolves with nothing to register, and codegen calls `TsConstruct` +on it: -- **Default:** a `[WirePool]` field of this type just resolves, nothing to register in - Configure first. - ```csharp - [WirePool][SerializeField] private DataTransferer _transferer; - ``` -- **Manual:** skip pooling entirely. Drag the shipped `DataTransferer` prefab into your - scene (or `AddComponent` it) and call `TsConstruct` on it yourself, since nothing does - that for an instance nobody registered anywhere. See - [`Process`](../../core-concepts/process) for why skipping construction breaks ownership - checks on anything this deep in the chain. +```csharp +[WirePool][SerializeField] private DataTransferer _transferer; +``` + +If you place the shipped `DataTransferer` prefab in your scene yourself instead, call +[`TsConstruct`](../../core-concepts/tsvrc-behaviour#construction) on it before using it. Its +`TsStart` stores the ID of the local player, and without it a receiver never finds itself among the +targets of a chunk, so it never acknowledges one and the transfer stalls. ## Usage @@ -57,16 +48,13 @@ public void OnTransferDone() } ``` -`LastPlayerIds` (inherited from `PlayerTracker`, several layers down) is populated on -`OnTransferStarted`/`Stopped`/`Completed`, carried over from the corresponding -`OnTracking*` broadcast underneath. Read it if you need to know who the transfer targeted, -alongside `LastData`, `LastChunkIndex`, and `LastTotalChunks` for the reception-specific -details. +`LastPlayerIds`, inherited from `PlayerTracker`, holds the targets of the transfer when +`OnTransferStarted`, `Stopped` and `Completed` fire, carried over from the `OnTracking*` broadcast +before each one. `LastData`, `LastChunkIndex` and `LastTotalChunks` hold the reception details. -## Where the real behavior lives +## Where the behavior is documented -Every failure mode worth knowing about, what happens when a target player leaves -mid-transfer, what happens across an ownership handover, why chunks are validated the way -they are, why events are deferred by a frame, is documented on the layer that actually -implements it. Start from [the chain overview](../data-transfer-overview) if you're -debugging something that doesn't match this page's description of the surface. +Each failure case is documented on the layer that handles it: a target player leaving during a +transfer, a new owner taking over, how chunks are validated, and why events are deferred by a frame. +Start from [the chain overview](../data-transfer-overview) when a transfer doesn't behave the way +this page describes. diff --git a/docs/core/networking-data/tsvrc-timer.md b/docs/core/networking-data/tsvrc-timer.md index e685be6..0824333 100644 --- a/docs/core/networking-data/tsvrc-timer.md +++ b/docs/core/networking-data/tsvrc-timer.md @@ -6,130 +6,111 @@ sidebar_position: 2 # TsvrcTimer -`Tsvrc.Timing.TsvrcTimer` is a networked, pausable timer built on top of -[Process](../core-concepts/process). Its generated shadow is `TsTimer`. All clients share -[server time](https://udonsharp.docs.vrchat.com/vrchat-api/#methods-4) anchors and compute -elapsed time locally, so a running timer needs no discrete network message per tick, -beyond the periodic `Process` [auto-resync -heartbeat](../core-concepts/process#the-tick-loop-and-auto-resync). See [Run a synced +`Tsvrc.Timing.TsvrcTimer` is a networked, pausable timer built on [Process](../core-concepts/process). +Its generated shadow is `TsTimer`. All clients share [server +time](https://udonsharp.docs.vrchat.com/vrchat-api/#methods-4) anchors and compute elapsed time +locally, so a running timer sends nothing per tick beyond the periodic [resync of +`Process`](../core-concepts/process#the-tick-loop). See [Run a synced countdown](../how-to/running-a-countdown) for a task-oriented walkthrough. ## Getting an instance -`TsvrcTimer` ships as one of [the Core default pool -entries](../explanations/builtin-registrations) (`TsBuiltinConfig`). - -- **Default:** a `[WirePool]` field of this type just resolves, nothing to register in - Configure first, and codegen calls `TsConstruct` for you at wire time: - ```csharp - public class RoundClock : TsBehaviour - { - [WirePool][SerializeField] private TsvrcTimer _timer; - - protected override void TsStart() - { - _timer.TsSubscribe(this, TsvrcTimer.OnTimerCompletedEvent, nameof(_OnRoundEnded)); - _timer.StartTimer(60_000); // 60-second round - } - - public void _OnRoundEnded() => LogInfo("Round timer finished."); - } - ``` -- **Manual:** skip pooling entirely. Drag the shipped `TsTimer` prefab into your scene and - reference it directly, but then call `TsConstruct` on it yourself before calling - anything else on it. Skipping that leaves the [cached `Process` local-player - ID](../core-concepts/process) at its default, which makes `IsProcessOwner()` report - `false` even for the actual owner, so `StartTimer`/`StopTimer`/`PauseTimer` all behave - as if called by a non-owner. - -Anything displaying the countdown reads `_timer.GetRemainingSeconds()` on its own update -loop rather than waiting for `OnTimerUpdatedEvent`: that event exists for reacting to state -changes, not for driving a per-frame UI redraw. +`TsvrcTimer` is one of the [builtin pool entries](../explanations/builtin-registrations), so a +`[WirePool]` field of this type resolves with nothing to register, and codegen calls `TsConstruct` +on it. If you place the shipped `TsTimer` prefab in your scene yourself instead, call +[`TsConstruct`](../core-concepts/tsvrc-behaviour#construction) on it before using it. + +## Usage + +```csharp +public class RoundClock : TsBehaviour +{ + [WirePool][SerializeField] private TsvrcTimer _timer; + + protected override void TsStart() + { + _timer.TsSubscribe(this, TsvrcTimer.OnTimerCompletedEvent, nameof(_OnRoundEnded)); + _timer.StartTimer(60_000); // 60-second round + } + + public void _OnRoundEnded() => LogInfo("Round timer finished."); +} +``` + +To show the countdown, read `_timer.GetRemainingSeconds()` from your own update loop. +`OnTimerUpdatedEvent` is for reacting to state changes, not for redrawing a UI every frame. ## Starting, stopping, pausing -- **`StartTimer()`** — open-ended, counts up indefinitely (`DurationMs` stays `0`). -- **`StartTimer(int durationMilliseconds)`** — auto-completes once elapsed time reaches the - given duration. A negative value is coerced to `0` (open-ended) rather than rejected. - Calling either overload while already running is a silent no-op that leaves the current - run's duration untouched: it does not restart the timer or change its duration mid-run. -- **`StopTimer()`** / inherited `CompleteProcess()` — stop before or at completion. Forwards - to the owner over the network if called by a non-owner, [same as - `Process`](../core-concepts/process#lifecycle). -- **`PauseTimer()`** / **`ResumeTimer()`** — freeze and unfreeze elapsed time. Pausing - extends the effective run time: the auto-complete check in `OnProcessUpdate` uses - `GetElapsedMilliseconds()`, which returns the frozen offset while paused, so a duration is - always fully observed regardless of how long a pause lasts. Both forward to the owner when - called by a non-owner, through their own [`[NetworkCallable]`](https://creators.vrchat.com/worlds/udon/networking/events/#rate-limiting) - targets (`RequestPauseTimer`/`RequestResumeTimer`, rate-limited to 2/second), the same - [dual-authority pattern](../core-concepts/process#ownership-and-authority) `Process` - itself uses for stop/complete. +- **`StartTimer()`** counts up with no end. `DurationMs` stays `0`. +- **`StartTimer(int durationMilliseconds)`** completes once elapsed time reaches the duration. A + negative value counts as `0`, a timer with no end. + +Either overload makes the calling player the owner first, so the timer runs on the player who +starts it. Calling either one while the timer runs does nothing: it doesn't restart the timer or +change its duration. + +- **`StopTimer()`** and the inherited `CompleteProcess()` end the timer early or complete it. Both + [send the call to the owner](../core-concepts/process#lifecycle) when another player makes it. +- **`PauseTimer()`** and **`ResumeTimer()`** freeze and unfreeze elapsed time. A pause extends the + run: the completion check in `OnProcessUpdate` uses `GetElapsedMilliseconds()`, which stays frozen + while paused, so the full duration always runs. Another player calling either one sends it to the + owner through `RequestPauseTimer` or `RequestResumeTimer`, both + [`[NetworkCallable]`](https://creators.vrchat.com/worlds/udon/networking/events/#rate-limiting) + and limited to two calls per second. ## Reading elapsed and remaining time -`GetElapsedMilliseconds()`/`GetElapsedSeconds()` compute from the synced server-time anchor -while running, or return the frozen offset while stopped or paused. `GetRemainingMilliseconds()`/ -`GetRemainingSeconds()` derive from `DurationMs - GetElapsedMilliseconds()`, clamped to `0` -for an open-ended timer, a timer already past its duration, or a completed timer. -`LastElapsedMilliseconds` holds the last value the behaviour itself computed. Read that -instead of calling `GetElapsedMilliseconds()` yourself if you just need "what the timer -last reported": it's refreshed on every start, stop, pause, resume, and deserialization, -and, while running and unpaused, on every tick of a local 0.25s loop, the same one that -drives `OnTimerUpdatedEvent`. +`GetElapsedMilliseconds()` and `GetElapsedSeconds()` compute from the synced server-time anchor +while running, and return the frozen offset while stopped or paused. `GetRemainingMilliseconds()` +and `GetRemainingSeconds()` return `DurationMs - GetElapsedMilliseconds()`, and `0` for a timer +with no end, one past its duration, or a completed one. + +`LastElapsedMilliseconds` holds the last value the timer computed. Read it when you need what the +timer last reported: it updates on every start, stop, pause, resume and deserialization, and every +0.25 seconds while running and not paused, from the same local loop that drives +`OnTimerUpdatedEvent`. ## Events -Two names for essentially the same thing: a `protected virtual` hook to override in a -subclass, and a public string constant to subscribe to via `TsSubscribe` from anywhere -else. Both fire on every client, hook before event, for the matching transition: +Each transition has a `protected virtual` hook to override in a subclass and a string constant to +pass to `TsSubscribe`. Both fire on every client, the hook first: | Hook | Event constant | Fires when | |---|---|---| -| `OnTimerStarted` | `OnTimerStartedEvent` | Timer starts, including a late joiner observing an already-running timer | -| `OnTimerStopped` | `OnTimerStoppedEvent` | Timer stops before completion | -| `OnTimerCompleted` | `OnTimerCompletedEvent` | Timer completes (duration reached, or `CompleteProcess` called) | -| `OnTimerPaused` | `OnTimerPausedEvent` | Timer pauses (including a late joiner discovering the timer is already paused) | -| `OnTimerResumed` | `OnTimerResumedEvent` | Timer resumes | -| `OnTimerUpdated` | `OnTimerUpdatedEvent` | The locally observed elapsed time, running state, or paused state actually changed | -| `OnTimerDeserialization` | `OnTimerDeserializationEvent` | Every `OnDeserialization` call, regardless of whether anything changed | - -## How non-owner clients detect transitions - -The owner gets its lifecycle hooks called directly from -`OnProcessStarted`/`Stopped`/`Completed` in `Process`. A non-owner has no such direct call: it derives -the same events by diffing synced state inside `OnDeserialization`, comparing the newly -received running/paused flags against what it last observed. This diffing approach has two -sharp edges worth knowing if you're relying on exact event sequencing: - -- **A late joiner arriving mid-run** sees running go from "never observed" to `true` in one - packet. That's treated the same as a fresh start: `OnTimerStarted` fires, and if the timer - happens to already be paused, `OnTimerPaused` fires right after it in the same - deserialization call. The joiner doesn't need to separately poll `_isPaused`. -- **A stop-then-restart lands in one packet.** VRChat manual sync doesn't send - immediately on `RequestSerialization`; it [waits until its own rate limit allows, - then sends whatever the synced fields currently - hold](https://creators.vrchat.com/worlds/udon/networking/network-details/#manual-synchronization). - A listener that reacts to `OnTimerStopped`/`OnTimerCompleted` by synchronously starting a - new run (possible because [`SendCustomNetworkEvent(All, ...)` fires inline on the - sender](https://creators.vrchat.com/worlds/udon/networking/events/#event-targeting)) can - therefore produce a single outbound packet where a remote client's view of "is it - running" never actually toggled to `false` in between. A plain running-state diff would - see no transition at all and silently drop the entire stop-then-restart. `TsvrcTimer` - detects this case with an internal run counter (`_runId`, incremented on every - `OnProcessStarted`): if the running flag stayed `true` across a packet but the run ID - changed, it treats it as a fresh start for event purposes. The old run's actual - stopped-vs-completed outcome isn't recoverable at that point, since it was already - overwritten before the packet was even sent. Only the new run's start (and pause state, - if applicable) gets surfaced. - -## Edge cases worth knowing - -- All the numeric read accessors return sane zero defaults on a timer that's never been - started. None of them throw on an unstarted timer. -- Elapsed-time arithmetic is clamped to never go negative, including in the specific case - of `_elapsedOffsetMs + delta` overflowing `int`: it clamps to `0` rather than wrapping - into a negative value. -- `OnProcessCleanup` (inherited from `Process`) preserves the final run's server-time - anchors rather than clearing them, specifically so a non-owner can still display the - correct final elapsed time after the timer stops. Those anchors are only reset in - `OnProcessStarted`, at the beginning of the *next* run, not at cleanup time. +| `OnTimerStarted` | `OnTimerStartedEvent` | The timer starts, including when a late joiner finds it running | +| `OnTimerStopped` | `OnTimerStoppedEvent` | The timer stops before completion | +| `OnTimerCompleted` | `OnTimerCompletedEvent` | The timer completes: duration reached, or `CompleteProcess` called | +| `OnTimerPaused` | `OnTimerPausedEvent` | The timer pauses, including when a late joiner finds it paused | +| `OnTimerResumed` | `OnTimerResumedEvent` | The timer resumes | +| `OnTimerUpdated` | `OnTimerUpdatedEvent` | The elapsed time, running state or paused state seen locally changed | +| `OnTimerDeserialization` | `OnTimerDeserializationEvent` | Every `OnDeserialization` call, whether or not anything changed | + +## How other players detect transitions + +On the owner, the `OnProcessStarted`, `Stopped` and `Completed` hooks of `Process` call the timer +hooks directly. Other players compare the running and paused flags in each packet against what they +saw last, inside `OnDeserialization`. Two cases matter if you rely on the exact order of events: + +- **A late joiner arriving mid-run** sees the timer go from never seen to running in one packet. + That counts as a start: `OnTimerStarted` fires, and `OnTimerPaused` right after it if the timer + is paused. +- **A stop and a restart in one packet.** Manual sync doesn't send on `RequestSerialization` right + away. It [waits for its rate limit and sends whatever the synced fields hold at that + moment](https://creators.vrchat.com/worlds/udon/networking/network-details/#manual-synchronization). + A listener that starts a new run from `OnTimerStopped` or `OnTimerCompleted` (possible because + [`SendCustomNetworkEvent(All, ...)` runs on the sender right + away](https://creators.vrchat.com/worlds/udon/networking/events/#event-targeting)) can produce one + packet in which the timer never stopped. `TsvrcTimer` counts runs in `_runId`, increased in every + `OnProcessStarted`. When the timer stays running across a packet but `_runId` changed, the timer + treats it as a new start. Whether the old run stopped or completed is lost by then, so only the + start of the new run (and its pause, if any) fires. + +## Edge cases + +- The read methods return `0` on a timer that never started, and none of them throw. +- Elapsed time never goes negative. If `_elapsedOffsetMs + delta` overflows `int`, it becomes `0` + instead of wrapping to a negative value. +- `OnProcessCleanup` keeps the server-time anchors of the last run, so other players can still + show the final elapsed time after the timer stops. `OnProcessStarted` resets them at the start of + the next run. diff --git a/docs/core/players-tracking/auto-player-tracker.md b/docs/core/players-tracking/auto-player-tracker.md index 2ce08cc..ecf2408 100644 --- a/docs/core/players-tracking/auto-player-tracker.md +++ b/docs/core/players-tracking/auto-player-tracker.md @@ -6,42 +6,32 @@ sidebar_position: 3 # AutoPlayerTracker -`Tsvrc.Tracking.AutoPlayerTracker` is a [PlayerTracker](./player-tracker) that tracks -every player in the instance automatically: no explicit 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 reach for this over a bare -`PlayerTracker`, or [Gate something on all players being -ready](../how-to/wiring-a-ready-check) for a common pairing with `ReadyCheckProcess`. +`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`. ## Getting an instance -`AutoPlayerTracker` ships as one of [the Core default pool -entries](../explanations/builtin-registrations) (`TsBuiltinConfig`). - -- **Default:** a `[WirePool]` field of this type just resolves, nothing to register in - Configure first, and codegen calls `TsConstruct` for you at wire time. -- **Manual:** skip pooling entirely. Drag the shipped `AutoPlayerTracker` prefab into your - scene and call `TsConstruct` on it yourself before calling anything else on it. Skipping - construction leaves the [cached `Process` local-player ID](../core-concepts/process) at - its default, which makes `IsProcessOwner()` report `false` even for the actual owner. +`AutoPlayerTracker` is one of the [builtin pool entries](../explanations/builtin-registrations), so a +`[WirePool]` field of this type resolves with nothing to register, and codegen calls `TsConstruct` +on it. If you place the shipped `AutoPlayerTracker` prefab in your scene yourself instead, call +[`TsConstruct`](../core-concepts/tsvrc-behaviour#construction) on it before using it. ## Usage -Call `StartAutoTracking()` instead of `StartPlayerTracking`. The base method is -overridden to redirect to the automatic snapshot regardless of what's passed to it, and -both of its parameters (`playerIds`, `useProcessUpdate`) are ignored entirely; -`AutoPlayerTracker` never uses the process-update loop. -`StopAutoTracking()`/`CompleteAutoTracking()` are thin renames of the base -`StopPlayerTracking`/`CompletePlayerTracking`. - -From there, it's genuinely automatic: on the process owner, every `OnPlayerJoined` while -the process is running adds that player. VRChat player references [can be -invalid](https://creators.vrchat.com/worlds/udon/players/#isvalid), and accessing one -that is has been reported to throw and crash the behaviour, so the join is skipped -whenever the reference fails that check. Every departure or suspension is removed -through the same paths `PlayerTracker` already provides. Non-owners don't act on -`OnPlayerJoined` at all: there's no local add to forward, since only the owner's copy of -the tracked set is authoritative. +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. +`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`, +because only the owner changes the tracked set. ```csharp public class InstanceRoster : TsBehaviour @@ -59,36 +49,25 @@ public class InstanceRoster : TsBehaviour } ``` -No add/remove calls of your own: join and leave are handled entirely by the base -`PlayerTracker` machinery once `StartAutoTracking()` has run. - -## The initial-snapshot timing - -The player list `StartAutoTracking` captures is read at the moment `StartAutoTracking` -is called, not inside `OnPlayerJoined`. This matters because of how VRChat handles the -local player's own join: [joining an instance fires `OnPlayerJoined` for every player -already present, including -yourself](https://creators.vrchat.com/worlds/udon/graph/event-nodes/#onplayerjoined). -That entire wave fires before any of your own code has run, so `IsProcessRunning()` is -still `false` for all of it, and every one of those events is dropped. By the time -`StartAutoTracking` actually runs, that wave is long gone; reading the live player list -at that instant is what captures everyone who's genuinely present. A player who is -still mid-join at that exact instant hasn't fired their own `OnPlayerJoined` yet. It -fires afterward, once the process is live, and gets picked up normally through the -ordinary join path. Because UdonSharp runs on the single Unity main thread, no -`OnPlayerJoined` can interleave between reading that snapshot and the process actually -going live, so there's no window where a joining player is missed by both paths. - -The snapshot itself excludes any player already suspended at that instant. A player -who's suspended before tracking even starts has no suspend *transition* left to -produce, and the suspend-handling in `PlayerTracker` only reacts to that transition, so a -pre-suspended player must never enter the tracked set in the first place. +## When the first list is read + +`StartAutoTracking` reads the player list when it's called, not in `OnPlayerJoined`. [Joining an +instance fires `OnPlayerJoined` for every player already +present](https://creators.vrchat.com/worlds/udon/graph/event-nodes/#onplayerjoined), the local +player included, and that wave fires before any of your code runs. The tracker isn't running yet, +so it drops all of those events, and the list read at start is what covers those players. + +A player still joining at that moment fires `OnPlayerJoined` later, once tracking runs, and is added +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. ## Events -Same six lifecycle names as `PlayerTracker`, renamed with an `Auto` prefix -(`OnAutoTrackingStarted`, `...Stopped`, `...Completed`, `...Deserialization`, -`...PlayersAdded`, `...PlayersRemoved`) and their matching `OnAutoTracking*Event` -constants. Each is just the base `OnTracking*` hook overridden to emit the renamed -event. The underlying data (`LastPlayerIds`, `LastAddedPlayerIds`, -`LastRemovedPlayerIds`) is identical to that of `PlayerTracker`. +The same six events as `PlayerTracker`, with an `Auto` prefix: `OnAutoTrackingStarted`, `Stopped`, +`Completed`, `Deserialization`, `PlayersAdded` and `PlayersRemoved`, each with an +`OnAutoTracking*Event` constant. Each one overrides the matching `OnTracking*` hook to emit the +renamed event. Read `LastPlayerIds`, `LastAddedPlayerIds` and `LastRemovedPlayerIds` as on +`PlayerTracker`. diff --git a/docs/core/players-tracking/player-tracker.md b/docs/core/players-tracking/player-tracker.md index c725f94..e2379ba 100644 --- a/docs/core/players-tracking/player-tracker.md +++ b/docs/core/players-tracking/player-tracker.md @@ -6,24 +6,18 @@ sidebar_position: 2 # PlayerTracker -`Tsvrc.Tracking.PlayerTracker` is a [Process](../core-concepts/process) that maintains a -networked, owner-managed set of tracked players, identified by -[`TsPlayer`](./ts-player) ID strings rather than live `VRCPlayerApi` references. -Its generated shadow is `TsPlayerTracker`. It's the base of `AutoPlayerTracker` and -`ReadyCheckProcess`. See [Track a custom group of +`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. ## Getting an instance -`PlayerTracker` ships as one of [the Core default pool -entries](../explanations/builtin-registrations) (`TsBuiltinConfig`). - -- **Default:** a `[WirePool]` field of this type just resolves, nothing to register in - Configure first, and codegen calls `TsConstruct` for you at wire time. -- **Manual:** skip pooling entirely. Drag the shipped `PlayerTracker` prefab into your - scene and call `TsConstruct` on it yourself before calling anything else on it. Skipping - construction leaves the [cached `Process` local-player ID](../core-concepts/process) at - its default, which makes `IsProcessOwner()` report `false` even for the actual owner. +`PlayerTracker` is one of the [builtin pool entries](../explanations/builtin-registrations), so a +`[WirePool]` field of this type resolves with nothing to register, and codegen calls `TsConstruct` +on it. If you place the shipped `PlayerTracker` prefab in your scene yourself instead, call +[`TsConstruct`](../core-concepts/tsvrc-behaviour#construction) on it before using it. ## Usage @@ -45,88 +39,72 @@ public class SpectatorRoster : TsBehaviour } ``` -If every player should be tracked automatically instead of an explicit list you manage, -use [`AutoPlayerTracker`](./auto-player-tracker) instead. Don't reimplement join/leave -handling on top of `PlayerTracker` directly. +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. -The list is sanitized before use: `null` entries are stripped and duplicates are -collapsed, so the "no ID appears twice" invariant holds from the very first broadcast, not -just for later additions. Calling it while already running is a no-op inherited from -`Process.StartProcess`, and deliberately leaves the pending initial-list field untouched in -that case, so a rejected start can never corrupt the *next* legitimate start's initial set. +`StartPlayerTracking(playerIds, useProcessUpdate)` starts the process with an initial set. A player +who isn't the owner takes ownership first, so the tracker runs on the player who starts it. The list +is cleaned before use: `null` entries are removed and duplicates collapsed, so no ID appears twice +from the first broadcast on. Calling it while the tracker runs logs a warning and leaves both the +run and the pending initial list alone, so a rejected start can't change the set of the next one. -`LastPlayerIds` is the tracked set as of the last received broadcast or deserialization. -Read it inside any `OnTracking*` callback for an accurate snapshot. `IsTrackedPlayer` (a -`protected` helper for subclasses) checks membership directly against the synced field -instead, which on a non-owner client can lag slightly behind a `Notify*` callback that just -fired; prefer `LastPlayerIds` inside those callbacks specifically for that reason. +`LastPlayerIds` is the tracked set as of the last received broadcast or deserialization. Read it +inside an `OnTracking*` callback. `IsTrackedPlayer`, a `protected` helper for subclasses, reads the +synced field instead, which on a non-owner can lag behind a `Notify*` callback that just fired. ## Adding and removing players while running -`AddTrackedPlayers`/`RemoveTrackedPlayers` are the public entry points. On the owner, they -apply directly; on a non-owner, they forward to the owner via -`SendCustomNetworkEvent(Owner, ...)`. The actual mutation happens in -`BroadcastAddTrackedPlayers`/`BroadcastRemoveTrackedPlayers`, both `[NetworkCallable]`, both -reachable directly by any player in the instance since VRChat can't restrict network-callable -callers, and both re-validate `IsProcessRunning() && IsProcessOwner()` themselves rather than -trusting that only the intended path reaches them. `BroadcastAddTrackedPlayers` also checks a -`CanAcceptTrackedPlayerAdditions()` hook (`true` by default): override it to reject -additions under some condition. It's the single interception point for *both* the owner's -local fast path and a non-owner's remote call, because a +`AddTrackedPlayers` and `RemoveTrackedPlayers` apply directly on the owner and send the call to the +owner from any other player. The change itself happens in `BroadcastAddTrackedPlayers` and +`BroadcastRemoveTrackedPlayers`. Both are `[NetworkCallable]`, so any player in the instance can +call them directly, and both check `IsProcessRunning() && IsProcessOwner()` themselves. + +`BroadcastAddTrackedPlayers` also asks the `CanAcceptTrackedPlayerAdditions()` hook, `true` by +default. Override it to reject additions. It's the one place that sees both the local call on the +owner and a call from another player, since a [`[NetworkCallable]`](https://creators.vrchat.com/worlds/udon/networking/events/#defining-an-event) -method itself can't be `virtual`. +method can't be `virtual`. -Both broadcast methods silently ignore IDs that don't apply (already tracked, for add; not -currently tracked, for remove) rather than erroring, and both deduplicate their payload -independently of the caller, so passing the same ID twice in one call never produces a -duplicate broadcast or a duplicate tracked entry. +Both broadcast methods skip IDs that don't apply (already tracked for an add, not tracked for a +remove) without an error, and remove duplicates from their own input, so passing the same ID twice +in one call never tracks or broadcasts it twice. ## Automatic removal -Three separate paths remove a player without an explicit `RemoveTrackedPlayers` call, each -covering a different way a tracked player can stop being reachable: - -- **`OnPlayerLeft`** — if the process is running and the departing player was tracked, the - current owner removes them immediately. -- **`OnPlayerSuspendChanged`** — a suspended player can't run Udon code or respond to any - network event, so leaving them tracked would permanently block any subclass logic waiting - on a response from every tracked player. The current owner removes them on the transition - to suspended; waking up needs no action, since the player was already removed on the way - in. -- **`OnOwnerAbandonedProcess`** — covers the case where the *tracker's owner itself* leaves - or suspends. The new owner (promoted by the `Process` ownership recovery) scans the - entire tracked set against the current, non-suspended player list and removes anyone - who's gone or suspended. This is what catches a tracked player who suspended while the - process owner was the one who couldn't act on it (all non-owner clients skip that - specific suspend-removal because they aren't the owner at the time). +The owner removes a tracked player without a `RemoveTrackedPlayers` call in three cases: + +- **`OnPlayerLeft`**: the player left while the tracker runs. +- **`OnPlayerSuspendChanged`**: the player was suspended. A suspended player can't run Udon or + answer network events, so subclass logic that waits for every tracked player would block + forever. Waking up needs nothing, since the player was already removed. +- **`OnBecameProcessOwner`**: the tracker changed owner during a run, for example because the + previous owner left or was suspended. Only the owner removes players, so a tracked player who + left or was suspended while the previous owner couldn't act is still in the set. The new owner + checks the whole set against the players who are present and not suspended, and removes the + rest. ## Events -Six `OnTracking*` hooks (`protected virtual`, all no-ops by default) paired with matching -`OnTracking*Event` string constants for `TsSubscribe`: `Started`, `Stopped`, `Completed`, -`Deserialization`, `PlayersAdded`, `PlayersRemoved`. Each hook's doc comment says which -`Last*Ids` property to read inside it. All the broadcast `Notify*` network-callable methods -that trigger these events are rate-limited at a uniform 100 events/second specifically so a -fast sequence of adds/removes can never overtake or fall behind a start/stop/complete -broadcast on a remote client. VRChat only guarantees [relative ordering between two event -types from the same sender when neither hits its own rate -limit](https://creators.vrchat.com/worlds/udon/networking/events/#rate-limiting), so -keeping all of them at the same limit is what makes that guarantee actually apply here. - -## Edge cases worth knowing - -- A network-callable `Notify*` method re-sanitizes its payload (null-strip, then dedupe) - independently of what the sender already sanitized, and re-validates that the caller is - actually the current owner (bypassed only for the owner's own inline broadcast, tracked - by the internal `_isBroadcasting` flag). Any player can invoke a `[NetworkCallable]` - method directly, not just through the intended call path. -- Serialization packets and discrete network events travel over separate delivery paths - with independent timing: [manual sync only sends once its own rate limit +Six `OnTracking*` hooks, `protected virtual` and empty by default, each paired with an +`OnTracking*Event` constant for `TsSubscribe`: `Started`, `Stopped`, `Completed`, `Deserialization`, +`PlayersAdded` and `PlayersRemoved`. The doc comment of each hook says which `Last*Ids` property to +read inside it. + +The `Notify*` methods behind these events all allow 100 calls per second. VRChat keeps the order of +events from one sender [only while none of them hits its rate +limit](https://creators.vrchat.com/worlds/udon/networking/events/#rate-limiting), so a run of adds +and removes can't overtake a start, stop or complete broadcast on another client. + +## Edge cases + +- Each `Notify*` method cleans its own input again (removes `null` entries, then duplicates) and + checks that the caller is the current owner, since any player can call a `[NetworkCallable]` + method directly. The owner skips that caller check during its own broadcast, tracked by the + internal `_isBroadcasting` flag. +- Synced fields and network events travel separately: [manual sync sends only when its own rate + limit allows](https://creators.vrchat.com/worlds/udon/networking/network-details/#manual-synchronization), - regardless of how often `RequestSerialization` is called, so nothing guarantees one - arrives before or after the other. `NotifyTrackedPlayersAdded` accounts for this by only - adding IDs to `LastPlayerIds` that aren't already present, rather than assuming a clean - delta. + however often `RequestSerialization` is called, so either can arrive first. + `NotifyTrackedPlayersAdded` adds to `LastPlayerIds` only the IDs it doesn't already hold. diff --git a/docs/core/players-tracking/ready-check-process.md b/docs/core/players-tracking/ready-check-process.md index 38df76e..8b4f089 100644 --- a/docs/core/players-tracking/ready-check-process.md +++ b/docs/core/players-tracking/ready-check-process.md @@ -6,38 +6,32 @@ sidebar_position: 4 # ReadyCheckProcess -`Tsvrc.Tracking.ReadyCheckProcess` is a [PlayerTracker](./player-tracker) that runs a ready -check: it tracks a fixed set of players and completes automatically once every one of them -has marked themselves ready. Its generated shadow is `TsReadyCheckProcess`. See [Gate -something on all players being ready](../how-to/wiring-a-ready-check) for a task-oriented -walkthrough. +`Tsvrc.Tracking.ReadyCheckProcess` is a [PlayerTracker](./player-tracker) that runs a ready check: +it tracks a fixed set of players and completes once every one of them has marked themselves ready. +Its generated shadow is `TsReadyCheckProcess`. See [Gate something on all players being +ready](../how-to/wiring-a-ready-check) for a task-oriented walkthrough. ## Getting an instance -`ReadyCheckProcess` ships as one of [the Core default pool -entries](../explanations/builtin-registrations) (`TsBuiltinConfig`). - -- **Default:** a `[WirePool]` field of this type just resolves, nothing to register in - Configure first, and codegen calls `TsConstruct` for you at wire time. -- **Manual:** skip pooling entirely. Drag the shipped `ReadyCheckProcess` prefab into your - scene and call `TsConstruct` on it yourself before calling anything else on it. Skipping - construction leaves the [cached `Process` local-player ID](../core-concepts/process) at - its default, which makes `IsProcessOwner()` report `false` even for the actual owner. +`ReadyCheckProcess` is one of the [builtin pool entries](../explanations/builtin-registrations), so a +`[WirePool]` field of this type resolves with nothing to register, and codegen calls `TsConstruct` +on it. If you place the shipped `ReadyCheckProcess` prefab in your scene yourself instead, call +[`TsConstruct`](../core-concepts/tsvrc-behaviour#construction) on it before using it. Its `TsStart` +stores the ID of the local player, and without it `SetReady` can't mark the local player ready. ## Usage -`StartReadyCheck(playerIds)` starts tracking the given players. Internally, this calls -`StartPlayerTracking(playerIds, useProcessUpdate: true)`: the process-update loop is what -polls readiness. Any player calls `SetReady()` (or `SetReady(false)` to un-ready) to toggle -their own status; the owner polls whether everyone is ready both immediately after each -change and on every 0.5s process tick, and calls `CompleteReadyCheck()` as soon as all -tracked players are ready. `StopReadyCheck()` ends it early without requiring everyone -ready. +`StartReadyCheck(playerIds)` starts tracking the given players through +`StartPlayerTracking(playerIds, useProcessUpdate: true)`, so the calling player becomes the owner +and the process update polls readiness. Each player calls `SetReady()` to mark themselves ready, or +`SetReady(false)` to take it back. The owner checks whether everyone is ready after each change and +on every 0.5-second tick, and calls `CompleteReadyCheck()` as soon as all tracked players are. +`StopReadyCheck()` ends the check early. -Three events, matching the usual `PlayerTracker` pattern: `OnReadyCheckStarted`, -`OnReadyCheckStopped`, `OnReadyCheckCompleted` (hooks) with matching `OnReadyCheck*Event` -constants for `TsSubscribe`. `IsPlayerReady(playerId)` checks the current ready set, -`LastPlayerIds` (inherited) is the tracked set. +There are three events, following the `PlayerTracker` pattern: the `OnReadyCheckStarted`, +`OnReadyCheckStopped` and `OnReadyCheckCompleted` hooks, with matching `OnReadyCheck*Event` +constants for `TsSubscribe`. `IsPlayerReady(playerId)` checks the ready set, and the inherited +`LastPlayerIds` is the tracked set. ```csharp public class LoadingGate : TsBehaviour @@ -58,73 +52,51 @@ public class LoadingGate : TsBehaviour } ``` -## Why SetReady doesn't just check IsProcessRunning - -`SetReady` gates on an internal `_readyCheckActive` flag instead of `IsProcessRunning()`. -The reason is a real ordering gap: VRChat manual sync only serializes and delivers -synced fields once its own rate limit allows, on a timeline independent of discrete -network events, so calling `SetReady` from inside a network event handler can run before -the synced `_isRunning` field has actually arrived, even though the corresponding -"process started" event already has. `_readyCheckActive` is instead driven directly by -the ordered `Notify*` events (`OnTrackingStarted`/`Stopped`/`Completed`) and corrected in -two more places for cases those events can't reach on their own: - -- **`OnTrackingDeserialization`** — a late joiner never receives the original - `NotifyTrackedPlayersProcessStarted` broadcast. [Network events aren't replayed to - players who join after they - fire](https://creators.vrchat.com/worlds/udon/networking/late-joiners/), so this derives - the flag from the now-current synced `_isRunning` instead, on every deserialization. -- **`OnOwnerAbandonedProcess`** — a client that becomes the new owner via takeover, without - having previously received either the start event or a deserialization, would otherwise be - stuck with `_readyCheckActive` still at its default `false` forever (a client never - receives `OnDeserialization` for a packet its own `RequestSerialization` produced). This - applies the same derivation as the deserialization case, specifically so the new owner - isn't permanently unable to call `SetReady` on itself. - -A `SetReady` call that arrives while `_readyCheckActive` is `false` is rejected with a -local-only warning. It never reaches the network, so a stuck flag from a genuinely missed -broadcast fails silently rather than producing any trace elsewhere. - -## Ownership and mutation - -Only the owner's copy of the ready set is authoritative; there's no `Notify*` broadcast for -individual ready/unready changes the way there is for the tracked set, so -`IsPlayerReady` on a non-owner reflects only the last deserialized snapshot; it's never -otherwise kept current. `SetReady` mutates directly when called on the owner and forwards to -the owner via `BroadcastAddReadyPlayer`/`BroadcastRemoveReadyPlayer` (each -`[NetworkCallable]`, rate-limited to 2/second: VRChat [recommends keeping -caller-invocable methods rate as low as the use case -allows](https://creators.vrchat.com/worlds/udon/networking/events/#rate-limiting), and -toggling ready a couple of times per check is all that's ever actually needed) otherwise. -Both broadcast targets check that the caller is only ever marking *themselves* ready or -unready: -[`NetworkCalling.CallingPlayer`](https://creators.vrchat.com/worlds/udon/networking/events/#accessing-the-sender-of-an-event), -which is `null` for a direct local call (so the check is skipped then) but set to the -real calling player for a genuine network call, must match the `playerId` argument or the -call is rejected. - -An internal `RemoveReadyPlayerInternal` bypasses the network-callable path entirely for a -few cases (a player leaving/being removed, or the owner calling `SetReady(false)` on -itself), not for convenience, but because VRChat keeps `CallingPlayer` set to the -*original, outermost* network call's sender for the entire duration of a call chain. -Calling `BroadcastRemoveReadyPlayer` from inside a handler that's itself already running -as part of a different player's network event would carry that other player's identity -as `CallingPlayer`, which is exactly the spoofing case the self-only guard exists to -catch. Removal in that situation mutates `_readyPlayerIds` directly instead, having -already validated the target player through other means. - -## Edge cases worth knowing - -- An untracked player's ready mark is rejected with a warning rather than silently - accepted. `CheckAllPlayersReady` only ever consults the tracked player list, so an - untracked player's ready status would otherwise never actually influence completion, - with no indication why. -- Removing several tracked players at once (`OnTrackingPlayersRemoved`, which can remove - up to 79 players in one call via `OnOwnerAbandonedProcess`, one below [the VRChat 80-player - hard cap per - instance](https://wiki.vrchat.com/wiki/Special:MyLanguage/Instances)) batches the - corresponding ready-list removal into one pass and one array allocation rather than one - `TsArray.Remove` call per player. -- Removing a not-yet-ready player from tracking can itself complete the check: if the - remaining tracked players are all already ready, `OnTrackingPlayersRemoved` re-checks - completion immediately after the removal. +## Why SetReady doesn't check IsProcessRunning + +`SetReady` checks an internal `_readyCheckActive` flag instead of `IsProcessRunning()`. Manual sync +sends synced fields [when its own rate limit +allows](https://creators.vrchat.com/worlds/udon/networking/network-details/#manual-synchronization), +independently of network events, so `SetReady` called from a network event handler can run before +the synced running state arrives, even though the start event already has. `_readyCheckActive` +follows the ordered `Notify*` events (`OnTrackingStarted`, `Stopped` and `Completed`) and is set in +two more places those events don't reach: + +- **`OnTrackingDeserialization`**: [network events aren't replayed to players who join + later](https://creators.vrchat.com/worlds/udon/networking/late-joiners/), so a late joiner never + gets the start event. The flag follows the synced running state on every deserialization instead. +- **`OnBecameProcessOwner`**: a player who becomes the owner before receiving the start event or a + packet would keep the flag `false`, and an owner never deserializes its own packets. The flag + follows the running state here too, so the new owner can still mark itself ready. + +A `SetReady` call while `_readyCheckActive` is `false` logs a warning locally and sends nothing, so +a flag stuck by a missed broadcast leaves no trace on any other client. + +## Ownership and changes to the ready set + +Only the copy of the ready set on the owner counts. Unlike the tracked set, it has no `Notify*` +broadcast, so `IsPlayerReady` on another player reflects only the last packet received. + +`SetReady` changes the set directly on the owner. Any other player sends the change to the owner +through `BroadcastAddReadyPlayer` or `BroadcastRemoveReadyPlayer`. Both are `[NetworkCallable]` and +limited to two calls per second, since VRChat [recommends keeping that limit as low as the use case +allows](https://creators.vrchat.com/worlds/udon/networking/events/#rate-limiting). Both reject a +call that marks anyone other than the caller: +[`NetworkCalling.CallingPlayer`](https://creators.vrchat.com/worlds/udon/networking/events/#accessing-the-sender-of-an-event) +must match the `playerId` argument. It's `null` for a direct local call, which skips the check. + +An internal `RemoveReadyPlayerInternal` changes the set without going through the network for a +player who leaves or is removed, and for the owner calling `SetReady(false)`. VRChat keeps +`CallingPlayer` set to the sender of the outermost network call for the whole call chain, so calling +`BroadcastRemoveReadyPlayer` from the handler of a network event sent by another player would carry +that player as the caller and fail the check above. + +## Edge cases + +- 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 + below the [80-player instance cap](https://wiki.vrchat.com/wiki/Special:MyLanguage/Instances). It + removes them from the ready set in one pass with one array allocation. +- Removing a player who isn't ready can complete the check: if everyone left is ready, + `OnTrackingPlayersRemoved` completes it right after the removal. From 8f574566c41d5940aac0a8028bbbd6161e384166 Mon Sep 17 00:00:00 2001 From: Toni Seas <83423392+ToniSeas@users.noreply.github.com> Date: Sun, 11 Oct 2026 13:08:29 -0600 Subject: [PATCH 3/3] Update the sync, TsPlayer and install pages for 0.1.3 Signed-off-by: Toni Seas <83423392+ToniSeas@users.noreply.github.com> --- docs/core/add-to-your-project.md | 15 ++-- docs/core/explanations/sync-model.md | 107 +++++++++++------------- docs/core/players-tracking/ts-player.md | 40 ++++----- 3 files changed, 71 insertions(+), 91 deletions(-) diff --git a/docs/core/add-to-your-project.md b/docs/core/add-to-your-project.md index a115593..029b76f 100644 --- a/docs/core/add-to-your-project.md +++ b/docs/core/add-to-your-project.md @@ -10,7 +10,7 @@ Install TsVRC Core in a VRChat world project. ## Requirements -- **Unity 2022.3.** The Core package manifest targets this version specifically. +- **Unity 2022.3.** The Core package manifest targets this version. - **The [VRChat Worlds SDK](https://creators.vrchat.com/worlds/) `3.10.x`.** ## Install the package @@ -19,10 +19,11 @@ Install **TsVRC Core** (`com.tsvrc.core`) as described in [Install a TsVRC package](/docs/install-a-package). Its `.unitypackage` is on [the Core GitHub releases](https://github.com/tsvrc/tsvrc-core/releases). -Core depends on [Udon Test Kit](/docs/udon-test-kit/intro) (`com.tsvrc.udon-test-kit`) for its -testing helpers. The Creator Companion installs it along with Core. With the `.unitypackage`, -import the [Udon Test Kit](/docs/udon-test-kit/add-to-your-project) one too, unless your project -already has the kit. +Core depends on [Udon Test Kit](/docs/udon-test-kit/intro) (`com.tsvrc.udon-test-kit`): its +codegen uses the kit to [hold back while tests run](./testing/testing-your-world#codegen-during-a-test-run). +The Creator Companion installs it along with Core. With the `.unitypackage`, import the +[Udon Test Kit](/docs/udon-test-kit/add-to-your-project) one too, unless your project already has +the kit. You'll know it worked when the Unity menu bar gains a **Tsvrc** menu. Everything Core does from here runs from there. @@ -30,8 +31,8 @@ from here runs from there. ## What you just added The Core manifest description sums it up: structured initialization, dependency wiring, -and editor codegen tooling for building VRChat worlds with UdonSharp. Concretely, the -package compiles as one runtime assembly (`Tsvrc.Runtime`), the base classes your world +and editor codegen tooling for building VRChat worlds with UdonSharp. The package compiles as +one runtime assembly (`Tsvrc.Runtime`), the base classes your world scripts extend and call into, and one editor-only assembly (`Tsvrc.Editor`) holding all the codegen and Configure-window tooling. See [How Core fits together](./core-concepts/how-it-fits-together) for how those two halves relate. diff --git a/docs/core/explanations/sync-model.md b/docs/core/explanations/sync-model.md index 834a712..d1a0ba3 100644 --- a/docs/core/explanations/sync-model.md +++ b/docs/core/explanations/sync-model.md @@ -6,70 +6,57 @@ sidebar_position: 3 # Decision note: the shared-memory and sync model -Every networked Core type that needs to replicate state, from -[`TsvrcMemory`](../networking-data/tsvrc-memory) to [`Process`](../core-concepts/process) -and everything built on it, to [`TsvrcTimer`](../networking-data/tsvrc-timer), uses the -same two ingredients: the VRChat manual sync mode (`BehaviourSyncMode.Manual`) plus an -explicit `RequestSerialization()` call whenever something changes, rather than the VRChat -automatic continuous sync. This note ties together why that combination is the framework's -consistent default, synthesizing what each individual type's own reference page already -establishes rather than introducing new claims. +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 +(`BehaviourSyncMode.Manual`) and an explicit `RequestSerialization()` call after each change. This +note explains why. ## Manual sync, sent on demand -[The VRChat documentation of the two sync -modes](https://creators.vrchat.com/worlds/udon/networking/variables/#2-manual-sync) states -that manual sync "requires calling `RequestSerialization()` to send data updates" and is -"best for crucial values that do not change often," while continuous sync "does not require -calling `RequestSerialization()`" and instead "updates automatically" on the VRChat -schedule, better suited to "frequently updated values." Every Core type built on `Process` -picks manual sync deliberately: state changes in these types are discrete events (a process -starts, a timer pauses, a player gets added to a tracked set), not continuously-varying -values like a transform position, so there's a well-defined moment to trigger a sync rather -than a reason to pay for automatic periodic replication. - -## The self-healing resync heartbeat - -`Process` layers a second mechanism on top of that: while a process is running, its owner -re-broadcasts its full synced state on a slower, independent cadence (every 5 seconds), -regardless of whether anything actually changed since the last discrete send. The `Process` -source states the reason directly: the discrete, event-driven `[NetworkCallable]` -broadcasts the Core process types use for lifecycle notifications aren't -delivery-confirmed, so a client that missed one has no other way to catch up. The VRChat -networking docs don't promise every network event arrives; the one case they document -explicitly is a client whose own rate limit is exceeded by a sender running a different, -incompatible world version, where [the server "may silently drop events and not deliver +[VRChat describes the two sync +modes](https://creators.vrchat.com/worlds/udon/networking/variables/#2-manual-sync) this way: manual +sync "requires calling `RequestSerialization()` to send data updates" and suits values that don't +change often, while continuous sync "updates automatically" and suits "frequently updated values." +State in the Core types changes in discrete steps, such as a process starting, a timer pausing or a +player joining a tracked set, not continuously like a transform position. Each change has a clear +moment to send it, so there's no reason to pay for automatic periodic replication. + +## The resync heartbeat + +`Process` adds a second mechanism: while a process runs, its owner sends its full synced state +again every 5 seconds, whether or not anything changed. The lifecycle broadcasts of the Core process +types are discrete `[NetworkCallable]` events with no delivery confirmation, and a client that misses +one has no other way to catch up. The VRChat docs document one case where events are lost: when +players in one instance run different world versions, [the server "may silently drop events and not +deliver them"](https://creators.vrchat.com/worlds/udon/networking/events/#mismatched-world-versions-in-the-same-instance). -The Core heartbeat isn't scoped only to that one documented case: it treats every discrete -broadcast as unconfirmed and resyncs regardless, which self-heals a missed broadcast within -a few seconds without any acknowledgement or retry logic needed anywhere in the framework. -See [`Process`](../core-concepts/process) for the full mechanics, including how ownership -recovery interacts with this heartbeat when the previous owner leaves mid-run. +The heartbeat doesn't depend on knowing which case happened: a client that missed a broadcast gets +the current synced state within a few seconds, with no acknowledgement or retry logic. See +[`Process`](../core-concepts/process#the-tick-loop) for the loop that sends it. ## Working around what can't be synced directly -The synced tier of `TsvrcMemory` exists specifically because `DataDictionary`, the natural -representation for an arbitrary key-value store, isn't itself a syncable type. [The VRChat -list of types synced variables -support](https://udonsharp.docs.vrchat.com/vrchat-api/#synced-variables) covers primitives, -the small Unity value types (`Vector2`/`3`/`4`, `Quaternion`, `Color`), strings, and `VRCUrl`; -`DataDictionary` isn't among them. Serializing it to a plain string via -[`TsJson`](../utilities/ts-json) before every -synced write, and parsing it back on `OnDeserialization`, is the accommodation: everything -about the manual-sync-plus-`RequestSerialization` pattern above still applies to that string -field itself, exactly as it would to any other synced value. See -[`TsvrcMemory`](../networking-data/tsvrc-memory) for what is and isn't guaranteed about ordering and -delivery for the synced tier specifically; this note doesn't repeat those specifics, only -the shared pattern behind them. - -## Ownership follows the same convention throughout - -Every one of these types transfers object ownership to the local player before writing a -value it wants that write to propagate (`TsvrcMemory.Set` on a synced key, -`Process.SetProcessOwner`, and so on). [The VRChat ownership -documentation](https://creators.vrchat.com/worlds/udon/networking/ownership/#how-ownership-works) -states plainly that "the owner of a networked object can modify its synchronized Udon -variables," so only the current owner's `RequestSerialization` calls actually produce an -outbound sync for anyone else. This is the same underlying VRChat ownership model in every -case; Core doesn't introduce a second ownership concept of its own, it just applies -the existing VRChat one consistently everywhere state needs to move between clients. +The synced tier of `TsvrcMemory` exists because `DataDictionary`, the natural shape for a key-value +store, isn't a syncable type. [The types synced variables +support](https://udonsharp.docs.vrchat.com/vrchat-api/#synced-variables) are primitives, the small +Unity value types (`Vector2`, `Vector3`, `Vector4`, `Quaternion`, `Color`), strings and `VRCUrl`. +`TsvrcMemory` serializes the dictionary to a string with [`TsJson`](../utilities/ts-json) before +each synced write and parses it back in `OnDeserialization`, and that string field follows the same +manual sync pattern as any other synced value. See [`TsvrcMemory`](../networking-data/tsvrc-memory) +for what the synced tier guarantees about ordering and delivery. + +## Ownership + +Core uses the [VRChat ownership +model](https://creators.vrchat.com/worlds/udon/networking/ownership/#how-ownership-works) as it is: +only "the owner of a networked object can modify its synchronized Udon variables," so only the +owner sends state to other players. There's no second ownership concept on top of it. + +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. diff --git a/docs/core/players-tracking/ts-player.md b/docs/core/players-tracking/ts-player.md index 3dab0ae..b4ea4d5 100644 --- a/docs/core/players-tracking/ts-player.md +++ b/docs/core/players-tracking/ts-player.md @@ -7,12 +7,11 @@ sidebar_position: 1 # TsPlayer `Tsvrc.Player.TsPlayer` is a static helper class for identifying and looking up players. -It has no dependency on any other Core type, despite being used throughout the -framework: by [`Process`](../core-concepts/process), the tracking chain +It depends on no other Core type. The tracking chain uses it ([`PlayerTracker`](./player-tracker), [`AutoPlayerTracker`](./auto-player-tracker), -[`ReadyCheckProcess`](./ready-check-process)), the overlay UI -([`PlayerPositionOverlay`](../ui-components/overlay/player-position-overlay), -[`PlayerMarkerRenderer`](../ui-components/overlay/player-marker-renderer)), and +[`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). ## Why a custom player ID exists @@ -46,32 +45,25 @@ if (winner != null) - **`GetPlayerID(VRCPlayerApi)`** — builds the `displayName#playerId` string for a player. - **`GetNumericPlayerId(string)`** — extracts the numeric ID from a `GetPlayerID` string, by - scanning backward from the end for digits until a `#` or a non-digit. It's a manual - parse rather than `int.Parse`, matching the framework's own preference for avoiding - standard-library calls that aren't guaranteed to be supported in the more limited Udon - runtime. A malformed string (no `#` at all, a `#` with nothing after it, or a - non-digit character right after the last `#`) returns `0` rather than throwing. A - display name that itself contains `#` characters is handled correctly: parsing works - backward from the end, so it always finds the *last* `#`, which is the one - `GetPlayerID` actually appended. + scanning backward from the end for digits until a `#` or a non-digit. It parses by hand + instead of calling `int.Parse`, since Core avoids standard-library calls that the Udon + runtime isn't guaranteed to support. A malformed string (no `#` at all, a `#` with nothing + after it, or a non-digit character right after the last `#`) returns `0` instead of + throwing. A display name that contains `#` works too: parsing from the end finds the last + `#`, which is the one `GetPlayerID` appended. - **`FindPlayerByID(string)`** — finds the live `VRCPlayerApi` matching a `GetPlayerID` string among players currently in the instance, or `null` if none match (for example, the player already left). - **`GetAllPlayers()`** / **`GetAllPlayerIDs()`** — the current player list, as `VRCPlayerApi[]` or as ID strings. - **`ToPlayerIDs(VRCPlayerApi[])`** / **`ToPlayerApis(string[])`** — convert between the two - representations. `ToPlayerApis` fetches the player list once and reuses it for every - lookup, rather than calling `GetAllPlayers()` per ID, and its result array is trimmed to - only the IDs that actually resolved to a live player: an ID for someone who's left the - instance is silently dropped, not represented as a null entry. + representations. `ToPlayerApis` fetches the player list once for all lookups instead of + calling `GetAllPlayers()` per ID, and its result holds only the IDs that resolved to a live + player: an ID for someone who left the instance is dropped, not returned as a `null` entry. - **`ToArray(string)`** — wraps a single player ID in a one-element array, for APIs that expect an array. -## Edge cases worth knowing +## Edge cases -`GetNumericPlayerId("First#0")` returns `0`, the same value used to signal "no valid -numeric ID found." [`Process.IsProcessOwner()`](../core-concepts/process#ownership-and-authority) -accounts for this ambiguity explicitly by also checking that the ID isn't `0` before -treating it as a real match, since VRChat player IDs start at `1`; code that calls -`GetNumericPlayerId` directly should apply the same caution rather than treating `0` as -an ordinary valid ID. +`GetNumericPlayerId("First#0")` returns `0`, the same value it returns for a string with no numeric +ID, so a caller can't tell the two apart from the result alone.