pinready 0.27.9

Cross-platform configurator and launcher supporting the adoption of Visual Pinball 10.8.1 — for testers and early adopters, not a replacement for a stable production setup
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
2251
2252
2253
2254
2255
2256
2257
2258
2259
2260
2261
2262
2263
2264
2265
2266
2267
2268
2269
2270
2271
2272
2273
2274
2275
2276
2277
2278
2279
2280
2281
2282
2283
2284
2285
2286
2287
2288
2289
2290
2291
2292
2293
2294
2295
2296
2297
2298
2299
2300
2301
2302
2303
2304
2305
2306
2307
2308
2309
2310
2311
2312
2313
2314
2315
2316
2317
2318
2319
2320
2321
2322
2323
2324
2325
2326
2327
2328
2329
2330
2331
2332
2333
2334
2335
2336
2337
2338
2339
2340
2341
2342
2343
2344
2345
2346
2347
2348
2349
2350
2351
2352
2353
2354
2355
2356
2357
2358
2359
2360
2361
2362
2363
2364
2365
2366
2367
2368
2369
2370
2371
2372
2373
2374
2375
2376
2377
2378
2379
2380
2381
2382
2383
2384
2385
2386
2387
2388
2389
2390
2391
2392
2393
2394
2395
2396
2397
2398
2399
2400
2401
2402
2403
2404
2405
2406
2407
2408
2409
2410
2411
2412
2413
2414
2415
2416
2417
2418
2419
2420
2421
2422
2423
2424
2425
2426
2427
2428
2429
2430
2431
2432
2433
2434
2435
2436
2437
2438
2439
2440
2441
2442
2443
2444
2445
2446
2447
2448
2449
2450
2451
2452
2453
2454
2455
2456
2457
2458
2459
2460
2461
2462
2463
2464
2465
2466
2467
2468
2469
2470
2471
2472
2473
2474
2475
2476
2477
2478
2479
2480
2481
2482
2483
2484
2485
2486
2487
2488
2489
2490
2491
2492
2493
2494
2495
2496
2497
2498
2499
2500
2501
2502
2503
2504
2505
2506
2507
2508
2509
2510
2511
2512
2513
2514
2515
2516
2517
2518
2519
2520
2521
2522
2523
2524
2525
2526
2527
2528
2529
2530
2531
2532
2533
2534
2535
2536
2537
2538
2539
2540
2541
2542
2543
2544
2545
2546
2547
2548
2549
2550
2551
2552
2553
2554
2555
2556
2557
2558
2559
2560
2561
2562
2563
2564
2565
2566
2567
2568
2569
2570
2571
2572
2573
2574
2575
2576
2577
2578
2579
2580
2581
2582
2583
2584
2585
2586
2587
2588
2589
2590
2591
2592
2593
2594
2595
2596
2597
2598
2599
2600
2601
2602
2603
2604
2605
2606
2607
2608
2609
2610
2611
2612
2613
2614
2615
2616
2617
2618
2619
2620
2621
2622
2623
2624
2625
2626
2627
2628
2629
2630
2631
2632
2633
2634
2635
2636
2637
2638
2639
2640
2641
2642
2643
2644
2645
2646
2647
2648
2649
2650
2651
2652
2653
2654
2655
2656
2657
2658
2659
2660
2661
2662
2663
2664
2665
2666
2667
2668
2669
2670
2671
2672
2673
2674
2675
2676
2677
2678
2679
2680
2681
2682
2683
2684
2685
2686
2687
2688
2689
2690
2691
2692
2693
2694
2695
2696
2697
2698
2699
2700
2701
2702
2703
2704
2705
2706
2707
2708
2709
2710
2711
2712
2713
2714
2715
2716
2717
2718
2719
2720
2721
2722
2723
2724
2725
2726
2727
2728
2729
2730
2731
2732
2733
2734
2735
2736
2737
2738
2739
2740
2741
2742
2743
2744
2745
2746
2747
2748
2749
2750
2751
2752
2753
2754
2755
2756
2757
2758
2759
2760
2761
2762
2763
2764
2765
2766
2767
2768
2769
2770
2771
2772
2773
2774
2775
2776
2777
2778
2779
2780
2781
2782
2783
2784
2785
2786
2787
2788
2789
2790
2791
2792
2793
2794
2795
2796
2797
2798
2799
2800
2801
2802
2803
2804
2805
2806
2807
2808
2809
2810
2811
2812
2813
2814
2815
2816
2817
2818
2819
2820
2821
2822
2823
2824
2825
2826
2827
2828
2829
2830
2831
2832
2833
2834
2835
2836
2837
2838
2839
2840
2841
2842
2843
2844
2845
2846
2847
2848
2849
2850
2851
2852
2853
2854
2855
2856
2857
2858
2859
2860
2861
2862
2863
2864
2865
2866
2867
2868
2869
2870
2871
2872
2873
2874
2875
2876
2877
2878
2879
2880
2881
2882
2883
2884
2885
2886
2887
2888
2889
2890
2891
2892
2893
2894
2895
2896
2897
2898
2899
2900
2901
2902
2903
2904
2905
2906
2907
2908
2909
2910
2911
2912
2913
2914
2915
use super::*;

/// Return the most-recent Unix-seconds mtime across every candidate
/// backglass source for a given table folder: `medias/launcher.*`,
/// `medias/bg.png` (vpinmediadb-installed cache), `.directb2s`, and the
/// `.vpx` itself. Missing files don't participate.
/// Used at scan time to invalidate the SQLite cache when any source
/// changes — especially a `launcher.*` override added after the
/// initial scan, or a fresh `medias/bg.png` from a catalog enrichment
/// run. Silent on any fs error — a 0 mtime just means "don't consider
/// this file newer than the cache".
fn max_source_mtime(table_dir: &std::path::Path, vpx_path: &std::path::Path) -> i64 {
    let b2s = vpx_path.with_extension("directb2s");
    let medias = table_dir.join("medias");
    let candidates = [
        medias.join("launcher.png"),
        medias.join("launcher.webp"),
        medias.join("launcher.jpg"),
        medias.join("launcher.jpeg"),
        medias.join("bg.png"),
        b2s,
        vpx_path.to_path_buf(),
    ];
    let mut max_mtime = 0i64;
    for candidate in &candidates {
        if let Ok(meta) = std::fs::metadata(candidate)
            && let Ok(m) = meta.modified()
            && let Ok(d) = m.duration_since(std::time::UNIX_EPOCH)
        {
            max_mtime = max_mtime.max(d.as_secs() as i64);
        }
    }
    max_mtime
}

/// mtime helper used by the VBS-patch scanner: only the `.vpx` and its
/// `.vbs` sidecar matter (the launcher.* override is irrelevant to VBS
/// classification). Same semantics and failure mode as
/// `max_source_mtime`.
/// Modification time of a file in seconds since the epoch; `None` when it is missing.
fn file_mtime(path: &std::path::Path) -> Option<i64> {
    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
    let secs = modified
        .duration_since(std::time::UNIX_EPOCH)
        .ok()?
        .as_secs();
    Some(secs as i64)
}

fn max_vbs_mtime(vpx_path: &std::path::Path) -> i64 {
    let sidecar = vpx_path.with_extension("vbs");
    let candidates = [vpx_path.to_path_buf(), sidecar];
    let mut max_mtime = 0i64;
    for candidate in &candidates {
        if let Ok(meta) = std::fs::metadata(candidate)
            && let Ok(m) = meta.modified()
            && let Ok(d) = m.duration_since(std::time::UNIX_EPOCH)
        {
            max_mtime = max_mtime.max(d.as_secs() as i64);
        }
    }
    max_mtime
}

/// Parse a percentage from a VPX SetProgress message.
/// Examples: "Initializing Visuals... 10%" → Some(0.10), "Loading..." → None
/// Describe an abnormal child-process exit (signal / crash / fault) and
/// point the user at where the OS stored the dump. Cross-platform:
///
///   - **Linux**: `core_pattern` (systemd-coredump or literal file).
///   - **macOS**: same signal info, plus `~/Library/Logs/DiagnosticReports/`.
///   - **Windows**: NTSTATUS-style exit code + Windows Error Reporting
///     (`%LOCALAPPDATA%\CrashDumps\`).
///
/// Returns `None` if the exit doesn't look like a crash (clean exit).
#[cfg(target_os = "linux")]
fn describe_coredump(child_pid: u32, status: &std::process::ExitStatus) -> Option<String> {
    use std::os::unix::process::ExitStatusExt;
    let signal = status.signal()?;
    let signal_name = signal_name(signal);
    let mut out = format!("Killed by {signal_name} (signal {signal}).\n");
    if !status.core_dumped() {
        out.push_str(
            "No core dump generated (ulimit -c is likely 0 — `ulimit -c unlimited` to enable).\n",
        );
        return Some(out);
    }
    out.push_str("A core dump was generated.\n\n");

    let pattern = std::fs::read_to_string("/proc/sys/kernel/core_pattern")
        .ok()
        .map(|s| s.trim().to_string())
        .unwrap_or_default();
    if pattern.starts_with('|') && pattern.contains("systemd-coredump") {
        out.push_str("Captured by systemd-coredump.\n");
        out.push_str(&format!(
            "  list:           coredumpctl list\n  open in gdb:    coredumpctl debug {child_pid}\n  raw file:       coredumpctl info {child_pid}\n  storage:        /var/lib/systemd/coredump/\n"
        ));
    } else if pattern.starts_with('|') {
        out.push_str(&format!("Core piped to handler: {pattern}\n"));
    } else if !pattern.is_empty() {
        out.push_str(&format!("Core file pattern: {pattern}\n"));
        out.push_str("(%p=PID, %e=exe-name, %t=epoch — see core(5))\n");
    } else {
        out.push_str("Core file location is unknown (empty core_pattern).\n");
    }
    out.push_str("\nHow to inspect a core file:\n");
    out.push_str("  https://wiki.archlinux.org/title/Core_dump\n");
    out.push_str("  https://www.freedesktop.org/software/systemd/man/coredumpctl.html\n");
    Some(out)
}

#[cfg(target_os = "macos")]
fn describe_coredump(child_pid: u32, status: &std::process::ExitStatus) -> Option<String> {
    use std::os::unix::process::ExitStatusExt;
    let signal = status.signal()?;
    let signal_name = signal_name(signal);
    let mut out = format!("Killed by {signal_name} (signal {signal}).\n\n");
    let _ = child_pid;
    let home = std::env::var("HOME").unwrap_or_else(|_| "~".into());
    out.push_str("macOS writes a crash report instead of a Unix-style core file.\n");
    out.push_str(&format!(
        "  per-user reports: {home}/Library/Logs/DiagnosticReports/\n"
    ));
    out.push_str("  system reports:   /Library/Logs/DiagnosticReports/\n");
    out.push_str("  view in GUI:      Console.app → Crash Reports\n");
    out.push_str("\nIf you actually need a core file (rare), enable it with:\n");
    out.push_str("  ulimit -c unlimited        (per-shell)\n");
    out.push_str("  sudo chmod 1777 /cores     (one-time, system-wide)\n");
    out.push_str("\nDocs:\n");
    out.push_str("  https://developer.apple.com/documentation/xcode/diagnosing-issues-using-crash-reports-and-device-logs\n");
    Some(out)
}

#[cfg(target_os = "windows")]
fn describe_coredump(child_pid: u32, status: &std::process::ExitStatus) -> Option<String> {
    let code = status.code()?;
    // Common NTSTATUS values that indicate a crash. Negative i32 ↔ 0x8…
    // / 0xC… NTSTATUS — interpret as u32 hex for clarity.
    let unsigned = code as u32;
    let label = match unsigned {
        0xC0000005 => Some("EXCEPTION_ACCESS_VIOLATION"),
        0xC000001D => Some("EXCEPTION_ILLEGAL_INSTRUCTION"),
        0xC0000094 => Some("EXCEPTION_INT_DIVIDE_BY_ZERO"),
        0xC00000FD => Some("EXCEPTION_STACK_OVERFLOW"),
        0xC0000409 => Some("STATUS_STACK_BUFFER_OVERRUN"),
        0xC0000374 => Some("STATUS_HEAP_CORRUPTION"),
        0xC000013A => Some("STATUS_CONTROL_C_EXIT"),
        _ => None,
    };
    // Anything matching the NTSTATUS severity bits 0xC… looks like a crash.
    let looks_like_crash = label.is_some() || unsigned >= 0xC000_0000;
    if !looks_like_crash {
        return None;
    }
    let mut out = match label {
        Some(name) => format!("Crashed with {name} (exit code 0x{unsigned:08X}).\n"),
        None => format!("Crashed with exit code 0x{unsigned:08X} (NTSTATUS-like).\n"),
    };
    out.push_str(&format!("PID was: {child_pid}\n\n"));
    out.push_str("Windows Error Reporting (if enabled) writes a minidump to:\n");
    out.push_str("  %LOCALAPPDATA%\\CrashDumps\\VPinballX_BGFX*.dmp\n\n");
    out.push_str("Enable user-mode minidumps if not already (one-off, run as admin):\n");
    out.push_str("  reg add \"HKLM\\SOFTWARE\\Microsoft\\Windows\\Windows Error Reporting\\LocalDumps\" /v DumpType /t REG_DWORD /d 2\n\n");
    out.push_str("Open the .dmp with WinDbg or Visual Studio.\n");
    out.push_str("\nDocs:\n");
    out.push_str(
        "  https://learn.microsoft.com/en-us/windows/win32/wer/collecting-user-mode-dumps\n",
    );
    out.push_str(
        "  https://learn.microsoft.com/en-us/windows-hardware/drivers/debugger/windbg-overview\n",
    );
    Some(out)
}

#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
fn describe_coredump(_child_pid: u32, _status: &std::process::ExitStatus) -> Option<String> {
    None
}

#[cfg(unix)]
fn signal_name(signal: i32) -> &'static str {
    match signal {
        4 => "SIGILL",
        6 => "SIGABRT",
        7 => "SIGBUS",
        8 => "SIGFPE",
        9 => "SIGKILL",
        11 => "SIGSEGV",
        13 => "SIGPIPE",
        15 => "SIGTERM",
        _ => "unknown",
    }
}

