feat: Refactor MD5 hashing and update save game codec for compatibility with new payload format

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-23 16:03:12 +01:00
parent f05a861998
commit c4c8e4149a
9 changed files with 394 additions and 29 deletions
@@ -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<Map<GameVersion, WolfensteinData>> discoverInDirectory({
String? directoryPath,
@@ -73,7 +74,7 @@ Future<Map<GameVersion, WolfensteinData>> 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} ---');
@@ -0,0 +1,227 @@
import 'dart:typed_data';
const int _mask32 = 0xFFFFFFFF;
const List<int> _s = <int>[
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<int> _k = <int>[
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<int> 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'));
}
}
@@ -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;
@@ -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.
@@ -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<int> _extensionMagic = <int>[
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<EntitySaveState> entities = _decodeDosEntities(actors);
final List<DoorSaveState> doors = _decodeDosDoors(
final PlayerSaveState dosPlayer = _decodeDosPlayer(gamestate, actors);
final List<EntitySaveState> dosEntities = _decodeDosEntities(actors);
final List<DoorSaveState> dosDoors = _decodeDosDoors(
doorposition,
doorobjlist,
);
final List<PushwallSaveState> pushwalls = _decodeDosPushwalls(
final List<PushwallSaveState> 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,
);
-1
View File
@@ -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
@@ -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,
);
});
});
}
@@ -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 <int>[]), '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}$')));
});
});
}
@@ -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 = <Entity>[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();