From 1ed63d5f9b1ccd3982221b77c299b51d1d52f31a Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 15:18:49 +0100 Subject: [PATCH] feat: Implement CompatibleSaveGameCodec for block payload format and legacy support Signed-off-by: Hans Kokx --- .../lib/src/engine/save/save_game_codec.dart | 316 ++++++++++++++++++ .../test/engine/save_game_codec_test.dart | 53 +++ 2 files changed, 369 insertions(+) diff --git a/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart b/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart index 47f89be..d3a8989 100644 --- a/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart +++ b/packages/wolf_3d_dart/lib/src/engine/save/save_game_codec.dart @@ -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 _payloadSignature = [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 blocks = {}; + 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> currentLevel = _readIntGrid( + _BinaryReader(_requiredBlock(blocks, _blockCurrentLevel)), + ); + final List> 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 areasByPlayer = List.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 entities = List.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 extraData = decoded is Map + ? decoded + : {}; + + 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 doors = List.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 pushwalls = List.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 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 { diff --git a/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart b/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart index 8ebc0bc..ff75a6d 100644 --- a/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart +++ b/packages/wolf_3d_dart/test/engine/save_game_codec_test.dart @@ -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 {