feat: Implement CompatibleSaveGameCodec for block payload format and legacy support

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 15:18:49 +01:00
parent 85fddd3df5
commit 1ed63d5f9b
2 changed files with 369 additions and 0 deletions
@@ -581,8 +581,291 @@ 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 {
static const List<int> _payloadSignature = <int>[0x57, 0x4C, 0x50, 0x53];
static const int _payloadVersion = 1;
static const int _blockSessionCore = 1;
static const int _blockPlayerState = 2;
static const int _blockCurrentLevel = 3;
static const int _blockAreaGrid = 4;
static const int _blockAreasByPlayer = 5;
static const int _blockEntities = 6;
static const int _blockDoors = 7;
static const int _blockPushwalls = 8;
final SaveGameCodec _legacyCodec = SaveGameCodec();
@override
Uint8List encodeSnapshotPayload(GameSessionSnapshot snapshot) {
final _BinaryWriter writer = _BinaryWriter();
writer.writeBytes(_payloadSignature);
writer.writeUint16(_payloadVersion);
writer.writeUint16(8);
_writeBlock(writer, _blockSessionCore, (block) {
block.writeInt32(snapshot.currentGameIndex);
block.writeInt32(snapshot.currentEpisodeIndex);
block.writeInt32(snapshot.currentLevelIndex);
block.writeBool(snapshot.returnLevelIndex != null);
block.writeInt32(snapshot.returnLevelIndex ?? -1);
block.writeInt32(snapshot.difficulty.index);
block.writeInt64(snapshot.timeAliveMs);
block.writeInt64(snapshot.lastAcousticAlertTime);
block.writeBool(snapshot.isMapOverlayVisible);
block.writeBool(snapshot.isMenuOverlayVisible);
});
_writeBlock(writer, _blockPlayerState, (block) {
_writePlayerState(block, snapshot.player);
});
_writeBlock(writer, _blockCurrentLevel, (block) {
_writeIntGrid(block, snapshot.currentLevel);
});
_writeBlock(writer, _blockAreaGrid, (block) {
_writeIntGrid(block, snapshot.areaGrid);
});
_writeBlock(writer, _blockAreasByPlayer, (block) {
final int count = snapshot.areasByPlayer.length;
block.writeUint32(count);
final Uint8List packed = Uint8List((count + 7) ~/ 8);
for (int i = 0; i < count; i++) {
if (snapshot.areasByPlayer[i]) {
packed[i >> 3] |= 1 << (i & 7);
}
}
block.writeUint32(packed.length);
block.writeBytes(packed);
});
_writeBlock(writer, _blockEntities, (block) {
block.writeUint32(snapshot.entities.length);
for (final EntitySaveState entity in snapshot.entities) {
block.writeUtf8(entity.kind);
block.writeFloat64(entity.x);
block.writeFloat64(entity.y);
block.writeInt32(entity.spriteIndex);
block.writeFloat64(entity.angle);
block.writeInt32(entity.state.index);
block.writeInt32(entity.mapId);
block.writeInt32(entity.lastActionTime);
block.writeUtf8(jsonEncode(entity.extraData));
}
});
_writeBlock(writer, _blockDoors, (block) {
block.writeUint32(snapshot.doors.length);
for (final DoorSaveState door in snapshot.doors) {
block.writeInt32(door.x);
block.writeInt32(door.y);
block.writeInt32(door.mapId);
block.writeInt32(door.state.index);
block.writeFloat64(door.offset);
block.writeInt32(door.openTime);
}
});
_writeBlock(writer, _blockPushwalls, (block) {
block.writeUint32(snapshot.pushwalls.length);
for (final PushwallSaveState pushwall in snapshot.pushwalls) {
block.writeInt32(pushwall.x);
block.writeInt32(pushwall.y);
block.writeInt32(pushwall.mapId);
block.writeInt32(pushwall.dirX);
block.writeInt32(pushwall.dirY);
block.writeFloat64(pushwall.offset);
block.writeInt32(pushwall.tilesMoved);
block.writeBool(pushwall.isActive);
}
});
return writer.toBytes();
}
@override
GameSessionSnapshot decodeSnapshotPayload(Uint8List payload) {
if (!_hasPayloadSignature(payload)) {
return super.decodeSnapshotPayload(payload);
}
final _BinaryReader reader = _BinaryReader(payload);
final Uint8List signature = reader.readBytes(_payloadSignature.length);
if (!_listEquals(signature, _payloadSignature)) {
throw const FormatException('Invalid compatible payload signature.');
}
final int payloadVersion = reader.readUint16();
if (payloadVersion != _payloadVersion) {
throw FormatException(
'Unsupported compatible payload version: $payloadVersion',
);
}
final int blockCount = reader.readUint16();
final Map<int, Uint8List> blocks = <int, Uint8List>{};
for (int i = 0; i < blockCount; i++) {
final int blockId = reader.readUint16();
final int blockLength = reader.readUint32();
blocks[blockId] = reader.readBytes(blockLength);
}
final _BinaryReader core = _BinaryReader(
_requiredBlock(blocks, _blockSessionCore),
);
final int currentGameIndex = core.readInt32();
final int currentEpisodeIndex = core.readInt32();
final int currentLevelIndex = core.readInt32();
final bool hasReturnLevel = core.readBool();
final int returnLevelRaw = core.readInt32();
final int difficultyIndex = core.readInt32();
if (difficultyIndex < 0 || difficultyIndex >= Difficulty.values.length) {
throw const FormatException('Invalid difficulty value in save payload.');
}
final Difficulty difficulty = Difficulty.values[difficultyIndex];
final int timeAliveMs = core.readInt64();
final int lastAcousticAlertTime = core.readInt64();
final bool isMapOverlayVisible = core.readBool();
final bool isMenuOverlayVisible = core.readBool();
final PlayerSaveState player = _readPlayerState(
_BinaryReader(_requiredBlock(blocks, _blockPlayerState)),
);
final List<List<int>> currentLevel = _readIntGrid(
_BinaryReader(_requiredBlock(blocks, _blockCurrentLevel)),
);
final List<List<int>> areaGrid = _readIntGrid(
_BinaryReader(_requiredBlock(blocks, _blockAreaGrid)),
);
final _BinaryReader areasReader = _BinaryReader(
_requiredBlock(blocks, _blockAreasByPlayer),
);
final int areasCount = areasReader.readUint32();
final int packedLength = areasReader.readUint32();
final Uint8List packedAreas = areasReader.readBytes(packedLength);
final List<bool> areasByPlayer = List<bool>.generate(
areasCount,
(int i) {
if ((i >> 3) >= packedAreas.length) {
return false;
}
return (packedAreas[i >> 3] & (1 << (i & 7))) != 0;
},
growable: false,
);
final _BinaryReader entityReader = _BinaryReader(
_requiredBlock(blocks, _blockEntities),
);
final int entityCount = entityReader.readUint32();
final List<EntitySaveState> entities = List<EntitySaveState>.generate(
entityCount,
(_) {
final String kind = entityReader.readUtf8();
final double x = entityReader.readFloat64();
final double y = entityReader.readFloat64();
final int spriteIndex = entityReader.readInt32();
final double angle = entityReader.readFloat64();
final int stateIndex = entityReader.readInt32();
final int mapId = entityReader.readInt32();
final int lastActionTime = entityReader.readInt32();
final String extraDataRaw = entityReader.readUtf8();
final Object? decoded = jsonDecode(extraDataRaw);
final Map<String, Object?> extraData = decoded is Map<String, Object?>
? decoded
: <String, Object?>{};
if (stateIndex < 0 || stateIndex >= EntityState.values.length) {
throw const FormatException('Invalid entity state index in save.');
}
return EntitySaveState(
kind: kind,
x: x,
y: y,
spriteIndex: spriteIndex,
angle: angle,
state: EntityState.values[stateIndex],
mapId: mapId,
lastActionTime: lastActionTime,
extraData: extraData,
);
},
growable: false,
);
final _BinaryReader doorReader = _BinaryReader(
_requiredBlock(blocks, _blockDoors),
);
final int doorCount = doorReader.readUint32();
final List<DoorSaveState> doors = List<DoorSaveState>.generate(
doorCount,
(_) {
final int x = doorReader.readInt32();
final int y = doorReader.readInt32();
final int mapId = doorReader.readInt32();
final int stateIndex = doorReader.readInt32();
final double offset = doorReader.readFloat64();
final int openTime = doorReader.readInt32();
if (stateIndex < 0 || stateIndex >= DoorState.values.length) {
throw const FormatException('Invalid door state index in save.');
}
return DoorSaveState(
x: x,
y: y,
mapId: mapId,
state: DoorState.values[stateIndex],
offset: offset,
openTime: openTime,
);
},
growable: false,
);
final _BinaryReader pushwallReader = _BinaryReader(
_requiredBlock(blocks, _blockPushwalls),
);
final int pushwallCount = pushwallReader.readUint32();
final List<PushwallSaveState> pushwalls = List<PushwallSaveState>.generate(
pushwallCount,
(_) => PushwallSaveState(
x: pushwallReader.readInt32(),
y: pushwallReader.readInt32(),
mapId: pushwallReader.readInt32(),
dirX: pushwallReader.readInt32(),
dirY: pushwallReader.readInt32(),
offset: pushwallReader.readFloat64(),
tilesMoved: pushwallReader.readInt32(),
isActive: pushwallReader.readBool(),
),
growable: false,
);
return GameSessionSnapshot(
currentGameIndex: currentGameIndex,
currentEpisodeIndex: currentEpisodeIndex,
currentLevelIndex: currentLevelIndex,
returnLevelIndex: hasReturnLevel ? returnLevelRaw : null,
difficulty: difficulty,
timeAliveMs: timeAliveMs,
lastAcousticAlertTime: lastAcousticAlertTime,
isMapOverlayVisible: isMapOverlayVisible,
isMenuOverlayVisible: isMenuOverlayVisible,
player: player,
currentLevel: currentLevel,
areaGrid: areaGrid,
areasByPlayer: areasByPlayer,
entities: entities,
doors: doors,
pushwalls: pushwalls,
);
}
@override
SaveGameFile decode(Uint8List bytes) {
try {
@@ -591,6 +874,39 @@ class CompatibleSaveGameCodec extends OriginalLayoutEnvelopeSaveGameCodec {
return _legacyCodec.decode(bytes);
}
}
void _writeBlock(
_BinaryWriter writer,
int blockId,
void Function(_BinaryWriter writer) writeBlock,
) {
final _BinaryWriter block = _BinaryWriter();
writeBlock(block);
final Uint8List bytes = block.toBytes();
writer.writeUint16(blockId);
writer.writeUint32(bytes.length);
writer.writeBytes(bytes);
}
Uint8List _requiredBlock(Map<int, Uint8List> blocks, int id) {
final Uint8List? data = blocks[id];
if (data == null) {
throw FormatException('Missing compatible payload block: $id');
}
return data;
}
bool _hasPayloadSignature(Uint8List payload) {
if (payload.length < _payloadSignature.length + 2) {
return false;
}
for (int i = 0; i < _payloadSignature.length; i++) {
if (payload[i] != _payloadSignature[i]) {
return false;
}
}
return true;
}
}
class _BinaryWriter {
@@ -164,6 +164,59 @@ void main() {
expect(decoded.description, 'Legacy Save');
expect(decoded.createdAtMs, 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, 2);
expect(decoded.description, 'Compatible Block Save');
expect(decoded.createdAtMs, 1234);
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, 4);
expect(decoded.description, 'Old Envelope Save');
expect(decoded.createdAtMs, 333);
});
}
class _TestInput extends Wolf3dInput {