feat(cafegame): 自 go-hua 迁入网咖游戏识别与运行记录框架

- 游戏来源常量/按目录特征识别(云更新/网维大师/易乐游)/RunAppRecord 上报框架
- 网吧业务专用包, 附测试、examples/cafegame 例程与包 README
This commit is contained in:
2026-09-20 12:37:10 +08:00
parent a6ba78c92c
commit 63d0a0caf8
10 changed files with 1843 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
# cafegame
网咖游戏业务包:游戏来源常量(云更新/网维大师/易乐游)、按目录特征识别游戏
(ID/名称/来源)、前台进程监控与运行记录上报回调框架。
> 迁移自 go-hua/cafegame,代码保持原样。**网吧业务专用包**(目录特征与各游戏
> 平台的落盘规则强耦合)。
## 用法
```go
import "git.zeroonesoft.cn/golib/zogo/cafegame"
// 游戏来源常量
cafegame.SourceYGX // 1 云更新
cafegame.SourceWWDS // 2 网维大师
cafegame.SourceYLY // 3 易乐游
cafegame.SourceToName(cafegame.SourceYGX)
// 按目录特征识别游戏(各平台一个判定函数)
ok, info, err := cafegame.IsGameDirByYGX(`D:\40001示例游戏`)
// info: AppData{AppId, AppName, AppSource, AppGuid...}
// 运行记录上报框架:启动后台监控,游戏开始/停止触发 recordCallback,
// 周期性触发 countCallback(在线游戏数+前台进程),事件触发 eventCallback
cafegame.RunAppRecord(recordCallback, countCallback, eventCallback,
[]int{cafegame.SourceYGX}, 60 /* 上报间隔秒 */)
```
完整可运行例程:[examples/cafegame/main.go](../examples/cafegame/main.go)
## 注意
- 目录识别规则与对应游戏平台版本耦合,平台更新落盘规则后需同步维护本包。
- `RunAppRecord` 内部启动 goroutine,服务进程常驻场景使用;回调里不要阻塞。
+63
View File
@@ -0,0 +1,63 @@
package cafegame
// 游戏运行状态
const GameStatusStart = 1 // 开始运行
const GameStatusStop = 0 // 停止运行
const SourceYGX = 1 // 云更新
const SourceWWDS = 2 // 网维大师
const SourceYLY = 3 // 易乐游
const SourceMTGX = 4 // 马蹄更新
const SourceStory = 11 // 蘑菇
const SourceGoodGame = 12 // 盖伦
const SourceHaoSiJi = 13 // 好司机
const SourceFast = 14 // 火箭
const SourceWfGame = 15 // WF游戏
// GetSourceAll 返回所有已支持的游戏来源 ID 列表
func GetSourceAll() []int {
return []int{SourceYGX, SourceWWDS, SourceYLY, SourceMTGX, SourceStory, SourceGoodGame, SourceHaoSiJi, SourceFast, SourceWfGame}
}
// SourceToName 返回来源 ID 对应的名称,未知来源返回空字符串
func SourceToName(id int) string {
switch id {
case SourceYGX:
return "云更新"
case SourceWWDS:
return "网维大师"
case SourceYLY:
return "易乐游"
case SourceMTGX:
return "马蹄更新"
case SourceStory:
return "蘑菇"
case SourceGoodGame:
return "盖伦"
case SourceHaoSiJi:
return "好司机"
case SourceFast:
return "火箭"
case SourceWfGame:
return "WF游戏"
}
return ""
}
// 游戏监控事件类型
const (
Error = iota
GameStart
GameStop
GameAdd
GameDelete
GameUpdate
GameExist
GameStartIntervalSmall
ProcessExit
ProcessIgnore
ProcessSourceNotMatch
ProcessExist
ProcessChange
ProcessPathDifferent
ForegroundWindow
)
+53
View File
@@ -0,0 +1,53 @@
package cafegame
import "testing"
func TestSourceToName(t *testing.T) {
cases := map[int]string{
SourceYGX: "云更新",
SourceWWDS: "网维大师",
SourceYLY: "易乐游",
SourceMTGX: "马蹄更新",
SourceStory: "蘑菇",
SourceGoodGame: "盖伦",
SourceHaoSiJi: "好司机",
SourceFast: "火箭",
SourceWfGame: "WF游戏",
999: "", // 未知来源
}
for id, want := range cases {
if got := SourceToName(id); got != want {
t.Errorf("SourceToName(%d) = %q, want %q", id, got, want)
}
}
}
func TestGetSourceAll(t *testing.T) {
all := GetSourceAll()
if len(all) == 0 {
t.Fatal("GetSourceAll 不应为空")
}
for _, id := range all {
if SourceToName(id) == "" {
t.Errorf("来源 %d 无对应名称", id)
}
}
}
func TestGameStatusConstants(t *testing.T) {
if GameStatusStart != 1 || GameStatusStop != 0 {
t.Errorf("游戏状态常量错误: start=%d stop=%d", GameStatusStart, GameStatusStop)
}
}
func TestIsAllNumberHandle(t *testing.T) {
if !IsAllNumberHandle("12345") {
t.Error("纯数字应通过")
}
if IsAllNumberHandle("12a45") {
t.Error("含字母应不通过")
}
if IsAllNumberHandle("") {
t.Error("空串应不通过")
}
}
+678
View File
@@ -0,0 +1,678 @@
//go:build windows
package cafegame
import (
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
)
// IsGameDirByYGX 云更新 id从40000开始
func IsGameDirByYGX(dir string) (bool, AppData, error) {
info := AppData{}
gamePatch := filepath.Join(dir, "gamepatch")
ok, err := pathExists(gamePatch)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
gconfig := filepath.Join(gamePatch, "gconfig.ini")
ok, err = pathExists(gconfig)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
id, _ := getIdByNumberIco(dir)
if id < 40000 {
return false, info, nil
}
info.AppId = id
info.AppName = filepath.Base(dir)
info.AppDir = dir
info.AppSource = SourceYGX
return true, info, nil
}
// IsGameDirByStory 蘑菇 id从8000开始
func IsGameDirByStory(dir string) (bool, AppData, error) {
info := AppData{}
gamePatch := filepath.Join(dir, "gamepatch")
ok, err := pathExists(gamePatch)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
gconfig := filepath.Join(gamePatch, "gconfig.ini")
ok, err = pathExists(gconfig)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
id, _ := getIdByNumberIco(dir)
if id < 10000 {
return false, info, nil
}
info.AppId = id
info.AppName = filepath.Base(dir)
info.AppDir = dir
info.AppSource = SourceStory
return true, info, nil
}
// IsGameDirByWWDS 网维大师 ID 从 10001 开始
func IsGameDirByWWDS(dir string) (bool, AppData, error) {
info := AppData{}
SunwardPkg := filepath.Join(dir, "SunwardPkg.idx")
ok, err := pathExists(SunwardPkg)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
SunwardPkgCks := filepath.Join(dir, "SunwardPkg.cks")
ok, err = pathExists(SunwardPkgCks)
if err != nil {
return false, info, err
}
if !ok {
fmt.Printf("没有cks文件 dir:%s\n", dir)
return false, info, nil
}
// 不能有这文件 这是盖伦文件
GameIndex := filepath.Join(dir, "GameIndex.txt")
ok, err = pathExists(GameIndex)
if err != nil {
return false, info, err
}
if ok {
return false, info, nil
}
// 不能有这文件,这是易乐游文件
GameInfodb := filepath.Join(dir, "GameInfodb.gz")
ok, err = pathExists(GameInfodb)
if err != nil {
return false, info, err
}
if ok {
return false, info, nil
}
// 不能有这文件 这是好司机文件
hsjGame := filepath.Join(dir, "hsjGame.exe")
ok, err = pathExists(hsjGame)
if err != nil {
return false, info, err
}
if ok {
return false, info, nil
}
id, _ := getIdBySunwardPkg(dir)
if id < 10000 {
return false, info, nil
}
info.AppId = id
info.AppName = filepath.Base(dir)
info.AppDir = dir
info.AppSource = SourceWWDS
return true, info, nil
}
// IsGameDirByGoodGame 盖伦 id应该是从 1001开始 steam是第一个游戏 1011
func IsGameDirByGoodGame(dir string) (bool, AppData, error) {
info := AppData{}
GameIndex := filepath.Join(dir, "GameIndex.txt")
ok, err := pathExists(GameIndex)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
GameCksum := filepath.Join(dir, "GameCksum.txt")
ok, err = pathExists(GameCksum)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
id, _ := getIdBySunwardPkg(dir)
if id < 1000 {
return false, info, nil
}
info.AppId = id
info.AppName = filepath.Base(dir)
info.AppDir = dir
info.AppSource = SourceGoodGame
return true, info, nil
}
// IsGameDirByYLY 易乐游
func IsGameDirByYLY(dir string) (bool, AppData, error) {
info := AppData{}
GameInfodb := filepath.Join(dir, "GameInfodb.gz")
ok, err := pathExists(GameInfodb)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
Gamever := filepath.Join(dir, "Gamever.ver")
ok, err = pathExists(Gamever)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
// TODO: 没有游戏ID,只有GUID
guid, _ := GetGuidByYLY(dir)
if guid == "{00000000-0000-0000-0000-000000000000}" {
return false, info, nil
}
info.AppId = 0
info.AppName = filepath.Base(dir)
info.AppDir = dir
info.AppSource = SourceYLY
info.AppGuid = guid
return true, info, nil
}
// IsGameDirByHaoSiJi 好司机
func IsGameDirByHaoSiJi(dir string) (bool, AppData, error) {
info := AppData{}
hsjGame := filepath.Join(dir, "hsjGame.exe")
ok, err := pathExists(hsjGame)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
hsjLoader := filepath.Join(dir, "hsjLoader.exe")
ok, err = pathExists(hsjLoader)
if !ok {
return false, info, nil
}
if err != nil {
return false, info, err
}
SunwardPkg := filepath.Join(dir, "SunwardPkg.idx")
ok, err = pathExists(SunwardPkg)
if !ok {
return false, info, nil
}
if err != nil {
return false, info, err
}
id, _ := getIdBySunwardPkg(dir)
if id == 0 {
return false, info, nil
}
guid, _ := GetGuidByYLY(dir)
if guid == "{00000000-0000-0000-0000-000000000000}" {
return false, info, nil
}
info.AppId = id
info.AppName = filepath.Base(dir)
info.AppDir = dir
info.AppSource = SourceHaoSiJi
info.AppGuid = guid
return true, info, nil
}
// IsGameDirByMTGX 马蹄更新 id从10000开始
func IsGameDirByMTGX(dir string) (bool, AppData, error) {
info := AppData{}
patchdata := filepath.Join(dir, "patchdata")
ok, err := pathExists(patchdata)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
config := filepath.Join(patchdata, "config.ini")
ok, err = pathExists(config)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
ndstorrent := filepath.Join(dir, "ndstorrent.idx")
ok, err = pathExists(ndstorrent)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
id, _ := getIdByNumberIco(dir)
if id < 10000 {
return false, info, nil
}
info.AppId = id
info.AppName = filepath.Base(dir)
info.AppDir = dir
info.AppSource = SourceMTGX
return true, info, nil
}
// IsGameDirByFast 火箭 id从8000开始
func IsGameDirByFast(dir string) (bool, AppData, error) {
info := AppData{}
patchdata := filepath.Join(dir, "patchdata")
ok, err := pathExists(patchdata)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
rkttorrent := filepath.Join(dir, "rkttorrent.idx")
ok, err = pathExists(rkttorrent)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
gconfig := filepath.Join(patchdata, "config.ini")
ok, err = pathExists(gconfig)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
id, _ := getIdByNumberIco(dir)
if id < 8000 {
return false, info, nil
}
info.AppId = id
info.AppName = filepath.Base(dir)
info.AppDir = dir
info.AppSource = SourceFast
return true, info, nil
}
// IsGameDirByWfGame WF游戏
func IsGameDirByWfGame(dir string) (bool, AppData, error) {
info := AppData{}
ndsgameres := filepath.Join(dir, "ndsgameres.index")
ok, err := pathExists(ndsgameres)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
dd_launcher := filepath.Join(dir, "dd_launcher.json")
ok, err = pathExists(dd_launcher)
if err != nil {
return false, info, err
}
if !ok {
return false, info, nil
}
id, _ := getIdByDdLauncher(dir)
if id == 0 {
return false, info, nil
}
info.AppId = id
info.AppName = filepath.Base(dir)
info.AppDir = dir
info.AppSource = SourceWfGame
return true, info, nil
}
// 网维大师 盖伦 好司机
func getIdBySunwardPkg(dir string) (int, error) {
path := filepath.Join(dir, "SunwardPkg.idx")
file, err := os.Open(path)
if err != nil {
fmt.Println("Error opening file:", err)
return 0, err
}
defer file.Close()
// Read a 4-byte integer
var num int32
err = binary.Read(file, binary.BigEndian, &num)
if err != nil {
fmt.Println("Error reading integer:", err)
return 0, err
}
// fmt.Println("Read integer:", num)
return int(num), nil
}
// 云更新 蘑菇 火箭使用
func getIdByNumberIco(dir string) (int, error) {
// 获该目录下所有文件
fileList, _ := getFileList(dir)
for _, s := range fileList {
ext := filepath.Ext(s)
// 判断是否ico图标
if strings.ToLower(ext) == ".ico" {
// fmt.Printf("ico: %s\n", s)
// 通过ico图标的数字确定游戏ID
fileName := strings.TrimSuffix(filepath.Base(s), filepath.Ext(s))
if IsAllNumberHandle(fileName) {
gameId, _ := strconv.Atoi(fileName)
if gameId >= 8000 {
return gameId, nil
}
}
} else {
continue
}
}
return 0, nil
}
// DdLauncherJson DDL Launcher 平台游戏信息(JSON 配置)
type DdLauncherJson struct {
Gameid int `json:"gameid"`
}
// getIdByDdLauncher 从 DDL Launcher 配置文件中读取游戏 ID
func getIdByDdLauncher(dir string) (int, error) {
path := filepath.Join(dir, "dd_launcher.json")
ok, err := pathExists(path)
if err != nil {
return 0, err
}
if !ok {
return 0, errors.New("文件不存在")
}
buf, err := os.ReadFile(path)
if err != nil {
fmt.Println(err)
return 0, err
}
data := DdLauncherJson{}
err = json.Unmarshal(buf, &data)
if err != nil {
fmt.Println(err)
return 0, err
}
if data.Gameid == 0 {
return 0, errors.New("游戏ID为空")
}
return data.Gameid, nil
}
// GetGuidByYLY 从易乐游目录配置中读取游戏 GUID
func GetGuidByYLY(dir string) (string, error) {
path := filepath.Join(dir, "$ResMetadata$", "ResAppData.dat")
ok, err := pathExists(path)
if err != nil {
return "", err
}
if !ok {
return "", errors.New("文件不存在")
}
file, err := os.Open(path)
if err != nil {
fmt.Println(err)
return "", err
}
defer file.Close()
// 设置读取起始位置为第25个字节
_, err = file.Seek(24, 0)
if err != nil {
fmt.Println(err)
return "", err
}
// 创建一个字节切片来存储GUID
buf := make([]byte, 16)
// 读取GUID到字节切片中
_, err = file.Read(buf)
if err != nil {
fmt.Println(err)
return "", err
}
guidInt32 := binary.LittleEndian.Uint32(buf[:4])
guidInt16_1 := binary.LittleEndian.Uint16(buf[4:6])
guidInt16_2 := binary.LittleEndian.Uint16(buf[6:8])
guidBytes_1 := buf[8:10]
guidBytes_2 := buf[10:]
guid := fmt.Sprintf("{%08x-%04x-%04x-%s-%s}", guidInt32, guidInt16_1, guidInt16_2, hex.EncodeToString(guidBytes_1), hex.EncodeToString(guidBytes_2))
guid = strings.ToTitle(guid)
return guid, err
}
// GetGameInfoByDir 获取游戏信息 从目录获取
func GetGameInfoByDir(root string) (AppData, error) {
// fmt.Println(root)
data := AppData{}
// 云更新
ok, data, err := IsGameDirByYGX(root)
if ok {
return data, err
}
// 网维大师
ok, data, err = IsGameDirByWWDS(root)
if ok {
return data, err
}
// 易乐游
ok, data, err = IsGameDirByYLY(root)
if ok {
return data, err
}
// 马蹄更新
ok, data, err = IsGameDirByMTGX(root)
if ok {
return data, err
}
// 蘑菇
ok, data, err = IsGameDirByStory(root)
if ok {
return data, err
}
// 盖伦
ok, data, err = IsGameDirByGoodGame(root)
if ok {
return data, err
}
// 好司机
ok, data, err = IsGameDirByHaoSiJi(root)
if ok {
return data, err
}
// 火箭
ok, data, err = IsGameDirByFast(root)
if ok {
return data, err
}
// WF游戏
ok, data, err = IsGameDirByWfGame(root)
if ok {
return data, err
}
return data, nil
}
// GetGameInfoByPath 获取游戏信息 从路径获取
func GetGameInfoByPath(exePath string) (AppData, error) {
path := filepath.ToSlash(exePath)
pathArr := strings.Split(path, "/")
// 3个/ 比如 D:/网络游戏/英雄联盟/xxx.exe
// 长度3 是 D:/英雄联盟/xxx.exe
if len(pathArr) < 3 {
return AppData{}, errors.New("不支持1级目录")
}
dir := ""
// 连接二级目录
for i, s := range pathArr {
// 最后是文件名,不检测
if i >= len(pathArr)-1 {
break
}
// 最多支持3级目录 如 D:/网络游戏/腾讯游戏/英雄联盟/xxx.exe
if i > 4 {
break
}
if i == 0 {
dir = s
} else {
dir = filepath.Join(dir, string(filepath.Separator), s)
}
// 获取游戏信息
info, err := GetGameInfoByDir(dir)
if err != nil {
return info, err
}
if info.AppId > 0 || info.AppGuid != "" {
return info, nil
}
}
return AppData{}, nil
}
// 获取文件列表
func getFileList(dir string) ([]string, error) {
list := make([]string, 0)
//fmt.Println(dir)
f, err := os.Open(dir)
if err != nil {
fmt.Println(err)
}
files, err := f.Readdir(-1)
f.Close()
if err != nil {
fmt.Println(err)
return nil, err
}
for _, file := range files {
if !file.IsDir() && file.Name() != "$RECYCLE.BIN" && file.Name() != "System Volume Information" {
list = append(list, filepath.Join(dir, file.Name()))
}
}
return list, nil
}
// IsAllNumberHandle /** 是否全部是数字 */
func IsAllNumberHandle(str string) bool {
// 空串不认为是数字
if str == "" {
return false
}
anyReplaceList := []int32{'.', '1', '2', '3', '4', '5', '6', '7', '8', '9', '0'}
for strIndex, strInt := range str {
if strIndex == 0 {
if strInt == '-' {
continue
}
}
if IsHaveReplaceList(strInt, anyReplaceList) {
continue
}
return false
}
return true
}
// IsHaveReplaceList 是否在过滤列表中
func IsHaveReplaceList(strInt int32, replaceList []int32) bool {
for _, chStrInt := range replaceList {
if chStrInt == strInt {
return true
}
}
return false
}
// pathExists 判断一个文件或文件夹是否存在
// 输入文件路径,根据返回的bool值来判断文件或文件夹是否存在
func pathExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
+117
View File
@@ -0,0 +1,117 @@
//go:build windows
package cafegame
import (
"os"
"path/filepath"
"sync"
"time"
)
// ProcessMonitor 单个游戏进程的监视器:等待进程退出后向 waitChan 发送结果
type ProcessMonitor struct {
pid int
exeName string
exePath string
AppData
status int
waitChan chan AppWaitData
}
// NewProcessMonitor 创建进程监视器
func NewProcessMonitor(pid int, exePath string, app AppData, waitChan chan AppWaitData) *ProcessMonitor {
process := new(ProcessMonitor)
process.pid = pid
process.exeName = filepath.Base(exePath)
process.exePath = exePath
process.waitChan = waitChan
process.AppData = app
return process
}
// monitor 阻塞等待进程退出
func (receiver *ProcessMonitor) monitor() error {
defer func() {
receiver.waitChan <- AppWaitData{Pid: receiver.pid, AppData: receiver.AppData}
}()
process, err := os.FindProcess(receiver.pid)
if err != nil {
// 查找进程失败
return err
}
_, err = process.Wait()
if err != nil {
//"进程运行错误
return err
}
// 进程已经结束
return nil
}
// Start 在后台协程中开始监视进程
func (receiver *ProcessMonitor) Start() {
go receiver.monitor()
}
// AppMonitor 单个游戏的监控器:聚合该游戏的所有进程
type AppMonitor struct {
AppData
processList []*ProcessMonitor
mux sync.Mutex
waitChan chan AppWaitData
recordId int
startTime int64
}
// NewAppMonitor 创建游戏监控器
func NewAppMonitor(app AppData, path string, waitChan chan AppWaitData) *AppMonitor {
monitor := new(AppMonitor)
monitor.AppData = app
monitor.waitChan = waitChan
monitor.startTime = time.Now().Unix()
return monitor
}
// AddProcess 添加该游戏的一个进程(之前无进程时重置开始时间)
func (receiver *AppMonitor) AddProcess(pid int, exePath string) {
receiver.mux.Lock()
defer receiver.mux.Unlock()
// 没有进程,重新设置开始时间为当前时间
if len(receiver.processList) == 0 {
receiver.startTime = time.Now().Unix()
}
process := NewProcessMonitor(pid, exePath, receiver.AppData, receiver.waitChan)
process.Start()
receiver.processList = append(receiver.processList, process)
}
// RemoveProcess 移除指定 PID 的进程
func (receiver *AppMonitor) RemoveProcess(pid int) {
receiver.mux.Lock()
defer receiver.mux.Unlock()
for i := 0; i < len(receiver.processList); i++ {
if pid == receiver.processList[i].pid {
receiver.processList = append(receiver.processList[0:i], receiver.processList[i+1:]...)
i--
}
}
}
// AppIsRun 该游戏是否仍有进程在运行
func (receiver *AppMonitor) AppIsRun() bool {
receiver.mux.Lock()
defer receiver.mux.Unlock()
return len(receiver.processList) > 0
}
// GetStartInterval 返回游戏开始运行至今的秒数
func (receiver *AppMonitor) GetStartInterval() int64 {
receiver.mux.Lock()
defer receiver.mux.Unlock()
now := time.Now().Unix()
interval := now - receiver.startTime
return interval
}
+597
View File
@@ -0,0 +1,597 @@
//go:build windows
package cafegame
import (
"fmt"
"github.com/lxn/win"
"github.com/shirou/gopsutil/v3/process"
"path/filepath"
"strings"
"sync"
"time"
"unicode"
)
// SimpleProcessData 简化版进程数据(用于已存在进程列表)
type SimpleProcessData struct {
pid int
name string
exeFile string
status int
game bool
}
// RecordCallback 游戏记录回调:游戏开始/停止时提交,返回记录 ID
type RecordCallback func(app AppData, status int) (int, error)
// CountCallback 游戏统计回调:周期性提交统计信息
type CountCallback func(apps []AppData, interval int, foreground []ForegroundApp) error
// EventCallback 游戏事件回调
type EventCallback func(eventData AppEventData)
// AppRecord 游戏运行记录/监控主对象
type AppRecord struct {
existProcessList []SimpleProcessData
appMonitorList []*AppMonitor
mux sync.Mutex
waitChan chan AppWaitData
postRecord RecordCallback // 提交记录 回调方法
postCount CountCallback // 提交统计 回调方法
eventCallback EventCallback // 事件回调方法
sourceList []int // 来源列表 表示需要监控哪个来源的游戏
reportInterval int
}
// AppWaitData 进程退出等待结果
type AppWaitData struct {
AppData
Pid int
Status int
}
var appRecord *AppRecord
var foregroundGame []ForegroundApp
// RunAppRecord 运行游戏记录/监控 入口函数
func RunAppRecord(recordCallback RecordCallback, countCallback CountCallback, eventCallback EventCallback, sourceList []int, reportInterval int) {
time.Sleep(time.Second * 10)
foregroundGame = make([]ForegroundApp, 0)
appRecord = new(AppRecord)
appRecord.waitChan = make(chan AppWaitData, 100)
appRecord.postRecord = recordCallback
appRecord.postCount = countCallback
appRecord.eventCallback = eventCallback
appRecord.sourceList = sourceList
appRecord.reportInterval = reportInterval
// 检查上报间隔范围
if appRecord.reportInterval < 60 {
appRecord.reportInterval = 60
} else if appRecord.reportInterval > 600 {
appRecord.reportInterval = 600
}
appRecord.Start()
}
// 应用事件
func (receiver *AppRecord) appEvent(eventData AppEventData) {
if appRecord.eventCallback != nil {
appRecord.eventCallback(eventData)
}
}
// AppMonitorIsRun 指定游戏监控否运行
func (receiver *AppRecord) AppMonitorIsRun(gameId int, name string) bool {
receiver.mux.Lock()
defer receiver.mux.Unlock()
for _, game := range receiver.appMonitorList {
if game.AppId == gameId {
return true
}
}
return false
}
// ProcessIsExist 进程是否存在
func (receiver *AppRecord) ProcessIsExist(pid int, path string) bool {
receiver.mux.Lock()
defer receiver.mux.Unlock()
for _, data := range receiver.existProcessList {
if pid == data.pid {
if strings.ToLower(path) == strings.ToLower(data.exeFile) {
return true
}
// 进程路径不一致
receiver.appEvent(AppEventData{
ProcessPid: data.pid,
ProcessPath: path,
ProcessOldPath: data.exeFile,
EventType: ProcessPathDifferent,
EventSource: "AddProcess",
})
return true
}
}
return false
}
// RemoveProcess 移除进程
func (receiver *AppRecord) RemoveProcess(pid int) {
receiver.mux.Lock()
defer receiver.mux.Unlock()
for i := 0; i < len(receiver.existProcessList); i++ {
if pid == receiver.existProcessList[i].pid {
receiver.existProcessList = append(receiver.existProcessList[:i], receiver.existProcessList[i+1:]...)
i--
}
}
}
// AddProcess 添加进程到已经存在列表
func (receiver *AppRecord) AddProcess(pid int, path string) {
receiver.mux.Lock()
defer receiver.mux.Unlock()
processData := SimpleProcessData{
pid: pid,
name: filepath.Base(path),
exeFile: path,
status: 1,
}
for i, data := range receiver.existProcessList {
if pid == data.pid {
if strings.ToLower(path) == strings.ToLower(data.exeFile) {
// 进程已存在
receiver.appEvent(AppEventData{
ProcessPid: data.pid,
ProcessPath: data.exeFile,
EventType: ProcessExist,
EventSource: "AddProcess",
})
return
} else {
// 进程信息变更
receiver.appEvent(AppEventData{
ProcessPid: data.pid,
ProcessPath: path,
ProcessOldPath: data.exeFile,
EventType: ProcessChange,
EventSource: "AddProcess",
})
receiver.existProcessList[i] = processData
}
return
}
}
receiver.existProcessList = append(receiver.existProcessList, processData)
}
// AddAppMonitor 添加监控
func (receiver *AppRecord) AddAppMonitor(info AppData, pid int, path string) {
receiver.mux.Lock()
defer receiver.mux.Unlock()
// 检查游戏是否已监控
for _, game := range receiver.appMonitorList {
if game.AppId == info.AppId {
// 已经监控,添加进程
if !game.AppIsRun() {
// 两次启动间隔 要大于60秒,防止多次运行 重复统计。如:游戏进程不断重新启动,崩溃重启等。
if game.GetStartInterval() > 60 {
if receiver.postRecord != nil {
id, _ := receiver.postRecord(game.AppData, GameStatusStart)
game.recordId = id
}
} else {
// 游戏已存在 但启动间隔小于60秒,可能是游戏进程崩溃重启,忽略统计
receiver.appEvent(AppEventData{
AppId: info.AppId,
AppName: info.AppName,
ProcessPid: pid,
ProcessPath: path,
EventType: GameStartIntervalSmall,
EventSource: "AddAppMonitor",
})
return
}
}
game.AddProcess(pid, path)
// 游戏已存在
receiver.appEvent(AppEventData{
AppId: info.AppId,
AppName: info.AppName,
ProcessPid: pid,
ProcessPath: path,
EventType: GameExist,
EventSource: "AddAppMonitor",
})
return
}
}
// 没有监控,添加游戏
game := NewAppMonitor(info, path, receiver.waitChan)
game.AddProcess(pid, path)
receiver.appMonitorList = append(receiver.appMonitorList, game)
// 新增游戏
receiver.appEvent(AppEventData{
AppId: info.AppId,
AppName: info.AppName,
ProcessPid: pid,
ProcessPath: path,
EventType: GameAdd,
EventSource: "AddAppMonitor",
})
if receiver.postRecord != nil {
id, _ := receiver.postRecord(game.AppData, GameStatusStart)
game.recordId = id
}
}
// Start 启动定时检查与进程退出监听协程
func (receiver *AppRecord) Start() {
go receiver.timer()
go receiver.readWaitChan()
}
// 定时器 每隔10秒检查一次所有系统中所有进程
func (receiver *AppRecord) timer() {
i := 0
interval := 10
for {
err := receiver.checkProcess()
if err != nil {
receiver.appEvent(AppEventData{
EventType: Error,
EventSource: "checkProcess",
Message: fmt.Sprintf("检查进程错误:%s", err.Error()),
})
}
receiver.checkForegroundApp(interval)
time.Sleep(time.Second * time.Duration(interval))
i += interval
// 60秒提交一次游戏统计信息
if i >= appRecord.reportInterval {
i = 0
err = receiver.count()
if err != nil {
receiver.appEvent(AppEventData{
EventType: Error,
EventSource: "checkProcess",
Message: fmt.Sprintf("提交游戏统计错误:%s", err.Error()),
})
}
}
}
}
// checkForegroundApp 检测前端窗口关联游戏
func (receiver *AppRecord) checkForegroundApp(interval int) {
hWnd := win.GetForegroundWindow()
if hWnd == 0 {
receiver.appEvent(AppEventData{
EventType: ForegroundWindow,
EventSource: "checkForegroundApp",
Message: "无前端窗口",
})
return
}
receiver.appEvent(AppEventData{
EventType: ForegroundWindow,
EventSource: "checkForegroundApp",
Message: fmt.Sprintf("前端窗口句柄:%d", hWnd),
})
pid := uint32(0)
ret := win.GetWindowThreadProcessId(hWnd, &pid)
if ret == 0 {
receiver.appEvent(AppEventData{
EventType: ForegroundWindow,
EventSource: "checkForegroundApp",
Message: "获取前端窗口关联进程失败",
})
return
}
receiver.appEvent(AppEventData{
EventType: ForegroundWindow,
EventSource: "checkForegroundApp",
Message: fmt.Sprintf("前端窗口关联进程ID:%d", pid),
})
game, ok := receiver.getGameByPid(int(pid))
if !ok {
receiver.appEvent(AppEventData{
EventType: ForegroundWindow,
EventSource: "checkForegroundApp",
Message: "未能获取前端窗口关联游戏",
})
return
}
receiver.appEvent(AppEventData{
EventType: ForegroundWindow,
EventSource: "checkForegroundApp",
Message: fmt.Sprintf("前端窗口关联游戏 id:%d name:%s", game.AppId, game.AppName),
})
receiver.mux.Lock()
defer receiver.mux.Unlock()
for i, info := range foregroundGame {
if info.AppId == game.AppId && info.AppSource == game.AppSource {
foregroundGame[i].Duration += interval
receiver.appEvent(AppEventData{
EventType: ForegroundWindow,
EventSource: "checkForegroundApp",
Message: fmt.Sprintf("找到游戏 id:%d name:%s duration:%d", game.AppId, game.AppName, foregroundGame[i].Duration),
})
return
}
}
receiver.appEvent(AppEventData{
EventType: ForegroundWindow,
EventSource: "checkForegroundApp",
Message: fmt.Sprintf("新增游戏 id:%d name:%s duration:%d", game.AppId, game.AppName, interval),
})
foregroundGame = append(foregroundGame, ForegroundApp{
AppData: game,
Duration: interval,
})
}
// getGameByPid 根据pid获取游戏信息
func (receiver *AppRecord) getGameByPid(pid int) (AppData, bool) {
receiver.mux.Lock()
defer receiver.mux.Unlock()
for _, game := range receiver.appMonitorList {
for _, processMonitor := range game.processList {
if processMonitor.pid == pid {
return game.AppData, true
}
}
}
return AppData{}, false
}
// count 提交游戏统计信息
func (receiver *AppRecord) count() error {
receiver.mux.Lock()
list := make([]AppData, 0)
for _, game := range receiver.appMonitorList {
if game.AppIsRun() {
list = append(list, game.AppData)
}
}
foreground := make([]ForegroundApp, 0)
for i, info := range foregroundGame {
if info.Duration > 0 {
foreground = append(foreground, info)
foregroundGame[i].Duration = 0
}
}
receiver.mux.Unlock()
if receiver.postCount != nil {
err := receiver.postCount(list, 60, foreground)
if err != nil {
return err
}
}
return nil
}
// 检查系统中所有进程
func (receiver *AppRecord) checkProcess() error {
processList, err := getProcessInfo()
if err != nil {
return err
}
for _, info := range processList {
driveLetter := int32(0)
for _, char := range info.ExE {
driveLetter = char
break
}
// 盘符是否为字母
if !unicode.IsLetter(driveLetter) {
continue
}
// 跳过B和C盘
caps := strings.ToTitle(info.ExE[0:1])
if caps == "B" || caps == "C" {
continue
}
// 进程是否已经存在 已经在监控中
if appRecord.ProcessIsExist(int(info.Pid), info.ExE) {
continue
}
// 检测是否游戏进程
appRecord.GameCheck(int(info.Pid), info.ExE)
// 添加到已存在进程,防止重复检测浪费CPU资源
appRecord.AddProcess(int(info.Pid), info.ExE)
}
return nil
}
// GameCheck 检测进程是否游戏进程
func (receiver *AppRecord) GameCheck(pid int, exePath string) error {
info, err := GetGameInfoByPath(exePath)
if err != nil {
return err
}
if info.AppId > 0 {
if receiver.inSource(info.AppSource) {
// 检查进程是否被忽略
if !checkGameProcessIsIgnore(info.AppId, filepath.Base(exePath)) {
// 没有被忽略 开始监控
appRecord.AddAppMonitor(info, pid, exePath)
} else {
// 进程被忽略
receiver.appEvent(AppEventData{
AppId: info.AppId,
AppName: info.AppName,
ProcessPid: pid,
ProcessPath: exePath,
EventType: ProcessIgnore,
EventSource: "GameCheck",
})
}
} else {
// 进程来源不在列表中
receiver.appEvent(AppEventData{
AppId: info.AppId,
AppName: info.AppName,
ProcessPid: pid,
ProcessPath: exePath,
EventType: ProcessSourceNotMatch,
EventSource: "GameCheck",
})
}
}
return nil
}
// inSource 判断来源是否在监控列表中
func (receiver *AppRecord) inSource(source int) bool {
for _, id := range receiver.sourceList {
if source == id {
return true
}
}
return false
}
// readWaitChan 消费进程退出结果:移除进程并延迟检查游戏是否整体退出
func (receiver *AppRecord) readWaitChan() {
// 进程结束通道
for waitData := range receiver.waitChan {
// 进程结束事件
receiver.appEvent(AppEventData{
AppId: waitData.AppId,
AppName: waitData.AppName,
ProcessPid: waitData.Pid,
EventType: ProcessExit,
EventSource: "readWaitChan",
})
// 从进程列表中移除
receiver.RemoveProcess(waitData.Pid)
// 检查游戏是否还有进程在运行
go func(data AppWaitData) {
time.Sleep(time.Second * 15)
receiver.checkGameIsRun(data)
}(waitData)
}
}
// checkGameIsRun 移除已退出的进程,游戏全部进程退出时结束统计
func (receiver *AppRecord) checkGameIsRun(data AppWaitData) {
receiver.mux.Lock()
defer receiver.mux.Unlock()
for _, game := range receiver.appMonitorList {
if game.AppId == data.AppId {
game.RemoveProcess(data.Pid)
if !game.AppIsRun() {
// 游戏停止运行
receiver.appEvent(AppEventData{
AppId: game.AppId,
AppName: game.AppName,
EventType: GameStop,
EventSource: "checkGameIsRun",
})
if receiver.postRecord != nil {
_, _ = receiver.postRecord(game.AppData, GameStatusStop)
}
}
}
}
}
// MemoryInfoStat 进程内存信息(RSS/虚拟内存)
type MemoryInfoStat struct {
Rss uint64 `json:"res"`
Vms uint64 `json:"vms"`
}
// ProcessInfo 进程信息(PID/名称/路径/CPU/内存/启动时间)
type ProcessInfo struct {
Pid int32 `json:"pid"`
Name string `json:"name"`
ExE string `json:"exe"`
Cmd string `json:"cmd"`
Cpu float64 `json:"cpu"`
Mem MemoryInfoStat `json:"mem"`
CreateTime int64 `json:"createTime"`
}
// 获取系统中所有进程信息
func getProcessInfo() ([]ProcessInfo, error) {
v, err := process.Processes()
if err != nil {
return nil, err
}
info := make([]ProcessInfo, len(v))
for i, p := range v {
name := ""
exe, _ := p.Exe()
cmd, _ := p.Cmdline()
// 先从执行文件中获取进程名
// 如果执行文件为空,再调用 Name() 方式,Name()方式很占用CPU
if exe == "" {
name, _ = p.Name()
} else {
name = filepath.Base(exe)
}
mem, _ := p.MemoryInfo()
cpu, _ := p.CPUPercent()
createTime, _ := p.CreateTime()
p.Times()
// 不获取线程数 太占用用CPU
// p.NumThreads()
info[i].Pid = p.Pid
info[i].Name = name
info[i].ExE = exe
info[i].Cmd = cmd
info[i].Cpu = cpu
if mem != nil {
info[i].Mem = MemoryInfoStat{
Rss: mem.RSS,
Vms: mem.VMS,
}
}
info[i].CreateTime = createTime
if name == "QQ.exe" {
//fmt.Printf("Pid:%d name:%s exe:%s\n", p.Pid, name, exe)
//fmt.Printf("工作集:%d 提交大小:%d\n", mem.RSS, mem.VMS)
//fmt.Printf("CPU:%.2f%% \n", cpu)
}
}
if err != nil {
return nil, err
}
return info, nil
}
+104
View File
@@ -0,0 +1,104 @@
//go:build windows
package cafegame
import (
"encoding/binary"
"os"
"path/filepath"
"strconv"
"testing"
)
// newYGXGameDir 构造一个云更新游戏目录(gamepatch/gconfig.ini + 数字 ico)
func newYGXGameDir(t *testing.T, id int) string {
dir := filepath.Join(t.TempDir(), "测试游戏")
if err := os.MkdirAll(filepath.Join(dir, "gamepatch"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "gamepatch", "gconfig.ini"), nil, 0644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, strconv.Itoa(id)+".ico"), nil, 0644); err != nil {
t.Fatal(err)
}
return dir
}
func TestIsGameDirByYGX(t *testing.T) {
dir := newYGXGameDir(t, 40001)
ok, info, err := IsGameDirByYGX(dir)
if err != nil {
t.Fatalf("IsGameDirByYGX: %v", err)
}
if !ok {
t.Fatal("云更新目录应识别成功")
}
if info.AppId != 40001 {
t.Errorf("AppId = %d, want 40001", info.AppId)
}
if info.AppSource != SourceYGX {
t.Errorf("AppSource = %d, want SourceYGX(%d)", info.AppSource, SourceYGX)
}
if info.AppName != "测试游戏" {
t.Errorf("AppName = %q", info.AppName)
}
}
func TestIsGameDirByYGXNegative(t *testing.T) {
// 空目录(无 gamepatch)
if ok, _, _ := IsGameDirByYGX(t.TempDir()); ok {
t.Error("空目录不应识别为云更新")
}
// 有 gamepatch/gconfig.ini 但 ico 编号过小
dir := filepath.Join(t.TempDir(), "small")
_ = os.MkdirAll(filepath.Join(dir, "gamepatch"), 0755)
_ = os.WriteFile(filepath.Join(dir, "gamepatch", "gconfig.ini"), nil, 0644)
_ = os.WriteFile(filepath.Join(dir, "12345.ico"), nil, 0644)
if ok, _, _ := IsGameDirByYGX(dir); ok {
t.Error("ico 编号小于 40000 不应识别为云更新")
}
}
func TestIsGameDirByStory(t *testing.T) {
dir := newYGXGameDir(t, 10001)
ok, info, err := IsGameDirByStory(dir)
if err != nil {
t.Fatalf("IsGameDirByStory: %v", err)
}
if !ok {
t.Fatal("蘑菇目录应识别成功")
}
if info.AppSource != SourceStory {
t.Errorf("AppSource = %d, want SourceStory(%d)", info.AppSource, SourceStory)
}
}
func TestGetGuidByYLY(t *testing.T) {
// 构造易乐游 $ResMetadata$/ResAppData.dat:24 字节头 + GUID 结构
dir := t.TempDir()
data := make([]byte, 40)
binary.LittleEndian.PutUint32(data[24:28], 0x12345678)
binary.LittleEndian.PutUint16(data[28:30], 0xABCD)
binary.LittleEndian.PutUint16(data[30:32], 0xEF01)
copy(data[32:36], []byte{1, 2, 3, 4})
copy(data[36:40], []byte{5, 6, 7, 8})
if err := os.MkdirAll(filepath.Join(dir, "$ResMetadata$"), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "$ResMetadata$", "ResAppData.dat"), data, 0644); err != nil {
t.Fatal(err)
}
guid, err := GetGuidByYLY(dir)
if err != nil {
t.Fatalf("GetGuidByYLY: %v", err)
}
want := "{12345678-ABCD-EF01-0102-030405060708}"
if guid != want {
t.Errorf("GUID = %q, want %q", guid, want)
}
}
+63
View File
@@ -0,0 +1,63 @@
//go:build windows
package cafegame
import (
"strings"
"sync"
)
var mux sync.Mutex
var appIgnoreProcess []AppIgnoreProcess
// AppIgnoreProcess 需要忽略的游戏进程配置(不参与监控统计)
type AppIgnoreProcess struct {
AppId int `json:"appId" gorm:"column:app_id"`
AppName string `json:"appName" gorm:"column:app_name"`
AppSource int `json:"appSource" gorm:"column:app_source"`
AppGuid string `json:"appGuid" gorm:"column:app_guid"`
ProcessName []string `json:"processName" gorm:"column:process_name"`
Status int `json:"status" gorm:"column:status"`
}
func init() {
mux.Lock()
appIgnoreProcess = make([]AppIgnoreProcess, 0)
mux.Unlock()
}
// AddIgnoreProcessItem 添加单条忽略配置
func AddIgnoreProcessItem(item AppIgnoreProcess) {
mux.Lock()
defer mux.Unlock()
appIgnoreProcess = append(appIgnoreProcess, item)
}
// AddIgnoreProcessList 批量添加忽略配置
func AddIgnoreProcessList(list []AppIgnoreProcess) {
mux.Lock()
defer mux.Unlock()
appIgnoreProcess = append(appIgnoreProcess, list...)
}
// checkGameProcessIsIgnore 判断指定游戏的进程名是否在忽略列表中(大小写不敏感)
func checkGameProcessIsIgnore(appId int, processName string) bool {
if appId == 0 || processName == "" {
return false
}
mux.Lock()
defer mux.Unlock()
for _, game := range appIgnoreProcess {
if appId == game.AppId && appId != 0 {
for _, s := range game.ProcessName {
if strings.ToLower(s) == strings.ToLower(processName) && processName != "" {
return true
}
}
}
}
return false
}
+90
View File
@@ -0,0 +1,90 @@
// Package cafegame 提供网咖游戏目录识别与游戏运行监控/统计(Windows)。
package cafegame
// AppData 单个游戏信息(目录识别结果)
type AppData struct {
AppSource int `json:"appSource"`
AppId int `json:"appId"`
AppName string `json:"appName"`
AppAlias string `json:"appAlias"`
AppDir string `json:"appDir"`
AppGuid string `json:"appGuid"`
}
// CountItem 单条游戏统计记录
type CountItem struct {
Runs int64 `json:"runs"` // 运行次数
RunTime int64 `json:"runTime"` // 运行时间
Duration int64 `json:"duration"` // 运行时长
Foreground int64 `json:"foreground"` // 前台时长
}
// AppCountInfo 单个游戏的统计汇总
type AppCountInfo struct {
Source int `json:"source"`
Id int `json:"id"`
Name string `json:"name"`
}
// AppCountItem 上报用的游戏统计项(游戏信息 + 统计)
type AppCountItem struct {
AppCountInfo
CountItem
}
// AppCountReqData 游戏统计请求数据
type AppCountReqData struct {
HostName string `json:"hostName"`
HostIp string `json:"hostIp"`
GatewayMac string `json:"gatewayMac"`
Apps []AppData `json:"game"`
BootTime uint64 `json:"bootTime"`
Duration int `json:"duration"`
Foreground []ForegroundApp `json:"foreground"`
Time int64 `json:"time"`
}
// ForegroundApp 前台窗口关联的游戏及持续时间(秒)
type ForegroundApp struct {
AppData
Duration int
}
// AppDownloadReqData 游戏下载上报数据
type AppDownloadReqData struct {
AppId int `json:"appId"`
AppName string `json:"appName"`
AppSource int `json:"appSource"`
Status int `json:"status"`
Progress float32 `json:"progress"`
UpdateQuantity float64 `json:"updateQuantity"`
Residue float64 `json:"residue"`
Speed float64 `json:"speed"`
ConnNumber int `json:"connNumber"`
Remainder int `json:"remainder"`
}
// AppRunReqData 游戏运行上报数据
type AppRunReqData struct {
HostName string `json:"hostName"`
HostIp string `json:"hostIp"`
GatewayMac string `json:"gatewayMac"`
App AppData `json:"app"`
BootTime uint64 `json:"bootTime"`
Status int `json:"status"`
Time int64 `json:"time"`
RecordId int `json:"recordId"`
}
// AppEventData 游戏事件上报数据
type AppEventData struct {
AppId int `json:"appId"` // 游戏ID
AppName string `json:"appName"` // 游戏名称
AppSource int `json:"appSource"` // 游戏来源
ProcessPid int `json:"processPid"` // 进程ID
ProcessPath string `json:"processPath"` // 进程路径
ProcessOldPath string `json:"processOldPath"` // 进程原路径
EventType int `json:"eventType"` // 事件类型
EventSource string `json:"eventSource"` // 事件来源
Message string `json:"message"` // 事件信息
}
+43
View File
@@ -0,0 +1,43 @@
// cafegame 包示例:网咖游戏识别(按各游戏平台目录特征判断游戏来源与 ID)
package main
import (
"fmt"
"os"
"path/filepath"
"git.zeroonesoft.cn/golib/zogo/cafegame"
)
func main() {
// 游戏来源常量与名称
for _, id := range cafegame.GetSourceAll() {
fmt.Printf("来源 %2d: %s\n", id, cafegame.SourceToName(id))
}
// 构造一个模拟的"云更新"游戏目录,演示目录识别
// 云更新特征:目录下有 gamepatch/gconfig.ini,且有数字命名的 .ico 图标(ID≥40000)
root := filepath.Join(os.TempDir(), "go-hua-cafegame-demo", "40001示例游戏")
_ = os.MkdirAll(filepath.Join(root, "gamepatch"), 0755)
_ = os.WriteFile(filepath.Join(root, "gamepatch", "gconfig.ini"), nil, 0644)
_ = os.WriteFile(filepath.Join(root, "40001.ico"), nil, 0644)
defer os.RemoveAll(filepath.Dir(root))
ok, info, err := cafegame.IsGameDirByYGX(root)
fmt.Println("是否云更新游戏目录:", ok, "错误:", err)
if ok {
fmt.Printf("识别结果: Id=%d Name=%s Source=%d(%s)\n",
info.AppId, info.AppName, info.AppSource, cafegame.SourceToName(info.AppSource))
}
// 实际使用中扫描游戏盘根目录,自动识别所有游戏:
// entries, _ := os.ReadDir("D:\\")
// for _, e := range entries {
// if info, err := cafegame.GetGameInfoByDir(filepath.Join("D:\\", e.Name())); err == nil && (info.AppId > 0 || info.AppGuid != "") {
// fmt.Printf("%+v\n", info)
// }
// }
//
// 游戏运行记录/监控入口(Windows):
// cafegame.RunAppRecord(recordCallback, countCallback, eventCallback, sourceList, reportInterval)
}