Simplify GUI settings and build flags

This commit is contained in:
every_holiday
2026-06-26 09:16:46 +09:00
parent 4d77e67870
commit ae426244e0
22 changed files with 846 additions and 237 deletions

View File

@@ -10,6 +10,7 @@ import (
"vrc_osc_go/internal/config"
"vrc_osc_go/internal/consenttool"
"vrc_osc_go/internal/buildinfo"
"vrc_osc_go/internal/osc"
)
@@ -18,17 +19,25 @@ type discordMuteState struct {
muted bool
buttonPressed bool
lastAction time.Time
lastPressEdge time.Time
}
func Run(configPath string, mode string) error {
if mode != "" {
return consenttool.Run(configPath, mode)
}
log.Printf("app run begin config=%s mode=%s", configPath, mode)
if err := setupRuntimeLogger(); err != nil {
return err
}
log.Printf("runtime logger ready")
cfg, err := config.Load(configPath)
if err != nil {
log.Printf("config load failed: %v", err)
return err
}
log.Printf("app version=%s build=%s", buildinfo.Version, buildinfo.BuildTime)
InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
log.Printf("vrc_osc_go starting osc=%s:%d config=%s", cfg.OSC.Host, cfg.OSC.Port, configPath)
@@ -52,14 +61,25 @@ func Run(configPath string, mode string) error {
}
return nil
})
server.Map("translation", func(_ string, args []osc.Value) error {
log.Printf("received translation args=%+v", args)
if err := runPythonTranslationFromLastOCR(); err != nil {
log.Printf("translation failed: %v", err)
return err
}
return nil
})
errCh := make(chan error, 1)
go func() {
log.Printf("osc server listening on %s:%d", cfg.OSC.Host, cfg.OSC.Port)
errCh <- server.Serve()
}()
log.Printf("starting vrchat log watcher")
go watchVrchatLog()
log.Printf("vrchat log watcher goroutine started")
startSelfMonitor(cfg, discordState)
log.Printf("self monitor started")
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)

View File

