A Beat Saber PC mod that tracks your play session and shows a shareable recap card — personal bests, PP gained on both leaderboards, accuracy, combos, session length and more.
You can open the card at any time, mid-session, without ending or resetting anything. It also appears automatically when a session ends.
| Stat | Notes |
|---|---|
| New personal bests | Local best-score store, works on unranked maps too |
| ScoreSaber PP gained | end − start for the session, from the ScoreSaber web API |
| BeatLeader PP gained | end − start for the session, from the BeatLeader web API |
| ScoreSaber / BeatLeader rank change | Ranks gained (positive) or lost (negative) |
| Session length | Wall-clock, plus active in-map time excluding pauses |
| Songs played | Attempts vs. completions |
| New songs | Maps completed for the first time ever |
| Best single play | Highest accuracy, tie-broken by score |
| Average accuracy | Across completed plays that reported a max score |
| Full combos, misses, longest combo | Aggregated across the session |
| Total score, pass rate, most-played difficulty | Cheap extras |
Every stat degrades gracefully. Anything unavailable renders as — rather than a zero,
a crash, or a wrong number. If both leaderboard mods are absent and no player ID is set,
the mod still runs and reports everything it can compute locally.
Beat Saber 1.40.x — download SessionRecap-1.40.8.zip from
Releases.
This is the only supported version. It is tested in-game: the mod loads, the card opens and refreshes live, and player ID detection resolves automatically.
Support for newer game versions will be released once this mod's dependencies (BSML and SiraUtil) are available for them.
Required
- BSIPA (BeatSaber-IPA-Reloaded)
- BeatSaberMarkupLanguage (BSML)
^1.12.0 - SiraUtil
^3.1.0
Optional — detected at runtime, never required
- ScoreSaber
- BeatLeader
- LeaderboardCore
The mod has no compile-time reference to any leaderboard mod. PP and rank come straight from the ScoreSaber and BeatLeader web APIs, and player IDs are read from config files or entered in Settings. That means a leaderboard mod updating, breaking, or being missing entirely cannot stop Session Recap from loading.
-
Make sure BSIPA, BSML and SiraUtil are installed, using whichever mod manager you normally use.
-
Download
SessionRecap-1.40.8.zipfrom the latest release. -
Extract it into your Beat Saber folder. You should end up with:
Beat Saber/ Plugins/ SessionRecap.dll SessionRecap.Core.dll
To uninstall, delete those two files.
Developed and tested against a BSManager instance of 1.40.8.
- Main menu →
Session Recapopens the card for the session so far. This is strictly read-only: it never starts, ends, resets or otherwise changes the session. Numbers are recomputed each time you open it, and the header readsLIVE · IN PROGRESS. - At session end the same card appears with the header
FINAL. - Export Card (available on both views) writes a timestamped PNG to
UserData/SessionRecap/exports/, plus the raw session JSON next to it. - End & Show Recap finalizes the current session on demand.
- Reset Session discards the current session and starts a new one.
A session starts at the first level start after launch, or after an idle gap longer than the configured timeout. It ends when you press End & Show Recap, when the idle timeout elapses, or when the game exits.
In-progress state is written to session_current.json after every event, so a crash on
exit — which Beat Saber is known for — does not lose your recap. On the next launch the
session is resumed if it is still within the idle window, or archived to exports/ if not.
Settings live under Settings → Session Recap, and in UserData/SessionRecap/config.json.
| Setting | Default | Meaning |
|---|---|---|
| ScoreSaber player ID | auto | Profile ID or full profile URL |
| BeatLeader player ID | auto | Profile ID or full profile URL |
| Auto-detect player IDs | on | Try leaderboard-mod configs, then Steam |
| Track ScoreSaber PP | on | |
| Track BeatLeader PP | on | |
| Idle timeout (minutes) | 45 | Gap that ends a session |
| Show recap automatically on session end | on | |
| Count practice mode plays | off | When off, practice is excluded from every stat |
| Ignore practice for personal bests | on | |
| Ignore No Fail rescues for personal bests | on | A run saved by No Fail cannot set a PB |
| Show PP gained | on | |
| Show rank change | on | |
| Save session JSON next to exported PNG | on |
config.json also holds countMultiplayer, which is not exposed in the UI because
multiplayer results are not currently captured (see Limitations).
On Steam, your ScoreSaber and BeatLeader IDs are both your SteamID64.
Auto-detection reads Steam's loginusers.vdf, locating Steam through the registry
(HKCU\Software\Valve\Steam) so it works even when the game lives outside the Steam
library — BSManager instances, for example. It then falls back to scanning leaderboard mod
configs.
Two things worth knowing:
- The game itself is not a usable source.
IPlatformUserModelexists on 1.40 but is not readily reachable, and later game versions removed it outright. - ScoreSaber and BeatLeader do not store a player ID in their configs — both resolve it from the platform at runtime — so that fallback rarely fires in practice.
If detection fails or picks the wrong account, paste your profile URL into Settings; it is
parsed and normalized on save. The log line Auto-detected player ID from Steam. confirms
it worked.
- At session start, the total PP and global rank are fetched for each enabled leaderboard. This is the baseline.
- After every ranked completion, the totals are re-fetched with exponential backoff (4 attempts, starting at 6 s, doubling, capped at 60 s). Server-side PP lags score submission, so a single immediate fetch would read a stale value. Polling stops as soon as the total actually changes.
gained = latest − baseline, computed independently per leaderboard.
If the baseline could never be captured (offline, no ID, API down), the card shows —
for that leaderboard and says why, instead of reporting a fake +0.00pp.
src/SessionRecap.Core/ version-independent: model, stats, API clients, storage
src/SessionRecap.Game/ BSIPA plugin, BSML UI, game hooks
Adapters/Common/ version-agnostic event hub + patcher interface
Adapters/Groups/G1_40/ 1.40.x hooks
scripts/ fetch-refs, build-all, installer payload
installer/ Inno Setup script (not currently shipped)
Game hooks live in Adapters/Groups/<group>/, one folder per compatibility group, selected
by MSBuild at build time. Only 1.40.x ships today; the structure exists so a new game
version is a new folder rather than conditionals sprayed through the codebase.
SessionRecap.Core references no game assembly. It is pure C# against net472 plus
Newtonsoft.Json, so its logic can be reasoned about and changed without touching anything
version-specific.
Rather than a separate project per supported version, a single project swaps its adapter folder per build configuration. Roughly 90% of the game-facing code — plugin entry point, BSML views, view controllers, exporter, composition root — is identical regardless of game version, so separate projects would duplicate all of it for no gain.
Adapters/Groups/<group>/ is included by MSBuild based on the active configuration:
<Compile Include="**\*.cs" Exclude="bin\**;obj\**;Adapters\Groups\**" />
<Compile Include="Adapters\Groups\$(AdapterGroup)\**\*.cs" />Each group provides the same three type names (GamePatcher, LevelActivityPatches,
PausePatches) in the same namespace, so the rest of the codebase compiles unchanged.
This keeps #if out of the source entirely — a BS_1_40-style symbol is defined and
available per group, but nothing needs one, because whole-file substitution is cleaner
than interleaved conditionals.
Determined by reflecting over the real game assemblies rather than from memory, and worth recording because these are the parts most likely to move in a future game version:
- Level results come from
StandardLevelScenesTransitionSetupDataSO.Finish, and level starts from itsInit— which has two overloads, so it is targeted with Harmony'sTargetMethods(). Patching by name alone throws an ambiguity error, and hard-coding the argument list would break on the next signature change. BeatmapKeyexposesbeatmapCharacteristic(aBeatmapCharacteristicSO, read via.serializedName) — not a plain enum.PauseControllerhasPause()but noResume(). Pause tracking therefore hooksGamePause, which exposes both.HMUI.Screen/ViewController/FlowCoordinatorlive inBeatSaber.ViewSystem.dll, notHMUI.dll, andScenesTransitionSetupDataSOderives from a type inBGLib.AppFlow.dll.- Accuracy is
multipliedScore / ScoreModel.ComputeMaxMultipliedScoreForBeatmap(...), using the transformed beatmap data off the transition setup data.
Game events (Finish, Init, Pause, Resume) are hooked with HarmonyX because those
call sites are stable and patching them needs no assumptions about which Zenject container
binds what — bindings move between versions far more often than method signatures do.
The menu UI is installed through SiraUtil's Zenjector at Location.Menu, which is the
idiomatic and well-supported path for menu-time objects.
Adding a compatibility group takes four edits:
-
Directory.Build.props— add the configuration names to<Configurations>and a property block:<PropertyGroup Condition="$(Configuration.EndsWith('_1_46'))"> <CompatGroup>1_46</CompatGroup> <AdapterGroup>G1_46</AdapterGroup> <BSGameVersion>1.46.0</BSGameVersion> <BSCompatSymbol>BS_1_46</BSCompatSymbol> <BeatSaberDir>$(BeatSaberDir_1_46)</BeatSaberDir> </PropertyGroup>
-
BeatSaberDir.user.props— add<BeatSaberDir_1_46>pointing at that install. -
src/SessionRecap.Game/Adapters/Groups/G1_46/— copy the closest existing group and adjust for API changes. Only these files may touch game assemblies. -
scripts/build-all.ps1andinstaller/SessionRecap.iss— add the group to the$gameVersions/GroupForVersionmaps.
Then scripts\fetch-refs.ps1 -CompatGroup 1_46 -SourceDir "<install>\Plugins" and build.
To work out what changed in a new version, the reflection approach used to build this mod is far more reliable than guessing. Windows PowerShell 5.1 runs on .NET Framework, so it can load the game's assemblies directly:
[System.Reflection.Assembly]::ReflectionOnlyLoadFrom("$Managed\Main.dll").GetTypes() |
Where-Object { $_.Name -match 'ScenesTransitionSetupData' }Requirements: .NET SDK (any version that can target net472) and a Beat Saber install
per compatibility group. Visual Studio is not required — the projects use
Microsoft.NETFramework.ReferenceAssemblies, so dotnet build alone is enough.
Two different things are resolved from two different places:
- Game assemblies come from
BeatSaberDirper group. A clean, unmodded install is fine — a DepotDownloader / BSManager instance works. - Everything else (BSIPA, HarmonyX, Newtonsoft.Json, BSML, SiraUtil) comes from
Refs\<group>\, populated byfetch-refs.ps1from a modded install. A clean install has none of these.
cp BeatSaberDir.user.props.example BeatSaberDir.user.propsEdit it to point at your installs, then point fetch-refs at a modded install root
(it searches Plugins\, Libs\ and Beat Saber_Data\Managed\ recursively):
.\scripts\fetch-refs.ps1 -CompatGroup 1_40 -SourceDir "C:\Path\To\A\Modded\Beat Saber"
.\scripts\build-all.ps1 -Groups 1_40The zip lands in dist/.
Refs/ and BeatSaberDir.user.props are gitignored — they contain other people's
assemblies and machine-specific paths.
All under UserData/SessionRecap/:
| File | Purpose |
|---|---|
config.json |
Settings |
pb_store.json |
Best score per map + difficulty + characteristic |
seen_hashes.json |
Every map ever completed, for "new song" detection |
session_current.json |
Resumable in-progress session |
exports/ |
Timestamped PNG + JSON per exported or archived session |
Every read tolerates a missing or corrupt file: unreadable JSON is renamed to
<name>.corrupt-<timestamp> and defaults are used, so a bad file never blocks startup.
Writes go to a temp file and are then moved into place.
Your first session will look odd, and that is expected:
- Every map counts as a "new song." The completed-maps list starts empty, so the first clear of anything is a first-ever clear as far as the mod is concerned.
- You will see zero personal bests. A PB means beating a previously recorded score. With no history yet, first clears count as new songs instead — the two never double-count the same play.
- PP gained stays at
+0.00ppuntil you finish a ranked map, since the baseline is captured when the session starts.
From the second session onward the numbers mean what you would expect. The mod does not import your existing scores; it only knows what it has watched you play.
- Multiplayer results are not captured. Only solo/campaign standard levels are hooked.
countMultiplayerexists in config and the data model but no source sets the flag yet. - Accuracy needs a max score. It is
multipliedScore / maxMultipliedScorecomputed from the transformed beatmap data. If that is unavailable the play still counts, but contributes no accuracy value. - A first-ever clear is a "new song", not a "personal best". A PB requires beating a previously stored score, so the two counters never double-count the same play.
MIT — see LICENSE.