Refactor menu rendering and asset registry structure

- Updated SoftwareRenderer to incorporate MenuHeaderBand for handling spear variant menus and improved backdrop drawing.
- Refactored asset registry imports to organize menu-related assets under a dedicated menu structure.
- Enhanced game session snapshot tests to validate menu theme restoration for spear variant games.
- Added tests for classic menu presentation module to ensure palette consistency with canonical constants.
- Implemented tests for spear asset registry to verify correct menu VGA index resolutions.
- Created unit tests for MenuHeaderBand to validate functionality in rendering menu headers and sidebars.
- Adjusted HUD module imports to align with new menu structure.

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-24 23:35:56 +01:00
parent 5c309c2240
commit d393ca98ec
40 changed files with 1327 additions and 296 deletions
@@ -0,0 +1,215 @@
import 'dart:typed_data';
import 'package:test/test.dart';
import 'package:wolf_3d_dart/src/rendering/menu_header_band.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
void main() {
test('shared Spear variant utility is correct', () {
expect(
MenuHeaderBand.isSpearVariant(GameVersion.retail),
isFalse,
);
expect(
MenuHeaderBand.isSpearVariant(GameVersion.shareware),
isFalse,
);
expect(
MenuHeaderBand.isSpearVariant(GameVersion.spearOfDestiny),
isTrue,
);
expect(
MenuHeaderBand.isSpearVariant(GameVersion.spearOfDestinyDemo),
isTrue,
);
});
test(
'black rows and rows sandwiched between them are extended to screen sides',
() {
// Interior column (x=1): row 0 → 9 (leading non-black, skip),
// row 1 → 0 (black, fill),
// row 2 → 0 (black, fill),
// row 3 → 4 (trailing, skip), row 4 → 3 (trailing, skip).
final image = _imageFromRows([
[5, 9, 9, 5],
[6, 0, 8, 5],
[6, 0, 8, 0],
[7, 4, 4, 7],
[7, 3, 3, 7],
]);
final List<(int, int, int, int)> edgeRows = <(int, int, int, int)>[];
MenuHeaderBand.applyFromHeadingImage(
image: image,
imageX320: 10,
fillSideEdgesRow:
(
int y200,
int leftWidth320,
int rightStartX320,
int paletteIndex,
) {
edgeRows.add((y200, leftWidth320, rightStartX320, paletteIndex));
},
);
// Only rows 1 and 2 have interior-column value 0 (black) → side fills.
expect(
edgeRows,
<(int, int, int, int)>[
(1, 10, 14, 0),
(2, 10, 14, 0),
],
);
},
);
test('no side fills when all interior rows are non-black', () {
final image = _imageFromRows([
[9, 1, 1, 3],
[8, 2, 2, 3],
[7, 4, 5, 6],
[6, 6, 6, 6],
[5, 7, 7, 2],
]);
final List<(int, int, int, int)> edgeRows = <(int, int, int, int)>[];
MenuHeaderBand.applyFromHeadingImage(
image: image,
imageX320: 50,
fillSideEdgesRow:
(
int y200,
int leftWidth320,
int rightStartX320,
int paletteIndex,
) {
edgeRows.add((y200, leftWidth320, rightStartX320, paletteIndex));
},
);
expect(edgeRows, isEmpty);
});
test('algorithm trims long trailing rows after final black run', () {
final image = _imageFromRows([
[9, 1, 1, 3],
[8, 2, 2, 3],
[7, 0, 0, 7],
[7, 0, 0, 7],
[7, 0, 0, 7],
[6, 6, 6, 6],
[5, 5, 5, 5],
[4, 4, 4, 4],
[3, 3, 3, 3],
[2, 2, 2, 2],
[1, 1, 1, 1],
]);
final List<(int, int, int, int)> edgeRows = <(int, int, int, int)>[];
MenuHeaderBand.applyFromHeadingImage(
image: image,
imageX320: 50,
fillSideEdgesRow:
(
int y200,
int leftWidth320,
int rightStartX320,
int paletteIndex,
) {
edgeRows.add((y200, leftWidth320, rightStartX320, paletteIndex));
},
);
// Rows 2-4 are black (interior x=1 → 0). Rows 5-10 are non-black trailing;
// effectiveBandRowCount trims to lastBlack(4)+1+3 = 8 rows processed.
// Only the 3 black rows produce fills (no interior non-black rows here).
expect(edgeRows.length, 3);
expect(
edgeRows,
<(int, int, int, int)>[
(2, 50, 54, 0),
(3, 50, 54, 0),
(4, 50, 54, 0),
],
);
// firstColumn is trimmed to 8 entries (rows 0-7).
expect(
MenuHeaderBand.firstColumn(image),
<int>[1, 2, 0, 0, 0, 6, 5, 4],
);
});
test('interior non-black rows sandwiched between black rows are extended', () {
// Simulates a heading image with a decorative non-black stripe between
// two black sections (e.g. Wolf3D row 32).
// Interior column (x=1): row 0 → 5 (leading non-black, skip),
// row 1 → 0 (first black, fill with palette 0),
// row 2 → 7 (interior non-black stripe, fill with palette 7),
// row 3 → 0 (last black, fill with palette 0),
// rows 47 → trailing non-black (4 rows > maxTrailing=3,
// so effectiveBandRowCount trims to 7 rows).
final image = _imageFromRows([
[9, 5, 5, 9],
[6, 0, 0, 6],
[6, 7, 7, 6],
[6, 0, 0, 6],
[8, 3, 3, 8],
[8, 4, 4, 8],
[8, 2, 2, 8],
[8, 1, 1, 8],
]);
final List<(int, int, int, int)> edgeRows = <(int, int, int, int)>[];
MenuHeaderBand.applyFromHeadingImage(
image: image,
imageX320: 10,
fillSideEdgesRow:
(
int y200,
int leftWidth320,
int rightStartX320,
int paletteIndex,
) {
edgeRows.add((y200, leftWidth320, rightStartX320, paletteIndex));
},
);
// Rows 1 and 3 are black → fill with palette 0.
// Row 2 is interior non-black between two black rows → fill with palette 7.
// Row 0 is before firstBlack → skip (background shows through at top).
// Rows 46 are after lastBlack → skip (background shows through at bottom).
expect(
edgeRows,
<(int, int, int, int)>[
(1, 10, 14, 0),
(2, 10, 14, 7),
(3, 10, 14, 0),
],
);
});
}
VgaImage _imageFromRows(List<List<int>> rows) {
const int width = 4;
final int height = rows.length;
final Uint8List pixels = Uint8List(width * height);
for (int y = 0; y < height; y++) {
final List<int> row = rows[y];
if (row.length != width) {
throw ArgumentError('Each row must have width=$width entries.');
}
for (int x = 0; x < width; x++) {
pixels[(x * height) + y] = row[x];
}
}
return VgaImage(width: width, height: height, pixels: pixels);
}