Skip to content

Commit 3da8b43

Browse files
committed
Merge origin/master into pr-663
2 parents 7a893dd + 422d96d commit 3da8b43

13 files changed

Lines changed: 1253 additions & 141 deletions

File tree

‎pkg/demoinfocs/demoinfocs_test.go‎

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"os"
1111
"regexp"
1212
"runtime"
13+
"slices"
1314
"strings"
1415
"sync"
1516
"sync/atomic"
@@ -33,6 +34,12 @@ const (
3334
demSetPathS2 = csDemosPath + "/s2"
3435
s2DemPath = demSetPathS2 + "/s2.dem"
3536
s2POVDemPath = demSetPathS2 + "/pov.dem"
37+
38+
// s2DMDemPath is a CS2 deathmatch demo. Unlike the rest of the s2 set,
39+
// its game rules use the CCSGameModeRules_Deathmatch polymorphic pointer
40+
// type, which has fields of its own — the only way to exercise the
41+
// polymorphic type's sub-fields end to end.
42+
s2DMDemPath = demSetPathS2 + "/deathmatch_sv.dem"
3643
)
3744

3845
var concurrentDemos = flag.Int("concurrentdemos", 2, "The `number` of current demos")
@@ -421,6 +428,133 @@ func TestDemoSetS2(t *testing.T) {
421428
testDemoSet(t, demSetPathS2)
422429
}
423430

