492 lines
12 KiB
Go
492 lines
12 KiB
Go
package app
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
"os"
|
||
"path/filepath"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
"unicode/utf8"
|
||
"syscall"
|
||
"unsafe"
|
||
)
|
||
|
||
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() {
|
||
log.Printf("vrchat log watcher started")
|
||
defer func() {
|
||
if r := recover(); r != nil {
|
||
log.Printf("vrchat log watcher panic: %v", r)
|
||
}
|
||
}()
|
||
lastPath := ""
|
||
lastSize := int64(0)
|
||
state := &vrcLogState{
|
||
presentSet: map[string]struct{}{},
|
||
}
|
||
for {
|
||
path, err := findLatestVrchatLog()
|
||
if err != nil {
|
||
log.Printf("vrchat log not found: %v", err)
|
||
time.Sleep(2 * time.Second)
|
||
continue
|
||
}
|
||
info, err := os.Stat(path)
|
||
if err != nil {
|
||
log.Printf("vrchat log stat failed: %v", err)
|
||
time.Sleep(2 * time.Second)
|
||
continue
|
||
}
|
||
log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize)
|
||
if path != lastPath {
|
||
log.Printf("vrchat log watching %s", path)
|
||
lastPath = path
|
||
lastSize = 0
|
||
log.Printf("vrchat log initial scan begin path=%s", path)
|
||
if err := scanExistingVrchatLog(path, state); err != nil {
|
||
log.Printf("vrchat log initial scan failed: %v", err)
|
||
} else {
|
||
log.Printf("vrchat log initial scan ok path=%s", path)
|
||
}
|
||
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 {
|
||
log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size())
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
log.Printf("vrchat log read failed: %v", err)
|
||
time.Sleep(2 * time.Second)
|
||
continue
|
||
}
|
||
text := decodeVRChatLog(b)
|
||
lines := strings.Split(text, "\n")
|
||
joinHits := 0
|
||
leaveHits := 0
|
||
for _, line := range lines {
|
||
line = strings.TrimRight(line, "\r")
|
||
if line == "" {
|
||
continue
|
||
}
|
||
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{}{}
|
||
SetCurrentWorld(worldLabel)
|
||
if tracker := GetGuestTracker(); tracker != nil {
|
||
tracker.SetCurrentInstance(worldLabel)
|
||
}
|
||
_ = appendRuntimeLog("VRC WORLD", worldLabel)
|
||
}
|
||
at := extractLineTime(line)
|
||
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
|
||
name := strings.TrimSpace(m[1])
|
||
log.Printf("join detected: %s", name)
|
||
joinHits++
|
||
state.presentSet[name] = struct{}{}
|
||
if tracker := GetGuestTracker(); tracker != nil {
|
||
tracker.MarkJoin(name, at)
|
||
}
|
||
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
|
||
log.Print(out)
|
||
if err := appendJoinLeaveLog(out); err != nil {
|
||
log.Printf("append join log failed: %v", err)
|
||
}
|
||
continue
|
||
}
|
||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||
name := strings.TrimSpace(m[1])
|
||
log.Printf("leave detected: %s", name)
|
||
leaveHits++
|
||
delete(state.presentSet, name)
|
||
if tracker := GetGuestTracker(); tracker != nil {
|
||
tracker.MarkLeave(name, at)
|
||
}
|
||
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
|
||
log.Print(out)
|
||
if err := appendJoinLeaveLog(out); err != nil {
|
||
log.Printf("append leave log failed: %v", err)
|
||
}
|
||
continue
|
||
}
|
||
}
|
||
log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits)
|
||
lastSize = info.Size()
|
||
}
|
||
time.Sleep(2 * time.Second)
|
||
}
|
||
}
|
||
|
||
func scanExistingVrchatLog(path string, state *vrcLogState) error {
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
state.presentSet = map[string]struct{}{}
|
||
state.location = ""
|
||
state.worldID = ""
|
||
state.instanceID = ""
|
||
state.worldName = ""
|
||
state.pendingWorldName = ""
|
||
state.initialized = false
|
||
|
||
text := decodeVRChatLog(b)
|
||
for _, raw := range strings.Split(text, "\n") {
|
||
line := strings.TrimRight(raw, "\r")
|
||
if line == "" {
|
||
continue
|
||
}
|
||
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{}{}
|
||
if tracker := GetGuestTracker(); tracker != nil {
|
||
tracker.MarkJoin(name, extractLineTime(line))
|
||
}
|
||
continue
|
||
}
|
||
if m := leftPattern.FindStringSubmatch(line); len(m) == 2 {
|
||
name := strings.TrimSpace(m[1])
|
||
delete(state.presentSet, name)
|
||
if tracker := GetGuestTracker(); tracker != nil {
|
||
tracker.MarkLeave(name, extractLineTime(line))
|
||
}
|
||
continue
|
||
}
|
||
}
|
||
state.initialized = true
|
||
if state.worldName != "" {
|
||
log.Printf("world changed: %s", state.worldName)
|
||
SetCurrentWorld(state.worldName)
|
||
if tracker := GetGuestTracker(); tracker != nil {
|
||
tracker.SetCurrentInstance(state.worldName)
|
||
}
|
||
if err := appendRuntimeLog("VRC WORLD", state.worldName); err != nil {
|
||
log.Printf("append world log failed: %v", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func decodeVRChatLog(b []byte) string {
|
||
if len(b) == 0 {
|
||
return ""
|
||
}
|
||
if utf8.Valid(b) {
|
||
return strings.TrimPrefix(string(b), "\ufeff")
|
||
}
|
||
if len(b) >= 2 {
|
||
if b[0] == 0xff && b[1] == 0xfe {
|
||
u16 := make([]uint16, 0, (len(b)-2)/2)
|
||
for i := 2; i+1 < len(b); i += 2 {
|
||
u16 = append(u16, uint16(b[i])|uint16(b[i+1])<<8)
|
||
}
|
||
return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\ufeff")
|
||
}
|
||
if b[0] == 0xfe && b[1] == 0xff {
|
||
u16 := make([]uint16, 0, (len(b)-2)/2)
|
||
for i := 2; i+1 < len(b); i += 2 {
|
||
u16 = append(u16, uint16(b[i+1])|uint16(b[i])<<8)
|
||
}
|
||
return strings.TrimPrefix(fixMojibake(syscall.UTF16ToString(u16)), "\ufeff")
|
||
}
|
||
}
|
||
for _, cp := range []uint32{932, 1252, 65001} {
|
||
if decoded := decodeWithCodePage(b, cp); decoded != "" {
|
||
return strings.TrimPrefix(fixMojibake(decoded), "\ufeff")
|
||
}
|
||
}
|
||
return strings.TrimPrefix(fixMojibake(string(b)), "\ufeff")
|
||
}
|
||
|
||
func DecodeVRChatLog(b []byte) string {
|
||
return decodeVRChatLog(b)
|
||
}
|
||
|
||
func fixMojibake(s string) string {
|
||
if s == "" {
|
||
return ""
|
||
}
|
||
candidates := []string{s}
|
||
if repaired := tryRepairShiftJISMojibake(s); repaired != "" {
|
||
candidates = append(candidates, repaired)
|
||
}
|
||
best := s
|
||
bestScore := scoreReadable(best)
|
||
for _, cand := range candidates {
|
||
if score := scoreReadable(cand); score > bestScore {
|
||
best = cand
|
||
bestScore = score
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
func tryRepairShiftJISMojibake(s string) string {
|
||
raw := []byte(s)
|
||
// Try the common "UTF-8 bytes interpreted as CP932" pattern repair by
|
||
// round-tripping through CP932 in the reverse direction.
|
||
if repaired := decodeWithCodePage(raw, 932); repaired != "" && repaired != s {
|
||
return repaired
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func scoreReadable(s string) int {
|
||
if s == "" {
|
||
return -1
|
||
}
|
||
score := 0
|
||
for _, r := range s {
|
||
switch {
|
||
case r == '<27>':
|
||
score -= 8
|
||
case r >= 0x3040 && r <= 0x30ff:
|
||
score += 3
|
||
case r >= 0x4e00 && r <= 0x9fff:
|
||
score += 3
|
||
case r >= 0x0020 && r <= 0x007e:
|
||
score += 1
|
||
case r == '\n' || r == '\r' || r == '\t':
|
||
score += 1
|
||
default:
|
||
score -= 1
|
||
}
|
||
}
|
||
return score
|
||
}
|
||
|
||
func decodeWithCodePage(b []byte, codePage uint32) string {
|
||
if len(b) == 0 {
|
||
return ""
|
||
}
|
||
kernel32 := syscall.NewLazyDLL("kernel32.dll")
|
||
multiByteToWideChar := kernel32.NewProc("MultiByteToWideChar")
|
||
n, _, _ := multiByteToWideChar.Call(
|
||
uintptr(codePage),
|
||
0,
|
||
uintptr(unsafe.Pointer(&b[0])),
|
||
uintptr(len(b)),
|
||
0,
|
||
0,
|
||
)
|
||
if n == 0 {
|
||
return ""
|
||
}
|
||
buf := make([]uint16, n)
|
||
multiByteToWideChar.Call(
|
||
uintptr(codePage),
|
||
0,
|
||
uintptr(unsafe.Pointer(&b[0])),
|
||
uintptr(len(b)),
|
||
uintptr(unsafe.Pointer(&buf[0])),
|
||
uintptr(len(buf)),
|
||
)
|
||
return syscall.UTF16ToString(buf)
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
func extractLineTime(line string) time.Time {
|
||
if len(line) < 19 {
|
||
return time.Now()
|
||
}
|
||
t, err := time.ParseInLocation("2006.01.02 15:04:05", line[:19], time.Local)
|
||
if err == nil {
|
||
return t
|
||
}
|
||
return time.Now()
|
||
}
|