package blender import ( "compress/gzip" "fmt" "io" "os" "os/exec" ) // ParseVersionFromFile parses the Blender version that a .blend file was saved with. // Returns major and minor version numbers. func ParseVersionFromFile(blendPath string) (major, minor int, err error) { file, err := os.Open(blendPath) if err != nil { return 0, 0, fmt.Errorf("failed to open blend file: %w", err) } defer file.Close() // Read the first 12 bytes of the blend file header // Format: BLENDER-v or BLENDER_v // The header is: "BLENDER" (7 bytes) + pointer size (1 byte: '-' for 64-bit, '_' for 32-bit) // + endianness (1 byte: 'v' for little-endian, 'V' for big-endian) // + version (3 bytes: e.g., "402" for 4.02) header := make([]byte, 12) n, err := file.Read(header) if err != nil || n < 12 { return 0, 0, fmt.Errorf("failed to read blend file header: %w", err) } // Check for BLENDER magic if string(header[:7]) != "BLENDER" { // Might be compressed - try to decompress file.Seek(0, 0) return parseCompressedVersion(file) } // Parse version from bytes 9-11 (3 digits) versionStr := string(header[9:12]) // Version format changed in Blender 3.0 // Pre-3.0: "279" = 2.79, "280" = 2.80 // 3.0+: "300" = 3.0, "402" = 4.02, "410" = 4.10 if len(versionStr) == 3 { // First digit is major version fmt.Sscanf(string(versionStr[0]), "%d", &major) // Next two digits are minor version fmt.Sscanf(versionStr[1:3], "%d", &minor) } return major, minor, nil } // parseCompressedVersion handles gzip and zstd compressed blend files. func parseCompressedVersion(file *os.File) (major, minor int, err error) { magic := make([]byte, 4) if _, err := file.Read(magic); err != nil { return 0, 0, err } file.Seek(0, 0) if magic[0] == 0x1f && magic[1] == 0x8b { // gzip compressed gzReader, err := gzip.NewReader(file) if err != nil { return 0, 0, fmt.Errorf("failed to create gzip reader: %w", err) } defer gzReader.Close() header := make([]byte, 12) n, err := gzReader.Read(header) if err != nil || n < 12 { return 0, 0, fmt.Errorf("failed to read compressed blend header: %w", err) } if string(header[:7]) != "BLENDER" { return 0, 0, fmt.Errorf("invalid blend file format") } versionStr := string(header[9:12]) if len(versionStr) == 3 { fmt.Sscanf(string(versionStr[0]), "%d", &major) fmt.Sscanf(versionStr[1:3], "%d", &minor) } return major, minor, nil } // Check for zstd magic (Blender 3.0+): 0x28 0xB5 0x2F 0xFD if magic[0] == 0x28 && magic[1] == 0xb5 && magic[2] == 0x2f && magic[3] == 0xfd { return parseZstdVersion(file) } return 0, 0, fmt.Errorf("unknown blend file format") } // parseZstdVersion handles zstd-compressed blend files (Blender 3.0+). // Uses zstd command line tool since Go doesn't have native zstd support. func parseZstdVersion(file *os.File) (major, minor int, err error) { file.Seek(0, 0) cmd := exec.Command("zstd", "-d", "-c") cmd.Stdin = file stdout, err := cmd.StdoutPipe() if err != nil { return 0, 0, fmt.Errorf("failed to create zstd stdout pipe: %w", err) } if err := cmd.Start(); err != nil { return 0, 0, fmt.Errorf("failed to start zstd decompression: %w", err) } // Read just the header (12 bytes) header := make([]byte, 12) n, readErr := io.ReadFull(stdout, header) // Kill the process early - we only need the header cmd.Process.Kill() cmd.Wait() if readErr != nil || n < 12 { return 0, 0, fmt.Errorf("failed to read zstd compressed blend header: %v", readErr) } if string(header[:7]) != "BLENDER" { return 0, 0, fmt.Errorf("invalid blend file format in zstd archive") } versionStr := string(header[9:12]) if len(versionStr) == 3 { fmt.Sscanf(string(versionStr[0]), "%d", &major) fmt.Sscanf(versionStr[1:3], "%d", &minor) } return major, minor, nil } // VersionString returns a formatted version string like "4.2". func VersionString(major, minor int) string { return fmt.Sprintf("%d.%d", major, minor) }