13 Commits

Author SHA1 Message Date
every_holiday
8bff7af6bc Add release auto-update launcher
All checks were successful
build-windows-exe / build (push) Successful in 1m47s
2026-06-24 02:08:19 +09:00
every_holiday
760d6988cb Attach Windows builds to Gitea release
All checks were successful
build-windows-exe / build (push) Successful in 1m5s
2026-06-24 02:00:03 +09:00
every_holiday
74ff0d3c76 Remove unsupported artifact upload from Gitea workflow
All checks were successful
build-windows-exe / build (push) Successful in 46s
2026-06-24 01:54:59 +09:00
every_holiday
72aa10de46 Refine Go runtime behavior and log handling
Some checks failed
build-windows-exe / build (push) Failing after 48s
2026-06-24 01:53:24 +09:00
every_holiday
b34c2fafb4 Force Go module mode in Gitea workflow
Some checks failed
build-windows-exe / build (push) Failing after 52s
2026-06-24 01:00:42 +09:00
every_holiday
a4394bfde6 Add Go module file for Gitea build
Some checks failed
build-windows-exe / build (push) Failing after 29s
2026-06-24 00:59:22 +09:00
every_holiday
450316d861 Debug Go module path in Gitea workflow
Some checks failed
build-windows-exe / build (push) Failing after 30s
2026-06-24 00:57:20 +09:00
every_holiday
e3f497def4 Use explicit cd in Gitea workflow
Some checks failed
build-windows-exe / build (push) Failing after 30s
2026-06-24 00:55:26 +09:00
every_holiday
1975bf6e08 Use public clone URL in Gitea workflow
Some checks failed
build-windows-exe / build (push) Failing after 30s
2026-06-24 00:54:24 +09:00
every_holiday
15a24f32ba Use git clone in Gitea workflow
Some checks failed
build-windows-exe / build (push) Failing after 3s
2026-06-24 00:53:24 +09:00
every_holiday
ad81bc6985 Use Ubuntu runner for Windows exe build
Some checks failed
build-windows-exe / build (push) Failing after 2m9s
2026-06-24 00:50:07 +09:00
every_holiday
b189adebe5 Move Gitea workflow to repo root
Some checks failed
build-windows-exe / build (push) Has been cancelled
2026-06-24 00:45:30 +09:00
every_holiday
bd29099425 Add Go consent tool and Gitea build workflow 2026-06-24 00:43:46 +09:00
21 changed files with 1715 additions and 0 deletions

View File

@@ -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"

View File

@@ -0,0 +1,22 @@
package main
import (
"flag"
"log"
"os"
"vrc_osc_go/internal/app"
)
func main() {
var configPath string
var mode string
flag.StringVar(&configPath, "config", "config/config.toml", "path to config.toml")
flag.StringVar(&mode, "mode", "", "generate-json or extract-log")
flag.Parse()
if err := app.Run(configPath, mode); err != nil {
log.SetOutput(os.Stderr)
log.Fatalf("vrc_osc_go: %v", err)
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}
}

4
VRWT_Tool/VRC_OSC/go.mod Normal file
View File

@@ -0,0 +1,4 @@
module vrc_osc_go
go 1.22

View File

@@ -0,0 +1,72 @@
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
}
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}
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
}
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
}
}

View File

@@ -0,0 +1,36 @@
package app
import (
"bytes"
"fmt"
"log"
"os/exec"
)
func pressDiscordMuteHotkey() error {
script := `
$p = Get-Process | Where-Object { $_.MainWindowTitle -match 'Discord' } | Select-Object -First 1
if ($null -eq $p) { exit 2 }
[void][System.Runtime.InteropServices.Marshal]::GetLastWin32Error()
$wshell = New-Object -ComObject WScript.Shell
$null = $wshell.AppActivate($p.Id)
Start-Sleep -Milliseconds 150
$wshell.SendKeys('^+m')
`
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
}
func pressDiscordMuteHotkeyLegacy() error {
var buf bytes.Buffer
_ = buf
return pressDiscordMuteHotkey()
}

View File

@@ -0,0 +1,88 @@
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
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
state.lastAction = now
} else {
log.Printf("INFO already muted")
}
return nil
}
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)
}
}

View File

@@ -0,0 +1,28 @@
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))
}
if err != nil {
return fmt.Errorf("ocr failed: %w", err)
}
return nil
}

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

@@ -0,0 +1,6 @@
package buildinfo
var (
Version = "dev"
BuildTime = "unknown"
)

View File

@@ -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() }

View File

@@ -0,0 +1,118 @@
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
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":
if key == "names" {
cfg.VrcLog.GuestNames = parseList(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
}
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
}

View File

@@ -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))
}

View File

@@ -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 ""
}

View File

@@ -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))
}
}

View File

@@ -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
}

View File

@@ -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"
}

View File

@@ -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}
}