v1.0.1
This commit is contained in:
@@ -16,7 +16,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
appVersion = "1.0.0"
|
||||
appVersion = "1.0.1"
|
||||
releaseRepo = "sendnwv/omarchy-sc"
|
||||
)
|
||||
|
||||
|
||||
@@ -147,20 +147,10 @@ func (a *App) gameStatus() GameStatus {
|
||||
Autopilot: uc.AutoMaintain,
|
||||
System: sys,
|
||||
}
|
||||
if err := validatePrefixPath(gc.Prefix); err != nil {
|
||||
st.Health = "hardware-blocked"
|
||||
st.HardwareReason = err.Error()
|
||||
return st
|
||||
}
|
||||
if hs.State == "blocked" {
|
||||
st.Health = "hardware-blocked"
|
||||
return st
|
||||
}
|
||||
if sys.State == "blocked" {
|
||||
st.Health = "hardware-blocked"
|
||||
st.HardwareReason = sys.Reason
|
||||
return st
|
||||
}
|
||||
// Always inspect the existing stack before deciding the overall health.
|
||||
// Support bundles must remain truthful even when hardware/preflight is
|
||||
// blocked; otherwise a perfectly valid prefix/launcher appears as missing.
|
||||
prefixPathErr := validatePrefixPath(gc.Prefix)
|
||||
if prefixInitialized(gc.Prefix) {
|
||||
st.PrefixState = "ready"
|
||||
} else if fi, err := os.Stat(gc.Prefix); err == nil && fi.IsDir() {
|
||||
@@ -179,6 +169,23 @@ func (a *App) gameStatus() GameStatus {
|
||||
st.GameState = "partial"
|
||||
}
|
||||
|
||||
// Health precedence is evaluated only after component discovery so diagnosis
|
||||
// never loses useful state.
|
||||
if prefixPathErr != nil {
|
||||
st.Health = "hardware-blocked"
|
||||
st.HardwareReason = prefixPathErr.Error()
|
||||
return st
|
||||
}
|
||||
if hs.State == "blocked" {
|
||||
st.Health = "hardware-blocked"
|
||||
return st
|
||||
}
|
||||
if sys.State == "blocked" {
|
||||
st.Health = "hardware-blocked"
|
||||
st.HardwareReason = sys.Reason
|
||||
return st
|
||||
}
|
||||
|
||||
// Around 150 GiB of free space is the current LUG quick-start recommendation
|
||||
// for a fresh install. Do not block an already-installed game because free
|
||||
// space naturally drops after installation.
|
||||
@@ -222,6 +229,90 @@ type hardwareInfo struct {
|
||||
DiskFreeGiB int
|
||||
}
|
||||
|
||||
type vulkanDevice struct {
|
||||
Name string
|
||||
Type string
|
||||
APIMajor int
|
||||
APIMinor int
|
||||
}
|
||||
|
||||
func parseVulkanDevices(summary string) []vulkanDevice {
|
||||
var devices []vulkanDevice
|
||||
var current *vulkanDevice
|
||||
flush := func() {
|
||||
if current != nil && (current.Name != "" || current.Type != "" || current.APIMajor != 0) {
|
||||
devices = append(devices, *current)
|
||||
}
|
||||
}
|
||||
apiRE := regexp.MustCompile(`^([0-9]+)\.([0-9]+)`)
|
||||
gpuRE := regexp.MustCompile(`^GPU[0-9]+:$`)
|
||||
for _, raw := range strings.Split(summary, "\n") {
|
||||
line := strings.TrimSpace(raw)
|
||||
if gpuRE.MatchString(line) {
|
||||
flush()
|
||||
current = &vulkanDevice{}
|
||||
continue
|
||||
}
|
||||
if current == nil {
|
||||
continue
|
||||
}
|
||||
key, val, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
key, val = strings.TrimSpace(key), strings.TrimSpace(val)
|
||||
switch key {
|
||||
case "deviceName":
|
||||
current.Name = val
|
||||
case "deviceType":
|
||||
current.Type = val
|
||||
case "apiVersion":
|
||||
if m := apiRE.FindStringSubmatch(val); len(m) == 3 {
|
||||
current.APIMajor, _ = strconv.Atoi(m[1])
|
||||
current.APIMinor, _ = strconv.Atoi(m[2])
|
||||
}
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return devices
|
||||
}
|
||||
|
||||
func softwareVulkanDevice(d vulkanDevice) bool {
|
||||
low := strings.ToLower(d.Name + " " + d.Type)
|
||||
return strings.Contains(low, "physical_device_type_cpu") ||
|
||||
strings.Contains(low, "llvmpipe") || strings.Contains(low, "lavapipe") || strings.Contains(low, "softpipe")
|
||||
}
|
||||
|
||||
func vulkanDeviceRank(d vulkanDevice) int {
|
||||
if softwareVulkanDevice(d) {
|
||||
return -1
|
||||
}
|
||||
switch strings.ToUpper(d.Type) {
|
||||
case "PHYSICAL_DEVICE_TYPE_DISCRETE_GPU":
|
||||
return 30
|
||||
case "PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU":
|
||||
return 20
|
||||
case "PHYSICAL_DEVICE_TYPE_VIRTUAL_GPU":
|
||||
return 10
|
||||
default:
|
||||
if d.Name != "" {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func selectVulkanHardwareDevice(summary string) (vulkanDevice, bool) {
|
||||
best, rank := vulkanDevice{}, -1
|
||||
for _, d := range parseVulkanDevices(summary) {
|
||||
r := vulkanDeviceRank(d)
|
||||
if r > rank {
|
||||
best, rank = d, r
|
||||
}
|
||||
}
|
||||
return best, rank >= 0
|
||||
}
|
||||
|
||||
func (a *App) hardwareStatus(prefix string) hardwareInfo {
|
||||
h := hardwareInfo{State: "ready", Vulkan: "unknown"}
|
||||
if runtime.GOARCH != "amd64" {
|
||||
@@ -267,8 +358,8 @@ func (a *App) hardwareStatus(prefix string) hardwareInfo {
|
||||
pci, _ := exec.Command("lspci", "-nnk").CombinedOutput()
|
||||
p := string(pci)
|
||||
if strings.TrimSpace(p) == "" {
|
||||
// lspci/pciutils is optional on minimal Debian installations. Detect the
|
||||
// QEMU VGA device through sysfs as a dependency-free fallback.
|
||||
// lspci/pciutils is optional on minimal installations. Detect QEMU VGA
|
||||
// through sysfs as a dependency-free fallback.
|
||||
vendors, _ := filepath.Glob("/sys/class/drm/card*/device/vendor")
|
||||
for _, vp := range vendors {
|
||||
v, _ := os.ReadFile(vp)
|
||||
@@ -287,45 +378,48 @@ func (a *App) hardwareStatus(prefix string) hardwareInfo {
|
||||
}
|
||||
}
|
||||
}
|
||||
lowp := strings.ToLower(p)
|
||||
if strings.Contains(lowp, "1234:1111") || strings.Contains(lowp, "kernel driver in use: bochs") {
|
||||
h.State = "blocked"
|
||||
h.Vulkan = "blocked"
|
||||
h.Reason = "QEMU Standard VGA/bochs-drm erkannt. Star Citizen benötigt eine echte Vulkan-GPU oder GPU-Passthrough."
|
||||
return h
|
||||
}
|
||||
qemuFallback := strings.Contains(strings.ToLower(p), "1234:1111") || strings.Contains(strings.ToLower(p), "kernel driver in use: bochs")
|
||||
|
||||
if path, err := exec.LookPath("vulkaninfo"); err == nil {
|
||||
cmd := exec.Command(path, "--summary")
|
||||
out, err := cmd.CombinedOutput()
|
||||
text := string(out)
|
||||
low := strings.ToLower(text)
|
||||
if err != nil || strings.Contains(text, "VK_ERROR_INCOMPATIBLE_DRIVER") {
|
||||
h.State = "blocked"
|
||||
h.Vulkan = "blocked"
|
||||
h.Reason = "Vulkan ist nicht funktionsfähig."
|
||||
return h
|
||||
}
|
||||
if strings.Contains(low, "device_type_cpu") || strings.Contains(low, "llvmpipe") || strings.Contains(low, "lavapipe") || strings.Contains(low, "softpipe") {
|
||||
dev, hasHardware := selectVulkanHardwareDevice(text)
|
||||
if !hasHardware {
|
||||
h.State = "blocked"
|
||||
h.Vulkan = "blocked"
|
||||
h.Reason = "Es wurde nur ein Software-Vulkan-Gerät erkannt. Star Citizen benötigt eine echte Vulkan-GPU."
|
||||
if qemuFallback {
|
||||
h.Reason = "QEMU Standard VGA/bochs-drm erkannt und keine echte Vulkan-GPU verfügbar. Für Star Citizen ist GPU-Passthrough erforderlich."
|
||||
} else {
|
||||
h.Reason = "Es wurde nur ein Software-Vulkan-Gerät erkannt. Star Citizen benötigt eine echte Vulkan-GPU."
|
||||
}
|
||||
return h
|
||||
}
|
||||
if m := regexp.MustCompile(`(?m)deviceName\s*=\s*(.+)$`).FindStringSubmatch(text); len(m) == 2 {
|
||||
h.GPU = strings.TrimSpace(m[1])
|
||||
if dev.Name != "" {
|
||||
h.GPU = dev.Name
|
||||
}
|
||||
if m := regexp.MustCompile(`(?m)apiVersion\s*=\s*([0-9]+)\.([0-9]+)`).FindStringSubmatch(text); len(m) == 3 {
|
||||
major, _ := strconv.Atoi(m[1])
|
||||
minor, _ := strconv.Atoi(m[2])
|
||||
if major < 1 || (major == 1 && minor < 3) {
|
||||
h.State = "blocked"
|
||||
h.Vulkan = "blocked"
|
||||
h.Reason = fmt.Sprintf("Vulkan %d.%d erkannt; der aktuelle DXVK-Stack benötigt Vulkan 1.3 oder neuer.", major, minor)
|
||||
return h
|
||||
}
|
||||
if dev.APIMajor < 1 || (dev.APIMajor == 1 && dev.APIMinor < 3) {
|
||||
h.State = "blocked"
|
||||
h.Vulkan = "blocked"
|
||||
h.Reason = fmt.Sprintf("Vulkan %d.%d erkannt; der aktuelle DXVK-Stack benötigt Vulkan 1.3 oder neuer.", dev.APIMajor, dev.APIMinor)
|
||||
return h
|
||||
}
|
||||
h.Vulkan = "ready"
|
||||
} else {
|
||||
// Without vulkaninfo we can only make a provisional assessment. QEMU's
|
||||
// bochs framebuffer is still a hard block because it is not a Vulkan GPU.
|
||||
if qemuFallback {
|
||||
h.State = "blocked"
|
||||
h.Vulkan = "blocked"
|
||||
h.Reason = "QEMU Standard VGA/bochs-drm erkannt. Star Citizen benötigt eine echte Vulkan-GPU oder GPU-Passthrough."
|
||||
return h
|
||||
}
|
||||
matches, _ := filepath.Glob("/usr/share/vulkan/icd.d/*.json")
|
||||
if len(matches) > 0 {
|
||||
h.Vulkan = "probable"
|
||||
@@ -886,17 +980,27 @@ func (a *App) writePowerShellMarker(prefix, version string) error {
|
||||
}
|
||||
|
||||
func (a *App) probePowerShell(gc GameConfig, env []string) error {
|
||||
_, _, wrapper64, _ := a.powerShellPaths(gc.Prefix)
|
||||
core, _, wrapper64, _ := a.powerShellPaths(gc.Prefix)
|
||||
runner, err := a.currentRunner()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wine := filepath.Join(runner, "bin", "wine")
|
||||
probe := exec.Command(wine, wrapper64, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "Write-Output CITIZEN_POWERSHELL_OK")
|
||||
probe.Env = env
|
||||
out, err := probe.CombinedOutput()
|
||||
if err != nil || !strings.Contains(string(out), "CITIZEN_POWERSHELL_OK") {
|
||||
return fmt.Errorf("PowerShell Selbsttest: %s", formatCommandFailure(err, out))
|
||||
|
||||
// Validate PowerShell Core directly first. Then validate the compatibility
|
||||
// wrapper using the invocation shape documented by the wrapper project.
|
||||
// Wine does not reliably forward stdout from a Windows child spawned by
|
||||
// another Windows executable, while wrapper v3+ deliberately propagates the
|
||||
// child's exit status. A successful exit is therefore authoritative.
|
||||
coreProbe := exec.Command(wine, core, "-NoLogo", "-NoProfile", "-NonInteractive", "-Command", "exit 0")
|
||||
coreProbe.Env = env
|
||||
if out, err := coreProbe.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("PowerShell Core Selbsttest: %s", formatCommandFailure(err, out))
|
||||
}
|
||||
wrapperProbe := exec.Command(wine, wrapper64, "-NoLogo", "-NonInteractive", "exit 0")
|
||||
wrapperProbe.Env = env
|
||||
if out, err := wrapperProbe.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("PowerShell Wrapper Selbsttest: %s", formatCommandFailure(err, out))
|
||||
}
|
||||
wait := exec.Command(filepath.Join(runner, "bin", "wineserver"), "-w")
|
||||
wait.Env = env
|
||||
|
||||
@@ -179,3 +179,116 @@ func TestRequiredReleaseDigestFailsClosed(t *testing.T) {
|
||||
t.Fatalf("known SHA-256 rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVulkanHardwareSelectionIgnoresLLVMPipeWhenRealGPUExists(t *testing.T) {
|
||||
summary := `Devices:
|
||||
========
|
||||
GPU0:
|
||||
apiVersion = 1.4.305
|
||||
deviceType = PHYSICAL_DEVICE_TYPE_INTEGRATED_GPU
|
||||
deviceName = AMD Radeon Graphics (RADV PHOENIX2)
|
||||
GPU1:
|
||||
apiVersion = 1.4.305
|
||||
deviceType = PHYSICAL_DEVICE_TYPE_CPU
|
||||
deviceName = llvmpipe (LLVM 19.1.7, 256 bits)
|
||||
`
|
||||
dev, ok := selectVulkanHardwareDevice(summary)
|
||||
if !ok {
|
||||
t.Fatal("real Vulkan GPU was rejected because a software ICD was also present")
|
||||
}
|
||||
if dev.Name != "AMD Radeon Graphics (RADV PHOENIX2)" {
|
||||
t.Fatalf("selected GPU=%q", dev.Name)
|
||||
}
|
||||
if dev.APIMajor != 1 || dev.APIMinor != 4 {
|
||||
t.Fatalf("selected Vulkan version=%d.%d", dev.APIMajor, dev.APIMinor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVulkanHardwareSelectionRejectsSoftwareOnly(t *testing.T) {
|
||||
summary := `Devices:
|
||||
========
|
||||
GPU0:
|
||||
apiVersion = 1.4.305
|
||||
deviceType = PHYSICAL_DEVICE_TYPE_CPU
|
||||
deviceName = llvmpipe (LLVM 19.1.7, 256 bits)
|
||||
`
|
||||
if dev, ok := selectVulkanHardwareDevice(summary); ok {
|
||||
t.Fatalf("software-only Vulkan device accepted: %#v", dev)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPowerShellProbeAcceptsSuccessfulWrapperWithoutStdout(t *testing.T) {
|
||||
a := testApp(t)
|
||||
prefix := filepath.Join(a.home, "Games", "star-citizen")
|
||||
gc := GameConfig{Prefix: prefix}
|
||||
core, _, wrapper64, _ := a.powerShellPaths(prefix)
|
||||
for _, p := range []string{core, wrapper64} {
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, make([]byte, 2048), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
runner := filepath.Join(a.vendorDir, "wine", "fixture")
|
||||
bin := filepath.Join(runner, "bin")
|
||||
if err := os.MkdirAll(bin, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The fixture intentionally emits no stdout: this reproduces the real Wine
|
||||
// behavior from the 1.0.0 support bundle.
|
||||
for _, name := range []string{"wine", "wineserver"} {
|
||||
p := filepath.Join(bin, name)
|
||||
if err := os.WriteFile(p, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Join(a.vendorDir, "wine"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(runner, filepath.Join(a.vendorDir, "wine", "current")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := a.probePowerShell(gc, os.Environ()); err != nil {
|
||||
t.Fatalf("successful wrapper without stdout was rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGameStatusStillReportsInstalledComponentsWhenHealthIsBlocked(t *testing.T) {
|
||||
a := testApp(t)
|
||||
prefix := filepath.Join(a.home, "Games", "star citizen") // intentionally invalid path: forces blocked health
|
||||
gc := GameConfig{
|
||||
Prefix: prefix,
|
||||
LauncherEXE: filepath.Join(prefix, "drive_c", "Program Files", "Roberts Space Industries", "RSI Launcher", "RSI Launcher.exe"),
|
||||
GameDir: filepath.Join(prefix, "drive_c", "Program Files", "Roberts Space Industries", "StarCitizen"),
|
||||
}
|
||||
for _, p := range []string{
|
||||
filepath.Join(prefix, "drive_c"),
|
||||
filepath.Dir(gc.LauncherEXE),
|
||||
filepath.Join(gc.GameDir, "LIVE", "Bin64"),
|
||||
} {
|
||||
if err := os.MkdirAll(p, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, p := range []string{
|
||||
filepath.Join(prefix, "system.reg"),
|
||||
filepath.Join(prefix, "user.reg"),
|
||||
gc.LauncherEXE,
|
||||
filepath.Join(gc.GameDir, "LIVE", "Bin64", "StarCitizen.exe"),
|
||||
} {
|
||||
if err := os.WriteFile(p, []byte("fixture"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := a.saveGameConfig(gc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st := a.gameStatus()
|
||||
if st.Health != "hardware-blocked" {
|
||||
t.Fatalf("health=%q want hardware-blocked", st.Health)
|
||||
}
|
||||
if st.PrefixState != "ready" || st.LauncherState != "ready" || st.GameState != "ready" {
|
||||
t.Fatalf("blocked health hid real component state: prefix=%s launcher=%s game=%s", st.PrefixState, st.LauncherState, st.GameState)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user