diff --git a/internal/command/command.go b/internal/command/command.go index 0c496ee..590ffa6 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -27,8 +27,11 @@ import ( // using the reliable package. func ParseInto[P types.ParameterView](ctx *context.Context[P], dest *types.Command[P]) error { err := readCommandHeaderInto(ctx.Reader, dest) + if err != nil { + return err + } - if dest.Type > types.SendReliableFragmentCommand { + if !types.IsKnownCommandType(dest.Type) { remaining := ctx.Reader.Max - ctx.Reader.Cursor - 1 if ctx.Config.SkipUnknownPayloads { @@ -45,30 +48,33 @@ func ParseInto[P types.ParameterView](ctx *context.Context[P], dest *types.Comma return nil } - if err != nil { - return err - } - if dest.Length < types.COMMAND_HEADER_SIZE { return errors.ErrHeaderSize } + payloadStart := ctx.Reader.Cursor + payloadLen := int(dest.Length - types.COMMAND_HEADER_SIZE) + commandEnd := payloadStart + payloadLen + if ctx.Config.SkipCommands[dest.Type] { - remaining := int(dest.Length - types.COMMAND_HEADER_SIZE) - return ctx.Reader.Skip(remaining) + return ctx.Reader.Skip(payloadLen) } err = readCommandPayloadInto(ctx, dest) if err != nil { - remaining := int(dest.Length - types.COMMAND_HEADER_SIZE) + remaining := commandEnd - ctx.Reader.Cursor + if remaining < 0 || commandEnd > ctx.Reader.Max { + return err + } if ctx.Config.SkipUnknownPayloads { return ctx.Reader.Skip(remaining) } - rest, _ := ctx.Reader.ReadBytes(remaining) - // don't fatal — just store raw for encrypted packets - dest.UnknownPayload = types.UnknownPayload{Raw: rest, Kind: dest.Type} + // Don't fatal — store the full raw command payload for encrypted or + // otherwise unsupported payloads, then advance to the next command. + dest.UnknownPayload = types.UnknownPayload{Raw: ctx.Reader.Buffer[payloadStart:commandEnd], Kind: dest.Type} + ctx.Reader.Cursor = commandEnd } emit(ctx.Hooks, dest) @@ -86,7 +92,7 @@ func readCommandHeaderInto[P types.ParameterView](r *reader.Reader, dest *types. dest.Type = types.CommandType(b) - if dest.Type > types.SendReliableFragmentCommand { + if !types.IsKnownCommandType(dest.Type) { return nil } @@ -120,7 +126,7 @@ func readCommandHeaderInto[P types.ParameterView](r *reader.Reader, dest *types. func readCommandPayloadInto[P types.ParameterView](ctx *context.Context[P], dest *types.Command[P]) error { switch dest.Type { - case types.SendUnreliableCommand: + case types.SendUnreliableCommand, types.SendUnreliableUnsequenced: _, err := ctx.Reader.ReadBytes(4) if err != nil { @@ -162,6 +168,8 @@ func readCommandPayloadInto[P types.ParameterView](ctx *context.Context[P], dest } case types.PingCommand: dest.PingPayload = struct{}{} + case types.FetchServerTimestampCommand: + dest.FetchTimestampPayload = struct{}{} case types.DisconnectCommand: dest.DisconnectPayload = struct{}{} default: @@ -180,14 +188,15 @@ func emit[P types.ParameterView](hooks *hooks.Hooks[P], dest *types.Command[P]) hooks.SyncHooks.OnCommand(*dest) } - if hooks.AsyncHooks.OnCommand == nil { + ch := hooks.AsyncHooks.OnCommand + if ch == nil || len(ch) == cap(ch) { return } s := DetachForAsync(*dest) select { - case hooks.AsyncHooks.OnCommand <- s: + case ch <- s: default: // don't block parser } @@ -202,7 +211,7 @@ func DetachForAsync[P types.ParameterView](cmd types.Command[P]) types.Command[P copy(p, s.ReliablePayload.Parameters) s.ReliablePayload.Parameters = p } - case types.SendUnreliableCommand: + case types.SendUnreliableCommand, types.SendUnreliableUnsequenced: if n := len(s.UnreliablePayload.Parameters); n > 0 { p := make([]P, n) copy(p, s.UnreliablePayload.Parameters) diff --git a/internal/command/command_internal_test.go b/internal/command/command_internal_test.go new file mode 100644 index 0000000..9d9d83a --- /dev/null +++ b/internal/command/command_internal_test.go @@ -0,0 +1,30 @@ +package command + +import ( + "testing" + + "github.com/AutoDruid/photon-parser/internal/hooks" + v18 "github.com/AutoDruid/photon-parser/internal/parameters/v18" + "github.com/AutoDruid/photon-parser/internal/types" +) + +func TestAsyncCommandEmitFullChannelDoesNotAllocate(t *testing.T) { + h := hooks.NewHooks[v18.Parameter]() + h.OnCommandAsync(types.HookOptions{Size: 1}) + h.AsyncHooks.OnCommand <- types.Command[v18.Parameter]{} + + cmd := types.Command[v18.Parameter]{ + CommandHeader: types.CommandHeader{Type: types.SendReliableCommand}, + ReliablePayload: types.Reliable[v18.Parameter]{ + Parameters: []v18.Parameter{{Header: v18.Header{ID: 1, Type: v18.Int8Type}}}, + }, + } + + allocs := testing.AllocsPerRun(100, func() { + emit(h, &cmd) + }) + + if allocs != 0 { + t.Fatalf("emit() allocations on full async channel = %v, want 0", allocs) + } +} diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 895b167..106604d 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -108,3 +108,95 @@ func TestParseSession(t *testing.T) { t.Fatalf("LoadFromWiresharkExport() failed: %v", err) } } + +func TestParseSendUnreliableUnsequencedCommand(t *testing.T) { + payload := []byte{ + 0x0b, 0x01, 0x02, 0x04, 0x00, 0x00, 0x00, 0x1d, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x3e, + 0xf3, 0x02, 0x02, 0x02, 0x00, 0x0b, 0x79, 0x01, 0x05, 0x00, 0x00, 0x00, 0x00, + } + + ctx := &context.Context[v18.Parameter]{ + Reader: reader.NewReader(payload), + Decoders: context.Decoders[v18.Parameter]{ + ParameterParser: &v18.Parameter{}, + ReliableHeaderParameterCount: &v18.ReliableHeaderParameterCountV18{}, + }, + PoolParameter: context.NewPool[v18.Parameter](10), + PoolCommand: context.NewPool[types.Command[v18.Parameter]](10), + } + + var cmd types.Command[v18.Parameter] + if err := command.ParseInto(ctx, &cmd); err != nil { + t.Fatalf("parse send unreliable unsequenced: %v", err) + } + + if cmd.Type != 0x0b { + t.Fatalf("command type: got %d, want 11", cmd.Type) + } + + if got := len(cmd.UnknownPayload.Raw); got != 0 { + t.Fatalf("unknown payload length: got %d, want 0", got) + } + + if cmd.UnreliablePayload.Type != types.OperationRequest { + t.Fatalf("unreliable payload type: got %d, want %d", cmd.UnreliablePayload.Type, types.OperationRequest) + } + + if cmd.UnreliablePayload.EventCode != 0x02 { + t.Fatalf("event code: got %d, want 2", cmd.UnreliablePayload.EventCode) + } + + if cmd.UnreliablePayload.ParameterCount != 2 { + t.Fatalf("parameter count: got %d, want 2", cmd.UnreliablePayload.ParameterCount) + } + + if got := len(cmd.UnreliablePayload.Parameters); got != 2 { + t.Fatalf("parameters length: got %d, want 2", got) + } + if cmd.UnreliablePayload.Parameters[0].Header.ID != 0x00 { + t.Fatalf("first parameter id: got %d, want 0", cmd.UnreliablePayload.Parameters[0].Header.ID) + } + if cmd.UnreliablePayload.Parameters[1].Header.ID != 0x01 { + t.Fatalf("second parameter id: got %d, want 1", cmd.UnreliablePayload.Parameters[1].Header.ID) + } +} + +func TestParseFetchServerTimestampCommand(t *testing.T) { + payload := []byte{0x0c, 0xff, 0x01, 0x04, 0x00, 0x00, 0x00, 0x0c, 0x00, 0x00, 0x00, 0x02} + + ctx := &context.Context[v18.Parameter]{ + Reader: reader.NewReader(payload), + Decoders: context.Decoders[v18.Parameter]{ + ParameterParser: &v18.Parameter{}, + ReliableHeaderParameterCount: &v18.ReliableHeaderParameterCountV18{}, + }, + PoolParameter: context.NewPool[v18.Parameter](10), + PoolCommand: context.NewPool[types.Command[v18.Parameter]](10), + } + + var cmd types.Command[v18.Parameter] + if err := command.ParseInto(ctx, &cmd); err != nil { + t.Fatalf("parse fetch server timestamp: %v", err) + } + + if cmd.Type != 0x0c { + t.Fatalf("command type: got %d, want 12", cmd.Type) + } + + if got := len(cmd.UnknownPayload.Raw); got != 0 { + t.Fatalf("unknown payload length: got %d, want 0", got) + } + + if cmd.ChannelID != 0xff { + t.Fatalf("channel id: got %d, want 255", cmd.ChannelID) + } + + if cmd.Length != 12 { + t.Fatalf("length: got %d, want 12", cmd.Length) + } + + if cmd.ReliableSequenceNumber != 2 { + t.Fatalf("reliable sequence number: got %d, want 2", cmd.ReliableSequenceNumber) + } +} diff --git a/internal/context/pools.go b/internal/context/pools.go index 6fdcd2c..9f691d6 100644 --- a/internal/context/pools.go +++ b/internal/context/pools.go @@ -20,9 +20,7 @@ func NewPool[P any](maxCap int) *Pool[P] { return &Pool[P]{ pool: sync.Pool{ New: func() any { - return &PooledSlice[P]{ - Items: make([]P, 0, maxCap), - } + return &PooledSlice[P]{} }, }, } diff --git a/internal/context/pools_test.go b/internal/context/pools_test.go new file mode 100644 index 0000000..1c173e5 --- /dev/null +++ b/internal/context/pools_test.go @@ -0,0 +1,14 @@ +package context + +import "testing" + +func TestPoolGetAllocatesToRequestedSizeOnFirstUse(t *testing.T) { + t.Parallel() + + pool := NewPool[int](64) + items := pool.Get(1) + + if cap(items.Items) != 1 { + t.Fatalf("cap(items.Items) = %d, want 1", cap(items.Items)) + } +} diff --git a/internal/parameters/v18/accessor_scalars.go b/internal/parameters/v18/accessor_scalars.go index 189be32..486fddc 100644 --- a/internal/parameters/v18/accessor_scalars.go +++ b/internal/parameters/v18/accessor_scalars.go @@ -10,6 +10,9 @@ func (p Parameter) StringValue() (string, bool) { } func (p Parameter) Float32Value() (float32, bool) { + if p.Kind == FloatZeroType { + return 0, true + } if p.Kind != Float32Type { return 0, false } @@ -17,6 +20,9 @@ func (p Parameter) Float32Value() (float32, bool) { } func (p Parameter) Float64Value() (float64, bool) { + if p.Kind == DoubleZeroType { + return 0, true + } if p.Kind != Float64Type { return 0, false } diff --git a/internal/parameters/v18/parameters.go b/internal/parameters/v18/parameters.go index 23008f4..a685f0a 100644 --- a/internal/parameters/v18/parameters.go +++ b/internal/parameters/v18/parameters.go @@ -176,7 +176,7 @@ func scanPayload(reader *reader.Reader, dest *Value) error { dest.Num = 1 case BooleanFalseType: dest.Num = 0 - case IntZeroType, ShortZeroType, LongZeroType, ByteZeroType: + case IntZeroType, ShortZeroType, LongZeroType, FloatZeroType, DoubleZeroType, ByteZeroType: break case ArrayType: err = scanArray(reader, dest) diff --git a/internal/parameters/v18/scan_scalars_test.go b/internal/parameters/v18/scan_scalars_test.go index c2b58c7..ee28e54 100644 --- a/internal/parameters/v18/scan_scalars_test.go +++ b/internal/parameters/v18/scan_scalars_test.go @@ -116,6 +116,61 @@ func TestParseFloat32ParameterAndAccessor(t *testing.T) { } } +func TestParseZeroFloatParametersAndAccessors(t *testing.T) { + tests := []struct { + name string + input []byte + wantID uint8 + wantKind v18.ParameterType + wantCursor int + }{ + { + name: "float32 zero shorthand", + input: []byte{0x01, byte(v18.FloatZeroType)}, + wantID: 1, + wantKind: v18.FloatZeroType, + wantCursor: 2, + }, + { + name: "float64 zero shorthand", + input: []byte{0x02, byte(v18.DoubleZeroType)}, + wantID: 2, + wantKind: v18.DoubleZeroType, + wantCursor: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := reader.NewReader(tt.input) + var parser v18.Parameter + var got v18.Parameter + + if err := parser.ParseInto(r, nil, &got); err != nil { + t.Fatalf("Parse() error = %v", err) + } + if got.ID() != tt.wantID { + t.Errorf("ID() = %d, want %d", got.ID(), tt.wantID) + } + if got.Kind != tt.wantKind { + t.Errorf("Kind = %d, want %d", got.Kind, tt.wantKind) + } + if r.Cursor != tt.wantCursor { + t.Errorf("Cursor = %d, want %d", r.Cursor, tt.wantCursor) + } + if tt.wantKind == v18.FloatZeroType { + if value, ok := got.Float32Value(); !ok || value != 0 { + t.Errorf("Float32Value() = %v, %v; want 0, true", value, ok) + } + } else { + if value, ok := got.Float64Value(); !ok || value != 0 { + t.Errorf("Float64Value() = %v, %v; want 0, true", value, ok) + } + } + }) + } +} + func TestParseInt8ParameterAndAccessor(t *testing.T) { tests := []struct { name string diff --git a/internal/parameters/v18/v18.go b/internal/parameters/v18/v18.go index 5313057..cc4de45 100644 --- a/internal/parameters/v18/v18.go +++ b/internal/parameters/v18/v18.go @@ -56,8 +56,10 @@ func (p Parameter) MarshalJSON() ([]byte, error) { out.Decoded, _ = p.IntValue() case StringType: out.Decoded, _ = p.StringValue() - case Float32Type: + case Float32Type, FloatZeroType: out.Decoded, _ = p.Float32Value() + case Float64Type, DoubleZeroType: + out.Decoded, _ = p.Float64Value() case BooleanType: out.Decoded, _ = p.BooleanValue() case Float32ArrayType: diff --git a/internal/session/session.go b/internal/session/session.go index c3fc3f7..153af7a 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -38,7 +38,7 @@ func ParseInto[P types.ParameterView](ctx *context.Context[P], dest *types.Sessi return err } - if dest.Commands[i].Type > types.SendReliableFragmentCommand { + if !types.IsKnownCommandType(dest.Commands[i].Type) { break } } @@ -90,7 +90,8 @@ func emit[P types.ParameterView](hooks *hooks.Hooks[P], dest *types.Session[P]) hooks.SyncHooks.OnSession(*dest) } - if hooks.AsyncHooks.OnSession == nil { + ch := hooks.AsyncHooks.OnSession + if ch == nil || len(ch) == cap(ch) { return } @@ -105,7 +106,7 @@ func emit[P types.ParameterView](hooks *hooks.Hooks[P], dest *types.Session[P]) } select { - case hooks.AsyncHooks.OnSession <- s: + case ch <- s: default: } } diff --git a/internal/session/session_internal_test.go b/internal/session/session_internal_test.go new file mode 100644 index 0000000..e0fab15 --- /dev/null +++ b/internal/session/session_internal_test.go @@ -0,0 +1,34 @@ +package session + +import ( + "testing" + + "github.com/AutoDruid/photon-parser/internal/hooks" + v18 "github.com/AutoDruid/photon-parser/internal/parameters/v18" + "github.com/AutoDruid/photon-parser/internal/types" +) + +func TestAsyncSessionEmitFullChannelDoesNotAllocate(t *testing.T) { + h := hooks.NewHooks[v18.Parameter]() + h.OnSessionAsync(types.HookOptions{Size: 1}) + h.AsyncHooks.OnSession <- types.Session[v18.Parameter]{} + + sess := types.Session[v18.Parameter]{ + Commands: []types.Command[v18.Parameter]{ + { + CommandHeader: types.CommandHeader{Type: types.SendReliableCommand}, + ReliablePayload: types.Reliable[v18.Parameter]{ + Parameters: []v18.Parameter{{Header: v18.Header{ID: 1, Type: v18.Int8Type}}}, + }, + }, + }, + } + + allocs := testing.AllocsPerRun(100, func() { + emit(h, &sess) + }) + + if allocs != 0 { + t.Fatalf("emit() allocations on full async channel = %v, want 0", allocs) + } +} diff --git a/internal/types/command.go b/internal/types/command.go index 92169ea..8ca7509 100644 --- a/internal/types/command.go +++ b/internal/types/command.go @@ -12,6 +12,8 @@ const ( SendReliableCommand CommandType = 0x06 SendUnreliableCommand CommandType = 0x07 SendReliableFragmentCommand CommandType = 0x08 + SendUnreliableUnsequenced CommandType = 0x0B + FetchServerTimestampCommand CommandType = 0x0C ) // COMMAND_HEADER_SIZE is the size in bytes of a command header (12 bytes). @@ -38,9 +40,28 @@ type Command[P ParameterView] struct { ConnectPayload Connect `json:"connect_payload"` UnknownPayload UnknownPayload `json:"unknown_payload"` PingPayload struct{} `json:"ping_payload"` + FetchTimestampPayload struct{} `json:"fetch_timestamp_payload"` DisconnectPayload struct{} `json:"disconnect_payload"` } +func IsKnownCommandType(t CommandType) bool { + switch t { + case AcknowledgeCommand, + ConnectCommand, + VerifyConnectCommand, + DisconnectCommand, + PingCommand, + SendReliableCommand, + SendUnreliableCommand, + SendReliableFragmentCommand, + SendUnreliableUnsequenced, + FetchServerTimestampCommand: + return true + default: + return false + } +} + type Connect struct { Mtu uint32 `json:"mtu"` WindowSize uint32 `json:"window_size"` diff --git a/options.go b/options.go index c072cc7..8f53d4c 100644 --- a/options.go +++ b/options.go @@ -10,32 +10,7 @@ type Option func(*types.Config) // event codes skipped. It is applied automatically by [NewParserV16] and // [NewParserV18] before any caller-supplied Options are evaluated. func defaultConfig() types.Config { - - skipCommands := map[types.CommandType]bool{ - types.SendReliableCommand: false, - types.SendUnreliableCommand: false, - types.SendReliableFragmentCommand: false, - types.AcknowledgeCommand: false, - types.ConnectCommand: false, - types.VerifyConnectCommand: false, - types.PingCommand: false, - types.DisconnectCommand: false, - } - - skipTargetEventCodes := map[types.MessageType]bool{ - types.OperationRequest: false, - types.OperationResponse: false, - types.OtherOperationResponse: false, - types.EventDataType: false, - types.ExchangeKeys: false, - } - - return types.Config{ - SkipUnknownPayloads: false, - SkipParameterParsing: false, - SkipCommands: skipCommands, - SkipTargetEventCodes: skipTargetEventCodes, - } + return types.Config{} } // SkipUnknownPayloads controls whether the parser silently skips command @@ -78,6 +53,12 @@ func SkipParameterParsing(skip bool) Option { // ) func SkipCommands(commands ...types.CommandType) Option { return func(c *types.Config) { + if len(commands) == 0 { + return + } + if c.SkipCommands == nil { + c.SkipCommands = make(map[types.CommandType]bool, len(commands)) + } for _, t := range commands { c.SkipCommands[t] = true } @@ -97,6 +78,12 @@ func SkipCommands(commands ...types.CommandType) Option { // ) func SkipTargetEventCodes(codes ...types.MessageType) Option { return func(c *types.Config) { + if len(codes) == 0 { + return + } + if c.SkipTargetEventCodes == nil { + c.SkipTargetEventCodes = make(map[types.MessageType]bool, len(codes)) + } for _, code := range codes { c.SkipTargetEventCodes[code] = true } diff --git a/options_internal_test.go b/options_internal_test.go new file mode 100644 index 0000000..48e2305 --- /dev/null +++ b/options_internal_test.go @@ -0,0 +1,13 @@ +package photon + +import "testing" + +func TestDefaultConfigDoesNotAllocate(t *testing.T) { + allocs := testing.AllocsPerRun(100, func() { + _ = defaultConfig() + }) + + if allocs != 0 { + t.Fatalf("defaultConfig() allocations = %v, want 0", allocs) + } +} diff --git a/parser_test.go b/parser_test.go index ed6d51a..2047f39 100644 --- a/parser_test.go +++ b/parser_test.go @@ -381,6 +381,34 @@ func TestParserOnASinglePacketVersion18(t *testing.T) { } } +func TestKnownCommandPayloadRecoveryDoesNotOverSkipNextCommand(t *testing.T) { + payload := []byte{ + 0x00, 0x00, 0x00, 0x02, // session: peer ID, CRC disabled, 2 commands + 0x00, 0x00, 0x00, 0x01, // timestamp + 0x00, 0x00, 0x00, 0x00, // challenge + + 0x06, 0x00, 0x00, 0x00, // send reliable header prefix + 0x00, 0x00, 0x00, 0x12, // command length: 12-byte header + 6-byte payload + 0x00, 0x00, 0x00, 0x01, // reliable sequence number + 0xf3, 0x02, 0x01, 0x01, // operation request with one parameter + 0x00, 0xff, // unsupported parameter type consumes the full payload before failing + + 0x05, 0xff, 0x01, 0x04, // ping command header prefix + 0x00, 0x00, 0x00, 0x0c, // command length: header only + 0x00, 0x00, 0x00, 0x02, // reliable sequence number + } + + parser := photon.NewParserV18(photon.SkipUnknownPayloads(true)) + var session photon.SessionV18 + + if err := parser.ParsePacketInto(payload, &session); err != nil { + t.Fatalf("ParsePacketInto() error = %v", err) + } + if got := session.Commands[1].Type; got != photon.PingCommand { + t.Fatalf("second command type = %v, want %v", got, photon.PingCommand) + } +} + func BenchmarkParserOn343PacketsVersion18(b *testing.B) { parser := photon.NewParserV18() frames := loadCapturesB("./tests/dataset/v18/1.json", b) diff --git a/types.go b/types.go index bfd90ef..9163f07 100644 --- a/types.go +++ b/types.go @@ -60,6 +60,8 @@ const ( SendReliableCommand = types.SendReliableCommand SendUnreliableCommand = types.SendUnreliableCommand SendReliableFragmentCommand = types.SendReliableFragmentCommand + SendUnreliableUnsequenced = types.SendUnreliableUnsequenced + FetchServerTimestampCommand = types.FetchServerTimestampCommand ) // Reliable payload message kinds (ReliableHeader.Type).