Smarter asset loading. Better audio rendering.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-15 13:40:47 +01:00
parent 649a1419a8
commit 27713dbbfb
5 changed files with 122 additions and 86 deletions

9
.vscode/launch.json vendored
View File

@@ -7,19 +7,22 @@
{ {
"name": "wolf_dart", "name": "wolf_dart",
"request": "launch", "request": "launch",
"type": "dart" "type": "dart",
"program": "lib/main.dart"
}, },
{ {
"name": "wolf_dart (profile mode)", "name": "wolf_dart (profile mode)",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"flutterMode": "profile" "flutterMode": "profile",
"program": "lib/main.dart"
}, },
{ {
"name": "wolf_dart (release mode)", "name": "wolf_dart (release mode)",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"flutterMode": "release" "flutterMode": "release",
"program": "lib/main.dart"
} }
] ]
} }

View File

@@ -59,6 +59,15 @@ class GameSelectScreen extends StatelessWidget {
); );
} }
Future<ByteData?> tryLoad(String path) async {
try {
return await rootBundle.load(path);
} catch (e) {
debugPrint("Asset not found: $path");
return null;
}
}
Future<List<WolfensteinData>> loadData({String? directory}) async { Future<List<WolfensteinData>> loadData({String? directory}) async {
final List<WolfensteinData> loadedGames = []; final List<WolfensteinData> loadedGames = [];
@@ -68,40 +77,28 @@ class GameSelectScreen extends StatelessWidget {
(version: GameVersion.shareware, path: 'shareware'), (version: GameVersion.shareware, path: 'shareware'),
]; ];
for (final config in versionsToTry) { for (final version in versionsToTry) {
try { try {
final ext = version.version.fileExtension;
final folder = 'assets/${version.path}';
final data = WolfensteinLoader.loadFromBytes( final data = WolfensteinLoader.loadFromBytes(
version: config.version, version: version.version,
vswap: await rootBundle.load( vswap: await tryLoad('$folder/VSWAP.$ext'),
'assets/${config.path}/VSWAP.${config.version.fileExtension}', mapHead: await tryLoad('$folder/MAPHEAD.$ext'),
), gameMaps: await tryLoad('$folder/GAMEMAPS.$ext'),
mapHead: await rootBundle.load( vgaDict: await tryLoad('$folder/VGADICT.$ext'),
'assets/${config.path}/MAPHEAD.${config.version.fileExtension}', vgaHead: await tryLoad('$folder/VGAHEAD.$ext'),
), vgaGraph: await tryLoad('$folder/VGAGRAPH.$ext'),
gameMaps: await rootBundle.load( audioHed: await tryLoad('$folder/AUDIOHED.$ext'),
'assets/${config.path}/GAMEMAPS.${config.version.fileExtension}', audioT: await tryLoad('$folder/AUDIOT.$ext'),
),
vgaDict: await rootBundle.load(
'assets/${config.path}/VGADICT.${config.version.fileExtension}',
),
vgaHead: await rootBundle.load(
'assets/${config.path}/VGAHEAD.${config.version.fileExtension}',
),
vgaGraph: await rootBundle.load(
'assets/${config.path}/VGAGRAPH.${config.version.fileExtension}',
),
audioHed: await rootBundle.load(
'assets/${config.path}/AUDIOHED.${config.version.fileExtension}',
),
audioT: await rootBundle.load(
'assets/${config.path}/AUDIOT.${config.version.fileExtension}',
),
); );
loadedGames.add(data); loadedGames.add(data);
} catch (e) { } catch (e) {
debugPrint( // The loader now provides the specific error:
"Bundled ${config.version.name} not found or failed to load.", // "ArgumentError: Cannot load retail: Missing files: VSWAP.WL6, ..."
); debugPrint(e.toString());
} }
} }

View File

@@ -23,30 +23,53 @@ class WolfensteinLoader {
); );
} }
/// Parses WolfensteinData directly from raw ByteData. /// Parses WolfensteinData from raw ByteData.
/// This is 100% pure Dart and is safe to use on all platforms, including Web. /// Throws an [ArgumentError] if any required file is null.
static WolfensteinData loadFromBytes({ static WolfensteinData loadFromBytes({
required GameVersion version, required GameVersion version,
required ByteData vswap, required ByteData? vswap,
required ByteData mapHead, required ByteData? mapHead,
required ByteData gameMaps, required ByteData? gameMaps,
required ByteData vgaDict, required ByteData? vgaDict,
required ByteData vgaHead, required ByteData? vgaHead,
required ByteData vgaGraph, required ByteData? vgaGraph,
required ByteData audioHed, required ByteData? audioHed,
required ByteData audioT, required ByteData? audioT,
}) { }) {
// We just act as a clean pass-through to the core parser // 1. Validation Check
final Map<String, ByteData?> files = {
'VSWAP': vswap,
'MAPHEAD': mapHead,
'GAMEMAPS': gameMaps,
'VGADICT': vgaDict,
'VGAHEAD': vgaHead,
'VGAGRAPH': vgaGraph,
'AUDIOHED': audioHed,
'AUDIOT': audioT,
};
final missing = files.entries
.where((e) => e.value == null)
.map((e) => "${e.key}.${version.fileExtension}")
.toList();
if (missing.isNotEmpty) {
throw ArgumentError(
'Cannot load ${version.name}: Missing files: ${missing.join(", ")}',
);
}
// 2. Pass-through to parser now that we are guaranteed non-null
return WLParser.load( return WLParser.load(
version: version, version: version,
vswap: vswap, vswap: vswap!,
mapHead: mapHead, mapHead: mapHead!,
gameMaps: gameMaps, gameMaps: gameMaps!,
vgaDict: vgaDict, vgaDict: vgaDict!,
vgaHead: vgaHead, vgaHead: vgaHead!,
vgaGraph: vgaGraph, vgaGraph: vgaGraph!,
audioHed: audioHed, audioHed: audioHed!,
audioT: audioT, audioT: audioT!,
); );
} }
} }

