UPDATE: update new language patch

This commit is contained in:
2025-08-21 21:44:16 +07:00
parent ba58d24e06
commit 88c5424e52
13 changed files with 727 additions and 178 deletions

View File

@@ -8,7 +8,7 @@ import {Call as $Call, Create as $Create} from "@wailsio/runtime";
/**
* @param {string} path
* @returns {Promise<[string, string]> & { cancel(): void }}
* @returns {Promise<[boolean, string, string, string]> & { cancel(): void }}
*/
export function GetLanguage(path) {
let $resultPromise = /** @type {any} */($Call.ByID(3450750492, path));
@@ -19,7 +19,7 @@ export function GetLanguage(path) {
* @param {string} path
* @param {string} text
* @param {string} voice
* @returns {Promise<boolean> & { cancel(): void }}
* @returns {Promise<[boolean, string]> & { cancel(): void }}
*/
export function SetLanguage(path, text, voice) {
let $resultPromise = /** @type {any} */($Call.ByID(2793672496, path, text, voice));

View File

@@ -27,28 +27,42 @@ export default function LanguagePage() {
useEffect(() => {
const getLanguage = async () => {
if (gameDir) {
const subPath = 'StarRail_Data/StreamingAssets/DesignData/Windows'
const fullPath = `${gameDir}/${subPath}`
if (!gameDir) return
const exists = await FSService.DirExists(fullPath)
if (exists) {
const [textLang, voiceLang] = await LanguageService.GetLanguage(fullPath)
setTextLang(textLang)
setVoiceLang(voiceLang)
setFolderCheckResult('success')
setSelectedTextLang(textLang)
setSelectedVoiceLang(voiceLang)
} else {
setTextLang('')
setVoiceLang('')
setSelectedTextLang('')
setSelectedVoiceLang('')
setFolderCheckResult('error')
setGameDir('')
}
const subPath = "StarRail_Data/StreamingAssets"
const fullPath = `${gameDir}/${subPath}`
const exists = await FSService.DirExists(fullPath)
if (!exists) {
setTextLang("")
setVoiceLang("")
setSelectedTextLang("")
setSelectedVoiceLang("")
setFolderCheckResult("error")
setGameDir("")
return
}
const [ok, textLang, voiceLang, err] = await LanguageService.GetLanguage(fullPath)
if (!ok) {
setTextLang("")
setVoiceLang("")
setSelectedTextLang("")
setSelectedVoiceLang("")
setFolderCheckResult("error")
setGameDir("")
toast.error(err)
return
}
// success
setTextLang(textLang)
setVoiceLang(voiceLang)
setFolderCheckResult("success")
setSelectedTextLang(textLang)
setSelectedVoiceLang(voiceLang)
}
getLanguage()
}, [gameDir])
@@ -86,19 +100,19 @@ export default function LanguagePage() {
}
try {
setIsSettingLanguage(true)
const result = await LanguageService.SetLanguage(
const [ok, err] = await LanguageService.SetLanguage(
`${gameDir}/StarRail_Data/StreamingAssets/DesignData/Windows`,
selectedTextLang,
selectedVoiceLang
)
if (result) {
toast.success('Language set successfully')
setTextLang(selectedTextLang)
setVoiceLang(selectedVoiceLang)
}
else {
toast.error('Language set failed')
}
if (ok) {
toast.success('Language set successfully')
setTextLang(selectedTextLang)
setVoiceLang(selectedVoiceLang)
}
else {
toast.error(err)
}
} catch (err: any) {
toast.error('SetLanguage error:', err)
@@ -154,8 +168,8 @@ export default function LanguagePage() {
</div>
{folderCheckResult && (
<div className={`flex items-center gap-2 p-3 rounded-lg ${folderCheckResult === 'success'
? 'bg-success/5 text-success border border-success'
: 'bg-error/5 text-error border border-error'
? 'bg-success/5 text-success border border-success'
: 'bg-error/5 text-error border border-error'
}`}>
{folderCheckResult === 'success' ? (
<>

View File

@@ -2,145 +2,174 @@ package internal
import (
"bytes"
"fmt"
assetMeta "firefly-launcher/pkg/language-patch/asset-meta"
excelLanguage "firefly-launcher/pkg/language-patch/excel-language"
"firefly-launcher/pkg/models"
"os"
"path/filepath"
"slices"
"strings"
)
type LanguageService struct{}
func isValidLang(lang string) bool {
valid := []string{"en", "jp", "cn", "kr"}
for _, v := range valid {
if lang == v {
return true
}
}
return false
return slices.Contains(valid, lang)
}
func (l *LanguageService) GetLanguage(path string) (string, string, error) {
files, err := os.ReadDir(path)
func (l *LanguageService) GetLanguage(path string) (bool, string, string, string) {
currentVersionGame, err := models.ParseBinaryVersion(filepath.Join(path, "BinaryVersion.bytes"))
if err != nil {
return "", "", err
return false, "", "", err.Error()
}
for _, file := range files {
filePath := filepath.Join(path, file.Name())
content, err := os.ReadFile(filePath)
if err != nil {
continue
}
patternToFind := []byte("SpriteOutput/UI/Fonts/RPG_CN.ttf")
idx := bytes.Index(content, patternToFind)
if idx == -1 {
continue
}
pattern := []byte("Korean")
idx = bytes.Index(content, pattern)
if idx == -1 {
continue
}
// Move to os text language
idx += 10
idx += 4
osText := string(content[idx : idx+2])
idx += 3 * 4
// Move to cn voice language
idx += 1
idx += 5
cnVoice := string(content[idx : idx+2])
idx += 3 * 2 // skip 2 entries
// Move to os voice language
idx += 1
idx += 5
osVoice := string(content[idx : idx+2])
idx += 3 * 5 // skip 5 entries
// Move to cn text language
idx += 1
idx += 4
cnText := string(content[idx : idx+2])
textLang := osText
voiceLang := osVoice
if !isValidLang(textLang) {
textLang = cnText
}
if !isValidLang(voiceLang) {
voiceLang = cnVoice
}
return textLang, voiceLang, nil
typeVersionGame := "os"
if strings.Contains(currentVersionGame.Name, "CN") {
typeVersionGame = "cn"
}
return "", "", fmt.Errorf("couldn't find file to read language from")
}
assetPath := filepath.Join(path, "DesignData\\Windows")
func replaceBytes(content []byte, idx int, choice string, param int) int {
for i := 0; i < param; i++ {
copy(content[idx:idx+2], []byte(choice))
idx += 3
}
return idx
}
func (l *LanguageService) SetLanguage(path string, text, voice string) (bool, error) {
files, err := os.ReadDir(path)
indexHash, err := assetMeta.GetIndexHash(assetPath)
if err != nil {
return false, err
return false, "", "", err.Error()
}
for _, file := range files {
filePath := filepath.Join(path, file.Name())
content, err := os.ReadFile(filePath)
if err != nil {
continue
}
patternToFind := []byte("SpriteOutput/UI/Fonts/RPG_CN.ttf")
idx := bytes.Index(content, patternToFind)
if idx == -1 {
continue
}
pattern := []byte("Korean")
idx = bytes.Index(content, pattern)
if idx == -1 {
continue
}
idx += 10
idx += 4
idx = replaceBytes(content, idx, text, 4)
idx += 1
idx += 5
idx = replaceBytes(content, idx, voice, 2)
idx += 1
idx += 5
idx = replaceBytes(content, idx, voice, 5)
idx += 1
idx += 4
_ = replaceBytes(content, idx, text, 2)
err = os.WriteFile(filePath, content, 0644)
if err != nil {
return false, err
}
return true, nil
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()
}
return false, fmt.Errorf("couldn't find file to patch. Make sure this file is placed in the correct folder")
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"
}

View File

@@ -11,7 +11,7 @@ const LauncherFile = "firefly-launcher.exe"
const TempUrl = "./temp"
const CurrentLauncherVersion = "1.3.0"
const CurrentLauncherVersion = "1.4.0"
type ToolFile string

View File

@@ -0,0 +1,30 @@
package assetMeta
import (
"fmt"
"io"
)
type ByteHash16 []byte
func ByteHash16FromBytes(r io.ReadSeeker) (ByteHash16, error) {
fullHash := make([]byte, 16)
buf := make([]byte, 4)
for i := 0; i < 4; i++ {
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
for j := 0; j < 4; j++ {
fullHash[i*4+j] = buf[3-j]
}
}
return ByteHash16(fullHash), nil
}
func (b ByteHash16) String() string {
s := ""
for _, v := range b {
s += fmt.Sprintf("%02x", v)
}
return s
}

View File

@@ -0,0 +1,28 @@
package assetMeta
import (
"encoding/binary"
"io"
)
type DataEntry struct {
NameHash int32
Size uint32
Offset uint32
}
func DataEntryFromBytes(r io.Reader) (*DataEntry, error) {
var d DataEntry
if err := binary.Read(r, binary.BigEndian, &d.NameHash); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &d.Size); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &d.Offset); err != nil {
return nil, err
}
return &d, nil
}

View File

@@ -0,0 +1,98 @@
package assetMeta
import (
"bytes"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
)
type DesignIndex struct {
UnkI64 int64
FileCount int32
DesignDataCount int32
FileList []FileEntry
}
func (d *DesignIndex) FindDataAndFileByTarget(target int32) (DataEntry, FileEntry, error) {
for _, file := range d.FileList {
for _, entry := range file.DataEntries {
if entry.NameHash == target {
return entry, file, nil
}
}
}
return DataEntry{}, FileEntry{}, errors.New("not found")
}
func DesignIndexFromBytes(assetFolder string, indexHash string) (*DesignIndex, error) {
path := filepath.Join(assetFolder, fmt.Sprintf("DesignV_%s.bytes", indexHash))
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
r := bytes.NewReader(data)
var d DesignIndex
if err := binary.Read(r, binary.BigEndian, &d.UnkI64); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &d.FileCount); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &d.DesignDataCount); err != nil {
return nil, err
}
d.FileList = make([]FileEntry, 0, d.FileCount)
for i := int32(0); i < d.FileCount; i++ {
entry, err := FileEntryFromBytes(r)
if err != nil {
return nil, err
}
d.FileList = append(d.FileList, *entry)
}
return &d, nil
}
func GetIndexHash(assetFolder string) (string, error) {
path := filepath.Join(assetFolder, "M_DesignV.bytes")
f, err := os.Open(path)
if err != nil {
return "", err
}
defer f.Close()
_, err = f.Seek(0x1C, 0)
if err != nil {
return "", err
}
hash := make([]byte, 0x10)
index := 0
for i := 0; i < 4; i++ {
chunk := make([]byte, 4)
_, err := f.Read(chunk)
if err != nil {
return "", err
}
for bytePos := 3; bytePos >= 0; bytePos-- {
hash[index] = chunk[bytePos]
index++
}
}
return hex.EncodeToString(hash), nil
}

View File

@@ -0,0 +1,63 @@
package assetMeta
import (
"encoding/binary"
"fmt"
"io"
)
type FileEntry struct {
NameHash int32
FileByteName string
Size int64
DataCount int32
DataEntries []DataEntry
Unk uint8
}
func FileEntryFromBytes(r io.Reader) (*FileEntry, error) {
var f FileEntry
if err := binary.Read(r, binary.BigEndian, &f.NameHash); err != nil {
return nil, err
}
buf := make([]byte, 16)
if _, err := io.ReadFull(r, buf); err != nil {
return nil, err
}
f.FileByteName = toHex(buf)
if err := binary.Read(r, binary.BigEndian, &f.Size); err != nil {
return nil, err
}
if err := binary.Read(r, binary.BigEndian, &f.DataCount); err != nil {
return nil, err
}
f.DataEntries = make([]DataEntry, 0, f.DataCount)
for i := int32(0); i < f.DataCount; i++ {
entry, err := DataEntryFromBytes(r)
if err != nil {
return nil, err
}
f.DataEntries = append(f.DataEntries, *entry)
}
// read 1 byte
b := make([]byte, 1)
if _, err := r.Read(b); err != nil {
return nil, err
}
f.Unk = b[0]
return &f, nil
}
func toHex(buf []byte) string {
s := ""
for _, b := range buf {
s += fmt.Sprintf("%02x", b)
}
return s
}

View File

@@ -0,0 +1,32 @@
package assetMeta
import (
"encoding/binary"
"io"
)
type MiniAsset struct {
RevisionID uint32
DesignIndexHash ByteHash16
}
func MiniAssetFromBytes(r io.ReadSeeker) (*MiniAsset, error) {
if _, err := r.Seek(6*4, io.SeekCurrent); err != nil {
return nil, err
}
var revID uint32
if err := binary.Read(r, binary.LittleEndian, &revID); err != nil {
return nil, err
}
hash, err := ByteHash16FromBytes(r)
if err != nil {
return nil, err
}
return &MiniAsset{
RevisionID: revID,
DesignIndexHash: hash,
}, nil
}

View File

@@ -0,0 +1,114 @@
package excelLanguage
import (
"bytes"
assetMeta "firefly-launcher/pkg/language-patch/asset-meta"
"io"
"os"
"path/filepath"
)
type ExcelLanguage struct {
AssetFolder string
ExcelDataEntry *assetMeta.DataEntry
ExcelFileEntry *assetMeta.FileEntry
}
func NewExcelLanguage(assetFolder string, dataEntry *assetMeta.DataEntry, fileEntry *assetMeta.FileEntry) *ExcelLanguage {
return &ExcelLanguage{
AssetFolder: assetFolder,
ExcelDataEntry: dataEntry,
ExcelFileEntry: fileEntry,
}
}
func (a *ExcelLanguage) Unmarshal(rows []LanguageRow) ([]byte, error) {
buf := new(bytes.Buffer)
buf.WriteByte(0)
if err := writeI8Varint(buf, int8(len(rows))); err != nil {
return nil, err
}
for _, row := range rows {
rowData, err := row.Unmarshal()
if err != nil {
return nil, err
}
if _, err := buf.Write(rowData); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}
func (a *ExcelLanguage) Parse() ([]LanguageRow, error) {
excelPath := filepath.Join(a.AssetFolder, a.ExcelFileEntry.FileByteName+".bytes")
f, err := os.Open(excelPath)
if err != nil {
return nil, err
}
defer f.Close()
if _, err := f.Seek(int64(a.ExcelDataEntry.Offset), io.SeekStart); err != nil {
return nil, err
}
buffer := make([]byte, a.ExcelDataEntry.Size)
if _, err := io.ReadFull(f, buffer); err != nil {
return nil, err
}
reader := bytes.NewReader(buffer)
_, _ = reader.ReadByte() // skip first byte
count, err := readI8Varint(reader)
if err != nil {
return nil, err
}
rows := make([]LanguageRow, 0, count)
for i := 0; i < count; i++ {
bitmask, err := reader.ReadByte()
if err != nil {
return nil, err
}
row := LanguageRow{}
if bitmask&(1<<0) != 0 {
s, err := readString(reader)
if err != nil {
return nil, err
}
row.Area = &s
}
if bitmask&(1<<1) != 0 {
t, err := reader.ReadByte()
if err != nil {
return nil, err
}
row.Type = &t
}
if bitmask&(1<<2) != 0 {
arr, err := readStringArray(reader)
if err != nil {
return nil, err
}
row.LanguageList = arr
}
if bitmask&(1<<3) != 0 {
s, err := readString(reader)
if err != nil {
return nil, err
}
row.DefaultLanguage = &s
}
rows = append(rows, row)
}
return rows, nil
}

View File

@@ -0,0 +1,81 @@
package excelLanguage
import (
"bytes"
"errors"
)
type LanguageRow struct {
Area *string
Type *uint8
LanguageList []string
DefaultLanguage *string
}
func (r *LanguageRow) Unmarshal() ([]byte, error) {
buf := new(bytes.Buffer)
var bitmask uint8
if r.Area != nil {
bitmask |= 1 << 0
}
if r.Type != nil {
bitmask |= 1 << 1
}
if len(r.LanguageList) > 0 {
bitmask |= 1 << 2
}
if r.DefaultLanguage != nil {
bitmask |= 1 << 3
}
if err := buf.WriteByte(bitmask); err != nil {
return nil, err
}
if r.Area != nil {
if err := writeString(buf, *r.Area); err != nil {
return nil, err
}
}
if r.Type != nil {
if err := buf.WriteByte(*r.Type); err != nil {
return nil, err
}
}
if len(r.LanguageList) > 0 {
if err := writeStringArray(buf, r.LanguageList); err != nil {
return nil, err
}
}
if r.DefaultLanguage != nil {
if err := writeString(buf, *r.DefaultLanguage); err != nil {
return nil, err
}
}
return buf.Bytes(), nil
}
func writeString(buf *bytes.Buffer, s string) error {
if len(s) > 255 {
return errors.New("string too long")
}
if err := buf.WriteByte(uint8(len(s))); err != nil {
return err
}
_, err := buf.Write([]byte(s))
return err
}
func writeStringArray(buf *bytes.Buffer, arr []string) error {
if err := writeI8Varint(buf, int8(len(arr))); err != nil {
return err
}
for _, s := range arr {
if err := writeString(buf, s); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,53 @@
package excelLanguage
import (
"bytes"
"encoding/binary"
"io"
)
func writeI8Varint(buf *bytes.Buffer, v int8) error {
uv := uint64((uint32(v) << 1) ^ uint32(v>>7)) // zigzag encode
b := make([]byte, binary.MaxVarintLen64)
n := binary.PutUvarint(b, uv)
_, err := buf.Write(b[:n])
return err
}
func readI8Varint(r *bytes.Reader) (int, error) {
uv, err := binary.ReadUvarint(r)
if err != nil {
return 0, err
}
// zigzag decode
v := int((uv >> 1) ^ uint64((int64(uv&1)<<63)>>63))
return v, nil
}
func readString(r *bytes.Reader) (string, error) {
l, err := r.ReadByte()
if err != nil {
return "", err
}
buf := make([]byte, l)
if _, err := io.ReadFull(r, buf); err != nil {
return "", err
}
return string(buf), nil
}
func readStringArray(r *bytes.Reader) ([]string, error) {
length, err := readI8Varint(r)
if err != nil {
return nil, err
}
arr := make([]string, 0, length)
for i := 0; i < length; i++ {
s, err := readString(r)
if err != nil {
return nil, err
}
arr = append(arr, s)
}
return arr, nil
}

View File

@@ -4,11 +4,13 @@ import (
"errors"
"fmt"
"os"
"regexp"
"strconv"
"strings"
)
type BinaryVersion struct {
Name string
Major int
Minor int
Patch int
@@ -22,42 +24,47 @@ func ParseBinaryVersion(path string) (*BinaryVersion, error) {
content := string(data)
dashPos := strings.LastIndex(content, "-")
if dashPos == -1 {
lastDash := strings.LastIndex(content, "-")
if lastDash == -1 {
return nil, errors.New("no dash found in version string")
}
start := dashPos - 6
if start < 0 {
start = 0
secondLastDash := strings.LastIndex(content[:lastDash], "-")
if secondLastDash == -1 {
return nil, errors.New("only one dash found in version string")
}
versionSlice := content[start:]
end := strings.Index(versionSlice, "-")
if end == -1 {
end = len(versionSlice)
}
versionStr := versionSlice[:end]
parts := strings.SplitN(versionStr, ".", 3)
if len(parts) != 3 {
versionSlice := content[secondLastDash+1 : lastDash]
re := regexp.MustCompile(`^([A-Za-z]+)([\d\.]+)$`)
matches := re.FindStringSubmatch(versionSlice)
if len(matches) < 3 {
return nil, errors.New("invalid version format")
}
binaryVersion := BinaryVersion{
Name: matches[1],
}
numbers := strings.Split(matches[2], ".")
major, err := strconv.Atoi(parts[0])
if err != nil {
return nil, err
if len(numbers) > 0 {
binaryVersion.Major, err = strconv.Atoi(numbers[0])
if err != nil {
return nil, err
}
}
minor, err := strconv.Atoi(parts[1])
if err != nil {
return nil, err
if len(numbers) > 1 {
binaryVersion.Minor, err = strconv.Atoi(numbers[1])
if err != nil {
return nil, err
}
}
patch, err := strconv.Atoi(parts[2])
if err != nil {
return nil, err
if len(numbers) > 2 {
binaryVersion.Patch, err = strconv.Atoi(numbers[2])
if err != nil {
return nil, err
}
}
return &BinaryVersion{major, minor, patch}, nil
return &binaryVersion, nil
}
func (v *BinaryVersion) String() string {