Parser performance & correctness improvements#180
Merged
Conversation
Ordered correctness+performance plan for the Source 2 replay parser, derived from a subsystem review against the Clarity (Java) reference parser. Executed one goal per commit on jcoene/goal-perf. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The benchmark streamed from *os.File inside the timed loop, so ~80% of CPU was per-byte read syscalls, masking parser changes. Read the replay into memory once, ResetTimer, and parse via in-memory NewParser in the loop. Add BenchmarkMatch8552595443 (build 6601, more representative of current replays) as the canonical bench and repoint the Makefile profile targets to it. No parser code changed; go test ./... green (identical golden values). Baseline (8552595443, M4 Pro, -benchmem -benchtime=10x -count=10): sec/op 1.523 ± 1% | B/op 791.5Mi | allocs/op 20.75M Alloc profile reconfirms readFieldPaths dominates at 56.6% of allocations. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
onCDemoPacket allocated a *pendingMessage per embedded message (~0.7M allocs/parse) and sorted via sort.Sort's reflection path. Switch pendingMessages to a value slice reusing a parser-level buffer, give priority() a value receiver, and use typed sort.Stable (deterministic file-order within each priority bucket, no reflection allocs). The priority reordering is preserved; onCDemoPacket is non-re-entrant so the reused backing array is safe. go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P0: sec/op 1.523 -> 1.514 -0.62% (p=0.000) B/op 791.5Mi -> 763.8Mi -3.51% allocs/op 20.75M -> 20.06M -3.33% Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readOuterMessage allocated &outerMessage{} per outer message and returned it
as a pointer, forcing a heap escape. Its only caller (Start) consumes the
message immediately and never retains it, so return it by value to keep it on
the stack.
go test ./... green (identical golden values).
benchstat (8552595443, 10x x10) vs P1.1:
allocs/op 20.06M -> 20.03M -0.19% (-30K)
B/op 763.8Mi -> 762.6Mi -0.15%
sec/op 1.514 -> 1.519 +0.32% (within run-to-run noise)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
snappy.Decode(nil, ...) allocated a fresh output buffer for every compressed outer message. Pass a reused parser-level scratch buffer as the destination; snappy reuses it when large enough, amortizing decompression to ~the largest compressed message. Safe because the decoded buffer is consumed within this message's dispatch and never retained across outer messages. go test ./... green, including the full 48-replay golden suite (the gate for this reuse-aliasing change). benchstat (8552595443, 10x x10) vs P1.2: B/op 762.6Mi -> 700.6Mi -8.14% (p=0.000) sec/op 1.519 -> 1.511 -0.49% allocs/op 20.03M -> 20.00M -0.12% Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
emitModifierTableEvents allocated a proto message and unmarshalled every ActiveModifiers entry on every create/update even when no OnModifierTableEntry handler was registered (the common case). Early-return when there are no handlers. Behavior is unchanged for consumers that register one. go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P1.3: allocs/op 20.00M -> 18.74M -6.32% (p=0.000, -1.26M) B/op 700.6Mi -> 647.9Mi -7.51% sec/op 1.511 -> 1.479 -2.15% Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
onCSVCMsg_PacketEntities allocated a new reader and a fresh []tuple (sized to the update count) on every PacketEntities message. Hoist the tuple type to a package-level entityOpTuple, add reader.reset, and reuse parser-level buffers for both. The handler still sees identical entities/ops in identical order (append order preserved), so expectEntityEvents is unchanged. Not re-entrant, so reuse is safe. go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P1.4: B/op 647.9Mi -> 571.0Mi -11.87% (p=0.000) allocs/op 18.74M -> 18.66M -0.40% sec/op 1.479 -> 1.494 +1.03% (run variance) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
name -> field-path resolution depends only on the class serializer, yet each entity allocated its own fpCache/fpNoop maps and recomputed the identical mapping. Move both caches to *class so a name is resolved once per class instead of once per entity, and drop two map allocations per entity creation. Parsing is single-goroutine, so plain maps are safe. go test ./... green (identical golden values; expectPlayer6Name / expectHeroEntityName exercise Get-by-name across many entities). benchstat (8552595443, 10x x10) vs P1.5: allocs/op 18.66M -> 18.63M -0.16% (-30K) B/op 571.0Mi -> 569.5Mi -0.27% sec/op ~ (p=0.190) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readFields called v(6) twice per field path in the hot loop (millions of times per parse). Evaluate it once per readFields call into a local. readFields is invoked fresh per entity update, so a mid-parse debug-level change still takes effect on the next call. go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P1.6: sec/op 1.509 -> 1.476 -2.18% (p=0.002) allocs/op unchanged (CPU-only) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The outer loop reads 3 varints + payload per message, one byte at a time; against a raw *os.File that is one read syscall per byte (~80% of CPU on the streaming path). Wrap the reader in a bufio.Reader when it does not already implement io.ByteReader, so in-memory readers (*bytes.Reader, used by NewParser) are left unwrapped and incur no extra copy. go test ./... green (identical golden values). Canonical in-memory bench flat (bytes.Reader unwrapped). Streaming NewStreamParser(os.File) path, measured separately: sec/op 1.612 -> 1.507 -6.47% (p=0.002) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…alloc slice) readFieldPaths allocated a fresh []*fieldPath per entity update and grew it via append(fp.copy()), which the profile attributed to ~56% of all allocations. Make fieldPath a value type with a fixed [7]int array and decode the op stream into a reused parser-level []fieldPath buffer; readFields consumes it in place. The two-pass order (all ops, then all values) and the field-path values are unchanged, so decoding is identical. The op accumulator is borrowed from the existing sync.Pool so taking its address for the op functions does not force a per-call heap escape (a value-typed local would, regressing B/op). go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P1.8: allocs/op 18.63M -> 10.65M -42.86% (p=0.000, -7.98M) B/op 569.5Mi -> 398.6Mi -30.00% sec/op 1.483 -> 1.198 -19.22% Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readBits refilled the accumulator one byte at a time; it is the hottest function in the parser. Load a full 64-bit little-endian word when >= 8 bytes remain, masking it to the whole bytes of headroom before merging so no stale partial-byte bits are left above bitCount, and fall back to byte-at-a-time only for the buffer tail. Every read is <= 32 bits (now asserted for quantized floats), so one refill always suffices. Because the refill reads bytes ahead into the accumulator, add realign() so the byte-aligned readBytes/readByte fast path rewinds those buffered bytes and stays zero-copy (aliasing the underlying buffer) at any byte boundary, not just when bitCount==0. This makes byte reads zero-copy more often than before. go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P1.9: sec/op 1.198 -> 1.058 -11.64% (p=0.000) B/op flat allocs/op flat Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Routing readByte/readLeUintX through readBits would consume into the word reader's read-ahead accumulator, leaving r.pos ahead of the logical position. The reader contract (r.pos, remBytes) and the reader unit tests rely on r.pos being the logical position. The marginal gain wasn't worth it; P1.10's realign already keeps these byte reads fast and zero-copy. Reverted. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readFieldPaths walked an interface-based huffman tree one bit at a time, with ~2-3 interface-method dispatches plus a pointer chase per bit. Flatten the tree (derived from manta's own huffTree, so codes are identical) into int32 child arrays where a negative entry -(op+1) marks a leaf, and walk those instead. Adds TestHuffmanFlatMatchesTree to lock the flat arrays to the interface tree. go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P1.10: sec/op 1.058 -> 0.960 -9.28% (p=0.000) B/op / allocs/op unchanged Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the per-bit flat-tree walk with a 256-entry lookup that resolves most ops (codes <= 8 bits) in a single index, falling back to the flat-tree walk for longer codes. Add reader.peekBits (zero-pads, never over-reads past the buffer) and skipBits so the lookup can inspect a fixed window even at the final FieldPathEncodeFinish near the end of the stream. The table is built once from manta's immutable huffTree (swapNodes/addNode are never called). TestHuffmanLookupMatchesWalk checks the lookup matches the walk (op + bits consumed) over 5000 random streams. go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P1.12: sec/op 0.960 -> 0.796 -17.10% (p=0.000) B/op / allocs/op unchanged Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every entity creation re-decoded the raw class baseline bitstream from scratch (~4x the cost of a delta update). Decode each class baseline once into a template fieldState and clone it for new entities instead. clone() shares immutable leaf values and deep-copies nested fieldStates, so it is cheaper than re-decoding (no huffman/bit decode, no re-boxing) while remaining independent per entity. Templates are invalidated when the instancebaseline string table updates, so a changed baseline is re-decoded. go test ./... green (identical golden values; the cloned+overlaid state is value-identical to a fresh decode). benchstat (8552595443, 10x x10) vs P1.13: sec/op 0.796 -> 0.766 -3.71% (p=0.000) B/op 398.6Mi -> 378.1Mi -5.14% allocs/op 10.65M -> 10.26M -3.61% Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add TestReaderReadBytesZeroCopy asserting a byte-aligned readBytes returns a slice aliasing the underlying buffer (not a copy), both when bitCount==0 and through the word reader's realign path. CDemoPacket pendingMessage parsing and sendtables rely on this aliasing; the test prevents a future reader change from silently turning it into a copy. go test ./... green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The non-increment branch computed an absolute index (= varuint+1) where the Source 2 format (and clarity's S2StringTableEmitter, and manta's own entity decoder) use an additive delta (+= varuint+2). The absolute form produced wrong, non-monotonic indices for delta-updated tables such as ActiveModifiers (thousands of mis-indexed entries on a typical replay). This was invisible to the existing tests because none resolve a delta-updated table by index. go test ./... green (identical golden values; init stays -1 so the increment path is unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parseStringTable deferred a recover() that logged and returned a partially populated table, so callers saw success and applied incomplete state. Return the error and propagate it from onCSVCMsg_Create/UpdateStringTable, matching clarity's fail-loud behaviour. recover never fires on healthy replays. Adds TestParseStringTableTruncated. go test ./... green (identical golden values). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getEventKey guarded with `f.i > len(keys)`, allowing f.i == len(keys) to fall through to GetKeys()[f.i] and panic. Use >= so a version-skewed event that advertises more fields than it carries returns the intended error instead. Well-formed events never hit this. go test ./... green (identical golden values). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
emitModifierTableEvents unmarshalled every entry including empty (deleted) ones, raising an all-zero CDOTAModifierBuffTableEntry to handlers. Skip entries with no value, matching clarity. A real modifier is never zero-length on the wire. go test ./... green (identical golden values). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Type(), TypeName() and String() hardcoded keys[0].GetValByte() for the combat
log type, assuming it is always the first key and always byte-encoded. Resolve
it via the descriptor (GetInt32("type")) instead, which tolerates a different
key order or int encoding across game versions. On current descriptors 'type'
is index 0, so output is unchanged; the combat-log test already asserts
GetInt32("type") succeeds on every event.
go test ./... green (identical golden values).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add decoders for base types clarity handles that manta lacked and that would otherwise fall through to the wrong default decoder (and desync) if a future build networked them: CUtlBinaryBlock (length-prefixed bytes), Quaternion (4-component vector), ResourceId_t (unsigned 64), CGlobalSymbol (string). None occur in the test corpus, so behavior is unchanged today. Also decode int64 with a full 64-bit varint (was 32-bit, which truncates large values). Only m_nTotalDamageTaken is int64 today and is <=1 byte, so its value is unchanged; note Get on an int64 field now returns int64 rather than int32. go test ./... green (identical golden values). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
HSequence had no decoder and fell through to an unsigned varint; clarity decodes it as varint-1 (a sequence handle, -1 for none). HeroID_t likewise fell through to unsigned; clarity uses a signed varint. Both consume the same bits as before (no desync) but produce the clarity-correct values. Neither field is asserted by the golden tests, which stay green (incl. builds 6600/6601 where these fields appear). HSequence is decoded as a signed int32 so wire value 0 becomes -1 rather than wrapping. go test ./... green (identical golden assertions). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
m_nBloodType was decoded as an unsigned varint; clarity reads a fixed 8-bit value. These agree bit-for-bit only while the value is < 128. The full golden suite (including builds 6600/6601 where m_nBloodType is decoded ~109k times) stays green, confirming every value in the corpus is < 128, so this is a safe forward-compat alignment that also reads correctly for future values >= 128. go test ./... green (identical golden values). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Rewrite qangleFactory to match clarity's QAngle decoders: qangle_pitch_yaw with bit count 0 or 32 carries raw 32-bit floats (not bit angles); qangle_precise reads three flagged 20-bit angles; and a general bit count of 32 reads three raw floats. Fields present in the corpus (bit counts 8/13, and the no-bit-count coord form) keep identical behavior, so these are dormant forward-compat paths for newer engine variants. go test ./... green (identical golden values). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The mana and runetime field patches applied unconditionally. Guard them on the synthetic full-float-range bounds (+/-MaxFloat32) the engine emits when no real range is set, matching clarity. The affected fields always carry that sentinel today, so behavior is unchanged; the guards just stop the patches from clobbering a field that already has real bounds. Manta keeps its bespoke 4-bit runetime decoder and /30 simtime handling. go test ./... green (identical golden values). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
readOuterMessage passed the size varint straight to stream.readBytes, which does make([]byte, n) with no upper bound, so a corrupt or truncated stream with a huge varint could attempt a multi-gigabyte allocation. Reject sizes above a generous 256 MiB cap (far above any legitimate message) with an error instead. go test ./... green (identical golden values). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce maxFieldPathDepth (= 7) with a comment citing clarity's S2LongFieldPathFormat, and use it for the fixed path array. The fixed-size array already enforces the bound via a recovered bounds-check panic; this just documents the invariant. No behavior change. go test ./... green (identical golden values). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Entity state stored every decoded value boxed in a []interface{}, so each
float/int/handle heap-allocated on the write path — per field, per entity, per
tick. Replace it with a 24-byte tagged-union cell {ref interface{}; num uint32;
kind}: scalars (float32, int32, uint32, bool, and <=32-bit uint64 handles) live
inline in num with zero write-path allocation; reference values (string,
[]float32, []byte) and the rare genuinely-64-bit ints (CStrongHandle, fixed64,
int64, steamids) go in ref; nested tables are a *fieldState in ref. Decoders now
return a cell; values are boxed into interface{} lazily in cell.iface(), only on
read (Entity.Get), which is far rarer than the write path.
Public API is unchanged: Entity.Get still returns interface{} with the exact
same dynamic type and value as before (a pure storage refactor). go test ./...
passes with identical golden assertions, including entity_test's exact-type
checks (int32/uint64/bool/string, plus a >2^32 steamid proving 64-bit values are
not truncated) and expectHeroEntityMana's float32 round-trip.
benchstat (8552595443, 10x x10) vs end of Phase 2:
allocs/op 10.264M -> 4.410M -57.03% (p=0.000)
sec/op 0.766 -> 0.734 -4.23%
B/op 378.1Mi -> 388.2Mi +2.65% (qangle/Vector []float32 left boxed to
preserve the []float32 Get contract)
Cumulative vs P0 baseline: allocs -78.8%, sec -51.8%, B/op -51.0%.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
clone() shallow-copied cells and only deep-copied nested fieldStates, so cells whose ref holds a []float32 vector or []byte blob were shared across every entity cloned from the cached class baseline (introduced when baselines became decode-once + cloned). A caller mutating a slice returned from Entity.Get/Map could corrupt the baseline template and sibling entities; before the branch, baselines were re-decoded per entity so this aliasing did not exist. Deep-copy []float32/[]byte leaves in clone(); strings and boxed integers are immutable and stay shared. Only fires for baseline vector/blob leaves. go test ./... green (identical golden values; deep-copy yields equal slices). benchstat (8552595443, 10x x10) vs P4 baseline: allocs/op 4.410M -> 4.478M +1.54% (+68K, the per-entity leaf copies) B/op 388.2Mi -> 389.4Mi +0.33% sec/op ~ (p=0.105) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
p.entityTuples and p.pendingMsgBuf were stored back with their full length and stale [len:cap] entries, so *Entity pointers (and their de-boxed state) and inner-packet buffers were retained an extra packet — stale slots from a larger prior packet could pin deleted entities. After dispatch, clear() the used entries and keep the slice at length zero; clearing the full written length each packet keeps [len:cap] zero across packets, so no stale references accumulate. go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P4.1: allocs/op flat, B/op flat (clear is alloc-free) sec/op +1.4% nominal, consistent with thermal drift across the run Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
peekBits zero-pads at end of buffer and skipBits blindly subtracted, so on a truncated or corrupt field-path op stream a lookup entry could consume more bits than were buffered, underflowing bitCount (uint32 wraps huge) and looping forever appending field paths. Make skipBits panic when n > bitCount; the parser's top-level recover turns it into an error, restoring the fail-fast behaviour the per-bit walk had. Cannot affect well-formed replays (the fast path never under-runs on valid streams). Adds TestReadFieldPathsTruncated (an all-zero buffer is an unbounded PlusOne run) asserting a clean panic instead of a hang. go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P4.2: all metrics flat (guard is free; sec p=0.631, B/op p=0.971, allocs p=0.057). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
position() assumed bitCount <= 8 (one byte buffered), but the word reader can leave 56/48/... bits buffered, making the byte/bit split wrong. Derive the logical bit position from pos*8 - bitCount. Debug-only (called under the v(6) guard); no parsing impact. go test ./... green; benchstat flat vs P4.3. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The branch deliberately changes what Entity.Get returns for a few fields, to match clarity's correct representation, and we are owning these as intentional API changes (Decision A, documented in IMPROVEMENTS.md): - int64 fields: int32 (truncated) -> int64 - HeroID_t: uint32 -> int32 (signed) - HSequence: uint32 -> int32, value - 1 (-1 = none) - BloodType: stayed uint64; only the fixed-8 encoding changed (value identical) Add decoder-level tests asserting the exact dynamic type AND value each produces (deterministic, not replay-dependent), plus a wiring test locking the fieldTypeDecoders map entries, so these intended downstream-visible values can't silently regress. Covers the inline-vs-boxed uint64 split including a >2^32 value (no truncation). go test ./... green; benchstat flat vs P4.4 (test-only). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P4.2 cleared p.pendingMsgBuf / p.entityTuples only after successful dispatch; on a handler/callByPacketType error the parser field still pointed at the just-refilled backing array, so packet buffers / entity pointers stayed live (bounded to one packet, freed at parser GC since the parse aborts, but a real inconsistency). Dispatch into a result variable and break on error, then run a single clear() + [:0] cleanup before a single return, so cleanup runs on success and error alike. Uses the result-variable pattern rather than a deferred closure on purpose: a defer closure captures the slice header (also stored on the Parser), forcing it to escape to the heap — a per-packet allocation in a path that runs thousands of times. The result-variable form is allocation-free. go test ./... green (identical golden values). benchstat (8552595443, 10x x10) vs P4.5: alloc- and B/op-neutral (4,478,290 allocs / 408.36M B identical), sec flat. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The working plan/benchmark log lived in the branch during development; the per-commit benchstat tables and the PR summary capture the durable record, so drop the scratch doc before merge. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Overhauls the Source 2 entity/replay decode hot path for performance and closes
several correctness gaps. On a representative recent replay it is ~2× faster,
uses ~half the memory, and makes ~78% fewer allocations — with the full golden
test suite green at every commit and the public API unchanged except for a few
deliberate, documented value-representation fixes (see Behavior changes below).
Developed as one focused change per commit, each with its own
benchstattablein the message, so it can be reviewed incrementally.
Benchmarks
BenchmarkMatch8552595443(build 6601, ~70 MB, parsed in-memory; M4 Pro,-benchmem -benchtime=10x -count=10):(Streaming callers —
NewStreamParseron an*os.File— get an additional~6% from buffered reads; the table above is the in-memory parse path.)
Performance changes
source (a freshly grown
[]*fieldPathper entity per tick, ~56–60% of allallocations); field paths are now value types in a reused buffer.
time instead of byte-by-byte, with a
realign()so byte reads stay zero-copy.interface-dispatch walk; most field-path ops resolve in a single table index.
baseline bitstream on every entity creation.
(scalars inline, no per-field
interface{}boxing on the write path); valuesbox lazily on
Get.tuples, and snappy scratch;
outerMessageby value; modifier/debug fast-outs;bufiofor non-byte-reader streams.Correctness fixes
absolute index, mis-indexing delta-updated tables (e.g.
ActiveModifiers);now additive, matching manta's own entity decoder.
now errors cleanly instead of looping.
CUtlBinaryBlock,Quaternion,QAngleprecise/noscale/pitch-yaw raw-float cases, full 64-bit
int64.getEventKeyoff-by-one; skip empty/deleted modifier entries; mana/runetimefield-patch sentinel guards.
A few fields now decode to a more correct representation. These change the
dynamic type/value returned by
Entity.Getfor those specific fields, so acaller doing a concrete type assertion on them may need an update. They're locked
by tests (
TestDecoderRepresentations/TestValueChangingDecoderWiring):int64int32(truncated >32 bits)int64(full width)HeroID_tuint32int32(signed)HSequenceuint32int32, value − 1 (−1 = none)BloodTypeuint64uint64(encoding fixed-8; value unchanged)These ship in v1.5.0; pin to an earlier release if you depend on the previous
representation of these fields.
Robustness fixes
clone()deep-copies mutable leaf slices ([]float32/[]byte) so acaller mutating a
Get/Mapresult can't corrupt the baseline template orsibling entities.
so they don't retain deleted entities or packet data.
Testing
combat-log counts, hero mana floats, and specific entity field values) stays
green with identical assertions across the whole branch.
lookup≡walk equivalence, truncated field-path stream handling, and a
zero-copy
readBytesinvariant.Thanks to clarity, which served as a
reference for some of these improvements.
🤖 Generated with Claude Code