From fc5e07ea100a6cdf434685ddeaf93112c7eb0229 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Sat, 14 Mar 2026 23:30:51 +0100 Subject: [PATCH] Moving level loading into the package Signed-off-by: Hans Kokx --- lib/features/map/wolf_map.dart | 6 +- lib/features/map/wolf_map_parser.dart | 187 ------------------ packages/wolf_3d_data/analysis_options.yaml | 32 +-- .../lib/src/classes/game_version.dart | 11 ++ .../lib/src/classes}/wolf_level.dart | 2 +- .../lib/src/classes/wolfenstein_data.dart | 81 ++++++++ packages/wolf_3d_data/lib/src/wl_parser.dart | 150 ++++++++++++++ packages/wolf_3d_data/lib/wolf_3d_data.dart | 2 + 8 files changed, 250 insertions(+), 221 deletions(-) delete mode 100644 lib/features/map/wolf_map_parser.dart create mode 100644 packages/wolf_3d_data/lib/src/classes/game_version.dart rename {lib/features/map => packages/wolf_3d_data/lib/src/classes}/wolf_level.dart (92%) create mode 100644 packages/wolf_3d_data/lib/src/classes/wolfenstein_data.dart diff --git a/lib/features/map/wolf_map.dart b/lib/features/map/wolf_map.dart index 3fc6fd8..bef3d60 100644 --- a/lib/features/map/wolf_map.dart +++ b/lib/features/map/wolf_map.dart @@ -1,7 +1,5 @@ import 'package:flutter/services.dart'; import 'package:wolf_3d_data/wolf_3d_data.dart'; -import 'package:wolf_dart/features/map/wolf_level.dart'; -import 'package:wolf_dart/features/map/wolf_map_parser.dart'; class WolfMap { /// The fully parsed and decompressed levels from the game files. @@ -24,7 +22,7 @@ class WolfMap { final vswap = await rootBundle.load("assets/VSWAP.WL1"); // 2. Parse the data using the parser we just built - final parsedLevels = WolfMapParser.parseMaps( + final parsedLevels = WLParser.parseMaps( mapHead, gameMaps, isShareware: true, @@ -48,7 +46,7 @@ class WolfMap { final vswap = await rootBundle.load("assets/VSWAP.WL6"); // 2. Parse the data using the parser we just built - final parsedLevels = WolfMapParser.parseMaps(mapHead, gameMaps); + final parsedLevels = WLParser.parseMaps(mapHead, gameMaps); final parsedTextures = WLParser.parseWalls(vswap); final parsedSprites = WLParser.parseSprites(vswap); diff --git a/lib/features/map/wolf_map_parser.dart b/lib/features/map/wolf_map_parser.dart deleted file mode 100644 index adc9dc0..0000000 --- a/lib/features/map/wolf_map_parser.dart +++ /dev/null @@ -1,187 +0,0 @@ -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:wolf_dart/features/entities/map_objects.dart'; -import 'package:wolf_dart/features/map/wolf_level.dart'; - -abstract class WolfMapParser { - /// Parses MAPHEAD and GAMEMAPS to extract the raw level data. - static List parseMaps( - ByteData mapHead, - ByteData gameMaps, { - bool isShareware = true, - }) { - List levels = []; - - // 1. READ MAPHEAD - // The very first 16-bit word in MAPHEAD is the RLEW tag (usually 0xABCD) - // We will need this later for decompression! - int rlewTag = mapHead.getUint16(0, Endian.little); - - // MAPHEAD contains up to 100 levels. - // Starting at byte 2, there are 100 32-bit integers representing - // the byte offset of each level's header inside GAMEMAPS. - for (int i = 0; i < 100; i++) { - int mapOffset = mapHead.getUint32(2 + (i * 4), Endian.little); - - // An offset of 0 means the level doesn't exist (end of the list) - if (mapOffset == 0) continue; - - // 2. READ GAMEMAPS HEADER - // Jump to the offset in GAMEMAPS to read the 38-byte Level Header - - // Pointers to the compressed data for the 3 planes (Walls, Objects, Extra) - int plane0Offset = gameMaps.getUint32(mapOffset + 0, Endian.little); - int plane1Offset = gameMaps.getUint32(mapOffset + 4, Endian.little); - // Plane 2 (offset + 8) is usually unused in standard Wolf3D - - // Lengths of the compressed data for each plane - int plane0Length = gameMaps.getUint16(mapOffset + 12, Endian.little); - int plane1Length = gameMaps.getUint16(mapOffset + 14, Endian.little); - - // Map Name (16 bytes of ASCII text) - List nameBytes = []; - for (int n = 0; n < 16; n++) { - int charCode = gameMaps.getUint8(mapOffset + 22 + n); - if (charCode == 0) break; // Null terminator - nameBytes.add(charCode); - } - String name = ascii.decode(nameBytes); - - // 3. EXTRACT AND DECOMPRESS BOTH PLANES - - // --- PLANE 0: WALLS --- - final compressedWallData = gameMaps.buffer.asUint8List( - plane0Offset, - plane0Length, - ); - Uint16List carmackExpandedWalls = _expandCarmack(compressedWallData); - List flatWallGrid = _expandRlew(carmackExpandedWalls, rlewTag); - - // --- PLANE 1: OBJECTS (NEW) --- - final compressedObjectData = gameMaps.buffer.asUint8List( - plane1Offset, - plane1Length, - ); - Uint16List carmackExpandedObjects = _expandCarmack(compressedObjectData); - List flatObjectGrid = _expandRlew(carmackExpandedObjects, rlewTag); - - for (int i = 0; i < flatObjectGrid.length; i++) { - int id = flatObjectGrid[i]; - - // Handle the 'secret' pushwalls (Logic check) - if (id == MapObject.pushwallTrigger) { - // In Wolf3D, ID 98 means the wall at this same index in Plane 0 is pushable. - // You might want to mark this in your engine state. - } - - // Filter out invalid IDs for Shareware to prevent crashes - if (isShareware && !MapObject.isSharewareCompatible(id)) { - flatObjectGrid[i] = 0; // Turn unknown objects into empty space - } - } - - 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; - } - - // --- 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; - } -} diff --git a/packages/wolf_3d_data/analysis_options.yaml b/packages/wolf_3d_data/analysis_options.yaml index dee8927..85ac016 100644 --- a/packages/wolf_3d_data/analysis_options.yaml +++ b/packages/wolf_3d_data/analysis_options.yaml @@ -1,30 +1,4 @@ -# This file configures the static analysis results for your project (errors, -# warnings, and lints). -# -# This enables the 'recommended' set of lints from `package:lints`. -# This set helps identify many issues that may lead to problems when running -# or consuming Dart code, and enforces writing Dart using a single, idiomatic -# style and format. -# -# If you want a smaller set of lints you can change this to specify -# 'package:lints/core.yaml'. These are just the most critical lints -# (the recommended set includes the core lints). -# The core lints are also what is used by pub.dev for scoring packages. +include: package:flutter_lints/flutter.yaml -include: package:lints/recommended.yaml - -# Uncomment the following section to specify additional rules. - -# linter: -# rules: -# - camel_case_types - -# analyzer: -# exclude: -# - path/to/excluded/files/** - -# For more information about the core and recommended set of lints, see -# https://dart.dev/go/core-lints - -# For additional information about configuring this file, see -# https://dart.dev/guides/language/analysis-options +formatter: + trailing_commas: preserve diff --git a/packages/wolf_3d_data/lib/src/classes/game_version.dart b/packages/wolf_3d_data/lib/src/classes/game_version.dart new file mode 100644 index 0000000..ba1b2d1 --- /dev/null +++ b/packages/wolf_3d_data/lib/src/classes/game_version.dart @@ -0,0 +1,11 @@ +enum GameVersion { + shareware("WL1"), + retail("WL6"), + spearOfDestinyDemo("SDM"), + spearOfDestiny("SOD"), + ; + + final String fileExtension; + + const GameVersion(this.fileExtension); +} diff --git a/lib/features/map/wolf_level.dart b/packages/wolf_3d_data/lib/src/classes/wolf_level.dart similarity index 92% rename from lib/features/map/wolf_level.dart rename to packages/wolf_3d_data/lib/src/classes/wolf_level.dart index e10e208..331cc19 100644 --- a/lib/features/map/wolf_level.dart +++ b/packages/wolf_3d_data/lib/src/classes/wolf_level.dart @@ -5,7 +5,7 @@ class WolfLevel { final Sprite wallGrid; final Sprite objectGrid; - WolfLevel({ + const WolfLevel({ required this.name, required this.wallGrid, required this.objectGrid, diff --git a/packages/wolf_3d_data/lib/src/classes/wolfenstein_data.dart b/packages/wolf_3d_data/lib/src/classes/wolfenstein_data.dart new file mode 100644 index 0000000..8e3e0a3 --- /dev/null +++ b/packages/wolf_3d_data/lib/src/classes/wolfenstein_data.dart @@ -0,0 +1,81 @@ +import 'dart:typed_data'; + +import 'package:wolf_3d_data/src/wl_parser.dart'; + +import 'game_version.dart'; +import 'sprite.dart'; +import 'wolf_level.dart'; + +class WolfensteinData { + final GameVersion version; + + // Raw file data references + final ByteData _vswap; + final ByteData _mapHead; + final ByteData _gameMaps; + + // Backing fields for lazy loading + List? _walls; + List? _sprites; + List? _levels; + + WolfensteinData._({ + required this.version, + required ByteData vswap, + required ByteData mapHead, + required ByteData gameMaps, + }) : _vswap = vswap, + _mapHead = mapHead, + _gameMaps = gameMaps; + + /// Initializes the data from a map of filenames to their byte contents. + /// Automatically detects the game version from the file extensions. + factory WolfensteinData.fromFiles(Map files) { + if (files.isEmpty) throw ArgumentError('File map cannot be empty'); + + // 1. Detect Game Version from the first file's extension + final sampleFilename = files.keys.first.toUpperCase(); + final extension = sampleFilename.split('.').last; + + final version = GameVersion.values.firstWhere( + (v) => v.fileExtension == extension, + orElse: () => + throw FormatException('Unsupported file extension: $extension'), + ); + + // 2. Extract the required files using the detected extension + final vswap = _getFile(files, 'VSWAP.$extension'); + final mapHead = _getFile(files, 'MAPHEAD.$extension'); + final gameMaps = _getFile(files, 'GAMEMAPS.$extension'); + + return WolfensteinData._( + version: version, + vswap: vswap, + mapHead: mapHead, + gameMaps: gameMaps, + ); + } + + // --- Lazy Getters --- + + List get walls => _walls ??= WLParser.parseWalls(_vswap); + + List get sprites => _sprites ??= WLParser.parseSprites(_vswap); + + List get levels => _levels ??= WLParser.parseMaps( + _mapHead, + _gameMaps, + isShareware: version == GameVersion.shareware, + ); + + // --- Helpers --- + + static ByteData _getFile(Map files, String name) { + // Case-insensitive lookup + final key = files.keys.firstWhere( + (k) => k.toUpperCase() == name.toUpperCase(), + orElse: () => throw FormatException('Missing required file: $name'), + ); + return files[key]!; + } +} diff --git a/packages/wolf_3d_data/lib/src/wl_parser.dart b/packages/wolf_3d_data/lib/src/wl_parser.dart index d7b4a2f..289b5bf 100644 --- a/packages/wolf_3d_data/lib/src/wl_parser.dart +++ b/packages/wolf_3d_data/lib/src/wl_parser.dart @@ -1,5 +1,8 @@ +import 'dart:convert'; import 'dart:typed_data'; +import 'package:wolf_3d_data/src/classes/wolf_level.dart'; + import 'classes/sprite.dart'; class WLParser { @@ -110,6 +113,153 @@ class WLParser { cmdOffset += 6; // Move to the next 6-byte instruction } } + + /// 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; + } + + // --- 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; + } } /// Helper class to parse and store redundant VSWAP header data diff --git a/packages/wolf_3d_data/lib/wolf_3d_data.dart b/packages/wolf_3d_data/lib/wolf_3d_data.dart index 1a1bd6d..1d42f16 100644 --- a/packages/wolf_3d_data/lib/wolf_3d_data.dart +++ b/packages/wolf_3d_data/lib/wolf_3d_data.dart @@ -3,5 +3,7 @@ /// More dartdocs go here. library; +export 'src/classes/game_version.dart' show GameVersion; export 'src/classes/sprite.dart' hide Matrix; +export 'src/classes/wolf_level.dart' show WolfLevel; export 'src/wl_parser.dart' show WLParser;