431+
// TestPolymorphicGameModeRules asserts that the polymorphic m_pGameModeRules
432+
// pointer on the game-rules entity is tracked per entity and that its
433+
// activation state is readable through the poly-aware name resolution.
434+
// See https://github.com/markus-wa/demoinfocs-golang/pull/654
435+
//
436+
// Note: all demos in the test set run game mode CCSGameModeRules_Noop, which
437+
// has no fields of its own, so the active type's sub-fields can't be asserted
438+
// here — the activation bool is what's observable with this data.
439+
func TestPolymorphicGameModeRules(t *testing.T) {
440+
t.Parallel()
441+
442+
if testing.Short() {
443+
t.Skip("skipping test due to -short flag")
444+
}
445+
446+
f, err := os.Open(s2DemPath)
447+
assert.NoError(t, err, "error opening demo %q", s2DemPath)
448+
449+
defer mustClose(t, f)
450+
451+
p := demoinfocs.NewParser(f)
452+
453+
var activated bool
454+
455+
p.RegisterEventHandler(func(e events.RoundStart) {
456+
entity := p.GameState().Rules().Entity()
457+
if entity == nil {
458+
return
459+
}
460+
461+
val, ok := entity.PropertyValue("m_pGameRules.m_pGameModeRules")
462+
if ok {
463+
activated = activated || val.BoolVal()
464+
}
465+
})
466+
467+
assert.NoError(t, p.ParseToEnd())
468+
assert.True(t, activated, "expected the m_pGameModeRules polymorphic pointer to be activated and readable on the game-rules entity")
469+
}
470+
471+
// TestPolymorphicGameModeRulesDeathmatch asserts that a polymorphic pointer
472+
// whose active type has its own sub-fields works end to end: the
473+
// CCSGameModeRules_Deathmatch sub-fields of m_pGameRules.m_pGameModeRules must
474+
// resolve by name and be enumerated by Properties() (regression for the
475+
// getFieldPaths nested-table traversal fix), while fields of other game modes
476+
// must not resolve.
477+
//
478+
// See https://github.com/markus-wa/demoinfocs-golang/pull/654
479+
func TestPolymorphicGameModeRulesDeathmatch(t *testing.T) {
480+
t.Parallel()
481+
482+
if testing.Short() {
483+
t.Skip("skipping test due to -short flag")
484+
}
485+
486+
f, err := os.Open(s2DMDemPath)
487+
assert.NoError(t, err, "error opening demo %q", s2DMDemPath)
488+
489+
defer mustClose(t, f)
490+
491+
p := demoinfocs.NewParser(f)
492+
493+
const prefix = "m_pGameRules.m_pGameModeRules."
494+
495+
wantProps := map[string]any{
496+
prefix + "m_flDMBonusStartTime": float32(44.67882),
497+
prefix + "m_flDMBonusTimeLength": float32(0),
498+
prefix + "m_sDMBonusWeapon": "",
499+
}
500+
501+
var checked bool
502+
503+
p.RegisterEventHandler(func(e events.RoundStart) {
504+
if checked {
505+
return
506+
}
507+
checked = true
508+
509+
entity := p.GameState().Rules().Entity()
510+
if !assert.NotNil(t, entity, "rules entity not found") {
511+
return
512+
}
513+
514+
// The active type's sub-fields must resolve by name.
515+
for name, want := range wantProps {
516+
prop := entity.Property(name)
517+
if !assert.NotNil(t, prop, "property %q should exist under the active deathmatch type", name) {
518+
continue
519+
}
520+
521+
got := prop.Value().Any
522+
switch want.(type) {
523+
case float32:
524+
assert.InDelta(t, want, got, 0.001, "property %q", name)
525+
default:
526+
assert.Equal(t, want, got, "property %q", name)
527+
}
528+
}
529+
530+
// The active type's sub-fields must be enumerated by Properties(),
531+
// not just resolvable by name.
532+
var gotProps []string
533+
for _, prop := range entity.Properties() {
534+
if strings.HasPrefix(prop.Name(), prefix) {
535+
gotProps = append(gotProps, prop.Name())
536+
}
537+
}
538+
assert.ElementsMatch(t, sortedKeys(wantProps), gotProps, "Properties() should enumerate the active deathmatch type's sub-fields")
539+
540+
// Fields of other game modes must not resolve under the deathmatch type.
541+
assert.Nil(t, entity.Property(prefix+"m_nHalvesPlayed"), "competitive-only field must not resolve under the deathmatch type")
542+
})
543+
544+
assert.NoError(t, p.ParseToEnd())
545+
assert.True(t, checked, "expected a RoundStart event to be parsed")
546+
}
547+
548+
func sortedKeys[V any](m map[string]V) []string {
549+
keys := make([]string, 0, len(m))
550+
for k := range m {
551+
keys = append(keys, k)
552+
}
553+
slices.Sort(keys)
554+
555+
return keys
556+
}
557+
424558
func BenchmarkDemoInfoCs(b *testing.B) {
425559
for i := 0; i < b.N; i++ {
426560
parseDefaultDemo(b)

‎pkg/demoinfocs/sendtables/sendtablescs2/class.go‎

Lines changed: 76 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
// Package sendtablescs2 implements the CS2 send-tables (FlattenedSerializer) parser.
12
package sendtablescs2
23

34
import (
@@ -17,6 +18,7 @@ type class struct {
1718
classId int32 //nolint:revive
1819
name string
1920
serializer *serializer
21+
polyCount int // size of the per-entity polySerializers slice needed by entities of this class; 0 if no polymorphic pointer fields are reachable
2022
createdHandlers []st.EntityCreatedHandler
2123
fpNameCache *fpNameTreeCache
2224
// fpFlatCache provides O(1) lookup for the common case: depth ≤ 3 and
@@ -56,7 +58,12 @@ func (c *class) collectFieldsEntries(fields []*field, prefix string) []string {
5658
paths := make([]string, 0)
5759

5860
for _, field := range fields {
59-
if field.serializer != nil {
61+
if len(field.polyTypes) > 0 { //nolint:gocritic
62+
// Polymorphic pointer: each candidate serializer contributes its
63+
// own sub-fields. The active type varies per entity, so enumerate
64+
// every alternative (the field's own serializer is polyTypes[0]).
65+
paths = append(paths, c.collectPolyFieldEntries(field, prefix)...)
66+
} else if field.serializer != nil {
6067
subPaths := c.collectFieldsEntries(field.serializer.fields, prefix+field.serializer.name+".")
6168
paths = append(paths, subPaths...)
6269
} else {
@@ -67,6 +74,29 @@ func (c *class) collectFieldsEntries(fields []*field, prefix string) []string {
6774
return paths
6875
}
6976

77+
// collectPolyFieldEntries enumerates the property entries of every serializer a
78+
// polymorphic pointer field could activate, deduplicated. The active type varies
79+
// per entity, so all alternatives must be listed.
80+
func (c *class) collectPolyFieldEntries(f *field, prefix string) []string {
81+
paths := make([]string, 0)
82+
seen := make(map[string]bool)
83+
84+
for _, ser := range f.polyTypes {
85+
if ser == nil {
86+
continue
87+
}
88+
89+
for _, sub := range c.collectFieldsEntries(ser.fields, prefix+ser.name+".") {
90+
if !seen[sub] {
91+
seen[sub] = true
92+
paths = append(paths, sub)
93+
}
94+
}
95+
}
96+
97+
return paths
98+
}
99+
70100
// fpFlatKey encodes a field path as a uint64 for O(1) map lookup.
71101
// Returns (key, true) when depth ≤ 3 and all components fit in 14 bits.
72102
// Falls back to the tree cache otherwise.
@@ -91,57 +121,65 @@ func fpFlatKey(fp *fieldPath) (uint64, bool) {
91121
return key, true
92122
}
93123

94-
func (c *class) getNameForFieldPath(fp *fieldPath) string {
95-
if key, ok := fpFlatKey(fp); ok {
96-
if name, hit := c.fpFlatCache[key]; hit {
97-
return name
98-
}
99-
100-
name := strings.Join(c.serializer.getNameForFieldPath(fp, 0), ".")
101-
c.fpFlatCache[key] = name
124+
//nolint:nestif
125+
func (c *class) getNameForFieldPath(fp *fieldPath, ps []*serializer) string {
126+
if ps == nil {
127+
// No polymorphic fields: use the shared class-level caches.
128+
if key, ok := fpFlatKey(fp); ok {
129+
if name, hit := c.fpFlatCache[key]; hit {
130+
return name
131+
}
102132

103-
return name
104-
}
133+
name := strings.Join(c.serializer.getNameForFieldPath(fp, 0, nil), ".")
134+
c.fpFlatCache[key] = name
105135

106-
// Slow path: deep or large-component path — use the pointer tree.
107-
currentCacheNode := c.fpNameCache
136+
return name
137+
}
108138

109-
for i := 0; i <= fp.last; i++ {
110-
pos := fp.path[i]
111-
if pos >= len(currentCacheNode.next) {
112-
needed := pos + 1
113-
if cap(currentCacheNode.next) >= needed {
114-
currentCacheNode.next = currentCacheNode.next[:needed]
115-
} else {
116-
newCap := needed * 2
117-
if newCap < 8 {
118-
newCap = 8
139+
// Slow path: deep or large-component path — use the pointer tree.
140+
currentCacheNode := c.fpNameCache
141+
142+
for i := 0; i <= fp.last; i++ {
143+
pos := fp.path[i]
144+
if pos >= len(currentCacheNode.next) {
145+
needed := pos + 1
146+
if cap(currentCacheNode.next) >= needed {
147+
currentCacheNode.next = currentCacheNode.next[:needed]
148+
} else {
149+
newCap := needed * 2
150+
if newCap < 8 {
151+
newCap = 8
152+
}
153+
154+
newNext := make([]*fpNameTreeCache, needed, newCap)
155+
copy(newNext, currentCacheNode.next)
156+
currentCacheNode.next = newNext
119157
}
158+
}
120159

121-
newNext := make([]*fpNameTreeCache, needed, newCap)
122-
copy(newNext, currentCacheNode.next)
123-
currentCacheNode.next = newNext
160+
if currentCacheNode.next[pos] == nil {
161+
currentCacheNode.next[pos] = &fpNameTreeCache{}
124162
}
125-
}
126163

127-
if currentCacheNode.next[pos] == nil {
128-
currentCacheNode.next[pos] = &fpNameTreeCache{}
164+
currentCacheNode = currentCacheNode.next[pos]
129165
}
130166

131-
currentCacheNode = currentCacheNode.next[pos]
132-
}
167+
if currentCacheNode.name == "" {
168+
currentCacheNode.name = strings.Join(c.serializer.getNameForFieldPath(fp, 0, nil), ".")
169+
}
133170

134-
if currentCacheNode.name == "" {
135-
currentCacheNode.name = strings.Join(c.serializer.getNameForFieldPath(fp, 0), ".")
171+
return currentCacheNode.name
136172
}
137173

138-
return currentCacheNode.name
174+
// Polymorphic entity: skip class-level caches because the active type
175+
// varies per entity and the same field path can resolve to different names.
176+
return strings.Join(c.serializer.getNameForFieldPath(fp, 0, ps), ".")
139177
}
140178

141-
func (c *class) getFieldPathForName(fp *fieldPath, name string) bool {
142-
return c.serializer.getFieldPathForName(fp, name)
179+
func (c *class) getFieldPathForName(fp *fieldPath, name string, ps []*serializer) bool {
180+
return c.serializer.getFieldPathForName(fp, name, ps)
143181
}
144182

145-
func (c *class) getFieldPaths(fp *fieldPath, state *fieldState) []*fieldPath {
146-
return c.serializer.getFieldPaths(fp, state)
183+
func (c *class) getFieldPaths(fp *fieldPath, state *fieldState, ps []*serializer) []*fieldPath {
184+
return c.serializer.getFieldPaths(fp, state, ps)
147185
}

0 commit comments

Comments
 (0)