From 183d2606dc1334f3fa69212dc035b50d56da1a70 Mon Sep 17 00:00:00 2001 From: imterah Date: Sun, 22 Jun 2025 20:14:04 -0400 Subject: [PATCH] feature: Add basic EDID reading, writing, and patching capabilities --- app/{ => config}/config.go | 2 +- app/{ => config}/default_config.yml | 0 app/edidtools/edid_parser.go | 72 +++++++++++++++ app/edidtools/patching_tools_linux.go | 127 ++++++++++++++++++++++++++ app/edidtools/patching_tools_macos.go | 21 +++++ app/edidtools/patching_tools_win.go | 21 +++++ app/edidtools/quirks.go | 14 +++ app/edidtools/struct.go | 20 ++++ app/main.go | 51 ++++++++++- edidpatcher/edidpatcher.go | 102 +++++++++++++++++++++ go.mod | 2 + go.sum | 4 + 12 files changed, 431 insertions(+), 5 deletions(-) rename app/{ => config}/config.go (99%) rename app/{ => config}/default_config.yml (100%) create mode 100644 app/edidtools/edid_parser.go create mode 100644 app/edidtools/patching_tools_linux.go create mode 100644 app/edidtools/patching_tools_macos.go create mode 100644 app/edidtools/patching_tools_win.go create mode 100644 app/edidtools/quirks.go create mode 100644 app/edidtools/struct.go create mode 100644 edidpatcher/edidpatcher.go diff --git a/app/config.go b/app/config/config.go similarity index 99% rename from app/config.go rename to app/config/config.go index 1f6b07d..307a14b 100644 --- a/app/config.go +++ b/app/config/config.go @@ -1,4 +1,4 @@ -package main +package config import _ "embed" diff --git a/app/default_config.yml b/app/config/default_config.yml similarity index 100% rename from app/default_config.yml rename to app/config/default_config.yml diff --git a/app/edidtools/edid_parser.go b/app/edidtools/edid_parser.go new file mode 100644 index 0000000..fbb7eba --- /dev/null +++ b/app/edidtools/edid_parser.go @@ -0,0 +1,72 @@ +package edidtools + +import ( + "fmt" + + edidparser "github.com/anoopengineer/edidparser/edid" +) + +func ParseEDID(rawEDIDFile []byte, allowUnsupportedDevices bool) (*DisplayMetadata, error) { + parsedEDID, err := edidparser.NewEdid(rawEDIDFile) + + if err != nil { + return nil, fmt.Errorf("failed to parse EDID file: %w", err) + } + + for manufacturer, manufacturerSupportedDevices := range QuirksRegistry { + if parsedEDID.ManufacturerId == manufacturer { + if deviceQuirks, ok := manufacturerSupportedDevices[parsedEDID.MonitorName]; ok || allowUnsupportedDevices { + maxWidth := 0 + maxHeight := 0 + maxRefreshRate := 0 + + for _, resolution := range parsedEDID.DetailedTimingDescriptors { + if int(resolution.HorizontalActive) > maxWidth && int(resolution.VerticalActive) > maxHeight { + maxWidth = int(resolution.HorizontalActive) + maxHeight = int(resolution.VerticalActive) + } + + // Convert pixel clock to refresh rate + // Refresh Rate = Pixel Clock / ((Horizontal Active + Horizontal Blanking) * (Vertical Active + Vertical Blanking)) + hTotal := int(resolution.HorizontalActive + resolution.HorizontalBlanking) + vTotal := int(resolution.VerticalActive + resolution.VerticalBlanking) + refreshRate := int(int(resolution.PixelClock*1000) / (hTotal * vTotal)) + + if refreshRate > maxRefreshRate { + maxRefreshRate = refreshRate + } + } + + if maxWidth == 0 || maxHeight == 0 { + if deviceQuirks.MaxWidth == 0 || deviceQuirks.MaxHeight == 0 { + return nil, fmt.Errorf("failed to determine maximum resolution for monitor '%s'", parsedEDID.MonitorName) + } + + maxWidth = deviceQuirks.MaxWidth + maxHeight = deviceQuirks.MaxHeight + } + + if maxRefreshRate == 0 { + if deviceQuirks.MaxRefreshRate == 0 { + return nil, fmt.Errorf("failed to determine maximum refresh rate for monitor '%s'", parsedEDID.MonitorName) + } + + maxRefreshRate = deviceQuirks.MaxRefreshRate + } + + displayMetadata := &DisplayMetadata{ + EDID: rawEDIDFile, + DeviceVendor: parsedEDID.ManufacturerId, + DeviceQuirks: deviceQuirks, + MaxWidth: maxWidth, + MaxHeight: maxHeight, + MaxRefreshRate: maxRefreshRate, + } + + return displayMetadata, nil + } + } + } + + return nil, fmt.Errorf("failed to match manufacturer for monitor vendor: '%s'", parsedEDID.ManufacturerId) +} diff --git a/app/edidtools/patching_tools_linux.go b/app/edidtools/patching_tools_linux.go new file mode 100644 index 0000000..6940321 --- /dev/null +++ b/app/edidtools/patching_tools_linux.go @@ -0,0 +1,127 @@ +//go:build linux +// +build linux + +package edidtools + +import ( + "fmt" + "os" + "os/exec" + "strings" + + "github.com/charmbracelet/log" +) + +// Attempts to fetch the EDID firmware for any supported XR glasses device +func FetchXRGlassEDID(allowUnsupportedDevices bool) (*DisplayMetadata, error) { + // Implementation goes here + pciDeviceCommand, err := exec.Command("lspci").Output() + + if err != nil { + return nil, fmt.Errorf("failed to execute lspci command: %w", err) + } + + pciDevices := strings.Split(string(pciDeviceCommand), "\n") + pciDevices = pciDevices[:len(pciDevices)-1] + + vgaDevices := []string{} + + for _, pciDevice := range pciDevices { + if strings.Contains(pciDevice, "VGA compatible controller:") { + vgaDevices = append(vgaDevices, pciDevice[:strings.Index(pciDevice, " ")]) + } + } + + for _, vgaDevice := range vgaDevices { + cardDevices, err := os.ReadDir("/sys/devices/pci0000:00/0000:" + vgaDevice + "/drm/") + + if err != nil { + return nil, fmt.Errorf("failed to read directory for device '%s': %w", vgaDevice, err) + } + + for _, cardDevice := range cardDevices { + if !strings.Contains(cardDevice.Name(), "card") { + continue + } + + monitors, err := os.ReadDir("/sys/devices/pci0000:00/0000:" + vgaDevice + "/drm/" + cardDevice.Name()) + + if err != nil { + return nil, fmt.Errorf("failed to read directory for card device '%s': %w", cardDevice.Name(), err) + } + + for _, monitor := range monitors { + if !strings.Contains(monitor.Name(), cardDevice.Name()) { + continue + } + + rawEDIDFile, err := os.ReadFile("/sys/devices/pci0000:00/0000:" + vgaDevice + "/drm/" + cardDevice.Name() + "/" + monitor.Name() + "/edid") + + if err != nil { + return nil, fmt.Errorf("failed to read EDID file for monitor '%s': %w", monitor.Name(), err) + } + + if len(rawEDIDFile) == 0 { + continue + } + + parsedEDID, err := ParseEDID(rawEDIDFile, allowUnsupportedDevices) + + if err != nil { + if !strings.HasPrefix(err.Error(), "failed to match manufacturer for monitor vendor") { + log.Warnf("Failed to parse EDID for monitor '%s': %s", monitor.Name(), err.Error()) + } + } else { + parsedEDID.LinuxDRMCard = cardDevice.Name() + parsedEDID.LinuxDRMConnector = strings.Replace(cardDevice.Name(), cardDevice.Name()+"-", "", 1) + + return parsedEDID, nil + } + } + } + } + + return nil, fmt.Errorf("could not find supported device! Check if the XR device is plugged in. If it is plugged in and working correctly, check the README or open an issue.") +} + +// Loads custom firmware for a supported XR glass device +func LoadCustomEDIDFirmware(displayMetadata *DisplayMetadata, edidFirmware []byte) error { + if displayMetadata.LinuxDRMCard == "" || displayMetadata.LinuxDRMConnector == "" { + return fmt.Errorf("missing Linux DRM card or connector information") + } + + drmFile, err := os.Open("/sys/kernel/debug/dri/" + strings.Replace(displayMetadata.LinuxDRMCard, "card", "", 1) + "/" + displayMetadata.LinuxDRMConnector + "/edid_override") + + if err != nil { + return fmt.Errorf("failed to open EDID override file for monitor '%s': %w", displayMetadata.LinuxDRMConnector, err) + } + + defer drmFile.Close() + + if _, err := drmFile.Write(edidFirmware); err != nil { + return fmt.Errorf("failed to write EDID firmware for monitor '%s': %w", displayMetadata.LinuxDRMConnector, err) + } + + return nil +} + +// Unloads custom firmware for a supported XR glass device +func UnloadCustomEDIDFirmware(displayMetadata *DisplayMetadata) error { + if displayMetadata.LinuxDRMCard == "" || displayMetadata.LinuxDRMConnector == "" { + return fmt.Errorf("missing Linux DRM card or connector information") + } + + drmFile, err := os.Open("/sys/kernel/debug/dri/" + strings.Replace(displayMetadata.LinuxDRMCard, "card", "", 1) + "/" + displayMetadata.LinuxDRMConnector + "/edid_override") + + if err != nil { + return fmt.Errorf("failed to open EDID override file for monitor '%s': %w", displayMetadata.LinuxDRMConnector, err) + } + + defer drmFile.Close() + + if _, err := drmFile.Write([]byte("reset")); err != nil { + return fmt.Errorf("failed to unload EDID firmware for monitor '%s': %w", displayMetadata.LinuxDRMConnector, err) + } + + return nil +} diff --git a/app/edidtools/patching_tools_macos.go b/app/edidtools/patching_tools_macos.go new file mode 100644 index 0000000..d41de0b --- /dev/null +++ b/app/edidtools/patching_tools_macos.go @@ -0,0 +1,21 @@ +//go:build darwin +// +build darwin + +package edidtools + +import "fmt" + +// Attempts to fetch the EDID firmware for any supported XR glasses device +func FetchXRGlassEDID(allowUnsupportedDevices bool) (*DisplayMetadata, error) { + return nil, fmt.Errorf("automatic fetching of EDID data is not supported on macOS") +} + +// Loads custom firmware for a supported XR glass device +func LoadCustomEDIDFirmware(displayMetadata *DisplayMetadata, edidFirmware []byte) error { + return fmt.Errorf("loading custom EDID firmware is not supported on macOS") +} + +// Unloads custom firmware for a supported XR glass device +func UnloadCustomEDIDFirmware(displayMetadata *DisplayMetadata) error { + return fmt.Errorf("unloading custom EDID firmware is not supported on macOS") +} diff --git a/app/edidtools/patching_tools_win.go b/app/edidtools/patching_tools_win.go new file mode 100644 index 0000000..e9f515f --- /dev/null +++ b/app/edidtools/patching_tools_win.go @@ -0,0 +1,21 @@ +//go:build windows +// +build windows + +package edidtools + +import "fmt" + +// Attempts to fetch the EDID firmware for any supported XR glasses device +func FetchXRGlassEDID(allowUnsupportedDevices bool) (*DisplayMetadata, error) { + return nil, fmt.Errorf("automatic fetching of EDID data is not supported on Windows") +} + +// Loads custom firmware for a supported XR glass device +func LoadCustomEDIDFirmware(displayMetadata *DisplayMetadata, edidFirmware []byte) error { + return fmt.Errorf("loading custom EDID firmware is not supported on Windows") +} + +// Unloads custom firmware for a supported XR glass device +func UnloadCustomEDIDFirmware(displayMetadata *DisplayMetadata) error { + return fmt.Errorf("unloading custom EDID firmware is not supported on Windows") +} diff --git a/app/edidtools/quirks.go b/app/edidtools/quirks.go new file mode 100644 index 0000000..bd7618b --- /dev/null +++ b/app/edidtools/quirks.go @@ -0,0 +1,14 @@ +package edidtools + +// Vendor and devices names sourced from "https://uefi.org/uefi-pnp-export" +var QuirksRegistry = map[string]map[string]DisplayQuirks{ + "MRG": { + "Air": { + MaxWidth: 1920, + MaxHeight: 1080, + MaxRefreshRate: 120, + SensorInitDelay: 10, + ZVectorDisabled: true, + }, + }, +} diff --git a/app/edidtools/struct.go b/app/edidtools/struct.go new file mode 100644 index 0000000..361c440 --- /dev/null +++ b/app/edidtools/struct.go @@ -0,0 +1,20 @@ +package edidtools + +type DisplayQuirks struct { + MaxWidth int + MaxHeight int + MaxRefreshRate int + SensorInitDelay int + ZVectorDisabled bool +} + +type DisplayMetadata struct { + EDID []byte + DeviceVendor string + DeviceQuirks DisplayQuirks + MaxWidth int + MaxHeight int + MaxRefreshRate int + LinuxDRMCard string + LinuxDRMConnector string +} diff --git a/app/main.go b/app/main.go index 1506e22..a7bf8f4 100644 --- a/app/main.go +++ b/app/main.go @@ -1,11 +1,15 @@ package main import ( + "bufio" "context" "fmt" "os" "path" + libconfig "git.terah.dev/UnrealXR/unrealxr/app/config" + "git.terah.dev/UnrealXR/unrealxr/app/edidtools" + "git.terah.dev/UnrealXR/unrealxr/edidpatcher" "github.com/charmbracelet/log" "github.com/goccy/go-yaml" "github.com/kirsle/configdir" @@ -35,7 +39,7 @@ func mainEntrypoint(context.Context, *cli.Command) error { if err != nil { log.Debug("Creating default config file") - err := os.WriteFile(path.Join(configDir, "config.yml"), InitialConfig, 0644) + err := os.WriteFile(path.Join(configDir, "config.yml"), libconfig.InitialConfig, 0644) if err != nil { return fmt.Errorf("failed to create initial config file: %w", err) @@ -49,14 +53,53 @@ func mainEntrypoint(context.Context, *cli.Command) error { return fmt.Errorf("failed to read config file: %w", err) } - config := &Config{} + config := &libconfig.Config{} err = yaml.Unmarshal(configBytes, config) if err != nil { return fmt.Errorf("failed to parse config file: %w", err) } - InitializePotentiallyMissingConfigValues(config) + libconfig.InitializePotentiallyMissingConfigValues(config) + log.Info("Attempting to read display EDID file and fetch metadata") + + displayMetadata, err := edidtools.FetchXRGlassEDID(*config.Overrides.AllowUnsupportedDevices) + + if err != nil { + return fmt.Errorf("failed to fetch EDID or get metadata: %w", err) + } + + log.Info("Got EDID file and metadata") + log.Info("Patching EDID firmware to be specialized") + + patchedFirmware, err := edidpatcher.PatchEDIDToBeSpecialized(displayMetadata.EDID) + + if err != nil { + return fmt.Errorf("failed to patch EDID firmware: %w", err) + } + + log.Info("Uploading patched EDID firmware") + err = edidtools.LoadCustomEDIDFirmware(displayMetadata, patchedFirmware) + + if err != nil { + return fmt.Errorf("failed to upload patched EDID firmware: %w", err) + } + + defer func() { + err := edidtools.UnloadCustomEDIDFirmware(displayMetadata) + + if err != nil { + log.Errorf("Failed to unload custom EDID firmware: %s", err.Error()) + } + + log.Info("Please unplug and plug in your XR device to restore it back to normal settings.") + }() + + fmt.Print("Press the Enter key to continue loading after you unplug and plug in your XR device.") + bufio.NewReader(os.Stdin).ReadBytes('\n') // Wait for Enter key press before continuing + + log.Info("Initializing XR headset") + return nil } @@ -90,6 +133,6 @@ func main() { } if err := cmd.Run(context.Background(), os.Args); err != nil { - log.Fatal(err) + log.Fatalf("Fatal error during execution: %s", err.Error()) } } diff --git a/edidpatcher/edidpatcher.go b/edidpatcher/edidpatcher.go new file mode 100644 index 0000000..a56b386 --- /dev/null +++ b/edidpatcher/edidpatcher.go @@ -0,0 +1,102 @@ +package edidpatcher + +import ( + "fmt" + + "github.com/charmbracelet/log" + "github.com/google/uuid" +) + +// Calculates a checksum for a given EDID block (base EDID, extension blocks, etc.) +func CalculateEDIDChecksum(edidBlock []byte) byte { + sum := 0 + + for _, value := range edidBlock[:len(edidBlock)-1] { + sum += int(value) + } + + return byte((-sum) & 0xFF) +} + +var MSFTPayloadSize = byte(22 + 4) + +// Patch a given EDID to be a "specialized display", allowing for the display to be used by third-party window-managers/compositors/applications directly. +func PatchEDIDToBeSpecialized(edid []byte) ([]byte, error) { + newEDID := make([]byte, len(edid)) + copy(newEDID, edid) + + isAnEnhancedEDID := len(newEDID) > 128 + + foundExtensionBase := 0 + extensionBaseExists := false + + // Find an appropriate extension base + if isAnEnhancedEDID { + for currentExtensionPosition := 128; currentExtensionPosition < len(newEDID); currentExtensionPosition += 128 { + if newEDID[currentExtensionPosition] != 0x02 { + continue + } + + if newEDID[currentExtensionPosition+1] != 0x03 { + log.Warn("Incompatible version detected for ANSI CTA data section in EDID") + } + + foundExtensionBase = currentExtensionPosition + extensionBaseExists = true + } + + if foundExtensionBase == 0 { + foundExtensionBase = len(newEDID) + newEDID = append(newEDID, make([]byte, 128)...) + } + } else { + foundExtensionBase = 128 + newEDID = append(newEDID, make([]byte, 128)...) + } + + newEDID[foundExtensionBase+2] = MSFTPayloadSize + + if !extensionBaseExists { + // Add another extension to the original EDID + if newEDID[126] == 255 { + return nil, fmt.Errorf("EDID extension block limit reached, but we need to add another extension") + } + + newEDID[126] += 1 + newEDID[127] = CalculateEDIDChecksum(newEDID[:128]) + + newEDID[foundExtensionBase] = 0x02 + newEDID[foundExtensionBase+1] = 0x03 + newEDID[foundExtensionBase+3] = 0x00 + } else { + if newEDID[foundExtensionBase+2] != MSFTPayloadSize && newEDID[foundExtensionBase+2] != 0 { + currentBase := newEDID[foundExtensionBase+2] + + copy(newEDID[foundExtensionBase+4:foundExtensionBase+int(currentBase)-1], make([]byte, int(currentBase)-1)) + copy(newEDID[foundExtensionBase+int(MSFTPayloadSize):foundExtensionBase+127], newEDID[foundExtensionBase+int(currentBase):foundExtensionBase+127]) + } + } + + generatedUUID := uuid.New() + uuidBytes, err := generatedUUID.MarshalBinary() + + if err != nil { + return nil, fmt.Errorf("failed to marshal UUID: %w", err) + } + + // Implemented using https://learn.microsoft.com/en-us/windows-hardware/drivers/display/specialized-monitors-edid-extension + // VST & Length + newEDID[foundExtensionBase+4] = 0x3<<5 | 0x15 // 0x3: vendor specific tag; 0x15: length + // Assigned IEEE OUI + newEDID[foundExtensionBase+5] = 0x5C + newEDID[foundExtensionBase+6] = 0x12 + newEDID[foundExtensionBase+7] = 0xCA + // Actual data + newEDID[foundExtensionBase+8] = 0x2 // Using version 0x2 for better compatibility + newEDID[foundExtensionBase+9] = 0x7 // Using VR tag for better compatibility even though it probably doesn't matter + copy(newEDID[foundExtensionBase+10:foundExtensionBase+10+16], uuidBytes) + + newEDID[foundExtensionBase+127] = CalculateEDIDChecksum(newEDID[foundExtensionBase : foundExtensionBase+127]) + + return newEDID, nil +} diff --git a/go.mod b/go.mod index 3078cf8..b09e4fc 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module git.terah.dev/UnrealXR/unrealxr go 1.24.3 require ( + github.com/anoopengineer/edidparser v0.0.0-20240602223913-86ca9ed3d2b0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/lipgloss v1.1.0 // indirect @@ -12,6 +13,7 @@ require ( github.com/charmbracelet/x/term v0.2.1 // indirect github.com/go-logfmt/logfmt v0.6.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index 7aa150c..63b1134 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/anoopengineer/edidparser v0.0.0-20240602223913-86ca9ed3d2b0 h1:rTfysyBCL7LPbq9GFpQbllvKT8vEI93lQUwksMMxHMI= +github.com/anoopengineer/edidparser v0.0.0-20240602223913-86ca9ed3d2b0/go.mod h1:fEt61NePh3ZMxA+g3iC4CaGzY9lEsHRUkYJY2x0lBAw= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= @@ -16,6 +18,8 @@ github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f h1:dKccXx7xA56UNqOcFIbuqFjAWPVtP688j5QMgmo6OHU= github.com/kirsle/configdir v0.0.0-20170128060238-e45d2f54772f/go.mod h1:4rEELDSfUAlBSyUjPG0JnaNGjf13JySHFeRdD/3dLP0= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=