Static RE (objdump/nm/c++filt) of the extracted Sony libs in
artifacts/rootfs_mnt/vendor/sony/lib/, to unblock the device-gated Cinder features. All of
these are non-virtual exported member functions callable by mangled symbol from a
clang/libc++ shim (same pattern as cinder-audio/src/player_shim.cpp), unless noted as a
callback interface. Object SIZES must be reserved like easel_abi.hpp (the 2026-06-25 sizing
bug) before new-ing any of these.
PlayerService::ConverPlayStatus(IPlayerService::PlayStatus const& src, pst::playservice::PlayStatus& dst)
@ libPlayerServiceClient.so:0x3fc8 copies the wire struct → the client PlayStatus our shim
reads. Disassembly gives the client pst::playservice::PlayStatus field offsets (dst):
| dst offset | from src | type | notes |
|---|---|---|---|
| +0x00 | src+0x00 | u32 | field A |
| +0x08 | src+0x04 | u32 | field B |
| +0x0c | src+0x08 | u32 | field C |
| +0x14 | src+0x0c | u32 | field D |
| +0x18 | src+0x10 | u32 | field E |
| +0x30 | src+0x14 | u32 | field F |
| +0x38 | src+0x18 | u32 | field G |
| +0x3c | src+0x1c | u32 | field H |
| +0x44..0x53 | src+0x20 | 4×u32 | block (vld1/vst1) |
| +0x54..0x63 | src+0x3c | 4×u32 | block |
| +0x64 | src+0x4c | u32 | |
| +0x68 | src+0x50 | u32 | |
| +0x6c | src+0x30 | std::string | URI — CONFIRMS the shim's uri @ +0x6c ✓ |
| +0x78 | src+0x54 | bool | flag (!=0 → 1) |
Semantics still need 1 short pass (which u32 is playstate / current-ms / total-ms). Two ways:
- Disassemble the SERVICE side (
libSoundServiceFw/the service that fillsIPlayerService::PlayStatus), or - Prefer the listener (next section): position/duration arrive as plain ints, no offset-guessing.
PlayController::Connect(PlayEventListener*) accepts a listener; the On* handlers are thin
forwarders to listener (this+0x30)->vtable[slot]:
| listener vtable slot | offset | signature (from the forwarder) | source fn |
|---|---|---|---|
| 0,1 | +0x0,+0x4 | ~dtor (D1/D0) |
— |
| 2 | +0x08 | onPlayStatusUpdated(uint state, PlayStatus const&) |
OnPlayStatusUpdated @0x3568 |
| 3 | +0x0c | onPlayTimeUpdated(int currentMs, int totalMs) |
OnPlayTimeUpdated @0x3618 |
| 4 | +0x10 | (unmapped — likely onError/onEndOfList) | |
| 5 | +0x14 | onNextTrack(change_track_mode_t) |
OnNextTrack @0x38c0 |
| 6 | +0x18 | onPrevTrack(change_track_mode_t) |
OnPrevTrack @0x3968 |
→ Implement a tiny PlayEventListener subclass (reserve its real size; provide the vtable), pass
it to Connect() instead of NULL. Then onPlayTimeUpdated gives position+duration directly
(progress bar), onPlayStatusUpdated/onNextTrack signal track changes (re-resolve now-playing).
Callbacks arrive on a binder thread → update shared state under the existing cinder-ffi mutex.
SetTrackSequence(shared_ptr<TrackSequence>) @0x3244 only ships TrackSequence+0x4 (an int
handle) over IPC; the service then pulls tracks by calling back into the TrackSequence
(PlayController_Alloc/AllocNext/OnNextTrack/OnPrevTrack family). So TrackSequence is a
callback object — but Sony ships a concrete one we can reuse:
pst::services::playerservice::util::NodeTrackSequence<UriInfo> in libPlayerServiceClientUtil.so:
- ctor
NodeTrackSequence(unique_ptr<Node<UriInfo>>, int startIndex, function<void(UpdateReason,int)>) - it already implements
Alloc/AllocNext/OnNextTrack/OnPrevTrack/CreateTracketc.
Build the Node<UriInfo> tree two ways:
- JSON (simplest):
NodeJsonUtil<UriInfo,UriInfoPolicy>::ConvJsonStringToNode(std::string)→unique_ptr<Node<UriInfo>>. Build a JSON playlist string (schema includes"children"; full schema = 1 shortConvNodeToJsondisasm pass, or an on-device round-trip viaConvNodeToJsonString). - C++ API:
Node<UriInfo>::Node(UriInfo, int id)+root->InsertChildAmongOriginalPosition(pos, unique_ptr<Node>, bool). NeedsUriInfo's layout (no exported ctor; small struct ≈{std::string uri; …}— get from theNode<UriInfo>::Node(UriInfo,int)disasm).
Recipe: build Node tree from the chosen track URI(s) → make_shared<NodeTrackSequence<UriInfo>>(move(node), startIdx, cb)
→ PlayController::SetTrackSequence(seq) → ChangePlayState(Play). Closes play-a-selected-track/album.
pst::services::sound::EffectCtrlDmp — default ctor EffectCtrlDmp() @0xdd40 (just construct it;
reserve its real size). Setters map 1:1 to the Cinder Sound + EQ screens:
| Cinder control | EffectCtrlDmp call |
|---|---|
| 10-band EQ (our EQ screen) | SetEq10Band(bool on), SetEq10BandValue(Eq10Band band, int gain) |
| 6-band EQ / presets | SetEq6Band, SetEq6BandPreset(Eq6BandPreset), SetEq6BandValue(Eq6Band,int) |
| DSEE HX | SetDseeHx(bool), SetDseeHxCustom(bool), SetDseeHxCustomMode(DseeHxCustomMode), SetDseeAi(bool) |
| VPT Surround | SetVpt(bool), SetVptMode(VptMode) |
| Dynamic Normalizer | SetDynamicNormalizer(bool) |
| DC Phase Linearizer | SetDcPhaseLinearizer(bool), SetDcPhaseFilterType(DcPhaseFilterType) |
| Vinyl Processor | SetVinylizer(bool), SetVinylizerType(uint) |
| ClearAudio+ | SetClearAudioPlus(bool) |
| Tone Control | SetToneControl(bool) |
| Effects → Bluetooth (goal #7!) | SetBtAudioSoundEffect(bool) |
| disable/re-enable all | DisableSoundEffects(), ReenableSoundEffects() |
→ Build cinder-audio/src/effect_shim.cpp wrapping EffectCtrlDmp behind a C ABI; wire the EQ
screen's Action::EqChanged → SetEq10BandValue, Sound toggles → the matching setters. Goal #7
("apply effects to Bluetooth") is a single call: SetBtAudioSoundEffect(true).
Still need: the Eq10Band enum (10 band indices) + gain units/range — short ctor/setter disasm,
or on-device probe.
libBtPlayerService.so:
BtPlayerServiceClient::SetLDAC(bool)— enable LDAC codecBtPlayerServiceClient::SetLDACBufferControl(BtLdacControl)— buffer/qualityBtPlayerService::LdacWriteSound()— the PCM-write entry into the LDAC encoder (the inverse the CLAUDE.md Part H5 "Approach A" was missing)BtTransmitterServiceClientFactory::CreateInstance()(libBtTransmitterService.so) — client factory
So the USB-DAC→LDAC bridge = tap USB-DAC PCM (UsbDeviceAudioPlayerService) → feed
LdacWriteSound() with LDAC enabled. Still device-gated for the PCM tap + the E4/E5 ALSA-topology
confirmation (CLAUDE.md Part H6), but the BT-side entry point is now identified.
No master-volume API in PlayerService/SoundService. libaudiohal-adleralsa.so (the CXD3778GF
wrapper) strings: "master volume", "analog playback mute", /sys/module/snd_soc_cxd3778gf/parameters/….
→ Main 3.5 mm volume is the CXD3778GF codec master volume, set via the ALSA mixer control
("master volume" on card0) or the sysfs parameter. Wire Action::VolUp/VolDown to an ALSA mixer
set (libasound snd_mixer_*) or a sysfs write. Exact control name / range = on-device amixer scontrols (E4). (Hardware vol keys may also be handled below us — confirm with getevent whether
they even reach userspace.)
libAudioAnalyzerServiceClient.so → pst::services::audioanalyzerservice::AudioAnalyzerService
is a complete spectrum-analyzer service (the stock player's spectrum screen uses it — confirmed:
HgrmMediaPlayerApp imports the exact same 7 symbols). Sony does the FFT itself and pushes
per-band magnitudes to a listener, so we get a real visualiser with no FFT cost on our side.
Client API (all RE-confirmed exported symbols):
AudioAnalyzerService::GetInstance()→ singleton (Sony-allocated → sizing rule N/A)SetMode(mode_t)—mode_t{LEVEL=0, SPECTRUM=1}(inferred: listener slot order Level<Spectrum + theaudioanalyzer_params/file namingdelay_normal_{level,spectrum}_<rate>_<bits>.txt; spectrum exists fornormalplayback only, notdacmode). Probe sweeps it if wrong.SetUpdateRate(float)·SetCalcSamples(unsigned)·SetPassband(vector<Passband>)(optional — omit to inherit the stock screen's defaults;Passbandlayout not needed for the default path)Start(IEventListener*)·Stop()·Terminate()
The one ABI reproduced — IEventListener vtable. From libAudioAnalyzerService.so
(AudioAnalyzerServiceServiceImpl's secondary base at +4, _ZThn4_ thunks), the listener
sub-object vtable is exactly the standard libc++ layout:
[0]=~dtor(D1) · [1]=deleting dtor(D0) · [2]=OnLevelUpdate(vector<int>const&) · [3]=OnSpectrumUpdate(vector<int>const&).
A faithful C++ re-declaration (virtual dtor, then the two virtuals in declaration order) reproduces
it; verified in the built binary — CinderListener vtable @ offset-to-top 0, slot2=OnLevelUpdate,
slot3=OnSpectrumUpdate. Start only stores the pointer; the analyzer thread later calls
listener->vtable[3](spectrum). The dtor slots are never called by Sony (we own a static listener).
Implementation (shipped): cinder-audio/src/analyzer_shim.cpp + include/cinder_analyzer.h —
dlopen-based (NOT a link dep, so a missing/renamed .so just disables the feature instead of
breaking cinder-home's dynamic load), forwards OnSpectrumUpdate → cinder_set_spectrum()
(cinder-ffi, spectrum::from_bands: resample to 36 bars + auto-gain, dB-or-linear). Default OFF
(gated by /contents/cinder_viz.conf: analyzer=1); started in deferred_up() behind run_guarded,
stopped on background/finalize. Validate on device first: cinder-probe --analyzer (reports frame
flow + raw band range for calibrating from_bands). Fallback path for a raw-PCM tap with no
analyzer (e.g. USB-DAC): cinder_set_pcm() (our own radix-2 FFT, spectrum::levels).
libAudioAnalyzerService.so was read properly rather than treated as a black box that emits
numbers. It is not an FFT. SpectrumAnalyzer is a bank of 2nd-order IIR bandpass filters,
one per passband, each feeding a level detector (Lvdet, an EffectSetParamLvdet client):
| Symbol | Address | What it does |
|---|---|---|
SpectrumAnalyzer::SpectrumAnalyzer() |
0x1faac | Pushes a hardcoded 12-entry default passband list (16/31/62/125/250/500/1k/2k/4k/8k/16k/32k Hz, mean=10.0f) and allocates ceil(n/5) Lvdet objects — min(remaining,5) bands each, 0x14088 bytes apiece |
SpectrumAnalyzer::SetPassband(vector<Passband>&) |
0x1fc58 | Only assigns the vector. It does NOT resize the Lvdet vector |
SpectrumAnalyzer::UpdateCoefSet() |
0x1fe60 | Per Lvdet, walks that Lvdet's own band count and computes one biquad per band |
SpectrumAnalyzer::GetSpectrumData(vector<int>&) |
0x20158 | Concatenates Lvdet::GetAmplitude(i) over every Lvdet — hence one int per band, in band order |
Consequences, all load-bearing:
- 12 bands is a hard ceiling for any client. The detectors are built in the CONSTRUCTOR from
the 12-entry default;
SetPassbandnever makes another one, so a 13th passband has nothing to run in and is silently ignored. wampy's "caps at 12" is confirmed, and now explained. Passband.meanis the filter's Q.UpdateCoefSetcomputesB = tan(pi*f/fs)/meanand emits[B/D, 0, -B/D, (2-2T²)/D, (B-T²-1)/D]withT = tan(pi*f/fs),D = T²+1+B— an RBJ bandpass whosealpha = sin(w0)/(2Q)equalsBin the small-angle limit, i.e. mean ≡ Q. Sony's own player passes 456: filters ~1/300 octave wide, twelve needles with the spectrum falling between them, which is where the "40k to millions, frame to frame" behaviour comes from. Contiguous coverage at our 12 log-spaced centres (ratio 1.76) isQ = 1/(sqrt(r)-1/sqrt(r)) = 1.75.- A band at or above fs/2 is ZEROED (
UpdateCoefSetcomparesfs>>1against the centre and stores an all-zero coef set). The stock table's 28 kHz entry — which the shim reproduced — was a dead column at every rate this device plays.
Shipped as a result: cinder_analyzer_set_bands() (table + per-band Q, settable while running),
cinder_analyzer_set_window(), a timestamped frame log with a passband-generation tag, and
cinder-probe --vizlab, which measures the ceiling, the Q sweep, the detector window, the true
emit rate, and how a live stream behaves when the table is swapped underneath it.
| Question | Measured |
|---|---|
| 24-entry passband table | bands=12 in every frame — ceiling confirmed, and it takes the FIRST twelve entries (matching the lvdet_idx*5 + i indexing in UpdateCoefSet) |
SetPassband on a live stream |
Applies, 1–7 ms typical (worst 60 ms). Proven unambiguously: a table with all twelve centres at 8 kHz makes all twelve reported magnitudes bit-identical (avg 122 507 900 across every band) |
SetUpdateRate(60) |
Honoured — mean inter-frame 17 ms (20 Hz → 47–50 ms) |
SetCalcSamples live |
Applies; visibly changes the dynamics (512 vs 4096 vs 16384) without touching the emit rate. This is the "time window" knob |
Q (mean) |
Q=456: band min/max spans ~1000:1 inside 2 s (needles). Q=1.75: ~10:1, every band populated. Magnitudes with Q=1.75: band avgs 5.6e7–5.7e8, maxima 1.8–2.1e9 (hence spectrum::FIXED_REF = 2.0e9) |
| 24 bands by alternating two tables | Not viable. Alternating all-100 Hz against all-8 kHz separates them 5.5x at 500 ms dwell, 2.5x at 250 ms, 1.1x at 100 ms — at display speed the two tables report the same thing. A bandpass needs ~Q/f to settle (>100 ms at the bottom of the range) and the detector averages on top. A complete 24-band frame would cost ~2 Hz against 20–60 Hz for twelve |
| Feature | Status | Next step |
|---|---|---|
| Now Playing progress + track-change | wireable | implement PlayEventListener (vtable mapped) |
Play a selected track/album (PlayIndex) |
wireable | NodeTrackSequence + ConvJsonStringToNode + SetTrackSequence |
| EQ → DSP, all Sound toggles | wireable | effect_shim.cpp over EffectCtrlDmp |
| Effects on Bluetooth (goal #7) | wireable | SetBtAudioSoundEffect(true) |
| Volume | mechanism found | ALSA mixer "master volume" / sysfs — confirm control name on device |
| USB-DAC → LDAC (headline) | entry point found | PCM tap + LdacWriteSound; E4/E5 ALSA topology on device |
| Real audio-reactive visualiser | shipped, default-ON | analyzer_shim over AudioAnalyzerService (12 bands, ours placed + Q'd — see §10b); cinder_viz.conf: analyzer=0 disables. Settings ▸ Visualiser owns scale/range/response/curve/peaks/window/rate |
| Battery care (charge limit) | NOT wired (UI label only) — mechanism found | PowerMgrServiceClient::EnableItawariCharging(bool) + IsItawariChargingEnabled() (§9) |
Deep pass for any path that could crash/corrupt on device. Findings:
| Area | Result |
|---|---|
| Framebuffer blit | BUG FIXED — wrote (page*H+y)*stride+copy_bytes for all H rows×pages with no map_len bound; a panel geometry where pages*H > yres_virtual (rotated/different unit) → OOB write past the mmap. Now bounded per-row against map_len (clips, never overruns). Confirmed device (480×800, virtual 2400) fits exactly. |
| PlayStatus stack object | SAFE — GetCurrentStatus→ConverPlayStatus highest write to dst = [r4,#120] (URI std::string @ +0x6c..+0x77, one byte @ +120) ⇒ real ≈124 B vs our _opaque[256] reserve (>2×). No stack smash. |
| Rust render indexing | SAFE — library loops are scroll..len (empty-range-safe when stale), play uses .get(), eq/sound/viz/settings cursors clamped, Album flat.get() None handled, set_library resets lib cursor/scroll. |
| Canvas draw primitives | SAFE — put/blend/fill_solid all clamp to [0,W)×[0,H); embedded-graphics clips to target. No OOB canvas writes. |
| DB (malformed MTPDB.dat) | SAFE — build_library uses unwrap_or_default() throughout; query errors propagate as Result (no panic) → empty/partial library. |
| C++ config/log parsing | SAFE — all fgets(_, sizeof, _) / fread(_,1,sizeof-1,_) / strtol with bounds; no gets/scanf/strcpy. evdev code bounds-checked before g_keymap[]. |
| Concurrency / panics | SAFE — single-threaded Rust core; analyzer thread self-masks SIGALRM; guard thread-owner check; panic=abort ⇒ no poison cascade. |
Two builds from ONE tree, selected by a single flag (build.sh stable | build.sh dev):
- stable (default): the lean player. Firmware row reads "CINDER 1.0 · RUST". No adb.
- dev: cargo
devfeature flips the marker to "CINDER DEV · RUST" (so you can tell them apart on-device), and-DCINDER_DEVmakes the dev binary self-enable adb at boot (indeferred_up, behindrun_guarded):setprop sys.usb.config mtp,adb+persist.sys.usb.config+start adbd. Artifacts:cinder-home/dist/{stable,dev}/(binaries + the channel-agnostic install/uninstall .UPGs).
Why self-enable, not a ramdisk init hook: adbd IS in the stock firmware (Wampy uses adb; the
sys.sony.config USB modes include adb; uac mode = audio_func,adb), but the scrobbler/wampy
persistent-service pattern modifies the boot ramdisk's init.rc (repacks the boot image) — the
project's single biggest brick risk. The dev binary already runs at boot, so it enables adb itself:
touches NO boot-critical files, fully guarded (a failure = no adb, player runs exactly like stable),
and persist.sys.usb.config makes adb come up early on later boots → a brick-recovery channel
(adb shell touch /contents/cinderhome_off reverts without wbrt). The exact property mechanism is
confirmed on the first dev flash; refine the one std::system(...) line in main.cpp if needed.
Prereqs: MTK/adb USB driver on Windows; usbipd-win for WSL passthrough (CLAUDE.md Part F). Security:
root adb = anyone with USB gets root — fine for the dev unit; the stable channel ships without it.
A sweep of every UI control for "looks live but does nothing." Status after this session:
| Control | Was | Now | Mechanism / next step |
|---|---|---|---|
| Settings ▸ Theme | wired | wired | internal |
| Settings ▸ Visualiser type/anim | wired | wired | internal |
| Settings ▸ Battery care | static "LIMIT 90%" | WIRED (On/Off) | PowerMgrServiceClient::EnableItawariCharging (§9) |
| Sound screen (DSEE/Vinyl/VPT/DC-Phase/Normalizer/ClearAudio+) | display-only (whole screen) | WIRED (6 On/Off toggles) | effect_shim/EffectCtrlDmp; VPT/DC-Phase mode/type still on/off-only (enum values TBD on device) |
| Sound screen ▸ A/B compare | n/a | WIRED (Option toggles A↔B) | EffectCtrlDmp Disable/ReenableSoundEffects → cinder_effects_set_bypass; instant DSP on/off listen test |
| EQ bands/preset | wired | wired | effect_shim |
| Volume (Vol±) | emits action, carry_out TODO | still TODO | CXD3778GF "master volume" ALSA mixer / sysfs — exact control name device-gated (§6, E4) |
| Play a selected track/album (PlayIndex) | emits action, carry_out TODO | still TODO | NodeTrackSequence + ConvJsonStringToNode + SetTrackSequence — RE'd, needs player_shim impl + device test |
| Bluetooth on/off | emits BtToggle, FFI continue |
still UI-only | BtTransmitterService SetCurrentSource/SetLdac (Part H4) |
| Settings ▸ USB mode | static, EnterUsbMsc TODO | still TODO | setprop sys.sony.config msc/uac (Part H4) |
| Settings ▸ Storage | static "12.4/16 GB" | WIRED (real statvfs) |
shell report_storage() → cinder_set_storage; info row (no drill-in) |
| Up Next screen | static data::SONGS render |
WIRED (real) | nav::now_playing_queue = current album from the library; auto-scrolls to the playing track |
| Settings ▸ Brightness / Screen-off timer | static | still static | backlight sysfs / appmgr power policy (device-gated) |
| Settings ▸ Database REBUILD | static | still static | triggers Sony MTP re-index (complex; deferred) |
| Settings ▸ Firmware / Model | static | static (honest info labels — not fake toggles) | could read real fw/NVP; low value |
The two egregious fake features (battery care; the entire Sound screen) are now real. The rest are either device-gated (volume/play-index/USB/brightness need on-device validation) or honest info labels.
NOW IMPLEMENTED — Settings ▸ Battery care is a live On/Off toggle (power_shim →
PowerMgrServiceClient), state read at boot. This section keeps the RE detail. (It was previously a
static "LIMIT 90%" label.) The device support:
libPowerMgrServiceClient.so→PowerMgrServiceClient::EnableItawariCharging(bool const&)andIsItawariChargingEnabled(). "Itawari" (いたわり, "considerate") is Sony's battery-care charging that caps charge at ~90% to preserve longevity.- It is an On/Off toggle, not a settable percentage — confirmed by the stock app's
isBatteryCareOn/OnBatteryCareOnOffToggled/updateBatteryCare(HgrmMediaPlayerApp).
To wire it: (1) PowerMgrServiceClient is constructed (public ctor, no factory) → the SIZING RULE
applies — RE its ctor write-extent and reserve storage like effect_abi.hpp before new. (2) A small
power_shim.cpp (C ABI: cinder_power_set_battery_care(int) / _get), all behind run_guarded.
(3) Make the Settings row interactive (On/Off toggle), read initial state from IsItawariChargingEnabled.
The label should read "On/Off" (the 90% cap is fixed in firmware), not "LIMIT 90%".
new-ing a Sony class needs sizeof ≥ the real object, else the device ctor overflows (the
2026-06-25 heap-overflow brick). Sizes from disassembling each ctor's highest str [this,#off]:
| class | how obtained | real size | reserve | notes |
|---|---|---|---|---|
EffectCtrlDmp |
ctor @0xdd40 writes this+0 (impl ptr), this+4 (bool) | ≈ 8 B | 0x10 | non-polymorphic PIMPL — trivially safe |
NodeTrackSequence<UriInfo> |
ctor @0xbcec writes through +0xb0 (176) | ≥ 180 B | 0x100 | reserve like CuiAppModule |
Node<UriInfo> |
— | n/a | — | Sony-allocated via ConvJsonStringToNode (unique_ptr) — we never size it |
PlayEventListener (impl) |
we subclass it | our choice | — | we control the object; just match the vtable (§2) |
So the effect path is the safest to wire first (8-byte object). For play-by-track, reserve 0x100
for NodeTrackSequence and let Sony allocate the Node tree.
Caution (per the 2026-06-26 soft-brick): every one of these is a Sony-service call — wire each
behind the run_guarded crash+hang guard in cinder-home, off the boot path, and reserve the sizes
above (static_assert them, like easel_abi.hpp). Confirm with cinder-probe-style isolation
before putting any on the boot path. Effect/EQ + volume + play-by-track are all invoked from UI
actions AFTER the UI is healthy, so a bug there can't brick (the guard catches it, the UI continues).