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:
@@ -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<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 {
|
||||
final BytesBuilder _builder = BytesBuilder(copy: false);
|
||||
|
||||
|
||||
@@ -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<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 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() {
|
||||
|
||||
@@ -132,6 +132,7 @@ class MenuManager {
|
||||
List<WolfMenuRendererOptionEntry> _rendererOptionEntries =
|
||||
const <WolfMenuRendererOptionEntry>[];
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user