- A failed final flush (a full disk) or a failed rename into place was
reported as "Saved State". A failed load undo rename still pointed load
undo at the current game, while the file was another game's state.
- Refusing a save or load for being online didn't call the callback,
unlike every other refusal.
- A rewind dropped after its decode started stayed active, and stopped
rewind snapshots until the next clear.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Saving and loading stalled the emu thread for 30-75 ms, long enough to
glitch audio. Now only the snapshot and the apply run there; compression
and file I/O run on a savestate worker thread, in order.
- Save: snapshot in Process(), then compress, write and (for a slot) move
the old state to its undo name on the worker. The callback runs once the
file is written. Saving again to a path with a save pending is a repeat
key press, and gets that save's result without a message.
- Load: read and decompress on the worker while the game runs, then apply.
A slot load keeps the state it replaces for load undo at that point,
rather than saving it in full before starting.
- Rewind: compression and decoding are worker jobs, and the deltas get
zstd level 1 on top, which makes them about a third of the size.
- At most two save snapshots are in memory at once, and buffers are freed
when their operation is done.
- Shutdown waits for pending writes, and drops callbacks as before.
On desktop, a save now costs the emu thread 2-3 ms, a load or rewind
12-15 ms.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A rewind used up the state it restored, so rewinding, playing on a bit and
rewinding again went further back than the state you just came from. Now
the state stays, and only a rewind within 0.8 seconds of the last one
steps back past it.
"Seconds ago" was wall-clock time since the snapshot, which counted
pauses, fast-forward and time spent in states rewound to earlier. It's now
emulated time from where the rewinding started.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Rewind:
- first_ never wrapped, so after about 40 snapshots rewinding past the
oldest state jumped back to the newest ones. Track a count instead.
- States whose base had been replaced stayed in the ring, and each one
failed a rewind with an empty error. Drop them when the base goes.
- Rewinding after pausing for longer than the snapshot interval restored a
snapshot taken just before the rewind ran.
- Rewind snapshots (and verification) counted as saves for IsStale().
Slots:
- Loading an empty slot replaced the load undo backup before failing.
- A refused save (hardcore mode) rotated the slot's screenshots first.
- Refused or dropped operations never called their callback, so auto-load
didn't notice it had failed.
- The slot listing went stale after a save until something rescanned.
Also: Verify always failed (its write pass never set the buffer start),
and corrupt or truncated files were reported as being from an older
version.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Save() hands the newest state to a compress thread and returns. A rewind
right after could take lock_ first and decode that slot's old buffer
against the new base, loading a garbage state.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GetCurrentDrawAsDebugVertices (GE debugger vertex preview) sized its index scratch
buffer at a fixed 65536 and then ran both expanding steps into it: index generation
turns strips/fans into up to 3 indices per input index, and RunSoftwareTransform can
then expand points/lines/rects into 6 more each. A 30000-vertex triangle strip wrote
~90000 entries. Size the buffer from the count instead.
The Expand{Rectangles,Lines,Points} capacity checks were also off: they compared the
expansion against indsSize but write the expanded indices at inds + vertexCount, so
the input count has to be part of the sum.
ControlMapper::Axis wrote rawAxisValue_[axis.axisId] with no bounds check, one line
below an explicit check on axis.deviceId. axisId comes straight from the device -
Android reports AXIS_GENERIC_13..16 as 44..47, against a 44-entry array - so it wrote
into the neighbouring deviceTimestamps_. NativeAxis had the same unchecked write into
HLEPlugins::PluginDataAxis, where it goes out of the object entirely.
Rewind's LockedDecompress computed its copy-from-base block size as
base.size() - result.size() in size_t and truncated to int, so it went negative once
the output grew past the base, and insert() then ran with last < first. That happens
because a state can outlive the base it was compressed against: there are 20 states
but only 2 bases, rotated every 16 saves. Track a generation per base and refuse to
decode a state whose base is gone, and bound the block size against the base itself.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DCPmm7FoQUoqrbMdhfqhQ2