Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .mise/config.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ run = "golangci-lint run ./..."

[tasks.format]
description = "Format the code"
run = "go fmt ./..."
run = "go fmt ./..."

[tasks.validate]
description = "Validate the code"
run = "mise lint && mise format && mise test && mise build"
125 changes: 125 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.

8 changes: 4 additions & 4 deletions internal/command/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,26 +64,26 @@ 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 {
return
}

select {
case hooks.AsyncHooks.OnCommand <- c.Command:
case hooks.AsyncHooks.OnCommand <- *out:
default: // don't block parser
}

Expand Down
68 changes: 29 additions & 39 deletions internal/command/reliable/reliable.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand All @@ -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.
Expand All @@ -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 {
Comment thread
Michelprogram marked this conversation as resolved.
return nil, nil
}

reliable.Header = header
reliable.ReliableHeader = header

if reliable.Signature != 0xF3 {
return nil, errors.ErrEncryptedPacket
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/hooks/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
}
Expand All @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions internal/types/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions internal/types/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down
Loading
Loading