Moved data loading to parser. Added remaining shareware data.
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
enum GameVersion {
|
||||
shareware("WL1"),
|
||||
retail("WL6"),
|
||||
spearOfDestinyDemo("SDM"),
|
||||
spearOfDestiny("SOD"),
|
||||
spearOfDestinyDemo("SDM"),
|
||||
;
|
||||
|
||||
final String fileExtension;
|
||||
|
||||
13
packages/wolf_3d_data/lib/src/classes/image.dart
Normal file
13
packages/wolf_3d_data/lib/src/classes/image.dart
Normal file
@@ -0,0 +1,13 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class VgaImage {
|
||||
final int width;
|
||||
final int height;
|
||||
final Uint8List pixels; // 8-bit paletted pixel data
|
||||
|
||||
VgaImage({
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.pixels,
|
||||
});
|
||||
}
|
||||
6
packages/wolf_3d_data/lib/src/classes/sound.dart
Normal file
6
packages/wolf_3d_data/lib/src/classes/sound.dart
Normal file
@@ -0,0 +1,6 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
class PcmSound {
|
||||
final Uint8List bytes;
|
||||
PcmSound(this.bytes);
|
||||
}
|
||||
@@ -1,81 +1,19 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:wolf_3d_data/src/wl_parser.dart';
|
||||
|
||||
import 'game_version.dart';
|
||||
import 'sprite.dart';
|
||||
import 'wolf_level.dart';
|
||||
import 'package:wolf_3d_data/wolf_3d_data.dart';
|
||||
|
||||
class WolfensteinData {
|
||||
final GameVersion version;
|
||||
final List<Sprite> walls;
|
||||
final List<Sprite> sprites;
|
||||
final List<PcmSound> sounds;
|
||||
final List<WolfLevel> levels;
|
||||
final List<VgaImage> vgaImages;
|
||||
|
||||
// Raw file data references
|
||||
final ByteData _vswap;
|
||||
final ByteData _mapHead;
|
||||
final ByteData _gameMaps;
|
||||
|
||||
// Backing fields for lazy loading
|
||||
List<Sprite>? _walls;
|
||||
List<Sprite>? _sprites;
|
||||
List<WolfLevel>? _levels;
|
||||
|
||||
WolfensteinData._({
|
||||
const WolfensteinData({
|
||||
required this.version,
|
||||
required ByteData vswap,
|
||||
required ByteData mapHead,
|
||||
required ByteData gameMaps,
|
||||
}) : _vswap = vswap,
|
||||
_mapHead = mapHead,
|
||||
_gameMaps = gameMaps;
|
||||
|
||||
/// Initializes the data from a map of filenames to their byte contents.
|
||||
/// Automatically detects the game version from the file extensions.
|
||||
factory WolfensteinData.fromFiles(Map<String, ByteData> files) {
|
||||
if (files.isEmpty) throw ArgumentError('File map cannot be empty');
|
||||
|
||||
// 1. Detect Game Version from the first file's extension
|
||||
final sampleFilename = files.keys.first.toUpperCase();
|
||||
final extension = sampleFilename.split('.').last;
|
||||
|
||||
final version = GameVersion.values.firstWhere(
|
||||
(v) => v.fileExtension == extension,
|
||||
orElse: () =>
|
||||
throw FormatException('Unsupported file extension: $extension'),
|
||||
);
|
||||
|
||||
// 2. Extract the required files using the detected extension
|
||||
final vswap = _getFile(files, 'VSWAP.$extension');
|
||||
final mapHead = _getFile(files, 'MAPHEAD.$extension');
|
||||
final gameMaps = _getFile(files, 'GAMEMAPS.$extension');
|
||||
|
||||
return WolfensteinData._(
|
||||
version: version,
|
||||
vswap: vswap,
|
||||
mapHead: mapHead,
|
||||
gameMaps: gameMaps,
|
||||
);
|
||||
}
|
||||
|
||||
// --- Lazy Getters ---
|
||||
|
||||
List<Sprite> get walls => _walls ??= WLParser.parseWalls(_vswap);
|
||||
|
||||
List<Sprite> get sprites => _sprites ??= WLParser.parseSprites(_vswap);
|
||||
|
||||
List<WolfLevel> get levels => _levels ??= WLParser.parseMaps(
|
||||
_mapHead,
|
||||
_gameMaps,
|
||||
isShareware: version == GameVersion.shareware,
|
||||
);
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
static ByteData _getFile(Map<String, ByteData> files, String name) {
|
||||
// Case-insensitive lookup
|
||||
final key = files.keys.firstWhere(
|
||||
(k) => k.toUpperCase() == name.toUpperCase(),
|
||||
orElse: () => throw FormatException('Missing required file: $name'),
|
||||
);
|
||||
return files[key]!;
|
||||
}
|
||||
required this.walls,
|
||||
required this.sprites,
|
||||
required this.sounds,
|
||||
required this.levels,
|
||||
required this.vgaImages,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,11 +1,79 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:wolf_3d_data/src/classes/game_version.dart';
|
||||
import 'package:wolf_3d_data/src/classes/image.dart';
|
||||
import 'package:wolf_3d_data/src/classes/sound.dart';
|
||||
import 'package:wolf_3d_data/src/classes/wolf_level.dart';
|
||||
import 'package:wolf_3d_data/src/classes/wolfenstein_data.dart';
|
||||
|
||||
import 'classes/sprite.dart';
|
||||
|
||||
class WLParser {
|
||||
abstract class WLParser {
|
||||
/// 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.
|
||||
static Future<WolfensteinData> loadAsync(
|
||||
Future<ByteData> Function(String filename) fileFetcher,
|
||||
) async {
|
||||
GameVersion? detectedVersion;
|
||||
ByteData? vswap;
|
||||
|
||||
// 1. Probe the data source to figure out which version we have
|
||||
for (final version in GameVersion.values) {
|
||||
try {
|
||||
vswap = await fileFetcher('VSWAP.${version.fileExtension}');
|
||||
detectedVersion = version;
|
||||
break; // We found the version!
|
||||
} catch (_) {
|
||||
// File wasn't found, try the next version extension
|
||||
}
|
||||
}
|
||||
|
||||
if (detectedVersion == null || vswap == null) {
|
||||
throw Exception(
|
||||
'Could not locate a valid VSWAP file for any game version.',
|
||||
);
|
||||
}
|
||||
|
||||
final ext = detectedVersion.fileExtension;
|
||||
|
||||
// 2. Now that we know the version, confidently load the rest of the files
|
||||
return load(
|
||||
version: detectedVersion,
|
||||
vswap: vswap,
|
||||
mapHead: await fileFetcher('MAPHEAD.$ext'),
|
||||
gameMaps: await fileFetcher('GAMEMAPS.$ext'),
|
||||
vgaDict: await fileFetcher('VGADICT.$ext'),
|
||||
vgaHead: await fileFetcher('VGAHEAD.$ext'),
|
||||
vgaGraph: await fileFetcher('VGAGRAPH.$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,
|
||||
}) {
|
||||
final isShareware = version == GameVersion.shareware;
|
||||
|
||||
return WolfensteinData(
|
||||
version: version,
|
||||
walls: parseWalls(vswap),
|
||||
sprites: parseSprites(vswap),
|
||||
sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(),
|
||||
levels: parseMaps(mapHead, gameMaps, isShareware: isShareware),
|
||||
vgaImages: parseVgaImages(vgaDict, vgaHead, vgaGraph),
|
||||
);
|
||||
}
|
||||
|
||||
/// Extracts the 64x64 wall textures from VSWAP.WL1
|
||||
static List<Sprite> parseWalls(ByteData vswap) {
|
||||
final header = _VswapHeader(vswap);
|
||||
@@ -114,6 +182,44 @@ class WLParser {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
|
||||
/// Parses MAPHEAD and GAMEMAPS to extract the raw level data.
|
||||
static List<WolfLevel> parseMaps(
|
||||
ByteData mapHead,
|
||||
@@ -260,6 +366,128 @@ class WLParser {
|
||||
}
|
||||
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
|
||||
@@ -278,3 +506,10 @@ class _VswapHeader {
|
||||
(i) => vswap.getUint32(6 + (i * 4), Endian.little),
|
||||
);
|
||||
}
|
||||
|
||||
class _HuffmanNode {
|
||||
final int bit0;
|
||||
final int bit1;
|
||||
|
||||
_HuffmanNode(this.bit0, this.bit1);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
library;
|
||||
|
||||
export 'src/classes/game_version.dart' show GameVersion;
|
||||
export 'src/classes/image.dart' show VgaImage;
|
||||
export 'src/classes/sound.dart' show PcmSound;
|
||||
export 'src/classes/sprite.dart' hide Matrix;
|
||||
export 'src/classes/wolf_level.dart' show WolfLevel;
|
||||
export 'src/classes/wolfenstein_data.dart' show WolfensteinData;
|
||||
export 'src/wl_parser.dart' show WLParser;
|
||||
|
||||
Reference in New Issue
Block a user