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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user