feat: Add CompatibleSaveGameCodec for legacy W3DS support and enhance SaveGamePersistence with existence check
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -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 {
|
class _BinaryWriter {
|
||||||
final BytesBuilder _builder = BytesBuilder(copy: false);
|
final BytesBuilder _builder = BytesBuilder(copy: false);
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ abstract class SaveGamePersistence {
|
|||||||
/// Loads raw bytes for [slot] and [version], or `null` when no save exists.
|
/// Loads raw bytes for [slot] and [version], or `null` when no save exists.
|
||||||
Future<Uint8List?> load({required int slot, required GameVersion version});
|
Future<Uint8List?> load({required int slot, required GameVersion version});
|
||||||
|
|
||||||
|
/// Returns whether a non-empty save exists for [slot] and [version].
|
||||||
|
Future<bool> 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].
|
/// Persists [bytes] for [slot] and [version].
|
||||||
Future<void> save({
|
Future<void> save({
|
||||||
required int slot,
|
required int slot,
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ class WolfEngine {
|
|||||||
'Provide either data or a non-empty availableGames list.',
|
'Provide either data or a non-empty availableGames list.',
|
||||||
),
|
),
|
||||||
_availableGames = availableGames ?? <WolfensteinData>[data!],
|
_availableGames = availableGames ?? <WolfensteinData>[data!],
|
||||||
saveGameCodec = saveGameCodec ?? SaveGameCodec(),
|
saveGameCodec = saveGameCodec ?? CompatibleSaveGameCodec(),
|
||||||
audio = engineAudio ?? CliSilentAudio(),
|
audio = engineAudio ?? CliSilentAudio(),
|
||||||
doorManager = DoorManager(
|
doorManager = DoorManager(
|
||||||
onPlaySound: (effect) => engineAudio?.playSoundEffect(effect),
|
onPlaySound: (effect) => engineAudio?.playSoundEffect(effect),
|
||||||
@@ -498,18 +498,10 @@ class WolfEngine {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool hasSave = false;
|
final bool hasSave = await saveGamePersistence!.exists(
|
||||||
for (final game in _availableGames) {
|
slot: defaultSaveSlot,
|
||||||
final Uint8List? bytes = await saveGamePersistence!.load(
|
version: data.version,
|
||||||
slot: defaultSaveSlot,
|
);
|
||||||
version: game.version,
|
|
||||||
);
|
|
||||||
if (bytes != null && bytes.isNotEmpty) {
|
|
||||||
hasSave = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_setLoadGameAvailability(hasSave);
|
_setLoadGameAvailability(hasSave);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -921,6 +913,9 @@ class WolfEngine {
|
|||||||
_currentEpisodeIndex = 0;
|
_currentEpisodeIndex = 0;
|
||||||
onEpisodeSelected?.call(null);
|
onEpisodeSelected?.call(null);
|
||||||
menuManager.clearEpisodeSelection();
|
menuManager.clearEpisodeSelection();
|
||||||
|
if (saveGamePersistence != null) {
|
||||||
|
unawaited(_refreshLoadGameAvailability());
|
||||||
|
}
|
||||||
menuManager.beginIntroSplash(
|
menuManager.beginIntroSplash(
|
||||||
includeRetailWarning: data.version == GameVersion.retail,
|
includeRetailWarning: data.version == GameVersion.retail,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -402,6 +402,48 @@ void main() {
|
|||||||
expect(quitCalls, 0);
|
expect(quitCalls, 0);
|
||||||
expect(exitCalls, 1);
|
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 <int>[
|
||||||
|
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<void>.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<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(retailEngine.hasLoadableSave, isFalse);
|
||||||
|
expect(sharewareEngine.hasLoadableSave, isTrue);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +452,7 @@ WolfEngine _buildMultiGameEngine({
|
|||||||
required Difficulty? difficulty,
|
required Difficulty? difficulty,
|
||||||
void Function()? onMenuExit,
|
void Function()? onMenuExit,
|
||||||
void Function()? onQuit,
|
void Function()? onQuit,
|
||||||
|
SaveGamePersistence? saveGamePersistence,
|
||||||
}) {
|
}) {
|
||||||
final WolfensteinData retail = _buildTestData(
|
final WolfensteinData retail = _buildTestData(
|
||||||
gameVersion: GameVersion.retail,
|
gameVersion: GameVersion.retail,
|
||||||
@@ -425,6 +468,7 @@ WolfEngine _buildMultiGameEngine({
|
|||||||
frameBuffer: FrameBuffer(64, 64),
|
frameBuffer: FrameBuffer(64, 64),
|
||||||
input: input,
|
input: input,
|
||||||
engineAudio: _SilentAudio(),
|
engineAudio: _SilentAudio(),
|
||||||
|
saveGamePersistence: saveGamePersistence,
|
||||||
onGameWon: () {},
|
onGameWon: () {},
|
||||||
onMenuExit: onMenuExit,
|
onMenuExit: onMenuExit,
|
||||||
onQuit: onQuit,
|
onQuit: onQuit,
|
||||||
@@ -585,10 +629,43 @@ class _SilentAudio implements EngineAudio {
|
|||||||
void dispose() {}
|
void dispose() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _InMemorySaveGamePersistence implements SaveGamePersistence {
|
||||||
|
_InMemorySaveGamePersistence({Map<String, Uint8List>? saves})
|
||||||
|
: _saves = saves ?? <String, Uint8List>{};
|
||||||
|
|
||||||
|
final Map<String, Uint8List> _saves;
|
||||||
|
|
||||||
|
static String key({required int slot, required GameVersion version}) =>
|
||||||
|
'${version.name}:$slot';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Uint8List?> load({
|
||||||
|
required int slot,
|
||||||
|
required GameVersion version,
|
||||||
|
}) async {
|
||||||
|
return _saves[key(slot: slot, version: version)];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<bool> exists({required int slot, required GameVersion version}) async {
|
||||||
|
final Uint8List? bytes = _saves[key(slot: slot, version: version)];
|
||||||
|
return bytes != null && bytes.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> 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) {
|
void _dismissIntroSplash(WolfEngine engine, _TestInput input) {
|
||||||
int safety = 0;
|
int safety = 0;
|
||||||
while (engine.menuManager.activeMenu == WolfMenuScreen.introSplash &&
|
while (engine.menuManager.activeMenu == WolfMenuScreen.introSplash &&
|
||||||
safety < 160) {
|
safety < 600) {
|
||||||
input.isInteracting = safety.isEven;
|
input.isInteracting = safety.isEven;
|
||||||
engine.tick(const Duration(milliseconds: 16));
|
engine.tick(const Duration(milliseconds: 16));
|
||||||
safety++;
|
safety++;
|
||||||
|
|||||||
@@ -109,10 +109,10 @@ void main() {
|
|||||||
expect(decoded.createdAtMs, 999);
|
expect(decoded.createdAtMs, 999);
|
||||||
expect(decoded.gameVersion, engine.data.version);
|
expect(decoded.gameVersion, engine.data.version);
|
||||||
expect(
|
expect(
|
||||||
decoded.snapshot.activeEpisodeIndex,
|
decoded.snapshot.currentEpisodeIndex,
|
||||||
file.snapshot.activeEpisodeIndex,
|
file.snapshot.currentEpisodeIndex,
|
||||||
);
|
);
|
||||||
expect(decoded.snapshot.activeLevelIndex, file.snapshot.activeLevelIndex);
|
expect(decoded.snapshot.currentLevelIndex, file.snapshot.currentLevelIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('OriginalLayoutEnvelopeSaveGameCodec rejects invalid checksum', () {
|
test('OriginalLayoutEnvelopeSaveGameCodec rejects invalid checksum', () {
|
||||||
@@ -140,6 +140,30 @@ void main() {
|
|||||||
throwsA(isA<FormatException>()),
|
throwsA(isA<FormatException>()),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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 {
|
class _TestInput extends Wolf3dInput {
|
||||||
|
|||||||
Reference in New Issue
Block a user