From c4c8e4149aa4c3ecd5e2bd9b0fdf444c32735eac Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Mon, 23 Mar 2026 16:03:12 +0100 Subject: [PATCH] feat: Refactor MD5 hashing and update save game codec for compatibility with new payload format Signed-off-by: Hans Kokx --- .../lib/src/data/io/discovery_io.dart | 5 +- .../wolf_3d_dart/lib/src/data/md5_hash.dart | 227 ++++++++++++++++++ .../wolf_3d_dart/lib/src/data/wl_parser.dart | 7 +- .../lib/src/data/wolfenstein_loader.dart | 4 +- .../lib/src/engine/save/save_game_codec.dart | 80 ++++-- packages/wolf_3d_dart/pubspec.yaml | 1 - .../test/data/data_version_checksum_test.dart | 21 ++ .../wolf_3d_dart/test/data/md5_hash_test.dart | 34 +++ .../test/engine/save_game_codec_test.dart | 44 +++- 9 files changed, 394 insertions(+), 29 deletions(-) create mode 100644 packages/wolf_3d_dart/lib/src/data/md5_hash.dart create mode 100644 packages/wolf_3d_dart/test/data/data_version_checksum_test.dart create mode 100644 packages/wolf_3d_dart/test/data/md5_hash_test.dart diff --git a/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart b/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart index f6feaef..1abbca6 100644 --- a/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart +++ b/packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart @@ -2,10 +2,11 @@ import 'dart:developer'; import 'dart:io'; import 'dart:typed_data'; -import 'package:crypto/crypto.dart'; import 'package:wolf_3d_dart/src/data/wl_parser.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import '../md5_hash.dart'; + /// dart:io implementation for directory discovery with version integrity checks. Future> discoverInDirectory({ String? directoryPath, @@ -73,7 +74,7 @@ Future> discoverInDirectory({ final vswapBytes = await vswapFile.readAsBytes(); // 2. Generate Checksum and Resolve Identity - final hash = md5.convert(vswapBytes).toString(); + final hash = md5HexLower(vswapBytes); final identity = DataVersion.fromChecksum(hash); log('--- Found ${version.name} ---'); diff --git a/packages/wolf_3d_dart/lib/src/data/md5_hash.dart b/packages/wolf_3d_dart/lib/src/data/md5_hash.dart new file mode 100644 index 0000000..8f44a8f --- /dev/null +++ b/packages/wolf_3d_dart/lib/src/data/md5_hash.dart @@ -0,0 +1,227 @@ +import 'dart:typed_data'; + +const int _mask32 = 0xFFFFFFFF; + +const List _s = [ + 7, + 12, + 17, + 22, + 7, + 12, + 17, + 22, + 7, + 12, + 17, + 22, + 7, + 12, + 17, + 22, + 5, + 9, + 14, + 20, + 5, + 9, + 14, + 20, + 5, + 9, + 14, + 20, + 5, + 9, + 14, + 20, + 4, + 11, + 16, + 23, + 4, + 11, + 16, + 23, + 4, + 11, + 16, + 23, + 4, + 11, + 16, + 23, + 6, + 10, + 15, + 21, + 6, + 10, + 15, + 21, + 6, + 10, + 15, + 21, + 6, + 10, + 15, + 21, +]; + +const List _k = [ + 0xd76aa478, + 0xe8c7b756, + 0x242070db, + 0xc1bdceee, + 0xf57c0faf, + 0x4787c62a, + 0xa8304613, + 0xfd469501, + 0x698098d8, + 0x8b44f7af, + 0xffff5bb1, + 0x895cd7be, + 0x6b901122, + 0xfd987193, + 0xa679438e, + 0x49b40821, + 0xf61e2562, + 0xc040b340, + 0x265e5a51, + 0xe9b6c7aa, + 0xd62f105d, + 0x02441453, + 0xd8a1e681, + 0xe7d3fbc8, + 0x21e1cde6, + 0xc33707d6, + 0xf4d50d87, + 0x455a14ed, + 0xa9e3e905, + 0xfcefa3f8, + 0x676f02d9, + 0x8d2a4c8a, + 0xfffa3942, + 0x8771f681, + 0x6d9d6122, + 0xfde5380c, + 0xa4beea44, + 0x4bdecfa9, + 0xf6bb4b60, + 0xbebfbc70, + 0x289b7ec6, + 0xeaa127fa, + 0xd4ef3085, + 0x04881d05, + 0xd9d4d039, + 0xe6db99e5, + 0x1fa27cf8, + 0xc4ac5665, + 0xf4292244, + 0x432aff97, + 0xab9423a7, + 0xfc93a039, + 0x655b59c3, + 0x8f0ccc92, + 0xffeff47d, + 0x85845dd1, + 0x6fa87e4f, + 0xfe2ce6e0, + 0xa3014314, + 0x4e0811a1, + 0xf7537e82, + 0xbd3af235, + 0x2ad7d2bb, + 0xeb86d391, +]; + +String md5HexLower(List input) { + final source = Uint8List.fromList(input); + final originalLength = source.length; + final bitLength = originalLength * 8; + + final paddingLength = (56 - ((originalLength + 1) % 64) + 64) % 64; + final totalLength = originalLength + 1 + paddingLength + 8; + + final bytes = Uint8List(totalLength); + bytes.setRange(0, originalLength, source); + bytes[originalLength] = 0x80; + + for (var index = 0; index < 8; index++) { + bytes[totalLength - 8 + index] = (bitLength >> (8 * index)) & 0xFF; + } + + var a0 = 0x67452301; + var b0 = 0xEFCDAB89; + var c0 = 0x98BADCFE; + var d0 = 0x10325476; + + for (var chunkOffset = 0; chunkOffset < bytes.length; chunkOffset += 64) { + final m = Uint32List(16); + + for (var j = 0; j < 16; j++) { + final base = chunkOffset + j * 4; + m[j] = + bytes[base] | + (bytes[base + 1] << 8) | + (bytes[base + 2] << 16) | + (bytes[base + 3] << 24); + } + + var a = a0; + var b = b0; + var c = c0; + var d = d0; + + for (var i = 0; i < 64; i++) { + late int f; + late int g; + + if (i < 16) { + f = ((b & c) | ((~b) & d)) & _mask32; + g = i; + } else if (i < 32) { + f = ((d & b) | ((~d) & c)) & _mask32; + g = (5 * i + 1) % 16; + } else if (i < 48) { + f = (b ^ c ^ d) & _mask32; + g = (3 * i + 5) % 16; + } else { + f = (c ^ (b | (~d))) & _mask32; + g = (7 * i) % 16; + } + + final temp = d; + d = c; + c = b; + + final sum = (a + f + _k[i] + m[g]) & _mask32; + b = (b + _leftRotate(sum, _s[i])) & _mask32; + a = temp; + } + + a0 = (a0 + a) & _mask32; + b0 = (b0 + b) & _mask32; + c0 = (c0 + c) & _mask32; + d0 = (d0 + d) & _mask32; + } + + final output = StringBuffer(); + _appendWordHex(output, a0); + _appendWordHex(output, b0); + _appendWordHex(output, c0); + _appendWordHex(output, d0); + return output.toString(); +} + +int _leftRotate(int value, int shift) { + return ((value << shift) | ((value & _mask32) >> (32 - shift))) & _mask32; +} + +void _appendWordHex(StringBuffer output, int value) { + for (var offset = 0; offset < 32; offset += 8) { + final byte = (value >> offset) & 0xFF; + output.write(byte.toRadixString(16).padLeft(2, '0')); + } +} diff --git a/packages/wolf_3d_dart/lib/src/data/wl_parser.dart b/packages/wolf_3d_dart/lib/src/data/wl_parser.dart index 52f1a19..11eaf8e 100644 --- a/packages/wolf_3d_dart/lib/src/data/wl_parser.dart +++ b/packages/wolf_3d_dart/lib/src/data/wl_parser.dart @@ -1,9 +1,10 @@ import 'dart:convert'; import 'dart:typed_data'; -import 'package:crypto/crypto.dart' show md5; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; +import 'md5_hash.dart'; + /// The primary parser for Wolfenstein 3D data formats. /// /// This abstract class serves as the extraction and decompression engine for @@ -45,7 +46,7 @@ abstract class WLParser { vswap.offsetInBytes, vswap.lengthInBytes, ); - final vswapHash = md5.convert(vswapBytes).toString(); + final vswapHash = md5HexLower(vswapBytes); final dataIdentity = DataVersion.fromChecksum(vswapHash); // 3. Load other required files @@ -112,7 +113,7 @@ abstract class WLParser { vswap.offsetInBytes, vswap.lengthInBytes, ); - final vswapHash = md5.convert(vswapBytes).toString(); + final vswapHash = md5HexLower(vswapBytes); final dataIdentity = DataVersion.fromChecksum(vswapHash); ByteData gameMapsData; diff --git a/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart b/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart index 2b451a5..44d746b 100644 --- a/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart +++ b/packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart @@ -1,11 +1,11 @@ import 'dart:typed_data'; -import 'package:crypto/crypto.dart'; import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; import 'io/discovery_stub.dart' if (dart.library.io) 'io/discovery_io.dart' as platform; +import 'md5_hash.dart'; import 'wl_parser.dart'; /// The main entry point for loading Wolfenstein 3D data. @@ -76,7 +76,7 @@ class WolfensteinLoader { vswap.offsetInBytes, vswap.lengthInBytes, ); - final hash = md5.convert(vswapBytes).toString(); + final hash = md5HexLower(vswapBytes); final dataIdentity = DataVersion.fromChecksum(hash); // 3. Pass-through to parser with the detected identity and optional override. 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 1f1f5e9..46e24f4 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 @@ -626,6 +626,12 @@ class CompatibleSaveGameCodec extends OriginalLayoutEnvelopeSaveGameCodec { static const int _weapPistol = 1; static const int _weapMachineGun = 2; static const int _weapChainGun = 3; + static const List _extensionMagic = [ + 0x57, + 0x33, + 0x45, + 0x58, + ]; // W3EX final SaveGameCodec _legacyCodec = SaveGameCodec(); @@ -686,6 +692,20 @@ class CompatibleSaveGameCodec extends OriginalLayoutEnvelopeSaveGameCodec { checksum = doChecksum(pushwallState, checksum: checksum); writer.writeUint32(checksum); + + final Uint8List extendedPayload = super.encodeSnapshotPayload( + file.snapshot, + ); + final int extendedChecksum = doChecksum(extendedPayload); + writer.writeBytes(_extensionMagic); + writer.writeUint8(file.gameVersion.index); + writer.writeUint8(file.slot & 0xFF); + writer.writeInt64(file.createdAtMs); + writer.writeUtf8(file.dataVersionName); + writer.writeUint32(extendedPayload.length); + writer.writeBytes(extendedPayload); + writer.writeUint32(extendedChecksum); + return writer.toBytes(); } @@ -766,23 +786,22 @@ class CompatibleSaveGameCodec extends OriginalLayoutEnvelopeSaveGameCodec { ); } - if (reader.remaining != 0) { - throw const FormatException( - 'Unexpected trailing bytes in DOS save file.', - ); - } - - final PlayerSaveState player = _decodeDosPlayer(gamestate, actors); - final List entities = _decodeDosEntities(actors); - final List doors = _decodeDosDoors( + final PlayerSaveState dosPlayer = _decodeDosPlayer(gamestate, actors); + final List dosEntities = _decodeDosEntities(actors); + final List dosDoors = _decodeDosDoors( doorposition, doorobjlist, ); - final List pushwalls = _decodeDosPushwalls( + final List dosPushwalls = _decodeDosPushwalls( pushwallState, ); - final GameSessionSnapshot snapshot = GameSessionSnapshot( + int slot = 0; + GameVersion gameVersion = GameVersion.retail; + String dataVersionName = 'dos-compat'; + int createdAtMs = 0; + + GameSessionSnapshot snapshot = GameSessionSnapshot( currentGameIndex: 0, currentEpisodeIndex: gamestate.episode.clamp(0, 5), currentLevelIndex: gamestate.mapon.clamp(0, 9), @@ -792,21 +811,44 @@ class CompatibleSaveGameCodec extends OriginalLayoutEnvelopeSaveGameCodec { lastAcousticAlertTime: 0, isMapOverlayVisible: false, isMenuOverlayVisible: false, - player: player, + player: dosPlayer, currentLevel: tilemap, areaGrid: _emptyAreaGrid(), areasByPlayer: areasByPlayer, - entities: entities, - doors: doors, - pushwalls: pushwalls, + entities: dosEntities, + doors: dosDoors, + pushwalls: dosPushwalls, ); + if (reader.remaining > 0) { + try { + final Uint8List extMagic = reader.readBytes(_extensionMagic.length); + if (_listEquals(extMagic, _extensionMagic)) { + final int gameVersionIndex = reader.readUint8(); + if (gameVersionIndex >= 0 && + gameVersionIndex < GameVersion.values.length) { + gameVersion = GameVersion.values[gameVersionIndex]; + } + slot = reader.readUint8(); + createdAtMs = reader.readInt64(); + dataVersionName = reader.readUtf8(); + final int extendedLength = reader.readUint32(); + final Uint8List extendedPayload = reader.readBytes(extendedLength); + final int extendedChecksum = reader.readUint32(); + final int computed = doChecksum(extendedPayload); + if (computed == extendedChecksum) { + snapshot = super.decodeSnapshotPayload(extendedPayload); + } + } + } catch (_) {} + } + return SaveGameFile( - slot: 0, - gameVersion: GameVersion.retail, - dataVersionName: 'dos-compat', + slot: slot, + gameVersion: gameVersion, + dataVersionName: dataVersionName, description: description, - createdAtMs: 0, + createdAtMs: createdAtMs, snapshot: snapshot, checksum: storedChecksum, ); diff --git a/packages/wolf_3d_dart/pubspec.yaml b/packages/wolf_3d_dart/pubspec.yaml index 9dae108..1c79277 100644 --- a/packages/wolf_3d_dart/pubspec.yaml +++ b/packages/wolf_3d_dart/pubspec.yaml @@ -9,7 +9,6 @@ environment: dependencies: arcane_helper_utils: ^1.4.7 audioplayers: ^6.6.0 - crypto: ^3.0.7 dev_dependencies: lints: ^6.0.0 diff --git a/packages/wolf_3d_dart/test/data/data_version_checksum_test.dart b/packages/wolf_3d_dart/test/data/data_version_checksum_test.dart new file mode 100644 index 0000000..ded8aeb --- /dev/null +++ b/packages/wolf_3d_dart/test/data/data_version_checksum_test.dart @@ -0,0 +1,21 @@ +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/wolf_3d_data_types.dart'; + +void main() { + group('DataVersion.fromChecksum', () { + test('resolves all known checksum constants', () { + for (final version in DataVersion.values.where( + (version) => version != DataVersion.unknown, + )) { + expect(DataVersion.fromChecksum(version.checksum), version); + } + }); + + test('returns unknown for unrecognized checksum', () { + expect( + DataVersion.fromChecksum('ffffffffffffffffffffffffffffffff'), + DataVersion.unknown, + ); + }); + }); +} diff --git a/packages/wolf_3d_dart/test/data/md5_hash_test.dart b/packages/wolf_3d_dart/test/data/md5_hash_test.dart new file mode 100644 index 0000000..e7e30ee --- /dev/null +++ b/packages/wolf_3d_dart/test/data/md5_hash_test.dart @@ -0,0 +1,34 @@ +import 'dart:convert'; + +import 'package:test/test.dart'; +import 'package:wolf_3d_dart/src/data/md5_hash.dart'; + +void main() { + group('md5HexLower', () { + test('matches canonical RFC vectors', () { + expect(md5HexLower(const []), 'd41d8cd98f00b204e9800998ecf8427e'); + expect( + md5HexLower(utf8.encode('abc')), + '900150983cd24fb0d6963f7d28e17f72', + ); + expect( + md5HexLower(utf8.encode('The quick brown fox jumps over the lazy dog')), + '9e107d9d372bb6826bd81d3542a419d6', + ); + expect( + md5HexLower( + utf8.encode( + '12345678901234567890123456789012345678901234567890123456789012345678901234567890', + ), + ), + '57edf4a22be3c955ac49da2e2107b67a', + ); + }); + + test('returns lowercase 32-character hex output', () { + final digest = md5HexLower(utf8.encode('Wolf3D')); + expect(digest, hasLength(32)); + expect(digest, matches(RegExp(r'^[0-9a-f]{32}$'))); + }); + }); +} 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 bd6d4e8..5165715 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 @@ -183,9 +183,10 @@ void main() { final Uint8List encoded = codec.encode(file); final SaveGameFile decoded = codec.decode(encoded); - expect(decoded.slot, 0); + expect(decoded.slot, 2); expect(decoded.description, 'Compatible Block Save'); - expect(decoded.createdAtMs, 0); + expect(decoded.createdAtMs, 1234); + expect(decoded.dataVersionName, file.dataVersionName); expect( decoded.snapshot.currentEpisodeIndex, file.snapshot.currentEpisodeIndex, @@ -193,6 +194,45 @@ void main() { expect(decoded.snapshot.currentLevelIndex, file.snapshot.currentLevelIndex); }); + test('CompatibleSaveGameCodec preserves entity state fidelity', () { + final WolfEngine engine = _buildEngine(); + engine.init(); + + final Guard guard = + EntityRegistry.spawn( + MapObject.guardStart, + 8.5, + 7.5, + Difficulty.medium, + engine.data.sprites.length, + registry: engine.data.registry, + )! + as Guard + ..health = 5 + ..state = EntityState.dead; + engine.entities = [guard]; + + final CompatibleSaveGameCodec codec = CompatibleSaveGameCodec(); + final SaveGameFile decoded = codec.decode( + codec.encode( + SaveGameFile( + slot: 0, + gameVersion: engine.data.version, + dataVersionName: engine.data.dataVersion.name, + description: 'Entity Fidelity', + createdAtMs: 1, + snapshot: engine.captureSaveState(), + checksum: 0, + ), + ), + ); + + expect(decoded.snapshot.entities, hasLength(1)); + expect(decoded.snapshot.entities.first.kind, 'Guard'); + expect(decoded.snapshot.entities.first.state, EntityState.dead); + expect(decoded.snapshot.entities.first.extraData['health'], 5); + }); + test('CompatibleSaveGameCodec decodes old envelope payload format', () { final WolfEngine engine = _buildEngine(); engine.init();