206 lines
5.7 KiB
Go
206 lines
5.7 KiB
Go
// Package readonly serves versioned, immutable new-account protocol data.
|
|
//
|
|
// The seed is a JSON representation of protobuf fields, rather than an HTTP
|
|
// response or an encoded protobuf blob. This makes the runtime independent
|
|
// of development captures while preserving unknown nested message layouts.
|
|
package readonly
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
|
|
"bd2server/internal/server/versionconfig"
|
|
"bd2server/internal/server/wire"
|
|
)
|
|
|
|
func StateVersion() string { return versionconfig.State() }
|
|
|
|
var ErrInvalidSeed = errors.New("readonly: invalid seed")
|
|
|
|
// Field is one protobuf field. Bytes is used only when a length-delimited
|
|
// value is not itself a protobuf message; Fields retains nested structure.
|
|
// Fixed values are little-endian integers, exactly as protobuf specifies.
|
|
type Field struct {
|
|
Number int `json:"number"`
|
|
Type int `json:"type"`
|
|
Varint uint64 `json:"varint,omitempty"`
|
|
Fixed32 uint32 `json:"fixed32_le,omitempty"`
|
|
Fixed64 uint64 `json:"fixed64_le,omitempty"`
|
|
Bytes []uint8 `json:"bytes,omitempty"`
|
|
Fields []Field `json:"fields,omitempty"`
|
|
}
|
|
|
|
type Response struct {
|
|
PacketCode int `json:"packet_code"`
|
|
Fields []Field `json:"fields"`
|
|
}
|
|
|
|
type Seed struct {
|
|
Version string `json:"version"`
|
|
Responses map[string]Response `json:"responses"`
|
|
}
|
|
|
|
// CashProductEventIndex returns the dynamic product event identity already
|
|
// present in the versioned CashShopInfo seed. Gacha preview state must use this
|
|
// identity because the client sends it back when locking a reroll result.
|
|
func (s *Seed) CashProductEventIndex(groupID, productID uint64) (uint64, error) {
|
|
if s == nil || groupID == 0 || productID == 0 {
|
|
return 0, errors.New("readonly: invalid cash product identity")
|
|
}
|
|
response, ok := s.Responses["/CashShopInfo"]
|
|
if !ok {
|
|
return 0, errors.New("readonly: CashShopInfo response is missing")
|
|
}
|
|
var found uint64
|
|
for _, product := range response.Fields {
|
|
if product.Number != 1 || product.Type != 2 {
|
|
continue
|
|
}
|
|
var group, id, event uint64
|
|
for _, field := range product.Fields {
|
|
switch field.Number {
|
|
case 1:
|
|
group = field.Varint
|
|
case 2:
|
|
id = field.Varint
|
|
case 8:
|
|
event = field.Varint
|
|
}
|
|
}
|
|
if group == groupID && id == productID {
|
|
if event == 0 {
|
|
return 0, fmt.Errorf("readonly: cash product %d/%d has no event index", groupID, productID)
|
|
}
|
|
if found != 0 {
|
|
return 0, fmt.Errorf("readonly: cash product %d/%d is duplicated", groupID, productID)
|
|
}
|
|
found = event
|
|
}
|
|
}
|
|
if found == 0 {
|
|
return 0, fmt.Errorf("readonly: cash product %d/%d is missing", groupID, productID)
|
|
}
|
|
return found, nil
|
|
}
|
|
|
|
func Load(path string) (*Seed, error) {
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("readonly: read seed: %w", err)
|
|
}
|
|
var s Seed
|
|
if err := json.Unmarshal(b, &s); err != nil {
|
|
return nil, fmt.Errorf("readonly: decode seed JSON: %w", err)
|
|
}
|
|
if err := s.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return &s, nil
|
|
}
|
|
|
|
func (s *Seed) Write(path string) error {
|
|
if err := s.Validate(); err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(s, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(path, append(b, '\n'), 0o600)
|
|
}
|
|
|
|
func (s *Seed) Validate() error {
|
|
if s == nil || s.Version != StateVersion() || len(s.Responses) == 0 {
|
|
return ErrInvalidSeed
|
|
}
|
|
for path, r := range s.Responses {
|
|
if len(path) < 2 || path[0] != '/' || r.PacketCode < 0 {
|
|
return fmt.Errorf("%w: response %q", ErrInvalidSeed, path)
|
|
}
|
|
if err := validateFields(r.Fields); err != nil {
|
|
return fmt.Errorf("%w: %s: %v", ErrInvalidSeed, path, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateFields(fields []Field) error {
|
|
for _, f := range fields {
|
|
if f.Number <= 0 || f.Type < 0 || f.Type > 5 || (f.Type != 0 && f.Type != 1 && f.Type != 2 && f.Type != 5) {
|
|
return fmt.Errorf("bad wire field %d/%d", f.Number, f.Type)
|
|
}
|
|
if f.Type != 2 && (len(f.Bytes) != 0 || len(f.Fields) != 0) {
|
|
return fmt.Errorf("non-length field %d has payload", f.Number)
|
|
}
|
|
if f.Type == 2 && len(f.Bytes) != 0 && len(f.Fields) != 0 {
|
|
return fmt.Errorf("field %d has both bytes and fields", f.Number)
|
|
}
|
|
if err := validateFields(f.Fields); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Handle re-encodes a response every time. It intentionally accepts any
|
|
// structurally valid sequenced request, matching other initialization routes.
|
|
func (s *Seed) Handle(path string, request []byte) (int, []byte, bool, error) {
|
|
r, ok := s.Responses[path]
|
|
if !ok {
|
|
return 0, nil, false, nil
|
|
}
|
|
seq, present, err := wire.Varint(request, 1)
|
|
if err != nil || !present || seq == 0 {
|
|
return 0, nil, true, fmt.Errorf("readonly: %s invalid sequence", path)
|
|
}
|
|
b, err := encodeFields(r.Fields)
|
|
if err != nil {
|
|
return 0, nil, true, err
|
|
}
|
|
return r.PacketCode, b, true, nil
|
|
}
|
|
|
|
func encodeFields(fields []Field) ([]byte, error) {
|
|
var out []byte
|
|
for _, f := range fields {
|
|
out = binary.AppendUvarint(out, uint64(f.Number<<3|f.Type))
|
|
switch f.Type {
|
|
case 0:
|
|
out = binary.AppendUvarint(out, f.Varint)
|
|
case 1:
|
|
var b [8]byte
|
|
binary.LittleEndian.PutUint64(b[:], f.Fixed64)
|
|
out = append(out, b[:]...)
|
|
case 5:
|
|
var b [4]byte
|
|
binary.LittleEndian.PutUint32(b[:], f.Fixed32)
|
|
out = append(out, b[:]...)
|
|
case 2:
|
|
payload := f.Bytes
|
|
if len(f.Fields) != 0 {
|
|
var err error
|
|
payload, err = encodeFields(f.Fields)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
out = binary.AppendUvarint(out, uint64(len(payload)))
|
|
out = append(out, payload...)
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// Service adapts a loaded seed to the session handler interface.
|
|
type Service struct{ Seed *Seed }
|
|
|
|
func (s Service) Handle(path string, request []byte) (int, []byte, bool, error) {
|
|
if s.Seed == nil {
|
|
return 0, nil, false, nil
|
|
}
|
|
return s.Seed.Handle(path, request)
|
|
}
|