All lab entries
Writeup

Baldur's Gate 3 — Honour Mode Restore

An experiment in recovering an Honour Mode save, documenting the restore process, the save data involved, and the tooling used to bring a run back to a valid state.

  • Reverse Engineering
  • Save Data
  • Tooling

Where this started

What I wanted to test

This started from a very simple question: if I’m playing Baldur’s Gate 3 through Steam + Proton on Arch, which save directory is actually the live one? Honour Mode makes that question a bit more interesting than usual. If I was going to keep a manual backup around, I wanted to know exactly what BG3 was updating and what would need to be restored after something worse than a normal misplay (elevator bug, grrrr). So the goal was not to build some giant save manager. I just wanted a small, boring, predictable backup/restore workflow that I could understand and test myself.

Setup

The test environment was:

  • Arch Linux
  • Steam
  • Proton
  • Baldur’s Gate 3
  • Steam Cloud disabled while testing
  • a dedicated local backup directory

The backup directory I used was:

~/Documents/misc/bg3-backups

The important part was keeping the game fully closed while taking or restoring a snapshot. I really did not want Steam, Proton, or BG3 touching the same files halfway through a copy operation.

Where the data actually lives

Native Linux location

The first obvious place to look was the native Linux-style directory:

~/.local/share/Larian Studios/Baldur's Gate 3/PlayerProfiles/Public/

It contains the usual:

Savegames/Story/
profile8.lsf

The problem: while running the game through Proton, this was not the directory being updated by the active session. So, cool, one copy found. Just not the one I cared about.

Proton save location

The live Proton profile was under Steam’s compatdata prefix:

~/.local/share/Steam/steamapps/compatdata/1086940/pfx/drive_c/users/steamuser/AppData/Local/Larian Studios/Baldur's Gate 3/PlayerProfiles/Public/

The important files are again:

Savegames/Story/
profile8.lsf

Honour Mode runs live inside a directory matching:

*__HonourMode

with the actual save stored as:

HonourMode.lsv

During the test, saving in-game immediately changed the HonourMode.lsv under this Proton path. That made this the primary source for the current session.

Steam userdata / remote copy

There was also another copy under Steam userdata:

~/.local/share/Steam/userdata/<STEAM_USER_ID>/1086940/remote/_SAVE_Public/Savegames/Story/

and the corresponding profile:

~/.local/share/Steam/userdata/<STEAM_USER_ID>/1086940/remote/_PROFILE_Public/profile8.lsf

That copy was changing too. So by the end of the test I had three distinct places worth keeping mentally separate:

native Linux profile
Proton profile
Steam userdata / remote copy

The main lesson here was mostly: do not assume the first plausible BG3 directory you find is the one your current session is actually using. :)

What the test confirmed

What actually changed

After launching BG3 through Proton and saving the Honour run, I compared the relevant locations. What I observed:

  • the Proton HonourMode.lsv changed;
  • the Steam userdata copy changed as well;
  • the old native Linux directory did not;
  • profile8.lsf existed separately in the native, Proton, and Steam userdata locations.

That was enough to identify the Proton copy as the live source I wanted my scripts to treat as authoritative.

Why I also back up profile8.lsf

Backing up only the __HonourMode directory sounds reasonable at first, and for ordinary save-state recovery it may be enough. Honour Mode is slightly more annoying (like the actual game difficulty). After a total party kill, some relevant run state can also be reflected in the profile, so my backup set includes both:

HonourMode.lsv
profile8.lsf

and, when they exist, the Steam userdata copies too. The script refuses to create a backup if the Proton save or Proton profile is missing:

[[ -f "$TEMP_DIR/proton/$run_name/HonourMode.lsv" ]] || {
  echo "ERROR: Backup verification failed: proton save copy is missing HonourMode.lsv" >&2
  exit 1
}

[[ -f "$TEMP_DIR/proton/profile8.lsf" ]] || {
  echo "ERROR: Backup verification failed: proton profile copy is missing profile8.lsf" >&2
  exit 1
}

Nothing fancy, but I wanted the backup command to fail loudly rather than happily produce a directory that only looks useful.

How backup and restore work

I ended up with two Bash scripts:

bg3-honour-backup.sh
bg3-honour-restore.sh

They are intentionally boring. The interesting part is mostly the amount of defensive checking around what would otherwise just be a couple of cp commands.

bg3-honour-backup.sh

The backup script first refuses to run while BG3 / Wine / Proton-related processes appear to be active:

is_game_running() {
  pgrep -af 'bg3|bg3_dx11|bg3\.exe|baldur.?s gate 3|wine|wineserver|wine-preloader' >/dev/null 2>&1
}

Then it looks through the Proton save directory for Honour Mode runs and selects the one whose HonourMode.lsv was modified most recently:

latest_honour_dir() {
  local best_dir=""
  local best_mtime=""
  local dir lsv mtime

  while IFS= read -r -d '' dir; do
    lsv="$dir/HonourMode.lsv"
    [[ -f "$lsv" ]] || continue

    mtime=$(stat -c '%Y' "$lsv")

    if [[ -z "$best_mtime" || "$mtime" -gt "$best_mtime" ]]; then
      best_mtime="$mtime"
      best_dir="$dir"
    fi
  done < <(
    find "$PROTON_STORY"       -mindepth 1       -maxdepth 1       -type d       -name '*__HonourMode'       -print0
  )

  [[ -n "$best_dir" ]] || return 1
  printf '%s\n' "$best_dir"
}

The backup is built in a temporary directory first:

TEMP_DIR=$(mktemp -d "$BACKUP_ROOT/.${backup_id}.tmp.XXXXXX")

mkdir -p "$TEMP_DIR/proton" "$TEMP_DIR/steam_remote"

cp -a "$run_dir" "$TEMP_DIR/proton/"
cp -a "$PROTON_PROFILE" "$TEMP_DIR/proton/"

Only after the copies are verified does the script move the temporary directory into its final location:

mv -- "$TEMP_DIR" "$final_dir"
TEMP_DIR=""

That also means a failed copy does not leave behind a half-finished backup that looks complete. The backup includes a small metadata.txt file recording things such as creation time, source paths, modification times, and whether Steam remote copies were present.

FULL BACKUP SCRIPT →

bg3-honour-restore.sh

The restore side is the one I cared about being extra cautious with. Before touching the live files, it creates a snapshot of the current state:

SNAPSHOT_DIR="$SNAPSHOT_ROOT/${now}__${run_name}"
mkdir -p "$SNAPSHOT_DIR/proton" "$SNAPSHOT_DIR/steam_remote"

if [[ -e "$PROTON_TARGET" ]]; then
  mv -- "$PROTON_TARGET" "$SNAPSHOT_DIR/proton/"
fi

if [[ -f "$PROTON_PROFILE" ]]; then
  mv -- "$PROTON_PROFILE" "$SNAPSHOT_DIR/proton/"
fi

The replacement files are copied into temporary paths and verified before being moved into place:

proton_tmp_dir="$PROTON_STORY/.${run_name}.restore_tmp.$$"
proton_profile_tmp="$PROTON_PUBLIC/.profile8.lsf.restore_tmp.$$"

cp -a -- "$proton_run_dir" "$proton_tmp_dir"
cp -a -- "$proton_backup_profile" "$proton_profile_tmp"

[[ -f "$proton_tmp_dir/HonourMode.lsv" ]] || {
  echo "ERROR: Proton temp restore missing HonourMode.lsv." >&2
  exit 1
}

[[ -f "$proton_profile_tmp" ]] || {
  echo "ERROR: Proton temp restore missing profile8.lsf." >&2
  exit 1
}

mv -- "$proton_tmp_dir" "$PROTON_TARGET"
mv -- "$proton_profile_tmp" "$PROTON_PROFILE"

And if something goes wrong after the old live state has been moved away, the error trap attempts to put it back:

rollback() {
  local rc=$?
  [[ "$ROLLBACK_READY" -eq 1 ]] || exit "$rc"

  rm -rf -- "$PROTON_TARGET"

  if [[ -d "$SNAPSHOT_DIR/proton/$(basename "$PROTON_TARGET")" ]]; then
    mv -- "$SNAPSHOT_DIR/proton/$(basename "$PROTON_TARGET")" "$PROTON_STORY/"
  fi

  if [[ -f "$SNAPSHOT_DIR/proton/profile8.lsf" ]]; then
    mv -- "$SNAPSHOT_DIR/proton/profile8.lsf" "$PROTON_PROFILE"
  fi

  echo "ERROR: Restore failed. Previous live files were rolled back from $SNAPSHOT_DIR" >&2
  exit "$rc"
}

That was the part I cared about most. A restore utility that destroys the current state while failing to restore the old one would be a pretty funny outcome for a “safety” script.

FULL RESTORE SCRIPT →

Intended workflow

With BG3 fully closed:

./bg3-honour-backup.sh

To inspect available backups:

./bg3-honour-restore.sh --list

And to restore the latest one:

./bg3-honour-restore.sh --latest

The restore script also asks for confirmation unless --yes is explicitly provided. The versions published here use local configuration values that should be reviewed before running them on another machine. In particular, the Steam user ID and backup location are machine-specific.

What I learned

What I ended up trusting

For the setup I tested, the Proton profile under:

steamapps/compatdata/1086940/pfx/...

was the live source for the current game session. The native Linux directory under:

~/.local/share/Larian Studios/...

was a separate copy and should stay separate rather than being casually mixed into the Proton workflow. The Steam userdata copy was useful as another state worth capturing, but the Proton files were the main source I used to identify the active run.

The actual useful part

The interesting result was not really “here are two Bash scripts”.

It was getting to a workflow where I knew:

  1. which save BG3 was actually updating;
  2. which profile file needed to travel with it;
  3. that the game was closed before touching anything;
  4. that a backup was verified before being accepted;
  5. that a restore preserved the previous live state first;
  6. that a failed restore had something to roll back to.

Pretty small experiment, but exactly the kind of annoying thing I would rather test once than discover during a dead Honour run. And yes, technically the best Honour Mode backup strategy is still “don’t die”. Working on it.

All lab entries