feat: initial commit for G-Launcher based on Firefly with Horoyoi enhancements

This commit is contained in:
CAtlas0784 committed 2026-10-30 20:56:35 +07:00
commit a99470a69f
155 files changed
+19061

No files matched your search

+63
View File
@@ -0,0 +1,63 @@
package appService
import (
"g-launcher/pkg/constant"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
type AppService struct{}
func (a *AppService) GetCurrentLauncherVersion() (bool, string) {
return true, constant.CurrentLauncherVersion
}
func (a *AppService) CloseAppAfterTimeout(timeout int) (bool, string) {
go func() {
time.Sleep(time.Duration(timeout) * time.Second)
application.Get().Quit()
}()
return true, ""
}
func (a *AppService) CloseApp() (bool, string) {
application.Get().Quit()
return true, ""
}
func (a *AppService) HideApp() (bool, string) {
window := application.Get().Window.Current()
if window == nil {
return false, "not found window"
}
window.Hide()
return true, ""
}
func (a *AppService) MinimizeApp() (bool, string) {
window := application.Get().Window.Current()
if window == nil {
return false, "not found window"
}
window.Minimise()
return true, ""
}
func (a *AppService) MaximizeApp() (bool, string) {
window := application.Get().Window.Current()
if window == nil {
return false, "not found window"
}
window.Maximise()
return true, ""
}
func (a *AppService) RestoreApp() (bool, string) {
window := application.Get().Window.Current()
if window == nil {
return false, "not found window"
}
window.Restore()
return true, ""
}
+264
View File
@@ -0,0 +1,264 @@
package diffService
import (
"bufio"
"encoding/json"
"g-launcher/pkg/constant"
"g-launcher/pkg/hpatchz"
"g-launcher/pkg/models"
"g-launcher/pkg/sevenzip"
"g-launcher/pkg/verifier"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
)
type DiffService struct{}
func (h *DiffService) CheckTypeHDiff(patchPath string) (bool, string, string) {
if ok, err := sevenzip.IsFileIn7z(patchPath, "hdifffiles.txt"); err == nil && ok {
return true, "hdifffiles.txt", ""
}
if ok, err := sevenzip.IsFileIn7z(patchPath, "hdifffiles.json"); err == nil && ok {
return true, "hdifffiles.json", ""
}
if ok, err := sevenzip.IsFileIn7z(patchPath, "hdiffmap.json"); err == nil && ok {
return true, "hdiffmap.json", ""
}
if ok, err := sevenzip.IsFileIn7z(patchPath, "manifest"); err == nil && ok {
return true, "manifest", ""
}
return false, "", "not found hdifffiles.txt or hdiffmap.json"
}
func (h *DiffService) VersionValidate(gamePath, patchPath string) (bool, string) {
oldBinPath := filepath.Join(gamePath, "StarRail_Data\\StreamingAssets\\BinaryVersion.bytes")
if _, err := os.Stat(oldBinPath); err != nil {
return false, err.Error()
}
if _, err := os.Stat(patchPath); err != nil {
return false, err.Error()
}
if _, err := os.Stat(constant.TempUrl); os.IsNotExist(err) {
if err := os.MkdirAll(constant.TempUrl, os.ModePerm); err != nil {
return false, err.Error()
}
}
okFull, errFull := sevenzip.IsFileIn7z(patchPath, "StarRail_Data\\StreamingAssets\\BinaryVersion.bytes")
okDiff, errDiff := sevenzip.IsFileIn7z(patchPath, "StarRail_Data\\StreamingAssets\\BinaryVersion.bytes.hdiff")
if (errFull != nil && errDiff != nil) || (!okFull && !okDiff) {
return false, "BinaryVersion file not found in patch"
}
var tempBinFile string
if okFull {
if err := sevenzip.ExtractAFileFromZip(patchPath, "StarRail_Data\\StreamingAssets\\BinaryVersion.bytes", constant.TempUrl); err != nil {
return false, err.Error()
}
tempBinFile = filepath.Join(constant.TempUrl, "BinaryVersion.bytes")
} else {
if err := sevenzip.ExtractAFileFromZip(patchPath, "StarRail_Data\\StreamingAssets\\BinaryVersion.bytes.hdiff", constant.TempUrl); err != nil {
return false, err.Error()
}
patchBinFile := filepath.Join(constant.TempUrl, "BinaryVersion.bytes.hdiff")
sourceBinFile := oldBinPath
tempBinFile = filepath.Join(constant.TempUrl, "BinaryVersion.bytes")
if err := hpatchz.ApplyPatch(sourceBinFile, patchBinFile, tempBinFile); err != nil {
os.Remove(patchBinFile)
return false, err.Error()
}
os.Remove(patchBinFile)
}
okFullPkg, err1 := sevenzip.IsFileIn7z(patchPath, "pkg_version")
okDiffPkg, err2 := sevenzip.IsFileIn7z(patchPath, "pkg_version.hdiff")
if err1 != nil && err2 != nil {
return false, err1.Error()
}
if okFullPkg {
if err := sevenzip.ExtractAFileFromZip(patchPath, "pkg_version", constant.TempUrl); err != nil {
return false, err.Error()
}
}
if okDiffPkg {
if err := sevenzip.ExtractAFileFromZip(patchPath, "pkg_version.hdiff", constant.TempUrl); err != nil {
return false, err.Error()
}
patchPkgFile := filepath.Join(constant.TempUrl, "pkg_version.hdiff")
sourcePkgFile := filepath.Join(gamePath, "pkg_version")
tempPkgFile := filepath.Join(constant.TempUrl, "pkg_version")
if err := hpatchz.ApplyPatch(sourcePkgFile, patchPkgFile, tempPkgFile); err != nil {
os.Remove(patchPkgFile)
os.Remove(tempPkgFile)
return false, err.Error()
}
os.Remove(patchPkgFile)
}
tempPkgFile := filepath.Join(constant.TempUrl, "pkg_version")
pkgDataList, err := models.LoadPkgVersion(tempPkgFile)
if err != nil {
os.Remove(tempPkgFile)
return false, err.Error()
}
os.Remove(tempPkgFile)
// MD5 check BinaryVersion
flags := false
for _, pkgData := range pkgDataList {
if filepath.ToSlash(pkgData.RemoteFile) == "StarRail_Data/StreamingAssets/BinaryVersion.bytes" {
flags = true
md5, err := verifier.FileMD5(tempBinFile)
if err != nil {
os.Remove(tempBinFile)
return false, err.Error()
}
if md5 != pkgData.MD5 {
os.Remove(tempBinFile)
return false, fmt.Sprintf("md5 mismatch for %s: expected %s, got %s",
tempBinFile, pkgData.MD5, md5)
}
break
}
}
if !flags {
os.Remove(tempBinFile)
return false, "BinaryVersion file not found in patch"
}
_, err = models.ParseBinaryVersion(tempBinFile)
if err != nil {
os.Remove(tempBinFile)
return false, err.Error()
}
os.Remove(tempBinFile)
return true, "validated"
}
func (h *DiffService) HDiffPatchData(gamePath string) (bool, string) {
hdiffMapPath := filepath.Join(gamePath, "hdiffmap.json")
hdiffFilesPath := filepath.Join(gamePath, "hdifffiles.txt")
hdifffilesJsonPath := filepath.Join(gamePath, "hdifffiles.json")
var jsonData struct {
DiffMap []*models.HDiffData `json:"diff_map"`
}
if _, err := os.Stat(hdiffMapPath); err == nil {
data, err := os.ReadFile(hdiffMapPath)
if err != nil {
return false, err.Error()
}
var jsonDataDiffMap struct {
DiffMap []*models.DiffMapType `json:"diff_map"`
}
if err := json.Unmarshal(data, &jsonDataDiffMap); err != nil {
return false, err.Error()
}
for _, entry := range jsonDataDiffMap.DiffMap {
jsonData.DiffMap = append(jsonData.DiffMap, entry.ToHDiffData())
}
} else if _, err := os.Stat(hdifffilesJsonPath); err == nil {
data, err := os.ReadFile(hdifffilesJsonPath)
if err != nil {
return false, err.Error()
}
var hdiffJson []*models.HDiffData
if err := json.Unmarshal(data, &hdiffJson); err != nil {
return false, err.Error()
}
jsonData.DiffMap = append(jsonData.DiffMap, hdiffJson...)
} else if _, err := os.Stat(hdiffFilesPath); err == nil {
files, err := models.LoadHDiffFiles(hdiffFilesPath)
if err != nil {
return false, err.Error()
}
for _, entry := range files {
jsonData.DiffMap = append(jsonData.DiffMap, entry.ToHDiffData())
}
} else {
return false, "no hdiff entries map exist"
}
application.Get().Event.Emit("diff:stage", map[string]string{"stage": "Patching HDiff"})
for i, entry := range jsonData.DiffMap {
application.Get().Event.Emit(
"diff:progress", map[string]int{
"progress": i,
"maxProgress": len(jsonData.DiffMap),
})
sourceFile := filepath.Join(gamePath, entry.SourceFileName)
patchFile := filepath.Join(gamePath, entry.PatchFileName)
targetFile := filepath.Join(gamePath, entry.TargetFileName)
if _, err := os.Stat(patchFile); os.IsNotExist(err) {
continue
}
if entry.SourceFileName == "" {
hpatchz.ApplyPatchEmpty(patchFile, targetFile)
os.Remove(patchFile)
continue
}
if _, err := os.Stat(sourceFile); os.IsNotExist(err) {
continue
}
hpatchz.ApplyPatch(sourceFile, patchFile, targetFile)
if entry.SourceFileName != entry.TargetFileName {
os.Remove(sourceFile)
}
os.Remove(patchFile)
}
os.Remove(filepath.Join(gamePath, "hdiffmap.json"))
os.Remove(filepath.Join(gamePath, "hdifffiles.txt"))
os.Remove(filepath.Join(gamePath, "hdifffiles.json"))
return true, "patching completed"
}
func (h *DiffService) DeleteFiles(gamePath string) (bool, string) {
var deleteFiles []string
file, err := os.Open(filepath.Join(gamePath, "deletefiles.txt"))
if err != nil {
return false, ""
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
deleteFiles = append(deleteFiles, line)
}
}
if err := scanner.Err(); err != nil {
file.Close()
return false, "no delete files exist"
}
file.Close()
for i, file := range deleteFiles {
os.Remove(filepath.Join(gamePath, file))
application.Get().Event.Emit("diff:progress", map[string]int{"progress": i, "maxProgress": len(deleteFiles)})
}
os.Remove(filepath.Join(gamePath, "deletefiles.txt"))
return true, ""
}
+172
View File
@@ -0,0 +1,172 @@
package diffService
import (
"g-launcher/pkg/firefly"
"g-launcher/pkg/firefly/pb"
"g-launcher/pkg/hpatchz"
"g-launcher/pkg/models"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/wailsapp/wails/v3/pkg/application"
)
func (h *DiffService) LDiffPatchData(gamePath string) (bool, string) {
entries, err := os.ReadDir(gamePath)
if err != nil {
return false, err.Error()
}
ldiffPath := filepath.Join(gamePath, "ldiff")
for _, entry := range entries {
if entry.IsDir() {
continue
}
if !entry.Type().IsRegular() {
continue
}
name := entry.Name()
if strings.HasPrefix(name, "manifest") {
manifestName := entry.Name()
manifestPath := filepath.Join(gamePath, manifestName)
manifest, err := firefly.LoadManifestProto(manifestPath)
if err != nil {
continue
}
ldiffEntries, err := os.ReadDir(ldiffPath)
if err != nil {
return false, err.Error()
}
application.Get().Event.Emit("diff:stage", map[string]string{"stage": "Processing LDiff"})
for i, ldiffEntry := range ldiffEntries {
assetName := ldiffEntry.Name()
var matchingAssets []struct {
AssetName string
AssetSize int64
Asset *pb.AssetManifest
}
application.Get().Event.Emit(
"diff:progress", map[string]int{
"progress": i,
"maxProgress": len(ldiffEntries),
})
var wg sync.WaitGroup
var mu sync.Mutex
for _, assetGroup := range manifest.Assets {
assetGroup := assetGroup
wg.Go(func() {
if data := assetGroup.AssetData; data != nil {
for _, asset := range data.Assets {
if asset.ChunkFileName == assetName {
mu.Lock()
matchingAssets = append(matchingAssets, struct {
AssetName string
AssetSize int64
Asset *pb.AssetManifest
}{assetGroup.AssetName, assetGroup.AssetSize, asset})
mu.Unlock()
}
}
}
})
}
wg.Wait()
for _, ma := range matchingAssets {
err := firefly.LDiffFile(ma.Asset, ma.AssetName, ma.AssetSize, ldiffPath, gamePath)
if err != nil {
continue
}
}
}
diffMapNames := make([]string, len(ldiffEntries))
for i, e := range ldiffEntries {
diffMapNames[i] = e.Name()
}
diffMapList, err := MakeDiffMap(manifest, diffMapNames)
if err != nil {
return false, err.Error()
}
application.Get().Event.Emit("diff:stage", map[string]string{"stage": "Patching HDiff"})
for i, entry := range diffMapList {
application.Get().Event.Emit(
"diff:progress", map[string]int{
"progress": i,
"maxProgress": len(diffMapList),
})
sourceFile := filepath.Join(gamePath, entry.SourceFileName)
patchFile := filepath.Join(gamePath, entry.PatchFileName)
targetFile := filepath.Join(gamePath, entry.TargetFileName)
if _, err := os.Stat(patchFile); os.IsNotExist(err) {
continue
}
if entry.SourceFileName == "" {
hpatchz.ApplyPatchEmpty(patchFile, targetFile)
os.Remove(patchFile)
continue
}
if _, err := os.Stat(sourceFile); os.IsNotExist(err) {
continue
}
hpatchz.ApplyPatch(sourceFile, patchFile, targetFile)
if entry.SourceFileName != entry.TargetFileName {
os.Remove(sourceFile)
}
os.Remove(patchFile)
}
}
}
os.RemoveAll(ldiffPath)
return true, "patching completed"
}
func MakeDiffMap(manifest *pb.ManifestProto, chunkNames []string) ([]*models.HDiffData, error) {
var hdiffFiles []*models.HDiffData
for _, asset := range manifest.Assets {
assetName := asset.AssetName
assetSize := asset.AssetSize
if asset.AssetData != nil {
for _, chunk := range asset.AssetData.Assets {
matched := false
for _, name := range chunkNames {
if name == chunk.ChunkFileName {
matched = true
break
}
}
if !matched {
continue
}
if chunk.OriginalFileSize != 0 || chunk.HdiffFileSize != assetSize {
hdiffFiles = append(hdiffFiles, &models.HDiffData{
SourceFileName: chunk.OriginalFilePath,
TargetFileName: assetName,
PatchFileName: fmt.Sprintf("%s.hdiff", assetName),
})
}
}
}
}
return hdiffFiles, nil
}
+87
View File
@@ -0,0 +1,87 @@
package diffService
import (
"g-launcher/pkg/constant"
"g-launcher/pkg/sevenzip"
"io"
"os"
"path/filepath"
"github.com/wailsapp/wails/v3/pkg/application"
)
func (h *DiffService) DataExtract(gamePath, patchPath string) (bool, string) {
os.RemoveAll(constant.TempUrl)
if _, err := os.Stat(gamePath); err != nil {
return false, err.Error()
}
if _, err := os.Stat(patchPath); err != nil {
return false, err.Error()
}
if _, err := os.Stat(constant.TempUrl); os.IsNotExist(err) {
if err := os.MkdirAll(constant.TempUrl, os.ModePerm); err != nil {
return false, err.Error()
}
}
if err := sevenzip.ExtractAllFilesFromZip(patchPath, constant.TempUrl); err != nil {
os.RemoveAll(constant.TempUrl)
return false, err.Error()
}
return true, "extract completed"
}
func (h *DiffService) CutData(gamePath string) (bool, string) {
if _, err := os.Stat(constant.TempUrl); os.IsNotExist(err) {
return false, err.Error()
}
err := filepath.Walk(constant.TempUrl, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(constant.TempUrl, path)
if err != nil {
return err
}
destPath := filepath.Join(gamePath, relPath)
application.Get().Event.Emit("diff:message", map[string]string{"message": destPath})
if info.IsDir() {
return os.MkdirAll(destPath, os.ModePerm)
}
if err := os.MkdirAll(filepath.Dir(destPath), os.ModePerm); err != nil {
return err
}
if err := os.Rename(path, destPath); err != nil {
srcFile, err := os.Open(path)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.Create(destPath)
if err != nil {
return err
}
defer dstFile.Close()
if _, err := io.Copy(dstFile, srcFile); err != nil {
return err
}
os.Remove(path)
}
return nil
})
if err != nil {
os.RemoveAll(constant.TempUrl)
return false, err.Error()
}
os.RemoveAll(constant.TempUrl)
return true, "cut completed"
}
+118
View File
@@ -0,0 +1,118 @@
package diffService
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
)
const ziftExecutablePath = "bin/zift.exe"
var ziftPercentPattern = regexp.MustCompile(`(?i)(\d+(?:\.\d+)?)\s*%`)
var ziftANSIPattern = regexp.MustCompile(`\x1b\[[0-?]*[ -/]*[@-~]`)
func (h *DiffService) ApplyDiffZiff(gamePath, patchPath string) (bool, string) {
launcherPath, err := os.Executable()
if err != nil {
return false, fmt.Sprintf("locate launcher directory: %v", err)
}
launcherDir := filepath.Dir(launcherPath)
ziftPath := filepath.Join(launcherDir, filepath.FromSlash(ziftExecutablePath))
if _, err := os.Stat(ziftPath); err != nil {
if cwdPath, cwdErr := filepath.Abs(filepath.FromSlash(ziftExecutablePath)); cwdErr == nil {
if _, statErr := os.Stat(cwdPath); statErr == nil {
ziftPath = cwdPath
}
}
}
if _, err := os.Stat(ziftPath); err != nil {
return false, fmt.Sprintf("Zift executable not found at %s: %v", ziftPath, err)
}
if info, err := os.Stat(patchPath); err != nil || !info.Mode().IsRegular() {
if err != nil {
return false, fmt.Sprintf("patch file is unavailable: %v", err)
}
return false, "selected patch is not a file"
}
cmd := exec.Command(ziftPath, "-a", "-y", patchPath, gamePath)
cmd.Dir = launcherDir
outputReader, outputWriter := io.Pipe()
cmd.Stdout = outputWriter
cmd.Stderr = outputWriter
application.Get().Event.Emit("diff:ziff-progress", map[string]interface{}{
"message": "Starting Zift…",
"percent": -1.0,
})
outputDone := make(chan string, 1)
go func() { outputDone <- collectZiftOutput(outputReader) }()
if err := cmd.Start(); err != nil {
_ = outputWriter.Close()
message := <-outputDone
if message != "" {
return false, fmt.Sprintf("run Zift: %v\n%s", err, message)
}
return false, fmt.Sprintf("run Zift: %v", err)
}
err = cmd.Wait()
_ = outputWriter.Close()
message := strings.TrimSpace(<-outputDone)
if err != nil {
if message == "" {
return false, fmt.Sprintf("run Zift: %v", err)
}
return false, fmt.Sprintf("Zift failed: %v\n%s", err, message)
}
if message == "" {
message = "Ziff patch applied successfully"
}
return true, message
}
func collectZiftOutput(reader io.Reader) string {
buffered := bufio.NewReader(reader)
var output strings.Builder
var line strings.Builder
emitLine := func() {
text := strings.TrimSpace(ziftANSIPattern.ReplaceAllString(line.String(), ""))
line.Reset()
if text == "" {
return
}
output.WriteString(text)
output.WriteByte('\n')
progress := -1.0
if match := ziftPercentPattern.FindStringSubmatch(text); len(match) > 1 {
if value, err := strconv.ParseFloat(match[1], 64); err == nil && value >= 0 && value <= 100 {
progress = value
}
}
application.Get().Event.Emit("diff:ziff-progress", map[string]interface{}{
"message": text,
"percent": progress,
})
}
for {
value, err := buffered.ReadByte()
if err != nil {
if line.Len() > 0 {
emitLine()
}
break
}
if value == '\r' || value == '\n' {
emitLine()
continue
}
line.WriteByte(value)
}
return output.String()
}
+185
View File
@@ -0,0 +1,185 @@
package fsService
import (
"fmt"
"g-launcher/pkg/sevenzip"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/wailsapp/wails/v3/pkg/application"
"golang.org/x/sys/windows"
)
type FSService struct{}
func (f *FSService) PickFolder() (string, error) {
dialog := application.Get().Dialog.OpenFile().
CanChooseDirectories(true).
CanChooseFiles(false).
ResolvesAliases(true)
if runtime.GOOS == "darwin" {
dialog.SetMessage("Select a folder")
} else {
dialog.SetTitle("Select a folder")
}
return dialog.PromptForSingleSelection()
}
func (f *FSService) PickFile(filter string) (string, error) {
dialog := application.Get().Dialog.OpenFile().
CanChooseFiles(true).
ResolvesAliases(true)
if runtime.GOOS == "darwin" {
dialog.SetMessage("Select a file")
} else {
dialog.SetTitle("Select a file")
}
if filter == "exe" {
dialog.AddFilter("Executable Files (*.exe)", "*.exe")
} else if filter == "zip" {
dialog.AddFilter("ZIP archives (*.zip)", "*.zip")
} else if filter == "patch" {
dialog.AddFilter("Patch files (*.zip;*.7z;*.ziff)", "*.zip;*.7z;*.ziff")
}
if path, err := dialog.PromptForSingleSelection(); err == nil {
return path, nil
}
return "", nil
}
func (f *FSService) DirExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return info.IsDir()
}
func (f *FSService) FileExists(path string) bool {
if info, err := os.Stat(path); err == nil {
return info.Mode().IsRegular()
}
return false
}
func (f *FSService) GetDir(path string) string {
return filepath.Dir(path)
}
func (f *FSService) Join(paths ...string) string {
return filepath.Join(paths...)
}
func (f *FSService) RemoveFile(path string) error {
return os.Remove(path)
}
func (f *FSService) StartApp(path string) (bool, string) {
dir := filepath.Dir(path)
cmd := exec.Command(path)
cmd.Dir = dir
err := cmd.Start()
if err != nil {
return false, err.Error()
}
if strings.HasSuffix(path, "StarRail.exe") || strings.HasSuffix(path, "ZenlessZoneZeroBeta.exe") {
go func() {
_ = cmd.Wait()
application.Get().Event.Emit("game:exit")
}()
}
return true, ""
}
func (f *FSService) StartWithConsole(path string) (bool, string) {
absPath, err := filepath.Abs(path)
if err != nil {
return false, err.Error()
}
if _, err := os.Stat(absPath); os.IsNotExist(err) {
return false, "file not found: " + absPath
}
cmd := exec.Command(absPath)
cmd.Dir = filepath.Dir(absPath)
cmd.Stdin = nil
cmd.Stdout = nil
cmd.Stderr = nil
cmd.SysProcAttr = &windows.SysProcAttr{
CreationFlags: windows.CREATE_NEW_CONSOLE |
windows.CREATE_BREAKAWAY_FROM_JOB,
NoInheritHandles: true,
}
err = cmd.Start()
if err != nil {
return false, err.Error()
}
go func() {
_ = cmd.Wait()
base := strings.ToLower(filepath.Base(path))
if base == "launcher.exe" || base == "starrail.exe" {
application.Get().Event.Emit("game:exit")
} else if base == "firefly-go_win.exe" || base == "gameserver.exe" {
application.Get().Event.Emit("server:exit")
} else if base == "firefly-go-proxy.exe" || base == "proxy.exe" {
application.Get().Event.Emit("proxy:exit")
}
}()
return true, ""
}
func (f *FSService) OpenFolder(path string) (bool, string) {
absPath, err := filepath.Abs(path)
if err != nil {
return false, "failed to resolve absolute path: " + err.Error()
}
if !f.DirExists(absPath) {
return false, "directory not found: " + absPath
}
url := "file:///" + filepath.ToSlash(absPath)
application.Get().Browser.OpenURL(url)
return true, ""
}
func (f *FSService) FileExistsInZip(archivePath, fileInside string) (bool, string) {
exists, err := sevenzip.IsFileIn7z(archivePath, fileInside)
if err != nil {
return false, err.Error()
}
return exists, ""
}
func (f *FSService) ReadConfigFile(gameDir, fileName string) (string, error) {
if strings.TrimSpace(gameDir) == "" {
return "", fmt.Errorf("select the game folder first")
}
target := filepath.Join(gameDir, fileName)
content, err := os.ReadFile(target)
if err != nil {
if os.IsNotExist(err) {
return "", nil
}
return "", fmt.Errorf("read file %s: %w", target, err)
}
return string(content), nil
}
func (f *FSService) WriteConfigFile(gameDir, fileName, content string) error {
if strings.TrimSpace(gameDir) == "" {
return fmt.Errorf("select the game folder first")
}
target := filepath.Join(gameDir, fileName)
return os.WriteFile(target, []byte(content), 0644)
}
+85
View File
@@ -0,0 +1,85 @@
package fsService
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"golang.org/x/sys/windows"
)
type gameSelection struct {
GamePath string `json:"gamePath"`
GameDir string `json:"gameDir"`
}
func gameSelectionPath() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("resolve user config directory: %w", err)
}
return filepath.Join(configDir, "g-launcher", "game-selection.json"), nil
}
func (f *FSService) GetSavedGameSelection() (string, string, error) {
path, err := gameSelectionPath()
if err != nil {
return "", "", err
}
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return "", "", nil
}
if err != nil {
return "", "", fmt.Errorf("read saved game selection: %w", err)
}
var selection gameSelection
if err := json.Unmarshal(data, &selection); err != nil {
return "", "", fmt.Errorf("parse saved game selection: %w", err)
}
return selection.GamePath, selection.GameDir, nil
}
func (f *FSService) SaveGameSelection(gamePath, gameDir string) error {
path, err := gameSelectionPath()
if err != nil {
return err
}
data, err := json.Marshal(gameSelection{GamePath: gamePath, GameDir: gameDir})
if err != nil {
return fmt.Errorf("encode game selection: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return fmt.Errorf("create game selection directory: %w", err)
}
tempPath := path + ".tmp"
if err := os.WriteFile(tempPath, data, 0600); err != nil {
return fmt.Errorf("write game selection: %w", err)
}
tempPathPtr, err := windows.UTF16PtrFromString(tempPath)
if err != nil {
_ = os.Remove(tempPath)
return fmt.Errorf("encode temporary game selection path: %w", err)
}
pathPtr, err := windows.UTF16PtrFromString(path)
if err != nil {
_ = os.Remove(tempPath)
return fmt.Errorf("encode game selection path: %w", err)
}
if err := windows.MoveFileEx(tempPathPtr, pathPtr, windows.MOVEFILE_REPLACE_EXISTING|windows.MOVEFILE_WRITE_THROUGH); err != nil {
_ = os.Remove(tempPath)
return fmt.Errorf("replace saved game selection: %w", err)
}
return nil
}
+271
View File
@@ -0,0 +1,271 @@
package gitService
import (
"archive/zip"
"bufio"
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
const (
minGitReleaseAPI = "https://api.github.com/repos/git-for-windows/git/releases/latest"
minGitDirectory = "./server/tools/mingit"
)
type gitRelease struct {
Assets []struct {
Name string `json:"name"`
URL string `json:"browser_download_url"`
} `json:"assets"`
}
// ensureGit provides a private, portable Git runtime when the user has not
// installed Git system-wide. It requires no administrator privileges.
func ensureGit() (string, error) {
if git, err := exec.LookPath("git"); err == nil {
return git, nil
}
gitPath := filepath.Join(minGitDirectory, "cmd", "git.exe")
if _, err := os.Stat(gitPath); err == nil {
return gitPath, nil
}
if runtime.GOOS != "windows" || runtime.GOARCH != "amd64" {
return "", fmt.Errorf("Git was not found on PATH, and automatic portable Git setup is currently supported on Windows x64 only")
}
client := &http.Client{Timeout: 2 * time.Minute}
request, err := http.NewRequest(http.MethodGet, minGitReleaseAPI, nil)
if err != nil {
return "", fmt.Errorf("create Git download request: %w", err)
}
request.Header.Set("Accept", "application/vnd.github+json")
request.Header.Set("User-Agent", "G-Launcher")
response, err := client.Do(request)
if err != nil {
return "", fmt.Errorf("find a portable Git release: %w", err)
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return "", fmt.Errorf("find a portable Git release: GitHub returned %s", response.Status)
}
var release gitRelease
if err := json.NewDecoder(io.LimitReader(response.Body, 4<<20)).Decode(&release); err != nil {
return "", fmt.Errorf("read portable Git release metadata: %w", err)
}
var archiveURL string
for _, asset := range release.Assets {
if strings.HasPrefix(asset.Name, "MinGit-") && strings.HasSuffix(asset.Name, "-64-bit.zip") {
archiveURL = asset.URL
break
}
}
if archiveURL == "" {
return "", fmt.Errorf("the latest Git for Windows release does not include a MinGit x64 package")
}
if err := os.MkdirAll(filepath.Dir(minGitDirectory), 0755); err != nil {
return "", fmt.Errorf("create portable Git directory: %w", err)
}
archive, err := os.CreateTemp(filepath.Dir(minGitDirectory), "mingit-*.zip")
if err != nil {
return "", fmt.Errorf("create portable Git archive: %w", err)
}
archivePath := archive.Name()
defer os.Remove(archivePath)
downloadRequest, err := http.NewRequest(http.MethodGet, archiveURL, nil)
if err != nil {
archive.Close()
return "", fmt.Errorf("create portable Git download request: %w", err)
}
downloadRequest.Header.Set("User-Agent", "G-Launcher")
downloadResponse, err := client.Do(downloadRequest)
if err != nil {
archive.Close()
return "", fmt.Errorf("download portable Git: %w", err)
}
if downloadResponse.StatusCode != http.StatusOK {
downloadResponse.Body.Close()
archive.Close()
return "", fmt.Errorf("download portable Git: GitHub returned %s", downloadResponse.Status)
}
_, copyErr := io.Copy(archive, io.LimitReader(downloadResponse.Body, 300<<20))
responseErr := downloadResponse.Body.Close()
closeErr := archive.Close()
if copyErr != nil {
return "", fmt.Errorf("save portable Git: %w", copyErr)
}
if responseErr != nil {
return "", fmt.Errorf("finish portable Git download: %w", responseErr)
}
if closeErr != nil {
return "", fmt.Errorf("save portable Git: %w", closeErr)
}
stagingDir, err := os.MkdirTemp(filepath.Dir(minGitDirectory), "mingit-install-*")
if err != nil {
return "", fmt.Errorf("create portable Git staging directory: %w", err)
}
defer os.RemoveAll(stagingDir)
if err := extractMinGit(archivePath, stagingDir); err != nil {
return "", err
}
if _, err := os.Stat(filepath.Join(stagingDir, "cmd", "git.exe")); err != nil {
return "", fmt.Errorf("portable Git archive is missing cmd/git.exe")
}
if err := os.RemoveAll(minGitDirectory); err != nil {
return "", fmt.Errorf("replace incomplete portable Git setup: %w", err)
}
if err := os.Rename(stagingDir, minGitDirectory); err != nil {
return "", fmt.Errorf("install portable Git: %w", err)
}
return gitPath, nil
}
func gitCommand(gitPath string, args ...string) *exec.Cmd {
command := exec.Command(gitPath, args...)
if filepath.IsAbs(gitPath) && strings.EqualFold(filepath.Base(filepath.Dir(gitPath)), "cmd") {
root := filepath.Dir(filepath.Dir(gitPath))
command.Env = append(os.Environ(), "PATH="+filepath.Join(root, "mingw64", "bin")+";"+filepath.Join(root, "usr", "bin")+";"+os.Getenv("PATH"))
}
return command
}
var gitProgressPattern = regexp.MustCompile(`(?i)(receiving objects|resolving deltas):\s*(\d+)%`)
func runGitWithProgress(command *exec.Cmd, action string) ([]byte, error) {
stdout, err := command.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("read Git output: %w", err)
}
stderr, err := command.StderrPipe()
if err != nil {
return nil, fmt.Errorf("read Git progress: %w", err)
}
if err := command.Start(); err != nil {
return nil, err
}
var stdoutOutput bytes.Buffer
var stderrOutput bytes.Buffer
stdoutDone := make(chan error, 1)
go func() {
_, copyErr := io.Copy(&stdoutOutput, stdout)
stdoutDone <- copyErr
}()
application.Get().Event.Emit("download:server", map[string]interface{}{
"percent": "0",
"speed": "",
"message": action,
})
scanner := bufio.NewScanner(stderr)
scanner.Buffer(make([]byte, 4096), 1024*1024)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
stderrOutput.WriteString(line)
stderrOutput.WriteByte('\n')
match := gitProgressPattern.FindStringSubmatch(line)
if len(match) != 3 {
continue
}
phasePercent, parseErr := strconv.Atoi(match[2])
if parseErr != nil {
continue
}
percent := phasePercent * 90 / 100
if strings.EqualFold(match[1], "resolving deltas") {
percent = 90 + phasePercent/10
}
application.Get().Event.Emit("download:server", map[string]interface{}{
"percent": fmt.Sprintf("%d", percent),
"speed": "",
"message": action + " · " + strings.ToLower(match[1]),
})
}
if err := scanner.Err(); err != nil {
command.Process.Kill()
_ = command.Wait()
<-stdoutDone
return append(stdoutOutput.Bytes(), stderrOutput.Bytes()...), fmt.Errorf("read Git progress: %w", err)
}
stdoutErr := <-stdoutDone
waitErr := command.Wait()
if stdoutErr != nil {
return append(stdoutOutput.Bytes(), stderrOutput.Bytes()...), fmt.Errorf("read Git output: %w", stdoutErr)
}
if waitErr != nil {
return append(stdoutOutput.Bytes(), stderrOutput.Bytes()...), waitErr
}
application.Get().Event.Emit("download:server", map[string]interface{}{
"percent": "100",
"speed": "",
"message": action + " · complete",
})
return append(stdoutOutput.Bytes(), stderrOutput.Bytes()...), nil
}
func extractMinGit(archivePath, destination string) error {
reader, err := zip.OpenReader(archivePath)
if err != nil {
return fmt.Errorf("open portable Git archive: %w", err)
}
defer reader.Close()
root, err := filepath.Abs(destination)
if err != nil {
return fmt.Errorf("resolve portable Git staging directory: %w", err)
}
for _, file := range reader.File {
name := filepath.Clean(filepath.FromSlash(file.Name))
if filepath.IsAbs(name) || name == "." || name == ".." || strings.HasPrefix(name, ".."+string(filepath.Separator)) {
return fmt.Errorf("portable Git archive contains an unsafe path")
}
path := filepath.Join(root, name)
if !strings.HasPrefix(path, root+string(filepath.Separator)) {
return fmt.Errorf("portable Git archive contains an unsafe path")
}
if file.FileInfo().IsDir() {
if err := os.MkdirAll(path, 0755); err != nil {
return fmt.Errorf("extract portable Git directory: %w", err)
}
continue
}
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("create portable Git directory: %w", err)
}
source, err := file.Open()
if err != nil {
return fmt.Errorf("read portable Git archive: %w", err)
}
output, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
source.Close()
return fmt.Errorf("extract portable Git file: %w", err)
}
_, copyErr := io.Copy(output, source)
sourceErr := source.Close()
outputErr := output.Close()
if copyErr != nil {
return fmt.Errorf("extract portable Git file: %w", copyErr)
}
if sourceErr != nil {
return fmt.Errorf("read portable Git archive: %w", sourceErr)
}
if outputErr != nil {
return fmt.Errorf("write portable Git file: %w", outputErr)
}
}
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package gitService
import (
"encoding/json"
"g-launcher/pkg/constant"
"g-launcher/pkg/models"
"io"
"net/http"
"github.com/minio/selfupdate"
)
type GitService struct{}
func (g *GitService) GetLatestLauncherVersion() (bool, string, string) {
resp, err := http.Get(constant.LauncherGitUrl)
if err != nil {
return false, "", err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []models.ReleaseType
err = json.Unmarshal(body, &releases)
if err != nil {
return false, "", err.Error()
}
if len(releases) == 0 {
return false, "", "no releases found"
}
return true, releases[0].TagName, ""
}
func (g *GitService) UpdateLauncherProgress(version string) (bool, string) {
asset, ok := g.getReleaseAsset(version, constant.LauncherGitUrl, constant.LauncherFile)
if !ok {
return false, "no release found"
}
resp, err := http.Get(asset.BrowserDownloadURL)
if err != nil {
return false, err.Error()
}
defer resp.Body.Close()
err = selfupdate.Apply(resp.Body, selfupdate.Options{})
if err != nil {
return false, err.Error()
}
return true, ""
}
+67
View File
@@ -0,0 +1,67 @@
package gitService
import (
"encoding/json"
"g-launcher/pkg/constant"
"g-launcher/pkg/models"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
func (g *GitService) GetLatestProxyVersion() (bool, string, string) {
resp, err := http.Get(constant.ProxyGitUrl)
if err != nil {
return false, "", err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []models.ReleaseType
err = json.Unmarshal(body, &releases)
if err != nil {
return false, "", err.Error()
}
if len(releases) == 0 {
return false, "", "no releases found"
}
return true, releases[0].TagName, ""
}
func (g *GitService) DownloadProxyProgress(version string) (bool, string) {
asset, ok := g.getReleaseAsset(version, constant.ProxyGitUrl, constant.ProxyFile)
if !ok {
return false, "no release found"
}
if err := os.MkdirAll(constant.ProxyStorageUrl, 0755); err != nil {
return false, err.Error()
}
saveFile := filepath.Join(constant.ProxyStorageUrl, asset.Name)
tmpPath, err := g.downloadFileParallel(saveFile, asset.BrowserDownloadURL, 4, func(percent float64, speed string) {
application.Get().Event.Emit("download:proxy", map[string]interface{}{
"percent": fmt.Sprintf("%.2f", percent),
"speed": speed,
})
})
if err != nil {
return false, err.Error()
}
for i := 0; i < 3; i++ {
if err := os.Rename(tmpPath, saveFile); err == nil {
return true, ""
}
time.Sleep(300 * time.Millisecond)
}
return false, "failed to rename tmp file after retries"
}
+71
View File
@@ -0,0 +1,71 @@
package gitService
import (
"encoding/json"
"g-launcher/pkg/constant"
"g-launcher/pkg/models"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"time"
"github.com/wailsapp/wails/v3/pkg/application"
)
func (g *GitService) GetLatestServerVersion() (bool, string, string) {
resp, err := http.Get(constant.ServerGitUrl)
if err != nil {
return false, "", err.Error()
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []models.ReleaseType
err = json.Unmarshal(body, &releases)
if err != nil {
return false, "", err.Error()
}
if len(releases) == 0 {
return false, "", "no releases found"
}
return true, releases[0].TagName, ""
}
func (g *GitService) DownloadServerProgress(version string) (bool, string) {
asset, ok := g.getReleaseAsset(version, constant.ServerGitUrl, constant.ServerZipFile)
if !ok {
return false, "no release found"
}
if err := os.MkdirAll(constant.ServerStorageUrl, 0755); err != nil {
return false, err.Error()
}
saveFile := filepath.Join(constant.ServerStorageUrl, asset.Name)
tmpPath, err := g.downloadFileParallel(saveFile, asset.BrowserDownloadURL, 4, func(percent float64, speed string) {
application.Get().Event.Emit("download:server", map[string]interface{}{
"percent": fmt.Sprintf("%.2f", percent),
"speed": speed,
})
})
if err != nil {
return false, err.Error()
}
for i := 0; i < 3; i++ {
if err := os.Rename(tmpPath, saveFile); err == nil {
return true, ""
}
time.Sleep(300 * time.Millisecond)
}
return false, "failed to rename tmp file after retries"
}
func (g *GitService) UnzipServer() {
g.unzipParallel(filepath.Join(constant.ServerStorageUrl, constant.ServerZipFile), constant.ServerStorageUrl)
os.Remove(filepath.Join(constant.ServerStorageUrl, constant.ServerZipFile))
}
+251
View File
@@ -0,0 +1,251 @@
package gitService
import (
"archive/zip"
"encoding/json"
"g-launcher/pkg/models"
"fmt"
"io"
"math"
"net/http"
"os"
"path/filepath"
"runtime"
"sync"
"time"
)
func HumanFormat(bytes float64) string {
if math.IsNaN(bytes) || math.IsInf(bytes, 0) {
return fmt.Sprintf("%v", bytes)
}
if bytes == 0 {
return "0B"
}
neg := bytes < 0
if neg {
bytes = -bytes
}
units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"}
i := 0
for bytes >= 1024 && i < len(units)-1 {
bytes /= 1024
i++
}
res := fmt.Sprintf("%.1f%s", bytes, units[i])
if neg {
res = "-" + res
}
return res
}
type WriteCounter struct {
Total uint64
StartTime time.Time
OnEmit func(percent float64, speed string)
TotalSize int64
mu sync.Mutex
}
func NewWriteCounter(total int64, onEmit func(percent float64, speed string)) *WriteCounter {
return &WriteCounter{
StartTime: time.Now(),
TotalSize: total,
OnEmit: onEmit,
}
}
func (wc *WriteCounter) Add(n int) {
wc.mu.Lock()
defer wc.mu.Unlock()
wc.Total += uint64(n)
wc.PrintProgress()
}
func (wc *WriteCounter) PrintProgress() {
elapsed := time.Since(wc.StartTime).Seconds()
if elapsed < 0.001 {
elapsed = 0.001
}
speed := float64(wc.Total) / elapsed
percent := float64(wc.Total) / float64(wc.TotalSize) * 100
if wc.OnEmit != nil {
wc.OnEmit(percent, fmt.Sprintf("%s/s", HumanFormat(speed)))
}
}
func (g *GitService) downloadFileParallel(filePath, url string, numParts int, onEmit func(percent float64, speed string)) (tmpPath string, err error) {
resp, err := http.Head(url)
if err != nil {
return "", fmt.Errorf("failed to get head: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("bad status: %s", resp.Status)
}
size := resp.ContentLength
tmpPath = filePath + ".tmp"
out, err := os.Create(tmpPath)
if err != nil {
return "", fmt.Errorf("failed to create tmp file: %w", err)
}
defer out.Close()
counter := NewWriteCounter(size, onEmit)
partSize := size / int64(numParts)
var wg sync.WaitGroup
var mu sync.Mutex
for i := 0; i < numParts; i++ {
start := int64(i) * partSize
end := start + partSize - 1
if i == numParts-1 {
end = size - 1
}
_ = start
_ = end
wg.Go(func() {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end))
resp, err := http.DefaultClient.Do(req)
if err != nil {
return
}
defer resp.Body.Close()
buf := make([]byte, 32*1024)
var written int64
for {
n, err := resp.Body.Read(buf)
if n > 0 {
mu.Lock()
out.Seek(start+written, 0)
out.Write(buf[:n])
mu.Unlock()
written += int64(n)
counter.Add(n)
}
if err == io.EOF {
break
}
if err != nil {
break
}
}
})
}
wg.Wait()
return tmpPath, nil
}
// --- Helper getReleaseAsset ---
func (g *GitService) getReleaseAsset(version, url, fileName string) (models.AssetType, bool) {
resp, err := http.Get(url)
if err != nil {
return models.AssetType{}, false
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
var releases []*models.ReleaseType
if err := json.Unmarshal(body, &releases); err != nil || len(releases) == 0 {
return models.AssetType{}, false
}
for _, release := range releases {
if release.TagName == version {
for _, asset := range release.Assets {
if asset.Name == fileName {
return asset, true
}
}
}
}
return models.AssetType{}, false
}
func (g *GitService) unzipParallel(src string, dest string) error {
numCPU := runtime.NumCPU()
reserved := 1
if numCPU > 4 {
reserved = 2
}
maxWorkers := numCPU - reserved
if maxWorkers < 1 {
maxWorkers = 1
}
r, err := zip.OpenReader(src)
if err != nil {
return err
}
defer r.Close()
err = os.MkdirAll(dest, 0755)
if err != nil {
return err
}
type job struct {
f *zip.File
}
jobs := make(chan job)
var wg sync.WaitGroup
for i := 0; i < maxWorkers; i++ {
wg.Go(func() {
for j := range jobs {
err := g.extractFile(j.f, dest)
if err != nil {
fmt.Printf("Error extracting %s: %v\n", j.f.Name, err)
}
}
})
}
for _, f := range r.File {
jobs <- job{f}
}
close(jobs)
wg.Wait()
return nil
}
func (g *GitService) extractFile(f *zip.File, dest string) error {
fp := filepath.Join(dest, f.Name)
if f.FileInfo().IsDir() {
return os.MkdirAll(fp, f.Mode())
}
err := os.MkdirAll(filepath.Dir(fp), 0755)
if err != nil {
return err
}
rc, err := f.Open()
if err != nil {
return err
}
defer rc.Close()
out, err := os.OpenFile(fp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, rc)
return err
}
+175
View File
@@ -0,0 +1,175 @@
package languageService
import (
"bytes"
assetMeta "g-launcher/pkg/language-patch/asset-meta"
excelLanguage "g-launcher/pkg/language-patch/excel-language"
"g-launcher/pkg/models"
"os"
"path/filepath"
"slices"
"strings"
)
type LanguageService struct{}
func isValidLang(lang string) bool {
valid := []string{"en", "jp", "cn", "kr"}
return slices.Contains(valid, lang)
}
func (l *LanguageService) GetLanguage(path string) (bool, string, string, string) {
currentVersionGame, err := models.ParseBinaryVersion(filepath.Join(path, "BinaryVersion.bytes"))
if err != nil {
return false, "", "", err.Error()
}
typeVersionGame := "os"
if strings.Contains(currentVersionGame.Name, "CN") {
typeVersionGame = "cn"
}
assetPath := filepath.Join(path, "DesignData\\Windows")
indexHash, err := assetMeta.GetIndexHash(assetPath)
if err != nil {
return false, "", "", err.Error()
}
DesignIndex, err := assetMeta.DesignIndexFromBytes(assetPath, indexHash)
if err != nil {
return false, "", "", err.Error()
}
dataEntry, fileEntry, err := DesignIndex.FindDataAndFileByTarget(-515329346)
if err != nil {
return false, "", "", err.Error()
}
allowedLanguage := excelLanguage.NewExcelLanguage(assetPath, &dataEntry, &fileEntry)
languageRows, err := allowedLanguage.Parse()
if err != nil {
return false, "", "", err.Error()
}
currentTextLang := ""
currentVoiceLang := ""
pairs := []struct {
area string
typ *uint8
}{
{"os", nil},
{"cn", func() *uint8 { v := uint8(1); return &v }()},
{"os", func() *uint8 { v := uint8(1); return &v }()},
{"cn", nil},
}
for _, p := range pairs {
var found *excelLanguage.LanguageRow
for i := range languageRows {
if languageRows[i].Area != nil && *languageRows[i].Area == p.area {
if (languageRows[i].Type == nil && p.typ == nil) ||
(languageRows[i].Type != nil && p.typ != nil && *languageRows[i].Type == *p.typ) {
found = &languageRows[i]
break
}
}
}
if found == nil {
continue
}
if found.DefaultLanguage != nil && found.Area != nil && *found.Area == typeVersionGame && found.Type == nil {
currentTextLang = *found.DefaultLanguage
}
if found.DefaultLanguage != nil && found.Area != nil && *found.Area == typeVersionGame && found.Type != nil {
currentVoiceLang = *found.DefaultLanguage
}
}
if currentTextLang == "" || currentVoiceLang == "" || !isValidLang(currentTextLang) || !isValidLang(currentVoiceLang) {
return false, "", "", "not found language"
}
return true, currentTextLang, currentVoiceLang, ""
}
func (l *LanguageService) SetLanguage(path string, text, voice string) (bool, string) {
indexHash, err := assetMeta.GetIndexHash(path)
if err != nil {
return false, err.Error()
}
DesignIndex, err := assetMeta.DesignIndexFromBytes(path, indexHash)
if err != nil {
return false, err.Error()
}
dataEntry, fileEntry, err := DesignIndex.FindDataAndFileByTarget(-515329346)
if err != nil {
return false, err.Error()
}
allowedLanguage := excelLanguage.NewExcelLanguage(path, &dataEntry, &fileEntry)
languageRows, err := allowedLanguage.Parse()
if err != nil {
return false, err.Error()
}
pairs := []struct {
area string
typ *uint8
lang string
}{
{"os", nil, text},
{"cn", func() *uint8 { v := uint8(1); return &v }(), voice},
{"os", func() *uint8 { v := uint8(1); return &v }(), voice},
{"cn", nil, text},
}
for _, p := range pairs {
var found *excelLanguage.LanguageRow
for i := range languageRows {
if languageRows[i].Area != nil && *languageRows[i].Area == p.area {
if (languageRows[i].Type == nil && p.typ == nil) ||
(languageRows[i].Type != nil && p.typ != nil && *languageRows[i].Type == *p.typ) {
found = &languageRows[i]
break
}
}
}
if found == nil {
continue
}
found.DefaultLanguage = &p.lang
found.LanguageList = []string{p.lang}
}
data, err := allowedLanguage.Unmarshal(languageRows)
if err != nil {
return false, err.Error()
}
filePath := filepath.Join(path, fileEntry.FileByteName+".bytes")
f, err := os.OpenFile(filePath, os.O_RDWR, 0644)
if err != nil {
return false, err.Error()
}
defer f.Close()
if _, err := f.Seek(int64(dataEntry.Offset), 0); err != nil {
return false, err.Error()
}
if _, err := f.Write(data); err != nil {
return false, err.Error()
}
if len(data) < int(dataEntry.Size) {
remaining := int(dataEntry.Size) - len(data)
zeros := bytes.Repeat([]byte{0}, remaining)
if _, err := f.Write(zeros); err != nil {
return false, err.Error()
}
}
return true, "success"
}
+251
View File
@@ -0,0 +1,251 @@
package languageService
import (
"bytes"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
)
const (
betaPersistentContainer = "79568e2a754e3eab8afe4b5bf96c27ab.bytes"
betaPersistentContainerCopy = "e303f4dc6c21f043021bb5b3b875b6c3.bytes"
betaStreamingContainer = "4703a1942cfa452f451ba0a2f826b764.bytes"
betaStreamingContainerCopy = "44b594f1fab938d6ed95d6a1154e03c0.bytes"
betaPersistentDesignV = "878d1fefee5f49221b003050cb4a2d2f"
betaPersistentDesignVCopy = "74b62fb914b36cccaa10d93ace9af2c0"
betaStreamingDesignV = "b5eefe4a429b498a8f5003f28a4b21c6"
betaStreamingDesignVCopy = "3ada95b84a1e0d96404ea5e7414ab353"
betaPersistentContainerLimit = 26240102
betaStreamingContainerLimit = 73631
)
func (l *LanguageService) InstallThaiPatch(gameRoot, packageRoot string) (bool, string) {
gameRoot, err := filepath.Abs(filepath.Clean(gameRoot))
if err != nil {
return false, fmt.Errorf("resolve game folder: %w", err).Error()
}
packageRoot, err = filepath.Abs(filepath.Clean(packageRoot))
if err != nil {
return false, fmt.Errorf("resolve patch package folder: %w", err).Error()
}
if _, err := os.Stat(filepath.Join(gameRoot, "StarRail.exe")); err != nil {
return false, "selected folder does not contain StarRail.exe"
}
streamingAssets := filepath.Join(gameRoot, "StarRail_Data", "StreamingAssets")
binaryVersion, err := os.ReadFile(filepath.Join(streamingAssets, "BinaryVersion.bytes"))
if err != nil {
return false, fmt.Errorf("read game version: %w", err).Error()
}
if !bytes.Contains(binaryVersion, []byte("BETAWin")) {
return false, "this Thai patch package only supports Honkai: Star Rail Beta"
}
assets := filepath.Join(packageRoot, "assets")
readAsset := func(relative string) ([]byte, error) {
data, err := os.ReadFile(filepath.Join(assets, filepath.FromSlash(relative)))
if err != nil {
return nil, fmt.Errorf("read patch asset %s: %w", relative, err)
}
return data, nil
}
requireAsset := func(relative string) ([]byte, error) {
data, err := readAsset(relative)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, fmt.Errorf("patch asset %s is empty", relative)
}
return data, nil
}
textMap, err := requireAsset("1_TextMap_th/1fb7b1dce8d66efe8e111bb07a3eef44.bytes")
if err != nil {
return false, err.Error()
}
persistentUI, err := requireAsset("2_UI_Persistent/fd5227547d65b8bb1238feb3e5820fa3.bytes")
if err != nil {
return false, err.Error()
}
streamingUI, err := requireAsset("3_UI_StreamingAssets/2ea44f7fabf315d35de1d7f3b4c07d89.bytes")
if err != nil {
return false, err.Error()
}
localePack, err := requireAsset("4_Locale_Pak/th.pak")
if err != nil {
return false, err.Error()
}
allowedLanguages, err := requireAsset("5_AllowedLanguage/allowed_language_140bytes.bin")
if err != nil {
return false, err.Error()
}
persistentDesign, err := requireAsset("prebuilt_designv/DesignV_" + betaPersistentDesignV + ".bytes")
if err != nil {
return false, err.Error()
}
streamingDesign, err := requireAsset("prebuilt_designv/DesignV_" + betaStreamingDesignV + ".bytes")
if err != nil {
return false, err.Error()
}
dataRoot := filepath.Join(gameRoot, "StarRail_Data")
persistentDesignDir := filepath.Join(dataRoot, "Persistent", "DesignData", "Windows")
streamingDesignDir := filepath.Join(dataRoot, "StreamingAssets", "DesignData", "Windows")
for _, dir := range []string{
filepath.Join(dataRoot, "Persistent", "Asb", "Windows"),
filepath.Join(dataRoot, "Persistent", "Audio", "AudioPackage", "Windows"),
persistentDesignDir,
filepath.Join(dataRoot, "StreamingAssets", "Asb", "Windows"),
streamingDesignDir,
} {
if err := os.MkdirAll(dir, 0755); err != nil {
return false, fmt.Errorf("create patch folder %s: %w", dir, err).Error()
}
}
type fileWrite struct {
path string
data []byte
}
writes := []fileWrite{
{filepath.Join(dataRoot, "Persistent", "Asb", "Windows", "1fb7b1dce8d66efe8e111bb07a3eef44.bytes"), textMap},
{filepath.Join(dataRoot, "Persistent", "Asb", "Windows", "fd5227547d65b8bb1238feb3e5820fa3.bytes"), persistentUI},
{filepath.Join(dataRoot, "StreamingAssets", "Asb", "Windows", "2ea44f7fabf315d35de1d7f3b4c07d89.bytes"), streamingUI},
{filepath.Join(dataRoot, "Persistent", "Audio", "AudioPackage", "Windows", "th.pak"), localePack},
{filepath.Join(persistentDesignDir, "DesignV_"+betaPersistentDesignV+".bytes"), persistentDesign},
{filepath.Join(persistentDesignDir, "DesignV_"+betaPersistentDesignVCopy+".bytes"), persistentDesign},
{filepath.Join(streamingDesignDir, "DesignV_"+betaStreamingDesignV+".bytes"), streamingDesign},
{filepath.Join(streamingDesignDir, "DesignV_"+betaStreamingDesignVCopy+".bytes"), streamingDesign},
}
for _, write := range writes {
if err := os.WriteFile(write.path, write.data, 0644); err != nil {
return false, fmt.Errorf("save patch file %s: %w", write.path, err).Error()
}
}
if err := patchContainerFile(filepath.Join(persistentDesignDir, betaPersistentContainer), persistentDesign, betaPersistentContainerLimit); err != nil {
return false, err.Error()
}
if err := patchContainerFile(filepath.Join(persistentDesignDir, betaPersistentContainerCopy), persistentDesign, betaPersistentContainerLimit); err != nil {
return false, err.Error()
}
if err := patchContainerFile(filepath.Join(streamingDesignDir, betaStreamingContainer), streamingDesign, betaStreamingContainerLimit); err != nil {
return false, err.Error()
}
if err := patchContainerFile(filepath.Join(streamingDesignDir, betaStreamingContainerCopy), streamingDesign, betaStreamingContainerLimit); err != nil {
return false, err.Error()
}
if err := patchAllowedLanguages(filepath.Join(dataRoot, "Persistent", "AllowedLanguage.bytes"), allowedLanguages); err != nil {
return false, err.Error()
}
return true, ""
}
func patchContainerFile(path string, targetPayload []byte, sizeLimit int64) error {
data, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil {
return fmt.Errorf("read container %s: %w", path, err)
}
if int64(len(data)) > sizeLimit {
return fmt.Errorf("container file is unexpectedly large: %s", path)
}
entries, err := parseDesignVContainer(data)
if err != nil {
return fmt.Errorf("parse container %s: %w", path, err)
}
for i := range entries {
entries[i].payload = append([]byte(nil), targetPayload...)
}
rebuilt, err := buildDesignVContainer(entries)
if err != nil {
return fmt.Errorf("rebuild container %s: %w", path, err)
}
if err := os.WriteFile(path, rebuilt, 0644); err != nil {
return fmt.Errorf("save container %s: %w", path, err)
}
return nil
}
type containerEntry struct {
hash string
payload []byte
}
func parseDesignVContainer(data []byte) ([]containerEntry, error) {
if len(data) < 4 {
return nil, fmt.Errorf("container is too short")
}
reader := bytes.NewReader(data)
var count uint32
if err := binary.Read(reader, binary.LittleEndian, &count); err != nil {
return nil, err
}
entries := make([]containerEntry, 0, count)
for i := uint32(0); i < count; i++ {
var hashBytes [16]byte
if _, err := io.ReadFull(reader, hashBytes[:]); err != nil {
return nil, err
}
var length uint32
if err := binary.Read(reader, binary.LittleEndian, &length); err != nil {
return nil, err
}
payload := make([]byte, length)
if _, err := io.ReadFull(reader, payload); err != nil {
return nil, err
}
entries = append(entries, containerEntry{
hash: hex.EncodeToString(hashBytes[:]),
payload: payload,
})
}
return entries, nil
}
func buildDesignVContainer(entries []containerEntry) ([]byte, error) {
sort.Slice(entries, func(i, j int) bool {
return strings.ToLower(entries[i].hash) < strings.ToLower(entries[j].hash)
})
var buffer bytes.Buffer
if err := binary.Write(&buffer, binary.LittleEndian, uint32(len(entries))); err != nil {
return nil, err
}
for _, entry := range entries {
rawHash, err := hex.DecodeString(entry.hash)
if err != nil || len(rawHash) != 16 {
return nil, fmt.Errorf("invalid hash in container entry: %s", entry.hash)
}
if _, err := buffer.Write(rawHash); err != nil {
return nil, err
}
if err := binary.Write(&buffer, binary.LittleEndian, uint32(len(entry.payload))); err != nil {
return nil, err
}
if _, err := buffer.Write(entry.payload); err != nil {
return nil, err
}
}
return buffer.Bytes(), nil
}
func patchAllowedLanguages(path string, payload []byte) error {
return os.WriteFile(path, payload, 0644)
}