DecodeControllerActorFields cannot determine the parameter width of a field index it does not explicitly know, so on the first unknown field it stops parsing the bunch, keeps the fields it already decoded, and returns true — which makes the caller treat the truncation as a success and throw away the explanatory error string.
Net effect: every RPC batched behind an unimplemented one in the same controller bunch is discarded, and nothing is logged. This is the mechanism that turns "RPC X is unimplemented" into "unrelated feature Y is intermittent."
Verified on main (84cbb97).
The fallthrough
DistrictServer/ApbUdp.cpp:3478-3494, the last branch of the per-field loop:
if (fieldIndex !=
serverUpdateLevelVisibilityField)
{
field.EndBit = reader.Tell();
fields.push_back(field);
std::ostringstream stream;
stream
<< "unknown controller field "
<< fieldIndex
<< " at bit "
<< field.BeginBit
<< "; parameter size is unknown";
error = stream.str();
return true;
}
The diagnosis is exactly right and the message is well written. The problem is return true — and the caller.
The caller only logs when the decode returned false and produced nothing
DistrictServer/DistrictServer.cpp:12196-12224:
const bool decoded =
ApbUdp::DecodeControllerActorFields(
bunch, kPlayerControllerFieldMax, ...,
fields, decodeError);
if (!decoded && fields.empty())
{
Logger(lWARN, "District Controller RX",
"account=%u packetId=%u seq=%u bits=%u decode failed: %s",
..., decodeError.c_str());
continue;
}
On the unknown-field path decoded is true and fields is non-empty (a bare field was just pushed), so the guard cannot fire. decodeError — the one string that names the unknown field index and its bit offset — is silently dropped on the floor.
The subsequent loop (:12226) dispatches on field.IsServerNotifyClientLoaded, field.IsServerSelectSpawnZone, and friends. The bare unknown field matches none of them, so it falls out of that loop too. The bunch's remaining bits are never revisited.
Why widths are needed at all
The decoder carries a hardcoded per-field bit-width table because this build's parameter sizes were established by observation, e.g. :3150-3193:
// Field 78 is emitted continuously by this client build.
// Every standalone occurrence is exactly 66 bits total:
//
// 10 bits bounded field index
// 56 bits parameters
That is the correct approach given no reflection metadata on the wire, and the comments documenting how each width was derived are genuinely useful. The gap is only in the failure mode: an unmeasurable field should be loud, and should not take its neighbours with it.
Consequences
- Silent data loss. Anything after the unknown field in the bunch is dropped. Whether a given RPC survives depends on the client's batching order that frame — i.e. it looks intermittent.
- Invisible. There is no other unknown-field logging path in the tree (a repo-wide search for
unhandled / unrecognized / unknown field finds one unrelated comment at DistrictServer.cpp:307). New client RPCs therefore arrive, break parsing, and leave no trace.
- Blocks incremental RPC work. Every future feature (interactions, chat, vehicles, weapons) starts with "which field index does the client send?" — the information needed to answer that is being computed and then discarded.
Suggested direction
Small and self-contained:
- Log the unknown-field case at
lWARN with the field index, bit offset, channel, sequence and remaining bit count. Field index + bit offset is exactly what is needed to identify a new RPC and measure its width from a capture.
- Distinguish "unknown field, parse aborted" from "decode failed" at the call site so the error string is not conditioned on
fields.empty(). Either return false on this path, or add an explicit bool truncatedAtUnknownField / std::uint32_t unknownFieldIndex to the result.
- Optionally rate-limit per field index so a high-frequency unknown RPC cannot flood the log — the same throttling pattern already used elsewhere in the district.
Step 1 alone converts a class of invisible failures into actionable data, and needs no protocol knowledge.
Line numbers are from 84cbb97. This was found by reading the decoder, not by observing a live truncation; no client session was run for this report. The control flow is unambiguous in the source, but which RPCs actually get eaten in practice depends on client batching and has not been measured.
DecodeControllerActorFieldscannot determine the parameter width of a field index it does not explicitly know, so on the first unknown field it stops parsing the bunch, keeps the fields it already decoded, and returnstrue— which makes the caller treat the truncation as a success and throw away the explanatory error string.Net effect: every RPC batched behind an unimplemented one in the same controller bunch is discarded, and nothing is logged. This is the mechanism that turns "RPC X is unimplemented" into "unrelated feature Y is intermittent."
Verified on
main(84cbb97).The fallthrough
DistrictServer/ApbUdp.cpp:3478-3494, the last branch of the per-field loop:The diagnosis is exactly right and the message is well written. The problem is
return true— and the caller.The caller only logs when the decode returned false and produced nothing
DistrictServer/DistrictServer.cpp:12196-12224:On the unknown-field path
decodedistrueandfieldsis non-empty (a bare field was just pushed), so the guard cannot fire.decodeError— the one string that names the unknown field index and its bit offset — is silently dropped on the floor.The subsequent loop (
:12226) dispatches onfield.IsServerNotifyClientLoaded,field.IsServerSelectSpawnZone, and friends. The bare unknown field matches none of them, so it falls out of that loop too. The bunch's remaining bits are never revisited.Why widths are needed at all
The decoder carries a hardcoded per-field bit-width table because this build's parameter sizes were established by observation, e.g.
:3150-3193:That is the correct approach given no reflection metadata on the wire, and the comments documenting how each width was derived are genuinely useful. The gap is only in the failure mode: an unmeasurable field should be loud, and should not take its neighbours with it.
Consequences
unhandled/unrecognized/unknown fieldfinds one unrelated comment atDistrictServer.cpp:307). New client RPCs therefore arrive, break parsing, and leave no trace.Suggested direction
Small and self-contained:
lWARNwith the field index, bit offset, channel, sequence and remaining bit count. Field index + bit offset is exactly what is needed to identify a new RPC and measure its width from a capture.fields.empty(). Either returnfalseon this path, or add an explicitbool truncatedAtUnknownField/std::uint32_t unknownFieldIndexto the result.Step 1 alone converts a class of invisible failures into actionable data, and needs no protocol knowledge.
Line numbers are from
84cbb97. This was found by reading the decoder, not by observing a live truncation; no client session was run for this report. The control flow is unambiguous in the source, but which RPCs actually get eaten in practice depends on client batching and has not been measured.