View File

@@ -5,75 +5,89 @@ import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'opl2_emulator.dart'; import 'opl2_emulator.dart';
class ImfRenderer { class ImfRenderer {
/// Renders an ImfMusic track into raw 16-bit PCM samples /// Renders music in Stereo with a 10ms Haas widening effect.
static Int16List render(ImfMusic music) { static Int16List render(ImfMusic music) {
final emulator = Opl2Emulator(); final emulator = Opl2Emulator();
const double samplesPerTick = Opl2Emulator.sampleRate / 700.0; const double samplesPerTick = Opl2Emulator.sampleRate / 700.0;
// 1. Pre-calculate the total number of audio samples we need
int totalTicks = 0; int totalTicks = 0;
for (final inst in music.instructions) { for (final inst in music.instructions) {
totalTicks += inst.delay; totalTicks += inst.delay;
} }
int totalSamples = (totalTicks * samplesPerTick).round();
// 2. Pre-allocate a fixed-size buffer (blazing fast!) // Allocate buffer for Stereo (2 channels per sample)
final pcmBuffer = Int16List(totalSamples); int totalMonoSamples = (totalTicks * samplesPerTick).round();
final pcmBuffer = Int16List(totalMonoSamples * 2);
int sampleIndex = 0; int sampleIndex = 0;
for (final instruction in music.instructions) { // --- Stereo Widening (Haas Effect) ---
// Write the instruction to the chip // 10ms at 44.1kHz = 441 samples.
emulator.writeRegister(instruction.register, instruction.data); // This is tight enough to sound like 'width' rather than 'echo'.
const int delaySamples = 441;
final delayLine = Float32List(delaySamples);
int delayPtr = 0;
// Generate the samples for the delay period for (final instruction in music.instructions) {
emulator.writeRegister(instruction.register, instruction.data);
int samplesToGenerate = (instruction.delay * samplesPerTick).round(); int samplesToGenerate = (instruction.delay * samplesPerTick).round();
for (int i = 0; i < samplesToGenerate; i++) { for (int i = 0; i < samplesToGenerate; i++) {
if (sampleIndex >= pcmBuffer.length) break; // Safety bounds if (sampleIndex + 1 >= pcmBuffer.length) break;
double sampleFloat = emulator.generateSample(); double monoSample = emulator.generateSample();
int sampleInt = (sampleFloat * 32767).toInt().clamp(-32768, 32767);
pcmBuffer[sampleIndex++] = sampleInt; // Left channel: Immediate
double left = monoSample;
// Right channel: Delayed
double right = delayLine[delayPtr];
delayLine[delayPtr] = monoSample;
delayPtr = (delayPtr + 1) % delaySamples;
// Interleave L and R
pcmBuffer[sampleIndex++] = (left * 32767).toInt().clamp(-32768, 32767);
pcmBuffer[sampleIndex++] = (right * 32767).toInt().clamp(-32768, 32767);
} }
} }
return pcmBuffer; return pcmBuffer;
} }
/// Wraps raw 16-bit PCM data in a standard WAV file header /// Wraps raw PCM data into a standard Stereo WAV file header.
static Uint8List createWavFile(Int16List pcmData) { static Uint8List createWavFile(
int byteRate = Opl2Emulator.sampleRate * 2; // 1 channel * 16-bit (2 bytes) Int16List pcmData, {
int dataSize = pcmData.length * 2; int sampleRate = Opl2Emulator.sampleRate,
int fileSize = 36 + dataSize; }) {
const int numChannels = 2;
const int bytesPerSample = 2;
const int blockAlign = numChannels * bytesPerSample;
final int dataSize = pcmData.length * bytesPerSample;
final int byteRate = sampleRate * blockAlign;
final int fileSize = 36 + dataSize;
final bytes = BytesBuilder(); final bytes = BytesBuilder();
// "RIFF" chunk descriptor // RIFF header
bytes.add('RIFF'.codeUnits); bytes.add('RIFF'.codeUnits);
bytes.add(_int32ToBytes(fileSize)); bytes.add(_int32ToBytes(fileSize));
bytes.add('WAVE'.codeUnits); bytes.add('WAVE'.codeUnits);
// "fmt " sub-chunk // Format chunk
bytes.add('fmt '.codeUnits); bytes.add('fmt '.codeUnits);
bytes.add(_int32ToBytes(16)); // Subchunk1Size (16 for PCM) bytes.add(_int32ToBytes(16));
bytes.add(_int16ToBytes(1)); // AudioFormat (1 = PCM) bytes.add(_int16ToBytes(1));
bytes.add(_int16ToBytes(1)); // NumChannels (1 = Mono) bytes.add(_int16ToBytes(numChannels));
bytes.add(_int32ToBytes(Opl2Emulator.sampleRate)); // SampleRate bytes.add(_int32ToBytes(sampleRate));
bytes.add(_int32ToBytes(byteRate)); // ByteRate bytes.add(_int32ToBytes(byteRate));
bytes.add(_int16ToBytes(2)); // BlockAlign bytes.add(_int16ToBytes(blockAlign));
bytes.add(_int16ToBytes(16)); // BitsPerSample bytes.add(_int16ToBytes(16));
// "data" sub-chunk // Data chunk
bytes.add('data'.codeUnits); bytes.add('data'.codeUnits);
bytes.add(_int32ToBytes(dataSize)); bytes.add(_int32ToBytes(dataSize));
// Append the actual raw audio data (Blazing Fast version)
final byteData = ByteData(dataSize); final byteData = ByteData(dataSize);
for (int i = 0; i < pcmData.length; i++) { for (int i = 0; i < pcmData.length; i++) {
// Multiply by 2 because each Int16 takes up 2 bytes.
// Endian.little is strictly required for the standard WAV format.
byteData.setInt16(i * 2, pcmData[i], Endian.little); byteData.setInt16(i * 2, pcmData[i], Endian.little);
} }
bytes.add(byteData.buffer.asUint8List()); bytes.add(byteData.buffer.asUint8List());
@@ -85,7 +99,6 @@ class ImfRenderer {
value & 0xff, value & 0xff,
(value >> 8) & 0xff, (value >> 8) & 0xff,
]; ];
static List<int> _int32ToBytes(int value) => [ static List<int> _int32ToBytes(int value) => [
value & 0xff, value & 0xff,
(value >> 8) & 0xff, (value >> 8) & 0xff,

View File

@@ -3,4 +3,4 @@
/// More dartdocs go here. /// More dartdocs go here.
library; library;
export 'src/imf_renderer.dart' show ImfRenderer; export 'src/imf_renderer.dart' show ImfRenderer, NumChannels;