package gameserver import ( "crypto/sha256" _ "embed" "encoding/base64" "encoding/json" "fmt" "os" "path/filepath" "strconv" "strings" "time" "gac-hkrpg-go/internal/common" goproto "gac-hkrpg-go/protocol/proto" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protowire" "google.golang.org/protobuf/proto" ) //go:embed battle_wire_probe.lua var battleWireProbe string type battleCapture struct { ResponseName string `json:"response_name"` CmdID uint16 `json:"cmd_id"` ResponseSHA256 string `json:"response_sha256"` BattleSHA256 string `json:"battle_sha256"` CapturedUTC time.Time `json:"captured_utc"` StageID uint32 `json:"stage_id"` } // Preserve every result field, including fields whose native names are still // obfuscated. The display report intentionally retains only aggregate stats. func saveBattleResultCapture(req *goproto.PVEBattleResultCsReq) error { if req == nil { return nil } wire, err := proto.Marshal(req) if err != nil { return err } pretty, err := (protojson.MarshalOptions{Indent: " ", EmitUnpopulated: true}).Marshal(req) if err != nil { return err } hash := sha256.Sum256(wire) dir := filepath.Join("battle_diagnostics", time.Now().UTC().Format("20060102T150405.000000000Z")+fmt.Sprintf("_result_%x", hash[:6])) if err := os.MkdirAll(dir, 0700); err != nil { return err } if err := os.WriteFile(filepath.Join(dir, "battle_result.bin"), wire, 0600); err != nil { return err } return os.WriteFile(filepath.Join(dir, "battle_result.json"), pretty, 0600) } func saveBattleCapture(msg proto.Message, cmdID uint16, response []byte) ([]byte, *battleCapture, error) { carrier, ok := msg.(interface { GetBattleInfo() *goproto.SceneBattleInfo }) if !ok || carrier.GetBattleInfo() == nil { return nil, nil, nil } battle := carrier.GetBattleInfo() wire, err := extractBattleWire(msg, response) if err != nil { return nil, nil, err } pretty, err := (protojson.MarshalOptions{Indent: " "}).Marshal(battle) if err != nil { return nil, nil, err } meta := &battleCapture{ ResponseName: string(msg.ProtoReflect().Descriptor().Name()), CmdID: cmdID, ResponseSHA256: fmt.Sprintf("%x", sha256.Sum256(response)), BattleSHA256: fmt.Sprintf("%x", sha256.Sum256(wire)), CapturedUTC: time.Now().UTC(), StageID: battle.StageId, } info, err := json.MarshalIndent(meta, "", " ") if err != nil { return nil, nil, err } files := map[string][]byte{"battle_last_response.bin": response, "battle_last.bin": wire, "battle_last.json": pretty, "battle_last_capture.json": info} // Keep earlier failures intact so consecutive attempts can be compared. archive := filepath.Join("battle_diagnostics", meta.CapturedUTC.Format("20060102T150405.000000000Z")+"_"+meta.ResponseSHA256[:12]) if err := os.MkdirAll(archive, 0700); err != nil { return nil, nil, err } for name, data := range files { if err := os.WriteFile(filepath.Join(archive, name), data, 0600); err != nil { return nil, nil, err } if err := os.WriteFile(name, data, 0600); err != nil { return nil, nil, err } } return wire, meta, nil } func extractBattleWire(msg proto.Message, response []byte) ([]byte, error) { field := msg.ProtoReflect().Descriptor().Fields().ByName("battle_info") if field == nil { return nil, fmt.Errorf("response has no battle_info field") } for len(response) > 0 { number, kind, n := protowire.ConsumeTag(response) if n < 0 { return nil, protowire.ParseError(n) } response = response[n:] if number == protowire.Number(field.Number()) && kind == protowire.BytesType { wire, n := protowire.ConsumeBytes(response) if n < 0 { return nil, protowire.ParseError(n) } return append([]byte(nil), wire...), nil } n = protowire.ConsumeFieldValue(number, kind, response) if n < 0 { return nil, protowire.ParseError(n) } response = response[n:] } return nil, fmt.Errorf("battle_info missing from serialized response") } func makeBattleProbe(wire []byte, typeName, hash string) []byte { // Values are quoted as Lua strings; base64 contains no executable syntax. replacer := strings.NewReplacer("__TYPE_NAME__", strconv.Quote(typeName), "__PAYLOAD__", strconv.Quote(base64.StdEncoding.EncodeToString(wire)), "__CAPTURE_HASH__", strconv.Quote(hash)) return []byte(replacer.Replace(battleWireProbe)) } func (s *Session) captureBattleResponse(msg proto.Message, cmdID uint16, body []byte) { wire, meta, err := saveBattleCapture(msg, cmdID, body) if err != nil { Log(fmt.Sprintf("[BATTLE CAPTURE] %v", err)) return } if meta == nil { return } Log(fmt.Sprintf("[BATTLE CAPTURE] cmd=%d stage=%d response_sha256=%s battle_sha256=%s", cmdID, meta.StageID, meta.ResponseSHA256, meta.BattleSHA256)) cfg, err := common.LoadRuntimeConfig() if err != nil { Log(fmt.Sprintf("[BATTLE PROBE] config error: %v", err)) return } if cfg.BattleDiagnostics.ClientBattleType == "" { return } s.battleProbeMu.Lock() s.battleProbe = makeBattleProbe(wire, cfg.BattleDiagnostics.ClientBattleType, meta.BattleSHA256) s.battleProbeMu.Unlock() Log("[BATTLE PROBE] queued read-only client parser check for next heartbeat") } func (s *Session) takeBattleProbe() []byte { s.battleProbeMu.Lock() defer s.battleProbeMu.Unlock() probe := s.battleProbe s.battleProbe = nil return probe }