From b10b1b08ac3f28f0c9f0b3e1969a6579176356b2 Mon Sep 17 00:00:00 2001 From: Vincenzo Petrucci Date: Wed, 28 Oct 2026 14:50:58 +0100 Subject: [PATCH 1/6] feat(cheats): add an elephant trunk and flying elephant projectiles --- src/App/SaveGame.php | 7 +- src/App/Session.php | 6 +- src/Graphics/ElephantArt.php | 149 ++++++++++++++++++++++++++ src/Graphics/Sprites.php | 57 ++++++---- src/Play/Cheats.php | 10 +- src/Play/Player.php | 2 + src/Play/Projectiles.php | 1 + src/Play/ThingTypes.php | 2 + src/Play/Weapons.php | 5 + src/Render/WeaponRenderer.php | 13 +++ src/Wad/Wad.php | 43 ++++++++ src/engine.php | 1 + tests/ElephantCheatTest.php | 191 ++++++++++++++++++++++++++++++++++ 13 files changed, 460 insertions(+), 27 deletions(-) create mode 100644 src/Graphics/ElephantArt.php create mode 100644 tests/ElephantCheatTest.php diff --git a/src/App/SaveGame.php b/src/App/SaveGame.php index 919742f..11dcbce 100644 --- a/src/App/SaveGame.php +++ b/src/App/SaveGame.php @@ -11,7 +11,7 @@ public function __construct() { $this->records = new SaveRecords(); } public function fingerprint(Session $s): string { if ($this->wadHash === "") { - $this->wadHash = hash("sha256", ptr_read_string(buffer_ptr($s->wad->data), $s->wad->size)); + $this->wadHash = hash("sha256", ptr_read_string(buffer_ptr($s->wad->data), $s->wad->sourceSize)); } return $this->wadHash; } @@ -31,7 +31,8 @@ public function encode(Session $s): string { for ($i = 0; $i < $s->things->count * 4; $i++) { $frames[] = $s->things->frameDefinition[$i]; } $c = $s->combat; $data = ["version" => 1, "wad" => $this->fingerprint($s), "map" => $s->mapName, "skill" => $s->config->skill, - "time" => $s->levelTime, "player" => $this->records->readPlayer($s->player), "objects" => $objects, + "time" => $s->levelTime, "elephant" => $s->player->elephantMode, + "player" => $this->records->readPlayer($s->player), "objects" => $objects, "sectors" => $sectors, "sides" => $sides, "lines" => $lines, "movers" => $movers, "lights" => $lights, "buttons" => $buttons, "secrets" => $secrets, "frames" => $frames, "combat" => [$c->seed, $c->kills, $c->totalKills, $c->items, $c->totalItems, $c->secrets, $c->totalSecrets, (int)$c->bossTriggered, $s->specials->seed, $c->clock]]; @@ -81,6 +82,7 @@ public function decode(Session $s, string $json): bool { $decoded = json_decode($json, true); if (!is_array($decoded)) { return false; } array $d = (array)$decoded; + if (array_key_exists("elephant", $d) && !is_bool($d["elephant"])) { return false; } if (($d["version"] ?? 0) !== 1 || ($d["wad"] ?? "") !== $this->fingerprint($s) || !is_string($d["map"] ?? null)) { return false; } string $mapName = (string)$d["map"]; if (strlen($mapName) !== 4 || $mapName[0] !== "E" || $mapName[2] !== "M" || $mapName[1] < "1" || $mapName[1] > "4" || $mapName[3] < "1" || $mapName[3] > "9") { return false; } @@ -151,6 +153,7 @@ public function references(Session $s, array $d, int $sectors, int $sides): bool public function restore(Session $s, array $d): void { $this->records->writePlayer($s->player, (array)$d["player"]); + $s->player->elephantMode = (bool)($d["elephant"] ?? false); $s->player->availableWeapons = $s->renderer->weapons->available; $s->levelTime = (int)$d["time"]; array $rows = (array)$d["objects"]; diff --git a/src/App/Session.php b/src/App/Session.php index 5c6ca3b..21d4999 100644 --- a/src/App/Session.php +++ b/src/App/Session.php @@ -5,6 +5,7 @@ use Showcases\Doom\Bsp\BspWalker; use Showcases\Doom\Graphics\Flats; use Showcases\Doom\Graphics\Palette; +use Showcases\Doom\Graphics\ElephantArt; use Showcases\Doom\Graphics\Sprites; use Showcases\Doom\Graphics\Textures; use Showcases\Doom\Map\MapData; @@ -99,7 +100,10 @@ public function boot(): bool { return false; } $this->say("Loaded WAD: " . $this->wad->kind . " | lumps: " . $this->wad->lumpCount); - if (!$this->palette->load($this->wad) || !$this->textures->load($this->wad) || !$this->flats->load($this->wad) || !$this->sprites->load($this->wad)) { + if (!$this->palette->load($this->wad)) { return false; } + ElephantArt $art = new ElephantArt(); + $art->install($this->wad, $this->palette); + if (!$this->textures->load($this->wad) || !$this->flats->load($this->wad) || !$this->sprites->load($this->wad)) { $this->say("The WAD lacks PLAYPAL, textures, flats or sprites"); return false; } diff --git a/src/Graphics/ElephantArt.php b/src/Graphics/ElephantArt.php new file mode 100644 index 0000000..66dc635 --- /dev/null +++ b/src/Graphics/ElephantArt.php @@ -0,0 +1,149 @@ +colors[] = $this->nearest($palette, (int)$rgb); } + $names = []; $pictures = []; + for ($frame = 0; $frame < 3; $frame++) { + $names[] = "ELTG" . chr(65 + $frame) . "0"; + int $recoil = $frame === 1 ? 6 : ($frame === 2 ? 2 : 0); + $pictures[] = $this->patch($this->trunk($frame), 64, 80, -128, -88 - $recoil); + } + for ($frame = 0; $frame < 5; $frame++) { + $names[] = "ELPH" . chr(65 + $frame) . "0"; + $pictures[] = $this->patch($this->elephant($frame), 32, 24, 16, 12); + } + $wad->appendLumps($names, $pictures); + } + + public function nearest(Palette $palette, int $rgb): int { + int $best = 0; int $distance = 2147483647; + for ($i = 0; $i < 256; $i++) { + int $color = $palette->argb[$i]; + int $r = (($color >> 16) & 255) - (($rgb >> 16) & 255); + int $g = (($color >> 8) & 255) - (($rgb >> 8) & 255); + int $b = ($color & 255) - ($rgb & 255); + int $d = $r * $r + $g * $g + $b * $b; + if ($d < $distance) { $best = $i; $distance = $d; } + } + return $best; + } + + public function trunk(int $frame): array { + $rows = []; + for ($y = 0; $y < 80; $y++) { + string $row = ""; + for ($x = 0; $x < 64; $x++) { + string $ink = "."; + int $center = 30 + intdiv((80 - $y) * (80 - $y), 500); + int $half = 10 + intdiv($y, 5); + int $dx = $x - $center; + if ($y >= 19 && abs($dx) <= $half) { + $ink = "0"; + if (abs($dx) < $half - 2) { + $ink = $dx < -5 ? "1" : ($dx < 0 ? "2" : ($dx < intdiv($half, 2) ? "3" : "4")); + if ($dx === intdiv($half, 2) - 1) { $ink = "5"; } + // Wrinkles wrap around the trunk, with a lit upper edge. + int $ring = ($y + intdiv(abs($dx), 3)) % 12; + if ($y > 34 && $ring < 2) { $ink = $dx < 0 ? "0" : "2"; } + else if ($y > 34 && $ring === 2 && $dx > 0) { $ink = "5"; } + } + } + int $mx = $x - 39; int $my = $y - 19; + int $rim = $mx * $mx * 81 + $my * $my * 225; + if ($rim <= 18225) { + $ink = "0"; + if ($rim < 14500) { $ink = $my < -2 ? "5" : "3"; } + if ($rim < 9000) { $ink = "1"; } + // Two dark nostrils make the muzzle read as a trunk. + if (($mx + 5) * ($mx + 5) + $my * $my * 2 < 25 + || ($mx - 5) * ($mx - 5) + $my * $my * 2 < 25) { $ink = "7"; } + if ($frame === 1 && $rim >= 9000 && $rim < 14500) { $ink = "6"; } + } + $row .= $ink; + } + $rows[] = $row; + } + return $rows; + } + + public function elephant(int $frame): array { + $rows = []; + for ($y = 0; $y < 24; $y++) { + string $row = ""; + for ($x = 0; $x < 32; $x++) { + string $ink = "."; + if ($frame >= 2) { + int $dx = abs($x - 16); int $dy = abs($y - 12); + int $radius = 4 + ($frame - 2) * 3; + int $distance = $dx + $dy; + if ($distance < $radius && $distance >= max(0, $radius - 5)) { + $ink = $distance < $radius - 2 ? "5" : "3"; + } + if ($dx === $dy && $dx >= $radius - 2 && $dx <= $radius) { $ink = "2"; } + } else { + // Tail and alternating feet, then body, head and large ear. + if ($x >= 2 && $x <= 4 && $y >= 9 && $y <= 15) { $ink = "1"; } + int $step = $frame * 2; + if (($x >= 7 && $x <= 11 && $y >= 15 && $y <= 22 - $step) + || ($x >= 16 && $x <= 20 && $y >= 15 && $y <= 20 + $step)) { $ink = "2"; } + int $bx = $x - 13; int $by = $y - 12; + int $body = $bx * $bx * 49 + $by * $by * 100; + if ($body <= 4900) { $ink = $body > 3650 ? "0" : ($y < 10 ? "4" : "3"); } + int $hx = $x - 24; int $hy = $y - 10; + int $head = $hx * $hx + $hy * $hy; + if ($head <= 36) { $ink = $head > 26 ? "0" : ($y < 8 ? "5" : "3"); } + int $ex = $x - 20; int $ey = $y - 11 - $frame; + int $ear = $ex * $ex * 36 + $ey * $ey * 16; + if ($ear <= 576) { $ink = $ear > 360 ? "1" : ($x < 20 ? "2" : "3"); } + if ($x >= 27 && $x <= 30 && $y >= 11 && $y <= 20) { $ink = $x === 27 || $x === 30 ? "1" : "4"; } + if ($x >= 25 && $x <= 29 && $y >= 19 && $y <= 21) { $ink = $y === 21 ? "1" : "4"; } + if ($x >= 25 && $x <= 28 && $y === 14) { $ink = "6"; } + if ($x === 25 && $y === 8) { $ink = "7"; } + } + $row .= $ink; + } + $rows[] = $row; + } + return $rows; + } + + // Encode column posts so built-in art uses the existing masked-patch paths. + public function patch(array $rows, int $width, int $height, int $left, int $top): string { + string $table = ""; string $posts = ""; + for ($x = 0; $x < $width; $x++) { + int $offset = 8 + $width * 4 + strlen($posts); + $table .= $this->dword($offset); + int $y = 0; + while ($y < $height) { + string $row = (string)$rows[$y]; + if ($row[$x] === ".") { $y++; continue; } + int $start = $y; + string $pixels = ""; + while ($y < $height) { + $row = (string)$rows[$y]; + if ($row[$x] === ".") { break; } + int $shade = ord($row[$x]) - 48; + $pixels .= chr((int)$this->colors[$shade]); + $y++; + } + $posts .= chr($start) . chr($y - $start) . chr(0) . $pixels . chr(0); + } + $posts .= chr(255); + } + return $this->word($width) . $this->word($height) . $this->word($left) . $this->word($top) . $table . $posts; + } + + public function word(int $value): string { return chr($value & 255) . chr(($value >> 8) & 255); } + public function dword(int $value): string { return $this->word($value) . $this->word($value >> 16); } +} diff --git a/src/Graphics/Sprites.php b/src/Graphics/Sprites.php index 78bb288..e2d0327 100644 --- a/src/Graphics/Sprites.php +++ b/src/Graphics/Sprites.php @@ -32,7 +32,7 @@ public function load(Wad $wad): bool { if ($start < 0 || $end <= $start) { return false; } - int $capacity = $end - $start; + int $capacity = $end - $start + $wad->lumpCount - $wad->sourceLumpCount; int $rotationSlots = $capacity * 8; buffer $oldSpriteKey = $this->spriteKey; buffer_free($oldSpriteKey); @@ -49,36 +49,47 @@ public function load(Wad $wad): bool { $this->rotated = buffer_new($capacity); $this->lump = buffer_new($rotationSlots); $this->flip = buffer_new($rotationSlots); + $this->count = 0; for ($slot = 0; $slot < $rotationSlots; $slot++) { $this->lump[$slot] = -1; } int $firstLump = $start + 1; for ($lump = $firstLump; $lump < $end; $lump++) { - int $key = $wad->lumpKey[$lump]; - int $sprite = $key & 4294967295; - int $frameShift = $key >> 32; - int $frameLetter = $frameShift & 255; - int $rotationShift = $key >> 40; - int $rotationDigit = $rotationShift & 255; - if ($frameLetter < 65 || $rotationDigit < 48 || $rotationDigit > 56) { - continue; - } - int $frameIndex = $frameLetter - 65; - int $rotation = $rotationDigit - 48; - $this->assign($sprite, $frameIndex, $rotation, $lump, 0); + $this->loadLump($wad, $lump); + } + bool $hasSprites = $this->count > 0; + // Built-in pictures live after the original directory, without changing + // the WAD file or the indices of its original sprite definitions. + for ($lump = $wad->sourceLumpCount; $lump < $wad->lumpCount; $lump++) { + $this->loadLump($wad, $lump); + } + return $hasSprites; + } - int $mirrorFrameShift = $key >> 48; - int $mirrorFrame = $mirrorFrameShift & 255; - int $mirrorRotationShift = $key >> 56; - int $mirrorRotationDigit = $mirrorRotationShift & 255; - if ($mirrorFrame >= 65 && $mirrorRotationDigit >= 48 && $mirrorRotationDigit <= 56) { - int $mirrorIndex = $mirrorFrame - 65; - int $mirrorRotation = $mirrorRotationDigit - 48; - $this->assign($sprite, $mirrorIndex, $mirrorRotation, $lump, 1); - } + public function loadLump(Wad $wad, int $lump): void { + int $key = $wad->lumpKey[$lump]; + int $sprite = $key & 4294967295; + int $frameShift = $key >> 32; + int $frameLetter = $frameShift & 255; + int $rotationShift = $key >> 40; + int $rotationDigit = $rotationShift & 255; + if ($frameLetter < 65 || $rotationDigit < 48 || $rotationDigit > 56) { + return; + } + int $frameIndex = $frameLetter - 65; + int $rotation = $rotationDigit - 48; + $this->assign($sprite, $frameIndex, $rotation, $lump, 0); + + int $mirrorFrameShift = $key >> 48; + int $mirrorFrame = $mirrorFrameShift & 255; + int $mirrorRotationShift = $key >> 56; + int $mirrorRotationDigit = $mirrorRotationShift & 255; + if ($mirrorFrame >= 65 && $mirrorRotationDigit >= 48 && $mirrorRotationDigit <= 56) { + int $mirrorIndex = $mirrorFrame - 65; + int $mirrorRotation = $mirrorRotationDigit - 48; + $this->assign($sprite, $mirrorIndex, $mirrorRotation, $lump, 1); } - return $this->count > 0; } public function assign(int $sprite, int $frameIndex, int $rotation, int $lump, int $flip): void { diff --git a/src/Play/Cheats.php b/src/Play/Cheats.php index 5c738db..c220f9e 100644 --- a/src/Play/Cheats.php +++ b/src/Play/Cheats.php @@ -24,7 +24,15 @@ public function feed(Session $s, string $text): void { public function apply(Session $s): void { Player $p = $s->player; string $message = ""; - if (str_ends_with($this->tail, "iddqd")) { + if (str_ends_with($this->tail, "idelephc")) { + $p->elephantMode = !$p->elephantMode; + $message = "ELEPHC MODE OFF"; + if ($p->elephantMode) { + $p->weapons |= 4; + $p->pendingWeapon = 2; + $message = "ELEPHC MODE ON"; + } + } else if (str_ends_with($this->tail, "iddqd")) { $p->godMode = !$p->godMode; $message = "GOD MODE OFF"; if ($p->godMode) { diff --git a/src/Play/Player.php b/src/Play/Player.php index 0b19953..efc120c 100644 --- a/src/Play/Player.php +++ b/src/Play/Player.php @@ -59,6 +59,7 @@ final class Player { public int $selectionHeld; public int $sawClock; public bool $fireHeld; + public bool $elephantMode = false; public function __construct() { $this->x = 0; @@ -98,6 +99,7 @@ public function reset(): void { $this->maxCells = 300; $this->backpack = false; $this->godMode = false; + $this->elephantMode = false; $this->noclip = false; $this->berserk = false; $this->fullMap = false; diff --git a/src/Play/Projectiles.php b/src/Play/Projectiles.php index ccbba7f..023866b 100644 --- a/src/Play/Projectiles.php +++ b/src/Play/Projectiles.php @@ -29,6 +29,7 @@ public function launch(Combat $c, int $source, int $x, int $y, int $z, int $tx, else if ($type === 6004) { $speed = 25; $damage = 100; } else if ($type === 6005) { $speed = 15; $damage = 8; } else if ($type === 6006) { $damage = 5; } + else if ($type === 6009) { $speed = 16; $damage = 3; } $m[$slot]->active = 1; $m[$slot]->type = $type; $m[$slot]->kind = $kind; diff --git a/src/Play/ThingTypes.php b/src/Play/ThingTypes.php index 23f66c9..89a4884 100644 --- a/src/Play/ThingTypes.php +++ b/src/Play/ThingTypes.php @@ -128,6 +128,8 @@ public function __construct() { [62, "GOR5", "A", 1, 0, 0, 20, 52, 0, 1], [63, "GOR1", "A", 3, 10, 0, 20, 68, 0, 1], ]; + // Append custom kinds so old savegame kind indices remain valid. + $rows[] = [6009, "ELPH", "A", 2, 4, 1, 6, 8, 0, 0]; int $count = count($rows); $this->count = $count; $this->type = buffer_new($count); diff --git a/src/Play/Weapons.php b/src/Play/Weapons.php index 14892d4..2b50966 100644 --- a/src/Play/Weapons.php +++ b/src/Play/Weapons.php @@ -52,6 +52,11 @@ public function tick(Player $p, Combat $c, Input $input, ptr $keys): void { $c->sounds->play($hit >= 0 ? Sounds::SAW_HIT : Sounds::SAW_FULL); } else if ($hit >= 0) { $c->sounds->play(Sounds::PUNCH); } if ($hit >= 0) { $c->noise(); } + } else if ($w === 2 && $p->elephantMode) { + $p->bullets--; + $p->weaponCooldown = 14; + $this->launch($p, $c, 6009); + $c->sounds->play(Sounds::PISTOL); } else if ($w === 2 || $w === 4) { $p->bullets--; $p->weaponCooldown = $w === 2 ? 14 : 4; diff --git a/src/Render/WeaponRenderer.php b/src/Render/WeaponRenderer.php index ef8b042..09127ad 100644 --- a/src/Render/WeaponRenderer.php +++ b/src/Render/WeaponRenderer.php @@ -12,9 +12,14 @@ final class WeaponRenderer { public Wad $wad; public buffer $patches; public int $available; + public buffer $trunk; public function __construct(Wad $wad) { $this->wad = $wad; + $this->trunk = buffer_new(3); + for ($f = 0; $f < 3; $f++) { + $this->trunk[$f] = $wad->find("ELTG" . chr(65 + $f) . "0"); + } $this->patches = buffer_new(90); $prefixes = ["", "PUNG", "PISG", "SHTG", "CHGG", "MISG", "PLSG", "BFGG", "SAWG"]; for ($weapon = 1; $weapon < 9; $weapon++) { @@ -87,6 +92,14 @@ public function draw(Screen $screen, Player $player, Trig $trig, int $tic): void int $bob = min(4, intdiv($speed, 3)); int $x = ($trig->cos($tic * 409) * $bob) >> 16; int $y = abs(($trig->sin($tic * 409) * $bob) >> 16); + if ($player->weapon === 2 && $player->elephantMode) { + int $trunkFrame = 0; + if ($player->weaponCooldown > 0 && $player->weaponAge < 9) { + $trunkFrame = $player->weaponAge < 5 ? 1 : 2; + } + $screen->drawPatch($this->wad, $this->trunk[$trunkFrame], $x, $y); + return; + } int $slot = $base + $frame; int $patch = $this->patches[$slot]; if ($patch < 0) { diff --git a/src/Wad/Wad.php b/src/Wad/Wad.php index 1cdf574..5e9bbae 100644 --- a/src/Wad/Wad.php +++ b/src/Wad/Wad.php @@ -8,6 +8,9 @@ final class Wad { public buffer $data; public int $size; + // Original file boundaries exclude generated art from save fingerprints. + public int $sourceSize = 0; + public int $sourceLumpCount = 0; public string $kind; public int $lumpCount; public buffer $lumpOffset; @@ -42,6 +45,7 @@ public function load(string $path): bool { $this->data = buffer_new($size); ptr_write_string(buffer_ptr($this->data), $bytes); $this->size = $size; + $this->sourceSize = $size; int $magic = $this->keyAt(0); int $kindKey = $magic & 4294967295; $this->kind = $this->nameFromKey($kindKey); @@ -57,6 +61,7 @@ public function load(string $path): bool { return false; } $this->lumpCount = $count; + $this->sourceLumpCount = $count; buffer $oldOffset = $this->lumpOffset; buffer_free($oldOffset); buffer $oldSize = $this->lumpSize; @@ -78,6 +83,44 @@ public function load(string $path): bool { return true; } + // Append built-in pictures before any renderer caches the WAD's buffers. + // Existing lump indices and the original file prefix remain unchanged. + public function appendLumps(array $names, array $pictures): void { + string $bytes = ptr_read_string(buffer_ptr($this->data), $this->size); + int $count = $this->lumpCount + count($names); + buffer $offsets = buffer_new($count); + buffer $sizes = buffer_new($count); + buffer $keys = buffer_new($count); + for ($i = 0; $i < $this->lumpCount; $i++) { + $offsets[$i] = $this->lumpOffset[$i]; + $sizes[$i] = $this->lumpSize[$i]; + $keys[$i] = $this->lumpKey[$i]; + } + for ($i = 0; $i < count($names); $i++) { + int $slot = $this->lumpCount + $i; + string $picture = (string)$pictures[$i]; + $offsets[$slot] = strlen($bytes); + $sizes[$slot] = strlen($picture); + $keys[$slot] = $this->key((string)$names[$i]); + $bytes .= $picture; + } + buffer $oldData = $this->data; + buffer $oldOffsets = $this->lumpOffset; + buffer $oldSizes = $this->lumpSize; + buffer $oldKeys = $this->lumpKey; + $this->size = strlen($bytes); + $this->data = buffer_new($this->size); + ptr_write_string(buffer_ptr($this->data), $bytes); + $this->lumpOffset = $offsets; + $this->lumpSize = $sizes; + $this->lumpKey = $keys; + $this->lumpCount = $count; + buffer_free($oldData); + buffer_free($oldOffsets); + buffer_free($oldSizes); + buffer_free($oldKeys); + } + public function u8(int $offset): int { return $this->data[$offset]; } diff --git a/src/engine.php b/src/engine.php index 0f86365..42c7e31 100644 --- a/src/engine.php +++ b/src/engine.php @@ -26,6 +26,7 @@ require_once 'Graphics/Animations.php'; require_once 'Graphics/Palette.php'; +require_once 'Graphics/ElephantArt.php'; require_once 'Graphics/Textures.php'; require_once 'Graphics/Flats.php'; require_once 'Graphics/Sprites.php'; diff --git a/tests/ElephantCheatTest.php b/tests/ElephantCheatTest.php new file mode 100644 index 0000000..2613db9 --- /dev/null +++ b/tests/ElephantCheatTest.php @@ -0,0 +1,191 @@ +boot(); + $s->player->weapons |= 8; + $s->player->weapon = 3; + $s->cheats->feed($s, "IdE"); + $this->assertFalse($s->player->elephantMode); + $s->cheats->feed($s, "lePhC"); + $this->assertTrue($s->player->elephantMode); + $this->advance($s, 1); + $this->assertSameInt(2, $s->player->weapon); + $s->cheats->feed($s, "IDELEPHC"); + $this->assertFalse($s->player->elephantMode); + $this->assertSameString("", $s->cheats->tail); + } + + public function testElephantTravelsBeforeDamagingItsTarget(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->things->mobjs[3]->x = 192; + $s->things->mobjs[3]->y = 128; + $s->things->mobjs[3]->health = 1000; + $s->cheats->feed($s, "idelephc"); + $this->fire($s); + $this->assertSameInt(49, $s->player->bullets); + $this->assertSameInt(14, $s->player->weaponCooldown); + $this->assertSameInt(1000, $s->things->mobjs[3]->health); + $this->assertSameInt(6009, $s->things->mobjs[0]->type); + $this->assertSameInt(6, $s->things->mobjs[0]->state); + $this->assertSameInt(0, $s->things->mobjs[0]->splashDamage); + $s->combat->projectiles->tick($s->combat, 0); + $this->assertTrue($s->things->mobjs[0]->x > $s->player->unitX()); + for ($i = 0; $i < 10; $i++) { $s->combat->projectiles->tick($s->combat, 0); } + $this->assertTrue($s->things->mobjs[3]->health < 1000); + $this->assertSameInt(7, $s->things->mobjs[0]->state); + $this->assertSameInt(100, $s->player->health); + for ($i = 0; $i < 15; $i++) { $s->combat->projectiles->tick($s->combat, 0); } + $this->assertSameInt(0, $s->things->mobjs[0]->active); + } + + public function testClosedDoorsStopElephantsAndProtectMonstersBehindThem(): void { + $s = $this->boot(); + $this->isolate($s, 4); + $s->things->mobjs[4]->x = 384; + $s->things->mobjs[4]->y = 128; + $s->things->mobjs[4]->health = 1000; + $s->map->sectors[1]->ceiling_height = 0; + $s->cheats->feed($s, "idelephc"); + $this->fire($s); + for ($i = 0; $i < 14; $i++) { $s->combat->projectiles->tick($s->combat, 0); } + $this->assertSameInt(1000, $s->things->mobjs[4]->health); + $this->assertSameInt(7, $s->things->mobjs[0]->state); + $this->assertTrue($s->things->mobjs[0]->x <= 256); + } + + public function testDisablingTheCheatRestoresHitscanWithoutRemovingFlyingElephants(): void { + $s = $this->boot(); + $this->isolate($s, 3); + $s->things->mobjs[3]->x = 192; + $s->things->mobjs[3]->y = 128; + $s->things->mobjs[3]->health = 1000; + $s->cheats->feed($s, "idelephc"); + $this->fire($s); + $s->cheats->feed($s, "idelephc"); + $this->assertSameInt(1, $s->things->mobjs[0]->active); + $s->player->weaponCooldown = 0; + $s->player->fireHeld = false; + $this->fire($s); + $this->assertTrue($s->things->mobjs[3]->health < 1000); + $this->assertSameInt(48, $s->player->bullets); + $this->assertSameInt(6, $s->things->mobjs[0]->state); + } + + public function testTrunkAnimatesAndOtherWeaponsKeepTheirArt(): void { + $s = $this->boot(); + string $pistol = $this->weaponFrame($s); + $s->cheats->feed($s, "idelephc"); + string $idle = $this->weaponFrame($s); + $this->assertFalse($idle === $pistol); + $s->player->weaponCooldown = 14; + string $firing = $this->weaponFrame($s); + $this->assertFalse($idle === $firing); + $s->player->weaponAge = 6; + $this->assertFalse($firing === $this->weaponFrame($s)); + $s->player->weapon = 3; + string $shotgun = $this->weaponFrame($s); + $s->player->elephantMode = false; + $this->assertSameString($shotgun, $this->weaponFrame($s)); + } + + public function testHeldFireKeepsPistolCadenceAndEmptyAmmoFallsBack(): void { + $s = $this->boot(); + $this->isolate($s, -1); + $s->cheats->feed($s, "idelephc"); + $this->advance($s, 15, 224); + $this->assertSameInt(48, $s->player->bullets); + $s->player->bullets = 0; + $s->player->weaponCooldown = 0; + $this->fire($s); + $this->assertSameInt(1, $s->player->weapon); + $this->assertSameInt(0, $s->player->bullets); + } + + public function testElephantSpritesAreVisibleAndClippedByWalls(): void { + $s = $this->boot(); + $this->isolate($s, -1); + $s->renderer->render($s->map, $s->things, $s->player, 0, ""); + string $empty = $this->frame($s); + $s->combat->projectiles->launch($s->combat, -1, 192, 128, 48, 240, 128, 48, 6009); + $s->renderer->render($s->map, $s->things, $s->player, 0, ""); + $this->assertFalse($empty === $this->frame($s)); + $s->map->sectors[1]->ceiling_height = 0; + $s->things->mobjs[0]->active = 0; + $s->renderer->render($s->map, $s->things, $s->player, 0, ""); + string $closed = $this->frame($s); + $s->things->mobjs[0]->active = 1; + $s->things->mobjs[0]->x = 384; + $s->things->mobjs[0]->sector = 1; + $s->renderer->render($s->map, $s->things, $s->player, 0, ""); + $this->assertSameString($closed, $this->frame($s)); + } + + public function testSavesPreserveTheModeAndProjectilesWithoutChangingTheWad(): void { + $s = $this->boot(); + $this->isolate($s, -1); + string $original = (string)file_get_contents($this->wadPath); + $this->assertSameString(hash("sha256", $original), $s->saves->fingerprint($s)); + $s->cheats->feed($s, "idelephc"); + $this->fire($s); + string $snapshot = $s->saves->encode($s); + $s->player->elephantMode = false; + $this->assertTrue($s->saves->decode($s, $snapshot)); + $this->assertTrue($s->player->elephantMode); + $this->assertSameInt(6009, $s->things->mobjs[0]->type); + int $before = $s->things->mobjs[0]->x; + $s->combat->projectiles->tick($s->combat, 0); + $this->assertTrue($s->things->mobjs[0]->x > $before); + $this->assertSameString($original, (string)file_get_contents($this->wadPath)); + } + + public function testLegacySavesDisableTheModeAndInvalidFlagsAreRejected(): void { + $s = $this->boot(); + string $snapshot = $s->saves->encode($s); + $s->cheats->feed($s, "idelephc"); + $this->assertFalse($s->saves->decode($s, str_replace('"elephant":false', '"elephant":1', $snapshot))); + $this->assertFalse($s->saves->decode($s, str_replace('"elephant":false', '"elephant":null', $snapshot))); + $this->assertFalse($s->saves->decode($s, str_replace('"elephant":false', '"elephant":"true"', $snapshot))); + $this->assertTrue($s->player->elephantMode); + $this->assertTrue($s->saves->decode($s, str_replace('"elephant":false,', "", $snapshot))); + $this->assertFalse($s->player->elephantMode); + } + + public function testMapChangesKeepTheModeAndFreshPlayersResetIt(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + $this->assertTrue($s->loadMap("E1M2")); + $this->assertTrue($s->player->elephantMode); + $s->player->reset(); + $this->assertFalse($s->player->elephantMode); + } + + protected function fire(Session $s): void { + buffer $keys = buffer_new(512); + $keys[224] = 1; + $s->weapons->tick($s->player, $s->combat, new Input(), buffer_ptr($keys)); + buffer_free($keys); + } + + protected function weaponFrame(Session $s): string { + $s->renderer->screen->fill(254); + $s->renderer->weapons->draw($s->renderer->screen, $s->player, $s->trig, 0); + return $this->frame($s); + } + + protected function frame(Session $s): string { + return hash("sha256", ptr_read_string(buffer_ptr($s->renderer->screen->pixels), 64000)); + } +} From 6d84db9f99b195c9c4e16af4c0f70b299b81573a Mon Sep 17 00:00:00 2001 From: Vincenzo Petrucci Date: Wed, 28 Oct 2026 14:50:58 +0100 Subject: [PATCH 2/6] docs(cheats): explain the elephant mode and validation --- .plans/README.md | 1 + .plans/elephant-cheat.md | 20 ++++++++++++++++++++ README.md | 8 ++++++++ 3 files changed, 29 insertions(+) create mode 100644 .plans/elephant-cheat.md diff --git a/.plans/README.md b/.plans/README.md index aad8732..3a66d16 100644 --- a/.plans/README.md +++ b/.plans/README.md @@ -5,6 +5,7 @@ menus, cheats, saves and music, including validation and compatibility limits. - [macOS runtime](macos-runtime.md): relocatable SDL libraries and clean-machine archive checks. - [Release automation](release-automation.md): Make targets, native CI and version-tag releases. +- [Elephant cheat](elephant-cheat.md): embedded trunk art and elephant projectiles. The completed gameplay specifications moved from Elephc when DOOM became an independent project. Historical compiler and Code Journal references belong to Elephc. diff --git a/.plans/elephant-cheat.md b/.plans/elephant-cheat.md new file mode 100644 index 0000000..72dfa98 --- /dev/null +++ b/.plans/elephant-cheat.md @@ -0,0 +1,20 @@ +# Elephant cheat + +- [x] Add embedded palette-matched trunk, elephant and impact patches. +- [x] Implement IDELEPHC as a reversible pistol mode with real projectiles. +- [x] Preserve mode and in-flight projectiles in saves and across maps. +- [x] Test firing, collisions, rendering, resets and legacy saves. +- [ ] Inspect real-WAD frames and run the native CI matrix. + +The pistol slot keeps its bullet ammo and cadence. Other weapons keep their +behavior. Custom art is generated into memory, using the WAD's palette and the +existing sprite clipping, lighting and projectile collision paths. The WAD file +on disk is never modified. Save fingerprints cover only the original WAD bytes; +new sprite/type records are appended so existing save indices stay stable. + +## Local validation + +The pinned elephc 0.27.6 compiler passes all 98 gameplay tests (832 assertions), +including 10 new elephant regressions, and the 16 release-tooling tests. The +native SDL game builds successfully. Real shareware E1M1 frames were inspected +at rest and during firing, showing the trunk, recoil and flying elephants. diff --git a/README.md b/README.md index 04a5667..92d96d6 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,14 @@ Type cheat codes during play, without a console, then release movement keys as n | `IDCLEVxy` | Warp to episode x, mission y, if present in the WAD | | `IDMUSxy` | Select an available episode/mission music track | | `IDMYPOS` | Toggle position display | +| `IDELEPHC` | Toggle the elephant trunk in pistol slot 2; fires red/orange elephant projectiles | + +`IDELEPHC` selects the pistol slot and replaces it with an animated trunk. The +elephants travel through the level, hit monsters and stop at walls, using the +pistol's bullets and firing cadence. Type the code again to restore the pistol. +Other weapons keep their behavior. The mode survives map changes and saves; +starting a new game or restarting after death resets it. Its pixel art is built +into the executable and uses the loaded WAD's palette, with no extra asset files. Saves live in `saves/slot0.json` through `slot5.json`, relative to the launch directory. They preserve player inventory and powers, enemies/projectiles, map changes, movers, From 74ffce9cef980d12e163f4d3deab40109d907073 Mon Sep 17 00:00:00 2001 From: Vincenzo Petrucci Date: Wed, 28 Oct 2026 14:53:47 +0100 Subject: [PATCH 3/6] fix(boot): retain missing palette diagnostics --- .plans/elephant-cheat.md | 7 ++++++- src/App/Session.php | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/.plans/elephant-cheat.md b/.plans/elephant-cheat.md index 72dfa98..c3a6207 100644 --- a/.plans/elephant-cheat.md +++ b/.plans/elephant-cheat.md @@ -4,7 +4,7 @@ - [x] Implement IDELEPHC as a reversible pistol mode with real projectiles. - [x] Preserve mode and in-flight projectiles in saves and across maps. - [x] Test firing, collisions, rendering, resets and legacy saves. -- [ ] Inspect real-WAD frames and run the native CI matrix. +- [x] Inspect real-WAD frames and run the native CI matrix. The pistol slot keeps its bullet ammo and cadence. Other weapons keep their behavior. Custom art is generated into memory, using the WAD's palette and the @@ -18,3 +18,8 @@ The pinned elephc 0.27.6 compiler passes all 98 gameplay tests (832 assertions), including 10 new elephant regressions, and the 16 release-tooling tests. The native SDL game builds successfully. Real shareware E1M1 frames were inspected at rest and during firing, showing the trunk, recoil and flying elephants. + +[Native CI run 40700243717](https://github.com/getelephc/doom/actions/runs/40700243717) +passed on Linux x86_64, Linux ARM64 and macOS ARM64 at +`6d84db9f99b195c9c4e16af4c0f70b299b81573a`. The macOS archive also passed its +startup and SDL initialization check after removing the Homebrew SDL kegs. diff --git a/src/App/Session.php b/src/App/Session.php index 21d4999..9a249c6 100644 --- a/src/App/Session.php +++ b/src/App/Session.php @@ -100,7 +100,10 @@ public function boot(): bool { return false; } $this->say("Loaded WAD: " . $this->wad->kind . " | lumps: " . $this->wad->lumpCount); - if (!$this->palette->load($this->wad)) { return false; } + if (!$this->palette->load($this->wad)) { + $this->say("The WAD lacks PLAYPAL, textures, flats or sprites"); + return false; + } ElephantArt $art = new ElephantArt(); $art->install($this->wad, $this->palette); if (!$this->textures->load($this->wad) || !$this->flats->load($this->wad) || !$this->sprites->load($this->wad)) { From 371020969d39a76479b2b52f4e095f9ba44d370f Mon Sep 17 00:00:00 2001 From: Vincenzo Petrucci Date: Wed, 28 Oct 2026 15:10:29 +0100 Subject: [PATCH 4/6] feat(hud): add elephant expressions and a forward-facing trunk --- src/Graphics/ElephantArt.php | 55 +++++++------ src/Graphics/ElephantFaces.php | 109 +++++++++++++++++++++++++ src/Render/Hud.php | 19 +++++ src/engine.php | 1 + tests/ElephantHudTest.php | 142 +++++++++++++++++++++++++++++++++ 5 files changed, 302 insertions(+), 24 deletions(-) create mode 100644 src/Graphics/ElephantFaces.php create mode 100644 tests/ElephantHudTest.php diff --git a/src/Graphics/ElephantArt.php b/src/Graphics/ElephantArt.php index 66dc635..ee11439 100644 --- a/src/Graphics/ElephantArt.php +++ b/src/Graphics/ElephantArt.php @@ -16,13 +16,19 @@ public function install(Wad $wad, Palette $palette): void { $names = []; $pictures = []; for ($frame = 0; $frame < 3; $frame++) { $names[] = "ELTG" . chr(65 + $frame) . "0"; - int $recoil = $frame === 1 ? 6 : ($frame === 2 ? 2 : 0); - $pictures[] = $this->patch($this->trunk($frame), 64, 80, -128, -88 - $recoil); + int $recoil = $frame === 1 ? 4 : ($frame === 2 ? 2 : 0); + $pictures[] = $this->patch($this->trunk($frame), 112, 64, -132 - $recoil, -110 - $recoil); } for ($frame = 0; $frame < 5; $frame++) { $names[] = "ELPH" . chr(65 + $frame) . "0"; $pictures[] = $this->patch($this->elephant($frame), 32, 24, 16, 12); } + // Portraits follow the existing projectile frames so save indices stay stable. + ElephantFaces $faces = new ElephantFaces(); + for ($slot = 0; $slot < ElephantFaces::COUNT; $slot++) { + $names[] = ElephantFaces::name($slot); + $pictures[] = $this->patch($faces->picture($slot), 32, 32, 0, 0); + } $wad->appendLumps($names, $pictures); } @@ -41,34 +47,35 @@ public function nearest(Palette $palette, int $rgb): int { public function trunk(int $frame): array { $rows = []; - for ($y = 0; $y < 80; $y++) { + for ($y = 0; $y < 64; $y++) { string $row = ""; - for ($x = 0; $x < 64; $x++) { + for ($x = 0; $x < 112; $x++) { string $ink = "."; - int $center = 30 + intdiv((80 - $y) * (80 - $y), 500); - int $half = 10 + intdiv($y, 5); - int $dx = $x - $center; - if ($y >= 19 && abs($dx) <= $half) { + // Foreshortened top/side view: a low curved trunk from the lower + // right, with its narrow end aimed into the center of the view. + int $along = $x - 28; + int $center = 16 + intdiv($along * $along, 95); + int $half = 9 + intdiv(max(0, $along), 5); + int $dy = $y - $center; + if ($along >= 0 && abs($dy) <= $half) { $ink = "0"; - if (abs($dx) < $half - 2) { - $ink = $dx < -5 ? "1" : ($dx < 0 ? "2" : ($dx < intdiv($half, 2) ? "3" : "4")); - if ($dx === intdiv($half, 2) - 1) { $ink = "5"; } - // Wrinkles wrap around the trunk, with a lit upper edge. - int $ring = ($y + intdiv(abs($dx), 3)) % 12; - if ($y > 34 && $ring < 2) { $ink = $dx < 0 ? "0" : "2"; } - else if ($y > 34 && $ring === 2 && $dx > 0) { $ink = "5"; } + if (abs($dy) < $half - 2) { + $ink = $dy < -3 ? "4" : ($dy < 2 ? "3" : ($dy < intdiv($half, 2) ? "2" : "1")); + if ($dy === -$half + 3) { $ink = "5"; } + int $fold = ($along + intdiv(abs($dy), 3)) % 13; + if ($along > 9 && $fold === 0) { $ink = $dy > 0 ? "0" : "2"; } + else if ($along > 9 && $fold === 1 && $dy < 0) { $ink = "5"; } } } - int $mx = $x - 39; int $my = $y - 19; - int $rim = $mx * $mx * 81 + $my * $my * 225; - if ($rim <= 18225) { + // The end is seen from the side, not as an upward-facing funnel. + int $mx = $x - 28; int $my = $y - 16; + int $rim = $mx * $mx * 100 + $my * $my * 49; + if ($rim <= 4900) { $ink = "0"; - if ($rim < 14500) { $ink = $my < -2 ? "5" : "3"; } - if ($rim < 9000) { $ink = "1"; } - // Two dark nostrils make the muzzle read as a trunk. - if (($mx + 5) * ($mx + 5) + $my * $my * 2 < 25 - || ($mx - 5) * ($mx - 5) + $my * $my * 2 < 25) { $ink = "7"; } - if ($frame === 1 && $rim >= 9000 && $rim < 14500) { $ink = "6"; } + if ($rim < 3600) { $ink = $my < -2 ? "4" : "2"; } + if ($mx < -1 && $rim < 2400) { $ink = "1"; } + if ($mx >= -4 && $mx <= -2 && (abs($my - 4) < 2 || abs($my + 4) < 2)) { $ink = "7"; } + if ($frame === 1 && $mx > 0 && $my < -3 && $rim < 3600) { $ink = "5"; } } $row .= $ink; } diff --git a/src/Graphics/ElephantFaces.php b/src/Graphics/ElephantFaces.php new file mode 100644 index 0000000..d4074fd --- /dev/null +++ b/src/Graphics/ElephantFaces.php @@ -0,0 +1,109 @@ +pixel($x, $y, $pain, $expression, $look); + } + $rows[] = $row; + } + return $rows; + } + + public function pixel(int $x, int $y, int $pain, int $expression, int $look): string { + string $ink = "."; + int $droop = intdiv($pain, 2); + if ($expression === 1) { $droop = -1; } + if ($expression === 5) { $droop = 3; } + int $earY = $y - 15 - $droop; + int $earX = $x < 16 ? $x - 6 : $x - 25; + int $ear = $earX * $earX * 81 + $earY * $earY * 36; + if ($ear <= 2916) { + $ink = "0"; + if ($ear < 2300) { $ink = $earY < -3 ? "3" : "1"; } + if ($ear < 1300) { $ink = "2"; } + } + int $headX = $x - 16; + int $headY = $y - 13 - ($expression === 5 ? 3 : 0); + int $head = $headX * $headX * 144 + $headY * $headY * 81; + if ($head <= 11664) { + $ink = $head > 9800 ? "0" : ($x < 12 ? "2" : ($x > 21 ? "3" : "4")); + if ($headY < -5 && abs($headX) < 4) { $ink = "5"; } + if ($pain >= 1 && $x === 23 && $y >= 14 && $y <= 16) { $ink = "1"; } + if ($pain >= 2 && $x >= 10 && $x <= 13 && $y >= 15 && $y <= 17) { $ink = "1"; } + if ($pain >= 4 && $x >= 18 && $x <= 23 && $y === 7) { $ink = "6"; } + } + // A downturned, curling trunk and two small ivory tusks frame the mouth. + if ($y >= 15 && $y <= 29) { + int $curl = $y > 23 ? intdiv(($y - 23) * ($y - 23), 8) : 0; + int $center = 16 + $curl; + int $radius = $y < 20 ? 4 : 3; + int $distance = abs($x - $center); + if ($distance <= $radius) { + $ink = $distance === $radius ? "1" : ($x < $center ? "3" : "4"); + if ($y === 20 || $y === 24) { $ink = $x < $center ? "1" : "2"; } + } + if ($y >= 27 && $y <= 28 && ($x === $center - 1 || $x === $center + 1)) { $ink = "7"; } + } + if ($y >= 19 && $y <= 23) { + int $tip = intdiv($y - 19, 2); + if ($x === 11 - $tip || $x === 21 + $tip) { $ink = "6"; } + if ($y < 22 && ($x === 12 - $tip || $x === 20 + $tip)) { $ink = "5"; } + } + if ($expression === 3 && $y >= 19 && $y <= 21 && (($x >= 9 && $x <= 11) || ($x >= 21 && $x <= 23))) { + $ink = $y === 21 ? "0" : "6"; + } + // Eyes track left/center/right. Damage opens them, firing lowers the + // brows, pickups squint happily, god mode wears shades, death has X eyes. + for ($eye = 0; $eye < 2; $eye++) { + int $cx = $eye === 0 ? 11 : 21; + int $dx = $x - $cx; + int $ey = $y - 11; + if ($expression === 5) { + $ey = $y - 15; + if (abs($dx) <= 2 && abs($dx) === abs($ey)) { $ink = "7"; } + } else if ($expression === 4) { + if (abs($dx) <= 4 && $ey >= -2 && $ey <= 2) { $ink = $ey === -2 ? "5" : "7"; } + if (abs($dx) <= 2 && $ey === -1) { $ink = "3"; } + if ($x >= 15 && $x <= 17 && $y === 10) { $ink = "7"; } + } else if ($expression === 3) { + if (abs($dx) <= 2 && $ey === -1 + intdiv(abs($dx), 2)) { $ink = "7"; } + } else { + int $height = $expression === 1 ? 2 : 1; + if (abs($dx) <= 2 && abs($ey) <= $height) { $ink = "6"; } + if ($dx === $look && abs($ey) <= 1) { $ink = "7"; } + int $brow = -3; + if ($expression === 2) { $brow = -3 + ($eye === 0 ? intdiv($dx + 2, 2) : intdiv(2 - $dx, 2)); } + else if ($pain >= 3) { $brow = -2; } + if (abs($dx) <= 3 && $ey === $brow) { $ink = "0"; } + } + } + return $ink; + } +} diff --git a/src/Render/Hud.php b/src/Render/Hud.php index d1da252..3f7b383 100644 --- a/src/Render/Hud.php +++ b/src/Render/Hud.php @@ -2,6 +2,7 @@ namespace Showcases\Doom\Render; +use Showcases\Doom\Graphics\ElephantFaces; use Showcases\Doom\Play\Player; use Showcases\Doom\Wad\Wad; @@ -9,6 +10,7 @@ // the big red STTNUM digits for ammo, health and armor, the STARMS weapon panel, // the marine's face (STFST, by pain level and glance direction), key cards, ammo // tallies in small STYSNUM digits, and STCFN font text for messages and the FPS. +// IDELEPHC selects built-in elephant portraits with state-driven expressions. final class Hud { public Wad $wad; public Screen $screen; @@ -22,6 +24,7 @@ final class Hud { public buffer $grayDigit; public buffer $keyCard; public buffer $face; + public buffer $elephantFace; public buffer $font; public int $bigDigitWidth; public int $faceLook; @@ -49,6 +52,10 @@ public function __construct(Wad $wad, Screen $screen, int $top) { $this->keyCard[$k] = $wad->find("STKEYS" . $k); } $this->face = buffer_new(15); + $this->elephantFace = buffer_new(ElephantFaces::COUNT); + for ($f = 0; $f < ElephantFaces::COUNT; $f++) { + $this->elephantFace[$f] = $wad->find(ElephantFaces::name($f)); + } for ($pain = 0; $pain < 5; $pain++) { for ($look = 0; $look < 3; $look++) { int $row = $pain * 3; @@ -158,6 +165,7 @@ public function drawStatusBar(Player $player): void { } public function faceLump(Player $player): int { + if ($player->elephantMode) { return $this->elephantFaceLump($player); } if ($player->health <= 0) { return $this->faceDead; } if ($player->godMode || $player->invulnerable > 0) { return $this->wad->find("STFGOD0"); } int $health = $player->health; @@ -172,6 +180,17 @@ public function faceLump(Player $player): int { return $this->face[$slot]; } + public function elephantFaceLump(Player $player): int { + if ($player->health <= 0) { return $this->elephantFace[ElephantFaces::DEAD]; } + if ($player->godMode || $player->invulnerable > 0) { return $this->elephantFace[ElephantFaces::GOD]; } + int $pain = intdiv((100 - min(100, $player->health)) * 5, 101); + int $slot = $pain * 3 + $this->faceLook; + if ($player->damageCount > 0) { $slot = ElephantFaces::HURT + $pain; } + else if ($player->bonusCount > 0) { $slot = ElephantFaces::GRIN + $pain; } + else if ($player->fireHeld && $player->weaponCooldown > 0) { $slot = ElephantFaces::FIRE + $pain; } + return $this->elephantFace[$slot]; + } + // Right-aligned at $right, like DOOM's number widgets. public function bigNumber(int $value, int $right, int $y): void { int $remaining = $value; diff --git a/src/engine.php b/src/engine.php index 42c7e31..e26c33c 100644 --- a/src/engine.php +++ b/src/engine.php @@ -26,6 +26,7 @@ require_once 'Graphics/Animations.php'; require_once 'Graphics/Palette.php'; +require_once 'Graphics/ElephantFaces.php'; require_once 'Graphics/ElephantArt.php'; require_once 'Graphics/Textures.php'; require_once 'Graphics/Flats.php'; diff --git a/tests/ElephantHudTest.php b/tests/ElephantHudTest.php new file mode 100644 index 0000000..9bfa69a --- /dev/null +++ b/tests/ElephantHudTest.php @@ -0,0 +1,142 @@ +add("STBAR", $wad->patch(320, 32, 0, 0, 40, 0)); + for ($pain = 0; $pain < 5; $pain++) { + for ($look = 0; $look < 3; $look++) { + $wad->add("STFST" . $pain . $look, $wad->patch(24, 30, 0, 0, 100 + $pain * 3 + $look, 0)); + } + } + $wad->add("STFGOD0", $wad->patch(24, 30, 0, 0, 206, 0)); + $wad->add("STFDEAD0", $wad->patch(24, 30, 0, 0, 207, 0)); + $this->wadPath = $wad->write(); + Config $config = new Config(); + $config->wadPath = $this->wadPath; + $config->verbose = false; + Session $s = new Session($config); + $this->assertTrue($s->boot()); + return $s; + } + + public function testElephantReplacesOnlyThePortraitAndTogglesBackToTheMarine(): void { + $s = $this->boot(); + string $classic = $this->status($s); + int $marine = $s->renderer->hud->faceLump($s->player); + $s->cheats->feed($s, "idelephc"); + string $elephant = $this->status($s); + $this->assertFalse($classic === $elephant); + int $outside = 0; + for ($i = 0; $i < 64000; $i++) { + int $x = $i % 320; int $y = intdiv($i, 320); + if (($x < 143 || $x >= 175 || $y < 168) && $classic[$i] !== $elephant[$i]) { $outside++; } + } + $this->assertSameInt(0, $outside); + $s->cheats->feed($s, "idelephc"); + $this->assertSameInt($marine, $s->renderer->hud->faceLump($s->player)); + $this->assertSameString($classic, $this->status($s)); + } + + public function testGlancesAndAllFiveHealthBandsHaveDistinctPortraits(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + $s->renderer->hud->tick(17, 0); + string $left = $this->portrait($s); + $s->renderer->hud->tick(34, 2); + string $right = $this->portrait($s); + $this->assertFalse($left === $right); + $s->renderer->hud->tick(51, 1); + $this->assertFalse($left === $this->portrait($s)); + $this->assertFalse($right === $this->portrait($s)); + $health = [100, 75, 50, 25, 1]; + $seen = []; + foreach ($health as $value) { + $s->player->health = (int)$value; + string $face = $this->portrait($s); + $this->assertFalse(in_array($face, $seen, true)); + $seen[] = $face; + } + } + + public function testPortraitReactsToFireDamagePickupsGodModeAndDeath(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + string $idle = $this->portrait($s); + $s->player->fireHeld = true; $s->player->weaponCooldown = 14; + string $firing = $this->portrait($s); + $this->assertFalse($idle === $firing); + $s->player->damageCount = 8; + string $hurt = $this->portrait($s); + $this->assertFalse($hurt === $firing); + $s->player->bonusCount = 8; + $this->assertSameString($hurt, $this->portrait($s)); + $s->player->damageCount = 0; + string $grin = $this->portrait($s); + $this->assertFalse($grin === $hurt); + $this->assertFalse($grin === $idle); + $s->player->godMode = true; + string $god = $this->portrait($s); + $this->assertFalse($god === $grin); + $s->player->godMode = false; $s->player->invulnerable = 10; + $this->assertSameString($god, $this->portrait($s)); + $s->player->health = 0; + $this->assertFalse($god === $this->portrait($s)); + $s->player->elephantMode = false; + $this->assertSameInt($s->wad->find("STFDEAD0"), $s->renderer->hud->faceLump($s->player)); + } + + public function testSavesRestoreTheElephantExpressionWithoutExtraState(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + $s->player->health = 25; + $s->player->damageCount = 8; + string $hurt = $this->portrait($s); + string $snapshot = $s->saves->encode($s); + $s->player->reset(); + $this->assertTrue($s->saves->decode($s, $snapshot)); + $this->assertSameString($hurt, $this->portrait($s)); + } + + public function testTrunkHasALowForwardSilhouette(): void { + $s = $this->boot(); + $s->cheats->feed($s, "idelephc"); + $screen = $s->renderer->screen; + $screen->fill(254); + $s->renderer->weapons->draw($screen, $s->player, $s->trig, 0); + int $left = 320; int $right = 0; int $top = 200; int $bottom = 0; + for ($i = 0; $i < 64000; $i++) { + if ($screen->pixels[$i] === 254) { continue; } + int $x = $i % 320; int $y = intdiv($i, 320); + $left = min($left, $x); $right = max($right, $x); + $top = min($top, $y); $bottom = max($bottom, $y); + } + $this->assertTrue($top >= 115); + $this->assertTrue($right - $left > $bottom - $top); + $this->assertTrue($right - $left >= 80); + } + + protected function status(Session $s): string { + $s->renderer->screen->fill(254); + $s->renderer->hud->drawStatusBar($s->player); + return ptr_read_string(buffer_ptr($s->renderer->screen->pixels), 64000); + } + + protected function portrait(Session $s): string { + string $screen = $this->status($s); + string $crop = ""; + for ($y = 168; $y < 200; $y++) { $crop .= substr($screen, $y * 320 + 143, 32); } + return hash("sha256", $crop); + } +} From 119df6694d54b084c46263816d1159062890ff9e Mon Sep 17 00:00:00 2001 From: Vincenzo Petrucci Date: Wed, 28 Oct 2026 15:10:30 +0100 Subject: [PATCH 5/6] docs(cheats): describe the elephant portrait and visual checks --- .plans/elephant-cheat.md | 16 ++++++++++++++++ README.md | 7 +++++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/.plans/elephant-cheat.md b/.plans/elephant-cheat.md index c3a6207..ab36788 100644 --- a/.plans/elephant-cheat.md +++ b/.plans/elephant-cheat.md @@ -23,3 +23,19 @@ at rest and during firing, showing the trunk, recoil and flying elephants. passed on Linux x86_64, Linux ARM64 and macOS ARM64 at `6d84db9f99b195c9c4e16af4c0f70b299b81573a`. The macOS archive also passed its startup and SDL initialization check after removing the Homebrew SDL kegs. + +## Visual revision + +- [x] Redraw the weapon as a low, curved trunk aimed forward from the lower right. +- [x] Add an elephant HUD portrait while IDELEPHC is active. +- [x] Cover glances, five health bands, firing, damage, pickups, god mode and death. +- [x] Check portrait bounds, toggle restoration, saves and real E1M1 frames. + +The portrait patches are appended after the original custom weapon/projectile +patches and use names excluded from the sprite-frame catalog. This keeps existing +saved projectile definitions stable. Expressions use the player's existing state; +no new save fields or random stream are needed. Five new HUD regressions cover +the expression priorities, health bands, frame bounds, save restoration and the +trunk's low silhouette. E1M1 idle/firing frames and a portrait sheet were inspected. +The two elephant test classes pass locally (15 tests, 88 assertions), and the +native SDL executable was rebuilt with this revision. diff --git a/README.md b/README.md index 92d96d6..07a98cd 100644 --- a/README.md +++ b/README.md @@ -174,9 +174,12 @@ Type cheat codes during play, without a console, then release movement keys as n | `IDMYPOS` | Toggle position display | | `IDELEPHC` | Toggle the elephant trunk in pistol slot 2; fires red/orange elephant projectiles | -`IDELEPHC` selects the pistol slot and replaces it with an animated trunk. The +`IDELEPHC` selects the pistol slot and replaces it with a low, forward-pointing +animated trunk. The status-bar portrait becomes an elephant that glances around +and reacts to health, damage, firing, pickups, invulnerability and death. The elephants travel through the level, hit monsters and stop at walls, using the -pistol's bullets and firing cadence. Type the code again to restore the pistol. +pistol's bullets and firing cadence. Type the code again to restore the pistol +and the marine portrait. Other weapons keep their behavior. The mode survives map changes and saves; starting a new game or restarting after death resets it. Its pixel art is built into the executable and uses the loaded WAD's palette, with no extra asset files. From 6fe5ca279dcf912739c8b1cec352a8fd9801d062 Mon Sep 17 00:00:00 2001 From: Vincenzo Petrucci Date: Wed, 28 Oct 2026 15:44:26 +0100 Subject: [PATCH 6/6] fix(render): show a rear-view elephant head in the pistol slot --- .plans/elephant-cheat.md | 20 ++++++------ README.md | 9 +++--- src/Graphics/ElephantArt.php | 56 +++++++++++++++++++--------------- src/Graphics/ElephantFaces.php | 2 +- src/Render/WeaponRenderer.php | 12 ++++---- tests/ElephantCheatTest.php | 2 +- tests/ElephantHudTest.php | 8 ++++- 7 files changed, 62 insertions(+), 47 deletions(-) diff --git a/.plans/elephant-cheat.md b/.plans/elephant-cheat.md index ab36788..7a934af 100644 --- a/.plans/elephant-cheat.md +++ b/.plans/elephant-cheat.md @@ -1,6 +1,6 @@ # Elephant cheat -- [x] Add embedded palette-matched trunk, elephant and impact patches. +- [x] Add embedded palette-matched rear-head, elephant and impact patches. - [x] Implement IDELEPHC as a reversible pistol mode with real projectiles. - [x] Preserve mode and in-flight projectiles in saves and across maps. - [x] Test firing, collisions, rendering, resets and legacy saves. @@ -14,10 +14,8 @@ new sprite/type records are appended so existing save indices stay stable. ## Local validation -The pinned elephc 0.27.6 compiler passes all 98 gameplay tests (832 assertions), -including 10 new elephant regressions, and the 16 release-tooling tests. The -native SDL game builds successfully. Real shareware E1M1 frames were inspected -at rest and during firing, showing the trunk, recoil and flying elephants. +The initial implementation passed 98 gameplay tests (832 assertions), including +10 elephant regressions, and the 16 release-tooling tests with elephc 0.27.6. [Native CI run 40700243717](https://github.com/getelephc/doom/actions/runs/40700243717) passed on Linux x86_64, Linux ARM64 and macOS ARM64 at @@ -26,7 +24,7 @@ startup and SDL initialization check after removing the Homebrew SDL kegs. ## Visual revision -- [x] Redraw the weapon as a low, curved trunk aimed forward from the lower right. +- [x] Show a small rear-view elephant head low in the view, with broad flapping ears. - [x] Add an elephant HUD portrait while IDELEPHC is active. - [x] Cover glances, five health bands, firing, damage, pickups, god mode and death. - [x] Check portrait bounds, toggle restoration, saves and real E1M1 frames. @@ -36,6 +34,10 @@ patches and use names excluded from the sprite-frame catalog. This keeps existin saved projectile definitions stable. Expressions use the player's existing state; no new save fields or random stream are needed. Five new HUD regressions cover the expression priorities, health bands, frame bounds, save restoration and the -trunk's low silhouette. E1M1 idle/firing frames and a portrait sheet were inspected. -The two elephant test classes pass locally (15 tests, 88 assertions), and the -native SDL executable was rebuilt with this revision. +weapon's low silhouette. Real E1M1 idle/firing frames and a portrait sheet were +inspected. The two elephant test classes pass locally (15 tests, 93 assertions), +and the native SDL executable was rebuilt with this revision. + +The weapon shows only the back of a rounded head and its ears. Its existing lump +names and frame order are preserved so saved sprite references remain stable. +The placement regression checks both ears and leaves the upper view clear. diff --git a/README.md b/README.md index 07a98cd..fd93927 100644 --- a/README.md +++ b/README.md @@ -172,11 +172,12 @@ Type cheat codes during play, without a console, then release movement keys as n | `IDCLEVxy` | Warp to episode x, mission y, if present in the WAD | | `IDMUSxy` | Select an available episode/mission music track | | `IDMYPOS` | Toggle position display | -| `IDELEPHC` | Toggle the elephant trunk in pistol slot 2; fires red/orange elephant projectiles | +| `IDELEPHC` | Toggle elephant mode in pistol slot 2; fires red/orange elephant projectiles | -`IDELEPHC` selects the pistol slot and replaces it with a low, forward-pointing -animated trunk. The status-bar portrait becomes an elephant that glances around -and reacts to health, damage, firing, pickups, invulnerability and death. The +`IDELEPHC` selects the pistol slot and shows an elephant's head from behind, +low in the view, with ears that flap when firing. The status-bar portrait becomes +an elephant that glances around and reacts to health, damage, firing, pickups, +invulnerability and death. The elephants travel through the level, hit monsters and stop at walls, using the pistol's bullets and firing cadence. Type the code again to restore the pistol and the marine portrait. diff --git a/src/Graphics/ElephantArt.php b/src/Graphics/ElephantArt.php index ee11439..d138b6e 100644 --- a/src/Graphics/ElephantArt.php +++ b/src/Graphics/ElephantArt.php @@ -15,9 +15,10 @@ public function install(Wad $wad, Palette $palette): void { foreach ($ramp as $rgb) { $this->colors[] = $this->nearest($palette, (int)$rgb); } $names = []; $pictures = []; for ($frame = 0; $frame < 3; $frame++) { + // Preserve the existing lump names and frame order for saved indices. $names[] = "ELTG" . chr(65 + $frame) . "0"; - int $recoil = $frame === 1 ? 4 : ($frame === 2 ? 2 : 0); - $pictures[] = $this->patch($this->trunk($frame), 112, 64, -132 - $recoil, -110 - $recoil); + int $recoil = $frame === 1 ? 3 : ($frame === 2 ? 1 : 0); + $pictures[] = $this->patch($this->rearHead($frame), 88, 64, -116, -110 - $recoil); } for ($frame = 0; $frame < 5; $frame++) { $names[] = "ELPH" . chr(65 + $frame) . "0"; @@ -45,37 +46,42 @@ public function nearest(Palette $palette, int $rgb): int { return $best; } - public function trunk(int $frame): array { + public function rearHead(int $frame): array { $rows = []; for ($y = 0; $y < 64; $y++) { string $row = ""; - for ($x = 0; $x < 112; $x++) { + for ($x = 0; $x < 88; $x++) { string $ink = "."; - // Foreshortened top/side view: a low curved trunk from the lower - // right, with its narrow end aimed into the center of the view. - int $along = $x - 28; - int $center = 16 + intdiv($along * $along, 95); - int $half = 9 + intdiv(max(0, $along), 5); - int $dy = $y - $center; - if ($along >= 0 && abs($dy) <= $half) { + // Broad, scalloped ears frame the back of the head. On firing, + // they spread slightly while the entire head recoils downward. + int $flap = $frame === 1 ? 2 : ($frame === 2 ? 1 : 0); + int $side = $x < 44 ? $x : 87 - $x; + int $ex = $side - 19 + $flap; + int $ey = $y - 31; + int $ear = $ex * $ex * 484 + $ey * $ey * 289; + bool $notch = $side < 10 && $y > 42; + if ($ear <= 139876 && !$notch) { $ink = "0"; - if (abs($dy) < $half - 2) { - $ink = $dy < -3 ? "4" : ($dy < 2 ? "3" : ($dy < intdiv($half, 2) ? "2" : "1")); - if ($dy === -$half + 3) { $ink = "5"; } - int $fold = ($along + intdiv(abs($dy), 3)) % 13; - if ($along > 9 && $fold === 0) { $ink = $dy > 0 ? "0" : "2"; } - else if ($along > 9 && $fold === 1 && $dy < 0) { $ink = "5"; } + if ($ear < 118000) { + $ink = $ey < -8 ? "3" : "2"; + if ($side > 25) { $ink = "1"; } + if ($ey < -15 && $side > 12 && $side < 24) { $ink = "4"; } + int $fold = 35 + intdiv(abs($ex), 2); + if ($y === $fold && $side < 26) { $ink = "1"; } } } - // The end is seen from the side, not as an upward-facing funnel. - int $mx = $x - 28; int $my = $y - 16; - int $rim = $mx * $mx * 100 + $my * $my * 49; - if ($rim <= 4900) { + // A rounded crown and nape are all that the rear view exposes. + int $hx = $x - 44; int $hy = $y - 42; + int $head = $hx * $hx * 676 + $hy * $hy * 625; + if ($head <= 422500) { $ink = "0"; - if ($rim < 3600) { $ink = $my < -2 ? "4" : "2"; } - if ($mx < -1 && $rim < 2400) { $ink = "1"; } - if ($mx >= -4 && $mx <= -2 && (abs($my - 4) < 2 || abs($my + 4) < 2)) { $ink = "7"; } - if ($frame === 1 && $mx > 0 && $my < -3 && $rim < 3600) { $ink = "5"; } + if ($head < 377000) { + $ink = $hx < -12 || $hy > 12 ? "2" : "3"; + int $shineX = $hx - 7; int $shineY = $hy + 10; + int $shine = $shineX * $shineX * 196 + $shineY * $shineY * 144; + if ($shine < 28224) { $ink = "4"; } + if ($shine < 3500 && $shineY < 0) { $ink = "5"; } + } } $row .= $ink; } diff --git a/src/Graphics/ElephantFaces.php b/src/Graphics/ElephantFaces.php index d4074fd..f343c46 100644 --- a/src/Graphics/ElephantFaces.php +++ b/src/Graphics/ElephantFaces.php @@ -2,7 +2,7 @@ namespace Showcases\Doom\Graphics; -// A 32x32 elephant portrait in the same indexed ramp as the trunk. All poses are +// A 32x32 elephant portrait in the same indexed ramp as the weapon. All poses are // generated at boot; the HUD only selects a cached patch from current game state. final class ElephantFaces { public const HURT = 15; diff --git a/src/Render/WeaponRenderer.php b/src/Render/WeaponRenderer.php index 09127ad..5d5f2d3 100644 --- a/src/Render/WeaponRenderer.php +++ b/src/Render/WeaponRenderer.php @@ -12,13 +12,13 @@ final class WeaponRenderer { public Wad $wad; public buffer $patches; public int $available; - public buffer $trunk; + public buffer $elephantHead; public function __construct(Wad $wad) { $this->wad = $wad; - $this->trunk = buffer_new(3); + $this->elephantHead = buffer_new(3); for ($f = 0; $f < 3; $f++) { - $this->trunk[$f] = $wad->find("ELTG" . chr(65 + $f) . "0"); + $this->elephantHead[$f] = $wad->find("ELTG" . chr(65 + $f) . "0"); } $this->patches = buffer_new(90); $prefixes = ["", "PUNG", "PISG", "SHTG", "CHGG", "MISG", "PLSG", "BFGG", "SAWG"]; @@ -93,11 +93,11 @@ public function draw(Screen $screen, Player $player, Trig $trig, int $tic): void int $x = ($trig->cos($tic * 409) * $bob) >> 16; int $y = abs(($trig->sin($tic * 409) * $bob) >> 16); if ($player->weapon === 2 && $player->elephantMode) { - int $trunkFrame = 0; + int $headFrame = 0; if ($player->weaponCooldown > 0 && $player->weaponAge < 9) { - $trunkFrame = $player->weaponAge < 5 ? 1 : 2; + $headFrame = $player->weaponAge < 5 ? 1 : 2; } - $screen->drawPatch($this->wad, $this->trunk[$trunkFrame], $x, $y); + $screen->drawPatch($this->wad, $this->elephantHead[$headFrame], $x, $y); return; } int $slot = $base + $frame; diff --git a/tests/ElephantCheatTest.php b/tests/ElephantCheatTest.php index 2613db9..61a2bb7 100644 --- a/tests/ElephantCheatTest.php +++ b/tests/ElephantCheatTest.php @@ -84,7 +84,7 @@ public function testDisablingTheCheatRestoresHitscanWithoutRemovingFlyingElephan $this->assertSameInt(6, $s->things->mobjs[0]->state); } - public function testTrunkAnimatesAndOtherWeaponsKeepTheirArt(): void { + public function testElephantHeadAnimatesAndOtherWeaponsKeepTheirArt(): void { $s = $this->boot(); string $pistol = $this->weaponFrame($s); $s->cheats->feed($s, "idelephc"); diff --git a/tests/ElephantHudTest.php b/tests/ElephantHudTest.php index 9bfa69a..5acf55f 100644 --- a/tests/ElephantHudTest.php +++ b/tests/ElephantHudTest.php @@ -109,7 +109,7 @@ public function testSavesRestoreTheElephantExpressionWithoutExtraState(): void { $this->assertSameString($hurt, $this->portrait($s)); } - public function testTrunkHasALowForwardSilhouette(): void { + public function testRearHeadStaysLowAndHasTwoWideEars(): void { $s = $this->boot(); $s->cheats->feed($s, "idelephc"); $screen = $s->renderer->screen; @@ -125,6 +125,12 @@ public function testTrunkHasALowForwardSilhouette(): void { $this->assertTrue($top >= 115); $this->assertTrue($right - $left > $bottom - $top); $this->assertTrue($right - $left >= 80); + // The ears flank a centered head, with an open gap above each shoulder. + $this->assertTrue($left < 130 && $right > 190); + $this->assertTrue($screen->pixels[140 * 320 + 132] !== 254); + $this->assertTrue($screen->pixels[140 * 320 + 188] !== 254); + $this->assertTrue($screen->pixels[118 * 320 + 132] === 254); + $this->assertTrue($screen->pixels[118 * 320 + 188] === 254); } protected function status(Session $s): string {