-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathlib.rs
More file actions
7278 lines (6934 loc) · 354 KB
/
Copy pathlib.rs
File metadata and controls
7278 lines (6934 loc) · 354 KB
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
//! cinder-ffi — C-ABI surface over the Rust Cinder UI, for the C++ easel shell
//! (`cinder-home`). One glibc process: the C++ shell does the appmgr/easel lifecycle
//! and the Sony IPC, then calls these `extern "C"` entry points to paint the panel.
//!
//! Frame model: the C++ pump calls `cinder_render_tick()` once per frame; the shell
//! pushes state via the setters. All state lives behind a Mutex; panics abort (the
//! workspace profile sets panic="abort"), so nothing unwinds across the FFI boundary.
// These are `#[no_mangle] extern "C"` entry points called from C++, so they legitimately take
// raw `*const c_char` args; every deref goes through `cstr()` which null-checks first. The lint
// (which assumes safe-Rust callers) is a false positive for this FFI surface.
#![allow(clippy::not_unsafe_ptr_arg_deref)]
mod art_cache;
mod art_load;
mod gpu;
mod likes;
mod lyrics;
mod playlists;
mod present;
mod scrobble;
mod spectrum;
use cinder_ui::now_playing::NowPlaying;
use cinder_ui::{Canvas, FontSet, H, W};
use std::ffi::c_char;
use std::ffi::CStr;
use std::fs::{File, OpenOptions};
use std::os::unix::io::AsRawFd;
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
// pub(crate): gpu.rs pokes the panel with the same ioctls after eglSwapBuffers (see gpu::PanelPoke).
// Shared rather than re-declared there — a second copy of these numbers and of VarInfo's layout is
// exactly the kind of duplicate that drifts.
pub(crate) const FBIOGET_VSCREENINFO: libc::Ioctl = 0x4600;
pub(crate) const FBIOPUT_VSCREENINFO: libc::Ioctl = 0x4601;
const FBIOGET_FSCREENINFO: libc::Ioctl = 0x4602;
/// fb_var_screeninfo.activate flag: force the driver to (re)apply the mode NOW. On mtkfb this is
/// what actually pushes the framebuffer to the panel — writing pixels into the mmap does NOTHING
/// on its own. icx_bootanimation's per-frame "flip" (disasm @0x1fae) is exactly
/// `var.activate |= 0x80; ioctl(fd, FBIOPUT_VSCREENINFO, &var)`; without it the glass keeps showing
/// whatever was pushed last, forever (the "frozen boot image" failure mode).
pub(crate) const FB_ACTIVATE_FORCE: u32 = 0x80;
#[repr(C)]
#[derive(Default, Clone, Copy)]
pub(crate) struct Bitfield {
offset: u32,
length: u32,
msb_right: u32,
}
#[repr(C)]
#[derive(Default, Clone, Copy)]
pub(crate) struct VarInfo {
xres: u32,
yres: u32,
xres_virtual: u32,
yres_virtual: u32,
xoffset: u32,
yoffset: u32,
bits_per_pixel: u32,
grayscale: u32,
red: Bitfield,
green: Bitfield,
blue: Bitfield,
transp: Bitfield,
nonstd: u32,
activate: u32,
height: u32,
width: u32,
accel_flags: u32,
pixclock: u32,
left_margin: u32,
right_margin: u32,
upper_margin: u32,
lower_margin: u32,
hsync_len: u32,
vsync_len: u32,
sync: u32,
vmode: u32,
rotate: u32,
colorspace: u32,
reserved: [u32; 4],
}
#[repr(C)]
struct FixInfo {
id: [u8; 16],
smem_start: libc::c_ulong,
smem_len: u32,
type_: u32,
type_aux: u32,
visual: u32,
xpanstep: u16,
ypanstep: u16,
ywrapstep: u16,
line_length: u32,
mmio_start: libc::c_ulong,
mmio_len: u32,
accel: u32,
capabilities: u16,
reserved: [u16; 2],
}
impl Default for FixInfo {
fn default() -> Self {
unsafe { std::mem::zeroed() }
}
}
/// /dev/graphics/fb0 mapping. `base` is held as usize so the struct is Send (we only
/// ever touch it under the global Mutex).
struct Framebuffer {
_file: File,
fd: libc::c_int,
var: VarInfo, // kept for the per-blit flip ioctl (offsets pinned to 0)
base: usize,
stride: usize,
pages: usize,
map_len: usize,
/// Write every mapped page instead of just the displayed one (escape hatch — see `blit`).
all_pages: bool,
/// What we last wrote to page 0. A blit compares against this and writes only the rows that
/// actually changed — see `blit` for why that is worth 1.5 MB of RAM.
shadow: Vec<u32>,
/// When the last unconditional full write happened (the insurance below).
last_full: std::time::Instant,
/// When the mapping was opened, so the early window can distrust the shadow entirely.
opened: std::time::Instant,
/// Cleared to force the next blit to write every row regardless of the shadow.
shadow_valid: bool,
/// One-shot efficiency sample, so the win is a measured number in the log rather than a claim.
stat_frames: u32,
stat_rows: u64,
stat_done: bool,
}
/// How often to write every row whether it changed or not. Insurance, not correctness: nothing
/// else is known to write fb0 during a session, but the cost of being wrong about that is a
/// permanently stale region of screen, and the cost of the insurance is one full blit a minute.
const FULL_BLIT_EVERY_S: u64 = 60;
/// How long after opening fb0 to assume something else may also be drawing into it. Covers
/// icx_bootanimation, which cinder-home kills repeatedly over roughly the first five seconds.
const UNCONTESTED_AFTER_S: u64 = 15;
impl Framebuffer {
fn open() -> Result<Self, String> {
let file = OpenOptions::new()
.read(true)
.write(true)
.open("/dev/graphics/fb0")
.map_err(|e| format!("open fb0: {e}"))?;
let fd = file.as_raw_fd();
let mut var = VarInfo::default();
let mut fix = FixInfo::default();
unsafe {
libc::ioctl(fd, FBIOGET_VSCREENINFO, &mut var as *mut _);
// Same init sequence as icx_bootanimation: pin the visible window to page 0 and force
// one mode (re)apply, THEN read the fixed info. This both claims the display for us and
// guarantees the stride we compute below matches the applied mode.
var.xoffset = 0;
var.yoffset = 0;
var.activate |= FB_ACTIVATE_FORCE;
libc::ioctl(fd, FBIOPUT_VSCREENINFO, &mut var as *mut _);
libc::ioctl(fd, FBIOGET_FSCREENINFO, &mut fix as *mut _);
}
let stride = fix.line_length as usize;
if stride == 0 {
return Err("fb stride 0".into());
}
let map_len = stride * var.yres_virtual as usize;
let ptr = unsafe {
libc::mmap(
std::ptr::null_mut(),
map_len,
libc::PROT_READ | libc::PROT_WRITE,
libc::MAP_SHARED,
fd,
0,
)
};
if ptr == libc::MAP_FAILED {
return Err("mmap fb0 failed".into());
}
let pages = (var.yres_virtual / var.yres.max(1)).max(1) as usize;
// CLEAR EVERY PAGE BEFORE THE FIRST PAINT.
//
// Nothing owns the framebuffer's contents across a reboot: mtkfb hands back the same
// memory, still holding whatever the last session and the boot animation drew into it.
// Cinder then paints ONE page (all three only when /contents/cinder_fb_allpages exists),
// so any page it does not touch keeps the old image — and when the panel scans that page
// out you get a frozen, stale UI sitting behind the live one. Reported repeatedly, most
// recently 2026-08-26 ("the shadow ui is still there frozen in the background", and the
// Sony boot screen over an already-drawn Cinder UI); it survives reboots, which is the
// tell that it is the buffer and not the drawing.
//
// One memset of the whole mapping at init costs a few milliseconds once and makes the
// starting state defined regardless of what was there before.
unsafe { std::ptr::write_bytes(ptr as *mut u8, 0, map_len) };
let all_pages = std::path::Path::new("/contents/cinder_fb_allpages").exists();
println!(
"cinder-ffi: fb {}x{} {}bpp stride {} pages {} (writing {}, ALL pages) — flip-on-blit active (FBIOPUT+FORCE)",
var.xres,
var.yres,
var.bits_per_pixel,
stride,
pages,
if all_pages { "every row" } else { "changed rows only" }
);
// The shadow starts as zeroes and so does the mapping (the clear above), so the very first
// blit can already trust it — no special first-frame case, and no 1.5 MB write of pixels
// that are already black.
Ok(Framebuffer {
_file: file,
fd,
var,
base: ptr as usize,
stride,
pages,
map_len,
all_pages,
shadow: vec![0u32; W * H],
last_full: std::time::Instant::now(),
opened: std::time::Instant::now(),
shadow_valid: true,
stat_frames: 0,
stat_rows: 0,
stat_done: false,
})
}
/// Blit one canvas to every page (the panel is triple-buffered).
///
/// Bullet-proofing: we NEVER write past the mapped region. On the confirmed panel
/// (480x800, virtual 2400 = 3x800) every row fits exactly, but if a unit/firmware ever reports
/// a geometry where `pages*H` overruns `yres_virtual` (e.g. yres_virtual not a multiple of H, a
/// rotated panel, or H > yres), an unchecked `(page*H+y)*stride` would write off the end of the
/// mmap → SIGSEGV/corruption. So each row is bounded against `map_len`; an out-of-range row is
/// skipped rather than written. Worst case is a cosmetically clipped frame, never a crash.
fn blit(&mut self, buf: &[u32]) {
let base = self.base as *mut u8;
let copy_bytes = (W * 4).min(self.stride);
// PAGES AND ROWS ARE INDEPENDENT QUESTIONS, and conflating them cost a regression.
// The first version of this partial blit wrote CHANGED ROWS to PAGE 0 ONLY, on the reading
// that the panel never pans (`fb0/pan` reads `0,0`). It does present another page around
// boot: dropping to page 0 put the boot animation back on top of the Cinder UI within one
// boot, reported from the device 2026-09-04. `fb0/pan` is not evidence.
//
// So: every page, always — and only the rows that actually differ. That keeps all three
// pages current no matter which one the panel scans, while still moving ~1% of the bytes.
// The saving was never about skipping pages; it was about skipping unchanged rows.
//
// WHY THE COMPARISON IS WORTH IT. The canvas and the shadow are ordinary cached RAM; the
// framebuffer is a device mapping where the WRITE is the expensive side. Trading two cached
// reads for avoided device-memory writes is a good deal, and it gets better the more of the
// screen is static. It also lets us SKIP THE FLIP entirely when nothing differs — the
// FBIOPUT ioctl that the driver sometimes blocks >33 ms in — which is what makes a static
// screen genuinely free rather than merely cheap.
//
// THIS CANNOT PRODUCE AN ARTEFACT. It is a pure optimisation of the transfer: the bytes
// that end up in every page are exactly the bytes a full blit would have put there. That is
// the difference between this and dirty-rect RASTERISATION, where a missed region means a
// wrong pixel.
//
// THE SHADOW ASSUMES WE ARE THE ONLY WRITER, AND EARLY IN A BOOT WE ARE NOT.
// icx_bootanimation draws into the same fb0 for the first seconds; a partial blit will not
// paint over it, because the shadow says those rows are already correct. Hence the opening
// window below, during which the shadow is distrusted entirely.
// `/contents/cinder_fb_allpages` remains the escape hatch: it forces every row, every time.
let force_full = self.all_pages
|| !self.shadow_valid
|| self.opened.elapsed().as_secs() < UNCONTESTED_AFTER_S
|| self.last_full.elapsed().as_secs() >= FULL_BLIT_EVERY_S;
// Past the point where most of the screen is changing, comparing is pure overhead — a
// scroll or a screen transition dirties nearly every row. So the comparison switches itself
// off once more than half the rows have differed and the rest are copied blind, which
// bounds the worst case at half a compare on top of the write it was always doing.
let mut compare = !force_full;
let mut wrote = 0usize;
for y in 0..H {
if (y + 1) * W > buf.len() {
break;
}
let row = y * W..(y + 1) * W;
if compare {
if self.shadow[row.clone()] == buf[row.clone()] {
continue;
}
if wrote * 2 > H {
compare = false;
}
}
self.shadow[row.clone()].copy_from_slice(&buf[row.clone()]);
// Bullet-proofing: we NEVER write past the mapped region. On the confirmed panel every
// row fits exactly, but if a unit ever reports a geometry where `pages*H` overruns
// `yres_virtual`, an unchecked offset would write off the end of the mmap. An
// out-of-range row is skipped rather than written — worst case a clipped frame.
for page in 0..self.pages {
let dst_row = (page * H + y) * self.stride;
if dst_row + copy_bytes > self.map_len {
break;
}
unsafe {
std::ptr::copy_nonoverlapping(
buf.as_ptr().add(y * W) as *const u8,
base.add(dst_row),
copy_bytes,
);
}
}
wrote += 1;
}
if force_full {
self.shadow_valid = true;
self.last_full = std::time::Instant::now();
}
// Report the actual ratio once, then never again. Only frames that took the partial path
// are sampled: the opening window forces full writes on purpose, and counting those
// reported 70.3% — a true number about the wrong thing.
if !self.stat_done && !force_full {
self.stat_frames += 1;
self.stat_rows += wrote as u64;
if self.stat_frames >= 300 {
self.stat_done = true;
println!(
"cinder-ffi: partial blit — {} rows over {} frames = {:.1}% of a full blit (all {} pages)",
self.stat_rows,
self.stat_frames,
100.0 * self.stat_rows as f64 / (self.stat_frames as f64 * H as f64),
self.pages
);
}
}
// Nothing reached the panel, so there is nothing to push to it.
if wrote == 0 {
return;
}
self.flip();
}
/// Push the frame to the glass. mtkfb does NOT scan the framebuffer continuously — the panel
/// only updates on this trigger ioctl (icx_bootanimation's flip, replicated exactly).
/// Occasionally the driver blocks >33 ms here (the anim logs it as "heavy ioctl") — harmless
/// at our frame rate, and skipped entirely now when no row changed.
fn flip(&mut self) {
self.var.xoffset = 0;
self.var.yoffset = 0;
self.var.activate |= FB_ACTIVATE_FORCE;
let rc = unsafe { libc::ioctl(self.fd, FBIOPUT_VSCREENINFO, &mut self.var as *mut _) };
if rc != 0 {
// One-time diagnostic: a failing flip means an invisible UI, which is otherwise
// indistinguishable from the old frozen-boot-image symptom on device.
static FLIP_ERR: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
if !FLIP_ERR.swap(true, std::sync::atomic::Ordering::Relaxed) {
eprintln!(
"cinder-ffi: fb flip ioctl FAILED (errno {}) — UI will not reach the panel",
std::io::Error::last_os_error().raw_os_error().unwrap_or(0)
);
}
}
}
}
impl Drop for Framebuffer {
fn drop(&mut self) {
unsafe {
libc::munmap(self.base as *mut libc::c_void, self.map_len);
}
}
}
/// Frame presentation backend. `Gl` is the GPU path (EGL + GLES2 on the device's Mali fbdev
/// driver — see `gpu.rs`); `Fb` is the original software path (mmap the framebuffer, memcpy each
/// page, force a mode re-apply). `cinder_render_init` prefers `Gl` and falls back to `Fb` if the
/// GPU won't initialise, so the panel always gets pixels.
enum Presenter {
Gl(gpu::GlPresenter),
Fb(Framebuffer),
}
impl present::PresentTarget for Presenter {
fn present(&mut self, buf: &[u32]) {
match self {
Presenter::Gl(g) => g.present(buf),
Presenter::Fb(f) => f.blit(buf),
}
}
}
/// Frames whose presentation has COMPLETED (blit + flip ioctl returned / swap + poke returned) —
/// i.e. pixels were pushed toward the glass, not merely queued. The shell reads this via
/// `cinder_frames_presented` to gate its "first frame painted" bad-boot health signal; with the
/// present running on its own thread, "cinder_render_tick returned" no longer implies that.
pub(crate) static FRAMES_PRESENTED: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(0);
/// How frames leave the render thread: inline (the original serial path, kept as the flagged
/// escape because it is strictly less machinery) or through the present thread (see present.rs).
enum Sink {
Sync(Presenter),
Threaded(present::PresentThread),
}
impl Sink {
fn present(&mut self, canvas: &mut Canvas) {
use present::PresentTarget;
match self {
Sink::Sync(p) => {
p.present(&canvas.buf);
FRAMES_PRESENTED.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
}
Sink::Threaded(t) => t.submit(&mut canvas.buf),
}
}
/// Block until `target` frames have completed (no-op on the sync path, where completion is
/// implied by `present` returning). Bench uses this to time the true present cost.
fn wait_presented(&self, target: u64) {
if let Sink::Threaded(t) = self {
t.wait_presented(target);
}
}
}
/// Owned now-playing state (the FFI setters fill this; render borrows from it).
#[derive(Default)]
struct Np {
title: String,
artist: String,
codec: String,
badge: String,
clock: String,
elapsed: String,
remaining: String,
art: String,
battery: u8,
progress: f32,
liked: bool,
playing: bool,
shuffle: bool,
repeat: u8,
}
struct Render {
/// Which album's cover is currently handed to the UI, so the 96x96 is loaded on change only.
album_cover_id: Option<i64>,
present: Sink,
/// The frame buffer, allocated ONCE and reused every frame. Re-allocating it per frame
/// is 1.5 MB of churn that fragmented the heap until an allocation failed outright on
/// device (SIGABRT, 2026-07-26).
canvas: Canvas,
fonts: FontSet,
night: bool,
np: Np,
db: Option<cinder_db::Db>,
app: cinder_ui::nav::App,
scrob: Option<scrobble::Scrobbler>,
last_track: Option<cinder_db::Track>, // last resolved track (for scrobble metadata)
/// Tracks that Cinder, rather than PlayerService, has already played. PlayerService loses
/// its own previous-track state whenever a queue edit replaces its sequence.
play_history: Vec<cinder_db::Track>,
/// Do not add this outgoing track when a Cinder-managed rewind starts it again.
rewind_from: Option<i64>,
// Now-playing position. Two sources, in priority order:
// 1. REAL position from PlayerService's PlayEventListener::onPlayTimeUpdated, pushed in via
// cinder_set_play_position. It arrives about once a second, so `real_pos_at` records when,
// and clock_tick interpolates forward from it — drift-free, and it follows seeks and
// mid-track starts, which the estimate below cannot.
// 2. The local play-clock ESTIMATE (duration from the DB, advance by wall-clock delta while
// playing). Used only until the first real update arrives, or if the listener goes quiet.
play_pos_ms: i64,
cur_duration_ms: i64,
last_pos: std::time::Instant, // wall-clock anchor for the position estimate (rate-independent)
real_pos_ms: i64, // last position from the service; -1 = none seen yet
real_pos_at: std::time::Instant, // when it arrived (interpolation anchor)
// Drag-to-seek: Some(target_ms) while a finger is dragging the progress rail. While set, the
// bar/labels show this pending target and incoming position updates are ignored, so the bar
// does not fight the finger. The shell issues the actual SeekTime on release.
/// Direction of the seek the UI last asked for (-1 / +1) and whether FM was switched on.
/// Parked here because an action code is one int and these ride alongside it.
fm_seek_dir: i32,
fm_power: bool,
fm_bt: bool,
scrub_ms: Option<i64>,
/// Action produced by a settings-slider drag, waiting for the shell to collect it.
scrub_act: Option<libc::c_int>,
// Screenshot request: Some(path) => the next rendered frame is also written to `path` as a PNG.
// Captured from the Canvas BEFORE presentation, so it is identical on the software framebuffer
// and the GPU/EGL path (under EGL the Mali swapchain owns the panel, so reading /dev/graphics/fb0
// from outside does NOT reliably show what's on screen — this is the only faithful capture).
pending_screenshot: Option<String>,
// Sleep timer: counts DOWN in wall-clock ms (regardless of play/pause); 0 = inactive. When it
// reaches 0 we raise sleep_fire, which the shell polls (cinder_sleep_should_pause) to pause.
sleep_remaining_ms: i64,
/// Deadline for the Settings ▸ Database "Rescanning…" label, counted down with the same dt as
/// the sleep timer. See the RescanLibrary arm for why the label needs a deadline at all.
rescan_left_ms: i64,
sleep_fire: bool,
// Persisted UI preferences (theme night + visualiser type/on) so choices survive a reboot. The
// shell points us at a file via cinder_settings_load; we re-save (best-effort) whenever one of
// them changes. last_saved is the fingerprint we last wrote, to avoid redundant writes.
settings_path: Option<String>,
last_saved_body: String, // the file body we last wrote (compare to skip redundant writes)
/// The palette folder (`cinder_palettes/`, next to the settings file). Known once
/// cinder_settings_load has run; read then, and again whenever Settings opens.
palette_dir: Option<std::path::PathBuf>,
// ── Resume across a reboot ─────────────────────────────────────────────────────────────
// Two files, not one, because the two halves change at completely different rates. The
// SEQUENCE (context + queue + un-shuffle order) is tens of kilobytes and changes when the
// user starts something or a track boundary passes; the POSITION is 30 bytes and moves every
// second. Putting them together would mean rewriting ~25 KB of flash once a second for the
// sake of a number, which is the kind of write amplification that wears an eMMC out.
//
// Neither lives in /contents: that is the USB-MSC volume, it disappears from under us while
// the PC holds it, and a machine-written queue file has nothing a user would want to edit.
resume_path: Option<String>, // sequence file; written only when the body changes
resume_last_body: String,
resume_pos_path: Option<String>, // position file; written at most every RESUME_POS_EVERY
resume_pos_last: String,
resume_pos_at: std::time::Instant,
/// A restored sequence that PlayerService has NOT been told about. Cinder does not hand it
/// over at boot: `cinder_audio_play_tracks` starts playback, and a player that begins playing
/// on its own the moment it powers up is a worse bug than the one being fixed. It is handed
/// over on the first ▶ instead, which is also when the ~400 ms SetTrackSequence is expected.
resume_pending: Option<(Vec<String>, usize, i64)>, // (uris, start index, position ms)
// Dirty-flag rendering (battery, goal #1): the pump ticks ~30-60x/s, but re-rendering +
// blitting the whole framebuffer (~4.6 MB copy) when nothing changed is pure waste. We only
// repaint when `dirty` is set — by input, a now-playing/theme change, or an active overlay
// animation. Idle = near-zero CPU.
dirty: bool,
// Visualiser animation phase (advanced only while playing AND Now Playing is showing AND the
// nav's viz is enabled — bounds the repaint cost). The viz TYPE + on/off live in nav (UI state,
// settable from the Settings screen); cinder-ffi only owns the animation timing.
viz_phase: f32,
last_viz: std::time::Instant, // throttle the visualiser repaint to ~20fps (battery)
viz_levels: Vec<f32>, // real spectrum bars (0..1) from the last set_pcm/set_spectrum; empty = synthetic
viz_peak: f32, // slow-decaying auto-gain peak for Scale::Dynamic
// Peak-hold markers and how long each has been sitting where it is. Only populated while the
// user has the markers switched on; `hold_peaks` clears both when they are off, so the render
// asks one `is_empty()` rather than carrying a second setting down to the draw call.
viz_peaks: Vec<f32>,
viz_held_ms: Vec<f32>,
// When the last spectrum frame arrived. Sony's analyzer streams at ~20 Hz WHILE IT RUNS, and it
// now runs on demand — so it stops on every screen blank, pause and (possibly) track change,
// and starts again up to a second later (housekeeping is 1 Hz) plus service latency. Without a
// staleness check the last frame simply STAYS on screen: a held snapshot of a drum hit, which
// is exactly as untrue as the synthetic animation it replaced, and would be visible on every
// single screen wake. Frames older than VIZ_FRESH_MS decay to nothing and are then dropped.
viz_at: std::time::Instant,
/// The user queue was edited and PlayerService has not been told yet. Flushed at a track
/// boundary — see `Action::QueueChanged` for why it cannot be flushed immediately.
queue_pending: bool,
/// A queue edit landed while a boot-time resume was still armed, so the sequence snapshot
/// `cinder_resume_load` took no longer describes what the user wants. Rebuilt at the first ▶
/// rather than at the edit, because rebuilding costs a library query and an edit made before
/// the first press is exactly the case where nothing is audible to be late for.
resume_stale: bool,
/// A queue flush is sitting in `pending_play` waiting for the shell to collect it. Reported
/// through `cinder_take_queue_flush` so the shell knows to hand it to PlayerService.
queue_flush: bool,
// Pending play request (Action::PlayIndex resolved through the DB): the chosen track's album
// context — file URIs in play order + the start index. The shell drains it via
// cinder_pending_play_* after a CINDER_ACT_PLAY_INDEX action and hands it to PlayerService
// (NodeTrackSequence). Replaced wholesale on every new PlayIndex.
pending_play: Vec<String>,
/// Row index carried by the last CINDER_ACT_BT_CONNECT_DEVICE / _BT_FORGET_DEVICE action. The
/// shell drains it with `cinder_pending_bt_device()`; -1 there means "no request", so a stale
/// index can never be mistaken for row 0.
pending_bt_device: Option<usize>,
// ── Liked songs ────────────────────────────────────────────────────────────────────────
// Track object_ids the user has hearted. Kept as a set so the Now Playing heart is an O(log n)
// lookup per track change, and persisted to its own file rather than the settings blob — it
// grows with the library, and losing every preference because one liked-list line is corrupt
// would be a bad trade. `liked_path` is None until cinder_db_open supplies it.
duration_checked: bool, // have we compared the DB duration against the service's yet?
last_tick: std::time::Instant, // real-time anchor for fling/HUD animation
/// Monotonic anchor for animations that need an ABSOLUTE phase rather than a delta — currently
/// the title marquee, whose position is a function of elapsed time, not of accumulated frames.
/// Deriving it from `last_tick` would tie the animation to how often the screen happened to be
/// dirty, which is exactly what it must not depend on.
boot: std::time::Instant,
last_scrob: std::time::Instant, // real-time anchor for the scrobble play clock
liked: std::collections::BTreeSet<i64>,
liked_path: Option<String>,
/// Playlists the user made ON the device, as .m3u8 files (see `playlists.rs`). Separate from
/// the Sony ones below because they are the only ones this app may write.
plists: playlists::Store,
/// Sony's playlist rows, kept from the last library build so a playlist edit can rebuild the
/// merged list without re-querying the database — one edit is a keypress away from the next,
/// and the DB half of the list cannot have changed in between.
db_playlists: Vec<cinder_ui::model::PlaylistRow>,
pending_play_start: usize,
// Decoded album cover for the CURRENT track, pre-scaled to the two draw sizes (480 full-bleed,
// 92 thumb). art_key = the object_id we last decoded for (skip re-decode on same-track polls);
// None images = no art found → the UI draws its gradient fallback.
art_full: Option<cinder_ui::art::Image>,
art_thumb: Option<cinder_ui::art::Image>,
art_key: Option<i64>,
/// This database's `album_id` -> the art cache's stable filename key. Built once by
/// `start_art_cache`. Needed because the album drill-in reads the 96 px cover straight off
/// disk, and disk is addressed by cover source, not by a row number that changes every rescan.
art_cache_keys: std::collections::HashMap<i64, u64>,
/// Path the library DB was opened from, so the cover decoder can open its own read-only
/// handle instead of borrowing this one across a thread (same reasoning as start_art_cache).
db_path: Option<String>,
}
fn now_unix() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
/// Current wall-clock time as "HH:MM" in LOCAL time (libc localtime_r respects the device TZ).
/// Empty on failure. (Y2038 caveat: on glibc-2.23 32-bit time_t this breaks in 2038 — the
/// device-wide issue, see project goals.)
fn current_hhmm() -> String {
unsafe {
let mut t: libc::time_t = 0;
libc::time(&mut t);
let mut tm: libc::tm = std::mem::zeroed();
if libc::localtime_r(&t, &mut tm).is_null() {
return String::new();
}
format!("{:02}:{:02}", tm.tm_hour, tm.tm_min)
}
}
static R: OnceLock<Mutex<Option<Render>>> = OnceLock::new();
/// Maximum EQ band gain, in the DSP's half-dB units (±20 = ±10 dB). Mirrors
/// `cinder_ui::eq::BAND_MAX`, which is what the EQ screen clamps to; kept here because the
/// settings loader has to clamp values that never went through the screen at all.
const EQ_BAND_MAX: i8 = 20;
fn cell() -> &'static Mutex<Option<Render>> {
R.get_or_init(|| Mutex::new(None))
}
/// Read a C string into an owned String (empty on null/invalid).
unsafe fn cstr(p: *const c_char) -> String {
if p.is_null() {
String::new()
} else {
CStr::from_ptr(p).to_string_lossy().into_owned()
}
}
/// Open the requested presenter, falling back from GPU to the software framebuffer. Runs on the
/// present thread in the default configuration (EGL thread affinity), inline under
/// /contents/cinder_nothread. Always names the live path in cinderhome.log: "the app ran but the
/// screen was stuck" is otherwise indistinguishable between these branches from the log alone.
fn open_presenter(want_gpu: bool) -> Result<Presenter, String> {
if want_gpu {
match gpu::GlPresenter::open(W as i32, H as i32) {
Ok(g) => {
println!("cinder-ffi: GPU present path active (EGL/GLES2 on Mali)");
return Ok(Presenter::Gl(g));
}
Err(e) => {
eprintln!("cinder-ffi: GPU init failed ({e}); falling back to software framebuffer")
}
}
} else {
println!("cinder-ffi: software framebuffer present path (GPU opt-in flag absent)");
}
Framebuffer::open().map(Presenter::Fb)
}
/// Open the framebuffer and initialise the renderer. Returns 0 on success, <0 on error.
#[no_mangle]
pub extern "C" fn cinder_render_init() -> libc::c_int {
// First thing, before anything can panic: a hook that says WHERE in the UI it happened.
// Idempotent in practice — render_init runs once per process.
install_panic_hook();
// GPU present path (EGL/GLES2 on Mali) is OPT-IN. It was briefly made the default on
// 2026-07-26 and that flip is what wedged the two flashes that evening: the app booted
// perfectly (deferred_up: DONE, "healthy: bad-boot counter cleared", no crash in the log)
// while the panel still showed the boot animation. eglSwapBuffers returns success on this
// fbdev build whether or not the compositor ever scans the buffer out, so a GPU present that
// reaches no pixels is INVISIBLE to us — and worse, invisible to the bad-boot counter, which
// this process clears on "a frame was rendered". Frozen glass therefore also disabled rung 1
// of the escape ladder; only the cable escape (rung 0) got the device back.
// The software framebuffer does not have that hole: Framebuffer::blit ends in an explicit
// FBIOPUT_VSCREENINFO(FB_ACTIVATE_FORCE), which is the only thing that makes mtkfb push pixels
// to the panel, and it reports failure.
// So the proven path is the default and the unproven one costs a deliberate flag file. The
// flag lives on /contents, which is reachable over USB-MSC from a stock boot — deleting it
// needs strictly less than the app it rescues, per the escape-ladder rule.
// Enable: /contents/cinder_gpu_on (or CINDER_GPU=1)
// Disable: delete that file (/contents/cinder_gpu_off and CINDER_GPU=0 also win)
let force_off = std::path::Path::new("/contents/cinder_gpu_off").exists()
|| std::env::var("CINDER_GPU").map(|v| v == "0").unwrap_or(false);
let opt_in = std::path::Path::new("/contents/cinder_gpu_on").exists()
|| std::env::var("CINDER_GPU").map(|v| v == "1").unwrap_or(false);
let want_gpu = opt_in && !force_off;
// The present runs on its own thread by default (raster and present overlap — see present.rs,
// incl. why the watchdog contract survives the move). /contents/cinder_nothread or
// CINDER_NOTHREAD=1 keeps the original in-line present: the escape depends on strictly less.
let no_thread = std::path::Path::new("/contents/cinder_nothread").exists()
|| std::env::var("CINDER_NOTHREAD").map(|v| v == "1").unwrap_or(false);
let present = if no_thread {
println!("cinder-ffi: synchronous present (present thread disabled by flag)");
match open_presenter(want_gpu) {
Ok(p) => Sink::Sync(p),
Err(e) => {
eprintln!("cinder-ffi: {e}");
return -1;
}
}
} else {
// The presenter is constructed ON the present thread (EGL contexts are thread-affine).
match present::PresentThread::start(move || open_presenter(want_gpu)) {
Ok(t) => {
println!("cinder-ffi: present thread active (raster overlaps present)");
Sink::Threaded(t)
}
Err(e) => {
eprintln!("cinder-ffi: {e}");
return -1;
}
}
};
let mut np = Np::default();
np.codec = "—".into();
np.battery = 100;
*cell().lock().unwrap() = Some(Render {
album_cover_id: None,
present,
canvas: Canvas::new(),
fonts: FontSet::load(),
night: false,
np,
db: None,
app: {
// MIX / the shuffle toggle permute inside cinder-ui, which has no clock (its 300-odd
// host tests depend on it having none). Hand it a per-session seed here, or the same
// album shuffles into the same order after every boot.
let mut a = cinder_ui::nav::App::unlocked();
a.seed_shuffle(Rng::new().next());
a
},
scrob: None,
last_track: None,
play_history: Vec::new(),
rewind_from: None,
play_pos_ms: 0,
cur_duration_ms: 0,
last_pos: std::time::Instant::now(),
real_pos_ms: -1,
real_pos_at: std::time::Instant::now(),
fm_seek_dir: 1,
fm_power: false,
fm_bt: false,
scrub_ms: None,
scrub_act: None,
pending_screenshot: None,
sleep_remaining_ms: 0,
rescan_left_ms: 0,
sleep_fire: false,
settings_path: None,
last_saved_body: String::new(),
palette_dir: None,
resume_path: None,
resume_last_body: String::new(),
resume_pos_path: None,
resume_pos_last: String::new(),
resume_pos_at: std::time::Instant::now(),
resume_pending: None,
dirty: true, // paint the first frame
viz_phase: 2.0,
last_viz: std::time::Instant::now(),
viz_levels: Vec::new(),
viz_peak: 0.0,
viz_peaks: Vec::new(),
viz_held_ms: Vec::new(),
viz_at: std::time::Instant::now(),
queue_pending: false,
resume_stale: false,
queue_flush: false,
pending_play: Vec::new(),
pending_bt_device: None,
duration_checked: false,
last_tick: std::time::Instant::now(),
boot: std::time::Instant::now(),
last_scrob: std::time::Instant::now(),
liked: std::collections::BTreeSet::new(),
liked_path: None,
plists: playlists::Store::default(),
db_playlists: Vec::new(),
pending_play_start: 0,
art_full: None,
art_thumb: None,
art_cache_keys: std::collections::HashMap::new(),
art_key: None,
db_path: None,
});
0
}
/// Format milliseconds as `M:SS` (or `H:MM:SS`). `duration_raw` units are assumed ms —
/// calibrate on device (see cinder-db notes); only this one place needs changing if not ms.
fn fmt_time(ms: i64) -> String {
let total = ms.max(0) / 1000;
let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60);
if h > 0 {
format!("{h}:{m:02}:{s:02}")
} else {
format!("{m}:{s:02}")
}
}
/// Derive (codec line, status-bar badge) from the file extension + hi-res flag.
/// Bit-depth/sample-rate aren't in cinder-db yet (they're extra MediaStore ext props) —
/// extend here once those props are read; until then we show the container + a Hi-Res mark.
fn codec_label(filename: &str, is_hires: bool) -> (String, String) {
let ext = filename.rsplit('.').next().unwrap_or("").to_ascii_uppercase();
let ext = if ext.is_empty() || ext.len() > 4 { "PCM".to_string() } else { ext };
if is_hires {
(format!("{ext} · Hi-Res"), format!("{ext} HR"))
} else {
(ext.clone(), ext)
}
}
/// Fill the now-playing string fields from a resolved library Track + playback progress.
// Track metadata only (title/artist/codec/badge). Position is set separately by set_progress so the
// local play-clock can advance it each second.
fn apply_track(np: &mut Np, t: &cinder_db::Track) {
np.title = if t.title.is_empty() {
t.filename.rsplit('/').next().unwrap_or("").to_string()
} else {
t.title.clone()
};
np.artist = t.artist.clone();
let (codec, badge) = codec_label(&t.filename, t.is_hires);
np.codec = codec;
np.badge = badge;
}
// Serialise the persisted UI preferences (theme + visualiser + EQ + sound effects) to the file body.
/// Serialise the setup that is NOT live, so both halves of the A/B pair survive a reboot. The LIVE
/// one keeps using the existing `eq=` / `sound=` / `balance100=` keys, which means an older build
/// reading this file still finds exactly what it expects and simply ignores the spare.
fn setup_body(s: &cinder_ui::nav::SoundSetup) -> String {
let eq: Vec<String> = s.eq_bands.iter().map(|b| b.to_string()).collect();
let flags = (s.dsee as u8)
| (s.vinyl as u8) << 1
| (s.vpt as u8) << 2
| (s.dc as u8) << 3
| (s.norm as u8) << 4
| (s.clear as u8) << 5;
format!("bank_eq={}\nbank_sound={}\nbank_balance={}\nbank_preset={}\n",
eq.join(","), flags, s.balance, s.eq_preset)
}
fn settings_body(r: &Render) -> String {
let eq: Vec<String> = r.app.eq_bands().iter().map(|b| b.to_string()).collect();
let mut body = format!(
"night={}\naccent={}\nviz_kind={}\nviz_size={}\nnp_page={}\nshuffle={}\nrepeat={}\neq={}\nsound={}\nonboarding={}\nbt_codec={}\nbt_ldac_quality={}\nbt_enhanced={}\nbt_on={}\nvolume={}\nbt_volume127={}\nbrightness={}\nscreen_off={}\nauto_off={}\nbalance100={}\nvpt_mode={}\ndc_type={}\nadv={}\ndsee_mode={}\nvinyl_type={}\ntone={}\nui_scale={}\nsetup={}\n",
r.app.night as u8,
r.app.accent(),
r.app.viz_kind(),
r.app.viz_size(),
r.app.np_page(),
r.np.shuffle as u8,
r.np.repeat,
eq.join(","),
r.app.sound_flags(),
r.app.onboarding_seen() as u8,
r.app.bt_codec(),
r.app.bt_ldac_quality(),
r.app.bt_enhanced() as u8,
r.app.bt_on() as u8,
r.app.volume_level(),
r.app.bt_volume_level(),
r.app.brightness_restore(), // never 0: backlight-off is transient, not a setting
r.app.screen_off_s(),
r.app.auto_off_min(),
r.app.balance(),
r.app.vpt_mode(),
r.app.dc_type(),
r.app.adv_flags(),
r.app.dsee_mode(),
r.app.vinyl_type(),
r.app.tone_bands().iter().map(|b| b.to_string()).collect::<Vec<_>>().join(","),
r.app.ui_scale_pct(),
r.app.setup_idx(),
);
body.push_str(&setup_body(&r.app.setup_inactive()));
// The visualiser's signal settings. One line each rather than a packed field, because these
// are exactly the lines someone tuning the display over adb will want to edit by hand — and
// every one is an INDEX into a table owned by `cinder_ui::vizcfg`, so an out-of-range value
// from a hand-edited file is wrapped by the setter rather than accepted.
// The Bluetooth fine-volume SPAN, not the trim: the trim is a live attenuation the user cannot
// see in any menu, and restoring one at boot would be a device that plays quiet for reasons
// nothing on screen explains.
body.push_str(&format!("bt_fine={}\n", r.app.bt_fine_span()));
body.push_str(&format!("volume_limit={}\n", r.app.volume_limit() as u8));
body.push_str(&format!("ignore_the={}\n", r.app.ignore_the() as u8));
// The palette by id — the CHOICE, not what happens to be drawn. If the folder could not be read
// this boot, Cinder is on screen, but the palette the user picked is still the one to keep.
body.push_str(&format!("palette={}\n", r.app.palette_id()));
body.push_str(&format!(
"viz_scale={}\nviz_range={}\nviz_response={}\nviz_interp={}\nviz_peaks={}\nviz_window={}\nviz_rate={}\n",
r.app.viz_scale_idx(),
r.app.viz_range_idx(),
r.app.viz_response_idx(),
r.app.viz_interp_idx(),
r.app.viz_peak_hold() as u8,
r.app.viz_window_idx(),
r.app.viz_rate_idx(),
));
// FM: the dial position and the scanned station list. A scan is a DELIBERATE ten-second wait
// that the user watches happen, so losing it on a reboot is the same defect as losing a shelf
// pin — and losing the frequency drops the dial to a hardcoded 97.3 that is nobody's station.
// Written unconditionally (the dial always has a value) and the list only when a scan has run.
body.push_str(&format!("fm_khz={}\n", r.app.fm_khz()));
let st = r.app.fm_stations();
if !st.is_empty() {
body.push_str(&format!(
"fm_stations={}\n",
st.iter().map(|k| k.to_string()).collect::<Vec<_>>().join(",")
));
}
// Shelf pins were session-scoped, so every reboot silently wiped the user's bookmarks — the
// one thing a "pin this place" feature must not do. One line per occupied slot.
for i in 0..cinder_ui::shelf::SLOTS {
let enc = r.app.shelf_pin_encode(i);
if !enc.is_empty() {
body.push_str(&format!("pin{i}={enc}\n"));
}
}
body
}
// Write the preferences to the configured file IF they changed since the last write (cheap body
// compare → most presses don't write). Best-effort: IO errors (RO/full fs) are ignored.
fn save_settings(r: &mut Render) {
if r.settings_path.is_none() {
return;
}
let body = settings_body(r);
if body == r.last_saved_body {
return;
}
if let Some(path) = r.settings_path.clone() {
let _ = std::fs::write(&path, &body);
r.last_saved_body = body;
}
}
/// Read the player's palette folder into the navigator.
///
/// A folder that does not exist means "no palettes". Any OTHER failure — the volume handed to a
/// PC, a FAT error — keeps whatever was loaded before, so a transient read error cannot drop the
/// palette on screen back to Cinder. The outcome is logged only when it changes: this runs every
/// time Settings opens, and the same broken file must not print on every visit.
fn scan_palettes(r: &mut Render) {
let Some(dir) = r.palette_dir.clone() else { return };
let entries = match std::fs::read_dir(&dir) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
if r.app.set_palettes(Vec::new(), Vec::new()) {
r.dirty = true;
}
return;
}
Err(e) => {
eprintln!("cinder-ffi: palettes: cannot read {}: {e} — keeping the loaded set", dir.display());
return;
}
};
let mut files = Vec::new();
for ent in entries.flatten() {
let name = ent.file_name().to_string_lossy().into_owned();
if cinder_ui::palette::palette_stem(&name).is_none() {
continue;
}
let body = match ent.metadata() {
Ok(m) if m.len() > cinder_ui::palette::MAX_BYTES => {
Err(format!("{} bytes, far larger than a palette", m.len()))
}
_ => std::fs::read_to_string(ent.path()).map_err(|e| e.to_string()),
};
files.push((name, body));
}
let (list, skipped) = cinder_ui::palette::load_files(files);
let loaded: Vec<String> = list.iter().map(|p| p.id.clone()).collect();
if r.app.set_palettes(list, skipped.clone()) {
eprintln!("cinder-ffi: palettes: [{}] from {}", loaded.join(", "), dir.display());
for s in &skipped {
eprintln!("cinder-ffi: palette skipped: {s}");