/// True when the exit looks like a crash (signal or NTSTATUS-style
/// error). Used to decide whether to surface an error popup even after
/// the user reached gameplay — a mid-game crash should never be silent.
fn is_abnormal_exit(status: &std::process::ExitStatus) -> bool {
    #[cfg(unix)]
    {
        use std::os::unix::process::ExitStatusExt;
        if status.signal().is_some() {
            return true;
        }
    }
    #[cfg(target_os = "windows")]
    {
        // No code = abnormal termination.
        status.code().is_none_or(|c| (c as u32) >= 0xC000_0000)
    }
    #[cfg(not(target_os = "windows"))]
    {
        let _ = status;
        false
    }
}

/// Per-launch transcript of everything we heard from VPX, written as it
/// arrives.
///
/// Three problems, one file. VPX appends every session to the same
/// `vpinball.log`, so a report can carry two launches with no way to tell
/// where one ends. A hard crash costs the tail of whatever was still
/// buffered. And when the error popup itself misbehaves — it has — the user
/// is left looking at a report they cannot get off the screen.
///
/// So: one file per launch, opened before VPX starts, carrying the exact
/// command line, flushed line by line, and named in the error report.
struct RunLog {
    path: std::path::PathBuf,
    file: Option<std::fs::File>,
}

impl RunLog {
    /// Directory holding the transcripts, next to `PinReady.log`.
    fn dir() -> Option<std::path::PathBuf> {
        Some(crate::db::default_db_path().parent()?.join("vpx-runs"))
    }

    /// Open the transcript for a launch and write `header` into it. Failing is
    /// never fatal: losing a diagnostic must not stop anyone playing.
    fn create(table: &std::path::Path, header: &str) -> Self {
        let stem: String = table
            .file_stem()
            .map(|s| s.to_string_lossy().into_owned())
            .unwrap_or_else(|| "table".into())
            .chars()
            .map(|c| if c.is_alphanumeric() { c } else { '-' })
            .take(60)
            .collect();
        let stamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_nanos())
            .unwrap_or_default();
        let Some(dir) = Self::dir() else {
            return Self {
                path: std::path::PathBuf::new(),
                file: None,
            };
        };
        let _ = std::fs::create_dir_all(&dir);
        Self::prune(&dir);
        let path = dir.join(format!("vpx-{stamp}-{stem}.log"));
        let file = std::fs::File::create(&path)
            .inspect_err(|e| {
                log::warn!("could not open the run transcript {}: {e}", path.display());
            })
            .ok();
        let mut me = Self { path, file };
        me.write(header);
        me
    }

    /// Keep the most recent runs and drop the rest: this is a diagnostic aid,
    /// not an archive, and a cabinet should not accumulate them forever.
    fn prune(dir: &std::path::Path) {
        const KEEP: usize = 10;
        let Ok(entries) = std::fs::read_dir(dir) else {
            return;
        };
        let mut files: Vec<_> = entries
            .flatten()
            .map(|e| e.path())
            .filter(|p| p.extension().is_some_and(|x| x == "log"))
            .collect();
        if files.len() < KEEP {
            return;
        }
        // Names start with a timestamp, so lexical order is chronological.
        files.sort();
        for old in files.iter().take(files.len() + 1 - KEEP) {
            let _ = std::fs::remove_file(old);
        }
    }

    /// Append and flush at once: whatever kills VPX must not take the last
    /// lines with it, which is exactly what makes `vpinball.log` unhelpful
    /// after a crash.
    fn write(&mut self, text: &str) {
        use std::io::Write as _;
        let Some(f) = self.file.as_mut() else {
            return;
        };
        if f.write_all(text.as_bytes()).is_err() || f.flush().is_err() {
            self.file = None;
        }
    }

    fn line(&mut self, tag: &str, line: &str) {
        self.write(&format!("[{tag}] {line}\n"));
    }
}

/// One line read from the child, and which stream it came out of.
///
/// Both streams feed a single watcher. Splitting them was a real bug: the
/// startup/progress detection only ever looked at stdout, so anything VPX
/// wrote to stderr — which is where a logger routes warnings by default —
/// could never satisfy it. `Startup done` arriving on the wrong pipe left
/// `startup_done` false and the loading hang-detector armed for the
/// whole session.
enum VpxLine {
    Out(String),
    Err(String),
    /// A line from VPX's own log file — see [`LogTail`].
    Log(String),
}

/// Follow VPX's own log file the way `tail -f` does.
///
/// This is the only stream VPX itself controls, and the only one that carries
/// `SetProgress` and `Startup done`. Its console output does not: the plog
/// console appender is compiled in under `__STANDALONE__` only, which the MSVC
/// Windows build does not define, so what lands in our pipes there is whatever
/// PinMAME and dmdutil happen to print on their own. Deducing VPX's state from
/// that was guesswork — a table was killed mid-load right after
/// `loading fshtl_5.rom`, because the ROM chatter stopped for 30 s.
struct LogTail {
    path: std::path::PathBuf,
    pos: u64,
    /// Bytes read past the last newline, waiting for the rest of their line.
    carry: String,
}

impl LogTail {
    /// Start at the end of whatever is already there: plog opens the file for
    /// append, so beginning at 0 would replay every previous session as if it
    /// were happening now. A missing file (first ever launch) starts at 0,
    /// which is also correct — VPX is about to create it.
    fn new(path: std::path::PathBuf) -> Self {
        let pos = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
        Self {
            path,
            pos,
            carry: String::new(),
        }
    }

    /// Every complete line appended since the last call. Empty while the file
    /// does not exist yet, which is the normal state for the first seconds of
    /// a first launch — and stays empty forever if the user turned VPX's
    /// logging off, which is why nothing may depend on this being non-empty.
    fn read_new(&mut self) -> Vec<String> {
        use std::io::{Read, Seek};
        let Ok(mut f) = std::fs::File::open(&self.path) else {
            return Vec::new();
        };
        let len = f.metadata().map(|m| m.len()).unwrap_or(0);
        if len < self.pos {
            // Rolled over (plog keeps 5 MB and one backup) or truncated:
            // whatever we were following is gone, follow the new file.
            self.pos = 0;
            self.carry.clear();
        }
        if len == self.pos {
            return Vec::new();
        }
        if f.seek(std::io::SeekFrom::Start(self.pos)).is_err() {
            return Vec::new();
        }
        let mut buf = Vec::new();
        let read = f
            .by_ref()
            .take(len - self.pos)
            .read_to_end(&mut buf)
            .unwrap_or(0);
        self.pos += read as u64;
        // Lossy on purpose: a torn multi-byte character at the read boundary
        // must not stall the tail, and this is a diagnostic stream.
        self.carry.push_str(&String::from_utf8_lossy(&buf));
        let mut out = Vec::new();
        while let Some(i) = self.carry.find('\n') {
            let line: String = self.carry.drain(..=i).collect();
            out.push(line.trim_end_matches(['\r', '\n']).to_string());
        }
        out
    }
}

/// POSIX-shell quote a path or argument: wrap in single quotes and
/// escape embedded `'` as `'\''`. Result is a string the user can paste
/// into a shell to re-run the exact same command.
fn shell_quote(s: &str) -> String {
    if !s.is_empty()
        && s.chars()
            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '/' | '.' | ':' | '='))
    {
        // Safe-looking — no quoting needed, keeps the line readable.
        s.to_string()
    } else {
        format!("'{}'", s.replace('\'', r"'\''"))
    }
}

fn parse_progress_pct(msg: &str) -> Option<f32> {
    // Look for a number followed by '%'
    let pct_pos = msg.find('%')?;
    let before = &msg[..pct_pos];
    // Walk backwards to find the start of the number
    let num_start = before
        .rfind(|c: char| !c.is_ascii_digit() && c != '.')
        .map(|p| p + 1)
        .unwrap_or(0);
    let num_str = &before[num_start..];
    let pct: f32 = num_str.parse().ok()?;
    Some((pct / 100.0).clamp(0.0, 1.0))
}

/// How the launcher orders the tables. The list itself stays in
/// alphabetical order (the pictures are numbered by position); this only
/// changes the order in which the tables are shown and navigated.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(super) enum SortMode {
    #[default]
    Alpha,
    FavoritesFirst,
    MyRating,
    Community,
    LastPlayed,
    MostPlayed,
}

impl SortMode {
    /// Value stored in the config.
    pub(super) fn key(self) -> &'static str {
        match self {
            Self::Alpha => "alpha",
            Self::FavoritesFirst => "favorites",
            Self::MyRating => "rating",
            Self::Community => "community",
            Self::LastPlayed => "last_played",
            Self::MostPlayed => "most_played",
        }
    }

    pub(super) fn from_key(key: &str) -> Self {
        [
            Self::Alpha,
            Self::FavoritesFirst,
            Self::MyRating,
            Self::Community,
            Self::LastPlayed,
            Self::MostPlayed,
        ]
        .into_iter()
        .find(|m| m.key() == key)
        .unwrap_or_default()
    }

    pub(super) fn next(self) -> Self {
        match self {
            Self::Alpha => Self::FavoritesFirst,
            Self::FavoritesFirst => Self::MyRating,
            Self::MyRating => Self::Community,
            Self::Community => Self::LastPlayed,
            Self::LastPlayed => Self::MostPlayed,
            Self::MostPlayed => Self::Alpha,
        }
    }

    pub(super) fn label(self) -> String {
        match self {
            Self::Alpha => t!("sort_alpha"),
            Self::FavoritesFirst => t!("sort_favorites"),
            Self::MyRating => t!("sort_rating"),
            Self::Community => t!("sort_community"),
            Self::LastPlayed => t!("sort_last_played"),
            Self::MostPlayed => t!("sort_most_played"),
        }
        .to_string()
    }

    /// Sort `order` (indices into the alphabetical table list) in place.
    /// Stable, so tables that compare equal stay alphabetical.
    pub(super) fn sort(
        self,
        order: &mut [usize],
        tables: &[TableEntry],
        community: &std::collections::HashMap<String, crate::vpinplay::CommunityRating>,
    ) {
        match self {
            Self::Alpha => {}
            Self::FavoritesFirst => order.sort_by_key(|&i| !tables[i].user.favorite),
            Self::MyRating => {
                order.sort_by_key(|&i| std::cmp::Reverse(tables[i].user.rating_tenths))
            }
            Self::Community => order.sort_by_key(|&i| {
                std::cmp::Reverse(
                    tables[i]
                        .vps_id
                        .as_ref()
                        .and_then(|id| community.get(id))
                        .map(|r| (r.avg * 100.0) as i32),
                )
            }),
            Self::LastPlayed => order.sort_by_key(|&i| std::cmp::Reverse(tables[i].user.last_run)),
            Self::MostPlayed => {
                order.sort_by_key(|&i| std::cmp::Reverse(tables[i].user.start_count))
            }
        }
    }
}

pub(super) fn unix_now() -> i64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_secs() as i64)
        .unwrap_or(0)
}

/// What the index thread learned about one table.
pub(super) struct IndexEntry {
    user: crate::db::UserTable,
    idx: usize,
    update_available: bool,
    vps_id: Option<String>,
    /// The VPS match is reliable enough to share (not low confidence).
    vps_shareable: bool,
    /// A cached picture (or a thumbnail to make) exists.
    has_bg: bool,
    /// The cached grid thumbnail, to be decoded and uploaded.
    thumb: Option<std::sync::Arc<[u8]>>,
}

/// Everything `scan_tables` used to look up in the database on the UI
/// thread, found out on a background thread instead so the window opens
/// first and the tiles follow.
pub(super) struct IndexResult {
    generation: u64,
    entries: Vec<IndexEntry>,
    /// Cached pictures with no thumbnail yet: `(idx, rel_path, mtime)`.
    backfill: Vec<(usize, String, i64)>,
    /// Tables with no (fresh) record: `(idx, table_dir, vpx_path, mtime)`.
    jobs: Vec<(usize, std::path::PathBuf, std::path::PathBuf, i64)>,
    vbs: Option<VbsJobs>,
    b2s: Vec<(std::path::PathBuf, String)>,
}

/// The jsm174 catalog and the tables to classify: `(vpx_path, rel_path, mtime)`.
type VbsJobs = (
    Vec<crate::vbs_patches::CatalogEntry>,
    Vec<(std::path::PathBuf, String, i64)>,
);

/// `(table_dir, vpx_path, rel_path, source_mtime)` of a table found on disk.
type FoundTable = (std::path::PathBuf, std::path::PathBuf, String, i64);

/// Threads for the per-table analyses: they are I/O bound (each reads a
/// whole `.vpx` or `.directb2s`), so a few are enough.
fn worker_count() -> usize {
    std::thread::available_parallelism()
        .map(|n| n.get())
        .unwrap_or(2)
        .clamp(2, 8)
}

pub(super) fn rel_to(dir_path: &std::path::Path, path: &std::path::Path) -> String {
    path.strip_prefix(dir_path)
        .map(|p| p.to_string_lossy().into_owned())
        .unwrap_or_else(|_| path.to_string_lossy().into_owned())
}

