Skip to content

Repository files navigation

Session Recap

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.


What it tracks

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.


Supported versions

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.


Dependencies

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.


Install

  1. Make sure BSIPA, BSML and SiraUtil are installed, using whichever mod manager you normally use.

  2. Download SessionRecap-1.40.8.zip from the latest release.

  3. 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.


Using it

  • Main menu → Session Recap opens 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 reads LIVE · 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.

Session lifecycle

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

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).

Player IDs

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. IPlatformUserModel exists 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.

How PP diffing works

  1. At session start, the total PP and global rank are fetched for each enabled leaderboard. This is the baseline.
  2. 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.
  3. 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.


Project layout

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.

One Game project, not one per game version

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.

Notes on the 1.40.x game API

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 its Init — which has two overloads, so it is targeted with Harmony's TargetMethods(). Patching by name alone throws an ambiguity error, and hard-coding the argument list would break on the next signature change.
  • BeatmapKey exposes beatmapCharacteristic (a BeatmapCharacteristicSO, read via .serializedName) — not a plain enum.
  • PauseController has Pause() but no Resume(). Pause tracking therefore hooks GamePause, which exposes both.
  • HMUI.Screen / ViewController / FlowCoordinator live in BeatSaber.ViewSystem.dll, not HMUI.dll, and ScenesTransitionSetupDataSO derives from a type in BGLib.AppFlow.dll.
  • Accuracy is multipliedScore / ScoreModel.ComputeMaxMultipliedScoreForBeatmap(...), using the transformed beatmap data off the transition setup data.

Why Harmony for game events and Zenject for UI

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 or fixing a version

Adding a compatibility group takes four edits:

  1. 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>
  2. BeatSaberDir.user.props — add <BeatSaberDir_1_46> pointing at that install.

  3. src/SessionRecap.Game/Adapters/Groups/G1_46/ — copy the closest existing group and adjust for API changes. Only these files may touch game assemblies.

  4. scripts/build-all.ps1 and installer/SessionRecap.iss — add the group to the $gameVersions / GroupForVersion maps.

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' }

Build from source

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 BeatSaberDir per 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 by fetch-refs.ps1 from a modded install. A clean install has none of these.
cp BeatSaberDir.user.props.example BeatSaberDir.user.props

Edit 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_40

The zip lands in dist/.

Refs/ and BeatSaberDir.user.props are gitignored — they contain other people's assemblies and machine-specific paths.


Data files

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.


First run

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.00pp until 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.


Limitations

  • Multiplayer results are not captured. Only solo/campaign standard levels are hooked. countMultiplayer exists in config and the data model but no source sets the flag yet.
  • Accuracy needs a max score. It is multipliedScore / maxMultipliedScore computed 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.

License

MIT — see LICENSE.

About

Beat Saber mod that tracks your play session and shows a shareable recap card — PBs, PP gained, accuracy and more.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages