1
0
Fork 0

Initial commit

This commit is contained in:
Henrik Hautakoski 2026-04-22 16:41:48 +02:00
commit 3304b53c41
38 changed files with 6573 additions and 0 deletions

View file

@ -0,0 +1,81 @@
<?php
use Doom\Image\Renderer;
use Doom\Picture\Picture;
use Intervention\Image\Encoders\PngEncoder;
/**
* @return array<int, array{0:int,1:int,2:int}>
*/
function testPalette(): array
{
$palette = array_fill(0, 256, [0, 0, 0]);
$palette[1] = [255, 0, 0];
$palette[2] = [0, 255, 0];
return $palette;
}
test('renders a patch to PNG bytes using configured palette', function (): void {
$picture = new Picture(
width: 2,
height: 2,
left: 0,
top: 0,
posts: [
0 => [
['y' => 0, 'topdelta' => 0, 'pixels' => [1]],
],
1 => [
['y' => 1, 'topdelta' => 1, 'pixels' => [2]],
],
],
);
$png = (string) (new Renderer(testPalette()))
->createPicture()
->render($picture)
->encode(new PngEncoder());
expect(substr($png, 0, 8))->toBe("\x89PNG\r\n\x1A\n");
$image = imagecreatefromstring($png);
expect($image)->not->toBeFalse();
$topLeft = imagecolorsforindex($image, imagecolorat($image, 0, 0));
expect([$topLeft['red'], $topLeft['green'], $topLeft['blue'], $topLeft['alpha']])->toBe([255, 0, 0, 0]);
$bottomLeft = imagecolorsforindex($image, imagecolorat($image, 0, 1));
expect($bottomLeft['alpha'])->toBe(127);
$bottomRight = imagecolorsforindex($image, imagecolorat($image, 1, 1));
expect([$bottomRight['red'], $bottomRight['green'], $bottomRight['blue'], $bottomRight['alpha']])->toBe([0, 255, 0, 0]);
imagedestroy($image);
});
test('renders with default palette when custom one is not provided', function (): void {
$picture = new Picture(
width: 1,
height: 1,
left: 0,
top: 0,
posts: [
0 => [
['y' => 0, 'topdelta' => 0, 'pixels' => [1]],
],
],
);
$png = (string) (new Renderer())
->createPicture()
->render($picture)
->encode(new PngEncoder());
$image = imagecreatefromstring($png);
expect($image)->not->toBeFalse();
$pixel = imagecolorsforindex($image, imagecolorat($image, 0, 0));
expect([$pixel['red'], $pixel['green'], $pixel['blue']])->toBe([31, 23, 11]);
imagedestroy($image);
});

View file

@ -0,0 +1,90 @@
<?php
use Doom\Image\Renderer;
use Doom\Picture\Picture;
use Intervention\Image\Encoders\PngEncoder;
/**
* @return array<int, array{0:int,1:int,2:int}>
*/
function imageTestPalette(): array
{
$palette = array_fill(0, 256, [0, 0, 0]);
$palette[1] = [255, 0, 0];
$palette[2] = [0, 255, 0];
return $palette;
}
function testPatchPicture(): Picture
{
return new Picture(
width: 2,
height: 2,
left: 0,
top: 0,
posts: [
0 => [
['y' => 0, 'topdelta' => 0, 'pixels' => [1]],
],
1 => [
['y' => 1, 'topdelta' => 1, 'pixels' => [2]],
],
],
);
}
test('can blit a Picture into a texture and encode png', function (): void {
$texture = (new Renderer(imageTestPalette()))->createTexture(4, 3);
$texture->blit(testPatchPicture(), 1, 1);
$png = (string) $texture->image()->encode(new PngEncoder());
expect(substr($png, 0, 8))->toBe("\x89PNG\r\n\x1A\n");
$image = imagecreatefromstring($png);
expect($image)->not->toBeFalse();
$pixel = imagecolorsforindex($image, imagecolorat($image, 1, 1));
expect([$pixel['red'], $pixel['green'], $pixel['blue'], $pixel['alpha']])->toBe([255, 0, 0, 0]);
$transparent = imagecolorsforindex($image, imagecolorat($image, 0, 0));
expect($transparent['alpha'])->toBe(127);
imagedestroy($image);
});
test('can blit a png file into a texture and write it', function (): void {
$sourcePath = tempnam(sys_get_temp_dir(), 'doom-src-');
$outPath = tempnam(sys_get_temp_dir(), 'doom-out-');
if ($sourcePath === false || $outPath === false) {
throw new RuntimeException('Failed to allocate temp files.');
}
$sourcePng = $sourcePath . '.png';
$targetPng = $outPath . '.png';
@unlink($sourcePath);
@unlink($outPath);
try {
$sourceTexture = (new Renderer(imageTestPalette()))->createTexture(2, 2);
$sourceTexture->blit(testPatchPicture());
$sourceTexture->image()->encode(new PngEncoder())->save($sourcePng);
$targetTexture = (new Renderer())->createTexture(6, 4);
$targetTexture->blit($sourcePng, 2, 1);
$targetTexture->image()->encode(new PngEncoder())->save($targetPng);
expect(is_file($targetPng))->toBeTrue();
$image = imagecreatefrompng($targetPng);
expect($image)->not->toBeFalse();
$pixel = imagecolorsforindex($image, imagecolorat($image, 2, 1));
expect([$pixel['red'], $pixel['green'], $pixel['blue']])->toBe([255, 0, 0]);
imagedestroy($image);
} finally {
@unlink($sourcePng);
@unlink($targetPng);
}
});

