Clean project layout and fix release workflow
Some checks failed
build-windows-exe / build (push) Failing after 1m13s

This commit is contained in:
messypy
2026-07-27 23:01:24 +09:00
parent 3ff33e40e3
commit dd165d9301
80 changed files with 2908 additions and 5661 deletions

View File

@@ -8,9 +8,9 @@ import (
"syscall"
"time"
"vrc_osc_go/internal/buildinfo"
"vrc_osc_go/internal/config"
"vrc_osc_go/internal/consenttool"
"vrc_osc_go/internal/buildinfo"
"vrc_osc_go/internal/osc"
)
@@ -39,6 +39,8 @@ func Run(configPath string, mode string) error {
}
log.Printf("app version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
InitJoinLeaveEventTracker(NewJoinLeaveEventTracker())
InitWorldHistoryTracker(NewWorldHistoryTracker())
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath)
server := osc.NewServer(cfg.OSC.Host, cfg.OSC.Port)
@@ -89,6 +91,9 @@ func Run(configPath string, mode string) error {
return err
case <-sigCh:
log.Printf("shutdown signal received")
if tracker := GetWorldHistoryTracker(); tracker != nil {
tracker.FinalizeCurrent(time.Now())
}
server.Close()
return nil
}

View File

@@ -3,87 +3,82 @@ package app
import (
"fmt"
"log"
"strings"
"syscall"
"unsafe"
)
const (
vkControl = 0x11
vkShift = 0x10
vkM = 0x4D
keyUp = 0x0002
swRestore = 9
"os/exec"
)
func pressDiscordMuteHotkey() error {
user32 := syscall.NewLazyDLL("user32.dll")
enumWindows := user32.NewProc("EnumWindows")
getWindowTextW := user32.NewProc("GetWindowTextW")
isWindowVisible := user32.NewProc("IsWindowVisible")
setForegroundWindow := user32.NewProc("SetForegroundWindow")
showWindow := user32.NewProc("ShowWindow")
getWindowRect := user32.NewProc("GetWindowRect")
keybdEvent := user32.NewProc("keybd_event")
setCursorPos := user32.NewProc("SetCursorPos")
sleep := syscall.NewLazyDLL("kernel32.dll").NewProc("Sleep")
script := `
$ErrorActionPreference = "Stop"
var target uintptr
cb := syscall.NewCallback(func(hwnd uintptr, lparam uintptr) uintptr {
vis, _, _ := isWindowVisible.Call(hwnd)
if vis == 0 {
return 1
}
buf := make([]uint16, 512)
n, _, _ := getWindowTextW.Call(hwnd, uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)))
if n == 0 {
return 1
}
title := strings.ToLower(strings.TrimSpace(syscall.UTF16ToString(buf[:n])))
if !strings.Contains(title, "discord") {
return 1
}
target = hwnd
return 0
})
r1, _, err1 := enumWindows.Call(cb, 0)
if r1 == 0 && target == 0 {
return fmt.Errorf("discord window not found: %v", err1)
}
if target == 0 {
return fmt.Errorf("discord window not found")
}
function Get-DiscordWindows {
$windows = foreach ($p in Get-Process) {
$title = ($p.MainWindowTitle | Out-String).Trim()
if (-not $title) { continue }
if (-not $title.ToLower().Contains('discord')) { continue }
$p
}
if (-not $windows) { return @() }
return $windows
}
showWindow.Call(target, swRestore)
setForegroundWindow.Call(target)
sleep.Call(150)
function Get-BestDiscordWindow {
$windows = Get-DiscordWindows
if (-not $windows -or $windows.Count -eq 0) { return $null }
type rect struct {
Left int32
Top int32
Right int32
Bottom int32
}
var r rect
if v, _, _ := getWindowRect.Call(target, uintptr(unsafe.Pointer(&r))); v != 0 {
x := int((r.Left + r.Right) / 2)
y := int((r.Top + r.Bottom) / 2)
setCursorPos.Call(uintptr(x), uintptr(y))
sleep.Call(50)
}
$browserKeywords = @('google chrome', 'microsoft edge', 'mozilla firefox', 'brave', 'opera')
$ranked = $windows | Sort-Object {
$title = ($_.MainWindowTitle | Out-String).Trim().ToLower()
$score = 0
foreach ($keyword in $browserKeywords) {
if ($title.Contains($keyword)) { $score += 10 }
}
if ($title -match '^\(\d+\)\s*discord') { $score += 20 }
if ($title.Contains('discord')) { $score += 5 }
$score
} -Descending
send := func(vk byte, flags uintptr) {
keybdEvent.Call(uintptr(vk), 0, flags, 0)
}
send(vkControl, 0)
send(vkShift, 0)
send(vkM, 0)
send(vkM, keyUp)
send(vkShift, keyUp)
send(vkControl, keyUp)
sleep.Call(100)
return $ranked | Select-Object -First 1
}
SetDiscordWindow("Discord")
log.Printf("discord hotkey sent via keybd_event")
function Get-VRChatWindow {
$windows = Get-Process | Where-Object {
$_.MainWindowTitle -and (
$_.MainWindowTitle -eq 'VRChat' -or $_.MainWindowTitle.ToLower().StartsWith('vrchat ')
)
}
if (-not $windows) { return $null }
return $windows | Sort-Object { $_.MainWindowHandle } -Descending | Select-Object -First 1
}
function Activate-Window($p) {
if ($null -eq $p) { return }
$wshell = New-Object -ComObject WScript.Shell
try { $null = $wshell.AppActivate($p.Id) } catch {}
Start-Sleep -Milliseconds 200
}
$discord = Get-BestDiscordWindow
if ($null -eq $discord) { exit 2 }
Activate-Window $discord
$wshell = New-Object -ComObject WScript.Shell
Start-Sleep -Milliseconds 200
$wshell.SendKeys('^+m')
Start-Sleep -Milliseconds 200
$vrchat = Get-VRChatWindow
if ($null -ne $vrchat) {
Activate-Window $vrchat
}
`
cmd := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", script)
out, err := cmd.CombinedOutput()
if len(out) > 0 {
log.Printf("discord mute output: %s", string(out))
}
if err != nil {
return fmt.Errorf("discord mute failed: %w", err)
}
return nil
}

