diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..5228d48 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +github: xan105 +custom: https://www.paypal.me/xan105 +patreon: xan105 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2bc1051 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +build/ +src/pkg/ +src/*.syso \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4397ac7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Anthony Beaumont + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index fdaa656..b886114 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,166 @@ -# RA3-Launcher -Red Alert 3 Launcher (RA3.exe re-implementation / alternative) +Welcome back, Commander 🫡. + +Red Alert 3 is one of my favorite game but it's in a sorry state since EA shut down their servers for the game. + +This project is an open source re-implementation of the original game launcher `RA3.exe` aimed at addressing and mitigating some of the issues that have emerged since EA servers were shut down. + +### Issues due to EA shutting down their servers + +1. Game Startup is (very) slow + + The game takes awfully long to start because the official launcher tries to get the latest "Comrade News" from files.ea.com + whether or not the gui interface was requested (-ui). And wait until the connection times out, before starting the game. + +2. LAN play: "CD key is already in use" (%CDKEY%) + + LAN play requires each player to have a different CD Key. + Steam no longer populates the `%CDKEY%` variable in the registry _(is it due to EA's servers shutdown ?)_. + + This is a problem because each time the game is run by Steam; Steam will write "%CDKEY%" as the user's CD key. + Every (Steam) players will therefore end up with the same CD Key: "%CDKEY%" and are unable to LAN play with each others. + + The registry value is read on game launch. It is uneffective to change it after the game is ran by Steam. + +3. Online play: emulation and DLL injection + + You can no longer play online nor co-op without using 3rd party service that emulate gamespy such as [revora/CnC:Online](https://cnc-online.net/en/) + + Requiring an additional launcher to be able to use their service. + + While _"Launchers inception"_ (Launcher which starts another Launcher) is despised by many it remains a matter of personal preference. + But for me the core issue was that their launcher didn't work with the Steam version nor with Linux/Proton when I tried it. + +## Features + +- Start Red Alert 3 process almost instantly _(Issue 1)_ + +- Fix the CD Key registry value if needed for LAN play before starting the game _(Issue 2)_ + +- Addons: revora/CnC:Online without their launcher _(Issue 3)_ + + See [xan105/CnC-Online](https://github.com/xan105/CnC-Online) for more details. + +- Compatible with 🐧 Linux/Proton + +- Splash screen customisation: none, random, ... + +## Installation + +Copy the files in the game directory and replace `RA3.exe` + +#### Steam + +> [!NOTE] +> In order to fix the issue with `%CDKEY%` _(Issue 2)_ `RA3.exe` will need elevated privileges.
+> Because the CD Key value is in the registry under `HKLM` which is write access protected. +> +> If you run the game via Steam, you may want to consider always running `RA3.exe` with elevated privileges
+> by right clicking RA3.exe > properties > Compatibility > check "Run this program as an administrator" + +
Wait... why does it need admin right and Steam does not ?! +
+Steam has its own windows service running in the background with system privileges (`steamservice.exe`) to do these kind of operations silently without the end user noticing. +
+ +## Command Line Arguments + +`RA3.exe --help` to display all arguments. + +Most of the orignal `RA3.exe` cmdline arguments were kept: + +|flag|type|description| +|----|----|-----------| +|xres|number|Sets resolution width| +|yres|number|Sets resolution height| +|xpos|number|Sets horizontal offset of the window| +|ypos|number|Sets vertical offset of the window| +|win|boolean|Runs the game in windowed mode| +|fullscreen|boolean|Runs the game in fullscreen mode. Combine with -win for borderless windowed mode| +|noaudio|boolean|Disables game audio| +|noAudioMusic|boolean|Disables game music| +|silentLogin|boolean|Forces the game to immediately log in to a multiplayer account| +|modConfig|string|Runs the game with selected mod (has to point to its .skudef file)| +|replayGame|string|Plays replay file| + +> [!TIP] +> `-modConfig path` +> +> If "path" is **not** an absolute path then it will look for any `.skudef` file corresponding in `%Documents%/Red Alert 3/Mods` +> +> eg: `%Documents%/Red Alert 3/Mods/Upheaval/Upheaval_1.16.skudef` > `-modConfig Upheaval` + +## Config File + +📄 `RA3.json` (required): + +- `lang?: string (auto)` + + Which language to start Red Alert 3 with. + + Default (auto) will query the registry value `HKCU/Software/Electronic Arts/Electronic Arts/Red Alert 3/language`.
+ If no value is set then it defaults to "english". + +- `borderless?: boolean (false)` + + Runs Red Alert 3 in borderless fullscreen.
+ This superseeds the cmdline arguments `-win` and `-fullscreen` + +- `upheaval?: boolean (false)` + + Starts Red Alert 3 with the infamous mod "upheaval" if it's present in the `%GAMEDIR%` or in `%Documents%/Red Alert 3/Mods/Upheaval` + +- `keygen?: boolean (true)` + + Check the CD Key value in the registry.
+ If it's empty or equals to "%CDKEY% generates a random CD Key and write it to the registry. + +> [!CAUTION] +> Unfortunately requires elevated privileges because the key is located under `HKLM` which is write access protected. + +- `splash?: boolean (true)` + + Display a splash screen while the game is loading similar to the original `RA3.exe` + +- `splash_image?: []string (["Launcher/splash.bmp"])` + + Splash screen filepath. When more than one, a splash screen is selected at random. + + Either absolute or relative path.
+ _NB: relative to `RA3.exe` and **not** the current working dir_ + +> [!NOTE] +> Image should be a 640x480 BMP file. + +- `addons?: []{ path: string, required?: boolean }` + + List of addons to inject to the game process.
+ When `required` is set to `true` and if the injection failed, alert the user and kill the game process. + + Either absolute or relative path.
+ _NB: relative to `RA3.exe` and **not** the current working dir_ + +> [!TIP] +> Example: You can use this option to load [xan105/CnC-Online](https://github.com/xan105/CnC-Online). +> +> Restoring the online features of Red Alert 3 without relying on the revora/CnC:Online launcher. + + ```json + { + "addons": [ + { "path": "Launcher/opencnconline.dll", "required": true } + ] + } + ``` + +

+ + Connected to C&C:Online under 🐧 Linux/Proton 9.0-2 (Fedora) +

+ +## Building + +- Golang v1.23 +- [go-winres](https://github.com/tc-hib/go-winres) installed in `%PATH%` env var for win32 manifest & cie + +Run `build.cmd`
+Output files are located in `./build` diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..b482cea --- /dev/null +++ b/build.cmd @@ -0,0 +1,9 @@ +@echo off +cd %~dp0src +set GOOS=windows +set GOARCH=386 +go-winres make --in "..\winres\winres.json" +echo Compiling x86 (DEBUG)... +go build -o "..\build\Debug\RA3.exe" launcher +echo Compiling x86 (RELEASE)... +go build -ldflags "-w -s -H windowsgui" -o "..\build\Release\RA3.exe" launcher \ No newline at end of file diff --git a/screenshot/linux_proton.png b/screenshot/linux_proton.png new file mode 100644 index 0000000..8594e24 Binary files /dev/null and b/screenshot/linux_proton.png differ diff --git a/src/alert.go b/src/alert.go new file mode 100644 index 0000000..8f1ddf4 --- /dev/null +++ b/src/alert.go @@ -0,0 +1,69 @@ +/* +Copyright (c) Anthony Beaumont +This source code is licensed under the MIT License +found in the LICENSE file in the root directory of this source tree. +*/ + +package main + +import ( + "os" + "golang.org/x/sys/windows" + "log/slog" +) + +func alert(message string){ + slog.Error(message); + windows.MessageBox( + windows.HWND(uintptr(0)), + windows.StringToUTF16Ptr(message), + windows.StringToUTF16Ptr("Red Alert 3"), + windows.MB_OK | windows.MB_ICONERROR) + os.Exit(1) +} + +func displayHelp(){ + windows.MessageBox( + windows.HWND(uintptr(0)), + windows.StringToUTF16Ptr( + "-win\n" + + "Runs the game in windowed mode\n" + + "\n" + + "-fullscreen\n" + + "Runs the game in fullscreen mode.\n" + + "Combine with -win for borderless windowed mode\n" + + "\n" + + "-modConfig filePath\n" + + "Runs the game with selected mod (has to point to its .skudef file)\n" + + "\n" + + "-replayGame filePath\n" + + "Plays replay file\n" + + "\n" + + "-noaudio\n" + + "Disables game audio\n" + + "\n" + + "-noAudioMusic\n" + + "Disables game music\n" + + "\n" + + "-silentLogin\n" + + "Forces the game to immediately log in to a multiplayer account\n" + + "\n" + + "-xres number\n" + + "Sets resolution width\n" + + "\n" + + "-yres number\n" + + "Sets resolution height\n" + + "\n" + + "-xpos number\n" + + "Sets horizontal offset of the window\n" + + "\n" + + "-ypos number\n" + + "Sets vertical offset of the window\n" + + "\n" + + "-help\n" + + "Show list of all arguments\n", + ), + windows.StringToUTF16Ptr("Red Alert 3"), + windows.MB_OK, + ) +} \ No newline at end of file diff --git a/src/fs.go b/src/fs.go new file mode 100644 index 0000000..800e2ae --- /dev/null +++ b/src/fs.go @@ -0,0 +1,99 @@ +/* +Copyright (c) Anthony Beaumont +This source code is licensed under the MIT License +found in the LICENSE file in the root directory of this source tree. +*/ + +package main + +import( + "os" + "io" + "io/fs" + "path/filepath" + "encoding/json" + "errors" + "launcher/internal/regedit" +) + +func locate() string{ + process, err := os.Executable() + if err != nil { alert(err.Error()) } + return filepath.Dir(process) +} + +func readJSON(filepath string) (config Config, err error) { + + //default values + config.Version = "1.12" + config.Lang = "auto" + config.Upheaval = false + config.Keygen = true + config.Borderless = false + config.Splash = true + config.SplashImage = []string{ "Launcher/splash.bmp" } + config.Addons = []Addon{} + + file, err := os.Open(filepath) + if err != nil { return } + defer file.Close() + + bytes, err := io.ReadAll(file) + if err != nil { return } + + err = json.Unmarshal(bytes, &config) + if err != nil { return } + + if config.Version == "" { + config.Version = "1.12" + } + if config.Lang == "" { + config.Version = "auto" + } + + if len(config.SplashImage) == 0 { + config.SplashImage = []string{ "Launcher/splash.bmp" } + } + + return +} + +func fileExist(path string) bool { + target, err := os.Stat(path) + if err == nil { + return !target.IsDir() + } + if errors.Is(err, os.ErrNotExist) { + return false + } + return false +} + +func findFilesWithExt(dirpath string, ext string) []string { + var matches []string + filepath.WalkDir(dirpath, func(path string, info fs.DirEntry, e error) error { + if e != nil { return e } + if filepath.Ext(info.Name()) == ext { + matches = append(matches, path) + } + return nil + }) + return matches +} + +func getUserProfilePath() string { + + const PATH = "Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\User Shell Folders" + keys := []string{ + "{F42EE2D3-909F-4907-8871-4C22FC0BF756}", //win10 + "Personal"} + + for _, key := range keys { + value := regedit.RegQueryStringValueAndExpand("HKCU", PATH, key) + if len(value) > 0 { + return value + } + } + + return filepath.Join(os.Getenv("USERPROFILE"), "Documents") +} \ No newline at end of file diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..6a3df2e --- /dev/null +++ b/src/go.mod @@ -0,0 +1,5 @@ +module launcher + +go 1.23.0 + +require golang.org/x/sys v0.24.0 // indirect diff --git a/src/go.sum b/src/go.sum new file mode 100644 index 0000000..d88e7bd --- /dev/null +++ b/src/go.sum @@ -0,0 +1,2 @@ +golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= +golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/src/internal/hook/hook.go b/src/internal/hook/hook.go new file mode 100644 index 0000000..7530ce5 --- /dev/null +++ b/src/internal/hook/hook.go @@ -0,0 +1,79 @@ +/* +Copyright (c) Anthony Beaumont +This source code is licensed under the MIT License +found in the LICENSE file in the root directory of this source tree. +*/ + +package hook + +import ( + "syscall" + "golang.org/x/sys/windows" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + + pVirtualAllocEx = kernel32.NewProc("VirtualAllocEx") +) + +func CreateRemoteThread(pid uintptr, path string) error { + + //Opens a handle to the target process with the needed permissions + hProcess, err := windows.OpenProcess( + windows.PROCESS_CREATE_THREAD | + windows.PROCESS_VM_OPERATION | + windows.PROCESS_VM_WRITE | + windows.PROCESS_VM_READ | + windows.PROCESS_QUERY_INFORMATION, + false, + uint32(pid), + ) + if err != nil { + return err + } + + //Allocates virtual memory for the file path + lpBaseAddress, _, err := pVirtualAllocEx.Call( + uintptr(hProcess), + 0, + uintptr(len(path)+1), + windows.MEM_RESERVE | windows.MEM_COMMIT, + windows.PAGE_EXECUTE_READWRITE, + ) + + //Converts the file path to type *byte + lpBuffer, err := windows.BytePtrFromString(path) + if err != nil { + return err + } + + //Writes the filename to the previously allocated space + lpNumberOfBytesWritten:= uintptr(0) + err = windows.WriteProcessMemory( + hProcess, + lpBaseAddress, + lpBuffer, + uintptr(len(path)+1), + &lpNumberOfBytesWritten, + ) + if err != nil { + return err + } + + //Gets a pointer to the LoadLibrary function + LoadLibAddr, err := syscall.GetProcAddress(syscall.Handle(kernel32.Handle()), "LoadLibraryA") + if err != nil { + return err + } + + //Creates a remote thread that loads the DLL triggering it + handle, _, err := kernel32.NewProc("CreateRemoteThread").Call(uintptr(hProcess), 0, 0, LoadLibAddr, lpBaseAddress, 0, 0) + if handle == 0 { + return err + } + + defer syscall.CloseHandle(syscall.Handle(handle)) + + return nil +} \ No newline at end of file diff --git a/src/internal/regedit/regedit.go b/src/internal/regedit/regedit.go new file mode 100644 index 0000000..01dcd4b --- /dev/null +++ b/src/internal/regedit/regedit.go @@ -0,0 +1,60 @@ +/* +Copyright (c) Anthony Beaumont +This source code is licensed under the MIT License +found in the LICENSE file in the root directory of this source tree. +*/ + +package regedit + +import ( + "golang.org/x/sys/windows/registry" +) + +func getHKEY(root string) registry.Key { + + var HKEY registry.Key + + switch root { + case "HKCU": HKEY = registry.CURRENT_USER + case "HKLM": HKEY = registry.LOCAL_MACHINE + case "HKU": HKEY = registry.USERS + case "HKCC": HKEY = registry.CURRENT_CONFIG + case "HKCR": HKEY = registry.CLASSES_ROOT + } + + return HKEY +} + +func RegQueryStringValue(root string, path string, key string) string { // REG_SZ & REG_EXPAND_SZ + + var result string + HKEY := getHKEY(root) + + k, _ := registry.OpenKey(HKEY , path, registry.QUERY_VALUE) + defer k.Close() + result, _, _ = k.GetStringValue(key) + + return result +} + +func RegQueryStringValueAndExpand(root string, path string, key string) string { // REG_EXPAND_SZ (expands environment-variable strings) + + var result string + HKEY := getHKEY(root) + + k, _ := registry.OpenKey(HKEY , path, registry.QUERY_VALUE) + defer k.Close() + str, _, _ := k.GetStringValue(key) + result, _ = registry.ExpandString(str) + + return result +} + +func RegWriteStringValue(root string, path string, key string, value string) { + + HKEY := getHKEY(root) + + k, _, _ := registry.CreateKey(HKEY, path, registry.ALL_ACCESS) + defer k.Close() + k.SetStringValue(key, value) +} \ No newline at end of file diff --git a/src/internal/splash/splash.go b/src/internal/splash/splash.go new file mode 100644 index 0000000..3cc7136 --- /dev/null +++ b/src/internal/splash/splash.go @@ -0,0 +1,399 @@ +/* +Copyright (c) Anthony Beaumont +This source code is licensed under the MIT License +found in the LICENSE file in the root directory of this source tree. +*/ + +package splash + +import ( + "log/slog" + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + + pGetModuleHandleW = kernel32.NewProc("GetModuleHandleW") +) + +func getModuleHandle() (syscall.Handle, error) { + ret, _, err := pGetModuleHandleW.Call(uintptr(0)) + if ret == 0 { + return 0, err + } + return syscall.Handle(ret), nil +} + +var ( + gdi32 = syscall.NewLazyDLL("Gdi32.dll") + + pGetDeviceCaps = gdi32.NewProc("GetDeviceCaps") + pCreatePatternBrush = gdi32.NewProc("CreatePatternBrush") +) + +const ( + HORZRES = 8 + VERTRES = 10 +) + +func getDeviceCaps(hDC syscall.Handle, index int32) uint32 { + ret, _, _ := pGetDeviceCaps.Call( + uintptr(hDC), + uintptr(index), + ) + + return uint32(ret) +} + +func createPatternBrush(hbm syscall.Handle) (syscall.Handle, error) { + ret, _, err := pCreatePatternBrush.Call(uintptr(hbm)) + if ret == 0 { + return 0, err + } + return syscall.Handle(ret), nil +} + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + + pCreateWindowExW = user32.NewProc("CreateWindowExW") + pDefWindowProcW = user32.NewProc("DefWindowProcW") + pDestroyWindow = user32.NewProc("DestroyWindow") + pDispatchMessageW = user32.NewProc("DispatchMessageW") + pGetMessageW = user32.NewProc("GetMessageW") + pLoadCursorW = user32.NewProc("LoadCursorW") + pPostQuitMessage = user32.NewProc("PostQuitMessage") + pRegisterClassExW = user32.NewProc("RegisterClassExW") + pTranslateMessage = user32.NewProc("TranslateMessage") + pLoadImageW = user32.NewProc("LoadImageW") + pSetWinEventHook = user32.NewProc("SetWinEventHook") + pUnhookWinEvent = user32.NewProc("UnhookWinEvent") //todo + pGetDC = user32.NewProc("GetDC") + pReleaseDC = user32.NewProc("ReleaseDC") + +) + +func getDC(hWnd syscall.Handle) (syscall.Handle, error) { + ret, _, err := pGetDC.Call( + uintptr(hWnd), + ) + if ret == 0 { + return 0, err + } + return syscall.Handle(ret), nil +} + +func releaseDC (hWnd syscall.Handle, hDC syscall.Handle) bool { + ret, _, _ := pReleaseDC.Call( + uintptr(hWnd), + uintptr(hDC), + ) + return ret != 0 +} + +const ( + cWS_VISIBLE = 0x10000000 + cWS_EX_TOPMOST = 0x00000008 + cWS_POPUP = 0x80000000 + cWS_EX_TOOLWINDOW = 0x000000080 + cWS_TABSTOP = 0x00010000 +) + +func createWindow(className, windowName string, style, style_ext uint32, x, y, width, height uint32, parent, menu, instance syscall.Handle) (syscall.Handle, error) { + ret, _, err := pCreateWindowExW.Call( + uintptr(style), + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(className))), + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(windowName))), + uintptr(style_ext), + uintptr(x), + uintptr(y), + uintptr(width), + uintptr(height), + uintptr(parent), + uintptr(menu), + uintptr(instance), + uintptr(0), + ) + if ret == 0 { + return 0, err + } + return syscall.Handle(ret), nil +} + +const ( + EVENT_SYSTEM_FOREGROUND = 0x0003 + EVENT_OBJECT_CREATE = 0x8000 + EVENT_OBJECT_SHOW = 0x8002 + WINEVENT_OUTOFCONTEXT = 0x0000 + WINEVENT_INCONTEXT = 0x0004 + WINEVENT_SKIPOWNPROCESS = 0x0002 + WINEVENT_SKIPOWNTHREAD = 0x0001 + OBJID_WINDOW = 0 + OBJID_CURSOR = -9 + OBJID_CLIENT = -4; +) + +func setWinEventHook(eventMin, eventMax uint32, hmodWinEventProc syscall.Handle, pfnWinEventProc uintptr, idProcess int, idThread, dwFlags uint32) (syscall.Handle, error) { + ret, _, err := pSetWinEventHook.Call( + uintptr(eventMin), + uintptr(eventMax), + uintptr(hmodWinEventProc), + pfnWinEventProc, + uintptr(idProcess), + uintptr(idThread), + uintptr(dwFlags), + ) + if ret == 0 { + return 0, err + } + return syscall.Handle(ret), nil +} + +func unhookWinEvent(hWinEventHook syscall.Handle) bool { + ret, _, _ := pUnhookWinEvent.Call( + uintptr(hWinEventHook), + ) + return ret != 0 +} + +const ( + IMAGE_BITMAP = 0x00 + LR_LOADFROMFILE = 0x00000010 +) + +func loadImage(imagePath string) (syscall.Handle, error) { + ret, _, err := pLoadImageW.Call( + uintptr(0), + uintptr(unsafe.Pointer(syscall.StringToUTF16Ptr(imagePath))), + uintptr(IMAGE_BITMAP), + uintptr(0), + uintptr(0), + uintptr(LR_LOADFROMFILE), + ) + if ret == 0 { + return 0, err + } + return syscall.Handle(ret), nil +} + +const ( + WM_CREATE = 0x0001 + WM_DESTROY = 0x0002 + WM_SHOWWINDOW = 0x0018 +) + +func defWindowProc(hwnd syscall.Handle, msg uint32, wparam, lparam uintptr) uintptr { + ret, _, _ := pDefWindowProcW.Call( + uintptr(hwnd), + uintptr(msg), + uintptr(wparam), + uintptr(lparam), + ) + return uintptr(ret) +} + +func destroyWindow(hwnd syscall.Handle) error { + ret, _, err := pDestroyWindow.Call(uintptr(hwnd)) + if ret == 0 { + return err + } + return nil +} + +type tPOINT struct { + x, y int32 +} + +type tMSG struct { + hwnd syscall.Handle + message uint32 + wParam uintptr + lParam uintptr + time uint32 + pt tPOINT +} + +func dispatchMessage(msg *tMSG) { + pDispatchMessageW.Call(uintptr(unsafe.Pointer(msg))) +} + +func getMessage(msg *tMSG, hwnd syscall.Handle, msgFilterMin, msgFilterMax uint32) (bool, error) { + ret, _, err := pGetMessageW.Call( + uintptr(unsafe.Pointer(msg)), + uintptr(hwnd), + uintptr(msgFilterMin), + uintptr(msgFilterMax), + ) + if int32(ret) == -1 { + return false, err + } + return int32(ret) != 0, nil +} + +func postQuitMessage(exitCode int32) { + pPostQuitMessage.Call(uintptr(exitCode)) +} + +type WNDCLASSEXW struct { + size uint32 + style uint32 + wndProc uintptr + clsExtra int32 + wndExtra int32 + instance syscall.Handle + icon syscall.Handle + cursor syscall.Handle + background syscall.Handle + menuName *uint16 + className *uint16 + iconSm syscall.Handle +} + +func registerClassEx(wcx *WNDCLASSEXW) (uint16, error) { + ret, _, err := pRegisterClassExW.Call( + uintptr(unsafe.Pointer(wcx)), + ) + if ret == 0 { + return 0, err + } + return uint16(ret), nil +} + +func translateMessage(msg *tMSG) { + pTranslateMessage.Call(uintptr(unsafe.Pointer(msg))) +} + +func CreateWindow(exit chan bool, pid int, splashImage string, width, height uint32) { + + slog.Info("splash") + + className := "E39055F1-BFCB-4FB9-983F-CDC766E39B93" //Random GUID + + instance, err := getModuleHandle() + if err != nil { + slog.Error(err.Error()) + exit <- true + return + } + + var win syscall.Handle + + activeWinEventHook := func(hWinEventHook syscall.Handle, event uint32, hwnd syscall.Handle, idObject int32, idChild int32, idEventThread uint32, dwmsEventTime uint32) uintptr { + + if event == EVENT_OBJECT_SHOW && + (idObject == OBJID_CURSOR || idObject == OBJID_WINDOW){ + slog.Info("Splash bye bye") + destroyWindow(win) + unhookWinEvent(hWinEventHook) + } + + return 0 + } + + lpfnWndProc := func(hwnd syscall.Handle, msg uint32, wparam, lparam uintptr) uintptr { + switch msg { + case WM_DESTROY: + postQuitMessage(0) + exit <- true + case WM_SHOWWINDOW: { + _, err := setWinEventHook( + EVENT_SYSTEM_FOREGROUND, + EVENT_OBJECT_SHOW, + 0, + syscall.NewCallback(activeWinEventHook), + pid, + 0, + WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS, + ) + if err != nil { + slog.Error(err.Error()) + exit <- true + } + + } + default: + ret := defWindowProc(hwnd, msg, wparam, lparam) + return ret + } + return 0 + } + + + //load bitmap + hbm , err := loadImage(splashImage) + if err != nil { + slog.Error(err.Error()) + exit <- true + return + } + //and create an HBRUSH around it using CreatePatternBrush(), and then assign that to the WNDCLASS::hbrBackground + hbrush, err := createPatternBrush(hbm) + if err != nil { + slog.Error(err.Error()) + exit <- true + return + } + + wcx := WNDCLASSEXW{ + wndProc: syscall.NewCallback(lpfnWndProc), + instance: instance, + background: hbrush, + className: syscall.StringToUTF16Ptr(className), + } + wcx.size = uint32(unsafe.Sizeof(wcx)) + + if _, err = registerClassEx(&wcx); err != nil { + slog.Error(err.Error()) + exit <- true + return + } + + //Get screen resolution + hDC, err := getDC(0) + if err != nil { + slog.Error(err.Error()) + exit <- true + return + } + defer releaseDC(0, hDC) + screenWidth := getDeviceCaps(hDC, HORZRES) + screenHeight := getDeviceCaps(hDC, VERTRES) + + //create window + win, err = createWindow( + className, + "Red Alert 3", + cWS_EX_TOOLWINDOW | cWS_EX_TOPMOST, + cWS_VISIBLE | cWS_POPUP | cWS_TABSTOP, + (screenWidth - width) / 2, //center X + (screenHeight - height) / 2, //center Y + width, + height, + 0, + 0, + instance, + ) + if err != nil { + slog.Error(err.Error()) + exit <- true + return + } + + for { + msg := tMSG{} + gotMessage, err := getMessage(&msg, 0, 0, 0) + if err != nil { + slog.Error(err.Error()) + return + } + + if gotMessage { + translateMessage(&msg) + dispatchMessage(&msg) + } else { + break + } + } +} \ No newline at end of file diff --git a/src/keygen.go b/src/keygen.go new file mode 100644 index 0000000..11b7bac --- /dev/null +++ b/src/keygen.go @@ -0,0 +1,68 @@ +/* +Copyright (c) Anthony Beaumont +This source code is licensed under the MIT License +found in the LICENSE file in the root directory of this source tree. +*/ + +package main + +import( + "math/rand" + "log/slog" + "launcher/internal/regedit" +) + +func randAlphaNumString(length int) string { + //cf: https://stackoverflow.com/questions/22892120/how-to-generate-a-random-string-of-a-fixed-length-in-go + + const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + const IdxBits = 6 + const IdxMask = 1<= 0; { + if remain == 0 { + cache, remain = rand.Int63(), IdxMax + } + if idx := int(cache & IdxMask); idx < len(charset) { + bytes[i] = charset[idx] + i-- + } + cache >>= IdxBits + remain-- + } + return string(bytes) +} + +func keygen(){ //Elevated perm required + + /* + LAN play requires each player to have a different CD Key. + Steam no longer populates the %CDKEY% variable in the registry (is it due to EA's servers shutdown ?) + This is a problem because each time the game is ran by Steam; Steam will write "%CDKEY%" as the user's CD key. + Every (Steam) players will therefore end up with the same CD Key: "%CDKEY%" and are unable to LAN play with each others. + The registry value is read on game launch. It is uneffective to change it after the game is ran by Steam. + + Since EA shutdown their servers for this game. Having a "real" and legit CD Key is of no concerns here. + Generate a random alphanum string and write it to the registry if the CD Key is "%CDKEY%" or there is none. + + Unfortunately writing to HKLM (registry) requires elevated privileges (UAC/Adming rights). + */ + + registration := regedit.RegQueryStringValue("HKLM", DEFAULT_REG_PATH, "Registration") + if registration == "" { + registration = DEFAULT_REG_PATH + "\\ergc" + regedit.RegWriteStringValue("HKLM", DEFAULT_REG_PATH, "Registration", registration) //elevated + } + + regkey := regedit.RegQueryStringValue("HKLM", registration, "") + + if regkey == "%CDKEY%" || regkey == "" { + slog.Warn("RA3 CD Key not found ! This will interfere with LAN play") + + key:= randAlphaNumString(20) + regedit.RegWriteStringValue("HKLM", registration, "", key) //elevated + slog.Info("Generated random CD Key to enable LAN play") + } +} \ No newline at end of file diff --git a/src/launcher.go b/src/launcher.go new file mode 100644 index 0000000..39f4cc6 --- /dev/null +++ b/src/launcher.go @@ -0,0 +1,222 @@ +/* +Copyright (c) Anthony Beaumont +This source code is licensed under the MIT License +found in the LICENSE file in the root directory of this source tree. +*/ + +package main + +import( + "flag" + "os" + "path/filepath" + "strings" + "strconv" + "os/exec" + "syscall" + "math/rand" + "log/slog" + "launcher/internal/regedit" + "launcher/internal/splash" + "launcher/internal/hook" +) + +type Args struct { + xres int + yres int + xpos int + ypos int + win bool + fullscreen bool + noaudio bool + noAudioMusic bool + silentLogin bool + help bool + modConfig string + replayGame string + //ui bool //UNIMPLEMENTED: Opens the autorun feature otherwise called by inserting the game disc > no gui but splash screen is implemented + //getPatch bool //DEPRECATED: Forces check for official updates > currently useless as EA's servers have been shut down + //runver int //DEPRECATED: Select which version of the game to run > does not work with Steam version (ra3game.dat) +} + +type Addon struct { + Path string `json:"path"` + Required bool `json:"required"` +} + +type Config struct { + Version string `json:"version"` + Lang string `json:"lang"` + Upheaval bool `json:"upheaval"` + Keygen bool `json:"keygen"` + Borderless bool `json:"borderless"` + Splash bool `json:"splash"` + SplashImage []string `json:"splash_image"` + Addons []Addon `json:"addons"` +} + +const DEFAULT_REG_PATH = "SOFTWARE\\Electronic Arts\\Electronic Arts\\Red Alert 3" + +func parseArgs() (args Args) { + flag.IntVar(&args.xres, "xres", 0, "Sets resolution width") + flag.IntVar(&args.yres, "yres", 0, "Sets resolution height") + flag.IntVar(&args.xpos, "xpos", 0, "Sets horizontal offset of the window") + flag.IntVar(&args.ypos, "ypos", 0, "Sets vertical offset of the window") + flag.BoolVar(&args.win, "win", false, "Runs the game in windowed mode") + flag.BoolVar(&args.fullscreen, "fullscreen", false, "Runs the game in fullscreen mode. Combine with -win for borderless windowed mode") + flag.BoolVar(&args.noaudio, "noaudio", false, "Disables game audio") + flag.BoolVar(&args.noAudioMusic, "noAudioMusic", false, "Disables game music") + flag.BoolVar(&args.silentLogin, "silentLogin", false, "Forces the game to immediately log in to a multiplayer account") + flag.BoolVar(&args.help, "help", false, "Show list of all arguments") + flag.StringVar(&args.modConfig, "modConfig", "", "Runs the game with selected mod (has to point to its .skudef file)") + flag.StringVar(&args.replayGame, "replayGame", "", "Plays replay file") + flag.Parse() + + if args.help { displayHelp() } + + return +} + +func buildCommandLine(root string, args *Args, config *Config) string { + + if(config.Lang == "auto"){ + reglang := regedit.RegQueryStringValue("HKCU", DEFAULT_REG_PATH, "language") + if len(reglang) > 0 { + config.Lang = strings.ToLower(reglang) + } else { + slog.Warn("Unable to determine game's language... defaulting to \"english\"") + config.Lang = "english" + } + } + slog.Info("Language: " + config.Lang) + + skudef := "RA3_" + config.Lang + "_" + config.Version + ".SkuDef" + cmdLine := []string{ "-config", "\"" + filepath.Join(root, skudef) + "\"" } + + if len(args.modConfig) > 0 { + if !filepath.IsAbs(args.modConfig) { + slog.Info("Looking for mod \"" + args.modConfig + "\"...") + dirpath := filepath.Join(getUserProfilePath(),"Red Alert 3/Mods", args.modConfig) + for _, modconfig := range findFilesWithExt(dirpath, ".skudef") { + if strings.Contains(filepath.Base(modconfig), args.modConfig) { + slog.Info("Found mod at \"" + modconfig + "\"") + cmdLine = append(cmdLine, "-modConfig " + "\"" + modconfig + "\"") + break + } + } + } else { + cmdLine = append(cmdLine, "-modConfig " + "\"" + args.modConfig + "\"") + } + } else if config.Upheaval { + locations := []string{ + filepath.Join(root, "Upheaval_1.16.SkuDef"), + filepath.Join(getUserProfilePath(), "Red Alert 3/Mods/Upheaval/Upheaval_1.16.SkuDef")} + + for _, location := range locations { + if fileExist(location) { + cmdLine = append(cmdLine, "-modConfig " + "\"" + location + "\"") + break + } + } + } + + if config.Borderless { + cmdLine = append(cmdLine, "-win", "-fullscreen") + } + + //passthrough + if args.xres > 0 && args.yres > 0 { + cmdLine = append(cmdLine, + "-xres " + strconv.Itoa(args.xres), + "-yres " + strconv.Itoa(args.yres)) + } + if args.xpos > 0 && args.ypos > 0 { + cmdLine = append(cmdLine, + "-xpos " + strconv.Itoa(args.xpos), + "-ypos " + strconv.Itoa(args.ypos)) + } + if args.win && !config.Borderless { + cmdLine = append(cmdLine, "-win") + } + if args.fullscreen && !config.Borderless { + cmdLine = append(cmdLine, "-fullscreen") + } + if args.noaudio { + cmdLine = append(cmdLine, "-noaudio") + } + if args.noAudioMusic { + cmdLine = append(cmdLine, "-noAudioMusic") + } + if args.silentLogin { + cmdLine = append(cmdLine, "-silentLogin") + } + if len(args.replayGame) > 0 { + cmdLine = append(cmdLine, "-replayGame " + args.replayGame) + } + + return strings.Join(cmdLine, " ") +} + +func main(){ + + args := parseArgs() + root := locate() + + config, err := readJSON(filepath.Join(root, "RA3.json")) + if err != nil { alert(err.Error()) } + + binary := filepath.Join(root, "/Data/", "ra3_" + config.Version + ".game") + cmdLine := buildCommandLine(root, &args, &config) + + if config.Keygen { keygen() } + + cmd := exec.Command(binary) + argv := []string{ "\"" + binary + "\"", cmdLine } + cmd.SysProcAttr = &syscall.SysProcAttr{ CmdLine: strings.Join(argv, " ") } //verbatim arguments + cmd.Dir = root + cmd.Env = os.Environ() + cmd.Stdin = nil + cmd.Stdout = nil + cmd.Stderr = nil + err = cmd.Start() + if err != nil { alert(err.Error()) } + + //splash screen + exit := make(chan bool) + if config.Splash { + splashImage := config.SplashImage[rand.Intn(len(config.SplashImage))] + if !filepath.IsAbs(splashImage) { + splashImage = filepath.Join(root, splashImage) + } + go splash.CreateWindow(exit, cmd.Process.Pid, splashImage, 640, 480) + } else { + go func(exit chan bool){ + exit <- true + }(exit) + } + + //Addons + if len(config.Addons) > 0 { + for _, addon := range config.Addons { + + dylib := addon.Path + if !filepath.IsAbs(dylib) { + dylib = filepath.Join(root, dylib) + } + + if fileExist(dylib){ + err = hook.CreateRemoteThread(uintptr(cmd.Process.Pid), dylib) + if err != nil { + if addon.Required { + cmd.Process.Kill() + alert(err.Error()) + } else { + slog.Error(err.Error()) + } + } + } + } + } + + <-exit +} \ No newline at end of file diff --git a/winres/icon.ico b/winres/icon.ico new file mode 100644 index 0000000..67a984a Binary files /dev/null and b/winres/icon.ico differ diff --git a/winres/icon.png b/winres/icon.png new file mode 100644 index 0000000..44f8e05 Binary files /dev/null and b/winres/icon.png differ diff --git a/winres/winres.json b/winres/winres.json new file mode 100644 index 0000000..7a5d7e6 --- /dev/null +++ b/winres/winres.json @@ -0,0 +1,58 @@ +{ + "RT_GROUP_ICON": { + "#42": { + "0409": "icon.ico" + } + }, + "RT_MANIFEST": { + "#1": { + "0409": { + "identity": { + "name": "", + "version": "" + }, + "description": "Red Alert 3 Launcher", + "minimum-os": "win10", + "execution-level": "as invoker", + "ui-access": false, + "auto-elevate": false, + "dpi-awareness": "per monitor v2", + "disable-theming": false, + "disable-window-filtering": false, + "high-resolution-scrolling-aware": false, + "ultra-high-resolution-scrolling-aware": false, + "long-path-aware": false, + "printer-driver-isolation": false, + "gdi-scaling": false, + "segment-heap": false, + "use-common-controls-v6": false + } + } + }, + "RT_VERSION": { + "#1": { + "0000": { + "fixed": { + "file_version": "1.0.0.0", + "product_version": "1.0.0.0" + }, + "info": { + "0409": { + "Comments": "https://xan105.com", + "CompanyName": "Xan", + "FileDescription": "Red Alert 3 Launcher", + "FileVersion": "1.0.0.0", + "InternalName": "RA3.exe", + "LegalCopyright": "Copyright (C) 2024", + "LegalTrademarks": "", + "OriginalFilename": "RA3.exe", + "PrivateBuild": "", + "ProductName": "Command & Conquer™ Red Alert™ 3", + "ProductVersion": "1.0.0.0", + "SpecialBuild": "" + } + } + } + } + } +} \ No newline at end of file