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..9517c18 --- /dev/null +++ b/README.md @@ -0,0 +1,1016 @@ +
|
+
+๐ผ๏ธ **HD upscaling**
+Chained xBRZ at 6ร / 8ร / 10ร / 15ร, MLAA edge smoothing, per-screen background cached once.
+
+๐บ **16:9 widescreen**
+Palette-derived gradient borders. No fake gameplay.
+
+โฑ๏ธ **Smooth animation**
+60 Hz render with sprite-position interpolation, 12.5 Hz logic.
+
+๐ฌ **Cutscene HD cache**
+PAF frames upscaled and stored under `cache/paf/ |
+
+
+๐ **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 ` |
+
.cpp` matching `_currentLevel`.
+
+### Cutscenes
+
+
+All 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 |
+| 23 | `kPafAnimation_CanyonAndyFalling` | In-gameplay: Canyon, falls without cannon |
+| 24 | `kPafAnimation_IslandAndyFalling` | In-gameplay: Island falling |
+
+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) |
+| `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** โ 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` 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 OR'd into `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` / `lar2` switches |
+| `1 << 7` | `kDebug_MENU` | Menu state machine |
+
+`--debug=255` enables everything.
+
+---
+
+## ๐ฆ 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
+
+
+
+
+*_HOD.SSS โ sound script
+
+- 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.MST โ monsters & scripting
+
+- Monster + scripting tables
+- Op codes interpreted by `executeMstCode()` in `monsters.cpp`
+
+
+
+
+HOD.PAF โ Packed Animation File
+
+- 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 โ fonts, hints, loading screen
+
+- 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
+
+
+
+---
+
+## ๐พ Save state
+
+`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
+- 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.
+
+---
+
+## ๐ง Troubleshooting
+
+| Symptom | Cause / fix |
+|---|---|
+| `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 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 at `https://github.com//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 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), 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
+
+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/
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;