@@ -3,120 +3,87 @@ package app
import (
"fmt"
"log"
"os/exec"
"strings"
"syscall"
"unsafe"
)
const (
vkControl = 0x11
vkShift = 0x10
vkM = 0x4D
keyUp = 0x0002
swRestore = 9
)
func pressDiscordMuteHotkey() error {
script := `
$ErrorActionPreference = "Stop"
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")
Add-Type @"
using System;
using System.Runtime.InteropServices;
public static class Win32 {
[StructLayout(LayoutKind.Sequential)]
public struct RECT {
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[DllImport("user32.dll")]
public static extern bool GetWindowRect(IntPtr hWnd, out RECT lpRect);
[DllImport("user32.dll")]
public static extern bool SetCursorPos(int X, int Y);
[DllImport("user32.dll")]
public static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, UIntPtr dwExtraInfo);
}
"@
function Click-WindowCenter($p) {
if ($null -eq $p) { return }
$hWnd = [IntPtr]$p.MainWindowHandle
if ($hWnd -eq [IntPtr]::Zero) { return }
$rect = New-Object 'Win32+RECT'
if (-not [Win32]::GetWindowRect($hWnd, [ref]$rect)) { return }
$x = [int](($rect.Left + $rect.Right) / 2)
$y = [int](($rect.Top + $rect.Bottom) / 2)
[Win32]::SetCursorPos($x, $y) | Out-Null
Start-Sleep -Milliseconds 50
[Win32]::mouse_event(0x0002, 0, 0, 0, [UIntPtr]::Zero)
[Win32]::mouse_event(0x0004, 0, 0, 0, [UIntPtr]::Zero)
Start-Sleep -Milliseconds 100
}
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
}
function Get-BestDiscordWindow {
$windows = Get-DiscordWindows
if (-not $windows -or $windows.Count -eq 0) { return $null }
$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
return $ranked | Select-Object -First 1
}
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
Click-WindowCenter $p
}
$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))
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 err != nil {
return fmt.Errorf("discord mute failed: %w", err)
if target == 0 {
return fmt.Errorf("discord window not found")
}
showWindow.Call(target, swRestore)
setForegroundWindow.Call(target)
sleep.Call(150)
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)
}
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)
SetDiscordWindow("Discord")
log.Printf("discord hotkey sent via keybd_event")
return nil
}

View File

@@ -30,33 +30,25 @@ func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
if isPressed && !state.buttonPressed {
state.buttonPressed = true
if state.muted {
log.Printf("ACTION VRChat button pressed -> Discord unmute hotkey")
if err := pressDiscordMuteHotkey(); err != nil {
return err
}
state.muted = false
SetDiscordMuted(state.muted)
state.lastAction = now
log.Printf("ACTION VRChat button pressed -> Discord mute toggle")
if err := pressDiscordMuteHotkey(); err != nil {
log.Printf("discord hotkey failed: %v", err)
SetDiscordAction("hotkey_failed")
} else {
log.Printf("INFO already unmuted")
state.muted = !state.muted
SetDiscordMuted(state.muted)
if state.muted {
SetDiscordAction("muted")
} else {
SetDiscordAction("unmuted")
}
}
state.lastAction = now
return nil
}
if !isPressed && state.buttonPressed {
state.buttonPressed = false
if !state.muted {
log.Printf("ACTION VRChat button released -> Discord mute hotkey")
if err := pressDiscordMuteHotkey(); err != nil {
return err
}
state.muted = true
SetDiscordMuted(state.muted)
state.lastAction = now
} else {
log.Printf("INFO already muted")
}
}
return nil

View File

@@ -0,0 +1,58 @@
package app
import (
"os"
"path/filepath"
"testing"
"time"
"vrc_osc_go/internal/common"
)
func TestGuestTrackerJoinLeave(t *testing.T) {
base := t.TempDir()
prev := common.SetRootDirForTest(func() string { return base })
defer prev()
tracker := NewGuestTracker([]string{"Alice", "Bob"})
now := time.Date(2026, 6, 25, 12, 0, 0, 0, time.Local)
tracker.MarkJoin("Alice", now)
tracker.MarkJoin("Bob", now.Add(10*time.Second))
got := tracker.Snapshot(now.Add(20 * time.Second))
if len(got) != 2 {
t.Fatalf("expected 2 guests, got %d", len(got))
}
present := 0
for _, g := range got {
if g.Present {
present++
}
}
if present != 2 {
t.Fatalf("expected 2 present guests, got %#v", got)
}
tracker.MarkLeave("Alice", now.Add(30*time.Second))
got = tracker.Snapshot(now.Add(40 * time.Second))
var alice GuestStatus
for _, g := range got {
if g.Name == "Alice" {
alice = g
}
}
if alice.Present {
t.Fatalf("expected Alice to be absent, got %#v", alice)
}
if alice.AbsentFor == "" {
t.Fatalf("expected Alice absent duration, got %#v", alice)
}
snapshotPath := filepath.Join(base, "runtime", "guest_snapshot.json")
if _, err := os.Stat(snapshotPath); err != nil {
t.Fatalf("expected snapshot file: %v", err)
}
}

View File

@@ -1,30 +1,128 @@
package app
import (
"encoding/json"
"fmt"
"log"
"os/exec"
"strings"
)
func runPythonOcrFromScreen() error {
script := `
import sys
from pathlib import Path
root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC")
sys.path.insert(0, str(root / "src"))
from ocr.ocr_actions import runOcrFromScreen
runOcrFromScreen()
`
type OcrResult struct {
ImagePath string
TextPath string
Text string
Translate string
ErrReason string
}
func runPythonScript(script string) (string, error) {
cmd := exec.Command("python", "-c", script)
cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC`
out, err := cmd.CombinedOutput()
if len(out) > 0 {
log.Printf("ocr output: %s", string(out))
SetOCRText(string(out))
SetTranslateText("翻訳: 未実装")
log.Printf("python output: %s", string(out))
}
if err != nil {
return fmt.Errorf("ocr failed: %w", err)
return string(out), fmt.Errorf("python failed: %w", err)
}
return string(out), nil
}
func capturePythonOcr() (map[string]any, error) {
script := `
import sys, json
from pathlib import Path
root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC")
sys.path.insert(0, str(root / "src"))
from ocr.ocr_actions import runOcrFromScreen
result = runOcrFromScreen()
print(json.dumps(result, ensure_ascii=False))
`
out, err := runPythonScript(script)
if err != nil {
return nil, err
}
lines := strings.Split(strings.TrimSpace(out), "\n")
for i := len(lines) - 1; i >= 0; i-- {
line := strings.TrimSpace(lines[i])
if strings.HasPrefix(line, "{") && strings.HasSuffix(line, "}") {
var m map[string]any
if err := json.Unmarshal([]byte(line), &m); err == nil {
return m, nil
}
}
}
return nil, fmt.Errorf("ocr result json not found")
}
func runPythonOcrFromScreen() error {
log.Printf("ocr trigger begin")
result, err := capturePythonOcr()
if err != nil {
log.Printf("ocr trigger failed: %v", err)
SetOCRText("OCRテキストなし")
SetTranslateText("翻訳エンジン未設定")
_ = AppendRuntimeLog("OCR ERROR", err.Error())
return err
}
text, _ := result["text"].(string)
imagePath, _ := result["image_path"].(string)
textPath, _ := result["text_path"].(string)
if strings.TrimSpace(text) == "" {
text = "OCRテキストなし"
}
SetOCRText(text)
SetTranslateText("")
_ = AppendRuntimeLog("OCR INPUT", fmt.Sprintf("image=%s\n%s", imagePath, text))
if textPath != "" {
_ = AppendRuntimeLog("OCR TEXT", fmt.Sprintf("image=%s\ntext_path=%s\n%s", imagePath, textPath, text))
}
return nil
}
func runPythonTranslationFromLastOCR() error {
runtime := SnapshotRuntime()
source := strings.TrimSpace(runtime.LastOCRText)
if source == "" || source == "OCRテキストなし" {
SetTranslateText("OCRテキストなし")
_ = AppendRuntimeLog("TRANSLATION ERROR", "OCRテキストなし")
return nil
}
log.Printf("translation trigger begin")
SetTranslateText("翻訳実行中")
_ = AppendRuntimeLog("TRANSLATION INPUT", source)
script := `
import sys, json
from pathlib import Path
root = Path(r"C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC")
sys.path.insert(0, str(root / "src"))
from translate.translate_actions import translateTextToJapanese, saveTranslationText
text = sys.argv[1]
translated = translateTextToJapanese(text)
print(translated or "")
if translated:
saveTranslationText("runtime", translated)
`
cmd := exec.Command("python", "-c", script, source)
cmd.Dir = `C:\Users\kenny\Documents\git\messpy\VRC\VRWT_Tool\VRC_OSC`
out, err := cmd.CombinedOutput()
if len(out) > 0 {
log.Printf("translation output: %s", string(out))
}
if err != nil {
SetTranslateText("翻訳失敗")
_ = AppendRuntimeLog("TRANSLATION ERROR", err.Error())
return err
}
translated := strings.TrimSpace(string(out))
if translated == "" {
SetTranslateText("翻訳結果なし")
_ = AppendRuntimeLog("TRANSLATION ERROR", "翻訳結果なし")
return nil
}
SetTranslateText(translated)
_ = AppendRuntimeLog("TRANSLATION RESULT", translated)
return nil
}

