wanted to see if theres a way to disable the self-update since it fails on nixos anyway.
binary lives at ~/.local/share/Hytale/install/release/package/launcher/*/hytale-launcher
nix-shell -p binutils
bin=~/.local/share/Hytale/install/release/package/launcher/2026.01.14-cabac20/hytale-launcher
strings $bin | grep -oE "HYTALE_LAUNCHER_[A-Z_]+" | sort -ufound:
- HYTALE_LAUNCHER_DEBUG_LOGGING
- HYTALE_LAUNCHER_OFFLINE_MODE
- HYTALE_LAUNCHER_ENABLE_KEYRING
- HYTALE_LAUNCHER_NO_TEST_RUN_BINARIES
- HYTALE_LAUNCHER_OS
- HYTALE_LAUNCHER_ARCH
strings $bin | grep -iE "auto.?update|disable.?update|skip.?update|no.?update"nothing useful.
strings $bin | grep -iE "self.?update"found references to selfupdate package and CanSelfUpdate function but no toggle.
running with HYTALE_LAUNCHER_DEBUG_LOGGING=1 shows whats happening:
- downloads update to ~/.local/share/Hytale/downloads/
- extracts to ~/.local/share/Hytale/install/release/package/launcher//
- new binary tries to overwrite the original at the nix store path
- fails because read-only, but keeps running fine
ls -la ~/.local/share/Hytale/account.dat - encrypted blob, probably auth tokens
downloads/ - temp downloads
eula.txt - legal
hytale-launcher.log - main log
install/ - game/launcher/jre installs
third-party-licenses.txt
UserData/ - saves, settings, logs
the install dir has:
install/release/package/
├── game/latest/Client/ - actual game binary + libs
├── jre/latest - bundled temurin 25.0.1+8-LTS
├── launcher/ - launcher versions (keeps old ones)
└── sig/ - signature files for validation
nix-shell -p file xxd
file ~/.local/share/Hytale/account.dat # -> "data"
xxd ~/.local/share/Hytale/account.dat | headjust random bytes, encrypted. cant read it without knowing the key/format.
file ~/.local/share/Hytale/install/release/package/game/latest/Client/HytaleClientELF 64-bit, stripped, dynamically linked. native binary, not java.
the client loads java for the actual game server/modding stuff:
--java-exec /home/.../jre/latest/bin/java
go binaries leak their source paths. found these:
strings $bin | grep -E "/hytale-launcher/internal" | grep "\.go$" | sort -uinteresting files:
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/selfupdate/update.go
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/selfupdate/cleanup.go
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/build/env.go
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/keyring/keyring_linux.go
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/pkg/launcher.go
/home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher/internal/pkg/java.go
theyre building it on github actions (actions-runner), hypixel org.
found the function names:
strings $bin | grep -E "selfupdate\." | sort -uselfupdate.Do
selfupdate.HasUpdateFlags
selfupdate.CleanupOldLauncher
selfupdate.replaceBin
selfupdate.updateBin
selfupdate.waitForProcessExit
also found this string:
skipping launcher update check since branch cannot self-update
so theres a branch check. maybe dev/internal branches cant self-update but release can.
when updating, the launcher spawns itself with these flags:
-start-pid <pid>
-source-exe <new binary path>
-dest-exe <original binary path>
-launcher-patchline <branch>
-launcher-version <version>
the new binary then waits for parent to exit, tries to overwrite dest-exe, fails on nix, continues anyway.
strings $bin | grep -E "github\.com/[^/]+/[^/]+" | sort -u | head -20found:
- github.com/wailsapp/wails/v2
- github.com/getsentry/sentry-go
- github.com/itchio/wharf (delta patching)
- github.com/godbus/dbus/v5
- github.com/pkg/browser
build info in binary:
build -ldflags="-X 'hytale-launcher/internal/build.Version=2026.01.14-cabac20' -X 'hytale-launcher/internal/build.Branch=release' -w -s"
so version and branch are baked in at compile time.
cat ~/.local/share/Hytale/install/release/package/jre/latest/releaseTemurin-25.0.1+8-LTS, confirms official recommendation is temurin 25.
launcher update manifest is public:
curl -s "https://launcher.hytale.com/version/release/launcher.json"{
"version": "2026.01.14-cabac20",
"download_url": {
"linux": {
"amd64": {
"url": "https://launcher.hytale.com/builds/release/linux/amd64/hytale-launcher-2026.01.14-cabac20.zip",
"sha256": "6b4bfe603f707555ba7916eae381b8bb5e059d2032f7745b99cef9b3544592b3"
}
},
...
}
}game and java manifests 404, probably need auth or different path.
connectivity check urls from strings:
- http://www.msftconnecttest.com/connecttest.txt
- http://connectivitycheck.gstatic.com/generate_204
- http://connectivity-check.ubuntu.com/
- http://www.cloudflare.com (for time sync probably)
wanted to see how it finds its own path:
nix-shell -p strace
strace -f -s 200 -e trace=readlinkat hytale-launcher 2>&1 | grep "/proc/self/exe"
# ctrl+c once it starts[pid 35227] readlinkat(AT_FDCWD, "/proc/self/exe", "/nix/store/...-hytale-launcher-unwrapped-.../opt/hytale-launcher/hytale-launcher", 128) = 124
[pid 35306] readlinkat(AT_FDCWD, "/proc/self/exe", "/home/karolbroda/.local/share/Hytale/install/release/package/launcher/2026.01.14-cabac20/hytale-launcher", 128) = 104
main process gets the nix store path, update helper (second pid) gets the downloaded version path. helper tries to overwrite the nix store path, fails.
idea: intercept readlinkat to return a fake writable path instead of the nix store path.
wrote /tmp/fake_exe.c:
#define _GNU_SOURCE
#include <dlfcn.h>
#include <string.h>
#include <unistd.h>
#include <stdio.h>
ssize_t readlinkat(int dirfd, const char *pathname, char *buf, size_t bufsiz) {
static ssize_t (*real_readlinkat)(int, const char*, char*, size_t) = NULL;
if (!real_readlinkat) {
real_readlinkat = dlsym(RTLD_NEXT, "readlinkat");
}
ssize_t result = real_readlinkat(dirfd, pathname, buf, bufsiz);
if (pathname && strcmp(pathname, "/proc/self/exe") == 0 && result > 0) {
if (strstr(buf, "/nix/store/") && strstr(buf, "hytale-launcher")) {
const char *fake = "/tmp/fake-hytale-launcher";
size_t len = strlen(fake);
if (len < bufsiz) {
strcpy(buf, fake);
return len;
}
}
}
return result;
}nix-shell -p gcc
gcc -shared -fPIC -o /tmp/fake_exe.so /tmp/fake_exe.c -ldl
touch /tmp/fake-hytale-launcher
chmod +x /tmp/fake-hytale-launcher
LD_PRELOAD=/tmp/fake_exe.so hytale-launcherdidnt work. the FHS environment (buildFHSEnv) uses bwrap which creates a new mount namespace, LD_PRELOAD doesnt propagate through. would need to modify the package.nix to pass it, and even then it would just make the launcher write to the fake path instead of actually stopping the update check.
created project in ~/personal/hytale-launcher.gpr, imported the binary, ran auto-analysis + golang analyzer
found these in the selfupdate package:
00a4b720 : selfupdate.init
00a4bbe0 : selfupdate.(*cleanupNote).WriteFile
00a4bc80 : selfupdate.consumeCleanupNote
00a4be60 : selfupdate.CleanupOldLauncher
00a4bfe0 : selfupdate.processExists
00a4c040 : selfupdate.replaceBin
00a4c4e0 : selfupdate.updateBin
00a4c6e0 : selfupdate.waitForProcessExit
00a4c940 : selfupdate.Do
the main update function:
void hytale_launcher_internal_selfupdate_Do(void) {
// line 120: check if source and dest paths are set
if (DAT_01a2f880 == NULL || DAT_01a2f880[1] == 0 ||
DAT_01a2f888 == NULL || DAT_01a2f888[1] == 0) {
return; // no update pending
}
// line 124: log "performing update" with source/target
log_slog___Logger__log(..., "performing update", ...);
// line 127: verify code signature of new binary
hytale_launcher_internal_codesign_Verify(*DAT_01a2f880, ...);
if (error) {
// line 128-130: log to sentry, "code signature verification failed"
return;
}
// line 133-134: wait for old process to exit
if (DAT_01a2f878 != NULL && *DAT_01a2f878 > 0) {
hytale_launcher_internal_selfupdate_waitForProcessExit(*DAT_01a2f878);
}
// line 138: THIS IS WHERE IT FAILS ON NIXOS
hytale_launcher_internal_selfupdate_updateBin();
if (error) {
// line 140: log "failed to update"
return;
}
// line 145: make new binary executable
hytale_launcher_internal_ioutil_MakeExecutable(*DAT_01a2f888, ...);
// line 152-156: write cleanup note for next launch
// line 164+: spawn new binary
}void hytale_launcher_internal_selfupdate_updateBin(void) {
// line 79: log "updating binary" from/to
log_slog___Logger__log(..., "updating binary", ...);
// line 80: TRY TO DELETE OLD BINARY
os_Remove(*DAT_01a2f888, ...); // target = nix store path
// line 81-83: if remove fails and its not "file not found"
if (error && !os_underlyingErrorIs(error, os.ErrNotExist)) {
// log "failed to remove existing executable"
return; // <-- NIXOS FAILS HERE with EROFS
}
// line 85: would copy new binary over old
hytale_launcher_internal_selfupdate_replaceBin(...);
}void hytale_launcher_internal_selfupdate_replaceBin(src, dst) {
// line 30: log "replacing binary" from/to
// line 34: read new binary
os_ReadFile(src);
if (error) {
return fmt_Errorf("error reading source binary: %w", error);
}
// line 38: write to destination (nix store)
os_WriteFile(dst, data, 0x1a4); // 0x1a4 = 0644 perms
if (error) {
return fmt_Errorf("error writing destination binary: %w", error);
}
// line 44-50: stat both to verify
}found in Launcher.CheckForUpdate:
// checking if branch == "release" (7 chars) or "stage" (5 chars)
if (DAT_018b5748 != 7 || memcmp(goss_release_18b5740, "release", 7) != 0) {
if (DAT_018b5748 != 5 || memcmp(goss_release_18b5740, "stage", 5) != 0) {
// not release or stage branch
log("skipping launcher update check since branch cannot self-update");
return;
}
}
// only release/stage branches proceed with update checkso the update check only runs for "release" or "stage" branches. dev builds skip it
this spawns the new binary:
void launcherUpdate_selfUpdate(...) {
// line 104: get current exe path
os_executable();
// line 110: get current pid
syscall_rawSyscallNoError(0x27, ...); // getpid
// line 108-115: build args array
args = [
"-start-pid", strconv.FormatInt(pid),
"-source-exe", new_binary_path,
"-dest-exe", current_exe_path, // this becomes nix store path
"-launcher-patchline", patchline,
"-launcher-version", version
];
// spawns new binary with these args
}CheckForUpdateruns (branch is "release" so it proceeds)- downloads new version to
~/.local/share/Hytale/downloads/ - extracts to
~/.local/share/Hytale/install/release/package/launcher/<version>/ - calls
selfUpdate()which spawns new binary with-dest-exe /nix/store/.../hytale-launcher - new binary runs
selfupdate.Do() updateBin()triesos.Remove("/nix/store/...")- fails with EROFS (read-only filesystem)
- logs error, returns, launcher keeps running
redress is made for Go binaries:
nix-shell -p go
go install github.com/goretk/redress@latest
~/go/bin/redress info $binOS EM_X86_64
Arch amd64
Compiler 1.25.5 (2025-12-02)
Build ID bE7GUOflrWGdy8NrL537/...
GoRoot /home/hypixel/actions-runner/_work/_tool/go/1.25.5/x64
Main root /home/hypixel/actions-runner/_work/hytale-launcher/hytale-launcher
-ldflags -X 'hytale-launcher/internal/build.Version=2026.01.14-cabac20' -X 'hytale-launcher/internal/build.Branch=release' -w -s
-tags desktop,webkit2_41,wv2runtime.download,production
the ldflags show Version and Branch are set at compile time
also checked the build package functions:
strings $bin | grep -E "^hytale-launcher/internal/build\.[A-Z]" | sort -ubuild.Arch
build.CanSelfUpdate
build.ChannelVersion
build.DebugLogging
build.FakeOfflineMode
build.GetPlatform
build.IsDev
build.OS
build.TestRunBinaries
CanSelfUpdate is the gate. IsDev probably checks if Branch != "release". the message "skipping launcher update check since branch cannot self-update" confirms its a branch check
Branch is hardcoded at compile time, no way to change it at runtime
no way to disable self-updates from outside. the binary checks CanSelfUpdate based on branch, but release branch always can. it resolves /proc/self/exe to find its real path so cant trick it with symlinks. fails gracefully though, just logs an error and keeps running
from ghidra decompilation: os.Remove() in update.go:80 tries to delete the binary at the nix store path, gets EROFS, logs "failed to remove existing executable", returns. never even gets to the write
working with ghidra to figure out more