package app import ( "bufio" "fmt" "log" "os" "path/filepath" "regexp" "strings" "time" ) var ( joinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`) leftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`) worldIdPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+)`) instanceIdPattern = regexp.MustCompile(`instanceId=([^,}\s]+)`) worldNamePattern = regexp.MustCompile(`worldName=([^,}]+)`) worldLocationPattern = regexp.MustCompile(`worldId=(wrld_[0-9a-fA-F-]+):([^\s,\]\)\"']+)`) worldPattern = regexp.MustCompile(`(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)`) enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`) ) type vrcLogState struct { location string worldID string instanceID string worldName string pendingWorldName string presentSet map[string]struct{} initialized bool } func getVrchatLogDir() (string, error) { userProfile := os.Getenv("USERPROFILE") if userProfile == "" { return "", os.ErrNotExist } return filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat"), nil } func findLatestVrchatLog() (string, error) { dir, err := getVrchatLogDir() if err != nil { return "", err } entries, err := os.ReadDir(dir) if err != nil { return "", err } var latest string var latestMod time.Time for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if !strings.HasSuffix(strings.ToLower(name), ".log") && !strings.HasSuffix(strings.ToLower(name), ".txt") { continue } info, err := entry.Info() if err != nil { continue } if info.ModTime().After(latestMod) { latestMod = info.ModTime() latest = filepath.Join(dir, name) } } if latest == "" { return "", os.ErrNotExist } return latest, nil } func watchVrchatLog() { lastPath := "" lastSize := int64(0) state := &vrcLogState{ presentSet: map[string]struct{}{}, } for { path, err := findLatestVrchatLog() if err != nil { time.Sleep(2 * time.Second) continue } info, err := os.Stat(path) if err != nil { time.Sleep(2 * time.Second) continue } if path != lastPath { log.Printf("vrchat log watching %s", path) lastPath = path lastSize = 0 if err := scanExistingVrchatLog(path, state); err != nil { log.Printf("vrchat log initial scan failed: %v", err) } if info2, err := os.Stat(path); err == nil { lastSize = info2.Size() } else { lastSize = info.Size() } time.Sleep(2 * time.Second) continue } if info.Size() < lastSize { lastSize = 0 } if info.Size() > lastSize { f, err := os.Open(path) if err != nil { time.Sleep(2 * time.Second) continue } _, _ = f.Seek(lastSize, 0) scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() if err := appendRuntimeLog("VRC LOG", line); err != nil { log.Printf("append runtime log failed: %v", err) } if changed, worldLabel := updateWorldState(state, line); changed { log.Printf("world changed: %s", worldLabel) state.presentSet = map[string]struct{}{} _ = appendRuntimeLog("VRC WORLD", worldLabel) } if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { name := strings.TrimSpace(m[1]) state.presentSet[name] = struct{}{} out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet)) log.Print(out) _ = appendJoinLeaveLog(out) continue } if m := leftPattern.FindStringSubmatch(line); len(m) == 2 { name := strings.TrimSpace(m[1]) delete(state.presentSet, name) out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet)) log.Print(out) _ = appendJoinLeaveLog(out) continue } } lastSize = info.Size() _ = f.Close() } time.Sleep(2 * time.Second) } } func scanExistingVrchatLog(path string, state *vrcLogState) error { f, err := os.Open(path) if err != nil { return err } defer f.Close() state.presentSet = map[string]struct{}{} state.location = "" state.worldID = "" state.instanceID = "" state.worldName = "" state.pendingWorldName = "" state.initialized = false scanner := bufio.NewScanner(f) for scanner.Scan() { line := scanner.Text() if changed, _ := updateWorldState(state, line); changed { state.presentSet = map[string]struct{}{} } if m := joinPattern.FindStringSubmatch(line); len(m) == 2 { name := strings.TrimSpace(m[1]) state.presentSet[name] = struct{}{} continue } if m := leftPattern.FindStringSubmatch(line); len(m) == 2 { name := strings.TrimSpace(m[1]) delete(state.presentSet, name) continue } } if err := scanner.Err(); err != nil { return err } state.initialized = true if state.worldName != "" { log.Printf("world changed: %s", state.worldName) _ = appendRuntimeLog("VRC WORLD", state.worldName) } return nil } func updateWorldState(state *vrcLogState, line string) (bool, string) { worldID := "" instanceID := "" worldName := "" roomTitle := "" if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 { roomTitle = strings.TrimSpace(m[1]) state.pendingWorldName = roomTitle } if m := worldLocationPattern.FindStringSubmatch(line); len(m) == 3 { worldID = strings.TrimSpace(m[1]) instanceID = strings.TrimSpace(m[2]) } if m := worldIdPattern.FindStringSubmatch(line); len(m) == 2 { worldID = strings.TrimSpace(m[1]) } if m := instanceIdPattern.FindStringSubmatch(line); len(m) == 2 { instanceID = strings.TrimSpace(m[1]) } if m := worldNamePattern.FindStringSubmatch(line); len(m) == 2 { worldName = strings.TrimSpace(m[1]) } if m := enteringRoomPattern.FindStringSubmatch(line); len(m) == 2 && worldID == "" { if worldMatch := worldPattern.FindStringSubmatch(m[1]); len(worldMatch) == 2 { worldID = worldMatch[1] } } nextLocation := "" if worldID != "" { nextLocation = worldID if instanceID != "" { nextLocation = worldID + ":" + instanceID } } if nextLocation != "" && nextLocation != state.location { state.location = nextLocation state.worldID = worldID state.instanceID = instanceID if worldName != "" { state.worldName = worldName } else if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) { state.worldName = roomTitle } label := state.worldName if label == "" { label = state.worldID } if label == "" { label = nextLocation } if state.initialized { log.Printf("world changed: %s", label) _ = appendRuntimeLog("VRC WORLD", label) } return true, label } if worldName != "" { state.worldName = worldName state.pendingWorldName = "" return false, "" } if shouldUseRoomTitlePreferLatest(roomTitle, state.worldName) { state.worldName = roomTitle } return false, "" } func shouldUseRoomTitle(roomTitle, currentWorldName string) bool { if roomTitle == "" { return false } if strings.Contains(roomTitle, "Home Location") { return false } if roomTitle == "Holiday-Cottage" && currentWorldName != "" && currentWorldName != roomTitle { return false } if currentWorldName == "" { return true } if roomTitle == currentWorldName { return false } if len(roomTitle) > len(currentWorldName) { return true } if strings.Contains(roomTitle, "「") || strings.Contains(roomTitle, "」") || strings.Contains(roomTitle, "[") { return true } return false } func shouldUseRoomTitlePreferLatest(roomTitle, currentWorldName string) bool { if roomTitle == "" { return false } if strings.Contains(roomTitle, "Home Location") { return false } if currentWorldName == "" { return true } if roomTitle == currentWorldName { return false } return true }