fn build_index(
    generation: u64,
    dir_path: &std::path::Path,
    found: Vec<FoundTable>,
    table_paths: &[std::path::PathBuf],
) -> Option<IndexResult> {
    use crate::startup_timing::Phase;
    let phase = Phase::start("index thread: database lookups");
    let db = match crate::db::Database::open(None) {
        Ok(db) => db,
        Err(e) => {
            log::error!("index thread: cannot open the database: {e}");
            return None;
        }
    };
    let positions: std::collections::HashMap<&std::path::Path, usize> = table_paths
        .iter()
        .enumerate()
        .map(|(i, p)| (p.as_path(), i))
        .collect();
    let mut meta = db.get_backglass_meta();
    let vps = db.get_vps_overview();
    let mut user = db.get_user_tables();
    let mut entries = Vec::with_capacity(found.len());
    let mut backfill = Vec::new();
    let mut jobs = Vec::new();
    for (table_dir, vpx_path, rel_path, source_mtime) in found {
        let Some(&idx) = positions.get(vpx_path.as_path()) else {
            continue;
        };
        let (vps_id, update_available, vps_shareable) = match vps.get(&rel_path) {
            Some((id, update, reliable)) => (Some(id.clone()), *update, *reliable),
            None => (None, false, false),
        };
        let mut entry = IndexEntry {
            user: user.remove(&rel_path).unwrap_or_default(),
            idx,
            update_available,
            vps_id,
            vps_shareable,
            has_bg: false,
            thumb: None,
        };
        match meta.remove(&rel_path) {
            Some(m) if m.mtime >= source_mtime => match m.thumb {
                Some(bytes) => {
                    entry.has_bg = true;
                    entry.thumb = Some(std::sync::Arc::from(bytes));
                }
                // Scanned before and found nothing: nothing to redo until
                // one of its source files changes.
                None if m.no_image => {}
                // Cached before thumbnails existed: its full picture is
                // good, only the thumbnail is missing.
                None => {
                    backfill.push((idx, rel_path.clone(), m.mtime));
                    entry.has_bg = true;
                }
            },
            _ => jobs.push((idx, table_dir, vpx_path, source_mtime)),
        }
        entries.push(entry);
    }
    phase.done();

    let b2s = collect_b2s_jobs(&db, table_paths, dir_path);
    let vbs = collect_vbs_jobs(&db, table_paths, dir_path);
    Some(IndexResult {
        generation,
        entries,
        backfill,
        jobs,
        vbs,
        b2s,
    })
}

/// The `.directb2s` files that changed since their DMD area was last
/// checked (see `b2s_dmd.rs`).
fn collect_b2s_jobs(
    db: &crate::db::Database,
    tables: &[std::path::PathBuf],
    dir_path: &std::path::Path,
) -> Vec<(std::path::PathBuf, String)> {
    if !db.b2s_dmd_enabled() {
        log::debug!("b2s_dmd: disabled — skipping");
        return Vec::new();
    }
    let cached = db.get_b2s_dmd_mtimes();
    let mut jobs = Vec::new();
    for table in tables {
        let b2s = table.with_extension("directb2s");
        // A table with no backglass file is checked too (one is made for it
        // when its game has a screen); its mtime is 0, so it is not looked at
        // again until a backglass file appears or the caches are rebuilt.
        let mtime = file_mtime(&b2s).unwrap_or(0);
        let rel_path = rel_to(dir_path, table);
        match cached.get(&rel_path) {
            Some(checked) if *checked >= mtime => {}
            _ => jobs.push((b2s, rel_path)),
        }
    }
    jobs
}

/// The jsm174 catalog and the tables whose VBS state is stale or unknown.
/// Refreshing the catalog is a network call: it must not run on the UI
/// thread.
fn collect_vbs_jobs(
    db: &crate::db::Database,
    tables: &[std::path::PathBuf],
    dir_path: &std::path::Path,
) -> Option<VbsJobs> {
    // Opt-in: user has to enable auto-patching explicitly from the
    // Tables wizard page. Default is off because the jsm174 catalog
    // occasionally ships patches with regressions (e.g. Apollo 13
    // needs an additional `vpmInit Me` fix on top of their patch —
    // see vpinball/vpinball#1536, #1650).
    if !db.jsm174_patching_enabled() {
        log::debug!("vbs_patches: jsm174 auto-patching is disabled — skipping");
        return None;
    }

    // Refresh the jsm174 catalog if upstream master has moved.
    // Non-fatal on network error — falls back to cached catalog.
    let refresh_phase = crate::startup_timing::Phase::start("vbs catalog refresh (network)");
    if let Err(e) = crate::vbs_patches::refresh_catalog_if_stale(db) {
        log::warn!("vbs_patches: catalog refresh failed: {e}");
    }
    refresh_phase.done();
    let catalog: Vec<crate::vbs_patches::CatalogEntry> = db
        .get_vbs_catalog()
        .and_then(|(_, json)| crate::vbs_patches::parse_catalog(&json).ok())
        .unwrap_or_default();
    if catalog.is_empty() {
        log::info!("vbs_patches: no catalog available yet (first boot offline?). Skipping.");
        return None;
    }

    // Collect jobs for stale / unclassified tables.
    let cached = db.get_vbs_patch_mtimes();
    let mut jobs = Vec::new();
    for table in tables {
        let rel_path = rel_to(dir_path, table);
        let vbs_mtime = max_vbs_mtime(table);
        match cached.get(&rel_path) {
            // Fresh classification — nothing to do.
            Some(checked) if *checked >= vbs_mtime => {}
            _ => jobs.push((table.clone(), rel_path, vbs_mtime)),
        }
    }
    if jobs.is_empty() {
        return None;
    }
    Some((catalog, jobs))
}

impl App {
    pub(super) fn finalize_wizard(&mut self, _ctx: &egui::Context) {
        // Save ALL pages
        self.save_screens();
        self.save_rendering();
        self.save_threed();
        self.save_inputs();
        self.save_tilt();
        self.save_audio();
        self.save_tables_dir();
        self.flush_config();

        if let Err(e) = self.db.set_configured() {
            log::error!("Failed to mark wizard complete: {e}");
        }

        // Apply the System page: autostart, desktop integration (menu
        // shortcuts + .vpx file association, pointed at the resolved VPX path
        // — empty installs only PinReady's own entry), desktop shortcut and
        // dock pin. On a worker thread: on Windows each of these runs
        // PowerShell, which froze the window. The thread outlives the wizard
        // window and finishes while the launcher comes up.
        system_apply::spawn_apply(system_apply::SystemChoices {
            autostart: self.autostart,
            desktop_integration: self.desktop_integration,
            vpx_exe_path: self.vpx_exe_path.clone(),
            desktop_shortcut: self.desktop_shortcut,
            dock_pinned: self.dock_pinning_available.then_some(self.dock_pinned),
        });

        // The knocker greets the launcher that opens next: the wizard window
        // closes right away. The System page worker keeps running in this
        // process, which goes on with the launcher.
        crate::app::request_knocker_on_start();
        log::info!("Wizard completed! Config saved; switching to the launcher.");
        crate::app::request_mode_switch(AppMode::Launcher);
        self.close_at = Some(std::time::Instant::now());
    }

    // Previous versions of this file had `enter_cabinet_mode_if_configured`
    // and `leave_cabinet_mode_live` that mutated the live viewport (rotation,
    // monitor, decorations) between wizard and launcher modes. Those were
    // removed in favour of the restart-eframe-per-mode model driven by
    // `request_mode_switch` + `main.rs` loop: each mode now comes up with
    // its viewport correctly configured at window-creation time, avoiding
    // the dual-render / stale-compositor glitches.

    pub(super) fn scan_tables(&mut self) {
        // Bump the scan generation BEFORE clearing — any in-flight bg
        // thread from a prior scan will continue running and may still
        // emit results, but their (gen, idx) tuples will fail the gen
        // check in `process_bg_extraction` and be discarded. This
        // prevents stale extractions from writing thumbnails onto the
        // wrong rows after a rescan that reshuffled the index space.
        self.scan_generation = self.scan_generation.wrapping_add(1);
        // Drop the prior receiver so the prior thread's `tx.send`
        // becomes a no-op (channel closed) — a small CPU optimisation
        // on top of the gen-check belt-and-suspenders above.
        self.bg_rx = None;
        self.index_rx = None;
        self.tables.clear();
        if self.tables_dir.is_empty() {
            return;
        }
        // Own the path so we can mix immutable reads of `dir_path`
        // with `&mut self` later (scan_vbs_patches needs `&mut self`).
        let dir: String = self.tables_dir.clone();
        let dir_path: std::path::PathBuf = std::path::PathBuf::from(&dir);
        if !dir_path.is_dir() {
            log::warn!("Tables directory does not exist: {}", dir);
            return;
        }
        let dir_path = dir_path.as_path();
        // Scan for .vpx files (folder-per-table layout: each subfolder has a .vpx).
        // Phase 1: collect raw (table_dir, vpx_path, rel_path, source_mtime).
        let listing_phase = crate::startup_timing::Phase::start("table listing (read_dir + stat)");
        let mut found: Vec<(std::path::PathBuf, std::path::PathBuf, String, i64)> = Vec::new();
        if let Ok(entries) = std::fs::read_dir(dir_path) {
            for entry in entries.flatten() {
                let table_dir = entry.path();
                if !table_dir.is_dir() {
                    continue;
                }
                if let Ok(files) = std::fs::read_dir(&table_dir) {
                    for file in files.flatten() {
                        let fp = file.path();
                        if fp.extension().and_then(|e| e.to_str()) == Some("vpx") {
                            let rel_path = fp
                                .strip_prefix(dir_path)
                                .map(|p| p.to_string_lossy().into_owned())
                                .unwrap_or_else(|_| fp.to_string_lossy().into_owned());
                            let source_mtime = max_source_mtime(&table_dir, &fp);
                            found.push((table_dir.clone(), fp, rel_path, source_mtime));
                            break; // one vpx per folder
                        }
                    }
                }
            }
        }

        listing_phase.done();
        // The list of tables comes first and needs no database: the rows
        // are made here, the rest (cached pictures, VPS links, jobs for
        // what changed) is found out by the index thread below and folded
        // in by `process_index` once the window is up.
        for (table_dir, vpx_path, _, _) in &found {
            let name = table_dir
                .file_name()
                .unwrap_or_default()
                .to_string_lossy()
                .replace('_', " ");
            self.tables.push(TableEntry {
                user: Default::default(),
                vps_shareable: false,
                path: vpx_path.clone(),
                name,
                has_bg: false,
                update_available: false,
                vps_id: None,
            });
        }
        self.tables.sort_by_key(|a| a.name.to_lowercase());
        // Fresh numbering for the pictures: textures and in-flight decodes
        // of the previous scan go away with the old generation.
        self.bg_cache.reset(self.scan_generation, self.tables.len());
        log::info!("Listed {} tables in {}", self.tables.len(), dir);

        // Cancel any prior scan so the new pool is the only one writing
        // to `medias/` and the DB.
        if let Some(prev) = self.catalog_cancel_token.take() {
            prev.store(true, std::sync::atomic::Ordering::SeqCst);
        }
        let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
        self.catalog_cancel_token = Some(cancel);

        let generation = self.scan_generation;
        let dir_owned = dir_path.to_path_buf();
        let table_paths: Vec<std::path::PathBuf> =
            self.tables.iter().map(|t| t.path.clone()).collect();
        let (tx, rx) = crossbeam_channel::unbounded();
        self.index_rx = Some(rx);
        std::thread::Builder::new()
            .name(format!("pinready-index-{generation}"))
            .spawn(move || {
                if let Some(result) = build_index(generation, &dir_owned, found, &table_paths) {
                    let _ = tx.send(result);
                }
            })
            .ok();
    }

    /// Fold the index thread's findings into the table list and start the
    /// background work. Called every frame; cheap until the result is in.
    pub(super) fn process_index(&mut self) {
        let Some(rx) = &self.index_rx else {
            return;
        };
        match rx.try_recv() {
            Ok(result) => {
                self.index_rx = None;
                if result.generation == self.scan_generation {
                    self.apply_index(result);
                }
            }
            Err(crossbeam_channel::TryRecvError::Disconnected) => self.index_rx = None,
            Err(crossbeam_channel::TryRecvError::Empty) => {}
        }
    }

    fn apply_index(&mut self, result: IndexResult) {
        let phase = crate::startup_timing::Phase::start("apply index (UI thread)");
        let Some(cancel) = self.catalog_cancel_token.clone() else {
            return;
        };
        let dir_path_buf = std::path::PathBuf::from(&self.tables_dir);
        let dir_path: &std::path::Path = &dir_path_buf;
        for entry in result.entries {
            if let Some(table) = self.tables.get_mut(entry.idx) {
                table.user = entry.user;
                table.update_available = entry.update_available;
                table.vps_id = entry.vps_id;
                table.vps_shareable = entry.vps_shareable;
                if entry.has_bg {
                    table.has_bg = true;
                }
            }
            if let Some(thumb) = entry.thumb {
                self.bg_cache.queue_thumb(entry.idx, thumb);
            }
        }

        // Build the per-table scan jobs. Each job is a self-contained
        // pipeline: VPSDB match → media install → backglass extract →
        // DB write → UI signal. Workers run them in parallel, sequential
        // within each job — so the same worker that may have just
        // installed `medias/bg.png` is the one that reads it back in
        // the priority chain. No cross-thread file race.
        let scan_jobs: Vec<crate::scan_worker::ScanJob> = result
            .jobs
            .into_iter()
            .map(|(idx, table_dir, vpx_path, source_mtime)| {
                let rel_path = vpx_path
                    .strip_prefix(dir_path)
                    .map(|p| p.to_string_lossy().into_owned())
                    .unwrap_or_else(|_| vpx_path.to_string_lossy().into_owned());
                let folder_name = table_dir
                    .file_name()
                    .map(|n| n.to_string_lossy().into_owned())
                    .unwrap_or_default();
                crate::scan_worker::ScanJob {
                    idx,
                    rel_path,
                    table_dir,
                    vpx_path,
                    folder_name,
                    source_mtime,
                }
            })
            .collect();

        let (tx, rx) = crossbeam_channel::unbounded();
        self.bg_rx = Some(rx);
        crate::scan_worker::spawn_thumb_backfill(
            result.backfill,
            tx.clone(),
            cancel.clone(),
            self.scan_generation,
        );

        if !scan_jobs.is_empty() {
            let tables_root = dir_path.to_path_buf();
            let generation = self.scan_generation;
            let enrichment_on = self.db.catalog_enrichment_enabled();

            // Sync VPSDB + MediaDb on a small bootstrap thread so we don't
            // block the UI; once both indices are loaded we hand them
            // (Arc-shared) to the worker pool.
            std::thread::Builder::new()
                .name(format!("pinready-scan-bootstrap-{generation}"))
                .spawn(move || {
                    use crate::vpsdb;
                    use std::sync::Arc;

                    let db = match crate::db::Database::open(None) {
                        Ok(db) => db,
                        Err(e) => {
                            log::error!("scan bootstrap: cannot open DB: {e}");
                            return;
                        }
                    };
                    let mirror = db.mirror_base_url();

                    let games: Arc<Vec<vpsdb::models::Game>> = if enrichment_on {
                        let cache = vpsdb::fetch::VpsDbCache::new(vpsdb::fetch::VpsDbCache::default_dir());
                        match vpsdb::fetch::sync_if_stale(&cache) {
                            Ok((games, _outcome)) => Arc::new(games),
                            Err(e) => {
                                log::warn!("scan bootstrap: VPSDB sync failed ({e}) — match-only");
                                Arc::new(Vec::new())
                            }
                        }
                    } else {
                        Arc::new(Vec::new())
                    };

                    let media_db: Option<Arc<crate::mediadb::MediaDb>> = if enrichment_on {
                        match crate::mediadb::MediaDb::sync(
                            crate::mediadb::MediaDb::default_cache_dir(),
                            mirror.as_deref(),
                        ) {
                            Ok(m) => Some(Arc::new(m)),
                            Err(e) => {
                                log::warn!("scan bootstrap: MediaDb sync failed ({e}) — match-only");
                                None
                            }
                        }
                    } else {
                        None
                    };

                    // matcher_version upgrade: bumped when the matcher chain
                    // changes (new strategy / confidence shift). Forces a
                    // one-shot full re-evaluation of every link this run.
                    let stored_matcher_version: i64 = db
                        .get_config("matcher_version")
                        .and_then(|s| s.parse().ok())
                        .unwrap_or(0);
                    let force_full_rematch = stored_matcher_version < crate::scan_worker::MATCHER_VERSION;
                    if force_full_rematch {
                        log::info!(
                            "Matcher upgraded ({stored_matcher_version} → {}); re-evaluating every vps_link this run",
                            crate::scan_worker::MATCHER_VERSION
                        );
                    }

                    crate::scan_worker::spawn_pool(
                        scan_jobs,
                        games,
                        media_db,
                        tx,
                        cancel,
                        tables_root,
                        generation,
                        force_full_rematch,
                    );

                    if force_full_rematch {
                        let _ = db.set_config(
                            "matcher_version",
                            &crate::scan_worker::MATCHER_VERSION.to_string(),
                        );
                    }
                })
                .ok();
        }

        // The VBS patches and the DMD area of the backglass files have
        // their own mtime tracking; they run whether or not any backglass
        // had to be extracted.
        if let Some((catalog, jobs)) = result.vbs {
            self.start_vbs_patches(catalog, jobs);
        }
        if !result.b2s.is_empty() {
            self.start_b2s_dmd(result.b2s);
        }
        self.start_ratings_refresh_if_stale();
        phase.done();
    }