View file

@ -0,0 +1,27 @@
<?php
use Doom\Picture\DoomPalette;
test('provides a valid default doom palette', function (): void {
$palette = DoomPalette::default();
expect($palette->toArray())->toHaveCount(256);
expect($palette->colorAt(0))->toBe([0, 0, 0]);
expect($palette->colorAt(1))->toBe([31, 23, 11]);
});
test('extracts palette data from PLAYPAL bytes', function (): void {
$palette0 = str_repeat("\x00\x00\x00", 256);
$palette1 = str_repeat("\x10\x20\x30", 256);
$palette = DoomPalette::fromPlaypal($palette0 . $palette1, 1);
expect($palette->toArray())->toHaveCount(256);
expect($palette->colorAt(0))->toBe([16, 32, 48]);
expect($palette->colorAt(255))->toBe([16, 32, 48]);
});
test('rejects invalid PLAYPAL palette selection', function (): void {
expect(fn () => DoomPalette::fromPlaypal("\x00", 0))->toThrow(RuntimeException::class);
expect(fn () => DoomPalette::fromPlaypal(str_repeat("\x00", 768), -1))->toThrow(InvalidArgumentException::class);
});

View file

@ -0,0 +1,16 @@
<?php
use Doom\Picture\Palette;
test('creates palette from bytes and roundtrips', function (): void {
$bytes = str_repeat("\x01\x02\x03", 256);
$palette = Palette::fromBytes($bytes);
expect($palette->colorAt(0))->toBe([1, 2, 3]);
expect($palette->colorAt(255))->toBe([1, 2, 3]);
expect($palette->toBytes())->toBe($bytes);
});
test('rejects invalid palette byte length', function (): void {
expect(fn () => Palette::fromBytes("\x00"))->toThrow(InvalidArgumentException::class);
});

View file

@ -0,0 +1,25 @@
<?php
use Doom\Texture\PNamesParser;
test('parses PNAMES lump names', function (): void {
$data = pack('V', 3)
. pack('a8', 'BROWN1')
. pack('a8', 'STARTAN3')
. pack('a8', 'BIGDOOR2');
$pNames = PNamesParser::parseBytes($data);
expect($pNames)->toHaveCount(3);
expect($pNames)->toBe(['BROWN1', 'STARTAN3', 'BIGDOOR2']);
expect($pNames[1] ?? null)->toBe('STARTAN3');
expect($pNames[10] ?? null)->toBeNull();
expect(array_search('BIGDOOR2', $pNames, true))->toBe(2);
expect(array_search('MISSING', $pNames, true))->toBeFalse();
});
test('throws on truncated PNAMES lump', function (): void {
$data = pack('V', 2) . pack('a8', 'ONLYONE');
expect(fn () => PNamesParser::parseBytes($data))->toThrow(RuntimeException::class);
});

View file

