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; return running;
} }
Uint8List encodeSnapshotPayload(GameSessionSnapshot snapshot) {
return _encodeSnapshot(snapshot);
}
GameSessionSnapshot decodeSnapshotPayload(Uint8List payload) {
return _decodeSnapshot(payload);
}
Uint8List _encodeSnapshot(GameSessionSnapshot snapshot) { Uint8List _encodeSnapshot(GameSessionSnapshot snapshot) {
final _BinaryWriter writer = _BinaryWriter(); 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 { class _BinaryWriter {
final BytesBuilder _builder = BytesBuilder(copy: false); final BytesBuilder _builder = BytesBuilder(copy: false);
@@ -149,11 +149,14 @@ class WolfEngine {
bool _isSaveLoadBusy = false; bool _isSaveLoadBusy = false;
String? _lastSaveLoadError; String? _lastSaveLoadError;
bool _hasLoadableSave = false;
bool get isSaveLoadBusy => _isSaveLoadBusy; bool get isSaveLoadBusy => _isSaveLoadBusy;
String? get lastSaveLoadError => _lastSaveLoadError; String? get lastSaveLoadError => _lastSaveLoadError;
bool get hasLoadableSave => _hasLoadableSave;
/// Host-reported mode/effect capabilities that drive menu visibility. /// Host-reported mode/effect capabilities that drive menu visibility.
WolfRendererCapabilities rendererCapabilities; WolfRendererCapabilities rendererCapabilities;
@@ -239,6 +242,7 @@ class WolfEngine {
initialEpisodeIndex: _currentEpisodeIndex, initialEpisodeIndex: _currentEpisodeIndex,
initialDifficulty: difficulty, initialDifficulty: difficulty,
hasResumableGame: false, hasResumableGame: false,
hasLoadableSave: _hasLoadableSave,
initialGameIsRetail: data.version == GameVersion.retail, initialGameIsRetail: data.version == GameVersion.retail,
); );
@@ -253,6 +257,10 @@ class WolfEngine {
} }
isInitialized = true; isInitialized = true;
if (saveGamePersistence != null) {
unawaited(_refreshLoadGameAvailability());
}
} }
/// Whether a menu overlay is currently blocking gameplay updates. /// Whether a menu overlay is currently blocking gameplay updates.
@@ -290,6 +298,7 @@ class WolfEngine {
version: data.version, version: data.version,
bytes: bytes, bytes: bytes,
); );
_setLoadGameAvailability(true);
return true; return true;
} catch (e) { } catch (e) {
_lastSaveLoadError = e.toString(); _lastSaveLoadError = e.toString();
@@ -314,6 +323,7 @@ class WolfEngine {
); );
if (bytes == null || bytes.isEmpty) { if (bytes == null || bytes.isEmpty) {
_lastSaveLoadError = 'No save found in slot $slot.'; _lastSaveLoadError = 'No save found in slot $slot.';
unawaited(_refreshLoadGameAvailability());
return false; return false;
} }
@@ -345,6 +355,7 @@ class WolfEngine {
} }
restoreSaveState(snapshot); restoreSaveState(snapshot);
_setLoadGameAvailability(true);
return true; return true;
} catch (e) { } catch (e) {
_lastSaveLoadError = e.toString(); _lastSaveLoadError = e.toString();
@@ -469,10 +480,39 @@ class WolfEngine {
.toList(growable: true); .toList(growable: true);
if (_isMenuOverlayVisible) { 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 _snapshotWithGameIndex(
GameSessionSnapshot snapshot, GameSessionSnapshot snapshot,
int gameIndex, int gameIndex,
@@ -922,7 +962,10 @@ class WolfEngine {
void _tickChangeViewMenu(EngineInput input) { void _tickChangeViewMenu(EngineInput input) {
final menuResult = menuManager.updateChangeViewMenu(input); final menuResult = menuManager.updateChangeViewMenu(input);
if (menuResult.goBack) { if (menuResult.goBack) {
menuManager.showMainMenu(hasResumableGame: _hasActiveSession); menuManager.showMainMenu(
hasResumableGame: _hasActiveSession,
hasLoadableSave: _hasLoadableSave,
);
return; return;
} }
@@ -988,7 +1031,10 @@ class WolfEngine {
} }
isMapOverlayVisible = false; isMapOverlayVisible = false;
_isMenuOverlayVisible = true; _isMenuOverlayVisible = true;
menuManager.showMainMenu(hasResumableGame: true); menuManager.showMainMenu(
hasResumableGame: true,
hasLoadableSave: _hasLoadableSave,
);
} }
void _resumeGame() { void _resumeGame() {
@@ -1013,7 +1059,10 @@ class WolfEngine {
_hasActiveSession = false; _hasActiveSession = false;
_returnLevelIndex = null; _returnLevelIndex = null;
onEpisodeSelected?.call(null); onEpisodeSelected?.call(null);
menuManager.showMainMenu(hasResumableGame: false); menuManager.showMainMenu(
hasResumableGame: false,
hasLoadableSave: _hasLoadableSave,
);
} }
void _exitTopLevelMenu() { void _exitTopLevelMenu() {
@@ -132,6 +132,7 @@ class MenuManager {
List<WolfMenuRendererOptionEntry> _rendererOptionEntries = List<WolfMenuRendererOptionEntry> _rendererOptionEntries =
const <WolfMenuRendererOptionEntry>[]; const <WolfMenuRendererOptionEntry>[];
bool _showResumeOption = false; bool _showResumeOption = false;
bool _hasLoadableSave = false;
int _gameCount = 1; int _gameCount = 1;
bool _prevUp = false; bool _prevUp = false;
@@ -334,11 +335,13 @@ class MenuManager {
int initialEpisodeIndex = 0, int initialEpisodeIndex = 0,
Difficulty? initialDifficulty, Difficulty? initialDifficulty,
bool hasResumableGame = false, bool hasResumableGame = false,
bool hasLoadableSave = false,
bool initialGameIsRetail = false, bool initialGameIsRetail = false,
WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade, WolfTransitionEffect introEffect = WolfTransitionEffect.normalFade,
}) { }) {
_gameCount = gameCount; _gameCount = gameCount;
_showResumeOption = hasResumableGame; _showResumeOption = hasResumableGame;
_hasLoadableSave = hasLoadableSave;
_selectedMainIndex = _defaultMainMenuIndex(); _selectedMainIndex = _defaultMainMenuIndex();
_selectedGameIndex = _clampIndex(initialGameIndex, gameCount); _selectedGameIndex = _clampIndex(initialGameIndex, gameCount);
_selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex; _selectedEpisodeIndex = initialEpisodeIndex < 0 ? 0 : initialEpisodeIndex;
@@ -400,8 +403,14 @@ class MenuManager {
_activeMenu = WolfMenuScreen.difficultySelect; _activeMenu = WolfMenuScreen.difficultySelect;
} }
void showMainMenu({required bool hasResumableGame}) { void showMainMenu({
required bool hasResumableGame,
bool? hasLoadableSave,
}) {
_showResumeOption = hasResumableGame; _showResumeOption = hasResumableGame;
if (hasLoadableSave != null) {
_hasLoadableSave = hasLoadableSave;
}
final int itemCount = mainMenuEntries.length; final int itemCount = mainMenuEntries.length;
if (itemCount == 0) { if (itemCount == 0) {
_selectedMainIndex = 0; _selectedMainIndex = 0;
@@ -418,6 +427,21 @@ class MenuManager {
_resetEdgeState(); _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 => int get _changeViewItemCount =>
_changeViewEntries.length + _rendererOptionEntries.length; _changeViewEntries.length + _rendererOptionEntries.length;
@@ -986,6 +1010,9 @@ class MenuManager {
required String label, required String label,
}) { }) {
bool isEnabled = _isWiredMainMenuAction(action); bool isEnabled = _isWiredMainMenuAction(action);
if (action == WolfMenuMainAction.loadGame) {
isEnabled = isEnabled && _hasLoadableSave;
}
if (action == WolfMenuMainAction.saveGame) { if (action == WolfMenuMainAction.saveGame) {
isEnabled = isEnabled && _showResumeOption; isEnabled = isEnabled && _showResumeOption;
} }
@@ -154,7 +154,7 @@ void main() {
engine.menuManager.mainMenuEntries engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled) .map((entry) => entry.isEnabled)
.toList(), .toList(),
[true, false, false, true, false, true, false, false, true, true], [true, false, false, false, false, true, false, false, true, true],
); );
input.isInteracting = true; input.isInteracting = true;
@@ -194,7 +194,7 @@ void main() {
engine.menuManager.mainMenuEntries engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled) .map((entry) => entry.isEnabled)
.toList(), .toList(),
[true, false, false, true, false, true, false, false, true, true], [true, false, false, false, false, true, false, false, true, true],
); );
input.isInteracting = true; input.isInteracting = true;
@@ -242,7 +242,7 @@ void main() {
engine.menuManager.mainMenuEntries engine.menuManager.mainMenuEntries
.map((entry) => entry.isEnabled) .map((entry) => entry.isEnabled)
.toList(), .toList(),
[true, false, false, true, true, true, false, true, true, true], [true, false, false, false, true, true, false, true, true, true],
); );
input.isMovingForward = true; input.isMovingForward = true;
@@ -273,10 +273,6 @@ void main() {
expect(manager.selectedMainIndex, 0); expect(manager.selectedMainIndex, 0);
manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 3);
manager.updateMainMenu(const EngineInput(isMovingBackward: true)); manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput()); manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 5); expect(manager.selectedMainIndex, 5);
@@ -292,6 +288,10 @@ void main() {
manager.updateMainMenu(const EngineInput(isMovingBackward: true)); manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput()); manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 0); expect(manager.selectedMainIndex, 0);
manager.updateMainMenu(const EngineInput(isMovingBackward: true));
manager.updateMainMenu(const EngineInput());
expect(manager.selectedMainIndex, 5);
}); });
test('menu transition defaults to normal fade and can opt into fizzle', () { test('menu transition defaults to normal fade and can opt into fizzle', () {
@@ -367,11 +367,6 @@ void main() {
input.isMovingBackward = false; input.isMovingBackward = false;
engine.tick(const Duration(milliseconds: 16)); engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = true;
engine.tick(const Duration(milliseconds: 16));
input.isMovingBackward = false;
engine.tick(const Duration(milliseconds: 16));
expect(engine.menuManager.selectedMainIndex, 9); expect(engine.menuManager.selectedMainIndex, 9);
input.isInteracting = true; input.isInteracting = true;
@@ -84,6 +84,62 @@ void main() {
throwsA(isA<FormatException>()), throwsA(isA<FormatException>()),
); );
}); });
test('OriginalLayoutEnvelopeSaveGameCodec round-trips save metadata', () {
final WolfEngine engine = _buildEngine();
engine.init();
final OriginalLayoutEnvelopeSaveGameCodec codec =
OriginalLayoutEnvelopeSaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 3,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Classic Envelope',
createdAtMs: 999,
snapshot: engine.captureSaveState(),
checksum: 0,
);
final Uint8List encoded = codec.encode(file);
final SaveGameFile decoded = codec.decode(encoded);
expect(decoded.slot, 3);
expect(decoded.description, 'Classic Envelope');
expect(decoded.createdAtMs, 999);
expect(decoded.gameVersion, engine.data.version);
expect(
decoded.snapshot.activeEpisodeIndex,
file.snapshot.activeEpisodeIndex,
);
expect(decoded.snapshot.activeLevelIndex, file.snapshot.activeLevelIndex);
});
test('OriginalLayoutEnvelopeSaveGameCodec rejects invalid checksum', () {
final WolfEngine engine = _buildEngine();
engine.init();
final OriginalLayoutEnvelopeSaveGameCodec codec =
OriginalLayoutEnvelopeSaveGameCodec();
final Uint8List encoded = codec.encode(
SaveGameFile(
slot: 0,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Classic Checksum',
createdAtMs: 12,
snapshot: engine.captureSaveState(),
checksum: 0,
),
);
encoded[encoded.length - 1] ^= 0xFF;
expect(
() => codec.decode(encoded),
throwsA(isA<FormatException>()),
);
});
} }
class _TestInput extends Wolf3dInput { class _TestInput extends Wolf3dInput {