641 lines
20 KiB
Dart
641 lines
20 KiB
Dart
import 'dart:convert';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
|
|
|
|
abstract class WLParser {
|
|
// --- Original Song Lookup Tables ---
|
|
static const List<int> _sharewareMusicMap = [
|
|
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Episode 1
|
|
];
|
|
|
|
static const List<int> _retailMusicMap = [
|
|
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Ep 1
|
|
8, 9, 10, 11, 8, 9, 11, 10, 6, 12, // Ep 2
|
|
13, 14, 15, 16, 13, 14, 15, 16, 17, 18, // Ep 3
|
|
2, 3, 4, 5, 2, 3, 4, 5, 6, 7, // Ep 4
|
|
8, 9, 10, 11, 8, 9, 11, 10, 6, 12, // Ep 5
|
|
13, 14, 15, 16, 13, 14, 15, 16, 17, 19, // Ep 6
|
|
];
|
|
|
|
/// 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, version);
|
|
|
|
return WolfensteinData(
|
|
version: version,
|
|
walls: parseWalls(vswap),
|
|
sprites: parseSprites(vswap),
|
|
sounds: parseSounds(vswap).map((bytes) => PcmSound(bytes)).toList(),
|
|
episodes: parseEpisodes(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;
|
|
}
|
|
|
|
// --- Episode Names (From the original C Executable) ---
|
|
static const List<String> _sharewareEpisodeNames = [
|
|
"Episode 1\nEscape from Wolfenstein",
|
|
];
|
|
|
|
static const List<String> _retailEpisodeNames = [
|
|
"Episode 1\nEscape from Wolfenstein",
|
|
"Episode 2\nOperation: Eisenfaust",
|
|
"Episode 3\nDie, Fuhrer, Die!",
|
|
"Episode 4\nA Dark Secret",
|
|
"Episode 5\nTrail of the Madman",
|
|
"Episode 6\nConfrontation",
|
|
];
|
|
|
|
/// Parses MAPHEAD and GAMEMAPS to extract the raw level data.
|
|
static List<Episode> parseEpisodes(
|
|
ByteData mapHead,
|
|
ByteData gameMaps, {
|
|
bool isShareware = true,
|
|
}) {
|
|
List<WolfLevel> allLevels = [];
|
|
int rlewTag = mapHead.getUint16(0, Endian.little);
|
|
|
|
// Select the correct music map based on the version
|
|
final activeMusicMap = isShareware ? _sharewareMusicMap : _retailMusicMap;
|
|
final episodeNames = isShareware
|
|
? _sharewareEpisodeNames
|
|
: _retailEpisodeNames;
|
|
|
|
// The game allows for up to 100 maps per file
|
|
for (int i = 0; i < 100; i++) {
|
|
int mapOffset = mapHead.getUint32(2 + (i * 4), Endian.little);
|
|
if (mapOffset == 0) continue; // Empty map slot
|
|
|
|
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);
|
|
|
|
// --- EXTRACT ACTUAL GAME DATA NAME ---
|
|
// The name is exactly 16 bytes long, starting at offset 22
|
|
List<int> nameBytes = [];
|
|
for (int n = 0; n < 16; n++) {
|
|
int charCode = gameMaps.getUint8(mapOffset + 22 + n);
|
|
if (charCode == 0) break; // Stop at the null-terminator
|
|
nameBytes.add(charCode);
|
|
}
|
|
String parsedName = 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 64x64 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);
|
|
}
|
|
|
|
// --- ASSIGN MUSIC ---
|
|
int trackIndex = (i < activeMusicMap.length)
|
|
? activeMusicMap[i]
|
|
: activeMusicMap[i % activeMusicMap.length];
|
|
|
|
allLevels.add(
|
|
WolfLevel(
|
|
name: parsedName,
|
|
wallGrid: wallGrid,
|
|
objectGrid: objectGrid,
|
|
musicIndex: trackIndex,
|
|
),
|
|
);
|
|
}
|
|
|
|
// 2. Group the parsed levels into Episodes!
|
|
List<Episode> episodes = [];
|
|
|
|
// Calculate how many episodes we need (10 levels per episode)
|
|
int totalEpisodes = (allLevels.length / 10).ceil();
|
|
|
|
for (int i = 0; i < totalEpisodes; i++) {
|
|
int startIndex = i * 10;
|
|
int endIndex = startIndex + 10;
|
|
|
|
// Safety clamp for incomplete episodes at the end of the file
|
|
if (endIndex > allLevels.length) {
|
|
endIndex = allLevels.length;
|
|
}
|
|
|
|
// If we run out of hardcoded id Software names, generate a custom one!
|
|
String epName = (i < episodeNames.length)
|
|
? episodeNames[i]
|
|
: "Episode ${i + 1}\nCustom Maps";
|
|
|
|
episodes.add(
|
|
Episode(
|
|
name: epName,
|
|
levels: allLevels.sublist(startIndex, endIndex),
|
|
),
|
|
);
|
|
}
|
|
|
|
return episodes;
|
|
}
|
|
|
|
/// Extracts AdLib sounds and IMF music tracks from the audio files.
|
|
static ({List<AdLibSound> adLib, List<ImfMusic> music}) parseAudio(
|
|
ByteData audioHed,
|
|
ByteData audioT,
|
|
GameVersion version,
|
|
) {
|
|
List<int> offsets = [];
|
|
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];
|
|
|
|
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),
|
|
);
|
|
}
|
|
}
|
|
|
|
// In Wolf3D v1.4 (Shareware and Retail), Music ALWAYS starts at chunk 261.
|
|
// Chunks 0-86: PC Sounds
|
|
// Chunks 87-173: AdLib Sounds
|
|
// Chunks 174-260: Digitized Sounds
|
|
int musicStartIndex = 261;
|
|
|
|
List<AdLibSound> adLib = allAudioChunks
|
|
.take(musicStartIndex)
|
|
.map((bytes) => AdLibSound(bytes))
|
|
.toList();
|
|
|
|
List<ImfMusic> music = allAudioChunks
|
|
.skip(musicStartIndex)
|
|
.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);
|
|
}
|