The Baldur's Gate 3 cloud-save trap

I play Baldur's Gate 3 without a local install. On a good connection I stream it via GeForce NOW, and when I travel I take my Steam Deck. Both run the Steam version, so Steam Cloud syncs the saves between them.

In a hotel I clicked one wrong button, overwrote the good cloud save with an older one, and spent an evening getting it back. I didn't lose anything in the end, but here is the trap and the way out.

The wrong click

On the Deck, Steam showed a cloud-conflict prompt and I clicked "Play Anyway."

That kept the Deck's older local saves and pushed them up, overwriting the newer cloud copy from my last GFN session. So: never blind-click "Play Anyway" on a conflict. Check which side is actually newer first -- it pushes local up and can replace the cloud.

Why sync was stuck

The real problem was underneath: Steam Cloud sync for BG3 had silently stopped, with no error and no warning.

BG3's Steam Cloud quota is about 2 GB. A single save here is 15 to 30 MB, and autosaves and quicksaves pile up fast. Once the quota is full, syncing just stops, and one stuck save jams the queue for every campaign, not only the one you are playing.

The fix was to get back under quota. Manual saves are not the problem -- autosaves and quicksaves are the bulk of it. I deleted a pile of old autosaves and quicksaves, and the sync finally completed.

Getting the good save back

What actually saved me: GFN keeps its own local copy of the saves, independent of Steam Cloud. I had assumed the cloud was the only place the good save lived, so overwriting it felt fatal. It wasn't -- the newer save was still sitting in GFN's own storage. The Deck has local storage too, but there it was only the older saves.

With sync working again:

  • On the next GFN launch a conflict prompt appeared, and this time I chose the GeForce NOW version -- the good one.

  • On the Deck I moved the local save folders aside before launching. Re-accepting the EULA had created a fresh profile with a "now" timestamp, which made Steam think local was newer. With the local folder empty, Steam downloaded the correct cloud copy.

Finding the save folder on the Deck

To move the saves aside I first had to find them. Don't trust hardcoded paths from the internet -- the popular ones are built around BG3's app ID (compatdata/1086940/...), and that did not match my setup.

In Desktop Mode, open Konsole and let find tell you:

find / -ipath "*Larian Studios/Baldur*Savegames*" 2>/dev/null

That prints the real path on your machine, ending in Larian Studios/Baldur's Gate 3/Savegames/Story. The Savegames/Story folder is the one to move aside.

Which status to trust

Steam's remote storage page was accurate the whole time. It showed exactly what was in the cloud, which turned out to be outdated saves -- the newer ones had never synced up and only existed in GFN's own storage. So trust the page, but read it for what it is: what's actually in the cloud, not necessarily your latest progress.

GeForce NOW's own "in sync" indicator is the one to distrust -- it reports saves as synced when they are not.

Even after the fix, GFN still falsely shows some older saves as "in sync." What reliably worked: from the last GFN save of each campaign, make a fresh manual save. Those sync up cleanly, as long as there is quota left for them.

CSV to ledger, revisited: matching, real dates, and a second bank

Back in 2022 I wrote about my ING DiBa csv to ledger converter. The closing line was "no additional magic" and I noted that I ignored the difference between booking date and effective date.

Three things changed since then:

  1. the script learned to categorize transactions instead of emitting Expenses:FIXME for everything,

  2. it learned to recover the real transaction date out of the VISA booking text, and

  3. I finally wrote a second converter, for my Revolut account, which had been sitting un-automated for a few years.

Auto-categorizing with a match table

The 2022 version produced one account for every row: Expenses:FIXME. I still copy entries into a ledger file per month by hand, but now most rows arrive pre-booked.

The core is a small list of matches -- a substring to look for in the transaction text, plus the account, payee and optional tags to emit:

@dataclass
class Match:
    match: str
    description: str
    account: str
    amount: Decimal | None = None
    tags: str | None = None

def process_match(string, amount=None):
    for item in [
        Match("Hetzner Online GmbH", "Hetzner", "Expenses:Infrastructure:Hetzner"),
        Match("Rundfunk ARD, ZDF, DRadio", "GEZ", "Expenses:Media:GEZ"),
        Match("LIDL SAGT DANKE", "Einkaufen", "Expenses:Supermarket:Lidl"),
        Match("LOGPAY FIN", "VVS Ticket", "Expenses:PublicTransport:VVS"),
        # ... lots more ...
        Match("", "FIXME", "Expenses:FIXME"),  # default
    ]:
        if item.match in string:
            if item.amount is not None and amount is not None:
                if abs(amount) != abs(item.amount):
                    continue
            return {
                "description": item.description,
                "account": item.account,
                "tags": item.tags,
            }

Expenses:FIXME is now the default at the bottom of the list instead of the only output. The first hit wins, so the list goes from specific to generic.

The amount field handles a wrinkle I did not anticipate: the same merchant string can mean different things depending on the amount. My Ionity charging is billed under one name, but €5.99 and €11.99 are two different subscription tiers, and anything else is an actual charging session:

