diff --git a/.gitea/workflows/build-windows-exe.yml b/.gitea/workflows/build-windows-exe.yml
new file mode 100644
index 0000000..6d8f9f6
--- /dev/null
+++ b/.gitea/workflows/build-windows-exe.yml
@@ -0,0 +1,79 @@
+name: build-windows-exe
+
+on:
+ push:
+ tags:
+ - "v*"
+
+jobs:
+ build:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ shell: bash
+ run: |
+ git clone --depth 1 --branch "${GITHUB_REF_NAME}" https://git.vrcworldtour.com/every_holiday/VRCWT-OSC.git .
+
+ - name: Install Go
+ shell: bash
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y golang-go
+ go version
+
+ - name: Test
+ shell: bash
+ run: |
+ cd VRWT_Tool/VRC_OSC
+ GO111MODULE=on go test ./...
+
+ - name: Build executables
+ shell: bash
+ run: |
+ cd VRWT_Tool/VRC_OSC
+ mkdir -p dist
+ VERSION="${GITHUB_REF_NAME}"
+ BUILD_TIME="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc.exe ./cmd/vrc_osc
+ GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrc_osc_launcher.exe ./cmd/vrc_osc_launcher
+ GO111MODULE=on GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X vrc_osc_go/internal/buildinfo.Version=${VERSION} -X vrc_osc_go/internal/buildinfo.BuildTime=${BUILD_TIME}" -o dist/vrwt_tool.exe ./cmd/vrwt_tool
+
+ - name: Package executables
+ shell: bash
+ run: |
+ cd VRWT_Tool/VRC_OSC
+ cp config/config.example.toml dist/config.example.toml
+ cp config/secrets.example.toml dist/secrets.example.toml
+ cp config/guests.example.txt dist/guests.example.txt
+ (cd dist && zip -r vrc_osc-windows.zip vrc_osc.exe vrc_osc_launcher.exe vrwt_tool.exe config.example.toml secrets.example.toml guests.example.txt)
+
+ - name: Create Release
+ env:
+ GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+ GITEA_BASE_URL: ${{ secrets.GITEA_BASE_URL }}
+ TAG_NAME: ${{ github.ref_name }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ api="${GITEA_BASE_URL:-https://git.vrcworldtour.com}/api/v1"
+ owner_repo="every_holiday/VRCWT-OSC"
+ release_json=$(curl -fsSL \
+ -H "Authorization: token ${GITEA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ "${api}/repos/${owner_repo}/releases/tags/${TAG_NAME}" || true)
+ if [ -n "${release_json}" ]; then
+ release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
+ else
+ release_json=$(curl -fsSL -X POST \
+ -H "Authorization: token ${GITEA_TOKEN}" \
+ -H "Content-Type: application/json" \
+ -d "{\"tag_name\":\"${TAG_NAME}\",\"name\":\"${TAG_NAME}\",\"draft\":false,\"prerelease\":false}" \
+ "${api}/repos/${owner_repo}/releases")
+ release_id=$(printf '%s' "${release_json}" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
+ fi
+ asset_path="VRWT_Tool/VRC_OSC/dist/vrc_osc-windows.zip"
+ curl -fsSL -X POST \
+ -H "Authorization: token ${GITEA_TOKEN}" \
+ -F "attachment=@${asset_path}" \
+ "${api}/repos/${owner_repo}/releases/${release_id}/assets?name=vrc_osc-windows.zip"
diff --git a/VRWT_Tool/.gitea/workflows/build-windows-exe.yml b/VRWT_Tool/.gitea/workflows/build-windows-exe.yml
new file mode 100644
index 0000000..91d0fcb
--- /dev/null
+++ b/VRWT_Tool/.gitea/workflows/build-windows-exe.yml
@@ -0,0 +1,50 @@
+name: build-windows-exe
+
+on:
+ push:
+
+jobs:
+ build:
+ runs-on: windows-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Install Go
+ shell: pwsh
+ run: |
+ choco install golang --version=1.22.0 -y --no-progress
+ $env:PATH = "C:\Program Files\Go\bin;$env:PATH"
+ go version
+
+ - name: Test
+ working-directory: VRC_OSC
+ shell: pwsh
+ run: |
+ $env:PATH = "C:\Program Files\Go\bin;$env:PATH"
+ go test ./...
+
+ - name: Build executables
+ working-directory: VRC_OSC
+ shell: pwsh
+ run: |
+ $env:PATH = "C:\Program Files\Go\bin;$env:PATH"
+ go build -o dist\vrc_osc.exe ./cmd/vrc_osc
+ go build -o dist\vrwt_tool.exe ./cmd/vrwt_tool
+
+ - name: Package executables
+ working-directory: VRC_OSC
+ shell: pwsh
+ run: |
+ New-Item -ItemType Directory -Force -Path dist | Out-Null
+ Copy-Item config\config.example.toml dist\config.example.toml
+ Copy-Item config\secrets.example.toml dist\secrets.example.toml
+ Copy-Item config\guests.example.txt dist\guests.example.txt
+ Compress-Archive -Path dist\vrc_osc.exe,dist\vrwt_tool.exe,dist\config.example.toml,dist\secrets.example.toml,dist\guests.example.txt -DestinationPath dist\vrc_osc-windows.zip -Force
+
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: vrc_osc-windows
+ path: VRC_OSC/dist/vrc_osc-windows.zip
diff --git a/VRWT_Tool/VRC_OSC/.gitignore b/VRWT_Tool/VRC_OSC/.gitignore
new file mode 100644
index 0000000..20a9c46
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/.gitignore
@@ -0,0 +1,13 @@
+__pycache__/
+*.py[cod]
+
+config/config.toml
+config/secrets.toml
+config/guests.txt
+runtime/
+.vrchat_cookie.txt
+src/.vrchat_cookie.txt
+
+*.bak
+*.bak_*
+backup_*/
diff --git a/VRWT_Tool/VRC_OSC/assets/icon.ico b/VRWT_Tool/VRC_OSC/assets/icon.ico
new file mode 100644
index 0000000..49294f2
Binary files /dev/null and b/VRWT_Tool/VRC_OSC/assets/icon.ico differ
diff --git a/VRWT_Tool/VRC_OSC/assets/icon.png b/VRWT_Tool/VRC_OSC/assets/icon.png
new file mode 100644
index 0000000..981b1f6
Binary files /dev/null and b/VRWT_Tool/VRC_OSC/assets/icon.png differ
diff --git a/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1 b/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1
new file mode 100644
index 0000000..647fa3e
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/bin/vrc_osc.ps1
@@ -0,0 +1,7 @@
+$ErrorActionPreference = "Stop"
+
+$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
+$RootDir = Resolve-Path (Join-Path $ScriptDir "..")
+$EntryPoint = Join-Path $RootDir "bin\vrc_osc.py"
+
+python $EntryPoint
diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go
new file mode 100644
index 0000000..67bca30
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/cmd/vrc_osc/main.go
@@ -0,0 +1,42 @@
+package main
+
+import (
+ "flag"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+
+ "vrc_osc_go/internal/app"
+)
+
+func main() {
+ var configPath string
+ var mode string
+ var noGUI bool
+ flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
+ flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
+ flag.BoolVar(&noGUI, "no-gui", false, "do not launch the GUI companion")
+ flag.Parse()
+
+ if mode == "" && !noGUI {
+ launchGUI()
+ }
+
+ if err := app.Run(configPath, mode); err != nil {
+ log.SetOutput(os.Stderr)
+ log.Fatalf("vrc_osc_go: %v", err)
+ }
+}
+
+func launchGUI() {
+ exe, err := os.Executable()
+ if err != nil {
+ return
+ }
+ gui := filepath.Join(filepath.Dir(exe), "vrc_osc_gui.exe")
+ if _, err := os.Stat(gui); err != nil {
+ return
+ }
+ _ = exec.Command(gui).Start()
+}
diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go
new file mode 100644
index 0000000..6f8b833
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_gui/main.go
@@ -0,0 +1,198 @@
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+)
+
+func runtimeDir() string {
+ exe, err := os.Executable()
+ if err != nil {
+ return "runtime"
+ }
+ return filepath.Join(filepath.Dir(exe), "runtime")
+}
+
+func main() {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", handleIndex)
+ mux.HandleFunc("/api/state", handleState)
+
+ addr := "127.0.0.1:18181"
+ go openBrowser("http://" + addr)
+ log.Printf("gui listening on http://%s", addr)
+ log.Fatal(http.ListenAndServe(addr, mux))
+}
+
+func handleIndex(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ html := `
+
+
+
+
+ VRTW_Tool
+
+
+
+ VRTW_Tool
+ loading...
+
+
+`
+ _, _ = fmt.Fprint(w, html)
+}
+
+func handleState(w http.ResponseWriter, r *http.Request) {
+ statePath := filepath.Join(runtimeDir(), "state.json")
+ body, err := os.ReadFile(statePath)
+ if err != nil {
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "world": "",
+ "discord_muted": false,
+ "ocr": "",
+ "translate": "",
+ "guests": []any{},
+ "guest_count": 0,
+ "error": err.Error(),
+ })
+ return
+ }
+ var state map[string]any
+ if err := json.Unmarshal(body, &state); err != nil {
+ _ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
+ return
+ }
+ guests := loadGuestSnapshot()
+ state["guests"] = guests
+ state["guest_count"] = len(guests)
+ _ = json.NewEncoder(w).Encode(state)
+}
+
+func loadGuestSnapshot() []map[string]any {
+ statePath := filepath.Join(runtimeDir(), "guest_snapshot.json")
+ body, err := os.ReadFile(statePath)
+ if err != nil {
+ return []map[string]any{}
+ }
+ var guests []map[string]any
+ if err := json.Unmarshal(body, &guests); err != nil {
+ return []map[string]any{}
+ }
+ return guests
+}
+
+func openBrowser(url string) {
+ switch runtime.GOOS {
+ case "windows":
+ _ = exec.Command("cmd", "/c", "start", "", url).Start()
+ default:
+ _ = exec.Command("xdg-open", url).Start()
+ }
+}
diff --git a/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go
new file mode 100644
index 0000000..87a681f
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/cmd/vrc_osc_launcher/main.go
@@ -0,0 +1,40 @@
+package main
+
+import (
+ "flag"
+ "log"
+ "os"
+ "path/filepath"
+
+ "vrc_osc_go/internal/buildinfo"
+ "vrc_osc_go/internal/common"
+ "vrc_osc_go/internal/update"
+)
+
+func main() {
+ var baseURL string
+ var ownerRepo string
+ var assetName string
+ flag.StringVar(&baseURL, "base-url", "https://git.vrcworldtour.com", "Gitea base URL")
+ flag.StringVar(&ownerRepo, "repo", "every_holiday/VRCWT-OSC", "owner/repo")
+ flag.StringVar(&assetName, "asset", "vrc_osc-windows.zip", "release asset name")
+ flag.Parse()
+
+ client := &update.Client{BaseURL: baseURL}
+ mgr := &update.Manager{
+ Client: client,
+ OwnerRepo: ownerRepo,
+ AssetName: assetName,
+ CurrentLabel: buildinfo.Version,
+ }
+ if _, err := mgr.CheckAndUpdate(); err != nil {
+ log.Printf("auto update skipped: %v", err)
+ }
+ exe := filepath.Join(common.RootDir(), "vrc_osc.exe")
+ if _, err := os.Stat(exe); err != nil {
+ log.Fatalf("missing exe: %v", err)
+ }
+ if err := update.Launch(exe, os.Args[1:]); err != nil {
+ log.Fatalf("launch failed: %v", err)
+ }
+}
diff --git a/VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go b/VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go
new file mode 100644
index 0000000..dda9087
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/cmd/vrwt_tool/main.go
@@ -0,0 +1,33 @@
+package main
+
+import (
+ "flag"
+ "fmt"
+ "log"
+ "os"
+
+ "vrc_osc_go/internal/consenttool"
+)
+
+func main() {
+ var cfgPath string
+ var mode string
+
+ flag.StringVar(&cfgPath, "config", "config/consent.toml", "path to consent tool config")
+ flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
+ flag.Parse()
+
+ if mode == "" && flag.NArg() > 0 {
+ mode = flag.Arg(0)
+ }
+
+ if mode == "" {
+ fmt.Fprintln(os.Stderr, "missing mode: generate-json or extract-log")
+ os.Exit(2)
+ }
+
+ if err := consenttool.Run(cfgPath, mode); err != nil {
+ log.SetOutput(os.Stderr)
+ log.Fatalf("vrwt_tool: %v", err)
+ }
+}
diff --git a/VRWT_Tool/VRC_OSC/config/config.example.toml b/VRWT_Tool/VRC_OSC/config/config.example.toml
new file mode 100644
index 0000000..e154d9e
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/config/config.example.toml
@@ -0,0 +1,22 @@
+[self]
+name = ""
+
+[staff]
+names = []
+
+[guest]
+file = "config/guests.txt"
+
+[notice]
+missing_count = 0
+
+[ocr.crop]
+left = 0.05
+top = 0.35
+right = 0.95
+bottom = 0.95
+
+[ocr]
+preprocess = true
+scale = 1.5
+use_angle_cls = false
diff --git a/VRWT_Tool/VRC_OSC/config/guests.example.txt b/VRWT_Tool/VRC_OSC/config/guests.example.txt
new file mode 100644
index 0000000..3124eb2
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/config/guests.example.txt
@@ -0,0 +1,3 @@
+GuestUserA
+GuestUserB
+GuestUserC
diff --git a/VRWT_Tool/VRC_OSC/config/secrets.example.toml b/VRWT_Tool/VRC_OSC/config/secrets.example.toml
new file mode 100644
index 0000000..8cc68c2
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/config/secrets.example.toml
@@ -0,0 +1,6 @@
+[discord]
+webhook_url = ""
+
+[vrchat]
+username = ""
+password = ""
diff --git a/VRWT_Tool/VRC_OSC/go.mod b/VRWT_Tool/VRC_OSC/go.mod
new file mode 100644
index 0000000..ea7c930
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/go.mod
@@ -0,0 +1,4 @@
+module vrc_osc_go
+
+go 1.22
+
diff --git a/VRWT_Tool/VRC_OSC/internal/app/app.go b/VRWT_Tool/VRC_OSC/internal/app/app.go
new file mode 100644
index 0000000..5960fb1
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/app.go
@@ -0,0 +1,75 @@
+package app
+
+import (
+ "log"
+ "os"
+ "os/signal"
+ "sync"
+ "syscall"
+ "time"
+
+ "vrc_osc_go/internal/config"
+ "vrc_osc_go/internal/consenttool"
+ "vrc_osc_go/internal/osc"
+)
+
+type discordMuteState struct {
+ mu sync.Mutex
+ muted bool
+ buttonPressed bool
+ lastAction time.Time
+}
+
+func Run(configPath string, mode string) error {
+ if mode != "" {
+ return consenttool.Run(configPath, mode)
+ }
+
+ cfg, err := config.Load(configPath)
+ if err != nil {
+ return err
+ }
+ InitGuestTracker(NewGuestTracker(cfg.VrcLog.GuestNames))
+ 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)
+ discordState := &discordMuteState{muted: true}
+ SetDiscordMuted(true)
+ server.Map("DiscordSend", func(_ string, args []osc.Value) error {
+ log.Printf("received DiscordSend args=%+v", args)
+ if err := handleDiscordSend(discordState, args); err != nil {
+ log.Printf("discord mute failed: %v", err)
+ return err
+ }
+ SetDiscordMuted(discordState.muted)
+ return nil
+ })
+ server.Map("ocrEnabled", func(_ string, args []osc.Value) error {
+ log.Printf("received ocrEnabled args=%+v", args)
+ if err := runPythonOcrFromScreen(); err != nil {
+ log.Printf("ocr 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()
+ }()
+ go watchVrchatLog()
+ startSelfMonitor(cfg, discordState)
+
+ sigCh := make(chan os.Signal, 1)
+ signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
+ select {
+ case err := <-errCh:
+ log.Printf("osc server stopped: %v", err)
+ return err
+ case <-sigCh:
+ log.Printf("shutdown signal received")
+ server.Close()
+ return nil
+ }
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/discord.go b/VRWT_Tool/VRC_OSC/internal/app/discord.go
new file mode 100644
index 0000000..39bd677
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/discord.go
@@ -0,0 +1,84 @@
+package app
+
+import (
+ "fmt"
+ "log"
+ "os/exec"
+)
+
+func pressDiscordMuteHotkey() error {
+ script := `
+$ErrorActionPreference = "Stop"
+
+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
+}
+
+$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
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go b/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go
new file mode 100644
index 0000000..7165d9d
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/discord_mute.go
@@ -0,0 +1,89 @@
+package app
+
+import (
+ "fmt"
+ "log"
+ "strings"
+ "time"
+
+ "vrc_osc_go/internal/osc"
+)
+
+const discordDebounce = 300 * time.Millisecond
+
+func handleDiscordSend(state *discordMuteState, args []osc.Value) error {
+ state.mu.Lock()
+ defer state.mu.Unlock()
+
+ if len(args) == 0 {
+ return fmt.Errorf("OSC args is empty")
+ }
+ isPressed, err := toBool(args[0])
+ if err != nil {
+ return err
+ }
+
+ now := time.Now()
+ if !state.lastAction.IsZero() && now.Sub(state.lastAction) < discordDebounce {
+ return nil
+ }
+
+ 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
+ } else {
+ log.Printf("INFO already unmuted")
+ }
+ 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
+}
+
+func toBool(v osc.Value) (bool, error) {
+ switch v.Type {
+ case 'T':
+ return true, nil
+ case 'F':
+ return false, nil
+ case 'i':
+ return v.Int != 0, nil
+ case 'f':
+ return v.Float != 0, nil
+ case 's':
+ switch strings.ToLower(strings.TrimSpace(v.Str)) {
+ case "true", "1", "on", "yes":
+ return true, nil
+ case "false", "0", "off", "no":
+ return false, nil
+ default:
+ return false, fmt.Errorf("unsupported OSC value: %q", v.Str)
+ }
+ case 0:
+ return false, fmt.Errorf("unsupported OSC value")
+ default:
+ return false, fmt.Errorf("unsupported OSC type: %q", v.Type)
+ }
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/guest_status.go b/VRWT_Tool/VRC_OSC/internal/app/guest_status.go
new file mode 100644
index 0000000..15139fe
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/guest_status.go
@@ -0,0 +1,136 @@
+package app
+
+import (
+ "fmt"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sort"
+ "sync"
+ "time"
+
+ "vrc_osc_go/internal/common"
+)
+
+type GuestStatus struct {
+ Name string `json:"name"`
+ Present bool `json:"present"`
+ LastJoin time.Time `json:"last_join"`
+ LastLeave time.Time `json:"last_leave"`
+ AbsentFor string `json:"absent_for"`
+}
+
+type GuestTracker struct {
+ mu sync.RWMutex
+ current string
+ items map[string]*GuestStatus
+}
+
+func NewGuestTracker(names []string) *GuestTracker {
+ items := make(map[string]*GuestStatus, len(names))
+ for _, name := range names {
+ items[name] = &GuestStatus{Name: name}
+ }
+ return &GuestTracker{items: items}
+}
+
+func (t *GuestTracker) SetCurrentInstance(label string) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ t.current = label
+}
+
+func (t *GuestTracker) CurrentInstance() string {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ return t.current
+}
+
+func (t *GuestTracker) MarkJoin(name string, at time.Time) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ s := t.ensure(name)
+ s.Present = true
+ s.LastJoin = at
+ s.AbsentFor = ""
+ _ = t.persistLocked()
+}
+
+func (t *GuestTracker) MarkLeave(name string, at time.Time) {
+ t.mu.Lock()
+ defer t.mu.Unlock()
+ s := t.ensure(name)
+ s.Present = false
+ s.LastLeave = at
+ if !s.LastJoin.IsZero() {
+ s.AbsentFor = humanSince(at)
+ }
+ _ = t.persistLocked()
+}
+
+func (t *GuestTracker) Snapshot(now time.Time) []GuestStatus {
+ t.mu.RLock()
+ defer t.mu.RUnlock()
+ out := make([]GuestStatus, 0, len(t.items))
+ for _, s := range t.items {
+ cp := *s
+ if !cp.Present && !cp.LastLeave.IsZero() {
+ cp.AbsentFor = humanSince(cp.LastLeave, now)
+ }
+ out = append(out, cp)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
+ return out
+}
+
+func (t *GuestTracker) persistLocked() error {
+ dir := filepath.Join(common.RootDir(), "runtime")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ snapshot := make([]GuestStatus, 0, len(t.items))
+ for _, s := range t.items {
+ cp := *s
+ snapshot = append(snapshot, cp)
+ }
+ f, err := os.Create(filepath.Join(dir, "guest_snapshot.json"))
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ enc := json.NewEncoder(f)
+ enc.SetIndent("", " ")
+ return enc.Encode(snapshot)
+}
+
+func (t *GuestTracker) ensure(name string) *GuestStatus {
+ if s, ok := t.items[name]; ok {
+ return s
+ }
+ s := &GuestStatus{Name: name}
+ t.items[name] = s
+ return s
+}
+
+func humanSince(at time.Time, now ...time.Time) string {
+ ref := time.Now()
+ if len(now) > 0 {
+ ref = now[0]
+ }
+ if at.IsZero() {
+ return ""
+ }
+ d := ref.Sub(at)
+ if d < time.Minute {
+ return "1分未満"
+ }
+ m := int(d.Minutes())
+ if m < 60 {
+ return fmt.Sprintf("%d分前", m)
+ }
+ h := m / 60
+ if h < 24 {
+ return fmt.Sprintf("%d時間前", h)
+ }
+ return fmt.Sprintf("%d日前", h/24)
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/ocr.go b/VRWT_Tool/VRC_OSC/internal/app/ocr.go
new file mode 100644
index 0000000..6f6e2ca
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/ocr.go
@@ -0,0 +1,30 @@
+package app
+
+import (
+ "fmt"
+ "log"
+ "os/exec"
+)
+
+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()
+`
+ 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("翻訳: 未実装")
+ }
+ if err != nil {
+ return fmt.Errorf("ocr failed: %w", err)
+ }
+ return nil
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/runtime.go b/VRWT_Tool/VRC_OSC/internal/app/runtime.go
new file mode 100644
index 0000000..cc4df2b
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/runtime.go
@@ -0,0 +1,48 @@
+package app
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ "vrc_osc_go/internal/common"
+)
+
+func appendRuntimeLog(title, text string) error {
+ dir := filepath.Join(common.RootDir(), "runtime")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ path := filepath.Join(dir, "runtime.log")
+ body := text
+ 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
+}
+
+func appendJoinLeaveLog(text string) error {
+ dir := filepath.Join(common.RootDir(), "runtime")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ path := filepath.Join(dir, "join_leave.log")
+ body := text
+ 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
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/self_monitor.go b/VRWT_Tool/VRC_OSC/internal/app/self_monitor.go
new file mode 100644
index 0000000..6ac10a6
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/self_monitor.go
@@ -0,0 +1,69 @@
+package app
+
+import (
+ "log"
+ "os"
+ "regexp"
+ "strings"
+ "time"
+
+ "vrc_osc_go/internal/config"
+ "vrc_osc_go/internal/osc"
+)
+
+var (
+ selfJoinPattern = regexp.MustCompile(`OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
+ selfLeftPattern = regexp.MustCompile(`OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)`)
+)
+
+func startSelfMonitor(cfg *config.Config, state *discordMuteState) {
+ if cfg.VrcLog.SelfName == "" {
+ return
+ }
+ go func() {
+ lastState := ""
+ for {
+ path, err := findLatestVrchatLog()
+ if err != nil {
+ time.Sleep(2 * time.Second)
+ continue
+ }
+ text, err := os.ReadFile(path)
+ if err != nil {
+ time.Sleep(2 * time.Second)
+ continue
+ }
+ nextState := extractLatestSelfLogState(string(text), cfg.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)
+ }
+ } 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)
+ }
+ }
+ lastState = nextState
+ }
+ time.Sleep(2 * time.Second)
+ }
+ }()
+}
+
+func extractLatestSelfLogState(text, selfName string) string {
+ if selfName == "" {
+ return ""
+ }
+ state := ""
+ for _, rawLine := range strings.Split(text, "\n") {
+ if m := selfJoinPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName {
+ state = "joined"
+ continue
+ }
+ if m := selfLeftPattern.FindStringSubmatch(rawLine); len(m) == 2 && strings.TrimSpace(m[1]) == selfName {
+ state = "left"
+ }
+ }
+ return state
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/state.go b/VRWT_Tool/VRC_OSC/internal/app/state.go
new file mode 100644
index 0000000..4240c98
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/state.go
@@ -0,0 +1,89 @@
+package app
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "sync"
+ "time"
+
+ "vrc_osc_go/internal/common"
+)
+
+type RuntimeState struct {
+ mu sync.RWMutex
+ DiscordMuted bool
+ LastOCRText string
+ LastTranslate string
+ CurrentWorld string
+}
+
+var runtimeState = &RuntimeState{}
+var guestTracker *GuestTracker
+
+func SetGuestTracker(t *GuestTracker) { guestTracker = t }
+func InitGuestTracker(t *GuestTracker) {
+ guestTracker = t
+ if guestTracker != nil {
+ guestTracker.mu.Lock()
+ _ = guestTracker.persistLocked()
+ guestTracker.mu.Unlock()
+ }
+}
+func GetGuestTracker() *GuestTracker { return guestTracker }
+
+func SetDiscordMuted(v bool) {
+ runtimeState.mu.Lock()
+ runtimeState.DiscordMuted = v
+ runtimeState.mu.Unlock()
+ _ = persistRuntimeState()
+}
+
+func SetOCRText(v string) {
+ runtimeState.mu.Lock()
+ runtimeState.LastOCRText = v
+ runtimeState.mu.Unlock()
+ _ = persistRuntimeState()
+}
+
+func SetTranslateText(v string) {
+ runtimeState.mu.Lock()
+ runtimeState.LastTranslate = v
+ runtimeState.mu.Unlock()
+ _ = persistRuntimeState()
+}
+
+func SetCurrentWorld(v string) {
+ runtimeState.mu.Lock()
+ runtimeState.CurrentWorld = v
+ runtimeState.mu.Unlock()
+ _ = persistRuntimeState()
+}
+
+func SnapshotRuntime() RuntimeState {
+ runtimeState.mu.RLock()
+ defer runtimeState.mu.RUnlock()
+ return *runtimeState
+}
+
+func persistRuntimeState() error {
+ s := SnapshotRuntime()
+ dir := filepath.Join(common.RootDir(), "runtime")
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return err
+ }
+ f, err := os.Create(filepath.Join(dir, "state.json"))
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ enc := json.NewEncoder(f)
+ enc.SetIndent("", " ")
+ return enc.Encode(map[string]any{
+ "discord_muted": s.DiscordMuted,
+ "ocr": s.LastOCRText,
+ "translate": s.LastTranslate,
+ "world": s.CurrentWorld,
+ "updated_at": time.Now().Format(time.RFC3339),
+ })
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go b/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go
new file mode 100644
index 0000000..9a1fdf8
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/app/vrc_log.go
@@ -0,0 +1,345 @@
+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{}{}
+ 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])
+ 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)
+ 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, at)
+ }
+ 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{}{}
+ 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
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return err
+ }
+ 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)
+ }
+ _ = 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
+}
+
+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()
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go b/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go
new file mode 100644
index 0000000..f63e477
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/buildinfo/buildinfo.go
@@ -0,0 +1,6 @@
+package buildinfo
+
+var (
+ Version = "dev"
+ BuildTime = "unknown"
+)
diff --git a/VRWT_Tool/VRC_OSC/internal/common/project_paths.go b/VRWT_Tool/VRC_OSC/internal/common/project_paths.go
new file mode 100644
index 0000000..76b587c
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/common/project_paths.go
@@ -0,0 +1,16 @@
+package common
+
+import (
+ "os"
+ "path/filepath"
+)
+
+func RuntimeBaseDir() string {
+ exe, err := os.Executable()
+ if err == nil {
+ return filepath.Dir(exe)
+ }
+ return "."
+}
+
+func RootDir() string { return RuntimeBaseDir() }
diff --git a/VRWT_Tool/VRC_OSC/internal/config/config.go b/VRWT_Tool/VRC_OSC/internal/config/config.go
new file mode 100644
index 0000000..e584686
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/config/config.go
@@ -0,0 +1,146 @@
+package config
+
+import (
+ "bufio"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "vrc_osc_go/internal/common"
+)
+
+type Config struct {
+ OSC OSCConfig
+ VrcLog VrcLogConfig
+}
+
+type OSCConfig struct {
+ Host string
+ Port int
+}
+
+type VrcLogConfig struct {
+ SelfName string
+ GuestNames []string
+ GuestFile string
+ StaffNames []string
+ MissingCount int
+ LogPatterns []string
+}
+
+func Load(path string) (*Config, error) {
+ cfg := &Config{
+ OSC: OSCConfig{Host: "127.0.0.1", Port: 9001},
+ }
+ if path == "" {
+ path = filepath.Join(common.RootDir(), "config", "config.toml")
+ } else if !filepath.IsAbs(path) {
+ path = filepath.Join(common.RootDir(), path)
+ }
+ file, err := os.Open(path)
+ if err != nil {
+ return cfg, nil
+ }
+ defer file.Close()
+
+ var section string
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ line = strings.TrimPrefix(line, "\ufeff")
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
+ section = strings.Trim(line, "[]")
+ continue
+ }
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ key := strings.TrimSpace(parts[0])
+ val := strings.TrimSpace(parts[1])
+ val = strings.Trim(val, "\"'")
+ switch section {
+ case "osc":
+ switch key {
+ case "host":
+ cfg.OSC.Host = val
+ case "port":
+ if n, err := strconv.Atoi(val); err == nil {
+ cfg.OSC.Port = n
+ }
+ }
+ case "self":
+ if key == "name" {
+ cfg.VrcLog.SelfName = val
+ }
+ case "notice":
+ if key == "missing_count" {
+ if n, err := strconv.Atoi(val); err == nil {
+ cfg.VrcLog.MissingCount = n
+ }
+ }
+ case "vrc_log":
+ if key == "patterns" {
+ cfg.VrcLog.LogPatterns = parseList(val)
+ }
+ case "staff":
+ if key == "names" {
+ cfg.VrcLog.StaffNames = parseList(val)
+ }
+ case "guest":
+ switch key {
+ case "names":
+ cfg.VrcLog.GuestNames = parseList(val)
+ case "file":
+ cfg.VrcLog.GuestFile = val
+ }
+ }
+ }
+ if cfg.VrcLog.GuestFile != "" {
+ if !filepath.IsAbs(cfg.VrcLog.GuestFile) {
+ cfg.VrcLog.GuestFile = filepath.Join(common.RootDir(), cfg.VrcLog.GuestFile)
+ }
+ if names, err := loadLines(cfg.VrcLog.GuestFile); err == nil {
+ cfg.VrcLog.GuestNames = names
+ }
+ }
+ return cfg, scanner.Err()
+}
+
+func parseList(value string) []string {
+ value = strings.TrimSpace(value)
+ value = strings.TrimPrefix(value, "[")
+ value = strings.TrimSuffix(value, "]")
+ if value == "" {
+ return nil
+ }
+ parts := strings.Split(value, ",")
+ out := make([]string, 0, len(parts))
+ for _, part := range parts {
+ part = strings.TrimSpace(strings.Trim(part, "\"'"))
+ if part != "" {
+ out = append(out, part)
+ }
+ }
+ return out
+}
+
+func loadLines(path string) ([]string, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ lines := strings.Split(string(data), "\n")
+ out := make([]string, 0, len(lines))
+ for _, line := range lines {
+ line = strings.TrimSpace(strings.TrimPrefix(line, "\ufeff"))
+ if line != "" && !strings.HasPrefix(line, "#") {
+ out = append(out, line)
+ }
+ }
+ return out, nil
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/consenttool/config.go b/VRWT_Tool/VRC_OSC/internal/consenttool/config.go
new file mode 100644
index 0000000..702f2ca
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/consenttool/config.go
@@ -0,0 +1,144 @@
+package consenttool
+
+import (
+ "bufio"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+)
+
+type Config struct {
+ Consent ConsentConfig
+ Log LogConfig
+}
+
+type ConsentConfig struct {
+ InputCSV string
+ OutputJSON string
+ DisplayFields []string
+ ConsentFields []string
+ ConsentValues []string
+ NormalizeCase bool
+ AllowDuplicates bool
+}
+
+type LogConfig struct {
+ InputPath string
+ OutputPath string
+ IncludeRegex string
+ KeepNonMatch bool
+}
+
+func LoadConfig(path string) (*Config, error) {
+ cfg := &Config{
+ Consent: ConsentConfig{
+ InputCSV: "config/consent.csv",
+ OutputJSON: "config/consent_list.json",
+ DisplayFields: []string{"display_name", "vrchat_name", "name"},
+ ConsentFields: []string{"consent", "agree", "agreed", "checked"},
+ ConsentValues: []string{"1", "true", "yes", "y", "on", "checked"},
+ NormalizeCase: false,
+ AllowDuplicates: false,
+ },
+ Log: LogConfig{
+ InputPath: "runtime/output_log.txt",
+ OutputPath: "runtime/research_log.txt",
+ IncludeRegex: `\[[A-Z0-9:_ -]+\]`,
+ KeepNonMatch: false,
+ },
+ }
+
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ var section string
+ scanner := bufio.NewScanner(file)
+ for scanner.Scan() {
+ line := strings.TrimSpace(scanner.Text())
+ line = strings.TrimPrefix(line, "\ufeff")
+ if line == "" || strings.HasPrefix(line, "#") {
+ continue
+ }
+ if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
+ section = strings.Trim(line, "[]")
+ continue
+ }
+ parts := strings.SplitN(line, "=", 2)
+ if len(parts) != 2 {
+ continue
+ }
+ key := strings.TrimSpace(parts[0])
+ val := strings.TrimSpace(parts[1])
+ val = strings.Trim(val, "\"'")
+
+ switch section {
+ case "consent":
+ switch key {
+ case "input_csv":
+ cfg.Consent.InputCSV = val
+ case "output_json":
+ cfg.Consent.OutputJSON = val
+ case "display_fields":
+ cfg.Consent.DisplayFields = parseList(val)
+ case "consent_fields":
+ cfg.Consent.ConsentFields = parseList(val)
+ case "consent_values":
+ cfg.Consent.ConsentValues = parseList(val)
+ case "normalize_case":
+ cfg.Consent.NormalizeCase = parseBool(val)
+ case "allow_duplicates":
+ cfg.Consent.AllowDuplicates = parseBool(val)
+ }
+ case "log":
+ switch key {
+ case "input_path":
+ cfg.Log.InputPath = val
+ case "output_path":
+ cfg.Log.OutputPath = val
+ case "include_regex":
+ cfg.Log.IncludeRegex = val
+ case "keep_non_match":
+ cfg.Log.KeepNonMatch = parseBool(val)
+ }
+ }
+ }
+ return cfg, scanner.Err()
+}
+
+func parseList(value string) []string {
+ value = strings.TrimSpace(value)
+ value = strings.TrimPrefix(value, "[")
+ value = strings.TrimSuffix(value, "]")
+ if value == "" {
+ return nil
+ }
+ items := strings.Split(value, ",")
+ out := make([]string, 0, len(items))
+ for _, item := range items {
+ item = strings.TrimSpace(item)
+ item = strings.Trim(item, "\"'")
+ if item != "" {
+ out = append(out, item)
+ }
+ }
+ return out
+}
+
+func parseBool(value string) bool {
+ n, err := strconv.ParseBool(strings.TrimSpace(value))
+ return err == nil && n
+}
+
+func resolvePath(baseDir, value string) string {
+ if value == "" {
+ return ""
+ }
+ if filepath.IsAbs(value) {
+ return value
+ }
+ return filepath.Clean(filepath.Join(baseDir, value))
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/consenttool/tool.go b/VRWT_Tool/VRC_OSC/internal/consenttool/tool.go
new file mode 100644
index 0000000..1a58dd2
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/consenttool/tool.go
@@ -0,0 +1,183 @@
+package consenttool
+
+import (
+ "encoding/csv"
+ "encoding/json"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+)
+
+func Run(configPath string, mode string) error {
+ cfg, err := LoadConfig(configPath)
+ if err != nil {
+ return err
+ }
+ baseDir := filepath.Dir(configPath)
+
+ switch mode {
+ case "generate-json":
+ return generateJSON(cfg, baseDir)
+ case "extract-log":
+ return extractLog(cfg, baseDir)
+ default:
+ return fmt.Errorf("unknown mode %q", mode)
+ }
+}
+
+func generateJSON(cfg *Config, baseDir string) error {
+ inputPath := resolvePath(baseDir, cfg.Consent.InputCSV)
+ outputPath := resolvePath(baseDir, cfg.Consent.OutputJSON)
+
+ rows, err := readCSVRows(inputPath)
+ if err != nil {
+ return err
+ }
+
+ names := collectConsentedNames(rows, cfg.Consent)
+ body, err := json.MarshalIndent(names, "", " ")
+ if err != nil {
+ return err
+ }
+
+ if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(outputPath, append(body, '\n'), 0o644)
+}
+
+func extractLog(cfg *Config, baseDir string) error {
+ inputPath := resolvePath(baseDir, cfg.Log.InputPath)
+ outputPath := resolvePath(baseDir, cfg.Log.OutputPath)
+
+ data, err := os.ReadFile(inputPath)
+ if err != nil {
+ return err
+ }
+
+ re, err := regexp.Compile(cfg.Log.IncludeRegex)
+ if err != nil {
+ return err
+ }
+
+ var kept []string
+ for _, line := range strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n") {
+ if re.MatchString(line) {
+ kept = append(kept, line)
+ continue
+ }
+ if cfg.Log.KeepNonMatch {
+ kept = append(kept, line)
+ }
+ }
+
+ body := strings.Join(kept, "\n")
+ if body != "" {
+ body += "\n"
+ }
+ if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
+ return err
+ }
+ return os.WriteFile(outputPath, []byte(body), 0o644)
+}
+
+func readCSVRows(path string) ([]map[string]string, error) {
+ file, err := os.Open(path)
+ if err != nil {
+ return nil, err
+ }
+ defer file.Close()
+
+ r := csv.NewReader(file)
+ headers, err := r.Read()
+ if err != nil {
+ return nil, err
+ }
+ for i, header := range headers {
+ headers[i] = strings.TrimPrefix(strings.TrimSpace(header), "\ufeff")
+ }
+
+ var rows []map[string]string
+ for {
+ record, err := r.Read()
+ if err != nil {
+ if err == io.EOF {
+ break
+ }
+ return nil, err
+ }
+ row := make(map[string]string, len(headers))
+ for i, header := range headers {
+ if i < len(record) {
+ row[strings.TrimSpace(header)] = strings.TrimPrefix(strings.TrimSpace(record[i]), "\ufeff")
+ }
+ }
+ rows = append(rows, row)
+ }
+ return rows, nil
+}
+
+func collectConsentedNames(rows []map[string]string, cfg ConsentConfig) []string {
+ displayFields := cfg.DisplayFields
+ if len(displayFields) == 0 {
+ displayFields = []string{"display_name", "vrchat_name", "name"}
+ }
+ consentFields := cfg.ConsentFields
+ if len(consentFields) == 0 {
+ consentFields = []string{"consent", "agree", "agreed", "checked"}
+ }
+ accepted := make(map[string]struct{})
+ var names []string
+
+ for _, row := range rows {
+ if !rowIsConsented(row, consentFields, cfg.ConsentValues) {
+ continue
+ }
+ name := firstNonEmpty(row, displayFields)
+ if name == "" {
+ continue
+ }
+ key := name
+ if cfg.NormalizeCase {
+ key = strings.ToLower(key)
+ }
+ if !cfg.AllowDuplicates {
+ if _, ok := accepted[key]; ok {
+ continue
+ }
+ accepted[key] = struct{}{}
+ }
+ names = append(names, name)
+ }
+ return names
+}
+
+func rowIsConsented(row map[string]string, fields []string, values []string) bool {
+ if len(values) == 0 {
+ values = []string{"1", "true", "yes", "y", "on", "checked"}
+ }
+ allowed := make(map[string]struct{}, len(values))
+ for _, v := range values {
+ allowed[strings.ToLower(strings.TrimSpace(v))] = struct{}{}
+ }
+ for _, field := range fields {
+ if v, ok := row[field]; ok {
+ if _, ok := allowed[strings.ToLower(strings.TrimSpace(v))]; ok {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func firstNonEmpty(row map[string]string, fields []string) string {
+ for _, field := range fields {
+ if v := strings.TrimSpace(row[field]); v != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go b/VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go
new file mode 100644
index 0000000..a7ef4b6
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/consenttool/tool_test.go
@@ -0,0 +1,44 @@
+package consenttool
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestCollectConsentedNames(t *testing.T) {
+ rows := []map[string]string{
+ {"display_name": "Alice", "consent": "true"},
+ {"display_name": "Bob", "consent": "false"},
+ {"display_name": "Alice", "consent": "yes"},
+ }
+
+ names := collectConsentedNames(rows, ConsentConfig{})
+ if len(names) != 1 || names[0] != "Alice" {
+ t.Fatalf("unexpected names: %#v", names)
+ }
+}
+
+func TestExtractLog(t *testing.T) {
+ dir := t.TempDir()
+ cfg := &Config{
+ Log: LogConfig{
+ InputPath: "input.log",
+ OutputPath: "out.log",
+ IncludeRegex: `ID:[0-9]+`,
+ },
+ }
+ if err := os.WriteFile(filepath.Join(dir, "input.log"), []byte("x\nID:1 hello\nnope\n"), 0o600); err != nil {
+ t.Fatalf("WriteFile: %v", err)
+ }
+ if err := extractLog(cfg, dir); err != nil {
+ t.Fatalf("extractLog: %v", err)
+ }
+ out, err := os.ReadFile(filepath.Join(dir, "out.log"))
+ if err != nil {
+ t.Fatalf("ReadFile: %v", err)
+ }
+ if string(out) != "ID:1 hello\n" {
+ t.Fatalf("unexpected output: %q", string(out))
+ }
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/osc/server.go b/VRWT_Tool/VRC_OSC/internal/osc/server.go
new file mode 100644
index 0000000..2488509
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/osc/server.go
@@ -0,0 +1,141 @@
+package osc
+
+import (
+ "bytes"
+ "encoding/binary"
+ "fmt"
+ "math"
+ "net"
+ "strings"
+ "sync"
+)
+
+type Value struct {
+ Type byte
+ Float float32
+ Int int32
+ Bool bool
+ Str string
+}
+
+type Handler func(address string, args []Value) error
+
+type Server struct {
+ addr string
+ conn *net.UDPConn
+ handlers map[string]Handler
+ mu sync.RWMutex
+}
+
+func NewServer(host string, port int) *Server {
+ return &Server{
+ addr: fmt.Sprintf("%s:%d", host, port),
+ handlers: map[string]Handler{},
+ }
+}
+
+func (s *Server) Map(param string, h Handler) { s.handlers["/avatar/parameters/"+param] = h }
+func (s *Server) Close() error {
+ if s.conn != nil {
+ return s.conn.Close()
+ }
+ return nil
+}
+
+func (s *Server) Serve() error {
+ addr, err := net.ResolveUDPAddr("udp", s.addr)
+ if err != nil {
+ return err
+ }
+ conn, err := net.ListenUDP("udp", addr)
+ if err != nil {
+ return err
+ }
+ s.conn = conn
+ buf := make([]byte, 2048)
+ for {
+ n, _, err := conn.ReadFromUDP(buf)
+ if err != nil {
+ return err
+ }
+ a, v, err := parseMessage(buf[:n])
+ if err != nil {
+ continue
+ }
+ s.mu.RLock()
+ h := s.handlers[a]
+ s.mu.RUnlock()
+ if h != nil {
+ _ = h(a, v)
+ }
+ }
+}
+
+func parseMessage(b []byte) (string, []Value, error) {
+ address, offset, err := parsePaddedString(b, 0)
+ if err != nil {
+ return "", nil, err
+ }
+ if !strings.HasPrefix(address, "/") {
+ return "", nil, fmt.Errorf("invalid address")
+ }
+ if offset >= len(b) {
+ return address, nil, nil
+ }
+ typetags, offset, err := parsePaddedString(b, offset)
+ if err != nil {
+ return "", nil, err
+ }
+ if !strings.HasPrefix(typetags, ",") {
+ return address, nil, nil
+ }
+ var values []Value
+ for _, tag := range typetags[1:] {
+ switch tag {
+ case 'i':
+ if offset+4 > len(b) {
+ return "", nil, fmt.Errorf("invalid int")
+ }
+ values = append(values, Value{Type: 'i', Int: int32(binary.BigEndian.Uint32(b[offset : offset+4]))})
+ offset += 4
+ case 'f':
+ if offset+4 > len(b) {
+ return "", nil, fmt.Errorf("invalid float")
+ }
+ bits := binary.BigEndian.Uint32(b[offset : offset+4])
+ values = append(values, Value{Type: 'f', Float: math.Float32frombits(bits)})
+ offset += 4
+ case 's':
+ s, next, err := parsePaddedString(b, offset)
+ if err != nil {
+ return "", nil, err
+ }
+ values = append(values, Value{Type: 's', Str: s})
+ offset = next
+ case 'T':
+ values = append(values, Value{Type: 'T', Bool: true})
+ case 'F':
+ values = append(values, Value{Type: 'F', Bool: false})
+ }
+ }
+ return address, values, nil
+}
+
+func parsePaddedString(b []byte, offset int) (string, int, error) {
+ if offset >= len(b) {
+ return "", offset, fmt.Errorf("invalid osc string")
+ }
+ end := bytes.IndexByte(b[offset:], 0)
+ if end < 0 {
+ return "", offset, fmt.Errorf("invalid osc string")
+ }
+ s := string(b[offset : offset+end])
+ next := offset + end + 1
+ for next%4 != 0 {
+ next++
+ }
+ if next > len(b) {
+ next = len(b)
+ }
+ return s, next, nil
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/update/manager.go b/VRWT_Tool/VRC_OSC/internal/update/manager.go
new file mode 100644
index 0000000..e377fab
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/update/manager.go
@@ -0,0 +1,106 @@
+package update
+
+import (
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+
+ "vrc_osc_go/internal/buildinfo"
+ "vrc_osc_go/internal/common"
+)
+
+type Manager struct {
+ Client *Client
+ OwnerRepo string
+ AssetName string
+ CurrentLabel string
+}
+
+func (m *Manager) CheckAndUpdate() (bool, error) {
+ if m.Client == nil || m.OwnerRepo == "" || m.AssetName == "" {
+ return false, nil
+ }
+ latest, err := m.Client.LatestRelease(m.OwnerRepo)
+ if err != nil {
+ return false, err
+ }
+ if latest.TagName == "" || latest.TagName == m.CurrentLabel {
+ return false, nil
+ }
+ assetURL := ""
+ for _, a := range latest.Assets {
+ if a.Name == m.AssetName {
+ assetURL = a.URL
+ break
+ }
+ }
+ if assetURL == "" {
+ return false, fmt.Errorf("asset %q not found in release %s", m.AssetName, latest.TagName)
+ }
+ base := common.RootDir()
+ tmpDir := filepath.Join(base, "update", latest.TagName)
+ if err := os.MkdirAll(tmpDir, 0o755); err != nil {
+ return false, err
+ }
+ zipPath := filepath.Join(tmpDir, m.AssetName)
+ log.Printf("update available: %s -> %s", m.CurrentLabel, latest.TagName)
+ if err := m.Client.DownloadAsset(assetURL, zipPath); err != nil {
+ return false, err
+ }
+ if err := ExtractZip(zipPath, tmpDir); err != nil {
+ return false, err
+ }
+ exePath := filepath.Join(base, CurrentExeName())
+ newExe := filepath.Join(tmpDir, CurrentExeName())
+ if _, err := os.Stat(newExe); err != nil {
+ return false, err
+ }
+ backup := exePath + ".bak"
+ _ = os.Remove(backup)
+ if err := os.Rename(exePath, backup); err != nil {
+ return false, err
+ }
+ if err := CopyFile(newExe, exePath); err != nil {
+ _ = os.Rename(backup, exePath)
+ return false, err
+ }
+ _ = os.Remove(backup)
+ return true, nil
+}
+
+func CopyFile(src, dst string) error {
+ in, err := os.Open(src)
+ if err != nil {
+ return err
+ }
+ defer in.Close()
+ if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
+ return err
+ }
+ out, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer out.Close()
+ _, err = io.Copy(out, in)
+ return err
+}
+
+func Launch(exe string, args []string) error {
+ cmd := exec.Command(exe, args...)
+ cmd.Dir = filepath.Dir(exe)
+ return cmd.Start()
+}
+
+func Version() string { return buildinfo.Version }
+
+func CurrentExeName() string {
+ if runtime.GOOS == "windows" {
+ return "vrc_osc.exe"
+ }
+ return "vrc_osc"
+}
diff --git a/VRWT_Tool/VRC_OSC/internal/update/release.go b/VRWT_Tool/VRC_OSC/internal/update/release.go
new file mode 100644
index 0000000..8fff9f1
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/internal/update/release.go
@@ -0,0 +1,125 @@
+package update
+
+import (
+ "archive/zip"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+)
+
+type ReleaseInfo struct {
+ TagName string `json:"tag_name"`
+ Assets []struct {
+ Name string `json:"name"`
+ URL string `json:"browser_download_url"`
+ } `json:"assets"`
+}
+
+type Client struct {
+ BaseURL string
+ HTTP *http.Client
+}
+
+func (c *Client) LatestRelease(ownerRepo string) (*ReleaseInfo, error) {
+ req, err := http.NewRequest(http.MethodGet, c.apiURL(ownerRepo, "/releases/latest"), nil)
+ if err != nil {
+ return nil, err
+ }
+ var info ReleaseInfo
+ if err := c.doJSON(req, &info); err != nil {
+ return nil, err
+ }
+ return &info, nil
+}
+
+func (c *Client) DownloadAsset(url, dst string) error {
+ req, err := http.NewRequest(http.MethodGet, url, nil)
+ if err != nil {
+ return err
+ }
+ resp, err := c.httpClient().Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("download failed: %s", resp.Status)
+ }
+ f, err := os.Create(dst)
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ _, err = io.Copy(f, resp.Body)
+ return err
+}
+
+func ExtractZip(zipPath, dstDir string) error {
+ zr, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return err
+ }
+ defer zr.Close()
+ root := filepath.Clean(dstDir) + string(os.PathSeparator)
+ for _, f := range zr.File {
+ target := filepath.Clean(filepath.Join(dstDir, f.Name))
+ if target != filepath.Clean(dstDir) && !strings.HasPrefix(target, root) {
+ return fmt.Errorf("zip path escapes destination: %s", f.Name)
+ }
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return err
+ }
+ continue
+ }
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return err
+ }
+ rc, err := f.Open()
+ if err != nil {
+ return err
+ }
+ out, err := os.Create(target)
+ if err != nil {
+ rc.Close()
+ return err
+ }
+ _, copyErr := io.Copy(out, rc)
+ out.Close()
+ rc.Close()
+ if copyErr != nil {
+ return copyErr
+ }
+ }
+ return nil
+}
+
+func (c *Client) apiURL(ownerRepo, suffix string) string {
+ base := strings.TrimRight(c.BaseURL, "/")
+ return base + "/api/v1/repos/" + strings.Trim(ownerRepo, "/") + suffix
+}
+
+func (c *Client) doJSON(req *http.Request, out any) error {
+ resp, err := c.httpClient().Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
+ return fmt.Errorf("request failed: %s: %s", resp.Status, strings.TrimSpace(string(body)))
+ }
+ return json.NewDecoder(resp.Body).Decode(out)
+}
+
+func (c *Client) httpClient() *http.Client {
+ if c.HTTP != nil {
+ return c.HTTP
+ }
+ return &http.Client{Timeout: 30 * time.Second}
+}
diff --git a/VRWT_Tool/VRC_OSC/readme.txt b/VRWT_Tool/VRC_OSC/readme.txt
new file mode 100644
index 0000000..ad91257
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/readme.txt
@@ -0,0 +1,238 @@
+# VRC_OSC
+
+VRChat の OSC パラメータを受けて、Discord ミュート、VRChat ログ監視、OCR、翻訳を実行するローカルツール。
+
+## 起動
+
+依存関係を入れる。
+
+```powershell
+python -m pip install -r requirements.txt
+```
+
+PowerShell から起動する。
+
+```powershell
+bin\vrc_osc.ps1
+```
+
+Python から直接起動する場合。
+
+```powershell
+python src\bin\vrc_osc.py
+```
+
+OSC は `127.0.0.1:9001` で待ち受ける。
+
+## 構成
+
+```text
+bin/ PowerShell 起動スクリプト
+config/ example とローカル設定置き場
+src/app.py アプリ本体のエントリ
+src/bin/ Python 起動スクリプト
+src/common/ パス、設定ロード、runtime ログなどの共通基盤
+src/discord_control/ Discord ウィンドウ操作
+src/ocr/ OCR 実行
+src/osc/ OSC gateway
+src/screen/ VRChat ウィンドウ検出とキャプチャ
+src/tools/ 単体実行用の補助ツール
+src/translate/ 翻訳
+src/vrc_log/ VRChat ログ解析と名簿照合
+```
+
+## 設定ファイル
+
+実設定は `config/config.toml` に置く。このファイルは Git に入れない。
+
+雛形は `config/config.example.toml`。
+
+```toml
+[self]
+name = "自分のVRChat表示名"
+
+[staff]
+names = [
+ "StaffUserA"
+]
+
+[guest]
+file = "config/guests.txt"
+
+[notice]
+missing_count = 3
+
+[ocr.crop]
+left = 0.05
+top = 0.35
+right = 0.95
+bottom = 0.95
+
+[ocr]
+preprocess = true
+scale = 1.5
+use_angle_cls = false
+```
+
+`self.name` は Discord 自動ミュート判定に使う。
+
+`staff.names` は表示対象だが guest 人数には含めない。
+
+`guest.file` は guest 名簿ファイル。guest 人数の母数と在室判定に使う。
+
+`notice.missing_count` は「あと何人以内で全員集合か」の通知範囲。例えば guest が 23 人で `missing_count = 3` の場合、20〜22 人のときに未入室 guest を出す。
+
+OCR 設定は速度と精度の調整用。
+
+`ocr.crop` は VRChat ウィンドウ内の相対座標。`0.0` から `1.0` で指定する。デフォルトは画面下側の中央寄りだけを読む。
+
+フル画面 OCR に戻す場合。
+
+```toml
+[ocr.crop]
+left = 0
+top = 0
+right = 1
+bottom = 1
+```
+
+`ocr.preprocess = true` はグレースケール化、コントラスト補正、シャープ化、拡大を行う。
+
+`ocr.scale` は前処理時の拡大率。大きいほど小さい文字に強くなるが遅くなる。
+
+`ocr.use_angle_cls = false` は角度分類を切って高速化する。縦書きや回転文字を読む必要がある場合だけ `true` にする。
+
+## guest 名簿
+
+`config/guests.txt` に 1 行 1 名で書く。このファイルは Git に入れない。
+
+雛形は `config/guests.example.txt`。
+
+```text
+GuestUserA
+GuestUserB
+GuestUserC
+```
+
+空行と `#` で始まる行は無視する。
+
+## 秘密情報
+
+秘密情報は `config/secrets.toml` に置く。このファイルは Git に入れない。
+
+雛形は `config/secrets.example.toml`。
+
+```toml
+[discord]
+webhook_url = ""
+
+[vrchat]
+username = ""
+password = ""
+```
+
+`discord.webhook_url` は VRC ログ結果の Discord Webhook 通知に使う。
+
+`vrchat.username` / `vrchat.password` は `src/tools/resolve_vrchat_user.py` を使う場合だけ必要。
+
+## OSC パラメータ
+
+Avatar Parameters 側の名前と一致させる。
+
+| Parameter | 動作 |
+| --- | --- |
+| `DiscordSend` | 押した瞬間に Discord の mute hotkey を送る |
+| `vrc_log` | VRC ログ監視の on/off |
+| `ocrEnabled` | VRChat 画面を撮って OCR |
+| `translation` | OCR して日本語翻訳 |
+
+## VRC ログ監視
+
+`vrc_log` を押すと、現在の VRChat ログを一度解析して Discord Webhook に送る。その後はログファイル末尾を追い、新しい join / leave / world 系ログが追記されたタイミングで即再解析する。
+
+以前のような `output_log = 10` の秒数ポーリング設定は使わない。
+
+解析対象は昼 12:00 以降。実行時刻が昼 12:00 前なら前日 12:00 以降を見る。
+
+インスタンスが変わったら在室状態はリセットする。
+
+出力例。
+
+```text
+ワールド入室: Midnight Rooftop
+現在Guest: 0/3
+[01:20] [join] GuestUserA 1/3
+[01:21] [join] GuestUserB 2/3
+[01:25] [leave] GuestUserA 1/3
+[01:26] [join] [staff] StaffUserA 1/3
+```
+
+notice 条件に入った場合。
+
+```text
+現在Guest: 2/3
+未入室Guest:
+- GuestUserC
+```
+
+## Discord 自動ミュート
+
+常時 self monitor が VRChat ログの新規追記だけを見る。
+
+新しく追記されたログの中で、自分の最後のイベントが `OnPlayerJoined` なら Discord を mute、`OnPlayerLeft` なら unmute する。
+
+過去ログ全体の最後が self leave / join だっただけでは反応しない。
+
+`setDiscordMuted()` にはロックがあり、同時実行で mute hotkey が連続送信されないようにしている。
+
+## OCR / 翻訳
+
+`ocrEnabled` は VRChat ウィンドウを撮影して PaddleOCR にかける。結果はクリップボードへコピーする。
+
+`translation` は OCR 結果を翻訳し、翻訳結果をクリップボードへコピーする。
+
+OCR はデフォルトで画面全体ではなく、画面下側の中央寄りを切り出して読む。会話ログや字幕を読む用途では速く、余計な UI 文字を拾いにくい。
+
+## runtime
+
+新規の実行ログは基本的に以下へまとめる。
+
+```text
+runtime/runtime.log
+runtime/latest_ocr.png
+runtime/latest_screenshot.png
+```
+
+`runtime/` は Git に入れない。
+
+OCR text、翻訳 text、VRC ログ結果は `runtime/runtime.log` に追記する。
+
+画像は最新ファイルに上書きする。日時別ファイルを増やさない。
+
+## ユーザー名照合
+
+`src/vrc_log/member_name_matcher.py` は TOML 登録名と VRChat 表示名を安全な順番で紐づける補助実装。
+
+照合順。
+
+1. 完全一致
+2. 正規化一致
+3. 高信頼 fuzzy
+4. guest のみ低信頼 fuzzy
+5. pending / ambiguous / unmatched
+
+完全一致・正規化一致で確定した名前は fuzzy 候補から除外する。
+
+## Git に入れないもの
+
+以下はローカル専用。
+
+- `config/config.toml`
+- `config/secrets.toml`
+- `config/guests.txt`
+- `runtime/`
+- `__pycache__/`
+- `*.pyc`
+- `.vrchat_cookie.txt`
+
+秘密情報を含む可能性があるものはコミットしない。
diff --git a/VRWT_Tool/VRC_OSC/requirements.txt b/VRWT_Tool/VRC_OSC/requirements.txt
new file mode 100644
index 0000000..ccef526
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/requirements.txt
@@ -0,0 +1,10 @@
+deep-translator
+paddleocr
+pillow
+pyautogui
+pygetwindow
+pykakasi
+pyperclip
+python-osc
+rapidfuzz
+vrchatapi
diff --git a/VRWT_Tool/VRC_OSC/src/app.py b/VRWT_Tool/VRC_OSC/src/app.py
new file mode 100644
index 0000000..6b25eba
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/app.py
@@ -0,0 +1,114 @@
+import os
+from pathlib import Path
+import sys
+
+os.environ["FLAGS_enable_pir_api"] = "0"
+os.environ["FLAGS_use_onednn"] = "0"
+os.environ["FLAGS_use_mkldnn"] = "0"
+os.environ["FLAGS_use_onednn_bfloat16"] = "0"
+os.environ["ONEDNN_VERBOSE"] = "0"
+
+SRC_DIR = Path(__file__).resolve().parents[1]
+if str(SRC_DIR) not in sys.path:
+ sys.path.insert(0, str(SRC_DIR))
+
+from osc.gateway import VrcOscGateway
+from discord_control.actions import setDiscordMute
+from ocr.ocr_actions import runOcrFromScreen
+from translate.translate_actions import saveTranslationText
+from translate.translate_actions import translateTextToJapanese
+from vrc_log.log_actions import collectVrchatLog
+from vrc_log.log_actions import startSelfMonitor
+from vrc_log.log_actions import startVrcLogMonitor
+from vrc_log.log_actions import stopSelfMonitor
+from vrc_log.log_actions import stopVrcLogMonitor
+
+PARAM_DISCORD_MUTE = "DiscordSend"
+PARAM_LOG_GREP = "vrc_log"
+PARAM_OCR = "ocrEnabled"
+PARAM_TRANSLATE_OCR = "translation"
+
+
+def create_gateway():
+ gateway = VrcOscGateway(host="127.0.0.1", port=9001)
+
+ def on_discord_mute(address, *args):
+ if not args:
+ gateway.log("ERROR", "DiscordSend args empty")
+ return
+
+ if gateway.is_rising_edge(address, args[0]):
+ gateway.log("INFO", f"received {address} args={args}")
+ setDiscordMute()
+
+ vrc_log_enabled = False
+ def on_log_grep(address, *args):
+ nonlocal vrc_log_enabled
+
+ if not args:
+ gateway.log("ERROR", "vrc_log args empty")
+ return
+
+ if gateway.is_rising_edge(address, args[0]):
+ vrc_log_enabled = not vrc_log_enabled
+ gateway.log("INFO", f"received {address} args={args} enabled={vrc_log_enabled}")
+
+ if vrc_log_enabled:
+ collectVrchatLog(notify_changed=True)
+ startVrcLogMonitor()
+ else:
+ stopVrcLogMonitor()
+
+ def on_ocr(address, *args):
+ if not args:
+ gateway.log("ERROR", "ocrEnabled args empty")
+ return
+
+ if gateway.is_rising_edge(address, args[0]):
+ gateway.log("INFO", f"received {address} args={args}")
+ runOcrFromScreen()
+
+ def on_translate_ocr(address, *args):
+ if not args:
+ gateway.log("ERROR", "translateOcr args empty")
+ return
+
+ if gateway.is_rising_edge(address, args[0]):
+ gateway.log("INFO", f"received {address} args={args}")
+
+ result = runOcrFromScreen()
+ if not result:
+ gateway.log("ERROR", "OCR result is None")
+ return
+
+ text = result.get("text", "")
+ if not text.strip():
+ gateway.log("INFO", "OCR text is empty. translation skipped")
+ return
+
+ translated = translateTextToJapanese(text)
+ if not translated.strip():
+ gateway.log("INFO", "translation result is empty")
+ return
+
+ image_path = result.get("image_path")
+ stem = image_path.stem if image_path else "translation"
+ saved = saveTranslationText(stem, translated)
+ gateway.log("INFO", f"translation saved={saved}")
+
+ gateway.map(PARAM_DISCORD_MUTE, on_discord_mute)
+ gateway.map(PARAM_LOG_GREP, on_log_grep)
+ gateway.map(PARAM_OCR, on_ocr)
+ gateway.map(PARAM_TRANSLATE_OCR, on_translate_ocr)
+ gateway.set_default_debug(enabled=False)
+ return gateway
+
+
+def main():
+ gateway = create_gateway()
+ startSelfMonitor()
+ gateway.serve_forever()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py b/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py
new file mode 100644
index 0000000..bc7e369
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/bin/vrc_osc.py
@@ -0,0 +1,13 @@
+from pathlib import Path
+import sys
+
+SRC_DIR = Path(__file__).resolve().parents[1]
+
+if str(SRC_DIR) not in sys.path:
+ sys.path.insert(0, str(SRC_DIR))
+
+from app import main
+
+
+if __name__ == "__main__":
+ main()
diff --git a/VRWT_Tool/VRC_OSC/src/common/config_loader.py b/VRWT_Tool/VRC_OSC/src/common/config_loader.py
new file mode 100644
index 0000000..364ea60
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/common/config_loader.py
@@ -0,0 +1,130 @@
+from pathlib import Path
+import tomllib
+
+from common.project_paths import ROOT_DIR
+
+CONFIG_DIR = ROOT_DIR / "config"
+CONFIG_FILE = CONFIG_DIR / "config.toml"
+SECRETS_FILE = CONFIG_DIR / "secrets.toml"
+DEFAULT_EVENT_FILE = CONFIG_DIR / "event.toml"
+DEFAULT_GUEST_FILE = CONFIG_DIR / "guests.txt"
+
+
+def loadTomlFile(path):
+ if not path.exists():
+ return {}
+
+ return tomllib.loads(path.read_text(encoding="utf-8"))
+
+
+def resolveConfigPath(value, default_path):
+ if not value:
+ return default_path
+
+ candidate = Path(value)
+ if candidate.is_absolute():
+ return candidate
+
+ return (ROOT_DIR / candidate).resolve()
+
+
+def loadNameListFile(path):
+ if not path.exists():
+ return []
+
+ names = []
+
+ for line in path.read_text(encoding="utf-8-sig", errors="replace").splitlines():
+ name = line.strip()
+ if not name or name.startswith("#"):
+ continue
+ names.append(name)
+
+ return names
+
+
+def loadSecretConfig():
+ return loadTomlFile(SECRETS_FILE)
+
+
+def getSecretValue(section_name, key, default=""):
+ secrets = loadSecretConfig()
+ section = secrets.get(section_name, {})
+ return str(section.get(key, default)).strip()
+
+
+def loadOcrConfig():
+ config = loadTomlFile(CONFIG_FILE)
+ ocr = config.get("ocr", {})
+ crop = ocr.get("crop", {})
+
+ return {
+ "crop_left": float(crop.get("left", 0.05)),
+ "crop_top": float(crop.get("top", 0.35)),
+ "crop_right": float(crop.get("right", 0.95)),
+ "crop_bottom": float(crop.get("bottom", 0.95)),
+ "preprocess": bool(ocr.get("preprocess", True)),
+ "scale": float(ocr.get("scale", 1.5)),
+ "use_angle_cls": bool(ocr.get("use_angle_cls", False)),
+ }
+
+
+def loadVrcLogConfig():
+ config = loadTomlFile(CONFIG_FILE)
+ vrc_log = config.get("vrc_log", {})
+ self_section = config.get("self", {})
+ event_file_value = (
+ vrc_log.get("event_toml")
+ or vrc_log.get("event_file")
+ or config.get("event_toml")
+ or config.get("event_file")
+ )
+
+ event_file = resolveConfigPath(event_file_value, DEFAULT_EVENT_FILE)
+ event_config = loadTomlFile(event_file)
+
+ if not event_config:
+ event_config = vrc_log or config
+
+ guest_section = event_config.get("guest", {})
+ guest_names = guest_section.get("names", [])
+ guest_file_value = (
+ guest_section.get("file")
+ or event_config.get("guest_file")
+ or config.get("guest_file")
+ )
+ guest_file = resolveConfigPath(guest_file_value, DEFAULT_GUEST_FILE)
+
+ if not guest_names:
+ guest_names = loadNameListFile(guest_file)
+
+ staff_names = event_config.get("staff", {}).get("names", [])
+
+ if not guest_names and isinstance(event_config.get("guest_names"), list):
+ guest_names = event_config.get("guest_names", [])
+ if not staff_names and isinstance(event_config.get("staff_names"), list):
+ staff_names = event_config.get("staff_names", [])
+
+ notice_section = event_config.get("notice", {})
+ missing_count = notice_section.get("missing_count", 0)
+
+ if not missing_count:
+ missing_count = event_config.get("missing_count", 0)
+
+ self_name = (
+ self_section.get("name")
+ or config.get("self_name")
+ or vrc_log.get("self_name")
+ or event_config.get("self", {}).get("name")
+ or ""
+ )
+
+ return {
+ "config_file": CONFIG_FILE,
+ "event_file": event_file,
+ "guest_file": guest_file,
+ "guest_names": [str(name).strip() for name in guest_names if str(name).strip()],
+ "staff_names": [str(name).strip() for name in staff_names if str(name).strip()],
+ "missing_count": int(missing_count),
+ "self_name": str(self_name).strip(),
+ }
diff --git a/VRWT_Tool/VRC_OSC/src/common/project_paths.py b/VRWT_Tool/VRC_OSC/src/common/project_paths.py
new file mode 100644
index 0000000..fa38746
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/common/project_paths.py
@@ -0,0 +1,6 @@
+from pathlib import Path
+
+SRC_DIR = Path(__file__).resolve().parents[1]
+ROOT_DIR = SRC_DIR.parent
+RUNTIME_DIR = ROOT_DIR / "runtime"
+RUNTIME_LOG_FILE = RUNTIME_DIR / "runtime.log"
diff --git a/VRWT_Tool/VRC_OSC/src/common/runtime_log.py b/VRWT_Tool/VRC_OSC/src/common/runtime_log.py
new file mode 100644
index 0000000..862cec0
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/common/runtime_log.py
@@ -0,0 +1,19 @@
+from datetime import datetime
+
+from common.project_paths import RUNTIME_LOG_FILE
+
+
+def appendRuntimeLog(title, text):
+ RUNTIME_LOG_FILE.parent.mkdir(parents=True, exist_ok=True)
+
+ now = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ body = str(text).strip()
+ if not body:
+ body = "(empty)"
+
+ with RUNTIME_LOG_FILE.open("a", encoding="utf-8") as file:
+ file.write(f"\n[{now}] {title}\n")
+ file.write(body)
+ file.write("\n")
+
+ return RUNTIME_LOG_FILE
diff --git a/VRWT_Tool/VRC_OSC/src/discord_control/actions.py b/VRWT_Tool/VRC_OSC/src/discord_control/actions.py
new file mode 100644
index 0000000..9d1b68f
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/discord_control/actions.py
@@ -0,0 +1,203 @@
+from datetime import datetime
+import ctypes
+import threading
+import time
+
+import pyautogui
+import pygetwindow as gw
+
+BROWSER_KEYWORDS = [
+ "google chrome",
+ "microsoft edge",
+ "mozilla firefox",
+]
+
+_discord_muted = True
+_discord_mute_lock = threading.Lock()
+
+
+def log(level, message):
+ now = datetime.now().strftime("%H:%M:%S")
+ print(f"[{level}] {now} {message}", flush=True)
+
+
+def findDiscordWindow():
+ candidates = []
+
+ for window in gw.getAllWindows():
+ title = (window.title or "").strip()
+ if not title:
+ continue
+ if "discord" not in title.lower():
+ continue
+ candidates.append(window)
+
+ if not candidates:
+ raise RuntimeError("Discordウィンドウが見つかりません")
+
+ candidates.sort(key=lambda window: -(window.width * window.height))
+ return candidates[0]
+
+
+def findBrowserWindow():
+ candidates = []
+
+ for window in gw.getAllWindows():
+ title = (window.title or "").strip()
+ if not title:
+ continue
+ lowered = title.lower()
+ if not any(keyword in lowered for keyword in BROWSER_KEYWORDS):
+ continue
+ candidates.append(window)
+
+ if not candidates:
+ raise RuntimeError("ブラウザウィンドウが見つかりません")
+
+ candidates.sort(key=lambda window: -(window.width * window.height))
+ return candidates[0]
+
+
+def findDiscordBrowserWindow():
+ candidates = []
+
+ for window in gw.getAllWindows():
+ title = (window.title or "").strip()
+ if not title:
+ continue
+ lowered = title.lower()
+ if "discord" not in lowered:
+ continue
+ if not any(keyword in lowered for keyword in BROWSER_KEYWORDS):
+ continue
+ candidates.append(window)
+
+ if not candidates:
+ return None
+
+ candidates.sort(key=lambda window: -(window.width * window.height))
+ return candidates[0]
+
+
+def activateDiscordWindow():
+ window = findDiscordWindow()
+ hwnd = getattr(window, "_hWnd", None)
+
+ try:
+ if window.isMinimized:
+ window.restore()
+ except Exception:
+ pass
+
+ if hwnd:
+ try:
+ ctypes.windll.user32.ShowWindow(hwnd, 9)
+ except Exception:
+ pass
+
+ try:
+ window.activate()
+ except Exception as e:
+ log("ERROR", f"Discord activate() failed detail={e}")
+
+ if hwnd:
+ try:
+ ctypes.windll.user32.BringWindowToTop(hwnd)
+ ctypes.windll.user32.SetForegroundWindow(hwnd)
+ except Exception as e:
+ log("ERROR", f"Discord SetForegroundWindow failed detail={e}")
+
+ time.sleep(0.15)
+
+ log("INFO", f"Discord window title={window.title}")
+ return window
+
+
+def findVrchatWindow():
+ candidates = []
+
+ for window in gw.getAllWindows():
+ title = (window.title or "").strip()
+ if not title:
+ continue
+ lowered = title.lower()
+ if lowered == "vrchat":
+ candidates.append((0, window))
+ continue
+ if lowered.startswith("vrchat "):
+ candidates.append((1, window))
+ continue
+ if lowered.startswith("vrchat"):
+ candidates.append((2, window))
+
+ if not candidates:
+ raise RuntimeError("VRChatウィンドウが見つかりません")
+
+ candidates.sort(key=lambda item: (item[0], -(item[1].width * item[1].height)))
+ return candidates[0][1]
+
+
+def activateWindow(window, label):
+ if not window:
+ return
+
+ hwnd = getattr(window, "_hWnd", None)
+
+ try:
+ if window.isMinimized:
+ window.restore()
+ except Exception:
+ pass
+
+ if hwnd:
+ try:
+ ctypes.windll.user32.ShowWindow(hwnd, 9)
+ except Exception:
+ pass
+
+ try:
+ window.activate()
+ except Exception as e:
+ log("ERROR", f"{label} activate() failed detail={e}")
+
+ if hwnd:
+ try:
+ ctypes.windll.user32.BringWindowToTop(hwnd)
+ ctypes.windll.user32.SetForegroundWindow(hwnd)
+ except Exception as e:
+ log("ERROR", f"{label} SetForegroundWindow failed detail={e}")
+
+ time.sleep(0.15)
+ try:
+ pyautogui.click(window.left + window.width // 2, window.top + window.height // 2)
+ except Exception:
+ pass
+ time.sleep(0.1)
+ log("INFO", f"{label} window title={window.title}")
+
+
+def setDiscordMute():
+ log("ACTION", "setDiscordMute called")
+ try:
+ activateDiscordWindow()
+ except Exception as e:
+ log("ERROR", f"Discord window unavailable detail={e}")
+ browser = findDiscordBrowserWindow() or findBrowserWindow()
+ activateWindow(browser, "Browser")
+ log("INFO", "Discord hotkey Ctrl+Shift+M")
+ pyautogui.hotkey("ctrl", "shift", "m")
+ activateWindow(findVrchatWindow(), "VRChat")
+
+
+def setDiscordMuted(muted):
+ global _discord_muted
+
+ muted = bool(muted)
+
+ with _discord_mute_lock:
+ if _discord_muted == muted:
+ log("INFO", f"Discord mute already {'on' if muted else 'off'}")
+ return
+
+ setDiscordMute()
+ _discord_muted = muted
diff --git a/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py b/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py
new file mode 100644
index 0000000..56f2a4a
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/ocr/ocr_actions.py
@@ -0,0 +1,186 @@
+import os
+from datetime import datetime
+
+os.environ["FLAGS_enable_pir_api"] = "0"
+os.environ["FLAGS_use_onednn"] = "0"
+os.environ["FLAGS_use_mkldnn"] = "0"
+os.environ["FLAGS_use_onednn_bfloat16"] = "0"
+os.environ["MKLDNN_DISABLE_WORKSPACE"] = "1"
+os.environ["ONEDNN_VERBOSE"] = "0"
+
+import pyperclip
+from paddleocr import PaddleOCR
+from PIL import Image
+from PIL import ImageFilter
+from PIL import ImageOps
+
+from common.config_loader import loadOcrConfig
+from common.runtime_log import appendRuntimeLog
+from screen.screen_actions import captureOcrRegion
+
+_ocr_engine = None
+
+
+def log(level, message):
+ now = datetime.now().strftime("%H:%M:%S")
+ print(f"[{level}] {now} {message}", flush=True)
+
+
+def getOcrEngine():
+ global _ocr_engine
+
+ if _ocr_engine is None:
+ log("INFO", "PaddleOCR initializing")
+ config = loadOcrConfig()
+ options = {
+ "lang": "japan",
+ "use_angle_cls": config["use_angle_cls"],
+ "show_log": False,
+ }
+
+ try:
+ _ocr_engine = PaddleOCR(**options)
+ except (TypeError, ValueError):
+ options.pop("show_log", None)
+ _ocr_engine = PaddleOCR(**options)
+
+ return _ocr_engine
+
+
+def saveOcrText(image_path, text):
+ return appendRuntimeLog(
+ "OCR TEXT",
+ f"image={image_path}\n{text}",
+ )
+
+
+def extractTextFromAny(obj):
+ lines = []
+
+ def walk(x):
+ if x is None:
+ return
+
+ if isinstance(x, dict):
+ for key in ("rec_texts", "texts"):
+ value = x.get(key)
+ if isinstance(value, list):
+ for item in value:
+ text = str(item).strip()
+ if text:
+ lines.append(text)
+
+ for key in ("text", "rec_text"):
+ value = x.get(key)
+ if isinstance(value, str) and value.strip():
+ lines.append(value.strip())
+
+ for value in x.values():
+ walk(value)
+ return
+
+ if isinstance(x, (list, tuple)):
+ if len(x) >= 2 and isinstance(x[1], (list, tuple)) and len(x[1]) >= 1:
+ candidate = x[1][0]
+ if isinstance(candidate, str) and candidate.strip():
+ lines.append(candidate.strip())
+
+ for item in x:
+ walk(item)
+
+ walk(obj)
+
+ unique_lines = []
+ seen = set()
+ for line in lines:
+ if line not in seen:
+ unique_lines.append(line)
+ seen.add(line)
+
+ return "\n".join(unique_lines)
+
+
+def printOcrResult(text):
+ cleaned = text.strip()
+ if not cleaned:
+ log("INFO", "OCR結果は空です")
+ return
+
+ log("CHECK", "OCR結果 full")
+ for line in cleaned.splitlines():
+ print(line, flush=True)
+
+
+def preprocessOcrImage(image_path):
+ config = loadOcrConfig()
+ enabled = config["preprocess"]
+ if not enabled:
+ return image_path
+
+ try:
+ image = Image.open(image_path)
+ image = image.convert("L")
+ image = ImageOps.autocontrast(image)
+ image = image.filter(ImageFilter.SHARPEN)
+
+ scale = max(1.0, min(2.0, config["scale"]))
+ if scale > 1.0:
+ width, height = image.size
+ resampling = getattr(
+ getattr(Image, "Resampling", Image),
+ "LANCZOS",
+ )
+ image = image.resize(
+ (int(width * scale), int(height * scale)),
+ resampling,
+ )
+
+ image.save(image_path)
+ log("INFO", f"OCR image preprocessed={image_path}")
+ except Exception as e:
+ log("ERROR", f"OCR preprocess failed detail={e}")
+
+ return image_path
+
+
+def runPaddleOcr(engine, image_path):
+ if hasattr(engine, "predict"):
+ return engine.predict(str(image_path))
+ return engine.ocr(str(image_path))
+
+
+def runOcrFromImage(image_path, show_result=True):
+ log("ACTION", f"PaddleOCR start image={image_path}")
+
+ try:
+ image_path = preprocessOcrImage(image_path)
+ engine = getOcrEngine()
+ result = runPaddleOcr(engine, image_path)
+ except Exception as e:
+ log("ERROR", f"PaddleOCR failed detail={e}")
+ return None
+
+ text = extractTextFromAny(result)
+ text_path = saveOcrText(image_path, text)
+
+ try:
+ pyperclip.copy(text)
+ log("INFO", "OCR結果をクリップボードへコピーしました")
+ except Exception as e:
+ log("ERROR", f"clipboard copy failed detail={e}")
+
+ log("INFO", f"OCR text appended={text_path}")
+ if show_result:
+ printOcrResult(text)
+
+ return {
+ "image_path": image_path,
+ "text_path": text_path,
+ "text": text,
+ }
+
+
+def runOcrFromScreen():
+ log("ACTION", "runOcrFromScreen called")
+ image_path = captureOcrRegion()
+ return runOcrFromImage(image_path, show_result=True)
diff --git a/VRWT_Tool/VRC_OSC/src/osc/gateway.py b/VRWT_Tool/VRC_OSC/src/osc/gateway.py
new file mode 100644
index 0000000..419d88b
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/osc/gateway.py
@@ -0,0 +1,85 @@
+from pythonosc.dispatcher import Dispatcher
+from pythonosc.osc_server import BlockingOSCUDPServer
+from datetime import datetime
+import sys
+
+class VrcOscGateway:
+ def __init__(self, host="127.0.0.1", port=9001):
+ self.host = host
+ self.port = port
+ self.dispatcher = Dispatcher()
+ self.last_values = {}
+
+ def log(self, level, message):
+ now = datetime.now().strftime("%H:%M:%S")
+ print(f"[{level}] {now} {message}", flush=True)
+
+ def to_bool(self, value):
+ if isinstance(value, bool):
+ return value
+
+ if isinstance(value, int) or isinstance(value, float):
+ return value != 0
+
+ if isinstance(value, str):
+ lowered = value.strip().lower()
+ if lowered in ("true", "1", "on"):
+ return True
+ if lowered in ("false", "0", "off"):
+ return False
+
+ raise ValueError(f"unsupported OSC value: {value}")
+
+ def is_rising_edge(self, address, value):
+ current = self.to_bool(value)
+ previous = self.last_values.get(address, False)
+ self.last_values[address] = current
+ return current and not previous
+
+ def is_changed(self, address, value):
+ current = self.to_bool(value)
+ previous = self.last_values.get(address, None)
+
+ if previous == current:
+ return False
+
+ self.last_values[address] = current
+ return True
+
+ def map(self, parameter_name, handler):
+ address = f"/avatar/parameters/{parameter_name}"
+ self.dispatcher.map(address, handler)
+ self.log("INFO", f"mapped {address}")
+
+ def set_default_debug(self, enabled=False):
+ if not enabled:
+ return
+
+ def on_any(address, *args):
+ if address.startswith("/avatar/parameters/"):
+ self.log("INFO", f"other address={address} args={args}")
+
+ self.dispatcher.set_default_handler(on_any)
+
+ def serve_forever(self):
+ self.log("CHECK", "STEP=1 状態確認")
+ self.log("INFO", f"listen={self.host}:{self.port}")
+
+ self.log("CHECK", "STEP=2 想定状態判定")
+ self.log("INFO", "VRChat OSC Enabled")
+ self.log("INFO", "Python は Windows PowerShell 側で実行")
+ self.log("INFO", "Unity側 MA Parameters と OSC名が一致していること")
+
+
+ try:
+ server = BlockingOSCUDPServer((self.host, self.port), self.dispatcher)
+ server.serve_forever()
+ except KeyboardInterrupt:
+ self.log("INFO", "Ctrl+C detected")
+ except Exception as e:
+ self.log("FATAL", f"OSC server error STEP=3 detail={e}")
+ input("Press Enter to exit...")
+ sys.exit(1)
+
+ self.log("CHECK", "STEP=4 結果確認")
+ self.log("INFO", "終了しました")
diff --git a/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py b/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py
new file mode 100644
index 0000000..da0cf0a
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/screen/screen_actions.py
@@ -0,0 +1,167 @@
+import pyautogui
+import pygetwindow as gw
+
+from common.config_loader import loadOcrConfig
+from common.project_paths import RUNTIME_DIR
+
+SCREENSHOT_PATH = RUNTIME_DIR / "latest_screenshot.png"
+OCR_SCREENSHOT_PATH = RUNTIME_DIR / "latest_ocr.png"
+
+EXCLUDED_WINDOW_KEYWORDS = [
+ "Google Chrome",
+ "Microsoft Edge",
+ "Mozilla Firefox",
+ "Visual Studio Code",
+ "PowerShell",
+ "Windows Terminal",
+ "ChatGPT",
+]
+
+
+def log(level, message):
+ from datetime import datetime
+
+ now = datetime.now().strftime("%H:%M:%S")
+ print(f"[{level}] {now} {message}", flush=True)
+
+
+def findVrchatWindow():
+ windows = gw.getAllWindows()
+ candidates = []
+
+ for window in windows:
+ title = window.title or ""
+ normalized = title.strip().lower()
+ if not normalized:
+ continue
+ if any(keyword.lower() in normalized for keyword in EXCLUDED_WINDOW_KEYWORDS):
+ continue
+
+ if normalized == "vrchat":
+ candidates.append((0, window))
+ continue
+ if normalized.startswith("vrchat "):
+ candidates.append((1, window))
+ continue
+ if normalized.startswith("vrchat"):
+ candidates.append((2, window))
+
+ if not candidates:
+ visible_titles = []
+ for window in windows:
+ title = (window.title or "").strip()
+ if title:
+ visible_titles.append(title)
+
+ log("ERROR", "VRChatウィンドウが見つかりません")
+ log("CHECK", "現在検出できるウィンドウ")
+ for title in visible_titles[:30]:
+ print(title, flush=True)
+
+ raise RuntimeError("VRChatウィンドウが見つかりません")
+
+ candidates = sorted(
+ candidates,
+ key=lambda item: (item[0], -(item[1].width * item[1].height)),
+ )
+ window = candidates[0][1]
+
+ if window.width <= 0 or window.height <= 0:
+ raise RuntimeError("VRChatウィンドウサイズが不正です")
+
+ return window
+
+
+def getVrchatRegion():
+ window = findVrchatWindow()
+
+ try:
+ if window.isMinimized:
+ window.restore()
+ except Exception:
+ pass
+
+ left = int(window.left)
+ top = int(window.top)
+ width = int(window.width)
+ height = int(window.height)
+
+ if width <= 0 or height <= 0:
+ raise RuntimeError("VRChatウィンドウ範囲が不正です")
+
+ log("INFO", f"VRChat window title={window.title}")
+ log("INFO", f"VRChat region left={left} top={top} width={width} height={height}")
+ return left, top, width, height
+
+
+def clampRatio(value):
+ return max(0.0, min(1.0, value))
+
+
+def getOcrRegion():
+ left, top, width, height = getVrchatRegion()
+ config = loadOcrConfig()
+
+ # Default to the lower central area. It is much faster than OCRing the
+ # whole VRChat window and usually contains chat/subtitle text.
+ crop_left = clampRatio(config["crop_left"])
+ crop_top = clampRatio(config["crop_top"])
+ crop_right = clampRatio(config["crop_right"])
+ crop_bottom = clampRatio(config["crop_bottom"])
+
+ if crop_right <= crop_left:
+ crop_left = 0.0
+ crop_right = 1.0
+
+ if crop_bottom <= crop_top:
+ crop_top = 0.0
+ crop_bottom = 1.0
+
+ region_left = left + int(width * crop_left)
+ region_top = top + int(height * crop_top)
+ region_width = max(1, int(width * (crop_right - crop_left)))
+ region_height = max(1, int(height * (crop_bottom - crop_top)))
+
+ log(
+ "INFO",
+ f"OCR crop left={region_left} top={region_top} width={region_width} height={region_height}",
+ )
+ return region_left, region_top, region_width, region_height
+
+
+def captureRegionTo(image_path, region):
+ image_path.parent.mkdir(parents=True, exist_ok=True)
+
+ left, top, width, height = region
+
+ image = pyautogui.screenshot(region=(left, top, width, height))
+ image.save(image_path)
+
+ log("ACTION", f"captureVrchatWindow saved={image_path}")
+ return image_path
+
+
+def captureVrchatWindowTo(image_path):
+ return captureRegionTo(
+ image_path,
+ getVrchatRegion(),
+ )
+
+
+def captureVrchatWindow():
+ return captureVrchatWindowTo(SCREENSHOT_PATH)
+
+
+def captureOcrRegion():
+ return captureRegionTo(
+ OCR_SCREENSHOT_PATH,
+ getOcrRegion(),
+ )
+
+
+def captureScreen():
+ return captureVrchatWindow()
+
+
+def takeScreenshot():
+ return captureVrchatWindow()
diff --git a/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py b/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py
new file mode 100644
index 0000000..03514d2
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/tools/discord_hold_mute_listener.py
@@ -0,0 +1,124 @@
+from pythonosc.dispatcher import Dispatcher
+from pythonosc.osc_server import BlockingOSCUDPServer
+from datetime import datetime
+import pyautogui
+import time
+import sys
+
+HOST = "127.0.0.1"
+PORT = 9001
+PARAMETER = "/avatar/parameters/DiscordSend"
+
+# Discordの初期状態を「ミュート中」と仮定
+discord_muted = True
+button_pressed = False
+last_action_time = 0.0
+DEBOUNCE_SECONDS = 0.3
+
+def log(level, message):
+ now = datetime.now().strftime("%H:%M:%S")
+ print(f"[{level}] {now} {message}", flush=True)
+
+def wait_exit():
+ input("Press Enter to exit...")
+
+def press_discord_mute_hotkey():
+ pyautogui.hotkey("ctrl", "shift", "m")
+
+def handle_discord_send(address, *args):
+ global discord_muted
+ global button_pressed
+ global last_action_time
+
+ if not args:
+ log("ERROR", "OSC args is empty")
+ return
+
+ value = args[0]
+ now_ts = time.time()
+
+ if isinstance(value, bool):
+ is_pressed = value
+ elif isinstance(value, int) or isinstance(value, float):
+ is_pressed = value != 0
+ else:
+ log("ERROR", f"unsupported value type value={value}")
+ return
+
+ if now_ts - last_action_time < DEBOUNCE_SECONDS:
+ return
+
+ # 押した瞬間: ミュート解除
+ if is_pressed and not button_pressed:
+ button_pressed = True
+
+ if discord_muted:
+ log("ACTION", "VRChat button pressed -> Discord unmute hotkey")
+ press_discord_mute_hotkey()
+ discord_muted = False
+ last_action_time = now_ts
+ else:
+ log("INFO", "already unmuted")
+
+ return
+
+ # 離した瞬間: ミュート
+ if not is_pressed and button_pressed:
+ button_pressed = False
+
+ if not discord_muted:
+ log("ACTION", "VRChat button released -> Discord mute hotkey")
+ press_discord_mute_hotkey()
+ discord_muted = True
+ last_action_time = now_ts
+ else:
+ log("INFO", "already muted")
+
+ return
+
+def main():
+ log("CHECK", "STEP=1 OSC受信状態確認")
+ log("INFO", f"host={HOST}")
+ log("INFO", f"port={PORT}")
+ log("INFO", f"parameter={PARAMETER}")
+
+ log("CHECK", "STEP=2 想定状態判定")
+ log("INFO", "Windows PowerShellで実行していること")
+ log("INFO", "VRChat OSC Enabledであること")
+ log("INFO", "Discordのミュート切替キーが Ctrl+Shift+M であること")
+ log("INFO", "Discordの現在状態はミュート中として扱います")
+
+ answer = input("[ACTION] 続行しますか? yes/no: ").strip().lower()
+
+ if answer != "yes":
+ if answer == "no":
+ log("FATAL", "ユーザー操作により停止 STEP=2")
+ wait_exit()
+ sys.exit(1)
+
+ log("ERROR", "不正入力です STEP=2")
+ wait_exit()
+ sys.exit(1)
+
+ log("ACTION", "STEP=3 OSC受信開始")
+ log("INFO", "VRChatのメニューを押している間だけDiscordミュート解除します")
+ log("INFO", "停止する場合は Ctrl+C")
+
+ dispatcher = Dispatcher()
+ dispatcher.map(PARAMETER, handle_discord_send)
+
+ try:
+ server = BlockingOSCUDPServer((HOST, PORT), dispatcher)
+ server.serve_forever()
+ except KeyboardInterrupt:
+ log("INFO", "Ctrl+C detected")
+ except Exception as e:
+ log("FATAL", f"OSC server error STEP=3 detail={e}")
+ wait_exit()
+ sys.exit(1)
+
+ log("CHECK", "STEP=4 結果確認")
+ log("INFO", "終了しました")
+
+if __name__ == "__main__":
+ main()
diff --git a/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py b/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py
new file mode 100644
index 0000000..c21b324
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/tools/osc_debug_listener.py
@@ -0,0 +1,21 @@
+from pythonosc.dispatcher import Dispatcher
+from pythonosc.osc_server import BlockingOSCUDPServer
+from datetime import datetime
+
+HOST = "127.0.0.1"
+PORT = 9001
+
+def on_any(address, *args):
+ now = datetime.now().strftime("%H:%M:%S")
+ print(f"[INFO] {now} address={address} args={args}", flush=True)
+
+dispatcher = Dispatcher()
+dispatcher.set_default_handler(on_any)
+
+print("[CHECK] OSC受信開始")
+print(f"[INFO] host={HOST}")
+print(f"[INFO] port={PORT}")
+print("[ACTION] VRChatでメニューを押してください")
+
+server = BlockingOSCUDPServer((HOST, PORT), dispatcher)
+server.serve_forever()
diff --git a/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py b/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py
new file mode 100644
index 0000000..5b67fb4
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/tools/osc_test_print_listener.py
@@ -0,0 +1,54 @@
+from pythonosc.dispatcher import Dispatcher
+from pythonosc.osc_server import BlockingOSCUDPServer
+from datetime import datetime
+
+HOST = "127.0.0.1"
+PORT = 9001
+
+def log(msg):
+ print(msg, flush=True)
+
+def on_test(address, *args):
+ now = datetime.now().strftime("%H:%M:%S")
+
+ log("")
+ log(f"[INFO] time={now}")
+ log(f"[ACTION] OSC parameter received")
+ log(f"[INFO] address={address}")
+ log(f"[INFO] args={args}")
+
+dispatcher = Dispatcher()
+
+dispatcher.map(
+ "/avatar/parameters/TestPrint",
+ on_test
+)
+
+log("[CHECK] STEP=1 OSC待受開始")
+log(f"[INFO] host={HOST}")
+log(f"[INFO] port={PORT}")
+
+try:
+ server = BlockingOSCUDPServer(
+ (HOST, PORT),
+ dispatcher
+ )
+
+ log("[INFO] OSC server started")
+ log("[ACTION] VRChatでTestPrintを押してください")
+
+ server.serve_forever()
+
+except KeyboardInterrupt:
+ log("")
+ log("[INFO] Ctrl+C detected")
+ log("[INFO] shutdown")
+
+except Exception as e:
+ log("")
+ log("[FATAL] OSC server error")
+ log(f"[ERROR] detail={e}")
+
+ print("")
+ input("Press Enter to exit...")
+ raise SystemExit(1)
diff --git a/VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py b/VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py
new file mode 100644
index 0000000..9841885
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/tools/resolve_vrchat_user.py
@@ -0,0 +1,134 @@
+import re
+from http.cookiejar import MozillaCookieJar
+
+import vrchatapi
+from vrchatapi.api import authentication_api
+from vrchatapi.api import users_api
+from vrchatapi.exceptions import UnauthorizedException
+from vrchatapi.models.two_factor_auth_code import TwoFactorAuthCode
+from vrchatapi.models.two_factor_email_code import TwoFactorEmailCode
+
+from common.config_loader import getSecretValue
+
+COOKIE_FILE = ".vrchat_cookie.txt"
+
+USER_AGENT = (
+ "VRC_OSC/1.0 "
+ "(contact: test@gmail.com)"
+)
+
+
+def get_required_secret(section, key):
+ value = getSecretValue(section, key)
+ if not value:
+ raise RuntimeError(f"{section}.{key} is not set in config/secrets.toml")
+ return value
+
+
+def setup_cookie_jar(api_client):
+ cookie_jar = MozillaCookieJar(COOKIE_FILE)
+
+ if os.path.exists(COOKIE_FILE):
+ try:
+ cookie_jar.load(
+ ignore_discard=True,
+ ignore_expires=True,
+ )
+ except Exception:
+ pass
+
+ api_client.rest_client.cookie_jar = cookie_jar
+ return cookie_jar
+
+
+def save_cookie_jar(cookie_jar):
+ try:
+ cookie_jar.save(
+ ignore_discard=True,
+ ignore_expires=True,
+ )
+ except Exception:
+ pass
+
+
+def login(api_client, cookie_jar):
+ auth_api = authentication_api.AuthenticationApi(api_client)
+
+ try:
+ current_user = auth_api.get_current_user()
+ save_cookie_jar(cookie_jar)
+ return current_user
+
+ except UnauthorizedException as e:
+ reason = str(e.reason)
+
+ if "Email 2 Factor Authentication" in reason:
+ code = input("Email 2FA Code: ").strip()
+
+ auth_api.verify2_fa_email_code(
+ TwoFactorEmailCode(code)
+ )
+
+ elif "2 Factor Authentication" in reason:
+ code = input("TOTP Code: ").strip()
+
+ auth_api.verify2_fa(
+ TwoFactorAuthCode(code)
+ )
+
+ else:
+ raise
+
+ current_user = auth_api.get_current_user()
+
+ save_cookie_jar(cookie_jar)
+
+ return current_user
+
+
+def extract_user_id(text):
+ match = re.search(
+ r"(usr_[0-9a-fA-F\-]+)",
+ text,
+ )
+
+ if not match:
+ return None
+
+ return match.group(1)
+
+
+def main():
+ text = input("参加者名: ").strip()
+
+ user_id = extract_user_id(text)
+
+ if not user_id:
+ print(text)
+ return
+
+ configuration = vrchatapi.Configuration(
+ username=get_required_secret("vrchat", "username"),
+ password=get_required_secret("vrchat", "password"),
+ )
+
+ with vrchatapi.ApiClient(configuration) as api_client:
+ api_client.user_agent = USER_AGENT
+
+ api_client.default_headers["User-Agent"] = USER_AGENT
+
+ cookie_jar = setup_cookie_jar(api_client)
+
+ login(api_client, cookie_jar)
+
+ users = users_api.UsersApi(api_client)
+
+ user = users.get_user(user_id)
+
+ print(user.display_name)
+
+ save_cookie_jar(cookie_jar)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py b/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py
new file mode 100644
index 0000000..6df4117
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/translate/translate_actions.py
@@ -0,0 +1,102 @@
+from datetime import datetime
+import re
+
+import pyperclip
+from deep_translator import GoogleTranslator
+
+from common.runtime_log import appendRuntimeLog
+
+IGNORE_EXACT = {
+ "戻る",
+ "翻訳",
+ "OCR",
+ "VRCLOG",
+ "Discord_Mute",
+ "Screenshot",
+ "OCR_Toggle",
+ "DCR_Toggle",
+ "HI",
+}
+
+
+def log(level, message):
+ now = datetime.now().strftime("%H:%M:%S")
+ print(f"[{level}] {now} {message}", flush=True)
+
+
+def saveTranslationText(stem, text):
+ return appendRuntimeLog(
+ "TRANSLATION TEXT",
+ f"source={stem}\n{text}",
+ )
+
+
+def looksLikeNoise(line):
+ value = line.strip()
+ if not value:
+ return True
+ if value in IGNORE_EXACT:
+ return True
+ if len(value) <= 2:
+ return True
+ if "_" in value:
+ return True
+ if re.fullmatch(r"[A-Za-z0-9\-_/]+", value):
+ return True
+ return False
+
+
+def normalizeSourceText(text):
+ lines = []
+
+ for raw_line in text.splitlines():
+ line = raw_line.strip()
+ if looksLikeNoise(line):
+ continue
+ lines.append(line)
+
+ unique_lines = []
+ seen = set()
+ for line in lines:
+ if line not in seen:
+ unique_lines.append(line)
+ seen.add(line)
+
+ return "\n".join(unique_lines).strip()
+
+
+def translateTextToJapanese(text):
+ source = normalizeSourceText(text)
+ if not source:
+ log("INFO", "翻訳対象が空です")
+ return ""
+
+ log("CHECK", "Translation source")
+ for line in source.splitlines():
+ print(line, flush=True)
+
+ try:
+ translated = GoogleTranslator(
+ source="auto",
+ target="ja",
+ ).translate(source)
+ except Exception as e:
+ log("ERROR", f"translation failed detail={e}")
+ return ""
+
+ translated = translated.strip()
+ if not translated:
+ log("INFO", "翻訳結果は空です")
+ return ""
+
+ try:
+ pyperclip.copy(translated)
+ log("INFO", "翻訳結果をクリップボードへコピーしました")
+ except Exception as e:
+ log("ERROR", f"clipboard copy failed detail={e}")
+
+ log("CHECK", "Translation result")
+ for line in translated.splitlines():
+ print(line, flush=True)
+
+ return translated
diff --git a/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py b/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py
new file mode 100644
index 0000000..e533599
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/vrc_log/log_actions.py
@@ -0,0 +1,563 @@
+import os
+import re
+import subprocess
+import tempfile
+import threading
+import time
+from datetime import datetime
+from datetime import timedelta
+from pathlib import Path
+
+from common.config_loader import loadVrcLogConfig
+from common.config_loader import getSecretValue
+from common.runtime_log import appendRuntimeLog
+from discord_control.actions import setDiscordMuted
+
+_monitor_thread = None
+_monitor_stop_event = threading.Event()
+_last_sent_output = None
+_self_monitor_thread = None
+_self_monitor_stop_event = threading.Event()
+_last_self_state = None
+REALTIME_IDLE_SECONDS = 0.2
+
+JOIN_PATTERN = re.compile(
+ r"OnPlayerJoined\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)"
+)
+LEFT_PATTERN = re.compile(
+ r"OnPlayerLeft\s+(.+?)\s+\(usr_[0-9a-fA-F-]+\)"
+)
+WORLD_PATTERN = re.compile(
+ r"(wrld_[0-9a-fA-F-]+(?::[^\s\]\)\"']+)?)"
+)
+ENTERING_ROOM_PATTERN = re.compile(
+ r"\[Behaviour\]\s+(?:Entering Room|Joining or Creating Room):\s+(.+)$"
+)
+WORLD_NAME_PATTERN = re.compile(
+ r"worldId=(wrld_[0-9a-fA-F-]+)(?::[^,}]*)?,\s*worldName=([^,}]+)"
+)
+TIME_PATTERN = re.compile(
+ r"(\d{4}\.\d{2}\.\d{2}\s+\d{2}:\d{2}:\d{2})"
+)
+RELEVANT_LOG_PATTERN = re.compile(
+ r"OnPlayerJoined|OnPlayerLeft|Entering Room|Joining or Creating Room|worldId=|wrld_"
+)
+
+
+def log(level, message):
+ now = datetime.now().strftime("%H:%M:%S")
+ print(f"[{level}] {now} {message}", flush=True)
+
+
+def getWebhookUrl():
+ return getSecretValue("discord", "webhook_url")
+
+
+def sendWebhook(text):
+ webhook_url = getWebhookUrl().strip()
+ if not webhook_url:
+ log("ERROR", "Discord webhook URL is not set config=config/secrets.toml")
+ return False
+
+ message = str(text).strip()
+ if not message:
+ log("INFO", "Webhook送信内容が空です")
+ return False
+
+ if len(message) > 1800:
+ message = message[-1800:]
+
+ ps_script = r'''
+param(
+ [string]$WebhookUrl,
+ [string]$Message
+)
+
+[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
+$Payload = @{ content = $Message } | ConvertTo-Json -Compress
+$Body = [System.Text.Encoding]::UTF8.GetBytes($Payload)
+Invoke-WebRequest -Uri $WebhookUrl -Method Post -ContentType 'application/json; charset=utf-8' -Body $Body -UseBasicParsing | Out-Null
+'''
+
+ ps_path = None
+
+ try:
+ with tempfile.NamedTemporaryFile(
+ mode="w",
+ suffix=".ps1",
+ delete=False,
+ encoding="utf-8-sig",
+ ) as tmp:
+ tmp.write(ps_script)
+ ps_path = tmp.name
+
+ result = subprocess.run(
+ [
+ "powershell",
+ "-NoProfile",
+ "-ExecutionPolicy",
+ "Bypass",
+ "-File",
+ ps_path,
+ webhook_url,
+ message,
+ ],
+ capture_output=True,
+ text=True,
+ timeout=20,
+ )
+ except Exception as e:
+ log("ERROR", f"Discord webhook PowerShell failed detail={e}")
+ return False
+ finally:
+ if ps_path:
+ try:
+ Path(ps_path).unlink(missing_ok=True)
+ except Exception:
+ pass
+
+ if result.returncode != 0:
+ stderr = (result.stderr or "").strip()
+ stdout = (result.stdout or "").strip()
+ log("ERROR", f"Discord webhook PowerShell failed code={result.returncode} stderr={stderr} stdout={stdout}")
+ return False
+
+ log("INFO", "Discord webhook sent via PowerShell")
+ return True
+
+
+def getVrchatLogDir():
+ userprofile = os.environ.get("USERPROFILE")
+ if not userprofile:
+ raise RuntimeError("USERPROFILE が取得できません")
+ return Path(userprofile) / "AppData" / "LocalLow" / "VRChat" / "VRChat"
+
+
+def findLatestVrchatLog():
+ log_dir = getVrchatLogDir()
+ if not log_dir.exists():
+ raise RuntimeError(f"VRChatログディレクトリが存在しません: {log_dir}")
+
+ candidates = []
+ for pattern in ("output_log_*.txt", "Player.log", "*.log", "*.txt"):
+ candidates.extend(log_dir.glob(pattern))
+
+ candidates = [path for path in candidates if path.is_file()]
+ if not candidates:
+ raise RuntimeError(f"VRChatログファイルが見つかりません: {log_dir}")
+
+ candidates.sort(key=lambda path: path.stat().st_mtime, reverse=True)
+ return candidates[0]
+
+
+def readTextSafe(path):
+ for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
+ try:
+ return path.read_text(encoding=encoding, errors="replace")
+ except Exception:
+ continue
+
+ raise RuntimeError(f"ログファイルを読めません: {path}")
+
+
+def parseLineTime(line):
+ matched = TIME_PATTERN.search(line)
+ if not matched:
+ return None
+
+ return datetime.strptime(matched.group(1), "%Y.%m.%d %H:%M:%S")
+
+
+def formatClock(dt):
+ return dt.strftime("%H:%M")
+
+
+def getLogStartTime(now):
+ noon_today = now.replace(hour=12, minute=0, second=0, microsecond=0)
+ if now >= noon_today:
+ return noon_today
+ return noon_today - timedelta(days=1)
+
+
+def splitWorldLocation(location):
+ if ":" not in location:
+ return location, ""
+
+ world_id, instance_id = location.split(":", 1)
+ return world_id, instance_id
+
+
+def extractWorldNameMap(text):
+ world_name_map = {}
+ pending_world_name = ""
+
+ for raw_line in text.splitlines():
+ room_match = ENTERING_ROOM_PATTERN.search(raw_line)
+ if room_match:
+ pending_world_name = room_match.group(1).strip()
+
+ match = WORLD_NAME_PATTERN.search(raw_line)
+ if match:
+ world_id = match.group(1).strip()
+ world_name = match.group(2).strip()
+ if world_id and world_name:
+ world_name_map[world_id] = world_name
+ continue
+
+ world_match = WORLD_PATTERN.search(raw_line)
+ if world_match and pending_world_name:
+ location = world_match.group(1).strip()
+ world_id, _ = splitWorldLocation(location)
+ if world_id:
+ world_name_map[world_id] = pending_world_name
+ pending_world_name = ""
+
+ return world_name_map
+
+
+def formatWorldLabel(world_id, world_name_map):
+ world_name = world_name_map.get(world_id, "").strip()
+ if not world_name:
+ return "不明"
+ return world_name
+
+
+def classifyUser(name, guest_names, staff_names):
+ if name in staff_names:
+ return "staff"
+ if name in guest_names:
+ return "guest"
+ return "other"
+
+
+def isSelf(name, self_name):
+ return bool(self_name) and name.strip() == self_name.strip()
+
+
+def buildNoticeLines(guest_names, guest_present_set, missing_count):
+ total_guest = len(guest_names)
+ current_guest = len(guest_present_set)
+ threshold = total_guest - missing_count
+
+ if missing_count <= 0:
+ return []
+ if not (threshold <= current_guest < total_guest):
+ return []
+
+ missing_guests = [name for name in guest_names if name not in guest_present_set]
+ if not missing_guests:
+ return []
+
+ lines = [f"現在Guest: {current_guest}/{total_guest}", "未入室Guest:"]
+ lines.extend(f"- {name}" for name in missing_guests)
+ return lines
+
+
+def appendNoticeIfNeeded(output_lines, state):
+ notice_lines = buildNoticeLines(
+ state["guest_names"],
+ state["guest_present_set"],
+ state["missing_count"],
+ )
+ notice_key = tuple(notice_lines)
+
+ if not notice_lines:
+ state["last_notice_key"] = None
+ return
+
+ if state["last_notice_key"] == notice_key:
+ return
+
+ output_lines.extend(notice_lines)
+ state["last_notice_key"] = notice_key
+
+
+def parseVrchatEvents(text, config):
+ start_time = getLogStartTime(datetime.now())
+ guest_names = config["guest_names"]
+ staff_names = set(config["staff_names"])
+ self_name = config.get("self_name", "")
+ world_name_map = extractWorldNameMap(text)
+
+ state = {
+ "location": "",
+ "world_id": "",
+ "instance_id": "",
+ "guest_names": guest_names,
+ "guest_name_set": set(guest_names),
+ "staff_names": staff_names,
+ "guest_present_set": set(),
+ "staff_present_set": set(),
+ "member_present_set": set(),
+ "missing_count": config["missing_count"],
+ "last_notice_key": None,
+ }
+ output_lines = []
+ self_state = None
+
+ for raw_line in text.splitlines():
+ line_time = parseLineTime(raw_line)
+ if not line_time or line_time < start_time:
+ continue
+
+ world_match = WORLD_PATTERN.search(raw_line)
+ if world_match:
+ location = world_match.group(1).strip()
+ world_id, instance_id = splitWorldLocation(location)
+
+ if location != state["location"]:
+ state["location"] = location
+ state["world_id"] = world_id
+ state["instance_id"] = instance_id
+ state["guest_present_set"].clear()
+ state["staff_present_set"].clear()
+ state["member_present_set"].clear()
+ state["last_notice_key"] = None
+ output_lines = []
+ output_lines.append(f"ワールド入室: {formatWorldLabel(world_id, world_name_map)}")
+ output_lines.append(f"現在Guest: 0/{len(guest_names)}")
+
+ join_match = JOIN_PATTERN.search(raw_line)
+ if join_match and state["world_id"]:
+ name = join_match.group(1).strip()
+ if isSelf(name, self_name):
+ self_state = "joined"
+ state["member_present_set"].add(name)
+ output_lines.append(
+ f'[{formatClock(line_time)}] [join] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}'
+ )
+ continue
+
+ role = classifyUser(name, state["guest_name_set"], state["staff_names"])
+
+ if role == "guest":
+ state["guest_present_set"].add(name)
+ state["member_present_set"].add(name)
+ output_lines.append(
+ f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}'
+ )
+ appendNoticeIfNeeded(output_lines, state)
+ elif role == "staff":
+ state["staff_present_set"].add(name)
+ output_lines.append(
+ f'[{formatClock(line_time)}] [join] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}'
+ )
+ else:
+ state["member_present_set"].add(name)
+ output_lines.append(
+ f'[{formatClock(line_time)}] [join] {name} {len(state["member_present_set"])}/{len(guest_names)}'
+ )
+ continue
+
+ left_match = LEFT_PATTERN.search(raw_line)
+ if left_match and state["world_id"]:
+ name = left_match.group(1).strip()
+ if isSelf(name, self_name):
+ self_state = "left"
+ state["member_present_set"].discard(name)
+ output_lines.append(
+ f'[{formatClock(line_time)}] [leave] [self] {name} {len(state["member_present_set"])}/{len(guest_names)}'
+ )
+ continue
+
+ role = classifyUser(name, state["guest_name_set"], state["staff_names"])
+
+ if role == "guest":
+ state["guest_present_set"].discard(name)
+ state["member_present_set"].discard(name)
+ output_lines.append(
+ f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}'
+ )
+ appendNoticeIfNeeded(output_lines, state)
+ elif role == "staff":
+ state["staff_present_set"].discard(name)
+ output_lines.append(
+ f'[{formatClock(line_time)}] [leave] [staff] {name} {len(state["member_present_set"])}/{len(guest_names)}'
+ )
+ else:
+ state["member_present_set"].discard(name)
+ output_lines.append(
+ f'[{formatClock(line_time)}] [leave] {name} {len(state["member_present_set"])}/{len(guest_names)}'
+ )
+
+ return output_lines, self_state
+
+
+def extractLatestSelfLogState(text, self_name):
+ if not self_name:
+ return None
+
+ latest_state = None
+
+ for raw_line in text.splitlines():
+ join_match = JOIN_PATTERN.search(raw_line)
+ if join_match and isSelf(join_match.group(1).strip(), self_name):
+ latest_state = "joined"
+ continue
+
+ left_match = LEFT_PATTERN.search(raw_line)
+ if left_match and isSelf(left_match.group(1).strip(), self_name):
+ latest_state = "left"
+
+ return latest_state
+
+
+def grepVrchatLog(pattern=None):
+ return collectVrchatLog(pattern=pattern, notify_changed=False)
+
+
+def collectVrchatLog(pattern=None, notify_changed=True):
+ config = loadVrcLogConfig()
+ latest_log = findLatestVrchatLog()
+ log("ACTION", f"parseVrchatLog file={latest_log}")
+ log("INFO", f'event_file={config["event_file"]}')
+
+ text = readTextSafe(latest_log)
+ lines, self_state = parseVrchatEvents(text, config)
+
+ output_text = "\n".join(lines) if lines else "[INFO] 対象イベントなし"
+ output_path = appendRuntimeLog("VRC LOG", output_text)
+
+ if notify_changed:
+ global _last_sent_output
+ if output_text != _last_sent_output:
+ try:
+ ok = sendWebhook(output_text)
+ if ok:
+ log("INFO", "Discord webhook sent")
+ _last_sent_output = output_text
+ else:
+ log("ERROR", "Discord webhook send failed")
+ except Exception as e:
+ log("ERROR", f"Discord webhook failed detail={e}")
+
+ log("INFO", f"result appended={output_path}")
+ for line in lines or ["[INFO] 対象イベントなし"]:
+ print(line, flush=True)
+
+ return output_path
+
+
+def _readNewText(path, offset):
+ for encoding in ("utf-8", "utf-8-sig", "cp932", "shift_jis"):
+ try:
+ with path.open("r", encoding=encoding, errors="replace") as file:
+ file.seek(offset)
+ return file.read(), file.tell()
+ except Exception:
+ continue
+
+ raise RuntimeError(f"ログファイルを読めません: {path}")
+
+
+def _watchVrchatLogChanges(stop_event, label, on_relevant_change):
+ log("ACTION", f"{label} realtime watcher started")
+
+ current_log = None
+ offset = 0
+
+ while not stop_event.is_set():
+ try:
+ latest_log = findLatestVrchatLog()
+ latest_size = latest_log.stat().st_size
+
+ if current_log is None:
+ current_log = latest_log
+ offset = latest_size
+ log("INFO", f"{label} watching file={current_log}")
+ continue
+
+ if latest_log != current_log:
+ current_log = latest_log
+ offset = 0
+ log("INFO", f"{label} watching new file={current_log}")
+
+ if latest_size < offset:
+ offset = 0
+
+ if latest_size > offset:
+ text, offset = _readNewText(latest_log, offset)
+
+ if RELEVANT_LOG_PATTERN.search(text):
+ on_relevant_change(text)
+ except Exception as e:
+ log("ERROR", f"{label} realtime watcher failed detail={e}")
+
+ stop_event.wait(REALTIME_IDLE_SECONDS)
+
+ log("INFO", f"{label} realtime watcher stopped")
+
+
+def _monitor_loop():
+ def on_relevant_change(_text):
+ collectVrchatLog(notify_changed=True)
+
+ _watchVrchatLogChanges(
+ _monitor_stop_event,
+ "VRC log monitor",
+ on_relevant_change,
+ )
+
+
+def _self_monitor_loop():
+ def on_relevant_change(text):
+ try:
+ config = loadVrcLogConfig()
+ self_state = extractLatestSelfLogState(
+ text,
+ config.get("self_name", ""),
+ )
+
+ global _last_self_state
+ if self_state and self_state != _last_self_state:
+ if self_state == "joined":
+ setDiscordMuted(True)
+ elif self_state == "left":
+ setDiscordMuted(False)
+ _last_self_state = self_state
+ except Exception as e:
+ log("ERROR", f"VRC self monitor failed detail={e}")
+
+ _watchVrchatLogChanges(
+ _self_monitor_stop_event,
+ "VRC self monitor",
+ on_relevant_change,
+ )
+
+
+def startVrcLogMonitor(_interval_seconds=None):
+ global _monitor_thread
+
+ if _monitor_thread and _monitor_thread.is_alive():
+ log("INFO", "VRC log monitor already running")
+ return
+
+ _monitor_stop_event.clear()
+ _monitor_thread = threading.Thread(
+ target=_monitor_loop,
+ daemon=True,
+ )
+ _monitor_thread.start()
+
+
+def startSelfMonitor(_interval_seconds=None):
+ global _self_monitor_thread
+
+ if _self_monitor_thread and _self_monitor_thread.is_alive():
+ log("INFO", "VRC self monitor already running")
+ return
+
+ _self_monitor_stop_event.clear()
+ _self_monitor_thread = threading.Thread(
+ target=_self_monitor_loop,
+ daemon=True,
+ )
+ _self_monitor_thread.start()
+
+
+def stopVrcLogMonitor():
+ _monitor_stop_event.set()
+
+
+def stopSelfMonitor():
+ _self_monitor_stop_event.set()
diff --git a/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py b/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py
new file mode 100644
index 0000000..efe2a34
--- /dev/null
+++ b/VRWT_Tool/VRC_OSC/src/vrc_log/member_name_matcher.py
@@ -0,0 +1,623 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+"""
+目的:
+VRChat表示名とTOML登録名を安全な順番で紐づける
+
+特徴:
+- 完全一致を先に確定して候補から除外
+- 正規化一致を確定して候補から除外
+- 残った候補だけを high confidence fuzzy 判定
+- guest のみ low confidence fuzzy で再選別
+- pending / ambiguous / unmatched 判定
+- 記号除去
+- 全角半角統一
+- 小文字化
+- 日本語 -> ローマ字変換
+- rapidfuzz による類似度
+- テストケース付き
+
+前提:
+pip install rapidfuzz pykakasi
+
+期待結果:
+- 100%一致した名前を fuzzy 候補に残さない
+- 似た名前同士の誤リンクを減らす
+"""
+
+import re
+import sys
+import unicodedata
+from dataclasses import dataclass, field
+from typing import Dict, Iterable, List, Optional, Tuple
+
+try:
+ from rapidfuzz import fuzz
+except Exception as e:
+ if __name__ == "__main__":
+ print(f"[FATAL] rapidfuzz import failed: {e}")
+ input("Press Enter to exit...")
+ sys.exit(1)
+ raise
+
+try:
+ from pykakasi import kakasi
+except Exception as e:
+ if __name__ == "__main__":
+ print(f"[FATAL] pykakasi import failed: {e}")
+ input("Press Enter to exit...")
+ sys.exit(1)
+ raise
+
+
+# =========================
+# 設定
+# =========================
+
+AUTO_LINK_SCORE = 95
+LOW_CONFIDENCE_SCORE = 80
+AMBIGUOUS_DIFF = 10
+LOW_CONFIDENCE_MAX_REMAINING = 3
+WEAK_MATCH_SCORE = 50
+
+STATUS_MATCHED = "matched"
+STATUS_MATCHED_LOW_CONFIDENCE = "matched_low_confidence"
+STATUS_PENDING = "pending"
+STATUS_AMBIGUOUS = "ambiguous"
+STATUS_UNMATCHED = "unmatched"
+STATUS_UNKNOWN_MEMBER = "unknown_member"
+
+ROLE_GUEST = "guest"
+ROLE_SELF = "self"
+ROLE_STAFF = "staff"
+
+TEST_GUESTS = [
+ "okada",
+ "tanaka",
+ "yamada",
+ "messykenty",
+]
+
+TEST_CURRENT_USERS = [
+ "岡田",
+ "ikada",
+ "okada_",
+ "OKADA",
+ "tanaka!!!",
+ "田中",
+ "やまだ",
+ "messy_kenty",
+ "messy.kenty",
+ "MESSYKENTY",
+ "山田",
+]
+
+
+@dataclass(frozen=True)
+class TomlMember:
+ name: str
+ role: str = ROLE_GUEST
+
+
+@dataclass
+class MatchResult:
+ toml_name: str
+ role: str
+ status: str
+ vrchat_name: Optional[str] = None
+ score: Optional[float] = None
+ second_score: Optional[float] = None
+ reason: str = ""
+ candidates: List[Tuple[str, float]] = field(default_factory=list)
+
+
+# =========================
+# ローマ字変換
+# =========================
+
+kks = kakasi()
+
+
+def jp_to_romaji(text: str) -> str:
+ try:
+ converted = kks.convert(text)
+
+ result = []
+ for item in converted:
+ result.append(item["hepburn"])
+
+ return "".join(result)
+
+ except Exception as e:
+ print(f"[ERROR] romaji convert failed: {text} : {e}")
+ return text
+
+
+# =========================
+# 正規化
+# =========================
+
+def normalize_name(name: str) -> str:
+ """
+ 正規化内容:
+ - 全角半角統一
+ - 小文字化
+ - 日本語→ローマ字
+ - 記号除去
+ """
+
+ # Unicode normalize
+ name = unicodedata.normalize("NFKC", name)
+
+ # 小文字化
+ name = name.lower()
+
+ # 日本語→ローマ字
+ name = jp_to_romaji(name)
+
+ # 記号除去
+ name = re.sub(r"[^a-z0-9]", "", name)
+
+ return name.strip()
+
+
+# =========================
+# 類似候補検索
+# =========================
+
+def find_candidates(
+ target_guest: str,
+ current_users: List[str],
+) -> List[Tuple[str, float]]:
+
+ normalized_target = normalize_name(target_guest)
+
+ normalized_map = {}
+
+ for user in current_users:
+ normalized_map[user] = normalize_name(user)
+
+ results = []
+
+ for original_name, normalized_name in normalized_map.items():
+
+ score = fuzz.ratio(
+ normalized_target,
+ normalized_name,
+ )
+
+ results.append((original_name, score))
+
+ results.sort(key=lambda x: x[1], reverse=True)
+
+ return results
+
+
+def _to_toml_member(member) -> TomlMember:
+ if isinstance(member, TomlMember):
+ return member
+
+ if isinstance(member, str):
+ return TomlMember(name=member)
+
+ if isinstance(member, dict):
+ return TomlMember(
+ name=str(member.get("name", "")),
+ role=str(member.get("role", ROLE_GUEST)),
+ )
+
+ name = getattr(member, "name", "")
+ role = getattr(member, "role", ROLE_GUEST)
+
+ return TomlMember(
+ name=str(name),
+ role=str(role),
+ )
+
+
+def _make_candidates(
+ toml_name: str,
+ current_users: Iterable[str],
+) -> List[Tuple[str, float]]:
+ normalized_toml = normalize_name(toml_name)
+ results = []
+
+ for user in current_users:
+ score = fuzz.ratio(
+ normalized_toml,
+ normalize_name(user),
+ )
+ results.append((user, score))
+
+ results.sort(key=lambda x: x[1], reverse=True)
+
+ return results
+
+
+def _classify_remaining(
+ member: TomlMember,
+ current_users: Iterable[str],
+) -> MatchResult:
+ candidates = _make_candidates(
+ member.name,
+ current_users,
+ )
+
+ if not candidates:
+ return MatchResult(
+ toml_name=member.name,
+ role=member.role,
+ status=STATUS_UNMATCHED,
+ reason="no_candidate",
+ )
+
+ top_name, top_score = candidates[0]
+ second_score = candidates[1][1] if len(candidates) >= 2 else 0
+ diff = top_score - second_score
+
+ if top_score < WEAK_MATCH_SCORE:
+ status = STATUS_UNMATCHED
+ reason = "weak_candidate"
+ elif diff < AMBIGUOUS_DIFF:
+ status = STATUS_AMBIGUOUS
+ reason = "close_candidates"
+ else:
+ status = STATUS_PENDING
+ reason = "below_auto_link_threshold"
+
+ return MatchResult(
+ toml_name=member.name,
+ role=member.role,
+ status=status,
+ vrchat_name=top_name,
+ score=top_score,
+ second_score=second_score,
+ reason=reason,
+ candidates=candidates,
+ )
+
+
+def _best_fuzzy_match(
+ member: TomlMember,
+ current_users: Iterable[str],
+ min_score: float,
+) -> Optional[MatchResult]:
+ candidates = _make_candidates(
+ member.name,
+ current_users,
+ )
+
+ if not candidates:
+ return None
+
+ top_name, top_score = candidates[0]
+ second_score = candidates[1][1] if len(candidates) >= 2 else 0
+
+ if (
+ top_score >= min_score
+ and top_score - second_score >= AMBIGUOUS_DIFF
+ ):
+ return MatchResult(
+ toml_name=member.name,
+ role=member.role,
+ status=STATUS_MATCHED,
+ vrchat_name=top_name,
+ score=top_score,
+ second_score=second_score,
+ reason="fuzzy",
+ candidates=candidates,
+ )
+
+ return None
+
+
+def _apply_fuzzy_round(
+ remaining_members: Dict[str, TomlMember],
+ remaining_users: set,
+ min_score: float,
+ status: str,
+ reason: str,
+ eligible_roles: Optional[set] = None,
+) -> List[MatchResult]:
+ results = []
+
+ while True:
+ possible_matches = []
+
+ for key, member in remaining_members.items():
+ if (
+ eligible_roles is not None
+ and member.role not in eligible_roles
+ ):
+ continue
+
+ match = _best_fuzzy_match(
+ member,
+ remaining_users,
+ min_score,
+ )
+
+ if match is None:
+ continue
+
+ possible_matches.append((key, match))
+
+ if not possible_matches:
+ break
+
+ possible_matches.sort(
+ key=lambda x: (
+ x[1].score if x[1].score is not None else 0,
+ (x[1].score or 0) - (x[1].second_score or 0),
+ ),
+ reverse=True,
+ )
+
+ key, match = possible_matches[0]
+ match.status = status
+ match.reason = reason
+
+ results.append(match)
+ remaining_members.pop(key)
+ remaining_users.remove(match.vrchat_name)
+
+ return results
+
+
+def match_username_sets(
+ toml_members,
+ current_users: List[str],
+) -> Tuple[List[MatchResult], List[str]]:
+ """
+ TOML登録名とVRChat現在ユーザーを段階的に1対1照合する。
+
+ 戻り値:
+ - TOML側の照合結果
+ - TOMLに紐づかなかったVRChat名
+ """
+
+ members = [_to_toml_member(member) for member in toml_members]
+ remaining_members = {
+ f"{idx}:{member.name}": member
+ for idx, member in enumerate(members)
+ }
+ remaining_users = set(current_users)
+ results = []
+
+ # 1. 完全一致: fuzzy判定せず即確定して候補から除外
+ for key, member in list(remaining_members.items()):
+ if member.name in remaining_users:
+ results.append(
+ MatchResult(
+ toml_name=member.name,
+ role=member.role,
+ status=STATUS_MATCHED,
+ vrchat_name=member.name,
+ score=100,
+ second_score=None,
+ reason="exact",
+ )
+ )
+ remaining_members.pop(key)
+ remaining_users.remove(member.name)
+
+ # 2. 正規化一致: 一意に対応できるものだけを確定して除外
+ normalized_members: Dict[str, List[str]] = {}
+ normalized_users: Dict[str, List[str]] = {}
+
+ for key, member in remaining_members.items():
+ normalized_members.setdefault(
+ normalize_name(member.name),
+ [],
+ ).append(key)
+
+ for user in remaining_users:
+ normalized_users.setdefault(
+ normalize_name(user),
+ [],
+ ).append(user)
+
+ for normalized_name, member_keys in normalized_members.items():
+ users = normalized_users.get(normalized_name, [])
+
+ if len(member_keys) != 1 or len(users) != 1:
+ continue
+
+ key = member_keys[0]
+ user = users[0]
+ member = remaining_members[key]
+
+ results.append(
+ MatchResult(
+ toml_name=member.name,
+ role=member.role,
+ status=STATUS_MATCHED,
+ vrchat_name=user,
+ score=100,
+ second_score=None,
+ reason="normalized",
+ )
+ )
+ remaining_members.pop(key)
+ remaining_users.remove(user)
+
+ # 3. 高信頼fuzzy: 残った候補だけで、確定ごとに候補集合を縮める
+ results.extend(
+ _apply_fuzzy_round(
+ remaining_members,
+ remaining_users,
+ AUTO_LINK_SCORE,
+ STATUS_MATCHED,
+ "high_confidence_fuzzy",
+ )
+ )
+
+ # 4. 低信頼guest再選別: guestのみ、候補集合が少ない場合だけ
+ if len(remaining_members) <= LOW_CONFIDENCE_MAX_REMAINING:
+ results.extend(
+ _apply_fuzzy_round(
+ remaining_members,
+ remaining_users,
+ LOW_CONFIDENCE_SCORE,
+ STATUS_MATCHED_LOW_CONFIDENCE,
+ "guest_low_confidence_fuzzy",
+ eligible_roles={ROLE_GUEST},
+ )
+ )
+
+ # 5. 残りは無理に確定しない
+ for member in remaining_members.values():
+ results.append(
+ _classify_remaining(
+ member,
+ remaining_users,
+ )
+ )
+
+ unknown_users = sorted(remaining_users)
+
+ return results, unknown_users
+
+
+# =========================
+# 判定
+# =========================
+
+def evaluate_guest_match(
+ guest_name: str,
+ current_users: List[str],
+):
+
+ print("")
+ print("=" * 60)
+ print(f"[CHECK] guest={guest_name}")
+
+ normalized_guest = normalize_name(guest_name)
+
+ print(f"[INFO] normalized_guest={normalized_guest}")
+
+ candidates = find_candidates(
+ guest_name,
+ current_users,
+ )
+
+ if not candidates:
+ print("[ERROR] no candidates")
+ return
+
+ print("")
+ print("[CHECK] candidate list")
+
+ for idx, (name, score) in enumerate(candidates[:5], start=1):
+ print(f" {idx}. {name:<20} score={score:.2f}")
+
+ top_name, top_score = candidates[0]
+
+ second_score = 0
+
+ if len(candidates) >= 2:
+ second_score = candidates[1][1]
+
+ diff = top_score - second_score
+
+ print("")
+ print(f"[INFO] top_score={top_score:.2f}")
+ print(f"[INFO] second_score={second_score:.2f}")
+ print(f"[INFO] diff={diff:.2f}")
+
+ # 自動確定
+ if (
+ top_score >= AUTO_LINK_SCORE
+ and diff >= AMBIGUOUS_DIFF
+ ):
+ print("")
+ print("[INFO] AUTO LINK SAFE")
+ print(f"[INFO] linked => {top_name}")
+ return
+
+ # 曖昧
+ print("")
+ print("[WARN] ambiguous match")
+ print("[WARN] auto link disabled")
+
+ print("")
+ print("[ACTION] manual review required")
+
+
+# =========================
+# main
+# =========================
+
+def main():
+
+ print("[CHECK] normalize preview")
+
+ for user in TEST_CURRENT_USERS:
+ print(
+ f"{user:<20}"
+ f" => "
+ f"{normalize_name(user)}"
+ )
+
+ print("")
+ print("[CHECK] username sync ordered matching test")
+
+ results, unknown_users = match_username_sets(
+ TEST_GUESTS,
+ TEST_CURRENT_USERS,
+ )
+
+ for result in results:
+ if result.vrchat_name is None:
+ print(
+ f"{result.toml_name:<20}"
+ f" => "
+ f"{result.status:<22}"
+ f" reason={result.reason}"
+ )
+ continue
+
+ score_text = ""
+ if result.score is not None:
+ score_text = f" score={result.score:.2f}"
+
+ second_text = ""
+ if result.second_score is not None:
+ second_text = f" second={result.second_score:.2f}"
+
+ print(
+ f"{result.toml_name:<20}"
+ f" => "
+ f"{result.vrchat_name:<20}"
+ f" {result.status:<22}"
+ f" reason={result.reason}"
+ f"{score_text}"
+ f"{second_text}"
+ )
+
+ for user in unknown_users:
+ print(
+ f"{user:<20}"
+ f" => "
+ f"{STATUS_UNKNOWN_MEMBER}"
+ )
+
+ print("")
+ print("[INFO] completed")
+
+
+if __name__ == "__main__":
+
+ print("[CHECK] start")
+
+ answer = input(
+ "[ACTION] run fuzzy test? (yes/no): "
+ ).strip().lower()
+
+ if answer != "yes":
+ print("[FATAL] cancelled")
+ input("Press Enter to exit...")
+ sys.exit(1)
+
+ main()