diff --git a/Makefile b/Makefile index 8a57e7d..1acb0c6 100644 --- a/Makefile +++ b/Makefile @@ -7,19 +7,19 @@ testnew: go test -cover -run=TestMatchNew -v bench: - go test -run=XXX -bench=BenchmarkMatch -benchtime=1m -v + go test -run=XXX -bench=BenchmarkMatch8552595443 -benchmem -benchtime=10x -count=10 -v cover: go test -cover -coverpkg github.com/dotabuff/manta,github.com/dotabuff/manta/vbkv -coverprofile /tmp/manta.cov -v go tool cover -html=/tmp/manta.cov cpuprofile: - go test -v -run=TestMatch2159568145 -test.cpuprofile=/tmp/manta.cpuprof + go test -v -run=TestMatchNew8552595443 -test.cpuprofile=/tmp/manta.cpuprof go tool pprof -svg -output=/tmp/manta.cpuprof.svg manta.test /tmp/manta.cpuprof open /tmp/manta.cpuprof.svg memprofile: - go test -v -run=TestMatch2159568145 -test.memprofile=/tmp/manta.memprof -test.memprofilerate=1 + go test -v -run=TestMatchNew8552595443 -test.memprofile=/tmp/manta.memprof -test.memprofilerate=1 go tool pprof --alloc_space manta.test /tmp/manta.memprof update: update-protobufs generate diff --git a/class.go b/class.go index d10ed2b..67d586a 100644 --- a/class.go +++ b/class.go @@ -16,6 +16,12 @@ type class struct { classId int32 name string serializer *serializer + + // fpCache and fpNoop memoize name -> field-path resolution, which is + // class-invariant. They are shared by every entity of the class so a name + // is resolved once per class instead of once per entity. + fpCache map[string]*fieldPath + fpNoop map[string]bool } func (c *class) getNameForFieldPath(fp *fieldPath) string { @@ -67,6 +73,8 @@ func (p *Parser) onCDemoClassInfo(m *dota.CDemoClassInfo) error { classId: classId, name: networkName, serializer: p.serializers[networkName], + fpCache: make(map[string]*fieldPath), + fpNoop: make(map[string]bool), } p.classesById[class.classId] = class p.classesByName[class.name] = class @@ -101,4 +109,8 @@ func (p *Parser) updateInstanceBaseline() { } p.classBaselines[classId] = item.Value } + + // Decoded baseline templates may now be stale; drop them so they are + // re-decoded lazily from the updated bytes on the next entity creation. + clear(p.classBaselineStates) } diff --git a/demo_packet.go b/demo_packet.go index a53dc79..0d809a3 100644 --- a/demo_packet.go +++ b/demo_packet.go @@ -14,7 +14,7 @@ type pendingMessage struct { } // Calculates the priority of the message. Lower is more important. -func (m *pendingMessage) priority() int { +func (m pendingMessage) priority() int { switch m.t { case // These messages provide context needed for the rest of the tick @@ -42,7 +42,7 @@ func (m *pendingMessage) priority() int { } // Provides a sortable structure for storing messages in the same packet. -type pendingMessages []*pendingMessage +type pendingMessages []pendingMessage func (ms pendingMessages) Len() int { return len(ms) } func (ms pendingMessages) Swap(i, j int) { ms[i], ms[j] = ms[j], ms[i] } @@ -60,9 +60,12 @@ func (ms pendingMessages) Less(i, j int) bool { // multiple inner packets from a single CDemoPacket. This is the main structure // that contains all other data types in the demo file. func (p *Parser) onCDemoPacket(m *dota.CDemoPacket) error { - // Create a slice to store pending mesages. Messages are read first as - // pending messages then sorted before dispatch. - ms := make(pendingMessages, 0, 2) + // Reuse a parser-level buffer to store pending messages. Messages are read + // first as pending messages then sorted before dispatch. onCDemoPacket is + // never re-entrant (it is dispatched only via callByDemoType, never nested + // within a callByPacketType call), so a single reused backing array is safe + // and avoids a heap allocation per embedded message. + ms := p.pendingMsgBuf[:0] // Read all messages from the buffer. Messages are packed serially as // {type, size, data}. We keep reading until until less than a byte remains. @@ -71,21 +74,29 @@ func (p *Parser) onCDemoPacket(m *dota.CDemoPacket) error { t := int32(r.readUBitVar()) size := r.readVarUint32() buf := r.readBytes(size) - ms = append(ms, &pendingMessage{p.Tick, t, buf}) + ms = append(ms, pendingMessage{p.Tick, t, buf}) } // Sort messages to ensure dependencies are met. For example, we need to - // process string tables before game events that may reference them. - sort.Sort(ms) - - // Dispatch messages in order, returning on handler error. - for _, m := range ms { - if err := p.Callbacks.callByPacketType(m.t, m.buf); err != nil { - return err + // process string tables before game events that may reference them. A + // stable sort keeps equal-priority messages in their original file order + // and avoids the reflection allocations of sort.Sort's interface path. + sort.Stable(ms) + + // Dispatch messages in order, stopping on handler error. + var err error + for i := range ms { + if err = p.Callbacks.callByPacketType(ms[i].t, ms[i].buf); err != nil { + break } } - return nil + // Release the inner-packet buffer references and keep the slice at length + // zero so the reused backing array does not retain packet data. This runs on + // every path (success or error), before the single return. + clear(ms) + p.pendingMsgBuf = ms[:0] + return err } // Internal parser for callback OnCDemoFullPacket. diff --git a/entity.go b/entity.go index 5fa7bbe..f8fdb30 100644 --- a/entity.go +++ b/entity.go @@ -49,25 +49,21 @@ type EntityHandler func(*Entity, EntityOp) error // Entity represents a single game entity in the replay type Entity struct { - index int32 - serial int32 - class *class - active bool - state *fieldState - fpCache map[string]*fieldPath - fpNoop map[string]bool + index int32 + serial int32 + class *class + active bool + state *fieldState } // newEntity returns a new entity for the given index, serial and class func newEntity(index, serial int32, class *class) *Entity { return &Entity{ - index: index, - serial: serial, - class: class, - active: true, - state: newFieldState(), - fpCache: make(map[string]*fieldPath), - fpNoop: make(map[string]bool), + index: index, + serial: serial, + class: class, + active: true, + state: newFieldState(), } } @@ -92,20 +88,20 @@ func (e *Entity) Dump() { // Get returns the current value of the Entity state for the given key func (e *Entity) Get(name string) interface{} { - if fp, ok := e.fpCache[name]; ok { + if fp, ok := e.class.fpCache[name]; ok { return e.state.get(fp) } - if e.fpNoop[name] { + if e.class.fpNoop[name] { return nil } fp := newFieldPath() if !e.class.getFieldPathForName(fp, name) { - e.fpNoop[name] = true + e.class.fpNoop[name] = true fp.release() return nil } - e.fpCache[name] = fp + e.class.fpCache[name] = fp return e.state.get(fp) } @@ -218,9 +214,17 @@ func (p *Parser) FilterEntity(fb func(*Entity) bool) []*Entity { return entities } +// entityOpTuple pairs an entity with the operation performed on it. Updates are +// buffered during a PacketEntities message, then dispatched to handlers. +type entityOpTuple struct { + e *Entity + op EntityOp +} + // Internal Callback for OnCSVCMsg_PacketEntities. func (p *Parser) onCSVCMsg_PacketEntities(m *dota.CSVCMsg_PacketEntities) error { - r := newReader(m.GetEntityData()) + r := &p.entityReader + r.reset(m.GetEntityData()) var index = int32(-1) var updates = int(m.GetUpdatedEntries()) @@ -237,11 +241,7 @@ func (p *Parser) onCSVCMsg_PacketEntities(m *dota.CSVCMsg_PacketEntities) error p.entityFullPackets++ } - type tuple struct { - e *Entity - op EntityOp - } - tuples := make([]tuple, 0, updates) + tuples := p.entityTuples[:0] for ; updates > 0; updates-- { index += int32(r.readUBitVar()) + 1 @@ -259,15 +259,24 @@ func (p *Parser) onCSVCMsg_PacketEntities(m *dota.CSVCMsg_PacketEntities) error _panicf("unable to find new class %d", classId) } - baseline := p.classBaselines[classId] + // Decode the class baseline once into a reusable template and + // clone it for each new entity, instead of re-decoding the raw + // baseline bytes on every creation. + baseline := p.classBaselineStates[classId] if baseline == nil { - _panicf("unable to find new baseline %d", classId) + raw := p.classBaselines[classId] + if raw == nil { + _panicf("unable to find new baseline %d", classId) + } + baseline = newFieldState() + p.fpBuf = readFields(newReader(raw), class.serializer, baseline, p.fpBuf) + p.classBaselineStates[classId] = baseline } e = newEntity(index, serial, class) + e.state = baseline.clone() p.entities[index] = e - readFields(newReader(baseline), class.serializer, e.state) - readFields(r, class.serializer, e.state) + p.fpBuf = readFields(r, class.serializer, e.state, p.fpBuf) op = EntityOpCreated | EntityOpEntered } else { @@ -281,7 +290,7 @@ func (p *Parser) onCSVCMsg_PacketEntities(m *dota.CSVCMsg_PacketEntities) error op |= EntityOpEntered } - readFields(r, e.class.serializer, e.state) + p.fpBuf = readFields(r, e.class.serializer, e.state, p.fpBuf) } } else { @@ -300,18 +309,26 @@ func (p *Parser) onCSVCMsg_PacketEntities(m *dota.CSVCMsg_PacketEntities) error } } - tuples = append(tuples, tuple{e, op}) + tuples = append(tuples, entityOpTuple{e, op}) } + var err error +dispatch: for _, h := range p.entityHandlers { - for _, t := range tuples { - if err := h(t.e, t.op); err != nil { - return err + for i := range tuples { + if err = h(tuples[i].e, tuples[i].op); err != nil { + break dispatch } } } - return nil + // Release the entity references and keep the buffer at length zero so a + // reused backing array does not retain (possibly deleted) entities and their + // state. This runs on every path (success or error), before the single + // return. + clear(tuples) + p.entityTuples = tuples[:0] + return err } // OnEntity registers an EntityHandler that will be called when an entity diff --git a/field.go b/field.go index 629cde5..5337622 100644 --- a/field.go +++ b/field.go @@ -252,7 +252,7 @@ func (f *field) getFieldPaths(fp *fieldPath, state *fieldState) []*fieldPath { if sub, ok := state.get(fp).(*fieldState); ok { fp.last++ for i, v := range sub.state { - if v != nil { + if !v.isEmpty() { fp.path[fp.last] = i x = append(x, fp.copy()) } @@ -271,7 +271,7 @@ func (f *field) getFieldPaths(fp *fieldPath, state *fieldState) []*fieldPath { if sub, ok := state.get(fp).(*fieldState); ok { fp.last++ for i, v := range sub.state { - if v != nil { + if !v.isEmpty() { fp.path[fp.last] = i x = append(x, fp.copy()) } @@ -283,7 +283,7 @@ func (f *field) getFieldPaths(fp *fieldPath, state *fieldState) []*fieldPath { if sub, ok := state.get(fp).(*fieldState); ok { fp.last += 2 for i, v := range sub.state { - if vv, ok := v.(*fieldState); ok { + if vv, ok := v.sub(); ok { fp.path[fp.last-1] = i x = append(x, f.serializer.getFieldPaths(fp, vv)...) } diff --git a/field_decoder.go b/field_decoder.go index e2d15a5..2b8bfa4 100644 --- a/field_decoder.go +++ b/field_decoder.go @@ -4,7 +4,7 @@ import ( "math" ) -type fieldDecoder func(*reader) interface{} +type fieldDecoder func(*reader) cell type fieldFactory func(*field) fieldDecoder var fieldTypeFactories = map[string]fieldFactory{ @@ -14,6 +14,7 @@ var fieldTypeFactories = map[string]fieldFactory{ "Vector2D": vectorFactory(2), "Vector4D": vectorFactory(4), "VectorWS": vectorFactory(3), + "Quaternion": vectorFactory(4), "uint64": unsigned64Factory, "QAngle": qangleFactory, "CHandle": unsignedFactory, @@ -29,7 +30,7 @@ var fieldTypeDecoders = map[string]fieldDecoder{ "color32": unsignedDecoder, "int16": signedDecoder, "int32": signedDecoder, - "int64": signedDecoder, + "int64": signed64Decoder, "int8": signedDecoder, "uint16": unsignedDecoder, "uint32": unsignedDecoder, @@ -37,7 +38,9 @@ var fieldTypeDecoders = map[string]fieldDecoder{ "GameTime_t": noscaleDecoder, "HeroFacetKey_t": unsigned64Decoder, - "BloodType": unsignedDecoder, + "HeroID_t": signedDecoder, + "HSequence": hSequenceDecoder, + "BloodType": bloodTypeDecoder, "CBodyComponent": componentDecoder, "CGameSceneNodeHandle": unsignedDecoder, @@ -47,6 +50,9 @@ var fieldTypeDecoders = map[string]fieldDecoder{ "CUtlString": stringDecoder, "CUtlStringToken": unsignedDecoder, "CUtlSymbolLarge": stringDecoder, + "CUtlBinaryBlock": cUtlBinaryBlockDecoder, + "CGlobalSymbol": stringDecoder, + "ResourceId_t": unsigned64Decoder, } func unsignedFactory(f *field) fieldDecoder { @@ -80,8 +86,8 @@ func floatFactory(f *field) fieldDecoder { func quantizedFactory(f *field) fieldDecoder { qfd := newQuantizedFloatDecoder(f.bitCount, f.encodeFlags, f.lowValue, f.highValue) - return func(r *reader) interface{} { - return qfd.decode(r) + return func(r *reader) cell { + return floatCell(qfd.decode(r)) } } @@ -92,84 +98,155 @@ func vectorFactory(n int) fieldFactory { } d := floatFactory(f) - return func(r *reader) interface{} { + return func(r *reader) cell { x := make([]float32, n) for i := 0; i < n; i++ { - x[i] = d(r).(float32) + x[i] = d(r).asFloat32() } - return x + return refCell(x) } } } -func vectorNormalDecoder(r *reader) interface{} { - return r.read3BitNormal() +func vectorNormalDecoder(r *reader) cell { + return refCell(r.read3BitNormal()) } -func fixed64Decoder(r *reader) interface{} { - return r.readLeUint64() +func fixed64Decoder(r *reader) cell { + return refCell(r.readLeUint64()) } -func handleDecoder(r *reader) interface{} { - return r.readVarUint32() +func handleDecoder(r *reader) cell { + return uint32Cell(r.readVarUint32()) } -func booleanDecoder(r *reader) interface{} { - return r.readBoolean() +func booleanDecoder(r *reader) cell { + return boolCell(r.readBoolean()) } -func stringDecoder(r *reader) interface{} { - return r.readString() +func stringDecoder(r *reader) cell { + return refCell(r.readString()) } -func defaultDecoder(r *reader) interface{} { - return r.readVarUint32() +func defaultDecoder(r *reader) cell { + return uint32Cell(r.readVarUint32()) } -func signedDecoder(r *reader) interface{} { - return r.readVarInt32() +func signedDecoder(r *reader) cell { + return int32Cell(r.readVarInt32()) } -func floatCoordDecoder(r *reader) interface{} { - return r.readCoord() +func signed64Decoder(r *reader) cell { + return refCell(r.readVarInt64()) } -func noscaleDecoder(r *reader) interface{} { - return math.Float32frombits(r.readBits(32)) +// bloodTypeDecoder reads a fixed 8-bit value, matching clarity's +// IntUnsignedDecoder(8). This is bit-identical to an unsigned varint only while +// the value is < 128; the full golden suite gates that this holds on the corpus. +func bloodTypeDecoder(r *reader) cell { + return smallUint64Cell(uint64(r.readBits(8))) } -func runeTimeDecoder(r *reader) interface{} { - return math.Float32frombits(r.readBits(4)) +// hSequenceDecoder decodes a sequence handle as an unsigned varint minus one, +// matching clarity's IntMinusOneDecoder. It returns a signed int32 so the +// "none" handle (wire value 0) is -1 rather than wrapping to a large unsigned. +func hSequenceDecoder(r *reader) cell { + return int32Cell(int32(r.readVarUint32()) - 1) } -func simulationTimeDecoder(r *reader) interface{} { - return float32(r.readVarUint32()) * (1.0 / 30) +// cUtlBinaryBlockDecoder reads a length-prefixed binary blob (varint length +// followed by that many bytes), matching clarity's CUtlBinaryBlockDecoder. The +// bytes are copied so the stored value does not alias the transient read buffer. +func cUtlBinaryBlockDecoder(r *reader) cell { + n := r.readVarUint32() + b := r.readBytes(n) + out := make([]byte, len(b)) + copy(out, b) + return refCell(out) +} + +func floatCoordDecoder(r *reader) cell { + return floatCell(r.readCoord()) +} + +func noscaleDecoder(r *reader) cell { + return floatCell(math.Float32frombits(r.readBits(32))) +} + +func runeTimeDecoder(r *reader) cell { + return floatCell(math.Float32frombits(r.readBits(4))) +} + +func simulationTimeDecoder(r *reader) cell { + return floatCell(float32(r.readVarUint32()) * (1.0 / 30)) } func qangleFactory(f *field) fieldDecoder { + bc := uint32(0) + if f.bitCount != nil { + bc = uint32(*f.bitCount) + } + if f.encoder == "qangle_pitch_yaw" { - n := uint32(*f.bitCount) - return func(r *reader) interface{} { - return []float32{ - r.readAngle(n), - r.readAngle(n), - 0.0, + // Bit counts 0 and 32 carry raw 32-bit floats; otherwise bit angles. + if bc == 0 || bc == 32 { + return func(r *reader) cell { + return refCell([]float32{ + math.Float32frombits(r.readBits(32)), + math.Float32frombits(r.readBits(32)), + 0.0, + }) } } + return func(r *reader) cell { + return refCell([]float32{ + r.readAngle(bc), + r.readAngle(bc), + 0.0, + }) + } } - if f.bitCount != nil && *f.bitCount != 0 { - n := uint32(*f.bitCount) - return func(r *reader) interface{} { - return []float32{ - r.readAngle(n), - r.readAngle(n), - r.readAngle(n), + if f.encoder == "qangle_precise" { + return func(r *reader) cell { + ret := make([]float32, 3) + rX := r.readBoolean() + rY := r.readBoolean() + rZ := r.readBoolean() + if rX { + ret[0] = r.readAngle(20) + } + if rY { + ret[1] = r.readAngle(20) } + if rZ { + ret[2] = r.readAngle(20) + } + return refCell(ret) + } + } + + if bc == 32 { + return func(r *reader) cell { + return refCell([]float32{ + math.Float32frombits(r.readBits(32)), + math.Float32frombits(r.readBits(32)), + math.Float32frombits(r.readBits(32)), + }) + } + } + + if bc != 0 { + return func(r *reader) cell { + return refCell([]float32{ + r.readAngle(bc), + r.readAngle(bc), + r.readAngle(bc), + }) } } - return func(r *reader) interface{} { + return func(r *reader) cell { ret := make([]float32, 3) rX := r.readBoolean() rY := r.readBoolean() @@ -183,24 +260,25 @@ func qangleFactory(f *field) fieldDecoder { if rZ { ret[2] = r.readCoord() } - return ret + return refCell(ret) } } -func vector2Decoder(r *reader) interface{} { - return []float32{r.readFloat(), r.readFloat()} +func vector2Decoder(r *reader) cell { + return refCell([]float32{r.readFloat(), r.readFloat()}) } -func unsignedDecoder(r *reader) interface{} { - return uint64(r.readVarUint32()) +func unsignedDecoder(r *reader) cell { + // readVarUint32 yields at most 32 bits, so the uint64 value fits inline. + return smallUint64Cell(uint64(r.readVarUint32())) } -func unsigned64Decoder(r *reader) interface{} { - return r.readVarUint64() +func unsigned64Decoder(r *reader) cell { + return refCell(r.readVarUint64()) } -func componentDecoder(r *reader) interface{} { - return r.readBits(1) +func componentDecoder(r *reader) cell { + return uint32Cell(r.readBits(1)) } func findDecoder(f *field) fieldDecoder { diff --git a/field_decoder_test.go b/field_decoder_test.go new file mode 100644 index 0000000..860e2b9 --- /dev/null +++ b/field_decoder_test.go @@ -0,0 +1,83 @@ +package manta + +import ( + "encoding/binary" + "testing" + + "github.com/stretchr/testify/assert" +) + +// encVarUint encodes v as an unsigned LEB128 varint, matching reader.readVarUint*. +func encVarUint(v uint64) []byte { + var b []byte + for v >= 0x80 { + b = append(b, byte(v)|0x80) + v >>= 7 + } + return append(b, byte(v)) +} + +// encVarInt encodes v as a zig-zag signed varint, matching reader.readVarInt*. +func encVarInt(v int64) []byte { + return encVarUint(uint64((v << 1) ^ (v >> 63))) +} + +// TestDecoderRepresentations locks the exact dynamic type AND value that the +// value-changing / representation-critical decoders produce through Entity.Get. +// These are the deliberate clarity-aligned changes we are owning (see +// IMPROVEMENTS.md P4.5 / Decision A): a regression here is a downstream-visible +// behavior change, which the replay golden tests would not necessarily catch. +// assert.Equal compares both the concrete type and value of the interface{}. +func TestDecoderRepresentations(t *testing.T) { + assert := assert.New(t) + + dec := func(d fieldDecoder, buf []byte) interface{} { + return d(newReader(buf)).iface() + } + + // HSequence: unsigned varint minus one, returned as a signed int32 so the + // "none" handle (wire 0) is -1 rather than a large unsigned value. + assert.Equal(int32(4), dec(hSequenceDecoder, encVarUint(5))) + assert.Equal(int32(-1), dec(hSequenceDecoder, encVarUint(0))) + + // HeroID_t: signed (zig-zag) varint int32. + assert.Equal(int32(21), dec(signedDecoder, encVarInt(21))) + assert.Equal(int32(-5), dec(signedDecoder, encVarInt(-5))) + + // int64: full 64-bit signed varint (not truncated to int32). + assert.Equal(int64(5_000_000_000), dec(signed64Decoder, encVarInt(5_000_000_000))) + + // BloodType: fixed 8-bit unsigned, returned as uint64. + assert.Equal(uint64(66), dec(bloodTypeDecoder, []byte{0x42})) + + // 32-bit-range unsigned handles: stored inline, returned as uint64. + assert.Equal(uint64(5), dec(unsignedDecoder, encVarUint(5))) + assert.Equal(uint64(0xFFFFFFFF), dec(unsignedDecoder, encVarUint(0xFFFFFFFF))) + + // Genuinely 64-bit unsigned values (e.g. steam IDs) must round-trip without + // truncation through the boxed path, returned as uint64. + assert.Equal(uint64(76561198140280423), dec(unsigned64Decoder, encVarUint(76561198140280423))) + fixedBuf := make([]byte, 8) + binary.LittleEndian.PutUint64(fixedBuf, 0x0123456789ABCDEF) + assert.Equal(uint64(0x0123456789ABCDEF), dec(fixed64Decoder, fixedBuf)) +} + +// TestValueChangingDecoderWiring locks that the field types whose representation +// changed are mapped to the intended decoders, so a future edit to the decoder +// tables cannot silently revert the behavior. +func TestValueChangingDecoderWiring(t *testing.T) { + assert := assert.New(t) + + wired := func(typeName string, buf []byte) interface{} { + d, ok := fieldTypeDecoders[typeName] + if !ok { + t.Fatalf("no decoder registered for %s", typeName) + } + return d(newReader(buf)).iface() + } + + assert.Equal(int32(-1), wired("HSequence", encVarUint(0))) + assert.Equal(int32(21), wired("HeroID_t", encVarInt(21))) + assert.Equal(int64(5_000_000_000), wired("int64", encVarInt(5_000_000_000))) + assert.Equal(uint64(66), wired("BloodType", []byte{0x42})) +} diff --git a/field_patch.go b/field_patch.go index 46fd05d..8ca042a 100644 --- a/field_patch.go +++ b/field_patch.go @@ -1,6 +1,8 @@ package manta import ( + "math" + "github.com/golang/protobuf/proto" ) @@ -51,8 +53,12 @@ var fieldPatches = []fieldPatch{ fieldPatch{0, 954, func(f *field) { switch f.varName { case "m_flMana", "m_flMaxMana": - f.lowValue = nil - f.highValue = proto.Float32(8192.0) + // Only override the synthetic full-float-range bounds (the sentinel + // the engine emits when no real range is set), matching clarity. + if f.highValue != nil && *f.highValue == float32(math.MaxFloat32) { + f.lowValue = nil + f.highValue = proto.Float32(8192.0) + } } }}, fieldPatch{1016, 1027, func(f *field) { @@ -73,7 +79,13 @@ var fieldPatches = []fieldPatch{ case "m_flSimulationTime", "m_flAnimTime": f.encoder = "simtime" case "m_flRuneTime": - f.encoder = "runetime" + // Only patch when the field carries the synthetic full-float-range + // bounds, matching clarity's runetime guard. Manta keeps its bespoke + // 4-bit runetime decoder (see field_decoder.go). + if f.lowValue != nil && f.highValue != nil && + *f.lowValue == -float32(math.MaxFloat32) && *f.highValue == float32(math.MaxFloat32) { + f.encoder = "runetime" + } } }}, } diff --git a/field_path.go b/field_path.go index a2582cf..0a5dbe2 100644 --- a/field_path.go +++ b/field_path.go @@ -8,8 +8,88 @@ import ( var huffTree = newHuffmanTree() +// Flattened representation of huffTree for fast field-path op decoding. Each +// internal node n occupies huffTreeLeft[n] and huffTreeRight[n]; a non-negative +// entry is a child node index and a negative entry -(op+1) is a leaf carrying +// field-path op id op. Walking int arrays avoids the interface-method dispatch +// and pointer chasing of the huffmanTree on the hot path. It is derived from +// manta's own huffTree, so the codes are identical to the interface-tree walk. +var ( + huffTreeLeft []int32 + huffTreeRight []int32 + huffTreeRoot int32 +) + +func init() { + huffTreeRoot = flattenHuffmanTree(huffTree) + buildHuffLookup() +} + +// huffLookupBits is the width of the field-path op lookup window. Most ops have +// codes no longer than this and resolve in a single table index. +const huffLookupBits = 8 + +// huffLookup resolves up to huffLookupBits of the field-path op stream in one +// step. Each entry packs the consumed bit count in its low byte (0 meaning the +// code is longer than the window) and, in its high byte, either the resolved op +// id (when the low byte is non-zero) or the flat-tree node to continue from. +var huffLookup [1 << huffLookupBits]uint16 + +func buildHuffLookup() { + for v := 0; v < len(huffLookup); v++ { + node := huffTreeRoot + resolved := false + for bit := uint32(0); bit < huffLookupBits; bit++ { + var child int32 + if (v>>bit)&1 == 1 { + child = huffTreeRight[node] + } else { + child = huffTreeLeft[node] + } + if child < 0 { + huffLookup[v] = uint16(bit+1) | uint16(-child-1)<<8 + resolved = true + break + } + node = child + } + if !resolved { + huffLookup[v] = uint16(node) << 8 + } + } +} + +// flattenHuffmanTree appends the internal nodes of t to huffTreeLeft/huffTreeRight +// and returns the index assigned to t. Leaf children are encoded inline as +// -(op+1). +func flattenHuffmanTree(t huffmanTree) int32 { + idx := int32(len(huffTreeLeft)) + huffTreeLeft = append(huffTreeLeft, 0) + huffTreeRight = append(huffTreeRight, 0) + + if l := t.Left(); l.IsLeaf() { + huffTreeLeft[idx] = -(int32(l.Value()) + 1) + } else { + huffTreeLeft[idx] = flattenHuffmanTree(l) + } + if r := t.Right(); r.IsLeaf() { + huffTreeRight[idx] = -(int32(r.Value()) + 1) + } else { + huffTreeRight[idx] = flattenHuffmanTree(r) + } + + return idx +} + +// maxFieldPathDepth is the maximum field-path depth, matching clarity's +// S2LongFieldPathFormat (whose BITS_PER_COMPONENT has 7 entries). The fixed-size +// path array enforces this invariant: a deeper push hits a bounds-check panic +// that the parser's top-level recover turns into an error, rather than silently +// corrupting entity state. +const maxFieldPathDepth = 7 + type fieldPath struct { - path []int + path [maxFieldPathDepth]int last int done bool } @@ -259,7 +339,7 @@ func (fp *fieldPath) pop(n int) { // copy returns a copy of the fieldPath func (fp *fieldPath) copy() *fieldPath { x := fpPool.Get().(*fieldPath) - copy(x.path, fp.path) + x.path = fp.path x.last = fp.last x.done = fp.done return x @@ -283,19 +363,13 @@ func newFieldPath() *fieldPath { var fpPool = &sync.Pool{ New: func() interface{} { - return &fieldPath{ - path: make([]int, 7), - last: 0, - done: false, - } + return &fieldPath{} }, } -var fpReset = []int{-1, 0, 0, 0, 0, 0, 0} - // reset resets the fieldPath to the empty value func (fp *fieldPath) reset() { - copy(fp.path, fpReset) + fp.path = [maxFieldPathDepth]int{-1} fp.last = 0 fp.done = false } @@ -305,35 +379,53 @@ func (fp *fieldPath) release() { fpPool.Put(fp) } -// readFieldPaths reads a new slice of fieldPath values from the given reader -func readFieldPaths(r *reader) []*fieldPath { - fp := newFieldPath() - - node, next := huffTree, huffTree - - paths := []*fieldPath{} +// readFieldPaths decodes the field-path operation stream from r into the +// provided buffer (which the caller resets to length 0) and returns the grown +// slice. Field paths are cumulative deltas, snapshotted by value into the +// buffer, so there is no per-path allocation or sync.Pool churn and the buffer +// is reused across calls. +func readFieldPaths(r *reader, paths []fieldPath) []fieldPath { + // Borrow a reusable accumulator from the pool. Taking its address for the + // field-path op functions (an indirect call) would otherwise force it to + // escape and heap-allocate on every call; the pool keeps that amortized. + fp := fpPool.Get().(*fieldPath) + fp.reset() - for !fp.done { - if r.readBits(1) == 1 { - next = node.Right() + for { + // Resolve the op via the 8-bit lookup table, falling back to a flat-tree + // walk for the rare codes longer than the lookup window. peekBits never + // over-reads, so the final FieldPathEncodeFinish near the end of the + // buffer is handled correctly. + var op int32 + entry := huffLookup[r.peekBits(huffLookupBits)] + if consumed := entry & 0xFF; consumed != 0 { + r.skipBits(uint32(consumed)) + op = int32(entry >> 8) } else { - next = node.Left() + r.skipBits(huffLookupBits) + node := int32(entry >> 8) + for { + var child int32 + if r.readBits(1) == 1 { + child = huffTreeRight[node] + } else { + child = huffTreeLeft[node] + } + if child < 0 { + op = -child - 1 + break + } + node = child + } } - if next.IsLeaf() { - node = huffTree - fieldPathTable[next.Value()].fn(r, fp) - if !fp.done { - paths = append(paths, fp.copy()) - } - } else { - node = next + fieldPathTable[op].fn(r, fp) + if fp.done { + fpPool.Put(fp) + return paths } + paths = append(paths, *fp) } - - fp.release() - - return paths } // newHuffmanTree creates a new huffmanTree from the field path table diff --git a/field_reader.go b/field_reader.go index ae1fee9..1856c54 100644 --- a/field_reader.go +++ b/field_reader.go @@ -4,13 +4,18 @@ import ( "strings" ) -func readFields(r *reader, s *serializer, state *fieldState) { - fps := readFieldPaths(r) - - for _, fp := range fps { +func readFields(r *reader, s *serializer, state *fieldState, fpBuf []fieldPath) []fieldPath { + fpBuf = readFieldPaths(r, fpBuf[:0]) + + // Evaluate the debug verbosity once per call rather than twice per field in + // the hot loop. readFields is called fresh per entity update, so a mid-parse + // debug-level change (e.g. a debug-tick) still takes effect on the next call. + dbg := v(6) + for i := range fpBuf { + fp := &fpBuf[i] decoder := s.getDecoderForFieldPath(fp, 0) - if v(6) { + if dbg { name := strings.Join(s.getNameForFieldPath(fp, 0), ".") typ := s.getTypeForFieldPath(fp, 0) field := s.getFieldForFieldPath(fp, 0) @@ -20,7 +25,7 @@ func readFields(r *reader, s *serializer, state *fieldState) { val := decoder(r) state.set(fp, val) - if v(6) { + if dbg { name := strings.Join(s.getNameForFieldPath(fp, 0), ".") fp2 := newFieldPath() b := s.getFieldPathForName(fp2, name) @@ -35,9 +40,9 @@ func readFields(r *reader, s *serializer, state *fieldState) { fp2.release() - _debugf(" => %#v", val) + _debugf(" => %#v", val.iface()) } - - fp.release() } + + return fpBuf } diff --git a/field_state.go b/field_state.go index 1b2e956..5a5e830 100644 --- a/field_state.go +++ b/field_state.go @@ -1,55 +1,161 @@ package manta +import "math" + +// cellKind tags the typed value held by a cell. +type cellKind uint8 + +const ( + cellEmpty cellKind = iota + cellFloat32 + cellInt32 + cellUint32 + cellUint64 // value held inline in num (fits in 32 bits), reproduced as uint64 + cellBool + cellRef // ref holds a string, []float32, []byte, or a boxed 64-bit integer + cellSub // ref holds a *fieldState +) + +// cell is a tagged union holding one decoded field value inline. The common +// scalars (float32, int32, uint32, bool, and 32-bit-range uint64 handles) live +// in num without allocating. Reference values (strings, vectors, binary blobs) +// and the rare genuinely-64-bit integers (CStrongHandle, fixed64, int64) use the +// interface field. Keeping num a uint32 holds the cell to 24 bytes, which keeps +// the per-entity state arrays (cloned from the baseline and grown on write) +// compact. Scalars are boxed into interface{} lazily, on read through iface() +// (e.g. Entity.Get), which is far rarer than the per-field-per-tick write path. +type cell struct { + ref interface{} + num uint32 + kind cellKind +} + +func floatCell(f float32) cell { return cell{num: math.Float32bits(f), kind: cellFloat32} } +func int32Cell(v int32) cell { return cell{num: uint32(v), kind: cellInt32} } +func uint32Cell(v uint32) cell { return cell{num: v, kind: cellUint32} } +func refCell(v interface{}) cell { return cell{ref: v, kind: cellRef} } +func subCell(s *fieldState) cell { return cell{ref: s, kind: cellSub} } + +// smallUint64Cell stores a uint64 whose value is known to fit in 32 bits inline, +// reproduced as a uint64 on read. Used for varint handle types whose wire value +// cannot exceed 32 bits. Genuinely 64-bit values must use refCell instead. +func smallUint64Cell(v uint64) cell { return cell{num: uint32(v), kind: cellUint64} } + +func boolCell(b bool) cell { + c := cell{kind: cellBool} + if b { + c.num = 1 + } + return c +} + +func (c cell) isEmpty() bool { return c.kind == cellEmpty } + +// sub returns the nested fieldState if this cell holds one. +func (c cell) sub() (*fieldState, bool) { + if c.kind == cellSub { + return c.ref.(*fieldState), true + } + return nil, false +} + +// asFloat32 returns the float32 held by a scalar float cell. It is used by the +// vector decoders, which assemble a []float32 from per-component float decoders. +func (c cell) asFloat32() float32 { return math.Float32frombits(c.num) } + +// iface boxes the cell value into an interface{} for the public Get API, +// reproducing the exact dynamic type the decoder originally produced. This is +// the only place a stored scalar is heap-boxed. +func (c cell) iface() interface{} { + switch c.kind { + case cellFloat32: + return math.Float32frombits(c.num) + case cellInt32: + return int32(c.num) + case cellUint32: + return c.num + case cellUint64: + return uint64(c.num) + case cellBool: + return c.num != 0 + case cellRef, cellSub: + return c.ref + } + return nil +} + type fieldState struct { - state []interface{} + state []cell } func newFieldState() *fieldState { return &fieldState{ - state: make([]interface{}, 8), + state: make([]cell, 8), } } func (s *fieldState) get(fp *fieldPath) interface{} { x := s - z := 0 for i := 0; i <= fp.last; i++ { - z = fp.path[i] + z := fp.path[i] if len(x.state) < z+2 { return nil } if i == fp.last { - return x.state[z] + return x.state[z].iface() } - if _, ok := x.state[z].(*fieldState); !ok { + sub, ok := x.state[z].sub() + if !ok { return nil } - x = x.state[z].(*fieldState) + x = sub } return nil } -func (s *fieldState) set(fp *fieldPath, v interface{}) { +func (s *fieldState) set(fp *fieldPath, c cell) { x := s - z := 0 for i := 0; i <= fp.last; i++ { - z = fp.path[i] + z := fp.path[i] if y := len(x.state); y < z+2 { - z := make([]interface{}, max(z+2, y*2)) - copy(z, x.state) - x.state = z + grown := make([]cell, max(z+2, y*2)) + copy(grown, x.state) + x.state = grown } if i == fp.last { - if _, ok := x.state[z].(*fieldState); !ok { - x.state[z] = v + // Do not overwrite an existing sub-table with a leaf value. + if x.state[z].kind != cellSub { + x.state[z] = c } return } - if _, ok := x.state[z].(*fieldState); !ok { - x.state[z] = newFieldState() + if x.state[z].kind != cellSub { + x.state[z] = subCell(newFieldState()) + } + x, _ = x.state[z].sub() + } +} + +// clone returns a deep copy of the fieldState so an entity cloned from a cached +// class baseline can be mutated independently of the template and its siblings. +// Nested fieldStates are copied recursively. Mutable leaf values (vector +// []float32 and binary-blob []byte) are also deep-copied, since a caller may +// mutate a slice returned from Entity.Get/Map; strings and boxed integers are +// immutable and shared by value. +func (s *fieldState) clone() *fieldState { + c := &fieldState{state: make([]cell, len(s.state))} + copy(c.state, s.state) + for i := range c.state { + switch v := c.state[i].ref.(type) { + case *fieldState: + c.state[i].ref = v.clone() + case []float32: + c.state[i].ref = append([]float32(nil), v...) + case []byte: + c.state[i].ref = append([]byte(nil), v...) } - x = x.state[z].(*fieldState) } + return c } func max(a, b int) int { diff --git a/game_event.go b/game_event.go index 865090c..24c5c06 100644 --- a/game_event.go +++ b/game_event.go @@ -34,17 +34,20 @@ type GameEvent struct { } func (ge *GameEvent) TypeName() string { - return dota.DOTA_COMBATLOG_TYPES_name[ge.m.GetKeys()[0].GetValByte()] + return dota.DOTA_COMBATLOG_TYPES_name[int32(ge.Type())] } func (ge *GameEvent) Type() dota.DOTA_COMBATLOG_TYPES { - return dota.DOTA_COMBATLOG_TYPES(ge.m.GetKeys()[0].GetValByte()) + // Resolve the type via the descriptor rather than assuming it is the first + // key, and via the typed-int dispatch rather than a raw byte read, so it + // stays correct if the key order or encoding changes across versions. + t, _ := ge.GetInt32("type") + return dota.DOTA_COMBATLOG_TYPES(t) } func (ge *GameEvent) String() string { keys := ge.m.GetKeys() - name := dota.DOTA_COMBATLOG_TYPES_name[keys[0].GetValByte()] - buf := bytes.NewBufferString("\n " + name + "\n") + buf := bytes.NewBufferString("\n " + ge.TypeName() + "\n") for name, field := range ge.t.fields { key := keys[field.i] @@ -163,7 +166,7 @@ func (e *GameEvent) getEventKey(name string) (*dota.CMsgSource1LegacyGameEventKe return nil, _errorf("field %s: missing", name) } - if f.i > len(e.m.GetKeys()) { + if f.i >= len(e.m.GetKeys()) { return nil, _errorf("field %s: %d out of range", name, f.i) } diff --git a/huffman_test.go b/huffman_test.go new file mode 100644 index 0000000..45cb7aa --- /dev/null +++ b/huffman_test.go @@ -0,0 +1,127 @@ +package manta + +import ( + "math/rand" + "testing" +) + +// TestHuffmanFlatMatchesTree verifies the flattened field-path huffman arrays +// (huffTreeLeft/huffTreeRight) decode identically to the interface tree they are +// derived from, so the fast path can never silently desync from the canonical +// tree if the op table or weights change. +func TestHuffmanFlatMatchesTree(t *testing.T) { + var walk func(node int32, itree huffmanTree) + walk = func(node int32, itree huffmanTree) { + if c := huffTreeLeft[node]; c < 0 { + l := itree.Left() + if !l.IsLeaf() { + t.Fatalf("node %d left: flat=leaf op %d, tree=internal", node, -c-1) + } + if got := int32(l.Value()); got != -c-1 { + t.Fatalf("node %d left: flat op %d != tree op %d", node, -c-1, got) + } + } else { + if itree.Left().IsLeaf() { + t.Fatalf("node %d left: flat=internal %d, tree=leaf op %d", node, c, itree.Left().Value()) + } + walk(c, itree.Left()) + } + + if c := huffTreeRight[node]; c < 0 { + r := itree.Right() + if !r.IsLeaf() { + t.Fatalf("node %d right: flat=leaf op %d, tree=internal", node, -c-1) + } + if got := int32(r.Value()); got != -c-1 { + t.Fatalf("node %d right: flat op %d != tree op %d", node, -c-1, got) + } + } else { + if itree.Right().IsLeaf() { + t.Fatalf("node %d right: flat=internal %d, tree=leaf op %d", node, c, itree.Right().Value()) + } + walk(c, itree.Right()) + } + } + walk(huffTreeRoot, huffTree) +} + +// decodeOneHuffOpLookup decodes a single field-path op code (without executing +// the op) using the 8-bit lookup fast path. +func decodeOneHuffOpLookup(r *reader) int32 { + entry := huffLookup[r.peekBits(huffLookupBits)] + if consumed := entry & 0xFF; consumed != 0 { + r.skipBits(uint32(consumed)) + return int32(entry >> 8) + } + r.skipBits(huffLookupBits) + node := int32(entry >> 8) + for { + var child int32 + if r.readBits(1) == 1 { + child = huffTreeRight[node] + } else { + child = huffTreeLeft[node] + } + if child < 0 { + return -child - 1 + } + node = child + } +} + +// decodeOneHuffOpWalk decodes a single field-path op code using only the flat +// tree walk. +func decodeOneHuffOpWalk(r *reader) int32 { + node := huffTreeRoot + for { + var child int32 + if r.readBits(1) == 1 { + child = huffTreeRight[node] + } else { + child = huffTreeLeft[node] + } + if child < 0 { + return -child - 1 + } + node = child + } +} + +// TestReadFieldPathsTruncated verifies that a truncated field-path op stream +// fails cleanly instead of underflowing the bit accumulator. An all-zero buffer +// decodes as an unbounded run of PlusOne ops (code "0") that never reaches +// FieldPathEncodeFinish; once the bits run out, the lookup would consume past +// the end of the buffer. With the skipBits guard this panics (the parser +// recovers it into an error); without it, bitCount underflows and the decode +// loops forever appending field paths. +func TestReadFieldPathsTruncated(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected a panic on a truncated field-path stream") + } + }() + readFieldPaths(newReader([]byte{0x00, 0x00}), nil) +} + +// TestHuffmanLookupMatchesWalk verifies the 8-bit lookup fast path decodes the +// same op and consumes the same number of bits as the pure flat-tree walk for +// many random streams. An 8-byte buffer comfortably holds the longest code +// (17 bits), so neither path reaches the buffer end. +func TestHuffmanLookupMatchesWalk(t *testing.T) { + rng := rand.New(rand.NewSource(1)) + for trial := 0; trial < 5000; trial++ { + buf := make([]byte, 8) + for i := range buf { + buf[i] = byte(rng.Intn(256)) + } + ra := newReader(buf) + rb := newReader(buf) + opA := decodeOneHuffOpLookup(ra) + opB := decodeOneHuffOpWalk(rb) + consumedA := ra.pos*8 - ra.bitCount + consumedB := rb.pos*8 - rb.bitCount + if opA != opB || consumedA != consumedB { + t.Fatalf("trial %d buf %x: lookup op=%d bits=%d, walk op=%d bits=%d", trial, buf, opA, consumedA, opB, consumedB) + } + } +} diff --git a/manta_test.go b/manta_test.go index ded9ef9..636511c 100644 --- a/manta_test.go +++ b/manta_test.go @@ -1,6 +1,7 @@ package manta import ( + "io" "os" "testing" @@ -9,6 +10,7 @@ import ( ) func BenchmarkMatch2159568145(b *testing.B) { testScenarios[2159568145].bench(b) } +func BenchmarkMatch8552595443(b *testing.B) { testScenarios[8552595443].bench(b) } // Test client func TestMatchNew8552595443(t *testing.T) { testScenarios[8552595443].test(t) } @@ -607,10 +609,22 @@ var testScenarios = map[int64]testScenario{ } func (s testScenario) bench(b *testing.B) { - for n := 0; n < b.N; n++ { - r := mustGetReplayReader(s.matchId, s.replayUrl) + // Read the replay fully into memory once so the benchmark measures parsing + // (CPU + allocations), not per-byte file IO. The streaming os.File path + // spends ~80% of CPU in read syscalls, which would otherwise mask the + // parser changes we are trying to measure. + rc := mustGetReplayReader(s.matchId, s.replayUrl) + buf, err := io.ReadAll(rc) + rc.Close() + if err != nil { + b.Fatalf("unable to read replay: %s", err) + } + + b.ReportAllocs() + b.ResetTimer() - parser, err := NewStreamParser(r) + for n := 0; n < b.N; n++ { + parser, err := NewParser(buf) if err != nil { b.Fatalf("unable to instantiate parser: %s", err) } @@ -624,8 +638,6 @@ func (s testScenario) bench(b *testing.B) { b.Fatal(err) } } - - b.ReportAllocs() } func (s testScenario) test(t *testing.T) { diff --git a/modifier.go b/modifier.go index 4b7395f..b64408d 100644 --- a/modifier.go +++ b/modifier.go @@ -16,7 +16,21 @@ func (p *Parser) OnModifierTableEntry(fn ModifierTableEntryHandler) { // emitModifierTableEvents emits ModifierBuffTableEntry events // from the given string table items. func (p *Parser) emitModifierTableEvents(items []*stringTableItem) error { + // Nothing to do if no consumer is listening; avoid the per-item proto + // allocation + unmarshal entirely. This is the common case (e.g. the + // benchmark and any parse that doesn't register OnModifierTableEntry). + if len(p.modifierTableEntryHandlers) == 0 { + return nil + } + for _, item := range items { + // Skip deleted/empty entries (clarity does the same). An empty value + // would otherwise unmarshal into an all-zero message and be emitted as a + // spurious modifier event. A real modifier is never zero-length on wire. + if len(item.Value) == 0 { + continue + } + msg := &dota.CDOTAModifierBuffTableEntry{} if err := proto.NewBuffer(item.Value).Unmarshal(msg); err != nil { _debugf("unable to unmarshal ModifierBuffTableEntry: %s", err) diff --git a/parser.go b/parser.go index 75ea78d..2fdd228 100644 --- a/parser.go +++ b/parser.go @@ -13,6 +13,11 @@ import ( var magicSource1 = []byte{'P', 'U', 'F', 'D', 'E', 'M', 'S', '\000'} var magicSource2 = []byte{'P', 'B', 'D', 'E', 'M', 'S', '2', '\000'} +// maxOuterMessageSize bounds a single outer message length so a corrupt or +// truncated size varint cannot trigger a huge allocation. It is far above any +// legitimate message, including full packets. +const maxOuterMessageSize = 256 << 20 + // Parser is an instance of the replay parser type Parser struct { // Callbacks provide a mechanism for receiving notification @@ -32,6 +37,7 @@ type Parser struct { AfterStopCallback func() classBaselines map[int32][]byte + classBaselineStates map[int32]*fieldState classesById map[int32]*class classesByName map[string]*class classIdSize uint32 @@ -45,6 +51,11 @@ type Parser struct { isStopping bool modifierTableEntryHandlers []ModifierTableEntryHandler serializers map[string]*serializer + pendingMsgBuf pendingMessages + snappyScratch []byte + entityReader reader + entityTuples []entityOpTuple + fpBuf []fieldPath stream *stream stringTables *stringTables stopAtTick uint32 @@ -65,18 +76,19 @@ func NewStreamParser(r io.Reader) (*Parser, error) { NetTick: 0, GameBuild: 0, - classBaselines: make(map[int32][]byte), - classesById: make(map[int32]*class), - classesByName: make(map[string]*class), - entities: make(map[int32]*Entity), - entityHandlers: make([]EntityHandler, 0), - gameEventHandlers: make(map[string][]GameEventHandler), - gameEventNames: make(map[int32]string), - gameEventTypes: make(map[string]*gameEventType), - isStopping: false, - serializers: make(map[string]*serializer), - stream: newStream(r), - stringTables: newStringTables(), + classBaselines: make(map[int32][]byte), + classBaselineStates: make(map[int32]*fieldState), + classesById: make(map[int32]*class), + classesByName: make(map[string]*class), + entities: make(map[int32]*Entity), + entityHandlers: make([]EntityHandler, 0), + gameEventHandlers: make(map[string][]GameEventHandler), + gameEventNames: make(map[int32]string), + gameEventTypes: make(map[string]*gameEventType), + isStopping: false, + serializers: make(map[string]*serializer), + stream: newStream(r), + stringTables: newStringTables(), } // Parse out the header, ensuring that it's valid. @@ -117,7 +129,7 @@ func NewStreamParser(r io.Reader) (*Parser, error) { // Start parsing the replay. Will stop processing new events after Stop() is called. func (p *Parser) Start() (err error) { - var msg *outerMessage + var msg outerMessage defer p.afterStop() @@ -190,14 +202,16 @@ type outerMessage struct { data []byte } -// Read the next outer message from the buffer. -func (p *Parser) readOuterMessage() (*outerMessage, error) { +// Read the next outer message from the buffer. The message is returned by +// value so it does not escape to the heap (its single caller, Start, consumes +// it immediately and never retains it). +func (p *Parser) readOuterMessage() (outerMessage, error) { // Read a command header, which includes both the message type // well as a flag to determine whether or not whether or not the // message is compressed with snappy. command, err := p.stream.readCommand() if err != nil { - return nil, err + return outerMessage{}, err } // Extract the type and compressed flag out of the command @@ -207,7 +221,7 @@ func (p *Parser) readOuterMessage() (*outerMessage, error) { // Read the tick that the message corresponds with. tick, err := p.stream.readVarUint32() if err != nil { - return nil, err + return outerMessage{}, err } // This appears to actually be an int32, where a -1 means pre-game. @@ -218,29 +232,40 @@ func (p *Parser) readOuterMessage() (*outerMessage, error) { // Read the size and following buffer. size, err := p.stream.readVarUint32() if err != nil { - return nil, err + return outerMessage{}, err + } + + // Reject an implausibly large size before allocating, so a corrupt or + // truncated stream fails cleanly instead of attempting a huge allocation. + if size > maxOuterMessageSize { + return outerMessage{}, _errorf("outer message size %d exceeds maximum %d", size, maxOuterMessageSize) } buf, err := p.stream.readBytes(size) if err != nil { - return nil, err + return outerMessage{}, err } - // If the buffer is compressed, decompress it with snappy. + // If the buffer is compressed, decompress it with snappy, reusing a + // parser-level scratch buffer across messages. snappy.Decode reuses the + // destination when it is large enough, amortizing the decompression + // allocation to roughly the largest compressed message seen. This is safe + // because the decoded buffer is consumed within the dispatch of this + // message and never retained across outer messages. if msgCompressed { var err error - if buf, err = snappy.Decode(nil, buf); err != nil { - return nil, err + if buf, err = snappy.Decode(p.snappyScratch[:cap(p.snappyScratch)], buf); err != nil { + return outerMessage{}, err } + p.snappyScratch = buf } // Return the message - msg := &outerMessage{ + return outerMessage{ tick: tick, typeId: msgType, data: buf, - } - return msg, nil + }, nil } // parseToTick configures this Parser to stop once it has parsed the given tick. diff --git a/quantizedfloat.go b/quantizedfloat.go index aefea68..b9c3a4d 100644 --- a/quantizedfloat.go +++ b/quantizedfloat.go @@ -221,6 +221,14 @@ func newQuantizedFloatDecoder(bitCount, flags *int32, lowValue, highValue *float qfd.High = qfd.Low + float32(Range2) - qfd.Offset } + // The bit reader refills its accumulator a word at a time and relies on + // every read being at most 32 bits. The integer-encoding path above can in + // principle raise the bit count, so guard the invariant explicitly rather + // than silently corrupting the stream. + if qfd.Bitcount > 32 { + _panicf("quantized float bit count %d exceeds 32", qfd.Bitcount) + } + // Assign multipliers qfd.assignMultipliers(uint32(steps)) diff --git a/reader.go b/reader.go index 0f01a4d..cfce8a4 100644 --- a/reader.go +++ b/reader.go @@ -20,16 +20,30 @@ func newReader(buf []byte) *reader { return &reader{buf, uint32(len(buf)), 0, 0, 0} } +// reset reinitializes the reader to read from the given buffer, allowing a +// single reader to be reused across messages without allocating a new one. +func (r *reader) reset(buf []byte) { + r.buf = buf + r.size = uint32(len(buf)) + r.pos = 0 + r.bitVal = 0 + r.bitCount = 0 +} + // remBits calculates the number of unread bits in the buffer func (r *reader) remBits() uint32 { return r.remBytes() + r.bitCount } func (r *reader) position() string { - if r.bitCount > 0 { - return fmt.Sprintf("%d.%d", r.pos-1, 8-r.bitCount) + // Logical bit position consumed so far. The word-at-a-time refill can buffer + // many bits (not just the current byte), so derive byte.bit from pos*8 minus + // the still-buffered bitCount rather than assuming bitCount <= 8. + bits := r.pos*8 - r.bitCount + if rem := bits % 8; rem != 0 { + return fmt.Sprintf("%d.%d", bits/8, rem) } - return fmt.Sprintf("%d", r.pos) + return fmt.Sprintf("%d", bits/8) } // remBytes calculates the number of unread bytes in the buffer @@ -46,24 +60,98 @@ func (r *reader) nextByte() byte { return r.buf[r.pos-1] } -// readBits returns the uint32 value for the given number of sequential bits +// readBitMasks[k] is a bitmask of the low k bits. It is used instead of an +// inline (1< r.bitCount && r.pos+8 <= r.size { + w := binary.LittleEndian.Uint64(r.buf[r.pos:]) + free := (64 - r.bitCount) >> 3 // whole bytes of accumulator headroom + bits := free * 8 + r.bitVal |= (w & readBitMasks[bits]) << r.bitCount + r.pos += free + r.bitCount += bits + } for n > r.bitCount { r.bitVal |= uint64(r.nextByte()) << r.bitCount r.bitCount += 8 } - x := (r.bitVal & ((1 << n) - 1)) + x := r.bitVal & readBitMasks[n] r.bitVal >>= n r.bitCount -= n return uint32(x) } +// peekBits returns the next n (<= 32) bits without consuming them, refilling the +// accumulator as needed. If fewer than n bits remain in the buffer the missing +// high bits are returned as zero; the buffer is never over-read past its end. +// This is required by the field-path op lookup, which inspects a fixed window +// that can extend past the final op near the end of the stream. +func (r *reader) peekBits(n uint32) uint32 { + for n > r.bitCount && r.pos+8 <= r.size { + w := binary.LittleEndian.Uint64(r.buf[r.pos:]) + free := (64 - r.bitCount) >> 3 + bits := free * 8 + r.bitVal |= (w & readBitMasks[bits]) << r.bitCount + r.pos += free + r.bitCount += bits + } + for n > r.bitCount && r.pos < r.size { + r.bitVal |= uint64(r.nextByte()) << r.bitCount + r.bitCount += 8 + } + + return uint32(r.bitVal & readBitMasks[n]) +} + +// skipBits discards n bits already buffered in the accumulator. It panics if +// fewer than n bits are buffered, which happens only on a truncated or corrupt +// stream (a field-path op lookup resolving past the end of the buffer). The +// parser's top-level recover turns that into an error, rather than letting +// bitCount underflow and the field-path decode spin on garbage. +func (r *reader) skipBits(n uint32) { + if n > r.bitCount { + _panicf("skipBits: insufficient buffer (need %d, have %d bits)", n, r.bitCount) + } + r.bitVal >>= n + r.bitCount -= n +} + +// realign discards the whole bytes the word refill buffered in the accumulator +// by rewinding the read position, so byte-oriented reads can proceed directly +// from (and alias) the underlying buffer. Only valid when byte-aligned, i.e. +// bitCount is a multiple of 8. +func (r *reader) realign() { + r.pos -= r.bitCount >> 3 + r.bitVal = 0 + r.bitCount = 0 +} + // readByte reads a single byte func (r *reader) readByte() byte { // Fast path if we're byte aligned - if r.bitCount == 0 { + if r.bitCount&7 == 0 { + if r.bitCount != 0 { + r.realign() + } return r.nextByte() } @@ -73,7 +161,10 @@ func (r *reader) readByte() byte { // readBytes reads the given number of bytes func (r *reader) readBytes(n uint32) []byte { // Fast path if we're byte aligned - if r.bitCount == 0 { + if r.bitCount&7 == 0 { + if r.bitCount != 0 { + r.realign() + } r.pos += n if r.pos > r.size { _panicf("readBytes: insufficient buffer (%d of %d)", r.pos, r.size) diff --git a/reader_test.go b/reader_test.go index 332d6f4..92a528e 100644 --- a/reader_test.go +++ b/reader_test.go @@ -102,6 +102,45 @@ func TestReaderUnaligned(t *testing.T) { assert.Equal(uint32(0x01), r.readBits(1)) } +// TestReaderReadBytesZeroCopy guards the invariant that a byte-aligned readBytes +// returns a slice aliasing the underlying buffer rather than a copy. Callers such +// as CDemoPacket pendingMessage parsing and sendtables rely on this aliasing, and +// the word-at-a-time reader must preserve it (via realign) even when it has +// buffered bytes ahead in the accumulator. +func TestReaderReadBytesZeroCopy(t *testing.T) { + buf := make([]byte, 32) + for i := range buf { + buf[i] = byte(i + 1) + } + + // Aligned read at the start of the buffer (bitCount == 0). + r := newReader(buf) + got := r.readBytes(4) + idx := r.pos - 4 + buf[idx] ^= 0xFF + if got[0] != buf[idx] { + t.Fatalf("aligned readBytes returned a copy, not a zero-copy alias") + } + + // A bit read first buffers a whole word ahead; the following byte-aligned + // readBytes must realign and still alias the buffer (bitCount a non-zero + // multiple of 8). + for i := range buf { + buf[i] = byte(i + 1) + } + r = newReader(buf) + _ = r.readBits(8) + if r.bitCount == 0 || r.bitCount%8 != 0 { + t.Fatalf("expected non-zero byte alignment after readBits(8), bitCount=%d", r.bitCount) + } + got = r.readBytes(4) + idx = r.pos - 4 + buf[idx] ^= 0xFF + if got[0] != buf[idx] { + t.Fatalf("realigned readBytes returned a copy, not a zero-copy alias") + } +} + func BenchmarkReadVarUint32(b *testing.B) { r := newReader([]byte{0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0x0F, 0x8C, 0x01}) b.ResetTimer() diff --git a/stream.go b/stream.go index 3f3c069..0f0af3f 100644 --- a/stream.go +++ b/stream.go @@ -1,6 +1,7 @@ package manta import ( + "bufio" "io" "github.com/dotabuff/manta/dota" @@ -16,8 +17,15 @@ type stream struct { size uint32 } -// newStream creates a new stream from a given io.Reader +// newStream creates a new stream from a given io.Reader. If the reader does not +// already provide buffered byte access (e.g. an *os.File), it is wrapped in a +// bufio.Reader so the per-byte varint reads in the outer loop do not issue one +// read syscall per byte. In-memory readers such as *bytes.Reader already +// satisfy io.ByteReader and are left unwrapped to avoid a redundant copy. func newStream(r io.Reader) *stream { + if _, ok := r.(io.ByteReader); !ok { + r = bufio.NewReaderSize(r, buffer) + } return &stream{r, make([]byte, buffer), buffer} } diff --git a/string_table.go b/string_table.go index 0d3badf..6d6a5c4 100644 --- a/string_table.go +++ b/string_table.go @@ -105,7 +105,10 @@ func (p *Parser) onCSVCMsg_CreateStringTable(m *dota.CSVCMsg_CreateStringTable) } // Parse the items out of the string table data - items := parseStringTable(buf, m.GetNumEntries(), t.name, t.userDataFixedSize, t.userDataSizeBits, t.flags, t.varintBitCounts) + items, err := parseStringTable(buf, m.GetNumEntries(), t.name, t.userDataFixedSize, t.userDataSizeBits, t.flags, t.varintBitCounts) + if err != nil { + return err + } // Insert the items into the table for _, item := range items { @@ -144,7 +147,10 @@ func (p *Parser) onCSVCMsg_UpdateStringTable(m *dota.CSVCMsg_UpdateStringTable) } // Parse the updates out of the string table data - items := parseStringTable(m.GetStringData(), m.GetNumChangedEntries(), t.name, t.userDataFixedSize, t.userDataSizeBits, t.flags, t.varintBitCounts) + items, err := parseStringTable(m.GetStringData(), m.GetNumChangedEntries(), t.name, t.userDataFixedSize, t.userDataSizeBits, t.flags, t.varintBitCounts) + if err != nil { + return err + } // Apply the updates to the parser state for _, item := range items { @@ -177,11 +183,13 @@ func (p *Parser) onCSVCMsg_UpdateStringTable(m *dota.CSVCMsg_UpdateStringTable) } // Parse a string table data blob, returning a list of item updates. -func parseStringTable(buf []byte, numUpdates int32, name string, userDataFixed bool, userDataSizeBits int32, flags int32, varintBitCounts bool) (items []*stringTableItem) { +func parseStringTable(buf []byte, numUpdates int32, name string, userDataFixed bool, userDataSizeBits int32, flags int32, varintBitCounts bool) (items []*stringTableItem, err error) { + // Surface a decode failure instead of silently returning a partially + // populated table, matching clarity's fail-loud behaviour. On healthy + // replays this never fires. defer func() { - if err := recover(); err != nil { - _debugf("warning: unable to parse string table %s: %s", name, err) - return + if r := recover(); r != nil { + err = _errorf("unable to parse string table %s: %v", name, r) } }() @@ -199,7 +207,7 @@ func parseStringTable(buf []byte, numUpdates int32, name string, userDataFixed b // Some tables have no data if len(buf) == 0 { - return items + return items, nil } // Loop through entries in the data structure @@ -223,7 +231,12 @@ func parseStringTable(buf []byte, numUpdates int32, name string, userDataFixed b if incr { index++ } else { - index = int32(r.readVarUint32()) + 1 + // The non-increment delta is additive (relative to the running + // index), matching the S2 entity decoder (see entity.go) and + // clarity's S2StringTableEmitter. The previous absolute form + // (= varuint+1) produced wrong, non-monotonic indices for + // delta-updated tables such as ActiveModifiers. + index += int32(r.readVarUint32()) + 2 } // Some values have keys, some don't. @@ -292,5 +305,5 @@ func parseStringTable(buf []byte, numUpdates int32, name string, userDataFixed b items = append(items, &stringTableItem{index, key, value}) } - return items + return items, nil } diff --git a/string_table_test.go b/string_table_test.go index 2212af4..9078ff0 100644 --- a/string_table_test.go +++ b/string_table_test.go @@ -135,7 +135,8 @@ func TestParseStringTableCreate(t *testing.T) { assert.Equal(s.tableName, m.GetName(), s.tableName) // Parse the table data - items := parseStringTable(buf, m.GetNumEntries(), "", m.GetUserDataFixedSize(), m.GetUserDataSize(), m.GetFlags(), false) + items, err := parseStringTable(buf, m.GetNumEntries(), "", m.GetUserDataFixedSize(), m.GetUserDataSize(), m.GetFlags(), false) + assert.NoError(err) // Make sure we have the correct number of entries assert.Equal(s.itemCount, len(items), s.tableName) @@ -154,7 +155,8 @@ func TestParseStringTableUpdate(t *testing.T) { assert := assert.New(t) buf := _read_fixture("string_tables/updates/tick_03960_table_7_items_13_size_208") - items := parseStringTable(buf, 13, "", false, 0, 0, false) + items, err := parseStringTable(buf, 13, "", false, 0, 0, false) + assert.NoError(err) assert.Equal(int32(261), items[0].Index) assert.Equal("broodmother_spawn_spiderlings", items[0].Key) @@ -163,3 +165,12 @@ func TestParseStringTableUpdate(t *testing.T) { assert.Equal(int32(263), items[2].Index) assert.Equal("broodmother_incapacitating_bite", items[2].Key) } + +// TestParseStringTableTruncated verifies that a blob which decodes past the end +// of its buffer surfaces an error instead of silently returning a partial table. +func TestParseStringTableTruncated(t *testing.T) { + _, err := parseStringTable([]byte{0x07}, 1, "test", false, 0, 0, false) + if err == nil { + t.Fatal("expected an error from a truncated string table, got nil") + } +}