Compare commits

..

3 Commits

Author SHA1 Message Date
hans 3b1f8c80d1 Enhance save game codec tests for compatibility and add DOS-style file writing test
- Updated assertions in existing tests to allow for multiple valid values for `slot` and `createdAtMs` to accommodate legacy data.
- Added a new test to verify that the CompatibleSaveGameCodec correctly writes DOS-style description-prefixed files, ensuring proper encoding and structure.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 15:34:10 +01:00
hans 1ed63d5f9b feat: Implement CompatibleSaveGameCodec for block payload format and legacy support
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 15:18:49 +01:00
hans 85fddd3df5 feat: Add CompatibleSaveGameCodec for legacy W3DS support and enhance SaveGamePersistence with existence check
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
2026-03-23 15:14:46 +01:00
5 changed files with 1309 additions and 17 deletions
File diff suppressed because it is too large Load Diff
@@ -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,111 @@ 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, anyOf(0, 1));
expect(decoded.description, 'Legacy Save');
expect(decoded.createdAtMs, anyOf(0, 777));
});
test('CompatibleSaveGameCodec round-trips with block payload format', () {
final WolfEngine engine = _buildEngine();
engine.init();
final CompatibleSaveGameCodec codec = CompatibleSaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 2,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Compatible Block Save',
createdAtMs: 1234,
snapshot: engine.captureSaveState(),
checksum: 0,
);
final Uint8List encoded = codec.encode(file);
final SaveGameFile decoded = codec.decode(encoded);
expect(decoded.slot, 0);
expect(decoded.description, 'Compatible Block Save');
expect(decoded.createdAtMs, 0);
expect(
decoded.snapshot.currentEpisodeIndex,
file.snapshot.currentEpisodeIndex,
);
expect(decoded.snapshot.currentLevelIndex, file.snapshot.currentLevelIndex);
});
test('CompatibleSaveGameCodec decodes old envelope payload format', () {
final WolfEngine engine = _buildEngine();
engine.init();
final OriginalLayoutEnvelopeSaveGameCodec oldEnvelopeCodec =
OriginalLayoutEnvelopeSaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 4,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'Old Envelope Save',
createdAtMs: 333,
snapshot: engine.captureSaveState(),
checksum: 0,
);
final Uint8List oldEncoded = oldEnvelopeCodec.encode(file);
final CompatibleSaveGameCodec compatibleCodec = CompatibleSaveGameCodec();
final SaveGameFile decoded = compatibleCodec.decode(oldEncoded);
expect(decoded.slot, anyOf(0, 4));
expect(decoded.description, 'Old Envelope Save');
expect(decoded.createdAtMs, anyOf(0, 333));
});
test(
'CompatibleSaveGameCodec writes DOS-style description-prefixed files',
() {
final WolfEngine engine = _buildEngine();
engine.init();
final CompatibleSaveGameCodec codec = CompatibleSaveGameCodec();
final SaveGameFile file = SaveGameFile(
slot: 0,
gameVersion: engine.data.version,
dataVersionName: engine.data.dataVersion.name,
description: 'DOS Layout Save',
createdAtMs: 222,
snapshot: engine.captureSaveState(),
checksum: 0,
);
final Uint8List encoded = codec.encode(file);
expect(encoded.length, greaterThan(32));
expect(String.fromCharCodes(encoded.sublist(0, 3)), 'DOS');
expect(
encoded[32],
isNot(equals(0x57)),
); // not WLFS signature at offset 32
},
);
} }
class _TestInput extends Wolf3dInput { class _TestInput extends Wolf3dInput {