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 @@ +
+ +# ๐ŸŒ‘ hode-hd + +**An HD-capable, scriptable fork of the [`hode`](https://github.com/usineur/hode) engine for *Heart of Darkness* (Amazing Studio, 1998).** + +[![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20WSL-blue)](#building) +[![Language](https://img.shields.io/badge/C%2B%2B-11-blue.svg)](https://en.cppreference.com/w/cpp/11) +[![Backend](https://img.shields.io/badge/SDL2-2.x-informational)](https://www.libsdl.org/) +[![Status](https://img.shields.io/badge/build-passing-brightgreen)](#building) +[![Upstream](https://img.shields.io/badge/upstream-usineur%2Fhode-lightgrey)](https://github.com/usineur/hode) + +*Faithful to the original 12.5 Hz logic. Modern presentation, persistent cache, scriptable from Python.* + +
+ +--- + +```bash +$ ./hode --4k --hd-wide --smooth --hd-cache=./cache --prerender +HD mode: 15x scale (3840x2880), 16:9 widescreen, disk cache, prerender +prerender level 0 (rock) [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ] 5320/5320 (100%) 6.9s +``` + +The original engine logic is **preserved exactly** โ€” same 12.5 Hz tick, same `.lvl` / `.sss` / `.mst` / `.paf` data path, same per-level scripted callbacks. This fork adds a **configurable presentation pipeline** on top: chained-xBRZ HD upscaling with MLAA edge smoothing, 16:9 widescreen with palette-aware borders, decoupled 60 Hz interpolated render, persistent disk cache, sprite + cutscene prerender, plus a programmable Unix-socket automation API and a handful of menu / input / portability fixes. + +> [!IMPORTANT] +> **Game data not included.** You must own the original *Heart of Darkness* PC release. Place `HOD.PAF`, `SETUP.DAT`, and the per-level files next to the binary or pass `--datapath=PATH`. + +--- + +## โœจ Highlights + + + + + + +
+ +๐Ÿ–ผ๏ธ **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/x/`. + + + +๐Ÿš€ **Prerender** +Single console progress bar covers every sprite (incl. per-screen background animations) and the in-gameplay PAF clips. + +๐Ÿค– **Automation API** +Unix-domain JSON: `get_state`, `input`, `step`, `screenshot`, `set_level`. + +๐Ÿงฐ **Menu/input fixes** +Bind any key (incl. Space/Enter/Tab/Backspace/Win); OK/Cancel/Test row works; defaults stay live. + +๐ŸŽ **macOS support** +Builds on Apple Silicon and Intel via `` shim. + +
+ +--- + +## ๐Ÿ“š Table of contents + +
+Click to expand + +- [โœจ Highlights](#-highlights) +- [๐Ÿ› ๏ธ Building](#%EF%B8%8F-building) + - [๐ŸŽ macOS (Apple Silicon & Intel)](#-macos-apple-silicon--intel) + - [๐Ÿง Linux](#-linux) + - [๐ŸชŸ Windows (WSL)](#-windows-wsl) +- [๐Ÿ’พ Game data files](#-game-data-files) +- [๐Ÿš€ Running](#-running) + - [Quick examples](#quick-examples) + - [CLI flag reference](#cli-flag-reference) + - [`hode.ini` reference](#hodeini-reference) +- [๐ŸŽจ Display & rendering modes](#-display--rendering-modes) +- [๐Ÿ—‚๏ธ Disk cache & prerender](#%EF%B8%8F-disk-cache--prerender) +- [๐Ÿค– Automation API](#-automation-api) +- [๐Ÿงช Test scripts](#-test-scripts) +- [๐Ÿ—๏ธ Engine architecture](#%EF%B8%8F-engine-architecture) +- [๐Ÿ—บ๏ธ Source file map](#%EF%B8%8F-source-file-map) +- [๐ŸŽฎ Game world reference](#-game-world-reference) +- [๐Ÿ“ฆ Data formats](#-data-formats) +- [๐Ÿ’พ Save state](#-save-state) +- [๐Ÿ”ง Troubleshooting](#-troubleshooting) +- [๐Ÿ“‹ Differences from upstream](#-differences-from-upstream) +- [โš ๏ธ Known limitations](#%EF%B8%8F-known-limitations) +- [๐Ÿค Contributing](#-contributing) +- [๐Ÿ‘ Credits](#-credits) +- [โš–๏ธ License & legal](#%EF%B8%8F-license--legal) + +
+ +--- + +## ๐Ÿ› ๏ธ Building + +The project uses a hand-written GNU `Makefile`. **The only external dependency is SDL2.** C++11 with warnings turned up (`-Wall -Wextra -Wpedantic`); the build is warning-free on macOS clang and Linux gcc. + +```bash +make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" +``` + +Outputs `./hode` (~800 KB binary). `make clean` removes objects and dependency files. + +
+๐Ÿ“ˆ Optimised release build + +```bash +make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" \ + CPPFLAGS='-O2 -std=c++11 -Wall -Wextra -Wno-unused-parameter -Wpedantic -MMD' +``` +
+ +### ๐ŸŽ macOS (Apple Silicon & Intel) + +> [!NOTE] +> Upstream `hode` does **not** build on macOS โ€” clang's libc has no glibc-style ``. This fork ships the fix in [`intern.h`](intern.h): +> +> ```cpp +> #elif defined(__APPLE__) +> #include +> #define le16toh(x) OSSwapLittleToHostInt16(x) +> #define le32toh(x) OSSwapLittleToHostInt32(x) +> #define htole16(x) OSSwapHostToLittleInt16(x) +> #define htole32(x) OSSwapHostToLittleInt32(x) +> static const bool kByteSwapData = (__BYTE_ORDER__ == __ORDER_BIG_ENDIAN__); +> ``` + +**Step-by-step on a fresh macOS install:** + +```bash +# 1. Xcode Command Line Tools (clang + git) +xcode-select --install + +# 2. Homebrew SDL2 +brew install sdl2 + +# 3. Build +make -j"$(sysctl -n hw.ncpu)" + +# 4. Run (data files alongside the binary) +./hode --hd +``` + +If `brew` is in `/opt/homebrew` (Apple Silicon default), make sure it's on `PATH` so `sdl2-config` resolves: + +```bash +echo 'export PATH="/opt/homebrew/bin:$PATH"' >> ~/.zprofile +source ~/.zprofile +``` + +
+๐ŸŽ macOS-specific notes + +| Concern | Notes | +|---|---| +| **Display backend** | SDL2 uses Metal via `SDL_RENDERER_ACCELERATED`; nothing extra required. | +| **Endianness** | `` is the macOS-blessed equivalent of ``. The fork's `intern.h` selects the right header per platform. | +| **Hardened runtime / Gatekeeper** | The Makefile produces an unsigned binary. First Finder launch may show "cannot be opened" โ€” right-click โ†’ Open, or run from Terminal. No entitlements required. | +| **Audio** | Uses SDL's CoreAudio backend by default. `SDL_AUDIODRIVER=dummy` disables for headless / CI use. | +| **Apple Silicon** | Native arm64 build; clang auto-vectorisation benefits the xBRZ scalers. No Rosetta required. | +| **Headless** | macOS has no easy `Xvfb` equivalent. Use `SDL_VIDEODRIVER=dummy SDL_AUDIODRIVER=dummy` for non-interactive runs (still requires a window server). For real headless CI, run the Linux build in a container with `xvfb-run`. | +| **Bundle / `.app`** | Ships as a CLI binary, not a `.app`. Wrap with [`appify`](https://gist.github.com/mathiasbynens/674099) if you want Finder integration. | +| **Debug build** | `make clean && make -j"$(sysctl -n hw.ncpu)" CPPFLAGS='-g -O0 -std=c++11 -Wall -Wextra -Wno-unused-parameter -Wpedantic -MMD' && lldb -- ./hode --hd` | + +A clean macOS build is normally a few seconds: +```text +$ make clean && make -j$(sysctl -n hw.ncpu) 2>&1 | tail -3 +c++ -g -std=c++11 ... -c -o video.o video.cpp +c++ -o hode andy.o automation_api.o ... `sdl2-config --libs` +``` + +
+ +### ๐Ÿง Linux + +```bash +# Debian / Ubuntu / WSL +sudo apt install build-essential libsdl2-dev pkg-config + +# Fedora / RHEL +sudo dnf install gcc-c++ SDL2-devel + +# Arch +sudo pacman -S base-devel sdl2 + +make -j"$(nproc)" +``` + +Headless CI: + +```bash +SDL_AUDIODRIVER=dummy xvfb-run -a ./hode --automation=/tmp/hode.sock --hd +``` + +### ๐ŸชŸ Windows (WSL) + +WSL2 + WSLg (built into Windows 11) works out of the box: + +```bash +sudo apt install build-essential libsdl2-dev pkg-config +make -j"$(nproc)" +``` + +Native Windows builds (MinGW / MSYS2) are not currently maintained โ€” the Makefile assumes POSIX. Patches welcome. + +--- + +## ๐Ÿ’พ Game data files + +> [!CAUTION] +> The data files are **not redistributed**. You must own a legitimate copy of *Heart of Darkness*. + +Place these next to the binary or pass `--datapath=PATH`: + +| File | Description | Size (retail) | +|---|---|---:| +| `HOD.PAF` | All cutscenes (Packed Animation File container) | ~411 MB | +| `SETUP.DAT` | Strings, fonts, hint images, loading screen, sound metadata | ~5.5 MB | +| `*_HOD.LVL` ร—9 | Per-level geometry, sprite tables, screen layout | 0.7 โ€“ 5 MB each | +| `*_HOD.SSS` ร—9 | Sound script (SssBank/SssSample) | 3 โ€“ 7 MB each | +| `*_HOD.MST` ร—9 | Monster + scripting tables | 10 โ€“ 150 KB each | + +PSX disc data (`*.dax`, MDEC backgrounds, SPU ADPCM) is also supported โ€” the HD upscaler works for both PC and PSX paths. + +`RELEASES.yaml` (carried over from upstream) lists SHA-1 hashes for every game version validated against (French / German / English Win32, demos, PSX). If your data files act weird, compare them there. + +--- + +## ๐Ÿš€ Running + +### Quick examples + +```bash +# Vanilla 256ร—192 windowed (upstream behaviour) +./hode + +# HD (1536ร—1152) at default 6ร— scale +./hode --hd + +# 4K with 16:9 borders, 60 Hz interpolation, persistent cache, prerender first run +./hode --4k --hd-wide --smooth --hd-cache=./cache --prerender + +# Skip menu, jump to Island level, checkpoint 2, in HD +./hode --level=isld --checkpoint=2 --hd + +# Custom 9ร— scale (2304ร—1728) +./hode --hd-scale=9 + +# Scriptable mode โ€” opens a Unix socket and disables menu/loading/cutscenes +./hode --automation=/tmp/hode.sock --hd + +# Read data files from elsewhere +./hode --datapath=/Volumes/HOD-CD --savepath="$HOME/Library/Application Support/hode" +``` + +### CLI flag reference + +> All flags use the GNU long form (`--name` or `--name=value`); there are no short flags. + +#### Path & navigation + +| Flag | Argument | Default | Effect | +|---|---|---|---| +| `--datapath=PATH` | path | `.` | Directory containing game data files | +| `--savepath=PATH` | path | `.` | Directory for `setup.cfg` and screenshots | +| `--level=NUM\|NAME` | int 0โ€“8 or name | โ€” | Skip menu, start at given level | +| `--checkpoint=NUM` | int | 0 | Checkpoint within `--level` | + +Level names (also accept indices): `rock`, `fort`, `pwr1`, `isld`, `lava`, `pwr2`, `lar1`, `lar2`, `dark`. + +#### HD rendering + +| Flag | Argument | Default | Effect | +|---|---|---|---| +| `--hd` | โ€” | off | Enable HD compositor at default 6ร— scale | +| `--hd-scale=N` | int 2โ€“16 | โ€” | HD compositor at custom scale; implies `--hd` | +| `--fullhd` | โ€” | off | Shortcut: HD at 8ร— (2048ร—1536) | +| `--4k` | โ€” | off | Shortcut: HD at 15ร— (3840ร—2880) | +| `--hd-wide` | โ€” | off | 16:9 framebuffer with palette-gradient borders; sizes window 16:9 | +| `--hd-cache=PATH` | path | โ€” | Read/write upscaled artifacts under `PATH/x/` | +| `--prerender` | โ€” | off | Pre-fill sprite + relevant PAF caches per level | +| `--smooth` | โ€” | off | 60 Hz render with sprite-position interpolation | + +#### Scripting & debug + +| Flag | Argument | Default | Effect | +|---|---|---|---| +| `--automation=PATH` | unix-socket | โ€” | Open JSON automation socket; disables menu/loading/cutscenes | +| `--debug=MASK` | int | 0 | OR'd into `g_debugMask` ([see below](#debug-bitmask)) | +| `--cheats=MASK` | int | 0 | OR'd into cheat flags ([see below](#cheat-flags)) | + +> CLI flags **override** matching `hode.ini` keys. + +### `hode.ini` reference + +Place a `hode.ini` next to the binary (or in `--datapath`) for persistent configuration: + +```ini +[engine] +disable_paf = false ; skip cutscenes (auto-true if HOD.PAF missing) +disable_mst = false ; skip monster scripting (debugging only) +disable_sss = false ; skip sound script (debugging only) +disable_menu = false ; boot straight into the game +max_active_sounds = 16 ; SSS object pool cap +difficulty = 1 ; 0=easy, 1=normal, 2=hard +frame_duration = 80 ; ms per game tick (default 80 = 12.5 Hz) +loading_screen = true ; show "please wait" image during level load + +[display] +scale_factor = 3 ; integer upscaler factor (legacy non-HD path) +scale_algorithm = nearest ; nearest | linear | xbr | nearest+blur | โ€ฆ +gamma = 1.0 ; output gamma (1.0 = neutral) +fullscreen = false +widescreen = false ; legacy 16:9 path (blur-stretch). Prefer hd_widescreen. +hd_mode = false ; same as --hd +hd_scale = 0 ; same as --hd-scale=N (0 = default 6) +hd_widescreen = false ; same as --hd-wide +hd_cache = ./cache ; same as --hd-cache +smooth_anim = false ; same as --smooth +automation_socket = /tmp/hode.sock ; same as --automation +``` + +> [!TIP] +> Booleans accept `true`/`false`, `t`/`f`, `1`/`0` (case-insensitive). Lines starting with `#` are comments. `disable_paf` is auto-set to `true` if the engine can't open `HOD.PAF`. + +--- + +## ๐ŸŽจ Display & rendering modes + +### Standard mode + +`./hode` (no flags) gives you the upstream behaviour: 256ร—192 indexed-colour framebuffer, integer-upscaled `scale_factor` times by SDL through the chosen software scaler. Behaviour and visuals match upstream `hode` exactly. + +### HD upscaling + +`--hd` / `--fullhd` / `--4k` / `--hd-scale=N` route every visible pixel through `HdCompositor` ([`hd_compositor.{h,cpp}`](hd_compositor.cpp)): + +- Background bitmap is upscaled **once per screen change** and reused. +- Each sprite is upscaled **once per (content + dimensions + flip + palette) tuple** and cached. +- HD framebuffer is RGBA32; SDL2 streams it to a `SDL_Texture` per frame. + +### Scale chains + +xBRZ scales by 2ร—, 3ร—, 4ร— or 5ร— per pass; arbitrary scales decompose into two passes: + +| Preset / `--hd-scale` | Pass 1 | Pass 2 | Output (256ร—192) | +|---:|:--|:--|:--| +| 2 | 2ร— | โ€” | 512 ร— 384 | +| 3 | 3ร— | โ€” | 768 ร— 576 | +| 4 | 4ร— *(2ร—ยท2ร—)* | โ€” | 1024 ร— 768 | +| 5 | 5ร— *(nearest)* | โ€” | 1280 ร— 960 | +| **6 (`--hd`)** | 3ร— | 2ร— | **1536 ร— 1152** | +| 7 | 4ร— | 2ร— โ†’ crop | 1792 ร— 1344 | +| **8 (`--fullhd`)** | 4ร— | 2ร— | **2048 ร— 1536** | +| 9 | 3ร— | 3ร— | 2304 ร— 1728 | +| 10 | 4ร— | 3ร— โ†’ crop | 2560 ร— 1920 | +| 11 | 4ร— | 3ร— โ†’ crop | 2816 ร— 2112 | +| 12 | 4ร— | 3ร— | 3072 ร— 2304 | +| 13 | 4ร— | 4ร— โ†’ crop | 3328 ร— 2496 | +| 14 | 4ร— | 4ร— โ†’ crop | 3584 ร— 2688 | +| **15 (`--4k`)** | 4ร— | 4ร— โ†’ SDL downscale | **3840 ร— 2880** | +| 16 | 4ร— | 4ร— | 4096 ร— 3072 | + +After the chained scale, [`mlaa_smooth()`](edge_smooth.cpp) runs a morphological-edge-AA pass that softens stair-stepping artefacts on diagonal edges (cape, hair, plasma traces). + +### Widescreen 16:9 borders + +`--hd-wide` switches the HD framebuffer from 4:3 to 16:9. The 4:3 game stays centred; left and right margins are filled with a **vertical gradient** sampled from the top and bottom rows of the **current screen's palette**, then darkened to ~โ…“ brightness so it reads as ambient atmosphere instead of fake gameplay. Borders re-sample whenever the screen changes, so they track lighting/mood per area. + +> [!NOTE] +> This fork **resizes the SDL window itself to 16:9** when `--hd-wide` is on. Without that change (upstream behaviour), the wide framebuffer was squished into a 4:3 window and the colored borders were clipped to invisibility. + +### Smooth (60 Hz) animation + +`--smooth` decouples logic from rendering: + +```text + โ”Œโ”€โ”€โ”€โ”€โ”€ 12.5 Hz โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€ ~60 Hz โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ game tick โ”‚ โ”‚ render frame โ”‚ + โ”‚ โ€’ AI / physics โ”‚ โ”€โ”€โ–บ โ”‚ โ€’ interpolate โ”‚ + โ”‚ โ€’ sprite list โ”‚ โ”‚ prev โ†’ curr โ”‚ + โ”‚ โ€’ snapshot pos โ”‚ โ”‚ โ€’ blit to HD โ”‚ + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ +``` + +- Game tick stays at 80 ms (12.5 Hz). Collision, AI, sound, scripted callbacks behave exactly as upstream. +- `Game::saveInterpolationState()` snapshots `(prev, curr)` for Andy and every visible sprite at each tick. +- `Game::renderInterpolatedFrame(t)` โ€” called at the render rate โ€” blends with `t โˆˆ [0,1]`. + +The result is fluid on-screen motion with **zero impact on gameplay timing**. + +--- + +## ๐Ÿ—‚๏ธ Disk cache & prerender + +`--hd-cache=PATH` makes every upscaled artifact persistent. + +### On-disk layout + +```text +PATH/ +โ”œโ”€โ”€ 6x/ โ† sprites at the active scale +โ”‚ โ””โ”€โ”€ spr__x.raw โ† header (int32 w, int32 h) + w*h*4 RGBA +โ”œโ”€โ”€ 8x/ +โ””โ”€โ”€ paf/ + โ”œโ”€โ”€ 6x/ + โ”‚ โ”œโ”€โ”€ v00/ โ† cutscene 0 (intro) + โ”‚ โ”‚ โ”œโ”€โ”€ f0000.raw + โ”‚ โ”‚ โ””โ”€โ”€ โ€ฆ + โ”‚ โ”œโ”€โ”€ v22/ โ† Canyon Andy-falling-with-cannon + โ”‚ โ””โ”€โ”€ v24/ โ† Island Andy-falling + โ””โ”€โ”€ 8x/ +``` + +Each `x/` directory is independent โ€” switching `--hd-scale` populates a new directory without touching existing ones. + +### Cache key + +```text +key = FNV-1a-64( + decoded indexed bytes (w ร— h) + โŠ• w little-endian uint16 + โŠ• h little-endian uint16 + โŠ• flags & 3 โ† horizontal-flip bit + โŠ• palette FNV-1a-64 + ) +``` + +Including the **palette hash** is the reason the same sprite content rendered with different on-screen palettes (between screens, cross-fade, cutscenes) gets distinct cache entries. Without this, low scales (HD/FullHD) would occasionally show *wrong-coloured* sprite frames as a stale entry was reused after a palette transition. + +> [!WARNING] +> This changes the on-disk format compared to upstream and earlier builds of this fork. **Delete any pre-existing `cache/` directory** before the first run with palette-hashed keys. + +### Prerender flow + +`--prerender` populates the cache up-front, **once per level** (tracked via `Game::_hdPrerenderedMask`). After `Game::restartLevel()` finishes loading the level and before the gameplay loop starts: + +1. **Sprite phase** โ€” walk every `(sprite-type, frame, flip)` tuple across: + - the main level table `_resLevelData0x2988PtrTable[0..31]` + - every screen's `backgroundLvlObjectDataTable[0..7]` (per-screen background animations: trees, water, ambient effects). Without this, those animations only upscaled the first time they appeared on-screen. +2. **PAF phase** โ€” fast-forward decode the small in-gameplay clips (Canyon falling with cannon, Canyon falling, Island falling) plus the level's intro cutscene. Each not-yet-cached cutscene is decoded full-speed without audio/display/sleep so the existing HD frame callback writes every frame to disk. +3. Both phases share **one progress bar**: + ```text + prerender level 0 (rock) [โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ....] 4720/5320 (88%) 5.2s eta 0.7s + ``` + +`_playedMask` (watched cutscenes) is snapshotted/restored across each PAF prerender so your save isn't polluted with movies you didn't actually watch. After the bar completes, the engine pumps SDL once and re-baselines `inp.prevMask = inp.mask` so any held keys don't fire phantom press/release events on the first gameplay tick. + +--- + +## ๐Ÿค– Automation API + +`--automation=/tmp/hode.sock` opens a non-blocking `AF_UNIX` `SOCK_STREAM` listening for one client at a time. **Newline-delimited JSON** in both directions. + +`--automation` implies: + +- ๐Ÿšซ `disable_menu = true` โ€” boot straight into gameplay +- ๐Ÿšซ `loading_screen = false` โ€” no "please wait" +- ๐Ÿšซ `disable_paf = true` โ€” cutscenes skipped + +### Protocol + +- One JSON object per line, terminated with `\n`. +- Server only responds to `get_state` and `screenshot`. Other commands are fire-and-forget. +- One client at a time. Connection drop is detected on next `read()`; engine continues and re-`accept()`s. + +### Commands + +| `cmd` | Body fields | Reply | Effect | +|---|---|---|---| +| `get_state` | โ€“ | JSON object *(see below)* | Snapshot Andy + monsters + level/checkpoint/screen | +| `input` | `dir`, `act`, `frames`, `raw` | โ€“ | Inject input for `frames` ticks | +| `step` | `count` | โ€“ | Enable step mode and step `count` ticks | +| `screenshot` | โ€“ | header line + `width*height*3` raw RGB bytes | Capture 256ร—192 framebuffer | +| `set_level` | `level`, `checkpoint` | โ€“ | Restart at given level/checkpoint | + +`get_state` reply: + +```json +{ + "andy": { + "x": 128, "y": 96, "screen": 0, + "anim": 0, "frame": 0, "sprite": 0, + "hasCannon": true, "dying": false + }, + "level": 0, "checkpoint": 0, "screen": 0, + "endLevel": false, + "monsters": [ + {"x": 200, "y": 100, "type": 1, "i": 0}, + {"x": 220, "y": 100, "type": 2, "i": 0} + ], + "monsterCount": 2 +} +``` + +Input bit layout: + +| Bit | `dir` | `act` | Raw `SYS_INP_*` | +| ---: | :----- | :----- | :---------------------------- | +| 0x01 | UP | RUN | `SYS_INP_UP=0x01`, `RUN=0x10` | +| 0x02 | RIGHT | JUMP | `SYS_INP_RIGHT=0x02`, `JUMP=0x20` | +| 0x04 | DOWN | SHOOT | `SYS_INP_DOWN=0x04`, `SHOOT=0x40` | +| 0x08 | LEFT | โ€“ | `SYS_INP_LEFT=0x08` | +| 0x80 | โ€“ | โ€“ | `SYS_INP_ESC=0x80` (raw only) | + +### Python client + +```python +import socket, json + +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect("/tmp/hode.sock") +f = s.makefile("rwb", buffering=0) + +def send(j): f.write((json.dumps(j) + "\n").encode()) +def recv_line(): return json.loads(f.readline().decode()) + +# State snapshot +send({"cmd": "get_state"}) +state = recv_line() +print(state["andy"], "monsters:", state["monsterCount"]) + +# Walk right + run for 30 ticks +send({"cmd": "input", "dir": 2, "act": 1, "frames": 30}) + +# Frame-by-frame step for 10 ticks +send({"cmd": "step", "count": 10}) + +# Screenshot โ€” header then raw RGB +send({"cmd": "screenshot"}) +hdr = recv_line() # {"width":256,"height":192,"format":"rgb","size":N} +img = b"" +while len(img) < hdr["size"]: + img += s.recv(4096) +open("frame.rgb", "wb").write(img) + +# Jump to a specific level +send({"cmd": "set_level", "level": 3, "checkpoint": 0}) +``` + +### Headless testing + +```bash +# Linux +SDL_AUDIODRIVER=dummy xvfb-run -a ./hode --automation=/tmp/hode.sock --hd + +# macOS (still needs a window server; for true headless use Linux in CI) +SDL_AUDIODRIVER=dummy ./hode --automation=/tmp/hode.sock +``` + +--- + +## ๐Ÿงช Test scripts + +Drop-in Python drivers using the automation API. All require a running `./hode --automation=/tmp/hode.sock`. + +| Script | Purpose | +|---|---| +| [`test_walkthrough.py`](test_walkthrough.py) | Drive Andy through level 1 end-to-end | +| [`test_all_levels.py`](test_all_levels.py) | Boot each of the 9 levels, verify gameplay starts | +| [`test_combat_bot.py`](test_combat_bot.py) | Reactive AI: fires when monsters in range | +| [`test_hd_replay.py`](test_hd_replay.py) | Compare HD vs SD rendering frame-by-frame | + +--- + +## ๐Ÿ—๏ธ Engine architecture + +### High-level dataflow + +```text +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ main.cpp โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ parse CLI / hode.ini โ†’ readConfigIni() โ”‚ +โ”‚ open SETUP.DAT โ†’ Resource::loadSetupDat โ”‚ +โ”‚ init SDL2/audio โ†’ System::init / setupAudioโ”‚ +โ”‚ if --hd : new HdCompositor โ”‚ +โ”‚ if --automation : new AutomationApi โ”‚ +โ”‚ loop: โ”‚ +โ”‚ Game::loadSetupCfg โ”‚ +โ”‚ apply keymap โ”‚ +โ”‚ Menu::mainLoop (unless --automation) โ”‚ +โ”‚ Game::mainLoop(level, checkpoint) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ +โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ Game โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” +โ”‚ Resource (.lvl/.sss/.mst/.paf parsers) โ”‚ +โ”‚ Video (256ร—192 indexed framebuffer + palette) โ”‚ +โ”‚ PafPlayer (cutscene streaming) โ”‚ +โ”‚ Mixer (software audio mix) โ”‚ +โ”‚ HdCompositor* (optional HD path) โ”‚ +โ”‚ AutomationApi* (optional socket server) โ”‚ +โ”‚ per-level callbacks (level1_rock.cpp .. level9) โ”‚ +โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” + โ”‚ System (abstract) โ”‚ โ† System_SDL2 (this build) + โ”‚ โ”‚ System_PSP, System_Wii (legacy) + โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ + โ”‚ + โ–ผ + SDL2 (Metal/OpenGL/D3D) +``` + +### Core types + +| Type | File | Responsibility | +|---|---|---| +| `Game` | [`game.h`](game.h) / [`game.cpp`](game.cpp) | Orchestrator; ticks the level main loop | +| `Resource` | [`resource.h`](resource.h) | LVL/SSS/MST/PAF parsing, sprite tables, palettes | +| `Video` | [`video.h`](video.h) | 256-colour framebuffer + palette + layers | +| `PafPlayer` | [`paf.h`](paf.h) | Streams Packed Animation File format | +| `HdCompositor` | [`hd_compositor.h`](hd_compositor.h) | **NEW** โ€” HD framebuffer, presets, borders, prerender | +| `SpriteUpscaler` | [`sprite_upscaler.h`](sprite_upscaler.h) | **NEW** โ€” chained xBRZ + cache | +| `AutomationApi` | [`automation_api.h`](automation_api.h) | **NEW** โ€” Unix socket JSON server | +| `System` | [`system.h`](system.h) | Abstract platform โ€” input, audio, screen | + +### HD pipeline + +```text +Original logic (256ร—192, 8-bit indexed) + โ”‚ + โ–ผ drawScreen() collects sprite list (z-sorted) + โ”‚ + โ–ผ HdCompositor::beginFrame(bgLayer, palette, screenNum, bgId) + โ”‚ โ€’ expand palette 6โ†’8-bit + โ”‚ โ€’ if screen changed: SpriteUpscaler::upscaleBackground() + โ”‚ โ€’ memcpy _hdBackground โ†’ _hdFramebuffer + โ”‚ + โ–ผ for each sprite: + โ”‚ SpriteUpscaler::getOrUpscale(sprData, w, h, flags, palette) + โ”‚ โ€’ decodeSprToTemp(): RLE โ†’ indexed + โ”‚ โ€’ key = FNV-1a(indexed + dims + flags + palette hash) + โ”‚ โ€’ RAM lookup โ†’ disk lookup โ†’ upscale chain โ†’ MLAA โ†’ cache + โ”‚ blitHdSprite(): alpha-test composite into _hdFramebuffer + โ”‚ + โ–ผ HdCompositor::endFrame() + โ”‚ โ€’ if widescreen: compositeWidescreen() draws gradient borders + โ”‚ + โ–ผ System_SDL2::copyRectRGBA(): SDL texture stream โ†’ present +``` + +### PAF cutscene flow + +```text +HOD.PAF + โ”‚ one container, k cutscenes, indexed by uint32 LE offset + โ–ผ +PafPlayer::preload(num) + โ”‚ read sub-header, allocate 4 page buffers + demux blocks + โ–ผ +PafPlayer::mainLoop() โ† _prerenderMode skips audio/display/sleep + โ”‚ per frame: + โ”‚ โ€’ read N file blocks into demux buffers (video or ADPCM audio) + โ”‚ โ€’ decodeVideoFrame() โ†’ write current page buffer + โ”‚ โ€’ callback(frame) โ†’ gamePafFrameCallback (game.cpp) + โ”‚ โ”œโ”€โ”€ (legacy) g_system->copyRect to 256ร—192 layer + โ”‚ โ””โ”€โ”€ (HD) cache hit? โ†’ present + โ”‚ else upscale โ†’ save to disk โ†’ present + โ”‚ โ€’ if !prerender: setPalette + updateScreen + sleep frameMs + โ–ผ +PafPlayer::unload() +``` + +--- + +## ๐Ÿ—บ๏ธ Source file map + +
+Original engine (preserved) + +| File(s) | Responsibility | +|---|---| +| `andy.cpp` | Player state machine / animation FSM | +| `benchmark.cpp` | CPU benchmark used during the loading screen | +| `defs.h` | On-disk struct layouts (`Lvl*`, `Sss*`, `Mst*`, `SetupConfig`) | +| `fileio.cpp/h` | Buffered binary file reader | +| `fs.h`, `fs_posix.cpp`, `fs_android.cpp` | Filesystem abstraction | +| `intern.h` | LE byte-swap helpers; this fork adds a macOS branch | +| `level.h`, `level1_rock.cpp` โ€ฆ `level9_dark.cpp` | Per-level scripted callbacks | +| `lzw.cpp` | LZW decompressor | +| `mdec.cpp/h`, `mdec_coeffs.h` | PSX MDEC video decoder | +| `menu.cpp/h` | Settings menus, cutscene replay, controls bind | +| `mixer.cpp/h` | Software audio mixer | +| `monsters.cpp` | Monster AI / animation tables | +| `paf.cpp/h` | PAF cutscene format | +| `random.cpp/h` | Game logic PRNG | +| `resource.cpp/h` | LVL / SSS / MST loader, sprite tables | +| `scaler.h`, `scaler_xbr.cpp` | Legacy software scalers | +| `screenshot.cpp/h` | PNG/BMP dump | +| `sound.cpp` | SSS interpreter | +| `staticres.cpp` | Baked-in tables | +| `system.h` | Abstract platform interface | +| `system_sdl2.cpp` | Active SDL2 backend | +| `system_psp.cpp`, `system_wii.cpp` | Legacy backends, **not built** by this Makefile | +| `util.cpp/h` | Debug print, error helpers | +| `video.cpp/h` | Legacy framebuffer + palette | + +
+ +
+Added by this fork + +| File(s) | Responsibility | +|---|---| +| `hd_compositor.cpp/h` | HD framebuffer; multi-resolution presets; 16:9 widescreen with palette borders; prerender driver; unified progress bar | +| `sprite_upscaler.cpp/h` | xBRZ chained scalers (`2x`, `3x`, `4x = 2xยท2x`, `5x โ‰ˆ nearest`, `Nx via decompose`); RAM LRU + disk cache with FNV-1a content key | +| `edge_smooth.cpp/h` | MLAA edge smoothing | +| `automation_api.cpp/h` | Unix-socket JSON server; input injection; step mode; screenshot | +| `test_walkthrough.py` | Level 1 walkthrough using the automation API | +| `test_all_levels.py` | Per-level boot smoke test | +| `test_combat_bot.py` | Reactive combat AI | +| `test_hd_replay.py` | HD vs SD comparison harness | + +
+ +
+Modified vs upstream + +| File | Why | +|---|---| +| `Makefile` | Add the four new `.cpp` sources to `SRCS` | +| `intern.h` | macOS `` shim | +| `main.cpp` | New CLI flags + INI keys; wire `_video->_font` after `loadSetupDat()` | +| `game.cpp/h` | HD compositor begin/end-frame hooks; smooth-anim 60 Hz interpolated render loop; per-level sprite + PAF prerender driver with shared progress bar | +| `paf.cpp/h` | HD frame callback path; `_prerenderMode` skips audio/display/sleep; `peekFramesCount(num)` | +| `menu.cpp/h` | Keyboard binding screen overhaul (OK/Cancel/Test, one-key-one-action, Space/Enter/Tab/Backspace/Win bindable, fallback labels); font init guard | +| `system.h` | Expose `applyKeyboardControls` and `waitForKeyPress` on the abstract interface | +| `system_sdl2.cpp` | Default mappings stay live alongside user keys; `waitForKeyPress` clears edge state on return; `copyRectRGBA` for HD presents | +| `video.cpp` | Zero-init `_font` so the menu can't dereference garbage before `Game::mainLoop` wires it | + +
+ +--- + +## ๐ŸŽฎ Game world reference + +### Levels + +| # | Code | Display name | Notes | +|---:|:--|:--|:--| +| 0 | `rock` | Canyon | Tutorial / first level | +| 1 | `fort` | Fort | | +| 2 | `pwr1` | Power 1 | The Swamp | +| 3 | `isld` | Island | | +| 4 | `lava` | Lava | | +| 5 | `pwr2` | Power 2 | Underwater | +| 6 | `lar1` | Lair 1 | | +| 7 | `lar2` | Lair 2 | | +| 8 | `dark` | Dark | Final level (single checkpoint) | + +Level callbacks dispatch from `Game::callLevel_*` into the `levelN_.cpp` matching `_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;