@@ -117,9 +117,126 @@ private static ulong GetTotalPhysicalMemory()
117117 // Ini LEBIH RELIABLE daripada vendor-name heuristic karena langsung nanya ke driver
118118 // storage — HDD selalu punya seek penalty, SSD tidak pernah.
119119 // Tidak perlu admin rights karena pake volume handle (bukan physical drive handle).
120+ //
121+ // ── Persistent cache (v7.3.0) ────────────────────────────────────────────
122+ // Sebelumnya hasil hanya di-cache in-memory (static field) yang reset tiap
123+ // Play karena bootstrapper selalu spawn proses baru → query IOCTL jalan
124+ // ULANG di SETIAP launch padahal hardware laptop tidak berubah.
125+ // Sekarang hasil + timestamp disimpan ke %LocalAppData%\BoneFish\HardwareCache.json.
126+ // IsSSD() memakai cache bila < 30 hari DAN drive sistem tidak berubah
127+ // (root path + volume serial). Tombol "Deteksi Ulang Hardware" di panel
128+ // System Info memanggil ForceRefreshHardwareCache() untuk refresh paksa
129+ // (misal user upgrade HDD → SSD tanpa mau nunggu cache kedaluwarsa).
130+ //
131+ // AUDIT CheckAndApply() untuk kandidat cache serupa: DetectSystemTier()
132+ // (Environment.ProcessorCount + GlobalMemoryStatusEx) dan GetTotalPhysicalMemory()
133+ // adalah syscall kernel super ringan (±µs) — membaca cache dari disk justru
134+ // lebih lambat, jadi TIDAK ikut di-persist. Query IOCTL volume handle
135+ // satu-satunya yang worth di-cache lintas sesi.
136+ private const int HardwareCacheMaxAgeDays = 30 ;
137+ private static readonly string HardwareCachePath = Path . Combine ( Paths . LocalAppData , App . ProjectName , "HardwareCache.json" ) ;
138+
139+ public sealed class HardwareCacheEntry
140+ {
141+ public bool IsSSD { get ; set ; }
142+ public string SystemDriveRoot { get ; set ; } = "" ;
143+ public uint VolumeSerial { get ; set ; }
144+ public DateTime DetectedAtUtc { get ; set ; }
145+ }
146+
147+ [ DllImport ( "kernel32.dll" , SetLastError = true , CharSet = CharSet . Auto ) ]
148+ private static extern bool GetVolumeInformation (
149+ string lpRootPathName ,
150+ StringBuilder ? lpVolumeNameBuffer ,
151+ int nVolumeNameSize ,
152+ out uint lpVolumeSerialNumber ,
153+ out uint lpMaximumComponentLength ,
154+ out uint lpFileSystemFlags ,
155+ StringBuilder ? lpFileSystemNameBuffer ,
156+ int nFileSystemNameSize ) ;
157+
120158 private static bool ? _isSSDCached = null ;
121159 private static readonly object _storageLock = new ( ) ;
122160
161+ private static string GetSystemDriveRoot ( ) =>
162+ Path . GetPathRoot ( Environment . SystemDirectory ) ?? "C:\\ " ;
163+
164+ private static uint GetSystemVolumeSerial ( )
165+ {
166+ try
167+ {
168+ var nameBuffer = new StringBuilder ( 256 ) ;
169+ if ( GetVolumeInformation ( GetSystemDriveRoot ( ) , nameBuffer , nameBuffer . Capacity ,
170+ out uint serial , out _ , out _ , null , 0 ) )
171+ return serial ;
172+ }
173+ catch ( Exception ex )
174+ {
175+ App . Logger . WriteLine ( LOG_IDENT , $ "GetVolumeInformation failed: { ex . Message } ") ;
176+ }
177+ return 0 ;
178+ }
179+
180+ private static bool ? TryLoadStorageTypeFromCache ( )
181+ {
182+ try
183+ {
184+ if ( ! File . Exists ( HardwareCachePath ) )
185+ return null ;
186+
187+ var entry = JsonSerializer . Deserialize < HardwareCacheEntry > ( File . ReadAllText ( HardwareCachePath ) ) ;
188+ if ( entry is null )
189+ return null ;
190+
191+ if ( ( DateTime . UtcNow - entry . DetectedAtUtc ) . TotalDays > HardwareCacheMaxAgeDays )
192+ {
193+ App . Logger . WriteLine ( LOG_IDENT , $ "Persistent storage cache expired (>{ HardwareCacheMaxAgeDays } days) — re-detecting") ;
194+ return null ;
195+ }
196+
197+ if ( ! string . Equals ( entry . SystemDriveRoot , GetSystemDriveRoot ( ) , StringComparison . OrdinalIgnoreCase ) )
198+ {
199+ App . Logger . WriteLine ( LOG_IDENT , "System drive root changed since cache write — re-detecting" ) ;
200+ return null ;
201+ }
202+
203+ uint currentSerial = GetSystemVolumeSerial ( ) ;
204+ if ( currentSerial != 0 && entry . VolumeSerial != 0 && currentSerial != entry . VolumeSerial )
205+ {
206+ App . Logger . WriteLine ( LOG_IDENT , "System volume serial changed since cache write — re-detecting" ) ;
207+ return null ;
208+ }
209+
210+ return entry . IsSSD ;
211+ }
212+ catch ( Exception ex )
213+ {
214+ App . Logger . WriteLine ( LOG_IDENT , $ "Could not read persistent storage cache: { ex . Message } ") ;
215+ return null ;
216+ }
217+ }
218+
219+ private static void SaveStorageTypeToCache ( bool isSSD )
220+ {
221+ try
222+ {
223+ var entry = new HardwareCacheEntry
224+ {
225+ IsSSD = isSSD ,
226+ SystemDriveRoot = GetSystemDriveRoot ( ) ,
227+ VolumeSerial = GetSystemVolumeSerial ( ) ,
228+ DetectedAtUtc = DateTime . UtcNow
229+ } ;
230+
231+ Directory . CreateDirectory ( Path . GetDirectoryName ( HardwareCachePath ) ! ) ;
232+ File . WriteAllText ( HardwareCachePath , JsonSerializer . Serialize ( entry , new JsonSerializerOptions { WriteIndented = true } ) ) ;
233+ }
234+ catch ( Exception ex )
235+ {
236+ App . Logger . WriteLine ( LOG_IDENT , $ "Could not save persistent storage cache: { ex . Message } ") ;
237+ }
238+ }
239+
123240 private static bool IsSSD ( )
124241 {
125242 if ( _isSSDCached . HasValue )
@@ -130,98 +247,130 @@ private static bool IsSSD()
130247 if ( _isSSDCached . HasValue )
131248 return _isSSDCached . Value ;
132249
133- try
250+ bool ? cachedSSD = TryLoadStorageTypeFromCache ( ) ;
251+ if ( cachedSSD . HasValue )
134252 {
135- // Buka handle ke system volume ("\\.\C:")
136- string systemDrive = Path . GetPathRoot ( Environment . SystemDirectory ) ?? "C:\\ " ;
137- string volumePath = @"\\.\" + systemDrive . TrimEnd ( '\\ ' ) ;
138-
139- IntPtr hVolume = CreateFile (
140- volumePath ,
141- GENERIC_READ ,
142- FILE_SHARE_READ | FILE_SHARE_WRITE ,
143- IntPtr . Zero ,
144- OPEN_EXISTING ,
145- FILE_ATTRIBUTE_NORMAL ,
146- IntPtr . Zero ) ;
147-
148- if ( hVolume == INVALID_HANDLE_VALUE )
149- {
150- App . Logger . WriteLine ( LOG_IDENT , $ "Could not open volume { volumePath } for seek penalty query. Assuming HDD.") ;
151- _isSSDCached = false ;
152- return false ;
153- }
253+ _isSSDCached = cachedSSD . Value ;
254+ App . Logger . WriteLine ( LOG_IDENT , $ "Storage type from persistent cache: { ( cachedSSD . Value ? "SSD" : "HDD" ) } (IOCTL skipped)") ;
255+ return cachedSSD . Value ;
256+ }
154257
155- try
258+ var stopwatch = Stopwatch . StartNew ( ) ;
259+ bool isSSD = DetectStorageType ( ) ;
260+ stopwatch . Stop ( ) ;
261+
262+ _isSSDCached = isSSD ;
263+ SaveStorageTypeToCache ( isSSD ) ;
264+ App . Logger . WriteLine ( LOG_IDENT , $ "Storage detection via IOCTL finished in { stopwatch . ElapsedMilliseconds } ms — persistent cache updated") ;
265+
266+ return isSSD ;
267+ }
268+ }
269+
270+ public static void ForceRefreshHardwareCache ( )
271+ {
272+ lock ( _storageLock )
273+ {
274+ _isSSDCached = null ;
275+ try { File . Delete ( HardwareCachePath ) ; } catch { }
276+ }
277+
278+ bool isSSD = IsSSD ( ) ;
279+ App . Logger . WriteLine ( LOG_IDENT , $ "Hardware detection manually refreshed: { ( isSSD ? "SSD" : "HDD" ) } (persistent cache re-written)") ;
280+ }
281+
282+ // Query seek-penalty IOCTL — logika deteksi ASLI (tidak diubah), hanya
283+ // dipisah dari caching supaya IsSSD() bisa memotong query ini saat
284+ // persistent cache masih valid.
285+ private static bool DetectStorageType ( )
286+ {
287+ try
288+ {
289+ // Buka handle ke system volume ("\\.\C:")
290+ string systemDrive = Path . GetPathRoot ( Environment . SystemDirectory ) ?? "C:\\ " ;
291+ string volumePath = @"\\.\" + systemDrive . TrimEnd ( '\\ ' ) ;
292+
293+ IntPtr hVolume = CreateFile (
294+ volumePath ,
295+ GENERIC_READ ,
296+ FILE_SHARE_READ | FILE_SHARE_WRITE ,
297+ IntPtr . Zero ,
298+ OPEN_EXISTING ,
299+ FILE_ATTRIBUTE_NORMAL ,
300+ IntPtr . Zero ) ;
301+
302+ if ( hVolume == INVALID_HANDLE_VALUE )
303+ {
304+ App . Logger . WriteLine ( LOG_IDENT , $ "Could not open volume { volumePath } for seek penalty query. Assuming HDD.") ;
305+ return false ;
306+ }
307+
308+ try
309+ {
310+ // Prepare query struct
311+ var query = new STORAGE_PROPERTY_QUERY
156312 {
157- // Prepare query struct
158- var query = new STORAGE_PROPERTY_QUERY
159- {
160- PropertyId = StorageDeviceSeekPenaltyProperty ,
161- QueryType = 0 // PropertyStandardQuery
162- } ;
313+ PropertyId = StorageDeviceSeekPenaltyProperty ,
314+ QueryType = 0 // PropertyStandardQuery
315+ } ;
163316
164- int querySize = Marshal . SizeOf ( typeof ( STORAGE_PROPERTY_QUERY ) ) ;
165- int descSize = Marshal . SizeOf ( typeof ( STORAGE_SEEK_PENALTY_DESCRIPTOR ) ) ;
317+ int querySize = Marshal . SizeOf ( typeof ( STORAGE_PROPERTY_QUERY ) ) ;
318+ int descSize = Marshal . SizeOf ( typeof ( STORAGE_SEEK_PENALTY_DESCRIPTOR ) ) ;
166319
167- IntPtr queryPtr = Marshal . AllocHGlobal ( querySize ) ;
168- IntPtr descPtr = Marshal . AllocHGlobal ( descSize ) ;
320+ IntPtr queryPtr = Marshal . AllocHGlobal ( querySize ) ;
321+ IntPtr descPtr = Marshal . AllocHGlobal ( descSize ) ;
169322
170- try
171- {
172- Marshal . StructureToPtr ( query , queryPtr , false ) ;
173- // Zero-initialize descriptor memory
323+ try
324+ {
325+ Marshal . StructureToPtr ( query , queryPtr , false ) ;
326+ // Zero-initialize descriptor memory
174327 var zeroBytes = new byte [ descSize ] ;
175328 Marshal . Copy ( zeroBytes , 0 , descPtr , descSize ) ;
176329
177- bool success = DeviceIoControl (
178- hVolume ,
179- IOCTL_STORAGE_QUERY_PROPERTY ,
180- queryPtr ,
181- ( uint ) querySize ,
182- descPtr ,
183- ( uint ) descSize ,
184- out uint bytesReturned ,
185- IntPtr . Zero ) ;
186-
187- if ( success && bytesReturned >= ( uint ) descSize )
188- {
189- var descriptor = Marshal . PtrToStructure < STORAGE_SEEK_PENALTY_DESCRIPTOR > ( descPtr ) ;
190- bool isSSD = descriptor . IncursSeekPenalty == 0 ; // false = no seek penalty = SSD
191- _isSSDCached = isSSD ;
192-
193- App . Logger . WriteLine ( LOG_IDENT ,
194- $ "Storage detected via DeviceIoControl: { ( isSSD ? "SSD" : "HDD" ) } " +
195- $ "(IncursSeekPenalty={ descriptor . IncursSeekPenalty } )") ;
196- return isSSD ;
197- }
198- else
199- {
200- int lastError = Marshal . GetLastWin32Error ( ) ;
201- App . Logger . WriteLine ( LOG_IDENT ,
202- $ "DeviceIoControl seek penalty query failed (error={ lastError } ). Assuming HDD.") ;
203- _isSSDCached = false ;
204- return false ;
205- }
330+ bool success = DeviceIoControl (
331+ hVolume ,
332+ IOCTL_STORAGE_QUERY_PROPERTY ,
333+ queryPtr ,
334+ ( uint ) querySize ,
335+ descPtr ,
336+ ( uint ) descSize ,
337+ out uint bytesReturned ,
338+ IntPtr . Zero ) ;
339+
340+ if ( success && bytesReturned >= ( uint ) descSize )
341+ {
342+ var descriptor = Marshal . PtrToStructure < STORAGE_SEEK_PENALTY_DESCRIPTOR > ( descPtr ) ;
343+ bool isSSD = descriptor . IncursSeekPenalty == 0 ; // false = no seek penalty = SSD
344+
345+ App . Logger . WriteLine ( LOG_IDENT ,
346+ $ "Storage detected via DeviceIoControl: { ( isSSD ? "SSD" : "HDD" ) } " +
347+ $ "(IncursSeekPenalty={ descriptor . IncursSeekPenalty } )") ;
348+ return isSSD ;
206349 }
207- finally
350+ else
208351 {
209- Marshal . FreeHGlobal ( queryPtr ) ;
210- Marshal . FreeHGlobal ( descPtr ) ;
352+ int lastError = Marshal . GetLastWin32Error ( ) ;
353+ App . Logger . WriteLine ( LOG_IDENT ,
354+ $ "DeviceIoControl seek penalty query failed (error={ lastError } ). Assuming HDD.") ;
355+ return false ;
211356 }
212357 }
213358 finally
214359 {
215- CloseHandle ( hVolume ) ;
360+ Marshal . FreeHGlobal ( queryPtr ) ;
361+ Marshal . FreeHGlobal ( descPtr ) ;
216362 }
217363 }
218- catch ( Exception ex )
364+ finally
219365 {
220- App . Logger . WriteLine ( LOG_IDENT , $ "Storage detection via DeviceIoControl failed: { ex . Message } . Assuming HDD.") ;
221- _isSSDCached = false ;
222- return false ;
366+ CloseHandle ( hVolume ) ;
223367 }
224368 }
369+ catch ( Exception ex )
370+ {
371+ App . Logger . WriteLine ( LOG_IDENT , $ "Storage detection via DeviceIoControl failed: { ex . Message } . Assuming HDD.") ;
372+ return false ;
373+ }
225374 }
226375
227376 private static SystemTier DetectSystemTier ( bool ignoreForceExtreme = false )
0 commit comments