|
|
|
|
@@ -5,6 +5,12 @@ 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';
|
|
|
|
|
|
|
|
|
|
/// The primary parser for Wolfenstein 3D data formats.
|
|
|
|
|
///
|
|
|
|
|
/// This abstract class serves as the extraction and decompression engine for
|
|
|
|
|
/// legacy id Software game assets. It processes raw byte streams into usable
|
|
|
|
|
/// objects (walls, sprites, audio, levels, and UI elements) using original
|
|
|
|
|
/// algorithms like Carmackization, RLEW, and Huffman expansion.
|
|
|
|
|
abstract class WLParser {
|
|
|
|
|
// --- Original Song Lookup Tables ---
|
|
|
|
|
static const List<int> _sharewareMusicMap = [
|
|
|
|
|
@@ -21,10 +27,11 @@ 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.
|
|
|
|
|
/// Asynchronously discovers the game version and loads all necessary files.
|
|
|
|
|
/// Asynchronously discovers the game version and loads all necessary files.
|
|
|
|
|
///
|
|
|
|
|
/// Provide a [fileFetcher] callback (e.g., Flutter's `rootBundle.load` or
|
|
|
|
|
/// standard `File.readAsBytes`) that takes a filename string and returns
|
|
|
|
|
/// its [ByteData]. The parser will probe for the `VSWAP` file to determine
|
|
|
|
|
/// the game version and then fetch the corresponding data files.
|
|
|
|
|
static Future<WolfensteinData> loadAsync(
|
|
|
|
|
Future<ByteData> Function(String filename) fileFetcher,
|
|
|
|
|
) async {
|
|
|
|
|
@@ -92,9 +99,11 @@ abstract class WLParser {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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.
|
|
|
|
|
/// 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.
|
|
|
|
|
/// The [dataIdentity] parameter is used to apply version-specific bug fixes or
|
|
|
|
|
/// legacy parsing adjustments (e.g., HUD string differences in v1.0).
|
|
|
|
|
static WolfensteinData load({
|
|
|
|
|
required GameVersion version,
|
|
|
|
|
required ByteData vswap,
|
|
|
|
|
@@ -128,7 +137,9 @@ abstract class WLParser {
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extracts the 64x64 wall textures from VSWAP.WL1
|
|
|
|
|
/// Extracts the 64x64 wall textures from the VSWAP file.
|
|
|
|
|
///
|
|
|
|
|
/// Wall textures are stored sequentially at the beginning of the chunks array.
|
|
|
|
|
static List<Sprite> parseWalls(ByteData vswap) {
|
|
|
|
|
final header = _VswapHeader(vswap);
|
|
|
|
|
|
|
|
|
|
@@ -139,7 +150,10 @@ abstract class WLParser {
|
|
|
|
|
.toList();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extracts the compiled scaled sprites from VSWAP.WL1
|
|
|
|
|
/// Extracts the compiled scaled sprites from the VSWAP file.
|
|
|
|
|
///
|
|
|
|
|
/// Sprites use a column-based "sparse" format to allow for transparency
|
|
|
|
|
/// and efficient vertical scaling in the raycaster.
|
|
|
|
|
static List<Sprite> parseSprites(ByteData vswap) {
|
|
|
|
|
final header = _VswapHeader(vswap);
|
|
|
|
|
final sprites = <Sprite>[];
|
|
|
|
|
@@ -155,7 +169,9 @@ abstract class WLParser {
|
|
|
|
|
return sprites;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extracts digitized sound effects (PCM Audio) from VSWAP.WL1
|
|
|
|
|
/// Extracts digitized sound effects (PCM Audio) from the VSWAP file.
|
|
|
|
|
///
|
|
|
|
|
/// Sounds are stored as raw 8-bit PCM audio bytes.
|
|
|
|
|
static List<Uint8List> parseSounds(ByteData vswap) {
|
|
|
|
|
final header = _VswapHeader(vswap);
|
|
|
|
|
final lengthStart = 6 + (header.chunks * 4);
|
|
|
|
|
@@ -235,7 +251,10 @@ abstract class WLParser {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extracts and decodes the UI graphics, Title Screens, and Menu items
|
|
|
|
|
/// Extracts and decodes the UI graphics, Title Screens, and Menu items.
|
|
|
|
|
///
|
|
|
|
|
/// The images are built using a dictionary (`VGADICT`), metadata (`VGAHEAD`),
|
|
|
|
|
/// and Huffman-compressed image chunks (`VGAGRAPH`).
|
|
|
|
|
static List<VgaImage> parseVgaImages(
|
|
|
|
|
ByteData vgaDict,
|
|
|
|
|
ByteData vgaHead,
|
|
|
|
|
@@ -287,7 +306,10 @@ abstract class WLParser {
|
|
|
|
|
"Episode 6\nConfrontation",
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
/// Parses MAPHEAD and GAMEMAPS to extract the raw level data.
|
|
|
|
|
/// Parses `MAPHEAD` and `GAMEMAPS` to extract the raw level data.
|
|
|
|
|
///
|
|
|
|
|
/// Level files undergo two layers of decompression (Carmack + RLEW). The map
|
|
|
|
|
/// data is then translated into 64x64 grids representing the walls and objects.
|
|
|
|
|
static List<Episode> parseEpisodes(
|
|
|
|
|
ByteData mapHead,
|
|
|
|
|
ByteData gameMaps, {
|
|
|
|
|
@@ -400,6 +422,9 @@ abstract class WLParser {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extracts AdLib sounds and IMF music tracks from the audio files.
|
|
|
|
|
///
|
|
|
|
|
/// Splits the sequential chunks in `AUDIOT` based on the offsets provided
|
|
|
|
|
/// in `AUDIOHED`. Converts raw music bytes into structured [ImfMusic] objects.
|
|
|
|
|
static ({List<PcmSound> adLib, List<ImfMusic> music}) parseAudio(
|
|
|
|
|
ByteData audioHed,
|
|
|
|
|
ByteData audioT,
|
|
|
|
|
@@ -452,40 +477,41 @@ abstract class WLParser {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- ALGORITHM 1: CARMACK EXPANSION ---
|
|
|
|
|
/// Expands data using the "Carmackization" near/far pointer compression.
|
|
|
|
|
///
|
|
|
|
|
/// This is the first layer of decompression used for level data in GAMEMAPS.
|
|
|
|
|
/// It works similarly to LZSS, matching near (`0xA7`) and far (`0xA8`) pointers
|
|
|
|
|
/// to replay previously decompressed data words.
|
|
|
|
|
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;
|
|
|
|
|
int expandedLengthWords = data.getUint16(0, Endian.little) ~/ 2;
|
|
|
|
|
Uint16List expanded = Uint16List(expandedLengthWords);
|
|
|
|
|
|
|
|
|
|
int inIdx = 2; // Skip the length word we just read
|
|
|
|
|
int inIdx = 2; // Word-based index
|
|
|
|
|
int outIdx = 0;
|
|
|
|
|
|
|
|
|
|
while (outIdx < expandedLengthWords && inIdx < compressed.length) {
|
|
|
|
|
while (outIdx < expandedLengthWords) {
|
|
|
|
|
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
|
|
|
|
|
// Carmack pointers: 0xA7 (Near) and 0xA8 (Far)
|
|
|
|
|
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.
|
|
|
|
|
// Edge case: if low byte is 0, it's a literal tag byte
|
|
|
|
|
int nextByte = data.getUint8(inIdx++);
|
|
|
|
|
expanded[outIdx++] = (nextByte << 8) | highByte;
|
|
|
|
|
} else if (highByte == 0xA7) {
|
|
|
|
|
// 0xA7 = Near Pointer (look back a few spaces)
|
|
|
|
|
// Near pointer: Copy [lowByte] words from [offset] back in the output
|
|
|
|
|
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)
|
|
|
|
|
} else {
|
|
|
|
|
// Far pointer: Copy [lowByte] words from absolute [offset] in the output
|
|
|
|
|
int offset = data.getUint16(inIdx, Endian.little);
|
|
|
|
|
inIdx += 2;
|
|
|
|
|
for (int i = 0; i < lowByte; i++) {
|
|
|
|
|
@@ -493,7 +519,6 @@ abstract class WLParser {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// Normal, uncompressed word
|
|
|
|
|
expanded[outIdx++] = word;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@@ -501,6 +526,10 @@ abstract class WLParser {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- ALGORITHM 2: RLEW EXPANSION ---
|
|
|
|
|
/// Expands data using Run-Length Encoding on Word boundaries.
|
|
|
|
|
///
|
|
|
|
|
/// This is the second layer of decompression for level maps. It identifies a
|
|
|
|
|
/// special marker ([rlewTag]) and expands the subsequent count and value.
|
|
|
|
|
static List<int> _expandRlew(Uint16List carmackExpanded, int rlewTag) {
|
|
|
|
|
// The first word is the expanded length in BYTES
|
|
|
|
|
int expandedLengthBytes = carmackExpanded[0];
|
|
|
|
|
@@ -530,6 +559,9 @@ abstract class WLParser {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Extracts decompressed VGA data chunks (UI, Fonts, Pictures)
|
|
|
|
|
///
|
|
|
|
|
/// Safely manages offsets and handles out-of-bounds safety checks when
|
|
|
|
|
/// reading from the `VGAGRAPH` blob.
|
|
|
|
|
static List<Uint8List> _parseVgaRaw(
|
|
|
|
|
ByteData vgaDict,
|
|
|
|
|
ByteData vgaHead,
|
|
|
|
|
@@ -610,6 +642,11 @@ abstract class WLParser {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- ALGORITHM 3: HUFFMAN EXPANSION ---
|
|
|
|
|
/// Decompresses data using id Software's Huffman algorithm.
|
|
|
|
|
///
|
|
|
|
|
/// Traverses a binary tree derived from `VGADICT`. Bits from the [compressed]
|
|
|
|
|
/// data stream dictate left/right traversal until a leaf node is found, at which
|
|
|
|
|
/// point the data byte is appended to the output buffer.
|
|
|
|
|
static Uint8List _expandHuffman(
|
|
|
|
|
Uint8List compressed,
|
|
|
|
|
List<_HuffmanNode> dict,
|
|
|
|
|
@@ -620,34 +657,31 @@ abstract class WLParser {
|
|
|
|
|
int outIdx = 0;
|
|
|
|
|
int byteIdx = 0;
|
|
|
|
|
int bitMask = 1;
|
|
|
|
|
int currentNode = 254; // id Software's Huffman root is always node 254
|
|
|
|
|
int currentNode = 254; // Root node is always 254 in id's implementation
|
|
|
|
|
|
|
|
|
|
while (outIdx < expandedLength && byteIdx < compressed.length) {
|
|
|
|
|
// Read the current bit (LSB to MSB)
|
|
|
|
|
// Extract bits from Least Significant Bit (LSB) to Most (MSB)
|
|
|
|
|
int bit = (compressed[byteIdx] & bitMask) == 0 ? 0 : 1;
|
|
|
|
|
|
|
|
|
|
// Advance to the next bit/byte
|
|
|
|
|
// Move to next bit/byte
|
|
|
|
|
bitMask <<= 1;
|
|
|
|
|
if (bitMask > 128) {
|
|
|
|
|
bitMask = 1;
|
|
|
|
|
byteIdx++;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Traverse the tree
|
|
|
|
|
// If bit is 0, take the left path; if 1, take the right path
|
|
|
|
|
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!)
|
|
|
|
|
// Found a leaf node: write the byte and reset to root
|
|
|
|
|
expanded[outIdx++] = nextVal;
|
|
|
|
|
currentNode =
|
|
|
|
|
254; // Reset to the root of the tree for the next character
|
|
|
|
|
currentNode = 254;
|
|
|
|
|
} else {
|
|
|
|
|
// If the value is >= 256, it's a pointer to the next internal node.
|
|
|
|
|
// Node indexes are offset by 256.
|
|
|
|
|
// Internal node: pointer to the next branch (offset by 256)
|
|
|
|
|
currentNode = nextVal - 256;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return expanded;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|