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 867c49e..47f89be 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 @@ -578,6 +578,21 @@ class OriginalLayoutEnvelopeSaveGameCodec extends SaveGameCodec { } } +/// Writes classic-envelope saves while remaining able to read legacy W3DS +/// saves created by earlier engine builds. +class CompatibleSaveGameCodec extends OriginalLayoutEnvelopeSaveGameCodec { + final SaveGameCodec _legacyCodec = SaveGameCodec(); + + @override + SaveGameFile decode(Uint8List bytes) { + try { + return super.decode(bytes); + } on FormatException { + return _legacyCodec.decode(bytes); + } + } +} + class _BinaryWriter { final BytesBuilder _builder = BytesBuilder(copy: false); diff --git a/packages/wolf_3d_dart/lib/src/engine/save/save_game_persistence.dart b/packages/wolf_3d_dart/lib/src/engine/save/save_game_persistence.dart index 4241dcd..035fd13 100644 --- a/packages/wolf_3d_dart/lib/src/engine/save/save_game_persistence.dart +++ b/packages/wolf_3d_dart/lib/src/engine/save/save_game_persistence.dart @@ -9,6 +9,12 @@ abstract class SaveGamePersistence { /// Loads raw bytes for [slot] and [version], or `null` when no save exists. Future load({required int slot, required GameVersion version}); + /// Returns whether a non-empty save exists for [slot] and [version]. + Future exists({required int slot, required GameVersion version}) async { + final Uint8List? bytes = await load(slot: slot, version: version); + return bytes != null && bytes.isNotEmpty; + } + /// Persists [bytes] for [slot] and [version]. Future save({ required int slot, 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 1e3af0b..88908d1 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 @@ -50,7 +50,7 @@ class WolfEngine { 'Provide either data or a non-empty availableGames list.', ), _availableGames = availableGames ?? [data!], - saveGameCodec = saveGameCodec ?? SaveGameCodec(), + saveGameCodec = saveGameCodec ?? CompatibleSaveGameCodec(), audio = engineAudio ?? CliSilentAudio(), doorManager = DoorManager( onPlaySound: (effect) => engineAudio?.playSoundEffect(effect), @@ -498,18 +498,10 @@ class WolfEngine { 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; - } - } - + final bool hasSave = await saveGamePersistence!.exists( + slot: defaultSaveSlot, + version: data.version, + ); _setLoadGameAvailability(hasSave); } @@ -921,6 +913,9 @@ class WolfEngine { _currentEpisodeIndex = 0; onEpisodeSelected?.call(null); menuManager.clearEpisodeSelection(); + if (saveGamePersistence != null) { + unawaited(_refreshLoadGameAvailability()); + } menuManager.beginIntroSplash( includeRetailWarning: data.version == GameVersion.retail, ); 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 0ca3e2d..e15fa11 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 @@ -402,6 +402,48 @@ void main() { expect(quitCalls, 0); expect(exitCalls, 1); }); + + test('load availability is scoped to active game version', () async { + final persistence = _InMemorySaveGamePersistence( + saves: { + _InMemorySaveGamePersistence.key( + slot: 0, + version: GameVersion.shareware, + ): Uint8List.fromList(const [ + 1, + ]), + }, + ); + + final retailEngine = WolfEngine( + data: _buildTestData(gameVersion: GameVersion.retail), + difficulty: null, + startingEpisode: 0, + frameBuffer: FrameBuffer(64, 64), + input: _TestInput(), + engineAudio: _SilentAudio(), + saveGamePersistence: persistence, + onGameWon: () {}, + ); + retailEngine.init(); + await Future.delayed(Duration.zero); + + final sharewareEngine = WolfEngine( + data: _buildTestData(gameVersion: GameVersion.shareware), + difficulty: null, + startingEpisode: 0, + frameBuffer: FrameBuffer(64, 64), + input: _TestInput(), + engineAudio: _SilentAudio(), + saveGamePersistence: persistence, + onGameWon: () {}, + ); + sharewareEngine.init(); + await Future.delayed(Duration.zero); + + expect(retailEngine.hasLoadableSave, isFalse); + expect(sharewareEngine.hasLoadableSave, isTrue); + }); }); } @@ -410,6 +452,7 @@ WolfEngine _buildMultiGameEngine({ required Difficulty? difficulty, void Function()? onMenuExit, void Function()? onQuit, + SaveGamePersistence? saveGamePersistence, }) { final WolfensteinData retail = _buildTestData( gameVersion: GameVersion.retail, @@ -425,6 +468,7 @@ WolfEngine _buildMultiGameEngine({ frameBuffer: FrameBuffer(64, 64), input: input, engineAudio: _SilentAudio(), + saveGamePersistence: saveGamePersistence, onGameWon: () {}, onMenuExit: onMenuExit, onQuit: onQuit, @@ -585,10 +629,43 @@ class _SilentAudio implements EngineAudio { void dispose() {} } +class _InMemorySaveGamePersistence implements SaveGamePersistence { + _InMemorySaveGamePersistence({Map? saves}) + : _saves = saves ?? {}; + + final Map _saves; + + static String key({required int slot, required GameVersion version}) => + '${version.name}:$slot'; + + @override + Future load({ + required int slot, + required GameVersion version, + }) async { + return _saves[key(slot: slot, version: version)]; + } + + @override + Future exists({required int slot, required GameVersion version}) async { + final Uint8List? bytes = _saves[key(slot: slot, version: version)]; + return bytes != null && bytes.isNotEmpty; + } + + @override + Future save({ + required int slot, + required GameVersion version, + required Uint8List bytes, + }) async { + _saves[key(slot: slot, version: version)] = Uint8List.fromList(bytes); + } +} + void _dismissIntroSplash(WolfEngine engine, _TestInput input) { int safety = 0; while (engine.menuManager.activeMenu == WolfMenuScreen.introSplash && - safety < 160) { + safety < 600) { input.isInteracting = safety.isEven; engine.tick(const Duration(milliseconds: 16)); safety++; 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 c2ab9f3..8ebc0bc 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 @@ -109,10 +109,10 @@ void main() { expect(decoded.createdAtMs, 999); expect(decoded.gameVersion, engine.data.version); expect( - decoded.snapshot.activeEpisodeIndex, - file.snapshot.activeEpisodeIndex, + decoded.snapshot.currentEpisodeIndex, + file.snapshot.currentEpisodeIndex, ); - expect(decoded.snapshot.activeLevelIndex, file.snapshot.activeLevelIndex); + expect(decoded.snapshot.currentLevelIndex, file.snapshot.currentLevelIndex); }); test('OriginalLayoutEnvelopeSaveGameCodec rejects invalid checksum', () { @@ -140,6 +140,30 @@ void main() { throwsA(isA()), ); }); + + test('CompatibleSaveGameCodec decodes legacy W3DS saves', () { + final WolfEngine engine = _buildEngine(); + engine.init(); + + final SaveGameCodec legacyCodec = SaveGameCodec(); + final SaveGameFile file = SaveGameFile( + slot: 1, + gameVersion: engine.data.version, + dataVersionName: engine.data.dataVersion.name, + description: 'Legacy Save', + createdAtMs: 777, + snapshot: engine.captureSaveState(), + checksum: 0, + ); + final Uint8List legacyBytes = legacyCodec.encode(file); + + final CompatibleSaveGameCodec compatibleCodec = CompatibleSaveGameCodec(); + final SaveGameFile decoded = compatibleCodec.decode(legacyBytes); + + expect(decoded.slot, 1); + expect(decoded.description, 'Legacy Save'); + expect(decoded.createdAtMs, 777); + }); } class _TestInput extends Wolf3dInput {