Skip to main content

DataChunkReceiver

Tsvrc.DataTransfer.DataChunkReceiver is where an incoming chunk actually gets validated, stored, and acknowledged. It overrides OnDataChunkSendRequested from DataSender to broadcast each chunk via its own [NetworkCallable] method, BroadcastDataChunkReceived, which every instance player receives and which validates the chunk before storing it. It's one layer of the data transfer chain, which names the type to use.

Validation, layer by layer​

BroadcastDataChunkReceived is reachable directly by any player, since VRChat can't restrict callers of a [NetworkCallable] method. So every check inside it is defending against a chunk that didn't come from the legitimate flow, not just handling malformed input:

  • Transfer must actually be active — checked via an internal _transferActive flag rather than IsProcessRunning(), because the synced _isRunning field and network events aren't delivery-ordered relative to each other; a chunk could otherwise arrive and be dropped before _isRunning catches up. _transferActive is instead set from the OnTransferStarted hook, which is guaranteed to have already run by the time a chunk event can arrive, since network events from the same sender are ordered.
  • Sender must match the expected process owner — an internal _expectedSenderPlayerId, captured in OnTransferStarted from Networking.LocalPlayer (if the local instance is the process owner) or from NetworkCalling.CallingPlayer (if not), rejects a chunk from anyone else. The owner skips this check during its own broadcast, tracked by the _isSendingChunk flag.
  • playerIds must be non-null and include the local player — otherwise the chunk isn't meant for this client and is ignored.
  • dataChunk must be non-null — StringBuilder.Append(null) is a silent no-op, so a null chunk stored without this check would leave that slot looking filled while actually corrupting the reassembled message.
  • totalChunks is bounds-checked against a computed maximum (⌈MAX_MESSAGE_SIZE / CHUNK_SIZE⌉) before it's ever used to size an array — an unbounded value from a malicious direct call could otherwise trigger an out-of-memory allocation on every client that receives it.
  • chunkIndex must fall within 1..totalChunks — otherwise it's rejected before it's ever used to index into _receivedChunks, which a malicious out-of-range value would crash on.
  • totalChunks is locked to the first chunk's value for the rest of the transfer; a later chunk claiming a different total is rejected outright, since accepting it would silently reallocate the receive buffer and discard everything already assembled.
  • A chunk index already filled is ignored — the VRChat documentation describes its internal splitting of large events as "almost transparent", which leaves room for a duplicate delivery under some conditions (network anomalies, an ownership-transfer race). Without de-duplication a repeat would re-fire OnChunkStored and send a redundant acknowledgment.

Why the rate limit is 100 per second​

BroadcastDataChunkReceived uses maxEventsPerSecond: 100, the same limit as the broadcasts it must stay in order with. It has a second reason of its own: VRChat splits any event payload over 1 KB into internal 1 KB packets, and each of those counts separately against the rate limit. A 2,500-character ASCII chunk is already close to 2,500 bytes (roughly three internal packets); a CJK chunk near the worst case discussed on the DataChunker page is closer to 7,500 bytes (roughly eight). At the SDK default of 5 events per second, even the ASCII case would take over half a second to clear the rate limiter per chunk, and the CJK case over a second. A 100/second budget clears either in well under a tenth of a second, comfortably inside the VRChat global throughput cap of about 11 KB/s, which ends up being the real limit on how fast a transfer can go.

Storing and acknowledging​

A validated chunk is stored at _receivedChunks[chunkIndex - 1], OnChunkStored fires (subclass hook for updating public properties), and only then does NotifyChunkReceived() acknowledge the chunk by calling the inherited SetReady(). OnChunkStored firing before the acknowledgment matters on the owner specifically: SetReady can synchronously cascade into completing the whole ready check, so emitting the chunk-received signal first guarantees a chunk event is never observed to arrive after the completion event it made possible.

Reassembly, and a player tracked only partway through​

OnTransferCompleted reassembles the message only if every chunk slot was actually filled (HasAllChunks); if any slot is still null, the result is an empty string, the same result an untracked bystander's never-touched array already produces. This is what a player who was tracked for only part of the transfer sees: someone removed mid-transfer, or filtered out during an inter-chunk gap because they left or suspended. Chunks sent after their removal are addressed to a playerIds list that no longer includes them and are silently ignored by the playerIds-membership check above. Reassembly deliberately doesn't attempt a partial result in that case.