Migrate all Dart packages to a single wolf_3d_dart package
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
27
packages/wolf_3d_dart/lib/src/data/data_version.dart
Normal file
27
packages/wolf_3d_dart/lib/src/data/data_version.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
enum DataVersion {
|
||||
/// V1.0 Retail (VSWAP.WL6)
|
||||
version10Retail('a6d901dfb455dfac96db5e4705837cdb'),
|
||||
|
||||
/// v1.1 Retail (VSWAP.WL6)
|
||||
version11Retail('a80904e0283a921d88d977b56c279b9d'),
|
||||
|
||||
/// v1.4 Shareware (VSWAP.WL1)
|
||||
version14Shareware('6efa079414b817c97db779cecfb081c9'),
|
||||
|
||||
/// v1.4 Retail (VSWAP.WL6) - GOG/Steam version
|
||||
version14Retail('b8ff4997461bafa5ef2a94c11f9de001'),
|
||||
|
||||
unknown('unknown'),
|
||||
;
|
||||
|
||||
final String checksum;
|
||||
|
||||
const DataVersion(this.checksum);
|
||||
|
||||
static DataVersion fromChecksum(String hash) {
|
||||
return DataVersion.values.firstWhere(
|
||||
(v) => v.checksum == hash,
|
||||
orElse: () => DataVersion.unknown,
|
||||
);
|
||||
}
|
||||
}
|
||||
109
packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart
Normal file
109
packages/wolf_3d_dart/lib/src/data/io/discovery_io.dart
Normal file
@@ -0,0 +1,109 @@
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:wolf_3d_dart/src/data/data_version.dart';
|
||||
import 'package:wolf_3d_dart/src/data/wl_parser.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||
|
||||
/// dart:io implementation for directory discovery with version integrity checks.
|
||||
Future<Map<GameVersion, WolfensteinData>> discoverInDirectory({
|
||||
String? directoryPath,
|
||||
bool recursive = false,
|
||||
}) async {
|
||||
final dir = Directory(directoryPath ?? Directory.current.path);
|
||||
if (!await dir.exists()) {
|
||||
print('Warning: Directory does not exist -> ${dir.path}');
|
||||
return {};
|
||||
}
|
||||
|
||||
final allFiles = await dir
|
||||
.list(recursive: recursive)
|
||||
.where((entity) => entity is File)
|
||||
.cast<File>()
|
||||
.toList();
|
||||
|
||||
final Map<GameVersion, WolfensteinData> loadedVersions = {};
|
||||
|
||||
for (final version in GameVersion.values) {
|
||||
final ext = version.fileExtension.toUpperCase();
|
||||
final Map<GameFile, File> foundFiles = {};
|
||||
|
||||
for (final requiredFile in GameFile.values) {
|
||||
final expectedName = '${requiredFile.baseName}.$ext';
|
||||
|
||||
var match = allFiles.where((file) {
|
||||
final fileName = file.uri.pathSegments.last.toUpperCase();
|
||||
return fileName == expectedName;
|
||||
}).firstOrNull;
|
||||
|
||||
// v1.0 FIX: Search for MAPTEMP if GAMEMAPS is missing
|
||||
if (match == null && requiredFile == GameFile.gameMaps) {
|
||||
final altName = 'MAPTEMP.$ext';
|
||||
match = allFiles.where((file) {
|
||||
final fileName = file.uri.pathSegments.last.toUpperCase();
|
||||
return fileName == altName;
|
||||
}).firstOrNull;
|
||||
}
|
||||
|
||||
if (match != null) {
|
||||
foundFiles[requiredFile] = match;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundFiles.isEmpty) continue;
|
||||
|
||||
// Validation for missing files
|
||||
if (foundFiles.length < GameFile.values.length) {
|
||||
final missingFiles = GameFile.values
|
||||
.where((f) => !foundFiles.containsKey(f))
|
||||
.map((f) => '${f.baseName}.$ext')
|
||||
.join(', ');
|
||||
print('Found partial data for ${version.name}. Missing: $missingFiles');
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Read VSWAP to determine the precise DataVersion identity
|
||||
final vswapFile = foundFiles[GameFile.vswap]!;
|
||||
final vswapBytes = await vswapFile.readAsBytes();
|
||||
|
||||
// 2. Generate Checksum and Resolve Identity
|
||||
final hash = md5.convert(vswapBytes).toString();
|
||||
final identity = DataVersion.fromChecksum(hash);
|
||||
|
||||
print('--- Found ${version.name} ---');
|
||||
print('MD5 Identity: ${identity.name} ($hash)');
|
||||
if (identity == DataVersion.version10Retail) {
|
||||
print(
|
||||
'Note: Detected v1.0 specific file structure (MAPTEMP support active).',
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Load the data using the parser
|
||||
final data = WLParser.load(
|
||||
version: version,
|
||||
dataIdentity: identity,
|
||||
vswap: vswapBytes.buffer.asByteData(),
|
||||
mapHead: await _readFile(foundFiles[GameFile.mapHead]!),
|
||||
gameMaps: await _readFile(foundFiles[GameFile.gameMaps]!),
|
||||
vgaDict: await _readFile(foundFiles[GameFile.vgaDict]!),
|
||||
vgaHead: await _readFile(foundFiles[GameFile.vgaHead]!),
|
||||
vgaGraph: await _readFile(foundFiles[GameFile.vgaGraph]!),
|
||||
audioHed: await _readFile(foundFiles[GameFile.audioHed]!),
|
||||
audioT: await _readFile(foundFiles[GameFile.audioT]!),
|
||||
);
|
||||
|
||||
loadedVersions[version] = data;
|
||||
} catch (e) {
|
||||
print('Error parsing data for ${version.name}: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return loadedVersions;
|
||||
}
|
||||
|
||||
Future<ByteData> _readFile(File file) async {
|
||||
final bytes = await file.readAsBytes();
|
||||
return bytes.buffer.asByteData();
|
||||
}
|
||||
12
packages/wolf_3d_dart/lib/src/data/io/discovery_stub.dart
Normal file
12
packages/wolf_3d_dart/lib/src/data/io/discovery_stub.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||
|
||||
/// Web-safe stub for directory discovery.
|
||||
Future<Map<GameVersion, WolfensteinData>> discoverInDirectory({
|
||||
String? directoryPath,
|
||||
bool recursive = false,
|
||||
}) async {
|
||||
throw UnsupportedError(
|
||||
'Directory scanning is not supported on Web. '
|
||||
'Please load the files manually using WolfensteinLoader.loadFromBytes().',
|
||||
);
|
||||
}
|
||||
677
packages/wolf_3d_dart/lib/src/data/wl_parser.dart
Normal file
677
packages/wolf_3d_dart/lib/src/data/wl_parser.dart
Normal file
@@ -0,0 +1,677 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart' show md5;
|
||||
import 'package:wolf_3d_dart/src/data/data_version.dart';
|
||||
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
|
||||
|
||||
abstract class WLParser {
|
||||
// --- Original Song Lookup Tables ---
|
||||
static const List<int> _sharewareMusicMap = [
|
||||
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Episode 1
|
||||
];
|
||||
|
||||
static const List<int> _retailMusicMap = [
|
||||
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Ep 1
|
||||
8, 9, 10, 11, 8, 9, 11, 10, 6, 12, // Ep 2
|
||||
13, 14, 15, 16, 13, 14, 15, 16, 17, 18, // Ep 3
|
||||
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Ep 4
|
||||
8, 9, 10, 11, 8, 9, 11, 10, 6, 12, // Ep 5
|
||||
13, 14, 15, 16, 13, 14, 15, 16, 17, 19, // Ep 6
|
||||
];
|
||||
|
||||
/// Asynchronously discovers the game version and loads all necessary files.
|
||||
/// Provide a [fileFetcher] callback (e.g., Flutter's rootBundle.load) that
|
||||
/// takes a filename and returns its ByteData.
|
||||
/// Asynchronously discovers the game version and loads all necessary files.
|
||||
/// Asynchronously discovers the game version and loads all necessary files.
|
||||
static Future<WolfensteinData> loadAsync(
|
||||
Future<ByteData> Function(String filename) fileFetcher,
|
||||
) async {
|
||||
GameVersion? detectedVersion;
|
||||
ByteData? vswap;
|
||||
|
||||
// 1. Probe the data source for VSWAP to determine the GameVersion
|
||||
for (final version in GameVersion.values) {
|
||||
try {
|
||||
vswap = await fileFetcher('VSWAP.${version.fileExtension}');
|
||||
detectedVersion = version;
|
||||
break;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
if (detectedVersion == null || vswap == null) {
|
||||
throw Exception('Could not locate a valid VSWAP file.');
|
||||
}
|
||||
|
||||
final ext = detectedVersion.fileExtension;
|
||||
|
||||
// 2. Determine DataIdentity (Checksum) immediately
|
||||
final vswapBytes = vswap.buffer.asUint8List(
|
||||
vswap.offsetInBytes,
|
||||
vswap.lengthInBytes,
|
||||
);
|
||||
final vswapHash = md5.convert(vswapBytes).toString();
|
||||
final dataIdentity = DataVersion.fromChecksum(vswapHash);
|
||||
|
||||
// 3. Load other required files
|
||||
// Special Case: v1.0 Retail uses MAPTEMP instead of GAMEMAPS
|
||||
ByteData gameMapsData;
|
||||
if (dataIdentity == DataVersion.version10Retail) {
|
||||
try {
|
||||
gameMapsData = await fileFetcher('MAPTEMP.$ext');
|
||||
} catch (_) {
|
||||
// Fallback in case v1.0 files were renamed to standard convention
|
||||
gameMapsData = await fileFetcher('GAMEMAPS.$ext');
|
||||
}
|
||||
} else {
|
||||
gameMapsData = await fileFetcher('GAMEMAPS.$ext');
|
||||
}
|
||||
|
||||
final rawFiles = {
|
||||
'MAPHEAD.$ext': await fileFetcher('MAPHEAD.$ext'),
|
||||
'VGADICT.$ext': await fileFetcher('VGADICT.$ext'),
|
||||
'VGAHEAD.$ext': await fileFetcher('VGAHEAD.$ext'),
|
||||
'VGAGRAPH.$ext': await fileFetcher('VGAGRAPH.$ext'),
|
||||
'AUDIOHED.$ext': await fileFetcher('AUDIOHED.$ext'),
|
||||
'AUDIOT.$ext': await fileFetcher('AUDIOT.$ext'),
|
||||
};
|
||||
|
||||
// 4. Final call to load with the required dataIdentity
|
||||
return load(
|
||||
version: detectedVersion,
|
||||
dataIdentity: dataIdentity, // Now correctly passed
|
||||
vswap: vswap,
|
||||
mapHead: rawFiles['MAPHEAD.$ext']!,
|
||||
gameMaps: gameMapsData,
|
||||
vgaDict: rawFiles['VGADICT.$ext']!,
|
||||
vgaHead: rawFiles['VGAHEAD.$ext']!,
|
||||
vgaGraph: rawFiles['VGAGRAPH.$ext']!,
|
||||
audioHed: rawFiles['AUDIOHED.$ext']!,
|
||||
audioT: rawFiles['AUDIOT.$ext']!,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parses all raw ByteData upfront and returns a fully populated
|
||||
/// WolfensteinData object. By using named parameters, the compiler
|
||||
/// guarantees no files are missing or misnamed.
|
||||
static WolfensteinData load({
|
||||
required GameVersion version,
|
||||
required ByteData vswap,
|
||||
required ByteData mapHead,
|
||||
required ByteData gameMaps,
|
||||
required ByteData vgaDict,
|
||||
required ByteData vgaHead,
|
||||
required ByteData vgaGraph,
|
||||
required ByteData audioHed,
|
||||
required ByteData audioT,
|
||||
required DataVersion dataIdentity,
|
||||
}) {
|
||||
final isShareware = version == GameVersion.shareware;
|
||||
|
||||
// v1.0/1.1 used different HUD strings and had different secret wall bugs
|
||||
final isLegacy =
|
||||
dataIdentity == DataVersion.version10Retail ||
|
||||
dataIdentity == DataVersion.version11Retail;
|
||||
|
||||
final audio = parseAudio(audioHed, audioT, version);
|
||||
|
||||
return WolfensteinData(
|
||||
version: version,
|
||||
walls: parseWalls(vswap),
|
||||
sprites: parseSprites(vswap),
|
||||
sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(),
|
||||
episodes: parseEpisodes(mapHead, gameMaps, isShareware: isShareware),
|
||||
vgaImages: parseVgaImages(vgaDict, vgaHead, vgaGraph),
|
||||
adLibSounds: audio.adLib,
|
||||
music: audio.music,
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracts the 64x64 wall textures from VSWAP.WL1
|
||||
static List<Sprite> parseWalls(ByteData vswap) {
|
||||
final header = _VswapHeader(vswap);
|
||||
|
||||
return header.offsets
|
||||
.take(header.spriteStart)
|
||||
.where((offset) => offset != 0) // Skip empty chunks
|
||||
.map((offset) => _parseWallChunk(vswap, offset))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Extracts the compiled scaled sprites from VSWAP.WL1
|
||||
static List<Sprite> parseSprites(ByteData vswap) {
|
||||
final header = _VswapHeader(vswap);
|
||||
final sprites = <Sprite>[];
|
||||
|
||||
// Sprites are located between the walls and the sounds
|
||||
for (int i = header.spriteStart; i < header.soundStart; i++) {
|
||||
int offset = header.offsets[i];
|
||||
if (offset != 0) {
|
||||
sprites.add(_parseSingleSprite(vswap, offset));
|
||||
}
|
||||
}
|
||||
|
||||
return sprites;
|
||||
}
|
||||
|
||||
/// Extracts digitized sound effects (PCM Audio) from VSWAP.WL1
|
||||
static List<Uint8List> parseSounds(ByteData vswap) {
|
||||
final header = _VswapHeader(vswap);
|
||||
final lengthStart = 6 + (header.chunks * 4);
|
||||
final sounds = <Uint8List>[];
|
||||
|
||||
// Sounds start after the sprites and go to the end of the chunks
|
||||
for (int i = header.soundStart; i < header.chunks; i++) {
|
||||
int offset = header.offsets[i];
|
||||
int length = vswap.getUint16(lengthStart + (i * 2), Endian.little);
|
||||
|
||||
if (offset == 0 || length == 0) {
|
||||
sounds.add(Uint8List(0)); // Empty placeholder
|
||||
} else {
|
||||
// Extract the raw 8-bit PCM audio bytes
|
||||
sounds.add(vswap.buffer.asUint8List(offset, length));
|
||||
}
|
||||
}
|
||||
|
||||
return sounds;
|
||||
}
|
||||
|
||||
// --- Private Helpers ---
|
||||
|
||||
static Sprite _parseWallChunk(ByteData vswap, int offset) {
|
||||
final pixels = Uint8List(64 * 64);
|
||||
for (int x = 0; x < 64; x++) {
|
||||
for (int y = 0; y < 64; y++) {
|
||||
// Flat 1D index: x * 64 + y
|
||||
pixels[x * 64 + y] = vswap.getUint8(offset + (x * 64) + y);
|
||||
}
|
||||
}
|
||||
return Sprite(pixels);
|
||||
}
|
||||
|
||||
static Sprite _parseSingleSprite(ByteData vswap, int offset) {
|
||||
// Initialize the 1D array with 255 (Transparency)
|
||||
final pixels = Uint8List(64 * 64)..fillRange(0, 4096, 255);
|
||||
Sprite sprite = Sprite(pixels);
|
||||
|
||||
int leftPix = vswap.getUint16(offset, Endian.little);
|
||||
int rightPix = vswap.getUint16(offset + 2, Endian.little);
|
||||
|
||||
for (int x = leftPix; x <= rightPix; x++) {
|
||||
int colOffset = vswap.getUint16(
|
||||
offset + 4 + ((x - leftPix) * 2),
|
||||
Endian.little,
|
||||
);
|
||||
if (colOffset != 0) {
|
||||
_parseSpriteColumn(vswap, sprite, x, offset, offset + colOffset);
|
||||
}
|
||||
}
|
||||
return sprite;
|
||||
}
|
||||
|
||||
static void _parseSpriteColumn(
|
||||
ByteData vswap,
|
||||
Sprite sprite,
|
||||
int x,
|
||||
int baseOffset,
|
||||
int cmdOffset,
|
||||
) {
|
||||
while (true) {
|
||||
int endY = vswap.getUint16(cmdOffset, Endian.little);
|
||||
if (endY == 0) break;
|
||||
endY ~/= 2;
|
||||
|
||||
int pixelOfs = vswap.getUint16(cmdOffset + 2, Endian.little);
|
||||
int startY = vswap.getUint16(cmdOffset + 4, Endian.little);
|
||||
startY ~/= 2;
|
||||
|
||||
for (int y = startY; y < endY; y++) {
|
||||
// Write directly to the 1D array
|
||||
sprite.pixels[x * 64 + y] = vswap.getUint8(baseOffset + pixelOfs + y);
|
||||
}
|
||||
|
||||
cmdOffset += 6;
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts and decodes the UI graphics, Title Screens, and Menu items
|
||||
static List<VgaImage> parseVgaImages(
|
||||
ByteData vgaDict,
|
||||
ByteData vgaHead,
|
||||
ByteData vgaGraph,
|
||||
) {
|
||||
// 1. Get all raw decompressed chunks using the Huffman algorithm
|
||||
List<Uint8List> rawChunks = _parseVgaRaw(vgaDict, vgaHead, vgaGraph);
|
||||
|
||||
// 2. Chunk 0 is the Picture Table (Dimensions for all images)
|
||||
// It contains consecutive 16-bit widths and 16-bit heights
|
||||
ByteData picTable = ByteData.sublistView(rawChunks[0]);
|
||||
int numPics = picTable.lengthInBytes ~/ 4;
|
||||
|
||||
List<VgaImage> images = [];
|
||||
|
||||
// 3. In Wolf3D, Chunk 1 and 2 are fonts. Pictures start at Chunk 3!
|
||||
int picStartIndex = 3;
|
||||
|
||||
for (int i = 0; i < numPics; i++) {
|
||||
int width = picTable.getUint16(i * 4, Endian.little);
|
||||
int height = picTable.getUint16(i * 4 + 2, Endian.little);
|
||||
|
||||
// Safety check: ensure we don't read out of bounds
|
||||
if (picStartIndex + i < rawChunks.length) {
|
||||
images.add(
|
||||
VgaImage(
|
||||
width: width,
|
||||
height: height,
|
||||
pixels: rawChunks[picStartIndex + i],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return images;
|
||||
}
|
||||
|
||||
// --- Episode Names (From the original C Executable) ---
|
||||
static const List<String> _sharewareEpisodeNames = [
|
||||
"Episode 1\nEscape from Wolfenstein",
|
||||
];
|
||||
|
||||
static const List<String> _retailEpisodeNames = [
|
||||
"Episode 1\nEscape from Wolfenstein",
|
||||
"Episode 2\nOperation: Eisenfaust",
|
||||
"Episode 3\nDie, Fuhrer, Die!",
|
||||
"Episode 4\nA Dark Secret",
|
||||
"Episode 5\nTrail of the Madman",
|
||||
"Episode 6\nConfrontation",
|
||||
];
|
||||
|
||||
/// Parses MAPHEAD and GAMEMAPS to extract the raw level data.
|
||||
static List<Episode> parseEpisodes(
|
||||
ByteData mapHead,
|
||||
ByteData gameMaps, {
|
||||
bool isShareware = true,
|
||||
}) {
|
||||
List<WolfLevel> allLevels = [];
|
||||
int rlewTag = mapHead.getUint16(0, Endian.little);
|
||||
|
||||
// Select the correct music map based on the version
|
||||
final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap;
|
||||
final episodeNames = isShareware
|
||||
? _sharewareEpisodeNames
|
||||
: _retailEpisodeNames;
|
||||
|
||||
// The game allows for up to 100 maps per file
|
||||
for (int i = 0; i < 100; i++) {
|
||||
int mapOffset = mapHead.getUint32(2 + (i * 4), Endian.little);
|
||||
if (mapOffset == 0) continue; // Empty map slot
|
||||
|
||||
int plane0Offset = gameMaps.getUint32(mapOffset + 0, Endian.little);
|
||||
int plane1Offset = gameMaps.getUint32(mapOffset + 4, Endian.little);
|
||||
|
||||
int plane0Length = gameMaps.getUint16(mapOffset + 12, Endian.little);
|
||||
int plane1Length = gameMaps.getUint16(mapOffset + 14, Endian.little);
|
||||
|
||||
// --- EXTRACT ACTUAL GAME DATA NAME ---
|
||||
// The name is exactly 16 bytes long, starting at offset 22
|
||||
List<int> nameBytes = [];
|
||||
for (int n = 0; n < 16; n++) {
|
||||
int charCode = gameMaps.getUint8(mapOffset + 22 + n);
|
||||
if (charCode == 0) break; // Stop at the null-terminator
|
||||
nameBytes.add(charCode);
|
||||
}
|
||||
String parsedName = ascii.decode(nameBytes);
|
||||
|
||||
// --- DECOMPRESS PLANES ---
|
||||
final compressedWallData = gameMaps.buffer.asUint8List(
|
||||
plane0Offset,
|
||||
plane0Length,
|
||||
);
|
||||
Uint16List carmackExpandedWalls = _expandCarmack(compressedWallData);
|
||||
List<int> flatWallGrid = _expandRlew(carmackExpandedWalls, rlewTag);
|
||||
|
||||
final compressedObjectData = gameMaps.buffer.asUint8List(
|
||||
plane1Offset,
|
||||
plane1Length,
|
||||
);
|
||||
Uint16List carmackExpandedObjects = _expandCarmack(compressedObjectData);
|
||||
List<int> flatObjectGrid = _expandRlew(carmackExpandedObjects, rlewTag);
|
||||
|
||||
// --- BUILD 64x64 GRIDS ---
|
||||
List<List<int>> wallGrid = [];
|
||||
List<List<int>> objectGrid = [];
|
||||
|
||||
for (int y = 0; y < 64; y++) {
|
||||
List<int> wallRow = [];
|
||||
List<int> objectRow = [];
|
||||
for (int x = 0; x < 64; x++) {
|
||||
wallRow.add(flatWallGrid[y * 64 + x]);
|
||||
objectRow.add(flatObjectGrid[y * 64 + x]);
|
||||
}
|
||||
wallGrid.add(wallRow);
|
||||
objectGrid.add(objectRow);
|
||||
}
|
||||
|
||||
// --- ASSIGN MUSIC ---
|
||||
int trackIndex = (i < activeMusicMap.length)
|
||||
? activeMusicMap[i]
|
||||
: activeMusicMap[i % activeMusicMap.length];
|
||||
|
||||
allLevels.add(
|
||||
WolfLevel(
|
||||
name: parsedName,
|
||||
wallGrid: wallGrid,
|
||||
objectGrid: objectGrid,
|
||||
musicIndex: trackIndex,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Group the parsed levels into Episodes!
|
||||
List<Episode> episodes = [];
|
||||
|
||||
// Calculate how many episodes we need (10 levels per episode)
|
||||
int totalEpisodes = (allLevels.length / 10).ceil();
|
||||
|
||||
for (int i = 0; i < totalEpisodes; i++) {
|
||||
int startIndex = i * 10;
|
||||
int endIndex = startIndex + 10;
|
||||
|
||||
// Safety clamp for incomplete episodes at the end of the file
|
||||
if (endIndex > allLevels.length) {
|
||||
endIndex = allLevels.length;
|
||||
}
|
||||
|
||||
// If we run out of hardcoded id Software names, generate a custom one!
|
||||
String epName = (i < episodeNames.length)
|
||||
? episodeNames[i]
|
||||
: "Episode ${i + 1}\nCustom Maps";
|
||||
|
||||
episodes.add(
|
||||
Episode(
|
||||
name: epName,
|
||||
levels: allLevels.sublist(startIndex, endIndex),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return episodes;
|
||||
}
|
||||
|
||||
/// Extracts AdLib sounds and IMF music tracks from the audio files.
|
||||
static ({List<PcmSound> adLib, List<ImfMusic> music}) parseAudio(
|
||||
ByteData audioHed,
|
||||
ByteData audioT,
|
||||
GameVersion version,
|
||||
) {
|
||||
List<int> offsets = [];
|
||||
for (int i = 0; i < audioHed.lengthInBytes ~/ 4; i++) {
|
||||
offsets.add(audioHed.getUint32(i * 4, Endian.little));
|
||||
}
|
||||
|
||||
List<Uint8List> allAudioChunks = [];
|
||||
|
||||
for (int i = 0; i < offsets.length - 1; i++) {
|
||||
int start = offsets[i];
|
||||
int next = offsets[i + 1];
|
||||
|
||||
if (start == 0xFFFFFFFF || start >= audioT.lengthInBytes) {
|
||||
allAudioChunks.add(Uint8List(0));
|
||||
continue;
|
||||
}
|
||||
|
||||
int length = next - start;
|
||||
if (length <= 0) {
|
||||
allAudioChunks.add(Uint8List(0));
|
||||
} else {
|
||||
allAudioChunks.add(
|
||||
audioT.buffer.asUint8List(audioT.offsetInBytes + start, length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// In Wolf3D v1.4 (Shareware and Retail), Music ALWAYS starts at chunk 261.
|
||||
// Chunks 0-86: PC Sounds
|
||||
// Chunks 87-173: AdLib Sounds
|
||||
// Chunks 174-260: Digitized Sounds
|
||||
int musicStartIndex = 261;
|
||||
|
||||
List<PcmSound> adLib = allAudioChunks
|
||||
.take(musicStartIndex)
|
||||
.map((bytes) => PcmSound(bytes))
|
||||
.toList();
|
||||
|
||||
List<ImfMusic> music = allAudioChunks
|
||||
.skip(musicStartIndex)
|
||||
.where((chunk) => chunk.isNotEmpty)
|
||||
.map((bytes) => ImfMusic.fromBytes(bytes))
|
||||
.toList();
|
||||
|
||||
return (adLib: adLib, music: music);
|
||||
}
|
||||
|
||||
// --- ALGORITHM 1: CARMACK EXPANSION ---
|
||||
static Uint16List _expandCarmack(Uint8List compressed) {
|
||||
ByteData data = ByteData.sublistView(compressed);
|
||||
|
||||
// The first 16-bit word is the total length of the expanded data in BYTES.
|
||||
int expandedLengthBytes = data.getUint16(0, Endian.little);
|
||||
int expandedLengthWords = expandedLengthBytes ~/ 2;
|
||||
Uint16List expanded = Uint16List(expandedLengthWords);
|
||||
|
||||
int inIdx = 2; // Skip the length word we just read
|
||||
int outIdx = 0;
|
||||
|
||||
while (outIdx < expandedLengthWords && inIdx < compressed.length) {
|
||||
int word = data.getUint16(inIdx, Endian.little);
|
||||
inIdx += 2;
|
||||
|
||||
int highByte = word >> 8;
|
||||
int lowByte = word & 0xFF;
|
||||
|
||||
// 0xA7 and 0xA8 are the Carmack Pointer Tags
|
||||
if (highByte == 0xA7 || highByte == 0xA8) {
|
||||
if (lowByte == 0) {
|
||||
// Exception Rule: If the length (lowByte) is 0, it's not a pointer.
|
||||
// It's literally just the tag byte followed by another byte.
|
||||
int nextByte = data.getUint8(inIdx++);
|
||||
expanded[outIdx++] = (nextByte << 8) | highByte;
|
||||
} else if (highByte == 0xA7) {
|
||||
// 0xA7 = Near Pointer (look back a few spaces)
|
||||
int offset = data.getUint8(inIdx++);
|
||||
int copyFrom = outIdx - offset;
|
||||
for (int i = 0; i < lowByte; i++) {
|
||||
expanded[outIdx++] = expanded[copyFrom++];
|
||||
}
|
||||
} else if (highByte == 0xA8) {
|
||||
// 0xA8 = Far Pointer (absolute offset from the very beginning)
|
||||
int offset = data.getUint16(inIdx, Endian.little);
|
||||
inIdx += 2;
|
||||
for (int i = 0; i < lowByte; i++) {
|
||||
expanded[outIdx++] = expanded[offset++];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Normal, uncompressed word
|
||||
expanded[outIdx++] = word;
|
||||
}
|
||||
}
|
||||
return expanded;
|
||||
}
|
||||
|
||||
// --- ALGORITHM 2: RLEW EXPANSION ---
|
||||
static List<int> _expandRlew(Uint16List carmackExpanded, int rlewTag) {
|
||||
// The first word is the expanded length in BYTES
|
||||
int expandedLengthBytes = carmackExpanded[0];
|
||||
int expandedLengthWords = expandedLengthBytes ~/ 2;
|
||||
List<int> rlewExpanded = List<int>.filled(expandedLengthWords, 0);
|
||||
|
||||
int inIdx = 1; // Skip the length word
|
||||
int outIdx = 0;
|
||||
|
||||
while (outIdx < expandedLengthWords && inIdx < carmackExpanded.length) {
|
||||
int word = carmackExpanded[inIdx++];
|
||||
|
||||
if (word == rlewTag) {
|
||||
// We found an RLEW tag!
|
||||
// The next word is the count, the word after that is the value.
|
||||
int count = carmackExpanded[inIdx++];
|
||||
int value = carmackExpanded[inIdx++];
|
||||
for (int i = 0; i < count; i++) {
|
||||
rlewExpanded[outIdx++] = value;
|
||||
}
|
||||
} else {
|
||||
// Normal word
|
||||
rlewExpanded[outIdx++] = word;
|
||||
}
|
||||
}
|
||||
return rlewExpanded;
|
||||
}
|
||||
|
||||
/// Extracts decompressed VGA data chunks (UI, Fonts, Pictures)
|
||||
static List<Uint8List> _parseVgaRaw(
|
||||
ByteData vgaDict,
|
||||
ByteData vgaHead,
|
||||
ByteData vgaGraph,
|
||||
) {
|
||||
List<Uint8List> vgaChunks = [];
|
||||
|
||||
// 1. Parse the Huffman Dictionary from VGADICT
|
||||
List<_HuffmanNode> dict = [];
|
||||
for (int i = 0; i < 255; i++) {
|
||||
dict.add(
|
||||
_HuffmanNode(
|
||||
vgaDict.getUint16(i * 4, Endian.little),
|
||||
vgaDict.getUint16(i * 4 + 2, Endian.little),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Read VGAHEAD to get the offsets for VGAGRAPH
|
||||
List<int> offsets = [];
|
||||
int numChunks = vgaHead.lengthInBytes ~/ 3;
|
||||
int lastOffset = -1;
|
||||
|
||||
for (int i = 0; i < numChunks; i++) {
|
||||
int offset =
|
||||
vgaHead.getUint8(i * 3) |
|
||||
(vgaHead.getUint8(i * 3 + 1) << 8) |
|
||||
(vgaHead.getUint8(i * 3 + 2) << 16);
|
||||
|
||||
if (offset == 0x00FFFFFF) break;
|
||||
|
||||
// --- SAFETY FIX ---
|
||||
// 1. Offsets cannot point beyond the file length.
|
||||
// 2. Offsets must not go backward (this means we hit the junk padding).
|
||||
if (offset > vgaGraph.lengthInBytes || offset < lastOffset) {
|
||||
break;
|
||||
}
|
||||
|
||||
offsets.add(offset);
|
||||
lastOffset = offset;
|
||||
}
|
||||
|
||||
// 3. Decompress the chunks from VGAGRAPH
|
||||
for (int i = 0; i < offsets.length; i++) {
|
||||
int offset = offsets[i];
|
||||
|
||||
// --- BOUNDARY CHECK ---
|
||||
// If the offset is exactly at the end of the file, or doesn't leave
|
||||
// enough room for a 4-byte length header, it's an empty chunk.
|
||||
if (offset + 4 > vgaGraph.lengthInBytes) {
|
||||
vgaChunks.add(Uint8List(0));
|
||||
continue;
|
||||
}
|
||||
|
||||
// The first 4 bytes of a compressed chunk specify its decompressed size
|
||||
int expandedLength = vgaGraph.getUint32(offset, Endian.little);
|
||||
|
||||
if (expandedLength == 0) {
|
||||
vgaChunks.add(Uint8List(0));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Decompress starting immediately after the 4-byte length header.
|
||||
// We pass the exact slice of bytes remaining so we don't read out of bounds.
|
||||
Uint8List expandedData = _expandHuffman(
|
||||
vgaGraph.buffer.asUint8List(
|
||||
vgaGraph.offsetInBytes + offset + 4,
|
||||
vgaGraph.lengthInBytes - (offset + 4),
|
||||
),
|
||||
dict,
|
||||
expandedLength,
|
||||
);
|
||||
|
||||
vgaChunks.add(expandedData);
|
||||
}
|
||||
|
||||
return vgaChunks;
|
||||
}
|
||||
|
||||
// --- ALGORITHM 3: HUFFMAN EXPANSION ---
|
||||
static Uint8List _expandHuffman(
|
||||
Uint8List compressed,
|
||||
List<_HuffmanNode> dict,
|
||||
int expandedLength,
|
||||
) {
|
||||
Uint8List expanded = Uint8List(expandedLength);
|
||||
|
||||
int outIdx = 0;
|
||||
int byteIdx = 0;
|
||||
int bitMask = 1;
|
||||
int currentNode = 254; // id Software's Huffman root is always node 254
|
||||
|
||||
while (outIdx < expandedLength && byteIdx < compressed.length) {
|
||||
// Read the current bit (LSB to MSB)
|
||||
int bit = (compressed[byteIdx] & bitMask) == 0 ? 0 : 1;
|
||||
|
||||
// Advance to the next bit/byte
|
||||
bitMask <<= 1;
|
||||
if (bitMask > 128) {
|
||||
bitMask = 1;
|
||||
byteIdx++;
|
||||
}
|
||||
|
||||
// Traverse the tree
|
||||
int nextVal = bit == 0 ? dict[currentNode].bit0 : dict[currentNode].bit1;
|
||||
|
||||
if (nextVal < 256) {
|
||||
// If the value is < 256, we've hit a leaf node (an actual character byte!)
|
||||
expanded[outIdx++] = nextVal;
|
||||
currentNode =
|
||||
254; // Reset to the root of the tree for the next character
|
||||
} else {
|
||||
// If the value is >= 256, it's a pointer to the next internal node.
|
||||
// Node indexes are offset by 256.
|
||||
currentNode = nextVal - 256;
|
||||
}
|
||||
}
|
||||
|
||||
return expanded;
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper class to parse and store redundant VSWAP header data
|
||||
class _VswapHeader {
|
||||
final int chunks;
|
||||
final int spriteStart;
|
||||
final int soundStart;
|
||||
final List<int> offsets;
|
||||
|
||||
_VswapHeader(ByteData vswap)
|
||||
: chunks = vswap.getUint16(0, Endian.little),
|
||||
spriteStart = vswap.getUint16(2, Endian.little),
|
||||
soundStart = vswap.getUint16(4, Endian.little),
|
||||
offsets = List.generate(
|
||||
vswap.getUint16(0, Endian.little), // total chunks
|
||||
(i) => vswap.getUint32(6 + (i * 4), Endian.little),
|
||||
);
|
||||
}
|
||||
|
||||
class _HuffmanNode {
|
||||
final int bit0;
|
||||
final int bit1;
|
||||
|
||||
_HuffmanNode(this.bit0, this.bit1);
|
||||
}
|
||||
83
packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart
Normal file
83
packages/wolf_3d_dart/lib/src/data/wolfenstein_loader.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:crypto/crypto.dart'; // Import for MD5
|
||||
import 'package:wolf_3d_dart/src/data/data_version.dart'; // Import your enum
|
||||
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 'wl_parser.dart';
|
||||
|
||||
class WolfensteinLoader {
|
||||
/// Scans a directory for Wolfenstein 3D data files and loads all available versions.
|
||||
static Future<Map<GameVersion, WolfensteinData>> discover({
|
||||
String? directoryPath,
|
||||
bool recursive = false,
|
||||
}) {
|
||||
return platform.discoverInDirectory(
|
||||
directoryPath: directoryPath,
|
||||
recursive: recursive,
|
||||
);
|
||||
}
|
||||
|
||||
/// Parses WolfensteinData from raw ByteData.
|
||||
/// Throws an [ArgumentError] if any required file is null.
|
||||
static WolfensteinData loadFromBytes({
|
||||
required GameVersion version,
|
||||
required ByteData? vswap,
|
||||
required ByteData? mapHead,
|
||||
required ByteData? gameMaps,
|
||||
required ByteData? vgaDict,
|
||||
required ByteData? vgaHead,
|
||||
required ByteData? vgaGraph,
|
||||
required ByteData? audioHed,
|
||||
required ByteData? audioT,
|
||||
}) {
|
||||
// 1. Validation Check
|
||||
final Map<String, ByteData?> files = {
|
||||
'VSWAP': vswap,
|
||||
'MAPHEAD': mapHead,
|
||||
'GAMEMAPS': gameMaps,
|
||||
'VGADICT': vgaDict,
|
||||
'VGAHEAD': vgaHead,
|
||||
'VGAGRAPH': vgaGraph,
|
||||
'AUDIOHED': audioHed,
|
||||
'AUDIOT': audioT,
|
||||
};
|
||||
|
||||
final missing = files.entries
|
||||
.where((e) => e.value == null)
|
||||
.map((e) => "${e.key}.${version.fileExtension}")
|
||||
.toList();
|
||||
|
||||
if (missing.isNotEmpty) {
|
||||
throw ArgumentError(
|
||||
'Cannot load ${version.name}: Missing files: ${missing.join(", ")}',
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Identify the DataVersion via Checksum
|
||||
final vswapBytes = vswap!.buffer.asUint8List(
|
||||
vswap.offsetInBytes,
|
||||
vswap.lengthInBytes,
|
||||
);
|
||||
final hash = md5.convert(vswapBytes).toString();
|
||||
final dataIdentity = DataVersion.fromChecksum(hash);
|
||||
|
||||
// 3. Pass-through to parser with the detected identity
|
||||
return WLParser.load(
|
||||
version: version,
|
||||
// Correctly identifies v1.0/1.1/1.4
|
||||
dataIdentity: dataIdentity,
|
||||
vswap: vswap,
|
||||
mapHead: mapHead!,
|
||||
gameMaps: gameMaps!,
|
||||
vgaDict: vgaDict!,
|
||||
vgaHead: vgaHead!,
|
||||
vgaGraph: vgaGraph!,
|
||||
audioHed: audioHed!,
|
||||
audioT: audioT!,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user