Added some dartdoc comments and an (untested) sixel rasterizer

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 12:37:11 +01:00
parent 552a80ecc8
commit a778e0f1fa
23 changed files with 593 additions and 75 deletions

View File

@@ -1,23 +1,31 @@
/// Represents the specific version identity of the Wolfenstein 3D data files.
///
/// Since files like 'VSWAP.WL6' exist across multiple releases (v1.0, v1.1, v1.4),
/// we use MD5 checksums of the VSWAP file to determine specific engine behaviors,
/// such as MAPTEMP support or HUD differences.
enum DataVersion {
/// V1.0 Retail (VSWAP.WL6)
/// Original v1.0 Retail release.
version10Retail('a6d901dfb455dfac96db5e4705837cdb'),
/// v1.1 Retail (VSWAP.WL6)
/// v1.1 Retail update.
version11Retail('a80904e0283a921d88d977b56c279b9d'),
/// v1.4 Shareware (VSWAP.WL1)
/// v1.4 Shareware release (VSWAP.WL1).
version14Shareware('6efa079414b817c97db779cecfb081c9'),
/// v1.4 Retail (VSWAP.WL6) - GOG/Steam version
/// v1.4 Retail release (found on GOG and Steam).
version14Retail('b8ff4997461bafa5ef2a94c11f9de001'),
unknown('unknown'),
/// Default state if the file hash is unrecognized.
unknown('unknown')
;
/// The MD5 hash of the VSWAP file associated with this version.
final String checksum;
const DataVersion(this.checksum);
/// Matches a provided [hash] string to a known [DataVersion].
static DataVersion fromChecksum(String hash) {
return DataVersion.values.firstWhere(
(v) => v.checksum == hash,

View File

@@ -37,7 +37,9 @@ Future<Map<GameVersion, WolfensteinData>> discoverInDirectory({
return fileName == expectedName;
}).firstOrNull;
// v1.0 FIX: Search for MAPTEMP if GAMEMAPS is missing
// v1.0 FIX: In the very first retail release, the maps were named
// MAPTEMP.WL6 instead of GAMEMAPS.WL6. We check for this alias
// to ensure v1.0 data is properly discovered.
if (match == null && requiredFile == GameFile.gameMaps) {
final altName = 'MAPTEMP.$ext';
match = allFiles.where((file) {

View File

@@ -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;
}
}

View File

@@ -9,8 +9,15 @@ import 'io/discovery_stub.dart'
as platform;
import 'wl_parser.dart';
/// The main entry point for loading Wolfenstein 3D data.
///
/// This class provides high-level methods to either scan a local filesystem
/// (on supported platforms) or load data directly from memory (Web/Flutter).
class WolfensteinLoader {
/// Scans a directory for Wolfenstein 3D data files and loads all available versions.
/// Scans [directoryPath] for Wolfenstein data files.
///
/// On Web, this will throw an [UnsupportedError]. On Desktop/Mobile, it will
/// attempt to identify and load all valid game versions found in the path.
static Future<Map<GameVersion, WolfensteinData>> discover({
String? directoryPath,
bool recursive = false,
@@ -21,8 +28,11 @@ class WolfensteinLoader {
);
}
/// Parses WolfensteinData from raw ByteData.
/// Throws an [ArgumentError] if any required file is null.
/// Manually loads game data from provided [ByteData] buffers.
///
/// Use this for Web or Flutter assets where automated directory scanning
/// is not possible. It automatically detects the [DataVersion] (v1.0, v1.4, etc.)
/// by hashing the [vswap] buffer.
static WolfensteinData loadFromBytes({
required GameVersion version,
required ByteData? vswap,
@@ -57,7 +67,7 @@ class WolfensteinLoader {
);
}
// 2. Identify the DataVersion via Checksum
// 2. Identify identity for specialized parsing (e.g., MAPTEMP vs GAMEMAPS)
final vswapBytes = vswap!.buffer.asUint8List(
vswap.offsetInBytes,
vswap.lengthInBytes,