View File

@@ -54,6 +54,41 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
return nil
}
func unmuteDiscordIfMuted(state *discordMuteState, source string) error {
if state == nil {
return fmt.Errorf("discord state is nil")
}
state.mu.Lock()
defer state.mu.Unlock()
if !state.muted {
log.Printf("INFO %s requested Discord unmute but already unmuted", source)
SetDiscordAction("already_unmuted")
SetDiscordSource(source)
return nil
}
now := time.Now()
if !state.lastAction.IsZero() && now.Sub(state.lastAction) < discordDebounce {
return nil
}
log.Printf("ACTION %s -> Discord unmute hotkey", source)
if err := pressDiscordMuteHotkey(); err != nil {
log.Printf("discord unmute hotkey failed: %v", err)
SetDiscordAction("hotkey_failed")
SetDiscordSource(source + "_error")
state.lastAction = now
return err
}
state.muted = false
state.lastAction = now
SetDiscordMuted(false)
SetDiscordAction("unmuted")
SetDiscordSource(source)
return nil
}
func toBool(v osc.Value) (bool, error) {
switch v.Type {
case 'T':

View File

@@ -1,8 +1,8 @@
package app
import (
"fmt"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
@@ -40,6 +40,14 @@ func (t *GuestTracker) SetCurrentInstance(label string) {
t.current = label
}
func (t *GuestTracker) ResetCurrentInstance(label string) {
t.mu.Lock()
defer t.mu.Unlock()
t.current = label
t.items = map[string]*GuestStatus{}
_ = t.persistLocked()
}
func (t *GuestTracker) CurrentInstance() string {
t.mu.RLock()
defer t.mu.RUnlock()

View File

@@ -0,0 +1,214 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"vrc_osc_go/internal/common"
)
const joinLeaveEventsFileName = "join_leave_events.json"
var joinLeaveEventLinePattern = regexp.MustCompile(`^\[(join|leave)\]\s+(.+?)\s+\((\d+)\)$`)
type JoinLeaveEvent struct {
At time.Time `json:"at"`
Kind string `json:"kind"`
Name string `json:"name"`
Count int `json:"count,omitempty"`
}
type joinLeaveEventsSnapshot struct {
UpdatedAt time.Time `json:"updated_at"`
Events []JoinLeaveEvent `json:"events"`
}
type JoinLeaveEventTracker struct {
mu sync.RWMutex
events []JoinLeaveEvent
}
var joinLeaveEventTracker *JoinLeaveEventTracker
func InitJoinLeaveEventTracker(t *JoinLeaveEventTracker) {
joinLeaveEventTracker = t
}
func GetJoinLeaveEventTracker() *JoinLeaveEventTracker {
return joinLeaveEventTracker
}
func NewJoinLeaveEventTracker() *JoinLeaveEventTracker {
t := &JoinLeaveEventTracker{}
_ = t.load()
return t
}
func (t *JoinLeaveEventTracker) load() error {
dir := filepath.Join(common.RootDir(), "runtime")
path := filepath.Join(dir, joinLeaveEventsFileName)
b, err := os.ReadFile(path)
if err == nil && len(b) > 0 {
if events, ok := decodeJoinLeaveEventsSnapshot(b); ok {
t.events = dedupeJoinLeaveEvents(events)
return nil
}
}
events, err := loadJoinLeaveEventsFromTextLog(filepath.Join(dir, "join_leave.log"))
if err != nil {
return err
}
t.events = dedupeJoinLeaveEvents(events)
if len(t.events) > 0 {
_ = t.persistLocked()
}
return nil
}
func decodeJoinLeaveEventsSnapshot(b []byte) ([]JoinLeaveEvent, bool) {
var snap joinLeaveEventsSnapshot
if err := json.Unmarshal(b, &snap); err == nil && len(snap.Events) > 0 {
return snap.Events, true
}
var events []JoinLeaveEvent
if err := json.Unmarshal(b, &events); err == nil {
return events, true
}
return nil, false
}
func loadJoinLeaveEventsFromTextLog(path string) ([]JoinLeaveEvent, error) {
b, err := os.ReadFile(path)
if err != nil || len(b) == 0 {
return nil, err
}
events := make([]JoinLeaveEvent, 0, 256)
var currentAt time.Time
for _, raw := range strings.Split(string(b), "\n") {
line := strings.TrimSpace(strings.TrimRight(raw, "\r"))
if line == "" {
continue
}
if strings.Contains(line, "VRC JOIN/LEAVE") {
if len(line) >= 21 {
if at, err := time.Parse("2006-01-02 15:04:05", line[1:20]); err == nil {
currentAt = at
}
}
continue
}
m := joinLeaveEventLinePattern.FindStringSubmatch(line)
if len(m) != 4 {
continue
}
count, _ := strconv.Atoi(m[3])
events = append(events, JoinLeaveEvent{
At: currentAt,
Kind: m[1],
Name: strings.TrimSpace(m[2]),
Count: count,
})
}
return events, nil
}
func (t *JoinLeaveEventTracker) Record(kind, name string, count int, at time.Time) {
if t == nil {
return
}
kind = strings.TrimSpace(kind)
name = strings.TrimSpace(name)
if kind != "join" && kind != "leave" {
return
}
if name == "" {
return
}
if at.IsZero() {
at = time.Now()
}
ev := JoinLeaveEvent{At: at, Kind: kind, Name: name, Count: count}
t.mu.Lock()
defer t.mu.Unlock()
if containsJoinLeaveEvent(t.events, ev) {
return
}
t.events = append(t.events, ev)
_ = t.persistLocked()
}
func (t *JoinLeaveEventTracker) Snapshot() []JoinLeaveEvent {
if t == nil {
return nil
}
t.mu.RLock()
defer t.mu.RUnlock()
out := append([]JoinLeaveEvent(nil), t.events...)
sort.SliceStable(out, func(i, j int) bool {
if out[i].At.Equal(out[j].At) {
if out[i].Kind == out[j].Kind {
return out[i].Name < out[j].Name
}
return out[i].Kind < out[j].Kind
}
return out[i].At.Before(out[j].At)
})
return out
}
func (t *JoinLeaveEventTracker) persistLocked() error {
dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
f, err := os.Create(filepath.Join(dir, joinLeaveEventsFileName))
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(joinLeaveEventsSnapshot{
UpdatedAt: time.Now(),
Events: append([]JoinLeaveEvent(nil), t.events...),
})
}
func dedupeJoinLeaveEvents(events []JoinLeaveEvent) []JoinLeaveEvent {
if len(events) <= 1 {
return events
}
seen := make(map[string]struct{}, len(events))
out := make([]JoinLeaveEvent, 0, len(events))
for _, ev := range events {
key := joinLeaveEventKey(ev)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, ev)
}
return out
}
func containsJoinLeaveEvent(events []JoinLeaveEvent, candidate JoinLeaveEvent) bool {
key := joinLeaveEventKey(candidate)
for _, ev := range events {
if joinLeaveEventKey(ev) == key {
return true
}
}
return false
}
func joinLeaveEventKey(ev JoinLeaveEvent) string {
return ev.At.UTC().Format(time.RFC3339Nano) + "|" + ev.Kind + "|" + ev.Name
}

View File

@@ -0,0 +1,26 @@
package app
import (
"testing"
"time"
)
func TestDeduplicateJoinLeaveEventsIgnoresRepeatedReplays(t *testing.T) {
at := time.Date(2026, 7, 1, 1, 23, 45, 0, time.FixedZone("JST", 9*60*60))
events := []JoinLeaveEvent{
{At: at, Kind: "join", Name: "Alice", Count: 10},
{At: at, Kind: "join", Name: "Alice", Count: 11},
{At: at.Add(5 * time.Second), Kind: "leave", Name: "Alice", Count: 9},
}
got := dedupeJoinLeaveEvents(events)
if len(got) != 2 {
t.Fatalf("expected 2 unique events, got %d: %#v", len(got), got)
}
if got[0].Count != 10 {
t.Fatalf("expected first event to remain stable, got %#v", got[0])
}
if got[1].Kind != "leave" {
t.Fatalf("expected leave event to remain, got %#v", got[1])
}
}

View File

@@ -0,0 +1,28 @@
package app
import (
"time"
"vrc_osc_go/internal/config"
)
const defaultGUIRefreshInterval = 2 * time.Second
func guiRefreshIntervalFromConfig(cfg *config.Config) time.Duration {
if cfg == nil {
return defaultGUIRefreshInterval
}
interval := cfg.GUI.RefreshInterval()
if interval < time.Second {
return time.Second
}
return interval
}
func currentGUIRefreshInterval() time.Duration {
cfg, err := config.Load("")
if err != nil || cfg == nil {
return defaultGUIRefreshInterval
}
return guiRefreshIntervalFromConfig(cfg)
}

View File

@@ -26,18 +26,50 @@ func (w *runtimeLogWriter) Write(p []byte) (int, error) {
return w.file.Write(p)
}
func (w *runtimeLogWriter) ensureFile(path string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file != nil {
return nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
w.file = f
return nil
}
func (w *runtimeLogWriter) appendf(path, format string, args ...any) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
w.file = f
}
_, err := fmt.Fprintf(w.file, format, args...)
return err
}
var runtimeLogFileWriter runtimeLogWriter
var joinLeaveLogFileWriter runtimeLogWriter
var desktopJoinLogMu sync.Mutex
var desktopJoinLogLastPath string
var desktopJoinLogLastBody string
func setupRuntimeLogger() error {
dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
path := filepath.Join(dir, "runtime.log")
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
if err := runtimeLogFileWriter.ensureFile(path); err != nil {
return err
}
logWriter := &runtimeLogWriter{file: f}
log.SetOutput(io.MultiWriter(os.Stderr, logWriter))
log.SetOutput(io.MultiWriter(os.Stderr, &runtimeLogFileWriter))
return nil
}
@@ -51,13 +83,7 @@ func appendRuntimeLog(title, text string) error {
if body == "" {
body = "(empty)"
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintf(f, "\n[%s] %s\n%s\n", time.Now().Format("2006-01-02 15:04:05"), title, body)
return err
return runtimeLogFileWriter.appendf(path, "\n[%s] %s\n%s\n", time.Now().Format("2006-01-02 15:04:05"), title, body)
}
func AppendRuntimeLog(title, text string) error {
@@ -74,13 +100,7 @@ func appendJoinLeaveLog(text string) error {
if body == "" {
body = "(empty)"
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644)
if err != nil {
return err
}
defer f.Close()
_, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
return err
return joinLeaveLogFileWriter.appendf(path, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
}
func appendDesktopJoinLog(title, worldLabel, text string) error {
@@ -101,6 +121,11 @@ func appendDesktopJoinLog(title, worldLabel, text string) error {
if body == "" {
body = "(empty)"
}
desktopJoinLogMu.Lock()
defer desktopJoinLogMu.Unlock()
if desktopJoinLogLastPath == path && desktopJoinLogLastBody == body {
return nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
fallbackDir := filepath.Join(common.RootDir(), "runtime")
@@ -115,8 +140,12 @@ func appendDesktopJoinLog(title, worldLabel, text string) error {
path = fallbackPath
}
defer f.Close()
_, err = fmt.Fprintf(f, "%s\nWorld: %s\n\n%s\n", title, worldLabel, body)
return err
if _, err = fmt.Fprintf(f, "%s\n", body); err != nil {
return err
}
desktopJoinLogLastPath = path
desktopJoinLogLastBody = body
return nil
}
func AppendDesktopJoinLog(title, worldLabel, text string) error {

View File

@@ -2,13 +2,11 @@ package app
import (
"log"
"os"
"regexp"
"strings"
"time"
"vrc_osc_go/internal/config"
"vrc_osc_go/internal/osc"
)
var (
@@ -25,31 +23,32 @@ func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
for {
path, err := findLatestVrchatLog()
if err != nil {
time.Sleep(2 * time.Second)
time.Sleep(currentGUIRefreshInterval())
continue
}
text, err := os.ReadFile(path)
text, err := readFileTail(path, vrchatLogTailBytes)
if err != nil {
time.Sleep(2 * time.Second)
time.Sleep(currentGUIRefreshInterval())
continue
}
nextState := extractLatestSelfLogState(string(text), cfg.VrcLog.SelfName)
liveCfg := cfg
if loaded, err := config.Load(""); err == nil && loaded != nil {
liveCfg = loaded
}
nextState := extractLatestSelfLogState(DecodeVRChatLog(text), liveCfg.VrcLog.SelfName)
if nextState != "" && nextState != lastState {
if nextState == "joined" {
if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil {
log.Printf("self monitor discord mute failed: %v", err)
if nextState == "left" && liveCfg.GUI.AutoUnmuteOnSelfLeave {
if err := unmuteDiscordIfMuted(state, "self_leave"); err != nil {
log.Printf("self monitor discord unmute failed: %v", err)
SetDiscordSource("self_monitor_error")
}
} else if nextState == "left" {
if err := handleDiscordSend(state, []osc.Value{{Type: 'F', Bool: false}}); err != nil {
log.Printf("self monitor discord mute failed: %v", err)
SetDiscordSource("self_monitor_error")
}
log.Printf("self monitor detected leave; auto unmute disabled")
}
SetDiscordSource("self_monitor")
lastState = nextState
}
time.Sleep(2 * time.Second)
time.Sleep(guiRefreshIntervalFromConfig(liveCfg))
}
}()
}

View File

@@ -19,6 +19,7 @@ type RuntimeState struct {
LastOCRText string
LastTranslate string
CurrentWorld string
CurrentWorldSince string
}
var runtimeState = &RuntimeState{}
@@ -84,6 +85,29 @@ func SetCurrentWorld(v string) {
_ = persistRuntimeState()
}
func SetCurrentWorldVisit(world string, since time.Time) {
runtimeState.mu.Lock()
runtimeState.CurrentWorld = world
if since.IsZero() {
runtimeState.CurrentWorldSince = ""
} else {
runtimeState.CurrentWorldSince = since.Format(time.RFC3339)
}
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func SetCurrentWorldSince(v time.Time) {
runtimeState.mu.Lock()
if v.IsZero() {
runtimeState.CurrentWorldSince = ""
} else {
runtimeState.CurrentWorldSince = v.Format(time.RFC3339)
}
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func SnapshotRuntime() RuntimeState {
runtimeState.mu.RLock()
defer runtimeState.mu.RUnlock()
@@ -111,6 +135,7 @@ func persistRuntimeState() error {
"ocr": s.LastOCRText,
"translate": s.LastTranslate,
"world": s.CurrentWorld,
"world_since": s.CurrentWorldSince,
"updated_at": time.Now().Format(time.RFC3339),
})
}

View File

@@ -1,15 +1,16 @@
package app
import (
"bytes"
"fmt"
"io"
"log"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode/utf8"
"syscall"
"time"
"unsafe"
)
@@ -24,6 +25,8 @@ var (
enteringRoomPattern = regexp.MustCompile(`\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$`)
)
const vrchatLogTailBytes = 2 * 1024 * 1024
type vrcLogState struct {
location string
worldID string
@@ -94,13 +97,13 @@ func watchVrchatLog() {
path, err := findLatestVrchatLog()
if err != nil {
log.Printf("vrchat log not found: %v", err)
time.Sleep(2 * time.Second)
time.Sleep(currentGUIRefreshInterval())
continue
}
info, err := os.Stat(path)
if err != nil {
log.Printf("vrchat log stat failed: %v", err)
time.Sleep(2 * time.Second)
time.Sleep(currentGUIRefreshInterval())
continue
}
log.Printf("vrchat log loop path=%s size=%d last=%d", path, info.Size(), lastSize)
@@ -119,7 +122,7 @@ func watchVrchatLog() {
} else {
lastSize = info.Size()
}
time.Sleep(2 * time.Second)
time.Sleep(currentGUIRefreshInterval())
continue
}
@@ -128,10 +131,10 @@ func watchVrchatLog() {
}
if info.Size() > lastSize {
log.Printf("vrchat log updated path=%s old=%d new=%d", path, lastSize, info.Size())
b, err := os.ReadFile(path)
b, err := readFileFrom(path, lastSize)
if err != nil {
log.Printf("vrchat log read failed: %v", err)
time.Sleep(2 * time.Second)
time.Sleep(currentGUIRefreshInterval())
continue
}
text := decodeVRChatLog(b)
@@ -146,16 +149,13 @@ func watchVrchatLog() {
if err := appendRuntimeLog("VRC LOG", line); err != nil {
log.Printf("append runtime log failed: %v", err)
}
at := extractLineTime(line)
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)
}
handleWorldChange(state, worldLabel, at)
_ = 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)
@@ -164,6 +164,9 @@ func watchVrchatLog() {
if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkJoin(name, at)
}
if tracker := GetJoinLeaveEventTracker(); tracker != nil {
tracker.Record("join", name, len(state.presentSet), at)
}
out := fmt.Sprintf("[join] %s (%d)", name, len(state.presentSet))
log.Print(out)
if err := appendJoinLeaveLog(out); err != nil {
@@ -179,6 +182,9 @@ func watchVrchatLog() {
if tracker := GetGuestTracker(); tracker != nil {
tracker.MarkLeave(name, at)
}
if tracker := GetJoinLeaveEventTracker(); tracker != nil {
tracker.Record("leave", name, len(state.presentSet), at)
}
out := fmt.Sprintf("[leave] %s (%d)", name, len(state.presentSet))
log.Print(out)
if err := appendJoinLeaveLog(out); err != nil {
@@ -190,10 +196,55 @@ func watchVrchatLog() {
log.Printf("vrchat log batch done join=%d leave=%d", joinHits, leaveHits)
lastSize = info.Size()
}
time.Sleep(2 * time.Second)
time.Sleep(currentGUIRefreshInterval())
}
}
func readFileFrom(path string, offset int64) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
if offset > 0 {
if _, err := f.Seek(offset, 0); err != nil {
return nil, err
}
}
return io.ReadAll(f)
}
func readFileTail(path string, maxBytes int64) ([]byte, error) {
if maxBytes <= 0 {
return os.ReadFile(path)
}
info, err := os.Stat(path)
if err != nil {
return nil, err
}
if info.Size() <= maxBytes {
return os.ReadFile(path)
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
if _, err := f.Seek(info.Size()-maxBytes, 0); err != nil {
return nil, err
}
b, err := io.ReadAll(f)
if err != nil {
return nil, err
}
for i, c := range b {
if c == '\n' && i+1 < len(b) {
return b[i+1:], nil
}
}
return b, nil
}
func scanExistingVrchatLog(path string, state *vrcLogState) error {
b, err := os.ReadFile(path)
if err != nil {
@@ -208,13 +259,18 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
state.initialized = false
text := decodeVRChatLog(b)
var lastWorldAt time.Time
sawWorldChange := false
for _, raw := range strings.Split(text, "\n") {
line := strings.TrimRight(raw, "\r")
if line == "" {
continue
}
if changed, _ := updateWorldState(state, line); changed {
sawWorldChange = true
lastWorldAt = extractLineTime(line)
state.presentSet = map[string]struct{}{}
handleWorldChange(state, state.worldName, lastWorldAt)
}
if m := joinPattern.FindStringSubmatch(line); len(m) == 2 {
name := strings.TrimSpace(m[1])
@@ -235,10 +291,12 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
}
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 !sawWorldChange {
log.Printf("world changed: %s", state.worldName)
if lastWorldAt.IsZero() {
lastWorldAt = time.Now()
}
handleWorldChange(state, state.worldName, lastWorldAt)
}
if err := appendRuntimeLog("VRC WORLD", state.worldName); err != nil {
log.Printf("append world log failed: %v", err)
@@ -251,9 +309,6 @@ 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)
@@ -270,12 +325,23 @@ func decodeVRChatLog(b []byte) string {
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")
lines := bytes.Split(b, []byte{'\n'})
var out strings.Builder
for i, raw := range lines {
if i > 0 {
out.WriteByte('\n')
}
out.WriteString(decodeVRChatLogLine(raw))
}
return strings.TrimPrefix(fixMojibake(string(b)), "\ufeff")
return strings.TrimPrefix(out.String(), "\ufeff")
}
func decodeVRChatLogLine(b []byte) string {
line := bytes.TrimRight(b, "\r")
if len(line) == 0 {
return ""
}
return strings.TrimPrefix(string(line), "\ufeff")
}
func DecodeVRChatLog(b []byte) string {
@@ -402,6 +468,9 @@ func updateWorldState(state *vrcLogState, line string) (bool, string) {
nextLocation = worldID + ":" + instanceID
}
}
if nextLocation == "" && roomTitle != "" {
nextLocation = roomTitle
}
if nextLocation != "" && nextLocation != state.location {
state.location = nextLocation
@@ -421,8 +490,8 @@ func updateWorldState(state *vrcLogState, line string) (bool, string) {
}
if state.initialized {
log.Printf("world changed: %s", label)
_ = appendRuntimeLog("VRC WORLD", label)
}
state.initialized = true
return true, label
}
@@ -489,3 +558,16 @@ func extractLineTime(line string) time.Time {
}
return time.Now()
}
func handleWorldChange(state *vrcLogState, worldLabel string, at time.Time) {
if state == nil {
return
}
SetCurrentWorldVisit(worldLabel, at)
if tracker := GetWorldHistoryTracker(); tracker != nil {
tracker.ObserveWorld(worldLabel, state.worldID, state.instanceID, at)
}
if tracker := GetGuestTracker(); tracker != nil {
tracker.ResetCurrentInstance(worldLabel)
}
}

View File

@@ -0,0 +1,43 @@
package app
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestFindLatestVrchatLogKeepsEmptyNewestFile(t *testing.T) {
base := t.TempDir()
userProfile := filepath.Join(base, "profile")
logDir := filepath.Join(userProfile, "AppData", "LocalLow", "VRChat", "VRChat")
if err := os.MkdirAll(logDir, 0o755); err != nil {
t.Fatalf("mkdir log dir: %v", err)
}
t.Setenv("USERPROFILE", userProfile)
oldPath := filepath.Join(logDir, "output_log_2026-06-29_20-46-23.txt")
newPath := filepath.Join(logDir, "output_log_2026-06-30_22-25-51.txt")
if err := os.WriteFile(oldPath, []byte("old"), 0o644); err != nil {
t.Fatalf("write old log: %v", err)
}
if err := os.WriteFile(newPath, nil, 0o644); err != nil {
t.Fatalf("write new log: %v", err)
}
oldTime := time.Date(2026, 6, 30, 22, 25, 52, 0, time.Local)
newTime := time.Date(2026, 6, 30, 22, 26, 0, 0, time.Local)
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
t.Fatalf("chtimes old log: %v", err)
}
if err := os.Chtimes(newPath, newTime, newTime); err != nil {
t.Fatalf("chtimes new log: %v", err)
}
got, err := findLatestVrchatLog()
if err != nil {
t.Fatalf("find latest log: %v", err)
}
if got != newPath {
t.Fatalf("expected newest empty log, got %q want %q", got, newPath)
}
}

View File

@@ -0,0 +1,260 @@
package app
import (
"encoding/json"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"vrc_osc_go/internal/common"
)
const worldHistoryFileName = "world_history.json"
type WorldVisit struct {
WorldLabel string `json:"world_label"`
WorldID string `json:"world_id,omitempty"`
InstanceID string `json:"instance_id,omitempty"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at,omitempty"`
}
type worldHistorySnapshot struct {
UpdatedAt time.Time `json:"updated_at"`
Visits []WorldVisit `json:"visits"`
}
type WorldHistoryTracker struct {
mu sync.RWMutex
current *WorldVisit
visits []WorldVisit
}
var worldHistoryTracker *WorldHistoryTracker
func InitWorldHistoryTracker(t *WorldHistoryTracker) {
worldHistoryTracker = t
}
func GetWorldHistoryTracker() *WorldHistoryTracker {
return worldHistoryTracker
}
func NewWorldHistoryTracker() *WorldHistoryTracker {
t := &WorldHistoryTracker{}
_ = t.load()
return t
}
func (t *WorldHistoryTracker) load() error {
dir := filepath.Join(common.RootDir(), "runtime")
path := filepath.Join(dir, worldHistoryFileName)
b, err := os.ReadFile(path)
if err != nil {
return err
}
var snap worldHistorySnapshot
if err := json.Unmarshal(b, &snap); err != nil {
return err
}
t.visits = mergeWorldVisits(snap.Visits)
return nil
}
func (t *WorldHistoryTracker) ObserveWorld(worldLabel, worldID, instanceID string, at time.Time) {
if t == nil {
return
}
if at.IsZero() {
at = time.Now()
}
label := normalizeWorldLabel(worldLabel, worldID, instanceID)
key := worldVisitKey(worldID, instanceID, label)
t.mu.Lock()
defer t.mu.Unlock()
if t.current != nil && t.currentKey() == key {
if t.current.WorldLabel == "" {
t.current.WorldLabel = label
}
if t.current.WorldID == "" {
t.current.WorldID = worldID
}
if t.current.InstanceID == "" {
t.current.InstanceID = instanceID
}
return
}
if t.current != nil {
if t.current.EndedAt.IsZero() {
t.current.EndedAt = at
}
t.visits = append(t.visits, *t.current)
}
t.current = &WorldVisit{
WorldLabel: label,
WorldID: worldID,
InstanceID: instanceID,
StartedAt: at,
}
_ = t.persistLocked()
}
func (t *WorldHistoryTracker) FinalizeCurrent(at time.Time) {
if t == nil {
return
}
if at.IsZero() {
at = time.Now()
}
t.mu.Lock()
defer t.mu.Unlock()
if t.current == nil {
return
}
if t.current.EndedAt.IsZero() {
t.current.EndedAt = at
}
t.visits = append(t.visits, *t.current)
t.current = nil
_ = t.persistLocked()
}
func (t *WorldHistoryTracker) Current() (WorldVisit, bool) {
if t == nil {
return WorldVisit{}, false
}
t.mu.RLock()
defer t.mu.RUnlock()
if t.current == nil {
return WorldVisit{}, false
}
return *t.current, true
}
func (t *WorldHistoryTracker) Snapshot() []WorldVisit {
if t == nil {
return nil
}
t.mu.RLock()
defer t.mu.RUnlock()
out := make([]WorldVisit, 0, len(t.visits)+1)
out = append(out, mergeWorldVisits(t.visits)...)
if t.current != nil {
out = append(out, *t.current)
}
sort.SliceStable(out, func(i, j int) bool {
ti := out[i].EndedAt
if ti.IsZero() {
ti = out[i].StartedAt
}
tj := out[j].EndedAt
if tj.IsZero() {
tj = out[j].StartedAt
}
if ti.Equal(tj) {
return strings.ToLower(out[i].WorldLabel) < strings.ToLower(out[j].WorldLabel)
}
return ti.After(tj)
})
return out
}
func (t *WorldHistoryTracker) currentKey() string {
if t.current == nil {
return ""
}
return worldVisitKey(t.current.WorldID, t.current.InstanceID, t.current.WorldLabel)
}
func (t *WorldHistoryTracker) persistLocked() error {
dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
f, err := os.Create(filepath.Join(dir, worldHistoryFileName))
if err != nil {
return err
}
defer f.Close()
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(worldHistorySnapshot{
UpdatedAt: time.Now(),
Visits: mergeWorldVisits(t.visits),
})
}
func mergeWorldVisits(visits []WorldVisit) []WorldVisit {
if len(visits) <= 1 {
return append([]WorldVisit(nil), visits...)
}
type bucket struct {
visit WorldVisit
}
seen := make(map[string]int, len(visits))
out := make([]bucket, 0, len(visits))
for _, visit := range visits {
key := worldVisitKey(visit.WorldID, visit.InstanceID, visit.WorldLabel) + "|" + visit.StartedAt.UTC().Format(time.RFC3339Nano)
if idx, ok := seen[key]; ok {
if visit.EndedAt.After(out[idx].visit.EndedAt) {
out[idx].visit.EndedAt = visit.EndedAt
}
if strings.TrimSpace(out[idx].visit.WorldLabel) == "" && strings.TrimSpace(visit.WorldLabel) != "" {
out[idx].visit.WorldLabel = visit.WorldLabel
}
continue
}
seen[key] = len(out)
out = append(out, bucket{visit: visit})
}
sort.SliceStable(out, func(i, j int) bool {
ti := out[i].visit.EndedAt
if ti.IsZero() {
ti = out[i].visit.StartedAt
}
tj := out[j].visit.EndedAt
if tj.IsZero() {
tj = out[j].visit.StartedAt
}
if ti.Equal(tj) {
return strings.ToLower(out[i].visit.WorldLabel) < strings.ToLower(out[j].visit.WorldLabel)
}
return ti.After(tj)
})
merged := make([]WorldVisit, 0, len(out))
for _, item := range out {
merged = append(merged, item.visit)
}
return merged
}
func worldVisitKey(worldID, instanceID, worldLabel string) string {
if worldID != "" {
if instanceID != "" {
return worldID + ":" + instanceID
}
return worldID
}
return strings.TrimSpace(worldLabel)
}
func normalizeWorldLabel(worldLabel, worldID, instanceID string) string {
label := strings.TrimSpace(worldLabel)
if label != "" {
return label
}
if worldID != "" {
if instanceID != "" {
return worldID + ":" + instanceID
}
return worldID
}
return "(unknown)"
}

View File

@@ -33,20 +33,33 @@ type VrcLogConfig struct {
}
type GUIConfig struct {
TopMost bool
FontSize int
AutoUnmuteOnSelfLeave bool
RefreshIntervalValue int
RefreshIntervalUnit string
HistoryRegex string
HistoryFromDate string
HistoryToDate string
TopMost bool
FontSize int
AutoUnmuteOnSelfLeave bool
RefreshIntervalValue int
RefreshIntervalUnit string
HistoryRegex string
HistoryFromDate string
HistoryToDate string
HistoryExportDir string
HistoryExportIncludeTime bool
HistoryExportIncludeWorld bool
HistoryExportIncludeJoinLeave bool
HistoryExportCustomEnabled bool
HistoryExportCustom string
}
func Load(path string) (*Config, error) {
cfg := &Config{
OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
GUI: GUIConfig{FontSize: 18, RefreshIntervalValue: 2, RefreshIntervalUnit: "sec"},
GUI: GUIConfig{
FontSize: 18,
RefreshIntervalValue: 2,
RefreshIntervalUnit: "sec",
HistoryExportIncludeTime: true,
HistoryExportIncludeWorld: true,
HistoryExportIncludeJoinLeave: true,
},
}
if path == "" {
path = filepath.Join(common.RootDir(), "config", "config.toml")
@@ -76,8 +89,7 @@ func Load(path string) (*Config, error) {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
val = strings.Trim(val, "\"'")
val := parseConfigValue(parts[1])
switch section {
case "osc":
switch key {
@@ -135,6 +147,18 @@ func Load(path string) (*Config, error) {
cfg.GUI.HistoryFromDate = normalizeHistoryDate(val)
case "history_to":
cfg.GUI.HistoryToDate = normalizeHistoryDate(val)
case "history_export_dir":
cfg.GUI.HistoryExportDir = strings.TrimSpace(val)
case "history_export_include_time":
cfg.GUI.HistoryExportIncludeTime = parseBool(val, true)
case "history_export_include_world":
cfg.GUI.HistoryExportIncludeWorld = parseBool(val, true)
case "history_export_include_join_leave":
cfg.GUI.HistoryExportIncludeJoinLeave = parseBool(val, true)
case "history_export_custom_enabled":
cfg.GUI.HistoryExportCustomEnabled = parseBool(val, false)
case "history_export_custom":
cfg.GUI.HistoryExportCustom = strings.TrimSpace(val)
}
}
}
@@ -144,6 +168,8 @@ func Load(path string) (*Config, error) {
cfg.GUI.RefreshIntervalUnit = normalizeRefreshIntervalUnit(cfg.GUI.RefreshIntervalUnit)
cfg.GUI.HistoryFromDate = normalizeHistoryDate(cfg.GUI.HistoryFromDate)
cfg.GUI.HistoryToDate = normalizeHistoryDate(cfg.GUI.HistoryToDate)
cfg.GUI.HistoryExportDir = strings.TrimSpace(cfg.GUI.HistoryExportDir)
cfg.GUI.HistoryExportCustom = strings.TrimSpace(cfg.GUI.HistoryExportCustom)
if cfg.VrcLog.GuestFile != "" {
if !filepath.IsAbs(cfg.VrcLog.GuestFile) {
cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile)
@@ -155,6 +181,17 @@ func Load(path string) (*Config, error) {
return cfg, scanner.Err()
}
func parseConfigValue(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if unquoted, err := strconv.Unquote(raw); err == nil {
return strings.TrimSpace(unquoted)
}
return strings.TrimSpace(strings.Trim(raw, "\"'"))
}
func SaveGUI(path string, gui GUIConfig) error {
path = resolvePath(path)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
@@ -263,6 +300,12 @@ func appendGUISection(out *[]string, gui GUIConfig) {
fmt.Sprintf("history_regex = %q", gui.HistoryRegex),
fmt.Sprintf("history_from = %q", gui.HistoryFromDate),
fmt.Sprintf("history_to = %q", gui.HistoryToDate),
fmt.Sprintf("history_export_dir = %q", strings.TrimSpace(gui.HistoryExportDir)),
fmt.Sprintf("history_export_include_time = %t", gui.HistoryExportIncludeTime),
fmt.Sprintf("history_export_include_world = %t", gui.HistoryExportIncludeWorld),
fmt.Sprintf("history_export_include_join_leave = %t", gui.HistoryExportIncludeJoinLeave),
fmt.Sprintf("history_export_custom_enabled = %t", gui.HistoryExportCustomEnabled),
fmt.Sprintf("history_export_custom = %q", strings.TrimSpace(gui.HistoryExportCustom)),
)
}

View File

@@ -35,14 +35,14 @@ func TestLoadOSCValues(t *testing.T) {
func TestLoadGUIValues(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.toml")
if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\nauto_unmute_on_self_leave = true\nrefresh_interval_value = 5\nrefresh_interval_unit = \"min\"\nhistory_regex = \"[wip]\"\nhistory_from = \"2026-01-01\"\nhistory_to = \"2026-01-31\"\n"), 0o600); err != nil {
if err := os.WriteFile(path, []byte("[gui]\ntop_most = true\nfont_size = 24\nauto_unmute_on_self_leave = true\nrefresh_interval_value = 5\nrefresh_interval_unit = \"min\"\nhistory_regex = \"[wip]\"\nhistory_from = \"2026-01-01\"\nhistory_to = \"2026-01-31\"\nhistory_export_dir = \"C:/tmp/visit\"\nhistory_export_include_time = false\nhistory_export_include_world = true\nhistory_export_include_join_leave = true\nhistory_export_custom_enabled = true\nhistory_export_custom = \"[wip]\"\n"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 5 || cfg.GUI.RefreshIntervalUnit != "min" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" {
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 24 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 5 || cfg.GUI.RefreshIntervalUnit != "min" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" || cfg.GUI.HistoryExportDir != "C:/tmp/visit" || cfg.GUI.HistoryExportIncludeTime != false || cfg.GUI.HistoryExportIncludeWorld != true || cfg.GUI.HistoryExportIncludeJoinLeave != true || cfg.GUI.HistoryExportCustomEnabled != true || cfg.GUI.HistoryExportCustom != "[wip]" {
t.Fatalf("unexpected gui values: %+v", cfg.GUI)
}
}
@@ -54,14 +54,14 @@ func TestSaveGUIUpdatesSection(t *testing.T) {
if err := os.WriteFile(path, initial, 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26, AutoUnmuteOnSelfLeave: true, RefreshIntervalValue: 3, RefreshIntervalUnit: "sec", HistoryRegex: "[wip]", HistoryFromDate: "2026-01-01", HistoryToDate: "2026-01-31"}); err != nil {
if err := SaveGUI(path, GUIConfig{TopMost: true, FontSize: 26, AutoUnmuteOnSelfLeave: true, RefreshIntervalValue: 3, RefreshIntervalUnit: "sec", HistoryRegex: "[wip]", HistoryFromDate: "2026-01-01", HistoryToDate: "2026-01-31", HistoryExportDir: "C:/tmp/visit", HistoryExportIncludeTime: false, HistoryExportIncludeWorld: true, HistoryExportIncludeJoinLeave: true, HistoryExportCustomEnabled: true, HistoryExportCustom: "[wip]"}); err != nil {
t.Fatalf("SaveGUI returned error: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 3 || cfg.GUI.RefreshIntervalUnit != "sec" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" {
if cfg.GUI.TopMost != true || cfg.GUI.FontSize != 26 || cfg.GUI.AutoUnmuteOnSelfLeave != true || cfg.GUI.RefreshIntervalValue != 3 || cfg.GUI.RefreshIntervalUnit != "sec" || cfg.GUI.HistoryRegex != "[wip]" || cfg.GUI.HistoryFromDate != "2026-01-01" || cfg.GUI.HistoryToDate != "2026-01-31" || cfg.GUI.HistoryExportDir != "C:/tmp/visit" || cfg.GUI.HistoryExportIncludeTime != false || cfg.GUI.HistoryExportIncludeWorld != true || cfg.GUI.HistoryExportIncludeJoinLeave != true || cfg.GUI.HistoryExportCustomEnabled != true || cfg.GUI.HistoryExportCustom != "[wip]" {
t.Fatalf("unexpected saved gui values: %+v", cfg.GUI)
}
data, err := os.ReadFile(path)