    /// Sizes of the ScoreView and Backglass windows from `VPinballX.ini`, for the
    /// images made for backglass files that have none; a default for the one
    /// the ini says nothing usable about.
    fn window_sizes(&self) -> ((u32, u32), (u32, u32)) {
        let size = |section: &str, name: &str| {
            let get = |key: String| {
                self.config
                    .get_i32(section, &key)
                    .filter(|v| (320..=8192).contains(v))
                    .map(|v| v as u32)
            };
            match (get(format!("{name}Width")), get(format!("{name}Height"))) {
                (Some(w), Some(h)) => (w, h),
                _ => crate::b2s_dmd::DEFAULT_SIZE,
            }
        };
        (
            size("ScoreView", "ScoreView"),
            size("Backglass", "Backglass"),
        )
    }

    /// Give the `.directb2s` of each table the DMD area VPX's B2S plugin
    /// needs, when it is missing (see `b2s_dmd.rs`). Reads and rewrites the
    /// files on background threads, only those that changed since the last
    /// check (`collect_b2s_jobs`); the result of each is recorded by
    /// `process_b2s_dmd`.
    fn start_b2s_dmd(&mut self, jobs: Vec<(std::path::PathBuf, String)>) {
        log::info!(
            "b2s_dmd: checking {} backglass files in background...",
            jobs.len()
        );
        let backup_root = crate::db::default_db_path()
            .parent()
            .map(|p| p.join("b2s-backup"))
            .unwrap_or_else(|| std::path::PathBuf::from("b2s-backup"));
        let (tx, rx) = crossbeam_channel::unbounded();
        let workers = worker_count().min(jobs.len());
        // the note drawn in the frames made for games with no DMD image, in the
        // language of the interface
        let note: std::sync::Arc<str> = t!("b2s_no_dmd_image").to_string().into();
        // and the size of the ScoreView window, which they are made to fill
        let (size, bg_size) = self.window_sizes();
        let (job_tx, job_rx) = crossbeam_channel::unbounded();
        for job in jobs {
            let _ = job_tx.send(job);
        }
        drop(job_tx);
        let remaining = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(workers));
        for w in 0..workers {
            let job_rx: crossbeam_channel::Receiver<(std::path::PathBuf, String)> = job_rx.clone();
            let tx = tx.clone();
            let backup_root = backup_root.clone();
            let note = note.clone();
            let remaining = remaining.clone();
            std::thread::Builder::new()
                .name(format!("pinready-b2s-{w}"))
                .spawn(move || {
                    for (b2s, rel_path) in job_rx.iter() {
                        let screen = crate::b2s_dmd::screen_for_table(&b2s.with_extension("vpx"));
                        let status = match crate::b2s_dmd::fix_file(
                            &b2s,
                            &backup_root,
                            screen,
                            crate::b2s_dmd::Generated {
                                note: &note,
                                size,
                                bg_size,
                            },
                        ) {
                            Ok(status) => status.to_string(),
                            Err(e) => {
                                log::warn!("b2s_dmd: {rel_path}: {e:#}");
                                crate::b2s_dmd::status::FAILED.to_string()
                            }
                        };
                        // The mtime after the check: a file we just rewrote is not
                        // "changed" for the next scan
                        let mtime = file_mtime(&b2s).unwrap_or(0);
                        log::info!("b2s_dmd: {rel_path} → {status}");
                        let _ = tx.send((rel_path, status, mtime));
                    }
                    if remaining.fetch_sub(1, Ordering::SeqCst) == 1 {
                        log::info!("b2s_dmd: check complete");
                    }
                })
                .ok();
        }
        // Only the workers hold a sender now: the receiver disconnects
        // once the last of them is done.
        drop(tx);
        self.b2s_dmd_rx = Some(rx);
    }

    /// Rebuild: forget the pictures and what was learnt about the backglass and
    /// the scripts, then scan again. Wiping the picture cache means walking
    /// gigabytes of pages, so it runs on its own thread (its own connection)
    /// and the scan starts when it is done.
    pub(super) fn start_rebuild(&mut self) {
        if self.rebuild_clear_rx.is_some() {
            return; // already wiping
        }
        // Stop the scan that may be running: it would write into the tables
        // being emptied
        self.scan_generation = self.scan_generation.wrapping_add(1);
        self.bg_rx = None;
        if let Some(token) = self.catalog_cancel_token.take() {
            token.store(true, std::sync::atomic::Ordering::SeqCst);
        }
        self.tables.iter_mut().for_each(|t| t.has_bg = false);
        let (tx, rx) = crossbeam_channel::bounded(1);
        std::thread::Builder::new()
            .name("pinready-rebuild-clear".into())
            .spawn(move || {
                match crate::db::Database::open(None) {
                    Ok(db) => {
                        let started = std::time::Instant::now();
                        if let Err(e) = db.clear_backglass() {
                            log::error!("clear_backglass failed: {e}");
                        }
                        if let Err(e) = db.clear_vbs_patches() {
                            log::error!("clear_vbs_patches failed: {e}");
                        }
                        if let Err(e) = db.clear_b2s_dmd() {
                            log::error!("clear_b2s_dmd failed: {e}");
                        }
                        log::info!("Rebuild: caches wiped in {:?}", started.elapsed());
                    }
                    Err(e) => log::error!("Rebuild: cannot open the database: {e}"),
                }
                let _ = tx.send(());
            })
            .ok();
        self.rebuild_clear_rx = Some(rx);
    }

    /// What the covers say about the selected table: take what the worker has
    /// read, and ask for the table if it is not known yet.
    pub(super) fn process_cover_info(&mut self) {
        while let Ok((path, info)) = self.cover_info_rx.try_recv() {
            self.cover_info.insert(path, info);
        }
        let Some(table) = self.tables.get(self.selected_table) else {
            return;
        };
        if self.cover_info.contains_key(&table.path) || self.cover_info_asked.contains(&table.path)
        {
            return;
        }
        let labels = crate::table_info::Labels {
            author: t!("info_author").to_string(),
            version: t!("info_version").to_string(),
            saved: t!("info_saved").to_string(),
            released: t!("info_released").to_string(),
            played: t!("info_played").to_string(),
            last_played: t!("info_last_played").to_string(),
        };
        self.cover_info_asked.insert(table.path.clone());
        let _ = self
            .cover_info_tx
            .send((table.path.clone(), table.name.clone(), labels));
    }

    /// Favourite flag and ratings of the selected table, for the covers: the
    /// player's own rating and, when the VPinPlay option is on, the
    /// community's. In the colours of the grid (gold for the player, blue for
    /// the community).
    pub(super) fn cover_extra(&self) -> Vec<(String, egui::Color32)> {
        let mut out = Vec::new();
        let Some(table) = self.tables.get(self.selected_table) else {
            return out;
        };
        let gold = egui::Color32::from_rgb(255, 200, 0);
        if table.user.favorite {
            out.push((format!("★ {}", t!("info_favorite")), gold));
        }
        if table.user.rating_tenths > 0 {
            out.push((
                format!(
                    "{}: {:.1} / 5",
                    t!("info_my_rating"),
                    f32::from(table.user.rating_tenths) / 10.0
                ),
                gold,
            ));
        }
        if self.vpinplay.enabled
            && let Some(c) = table.vps_id.as_ref().and_then(|id| self.community.get(id))
        {
            out.push((
                format!(
                    "{}: {:.1} / 5 ({} {})",
                    t!("info_community"),
                    c.avg,
                    c.count,
                    t!("info_votes")
                ),
                egui::Color32::from_rgb(110, 170, 255),
            ));
        }
        out
    }

    /// The facts of the selected table, when they have been read.
    pub(super) fn selected_cover_info(&self) -> Option<Arc<crate::table_info::TableInfo>> {
        let table = self.tables.get(self.selected_table)?;
        self.cover_info.get(&table.path).cloned()
    }

    /// Start the scan once the caches of a Rebuild are wiped.
    pub(super) fn process_rebuild_clear(&mut self) {
        let Some(rx) = &self.rebuild_clear_rx else {
            return;
        };
        match rx.try_recv() {
            Ok(()) | Err(crossbeam_channel::TryRecvError::Disconnected) => {
                self.rebuild_clear_rx = None;
                self.scan_tables();
            }
            Err(crossbeam_channel::TryRecvError::Empty) => {}
        }
    }

    /// Record the results of `start_b2s_dmd`. Silent by design, like the VBS patches.
    pub(super) fn process_b2s_dmd(&mut self) {
        if let Some(rx) = &self.b2s_dmd_rx {
            let drained: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
            let disconnected = matches!(
                rx.try_recv(),
                Err(crossbeam_channel::TryRecvError::Disconnected)
            );
            if disconnected {
                self.b2s_dmd_rx = None;
            }
            for (rel_path, status, mtime) in drained {
                if let Err(e) = self.db.set_b2s_dmd(&rel_path, &status, mtime) {
                    log::error!("Failed to upsert b2s_dmd row for {rel_path}: {e}");
                }
            }
        }
    }

    /// Classify each table's VBS state and apply patches from the jsm174
    /// catalog when appropriate: the classification + file operations run
    /// on a small pool of background threads (one `.vpx` script read and
    /// hash per job, independent of each other); the UI gets results via
    /// `vbs_rx` and folds them into the `vbs_patches` table.
    fn start_vbs_patches(
        &mut self,
        catalog: Vec<crate::vbs_patches::CatalogEntry>,
        jobs: Vec<(std::path::PathBuf, String, i64)>,
    ) {
        log::info!(
            "vbs_patches: classifying {} tables in background...",
            jobs.len()
        );
        let catalog = std::sync::Arc::new(catalog);
        let (tx, rx) = crossbeam_channel::unbounded();
        let workers = worker_count().min(jobs.len());
        let (job_tx, job_rx) = crossbeam_channel::unbounded();
        for job in jobs {
            let _ = job_tx.send(job);
        }
        drop(job_tx);
        let remaining = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(workers));
        for w in 0..workers {
            let job_rx: crossbeam_channel::Receiver<(std::path::PathBuf, String, i64)> =
                job_rx.clone();
            let tx = tx.clone();
            let catalog = catalog.clone();
            let remaining = remaining.clone();
            std::thread::Builder::new()
                .name(format!("pinready-vbs-{w}"))
                .spawn(move || {
                    for (vpx_path, rel_path, mtime) in job_rx.iter() {
                        match crate::vbs_patches::classify(&vpx_path, &catalog) {
                            Ok(classification) => {
                                let decision_status =
                                    crate::vbs_patches::decision_status(&classification.decision);
                                // Apply side-effects (download + install). A
                                // failure here flips the recorded status to
                                // Failed so the next scan will retry.
                                let status = match crate::vbs_patches::apply_patch(
                                    &vpx_path,
                                    &classification.decision,
                                ) {
                                    Ok(()) => decision_status.to_string(),
                                    Err(e) => {
                                        log::warn!(
                                            "vbs_patches: apply failed for {}: {e}",
                                            rel_path
                                        );
                                        crate::vbs_patches::status::FAILED.to_string()
                                    }
                                };
                                log::info!("vbs_patches: {} → {}", rel_path, status);
                                let _ = tx.send((
                                    rel_path,
                                    classification.embedded_sha,
                                    classification.sidecar_sha,
                                    status,
                                    mtime,
                                ));
                            }
                            Err(e) => {
                                log::warn!("vbs_patches: classify failed for {}: {e}", rel_path);
                                let _ = tx.send((
                                    rel_path,
                                    String::new(),
                                    None,
                                    crate::vbs_patches::status::FAILED.to_string(),
                                    mtime,
                                ));
                            }
                        }
                    }
                    if remaining.fetch_sub(1, Ordering::SeqCst) == 1 {
                        log::info!("vbs_patches: classification run complete");
                    }
                })
                .ok();
        }
        drop(tx);
        self.vbs_rx = Some(rx);
    }

    /// Nuke PinReady's entire SDL3 footprint before spawning VPX.
    /// Drop the audio sender + flip the joystick running flag, join
    /// both worker threads (guarantees nobody is mid-call into SDL3),
    /// then call `SDL_Quit()` to slam every subsystem + open device
    /// down in one go. After this PinReady's process holds zero SDL3
    /// state — VPX spawns into a fresh SDL3 universe.
    pub(super) fn shutdown_sdl_threads(&mut self) {
        self.audio_cmd_tx = None;
        if let Some(handle) = self.audio_thread.take() {
            let _ = handle.join();
        }

        if let Some(running) = self.joystick_running.take() {
            running.store(false, Ordering::Relaxed);
        }
        self.joystick_rx = None;
        if let Some(handle) = self.joystick_thread.take() {
            let _ = handle.join();
        }

        unsafe {
            sdl3_sys::everything::SDL_Quit();
        }

        // Confirm SDL has fully wound down before we hand off to VPX.
        // `SDL_WasInit(0)` returns the bitmask of currently-initialized
        // subsystems and should hit zero immediately after `SDL_Quit`
        // (https://wiki.libsdl.org/SDL3/SDL_WasInit). On Linux some
        // teardown is observably async (PipeWire audio session
        // retention, joystick hotplug worker) so we poll for up to
        // 300 ms just to be sure.
        let poll_deadline = std::time::Instant::now() + std::time::Duration::from_millis(300);
        let zero = sdl3_sys::init::SDL_InitFlags(0);
        let mut residual = unsafe { sdl3_sys::everything::SDL_WasInit(zero) };
        while residual.0 != 0 && std::time::Instant::now() < poll_deadline {
            std::thread::sleep(std::time::Duration::from_millis(10));
            residual = unsafe { sdl3_sys::everything::SDL_WasInit(zero) };
        }
        if residual.0 != 0 {
            log::warn!(
                "SDL_WasInit still reports {:#x} 300 ms after SDL_Quit — proceeding anyway",
                residual.0
            );
        } else {
            log::info!("SDL_Quit() complete — all subsystems fully released");
        }
    }

    /// Re-spawn audio + joystick threads after VPX exits. New SDL3
    /// subsystem inits happen inside each thread, so the launcher is
    /// fully responsive again as soon as this returns.
    pub(super) fn respawn_sdl_threads(&mut self) {
        let (rx, running, handle) = crate::inputs::spawn_joystick_thread();
        self.joystick_rx = Some(rx);
        self.joystick_running = Some(running);
        self.joystick_thread = Some(handle);

        let (tx, handle) = crate::audio::spawn_audio_thread();
        self.audio_cmd_tx = Some(tx);
        self.audio_thread = Some(handle);
    }

    /// Entry point for launching a table from any UI path (click, Enter,
    /// joystick). On Wayland this is a two-step launch: request a fresh
    /// xdg-activation-v1 token from the compositor, then spawn VPX on a
    /// later frame once eframe hands it over (`Frame::take_activation_token`)
    /// — without a serial-sealed token,
    /// mutter refuses to grant focus and the table opens behind PinReady.
    /// A 500 ms deadline in `App::ui` falls back to launching without a
    /// token if the compositor never replies.
    ///
    /// Everywhere else (macOS, Windows, X11, headless) the token dance is
    /// pointless — the token request would be a no-op or produce an
    /// unused X11 startup id, and every launch would eat the full 500 ms
    /// deadline — so we spawn immediately, exactly like pre-0.14.1.
    pub(super) fn begin_table_launch(&mut self, path: std::path::PathBuf, ctx: &egui::Context) {
        if crate::session::detect() == Some("wayland") {
            // The request needs the eframe `Frame`, which `App::ui` holds:
            // it sends it at the start of the next frame.
            self.pending_vpx_launch = Some((path, std::time::Instant::now()));
            self.activation_token_wanted = true;
            ctx.request_repaint();
        } else {
            self.launch_table(&path, None);
        }
    }

    pub(super) fn launch_table(
        &mut self,
        table_path: &std::path::Path,
        activation_token: Option<String>,
    ) {
        if self.vpx_running.load(Ordering::Relaxed) {
            return;
        }
        // Preview audio stops automatically when we tear down the audio
        // thread below — no explicit PreviewStop needed.
        self.preview_playing = false;
        self.preview_due_at = None;
        let resolved = updater::resolve_vpx_exe(std::path::Path::new(&self.vpx_exe_path));
        if self.vpx_exe_path.is_empty() || !resolved.is_file() {
            log::error!("Visual Pinball executable not found: {}", self.vpx_exe_path);
            return;
        }
        self.record_table_start(table_path);
        // Release every SDL3 subsystem PinReady is holding (audio
        // device + open joystick handles + their respective subsystem
        // counters) so VPX can claim them cleanly. They'll be re-spawned
        // when VPX exits — see `process_vpx_status`.
        self.shutdown_sdl_threads();
        // Make room for VPX: the backglass pictures and thumbnails of the
        // grid are not needed while it runs. Only the cover of the table being
        // launched stays until VPX's own windows are up.
        let launching = self.tables.iter().position(|t| t.path == table_path);
        self.bg_cache.release(launching);
        log::info!(
            "Launching: {} -Play {}",
            resolved.display(),
            table_path.display()
        );
        let exe = resolved.display().to_string();
        let path = table_path.to_path_buf();
        let running = self.vpx_running.clone();
        running.store(true, Ordering::Relaxed);
        self.vpx_loading_msg = t!("launcher_loading").to_string();
        self.vpx_error_log = None;
        // The driver was chosen (and the config reconciled to it) once at
        // launcher startup; carry it into the launch thread for SDL_VIDEODRIVER.
        let vpx_driver = self.vpx_driver.clone();
        // VPX writes `vpinball.log` beside the settings file it loaded, so the
        // ini PinReady is actually using is what says where to listen. Deriving
        // it from the default location instead would silently watch the wrong
        // file whenever that ini is somewhere else — and a wrong file reads
        // exactly like a VPX with nothing to say.
        let vpx_log_path = self.config.path().with_file_name("vpinball.log");

        let (tx, rx) = crossbeam_channel::unbounded();
        self.vpx_status_rx = Some(rx);

        std::thread::spawn(move || {
            use std::io::BufRead;
            // Reproducible call header: same shell line a user could run
            // by hand, plus the cwd we spawned from and the host system
            // summary. Prepended to every error string so the popup
            // carries enough context to file a bug without going back to
            // re-derive paths or `uname -a` info.
            let call_header = || -> String {
                let cwd = std::env::current_dir()
                    .map(|p| p.display().to_string())
                    .unwrap_or_else(|_| "?".into());
                let sys = crate::system_info::detect().one_liner();
                format!(
                    "$ {} -Play {}\n  cwd:    {}\n  system: {}\n  client: PinReady v{}\n\n",
                    shell_quote(&exe),
                    shell_quote(&path.display().to_string()),
                    cwd,
                    sys,
                    crate::VERSION,
                )
            };
            let mut cmd = std::process::Command::new(&exe);
            cmd.arg("-Play")
                .arg(&path)
                .stdout(std::process::Stdio::piped())
                .stderr(std::process::Stdio::piped());
            // Pin VPX's SDL backend to the driver chosen above (Linux only —
            // `None` on macOS/Windows, a no-op there). Newer SDL3 auto-selects
            // XWayland when it sees both DISPLAY and WAYLAND_DISPLAY; forcing
            // the name here overrides any inherited or auto-detected value, and
            // it matches the driver the `*Display=` names were just reconciled
            // for so VPX finds its screens.
            if let Some(driver) = &vpx_driver {
                log::info!("Pinning VPX's SDL_VIDEODRIVER to {driver}");
                cmd.env("SDL_VIDEODRIVER", driver);
            }
            // Inject the compositor-issued xdg-activation-v1 token into
            // VPX's env so its SDL3 window can grab focus. The token was
            // requested from the compositor via
            // `ViewportCommand::RequestActivationToken` at click time and
            // delivered by winit through `Event::ActivationTokenReceived`.
            // A missing token means either X11/non-Wayland (harmless — the
            // env var is Wayland-specific) or the compositor didn't reply
            // in time — VPX may open behind PinReady in that case.
            if let Some(token) = activation_token {
                log::info!(
                    "xdg-activation token obtained (len {}), passing to VPX",
                    token.len()
                );
                cmd.env("XDG_ACTIVATION_TOKEN", token);
            } else {
                log::debug!(
                    "no xdg-activation token available; VPX may launch behind PinReady on Wayland"
                );
            }
            // Snapshot where VPX's own log currently ends, BEFORE starting it:
            // plog appends, so anything already there belongs to a previous
            // session and must not be replayed as if it were happening now.
            let mut log_tail = LogTail::new(vpx_log_path);
            // Opened BEFORE the spawn, so the exact command line survives even
            // a failure to start.
            let vpx_log_path = log_tail.path.clone();
            let mut run_log = RunLog::create(&path, &call_header());
            let child = cmd.spawn();
            match child {
                Ok(mut child) => {
                    log::info!("Visual Pinball launched, reading stdout+stderr...");

                    let stdout = child.stdout.take();
                    let stderr = child.stderr.take();
                    let mut stderr_lines: Vec<String> = Vec::new();
                    // Two-tier log buffer: the loading phase is kept in
                    // full (header + every line up to "Startup done"),
                    // then post-startup lines flow into a ring of the
                    // last 100. This gives the user always-meaningful
                    // diagnostics in the error popup — even if a table
                    // crashes mid-game (which used to be silent because
                    // `startup_done` short-circuited to ExitOk).
                    const INGAME_TAIL: usize = 100;
                    let mut loading_log: Vec<String> = Vec::new();
                    let mut ingame_log: std::collections::VecDeque<String> =
                        std::collections::VecDeque::with_capacity(INGAME_TAIL);
                    let mut startup_done = false;
                    // Has a line from VPX's OWN logger ever reached us?
                    //
                    // Not the same question as "did anything arrive". On
                    // Windows plenty arrives — PinMAME prints its ROM loading,
                    // dmdutil64.dll prints its converter warnings — but none of
                    // it comes from VPX, because VPX's plog only gets a console
                    // appender under `__STANDALONE__`, which the MSVC build does
                    // not define. So `SetProgress` and `Startup done` never
                    // arrive, `startup_done` can never flip, and the loading
                    // hang-detector below stays armed for the whole session.
                    let mut saw_vpx_log = false;
                    // Build the full log we hand to ExitError. Always
                    // contains the call header and every loading-phase
                    // line; if startup_done was reached, also a visible
                    // separator and the in-game tail.
                    let run_log_path = run_log.path.clone();
                    let build_error_log = |reason: &str,
                                           loading: &[String],
                                           ingame: &std::collections::VecDeque<String>,
                                           heard_vpx: bool|
                     -> String {
                        let mut out = call_header();
                        if !reason.is_empty() {
                            out.push_str(reason);
                            out.push_str("\n\n");
                        }
                        if !loading.is_empty() {
                            out.push_str("----- loading -----\n");
                            out.push_str(&loading.join("\n"));
                            out.push('\n');
                        }
                        if !ingame.is_empty() {
                            out.push_str("\n----- in-game (last ");
                            out.push_str(&ingame.len().to_string());
                            out.push_str(" lines) -----\n");
                            for l in ingame {
                                out.push_str(l);
                                out.push('\n');
                            }
                        }
                        if !run_log_path.as_os_str().is_empty() {
                            // Name the transcript: when the popup itself
                            // misbehaves, this is how the report still
                            // reaches us.
                            out.push_str("\nFull transcript of this launch:\n  ");
                            out.push_str(&run_log_path.display().to_string());
                            out.push('\n');
                        }
                        if !heard_vpx {
                            // Not one line from VPX itself — whatever we did
                            // capture came from PinMAME or dmdutil. That also
                            // means the stopped-responding detector never
                            // armed, so say so: a launch left silently
                            // unprotected is the failure we keep having to
                            // hunt. Naming the file makes a wrong path (a
                            // custom -PrefPath, a different VPX layout)
                            // obvious to whoever reads the report.
                            out.push_str(
                                "\n----- PinReady never heard from VPX -----\n\
                                 No line from VPX's own log reached us, so the \
                                 stopped-responding detector stayed disarmed for this \
                                 launch.\nThe file being watched was:\n  ",
                            );
                            out.push_str(&vpx_log_path.display().to_string());
                            out.push_str(
                                "\nIf that path is wrong, or logging is off in VPX's editor \
                                 options, PinReady is blind to what VPX is doing.\n",
                            );
                        }
                        out
                    };

                    {
                        // Poll often, decide slowly: draining every 250 ms
                        // notices an exit promptly, while the hang rule below
                        // still measures a real 2 min of silence. VPX logs
                        // nothing while it decodes a table's images: a 4-core
                        // Windows machine spent 60 s there on The Lord of the
                        // Rings, and 30 s killed that healthy load.
                        const POLL: std::time::Duration = std::time::Duration::from_millis(250);
                        const HANG_AFTER: std::time::Duration = std::time::Duration::from_secs(120);
                        let (line_tx, line_rx) = crossbeam_channel::unbounded();

                        // Both pipes feed the channel. They carry the
                        // third-party output — PinMAME's ROM loading, dmdutil's
                        // converter warnings — which is worth keeping for the
                        // report, plus VPX's own lines on the builds that have
                        // a console appender.
                        for (stream, tag) in [
                            (
                                stdout.map(|s| Box::new(s) as Box<dyn std::io::Read + Send>),
                                false,
                            ),
                            (
                                stderr.map(|s| Box::new(s) as Box<dyn std::io::Read + Send>),
                                true,
                            ),
                        ] {
                            let Some(stream) = stream else { continue };
                            let tx = line_tx.clone();
                            std::thread::spawn(move || {
                                let reader = std::io::BufReader::new(stream);
                                for line in reader.lines().map_while(Result::ok) {
                                    let item = if tag {
                                        VpxLine::Err(line)
                                    } else {
                                        VpxLine::Out(line)
                                    };
                                    if tx.send(item).is_err() {
                                        break;
                                    }
                                }
                            });
                        }
                        // ...and VPX's own log file, the one stream it drives
                        // on every platform.
                        let tail_stop = std::sync::Arc::new(AtomicBool::new(false));
                        let tail_handle = {
                            let tx = line_tx.clone();
                            let stop = std::sync::Arc::clone(&tail_stop);
                            std::thread::spawn(move || {
                                loop {
                                    // Read the flag first, then the file: the
                                    // pass after VPX exits still collects what
                                    // it wrote on the way out, which is exactly
                                    // where a crash reason lives.
                                    let stopping = stop.load(Ordering::Relaxed);
                                    for line in log_tail.read_new() {
                                        if tx.send(VpxLine::Log(line)).is_err() {
                                            return;
                                        }
                                    }
                                    if stopping {
                                        return;
                                    }
                                    std::thread::sleep(std::time::Duration::from_millis(200));
                                }
                            })
                        };
                        // Our own handle must go, or `Disconnected` never fires.
                        drop(line_tx);

                        let mut last_vpx_line = std::time::Instant::now();
                        let mut drain_until: Option<std::time::Instant> = None;
                        loop {
                            match line_rx.recv_timeout(POLL) {
                                Ok(item) => {
                                    let (line, from_log) = match item {
                                        VpxLine::Out(l) => {
                                            log::info!("[VPX] {}", l);
                                            run_log.line("out", &l);
                                            (l, false)
                                        }
                                        VpxLine::Err(l) => {
                                            log::warn!("[VPX stderr] {}", l);
                                            run_log.line("err", &l);
                                            stderr_lines.push(l.clone());
                                            (l, false)
                                        }
                                        VpxLine::Log(l) => {
                                            log::info!("[VPX log] {}", l);
                                            run_log.line("log", &l);
                                            (l, true)
                                        }
                                    };
                                    // A line is VPX's own if it came from VPX's
                                    // log file, or if it carries a marker only
                                    // VPX emits. Anything else is a library
                                    // shouting into our pipe and says nothing
                                    // about whether VPX is alive.
                                    if from_log
                                        || line.contains("SetProgress")
                                        || line.contains("Startup done")
                                        || line.contains("RenderStaticPrepass")
                                        || line.contains("PluginLog")
                                    {
                                        saw_vpx_log = true;
                                        last_vpx_line = std::time::Instant::now();
                                    }
                                    if line.contains("SetProgress") {
                                        if let Some(start) = line.find("] ") {
                                            let msg = &line[start + 2..];
                                            let pct = parse_progress_pct(msg);
                                            let _ =
                                                tx.send(VpxStatus::Loading(msg.to_string(), pct));
                                        }
                                    } else if line.contains("RenderStaticPrepass")
                                        && line.contains("Reflection Probe")
                                    {
                                        let _ = tx.send(VpxStatus::Loading(
                                            "Reflection Probe...".to_string(),
                                            None,
                                        ));
                                    } else if line.contains("PluginLog") {
                                        if let Some(start) = line.rfind("] ") {
                                            let msg = &line[start + 2..];
                                            if let Some(colon) = msg.find(':') {
                                                let plugin = &msg[..colon];
                                                let _ = tx.send(VpxStatus::Loading(
                                                    format!("Plugin {plugin}..."),
                                                    None,
                                                ));
                                            }
                                        }
                                    } else if line.contains("Startup done") {
                                        startup_done = true;
                                        loading_log.push(line);
                                        let _ = tx.send(VpxStatus::Started);
                                        continue;
                                    }
                                    if !startup_done {
                                        loading_log.push(line);
                                    } else {
                                        if ingame_log.len() == INGAME_TAIL {
                                            ingame_log.pop_front();
                                        }
                                        ingame_log.push_back(line);
                                    }
                                    continue;
                                }
                                Err(crossbeam_channel::RecvTimeoutError::Disconnected) => {
                                    // Every producer is gone.
                                    break;
                                }
                                Err(crossbeam_channel::RecvTimeoutError::Timeout) => {}
                            }

                            // Idle tick: has VPX exited, or stopped talking?
                            if let Some(deadline) = drain_until {
                                // It exited; keep pumping briefly so the log's
                                // final lines make it into the report.
                                if std::time::Instant::now() >= deadline {
                                    break;
                                }
                                continue;
                            }
                            if matches!(child.try_wait(), Ok(Some(_))) {
                                tail_stop.store(true, Ordering::Relaxed);
                                drain_until = Some(
                                    std::time::Instant::now() + std::time::Duration::from_secs(2),
                                );
                                continue;
                            }
                            if last_vpx_line.elapsed() >= HANG_AFTER {
                                if startup_done {
                                    // After startup, silence is normal: an
                                    // in-game VPX logs sparsely. Keep draining —
                                    // dropping `line_rx` would close the read end
                                    // of the pipes, and VPX's next write triggers
                                    // SIGPIPE and kills the game mid-play.
                                    continue;
                                }
                                if !saw_vpx_log {
                                    // VPX has never spoken to us, so its silence says
                                    // nothing and killing on it is a guess. On Windows
                                    // that guess is always wrong: whatever we did
                                    // receive came from PinMAME or dmdutil, and once
                                    // those go quiet — a long load, or simply a table
                                    // running — the hang timer fires on a perfectly
                                    // healthy game. Field report: a table killed
                                    // mid-load right after `loading fshtl_5.rom`.
                                    //
                                    // Treat the process as up instead: mark startup
                                    // done so the covers lift and the cursor is handed
                                    // to VPX, keep draining, and let VPX decide when it
                                    // exits. We lose the hang detector where VPX does
                                    // not report — there is no signal here to rebuild
                                    // it from, and killing someone's game on a guess is
                                    // the worse failure.
                                    log::warn!(
                                        "no VPX log line in {}s (only third-party output, if any): \
                                             this build has no console logger. Assuming it started; \
                                             VPX's own diagnostics are in vpinball.log.",
                                        HANG_AFTER.as_secs()
                                    );
                                    startup_done = true;
                                    let _ = tx.send(VpxStatus::Started);
                                    continue;
                                }
                                log::error!(
                                    "VPX log silent for {}s during loading — treating as hung",
                                    HANG_AFTER.as_secs()
                                );
                                let _ = child.kill();
                                let err = build_error_log(
                                    &format!(
                                        "Timeout: Visual Pinball stopped responding during loading (no output for {}s).",
                                        HANG_AFTER.as_secs()
                                    ),
                                    &loading_log,
                                    &ingame_log,
                                    saw_vpx_log,
                                );
                                let _ = tx.send(VpxStatus::ExitError(err));
                                tail_stop.store(true, Ordering::Relaxed);
                                running.store(false, Ordering::Relaxed);
                                return;
                            }
                        }
                        tail_stop.store(true, Ordering::Relaxed);
                        let _ = tail_handle.join();
                    }

                    let child_pid = child.id();
                    match child.wait() {
                        Ok(status) => {
                            log::info!("Visual Pinball exited with status: {status}");
                            // Decide whether to show the error popup. We
                            // diverge from "any non-zero after startup is
                            // OK": a table that closes mid-game without a
                            // popup is exactly the frustrating thing the
                            // user wants to avoid. Silent only when:
                            //   - exit code 0, OR
                            //   - exited cleanly (no signal, no NTSTATUS
                            //     crash) AND the user reached gameplay.
                            let abnormal = is_abnormal_exit(&status);
                            if status.success() || (startup_done && !abnormal) {
                                let _ = tx.send(VpxStatus::ExitOk);
                            } else {
                                let mut reason =
                                    format!("Visual Pinball exited with status: {status}");
                                if let Some(desc) = describe_coredump(child_pid, &status) {
                                    reason.push_str("\n\n");
                                    reason.push_str(&desc);
                                }
                                let mut combined = build_error_log(
                                    &reason,
                                    &loading_log,
                                    &ingame_log,
                                    saw_vpx_log,
                                );
                                if !stderr_lines.is_empty() {
                                    combined.push_str("\n----- stderr -----\n");
                                    combined.push_str(&stderr_lines.join("\n"));
                                    combined.push('\n');
                                }
                                let _ = tx.send(VpxStatus::ExitError(combined));
                            }
                        }
                        Err(e) => {
                            log::error!("Failed to wait for Visual Pinball: {e}");
                            let combined = build_error_log(
                                &format!("Process error: {e}"),
                                &loading_log,
                                &ingame_log,
                                saw_vpx_log,
                            );
                            let _ = tx.send(VpxStatus::ExitError(combined));
                        }
                    }
                }
                Err(e) => {
                    log::error!("Failed to launch Visual Pinball: {e}");
                    let _ = tx.send(VpxStatus::LaunchError(format!(
                        "{}Failed to launch: {e}",
                        call_header()
                    )));
                }
            }
            running.store(false, Ordering::Relaxed);
        });
    }

    /// Drive the per-table audio preview: when `selected_table` changes
    /// we stop any current preview, schedule a debounced PreviewStart, and
    /// fire it once the deadline passes. VPX-running suspends previews so
    /// the table soundtrack doesn't double up with our jingle.
    pub(super) fn process_preview_audio(&mut self, ctx: &egui::Context) {
        if self.tables.is_empty() {
            return;
        }
        let vpx_running = self.vpx_running.load(Ordering::Relaxed);
        if vpx_running {
            if self.preview_playing {
                if let Some(tx) = &self.audio_cmd_tx {
                    let _ = tx.send(AudioCommand::PreviewStop);
                }
                self.preview_playing = false;
            }
            self.preview_last_idx = None;
            self.preview_due_at = None;
            return;
        }

        let cur = self.selected_table;
        if Some(cur) != self.preview_last_idx {
            // Selection changed — stop current, debounce next start.
            if self.preview_playing {
                if let Some(tx) = &self.audio_cmd_tx {
                    let _ = tx.send(AudioCommand::PreviewStop);
                }
                self.preview_playing = false;
            }
            self.preview_last_idx = Some(cur);
            self.preview_due_at =
                Some(std::time::Instant::now() + std::time::Duration::from_millis(700));
        }

        if let Some(due) = self.preview_due_at {
            if std::time::Instant::now() >= due {
                self.preview_due_at = None;
                if let Some(table) = self.tables.get(cur)
                    && let Some(table_dir) = table.path.parent()
                {
                    let audio_path = table_dir.join("medias").join("audio.mp3");
                    if audio_path.is_file()
                        && let Some(tx) = &self.audio_cmd_tx
                    {
                        // Preview clips are halved so they sit
                        // below the in-game soundtrack baseline —
                        // hovering over a card shouldn't be louder
                        // than the table the user is browsing for.
                        let volume = (self.audio.music_volume as f32 / 100.0 * 0.5).clamp(0.0, 1.0);
                        let _ = tx.send(AudioCommand::PreviewStart {
                            path: audio_path,
                            volume,
                        });
                        self.preview_playing = true;
                    }
                }
            } else {
                ctx.request_repaint_after(due - std::time::Instant::now());
            }
        }
    }

    pub(super) fn process_bg_extraction(&mut self) {
        if let Some(rx) = &self.bg_rx {
            // Drain without holding a borrow of `self` — we need `&mut self`
            // below for `self.db.set_backglass` and the TableEntry update.
            let drained: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
            let disconnected = matches!(
                rx.try_recv(),
                Err(crossbeam_channel::TryRecvError::Disconnected)
            );
            if disconnected {
                log::info!("Background backglass extraction channel closed");
                self.bg_rx = None;
            }
            for (msg_gen, idx, rel_path, bytes, source_mtime) in drained {
                // Drop messages from a prior scan whose index space
                // no longer matches `self.tables` (the user may have
                // hit Rescan while this thread was still extracting).
                if msg_gen != self.scan_generation {
                    log::debug!(
                        "Dropping stale BG result gen={msg_gen} (current={}) for {rel_path}",
                        self.scan_generation
                    );
                    continue;
                }
                // Belt-and-suspenders: the row that was at `idx` in the
                // bg thread's snapshot may be a *different* table now
                // (sort order can shift between scans). Look up the
                // current row by path and trust that over the index.
                let cur_idx = self
                    .tables
                    .iter()
                    .position(|t| {
                        t.path
                            .strip_prefix(&self.tables_dir)
                            .map(|p| p.to_string_lossy() == *rel_path)
                            .unwrap_or(false)
                    })
                    .or(if idx < self.tables.len() {
                        Some(idx)
                    } else {
                        None
                    });
                let Some(idx) = cur_idx else {
                    continue;
                };
                // The worker already stored the picture and its thumbnail
                // (database writes stay off the UI thread); the bytes here
                // are the thumbnail, decoded by the cache's threads.
                let _ = source_mtime;
                self.tables[idx].has_bg = true;
                self.bg_cache
                    .queue_thumb(idx, std::sync::Arc::from(bytes.into_boxed_slice()));
                log::debug!("BG cached for table {idx} ({rel_path})");
            }
        }
    }

    /// Drain VBS-patch classification results and persist them in
    /// `vbs_patches`. No UI side-effects — patching is silent by design
    /// (user validates via log + `.pre_standalone.vbs` files appearing).
    pub(super) fn process_vbs_extraction(&mut self) {
        if let Some(rx) = &self.vbs_rx {
            let drained: Vec<_> = std::iter::from_fn(|| rx.try_recv().ok()).collect();
            let disconnected = matches!(
                rx.try_recv(),
                Err(crossbeam_channel::TryRecvError::Disconnected)
            );
            if disconnected {
                log::info!("vbs_patches: channel closed");
                self.vbs_rx = None;
            }
            for (rel_path, embedded_sha, sidecar_sha, status, mtime) in drained {
                if let Err(e) = self.db.set_vbs_patch(
                    &rel_path,
                    &embedded_sha,
                    sidecar_sha.as_deref(),
                    &status,
                    mtime,
                ) {
                    log::error!("Failed to upsert vbs_patches row for {rel_path}: {e}");
                }
            }
        }
    }

    /// Find launcher navigation action for a button.
    /// Only matches LeftFlipper, RightFlipper, LeftMagna, RightMagna, Start,
    /// LaunchBall, ExitGame — ignores StagedFlipper and other actions to avoid
    /// conflicts when flipper and staged are on the same physical button.
    fn action_for_launcher_nav(&self, button: u8) -> Option<String> {
        const NAV_ACTIONS: &[&str] = &[
            "LeftFlipper",
            "RightFlipper",
            "LeftMagna",
            "RightMagna",
            "Start",
            "LaunchBall",
            "ExitGame",
        ];
        for action in &self.actions {
            if !NAV_ACTIONS.contains(&action.setting_id) {
                continue;
            }
            if let Some(inputs::CapturedInput::JoystickButton { button: b, .. }) = &action.joystick
                && *b == button
            {
                return Some(action.setting_id.to_string());
            }
        }
        None
    }

    /// Send `Close` to every cover viewport we may have spawned
    /// (BG/DMD/Topper). Must run *before* closing the root viewport
    /// when exiting the launcher: if the root dies first eframe
    /// theoretically tears the rest down in cascade, but on
    /// Wayland/Mutter this leaves the cover windows behind as
    /// compositor ghosts. Closing them ourselves makes the
    /// destruction order deterministic. Sending Close to a viewport
    /// that doesn't exist is a no-op, so addressing all three
    /// unconditionally is safe.
    pub(super) fn close_cover_viewports(ctx: &egui::Context) {
        for cover_id in [
            crate::app::BG_VIEWPORT,
            crate::app::DMD_VIEWPORT,
            crate::app::TOPPER_VIEWPORT,
        ] {
            let viewport_id = egui::ViewportId::from_hash_of(cover_id);
            ctx.send_viewport_cmd_to(viewport_id, egui::ViewportCommand::Close);
        }
    }

    /// Unified exit: release cursor capture (otherwise the OS cursor stays
    /// hidden while the window tears down), close every cover viewport
    /// (BG/DMD/Topper) explicitly, then request the root viewport close.
    /// Called from the Quit button, ExitGame joystick action, and Escape key.
    pub(super) fn quit_launcher(&mut self, ctx: &egui::Context) {
        // Releasing the capture is enough since egui-rotate 1.1: the plugin
        // drops its OS grab and stops hiding the pointer on that transition.
        Self::with_software_cursor(ctx, |c| c.release());
        ctx.send_viewport_cmd(egui::ViewportCommand::CursorVisible(true));

        Self::close_cover_viewports(ctx);
        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
    }

    /// Indices into `self.tables` of the currently-visible cards
    /// (matches the grid the user actually sees). Empty filter →
    /// every table; non-empty → tables whose name contains the
    /// lowercased filter.
    pub(super) fn visible_indices(&self) -> Vec<usize> {
        let mut order: Vec<usize> = if self.table_filter_lower.is_empty() {
            (0..self.tables.len()).collect()
        } else {
            let f = self.table_filter_lower.as_str();
            (0..self.tables.len())
                .filter(|&i| self.tables[i].name.to_lowercase().contains(f))
                .collect()
        };
        self.sort_mode
            .sort(&mut order, &self.tables, &self.community);
        order
    }

    fn rel_path_of(&self, idx: usize) -> String {
        rel_to(
            std::path::Path::new(&self.tables_dir),
            &self.tables[idx].path,
        )
    }

    /// Favourite or un-favourite the selected table.
    pub(super) fn toggle_favorite_selected(&mut self) {
        let idx = self.selected_table;
        if idx >= self.tables.len() {
            return;
        }
        let rel = self.rel_path_of(idx);
        let now = unix_now();
        let on = !self.tables[idx].user.favorite;
        self.tables[idx].user.favorite = on;
        self.tables[idx].user.favorite_at = Some(now);
        if let Err(e) = self.db.set_favorite(&rel, on, now) {
            log::error!("could not store the favourite flag of {rel}: {e}");
        }
        self.scroll_to_selected = true;
    }

    /// Rate the selected table in tenths of a star, 0 clears the rating.
    pub(super) fn rate_selected(&mut self, tenths: u8) {
        let idx = self.selected_table;
        if idx >= self.tables.len() {
            return;
        }
        let rel = self.rel_path_of(idx);
        let now = unix_now();
        self.tables[idx].user.rating_tenths = tenths.min(50);
        self.tables[idx].user.rated_at = Some(now);
        if let Err(e) = self.db.set_rating(&rel, tenths, now) {
            log::error!("could not store the rating of {rel}: {e}");
        }
        self.scroll_to_selected = true;
    }

    /// Switch to the next sort order and remember it.
    pub(super) fn cycle_sort(&mut self) {
        self.sort_mode = self.sort_mode.next();
        if let Err(e) = self.db.set_config("launcher_sort", self.sort_mode.key()) {
            log::error!("could not store the sort order: {e}");
        }
        self.scroll_to_selected = true;
    }

    /// A table is about to start: count it and note when.
    fn record_table_start(&mut self, table_path: &std::path::Path) {
        let Some(idx) = self.tables.iter().position(|t| t.path == table_path) else {
            return;
        };
        let rel = self.rel_path_of(idx);
        let now = unix_now();
        let user = &mut self.tables[idx].user;
        user.last_run = Some(now);
        user.start_count += 1;
        if let Err(e) = self.db.record_start(&rel, now) {
            log::error!("could not record the start of {rel}: {e}");
        }
        self.vpx_session = Some((std::time::Instant::now(), rel));
    }

    /// Visual Pinball exited: add the length of the session to the stats.
    fn finish_vpx_session(&mut self) {
        let Some((started, rel)) = self.vpx_session.take() else {
            return;
        };
        let seconds = started.elapsed().as_secs().min(u32::MAX as u64) as u32;
        if let Err(e) = self.db.add_run_time(&rel, seconds) {
            log::error!("could not record the play time of {rel}: {e}");
        }
        let dir = std::path::PathBuf::from(&self.tables_dir);
        if let Some(t) = self
            .tables
            .iter_mut()
            .find(|t| rel_to(&dir, &t.path) == rel)
        {
            t.user.run_time = t.user.run_time.saturating_add(seconds);
        }
        // Share the new numbers when the player asked for it.
        self.push_after_session(&rel);
    }

    /// Dispatch a launcher action. Navigation actions loop over the
    /// currently-visible (filtered) tables only; Launch and Cancel
    /// drive table launch and search-clear/quit respectively. Returns
    /// `true` when a directional action moved the selection — used by
    /// the joystick repeat scheduler to decide whether to keep firing.
    pub(super) fn apply_launcher_action(
        &mut self,
        action: launcher_input::LauncherAction,
        ctx: &egui::Context,
    ) -> bool {
        use launcher_input::LauncherAction;
        match action {
            LauncherAction::PrevCard
            | LauncherAction::NextCard
            | LauncherAction::PrevRow
            | LauncherAction::NextRow => {
                let visible = self.visible_indices();
                if visible.is_empty() {
                    return false;
                }
                let cols = self.launcher_cols.max(1);
                let n = visible.len();
                let pos = visible
                    .iter()
                    .position(|&i| i == self.selected_table)
                    .unwrap_or(0);
                let new_pos = match action {
                    LauncherAction::PrevCard => {
                        if pos > 0 {
                            pos - 1
                        } else {
                            n - 1
                        }
                    }
                    LauncherAction::NextCard => (pos + 1) % n,
                    LauncherAction::PrevRow => {
                        if pos >= cols {
                            pos - cols
                        } else {
                            (n - 1).min(pos + n - cols)
                        }
                    }
                    LauncherAction::NextRow => {
                        if pos + cols < n {
                            pos + cols
                        } else {
                            pos % cols
                        }
                    }
                    _ => unreachable!(),
                };
                self.selected_table = visible[new_pos];
                self.scroll_to_selected = true;
                true
            }
            LauncherAction::Launch => {
                if !self.tables.is_empty() {
                    let path = self.tables[self.selected_table].path.clone();
                    self.begin_table_launch(path, ctx);
                }
                false
            }
            LauncherAction::ToggleFavorite => {
                self.toggle_favorite_selected();
                false
            }
            LauncherAction::AdjustRating(delta) => {
                let current = self
                    .tables
                    .get(self.selected_table)
                    .map_or(0, |t| t.user.rating_tenths as i16);
                self.rate_selected((current + delta as i16).clamp(0, 50) as u8);
                false
            }
            LauncherAction::CycleSort => {
                self.cycle_sort();
                false
            }
            LauncherAction::Cancel => {
                if !self.table_filter.is_empty() {
                    self.table_filter.clear();
                    self.table_filter_lower.clear();
                } else {
                    self.quit_launcher(ctx);
                }
                false
            }
        }
    }

    /// How long the launcher stays deaf after VPX exits.
    pub(super) const INPUT_GRACE_AFTER_VPX: std::time::Duration = std::time::Duration::from_secs(1);

    /// True while the post-VPX-exit grace window is still open. Clears the
    /// deadline once it has elapsed so the check stays cheap afterwards.
    pub(super) fn launcher_input_suppressed(&mut self) -> bool {
        match self.input_resume_at {
            Some(at) if std::time::Instant::now() < at => true,
            Some(_) => {
                self.input_resume_at = None;
                false
            }
            None => false,
        }
    }

    pub(super) fn handle_launcher_joystick(&mut self, ui: &mut egui::Ui) {
        use launcher_input::LauncherAction;
        let vpx_running = self.vpx_running.load(Ordering::Relaxed);
        // Drain joystick events into a local vec to avoid borrow conflict
        let events: Vec<JoystickEvent> = self
            .joystick_rx
            .as_ref()
            .map(|rx| rx.try_iter().collect())
            .unwrap_or_default();

        if vpx_running || self.tables.is_empty() {
            return;
        }

        // Post-exit grace: the events we just drained (the exit button, its
        // release, any repeat) are discarded, and a held-nav is cancelled,
        // until the window elapses. Draining here is what stops them firing
        // the instant the window closes.
        if self.launcher_input_suppressed() {
            self.nav_held = None;
            return;
        }

        // Key-repeat for held directional button: 400ms initial delay,
        // then 80ms interval — same cadence as a typical OS keyboard.
        const INITIAL_DELAY: std::time::Duration = std::time::Duration::from_millis(400);
        const REPEAT_INTERVAL: std::time::Duration = std::time::Duration::from_millis(80);
        if let Some((_, action, pressed_at, last_fire)) = self.nav_held {
            let now = std::time::Instant::now();
            if now.duration_since(pressed_at) >= INITIAL_DELAY
                && now.duration_since(last_fire) >= REPEAT_INTERVAL
                && self.apply_launcher_action(action, ui.ctx())
            {
                if let Some(held) = self.nav_held.as_mut() {
                    held.3 = now;
                }
                ui.ctx().request_repaint();
            }
        }

        for event in events {
            match &event {
                JoystickEvent::ButtonDown { button, .. } => {
                    let Some(action) = self
                        .action_for_launcher_nav(*button)
                        .as_deref()
                        .and_then(LauncherAction::from_vpx_action)
                    else {
                        continue;
                    };
                    // Joystick navigation parks the software cursor (dissolve
                    // + hover cleared): a pointer resting on a card would
                    // otherwise re-select it and override flipper navigation.
                    // egui never sees joystick events, so this is signalled
                    // manually; keyboard gets the same via the plugin's
                    // `with_dormant_on_keys`. Any mouse move reforms it.
                    Self::with_software_cursor(ui.ctx(), |c| c.set_dormant(true));
                    let applied = self.apply_launcher_action(action, ui.ctx());
                    if applied && action.is_directional() {
                        let now = std::time::Instant::now();
                        self.nav_held = Some((*button, action, now, now));
                    }
                }
                JoystickEvent::ButtonUp { button, .. } => {
                    if let Some((held_btn, _, _, _)) = &self.nav_held
                        && held_btn == button
                    {
                        self.nav_held = None;
                    }
                }
                JoystickEvent::AccelUpdate { .. } => {}
                _ => {}
            }
        }
    }

    /// VPX is gone: the grid's pictures were freed when it launched, so load
    /// the thumbnails again from the database (the full pictures follow the
    /// page on screen).
    pub(super) fn restore_bg_cache(&mut self) {
        if !self.bg_cache.is_released() {
            return;
        }
        self.bg_cache.restore();
        let thumbs = self.db.get_all_thumbs();
        for (idx, table) in self.tables.iter().enumerate() {
            let Ok(rel) = table.path.strip_prefix(&self.tables_dir) else {
                continue;
            };
            if let Some((bytes, _)) = thumbs.get(rel.to_string_lossy().as_ref()) {
                self.bg_cache
                    .queue_thumb(idx, std::sync::Arc::from(bytes.as_slice()));
            }
        }
    }

    pub(super) fn process_vpx_status(&mut self, ctx: &egui::Context) {
        if let Some(rx) = &self.vpx_status_rx {
            while let Ok(status) = rx.try_recv() {
                match status {
                    VpxStatus::Loading(msg, pct) => {
                        self.vpx_loading_msg = msg;
                        self.vpx_loading_pct = pct;
                    }
                    VpxStatus::Started => {
                        self.vpx_loading_msg = "Startup done".to_string();
                        self.vpx_loading_pct = None;
                        self.vpx_hide_covers = true;
                        // The covers turn black (see `render_black_covers`);
                        // repaint now, the launcher may otherwise sit idle
                        // behind Visual Pinball and they would stay as they were.
                        ctx.request_repaint();
                        // The covers are hidden now: the last picture can go too.
                        self.bg_cache.release(None);
                        // Release the cursor capture so VPX gets the mouse —
                        // the plugin drops its OS grab on that transition
                        // (egui-rotate 1.1). Focus is released naturally
                        // because the kiosk focus-reclaim loop is gated on
                        // !vpx_running. VPX windows then z-order on top.
                        Self::with_software_cursor(ctx, |c| c.release());
                        ctx.send_viewport_cmd(egui::ViewportCommand::CursorVisible(true));
                    }
                    VpxStatus::ExitOk => {
                        self.finish_vpx_session();
                        self.vpx_loading_msg.clear();
                        self.vpx_loading_pct = None;
                        self.vpx_hide_covers = false;
                        self.vpx_status_rx = None;
                        self.respawn_sdl_threads();
                        self.restore_bg_cache();
                        self.restore_kiosk_after_vpx(ctx);
                        return;
                    }
                    VpxStatus::ExitError(log) => {
                        self.finish_vpx_session();
                        self.vpx_loading_msg.clear();
                        self.vpx_hide_covers = false;
                        // the crash report must be reachable: no cover over it
                        Self::close_cover_viewports(ctx);
                        self.vpx_error_log = Some(log);
                        self.vpx_status_rx = None;
                        self.respawn_sdl_threads();
                        self.restore_bg_cache();
                        self.restore_kiosk_after_vpx(ctx);
                        return;
                    }
                    VpxStatus::LaunchError(msg) => {
                        self.vpx_loading_msg.clear();
                        self.vpx_hide_covers = false;
                        self.vpx_error_log = Some(msg);
                        self.vpx_status_rx = None;
                        self.respawn_sdl_threads();
                        self.restore_bg_cache();
                        self.restore_kiosk_after_vpx(ctx);
                        return;
                    }
                }
            }
        }
    }

    /// When VPX exits, trigger re-warp + re-focus on the next frame. The
    /// kiosk_cursor loop in App::ui handles the actual Focus + CursorPosition
    /// commands once vpx_running flips to false.
    fn restore_kiosk_after_vpx(&mut self, _ctx: &egui::Context) {
        if self.kiosk_cursor {
            self.kiosk_cursor_warped = false;
        }
        // Hold off launcher input for a beat: the exit button/key that just
        // closed VPX is frequently still down (or already queued in our
        // joystick channel) when control returns here, and the launcher
        // would read it as `Cancel` and quit itself. Input paths drain and
        // ignore events until this instant.
        //
        // A second, not a few frames. The key does not arrive when VPX dies
        // but when the compositor hands focus back, which on a cabinet comes
        // after the window teardown, the output remode and our own repaint —
        // easily past a short window, and the launcher then quits along with
        // the table.
        self.input_resume_at = Some(std::time::Instant::now() + Self::INPUT_GRACE_AFTER_VPX);
    }

    pub(super) fn process_update_check(&mut self) {
        // Receive update check result
        if let Some(rx) = &self.update_check_rx
            && let Ok(result) = rx.try_recv()
        {
            match result {
                Ok(release) => {
                    log::info!(
                        "Latest release: {} (installed: {})",
                        release.tag,
                        self.vpx_installed_tag
                    );

                    // Never offer auto-updates for manually installed VPX.
                    // Users managing manual installs are responsible for updates.
                    if self.vpx_install_mode == VpxInstallMode::Manual {
                        log::info!(
                            "Skipping update prompt: VPX was manually installed (not auto-downloaded)"
                        );
                        self.vpx_latest_release = None;
                    } else if release.tag != self.vpx_installed_tag {
                        self.vpx_latest_release = Some(release);
                    } else {
                        self.vpx_latest_release = None;
                    }
                }
                Err(e) => {
                    log::warn!("Update check failed: {e}");
                }
            }
            self.update_check_rx = None;
        }
        // Receive download progress
        if let Some(rx) = &self.update_progress_rx {
            while let Ok(progress) = rx.try_recv() {
                match progress {
                    UpdateProgress::Downloading(current, total) => {
                        self.update_progress = (current, total);
                    }
                    UpdateProgress::Extracting => {
                        self.update_downloading = true;
                    }
                    UpdateProgress::Done(exe_path) => {
                        let path_str = exe_path.display().to_string();
                        self.vpx_exe_path = path_str.clone();
                        let _ = self.db.set_config("vpx_exe_path", &path_str);
                        if let Some(rel) = &self.vpx_latest_release {
                            self.vpx_installed_tag = rel.tag.clone();
                            let _ = self.db.set_config("vpx_installed_tag", &rel.tag);
                        }
                        self.update_downloading = false;
                        self.update_progress = (0, 0);
                        self.vpx_latest_release = None;
                        self.update_progress_rx = None;
                        self.update_error = None;
                        log::info!("Visual Pinball installed to: {}", path_str);
                        return;
                    }
                    UpdateProgress::Error(msg) => {
                        self.update_downloading = false;
                        self.update_error = Some(msg.clone());
                        self.update_progress_rx = None;
                        log::error!("Visual Pinball update failed: {}", msg);
                        return;
                    }
                }
            }
        }
    }

    pub(super) fn start_vpx_download(&mut self, release: &ReleaseInfo) {
        let install_dir = std::path::PathBuf::from(&self.vpx_install_dir);
        let release = release.clone();
        let (tx, rx) = crossbeam_channel::unbounded();
        self.update_progress_rx = Some(rx);
        self.update_downloading = true;
        self.update_progress = (0, release.asset_size);
        self.update_error = None;
        std::thread::spawn(move || {
            if let Err(e) = updater::download_and_install(&release, &install_dir, tx.clone()) {
                let dir = install_dir.display().to_string();
                let msg = match updater::classify_install_error(&e) {
                    Some(updater::InstallFailure::Denied) => {
                        t!("update_install_denied", dir = dir).to_string()
                    }
                    Some(updater::InstallFailure::NotFound) => {
                        t!("update_install_not_found", dir = dir).to_string()
                    }
                    None => format!("{e}"),
                };
                let _ = tx.send(UpdateProgress::Error(msg));
            }
        });
    }

    /// Poll the PinReady self-update channels. On a completed download the
    /// running process exits immediately — the freshly-spawned child from
    /// `download_pinready_and_replace` takes over as the user-facing instance.
    pub(super) fn process_pinready_update_check(&mut self, ctx: &egui::Context) {
        if let Some(rx) = &self.pinready_update_check_rx
            && let Ok(result) = rx.try_recv()
        {
            match result {
                Ok(release) => {
                    if updater::is_pinready_update_available(&release) {
                        log::info!(
                            "PinReady update available: {} (running: {})",
                            release.tag,
                            updater::CURRENT_PINREADY_VERSION
                        );
                        self.pinready_latest_release = Some(release);
                    } else {
                        log::info!("PinReady is up to date ({})", release.tag);
                        self.pinready_latest_release = None;
                    }
                }
                Err(e) => log::warn!("PinReady update check failed: {e}"),
            }
            self.pinready_update_check_rx = None;
        }

        if let Some(rx) = &self.pinready_update_progress_rx {
            while let Ok(progress) = rx.try_recv() {
                match progress {
                    UpdateProgress::Downloading(current, total) => {
                        self.pinready_update_progress = (current, total);
                    }
                    UpdateProgress::Extracting => {
                        self.pinready_updating = true;
                    }
                    UpdateProgress::Done(_) => {
                        log::info!("PinReady update: binary replaced, restarting");
                        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
                        std::process::exit(0);
                    }
                    UpdateProgress::Error(msg) => {
                        self.pinready_updating = false;
                        self.pinready_update_error = Some(msg.clone());
                        self.pinready_update_progress_rx = None;
                        log::error!("PinReady update failed: {}", msg);
                        return;
                    }
                }
            }
        }
    }

    pub(super) fn start_pinready_download(&mut self, release: &ReleaseInfo) {
        let release = release.clone();
        let (tx, rx) = crossbeam_channel::unbounded();
        self.pinready_update_progress_rx = Some(rx);
        self.pinready_updating = true;
        self.pinready_update_progress = (0, release.asset_size);
        self.pinready_update_error = None;
        std::thread::spawn(move || {
            if let Err(e) = updater::download_pinready_and_replace(&release, tx.clone()) {
                let _ = tx.send(UpdateProgress::Error(format!("{e}")));
            }
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn parse_pct_with_integer() {
        let pct = parse_progress_pct("Initializing Visuals... 10%");
        assert!(pct.is_some());
        assert!((pct.unwrap() - 0.10).abs() < 0.001);
    }

    #[test]
    fn parse_pct_full() {
        let pct = parse_progress_pct("Done 100%");
        assert!(pct.is_some());
        assert!((pct.unwrap() - 1.0).abs() < 0.001);
    }

    #[test]
    fn parse_pct_zero() {
        let pct = parse_progress_pct("Starting 0%");
        assert!(pct.is_some());
        assert!((pct.unwrap() - 0.0).abs() < 0.001);
    }

    #[test]
    fn parse_pct_no_percentage() {
        assert!(parse_progress_pct("Loading...").is_none());
    }

    #[test]
    fn parse_pct_no_number_before_percent() {
        assert!(parse_progress_pct("Progress: %").is_none());
    }

    #[test]
    fn parse_pct_clamped_above_100() {
        let pct = parse_progress_pct("Overflow 150%");
        assert!(pct.is_some());
        assert!((pct.unwrap() - 1.0).abs() < 0.001);
    }

    #[test]
    fn parse_pct_with_decimal() {
        let pct = parse_progress_pct("Loading 33.5%");
        assert!(pct.is_some());
        assert!((pct.unwrap() - 0.335).abs() < 0.001);
    }

    #[test]
    fn parse_pct_embedded_in_brackets() {
        // Realistic VPX format: "[INFO SetProgress] Loading Textures... 45%"
        let pct = parse_progress_pct("Loading Textures... 45%");
        assert!(pct.is_some());
        assert!((pct.unwrap() - 0.45).abs() < 0.001);
    }

    /// A pre-existing log belongs to the previous session: starting at 0 would
    /// replay it and, worse, re-trigger `Startup done` before VPX had started.
    #[test]
    fn log_tail_starts_at_the_end_of_what_is_already_there() {
        let path = std::env::temp_dir().join("pinready-tail-existing.log");
        std::fs::write(&path, "old line from last time\n").unwrap();
        let mut tail = LogTail::new(path.clone());
        assert!(
            tail.read_new().is_empty(),
            "the old session must not replay"
        );
        append(&path, "brand new line\n");
        assert_eq!(tail.read_new(), vec!["brand new line".to_string()]);
        let _ = std::fs::remove_file(&path);
    }

    /// The first launch creates the file after we start watching: a missing
    /// file is a normal state, not an error, and must be picked up once it
    /// appears.
    #[test]
    fn log_tail_waits_for_a_file_that_does_not_exist_yet() {
        let path = std::env::temp_dir().join("pinready-tail-absent.log");
        let _ = std::fs::remove_file(&path);
        let mut tail = LogTail::new(path.clone());
        assert!(tail.read_new().is_empty());
        std::fs::write(&path, "first line\n").unwrap();
        assert_eq!(tail.read_new(), vec!["first line".to_string()]);
        let _ = std::fs::remove_file(&path);
    }

    /// Reads land mid-line all the time. Half a line must be held back, not
    /// emitted as if it were complete — `Startup don` matches nothing.
    #[test]
    fn log_tail_holds_back_a_partial_line() {
        let path = std::env::temp_dir().join("pinready-tail-partial.log");
        std::fs::write(&path, "").unwrap();
        let mut tail = LogTail::new(path.clone());
        append(&path, "Startup do");
        assert!(tail.read_new().is_empty(), "half a line is not a line");
        append(&path, "ne\nnext\n");
        assert_eq!(
            tail.read_new(),
            vec!["Startup done".to_string(), "next".to_string()]
        );
        let _ = std::fs::remove_file(&path);
    }

    /// plog rolls the file at 5 MB. After a roll the new file is shorter than
    /// our cursor; following from the old offset would skip real content or
    /// read garbage.
    #[test]
    fn log_tail_follows_the_file_across_a_rollover() {
        let path = std::env::temp_dir().join("pinready-tail-roll.log");
        std::fs::write(&path, "a long first session line\n").unwrap();
        let mut tail = LogTail::new(path.clone());
        append(&path, "second line\n");
        assert_eq!(tail.read_new(), vec!["second line".to_string()]);
        // Rolled over: same name, fresh and shorter.
        std::fs::write(&path, "after roll\n").unwrap();
        assert_eq!(tail.read_new(), vec!["after roll".to_string()]);
        let _ = std::fs::remove_file(&path);
    }

    fn append(path: &std::path::Path, text: &str) {
        use std::io::Write as _;
        let mut f = std::fs::OpenOptions::new().append(true).open(path).unwrap();
        f.write_all(text.as_bytes()).unwrap();
    }

    /// The transcript must carry the launch command *before* VPX has said
    /// anything: a crash on start still leaves the exact command line on disk.
    #[test]
    fn run_log_records_the_command_before_any_output() {
        let table = std::path::Path::new("/tmp/Fish Tales (Williams 1992).vpx");
        let header = "$ 'VPinballX' -Play 'Fish Tales'\n  system: test\n\n";
        let mut rl = RunLog::create(table, header);
        assert!(rl.path.exists(), "the file is created up front");
        rl.line("out", "loading fshtl_5.rom");
        rl.line("err", "something on stderr");
        let body = std::fs::read_to_string(&rl.path).unwrap();
        assert!(body.contains("-Play 'Fish Tales'"), "{body}");
        assert!(body.contains("[out] loading fshtl_5.rom"), "{body}");
        assert!(body.contains("[err] something on stderr"), "{body}");
        // Named after the table, so a folder of them reads at a glance.
        let name = rl.path.file_name().unwrap().to_string_lossy().into_owned();
        assert!(name.contains("Fish-Tales"), "{name}");
        let _ = std::fs::remove_file(&rl.path);
    }

    /// A cabinet launches tables all evening; the folder must not grow without
    /// bound, and it must be the OLDEST that go.
    #[test]
    fn run_log_prunes_to_the_most_recent() {
        let dir = std::env::temp_dir().join("pinready-runlog-prune");
        let _ = std::fs::remove_dir_all(&dir);
        std::fs::create_dir_all(&dir).unwrap();
        for i in 0..15 {
            std::fs::write(dir.join(format!("vpx-{i:04}-t.log")), "x").unwrap();
        }
        RunLog::prune(&dir);
        let mut left: Vec<String> = std::fs::read_dir(&dir)
            .unwrap()
            .flatten()
            .map(|e| e.file_name().to_string_lossy().into_owned())
            .collect();
        left.sort();
        // 9 kept, so the run about to be created makes 10.
        assert_eq!(left.len(), 9, "{left:?}");
        assert_eq!(
            left[0], "vpx-0006-t.log",
            "the oldest must be the ones dropped"
        );
        let _ = std::fs::remove_dir_all(&dir);
    }
}