@@ -376,6 +376,8 @@ pub struct SocInfo {
376376 pub memory_gb : u8 ,
377377 pub ecpu_cores : u8 ,
378378 pub pcpu_cores : u8 ,
379+ pub ecpu_label : String , // "E" on M1-M4, "P" on M5+
380+ pub pcpu_label : String , // "P" on M1-M4, "S" on M5+
379381 pub ecpu_freqs : Vec < u32 > ,
380382 pub pcpu_freqs : Vec < u32 > ,
381383 pub gpu_cores : u8 ,
@@ -389,9 +391,9 @@ impl SocInfo {
389391}
390392
391393// dynamic voltage and frequency scaling
392- pub fn get_dvfs_mhz ( dict : CFDictionaryRef , key : & str ) -> ( Vec < u32 > , Vec < u32 > ) {
394+ pub fn get_dvfs_mhz ( dict : CFDictionaryRef , key : & str ) -> Option < ( Vec < u32 > , Vec < u32 > ) > {
393395 unsafe {
394- let obj = cfdict_get_val ( dict, key) . unwrap ( ) as CFDataRef ;
396+ let obj = cfdict_get_val ( dict, key) ? as CFDataRef ;
395397 let obj_len = CFDataGetLength ( obj) ;
396398 let obj_val = vec ! [ 0u8 ; obj_len as usize ] ;
397399 CFDataGetBytes ( obj, CFRange :: init ( 0 , obj_len) , obj_val. as_ptr ( ) as * mut u8 ) ;
@@ -404,10 +406,40 @@ pub fn get_dvfs_mhz(dict: CFDictionaryRef, key: &str) -> (Vec<u32>, Vec<u32>) {
404406 freqs[ i] = u32:: from_le_bytes ( [ x[ 0 ] , x[ 1 ] , x[ 2 ] , x[ 3 ] ] ) ;
405407 }
406408
407- ( volts, freqs)
409+ Some ( ( volts, freqs) )
408410 }
409411}
410412
413+ // Parse acc-clusters bytes into (ecpu_key, pcpu_key) voltage-states key names.
414+ // Each 8-byte entry: byte 0 = voltage-states index, byte 1 = cluster type
415+ // (0 = efficiency/lowest tier, higher = higher perf tier).
416+ // Picks highest type as pcpu, second-highest as ecpu — handles M5 Max where
417+ // type 0 (E-core cluster) is absent and the two active tiers are 1 and 2.
418+ fn parse_acc_clusters ( data : & [ u8 ] ) -> Option < ( String , String ) > {
419+ let mut clusters: Vec < ( u8 , String ) > = Vec :: new ( ) ;
420+ for chunk in data. chunks_exact ( 8 ) {
421+ clusters. push ( ( chunk[ 1 ] , format ! ( "voltage-states{}-sram" , chunk[ 0 ] ) ) ) ;
422+ }
423+ clusters. sort_by_key ( |c| c. 0 ) ;
424+ if clusters. len ( ) < 2 { return None ; }
425+ let ecpu_key = clusters[ clusters. len ( ) - 2 ] . 1 . clone ( ) ;
426+ let pcpu_key = clusters. last ( ) ?. 1 . clone ( ) ;
427+ Some ( ( ecpu_key, pcpu_key) )
428+ }
429+
430+ // Read acc-clusters from pmgr dict and parse into (ecpu_key, pcpu_key).
431+ fn parse_acc_clusters_from ( dict : CFDictionaryRef ) -> Option < ( String , String ) > {
432+ let obj = cfdict_get_val ( dict, "acc-clusters" ) ? as CFDataRef ;
433+
434+ let len = unsafe { CFDataGetLength ( obj) } as usize ;
435+ if len < 8 { return None ; }
436+
437+ let mut data = vec ! [ 0u8 ; len] ;
438+ unsafe { CFDataGetBytes ( obj, CFRange :: init ( 0 , len as _ ) , data. as_mut_ptr ( ) ) } ;
439+
440+ parse_acc_clusters ( & data)
441+ }
442+
411443pub fn run_system_profiler ( ) -> WithError < serde_json:: Value > {
412444 // system_profiler -listDataTypes
413445 let out = std:: process:: Command :: new ( "system_profiler" )
@@ -423,6 +455,29 @@ fn to_mhz(vals: Vec<u32>, scale: u32) -> Vec<u32> {
423455 vals. iter ( ) . map ( |x| * x / scale) . collect ( )
424456}
425457
458+ // Parse "proc T:P:E" (macOS 15) or "proc T:P_or_S:E:M" (macOS 26+) into (ecpu, pcpu, has_mcpu).
459+ // macOS 26 always uses 4 fields regardless of chip; the 4th field (M-cores) is >0 only on M5+.
460+ fn parse_cpu_cores ( s : & str ) -> ( u64 , u64 , bool ) {
461+ let fields: Vec < u64 > = s
462+ . strip_prefix ( "proc " )
463+ . unwrap_or ( "" )
464+ . split ( ':' )
465+ . map ( |x| x. parse ( ) . unwrap_or ( 0 ) )
466+ . collect ( ) ;
467+
468+ match fields. len ( ) {
469+ 4 => {
470+ // macOS 26+: "proc total:P_or_S:E:M"
471+ // M5+: E=0, M>0 → ecpu=M (Performance cores), pcpu=S (Super cores)
472+ // M1-M4: M=0, E>0 → ecpu=E (Efficiency cores), pcpu=P (Performance cores)
473+ let ( e, m) = ( fields[ 2 ] , fields[ 3 ] ) ;
474+ if m > 0 { ( m, fields[ 1 ] , true ) } else { ( e, fields[ 1 ] , false ) }
475+ }
476+ 3 => ( fields[ 2 ] , fields[ 1 ] , false ) , // macOS 15: "proc total:P:E"
477+ _ => ( 0 , 0 , false ) ,
478+ }
479+ }
480+
426481pub fn get_soc_info ( ) -> WithError < SocInfo > {
427482 let out = run_system_profiler ( ) ?;
428483 let mut info = SocInfo :: default ( ) ;
@@ -443,19 +498,10 @@ pub fn get_soc_info() -> WithError<SocInfo> {
443498 . parse :: < u64 > ( )
444499 . unwrap_or ( 0 ) ;
445500
446- // SPHardwareDataType.0.number_processors -> "proc x:y:z"
447- let cpu_cores = out[ "SPHardwareDataType" ] [ 0 ] [ "number_processors" ]
448- . as_str ( )
449- . and_then ( |cores| cores. strip_prefix ( "proc " ) )
450- . unwrap_or ( "" )
451- . split ( ':' )
452- . map ( |x| x. parse :: < u64 > ( ) . unwrap_or ( 0 ) )
453- . collect :: < Vec < _ > > ( ) ;
454- let ( ecpu_cores, pcpu_cores) = if cpu_cores. len ( ) == 3 {
455- ( cpu_cores[ 2 ] , cpu_cores[ 1 ] )
456- } else {
457- ( 0 , 0 ) // Fallback in case of invalid data
458- } ;
501+ // SPHardwareDataType.0.number_processors -> "proc x:y:z" or "proc x:y:z:w"
502+ let number_processors =
503+ out[ "SPHardwareDataType" ] [ 0 ] [ "number_processors" ] . as_str ( ) . unwrap_or ( "" ) ;
504+ let ( ecpu_cores, pcpu_cores, has_mcpu) = parse_cpu_cores ( number_processors) ;
459505
460506 // SPDisplaysDataType.0.sppci_cores
461507 let gpu_cores =
@@ -473,6 +519,8 @@ pub fn get_soc_info() -> WithError<SocInfo> {
473519 info. gpu_cores = gpu_cores as u8 ;
474520 info. ecpu_cores = ecpu_cores as u8 ;
475521 info. pcpu_cores = pcpu_cores as u8 ;
522+ info. ecpu_label = if has_mcpu { "P" . into ( ) } else { "E" . into ( ) } ;
523+ info. pcpu_label = if has_mcpu { "S" . into ( ) } else { "P" . into ( ) } ;
476524
477525 // CPU frequencies
478526 for ( entry, name) in IOServiceIterator :: new ( "AppleARMIODevice" ) ? {
@@ -481,9 +529,24 @@ pub fn get_soc_info() -> WithError<SocInfo> {
481529 // 1) `strings /usr/bin/powermetrics | grep voltage-states` uses non-sram keys
482530 // but their values are zero, so sram used here; it looks valid.
483531 // 2) sudo powermetrics --samplers cpu_power -i 1000 -n 1 | grep "active residency" | grep "Cluster"
484- info. ecpu_freqs = to_mhz ( get_dvfs_mhz ( item, "voltage-states1-sram" ) . 1 , cpu_scale) ;
485- info. pcpu_freqs = to_mhz ( get_dvfs_mhz ( item, "voltage-states5-sram" ) . 1 , cpu_scale) ;
486- info. gpu_freqs = to_mhz ( get_dvfs_mhz ( item, "voltage-states9" ) . 1 , gpu_scale) ;
532+ // Try legacy keys first (M1-M4), fall back to acc-clusters discovery (M5+)
533+ if let Some ( ( _, freqs) ) = get_dvfs_mhz ( item, "voltage-states1-sram" ) {
534+ info. ecpu_freqs = to_mhz ( freqs, cpu_scale) ;
535+ } else if let Some ( ( ecpu_key, _) ) = parse_acc_clusters_from ( item) {
536+ if let Some ( ( _, freqs) ) = get_dvfs_mhz ( item, & ecpu_key) {
537+ info. ecpu_freqs = to_mhz ( freqs, cpu_scale) ;
538+ }
539+ }
540+ if let Some ( ( _, freqs) ) = get_dvfs_mhz ( item, "voltage-states5-sram" ) {
541+ info. pcpu_freqs = to_mhz ( freqs, cpu_scale) ;
542+ } else if let Some ( ( _, pcpu_key) ) = parse_acc_clusters_from ( item) {
543+ if let Some ( ( _, freqs) ) = get_dvfs_mhz ( item, & pcpu_key) {
544+ info. pcpu_freqs = to_mhz ( freqs, cpu_scale) ;
545+ }
546+ }
547+ if let Some ( ( _, freqs) ) = get_dvfs_mhz ( item, "voltage-states9" ) {
548+ info. gpu_freqs = to_mhz ( freqs, gpu_scale) ;
549+ }
487550 unsafe { CFRelease ( item as _ ) }
488551 }
489552 }
@@ -915,3 +978,65 @@ impl Drop for SMC {
915978 }
916979 }
917980}
981+
982+ #[ cfg( test) ]
983+ mod tests {
984+ use super :: * ;
985+
986+ #[ test]
987+ fn parse_acc_clusters_m5_max ( ) {
988+ // Real acc-clusters bytes captured from M5 Max via ioreg
989+ let data = [
990+ 0x16 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 ,
991+ 0x17 , 0x01 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 ,
992+ 0x05 , 0x02 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 , 0x00 ,
993+ ] ;
994+ let ( e, p) = parse_acc_clusters ( & data) . unwrap ( ) ;
995+ // Second-highest type (1 = Performance) as ecpu, highest (2 = Super) as pcpu
996+ assert_eq ! ( e, "voltage-states23-sram" ) ;
997+ assert_eq ! ( p, "voltage-states5-sram" ) ;
998+ }
999+
1000+ #[ test]
1001+ fn parse_acc_clusters_incomplete ( ) {
1002+ assert ! ( parse_acc_clusters( & [ ] ) . is_none( ) ) ;
1003+ // Single cluster – need both ecpu and pcpu
1004+ assert ! ( parse_acc_clusters( & [ 1 , 0 , 0 , 0 , 0 , 0 , 0 , 0 ] ) . is_none( ) ) ;
1005+ }
1006+
1007+ #[ test]
1008+ fn parse_cpu_cores_macos26_4field ( ) {
1009+ // Real data captured from macOS 26 machines
1010+ // M5 Max: 18 total, 6 super, 0 efficiency, 12 performance(M-cores)
1011+ assert_eq ! ( parse_cpu_cores( "proc 18:6:0:12" ) , ( 12 , 6 , true ) ) ;
1012+ // M4 Max: 16 total, 12 performance, 4 efficiency, 0 M-cores
1013+ assert_eq ! ( parse_cpu_cores( "proc 16:12:4:0" ) , ( 4 , 12 , false ) ) ;
1014+ // M3 Air: 8 total, 4 performance, 4 efficiency, 0 M-cores
1015+ assert_eq ! ( parse_cpu_cores( "proc 8:4:4:0" ) , ( 4 , 4 , false ) ) ;
1016+ }
1017+
1018+ #[ test]
1019+ fn parse_cpu_cores_macos15_3field ( ) {
1020+ // Real data: M3 Air on macOS 15.6.1
1021+ assert_eq ! ( parse_cpu_cores( "proc 8:4:4" ) , ( 4 , 4 , false ) ) ;
1022+ }
1023+
1024+ #[ test]
1025+ fn parse_cpu_cores_invalid ( ) {
1026+ assert_eq ! ( parse_cpu_cores( "" ) , ( 0 , 0 , false ) ) ;
1027+ assert_eq ! ( parse_cpu_cores( "garbage" ) , ( 0 , 0 , false ) ) ;
1028+ assert_eq ! ( parse_cpu_cores( "10:8:2" ) , ( 0 , 0 , false ) ) ; // missing "proc " prefix
1029+ assert_eq ! ( parse_cpu_cores( "proc 8" ) , ( 0 , 0 , false ) ) ; // too few fields
1030+ assert_eq ! ( parse_cpu_cores( "proc 8:4" ) , ( 0 , 0 , false ) ) ; // 2 fields, unsupported
1031+ assert_eq ! ( parse_cpu_cores( "proc 24:6:0:12:6" ) , ( 0 , 0 , false ) ) ; // unknown future format
1032+ }
1033+
1034+ #[ test]
1035+ fn to_mhz_scales ( ) {
1036+ // M4+: KHz scale
1037+ assert_eq ! ( to_mhz( vec![ 4608000 , 3000000 ] , 1000 ) , vec![ 4608 , 3000 ] ) ;
1038+ // M1-M3: MHz scale
1039+ assert_eq ! ( to_mhz( vec![ 3_000_000_000 , 2_000_000_000 ] , 1000 * 1000 ) , vec![ 3000 , 2000 ] ) ;
1040+ assert_eq ! ( to_mhz( vec![ ] , 1000 ) , Vec :: <u32 >:: new( ) ) ;
1041+ }
1042+ }
0 commit comments