feat: Add original layout envelope save codec with encoding/decoding and tests

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 14:57:36 +01:00
parent db06f5f5cb
commit de8bff1964
5 changed files with 244 additions and 17 deletions
@@ -111,6 +111,14 @@ class SaveGameCodec {
return running;
}
Uint8List encodeSnapshotPayload(GameSessionSnapshot snapshot) {
return _encodeSnapshot(snapshot);
}
GameSessionSnapshot decodeSnapshotPayload(Uint8List payload) {
return _decodeSnapshot(payload);
}
Uint8List _encodeSnapshot(GameSessionSnapshot snapshot) {
final _BinaryWriter writer = _BinaryWriter();
@@ -478,6 +486,98 @@ class SaveGameCodec {
}
}
/// Step toward source-layout compatibility: classic save-style envelope.
///
/// This writes a fixed 32-byte description prefix and stores a checksum over
/// the payload body using canonical DoChecksum semantics.
class OriginalLayoutEnvelopeSaveGameCodec extends SaveGameCodec {
static const int _envelopeVersion = 1;
static const int _descriptionBytes = 32;
static const List<int> _signature = <int>[0x57, 0x4C, 0x46, 0x53]; // WLFS
@override
Uint8List encode(SaveGameFile file) {
final _BinaryWriter writer = _BinaryWriter();
final Uint8List body = encodeSnapshotPayload(file.snapshot);
final int checksum = doChecksum(body);
writer.writeFixedUtf8(file.description, _descriptionBytes);
writer.writeBytes(_signature);
writer.writeUint16(_envelopeVersion);
writer.writeUint8(file.gameVersion.index);
writer.writeUint8(file.slot & 0xFF);
writer.writeInt64(file.createdAtMs);
writer.writeUtf8(file.dataVersionName);
writer.writeUint32(body.length);
writer.writeBytes(body);
writer.writeUint32(checksum);
return writer.toBytes();
}
@override
SaveGameFile decode(Uint8List bytes) {
final _BinaryReader reader = _BinaryReader(bytes);
final String description = reader.readFixedUtf8(_descriptionBytes);
final Uint8List signature = reader.readBytes(_signature.length);
if (!_listEquals(signature, _signature)) {
throw const FormatException('Invalid original-layout save signature.');
}
final int envelopeVersion = reader.readUint16();
if (envelopeVersion != _envelopeVersion) {
throw FormatException(
'Unsupported original-layout envelope version: $envelopeVersion',
);
}
final int gameVersionIndex = reader.readUint8();
if (gameVersionIndex < 0 || gameVersionIndex >= GameVersion.values.length) {
throw const FormatException('Invalid game version in save envelope.');
}
final GameVersion gameVersion = GameVersion.values[gameVersionIndex];
final int slot = reader.readUint8();
final int createdAtMs = reader.readInt64();
final String dataVersionName = reader.readUtf8();
final int bodyLength = reader.readUint32();
final Uint8List body = reader.readBytes(bodyLength);
final int checksum = reader.readUint32();
final int computed = doChecksum(body);
if (computed != checksum) {
throw FormatException(
'Save checksum mismatch. expected=$checksum actual=$computed',
);
}
final snapshot = decodeSnapshotPayload(body);
return SaveGameFile(
slot: slot,
gameVersion: gameVersion,
dataVersionName: dataVersionName,
description: description,
createdAtMs: createdAtMs,
snapshot: snapshot,
checksum: checksum,
);
}
@override
bool _listEquals(List<int> a, List<int> b) {
if (a.length != b.length) {
return false;
}
for (int i = 0; i < a.length; i++) {
if (a[i] != b[i]) {
return false;
}
}
return true;
}
}
class _BinaryWriter {
final BytesBuilder _builder = BytesBuilder(copy: false);
@@ -149,11 +149,14 @@ class WolfEngine {
bool _isSaveLoadBusy = false;
String? _lastSaveLoadError;
bool _hasLoadableSave = false;
bool get isSaveLoadBusy => _isSaveLoadBusy;
String? get lastSaveLoadError => _lastSaveLoadError;
bool get hasLoadableSave => _hasLoadableSave;
/// Host-reported mode/effect capabilities that drive menu visibility.
WolfRendererCapabilities rendererCapabilities;
@@ -239,6 +242,7 @@ class WolfEngine {
initialEpisodeIndex: _currentEpisodeIndex,
initialDifficulty: difficulty,
hasResumableGame: false,
hasLoadableSave: _hasLoadableSave,
initialGameIsRetail: data.version == GameVersion.retail,
);
@@ -253,6 +257,10 @@ class WolfEngine {
}
isInitialized = true;
if (saveGamePersistence != null) {
unawaited(_refreshLoadGameAvailability());
}
}
/// Whether a menu overlay is currently blocking gameplay updates.
@@ -290,6 +298,7 @@ class WolfEngine {
version: data.version,
bytes: bytes,
);
_setLoadGameAvailability(true);
return true;
} catch (e) {
_lastSaveLoadError = e.toString();
@@ -314,6 +323,7 @@ class WolfEngine {
);
if (bytes == null || bytes.isEmpty) {
_lastSaveLoadError = 'No save found in slot $slot.';
unawaited(_refreshLoadGameAvailability());
return false;
}
@@ -345,6 +355,7 @@ class WolfEngine {
}
restoreSaveState(snapshot);
_setLoadGameAvailability(true);
return true;
} catch (e) {
_lastSaveLoadError = e.toString();
@@ -469,10 +480,39 @@ class WolfEngine {
.toList(growable: true);
if (_isMenuOverlayVisible) {
menuManager.showMainMenu(hasResumableGame: true);
menuManager.showMainMenu(
hasResumableGame: true,
hasLoadableSave: _hasLoadableSave,
);
}
}
void _setLoadGameAvailability(bool isAvailable) {
_hasLoadableSave = isAvailable;
menuManager.setLoadGameAvailable(isAvailable);
}
Future<void> _refreshLoadGameAvailability() async {
if (saveGamePersistence == null) {
_setLoadGameAvailability(false);
return;
}
bool hasSave = false;
for (final game in _availableGames) {
final Uint8List? bytes = await saveGamePersistence!.load(
slot: defaultSaveSlot,
version: game.version,
);
if (bytes != null && bytes.isNotEmpty) {
hasSave = true;
break;
}
}
_setLoadGameAvailability(hasSave);
}
GameSessionSnapshot _snapshotWithGameIndex(
GameSessionSnapshot snapshot,
int gameIndex,
@@ -922,7 +962,10 @@ class WolfEngine {
void _tickChangeViewMenu(EngineInput input) {
final menuResult = menuManager.updateChangeViewMenu(input);
if (menuResult.goBack) {
menuManager.showMainMenu(hasResumableGame: _hasActiveSession);
menuManager.showMainMenu(
hasResumableGame: _hasActiveSession,
hasLoadableSave: _hasLoadableSave,
);
return;
}
@@ -988,7 +1031,10 @@ class WolfEngine {
}
isMapOverlayVisible = false;
_isMenuOverlayVisible = true;
menuManager.showMainMenu(hasResumableGame: true);
menuManager.showMainMenu(
hasResumableGame: true,
hasLoadableSave: _hasLoadableSave,
);
}
void _resumeGame() {
@@ -1013,7 +1059,10 @@ class WolfEngine {
_hasActiveSession = false;
_returnLevelIndex = null;
onEpisodeSelected?.call(null);
menuManager.showMainMenu(hasResumableGame: false);
menuManager.showMainMenu(
hasResumableGame: false,
hasLoadableSave: _hasLoadableSave,
);
}
void _exitTopLevelMenu() {
@@ -132,6 +132,7 @@ class MenuManager {
List<WolfMenuRendererOptionEntry> _rendererOptionEntries =
const <WolfMenuRendererOptionEntry>[];
bool _showResumeOption = false;
bool _hasLoadableSave = false;
int _gameCount = 1;
bool _prevUp = false;
@@ -334,11 +335,13 @@ class MenuManager {
int initialEpisodeIndex = 0,
Difficulty? initialDifficulty,
bool hasResumableGame = false,
bool hasLoadableSave = false,
bool initialGameIsRetail = false,
WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade,
}) {
_gameCount = gameCount;
_showResumeOption = hasResumableGame;
_hasLoadableSave = hasLoadableSave;
_selectedMainIndex = _defaultMainMenuIndex();
_selectedGameIndex = _clampIndex(initialGameIndex, gameCount);
_selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex;
@@ -400,8 +403,14 @@ class MenuManager {
_activeMenu = WolfMenuScreen.difficultySelect;
}
void showMainMenu({required bool hasResumableGame}) {
void showMainMenu({
required bool hasResumableGame,
bool? hasLoadableSave,
}) {
_showResumeOption = hasResumableGame;
if (hasLoadableSave != null) {
_hasLoadableSave = hasLoadableSave;
}
final int itemCount = mainMenuEntries.length;
if (itemCount == 0) {
_selectedMainIndex = 0;
@@ -418,6 +427,21 @@ class MenuManager {
_resetEdgeState();
}
void setLoadGameAvailable(bool isAvailable) {
if (_hasLoadableSave == isAvailable) {
return;
}
_hasLoadableSave = isAvailable;
final int itemCount = mainMenuEntries.length;
if (itemCount <= 0 || !_isSelectableMainIndex(_selectedMainIndex)) {
_selectedMainIndex = _findSelectableIndex(
_clampIndex(_selectedMainIndex, itemCount),
itemCount,
_isSelectableMainIndex,
);
}
}
int get _changeViewItemCount =>
_changeViewEntries.length + _rendererOptionEntries.length;
@@ -986,6 +1010,9 @@ class MenuManager {
required String label,
}) {
bool isEnabled = _isWiredMainMenuAction(action);
if (action == WolfMenuMainAction.loadGame) {
isEnabled = isEnabled && _hasLoadableSave;
}
if (action == WolfMenuMainAction.saveGame) {
isEnabled = isEnabled && _showResumeOption;
}