Files
wolf_dart/packages/wolf_3d_data/lib/src/wl_parser.dart
2026-03-15 11:42:02 +01:00

574 lines
18 KiB
Dart

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<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'),
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<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) {
// 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<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,
ByteData gameMaps, {
bool isShareware = true,
}) {
List<WolfLevel> 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<int> 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<int> flatWallGrid = _expandRlew(carmackExpandedWalls, rlewTag);
final compressedObjectData = gameMaps.buffer.asUint8List(
plane1Offset,
plane1Length,
);
Uint16List carmackExpandedObjects = _expandCarmack(compressedObjectData);
List<int> flatObjectGrid = _expandRlew(carmackExpandedObjects, rlewTag);
// --- BUILD 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);
}
levels.add(
WolfLevel(
name: name,
wallGrid: wallGrid,
objectGrid: objectGrid,
),
);
}
return levels;
}
/// Extracts AdLib sounds and IMF music tracks from the audio files.
static ({List<AdLibSound> adLib, List<ImfMusic> music}) parseAudio(
ByteData audioHed,
ByteData audioT,
) {
List<int> 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<Uint8List> 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<AdLibSound> adLib = allAudioChunks
.take(300)
.map((bytes) => AdLibSound(bytes))
.toList();
List<ImfMusic> 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<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);
}