diff --git a/.mise/config.toml b/.mise/config.toml index d2b81ed..0bf99ee 100644 --- a/.mise/config.toml +++ b/.mise/config.toml @@ -20,4 +20,8 @@ run = "golangci-lint run ./..." [tasks.format] description = "Format the code" -run = "go fmt ./..." \ No newline at end of file +run = "go fmt ./..." + +[tasks.validate] +description = "Validate the code" +run = "mise lint && mise format && mise test && mise build" \ No newline at end of file diff --git a/README.md b/README.md index e69de29..7bf7888 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,125 @@ +# photon-parser + +## Introduction + +**photon-parser** is a Go library that decodes Photon session envelopes and command payloads from raw bytes—useful for inspecting captures, learning how the wire format is structured, and building tools around Photon traffic. This project is **explanatory and educational**: it documents and parses protocol data for understanding; it does **not** endorse any particular use, and **maintainers are not responsible** for how you apply it (including compliance, game or service terms, or any risk you take when handling real traffic). The library supports **protocol version 16** and **protocol version 18** (distinct parameter layouts and reliable-header parameter counting). + +## How it works + +Parsing is **sequential**: an internal `Reader` holds the input buffer and a **cursor** that advances as fields are read—there is no upfront full decode into a giant tree. **Lazy access** shows up in how you read values after parse: scalars are read when you call accessor methods; **arrays, dictionaries, and other composite parameters** are exposed as Go `iter` sequences (or similar pull-based access) so underlying bytes are processed as you iterate, instead of allocating everything up front. + +## Examples + +### `ParseV18` + +```go +import "github.com/AutoDruid/photon-parser" + +func exampleParseV18(payload []byte) error { + sess, err := photon.ParseV18(payload) + if err != nil { + return err + } + _ = sess // use decoded session (commands, headers, …) + return nil +} +``` + +### `ParsePacket` + +Use a **reusable parser** when you want to attach hooks (below) or parse many packets with the same protocol version. + +```go +import "github.com/AutoDruid/photon-parser" + +func exampleParsePacket(payload []byte) error { + p := photon.NewV18() + sess, err := p.ParsePacket(payload) + if err != nil { + return err + } + _ = sess + return nil +} +``` + +### `OnEventData` + +Register a callback for **event data** reliable messages (the common path for server-raised events). It runs during `ParsePacket` when a matching reliable payload is decoded. + +```go +import "github.com/AutoDruid/photon-parser" + +func exampleOnEventData(payload []byte) error { + p := photon.NewV18() + p.OnEventData(func(msg photon.ReliableV18) { + _ = msg.EventCode + _ = msg.Parameters + }) + _, err := p.ParsePacket(payload) + return err +} +``` + +`OnOperationRequest`, `OnOperationResponse`, and `OnOtherOperationResponse` are the same pattern: register the hook, then call `ParsePacket`. + +### `OnSessionSync` + +```go +import "github.com/AutoDruid/photon-parser" + +func exampleOnSessionSync(payload []byte) error { + p := photon.NewV18() + p.OnSessionSync(func(s photon.Session) { + _ = s.CommandCount + }) + _, err := p.ParsePacket(payload) + return err +} +``` + +`OnCommandSync` and `OnParameterSync` follow the same pattern: register a `func(Command)` or `func(ParameterV16|ParameterV18)` before calling `ParsePacket`. + +### `OnSessionAsync` + +```go +import ( + "time" + + "github.com/AutoDruid/photon-parser" +) + +func exampleOnSessionAsync(payload []byte) error { + p := photon.NewV18() + ch := p.OnSessionAsync(photon.HookOptions{Size: 8}) + defer p.Close() + + go func() { + _, _ = p.ParsePacket(payload) + }() + + select { + case s := <-ch: + _ = s.Timestamp + case <-time.After(time.Second): + } + return nil +} +``` + +`OnCommandAsync` and `OnParameterAsync` are analogous: they return `<-chan Command` or `<-chan P` with the same `HookOptions` channel sizing. + +## Local development + +This repo pins Go and tooling in `.mise/config.toml`. With [mise](https://mise.jdx.dev/) installed: + +1. Clone the repository and `cd` into it. +2. Run `mise install` in the project root so the pinned **Go** and **golangci-lint** versions are available. +3. Use the defined tasks (run `mise tasks` to list them) + +From any subdirectory, mise resolves config upward; staying under the repo root ensures these tasks use the pinned toolchain. + +## Contributing + +Contributions are welcome. **Open an issue** to describe a bug, a protocol gap, or a feature you have in mind so maintainers can discuss scope and approach. When you are ready to implement a fix or enhancement, **resolve it with a pull request** that references the issue and keeps the change focused and easy to review. + diff --git a/internal/command/command.go b/internal/command/command.go index 69eddbe..7185d7b 100644 --- a/internal/command/command.go +++ b/internal/command/command.go @@ -64,18 +64,18 @@ func Parse[P types.ParameterView](ctx *context.Context[P], out *types.Command) e out.Payload = parsed } - cmd.emit(ctx.Reader, ctx.Hooks) + emit(ctx.Hooks, out) return nil } -func (c Command[P]) emit(r *reader.Reader, hooks *hooks.Hooks[P]) { +func emit[P types.ParameterView](hooks *hooks.Hooks[P], out *types.Command) { if hooks == nil { return } if hooks.SyncHooks.OnCommand != nil { - hooks.SyncHooks.OnCommand(c.Command) + hooks.SyncHooks.OnCommand(*out) } if hooks.AsyncHooks.OnCommand == nil { @@ -83,7 +83,7 @@ func (c Command[P]) emit(r *reader.Reader, hooks *hooks.Hooks[P]) { } select { - case hooks.AsyncHooks.OnCommand <- c.Command: + case hooks.AsyncHooks.OnCommand <- *out: default: // don't block parser } diff --git a/internal/command/reliable/reliable.go b/internal/command/reliable/reliable.go index 545156d..5ef3150 100644 --- a/internal/command/reliable/reliable.go +++ b/internal/command/reliable/reliable.go @@ -5,6 +5,7 @@ import ( "github.com/AutoDruid/photon-parser/internal/context" "github.com/AutoDruid/photon-parser/internal/errors" + "github.com/AutoDruid/photon-parser/internal/hooks" "github.com/AutoDruid/photon-parser/internal/types" ) @@ -13,34 +14,11 @@ const HEADER_SIZE = 5 const READED_HEADER_SIZE = 14 -// Type represents a Photon reliable message type. -type Type uint8 - -// Photon Protocol reliable message types. -// These define the different kinds of reliable messages that can be exchanged. -const ( - OperationRequest Type = 0x02 // Client requests an operation - OperationResponse Type = 0x07 // Server responds to an operation - OtherOperationResponse Type = 0x03 // Alternative response format - EventDataType Type = 0x04 // Server sends an event to client - ExchangeKeys Type = 0x06 // Key exchange for encryption -) - -// Header represents the reliable message header. -// This appears at the start of the payload in SendReliable commands. -type Header struct { - Signature uint8 `json:"signature"` // Message signature (typically 0xF3) - Type Type `json:"type"` // Message type (operation, event, etc.) - EventCode uint8 `json:"event_code"` // Operation/event code (application-specific) - ParameterCount int `json:"parameter_count"` // Number of parameters following this header -} - // Reliable represents a complete reliable message with header and parameters. // Parameters contain the actual game data as key-value pairs where each // parameter has an ID, type, and value. type Reliable[P types.ParameterView] struct { - Header - Parameters []P // Slice of decoded parameters + types.Reliable[P] } // ParseFromReader parses a Photon reliable message from a parser.Reader. @@ -60,11 +38,11 @@ func Parse[P types.ParameterView](ctx *context.Context[P], length uint32) (*Reli return nil, err } - if header.Type >= ExchangeKeys { + if header.Type >= types.ExchangeKeys { return nil, nil } - reliable.Header = header + reliable.ReliableHeader = header if reliable.Signature != 0xF3 { return nil, errors.ErrEncryptedPacket @@ -79,61 +57,73 @@ func Parse[P types.ParameterView](ctx *context.Context[P], length uint32) (*Reli } } + emit(ctx.Hooks, &reliable) + return &reliable, nil } -func (r *Reliable[P]) parseHeader(ctx *context.Context[P], length uint32) (Header, error) { +func emit[P types.ParameterView](hooks *hooks.Hooks[P], out *Reliable[P]) { + if hooks == nil { + return + } + + if hooks.OnEvents[out.Type] != nil { + hooks.OnEvents[out.Type](out.Reliable) + } +} + +func (r *Reliable[P]) parseHeader(ctx *context.Context[P], length uint32) (types.ReliableHeader, error) { var err error - var header Header + var header types.ReliableHeader header.Signature, err = ctx.Reader.ReadUInt8() if err != nil { - return Header{}, err + return types.ReliableHeader{}, err } b, err := ctx.Reader.ReadUInt8() if err != nil { - return Header{}, err + return types.ReliableHeader{}, err } - header.Type = Type(b) + header.Type = types.Type(b) switch header.Type { - case OperationResponse, OtherOperationResponse: + case types.OperationResponse, types.OtherOperationResponse: header.EventCode, err = ctx.Reader.ReadUInt8() if err != nil { - return Header{}, err + return types.ReliableHeader{}, err } //Return code _, err = ctx.Reader.ReadInt16(binary.LittleEndian) if err != nil { - return Header{}, err + return types.ReliableHeader{}, err } //Read debug msg _, err = ctx.Reader.ReadByte() if err != nil { - return Header{}, err + return types.ReliableHeader{}, err } - case EventDataType, OperationRequest: + case types.EventDataType, types.OperationRequest: header.EventCode, err = ctx.Reader.ReadUInt8() if err != nil { - return Header{}, err + return types.ReliableHeader{}, err } default: _, err = ctx.Reader.ReadBytes(int(length) - READED_HEADER_SIZE) if err != nil { - return Header{}, err + return types.ReliableHeader{}, err } return header, nil } header.ParameterCount, err = ctx.Decoders.ReliableHeaderParameterCount.Count(ctx.Reader) if err != nil { - return Header{}, err + return types.ReliableHeader{}, err } return header, nil diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index 38fe026..3886bf9 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -17,6 +17,7 @@ func NewHooks[P types.ParameterView]() *Hooks[P] { SyncHooks: types.SyncHooks[P]{ OnSession: nil, OnCommand: nil, + OnEvents: make(map[types.Type]func(types.Reliable[P])), OnParameter: nil, }, } @@ -43,7 +44,6 @@ func (h *Hooks[P]) OnCommandAsync(options types.HookOptions) <-chan types.Comman func (h *Hooks[P]) OnParameterAsync(options types.HookOptions) <-chan P { return ensureChan(&h.AsyncHooks.OnParameter, options.Size) } - func (h *Hooks[P]) CloseAsyncHooks() { if h.AsyncHooks.OnSession != nil { diff --git a/internal/types/command.go b/internal/types/command.go index 74598f5..d9058da 100644 --- a/internal/types/command.go +++ b/internal/types/command.go @@ -51,3 +51,30 @@ type Fragment struct { Offset uint32 Data []byte } + +// Type represents a Photon reliable message type. +type Type uint8 + +// Photon Protocol reliable message types. +// These define the different kinds of reliable messages that can be exchanged. +const ( + OperationRequest Type = 0x02 // Client requests an operation + OperationResponse Type = 0x07 // Server responds to an operation + OtherOperationResponse Type = 0x03 // Alternative response format + EventDataType Type = 0x04 // Server sends an event to client + ExchangeKeys Type = 0x06 // Key exchange for encryption +) + +// ReliableHeader represents the reliable message header. +// This appears at the start of the payload in SendReliable commands. +type ReliableHeader struct { + Signature uint8 `json:"signature"` // Message signature (typically 0xF3) + Type Type `json:"type"` // Message type (operation, event, etc.) + EventCode uint8 `json:"event_code"` // Operation/event code (application-specific) + ParameterCount int `json:"parameter_count"` // Number of parameters following this header +} + +type Reliable[P ParameterView] struct { + ReliableHeader + Parameters []P +} diff --git a/internal/types/hooks.go b/internal/types/hooks.go index 8a8c6e0..3f0aec3 100644 --- a/internal/types/hooks.go +++ b/internal/types/hooks.go @@ -3,6 +3,7 @@ package types type SyncHooks[P ParameterView] struct { OnSession func(Session) OnCommand func(Command) + OnEvents map[Type]func(Reliable[P]) OnParameter func(P) } diff --git a/parser.go b/parser.go index b66471f..a5b5931 100644 --- a/parser.go +++ b/parser.go @@ -1,3 +1,9 @@ +// Package photon decodes Photon session envelopes and reliable payloads from raw bytes. +// It supports protocol versions 16 and 18, which differ in parameter layout and how +// reliable headers declare parameter counts. +// +// Register hooks on a Parser before calling ParsePacket; synchronous and asynchronous +// callbacks run as the decoder walks the buffer. package photon import ( @@ -11,10 +17,13 @@ import ( "github.com/AutoDruid/photon-parser/internal/types" ) +// Parser decodes Photon UDP payloads for a fixed protocol version (16 or 18). +// Create one with NewV16 or NewV18; it is safe to reuse for multiple ParsePacket calls. type Parser[P types.ParameterView] struct { ctx *context.Context[P] } +// NewV16 returns a Parser that interprets parameters and reliable headers using protocol 16 rules. func NewV16() *Parser[v16.Parameter] { return &Parser[v16.Parameter]{ ctx: context.NewContext( @@ -29,11 +38,13 @@ func NewV16() *Parser[v16.Parameter] { } } +// ParseV16 parses data using a newly allocated protocol 16 Parser and returns the resulting Session. func ParseV16(data []byte) (*Session, error) { p := NewV16() return p.ParsePacket(data) } +// NewV18 returns a Parser that interprets parameters and reliable headers using protocol 18 rules. func NewV18() *Parser[v18.Parameter] { return &Parser[v18.Parameter]{ ctx: context.NewContext( @@ -48,11 +59,14 @@ func NewV18() *Parser[v18.Parameter] { } } +// ParseV18 parses data using a newly allocated protocol 18 Parser and returns the resulting Session. func ParseV18(data []byte) (*Session, error) { p := NewV18() return p.ParsePacket(data) } +// ParsePacket resets the internal reader to data and decodes one Photon session. +// Hooks registered on the Parser are invoked during this call. func (p *Parser[P]) ParsePacket(data []byte) (*Session, error) { p.ctx.Reader.Reset(data) @@ -67,30 +81,59 @@ func (p *Parser[P]) ParsePacket(data []byte) (*Session, error) { return &sess, nil } +// OnEventData registers a callback for reliable messages of type event data (server-raised events). +// The callback runs during ParsePacket when such a message is decoded. +func (p *Parser[P]) OnEventData(fn func(Reliable[P])) { + p.ctx.Hooks.OnEvents[types.EventDataType] = fn +} + +// OnOperationResponse registers a callback for reliable operation response messages. +func (p *Parser[P]) OnOperationResponse(fn func(Reliable[P])) { + p.ctx.Hooks.OnEvents[types.OperationResponse] = fn +} + +// OnOperationRequest registers a callback for reliable operation request messages. +func (p *Parser[P]) OnOperationRequest(fn func(Reliable[P])) { + p.ctx.Hooks.OnEvents[types.OperationRequest] = fn +} + +// OnOtherOperationResponse registers a callback for reliable other-operation-response messages. +func (p *Parser[P]) OnOtherOperationResponse(fn func(Reliable[P])) { + p.ctx.Hooks.OnEvents[types.OtherOperationResponse] = fn +} + +// OnSessionSync registers a function called once per ParsePacket when the session has been parsed. func (p *Parser[P]) OnSessionSync(fn func(Session)) { p.ctx.Hooks.SyncHooks.OnSession = fn } +// OnCommandSync registers a function called for each top-level command during ParsePacket. func (p *Parser[P]) OnCommandSync(fn func(Command)) { p.ctx.Hooks.SyncHooks.OnCommand = fn } +// OnParameterSync registers a function called for each decoded parameter during ParsePacket. func (p *Parser[P]) OnParameterSync(fn func(P)) { p.ctx.Hooks.SyncHooks.OnParameter = fn } +// OnSessionAsync returns a receive-only channel of parsed sessions. +// HookOptions.Size sets the channel buffer capacity; see Close when finished with async hooks. func (p *Parser[P]) OnSessionAsync(options types.HookOptions) <-chan Session { return p.ctx.Hooks.OnSessionAsync(options) } +// OnCommandAsync returns a receive-only channel that receives each command as it is parsed. func (p *Parser[P]) OnCommandAsync(options types.HookOptions) <-chan Command { return p.ctx.Hooks.OnCommandAsync(options) } +// OnParameterAsync returns a receive-only channel that receives each parameter as it is parsed. func (p *Parser[P]) OnParameterAsync(options types.HookOptions) <-chan P { return p.ctx.Hooks.OnParameterAsync(options) } +// Close shuts down asynchronous hook channels created by OnSessionAsync, OnCommandAsync, and OnParameterAsync. func (p *Parser[P]) Close() { p.ctx.Hooks.CloseAsyncHooks() } diff --git a/parser_test.go b/parser_test.go index b29f48c..0c8f643 100644 --- a/parser_test.go +++ b/parser_test.go @@ -1,8 +1,6 @@ package photon_test import ( - "github.com/AutoDruid/photon-parser" - "github.com/AutoDruid/photon-parser/internal/types" "encoding/hex" "encoding/json" "fmt" @@ -10,6 +8,9 @@ import ( "os" "strings" "testing" + + "github.com/AutoDruid/photon-parser" + "github.com/AutoDruid/photon-parser/internal/types" ) type WiresharkFrame struct { diff --git a/types.go b/types.go index e9e65c9..2e413c6 100644 --- a/types.go +++ b/types.go @@ -12,3 +12,5 @@ type HookOptions = types.HookOptions type ParameterV16 = v16.Parameter type ParameterV18 = v18.Parameter type ParameterV18Type = v18.ParameterType +type Reliable[P types.ParameterView] = types.Reliable[P] +type ReliableV18 = Reliable[ParameterV18]