Match(" IONITY", "Ionity Power Monthly", "Expenses:Car:Ionity:SubMotion",
      amount=Decimal("5.99"), tags="subscription-monthly:"),
Match(" IONITY", "Ionity Power Monthly", "Expenses:Car:Ionity:SubPower",
      amount=Decimal("11.99"), tags="subscription-monthly:"),
Match(" IONITY", "Ionity Charge", "Expenses:Car:Ionity:Charge39"),

The price for the charge can be €0.39, €0.49 or €0.65. I currently haven't automated this based on the previous monthly subscription. So I change the name of the account manually.

The real transaction date

In 2022 I wrote "I chose to only use the effective date" but a few weeks ago this annoyed me too much. Some subscription cycle calculations were off by a few days, because a card payment is booked a day or two after I actually swiped the card.

ING hides the real date in the VISA text as KAUFUMSATZ DD.MM (without the year). So I pull it back out:

KAUFUMSATZ_RE = re.compile(r"KAUFUMSATZ (\d{2})\.(\d{2})")

def kaufumsatz_date(comment: str, booking: date) -> date | None:
    m = KAUFUMSATZ_RE.search(comment)
    if not m:
        return None
    day, month = int(m.group(1)), int(m.group(2))
    try:
        d = date(booking.year, month, day)
    except ValueError:
        return None
    # KAUFUMSATZ always precedes booking; roll back a year across Jan/Dec wrap.
    return d.replace(year=booking.year - 1) if d > booking else d

The year is filled from the booking date and roll back one year if that would put the transaction after its own booking (the December/January wrap).

I only apply this where it matters -- the car charging and subscription entries, whose cycle analysis is day-sensitive. Everything else keeps the booking date, emitted as a date: tag only when it actually differs, so the journal stays uncluttered.

The thing that makes that analysis possible is not a tag but the account names themselves. Look back at the Ionity matches: Expenses:Car:Ionity:SubPower marks a monthly subscription tier, and Expenses:Car:Ionity:Charge39 is a charging session where the 39 is the price -- €0.39/kWh, encoded in the account name. My analysis script just asks hledger for Expenses:Car:.*:(Sub|Charge).* and reads the rate straight out of the account string. That structured naming is exactly what feeds my post on Ionity subscription calculations. Now I have exact dates which I didn't have when writing the Ionity post.

Adding a second bank: Revolut

I have had a Revolut account for years. For the first years of the account I manually converted the CSV export to transactions. I didn't use the card that much so this was not an issue. But since then I started to use virtual credit cards more often and this manual process kept me from updating my ledger files for quite a while now.

The CSV is a completely different shape from ING's: ISO dates, international number format, and columns for Type, Product and Fee. The conversion itself is the same idea as before; the interesting part turned out to be deciding what not to book.

  • Interest accrues on the savings pot in hundreds of tiny rows. I ignore all of them (for now).

  • The file contains the savings account's own view of every transfer as Product = Deposit rows. Those mirror the Current side I already book, so booking both would double-count. I keep only Current.

  • A couple of net-zero "balance migration to another region or legal entity" transfers are pure noise and get dropped.

Card payments map through the same match table. Revolut reports its small FX fee in a separate column, so it becomes its own posting whenever it is non-zero:

# Card Payment | GitHub
2025-06-28 Github
    Expenses:edu:simonwillison                 €8.57  ; subscription-monthly:
    Expenses:misc:ExchangeFee                  €0.09
    Assets:Revolut:Euro

Top-ups bridge to my ING account (Assets:Girokonto), and savings moves stay internal between Assets:Revolut:Euro and Assets:Revolut:InstantAccessSavings.

I am still not tracking the accounts of my investment depots. Only the money I transfer there. This is a task for another day.

Tracking a hackerspace's open status in Home Assistant via SpaceAPI

A lot of hackerspaces publish their door state through SpaceAPI: a small JSON document with -- among other things -- a state.open boolean. My local space Essembly does too, at essembly.de/spaceapi.json:

{
  "api_compatibility": ["14", "15"],
  "space": "Essembly",
  "state": { "open": false }
}

I wanted that open/closed state in Home Assistant, using only built-in integrations.

There is an official SpaceAPI integration, but it goes the wrong way: it publishes your Home Assistant instance as a SpaceAPI endpoint. Cool for running a space, but no help for reading someone else's.

The core RESTful integration is all that is needed. My configuration.yaml already splits sensors into a folder:

sensor: !include_dir_merge_list includes/sensors

So the whole thing is one file, includes/sensors/essembly.yaml, as a list item (every file in a merge_list directory must be a list):

- platform: rest
  name: Essembly Space
  resource: https://essembly.de/spaceapi.json
  value_template: "{{ 'open' if value_json.state.open else 'closed' }}"
  scan_interval: 300

The value_template turns the boolean into a plain open/closed string; scan_interval: 300 polls every five minutes instead of the default 30 seconds.

A full reload was needed for the yaml file to be loaded.

essembly-state