Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .plans/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
43 changes: 43 additions & 0 deletions .plans/elephant-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Elephant cheat

- [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.
- [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
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 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
`6d84db9f99b195c9c4e16af4c0f70b299b81573a`. The macOS archive also passed its
startup and SDL initialization check after removing the Homebrew SDL kegs.

## Visual revision

- [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.

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
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.
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,18 @@ 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 elephant mode in pistol slot 2; fires red/orange elephant projectiles |

`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.
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,
Expand Down
7 changes: 5 additions & 2 deletions src/App/SaveGame.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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]];
Expand Down Expand Up @@ -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; }
Expand Down Expand Up @@ -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"];
Expand Down
9 changes: 8 additions & 1 deletion src/App/Session.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -99,7 +100,13 @@ 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)) {
$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)) {
$this->say("The WAD lacks PLAYPAL, textures, flats or sprites");
return false;
}
Expand Down
162 changes: 162 additions & 0 deletions src/Graphics/ElephantArt.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php

namespace Showcases\Doom\Graphics;

use Showcases\Doom\Wad\Wad;

// Original pixel art, generated once at boot as ordinary transparent WAD patches.
// The warm red/orange ramp follows elephc's elephant mark. No external assets or
// changes to the player's WAD are needed; normal sprite projection clips the art.
final class ElephantArt {
public array $colors = [];

public function install(Wad $wad, Palette $palette): void {
$ramp = [0x391522, 0x80182D, 0xE52C46, 0xFF6928, 0xFFA22A, 0xFFDA69, 0xFFF1CB, 0x20131D];
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 ? 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";
$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);
}

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 rearHead(int $frame): array {
$rows = [];
for ($y = 0; $y < 64; $y++) {
string $row = "";
for ($x = 0; $x < 88; $x++) {
string $ink = ".";
// 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 ($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"; }
}
}
// 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 ($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;
}
$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); }
}
Loading
Loading