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'; 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 loadAsync( Future 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'), audioHed: await fileFetcher('AUDIOHED.$ext'), audioT: await fileFetcher('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, }) { final isShareware = version == GameVersion.shareware; final audio = parseAudio(audioHed, audioT); 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), adLibSounds: audio.adLib, music: audio.music, ); } /// Extracts the 64x64 wall textures from VSWAP.WL1 static List 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 parseSprites(ByteData vswap) { final header = _VswapHeader(vswap); final sprites = []; // 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 parseSounds(ByteData vswap) { final header = _VswapHeader(vswap); final lengthStart = 6 + (header.chunks * 4); final sounds = []; // 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) { // Generate the 64x64 pixel grid in column-major order functionally return List.generate( 64, (x) => List.generate(64, (y) => vswap.getUint8(offset + (x * 64) + y)), ); } static Sprite _parseSingleSprite(ByteData vswap, int offset) { // Initialize the 64x64 grid with 255 (The Magenta Transparency Color!) Sprite sprite = List.generate(64, (_) => List.filled(64, 255)); int leftPix = vswap.getUint16(offset, Endian.little); int rightPix = vswap.getUint16(offset + 2, Endian.little); // Parse vertical columns within the sprite bounds 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, ) { // Execute the column drawing commands while (true) { int endY = vswap.getUint16(cmdOffset, Endian.little); if (endY == 0) break; // 0 marks the end of the column endY ~/= 2; // Wolf3D stores Y coordinates multiplied by 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++) { // The Carmack 286 Hack: pixelOfs + y gives the exact byte address sprite[x][y] = vswap.getUint8(baseOffset + pixelOfs + y); } cmdOffset += 6; // Move to the next 6-byte instruction } } /// Extracts and decodes the UI graphics, Title Screens, and Menu items static List parseVgaImages( ByteData vgaDict, ByteData vgaHead, ByteData vgaGraph, ) { // 1. Get all raw decompressed chunks using the Huffman algorithm List 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 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 parseMaps( ByteData mapHead, ByteData gameMaps, { bool isShareware = true, }) { List levels = []; int rlewTag = mapHead.getUint16(0, Endian.little); for (int i = 0; i < 100; i++) { int mapOffset = mapHead.getUint32(2 + (i * 4), Endian.little); if (mapOffset == 0) continue; 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); List nameBytes = []; for (int n = 0; n < 16; n++) { int charCode = gameMaps.getUint8(mapOffset + 22 + n); if (charCode == 0) break; nameBytes.add(charCode); } String name = ascii.decode(nameBytes); // --- DECOMPRESS PLANES --- final compressedWallData = gameMaps.buffer.asUint8List( plane0Offset, plane0Length, ); Uint16List carmackExpandedWalls = _expandCarmack(compressedWallData); List flatWallGrid = _expandRlew(carmackExpandedWalls, rlewTag); final compressedObjectData = gameMaps.buffer.asUint8List( plane1Offset, plane1Length, ); Uint16List carmackExpandedObjects = _expandCarmack(compressedObjectData); List flatObjectGrid = _expandRlew(carmackExpandedObjects, rlewTag); // --- BUILD GRIDS --- List> wallGrid = []; List> objectGrid = []; for (int y = 0; y < 64; y++) { List wallRow = []; List 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); } levels.add( WolfLevel( name: name, wallGrid: wallGrid, objectGrid: objectGrid, ), ); } return levels; } /// Extracts AdLib sounds and IMF music tracks from the audio files. static ({List adLib, List music}) parseAudio( ByteData audioHed, ByteData audioT, ) { List offsets = []; // AUDIOHED is a series of 32-bit unsigned integers for (int i = 0; i < audioHed.lengthInBytes ~/ 4; i++) { offsets.add(audioHed.getUint32(i * 4, Endian.little)); } List allAudioChunks = []; for (int i = 0; i < offsets.length - 1; i++) { int start = offsets[i]; int next = offsets[i + 1]; // 0xFFFFFFFF (or 4294967295) marks an empty slot 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), ); } } // Wolfenstein 3D split: // Chunks 0-299: AdLib Sounds // Chunks 300+: IMF Music List adLib = allAudioChunks .take(300) .map((bytes) => AdLibSound(bytes)) .toList(); List music = allAudioChunks .skip(300) .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 _expandRlew(Uint16List carmackExpanded, int rlewTag) { // The first word is the expanded length in BYTES int expandedLengthBytes = carmackExpanded[0]; int expandedLengthWords = expandedLengthBytes ~/ 2; List rlewExpanded = List.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 _parseVgaRaw( ByteData vgaDict, ByteData vgaHead, ByteData vgaGraph, ) { List 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 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 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); }