@ -0,0 +1,135 @@
<?php
use Doom\Texture\TextureResolver;
use Doom\Wad\File;
use Doom\Wad\Types\Lump;
function s16(int $value): string
{
return pack('v', $value & 0xFFFF);
}
/**
* @param array<int, array{originX:int, originY:int, patch:int, stepDir:int, colorMap:int}> $patches
*/
function textureRecord(string $name, bool $masked, int $width, int $height, array $patches): string
{
$data = pack('a8VvvVv', $name, $masked ? 1 : 0, $width, $height, 0, count($patches));
foreach ($patches as $patch) {
$data .= s16($patch['originX']);
$data .= s16($patch['originY']);
$data .= pack('v', $patch['patch']);
$data .= pack('v', $patch['stepDir']);
$data .= pack('v', $patch['colorMap']);
}
return $data;
}
/**
* @param string[] $records
*/
function textureXData(array $records): string
{
$count = count($records);
$offset = 4 + ($count * 4);
$offsets = [];
$body = '';
foreach ($records as $record) {
$offsets[] = $offset;
$body .= $record;
$offset += strlen($record);
}
$data = pack('V', $count);
foreach ($offsets as $itemOffset) {
$data .= pack('V', $itemOffset);
}
return $data . $body;
}
/**
* @param string[] $names
*/
function pnamesData(array $names): string
{
$data = pack('V', count($names));
foreach ($names as $name) {
$data .= pack('a8', $name);
}
return $data;
}
function appendLump(File $wad, string $name, string $data): void
{
$index = $wad->lumps->count();
$wad->lumps->push(new Lump(
offset: 0,
size: strlen($data),
name: strtoupper($name),
data: $data,
index: $index,
));
}
test('resolves textures using PNAMES and patch lumps', function (): void {
$wad = new File();
appendLump($wad, 'PNAMES', pnamesData(['PATCHA', 'PATCHB']));
appendLump($wad, 'TEXTURE1', textureXData([
textureRecord('TEXA', false, 64, 64, [
['originX' => 0, 'originY' => 0, 'patch' => 0, 'stepDir' => 0, 'colorMap' => 0],
]),
textureRecord('TEXB', false, 32, 16, [
['originX' => 8, 'originY' => -2, 'patch' => 0, 'stepDir' => 0, 'colorMap' => 0],
]),
]));
appendLump($wad, 'TEXTURE2', textureXData([
textureRecord('TEXA', true, 128, 32, [
['originX' => 4, 'originY' => 5, 'patch' => 1, 'stepDir' => 0, 'colorMap' => 0],
['originX' => 0, 'originY' => 0, 'patch' => 5, 'stepDir' => 0, 'colorMap' => 0],
]),
]));
appendLump($wad, 'PATCHA', '');
$resolved = $wad->resolveTextures();
expect($resolved)->toHaveCount(2);
expect($resolved)->toHaveKey('TEXA');
expect($resolved)->toHaveKey('TEXB');
$texA = $resolved['TEXA'];
expect($texA->masked)->toBeTrue();
expect($texA->width)->toBe(128);
expect($texA->height)->toBe(32);
expect($texA->patches)->toHaveCount(2);
expect($texA->patches[0]->patchName)->toBe('PATCHB');
expect($texA->patches[0]->lumpIndex)->toBeNull();
expect($texA->patches[0]->isResolved())->toBeFalse();
expect($texA->patches[1]->patchName)->toBeNull();
$texB = TextureResolver::forWad($wad)->resolveByName('texb');
expect($texB)->not->toBeNull();
expect($texB->patches[0]->patchName)->toBe('PATCHA');
expect($texB->patches[0]->lumpIndex)->toBe(3);
expect($texB->patches[0]->isResolved())->toBeTrue();
});
test('throws when PNAMES lump is missing', function (): void {
$wad = new File();
appendLump($wad, 'TEXTURE1', textureXData([
textureRecord('TEXA', false, 16, 16, [
['originX' => 0, 'originY' => 0, 'patch' => 0, 'stepDir' => 0, 'colorMap' => 0],
]),
]));
expect(fn () => $wad->resolveTextures())->toThrow(RuntimeException::class);
});

View file

@ -0,0 +1,69 @@
<?php
use Doom\Texture\TextureXParser;
function le16s(int $value): string
{
return pack('v', $value & 0xFFFF);
}
test('parses TEXTUREX definitions and patch references', function (): void {
$headerSize = 4 + (2 * 4);
$offset0 = $headerSize;
$offset1 = $offset0 + 32;
$texture0 = pack('a8VvvVv', 'BRICK1', 0, 64, 128, 0, 1)
. le16s(8)
. le16s(-4)
. pack('v', 3)
. pack('v', 0)
. pack('v', 0);
$texture1 = pack('a8VvvVv', 'METAL2', 1, 32, 32, 0, 2)
. le16s(0)
. le16s(0)
. pack('v', 7)
. pack('v', 0)
. pack('v', 0)
. le16s(12)
. le16s(4)
. pack('v', 8)
. pack('v', 0)
. pack('v', 0);
$data = pack('V', 2)
. pack('V', $offset0)
. pack('V', $offset1)
. $texture0
. $texture1;
$textureX = TextureXParser::parseBytes($data);
expect($textureX->numTextures)->toBe(2);
expect($textureX->offsets)->toBe([$offset0, $offset1]);
expect($textureX->textures)->toHaveCount(2);
$first = $textureX->textures[0];
expect($first->name)->toBe('BRICK1');
expect($first->masked)->toBeFalse();
expect($first->width)->toBe(64);
expect($first->height)->toBe(128);
expect($first->patches)->toHaveCount(1);
expect($first->patches[0]->originX)->toBe(8);
expect($first->patches[0]->originY)->toBe(-4);
expect($first->patches[0]->patch)->toBe(3);
$second = $textureX->firstTextureByName('metal2');
expect($second)->not->toBeNull();
expect($second->masked)->toBeTrue();
expect($second->patches)->toHaveCount(2);
expect($second->patches[1]->originX)->toBe(12);
expect($second->patches[1]->originY)->toBe(4);
expect($second->patches[1]->patch)->toBe(8);
});
test('throws on invalid texture offset', function (): void {
$data = pack('V', 1) . pack('V', 999);
expect(fn () => TextureXParser::parseBytes($data))->toThrow(RuntimeException::class);
});