package companion import ( "crypto/sha256" "encoding/binary" "encoding/hex" "regexp" "strings" "time" "unicode/utf16" ) const ( SCUMLoginLogParserKey = "scum-login-log-parser-v1" SCUMLoginLogParserVersion = "scum-login-log-v1" SCUMLoginLogMaxLineBytes = 4096 ) var scumLoginLogPattern = regexp.MustCompile(`^(\d{4}\.\d{2}\.\d{2}-\d{2}\.\d{2}\.\d{2}): '(.+)\s+(\d{17}):(.+)\(([^()]*)\)' logged (in|out) at: X=([+-]?\d+(?:\.\d+)?) Y=([+-]?\d+(?:\.\d+)?) Z=([+-]?\d+(?:\.\d+)?)$`) // SCUMLoginLogRecord is a single complete or buffered line from a plugin-declared // UTF-16LE SCUM login log source. It carries transport identity, not host paths. type SCUMLoginLogRecord struct { ServerID string SourceIdentity string StreamGeneration string Sequence uint64 RawLine []byte Partial bool } type SCUMLoginLogParseOptions struct { MaxLineBytes int AcknowledgedTransportCursors map[SCUMTransportCursor]struct{} AcknowledgedLogicalEventIdentities map[string]struct{} } type SCUMTransportCursor struct { SourceIdentity string StreamGeneration string Sequence uint64 } type SCUMLoginLogEvent struct { ServerID string ParserKey string ParserVersion string TransportCursor SCUMTransportCursor Type string ExternalPlayerID string DisplayName string ProfileLocalID string OccurredAt time.Time OccurredAtSourceText string LogicalEventIdentity string } type SCUMLoginLogDiagnostic struct { ServerID string TransportCursor SCUMTransportCursor Code string } type SCUMLoginLogBatch struct { ServerID string ParserKey string ParserVersion string Events []SCUMLoginLogEvent Diagnostics []SCUMLoginLogDiagnostic } type SCUMLoginLogParserAvailability struct { Available bool ParserKey string ParserVersion string MaxLineBytes int Reason string } func VerifiedSCUMLoginLogParser() SCUMLoginLogParserAvailability { return SCUMLoginLogParserAvailability{Available: true, ParserKey: SCUMLoginLogParserKey, ParserVersion: SCUMLoginLogParserVersion, MaxLineBytes: SCUMLoginLogMaxLineBytes, Reason: "SCUM UTF-16LE login log parser is available"} } func ParseSCUMLoginLogRecords(serverID string, records []SCUMLoginLogRecord, options SCUMLoginLogParseOptions) SCUMLoginLogBatch { maxLineBytes := options.MaxLineBytes if maxLineBytes <= 0 || maxLineBytes > SCUMLoginLogMaxLineBytes { maxLineBytes = SCUMLoginLogMaxLineBytes } batch := SCUMLoginLogBatch{ServerID: serverID, ParserKey: SCUMLoginLogParserKey, ParserVersion: SCUMLoginLogParserVersion} seenTransport := cloneTransportSet(options.AcknowledgedTransportCursors) seenLogical := cloneStringSet(options.AcknowledgedLogicalEventIdentities) lastSequenceByGeneration := map[string]uint64{} if len(records) > 100 { records = records[:100] } for _, record := range records { cursor := SCUMTransportCursor{SourceIdentity: record.SourceIdentity, StreamGeneration: record.StreamGeneration, Sequence: record.Sequence} if record.ServerID != serverID || cursor.SourceIdentity == "" || cursor.StreamGeneration == "" || cursor.Sequence == 0 { batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "invalid-transport-cursor") continue } generationKey := cursor.SourceIdentity + "\x00" + cursor.StreamGeneration if last := lastSequenceByGeneration[generationKey]; last != 0 && cursor.Sequence < last { batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "out-of-order-transport") } if cursor.Sequence > lastSequenceByGeneration[generationKey] { lastSequenceByGeneration[generationKey] = cursor.Sequence } if _, ok := seenTransport[cursor]; ok { batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "duplicate-transport-cursor") continue } seenTransport[cursor] = struct{}{} if record.Partial { batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "partial-line") continue } if len(record.RawLine) == 0 || len(record.RawLine) > maxLineBytes { batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "oversized-line") continue } line, ok := decodeUTF16LELine(record.RawLine) if !ok { batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "undecodable-line") continue } event, diagnosticCode := parseSCUMLoginLogLine(serverID, cursor, line) if diagnosticCode != "" { batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, diagnosticCode) continue } if _, ok := seenLogical[event.LogicalEventIdentity]; ok { batch.Diagnostics = appendSCUMLoginDiagnostic(batch.Diagnostics, serverID, cursor, "duplicate-logical-event") continue } seenLogical[event.LogicalEventIdentity] = struct{}{} batch.Events = append(batch.Events, event) } return batch } func parseSCUMLoginLogLine(serverID string, cursor SCUMTransportCursor, rawLine string) (SCUMLoginLogEvent, string) { line := strings.TrimPrefix(strings.TrimRight(rawLine, "\r\n"), "\ufeff") lower := strings.ToLower(line) if strings.Contains(lower, "failed") || strings.Contains(lower, "rejected") { return SCUMLoginLogEvent{}, "failed-login" } match := scumLoginLogPattern.FindStringSubmatch(line) if match == nil { return SCUMLoginLogEvent{}, "malformed-line" } occurredAt, err := time.ParseInLocation("2006.01.02-15.04.05", match[1], time.UTC) if err != nil { return SCUMLoginLogEvent{}, "malformed-line" } eventType := "scum.login" if match[6] == "out" { eventType = "scum.logout" } displayName := strings.TrimSpace(match[4]) profileLocalID := strings.TrimSpace(match[5]) if displayName == "" || profileLocalID == "" { return SCUMLoginLogEvent{}, "malformed-line" } event := SCUMLoginLogEvent{ ServerID: serverID, ParserKey: SCUMLoginLogParserKey, ParserVersion: SCUMLoginLogParserVersion, TransportCursor: cursor, Type: eventType, ExternalPlayerID: match[3], DisplayName: displayName, ProfileLocalID: profileLocalID, OccurredAt: occurredAt, OccurredAtSourceText: match[1], } event.LogicalEventIdentity = scumLoginLogicalIdentity(serverID, eventType, match[1], event.ExternalPlayerID, displayName, profileLocalID) return event, "" } func scumLoginLogicalIdentity(serverID, eventType, occurrenceText, externalPlayerID, displayName, profileLocalID string) string { normalized := strings.Join([]string{SCUMLoginLogParserVersion, serverID, eventType, occurrenceText, externalPlayerID, displayName, profileLocalID}, "\x00") digest := sha256.Sum256([]byte(normalized)) return "sha256:" + hex.EncodeToString(digest[:]) } func decodeUTF16LELine(raw []byte) (string, bool) { if len(raw)%2 != 0 { return "", false } codeUnits := make([]uint16, len(raw)/2) for index := range codeUnits { codeUnits[index] = binary.LittleEndian.Uint16(raw[index*2:]) } for index := 0; index < len(codeUnits); index++ { unit := codeUnits[index] if 0xD800 <= unit && unit <= 0xDBFF { if index+1 >= len(codeUnits) || codeUnits[index+1] < 0xDC00 || codeUnits[index+1] > 0xDFFF { return "", false } index++ continue } if 0xDC00 <= unit && unit <= 0xDFFF { return "", false } } return string(utf16.Decode(codeUnits)), true } func appendSCUMLoginDiagnostic(existing []SCUMLoginLogDiagnostic, serverID string, cursor SCUMTransportCursor, code string) []SCUMLoginLogDiagnostic { if len(existing) >= 64 { return existing } return append(existing, SCUMLoginLogDiagnostic{ServerID: serverID, TransportCursor: cursor, Code: code}) } func cloneTransportSet(source map[SCUMTransportCursor]struct{}) map[SCUMTransportCursor]struct{} { target := make(map[SCUMTransportCursor]struct{}, len(source)) for key, value := range source { target[key] = value } return target } func cloneStringSet(source map[string]struct{}) map[string]struct{} { target := make(map[string]struct{}, len(source)) for key, value := range source { target[key] = value } return target }