// Package fixture reads captured BD2 protocol messages without duplicating the // large capture in the source tree. It is intended for local-server bootstrap // fixtures and protocol tests, not as a persistence format. package fixture import ( "bytes" "encoding/base64" "encoding/json" "errors" "fmt" "io/fs" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "bd2server/internal/cryptox" ) const CaptureVersion = "2.34.13" // NamedFixture names the small, stable bootstrap response set. Files remain // in capture///bodies and are loaded on demand. type NamedFixture struct { Name string Path string File string Key KeyKind } type KeyKind uint8 const ( Plain KeyKind = iota FixedLogin Session ) var Bootstrap = []NamedFixture{ {Name: "maintenance", Path: "/MaintenanceInfo", File: "00010_resp_MaintenanceInfo.bin", Key: Plain}, {Name: "server_info", Path: "/ServerInfo", File: "00012_resp_ServerInfo.bin", Key: Plain}, {Name: "login", Path: "/LoginUser", File: "00016_resp_LoginUser.bin", Key: FixedLogin}, {Name: "notice", Path: "/NoticeInfo", File: "00020_resp_NoticeInfo.bin", Key: Plain}, } var ( ErrNotFound = errors.New("bd2 fixture: response not found") ErrUnsafePath = errors.New("bd2 fixture: unsafe capture path") ErrNoPayload = errors.New("bd2 fixture: response has no data payload") ErrLengthMismatch = errors.New("bd2 fixture: response length does not match decoded payload") ) // Capture points at a single timestamped capture directory, i.e. // capture/2.34.13/20260920-003254. type Capture struct { Root string } // Set is the local-server integration view of one captured new-player run. // It intentionally keeps responses on disk and only loads bodies on demand; // this avoids copying the capture into binaries or maintaining a second large // fixture tree. Load accepts the timestamped capture directory itself. type Set struct { *Capture core map[string]NamedFixture responses map[string][]RecordedResponse } // RecordedResponse has an unambiguous selector: Path plus RequestSequence. // Sequence is the one-based request event number in bd2_dump.jsonl, rather // than an ordinal response count. This prevents a state-changing endpoint // from accidentally receiving a response captured for another request. type RecordedResponse struct { Path string Method string File string RequestSequence int ClientSequence uint64 } // RecordedResponses lists the unambiguous capture selectors for path. The // returned slice is detached from the fixture index and safe for callers to // retain as a scripted bootstrap plan. func (s *Set) RecordedResponses(path string) []RecordedResponse { return append([]RecordedResponse(nil), s.responses[path]...) } // Load opens an on-disk capture and indexes its stable core endpoints. func Load(captureDir string) (*Set, error) { c, err := Open(captureDir) if err != nil { return nil, err } core := make(map[string]NamedFixture, len(Bootstrap)) for _, spec := range Bootstrap { core[spec.Path] = spec } responses, err := indexResponses(c.Root) if err != nil { return nil, err } return &Set{Capture: c, core: core, responses: responses}, nil } type dumpRecord struct { Event string `json:"ev"` Game bool `json:"game"` FlowID string `json:"flow_id"` Method string `json:"method"` Path string `json:"path"` RawFile string `json:"raw_file"` PB []string `json:"pb"` } type indexedRequest struct { CaptureSequence int ClientSequence uint64 } func indexResponses(root string) (map[string][]RecordedResponse, error) { data, err := os.ReadFile(filepath.Join(root, "bd2_dump.jsonl")) if err != nil { return nil, fmt.Errorf("read capture index: %w", err) } requests := make(map[string]indexedRequest) responses := make(map[string][]RecordedResponse) sequence := 0 for lineNo, line := range bytes.Split(data, []byte{'\n'}) { if len(bytes.TrimSpace(line)) == 0 { continue } var record dumpRecord if err := json.Unmarshal(line, &record); err != nil { return nil, fmt.Errorf("decode capture index line %d: %w", lineNo+1, err) } if !record.Game || record.FlowID == "" { continue } if record.Event == "req" { sequence++ requests[record.FlowID] = indexedRequest{CaptureSequence: sequence, ClientSequence: protobufSequence(record.PB)} continue } if record.Event != "resp" || record.RawFile == "" { continue } request, found := requests[record.FlowID] if !found { continue // capture can begin after an already-open HTTP flow } if err := safeName(filepath.Base(record.RawFile)); err != nil { return nil, fmt.Errorf("capture index line %d: %w", lineNo+1, err) } responses[record.Path] = append(responses[record.Path], RecordedResponse{ Path: record.Path, Method: record.Method, File: filepath.Base(record.RawFile), RequestSequence: request.CaptureSequence, ClientSequence: request.ClientSequence, }) } return responses, nil } func protobufSequence(fields []string) uint64 { const prefix = "#1 varint " for _, field := range fields { text := strings.TrimSpace(field) if !strings.HasPrefix(text, prefix) { continue } value, err := strconv.ParseUint(strings.TrimSpace(strings.TrimPrefix(text, prefix)), 10, 64) if err == nil { return value } } return 0 } // RecordedResponseForClientSequence resolves the captured response using the // endpoint and the request message's protobuf seq field. A missing/zero seq // never falls back to a path-only match. func (s *Set) RecordedResponseForClientSequence(path string, clientSequence uint64) (RecordedResponse, error) { if clientSequence == 0 { return RecordedResponse{}, fmt.Errorf("%w: response %s has no client sequence", ErrNotFound, path) } for _, response := range s.responses[path] { if response.ClientSequence == clientSequence { return response, nil } } return RecordedResponse{}, fmt.Errorf("%w: response %s at client sequence %d", ErrNotFound, path, clientSequence) } func Open(root string) (*Capture, error) { info, err := os.Stat(root) if err != nil { return nil, fmt.Errorf("open capture: %w", err) } if !info.IsDir() { return nil, fmt.Errorf("open capture: %s is not a directory", root) } if _, err := os.Stat(filepath.Join(root, "bodies")); err != nil { return nil, fmt.Errorf("open capture bodies: %w", err) } return &Capture{Root: root}, nil } // Response is a raw captured HTTP body along with its recorded endpoint. type Response struct { Path string File string Body []byte } // BootstrapResponse loads one of the fixed bootstrap fixtures by symbolic name. func (c *Capture) BootstrapResponse(name string) (Response, error) { for _, spec := range Bootstrap { if spec.Name == name { return c.readResponse(spec.Path, spec.File) } } return Response{}, fmt.Errorf("%w: bootstrap %q", ErrNotFound, name) } func (c *Capture) Maintenance() (Response, error) { return c.BootstrapResponse("maintenance") } func (c *Capture) ServerInfo() (Response, error) { return c.BootstrapResponse("server_info") } func (c *Capture) Login() (Response, error) { return c.BootstrapResponse("login") } func (c *Capture) Notice() (Response, error) { return c.BootstrapResponse("notice") } // CoreResponse returns the original JSON HTTP response for a bootstrap path. // It is useful where a handler deliberately wants an exact captured response. func (s *Set) CoreResponse(path string) ([]byte, error) { spec, ok := s.core[path] if !ok { return nil, fmt.Errorf("%w: core path %q", ErrNotFound, path) } response, err := s.readResponse(spec.Path, spec.File) if err != nil { return nil, err } return append([]byte(nil), response.Body...), nil } // RecordedResponseAt retrieves one captured HTTP response only when both its // endpoint path and original request sequence match. Passing an arbitrary // ordinal is deliberately not supported; callers should record the sequence // that their route/session policy maps to a fixture. func (s *Set) RecordedResponseAt(path string, requestSequence int) ([]byte, RecordedResponse, error) { for _, response := range s.responses[path] { if response.RequestSequence != requestSequence { continue } body, err := os.ReadFile(filepath.Join(s.Root, "bodies", response.File)) if err != nil { return nil, RecordedResponse{}, fmt.Errorf("read recorded response %s: %w", response.File, err) } return body, response, nil } return nil, RecordedResponse{}, fmt.Errorf("%w: response %s at request sequence %d", ErrNotFound, path, requestSequence) } // RecordedPayloadAt decodes a selected captured response with the captured // session key. LoginUser is always decoded with the fixed pre-login key. func (s *Set) RecordedPayloadAt(path string, requestSequence int) (Payload, RecordedResponse, error) { body, response, err := s.RecordedResponseAt(path, requestSequence) if err != nil { return Payload{}, RecordedResponse{}, err } key := []byte(nil) if path == "/LoginUser" || path == "/JoinUser" { key = cryptox.Key() } else { capturedKey, err := s.SessionKey() if err != nil { return Payload{}, RecordedResponse{}, err } key = []byte(capturedKey) } env, proto, encrypted, err := decodeEnvelope(body, key) if err != nil { return Payload{}, RecordedResponse{}, err } return Payload{Path: path, PacketCode: env.PacketCode, Proto: proto, Envelope: env, Encrypted: encrypted}, response, nil } // ReencryptRecordedAt is the safe replay helper for a selected response. It // preserves unencrypted protocol responses, but re-encrypts every encrypted // response payload with the supplied local-session key and fixes length. func (s *Set) ReencryptRecordedAt(path string, requestSequence int, localSessionKey []byte) ([]byte, RecordedResponse, error) { payload, response, err := s.RecordedPayloadAt(path, requestSequence) if err != nil { return nil, RecordedResponse{}, err } if !payload.Encrypted { body, _, err := s.RecordedResponseAt(path, requestSequence) return body, response, err } body, err := EncodePayload(payload, localSessionKey, true) return body, response, err } // Payload is the decoded portion of a captured endpoint response. PacketCode // and Proto are sufficient for a server to modify state and re-encrypt it. type Payload struct { Path string PacketCode int Proto []byte Envelope Envelope Encrypted bool } // CorePayload decodes a core fixture. LoginUser uses the fixed pre-login key; // the remaining initial bootstrap bodies are direct base64 protobuf payloads. func (s *Set) CorePayload(path string) (Payload, error) { spec, ok := s.core[path] if !ok { return Payload{}, fmt.Errorf("%w: core path %q", ErrNotFound, path) } body, err := s.CoreResponse(path) if err != nil { return Payload{}, err } key := []byte(nil) if spec.Key == FixedLogin { key = cryptox.Key() } env, proto, encrypted, err := decodeEnvelope(body, key) if err != nil { return Payload{}, err } return Payload{Path: path, PacketCode: env.PacketCode, Proto: proto, Envelope: env, Encrypted: encrypted}, nil } // SessionKey returns the captured LoginUser user_key. It is intended only for // offline fixture decoding; callers should replace it with a local test key // once their account/session layer exists. This package never logs it. func (s *Set) SessionKey() (string, error) { payload, err := s.CorePayload("/LoginUser") if err != nil { return "", err } match := sessionKeyPattern.Find(payload.Proto) if match == nil { return "", errors.New("bd2 fixture: LoginUser payload does not contain user_key") } return string(match), nil } var sessionKeyPattern = regexp.MustCompile(`[0-9a-f]{32}`) // ReencryptCore returns a core response whose payload has been encrypted with // key. It is primarily for LoginUser replay after a handler substitutes a // local user key in its protobuf. Callers who mutate protobuf should prefer // EncodePayload below. func (s *Set) ReencryptCore(path string, key []byte) ([]byte, error) { payload, err := s.CorePayload(path) if err != nil { return nil, err } return EncodePayload(payload, key, true) } // EncodePayload serializes a server-owned payload with an accurate length. func EncodePayload(payload Payload, key []byte, encrypted bool) ([]byte, error) { return EncodeEnvelope(payload.Envelope, payload.Proto, key, encrypted) } // Batch loads one of the two recorded initial BatchRequest response bodies. func (c *Capture) Batch(number int) (Response, error) { switch number { case 1: return c.readResponse("/BatchRequest", "00041_resp_BatchRequest.bin") case 2: return c.readResponse("/BatchRequest", "00077_resp_BatchRequest.bin") default: return Response{}, fmt.Errorf("%w: batch %d", ErrNotFound, number) } } // BatchResponse returns the captured raw BatchRequest response. For live // replay prefer BatchForPaths, because capture response order is arbitrary. func (s *Set) BatchResponse(index int) ([]byte, error) { response, err := s.Batch(index) if err != nil { return nil, err } return append([]byte(nil), response.Body...), nil } // BatchForPaths returns the captured responses in exactly the request path // order supplied by the client. It verifies both the captured request and // response before selecting items, preventing accidental path drift. func (s *Set) BatchForPaths(index int, paths []string) ([]BatchItem, error) { key, err := s.SessionKey() if err != nil { return nil, err } return s.batchForPathsWithKey(index, paths, []byte(key)) } // HandleBatch decodes an encrypted client BatchRequest body then produces a // JSON BatchRequest response arranged to match its request order. The result // is not AES-wrapped: captured BatchRequest responses are JSON arrays too. func (s *Set) HandleBatch(index int, requestBody []byte, sessionKey []byte) ([]byte, error) { requests, _, err := DecodeBatchRequest(requestBody, sessionKey) if err != nil { return nil, err } paths := make([]string, len(requests)) for i, request := range requests { paths[i] = request.Path } items, err := s.batchForPathsCaptured(index, paths) if err != nil { return nil, err } if err := s.reencryptBatchItems(items, sessionKey); err != nil { return nil, err } return json.Marshal(items) } func (s *Set) batchForPathsWithKey(index int, paths []string, key []byte) ([]BatchItem, error) { items, err := s.batchForPathsCaptured(index, paths) if err != nil { return nil, err } if err := s.reencryptBatchItems(items, key); err != nil { return nil, err } return items, nil } // batchForPathsCaptured selects data using only the captured session key. The // captured request itself is validated before its response map is trusted. func (s *Set) batchForPathsCaptured(index int, paths []string) ([]BatchItem, error) { capturedKey, err := s.SessionKey() if err != nil { return nil, err } key := []byte(capturedKey) if err := s.validateBatchRequest(index, key); err != nil { return nil, err } raw, err := s.BatchResponse(index) if err != nil { return nil, err } items, _, err := DecodeBatch(raw, key) if err != nil { return nil, err } byPath := make(map[string]BatchItem, len(items)) for _, item := range items { byPath[item.Path] = item } ordered := make([]BatchItem, 0, len(paths)) seen := make(map[string]struct{}, len(paths)) for _, path := range paths { if _, duplicate := seen[path]; duplicate { return nil, fmt.Errorf("bd2 fixture: duplicate requested batch path %q", path) } seen[path] = struct{}{} item, ok := byPath[path] if !ok { return nil, fmt.Errorf("%w: batch %d path %q", ErrNotFound, index, path) } ordered = append(ordered, item) } return ordered, nil } func (s *Set) reencryptBatchItems(items []BatchItem, localSessionKey []byte) error { if len(localSessionKey) != cryptox.AESKeySize { return cryptox.ErrInvalidKey } capturedKey, err := s.SessionKey() if err != nil { return err } for i := range items { body, err := json.Marshal(items[i].Response) if err != nil { return err } env, proto, _, err := decodeEnvelope(body, []byte(capturedKey)) if err != nil { return fmt.Errorf("decode batch response %s: %w", items[i].Path, err) } encoded, err := EncodeEnvelope(env, proto, localSessionKey, true) if err != nil { return err } if err := json.Unmarshal(encoded, &items[i].Response); err != nil { return err } } return nil } func (s *Set) validateBatchRequest(index int, key []byte) error { var file string switch index { case 1: file = "00040_req_BatchRequest.bin" case 2: file = "00076_req_BatchRequest.bin" default: return fmt.Errorf("%w: batch %d", ErrNotFound, index) } body, err := os.ReadFile(filepath.Join(s.Root, "bodies", file)) if err != nil { return err } _, _, err = DecodeBatchRequest(body, key) return err } func (c *Capture) readResponse(path, file string) (Response, error) { if err := safeName(file); err != nil { return Response{}, err } body, err := os.ReadFile(filepath.Join(c.Root, "bodies", file)) if err != nil { return Response{}, fmt.Errorf("read fixture %s: %w", file, err) } return Response{Path: path, File: file, Body: body}, nil } func safeName(name string) error { if filepath.Base(name) != name || strings.Contains(name, "..") || name == "" { return fmt.Errorf("%w: %q", ErrUnsafePath, name) } return nil } // Envelope is the common game response envelope. Unknown fields are retained // in Extra, which keeps a replay fixture forward-compatible. type Envelope struct { ErrorType int `json:"errorType"` PacketCode int `json:"packetCode,omitempty"` ErrorMessage string `json:"errorMessage,omitempty"` Length int `json:"length"` Data string `json:"data"` ServerNowTime int64 `json:"serverNowTime,omitempty"` Notify string `json:"notify,omitempty"` Extra json.RawMessage `json:"-"` } // DecodeEnvelope accepts either base64(protobuf) or // base64(AES-CBC(base64(protobuf))). The latter uses key. Captures legitimately // contain both forms, including after login, so the fallback is intentional. func DecodeEnvelope(body, key []byte) (Envelope, []byte, error) { env, proto, _, err := decodeEnvelope(body, key) return env, proto, err } func decodeEnvelope(body, key []byte) (Envelope, []byte, bool, error) { var env Envelope if err := json.Unmarshal(body, &env); err != nil { return Envelope{}, nil, false, fmt.Errorf("decode envelope JSON: %w", err) } if env.Data == "" { if env.Length != 0 { return Envelope{}, nil, false, fmt.Errorf("%w: advertised %d bytes", ErrNoPayload, env.Length) } return env, nil, false, nil } plain, encrypted, err := decodeData(env.Data, key) if err != nil { return Envelope{}, nil, false, err } // In an encrypted envelope length is the character count of the inner // base64(protobuf), not the protobuf byte count. Direct-base64 bootstrap // envelopes commonly leave it at zero (MaintenanceInfo is an example). if encrypted && env.Length != base64.StdEncoding.EncodedLen(len(plain)) { return Envelope{}, nil, false, fmt.Errorf("%w: advertised=%d wire=%d decoded=%d encrypted=%t", ErrLengthMismatch, env.Length, base64.StdEncoding.EncodedLen(len(plain)), len(plain), encrypted) } return env, plain, encrypted, nil } func decodeData(data string, key []byte) ([]byte, bool, error) { if len(key) == cryptox.AESKeySize { if inner, err := cryptox.DecryptBase64(data, key); err == nil { if proto, err := base64.StdEncoding.DecodeString(string(inner)); err == nil { return proto, true, nil } } } proto, err := base64.StdEncoding.DecodeString(data) if err != nil { return nil, false, fmt.Errorf("decode fixture data: %w", err) } return proto, false, nil } // EncodeEnvelope creates a client-shaped response. encrypted chooses the // AES-wrapped variant; plain responses use a direct base64 protobuf payload. func EncodeEnvelope(env Envelope, proto, key []byte, encrypted bool) ([]byte, error) { copyEnv := env if encrypted { if len(key) != cryptox.AESKeySize { return nil, cryptox.ErrInvalidKey } payload, err := cryptox.EncryptBase64Payload(proto, key) if err != nil { return nil, err } copyEnv.Data = payload copyEnv.Length = base64.StdEncoding.EncodedLen(len(proto)) } else { copyEnv.Data = base64.StdEncoding.EncodeToString(proto) } return json.Marshal(copyEnv) } // BatchItem is one JSON member in a BatchRequest response. type BatchItem struct { Path string `json:"path"` Response Envelope `json:"responseData"` } // DecodeBatch parses and validates a BatchRequest response. It rejects // duplicate endpoint paths because a local dispatcher could not replay them // deterministically by path. func DecodeBatch(body, key []byte) ([]BatchItem, map[string][]byte, error) { var items []BatchItem if err := json.Unmarshal(body, &items); err != nil { return nil, nil, fmt.Errorf("decode batch JSON: %w", err) } if len(items) == 0 { return nil, nil, errors.New("bd2 fixture: empty batch response") } decoded := make(map[string][]byte, len(items)) for i, item := range items { if item.Path == "" || !strings.HasPrefix(item.Path, "/") { return nil, nil, fmt.Errorf("bd2 fixture: batch item %d has invalid path %q", i, item.Path) } if _, exists := decoded[item.Path]; exists { return nil, nil, fmt.Errorf("bd2 fixture: duplicate batch path %q", item.Path) } body, err := json.Marshal(item.Response) if err != nil { return nil, nil, err } _, proto, err := DecodeEnvelope(body, key) if err != nil { return nil, nil, fmt.Errorf("decode batch item %s: %w", item.Path, err) } decoded[item.Path] = proto } return items, decoded, nil } // Request describes a decoded BatchRequest member. RequestData is a direct // base64 protobuf payload; the JSON array itself is AES-wrapped on the wire. type Request struct { Path string `json:"path"` RequestData string `json:"requestData"` } func DecodeBatchRequest(body, key []byte) ([]Request, map[string][]byte, error) { plain, err := cryptox.DecryptBase64(strings.TrimSpace(string(body)), key) if err != nil { return nil, nil, fmt.Errorf("decrypt batch request: %w", err) } var requests []Request if err := json.Unmarshal(plain, &requests); err != nil { return nil, nil, fmt.Errorf("decode batch request JSON: %w", err) } decoded := make(map[string][]byte, len(requests)) for i, req := range requests { if req.Path == "" || !strings.HasPrefix(req.Path, "/") || req.RequestData == "" { return nil, nil, fmt.Errorf("bd2 fixture: batch request item %d is invalid", i) } if _, exists := decoded[req.Path]; exists { return nil, nil, fmt.Errorf("bd2 fixture: duplicate batch request path %q", req.Path) } proto, err := base64.StdEncoding.DecodeString(req.RequestData) if err != nil { return nil, nil, fmt.Errorf("decode batch request %s: %w", req.Path, err) } decoded[req.Path] = proto } return requests, decoded, nil } // Paths returns the fixture response paths in lexical order for diagnostics. func Paths(items []BatchItem) []string { paths := make([]string, 0, len(items)) for _, item := range items { paths = append(paths, item.Path) } sort.Strings(paths) return paths } // EqualProto deliberately treats nil and empty payloads equally; protocol // endpoints with response length zero use both representations in practice. func EqualProto(a, b []byte) bool { return bytes.Equal(a, b) } var _ fs.FS // document that fixtures deliberately use an on-disk capture.