View File

@@ -2,13 +2,45 @@ package app
import (
"fmt"
"io"
"log"
"os"
"path/filepath"
"sync"
"time"
"vrc_osc_go/internal/common"
)
type runtimeLogWriter struct {
mu sync.Mutex
file *os.File
}
func (w *runtimeLogWriter) Write(p []byte) (int, error) {
w.mu.Lock()
defer w.mu.Unlock()
if w.file == nil {
return len(p), nil
}
return w.file.Write(p)
}
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 {
return err
}
logWriter := &runtimeLogWriter{file: f}
log.SetOutput(io.MultiWriter(os.Stderr, logWriter))
return nil
}
func appendRuntimeLog(title, text string) error {
dir := filepath.Join(common.RootDir(), "runtime")
if err := os.MkdirAll(dir, 0o755); err != nil {
@@ -50,3 +82,43 @@ func appendJoinLeaveLog(text string) error {
_, err = fmt.Fprintf(f, "\n[%s] VRC JOIN/LEAVE\n%s\n", time.Now().Format("2006-01-02 15:04:05"), body)
return err
}
func appendDesktopJoinLog(title, worldLabel, text string) error {
dir := filepath.Join(os.Getenv("USERPROFILE"), "Desktop")
if _, err := os.Stat(dir); err != nil {
if home, herr := os.UserHomeDir(); herr == nil {
dir = filepath.Join(home, "Desktop")
}
}
if err := os.MkdirAll(dir, 0o755); err != nil {
dir = filepath.Join(common.RootDir(), "runtime")
if mkErr := os.MkdirAll(dir, 0o755); mkErr != nil {
return err
}
}
path := filepath.Join(dir, "join.txt")
body := text
if body == "" {
body = "(empty)"
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
fallbackDir := filepath.Join(common.RootDir(), "runtime")
if mkErr := os.MkdirAll(fallbackDir, 0o755); mkErr != nil {
return err
}
fallbackPath := filepath.Join(fallbackDir, "join.txt")
f, err = os.OpenFile(fallbackPath, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o644)
if err != nil {
return err
}
path = fallbackPath
}
defer f.Close()
_, err = fmt.Fprintf(f, "%s\nWorld: %s\n\n%s\n", title, worldLabel, body)
return err
}
func AppendDesktopJoinLog(title, worldLabel, text string) error {
return appendDesktopJoinLog(title, worldLabel, text)
}

View File

@@ -38,12 +38,15 @@ func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
if nextState == "joined" {
if err := handleDiscordSend(state, []osc.Value{{Type: 'T', Bool: true}}); err != nil {
log.Printf("self monitor discord mute 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")
}
}
SetDiscordSource("self_monitor")
lastState = nextState
}
time.Sleep(2 * time.Second)

View File

@@ -13,6 +13,9 @@ import (
type RuntimeState struct {
mu sync.RWMutex
DiscordMuted bool
DiscordWindow string
DiscordSource string
DiscordAction string
LastOCRText string
LastTranslate string
CurrentWorld string
@@ -39,6 +42,27 @@ func SetDiscordMuted(v bool) {
_ = persistRuntimeState()
}
func SetDiscordWindow(v string) {
runtimeState.mu.Lock()
runtimeState.DiscordWindow = v
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func SetDiscordSource(v string) {
runtimeState.mu.Lock()
runtimeState.DiscordSource = v
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func SetDiscordAction(v string) {
runtimeState.mu.Lock()
runtimeState.DiscordAction = v
runtimeState.mu.Unlock()
_ = persistRuntimeState()
}
func SetOCRText(v string) {
runtimeState.mu.Lock()
runtimeState.LastOCRText = v
@@ -81,6 +105,9 @@ func persistRuntimeState() error {
enc.SetIndent("", " ")
return enc.Encode(map[string]any{
"discord_muted": s.DiscordMuted,
"discord_window": s.DiscordWindow,
"discord_source": s.DiscordSource,
"discord_action": s.DiscordAction,
"ocr": s.LastOCRText,
"translate": s.LastTranslate,
"world": s.CurrentWorld,

View File

@@ -1,7 +1,6 @@
package app
import (
"bufio"
"fmt"
"log"
"os"
@@ -9,6 +8,9 @@ import (
"regexp"
"strings"
"time"
"unicode/utf8"
"syscall"
"unsafe"
)
var (
@@ -77,6 +79,12 @@ func findLatestVrchatLog() (string, error) {
}
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{
@@ -85,20 +93,26 @@ func watchVrchatLog() {
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()
@@ -113,15 +127,22 @@ func watchVrchatLog() {
lastSize = 0
}
if info.Size() > lastSize {
f, err := os.Open(path)
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
}
_, _ = f.Seek(lastSize, 0)
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
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)
}
@@ -137,41 +158,47 @@ func watchVrchatLog() {
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)
_ = appendJoinLeaveLog(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)
_ = appendJoinLeaveLog(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()
_ = f.Close()
}
time.Sleep(2 * time.Second)
}
}
func scanExistingVrchatLog(path string, state *vrcLogState) error {
f, err := os.Open(path)
b, err := os.ReadFile(path)
if err != nil {
return err
}
defer f.Close()
state.presentSet = map[string]struct{}{}
state.location = ""
state.worldID = ""
@@ -180,9 +207,12 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
state.pendingWorldName = ""
state.initialized = false
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
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{}{}
}
@@ -203,9 +233,6 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
continue
}
}
if err := scanner.Err(); err != nil {
return err
}
state.initialized = true
if state.worldName != "" {
log.Printf("world changed: %s", state.worldName)
@@ -213,11 +240,130 @@ func scanExistingVrchatLog(path string, state *vrcLogState) error {
if tracker := GetGuestTracker(); tracker != nil {
tracker.SetCurrentInstance(state.worldName)
}
_ = appendRuntimeLog("VRC WORLD", 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 := ""