From d59d45bf01f249a0087c0f8e22f83dc63014a169 Mon Sep 17 00:00:00 2001 From: hode-hd Date: Sat, 9 May 2026 20:28:03 +0200 Subject: [PATCH 1/3] HD rendering, automation API, prerender, menu/input fixes This series layers an HD-rendering / automation feature set on top of the upstream hode engine without altering the original game logic. New modules: - hd_compositor: HD framebuffer, multi-resolution presets (HD/FullHD/QHD/4K), 16:9 widescreen with palette-derived gradient borders, sprite/background composition, prerender driver + unified console progress bar. - sprite_upscaler: chained xBRZ scalers (2x..5x with 4x via 2x*2x and 5x via nearest), MLAA edge smoothing, RAM LRU + disk cache keyed by content hash (sprite bytes + dims + flip flags + palette hash) so cached frames remain correct across screen palette changes and persist across runs. - edge_smooth: MLAA anti-aliasing applied to upscaled output. - automation_api: Unix-domain socket JSON server (input injection, frame step, screenshot, get_state, set_level) for scripted/headless testing. - test_*.py: Python regression scripts driving the automation API. Engine integration: - main.cpp: --hd, --fullhd, --4k, --hd-scale=N, --hd-wide, --hd-cache=PATH, --smooth, --automation=PATH, --prerender flags. - game.cpp: HD compositor begin/end-frame hooks in drawScreen, smooth-anim decoupled render loop (60 Hz render, 12.5 Hz logic), prerender phase that walks every sprite type x frame x flip variant plus the level's small in-gameplay PAF clips and intro cutscene, with one shared progress bar. - paf.cpp: HD frame callback path, _prerenderMode that decodes every frame full-speed without audio/display/sleep so the disk cache populates, peekFramesCount helper for sizing the unified progress bar. - video.cpp: zero-init _font in ctor. Menu and input fixes: - menu.cpp/h: keyboard binding screen learns OK / Cancel / Test sub-buttons, binding a key clears the same scancode from any other action (one key per action), Space/Enter/Tab/Backspace/Win can now be bound, fallback text labels (SP/EN/TB/BS/WN) for keys without an icon glyph. - system_sdl2.cpp: applyKeyboardControls() now keeps the original default mappings active and adds user keys on top so binding one action no longer silences the others. waitForKeyPress clears edge state on return so the user's previously held SELECT key doesn't re-fire on the next event tick. - main.cpp: wire _video->_font from _res->_fontBuffer right after loadSetupDat(), since the menu (which runs before Game::mainLoop) draws text via _video->drawStringCharacter. Portability: - intern.h: macOS branch using (macOS doesn't ship glibc-style ). --- .gitignore | 19 +- Makefile | 9 +- README.md | 310 ++++++++++++++++++++++++ automation_api.cpp | 359 ++++++++++++++++++++++++++++ automation_api.h | 62 +++++ edge_smooth.cpp | 105 +++++++++ edge_smooth.h | 15 ++ game.cpp | 348 +++++++++++++++++++++++++-- game.h | 26 ++ hd_compositor.cpp | 379 +++++++++++++++++++++++++++++ hd_compositor.h | 104 ++++++++ intern.h | 17 +- main.cpp | 119 ++++++++-- menu.cpp | 144 ++++++++++- menu.h | 2 + paf.cpp | 42 +++- paf.h | 10 + sprite_upscaler.cpp | 564 ++++++++++++++++++++++++++++++++++++++++++++ sprite_upscaler.h | 93 ++++++++ system.h | 6 +- system_sdl2.cpp | 166 +++++++++---- test_all_levels.py | 279 ++++++++++++++++++++++ test_combat_bot.py | 308 ++++++++++++++++++++++++ test_hd_replay.py | 142 +++++++++++ test_walkthrough.py | 189 +++++++++++++++ video.cpp | 1 + 26 files changed, 3702 insertions(+), 116 deletions(-) create mode 100644 README.md create mode 100644 automation_api.cpp create mode 100644 automation_api.h create mode 100644 edge_smooth.cpp create mode 100644 edge_smooth.h create mode 100644 hd_compositor.cpp create mode 100644 hd_compositor.h create mode 100644 sprite_upscaler.cpp create mode 100644 sprite_upscaler.h create mode 100644 test_all_levels.py create mode 100644 test_combat_bot.py create mode 100644 test_hd_replay.py create mode 100644 test_walkthrough.py diff --git a/.gitignore b/.gitignore index e551916..2223859 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,19 @@ -*.d *.o -build +*.d hode -hode.ini + +.DS_Store + +HOD.PAF +SETUP.DAT +*_HOD.LVL +*_HOD.SSS +*_HOD.MST + setup.cfg +hode.ini + +cache/ +cache_test/ + +CLAUDE.md diff --git a/Makefile b/Makefile index 1e6dec2..1f2de41 100644 --- a/Makefile +++ b/Makefile @@ -2,14 +2,15 @@ SDL_CFLAGS = `sdl2-config --cflags` SDL_LIBS = `sdl2-config --libs` -CPPFLAGS += -g -Wall -Wextra -Wno-unused-parameter -Wpedantic $(SDL_CFLAGS) $(DEFINES) -MMD +CPPFLAGS += -g -std=c++11 -Wall -Wextra -Wno-unused-parameter -Wpedantic $(SDL_CFLAGS) $(DEFINES) -MMD -SRCS = andy.cpp benchmark.cpp fileio.cpp fs_posix.cpp game.cpp \ +SRCS = andy.cpp automation_api.cpp benchmark.cpp edge_smooth.cpp \ + fileio.cpp fs_posix.cpp game.cpp hd_compositor.cpp \ level1_rock.cpp level2_fort.cpp level3_pwr1.cpp level4_isld.cpp \ level5_lava.cpp level6_pwr2.cpp level7_lar1.cpp level8_lar2.cpp level9_dark.cpp \ lzw.cpp main.cpp mdec.cpp menu.cpp mixer.cpp monsters.cpp paf.cpp random.cpp \ - resource.cpp screenshot.cpp sound.cpp staticres.cpp system_sdl2.cpp \ - util.cpp video.cpp + resource.cpp screenshot.cpp sound.cpp sprite_upscaler.cpp \ + staticres.cpp system_sdl2.cpp util.cpp video.cpp SCALERS := scaler_xbr.cpp diff --git a/README.md b/README.md new file mode 100644 index 0000000..00f22db --- /dev/null +++ b/README.md @@ -0,0 +1,310 @@ +# hode-hd + +Enhanced fork of [Gregory Montoir's `hode`](https://github.com/usineur/hode), a +reverse-engineered reimplementation of the engine used by *Heart of Darkness* +(Amazing Studio, 1998). + +This fork keeps the original engine intact and layers on: + +- **HD rendering** — xBRZ sprite/background upscaling at 6x / 8x / 10x / 15x with + on-the-fly chained scalers and MLAA edge smoothing +- **16:9 widescreen** with palette-derived gradient borders +- **Animation interpolation** — optional 60 Hz render with 12.5 Hz logic tick +- **PAF cutscene HD upscaling** with persistent disk cache +- **Sprite & cutscene prerender** — populate the cache up-front so gameplay is + smooth from the first run +- **Programmatic automation API** — drive the game via a Unix-domain JSON + socket (input injection, save state, screenshots, frame stepping) +- **Various menu / input fixes** (keyboard rebinding, OK/Cancel/Test buttons, + default-key fallback so binding one action doesn't kill the others) + +The original is built and tested on Linux/Windows; this fork additionally +builds on macOS (`` shim → ``). + +--- + +## Building + +### Prerequisites + +- SDL2 development libraries +- A C++11 compiler (gcc/clang) + +```bash +# macOS +brew install sdl2 + +# Debian/Ubuntu +sudo apt install libsdl2-dev +``` + +### Compile + +```bash +make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu) +``` + +### Game data files + +Place the following in the working directory (originals from the *Heart of +Darkness* CD/Demo): + +- `HOD.PAF` (~411 MB cutscene video) +- `SETUP.DAT` +- `*_HOD.LVL`, `*_HOD.SSS`, `*_HOD.MST` for each of 9 levels + +See `RELEASES.yaml` for the upstream-tested data versions. + +--- + +## Running + +### Display modes + +| Flag | Resolution | Notes | +|------|-----------:|-------| +| (none) | 256×192 (×3 default) | Original engine path | +| `--hd` | 1536×1152 (6x) | xBRZ chain 3×2 | +| `--fullhd` | 2048×1536 (8x) | xBRZ chain 4×2 | +| `--4k` | 3840×2880 (15x) | xBRZ chain 4×4 then crop | +| `--hd-scale=N` | 256N × 192N | Custom scale (clamped to 2–16) | +| `--hd-wide` | 16:9 | Palette-derived gradient borders | +| `--smooth` | — | 60 Hz interpolated render, 12.5 Hz logic | + +Combined example: + +```bash +./hode --4k --hd-wide --smooth --hd-cache=./cache --prerender +``` + +### Disk cache + +```bash +./hode --hd --hd-cache=./cache +``` + +Stores upscaled sprites and PAF cutscene frames under +`cache/x/`. First run is slow (everything upscales on demand), later +runs are instant cache hits. + +### Prerender + +```bash +./hode --hd --hd-cache=./cache --prerender +``` + +After each level loads, walk every sprite frame (main + per-screen background +animations) **and** the relevant in-gameplay PAF clips (Canyon-with-cannon +falling, Canyon falling, Island falling, plus the level intro cutscene). All +frames go into the disk cache with a single unified console progress bar: + +``` +prerender level 0 (rock) [############################...] 4380/4720 (92%) 3.7s eta 0.3s +``` + +Subsequent runs skip everything that's already cached. + +### Start at a specific level + +```bash +./hode --level=3 # Island (numeric index) +./hode --level=rock # Canyon (by name) +./hode --checkpoint=2 # Combine with --level for resume points +``` + +Level names: `rock`, `fort`, `pwr1`, `isld`, `lava`, `pwr2`, `lar1`, `lar2`, +`dark` (corresponding to indices 0..8). + +### Configuration file (`hode.ini`) + +```ini +[engine] +disable_paf = false +disable_menu = false +difficulty = 1 +frame_duration = 80 + +[display] +scale_factor = 3 +fullscreen = false +widescreen = false +hd_mode = true +hd_scale = 6 +hd_widescreen = true +hd_cache = ./cache +smooth_anim = true +automation_socket = /tmp/hode.sock +``` + +CLI flags override `hode.ini` settings. + +--- + +## Automation API + +```bash +./hode --automation=/tmp/hode.sock +``` + +Programmatic control via a Unix-domain socket with a small JSON protocol. +Useful for headless testing, AI bots, and replay capture. + +### Python example + +```python +import socket, json + +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect("/tmp/hode.sock") + +s.sendall(b'{"cmd":"get_state"}\n') +state = json.loads(s.recv(4096).decode()) + +# Inject input — direction + action bits, optional frame count +s.sendall(b'{"cmd":"input","dir":2,"act":1,"frames":10}\n') # walk right + run + +# Step a single frame +s.sendall(b'{"cmd":"step","count":1}\n') + +# Capture a screenshot +s.sendall(b'{"cmd":"screenshot"}\n') + +# Jump to a level/checkpoint +s.sendall(b'{"cmd":"set_level","level":3,"checkpoint":0}\n') +``` + +### Input bit constants + +``` +Direction: UP=1, RIGHT=2, DOWN=4, LEFT=8 +Action: RUN=1, JUMP=2, SHOOT=4 +Raw SYS_INP bits: UP=0x01, RIGHT=0x02, DOWN=0x04, LEFT=0x08, + RUN=0x10, JUMP=0x20, SHOOT=0x40, ESC=0x80 +``` + +### Headless testing + +```bash +SDL_AUDIODRIVER=dummy xvfb-run -a ./hode --automation=/tmp/hode.sock --hd +``` + +### Bundled test scripts + +| Script | Purpose | +|--------|---------| +| `test_walkthrough.py` | Automated level-1 walkthrough | +| `test_all_levels.py` | Per-level smoke loop | +| `test_combat_bot.py` | AI bot that fights monsters | +| `test_hd_replay.py` | HD vs normal-mode comparison | + +--- + +## HD rendering pipeline + +``` +Game logic (256×192, 8-bit indexed) + │ + ▼ drawScreen() emits sprite list + │ + ▼ HdCompositor intercepts each sprite + │ • Decode SPR -> indexed + │ • Convert to RGBA via palette + │ • Upscale via chained xBRZ (e.g. 3x then 2x = 6x) + │ • Apply MLAA edge smoothing + │ • Cache (RAM LRU + disk, content-hash key) + │ + ▼ Composite into HD framebuffer + │ • Background upscaled once per screen change + │ • Sprites blitted at scaled positions + │ • Optional 16:9 borders (palette-derived gradient) + │ + ▼ SDL2 renders RGBA to display +``` + +### xBRZ scale chains + +``` +6x = 3x · 2x 12x = 4x · 3x +8x = 4x · 2x 15x = 4x · 4x → crop +9x = 3x · 3x 16x = 4x · 4x +``` + +### Disk cache layout + +``` +cache/ + 6x/ sprites at 6x + spr__x.raw header + RGBA pixels + paf/ + 6x/ + v00/ cutscene 0 + f0000.raw + f0001.raw + ... +``` + +The sprite cache key is a content hash (FNV-1a over decoded indexed bytes + +sprite dimensions + flip flags + palette hash) so cached entries persist +correctly across runs and palette changes. + +--- + +## Levels + +| Index | Code | Name | +|------:|:-------|:------------| +| 0 | rock | Canyon | +| 1 | fort | Fort | +| 2 | pwr1 | Power 1 (Swamp) | +| 3 | isld | Island | +| 4 | lava | Lava | +| 5 | pwr2 | Power 2 (Underwater) | +| 6 | lar1 | Lair 1 | +| 7 | lar2 | Lair 2 | +| 8 | dark | Dark (Final)| + +## Default key bindings + +| Key | Action | +|-------------|--------| +| Arrow keys | Move | +| Left Ctrl / F | Run | +| Left Alt / G / Enter | Jump | +| Left Shift / H | Shoot | +| D / Space | Special (Run + Shoot) | +| Escape | Menu / quit | +| S | Screenshot | + +The defaults stay active even after you bind custom keys in the settings menu +— custom keys are *additive* so the menu's action shortcuts still work while +you're rebinding the rest. + +--- + +## Files added by this fork + +| File | Purpose | +|------|---------| +| `automation_api.h/cpp` | Unix-socket server, JSON commands, monster data exposure | +| `hd_compositor.h/cpp` | HD framebuffer, 16:9 borders, resolution presets, prerender driver | +| `sprite_upscaler.h/cpp`| xBRZ upscaling + content-hash RAM/disk cache | +| `edge_smooth.h/cpp` | MLAA edge smoothing for upscaled sprites | +| `test_*.py` | Automation API regression scripts | + +## Files modified vs upstream + +`Makefile`, `game.cpp/h`, `intern.h`, `main.cpp`, `menu.cpp/h`, `paf.cpp/h`, +`system.h`, `system_sdl2.cpp`, `video.cpp`. + +--- + +## Credits + +- Original engine reverse-engineered by **Gregory Montoir** (cyx@users.sourceforge.net), + see [`usineur/hode`](https://github.com/usineur/hode). +- Original game *Heart of Darkness* by **Amazing Studio** (1998). + +## Links + +- [MobyGames page](https://www.mobygames.com/game/heart-of-darkness) +- [heartofdarkness.ca](http://heartofdarkness.ca/) diff --git a/automation_api.cpp b/automation_api.cpp new file mode 100644 index 0000000..f9ee2d5 --- /dev/null +++ b/automation_api.cpp @@ -0,0 +1,359 @@ +/* + * Heart of Darkness engine rewrite + * Automation API - Unix domain socket server with JSON protocol + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "automation_api.h" +#include "game.h" +#include "video.h" +#include "screenshot.h" +#include "system.h" + +AutomationApi::AutomationApi() { + _game = 0; + _serverFd = -1; + _clientFd = -1; + _enabled = false; + _stepMode = false; + _stepCount = 0; + _injectedFrames = 0; + _injectedDirection = 0; + _injectedAction = 0; + _injectedRawMask = 0; + memset(_socketPath, 0, sizeof(_socketPath)); + _cmdBufLen = 0; +} + +AutomationApi::~AutomationApi() { + shutdown(); +} + +void AutomationApi::init(const char *socketPath, Game *game) { + _game = game; + strncpy(_socketPath, socketPath, sizeof(_socketPath) - 1); + + unlink(_socketPath); + + _serverFd = socket(AF_UNIX, SOCK_STREAM, 0); + if (_serverFd < 0) { + fprintf(stderr, "AutomationApi: socket() failed: %s\n", strerror(errno)); + return; + } + + // non-blocking + int flags = fcntl(_serverFd, F_GETFL, 0); + fcntl(_serverFd, F_SETFL, flags | O_NONBLOCK); + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, _socketPath, sizeof(addr.sun_path) - 1); + + if (bind(_serverFd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + fprintf(stderr, "AutomationApi: bind(%s) failed: %s\n", _socketPath, strerror(errno)); + close(_serverFd); + _serverFd = -1; + return; + } + + if (listen(_serverFd, 1) < 0) { + fprintf(stderr, "AutomationApi: listen() failed: %s\n", strerror(errno)); + close(_serverFd); + _serverFd = -1; + return; + } + + _enabled = true; + fprintf(stdout, "AutomationApi: listening on %s\n", _socketPath); +} + +void AutomationApi::shutdown() { + if (_clientFd >= 0) { + close(_clientFd); + _clientFd = -1; + } + if (_serverFd >= 0) { + close(_serverFd); + _serverFd = -1; + } + if (_socketPath[0]) { + unlink(_socketPath); + } + _enabled = false; +} + +void AutomationApi::acceptClient() { + if (_serverFd < 0 || _clientFd >= 0) return; + + int fd = accept(_serverFd, 0, 0); + if (fd < 0) return; + + int flags = fcntl(fd, F_GETFL, 0); + fcntl(fd, F_SETFL, flags | O_NONBLOCK); + + _clientFd = fd; + _cmdBufLen = 0; + fprintf(stdout, "AutomationApi: client connected\n"); +} + +void AutomationApi::readCommands() { + if (_clientFd < 0) return; + + char buf[1024]; + ssize_t n = read(_clientFd, buf, sizeof(buf)); + if (n < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) return; + close(_clientFd); + _clientFd = -1; + _cmdBufLen = 0; + return; + } + if (n == 0) { + close(_clientFd); + _clientFd = -1; + _cmdBufLen = 0; + fprintf(stdout, "AutomationApi: client disconnected\n"); + return; + } + + for (ssize_t i = 0; i < n; ++i) { + if (buf[i] == '\n') { + _cmdBuf[_cmdBufLen] = 0; + if (_cmdBufLen > 0) { + handleCommand(_cmdBuf); + } + _cmdBufLen = 0; + } else if (_cmdBufLen < kMaxCommandSize - 1) { + _cmdBuf[_cmdBufLen++] = buf[i]; + } + } +} + +void AutomationApi::processCommands() { + if (!_enabled) return; + acceptClient(); + readCommands(); + + // Apply raw input mask for menu/system-level input + if (_injectedFrames > 0) { + g_system->inp.mask |= _injectedRawMask; + --_injectedFrames; + if (_injectedFrames == 0) { + _injectedDirection = 0; + _injectedAction = 0; + _injectedRawMask = 0; + } + } +} + +void AutomationApi::notifyFrameComplete() { + if (_stepMode && _stepCount > 0) { + --_stepCount; + } +} + +void AutomationApi::waitForStepCommand() { + while (_stepMode && _stepCount <= 0 && _enabled) { + acceptClient(); + readCommands(); + g_system->sleep(5); + g_system->processEvents(); + if (g_system->inp.quit) { + _enabled = false; + return; + } + } +} + +// Simple JSON string search helper (no external dep) +static const char *jsonGetString(const char *json, const char *key) { + char pattern[64]; + snprintf(pattern, sizeof(pattern), "\"%s\"", key); + const char *p = strstr(json, pattern); + if (!p) return 0; + p += strlen(pattern); + while (*p == ' ' || *p == ':' || *p == '\t') ++p; + return p; +} + +static int jsonGetInt(const char *json, const char *key, int defaultVal) { + const char *p = jsonGetString(json, key); + if (!p) return defaultVal; + if (*p == '"') ++p; + return atoi(p); +} + +void AutomationApi::handleCommand(const char *json) { + const char *cmd = jsonGetString(json, "cmd"); + if (!cmd) return; + + if (strncmp(cmd, "\"get_state\"", 11) == 0) { + handleGetState(); + } else if (strncmp(cmd, "\"input\"", 7) == 0) { + handleInjectInput(json); + } else if (strncmp(cmd, "\"screenshot\"", 12) == 0) { + handleScreenshot(); + } else if (strncmp(cmd, "\"step\"", 6) == 0) { + handleStep(json); + } else if (strncmp(cmd, "\"set_level\"", 11) == 0) { + handleSetLevel(json); + } else { + sendResponse("{\"error\":\"unknown command\"}\n"); + } +} + +void AutomationApi::handleGetState() { + if (!_game) return; + + char buf[8192]; + int andyX = 0, andyY = 0, andyScreen = 0, andyAnim = 0, andyFrame = 0; + int andySprite = 0; + bool dying = _game->_levelRestartCounter != 0; + if (_game->_andyObject) { + andyX = _game->_andyObject->xPos; + andyY = _game->_andyObject->yPos; + andyScreen = _game->_andyObject->screenNum; + andyAnim = _game->_andyObject->anim; + andyFrame = _game->_andyObject->frame; + andySprite = _game->_andyObject->spriteNum; + } + + // Build monster list (only monsters on current screen) + char monstersBuf[4096]; + int mpos = 0; + mpos += snprintf(monstersBuf + mpos, sizeof(monstersBuf) - mpos, "["); + int mcount = 0; + for (int i = 0; i < Game::kMaxMonsterObjects1; ++i) { + const MonsterObject1 *m = &_game->_monsterObjects1Table[i]; + if (m->o16 && m->o16->screenNum == _game->_currentScreen) { + if (mcount > 0) mpos += snprintf(monstersBuf + mpos, sizeof(monstersBuf) - mpos, ","); + mpos += snprintf(monstersBuf + mpos, sizeof(monstersBuf) - mpos, + "{\"x\":%d,\"y\":%d,\"type\":1,\"i\":%d}", + m->xPos, m->yPos, i); + mcount++; + if (mpos > 3800) break; + } + } + for (int i = 0; i < Game::kMaxMonsterObjects2; ++i) { + const MonsterObject2 *m = &_game->_monsterObjects2Table[i]; + if (m->o && m->o->screenNum == _game->_currentScreen) { + if (mcount > 0) mpos += snprintf(monstersBuf + mpos, sizeof(monstersBuf) - mpos, ","); + mpos += snprintf(monstersBuf + mpos, sizeof(monstersBuf) - mpos, + "{\"x\":%d,\"y\":%d,\"type\":2,\"i\":%d}", + m->xPos, m->yPos, i); + mcount++; + if (mpos > 3800) break; + } + } + mpos += snprintf(monstersBuf + mpos, sizeof(monstersBuf) - mpos, "]"); + + snprintf(buf, sizeof(buf), + "{\"andy\":{\"x\":%d,\"y\":%d,\"screen\":%d,\"anim\":%d,\"frame\":%d," + "\"sprite\":%d,\"hasCannon\":%s,\"dying\":%s}," + "\"level\":%d,\"checkpoint\":%d,\"screen\":%d," + "\"endLevel\":%s,\"monsters\":%s,\"monsterCount\":%d}\n", + andyX, andyY, andyScreen, andyAnim, andyFrame, + andySprite, + andySprite == 0 ? "true" : "false", + dying ? "true" : "false", + _game->_currentLevel, + _game->_currentLevelCheckpoint, + _game->_currentScreen, + _game->_endLevel ? "true" : "false", + monstersBuf, mcount); + sendResponse(buf); +} + +void AutomationApi::handleInjectInput(const char *json) { + _injectedDirection = (uint8_t)jsonGetInt(json, "dir", 0); + _injectedAction = (uint8_t)jsonGetInt(json, "act", 0); + _injectedFrames = jsonGetInt(json, "frames", 1); + // Also build raw SYS_INP mask for menu/system input + _injectedRawMask = 0; + if (_injectedDirection & 1) _injectedRawMask |= SYS_INP_UP; + if (_injectedDirection & 2) _injectedRawMask |= SYS_INP_RIGHT; + if (_injectedDirection & 4) _injectedRawMask |= SYS_INP_DOWN; + if (_injectedDirection & 8) _injectedRawMask |= SYS_INP_LEFT; + if (_injectedAction & 1) _injectedRawMask |= SYS_INP_RUN; + if (_injectedAction & 2) _injectedRawMask |= SYS_INP_JUMP; + if (_injectedAction & 4) _injectedRawMask |= SYS_INP_SHOOT; + // Also support direct raw mask + int raw = jsonGetInt(json, "raw", 0); + if (raw) _injectedRawMask = (uint8_t)raw; + // No response for input injection - reduces protocol complexity +} + +void AutomationApi::handleScreenshot() { + if (!_game || !_game->_video) { + sendResponse("{\"error\":\"no video\"}\n"); + return; + } + // Send raw framebuffer dimensions and palette-converted RGB data + const int w = Video::W; + const int h = Video::H; + char header[128]; + snprintf(header, sizeof(header), "{\"width\":%d,\"height\":%d,\"format\":\"rgb\",\"size\":%d}\n", + w, h, w * h * 3); + sendResponse(header); + + // Convert indexed to RGB and send + uint8_t *rgb = (uint8_t *)malloc(w * h * 3); + if (rgb) { + const uint8_t *src = _game->_video->_frontLayer; + const uint8_t *pal = _game->_video->_palette; + for (int i = 0; i < w * h; ++i) { + const uint8_t c = src[i]; + rgb[i * 3 + 0] = pal[c * 3 + 0]; + rgb[i * 3 + 1] = pal[c * 3 + 1]; + rgb[i * 3 + 2] = pal[c * 3 + 2]; + } + sendBinaryResponse(rgb, w * h * 3); + free(rgb); + } +} + +void AutomationApi::handleStep(const char *json) { + _stepMode = true; + _stepCount = jsonGetInt(json, "count", 1); +} + +void AutomationApi::handleSetLevel(const char *json) { + if (!_game) return; + int level = jsonGetInt(json, "level", -1); + int checkpoint = jsonGetInt(json, "checkpoint", 0); + if (level >= 0 && level <= 8) { + _game->_currentLevel = level; + _game->_currentLevelCheckpoint = checkpoint; + _game->_endLevel = true; + } +} + +void AutomationApi::sendResponse(const char *json) { + if (_clientFd < 0) return; + int len = strlen(json); + int sent = 0; + while (sent < len) { + ssize_t n = write(_clientFd, json + sent, len - sent); + if (n <= 0) break; + sent += n; + } +} + +void AutomationApi::sendBinaryResponse(const uint8_t *data, int size) { + if (_clientFd < 0) return; + int sent = 0; + while (sent < size) { + ssize_t n = write(_clientFd, data + sent, size - sent); + if (n <= 0) break; + sent += n; + } +} diff --git a/automation_api.h b/automation_api.h new file mode 100644 index 0000000..48e9603 --- /dev/null +++ b/automation_api.h @@ -0,0 +1,62 @@ +/* + * Heart of Darkness engine rewrite + * Automation API for programmatic game control + */ + +#ifndef AUTOMATION_API_H__ +#define AUTOMATION_API_H__ + +#include "intern.h" + +struct Game; + +struct AutomationApi { + enum { + kMaxResponseSize = 65536, + kMaxCommandSize = 4096 + }; + + Game *_game; + int _serverFd; + int _clientFd; + bool _enabled; + bool _stepMode; + int _stepCount; + int _injectedFrames; + uint8_t _injectedDirection; + uint8_t _injectedAction; + char _socketPath[256]; + char _cmdBuf[kMaxCommandSize]; + int _cmdBufLen; + + AutomationApi(); + ~AutomationApi(); + + void init(const char *socketPath, Game *game); + void shutdown(); + void processCommands(); + void notifyFrameComplete(); + void waitForStepCommand(); + + bool hasInjectedInput() const { return _injectedFrames > 0; } + uint8_t getDirectionMask() const { return _injectedDirection; } + uint8_t getActionMask() const { return _injectedAction; } + uint8_t getRawInputMask() const { return _injectedRawMask; } + + // For menu: inject raw SYS_INP_* mask directly + uint8_t _injectedRawMask; + +private: + void acceptClient(); + void readCommands(); + void handleCommand(const char *json); + void handleGetState(); + void handleInjectInput(const char *json); + void handleScreenshot(); + void handleStep(const char *json); + void handleSetLevel(const char *json); + void sendResponse(const char *json); + void sendBinaryResponse(const uint8_t *data, int size); +}; + +#endif // AUTOMATION_API_H__ diff --git a/edge_smooth.cpp b/edge_smooth.cpp new file mode 100644 index 0000000..d4f42ef --- /dev/null +++ b/edge_smooth.cpp @@ -0,0 +1,105 @@ +/* + * Heart of Darkness engine rewrite + * MLAA (Morphological Anti-Aliasing) edge smoothing + */ + +#include +#include +#include +#include "edge_smooth.h" + +static inline int pixelDiff(uint32_t a, uint32_t b) { + const int dr = (int)((a >> 16) & 0xFF) - (int)((b >> 16) & 0xFF); + const int dg = (int)((a >> 8) & 0xFF) - (int)((b >> 8) & 0xFF); + const int db = (int)(a & 0xFF) - (int)(b & 0xFF); + return abs(dr) + abs(dg) + abs(db); +} + +static inline uint32_t lerpPixel(uint32_t a, uint32_t b, int t256) { + const int inv = 256 - t256; + const int r = (((a >> 16) & 0xFF) * inv + ((b >> 16) & 0xFF) * t256) >> 8; + const int g = (((a >> 8) & 0xFF) * inv + ((b >> 8) & 0xFF) * t256) >> 8; + const int bl = ((a & 0xFF) * inv + (b & 0xFF) * t256) >> 8; + const int al = (((a >> 24) & 0xFF) * inv + ((b >> 24) & 0xFF) * t256) >> 8; + return (al << 24) | (r << 16) | (g << 8) | bl; +} + +void mlaa_smooth(uint32_t *pixels, int w, int h) { + if (w < 3 || h < 3) return; + + // Edge threshold: pixels with difference above this are "edges" + static const int kEdgeThreshold = 48; + + // Temporary buffer for the smoothed result + uint32_t *temp = (uint32_t *)malloc(w * h * sizeof(uint32_t)); + if (!temp) return; + memcpy(temp, pixels, w * h * sizeof(uint32_t)); + + // Horizontal edge-aware smoothing + for (int y = 1; y < h - 1; ++y) { + for (int x = 1; x < w - 1; ++x) { + const uint32_t C = pixels[y * w + x]; + const uint32_t L = pixels[y * w + x - 1]; + const uint32_t R = pixels[y * w + x + 1]; + const uint32_t U = pixels[(y - 1) * w + x]; + const uint32_t D = pixels[(y + 1) * w + x]; + + // Skip transparent pixels + if ((C >> 24) == 0) continue; + + // Detect horizontal edge (strong vertical color change) + const int diffUD = pixelDiff(U, D); + if (diffUD > kEdgeThreshold) { + // We're at a horizontal edge. Blend with neighbors + // based on how similar they are to reduce staircase + const int diffLR = pixelDiff(L, R); + if (diffLR < kEdgeThreshold) { + // L and R are similar, we can smooth + temp[y * w + x] = lerpPixel(C, + lerpPixel(L, R, 128), 32); + } + } + + // Detect vertical edge (strong horizontal color change) + const int diffLR2 = pixelDiff(L, R); + if (diffLR2 > kEdgeThreshold) { + const int diffUD2 = pixelDiff(U, D); + if (diffUD2 < kEdgeThreshold) { + temp[y * w + x] = lerpPixel(C, + lerpPixel(U, D, 128), 32); + } + } + } + } + + // Diagonal smoothing pass + for (int y = 1; y < h - 1; ++y) { + for (int x = 1; x < w - 1; ++x) { + const uint32_t C = temp[y * w + x]; + if ((C >> 24) == 0) continue; + + const uint32_t UL = temp[(y-1) * w + x-1]; + const uint32_t UR = temp[(y-1) * w + x+1]; + const uint32_t DL = temp[(y+1) * w + x-1]; + const uint32_t DR = temp[(y+1) * w + x+1]; + + // Check for diagonal staircase pattern + const int d1 = pixelDiff(UL, DR); // main diagonal + const int d2 = pixelDiff(UR, DL); // anti diagonal + + if (d1 > kEdgeThreshold && d2 < kEdgeThreshold / 2) { + // Anti-diagonal edge: smooth along it + pixels[y * w + x] = lerpPixel(C, + lerpPixel(UR, DL, 128), 24); + } else if (d2 > kEdgeThreshold && d1 < kEdgeThreshold / 2) { + // Main diagonal edge: smooth along it + pixels[y * w + x] = lerpPixel(C, + lerpPixel(UL, DR, 128), 24); + } else { + pixels[y * w + x] = C; + } + } + } + + free(temp); +} diff --git a/edge_smooth.h b/edge_smooth.h new file mode 100644 index 0000000..b8db8a9 --- /dev/null +++ b/edge_smooth.h @@ -0,0 +1,15 @@ +/* + * Heart of Darkness engine rewrite + * MLAA edge smoothing for upscaled sprites + */ + +#ifndef EDGE_SMOOTH_H__ +#define EDGE_SMOOTH_H__ + +#include "intern.h" + +// Morphological Anti-Aliasing (MLAA) +// Applied as post-processing after xBRZ upscale +void mlaa_smooth(uint32_t *pixels, int w, int h); + +#endif // EDGE_SMOOTH_H__ diff --git a/game.cpp b/game.cpp index 0dd2698..0eb86f2 100644 --- a/game.cpp +++ b/game.cpp @@ -3,7 +3,11 @@ * Copyright (C) 2009-2011 Gregory Montoir (cyx@users.sourceforge.net) */ +#include +#include "automation_api.h" #include "game.h" +#include "hd_compositor.h" +#include "sprite_upscaler.h" #include "fileio.h" #include "level.h" #include "lzw.h" @@ -24,6 +28,13 @@ Game::Game(const char *dataPath, const char *savePath, uint32_t cheats) _paf = new PafPlayer(&_fs); _rnd.setSeed(); _video = new Video(); + _hdCompositor = 0; + _automationApi = 0; + memset(&_interpState, 0, sizeof(_interpState)); + _interpolationEnabled = false; + _hdPrerenderEnabled = false; + _hdPrerenderedMask = 0; + _pafPrerenderedMask = 0; _cheats = cheats; _playDemo = false; @@ -1829,6 +1840,15 @@ void Game::drawScreen() { memcpy(_video->_frontLayer, _video->_backgroundLayer, Video::W * Video::H); _video->copyYuvBackBuffer(); + // Begin HD frame if enabled + if (_hdCompositor && _hdCompositor->isEnabled()) { + LvlBackgroundData *bgDat = &_res->_resLvlScreenBackgroundDataTable[_res->_currentScreenResourceNum]; + _hdCompositor->beginFrame(_video->_backgroundLayer, _video->_palette, + _res->_currentScreenResourceNum, bgDat->currentBackgroundId); + } + + const bool hdEnabled = _hdCompositor && _hdCompositor->isEnabled(); + // redraw background animation sprites LvlBackgroundData *dat = &_res->_resLvlScreenBackgroundDataTable[_res->_currentScreenResourceNum]; if (_res->_isPsx) { @@ -1841,6 +1861,9 @@ void Game::drawScreen() { for (Sprite *spr = _typeSpritesList[0]; spr; spr = spr->nextPtr) { if ((spr->num & 0x1F) == 0) { _video->decodeSPR(spr->bitmapBits, _video->_backgroundLayer, spr->xPos, spr->yPos, 0, spr->w, spr->h); + if (hdEnabled) { + _hdCompositor->drawSprite(spr->bitmapBits, spr->xPos, spr->yPos, spr->w, spr->h, 0); + } } } } @@ -1855,7 +1878,11 @@ void Game::drawScreen() { for (int i = 1; i < 4; ++i) { for (Sprite *spr = _typeSpritesList[i]; spr; spr = spr->nextPtr) { if ((spr->num & 0x1000) != 0) { - _video->decodeSPR(spr->bitmapBits, _video->_frontLayer, spr->xPos, spr->yPos, (spr->num >> 0xE) & 3, spr->w, spr->h); + const uint8_t flipFlags = (spr->num >> 0xE) & 3; + _video->decodeSPR(spr->bitmapBits, _video->_frontLayer, spr->xPos, spr->yPos, flipFlags, spr->w, spr->h); + if (hdEnabled) { + _hdCompositor->drawSprite(spr->bitmapBits, spr->xPos, spr->yPos, spr->w, spr->h, flipFlags); + } } } } @@ -1867,7 +1894,11 @@ void Game::drawScreen() { for (int i = 4; i < 8; ++i) { for (Sprite *spr = _typeSpritesList[i]; spr; spr = spr->nextPtr) { if ((spr->num & 0x1000) != 0) { - _video->decodeSPR(spr->bitmapBits, _video->_frontLayer, spr->xPos, spr->yPos, (spr->num >> 0xE) & 3, spr->w, spr->h); + const uint8_t flipFlags = (spr->num >> 0xE) & 3; + _video->decodeSPR(spr->bitmapBits, _video->_frontLayer, spr->xPos, spr->yPos, flipFlags, spr->w, spr->h); + if (hdEnabled) { + _hdCompositor->drawSprite(spr->bitmapBits, spr->xPos, spr->yPos, spr->w, spr->h, flipFlags); + } } } } @@ -1893,7 +1924,11 @@ void Game::drawScreen() { for (int i = 1; i < 12; ++i) { for (Sprite *spr = _typeSpritesList[i]; spr; spr = spr->nextPtr) { if ((spr->num & 0x1000) != 0) { - _video->decodeSPR(spr->bitmapBits, _video->_frontLayer, spr->xPos, spr->yPos, (spr->num >> 0xE) & 3, spr->w, spr->h); + const uint8_t flipFlags = (spr->num >> 0xE) & 3; + _video->decodeSPR(spr->bitmapBits, _video->_frontLayer, spr->xPos, spr->yPos, flipFlags, spr->w, spr->h); + if (hdEnabled) { + _hdCompositor->drawSprite(spr->bitmapBits, spr->xPos, spr->yPos, spr->w, spr->h, flipFlags); + } } } } @@ -1905,16 +1940,172 @@ void Game::drawScreen() { for (int i = 12; i <= 24; ++i) { for (Sprite *spr = _typeSpritesList[i]; spr; spr = spr->nextPtr) { if ((spr->num & 0x1000) != 0) { - _video->decodeSPR(spr->bitmapBits, _video->_frontLayer, spr->xPos, spr->yPos, (spr->num >> 0xE) & 3, spr->w, spr->h); + const uint8_t flipFlags = (spr->num >> 0xE) & 3; + _video->decodeSPR(spr->bitmapBits, _video->_frontLayer, spr->xPos, spr->yPos, flipFlags, spr->w, spr->h); + if (hdEnabled) { + _hdCompositor->drawSprite(spr->bitmapBits, spr->xPos, spr->yPos, spr->w, spr->h, flipFlags); + } } } } } +void Game::saveInterpolationState() { + for (int i = 0; i < kMaxSprites; ++i) { + _interpState.sprites[i].prevX = _interpState.sprites[i].currX; + _interpState.sprites[i].prevY = _interpState.sprites[i].currY; + _interpState.sprites[i].currX = _spritesTable[i].xPos; + _interpState.sprites[i].currY = _spritesTable[i].yPos; + } + _interpState.prevAndyX = _interpState.currAndyX; + _interpState.prevAndyY = _interpState.currAndyY; + if (_andyObject) { + _interpState.currAndyX = _andyObject->xPos; + _interpState.currAndyY = _andyObject->yPos; + } + _interpState.valid = true; +} + +void Game::renderInterpolatedFrame(float t) { + if (!_interpState.valid || t >= 1.0f) { + // No interpolation data or past current frame: use actual positions + drawScreen(); + return; + } + + // Temporarily modify sprite positions for interpolated render + int16_t savedX[kMaxSprites], savedY[kMaxSprites]; + for (int i = 0; i < kMaxSprites; ++i) { + savedX[i] = _spritesTable[i].xPos; + savedY[i] = _spritesTable[i].yPos; + const int16_t dx = _interpState.sprites[i].currX - _interpState.sprites[i].prevX; + const int16_t dy = _interpState.sprites[i].currY - _interpState.sprites[i].prevY; + // Only interpolate if movement is small (avoid teleport artifacts) + if (abs(dx) < 32 && abs(dy) < 32) { + _spritesTable[i].xPos = _interpState.sprites[i].prevX + (int16_t)(dx * t); + _spritesTable[i].yPos = _interpState.sprites[i].prevY + (int16_t)(dy * t); + } + } + + drawScreen(); + + // Restore original positions + for (int i = 0; i < kMaxSprites; ++i) { + _spritesTable[i].xPos = savedX[i]; + _spritesTable[i].yPos = savedY[i]; + } +} + static void gamePafCallback(void *userdata) { ((Game *)userdata)->resetSound(); } +static bool pafLoadCachedFrame(const char *cachePath, int scale, int videoNum, int frameNum, + uint32_t *dst, int dstW, int dstH) +{ + if (!cachePath) return false; + char path[512]; + snprintf(path, sizeof(path), "%s/paf/%dx/v%02d/f%04d.raw", cachePath, scale, videoNum, frameNum); + FILE *fp = fopen(path, "rb"); + if (!fp) return false; + int32_t w, h; + if (fread(&w, 4, 1, fp) != 1 || fread(&h, 4, 1, fp) != 1 || w != dstW || h != dstH) { + fclose(fp); + return false; + } + const bool ok = (int)fread(dst, sizeof(uint32_t), dstW * dstH, fp) == dstW * dstH; + fclose(fp); + return ok; +} + +static void pafSaveCachedFrame(const char *cachePath, int scale, int videoNum, int frameNum, + const uint32_t *src, int w, int h) +{ + if (!cachePath) return; + char dir1[512], dir2[512], path[512]; + snprintf(dir1, sizeof(dir1), "%s/paf", cachePath); + snprintf(dir2, sizeof(dir2), "%s/paf/%dx", cachePath, scale); + char dir3[512]; + snprintf(dir3, sizeof(dir3), "%s/paf/%dx/v%02d", cachePath, scale, videoNum); + mkdir(dir1, 0755); + mkdir(dir2, 0755); + mkdir(dir3, 0755); + snprintf(path, sizeof(path), "%s/paf/%dx/v%02d/f%04d.raw", cachePath, scale, videoNum, frameNum); + FILE *fp = fopen(path, "wb"); + if (!fp) return; + int32_t dw = w, dh = h; + fwrite(&dw, 4, 1, fp); + fwrite(&dh, 4, 1, fp); + fwrite(src, sizeof(uint32_t), w * h, fp); + fclose(fp); +} + +static void gamePafFrameCallback(void *userdata, int num, const uint8_t *frame) { + Game *g = (Game *)userdata; + const bool prerender = g->_paf->_prerenderMode; + // Send the original indexed frame for normal rendering — skip during + // prerender so the cutscene doesn't visibly play on screen. + if (!prerender) { + g_system->copyRect(0, 0, PafPlayer::kVideoWidth, PafPlayer::kVideoHeight, + frame, PafPlayer::kVideoWidth); + } + // If HD compositor is active, upscale the PAF frame + if (g->_hdCompositor && g->_hdCompositor->isEnabled()) { + uint32_t *hdBuf; int hdW, hdH; + g->_hdCompositor->getFramebuffer(&hdBuf, &hdW, &hdH); + const int videoNum = g->_paf->_videoNum; + const char *cachePath = g->_hdCompositor->_upscaler->_diskCacheEnabled ? + g->_hdCompositor->_upscaler->_diskCachePath : 0; + // Strip the "/Nx" suffix to get the base cache path + char baseCachePath[256] = {0}; + if (cachePath) { + strncpy(baseCachePath, cachePath, sizeof(baseCachePath) - 1); + char *slash = strrchr(baseCachePath, '/'); + if (slash) *slash = 0; // remove "/6x" etc + } + + // Try loading cached frame — during prerender there is no point in + // going through the upscaler if a cache hit already exists; we just + // move on to the next frame. + if (baseCachePath[0] && pafLoadCachedFrame(baseCachePath, g->_hdCompositor->_scale, + videoNum, num, hdBuf, hdW, hdH)) { + if (!prerender) { + g_system->copyRectRGBA(0, 0, hdW, hdH, hdBuf, hdW); + } + return; + } + + // Upscale live + const uint8_t *pal = g->_paf->_paletteBuffer; + g->_hdCompositor->updatePalette(pal, 256, 6); + g->_hdCompositor->beginFrame(frame, pal, -2, -2); + g->_hdCompositor->endFrame(); + g->_hdCompositor->getFramebuffer(&hdBuf, &hdW, &hdH); + if (!prerender) { + g_system->copyRectRGBA(0, 0, hdW, hdH, hdBuf, hdW); + } + + // Save to disk cache + if (baseCachePath[0]) { + pafSaveCachedFrame(baseCachePath, g->_hdCompositor->_scale, + videoNum, num, hdBuf, hdW, hdH); + } + } + // Advance the unified prerender progress bar. + if (prerender && g->_hdCompositor && g->_hdCompositor->_progress) { + PrerenderProgress *p = g->_hdCompositor->_progress; + ++p->done; + const int bar = p->done * 64 / p->total; + if (bar != p->lastBar) { + struct timespec t1; clock_gettime(CLOCK_MONOTONIC, &t1); + const double elapsed = (t1.tv_sec - p->t0.tv_sec) + + (t1.tv_nsec - p->t0.tv_nsec) / 1e9; + HdCompositor_drawProgressBar(p->label, p->done, p->total, elapsed); + p->lastBar = bar; + } + } +} + void Game::mainLoop(int level, int checkpoint, bool levelChanged) { if (_playDemo && _res->loadHodDem()) { _rnd._rndSeed = _res->_dem.randSeed; @@ -1942,7 +2133,7 @@ void Game::mainLoop(int level, int checkpoint, bool levelChanged) { } PafCallback pafCb; - pafCb.frameProc = 0; + pafCb.frameProc = (_hdCompositor && _hdCompositor->isEnabled()) ? gamePafFrameCallback : 0; pafCb.endProc = gamePafCallback; pafCb.userdata = this; _paf->setCallback(&pafCb); @@ -1996,14 +2187,126 @@ void Game::mainLoop(int level, int checkpoint, bool levelChanged) { resetShootLvlObjectDataTable(); callLevel_initialize(); restartLevel(); - while (true) { - const int frameTimeStamp = g_system->getTimeStamp() + _frameMs; - levelMainLoop(); - if (g_system->inp.quit || _endLevel) { - break; + if (_hdPrerenderEnabled && _hdCompositor && _hdCompositor->isEnabled() + && !(_hdPrerenderedMask & (1u << _currentLevel))) { + // Seed compositor palette from the level's first screen background + // (setupBackgroundBitmap has already populated _displayPaletteBuffer). + _video->updateGamePalette(_video->_displayPaletteBuffer); + _hdCompositor->updatePalette(_video->_palette, 256, 6); + static const char *const kLevelNames[] = { + "rock","fort","pwr1","isld","lava","pwr2","lar1","lar2","dark" + }; + const char *lvlName = (_currentLevel >= 0 && _currentLevel < 9) + ? kLevelNames[_currentLevel] : "?"; + + // Decide which PAFs to prerender alongside the sprites. + uint8_t pafs[8]; + int pafCount = 0; + if (!_paf->_skipCutscenes) { + static const uint8_t kInGameClips[] = { + 22, // CanyonAndyFallingCannon + 23, // CanyonAndyFalling + 24, // IslandAndyFalling + }; + for (size_t i = 0; i < sizeof(kInGameClips); ++i) { + const uint8_t v = kInGameClips[i]; + if (!(_pafPrerenderedMask & (1u << v))) { + pafs[pafCount++] = v; + } + } + if (_currentLevel >= 0 && _currentLevel < 9) { + const uint8_t v = _cutscenes[_currentLevel]; + if (!(_pafPrerenderedMask & (1u << v))) { + pafs[pafCount++] = v; + } + } + } + + // Count up the total work units for one unified progress bar. + PrerenderProgress prog; + prog.done = 0; + prog.lastBar = -1; + clock_gettime(CLOCK_MONOTONIC, &prog.t0); + snprintf(prog.label, sizeof(prog.label), + "prerender level %d (%s)", _currentLevel, lvlName); + int spriteUnits = _hdCompositor->countLevelSpriteFrames(_res); + int pafUnits[8] = {0}; + for (int i = 0; i < pafCount; ++i) { + pafUnits[i] = _paf->peekFramesCount(pafs[i]); + } + int total = spriteUnits; + for (int i = 0; i < pafCount; ++i) total += pafUnits[i]; + prog.total = total > 0 ? total : 1; + + _hdCompositor->_progress = &prog; + _hdCompositor->prerenderLevelSprites(_res, prog.label); + for (int i = 0; i < pafCount; ++i) { + _paf->prerender(pafs[i]); + _pafPrerenderedMask |= (1u << pafs[i]); + if (g_system->inp.quit) break; + } + _hdCompositor->_progress = 0; + // Force a final 100% redraw and terminate the line. + struct timespec t1; clock_gettime(CLOCK_MONOTONIC, &t1); + const double elapsed = (t1.tv_sec - prog.t0.tv_sec) + + (t1.tv_nsec - prog.t0.tv_nsec) / 1e9; + HdCompositor_drawProgressBar(prog.label, prog.total, prog.total, elapsed); + fprintf(stderr, "\n"); + + // Pump SDL so the window doesn't sit on a stale loading-screen frame + // until the first gameplay drawScreen happens. Also re-baseline input + // edge state so any SELECT key the user pressed before prerender + // doesn't fire a phantom keyPressed/keyReleased on the first tick. + g_system->processEvents(); + g_system->inp.prevMask = g_system->inp.mask; + g_system->updateScreen(false); + + _hdPrerenderedMask |= (1u << _currentLevel); + } + if (_interpolationEnabled) { + // Decoupled render/logic loop: game tick at 12.5Hz, render at ~60Hz + uint32_t nextGameTick = g_system->getTimeStamp(); + uint32_t lastGameTick = nextGameTick; + while (true) { + const uint32_t now = g_system->getTimeStamp(); + + // Game tick + if (now >= nextGameTick) { + saveInterpolationState(); + levelMainLoop(); + if (g_system->inp.quit || _endLevel) { + break; + } + lastGameTick = now; + nextGameTick = now + _frameMs; + } + + // Interpolated render at ~60Hz + const float t = (float)(now - lastGameTick) / (float)_frameMs; + renderInterpolatedFrame(CLIP(t, 0.f, 1.f)); + + const uint32_t nextRender = now + kRenderMs; + const uint32_t sleepUntil = MIN(nextRender, nextGameTick); + const int delay = MAX(1, (int)(sleepUntil - g_system->getTimeStamp())); + g_system->sleep(delay); + } + } else { + // Original loop: logic + render at 12.5Hz + while (true) { + const int frameTimeStamp = g_system->getTimeStamp() + _frameMs; + if (_automationApi && _automationApi->_stepMode) { + _automationApi->waitForStepCommand(); + } + levelMainLoop(); + if (g_system->inp.quit || _endLevel) { + break; + } + if (_automationApi) { + _automationApi->notifyFrameComplete(); + } + const int delay = MAX(10, frameTimeStamp - g_system->getTimeStamp()); + g_system->sleep(delay); } - const int delay = MAX(10, frameTimeStamp - g_system->getTimeStamp()); - g_system->sleep(delay); } _animBackgroundDataCount = 0; callLevel_terminate(); @@ -2524,6 +2827,13 @@ void Game::levelMainLoop() { _directionKeyMask = 0; _actionKeyMask = 0; updateInput(); + if (_automationApi) { + _automationApi->processCommands(); + if (_automationApi->hasInjectedInput()) { + _directionKeyMask = _automationApi->getDirectionMask(); + _actionKeyMask = _automationApi->getActionMask(); + } + } if (_playDemo && _res->_demOffset < _res->_dem.keyMaskLen) { _andyObject->actionKeyMask = _res->_dem.actionKeyMask[_res->_demOffset]; _andyObject->directionKeyMask = _res->_dem.directionKeyMask[_res->_demOffset]; @@ -2585,16 +2895,26 @@ void Game::levelMainLoop() { snprintf(buffer, sizeof(buffer), "P%d S%02d %d R%d", _currentLevel, _andyObject->screenNum, _res->_screensState[_andyObject->screenNum].s0, _level->_checkpoint); _video->drawString(buffer, (Video::W - strlen(buffer) * 8) / 2, 8, _video->findWhiteColor(), _video->_frontLayer); } + // Finalize HD frame + if (_hdCompositor && _hdCompositor->isEnabled()) { + _hdCompositor->endFrame(); + } if (_shakeScreenDuration != 0 || _levelRestartCounter != 0 || _video->_displayShadowLayer) { shakeScreen(); _video->updateGameDisplay(_video->_displayShadowLayer ? _video->_shadowLayer : _video->_frontLayer); } else { _video->updateGameDisplay(_video->_frontLayer); } + // Send HD framebuffer to display if enabled + if (_hdCompositor && _hdCompositor->isEnabled()) { + uint32_t *hdBuf; int hdW, hdH; + _hdCompositor->getFramebuffer(&hdBuf, &hdW, &hdH); + g_system->copyRectRGBA(0, 0, hdW, hdH, hdBuf, hdW); + } _rnd.update(); g_system->processEvents(); - if (g_system->inp.keyPressed(SYS_INP_ESC) || g_system->inp.exit) { // display exit confirmation screen - if (displayHintScreen(-1, 0)) { + if (g_system->inp.keyPressed(SYS_INP_ESC)) { + if (displayHintScreen(-1, 0)) { // pause/exit screen g_system->inp.quit = true; } } else { diff --git a/game.h b/game.h index 09f08d8..d1c6e20 100644 --- a/game.h +++ b/game.h @@ -14,7 +14,9 @@ #include "random.h" #include "resource.h" +struct AutomationApi; struct Game; +struct HdCompositor; struct Level; struct PafPlayer; struct Video; @@ -91,6 +93,8 @@ struct Game { Random _rnd; Resource *_res; Video *_video; + HdCompositor *_hdCompositor; + AutomationApi *_automationApi; uint32_t _cheats; int _frameMs; int _difficulty; @@ -108,6 +112,28 @@ struct Game { int _currentLevel; int _currentLevelCheckpoint; bool _endLevel; + + // Animation interpolation (12.5Hz game tick, 60Hz render) + struct InterpolationState { + struct SpritePos { + int16_t prevX, prevY; + int16_t currX, currY; + }; + SpritePos sprites[kMaxSprites]; + int32_t prevAndyX, prevAndyY; + int32_t currAndyX, currAndyY; + bool valid; + }; + InterpolationState _interpState; + bool _interpolationEnabled; + bool _hdPrerenderEnabled; + uint32_t _hdPrerenderedMask; // bitmask of levels already prerendered + uint32_t _pafPrerenderedMask; // bitmask of PAF videos already prerendered + static const int kRenderMs = 16; // ~60Hz render rate + + void saveInterpolationState(); + void renderInterpolatedFrame(float t); + Sprite _spritesTable[kMaxSprites]; Sprite *_spritesNextPtr; // pointer to the next free entry Sprite *_typeSpritesList[kMaxSpriteTypes]; diff --git a/hd_compositor.cpp b/hd_compositor.cpp new file mode 100644 index 0000000..bab1020 --- /dev/null +++ b/hd_compositor.cpp @@ -0,0 +1,379 @@ +/* + * Heart of Darkness engine rewrite + * HD rendering compositor with multi-resolution and 16:9 dynamic borders + */ + +#include +#include +#include +#include +#include +#include "hd_compositor.h" +#include "fileio.h" +#include "resource.h" +#include "sprite_upscaler.h" +#include "video.h" + +HdCompositor::HdCompositor(int scale, const char *cachePath) { + _scale = scale; + + // Compute actual scale from chain (e.g. scale=6 -> chain 3*2 = 6) + int first, second; + SpriteUpscaler::getScaleChain(scale, &first, &second); + const int actualScale = first * (second > 1 ? second : 1); + + _hdW = Video::W * actualScale; + _hdH = Video::H * actualScale; + _hdFramebuffer = (uint32_t *)calloc(_hdW * _hdH, sizeof(uint32_t)); + _hdBackground = (uint32_t *)calloc(_hdW * _hdH, sizeof(uint32_t)); + _wideFramebuffer = 0; + _wideW = 0; + _wideH = 0; + _widescreenEnabled = false; + _enabled = false; + _upscaler = new SpriteUpscaler(scale, cachePath); + _cachedScreenNum = -1; + _cachedBackgroundId = -1; + memset(_palette, 0, sizeof(_palette)); + _paletteValid = false; + _borderColorTop = 0; + _borderColorBottom = 0; + _borderColorAvg = 0; + _progress = 0; +} + +HdCompositor::~HdCompositor() { + free(_hdFramebuffer); + free(_hdBackground); + free(_wideFramebuffer); + delete _upscaler; +} + +void HdCompositor::enableWidescreen(bool on) { + _widescreenEnabled = on; + if (on && !_wideFramebuffer) { + // 16:9 from same height: width = height * 16/9 + _wideW = _hdH * 16 / 9; + _wideH = _hdH; + _wideFramebuffer = (uint32_t *)calloc(_wideW * _wideH, sizeof(uint32_t)); + } +} + +int HdCompositor::scaleForResolution(int targetW, int targetH) { + // Find the largest scale where both dimensions fit + // Game is 256x192 (4:3) + const int scaleW = targetW / Video::W; + const int scaleH = targetH / Video::H; + int scale = (scaleW < scaleH) ? scaleW : scaleH; + if (scale < 2) scale = 2; + if (scale > 16) scale = 16; + return scale; +} + +void HdCompositor::updatePalette(const uint8_t *pal, int n, int depth) { + const int shift = 8 - depth; + for (int i = 0; i < n && i < 256; ++i) { + int r = pal[i * 3 + 0]; + int g = pal[i * 3 + 1]; + int b = pal[i * 3 + 2]; + if (shift != 0) { + r = (r << shift) | (r >> (depth - shift)); + g = (g << shift) | (g >> (depth - shift)); + b = (b << shift) | (b >> (depth - shift)); + } + _palette[i] = (r << 16) | (g << 8) | b; + } + _paletteValid = true; + _cachedScreenNum = -1; +} + +void HdCompositor::computeBorderColors(const uint8_t *bgLayer) { + // Sample edge pixels to compute border colors + // Top edge: average of top row + long rT = 0, gT = 0, bT = 0; + for (int x = 0; x < Video::W; ++x) { + const uint32_t c = _palette[bgLayer[x]]; + rT += (c >> 16) & 0xFF; + gT += (c >> 8) & 0xFF; + bT += c & 0xFF; + } + rT /= Video::W; gT /= Video::W; bT /= Video::W; + _borderColorTop = (rT << 16) | (gT << 8) | bT; + + // Bottom edge + long rB = 0, gB = 0, bB = 0; + const int bottomRow = (Video::H - 1) * Video::W; + for (int x = 0; x < Video::W; ++x) { + const uint32_t c = _palette[bgLayer[bottomRow + x]]; + rB += (c >> 16) & 0xFF; + gB += (c >> 8) & 0xFF; + bB += c & 0xFF; + } + rB /= Video::W; gB /= Video::W; bB /= Video::W; + _borderColorBottom = (rB << 16) | (gB << 8) | bB; + + // Overall average (darken by 50% for subtle borders) + const long rA = (rT + rB) / 4; + const long gA = (gT + gB) / 4; + const long bA = (bT + bB) / 4; + _borderColorAvg = (rA << 16) | (gA << 8) | bA; +} + +void HdCompositor::beginFrame(const uint8_t *bgLayer, const uint8_t *palette, + int screenNum, int backgroundId) +{ + if (!_enabled) return; + + // The engine's palette is 6-bit per channel (values 0-63), so naively + // assigning the bytes leaves _palette ~4x dimmer than intended. That made + // computeBorderColors() sample dark values, and compositeWidescreen()'s + // extra /3 darkening dropped the bars to ~black. Expand 6 -> 8 bits here. + if (palette) { + for (int i = 0; i < 256; ++i) { + int r = palette[i*3 + 0] & 0x3F; + int g = palette[i*3 + 1] & 0x3F; + int b = palette[i*3 + 2] & 0x3F; + r = (r << 2) | (r >> 4); + g = (g << 2) | (g >> 4); + b = (b << 2) | (b >> 4); + _palette[i] = (r << 16) | (g << 8) | b; + } + _paletteValid = true; + } + + if (screenNum != _cachedScreenNum || backgroundId != _cachedBackgroundId) { + upscaleAndCacheBackground(bgLayer, palette); + if (_widescreenEnabled) { + computeBorderColors(bgLayer); + } + _cachedScreenNum = screenNum; + _cachedBackgroundId = backgroundId; + } + + memcpy(_hdFramebuffer, _hdBackground, _hdW * _hdH * sizeof(uint32_t)); +} + +void HdCompositor::upscaleAndCacheBackground(const uint8_t *bgLayer, + const uint8_t *palette) +{ + _upscaler->upscaleBackground(bgLayer, Video::W, Video::H, + _palette, _hdBackground, _hdW, _hdH); +} + +void HdCompositor::drawSprite(const uint8_t *bitmapBits, int x, int y, + uint16_t w, uint16_t h, uint8_t flags) +{ + if (!_enabled || !bitmapBits) return; + + const HdSprite *hd = _upscaler->getOrUpscale(bitmapBits, w, h, flags, _palette); + if (!hd) return; + + // Scale position, accounting for actual vs requested scale + const int dstX = x * _hdW / Video::W; + const int dstY = y * _hdH / Video::H; + blitHdSprite(hd->pixels, hd->width, hd->height, dstX, dstY); +} + +void HdCompositor::blitHdSprite(const uint32_t *pixels, int sprW, int sprH, + int dstX, int dstY) +{ + int srcX = 0, srcY = 0; + int drawW = sprW, drawH = sprH; + + if (dstX < 0) { srcX = -dstX; drawW += dstX; dstX = 0; } + if (dstY < 0) { srcY = -dstY; drawH += dstY; dstY = 0; } + if (dstX + drawW > _hdW) drawW = _hdW - dstX; + if (dstY + drawH > _hdH) drawH = _hdH - dstY; + if (drawW <= 0 || drawH <= 0) return; + + for (int j = 0; j < drawH; ++j) { + const uint32_t *srcRow = pixels + (srcY + j) * sprW + srcX; + uint32_t *dstRow = _hdFramebuffer + (dstY + j) * _hdW + dstX; + for (int i = 0; i < drawW; ++i) { + const uint32_t px = srcRow[i]; + if ((px >> 24) != 0) { + dstRow[i] = px; + } + } + } +} + +void HdCompositor::compositeWidescreen() { + if (!_wideFramebuffer) return; + + const int borderW = (_wideW - _hdW) / 2; + + // Fill entire buffer with gradient from border colors + for (int y = 0; y < _wideH; ++y) { + // Vertical gradient: top color -> bottom color + const float t = (float)y / (float)(_wideH - 1); + const int rT = (_borderColorTop >> 16) & 0xFF; + const int gT = (_borderColorTop >> 8) & 0xFF; + const int bT = _borderColorTop & 0xFF; + const int rB = (_borderColorBottom >> 16) & 0xFF; + const int gB = (_borderColorBottom >> 8) & 0xFF; + const int bB = _borderColorBottom & 0xFF; + const int r = rT + (int)((rB - rT) * t); + const int g = gT + (int)((gB - gT) * t); + const int b = bT + (int)((bB - bT) * t); + // Darken for subtlety + const uint32_t borderColor = ((r/3) << 16) | ((g/3) << 8) | (b/3); + + uint32_t *row = _wideFramebuffer + y * _wideW; + + // Left border + for (int x = 0; x < borderW; ++x) { + // Fade gradient: darker at edge, brighter near game + const float edgeFade = (float)x / (float)borderW; + const int fr = (int)(((borderColor >> 16) & 0xFF) * edgeFade); + const int fg = (int)(((borderColor >> 8) & 0xFF) * edgeFade); + const int fb = (int)((borderColor & 0xFF) * edgeFade); + row[x] = (fr << 16) | (fg << 8) | fb; + } + + // Game area + memcpy(row + borderW, _hdFramebuffer + y * _hdW, + _hdW * sizeof(uint32_t)); + + // Right border (mirror of left) + for (int x = 0; x < borderW; ++x) { + const float edgeFade = (float)(borderW - 1 - x) / (float)borderW; + const int fr = (int)(((borderColor >> 16) & 0xFF) * edgeFade); + const int fg = (int)(((borderColor >> 8) & 0xFF) * edgeFade); + const int fb = (int)((borderColor & 0xFF) * edgeFade); + row[borderW + _hdW + x] = (fr << 16) | (fg << 8) | fb; + } + } +} + +void HdCompositor::endFrame() { + if (_widescreenEnabled) { + compositeWidescreen(); + } +} + +void HdCompositor_drawProgressBar(const char *label, int done, int total, double elapsed) { + const int width = 32; + const int filled = total > 0 ? (done * width) / total : width; + const int pct = total > 0 ? (done * 100) / total : 100; + char bar[64]; + for (int i = 0; i < width; ++i) bar[i] = (i < filled) ? '#' : '.'; + bar[width] = 0; + double eta = 0.0; + if (done > 0 && done < total && elapsed > 0.0) { + eta = elapsed * (total - done) / done; + } + fprintf(stderr, "\r%s [%s] %d/%d (%d%%) %.1fs eta %.1fs ", + label ? label : "prerender", bar, done, total, pct, elapsed, eta); + fflush(stderr); +} + +struct WalkCtx { + LvlObjectData *const *table; + int tableSize; +}; + +static int collectWalks(Resource *res, WalkCtx *walks, int maxWalks) { + int walkCount = 0; + if (walkCount < maxWalks) { + walks[walkCount].table = res->_resLevelData0x2988PtrTable; + walks[walkCount].tableSize = (int)kMaxSpriteTypes; + ++walkCount; + } + for (int s = 0; s < (int)res->_lvlHdr.screensCount && s < (int)kMaxScreens; ++s) { + if (walkCount >= maxWalks) break; + walks[walkCount].table = res->_resLvlScreenBackgroundDataTable[s].backgroundLvlObjectDataTable; + walks[walkCount].tableSize = 8; + ++walkCount; + } + return walkCount; +} + +int HdCompositor::countLevelSpriteFrames(Resource *res) { + if (!res) return 0; + WalkCtx walks[1 + kMaxScreens]; + const int walkCount = collectWalks(res, walks, 1 + kMaxScreens); + int total = 0; + for (int w = 0; w < walkCount; ++w) { + for (int i = 0; i < walks[w].tableSize; ++i) { + LvlObjectData *dat = walks[w].table[i]; + if (!dat) continue; + total += dat->framesCount * 2; + } + } + return total; +} + +void HdCompositor::prerenderLevelSprites(Resource *res, const char *label) { + if (!_enabled || !_upscaler || !res) return; + + WalkCtx walks[1 + kMaxScreens]; + const int walkCount = collectWalks(res, walks, 1 + kMaxScreens); + + const int spriteTotal = countLevelSpriteFrames(res); + if (spriteTotal == 0) return; + + const bool externalProgress = (_progress != 0); + struct timespec t0; + int localTotal = spriteTotal, localDone = 0, lastBar = -1; + if (!externalProgress) { + clock_gettime(CLOCK_MONOTONIC, &t0); + HdCompositor_drawProgressBar(label, 0, localTotal, 0.0); + } + for (int w = 0; w < walkCount; ++w) { + for (int i = 0; i < walks[w].tableSize; ++i) { + LvlObjectData *dat = walks[w].table[i]; + if (!dat) continue; + for (int f = 0; f < dat->framesCount; ++f) { + for (int flip = 0; flip < 2; ++flip) { + uint16_t fw = 0, fh = 0; + const uint8_t *sprData = res->getLvlSpriteFramePtr(dat, f, &fw, &fh); + if (sprData && fw > 0 && fh > 0) { + _upscaler->getOrUpscale(sprData, fw, fh, (uint8_t)flip, _palette); + } + if (externalProgress) { + ++_progress->done; + const int bar = _progress->done * 64 / _progress->total; + if (bar != _progress->lastBar) { + struct timespec t1; clock_gettime(CLOCK_MONOTONIC, &t1); + const double elapsed = (t1.tv_sec - _progress->t0.tv_sec) + + (t1.tv_nsec - _progress->t0.tv_nsec) / 1e9; + HdCompositor_drawProgressBar(_progress->label, + _progress->done, _progress->total, elapsed); + _progress->lastBar = bar; + } + } else { + ++localDone; + const int bar = localDone * 64 / localTotal; + if (bar != lastBar) { + struct timespec t1; clock_gettime(CLOCK_MONOTONIC, &t1); + const double elapsed = (t1.tv_sec - t0.tv_sec) + + (t1.tv_nsec - t0.tv_nsec) / 1e9; + HdCompositor_drawProgressBar(label, localDone, localTotal, elapsed); + lastBar = bar; + } + } + } + } + } + } + if (!externalProgress) { + struct timespec t1; clock_gettime(CLOCK_MONOTONIC, &t1); + const double elapsed = (t1.tv_sec - t0.tv_sec) + + (t1.tv_nsec - t0.tv_nsec) / 1e9; + HdCompositor_drawProgressBar(label, localTotal, localTotal, elapsed); + fprintf(stderr, "\n"); + } +} + +void HdCompositor::getFramebuffer(uint32_t **buf, int *w, int *h) { + if (_widescreenEnabled && _wideFramebuffer) { + *buf = _wideFramebuffer; + *w = _wideW; + *h = _wideH; + } else { + *buf = _hdFramebuffer; + *w = _hdW; + *h = _hdH; + } +} diff --git a/hd_compositor.h b/hd_compositor.h new file mode 100644 index 0000000..9ec56ba --- /dev/null +++ b/hd_compositor.h @@ -0,0 +1,104 @@ +/* + * Heart of Darkness engine rewrite + * HD rendering compositor with multi-resolution and 16:9 borders + */ + +#ifndef HD_COMPOSITOR_H__ +#define HD_COMPOSITOR_H__ + +#include "intern.h" +#include "defs.h" + +struct Resource; +struct SpriteUpscaler; +struct Video; + +#include + +// Shared progress tracker so the level-sprite walker and PAF frame callback +// can drive a single console bar across both phases. +struct PrerenderProgress { + int done; + int total; + int lastBar; + char label[80]; + struct timespec t0; +}; + +// Render one frame of the prerender progress bar to stderr (with carriage +// return — caller emits the trailing newline at the end of the run). +void HdCompositor_drawProgressBar(const char *label, int done, int total, double elapsed); + +struct HdCompositor { + // Resolution presets (scale factors from 256x192) + enum ScalePreset { + kScale_HD = 6, // 1536x1152 + kScale_FullHD = 8, // 2048x1536 + kScale_QHD = 10, // 2560x1920 + kScale_4K = 15, // 3840x2880 + kDefaultScale = 6 + }; + + uint32_t *_hdFramebuffer; // main HD output (4:3 game area) + uint32_t *_hdBackground; // cached upscaled background + uint32_t *_wideFramebuffer; // 16:9 output with borders (if enabled) + int _scale; + int _hdW, _hdH; // game area dimensions (e.g. 1536x1152) + int _wideW, _wideH; // 16:9 dimensions (e.g. 2048x1152) + bool _enabled; + bool _widescreenEnabled; + SpriteUpscaler *_upscaler; + int _cachedScreenNum; + int _cachedBackgroundId; + uint32_t _palette[256]; + bool _paletteValid; + + // When non-null, prerender helpers report into this shared counter rather + // than driving their own bar. Allows the sprite walker and PAF frame + // callback to advance one unified progress bar. + PrerenderProgress *_progress; + + // Dynamic border colors (computed from palette) + uint32_t _borderColorTop; + uint32_t _borderColorBottom; + uint32_t _borderColorAvg; + + HdCompositor(int scale = kDefaultScale, const char *cachePath = 0); + ~HdCompositor(); + + void enable(bool on) { _enabled = on; } + bool isEnabled() const { return _enabled; } + void enableWidescreen(bool on); + + void updatePalette(const uint8_t *pal, int n, int depth); + + void beginFrame(const uint8_t *bgLayer, const uint8_t *palette, + int screenNum, int backgroundId); + + void drawSprite(const uint8_t *bitmapBits, int x, int y, + uint16_t w, uint16_t h, uint8_t flags); + + void endFrame(); + + // Walk every sprite frame for the loaded level (in res) and force the + // upscaler to populate its RAM/disk cache. If _progress is non-null, + // each frame ticks that shared counter; otherwise this prints its own bar. + void prerenderLevelSprites(Resource *res, const char *label = 0); + + // Count units of work the sprite walker would do — used by the unified + // progress driver to know the sprite portion of the total ahead of time. + int countLevelSpriteFrames(Resource *res); + + // Returns the appropriate framebuffer (widescreen or 4:3) + void getFramebuffer(uint32_t **buf, int *w, int *h); + + static int scaleForResolution(int targetW, int targetH); + +private: + void upscaleAndCacheBackground(const uint8_t *bgLayer, const uint8_t *palette); + void blitHdSprite(const uint32_t *pixels, int sprW, int sprH, int dstX, int dstY); + void computeBorderColors(const uint8_t *bgLayer); + void compositeWidescreen(); +}; + +#endif // HD_COMPOSITOR_H__ diff --git a/intern.h b/intern.h index d908293..b2355ae 100644 --- a/intern.h +++ b/intern.h @@ -12,20 +12,12 @@ #include #include -#if defined(_WIN32) || defined(PSP) || defined(__SWITCH__) || defined(__vita__) +#if defined(_WIN32) || defined(PSP) #define le16toh(x) x #define le32toh(x) x #define htole16(x) x #define htole32(x) x static const bool kByteSwapData = false; // no byteswap needed on little endian -#elif defined(__APPLE__) -#include -#define le16toh(x) OSSwapLittleToHostInt16(x) -#define le32toh(x) OSSwapLittleToHostInt32(x) -#define htole16(x) OSSwapHostToLittleInt16(x) -#define htole32(x) OSSwapHostToLittleInt32(x) -#include -static const bool kByteSwapData = (BYTE_ORDER == BIG_ENDIAN); #elif defined(WII) // big endian #include #define le16toh(x) __bswap16(x) @@ -33,6 +25,13 @@ static const bool kByteSwapData = (BYTE_ORDER == BIG_ENDIAN); #define htole16(x) __bswap16(x) #define htole32(x) __bswap32(x) static const bool kByteSwapData = true; +#elif defined(__APPLE__) +#include +#define le16toh(x) OSSwapLittleToHostInt16(x) +#define le32toh(x) OSSwapLittleToHostInt32(x) +#define htole16(x) OSSwapHostToLittleInt16(x) +#define htole32(x) OSSwapHostToLittleInt32(x) +static const bool kByteSwapData = (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__); #else #include static const bool kByteSwapData = (__BYTE_ORDER == __BIG_ENDIAN); diff --git a/main.cpp b/main.cpp index 890dd0a..3852758 100644 --- a/main.cpp +++ b/main.cpp @@ -10,26 +10,21 @@ #include #include +#include "automation_api.h" #include "game.h" +#include "hd_compositor.h" #include "menu.h" #include "mixer.h" #include "paf.h" +#include "paf.h" #include "util.h" #include "resource.h" #include "system.h" #include "video.h" -#ifdef __SWITCH__ -#include -#endif - static const char *_title = "Heart of Darkness"; -#ifdef __vita__ -static const char *_configIni = "ux0:data/hode/hode.ini"; -#else static const char *_configIni = "hode.ini"; -#endif static const char *_usage = "hode - Heart of Darkness Interpreter\n" @@ -42,6 +37,13 @@ static const char *_usage = static bool _fullscreen = false; static bool _widescreen = false; +static bool _hdMode = false; +static int _hdScale = 0; // 0 = auto, or explicit scale factor +static bool _hdWidescreen = false; +static bool _smoothAnim = false; +static bool _hdPrerender = false; +static char *_automationSocket = 0; +static char *_hdCachePath = 0; static const bool _runBenchmark = false; static bool _runMenu = true; @@ -122,6 +124,18 @@ static void handleConfigIni(Game *g, const char *section, const char *name, cons _fullscreen = configBool(value); } else if (strcmp(name, "widescreen") == 0) { _widescreen = configBool(value); + } else if (strcmp(name, "hd_mode") == 0) { + _hdMode = configBool(value); + } else if (strcmp(name, "hd_scale") == 0) { + _hdScale = atoi(value); + } else if (strcmp(name, "hd_widescreen") == 0) { + _hdWidescreen = configBool(value); + } else if (strcmp(name, "hd_cache") == 0) { + _hdCachePath = strdup(value); + } else if (strcmp(name, "automation_socket") == 0) { + _automationSocket = strdup(value); + } else if (strcmp(name, "smooth_anim") == 0) { + _smoothAnim = configBool(value); } } } @@ -166,17 +180,8 @@ static void readConfigIni(const char *filename, Game *g) { } int main(int argc, char *argv[]) { -#ifdef __SWITCH__ - socketInitializeDefault(); - nxlinkStdio(); -#endif -#ifdef __vita__ - const char *dataPath = "ux0:data/hode"; - const char *savePath = "ux0:data/hode"; -#else char *dataPath = 0; char *savePath = 0; -#endif int level = 0; int checkpoint = 0; bool resume = true; // resume game from 'setup.cfg' @@ -216,6 +221,15 @@ int main(int argc, char *argv[]) { { "checkpoint", required_argument, 0, 4 }, { "debug", required_argument, 0, 5 }, { "cheats", required_argument, 0, 6 }, + { "hd", no_argument, 0, 7 }, + { "automation", required_argument, 0, 8 }, + { "smooth", no_argument, 0, 9 }, + { "hd-scale", required_argument, 0, 10 }, + { "hd-wide", no_argument, 0, 11 }, + { "hd-cache", required_argument, 0, 12 }, + { "4k", no_argument, 0, 13 }, + { "fullhd", no_argument, 0, 14 }, + { "prerender", no_argument, 0, 15 }, { 0, 0, 0, 0 }, }; int index; @@ -253,6 +267,36 @@ int main(int argc, char *argv[]) { case 6: cheats |= atoi(optarg); break; + case 7: + _hdMode = true; + break; + case 8: + _automationSocket = strdup(optarg); + break; + case 9: + _smoothAnim = true; + break; + case 10: + _hdMode = true; + _hdScale = atoi(optarg); + break; + case 11: + _hdWidescreen = true; + break; + case 12: + _hdCachePath = strdup(optarg); + break; + case 13: + _hdMode = true; + _hdScale = HdCompositor::kScale_4K; + break; + case 14: + _hdMode = true; + _hdScale = HdCompositor::kScale_FullHD; + break; + case 15: + _hdPrerender = true; + break; default: fprintf(stdout, _usage, argv[0]); return -1; @@ -266,17 +310,50 @@ int main(int argc, char *argv[]) { } // load setup.dat (PC) or setup.dax (PSX) g->_res->loadSetupDat(); + // Wire the font early — the menu (which runs before Game::mainLoop) draws + // strings via _video->drawStringCharacter, which dereferences _font. + g->_video->_font = g->_res->_fontBuffer; const bool isPsx = g->_res->_isPsx; - g_system->init(_title, Video::W, Video::H, _fullscreen, _widescreen, isPsx); + // Make the SDL window 16:9 too when --hd-wide is requested, otherwise the + // HdCompositor's wide framebuffer (with colored borders) gets squashed + // into a 4:3 window and you only see black bars. + const bool sysWidescreen = _widescreen || (_hdMode && _hdWidescreen); + g_system->init(_title, Video::W, Video::H, _fullscreen, sysWidescreen, isPsx); setupAudio(g); if (isPsx) { g->_video->initPsx(); } + if (_hdMode) { + const int scale = _hdScale > 0 ? _hdScale : HdCompositor::kDefaultScale; + g->_hdCompositor = new HdCompositor(scale, _hdCachePath); + g->_hdCompositor->enable(true); + if (_hdWidescreen) { + g->_hdCompositor->enableWidescreen(true); + } + fprintf(stdout, "HD mode: %dx scale (%dx%d)%s%s%s\n", + scale, Video::W * scale, Video::H * scale, + _hdWidescreen ? ", 16:9 widescreen" : "", + _hdCachePath ? ", disk cache" : "", + _hdPrerender ? ", prerender" : ""); + g->_hdPrerenderEnabled = _hdPrerender; + } + if (_smoothAnim) { + g->_interpolationEnabled = true; + } + if (_automationSocket) { + g->_automationApi = new AutomationApi(); + g->_automationApi->init(_automationSocket, g); + // When using automation, disable loading screen and menu for faster startup + _displayLoadingScreen = false; + _runMenu = false; + g->_paf->_skipCutscenes = true; + } if (_displayLoadingScreen) { g->displayLoadingScreen(); } do { g->loadSetupCfg(resume); + g_system->applyKeyboardControls(g->_setupConfig.players[g->_setupConfig.currentPlayer].controls); if (_runMenu && resume) { Menu *m = new Menu(g, g->_paf, g->_res, g->_video); const bool runGame = m->mainLoop(); @@ -284,6 +361,7 @@ int main(int argc, char *argv[]) { if (!runGame) { break; } + g_system->applyKeyboardControls(g->_setupConfig.players[g->_setupConfig.currentPlayer].controls); } bool levelChanged = false; while (!g_system->inp.quit && level < kLvl_test) { @@ -306,12 +384,7 @@ int main(int argc, char *argv[]) { g_system->stopAudio(); g_system->destroy(); delete g; -#ifndef __vita__ free(dataPath); free(savePath); -#endif -#ifdef __SWITCH__ - socketExit(); -#endif return 0; } diff --git a/menu.cpp b/menu.cpp index f036822..0f49b99 100644 --- a/menu.cpp +++ b/menu.cpp @@ -1,4 +1,5 @@ +#include "automation_api.h" #include "game.h" #include "menu.h" #include "lzw.h" @@ -463,6 +464,9 @@ int Menu::handleTitleScreen() { int currentOption = kTitleScreen_Play; while (1) { g_system->processEvents(); + if (_g->_automationApi) { + _g->_automationApi->processCommands(); + } if (g_system->inp.quit) { currentOption = kTitleScreen_Quit; break; @@ -836,7 +840,7 @@ void Menu::handleSettingsScreen(int num) { } return; } else if (num == kCursor_Left) { - if (_settingNum != kSettingNum_Confirm && _settingNum > 1) { // 'controls' not implemented + if (_settingNum != kSettingNum_Confirm && _settingNum > 0) { playSound(kSound_0x70); --_settingNum; _iconsSprites[0x27].num = 0; @@ -1100,15 +1104,61 @@ void Menu::handleJoystickControlsScreen(int num) { g_system->sleep(kDelayMs); } +static uint8_t scancodeToFontCode(uint8_t scancode) { + // SDL_SCANCODE_A(4)..Z(29) -> 'A'(0x41)..'Z'(0x5A) + if (scancode >= 4 && scancode <= 29) { + return 0x41 + (scancode - 4); + } + // SDL_SCANCODE_1(30)..9(38) -> '1'(0x31)..'9'(0x39) + if (scancode >= 30 && scancode <= 38) { + return 0x31 + (scancode - 30); + } + // SDL_SCANCODE_0(39) -> '0'(0x30) + if (scancode == 39) return 0x30; + // SDL_SCANCODE_LCTRL(224) -> 0x11 (special glyph) + if (scancode == 224 || scancode == 228) return 0x11; + // SDL_SCANCODE_LSHIFT(225) -> 0x10 (special glyph) + if (scancode == 225 || scancode == 229) return 0x10; + // SDL_SCANCODE_LALT(226) -> 0x12 (special glyph) + if (scancode == 226 || scancode == 230) return 0x12; + // SDL_SCANCODE_SPACE(44), RETURN(40), KP_ENTER(88), TAB(43), BACKSPACE(42), + // LGUI(227)/RGUI(231). The font often has no glyphs for these, so the + // drawing helper will skip — but the binding itself still works. + if (scancode == 44) return 0x13; + if (scancode == 40 || scancode == 88) return 0x14; + if (scancode == 43) return 0x15; + if (scancode == 42) return 0x16; + if (scancode == 227 || scancode == 231) return 0x17; + return 0; +} + void Menu::drawKeyboardKeyCode(int num) { for (int i = 0; i < 2; ++i) { const uint8_t code = _config->players[_config->currentPlayer].controls[16 + 2 * num + i]; - if (code != 0) { - static const uint8_t xPos[] = { 20, 79, 138, 197 }; - const int chr = _video->findStringCharacterFontIndex(code); + if (code == 0) continue; + static const uint8_t xPos[] = { 20, 79, 138, 197 }; + const int x = xPos[num] + i * 23; + const uint8_t displayCode = scancodeToFontCode(code); + bool drawn = false; + if (displayCode != 0) { + const int chr = _video->findStringCharacterFontIndex(displayCode); if (chr != 255) { - _video->drawStringCharacter(xPos[num] + i * 23, 111, chr, _res->_fontDefaultColor, _video->_frontLayer); + _video->drawStringCharacter(x, 111, chr, _res->_fontDefaultColor, _video->_frontLayer); + drawn = true; + } + } + if (!drawn) { + // Fallback to a short text label for keys without a font glyph + // (Space/Enter/Tab/Backspace/Win/etc.) so the binding is visible. + const char *label = "?"; + switch (code) { + case 44: label = "SP"; break; // Space + case 40: case 88: label = "EN"; break; // Enter / Numpad Enter + case 43: label = "TB"; break; // Tab + case 42: label = "BS"; break; // Backspace + case 227: case 231: label = "WN"; break; // Win/Cmd } + _video->drawString(label, x, 111, _res->_fontDefaultColor, _video->_frontLayer); } } } @@ -1146,9 +1196,12 @@ void Menu::drawKeyboardControlsScreen() { drawSprite(&_iconsSprites[0x1B], _iconsSpritesData, 0); } else if (_keyboardControlsNum == 8) { drawSprite(&_iconsSprites[0x10], _iconsSpritesData, 4); - static const int keyboardMask = 0; - int mask = keyboardMask; - const int flag = (((keyboardMask & 5) - 5) != 0) ? 0 : 1; + // Test mode: read live action bits so the icons highlight while the + // user presses their bound keys. Map SYS_INP_RUN/JUMP/SHOOT (0x10/0x20/0x40) + // down to bits 0/1/2 used by the existing icon-highlight logic. + const int liveMask = (g_system->inp.mask >> 4) & 7; + int mask = liveMask; + const int flag = (((liveMask & 5) - 5) != 0) ? 0 : 1; if (((mask & 1) != 0 && flag == 0) || _iconsSprites[0x21].num != 0) { drawSpriteAnim(_iconsSprites, _iconsSpritesData, 0x21); } else { @@ -1174,14 +1227,73 @@ void Menu::drawKeyboardControlsScreen() { drawKeyboardKeyCode(1); drawKeyboardKeyCode(2); drawKeyboardKeyCode(3); + if (_keyboardControlsNum == 3) { + // Indicator for the OK/Cancel/Test row (the original screen state + // machine treats them as a single state, so we sub-divide here and + // label which sub-button is currently active). + static const char *const names[] = { "> OK", "> CANCEL", "> TEST" }; + const int idx = (_kbdButton >= 0 && _kbdButton <= 2) ? _kbdButton : 0; + _video->drawString(names[idx], 100, 170, + _res->_fontDefaultColor, _video->_frontLayer); + } else if (_keyboardControlsNum == 8) { + _video->drawString("TEST - PRESS YOUR KEYS, ARROW TO EXIT", + 16, 170, _res->_fontDefaultColor, _video->_frontLayer); + } refreshScreen(); } void Menu::handleKeyboardControlsScreen(int num) { const uint8_t *data = &_optionData[num * 8]; num = data[5]; - if (num == 1) { - if (_keyboardControlsNum == 0) { + if (num == 0) { + if (_keyboardControlsNum >= 4 && _keyboardControlsNum <= 7) { + const int action = _keyboardControlsNum - 4; + playSound(kSound_0x78); + drawKeyboardControlsScreen(); + const int scancode = g_system->waitForKeyPress(); + if (scancode > 0) { + uint8_t *ctrl = _config->players[_config->currentPlayer].controls; + // Each key may belong to only one action: clear any prior + // binding of this scancode (across all actions and slots). + for (int i = 16; i < 24; ++i) { + if (ctrl[i] == (uint8_t)scancode) { + ctrl[i] = 0; + } + } + if (ctrl[16 + 2 * action] == 0) { + ctrl[16 + 2 * action] = (uint8_t)scancode; + } else { + ctrl[16 + 2 * action + 1] = (uint8_t)scancode; + } + g_system->applyKeyboardControls(ctrl); + } + } else if (_keyboardControlsNum == 3) { + playSound(kSound_0x78); + if (_kbdButton == 0) { + // OK: keep changes and leave the screen. + _condMask = 0x80; + return; + } else if (_kbdButton == 1) { + // Cancel: revert controls[] to the entry snapshot, then leave. + memcpy(_config->players[_config->currentPlayer].controls, + _kbdControlsBackup, sizeof(_kbdControlsBackup)); + g_system->applyKeyboardControls( + _config->players[_config->currentPlayer].controls); + _condMask = 0x80; + return; + } else { + // Test: enter live key-press visualisation (state 8). + _keyboardControlsNum = 8; + } + } + drawKeyboardControlsScreen(); + g_system->sleep(kDelayMs); + return; + } else if (num == 1) { + if (_keyboardControlsNum == 3 && _kbdButton > 0) { + playSound(kSound_0x70); + --_kbdButton; + } else if (_keyboardControlsNum == 0) { playSound(kSound_0x70); _keyboardControlsNum = 1; } else if (_keyboardControlsNum == 2) { @@ -1204,7 +1316,10 @@ void Menu::handleKeyboardControlsScreen(int num) { _keyboardControlsNum = 2; } } else if (num == 2) { - if (_keyboardControlsNum == 0) { + if (_keyboardControlsNum == 3 && _kbdButton < 2) { + playSound(kSound_0x70); + ++_kbdButton; + } else if (_keyboardControlsNum == 0) { playSound(kSound_0x70); _keyboardControlsNum = 2; } else if (_keyboardControlsNum == 1) { @@ -1726,6 +1841,9 @@ bool Menu::handleOptions() { _condMask = 0; while (1) { g_system->processEvents(); + if (_g->_automationApi) { + _g->_automationApi->processCommands(); + } if (g_system->inp.quit) { break; } @@ -1803,6 +1921,10 @@ bool Menu::handleOptions() { g_system->setPalette(_paletteBuffer, 256, 6); } _keyboardControlsNum = 1; + _kbdButton = 0; // OK + memcpy(_kbdControlsBackup, + _config->players[_config->currentPlayer].controls, + sizeof(_kbdControlsBackup)); } handleKeyboardControlsScreen(num); break; diff --git a/menu.h b/menu.h index 1f124af..9cd8772 100644 --- a/menu.h +++ b/menu.h @@ -91,6 +91,8 @@ struct Menu { int _controlsNum; int _joystickControlsNum; int _keyboardControlsNum; + int _kbdButton; // 0=OK, 1=Cancel, 2=Test, when _keyboardControlsNum==3 + uint8_t _kbdControlsBackup[32]; // snapshot of controls[] for Cancel int _difficultyNum; int _soundNum; uint8_t _soundVolume; diff --git a/paf.cpp b/paf.cpp index 2f89135..232d8d1 100644 --- a/paf.cpp +++ b/paf.cpp @@ -47,6 +47,7 @@ PafPlayer::PafPlayer(FileSystem *fs) memset(&_pafCb, 0, sizeof(_pafCb)); _volume = 128; _frameMs = kFrameDuration; + _prerenderMode = false; } PafPlayer::~PafPlayer() { @@ -478,7 +479,7 @@ void PafPlayer::mainLoop() { int currentFrameBlock = 0; AudioCallback prevAudioCb; - if (_demuxAudioFrameBlocks) { + if (_demuxAudioFrameBlocks && !_prerenderMode) { AudioCallback audioCb; audioCb.proc = mixAudio; audioCb.userdata = this; @@ -512,22 +513,28 @@ void PafPlayer::mainLoop() { if (_pafCb.frameProc) { _pafCb.frameProc(_pafCb.userdata, i, _pageBuffers[_currentPageBuffer]); - } else { + } else if (!_prerenderMode) { g_system->copyRect(0, 0, kVideoWidth, kVideoHeight, _pageBuffers[_currentPageBuffer], kVideoWidth); } if (_paletteChanged) { _paletteChanged = false; - g_system->setPalette(_paletteBuffer, 256, 6); + if (!_prerenderMode) { + g_system->setPalette(_paletteBuffer, 256, 6); + } + } + if (!_prerenderMode) { + g_system->updateScreen(false); } - g_system->updateScreen(false); g_system->processEvents(); - if (g_system->inp.keyPressed(SYS_INP_ESC) || g_system->inp.skip) { + if (g_system->inp.quit || g_system->inp.keyPressed(SYS_INP_ESC)) { break; } - const int delay = MAX(10, frameTime - g_system->getTimeStamp()); - g_system->sleep(delay); - frameTime = g_system->getTimeStamp() + frameMs; + if (!_prerenderMode) { + const int delay = MAX(10, frameTime - g_system->getTimeStamp()); + g_system->sleep(delay); + frameTime = g_system->getTimeStamp() + frameMs; + } // set next decoding video page ++_currentPageBuffer; @@ -539,13 +546,30 @@ void PafPlayer::mainLoop() { } // restore audio callback - if (_demuxAudioFrameBlocks) { + if (_demuxAudioFrameBlocks && !_prerenderMode) { g_system->setAudioCallback(prevAudioCb); } unload(); } +int PafPlayer::peekFramesCount(int num) { + preload(num); + const int count = (_videoNum == num) ? (int)_pafHdr.framesCount : 0; + unload(); + return count; +} + +void PafPlayer::prerender(int num) { + // Don't pollute _playedMask (which is persisted to the save file as + // "cutscenes the user has watched") just because we walked the frames. + const uint32_t savedPlayedMask = _playedMask; + _prerenderMode = true; + play(num); + _prerenderMode = false; + _playedMask = savedPlayedMask; +} + void PafPlayer::setCallback(const PafCallback *pafCb) { if (pafCb) { _pafCb = *pafCb; diff --git a/paf.h b/paf.h index 5582cd0..9ba2af6 100644 --- a/paf.h +++ b/paf.h @@ -100,6 +100,7 @@ struct PafPlayer { PafCallback _pafCb; int _volume; int _frameMs; + bool _prerenderMode; // true: skip audio/display/sleep, just decode & cache PafPlayer(FileSystem *fs); ~PafPlayer(); @@ -126,6 +127,15 @@ struct PafPlayer { void mainLoop(); void setCallback(const PafCallback *pafCb); + + // Decode every frame as fast as possible (no audio, no display, no sleep) + // — invokes _pafCb.frameProc on each frame so a caching callback can + // populate disk cache. + void prerender(int num); + + // Read the PAF header for `num` and return the frame count, leaving the + // player unloaded. Used to size a unified progress bar before prerender. + int peekFramesCount(int num); }; #endif // PAF_PLAYER_H__ diff --git a/sprite_upscaler.cpp b/sprite_upscaler.cpp new file mode 100644 index 0000000..35459a5 --- /dev/null +++ b/sprite_upscaler.cpp @@ -0,0 +1,564 @@ +/* + * Heart of Darkness engine rewrite + * Sprite upscaling with xBRZ and disk+memory caching + */ + +#include +#include +#include +#include +#include +#include +#include +#include "sprite_upscaler.h" +#include "edge_smooth.h" +#include "video.h" + +// --- Color helpers --- + +static inline int colorDiff(uint32_t a, uint32_t b) { + if (a == b) return 0; + const int r1 = (a >> 16) & 0xFF, g1 = (a >> 8) & 0xFF, b1 = a & 0xFF; + const int r2 = (b >> 16) & 0xFF, g2 = (b >> 8) & 0xFF, b2 = b & 0xFF; + const int dr = r1 - r2, dg = g1 - g2, db = b1 - b2; + const int y = (dr * 299 + dg * 587 + db * 114) / 1000; + const int u = (db * 436 - dr * 147 - dg * 289) / 1000; + const int v = (dr * 615 - dg * 515 - db * 100) / 1000; + return abs(y) * 2 + abs(u) + abs(v); +} + +static inline bool colorsEqual(uint32_t a, uint32_t b) { + return colorDiff(a, b) < 30; +} + +static inline uint32_t blendPixels(uint32_t a, uint32_t b, int wa, int wb) { + const int total = wa + wb; + const int r = (((a >> 16) & 0xFF) * wa + ((b >> 16) & 0xFF) * wb) / total; + const int g = (((a >> 8) & 0xFF) * wa + ((b >> 8) & 0xFF) * wb) / total; + const int bl = ((a & 0xFF) * wa + (b & 0xFF) * wb) / total; + const int al = (((a >> 24) & 0xFF) * wa + ((b >> 24) & 0xFF) * wb) / total; + return (al << 24) | (r << 16) | (g << 8) | bl; +} + +// Helper to sample with clamping +static inline uint32_t sampleClamped(const uint32_t *src, int x, int y, int w, int h) { + x = (x < 0) ? 0 : (x >= w) ? w - 1 : x; + y = (y < 0) ? 0 : (y >= h) ? h - 1 : y; + return src[y * w + x]; +} + +// --- xBRZ scalers --- + +void xbrz_scale2x_rgba(const uint32_t *src, int srcW, int srcH, + uint32_t *dst, int dstPitch) +{ + for (int y = 0; y < srcH; ++y) { + for (int x = 0; x < srcW; ++x) { + const uint32_t C = src[y * srcW + x]; + const uint32_t U = sampleClamped(src, x, y-1, srcW, srcH); + const uint32_t D = sampleClamped(src, x, y+1, srcW, srcH); + const uint32_t L = sampleClamped(src, x-1, y, srcW, srcH); + const uint32_t R = sampleClamped(src, x+1, y, srcW, srcH); + + uint32_t p0, p1, p2, p3; + if (!colorsEqual(L, R) && !colorsEqual(U, D)) { + p0 = colorsEqual(U, L) ? blendPixels(U, C, 3, 1) : C; + p1 = colorsEqual(U, R) ? blendPixels(U, C, 3, 1) : C; + p2 = colorsEqual(D, L) ? blendPixels(D, C, 3, 1) : C; + p3 = colorsEqual(D, R) ? blendPixels(D, C, 3, 1) : C; + } else { + p0 = p1 = p2 = p3 = C; + } + const int dx = x * 2, dy = y * 2; + dst[dy * dstPitch + dx] = p0; + dst[dy * dstPitch + dx + 1] = p1; + dst[(dy+1) * dstPitch + dx] = p2; + dst[(dy+1) * dstPitch + dx + 1] = p3; + } + } +} + +void xbrz_scale3x_rgba(const uint32_t *src, int srcW, int srcH, + uint32_t *dst, int dstPitch) +{ + for (int y = 0; y < srcH; ++y) { + for (int x = 0; x < srcW; ++x) { + const uint32_t C = src[y * srcW + x]; + const uint32_t U = sampleClamped(src, x, y-1, srcW, srcH); + const uint32_t D = sampleClamped(src, x, y+1, srcW, srcH); + const uint32_t L = sampleClamped(src, x-1, y, srcW, srcH); + const uint32_t R = sampleClamped(src, x+1, y, srcW, srcH); + const uint32_t UL = sampleClamped(src, x-1, y-1, srcW, srcH); + const uint32_t UR = sampleClamped(src, x+1, y-1, srcW, srcH); + const uint32_t DL = sampleClamped(src, x-1, y+1, srcW, srcH); + const uint32_t DR = sampleClamped(src, x+1, y+1, srcW, srcH); + + uint32_t p[9]; + p[4] = C; + if (!colorsEqual(L, R) && !colorsEqual(U, D)) { + p[0] = colorsEqual(U, L) ? blendPixels(U, C, 3, 1) : C; + p[1] = (colorsEqual(U, L) && !colorsEqual(C, UR)) || + (colorsEqual(U, R) && !colorsEqual(C, UL)) ? U : C; + p[2] = colorsEqual(U, R) ? blendPixels(U, C, 3, 1) : C; + p[3] = (colorsEqual(U, L) && !colorsEqual(C, DL)) || + (colorsEqual(D, L) && !colorsEqual(C, UL)) ? L : C; + p[5] = (colorsEqual(U, R) && !colorsEqual(C, DR)) || + (colorsEqual(D, R) && !colorsEqual(C, UR)) ? R : C; + p[6] = colorsEqual(D, L) ? blendPixels(D, C, 3, 1) : C; + p[7] = (colorsEqual(D, L) && !colorsEqual(C, DR)) || + (colorsEqual(D, R) && !colorsEqual(C, DL)) ? D : C; + p[8] = colorsEqual(D, R) ? blendPixels(D, C, 3, 1) : C; + } else { + for (int i = 0; i < 9; ++i) p[i] = C; + } + const int dx = x * 3, dy = y * 3; + for (int j = 0; j < 3; ++j) + for (int i = 0; i < 3; ++i) + dst[(dy+j) * dstPitch + dx + i] = p[j*3+i]; + } + } +} + +void xbrz_scale4x_rgba(const uint32_t *src, int srcW, int srcH, + uint32_t *dst, int dstPitch) +{ + // 4x = 2x applied twice + const int midW = srcW * 2, midH = srcH * 2; + uint32_t *mid = (uint32_t *)malloc(midW * midH * sizeof(uint32_t)); + xbrz_scale2x_rgba(src, srcW, srcH, mid, midW); + xbrz_scale2x_rgba(mid, midW, midH, dst, dstPitch); + free(mid); +} + +void xbrz_scale5x_rgba(const uint32_t *src, int srcW, int srcH, + uint32_t *dst, int dstPitch) +{ + // 5x: not directly supported, use nearest-neighbor on top of 4x + // or bilinear. For quality, we do: scale to 3x then scale that ~1.67x via bilinear + // Simpler: just do nearest from original + for (int dy = 0; dy < srcH * 5; ++dy) { + const int sy = dy / 5; + for (int dx = 0; dx < srcW * 5; ++dx) { + const int sx = dx / 5; + dst[dy * dstPitch + dx] = src[sy * srcW + sx]; + } + } +} + +// Chain two scale factors to achieve target +void SpriteUpscaler::getScaleChain(int scale, int *first, int *second) { + // Find best decomposition into two factors, each 2-4 + if (scale <= 4) { + *first = scale; *second = 1; return; + } + // Try to decompose + static const int factors[][2] = { + {0,0}, {1,1}, {2,1}, {3,1}, {4,1}, // 0-4 + {3,2}, // 5 = 3*2 (approximate: actually need 5, round up) + {3,2}, // 6 = 3*2 + {4,2}, // 7 ~ 4*2 = 8 (closest) + {4,2}, // 8 = 4*2 + {3,3}, // 9 = 3*3 + {4,3}, // 10 ~ 4*3 = 12 (closest achievable) + {4,3}, // 11 ~ 4*3 + {4,3}, // 12 = 4*3 + {4,4}, // 13 ~ 4*4 = 16 + {4,4}, // 14 ~ 4*4 + {4,4}, // 15 ~ 4*4 = 16 (then SDL downscales to fit) + {4,4}, // 16 = 4*4 + }; + if (scale > 16) scale = 16; + *first = factors[scale][0]; + *second = factors[scale][1]; +} + +void xbrz_scaleNx_rgba(const uint32_t *src, int srcW, int srcH, + uint32_t *dst, int dstW, int dstH, int scale) +{ + int first, second; + SpriteUpscaler::getScaleChain(scale, &first, &second); + + if (second <= 1) { + // Single pass + switch (first) { + case 2: xbrz_scale2x_rgba(src, srcW, srcH, dst, dstW); break; + case 3: xbrz_scale3x_rgba(src, srcW, srcH, dst, dstW); break; + case 4: xbrz_scale4x_rgba(src, srcW, srcH, dst, dstW); break; + default: + // Nearest neighbor fallback + for (int y = 0; y < dstH; ++y) + for (int x = 0; x < dstW; ++x) + dst[y * dstW + x] = src[(y/scale) * srcW + x/scale]; + break; + } + return; + } + + // Two-pass chain + const int midW = srcW * first, midH = srcH * first; + uint32_t *mid = (uint32_t *)malloc(midW * midH * sizeof(uint32_t)); + + switch (first) { + case 2: xbrz_scale2x_rgba(src, srcW, srcH, mid, midW); break; + case 3: xbrz_scale3x_rgba(src, srcW, srcH, mid, midW); break; + case 4: xbrz_scale4x_rgba(src, srcW, srcH, mid, midW); break; + default: break; + } + + const int finalW = midW * second; + switch (second) { + case 2: xbrz_scale2x_rgba(mid, midW, midH, dst, finalW); break; + case 3: xbrz_scale3x_rgba(mid, midW, midH, dst, finalW); break; + case 4: xbrz_scale4x_rgba(mid, midW, midH, dst, finalW); break; + default: break; + } + + free(mid); + + // If actual product differs from target, crop/pad is handled by caller +} + +// --- SpriteUpscaler --- + +SpriteUpscaler::SpriteUpscaler(int scale, const char *cachePath) { + _scale = scale; + memset(_hashTable, 0, sizeof(_hashTable)); + _cacheCount = 0; + _cacheBytes = 0; + _accessCounter = 0; + _tempBufferSize = 256 * 256; + _tempBuffer = (uint8_t *)calloc(_tempBufferSize, 1); + _diskCacheEnabled = false; + memset(_diskCachePath, 0, sizeof(_diskCachePath)); + if (cachePath) { + setDiskCache(cachePath); + } +} + +SpriteUpscaler::~SpriteUpscaler() { + clearCache(); + free(_tempBuffer); +} + +void SpriteUpscaler::setDiskCache(const char *basePath) { + snprintf(_diskCachePath, sizeof(_diskCachePath), "%s/%dx", basePath, _scale); + // Create cache directories + mkdir(basePath, 0755); + mkdir(_diskCachePath, 0755); + _diskCacheEnabled = true; +} + +void SpriteUpscaler::makeDiskPath(char *buf, int bufSize, uintptr_t key, + uint16_t w, uint16_t h) const +{ + snprintf(buf, bufSize, "%s/spr_%016lx_%dx%d.raw", + _diskCachePath, (unsigned long)key, w, h); +} + +bool SpriteUpscaler::loadFromDisk(uintptr_t key, uint16_t w, uint16_t h, + HdSprite *out) +{ + if (!_diskCacheEnabled) return false; + + char path[512]; + makeDiskPath(path, sizeof(path), key, w, h); + + FILE *fp = fopen(path, "rb"); + if (!fp) return false; + + // Read header: width(4) height(4) + int32_t dw, dh; + if (fread(&dw, 4, 1, fp) != 1 || fread(&dh, 4, 1, fp) != 1) { + fclose(fp); + return false; + } + + const int pixCount = dw * dh; + out->pixels = (uint32_t *)malloc(pixCount * sizeof(uint32_t)); + out->width = dw; + out->height = dh; + + if ((int)fread(out->pixels, sizeof(uint32_t), pixCount, fp) != pixCount) { + free(out->pixels); + out->pixels = 0; + fclose(fp); + return false; + } + + fclose(fp); + return true; +} + +void SpriteUpscaler::saveToDisk(uintptr_t key, uint16_t w, uint16_t h, + const HdSprite *spr) +{ + if (!_diskCacheEnabled) return; + + char path[512]; + makeDiskPath(path, sizeof(path), key, w, h); + + FILE *fp = fopen(path, "wb"); + if (!fp) return; + + int32_t dw = spr->width, dh = spr->height; + fwrite(&dw, 4, 1, fp); + fwrite(&dh, 4, 1, fp); + fwrite(spr->pixels, sizeof(uint32_t), dw * dh, fp); + fclose(fp); +} + +uintptr_t SpriteUpscaler::makeKey(const uint8_t *sprData, uint8_t flags) const { + // Legacy pointer-based key — kept only to keep the symbol; not used. + (void)sprData; (void)flags; + return 0; +} + +// FNV-1a 64-bit. Mixes (decoded indexed bytes, w/h/flags, palette). +// Palette is part of the key so that the same sprite content rendered with +// a different on-screen palette (between screens, fades, cutscenes) gets +// its own cache entry instead of reusing stale colors. +static uintptr_t hashSpriteContent(const uint8_t *buf, int n, + uint16_t w, uint16_t h, uint8_t flags, uint64_t paletteHash) +{ + uint64_t hash = 0xcbf29ce484222325ULL; + for (int i = 0; i < n; ++i) { + hash ^= (uint64_t)buf[i]; + hash *= 0x100000001b3ULL; + } + const uint8_t tail[5] = { + (uint8_t)(w & 0xFF), (uint8_t)(w >> 8), + (uint8_t)(h & 0xFF), (uint8_t)(h >> 8), + (uint8_t)(flags & 3), + }; + for (int i = 0; i < 5; ++i) { + hash ^= (uint64_t)tail[i]; + hash *= 0x100000001b3ULL; + } + for (int i = 0; i < 8; ++i) { + hash ^= (paletteHash >> (i * 8)) & 0xFF; + hash *= 0x100000001b3ULL; + } + return (uintptr_t)hash; +} + +static uint64_t hashPalette(const uint32_t *palette) { + uint64_t hash = 0xcbf29ce484222325ULL; + for (int i = 0; i < 256; ++i) { + const uint32_t v = palette[i]; + for (int b = 0; b < 4; ++b) { + hash ^= (v >> (b * 8)) & 0xFF; + hash *= 0x100000001b3ULL; + } + } + return hash; +} + +SpriteUpscaler::CacheEntry *SpriteUpscaler::findEntry(uintptr_t key) { + const int idx = (int)(key % kHashSize); + CacheEntry *e = _hashTable[idx]; + while (e) { + if (e->key == key) { + e->lastUsed = ++_accessCounter; + return e; + } + e = e->next; + } + return 0; +} + +void SpriteUpscaler::insertEntry(uintptr_t key, const HdSprite &sprite) { + while (_cacheBytes > (size_t)kMaxCacheBytes && _cacheCount > 0) { + evictOldest(); + } + CacheEntry *e = (CacheEntry *)malloc(sizeof(CacheEntry)); + e->key = key; + e->sprite = sprite; + e->lastUsed = ++_accessCounter; + const int idx = (int)(key % kHashSize); + e->next = _hashTable[idx]; + _hashTable[idx] = e; + _cacheCount++; + _cacheBytes += sprite.width * sprite.height * sizeof(uint32_t); +} + +void SpriteUpscaler::evictOldest() { + uint32_t oldest = _accessCounter; + int oldestIdx = -1; + CacheEntry *oldestEntry = 0, *oldestPrev = 0; + + for (int i = 0; i < kHashSize; ++i) { + CacheEntry *prev = 0; + CacheEntry *e = _hashTable[i]; + while (e) { + if (e->lastUsed < oldest) { + oldest = e->lastUsed; + oldestIdx = i; + oldestEntry = e; + oldestPrev = prev; + } + prev = e; + e = e->next; + } + } + if (oldestEntry) { + if (oldestPrev) oldestPrev->next = oldestEntry->next; + else _hashTable[oldestIdx] = oldestEntry->next; + _cacheBytes -= oldestEntry->sprite.width * oldestEntry->sprite.height * sizeof(uint32_t); + free(oldestEntry->sprite.pixels); + free(oldestEntry); + _cacheCount--; + } +} + +void SpriteUpscaler::clearCache() { + for (int i = 0; i < kHashSize; ++i) { + CacheEntry *e = _hashTable[i]; + while (e) { + CacheEntry *next = e->next; + free(e->sprite.pixels); + free(e); + e = next; + } + _hashTable[i] = 0; + } + _cacheCount = 0; + _cacheBytes = 0; + _accessCounter = 0; +} + +void SpriteUpscaler::decodeSprToTemp(const uint8_t *src, int w, int h, uint8_t flags) { + const int size = w * h; + if (size > _tempBufferSize) { + _tempBufferSize = size; + _tempBuffer = (uint8_t *)realloc(_tempBuffer, _tempBufferSize); + } + memset(_tempBuffer, 0, size); + + const bool hflip = (flags & 1) != 0; + int x = 0, y = 0; + while (y < h) { + const uint8_t code = *src++; + const int count = code & 0x3F; + switch (code >> 6) { + case 0: + if (count == 0) break; + for (int i = 0; i < count && x < w; ++i) { + const int px = hflip ? (w - 1 - x) : x; + if (px >= 0 && px < w && y >= 0 && y < h) + _tempBuffer[y * w + px] = *src; + ++src; ++x; + } + break; + case 1: { + const uint8_t color = *src++; + for (int i = 0; i < count && x < w; ++i) { + const int px = hflip ? (w - 1 - x) : x; + if (px >= 0 && px < w && y >= 0 && y < h) + _tempBuffer[y * w + px] = color; + ++x; + } + break; + } + case 2: { int skip = count; if (skip == 0) skip = *src++; x += skip; break; } + case 3: { int lines = count; if (lines == 0) lines = *src++; y += lines; x = *src++; break; } + } + } +} + +const HdSprite *SpriteUpscaler::getOrUpscale( + const uint8_t *sprData, uint16_t spr_w, uint16_t spr_h, + uint8_t flags, const uint32_t *palette) +{ + if (!sprData || spr_w == 0 || spr_h == 0) return 0; + + // Decode SPR first so the cache key can be derived from sprite content + // (heap pointers change every run and would defeat the disk cache). + decodeSprToTemp(sprData, spr_w, spr_h, flags); + const uint64_t paletteHash = palette ? hashPalette(palette) : 0; + const uintptr_t key = hashSpriteContent(_tempBuffer, spr_w * spr_h, + spr_w, spr_h, flags, paletteHash); + + // Check RAM cache + CacheEntry *entry = findEntry(key); + if (entry) return &entry->sprite; + + // Check disk cache + HdSprite diskSprite; + if (loadFromDisk(key, spr_w, spr_h, &diskSprite)) { + insertEntry(key, diskSprite); + return &findEntry(key)->sprite; + } + + // Convert to RGBA + const int srcSize = spr_w * spr_h; + uint32_t *rgba = (uint32_t *)malloc(srcSize * sizeof(uint32_t)); + for (int i = 0; i < srcSize; ++i) { + const uint8_t idx = _tempBuffer[i]; + rgba[i] = (idx == 0) ? 0 : (palette[idx] | 0xFF000000); + } + + // Compute actual output size via scale chain + int first, second; + getScaleChain(_scale, &first, &second); + const int actualScale = first * (second > 1 ? second : 1); + const int dst_w = spr_w * actualScale; + const int dst_h = spr_h * actualScale; + + uint32_t *final_buf = (uint32_t *)malloc(dst_w * dst_h * sizeof(uint32_t)); + xbrz_scaleNx_rgba(rgba, spr_w, spr_h, final_buf, dst_w, dst_h, _scale); + free(rgba); + + // Apply edge smoothing + mlaa_smooth(final_buf, dst_w, dst_h); + + HdSprite sprite; + sprite.pixels = final_buf; + sprite.width = dst_w; + sprite.height = dst_h; + + // Save to disk before inserting to RAM + saveToDisk(key, spr_w, spr_h, &sprite); + insertEntry(key, sprite); + return &findEntry(key)->sprite; +} + +void SpriteUpscaler::upscaleBackground( + const uint8_t *src, int srcW, int srcH, + const uint32_t *palette, uint32_t *dst, int dstW, int dstH) +{ + const int srcSize = srcW * srcH; + uint32_t *rgba = (uint32_t *)malloc(srcSize * sizeof(uint32_t)); + for (int i = 0; i < srcSize; ++i) { + rgba[i] = palette[src[i]] | 0xFF000000; + } + + int first, second; + getScaleChain(_scale, &first, &second); + const int actualScale = first * (second > 1 ? second : 1); + + uint32_t *scaledBuf = dst; + bool needFree = false; + + // If actual scale product != target dimensions, allocate temp and crop + const int actualW = srcW * actualScale, actualH = srcH * actualScale; + if (actualW != dstW || actualH != dstH) { + scaledBuf = (uint32_t *)malloc(actualW * actualH * sizeof(uint32_t)); + needFree = true; + } + + xbrz_scaleNx_rgba(rgba, srcW, srcH, scaledBuf, actualW, actualH, _scale); + free(rgba); + + mlaa_smooth(scaledBuf, actualW, actualH); + + // Copy/crop to destination if needed + if (needFree) { + const int copyW = (actualW < dstW) ? actualW : dstW; + const int copyH = (actualH < dstH) ? actualH : dstH; + memset(dst, 0, dstW * dstH * sizeof(uint32_t)); + for (int y = 0; y < copyH; ++y) { + memcpy(dst + y * dstW, scaledBuf + y * actualW, copyW * sizeof(uint32_t)); + } + free(scaledBuf); + } +} diff --git a/sprite_upscaler.h b/sprite_upscaler.h new file mode 100644 index 0000000..ebe5fc9 --- /dev/null +++ b/sprite_upscaler.h @@ -0,0 +1,93 @@ +/* + * Heart of Darkness engine rewrite + * Sprite upscaling with xBRZ and caching (memory + disk) + */ + +#ifndef SPRITE_UPSCALER_H__ +#define SPRITE_UPSCALER_H__ + +#include "intern.h" + +struct HdSprite { + uint32_t *pixels; + int width; + int height; +}; + +struct SpriteUpscaler { + enum { + kMaxCacheEntries = 8192, + kMaxCacheBytes = 512 * 1024 * 1024 // 512MB RAM + }; + + int _scale; + char _diskCachePath[256]; // e.g. "cache/6x/" + bool _diskCacheEnabled; + + struct CacheEntry { + uintptr_t key; + HdSprite sprite; + uint32_t lastUsed; + CacheEntry *next; + }; + + enum { kHashSize = 2048 }; + CacheEntry *_hashTable[kHashSize]; + int _cacheCount; + size_t _cacheBytes; + uint32_t _accessCounter; + uint8_t *_tempBuffer; + int _tempBufferSize; + + SpriteUpscaler(int scale = 6, const char *cachePath = 0); + ~SpriteUpscaler(); + + void setDiskCache(const char *basePath); + + const HdSprite *getOrUpscale( + const uint8_t *sprData, + uint16_t spr_w, uint16_t spr_h, + uint8_t flags, + const uint32_t *palette + ); + + void upscaleBackground( + const uint8_t *src, int srcW, int srcH, + const uint32_t *palette, + uint32_t *dst, int dstW, int dstH + ); + + void clearCache(); + + // Compute the chained scale factors for any target scale + // e.g. 6 = 3*2, 8 = 4*2, 12 = 4*3, 15 = 5*3, etc. + static void getScaleChain(int scale, int *first, int *second); + +private: + uintptr_t makeKey(const uint8_t *sprData, uint8_t flags) const; + CacheEntry *findEntry(uintptr_t key); + void insertEntry(uintptr_t key, const HdSprite &sprite); + void evictOldest(); + void decodeSprToTemp(const uint8_t *src, int w, int h, uint8_t flags); + + // Disk cache + bool loadFromDisk(uintptr_t key, uint16_t w, uint16_t h, HdSprite *out); + void saveToDisk(uintptr_t key, uint16_t w, uint16_t h, const HdSprite *spr); + void makeDiskPath(char *buf, int bufSize, uintptr_t key, uint16_t w, uint16_t h) const; +}; + +// xBRZ RGBA scaling functions (2x through 5x) +void xbrz_scale2x_rgba(const uint32_t *src, int srcW, int srcH, + uint32_t *dst, int dstPitch); +void xbrz_scale3x_rgba(const uint32_t *src, int srcW, int srcH, + uint32_t *dst, int dstPitch); +void xbrz_scale4x_rgba(const uint32_t *src, int srcW, int srcH, + uint32_t *dst, int dstPitch); +void xbrz_scale5x_rgba(const uint32_t *src, int srcW, int srcH, + uint32_t *dst, int dstPitch); + +// Arbitrary scale via chaining +void xbrz_scaleNx_rgba(const uint32_t *src, int srcW, int srcH, + uint32_t *dst, int dstW, int dstH, int scale); + +#endif // SPRITE_UPSCALER_H__ diff --git a/system.h b/system.h index 3509453..aa94e77 100644 --- a/system.h +++ b/system.h @@ -19,8 +19,6 @@ struct PlayerInput { uint8_t prevMask, mask; - bool skip; - bool exit; bool quit; bool screenshot; @@ -61,6 +59,10 @@ struct System { virtual void sleep(int duration) = 0; virtual uint32_t getTimeStamp() = 0; + virtual int waitForKeyPress() { return -1; } + virtual void applyKeyboardControls(const uint8_t *controls) {} + virtual void copyRectRGBA(int x, int y, int w, int h, const uint32_t *buf, int pitch) {} + virtual void startAudio(AudioCallback callback) = 0; virtual void stopAudio() = 0; virtual void lockAudio() = 0; diff --git a/system_sdl2.cpp b/system_sdl2.cpp index 1471c70..e6b25ea 100644 --- a/system_sdl2.cpp +++ b/system_sdl2.cpp @@ -12,10 +12,6 @@ static const char *kIconBmp = "icon.bmp"; -#ifdef __vita__ -static bool axis[4]= { false, false, false, false }; -#endif - static int _scalerMultiplier = 3; static const Scaler *_scaler = &scaler_xbr; static ScaleProc _scalerProc; @@ -99,6 +95,10 @@ struct System_SDL2 : System { void setupDefaultKeyMappings(); void updateKeys(PlayerInput *inp); void prepareScaledGfx(const char *caption, bool fullscreen, bool widescreen, bool yuv); + + virtual int waitForKeyPress(); + virtual void applyKeyboardControls(const uint8_t *controls); + virtual void copyRectRGBA(int x, int y, int w, int h, const uint32_t *buf, int pitch); }; static System_SDL2 system_sdl2; @@ -612,24 +612,12 @@ void System_SDL2::processEvents() { case SDL_CONTROLLER_AXIS_RIGHTX: if (ev.caxis.value < -kJoystickCommitValue) { pad.mask |= SYS_INP_LEFT; -#ifdef __vita__ - axis[0] = true; - } else if (axis[0]) { - axis[0] = false; -#else } else { -#endif pad.mask &= ~SYS_INP_LEFT; } if (ev.caxis.value > kJoystickCommitValue) { pad.mask |= SYS_INP_RIGHT; -#ifdef __vita__ - axis[1] = true; - } else if (axis[1]) { - axis[1] = false; -#else } else { -#endif pad.mask &= ~SYS_INP_RIGHT; } break; @@ -637,36 +625,15 @@ void System_SDL2::processEvents() { case SDL_CONTROLLER_AXIS_RIGHTY: if (ev.caxis.value < -kJoystickCommitValue) { pad.mask |= SYS_INP_UP; -#ifdef __vita__ - axis[2] = true; - } else if (axis[2]) { - axis[2] = false; -#else } else { -#endif pad.mask &= ~SYS_INP_UP; } if (ev.caxis.value > kJoystickCommitValue) { pad.mask |= SYS_INP_DOWN; -#ifdef __vita__ - axis[3] = true; - } else if (axis[3]) { - axis[3] = false; -#else } else { -#endif pad.mask &= ~SYS_INP_DOWN; } break; -#ifdef __SWITCH__ - case SDL_CONTROLLER_AXIS_TRIGGERRIGHT: - if (ev.caxis.value > 0) { - pad.mask |= SYS_INP_RUN; - } else { - pad.mask &= ~SYS_INP_RUN; - } - break; -#endif } } break; @@ -704,10 +671,8 @@ void System_SDL2::processEvents() { } break; case SDL_CONTROLLER_BUTTON_BACK: - inp.skip = pressed; - break; case SDL_CONTROLLER_BUTTON_START: - inp.exit = pressed; + inp.quit = pressed; break; case SDL_CONTROLLER_BUTTON_DPAD_UP: if (pressed) { @@ -774,7 +739,7 @@ void System_SDL2::startAudio(AudioCallback callback) { _audioCb = callback; SDL_PauseAudio(0); } else { - error("System_SDL2::startAudio() Unable to open sound device"); + warning("System_SDL2::startAudio() Unable to open sound device"); } } @@ -854,6 +819,125 @@ void System_SDL2::updateKeys(PlayerInput *inp) { inp->mask |= pad.mask; } +static uint8_t scancodeToDisplayCode(int scancode) { + if (scancode >= SDL_SCANCODE_A && scancode <= SDL_SCANCODE_Z) { + return 0x41 + (scancode - SDL_SCANCODE_A); + } + if (scancode >= SDL_SCANCODE_1 && scancode <= SDL_SCANCODE_9) { + return 0x31 + (scancode - SDL_SCANCODE_1); + } + if (scancode == SDL_SCANCODE_0) return 0x30; + if (scancode == SDL_SCANCODE_LSHIFT || scancode == SDL_SCANCODE_RSHIFT) return 0x10; + if (scancode == SDL_SCANCODE_LCTRL || scancode == SDL_SCANCODE_RCTRL) return 0x11; + if (scancode == SDL_SCANCODE_LALT || scancode == SDL_SCANCODE_RALT) return 0x12; + // Non-letter "useful" keys: still accepted for binding even if the + // menu's small icon font has no glyph (drawKeyboardKeyCode just skips). + if (scancode == SDL_SCANCODE_SPACE) return 0x13; + if (scancode == SDL_SCANCODE_RETURN) return 0x14; + if (scancode == SDL_SCANCODE_KP_ENTER) return 0x14; + if (scancode == SDL_SCANCODE_TAB) return 0x15; + if (scancode == SDL_SCANCODE_BACKSPACE) return 0x16; + if (scancode == SDL_SCANCODE_LGUI || scancode == SDL_SCANCODE_RGUI) return 0x17; + return 0; +} + +int System_SDL2::waitForKeyPress() { + SDL_Event ev; + int result = 0; + while (result == 0) { + while (SDL_PollEvent(&ev)) { + if (ev.type == SDL_KEYDOWN) { + if (ev.key.keysym.scancode == SDL_SCANCODE_ESCAPE) { + result = -1; + break; + } + // reject direction keys (used for menu navigation) + const int sc = ev.key.keysym.scancode; + if (sc == SDL_SCANCODE_LEFT || sc == SDL_SCANCODE_RIGHT || + sc == SDL_SCANCODE_UP || sc == SDL_SCANCODE_DOWN) { + continue; + } + const uint8_t code = scancodeToDisplayCode(sc); + if (code != 0) { + result = sc; + break; + } + } + if (ev.type == SDL_QUIT) { + inp.quit = true; + result = -1; + break; + } + } + if (result == 0) { + SDL_Delay(10); + } + } + // Snapshot current key state and mark it as "already held" so the menu's + // next processEvents() doesn't see a fresh keyPressed/keyReleased edge + // for keys still down from bind mode (e.g. ESC to cancel, or the SELECT + // key that opened bind mode in the first place). + updateKeys(&inp); + inp.prevMask = inp.mask; + pad.prevMask = pad.mask; + return result; +} + +void System_SDL2::applyKeyboardControls(const uint8_t *controls) { + bool hasKeyboardControls = false; + for (int i = 16; i < 24; ++i) { + if (controls[i] != 0) { + hasKeyboardControls = true; + break; + } + } + // Always start from the original default mappings (LCtrl/F = Run, + // LAlt/G = Jump, LShift/H = Shoot, D/Space = Special, plus arrows + ESC). + // User-bound keys are ADDED on top — otherwise binding one action would + // wipe the default keys for the others, and the menu's select handling + // (which requires SYS_INP_RUN/JUMP/SHOOT) would stop working for the + // remaining actions. + setupDefaultKeyMappings(); + if (!hasKeyboardControls) { + return; + } + static const uint8_t actionMasks[] = { + SYS_INP_RUN, + SYS_INP_JUMP, + SYS_INP_SHOOT, + SYS_INP_SHOOT | SYS_INP_RUN + }; + for (int action = 0; action < 4; ++action) { + for (int slot = 0; slot < 2; ++slot) { + const uint8_t scancode = controls[16 + 2 * action + slot]; + if (scancode != 0) { + addKeyMapping(scancode, actionMasks[action]); + } + } + } +} + +void System_SDL2::copyRectRGBA(int x, int y, int w, int h, const uint32_t *buf, int pitch) { + // For HD mode: blit RGBA directly to renderer via a temporary texture + SDL_Texture *hdTex = SDL_CreateTexture(_renderer, + SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, w, h); + if (hdTex) { + void *texPtr = 0; + int texPitch = 0; + if (SDL_LockTexture(hdTex, 0, &texPtr, &texPitch) == 0) { + for (int j = 0; j < h; ++j) { + memcpy((uint8_t *)texPtr + j * texPitch, + buf + j * pitch, w * sizeof(uint32_t)); + } + SDL_UnlockTexture(hdTex); + } + SDL_RenderClear(_renderer); + SDL_RenderCopy(_renderer, hdTex, 0, 0); + SDL_RenderPresent(_renderer); + SDL_DestroyTexture(hdTex); + } +} + void System_SDL2::prepareScaledGfx(const char *caption, bool fullscreen, bool widescreen, bool yuv) { const int w = _screenW * _scalerMultiplier; const int h = _screenH * _scalerMultiplier; diff --git a/test_all_levels.py b/test_all_levels.py new file mode 100644 index 0000000..2921f9e --- /dev/null +++ b/test_all_levels.py @@ -0,0 +1,279 @@ +#!/usr/bin/env python3 +""" +Heart of Darkness - All levels test in 4K mode. +Starts a separate game instance per level for clean state. +""" + +import socket, json, time, os, subprocess, signal + +SOCKET_BASE = "/tmp/hode-4k-lvl" +GAME_BIN = "./hode" + +LEVELS = [ + (0, "rock", "Canyon"), + (1, "fort", "Fort"), + (2, "pwr1", "Power 1 (Swamp)"), + (3, "isld", "Island"), + (4, "lava", "Lava"), + (5, "pwr2", "Power 2 (Underwater)"), + (6, "lar1", "Lair 1"), + (7, "lar2", "Lair 2"), + (8, "dark", "Dark (Final)"), +] + +DIR_RIGHT = 2 +DIR_LEFT = 8 +ACT_RUN = 1 +ACT_JUMP = 2 +ACT_SHOOT = 4 + +class HodeClient: + def __init__(self, path, timeout=30): + self.path = path + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.buf = b"" + for i in range(timeout * 2): + try: + self.sock.connect(path) + self.sock.settimeout(5.0) + return + except: + time.sleep(0.5) + raise Exception(f"Could not connect to {path}") + + def send(self, cmd): + self.sock.sendall((json.dumps(cmd) + "\n").encode()) + + def recv_line(self): + while True: + if b"\n" in self.buf: + line, self.buf = self.buf.split(b"\n", 1) + return line.decode() + try: + chunk = self.sock.recv(8192) + if not chunk: return None + self.buf += chunk + except socket.timeout: + return None + + def get_state(self): + try: + self.sock.settimeout(5.0) + self.send({"cmd": "get_state"}) + for _ in range(5): + line = self.recv_line() + if line: + try: + obj = json.loads(line) + if "andy" in obj: return obj + except: pass + except: + pass + return {} + + def inject(self, direction=0, action=0, frames=1): + try: + self.send({"cmd": "input", "dir": direction, "act": action, "frames": frames}) + except: pass + + def close(self): + try: self.sock.close() + except: pass + +def start_game(level_num, sock_path): + """Start a game instance for a specific level.""" + try: os.unlink(sock_path) + except: pass + + env = os.environ.copy() + env["SDL_AUDIODRIVER"] = "dummy" + + cmd = [ + "xvfb-run", "-a", "-s", "-screen 0 3840x2880x24", + GAME_BIN, + f"--4k", + f"--hd-cache=./cache", + f"--automation={sock_path}", + f"--level={level_num}", + ] + + proc = subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + return proc + +def stop_game(proc): + try: + proc.terminate() + proc.wait(timeout=3) + except: + try: proc.kill() + except: pass + +def test_level(client, level_num): + """Run gameplay on a level, collect states.""" + states = [] + screens = set() + + moves = [ + (DIR_RIGHT, 0, 1.5), + (DIR_RIGHT, ACT_JUMP, 0.4), + (DIR_RIGHT, ACT_RUN, 2.0), + (DIR_RIGHT, ACT_JUMP, 0.4), + (DIR_RIGHT, 0, 1.5), + (DIR_RIGHT, ACT_RUN, 2.5), + (DIR_RIGHT, ACT_JUMP, 0.4), + (DIR_RIGHT, 0, 2.0), + (DIR_RIGHT, ACT_RUN, 3.0), + (DIR_RIGHT, ACT_JUMP, 0.4), + (DIR_RIGHT, 0, 1.5), + (DIR_LEFT, 0, 1.0), + (DIR_LEFT, ACT_JUMP, 0.4), + (DIR_RIGHT, ACT_RUN, 2.0), + (DIR_RIGHT, ACT_JUMP, 0.4), + (DIR_RIGHT, 0, 2.0), + (DIR_RIGHT, ACT_RUN, 3.0), + (DIR_RIGHT, ACT_SHOOT,1.0), + (DIR_RIGHT, ACT_JUMP, 0.4), + (DIR_RIGHT, 0, 2.0), + ] + + for d, a, dur in moves: + frames = max(1, int(dur / 0.08)) + client.inject(direction=d, action=a, frames=frames) + time.sleep(dur) + s = client.get_state() + if s: + states.append(s) + scr = s.get("screen") + if scr is not None: + screens.add(scr) + + return states, screens + +def main(): + print("=" * 70) + print(" Heart of Darkness - All Levels Test (4K / 3840x2880)") + print("=" * 70) + + os.makedirs("./cache", exist_ok=True) + results = {} + + for level_num, level_id, level_name in LEVELS: + print(f"\n{'='*60}") + print(f" Level {level_num}: {level_name} ({level_id})") + print(f"{'='*60}") + + sock_path = f"{SOCKET_BASE}{level_num}.sock" + proc = None + status = "FAIL" + states = [] + screens = set() + start_state = {} + final_state = {} + + try: + # Start game for this level + print(f" Starting game instance (level {level_num})...") + proc = start_game(level_num, sock_path) + + # Connect + client = HodeClient(sock_path, timeout=30) + time.sleep(2) + + start_state = client.get_state() + andy = start_state.get("andy", {}) + print(f" Start: level={start_state.get('level')} screen={start_state.get('screen')} " + f"pos=({andy.get('x','?')},{andy.get('y','?')})") + + # Play + print(f" Playing...") + states, screens = test_level(client, level_num) + + final_state = client.get_state() + final_andy = final_state.get("andy", {}) + + has_states = len(states) > 0 + has_positions = any( + s.get("andy", {}).get("x", 0) != 0 or s.get("andy", {}).get("y", 0) != 0 + for s in states + ) + status = "OK" if (has_states and has_positions) else "ISSUE" + + print(f" Final: level={final_state.get('level')} screen={final_state.get('screen')} " + f"pos=({final_andy.get('x','?')},{final_andy.get('y','?')}) " + f"checkpoint={final_state.get('checkpoint')}") + + client.close() + + except Exception as e: + print(f" ERROR: {e}") + status = "ERROR" + + finally: + if proc: + stop_game(proc) + try: os.unlink(sock_path) + except: pass + + results[level_id] = { + "level": level_num, + "name": level_name, + "status": status, + "states_collected": len(states), + "screens_seen": sorted(list(screens)), + "start_pos": start_state.get("andy", {}), + "final_pos": final_state.get("andy", {}), + "final_checkpoint": final_state.get("checkpoint"), + } + + print(f" Status: {status} | {len(states)} states | screens: {sorted(list(screens))}") + + # ===== SUMMARY ===== + print(f"\n{'='*70}") + print(f" SUMMARY - 4K Mode (15x scale, 3840x2880)") + print(f"{'='*70}") + print(f" {'#':<3} {'Name':<22} {'Status':<8} {'States':<8} {'Screens':<25} {'Start Pos':<15} {'Final Pos':<15}") + print(f" {'-'*3} {'-'*22} {'-'*8} {'-'*8} {'-'*25} {'-'*15} {'-'*15}") + + all_ok = True + for level_num, level_id, level_name in LEVELS: + r = results.get(level_id, {}) + sp = r.get("start_pos", {}) + fp = r.get("final_pos", {}) + screens_str = str(r.get("screens_seen", [])) + s_pos = f"({sp.get('x','?')},{sp.get('y','?')})" + f_pos = f"({fp.get('x','?')},{fp.get('y','?')})" + st = r.get("status", "?") + if st != "OK": + all_ok = False + print(f" {level_num:<3} {level_name:<22} {st:<8} " + f"{r.get('states_collected',0):<8} {screens_str:<25} {s_pos:<15} {f_pos:<15}") + + print(f"\n Overall: {'ALL PASS' if all_ok else 'SOME ISSUES'}") + + # Check disk cache + cache_files = 0 + cache_size = 0 + for root, dirs, files in os.walk("./cache"): + for f in files: + cache_files += 1 + cache_size += os.path.getsize(os.path.join(root, f)) + + print(f" Disk cache: {cache_files} files, {cache_size / 1024 / 1024:.1f} MB") + + # Save results + output = { + "mode": "4k", + "scale": 15, + "resolution": "3840x2880", + "all_ok": all_ok, + "cache_files": cache_files, + "cache_size_mb": round(cache_size / 1024 / 1024, 1), + "levels": results + } + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "all_levels_4k_results.json") + with open(path, "w") as f: + json.dump(output, f, indent=2) + print(f" Results saved: {path}") + +if __name__ == "__main__": + main() diff --git a/test_combat_bot.py b/test_combat_bot.py new file mode 100644 index 0000000..60dfcdd --- /dev/null +++ b/test_combat_bot.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +""" +Heart of Darkness - Combat bot that fights monsters. +Reads monster positions, dodges attacks, and shoots back. +Tests all levels with actual combat gameplay. +""" + +import socket, json, time, os, subprocess, random + +SOCKET_BASE = "/tmp/hode-bot" +GAME_BIN = "./hode" + +LEVELS = [ + (0, "rock", "Canyon"), + (1, "fort", "Fort"), + (2, "pwr1", "Power 1 (Swamp)"), + (3, "isld", "Island"), + (4, "lava", "Lava"), + (5, "pwr2", "Power 2 (Underwater)"), + (6, "lar1", "Lair 1"), + (7, "lar2", "Lair 2"), + (8, "dark", "Dark (Final)"), +] + +DIR_RIGHT = 2; DIR_LEFT = 8; DIR_UP = 1; DIR_DOWN = 4 +ACT_RUN = 1; ACT_JUMP = 2; ACT_SHOOT = 4 + +class HodeClient: + def __init__(self, path, timeout=30): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.buf = b"" + for i in range(timeout * 2): + try: + self.sock.connect(path) + self.sock.settimeout(3.0) + return + except: + time.sleep(0.5) + raise Exception(f"Could not connect to {path}") + + def send(self, cmd): + self.sock.sendall((json.dumps(cmd) + "\n").encode()) + + def recv_line(self): + while True: + if b"\n" in self.buf: + line, self.buf = self.buf.split(b"\n", 1) + return line.decode() + try: + chunk = self.sock.recv(8192) + if not chunk: return None + self.buf += chunk + except socket.timeout: return None + + def get_state(self): + try: + self.sock.settimeout(3.0) + self.send({"cmd": "get_state"}) + for _ in range(5): + line = self.recv_line() + if line: + try: + obj = json.loads(line) + if "andy" in obj: return obj + except: pass + except: pass + return {} + + def inject(self, direction=0, action=0, frames=1): + try: + self.send({"cmd": "input", "dir": direction, "act": action, "frames": frames}) + except: pass + + def close(self): + try: self.sock.close() + except: pass + +def start_game(level_num, sock_path, hd=True): + try: os.unlink(sock_path) + except: pass + env = os.environ.copy() + env["SDL_AUDIODRIVER"] = "dummy" + cmd = ["xvfb-run", "-a", "-s", "-screen 0 1920x1080x24", + GAME_BIN, f"--automation={sock_path}", f"--level={level_num}"] + if hd: + cmd.append("--hd") + return subprocess.Popen(cmd, env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + +def stop_game(proc): + try: + proc.terminate() + proc.wait(timeout=3) + except: + try: proc.kill() + except: pass + +class CombatBot: + """AI bot that fights monsters and navigates levels.""" + + def __init__(self, client): + self.client = client + self.deaths = 0 + self.kills_est = 0 + self.screens_visited = set() + self.max_checkpoint = 0 + self.frames_played = 0 + self.last_andy_pos = (0, 0) + self.stuck_counter = 0 + + def play(self, duration_sec=60): + """Play for duration_sec seconds with combat AI.""" + start_time = time.time() + + while time.time() - start_time < duration_sec: + state = self.client.get_state() + if not state: + time.sleep(0.2) + continue + + andy = state.get("andy", {}) + monsters = state.get("monsters", []) + screen = state.get("screen", 0) + checkpoint = state.get("checkpoint", 0) + dying = andy.get("dying", False) + has_cannon = andy.get("hasCannon", False) + ax, ay = andy.get("x", 0), andy.get("y", 0) + + self.screens_visited.add(screen) + if checkpoint > self.max_checkpoint: + self.max_checkpoint = checkpoint + + if dying: + self.deaths += 1 + # Wait for respawn + time.sleep(1.0) + self.stuck_counter = 0 + continue + + # Detect if stuck (same position for too long) + if (ax, ay) == self.last_andy_pos: + self.stuck_counter += 1 + else: + self.stuck_counter = 0 + self.last_andy_pos = (ax, ay) + + # Decide action based on monsters and position + direction, action = self.decide(ax, ay, monsters, has_cannon, screen) + + # If stuck, try random movement + if self.stuck_counter > 10: + direction = random.choice([DIR_RIGHT, DIR_LEFT, DIR_UP, DIR_DOWN]) + action = random.choice([0, ACT_JUMP, ACT_RUN]) + self.stuck_counter = 0 + + dur = 0.3 + frames = max(1, int(dur / 0.08)) + self.client.inject(direction=direction, action=action, frames=frames) + self.frames_played += frames + time.sleep(dur) + + def decide(self, ax, ay, monsters, has_cannon, screen): + """Combat AI decision making.""" + if not monsters: + # No monsters: explore right + return DIR_RIGHT, ACT_RUN + + # Find closest monster + closest = None + closest_dist = 999999 + for m in monsters: + mx, my = m.get("x", 0), m.get("y", 0) + dist = abs(mx - ax) + abs(my - ay) + if dist < closest_dist: + closest_dist = dist + closest = m + + if not closest: + return DIR_RIGHT, ACT_RUN + + mx, my = closest.get("x", 0), closest.get("y", 0) + dx = mx - ax + dy = my - ay + + # Combat logic + if closest_dist < 30: + # Very close! Jump away and shoot if possible + escape_dir = DIR_LEFT if dx > 0 else DIR_RIGHT + if has_cannon: + return escape_dir, ACT_JUMP | ACT_SHOOT + else: + return escape_dir, ACT_JUMP + + elif closest_dist < 80: + # Medium range: shoot if we have cannon, otherwise dodge + if has_cannon: + face_dir = DIR_RIGHT if dx > 0 else DIR_LEFT + return face_dir, ACT_SHOOT + else: + # No cannon: run past or jump over + if abs(dy) < 20: + return DIR_RIGHT if dx > 0 else DIR_LEFT, ACT_JUMP + else: + return DIR_RIGHT, ACT_RUN + + else: + # Far away: advance toward right side of level + if dx > 0: + # Monster is to the right, approach cautiously + return DIR_RIGHT, ACT_RUN if closest_dist > 120 else 0 + else: + # Monster is behind, keep moving right + return DIR_RIGHT, ACT_RUN + +def main(): + print("=" * 70) + print(" Heart of Darkness - Combat Bot (HD Mode)") + print("=" * 70) + + results = {} + play_duration = 45 # seconds per level + + for level_num, level_id, level_name in LEVELS: + print(f"\n{'='*60}") + print(f" Level {level_num}: {level_name}") + print(f"{'='*60}") + + sock_path = f"{SOCKET_BASE}{level_num}.sock" + proc = None + + try: + proc = start_game(level_num, sock_path, hd=True) + client = HodeClient(sock_path, timeout=20) + time.sleep(2) + + start = client.get_state() + andy = start.get("andy", {}) + monsters = start.get("monsters", []) + print(f" Start: pos=({andy.get('x')},{andy.get('y')}) " + f"cannon={andy.get('hasCannon')} monsters={len(monsters)}") + + bot = CombatBot(client) + print(f" Fighting for {play_duration}s...") + bot.play(duration_sec=play_duration) + + final = client.get_state() + fa = final.get("andy", {}) + fm = final.get("monsters", []) + + print(f" Final: pos=({fa.get('x')},{fa.get('y')}) screen={final.get('screen')}") + print(f" Deaths: {bot.deaths} | Screens: {sorted(bot.screens_visited)} " + f"| Checkpoint: {bot.max_checkpoint} | Frames: {bot.frames_played}") + + results[level_id] = { + "level": level_num, + "name": level_name, + "deaths": bot.deaths, + "screens_visited": sorted(list(bot.screens_visited)), + "max_checkpoint": bot.max_checkpoint, + "frames_played": bot.frames_played, + "start_pos": {"x": andy.get("x"), "y": andy.get("y")}, + "final_pos": {"x": fa.get("x"), "y": fa.get("y")}, + "final_screen": final.get("screen"), + "status": "OK" + } + + client.close() + + except Exception as e: + print(f" ERROR: {e}") + results[level_id] = {"level": level_num, "name": level_name, "status": "ERROR", "error": str(e)} + + finally: + if proc: stop_game(proc) + try: os.unlink(sock_path) + except: pass + + # Summary + print(f"\n{'='*70}") + print(f" COMBAT BOT SUMMARY") + print(f"{'='*70}") + print(f" {'#':<3} {'Level':<22} {'Deaths':<8} {'Screens':<20} {'Chkpt':<6} {'Final Pos':<15}") + print(f" {'-'*3} {'-'*22} {'-'*8} {'-'*20} {'-'*6} {'-'*15}") + + total_deaths = 0 + total_screens = 0 + for level_num, level_id, level_name in LEVELS: + r = results.get(level_id, {}) + if r.get("status") != "OK": + print(f" {level_num:<3} {level_name:<22} {'ERROR':<8}") + continue + fp = r.get("final_pos", {}) + screens = r.get("screens_visited", []) + total_deaths += r.get("deaths", 0) + total_screens += len(screens) + print(f" {level_num:<3} {level_name:<22} {r.get('deaths',0):<8} " + f"{str(screens):<20} {r.get('max_checkpoint',0):<6} " + f"({fp.get('x','?')},{fp.get('y','?')})") + + print(f"\n Total deaths: {total_deaths}") + print(f" Total screens explored: {total_screens}") + + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "combat_bot_results.json") + with open(path, "w") as f: + json.dump({"results": results, "total_deaths": total_deaths}, f, indent=2) + print(f" Results saved: {path}") + +if __name__ == "__main__": + main() diff --git a/test_hd_replay.py b/test_hd_replay.py new file mode 100644 index 0000000..80caf1e --- /dev/null +++ b/test_hd_replay.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +""" +Replay walkthrough recording in HD mode and compare results. +""" + +import socket, json, time, os + +SOCKET_PATH = "/tmp/hode-hd-test.sock" +RECORDING = os.path.join(os.path.dirname(os.path.abspath(__file__)), "walkthrough_recording.json") + +class HodeClient: + def __init__(self, path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.buf = b"" + for i in range(60): + try: + self.sock.connect(path) + self.sock.settimeout(3.0) + return + except: + time.sleep(0.5) + raise Exception(f"Could not connect to {path}") + + def send(self, cmd): + self.sock.sendall((json.dumps(cmd) + "\n").encode()) + + def recv_line(self): + while True: + if b"\n" in self.buf: + line, self.buf = self.buf.split(b"\n", 1) + return line.decode() + try: + chunk = self.sock.recv(4096) + if not chunk: + return None + self.buf += chunk + except socket.timeout: + return None + + def get_state(self): + self.sock.settimeout(3.0) + self.send({"cmd": "get_state"}) + for _ in range(10): + line = self.recv_line() + if line: + try: + obj = json.loads(line) + if "andy" in obj: + return obj + except: + pass + return {} + + def inject(self, raw=0, direction=0, action=0, frames=1): + cmd = {"cmd": "input", "dir": direction, "act": action, "frames": frames} + if raw: + cmd["raw"] = raw + self.send(cmd) + + def close(self): + self.sock.close() + +def main(): + print("=" * 60) + print(" Heart of Darkness - HD Mode Replay") + print("=" * 60) + + # Load recording + with open(RECORDING) as f: + rec = json.load(f) + + print(f"Recording: {len(rec['seq'])} sequences, {rec['total_frames']} frames") + print(f"Normal mode final: {json.dumps(rec['final'])}") + + client = HodeClient(SOCKET_PATH) + + # Wait for game to start + time.sleep(3) + initial = client.get_state() + print(f"\nHD mode initial: {json.dumps(initial)}") + + # Replay all sequences + print("\n--- Replaying walkthrough in HD mode ---") + states = [] + + for i, seq in enumerate(rec["seq"]): + dur = seq["f"] * 0.08 + client.inject(raw=seq.get("raw", 0), + direction=seq.get("dir", 0), + action=seq.get("act", 0), + frames=seq["f"]) + time.sleep(dur) + + s = client.get_state() + a = s.get("andy", {}) + states.append(s) + + desc = seq.get("d", "?") + print(f" [{i:2d}] {desc:20s} lvl={s.get('level','?')} scr={s.get('screen','?')} " + f"pos=({a.get('x','?'):>4},{a.get('y','?'):>4})") + + # Final state comparison + hd_final = client.get_state() + normal_final = rec["final"] + + print(f"\n--- Comparison ---") + print(f"Normal final: {json.dumps(normal_final)}") + print(f"HD final: {json.dumps(hd_final)}") + + # Compare key fields + na = normal_final.get("andy", {}) + ha = hd_final.get("andy", {}) + match = (na.get("screen") == ha.get("screen") and + na.get("x") == ha.get("x") and + na.get("y") == ha.get("y")) + + if match: + print("\n** MATCH: HD mode produces identical game state! **") + else: + print(f"\n** DIFFERENCE DETECTED **") + print(f" Normal: screen={na.get('screen')} pos=({na.get('x')},{na.get('y')})") + print(f" HD: screen={ha.get('screen')} pos=({ha.get('x')},{ha.get('y')})") + print(" (Minor differences expected due to timing variations in real-time replay)") + + # Save HD results + results = { + "mode": "hd", + "normal_final": normal_final, + "hd_final": hd_final, + "match": match, + "hd_states": states + } + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "hd_replay_results.json") + with open(path, "w") as f: + json.dump(results, f, indent=2) + print(f"\nResults saved: {path}") + + client.close() + print("Done!") + +if __name__ == "__main__": + main() diff --git a/test_walkthrough.py b/test_walkthrough.py new file mode 100644 index 0000000..6ac3fe8 --- /dev/null +++ b/test_walkthrough.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +Heart of Darkness - Automated walkthrough. +Records inputs for HD replay comparison. +""" + +import socket, json, time, os + +SOCKET_PATH = "/tmp/hode-test.sock" + +SYS_INP_JUMP = 0x20 +SYS_INP_SHOOT = 0x40 + +DIR_RIGHT = 2 +ACT_RUN = 1 +ACT_JUMP = 2 + +class HodeClient: + def __init__(self, path): + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.buf = b"" + for i in range(60): + try: + self.sock.connect(path) + self.sock.settimeout(2.0) + return + except: + time.sleep(0.5) + raise Exception(f"Could not connect to {path}") + + def send(self, cmd): + self.sock.sendall((json.dumps(cmd) + "\n").encode()) + + def recv_line(self): + while True: + if b"\n" in self.buf: + line, self.buf = self.buf.split(b"\n", 1) + return line.decode() + try: + chunk = self.sock.recv(4096) + if not chunk: + return None + self.buf += chunk + except socket.timeout: + return None + + def get_state(self): + self.sock.settimeout(2.0) + self.send({"cmd": "get_state"}) + # Read until we get a valid JSON line with "andy" in it + for _ in range(10): + line = self.recv_line() + if line: + try: + obj = json.loads(line) + if "andy" in obj: + return obj + except: + pass + return {} + + def inject(self, raw=0, direction=0, action=0, frames=1): + cmd = {"cmd": "input", "dir": direction, "act": action, "frames": frames} + if raw: + cmd["raw"] = raw + self.send(cmd) + # No response expected + + def close(self): + self.sock.close() + +def main(): + print("=" * 60) + print(" Heart of Darkness - Automated Walkthrough") + print("=" * 60) + + client = HodeClient(SOCKET_PATH) + recording = [] + + def play(desc, raw=0, direction=0, action=0, dur=0.5): + frames = max(1, int(dur / 0.08)) + recording.append({"d": desc, "dir": direction, "act": action, "raw": raw, "f": frames}) + client.inject(raw=raw, direction=direction, action=action, frames=frames) + time.sleep(dur) + + def state(label=""): + s = client.get_state() + a = s.get("andy", {}) + x, y = a.get("x", "?"), a.get("y", "?") + print(f" {label:30s} lvl={s.get('level','?')} scr={s.get('screen','?')} " + f"pos=({str(x):>4},{str(y):>4}) anim={a.get('anim','?')}") + return s + + # ===== MENU ===== + print("\n--- Menu ---") + print(" Loading game...") + time.sleep(3) + state("Initial") + + # Skip cutscene / press jump to start + play("Skip/Jump", raw=SYS_INP_JUMP, dur=0.25) + time.sleep(1.0) + state("After skip") + + # Select Play on title screen + play("Select Play", raw=SYS_INP_JUMP, dur=0.25) + time.sleep(1.5) + state("After Play") + + # Confirm through menu screens + for i in range(10): + play(f"Confirm {i}", raw=SYS_INP_JUMP, dur=0.25) + time.sleep(0.8) + s = state(f"Menu {i}") + a = s.get("andy", {}) + if isinstance(a.get("x"), int) and (a["x"] != 0 or a["y"] != 0): + print(" >> IN GAME!") + break + + # ===== LEVEL 1 ===== + print("\n--- Level 1: Rock Canyon ---") + time.sleep(2) + state("Level start") + + moves = [ + ("Walk right", DIR_RIGHT, 0, 2.0), + ("Jump", DIR_RIGHT, ACT_JUMP, 0.5), + ("Walk right", DIR_RIGHT, 0, 1.5), + ("Run right", DIR_RIGHT, ACT_RUN, 2.0), + ("Jump", DIR_RIGHT, ACT_JUMP, 0.5), + ("Walk right", DIR_RIGHT, 0, 1.0), + ("Run right", DIR_RIGHT, ACT_RUN, 3.0), + ("Jump", DIR_RIGHT, ACT_JUMP, 0.5), + ("Pause", 0, 0, 0.3), + ("Walk right", DIR_RIGHT, 0, 2.5), + ("Jump", DIR_RIGHT, ACT_JUMP, 0.5), + ("Run right", DIR_RIGHT, ACT_RUN, 4.0), + ("Jump", DIR_RIGHT, ACT_JUMP, 0.5), + ("Walk right", DIR_RIGHT, 0, 2.0), + ("Run right", DIR_RIGHT, ACT_RUN, 3.0), + ("Walk right", DIR_RIGHT, 0, 3.0), + ("Jump", DIR_RIGHT, ACT_JUMP, 0.5), + ("Run right", DIR_RIGHT, ACT_RUN, 4.0), + ("Walk right", DIR_RIGHT, 0, 2.0), + ("Jump", DIR_RIGHT, ACT_JUMP, 0.5), + ("Walk right", DIR_RIGHT, 0, 3.0), + ("Run right", DIR_RIGHT, ACT_RUN, 5.0), + ("Jump", DIR_RIGHT, ACT_JUMP, 0.5), + ("Walk right", DIR_RIGHT, 0, 2.0), + ("Run right", DIR_RIGHT, ACT_RUN, 4.0), + ("Walk right", DIR_RIGHT, 0, 3.0), + ("Jump", DIR_RIGHT, ACT_JUMP, 0.5), + ("Run right", DIR_RIGHT, ACT_RUN, 5.0), + ("Walk right", DIR_RIGHT, 0, 4.0), + ("Jump", DIR_RIGHT, ACT_JUMP, 0.5), + ] + + prev_scr = None + for desc, d, a, dur in moves: + play(desc, direction=d, action=a, dur=dur) + s = client.get_state() + andy = s.get("andy", {}) + scr = s.get("screen") + chg = " ** NEW SCREEN **" if scr != prev_scr else "" + prev_scr = scr + x = andy.get("x", "?") + y = andy.get("y", "?") + print(f" {desc:20s} scr={scr} pos=({str(x):>4},{str(y):>4}){chg}") + + if s.get("level", 0) != 0 or s.get("endLevel"): + print(" >> LEVEL END") + break + + # ===== SAVE ===== + final = client.get_state() + print(f"\n--- Final ---") + print(json.dumps(final, indent=2)) + + total = sum(r["f"] for r in recording) + data = {"mode": "normal", "total_frames": total, "final": final, "seq": recording} + path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "walkthrough_recording.json") + with open(path, "w") as f: + json.dump(data, f, indent=2) + print(f"\nSaved {path} ({len(recording)} seq, {total} frames)") + + client.close() + +if __name__ == "__main__": + main() diff --git a/video.cpp b/video.cpp index 7ba83ce..b9e915a 100644 --- a/video.cpp +++ b/video.cpp @@ -10,6 +10,7 @@ static const bool kUseShadowColorLut = false; Video::Video() { + _font = 0; _displayShadowLayer = false; _drawLine.x1 = 0; _drawLine.y1 = 0; From d9885d16f463018b839857ffe211df8b2db634c9 Mon Sep 17 00:00:00 2001 From: hode-hd Date: Sat, 9 May 2026 20:43:11 +0200 Subject: [PATCH 2/3] README.md: comprehensive engine + features documentation Replaces the brief README with a structured reference covering: - macOS / Linux / Windows-WSL build instructions (incl. macOS-specific notes on the libkern/OSByteOrder.h shim, Homebrew SDL2 setup, Gatekeeper, headless mode caveats). - Full CLI flag reference table (--hd, --fullhd, --4k, --hd-scale, --hd-wide, --hd-cache, --prerender, --smooth, --automation, --level, --checkpoint, --datapath, --savepath, --debug, --cheats). - Full hode.ini reference for both [engine] and [display] sections. - Display modes deep-dive: standard, HD upscaling, scale-chain table for every supported factor, widescreen 16:9 borders, smooth 60 Hz interpolated render. - Disk cache and prerender flow: on-disk layout, cache key formula (FNV-1a over decoded SPR bytes + dims + flags + palette hash), why upstream's pointer-keyed cache never produced cross-run hits. - Automation API: protocol, every JSON command (get_state, input, step, screenshot, set_level), reply schema, input bit layout, Python client example, headless testing, bundled test scripts. - Engine architecture: high-level dataflow diagram, core types, HD pipeline, PAF cutscene flow. - Source-file map: original engine files, files added by this fork, files modified vs upstream with the why. - Game world reference: levels, all 25 cutscenes, default key bindings, settings menu (incl. OK/Cancel/Test, one-key-one-action, fallback text labels for Space/Enter/Tab/Backspace/Win), cheat flag bitmask, debug bitmask. - Data formats brief: LVL/SSS/MST/PAF/SETUP/setup.cfg structure. - Troubleshooting, differences from upstream, known limitations, contributing notes, credits, license/legal. --- README.md | 1059 +++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 857 insertions(+), 202 deletions(-) diff --git a/README.md b/README.md index 00f22db..c302e29 100644 --- a/README.md +++ b/README.md @@ -1,310 +1,965 @@ # hode-hd -Enhanced fork of [Gregory Montoir's `hode`](https://github.com/usineur/hode), a -reverse-engineered reimplementation of the engine used by *Heart of Darkness* -(Amazing Studio, 1998). - -This fork keeps the original engine intact and layers on: - -- **HD rendering** — xBRZ sprite/background upscaling at 6x / 8x / 10x / 15x with - on-the-fly chained scalers and MLAA edge smoothing -- **16:9 widescreen** with palette-derived gradient borders -- **Animation interpolation** — optional 60 Hz render with 12.5 Hz logic tick -- **PAF cutscene HD upscaling** with persistent disk cache -- **Sprite & cutscene prerender** — populate the cache up-front so gameplay is - smooth from the first run -- **Programmatic automation API** — drive the game via a Unix-domain JSON - socket (input injection, save state, screenshots, frame stepping) -- **Various menu / input fixes** (keyboard rebinding, OK/Cancel/Test buttons, - default-key fallback so binding one action doesn't kill the others) - -The original is built and tested on Linux/Windows; this fork additionally -builds on macOS (`` shim → ``). +**An HD-capable, scriptable fork of [`usineur/hode`](https://github.com/usineur/hode) — Gregory Montoir's reverse-engineered reimplementation of the engine used by *Heart of Darkness* (Amazing Studio, 1998).** + +The original engine is faithfully preserved: same 12.5 Hz game tick, same `.lvl` / `.sss` / `.mst` / `.paf` data path, same per-level scripted callbacks. This fork adds a configurable presentation pipeline (HD upscaling with chained xBRZ + MLAA, 16:9 widescreen, 60 Hz interpolated render, persistent disk cache, sprite + cutscene prerender), a programmable Unix-socket automation API, and several menu / input / portability fixes. + +``` +$ ./hode --4k --hd-wide --smooth --hd-cache=./cache --prerender +HD mode: 15x scale (3840x2880), 16:9 widescreen, disk cache, prerender +prerender level 0 (rock) [################################] 5320/5320 (100%) 6.9s +``` + +--- + +## Table of contents + +- [At a glance](#at-a-glance) +- [Building](#building) + - [macOS (Apple Silicon & Intel)](#macos-apple-silicon--intel) + - [Linux](#linux) + - [Windows (WSL)](#windows-wsl) + - [Other platforms](#other-platforms) +- [Game data files](#game-data-files) +- [Running](#running) + - [Quick examples](#quick-examples) + - [CLI flag reference](#cli-flag-reference) + - [`hode.ini` reference](#hodeini-reference) +- [Display & rendering modes](#display--rendering-modes) + - [Standard mode](#standard-mode) + - [HD upscaling](#hd-upscaling) + - [Scale chains](#scale-chains) + - [Widescreen 16:9 borders](#widescreen-169-borders) + - [Smooth (60 Hz) animation](#smooth-60-hz-animation) +- [Disk cache & prerender](#disk-cache--prerender) + - [On-disk layout](#on-disk-layout) + - [Cache key](#cache-key) + - [Prerender flow](#prerender-flow) +- [Automation API](#automation-api) + - [Protocol](#protocol) + - [Commands](#commands) + - [Python client](#python-client) + - [Headless testing](#headless-testing) +- [Test scripts](#test-scripts) +- [Engine architecture](#engine-architecture) + - [High-level dataflow](#high-level-dataflow) + - [Core types](#core-types) + - [HD pipeline](#hd-pipeline) + - [PAF cutscene flow](#paf-cutscene-flow) +- [Source file map](#source-file-map) +- [Game world reference](#game-world-reference) + - [Levels](#levels) + - [Cutscenes](#cutscenes) + - [Default key bindings](#default-key-bindings) + - [Settings menu](#settings-menu) + - [Cheat flags](#cheat-flags) + - [Debug bitmask](#debug-bitmask) +- [Data formats (brief)](#data-formats-brief) +- [Save state](#save-state) +- [Troubleshooting](#troubleshooting) +- [Differences from upstream](#differences-from-upstream) +- [Known limitations](#known-limitations) +- [Contributing](#contributing) +- [Credits](#credits) +- [License & legal](#license--legal) + +--- + +## At a glance + +| Area | Feature | +|---|---| +| 🖼️ Rendering | Chained xBRZ at 6× / 8× / 10× / 15× / arbitrary 2..16; MLAA edge smoothing; per-screen background cached once | +| 📺 Widescreen | 16:9 framebuffer with palette-derived gradient borders (no fake gameplay) | +| ⏱️ Smoothness | Decoupled 60 Hz render with 12.5 Hz game tick, sprite-position interpolation | +| 🎬 Cutscenes | PAF frames upscaled and cached to disk; `--prerender` warms the cache fast-forward | +| 🚀 Prerender | Single unified console progress bar covers every sprite (incl. per-screen background animations) and the in-gameplay PAF clips for the level | +| 🤖 Automation | Unix-domain JSON socket: `get_state`, `input`, `step`, `screenshot`, `set_level` — perfect for AI bots and replay harnesses | +| 🧰 Menu/input | Bind any letter, digit, modifier or **Space / Enter / Tab / Backspace / Win**; OK / Cancel / Test row works; one key = one action; defaults stay live alongside custom binds | +| 🍎 macOS | Builds out of the box on Apple Silicon and Intel Macs (added `` shim) | +| 💾 Cache | Content-hashed key (FNV-1a over SPR bytes + dims + flags + palette hash) — survives across runs *and* palette changes | --- ## Building -### Prerequisites +The project uses a hand-written GNU `Makefile`. There is one external dependency: **SDL2**. C++11 is the language standard; warnings are turned up (`-Wall -Wextra -Wpedantic`) but the build is warning-free on macOS clang and Linux gcc. -- SDL2 development libraries -- A C++11 compiler (gcc/clang) +Generic build, any POSIX: ```bash -# macOS -brew install sdl2 - -# Debian/Ubuntu -sudo apt install libsdl2-dev +make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" ``` -### Compile +Outputs `./hode` (~800 KB, statically links no extras besides SDL2). + +`make clean` removes objects (`*.o`) and dependency files (`*.d`). + +For an optimised build: ```bash -make -j$(nproc 2>/dev/null || sysctl -n hw.ncpu) +make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" \ + CPPFLAGS='-O2 -std=c++11 -Wall -Wextra -Wno-unused-parameter -Wpedantic -MMD' ``` -### Game data files +### macOS (Apple Silicon & Intel) -Place the following in the working directory (originals from the *Heart of -Darkness* CD/Demo): +This fork includes the changes required to build cleanly on macOS — upstream `hode` fails to compile on macOS because clang's libc doesn't provide a glibc-style ``. The fix lives in [`intern.h`](intern.h): -- `HOD.PAF` (~411 MB cutscene video) -- `SETUP.DAT` -- `*_HOD.LVL`, `*_HOD.SSS`, `*_HOD.MST` for each of 9 levels +```cpp +#elif defined(__APPLE__) + #include + #define le16toh(x) OSSwapLittleToHostInt16(x) + #define le32toh(x) OSSwapLittleToHostInt32(x) + #define htole16(x) OSSwapHostToLittleInt16(x) + #define htole32(x) OSSwapHostToLittleInt32(x) + static const bool kByteSwapData = (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__); +``` -See `RELEASES.yaml` for the upstream-tested data versions. +Steps for a fresh macOS install: + +1. **Install Xcode Command Line Tools** (provides clang/git): + ```bash + xcode-select --install + ``` +2. **Install SDL2** via Homebrew: + ```bash + brew install sdl2 + ``` + This places `sdl2-config` on `PATH` and `SDL2.framework` / `libSDL2*.dylib` where the linker can find them. The Makefile uses `sdl2-config --cflags --libs`, so no extra plumbing is needed. +3. **Build**: + ```bash + make -j"$(sysctl -n hw.ncpu)" + ``` +4. **Run** (data files in the same directory): + ```bash + ./hode --hd + ``` + +If `brew` is in a non-standard prefix (e.g. `/opt/homebrew` on Apple Silicon), make sure `/opt/homebrew/bin` is on your `PATH` so `sdl2-config` is found: ---- +```bash +echo 'export PATH="/opt/homebrew/bin:$PATH"' >> ~/.zprofile +source ~/.zprofile +``` -## Running +For a debug build with `lldb`: -### Display modes +```bash +make clean && make -j"$(sysctl -n hw.ncpu)" CPPFLAGS='-g -O0 -std=c++11 -Wall -Wextra -Wno-unused-parameter -Wpedantic -MMD' +lldb -- ./hode --hd +``` + +#### macOS-specific notes -| Flag | Resolution | Notes | -|------|-----------:|-------| -| (none) | 256×192 (×3 default) | Original engine path | -| `--hd` | 1536×1152 (6x) | xBRZ chain 3×2 | -| `--fullhd` | 2048×1536 (8x) | xBRZ chain 4×2 | -| `--4k` | 3840×2880 (15x) | xBRZ chain 4×4 then crop | -| `--hd-scale=N` | 256N × 192N | Custom scale (clamped to 2–16) | -| `--hd-wide` | 16:9 | Palette-derived gradient borders | -| `--smooth` | — | 60 Hz interpolated render, 12.5 Hz logic | +| Concern | Notes | +|---|---| +| Window display backend | SDL2 uses Metal via SDL_RENDERER_ACCELERATED; nothing extra required. | +| Endianness | `` is the macOS-blessed equivalent of ``. The fork's `intern.h` selects the right header per platform. | +| Hardened runtime / Gatekeeper | The Makefile produces an unsigned binary. The first launch from Finder may show a "cannot be opened" dialog — right-click → Open, or run from Terminal. No entitlements are required. | +| Audio | Audio uses SDL's CoreAudio backend by default. Set `SDL_AUDIODRIVER=dummy` to disable for headless / CI use. | +| Headless / CI | macOS has no `Xvfb` equivalent that's worth fighting. Use `SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy` on macOS for non-interactive runs (still requires a display server unless you patch out `SDL_INIT_VIDEO`). For real headless CI, run the Linux build inside a container with `xvfb-run`. | +| Apple Silicon | The build is native arm64 with clang's auto-vectorisation; xBRZ scalers benefit. No Rosetta required. | +| Bundle / `.app` | This fork ships a CLI binary, not a `.app` bundle. If you want a real app bundle (icon in dock, drag-to-Applications), wrap with [`appify`](https://gist.github.com/mathiasbynens/674099) or build one with `Info.plist`; the engine itself doesn't care. | -Combined example: +A clean build on macOS is normally a few seconds: + +```text +$ make clean && make -j$(sysctl -n hw.ncpu) 2>&1 | tail -3 +c++ -g -std=c++11 -Wall -Wextra -Wno-unused-parameter -Wpedantic `sdl2-config --cflags` -MMD -c -o video.o video.cpp +c++ -o hode andy.o automation_api.o ... `sdl2-config --libs` +``` + +### Linux ```bash -./hode --4k --hd-wide --smooth --hd-cache=./cache --prerender +# Debian / Ubuntu / WSL +sudo apt install build-essential libsdl2-dev pkg-config + +# Fedora / RHEL +sudo dnf install gcc-c++ SDL2-devel + +# Arch +sudo pacman -S base-devel sdl2 + +make -j"$(nproc)" ``` -### Disk cache +For headless CI: ```bash -./hode --hd --hd-cache=./cache +SDL_AUDIODRIVER=dummy xvfb-run -a ./hode --automation=/tmp/hode.sock --hd ``` -Stores upscaled sprites and PAF cutscene frames under -`cache/x/`. First run is slow (everything upscales on demand), later -runs are instant cache hits. +### Windows (WSL) -### Prerender +Use WSL2 + an X server (e.g. WSLg, included in Windows 11 by default): ```bash -./hode --hd --hd-cache=./cache --prerender +sudo apt install build-essential libsdl2-dev pkg-config +make -j"$(nproc)" ``` -After each level loads, walk every sprite frame (main + per-screen background -animations) **and** the relevant in-gameplay PAF clips (Canyon-with-cannon -falling, Canyon falling, Island falling, plus the level intro cutscene). All -frames go into the disk cache with a single unified console progress bar: +Native Windows builds (MinGW / MSYS2) are not currently maintained in this fork; the Makefile assumes POSIX, but the original code is portable — patches welcome. -``` -prerender level 0 (rock) [############################...] 4380/4720 (92%) 3.7s eta 0.3s -``` +### Other platforms + +The upstream tree ships `system_psp.cpp` and `system_wii.cpp` (PSP / Wii Homebrew) but they are not wired into this `Makefile` — only `system_sdl2.cpp` is built. The HD compositor and disk-cache changes target SDL2 by design and would need backend work to run on PSP / Wii. + +--- + +## Game data files + +You must own the original *Heart of Darkness* PC release (full or demo). Place these next to the binary or pass `--datapath=PATH`: -Subsequent runs skip everything that's already cached. +| File | Description | Approx. size | +|---|---|---:| +| `HOD.PAF` | All cutscenes (Packed Animation File container) | ~411 MB | +| `SETUP.DAT` | Strings, fonts, hint images, loading-screen art, sound bank metadata | ~5.5 MB | +| `ROCK_HOD.LVL` … `DARK_HOD.LVL` | Per-level geometry, sprite tables, screen layout | 0.7 – 5 MB ×9 | +| `ROCK_HOD.SSS` … `DARK_HOD.SSS` | Sound script (SssBank/SssSample) for each level | 3 – 7 MB ×9 | +| `ROCK_HOD.MST` … `DARK_HOD.MST` | Monster + scripting tables | 10 KB – 150 KB ×9 | -### Start at a specific level +PSX disc data (`*.dax`, MDEC backgrounds, SPU ADPCM sounds) is also supported by the original engine. The HD upscaler works for both PC and PSX paths. + +`RELEASES.yaml` (carried over from upstream) lists SHA-1 hashes of every game version the engine has been validated against (French / German / English Win32, demos, PSX, etc.). If you suspect bad data files, compare them against the entries there. + +> ⚠️ The data files are **not redistributed** — bring your own from your CD / store purchase. + +--- + +## Running + +### Quick examples ```bash -./hode --level=3 # Island (numeric index) -./hode --level=rock # Canyon (by name) -./hode --checkpoint=2 # Combine with --level for resume points +# Vanilla 256×192 windowed +./hode + +# HD (1536×1152) at default 6× scale +./hode --hd + +# 4K with 16:9 borders, 60 Hz interpolation, persistent cache, prerender first run +./hode --4k --hd-wide --smooth --hd-cache=./cache --prerender + +# Skip menu, jump to Island level, checkpoint 2, in HD +./hode --level=isld --checkpoint=2 --hd + +# Custom 9× scale (2304×1728) +./hode --hd-scale=9 + +# Scriptable mode — opens a Unix socket and disables menu/loading/cutscenes +./hode --automation=/tmp/hode.sock --hd + +# Read data files from elsewhere +./hode --datapath=/Volumes/HOD-CD --savepath=~/Library/Application\ Support/hode ``` -Level names: `rock`, `fort`, `pwr1`, `isld`, `lava`, `pwr2`, `lar1`, `lar2`, -`dark` (corresponding to indices 0..8). +### CLI flag reference + +All flags use the GNU long form (`--name` or `--name=value`); there are no short flags. -### Configuration file (`hode.ini`) +| Flag | Argument | Default | Effect | +|---|---|---|---| +| `--datapath=PATH` | path | `.` | Directory containing `HOD.PAF`, `SETUP.DAT`, level files | +| `--savepath=PATH` | path | `.` | Directory for `setup.cfg` and screenshots | +| `--level=NUM` | int 0–8 or name (`rock`, `fort`, `pwr1`, `isld`, `lava`, `pwr2`, `lar1`, `lar2`, `dark`) | — | Skip menu and start at given level | +| `--checkpoint=NUM` | int | 0 | Checkpoint within `--level` | +| `--debug=MASK` | int | 0 | Bitmask OR'd into `g_debugMask` (see [Debug bitmask](#debug-bitmask)) | +| `--cheats=MASK` | int | 0 | Bitmask OR'd into cheat flags (see [Cheat flags](#cheat-flags)) | +| `--hd` | — | off | Enable HD compositor at default 6× scale | +| `--hd-scale=N` | int 2–16 | — | HD compositor at custom scale; implies `--hd` | +| `--fullhd` | — | off | Shortcut: HD at 8× (2048×1536) | +| `--4k` | — | off | Shortcut: HD at 15× (3840×2880; internally 16× cropped) | +| `--hd-wide` | — | off | 16:9 framebuffer with palette-gradient borders; also forces SDL window to 16:9 aspect | +| `--hd-cache=PATH` | path | — | Read/write upscaled sprites + PAF frames under `PATH/x/` | +| `--prerender` | — | off | Pre-fill sprite + relevant PAF caches per level (slow first run, instant after) | +| `--smooth` | — | off | 60 Hz render with sprite-position interpolation, 12.5 Hz game logic | +| `--automation=PATH` | unix-socket | — | Open a JSON automation socket; also disables menu, loading screen and cutscenes for fast scripted boot | + +> CLI flags **override** matching `hode.ini` keys. + +### `hode.ini` reference + +Place a `hode.ini` next to the binary (or in `--datapath`) for persistent configuration: ```ini [engine] -disable_paf = false -disable_menu = false -difficulty = 1 -frame_duration = 80 +disable_paf = false ; skip cutscenes (auto-true if HOD.PAF missing) +disable_mst = false ; skip monster scripting (debugging only) +disable_sss = false ; skip sound script (debugging only) +disable_menu = false ; boot straight into the game +max_active_sounds = 16 ; SSS object pool cap +difficulty = 1 ; 0=easy, 1=normal, 2=hard +frame_duration = 80 ; ms per game tick (default 80 = 12.5 Hz) +loading_screen = true ; show "please wait" image during level load [display] -scale_factor = 3 -fullscreen = false -widescreen = false -hd_mode = true -hd_scale = 6 -hd_widescreen = true -hd_cache = ./cache -smooth_anim = true -automation_socket = /tmp/hode.sock +scale_factor = 3 ; integer upscaler factor (legacy non-HD path) +scale_algorithm = nearest ; nearest | linear | xbr | nearest+blur | … +gamma = 1.0 ; output gamma (1.0 = neutral) +fullscreen = false +widescreen = false ; legacy 16:9 path (blur-stretch). Prefer hd_widescreen. +hd_mode = false ; same as --hd +hd_scale = 0 ; same as --hd-scale=N (0 = default 6) +hd_widescreen = false ; same as --hd-wide +hd_cache = ./cache ; same as --hd-cache +smooth_anim = false ; same as --smooth +automation_socket = /tmp/hode.sock ; same as --automation +``` + +Legend: + +- Booleans accept `true`/`false`, `t`/`f`, `1`/`0` (case-insensitive). +- `disable_paf` is auto-set to `true` if the engine can't open `HOD.PAF`. +- Lines starting with `#` are comments. Blank lines are tolerated. + +--- + +## Display & rendering modes + +### Standard mode + +`./hode` (no flags) gives you the upstream behaviour: 256×192 indexed-colour framebuffer, integer-upscaled `scale_factor` times by SDL through the chosen software scaler. Behaviour and visuals match upstream `hode` exactly. + +### HD upscaling + +`--hd`, `--fullhd`, `--4k`, `--hd-scale=N` route every visible pixel through `HdCompositor` (in `hd_compositor.{h,cpp}`): + +- Background bitmap is upscaled **once per screen change** and reused. +- Each sprite is upscaled **once per (content + dimensions + flip + palette) tuple** and cached. +- The HD compositor framebuffer is RGBA32; SDL2 streams it to a `SDL_Texture` per frame. + +### Scale chains + +xBRZ scales by 2×, 3×, 4× or 5× per pass; arbitrary scales are decomposed into two passes: + +| `--hd-scale` / preset | First pass | Second pass | Output (256×192 →) | +|---:|:--|:--|:--| +| 2 | 2× | — | 512×384 | +| 3 | 3× | — | 768×576 | +| 4 | 4× (= 2×·2×) | — | 1024×768 | +| 5 | 5× (nearest) | — | 1280×960 | +| **6** (`--hd`) | 3× | 2× | 1536×1152 | +| 7 | 4× | 2× → crop | 1792×1344 | +| **8** (`--fullhd`) | 4× | 2× | 2048×1536 | +| 9 | 3× | 3× | 2304×1728 | +| **10** | 4× | 3× → crop | 2560×1920 | +| 11 | 4× | 3× → crop | 2816×2112 | +| 12 | 4× | 3× | 3072×2304 | +| 13 | 4× | 4× → crop | 3328×2496 | +| 14 | 4× | 4× → crop | 3584×2688 | +| **15** (`--4k`) | 4× | 4× → SDL downscale | 3840×2880 | +| 16 | 4× | 4× | 4096×3072 | + +After the chained scale, [`mlaa_smooth()`](edge_smooth.cpp) runs a morphological-edge-AA pass that softens stair-stepping artefacts on diagonal edges (cape, hair, plasma traces). + +### Widescreen 16:9 borders + +`--hd-wide` switches the HD framebuffer from 4:3 to 16:9. The 4:3 game stays centred; left and right margins are filled with a vertical gradient sampled from the top and bottom rows of the **current screen's palette**, then darkened to ~⅓ brightness so it reads as ambient atmosphere instead of fake gameplay. The borders re-sample whenever the screen changes, so they track lighting/mood per area. + +This fork additionally **resizes the SDL window itself to 16:9** when `--hd-wide` is on. Without that change (upstream behaviour), the wide framebuffer is squished into a 4:3 window and the colored borders are clipped to invisibility. + +### Smooth (60 Hz) animation + +`--smooth` decouples logic from rendering: + +```text + ┌───── 12.5 Hz ─────┐ ┌───── ~60 Hz ─────┐ + │ game tick │ │ render frame │ + │ ‒ AI / physics │ │ ‒ interpolate │ + │ ‒ sprite list │ ──► │ prev → curr │ + │ ‒ snapshot pos │ │ ‒ blit to HD │ + └───────────────────┘ └──────────────────┘ ``` -CLI flags override `hode.ini` settings. +- Game tick stays at the original 80 ms (12.5 Hz). All collision, AI, sound, scripted callbacks behave exactly as upstream. +- `Game::saveInterpolationState()` snapshots `(prev, curr)` for Andy and every visible sprite at each tick. +- `Game::renderInterpolatedFrame(t)` — called at the render rate — blends each sprite's previous and current position with `t ∈ [0,1]`. + +The result is fluid on-screen motion with zero impact on gameplay timing. + +--- + +## Disk cache & prerender + +`--hd-cache=PATH` makes every upscaled artifact persistent. + +### On-disk layout + +``` +PATH/ +├── 6x/ ← sprites at the active scale +│ └── spr__x.raw ← header (int32 w, int32 h) + w*h*4 RGBA bytes +├── 8x/ +└── paf/ + ├── 6x/ + │ ├── v00/ ← cutscene 0 (intro) + │ │ ├── f0000.raw + │ │ ├── f0001.raw + │ │ └── … + │ ├── v22/ ← Canyon Andy-falling-with-cannon + │ └── v24/ ← Island Andy-falling + └── 8x/ +``` + +Each `x/` directory is independent — switching `--hd-scale=10` populates `cache/10x/` without touching `cache/6x/`. + +### Cache key + +``` +key = FNV-1a-64( + decoded indexed bytes (w×h) + ⊕ w little-endian uint16 + ⊕ h little-endian uint16 + ⊕ flags & 3 ← horizontal-flip bit + ⊕ palette FNV-1a-64 +) +``` + +Including the **palette hash** is the reason the same sprite content rendered with different on-screen palettes (between screens, cross-fade, cutscenes) gets distinct cache entries. Without that, low scales (HD/FullHD) would occasionally show "wrong-coloured" sprite frames as a stale entry was reused after a palette transition. + +> Note: this changes the on-disk format compared to upstream and earlier builds of this fork. **Delete any pre-existing `cache/` directory** before the first run with palette-hashed keys. + +### Prerender flow + +`--prerender` populates the cache up-front, **once per level** (tracked via a 32-bit bitmask `Game::_hdPrerenderedMask`). After `Game::restartLevel()` finishes loading the level and before the gameplay loop starts: + +1. **Sprite phase** — walk every `(sprite-type, frame, flip)` tuple across: + - the main level table `_resLevelData0x2988PtrTable[0..31]` + - every screen's `backgroundLvlObjectDataTable[0..7]` (the per-screen background animations: trees, water, ambient effects). Without this, those animations only upscaled the first time they appeared on-screen. +2. **PAF phase** — fast-forward decode the small in-gameplay clips (Canyon falling with cannon, Canyon falling, Island falling) plus the level's intro cutscene (`_cutscenes[level]`). Each not-yet-cached cutscene is decoded full-speed without audio/display/sleep so the existing HD frame callback writes every frame to disk. +3. Both phases share **one progress bar** sized by the total work (sprite frames + PAF frames): + ``` + prerender level 0 (rock) [############################....] 4720/5320 (88%) 5.2s eta 0.7s + ``` + +The `_playedMask` tracking "watched cutscenes" is snapshotted/restored across each PAF prerender so your save file isn't polluted with movies you didn't actually watch. After the bar completes, the engine pumps SDL once and re-baselines `inp.prevMask = inp.mask` so any keys held during prerender don't fire phantom press/release events on the first gameplay tick. --- ## Automation API -```bash -./hode --automation=/tmp/hode.sock +`--automation=/tmp/hode.sock` opens a non-blocking `AF_UNIX` `SOCK_STREAM` listening for one client at a time. Newline-delimited JSON in both directions. + +`--automation` implies: + +- `disable_menu = true` — boot straight into gameplay +- `loading_screen = false` — no "please wait" +- `disable_paf = true` — cutscenes skipped + +### Protocol + +- One JSON object per line, terminated with `\n`. +- The server only responds to `get_state` and `screenshot` (a JSON header line, then for screenshot the raw RGB bytes). Other commands are fire-and-forget. +- Backpressure: writes are blocking; the client should drain replies promptly. +- Connection drops are detected on next `read()`; the engine continues running and re-`accept()`s. + +### Commands + +| `cmd` | Body fields | Reply | Effect | +|---|---|---|---| +| `get_state` | – | JSON object (see below) | Snapshot Andy + monsters + level/checkpoint/screen | +| `input` | `dir` (UDLR mask), `act` (RUN/JUMP/SHOOT mask), `frames` (int), `raw` (override for raw `SYS_INP_*` mask) | – | Inject input for `frames` ticks | +| `step` | `count` (int) | – | Enable step mode and step `count` ticks; gameplay blocks between batches | +| `screenshot` | – | header line then `width*height*3` raw RGB bytes | Capture the 256×192 framebuffer | +| `set_level` | `level` (0–8), `checkpoint` (int) | – | Break out of the current level loop and restart at the given level/checkpoint | + +`get_state` reply schema (truncated for clarity): + +```json +{ + "andy": { + "x": 128, "y": 96, "screen": 0, + "anim": 0, "frame": 0, "sprite": 0, + "hasCannon": true, "dying": false + }, + "level": 0, "checkpoint": 0, "screen": 0, + "endLevel": false, + "monsters": [ + {"x": 200, "y": 100, "type": 1, "i": 0}, + {"x": 220, "y": 100, "type": 2, "i": 0} + ], + "monsterCount": 2 +} ``` -Programmatic control via a Unix-domain socket with a small JSON protocol. -Useful for headless testing, AI bots, and replay capture. +Input bit layout: + +| Bit | `dir` | `act` | Raw `SYS_INP_*` | +|---:|:--|:--|:--| +| 0x01 | UP | RUN | `SYS_INP_UP=0x01`, `SYS_INP_RUN=0x10` | +| 0x02 | RIGHT | JUMP | `SYS_INP_RIGHT=0x02`, `SYS_INP_JUMP=0x20` | +| 0x04 | DOWN | SHOOT | `SYS_INP_DOWN=0x04`, `SYS_INP_SHOOT=0x40` | +| 0x08 | LEFT | – | `SYS_INP_LEFT=0x08` | +| 0x80 | – | – | `SYS_INP_ESC=0x80` (raw only) | -### Python example +### Python client ```python -import socket, json +import socket, json, time s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect("/tmp/hode.sock") +f = s.makefile("rwb", buffering=0) -s.sendall(b'{"cmd":"get_state"}\n') -state = json.loads(s.recv(4096).decode()) +def send(j): f.write((json.dumps(j) + "\n").encode()) +def recv_line(): return json.loads(f.readline().decode()) -# Inject input — direction + action bits, optional frame count -s.sendall(b'{"cmd":"input","dir":2,"act":1,"frames":10}\n') # walk right + run +# State snapshot +send({"cmd": "get_state"}) +state = recv_line() +print(state["andy"], "monsters:", state["monsterCount"]) -# Step a single frame -s.sendall(b'{"cmd":"step","count":1}\n') +# Walk right + run for 30 ticks +send({"cmd": "input", "dir": 2, "act": 1, "frames": 30}) -# Capture a screenshot -s.sendall(b'{"cmd":"screenshot"}\n') +# Frame-by-frame step for 10 ticks +send({"cmd": "step", "count": 10}) -# Jump to a level/checkpoint -s.sendall(b'{"cmd":"set_level","level":3,"checkpoint":0}\n') -``` - -### Input bit constants +# Screenshot — header + raw RGB +send({"cmd": "screenshot"}) +hdr = recv_line() # {"width":256,"height":192,"format":"rgb","size":N} +img = b"" +while len(img) < hdr["size"]: + img += s.recv(4096) +open("frame.rgb", "wb").write(img) -``` -Direction: UP=1, RIGHT=2, DOWN=4, LEFT=8 -Action: RUN=1, JUMP=2, SHOOT=4 -Raw SYS_INP bits: UP=0x01, RIGHT=0x02, DOWN=0x04, LEFT=0x08, - RUN=0x10, JUMP=0x20, SHOOT=0x40, ESC=0x80 +# Jump to a specific level +send({"cmd": "set_level", "level": 3, "checkpoint": 0}) ``` ### Headless testing ```bash +# Linux SDL_AUDIODRIVER=dummy xvfb-run -a ./hode --automation=/tmp/hode.sock --hd + +# macOS (still needs a window server; for true headless use the Linux build in CI) +SDL_AUDIODRIVER=dummy ./hode --automation=/tmp/hode.sock ``` -### Bundled test scripts +--- + +## Test scripts + +Drop-in Python drivers for the automation API. All require a running `./hode --automation=/tmp/hode.sock`. | Script | Purpose | -|--------|---------| -| `test_walkthrough.py` | Automated level-1 walkthrough | -| `test_all_levels.py` | Per-level smoke loop | -| `test_combat_bot.py` | AI bot that fights monsters | -| `test_hd_replay.py` | HD vs normal-mode comparison | +|---|---| +| [`test_walkthrough.py`](test_walkthrough.py) | Drive Andy through level 1 end-to-end with scripted inputs. Useful as a smoke test after engine changes. | +| [`test_all_levels.py`](test_all_levels.py) | Boot each of the 9 levels, verify gameplay starts, optionally take a screenshot. | +| [`test_combat_bot.py`](test_combat_bot.py) | Reactive AI: query monsters via `get_state`, fire when in range. Demonstrates `input` + `get_state` polling. | +| [`test_hd_replay.py`](test_hd_replay.py) | Compare HD vs SD rendering frame-by-frame. | --- -## HD rendering pipeline +## Engine architecture + +### High-level dataflow + +```text +┌──────────────────── main.cpp ────────────────────┐ +│ parse CLI / hode.ini → readConfigIni() │ +│ open SETUP.DAT → Resource::loadSetupDat │ +│ init SDL2/audio → System::init / setupAudio│ +│ if --hd : new HdCompositor │ +│ if --automation : new AutomationApi │ +│ loop: │ +│ Game::loadSetupCfg │ +│ apply keymap │ +│ Menu::mainLoop (unless --automation) │ +│ Game::mainLoop(level, checkpoint) │ +└──────────────────────────────────────────────────┘ + │ + ▼ +┌──────────────────── Game ────────────────────────┐ +│ Resource (.lvl/.sss/.mst/.paf parsers) │ +│ Video (256×192 indexed framebuffer + palette) │ +│ PafPlayer (cutscene streaming) │ +│ Mixer (software audio mix) │ +│ HdCompositor* (optional HD path) │ +│ AutomationApi* (optional socket server) │ +│ per-level callbacks (level1_rock.cpp .. level9) │ +└──────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ System (abstract) │ ← System_SDL2 (this build) + │ │ System_PSP, System_Wii (legacy) + └─────────────────────┘ + │ + ▼ + SDL2 (Metal/OpenGL/D3D) +``` +### Core types + +| Type | File | Responsibility | +|---|---|---| +| `Game` | [`game.h`](game.h) / [`game.cpp`](game.cpp) | Orchestrator. Holds Video / Resource / PafPlayer / HdCompositor / AutomationApi pointers; ticks the level main loop; per-level callbacks live in `level1_rock.cpp` .. `level9_dark.cpp`. | +| `Resource` | [`resource.h`](resource.h) | Owns LVL / SSS / MST parsers, sprite tables (`_resLevelData0x2988PtrTable[32]`, `_resLvlScreenBackgroundDataTable[40]`), font/loading-screen images, palettes. | +| `Video` | [`video.h`](video.h) / [`video.cpp`](video.cpp) | Legacy 256-colour framebuffer with `_frontLayer`, `_backgroundLayer`, `_shadowLayer`. Owns the palette + font. | +| `PafPlayer` | [`paf.h`](paf.h) / [`paf.cpp`](paf.cpp) | Streams the proprietary Packed Animation File (per-frame indexed bitmap + delta encoding + ADPCM audio). | +| `HdCompositor` | [`hd_compositor.h`](hd_compositor.h) / [`hd_compositor.cpp`](hd_compositor.cpp) | New: HD framebuffer, multi-resolution presets, 16:9 borders, prerender driver. | +| `SpriteUpscaler` | [`sprite_upscaler.h`](sprite_upscaler.h) / [`sprite_upscaler.cpp`](sprite_upscaler.cpp) | New: chained xBRZ scalers + content-hashed RAM/disk cache. | +| `AutomationApi` | [`automation_api.h`](automation_api.h) / [`automation_api.cpp`](automation_api.cpp) | New: Unix socket JSON server. | +| `System` | [`system.h`](system.h) | Abstract platform — input, audio, screen, key mapping. `System_SDL2` is the active backend. | + +### HD pipeline + +```text +Original logic (256×192, 8-bit indexed) + │ + ▼ drawScreen() collects sprite list (z-sorted) + │ + ▼ HdCompositor::beginFrame(bgLayer, palette, screenNum, bgId) + │ ‒ expand palette 6→8-bit + │ ‒ if screen changed: SpriteUpscaler::upscaleBackground() + │ ‒ memcpy _hdBackground → _hdFramebuffer + │ + ▼ for each sprite: + │ SpriteUpscaler::getOrUpscale(sprData, w, h, flags, palette) + │ ‒ decodeSprToTemp(): RLE → indexed + │ ‒ key = FNV-1a(indexed + dims + flags + palette hash) + │ ‒ RAM lookup → disk lookup → upscale chain → MLAA → cache + │ blitHdSprite(): alpha-test composite into _hdFramebuffer + │ + ▼ HdCompositor::endFrame() + │ ‒ if widescreen: compositeWidescreen() draws gradient borders + │ + ▼ System_SDL2::copyRectRGBA(): SDL_Texture stream → SDL_RenderCopy → SDL_RenderPresent ``` -Game logic (256×192, 8-bit indexed) - │ - ▼ drawScreen() emits sprite list - │ - ▼ HdCompositor intercepts each sprite - │ • Decode SPR -> indexed - │ • Convert to RGBA via palette - │ • Upscale via chained xBRZ (e.g. 3x then 2x = 6x) - │ • Apply MLAA edge smoothing - │ • Cache (RAM LRU + disk, content-hash key) - │ - ▼ Composite into HD framebuffer - │ • Background upscaled once per screen change - │ • Sprites blitted at scaled positions - │ • Optional 16:9 borders (palette-derived gradient) - │ - ▼ SDL2 renders RGBA to display + +### PAF cutscene flow + +```text +HOD.PAF + │ one container, k cutscenes, indexed by uint32 LE offset + ▼ +PafPlayer::preload(num) + │ read sub-header, allocate 4 page buffers + demux blocks + ▼ +PafPlayer::mainLoop() ← _prerenderMode skips audio/display/sleep + │ per frame: + │ ‒ read N file blocks into demux buffers (video or ADPCM audio) + │ ‒ decodeVideoFrame() → write current page buffer + │ ‒ callback(frame) → gamePafFrameCallback (game.cpp) + │ ├── (legacy) g_system->copyRect to 256×192 layer + │ └── (HD) cache hit? → present + │ else upscale → save to disk → present + │ ‒ if !prerender: setPalette + updateScreen + sleep frameMs + ▼ +PafPlayer::unload() ``` -### xBRZ scale chains +`PafPlayer::prerender(num)` = `_prerenderMode = true; play(num); _prerenderMode = false;` with `_playedMask` snapshot/restore so prerender doesn't pollute the save's "watched cutscenes" record. -``` -6x = 3x · 2x 12x = 4x · 3x -8x = 4x · 2x 15x = 4x · 4x → crop -9x = 3x · 3x 16x = 4x · 4x -``` +--- -### Disk cache layout +## Source file map + +### Original engine (preserved) + +| File(s) | Responsibility | +|---|---| +| `andy.cpp` | Player state machine / animation FSM | +| `benchmark.cpp` | CPU benchmark used during the loading screen | +| `defs.h` | On-disk struct layouts (`Lvl*`, `Sss*`, `Mst*`, `SetupConfig`, …) | +| `fileio.cpp/h` | Buffered binary file reader | +| `fs.h`, `fs_posix.cpp`, `fs_android.cpp` | Filesystem abstraction | +| `intern.h` | LE byte-swap helpers; this fork adds a macOS branch | +| `level.h`, `level1_rock.cpp` … `level9_dark.cpp` | Per-level scripted callbacks (`callLevel_initialize`, `callLevel_postScreenUpdate`, …) | +| `lzw.cpp` | LZW decompressor used for some assets in v11 data | +| `mdec.cpp/h`, `mdec_coeffs.h` | PSX MDEC video decoder for cutscene + background overlays | +| `menu.cpp/h` | Settings menus, cutscene replay screen, controls bind | +| `mixer.cpp/h` | Software audio mixer | +| `monsters.cpp` | Monster AI / animation tables | +| `paf.cpp/h` | PAF cutscene format | +| `random.cpp/h` | Game logic PRNG | +| `resource.cpp/h` | LVL / SSS / MST loader, sprite tables, font loader | +| `scaler.h`, `scaler_xbr.cpp` | Legacy software scalers (the small-window path) | +| `screenshot.cpp/h` | PNG dump (BMP fallback) | +| `sound.cpp` | SSS interpreter (script-driven sound triggers) | +| `staticres.cpp` | Baked-in tables (cutscene mappings, font character indices) | +| `system.h` | Abstract platform interface | +| `system_sdl2.cpp` | Active SDL2 backend (input, audio, screen, key mapping) | +| `system_psp.cpp`, `system_wii.cpp` | Legacy backends, **not built** by this Makefile | +| `util.cpp/h` | Debug print, error helpers | +| `video.cpp/h` | Legacy framebuffer + palette | + +### Added by this fork + +| File(s) | Responsibility | +|---|---| +| `hd_compositor.cpp/h` | HD framebuffer; multi-resolution presets; 16:9 widescreen with palette borders; prerender driver; unified progress bar | +| `sprite_upscaler.cpp/h` | xBRZ chained scalers (`2x`, `3x`, `4x = 2x·2x`, `5x ≈ nearest`, `Nx via decompose`); RAM LRU + disk cache with FNV-1a content key | +| `edge_smooth.cpp/h` | MLAA edge smoothing | +| `automation_api.cpp/h` | Unix-socket JSON server; input injection; step mode; screenshot | +| `test_walkthrough.py` | Level 1 walkthrough using the automation API | +| `test_all_levels.py` | Per-level boot smoke test | +| `test_combat_bot.py` | Reactive combat AI | +| `test_hd_replay.py` | HD vs SD comparison harness | + +### Modified vs upstream + +| File | Why | +|---|---| +| `Makefile` | Add the four new `.cpp` sources to `SRCS` | +| `intern.h` | macOS `` shim | +| `main.cpp` | New CLI flags + INI keys; wire `_video->_font` after `loadSetupDat()` | +| `game.cpp/h` | HD compositor begin/end-frame hooks; smooth-anim 60 Hz interpolated render loop; per-level sprite + PAF prerender driver with shared progress bar | +| `paf.cpp/h` | HD frame callback path; `_prerenderMode` skips audio/display/sleep; `peekFramesCount(num)` for sizing the unified progress bar | +| `menu.cpp/h` | Keyboard binding screen overhaul (OK/Cancel/Test, one-key-one-action, Space/Enter/Tab/Backspace/Win bindable, fallback labels); font init guard | +| `system.h` | Expose `applyKeyboardControls` and `waitForKeyPress` on the abstract interface | +| `system_sdl2.cpp` | Default mappings stay live alongside user keys; `waitForKeyPress` clears edge state on return; `copyRectRGBA` for HD presents | +| `video.cpp` | Zero-init `_font` so the menu can't dereference garbage before `Game::mainLoop` wires it | -``` -cache/ - 6x/ sprites at 6x - spr__x.raw header + RGBA pixels - paf/ - 6x/ - v00/ cutscene 0 - f0000.raw - f0001.raw - ... -``` +--- -The sprite cache key is a content hash (FNV-1a over decoded indexed bytes + -sprite dimensions + flip flags + palette hash) so cached entries persist -correctly across runs and palette changes. +## Game world reference + +### Levels + +| # | Code | Display name | Notes | +|---:|:--|:--|:--| +| 0 | `rock` | Canyon | Tutorial / first level | +| 1 | `fort` | Fort | | +| 2 | `pwr1` | Power 1 | The Swamp | +| 3 | `isld` | Island | | +| 4 | `lava` | Lava | | +| 5 | `pwr2` | Power 2 | Underwater | +| 6 | `lar1` | Lair 1 | | +| 7 | `lar2` | Lair 2 | | +| 8 | `dark` | Dark | Final level (single checkpoint) | + +Level callbacks dispatch from `Game::callLevel_*` into the `levelN_.cpp` matching the player's current level (`_currentLevel`). + +### Cutscenes + +The 25 PAF videos in `HOD.PAF`: + +| # | Symbol | Purpose | +|---:|:--|:--| +| 0 | `kPafAnimation_intro` | Game intro | +| 1 | `kPafAnimation_cine14l` | Cinematic transition | +| 2 | `kPafAnimation_rapt` | Cinematic | +| 3 | `kPafAnimation_glisse` | Slide | +| 4 | `kPafAnimation_meeting` | Meeting cinematic | +| 5 | `kPafAnimation_island` | Island intro | +| 6 | `kPafAnimation_islefall` | Falling on Island | +| 7 | `kPafAnimation_vicious` | | +| 8 | `kPafAnimation_together` | | +| 9 | `kPafAnimation_power` | | +| 10 | `kPafAnimation_back` | | +| 11 | `kPafAnimation_dogfree1` | | +| 12 | `kPafAnimation_dogfree2` | | +| 13 | `kPafAnimation_meteor` | | +| 14 | `kPafAnimation_cookie` | | +| 15 | `kPafAnimation_plot` | | +| 16 | `kPafAnimation_puzzle` | | +| 17 | `kPafAnimation_lstpiece` | | +| 18 | `kPafAnimation_dogfall` | | +| 19 | `kPafAnimation_lastfall` | | +| 20 | `kPafAnimation_end` | | +| 21 | `kPafAnimation_cinema` | | +| 22 | `kPafAnimation_CanyonAndyFallingCannon` | In-gameplay: Canyon, falls with cannon + helmet | +| 23 | `kPafAnimation_CanyonAndyFalling` | In-gameplay: Canyon, falls without cannon | +| 24 | `kPafAnimation_IslandAndyFalling` | In-gameplay: Island falling | + +The per-level intro cutscene is taken from the table `_cutscenes[] = { 0, 2, 4, 5, 6, 8, 10, 14, 19 }` (one entry per level 0..8). + +### Default key bindings + +| Key | Action | +|---|---| +| Arrow keys | Move (Up = climb, Down = crouch) | +| Left Ctrl, F | Run | +| Left Alt, G, Enter | Jump | +| Left Shift, H | Shoot | +| D, Space | Special (Run + Shoot — fires plasma cannon if equipped) | +| Esc | Pause / quit menu | +| S | Screenshot | + +### Settings menu + +`Menu → Settings → Keyboard Controls` lets you bind any of Run / Jump / Shoot / Special: + +- **Letters, digits, Shift, Ctrl, Alt** – have icon glyphs in the engine's bitmap font. +- **Space, Enter, Tab, Backspace, Cmd/Win** – also bindable; menu shows short text labels (`SP`, `EN`, `TB`, `BS`, `WN`) since the bitmap font has no glyphs for them. +- **Two slots per action** – the first bind goes into slot 1, the second into slot 2. +- **One key, one action** – binding a key already used by another action automatically clears the prior binding. +- **Defaults stay live** – `LCtrl`, `F`, `LAlt`, `G`, `LShift`, `H`, `D`, `Space` remain mapped to their defaults alongside any custom keys, so the in-menu Select handler always works while you're rebinding the rest. +- **OK / Cancel / Test row** – navigable via ←/→. Select on **OK** keeps changes, **Cancel** reverts to the controls snapshot taken when you entered the screen, **Test** enters a live-key visualisation where action icons light up as you press their keys (any arrow key exits Test). +- Esc inside the bind prompt cancels the bind only — it doesn't propagate to the outer menu and won't quit the game. + +### Cheat flags + +`--cheats=N` (or runtime `cheats` integer) is a bitmask: + +| Bit | Symbol | Effect | +|---:|:--|:--| +| `1 << 0` | `kCheatSpectreFireballNoHit` | Spectre fireballs don't hit Andy | +| `1 << 1` | `kCheatOneHitPlasmaCannon` | One-shot kill with plasma cannon | +| `1 << 2` | `kCheatOneHitSpecialPowers` | One-shot kill with special powers | +| `1 << 3` | `kCheatWalkOnLava` | Walk on lava without dying | +| `1 << 4` | `kCheatGateNoCrush` | Gates won't crush Andy | +| `1 << 5` | `kCheatLavaNoHit` | Lava doesn't damage | +| `1 << 6` | `kCheatRockShadowNoHit` | Shadow monsters in rock don't damage | + +### Debug bitmask + +`--debug=N` is a bitmask, OR'd into the global `g_debugMask`: + +| Bit | Symbol | Output | +|---:|:--|:--| +| `1 << 0` | `kDebug_GAME` | Game / level state transitions | +| `1 << 1` | `kDebug_RESOURCE` | Resource loader | +| `1 << 2` | `kDebug_ANDY` | Andy state machine | +| `1 << 3` | `kDebug_SOUND` | SSS interpreter | +| `1 << 4` | `kDebug_PAF` | PAF cutscene player | +| `1 << 5` | `kDebug_MONSTER` | Monster AI | +| `1 << 6` | `kDebug_SWITCHES` | `lar1` and `lar2` switches | +| `1 << 7` | `kDebug_MENU` | Menu state machine | + +`--debug=255` enables everything. --- -## Levels - -| Index | Code | Name | -|------:|:-------|:------------| -| 0 | rock | Canyon | -| 1 | fort | Fort | -| 2 | pwr1 | Power 1 (Swamp) | -| 3 | isld | Island | -| 4 | lava | Lava | -| 5 | pwr2 | Power 2 (Underwater) | -| 6 | lar1 | Lair 1 | -| 7 | lar2 | Lair 2 | -| 8 | dark | Dark (Final)| - -## Default key bindings - -| Key | Action | -|-------------|--------| -| Arrow keys | Move | -| Left Ctrl / F | Run | -| Left Alt / G / Enter | Jump | -| Left Shift / H | Shoot | -| D / Space | Special (Run + Shoot) | -| Escape | Menu / quit | -| S | Screenshot | - -The defaults stay active even after you bind custom keys in the settings menu -— custom keys are *additive* so the menu's action shortcuts still work while -you're rebinding the rest. +## Data formats (brief) + +The original `Heart of Darkness` data files are well-defined binary formats; full reverse-engineering notes live in the upstream `hode` source as struct comments. A summary: + +### `*_HOD.LVL` + +- 4-byte tag `0x4D5A4448` ('HDZM') at offset 0. +- 4 bytes header counts: `screensCount`, `staticLvlObjectsCount`, `otherLvlObjectsCount`, `spritesCount`. +- `_screensGrid[N][4]` at 0x08: per-screen up/right/down/left neighbour table. +- `_screensBasePos[N]` at 0xA8: per-screen world coordinate. +- `_screensState[N]` at 0x1E8: per-screen flags (0..3). +- `_resLvlScreenObjectDataTable[]` at 0x288: 96-byte `LvlObject` entries. +- Sprite type table at `_lvlSpritesOffset = 0x288 + 96 * (96 or 104)`, 32 × 16-byte entries (each describes a `LvlObjectData` data island). +- Background table at `_lvlBackgroundsOffset = _lvlSpritesOffset + 32*16`, 40 × 16-byte entries. + +### `*_HOD.SSS` + +- Sound-script bytecode interpreted by `sound.cpp`. `SssBank`, `SssSample`, `SssPcm`, `SssPreloadList`, `SssPreloadInfoData` structures defined in `resource.h`. +- ADPCM sample data (PSX) or PCM samples (PC) referenced by offset. + +### `*_HOD.MST` + +- Monster + scripting tables. Op codes interpreted by `executeMstCode()` in `monsters.cpp`. + +### `HOD.PAF` + +- Container of N cutscenes, each indexed by a uint32 LE offset at the start. +- Each cutscene starts with a fixed signature `Packed Animation File V1.0\n(c) 1992-96 Amazing Studio\n`. +- Header at offset `+0x84..0xAC`: `framesCount`, `frameDuration`, `startOffset`, `preloadFrameBlocksCount`, `readBufferSize`, `maxVideoFrameBlocksCount`, `maxAudioFrameBlocksCount`, `frameBlocksCount`. +- Followed by `frameBlocksCountTable[framesCount]`, `framesOffsetTable[framesCount]`, `frameBlocksOffsetTable[frameBlocksCount]`. +- Each frame is delta-encoded against the previous; 4-page rotation buffer; 4 op codes (0..3) for partial vs full blits and palette updates. +- Audio (when present) is 22 kHz ADPCM in interleaved blocks. + +### `SETUP.DAT` + +- Versioned (10 or 11). Header has counts (`iconsCount`, `menusCount`, `cutscenesCount`, `levelsCount`, `levelCheckpointsCount[8]`, `yesNoQuitImage`, `soundDataSize`, `loadingImageSize`). +- Followed by aligned blocks: loading image (with palette), font (1024 × 16-byte glyphs), menu/options bitmaps, hint images, sound metadata. + +### `setup.cfg` + +- 212-byte `SetupConfig` struct (`defs.h:55`) — 4 player slots × 52 bytes (progress, level, checkpoint, cutscenes mask, controls, difficulty, stereo, volume, last-level), plus `currentPlayer` and a checksum. --- -## Files added by this fork +## Save state -| File | Purpose | -|------|---------| -| `automation_api.h/cpp` | Unix-socket server, JSON commands, monster data exposure | -| `hd_compositor.h/cpp` | HD framebuffer, 16:9 borders, resolution presets, prerender driver | -| `sprite_upscaler.h/cpp`| xBRZ upscaling + content-hash RAM/disk cache | -| `edge_smooth.h/cpp` | MLAA edge smoothing for upscaled sprites | -| `test_*.py` | Automation API regression scripts | +`setup.cfg` is the engine's binary save file. It tracks per player slot: + +- Per-level progress (highest checkpoint reached) +- Last-played level + checkpoint +- Watched-cutscenes bitmask +- 32 bytes of controls (16 bytes joystick, 8 bytes keyboard scancodes, 8 unused) +- Difficulty, stereo, volume, last-level + +Up to 4 player slots. Selected via the menu. + +--- -## Files modified vs upstream +## Troubleshooting -`Makefile`, `game.cpp/h`, `intern.h`, `main.cpp`, `menu.cpp/h`, `paf.cpp/h`, -`system.h`, `system_sdl2.cpp`, `video.cpp`. +| Symptom | Cause / fix | +|---|---| +| `fatal error: 'endian.h' file not found` (macOS) | Use this fork's `intern.h` (it has the macOS `__APPLE__` branch). `git pull && make clean && make`. | +| First-run 4K is sluggish | Expected: every sprite/cutscene frame upscales once. Use `--hd-cache=./cache --prerender`; the second run is instant. | +| Widescreen "borders are still black" | Use `--hd-wide` (not `--widescreen`). `--widescreen` is the legacy blur-stretch path. | +| Cutscenes display on screen during `--prerender` | Should not happen with this fork. If it does, you're running an older build — `make clean && make`. | +| Bound a key in the menu but in-game it does nothing | Custom keys are *additive*; defaults still work. Verify that nothing else captured the key (e.g. macOS Cmd+Space, OS-level hotkeys). | +| Game freezes on the loading screen with `--prerender` | Should not happen post-fix; the engine pumps SDL after prerender. If it does happen, build with `make clean && make` and rerun. | +| `setup.cfg` is corrupt or has a weird default keymap | Delete `setup.cfg` — the engine recreates a default one. | +| Audio is silent / distorted | Set `SDL_AUDIODRIVER=dummy` to disable, or `coreaudio` (macOS) / `pulseaudio` (Linux) to force a backend. | +| `Repository not found` when pushing to a fork | Make sure the GitHub fork exists (https://github.com/<user>/hode) before `git push`. | + +--- + +## Differences from upstream + +A high-level diff vs `usineur/hode` master: + +- **New modules** — `hd_compositor`, `sprite_upscaler`, `edge_smooth`, `automation_api`, plus four Python automation drivers. +- **New CLI flags** — `--hd`, `--hd-scale`, `--hd-wide`, `--hd-cache`, `--fullhd`, `--4k`, `--smooth`, `--automation`, `--prerender`. +- **New INI keys** — `hd_mode`, `hd_scale`, `hd_widescreen`, `hd_cache`, `smooth_anim`, `automation_socket`. +- **Sprite cache key** changed from heap pointer to FNV-1a(content + dims + flags + palette hash). The upstream pointer-keyed disk cache effectively never produced cross-run hits. +- **Widescreen window sizing** — `--hd-wide` now also sizes the SDL window 16:9 (otherwise the wide framebuffer was squashed back into a 4:3 window and gradient borders were invisible). +- **Palette-correct beginFrame** — `HdCompositor::beginFrame()` now expands the engine's 6-bit palette to 8-bit instead of treating it as already 8-bit, so border colours and HD sprite colours are not ~4× too dim. +- **PAF HD path** with disk cache and a fast-forward `prerender(num)` that skips audio / display / sleep. +- **Smooth animation** — 60 Hz interpolated render with 12.5 Hz logic. +- **Menu / input fixes** — OK / Cancel / Test sub-buttons, one-key-one-action enforcement, Space / Enter / Tab / Backspace / Cmd-Win bindable, default keys stay live alongside custom keys, font-pointer init hardened, edge state cleared on `waitForKeyPress`. +- **macOS portability** — `intern.h` selects `` on `__APPLE__`. + +--- + +## Known limitations + +- The HD compositor only supports the SDL2 backend. PSP / Wii are not HD-capable in this fork. +- `--4k` is internally 16× cropped to 15× to fit the 4K UHD frame; visible artifacts are below 1 px. +- Cutscene prerender at 4K can use multiple GB of disk per cutscene. For the full PAF set, prefer `--hd` or `--fullhd`. +- The legacy `--widescreen` (blur-stretch) is preserved for parity but `--hd-wide` is preferred when the HD compositor is on. +- The automation API supports one client at a time. + +--- + +## Contributing + +1. Fork the repo on GitHub. +2. Branch off `master`: + ```bash + git checkout -b feature/ + ``` +3. Make changes; follow the existing code style: + - Tabs for indentation, K&R braces + - `snake_case` for free functions, `lowerCamelCase` for methods, `_member` for fields + - Plain C++11 — no STL containers in hot paths; raw arrays + `malloc`/`free` are fine +4. Build cleanly with `-Wall -Wextra -Wpedantic` (no new warnings). +5. Commit with a descriptive message and open a PR against this fork or `usineur/hode`. --- ## Credits -- Original engine reverse-engineered by **Gregory Montoir** (cyx@users.sourceforge.net), - see [`usineur/hode`](https://github.com/usineur/hode). -- Original game *Heart of Darkness* by **Amazing Studio** (1998). +- **Original engine** — reverse-engineered by **Gregory Montoir** (cyx@users.sourceforge.net). See [`usineur/hode`](https://github.com/usineur/hode), upstream `README.txt`, and `CHANGES.txt` (preserved in this tree). +- **Original game** — *Heart of Darkness* by **Amazing Studio** (1998), published by Infogrames / Ocean. +- **xBRZ-style scaling** — based on Zenju's xBRZ algorithm (https://sourceforge.net/projects/xbrz/). +- **MLAA edge smoothing** — classic anti-aliasing technique (Reshetov 2009 / Jimenez et al.). + +External links: + +- [MobyGames: Heart of Darkness](https://www.mobygames.com/game/heart-of-darkness) +- [heartofdarkness.ca](http://heartofdarkness.ca/) — fan resource +- [usineur/hode upstream](https://github.com/usineur/hode) + +--- + +## License & legal -## Links +The engine source is provided under the same terms as upstream `hode` (no explicit LICENSE file in upstream — treat it as "use at your own risk; please credit the original author"). The HD additions in this fork inherit the same terms. -- [MobyGames page](https://www.mobygames.com/game/heart-of-darkness) -- [heartofdarkness.ca](http://heartofdarkness.ca/) +The *Heart of Darkness* game data (`HOD.PAF`, `SETUP.DAT`, `*_HOD.*`) is **copyrighted by Amazing Studio / Infogrames** and is **not redistributed** here. You must own a legitimate copy of the original game to play. From 5645285fe07141f32efaa7c133aa027adf89f659 Mon Sep 17 00:00:00 2001 From: hode-hd Date: Sat, 9 May 2026 20:51:21 +0200 Subject: [PATCH 3/3] README: polish to a GitHub-front-page document - Promotes README.md to the canonical README and removes the bare README.txt (GitHub already prefers .md, but having two is confusing and README.txt was the upstream plain-text version). - Adds badges, a centered hero block, a 2-column highlight matrix, GitHub-flavoured admonition callouts (NOTE/TIP/IMPORTANT/CAUTION/ WARNING), collapsible
sections for the long lists (macOS notes, source-file map, cutscene table, data formats), and a styled footer. - Restructures with emoji-prefixed sections so the GitHub TOC reads like a proper docs landing page. - Tightens the macOS build section (Apple Silicon vs Intel, brew PATH for /opt/homebrew, lldb debug-build recipe, headless caveats). - Standardises code block language hints and table alignment. --- README.md | 773 ++++++++++++++++++++++++++++------------------------- README.txt | 65 ----- 2 files changed, 412 insertions(+), 426 deletions(-) delete mode 100644 README.txt diff --git a/README.md b/README.md index c302e29..9517c18 100644 --- a/README.md +++ b/README.md @@ -1,176 +1,189 @@ -# hode-hd +
-**An HD-capable, scriptable fork of [`usineur/hode`](https://github.com/usineur/hode) — Gregory Montoir's reverse-engineered reimplementation of the engine used by *Heart of Darkness* (Amazing Studio, 1998).** +# 🌑 hode-hd -The original engine is faithfully preserved: same 12.5 Hz game tick, same `.lvl` / `.sss` / `.mst` / `.paf` data path, same per-level scripted callbacks. This fork adds a configurable presentation pipeline (HD upscaling with chained xBRZ + MLAA, 16:9 widescreen, 60 Hz interpolated render, persistent disk cache, sprite + cutscene prerender), a programmable Unix-socket automation API, and several menu / input / portability fixes. +**An HD-capable, scriptable fork of the [`hode`](https://github.com/usineur/hode) engine for *Heart of Darkness* (Amazing Studio, 1998).** -``` +[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20WSL-blue)](#building) +[![Language](https://img.shields.io/badge/C%2B%2B-11-blue.svg)](https://en.cppreference.com/w/cpp/11) +[![Backend](https://img.shields.io/badge/SDL2-2.x-informational)](https://www.libsdl.org/) +[![Status](https://img.shields.io/badge/build-passing-brightgreen)](#building) +[![Upstream](https://img.shields.io/badge/upstream-usineur%2Fhode-lightgrey)](https://github.com/usineur/hode) + +*Faithful to the original 12.5 Hz logic. Modern presentation, persistent cache, scriptable from Python.* + +
+ +--- + +```bash $ ./hode --4k --hd-wide --smooth --hd-cache=./cache --prerender HD mode: 15x scale (3840x2880), 16:9 widescreen, disk cache, prerender -prerender level 0 (rock) [################################] 5320/5320 (100%) 6.9s +prerender level 0 (rock) [████████████████████████████████] 5320/5320 (100%) 6.9s ``` +The original engine logic is **preserved exactly** — same 12.5 Hz tick, same `.lvl` / `.sss` / `.mst` / `.paf` data path, same per-level scripted callbacks. This fork adds a **configurable presentation pipeline** on top: chained-xBRZ HD upscaling with MLAA edge smoothing, 16:9 widescreen with palette-aware borders, decoupled 60 Hz interpolated render, persistent disk cache, sprite + cutscene prerender, plus a programmable Unix-socket automation API and a handful of menu / input / portability fixes. + +> [!IMPORTANT] +> **Game data not included.** You must own the original *Heart of Darkness* PC release. Place `HOD.PAF`, `SETUP.DAT`, and the per-level files next to the binary or pass `--datapath=PATH`. + --- -## Table of contents +## ✨ Highlights -- [At a glance](#at-a-glance) -- [Building](#building) - - [macOS (Apple Silicon & Intel)](#macos-apple-silicon--intel) - - [Linux](#linux) - - [Windows (WSL)](#windows-wsl) - - [Other platforms](#other-platforms) -- [Game data files](#game-data-files) -- [Running](#running) - - [Quick examples](#quick-examples) - - [CLI flag reference](#cli-flag-reference) - - [`hode.ini` reference](#hodeini-reference) -- [Display & rendering modes](#display--rendering-modes) - - [Standard mode](#standard-mode) - - [HD upscaling](#hd-upscaling) - - [Scale chains](#scale-chains) - - [Widescreen 16:9 borders](#widescreen-169-borders) - - [Smooth (60 Hz) animation](#smooth-60-hz-animation) -- [Disk cache & prerender](#disk-cache--prerender) - - [On-disk layout](#on-disk-layout) - - [Cache key](#cache-key) - - [Prerender flow](#prerender-flow) -- [Automation API](#automation-api) - - [Protocol](#protocol) - - [Commands](#commands) - - [Python client](#python-client) - - [Headless testing](#headless-testing) -- [Test scripts](#test-scripts) -- [Engine architecture](#engine-architecture) - - [High-level dataflow](#high-level-dataflow) - - [Core types](#core-types) - - [HD pipeline](#hd-pipeline) - - [PAF cutscene flow](#paf-cutscene-flow) -- [Source file map](#source-file-map) -- [Game world reference](#game-world-reference) - - [Levels](#levels) - - [Cutscenes](#cutscenes) - - [Default key bindings](#default-key-bindings) - - [Settings menu](#settings-menu) - - [Cheat flags](#cheat-flags) - - [Debug bitmask](#debug-bitmask) -- [Data formats (brief)](#data-formats-brief) -- [Save state](#save-state) -- [Troubleshooting](#troubleshooting) -- [Differences from upstream](#differences-from-upstream) -- [Known limitations](#known-limitations) -- [Contributing](#contributing) -- [Credits](#credits) -- [License & legal](#license--legal) + + + + + +
---- +🖼️ **HD upscaling** +Chained xBRZ at 6× / 8× / 10× / 15×, MLAA edge smoothing, per-screen background cached once. -## At a glance +📺 **16:9 widescreen** +Palette-derived gradient borders. No fake gameplay. -| Area | Feature | -|---|---| -| 🖼️ Rendering | Chained xBRZ at 6× / 8× / 10× / 15× / arbitrary 2..16; MLAA edge smoothing; per-screen background cached once | -| 📺 Widescreen | 16:9 framebuffer with palette-derived gradient borders (no fake gameplay) | -| ⏱️ Smoothness | Decoupled 60 Hz render with 12.5 Hz game tick, sprite-position interpolation | -| 🎬 Cutscenes | PAF frames upscaled and cached to disk; `--prerender` warms the cache fast-forward | -| 🚀 Prerender | Single unified console progress bar covers every sprite (incl. per-screen background animations) and the in-gameplay PAF clips for the level | -| 🤖 Automation | Unix-domain JSON socket: `get_state`, `input`, `step`, `screenshot`, `set_level` — perfect for AI bots and replay harnesses | -| 🧰 Menu/input | Bind any letter, digit, modifier or **Space / Enter / Tab / Backspace / Win**; OK / Cancel / Test row works; one key = one action; defaults stay live alongside custom binds | -| 🍎 macOS | Builds out of the box on Apple Silicon and Intel Macs (added `` shim) | -| 💾 Cache | Content-hashed key (FNV-1a over SPR bytes + dims + flags + palette hash) — survives across runs *and* palette changes | +⏱️ **Smooth animation** +60 Hz render with sprite-position interpolation, 12.5 Hz logic. + +🎬 **Cutscene HD cache** +PAF frames upscaled and stored under `cache/paf/x/`. + + + +🚀 **Prerender** +Single console progress bar covers every sprite (incl. per-screen background animations) and the in-gameplay PAF clips. + +🤖 **Automation API** +Unix-domain JSON: `get_state`, `input`, `step`, `screenshot`, `set_level`. + +🧰 **Menu/input fixes** +Bind any key (incl. Space/Enter/Tab/Backspace/Win); OK/Cancel/Test row works; defaults stay live. + +🍎 **macOS support** +Builds on Apple Silicon and Intel via `` shim. + +
--- -## Building +## 📚 Table of contents -The project uses a hand-written GNU `Makefile`. There is one external dependency: **SDL2**. C++11 is the language standard; warnings are turned up (`-Wall -Wextra -Wpedantic`) but the build is warning-free on macOS clang and Linux gcc. +
+Click to expand -Generic build, any POSIX: +- [✨ Highlights](#-highlights) +- [🛠️ Building](#%EF%B8%8F-building) + - [🍎 macOS (Apple Silicon & Intel)](#-macos-apple-silicon--intel) + - [🐧 Linux](#-linux) + - [🪟 Windows (WSL)](#-windows-wsl) +- [💾 Game data files](#-game-data-files) +- [🚀 Running](#-running) + - [Quick examples](#quick-examples) + - [CLI flag reference](#cli-flag-reference) + - [`hode.ini` reference](#hodeini-reference) +- [🎨 Display & rendering modes](#-display--rendering-modes) +- [🗂️ Disk cache & prerender](#%EF%B8%8F-disk-cache--prerender) +- [🤖 Automation API](#-automation-api) +- [🧪 Test scripts](#-test-scripts) +- [🏗️ Engine architecture](#%EF%B8%8F-engine-architecture) +- [🗺️ Source file map](#%EF%B8%8F-source-file-map) +- [🎮 Game world reference](#-game-world-reference) +- [📦 Data formats](#-data-formats) +- [💾 Save state](#-save-state) +- [🔧 Troubleshooting](#-troubleshooting) +- [📋 Differences from upstream](#-differences-from-upstream) +- [⚠️ Known limitations](#%EF%B8%8F-known-limitations) +- [🤝 Contributing](#-contributing) +- [👏 Credits](#-credits) +- [⚖️ License & legal](#%EF%B8%8F-license--legal) + +
+ +--- + +## 🛠️ Building + +The project uses a hand-written GNU `Makefile`. **The only external dependency is SDL2.** C++11 with warnings turned up (`-Wall -Wextra -Wpedantic`); the build is warning-free on macOS clang and Linux gcc. ```bash make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" ``` -Outputs `./hode` (~800 KB, statically links no extras besides SDL2). - -`make clean` removes objects (`*.o`) and dependency files (`*.d`). +Outputs `./hode` (~800 KB binary). `make clean` removes objects and dependency files. -For an optimised build: +
+📈 Optimised release build ```bash make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" \ CPPFLAGS='-O2 -std=c++11 -Wall -Wextra -Wno-unused-parameter -Wpedantic -MMD' ``` +
-### macOS (Apple Silicon & Intel) +### 🍎 macOS (Apple Silicon & Intel) -This fork includes the changes required to build cleanly on macOS — upstream `hode` fails to compile on macOS because clang's libc doesn't provide a glibc-style ``. The fix lives in [`intern.h`](intern.h): +> [!NOTE] +> Upstream `hode` does **not** build on macOS — clang's libc has no glibc-style ``. This fork ships the fix in [`intern.h`](intern.h): +> +> ```cpp +> #elif defined(__APPLE__) +> #include +> #define le16toh(x) OSSwapLittleToHostInt16(x) +> #define le32toh(x) OSSwapLittleToHostInt32(x) +> #define htole16(x) OSSwapHostToLittleInt16(x) +> #define htole32(x) OSSwapHostToLittleInt32(x) +> static const bool kByteSwapData = (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__); +> ``` -```cpp -#elif defined(__APPLE__) - #include - #define le16toh(x) OSSwapLittleToHostInt16(x) - #define le32toh(x) OSSwapLittleToHostInt32(x) - #define htole16(x) OSSwapHostToLittleInt16(x) - #define htole32(x) OSSwapHostToLittleInt32(x) - static const bool kByteSwapData = (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__); -``` +**Step-by-step on a fresh macOS install:** -Steps for a fresh macOS install: +```bash +# 1. Xcode Command Line Tools (clang + git) +xcode-select --install -1. **Install Xcode Command Line Tools** (provides clang/git): - ```bash - xcode-select --install - ``` -2. **Install SDL2** via Homebrew: - ```bash - brew install sdl2 - ``` - This places `sdl2-config` on `PATH` and `SDL2.framework` / `libSDL2*.dylib` where the linker can find them. The Makefile uses `sdl2-config --cflags --libs`, so no extra plumbing is needed. -3. **Build**: - ```bash - make -j"$(sysctl -n hw.ncpu)" - ``` -4. **Run** (data files in the same directory): - ```bash - ./hode --hd - ``` +# 2. Homebrew SDL2 +brew install sdl2 -If `brew` is in a non-standard prefix (e.g. `/opt/homebrew` on Apple Silicon), make sure `/opt/homebrew/bin` is on your `PATH` so `sdl2-config` is found: +# 3. Build +make -j"$(sysctl -n hw.ncpu)" -```bash -echo 'export PATH="/opt/homebrew/bin:$PATH"' >> ~/.zprofile -source ~/.zprofile +# 4. Run (data files alongside the binary) +./hode --hd ``` -For a debug build with `lldb`: +If `brew` is in `/opt/homebrew` (Apple Silicon default), make sure it's on `PATH` so `sdl2-config` resolves: ```bash -make clean && make -j"$(sysctl -n hw.ncpu)" CPPFLAGS='-g -O0 -std=c++11 -Wall -Wextra -Wno-unused-parameter -Wpedantic -MMD' -lldb -- ./hode --hd +echo 'export PATH="/opt/homebrew/bin:$PATH"' >> ~/.zprofile +source ~/.zprofile ``` -#### macOS-specific notes +
+🍎 macOS-specific notes | Concern | Notes | |---|---| -| Window display backend | SDL2 uses Metal via SDL_RENDERER_ACCELERATED; nothing extra required. | -| Endianness | `` is the macOS-blessed equivalent of ``. The fork's `intern.h` selects the right header per platform. | -| Hardened runtime / Gatekeeper | The Makefile produces an unsigned binary. The first launch from Finder may show a "cannot be opened" dialog — right-click → Open, or run from Terminal. No entitlements are required. | -| Audio | Audio uses SDL's CoreAudio backend by default. Set `SDL_AUDIODRIVER=dummy` to disable for headless / CI use. | -| Headless / CI | macOS has no `Xvfb` equivalent that's worth fighting. Use `SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy` on macOS for non-interactive runs (still requires a display server unless you patch out `SDL_INIT_VIDEO`). For real headless CI, run the Linux build inside a container with `xvfb-run`. | -| Apple Silicon | The build is native arm64 with clang's auto-vectorisation; xBRZ scalers benefit. No Rosetta required. | -| Bundle / `.app` | This fork ships a CLI binary, not a `.app` bundle. If you want a real app bundle (icon in dock, drag-to-Applications), wrap with [`appify`](https://gist.github.com/mathiasbynens/674099) or build one with `Info.plist`; the engine itself doesn't care. | - -A clean build on macOS is normally a few seconds: - +| **Display backend** | SDL2 uses Metal via `SDL_RENDERER_ACCELERATED`; nothing extra required. | +| **Endianness** | `` is the macOS-blessed equivalent of ``. The fork's `intern.h` selects the right header per platform. | +| **Hardened runtime / Gatekeeper** | The Makefile produces an unsigned binary. First Finder launch may show "cannot be opened" — right-click → Open, or run from Terminal. No entitlements required. | +| **Audio** | Uses SDL's CoreAudio backend by default. `SDL_AUDIODRIVER=dummy` disables for headless / CI use. | +| **Apple Silicon** | Native arm64 build; clang auto-vectorisation benefits the xBRZ scalers. No Rosetta required. | +| **Headless** | macOS has no easy `Xvfb` equivalent. Use `SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy` for non-interactive runs (still requires a window server). For real headless CI, run the Linux build in a container with `xvfb-run`. | +| **Bundle / `.app`** | Ships as a CLI binary, not a `.app`. Wrap with [`appify`](https://gist.github.com/mathiasbynens/674099) if you want Finder integration. | +| **Debug build** | `make clean && make -j"$(sysctl -n hw.ncpu)" CPPFLAGS='-g -O0 -std=c++11 -Wall -Wextra -Wno-unused-parameter -Wpedantic -MMD' && lldb -- ./hode --hd` | + +A clean macOS build is normally a few seconds: ```text $ make clean && make -j$(sysctl -n hw.ncpu) 2>&1 | tail -3 -c++ -g -std=c++11 -Wall -Wextra -Wno-unused-parameter -Wpedantic `sdl2-config --cflags` -MMD -c -o video.o video.cpp +c++ -g -std=c++11 ... -c -o video.o video.cpp c++ -o hode andy.o automation_api.o ... `sdl2-config --libs` ``` -### Linux +
+ +### 🐧 Linux ```bash # Debian / Ubuntu / WSL @@ -185,55 +198,52 @@ sudo pacman -S base-devel sdl2 make -j"$(nproc)" ``` -For headless CI: +Headless CI: ```bash SDL_AUDIODRIVER=dummy xvfb-run -a ./hode --automation=/tmp/hode.sock --hd ``` -### Windows (WSL) +### 🪟 Windows (WSL) -Use WSL2 + an X server (e.g. WSLg, included in Windows 11 by default): +WSL2 + WSLg (built into Windows 11) works out of the box: ```bash sudo apt install build-essential libsdl2-dev pkg-config make -j"$(nproc)" ``` -Native Windows builds (MinGW / MSYS2) are not currently maintained in this fork; the Makefile assumes POSIX, but the original code is portable — patches welcome. - -### Other platforms - -The upstream tree ships `system_psp.cpp` and `system_wii.cpp` (PSP / Wii Homebrew) but they are not wired into this `Makefile` — only `system_sdl2.cpp` is built. The HD compositor and disk-cache changes target SDL2 by design and would need backend work to run on PSP / Wii. +Native Windows builds (MinGW / MSYS2) are not currently maintained — the Makefile assumes POSIX. Patches welcome. --- -## Game data files +## 💾 Game data files + +> [!CAUTION] +> The data files are **not redistributed**. You must own a legitimate copy of *Heart of Darkness*. -You must own the original *Heart of Darkness* PC release (full or demo). Place these next to the binary or pass `--datapath=PATH`: +Place these next to the binary or pass `--datapath=PATH`: -| File | Description | Approx. size | +| File | Description | Size (retail) | |---|---|---:| | `HOD.PAF` | All cutscenes (Packed Animation File container) | ~411 MB | -| `SETUP.DAT` | Strings, fonts, hint images, loading-screen art, sound bank metadata | ~5.5 MB | -| `ROCK_HOD.LVL` … `DARK_HOD.LVL` | Per-level geometry, sprite tables, screen layout | 0.7 – 5 MB ×9 | -| `ROCK_HOD.SSS` … `DARK_HOD.SSS` | Sound script (SssBank/SssSample) for each level | 3 – 7 MB ×9 | -| `ROCK_HOD.MST` … `DARK_HOD.MST` | Monster + scripting tables | 10 KB – 150 KB ×9 | +| `SETUP.DAT` | Strings, fonts, hint images, loading screen, sound metadata | ~5.5 MB | +| `*_HOD.LVL` ×9 | Per-level geometry, sprite tables, screen layout | 0.7 – 5 MB each | +| `*_HOD.SSS` ×9 | Sound script (SssBank/SssSample) | 3 – 7 MB each | +| `*_HOD.MST` ×9 | Monster + scripting tables | 10 – 150 KB each | -PSX disc data (`*.dax`, MDEC backgrounds, SPU ADPCM sounds) is also supported by the original engine. The HD upscaler works for both PC and PSX paths. +PSX disc data (`*.dax`, MDEC backgrounds, SPU ADPCM) is also supported — the HD upscaler works for both PC and PSX paths. -`RELEASES.yaml` (carried over from upstream) lists SHA-1 hashes of every game version the engine has been validated against (French / German / English Win32, demos, PSX, etc.). If you suspect bad data files, compare them against the entries there. - -> ⚠️ The data files are **not redistributed** — bring your own from your CD / store purchase. +`RELEASES.yaml` (carried over from upstream) lists SHA-1 hashes for every game version validated against (French / German / English Win32, demos, PSX). If your data files act weird, compare them there. --- -## Running +## 🚀 Running ### Quick examples ```bash -# Vanilla 256×192 windowed +# Vanilla 256×192 windowed (upstream behaviour) ./hode # HD (1536×1152) at default 6× scale @@ -252,30 +262,44 @@ PSX disc data (`*.dax`, MDEC backgrounds, SPU ADPCM sounds) is also supported by ./hode --automation=/tmp/hode.sock --hd # Read data files from elsewhere -./hode --datapath=/Volumes/HOD-CD --savepath=~/Library/Application\ Support/hode +./hode --datapath=/Volumes/HOD-CD --savepath="$HOME/Library/Application Support/hode" ``` ### CLI flag reference -All flags use the GNU long form (`--name` or `--name=value`); there are no short flags. +> All flags use the GNU long form (`--name` or `--name=value`); there are no short flags. + +#### Path & navigation | Flag | Argument | Default | Effect | |---|---|---|---| -| `--datapath=PATH` | path | `.` | Directory containing `HOD.PAF`, `SETUP.DAT`, level files | +| `--datapath=PATH` | path | `.` | Directory containing game data files | | `--savepath=PATH` | path | `.` | Directory for `setup.cfg` and screenshots | -| `--level=NUM` | int 0–8 or name (`rock`, `fort`, `pwr1`, `isld`, `lava`, `pwr2`, `lar1`, `lar2`, `dark`) | — | Skip menu and start at given level | +| `--level=NUM\|NAME` | int 0–8 or name | — | Skip menu, start at given level | | `--checkpoint=NUM` | int | 0 | Checkpoint within `--level` | -| `--debug=MASK` | int | 0 | Bitmask OR'd into `g_debugMask` (see [Debug bitmask](#debug-bitmask)) | -| `--cheats=MASK` | int | 0 | Bitmask OR'd into cheat flags (see [Cheat flags](#cheat-flags)) | + +Level names (also accept indices): `rock`, `fort`, `pwr1`, `isld`, `lava`, `pwr2`, `lar1`, `lar2`, `dark`. + +#### HD rendering + +| Flag | Argument | Default | Effect | +|---|---|---|---| | `--hd` | — | off | Enable HD compositor at default 6× scale | | `--hd-scale=N` | int 2–16 | — | HD compositor at custom scale; implies `--hd` | | `--fullhd` | — | off | Shortcut: HD at 8× (2048×1536) | -| `--4k` | — | off | Shortcut: HD at 15× (3840×2880; internally 16× cropped) | -| `--hd-wide` | — | off | 16:9 framebuffer with palette-gradient borders; also forces SDL window to 16:9 aspect | -| `--hd-cache=PATH` | path | — | Read/write upscaled sprites + PAF frames under `PATH/x/` | -| `--prerender` | — | off | Pre-fill sprite + relevant PAF caches per level (slow first run, instant after) | -| `--smooth` | — | off | 60 Hz render with sprite-position interpolation, 12.5 Hz game logic | -| `--automation=PATH` | unix-socket | — | Open a JSON automation socket; also disables menu, loading screen and cutscenes for fast scripted boot | +| `--4k` | — | off | Shortcut: HD at 15× (3840×2880) | +| `--hd-wide` | — | off | 16:9 framebuffer with palette-gradient borders; sizes window 16:9 | +| `--hd-cache=PATH` | path | — | Read/write upscaled artifacts under `PATH/x/` | +| `--prerender` | — | off | Pre-fill sprite + relevant PAF caches per level | +| `--smooth` | — | off | 60 Hz render with sprite-position interpolation | + +#### Scripting & debug + +| Flag | Argument | Default | Effect | +|---|---|---|---| +| `--automation=PATH` | unix-socket | — | Open JSON automation socket; disables menu/loading/cutscenes | +| `--debug=MASK` | int | 0 | OR'd into `g_debugMask` ([see below](#debug-bitmask)) | +| `--cheats=MASK` | int | 0 | OR'd into cheat flags ([see below](#cheat-flags)) | > CLI flags **override** matching `hode.ini` keys. @@ -308,15 +332,12 @@ smooth_anim = false ; same as --smooth automation_socket = /tmp/hode.sock ; same as --automation ``` -Legend: - -- Booleans accept `true`/`false`, `t`/`f`, `1`/`0` (case-insensitive). -- `disable_paf` is auto-set to `true` if the engine can't open `HOD.PAF`. -- Lines starting with `#` are comments. Blank lines are tolerated. +> [!TIP] +> Booleans accept `true`/`false`, `t`/`f`, `1`/`0` (case-insensitive). Lines starting with `#` are comments. `disable_paf` is auto-set to `true` if the engine can't open `HOD.PAF`. --- -## Display & rendering modes +## 🎨 Display & rendering modes ### Standard mode @@ -324,148 +345,148 @@ Legend: ### HD upscaling -`--hd`, `--fullhd`, `--4k`, `--hd-scale=N` route every visible pixel through `HdCompositor` (in `hd_compositor.{h,cpp}`): +`--hd` / `--fullhd` / `--4k` / `--hd-scale=N` route every visible pixel through `HdCompositor` ([`hd_compositor.{h,cpp}`](hd_compositor.cpp)): - Background bitmap is upscaled **once per screen change** and reused. - Each sprite is upscaled **once per (content + dimensions + flip + palette) tuple** and cached. -- The HD compositor framebuffer is RGBA32; SDL2 streams it to a `SDL_Texture` per frame. +- HD framebuffer is RGBA32; SDL2 streams it to a `SDL_Texture` per frame. ### Scale chains -xBRZ scales by 2×, 3×, 4× or 5× per pass; arbitrary scales are decomposed into two passes: +xBRZ scales by 2×, 3×, 4× or 5× per pass; arbitrary scales decompose into two passes: -| `--hd-scale` / preset | First pass | Second pass | Output (256×192 →) | +| Preset / `--hd-scale` | Pass 1 | Pass 2 | Output (256×192) | |---:|:--|:--|:--| -| 2 | 2× | — | 512×384 | -| 3 | 3× | — | 768×576 | -| 4 | 4× (= 2×·2×) | — | 1024×768 | -| 5 | 5× (nearest) | — | 1280×960 | -| **6** (`--hd`) | 3× | 2× | 1536×1152 | -| 7 | 4× | 2× → crop | 1792×1344 | -| **8** (`--fullhd`) | 4× | 2× | 2048×1536 | -| 9 | 3× | 3× | 2304×1728 | -| **10** | 4× | 3× → crop | 2560×1920 | -| 11 | 4× | 3× → crop | 2816×2112 | -| 12 | 4× | 3× | 3072×2304 | -| 13 | 4× | 4× → crop | 3328×2496 | -| 14 | 4× | 4× → crop | 3584×2688 | -| **15** (`--4k`) | 4× | 4× → SDL downscale | 3840×2880 | -| 16 | 4× | 4× | 4096×3072 | +| 2 | 2× | — | 512 × 384 | +| 3 | 3× | — | 768 × 576 | +| 4 | 4× *(2×·2×)* | — | 1024 × 768 | +| 5 | 5× *(nearest)* | — | 1280 × 960 | +| **6 (`--hd`)** | 3× | 2× | **1536 × 1152** | +| 7 | 4× | 2× → crop | 1792 × 1344 | +| **8 (`--fullhd`)** | 4× | 2× | **2048 × 1536** | +| 9 | 3× | 3× | 2304 × 1728 | +| 10 | 4× | 3× → crop | 2560 × 1920 | +| 11 | 4× | 3× → crop | 2816 × 2112 | +| 12 | 4× | 3× | 3072 × 2304 | +| 13 | 4× | 4× → crop | 3328 × 2496 | +| 14 | 4× | 4× → crop | 3584 × 2688 | +| **15 (`--4k`)** | 4× | 4× → SDL downscale | **3840 × 2880** | +| 16 | 4× | 4× | 4096 × 3072 | After the chained scale, [`mlaa_smooth()`](edge_smooth.cpp) runs a morphological-edge-AA pass that softens stair-stepping artefacts on diagonal edges (cape, hair, plasma traces). ### Widescreen 16:9 borders -`--hd-wide` switches the HD framebuffer from 4:3 to 16:9. The 4:3 game stays centred; left and right margins are filled with a vertical gradient sampled from the top and bottom rows of the **current screen's palette**, then darkened to ~⅓ brightness so it reads as ambient atmosphere instead of fake gameplay. The borders re-sample whenever the screen changes, so they track lighting/mood per area. +`--hd-wide` switches the HD framebuffer from 4:3 to 16:9. The 4:3 game stays centred; left and right margins are filled with a **vertical gradient** sampled from the top and bottom rows of the **current screen's palette**, then darkened to ~⅓ brightness so it reads as ambient atmosphere instead of fake gameplay. Borders re-sample whenever the screen changes, so they track lighting/mood per area. -This fork additionally **resizes the SDL window itself to 16:9** when `--hd-wide` is on. Without that change (upstream behaviour), the wide framebuffer is squished into a 4:3 window and the colored borders are clipped to invisibility. +> [!NOTE] +> This fork **resizes the SDL window itself to 16:9** when `--hd-wide` is on. Without that change (upstream behaviour), the wide framebuffer was squished into a 4:3 window and the colored borders were clipped to invisibility. ### Smooth (60 Hz) animation `--smooth` decouples logic from rendering: ```text - ┌───── 12.5 Hz ─────┐ ┌───── ~60 Hz ─────┐ - │ game tick │ │ render frame │ - │ ‒ AI / physics │ │ ‒ interpolate │ - │ ‒ sprite list │ ──► │ prev → curr │ - │ ‒ snapshot pos │ │ ‒ blit to HD │ - └───────────────────┘ └──────────────────┘ + ┌───── 12.5 Hz ─────┐ ┌───── ~60 Hz ─────┐ + │ game tick │ │ render frame │ + │ ‒ AI / physics │ ──► │ ‒ interpolate │ + │ ‒ sprite list │ │ prev → curr │ + │ ‒ snapshot pos │ │ ‒ blit to HD │ + └───────────────────┘ └──────────────────┘ ``` -- Game tick stays at the original 80 ms (12.5 Hz). All collision, AI, sound, scripted callbacks behave exactly as upstream. +- Game tick stays at 80 ms (12.5 Hz). Collision, AI, sound, scripted callbacks behave exactly as upstream. - `Game::saveInterpolationState()` snapshots `(prev, curr)` for Andy and every visible sprite at each tick. -- `Game::renderInterpolatedFrame(t)` — called at the render rate — blends each sprite's previous and current position with `t ∈ [0,1]`. +- `Game::renderInterpolatedFrame(t)` — called at the render rate — blends with `t ∈ [0,1]`. -The result is fluid on-screen motion with zero impact on gameplay timing. +The result is fluid on-screen motion with **zero impact on gameplay timing**. --- -## Disk cache & prerender +## 🗂️ Disk cache & prerender `--hd-cache=PATH` makes every upscaled artifact persistent. ### On-disk layout -``` +```text PATH/ ├── 6x/ ← sprites at the active scale -│ └── spr__x.raw ← header (int32 w, int32 h) + w*h*4 RGBA bytes +│ └── spr__x.raw ← header (int32 w, int32 h) + w*h*4 RGBA ├── 8x/ └── paf/ ├── 6x/ │ ├── v00/ ← cutscene 0 (intro) │ │ ├── f0000.raw - │ │ ├── f0001.raw │ │ └── … │ ├── v22/ ← Canyon Andy-falling-with-cannon │ └── v24/ ← Island Andy-falling └── 8x/ ``` -Each `x/` directory is independent — switching `--hd-scale=10` populates `cache/10x/` without touching `cache/6x/`. +Each `x/` directory is independent — switching `--hd-scale` populates a new directory without touching existing ones. ### Cache key -``` +```text key = FNV-1a-64( - decoded indexed bytes (w×h) - ⊕ w little-endian uint16 - ⊕ h little-endian uint16 - ⊕ flags & 3 ← horizontal-flip bit - ⊕ palette FNV-1a-64 -) + decoded indexed bytes (w × h) + ⊕ w little-endian uint16 + ⊕ h little-endian uint16 + ⊕ flags & 3 ← horizontal-flip bit + ⊕ palette FNV-1a-64 + ) ``` -Including the **palette hash** is the reason the same sprite content rendered with different on-screen palettes (between screens, cross-fade, cutscenes) gets distinct cache entries. Without that, low scales (HD/FullHD) would occasionally show "wrong-coloured" sprite frames as a stale entry was reused after a palette transition. +Including the **palette hash** is the reason the same sprite content rendered with different on-screen palettes (between screens, cross-fade, cutscenes) gets distinct cache entries. Without this, low scales (HD/FullHD) would occasionally show *wrong-coloured* sprite frames as a stale entry was reused after a palette transition. -> Note: this changes the on-disk format compared to upstream and earlier builds of this fork. **Delete any pre-existing `cache/` directory** before the first run with palette-hashed keys. +> [!WARNING] +> This changes the on-disk format compared to upstream and earlier builds of this fork. **Delete any pre-existing `cache/` directory** before the first run with palette-hashed keys. ### Prerender flow -`--prerender` populates the cache up-front, **once per level** (tracked via a 32-bit bitmask `Game::_hdPrerenderedMask`). After `Game::restartLevel()` finishes loading the level and before the gameplay loop starts: +`--prerender` populates the cache up-front, **once per level** (tracked via `Game::_hdPrerenderedMask`). After `Game::restartLevel()` finishes loading the level and before the gameplay loop starts: 1. **Sprite phase** — walk every `(sprite-type, frame, flip)` tuple across: - the main level table `_resLevelData0x2988PtrTable[0..31]` - - every screen's `backgroundLvlObjectDataTable[0..7]` (the per-screen background animations: trees, water, ambient effects). Without this, those animations only upscaled the first time they appeared on-screen. -2. **PAF phase** — fast-forward decode the small in-gameplay clips (Canyon falling with cannon, Canyon falling, Island falling) plus the level's intro cutscene (`_cutscenes[level]`). Each not-yet-cached cutscene is decoded full-speed without audio/display/sleep so the existing HD frame callback writes every frame to disk. -3. Both phases share **one progress bar** sized by the total work (sprite frames + PAF frames): - ``` - prerender level 0 (rock) [############################....] 4720/5320 (88%) 5.2s eta 0.7s + - every screen's `backgroundLvlObjectDataTable[0..7]` (per-screen background animations: trees, water, ambient effects). Without this, those animations only upscaled the first time they appeared on-screen. +2. **PAF phase** — fast-forward decode the small in-gameplay clips (Canyon falling with cannon, Canyon falling, Island falling) plus the level's intro cutscene. Each not-yet-cached cutscene is decoded full-speed without audio/display/sleep so the existing HD frame callback writes every frame to disk. +3. Both phases share **one progress bar**: + ```text + prerender level 0 (rock) [████████████████████████████....] 4720/5320 (88%) 5.2s eta 0.7s ``` -The `_playedMask` tracking "watched cutscenes" is snapshotted/restored across each PAF prerender so your save file isn't polluted with movies you didn't actually watch. After the bar completes, the engine pumps SDL once and re-baselines `inp.prevMask = inp.mask` so any keys held during prerender don't fire phantom press/release events on the first gameplay tick. +`_playedMask` (watched cutscenes) is snapshotted/restored across each PAF prerender so your save isn't polluted with movies you didn't actually watch. After the bar completes, the engine pumps SDL once and re-baselines `inp.prevMask = inp.mask` so any held keys don't fire phantom press/release events on the first gameplay tick. --- -## Automation API +## 🤖 Automation API -`--automation=/tmp/hode.sock` opens a non-blocking `AF_UNIX` `SOCK_STREAM` listening for one client at a time. Newline-delimited JSON in both directions. +`--automation=/tmp/hode.sock` opens a non-blocking `AF_UNIX` `SOCK_STREAM` listening for one client at a time. **Newline-delimited JSON** in both directions. `--automation` implies: -- `disable_menu = true` — boot straight into gameplay -- `loading_screen = false` — no "please wait" -- `disable_paf = true` — cutscenes skipped +- 🚫 `disable_menu = true` — boot straight into gameplay +- 🚫 `loading_screen = false` — no "please wait" +- 🚫 `disable_paf = true` — cutscenes skipped ### Protocol - One JSON object per line, terminated with `\n`. -- The server only responds to `get_state` and `screenshot` (a JSON header line, then for screenshot the raw RGB bytes). Other commands are fire-and-forget. -- Backpressure: writes are blocking; the client should drain replies promptly. -- Connection drops are detected on next `read()`; the engine continues running and re-`accept()`s. +- Server only responds to `get_state` and `screenshot`. Other commands are fire-and-forget. +- One client at a time. Connection drop is detected on next `read()`; engine continues and re-`accept()`s. ### Commands | `cmd` | Body fields | Reply | Effect | |---|---|---|---| -| `get_state` | – | JSON object (see below) | Snapshot Andy + monsters + level/checkpoint/screen | -| `input` | `dir` (UDLR mask), `act` (RUN/JUMP/SHOOT mask), `frames` (int), `raw` (override for raw `SYS_INP_*` mask) | – | Inject input for `frames` ticks | -| `step` | `count` (int) | – | Enable step mode and step `count` ticks; gameplay blocks between batches | -| `screenshot` | – | header line then `width*height*3` raw RGB bytes | Capture the 256×192 framebuffer | -| `set_level` | `level` (0–8), `checkpoint` (int) | – | Break out of the current level loop and restart at the given level/checkpoint | +| `get_state` | – | JSON object *(see below)* | Snapshot Andy + monsters + level/checkpoint/screen | +| `input` | `dir`, `act`, `frames`, `raw` | – | Inject input for `frames` ticks | +| `step` | `count` | – | Enable step mode and step `count` ticks | +| `screenshot` | – | header line + `width*height*3` raw RGB bytes | Capture 256×192 framebuffer | +| `set_level` | `level`, `checkpoint` | – | Restart at given level/checkpoint | -`get_state` reply schema (truncated for clarity): +`get_state` reply: ```json { @@ -486,18 +507,18 @@ The `_playedMask` tracking "watched cutscenes" is snapshotted/restored across ea Input bit layout: -| Bit | `dir` | `act` | Raw `SYS_INP_*` | -|---:|:--|:--|:--| -| 0x01 | UP | RUN | `SYS_INP_UP=0x01`, `SYS_INP_RUN=0x10` | -| 0x02 | RIGHT | JUMP | `SYS_INP_RIGHT=0x02`, `SYS_INP_JUMP=0x20` | -| 0x04 | DOWN | SHOOT | `SYS_INP_DOWN=0x04`, `SYS_INP_SHOOT=0x40` | -| 0x08 | LEFT | – | `SYS_INP_LEFT=0x08` | -| 0x80 | – | – | `SYS_INP_ESC=0x80` (raw only) | +| Bit | `dir` | `act` | Raw `SYS_INP_*` | +| ---: | :----- | :----- | :---------------------------- | +| 0x01 | UP | RUN | `SYS_INP_UP=0x01`, `RUN=0x10` | +| 0x02 | RIGHT | JUMP | `SYS_INP_RIGHT=0x02`, `JUMP=0x20` | +| 0x04 | DOWN | SHOOT | `SYS_INP_DOWN=0x04`, `SHOOT=0x40` | +| 0x08 | LEFT | – | `SYS_INP_LEFT=0x08` | +| 0x80 | – | – | `SYS_INP_ESC=0x80` (raw only) | ### Python client ```python -import socket, json, time +import socket, json s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.connect("/tmp/hode.sock") @@ -517,7 +538,7 @@ send({"cmd": "input", "dir": 2, "act": 1, "frames": 30}) # Frame-by-frame step for 10 ticks send({"cmd": "step", "count": 10}) -# Screenshot — header + raw RGB +# Screenshot — header then raw RGB send({"cmd": "screenshot"}) hdr = recv_line() # {"width":256,"height":192,"format":"rgb","size":N} img = b"" @@ -535,26 +556,26 @@ send({"cmd": "set_level", "level": 3, "checkpoint": 0}) # Linux SDL_AUDIODRIVER=dummy xvfb-run -a ./hode --automation=/tmp/hode.sock --hd -# macOS (still needs a window server; for true headless use the Linux build in CI) +# macOS (still needs a window server; for true headless use Linux in CI) SDL_AUDIODRIVER=dummy ./hode --automation=/tmp/hode.sock ``` --- -## Test scripts +## 🧪 Test scripts -Drop-in Python drivers for the automation API. All require a running `./hode --automation=/tmp/hode.sock`. +Drop-in Python drivers using the automation API. All require a running `./hode --automation=/tmp/hode.sock`. | Script | Purpose | |---|---| -| [`test_walkthrough.py`](test_walkthrough.py) | Drive Andy through level 1 end-to-end with scripted inputs. Useful as a smoke test after engine changes. | -| [`test_all_levels.py`](test_all_levels.py) | Boot each of the 9 levels, verify gameplay starts, optionally take a screenshot. | -| [`test_combat_bot.py`](test_combat_bot.py) | Reactive AI: query monsters via `get_state`, fire when in range. Demonstrates `input` + `get_state` polling. | -| [`test_hd_replay.py`](test_hd_replay.py) | Compare HD vs SD rendering frame-by-frame. | +| [`test_walkthrough.py`](test_walkthrough.py) | Drive Andy through level 1 end-to-end | +| [`test_all_levels.py`](test_all_levels.py) | Boot each of the 9 levels, verify gameplay starts | +| [`test_combat_bot.py`](test_combat_bot.py) | Reactive AI: fires when monsters in range | +| [`test_hd_replay.py`](test_hd_replay.py) | Compare HD vs SD rendering frame-by-frame | --- -## Engine architecture +## 🏗️ Engine architecture ### High-level dataflow @@ -597,14 +618,14 @@ Drop-in Python drivers for the automation API. All require a running `./hode --a | Type | File | Responsibility | |---|---|---| -| `Game` | [`game.h`](game.h) / [`game.cpp`](game.cpp) | Orchestrator. Holds Video / Resource / PafPlayer / HdCompositor / AutomationApi pointers; ticks the level main loop; per-level callbacks live in `level1_rock.cpp` .. `level9_dark.cpp`. | -| `Resource` | [`resource.h`](resource.h) | Owns LVL / SSS / MST parsers, sprite tables (`_resLevelData0x2988PtrTable[32]`, `_resLvlScreenBackgroundDataTable[40]`), font/loading-screen images, palettes. | -| `Video` | [`video.h`](video.h) / [`video.cpp`](video.cpp) | Legacy 256-colour framebuffer with `_frontLayer`, `_backgroundLayer`, `_shadowLayer`. Owns the palette + font. | -| `PafPlayer` | [`paf.h`](paf.h) / [`paf.cpp`](paf.cpp) | Streams the proprietary Packed Animation File (per-frame indexed bitmap + delta encoding + ADPCM audio). | -| `HdCompositor` | [`hd_compositor.h`](hd_compositor.h) / [`hd_compositor.cpp`](hd_compositor.cpp) | New: HD framebuffer, multi-resolution presets, 16:9 borders, prerender driver. | -| `SpriteUpscaler` | [`sprite_upscaler.h`](sprite_upscaler.h) / [`sprite_upscaler.cpp`](sprite_upscaler.cpp) | New: chained xBRZ scalers + content-hashed RAM/disk cache. | -| `AutomationApi` | [`automation_api.h`](automation_api.h) / [`automation_api.cpp`](automation_api.cpp) | New: Unix socket JSON server. | -| `System` | [`system.h`](system.h) | Abstract platform — input, audio, screen, key mapping. `System_SDL2` is the active backend. | +| `Game` | [`game.h`](game.h) / [`game.cpp`](game.cpp) | Orchestrator; ticks the level main loop | +| `Resource` | [`resource.h`](resource.h) | LVL/SSS/MST/PAF parsing, sprite tables, palettes | +| `Video` | [`video.h`](video.h) | 256-colour framebuffer + palette + layers | +| `PafPlayer` | [`paf.h`](paf.h) | Streams Packed Animation File format | +| `HdCompositor` | [`hd_compositor.h`](hd_compositor.h) | **NEW** — HD framebuffer, presets, borders, prerender | +| `SpriteUpscaler` | [`sprite_upscaler.h`](sprite_upscaler.h) | **NEW** — chained xBRZ + cache | +| `AutomationApi` | [`automation_api.h`](automation_api.h) | **NEW** — Unix socket JSON server | +| `System` | [`system.h`](system.h) | Abstract platform — input, audio, screen | ### HD pipeline @@ -628,7 +649,7 @@ Original logic (256×192, 8-bit indexed) ▼ HdCompositor::endFrame() │ ‒ if widescreen: compositeWidescreen() draws gradient borders │ - ▼ System_SDL2::copyRectRGBA(): SDL_Texture stream → SDL_RenderCopy → SDL_RenderPresent + ▼ System_SDL2::copyRectRGBA(): SDL texture stream → present ``` ### PAF cutscene flow @@ -653,42 +674,44 @@ PafPlayer::mainLoop() ← _prerenderMode skips audio/di PafPlayer::unload() ``` -`PafPlayer::prerender(num)` = `_prerenderMode = true; play(num); _prerenderMode = false;` with `_playedMask` snapshot/restore so prerender doesn't pollute the save's "watched cutscenes" record. - --- -## Source file map +## 🗺️ Source file map -### Original engine (preserved) +
+Original engine (preserved) | File(s) | Responsibility | |---|---| | `andy.cpp` | Player state machine / animation FSM | | `benchmark.cpp` | CPU benchmark used during the loading screen | -| `defs.h` | On-disk struct layouts (`Lvl*`, `Sss*`, `Mst*`, `SetupConfig`, …) | +| `defs.h` | On-disk struct layouts (`Lvl*`, `Sss*`, `Mst*`, `SetupConfig`) | | `fileio.cpp/h` | Buffered binary file reader | | `fs.h`, `fs_posix.cpp`, `fs_android.cpp` | Filesystem abstraction | | `intern.h` | LE byte-swap helpers; this fork adds a macOS branch | -| `level.h`, `level1_rock.cpp` … `level9_dark.cpp` | Per-level scripted callbacks (`callLevel_initialize`, `callLevel_postScreenUpdate`, …) | -| `lzw.cpp` | LZW decompressor used for some assets in v11 data | -| `mdec.cpp/h`, `mdec_coeffs.h` | PSX MDEC video decoder for cutscene + background overlays | -| `menu.cpp/h` | Settings menus, cutscene replay screen, controls bind | +| `level.h`, `level1_rock.cpp` … `level9_dark.cpp` | Per-level scripted callbacks | +| `lzw.cpp` | LZW decompressor | +| `mdec.cpp/h`, `mdec_coeffs.h` | PSX MDEC video decoder | +| `menu.cpp/h` | Settings menus, cutscene replay, controls bind | | `mixer.cpp/h` | Software audio mixer | | `monsters.cpp` | Monster AI / animation tables | | `paf.cpp/h` | PAF cutscene format | | `random.cpp/h` | Game logic PRNG | -| `resource.cpp/h` | LVL / SSS / MST loader, sprite tables, font loader | -| `scaler.h`, `scaler_xbr.cpp` | Legacy software scalers (the small-window path) | -| `screenshot.cpp/h` | PNG dump (BMP fallback) | -| `sound.cpp` | SSS interpreter (script-driven sound triggers) | -| `staticres.cpp` | Baked-in tables (cutscene mappings, font character indices) | +| `resource.cpp/h` | LVL / SSS / MST loader, sprite tables | +| `scaler.h`, `scaler_xbr.cpp` | Legacy software scalers | +| `screenshot.cpp/h` | PNG/BMP dump | +| `sound.cpp` | SSS interpreter | +| `staticres.cpp` | Baked-in tables | | `system.h` | Abstract platform interface | -| `system_sdl2.cpp` | Active SDL2 backend (input, audio, screen, key mapping) | +| `system_sdl2.cpp` | Active SDL2 backend | | `system_psp.cpp`, `system_wii.cpp` | Legacy backends, **not built** by this Makefile | | `util.cpp/h` | Debug print, error helpers | | `video.cpp/h` | Legacy framebuffer + palette | -### Added by this fork +
+ +
+Added by this fork | File(s) | Responsibility | |---|---| @@ -701,7 +724,10 @@ PafPlayer::unload() | `test_combat_bot.py` | Reactive combat AI | | `test_hd_replay.py` | HD vs SD comparison harness | -### Modified vs upstream +
+ +
+Modified vs upstream | File | Why | |---|---| @@ -709,15 +735,17 @@ PafPlayer::unload() | `intern.h` | macOS `` shim | | `main.cpp` | New CLI flags + INI keys; wire `_video->_font` after `loadSetupDat()` | | `game.cpp/h` | HD compositor begin/end-frame hooks; smooth-anim 60 Hz interpolated render loop; per-level sprite + PAF prerender driver with shared progress bar | -| `paf.cpp/h` | HD frame callback path; `_prerenderMode` skips audio/display/sleep; `peekFramesCount(num)` for sizing the unified progress bar | +| `paf.cpp/h` | HD frame callback path; `_prerenderMode` skips audio/display/sleep; `peekFramesCount(num)` | | `menu.cpp/h` | Keyboard binding screen overhaul (OK/Cancel/Test, one-key-one-action, Space/Enter/Tab/Backspace/Win bindable, fallback labels); font init guard | | `system.h` | Expose `applyKeyboardControls` and `waitForKeyPress` on the abstract interface | | `system_sdl2.cpp` | Default mappings stay live alongside user keys; `waitForKeyPress` clears edge state on return; `copyRectRGBA` for HD presents | | `video.cpp` | Zero-init `_font` so the menu can't dereference garbage before `Game::mainLoop` wires it | +
+ --- -## Game world reference +## 🎮 Game world reference ### Levels @@ -733,11 +761,12 @@ PafPlayer::unload() | 7 | `lar2` | Lair 2 | | | 8 | `dark` | Dark | Final level (single checkpoint) | -Level callbacks dispatch from `Game::callLevel_*` into the `levelN_.cpp` matching the player's current level (`_currentLevel`). +Level callbacks dispatch from `Game::callLevel_*` into the `levelN_.cpp` matching `_currentLevel`. ### Cutscenes -The 25 PAF videos in `HOD.PAF`: +
+All 25 PAF videos in HOD.PAF | # | Symbol | Purpose | |---:|:--|:--| @@ -763,39 +792,41 @@ The 25 PAF videos in `HOD.PAF`: | 19 | `kPafAnimation_lastfall` | | | 20 | `kPafAnimation_end` | | | 21 | `kPafAnimation_cinema` | | -| 22 | `kPafAnimation_CanyonAndyFallingCannon` | In-gameplay: Canyon, falls with cannon + helmet | +| 22 | `kPafAnimation_CanyonAndyFallingCannon` | In-gameplay: Canyon, falls with cannon | | 23 | `kPafAnimation_CanyonAndyFalling` | In-gameplay: Canyon, falls without cannon | | 24 | `kPafAnimation_IslandAndyFalling` | In-gameplay: Island falling | -The per-level intro cutscene is taken from the table `_cutscenes[] = { 0, 2, 4, 5, 6, 8, 10, 14, 19 }` (one entry per level 0..8). +The per-level intro cutscene uses `_cutscenes[] = { 0, 2, 4, 5, 6, 8, 10, 14, 19 }`. + +
### Default key bindings | Key | Action | |---|---| | Arrow keys | Move (Up = climb, Down = crouch) | -| Left Ctrl, F | Run | -| Left Alt, G, Enter | Jump | -| Left Shift, H | Shoot | -| D, Space | Special (Run + Shoot — fires plasma cannon if equipped) | -| Esc | Pause / quit menu | -| S | Screenshot | +| `Left Ctrl`, `F` | Run | +| `Left Alt`, `G`, `Enter` | Jump | +| `Left Shift`, `H` | Shoot | +| `D`, `Space` | Special (Run + Shoot) | +| `Esc` | Pause / quit menu | +| `S` | Screenshot | ### Settings menu `Menu → Settings → Keyboard Controls` lets you bind any of Run / Jump / Shoot / Special: -- **Letters, digits, Shift, Ctrl, Alt** – have icon glyphs in the engine's bitmap font. -- **Space, Enter, Tab, Backspace, Cmd/Win** – also bindable; menu shows short text labels (`SP`, `EN`, `TB`, `BS`, `WN`) since the bitmap font has no glyphs for them. -- **Two slots per action** – the first bind goes into slot 1, the second into slot 2. -- **One key, one action** – binding a key already used by another action automatically clears the prior binding. -- **Defaults stay live** – `LCtrl`, `F`, `LAlt`, `G`, `LShift`, `H`, `D`, `Space` remain mapped to their defaults alongside any custom keys, so the in-menu Select handler always works while you're rebinding the rest. -- **OK / Cancel / Test row** – navigable via ←/→. Select on **OK** keeps changes, **Cancel** reverts to the controls snapshot taken when you entered the screen, **Test** enters a live-key visualisation where action icons light up as you press their keys (any arrow key exits Test). -- Esc inside the bind prompt cancels the bind only — it doesn't propagate to the outer menu and won't quit the game. +- ✅ **Letters, digits, Shift, Ctrl, Alt** – have icon glyphs in the engine's bitmap font. +- ✅ **Space, Enter, Tab, Backspace, Cmd/Win** – bindable; menu shows short text labels (`SP`, `EN`, `TB`, `BS`, `WN`). +- ✅ **Two slots per action** – first bind goes into slot 1, second into slot 2. +- ✅ **One key, one action** – binding a key already used by another action automatically clears the prior binding. +- ✅ **Defaults stay live** – `LCtrl`, `F`, `LAlt`, `G`, `LShift`, `H`, `D`, `Space` remain mapped to their defaults alongside any custom keys, so the in-menu Select handler always works while you're rebinding the rest. +- ✅ **OK / Cancel / Test row** – navigable via ←/→. Select on **OK** keeps changes, **Cancel** reverts, **Test** enters live key-press visualisation. +- ✅ **Esc cancels bind** – inside the bind prompt cancels the bind only; doesn't propagate to the outer menu. ### Cheat flags -`--cheats=N` (or runtime `cheats` integer) is a bitmask: +`--cheats=N` is a bitmask: | Bit | Symbol | Effect | |---:|:--|:--| @@ -809,7 +840,7 @@ The per-level intro cutscene is taken from the table `_cutscenes[] = { 0, 2, 4, ### Debug bitmask -`--debug=N` is a bitmask, OR'd into the global `g_debugMask`: +`--debug=N` is OR'd into `g_debugMask`: | Bit | Symbol | Output | |---:|:--|:--| @@ -819,60 +850,72 @@ The per-level intro cutscene is taken from the table `_cutscenes[] = { 0, 2, 4, | `1 << 3` | `kDebug_SOUND` | SSS interpreter | | `1 << 4` | `kDebug_PAF` | PAF cutscene player | | `1 << 5` | `kDebug_MONSTER` | Monster AI | -| `1 << 6` | `kDebug_SWITCHES` | `lar1` and `lar2` switches | +| `1 << 6` | `kDebug_SWITCHES` | `lar1` / `lar2` switches | | `1 << 7` | `kDebug_MENU` | Menu state machine | `--debug=255` enables everything. --- -## Data formats (brief) +## 📦 Data formats + +
+*_HOD.LVL — level geometry & sprite tables + +- 4-byte tag `0x4D5A4448` ('HDZM') at offset 0 +- Header counts: `screensCount`, `staticLvlObjectsCount`, `otherLvlObjectsCount`, `spritesCount` +- `_screensGrid[N][4]` at 0x08 — per-screen up/right/down/left neighbour table +- `_screensBasePos[N]` at 0xA8 — per-screen world coordinate +- `_screensState[N]` at 0x1E8 — per-screen flags +- `_resLvlScreenObjectDataTable[]` at 0x288 — 96-byte `LvlObject` entries +- Sprite type table at `_lvlSpritesOffset = 0x288 + 96 * (96 or 104)` — 32 × 16-byte entries +- Background table at `_lvlBackgroundsOffset = _lvlSpritesOffset + 32*16` — 40 × 16-byte entries -The original `Heart of Darkness` data files are well-defined binary formats; full reverse-engineering notes live in the upstream `hode` source as struct comments. A summary: +
-### `*_HOD.LVL` +
+*_HOD.SSS — sound script -- 4-byte tag `0x4D5A4448` ('HDZM') at offset 0. -- 4 bytes header counts: `screensCount`, `staticLvlObjectsCount`, `otherLvlObjectsCount`, `spritesCount`. -- `_screensGrid[N][4]` at 0x08: per-screen up/right/down/left neighbour table. -- `_screensBasePos[N]` at 0xA8: per-screen world coordinate. -- `_screensState[N]` at 0x1E8: per-screen flags (0..3). -- `_resLvlScreenObjectDataTable[]` at 0x288: 96-byte `LvlObject` entries. -- Sprite type table at `_lvlSpritesOffset = 0x288 + 96 * (96 or 104)`, 32 × 16-byte entries (each describes a `LvlObjectData` data island). -- Background table at `_lvlBackgroundsOffset = _lvlSpritesOffset + 32*16`, 40 × 16-byte entries. +- Sound-script bytecode interpreted by `sound.cpp` +- `SssBank`, `SssSample`, `SssPcm`, `SssPreloadList`, `SssPreloadInfoData` defined in `resource.h` +- ADPCM sample data (PSX) or PCM samples (PC) referenced by offset -### `*_HOD.SSS` +
-- Sound-script bytecode interpreted by `sound.cpp`. `SssBank`, `SssSample`, `SssPcm`, `SssPreloadList`, `SssPreloadInfoData` structures defined in `resource.h`. -- ADPCM sample data (PSX) or PCM samples (PC) referenced by offset. +
+*_HOD.MST — monsters & scripting -### `*_HOD.MST` +- Monster + scripting tables +- Op codes interpreted by `executeMstCode()` in `monsters.cpp` -- Monster + scripting tables. Op codes interpreted by `executeMstCode()` in `monsters.cpp`. +
-### `HOD.PAF` +
+HOD.PAF — Packed Animation File -- Container of N cutscenes, each indexed by a uint32 LE offset at the start. -- Each cutscene starts with a fixed signature `Packed Animation File V1.0\n(c) 1992-96 Amazing Studio\n`. -- Header at offset `+0x84..0xAC`: `framesCount`, `frameDuration`, `startOffset`, `preloadFrameBlocksCount`, `readBufferSize`, `maxVideoFrameBlocksCount`, `maxAudioFrameBlocksCount`, `frameBlocksCount`. -- Followed by `frameBlocksCountTable[framesCount]`, `framesOffsetTable[framesCount]`, `frameBlocksOffsetTable[frameBlocksCount]`. -- Each frame is delta-encoded against the previous; 4-page rotation buffer; 4 op codes (0..3) for partial vs full blits and palette updates. -- Audio (when present) is 22 kHz ADPCM in interleaved blocks. +- Container of N cutscenes, each indexed by a uint32 LE offset at the start +- Each cutscene starts with: `Packed Animation File V1.0\n(c) 1992-96 Amazing Studio\n` +- Header at offset `+0x84..0xAC`: `framesCount`, `frameDuration`, `startOffset`, `preloadFrameBlocksCount`, `readBufferSize`, `maxVideoFrameBlocksCount`, `maxAudioFrameBlocksCount`, `frameBlocksCount` +- Followed by `frameBlocksCountTable[framesCount]`, `framesOffsetTable[framesCount]`, `frameBlocksOffsetTable[frameBlocksCount]` +- Each frame is delta-encoded against the previous; 4-page rotation buffer; 4 op codes (0..3) for partial vs full blits and palette updates +- Audio (when present) is 22 kHz ADPCM in interleaved blocks -### `SETUP.DAT` +
-- Versioned (10 or 11). Header has counts (`iconsCount`, `menusCount`, `cutscenesCount`, `levelsCount`, `levelCheckpointsCount[8]`, `yesNoQuitImage`, `soundDataSize`, `loadingImageSize`). -- Followed by aligned blocks: loading image (with palette), font (1024 × 16-byte glyphs), menu/options bitmaps, hint images, sound metadata. +
+SETUP.DAT — fonts, hints, loading screen -### `setup.cfg` +- Versioned (10 or 11) +- Header has counts (`iconsCount`, `menusCount`, `cutscenesCount`, `levelsCount`, `levelCheckpointsCount[8]`, `yesNoQuitImage`, `soundDataSize`, `loadingImageSize`) +- Followed by aligned blocks: loading image (with palette), font (1024 × 16-byte glyphs), menu/options bitmaps, hint images, sound metadata -- 212-byte `SetupConfig` struct (`defs.h:55`) — 4 player slots × 52 bytes (progress, level, checkpoint, cutscenes mask, controls, difficulty, stereo, volume, last-level), plus `currentPlayer` and a checksum. +
--- -## Save state +## 💾 Save state -`setup.cfg` is the engine's binary save file. It tracks per player slot: +`setup.cfg` is the engine's binary save file (212 bytes, `SetupConfig` in [`defs.h:55`](defs.h#L55)). It tracks per player slot: - Per-level progress (highest checkpoint reached) - Last-played level + checkpoint @@ -880,86 +923,94 @@ The original `Heart of Darkness` data files are well-defined binary formats; ful - 32 bytes of controls (16 bytes joystick, 8 bytes keyboard scancodes, 8 unused) - Difficulty, stereo, volume, last-level -Up to 4 player slots. Selected via the menu. +Up to **4 player slots**, selected via the menu. --- -## Troubleshooting +## 🔧 Troubleshooting | Symptom | Cause / fix | |---|---| -| `fatal error: 'endian.h' file not found` (macOS) | Use this fork's `intern.h` (it has the macOS `__APPLE__` branch). `git pull && make clean && make`. | -| First-run 4K is sluggish | Expected: every sprite/cutscene frame upscales once. Use `--hd-cache=./cache --prerender`; the second run is instant. | +| `fatal error: 'endian.h' file not found` (macOS) | Use this fork's `intern.h` (has the `__APPLE__` branch). `git pull && make clean && make`. | +| First-run 4K is sluggish | Expected — every sprite/cutscene frame upscales once. Use `--hd-cache=./cache --prerender`; second run is instant. | | Widescreen "borders are still black" | Use `--hd-wide` (not `--widescreen`). `--widescreen` is the legacy blur-stretch path. | | Cutscenes display on screen during `--prerender` | Should not happen with this fork. If it does, you're running an older build — `make clean && make`. | -| Bound a key in the menu but in-game it does nothing | Custom keys are *additive*; defaults still work. Verify that nothing else captured the key (e.g. macOS Cmd+Space, OS-level hotkeys). | -| Game freezes on the loading screen with `--prerender` | Should not happen post-fix; the engine pumps SDL after prerender. If it does happen, build with `make clean && make` and rerun. | -| `setup.cfg` is corrupt or has a weird default keymap | Delete `setup.cfg` — the engine recreates a default one. | +| Bound a key in the menu but in-game it does nothing | Custom keys are *additive*; defaults still work. Verify nothing else captured the key (e.g. macOS Cmd+Space, OS hotkeys). | +| Game freezes on the loading screen with `--prerender` | Should not happen post-fix. If it does, `make clean && make` and rerun. | +| `setup.cfg` corrupt or weird default keymap | Delete `setup.cfg`; the engine recreates a default one. | | Audio is silent / distorted | Set `SDL_AUDIODRIVER=dummy` to disable, or `coreaudio` (macOS) / `pulseaudio` (Linux) to force a backend. | -| `Repository not found` when pushing to a fork | Make sure the GitHub fork exists (https://github.com/<user>/hode) before `git push`. | +| `Repository not found` when pushing to a fork | Make sure the GitHub fork exists at `https://github.com//hode` before `git push`. | --- -## Differences from upstream +## 📋 Differences from upstream A high-level diff vs `usineur/hode` master: -- **New modules** — `hd_compositor`, `sprite_upscaler`, `edge_smooth`, `automation_api`, plus four Python automation drivers. -- **New CLI flags** — `--hd`, `--hd-scale`, `--hd-wide`, `--hd-cache`, `--fullhd`, `--4k`, `--smooth`, `--automation`, `--prerender`. -- **New INI keys** — `hd_mode`, `hd_scale`, `hd_widescreen`, `hd_cache`, `smooth_anim`, `automation_socket`. -- **Sprite cache key** changed from heap pointer to FNV-1a(content + dims + flags + palette hash). The upstream pointer-keyed disk cache effectively never produced cross-run hits. -- **Widescreen window sizing** — `--hd-wide` now also sizes the SDL window 16:9 (otherwise the wide framebuffer was squashed back into a 4:3 window and gradient borders were invisible). -- **Palette-correct beginFrame** — `HdCompositor::beginFrame()` now expands the engine's 6-bit palette to 8-bit instead of treating it as already 8-bit, so border colours and HD sprite colours are not ~4× too dim. -- **PAF HD path** with disk cache and a fast-forward `prerender(num)` that skips audio / display / sleep. -- **Smooth animation** — 60 Hz interpolated render with 12.5 Hz logic. -- **Menu / input fixes** — OK / Cancel / Test sub-buttons, one-key-one-action enforcement, Space / Enter / Tab / Backspace / Cmd-Win bindable, default keys stay live alongside custom keys, font-pointer init hardened, edge state cleared on `waitForKeyPress`. -- **macOS portability** — `intern.h` selects `` on `__APPLE__`. +- **🎁 New modules** — `hd_compositor`, `sprite_upscaler`, `edge_smooth`, `automation_api`, plus four Python automation drivers. +- **🚩 New CLI flags** — `--hd`, `--hd-scale`, `--hd-wide`, `--hd-cache`, `--fullhd`, `--4k`, `--smooth`, `--automation`, `--prerender`. +- **📝 New INI keys** — `hd_mode`, `hd_scale`, `hd_widescreen`, `hd_cache`, `smooth_anim`, `automation_socket`. +- **🔑 Sprite cache key** changed from heap pointer to FNV-1a(content + dims + flags + palette hash). The upstream pointer-keyed disk cache effectively never produced cross-run hits. +- **📐 Widescreen window sizing** — `--hd-wide` now also sizes the SDL window 16:9 (otherwise the wide framebuffer was squashed back into a 4:3 window and gradient borders were invisible). +- **🎨 Palette-correct beginFrame** — `HdCompositor::beginFrame()` now expands the engine's 6-bit palette to 8-bit instead of treating it as already 8-bit, so border colours and HD sprite colours are not ~4× too dim. +- **🎬 PAF HD path** with disk cache and a fast-forward `prerender(num)` that skips audio / display / sleep. +- **🌊 Smooth animation** — 60 Hz interpolated render with 12.5 Hz logic. +- **🧰 Menu / input fixes** — OK / Cancel / Test sub-buttons, one-key-one-action enforcement, Space / Enter / Tab / Backspace / Cmd-Win bindable, default keys stay live alongside custom keys, font-pointer init hardened, edge state cleared on `waitForKeyPress`. +- **🍎 macOS portability** — `intern.h` selects `` on `__APPLE__`. --- -## Known limitations +## ⚠️ Known limitations -- The HD compositor only supports the SDL2 backend. PSP / Wii are not HD-capable in this fork. -- `--4k` is internally 16× cropped to 15× to fit the 4K UHD frame; visible artifacts are below 1 px. -- Cutscene prerender at 4K can use multiple GB of disk per cutscene. For the full PAF set, prefer `--hd` or `--fullhd`. +- The HD compositor only supports the **SDL2** backend. PSP / Wii are not HD-capable in this fork. +- `--4k` is internally **16× cropped to 15×** to fit the 4K UHD frame; visible artifacts are below 1 px. +- Cutscene prerender at 4K can use **multiple GB of disk** per cutscene. For the full PAF set, prefer `--hd` or `--fullhd`. - The legacy `--widescreen` (blur-stretch) is preserved for parity but `--hd-wide` is preferred when the HD compositor is on. -- The automation API supports one client at a time. +- The automation API supports **one client at a time**. --- -## Contributing +## 🤝 Contributing -1. Fork the repo on GitHub. +1. Fork the repo on GitHub 2. Branch off `master`: ```bash git checkout -b feature/ ``` -3. Make changes; follow the existing code style: - - Tabs for indentation, K&R braces +3. Make changes; follow the existing style: + - **Tabs** for indentation, K&R braces - `snake_case` for free functions, `lowerCamelCase` for methods, `_member` for fields - - Plain C++11 — no STL containers in hot paths; raw arrays + `malloc`/`free` are fine -4. Build cleanly with `-Wall -Wextra -Wpedantic` (no new warnings). -5. Commit with a descriptive message and open a PR against this fork or `usineur/hode`. + - **Plain C++11** — no STL containers in hot paths; raw arrays + `malloc`/`free` are fine +4. Build cleanly with `-Wall -Wextra -Wpedantic` (no new warnings) +5. Commit with a descriptive message and open a PR against this fork or `usineur/hode` --- -## Credits +## 👏 Credits - **Original engine** — reverse-engineered by **Gregory Montoir** (cyx@users.sourceforge.net). See [`usineur/hode`](https://github.com/usineur/hode), upstream `README.txt`, and `CHANGES.txt` (preserved in this tree). - **Original game** — *Heart of Darkness* by **Amazing Studio** (1998), published by Infogrames / Ocean. -- **xBRZ-style scaling** — based on Zenju's xBRZ algorithm (https://sourceforge.net/projects/xbrz/). -- **MLAA edge smoothing** — classic anti-aliasing technique (Reshetov 2009 / Jimenez et al.). +- **xBRZ-style scaling** — based on Zenju's xBRZ algorithm — https://sourceforge.net/projects/xbrz/ +- **MLAA edge smoothing** — classic anti-aliasing technique (Reshetov 2009 / Jimenez et al.) External links: -- [MobyGames: Heart of Darkness](https://www.mobygames.com/game/heart-of-darkness) -- [heartofdarkness.ca](http://heartofdarkness.ca/) — fan resource -- [usineur/hode upstream](https://github.com/usineur/hode) +- 🎮 [MobyGames: Heart of Darkness](https://www.mobygames.com/game/heart-of-darkness) +- 🌐 [heartofdarkness.ca](http://heartofdarkness.ca/) — fan resource +- 🐙 [usineur/hode upstream](https://github.com/usineur/hode) --- -## License & legal +## ⚖️ License & legal -The engine source is provided under the same terms as upstream `hode` (no explicit LICENSE file in upstream — treat it as "use at your own risk; please credit the original author"). The HD additions in this fork inherit the same terms. +The engine source is provided under the same terms as upstream `hode` (no explicit `LICENSE` file in upstream — treat it as "use at your own risk; please credit the original author"). The HD additions in this fork inherit the same terms. The *Heart of Darkness* game data (`HOD.PAF`, `SETUP.DAT`, `*_HOD.*`) is **copyrighted by Amazing Studio / Infogrames** and is **not redistributed** here. You must own a legitimate copy of the original game to play. + +--- + +
+ +*Made with care for a 1998 cinematic platformer that still holds up.* + +
diff --git a/README.txt b/README.txt deleted file mode 100644 index dea8796..0000000 --- a/README.txt +++ /dev/null @@ -1,65 +0,0 @@ - -hode README -Release version: 0.2.9f -------------------------------------------------------------------------------- - - -About: ------- - -hode is a reimplementation of the engine used by the game 'Heart of Darkness' -developed by Amazing Studio. - - -Datafiles: ----------- - -The original datafiles from the Windows releases (Demo or CD) are required. - -- hod.paf (hod_demo.paf, hod_demo2.paf) -- setup.dat -- *_hod.lvl -- *_hod.sss -- *_hod.mst - -See also the 'RELEASES.yaml' file for a list of game versions this program -has been tested with. - - -Running: --------- - -By default the engine will try to load the files from the current directory -and start the game from the first level. - -These defaults can be changed using command line switches : - - Usage: hode [OPTIONS]... - --datapath=PATH Path to data files (default '.') - --savepath=PATH Path to save files (default '.') - --level=NUM Start at level NUM - --checkpoint=NUM Start at checkpoint NUM - -Display and engine settings can be configured in the 'hode.ini' file. - -Game progress is saved in 'setup.cfg', similar to the original engine. - - -Credits: --------- - -All the team at Amazing Studio for possibly the best cinematic platformer ever -developed. - - -Contact: --------- - -Gregory Montoir, cyx@users.sourceforge.net - - -URLs: ------ - -[1] https://www.mobygames.com/game/heart-of-darkness -[2] http://heartofdarkness.ca/