DataChunkReceiver
Tsvrc.DataTransfer.DataChunkReceiver is where an incoming chunk actually gets validated,
stored, and acknowledged. It overrides DataSender's
OnDataChunkSendRequested to broadcast each chunk via its own [NetworkCallable] method,
BroadcastDataChunkReceived, which every instance player receives and which validates the
chunk before storing it. You never call it directly. See
DataTransferer for the type a consumer actually uses.
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
_transferActiveflag rather thanIsProcessRunning(), because the synced_isRunningfield and network events aren't delivery-ordered relative to each other; a chunk could otherwise arrive and be dropped before_isRunningcatches up._transferActiveis instead set from theOnTransferStartedhook, 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 inOnTransferStartedfromNetworking.LocalPlayer(if the local instance is the process owner) or fromNetworkCalling.CallingPlayer(if not), rejects a chunk from anyone else. The owner's own broadcast bypasses this via the same inline-call flag pattern used elsewhere in the chain (here,_isSendingChunk). playerIdsmust be non-null and include the local player — otherwise the chunk isn't meant for this client and is ignored.dataChunkmust 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.totalChunksis 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.chunkIndexmust fall within1..totalChunks— otherwise it's rejected before it's ever used to index into_receivedChunks, which a malicious out-of-range value would crash on.totalChunksis 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 — VRChat's own 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
OnChunkStoredand send a redundant acknowledgment.
A second reason for the same rate limit
DataSender explains why every broadcast in this family shares
maxEventsPerSecond: 100: event ordering. BroadcastDataChunkReceived has an additional,
independent reason of its own: VRChat splits any event payload over 1 KB into internal 1 KB
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 DataChunker's page is closer to 7,500 bytes
(roughly eight). At the SDK's 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
VRChat's 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()
run, which is just SetReady() under the hood, acknowledging via the same ready-check
mechanism the rest of the chain already uses. OnChunkStored firing before the
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.