diff --git a/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart b/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart index e67e11c..867c49e 100644 --- a/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart +++ b/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart @@ -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 _signature = [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 a, List 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); diff --git a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart index 82da94d..1e3af0b 100644 --- a/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart +++ b/packages/wolf_3d_dart/lib/src/engine/wolf_3d_engine_base.dart @@ -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 _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() { diff --git a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart index b6f2196..7c38dbc 100644 --- a/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart +++ b/packages/wolf_3d_dart/lib/src/menu/menu_manager.dart @@ -132,6 +132,7 @@ class MenuManager { List _rendererOptionEntries = const []; 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; } diff --git a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart index 303a182..0ca3e2d 100644 --- a/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart +++ b/packages/wolf_3d_dart/test/engine/level_state_and_pause_menu_test.dart @@ -154,7 +154,7 @@ void main() { engine.menuManager.mainMenuEntries .map((entry) => entry.isEnabled) .toList(), - [true, false, false, true, false, true, false, false, true, true], + [true, false, false, false, false, true, false, false, true, true], ); input.isInteracting = true; @@ -194,7 +194,7 @@ void main() { engine.menuManager.mainMenuEntries .map((entry) => entry.isEnabled) .toList(), - [true, false, false, true, false, true, false, false, true, true], + [true, false, false, false, false, true, false, false, true, true], ); input.isInteracting = true; @@ -242,7 +242,7 @@ void main() { engine.menuManager.mainMenuEntries .map((entry) => entry.isEnabled) .toList(), - [true, false, false, true, true, true, false, true, true, true], + [true, false, false, false, true, true, false, true, true, true], ); input.isMovingForward = true; @@ -273,10 +273,6 @@ void main() { 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()); expect(manager.selectedMainIndex, 5); @@ -292,6 +288,10 @@ void main() { manager.updateMainMenu(const EngineInput(isMovingBackward: true)); manager.updateMainMenu(const EngineInput()); 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', () { @@ -367,11 +367,6 @@ void main() { input.isMovingBackward = false; 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); input.isInteracting = true; diff --git a/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart b/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart index c275db4..c2ab9f3 100644 --- a/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart +++ b/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart @@ -84,6 +84,62 @@ void main() { throwsA(isA()), ); }); + + 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()), + ); + }); } class _TestInput extends Wolf3dInput {