From a0a2db6d10acba660f7b691af0b3a053a3cb8424 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:53:42 -0400 Subject: [PATCH 01/14] Add Fight Caves environment with flat PufferLib 4.0 layout Preserve the complete C simulation, Puffer adapter, playable OSRS viewer, checkpoint replay and verified asset bundles in a 21-file integration. Consolidate implementation headers and environment tooling, with acceptance tests under tests/. Validation: 12 Python checks, fixed-seed C fixture (483 steps, a361005c), CPU adapter terminal test, native and viewer builds, short CPU training and 25-tick checkpoint replay. Before/after traces match for 258048 all-wave core ticks and 32768 adapter steps. CUDA training was not rerun. Supersedes fork PR #3; intended only for jordanbailey00/PufferLib:4.0. --- .github/workflows/fight-caves.yml | 50 + build.sh | 63 +- config/fight_caves.ini | 93 + ocean/fight_caves/CMakeLists.txt | 33 + ocean/fight_caves/README.md | 127 + ocean/fight_caves/assets.h | 2904 ++++++++ ocean/fight_caves/binding.c | 260 + ocean/fight_caves/fight_caves.c | 82 + ocean/fight_caves/fight_caves.h | 505 ++ ocean/fight_caves/render.h | 3533 +++++++++ ocean/fight_caves/simulation.h | 8078 +++++++++++++++++++++ ocean/fight_caves/tools.py | 1526 ++++ ocean/fight_caves/ui.h | 3538 +++++++++ ocean/fight_caves/viewer.c | 2695 +++++++ resources/fight_caves/.gitignore | 6 + resources/fight_caves/ASSET_NOTICE.md | 9 + resources/fight_caves/README.md | 42 + resources/fight_caves/asset_manifest.json | 3423 +++++++++ tests/fight_caves.c | 75 + tests/fight_caves.sh | 232 + tests/test_fight_caves.py | 324 + 21 files changed, 27587 insertions(+), 11 deletions(-) create mode 100644 .github/workflows/fight-caves.yml create mode 100644 config/fight_caves.ini create mode 100644 ocean/fight_caves/CMakeLists.txt create mode 100644 ocean/fight_caves/README.md create mode 100644 ocean/fight_caves/assets.h create mode 100644 ocean/fight_caves/binding.c create mode 100644 ocean/fight_caves/fight_caves.c create mode 100644 ocean/fight_caves/fight_caves.h create mode 100644 ocean/fight_caves/render.h create mode 100644 ocean/fight_caves/simulation.h create mode 100644 ocean/fight_caves/tools.py create mode 100644 ocean/fight_caves/ui.h create mode 100644 ocean/fight_caves/viewer.c create mode 100644 resources/fight_caves/.gitignore create mode 100644 resources/fight_caves/ASSET_NOTICE.md create mode 100644 resources/fight_caves/README.md create mode 100644 resources/fight_caves/asset_manifest.json create mode 100644 tests/fight_caves.c create mode 100644 tests/fight_caves.sh create mode 100644 tests/test_fight_caves.py diff --git a/.github/workflows/fight-caves.yml b/.github/workflows/fight-caves.yml new file mode 100644 index 0000000000..7a894fffd3 --- /dev/null +++ b/.github/workflows/fight-caves.yml @@ -0,0 +1,50 @@ +name: fight-caves + +on: + pull_request: + paths: + - "build.sh" + - "config/fight_caves.ini" + - "ocean/fight_caves/**" + - "resources/fight_caves/**" + - "tests/test_fight_caves.py" + - "tests/fight_caves.*" + - ".github/workflows/fight-caves.yml" + workflow_dispatch: + +jobs: + clean-checkout: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + env: + CC: clang + CXX: clang++ + PUFFER_OMP_LIB: -lomp + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install native dependencies + run: | + sudo apt-get update + sudo apt-get install -y \ + clang libomp-dev libomp5 cmake \ + libgl1-mesa-dev libx11-dev libxrandr-dev libxi-dev \ + libxcursor-dev libxinerama-dev x11-utils xvfb + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install torch --index-url https://download.pytorch.org/whl/cpu + python -m pip install -e . --no-build-isolation + python -m pip install pytest + + - name: Validate clean checkout + env: + PYTHON: python + run: bash tests/fight_caves.sh checkout diff --git a/build.sh b/build.sh index 19261e88c6..ec4466f66e 100755 --- a/build.sh +++ b/build.sh @@ -1,6 +1,12 @@ #!/bin/bash set -e +PYTHON=${PYTHON:-python3} +if ! command -v "$PYTHON" >/dev/null 2>&1; then + echo "Error: Python interpreter '$PYTHON' was not found. Set PYTHON to a Python 3.10+ executable." >&2 + exit 1 +fi + # Usage: # ./build.sh breakout # Build _C.so with breakout statically linked # ./build.sh breakout --float # float32 precision (required for --slowly) @@ -9,11 +15,12 @@ set -e # ./build.sh breakout --local # Standalone executable (debug, sanitizers) # ./build.sh breakout --fast # Standalone executable (optimized) # ./build.sh breakout --web # Emscripten web build +# ./build.sh fight_caves --viewer # Optional environment viewer # ./build.sh breakout --profile # Kernel profiling binary # ./build.sh all # Build all envs with default and --float if [ -z "$1" ]; then - echo "Usage: ./build.sh ENV_NAME [--float] [--debug] [--local|--fast|--web|--profile|--cpu|--all]" + echo "Usage: ./build.sh ENV_NAME [--float] [--debug] [--local|--fast|--web|--viewer|--profile|--cpu|--all]" exit 1 fi ENV=$1 @@ -26,12 +33,29 @@ for arg in "$@"; do --local) MODE=local ;; --fast) MODE=fast ;; --web) MODE=web ;; + --viewer) MODE=viewer ;; --profile) MODE=profile ;; --cpu) MODE=cpu; PRECISION="-DPRECISION_FLOAT" ;; *) echo "Error: unknown argument '$arg'" && exit 1 ;; esac done +# Fight Caves ships authoritative runtime/viewer data separately. Validate its +# selected build path before downloading or compiling anything so a missing +# dependency or incomplete asset bundle cannot produce a degraded environment. +if [ "$ENV" = "fight_caves" ]; then + FC_PREFLIGHT_MODE=cuda + case "${MODE:-}" in + local|fast) FC_PREFLIGHT_MODE=native ;; + cpu) FC_PREFLIGHT_MODE=cpu ;; + viewer) FC_PREFLIGHT_MODE=viewer ;; + web) FC_PREFLIGHT_MODE=web ;; + profile) FC_PREFLIGHT_MODE=cuda ;; + esac + "$PYTHON" ocean/fight_caves/tools.py preflight \ + --mode "$FC_PREFLIGHT_MODE" +fi + if [ "$ENV" = "all" ]; then FAILED="" for env_dir in ocean/*/; do @@ -54,13 +78,13 @@ fi PLATFORM="$(uname -s)" if [ "$PLATFORM" = "Linux" ]; then RAYLIB_NAME='raylib-5.5_linux_amd64' - OMP_LIB=-lomp5 + OMP_LIB=${PUFFER_OMP_LIB:--lomp5} SANITIZE_FLAGS=(-fsanitize=address,undefined,bounds,pointer-overflow,leak -fno-omit-frame-pointer) STANDALONE_LDFLAGS=(-lGL) SHARED_LDFLAGS=(-Bsymbolic-functions) else RAYLIB_NAME='raylib-5.5_macos' - OMP_LIB=-lomp + OMP_LIB=${PUFFER_OMP_LIB:--lomp} SANITIZE_FLAGS=() STANDALONE_LDFLAGS=(-framework Cocoa -framework IOKit -framework CoreVideo -framework OpenGL) SHARED_LDFLAGS=(-framework Cocoa -framework OpenGL -framework IOKit -undefined dynamic_lookup) @@ -142,6 +166,21 @@ fi OUTPUT_NAME=${OUTPUT_NAME:-$ENV} +if [ "$MODE" = "viewer" ]; then + VIEWER_SOURCE_DIR="$SRC_DIR" + VIEWER_BUILD_DIR="build/$ENV-viewer" + if [ ! -f "$VIEWER_SOURCE_DIR/CMakeLists.txt" ]; then + echo "Error: environment '$ENV' does not provide a viewer build" + exit 1 + fi + cmake -S "$VIEWER_SOURCE_DIR" -B "$VIEWER_BUILD_DIR" \ + -DCMAKE_BUILD_TYPE=Release \ + -DRAYLIB_ROOT="$(pwd)/$RAYLIB_NAME" + cmake --build "$VIEWER_BUILD_DIR" --parallel + echo "Built: $VIEWER_BUILD_DIR/fc_viewer" + exit 0 +fi + # Standalone environment build # -mavx2 enables AVX2 intrinsics (__m256, _mm256_*) which drive.h and # src/bf16.h use directly. x86_64 only — strip if porting to ARM/Apple Silicon. @@ -207,10 +246,10 @@ for dir in /usr/local/cuda/lib64 /usr/lib/x86_64-linux-gnu; do fi done if [ -z "$CUDNN_IFLAG" ]; then - CUDNN_IFLAG=$(python -c "import nvidia.cudnn, os; print('-I' + os.path.join(nvidia.cudnn.__path__[0], 'include'))" 2>/dev/null || echo "") + CUDNN_IFLAG=$("$PYTHON" -c "import nvidia.cudnn, os; print('-I' + os.path.join(nvidia.cudnn.__path__[0], 'include'))" 2>/dev/null || echo "") fi if [ -z "$CUDNN_LFLAG" ]; then - CUDNN_LFLAG=$(python -c "import nvidia.cudnn, os; print('-L' + os.path.join(nvidia.cudnn.__path__[0], 'lib'))" 2>/dev/null || echo "") + CUDNN_LFLAG=$("$PYTHON" -c "import nvidia.cudnn, os; print('-L' + os.path.join(nvidia.cudnn.__path__[0], 'lib'))" 2>/dev/null || echo "") fi # NCCL include/lib fallback (mirrors the cuDNN fallback above). @@ -224,10 +263,10 @@ for dir in /usr/lib/x86_64-linux-gnu /usr/local/cuda/lib64; do if [ -f "$dir/libnccl.so" ] || [ -f "$dir/libnccl.so.2" ]; then NCCL_LFLAG="-L$dir"; break; fi done if [ -z "$NCCL_IFLAG" ]; then - NCCL_IFLAG=$(python -c "import nvidia.nccl, os; print('-I' + os.path.join(nvidia.nccl.__path__[0], 'include'))" 2>/dev/null || echo "") + NCCL_IFLAG=$("$PYTHON" -c "import nvidia.nccl, os; print('-I' + os.path.join(nvidia.nccl.__path__[0], 'include'))" 2>/dev/null || echo "") fi if [ -z "$NCCL_LFLAG" ]; then - NCCL_LFLAG=$(python -c "import nvidia.nccl, os; print('-L' + os.path.join(nvidia.nccl.__path__[0], 'lib'))" 2>/dev/null || echo "") + NCCL_LFLAG=$("$PYTHON" -c "import nvidia.nccl, os; print('-L' + os.path.join(nvidia.nccl.__path__[0], 'lib'))" 2>/dev/null || echo "") fi WHEEL_RPATH_FLAGS=() @@ -244,10 +283,10 @@ NVCC="ccache $CUDA_HOME/bin/nvcc" CC="${CC:-$(command -v ccache >/dev/null && echo 'ccache clang' || echo 'clang')}" ARCH=${NVCC_ARCH:-native} -PYTHON_INCLUDE=$(python -c "import sysconfig; print(sysconfig.get_path('include'))") -PYBIND_INCLUDE=$(python -c "import pybind11; print(pybind11.get_include())") -NUMPY_INCLUDE=$(python -c "import numpy; print(numpy.get_include())") -EXT_SUFFIX=$(python -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))") +PYTHON_INCLUDE=$("$PYTHON" -c "import sysconfig; print(sysconfig.get_path('include'))") +PYBIND_INCLUDE=$("$PYTHON" -c "import pybind11; print(pybind11.get_include())") +NUMPY_INCLUDE=$("$PYTHON" -c "import numpy; print(numpy.get_include())") +EXT_SUFFIX=$("$PYTHON" -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))") OUTPUT="pufferlib/_C${EXT_SUFFIX}" BINDING_SRC="$SRC_DIR/binding.c" @@ -269,6 +308,8 @@ ${CC:-clang} -c "${CLANG_OPT[@]}" $EXTRA_CFLAGS \ -fno-semantic-interposition -fvisibility=hidden \ -fPIC -fopenmp \ "$BINDING_SRC" -o "$STATIC_OBJ" +# Discard members left by an older multi-object environment build. +rm -f "$STATIC_LIB" ar rcs "$STATIC_LIB" "$STATIC_OBJ" # Brittle hack: have to extract the tensor type from the static lib to build trainer diff --git a/config/fight_caves.ini b/config/fight_caves.ini new file mode 100644 index 0000000000..36d26357be --- /dev/null +++ b/config/fight_caves.ini @@ -0,0 +1,93 @@ +[base] +env_name = fight_caves +checkpoint_interval = 50 +eval_episodes = 10000 +cudagraphs = 10 +reset_state = True +seed = 73 + +# The trainer architecture, hyperparameters, 750M-step budget, and seed remain +# pinned to sweep winner 1nvvx5qu and deterministic retrain 8oivozuq. The live +# environment includes the approved OSRS movement/LOS parity work and the +# zero-danger-prayer-reward setting evaluated by W&B run txqsiahp. +[env] +initial_sharks = 0 +initial_prayer_doses = 0 +w_damage_dealt = 0.0 +w_progress = 0.001 +w_damage_taken = -0.25 +w_npc_kill = 0.0 +w_wave_clear = 0.0 +w_jad_kill = 0.0 +w_cave_complete = 1.0 +w_player_death = -1.0 +w_correct_jad_prayer = 0.0 +w_correct_danger_prayer = 0.0 +w_prayer_lost = -0.02 +w_invalid_action = -0.1 +w_tick_penalty = -0.0001 +shape_unnecessary_prayer_penalty = 0.0 +shape_wave_stall_start = 0 +shape_wave_stall_ramp_interval = 0 +shape_wave_stall_base_penalty = 0.0 +shape_wave_stall_cap = 0.0 +shape_jad_heal_penalty = 0.0 +shape_npc_heal_penalty = -0.005 +shape_no_progress_start_1 = 800 +shape_no_progress_start_2 = 1600 +shape_no_progress_start_3 = 2400 +shape_no_progress_penalty_1 = -0.001 +shape_no_progress_penalty_2 = -0.005 +shape_no_progress_penalty_3 = -0.02 +shape_no_attack_start = 50 +shape_no_attack_base_penalty = -0.005 +shape_no_attack_wave_scale = 0.05 +obs_ablate_npc_distance = 0 +obs_ablate_incoming_aggregates = 1 +obs_ablate_npc_valid = 0 + +[vec] +total_agents = 4096 +num_buffers = 2 +num_threads = 16 + +[train] +gpus = 1 +total_timesteps = 750_000_000 +anneal_lr = 0 +learning_rate = 0.00207567504650331 +ent_coef = 0.000625460620549345 +gamma = 0.9991261141073255 +gae_lambda = 0.9 +horizon = 256 +minibatch_size = 32768 +replay_ratio = 2.055184291514704 +clip_coef = 0.05 +vf_coef = 0.9336215311545304 +vf_clip_coef = 0.16791546282962394 +max_grad_norm = 0.1418276517190492 +vtrace_rho_clip = 2.0 +vtrace_c_clip = 0.9746667741536915 +prio_alpha = 0.9110743956381228 +prio_beta0 = 0.2258134371255269 +beta1 = 0.9832670364021693 +beta2 = 0.9995810484472892 +eps = 1e-10 + +[policy] +hidden_size = 512 +num_layers = 3 +expansion_factor = 1 + +[run] +manifest_path = '' +manifest_schema_version = 2 +observation_version = 'fight_caves_puffer_policy_obs_v9_run_energy_prayer_timing_mask8_no_supplies' +action_version = 'fight_caves_multidiscrete_3_head_no_supplies_v4_run_energy_prayer8_stationary_attack_tick' +reward_version = 'fight_caves_v4_progress_npc_heal_penalty_m0005_prayer_snapshot_flick_drain' +reward_clip_enabled = 1 +reward_clip_min = -1.0 +reward_clip_max = 1.0 + +[sweep] +metric = jad_kill_rate diff --git a/ocean/fight_caves/CMakeLists.txt b/ocean/fight_caves/CMakeLists.txt new file mode 100644 index 0000000000..4997f8e5b2 --- /dev/null +++ b/ocean/fight_caves/CMakeLists.txt @@ -0,0 +1,33 @@ +cmake_minimum_required(VERSION 3.20) +project(fight_caves_viewer C) +set(CMAKE_C_STANDARD 11) +set(CMAKE_C_STANDARD_REQUIRED ON) +get_filename_component(PUFFERLIB_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../.." ABSOLUTE) + +if(NOT RAYLIB_ROOT) + if(APPLE) + set(RAYLIB_ROOT "${PUFFERLIB_ROOT}/raylib-5.5_macos") + else() + set(RAYLIB_ROOT "${PUFFERLIB_ROOT}/raylib-5.5_linux_amd64") + endif() +endif() +set(RAYLIB_ROOT "${RAYLIB_ROOT}" CACHE PATH "Raylib distribution root") +if(NOT EXISTS "${RAYLIB_ROOT}/include/raylib.h" OR + NOT EXISTS "${RAYLIB_ROOT}/lib/libraylib.a") + message(FATAL_ERROR "Raylib unavailable at ${RAYLIB_ROOT}. Run ./build.sh fight_caves --viewer") +endif() + +add_executable(fc_viewer viewer.c) +target_include_directories(fc_viewer PRIVATE "${RAYLIB_ROOT}/include") +target_link_libraries(fc_viewer PRIVATE "${RAYLIB_ROOT}/lib/libraylib.a" m pthread) +set(FC_ACTIVE_LOADOUT "" CACHE STRING "Optional Fight Caves active loadout override") +if(FC_ACTIVE_LOADOUT) + target_compile_definitions(fc_viewer PRIVATE FC_ACTIVE_LOADOUT=${FC_ACTIVE_LOADOUT}) +endif() +if(APPLE) + target_link_libraries(fc_viewer PRIVATE + "-framework Cocoa" "-framework IOKit" "-framework CoreVideo" "-framework OpenGL") +else() + find_package(X11 REQUIRED) + target_link_libraries(fc_viewer PRIVATE dl ${X11_LIBRARIES} GL) +endif() diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md new file mode 100644 index 0000000000..326d2a03d4 --- /dev/null +++ b/ocean/fight_caves/README.md @@ -0,0 +1,127 @@ +# Fight Caves + +Fight Caves is a single-agent native C environment for PufferLib 4.0. Its +training adapter, standalone simulator, and full Raylib viewer all compile the +same `simulation.h` implementation. Presentation remains separate from gameplay. + +## Layout + +The environment uses flat implementation headers, with no separate `src/`, +`include/`, or viewer source tree: + +- `fight_caves.h`: Puffer lifecycle, observations, rewards and episode logging. +- `simulation.h`: game state, contracts, combat, routing, waves and loadouts. +- `binding.c`: Puffer 4.0 binding, configuration and compiled-contract export. +- `fight_caves.c`: standalone random-action simulator/benchmark. +- `viewer.c`: playable and policy-pipe entry point, input and scene lifecycle. +- `assets.h`: asset readers, models, animations, terrain and animated atlases. +- `ui.h`: OSRS interfaces, sprites, fonts, minimap and orbs. +- `render.h`: actor motion, animation selection, combat effects and debug overlays. +- `tools.py`: asset installation/verification, bundle creation, preflight, + playable launch and checkpoint replay. +- `CMakeLists.txt`: optional viewer build using Puffer's pinned Raylib 5.5. + +Acceptance tests live in the repository's `tests/` directory. The full graphical +viewer is retained; the flat layout does not substitute a minimal renderer or +change the simulation, policy contract, or configuration. + +## Requirements + +Python 3.10 or newer and the normal PufferLib Python dependencies are required. +Native builds require Clang, `ar`, and an OpenMP development runtime. The viewer +also requires CMake, OpenGL development libraries, and X11 development headers +on Linux. + +On Ubuntu, the relevant system packages are: + +```bash +sudo apt-get install clang libomp-dev libomp5 cmake \ + libgl1-mesa-dev libx11-dev libxrandr-dev libxi-dev \ + libxcursor-dev libxinerama-dev x11-utils xvfb +``` + +The build preflight exits with a nonzero status and names any missing +dependency. It never substitutes a reduced simulator or viewer. + +## Install assets + +The runtime maps and graphical viewer data are published as versioned GitHub +release bundles. Install and verify both bundles from the repository root: + +```bash +python3 ocean/fight_caves/tools.py setup --all +``` + +The installer verifies the archive and every installed file against +`resources/fight_caves/asset_manifest.json`. A download, checksum, extraction, +or installation error exits nonzero without replacing an existing installation. + +## Build and test + +Build the CPU Puffer backend and run the environment acceptance tests: + +```bash +bash tests/fight_caves.sh test --puffer +``` + +Include the viewer build: + +```bash +bash tests/fight_caves.sh test --all +``` + +Run the playable viewer through its asset-verifying launcher: + +```bash +./build.sh fight_caves --viewer +python3 ocean/fight_caves/tools.py play +``` + +The launcher verifies all required assets and checks the graphical display. +The viewer retains tile clicking and route previews, OSRS click indicators, +camera controls, equipment/prayer/inventory tabs, run-energy and minimap orbs, +wave/TPS/target controls, god mode, debug information, prayer-window indicators, +projectiles, impacts, health bars and hitsplats. + +Useful controls include `Space` to pause/resume, `Right Arrow` to step one tick, +`O` for debug overlays, right-drag to orbit, the mouse wheel to zoom, and +`Q`/`Escape` to quit. + +## Checkpoint replay + +Build the Fight Caves backend (`./build.sh fight_caves --cpu`, or the normal CUDA +build), then replay a checkpoint in the same viewer: + +```bash +python3 ocean/fight_caves/tools.py eval --ckpt /absolute/path/to/checkpoint.bin --episodes 1 +``` + +Replay retains the raw CUDA and PyTorch CPU checkpoint readers, compiled-contract +and size checks, masking, pause/speed controls and episode summaries. `--ckpt latest` +selects the newest compatible checkpoint; `--random` uses random legal actions +without loading a checkpoint. The existing dedicated policy-pipe evaluator is +preserved, rather than changing inference or switching to a different renderer. + +The viewer defaults to `resources/fight_caves/viewer`; arena maps default to +`resources/fight_caves/runtime`. Explicit `FC_ASSET_ROOT`, `FC_REPO_ROOT`, +`FC_COLLISION_PATH`, `FC_MOVEMENT_PATH` and `FC_LOS_PATH` overrides remain available. +Use `python3 ocean/fight_caves/tools.py COMMAND --help` for setup, bundle, +preflight and replay options. + +## Clean-clone acceptance + +Maintainers can reproduce installation, native and Puffer builds, a short +training run, viewer startup, checkpoint replay, and deliberate failure cases +from a new checkout with: + +```bash +bash tests/fight_caves.sh clean-clone +``` + +The command clones the current origin branch into a temporary directory, creates +a new virtual environment, downloads only the published pinned asset bundles, +and runs the complete acceptance sequence. Set `FC_CLEAN_CLONE_KEEP=1` to retain +the isolated checkout after a failure for inspection. +This validates the committed branch, not uncommitted local changes. The +`checkout` subcommand runs acceptance directly in a fresh checkout without +installed assets, and `test --core` runs just the asset/contract and C checks. diff --git a/ocean/fight_caves/assets.h b/ocean/fight_caves/assets.h new file mode 100644 index 0000000000..061bdcf912 --- /dev/null +++ b/ocean/fight_caves/assets.h @@ -0,0 +1,2904 @@ +#ifndef FIGHT_CAVES_ASSETS_H +#define FIGHT_CAVES_ASSETS_H + +/* Io */ + +#include +#include + +static inline bool fc_read_exact(FILE* f, void* dst, size_t elem_size, + size_t elem_count, const char* path, + const char* what) { + if (fread(dst, elem_size, elem_count, f) != elem_count) { + fprintf(stderr, "%s: short read while loading %s\n", path, what); + return false; + } + return true; +} + +static inline bool fc_seek(FILE* f, long offset, int origin, + const char* path, const char* what) { + if (fseek(f, offset, origin) != 0) { + fprintf(stderr, "%s: seek failed while loading %s\n", path, what); + return false; + } + return true; +} + + +/* Assets */ + +#include +#include + +#define FC_ASSET_PATH_MAX 1024 + +const char* fc_asset_root(void); +const char* fc_repo_root(void); + +int fc_asset_resolve_path(const char* logical_path, char* out, size_t cap); +int fc_repo_resolve_path(const char* logical_path, char* out, size_t cap); +int fc_asset_exists(const char* logical_path); + +FILE* fc_asset_fopen(const char* logical_path, const char* mode); +int fc_asset_close(FILE* f); +unsigned char* fc_asset_read_all(const char* logical_path, size_t* out_size); + + +/* Asset Raylib */ + +#include "raylib.h" + +Texture2D fc_load_texture_asset(const char* path); +Image fc_load_image_asset(const char* path); +Font fc_load_font_asset(const char* path, int font_size); + + +/* Animated Atlas */ + +#include "raylib.h" + +#include + +typedef struct { + uint32_t texture_id; + uint16_t x; + uint16_t y; + uint16_t w; + uint16_t h; + uint8_t direction; + uint8_t speed; + uint16_t pad; +} FcTextureAnimRow; + +typedef struct { + Texture2D texture; + unsigned char* base_pixels; + unsigned char* pixels; + FcTextureAnimRow* anims; + int width; + int height; + int anim_count; + float anim_ticks; +} FcAnimatedAtlas; + +int fc_animated_atlas_load(FcAnimatedAtlas* atlas, const char* atlas_path, + int enable_animation); +void fc_animated_atlas_update(FcAnimatedAtlas* atlas, float dt); +void fc_animated_atlas_unload(FcAnimatedAtlas* atlas); + + +/* Anim Loader */ + +#include + +#define ANIM_MAX_LABELS 256 + +typedef struct { + uint16_t base_id; + uint8_t slot_count; + uint8_t *types; + uint8_t *map_lengths; + uint8_t **frame_maps; +} AnimFrameBase; + +typedef struct { + uint8_t slot_index; + int16_t dx; + int16_t dy; + int16_t dz; +} AnimTransform; + +typedef struct { + uint16_t framebase_id; + uint8_t transform_count; + AnimTransform *transforms; +} AnimFrameData; + +typedef struct { + uint16_t delay; + AnimFrameData frame; +} AnimSequenceFrame; + +typedef struct { + uint16_t seq_id; + uint16_t frame_count; + uint8_t interleave_count; + uint8_t *interleave_order; + int16_t frame_step; + int8_t preanim_move; + int8_t postanim_move; + uint8_t forced_priority; + uint8_t max_loops; + int8_t reply_mode; + uint8_t stretches; + int8_t walk_flag; + AnimSequenceFrame *frames; +} AnimSequence; + +typedef struct { + AnimFrameBase *bases; + int base_count; + uint16_t *base_ids; + AnimSequence *sequences; + int seq_count; +} AnimCache; + +typedef struct { + int16_t *verts; + int vert_count; + int **groups; + int *group_counts; +} AnimModelState; + +AnimCache *anim_cache_load(const char *path); +AnimSequence *anim_get_sequence(AnimCache *cache, uint16_t seq_id); +AnimFrameBase *anim_get_framebase(AnimCache *cache, uint16_t base_id); +AnimModelState *anim_model_state_create(const uint8_t *vertex_skins, + int base_vert_count); +void anim_model_state_free(AnimModelState *state); +void anim_apply_frame(AnimModelState *state, const int16_t *base_verts_src, + const AnimFrameData *frame, const AnimFrameBase *fb); +int anim_mix_pose_action(AnimCache *cache, AnimModelState *state, + const int16_t *base_verts, AnimSequence *pose, + int pose_frame_index, AnimSequence *action, + int action_frame_index); +void anim_update_mesh(float *mesh_vertices, const AnimModelState *state, + const uint16_t *face_indices, int face_count); +void anim_cache_free(AnimCache *cache); + + +/* Models */ + +#include "raylib.h" +#include + +typedef struct { + uint8_t textured; + uint16_t tex_a; + uint16_t tex_b; + uint16_t tex_c; + float u_base; + float v_base; + float u_scale; + float v_scale; + float repeat_v_margin; +} ModelFaceUvInfo; + +typedef struct { + uint32_t model_id; + Model model; + int loaded; + float *rest_verts; + float *rest_texcoords; + int16_t *base_verts; + uint8_t *vertex_skins; + uint16_t *face_indices; + uint8_t *face_priorities; + ModelFaceUvInfo *face_uvs; + int base_vert_count; + int face_count; +} ModelEntry; + +typedef struct { + ModelEntry *entries; + int *index_by_id; + int count; + int index_limit; + int has_textures; + int loaded; +} ModelSet; + +ModelSet *models_load(const char *path, Texture2D atlas_texture); +ModelEntry *model_find(ModelSet *set, uint32_t id); +void models_recompute_texture_uvs_from_vertices(ModelEntry *entry, + const int16_t *verts); +void models_free(ModelSet *set); + + +/* Npc Models */ + +#define FC_B237_TZ_KIH 3116u +#define FC_B237_TZ_KEK 3118u +#define FC_B237_TZ_KEK_SM 3120u +#define FC_B237_TOK_XIL 3121u +#define FC_B237_YT_MEJKOT 3123u +#define FC_B237_KET_ZEK 3125u +#define FC_B237_TZTOK_JAD 3127u +#define FC_B237_YT_HURKOT 3128u + +typedef ModelEntry NpcModelEntry; +typedef ModelSet NpcModelSet; + +uint32_t fc_npc_type_to_model_id(int npc_type); +NpcModelEntry *fc_npc_model_find(NpcModelSet *set, uint32_t model_id); +NpcModelSet *fc_npc_models_load(const char *path, Texture2D atlas_texture); +void fc_npc_models_unload(NpcModelSet *set); + + +/* Model Animation */ + +void fc_model_animation_upload(NpcModelEntry *entry, AnimModelState *state); +void fc_model_animation_update(NpcModelEntry *entry, + AnimCache *cache, + AnimModelState **state, + uint16_t *current_sequence, + int *frame_index, + float *frame_timer, + int animation_id, + float dt, + float phase_ticks); + + +/* Objects Loader */ + +#include "raylib.h" +#include + +#define OANM_FLAG_DYNAMIC_BASE 1u +#define OANM_FLAG_DYNAMIC_REPLACEMENT 2u + +typedef struct { + Model model; + FcAnimatedAtlas atlas; + int placement_count; + int total_vertex_count; + int min_world_x; + int min_world_y; + int has_textures; + int loaded; +} ObjectMesh; + +typedef struct { + uint32_t model_id; + uint32_t obj_id; + int32_t animation_id; + int32_t world_x; + int32_t world_y; + uint8_t plane; + uint8_t obj_type; + uint8_t rotation; + uint8_t flags; + float pos_x; + float pos_y; + float pos_z; + float phase_ticks; +} ObjectAnimPlacement; + +typedef struct { + ObjectAnimPlacement *rows; + int count; + int loaded; +} ObjectAnimSet; + +ObjectMesh *objects_load(const char *path); +ObjectAnimSet *object_anims_load(const char *path); +void object_anims_offset(ObjectAnimSet *set, int wx, int wy); +void objects_offset(ObjectMesh *om, int wx, int wy); +void objects_free(ObjectMesh *om); +void object_anims_free(ObjectAnimSet *set); + + +/* Terrain Loader */ + +#include "raylib.h" + +typedef struct { + Model model; + int vertex_count; + int region_count; + int min_world_x; + int min_world_y; + int loaded; + float *heightmap; + int hm_min_x; + int hm_min_y; + int hm_width; + int hm_height; +} TerrainMesh; + +TerrainMesh *terrain_load(const char *path); +void terrain_offset(TerrainMesh *tm, int wx, int wy); +float terrain_height_at(TerrainMesh *tm, int world_x, int world_y); +void terrain_free(TerrainMesh *tm); + + +/* Spotanims */ + +#include + +typedef struct { + uint32_t id; + int32_t model_id; + int32_t animation_id; + uint32_t resize_xy; + uint32_t resize_z; + uint32_t rotation; + int32_t brightness; + int32_t shadow; +} SpotAnimDef; + +typedef struct { + SpotAnimDef *defs; + int count; + int loaded; +} SpotAnimSet; + +SpotAnimSet *spotanims_load(const char *path); +const SpotAnimDef *spotanim_find(const SpotAnimSet *set, int id); +void spotanims_free(SpotAnimSet *set); + + +/* Assets */ +#include +#include +#include +#include + +static int fc_has_prefix(const char* s, const char* prefix) { + size_t n; + if (!s || !prefix) return 0; + n = strlen(prefix); + return strncmp(s, prefix, n) == 0; +} + +static int fc_path_is_absolute(const char* path) { + return path && path[0] == '/'; +} + +static int fc_file_exists_path(const char* path) { + struct stat st; + return path && stat(path, &st) == 0 && S_ISREG(st.st_mode); +} + +static int fc_dir_exists_path(const char* path) { + struct stat st; + return path && stat(path, &st) == 0 && S_ISDIR(st.st_mode); +} + +static int fc_join_path(char* out, size_t cap, const char* a, const char* b) { + int n; + if (!out || cap == 0 || !a || !b) return 0; + if (a[0] == '\0') { + n = snprintf(out, cap, "%s", b); + } else if (a[strlen(a) - 1] == '/') { + n = snprintf(out, cap, "%s%s", a, b); + } else { + n = snprintf(out, cap, "%s/%s", a, b); + } + return n > 0 && (size_t)n < cap; +} + +static void fc_copy_path(char* dst, size_t cap, const char* src) { + if (!dst || cap == 0) return; + if (!src) src = ""; + snprintf(dst, cap, "%s", src); +} + +static void fc_trim_trailing_slash(char* path) { + size_t n; + if (!path) return; + n = strlen(path); + while (n > 1 && path[n - 1] == '/') { + path[n - 1] = '\0'; + n--; + } +} + +static const char* fc_asset_logical_path(const char* path) { + const char* marker; + if (!path) return ""; + while (fc_has_prefix(path, "./")) path += 2; + marker = strstr(path, "/resources/fight_caves/viewer/"); + if (marker) return marker + strlen("/resources/fight_caves/viewer/"); + if (fc_has_prefix(path, "resources/fight_caves/viewer/")) + return path + strlen("resources/fight_caves/viewer/"); + if (fc_has_prefix(path, "assets/")) + return path + strlen("assets/"); + return path; +} + +static int fc_derive_asset_root(char* out, size_t cap) { + const char* marker = strstr(__FILE__, "ocean/fight_caves/"); + size_t prefix_len; + int n; + if (!marker) return 0; + prefix_len = (size_t)(marker - __FILE__); + n = snprintf(out, cap, "%.*sresources/fight_caves/viewer", + (int)prefix_len, __FILE__); + return n > 0 && (size_t)n < cap; +} + +static int fc_derive_repo_root(char* out, size_t cap) { + const char* marker = strstr(__FILE__, "ocean/fight_caves/"); + size_t prefix_len; + int n; + if (!marker) return 0; + prefix_len = (size_t)(marker - __FILE__); + n = snprintf(out, cap, "%.*s", (int)prefix_len, __FILE__); + if (!(n > 0 && (size_t)n < cap)) return 0; + fc_trim_trailing_slash(out); + return out[0] != '\0'; +} + +static int fc_derive_repo_root_from_asset_root(char* out, size_t cap, + const char* asset_root) { + const char* marker; + size_t prefix_len; + int n; + if (!asset_root || !asset_root[0]) return 0; + marker = strstr(asset_root, "/resources/fight_caves/viewer"); + if (!marker + && fc_has_prefix(asset_root, "resources/fight_caves/viewer")) + return snprintf(out, cap, ".") > 0; + if (!marker) return 0; + prefix_len = (size_t)(marker - asset_root); + n = snprintf(out, cap, "%.*s", (int)prefix_len, asset_root); + if (!(n > 0 && (size_t)n < cap)) return 0; + fc_trim_trailing_slash(out); + return out[0] != '\0'; +} + +static int fc_repo_candidate_valid(const char* root) { + char path[FC_ASSET_PATH_MAX]; + if (!root || !root[0]) return 0; + return fc_join_path(path, sizeof(path), root, "ocean/fight_caves") + && fc_dir_exists_path(path); +} + +const char* fc_asset_root(void) { + static char root[FC_ASSET_PATH_MAX]; + static int initialized; + const char* env; + const char* candidates[] = { + "resources/fight_caves/viewer", + "../resources/fight_caves/viewer", + "../../resources/fight_caves/viewer", + NULL + }; + + env = getenv("FC_ASSET_ROOT"); + if (!env || !env[0]) env = getenv("FC_ASSETS_PATH"); + if (env && env[0]) return env; + if (initialized) return root; + initialized = 1; + + if (fc_derive_asset_root(root, sizeof(root)) && fc_dir_exists_path(root)) + return root; + for (int i = 0; candidates[i]; i++) { + if (fc_dir_exists_path(candidates[i])) { + fc_copy_path(root, sizeof(root), candidates[i]); + return root; + } + } + fc_copy_path(root, sizeof(root), "resources/fight_caves/viewer"); + return root; +} + +const char* fc_repo_root(void) { + static char root[FC_ASSET_PATH_MAX]; + static int initialized; + const char* env; + const char* asset_root; + const char* candidates[] = { ".", "runescape-rl", "..", "../..", NULL }; + + env = getenv("FC_REPO_ROOT"); + if (env && env[0]) return env; + if (initialized) return root; + initialized = 1; + + if (fc_derive_repo_root(root, sizeof(root)) && fc_repo_candidate_valid(root)) + return root; + asset_root = fc_asset_root(); + if (fc_derive_repo_root_from_asset_root(root, sizeof(root), asset_root) + && fc_repo_candidate_valid(root)) + return root; + for (int i = 0; candidates[i]; i++) { + if (fc_repo_candidate_valid(candidates[i])) { + fc_copy_path(root, sizeof(root), candidates[i]); + return root; + } + } + fc_copy_path(root, sizeof(root), "."); + return root; +} + +int fc_asset_resolve_path(const char* logical_path, char* out, size_t cap) { + const char* logical = fc_asset_logical_path(logical_path); + const char* root; + char joined[FC_ASSET_PATH_MAX]; + if (!out || cap == 0 || !logical_path || !logical[0]) return 0; + if (fc_path_is_absolute(logical_path) && fc_file_exists_path(logical_path)) { + fc_copy_path(out, cap, logical_path); + return 1; + } + root = fc_asset_root(); + if (fc_join_path(joined, sizeof(joined), root, logical) + && fc_file_exists_path(joined)) { + fc_copy_path(out, cap, joined); + return 1; + } + if (fc_file_exists_path(logical_path)) { + fc_copy_path(out, cap, logical_path); + return 1; + } + if (fc_join_path(joined, sizeof(joined), root, logical)) + fc_copy_path(out, cap, joined); + else + fc_copy_path(out, cap, logical); + return 0; +} + +int fc_repo_resolve_path(const char* logical_path, char* out, size_t cap) { + char joined[FC_ASSET_PATH_MAX]; + if (!out || cap == 0 || !logical_path || !logical_path[0]) return 0; + if (fc_path_is_absolute(logical_path) && fc_file_exists_path(logical_path)) { + fc_copy_path(out, cap, logical_path); + return 1; + } + if (fc_join_path(joined, sizeof(joined), fc_repo_root(), logical_path) + && fc_file_exists_path(joined)) { + fc_copy_path(out, cap, joined); + return 1; + } + if (fc_file_exists_path(logical_path)) { + fc_copy_path(out, cap, logical_path); + return 1; + } + if (fc_join_path(joined, sizeof(joined), fc_repo_root(), logical_path)) + fc_copy_path(out, cap, joined); + else + fc_copy_path(out, cap, logical_path); + return 0; +} + +int fc_asset_exists(const char* logical_path) { + char resolved[FC_ASSET_PATH_MAX]; + return fc_asset_resolve_path(logical_path, resolved, sizeof(resolved)); +} + +FILE* fc_asset_fopen(const char* logical_path, const char* mode) { + char resolved[FC_ASSET_PATH_MAX]; + FILE* f; + if (!fc_asset_resolve_path(logical_path, resolved, sizeof(resolved))) { + fprintf(stderr, "fc_asset_fopen: missing %s (looked for %s)\n", + logical_path ? logical_path : "(null)", resolved); + return NULL; + } + f = fopen(resolved, mode); + if (!f) + fprintf(stderr, "fc_asset_fopen: cannot open %s: %s\n", + resolved, strerror(errno)); + return f; +} + +int fc_asset_close(FILE* f) { + return f ? fclose(f) : 0; +} + +unsigned char* fc_asset_read_all(const char* logical_path, size_t* out_size) { + FILE* f = fc_asset_fopen(logical_path, "rb"); + long size; + unsigned char* data; + size_t got; + + if (out_size) *out_size = 0; + if (!f) return NULL; + if (fseek(f, 0, SEEK_END) != 0) { + fprintf(stderr, "%s: seek failed while reading asset\n", logical_path); + fc_asset_close(f); + return NULL; + } + size = ftell(f); + if (size <= 0) { + fprintf(stderr, "%s: empty or unreadable asset\n", logical_path); + fc_asset_close(f); + return NULL; + } + if (fseek(f, 0, SEEK_SET) != 0) { + fprintf(stderr, "%s: seek failed while reading asset\n", logical_path); + fc_asset_close(f); + return NULL; + } + data = malloc((size_t)size); + if (!data) { + fprintf(stderr, "%s: out of memory while reading asset\n", logical_path); + fc_asset_close(f); + return NULL; + } + got = fread(data, 1, (size_t)size, f); + fc_asset_close(f); + if (got != (size_t)size) { + fprintf(stderr, "%s: short read while reading asset\n", logical_path); + free(data); + return NULL; + } + if (out_size) *out_size = (size_t)size; + return data; +} + + +/* Asset Raylib */ +#include +#include + +static const char* fc_asset_extension(const char* path, const char* fallback) { + const char* dot = path ? strrchr(path, '.') : NULL; + return dot && dot[0] ? dot : fallback; +} + +Image fc_load_image_asset(const char* path) { + Image empty = {0}; + size_t size = 0; + unsigned char* bytes = fc_asset_read_all(path, &size); + Image image; + + if (!bytes || size == 0) return empty; + image = LoadImageFromMemory(fc_asset_extension(path, ".png"), bytes, + (int)size); + free(bytes); + return image; +} + +Texture2D fc_load_texture_asset(const char* path) { + Texture2D empty = {0}; + Image image = fc_load_image_asset(path); + Texture2D texture; + + if (!image.data) return empty; + texture = LoadTextureFromImage(image); + UnloadImage(image); + return texture; +} + +Font fc_load_font_asset(const char* path, int font_size) { + Font empty = {0}; + size_t size = 0; + unsigned char* bytes = fc_asset_read_all(path, &size); + Font font; + + if (!bytes || size == 0) return empty; + font = LoadFontFromMemory(fc_asset_extension(path, ".ttf"), bytes, + (int)size, font_size, NULL, 95); + free(bytes); + return font.texture.id != 0 ? font : empty; +} + + +/* Animated Atlas */ +#include +#include +#include +#include + +#define FC_ATLAS_MAGIC 0x41544C53u +#define FC_TEXTURE_ANIM_MAGIC 0x4D4E4154u +#define FC_TEXTURE_ANIM_VERSION 1u + +_Static_assert(sizeof(FcTextureAnimRow) == 16, + "TANM rows must retain their 16-byte file layout"); + +static int fc_companion_path(char* out, size_t cap, const char* path, + const char* extension) { + char* dot; + int written; + size_t offset; + size_t remaining; + if (!out || cap == 0 || !path || !extension) return 0; + written = snprintf(out, cap, "%s", path); + if (written < 0 || (size_t)written >= cap) return 0; + dot = strrchr(out, '.'); + if (dot) { + offset = (size_t)(dot - out); + } else { + offset = strlen(out); + } + remaining = cap - offset; + written = snprintf(out + offset, remaining, "%s", extension); + return written >= 0 && (size_t)written < remaining; +} + +static void fc_animated_atlas_load_anims(FcAnimatedAtlas* atlas, + const char* atlas_path) { + char path[FC_ASSET_PATH_MAX]; + FILE* file; + uint32_t magic = 0; + uint32_t version = 0; + uint32_t count = 0; + FcTextureAnimRow* rows; + + if (!atlas || !atlas->base_pixels || !atlas->pixels + || !fc_companion_path(path, sizeof(path), atlas_path, ".tanim") + || !fc_asset_exists(path)) + return; + file = fc_asset_fopen(path, "rb"); + if (!file) return; + if (!fc_read_exact(file, &magic, sizeof(magic), 1, path, "tanim magic") + || !fc_read_exact(file, &version, sizeof(version), 1, path, + "tanim version") + || !fc_read_exact(file, &count, sizeof(count), 1, path, + "tanim count") + || magic != FC_TEXTURE_ANIM_MAGIC + || version != FC_TEXTURE_ANIM_VERSION) { + fc_asset_close(file); + return; + } + rows = count > 0 ? calloc(count, sizeof(*rows)) : NULL; + if (count > 0 && !rows) { + fc_asset_close(file); + return; + } + for (uint32_t i = 0; i < count; i++) { + if (!fc_read_exact(file, &rows[i], sizeof(rows[i]), 1, path, + "tanim row")) { + free(rows); + fc_asset_close(file); + return; + } + } + fc_asset_close(file); + atlas->anims = rows; + atlas->anim_count = (int)count; + fprintf(stderr, "animated atlas: %d cells loaded from %s\n", + atlas->anim_count, path); +} + +int fc_animated_atlas_load(FcAnimatedAtlas* atlas, const char* atlas_path, + int enable_animation) { + FcAnimatedAtlas loaded = {0}; + FILE* file; + uint32_t magic = 0; + uint32_t width = 0; + uint32_t height = 0; + size_t pixel_count; + size_t pixel_size; + unsigned char* source_pixels; + Image image; + + if (!atlas || !atlas_path || atlas->texture.id > 0) return 0; + file = fc_asset_fopen(atlas_path, "rb"); + if (!file) return 0; + if (!fc_read_exact(file, &magic, sizeof(magic), 1, atlas_path, + "atlas magic") + || !fc_read_exact(file, &width, sizeof(width), 1, atlas_path, + "atlas width") + || !fc_read_exact(file, &height, sizeof(height), 1, atlas_path, + "atlas height") + || magic != FC_ATLAS_MAGIC || width == 0 || height == 0 + || (size_t)width > SIZE_MAX / (size_t)height) { + fc_asset_close(file); + return 0; + } + pixel_count = (size_t)width * (size_t)height; + if (pixel_count > SIZE_MAX / 4) { + fc_asset_close(file); + return 0; + } + pixel_size = pixel_count * 4; + source_pixels = malloc(pixel_size); + if (!source_pixels + || !fc_read_exact(file, source_pixels, 1, pixel_size, atlas_path, + "atlas pixels")) { + free(source_pixels); + fc_asset_close(file); + return 0; + } + fc_asset_close(file); + + image = (Image) { + .data = source_pixels, + .width = (int)width, + .height = (int)height, + .mipmaps = 1, + .format = PIXELFORMAT_UNCOMPRESSED_R8G8B8A8, + }; + loaded.texture = LoadTextureFromImage(image); + if (loaded.texture.id == 0) { + free(source_pixels); + return 0; + } + SetTextureFilter(loaded.texture, TEXTURE_FILTER_POINT); + loaded.width = (int)width; + loaded.height = (int)height; + if (enable_animation) { + loaded.base_pixels = malloc(pixel_size); + loaded.pixels = malloc(pixel_size); + if (loaded.base_pixels && loaded.pixels) { + memcpy(loaded.base_pixels, source_pixels, pixel_size); + memcpy(loaded.pixels, source_pixels, pixel_size); + } else { + free(loaded.base_pixels); + free(loaded.pixels); + loaded.base_pixels = NULL; + loaded.pixels = NULL; + } + } + free(source_pixels); + if (enable_animation) + fc_animated_atlas_load_anims(&loaded, atlas_path); + *atlas = loaded; + fprintf(stderr, "animated atlas: %ux%u loaded from %s\n", + width, height, atlas_path); + return 1; +} + +void fc_animated_atlas_update(FcAnimatedAtlas* atlas, float dt) { + size_t total; + if (!atlas || !atlas->pixels || !atlas->base_pixels + || atlas->texture.id == 0 || atlas->anim_count <= 0) + return; + + atlas->anim_ticks += dt * 50.0f; + total = (size_t)atlas->width * (size_t)atlas->height * 4; + memcpy(atlas->pixels, atlas->base_pixels, total); + for (int r = 0; r < atlas->anim_count; r++) { + FcTextureAnimRow* row = &atlas->anims[r]; + int shift; + if (row->w == 0 || row->h == 0 + || row->x + row->w > atlas->width + || row->y + row->h > atlas->height + || row->speed == 0) + continue; + shift = (int)(atlas->anim_ticks * (float)row->speed); + if (row->direction == 1 || row->direction == 3) { + int pad = row->pad; + int center_h; + if (pad * 2 >= row->h) pad = 0; + center_h = row->h - pad * 2; + shift %= center_h; + if (row->direction == 1) shift = -shift; + for (int y = 0; y < row->h; y++) { + int sy = (y - pad + shift) % center_h; + if (sy < 0) sy += center_h; + sy += pad; + for (int x = 0; x < row->w; x++) { + size_t dst = ((size_t)(row->y + y) * atlas->width + + row->x + x) * 4; + size_t src = ((size_t)(row->y + sy) * atlas->width + + row->x + x) * 4; + memcpy(&atlas->pixels[dst], &atlas->base_pixels[src], 4); + } + } + } else if (row->direction == 2 || row->direction == 4) { + shift %= row->w; + if (row->direction == 2) shift = -shift; + for (int y = 0; y < row->h; y++) { + for (int x = 0; x < row->w; x++) { + int sx = (x + shift) % row->w; + size_t dst; + size_t src; + if (sx < 0) sx += row->w; + dst = ((size_t)(row->y + y) * atlas->width + + row->x + x) * 4; + src = ((size_t)(row->y + y) * atlas->width + + row->x + sx) * 4; + memcpy(&atlas->pixels[dst], &atlas->base_pixels[src], 4); + } + } + } + } + UpdateTexture(atlas->texture, atlas->pixels); +} + +void fc_animated_atlas_unload(FcAnimatedAtlas* atlas) { + if (!atlas) return; + if (atlas->texture.id > 0) UnloadTexture(atlas->texture); + free(atlas->base_pixels); + free(atlas->pixels); + free(atlas->anims); + *atlas = (FcAnimatedAtlas) {0}; +} + +#undef FC_ATLAS_MAGIC +#undef FC_TEXTURE_ANIM_MAGIC +#undef FC_TEXTURE_ANIM_VERSION + +/* Anim Loader */ +/** + * @fileoverview OSRS animation runtime — loads .anims binary, applies vertex-group + * transforms to model base geometry, re-expands into raylib mesh for rendering. + * + * OSRS animations use vertex-group-based transforms (not bones). Each vertex has a + * skin label (group index). FrameBase defines transform slots with types + label arrays. + * Each frame provides per-slot {dx,dy,dz} values. Transform types: + * 0 = origin (compute centroid of referenced vertex groups → set pivot) + * 1 = translate (add dx/dy/dz to all vertices in referenced groups) + * 2 = rotate (euler Z-X-Y around pivot, raw*8 → 2048-entry sine table) + * 3 = scale (relative to pivot, 128 = 1.0x identity) + * 5 = alpha (face transparency, not used in our viewer) + * + * Binary format (.anims) produced by tools/cache_pipeline/export_animations.py: + * legacy header: uint32 magic ("MINA"), uint16 framebase_count, + * uint16 sequence_count + * current header: char[4] magic ("ANM2"), uint16 version, + * uint16 header_size, uint32 framebase_count, + * uint32 sequence_count, uint32 sequence_frame_count, + * uint32 flags + * framebases section, sequences section with inlined frame data. + */ + +#include +#include +#include +#include +#include + +#define ANIM_MAGIC 0x414E494D /* legacy bytes "MINA" when read little-endian */ +#define ANIM2_MAGIC 0x324D4E41 /* bytes "ANM2" when read little-endian */ +#define ANIM2_MIN_VERSION 2 +#define ANIM2_VERSION 3 +#define ANIM2_HEADER_SIZE 24 +#define ANIM_MAX_SLOTS 256 +#define ANIM_SINE_COUNT 2048 + +/* ======================================================================== */ +/* sine/cosine table (matches OSRS Rasterizer3D, fixed-point scale 65536) */ +/* ======================================================================== */ + +static int anim_sine[ANIM_SINE_COUNT]; +static int anim_cosine[ANIM_SINE_COUNT]; +static int anim_trig_initialized = 0; + +static void anim_init_trig(void) { + if (anim_trig_initialized) return; + for (int i = 0; i < ANIM_SINE_COUNT; i++) { + double angle = (double)i * (2.0 * 3.14159265358979323846 / ANIM_SINE_COUNT); + anim_sine[i] = (int)(65536.0 * sin(angle)); + anim_cosine[i] = (int)(65536.0 * cos(angle)); + } + anim_trig_initialized = 1; +} + +/* ======================================================================== */ +/* loading */ +/* ======================================================================== */ + +typedef struct { + const uint8_t* p; + size_t remaining; + const char* path; +} AnimCursor; + +static int anim_take(AnimCursor* c, void* dst, size_t size, const char* what) { + if (!c || c->remaining < size) { + fprintf(stderr, "%s: short read while loading %s\n", + c && c->path ? c->path : "(anim)", what); + return 0; + } + if (dst) memcpy(dst, c->p, size); + c->p += size; + c->remaining -= size; + return 1; +} + +static int anim_read_u8(AnimCursor* c, uint8_t* out, const char* what) { + return anim_take(c, out, 1, what); +} + +static int anim_read_u16(AnimCursor* c, uint16_t* out, const char* what) { + uint8_t b[2]; + if (!anim_take(c, b, sizeof(b), what)) return 0; + *out = (uint16_t)b[0] | ((uint16_t)b[1] << 8); + return 1; +} + +static int anim_read_i16(AnimCursor* c, int16_t* out, const char* what) { + uint16_t u = 0; + if (!anim_read_u16(c, &u, what)) return 0; + *out = (int16_t)u; + return 1; +} + +static int anim_read_i8(AnimCursor* c, int8_t* out, const char* what) { + uint8_t u = 0; + if (!anim_read_u8(c, &u, what)) return 0; + *out = (int8_t)u; + return 1; +} + +static int anim_read_u32(AnimCursor* c, uint32_t* out, const char* what) { + uint8_t b[4]; + if (!anim_take(c, b, sizeof(b), what)) return 0; + *out = (uint32_t)b[0] + | ((uint32_t)b[1] << 8) + | ((uint32_t)b[2] << 16) + | ((uint32_t)b[3] << 24); + return 1; +} + +AnimCache* anim_cache_load(const char* path) { + size_t size = 0; + uint8_t* buf = fc_asset_read_all(path, &size); + AnimCursor cur; + uint32_t magic = 0; + uint32_t base_count = 0; + uint32_t seq_count = 0; + uint16_t format_version = 1; + AnimCache* cache; + + if (!buf) return NULL; + cur.p = buf; + cur.remaining = size; + cur.path = path; + + if (!anim_read_u32(&cur, &magic, "anim magic")) { + free(buf); + return NULL; + } + if (magic == ANIM_MAGIC) { + uint16_t legacy_base_count = 0; + uint16_t legacy_seq_count = 0; + if (!anim_read_u16(&cur, &legacy_base_count, "framebase count") || + !anim_read_u16(&cur, &legacy_seq_count, "sequence count")) { + free(buf); + return NULL; + } + base_count = legacy_base_count; + seq_count = legacy_seq_count; + } else if (magic == ANIM2_MAGIC) { + uint16_t version = 0; + uint16_t header_size = 0; + uint32_t sequence_frame_count = 0; + uint32_t flags = 0; + (void)sequence_frame_count; + (void)flags; + if (!anim_read_u16(&cur, &version, "anim version") || + !anim_read_u16(&cur, &header_size, "anim header size") || + !anim_read_u32(&cur, &base_count, "framebase count") || + !anim_read_u32(&cur, &seq_count, "sequence count") || + !anim_read_u32(&cur, &sequence_frame_count, "sequence frame count") || + !anim_read_u32(&cur, &flags, "anim flags")) { + free(buf); + return NULL; + } + if (version < ANIM2_MIN_VERSION || version > ANIM2_VERSION || + header_size < ANIM2_HEADER_SIZE) { + fprintf(stderr, "anim_cache_load: unsupported ANM2 v%u header %u\n", + version, header_size); + free(buf); + return NULL; + } + format_version = version; + if (header_size > ANIM2_HEADER_SIZE && + !anim_take(&cur, NULL, header_size - ANIM2_HEADER_SIZE, + "anim header extension")) { + free(buf); + return NULL; + } + } else { + fprintf(stderr, "anim_cache_load: bad magic 0x%08X\n", magic); + free(buf); + return NULL; + } + + if (base_count > 65535u || seq_count > 65535u) { + fprintf(stderr, "anim_cache_load: unreasonable counts %u/%u\n", + base_count, seq_count); + free(buf); + return NULL; + } + + cache = (AnimCache*)calloc(1, sizeof(AnimCache)); + if (!cache) { + free(buf); + return NULL; + } + cache->base_count = (int)base_count; + cache->seq_count = (int)seq_count; + + /* load framebases */ + cache->bases = (AnimFrameBase*)calloc(cache->base_count, sizeof(AnimFrameBase)); + cache->base_ids = (uint16_t*)malloc(cache->base_count * sizeof(uint16_t)); + if (!cache->bases || !cache->base_ids) { + free(buf); + anim_cache_free(cache); + return NULL; + } + + for (int i = 0; i < cache->base_count; i++) { + AnimFrameBase* fb = &cache->bases[i]; + if (!anim_read_u16(&cur, &fb->base_id, "framebase id")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + cache->base_ids[i] = fb->base_id; + if (!anim_read_u8(&cur, &fb->slot_count, "framebase slot count")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + + fb->types = (uint8_t*)malloc(fb->slot_count); + for (int s = 0; s < fb->slot_count; s++) { + if (!fb->types || + !anim_read_u8(&cur, &fb->types[s], "framebase slot type")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + } + + fb->map_lengths = (uint8_t*)malloc(fb->slot_count); + fb->frame_maps = (uint8_t**)malloc(fb->slot_count * sizeof(uint8_t*)); + if (!fb->map_lengths || !fb->frame_maps) { + free(buf); + anim_cache_free(cache); + return NULL; + } + for (int s = 0; s < fb->slot_count; s++) { + uint8_t ml = 0; + if (!anim_read_u8(&cur, &ml, "framebase map length")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + fb->map_lengths[s] = ml; + fb->frame_maps[s] = (uint8_t*)malloc(ml); + if (ml > 0 && !fb->frame_maps[s]) { + free(buf); + anim_cache_free(cache); + return NULL; + } + for (int j = 0; j < ml; j++) { + if (!anim_read_u8(&cur, &fb->frame_maps[s][j], + "framebase map label")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + } + } + } + + /* load sequences */ + cache->sequences = (AnimSequence*)calloc(cache->seq_count, sizeof(AnimSequence)); + if (!cache->sequences) { + free(buf); + anim_cache_free(cache); + return NULL; + } + for (int i = 0; i < cache->seq_count; i++) { + AnimSequence* seq = &cache->sequences[i]; + if (!anim_read_u16(&cur, &seq->seq_id, "sequence id") || + !anim_read_u16(&cur, &seq->frame_count, "sequence frame count")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + + if (!anim_read_u8(&cur, &seq->interleave_count, "sequence interleave count")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + if (seq->interleave_count > 0) { + seq->interleave_order = (uint8_t*)malloc(seq->interleave_count); + if (!seq->interleave_order) { + free(buf); + anim_cache_free(cache); + return NULL; + } + for (int j = 0; j < seq->interleave_count; j++) { + if (!anim_read_u8(&cur, &seq->interleave_order[j], + "sequence interleave slot")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + } + } + + if (format_version >= 3) { + if (!anim_read_i16(&cur, &seq->frame_step, + "sequence frame step") || + !anim_read_i8(&cur, &seq->preanim_move, + "sequence pre-animation movement") || + !anim_read_i8(&cur, &seq->postanim_move, + "sequence post-animation movement") || + !anim_read_u8(&cur, &seq->forced_priority, + "sequence forced priority") || + !anim_read_u8(&cur, &seq->max_loops, + "sequence max loops") || + !anim_read_i8(&cur, &seq->reply_mode, + "sequence reply mode") || + !anim_read_u8(&cur, &seq->stretches, + "sequence stretches")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + seq->walk_flag = seq->postanim_move; + } else { + uint8_t walk_flag = 0; + if (!anim_read_u8(&cur, &walk_flag, "sequence walk flag")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + seq->walk_flag = (int8_t)walk_flag; + seq->frame_step = -1; + seq->preanim_move = seq->interleave_count > 0 ? 2 : 0; + seq->postanim_move = seq->walk_flag >= 0 + ? seq->walk_flag + : (seq->interleave_count > 0 ? 2 : 0); + seq->forced_priority = 5; + seq->max_loops = 99; + seq->reply_mode = -1; + seq->stretches = 0; + } + + seq->frames = (AnimSequenceFrame*)calloc(seq->frame_count, sizeof(AnimSequenceFrame)); + if (!seq->frames) { + free(buf); + anim_cache_free(cache); + return NULL; + } + for (int fi = 0; fi < seq->frame_count; fi++) { + AnimSequenceFrame* sf = &seq->frames[fi]; + if (!anim_read_u16(&cur, &sf->delay, "sequence frame delay") || + !anim_read_u16(&cur, &sf->frame.framebase_id, "sequence framebase id") || + !anim_read_u8(&cur, &sf->frame.transform_count, "sequence transform count")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + + if (sf->frame.transform_count > 0) { + sf->frame.transforms = (AnimTransform*)malloc( + sf->frame.transform_count * sizeof(AnimTransform)); + if (!sf->frame.transforms) { + free(buf); + anim_cache_free(cache); + return NULL; + } + for (int t = 0; t < sf->frame.transform_count; t++) { + if (!anim_read_u8(&cur, &sf->frame.transforms[t].slot_index, + "sequence transform slot") || + !anim_read_i16(&cur, &sf->frame.transforms[t].dx, + "sequence transform dx") || + !anim_read_i16(&cur, &sf->frame.transforms[t].dy, + "sequence transform dy") || + !anim_read_i16(&cur, &sf->frame.transforms[t].dz, + "sequence transform dz")) { + free(buf); + anim_cache_free(cache); + return NULL; + } + } + } + } + } + + free(buf); + anim_init_trig(); + + fprintf(stderr, "anim_cache_load: loaded %d framebases, %d sequences from %s\n", + cache->base_count, cache->seq_count, path); + return cache; +} + +/* ======================================================================== */ +/* lookup */ +/* ======================================================================== */ + +AnimSequence* anim_get_sequence(AnimCache* cache, uint16_t seq_id) { + if (!cache) return NULL; + for (int i = 0; i < cache->seq_count; i++) { + if (cache->sequences[i].seq_id == seq_id) { + return &cache->sequences[i]; + } + } + return NULL; +} + +AnimFrameBase* anim_get_framebase(AnimCache* cache, uint16_t base_id) { + if (!cache) return NULL; + for (int i = 0; i < cache->base_count; i++) { + if (cache->bases[i].base_id == base_id) { + return &cache->bases[i]; + } + } + return NULL; +} + +/* ======================================================================== */ +/* per-model animation state */ +/* ======================================================================== */ + +AnimModelState* anim_model_state_create( + const uint8_t* vertex_skins, + int base_vert_count +) { + AnimModelState* state = (AnimModelState*)calloc(1, sizeof(AnimModelState)); + state->vert_count = base_vert_count; + state->verts = (int16_t*)calloc(base_vert_count * 3, sizeof(int16_t)); + + /* build vertex group lookup from skin labels */ + state->groups = (int**)calloc(ANIM_MAX_LABELS, sizeof(int*)); + state->group_counts = (int*)calloc(ANIM_MAX_LABELS, sizeof(int)); + + /* first pass: count vertices per label */ + int label_counts[ANIM_MAX_LABELS] = {0}; + for (int v = 0; v < base_vert_count; v++) { + uint8_t label = vertex_skins[v]; + label_counts[label]++; + } + + /* allocate per-label arrays */ + for (int l = 0; l < ANIM_MAX_LABELS; l++) { + if (label_counts[l] > 0) { + state->groups[l] = (int*)malloc(label_counts[l] * sizeof(int)); + state->group_counts[l] = 0; + } + } + + /* second pass: fill vertex indices */ + for (int v = 0; v < base_vert_count; v++) { + uint8_t label = vertex_skins[v]; + state->groups[label][state->group_counts[label]++] = v; + } + + return state; +} + +void anim_model_state_free(AnimModelState* state) { + if (!state) return; + free(state->verts); + for (int l = 0; l < ANIM_MAX_LABELS; l++) { + free(state->groups[l]); + } + free(state->groups); + free(state->group_counts); + free(state); +} + +/* ======================================================================== */ +/* transform application (mirrors OSRS Model.transform) */ +/* ======================================================================== */ + +void anim_apply_frame( + AnimModelState* state, + const int16_t* base_verts_src, + const AnimFrameData* frame, + const AnimFrameBase* fb +) { + /* reset to base pose */ + memcpy(state->verts, base_verts_src, state->vert_count * 3 * sizeof(int16_t)); + + /* pivot point for rotate/scale */ + int pivot_x = 0, pivot_y = 0, pivot_z = 0; + + for (int t = 0; t < frame->transform_count; t++) { + uint8_t slot_idx = frame->transforms[t].slot_index; + if (slot_idx >= fb->slot_count) continue; + + int type = fb->types[slot_idx]; + int dx = frame->transforms[t].dx; + int dy = frame->transforms[t].dy; + int dz = frame->transforms[t].dz; + + uint8_t map_len = fb->map_lengths[slot_idx]; + const uint8_t* labels = fb->frame_maps[slot_idx]; + + if (type == 0) { + /* origin: compute centroid of referenced vertex groups */ + int count = 0; + int sum_x = 0, sum_y = 0, sum_z = 0; + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + sum_x += state->verts[v * 3]; + sum_y += state->verts[v * 3 + 1]; + sum_z += state->verts[v * 3 + 2]; + count++; + } + } + if (count > 0) { + pivot_x = sum_x / count + dx; + pivot_y = sum_y / count + dy; + pivot_z = sum_z / count + dz; + } else { + pivot_x = dx; + pivot_y = dy; + pivot_z = dz; + } + } else if (type == 1) { + /* translate: add dx/dy/dz to all vertices in referenced groups */ + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + state->verts[v * 3] += (int16_t)dx; + state->verts[v * 3 + 1] += (int16_t)dy; + state->verts[v * 3 + 2] += (int16_t)dz; + } + } + } else if (type == 2) { + /* rotate: euler Z-X-Y around pivot. + * raw value * 8 → index into 2048-entry sine table. + * rotation order: Z first, then X, then Y. */ + int ax = (dx & 0xFF) * 8; + int ay = (dy & 0xFF) * 8; + int az = (dz & 0xFF) * 8; + + int sin_x = anim_sine[ax & 2047]; + int cos_x = anim_cosine[ax & 2047]; + int sin_y = anim_sine[ay & 2047]; + int cos_y = anim_cosine[ay & 2047]; + int sin_z = anim_sine[az & 2047]; + int cos_z = anim_cosine[az & 2047]; + + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - pivot_x; + int vy = state->verts[v * 3 + 1] - pivot_y; + int vz = state->verts[v * 3 + 2] - pivot_z; + + /* Z rotation */ + int rx = (vx * cos_z + vy * sin_z) >> 16; + int ry = (vy * cos_z - vx * sin_z) >> 16; + vx = rx; vy = ry; + + /* X rotation */ + ry = (vy * cos_x - vz * sin_x) >> 16; + int rz = (vy * sin_x + vz * cos_x) >> 16; + vy = ry; vz = rz; + + /* Y rotation */ + rx = (vz * sin_y + vx * cos_y) >> 16; + rz = (vz * cos_y - vx * sin_y) >> 16; + vx = rx; vz = rz; + + state->verts[v * 3] = (int16_t)(vx + pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + pivot_y); + state->verts[v * 3 + 2] = (int16_t)(vz + pivot_z); + } + } + } else if (type == 3) { + /* scale: relative to pivot, 128 = 1.0x identity */ + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - pivot_x; + int vy = state->verts[v * 3 + 1] - pivot_y; + int vz = state->verts[v * 3 + 2] - pivot_z; + + vx = (vx * dx) / 128; + vy = (vy * dy) / 128; + vz = (vz * dz) / 128; + + state->verts[v * 3] = (int16_t)(vx + pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + pivot_y); + state->verts[v * 3 + 2] = (int16_t)(vz + pivot_z); + } + } + } + /* type 5 (alpha) skipped — we don't use face transparency in the viewer */ + } +} + +/* ======================================================================== */ +/* two-track interleaved animation (matches OSRS Model.applyAnimationFrames) */ +/* ======================================================================== */ + +/** + * Apply a single transform slot to the vertex state (extracted from anim_apply_frame + * to allow per-slot interleave filtering). + * + * pivot_x/y/z are read/written through pointers — they persist across slots + * within a pass, exactly like the reference's transformTempX/Y/Z. + */ +static void anim_apply_single_transform( + AnimModelState* state, + int type, const uint8_t* labels, uint8_t map_len, + int dx, int dy, int dz, + int* pivot_x, int* pivot_y, int* pivot_z +) { + if (type == 0) { + /* origin: compute centroid of referenced vertex groups */ + int count = 0, sx = 0, sy = 0, sz = 0; + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + sx += state->verts[v * 3]; + sy += state->verts[v * 3 + 1]; + sz += state->verts[v * 3 + 2]; + count++; + } + } + if (count > 0) { + *pivot_x = sx / count + dx; + *pivot_y = sy / count + dy; + *pivot_z = sz / count + dz; + } else { + *pivot_x = dx; + *pivot_y = dy; + *pivot_z = dz; + } + } else if (type == 1) { + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + state->verts[v * 3] += (int16_t)dx; + state->verts[v * 3 + 1] += (int16_t)dy; + state->verts[v * 3 + 2] += (int16_t)dz; + } + } + } else if (type == 2) { + int ax = (dx & 0xFF) * 8, ay = (dy & 0xFF) * 8, az = (dz & 0xFF) * 8; + int sin_x = anim_sine[ax & 2047], cos_x = anim_cosine[ax & 2047]; + int sin_y = anim_sine[ay & 2047], cos_y = anim_cosine[ay & 2047]; + int sin_z = anim_sine[az & 2047], cos_z = anim_cosine[az & 2047]; + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - *pivot_x; + int vy = state->verts[v * 3 + 1] - *pivot_y; + int vz = state->verts[v * 3 + 2] - *pivot_z; + int rx = (vx * cos_z + vy * sin_z) >> 16; + int ry = (vy * cos_z - vx * sin_z) >> 16; + vx = rx; vy = ry; + ry = (vy * cos_x - vz * sin_x) >> 16; + int rz = (vy * sin_x + vz * cos_x) >> 16; + vy = ry; vz = rz; + rx = (vz * sin_y + vx * cos_y) >> 16; + rz = (vz * cos_y - vx * sin_y) >> 16; + state->verts[v * 3] = (int16_t)(rx + *pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + *pivot_y); + state->verts[v * 3 + 2] = (int16_t)(rz + *pivot_z); + } + } + } else if (type == 3) { + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - *pivot_x; + int vy = state->verts[v * 3 + 1] - *pivot_y; + int vz = state->verts[v * 3 + 2] - *pivot_z; + state->verts[v * 3] = (int16_t)((vx * dx) / 128 + *pivot_x); + state->verts[v * 3 + 1] = (int16_t)((vy * dy) / 128 + *pivot_y); + state->verts[v * 3 + 2] = (int16_t)((vz * dz) / 128 + *pivot_z); + } + } + } +} + +/** + * Apply two animation frames with body-part interleaving. + * + * Mirrors OSRS Model.applyAnimationFrames(): + * - interleave_order lists framebase SLOT INDICES owned by SECONDARY (walk) + * - Pass 1: apply primary transforms for slots NOT in interleave_order + * - Pass 2: apply secondary transforms for slots IN interleave_order + * - Type-0 (pivot) transforms always execute in both passes + * + * CRITICAL: interleave_order contains framebase SLOT INDICES, not vertex labels! + * The reference code (Model.java:1322-1343) walks both the frame's slot list and + * the interleave_order simultaneously, comparing slot indices directly. + * + * Both passes operate on the same vertex state with independent pivot tracking, + * exactly as the reference does with transformTempX/Y/Z reset between passes. + */ +static void anim_apply_frame_interleaved( + AnimModelState* state, + const int16_t* base_verts_src, + const AnimFrameData* secondary_frame, const AnimFrameBase* secondary_fb, + const AnimFrameData* primary_frame, const AnimFrameBase* primary_fb, + const uint8_t* interleave_order, int interleave_count +) { + /* reset to base pose */ + memcpy(state->verts, base_verts_src, state->vert_count * 3 * sizeof(int16_t)); + + /* build boolean mask: interleave_order lists SLOT INDICES the SECONDARY owns. + index by slot index (0-244 for our 245-slot framebase), NOT vertex labels. */ + uint8_t secondary_slot[256]; + memset(secondary_slot, 0, sizeof(secondary_slot)); + for (int i = 0; i < interleave_count; i++) { + secondary_slot[interleave_order[i]] = 1; + } + + /* pass 1: primary frame — apply transforms for slots NOT in interleave_order. + * type-0 (pivot) always executes regardless of ownership. + * matches reference: if (k1 != i1 || class18.types[k1] == 0) */ + int pivot_x = 0, pivot_y = 0, pivot_z = 0; + for (int t = 0; t < primary_frame->transform_count; t++) { + uint8_t slot_idx = primary_frame->transforms[t].slot_index; + if (slot_idx >= primary_fb->slot_count) continue; + + int type = primary_fb->types[slot_idx]; + int in_interleave = secondary_slot[slot_idx]; + + if (!in_interleave || type == 0) { + anim_apply_single_transform( + state, type, + primary_fb->frame_maps[slot_idx], + primary_fb->map_lengths[slot_idx], + primary_frame->transforms[t].dx, + primary_frame->transforms[t].dy, + primary_frame->transforms[t].dz, + &pivot_x, &pivot_y, &pivot_z); + } + } + + /* pass 2: secondary frame — apply transforms for slots IN interleave_order. + * type-0 (pivot) always executes. + * matches reference: if (i2 == i1 || class18.types[i2] == 0) */ + pivot_x = 0; pivot_y = 0; pivot_z = 0; + for (int t = 0; t < secondary_frame->transform_count; t++) { + uint8_t slot_idx = secondary_frame->transforms[t].slot_index; + if (slot_idx >= secondary_fb->slot_count) continue; + + int type = secondary_fb->types[slot_idx]; + int in_interleave = secondary_slot[slot_idx]; + + if (in_interleave || type == 0) { + anim_apply_single_transform( + state, type, + secondary_fb->frame_maps[slot_idx], + secondary_fb->map_lengths[slot_idx], + secondary_frame->transforms[t].dx, + secondary_frame->transforms[t].dy, + secondary_frame->transforms[t].dz, + &pivot_x, &pivot_y, &pivot_z); + } + } +} + +/* Apply the current pose/action pair to one model state. The action owns the + * full model unless its sequence supplies an OSRS interleave table, in which + * case the pose supplies the interleaved transform slots. If the action frame + * cannot be applied, fall back to the pose exactly as a single-track actor + * would. Mesh upload remains the caller's responsibility because player + * models are unique while same-type NPCs share one render mesh. */ +int anim_mix_pose_action( + AnimCache* cache, + AnimModelState* state, + const int16_t* base_verts, + AnimSequence* pose, + int pose_frame_index, + AnimSequence* action, + int action_frame_index +) { + if (!cache || !state || !base_verts) return 0; + + if (action && action_frame_index >= 0 && + action_frame_index < action->frame_count) { + AnimFrameData* action_frame = + &action->frames[action_frame_index].frame; + AnimFrameBase* action_base = + anim_get_framebase(cache, action_frame->framebase_id); + if (action_base && pose && pose_frame_index >= 0 && + pose_frame_index < pose->frame_count && + action->interleave_count > 0 && action->interleave_order) { + AnimFrameData* pose_frame = + &pose->frames[pose_frame_index].frame; + AnimFrameBase* pose_base = + anim_get_framebase(cache, pose_frame->framebase_id); + if (pose_base) { + anim_apply_frame_interleaved( + state, base_verts, + pose_frame, pose_base, action_frame, action_base, + action->interleave_order, action->interleave_count); + return 1; + } + } else if (action_base) { + anim_apply_frame(state, base_verts, action_frame, action_base); + return 1; + } + } + + if (pose && pose_frame_index >= 0 && + pose_frame_index < pose->frame_count) { + AnimFrameData* pose_frame = &pose->frames[pose_frame_index].frame; + AnimFrameBase* pose_base = + anim_get_framebase(cache, pose_frame->framebase_id); + if (pose_base) { + anim_apply_frame(state, base_verts, pose_frame, pose_base); + return 1; + } + } + return 0; +} + +/* ======================================================================== */ +/* mesh re-expansion (apply animated base verts → expanded rendering verts) */ +/* ======================================================================== */ + +/** + * Re-expand animated base vertices into the raylib mesh's expanded vertex buffer. + * This mirrors expand_model from the Python exporter but in-place, using + * face_indices to map from base to expanded vertices. + * + * The mesh has face_count*3 expanded vertices. Each triplet (i*3, i*3+1, i*3+2) + * corresponds to face_indices[i*3], face_indices[i*3+1], face_indices[i*3+2] + * pointing into base_vertices. + * + * OSRS Y is negated for rendering (negative-up → positive-up). + */ +void anim_update_mesh( + float* mesh_vertices, + const AnimModelState* state, + const uint16_t* face_indices, + int face_count +) { + for (int fi = 0; fi < face_count; fi++) { + int a = face_indices[fi * 3]; + int b = face_indices[fi * 3 + 1]; + int c = face_indices[fi * 3 + 2]; + + int vi = fi * 9; /* 3 verts * 3 coords */ + mesh_vertices[vi] = (float)state->verts[a * 3]; + mesh_vertices[vi + 1] = (float)(-state->verts[a * 3 + 1]); /* negate Y */ + mesh_vertices[vi + 2] = (float)state->verts[a * 3 + 2]; + + mesh_vertices[vi + 3] = (float)state->verts[b * 3]; + mesh_vertices[vi + 4] = (float)(-state->verts[b * 3 + 1]); + mesh_vertices[vi + 5] = (float)state->verts[b * 3 + 2]; + + mesh_vertices[vi + 6] = (float)state->verts[c * 3]; + mesh_vertices[vi + 7] = (float)(-state->verts[c * 3 + 1]); + mesh_vertices[vi + 8] = (float)state->verts[c * 3 + 2]; + } +} + +/* ======================================================================== */ +/* cleanup */ +/* ======================================================================== */ + +void anim_cache_free(AnimCache* cache) { + if (!cache) return; + + for (int i = 0; i < cache->base_count; i++) { + AnimFrameBase* fb = &cache->bases[i]; + free(fb->types); + free(fb->map_lengths); + for (int s = 0; s < fb->slot_count; s++) { + free(fb->frame_maps[s]); + } + free(fb->frame_maps); + } + free(cache->bases); + free(cache->base_ids); + + for (int i = 0; i < cache->seq_count; i++) { + AnimSequence* seq = &cache->sequences[i]; + free(seq->interleave_order); + for (int fi = 0; fi < seq->frame_count; fi++) { + free(seq->frames[fi].frame.transforms); + } + free(seq->frames); + } + free(cache->sequences); + free(cache); +} + +#undef ANIM_MAGIC +#undef ANIM2_MAGIC +#undef ANIM2_MIN_VERSION +#undef ANIM2_VERSION +#undef ANIM2_HEADER_SIZE +#undef ANIM_MAX_SLOTS +#undef ANIM_SINE_COUNT + +/* Models */ +// Loads models from .models MDL2/MDL3 binary for Raylib rendering. +// Fight Caves raylib model loader. + +#include "raylib.h" +#include +#include +#include +#include +#include + +#define MDL2_MAGIC 0x4D444C32 +#define MDL3_MAGIC 0x4D444C33 +#define MUV1_MAGIC 0x3156554D +#define MODEL_ID_INDEX_MAX 20000 + +static int model_id_filter_contains(const uint32_t *ids, int id_count, uint32_t id) { + if (!ids) return 1; + if (id_count <= 0) return 0; + for (int i = 0; i < id_count; i++) + if (ids[i] == id) return 1; + return 0; +} + +ModelEntry *model_find(ModelSet *set, uint32_t id) { + if (!set) return NULL; + if (id < (uint32_t)set->index_limit && set->index_by_id) { + int idx = set->index_by_id[id]; + if (idx >= 0 && idx < set->count) return &set->entries[idx]; + } + for (int i = 0; i < set->count; i++) + if (set->entries[i].model_id == id && set->entries[i].loaded) return &set->entries[i]; + return NULL; +} + +static float model_clamp_uv(float v) { + if (v < 0.0f) return 0.0f; + if (v > 1.0f) return 1.0f; + return v; +} + +static float model_repeat_uv_with_margin(float v, float margin) { + if (margin <= 0.0f) + return model_clamp_uv(v); + while (v < -margin) v += 1.0f; + while (v > 1.0f + margin) v -= 1.0f; + if (v < -margin) return -margin; + if (v > 1.0f + margin) return 1.0f + margin; + return v; +} + +static void model_project_uvs_for_face(const int16_t *verts, + int tri_a, int tri_b, int tri_c, + int tex_a, int tex_b, int tex_c, + float *u, float *v) { + u[0] = 0.0f; u[1] = 1.0f; u[2] = 0.0f; + v[0] = 0.0f; v[1] = 0.0f; v[2] = 1.0f; + + float v1x = (float)verts[tex_a * 3]; + float v1y = (float)verts[tex_a * 3 + 1]; + float v1z = (float)verts[tex_a * 3 + 2]; + float v2x = (float)verts[tex_b * 3] - v1x; + float v2y = (float)verts[tex_b * 3 + 1] - v1y; + float v2z = (float)verts[tex_b * 3 + 2] - v1z; + float v3x = (float)verts[tex_c * 3] - v1x; + float v3y = (float)verts[tex_c * 3 + 1] - v1y; + float v3z = (float)verts[tex_c * 3 + 2] - v1z; + float v4x = (float)verts[tri_a * 3] - v1x; + float v4y = (float)verts[tri_a * 3 + 1] - v1y; + float v4z = (float)verts[tri_a * 3 + 2] - v1z; + float v5x = (float)verts[tri_b * 3] - v1x; + float v5y = (float)verts[tri_b * 3 + 1] - v1y; + float v5z = (float)verts[tri_b * 3 + 2] - v1z; + float v6x = (float)verts[tri_c * 3] - v1x; + float v6y = (float)verts[tri_c * 3 + 1] - v1y; + float v6z = (float)verts[tri_c * 3 + 2] - v1z; + + float v7x = v2y * v3z - v2z * v3y; + float v7y = v2z * v3x - v2x * v3z; + float v7z = v2x * v3y - v2y * v3x; + + float v8x = v3y * v7z - v3z * v7y; + float v8y = v3z * v7x - v3x * v7z; + float v8z = v3x * v7y - v3y * v7x; + float denom = v8x * v2x + v8y * v2y + v8z * v2z; + if (fabsf(denom) < 1.0e-6f) + return; + float inv = 1.0f / denom; + u[0] = (v8x * v4x + v8y * v4y + v8z * v4z) * inv; + u[1] = (v8x * v5x + v8y * v5y + v8z * v5z) * inv; + u[2] = (v8x * v6x + v8y * v6y + v8z * v6z) * inv; + + v8x = v2y * v7z - v2z * v7y; + v8y = v2z * v7x - v2x * v7z; + v8z = v2x * v7y - v2y * v7x; + denom = v8x * v3x + v8y * v3y + v8z * v3z; + if (fabsf(denom) < 1.0e-6f) + return; + inv = 1.0f / denom; + v[0] = (v8x * v4x + v8y * v4y + v8z * v4z) * inv; + v[1] = (v8x * v5x + v8y * v5y + v8z * v5z) * inv; + v[2] = (v8x * v6x + v8y * v6y + v8z * v6z) * inv; +} + +void models_recompute_texture_uvs_from_vertices(ModelEntry *entry, + const int16_t *verts) { + if (!entry || !entry->loaded || !entry->face_uvs || !verts) + return; + Mesh *mesh = &entry->model.meshes[0]; + if (!mesh->texcoords || !entry->face_indices) + return; + for (int fi = 0; fi < entry->face_count; fi++) { + ModelFaceUvInfo *info = &entry->face_uvs[fi]; + if (!info->textured) + continue; + int tri_a = entry->face_indices[fi * 3]; + int tri_b = entry->face_indices[fi * 3 + 1]; + int tri_c = entry->face_indices[fi * 3 + 2]; + if (tri_a >= entry->base_vert_count || tri_b >= entry->base_vert_count + || tri_c >= entry->base_vert_count + || info->tex_a >= entry->base_vert_count + || info->tex_b >= entry->base_vert_count + || info->tex_c >= entry->base_vert_count) + continue; + float u[3], v[3]; + model_project_uvs_for_face(verts, tri_a, tri_b, tri_c, + info->tex_a, info->tex_b, info->tex_c, + u, v); + for (int j = 0; j < 3; j++) { + float cu = model_clamp_uv(u[j]); + float cv = model_repeat_uv_with_margin(v[j], + info->repeat_v_margin); + int out = (fi * 3 + j) * 2; + mesh->texcoords[out] = info->u_base + cu * info->u_scale; + mesh->texcoords[out + 1] = info->v_base + cv * info->v_scale; + } + } + UpdateMeshBuffer(*mesh, 1, mesh->texcoords, + mesh->vertexCount * 2 * sizeof(float), 0); +} + +static ModelSet *models_load_filtered(const char *path, const uint32_t *ids, + int id_count, Texture2D atlas_texture) { + FILE *f = fc_asset_fopen(path, "rb"); + if (!f) { fprintf(stderr, "models: can't open %s\n", path); return NULL; } + + uint32_t magic, count; + if (!fc_read_exact(f, &magic, sizeof(magic), 1, path, "model magic") + || (magic != MDL2_MAGIC && magic != MDL3_MAGIC)) { + fprintf(stderr, "models: bad magic\n"); + fc_asset_close(f); + return NULL; + } + int has_tex = (magic == MDL3_MAGIC); + if (!fc_read_exact(f, &count, sizeof(count), 1, path, "model count")) { + fc_asset_close(f); + return NULL; + } + uint32_t *offsets = malloc(count * 4); + if (!offsets + || !fc_read_exact(f, offsets, sizeof(offsets[0]), count, path, "model offsets")) { + free(offsets); + fc_asset_close(f); + return NULL; + } + + ModelSet *set = calloc(1, sizeof(ModelSet)); + if (!set) { + free(offsets); + fc_asset_close(f); + return NULL; + } + set->entries = calloc(count, sizeof(ModelEntry)); + set->index_limit = MODEL_ID_INDEX_MAX; + set->index_by_id = malloc(sizeof(int) * set->index_limit); + if (!set->entries || !set->index_by_id) { + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + for (int i = 0; i < set->index_limit; i++) set->index_by_id[i] = -1; + set->count = (int)count; + set->has_textures = has_tex; + + if (has_tex && atlas_texture.id == 0) { + fprintf(stderr, "models: shared atlas unavailable for %s\n", path); + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + + long model_file_end = 0; + long model_file_pos = ftell(f); + if (model_file_pos >= 0 && fseek(f, 0, SEEK_END) == 0) { + model_file_end = ftell(f); + fseek(f, model_file_pos, SEEK_SET); + } + + int loaded_count = 0; + for (uint32_t m = 0; m < count; m++) { + if (!fc_seek(f, offsets[m], SEEK_SET, path, "model offset table")) { + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + uint32_t mid; uint16_t evc, fc, bvc; + if (!fc_read_exact(f, &mid, sizeof(mid), 1, path, "model id") + || !fc_read_exact(f, &evc, sizeof(evc), 1, path, "model expanded vertex count") + || !fc_read_exact(f, &fc, sizeof(fc), 1, path, "model face count") + || !fc_read_exact(f, &bvc, sizeof(bvc), 1, path, "model base vertex count")) { + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + if (!model_id_filter_contains(ids, id_count, mid)) continue; + + int vc = (int)evc, tc = (int)fc; + float *verts = malloc(vc * 3 * sizeof(float)); + if (!verts + || !fc_read_exact(f, verts, sizeof(float), vc * 3, path, "model vertices")) { + free(verts); + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + unsigned char *colors = malloc(vc * 4); + if (!colors + || !fc_read_exact(f, colors, sizeof(unsigned char), vc * 4, path, "model colors")) { + free(verts); + free(colors); + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + float *texcoords = NULL; + if (has_tex) { + texcoords = malloc(vc * 2 * sizeof(float)); + if (!texcoords + || !fc_read_exact(f, texcoords, sizeof(float), vc * 2, path, "model texcoords")) { + free(verts); + free(colors); + free(texcoords); + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + } + + // OSRS units -> tile units, flip Z for Raylib + for (int i = 0; i < vc; i++) { + verts[i*3] /= 128.0f; + verts[i*3+1] /= 128.0f; + verts[i*3+2] /= -128.0f; + } + float *rest_verts = malloc(vc * 3 * sizeof(float)); + if (!rest_verts) { + free(verts); + free(colors); + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + memcpy(rest_verts, verts, vc * 3 * sizeof(float)); + + Mesh mesh = {0}; + mesh.vertexCount = vc; + mesh.triangleCount = tc; + mesh.vertices = verts; + mesh.colors = colors; + mesh.texcoords = texcoords; + mesh.normals = calloc(vc * 3, sizeof(float)); + for (int i = 0; i < tc; i++) { + int i0 = i*3, i1 = i*3+1, i2 = i*3+2; + float ax = verts[i1*3]-verts[i0*3], ay = verts[i1*3+1]-verts[i0*3+1], az = verts[i1*3+2]-verts[i0*3+2]; + float bx = verts[i2*3]-verts[i0*3], by = verts[i2*3+1]-verts[i0*3+1], bz = verts[i2*3+2]-verts[i0*3+2]; + float nx = ay*bz-az*by, ny = az*bx-ax*bz, nz = ax*by-ay*bx; + float len = sqrtf(nx*nx+ny*ny+nz*nz); + if (len > 1e-4f) { nx/=len; ny/=len; nz/=len; } + for (int j = 0; j < 3; j++) { + mesh.normals[(i*3+j)*3] = nx; mesh.normals[(i*3+j)*3+1] = ny; mesh.normals[(i*3+j)*3+2] = nz; + } + } + UploadMesh(&mesh, false); + + // Animation data + int16_t *bv = malloc(bvc * 3 * sizeof(int16_t)); + if (!bv + || !fc_read_exact(f, bv, sizeof(int16_t), bvc * 3, path, "model base vertices")) { + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + uint8_t *skins = malloc(bvc); + if (!skins + || !fc_read_exact(f, skins, sizeof(uint8_t), bvc, path, "model vertex skins")) { + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + uint16_t *fi = malloc(tc * 3 * sizeof(uint16_t)); + if (!fi + || !fc_read_exact(f, fi, sizeof(uint16_t), tc * 3, path, "model face indices")) { + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + uint8_t *pri = malloc(tc); + if (!pri + || !fc_read_exact(f, pri, sizeof(uint8_t), tc, path, "model priorities")) { + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + + ModelFaceUvInfo *face_uvs = NULL; + long next_off = (m + 1 < count) ? (long)offsets[m + 1] : model_file_end; + long opt_pos = ftell(f); + if (has_tex && opt_pos >= 0 && next_off >= opt_pos + 8) { + uint32_t uv_magic = 0; + uint32_t uv_count = 0; + if (fc_read_exact(f, &uv_magic, sizeof(uv_magic), 1, path, + "model uv magic") + && fc_read_exact(f, &uv_count, sizeof(uv_count), 1, path, + "model uv count") + && uv_magic == MUV1_MAGIC && uv_count == (uint32_t)tc) { + face_uvs = calloc(tc, sizeof(*face_uvs)); + if (!face_uvs) { + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + for (int i = 0; i < tc; i++) { + uint8_t textured = 0, pad[3]; + if (!fc_read_exact(f, &textured, sizeof(textured), 1, + path, "model uv textured") + || !fc_read_exact(f, pad, sizeof(pad), 1, + path, "model uv pad") + || !fc_read_exact(f, &face_uvs[i].tex_a, + sizeof(face_uvs[i].tex_a), 1, + path, "model uv tex a") + || !fc_read_exact(f, &face_uvs[i].tex_b, + sizeof(face_uvs[i].tex_b), 1, + path, "model uv tex b") + || !fc_read_exact(f, &face_uvs[i].tex_c, + sizeof(face_uvs[i].tex_c), 1, + path, "model uv tex c") + || !fc_read_exact(f, &face_uvs[i].u_base, + sizeof(face_uvs[i].u_base), 1, + path, "model uv u base") + || !fc_read_exact(f, &face_uvs[i].v_base, + sizeof(face_uvs[i].v_base), 1, + path, "model uv v base") + || !fc_read_exact(f, &face_uvs[i].u_scale, + sizeof(face_uvs[i].u_scale), 1, + path, "model uv u scale") + || !fc_read_exact(f, &face_uvs[i].v_scale, + sizeof(face_uvs[i].v_scale), 1, + path, "model uv v scale") + || !fc_read_exact(f, + &face_uvs[i].repeat_v_margin, + sizeof(face_uvs[i].repeat_v_margin), + 1, path, "model uv repeat v")) { + free(face_uvs); + free(offsets); + fc_asset_close(f); + models_free(set); + return NULL; + } + face_uvs[i].textured = textured != 0; + } + } else { + fseek(f, opt_pos, SEEK_SET); + } + } + + Model ray_model = LoadModelFromMesh(mesh); + if (has_tex) + ray_model.materials[0].maps[MATERIAL_MAP_DIFFUSE].texture = + atlas_texture; + + set->entries[m] = (ModelEntry){ + .model_id = mid, .model = ray_model, .loaded = 1, + .rest_verts = rest_verts, + .rest_texcoords = texcoords && vc > 0 + ? malloc((size_t)vc * 2 * sizeof(float)) : NULL, + .base_verts = bv, .vertex_skins = skins, .face_indices = fi, + .face_priorities = pri, .face_uvs = face_uvs, + .base_vert_count = (int)bvc, .face_count = tc, + }; + if (texcoords && set->entries[m].rest_texcoords) + memcpy(set->entries[m].rest_texcoords, texcoords, + (size_t)vc * 2 * sizeof(float)); + if (mid < (uint32_t)set->index_limit) set->index_by_id[mid] = (int)m; + loaded_count++; + fprintf(stderr, " model %u: %d tris, %d base verts\n", mid, tc, (int)bvc); + } + free(offsets); fc_asset_close(f); + set->loaded = 1; + fprintf(stderr, "models: loaded %d from %s\n", loaded_count, path); + return set; +} + +ModelSet *models_load(const char *path, Texture2D atlas_texture) { + return models_load_filtered(path, NULL, 0, atlas_texture); +} + +void models_free(ModelSet *set) { + if (!set) return; + for (int i = 0; i < set->count; i++) { + if (set->entries[i].loaded) { + UnloadModel(set->entries[i].model); + free(set->entries[i].base_verts); + free(set->entries[i].rest_verts); + free(set->entries[i].rest_texcoords); + free(set->entries[i].vertex_skins); + free(set->entries[i].face_indices); + free(set->entries[i].face_priorities); + free(set->entries[i].face_uvs); + } + } + free(set->entries); + free(set->index_by_id); + free(set); +} + +#undef MDL2_MAGIC +#undef MDL3_MAGIC +#undef MUV1_MAGIC +#undef MODEL_ID_INDEX_MAX + +/* Npc Models */ +/* + * Fight Caves model compatibility wrapper. + * + * The viewer historically used NpcModelSet/NpcModelEntry names for every + * runtime model file. Keep those names while routing all NPC/player/projectile + * files through the generalized MDL2/MDL3 model loader. + */ + +uint32_t fc_npc_type_to_model_id(int npc_type) { + switch (npc_type) { + case 1: return FC_B237_TZ_KIH; + case 2: return FC_B237_TZ_KEK; + case 3: return FC_B237_TZ_KEK_SM; + case 4: return FC_B237_TOK_XIL; + case 5: return FC_B237_YT_MEJKOT; + case 6: return FC_B237_KET_ZEK; + case 7: return FC_B237_TZTOK_JAD; + case 8: return FC_B237_YT_HURKOT; + default: return 0; + } +} + +NpcModelEntry* fc_npc_model_find(NpcModelSet* set, uint32_t model_id) { + return model_find(set, model_id); +} + +NpcModelSet* fc_npc_models_load(const char* path, Texture2D atlas_texture) { + return models_load(path, atlas_texture); +} + +void fc_npc_models_unload(NpcModelSet* set) { + models_free(set); +} + + +/* Model Animation */ +#include "raylib.h" + +static void apply_frame(NpcModelEntry *entry, + AnimModelState *state, + const AnimFrameData *frame, + const AnimFrameBase *framebase) { + if (!entry || !entry->loaded || !state || !frame || !framebase) return; + anim_apply_frame(state, entry->base_verts, frame, framebase); + fc_model_animation_upload(entry, state); +} + +void fc_model_animation_upload(NpcModelEntry *entry, AnimModelState *state) { + if (!entry || !entry->loaded || !state) return; + models_recompute_texture_uvs_from_vertices(entry, state->verts); + float *mesh_vertices = entry->model.meshes[0].vertices; + anim_update_mesh(mesh_vertices, state, entry->face_indices, + entry->face_count); + int expanded_vertices = entry->face_count * 3; + for (int i = 0; i < expanded_vertices; i++) { + mesh_vertices[i * 3] /= 128.0f; + mesh_vertices[i * 3 + 1] /= 128.0f; + mesh_vertices[i * 3 + 2] /= -128.0f; + } + UpdateMeshBuffer(entry->model.meshes[0], 0, mesh_vertices, + expanded_vertices * 3 * sizeof(float), 0); +} + +void fc_model_animation_update(NpcModelEntry *entry, + AnimCache *cache, + AnimModelState **state, + uint16_t *current_sequence, + int *frame_index, + float *frame_timer, + int animation_id, + float dt, + float phase_ticks) { + if (!entry || !entry->loaded || !cache || animation_id < 0 || + !entry->vertex_skins || !state || !current_sequence || + !frame_index || !frame_timer) return; + AnimSequence *sequence = anim_get_sequence(cache, (uint16_t)animation_id); + if (!sequence || sequence->frame_count == 0) return; + if (!*state || (*state)->vert_count != entry->base_vert_count) { + if (*state) anim_model_state_free(*state); + *state = anim_model_state_create(entry->vertex_skins, + entry->base_vert_count); + *current_sequence = (uint16_t)animation_id; + *frame_index = (int)phase_ticks % sequence->frame_count; + if (*frame_index < 0) *frame_index = 0; + *frame_timer = (float)sequence->frames[*frame_index].delay * 0.02f; + if (*frame_timer < 0.016f) *frame_timer = 0.016f; + } + if (*current_sequence != (uint16_t)animation_id) { + *current_sequence = (uint16_t)animation_id; + *frame_index = 0; + *frame_timer = (float)sequence->frames[0].delay * 0.02f; + if (*frame_timer < 0.016f) *frame_timer = 0.016f; + } + *frame_timer -= dt; + while (*frame_timer <= 0.0f) { + *frame_index = (*frame_index + 1) % sequence->frame_count; + float delay = (float)sequence->frames[*frame_index].delay * 0.02f; + if (delay < 0.016f) delay = 0.016f; + *frame_timer += delay; + } + AnimFrameData *frame = &sequence->frames[*frame_index].frame; + AnimFrameBase *framebase = anim_get_framebase(cache, frame->framebase_id); + if (framebase) apply_frame(entry, *state, frame, framebase); +} + + +/* Objects Loader */ +/** + * @fileoverview Loads placed map objects from .objects binary into a single raylib Model. + * + * Supports two binary formats: + * v1 (OBJS): vertices + colors only (flat vertex coloring) + * v2 (OBJ2): vertices + colors + texcoords (texture atlas support) + * + * When v2 format is detected, also loads the companion .atlas file (raw RGBA) + * and assigns it as the model's diffuse texture. Vertex colors are multiplied + * by the texture sample: textured faces use white vertex color + real texture, + * non-textured faces use HSL vertex color + white atlas pixel. + */ + +#include "raylib.h" +#include "rlgl.h" +#include +#include +#include +#include +#include + +#define OBJS_MAGIC 0x4F424A53 /* "OBJS" v1 */ +#define OBJ2_MAGIC 0x4F424A32 /* "OBJ2" v2 with texcoords */ +#define OANM_MAGIC 0x4D4E414F /* "OANM" animated object placements */ +#define OANM_VERSION 1 +ObjectMesh* objects_load(const char* path) { + FILE* f = fc_asset_fopen(path, "rb"); + if (!f) { + fprintf(stderr, "objects_load: could not open %s\n", path); + return NULL; + } + + uint32_t magic, placement_count, total_verts; + int32_t min_wx, min_wy; + if (!fc_read_exact(f, &magic, sizeof(magic), 1, path, "object magic")) { + fc_asset_close(f); + return NULL; + } + + int has_textures = 0; + if (magic == OBJ2_MAGIC) { + has_textures = 1; + } else if (magic != OBJS_MAGIC) { + fprintf(stderr, "objects_load: bad magic %08x\n", magic); + fc_asset_close(f); + return NULL; + } + + if (!fc_read_exact(f, &placement_count, sizeof(placement_count), 1, path, "object placement count") || + !fc_read_exact(f, &min_wx, sizeof(min_wx), 1, path, "object min world x") || + !fc_read_exact(f, &min_wy, sizeof(min_wy), 1, path, "object min world y") || + !fc_read_exact(f, &total_verts, sizeof(total_verts), 1, path, "object vertex count")) { + fc_asset_close(f); + return NULL; + } + + fprintf(stderr, "objects_load: %u placements, %u verts, format=%s\n", + placement_count, total_verts, has_textures ? "OBJ2" : "OBJS"); + + /* read vertices */ + float* raw_verts = (float*)malloc(total_verts * 3 * sizeof(float)); + if (!raw_verts || + !fc_read_exact(f, raw_verts, sizeof(float), total_verts * 3, path, "object vertices")) { + free(raw_verts); + fc_asset_close(f); + return NULL; + } + + /* read colors */ + unsigned char* raw_colors = (unsigned char*)malloc(total_verts * 4); + if (!raw_colors || + !fc_read_exact(f, raw_colors, 1, total_verts * 4, path, "object colors")) { + free(raw_verts); + free(raw_colors); + fc_asset_close(f); + return NULL; + } + /* read texture coordinates (v2 only) */ + float* raw_texcoords = NULL; + if (has_textures) { + raw_texcoords = (float*)malloc(total_verts * 2 * sizeof(float)); + if (!raw_texcoords || + !fc_read_exact(f, raw_texcoords, sizeof(float), total_verts * 2, + path, "object texcoords")) { + free(raw_verts); + free(raw_colors); + free(raw_texcoords); + fc_asset_close(f); + return NULL; + } + } + fc_asset_close(f); + + /* build raylib mesh */ + Mesh mesh = { 0 }; + mesh.vertexCount = (int)total_verts; + mesh.triangleCount = (int)(total_verts / 3); + mesh.vertices = raw_verts; + mesh.colors = raw_colors; + mesh.texcoords = raw_texcoords; + + /* compute normals */ + mesh.normals = (float*)calloc(total_verts * 3, sizeof(float)); + if (!mesh.normals) { + free(raw_verts); + free(raw_colors); + free(raw_texcoords); + return NULL; + } + for (int i = 0; i < mesh.triangleCount; i++) { + int base = i * 9; + float ax = raw_verts[base + 0], ay = raw_verts[base + 1], az = raw_verts[base + 2]; + float bx = raw_verts[base + 3], by = raw_verts[base + 4], bz = raw_verts[base + 5]; + float cx = raw_verts[base + 6], cy = raw_verts[base + 7], cz = raw_verts[base + 8]; + + float e1x = bx - ax, e1y = by - ay, e1z = bz - az; + float e2x = cx - ax, e2y = cy - ay, e2z = cz - az; + float nx = e1y * e2z - e1z * e2y; + float ny = e1z * e2x - e1x * e2z; + float nz = e1x * e2y - e1y * e2x; + float len = sqrtf(nx * nx + ny * ny + nz * nz); + if (len > 0.0001f) { nx /= len; ny /= len; nz /= len; } + + for (int v = 0; v < 3; v++) { + mesh.normals[i * 9 + v * 3 + 0] = nx; + mesh.normals[i * 9 + v * 3 + 1] = ny; + mesh.normals[i * 9 + v * 3 + 2] = nz; + } + } + + UploadMesh(&mesh, false); + + ObjectMesh* om = (ObjectMesh*)calloc(1, sizeof(ObjectMesh)); + om->model = LoadModelFromMesh(mesh); + om->placement_count = (int)placement_count; + om->total_vertex_count = (int)total_verts; + om->min_world_x = min_wx; + om->min_world_y = min_wy; + om->has_textures = has_textures; + om->loaded = 1; + + /* load atlas texture if v2 format */ + if (has_textures) { + /* derive atlas path from objects path: replace .objects with .atlas */ + char atlas_path[1024]; + strncpy(atlas_path, path, sizeof(atlas_path) - 1); + atlas_path[sizeof(atlas_path) - 1] = '\0'; + char* dot = strrchr(atlas_path, '.'); + if (dot) { + strcpy(dot, ".atlas"); + } else { + strncat(atlas_path, ".atlas", sizeof(atlas_path) - strlen(atlas_path) - 1); + } + + if (fc_animated_atlas_load(&om->atlas, atlas_path, 1)) { + /* assign atlas as diffuse map for the model's material */ + om->model.materials[0].maps[MATERIAL_MAP_DIFFUSE].texture = + om->atlas.texture; + } + } + + return om; +} + +ObjectAnimSet* object_anims_load(const char* path) { + FILE* f = fc_asset_fopen(path, "rb"); + if (!f) { + fprintf(stderr, "object_anims_load: could not open %s\n", path); + return NULL; + } + + uint32_t magic = 0, version = 0, count = 0; + if (!fc_read_exact(f, &magic, sizeof(magic), 1, path, "oanim magic") || + !fc_read_exact(f, &version, sizeof(version), 1, path, "oanim version") || + !fc_read_exact(f, &count, sizeof(count), 1, path, "oanim count") || + magic != OANM_MAGIC || version != OANM_VERSION) { + fc_asset_close(f); + return NULL; + } + + ObjectAnimSet* set = (ObjectAnimSet*)calloc(1, sizeof(*set)); + if (!set) { + fc_asset_close(f); + return NULL; + } + set->rows = (ObjectAnimPlacement*)calloc(count, sizeof(*set->rows)); + if (count > 0 && !set->rows) { + fc_asset_close(f); + free(set); + return NULL; + } + set->count = (int)count; + + for (uint32_t i = 0; i < count; i++) { + ObjectAnimPlacement* row = &set->rows[i]; + if (!fc_read_exact(f, &row->model_id, sizeof(row->model_id), 1, path, "oanim model id") || + !fc_read_exact(f, &row->obj_id, sizeof(row->obj_id), 1, path, "oanim object id") || + !fc_read_exact(f, &row->animation_id, sizeof(row->animation_id), 1, path, "oanim animation id") || + !fc_read_exact(f, &row->world_x, sizeof(row->world_x), 1, path, "oanim world x") || + !fc_read_exact(f, &row->world_y, sizeof(row->world_y), 1, path, "oanim world y") || + !fc_read_exact(f, &row->plane, sizeof(row->plane), 1, path, "oanim plane") || + !fc_read_exact(f, &row->obj_type, sizeof(row->obj_type), 1, path, "oanim obj type") || + !fc_read_exact(f, &row->rotation, sizeof(row->rotation), 1, path, "oanim rotation") || + !fc_read_exact(f, &row->flags, sizeof(row->flags), 1, path, "oanim flags") || + !fc_read_exact(f, &row->pos_x, sizeof(row->pos_x), 1, path, "oanim pos x") || + !fc_read_exact(f, &row->pos_y, sizeof(row->pos_y), 1, path, "oanim pos y") || + !fc_read_exact(f, &row->pos_z, sizeof(row->pos_z), 1, path, "oanim pos z") || + !fc_read_exact(f, &row->phase_ticks, sizeof(row->phase_ticks), 1, path, "oanim phase")) { + fc_asset_close(f); + free(set->rows); + free(set); + return NULL; + } + } + fc_asset_close(f); + set->loaded = 1; + fprintf(stderr, "object_anims_load: loaded %d animated placements from %s\n", + set->count, path); + return set; +} + +void object_anims_offset(ObjectAnimSet* set, int wx, int wy) { + if (!set || !set->loaded) return; + for (int i = 0; i < set->count; i++) { + set->rows[i].pos_x -= (float)wx; + set->rows[i].pos_z += (float)wy; + set->rows[i].world_x -= wx; + set->rows[i].world_y -= wy; + } +} + +/* shift object vertices so world coordinates (wx, wy) become local (0, 0). + must match terrain_offset() values for alignment. */ +void objects_offset(ObjectMesh* om, int wx, int wy) { + if (!om || !om->loaded) return; + float dx = (float)wx; + float dz = (float)wy; + float* verts = om->model.meshes[0].vertices; + for (int i = 0; i < om->total_vertex_count; i++) { + verts[i * 3 + 0] -= dx; /* X */ + verts[i * 3 + 2] += dz; /* Z (negated world Y) */ + } + UpdateMeshBuffer(om->model.meshes[0], 0, verts, + om->total_vertex_count * 3 * sizeof(float), 0); + om->min_world_x -= wx; + om->min_world_y -= wy; + fprintf(stderr, "objects_offset: shifted by (%d, %d)\n", wx, wy); +} + +void objects_free(ObjectMesh* om) { + if (!om) return; + fc_animated_atlas_unload(&om->atlas); + if (om->loaded) UnloadModel(om->model); + free(om); +} + +void object_anims_free(ObjectAnimSet* set) { + if (!set) return; + free(set->rows); + free(set); +} + +#undef OBJS_MAGIC +#undef OBJ2_MAGIC +#undef OANM_MAGIC +#undef OANM_VERSION + +/* Terrain Loader */ +/** + * @fileoverview Loads terrain mesh from .terrain binary into raylib Model. + * + * Binary format: + * magic: uint32 "TERR" (0x54455252) + * vertex_count: uint32 + * region_count: uint32 + * min_world_x: int32 + * min_world_y: int32 + * vertices: float32[vertex_count * 3] + * colors: uint8[vertex_count * 4] + */ + +#include "raylib.h" +#include +#include +#include +#include +#include + +#define TERR_MAGIC 0x54455252 + +TerrainMesh* terrain_load(const char* path) { + FILE* f = fc_asset_fopen(path, "rb"); + if (!f) { + fprintf(stderr, "terrain_load: could not open %s\n", path); + return NULL; + } + + uint32_t magic, vert_count, region_count; + int32_t min_wx, min_wy; + if (!fc_read_exact(f, &magic, sizeof(magic), 1, path, "terrain magic")) { + fc_asset_close(f); + return NULL; + } + if (magic != TERR_MAGIC) { + fprintf(stderr, "terrain_load: bad magic %08x\n", magic); + fc_asset_close(f); + return NULL; + } + if (!fc_read_exact(f, &vert_count, sizeof(vert_count), 1, path, "terrain vertex count") || + !fc_read_exact(f, ®ion_count, sizeof(region_count), 1, path, "terrain region count") || + !fc_read_exact(f, &min_wx, sizeof(min_wx), 1, path, "terrain min world x") || + !fc_read_exact(f, &min_wy, sizeof(min_wy), 1, path, "terrain min world y")) { + fc_asset_close(f); + return NULL; + } + + fprintf(stderr, "terrain_load: %u verts, %u regions, origin (%d, %d)\n", + vert_count, region_count, min_wx, min_wy); + + /* read vertices */ + float* raw_verts = (float*)malloc(vert_count * 3 * sizeof(float)); + if (!raw_verts || + !fc_read_exact(f, raw_verts, sizeof(float), vert_count * 3, path, "terrain vertices")) { + free(raw_verts); + fc_asset_close(f); + return NULL; + } + + /* read colors */ + unsigned char* raw_colors = (unsigned char*)malloc(vert_count * 4); + if (!raw_colors || + !fc_read_exact(f, raw_colors, 1, vert_count * 4, path, "terrain colors")) { + free(raw_verts); + free(raw_colors); + fc_asset_close(f); + return NULL; + } + /* build raylib mesh */ + Mesh mesh = { 0 }; + mesh.vertexCount = (int)vert_count; + mesh.triangleCount = (int)(vert_count / 3); + mesh.vertices = raw_verts; + mesh.colors = raw_colors; + + /* compute normals for proper lighting */ + mesh.normals = (float*)calloc(vert_count * 3, sizeof(float)); + if (!mesh.normals) { + free(raw_verts); + free(raw_colors); + fc_asset_close(f); + return NULL; + } + for (int i = 0; i < mesh.triangleCount; i++) { + int base = i * 9; + float ax = raw_verts[base + 0], ay = raw_verts[base + 1], az = raw_verts[base + 2]; + float bx = raw_verts[base + 3], by = raw_verts[base + 4], bz = raw_verts[base + 5]; + float cx = raw_verts[base + 6], cy = raw_verts[base + 7], cz = raw_verts[base + 8]; + + float e1x = bx - ax, e1y = by - ay, e1z = bz - az; + float e2x = cx - ax, e2y = cy - ay, e2z = cz - az; + float nx = e1y * e2z - e1z * e2y; + float ny = e1z * e2x - e1x * e2z; + float nz = e1x * e2y - e1y * e2x; + float len = sqrtf(nx * nx + ny * ny + nz * nz); + if (len > 0.0001f) { nx /= len; ny /= len; nz /= len; } + + for (int v = 0; v < 3; v++) { + mesh.normals[i * 9 + v * 3 + 0] = nx; + mesh.normals[i * 9 + v * 3 + 1] = ny; + mesh.normals[i * 9 + v * 3 + 2] = nz; + } + } + + UploadMesh(&mesh, false); + + TerrainMesh* tm = (TerrainMesh*)calloc(1, sizeof(TerrainMesh)); + tm->model = LoadModelFromMesh(mesh); + tm->vertex_count = (int)vert_count; + tm->region_count = (int)region_count; + tm->min_world_x = min_wx; + tm->min_world_y = min_wy; + tm->loaded = 1; + + /* read heightmap (appended after colors in the binary) */ + int32_t hm_min_x, hm_min_y; + uint32_t hm_w, hm_h; + int next = fgetc(f); + if (next != EOF) { + ungetc(next, f); + if (!fc_read_exact(f, &hm_min_x, sizeof(hm_min_x), 1, path, "terrain heightmap min x") || + !fc_read_exact(f, &hm_min_y, sizeof(hm_min_y), 1, path, "terrain heightmap min y") || + !fc_read_exact(f, &hm_w, sizeof(hm_w), 1, path, "terrain heightmap width") || + !fc_read_exact(f, &hm_h, sizeof(hm_h), 1, path, "terrain heightmap height")) { + fc_asset_close(f); + terrain_free(tm); + return NULL; + } + if (!(hm_w > 0 && hm_h > 0 && hm_w <= 4096 && hm_h <= 4096)) { + fprintf(stderr, "%s: invalid terrain heightmap dimensions %ux%u\n", + path, hm_w, hm_h); + fc_asset_close(f); + terrain_free(tm); + return NULL; + } + tm->hm_min_x = hm_min_x; + tm->hm_min_y = hm_min_y; + tm->hm_width = (int)hm_w; + tm->hm_height = (int)hm_h; + tm->heightmap = (float*)malloc(hm_w * hm_h * sizeof(float)); + if (!tm->heightmap || + !fc_read_exact(f, tm->heightmap, sizeof(float), hm_w * hm_h, + path, "terrain heightmap values")) { + fc_asset_close(f); + terrain_free(tm); + return NULL; + } + fprintf(stderr, "terrain heightmap: %dx%d, origin (%d, %d)\n", + tm->hm_width, tm->hm_height, tm->hm_min_x, tm->hm_min_y); + } + + fc_asset_close(f); + return tm; +} + +/* shift terrain so world coordinates (wx, wy) become local (0, 0). + offsets all mesh vertices and heightmap origin. must call before rendering. */ +void terrain_offset(TerrainMesh* tm, int wx, int wy) { + if (!tm || !tm->loaded) return; + float dx = (float)wx; + float dz = (float)wy; /* Z = -world_y in our coord system */ + float* verts = tm->model.meshes[0].vertices; + for (int i = 0; i < tm->vertex_count; i++) { + verts[i * 3 + 0] -= dx; /* X */ + verts[i * 3 + 2] += dz; /* Z (negated world Y) */ + } + UpdateMeshBuffer(tm->model.meshes[0], 0, verts, + tm->vertex_count * 3 * sizeof(float), 0); + tm->min_world_x -= wx; + tm->min_world_y -= wy; + if (tm->heightmap) { + tm->hm_min_x -= wx; + tm->hm_min_y -= wy; + } + fprintf(stderr, "terrain_offset: shifted by (%d, %d), new origin (%d, %d)\n", + wx, wy, tm->min_world_x, tm->min_world_y); +} + +/* query terrain height at a world tile position (tile corner) */ +float terrain_height_at(TerrainMesh* tm, int world_x, int world_y) { + if (!tm || !tm->heightmap) return -2.0f; + int lx = world_x - tm->hm_min_x; + int ly = world_y - tm->hm_min_y; + if (lx < 0 || lx >= tm->hm_width || ly < 0 || ly >= tm->hm_height) + return -2.0f; + return tm->heightmap[lx + ly * tm->hm_width]; +} + +void terrain_free(TerrainMesh* tm) { + if (!tm) return; + if (tm->loaded) { + UnloadModel(tm->model); + } + free(tm->heightmap); + free(tm); +} + +#undef TERR_MAGIC + +/* Spotanims */ +#include +#include +#include + +#define SPOTANIM_MAGIC 0x544F5053u +#define SPOTANIM_VERSION 1u + +SpotAnimSet *spotanims_load(const char *path) { + FILE *f = fc_asset_fopen(path, "rb"); + if (!f) { + fprintf(stderr, "spotanims: can't open %s\n", path); + return NULL; + } + + uint32_t magic, version, count; + if (!fc_read_exact(f, &magic, sizeof(magic), 1, path, "spotanim magic") + || !fc_read_exact(f, &version, sizeof(version), 1, path, + "spotanim version") + || !fc_read_exact(f, &count, sizeof(count), 1, path, + "spotanim count") + || magic != SPOTANIM_MAGIC || version != SPOTANIM_VERSION) { + fc_asset_close(f); + return NULL; + } + + SpotAnimSet *set = (SpotAnimSet *)calloc(1, sizeof(*set)); + if (!set) { + fc_asset_close(f); + return NULL; + } + set->defs = (SpotAnimDef *)calloc(count, sizeof(*set->defs)); + if (count > 0 && !set->defs) { + fc_asset_close(f); + spotanims_free(set); + return NULL; + } + set->count = (int)count; + for (uint32_t i = 0; i < count; i++) { + if (!fc_read_exact(f, &set->defs[i], sizeof(set->defs[i]), 1, path, + "spotanim row")) { + fc_asset_close(f); + spotanims_free(set); + return NULL; + } + } + fc_asset_close(f); + set->loaded = 1; + fprintf(stderr, "spotanims: loaded %d from %s\n", set->count, path); + return set; +} + +const SpotAnimDef *spotanim_find(const SpotAnimSet *set, int id) { + if (!set || !set->loaded || id < 0) return NULL; + for (int i = 0; i < set->count; i++) { + if ((int)set->defs[i].id == id) return &set->defs[i]; + } + return NULL; +} + +void spotanims_free(SpotAnimSet *set) { + if (!set) return; + free(set->defs); + free(set); +} + +#undef SPOTANIM_MAGIC +#undef SPOTANIM_VERSION + +#endif diff --git a/ocean/fight_caves/binding.c b/ocean/fight_caves/binding.c new file mode 100644 index 0000000000..cc1b10d539 --- /dev/null +++ b/ocean/fight_caves/binding.c @@ -0,0 +1,260 @@ +/* + * binding.c — PufferLib 4.0 binding for Fight Caves environment. + * + * Defines the macros PufferLib needs, includes vecenv.h, and implements + * the my_init/my_log hooks for config parsing and stat logging. + */ + +#include "fight_caves.h" + +/* Kept in this linked object so the selected Puffer extension exports the + * same implementation exercised by validation. It is never called per-step. */ +/* Cold-path machine-readable metadata exported by the compiled FC backend. */ + +#include + +#include "simulation.h" +#if defined(_WIN32) +#define FC_CONTRACT_EXPORT __declspec(dllexport) +#else +#define FC_CONTRACT_EXPORT __attribute__((visibility("default"))) +#endif + +#define FC_STRINGIFY_INNER(value) #value +#define FC_STRINGIFY(value) FC_STRINGIFY_INNER(value) + +FC_CONTRACT_EXPORT const char* fc_training_contract_json(void) { + static char json[2048]; + static int initialized = 0; + if (!initialized) { + snprintf( + json, + sizeof(json), + "{" + "\"contract_dump_schema_version\":%d," + "\"policy_obs_size\":%d," + "\"puffer_obs_size\":%d," + "\"puffer_action_dims\":[%d,%d,%d]," + "\"puffer_mask_size\":%d," + "\"core_obs_size\":%d," + "\"core_action_dims\":[%d,%d,%d,%d,%d,%d,%d]," + "\"core_action_mask\":%d," + "\"reward_feature_count\":%d," + "\"observation_version\":\"%s\"," + "\"action_version\":\"%s\"," + "\"reward_version\":\"%s\"," + "\"prayer_timing_version\":\"%s\"," + "\"state_hash_version\":%u," + "\"active_loadout\":\"%s\"" + "}", + FC_CONTRACT_DUMP_SCHEMA_VERSION, + FC_POLICY_OBS_SIZE, + FC_PUFFER_OBS_SIZE, + FC_PUFFER_ACTION_DIMS[0], + FC_PUFFER_ACTION_DIMS[1], + FC_PUFFER_ACTION_DIMS[2], + FC_PUFFER_MASK_SIZE, + FC_OBS_SIZE, + FC_ACTION_DIMS[0], + FC_ACTION_DIMS[1], + FC_ACTION_DIMS[2], + FC_ACTION_DIMS[3], + FC_ACTION_DIMS[4], + FC_ACTION_DIMS[5], + FC_ACTION_DIMS[6], + FC_ACTION_MASK_SIZE, + FC_REWARD_FEATURES, + FC_OBSERVATION_VERSION, + FC_ACTION_VERSION, + FC_REWARD_VERSION, + FC_PRAYER_TIMING_VERSION, + FC_STATE_HASH_VERSION, + FC_STRINGIFY(FC_ACTIVE_LOADOUT)); + initialized = 1; + } + return json; +} + + +#define OBS_SIZE FC_PUFFER_OBS_SIZE +#define OBS_TENSOR_T FloatTensor +#define NUM_ATNS FC_PUFFER_NUM_ATNS +#define ACT_SIZES FC_PUFFER_ACT_SIZES +#define OBS_TYPE FLOAT +#define ACT_TYPE DOUBLE +#define MY_ACTION_MASK FC_PUFFER_MASK_SIZE + +#define Env FightCaves +#include "vecenv.h" + +static void fc_override_float_config( + Dict* kwargs, const char* key, float* value) { + DictItem* item = dict_get_unsafe(kwargs, key); + if (item != NULL) *value = (float)item->value; +} + +static void fc_override_int_config(Dict* kwargs, const char* key, int* value) { + DictItem* item = dict_get_unsafe(kwargs, key); + if (item != NULL) *value = (int)item->value; +} + +void my_init(Env* env, Dict* kwargs) { + env->num_agents = 1; /* Fight Caves is single-agent */ + env->reward_params = fc_reward_default_params(); + + /* Reward shaping weights (from config/fight_caves.ini [env] section) */ + fc_override_float_config( + kwargs, "w_damage_dealt", &env->reward_params.w_damage_dealt); + fc_override_float_config( + kwargs, "w_progress", &env->reward_params.w_progress); + fc_override_float_config(kwargs, "negative_progress_multiplier", + &env->reward_params.negative_progress_multiplier); + fc_override_float_config( + kwargs, "w_damage_taken", &env->reward_params.w_damage_taken); + fc_override_float_config( + kwargs, "w_npc_kill", &env->reward_params.w_npc_kill); + fc_override_float_config( + kwargs, "w_wave_clear", &env->reward_params.w_wave_clear); + fc_override_float_config( + kwargs, "w_jad_kill", &env->reward_params.w_jad_kill); + fc_override_float_config( + kwargs, "w_cave_complete", &env->reward_params.w_cave_complete); + fc_override_float_config( + kwargs, "w_player_death", &env->reward_params.w_player_death); + fc_override_int_config(kwargs, "scale_player_death_with_progress", + &env->reward_params.scale_player_death_with_progress); + fc_override_float_config(kwargs, "player_death_min_scale", + &env->reward_params.player_death_min_scale); + fc_override_float_config(kwargs, "w_correct_jad_prayer", + &env->reward_params.w_correct_jad_prayer); + fc_override_float_config(kwargs, "w_correct_danger_prayer", + &env->reward_params.w_correct_danger_prayer); + fc_override_float_config( + kwargs, "w_prayer_lost", &env->reward_params.w_prayer_lost); + fc_override_float_config( + kwargs, "w_invalid_action", &env->reward_params.w_invalid_action); + fc_override_float_config( + kwargs, "w_tick_penalty", &env->reward_params.w_tick_penalty); + + /* Configurable shaping terms */ + fc_override_float_config(kwargs, "shape_unnecessary_prayer_penalty", + &env->reward_params.shape_unnecessary_prayer_penalty); + fc_override_float_config(kwargs, "shape_wave_stall_base_penalty", + &env->reward_params.shape_wave_stall_base_penalty); + fc_override_float_config(kwargs, "shape_wave_stall_cap", + &env->reward_params.shape_wave_stall_cap); + fc_override_float_config(kwargs, "shape_jad_heal_penalty", + &env->reward_params.shape_jad_heal_penalty); + fc_override_float_config(kwargs, "shape_npc_heal_penalty", + &env->reward_params.shape_npc_heal_penalty); + fc_override_float_config(kwargs, "shape_no_progress_penalty_1", + &env->reward_params.shape_no_progress_penalty_1); + fc_override_float_config(kwargs, "shape_no_progress_penalty_2", + &env->reward_params.shape_no_progress_penalty_2); + fc_override_float_config(kwargs, "shape_no_progress_penalty_3", + &env->reward_params.shape_no_progress_penalty_3); + fc_override_float_config(kwargs, "shape_no_attack_base_penalty", + &env->reward_params.shape_no_attack_base_penalty); + fc_override_float_config(kwargs, "shape_no_attack_wave_scale", + &env->reward_params.shape_no_attack_wave_scale); + fc_override_int_config(kwargs, "shape_wave_stall_start", + &env->reward_params.shape_wave_stall_start); + fc_override_int_config(kwargs, "shape_wave_stall_ramp_interval", + &env->reward_params.shape_wave_stall_ramp_interval); + fc_override_int_config(kwargs, "shape_no_progress_start_1", + &env->reward_params.shape_no_progress_start_1); + fc_override_int_config(kwargs, "shape_no_progress_start_2", + &env->reward_params.shape_no_progress_start_2); + fc_override_int_config(kwargs, "shape_no_progress_start_3", + &env->reward_params.shape_no_progress_start_3); + fc_override_int_config(kwargs, "shape_no_attack_start", + &env->reward_params.shape_no_attack_start); + + DictItem* item = dict_get_unsafe(kwargs, "initial_sharks"); + env->initial_sharks = item ? (int)item->value : 0; + item = dict_get_unsafe(kwargs, "initial_prayer_doses"); + env->initial_prayer_doses = item ? (int)item->value : 0; + + /* Obs ablation flags (default 0 — i.e. no ablation, full obs). + * See fc_apply_obs_ablation in simulation.h for what each zeroes. */ + item = dict_get_unsafe(kwargs, "obs_ablate_npc_distance"); + env->obs_ablate_npc_distance = item ? (int)item->value : 0; + item = dict_get_unsafe(kwargs, "obs_ablate_incoming_aggregates"); + env->obs_ablate_incoming_aggregates = item ? (int)item->value : 0; + item = dict_get_unsafe(kwargs, "obs_ablate_npc_valid"); + env->obs_ablate_npc_valid = item ? (int)item->value : 0; + + /* Initialize game state */ + env->seed_counter = 0; + fc_init(&env->state); +} + +void my_log(Log* log, Dict* out) { + dict_set(out, "episode_length", log->episode_length); + dict_set(out, "wave_reached", log->wave_reached); + dict_set(out, "npcs_slayed", log->npcs_slayed); + dict_set(out, "prayer_uptime_melee", log->prayer_uptime_melee); + dict_set(out, "prayer_uptime_range", log->prayer_uptime_range); + dict_set(out, "prayer_uptime_magic", log->prayer_uptime_magic); + dict_set(out, "correct_prayer", log->correct_prayer); + dict_set(out, "wrong_prayer_hits", log->wrong_prayer_hits); + dict_set(out, "no_prayer_hits", log->no_prayer_hits); + dict_set(out, "prayer_switches", log->prayer_switches); + dict_set(out, "damage_blocked", log->damage_blocked); + dict_set(out, "dmg_taken_avg", log->dmg_taken_avg); + dict_set(out, "attack_when_ready_rate", log->attack_when_ready_rate); + dict_set(out, "tokxil_melee_ticks", log->tokxil_melee_ticks); + dict_set(out, "ketzek_melee_ticks", log->ketzek_melee_ticks); + dict_set(out, "max_wave_ticks", log->max_wave_ticks); + dict_set(out, "max_wave_ticks_wave", log->max_wave_ticks_wave); + dict_set(out, "reached_wave_63", log->reached_wave_63); + dict_set(out, "jad_kill_rate", log->jad_kill_rate); + dict_set(out, "player_death_rate", log->player_death_rate); + dict_set(out, "target_held_ticks", log->target_held_ticks); + dict_set(out, "no_target_ticks", log->no_target_ticks); + dict_set(out, "target_in_range_los_ticks", log->target_in_range_los_ticks); + dict_set(out, "target_out_of_range_or_los_ticks", log->target_out_of_range_or_los_ticks); + dict_set(out, "attack_cooldown_wait_ticks", log->attack_cooldown_wait_ticks); + dict_set(out, "ready_but_no_attack_ticks", log->ready_but_no_attack_ticks); + dict_set(out, "action_move_idle_ticks", log->action_move_idle_ticks); + dict_set(out, "action_move_walk_ticks", log->action_move_walk_ticks); + dict_set(out, "action_move_run_ticks", log->action_move_run_ticks); + dict_set(out, "action_attack_none_ticks", log->action_attack_none_ticks); + dict_set(out, "action_attack_target_ticks", log->action_attack_target_ticks); + dict_set(out, "action_prayer_noop_ticks", log->action_prayer_noop_ticks); + dict_set(out, "action_prayer_cmd_ticks", log->action_prayer_cmd_ticks); + dict_set(out, "no_progress_ticks", log->no_progress_ticks); + dict_set(out, "required_work_remaining", log->required_work_remaining); + dict_set(out, "required_work_start", log->required_work_start); + dict_set(out, "cave_progress", log->cave_progress); + dict_set(out, "current_wave_progress", log->current_wave_progress); + dict_set(out, "progress_delta", log->progress_delta); + dict_set(out, "progress_reward", log->progress_reward); + dict_set(out, "ticks_since_positive_progress", log->ticks_since_positive_progress); + dict_set(out, "positive_progress_ticks", log->positive_progress_ticks); + dict_set(out, "zero_progress_ticks", log->zero_progress_ticks); + dict_set(out, "negative_progress_ticks", log->negative_progress_ticks); + dict_set(out, "gross_damage_dealt", log->gross_damage_dealt); + dict_set(out, "net_required_work_removed", log->net_required_work_removed); + dict_set(out, "gross_damage_to_net_progress_ratio", log->gross_damage_to_net_progress_ratio); + dict_set(out, "npc_healing_total", log->npc_healing_total); + dict_set(out, "mejkot_healing_total", log->mejkot_healing_total); + dict_set(out, "jad_healing_total", log->jad_healing_total); + dict_set(out, "preclip_reward", log->preclip_reward); + dict_set(out, "postclip_reward", log->postclip_reward); + dict_set(out, "positive_clip_count", log->positive_clip_count); + dict_set(out, "negative_clip_count", log->negative_clip_count); + + static char npc_dmg_keys[NPC_TYPE_COUNT][48]; + static int npc_keys_built = 0; + if (!npc_keys_built) { + for (int i = 1; i < NPC_TYPE_COUNT; i++) { + const char* npc_name = fc_episode_npc_metric_name(i); + snprintf(npc_dmg_keys[i], 48, "dmg_to_%s", npc_name); + } + npc_keys_built = 1; + } + for (int i = 1; i < NPC_TYPE_COUNT; i++) { + dict_set(out, npc_dmg_keys[i], log->dmg_to_npc_type[i]); + } +} diff --git a/ocean/fight_caves/fight_caves.c b/ocean/fight_caves/fight_caves.c new file mode 100644 index 0000000000..628111a070 --- /dev/null +++ b/ocean/fight_caves/fight_caves.c @@ -0,0 +1,82 @@ +/* + * fight_caves.c — Standalone entry point for testing without PufferLib. + * + * Compiled with: ./build.sh --local (debug) or ./build.sh --fast (optimized) + * Runs N episodes with random actions and prints stats. + */ + +#include "fight_caves.h" +#include +#include + +int main(void) { + FightCaves env = {0}; + env.num_agents = 1; + env.observations = (float*)calloc(FC_PUFFER_OBS_SIZE, sizeof(float)); + env.actions = (float*)calloc(FC_PUFFER_NUM_ATNS, sizeof(float)); + env.rewards = (float*)calloc(1, sizeof(float)); + env.terminals = (float*)calloc(1, sizeof(float)); + + { + env.reward_params = fc_reward_default_params(); + env.initial_sharks = 0; + env.initial_prayer_doses = 0; + + /* Obs ablation flags default to 0 (no ablation) for the standalone harness. */ + env.obs_ablate_npc_distance = 0; + env.obs_ablate_incoming_aggregates = 0; + env.obs_ablate_npc_valid = 0; + } + + fc_init(&env.state); + + srand((unsigned)time(NULL)); + int episodes = 100; + int total_ticks = 0; + float total_reward = 0; + int max_wave = 0; + + printf("Running %d episodes with random actions...\n", episodes); + clock_t start = clock(); + + for (int ep = 0; ep < episodes; ep++) { + c_reset(&env); + env.terminals[0] = 0.0f; + int ep_ticks = 0; + while (!env.terminals[0] && ep_ticks < 30000) { + if (env.state.current_wave > max_wave) { + max_wave = env.state.current_wave; + } + for (int h = 0; h < FC_PUFFER_NUM_ATNS; h++) + env.actions[h] = (float)(rand() % 17); + env.actions[0] = (rand() % 3 == 0) ? (float)(rand() % 17) : 0.0f; + env.actions[1] = (rand() % 5 == 0) ? (float)(rand() % 9) : 0.0f; + env.actions[2] = (rand() % 10 == 0) + ? (float)(rand() % FC_PRAYER_DIM) : 0.0f; + c_step(&env); + total_reward += env.rewards[0]; + ep_ticks++; + } + total_ticks += ep_ticks; + } + + clock_t end = clock(); + double elapsed = (double)(end - start) / CLOCKS_PER_SEC; + + printf("Results:\n"); + printf(" Episodes: %d\n", episodes); + printf(" Total ticks: %d\n", total_ticks); + printf(" SPS: %.0f steps/sec\n", total_ticks / elapsed); + printf(" Avg reward: %.2f\n", total_reward / episodes); + printf(" Max wave: %d\n", max_wave); + printf(" Time: %.2fs\n", elapsed); + printf(" Log: ep_len=%.1f wave=%.1f n=%.0f\n", + env.log.episode_length, env.log.wave_reached, env.log.n); + + c_close(&env); + free(env.observations); + free(env.actions); + free(env.rewards); + free(env.terminals); + return 0; +} diff --git a/ocean/fight_caves/fight_caves.h b/ocean/fight_caves/fight_caves.h new file mode 100644 index 0000000000..bb26b50552 --- /dev/null +++ b/ocean/fight_caves/fight_caves.h @@ -0,0 +1,505 @@ +/* + * fight_caves.h — PufferLib 4.0 environment wrapper for Fight Caves. + * + * Wraps simulation.h into PufferLib's c_reset/c_step/c_render interface. + * All game logic is compiled from that shared implementation header. + * This file only handles the PufferLib adapter layer: + * - FightCaves struct with PufferLib-required fields + * - c_reset: init game state, compute initial obs + * - c_step: read actions, step game, compute reward+obs, handle terminal + * - c_render: required no-op; evaluation uses the external viewer + * - c_close: cleanup + * + * Single-agent environment (num_agents=1 always for Fight Caves). + */ + +#include +#include +#include + +/* Shared simulation and contract implementation. */ +#include "simulation.h" +/* ======================================================================== */ +/* PufferLib Log struct (required fields) */ +/* ======================================================================== */ + +typedef struct { + float episode_length; + float wave_reached; + float npcs_slayed; + float prayer_uptime_melee; + float prayer_uptime_range; + float prayer_uptime_magic; + float correct_prayer; + float wrong_prayer_hits; + float no_prayer_hits; + float prayer_switches; + float damage_blocked; + float dmg_taken_avg; + float attack_when_ready_rate; + float invalid_move; + float invalid_attack; + float invalid_prayer; + float tokxil_melee_ticks; + float ketzek_melee_ticks; + float max_wave_ticks; + float max_wave_ticks_wave; + float reached_wave_63; + float jad_kill_rate; + float player_death_rate; + float dmg_to_npc_type[NPC_TYPE_COUNT]; + float resolved_hits_to_npc_type[NPC_TYPE_COUNT]; + float damaging_hits_to_npc_type[NPC_TYPE_COUNT]; + float attack_cycles_to_npc_type[NPC_TYPE_COUNT]; + float target_ticks_by_npc_type[NPC_TYPE_COUNT]; + float target_held_ticks; + float no_target_ticks; + float target_in_range_los_ticks; + float target_out_of_range_or_los_ticks; + float attack_cooldown_wait_ticks; + float ready_but_no_attack_ticks; + float action_move_idle_ticks; + float action_move_walk_ticks; + float action_move_run_ticks; + float action_attack_none_ticks; + float action_attack_target_ticks; + float action_prayer_noop_ticks; + float action_prayer_cmd_ticks; + float no_progress_ticks; + float no_progress_idle_move_ticks; + float no_progress_move_cmd_ticks; + float no_progress_attack_none_ticks; + float no_progress_attack_target_ticks; + float no_progress_has_target_ticks; + float no_progress_no_target_ticks; + float no_progress_prayer_cmd_ticks; + float no_progress_invalid_action_ticks; + float required_work_remaining; + float required_work_start; + float cave_progress; + float current_wave_progress; + float progress_delta; + float progress_reward; + float ticks_since_positive_progress; + float positive_progress_ticks; + float zero_progress_ticks; + float negative_progress_ticks; + float gross_damage_dealt; + float net_required_work_removed; + float gross_damage_to_net_progress_ratio; + float npc_healing_total; + float mejkot_healing_total; + float jad_healing_total; + float preclip_reward; + float postclip_reward; + float positive_clip_count; + float negative_clip_count; + float rwd_sum[FC_CH_COUNT]; + float rwd_fires[FC_CH_COUNT]; + float n; /* must be last */ +} Log; + +static void fc_puffer_accumulate_episode_summary( + Log* log, const FcEpisodeSummary* summary) { + log->episode_length += (float)summary->episode_length; + log->wave_reached += (float)summary->wave_reached; + log->npcs_slayed += (float)summary->npcs_slayed; + log->prayer_uptime_melee += summary->prayer_uptime_melee; + log->prayer_uptime_range += summary->prayer_uptime_range; + log->prayer_uptime_magic += summary->prayer_uptime_magic; + log->correct_prayer += (float)summary->correct_prayer; + log->wrong_prayer_hits += (float)summary->wrong_prayer_hits; + log->no_prayer_hits += (float)summary->no_prayer_hits; + log->prayer_switches += (float)summary->prayer_switches; + log->damage_blocked += (float)summary->damage_blocked; + log->dmg_taken_avg += (float)summary->damage_taken; + log->attack_when_ready_rate += summary->attack_when_ready_rate; + log->invalid_move += (float)summary->invalid_move; + log->invalid_attack += (float)summary->invalid_attack; + log->invalid_prayer += (float)summary->invalid_prayer; + log->tokxil_melee_ticks += (float)summary->tokxil_melee_ticks; + log->ketzek_melee_ticks += (float)summary->ketzek_melee_ticks; + log->max_wave_ticks += (float)summary->max_wave_ticks; + log->max_wave_ticks_wave += (float)summary->max_wave_ticks_wave; + log->reached_wave_63 += (float)summary->reached_wave_63; + log->jad_kill_rate += (float)summary->jad_killed; + log->player_death_rate += (float)summary->player_died; + for (int i = 0; i < NPC_TYPE_COUNT; i++) { + log->dmg_to_npc_type[i] += + (float)summary->damage_to_npc_type[i]; + log->resolved_hits_to_npc_type[i] += + (float)summary->resolved_hits_to_npc_type[i]; + log->damaging_hits_to_npc_type[i] += + (float)summary->damaging_hits_to_npc_type[i]; + log->attack_cycles_to_npc_type[i] += + (float)summary->attack_cycles_to_npc_type[i]; + log->target_ticks_by_npc_type[i] += + (float)summary->target_ticks_by_npc_type[i]; + } + log->target_held_ticks += (float)summary->target_held_ticks; + log->no_target_ticks += (float)summary->no_target_ticks; + log->target_in_range_los_ticks += + (float)summary->target_in_range_los_ticks; + log->target_out_of_range_or_los_ticks += + (float)summary->target_out_of_range_or_los_ticks; + log->attack_cooldown_wait_ticks += + (float)summary->attack_cooldown_wait_ticks; + log->ready_but_no_attack_ticks += + (float)summary->ready_but_no_attack_ticks; + log->action_move_idle_ticks += (float)summary->action_move_idle_ticks; + log->action_move_walk_ticks += (float)summary->action_move_walk_ticks; + log->action_move_run_ticks += (float)summary->action_move_run_ticks; + log->action_attack_none_ticks += + (float)summary->action_attack_none_ticks; + log->action_attack_target_ticks += + (float)summary->action_attack_target_ticks; + log->action_prayer_noop_ticks += + (float)summary->action_prayer_noop_ticks; + log->action_prayer_cmd_ticks += + (float)summary->action_prayer_cmd_ticks; +} + +/* ======================================================================== */ +/* PufferLib Environment struct */ +/* ======================================================================== */ + +typedef struct FightCaves { + Log log; /* required by PufferLib */ + float* observations; /* required: FC_PUFFER_OBS_SIZE per agent */ + float* actions; /* required: NUM_ATNS per agent (vecenv uses float*) */ + float* rewards; /* required: 1 per agent */ + float* terminals; /* required: 1 per agent (vecenv uses float*) */ + unsigned char* action_mask; /* required when MY_ACTION_MASK is enabled */ + int num_agents; /* always 1 for Fight Caves */ + int rng; /* per-env RNG seed (set by vecenv.h) */ + + /* Game state */ + FcState state; + + /* Reward weights and shaping configuration, initialized once per env. */ + FcRewardParams reward_params; + int initial_sharks; + int initial_prayer_doses; + FcRewardRuntime reward_runtime; + + /* Obs ablation flags (experimental — see fc_apply_obs_ablation in fc_state.c). + * When non-zero, the corresponding obs slots are zeroed AFTER fc_write_obs. + * Used by the OBS Sweep / Ablation experiment to test which features the + * policy actually relies on vs. which the GRU could re-derive from the rest. */ + int obs_ablate_npc_distance; + int obs_ablate_incoming_aggregates; + int obs_ablate_npc_valid; + + int ep_length; + + /* Per-episode reward-channel analytics. Reset at c_reset, transferred to + * the per-env PufferLib Log on terminal. See FcRwdChannel enum in + * fc_reward.h for channel indices and names. */ + float ep_rwd_sum[FC_CH_COUNT]; + int ep_rwd_fires[FC_CH_COUNT]; + + /* Puffer-action no-progress diagnostics. These are adapter-level metrics + * for stall-like ticks: no movement, no attack cycle, no damage, no kill, + * no wave clear, and not just normal in-range weapon cooldown waiting. */ + float ep_no_progress_ticks; + float ep_no_progress_idle_move_ticks; + float ep_no_progress_move_cmd_ticks; + float ep_no_progress_attack_none_ticks; + float ep_no_progress_attack_target_ticks; + float ep_no_progress_has_target_ticks; + float ep_no_progress_no_target_ticks; + float ep_no_progress_prayer_cmd_ticks; + float ep_no_progress_invalid_action_ticks; + float ep_progress_delta; + float ep_progress_reward; + float ep_gross_damage_dealt; + float ep_net_required_work_removed; + float ep_npc_healing_total; + float ep_mejkot_healing_total; + float ep_jad_healing_total; + float ep_preclip_reward; + float ep_postclip_reward; + float ep_positive_clip_count; + float ep_negative_clip_count; + + /* RNG seed counter (increments each episode for variety) */ + uint32_t seed_counter; +} FightCaves; + +/* ======================================================================== */ +/* Observation writer — policy obs + action mask into flat float buffer */ +/* ======================================================================== */ + +static void fc_puffer_write_obs(FightCaves* env) { + float* obs = env->observations; + + /* Policy observations */ + fc_write_obs(&env->state, obs); + + /* Optional obs ablation (zero specific feature slots in-place) */ + fc_apply_obs_ablation(obs, + env->obs_ablate_npc_distance, + env->obs_ablate_incoming_aggregates, + env->obs_ablate_npc_valid); + + /* Keep the float mask in observations for checkpoint compatibility, and + * publish the same legality flags through PufferLib's native mask channel. */ + float full_mask[FC_ACTION_MASK_SIZE]; + fc_write_mask(&env->state, full_mask); + memcpy(obs + FC_POLICY_OBS_SIZE, full_mask, sizeof(float) * FC_PUFFER_MASK_SIZE); + if (env->action_mask != NULL) { + for (int i = 0; i < FC_PUFFER_MASK_SIZE; i++) { + env->action_mask[i] = (unsigned char)(full_mask[i] != 0.0f); + } + } +} + +/* ======================================================================== */ +/* Reward computation from reward features */ +/* ======================================================================== */ + +static float fc_puffer_compute_reward(FightCaves* env) { + FcRewardBreakdown breakdown = + fc_reward_compute_breakdown( + &env->state, &env->reward_params, &env->reward_runtime); + fc_reward_sync_progress_state(&env->state, &env->reward_runtime); + + if (breakdown.threat_ctx.tokxil_melee) env->state.ep_tokxil_melee_ticks++; + if (breakdown.threat_ctx.ketzek_melee) env->state.ep_ketzek_melee_ticks++; + + /* Per-channel analytics: accumulate value and fire count per reward channel. + * Drains into the per-env PufferLib Log on terminal; see c_step. */ + float ch[FC_CH_COUNT]; + fc_reward_breakdown_channels(&breakdown, ch); + for (int i = 0; i < FC_CH_COUNT; i++) { + env->ep_rwd_sum[i] += ch[i]; + if (ch[i] != 0.0f) env->ep_rwd_fires[i]++; + } + + env->ep_progress_delta += env->reward_runtime.last_progress_delta; + env->ep_progress_reward += breakdown.progress; + env->ep_gross_damage_dealt += (float)env->state.damage_dealt_this_tick; + env->ep_net_required_work_removed += env->reward_runtime.last_net_required_work_removed; + env->ep_npc_healing_total += (float)env->state.npc_heal_amount_this_tick; + env->ep_mejkot_healing_total += (float)env->state.mejkot_heal_amount_this_tick; + env->ep_jad_healing_total += (float)env->state.jad_heal_amount_this_tick; + env->ep_preclip_reward += breakdown.total; + { + float clipped = breakdown.total; + if (clipped > 1.0f) { + clipped = 1.0f; + env->ep_positive_clip_count += 1.0f; + } else if (clipped < -1.0f) { + clipped = -1.0f; + env->ep_negative_clip_count += 1.0f; + } + env->ep_postclip_reward += clipped; + } + + return breakdown.total; +} + +static void fc_puffer_reset_episode_action_diagnostics(FightCaves* env) { + env->ep_no_progress_ticks = 0.0f; + env->ep_no_progress_idle_move_ticks = 0.0f; + env->ep_no_progress_move_cmd_ticks = 0.0f; + env->ep_no_progress_attack_none_ticks = 0.0f; + env->ep_no_progress_attack_target_ticks = 0.0f; + env->ep_no_progress_has_target_ticks = 0.0f; + env->ep_no_progress_no_target_ticks = 0.0f; + env->ep_no_progress_prayer_cmd_ticks = 0.0f; + env->ep_no_progress_invalid_action_ticks = 0.0f; + env->ep_progress_delta = 0.0f; + env->ep_progress_reward = 0.0f; + env->ep_gross_damage_dealt = 0.0f; + env->ep_net_required_work_removed = 0.0f; + env->ep_npc_healing_total = 0.0f; + env->ep_mejkot_healing_total = 0.0f; + env->ep_jad_healing_total = 0.0f; + env->ep_preclip_reward = 0.0f; + env->ep_postclip_reward = 0.0f; + env->ep_positive_clip_count = 0.0f; + env->ep_negative_clip_count = 0.0f; +} + +static void fc_puffer_record_no_progress_diagnostics( + FightCaves* env, + const int actions[FC_NUM_ACTION_HEADS]) { + const FcState* state = &env->state; + const FcPlayer* player = &state->player; + int target_active = 0; + + if (state->terminal != TERMINAL_NONE || state->npcs_remaining <= 0) return; + if (state->movement_this_tick || state->attack_attempt_this_tick) return; + if (state->damage_dealt_this_tick > 0 || state->npcs_killed_this_tick > 0) return; + if (state->wave_just_cleared) return; + + if (player->attack_target_idx >= 0) { + const FcNpc* target = &state->npcs[player->attack_target_idx]; + target_active = target->active && !target->is_dead; + if (target_active && player->attack_timer > 0) { + int dist = fc_distance_to_npc(player->x, player->y, target); + int has_los = fc_has_los_between_areas( + player->x, player->y, 1, + target->x, target->y, target->size, state->los_flags); + if (dist <= player->weapon_range && has_los) return; + } + } + + env->ep_no_progress_ticks += 1.0f; + if (actions[0] == FC_MOVE_IDLE) { + env->ep_no_progress_idle_move_ticks += 1.0f; + } else { + env->ep_no_progress_move_cmd_ticks += 1.0f; + } + if (actions[1] == FC_ATTACK_NONE) { + env->ep_no_progress_attack_none_ticks += 1.0f; + } else { + env->ep_no_progress_attack_target_ticks += 1.0f; + } + if (target_active) { + env->ep_no_progress_has_target_ticks += 1.0f; + } else { + env->ep_no_progress_no_target_ticks += 1.0f; + } + if (actions[2] != 0) { + env->ep_no_progress_prayer_cmd_ticks += 1.0f; + } + if (state->invalid_action_this_tick) { + env->ep_no_progress_invalid_action_ticks += 1.0f; + } +} + +/* ======================================================================== */ +/* PufferLib interface: c_reset, c_step, c_render, c_close */ +/* ======================================================================== */ + +static uint32_t fc_puffer_mix_reset_seed(uint32_t env_rng, uint32_t episode) { + uint32_t x = env_rng + 0x9E3779B9u * (episode + 1u); + x ^= x >> 16; + x *= 0x7FEB352Du; + x ^= x >> 15; + x *= 0x846CA68Bu; + x ^= x >> 16; + return (x != 0u) ? x : 0x12345678u; +} + +void c_reset(FightCaves* env) { + env->seed_counter++; + fc_reset(&env->state, + fc_puffer_mix_reset_seed((uint32_t)env->rng, env->seed_counter)); + if (env->initial_sharks < 0) env->initial_sharks = 0; + if (env->initial_sharks > FC_MAX_SHARKS) env->initial_sharks = FC_MAX_SHARKS; + if (env->initial_prayer_doses < 0) env->initial_prayer_doses = 0; + if (env->initial_prayer_doses > FC_MAX_PRAYER_DOSES) + env->initial_prayer_doses = FC_MAX_PRAYER_DOSES; + env->state.player.sharks_remaining = env->initial_sharks; + env->state.player.prayer_doses_remaining = env->initial_prayer_doses; + + env->ep_length = 0; + fc_reward_runtime_begin_episode(&env->reward_runtime, &env->state); + for (int i = 0; i < FC_CH_COUNT; i++) { + env->ep_rwd_sum[i] = 0.0f; + env->ep_rwd_fires[i] = 0; + } + fc_puffer_reset_episode_action_diagnostics(env); + + /* Compute initial observations */ + fc_puffer_write_obs(env); +} + +void c_step(FightCaves* env) { + env->rewards[0] = 0.0f; + env->terminals[0] = 0.0f; + + /* Convert float actions from network to int action heads. + * PufferLib sends actions as floats in a flat array. + * Puffer-facing no-supplies policy uses only move/attack/prayer. + * Core heads 3-6 are left as zero: no eat, no drink, no walk-to-tile. */ + int actions[FC_NUM_ACTION_HEADS]; + memset(actions, 0, sizeof(actions)); + for (int h = 0; h < FC_PUFFER_NUM_ATNS; h++) { + actions[h] = (int)env->actions[h]; + } + /* Heads 5+6 (walk-to-tile) always 0 — not used in v1 */ + + /* Step the game simulation */ + fc_step(&env->state, actions); + fc_puffer_record_no_progress_diagnostics(env, actions); + + /* Compute reward */ + float reward = fc_puffer_compute_reward(env); + env->rewards[0] = reward; + env->ep_length++; + + /* Write the current tick's observation. On terminal steps, c_reset() + * below replaces it with the next episode's initial observation. */ + fc_puffer_write_obs(env); + + /* Check terminal */ + if (fc_is_terminal(&env->state)) { + FcEpisodeSummary summary; + fc_episode_summary_build(&env->state, env->ep_length, &summary); + env->terminals[0] = 1.0f; + fc_puffer_accumulate_episode_summary(&env->log, &summary); + env->log.no_progress_ticks += env->ep_no_progress_ticks; + env->log.no_progress_idle_move_ticks += env->ep_no_progress_idle_move_ticks; + env->log.no_progress_move_cmd_ticks += env->ep_no_progress_move_cmd_ticks; + env->log.no_progress_attack_none_ticks += env->ep_no_progress_attack_none_ticks; + env->log.no_progress_attack_target_ticks += env->ep_no_progress_attack_target_ticks; + env->log.no_progress_has_target_ticks += env->ep_no_progress_has_target_ticks; + env->log.no_progress_no_target_ticks += env->ep_no_progress_no_target_ticks; + env->log.no_progress_prayer_cmd_ticks += env->ep_no_progress_prayer_cmd_ticks; + env->log.no_progress_invalid_action_ticks += env->ep_no_progress_invalid_action_ticks; + env->log.required_work_remaining += + env->reward_runtime.last_required_work_remaining; + env->log.required_work_start += + env->reward_runtime.required_work_at_wave_start; + env->log.cave_progress += env->reward_runtime.last_cave_progress; + env->log.current_wave_progress += + env->reward_runtime.last_current_wave_progress; + env->log.progress_delta += env->ep_progress_delta; + env->log.progress_reward += env->ep_progress_reward; + env->log.ticks_since_positive_progress += + (float)env->reward_runtime.ticks_since_positive_progress; + env->log.positive_progress_ticks += + (float)env->reward_runtime.positive_progress_ticks; + env->log.zero_progress_ticks += + (float)env->reward_runtime.zero_progress_ticks; + env->log.negative_progress_ticks += + (float)env->reward_runtime.negative_progress_ticks; + env->log.gross_damage_dealt += env->ep_gross_damage_dealt; + env->log.net_required_work_removed += env->ep_net_required_work_removed; + env->log.gross_damage_to_net_progress_ratio += + env->ep_gross_damage_dealt / + ((env->ep_net_required_work_removed > 1.0f) + ? env->ep_net_required_work_removed : 1.0f); + env->log.npc_healing_total += env->ep_npc_healing_total; + env->log.mejkot_healing_total += env->ep_mejkot_healing_total; + env->log.jad_healing_total += env->ep_jad_healing_total; + env->log.preclip_reward += env->ep_preclip_reward; + env->log.postclip_reward += env->ep_postclip_reward; + env->log.positive_clip_count += env->ep_positive_clip_count; + env->log.negative_clip_count += env->ep_negative_clip_count; + + for (int i = 0; i < FC_CH_COUNT; i++) { + env->log.rwd_sum[i] += env->ep_rwd_sum[i]; + env->log.rwd_fires[i] += (float)env->ep_rwd_fires[i]; + } + env->log.n += 1.0f; + + /* Same-step autoreset: return the completed episode's reward and + * terminal flag alongside the next episode's initial observation. */ + c_reset(env); + } +} + +void c_render(FightCaves* env) { + /* Rendering handled by external viewer via --policy-pipe mode. + * See tools.py's eval command for the eval pipeline. */ + (void)env; +} + +void c_close(FightCaves* env) { + fc_destroy(&env->state); +} diff --git a/ocean/fight_caves/render.h b/ocean/fight_caves/render.h new file mode 100644 index 0000000000..ba2e1858db --- /dev/null +++ b/ocean/fight_caves/render.h @@ -0,0 +1,3533 @@ +#ifndef FIGHT_CAVES_RENDER_H +#define FIGHT_CAVES_RENDER_H +#include "simulation.h" + +/* Actor Visual */ + +#define FC_VISUAL_LOCAL_UNITS 128.0f +#define FC_VISUAL_CLIENT_TICK_SECONDS 0.02f +#define FC_VISUAL_PATH_CAPACITY 10 +#define FC_VISUAL_ACTIVE_PATH_MAX 9 + +typedef enum { + FC_VISUAL_LOCOMOTION_IDLE = 0, + FC_VISUAL_LOCOMOTION_TURN, + FC_VISUAL_LOCOMOTION_WALK_FORWARD, + FC_VISUAL_LOCOMOTION_WALK_BACK, + FC_VISUAL_LOCOMOTION_WALK_LEFT, + FC_VISUAL_LOCOMOTION_WALK_RIGHT, + FC_VISUAL_LOCOMOTION_RUN, +} FcVisualLocomotion; + +typedef enum { + FC_VISUAL_TARGET_NONE = 0, + FC_VISUAL_TARGET_PLAYER, + FC_VISUAL_TARGET_NPC, +} FcVisualTargetKind; + +typedef struct { + int active; + int size; + int server_tile_x; + int server_tile_y; + + /* Persistent client-local position, in 1/128-tile RuneScape units. */ + float local_x; + float local_y; + float previous_local_x; + float previous_local_y; + + int path_x[FC_VISUAL_PATH_CAPACITY]; + int path_y[FC_VISUAL_PATH_CAPACITY]; + unsigned char path_running[FC_VISUAL_PATH_CAPACITY]; + int path_count; + + float yaw_degrees; + float desired_yaw_degrees; + FcVisualTargetKind target_kind; + int target_slot; + int movement_blocked; + int moving; + FcVisualLocomotion locomotion; +} FcVisualActor; + +typedef struct { + FcVisualActor player; + FcVisualActor npcs[FC_MAX_NPCS]; + float client_tick_accumulator; + float render_alpha; +} FcVisualScene; + +typedef struct { + float x; + float y; + float yaw_degrees; + int moving; + FcVisualLocomotion locomotion; +} FcVisualPose; + +void fc_visual_scene_init(FcVisualScene* scene); +void fc_visual_scene_reset_player(FcVisualScene* scene, int tile_x, int tile_y, + int size, float yaw_degrees); +void fc_visual_scene_reset_npc(FcVisualScene* scene, int slot, int tile_x, + int tile_y, int size, float yaw_degrees); +void fc_visual_scene_deactivate_npc(FcVisualScene* scene, int slot); + +void fc_visual_actor_enqueue_tile(FcVisualActor* actor, int tile_x, int tile_y, + int running); +void fc_visual_actor_enqueue_transition(FcVisualActor* actor, int from_x, + int from_y, int to_x, int to_y, + int running); +void fc_visual_actor_set_target(FcVisualActor* actor, + FcVisualTargetKind target_kind, + int target_slot); +void fc_visual_actor_set_movement_blocked(FcVisualActor* actor, int blocked); + +void fc_visual_scene_update(FcVisualScene* scene, float elapsed_seconds); +FcVisualPose fc_visual_actor_pose(const FcVisualScene* scene, + const FcVisualActor* actor); +FcVisualPose fc_visual_scene_player_pose(const FcVisualScene* scene); +FcVisualPose fc_visual_scene_npc_pose(const FcVisualScene* scene, int slot); + +#include "assets.h" + +/* Actor Animation */ + +#include "raylib.h" + +#include + +typedef struct { + uint16_t idle_anim; + uint16_t walk_anim; + uint16_t walk_back_anim; + uint16_t walk_left_anim; + uint16_t walk_right_anim; + uint16_t turn_anim; + uint16_t run_anim; + uint16_t attack_anim; + uint32_t projectile_travel_spot; + uint32_t projectile_launch_spot; + uint32_t projectile_impact_spot; + Color projectile_color; + float projectile_radius; + float projectile_start_height; + float projectile_end_height; + float projectile_launch_delay_client_ticks; + float projectile_angle; + float projectile_length_adjustment; + float projectile_progress; + float projectile_step_multiplier; +} FcPlayerVisualProfile; + +typedef struct { + FcVisualScene scene; + AnimModelState *player_state; + uint16_t player_sequence; + int player_frame; + float player_timer; + uint16_t player_pose_sequence; + int player_pose_frame; + float player_pose_timer; + uint16_t player_action_sequence; + int player_action_frame; + float player_action_timer; + uint16_t player_lock_sequence; + float player_lock_timer; + int player_attack_target; + float prayer_flick_timer; + AnimModelState *npc_states[FC_MAX_NPCS]; + uint16_t npc_sequences[FC_MAX_NPCS]; + int npc_frames[FC_MAX_NPCS]; + float npc_timers[FC_MAX_NPCS]; + uint16_t npc_action_sequences[FC_MAX_NPCS]; + int npc_action_frames[FC_MAX_NPCS]; + float npc_action_timers[FC_MAX_NPCS]; + int npc_attack_styles[FC_MAX_NPCS]; + float npc_attack_timers[FC_MAX_NPCS]; + float npc_prayer_indicator_timers[FC_MAX_NPCS]; + int npc_prayer_lock_ticks[FC_MAX_NPCS]; + int previous_npc_x[FC_MAX_NPCS]; + int previous_npc_y[FC_MAX_NPCS]; + int previous_npc_active[FC_MAX_NPCS]; +} FcActorAnimation; + +void fc_actor_animation_init(FcActorAnimation *animation); +void fc_actor_animation_reset(FcActorAnimation *animation, + const FcState *state, + NpcModelSet *player_models, + int active_loadout); +void fc_actor_animation_shutdown(FcActorAnimation *animation); + +void fc_actor_animation_capture_tick_start(FcActorAnimation *animation, + const FcState *state); +void fc_actor_animation_ingest_tick(FcActorAnimation *animation, + const FcState *state, + const FcRenderEvents *events); +void fc_actor_animation_ingest_events(FcActorAnimation *animation, + const FcRenderEvents *events, + AnimCache *cache, + int active_loadout, + float tps); +void fc_actor_animation_update_scene(FcActorAnimation *animation, + const FcState *state, + AnimCache *cache, + float tps, + float dt, + int advance_scene, + const unsigned char deferred_deaths[FC_MAX_NPCS]); +void fc_actor_animation_update_models(FcActorAnimation *animation, + const FcState *state, + NpcModelSet *player_models, + NpcModelSet *npc_models, + AnimCache *cache, + int active_loadout, + float tps, + float dt, + const unsigned char deferred_deaths[FC_MAX_NPCS]); + +const FcPlayerVisualProfile *fc_player_visual_profile(int active_loadout); +NpcModelEntry *fc_actor_player_model_entry(NpcModelSet *player_models, + int active_loadout); +void fc_actor_animation_upload_npc(FcActorAnimation *animation, + int npc_slot, + NpcModelEntry *entry); +float fc_actor_animation_scaled_dt(float tps, float dt); +float fc_actor_animation_scaled_duration(float tps, float seconds); +int fc_actor_animation_render_prayer(const FcActorAnimation *animation, + const FcState *state); +int fc_actor_animation_prayer_window_active(const FcActorAnimation *animation, + int npc_slot, + int current_tick); +int fc_actor_animation_previous_npc_active(const FcActorAnimation *animation, + int npc_slot); + + +/* Projectile Visual */ + +typedef struct { + float source_x; + float source_y; + float source_z; + float target_x; + float target_y; + float target_z; + float duration; + float angle; + float progress; +} FcProjectilePath; + +typedef struct { + float x; + float y; + float z; + float velocity_x; + float velocity_y; + float velocity_z; +} FcProjectileSample; + +typedef struct { + float launch_delay; + float flight_duration; + float total_duration; +} FcProjectileTiming; + +float fc_projectile_profile_end_cycle(float launch_cycle, + float length_adjustment, + float step_multiplier, + int tile_distance); + +/* Convert the client's 30-cycle-per-game-tick projectile profile to viewer + * seconds. The extra client cycle in flight_duration matches the client + * endpoint convention used by RuneC's projectile sampler. */ +int fc_projectile_timing_from_client_cycles(float launch_cycle, + float end_cycle, + float ticks_per_second, + FcProjectileTiming* timing); + +/* Effects are retained only until either their animation ends or the client + * retention window closes. This prevents short spot animations from looping. */ +float fc_projectile_effect_duration_seconds(float animation_client_cycles, + float retain_client_cycles, + float ticks_per_second); + +/* Sample the client projectile curve at an absolute point in its flight. + * The target may be replaced on every render frame to reproduce the client's + * actor-targeted homing behavior without frame-rate-dependent integration. */ +int fc_projectile_path_sample(const FcProjectilePath* path, + float elapsed, + FcProjectileSample* sample); + + +/* Click Feedback */ + +#define FC_CLICK_CROSS_FRAME_COUNT 4 +#define FC_CLICK_CROSS_FRAME_SECONDS 0.10f + +typedef enum { + FC_CLICK_CROSS_NONE = 0, + FC_CLICK_CROSS_MOVE, + FC_CLICK_CROSS_INTERACTION, +} FcClickCrossKind; + +typedef struct { + int destination_active; + int destination_x; + int destination_y; + + int preview_pending; + int preview_route_x[FC_MAX_ROUTE]; + int preview_route_y[FC_MAX_ROUTE]; + int preview_route_len; + + FcClickCrossKind cross_kind; + float cross_screen_x; + float cross_screen_y; + float cross_elapsed; +} FcClickFeedback; + +void fc_click_feedback_reset(FcClickFeedback* feedback); + +/* Build a read-only preview with the same move-near pathfinder used by + * fc_step(). The simulation state itself is never modified. */ +void fc_click_feedback_select_move(FcClickFeedback* feedback, + const FcState* state, + int tile_x, int tile_y, + float screen_x, float screen_y); + +void fc_click_feedback_select_interaction(FcClickFeedback* feedback, + float screen_x, float screen_y); + +/* Hand the preview over to the route produced by the authoritative tick. */ +void fc_click_feedback_accept_move_tick(FcClickFeedback* feedback, + const FcState* state); + +/* Clear a completed or cancelled authoritative destination. */ +void fc_click_feedback_sync(FcClickFeedback* feedback, + const FcState* state); + +void fc_click_feedback_update(FcClickFeedback* feedback, + float elapsed_seconds); + +int fc_click_feedback_cross_frame(const FcClickFeedback* feedback); + +/* Return the immediate preview while the click is buffered, then the live + * core route after the next simulation tick accepts it. */ +int fc_click_feedback_route(const FcClickFeedback* feedback, + const FcState* state, + const int** out_x, const int** out_y, + int* out_start, int* out_len); + +#include "ui.h" + +/* Combat Presentation */ + +typedef struct FcCombatPresentation FcCombatPresentation; + +typedef struct { + const FcState *state; + const FcRenderEvents *events; + const FcVisualScene *scene; + TerrainMesh *terrain; + AnimCache *anim_cache; + const FcPlayerVisualProfile *player_profile; + float tps; +} FcCombatPresentationContext; + +typedef struct { + FcCombatPresentationContext presentation; + const FcRenderEntity *entities; + int entity_count; + NpcModelSet *player_models; + NpcModelSet *npc_models; + int active_loadout; + const RuneCUiAssets *ui_assets; + Camera3D camera; +} FcCombatPresentationDrawContext; + +FcCombatPresentation *fc_combat_presentation_create(Texture2D shared_atlas); +int fc_combat_presentation_ready( + const FcCombatPresentation *presentation); +void fc_combat_presentation_destroy(FcCombatPresentation *presentation); +void fc_combat_presentation_reset(FcCombatPresentation *presentation); +void fc_combat_presentation_clear_npc_healthbar( + FcCombatPresentation *presentation, int npc_slot); + +void fc_combat_presentation_ingest_tick( + FcCombatPresentation *presentation, + const FcCombatPresentationContext *context); +void fc_combat_presentation_update( + FcCombatPresentation *presentation, + const FcCombatPresentationContext *context, + float dt); +void fc_combat_presentation_draw_world( + FcCombatPresentation *presentation, + const FcCombatPresentationContext *context, + float dt); +void fc_combat_presentation_draw_healthbars( + const FcCombatPresentation *presentation, + const FcCombatPresentationDrawContext *context); +void fc_combat_presentation_draw_hitsplats( + const FcCombatPresentation *presentation, + const FcCombatPresentationDrawContext *context); + +int fc_combat_presentation_npc_death_deferred( + const FcCombatPresentation *presentation, + const FcState *state, + int npc_slot); +void fc_combat_presentation_deferred_deaths( + const FcCombatPresentation *presentation, + const FcState *state, + unsigned char deferred_deaths[FC_MAX_NPCS]); + + +/* Debug Overlay */ + +#include "raylib.h" + +#define DBG_COLLISION (1 << 0) +#define DBG_LOS (1 << 1) +#define DBG_PATH (1 << 2) +#define DBG_RANGE (1 << 3) +#define DBG_ALL (DBG_COLLISION | DBG_LOS | DBG_PATH | DBG_RANGE) + +void dbg_log_clear(void); +void dbg_log_tick(const FcState *state); +void debug_overlay_3d(const FcState *state, int dbg_flags); +void debug_overlay_screen(const FcState *state, Camera3D cam, int dbg_flags); +void dbg_draw_prayer_window_indicator(Vector3 world_anchor, Camera3D cam); +int dbg_draw_panel_tabs(const FcState *state, + const FcRewardBreakdown *reward_breakdown, + const FcRewardRuntime *reward_runtime, + int reward_config_loaded, + const char *reward_config_path, + int px, int x, int by, int pw, int dbg_tab, + int draw_tabs, int content_height); + + +/* Actor Visual */ +#include +#include +#include + +#define FC_VISUAL_WALK_UNITS_PER_TICK 4.0f +#define FC_VISUAL_TURN_DEGREES_PER_TICK 5.625f + +static float normalize_degrees(float degrees) { + while (degrees >= 180.0f) degrees -= 360.0f; + while (degrees < -180.0f) degrees += 360.0f; + return degrees; +} + +static float face_angle(float ax, float ay, float bx, float by) { + float dx = bx - ax; + float dy = by - ay; + if (fabsf(dx) < 0.0001f && fabsf(dy) < 0.0001f) return 0.0f; + return normalize_degrees(atan2f(dx, -dy) * (180.0f / 3.14159265358979323846f)); +} + +static void reset_actor(FcVisualActor* actor, int tile_x, int tile_y, + int size, float yaw_degrees) { + if (!actor) return; + memset(actor, 0, sizeof(*actor)); + actor->active = 1; + actor->size = size > 0 ? size : 1; + actor->server_tile_x = tile_x; + actor->server_tile_y = tile_y; + actor->local_x = (float)tile_x * FC_VISUAL_LOCAL_UNITS + + (float)actor->size * (FC_VISUAL_LOCAL_UNITS * 0.5f); + actor->local_y = (float)tile_y * FC_VISUAL_LOCAL_UNITS + + (float)actor->size * (FC_VISUAL_LOCAL_UNITS * 0.5f); + actor->previous_local_x = actor->local_x; + actor->previous_local_y = actor->local_y; + actor->yaw_degrees = normalize_degrees(yaw_degrees); + actor->desired_yaw_degrees = actor->yaw_degrees; + actor->target_kind = FC_VISUAL_TARGET_NONE; + actor->target_slot = -1; + actor->locomotion = FC_VISUAL_LOCOMOTION_IDLE; +} + +void fc_visual_scene_init(FcVisualScene* scene) { + if (!scene) return; + memset(scene, 0, sizeof(*scene)); + scene->player.target_slot = -1; + for (int i = 0; i < FC_MAX_NPCS; i++) scene->npcs[i].target_slot = -1; + scene->render_alpha = 1.0f; +} + +void fc_visual_scene_reset_player(FcVisualScene* scene, int tile_x, int tile_y, + int size, float yaw_degrees) { + if (!scene) return; + reset_actor(&scene->player, tile_x, tile_y, size, yaw_degrees); + scene->client_tick_accumulator = 0.0f; + scene->render_alpha = 1.0f; +} + +void fc_visual_scene_reset_npc(FcVisualScene* scene, int slot, int tile_x, + int tile_y, int size, float yaw_degrees) { + if (!scene || slot < 0 || slot >= FC_MAX_NPCS) return; + reset_actor(&scene->npcs[slot], tile_x, tile_y, size, yaw_degrees); +} + +void fc_visual_scene_deactivate_npc(FcVisualScene* scene, int slot) { + if (!scene || slot < 0 || slot >= FC_MAX_NPCS) return; + memset(&scene->npcs[slot], 0, sizeof(scene->npcs[slot])); + scene->npcs[slot].target_slot = -1; +} + +void fc_visual_actor_enqueue_tile(FcVisualActor* actor, int tile_x, int tile_y, + int running) { + if (!actor || !actor->active) return; + actor->server_tile_x = tile_x; + actor->server_tile_y = tile_y; + + if (actor->path_count > 0) { + int last = actor->path_count - 1; + if (actor->path_x[last] == tile_x && actor->path_y[last] == tile_y) { + actor->path_running[last] = running ? 1u : 0u; + return; + } + } + + if (actor->path_count >= FC_VISUAL_ACTIVE_PATH_MAX) { + /* Native actor queues store ten entries but keep at most nine active + * route points. A new server step drops the oldest visual waypoint. */ + memmove(actor->path_x, actor->path_x + 1, + (FC_VISUAL_ACTIVE_PATH_MAX - 1) * sizeof(actor->path_x[0])); + memmove(actor->path_y, actor->path_y + 1, + (FC_VISUAL_ACTIVE_PATH_MAX - 1) * sizeof(actor->path_y[0])); + memmove(actor->path_running, actor->path_running + 1, + (FC_VISUAL_ACTIVE_PATH_MAX - 1) * sizeof(actor->path_running[0])); + actor->path_count = FC_VISUAL_ACTIVE_PATH_MAX - 1; + } + + int next = actor->path_count++; + actor->path_x[next] = tile_x; + actor->path_y[next] = tile_y; + actor->path_running[next] = running ? 1u : 0u; +} + +void fc_visual_actor_enqueue_transition(FcVisualActor* actor, int from_x, + int from_y, int to_x, int to_y, + int running) { + if (!actor || !actor->active) return; + int x = from_x; + int y = from_y; + int dx = to_x - from_x; + int dy = to_y - from_y; + int steps = abs(dx) > abs(dy) ? abs(dx) : abs(dy); + int sx = (dx > 0) - (dx < 0); + int sy = (dy > 0) - (dy < 0); + + /* Large transitions are resets/teleports, not ordinary route updates. */ + if (steps > FC_VISUAL_PATH_CAPACITY) { + reset_actor(actor, to_x, to_y, actor->size, actor->yaw_degrees); + return; + } + + for (int i = 0; i < steps; i++) { + if (x != to_x) x += sx; + if (y != to_y) y += sy; + fc_visual_actor_enqueue_tile(actor, x, y, running); + } + if (steps == 0) { + actor->server_tile_x = to_x; + actor->server_tile_y = to_y; + } +} + +void fc_visual_actor_set_target(FcVisualActor* actor, + FcVisualTargetKind target_kind, + int target_slot) { + if (!actor) return; + actor->target_kind = target_kind; + actor->target_slot = target_slot; +} + +void fc_visual_actor_set_movement_blocked(FcVisualActor* actor, int blocked) { + if (!actor) return; + actor->movement_blocked = blocked ? 1 : 0; +} + +static const FcVisualActor* target_actor(const FcVisualScene* scene, + const FcVisualActor* actor) { + if (!scene || !actor) return NULL; + if (actor->target_kind == FC_VISUAL_TARGET_PLAYER) + return scene->player.active ? &scene->player : NULL; + if (actor->target_kind == FC_VISUAL_TARGET_NPC && + actor->target_slot >= 0 && actor->target_slot < FC_MAX_NPCS && + scene->npcs[actor->target_slot].active) + return &scene->npcs[actor->target_slot]; + return NULL; +} + +static void pop_path_front(FcVisualActor* actor) { + if (!actor || actor->path_count <= 0) return; + actor->path_count--; + if (actor->path_count > 0) { + memmove(actor->path_x, actor->path_x + 1, + actor->path_count * sizeof(actor->path_x[0])); + memmove(actor->path_y, actor->path_y + 1, + actor->path_count * sizeof(actor->path_y[0])); + memmove(actor->path_running, actor->path_running + 1, + actor->path_count * sizeof(actor->path_running[0])); + } +} + +static void move_toward(float* value, float destination, float speed) { + if (*value < destination) { + *value += speed; + if (*value > destination) *value = destination; + } else if (*value > destination) { + *value -= speed; + if (*value < destination) *value = destination; + } +} + +static FcVisualLocomotion directional_locomotion(float movement_yaw, + float actor_yaw, + int fast_movement) { + float relative = normalize_degrees(movement_yaw - actor_yaw); + if (relative >= -45.0f && relative <= 45.0f) { + return fast_movement ? FC_VISUAL_LOCOMOTION_RUN + : FC_VISUAL_LOCOMOTION_WALK_FORWARD; + } + if (relative > 45.0f && relative < 135.0f) + return FC_VISUAL_LOCOMOTION_WALK_RIGHT; + if (relative < -45.0f && relative > -135.0f) + return FC_VISUAL_LOCOMOTION_WALK_LEFT; + return FC_VISUAL_LOCOMOTION_WALK_BACK; +} + +static void update_actor_movement(FcVisualActor* actor) { + actor->moving = 0; + actor->locomotion = FC_VISUAL_LOCOMOTION_IDLE; + if (!actor->active || actor->path_count <= 0 || actor->movement_blocked) + return; + + float dst_x = (float)actor->path_x[0] * FC_VISUAL_LOCAL_UNITS + + (float)actor->size * (FC_VISUAL_LOCAL_UNITS * 0.5f); + float dst_y = (float)actor->path_y[0] * FC_VISUAL_LOCAL_UNITS + + (float)actor->size * (FC_VISUAL_LOCAL_UNITS * 0.5f); + + /* The native client snaps to a queued waypoint when local prediction is + * more than two tiles out of sync, rather than gliding across the gap. */ + if (fabsf(actor->local_x - dst_x) > 256.0f || + fabsf(actor->local_y - dst_y) > 256.0f) { + actor->local_x = dst_x; + actor->local_y = dst_y; + actor->previous_local_x = dst_x; + actor->previous_local_y = dst_y; + return; + } + + float movement_yaw = face_angle(actor->local_x, actor->local_y, dst_x, dst_y); + int running = actor->path_running[0] != 0; + float speed = FC_VISUAL_WALK_UNITS_PER_TICK; + if (actor->target_kind == FC_VISUAL_TARGET_NONE && + fabsf(normalize_degrees(movement_yaw - actor->yaw_degrees)) > 0.01f) + speed = 2.0f; + if (actor->path_count > 2) speed = 6.0f; + if (actor->path_count > 3) speed = 8.0f; + if (running) speed *= 2.0f; + + actor->desired_yaw_degrees = movement_yaw; + actor->locomotion = directional_locomotion( + movement_yaw, actor->yaw_degrees, running || speed >= 8.0f); + actor->moving = 1; + move_toward(&actor->local_x, dst_x, speed); + move_toward(&actor->local_y, dst_y, speed); + if (fabsf(actor->local_x - dst_x) < 0.001f && + fabsf(actor->local_y - dst_y) < 0.001f) + pop_path_front(actor); +} + +static void update_actor_facing(const FcVisualScene* scene, + FcVisualActor* actor) { + if (!actor->active) return; + const FcVisualActor* target = target_actor(scene, actor); + if (target) { + actor->desired_yaw_degrees = face_angle( + actor->local_x, actor->local_y, target->local_x, target->local_y); + } + + float delta = normalize_degrees(actor->desired_yaw_degrees - + actor->yaw_degrees); + if (fabsf(delta) <= FC_VISUAL_TURN_DEGREES_PER_TICK) { + actor->yaw_degrees = actor->desired_yaw_degrees; + } else { + actor->yaw_degrees = normalize_degrees( + actor->yaw_degrees + + (delta > 0.0f ? FC_VISUAL_TURN_DEGREES_PER_TICK + : -FC_VISUAL_TURN_DEGREES_PER_TICK)); + } + + if (!actor->moving && fabsf(delta) > 0.01f) + actor->locomotion = FC_VISUAL_LOCOMOTION_TURN; +} + +static void update_client_tick(FcVisualScene* scene) { + scene->player.previous_local_x = scene->player.local_x; + scene->player.previous_local_y = scene->player.local_y; + for (int i = 0; i < FC_MAX_NPCS; i++) { + scene->npcs[i].previous_local_x = scene->npcs[i].local_x; + scene->npcs[i].previous_local_y = scene->npcs[i].local_y; + } + + update_actor_movement(&scene->player); + for (int i = 0; i < FC_MAX_NPCS; i++) + update_actor_movement(&scene->npcs[i]); + + update_actor_facing(scene, &scene->player); + for (int i = 0; i < FC_MAX_NPCS; i++) + update_actor_facing(scene, &scene->npcs[i]); +} + +void fc_visual_scene_update(FcVisualScene* scene, float elapsed_seconds) { + if (!scene || elapsed_seconds <= 0.0f) return; + scene->client_tick_accumulator += elapsed_seconds; + /* Avoid an unbounded catch-up loop after a debugger stop or window drag. */ + if (scene->client_tick_accumulator > 0.25f) + scene->client_tick_accumulator = 0.25f; + while (scene->client_tick_accumulator >= FC_VISUAL_CLIENT_TICK_SECONDS) { + update_client_tick(scene); + scene->client_tick_accumulator -= FC_VISUAL_CLIENT_TICK_SECONDS; + } + scene->render_alpha = scene->client_tick_accumulator / + FC_VISUAL_CLIENT_TICK_SECONDS; +} + +FcVisualPose fc_visual_actor_pose(const FcVisualScene* scene, + const FcVisualActor* actor) { + FcVisualPose pose = {0}; + if (!scene || !actor || !actor->active) return pose; + float alpha = scene->render_alpha; + if (alpha < 0.0f) alpha = 0.0f; + if (alpha > 1.0f) alpha = 1.0f; + float local_x = actor->previous_local_x + + (actor->local_x - actor->previous_local_x) * alpha; + float local_y = actor->previous_local_y + + (actor->local_y - actor->previous_local_y) * alpha; + pose.x = local_x / FC_VISUAL_LOCAL_UNITS; + pose.y = local_y / FC_VISUAL_LOCAL_UNITS; + pose.yaw_degrees = actor->yaw_degrees; + pose.moving = actor->moving; + pose.locomotion = actor->locomotion; + return pose; +} + +FcVisualPose fc_visual_scene_player_pose(const FcVisualScene* scene) { + return scene ? fc_visual_actor_pose(scene, &scene->player) + : (FcVisualPose){0}; +} + +FcVisualPose fc_visual_scene_npc_pose(const FcVisualScene* scene, int slot) { + if (!scene || slot < 0 || slot >= FC_MAX_NPCS) + return (FcVisualPose){0}; + return fc_visual_actor_pose(scene, &scene->npcs[slot]); +} + +#undef FC_VISUAL_WALK_UNITS_PER_TICK +#undef FC_VISUAL_TURN_DEGREES_PER_TICK + +/* Actor Animation */ +#include +#include +#include + +#define POLICY_REPLAY_BASE_TPS (5.0f / 3.0f) + +#define PLAYER_ANIM_HUMAN_IDLE 808 +#define PLAYER_ANIM_HUMAN_WALK 819 +#define PLAYER_ANIM_HUMAN_WALK_BACK 820 +#define PLAYER_ANIM_HUMAN_WALK_RIGHT 821 +#define PLAYER_ANIM_HUMAN_WALK_LEFT 822 +#define PLAYER_ANIM_HUMAN_TURN 823 +#define PLAYER_ANIM_HUMAN_RUN 824 +#define PLAYER_ANIM_BOW_ATTACK 426 +#define PLAYER_ANIM_XBOW_IDLE 4591 +#define PLAYER_ANIM_XBOW_WALK 4226 +#define PLAYER_ANIM_XBOW_RUN 4228 +#define PLAYER_ANIM_XBOW_ATTACK 7552 +#define PLAYER_ANIM_BLOWPIPE_ATTACK 5061 +#define PLAYER_ANIM_EAT 829 +#define PLAYER_ANIM_DEATH 836 + +#define JAD_ANIM_RANGED 2652 +#define JAD_ANIM_MELEE 2655 +#define JAD_ANIM_MAGIC 2656 + +static const FcPlayerVisualProfile PLAYER_VISUALS[FC_NUM_LOADOUTS] = { + [FC_LOADOUT_BLACK_DHIDE_RCB] = { + PLAYER_ANIM_XBOW_IDLE, PLAYER_ANIM_XBOW_WALK, + PLAYER_ANIM_HUMAN_WALK_BACK, PLAYER_ANIM_HUMAN_WALK_LEFT, + PLAYER_ANIM_HUMAN_WALK_RIGHT, PLAYER_ANIM_HUMAN_TURN, + PLAYER_ANIM_XBOW_RUN, PLAYER_ANIM_XBOW_ATTACK, + 27, 0, 0, {200, 200, 50, 255}, 0.12f, + 155.0f, 146.0f, 41.0f, 5.0f, 5.0f, 11.0f, 5.0f, + }, + [FC_LOADOUT_SOTA_TBOW] = { + PLAYER_ANIM_HUMAN_IDLE, PLAYER_ANIM_HUMAN_WALK, + PLAYER_ANIM_HUMAN_WALK_BACK, PLAYER_ANIM_HUMAN_WALK_LEFT, + PLAYER_ANIM_HUMAN_WALK_RIGHT, PLAYER_ANIM_HUMAN_TURN, + PLAYER_ANIM_HUMAN_RUN, PLAYER_ANIM_BOW_ATTACK, + 1120, 1116, 0, {190, 120, 55, 255}, 0.13f, + 163.0f, 146.0f, 41.0f, 15.0f, 5.0f, 11.0f, 5.0f, + }, + [FC_LOADOUT_LOW_DEF_RCB] = { + PLAYER_ANIM_XBOW_IDLE, PLAYER_ANIM_XBOW_WALK, + PLAYER_ANIM_HUMAN_WALK_BACK, PLAYER_ANIM_HUMAN_WALK_LEFT, + PLAYER_ANIM_HUMAN_WALK_RIGHT, PLAYER_ANIM_HUMAN_TURN, + PLAYER_ANIM_XBOW_RUN, PLAYER_ANIM_XBOW_ATTACK, + 27, 0, 0, {200, 200, 50, 255}, 0.12f, + 155.0f, 146.0f, 41.0f, 5.0f, 5.0f, 11.0f, 5.0f, + }, + [FC_LOADOUT_RCB_PURE] = { + PLAYER_ANIM_XBOW_IDLE, PLAYER_ANIM_XBOW_WALK, + PLAYER_ANIM_HUMAN_WALK_BACK, PLAYER_ANIM_HUMAN_WALK_LEFT, + PLAYER_ANIM_HUMAN_WALK_RIGHT, PLAYER_ANIM_HUMAN_TURN, + PLAYER_ANIM_XBOW_RUN, PLAYER_ANIM_XBOW_ATTACK, + 27, 0, 0, {200, 200, 50, 255}, 0.12f, + 155.0f, 146.0f, 41.0f, 5.0f, 5.0f, 11.0f, 5.0f, + }, + [FC_LOADOUT_MSBI_PURE] = { + PLAYER_ANIM_HUMAN_IDLE, PLAYER_ANIM_HUMAN_WALK, + PLAYER_ANIM_HUMAN_WALK_BACK, PLAYER_ANIM_HUMAN_WALK_LEFT, + PLAYER_ANIM_HUMAN_WALK_RIGHT, PLAYER_ANIM_HUMAN_TURN, + PLAYER_ANIM_HUMAN_RUN, PLAYER_ANIM_BOW_ATTACK, + 15, 24, 0, {145, 155, 165, 255}, 0.10f, + 163.0f, 146.0f, 41.0f, 15.0f, 5.0f, 11.0f, 5.0f, + }, + [FC_LOADOUT_BLOWPIPE_PURE] = { + PLAYER_ANIM_HUMAN_IDLE, PLAYER_ANIM_HUMAN_WALK, + PLAYER_ANIM_HUMAN_WALK_BACK, PLAYER_ANIM_HUMAN_WALK_LEFT, + PLAYER_ANIM_HUMAN_WALK_RIGHT, PLAYER_ANIM_HUMAN_TURN, + PLAYER_ANIM_HUMAN_RUN, PLAYER_ANIM_BLOWPIPE_ATTACK, + 230, 236, 0, {115, 175, 85, 255}, 0.09f, + 163.0f, 146.0f, 32.0f, 15.0f, 0.0f, 11.0f, 5.0f, + }, + [FC_LOADOUT_ACB_ARMADYL] = { + PLAYER_ANIM_XBOW_IDLE, PLAYER_ANIM_XBOW_WALK, + PLAYER_ANIM_HUMAN_WALK_BACK, PLAYER_ANIM_HUMAN_WALK_LEFT, + PLAYER_ANIM_HUMAN_WALK_RIGHT, PLAYER_ANIM_HUMAN_TURN, + PLAYER_ANIM_XBOW_RUN, PLAYER_ANIM_XBOW_ATTACK, + 1468, 0, 0, {165, 210, 240, 255}, 0.12f, + 155.0f, 146.0f, 41.0f, 5.0f, 5.0f, 11.0f, 5.0f, + }, + [FC_LOADOUT_BOWFA_CRYSTAL] = { + PLAYER_ANIM_HUMAN_IDLE, PLAYER_ANIM_HUMAN_WALK, + PLAYER_ANIM_HUMAN_WALK_BACK, PLAYER_ANIM_HUMAN_WALK_LEFT, + PLAYER_ANIM_HUMAN_WALK_RIGHT, PLAYER_ANIM_HUMAN_TURN, + PLAYER_ANIM_HUMAN_RUN, PLAYER_ANIM_BOW_ATTACK, + 1922, 1923, 0, {120, 235, 225, 255}, 0.13f, + 163.0f, 146.0f, 41.0f, 15.0f, 5.0f, 11.0f, 5.0f, + }, + [FC_LOADOUT_TBOW_MASORI] = { + PLAYER_ANIM_HUMAN_IDLE, PLAYER_ANIM_HUMAN_WALK, + PLAYER_ANIM_HUMAN_WALK_BACK, PLAYER_ANIM_HUMAN_WALK_LEFT, + PLAYER_ANIM_HUMAN_WALK_RIGHT, PLAYER_ANIM_HUMAN_TURN, + PLAYER_ANIM_HUMAN_RUN, PLAYER_ANIM_BOW_ATTACK, + 1120, 1116, 0, {190, 120, 55, 255}, 0.13f, + 163.0f, 146.0f, 41.0f, 15.0f, 5.0f, 11.0f, 5.0f, + }, +}; + +static const uint16_t NPC_ANIM_IDLE[] = { + 0, 2618, 2624, 2624, 2631, 2636, 2642, 2650, 2636 +}; +static const uint16_t NPC_ANIM_WALK[] = { + 0, 2619, 2623, 2623, 2632, 2634, 2643, 2651, 2634 +}; +static const uint16_t NPC_ANIM_ATTACK[] = { + 0, 2621, 2625, 2625, 2628, 2637, 2644, 2655, 2637 +}; +static const uint16_t NPC_ANIM_DEATH[] = { + 0, 2620, 2627, 2627, 2630, 2638, 2646, 2654, 2638 +}; + +const FcPlayerVisualProfile *fc_player_visual_profile(int active_loadout) { + if (active_loadout < 0 || active_loadout >= FC_NUM_LOADOUTS) + active_loadout = FC_ACTIVE_LOADOUT; + return &PLAYER_VISUALS[active_loadout]; +} + +NpcModelEntry *fc_actor_player_model_entry(NpcModelSet *player_models, + int active_loadout) { + if (!player_models) return NULL; + if (active_loadout < 0 || active_loadout >= FC_NUM_LOADOUTS) + active_loadout = FC_ACTIVE_LOADOUT; + uint32_t model_id = FC_LOADOUTS[active_loadout].player_model_id; + NpcModelEntry *entry = fc_npc_model_find(player_models, model_id); + if (!entry && player_models->count > 0) + entry = &player_models->entries[0]; + return entry && entry->loaded ? entry : NULL; +} + + +void fc_actor_animation_upload_npc(FcActorAnimation *animation, + int npc_slot, + NpcModelEntry *entry) { + if (!animation || npc_slot < 0 || npc_slot >= FC_MAX_NPCS) return; + fc_model_animation_upload(entry, animation->npc_states[npc_slot]); +} + +static AnimSequence *advance_track(AnimCache *cache, uint16_t desired, + uint16_t *current, int *frame, + float *timer, float dt, int play_once) { + if (!cache || desired == 0 || !current || !frame || !timer) return NULL; + AnimSequence *sequence = anim_get_sequence(cache, desired); + if (!sequence || sequence->frame_count == 0) return NULL; + if (*current != desired) { + *current = desired; + *frame = 0; + *timer = (float)sequence->frames[0].delay * 0.02f; + if (*timer < 0.016f) *timer = 0.016f; + } + if (*frame < 0 || *frame >= sequence->frame_count) *frame = 0; + *timer -= dt; + while (*timer <= 0.0f && (!play_once || *frame < sequence->frame_count - 1)) { + (*frame)++; + if (*frame >= sequence->frame_count) { + if (sequence->frame_step > 0 && + sequence->frame_step <= sequence->frame_count) { + *frame -= sequence->frame_step; + } else { + *frame = 0; + } + } + float delay = (float)sequence->frames[*frame].delay * 0.02f; + if (delay < 0.016f) delay = 0.016f; + *timer += delay; + } + if (play_once && *timer <= 0.0f) *timer = 0.016f; + return sequence; +} + +static float frame_duration(const AnimSequence *sequence, int frame) { + if (!sequence || frame < 0 || frame >= sequence->frame_count) return 0.016f; + float duration = (float)sequence->frames[frame].delay * 0.02f; + return duration < 0.016f ? 0.016f : duration; +} + +static float track_duration(const AnimSequence *sequence) { + if (!sequence || sequence->frame_count == 0) return 0.0f; + float duration = 0.0f; + for (int i = 0; i < sequence->frame_count; i++) + duration += frame_duration(sequence, i); + return duration; +} + +static void retarget_track(AnimCache *cache, uint16_t desired, + uint16_t *current, int *frame, float *timer) { + if (!cache || desired == 0 || !current || !frame || !timer || + *current == 0 || *current == desired) return; + AnimSequence *old_sequence = anim_get_sequence(cache, *current); + AnimSequence *new_sequence = anim_get_sequence(cache, desired); + if (!old_sequence || old_sequence->frame_count == 0 || + !new_sequence || new_sequence->frame_count == 0) return; + int old_frame = *frame; + if (old_frame < 0 || old_frame >= old_sequence->frame_count) old_frame = 0; + float old_total = track_duration(old_sequence); + float new_total = track_duration(new_sequence); + if (old_total <= 0.0f || new_total <= 0.0f) return; + float old_elapsed = 0.0f; + for (int i = 0; i < old_frame; i++) + old_elapsed += frame_duration(old_sequence, i); + float old_frame_duration = frame_duration(old_sequence, old_frame); + float remaining = *timer; + if (remaining < 0.0f) remaining = 0.0f; + if (remaining > old_frame_duration) remaining = old_frame_duration; + old_elapsed += old_frame_duration - remaining; + float target = fmodf(old_elapsed, old_total) / old_total * new_total; + float elapsed = 0.0f; + int new_frame = 0; + for (; new_frame < new_sequence->frame_count - 1; new_frame++) { + float duration = frame_duration(new_sequence, new_frame); + if (target < elapsed + duration) break; + elapsed += duration; + } + *current = desired; + *frame = new_frame; + *timer = elapsed + frame_duration(new_sequence, new_frame) - target; + if (*timer < 0.001f) *timer = 0.001f; +} + +static int movement_sequence(const FcPlayerVisualProfile *profile, + uint16_t sequence) { + return profile && sequence != 0 && + (sequence == profile->walk_anim || + sequence == profile->walk_back_anim || + sequence == profile->walk_left_anim || + sequence == profile->walk_right_anim || + sequence == profile->run_anim); +} + +static float sequence_duration(const AnimSequence *sequence) { + if (!sequence || sequence->frame_count == 0) return 0.45f; + float total = track_duration(sequence); + return total < 0.35f ? 0.35f : total; +} + +static uint16_t npc_attack_sequence(int npc_type, int attack_style) { + if (npc_type == NPC_TZTOK_JAD) { + if (attack_style == ATTACK_MAGIC) return JAD_ANIM_MAGIC; + if (attack_style == ATTACK_RANGED) return JAD_ANIM_RANGED; + if (attack_style == ATTACK_MELEE) return JAD_ANIM_MELEE; + } + return npc_type > 0 && npc_type < 9 ? NPC_ANIM_ATTACK[npc_type] : 0; +} + +float fc_actor_animation_scaled_dt(float tps, float dt) { + if (tps <= 0.0f) return dt; + float scale = tps / POLICY_REPLAY_BASE_TPS; + if (scale < 0.05f) scale = 0.05f; + if (scale > 36.0f) scale = 36.0f; + return dt * scale; +} + +float fc_actor_animation_scaled_duration(float tps, float seconds) { + float scale = tps > 0.0f ? tps / POLICY_REPLAY_BASE_TPS : 1.0f; + if (scale < 0.05f) scale = 0.05f; + if (scale > 36.0f) scale = 36.0f; + seconds /= scale; + return seconds < 0.05f ? 0.05f : seconds; +} + +static int player_lock_active(const FcActorAnimation *animation) { + return animation && animation->player_lock_sequence != 0 && + animation->player_lock_timer > 0.0f; +} + +static uint16_t player_action_sequence(const FcActorAnimation *animation, + const FcState *state) { + if (!animation || !state) return 0; + if (state->terminal == TERMINAL_PLAYER_DEATH) return PLAYER_ANIM_DEATH; + if (state->player.food_eaten_this_tick) return PLAYER_ANIM_EAT; + return player_lock_active(animation) ? animation->player_lock_sequence : 0; +} + +static int sequence_blocks_movement(AnimCache *cache, uint16_t sequence_id) { + if (!cache || sequence_id == 0) return 0; + AnimSequence *sequence = anim_get_sequence(cache, sequence_id); + return sequence && sequence->postanim_move == 0; +} + +static void recreate_player_state(FcActorAnimation *animation, + NpcModelEntry *entry, + int active_loadout) { + if (!animation || !entry || !entry->loaded || !entry->vertex_skins) return; + if (animation->player_state && + animation->player_state->vert_count == entry->base_vert_count) return; + if (animation->player_state) anim_model_state_free(animation->player_state); + animation->player_state = anim_model_state_create(entry->vertex_skins, + entry->base_vert_count); + const FcPlayerVisualProfile *profile = fc_player_visual_profile(active_loadout); + animation->player_sequence = profile->idle_anim; + animation->player_frame = 0; + animation->player_timer = 0.0f; + fprintf(stderr, "Player animation state created (%d base verts, model %u)\n", + entry->base_vert_count, entry->model_id); +} + +void fc_actor_animation_init(FcActorAnimation *animation) { + if (!animation) return; + memset(animation, 0, sizeof(*animation)); + animation->player_attack_target = -1; + for (int i = 0; i < FC_MAX_NPCS; i++) + animation->npc_prayer_lock_ticks[i] = -1; +} + +void fc_actor_animation_reset(FcActorAnimation *animation, + const FcState *state, + NpcModelSet *player_models, + int active_loadout) { + if (!animation || !state) return; + fc_visual_scene_init(&animation->scene); + fc_visual_scene_reset_player(&animation->scene, state->player.x, + state->player.y, 1, + state->player.facing_angle); + const FcPlayerVisualProfile *profile = fc_player_visual_profile(active_loadout); + animation->player_pose_sequence = profile->idle_anim; + animation->player_pose_frame = 0; + animation->player_pose_timer = 0.0f; + animation->player_action_sequence = 0; + animation->player_action_frame = 0; + animation->player_action_timer = 0.0f; + animation->player_lock_sequence = 0; + animation->player_lock_timer = 0.0f; + animation->player_attack_target = -1; + animation->prayer_flick_timer = 0.0f; + recreate_player_state(animation, + fc_actor_player_model_entry(player_models, active_loadout), + active_loadout); + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc *npc = &state->npcs[i]; + animation->previous_npc_x[i] = npc->x; + animation->previous_npc_y[i] = npc->y; + animation->previous_npc_active[i] = npc->active; + if (npc->active || npc->died_this_tick) { + fc_visual_scene_reset_npc(&animation->scene, i, npc->x, npc->y, + npc->size, 0.0f); + } + if (animation->npc_states[i]) { + anim_model_state_free(animation->npc_states[i]); + animation->npc_states[i] = NULL; + } + animation->npc_sequences[i] = 0; + animation->npc_frames[i] = 0; + animation->npc_timers[i] = 0.0f; + animation->npc_action_sequences[i] = 0; + animation->npc_action_frames[i] = 0; + animation->npc_action_timers[i] = 0.0f; + animation->npc_attack_styles[i] = ATTACK_NONE; + animation->npc_attack_timers[i] = 0.0f; + animation->npc_prayer_indicator_timers[i] = 0.0f; + animation->npc_prayer_lock_ticks[i] = -1; + } +} + +void fc_actor_animation_shutdown(FcActorAnimation *animation) { + if (!animation) return; + if (animation->player_state) anim_model_state_free(animation->player_state); + for (int i = 0; i < FC_MAX_NPCS; i++) { + if (animation->npc_states[i]) + anim_model_state_free(animation->npc_states[i]); + } + memset(animation, 0, sizeof(*animation)); +} + +void fc_actor_animation_capture_tick_start(FcActorAnimation *animation, + const FcState *state) { + if (!animation || !state) return; + for (int i = 0; i < FC_MAX_NPCS; i++) { + animation->previous_npc_x[i] = state->npcs[i].x; + animation->previous_npc_y[i] = state->npcs[i].y; + animation->previous_npc_active[i] = state->npcs[i].active; + } +} + +void fc_actor_animation_ingest_tick(FcActorAnimation *animation, + const FcState *state, + const FcRenderEvents *events) { + if (!animation || !state || !events) return; + FcVisualActor *player = &animation->scene.player; + if (!player->active) { + fc_visual_scene_reset_player(&animation->scene, + events->player_move_start_x, events->player_move_start_y, 1, + state->player.facing_angle); + } + int waypoint_count = events->player_move_waypoint_count; + int running = waypoint_count > 1; + for (int i = 0; i < waypoint_count; i++) { + fc_visual_actor_enqueue_tile(player, events->player_move_waypoint_x[i], + events->player_move_waypoint_y[i], running); + } + if (waypoint_count == 0 && + (player->server_tile_x != state->player.x || + player->server_tile_y != state->player.y)) { + fc_visual_actor_enqueue_transition(player, player->server_tile_x, + player->server_tile_y, state->player.x, state->player.y, + state->player.is_running); + } + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc *npc = &state->npcs[i]; + FcVisualActor *visual = &animation->scene.npcs[i]; + if (npc->active && !animation->previous_npc_active[i]) { + fc_visual_scene_reset_npc(&animation->scene, i, npc->x, npc->y, + npc->size, 0.0f); + } else if ((npc->active || npc->died_this_tick) && visual->active) { + fc_visual_actor_enqueue_transition(visual, + animation->previous_npc_x[i], animation->previous_npc_y[i], + npc->x, npc->y, 0); + } else if (!npc->active && !npc->died_this_tick) { + fc_visual_scene_deactivate_npc(&animation->scene, i); + } + } +} + +void fc_actor_animation_ingest_events(FcActorAnimation *animation, + const FcRenderEvents *events, + AnimCache *cache, + int active_loadout, + float tps) { + if (!animation || !events) return; + if (events->player_attack_fired) { + const FcPlayerVisualProfile *profile = + fc_player_visual_profile(active_loadout); + AnimSequence *sequence = cache + ? anim_get_sequence(cache, profile->attack_anim) : NULL; + animation->player_lock_sequence = profile->attack_anim; + animation->player_lock_timer = fc_actor_animation_scaled_duration( + tps, sequence_duration(sequence)); + animation->player_attack_target = + events->player_attack_target_npc_slot; + animation->player_action_sequence = 0; + animation->player_action_frame = 0; + animation->player_action_timer = 0.0f; + } + if (events->prayer_flick_performed) { + animation->prayer_flick_timer = + fc_actor_animation_scaled_duration(tps, 0.10f); + } + for (int i = 0; i < events->npc_attack_count; i++) { + const FcRenderNpcAttack *attack = &events->npc_attacks[i]; + int slot = attack->npc_slot; + if (slot < 0 || slot >= FC_MAX_NPCS || + attack->attack_style == ATTACK_NONE) continue; + animation->npc_attack_styles[slot] = attack->attack_style; + animation->npc_attack_timers[slot] = 1.15f; + animation->npc_prayer_indicator_timers[slot] = 0.30f; + animation->npc_prayer_lock_ticks[slot] = attack->prayer_lock_tick; + } +} + +static void update_targets(FcActorAnimation *animation, const FcState *state) { + int target = player_lock_active(animation) + ? animation->player_attack_target : state->player.attack_target_idx; + if (target >= 0 && target < FC_MAX_NPCS && + animation->scene.npcs[target].active) { + fc_visual_actor_set_target(&animation->scene.player, + FC_VISUAL_TARGET_NPC, target); + } else { + fc_visual_actor_set_target(&animation->scene.player, + FC_VISUAL_TARGET_NONE, -1); + } + for (int i = 0; i < FC_MAX_NPCS; i++) { + FcVisualActor *actor = &animation->scene.npcs[i]; + if (actor->active && state->npcs[i].active) { + int heal_target = state->npcs[i].heal_target_idx; + if (heal_target >= 0 && heal_target < FC_MAX_NPCS && + heal_target != i && animation->scene.npcs[heal_target].active) { + fc_visual_actor_set_target(actor, FC_VISUAL_TARGET_NPC, + heal_target); + } else if (heal_target == i) { + fc_visual_actor_set_target(actor, FC_VISUAL_TARGET_NONE, -1); + } else { + fc_visual_actor_set_target(actor, FC_VISUAL_TARGET_PLAYER, 0); + } + } else { + fc_visual_actor_set_target(actor, FC_VISUAL_TARGET_NONE, -1); + } + } +} + +void fc_actor_animation_update_scene(FcActorAnimation *animation, + const FcState *state, + AnimCache *cache, + float tps, + float dt, + int advance_scene, + const unsigned char deferred_deaths[FC_MAX_NPCS]) { + if (!animation || !state) return; + update_targets(animation, state); + fc_visual_actor_set_movement_blocked(&animation->scene.player, + sequence_blocks_movement(cache, + player_action_sequence(animation, state))); + for (int i = 0; i < FC_MAX_NPCS; i++) { + uint16_t action = 0; + if ((state->npcs[i].is_dead || state->npcs[i].died_this_tick) && + (!deferred_deaths || !deferred_deaths[i])) { + int type = state->npcs[i].npc_type; + if (type > 0 && type < 9) action = NPC_ANIM_DEATH[type]; + } else if (animation->npc_attack_timers[i] > 0.0f) { + action = npc_attack_sequence(state->npcs[i].npc_type, + animation->npc_attack_styles[i]); + } + fc_visual_actor_set_movement_blocked(&animation->scene.npcs[i], + sequence_blocks_movement(cache, action)); + } + float visual_dt = fc_actor_animation_scaled_dt(tps, dt); + if (advance_scene) fc_visual_scene_update(&animation->scene, visual_dt); + if (animation->player_lock_timer > 0.0f) { + animation->player_lock_timer -= dt; + if (animation->player_lock_timer <= 0.0f) { + animation->player_lock_timer = 0.0f; + animation->player_lock_sequence = 0; + animation->player_attack_target = -1; + animation->player_action_sequence = 0; + animation->player_action_frame = 0; + animation->player_action_timer = 0.0f; + } + } + if (animation->prayer_flick_timer > 0.0f) { + animation->prayer_flick_timer -= dt; + if (animation->prayer_flick_timer < 0.0f) + animation->prayer_flick_timer = 0.0f; + } + for (int i = 0; i < FC_MAX_NPCS; i++) { + if (animation->npc_prayer_lock_ticks[i] >= 0 && + state->tick >= animation->npc_prayer_lock_ticks[i]) { + animation->npc_prayer_indicator_timers[i] = 0.0f; + animation->npc_prayer_lock_ticks[i] = -1; + } + if (animation->npc_prayer_indicator_timers[i] > 0.0f) { + animation->npc_prayer_indicator_timers[i] -= dt; + if (animation->npc_prayer_indicator_timers[i] < 0.0f) + animation->npc_prayer_indicator_timers[i] = 0.0f; + } + } +} + +static uint16_t player_pose_sequence(const FcPlayerVisualProfile *profile, + FcVisualLocomotion locomotion) { + switch (locomotion) { + case FC_VISUAL_LOCOMOTION_TURN: return profile->turn_anim; + case FC_VISUAL_LOCOMOTION_WALK_BACK: return profile->walk_back_anim; + case FC_VISUAL_LOCOMOTION_WALK_LEFT: return profile->walk_left_anim; + case FC_VISUAL_LOCOMOTION_WALK_RIGHT: return profile->walk_right_anim; + case FC_VISUAL_LOCOMOTION_RUN: return profile->run_anim; + case FC_VISUAL_LOCOMOTION_WALK_FORWARD: return profile->walk_anim; + default: return profile->idle_anim; + } +} + +void fc_actor_animation_update_models(FcActorAnimation *animation, + const FcState *state, + NpcModelSet *player_models, + NpcModelSet *npc_models, + AnimCache *cache, + int active_loadout, + float tps, + float dt, + const unsigned char deferred_deaths[FC_MAX_NPCS]) { + if (!animation || !state || !cache) return; + float anim_dt = fc_actor_animation_scaled_dt(tps, dt); + NpcModelEntry *player_entry = + fc_actor_player_model_entry(player_models, active_loadout); + recreate_player_state(animation, player_entry, active_loadout); + if (animation->player_state && player_entry) { + const FcPlayerVisualProfile *profile = + fc_player_visual_profile(active_loadout); + FcVisualPose pose = fc_visual_scene_player_pose(&animation->scene); + uint16_t pose_sequence = player_pose_sequence(profile, pose.locomotion); + uint16_t action_sequence = player_action_sequence(animation, state); + if (pose_sequence != animation->player_pose_sequence && + movement_sequence(profile, animation->player_pose_sequence) && + movement_sequence(profile, pose_sequence)) { + retarget_track(cache, pose_sequence, + &animation->player_pose_sequence, + &animation->player_pose_frame, + &animation->player_pose_timer); + } + AnimSequence *pose_track = advance_track(cache, pose_sequence, + &animation->player_pose_sequence, &animation->player_pose_frame, + &animation->player_pose_timer, anim_dt, 0); + AnimSequence *action_track = NULL; + if (action_sequence != 0) { + action_track = advance_track(cache, action_sequence, + &animation->player_action_sequence, + &animation->player_action_frame, + &animation->player_action_timer, anim_dt, + player_lock_active(animation)); + } else { + animation->player_action_sequence = 0; + animation->player_action_frame = 0; + animation->player_action_timer = 0.0f; + } + animation->player_sequence = action_track ? action_sequence : pose_sequence; + animation->player_frame = action_track + ? animation->player_action_frame : animation->player_pose_frame; + if (anim_mix_pose_action(cache, animation->player_state, + player_entry->base_verts, pose_track, + animation->player_pose_frame, action_track, + animation->player_action_frame)) { + fc_model_animation_upload(player_entry, animation->player_state); + } + } + if (!npc_models) return; + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc *npc = &state->npcs[i]; + if (!npc->active && !npc->died_this_tick) { + if (animation->npc_states[i]) { + anim_model_state_free(animation->npc_states[i]); + animation->npc_states[i] = NULL; + } + animation->npc_attack_styles[i] = ATTACK_NONE; + animation->npc_attack_timers[i] = 0.0f; + animation->npc_prayer_indicator_timers[i] = 0.0f; + animation->npc_prayer_lock_ticks[i] = -1; + continue; + } + NpcModelEntry *entry = fc_npc_model_find( + npc_models, fc_npc_type_to_model_id(npc->npc_type)); + if (!entry || !entry->loaded || !entry->vertex_skins) continue; + if (!animation->npc_states[i] || + animation->npc_states[i]->vert_count != entry->base_vert_count) { + if (animation->npc_states[i]) + anim_model_state_free(animation->npc_states[i]); + animation->npc_states[i] = anim_model_state_create( + entry->vertex_skins, entry->base_vert_count); + animation->npc_sequences[i] = + npc->npc_type > 0 && npc->npc_type < 9 + ? NPC_ANIM_IDLE[npc->npc_type] : 0; + animation->npc_frames[i] = 0; + animation->npc_timers[i] = 0.0f; + animation->npc_action_sequences[i] = 0; + animation->npc_action_frames[i] = 0; + animation->npc_action_timers[i] = 0.0f; + } + uint16_t pose_sequence = npc->npc_type > 0 && npc->npc_type < 9 + ? NPC_ANIM_IDLE[npc->npc_type] : 0; + if (animation->scene.npcs[i].moving && + npc->npc_type > 0 && npc->npc_type < 9) + pose_sequence = NPC_ANIM_WALK[npc->npc_type]; + uint16_t action_sequence = 0; + if ((npc->is_dead || npc->died_this_tick) && + (!deferred_deaths || !deferred_deaths[i])) { + if (npc->npc_type > 0 && npc->npc_type < 9) + action_sequence = NPC_ANIM_DEATH[npc->npc_type]; + } else if (animation->npc_attack_timers[i] > 0.0f) { + action_sequence = npc_attack_sequence( + npc->npc_type, animation->npc_attack_styles[i]); + } + AnimSequence *pose_track = advance_track(cache, pose_sequence, + &animation->npc_sequences[i], &animation->npc_frames[i], + &animation->npc_timers[i], anim_dt, 0); + AnimSequence *action_track = NULL; + if (action_sequence != 0) { + action_track = advance_track(cache, action_sequence, + &animation->npc_action_sequences[i], + &animation->npc_action_frames[i], + &animation->npc_action_timers[i], anim_dt, 1); + } else { + animation->npc_action_sequences[i] = 0; + animation->npc_action_frames[i] = 0; + animation->npc_action_timers[i] = 0.0f; + } + anim_mix_pose_action(cache, animation->npc_states[i], + entry->base_verts, pose_track, animation->npc_frames[i], + action_track, animation->npc_action_frames[i]); + if (animation->npc_attack_timers[i] > 0.0f) { + animation->npc_attack_timers[i] -= anim_dt; + if (animation->npc_attack_timers[i] <= 0.0f) { + animation->npc_attack_timers[i] = 0.0f; + animation->npc_attack_styles[i] = ATTACK_NONE; + } + } + } +} + +int fc_actor_animation_render_prayer(const FcActorAnimation *animation, + const FcState *state) { + if (!animation || !state || animation->prayer_flick_timer > 0.0f) + return PRAYER_NONE; + return state->player.prayer; +} + +int fc_actor_animation_prayer_window_active(const FcActorAnimation *animation, + int npc_slot, + int current_tick) { + if (!animation || npc_slot < 0 || npc_slot >= FC_MAX_NPCS) return 0; + return animation->npc_prayer_indicator_timers[npc_slot] > 0.0f || + (animation->npc_prayer_lock_ticks[npc_slot] >= 0 && + current_tick < animation->npc_prayer_lock_ticks[npc_slot]); +} + +int fc_actor_animation_previous_npc_active(const FcActorAnimation *animation, + int npc_slot) { + return animation && npc_slot >= 0 && npc_slot < FC_MAX_NPCS + ? animation->previous_npc_active[npc_slot] : 0; +} + +#undef POLICY_REPLAY_BASE_TPS +#undef PLAYER_ANIM_HUMAN_IDLE +#undef PLAYER_ANIM_HUMAN_WALK +#undef PLAYER_ANIM_HUMAN_WALK_BACK +#undef PLAYER_ANIM_HUMAN_WALK_RIGHT +#undef PLAYER_ANIM_HUMAN_WALK_LEFT +#undef PLAYER_ANIM_HUMAN_TURN +#undef PLAYER_ANIM_HUMAN_RUN +#undef PLAYER_ANIM_BOW_ATTACK +#undef PLAYER_ANIM_XBOW_IDLE +#undef PLAYER_ANIM_XBOW_WALK +#undef PLAYER_ANIM_XBOW_RUN +#undef PLAYER_ANIM_XBOW_ATTACK +#undef PLAYER_ANIM_BLOWPIPE_ATTACK +#undef PLAYER_ANIM_EAT +#undef PLAYER_ANIM_DEATH +#undef JAD_ANIM_RANGED +#undef JAD_ANIM_MELEE +#undef JAD_ANIM_MAGIC + +/* Projectile Visual */ +#include + +float fc_projectile_profile_end_cycle(float launch_cycle, + float length_adjustment, + float step_multiplier, + int tile_distance) { + if (tile_distance < 0) tile_distance = 0; + float end_cycle = launch_cycle + length_adjustment + + step_multiplier * (float)tile_distance; + return end_cycle > launch_cycle ? end_cycle : launch_cycle + 1.0f; +} + +int fc_projectile_timing_from_client_cycles(float launch_cycle, + float end_cycle, + float ticks_per_second, + FcProjectileTiming* timing) { + if (!timing || ticks_per_second <= 0.0f || launch_cycle < 0.0f || + end_cycle <= launch_cycle) + return 0; + + float seconds_per_client_cycle = 1.0f / (30.0f * ticks_per_second); + timing->launch_delay = launch_cycle * seconds_per_client_cycle; + timing->flight_duration = + (end_cycle + 1.0f - launch_cycle) * seconds_per_client_cycle; + timing->total_duration = timing->launch_delay + timing->flight_duration; + return 1; +} + +float fc_projectile_effect_duration_seconds(float animation_client_cycles, + float retain_client_cycles, + float ticks_per_second) { + if (ticks_per_second <= 0.0f || retain_client_cycles <= 0.0f) + return 0.0f; + if (animation_client_cycles <= 0.0f) + animation_client_cycles = 30.0f; + float visible_cycles = animation_client_cycles < retain_client_cycles + ? animation_client_cycles : retain_client_cycles; + return visible_cycles / (30.0f * ticks_per_second); +} + +int fc_projectile_path_sample(const FcProjectilePath* path, + float elapsed, + FcProjectileSample* sample) { + if (!path || !sample || path->duration <= 0.0f) + return 0; + + float dx = path->target_x - path->source_x; + float dz = path->target_z - path->source_z; + float horizontal = sqrtf(dx * dx + dz * dz); + float direction_x = 0.0f; + float direction_z = 1.0f; + if (horizontal > 0.00001f) { + direction_x = dx / horizontal; + direction_z = dz / horizontal; + } + + float source_x = path->source_x + direction_x * path->progress; + float source_z = path->source_z + direction_z * path->progress; + float velocity_x = (path->target_x - source_x) / path->duration; + float velocity_z = (path->target_z - source_z) / path->duration; + float horizontal_speed = sqrtf( + velocity_x * velocity_x + velocity_z * velocity_z); + float velocity_y = horizontal_speed * + tanf(path->angle * (3.14159265358979323846f / 128.0f)); + float acceleration_y = 2.0f * + (path->target_y - path->source_y - velocity_y * path->duration) / + (path->duration * path->duration); + + float t = elapsed; + if (t < 0.0f) t = 0.0f; + if (t > path->duration) t = path->duration; + sample->x = source_x + velocity_x * t; + sample->y = path->source_y + velocity_y * t + + 0.5f * acceleration_y * t * t; + sample->z = source_z + velocity_z * t; + sample->velocity_x = velocity_x; + sample->velocity_y = velocity_y + acceleration_y * t; + sample->velocity_z = velocity_z; + return 1; +} + + +/* Click Feedback */ +#include + +void fc_click_feedback_reset(FcClickFeedback* feedback) { + if (!feedback) return; + memset(feedback, 0, sizeof(*feedback)); + feedback->destination_x = -1; + feedback->destination_y = -1; +} + +static void start_cross(FcClickFeedback* feedback, FcClickCrossKind kind, + float screen_x, float screen_y) { + feedback->cross_kind = kind; + feedback->cross_screen_x = screen_x; + feedback->cross_screen_y = screen_y; + feedback->cross_elapsed = 0.0f; +} + +void fc_click_feedback_select_move(FcClickFeedback* feedback, + const FcState* state, + int tile_x, int tile_y, + float screen_x, float screen_y) { + if (!feedback || !state || + tile_x < 0 || tile_x >= FC_ARENA_WIDTH || + tile_y < 0 || tile_y >= FC_ARENA_HEIGHT) { + return; + } + + feedback->destination_active = 1; + feedback->destination_x = tile_x; + feedback->destination_y = tile_y; + feedback->preview_pending = 1; + feedback->preview_route_len = fc_pathfind_bfs_move_near( + state->player.x, state->player.y, tile_x, tile_y, + state->walkable, state->movement_flags, + feedback->preview_route_x, feedback->preview_route_y, FC_MAX_ROUTE); + start_cross(feedback, FC_CLICK_CROSS_MOVE, screen_x, screen_y); +} + +void fc_click_feedback_select_interaction(FcClickFeedback* feedback, + float screen_x, float screen_y) { + if (!feedback) return; + feedback->destination_active = 0; + feedback->destination_x = -1; + feedback->destination_y = -1; + feedback->preview_pending = 0; + feedback->preview_route_len = 0; + start_cross(feedback, FC_CLICK_CROSS_INTERACTION, screen_x, screen_y); +} + +void fc_click_feedback_accept_move_tick(FcClickFeedback* feedback, + const FcState* state) { + if (!feedback || !state || !feedback->preview_pending) return; + feedback->preview_pending = 0; + feedback->preview_route_len = 0; + fc_click_feedback_sync(feedback, state); +} + +void fc_click_feedback_sync(FcClickFeedback* feedback, + const FcState* state) { + if (!feedback || !state || !feedback->destination_active || + feedback->preview_pending) { + return; + } + if (state->player.route_idx >= state->player.route_len) { + feedback->destination_active = 0; + feedback->destination_x = -1; + feedback->destination_y = -1; + } +} + +void fc_click_feedback_update(FcClickFeedback* feedback, + float elapsed_seconds) { + if (!feedback || feedback->cross_kind == FC_CLICK_CROSS_NONE || + elapsed_seconds <= 0.0f) { + return; + } + feedback->cross_elapsed += elapsed_seconds; + if (feedback->cross_elapsed >= + FC_CLICK_CROSS_FRAME_COUNT * FC_CLICK_CROSS_FRAME_SECONDS) { + feedback->cross_kind = FC_CLICK_CROSS_NONE; + feedback->cross_elapsed = 0.0f; + } +} + +int fc_click_feedback_cross_frame(const FcClickFeedback* feedback) { + if (!feedback || feedback->cross_kind == FC_CLICK_CROSS_NONE) return -1; + int frame = (int)(feedback->cross_elapsed / FC_CLICK_CROSS_FRAME_SECONDS); + if (frame < 0) frame = 0; + if (frame >= FC_CLICK_CROSS_FRAME_COUNT) + frame = FC_CLICK_CROSS_FRAME_COUNT - 1; + return frame; +} + +int fc_click_feedback_route(const FcClickFeedback* feedback, + const FcState* state, + const int** out_x, const int** out_y, + int* out_start, int* out_len) { + if (!feedback || !state || !out_x || !out_y || !out_start || !out_len || + !feedback->destination_active) { + return 0; + } + + if (feedback->preview_pending) { + *out_x = feedback->preview_route_x; + *out_y = feedback->preview_route_y; + *out_start = 0; + *out_len = feedback->preview_route_len; + return feedback->preview_route_len > 0; + } + + *out_x = state->player.route_x; + *out_y = state->player.route_y; + *out_start = state->player.route_idx; + *out_len = state->player.route_len; + return state->player.route_idx < state->player.route_len; +} + + +/* Combat Presentation */ +#include "raymath.h" +#include "rlgl.h" + +#include +#include +#include +#include + +#define MAX_HITSPLATS 32 +#define MAX_PROJECTILES 16 +#define MAX_VISUAL_EFFECTS 32 +#define OSRS_HITSPLAT_SECONDS 1.0f +#define OSRS_HEALTHBAR_SECONDS 6.0f +#define POLICY_REPLAY_BASE_TPS (5.0f / 3.0f) + +#define PROJ_JAD_MAGIC_LAUNCH 439 +#define PROJ_TOK_XIL_SPINE 443 +#define PROJ_TOK_XIL_IMPACT 444 +#define PROJ_KET_ZEK_FIRE 445 +#define PROJ_KET_ZEK_IMPACT 446 +#define PROJ_JAD_MAGIC_TRAVEL 448 +#define PROJ_JAD_MAGIC_IMPACT 157 +#define PROJ_JAD_RANGED_IMPACT 451 +#define PROJ_SPOTANIM_MODEL_BASE 0xA2000000u + +typedef enum { + HITSPLAT_DAMAGE = 0, + HITSPLAT_HEAL = 1, + HITSPLAT_PRAYER_DRAIN = 2, +} HitsplatKind; + +typedef struct { + int active; + float world_x; + float world_y; + float world_z; + FcVisualTargetKind actor_kind; + int actor_slot; + int overlay_slot; + int damage; + int kind; + float seconds_left; +} Hitsplat; + +typedef struct { + int active; + float src_x; + float src_y; + float src_z; + float dst_x; + float dst_y; + float dst_z; + float x; + float y; + float z; + float velocity_x; + float velocity_y; + float velocity_z; + float total_time; + float elapsed; + float launch_delay; + int launched; + FcVisualTargetKind source_kind; + int source_slot; + float source_y_offset; + FcVisualTargetKind target_kind; + int target_slot; + float target_y_offset; + int track_target; + int attack_style; + int launch_tick; + int has_deferred_hitsplat; + FcVisualTargetKind hitsplat_actor_kind; + int hitsplat_actor_slot; + float hitsplat_world_x; + float hitsplat_world_y; + float hitsplat_world_z; + int hitsplat_damage; + Color color; + float radius; + uint32_t spot_id; + uint32_t launch_spot_id; + uint32_t impact_spot_id; + float projectile_angle; + float projectile_progress; + AnimModelState *anim_state; + uint16_t anim_sequence; + int anim_frame; + float anim_timer; +} VisualProjectile; + +typedef struct { + int active; + float x; + float y; + float z; + float total_time; + float elapsed; + Color color; + float radius; + uint32_t spot_id; + float yaw_degrees; + int attached; + FcVisualTargetKind attached_kind; + int attached_slot; + float attached_y_offset; + FcVisualTargetKind face_kind; + int face_slot; + AnimModelState *anim_state; + uint16_t anim_sequence; + int anim_frame; + float anim_timer; +} VisualEffect; + +struct FcCombatPresentation { + Hitsplat hitsplats[MAX_HITSPLATS]; + float player_healthbar_timer; + float npc_healthbar_timers[FC_MAX_NPCS]; + VisualProjectile projectiles[MAX_PROJECTILES]; + VisualEffect effects[MAX_VISUAL_EFFECTS]; + NpcModelSet *projectile_models; + SpotAnimSet *spotanims; + Texture2D hitsplat_zero_texture; + Texture2D hitsplat_damage_texture; + Texture2D hitsplat_heal_texture; + Texture2D hitsplat_prayer_drain_texture; + Texture2D healthbar_full_texture; + Texture2D healthbar_empty_texture; +}; + +static float ground_height(const FcCombatPresentationContext *context, + int tile_x, int tile_y) { + return context && context->terrain && context->terrain->loaded + ? terrain_height_at(context->terrain, tile_x, tile_y) + 0.1f : 0.0f; +} + +static float smooth_ground_height(const FcCombatPresentationContext *context, + float tile_x, float tile_y) { + int x0 = (int)floorf(tile_x); + int y0 = (int)floorf(tile_y); + if (x0 < 0) x0 = 0; + if (y0 < 0) y0 = 0; + if (x0 >= FC_ARENA_WIDTH) x0 = FC_ARENA_WIDTH - 1; + if (y0 >= FC_ARENA_HEIGHT) y0 = FC_ARENA_HEIGHT - 1; + int x1 = x0 + 1 < FC_ARENA_WIDTH ? x0 + 1 : x0; + int y1 = y0 + 1 < FC_ARENA_HEIGHT ? y0 + 1 : y0; + float tx = tile_x - floorf(tile_x); + float ty = tile_y - floorf(tile_y); + float h00 = ground_height(context, x0, y0); + float h10 = ground_height(context, x1, y0); + float h01 = ground_height(context, x0, y1); + float h11 = ground_height(context, x1, y1); + float h0 = h00 + (h10 - h00) * tx; + float h1 = h01 + (h11 - h01) * tx; + return h0 + (h1 - h0) * ty; +} + +static void free_projectile(VisualProjectile *projectile) { + if (!projectile) return; + if (projectile->anim_state) + anim_model_state_free(projectile->anim_state); + memset(projectile, 0, sizeof(*projectile)); +} + +static void free_effect(VisualEffect *effect) { + if (!effect) return; + if (effect->anim_state) anim_model_state_free(effect->anim_state); + memset(effect, 0, sizeof(*effect)); +} + +static void clear_visuals(FcCombatPresentation *presentation) { + for (int i = 0; i < MAX_PROJECTILES; i++) + free_projectile(&presentation->projectiles[i]); + for (int i = 0; i < MAX_VISUAL_EFFECTS; i++) + free_effect(&presentation->effects[i]); +} + +FcCombatPresentation *fc_combat_presentation_create(Texture2D shared_atlas) { + FcCombatPresentation *presentation = calloc(1, sizeof(*presentation)); + if (!presentation) return NULL; + if (fc_asset_exists("fc_projectiles.models")) { + presentation->projectile_models = fc_npc_models_load( + "fc_projectiles.models", shared_atlas); + } + if (fc_asset_exists("fc_spotanims.bin")) + presentation->spotanims = spotanims_load("fc_spotanims.bin"); + presentation->hitsplat_zero_texture = fc_load_texture_asset( + "data/sprites/ui/hitsplat_zero.png"); + presentation->hitsplat_damage_texture = fc_load_texture_asset( + "data/sprites/ui/hitsplat_damage.png"); + presentation->hitsplat_heal_texture = fc_load_texture_asset( + "data/sprites/ui/hitsplat_heal.png"); + presentation->hitsplat_prayer_drain_texture = fc_load_texture_asset( + "data/sprites/ui/hitsplat_prayer_drain.png"); + presentation->healthbar_full_texture = fc_load_texture_asset( + "data/sprites/ui/healthbar_full_30.png"); + presentation->healthbar_empty_texture = fc_load_texture_asset( + "data/sprites/ui/healthbar_empty_30.png"); + Texture2D *textures[] = { + &presentation->hitsplat_zero_texture, + &presentation->hitsplat_damage_texture, + &presentation->hitsplat_heal_texture, + &presentation->hitsplat_prayer_drain_texture, + &presentation->healthbar_full_texture, + &presentation->healthbar_empty_texture, + }; + int loaded = 0; + for (int i = 0; i < (int)(sizeof(textures) / sizeof(textures[0])); i++) { + if (textures[i]->id > 0) { + SetTextureFilter(*textures[i], TEXTURE_FILTER_POINT); + loaded++; + } + } + fprintf(stderr, "Actor overhead sprites loaded: %d/6\n", loaded); + return presentation; +} + +int fc_combat_presentation_ready( + const FcCombatPresentation *presentation) { + if (!presentation || !presentation->projectile_models || + !presentation->projectile_models->loaded || !presentation->spotanims || + !presentation->spotanims->loaded) { + return 0; + } + const Texture2D textures[] = { + presentation->hitsplat_zero_texture, + presentation->hitsplat_damage_texture, + presentation->hitsplat_heal_texture, + presentation->hitsplat_prayer_drain_texture, + presentation->healthbar_full_texture, + presentation->healthbar_empty_texture, + }; + for (int i = 0; i < (int)(sizeof(textures) / sizeof(textures[0])); i++) { + if (textures[i].id == 0) return 0; + } + return 1; +} + +void fc_combat_presentation_destroy(FcCombatPresentation *presentation) { + if (!presentation) return; + clear_visuals(presentation); + Texture2D textures[] = { + presentation->hitsplat_zero_texture, + presentation->hitsplat_damage_texture, + presentation->hitsplat_heal_texture, + presentation->hitsplat_prayer_drain_texture, + presentation->healthbar_full_texture, + presentation->healthbar_empty_texture, + }; + for (int i = 0; i < (int)(sizeof(textures) / sizeof(textures[0])); i++) { + if (textures[i].id > 0) UnloadTexture(textures[i]); + } + if (presentation->spotanims) spotanims_free(presentation->spotanims); + if (presentation->projectile_models) + fc_npc_models_unload(presentation->projectile_models); + free(presentation); +} + +void fc_combat_presentation_reset(FcCombatPresentation *presentation) { + if (!presentation) return; + clear_visuals(presentation); + memset(presentation->hitsplats, 0, sizeof(presentation->hitsplats)); + presentation->player_healthbar_timer = 0.0f; + memset(presentation->npc_healthbar_timers, 0, + sizeof(presentation->npc_healthbar_timers)); +} + +void fc_combat_presentation_clear_npc_healthbar( + FcCombatPresentation *presentation, int npc_slot) { + if (!presentation || npc_slot < 0 || npc_slot >= FC_MAX_NPCS) return; + presentation->npc_healthbar_timers[npc_slot] = 0.0f; +} + +static VisualEffect *spawn_effect(FcCombatPresentation *presentation, + uint32_t spot_id, float x, float y, float z, + float duration, Color color, float radius, + float yaw_degrees) { + if (!presentation || spot_id == 0) return NULL; + for (int i = 0; i < MAX_VISUAL_EFFECTS; i++) { + if (!presentation->effects[i].active) { + VisualEffect *effect = &presentation->effects[i]; + memset(effect, 0, sizeof(*effect)); + effect->active = 1; + effect->x = x; + effect->y = y; + effect->z = z; + effect->total_time = duration; + effect->color = color; + effect->radius = radius; + effect->spot_id = spot_id; + effect->yaw_degrees = yaw_degrees; + return effect; + } + } + return NULL; +} + +static VisualProjectile *spawn_projectile(FcCombatPresentation *presentation, + float source_x, float source_y, float source_z, + float target_x, float target_y, float target_z, + float travel_seconds, Color color, float radius, + uint32_t travel_spot, uint32_t launch_spot, uint32_t impact_spot) { + if (!presentation || + (travel_spot == 0 && launch_spot == 0 && impact_spot == 0)) return NULL; + for (int i = 0; i < MAX_PROJECTILES; i++) { + if (!presentation->projectiles[i].active) { + VisualProjectile *projectile = &presentation->projectiles[i]; + free_projectile(projectile); + projectile->active = 1; + projectile->src_x = source_x; + projectile->src_y = source_y; + projectile->src_z = source_z; + projectile->dst_x = target_x; + projectile->dst_y = target_y; + projectile->dst_z = target_z; + projectile->x = source_x; + projectile->y = source_y; + projectile->z = source_z; + projectile->total_time = travel_seconds; + projectile->color = color; + projectile->radius = radius; + projectile->spot_id = travel_spot; + projectile->launch_spot_id = launch_spot; + projectile->impact_spot_id = impact_spot; + return projectile; + } + } + return NULL; +} + +static int actor_world_point(const FcCombatPresentationContext *context, + FcVisualTargetKind kind, int slot, + float *x, float *y, float *z) { + if (!context || !context->scene || !x || !y || !z) return 0; + FcVisualPose pose; + float height; + if (kind == FC_VISUAL_TARGET_PLAYER && context->scene->player.active) { + pose = fc_visual_scene_player_pose(context->scene); + height = 1.5f; + } else if (kind == FC_VISUAL_TARGET_NPC && slot >= 0 && + slot < FC_MAX_NPCS && context->scene->npcs[slot].active) { + pose = fc_visual_scene_npc_pose(context->scene, slot); + height = 1.0f + (float)context->scene->npcs[slot].size * 0.3f; + } else { + return 0; + } + *x = pose.x; + *z = -pose.y; + *y = smooth_ground_height(context, pose.x, pose.y) + height; + return 1; +} + +static int tile_distance(int source_x, int source_y, + int target_x, int target_y) { + int dx = abs(target_x - source_x); + int dy = abs(target_y - source_y); + return dx > dy ? dx : dy; +} + +static float animation_client_cycles(const AnimSequence *sequence) { + if (!sequence || sequence->frame_count == 0) return 0.0f; + float total = 0.0f; + for (int i = 0; i < sequence->frame_count; i++) + total += sequence->frames[i].delay > 0 ? sequence->frames[i].delay : 1; + return total; +} + +static float effect_duration(const FcCombatPresentation *presentation, + const FcCombatPresentationContext *context, + uint32_t spot_id, float retained_cycles) { + const SpotAnimDef *spot = presentation && presentation->spotanims + ? spotanim_find(presentation->spotanims, (int)spot_id) : NULL; + float cycles = 0.0f; + if (spot && spot->animation_id >= 0 && context && context->anim_cache) { + cycles = animation_client_cycles(anim_get_sequence( + context->anim_cache, (uint16_t)spot->animation_id)); + } + return fc_projectile_effect_duration_seconds(cycles, retained_cycles, + context && context->tps > 0.0f + ? context->tps : POLICY_REPLAY_BASE_TPS); +} + +static void configure_tracking(FcCombatPresentation *presentation, + const FcCombatPresentationContext *context, VisualProjectile *projectile, + FcVisualTargetKind source_kind, int source_slot, + FcVisualTargetKind target_kind, int target_slot, int attack_style, + float launch_cycles, float end_cycles, float angle, float progress, + int track_target) { + if (!presentation || !context || !projectile) return; + projectile->source_kind = source_kind; + projectile->source_slot = source_slot; + projectile->target_kind = target_kind; + projectile->target_slot = target_slot; + projectile->track_target = track_target; + projectile->attack_style = attack_style; + projectile->launch_tick = context->state->tick; + projectile->projectile_angle = angle >= 0.0f ? angle : 15.0f; + projectile->projectile_progress = progress >= 0.0f ? progress : 0.0f; + FcProjectileTiming timing = {0}; + if (fc_projectile_timing_from_client_cycles( + launch_cycles, end_cycles, context->tps, &timing)) { + projectile->launch_delay = timing.launch_delay; + projectile->total_time = timing.total_duration; + } else { + projectile->launch_delay = 0.0f; + } + float x; + float y; + float z; + if (actor_world_point(context, source_kind, source_slot, &x, &y, &z)) { + projectile->source_y_offset = projectile->src_y - y; + projectile->src_x = x; + projectile->src_y = y + projectile->source_y_offset; + projectile->src_z = z; + } + if (track_target && + actor_world_point(context, target_kind, target_slot, &x, &y, &z)) { + projectile->target_y_offset = projectile->dst_y - y; + projectile->dst_x = x; + projectile->dst_y = y + projectile->target_y_offset; + projectile->dst_z = z; + } + projectile->x = projectile->src_x; + projectile->y = projectile->src_y; + projectile->z = projectile->src_z; + if (projectile->launch_spot_id != 0) { + float retain = launch_cycles > 30.0f ? launch_cycles : 30.0f; + float duration = effect_duration(presentation, context, + projectile->launch_spot_id, retain); + float yaw = atan2f(projectile->dst_x - projectile->src_x, + projectile->dst_z - projectile->src_z) * RAD2DEG; + VisualEffect *effect = spawn_effect(presentation, + projectile->launch_spot_id, projectile->src_x, projectile->src_y, + projectile->src_z, duration, projectile->color, + projectile->radius * 1.4f, yaw); + if (effect) { + effect->attached = 1; + effect->attached_kind = source_kind; + effect->attached_slot = source_slot; + effect->face_kind = target_kind; + effect->face_slot = target_slot; + if (actor_world_point(context, source_kind, source_slot, + &x, &y, &z)) + effect->attached_y_offset = effect->y - y; + } + } +} + +static void show_healthbar(FcCombatPresentation *presentation, + FcVisualTargetKind kind, int slot) { + if (kind == FC_VISUAL_TARGET_PLAYER) { + presentation->player_healthbar_timer = OSRS_HEALTHBAR_SECONDS; + } else if (kind == FC_VISUAL_TARGET_NPC && + slot >= 0 && slot < FC_MAX_NPCS) { + presentation->npc_healthbar_timers[slot] = OSRS_HEALTHBAR_SECONDS; + } +} + +static int next_overlay_slot(const FcCombatPresentation *presentation, + FcVisualTargetKind kind, int actor_slot) { + unsigned int used = 0; + for (int i = 0; i < MAX_HITSPLATS; i++) { + const Hitsplat *hit = &presentation->hitsplats[i]; + if (hit->active && hit->actor_kind == kind && + hit->actor_slot == actor_slot && + hit->overlay_slot >= 0 && hit->overlay_slot < 4) + used |= 1u << hit->overlay_slot; + } + for (int i = 0; i < 4; i++) + if ((used & (1u << i)) == 0) return i; + return 0; +} + +static void spawn_status_splat(FcCombatPresentation *presentation, + FcVisualTargetKind kind, int actor_slot, + float x, float y, float z, int damage, HitsplatKind splat_kind) { + for (int i = 0; i < MAX_HITSPLATS; i++) { + if (!presentation->hitsplats[i].active) { + Hitsplat *hit = &presentation->hitsplats[i]; + hit->active = 1; + hit->world_x = x; + hit->world_y = y; + hit->world_z = z; + hit->actor_kind = kind; + hit->actor_slot = actor_slot; + hit->overlay_slot = next_overlay_slot(presentation, kind, actor_slot); + hit->damage = damage; + hit->kind = splat_kind; + hit->seconds_left = OSRS_HITSPLAT_SECONDS; + if (splat_kind != HITSPLAT_PRAYER_DRAIN) + show_healthbar(presentation, kind, actor_slot); + return; + } + } +} + +static void spawn_hitsplat(FcCombatPresentation *presentation, + FcVisualTargetKind kind, int actor_slot, + float x, float y, float z, int damage) { + spawn_status_splat(presentation, kind, actor_slot, x, y, z, + damage, HITSPLAT_DAMAGE); +} + +static int defer_hitsplat(FcCombatPresentation *presentation, + const FcCombatPresentationContext *context, + const FcRenderHit *hit, + FcVisualTargetKind kind, int actor_slot, + float x, float y, float z) { + if (!hit || hit->attack_style == ATTACK_MELEE) return 0; + FcVisualTargetKind source_kind; + FcVisualTargetKind target_kind; + int source_slot; + int target_slot; + if (hit->target_entity_type == ENTITY_PLAYER) { + if (hit->source_npc_slot < 0) return 0; + source_kind = FC_VISUAL_TARGET_NPC; + source_slot = hit->source_npc_slot; + target_kind = FC_VISUAL_TARGET_PLAYER; + target_slot = 0; + } else if (hit->target_entity_type == ENTITY_NPC) { + if (hit->target_npc_slot < 0) return 0; + source_kind = FC_VISUAL_TARGET_PLAYER; + source_slot = 0; + target_kind = FC_VISUAL_TARGET_NPC; + target_slot = hit->target_npc_slot; + } else { + return 0; + } + VisualProjectile *match = NULL; + for (int i = 0; i < MAX_PROJECTILES; i++) { + VisualProjectile *projectile = &presentation->projectiles[i]; + if (!projectile->active || projectile->has_deferred_hitsplat || + projectile->launch_tick >= context->state->tick || + projectile->source_kind != source_kind || + projectile->source_slot != source_slot || + projectile->target_kind != target_kind || + projectile->target_slot != target_slot || + projectile->attack_style != hit->attack_style) continue; + if (!match || projectile->elapsed > match->elapsed) match = projectile; + } + if (!match) return 0; + match->has_deferred_hitsplat = 1; + match->hitsplat_actor_kind = kind; + match->hitsplat_actor_slot = actor_slot; + match->hitsplat_world_x = x; + match->hitsplat_world_y = y; + match->hitsplat_world_z = z; + match->hitsplat_damage = hit->damage; + return 1; +} + +static int deferred_damage(const FcCombatPresentation *presentation, + FcVisualTargetKind kind, int actor_slot) { + int damage = 0; + if (!presentation) return 0; + for (int i = 0; i < MAX_PROJECTILES; i++) { + const VisualProjectile *projectile = &presentation->projectiles[i]; + if (projectile->active && projectile->has_deferred_hitsplat && + projectile->hitsplat_actor_kind == kind && + projectile->hitsplat_actor_slot == actor_slot) + damage += projectile->hitsplat_damage; + } + return damage; +} + +int fc_combat_presentation_npc_death_deferred( + const FcCombatPresentation *presentation, + const FcState *state, + int npc_slot) { + return presentation && state && npc_slot >= 0 && npc_slot < FC_MAX_NPCS && + state->npcs[npc_slot].is_dead && + deferred_damage(presentation, FC_VISUAL_TARGET_NPC, npc_slot) > 0; +} + +void fc_combat_presentation_deferred_deaths( + const FcCombatPresentation *presentation, + const FcState *state, + unsigned char deferred_deaths[FC_MAX_NPCS]) { + if (!deferred_deaths) return; + for (int i = 0; i < FC_MAX_NPCS; i++) { + deferred_deaths[i] = (unsigned char) + fc_combat_presentation_npc_death_deferred(presentation, state, i); + } +} + +static void ingest_player_attack(FcCombatPresentation *presentation, + const FcCombatPresentationContext *context) { + const FcRenderEvents *events = context->events; + const FcPlayerVisualProfile *profile = context->player_profile; + int sx = events->player_attack_source_x; + int sy = events->player_attack_source_y; + int tx = events->player_attack_target_x; + int ty = events->player_attack_target_y; + int target_size = events->player_attack_target_size; + float source_x = (float)sx + 0.5f; + float source_y = ground_height(context, sx, sy) + + profile->projectile_start_height / 128.0f; + float source_z = -((float)sy + 0.5f); + float target_x = (float)tx + (float)target_size * 0.5f; + float target_y = ground_height(context, tx, ty) + + profile->projectile_end_height / 128.0f; + float target_z = -((float)ty + (float)target_size * 0.5f); + float end_cycle = fc_projectile_profile_end_cycle( + profile->projectile_launch_delay_client_ticks, + profile->projectile_length_adjustment, + profile->projectile_step_multiplier, tile_distance(sx, sy, tx, ty)); + VisualProjectile *projectile = spawn_projectile(presentation, + source_x, source_y, source_z, target_x, target_y, target_z, 0.1f, + profile->projectile_color, profile->projectile_radius, + profile->projectile_travel_spot, profile->projectile_launch_spot, + profile->projectile_impact_spot); + configure_tracking(presentation, context, projectile, + FC_VISUAL_TARGET_PLAYER, 0, FC_VISUAL_TARGET_NPC, + events->player_attack_target_npc_slot, ATTACK_RANGED, + profile->projectile_launch_delay_client_ticks, end_cycle, + profile->projectile_angle, profile->projectile_progress, 1); +} + +static void ingest_npc_attack(FcCombatPresentation *presentation, + const FcCombatPresentationContext *context, + const FcRenderNpcAttack *attack) { + if (!attack->hit_queued || attack->attack_style == ATTACK_MELEE) return; + int source_x = attack->source_x; + int source_y = attack->source_y; + if (attack->source_size > 1) { + if (attack->target_x < attack->source_x) source_x = attack->source_x; + else if (attack->target_x >= attack->source_x + attack->source_size) + source_x = attack->source_x + attack->source_size - 1; + else source_x = attack->target_x; + if (attack->target_y < attack->source_y) source_y = attack->source_y; + else if (attack->target_y >= attack->source_y + attack->source_size) + source_y = attack->source_y + attack->source_size - 1; + else source_y = attack->target_y; + } + float source_ground = ground_height(context, source_x, source_y); + float source_world_x = (float)source_x + 0.5f; + float source_world_y = source_ground + 1.0f + + (float)attack->source_size * 0.3f; + float source_world_z = -((float)source_y + 0.5f); + float target_ground = ground_height( + context, attack->target_x, attack->target_y); + float target_world_x = (float)attack->target_x + 0.5f; + float target_world_y = target_ground + 1.5f; + float target_world_z = -((float)attack->target_y + 0.5f); + Color color = attack->attack_style == ATTACK_MAGIC + ? CLITERAL(Color){255, 104, 36, 235} + : CLITERAL(Color){218, 178, 92, 235}; + float radius = attack->npc_type == NPC_TZTOK_JAD ? 0.3f : 0.15f; + uint32_t travel_spot = 0; + uint32_t launch_spot = 0; + uint32_t impact_spot = 0; + float start_height = -1.0f; + float end_height = -1.0f; + float start_cycle = 0.0f; + float angle = -1.0f; + float length_adjustment = 0.0f; + float progress = -1.0f; + float step_multiplier = 0.0f; + float fixed_end_cycle = -1.0f; + int track_target = 1; + if (attack->npc_type == NPC_TOK_XIL) { + travel_spot = PROJ_TOK_XIL_SPINE; + impact_spot = PROJ_TOK_XIL_IMPACT; + start_height = 296.0f; + end_height = 40.0f; + start_cycle = 32.0f; + angle = 16.0f; + progress = 0.0f; + step_multiplier = 5.0f; + } else if (attack->npc_type == NPC_KET_ZEK) { + travel_spot = PROJ_KET_ZEK_FIRE; + impact_spot = PROJ_KET_ZEK_IMPACT; + start_height = 192.0f; + end_height = 40.0f; + start_cycle = 28.0f; + angle = 16.0f; + length_adjustment = 8.0f; + progress = 0.0f; + step_multiplier = 8.0f; + } else if (attack->npc_type == NPC_TZTOK_JAD && + attack->attack_style == ATTACK_MAGIC) { + launch_spot = PROJ_JAD_MAGIC_LAUNCH; + travel_spot = PROJ_JAD_MAGIC_TRAVEL; + impact_spot = PROJ_JAD_MAGIC_IMPACT; + start_height = 172.0f; + end_height = 124.0f; + start_cycle = 41.0f; + angle = 16.0f; + progress = 64.0f; + step_multiplier = 5.0f; + } else if (attack->npc_type == NPC_TZTOK_JAD && + attack->attack_style == ATTACK_RANGED) { + impact_spot = PROJ_JAD_RANGED_IMPACT; + start_height = 768.0f; + end_height = 52.0f; + angle = 0.0f; + progress = 0.0f; + fixed_end_cycle = 60.0f; + track_target = 0; + } + if (start_height >= 0.0f) + source_world_y = source_ground + start_height / 128.0f; + if (end_height >= 0.0f) + target_world_y = target_ground + end_height / 128.0f; + float end_cycle = fixed_end_cycle >= 0.0f ? fixed_end_cycle + : fc_projectile_profile_end_cycle(start_cycle, length_adjustment, + step_multiplier, tile_distance(source_x, source_y, + attack->target_x, attack->target_y)); + VisualProjectile *projectile = spawn_projectile(presentation, + source_world_x, source_world_y, source_world_z, + target_world_x, target_world_y, target_world_z, + 0.1f, color, radius, travel_spot, launch_spot, impact_spot); + configure_tracking(presentation, context, projectile, + FC_VISUAL_TARGET_NPC, attack->npc_slot, + FC_VISUAL_TARGET_PLAYER, 0, attack->attack_style, + start_cycle, end_cycle, angle, progress, track_target); +} + +void fc_combat_presentation_ingest_tick( + FcCombatPresentation *presentation, + const FcCombatPresentationContext *context) { + if (!presentation || !context || !context->state || !context->events || + !context->player_profile) return; + const FcState *state = context->state; + int player_x = state->player.x; + int player_y = state->player.y; + if (player_x < 0) player_x = 0; + if (player_y < 0) player_y = 0; + if (player_x >= FC_ARENA_WIDTH) player_x = FC_ARENA_WIDTH - 1; + if (player_y >= FC_ARENA_HEIGHT) player_y = FC_ARENA_HEIGHT - 1; + float player_ground = ground_height(context, player_x, player_y); + float player_world_x = (float)state->player.x + 0.5f; + float player_world_z = -((float)state->player.y + 0.5f); + if (state->tz_kih_prayer_drain_this_tick > 0) { + spawn_status_splat(presentation, FC_VISUAL_TARGET_PLAYER, 0, + player_world_x + 0.3f, player_ground + 3.0f, player_world_z, + state->tz_kih_prayer_drain_this_tick, HITSPLAT_PRAYER_DRAIN); + } + if (context->events->player_attack_fired) + ingest_player_attack(presentation, context); + for (int i = 0; i < context->events->hit_count; i++) { + const FcRenderHit *hit = &context->events->hits[i]; + if (hit->target_entity_type == ENTITY_PLAYER) { + if (!defer_hitsplat(presentation, context, hit, + FC_VISUAL_TARGET_PLAYER, 0, player_world_x, + player_ground + 2.5f, player_world_z)) { + spawn_hitsplat(presentation, FC_VISUAL_TARGET_PLAYER, 0, + player_world_x, player_ground + 2.5f, + player_world_z, hit->damage); + } + } else if (hit->target_entity_type == ENTITY_NPC && + hit->target_npc_slot >= 0 && + hit->target_npc_slot < FC_MAX_NPCS) { + const FcNpc *target = &state->npcs[hit->target_npc_slot]; + float ground = ground_height(context, target->x, target->y); + float x = (float)target->x + (float)target->size * 0.5f; + float z = -((float)target->y + (float)target->size * 0.5f); + float y = ground + 1.0f + (float)target->size * 0.5f; + if (!defer_hitsplat(presentation, context, hit, + FC_VISUAL_TARGET_NPC, hit->target_npc_slot, x, y, z)) { + spawn_hitsplat(presentation, FC_VISUAL_TARGET_NPC, + hit->target_npc_slot, x, y, z, hit->damage); + } + } + } + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc *npc = &state->npcs[i]; + if (npc->healing_received_this_tick > 0) { + float ground = ground_height(context, npc->x, npc->y); + float x = (float)npc->x + (float)npc->size * 0.5f; + float z = -((float)npc->y + (float)npc->size * 0.5f); + float y = ground + 1.4f + (float)npc->size * 0.5f; + spawn_status_splat(presentation, FC_VISUAL_TARGET_NPC, i, + x, y, z, npc->healing_received_this_tick, HITSPLAT_HEAL); + } + } + for (int i = 0; i < context->events->npc_attack_count; i++) { + const FcRenderNpcAttack *attack = &context->events->npc_attacks[i]; + if (attack->npc_slot >= 0 && attack->npc_slot < FC_MAX_NPCS) + ingest_npc_attack(presentation, context, attack); + } +} + +static void refresh_actor_points(const FcCombatPresentationContext *context, + VisualProjectile *projectile, + int refresh_source) { + float x; + float y; + float z; + if (refresh_source && actor_world_point(context, projectile->source_kind, + projectile->source_slot, &x, &y, &z)) { + projectile->src_x = x; + projectile->src_y = y + projectile->source_y_offset; + projectile->src_z = z; + } + if (projectile->track_target && actor_world_point(context, + projectile->target_kind, projectile->target_slot, &x, &y, &z)) { + projectile->dst_x = x; + projectile->dst_y = y + projectile->target_y_offset; + projectile->dst_z = z; + } +} + +static int update_projectile(const FcCombatPresentationContext *context, + VisualProjectile *projectile, float dt) { + float end = projectile->elapsed + dt; + if (end > projectile->total_time) end = projectile->total_time; + refresh_actor_points(context, projectile, !projectile->launched); + if (end < projectile->launch_delay) { + projectile->x = projectile->src_x; + projectile->y = projectile->src_y; + projectile->z = projectile->src_z; + projectile->elapsed = end; + return 0; + } + projectile->launched = 1; + float duration = projectile->total_time - projectile->launch_delay; + if (duration < 0.001f) duration = 0.001f; + FcProjectilePath path = { + .source_x = projectile->src_x, + .source_y = projectile->src_y, + .source_z = projectile->src_z, + .target_x = projectile->dst_x, + .target_y = projectile->dst_y, + .target_z = projectile->dst_z, + .duration = duration, + .angle = projectile->projectile_angle, + .progress = projectile->projectile_progress / 128.0f, + }; + FcProjectileSample sample = {0}; + if (fc_projectile_path_sample(&path, end - projectile->launch_delay, + &sample)) { + projectile->x = sample.x; + projectile->y = sample.y; + projectile->z = sample.z; + projectile->velocity_x = sample.velocity_x; + projectile->velocity_y = sample.velocity_y; + projectile->velocity_z = sample.velocity_z; + } + projectile->elapsed = end; + if (end >= projectile->total_time) { + projectile->x = projectile->dst_x; + projectile->y = projectile->dst_y; + projectile->z = projectile->dst_z; + return 1; + } + return 0; +} + +void fc_combat_presentation_update( + FcCombatPresentation *presentation, + const FcCombatPresentationContext *context, + float dt) { + if (!presentation || !context || dt <= 0.0f) return; + for (int i = 0; i < MAX_HITSPLATS; i++) { + if (presentation->hitsplats[i].active) { + presentation->hitsplats[i].seconds_left -= dt; + if (presentation->hitsplats[i].seconds_left <= 0.0f) + presentation->hitsplats[i].active = 0; + } + } + if (presentation->player_healthbar_timer > 0.0f) { + presentation->player_healthbar_timer -= dt; + if (presentation->player_healthbar_timer < 0.0f) + presentation->player_healthbar_timer = 0.0f; + } + for (int i = 0; i < FC_MAX_NPCS; i++) { + if (presentation->npc_healthbar_timers[i] > 0.0f) { + presentation->npc_healthbar_timers[i] -= dt; + if (presentation->npc_healthbar_timers[i] < 0.0f) + presentation->npc_healthbar_timers[i] = 0.0f; + } + } + for (int i = 0; i < MAX_PROJECTILES; i++) { + VisualProjectile *projectile = &presentation->projectiles[i]; + if (!projectile->active || !update_projectile(context, projectile, dt)) + continue; + float duration = effect_duration(presentation, context, + projectile->impact_spot_id, 90.0f); + spawn_effect(presentation, projectile->impact_spot_id, + projectile->x, projectile->y, projectile->z, duration, + projectile->color, projectile->radius * 1.4f, 0.0f); + if (projectile->has_deferred_hitsplat) { + spawn_hitsplat(presentation, projectile->hitsplat_actor_kind, + projectile->hitsplat_actor_slot, projectile->hitsplat_world_x, + projectile->hitsplat_world_y, projectile->hitsplat_world_z, + projectile->hitsplat_damage); + } + free_projectile(projectile); + } + for (int i = 0; i < MAX_VISUAL_EFFECTS; i++) { + VisualEffect *effect = &presentation->effects[i]; + if (effect->active) { + effect->elapsed += dt; + if (effect->elapsed >= effect->total_time) free_effect(effect); + } + } +} + +static NpcModelEntry *projectile_model_for_spot( + FcCombatPresentation *presentation, uint32_t spot_id, + const SpotAnimDef **out_spot) { + const SpotAnimDef *spot = presentation && presentation->spotanims + ? spotanim_find(presentation->spotanims, (int)spot_id) : NULL; + if (out_spot) *out_spot = spot; + if (!presentation || spot_id == 0 || !presentation->projectile_models) + return NULL; + NpcModelEntry *entry = fc_npc_model_find(presentation->projectile_models, + PROJ_SPOTANIM_MODEL_BASE + spot_id); + if (!entry && spot && spot->model_id >= 0) + entry = fc_npc_model_find(presentation->projectile_models, + (uint32_t)spot->model_id); + if (!entry) + entry = fc_npc_model_find(presentation->projectile_models, spot_id); + return entry && entry->loaded ? entry : NULL; +} + +void fc_combat_presentation_draw_world( + FcCombatPresentation *presentation, + const FcCombatPresentationContext *context, + float dt) { + if (!presentation || !context) return; + float animation_dt = fc_actor_animation_scaled_dt(context->tps, dt); + for (int i = 0; i < MAX_PROJECTILES; i++) { + VisualProjectile *projectile = &presentation->projectiles[i]; + if (!projectile->active || projectile->spot_id == 0 || + !projectile->launched) continue; + const SpotAnimDef *spot = NULL; + NpcModelEntry *entry = projectile_model_for_spot( + presentation, projectile->spot_id, &spot); + if (entry) { + float horizontal_speed = sqrtf( + projectile->velocity_x * projectile->velocity_x + + projectile->velocity_z * projectile->velocity_z); + float angle = atan2f(projectile->velocity_x, + projectile->velocity_z) * RAD2DEG; + float pitch = spot ? 0.0f + : atan2f(projectile->velocity_y, horizontal_speed); + float scale_xy = spot && spot->resize_xy > 0 + ? (float)spot->resize_xy / 128.0f : 1.0f; + float scale_z = spot && spot->resize_z > 0 + ? (float)spot->resize_z / 128.0f : 1.0f; + if (spot) angle += (float)spot->rotation; + if (spot && spot->animation_id >= 0) { + fc_model_animation_update(entry, context->anim_cache, + &projectile->anim_state, &projectile->anim_sequence, + &projectile->anim_frame, &projectile->anim_timer, + spot->animation_id, animation_dt, 0.0f); + } + Quaternion yaw = QuaternionFromAxisAngle( + (Vector3){0, 1, 0}, angle * DEG2RAD); + Quaternion tilt = QuaternionFromAxisAngle( + (Vector3){1, 0, 0}, -pitch); + Quaternion rotation = QuaternionMultiply(yaw, tilt); + Vector3 axis = {0, 1, 0}; + float rotation_angle = 0.0f; + QuaternionToAxisAngle(rotation, &axis, &rotation_angle); + rlDisableBackfaceCulling(); + DrawModelEx(entry->model, + (Vector3){projectile->x, projectile->y, projectile->z}, + axis, rotation_angle * RAD2DEG, + (Vector3){scale_xy, scale_z, scale_xy}, WHITE); + rlEnableBackfaceCulling(); + } else if (projectile->radius > 0.0f) { + DrawSphere((Vector3){projectile->x, projectile->y, projectile->z}, + projectile->radius, projectile->color); + } + } + for (int i = 0; i < MAX_VISUAL_EFFECTS; i++) { + VisualEffect *effect = &presentation->effects[i]; + if (!effect->active) continue; + float x = effect->x; + float y = effect->y; + float z = effect->z; + if (effect->attached) { + float actor_x; + float actor_y; + float actor_z; + if (actor_world_point(context, effect->attached_kind, + effect->attached_slot, &actor_x, &actor_y, &actor_z)) { + x = actor_x; + y = actor_y + effect->attached_y_offset; + z = actor_z; + } + } + float yaw = effect->yaw_degrees; + if (effect->face_kind != FC_VISUAL_TARGET_NONE) { + float target_x; + float target_y; + float target_z; + if (actor_world_point(context, effect->face_kind, + effect->face_slot, &target_x, &target_y, &target_z)) { + (void)target_y; + yaw = atan2f(target_x - x, target_z - z) * RAD2DEG; + } + } + const SpotAnimDef *spot = NULL; + NpcModelEntry *entry = projectile_model_for_spot( + presentation, effect->spot_id, &spot); + if (entry) { + float scale_xy = spot && spot->resize_xy > 0 + ? (float)spot->resize_xy / 128.0f : 1.0f; + float scale_z = spot && spot->resize_z > 0 + ? (float)spot->resize_z / 128.0f : 1.0f; + if (spot && spot->animation_id >= 0) { + fc_model_animation_update(entry, context->anim_cache, + &effect->anim_state, &effect->anim_sequence, + &effect->anim_frame, &effect->anim_timer, + spot->animation_id, animation_dt, 0.0f); + } + rlDisableBackfaceCulling(); + DrawModelEx(entry->model, (Vector3){x, y, z}, + (Vector3){0, 1, 0}, + yaw + (spot ? (float)spot->rotation : 0.0f), + (Vector3){scale_xy, scale_z, scale_xy}, WHITE); + rlEnableBackfaceCulling(); + } else { + DrawSphere((Vector3){x, y, z}, effect->radius, effect->color); + } + } +} + +static const FcRenderEntity *find_actor( + const FcCombatPresentationDrawContext *context, + int actor_kind, int actor_slot) { + for (int i = 0; i < context->entity_count; i++) { + const FcRenderEntity *entity = &context->entities[i]; + if (actor_kind == FC_VISUAL_TARGET_PLAYER && + entity->entity_type == ENTITY_PLAYER) return entity; + if (actor_kind == FC_VISUAL_TARGET_NPC && + entity->entity_type == ENTITY_NPC && + entity->npc_slot == actor_slot) return entity; + } + return NULL; +} + +static FcVisualPose entity_pose(const FcCombatPresentationDrawContext *context, + const FcRenderEntity *entity) { + return entity->entity_type == ENTITY_PLAYER + ? fc_visual_scene_player_pose(context->presentation.scene) + : fc_visual_scene_npc_pose(context->presentation.scene, + entity->npc_slot); +} + +static float entity_model_top(const FcCombatPresentationDrawContext *context, + const FcRenderEntity *entity) { + Model *model = NULL; + if (entity->entity_type == ENTITY_PLAYER) { + NpcModelEntry *entry = fc_actor_player_model_entry( + context->player_models, context->active_loadout); + if (entry) model = &entry->model; + } else if (context->npc_models) { + NpcModelEntry *entry = fc_npc_model_find(context->npc_models, + fc_npc_type_to_model_id(entity->npc_type)); + if (entry && entry->loaded) model = &entry->model; + } + if (model) { + BoundingBox bounds = GetModelBoundingBox(*model); + if (bounds.max.y > 0.1f && bounds.max.y < 20.0f) return bounds.max.y; + } + return entity->entity_type == ENTITY_PLAYER + ? 2.0f : 1.3f + (float)entity->size * 0.5f; +} + +static Vector3 overlay_anchor(const FcCombatPresentationDrawContext *context, + const FcRenderEntity *entity, + float height_fraction, float extra_height) { + FcVisualPose pose = entity_pose(context, entity); + return (Vector3){ + pose.x, + smooth_ground_height(&context->presentation, pose.x, pose.y) + + entity_model_top(context, entity) * height_fraction + extra_height, + -pose.y, + }; +} + +static Texture2D hitsplat_texture(const FcCombatPresentation *presentation, + const Hitsplat *hit) { + if (hit->kind == HITSPLAT_HEAL) return presentation->hitsplat_heal_texture; + if (hit->kind == HITSPLAT_PRAYER_DRAIN) + return presentation->hitsplat_prayer_drain_texture; + return hit->damage > 0 ? presentation->hitsplat_damage_texture + : presentation->hitsplat_zero_texture; +} + +void fc_combat_presentation_draw_healthbars( + const FcCombatPresentation *presentation, + const FcCombatPresentationDrawContext *context) { + if (!presentation || !context || !context->entities || + !context->presentation.scene) return; + for (int i = 0; i < context->entity_count; i++) { + const FcRenderEntity *entity = &context->entities[i]; + float timer = entity->entity_type == ENTITY_PLAYER + ? presentation->player_healthbar_timer + : entity->npc_slot >= 0 && entity->npc_slot < FC_MAX_NPCS + ? presentation->npc_healthbar_timers[entity->npc_slot] : 0.0f; + if (timer <= 0.0f || entity->max_hp <= 0) continue; + Vector2 screen = GetWorldToScreen( + overlay_anchor(context, entity, 1.0f, 0.12f), context->camera); + if (screen.x < -40.0f || screen.x > GetScreenWidth() + 40.0f || + screen.y < -20.0f || screen.y > GetScreenHeight() + 20.0f) continue; + FcVisualTargetKind kind = entity->entity_type == ENTITY_PLAYER + ? FC_VISUAL_TARGET_PLAYER : FC_VISUAL_TARGET_NPC; + int slot = entity->entity_type == ENTITY_PLAYER ? 0 : entity->npc_slot; + int visible_hp = entity->current_hp + deferred_damage(presentation, + kind, slot); + if (visible_hp > entity->max_hp) visible_hp = entity->max_hp; + int fill = visible_hp * 30 / entity->max_hp; + if (fill < 0) fill = 0; + if (fill > 30) fill = 30; + int x = (int)roundf(screen.x) - 15; + int y = (int)roundf(screen.y) - 3; + if (presentation->healthbar_empty_texture.id > 0 && + presentation->healthbar_full_texture.id > 0) { + DrawTexture(presentation->healthbar_empty_texture, x, y, WHITE); + if (fill > 0) { + Rectangle source = {0.0f, 0.0f, (float)fill, 5.0f}; + DrawTextureRec(presentation->healthbar_full_texture, source, + (Vector2){(float)x, (float)y}, WHITE); + } + } else { + DrawRectangle(x, y, 30, 5, RED); + DrawRectangle(x, y, fill, 5, GREEN); + } + } +} + +void fc_combat_presentation_draw_hitsplats( + const FcCombatPresentation *presentation, + const FcCombatPresentationDrawContext *context) { + if (!presentation || !context || !context->entities || + !context->presentation.scene) return; + static const int slot_x[4] = {0, 0, -15, 15}; + static const int slot_y[4] = {0, -20, -10, -10}; + for (int i = 0; i < MAX_HITSPLATS; i++) { + const Hitsplat *hit = &presentation->hitsplats[i]; + if (!hit->active) continue; + Vector3 world = {hit->world_x, hit->world_y, hit->world_z}; + const FcRenderEntity *entity = find_actor(context, hit->actor_kind, + hit->actor_slot); + if (entity) world = overlay_anchor(context, entity, 0.5f, 0.0f); + Vector2 screen = GetWorldToScreen(world, context->camera); + if (screen.x < -50.0f || screen.x > GetScreenWidth() + 50.0f || + screen.y < -50.0f || screen.y > GetScreenHeight() + 50.0f) continue; + int slot = hit->overlay_slot; + if (slot < 0 || slot >= 4) slot = 0; + int center_x = (int)roundf(screen.x) + slot_x[slot]; + int center_y = (int)roundf(screen.y) + slot_y[slot]; + Texture2D texture = hitsplat_texture(presentation, hit); + if (texture.id > 0) + DrawTexture(texture, center_x - 12, center_y - 12, WHITE); + int value = hit->kind == HITSPLAT_DAMAGE + ? hit->damage / 10 : (hit->damage + 9) / 10; + char text[16]; + snprintf(text, sizeof(text), "%d", value); + const float font_size = 12.0f; + Font font = runec_ui_font_for_size(context->ui_assets, font_size); + Vector2 measured = MeasureTextEx(font, text, font_size, 0.0f); + float text_x = floorf((float)center_x - 1.0f - measured.x * 0.5f); + float text_y = floorf((float)center_y - 6.0f); + DrawTextEx(font, text, (Vector2){text_x + 1.0f, text_y + 1.0f}, + font_size, 0.0f, BLACK); + DrawTextEx(font, text, (Vector2){text_x, text_y}, + font_size, 0.0f, WHITE); + } +} + +#undef MAX_HITSPLATS +#undef MAX_PROJECTILES +#undef MAX_VISUAL_EFFECTS +#undef OSRS_HITSPLAT_SECONDS +#undef OSRS_HEALTHBAR_SECONDS +#undef POLICY_REPLAY_BASE_TPS +#undef PROJ_JAD_MAGIC_LAUNCH +#undef PROJ_TOK_XIL_SPINE +#undef PROJ_TOK_XIL_IMPACT +#undef PROJ_KET_ZEK_FIRE +#undef PROJ_KET_ZEK_IMPACT +#undef PROJ_JAD_MAGIC_TRAVEL +#undef PROJ_JAD_MAGIC_IMPACT +#undef PROJ_JAD_RANGED_IMPACT +#undef PROJ_SPOTANIM_MODEL_BASE + +/* Debug Overlay */ +/* + * fc_debug_overlay.c — viewer debug tooling and overlays. + * + * Separate from viewer.c to isolate debug visualization from core viewer code. + * All functions are read-only — they never modify FcState or ViewerState. + * + * Two entry points called from viewer.c: + * debug_overlay_3d() — called inside BeginMode3D/EndMode3D (tiles, rays, ranges) + * debug_overlay_screen() — called after EndMode3D (screen-space overlays) + * + * Toggle with 'O' key (debug overlay master toggle). + * Sub-toggles cycle with Shift+O or number keys in debug mode. + */ + +#include "raylib.h" +#include "rlgl.h" +#include +#include +#include +#include +#include + +/* ======================================================================== */ +/* Event log ring buffer */ +/* ======================================================================== */ + +#define DBG_LOG_MAX_ENTRIES 128 +#define DBG_LOG_MAX_MSG 80 + +typedef struct { + char entries[DBG_LOG_MAX_ENTRIES][DBG_LOG_MAX_MSG]; + int tick[DBG_LOG_MAX_ENTRIES]; /* game tick when event occurred */ + Color color[DBG_LOG_MAX_ENTRIES]; /* display color per entry */ + int head; /* next write position */ + int count; /* total entries (capped at MAX) */ + int scroll_offset; /* for scrollable display */ +} DbgEventLog; + +static DbgEventLog g_dbg_log = {0}; + +void dbg_log_clear(void) { + g_dbg_log.head = 0; + g_dbg_log.count = 0; + g_dbg_log.scroll_offset = 0; +} + +static void dbg_log_event(int game_tick, Color c, const char* fmt, ...) { + int idx = g_dbg_log.head; + g_dbg_log.tick[idx] = game_tick; + g_dbg_log.color[idx] = c; + + va_list args; + va_start(args, fmt); + vsnprintf(g_dbg_log.entries[idx], DBG_LOG_MAX_MSG, fmt, args); + va_end(args); + + g_dbg_log.head = (g_dbg_log.head + 1) % DBG_LOG_MAX_ENTRIES; + if (g_dbg_log.count < DBG_LOG_MAX_ENTRIES) g_dbg_log.count++; +} + +/* Call once per tick to auto-generate events from state changes. + * Reads current state and emits relevant log entries. */ +void dbg_log_tick(const FcState* state) { + int t = state->tick; + const FcPlayer* p = &state->player; + static const char* npc_names[] = {"?","Tz-Kih","Tz-Kek","Kek-Sm","Tok-Xil", + "MejKot","Ket-Zek","Jad","HurKot"}; + float rwd[FC_REWARD_FEATURES]; + + fc_write_reward_features(state, rwd); + + /* Player took damage */ + if (p->damage_taken_this_tick > 0) { + dbg_log_event(t, CLITERAL(Color){255,120,120,255}, + "Player took %d damage", p->damage_taken_this_tick / 10); + } + if (state->tz_kih_prayer_drain_this_tick > 0) { + dbg_log_event(t, CLITERAL(Color){100,190,255,255}, + "Tz-Kih drained %d Prayer", + (state->tz_kih_prayer_drain_this_tick + 9) / 10); + } + + /* Player ate food */ + if (p->food_eaten_this_tick) { + dbg_log_event(t, CLITERAL(Color){180,255,180,255}, + "Ate shark (+20 HP) [%d left]", p->sharks_remaining); + } + + /* Player drank potion */ + if (p->potion_used_this_tick) { + dbg_log_event(t, CLITERAL(Color){120,180,255,255}, + "Drank ppot (+17 pray) [%d doses left]", p->prayer_doses_remaining); + } + + /* Player switched prayer */ + if (p->prayer_changed_this_tick) { + static const char* pray_names[] = {"OFF","Prot Melee","Prot Range","Prot Magic"}; + dbg_log_event(t, CLITERAL(Color){255,255,100,255}, + "Prayer -> %s", pray_names[p->prayer]); + } + + /* Player attack attempt */ + if (rwd[FC_RWD_ATTACK_ATTEMPT] > 0.0f && p->attack_target_idx >= 0) { + const FcNpc* tgt = &state->npcs[p->attack_target_idx]; + const char* nm = (tgt->npc_type > 0 && tgt->npc_type < 9) ? npc_names[tgt->npc_type] : "?"; + dbg_log_event(t, CLITERAL(Color){200,200,200,255}, + "Attacked %s [%d]", nm, p->attack_target_idx); + } + + /* NPC events */ + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc* n = &state->npcs[i]; + + /* NPC damage taken */ + if (n->damage_taken_this_tick > 0) { + const char* nm = (n->npc_type > 0 && n->npc_type < 9) ? npc_names[n->npc_type] : "?"; + dbg_log_event(t, CLITERAL(Color){255,200,100,255}, + "%s[%d] took %d dmg (%d HP left)", + nm, i, n->damage_taken_this_tick / 10, n->current_hp / 10); + } + if (n->healing_received_this_tick > 0) { + const char* nm = (n->npc_type > 0 && n->npc_type < 9) ? npc_names[n->npc_type] : "?"; + dbg_log_event(t, CLITERAL(Color){120,255,140,255}, + "%s[%d] healed %d HP", + nm, i, (n->healing_received_this_tick + 9) / 10); + } + + /* NPC died */ + if (n->died_this_tick) { + const char* nm = (n->npc_type > 0 && n->npc_type < 9) ? npc_names[n->npc_type] : "?"; + dbg_log_event(t, CLITERAL(Color){255,80,80,255}, + "%s[%d] KILLED", nm, i); + } + + } + + /* Movement — log new route destination (walk-to-tile or click-to-move) */ + { + static int prev_route_dest_x = -1, prev_route_dest_y = -1; + if (p->route_len > 0 && p->route_idx < p->route_len) { + int dx = p->route_x[p->route_len - 1]; + int dy = p->route_y[p->route_len - 1]; + if (dx != prev_route_dest_x || dy != prev_route_dest_y) { + dbg_log_event(t, CLITERAL(Color){180,220,255,255}, + "Move to tile (%d,%d) [%d steps]", dx, dy, p->route_len - p->route_idx); + prev_route_dest_x = dx; + prev_route_dest_y = dy; + } + } else { + prev_route_dest_x = -1; + prev_route_dest_y = -1; + } + } + + /* Wave events */ + if (state->wave_just_cleared) { + dbg_log_event(t, CLITERAL(Color){100,255,255,255}, + "Wave %d CLEARED — advancing", state->current_wave - 1); + } + + /* Jad killed */ + if (state->jad_killed) { + dbg_log_event(t, CLITERAL(Color){255,215,0,255}, "*** JAD DEFEATED ***"); + } + + /* Player death */ + if (state->terminal == TERMINAL_PLAYER_DEATH && p->current_hp <= 0) { + dbg_log_event(t, CLITERAL(Color){255,0,0,255}, "*** PLAYER DIED ***"); + } + + /* Cave complete */ + if (state->terminal == TERMINAL_CAVE_COMPLETE) { + dbg_log_event(t, CLITERAL(Color){0,255,0,255}, "*** CAVE COMPLETE — FIRE CAPE ***"); + } +} + +/* Colors */ +#define DBG_COL_WALK CLITERAL(Color){ 30, 180, 30, 40 } +#define DBG_COL_BLOCK CLITERAL(Color){ 200, 30, 30, 60 } +#define DBG_COL_LOS_OK CLITERAL(Color){ 30, 255, 30, 200 } +#define DBG_COL_LOS_FAIL CLITERAL(Color){ 255, 30, 30, 200 } +#define DBG_COL_PATH CLITERAL(Color){ 255, 255, 0, 160 } +#define DBG_COL_RANGE CLITERAL(Color){ 100, 200, 255, 120 } +#define DBG_COL_LABEL CLITERAL(Color){ 200, 200, 200, 255 } +#define DBG_COL_VALUE CLITERAL(Color){ 255, 255, 100, 255 } +#define DBG_COL_GOOD CLITERAL(Color){ 100, 255, 100, 255 } +#define DBG_COL_BAD CLITERAL(Color){ 255, 100, 100, 255 } +#define DBG_COL_DIM CLITERAL(Color){ 120, 120, 120, 255 } + +static const char* dbg_basename(const char* path) { + const char* slash = strrchr(path, '/'); + return slash ? slash + 1 : path; +} + +static Color dbg_reward_color(float value) { + if (value > 0.0001f) return DBG_COL_GOOD; + if (value < -0.0001f) return DBG_COL_BAD; + return DBG_COL_DIM; +} + +/* ======================================================================== */ +/* A. Collision / LOS / Path / Range overlays (3D) */ +/* ======================================================================== */ + +/* Draw walkable/blocked tile overlay */ +static void dbg_draw_collision(const FcState* state) { + for (int tx = 0; tx < FC_ARENA_WIDTH; tx++) { + for (int ty = 0; ty < FC_ARENA_HEIGHT; ty++) { + Color c = state->walkable[tx][ty] ? DBG_COL_WALK : DBG_COL_BLOCK; + DrawCube((Vector3){tx + 0.5f, 0.02f, -(ty + 0.5f)}, 0.9f, 0.02f, 0.9f, c); + } + } +} + +/* Master 3D overlay — called inside BeginMode3D/EndMode3D. + * Only collision tiles use 3D (they work with depth test disabled). */ +void debug_overlay_3d(const FcState* state, int dbg_flags) { + rlDisableDepthTest(); + if (dbg_flags & DBG_COLLISION) dbg_draw_collision(state); + rlEnableDepthTest(); +} + +/* ======================================================================== */ +/* 2D screen-space overlays (LOS, path, range) — drawn after EndMode3D */ +/* Uses GetWorldToScreen() projection so nothing can occlude them. */ +/* ======================================================================== */ + +static Vector2 dbg_tile_to_screen(int tx, int ty, Camera3D cam) { + Vector3 w = { tx + 0.5f, 0.5f, -(ty + 0.5f) }; + return GetWorldToScreen(w, cam); +} + +/* LOS rays — 2D projected lines from player to NPCs */ +static void dbg_draw_los_2d(const FcState* state, Camera3D cam) { + const FcPlayer* p = &state->player; + Vector2 ps = dbg_tile_to_screen(p->x, p->y, cam); + + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc* n = &state->npcs[i]; + if (!n->active || n->is_dead) continue; + + int ncx = n->x + n->size / 2; + int ncy = n->y + n->size / 2; + Vector2 ns = dbg_tile_to_screen(ncx, ncy, cam); + + int has_los = fc_has_los_between_areas( + p->x, p->y, 1, n->x, n->y, n->size, state->los_flags); + Color c = has_los ? DBG_COL_LOS_OK : DBG_COL_LOS_FAIL; + DrawLineEx(ps, ns, 3.0f, c); + DrawCircleV(ns, 6.0f, c); + } +} + +/* Path visualization — 2D projected dots and lines along route */ +static void dbg_draw_path_2d(const FcState* state, Camera3D cam) { + const FcPlayer* p = &state->player; + if (p->route_idx >= p->route_len) return; + + Vector2 prev = {0}; + for (int i = p->route_idx; i < p->route_len; i++) { + Vector2 s = dbg_tile_to_screen(p->route_x[i], p->route_y[i], cam); + DrawCircleV(s, 4.0f, DBG_COL_PATH); + if (i > p->route_idx) { + DrawLineEx(prev, s, 2.0f, DBG_COL_PATH); + } + prev = s; + } + + /* Destination marker — larger circle */ + if (p->route_len > 0) { + Vector2 dest = dbg_tile_to_screen( + p->route_x[p->route_len - 1], p->route_y[p->route_len - 1], cam); + DrawCircleV(dest, 8.0f, DBG_COL_PATH); + DrawCircleLinesV(dest, 12.0f, DBG_COL_PATH); + } +} + +/* Attack range ring — 2D projected Chebyshev boundary */ +static void dbg_draw_range_2d(const FcState* state, Camera3D cam) { + const FcPlayer* p = &state->player; + int range = 7; + + for (int dx = -range; dx <= range; dx++) { + for (int dy = -range; dy <= range; dy++) { + int dist = (abs(dx) > abs(dy)) ? abs(dx) : abs(dy); + if (dist == range) { + int tx = p->x + dx; + int ty = p->y + dy; + if (tx >= 0 && tx < FC_ARENA_WIDTH && ty >= 0 && ty < FC_ARENA_HEIGHT) { + Vector2 s = dbg_tile_to_screen(tx, ty, cam); + DrawCircleV(s, 3.0f, DBG_COL_RANGE); + } + } + } + } +} + +static void dbg_draw_run_energy(const FcState* state) { + int energy = state->player.run_energy; + if (energy < 0) energy = 0; + if (energy > FC_RUN_ENERGY_MAX) energy = FC_RUN_ENERGY_MAX; + + char text[32]; + snprintf(text, sizeof(text), "Run energy: %d.%02d%%", + energy / 100, energy % 100); + int width = fc_osrs_measure_text(text, 9); + DrawRectangle(6, 6, width + 10, 18, + CLITERAL(Color){20, 18, 14, 210}); + DrawRectangleLines(6, 6, width + 10, 18, + CLITERAL(Color){120, 110, 90, 255}); + fc_osrs_draw_text(text, 11, 11, 9, DBG_COL_VALUE); +} + +/* Master 2D overlays for LOS/path/range — called after EndMode3D */ +void debug_overlay_screen(const FcState* state, Camera3D cam, int dbg_flags) { + if (dbg_flags & DBG_LOS) dbg_draw_los_2d(state, cam); + if (dbg_flags & DBG_PATH) dbg_draw_path_2d(state, cam); + if (dbg_flags & DBG_RANGE) dbg_draw_range_2d(state, cam); + dbg_draw_run_energy(state); +} + +void dbg_draw_prayer_window_indicator(Vector3 world_anchor, Camera3D cam) { + Vector2 screen = GetWorldToScreen(world_anchor, cam); + /* Keep the marker visibly separate from the centered overhead HP bar. */ + screen.x += 24.0f; + screen.y -= 12.0f; + int screen_width = GetScreenWidth(); + int screen_height = GetScreenHeight(); + if (screen.x < -20.0f || screen.x > (float)screen_width + 20.0f || + screen.y < -20.0f || screen.y > (float)screen_height + 20.0f) { + return; + } + + DrawCircleV(screen, 15.0f, CLITERAL(Color){70, 255, 100, 70}); + DrawCircleV(screen, 9.0f, CLITERAL(Color){70, 255, 100, 250}); + DrawCircleLines((int)screen.x, (int)screen.y, 10.0f, WHITE); +} + +/* ======================================================================== */ +/* Compact debug tabs (usable in any viewer panel) */ +/* ======================================================================== */ + +/* Draw debug info as a tabbed panel section. + * px = panel X, x = content X, by = Y start position, pw = panel width. + * dbg_tab: 0=player, 1=obs, 2=mask, 3=reward, 4=log. + * draw_tabs controls whether this helper renders its own tab selector. + * content_height limits the log viewport; zero keeps the legacy 20 rows. + * Returns end Y position. */ +int dbg_draw_panel_tabs(const FcState* state, + const FcRewardBreakdown* reward_breakdown, + const FcRewardRuntime* reward_runtime, + int reward_config_loaded, + const char* reward_config_path, + int px, int x, int by, int pw, int dbg_tab, + int draw_tabs, int content_height) { + char buf[256]; + int lh = 14; + + if (draw_tabs) { + DrawLine(px+4, by, px+pw-4, by, + CLITERAL(Color){42,36,28,255}); + by += 2; + static const char* dtab_labels[] = { + "Player", "Obs", "Mask", "Reward", "Log" + }; + int num_dtabs = 5; + int dtab_w = (pw - 12) / num_dtabs; + int dtab_h = 16; + for (int t = 0; t < num_dtabs; t++) { + int tx = px + 4 + t * dtab_w; + int selected = (t == dbg_tab); + Color bg = selected ? CLITERAL(Color){82,73,61,255} + : CLITERAL(Color){42,36,28,255}; + DrawRectangle(tx, by, dtab_w, dtab_h, bg); + Color tc = selected ? DBG_COL_VALUE : DBG_COL_DIM; + int tw = fc_osrs_measure_text(dtab_labels[t], 8); + fc_osrs_draw_text(dtab_labels[t], tx + (dtab_w - tw) / 2, + by + 4, 8, tc); + if (selected) + DrawLine(tx+2, by+dtab_h-1, tx+dtab_w-2, + by+dtab_h-1, DBG_COL_VALUE); + } + by += dtab_h + 3; + } + + /* Tab content */ + if (dbg_tab == 0) { + /* Player state */ + const FcPlayer* p = &state->player; + static const char* pray_str[] = { "OFF", "Melee", "Range", "Magic" }; + + snprintf(buf, sizeof(buf), "Pos:(%d,%d) Face:%.0f", p->x, p->y, p->facing_angle); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "HP:%d/%d Pray:%d/%d", + p->current_hp/10, p->max_hp/10, p->current_prayer/10, p->max_prayer/10); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "Tmr atk:%d fd:%d pot:%d", + p->attack_timer, p->food_timer, p->potion_timer); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "Prayer:%s Drain:%d Bonus:+%d", + pray_str[p->prayer], p->prayer_drain_counter, p->prayer_bonus); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "Shark:%d Dose:%d Ammo:%d", + p->sharks_remaining, p->prayer_doses_remaining, p->ammo_count); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "Tgt:%d Appr:%d Route:%d/%d", + p->attack_target_idx, p->approach_target, p->route_idx, p->route_len); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "Hits:%d Run:%d Regen:%d", + p->num_pending_hits, p->is_running, p->hp_regen_counter); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + + /* Active NPCs compact list */ + by += 2; + static const char* npc_names[] = {"?","Kih","Kek","KSm","Xil","Mej","Zek","Jad","Hur"}; + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc* n = &state->npcs[i]; + if (!n->active) continue; + const char* nm = (n->npc_type>0 && n->npc_type<9) ? npc_names[n->npc_type] : "?"; + snprintf(buf, sizeof(buf), "[%d]%s hp:%d atk:%d/%d", + i, nm, n->current_hp/10, n->attack_timer, n->attack_speed); + Color c = n->is_dead ? DBG_COL_BAD : DBG_COL_DIM; + fc_osrs_draw_text(buf, x, by, 7, c); by += lh - 2; + } + + } else if (dbg_tab == 1) { + /* Observation values */ + float obs[FC_OBS_SIZE]; + fc_write_obs(state, obs); + int mbase = FC_OBS_META_START; + + snprintf(buf, sizeof(buf), "HP:%.2f Pray:%.2f X:%.2f Y:%.2f", + obs[FC_OBS_PLAYER_HP], obs[FC_OBS_PLAYER_PRAYER], + obs[FC_OBS_PLAYER_X], obs[FC_OBS_PLAYER_Y]); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "Atk:%.2f Run:%.2f Sharks:%.2f Dose:%.2f", + obs[FC_OBS_PLAYER_ATK_TIMER], + obs[FC_OBS_PLAYER_RUN_ENERGY], + obs[FC_OBS_PLAYER_SHARKS], + obs[FC_OBS_PLAYER_DOSES]); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "Pray M%.0f R%.0f G%.0f T%.2f", + obs[FC_OBS_PLAYER_PRAY_MEL], + obs[FC_OBS_PLAYER_PRAY_RNG], + obs[FC_OBS_PLAYER_PRAY_MAG], + obs[FC_OBS_PLAYER_TARGET]); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "PrayDDL M%.2f R%.2f G%.2f", + obs[FC_OBS_PLAYER_PRAY_DDL_MEL], + obs[FC_OBS_PLAYER_PRAY_DDL_RNG], + obs[FC_OBS_PLAYER_PRAY_DDL_MAG]); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "In1 M%.2f R%.2f G%.2f", + obs[FC_OBS_PLAYER_IN_MEL_1T], + obs[FC_OBS_PLAYER_IN_RNG_1T], + obs[FC_OBS_PLAYER_IN_MAG_1T]); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "In2 M%.2f R%.2f G%.2f", + obs[FC_OBS_PLAYER_IN_MEL_2T], + obs[FC_OBS_PLAYER_IN_RNG_2T], + obs[FC_OBS_PLAYER_IN_MAG_2T]); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "Meta W%.2f Rot%.2f Rem%.2f", + obs[mbase + FC_OBS_META_WAVE], + obs[mbase + FC_OBS_META_ROTATION], + obs[mbase + FC_OBS_META_REMAINING]); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "Drain:%.2f Dmg:%.2f Clear:%.0f", + obs[mbase + FC_OBS_META_PRAY_DRAIN], + obs[mbase + FC_OBS_META_DMG_T_TICK], + obs[mbase + FC_OBS_META_WAVE_CLR]); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "In3 M%.2f R%.2f G%.2f", + obs[mbase + FC_OBS_META_IN_MEL_3T], + obs[mbase + FC_OBS_META_IN_RNG_3T], + obs[mbase + FC_OBS_META_IN_MAG_3T]); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + snprintf(buf, sizeof(buf), "Prog C%.2f W%.2f Rem%.2f NoP%.2f", + obs[mbase + FC_OBS_META_CAVE_PROG], + obs[mbase + FC_OBS_META_WAVE_PROG], + obs[mbase + FC_OBS_META_WORK_REM], + obs[mbase + FC_OBS_META_NO_PROG]); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + + by += 2; + fc_osrs_draw_text("NPC slots:", x, by, 7, DBG_COL_DIM); by += lh - 1; + for (int s = 0; s < FC_OBS_NPC_SLOTS; s++) { + int base = FC_OBS_NPC_START + s * FC_OBS_NPC_STRIDE; + if (obs[base + FC_NPC_VALID] < 0.5f) continue; + char tele = '-'; + if (obs[base + FC_NPC_TELE_MELEE] > 0.5f) tele = 'M'; + else if (obs[base + FC_NPC_TELE_RANGED] > 0.5f) tele = 'R'; + else if (obs[base + FC_NPC_TELE_MAGIC] > 0.5f) tele = 'A'; + snprintf(buf, sizeof(buf), "[%d] hp:%.2f d:%.2f tele:%c los:%.0f pd:%.2f ddl:%.0f/%.2f", + s, + obs[base + FC_NPC_HP], + obs[base + FC_NPC_DISTANCE], + tele, + obs[base + FC_NPC_LOS], + obs[base + FC_NPC_PENDING_STYLE], + obs[base + FC_NPC_PENDING_PRAYER_WINDOW], + obs[base + FC_NPC_PENDING_PRAYER_DEADLINE]); + fc_osrs_draw_text(buf, x, by, 7, DBG_COL_LABEL); by += lh - 2; + } + + } else if (dbg_tab == 2) { + /* Action mask */ + float mask[FC_ACTION_MASK_SIZE]; + fc_write_mask(state, mask); + + fc_osrs_draw_text("MOVE:", x, by, 8, DBG_COL_DIM); + snprintf(buf, sizeof(buf), " "); + int len = 1; + for (int m = 0; m < FC_MOVE_DIM && len < 120; m++) + buf[len++] = mask[FC_MASK_MOVE_START + m] > 0.5f ? '1' : '0'; + buf[len] = '\0'; + fc_osrs_draw_text(buf, x + 30, by, 8, DBG_COL_LABEL); by += lh; + + fc_osrs_draw_text("ATK:", x, by, 8, DBG_COL_DIM); + len = 1; buf[0] = ' '; + for (int m = 0; m < FC_ATTACK_DIM && len < 120; m++) + buf[len++] = mask[FC_MASK_ATTACK_START + m] > 0.5f ? '1' : '0'; + buf[len] = '\0'; + fc_osrs_draw_text(buf, x + 30, by, 8, DBG_COL_LABEL); by += lh; + + fc_osrs_draw_text("PRAY:", x, by, 8, DBG_COL_DIM); + len = 1; buf[0] = ' '; + for (int m = 0; m < FC_PRAYER_DIM && len < 120; m++) + buf[len++] = mask[FC_MASK_PRAYER_START + m] > 0.5f ? '1' : '0'; + buf[len] = '\0'; + fc_osrs_draw_text(buf, x + 30, by, 8, DBG_COL_LABEL); by += lh; + + snprintf(buf, sizeof(buf), "EAT:%c%c%c DRINK:%c%c", + mask[FC_MASK_EAT_START+0]>0.5f?'1':'0', + mask[FC_MASK_EAT_START+1]>0.5f?'1':'0', + mask[FC_MASK_EAT_START+2]>0.5f?'1':'0', + mask[FC_MASK_DRINK_START+0]>0.5f?'1':'0', + mask[FC_MASK_DRINK_START+1]>0.5f?'1':'0'); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + + int vx = 0, vy = 0; + for (int m = 0; m < FC_MOVE_TARGET_X_DIM; m++) + if (mask[FC_MASK_TARGET_X_START + m] > 0.5f) vx++; + for (int m = 0; m < FC_MOVE_TARGET_Y_DIM; m++) + if (mask[FC_MASK_TARGET_Y_START + m] > 0.5f) vy++; + snprintf(buf, sizeof(buf), "TGT_X:%d/65 TGT_Y:%d/65", vx, vy); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += lh; + + } else if (dbg_tab == 3) { + const FcRewardBreakdown* b = reward_breakdown; + int sh = 14; + const char* cfg_name = dbg_basename(reward_config_path); + + fc_osrs_draw_text("Training reward parity", x, by, 8, DBG_COL_VALUE); by += sh + 2; + snprintf(buf, sizeof(buf), "cfg:%s%s", + cfg_name, + reward_config_loaded ? "" : " (defaults)"); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += sh; + snprintf(buf, sizeof(buf), "total:%+.4f noatk_t:%d", + b->total, + reward_runtime->ticks_since_attack); + fc_osrs_draw_text(buf, x, by, 8, dbg_reward_color(b->total)); by += sh; + snprintf(buf, sizeof(buf), "threat any:%d melee:%d", + b->threat_ctx.any_threat, + b->threat_ctx.melee_pressure_npcs); + fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += sh + 2; + + { + struct { + const char* name; + float value; + } terms[] = { + {"dmg_dealt", b->damage_dealt}, + {"progress", b->progress}, + {"dmg_taken", b->damage_taken}, + {"npc_kill", b->npc_kill}, + {"wave_clear", b->wave_clear}, + {"jad_kill", b->jad_kill}, + {"complete", b->cave_complete}, + {"death", b->player_death}, + {"jad_ok", b->correct_jad_prayer}, + {"danger_ok", b->correct_danger_prayer}, + {"pray_lost", b->prayer_lost}, + {"pray_waste", b->unnecessary_prayer}, + {"wave_stall", b->wave_stall}, + {"no_progress", b->no_progress}, + {"no_attack", b->no_attack}, + {"jad_heal", b->jad_heal}, + {"npc_heal", b->npc_heal}, + {"invalid", b->invalid_action}, + {"tick_pen", b->tick_penalty}, + }; + int term_count = (int)(sizeof(terms) / sizeof(terms[0])); + + for (int i = 0; i < term_count; i++) { + snprintf(buf, sizeof(buf), "%-12s %+.4f", + terms[i].name, terms[i].value); + fc_osrs_draw_text(buf, x, by, 7, dbg_reward_color(terms[i].value)); + by += sh; + } + } + } else if (dbg_tab == 4) { + /* Event log — scrollable with scrollbar, most recent at top */ + int entry_h = lh - 2; + int max_visible = content_height > 0 + ? content_height / entry_h : 20; + if (max_visible < 1) max_visible = 1; + int total = g_dbg_log.count; + int log_h = max_visible * entry_h; + int scrollbar_w = 10; + if (total == 0) { + fc_osrs_draw_text("No events yet", x, by, 8, DBG_COL_DIM); + by += lh; + } else { + int max_scroll = total - max_visible; + if (max_scroll < 0) max_scroll = 0; + + /* Clamp scroll */ + if (g_dbg_log.scroll_offset > max_scroll) + g_dbg_log.scroll_offset = max_scroll; + if (g_dbg_log.scroll_offset < 0) + g_dbg_log.scroll_offset = 0; + + /* Mouse wheel scroll */ + float wheel = GetMouseWheelMove(); + if (wheel != 0.0f) { + Vector2 mp = GetMousePosition(); + if (mp.x >= px && mp.x < px + pw && + mp.y >= by && mp.y < by + log_h) { + g_dbg_log.scroll_offset -= (int)wheel * 3; + if (g_dbg_log.scroll_offset < 0) g_dbg_log.scroll_offset = 0; + if (g_dbg_log.scroll_offset > max_scroll) g_dbg_log.scroll_offset = max_scroll; + } + } + + /* Scrollbar track */ + int sb_x = px + pw - scrollbar_w - 4; + int sb_y = by; + DrawRectangle(sb_x, sb_y, scrollbar_w, log_h, CLITERAL(Color){30,26,20,255}); + + /* Scrollbar thumb */ + if (total > max_visible) { + float thumb_frac = (float)max_visible / (float)total; + int thumb_h = (int)(log_h * thumb_frac); + if (thumb_h < 12) thumb_h = 12; + float scroll_frac = (max_scroll > 0) ? (float)g_dbg_log.scroll_offset / (float)max_scroll : 0; + int thumb_y = sb_y + (int)((log_h - thumb_h) * scroll_frac); + DrawRectangle(sb_x, thumb_y, scrollbar_w, thumb_h, CLITERAL(Color){120,110,90,255}); + + /* Drag scrollbar with mouse */ + if (IsMouseButtonDown(MOUSE_BUTTON_LEFT)) { + Vector2 mp = GetMousePosition(); + if (mp.x >= sb_x && mp.x < sb_x + scrollbar_w && + mp.y >= sb_y && mp.y < sb_y + log_h) { + float click_frac = (mp.y - sb_y) / (float)log_h; + g_dbg_log.scroll_offset = (int)(click_frac * (float)(max_scroll + 1)); + if (g_dbg_log.scroll_offset > max_scroll) g_dbg_log.scroll_offset = max_scroll; + } + } + } + + /* Draw entries */ + int drawn = 0; + for (int i = g_dbg_log.scroll_offset; i < total && drawn < max_visible; i++) { + int idx = (g_dbg_log.head - 1 - i + DBG_LOG_MAX_ENTRIES * 2) % DBG_LOG_MAX_ENTRIES; + snprintf(buf, sizeof(buf), "t%d %s", g_dbg_log.tick[idx], g_dbg_log.entries[idx]); + /* Truncate to fit content width */ + fc_osrs_draw_text(buf, x, by, 7, g_dbg_log.color[idx]); + by += entry_h; + drawn++; + } + + /* Pad remaining space if fewer entries than max_visible */ + by += (max_visible - drawn) * entry_h; + } + } + + return by; +} + +#undef DBG_LOG_MAX_ENTRIES +#undef DBG_LOG_MAX_MSG +#undef DBG_COL_WALK +#undef DBG_COL_BLOCK +#undef DBG_COL_LOS_OK +#undef DBG_COL_LOS_FAIL +#undef DBG_COL_PATH +#undef DBG_COL_RANGE +#undef DBG_COL_LABEL +#undef DBG_COL_VALUE +#undef DBG_COL_GOOD +#undef DBG_COL_BAD +#undef DBG_COL_DIM + +#endif diff --git a/ocean/fight_caves/simulation.h b/ocean/fight_caves/simulation.h new file mode 100644 index 0000000000..a4ce95ab33 --- /dev/null +++ b/ocean/fight_caves/simulation.h @@ -0,0 +1,8078 @@ +#ifndef FIGHT_CAVES_SIMULATION_H +#define FIGHT_CAVES_SIMULATION_H + +/* Player Init */ + +#include + +/* + * Player skill, equipment, and consumable configuration shared by the core, + * training adapter, viewer, and asset tooling. The immutable table itself is + * defined once in fc_loadouts.c. + */ + +typedef enum { + FC_LOADOUT_BLACK_DHIDE_RCB = 0, + FC_LOADOUT_SOTA_TBOW = 1, + FC_LOADOUT_LOW_DEF_RCB = 2, + FC_LOADOUT_RCB_PURE = 3, + FC_LOADOUT_MSBI_PURE = 4, + FC_LOADOUT_BLOWPIPE_PURE = 5, + FC_LOADOUT_ACB_ARMADYL = 6, + FC_LOADOUT_BOWFA_CRYSTAL = 7, + FC_LOADOUT_TBOW_MASORI = 8, + FC_LOADOUT_COUNT +} FcLoadoutId; + +#ifndef FC_ACTIVE_LOADOUT +#define FC_ACTIVE_LOADOUT FC_LOADOUT_SOTA_TBOW +#endif + +#define FC_LOADOUT_EQUIP_MAX 12 +#define FC_LOADOUT_MODEL_ITEM_MAX 12 +#define FC_PLAYER_MODEL_BASE 0xFC000000u + +typedef enum { + FC_EQUIP_SLOT_HEAD = 0, + FC_EQUIP_SLOT_CAPE = 1, + FC_EQUIP_SLOT_NECK = 2, + FC_EQUIP_SLOT_WEAPON = 3, + FC_EQUIP_SLOT_BODY = 4, + FC_EQUIP_SLOT_SHIELD = 5, + FC_EQUIP_SLOT_AMMO = 6, + FC_EQUIP_SLOT_LEGS = 7, + FC_EQUIP_SLOT_HANDS = 9, + FC_EQUIP_SLOT_FEET = 10, + FC_EQUIP_SLOT_RING = 12, +} FcEquipmentSlot; + +typedef struct { + int slot; + uint32_t item_id; + uint32_t icon_item_id; + const char* label; +} FcLoadoutEquipmentItem; + +typedef enum { + FC_CRYSTAL_PIECE_NONE = 0, + FC_CRYSTAL_PIECE_HELM = 1 << 0, + FC_CRYSTAL_PIECE_BODY = 1 << 1, + FC_CRYSTAL_PIECE_LEGS = 1 << 2, + FC_CRYSTAL_PIECE_ALL = FC_CRYSTAL_PIECE_HELM | + FC_CRYSTAL_PIECE_BODY | + FC_CRYSTAL_PIECE_LEGS +} FcCrystalPieceMask; + +/* Exact per-piece modifiers. One percentage point is 100 basis points. */ +#define FC_CRYSTAL_HELM_ACCURACY_BP 500 +#define FC_CRYSTAL_HELM_DAMAGE_BP 250 +#define FC_CRYSTAL_BODY_ACCURACY_BP 1500 +#define FC_CRYSTAL_BODY_DAMAGE_BP 750 +#define FC_CRYSTAL_LEGS_ACCURACY_BP 1000 +#define FC_CRYSTAL_LEGS_DAMAGE_BP 500 + +typedef struct { + const char* name; + const char* weapon_name; + uint32_t player_model_id; + int combat_style_profile; + int max_hp, max_prayer; + int attack_lvl, strength_lvl, defence_lvl; + int ranged_lvl, prayer_lvl, magic_lvl; + int weapon_kind; + int weapon_uses_ammo; + int crystal_piece_mask; + int weapon_speed; + int weapon_range; + int ranged_atk, ranged_str; + int def_stab, def_slash, def_crush, def_magic, def_ranged; + int prayer_bonus; + int ammo; + int equipment_count; + FcLoadoutEquipmentItem equipment[FC_LOADOUT_EQUIP_MAX]; + int model_item_count; + int model_item_ids[FC_LOADOUT_MODEL_ITEM_MAX]; +} FcLoadout; + +typedef enum { + FC_WEAPON_GENERIC_RANGED = 0, + FC_WEAPON_TWISTED_BOW = 1, + FC_WEAPON_BOW_OF_FAERDHINEN = 2 +} FcWeaponKind; + +#define FC_NUM_LOADOUTS FC_LOADOUT_COUNT + +extern const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS]; + +#define FC_PLAYER_MAX_HP (FC_LOADOUTS[FC_ACTIVE_LOADOUT].max_hp) +#define FC_PLAYER_MAX_PRAYER (FC_LOADOUTS[FC_ACTIVE_LOADOUT].max_prayer) +#define FC_PLAYER_DEFENCE_LVL (FC_LOADOUTS[FC_ACTIVE_LOADOUT].defence_lvl) +#define FC_PLAYER_RANGED_LVL (FC_LOADOUTS[FC_ACTIVE_LOADOUT].ranged_lvl) +#define FC_PLAYER_PRAYER_LVL (FC_LOADOUTS[FC_ACTIVE_LOADOUT].prayer_lvl) +#define FC_PLAYER_MAGIC_LVL (FC_LOADOUTS[FC_ACTIVE_LOADOUT].magic_lvl) +#define FC_PLAYER_WEAPON_USES_AMMO \ + (FC_LOADOUTS[FC_ACTIVE_LOADOUT].weapon_uses_ammo) +#define FC_PLAYER_WEAPON_SPEED (FC_LOADOUTS[FC_ACTIVE_LOADOUT].weapon_speed) +#define FC_PLAYER_WEAPON_RANGE (FC_LOADOUTS[FC_ACTIVE_LOADOUT].weapon_range) +#define FC_EQUIP_RANGED_ATK (FC_LOADOUTS[FC_ACTIVE_LOADOUT].ranged_atk) +#define FC_EQUIP_RANGED_STR (FC_LOADOUTS[FC_ACTIVE_LOADOUT].ranged_str) +#define FC_EQUIP_DEF_CRUSH (FC_LOADOUTS[FC_ACTIVE_LOADOUT].def_crush) +#define FC_EQUIP_DEF_MAGIC (FC_LOADOUTS[FC_ACTIVE_LOADOUT].def_magic) +#define FC_EQUIP_DEF_RANGED (FC_LOADOUTS[FC_ACTIVE_LOADOUT].def_ranged) + + +/* Types */ + +#include + +/* + * fc_types.h — Core data structures for Fight Caves simulation. + * + * Design rules (from PufferLib OSRS PvP reference): + * - All state is flat fields on structs. No nested pointers, no heap alloc per tick. + * - Enables cache locality, fast memset reset, linear observation generation. + * - All structs are zeroed on reset via memset; zero must be a safe default for every field. + * + * PR 2 combat note — PvM prayer semantics: + * Protection prayers in Fight Caves BLOCK 100% of the matching NPC attack style. + * This is standard OSRS PvM behavior, NOT the PvP 60% reduction from osrs_pvp. + * Exceptions (e.g. TzTok-Jad hits through wrong prayer) must be explicit per NPC/attack. + * See fc_combat.c (PR 2) for implementation. + */ + +/* ======================================================================== */ +/* Enums */ +/* ======================================================================== */ + +typedef enum { + ENTITY_PLAYER = 0, + ENTITY_NPC = 1 +} FcEntityType; + +/* NPC type codes — map to OSRS Fight Caves monsters */ +typedef enum { + NPC_NONE = 0, + NPC_TZ_KIH = 1, /* Lv 22, melee, drains prayer on hit */ + NPC_TZ_KEK = 2, /* Lv 45, melee, splits into 2 small on death */ + NPC_TZ_KEK_SM = 3, /* Lv 22, melee, small Tz-Kek spawn (from split) */ + NPC_TOK_XIL = 4, /* Lv 90, ranged */ + NPC_YT_MEJKOT = 5, /* Lv 180, melee + heals nearby NPCs */ + NPC_KET_ZEK = 6, /* Lv 360, magic (primary) + melee */ + NPC_TZTOK_JAD = 7, /* Lv 702, magic + ranged, prayer switching */ + NPC_YT_HURKOT = 8, /* Jad healer, permanently targets player once tagged */ + NPC_TYPE_COUNT = 9 +} FcNpcType; + +/* Attack styles */ +typedef enum { + ATTACK_NONE = 0, + ATTACK_MELEE = 1, + ATTACK_RANGED = 2, + ATTACK_MAGIC = 3 +} FcAttackStyle; + +/* Exact incoming attack type used for equipment-defence selection. This is + * intentionally separate from FcAttackStyle, which remains the broad style + * used for prayer matching, observations, and animations. */ +typedef enum { + FC_ATTACK_TYPE_NONE = 0, + FC_ATTACK_TYPE_STAB = 1, + FC_ATTACK_TYPE_SLASH = 2, + FC_ATTACK_TYPE_CRUSH = 3, + FC_ATTACK_TYPE_RANGED = 4, + FC_ATTACK_TYPE_MAGIC = 5 +} FcAttackType; + +/* Protection prayers */ +typedef enum { + PRAYER_NONE = 0, + PRAYER_PROTECT_MELEE = 1, + PRAYER_PROTECT_RANGE = 2, + PRAYER_PROTECT_MAGIC = 3 +} FcPrayer; + +/* Terminal state codes */ +typedef enum { + TERMINAL_NONE = 0, + TERMINAL_PLAYER_DEATH = 1, + TERMINAL_CAVE_COMPLETE = 2, + TERMINAL_TICK_CAP = 3 +} FcTerminalCode; + +/* Invalid-action diagnostic classes for Puffer-facing heads 0-2. */ +typedef enum { + FC_INVALID_ACTION_MOVE = 0, + FC_INVALID_ACTION_ATTACK = 1, + FC_INVALID_ACTION_PRAYER = 2, + FC_INVALID_ACTION_CLASS_COUNT = 3 +} FcInvalidActionClass; + +/* NPC spawn direction for wave rotations */ +typedef enum { + SPAWN_SOUTH = 0, + SPAWN_SOUTH_WEST = 1, + SPAWN_NORTH_WEST = 2, + SPAWN_SOUTH_EAST = 3, + SPAWN_CENTER = 4 +} FcSpawnDir; + +/* ======================================================================== */ +/* Constants */ +/* ======================================================================== */ + +/* Arena */ +#define FC_ARENA_WIDTH 64 +#define FC_ARENA_HEIGHT 64 + +/* OSRS movement-wall flags. These retain the cache's low-byte directional + * layout so a wall blocks only the boundary it occupies, not the whole tile. */ +#define FC_MOVE_WALL_NORTH_WEST (1u << 0) +#define FC_MOVE_WALL_NORTH (1u << 1) +#define FC_MOVE_WALL_NORTH_EAST (1u << 2) +#define FC_MOVE_WALL_EAST (1u << 3) +#define FC_MOVE_WALL_SOUTH_EAST (1u << 4) +#define FC_MOVE_WALL_SOUTH (1u << 5) +#define FC_MOVE_WALL_SOUTH_WEST (1u << 6) +#define FC_MOVE_WALL_WEST (1u << 7) + +/* Directional projectile/line-of-sight collision flags. These are separate + * from walkability: an obstacle may block movement without blocking attacks. */ +#define FC_LOS_NORTH (1u << 0) +#define FC_LOS_EAST (1u << 1) +#define FC_LOS_SOUTH (1u << 2) +#define FC_LOS_WEST (1u << 3) +#define FC_LOS_FULL (1u << 4) + +/* Entity limits */ +#define FC_MAX_NPCS 16 /* max simultaneous NPCs in the arena */ +#define FC_VISIBLE_NPCS 8 /* max NPCs in observation (see fc_contracts.h) */ +#define FC_MAX_PENDING_HITS 8 /* per entity pending hit queue */ + +/* Waves */ +#define FC_NUM_WAVES 63 +#define FC_NUM_ROTATIONS 15 + +/* Consumables (standard Fight Caves loadout) */ +#define FC_MAX_SHARKS 20 +#define FC_MAX_PRAYER_DOSES 32 /* 8 potions × 4 doses */ + +/* Tick timing */ +#define FC_FOOD_COOLDOWN_TICKS 3 /* food_delay: 3 ticks */ +#define FC_POTION_COOLDOWN_TICKS 2 /* drink_delay: 2 ticks (NOT 3 — separate clock from food) */ +#define FC_COMBO_EAT_TICKS 1 /* karambwan combo delay after food */ +#define FC_MAX_EPISODE_TICKS 200000 /* ~33 hours at 0.6s/tick — force prayer drain */ +#define FC_HP_REGEN_INTERVAL 100 /* HP regen: 1 HP every 100 ticks (60 seconds) */ + +/* OSRS run energy uses 100 internal units per displayed percent. The canonical + * no-supplies Masori/Twisted-bow loadout floors to 30 kg. Applying the current + * integer formulas at level 99 Agility loses 60 units per two-step running + * tick and restores 24 units per other tick. */ +#define FC_RUN_ENERGY_MAX 10000 +#define FC_RUN_ENERGY_MIN_START 100 +#define FC_RUN_AGILITY_LEVEL 99 +#define FC_RUN_WEIGHT_KG 30 +#define FC_RUN_ENERGY_DRAIN \ + ((60 + (67 * FC_RUN_WEIGHT_KG) / 64) * \ + (300 - FC_RUN_AGILITY_LEVEL) / 300) +#define FC_RUN_ENERGY_RESTORE \ + (15 + FC_RUN_AGILITY_LEVEL / 10) + +/* Player base stats — defined in fc_player_init.h */ +/* ======================================================================== */ +/* Pending Hit (projectile in flight or delayed melee) */ +/* ======================================================================== */ + +/* + * Attacks queue a PendingHit with a tick delay before damage applies. + * Prayer blocking is locked into the pending hit before impact: + * - normal Fight Caves NPCs snapshot prayer on the attack tick + * - Jad special-cases ranged/magic to snapshot shortly after the tell + * The projectile/hitsplat can still land later, but prayer no longer re-checks + * the live player prayer on impact. + * + * This models OSRS projectile flight: + * Melee: 0 tick delay (instant) + * Ranged: 1 + floor((3 + distance) / 6) ticks + * Magic: 1 + floor((1 + distance) / 3) ticks + * + * PR 2 note: Protection prayer blocks 100% damage if correct style match. + * Unlike PvP (60% reduction), PvM prayer fully blocks the hit. + * Exception: Jad attacks always deal damage if WRONG prayer is active. + */ +typedef struct { + int active; /* 1 if this slot is in use */ + int damage; /* pre-prayer damage roll (0 = miss) */ + int ticks_remaining; /* ticks until hit resolves */ + int attack_style; /* FcAttackStyle of the incoming hit */ + int source_npc_idx; /* index into FcState.npcs[] of the attacker */ + int prayer_drain; /* base prayer drain in tenths (Tz-Kih adds final damage) */ + int prayer_snapshot; /* prayer locked for this hit; -1 = snapshot pending */ + int prayer_lock_tick; /* first tick on which prayer_snapshot should be filled */ +} FcPendingHit; + +/* ======================================================================== */ +/* Player */ +/* ======================================================================== */ + +typedef struct { + /* Position */ + int x, y; + + /* Vitals (in tenths for precision: 700 = 70.0 HP) */ + int current_hp, max_hp; + int current_prayer, max_prayer; + + /* Active prayer */ + int prayer; /* FcPrayer enum: final/live overhead */ + int prayer_at_tick_start; /* immutable broad-style protection snapshot */ + + /* Prayer drain counter (OSRS counter-based system from PrayerDrain.kt): + * Each tick: counter += active prayer drain rate (12 for protect prayers). + * When counter > resistance (60 + 2*prayer_bonus): drain 1 point, counter -= resistance. + * This field is an integer accumulator, NOT in tenths. */ + int prayer_drain_counter; + + /* Consumables */ + int sharks_remaining; + int prayer_doses_remaining; + + /* Timers (tick countdown, 0 = ready) */ + int attack_timer; + int food_timer; + int potion_timer; + int combo_timer; /* karambwan combo eat delay */ + + /* Run */ + int run_energy; /* 0-FC_RUN_ENERGY_MAX (100.00%) */ + int is_running; /* 1 if run mode active */ + + /* Combat stats (from FightCaveEpisodeInitializer.kt) */ + int attack_level; + int strength_level; + int defence_level; + int ranged_level; + int prayer_level; + int magic_level; + int weapon_kind; + int weapon_uses_ammo; + int crystal_piece_mask; + int weapon_speed; + int weapon_range; + + /* Equipment bonuses (exact values from Void 634 cache item definitions) */ + int ranged_attack_bonus; + int ranged_strength_bonus; + int defence_stab, defence_slash, defence_crush; + int defence_magic, defence_ranged; + int prayer_bonus; + + /* Ammo */ + int ammo_count; + + /* HP regen counter (ticks since last regen) */ + int hp_regen_counter; + + /* Click-to-move route (like RSMod RouteDestination / Void walkTo). + * Set once on click, consumed one step per tick until empty. + * When route_len == route_idx, player stands still. */ + #define FC_MAX_ROUTE 64 + int route_x[FC_MAX_ROUTE]; + int route_y[FC_MAX_ROUTE]; + int route_len; /* total steps in current route */ + int route_idx; /* next step to consume (0..route_len-1) */ + + /* Facing direction — angle in degrees, set each movement step */ + float facing_angle; + + /* Attack target — NPC array index, or -1 for none. */ + int attack_target_idx; + /* 1 = player explicitly clicked this NPC (approach + attack). + * 0 = auto-retaliate set target (attack in place only, no approach). */ + int approach_target; + int approach_target_x; + int approach_target_y; + int approach_target_size; + + /* Pending hits (from NPC attacks in flight) */ + FcPendingHit pending_hits[FC_MAX_PENDING_HITS]; + int num_pending_hits; + + /* Per-tick event flags (cleared each tick, used for obs/reward/hitsplats) */ + int damage_taken_this_tick; + int hit_style_this_tick; /* FcAttackStyle of the last hit that resolved this tick */ + int hit_source_npc_type; /* FcNpcType of the NPC that landed the last hit this tick */ + int hit_locked_prayer_this_tick; /* FcPrayer snapshot used for the last resolved hit */ + int hit_blocked_this_tick; /* 1 if the last resolved hit this tick was prayer-blocked */ + int hit_landed_this_tick; + int food_eaten_this_tick; + int potion_used_this_tick; + int prayer_changed_this_tick; + + /* Cumulative stats (for reward/logging) */ + int total_damage_taken; + int total_food_eaten; + int total_potions_used; +} FcPlayer; + +/* ======================================================================== */ +/* NPC */ +/* ======================================================================== */ + +typedef struct { + /* Identity */ + int active; /* 1 if alive and in the arena */ + int npc_type; /* FcNpcType */ + int spawn_index; /* unique index across the episode, stable for NPC slot ordering */ + + /* Position */ + int x, y; + int size; /* tile size: 1 for most, 2+ for larger NPCs */ + + /* Vitals */ + int current_hp, max_hp; + int is_dead; + int death_timer; /* ticks remaining before despawn (0 = despawn immediately) */ + + /* Combat */ + int attack_style; /* FcAttackStyle: what this NPC attacks with */ + int attack_timer; /* tick countdown to next attack */ + int attack_speed; /* ticks between attacks */ + int attack_range; /* tile distance for ranged/magic, 1 for melee */ + + /* AI */ + int movement_speed; /* 1 = walk, 2 = run */ + + /* NPC healing */ + int heal_timer; /* ticks until next independent heal attempt (Yt-HurKot) */ + int heal_amount; /* HP healed per proc */ + + /* Yt-HurKot (Jad healer) */ + int healer_distracted; /* permanent player aggro after the healer is tagged */ + int heal_target_idx; /* NPC index of the entity being healed (Jad) */ + int is_respawned_jad_healer; /* 1 for generations spawned after the first */ + + /* Per-tick event flags */ + int damage_taken_this_tick; + int prayer_drain_dealt_this_tick; + int healing_received_this_tick; + int healing_given_this_tick; + int healed_by_mejkot_this_tick; + int healed_by_hurkot_this_tick; + int healed_self_this_tick; + int died_this_tick; + + /* Pending hits (player attacks in flight toward this NPC) */ + FcPendingHit pending_hits[FC_MAX_PENDING_HITS]; + int num_pending_hits; +} FcNpc; + +/* ======================================================================== */ +/* Wave entry (spawn table row) */ +/* ======================================================================== */ + +#define FC_MAX_SPAWNS_PER_WAVE 6 + +typedef struct { + int npc_types[FC_MAX_SPAWNS_PER_WAVE]; /* FcNpcType per spawn */ + int num_spawns; +} FcWaveEntry; + +/* ======================================================================== */ +/* Render entity (value type for viewer — filled by fc_fill_render_entities) */ +/* ======================================================================== */ + +/* + * The viewer never reads FcPlayer/FcNpc directly. It receives an array of + * these render entities via the fc_fill_render_entities callback. + * This decouples rendering from simulation internals. + */ +typedef struct { + int entity_type; /* FcEntityType */ + int npc_type; /* FcNpcType (0 for player) */ + int x, y; + int size; /* tile size */ + int current_hp, max_hp; + int attack_style; /* FcAttackStyle: current/last attack style */ + int prayer; /* FcPrayer: active prayer (player only) */ + int is_dead; + + /* Per-tick events for hitsplat/animation rendering */ + int damage_taken_this_tick; + int healing_received_this_tick; + int hit_landed_this_tick; + int died_this_tick; + + int npc_slot; /* NPC array index (for stable interpolation lookup) */ +} FcRenderEntity; + +#define FC_MAX_RENDER_ENTITIES (1 + FC_MAX_NPCS) /* player + NPCs */ + +/* ======================================================================== */ +/* Per-tick rendering events */ +/* ======================================================================== */ + +#define FC_MAX_RENDER_MOVE_WAYPOINTS 2 +#define FC_MAX_RENDER_NPC_ATTACKS FC_MAX_NPCS +#define FC_MAX_RENDER_HITS 32 + +typedef struct { + int npc_slot; + int npc_type; + int attack_style; + int source_x; + int source_y; + int source_size; + int target_x; + int target_y; + int hit_delay_ticks; + int prayer_lock_tick; + int hit_queued; +} FcRenderNpcAttack; + +typedef struct { + int target_entity_type; /* FcEntityType */ + int target_npc_slot; /* -1 when the player is the target */ + int source_npc_slot; /* -1 when the player is the source */ + int attack_style; + int damage; + int blocked; +} FcRenderHit; + +/* + * Authoritative, read-only facts captured while a simulation tick executes. + * These events let renderers reproduce transitions whose intermediate state + * is no longer present in the final FcState snapshot. They do not participate + * in combat, observations, rewards, or action validation. + */ +typedef struct { + /* Prayer transition. A successful flick can begin and end on the same + * prayer, so final player.prayer alone cannot represent the off edge. */ + int prayer_prior; + int prayer_final; + int prayer_off_performed; + int prayer_on_succeeded; + int prayer_flick_performed; + + /* Player ranged attack, captured at its authoritative stationary launch + * tile before later phases can change target state or facing direction. */ + int player_attack_fired; + int player_attack_source_x; + int player_attack_source_y; + int player_attack_target_npc_slot; + int player_attack_target_x; + int player_attack_target_y; + int player_attack_target_size; + int player_attack_hit_delay_ticks; + + /* NPC attacks captured when the NPC AI commits the attack. There can be + * at most one launch per NPC during a simulation tick. */ + int npc_attack_count; + FcRenderNpcAttack npc_attacks[FC_MAX_RENDER_NPC_ATTACKS]; + + /* Hits captured when pending-hit resolution consumes them. This includes + * misses and prayer-blocked hits whose final damage is zero. */ + int hit_count; + FcRenderHit hits[FC_MAX_RENDER_HITS]; + + /* Exact player movement path consumed during this tick. Waypoints contain + * each successfully reached tile, including the final tile. */ + int player_move_start_x; + int player_move_start_y; + int player_move_waypoint_count; + int player_move_waypoint_x[FC_MAX_RENDER_MOVE_WAYPOINTS]; + int player_move_waypoint_y[FC_MAX_RENDER_MOVE_WAYPOINTS]; +} FcRenderEvents; + +/* ======================================================================== */ +/* Top-level simulation state */ +/* ======================================================================== */ + +typedef struct { + FcPlayer player; + FcNpc npcs[FC_MAX_NPCS]; + + /* Compile-selected loadout copied into state for diagnostics/contracts. */ + int active_loadout; + + /* Viewer-facing transition facts for the most recently completed tick. */ + FcRenderEvents render_events; + + /* Wave progression */ + int current_wave; /* 1-indexed: 1..63. 0 = not started */ + int rotation_id; /* 0..14, selected at episode start */ + int npcs_remaining; /* count of active (alive) NPCs in current wave */ + int total_npcs_killed; + int next_spawn_index; /* monotonic counter for NPC spawn ordering */ + + /* Tick */ + int tick; + + /* Terminal */ + int terminal; /* FcTerminalCode */ + + /* RNG — XORshift32, single state, seeded at reset */ + uint32_t rng_state; + uint32_t rng_seed; /* saved for replay */ + + /* Whole-tile movement collision (1 = standable, 0 = blocked). */ + uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]; + + /* Directional movement walls, independent from whole-tile blocking. */ + uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]; + + /* Directional projectile collision, independent from movement. */ + uint8_t los_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]; + + /* Jad healer state */ + int jad_healers_spawned; /* 1 until Jad has been healed back to full HP */ + int jad_healer_spawn_generations; /* successful healer generations spawned this wave */ + + /* Per-tick aggregated event flags (for reward features) */ + int damage_dealt_this_tick; + int hits_landed_this_tick; /* count of player pending-hits that dealt damage this tick */ + int damage_taken_this_tick; + int prayer_lost_this_tick; /* prayer points lost this tick, in tenths */ + int overhead_prayer_lost_this_tick; /* passive overhead drain, in tenths */ + int tz_kih_prayer_drain_this_tick; /* Tz-Kih share of prayer loss, in tenths */ + int npcs_killed_this_tick; + int respawned_jad_healers_killed_this_tick; + int wave_just_cleared; + int jad_damage_this_tick; + int jad_killed; + int correct_jad_prayer; + int wrong_jad_prayer; + int correct_danger_prayer; + int wrong_danger_prayer; + int attack_attempt_this_tick; + int invalid_action_this_tick; + int invalid_action_class_this_tick[FC_INVALID_ACTION_CLASS_COUNT]; + int movement_this_tick; + int idle_this_tick; + int food_used_this_tick; + int prayer_potion_used_this_tick; + int jad_heal_procs_this_tick; /* number of Yt-HurKot heal procs that restored Jad HP */ + int npc_heal_procs_this_tick; /* number of NPC heal procs that restored any NPC HP */ + int npc_heal_amount_this_tick; /* total NPC HP restored this tick */ + int mejkot_heal_amount_this_tick; /* total HP restored by Yt-MejKot this tick */ + int jad_heal_amount_this_tick; /* total HP restored to Jad this tick */ + + /* Derived progression state, maintained by reward/runtime code for obs. */ + float progress_required_work_start; + float progress_required_work_remaining; + float progress_current_wave_progress; + float progress_cave_progress; + int progress_ticks_since_positive; + + /* Episode-level analytics (cumulative, zeroed on fc_reset via memset) */ + int ep_ticks_pray_melee; /* ticks with protect melee active */ + int ep_ticks_pray_range; /* ticks with protect range active */ + int ep_ticks_pray_magic; /* ticks with protect magic active */ + int ep_correct_blocks; /* hits correctly blocked by matching prayer */ + int ep_wrong_prayer_hits; /* hits where prayer active but wrong type */ + int ep_no_prayer_hits; /* hits where no prayer was active */ + int ep_damage_blocked; /* total damage prevented by correct prayer */ + int ep_prayer_switches; /* number of prayer changes */ + int ep_pots_used; /* prayer pot doses consumed */ + int ep_pots_wasted; /* doses consumed when prayer was above 20% */ + int ep_pot_pre_prayer_sum; /* prayer points before each potion use */ + int ep_food_eaten; /* sharks consumed */ + int ep_food_pre_hp_sum; /* HP before each food use */ + int ep_food_overhealed; /* sharks that overhealed (wasted HP) */ + int ep_pots_overrestored; /* doses that over-restored (wasted prayer) */ + int ep_tokxil_melee_ticks; /* ticks with any Tok-Xil at melee distance */ + int ep_ketzek_melee_ticks; /* ticks with any Ket-Zek at melee distance */ + int ep_attack_ready_ticks; /* ticks where attack cooldown was ready */ + int ep_attack_attempt_ticks;/* ready ticks where a real attack fired */ + int ep_invalid_action_classes[FC_INVALID_ACTION_CLASS_COUNT]; + int ep_damage_to_npc_type[NPC_TYPE_COUNT]; /* player damage by NPC type */ + int ep_resolved_hits_to_npc_type[NPC_TYPE_COUNT];/* all resolved player hitsplats, including 0s */ + int ep_damaging_hits_to_npc_type[NPC_TYPE_COUNT];/* resolved player hitsplats with damage > 0 */ + int ep_attack_cycles_to_npc_type[NPC_TYPE_COUNT];/* actual attack cycles fired by target type */ + int ep_target_ticks_by_npc_type[NPC_TYPE_COUNT]; /* ticks with active attack target by type */ + int ep_target_held_ticks; /* ticks with any active attack target */ + int ep_no_target_ticks; /* ticks with NPCs alive and no active attack target */ + int ep_target_in_range_los_ticks;/* target held, in range, and line of sight available */ + int ep_target_out_of_range_or_los_ticks; /* target held but cannot currently fire */ + int ep_attack_cooldown_wait_ticks; /* target held and fireable, but weapon cooling down */ + int ep_ready_but_no_attack_ticks; /* target held/fireable/ready but no attack cycle launched */ + int ep_action_move_idle_ticks; + int ep_action_move_walk_ticks; + int ep_action_move_run_ticks; + int ep_action_attack_none_ticks; + int ep_action_attack_target_ticks; + int ep_action_prayer_noop_ticks; + int ep_action_prayer_cmd_ticks; + int ep_reached_wave_63; /* 1 if episode reached Jad wave */ + int ep_jad_killed; /* 1 if Jad died at any point this episode */ + int wave_start_tick; /* tick when current wave was spawned */ + int ep_max_wave_ticks; /* longest single wave duration in ticks */ + int ep_max_wave_ticks_wave; /* which wave number that was */ +} FcState; + + +/* Contracts */ + +/* Machine-readable training-contract metadata. These identifiers describe + * compiled semantics, not tunable reward weights. A selected runnable config + * may override FC_REWARD_VERSION at build time when it preserves its own + * reward-family name under the same Prayer parity semantics. */ +#define FC_CONTRACT_DUMP_SCHEMA_VERSION 1 +#ifndef FC_OBSERVATION_VERSION +#define FC_OBSERVATION_VERSION "fight_caves_puffer_policy_obs_v9_run_energy_prayer_timing_mask8_no_supplies" +#endif +#ifndef FC_ACTION_VERSION +#define FC_ACTION_VERSION "fight_caves_multidiscrete_3_head_no_supplies_v4_run_energy_prayer8_stationary_attack_tick" +#endif +#ifndef FC_REWARD_VERSION +#define FC_REWARD_VERSION "fight_caves_v4_progress_npc_heal_penalty_m0005_prayer_snapshot_flick_drain" +#endif +#ifndef FC_PRAYER_TIMING_VERSION +#define FC_PRAYER_TIMING_VERSION "fight_caves_prayer_timing_v1_tick_start_snapshot_flick_drain_jad_lock" +#endif + +/* + * fc_contracts.h — Observation, action, reward, and mask contracts. + * + * SINGLE SOURCE OF TRUTH for all buffer layouts. Python reads these constants + * (via codegen or manual sync) — it never redefines them. + * + * All observations are float32, normalized to [0,1]. All actions are int32 per + * head. The canonical core buffer contains policy observations followed by raw + * reward features. The Puffer adapter exposes its own checkpoint-compatible + * model input, documented below; reward features are not model inputs. + */ + +/* ======================================================================== */ +/* Observation layout */ +/* ======================================================================== */ + +/* + * The optional complete core buffer is split into three contiguous regions: + * + * [0 .. FC_POLICY_OBS_SIZE-1] policy observations + * [FC_POLICY_OBS_SIZE .. FC_POLICY_OBS_SIZE+FC_REWARD_FEATURES-1] reward features + * [FC_TOTAL_OBS .. FC_TOTAL_OBS+FC_ACTION_MASK_SIZE-1] action mask + * + * Core callers may use policy observations plus raw reward features + * (FC_TOTAL_OBS), then append the seven-head core mask for a 475-float + * diagnostic buffer (FC_OBS_SIZE). + * + * Puffer does not receive that diagnostic layout. Its model input is 320 + * floats: FC_POLICY_OBS_SIZE (286) followed by FC_PUFFER_MASK_SIZE (34) mask + * bits for the no-supplies policy heads. Those same 34 bits are also supplied + * through PufferLib's native action-mask channel. The 20 reward features and + * the masks for core-only action heads are not exposed to the model. + */ + +/* --- Player features (23 floats) --- */ +#define FC_OBS_PLAYER_START 0 +#define FC_OBS_PLAYER_HP 0 /* current_hp / max_hp */ +#define FC_OBS_PLAYER_PRAYER 1 /* current_prayer / max_prayer */ +#define FC_OBS_PLAYER_X 2 /* x / ARENA_WIDTH */ +#define FC_OBS_PLAYER_Y 3 /* y / ARENA_HEIGHT */ +#define FC_OBS_PLAYER_ATK_TIMER 4 /* attack_timer / max_attack_timer */ +#define FC_OBS_PLAYER_PRAY_MEL 5 /* prayer == PROTECT_MELEE (0 or 1) */ +#define FC_OBS_PLAYER_PRAY_RNG 6 /* prayer == PROTECT_RANGE (0 or 1) */ +#define FC_OBS_PLAYER_PRAY_MAG 7 /* prayer == PROTECT_MAGIC (0 or 1) */ +#define FC_OBS_PLAYER_SHARKS 8 /* sharks_remaining / MAX_SHARKS */ +#define FC_OBS_PLAYER_DOSES 9 /* prayer_doses / MAX_DOSES */ +#define FC_OBS_PLAYER_IN_MEL_1T 10 /* normalized count of melee hits landing in 1 tick */ +#define FC_OBS_PLAYER_IN_RNG_1T 11 /* normalized count of ranged hits landing in 1 tick */ +#define FC_OBS_PLAYER_IN_MAG_1T 12 /* normalized count of magic hits landing in 1 tick */ +#define FC_OBS_PLAYER_IN_MEL_2T 13 /* normalized count of melee hits landing in 2 ticks */ +#define FC_OBS_PLAYER_IN_RNG_2T 14 /* normalized count of ranged hits landing in 2 ticks */ +#define FC_OBS_PLAYER_IN_MAG_2T 15 /* normalized count of magic hits landing in 2 ticks */ +#define FC_OBS_PLAYER_TARGET 16 /* attack_target NPC slot index / 8 (0=no target, 0.125-1.0=slot 0-7) */ +#define FC_OBS_PLAYER_PRAY_DDL_MEL 17 /* style-specific prayer deadline urgency for actionable melee hits */ +#define FC_OBS_PLAYER_PRAY_DDL_RNG 18 /* style-specific prayer deadline urgency for actionable ranged hits */ +#define FC_OBS_PLAYER_PRAY_DDL_MAG 19 /* style-specific prayer deadline urgency for actionable magic hits */ +#define FC_OBS_PLAYER_PRAYER_LOST 20 /* total prayer lost this tick / max_prayer */ +#define FC_OBS_PLAYER_OVERHEAD_PRAYER_LOST 21 /* 1 if passive overhead drain removed prayer this tick */ +#define FC_OBS_PLAYER_RUN_ENERGY 22 /* current run energy / FC_RUN_ENERGY_MAX */ +#define FC_OBS_PLAYER_SIZE 23 + +/* --- Per-NPC features (31 floats x 8 visible NPCs = 248 floats) --- */ +/* + * NPC slot ordering — deterministic rules for the 8 visible NPC slots: + * + * 1. Only active (alive) NPCs are eligible for slots. + * 2. Sort eligible NPCs by: + * a. Chebyshev distance to the nearest tile of the NPC footprint, + * ascending (closest first). + * b. On distance tie: spawn_index ascending (earlier spawns first). + * 3. Take the first 8 from the sorted list. + * 4. If fewer than 8 active NPCs, remaining slots are zeroed (valid=0). + * + * Overflow behavior: If more than 8 NPCs are alive, the 8 closest are visible. + * NPCs beyond slot 8 are still simulated (they attack, move, take damage) but + * are not included in the observation. The agent cannot directly target overflow + * NPCs via the ATTACK action head, but area/splash effects may still hit them. + * + * The spawn_index tiebreaker ensures deterministic ordering when distances are + * equal, which is critical for replay consistency and debug reproducibility. + */ +#define FC_OBS_NPC_START FC_OBS_PLAYER_SIZE /* 23 */ +#define FC_OBS_NPC_STRIDE 31 +#define FC_OBS_NPC_SLOTS 8 /* FC_VISIBLE_NPCS */ + +/* Per-NPC feature offsets within stride. + * + * Telegraph bits (TELE_MELEE/RANGED/MAGIC) are one-hot: the style this NPC + * would use RIGHT NOW based on distance (no LOS check). Stays on even when + * LOS=0 so the agent can prepare prayer for when LOS resumes. Untagged + * Yt-HurKot does not telegraph; a tagged healer telegraphs melee. Jad + * telegraphs only after it commits a pending_hit (style is stochastic). + * All zero when NPC slot is empty/dead. + */ +#define FC_NPC_VALID 0 /* 1 if slot occupied, 0 if empty */ +#define FC_NPC_X 1 /* x / ARENA_WIDTH */ +#define FC_NPC_Y 2 /* y / ARENA_HEIGHT */ +#define FC_NPC_HP 3 /* current_hp / max_hp */ +#define FC_NPC_DISTANCE 4 /* chebyshev distance to NPC footprint / ARENA_WIDTH */ +#define FC_NPC_TELE_MELEE 5 /* one-hot: NPC would melee at current distance */ +#define FC_NPC_TELE_RANGED 6 /* one-hot: NPC would range at current distance */ +#define FC_NPC_TELE_MAGIC 7 /* one-hot: NPC would magic at current distance */ +#define FC_NPC_ATK_TIMER 8 /* attack_timer / attack_speed */ +#define FC_NPC_LOS 9 /* 1 if player has line of sight, 0 if blocked */ +#define FC_NPC_PENDING_STYLE 10 /* incoming attack style (0=none, 0.33/0.67/1.0) */ +#define FC_NPC_PENDING_TICKS 11 /* ticks until incoming attack resolves (normalized) */ +#define FC_NPC_TYPE_TZ_KIH 12 /* one-hot NPC identity: Tz-Kih */ +#define FC_NPC_TYPE_TZ_KEK 13 /* one-hot NPC identity: Tz-Kek */ +#define FC_NPC_TYPE_TZ_KEK_SM 14 /* one-hot NPC identity: small Tz-Kek */ +#define FC_NPC_TYPE_TOK_XIL 15 /* one-hot NPC identity: Tok-Xil */ +#define FC_NPC_TYPE_YT_MEJKOT 16 /* one-hot NPC identity: Yt-MejKot */ +#define FC_NPC_TYPE_KET_ZEK 17 /* one-hot NPC identity: Ket-Zek */ +#define FC_NPC_TYPE_TZTOK_JAD 18 /* one-hot NPC identity: TzTok-Jad */ +#define FC_NPC_TYPE_YT_HURKOT 19 /* one-hot NPC identity: Yt-HurKot */ +#define FC_NPC_PENDING_PRAYER_WINDOW 20 /* 1 if current prayer action can still affect this pending hit */ +#define FC_NPC_PENDING_PRAYER_DEADLINE 21 /* urgency until prayer locks: 1=act now, 0=no actionable hit */ +#define FC_NPC_PRAYER_DRAIN_DEALT 22 /* actual prayer drained this tick / source maximum */ +#define FC_NPC_HEAL_RECEIVED 23 /* HP restored to this NPC this tick / max_hp */ +#define FC_NPC_HEAL_GIVEN 24 /* HP this NPC restored this tick / configured heal_amount */ +#define FC_NPC_HEALED_BY_MEJKOT 25 /* 1 if Yt-MejKot restored this NPC's HP this tick */ +#define FC_NPC_HEALED_BY_HURKOT 26 /* 1 if Yt-HurKot restored this NPC's HP this tick */ +#define FC_NPC_HEALED_SELF 27 /* 1 if this NPC restored its own HP this tick */ +#define FC_NPC_TARGETS_PLAYER 28 /* 1 if the NPC's current movement/combat target is the player */ +#define FC_NPC_HEAL_COOLDOWN 29 /* normalized time until the next possible healing cycle */ +#define FC_NPC_KILL_REWARD_ELIGIBLE 30 /* 1 if this NPC's death would pay FC_RWD_NPC_KILL */ + +#define FC_OBS_NPC_TOTAL (FC_OBS_NPC_STRIDE * FC_OBS_NPC_SLOTS) /* 248 */ + +/* --- Wave/meta features (15 floats) --- */ +#define FC_OBS_META_START (FC_OBS_NPC_START + FC_OBS_NPC_TOTAL) /* 271 */ +#define FC_OBS_META_WAVE 0 /* current_wave / NUM_WAVES */ +#define FC_OBS_META_ROTATION 1 /* rotation_id / NUM_ROTATIONS */ +#define FC_OBS_META_REMAINING 2 /* npcs_remaining / MAX_NPCS */ +#define FC_OBS_META_PRAY_DRAIN 3 /* prayer_drain_counter / drain_resistance */ +#define FC_OBS_META_IN_MEL_3T 4 /* normalized count of melee hits landing in 3 ticks */ +#define FC_OBS_META_IN_RNG_3T 5 /* normalized count of ranged hits landing in 3 ticks */ +#define FC_OBS_META_IN_MAG_3T 6 /* normalized count of magic hits landing in 3 ticks */ +#define FC_OBS_META_DMG_T_TICK 7 /* damage_taken_this_tick / max_hp */ +#define FC_OBS_META_WAVE_CLR 8 /* wave_just_cleared (0 or 1) */ +#define FC_OBS_META_CAVE_PROG 9 /* derived cave progress, [0,1] */ +#define FC_OBS_META_WAVE_PROG 10 /* derived current-wave progress, [0,1] */ +#define FC_OBS_META_WORK_REM 11 /* required_work_remaining / required_work_start */ +#define FC_OBS_META_NO_PROG 12 /* ticks_since_positive_progress / 2400 */ +#define FC_OBS_META_NPC_HEALING 13 /* HP restored this tick / required work at wave start */ +#define FC_OBS_META_REWARDABLE_NPC_KILL 14 /* 1 if at least one eligible NPC died this tick */ +#define FC_OBS_META_SIZE 15 + +/* --- Policy observation total --- */ +#define FC_POLICY_OBS_SIZE (FC_OBS_PLAYER_SIZE + FC_OBS_NPC_TOTAL + FC_OBS_META_SIZE) /* 286 */ + +/* --- Reward features (20 floats) --- */ +/* + * These are packed AFTER policy observations in the same buffer. + * The trainer reads them for reward shaping and logging. + * The policy DOES NOT consume these by default. + * Python applies configurable shaping weights to produce the scalar reward. + */ +#define FC_REWARD_START FC_POLICY_OBS_SIZE /* 286 */ +#define FC_RWD_DAMAGE_DEALT 0 /* NPC HP reduced this tick (normalized) */ +#define FC_RWD_DAMAGE_TAKEN 1 /* player HP reduced this tick */ +#define FC_RWD_NPC_KILL 2 /* rewardable deaths; excludes respawned Jad healers */ +#define FC_RWD_WAVE_CLEAR 3 /* all wave NPCs dead */ +#define FC_RWD_JAD_DAMAGE 4 /* Jad HP reduced this tick */ +#define FC_RWD_JAD_KILL 5 /* Jad defeated */ +#define FC_RWD_PLAYER_DEATH 6 /* player HP <= 0 */ +#define FC_RWD_CAVE_COMPLETE 7 /* all 63 waves cleared */ +#define FC_RWD_FOOD_USED 8 /* shark consumed this tick */ +#define FC_RWD_PRAYER_POT_USED 9 /* potion consumed this tick */ +#define FC_RWD_CORRECT_JAD_PRAY 10 /* Jad-specific correct-block diagnostic */ +#define FC_RWD_WRONG_JAD_PRAY 11 /* Jad-specific wrong-block diagnostic */ +#define FC_RWD_INVALID_ACTION 12 /* rejected/masked action attempted */ +#define FC_RWD_MOVEMENT 13 /* walk/run action executed */ +#define FC_RWD_IDLE 14 /* wait/idle action */ +#define FC_RWD_TICK_PENALTY 15 /* fires every tick (time discount) */ +#define FC_RWD_CORRECT_DANGER_PRAY 16 /* prayer matched any resolved NPC style, including Jad */ +#define FC_RWD_WRONG_DANGER_PRAY 17 /* prayer missed resolved non-Jad NPC style */ +#define FC_RWD_ATTACK_ATTEMPT 18 /* valid attack cycle launched this tick */ +#define FC_RWD_PRAYER_LOST 19 /* prayer points lost this tick */ +#define FC_REWARD_FEATURES 20 + +/* --- Total observation (policy obs + reward features) --- */ +#define FC_TOTAL_OBS (FC_POLICY_OBS_SIZE + FC_REWARD_FEATURES) /* 306 */ + +/* ======================================================================== */ +/* Action space — 7 canonical core heads */ +/* ======================================================================== */ + +/* + * Canonical action interface shared by: + * - Headless RL training + * - Human playable viewer (click/keyboard → action buffer) + * - Replay playback (recorded action buffer per tick) + * - Policy playback (policy output → action buffer) + * + * Human input (click-to-move, click-to-attack) must translate into these + * head values. The viewer enqueues canonical MOVE steps per tick via + * pathfinding; it NEVER bypasses the action interface to mutate state. + */ + +#define FC_NUM_ACTION_HEADS 7 + +/* Head 0: MOVE — directional tile movement (low-level) */ +/* + * 0 = idle (no movement) + * 1-8 = walk 1 tile (N, NE, E, SE, S, SW, W, NW) + * 9-16 = run 2 tiles (N, NE, E, SE, S, SW, W, NW) + * + * For fine-grained per-tick control. Ignored when a BFS route is active + * (set via heads 5+6 or viewer click-to-move). + */ +#define FC_MOVE_DIM 17 +#define FC_MOVE_IDLE 0 +#define FC_MOVE_WALK_N 1 +#define FC_MOVE_WALK_NE 2 +#define FC_MOVE_WALK_E 3 +#define FC_MOVE_WALK_SE 4 +#define FC_MOVE_WALK_S 5 +#define FC_MOVE_WALK_SW 6 +#define FC_MOVE_WALK_W 7 +#define FC_MOVE_WALK_NW 8 +#define FC_MOVE_RUN_N 9 +#define FC_MOVE_RUN_NE 10 +#define FC_MOVE_RUN_E 11 +#define FC_MOVE_RUN_SE 12 +#define FC_MOVE_RUN_S 13 +#define FC_MOVE_RUN_SW 14 +#define FC_MOVE_RUN_W 15 +#define FC_MOVE_RUN_NW 16 + +/* Direction offset tables (dx, dy) for walk actions 1-8 */ +static const int FC_MOVE_DX[17] = { + 0, /* idle */ + 0, 1, 1, 1, 0, -1, -1, -1, /* walk: N, NE, E, SE, S, SW, W, NW */ + 0, 2, 2, 2, 0, -2, -2, -2 /* run: N, NE, E, SE, S, SW, W, NW (2-tile target) */ +}; +static const int FC_MOVE_DY[17] = { + 0, + 1, 1, 0, -1, -1, -1, 0, 1, /* walk */ + 2, 2, 0, -2, -2, -2, 0, 2 /* run */ +}; + +/* Head 1: ATTACK — target a visible NPC by slot index */ +#define FC_ATTACK_DIM 9 /* 0=none, 1-8=NPC slot 0-7 */ +#define FC_ATTACK_NONE 0 + +/* Head 2: PRAYER — toggle protection prayer */ +/* + * 0 = no change + * 1 = prayer off + * 2 = protect from magic + * 3 = protect from missiles (ranged) + * 4 = protect from melee + * 5 = explicit OFF edge, then protect from magic + * 6 = explicit OFF edge, then protect from missiles (ranged) + * 7 = explicit OFF edge, then protect from melee + * + * PR 2 note — PvM prayer semantics: + * Correct protection prayer BLOCKS 100% of the matching NPC attack style. + * This is NOT the PvP 60% reduction. Full block is standard OSRS PvM behavior. + * Only exception: attacking while wrong prayer is active against Jad still takes + * full damage. Prayer must be switched before the hit's snapshot/lock tick. + */ +#define FC_PRAYER_DIM 8 +#define FC_PRAYER_NO_CHANGE 0 +#define FC_PRAYER_OFF 1 +#define FC_PRAYER_MAGIC 2 +#define FC_PRAYER_RANGE 3 +#define FC_PRAYER_MELEE 4 +#define FC_PRAYER_FLICK_MAGIC 5 +#define FC_PRAYER_FLICK_RANGE 6 +#define FC_PRAYER_FLICK_MELEE 7 + +/* Head 3: EAT */ +#define FC_EAT_DIM 3 +#define FC_EAT_NONE 0 +#define FC_EAT_SHARK 1 +#define FC_EAT_COMBO 2 /* karambwan combo eat (if available) */ + +/* Head 4: DRINK */ +#define FC_DRINK_DIM 2 +#define FC_DRINK_NONE 0 +#define FC_DRINK_PRAYER_POT 1 + +/* Head 5: MOVE_TARGET_X — BFS pathfinding target X coordinate (high-level) */ +/* + * 0 = no pathfind target (use directional head 0 instead) + * 1-64 = tile X coordinate 0-63 + * + * When BOTH head 5 and head 6 are non-zero, the backend calls BFS pathfind + * to tile (target_x-1, target_y-1) and sets the player route. This is + * identical to a human clicking a tile in the viewer. + * + * The route is consumed one step per tick (or two if running). While a route + * is active, directional actions (head 0) are ignored. + * + * If the exact target is unwalkable or unreachable, native move-near chooses + * the best reachable endpoint within ten tiles. If none exists, it is a no-op. + */ +#define FC_MOVE_TARGET_X_DIM 65 /* 0=no-op, 1-64=tile x 0-63 */ +#define FC_MOVE_TARGET_X_NONE 0 + +/* Head 6: MOVE_TARGET_Y — BFS pathfinding target Y coordinate */ +#define FC_MOVE_TARGET_Y_DIM 65 /* 0=no-op, 1-64=tile y 0-63 */ +#define FC_MOVE_TARGET_Y_NONE 0 + +/* Head dimension array (for binding.c) */ +#define FC_ACT_SIZES { FC_MOVE_DIM, FC_ATTACK_DIM, FC_PRAYER_DIM, FC_EAT_DIM, FC_DRINK_DIM, FC_MOVE_TARGET_X_DIM, FC_MOVE_TARGET_Y_DIM } + +/* Action head dimensions as a static array (for iteration in viewer/binding) */ +static const int FC_ACTION_DIMS[FC_NUM_ACTION_HEADS] = FC_ACT_SIZES; + +/* Puffer-facing no-supplies policy contract. + * This is the single source of truth for the action heads and masks consumed + * by Puffer training and policy replay. Core still supports all canonical + * action heads; the Puffer policy emits the prefix below. */ +#define FC_PUFFER_NUM_ATNS 3 +#define FC_PUFFER_ACT_SIZES { FC_MOVE_DIM, FC_ATTACK_DIM, FC_PRAYER_DIM } +#define FC_PUFFER_MASK_SIZE (FC_MOVE_DIM + FC_ATTACK_DIM + FC_PRAYER_DIM) +#define FC_PUFFER_OBS_SIZE (FC_POLICY_OBS_SIZE + FC_PUFFER_MASK_SIZE) + +static const int FC_PUFFER_ACTION_DIMS[FC_PUFFER_NUM_ATNS] = FC_PUFFER_ACT_SIZES; + +/* ======================================================================== */ +/* Action mask */ +/* ======================================================================== */ + +/* + * Per-tick binary mask: 1.0 = valid, 0.0 = invalid. + * One float per action value per head. Appended after FC_TOTAL_OBS in the buffer. + * + * Layout: [MOVE(17)] [ATTACK(9)] [PRAYER(8)] [EAT(3)] [DRINK(2)] [TARGET_X(65)] [TARGET_Y(65)] + */ +#define FC_ACTION_MASK_SIZE (FC_MOVE_DIM + FC_ATTACK_DIM + FC_PRAYER_DIM + FC_EAT_DIM + FC_DRINK_DIM + FC_MOVE_TARGET_X_DIM + FC_MOVE_TARGET_Y_DIM) /* 169 */ + +/* Mask region offsets within the mask buffer */ +#define FC_MASK_MOVE_START 0 +#define FC_MASK_ATTACK_START FC_MOVE_DIM /* 17 */ +#define FC_MASK_PRAYER_START (FC_MASK_ATTACK_START + FC_ATTACK_DIM) /* 26 */ +#define FC_MASK_EAT_START (FC_MASK_PRAYER_START + FC_PRAYER_DIM) /* 34 */ +#define FC_MASK_DRINK_START (FC_MASK_EAT_START + FC_EAT_DIM) /* 37 */ +#define FC_MASK_TARGET_X_START (FC_MASK_DRINK_START + FC_DRINK_DIM) /* 39 */ +#define FC_MASK_TARGET_Y_START (FC_MASK_TARGET_X_START + FC_MOVE_TARGET_X_DIM) /* 104 */ + +/* ======================================================================== */ +/* Optional complete core diagnostic buffer size */ +/* ======================================================================== */ + +/* + * Total floats in the full FC backend buffer: + * FC_POLICY_OBS_SIZE (286) + FC_REWARD_FEATURES (20) + FC_ACTION_MASK_SIZE (169) = 475 + * + * The PufferLib adapter does not allocate or expose this layout. It allocates + * FC_PUFFER_OBS_SIZE (320), copies the no-supplies mask into the model input at + * FC_POLICY_OBS_SIZE, and publishes the same flags through the native mask + * pointer. Reward computation reads authoritative state through fc_reward. + */ +#define FC_OBS_SIZE (FC_TOTAL_OBS + FC_ACTION_MASK_SIZE) /* 475 */ + +/* ======================================================================== */ +/* Normalization divisors */ +/* ======================================================================== */ + +/* + * Each observation feature is divided by its divisor to normalize to [0,1]. + * Divisors are defined in fc_obs.c and indexed by feature offset. + * Policy sees normalized values only. + */ + + +/* Episode Summary */ + +/* Read-only, consumer-neutral episode metrics derived from FcState. Training + * may aggregate these values and evaluators may serialize them, but neither + * consumer should independently reproduce their formulas. */ +typedef struct { + int episode_length; + int wave_reached; + int npcs_slayed; + float prayer_uptime_melee; + float prayer_uptime_range; + float prayer_uptime_magic; + int correct_prayer; + int wrong_prayer_hits; + int no_prayer_hits; + int prayer_switches; + int damage_blocked; + int damage_taken; + float attack_when_ready_rate; + int invalid_move; + int invalid_attack; + int invalid_prayer; + int tokxil_melee_ticks; + int ketzek_melee_ticks; + int max_wave_ticks; + int max_wave_ticks_wave; + int reached_wave_63; + int jad_killed; + int player_died; + int damage_to_npc_type[NPC_TYPE_COUNT]; + int resolved_hits_to_npc_type[NPC_TYPE_COUNT]; + int damaging_hits_to_npc_type[NPC_TYPE_COUNT]; + int attack_cycles_to_npc_type[NPC_TYPE_COUNT]; + int target_ticks_by_npc_type[NPC_TYPE_COUNT]; + int target_held_ticks; + int no_target_ticks; + int target_in_range_los_ticks; + int target_out_of_range_or_los_ticks; + int attack_cooldown_wait_ticks; + int ready_but_no_attack_ticks; + int action_move_idle_ticks; + int action_move_walk_ticks; + int action_move_run_ticks; + int action_attack_none_ticks; + int action_attack_target_ticks; + int action_prayer_noop_ticks; + int action_prayer_cmd_ticks; +} FcEpisodeSummary; + +/* episode_length is supplied by the consumer because standalone simulation + * ticks and adapter step counts can intentionally differ in tests/tools. */ +void fc_episode_summary_build(const FcState* state, int episode_length, + FcEpisodeSummary* summary); + +/* Stable lowercase suffix shared by training and evaluator metric keys. */ +const char* fc_episode_npc_metric_name(int npc_type); + + +/* Combat */ + +/* OSRS accuracy formula: returns hit probability in [0,1] */ +float fc_hit_chance(int att_roll, int def_roll); + +/* NPC combat */ +int fc_npc_attack_roll(int att_level, int att_bonus); + +/* Player combat */ +int fc_player_def_roll(const FcPlayer* p, FcAttackType attack_type); +int fc_player_ranged_base_attack_roll(const FcPlayer* p); +int fc_player_ranged_attack_roll(const FcPlayer* p, const FcNpc* target); +int fc_player_ranged_base_max_hit_hp(const FcPlayer* p); +int fc_player_ranged_final_max_hit_hp(const FcPlayer* p, const FcNpc* target); + +/* Damage helpers accept a whole-HP maximum and return tenths storage units. */ +int fc_roll_player_damage_tenths(FcState* state, int final_max_hit_hp); +int fc_roll_npc_damage_tenths(FcState* state, int final_max_hit_hp); + +/* Twisted-bow staged multiplier boundaries. */ +int fc_tbow_accuracy_multiplier_pct(int target_magic_level); +int fc_tbow_damage_multiplier_pct(int target_magic_level); + +/* PvM prayer: returns 1 if prayer blocks the attack style (100% block) */ +int fc_prayer_blocks_style(int prayer, int attack_style); + +/* Distance to multi-tile NPC (Chebyshev) */ +int fc_distance_to_npc(int px, int py, const FcNpc* npc); + +/* Hit delay */ +int fc_ranged_hit_delay(int distance); /* player ranged projectile */ +int fc_npc_hit_delay(int npc_type, int attack_style, int distance); /* per-NPC exact timing */ + +/* NPC defence roll (for player accuracy against NPC) */ +int fc_npc_def_roll(int def_level, int def_bonus); + +/* Pending hit queue */ +int fc_queue_pending_hit(FcPendingHit hits[], int* num_hits, int max_hits, + int damage, int ticks, int style, int source_idx, + int prayer_drain); + +/* Resolve pending hits (call each tick) */ +void fc_resolve_player_pending_hits(FcState* state); +void fc_resolve_npc_pending_hits(FcState* state, int npc_idx); + + +/* Npc */ + +/* NPC stat table entry (one per NPC_TYPE) */ +typedef struct { + int max_hp; + int attack_style; /* FcAttackStyle: primary style (ranged/magic for dual-mode) */ + int attack_speed; /* ticks between attacks */ + int attack_range; /* primary attack range (1 for melee-only, 14 for ranged/magic) */ + int melee_max_hit_tenths; + int ranged_max_hit_tenths; + int magic_max_hit_tenths; + int att_level; /* melee Attack */ + int ranged_level; + int magic_level; /* also the Twisted-bow target input */ + int melee_attack_bonus; + int ranged_attack_bonus; + int magic_attack_bonus; + int def_level; /* NPC defence level (for player attack accuracy) */ + int ranged_def_bonus; /* NPC equipment defence vs Ranged */ + int melee_attack_type; /* FcAttackType */ + int size; /* tile footprint */ + int movement_speed; /* 1=walk, 2=run */ + int prayer_drain; /* base prayer drain in tenths (Tz-Kih specific) */ + int heal_amount; /* HP healed per proc */ + int heal_interval; /* ticks between independent Yt-HurKot heals */ +} FcNpcStats; + +/* Get stats for a given NPC type */ +const FcNpcStats* fc_npc_get_stats(int npc_type); + +/* Unit-explicit style maximum boundaries. Unsupported/invalid styles return + * zero. The HP accessor rejects non-integral tenths values by returning zero. */ +int fc_npc_max_hit_tenths_for_style(const FcNpcStats* stats, int attack_style); +int fc_npc_max_hit_hp_for_style(const FcNpcStats* stats, int attack_style); + +/* Returns nonzero when every populated style maximum is valid tenths data. */ +int fc_npc_stats_valid(const FcNpcStats* stats); + +/* Initialize an NPC slot from type and spawn position */ +void fc_npc_spawn(FcNpc* npc, int npc_type, int x, int y, int spawn_index); + +/* True when the NPC could attack the player if its top-left footprint stood + * at candidate_x,candidate_y. This checks attack style, range, melee contact, + * and static LOS. */ +int fc_npc_position_can_attack_player(const FcState* state, const FcNpc* npc, + int candidate_x, int candidate_y); + +/* Run NPC AI for one tick: movement + attack decision */ +void fc_npc_tick(FcState* state, int npc_idx); + +/* Tz-Kek split-on-death: spawn 2 NPC_TZ_KEK_SM at death position */ +void fc_npc_tz_kek_split(FcState* state, int dead_x, int dead_y); + + +/* Pathfinding */ + +/* ======================================================================== */ +/* Tile queries */ +/* ======================================================================== */ + +/* Check if a single tile is walkable (bounds + collision). */ +int fc_tile_walkable(int x, int y, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]); + +/* Check if an entity of given size can stand at (x,y). + * All tiles in the [x..x+size-1, y..y+size-1] footprint must be walkable. */ +int fc_footprint_walkable(int x, int y, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]); + +/* Check one sized step using the native client/RSMod leading-edge collision + * masks, including the distinct size-1, size-2, and large-actor rules. */ +int fc_footprint_step_walkable( + int x, int y, int dx, int dy, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]); + +/* ======================================================================== */ +/* Dynamic occupancy */ +/* ======================================================================== */ + +/* Clear an occupancy grid to all-free. */ +void fc_clear_occupancy(uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]); + +/* Mark all in-bounds tiles in a footprint occupied. Out-of-bounds tiles are + * ignored here; availability checks still reject out-of-bounds footprints via + * the static walkability check. */ +void fc_mark_footprint_occupied(uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int x, int y, int size); + +/* Build an occupancy grid from the current live entities. Pass ignore_npc_idx + * to omit the moving NPC's own current footprint. Set ignore_player when + * validating player movement or intentionally ignoring the player. */ +void fc_build_occupancy(const FcState* state, + uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int ignore_npc_idx, + int ignore_player); + +/* Check static terrain plus a caller-provided dynamic occupancy grid. */ +int fc_footprint_available_dynamic( + int x, int y, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]); + +/* Dynamic equivalent of the native sized-step rule. Occupied tiles behave as + * whole-tile blockers on the exact leading-edge cells checked by that rule. */ +int fc_footprint_step_available_dynamic( + int x, int y, int dx, int dy, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]); + +/* ======================================================================== */ +/* Movement */ +/* ======================================================================== */ + +/* Move a size-1 entity from (x,y) toward offset (dx,dy) for up to max_steps. + * Diagonal-first fallback. Returns number of tiles moved. Updates *x,*y. */ +int fc_move_toward(int* x, int* y, int dx, int dy, int max_steps, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]); + +/* Same movement operation, additionally returning each successfully consumed + * tile in step_x/step_y up to step_capacity entries. */ +int fc_move_toward_traced( + int* x, int* y, int dx, int dy, int max_steps, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int* step_x, int* step_y, int step_capacity); + +/* Dynamic-aware sized step. Diagonal movement checks the final footprint and + * both cardinal side footprints to prevent static or dynamic corner clipping. */ +int fc_npc_step_toward_sized_dynamic( + int* x, int* y, int target_x, int target_y, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]); + +/* ======================================================================== */ +/* Line of sight */ +/* ======================================================================== */ + +/* Chebyshev distance between the nearest tiles of two rectangular areas. */ +int fc_distance_between_areas(int src_x, int src_y, int src_size, + int dst_x, int dst_y, int dst_size); + +/* Footprint-aware LOS using the closest coordinate from each rectangle, as in + * the native line validator. */ +int fc_has_los_between_areas( + int src_x, int src_y, int src_size, + int dst_x, int dst_y, int dst_size, + const uint8_t los_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]); + +/* Rectangular-exclusive melee reach: only an open shared cardinal edge is + * valid. Diagonal contact and overlapping footprints are rejected. */ +int fc_npc_can_melee_player(int player_x, int player_y, + int npc_x, int npc_y, int npc_size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]); + +/* ======================================================================== */ +/* BFS pathfinding (for click-to-move) */ +/* ======================================================================== */ + +/* Native move-near fallback for human click-to-move. If the exact destination + * is unreachable, chooses the reachable tile within ten tiles with the lowest + * squared destination distance, breaking ties by route length. */ +int fc_pathfind_bfs_move_near( + int sx, int sy, int dx, int dy, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int out_x[], int out_y[], int max_steps); + +/* Route to the first shortest-path tile outside a target rectangle that has + * both the requested attack range and authoritative projectile LOS. */ +int fc_pathfind_attack_position( + int sx, int sy, int target_x, int target_y, int target_size, + int attack_range, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t los_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int out_x[], int out_y[], int max_steps); + + +/* Prayer */ + +typedef struct { + int prior_prayer; + int requested_final_prayer; + int actual_final_prayer; + int off_requested; + int off_performed; + int on_requested; + int on_succeeded; + int explicit_off_then_on; + int final_state_changed; +} FcPrayerTransition; + +/* Drain prayer points based on active prayer and bonus. + * prayer_active_at_tick_start should reflect the prayer state before the + * current tick's input actions were applied, so 1-tick flicks do not drain. */ +int fc_prayer_drain_tick(FcPlayer* p, int prayer_at_tick_start, + const FcPrayerTransition* transition); + +/* Apply a prayer action (from FC_PRAYER_* constants in fc_contracts.h) */ +FcPrayerTransition fc_prayer_apply_action(FcPlayer* p, int prayer_action); + +/* Apply capped Prayer loss in tenths. Every Prayer-loss source uses this + * boundary so depletion always deactivates the overhead and clears fraction. */ +int fc_prayer_apply_loss_tenths(FcPlayer* p, int requested_loss_tenths); + +/* Prayer potion restore amount in tenths (level-dependent) */ +int fc_prayer_potion_restore(int prayer_level); + + +/* Reward */ + +typedef struct { + float w_damage_dealt; /* legacy per-hit shaping; active config uses 0 */ + float w_progress; /* reward per required-work unit removed */ + float negative_progress_multiplier; + float w_damage_taken; + float w_npc_kill; + float w_wave_clear; + float w_jad_kill; + float w_cave_complete; + float w_player_death; + int scale_player_death_with_progress; + float player_death_min_scale; + float w_correct_jad_prayer; + float w_correct_danger_prayer; + float w_prayer_lost; + float w_invalid_action; + float w_tick_penalty; + + float shape_unnecessary_prayer_penalty; + float shape_wave_stall_base_penalty; + float shape_wave_stall_cap; + float shape_jad_heal_penalty; + float shape_npc_heal_penalty; + float shape_no_progress_penalty_1; + float shape_no_progress_penalty_2; + float shape_no_progress_penalty_3; + float shape_no_attack_base_penalty; + float shape_no_attack_wave_scale; + + int shape_wave_stall_start; + int shape_wave_stall_ramp_interval; + int shape_no_progress_start_1; + int shape_no_progress_start_2; + int shape_no_progress_start_3; + int shape_no_attack_start; +} FcRewardParams; + +typedef struct { + int ticks_since_attack; + int ticks_in_wave; + float required_work_at_wave_start; + float cave_progress_prev; + float last_required_work_remaining; + float last_current_wave_progress; + float last_cave_progress; + float last_progress_delta; + float last_progress_reward; + float last_net_required_work_removed; + int ticks_since_positive_progress; + int positive_progress_ticks; + int zero_progress_ticks; + int negative_progress_ticks; +} FcRewardRuntime; + +typedef struct { + int melee_pressure_npcs; + int any_threat; + int tokxil_melee; + int ketzek_melee; +} FcRewardThreatContext; + +typedef struct { + float raw[FC_REWARD_FEATURES]; + + float damage_dealt; + float progress; + float damage_taken; + float npc_kill; + float wave_clear; + float jad_kill; + float cave_complete; + float player_death; + + float correct_jad_prayer; + float correct_danger_prayer; + float prayer_lost; + float unnecessary_prayer; + float wave_stall; + float no_progress; + float no_attack; + float jad_heal; + float npc_heal; + + float invalid_action; + float tick_penalty; + + float total; + FcRewardThreatContext threat_ctx; +} FcRewardBreakdown; + +/* One slot per named breakdown field, excluding raw inputs and the total. */ +typedef enum { + FC_CH_DAMAGE_DEALT = 0, + FC_CH_PROGRESS, + FC_CH_DAMAGE_TAKEN, + FC_CH_NPC_KILL, + FC_CH_WAVE_CLEAR, + FC_CH_JAD_KILL, + FC_CH_CAVE_COMPLETE, + FC_CH_PLAYER_DEATH, + FC_CH_CORRECT_JAD_PRAYER, + FC_CH_CORRECT_DANGER_PRAYER, + FC_CH_PRAYER_LOST, + FC_CH_UNNECESSARY_PRAYER, + FC_CH_WAVE_STALL, + FC_CH_NO_PROGRESS, + FC_CH_NO_ATTACK, + FC_CH_JAD_HEAL, + FC_CH_NPC_HEAL, + FC_CH_INVALID_ACTION, + FC_CH_TICK_PENALTY, + FC_CH_COUNT +} FcRwdChannel; + +extern const char* const FC_CH_NAMES[FC_CH_COUNT]; + +void fc_reward_breakdown_channels(const FcRewardBreakdown* breakdown, + float out[FC_CH_COUNT]); +FcRewardParams fc_reward_default_params(void); +void fc_reward_runtime_reset(FcRewardRuntime* runtime); +float fc_reward_player_death_scale(const FcRewardParams* params, + float cave_progress); +float fc_reward_required_work_remaining(const FcState* state); +void fc_reward_sync_progress_state(FcState* state, + const FcRewardRuntime* runtime); +void fc_reward_runtime_begin_episode(FcRewardRuntime* runtime, FcState* state); +FcRewardBreakdown fc_reward_compute_breakdown( + const FcState* state, const FcRewardParams* params, FcRewardRuntime* runtime); + + +/* Wave */ + +/* + * fc_wave.h — Wave system for Fight Caves. + * + * 63 waves, 15 spawn rotations per wave. + * Wave table sourced from OSRS wiki + Kotlin archive TOML data. + * + * Spawn directions map to arena coordinates: + * SPAWN_SOUTH → (32, 5) bottom center + * SPAWN_SOUTH_WEST → (8, 8) bottom left + * SPAWN_NORTH_WEST → (8, 55) top left + * SPAWN_SOUTH_EAST → (55, 8) bottom right + * SPAWN_CENTER → (32, 32) arena center + */ + +/* Spawn direction → arena coordinate mapping */ +void fc_spawn_position(int spawn_dir, int* x, int* y); + +/* Spawn all NPCs for the given wave using the state's rotation_id */ +void fc_wave_spawn(FcState* state, int wave_num); + +/* Check if wave is cleared and advance to next wave. + * Called from check_terminal in fc_tick.c. + * Returns 1 if wave was advanced, 0 otherwise. */ +int fc_wave_check_advance(FcState* state); + +/* Jad healer spawn threshold: spawn 4 Yt-HurKot when Jad HP drops below this. + * Respawns are re-armed only if the previous healers restored Jad to full HP. */ +#define FC_JAD_HEALER_THRESHOLD_HP_TENTHS 1500 /* 150 HP */ +#define FC_JAD_NUM_HEALERS 4 + + +/* Api */ + +/* + * fc_api.h — Public API for the Fight Caves simulation. + * + * Follows the encounter vtable pattern from PufferLib OSRS PvP/Zulrah: + * init → reset(seed) → [step(actions) → write_obs → ...]* → destroy + * + * All functions operate on FcState. No global/static state. + */ + +/* ======================================================================== */ +/* Lifecycle */ +/* ======================================================================== */ + +/* Initialize state to a clean starting configuration. Zeroes all fields. + * Must be called once before the first reset. */ +void fc_init(FcState* state); + +/* Reset the episode with the given seed. Seeds the RNG, resets player/NPCs, + * selects a random wave rotation, sets up the arena. + * After reset, state is at tick 0 with wave 1 ready. */ +void fc_reset(FcState* state, uint32_t seed); + +/* Advance the simulation by one tick with the given actions. + * actions[0..FC_NUM_ACTION_HEADS-1] are the canonical core action head values. + * After step, per-tick event flags and terminal status are set. */ +void fc_step(FcState* state, const int actions[FC_NUM_ACTION_HEADS]); + +/* Canonical client preference request used by playable frontends. Gameplay + * movement still occurs only through fc_step(). */ +void fc_request_set_running(FcState* state, int enabled); + +/* Internal tick loop (called by fc_step). Exposed for testing. */ +void fc_tick(FcState* state, const int actions[FC_NUM_ACTION_HEADS]); + +/* Free any resources (currently none — FcState is stack/caller-allocated). + * Called for API symmetry and future-proofing. */ +void fc_destroy(FcState* state); + +/* ======================================================================== */ +/* Observation / Mask / Reward */ +/* ======================================================================== */ + +/* Write the observation buffer (policy obs + reward features). + * out must have room for FC_TOTAL_OBS floats. + * Values are normalized to [0,1] via divisor tables. */ +void fc_write_obs(const FcState* state, float* out); + +/* Zero specific observation slots in-place (for obs-ablation experiments). + * Apply AFTER fc_write_obs. Each flag, when non-zero, zeroes the + * corresponding region of the policy obs (does not touch reward features + * or the action mask): + * ablate_npc_distance — zeroes FC_NPC_DISTANCE for all 8 NPC slots + * ablate_incoming_aggregates — zeroes the 6 player-block IN_*_1T/2T fields + * + the 3 meta-block IN_*_3T fields + * ablate_npc_valid — zeroes FC_NPC_VALID for all 8 NPC slots + * + * out must point to the same buffer fc_write_obs filled (length FC_TOTAL_OBS). + * No-op when all three flags are 0. */ +void fc_apply_obs_ablation(float* out, + int ablate_npc_distance, + int ablate_incoming_aggregates, + int ablate_npc_valid); + +/* Fill out_indices with the active NPC array indices currently visible in + * policy NPC slots, using the same ordering as fc_write_obs and fc_write_mask. + * Returns the number of filled slots, capped at FC_VISIBLE_NPCS. */ +int fc_visible_npc_indices(const FcState* state, int out_indices[FC_VISIBLE_NPCS]); + +/* Write the action mask buffer. + * out must have room for FC_ACTION_MASK_SIZE floats. + * 1.0 = valid action, 0.0 = invalid. */ +void fc_write_mask(const FcState* state, float* out); + +/* Fill out_classes with 0/1 invalid-action diagnostics for Puffer-facing heads + * 0-2 only: move, attack, prayer. Core consumable/path-target heads stay + * excluded because the no-supplies policy does not emit them. */ +void fc_action_invalid_classes(const FcState* state, + const int actions[FC_NUM_ACTION_HEADS], + int out_classes[FC_INVALID_ACTION_CLASS_COUNT]); + +/* Compute and write reward features for the current tick. + * out must have room for FC_REWARD_FEATURES floats. + * These are raw feature values (not weighted). Python applies shaping weights. */ +void fc_write_reward_features(const FcState* state, float* out); + +/* Returns 1 if the episode has terminated (player death, cave complete, tick cap). */ +int fc_is_terminal(const FcState* state); + +/* ======================================================================== */ +/* Determinism */ +/* ======================================================================== */ + +/* Version 4 removes redundant compatibility/temporary fields from the + * complete core-owned fixed-width FcState serialization. */ +#define FC_STATE_HASH_VERSION 4u + +/* + * Compute a deterministic hash of the game state. + * + * Implementation: FNV-1a hash over explicit field values written to a + * canonical byte sequence. Does NOT hash raw struct padding bytes. + * This guarantees that two states with identical logical content produce + * identical hashes regardless of compiler padding or uninitialized bytes. + * + * Usage: Call after each fc_step to verify determinism. Two runs with + * the same (seed, action_sequence) must produce identical hashes at every tick. + */ +uint32_t fc_state_hash(const FcState* state); + +/* ======================================================================== */ +/* Rendering */ +/* ======================================================================== */ + +/* + * Fill an array of render entities for the viewer. + * The viewer calls this each tick to get a snapshot of all visible entities. + * entities[] must have room for FC_MAX_RENDER_ENTITIES entries. + * *count is set to the number of filled entries. + * + * Entity 0 is always the player. Entities 1..count-1 are active NPCs. + */ +void fc_fill_render_entities(const FcState* state, FcRenderEntity* entities, int* count); + +/* Copy the authoritative transition facts captured during the last tick. */ +void fc_fill_render_events(const FcState* state, FcRenderEvents* events); + +/* ======================================================================== */ +/* RNG (exposed for testing; normal callers use fc_reset to seed) */ +/* ======================================================================== */ + +/* Seed the RNG state. Called internally by fc_reset. */ +void fc_rng_seed(FcState* state, uint32_t seed); + +/* Generate a random uint32. Advances the RNG state. */ +uint32_t fc_rng_next(FcState* state); + +/* Generate a random int in [0, max) */ +int fc_rng_int(FcState* state, int max); + +/* Generate a random float in [0.0, 1.0) */ +float fc_rng_float(FcState* state); + + +/* Action Internal */ + +/* An already-active run may consume its remaining energy below 1%. Starting + * run mode follows the OSRS client/server minimum of one displayed percent. */ +static inline int fc_player_can_run(const FcPlayer* player) { + return player->run_energy > 0 && + (player->is_running || + player->run_energy >= FC_RUN_ENERGY_MIN_START); +} + +int fc_eat_action_valid(const FcState* state, int action); +int fc_drink_action_valid(const FcState* state, int action); + + +/* Spawn Internal */ + +int fc_spawn_find_available_footprint(const FcState* state, + int preferred_x, int preferred_y, + int size, int max_radius, + int* out_x, int* out_y); + +int fc_spawn_npc_first_free(FcState* state, int npc_type, int x, int y); + + +/* Wave Internal */ + +void fc_wave_record_current_duration(FcState* state); + + +/* Combat */ +#include +#include +#include + +/* + * fc_combat.c — OSRS combat math and pending hit resolution. + * + * Formulas adapted from osrs_combat_shared.h (PufferLib). + * + * PvM prayer semantics: + * Correct protection prayer BLOCKS 100% of the matching NPC attack style. + * This is standard OSRS PvM — NOT the PvP 60% reduction. + * Exceptions must be explicit per NPC/attack (e.g. Jad wrong-prayer still takes damage). + */ + +/* ======================================================================== */ +/* OSRS accuracy formula */ +/* ======================================================================== */ + +float fc_hit_chance(int att_roll, int def_roll) { + if (att_roll > def_roll) + return 1.0f - (float)(def_roll + 2) / (2.0f * (float)(att_roll + 1)); + else + return (float)att_roll / (2.0f * (float)(def_roll + 1)); +} + +/* ======================================================================== */ +/* NPC attack/max-hit formulas */ +/* ======================================================================== */ + +int fc_npc_attack_roll(int att_level, int att_bonus) { + /* NPCs use level + invisible_boost(9) × (bonus + 64) */ + return (att_level + 9) * (att_bonus + 64); +} + +/* ======================================================================== */ +/* Player defence roll */ +/* ======================================================================== */ + +int fc_player_def_roll(const FcPlayer* p, FcAttackType attack_type) { + int def_bonus; + switch (attack_type) { + case FC_ATTACK_TYPE_STAB: def_bonus = p->defence_stab; break; + case FC_ATTACK_TYPE_SLASH: def_bonus = p->defence_slash; break; + case FC_ATTACK_TYPE_CRUSH: def_bonus = p->defence_crush; break; + case FC_ATTACK_TYPE_RANGED: def_bonus = p->defence_ranged; break; + case FC_ATTACK_TYPE_MAGIC: def_bonus = p->defence_magic; break; + default: def_bonus = 0; break; + } + + int eff_def; + if (attack_type == FC_ATTACK_TYPE_MAGIC) { + /* OSRS truncates the Defence and Magic contributions separately. */ + eff_def = 3 * p->defence_level / 10 + + 7 * p->magic_level / 10 + 8; + } else { + eff_def = p->defence_level + 8; + } + return eff_def * (def_bonus + 64); +} + +/* ======================================================================== */ +/* Player ranged attack / max-hit */ +/* ======================================================================== */ + +static int fc_player_effective_ranged_level(const FcPlayer* p) { + /* Rapid is the active DPS style for both RCB and TBow in this sim. */ + return p->ranged_level + 8; +} + +int fc_player_ranged_base_attack_roll(const FcPlayer* p) { + int eff_ranged = fc_player_effective_ranged_level(p); + return eff_ranged * (p->ranged_attack_bonus + 64); +} + +static int fc_tbow_target_magic_level(const FcNpc* target) { + const FcNpcStats* stats = fc_npc_get_stats(target->npc_type); + int magic_level = stats->magic_level; + + if (magic_level < 0) magic_level = 0; + if (magic_level > 250) magic_level = 250; /* non-CoX cap */ + return magic_level; +} + +int fc_tbow_accuracy_multiplier_pct(int target_magic_level) { + int64_t magic = target_magic_level; + if (magic < 0) magic = 0; + if (magic > 250) magic = 250; + + int64_t inner = 3 * magic / 10; + int64_t delta = inner - 100; + int64_t pct = 140 + (3 * magic - 10) / 100 - + delta * delta / 100; + if (pct < 0) pct = 0; + if (pct > 140) pct = 140; + return (int)pct; +} + +int fc_tbow_damage_multiplier_pct(int target_magic_level) { + int64_t magic = target_magic_level; + if (magic < 0) magic = 0; + if (magic > 250) magic = 250; + + int64_t inner = 3 * magic / 10; + int64_t delta = inner - 140; + int64_t pct = 250 + (3 * magic - 14) / 100 - + delta * delta / 100; + if (pct < 0) pct = 0; + if (pct > 250) pct = 250; + return (int)pct; +} + +static void fc_crystal_modifiers_bp(int crystal_piece_mask, + int* accuracy_bp, int* damage_bp) { + int mask = crystal_piece_mask & FC_CRYSTAL_PIECE_ALL; + *accuracy_bp = 0; + *damage_bp = 0; + + if (mask & FC_CRYSTAL_PIECE_HELM) { + *accuracy_bp += FC_CRYSTAL_HELM_ACCURACY_BP; + *damage_bp += FC_CRYSTAL_HELM_DAMAGE_BP; + } + if (mask & FC_CRYSTAL_PIECE_BODY) { + *accuracy_bp += FC_CRYSTAL_BODY_ACCURACY_BP; + *damage_bp += FC_CRYSTAL_BODY_DAMAGE_BP; + } + if (mask & FC_CRYSTAL_PIECE_LEGS) { + *accuracy_bp += FC_CRYSTAL_LEGS_ACCURACY_BP; + *damage_bp += FC_CRYSTAL_LEGS_DAMAGE_BP; + } +} + +static int fc_apply_basis_points(int value, int bonus_bp) { + return (int)((int64_t)value * (10000 + bonus_bp) / 10000); +} + +int fc_player_ranged_attack_roll(const FcPlayer* p, const FcNpc* target) { + int attack_roll = fc_player_ranged_base_attack_roll(p); + + if (p->weapon_kind == FC_WEAPON_TWISTED_BOW && target) { + attack_roll = (int)((int64_t)attack_roll * + fc_tbow_accuracy_multiplier_pct(fc_tbow_target_magic_level(target)) / + 100); + } else if (p->weapon_kind == FC_WEAPON_BOW_OF_FAERDHINEN) { + int accuracy_bp; + int damage_bp; + fc_crystal_modifiers_bp(p->crystal_piece_mask, + &accuracy_bp, &damage_bp); + (void)damage_bp; + attack_roll = fc_apply_basis_points(attack_roll, accuracy_bp); + } + + return attack_roll; +} + +int fc_player_ranged_base_max_hit_hp(const FcPlayer* p) { + int eff_str = fc_player_effective_ranged_level(p); + return (int)(((int64_t)eff_str * (p->ranged_strength_bonus + 64) + 320) / + 640); +} + +int fc_player_ranged_final_max_hit_hp(const FcPlayer* p, const FcNpc* target) { + int base_hp = fc_player_ranged_base_max_hit_hp(p); + + if (p->weapon_kind == FC_WEAPON_TWISTED_BOW && target) { + base_hp = (int)((int64_t)base_hp * + fc_tbow_damage_multiplier_pct(fc_tbow_target_magic_level(target)) / + 100); + } else if (p->weapon_kind == FC_WEAPON_BOW_OF_FAERDHINEN) { + int accuracy_bp; + int damage_bp; + fc_crystal_modifiers_bp(p->crystal_piece_mask, + &accuracy_bp, &damage_bp); + (void)accuracy_bp; + base_hp = fc_apply_basis_points(base_hp, damage_bp); + } + + return base_hp; +} + +static int fc_damage_max_valid(const FcState* state, int final_max_hit_hp) { + return state != NULL && final_max_hit_hp >= 0 && + final_max_hit_hp <= INT_MAX / 10; +} + +int fc_roll_player_damage_tenths(FcState* state, int final_max_hit_hp) { + if (!fc_damage_max_valid(state, final_max_hit_hp) || + final_max_hit_hp == 0) { + return 0; + } + int rolled_hp = fc_rng_int(state, final_max_hit_hp + 1); + if (rolled_hp == 0) rolled_hp = 1; + return rolled_hp * 10; +} + +int fc_roll_npc_damage_tenths(FcState* state, int final_max_hit_hp) { + if (!fc_damage_max_valid(state, final_max_hit_hp) || + final_max_hit_hp == 0) { + return 0; + } + return fc_rng_int(state, final_max_hit_hp + 1) * 10; +} + +/* ======================================================================== */ +/* Prayer check */ +/* ======================================================================== */ + +int fc_prayer_blocks_style(int prayer, int attack_style) { + /* + * PvM: correct protection prayer blocks 100% of matching style. + * Our enum mapping: + * PRAYER_PROTECT_MELEE(1) blocks ATTACK_MELEE(1) + * PRAYER_PROTECT_RANGE(2) blocks ATTACK_RANGED(2) + * PRAYER_PROTECT_MAGIC(3) blocks ATTACK_MAGIC(3) + */ + if (prayer == PRAYER_NONE || attack_style == ATTACK_NONE) return 0; + return (prayer == attack_style) ? 1 : 0; +} + +/* ======================================================================== */ +/* Chebyshev distance to multi-tile NPC */ +/* ======================================================================== */ + +int fc_distance_to_npc(int px, int py, const FcNpc* npc) { + return fc_distance_between_areas(px, py, 1, + npc->x, npc->y, npc->size); +} + +/* ======================================================================== */ +/* Hit delay formulas */ +/* ======================================================================== */ + +/* + * OSRS projectile hit delay: + * travel_time = time_offset + (distance * multiplier) [in client ticks, 20ms each] + * game_ticks = travel_time / 30 + 1 [CLIENT_TICKS.toTicks() = n/30] + * + * Per-NPC projectile definitions from tzhaar_fight_cave.gfx.toml: + * tok_xil_shoot: delay=32, height=256, curve=16, no offset/mult → default mult=5 + * ket_zek_travel: delay=28, height=128, curve=16, offset=8, mult=8 + * tztok_jad_travel: delay=86, height=50, curve=16, mult=8, no offset + * Jad ranged: no projectile, fixed client delay=120 + * + * Melee: delay 1 (resolves same tick in our system — queued then resolved in same tick loop) + */ + +/* Player ranged projectile timing */ +int fc_ranged_hit_delay(int distance) { + /* Keep the existing lightweight projectile timing for player ranged attacks. */ + int travel = 5 * distance; /* default multiplier for player ranged */ + return travel / 30 + 1; +} + +/* + * Per-NPC-type hit delay — uses exact projectile timing from Void 634 gfx.toml. + * Called from NPC attack code for precise parity with RSPS. + */ +int fc_npc_hit_delay(int npc_type, int attack_style, int distance) { + if (attack_style == ATTACK_MELEE) return 1; + + switch (npc_type) { + case NPC_TOK_XIL: + /* tok_xil_shoot: default mult=5, no offset */ + return (5 * distance) / 30 + 1; + + case NPC_KET_ZEK: + /* ket_zek_travel: offset=8, mult=8 */ + return (8 + 8 * distance) / 30 + 1; + + case NPC_TZTOK_JAD: + if (attack_style == ATTACK_MAGIC) { + /* Keep at least one full policy decision tick between Jad's + * tell and impact, including when Magic is selected in melee. */ + int delay = (8 * distance) / 30 + 1; + return delay < 2 ? 2 : delay; + } else { + /* Jad ranged: no projectile, fixed client delay=120 */ + return 120 / 30 + 1; /* = 5 game ticks */ + } + + default: + /* Fallback for any other ranged/magic NPC */ + if (attack_style == ATTACK_RANGED) return (5 * distance) / 30 + 1; + return (8 + 8 * distance) / 30 + 1; + } +} + +/* ======================================================================== */ +/* NPC defence roll (for player attack accuracy against NPC) */ +/* ======================================================================== */ + +int fc_npc_def_roll(int def_level, int def_bonus) { + /* NPC defence: (def_level + 9) × (def_bonus + 64) */ + return (def_level + 9) * (def_bonus + 64); +} + +/* ======================================================================== */ +/* Queue a pending hit */ +/* ======================================================================== */ + +int fc_queue_pending_hit(FcPendingHit hits[], int* num_hits, int max_hits, + int damage, int ticks, int style, int source_idx, + int prayer_drain) { + if (*num_hits >= max_hits) return 0; + FcPendingHit* h = &hits[*num_hits]; + h->active = 1; + h->damage = damage; + h->ticks_remaining = ticks; + h->attack_style = style; + h->source_npc_idx = source_idx; + h->prayer_drain = prayer_drain; + h->prayer_snapshot = PRAYER_NONE; + h->prayer_lock_tick = -1; + (*num_hits)++; + return 1; +} + +/* ======================================================================== */ +/* Resolve pending hits (called each tick) */ +/* ======================================================================== */ + +static void record_render_hit(FcState* state, int target_entity_type, + int target_npc_slot, int source_npc_slot, + int attack_style, int damage, int blocked) { + FcRenderEvents* events = &state->render_events; + if (events->hit_count >= FC_MAX_RENDER_HITS) return; + + FcRenderHit* hit = &events->hits[events->hit_count++]; + hit->target_entity_type = target_entity_type; + hit->target_npc_slot = target_npc_slot; + hit->source_npc_slot = source_npc_slot; + hit->attack_style = attack_style; + hit->damage = damage; + hit->blocked = blocked; +} + +void fc_resolve_player_pending_hits(FcState* state) { + FcPlayer* p = &state->player; + int write = 0; + + for (int i = 0; i < p->num_pending_hits; i++) { + FcPendingHit* h = &p->pending_hits[i]; + if (!h->active) continue; + + h->ticks_remaining--; + if (h->ticks_remaining <= 0) { + /* Hit resolves now — use the prayer locked into this hit. */ + int locked_prayer = h->prayer_snapshot >= 0 + ? h->prayer_snapshot : PRAYER_NONE; + int blocked = fc_prayer_blocks_style(locked_prayer, h->attack_style); + int final_damage = blocked ? 0 : h->damage; + + /* Apply damage */ + p->current_hp -= final_damage; + if (p->current_hp < 0) p->current_hp = 0; + + p->damage_taken_this_tick += final_damage; + p->hit_style_this_tick = h->attack_style; + p->hit_source_npc_type = state->npcs[h->source_npc_idx].npc_type; + p->hit_locked_prayer_this_tick = locked_prayer; + p->hit_blocked_this_tick = blocked; + state->damage_taken_this_tick += final_damage; + p->total_damage_taken += final_damage; + p->hit_landed_this_tick = 1; + record_render_hit(state, ENTITY_PLAYER, -1, + h->source_npc_idx, h->attack_style, + final_damage, blocked); + + /* Auto-retaliate: if player has no target, target the attacker. + * approach_target stays 0 — player attacks in place, doesn't chase. */ + if (p->attack_target_idx < 0 && h->source_npc_idx >= 0) { + FcNpc* attacker = &state->npcs[h->source_npc_idx]; + if (attacker->active && !attacker->is_dead) { + p->attack_target_idx = h->source_npc_idx; + p->approach_target = 0; /* don't chase, attack from here */ + p->approach_target_x = -1; + p->approach_target_y = -1; + p->approach_target_size = 0; + } + } + + /* Tz-Kih drains damage dealt + 1 Prayer point, with the base point + * still applying to misses and prayer-blocked attacks. HP and Prayer + * both use tenths internally, so final_damage can be added directly. */ + if (h->prayer_drain > 0) { + int drain = h->prayer_drain; + if (h->source_npc_idx >= 0 && h->source_npc_idx < FC_MAX_NPCS && + state->npcs[h->source_npc_idx].npc_type == NPC_TZ_KIH) { + drain += final_damage; + } + int actual_drain = fc_prayer_apply_loss_tenths(p, drain); + state->prayer_lost_this_tick += actual_drain; + state->tz_kih_prayer_drain_this_tick += actual_drain; + if (h->source_npc_idx >= 0 && h->source_npc_idx < FC_MAX_NPCS) { + state->npcs[h->source_npc_idx].prayer_drain_dealt_this_tick += + actual_drain; + } + } + + /* Track prayer correctness. Correctly blocked Jad hits also use + * the shared correct-prayer reward applied to every NPC. */ + if (state->npcs[h->source_npc_idx].npc_type == NPC_TZTOK_JAD) { + if (blocked) { + state->correct_jad_prayer = 1; + state->correct_danger_prayer = 1; + } else { + state->wrong_jad_prayer = 1; + } + } else if (h->attack_style == ATTACK_RANGED || + h->attack_style == ATTACK_MAGIC || + h->attack_style == ATTACK_MELEE) { + if (blocked) state->correct_danger_prayer = 1; + else state->wrong_danger_prayer = 1; + } + + /* Episode-level hit analytics */ + if (locked_prayer != PRAYER_NONE) { + if (blocked) { + state->ep_correct_blocks++; + state->ep_damage_blocked += h->damage; + } else { + state->ep_wrong_prayer_hits++; + } + } else { + state->ep_no_prayer_hits++; + } + + h->active = 0; /* consumed */ + } else { + /* Still in flight — keep */ + if (write != i) p->pending_hits[write] = *h; + write++; + } + } + p->num_pending_hits = write; +} + +static void complete_fight_caves(FcState* state) { + state->jad_killed = 1; + state->ep_jad_killed = 1; + + /* Jad death completes the cave and immediately despawns surviving + * healers, matching the encounter lifecycle. */ + for (int i = 0; i < FC_MAX_NPCS; i++) { + FcNpc* other = &state->npcs[i]; + if (other->active && !other->is_dead && + other->npc_type == NPC_YT_HURKOT) { + other->is_dead = 1; + other->died_this_tick = 1; + other->death_timer = 0; + state->npcs_remaining--; + } + } + state->wave_just_cleared = 1; + state->terminal = TERMINAL_CAVE_COMPLETE; + fc_wave_record_current_duration(state); +} + +static void resolve_npc_death(FcState* state, FcNpc* npc) { + npc->is_dead = 1; + npc->died_this_tick = 1; + npc->death_timer = 3; + + /* Each entity is one kill. A Tz-Kek parent is counted here before its two + * children are counted through this same path later. */ + state->npcs_killed_this_tick++; + if (npc->npc_type == NPC_YT_HURKOT && + npc->is_respawned_jad_healer) { + state->respawned_jad_healers_killed_this_tick++; + } + state->total_npcs_killed++; + + if (npc->npc_type == NPC_TZTOK_JAD) { + complete_fight_caves(state); + } + + /* The Tz-Kek parent was pre-counted as two at spawn. Only its split + * children decrement the wave's remaining-work count when they die. */ + if (npc->npc_type == NPC_TZ_KEK) { + fc_npc_tz_kek_split(state, npc->x, npc->y); + } else { + state->npcs_remaining--; + } +} + +void fc_resolve_npc_pending_hits(FcState* state, int npc_idx) { + FcNpc* npc = &state->npcs[npc_idx]; + int write = 0; + + for (int i = 0; i < npc->num_pending_hits; i++) { + FcPendingHit* h = &npc->pending_hits[i]; + if (!h->active) continue; + + h->ticks_remaining--; + if (h->ticks_remaining <= 0) { + /* Player's hit lands on NPC */ + npc->current_hp -= h->damage; + if (npc->current_hp < 0) npc->current_hp = 0; + + npc->damage_taken_this_tick += h->damage; + state->damage_dealt_this_tick += h->damage; + if (npc->npc_type > NPC_NONE && npc->npc_type < NPC_TYPE_COUNT) { + state->ep_resolved_hits_to_npc_type[npc->npc_type]++; + state->ep_damage_to_npc_type[npc->npc_type] += h->damage; + if (h->damage > 0) { + state->ep_damaging_hits_to_npc_type[npc->npc_type]++; + } + } + if (h->damage > 0) { + state->hits_landed_this_tick++; + } + record_render_hit(state, ENTITY_NPC, npc_idx, -1, + h->attack_style, h->damage, 0); + + /* Track Jad-specific damage */ + if (npc->npc_type == NPC_TZTOK_JAD) { + state->jad_damage_this_tick += h->damage; + } + + /* Yt-HurKot: any landed player attack distracts healer, including 0s. */ + if (npc->npc_type == NPC_YT_HURKOT) { + npc->healer_distracted = 1; + npc->heal_target_idx = -1; + } + + /* NPC death — keep active for a few ticks so viewer can + * show the killing hitsplat and death animation. */ + if (npc->current_hp <= 0 && !npc->is_dead) { + resolve_npc_death(state, npc); + } + + h->active = 0; + } else { + if (write != i) npc->pending_hits[write] = *h; + write++; + } + } + npc->num_pending_hits = write; +} + + +/* Episode Summary */ +#include + +void fc_episode_summary_build(const FcState* state, int episode_length, + FcEpisodeSummary* summary) { + if (!summary) return; + memset(summary, 0, sizeof(*summary)); + if (!state) return; + + summary->episode_length = episode_length; + summary->wave_reached = state->current_wave; + summary->npcs_slayed = state->total_npcs_killed; + if (episode_length > 0) { + summary->prayer_uptime_melee = + (float)state->ep_ticks_pray_melee / (float)episode_length; + summary->prayer_uptime_range = + (float)state->ep_ticks_pray_range / (float)episode_length; + summary->prayer_uptime_magic = + (float)state->ep_ticks_pray_magic / (float)episode_length; + } + summary->correct_prayer = state->ep_correct_blocks; + summary->wrong_prayer_hits = state->ep_wrong_prayer_hits; + summary->no_prayer_hits = state->ep_no_prayer_hits; + summary->prayer_switches = state->ep_prayer_switches; + summary->damage_blocked = state->ep_damage_blocked; + summary->damage_taken = state->player.total_damage_taken; + if (state->ep_attack_ready_ticks > 0) { + summary->attack_when_ready_rate = + (float)state->ep_attack_attempt_ticks / + (float)state->ep_attack_ready_ticks; + } + summary->invalid_move = + state->ep_invalid_action_classes[FC_INVALID_ACTION_MOVE]; + summary->invalid_attack = + state->ep_invalid_action_classes[FC_INVALID_ACTION_ATTACK]; + summary->invalid_prayer = + state->ep_invalid_action_classes[FC_INVALID_ACTION_PRAYER]; + summary->tokxil_melee_ticks = state->ep_tokxil_melee_ticks; + summary->ketzek_melee_ticks = state->ep_ketzek_melee_ticks; + summary->max_wave_ticks = state->ep_max_wave_ticks; + summary->max_wave_ticks_wave = state->ep_max_wave_ticks_wave; + summary->reached_wave_63 = state->ep_reached_wave_63; + summary->jad_killed = state->ep_jad_killed; + summary->player_died = state->terminal == TERMINAL_PLAYER_DEATH; + + memcpy(summary->damage_to_npc_type, state->ep_damage_to_npc_type, + sizeof(summary->damage_to_npc_type)); + memcpy(summary->resolved_hits_to_npc_type, + state->ep_resolved_hits_to_npc_type, + sizeof(summary->resolved_hits_to_npc_type)); + memcpy(summary->damaging_hits_to_npc_type, + state->ep_damaging_hits_to_npc_type, + sizeof(summary->damaging_hits_to_npc_type)); + memcpy(summary->attack_cycles_to_npc_type, + state->ep_attack_cycles_to_npc_type, + sizeof(summary->attack_cycles_to_npc_type)); + memcpy(summary->target_ticks_by_npc_type, + state->ep_target_ticks_by_npc_type, + sizeof(summary->target_ticks_by_npc_type)); + + summary->target_held_ticks = state->ep_target_held_ticks; + summary->no_target_ticks = state->ep_no_target_ticks; + summary->target_in_range_los_ticks = + state->ep_target_in_range_los_ticks; + summary->target_out_of_range_or_los_ticks = + state->ep_target_out_of_range_or_los_ticks; + summary->attack_cooldown_wait_ticks = + state->ep_attack_cooldown_wait_ticks; + summary->ready_but_no_attack_ticks = + state->ep_ready_but_no_attack_ticks; + summary->action_move_idle_ticks = state->ep_action_move_idle_ticks; + summary->action_move_walk_ticks = state->ep_action_move_walk_ticks; + summary->action_move_run_ticks = state->ep_action_move_run_ticks; + summary->action_attack_none_ticks = state->ep_action_attack_none_ticks; + summary->action_attack_target_ticks = + state->ep_action_attack_target_ticks; + summary->action_prayer_noop_ticks = state->ep_action_prayer_noop_ticks; + summary->action_prayer_cmd_ticks = state->ep_action_prayer_cmd_ticks; +} + +const char* fc_episode_npc_metric_name(int npc_type) { + switch (npc_type) { + case NPC_NONE: return "none"; + case NPC_TZ_KIH: return "tz_kih"; + case NPC_TZ_KEK: return "tz_kek"; + case NPC_TZ_KEK_SM: return "tz_kek_sm"; + case NPC_TOK_XIL: return "tok_xil"; + case NPC_YT_MEJKOT: return "yt_mejkot"; + case NPC_KET_ZEK: return "ket_zek"; + case NPC_TZTOK_JAD: return "tztok_jad"; + case NPC_YT_HURKOT: return "yt_hurkot"; + default: return "unknown"; + } +} + + +/* Hash */ +#include +#include + +/* + * Version 2 serializes every FcState field explicitly in the documented order + * below, including whole-tile, directional movement, and projectile collision + * maps. Signed integers and floats are represented by 32 bits, then fed + * least-significant byte first. Arena bytes are fed directly. This order is + * the canonical contract: never hash struct storage, padding, or pointers. + */ + +#define FC_HASH_FNV_OFFSET UINT32_C(0x811c9dc5) +#define FC_HASH_FNV_PRIME UINT32_C(0x01000193) + +_Static_assert(sizeof(int) == sizeof(int32_t), + "canonical state hash requires 32-bit int"); +_Static_assert(sizeof(float) == sizeof(uint32_t), + "canonical state hash requires 32-bit float"); + +static uint32_t fc_hash_u8(uint32_t hash, uint8_t value) { + return (hash ^ value) * FC_HASH_FNV_PRIME; +} + +static uint32_t fc_hash_u32(uint32_t hash, uint32_t value) { + for (unsigned shift = 0; shift < 32; shift += 8) { + hash = fc_hash_u8(hash, (uint8_t)(value >> shift)); + } + return hash; +} + +static uint32_t fc_hash_i32(uint32_t hash, int value) { + return fc_hash_u32(hash, (uint32_t)(int32_t)value); +} + +static uint32_t fc_hash_f32(uint32_t hash, float value) { + uint32_t bits; + memcpy(&bits, &value, sizeof(bits)); + return fc_hash_u32(hash, bits); +} + +#define FC_HASH_I32(value) hash = fc_hash_i32(hash, (value)) +#define FC_HASH_U32(value) hash = fc_hash_u32(hash, (value)) +#define FC_HASH_F32(value) hash = fc_hash_f32(hash, (value)) + +static uint32_t fc_hash_pending_hit(uint32_t hash, const FcPendingHit* hit) { + FC_HASH_I32(hit->active); + FC_HASH_I32(hit->damage); + FC_HASH_I32(hit->ticks_remaining); + FC_HASH_I32(hit->attack_style); + FC_HASH_I32(hit->source_npc_idx); + FC_HASH_I32(hit->prayer_drain); + FC_HASH_I32(hit->prayer_snapshot); + FC_HASH_I32(hit->prayer_lock_tick); + return hash; +} + +static uint32_t fc_hash_player(uint32_t hash, const FcPlayer* player) { + FC_HASH_I32(player->x); + FC_HASH_I32(player->y); + FC_HASH_I32(player->current_hp); + FC_HASH_I32(player->max_hp); + FC_HASH_I32(player->current_prayer); + FC_HASH_I32(player->max_prayer); + FC_HASH_I32(player->prayer); + FC_HASH_I32(player->prayer_at_tick_start); + FC_HASH_I32(player->prayer_drain_counter); + FC_HASH_I32(player->sharks_remaining); + FC_HASH_I32(player->prayer_doses_remaining); + FC_HASH_I32(player->attack_timer); + FC_HASH_I32(player->food_timer); + FC_HASH_I32(player->potion_timer); + FC_HASH_I32(player->combo_timer); + FC_HASH_I32(player->run_energy); + FC_HASH_I32(player->is_running); + FC_HASH_I32(player->attack_level); + FC_HASH_I32(player->strength_level); + FC_HASH_I32(player->defence_level); + FC_HASH_I32(player->ranged_level); + FC_HASH_I32(player->prayer_level); + FC_HASH_I32(player->magic_level); + FC_HASH_I32(player->weapon_kind); + FC_HASH_I32(player->weapon_uses_ammo); + FC_HASH_I32(player->crystal_piece_mask); + FC_HASH_I32(player->weapon_speed); + FC_HASH_I32(player->weapon_range); + FC_HASH_I32(player->ranged_attack_bonus); + FC_HASH_I32(player->ranged_strength_bonus); + FC_HASH_I32(player->defence_stab); + FC_HASH_I32(player->defence_slash); + FC_HASH_I32(player->defence_crush); + FC_HASH_I32(player->defence_magic); + FC_HASH_I32(player->defence_ranged); + FC_HASH_I32(player->prayer_bonus); + FC_HASH_I32(player->ammo_count); + FC_HASH_I32(player->hp_regen_counter); + for (int i = 0; i < FC_MAX_ROUTE; ++i) { + FC_HASH_I32(player->route_x[i]); + FC_HASH_I32(player->route_y[i]); + } + FC_HASH_I32(player->route_len); + FC_HASH_I32(player->route_idx); + FC_HASH_F32(player->facing_angle); + FC_HASH_I32(player->attack_target_idx); + FC_HASH_I32(player->approach_target); + FC_HASH_I32(player->approach_target_x); + FC_HASH_I32(player->approach_target_y); + FC_HASH_I32(player->approach_target_size); + for (int i = 0; i < FC_MAX_PENDING_HITS; ++i) { + hash = fc_hash_pending_hit(hash, &player->pending_hits[i]); + } + FC_HASH_I32(player->num_pending_hits); + FC_HASH_I32(player->damage_taken_this_tick); + FC_HASH_I32(player->hit_style_this_tick); + FC_HASH_I32(player->hit_source_npc_type); + FC_HASH_I32(player->hit_locked_prayer_this_tick); + FC_HASH_I32(player->hit_blocked_this_tick); + FC_HASH_I32(player->hit_landed_this_tick); + FC_HASH_I32(player->food_eaten_this_tick); + FC_HASH_I32(player->potion_used_this_tick); + FC_HASH_I32(player->prayer_changed_this_tick); + FC_HASH_I32(player->total_damage_taken); + FC_HASH_I32(player->total_food_eaten); + FC_HASH_I32(player->total_potions_used); + return hash; +} + +static uint32_t fc_hash_npc(uint32_t hash, const FcNpc* npc) { + FC_HASH_I32(npc->active); + FC_HASH_I32(npc->npc_type); + FC_HASH_I32(npc->spawn_index); + FC_HASH_I32(npc->x); + FC_HASH_I32(npc->y); + FC_HASH_I32(npc->size); + FC_HASH_I32(npc->current_hp); + FC_HASH_I32(npc->max_hp); + FC_HASH_I32(npc->is_dead); + FC_HASH_I32(npc->death_timer); + FC_HASH_I32(npc->attack_style); + FC_HASH_I32(npc->attack_timer); + FC_HASH_I32(npc->attack_speed); + FC_HASH_I32(npc->attack_range); + FC_HASH_I32(npc->movement_speed); + FC_HASH_I32(npc->heal_timer); + FC_HASH_I32(npc->heal_amount); + FC_HASH_I32(npc->healer_distracted); + FC_HASH_I32(npc->heal_target_idx); + FC_HASH_I32(npc->is_respawned_jad_healer); + FC_HASH_I32(npc->damage_taken_this_tick); + FC_HASH_I32(npc->prayer_drain_dealt_this_tick); + FC_HASH_I32(npc->healing_received_this_tick); + FC_HASH_I32(npc->healing_given_this_tick); + FC_HASH_I32(npc->healed_by_mejkot_this_tick); + FC_HASH_I32(npc->healed_by_hurkot_this_tick); + FC_HASH_I32(npc->healed_self_this_tick); + FC_HASH_I32(npc->died_this_tick); + for (int i = 0; i < FC_MAX_PENDING_HITS; ++i) { + hash = fc_hash_pending_hit(hash, &npc->pending_hits[i]); + } + FC_HASH_I32(npc->num_pending_hits); + return hash; +} + +uint32_t fc_state_hash(const FcState* state) { + uint32_t hash = FC_HASH_FNV_OFFSET; + + hash = fc_hash_player(hash, &state->player); + for (int i = 0; i < FC_MAX_NPCS; ++i) { + hash = fc_hash_npc(hash, &state->npcs[i]); + } + + FC_HASH_I32(state->active_loadout); + FC_HASH_I32(state->current_wave); + FC_HASH_I32(state->rotation_id); + FC_HASH_I32(state->npcs_remaining); + FC_HASH_I32(state->total_npcs_killed); + FC_HASH_I32(state->next_spawn_index); + FC_HASH_I32(state->tick); + FC_HASH_I32(state->terminal); + FC_HASH_U32(state->rng_state); + FC_HASH_U32(state->rng_seed); + for (int x = 0; x < FC_ARENA_WIDTH; ++x) { + for (int y = 0; y < FC_ARENA_HEIGHT; ++y) { + hash = fc_hash_u8(hash, state->walkable[x][y]); + } + } + for (int x = 0; x < FC_ARENA_WIDTH; ++x) { + for (int y = 0; y < FC_ARENA_HEIGHT; ++y) { + hash = fc_hash_u8(hash, state->movement_flags[x][y]); + } + } + for (int x = 0; x < FC_ARENA_WIDTH; ++x) { + for (int y = 0; y < FC_ARENA_HEIGHT; ++y) { + hash = fc_hash_u8(hash, state->los_flags[x][y]); + } + } + + FC_HASH_I32(state->jad_healers_spawned); + FC_HASH_I32(state->jad_healer_spawn_generations); + + FC_HASH_I32(state->damage_dealt_this_tick); + FC_HASH_I32(state->hits_landed_this_tick); + FC_HASH_I32(state->damage_taken_this_tick); + FC_HASH_I32(state->prayer_lost_this_tick); + FC_HASH_I32(state->overhead_prayer_lost_this_tick); + FC_HASH_I32(state->tz_kih_prayer_drain_this_tick); + FC_HASH_I32(state->npcs_killed_this_tick); + FC_HASH_I32(state->respawned_jad_healers_killed_this_tick); + FC_HASH_I32(state->wave_just_cleared); + FC_HASH_I32(state->jad_damage_this_tick); + FC_HASH_I32(state->jad_killed); + FC_HASH_I32(state->correct_jad_prayer); + FC_HASH_I32(state->wrong_jad_prayer); + FC_HASH_I32(state->correct_danger_prayer); + FC_HASH_I32(state->wrong_danger_prayer); + FC_HASH_I32(state->attack_attempt_this_tick); + FC_HASH_I32(state->invalid_action_this_tick); + for (int i = 0; i < FC_INVALID_ACTION_CLASS_COUNT; ++i) { + FC_HASH_I32(state->invalid_action_class_this_tick[i]); + } + FC_HASH_I32(state->movement_this_tick); + FC_HASH_I32(state->idle_this_tick); + FC_HASH_I32(state->food_used_this_tick); + FC_HASH_I32(state->prayer_potion_used_this_tick); + FC_HASH_I32(state->jad_heal_procs_this_tick); + FC_HASH_I32(state->npc_heal_procs_this_tick); + FC_HASH_I32(state->npc_heal_amount_this_tick); + FC_HASH_I32(state->mejkot_heal_amount_this_tick); + FC_HASH_I32(state->jad_heal_amount_this_tick); + + FC_HASH_F32(state->progress_required_work_start); + FC_HASH_F32(state->progress_required_work_remaining); + FC_HASH_F32(state->progress_current_wave_progress); + FC_HASH_F32(state->progress_cave_progress); + FC_HASH_I32(state->progress_ticks_since_positive); + + FC_HASH_I32(state->ep_ticks_pray_melee); + FC_HASH_I32(state->ep_ticks_pray_range); + FC_HASH_I32(state->ep_ticks_pray_magic); + FC_HASH_I32(state->ep_correct_blocks); + FC_HASH_I32(state->ep_wrong_prayer_hits); + FC_HASH_I32(state->ep_no_prayer_hits); + FC_HASH_I32(state->ep_damage_blocked); + FC_HASH_I32(state->ep_prayer_switches); + FC_HASH_I32(state->ep_pots_used); + FC_HASH_I32(state->ep_pots_wasted); + FC_HASH_I32(state->ep_pot_pre_prayer_sum); + FC_HASH_I32(state->ep_food_eaten); + FC_HASH_I32(state->ep_food_pre_hp_sum); + FC_HASH_I32(state->ep_food_overhealed); + FC_HASH_I32(state->ep_pots_overrestored); + FC_HASH_I32(state->ep_tokxil_melee_ticks); + FC_HASH_I32(state->ep_ketzek_melee_ticks); + FC_HASH_I32(state->ep_attack_ready_ticks); + FC_HASH_I32(state->ep_attack_attempt_ticks); + for (int i = 0; i < FC_INVALID_ACTION_CLASS_COUNT; ++i) { + FC_HASH_I32(state->ep_invalid_action_classes[i]); + } + for (int i = 0; i < NPC_TYPE_COUNT; ++i) { + FC_HASH_I32(state->ep_damage_to_npc_type[i]); + FC_HASH_I32(state->ep_resolved_hits_to_npc_type[i]); + FC_HASH_I32(state->ep_damaging_hits_to_npc_type[i]); + FC_HASH_I32(state->ep_attack_cycles_to_npc_type[i]); + FC_HASH_I32(state->ep_target_ticks_by_npc_type[i]); + } + FC_HASH_I32(state->ep_target_held_ticks); + FC_HASH_I32(state->ep_no_target_ticks); + FC_HASH_I32(state->ep_target_in_range_los_ticks); + FC_HASH_I32(state->ep_target_out_of_range_or_los_ticks); + FC_HASH_I32(state->ep_attack_cooldown_wait_ticks); + FC_HASH_I32(state->ep_ready_but_no_attack_ticks); + FC_HASH_I32(state->ep_action_move_idle_ticks); + FC_HASH_I32(state->ep_action_move_walk_ticks); + FC_HASH_I32(state->ep_action_move_run_ticks); + FC_HASH_I32(state->ep_action_attack_none_ticks); + FC_HASH_I32(state->ep_action_attack_target_ticks); + FC_HASH_I32(state->ep_action_prayer_noop_ticks); + FC_HASH_I32(state->ep_action_prayer_cmd_ticks); + FC_HASH_I32(state->ep_reached_wave_63); + FC_HASH_I32(state->ep_jad_killed); + FC_HASH_I32(state->wave_start_tick); + FC_HASH_I32(state->ep_max_wave_ticks); + FC_HASH_I32(state->ep_max_wave_ticks_wave); + return hash; +} + +#undef FC_HASH_I32 +#undef FC_HASH_U32 +#undef FC_HASH_F32 + +#undef FC_HASH_FNV_OFFSET +#undef FC_HASH_FNV_PRIME +#undef FC_HASH_I32 +#undef FC_HASH_U32 +#undef FC_HASH_F32 + +/* Loadouts */ +/* + * LOADOUT A: Mid-level — Black D'hide + Rune Crossbow + * + * Slot Item Rng Atk Rng Str Stab Slash Crush Magic Ranged Prayer + * ---- ---- ------- ------- ---- ----- ----- ----- ------ ------ + * Head Coif 2 0 4 6 8 4 4 0 + * Weapon Rune Crossbow 90 0 0 0 0 0 0 0 + * Body Black D'hide Body 30 0 55 47 60 50 55 0 + * Legs Black D'hide Chaps 17 0 31 25 33 28 31 0 + * Hands Black D'hide Vambraces 11 0 6 5 7 8 0 0 + * Feet Snakeskin Boots 3 0 1 1 2 1 0 0 + * Ammo Adamant Bolts 0 100 0 0 0 0 0 0 + * ---- ---- ---- ---- ---- ---- ---- ---- + * TOTAL 153 100 97 84 110 91 90 0 + */ + +/* + * LOADOUT B: End-game — Masori (f) + Twisted Bow + * + * Slot Item Rng Atk Rng Str Stab Slash Crush Magic Ranged Prayer + * ---- ---- ------- ------- ---- ----- ----- ----- ------ ------ + * Head Masori mask (f) 12 2 8 10 12 12 9 1 + * Cape Ava's assembler 8 2 0 0 0 0 0 0 + * Neck Necklace of anguish 15 5 0 0 0 0 0 2 + * Weapon Twisted bow 70 20 0 0 0 0 0 0 + * Body Masori body (f) 43 4 59 52 64 74 60 1 + * Legs Masori chaps (f) 27 2 35 30 39 46 37 1 + * Hands Zaryte vambraces 18 2 8 8 8 5 8 1 + * Feet Pegasian boots 12 0 5 5 5 5 5 0 + * Ring Venator ring 10 2 0 0 0 0 0 0 + * Ammo Dragon arrows 0 60 0 0 0 0 0 0 + * Shield (none) 0 0 0 0 0 0 0 0 + * ---- ---- ---- ---- ---- ---- ---- ---- + * TOTAL 215 99 116 106 129 150 121 6 + */ + +const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { + /* [FC_LOADOUT_BLACK_DHIDE_RCB] Mid-level — Black D'hide + Rune Crossbow */ + { + .name = "A: Black D'hide + RCB", + .weapon_name = "Rune crossbow", + .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_BLACK_DHIDE_RCB, + .combat_style_profile = 9, + .max_hp = 700, /* 70 HP */ + .max_prayer = 430, /* 43 prayer */ + .attack_lvl = 1, + .strength_lvl = 1, + .defence_lvl = 70, + .ranged_lvl = 70, + .prayer_lvl = 43, + .magic_lvl = 1, + .weapon_kind = FC_WEAPON_GENERIC_RANGED, + .weapon_uses_ammo = 1, + .weapon_speed = 5, + .weapon_range = 7, + .ranged_atk = 153, /* 2+90+30+17+11+3 */ + .ranged_str = 100, /* adamant bolts */ + .def_stab = 97, /* 4+0+55+31+6+1 */ + .def_slash = 84, /* 6+0+47+25+5+1 */ + .def_crush = 110, /* 8+0+60+33+7+2 */ + .def_magic = 91, /* 4+0+50+28+8+1 */ + .def_ranged = 90, /* 4+0+55+31+0+0 */ + .prayer_bonus = 0, + .ammo = 50000, + .equipment_count = 7, + .equipment = { + {FC_EQUIP_SLOT_HEAD, 1169, 0, "Coif"}, + {FC_EQUIP_SLOT_WEAPON, 9185, 0, "Rune crossbow"}, + {FC_EQUIP_SLOT_BODY, 2503, 0, "Black d'hide body"}, + {FC_EQUIP_SLOT_AMMO, 9143, 0, "Adamant bolts"}, + {FC_EQUIP_SLOT_LEGS, 2497, 0, "Black d'hide chaps"}, + {FC_EQUIP_SLOT_HANDS, 2491, 0, "Black d'hide vambraces"}, + {FC_EQUIP_SLOT_FEET, 6328, 0, "Snakeskin boots"}, + }, + .model_item_count = 6, + .model_item_ids = {1169, 9185, 2503, 2497, 2491, 6328}, + }, + /* [FC_LOADOUT_SOTA_TBOW] End-game — Masori (f) + Twisted Bow */ + { + .name = "B: Masori (f) + TBow", + .weapon_name = "Twisted bow", + .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_SOTA_TBOW, + .combat_style_profile = 25, + .max_hp = 990, /* 99 HP */ + .max_prayer = 990, /* 99 prayer */ + .attack_lvl = 1, + .strength_lvl = 1, + .defence_lvl = 99, + .ranged_lvl = 99, + .prayer_lvl = 99, + .magic_lvl = 1, + .weapon_kind = FC_WEAPON_TWISTED_BOW, + .weapon_uses_ammo = 1, + .weapon_speed = 5, /* rapid */ + .weapon_range = 10, + .ranged_atk = 215, /* 12+8+15+70+43+27+18+12+10 */ + .ranged_str = 99, /* 2+2+5+20+4+2+2+0+2+60(dragon arrows) */ + .def_stab = 116, /* 8+1+0+0+59+35+8+5+0+0 */ + .def_slash = 106, /* 10+1+0+0+52+30+8+5+0+0 */ + .def_crush = 129, /* 12+1+0+0+64+39+8+5+0+0 */ + .def_magic = 150, /* 12+8+0+0+74+46+5+5+0+0 */ + .def_ranged = 121, /* 9+2+0+0+60+37+8+5+0+0 */ + .prayer_bonus = 6, /* 1+0+2+0+1+1+1+0+0+0 */ + .ammo = 50000, + .equipment_count = 10, + .equipment = { + {FC_EQUIP_SLOT_HEAD, 27235, 0, "Masori mask (f)"}, + {FC_EQUIP_SLOT_CAPE, 22109, 0, "Ava's assembler"}, + {FC_EQUIP_SLOT_NECK, 19547, 0, "Necklace of anguish"}, + {FC_EQUIP_SLOT_WEAPON, 20997, 0, "Twisted bow"}, + {FC_EQUIP_SLOT_BODY, 27238, 0, "Masori body (f)"}, + {FC_EQUIP_SLOT_AMMO, 11212, 0, "Dragon arrows"}, + {FC_EQUIP_SLOT_LEGS, 27241, 0, "Masori chaps (f)"}, + {FC_EQUIP_SLOT_HANDS, 26235, 0, "Zaryte vambraces"}, + {FC_EQUIP_SLOT_FEET, 13237, 0, "Pegasian boots"}, + {FC_EQUIP_SLOT_RING, 25487, 0, "Venator ring"}, + }, + .model_item_count = 8, + .model_item_ids = {27235, 22109, 19547, 20997, 27238, 27241, 26235, 13237}, + }, + /* [FC_LOADOUT_LOW_DEF_RCB] Low-defence — Robin Hood + Red D'hide + RCB */ + { + .name = "C: 1 Def Robin + RCB", + .weapon_name = "Rune crossbow", + .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_LOW_DEF_RCB, + .combat_style_profile = 9, + .max_hp = 550, /* 55 HP */ + .max_prayer = 430, /* 43 prayer */ + .attack_lvl = 1, + .strength_lvl = 1, + .defence_lvl = 1, + .ranged_lvl = 61, + .prayer_lvl = 43, + .magic_lvl = 1, + .weapon_kind = FC_WEAPON_GENERIC_RANGED, + .weapon_uses_ammo = 1, + .weapon_speed = 5, /* rapid rune crossbow */ + .weapon_range = 7, + .ranged_atk = 166, /* 8+4+10+90+15+14+7+8+10 */ + .ranged_str = 100, /* adamant bolts */ + .def_stab = 48, /* 4+0+3+0+6+28+5+2+0 */ + .def_slash = 49, /* 6+1+3+0+9+22+5+3+0 */ + .def_crush = 62, /* 8+0+3+0+12+30+5+4+0 */ + .def_magic = 42, /* 4+4+3+0+6+20+3+2+0 */ + .def_ranged = 46, /* 4+0+3+0+6+28+5+0+0 */ + .prayer_bonus = 8, /* glory + book of law */ + .ammo = 50000, + .equipment_count = 10, + .equipment = { + {FC_EQUIP_SLOT_HEAD, 2581, 0, "Robin hood hat"}, + {FC_EQUIP_SLOT_CAPE, 10499, 0, "Ava's accumulator"}, + {FC_EQUIP_SLOT_NECK, 1704, 0, "Amulet of glory"}, + {FC_EQUIP_SLOT_WEAPON, 9185, 0, "Rune crossbow"}, + {FC_EQUIP_SLOT_BODY, 12596, 0, "Rangers' tunic"}, + {FC_EQUIP_SLOT_SHIELD, 12610, 0, "Book of law"}, + {FC_EQUIP_SLOT_AMMO, 9143, 0, "Adamant bolts"}, + {FC_EQUIP_SLOT_LEGS, 2495, 0, "Red d'hide chaps"}, + {FC_EQUIP_SLOT_HANDS, 11126, 0, "Combat bracelet"}, + {FC_EQUIP_SLOT_FEET, 2577, 0, "Ranger boots"}, + }, + .model_item_count = 9, + .model_item_ids = {2581, 10499, 1704, 9185, 12596, 12610, 2495, 11126, 2577}, + }, + /* [FC_LOADOUT_RCB_PURE] Low-level 1-def Fight Caves rune crossbow pure */ + { + .name = "RCB Pure", + .weapon_name = "Rune crossbow", + .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_RCB_PURE, + .combat_style_profile = 9, + .max_hp = 550, + .max_prayer = 430, + .attack_lvl = 1, + .strength_lvl = 1, + .defence_lvl = 1, + .ranged_lvl = 61, + .prayer_lvl = 43, + .magic_lvl = 1, + .weapon_kind = FC_WEAPON_GENERIC_RANGED, + .weapon_uses_ammo = 1, + .weapon_speed = 5, + .weapon_range = 7, + .ranged_atk = 166, + .ranged_str = 100, + .def_stab = 48, + .def_slash = 49, + .def_crush = 62, + .def_magic = 42, + .def_ranged = 46, + .prayer_bonus = 8, + .ammo = 50000, + .equipment_count = 10, + .equipment = { + {FC_EQUIP_SLOT_HEAD, 2581, 0, "Robin hood hat"}, + {FC_EQUIP_SLOT_CAPE, 10499, 0, "Ava's accumulator"}, + {FC_EQUIP_SLOT_NECK, 1704, 0, "Amulet of glory"}, + {FC_EQUIP_SLOT_WEAPON, 9185, 0, "Rune crossbow"}, + {FC_EQUIP_SLOT_BODY, 12596, 0, "Rangers' tunic"}, + {FC_EQUIP_SLOT_SHIELD, 12610, 0, "Book of law"}, + {FC_EQUIP_SLOT_AMMO, 9143, 0, "Adamant bolts"}, + {FC_EQUIP_SLOT_LEGS, 2495, 0, "Red d'hide chaps"}, + {FC_EQUIP_SLOT_HANDS, 11126, 0, "Combat bracelet"}, + {FC_EQUIP_SLOT_FEET, 2577, 0, "Ranger boots"}, + }, + .model_item_count = 9, + .model_item_ids = {2581, 10499, 1704, 9185, 12596, 12610, 2495, 11126, 2577}, + }, + /* [FC_LOADOUT_MSBI_PURE] Faster but weaker 1-def magic shortbow pure */ + { + .name = "MSB(i) Pure", + .weapon_name = "Magic shortbow (i)", + .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_MSBI_PURE, + .combat_style_profile = 25, + .max_hp = 600, + .max_prayer = 430, + .attack_lvl = 1, + .strength_lvl = 1, + .defence_lvl = 1, + .ranged_lvl = 70, + .prayer_lvl = 43, + .magic_lvl = 1, + .weapon_kind = FC_WEAPON_GENERIC_RANGED, + .weapon_uses_ammo = 1, + .weapon_speed = 3, + .weapon_range = 7, + .ranged_atk = 141, + .ranged_str = 49, + .def_stab = 48, + .def_slash = 49, + .def_crush = 62, + .def_magic = 42, + .def_ranged = 46, + .prayer_bonus = 3, + .ammo = 50000, + .equipment_count = 9, + .equipment = { + {FC_EQUIP_SLOT_HEAD, 2581, 0, "Robin hood hat"}, + {FC_EQUIP_SLOT_CAPE, 10499, 0, "Ava's accumulator"}, + {FC_EQUIP_SLOT_NECK, 1704, 0, "Amulet of glory"}, + {FC_EQUIP_SLOT_WEAPON, 12788, 0, "Magic shortbow (i)"}, + {FC_EQUIP_SLOT_AMMO, 892, 0, "Rune arrow"}, + {FC_EQUIP_SLOT_BODY, 12596, 0, "Rangers' tunic"}, + {FC_EQUIP_SLOT_LEGS, 2495, 0, "Red d'hide chaps"}, + {FC_EQUIP_SLOT_HANDS, 11126, 0, "Combat bracelet"}, + {FC_EQUIP_SLOT_FEET, 2577, 0, "Ranger boots"}, + }, + .model_item_count = 8, + .model_item_ids = {2581, 10499, 1704, 12788, 12596, 2495, 11126, 2577}, + }, + /* [FC_LOADOUT_BLOWPIPE_PURE] Fast 1-def toxic blowpipe pure with loaded adamant darts */ + { + .name = "Blowpipe Pure", + .weapon_name = "Toxic blowpipe", + .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_BLOWPIPE_PURE, + .combat_style_profile = 23, + .max_hp = 750, + .max_prayer = 430, + .attack_lvl = 1, + .strength_lvl = 1, + .defence_lvl = 1, + .ranged_lvl = 75, + .prayer_lvl = 43, + .magic_lvl = 1, + .weapon_kind = FC_WEAPON_GENERIC_RANGED, + .weapon_uses_ammo = 1, + .weapon_speed = 2, + .weapon_range = 5, + .ranged_atk = 101, + .ranged_str = 42, + .def_stab = 45, + .def_slash = 46, + .def_crush = 59, + .def_magic = 39, + .def_ranged = 43, + .prayer_bonus = 2, + .ammo = 50000, + .equipment_count = 9, + .equipment = { + {FC_EQUIP_SLOT_HEAD, 2581, 0, "Robin hood hat"}, + {FC_EQUIP_SLOT_CAPE, 10499, 0, "Ava's accumulator"}, + {FC_EQUIP_SLOT_NECK, 19547, 0, "Necklace of anguish"}, + {FC_EQUIP_SLOT_WEAPON, 12926, 0, "Toxic blowpipe"}, + {FC_EQUIP_SLOT_AMMO, 810, 0, "Adamant dart"}, + {FC_EQUIP_SLOT_BODY, 12596, 0, "Rangers' tunic"}, + {FC_EQUIP_SLOT_LEGS, 2495, 0, "Red d'hide chaps"}, + {FC_EQUIP_SLOT_HANDS, 11126, 0, "Combat bracelet"}, + {FC_EQUIP_SLOT_FEET, 2577, 0, "Ranger boots"}, + }, + .model_item_count = 8, + .model_item_ids = {2581, 10499, 19547, 12926, 12596, 2495, 11126, 2577}, + }, + /* [FC_LOADOUT_ACB_ARMADYL] Tankier high-level Armadyl crossbow + Armadyl armour */ + { + .name = "ACB Armadyl", + .weapon_name = "Armadyl crossbow", + .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_ACB_ARMADYL, + .combat_style_profile = 9, + .max_hp = 800, + .max_prayer = 700, + .attack_lvl = 1, + .strength_lvl = 1, + .defence_lvl = 75, + .ranged_lvl = 80, + .prayer_lvl = 70, + .magic_lvl = 1, + .weapon_kind = FC_WEAPON_GENERIC_RANGED, + .weapon_uses_ammo = 1, + .weapon_speed = 5, + .weapon_range = 8, + .ranged_atk = 220, + .ranged_str = 129, + .def_stab = 112, + .def_slash = 100, + .def_crush = 123, + .def_magic = 139, + .def_ranged = 117, + .prayer_bonus = 11, + .ammo = 50000, + .equipment_count = 10, + .equipment = { + {FC_EQUIP_SLOT_HEAD, 11826, 0, "Armadyl helmet"}, + {FC_EQUIP_SLOT_CAPE, 22109, 0, "Ava's assembler"}, + {FC_EQUIP_SLOT_NECK, 19547, 0, "Necklace of anguish"}, + {FC_EQUIP_SLOT_WEAPON, 11785, 0, "Armadyl crossbow"}, + {FC_EQUIP_SLOT_BODY, 11828, 0, "Armadyl chestplate"}, + {FC_EQUIP_SLOT_SHIELD, 12610, 0, "Book of law"}, + {FC_EQUIP_SLOT_AMMO, 21946, 0, "Diamond dragon bolts (e)"}, + {FC_EQUIP_SLOT_LEGS, 11830, 0, "Armadyl chainskirt"}, + {FC_EQUIP_SLOT_HANDS, 7462, 0, "Barrows gloves"}, + {FC_EQUIP_SLOT_FEET, 13237, 0, "Pegasian boots"}, + }, + .model_item_count = 9, + .model_item_ids = {11826, 22109, 19547, 11785, 11828, 12610, 11830, 7462, 13237}, + }, + /* [FC_LOADOUT_BOWFA_CRYSTAL] Bowfa + crystal armour. */ + { + .name = "Bowfa Crystal", + .weapon_name = "Bow of faerdhinen (c)", + .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_BOWFA_CRYSTAL, + .combat_style_profile = 25, + .max_hp = 850, + .max_prayer = 700, + .attack_lvl = 1, + .strength_lvl = 1, + .defence_lvl = 75, + .ranged_lvl = 85, + .prayer_lvl = 70, + .magic_lvl = 1, + .weapon_kind = FC_WEAPON_BOW_OF_FAERDHINEN, + .weapon_uses_ammo = 0, + .crystal_piece_mask = FC_CRYSTAL_PIECE_ALL, + .weapon_speed = 4, + .weapon_range = 10, + .ranged_atk = 233, + .ranged_str = 113, + .def_stab = 102, + .def_slash = 85, + .def_crush = 110, + .def_magic = 107, + .def_ranged = 143, + .prayer_bonus = 9, + .ammo = 0, + .equipment_count = 8, + .equipment = { + {FC_EQUIP_SLOT_HEAD, 23971, 0, "Crystal helm"}, + {FC_EQUIP_SLOT_CAPE, 22109, 0, "Ava's assembler"}, + {FC_EQUIP_SLOT_NECK, 19547, 0, "Necklace of anguish"}, + {FC_EQUIP_SLOT_WEAPON, 25867, 0, "Bow of faerdhinen (c)"}, + {FC_EQUIP_SLOT_BODY, 23975, 0, "Crystal body"}, + {FC_EQUIP_SLOT_LEGS, 23979, 0, "Crystal legs"}, + {FC_EQUIP_SLOT_HANDS, 7462, 0, "Barrows gloves"}, + {FC_EQUIP_SLOT_FEET, 13237, 0, "Pegasian boots"}, + }, + .model_item_count = 8, + .model_item_ids = {23971, 22109, 19547, 25867, 23975, 23979, 7462, 13237}, + }, + /* [FC_LOADOUT_TBOW_MASORI] Max-ish Twisted bow + fortified Masori loadout */ + { + .name = "Tbow Masori", + .weapon_name = "Twisted bow", + .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_TBOW_MASORI, + .combat_style_profile = 25, + .max_hp = 990, + .max_prayer = 770, + .attack_lvl = 1, + .strength_lvl = 1, + .defence_lvl = 80, + .ranged_lvl = 99, + .prayer_lvl = 77, + .magic_lvl = 1, + .weapon_kind = FC_WEAPON_TWISTED_BOW, + .weapon_uses_ammo = 1, + .weapon_speed = 5, + .weapon_range = 10, + .ranged_atk = 205, + .ranged_str = 97, + .def_stab = 116, + .def_slash = 106, + .def_crush = 129, + .def_magic = 150, + .def_ranged = 121, + .prayer_bonus = 6, + .ammo = 50000, + .equipment_count = 9, + .equipment = { + {FC_EQUIP_SLOT_HEAD, 27235, 0, "Masori mask (f)"}, + {FC_EQUIP_SLOT_CAPE, 22109, 0, "Ava's assembler"}, + {FC_EQUIP_SLOT_NECK, 19547, 0, "Necklace of anguish"}, + {FC_EQUIP_SLOT_WEAPON, 20997, 0, "Twisted bow"}, + {FC_EQUIP_SLOT_BODY, 27238, 0, "Masori body (f)"}, + {FC_EQUIP_SLOT_AMMO, 11212, 0, "Dragon arrow"}, + {FC_EQUIP_SLOT_LEGS, 27241, 0, "Masori chaps (f)"}, + {FC_EQUIP_SLOT_HANDS, 26235, 0, "Zaryte vambraces"}, + {FC_EQUIP_SLOT_FEET, 13237, 0, "Pegasian boots"}, + }, + .model_item_count = 8, + .model_item_ids = {27235, 22109, 19547, 20997, 27238, 27241, 26235, 13237}, + }, +}; + + +/* Npc */ +#include + +/* + * fc_npc.c — NPC framework with stat table and type-specific AI dispatch. + * + * PR 5: All 8 NPC types have full AI. + * + * NPC AI per tick (generic): + * 1. If dead or inactive, skip. + * 2. Decrement attack timer. + * 3. Type-specific behavior (Jad style selection, Yt-MejKot heal, Yt-HurKot heal). + * 4. If not in attack range, move toward player (greedy step). + * 5. If in range and attack timer ready, roll attack and queue pending hit. + * + * Type-specific: + * Tz-Kih: Melee + prayer drain on hit. + * Tz-Kek: Melee. Splits into 2 small Tz-Kek on death. + * Tz-Kek-Sm: Melee. (no special) + * Tok-Xil: Ranged with projectile delay. + * Yt-MejKot: Chooses one melee-cycle action: attack or heal a weak NPC. + * Ket-Zek: Magic with projectile delay. + * TzTok-Jad: Magic/ranged at distance; melee/magic/ranged at range 1. + * Yt-HurKot: Heals Jad in range; attacks the player after being tagged. + */ + +/* ======================================================================== */ +/* NPC stat table */ +/* ======================================================================== */ + +/* + * Fight Caves stats use the reviewed OSRS parity table. Sizes and non-combat + * behavior fields retain their existing cache/config-derived values. + */ +static const FcNpcStats NPC_STATS[NPC_TYPE_COUNT] = { + [NPC_NONE] = {0}, + + /* NPC_TZ_KIH: Lv 22 melee bat. Drains damage + 1 Prayer point. + * Void 634: HP 100, Att 20, Str 30, Def 15, size 1, stab max 40 */ + [NPC_TZ_KIH] = { + .max_hp = 100, .attack_style = ATTACK_MELEE, + .attack_speed = 4, .attack_range = 1, + .melee_max_hit_tenths = 40, + .att_level = 20, .ranged_level = 30, .magic_level = 15, + .def_level = 15, .ranged_def_bonus = 0, + .melee_attack_type = FC_ATTACK_TYPE_STAB, + .size = 1, .movement_speed = 1, .prayer_drain = 10, + }, + + /* NPC_TZ_KEK: Lv 45 melee blob. Splits into 2 small on death. + * Void 634: HP 200, Att 40, Str 60, Def 30, size 2, crush max 70 */ + [NPC_TZ_KEK] = { + .max_hp = 200, .attack_style = ATTACK_MELEE, + .attack_speed = 4, .attack_range = 1, + .melee_max_hit_tenths = 70, + .att_level = 40, .ranged_level = 60, .magic_level = 30, + .def_level = 30, .ranged_def_bonus = 0, + .melee_attack_type = FC_ATTACK_TYPE_CRUSH, + .size = 2, .movement_speed = 1, + }, + + /* NPC_TZ_KEK_SM: Lv 22 small blob (from split). + * Void 634: HP 100, Att 20, Str 30, Def 15, size 1, crush max 40 */ + [NPC_TZ_KEK_SM] = { + .max_hp = 100, .attack_style = ATTACK_MELEE, + .attack_speed = 4, .attack_range = 1, + .melee_max_hit_tenths = 40, + .att_level = 20, .ranged_level = 30, .magic_level = 15, + .def_level = 15, .ranged_def_bonus = 0, + .melee_attack_type = FC_ATTACK_TYPE_CRUSH, + .size = 1, .movement_speed = 1, + }, + + /* NPC_TOK_XIL: Lv 90 ranged + melee (DUAL MODE). + * Void 634: HP 400, Att 80, Str 120, Def 60, Rng 120, size 3 + * Current Fight Caves maxima are 130 for both melee and Ranged. */ + [NPC_TOK_XIL] = { + .max_hp = 400, .attack_style = ATTACK_RANGED, + .attack_speed = 4, .attack_range = 14, + .melee_max_hit_tenths = 130, .ranged_max_hit_tenths = 130, + .att_level = 80, .ranged_level = 120, .magic_level = 60, + .def_level = 60, .ranged_def_bonus = 0, + .melee_attack_type = FC_ATTACK_TYPE_CRUSH, + .size = 3, .movement_speed = 1, + }, + + /* NPC_YT_MEJKOT: Lv 180 melee + heals self/nearby NPCs with HP < 50% max. + * Void 634: HP 800, Att 160, Str 240, Def 120, size 4 + * combat.toml: crush max 250. Heals 100 tenths (10 HP) as its attack. */ + [NPC_YT_MEJKOT] = { + .max_hp = 800, .attack_style = ATTACK_MELEE, + .attack_speed = 4, .attack_range = 1, + .melee_max_hit_tenths = 250, + .att_level = 160, .ranged_level = 240, .magic_level = 120, + .def_level = 120, .ranged_def_bonus = 0, + .melee_attack_type = FC_ATTACK_TYPE_CRUSH, + .size = 4, .movement_speed = 1, .heal_amount = 100, + }, + + /* NPC_KET_ZEK: Lv 360 magic + melee (DUAL MODE). + * Void 634: HP 1600, Att 320, Str 480, Def 240, Mag 240, size 5 + * Current Fight Caves maxima are 550 melee and 520 Magic. The Magic + * attack has +60 accuracy; the melee attack has no equipment bonus. */ + [NPC_KET_ZEK] = { + .max_hp = 1600, .attack_style = ATTACK_MAGIC, + .attack_speed = 4, .attack_range = 14, + .melee_max_hit_tenths = 550, .magic_max_hit_tenths = 520, + .att_level = 320, .ranged_level = 480, .magic_level = 240, + .magic_attack_bonus = 60, + .def_level = 240, .ranged_def_bonus = 0, + .melee_attack_type = FC_ATTACK_TYPE_STAB, + .size = 5, .movement_speed = 1, + }, + + /* NPC_TZTOK_JAD: Lv 702 magic + ranged + melee. + * Void 634: HP 2500, Att 640, Str 960, Def 480, Mag 480, Rng 960, size 5 + * combat.toml: melee stab max 970 (range 1), magic max 950 (range 14), ranged max 970 + * attack speed 8 (double normal), range 14. The Magic attack has +60 + * accuracy; the melee and Ranged attacks have no equipment bonus. */ + [NPC_TZTOK_JAD] = { + .max_hp = 2500, .attack_style = ATTACK_MAGIC, + .attack_speed = 8, .attack_range = 14, + .melee_max_hit_tenths = 970, .ranged_max_hit_tenths = 970, + .magic_max_hit_tenths = 950, + .att_level = 640, .ranged_level = 960, .magic_level = 480, + .magic_attack_bonus = 60, + .def_level = 480, .ranged_def_bonus = 0, + .melee_attack_type = FC_ATTACK_TYPE_STAB, + .size = 5, .movement_speed = 1, + }, + + /* NPC_YT_HURKOT: Lv 108 Jad healer. Heals Jad 50 tenths (5 HP) every 4 ticks within 5 tiles. + * Void 634: HP 600, Att 140, Str 100, Def 60, size 1 + * combat.toml: crush max 140 */ + [NPC_YT_HURKOT] = { + .max_hp = 600, .attack_style = ATTACK_MELEE, + .attack_speed = 4, .attack_range = 1, + .melee_max_hit_tenths = 140, + .att_level = 140, .ranged_level = 120, .magic_level = 120, + .def_level = 60, .ranged_def_bonus = 100, + .melee_attack_type = FC_ATTACK_TYPE_CRUSH, + .size = 1, .movement_speed = 1, + .heal_amount = 50, .heal_interval = 4, + }, +}; + +int fc_npc_max_hit_tenths_for_style(const FcNpcStats* stats, int attack_style) { + if (stats == NULL) return 0; + switch (attack_style) { + case ATTACK_MELEE: return stats->melee_max_hit_tenths; + case ATTACK_RANGED: return stats->ranged_max_hit_tenths; + case ATTACK_MAGIC: return stats->magic_max_hit_tenths; + default: return 0; + } +} + +int fc_npc_max_hit_hp_for_style(const FcNpcStats* stats, int attack_style) { + int max_hit_tenths = fc_npc_max_hit_tenths_for_style(stats, attack_style); + if (max_hit_tenths < 0 || max_hit_tenths % 10 != 0) return 0; + return max_hit_tenths / 10; +} + +int fc_npc_stats_valid(const FcNpcStats* stats) { + if (stats == NULL) return 0; + const int maxima[] = { + stats->melee_max_hit_tenths, + stats->ranged_max_hit_tenths, + stats->magic_max_hit_tenths, + }; + for (int i = 0; i < 3; i++) { + if (maxima[i] < 0 || maxima[i] % 10 != 0) return 0; + } + return 1; +} + +const FcNpcStats* fc_npc_get_stats(int npc_type) { + if (npc_type < 0 || npc_type >= NPC_TYPE_COUNT) return &NPC_STATS[0]; + return &NPC_STATS[npc_type]; +} + +static int npc_attack_level_for_style(const FcNpcStats* stats, + int attack_style) { + switch (attack_style) { + case ATTACK_MELEE: return stats->att_level; + case ATTACK_RANGED: return stats->ranged_level; + case ATTACK_MAGIC: return stats->magic_level; + default: return 0; + } +} + +static int npc_attack_bonus_for_style(const FcNpcStats* stats, + int attack_style) { + switch (attack_style) { + case ATTACK_MELEE: return stats->melee_attack_bonus; + case ATTACK_RANGED: return stats->ranged_attack_bonus; + case ATTACK_MAGIC: return stats->magic_attack_bonus; + default: return 0; + } +} + +static FcAttackType npc_attack_type_for_style(const FcNpcStats* stats, + int attack_style) { + switch (attack_style) { + case ATTACK_MELEE: return (FcAttackType)stats->melee_attack_type; + case ATTACK_RANGED: return FC_ATTACK_TYPE_RANGED; + case ATTACK_MAGIC: return FC_ATTACK_TYPE_MAGIC; + default: return FC_ATTACK_TYPE_NONE; + } +} + +/* ======================================================================== */ +/* Spawn */ +/* ======================================================================== */ + +void fc_npc_spawn(FcNpc* npc, int npc_type, int x, int y, int spawn_index) { + const FcNpcStats* stats = fc_npc_get_stats(npc_type); + + npc->active = 1; + npc->npc_type = npc_type; + npc->spawn_index = spawn_index; + npc->x = x; + npc->y = y; + npc->size = stats->size; + npc->current_hp = stats->max_hp; + npc->max_hp = stats->max_hp; + npc->is_dead = 0; + npc->attack_style = stats->attack_style; + npc->attack_timer = stats->attack_speed; /* first attack after full cooldown */ + npc->attack_speed = stats->attack_speed; + npc->attack_range = stats->attack_range; + npc->movement_speed = stats->movement_speed; + npc->heal_timer = stats->heal_interval; /* start at full cooldown */ + npc->heal_amount = stats->heal_amount; + npc->healer_distracted = 0; + npc->heal_target_idx = -1; + npc->is_respawned_jad_healer = 0; + npc->damage_taken_this_tick = 0; + npc->prayer_drain_dealt_this_tick = 0; + npc->healing_received_this_tick = 0; + npc->healing_given_this_tick = 0; + npc->healed_by_mejkot_this_tick = 0; + npc->healed_by_hurkot_this_tick = 0; + npc->healed_self_this_tick = 0; + npc->died_this_tick = 0; + npc->num_pending_hits = 0; +} + +static void build_npc_movement_occupancy( + const FcState* state, + int npc_idx, + uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + fc_build_occupancy(state, occupied, npc_idx, 0); +} + +static int npc_dynamic_step_toward(FcState* state, int npc_idx, + int target_x, int target_y) { + FcNpc* npc = &state->npcs[npc_idx]; + uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]; + build_npc_movement_occupancy(state, npc_idx, occupied); + return fc_npc_step_toward_sized_dynamic(&npc->x, &npc->y, + target_x, target_y, npc->size, + state->walkable, + state->movement_flags, + occupied); +} + +static int min_i(int a, int b) { + return a < b ? a : b; +} + +static int max_i(int a, int b) { + return a > b ? a : b; +} + +static void npc_naive_player_chase_destination(const FcNpc* npc, + const FcPlayer* player, + int* out_x, int* out_y) { + int source_width = npc->size; + int source_length = npc->size; + int target_width = 1; + int target_length = 1; + int diagonal = (npc->x - player->x) + (npc->y - player->y); + int anti = (npc->x - player->x) - (npc->y - player->y); + int south_west_clockwise = anti < 0; + int north_west_clockwise = + diagonal >= (target_length - 1) - (source_width - 1); + int north_east_clockwise = anti > source_width - source_length; + int south_east_clockwise = + diagonal <= (target_width - 1) - (source_length - 1); + + if (south_west_clockwise && !north_west_clockwise) { + int off_y; + if (diagonal >= -source_width) { + off_y = min_i(diagonal + source_width, target_length - 1); + } else if (anti > -source_width) { + off_y = -(source_width + anti); + } else { + off_y = 0; + } + *out_x = player->x - source_width; + *out_y = player->y + off_y; + } else if (north_west_clockwise && !north_east_clockwise) { + int off_x; + if (anti >= -target_length) { + off_x = min_i(anti + target_length, target_width - 1); + } else if (diagonal < target_length) { + off_x = max_i(diagonal - target_length, -(source_width - 1)); + } else { + off_x = 0; + } + *out_x = player->x + off_x; + *out_y = player->y + target_length; + } else if (north_east_clockwise && !south_east_clockwise) { + int off_y; + if (anti <= target_width) { + off_y = target_length - anti; + } else if (diagonal < target_width) { + off_y = max_i(diagonal - target_width, -(source_length - 1)); + } else { + off_y = 0; + } + *out_x = player->x + target_width; + *out_y = player->y + off_y; + } else { + int off_x; + if (diagonal > -source_length) { + off_x = min_i(diagonal + source_length, target_width - 1); + } else if (anti < source_length) { + off_x = max_i(anti - source_length, -(source_length - 1)); + } else { + off_x = 0; + } + *out_x = player->x + off_x; + *out_y = player->y - source_length; + } +} + +int fc_npc_position_can_attack_player(const FcState* state, const FcNpc* npc, + int candidate_x, int candidate_y) { + if (!state || !npc || !npc->active || npc->is_dead) return 0; + + const FcNpcStats* stats = fc_npc_get_stats(npc->npc_type); + const FcPlayer* p = &state->player; + + int can_melee = fc_npc_can_melee_player(p->x, p->y, + candidate_x, candidate_y, + npc->size, state->walkable, + state->movement_flags); + if (can_melee && + (stats->melee_max_hit_tenths > 0 || + npc->attack_style == ATTACK_MELEE)) { + return 1; + } + + int distance = fc_distance_between_areas( + p->x, p->y, 1, candidate_x, candidate_y, npc->size); + if (npc->attack_style != ATTACK_MELEE && distance > 0 && + distance <= npc->attack_range) { + return fc_has_los_between_areas( + candidate_x, candidate_y, npc->size, + p->x, p->y, 1, state->los_flags); + } + + return 0; +} + +static int npc_dynamic_step_toward_player_bounds(FcState* state, int npc_idx) { + FcNpc* npc = &state->npcs[npc_idx]; + int target_x; + int target_y; + + npc_naive_player_chase_destination(npc, &state->player, + &target_x, &target_y); + if (target_x == npc->x && target_y == npc->y) return 0; + + return npc_dynamic_step_toward(state, npc_idx, target_x, target_y); +} + +/* ======================================================================== */ +/* Tz-Kek: split on death — spawn 2 small Tz-Kek */ +/* ======================================================================== */ + +void fc_npc_tz_kek_split(FcState* state, int dead_x, int dead_y) { + /* Spawn 2 NPC_TZ_KEK_SM at/near the death position. + * Do NOT increment npcs_remaining — the parent Tz-Kek was pre-counted as 2 + * at wave spawn time. These children inherit that count and decrement normally + * when they die. (Matches RSPS: parent not in npcDespawn list, children are.) */ + const FcNpcStats* child_stats = fc_npc_get_stats(NPC_TZ_KEK_SM); + for (int spawned = 0; spawned < 2; spawned++) { + int sx = dead_x + (spawned == 0 ? 0 : 1); + int sy = dead_y; + /* Clamp to arena */ + if (sx >= FC_ARENA_WIDTH - 1) sx = dead_x - 1; + if (sx < 1) sx = 1; + + if (!fc_spawn_find_available_footprint( + state, sx, sy, child_stats->size, FC_ARENA_WIDTH - 1, + &sx, &sy)) { + break; + } + if (fc_spawn_npc_first_free(state, NPC_TZ_KEK_SM, sx, sy) < 0) { + break; + } + /* No npcs_remaining++ — already pre-counted */ + } +} + +/* ======================================================================== */ +/* Jad direct attack selection */ +/* ======================================================================== */ + +static void record_npc_attack(FcState* state, const FcNpc* npc, int npc_idx, + int attack_style, int hit_delay_ticks, + int prayer_lock_tick, int hit_queued) { + FcRenderEvents* events = &state->render_events; + if (events->npc_attack_count >= FC_MAX_RENDER_NPC_ATTACKS) return; + + FcRenderNpcAttack* attack = + &events->npc_attacks[events->npc_attack_count++]; + attack->npc_slot = npc_idx; + attack->npc_type = npc->npc_type; + attack->attack_style = attack_style; + attack->source_x = npc->x; + attack->source_y = npc->y; + attack->source_size = npc->size; + attack->target_x = state->player.x; + attack->target_y = state->player.y; + attack->hit_delay_ticks = hit_delay_ticks; + attack->prayer_lock_tick = prayer_lock_tick; + attack->hit_queued = hit_queued; +} + +static void launch_npc_attack(FcState* state, FcNpc* npc, int npc_idx, + int attack_style, int hit_delay_ticks, + int prayer_drain, int prayer_lock_delay_ticks) { + const FcNpcStats* stats = fc_npc_get_stats(npc->npc_type); + FcPlayer* player = &state->player; + int max_hit_hp = fc_npc_max_hit_hp_for_style(stats, attack_style); + int attack_level = npc_attack_level_for_style(stats, attack_style); + int attack_bonus = npc_attack_bonus_for_style(stats, attack_style); + int attack_roll = fc_npc_attack_roll(attack_level, attack_bonus); + FcAttackType attack_type = + npc_attack_type_for_style(stats, attack_style); + int defence_roll = fc_player_def_roll(player, attack_type); + float hit_chance = fc_hit_chance(attack_roll, defence_roll); + int hit = fc_rng_float(state) < hit_chance; + int damage = hit ? fc_roll_npc_damage_tenths(state, max_hit_hp) : 0; + + int hit_queued = fc_queue_pending_hit( + player->pending_hits, &player->num_pending_hits, FC_MAX_PENDING_HITS, + damage, hit_delay_ticks, attack_style, npc_idx, prayer_drain); + int prayer_lock_tick = -1; + if (hit_queued) { + FcPendingHit* queued = + &player->pending_hits[player->num_pending_hits - 1]; + if (prayer_lock_delay_ticks > 0) { + queued->prayer_snapshot = -1; + queued->prayer_lock_tick = + state->tick + prayer_lock_delay_ticks; + prayer_lock_tick = queued->prayer_lock_tick; + } else { + queued->prayer_snapshot = player->prayer_at_tick_start; + } + } + + record_npc_attack(state, npc, npc_idx, attack_style, hit_delay_ticks, + prayer_lock_tick, hit_queued); + npc->attack_timer = npc->attack_speed; +} + +static void jad_attack(FcState* state, FcNpc* npc, int npc_idx) { + const FcNpcStats* stats = fc_npc_get_stats(npc->npc_type); + FcPlayer* p = &state->player; + int dist = fc_distance_to_npc(p->x, p->y, npc); + int can_melee = fc_npc_can_melee_player(p->x, p->y, npc->x, npc->y, + npc->size, state->walkable, + state->movement_flags); + + if (npc->attack_timer > 0) return; + + int use_style = ATTACK_NONE; + int in_range = 0; + + int can_use_distance_styles = + dist > 0 && dist <= npc->attack_range && + fc_has_los_between_areas( + npc->x, npc->y, npc->size, + p->x, p->y, 1, state->los_flags); + + if (can_melee && stats->melee_max_hit_tenths > 0) { + /* In melee range Jad can still choose Magic or Ranged. All three + * configured attacks have equal selection weight. */ + int choice = can_use_distance_styles ? fc_rng_int(state, 3) : 0; + if (choice == 0) { + use_style = ATTACK_MELEE; + } else if (choice == 1) { + use_style = ATTACK_MAGIC; + } else { + use_style = ATTACK_RANGED; + } + in_range = 1; + } else if (can_use_distance_styles) { + use_style = (fc_rng_int(state, 2) == 0) ? ATTACK_MAGIC : ATTACK_RANGED; + in_range = 1; + } + + if (!in_range) return; + + int delay = fc_npc_hit_delay(npc->npc_type, use_style, dist); + if (use_style != ATTACK_MELEE && delay < 3) delay = 3; + int prayer_lock_delay = use_style == ATTACK_MELEE ? 0 : 2; + launch_npc_attack(state, npc, npc_idx, use_style, delay, 0, + prayer_lock_delay); +} + +/* ======================================================================== */ +/* Yt-MejKot: heal nearby NPCs */ +/* ======================================================================== */ + +static int apply_npc_heal(FcState* state, FcNpc* source, FcNpc* target, + int amount) { + int before = target->current_hp; + target->current_hp += amount; + if (target->current_hp > target->max_hp) { + target->current_hp = target->max_hp; + } + amount = target->current_hp - before; + if (amount <= 0) return 0; + source->healing_given_this_tick += amount; + target->healing_received_this_tick += amount; + if (source->npc_type == NPC_YT_MEJKOT) { + target->healed_by_mejkot_this_tick = 1; + } else if (source->npc_type == NPC_YT_HURKOT) { + target->healed_by_hurkot_this_tick = 1; + } + if (source == target) target->healed_self_this_tick = 1; + + state->npc_heal_procs_this_tick++; + state->npc_heal_amount_this_tick += amount; + if (source->npc_type == NPC_YT_MEJKOT) { + state->mejkot_heal_amount_this_tick += amount; + } + if (target->npc_type == NPC_TZTOK_JAD) { + state->jad_heal_amount_this_tick += amount; + } + return amount; +} + +static int npc_anchor_distance(const FcNpc* a, const FcNpc* b) { + return fc_distance_between_areas(a->x, a->y, 1, b->x, b->y, 1); +} + +static FcNpc* yt_mejkot_heal_target(FcState* state, FcNpc* npc) { + if (npc->current_hp < npc->max_hp / 2) return npc; + + FcNpc* best = NULL; + int best_distance = FC_ARENA_WIDTH; + for (int i = 0; i < FC_MAX_NPCS; i++) { + FcNpc* target = &state->npcs[i]; + if (target == npc || !target->active || target->is_dead) continue; + if (target->current_hp >= target->max_hp / 2) continue; + + int distance = npc_anchor_distance(npc, target); + if (distance > 8) continue; + if (!best || distance < best_distance || + (distance == best_distance && target->spawn_index < best->spawn_index)) { + best = target; + best_distance = distance; + } + } + return best; +} + +static int yt_mejkot_try_heal(FcState* state, FcNpc* npc) { + if (npc->attack_timer > 0) return 0; + if (!fc_npc_can_melee_player(state->player.x, state->player.y, + npc->x, npc->y, npc->size, + state->walkable, state->movement_flags)) { + return 0; + } + + FcNpc* target = yt_mejkot_heal_target(state, npc); + if (!target) return 0; + + apply_npc_heal(state, npc, target, npc->heal_amount); + npc->heal_target_idx = (int)(target - state->npcs); + npc->attack_timer = npc->attack_speed; + return 1; +} + +/* ======================================================================== */ +/* Yt-HurKot: heal Jad until permanently tagged onto the player */ +/* ======================================================================== */ + +#define FC_HURKOT_HEAL_RANGE 5 +static void npc_generic_attack(FcState* state, FcNpc* npc, int npc_idx); + +static int find_active_jad(const FcState* state) { + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc* npc = &state->npcs[i]; + if (npc->active && !npc->is_dead && npc->npc_type == NPC_TZTOK_JAD) { + return i; + } + } + return -1; +} + +static void yt_hurkot_heal_cycle(FcState* state, FcNpc* npc, FcNpc* jad) { + const FcNpcStats* stats = fc_npc_get_stats(npc->npc_type); + if (npc->heal_timer > 1) { + npc->heal_timer--; + return; + } + + npc->heal_timer = stats->heal_interval; + if (npc_anchor_distance(npc, jad) > FC_HURKOT_HEAL_RANGE || + jad->current_hp >= jad->max_hp) { + return; + } + + if (apply_npc_heal(state, npc, jad, npc->heal_amount) > 0) { + state->jad_heal_procs_this_tick++; + } +} + +static void yt_hurkot_tick(FcState* state, FcNpc* npc, int npc_idx) { + /* Once tagged, a healer permanently pursues the player using the same + * local, non-routing movement as every other NPC. It no longer heals Jad. */ + if (npc->healer_distracted) { + npc->heal_target_idx = -1; + if (!fc_npc_position_can_attack_player(state, npc, npc->x, npc->y)) { + for (int step = 0; step < npc->movement_speed; step++) { + if (!npc_dynamic_step_toward_player_bounds(state, npc_idx)) break; + } + } + npc_generic_attack(state, npc, npc_idx); + return; + } + + int jad_idx = find_active_jad(state); + FcNpc* jad = jad_idx >= 0 ? &state->npcs[jad_idx] : NULL; + npc->heal_target_idx = jad_idx; + if (jad) yt_hurkot_heal_cycle(state, npc, jad); + + if (jad && npc_anchor_distance(npc, jad) > FC_HURKOT_HEAL_RANGE) { + npc_dynamic_step_toward(state, npc_idx, jad->x, jad->y); + } +} + +/* ======================================================================== */ +/* Generic NPC attack (melee/ranged/magic, non-Jad) */ +/* ======================================================================== */ + +/* + * Tok-Xil switches to its weaker melee attack at contact. Ket-Zek keeps both + * its Magic and Melee attacks valid at contact and samples between them. + */ +static void npc_generic_attack(FcState* state, FcNpc* npc, int npc_idx) { + const FcNpcStats* stats = fc_npc_get_stats(npc->npc_type); + FcPlayer* p = &state->player; + int dist = fc_distance_to_npc(p->x, p->y, npc); + int can_melee = fc_npc_can_melee_player(p->x, p->y, npc->x, npc->y, + npc->size, state->walkable, + state->movement_flags); + + if (npc->attack_timer > 0) return; + + /* Determine attack style and max hit based on distance */ + int use_style = npc->attack_style; /* primary style */ + int in_range = 0; + int primary_in_range = + npc->attack_style != ATTACK_MELEE && + dist > 0 && dist <= npc->attack_range && + fc_has_los_between_areas( + npc->x, npc->y, npc->size, + p->x, p->y, 1, state->los_flags); + + if (can_melee && stats->melee_max_hit_tenths > 0) { + if (npc->npc_type == NPC_KET_ZEK && primary_in_range && + fc_rng_int(state, 2) == 0) { + use_style = npc->attack_style; + } else { + use_style = ATTACK_MELEE; + } + in_range = 1; + } else if (can_melee && npc->attack_style == ATTACK_MELEE) { + /* Pure melee NPC, in range */ + in_range = 1; + } else if (primary_in_range) { + in_range = 1; + } + + if (!in_range) return; + + int delay = fc_npc_hit_delay(npc->npc_type, use_style, dist); + launch_npc_attack(state, npc, npc_idx, use_style, delay, + stats->prayer_drain, 0); +} + +static int npc_has_attack_position(FcState* state, FcNpc* npc) { + return fc_npc_position_can_attack_player(state, npc, npc->x, npc->y); +} + +/* ======================================================================== */ +/* NPC AI tick — type dispatch */ +/* ======================================================================== */ + +void fc_npc_tick(FcState* state, int npc_idx) { + FcNpc* npc = &state->npcs[npc_idx]; + if (!npc->active || npc->is_dead) return; + + if (npc->npc_type != NPC_YT_HURKOT) npc->heal_target_idx = -1; + + /* Decrement attack timer */ + if (npc->attack_timer > 0) npc->attack_timer--; + + /* --- Type-specific pre-attack behavior --- */ + + /* Yt-HurKot either heals/follows Jad or permanently targets the player. */ + if (npc->npc_type == NPC_YT_HURKOT) { + yt_hurkot_tick(state, npc, npc_idx); + return; + } + + /* Jad: move into range, then choose its attack style when the hit is queued */ + if (npc->npc_type == NPC_TZTOK_JAD) { + if (!npc_has_attack_position(state, npc)) { + for (int step = 0; step < npc->movement_speed; step++) { + if (!npc_dynamic_step_toward_player_bounds(state, npc_idx)) break; + } + } + jad_attack(state, npc, npc_idx); + return; + } + + /* --- Generic movement + attack for all other types --- */ + + /* Movement: keep walking until this tile can actually attack. */ + if (!npc_has_attack_position(state, npc)) { + for (int step = 0; step < npc->movement_speed; step++) { + if (!npc_dynamic_step_toward_player_bounds(state, npc_idx)) break; + } + } + + /* A MejKot heal is an attack-cycle choice, not a parallel action. */ + if (npc->npc_type == NPC_YT_MEJKOT && yt_mejkot_try_heal(state, npc)) { + return; + } + + npc_generic_attack(state, npc, npc_idx); +} + +#undef FC_HURKOT_HEAL_RANGE + +/* Pathfinding */ +#include +#include + +/* + * fc_pathfinding.c — Grid movement, footprint checks, and LOS for Fight Caves. + * + * Key design: + * - NPCs have sizes 1-5 (Jad and Ket-Zek are 5x5!). Movement must check + * the entire footprint at the destination tile. + * - Projectile LOS uses its own directional collision flags. + * - Movement uses whole-tile blocking plus directional wall flags from the + * authoritative cache data, never the visual mesh. + */ + +/* ======================================================================== */ +/* Tile queries */ +/* ======================================================================== */ + +int fc_tile_walkable(int x, int y, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + if (x < 0 || x >= FC_ARENA_WIDTH || y < 0 || y >= FC_ARENA_HEIGHT) return 0; + return walkable[x][y]; +} + +int fc_footprint_walkable(int x, int y, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + /* Check all tiles in the NPC's [x..x+size-1, y..y+size-1] footprint */ + for (int dx = 0; dx < size; dx++) { + for (int dy = 0; dy < size; dy++) { + if (!fc_tile_walkable(x + dx, y + dy, walkable)) return 0; + } + } + return 1; +} + +/* ======================================================================== */ +/* Dynamic occupancy */ +/* ======================================================================== */ + +void fc_clear_occupancy(uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + for (int x = 0; x < FC_ARENA_WIDTH; x++) { + for (int y = 0; y < FC_ARENA_HEIGHT; y++) { + occupied[x][y] = 0; + } + } +} + +void fc_mark_footprint_occupied(uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int x, int y, int size) { + for (int dx = 0; dx < size; dx++) { + for (int dy = 0; dy < size; dy++) { + int tx = x + dx; + int ty = y + dy; + if (tx >= 0 && tx < FC_ARENA_WIDTH && + ty >= 0 && ty < FC_ARENA_HEIGHT) { + occupied[tx][ty] = 1; + } + } + } +} + +void fc_build_occupancy(const FcState* state, + uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int ignore_npc_idx, + int ignore_player) { + fc_clear_occupancy(occupied); + + if (!ignore_player) { + fc_mark_footprint_occupied(occupied, state->player.x, state->player.y, 1); + } + + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc* npc = &state->npcs[i]; + if (i == ignore_npc_idx) continue; + if (!npc->active || npc->is_dead) continue; + fc_mark_footprint_occupied(occupied, npc->x, npc->y, npc->size); + } +} + +int fc_footprint_available_dynamic( + int x, int y, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + if (size <= 0) return 0; + if (!fc_footprint_walkable(x, y, size, walkable)) return 0; + + for (int dx = 0; dx < size; dx++) { + for (int dy = 0; dy < size; dy++) { + if (occupied[x + dx][y + dy]) return 0; + } + } + return 1; +} + +/* Low-byte equivalents of the composite clipping masks used by the native + * client route finder. A non-walkable or occupied tile is the local equivalent + * of its whole-tile LOC blocker. */ +#define FC_BLOCK_WEST FC_MOVE_WALL_EAST +#define FC_BLOCK_EAST FC_MOVE_WALL_WEST +#define FC_BLOCK_SOUTH FC_MOVE_WALL_NORTH +#define FC_BLOCK_NORTH FC_MOVE_WALL_SOUTH +#define FC_BLOCK_SOUTH_WEST \ + (FC_MOVE_WALL_NORTH | FC_MOVE_WALL_NORTH_EAST | FC_MOVE_WALL_EAST) +#define FC_BLOCK_SOUTH_EAST \ + (FC_MOVE_WALL_NORTH_WEST | FC_MOVE_WALL_NORTH | FC_MOVE_WALL_WEST) +#define FC_BLOCK_NORTH_WEST \ + (FC_MOVE_WALL_EAST | FC_MOVE_WALL_SOUTH_EAST | FC_MOVE_WALL_SOUTH) +#define FC_BLOCK_NORTH_EAST \ + (FC_MOVE_WALL_SOUTH | FC_MOVE_WALL_SOUTH_WEST | FC_MOVE_WALL_WEST) +#define FC_BLOCK_NORTH_AND_SOUTH_EAST \ + (FC_MOVE_WALL_NORTH | FC_MOVE_WALL_NORTH_EAST | FC_MOVE_WALL_EAST | \ + FC_MOVE_WALL_SOUTH_EAST | FC_MOVE_WALL_SOUTH) +#define FC_BLOCK_NORTH_AND_SOUTH_WEST \ + (FC_MOVE_WALL_NORTH_WEST | FC_MOVE_WALL_NORTH | FC_MOVE_WALL_SOUTH | \ + FC_MOVE_WALL_SOUTH_WEST | FC_MOVE_WALL_WEST) +#define FC_BLOCK_NORTH_EAST_AND_WEST \ + (FC_MOVE_WALL_NORTH_WEST | FC_MOVE_WALL_NORTH | \ + FC_MOVE_WALL_NORTH_EAST | FC_MOVE_WALL_EAST | FC_MOVE_WALL_WEST) +#define FC_BLOCK_SOUTH_EAST_AND_WEST \ + (FC_MOVE_WALL_EAST | FC_MOVE_WALL_SOUTH_EAST | FC_MOVE_WALL_SOUTH | \ + FC_MOVE_WALL_SOUTH_WEST | FC_MOVE_WALL_WEST) + +static int fc_step_tile_blocked( + int x, int y, uint8_t mask, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + if (x < 0 || x >= FC_ARENA_WIDTH || y < 0 || y >= FC_ARENA_HEIGHT) + return 1; + return !walkable[x][y] || (movement_flags[x][y] & mask) != 0 || + (occupied && occupied[x][y]); +} + +static int fc_footprint_step_valid( + int x, int y, int dx, int dy, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + if (size <= 0 || dx < -1 || dx > 1 || dy < -1 || dy > 1 || + (dx == 0 && dy == 0)) return 0; + +#define BLOCKED(tx, ty, mask) \ + fc_step_tile_blocked((tx), (ty), (uint8_t)(mask), walkable, \ + movement_flags, occupied) + + if (dx == 0 && dy == -1) { + if (size == 1) return !BLOCKED(x, y - 1, FC_BLOCK_SOUTH); + if (BLOCKED(x, y - 1, FC_BLOCK_SOUTH_WEST) || + BLOCKED(x + size - 1, y - 1, FC_BLOCK_SOUTH_EAST)) return 0; + for (int i = 1; i < size - 1; i++) + if (BLOCKED(x + i, y - 1, FC_BLOCK_NORTH_EAST_AND_WEST)) return 0; + return 1; + } + if (dx == 0 && dy == 1) { + if (size == 1) return !BLOCKED(x, y + 1, FC_BLOCK_NORTH); + if (BLOCKED(x, y + size, FC_BLOCK_NORTH_WEST) || + BLOCKED(x + size - 1, y + size, FC_BLOCK_NORTH_EAST)) return 0; + for (int i = 1; i < size - 1; i++) + if (BLOCKED(x + i, y + size, FC_BLOCK_SOUTH_EAST_AND_WEST)) return 0; + return 1; + } + if (dx == -1 && dy == 0) { + if (size == 1) return !BLOCKED(x - 1, y, FC_BLOCK_WEST); + if (BLOCKED(x - 1, y, FC_BLOCK_SOUTH_WEST) || + BLOCKED(x - 1, y + size - 1, FC_BLOCK_NORTH_WEST)) return 0; + for (int i = 1; i < size - 1; i++) + if (BLOCKED(x - 1, y + i, FC_BLOCK_NORTH_AND_SOUTH_EAST)) return 0; + return 1; + } + if (dx == 1 && dy == 0) { + if (size == 1) return !BLOCKED(x + 1, y, FC_BLOCK_EAST); + if (BLOCKED(x + size, y, FC_BLOCK_SOUTH_EAST) || + BLOCKED(x + size, y + size - 1, FC_BLOCK_NORTH_EAST)) return 0; + for (int i = 1; i < size - 1; i++) + if (BLOCKED(x + size, y + i, FC_BLOCK_NORTH_AND_SOUTH_WEST)) return 0; + return 1; + } + if (dx == -1 && dy == -1) { + if (size == 1) + return !BLOCKED(x - 1, y - 1, FC_BLOCK_SOUTH_WEST) && + !BLOCKED(x - 1, y, FC_BLOCK_WEST) && + !BLOCKED(x, y - 1, FC_BLOCK_SOUTH); + if (BLOCKED(x - 1, y - 1, FC_BLOCK_SOUTH_WEST)) return 0; + for (int i = 1; i < size; i++) { + if (BLOCKED(x - 1, y + i - 1, FC_BLOCK_NORTH_AND_SOUTH_EAST) || + BLOCKED(x + i - 1, y - 1, FC_BLOCK_NORTH_EAST_AND_WEST)) return 0; + } + return 1; + } + if (dx == -1 && dy == 1) { + if (size == 1) + return !BLOCKED(x - 1, y + 1, FC_BLOCK_NORTH_WEST) && + !BLOCKED(x - 1, y, FC_BLOCK_WEST) && + !BLOCKED(x, y + 1, FC_BLOCK_NORTH); + if (BLOCKED(x - 1, y + size, FC_BLOCK_NORTH_WEST)) return 0; + for (int i = 1; i < size; i++) { + if (BLOCKED(x - 1, y + i, FC_BLOCK_NORTH_AND_SOUTH_EAST) || + BLOCKED(x + i - 1, y + size, FC_BLOCK_SOUTH_EAST_AND_WEST)) return 0; + } + return 1; + } + if (dx == 1 && dy == -1) { + if (size == 1) + return !BLOCKED(x + 1, y - 1, FC_BLOCK_SOUTH_EAST) && + !BLOCKED(x + 1, y, FC_BLOCK_EAST) && + !BLOCKED(x, y - 1, FC_BLOCK_SOUTH); + if (BLOCKED(x + size, y - 1, FC_BLOCK_SOUTH_EAST)) return 0; + for (int i = 1; i < size; i++) { + if (BLOCKED(x + size, y + i - 1, FC_BLOCK_NORTH_AND_SOUTH_WEST) || + BLOCKED(x + i, y - 1, FC_BLOCK_NORTH_EAST_AND_WEST)) return 0; + } + return 1; + } + if (dx == 1 && dy == 1) { + if (size == 1) + return !BLOCKED(x + 1, y + 1, FC_BLOCK_NORTH_EAST) && + !BLOCKED(x + 1, y, FC_BLOCK_EAST) && + !BLOCKED(x, y + 1, FC_BLOCK_NORTH); + if (BLOCKED(x + size, y + size, FC_BLOCK_NORTH_EAST)) return 0; + for (int i = 1; i < size; i++) { + if (BLOCKED(x + i, y + size, FC_BLOCK_SOUTH_EAST_AND_WEST) || + BLOCKED(x + size, y + i, FC_BLOCK_NORTH_AND_SOUTH_WEST)) return 0; + } + return 1; + } +#undef BLOCKED + return 0; +} + +int fc_footprint_step_walkable( + int x, int y, int dx, int dy, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + return fc_footprint_step_valid(x, y, dx, dy, size, walkable, + movement_flags, NULL); +} + +int fc_footprint_step_available_dynamic( + int x, int y, int dx, int dy, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + return fc_footprint_step_valid(x, y, dx, dy, size, walkable, + movement_flags, occupied); +} + +/* ======================================================================== */ +/* Size-1 movement (player, small NPCs) */ +/* ======================================================================== */ + +int fc_move_toward_traced( + int* x, int* y, int dx, int dy, int max_steps, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int* step_x, int* step_y, int step_capacity) { + int tx = *x + dx; + int ty = *y + dy; + int steps = 0; + + for (int step = 0; step < max_steps; step++) { + if (*x == tx && *y == ty) break; + + int sx = 0, sy = 0; + if (tx > *x) sx = 1; else if (tx < *x) sx = -1; + if (ty > *y) sy = 1; else if (ty < *y) sy = -1; + + /* Try diagonal first, then x-only, then y-only */ + int moved = 0; + if (sx != 0 && sy != 0 && + fc_footprint_step_walkable( + *x, *y, sx, sy, 1, walkable, movement_flags)) { + *x += sx; *y += sy; moved = 1; + } else if (sx != 0 && fc_footprint_step_walkable( + *x, *y, sx, 0, 1, walkable, movement_flags)) { + *x += sx; moved = 1; + } else if (sy != 0 && fc_footprint_step_walkable( + *x, *y, 0, sy, 1, walkable, movement_flags)) { + *y += sy; moved = 1; + } else { + break; + } + if (moved) { + if (steps < step_capacity && step_x && step_y) { + step_x[steps] = *x; + step_y[steps] = *y; + } + steps++; + } + } + return steps; +} + +int fc_move_toward(int* x, int* y, int dx, int dy, int max_steps, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + return fc_move_toward_traced(x, y, dx, dy, max_steps, walkable, + movement_flags, NULL, NULL, 0); +} + +/* ======================================================================== */ +/* Size-aware NPC movement */ +/* ======================================================================== */ + +int fc_npc_step_toward_sized_dynamic( + int* x, int* y, int target_x, int target_y, int size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + int dx = 0, dy = 0; + if (target_x > *x) dx = 1; else if (target_x < *x) dx = -1; + if (target_y > *y) dy = 1; else if (target_y < *y) dy = -1; + + if (dx == 0 && dy == 0) return 0; + + if (dx != 0 && dy != 0 && + fc_footprint_step_available_dynamic( + *x, *y, dx, dy, size, + walkable, movement_flags, occupied)) { + *x += dx; *y += dy; return 1; + } + if (dx != 0 && + fc_footprint_step_available_dynamic( + *x, *y, dx, 0, size, + walkable, movement_flags, occupied)) { + *x += dx; return 1; + } + if (dy != 0 && + fc_footprint_step_available_dynamic( + *x, *y, 0, dy, size, + walkable, movement_flags, occupied)) { + *y += dy; return 1; + } + return 0; +} + +/* ======================================================================== */ +/* Line of sight — directional projectile collision */ +/* ======================================================================== */ + +int fc_distance_between_areas(int src_x, int src_y, int src_size, + int dst_x, int dst_y, int dst_size) { + if (src_size <= 0 || dst_size <= 0) return 0; + int src_max_x = src_x + src_size - 1; + int src_max_y = src_y + src_size - 1; + int dst_max_x = dst_x + dst_size - 1; + int dst_max_y = dst_y + dst_size - 1; + int dx = src_max_x < dst_x ? dst_x - src_max_x : + dst_max_x < src_x ? src_x - dst_max_x : 0; + int dy = src_max_y < dst_y ? dst_y - src_max_y : + dst_max_y < src_y ? src_y - dst_max_y : 0; + return dx > dy ? dx : dy; +} + +static int fc_has_line_of_sight( + int x0, int y0, int x1, int y1, + const uint8_t los_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + if (x0 < 0 || y0 < 0 || x0 >= FC_ARENA_WIDTH || y0 >= FC_ARENA_HEIGHT || + x1 < 0 || y1 < 0 || x1 >= FC_ARENA_WIDTH || y1 >= FC_ARENA_HEIGHT) { + return 0; + } + if (x0 == x1 && y0 == y1) return 1; + if (los_flags[x0][y0] & FC_LOS_FULL) return 0; + + int dx = x1 - x0; + int dy = y1 - y0; + int dx_abs = dx < 0 ? -dx : dx; + int dy_abs = dy < 0 ? -dy : dy; + uint8_t x_flags = FC_LOS_FULL | (dx < 0 ? FC_LOS_EAST : FC_LOS_WEST); + uint8_t y_flags = FC_LOS_FULL | (dy < 0 ? FC_LOS_NORTH : FC_LOS_SOUTH); + + /* Fixed-point major-axis traversal matches directional OSRS tile LOS. + * Each crossed destination tile supplies the boundary flag to test. */ + if (dx_abs > dy_abs) { + int x = x0; + int y_big = (y0 << 16) + 0x8000; + int slope = (dy * 65536) / dx_abs; + if (dy < 0) y_big--; + int direction = dx < 0 ? -1 : 1; + + while (x != x1) { + x += direction; + int y = y_big >> 16; + uint8_t step_x_flags = x_flags; + if (x == x1 && y == y1) step_x_flags &= (uint8_t)~FC_LOS_FULL; + if (los_flags[x][y] & step_x_flags) return 0; + y_big += slope; + int next_y = y_big >> 16; + uint8_t step_y_flags = y_flags; + if (x == x1 && next_y == y1) step_y_flags &= (uint8_t)~FC_LOS_FULL; + if (next_y != y && (los_flags[x][next_y] & step_y_flags)) return 0; + } + } else { + int y = y0; + int x_big = (x0 << 16) + 0x8000; + int slope = (dx * 65536) / dy_abs; + if (dx < 0) x_big--; + int direction = dy < 0 ? -1 : 1; + + while (y != y1) { + y += direction; + int x = x_big >> 16; + uint8_t step_y_flags = y_flags; + if (x == x1 && y == y1) step_y_flags &= (uint8_t)~FC_LOS_FULL; + if (los_flags[x][y] & step_y_flags) return 0; + x_big += slope; + int next_x = x_big >> 16; + uint8_t step_x_flags = x_flags; + if (next_x == x1 && y == y1) step_x_flags &= (uint8_t)~FC_LOS_FULL; + if (next_x != x && (los_flags[next_x][y] & step_x_flags)) return 0; + } + } + + return 1; +} + +static int fc_closest_area_coordinate(int anchor, int other, int size) { + if (anchor >= other) return anchor; + if (anchor + size - 1 <= other) return anchor + size - 1; + return other; +} + +int fc_has_los_between_areas( + int src_x, int src_y, int src_size, + int dst_x, int dst_y, int dst_size, + const uint8_t los_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + if (src_size <= 0 || dst_size <= 0) return 0; + if (src_x < 0 || src_y < 0 || src_x + src_size > FC_ARENA_WIDTH || + src_y + src_size > FC_ARENA_HEIGHT || + dst_x < 0 || dst_y < 0 || dst_x + dst_size > FC_ARENA_WIDTH || + dst_y + dst_size > FC_ARENA_HEIGHT) { + return 0; + } + + int ray_src_x = fc_closest_area_coordinate(src_x, dst_x, src_size); + int ray_src_y = fc_closest_area_coordinate(src_y, dst_y, src_size); + int ray_dst_x = fc_closest_area_coordinate(dst_x, src_x, dst_size); + int ray_dst_y = fc_closest_area_coordinate(dst_y, src_y, dst_size); + return fc_has_line_of_sight(ray_src_x, ray_src_y, + ray_dst_x, ray_dst_y, los_flags); +} + +int fc_npc_can_melee_player(int player_x, int player_y, + int npc_x, int npc_y, int npc_size, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + if (npc_size <= 0) return 0; + int npc_max_x = npc_x + npc_size - 1; + int npc_max_y = npc_y + npc_size - 1; + + /* Rectangular-exclusive reach: shared cardinal edges are valid; diagonal + * corner contact and overlapping footprints are not. */ + (void)walkable; + uint8_t source_wall; + if (player_x == npc_x - 1 && player_y >= npc_y && player_y <= npc_max_y) + source_wall = FC_MOVE_WALL_EAST; + else if (player_x == npc_max_x + 1 && + player_y >= npc_y && player_y <= npc_max_y) + source_wall = FC_MOVE_WALL_WEST; + else if (player_y == npc_y - 1 && + player_x >= npc_x && player_x <= npc_max_x) + source_wall = FC_MOVE_WALL_NORTH; + else if (player_y == npc_max_y + 1 && + player_x >= npc_x && player_x <= npc_max_x) + source_wall = FC_MOVE_WALL_SOUTH; + else + return 0; + + return player_x >= 0 && player_x < FC_ARENA_WIDTH && + player_y >= 0 && player_y < FC_ARENA_HEIGHT && + (movement_flags[player_x][player_y] & source_wall) == 0; +} + +/* ======================================================================== */ +/* BFS pathfinding */ +/* ======================================================================== */ + +static const int FC_ROUTE_DIRECTIONS[8][2] = { + {-1, 0}, {1, 0}, {0, -1}, {0, 1}, + {-1, -1}, {1, -1}, {-1, 1}, {1, 1}, +}; + +static int fc_area_distance(int x, int y, int dst_x, int dst_y, int dst_size) { + return fc_distance_between_areas(x, y, 1, dst_x, dst_y, dst_size); +} + +typedef enum { + FC_ROUTE_EXACT, + FC_ROUTE_ATTACK, +} FcRouteGoalKind; + +static int fc_route_goal_reached( + FcRouteGoalKind kind, int x, int y, + int dst_x, int dst_y, int dst_size, int attack_range, + const uint8_t los_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { + if (kind == FC_ROUTE_EXACT) return x == dst_x && y == dst_y; + int distance = fc_area_distance(x, y, dst_x, dst_y, dst_size); + return distance > 0 && distance <= attack_range && + fc_has_los_between_areas(x, y, 1, dst_x, dst_y, dst_size, + los_flags); +} + +static int fc_reconstruct_route( + int sx, int sy, int end_x, int end_y, + const int8_t pdx[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const int8_t pdy[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int out_x[], int out_y[], int max_steps) { + int px[FC_ARENA_WIDTH * FC_ARENA_HEIGHT]; + int py[FC_ARENA_WIDTH * FC_ARENA_HEIGHT]; + int plen = 0; + int x = end_x; + int y = end_y; + while ((x != sx || y != sy) && plen < FC_ARENA_WIDTH * FC_ARENA_HEIGHT) { + px[plen] = x; + py[plen] = y; + plen++; + int back_x = pdx[x][y]; + int back_y = pdy[x][y]; + x += back_x; + y += back_y; + } + int steps = plen < max_steps ? plen : max_steps; + for (int i = 0; i < steps; i++) { + out_x[i] = px[plen - 1 - i]; + out_y[i] = py[plen - 1 - i]; + } + return steps; +} + +static int fc_bfs_route( + int sx, int sy, int dst_x, int dst_y, int dst_size, + int move_near, FcRouteGoalKind goal_kind, int attack_range, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t los_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int out_x[], int out_y[], int max_steps) { + int8_t pdx[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]; + int8_t pdy[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]; + uint8_t vis[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]; + uint16_t distance[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]; + int qx[FC_ARENA_WIDTH * FC_ARENA_HEIGHT]; + int qy[FC_ARENA_WIDTH * FC_ARENA_HEIGHT]; + int qh = 0, qt = 0; + + if (sx < 0 || sx >= FC_ARENA_WIDTH || sy < 0 || sy >= FC_ARENA_HEIGHT || + dst_size <= 0 || max_steps <= 0) return 0; + memset(vis, 0, sizeof(vis)); + memset(distance, 0, sizeof(distance)); + + vis[sx][sy] = 1; + qx[qt] = sx; qy[qt] = sy; qt++; + int found_x = -1; + int found_y = -1; + while (qh < qt) { + int cx = qx[qh], cy = qy[qh]; qh++; + if (fc_route_goal_reached(goal_kind, cx, cy, dst_x, dst_y, dst_size, + attack_range, los_flags)) { + found_x = cx; + found_y = cy; + break; + } + for (int d = 0; d < 8; d++) { + int step_x = FC_ROUTE_DIRECTIONS[d][0]; + int step_y = FC_ROUTE_DIRECTIONS[d][1]; + int nx = cx + step_x, ny = cy + step_y; + if (nx < 0 || nx >= FC_ARENA_WIDTH || ny < 0 || ny >= FC_ARENA_HEIGHT) continue; + if (vis[nx][ny] || !fc_footprint_step_walkable( + cx, cy, step_x, step_y, 1, + walkable, movement_flags)) continue; + vis[nx][ny] = 1; + pdx[nx][ny] = (int8_t)-step_x; + pdy[nx][ny] = (int8_t)-step_y; + distance[nx][ny] = (uint16_t)(distance[cx][cy] + 1u); + qx[qt] = nx; qy[qt] = ny; qt++; + } + } + + if (found_x < 0 && move_near && goal_kind == FC_ROUTE_EXACT) { + int best_cost = 1000; + int best_distance = 100; + for (int x = dst_x - 10; x <= dst_x + 10; x++) { + for (int y = dst_y - 10; y <= dst_y + 10; y++) { + if (x < 0 || x >= FC_ARENA_WIDTH || y < 0 || y >= FC_ARENA_HEIGHT || + !vis[x][y] || distance[x][y] >= 100) continue; + int off_x = x - dst_x; + int off_y = y - dst_y; + int cost = off_x * off_x + off_y * off_y; + if (cost < best_cost || + (cost == best_cost && distance[x][y] < best_distance)) { + best_cost = cost; + best_distance = distance[x][y]; + found_x = x; + found_y = y; + } + } + } + } + + if (found_x < 0 || (found_x == sx && found_y == sy)) return 0; + return fc_reconstruct_route(sx, sy, found_x, found_y, pdx, pdy, + out_x, out_y, max_steps); +} + +int fc_pathfind_bfs_move_near( + int sx, int sy, int dx, int dy, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int out_x[], int out_y[], int max_steps) { + return fc_bfs_route(sx, sy, dx, dy, 1, 1, FC_ROUTE_EXACT, 0, + walkable, movement_flags, NULL, + out_x, out_y, max_steps); +} + +int fc_pathfind_attack_position( + int sx, int sy, int target_x, int target_y, int target_size, + int attack_range, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t los_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + int out_x[], int out_y[], int max_steps) { + return fc_bfs_route(sx, sy, target_x, target_y, target_size, 0, + FC_ROUTE_ATTACK, attack_range, walkable, + movement_flags, los_flags, out_x, out_y, max_steps); +} + +#undef FC_BLOCK_WEST +#undef FC_BLOCK_EAST +#undef FC_BLOCK_SOUTH +#undef FC_BLOCK_NORTH +#undef FC_BLOCK_SOUTH_WEST +#undef FC_BLOCK_SOUTH_EAST +#undef FC_BLOCK_NORTH_WEST +#undef FC_BLOCK_NORTH_EAST +#undef FC_BLOCK_NORTH_AND_SOUTH_EAST +#undef FC_BLOCK_NORTH_AND_SOUTH_WEST +#undef FC_BLOCK_NORTH_EAST_AND_WEST +#undef FC_BLOCK_SOUTH_EAST_AND_WEST +#undef BLOCKED + +/* Prayer */ +#include + +/* + * fc_prayer.c — Prayer activation, drain, and potion restore. + * + * OSRS prayer drain (from PrayerDrain.kt): + * Each tick: prayerDrainCounter += totalDrainEffect (sum of active prayer drains) + * prayerDrainResistance = 60 + (prayerBonus * 2) + * While counter > resistance: drain 1 prayer point, counter -= resistance + * + * Protection prayers all cost drain=12 per tick (from prayers.toml). + * Prayer points are in tenths (430 = 43 prayer points). + * Each "drain 1 point" = subtract 10 tenths. + * + * Prayer potion restore: + * floor(prayer_level * 0.25) + 7 points per dose. + * For level 43: floor(10.75) + 7 = 17 points → 170 in tenths. + */ + +#define PRAYER_OVERHEAD_DRAIN_RATE 12 + +static void enforce_post_loss_invariant(FcPlayer* p) { + if (p->current_prayer > 0) return; + p->current_prayer = 0; + p->prayer = PRAYER_NONE; + p->prayer_drain_counter = 0; +} + +int fc_prayer_drain_tick(FcPlayer* p, int prayer_at_tick_start, + const FcPrayerTransition* transition) { + int prayer_before = p->current_prayer; + + if (p->current_prayer <= 0) { + enforce_post_loss_invariant(p); + return 0; + } + + if (p->prayer == PRAYER_NONE) return 0; + if (prayer_at_tick_start == PRAYER_NONE) return 0; + + int performed_flick = transition != NULL && + transition->explicit_off_then_on && + transition->off_performed && + transition->on_succeeded; + if (performed_flick) return 0; + + /* Counter-based drain matching OSRS PrayerDrain.kt exactly: + * Accumulate drain rate each tick, drain 1 point when counter exceeds resistance. */ + int drain_rate = PRAYER_OVERHEAD_DRAIN_RATE; /* all 3 protect prayers = 12 */ + int resistance = 60 + 2 * p->prayer_bonus; + + p->prayer_drain_counter += drain_rate; + + int requested_loss = 0; + while (p->prayer_drain_counter > resistance) { + p->prayer_drain_counter -= resistance; + requested_loss += 10; + if (requested_loss >= p->current_prayer) break; + } + + if (requested_loss > 0) { + fc_prayer_apply_loss_tenths(p, requested_loss); + } + return prayer_before - p->current_prayer; +} + +FcPrayerTransition fc_prayer_apply_action(FcPlayer* p, int prayer_action) { + FcPrayerTransition result = {0}; + result.prior_prayer = p->prayer; + result.requested_final_prayer = p->prayer; + + switch (prayer_action) { + case FC_PRAYER_NO_CHANGE: + break; + case FC_PRAYER_OFF: + result.requested_final_prayer = PRAYER_NONE; + result.off_requested = (p->prayer != PRAYER_NONE); + p->prayer = PRAYER_NONE; + break; + case FC_PRAYER_MAGIC: + result.requested_final_prayer = PRAYER_PROTECT_MAGIC; + result.on_requested = (p->prayer != PRAYER_PROTECT_MAGIC); + p->prayer = (p->current_prayer > 0) ? PRAYER_PROTECT_MAGIC : PRAYER_NONE; + break; + case FC_PRAYER_RANGE: + result.requested_final_prayer = PRAYER_PROTECT_RANGE; + result.on_requested = (p->prayer != PRAYER_PROTECT_RANGE); + p->prayer = (p->current_prayer > 0) ? PRAYER_PROTECT_RANGE : PRAYER_NONE; + break; + case FC_PRAYER_MELEE: + result.requested_final_prayer = PRAYER_PROTECT_MELEE; + result.on_requested = (p->prayer != PRAYER_PROTECT_MELEE); + p->prayer = (p->current_prayer > 0) ? PRAYER_PROTECT_MELEE : PRAYER_NONE; + break; + case FC_PRAYER_FLICK_MAGIC: + case FC_PRAYER_FLICK_RANGE: + case FC_PRAYER_FLICK_MELEE: { + int requested_prayer = prayer_action == FC_PRAYER_FLICK_MAGIC + ? PRAYER_PROTECT_MAGIC + : (prayer_action == FC_PRAYER_FLICK_RANGE + ? PRAYER_PROTECT_RANGE : PRAYER_PROTECT_MELEE); + result.requested_final_prayer = requested_prayer; + result.off_requested = 1; + result.off_performed = p->prayer != PRAYER_NONE; + result.on_requested = 1; + result.explicit_off_then_on = 1; + p->prayer = PRAYER_NONE; + if (p->current_prayer > 0) p->prayer = requested_prayer; + break; + } + default: + break; + } + + result.actual_final_prayer = p->prayer; + if (!result.explicit_off_then_on) { + result.off_performed = result.off_requested; + } + result.on_succeeded = result.on_requested && + result.actual_final_prayer == result.requested_final_prayer; + result.final_state_changed = + result.actual_final_prayer != result.prior_prayer; + return result; +} + +int fc_prayer_apply_loss_tenths(FcPlayer* p, int requested_loss_tenths) { + if (p == NULL) return 0; + int prayer_before = p->current_prayer; + if (requested_loss_tenths > 0 && p->current_prayer > 0) { + int loss = requested_loss_tenths; + if (loss > p->current_prayer) loss = p->current_prayer; + p->current_prayer -= loss; + } + enforce_post_loss_invariant(p); + return prayer_before - p->current_prayer; +} + +int fc_prayer_potion_restore(int prayer_level) { + /* floor(level * 0.25) + 7 points → in tenths */ + return (prayer_level / 4 + 7) * 10; +} + +#undef PRAYER_OVERHEAD_DRAIN_RATE + +/* Reward */ +#include + +const char* const FC_CH_NAMES[FC_CH_COUNT] = { + "damage_dealt", "progress", "damage_taken", "npc_kill", "wave_clear", + "jad_kill", "cave_complete", "player_death", "correct_jad_prayer", + "correct_danger_prayer", "prayer_lost", "unnecessary_prayer", "wave_stall", + "no_progress", "no_attack", "jad_heal", "npc_heal", "invalid_action", + "tick_penalty" +}; + +/* Populate a contiguous array view of the breakdown channels for iteration. + * Order matches FcRwdChannel enum above. */ +void fc_reward_breakdown_channels(const FcRewardBreakdown* b, + float out[FC_CH_COUNT]) { + out[FC_CH_DAMAGE_DEALT] = b->damage_dealt; + out[FC_CH_PROGRESS] = b->progress; + out[FC_CH_DAMAGE_TAKEN] = b->damage_taken; + out[FC_CH_NPC_KILL] = b->npc_kill; + out[FC_CH_WAVE_CLEAR] = b->wave_clear; + out[FC_CH_JAD_KILL] = b->jad_kill; + out[FC_CH_CAVE_COMPLETE] = b->cave_complete; + out[FC_CH_PLAYER_DEATH] = b->player_death; + out[FC_CH_CORRECT_JAD_PRAYER] = b->correct_jad_prayer; + out[FC_CH_CORRECT_DANGER_PRAYER] = b->correct_danger_prayer; + out[FC_CH_PRAYER_LOST] = b->prayer_lost; + out[FC_CH_UNNECESSARY_PRAYER] = b->unnecessary_prayer; + out[FC_CH_WAVE_STALL] = b->wave_stall; + out[FC_CH_NO_PROGRESS] = b->no_progress; + out[FC_CH_NO_ATTACK] = b->no_attack; + out[FC_CH_JAD_HEAL] = b->jad_heal; + out[FC_CH_NPC_HEAL] = b->npc_heal; + out[FC_CH_INVALID_ACTION] = b->invalid_action; + out[FC_CH_TICK_PENALTY] = b->tick_penalty; +} + +FcRewardParams fc_reward_default_params(void) { + FcRewardParams params; + memset(¶ms, 0, sizeof(params)); + + params.w_damage_dealt = 0.0f; + params.w_progress = 0.001f; + params.negative_progress_multiplier = 1.0f; + params.w_damage_taken = -0.25f; + params.w_npc_kill = 0.0f; + params.w_wave_clear = 0.0f; + params.w_jad_kill = 0.0f; + params.w_cave_complete = 1.0f; + params.w_player_death = -1.0f; + params.scale_player_death_with_progress = 0; + params.player_death_min_scale = 0.1f; + params.w_correct_jad_prayer = 0.0f; + params.w_correct_danger_prayer = 0.005f; + params.w_prayer_lost = -0.02f; + params.w_invalid_action = -0.1f; + params.w_tick_penalty = -0.0001f; + + params.shape_unnecessary_prayer_penalty = 0.0f; + params.shape_wave_stall_base_penalty = 0.0f; + params.shape_wave_stall_cap = 0.0f; + params.shape_jad_heal_penalty = 0.0f; + params.shape_npc_heal_penalty = 0.0f; + params.shape_no_progress_penalty_1 = -0.001f; + params.shape_no_progress_penalty_2 = -0.005f; + params.shape_no_progress_penalty_3 = -0.02f; + params.shape_no_attack_base_penalty = -0.005f; + params.shape_no_attack_wave_scale = 0.05f; + + params.shape_wave_stall_start = 0; + params.shape_wave_stall_ramp_interval = 0; + params.shape_no_progress_start_1 = 800; + params.shape_no_progress_start_2 = 1600; + params.shape_no_progress_start_3 = 2400; + params.shape_no_attack_start = 50; + + return params; +} + +void fc_reward_runtime_reset(FcRewardRuntime* runtime) { + memset(runtime, 0, sizeof(*runtime)); +} + +static float reward_clamp01(float value) { + if (value < 0.0f) return 0.0f; + if (value > 1.0f) return 1.0f; + return value; +} + +float fc_reward_player_death_scale( + const FcRewardParams* params, float cave_progress) { + if (!params->scale_player_death_with_progress) return 1.0f; + + float floor = reward_clamp01(params->player_death_min_scale); + float progress = reward_clamp01(cave_progress); + return floor + (1.0f - floor) * progress; +} + +float fc_reward_required_work_remaining(const FcState* state) { + if (state->terminal == TERMINAL_CAVE_COMPLETE) { + return 0.0f; + } + + float work = 0.0f; + const FcNpcStats* small_kek_stats = fc_npc_get_stats(NPC_TZ_KEK_SM); + + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc* npc = &state->npcs[i]; + if (!npc->active || npc->is_dead) continue; + + if (state->current_wave == FC_NUM_WAVES) { + if (npc->npc_type == NPC_TZTOK_JAD) { + work += (float)npc->current_hp; + } + continue; + } + + if (npc->npc_type == NPC_TZ_KEK) { + work += (float)npc->current_hp + + 2.0f * (float)small_kek_stats->max_hp; + } else { + work += (float)npc->current_hp; + } + } + + return (work > 0.0f) ? work : 0.0f; +} + +static float reward_current_wave_progress( + const FcState* state, const FcRewardRuntime* runtime, + float required_work_remaining) { + if (state->terminal == TERMINAL_CAVE_COMPLETE) { + return 1.0f; + } + if (state->wave_just_cleared && state->terminal == TERMINAL_NONE) { + return 0.0f; + } + if (runtime->required_work_at_wave_start <= 0.0f) { + return (required_work_remaining <= 0.0f) ? 1.0f : 0.0f; + } + + return reward_clamp01( + 1.0f - required_work_remaining / runtime->required_work_at_wave_start); +} + +static float reward_cave_progress( + const FcState* state, float current_wave_progress) { + if (state->terminal == TERMINAL_CAVE_COMPLETE) { + return 1.0f; + } + + int waves_cleared = state->current_wave - 1; + if (waves_cleared < 0) waves_cleared = 0; + if (waves_cleared > FC_NUM_WAVES) waves_cleared = FC_NUM_WAVES; + return reward_clamp01( + ((float)waves_cleared + current_wave_progress) / (float)FC_NUM_WAVES); +} + +void fc_reward_sync_progress_state( + FcState* state, const FcRewardRuntime* runtime) { + state->progress_required_work_start = runtime->required_work_at_wave_start; + state->progress_required_work_remaining = runtime->last_required_work_remaining; + state->progress_current_wave_progress = runtime->last_current_wave_progress; + state->progress_cave_progress = runtime->last_cave_progress; + state->progress_ticks_since_positive = runtime->ticks_since_positive_progress; +} + +void fc_reward_runtime_begin_episode( + FcRewardRuntime* runtime, FcState* state) { + fc_reward_runtime_reset(runtime); + runtime->required_work_at_wave_start = fc_reward_required_work_remaining(state); + runtime->last_required_work_remaining = runtime->required_work_at_wave_start; + runtime->last_current_wave_progress = reward_current_wave_progress( + state, runtime, runtime->last_required_work_remaining); + runtime->last_cave_progress = reward_cave_progress( + state, runtime->last_current_wave_progress); + runtime->cave_progress_prev = runtime->last_cave_progress; + fc_reward_sync_progress_state(state, runtime); +} + +static FcRewardThreatContext reward_collect_threat_context( + const FcState* state) { + FcRewardThreatContext ctx; + const FcPlayer* p = &state->player; + + memset(&ctx, 0, sizeof(ctx)); + + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc* n = &state->npcs[i]; + if (!n->active || n->is_dead) continue; + + int dist = fc_distance_to_npc(p->x, p->y, n); + if (dist <= 1) { + ctx.melee_pressure_npcs++; + if (n->npc_type == NPC_TOK_XIL) ctx.tokxil_melee = 1; + if (n->npc_type == NPC_KET_ZEK) ctx.ketzek_melee = 1; + } + + if (dist <= n->attack_range) { + ctx.any_threat = 1; + } + } + + for (int i = 0; i < p->num_pending_hits; i++) { + const FcPendingHit* ph = &p->pending_hits[i]; + if (!ph->active) continue; + ctx.any_threat = 1; + } + + return ctx; +} + +FcRewardBreakdown fc_reward_compute_breakdown( + const FcState* state, const FcRewardParams* params, FcRewardRuntime* runtime) { + FcRewardBreakdown out; + const FcPlayer* p = &state->player; + int prayer_reward_idle; + + memset(&out, 0, sizeof(out)); + fc_write_reward_features(state, out.raw); + out.threat_ctx = reward_collect_threat_context(state); + prayer_reward_idle = + (runtime->ticks_since_attack >= 1 && out.raw[FC_RWD_ATTACK_ATTEMPT] <= 0.0f); + + { + float work_remaining = fc_reward_required_work_remaining(state); + float wave_progress = reward_current_wave_progress( + state, runtime, work_remaining); + float cave_progress = reward_cave_progress(state, wave_progress); + float start_work = runtime->required_work_at_wave_start; + float progress_delta = cave_progress - runtime->cave_progress_prev; + float net_work_removed = progress_delta * (float)FC_NUM_WAVES * + ((start_work > 0.0f) ? start_work : 0.0f); + + /* Scalar reward uses raw net required-work removed. The cave-progress + * delta stays normalized for observations/logs, while this channel pays + * for actual HP/work removed and goes negative when healing restores + * work. The optional negative multiplier makes restored work more costly + * without changing positive progress. Multiplying by the wave's start + * work preserves wave-clear handling and avoids treating the next wave + * spawn as negative progress. */ + float progress_weight = params->w_progress; + if (net_work_removed < 0.0f) { + progress_weight *= params->negative_progress_multiplier; + } + out.progress = net_work_removed * progress_weight; + + runtime->last_required_work_remaining = work_remaining; + runtime->last_current_wave_progress = wave_progress; + runtime->last_cave_progress = cave_progress; + runtime->last_progress_delta = progress_delta; + runtime->last_progress_reward = out.progress; + runtime->last_net_required_work_removed = net_work_removed; + + if (net_work_removed > 0.0001f) { + runtime->ticks_since_positive_progress = 0; + runtime->positive_progress_ticks++; + } else { + runtime->ticks_since_positive_progress++; + if (net_work_removed < -0.0001f) { + runtime->negative_progress_ticks++; + } else { + runtime->zero_progress_ticks++; + } + } + + if (params->shape_no_progress_start_1 > 0 && + runtime->ticks_since_positive_progress > params->shape_no_progress_start_1) { + out.no_progress += params->shape_no_progress_penalty_1; + } + if (params->shape_no_progress_start_2 > 0 && + runtime->ticks_since_positive_progress > params->shape_no_progress_start_2) { + out.no_progress += params->shape_no_progress_penalty_2; + } + if (params->shape_no_progress_start_3 > 0 && + runtime->ticks_since_positive_progress > params->shape_no_progress_start_3) { + out.no_progress += params->shape_no_progress_penalty_3; + } + } + + /* damage_dealt fires per damaging hit: (damage + damaging_hits) * w. + * Base reward per hit only applies when actual damage is dealt; zero + * damage impacts still resolve mechanically but do not pay damage reward. */ + out.damage_dealt = (out.raw[FC_RWD_DAMAGE_DEALT] + + (float)state->hits_landed_this_tick) * params->w_damage_dealt; + + { + float dmg_frac = out.raw[FC_RWD_DAMAGE_TAKEN]; + out.damage_taken = dmg_frac * params->w_damage_taken; + } + + out.npc_kill = out.raw[FC_RWD_NPC_KILL] * params->w_npc_kill; + + if (out.raw[FC_RWD_WAVE_CLEAR] > 0.0f) { + int cleared_wave = state->current_wave - 1; + if (cleared_wave < 1) cleared_wave = 1; + out.wave_clear = params->w_wave_clear * (float)cleared_wave; + } + + out.jad_kill = out.raw[FC_RWD_JAD_KILL] * params->w_jad_kill; + out.cave_complete = out.raw[FC_RWD_CAVE_COMPLETE] * params->w_cave_complete; + out.player_death = out.raw[FC_RWD_PLAYER_DEATH] * params->w_player_death * + fc_reward_player_death_scale(params, runtime->last_cave_progress); + + /* Jad now participates in the same correct-block reward as every other + * NPC. Preserve the existing attack-idle suppression unchanged. Jad's + * separate channel remains an optional additional bonus. */ + if (!prayer_reward_idle) { + out.correct_jad_prayer = + out.raw[FC_RWD_CORRECT_JAD_PRAY] * params->w_correct_jad_prayer; + out.correct_danger_prayer = + out.raw[FC_RWD_CORRECT_DANGER_PRAY] * params->w_correct_danger_prayer; + } + out.prayer_lost = out.raw[FC_RWD_PRAYER_LOST] * params->w_prayer_lost; + if (p->prayer != PRAYER_NONE && !out.threat_ctx.any_threat) { + out.unnecessary_prayer = params->shape_unnecessary_prayer_penalty; + } + + out.invalid_action = out.raw[FC_RWD_INVALID_ACTION] * params->w_invalid_action; + out.tick_penalty = out.raw[FC_RWD_TICK_PENALTY] * params->w_tick_penalty; + + if (out.raw[FC_RWD_ATTACK_ATTEMPT] > 0.0f) { + runtime->ticks_since_attack = 0; + } else if (state->npcs_remaining > 0 && p->attack_timer <= 0) { + runtime->ticks_since_attack++; + } else if (state->npcs_remaining <= 0) { + runtime->ticks_since_attack = 0; + } + + if (params->shape_no_attack_start > 0 && + params->shape_no_attack_base_penalty != 0.0f && + state->npcs_remaining > 0 && + runtime->ticks_since_attack > params->shape_no_attack_start) { + float wave = (float)state->current_wave; + if (wave < 1.0f) wave = 1.0f; + float multiplier = 1.0f + + params->shape_no_attack_wave_scale * (wave - 1.0f); + if (multiplier < 0.0f) multiplier = 0.0f; + out.no_attack = params->shape_no_attack_base_penalty * multiplier; + } + + /* Wave-stall penalty — timer-based, fires every tick past the threshold + * while the wave still has NPCs. Ramps linearly and clamps at cap. + * Runtime timer resets when a wave_clear fires this tick. */ + if (state->npcs_remaining > 0) { + runtime->ticks_in_wave++; + if (params->shape_wave_stall_base_penalty != 0.0f && + runtime->ticks_in_wave > params->shape_wave_stall_start) { + int over = runtime->ticks_in_wave - params->shape_wave_stall_start; + int ramps = (params->shape_wave_stall_ramp_interval > 0) + ? over / params->shape_wave_stall_ramp_interval : 0; + float p = params->shape_wave_stall_base_penalty * (1.0f + (float)ramps); + float cap = params->shape_wave_stall_cap; + if (cap != 0.0f && p < cap) p = cap; + out.wave_stall = p; + } + } + if (out.raw[FC_RWD_WAVE_CLEAR] > 0.0f) { + runtime->ticks_in_wave = 0; + } + + /* Jad heal penalty: fires per Yt-HurKot heal proc that landed on Jad + * this tick. Encourages the agent to break healer link or kill healers + * before they restore Jad's HP. */ + if (state->jad_heal_procs_this_tick > 0 && + params->shape_jad_heal_penalty != 0.0f) { + out.jad_heal = params->shape_jad_heal_penalty * + (float)state->jad_heal_procs_this_tick; + } + if (state->npc_heal_procs_this_tick > 0 && + params->shape_npc_heal_penalty != 0.0f) { + out.npc_heal = params->shape_npc_heal_penalty * + (float)state->npc_heal_procs_this_tick; + } + + if (state->wave_just_cleared && state->terminal == TERMINAL_NONE) { + runtime->required_work_at_wave_start = + fc_reward_required_work_remaining(state); + runtime->last_required_work_remaining = + runtime->required_work_at_wave_start; + runtime->last_current_wave_progress = 0.0f; + runtime->last_cave_progress = + reward_cave_progress(state, runtime->last_current_wave_progress); + } + runtime->cave_progress_prev = runtime->last_cave_progress; + + out.total = + out.damage_dealt + + out.progress + + out.damage_taken + + out.npc_kill + + out.wave_clear + + out.jad_kill + + out.cave_complete + + out.player_death + + out.correct_jad_prayer + + out.correct_danger_prayer + + out.prayer_lost + + out.unnecessary_prayer + + out.wave_stall + + out.no_progress + + out.no_attack + + out.jad_heal + + out.npc_heal + + out.invalid_action + + out.tick_penalty; + + return out; +} + + +/* Rng */ +/* + * XORshift32 RNG — single state, deterministic, seeded at reset. + * All randomness in the simulation flows through this RNG. + * Adopted from PufferLib OSRS PvP (osrs_pvp_types.h). + */ + +void fc_rng_seed(FcState* state, uint32_t seed) { + /* XORshift32 cannot have state 0 — if seed is 0, use a fixed nonzero value */ + state->rng_state = (seed != 0) ? seed : 0x12345678u; + state->rng_seed = seed; +} + +uint32_t fc_rng_next(FcState* state) { + uint32_t x = state->rng_state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + state->rng_state = x; + return x; +} + +int fc_rng_int(FcState* state, int max) { + if (max <= 0) return 0; + return (int)(fc_rng_next(state) % (uint32_t)max); +} + +float fc_rng_float(FcState* state) { + return (float)(fc_rng_next(state) & 0x00FFFFFFu) / (float)0x01000000u; +} + + +/* Spawn */ +int fc_spawn_find_available_footprint(const FcState* state, + int preferred_x, int preferred_y, + int size, int max_radius, + int* out_x, int* out_y) { + uint8_t occupied[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]; + fc_build_occupancy(state, occupied, -1, 0); + + *out_x = preferred_x; + *out_y = preferred_y; + if (fc_footprint_available_dynamic(preferred_x, preferred_y, size, + state->walkable, occupied)) { + return 1; + } + + for (int radius = 1; radius <= max_radius; radius++) { + for (int dx = -radius; dx <= radius; dx++) { + for (int dy = -radius; dy <= radius; dy++) { + if (dx != -radius && dx != radius && + dy != -radius && dy != radius) { + continue; + } + int x = preferred_x + dx; + int y = preferred_y + dy; + if (fc_footprint_available_dynamic(x, y, size, + state->walkable, occupied)) { + *out_x = x; + *out_y = y; + return 1; + } + } + } + } + + return 0; +} + +int fc_spawn_npc_first_free(FcState* state, int npc_type, int x, int y) { + for (int slot = 0; slot < FC_MAX_NPCS; slot++) { + if (state->npcs[slot].active) continue; + fc_npc_spawn(&state->npcs[slot], npc_type, x, y, + state->next_spawn_index++); + return slot; + } + return -1; +} + + +/* State */ +#include +#include +#include + +/* + * fc_state.c — State allocation, initialization, reset, rendering. + * + * FcState is caller-allocated (stack or heap). These functions + * initialize and reset it. memset to zero is the canonical reset + * mechanism — all fields must have safe zero defaults. + */ + +/* ======================================================================== */ +/* Arena collision map (from Void 634 cache, region 37,79, level 0) */ +/* ======================================================================== */ + +/* + * Binary collision extracted via DumpFcCollision.kt from the Void 634 cache. + * fightcaves.collision stores whole-tile blocking as 64*64 row-major bytes. + * fightcaves.movement stores the corresponding directional wall bits. + * + * Loaded from resources/fight_caves/runtime in a packaged PufferLib checkout. + * All three arena maps are required; running without one would change the + * simulation's movement or line-of-sight rules. + */ +/* Cached arena data — loaded once and shared by all envs to avoid per-reset + * file I/O. Each map retains separate storage and initialization state. */ +typedef struct { + uint8_t cells[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]; + int loaded; +} FcArenaMapCache; + +static FcArenaMapCache g_collision_cache; +static FcArenaMapCache g_movement_cache; +static FcArenaMapCache g_los_cache; + +static const char* const g_arena_asset_path_formats[] = { + "resources/fight_caves/runtime/%s", + NULL +}; + +static void fail_required_arena_asset(const char* filename, + const char* override_name) { + fprintf(stderr, + "fatal: required Fight Caves arena asset '%s' is missing, " + "unreadable, or not exactly %d bytes.\n" + "Set %s to the asset's absolute path or install the pinned " + "runtime bundle with 'python3 ocean/fight_caves/tools.py " + "setup --core'.\n", + filename, FC_ARENA_WIDTH * FC_ARENA_HEIGHT, + override_name); + fflush(stderr); + exit(EXIT_FAILURE); +} + +static void load_required_arena_map(FcArenaMapCache* cache, + const char* filename, + const char* override_name) { + FILE* file = NULL; + const char* override_path; + char path[256]; + uint8_t bytes[FC_ARENA_WIDTH * FC_ARENA_HEIGHT]; + size_t count; + + if (cache->loaded) return; + override_path = getenv(override_name); + if (override_path && override_path[0]) { + file = fopen(override_path, "rb"); + if (!file) { + fprintf(stderr, + "fatal: %s points to an unreadable Fight Caves arena " + "asset: %s\n", + override_name, override_path); + fail_required_arena_asset(filename, override_name); + } + } else { + for (int i = 0; !file && g_arena_asset_path_formats[i]; i++) { + int length = snprintf(path, sizeof(path), + g_arena_asset_path_formats[i], filename); + if (length > 0 && (size_t)length < sizeof(path)) { + file = fopen(path, "rb"); + } + } + } + if (!file) fail_required_arena_asset(filename, override_name); + + count = fread(bytes, 1, sizeof(bytes), file); + fclose(file); + if (count != sizeof(bytes)) { + fail_required_arena_asset(filename, override_name); + } + + /* Binary files are row-major [y][x]; FcState maps are [x][y]. */ + for (int y = 0; y < FC_ARENA_HEIGHT; y++) { + for (int x = 0; x < FC_ARENA_WIDTH; x++) { + cache->cells[x][y] = bytes[y * FC_ARENA_WIDTH + x]; + } + } + cache->loaded = 1; +} + +static void setup_arena(FcState* state) { + load_required_arena_map(&g_collision_cache, "fightcaves.collision", + "FC_COLLISION_PATH"); + load_required_arena_map(&g_movement_cache, "fightcaves.movement", + "FC_MOVEMENT_PATH"); + load_required_arena_map(&g_los_cache, "fightcaves.los", "FC_LOS_PATH"); + memcpy(state->walkable, g_collision_cache.cells, sizeof(state->walkable)); + memcpy(state->movement_flags, g_movement_cache.cells, + sizeof(state->movement_flags)); + memcpy(state->los_flags, g_los_cache.cells, sizeof(state->los_flags)); +} + +/* Player initialization — the loadout table is the combat-state authority. */ + +static void apply_loadout_combat_fields(FcPlayer* p, + const FcLoadout* loadout) { + p->max_hp = loadout->max_hp; + p->max_prayer = loadout->max_prayer; + p->attack_level = loadout->attack_lvl; + p->strength_level = loadout->strength_lvl; + p->defence_level = loadout->defence_lvl; + p->ranged_level = loadout->ranged_lvl; + p->prayer_level = loadout->prayer_lvl; + p->magic_level = loadout->magic_lvl; + p->weapon_kind = loadout->weapon_kind; + p->weapon_uses_ammo = loadout->weapon_uses_ammo; + p->crystal_piece_mask = loadout->crystal_piece_mask; + p->weapon_speed = loadout->weapon_speed; + p->weapon_range = loadout->weapon_range; + p->ranged_attack_bonus = loadout->ranged_atk; + p->ranged_strength_bonus = loadout->ranged_str; + p->defence_stab = loadout->def_stab; + p->defence_slash = loadout->def_slash; + p->defence_crush = loadout->def_crush; + p->defence_magic = loadout->def_magic; + p->defence_ranged = loadout->def_ranged; + p->prayer_bonus = loadout->prayer_bonus; + p->ammo_count = loadout->ammo; +} + +static void init_player(FcPlayer* p) { + const FcLoadout* loadout = &FC_LOADOUTS[FC_ACTIVE_LOADOUT]; + apply_loadout_combat_fields(p, loadout); + p->x = FC_ARENA_WIDTH / 2; + p->y = FC_ARENA_HEIGHT / 2; + p->current_hp = p->max_hp; + p->current_prayer = p->max_prayer; + p->prayer = PRAYER_NONE; + p->prayer_at_tick_start = PRAYER_NONE; + p->sharks_remaining = FC_MAX_SHARKS; + p->prayer_doses_remaining = FC_MAX_PRAYER_DOSES; + p->attack_timer = 0; + p->food_timer = 0; + p->potion_timer = 0; + p->combo_timer = 0; + p->run_energy = FC_RUN_ENERGY_MAX; + p->is_running = 1; + p->hp_regen_counter = 0; + p->route_len = 0; + p->route_idx = 0; + p->attack_target_idx = -1; + p->approach_target = 0; + p->approach_target_x = -1; + p->approach_target_y = -1; + p->approach_target_size = 0; +} + +/* ======================================================================== */ +/* Lifecycle */ +/* ======================================================================== */ + +static void validate_npc_table_or_abort(void) { + for (int npc_type = NPC_TZ_KIH; npc_type < NPC_TYPE_COUNT; npc_type++) { + const FcNpcStats* stats = fc_npc_get_stats(npc_type); + if (fc_npc_stats_valid(stats)) continue; + + fprintf(stderr, + "fc_init: invalid NPC maxima for type %d: melee=%d ranged=%d magic=%d tenths\n", + npc_type, stats->melee_max_hit_tenths, + stats->ranged_max_hit_tenths, + stats->magic_max_hit_tenths); + abort(); + } +} + +static void validate_loadout_table_or_abort(void) { + if (FC_ACTIVE_LOADOUT < 0 || FC_ACTIVE_LOADOUT >= FC_NUM_LOADOUTS) { + fprintf(stderr, "fc_init: active loadout %d is outside [0,%d)\n", + FC_ACTIVE_LOADOUT, FC_NUM_LOADOUTS); + abort(); + } + + for (int loadout_id = 0; loadout_id < FC_NUM_LOADOUTS; loadout_id++) { + const FcLoadout* loadout = &FC_LOADOUTS[loadout_id]; + int valid = loadout->max_hp > 0 && loadout->max_prayer > 0 && + loadout->attack_lvl >= 1 && loadout->strength_lvl >= 1 && + loadout->defence_lvl >= 1 && loadout->ranged_lvl >= 1 && + loadout->prayer_lvl >= 1 && loadout->magic_lvl >= 1 && + loadout->weapon_kind >= FC_WEAPON_GENERIC_RANGED && + loadout->weapon_kind <= FC_WEAPON_BOW_OF_FAERDHINEN && + (loadout->weapon_uses_ammo == 0 || + loadout->weapon_uses_ammo == 1) && + loadout->ammo >= 0 && + (loadout->crystal_piece_mask & ~FC_CRYSTAL_PIECE_ALL) == 0 && + loadout->equipment_count >= 0 && + loadout->equipment_count <= FC_LOADOUT_EQUIP_MAX && + loadout->model_item_count >= 0 && + loadout->model_item_count <= FC_LOADOUT_MODEL_ITEM_MAX; + + FcPlayer player = {0}; + apply_loadout_combat_fields(&player, loadout); + if (fc_player_ranged_base_max_hit_hp(&player) <= 0) valid = 0; + for (int npc_type = NPC_TZ_KIH; + valid && npc_type < NPC_TYPE_COUNT; npc_type++) { + FcNpc target = {0}; + target.npc_type = npc_type; + if (fc_player_ranged_final_max_hit_hp(&player, &target) <= 0) + valid = 0; + } + + if (valid) continue; + fprintf(stderr, + "fc_init: invalid loadout %d (skills=%d/%d/%d/%d/%d/%d weapon=%d ammo=%d/%d crystal=%d)\n", + loadout_id, loadout->attack_lvl, loadout->strength_lvl, + loadout->defence_lvl, loadout->ranged_lvl, + loadout->prayer_lvl, loadout->magic_lvl, + loadout->weapon_kind, loadout->weapon_uses_ammo, + loadout->ammo, loadout->crystal_piece_mask); + abort(); + } +} + +void fc_init(FcState* state) { + validate_npc_table_or_abort(); + validate_loadout_table_or_abort(); + memset(state, 0, sizeof(FcState)); + state->active_loadout = FC_ACTIVE_LOADOUT; +} + +void fc_reset(FcState* state, uint32_t seed) { + /* Zero everything first — ensures no stale state, padding is clean */ + memset(state, 0, sizeof(FcState)); + + /* Seed RNG */ + fc_rng_seed(state, seed); + + /* Select random rotation */ + state->rotation_id = fc_rng_int(state, FC_NUM_ROTATIONS); + + /* Setup arena */ + setup_arena(state); + + /* Initialize player */ + init_player(&state->player); + state->active_loadout = FC_ACTIVE_LOADOUT; + state->render_events.player_attack_target_npc_slot = -1; + state->render_events.player_move_start_x = state->player.x; + state->render_events.player_move_start_y = state->player.y; + + /* Spawn wave 1 NPCs */ + state->current_wave = 1; + state->next_spawn_index = 0; + state->wave_start_tick = 0; + fc_wave_spawn(state, 1); +} + +void fc_step(FcState* state, const int actions[FC_NUM_ACTION_HEADS]) { + if (state->terminal != TERMINAL_NONE) return; /* episode over */ + fc_tick(state, actions); +} + +void fc_request_set_running(FcState* state, int enabled) { + if (!state || state->terminal != TERMINAL_NONE) return; + state->player.is_running = enabled && + state->player.run_energy >= FC_RUN_ENERGY_MIN_START; +} + +void fc_destroy(FcState* state) { + /* Currently no heap allocations. Zero for safety. */ + memset(state, 0, sizeof(FcState)); +} + +/* ======================================================================== */ +/* Observation / Mask / Reward */ +/* ======================================================================== */ + +/* Sort helper: indices of active NPCs, sorted by (distance, spawn_index) */ +static int compare_npc_slots(const void* a, const void* b, const FcState* state) { + int ia = *(const int*)a; + int ib = *(const int*)b; + int da = fc_distance_to_npc(state->player.x, state->player.y, &state->npcs[ia]); + int db = fc_distance_to_npc(state->player.x, state->player.y, &state->npcs[ib]); + if (da != db) return da - db; + return state->npcs[ia].spawn_index - state->npcs[ib].spawn_index; +} + +/* Simple insertion sort for small arrays (max 16 elements) */ +static void sort_npc_indices(int* indices, int count, const FcState* state) { + for (int i = 1; i < count; i++) { + int key = indices[i]; + int j = i - 1; + while (j >= 0 && compare_npc_slots(&key, &indices[j], state) < 0) { + indices[j + 1] = indices[j]; + j--; + } + indices[j + 1] = key; + } +} + +int fc_visible_npc_indices(const FcState* state, int out_indices[FC_VISIBLE_NPCS]) { + int active_indices[FC_MAX_NPCS]; + int active_count = 0; + + for (int i = 0; i < FC_MAX_NPCS; i++) { + if (state->npcs[i].active && !state->npcs[i].is_dead) { + active_indices[active_count++] = i; + } + } + + sort_npc_indices(active_indices, active_count, state); + + int visible = (active_count < FC_VISIBLE_NPCS) ? active_count : FC_VISIBLE_NPCS; + for (int slot = 0; slot < visible; slot++) { + out_indices[slot] = active_indices[slot]; + } + return visible; +} + +static int move_action_valid(const FcState* state, int action) { + const FcPlayer* p = &state->player; + + if (action < 0 || action >= FC_MOVE_DIM) return 0; + if (action == FC_MOVE_IDLE) return 1; + if (action >= FC_MOVE_RUN_N && !fc_player_can_run(p)) { + return 0; + } + + int tx = p->x; + int ty = p->y; + int max_steps = (action >= FC_MOVE_RUN_N) ? 2 : 1; + return fc_move_toward(&tx, &ty, FC_MOVE_DX[action], FC_MOVE_DY[action], + max_steps, state->walkable, + state->movement_flags) > 0; +} + +static int attack_action_valid(const FcState* state, int action) { + int visible_indices[FC_VISIBLE_NPCS]; + int visible; + int slot; + + if (action < 0 || action >= FC_ATTACK_DIM) return 0; + if (action == FC_ATTACK_NONE) return 1; + + visible = fc_visible_npc_indices(state, visible_indices); + slot = action - 1; + return slot < visible; +} + +static int prayer_action_valid(int action) { + return action >= 0 && action < FC_PRAYER_DIM; +} + +int fc_eat_action_valid(const FcState* state, int action) { + const FcPlayer* p = &state->player; + + if (action < 0 || action >= FC_EAT_DIM) return 0; + if (action == FC_EAT_NONE) return 1; + if (action == FC_EAT_SHARK) { + return p->sharks_remaining > 0 && + p->food_timer <= 0 && + p->current_hp < p->max_hp; + } + if (action == FC_EAT_COMBO) { + return p->sharks_remaining > 0 && + p->combo_timer <= 0 && + p->current_hp < p->max_hp; + } + return 0; +} + +int fc_drink_action_valid(const FcState* state, int action) { + const FcPlayer* p = &state->player; + + if (action < 0 || action >= FC_DRINK_DIM) return 0; + if (action == FC_DRINK_NONE) return 1; + if (action == FC_DRINK_PRAYER_POT) { + return p->prayer_doses_remaining > 0 && + p->potion_timer <= 0 && + p->current_prayer < p->max_prayer; + } + return 0; +} + +static int attack_style_summary_idx(int style) { + switch (style) { + case ATTACK_MELEE: return 0; + case ATTACK_RANGED: return 1; + case ATTACK_MAGIC: return 2; + default: return -1; + } +} + +static float normalize_incoming_count(int count) { + if (count <= 0) return 0.0f; + if (count >= 4) return 1.0f; + return (float)count / 4.0f; +} + +static float clamp01(float value) { + if (value < 0.0f) return 0.0f; + if (value > 1.0f) return 1.0f; + return value; +} + +static float normalize_prayer_drain_counter(const FcPlayer* p) { + int resistance = 60 + 2 * p->prayer_bonus; + if (resistance <= 0) return 0.0f; + float normalized = (float)p->prayer_drain_counter / (float)resistance; + if (normalized < 0.0f) return 0.0f; + if (normalized > 1.0f) return 1.0f; + return normalized; +} + +static float normalize_npc_prayer_drain(const FcNpc* npc) { + const FcNpcStats* stats = fc_npc_get_stats(npc->npc_type); + int maximum = stats->prayer_drain; + if (npc->npc_type == NPC_TZ_KIH) { + maximum += fc_npc_max_hit_tenths_for_style(stats, ATTACK_MELEE); + } + if (maximum <= 0) return 0.0f; + return clamp01((float)npc->prayer_drain_dealt_this_tick / (float)maximum); +} + +static float normalize_npc_heal_cooldown(const FcNpc* npc) { + if (npc->npc_type == NPC_YT_MEJKOT) { + if (npc->attack_speed <= 0) return 0.0f; + return clamp01((float)npc->attack_timer / (float)npc->attack_speed); + } + + if (npc->npc_type == NPC_YT_HURKOT && !npc->healer_distracted) { + const FcNpcStats* stats = fc_npc_get_stats(npc->npc_type); + if (stats->heal_interval <= 0) return 0.0f; + return clamp01((float)npc->heal_timer / (float)stats->heal_interval); + } + + return 0.0f; +} + +static int pending_hit_prayer_actionable(const FcState* state, + const FcPendingHit* ph) { + if (!ph->active) return 0; + if (ph->prayer_snapshot >= 0) return 0; + if (ph->prayer_lock_tick < 0) return 0; + if (state->tick >= ph->prayer_lock_tick) return 0; + return attack_style_summary_idx(ph->attack_style) >= 0; +} + +static float pending_hit_prayer_deadline_urgency(const FcState* state, + const FcPendingHit* ph) { + if (!pending_hit_prayer_actionable(state, ph)) return 0.0f; + + int ticks_until_lock = ph->prayer_lock_tick - state->tick; + if (ticks_until_lock > 4) ticks_until_lock = 4; + + return (float)(5 - ticks_until_lock) / 4.0f; +} + +/* Distance-only attack-style telegraph: what style would this NPC throw if it + * attacked right now from its current position? Does NOT check LOS; the LOS + * bit is a separate obs feature so the agent can distinguish "melee threat, + * safespotted" (style=MELEE, LOS=0) from "melee threat, can hit me" (style=MELEE, + * LOS=1). Returns ATTACK_NONE for empty slots, an untagged Yt-HurKot, and + * stochastic style choices before the NPC has committed an attack. */ +static int npc_telegraph_style(const FcState* state, const FcNpc* npc) { + if (!npc->active || npc->is_dead) return ATTACK_NONE; + if (npc->npc_type == NPC_YT_HURKOT) { + return npc->healer_distracted ? ATTACK_MELEE : ATTACK_NONE; + } + + if (npc->npc_type == NPC_TZTOK_JAD) { + /* Jad alternates magic/ranged randomly at commit; read the committed + * style from the queued pending_hit. No prediction before commit. */ + const FcPlayer* p = &state->player; + for (int i = 0; i < p->num_pending_hits; i++) { + const FcPendingHit* ph = &p->pending_hits[i]; + if (ph->active && ph->source_npc_idx >= 0 && + state->npcs[ph->source_npc_idx].npc_type == NPC_TZTOK_JAD) { + return ph->attack_style; + } + } + return ATTACK_NONE; + } + + const FcNpcStats* stats = fc_npc_get_stats(npc->npc_type); + int can_melee = fc_npc_can_melee_player(state->player.x, state->player.y, + npc->x, npc->y, npc->size, + state->walkable, + state->movement_flags); + if (npc->npc_type == NPC_KET_ZEK && can_melee && + fc_has_los_between_areas( + npc->x, npc->y, npc->size, + state->player.x, state->player.y, 1, state->los_flags)) { + return ATTACK_NONE; + } + /* Tok-Xil telegraphs melee when adjacent. Ket-Zek returns NONE above while + * both adjacent styles remain possible. Pure melee NPCs (Yt-MejKot, + * Tz-Kih, Tz-Kek) telegraph MELEE even when far + * — they'll close the gap and that's what they'll hit with. */ + if (can_melee && (stats->melee_max_hit_tenths > 0 || + npc->attack_style == ATTACK_MELEE)) { + return ATTACK_MELEE; + } + return npc->attack_style; +} + +static int npc_type_obs_offset(int npc_type) { + switch (npc_type) { + case NPC_TZ_KIH: return FC_NPC_TYPE_TZ_KIH; + case NPC_TZ_KEK: return FC_NPC_TYPE_TZ_KEK; + case NPC_TZ_KEK_SM: return FC_NPC_TYPE_TZ_KEK_SM; + case NPC_TOK_XIL: return FC_NPC_TYPE_TOK_XIL; + case NPC_YT_MEJKOT: return FC_NPC_TYPE_YT_MEJKOT; + case NPC_KET_ZEK: return FC_NPC_TYPE_KET_ZEK; + case NPC_TZTOK_JAD: return FC_NPC_TYPE_TZTOK_JAD; + case NPC_YT_HURKOT: return FC_NPC_TYPE_YT_HURKOT; + default: return -1; + } +} + +static int npc_kill_reward_eligible(const FcNpc* npc) { + return npc->npc_type != NPC_YT_HURKOT || + !npc->is_respawned_jad_healer; +} + +static int rewardable_npc_kills_this_tick(const FcState* state) { + int count = state->npcs_killed_this_tick - + state->respawned_jad_healers_killed_this_tick; + return count > 0 ? count : 0; +} + +void fc_write_obs(const FcState* state, float* out) { + memset(out, 0, sizeof(float) * FC_TOTAL_OBS); + + const FcPlayer* p = &state->player; + int incoming_counts[3][3] = {{0}}; + float prayer_deadline_urgency[3] = {0.0f, 0.0f, 0.0f}; + + /* Compact incoming-hit timeline summary. + * Counts by style for hits landing in 1, 2, and 3 ticks. This gives the + * policy a relative timing signal without leaking absolute episode clocks. + * Prayer deadline urgency is separate: it marks pending hits whose prayer + * snapshot has not locked yet, which is the actual decision window. */ + for (int hi = 0; hi < p->num_pending_hits; hi++) { + const FcPendingHit* ph = &p->pending_hits[hi]; + if (!ph->active) continue; + int style_idx = attack_style_summary_idx(ph->attack_style); + if (style_idx >= 0 && pending_hit_prayer_actionable(state, ph)) { + float urgency = pending_hit_prayer_deadline_urgency(state, ph); + if (urgency > prayer_deadline_urgency[style_idx]) { + prayer_deadline_urgency[style_idx] = urgency; + } + } + if (ph->ticks_remaining < 1 || ph->ticks_remaining > 3) continue; + if (style_idx < 0) continue; + int bucket = ph->ticks_remaining - 1; + if (incoming_counts[bucket][style_idx] < 4) { + incoming_counts[bucket][style_idx]++; + } + } + + /* Player features */ + float* player = out + FC_OBS_PLAYER_START; + player[FC_OBS_PLAYER_HP] = (p->max_hp > 0) ? (float)p->current_hp / (float)p->max_hp : 0.0f; + player[FC_OBS_PLAYER_PRAYER] = (p->max_prayer > 0) ? (float)p->current_prayer / (float)p->max_prayer : 0.0f; + player[FC_OBS_PLAYER_X] = (float)p->x / (float)FC_ARENA_WIDTH; + player[FC_OBS_PLAYER_Y] = (float)p->y / (float)FC_ARENA_HEIGHT; + player[FC_OBS_PLAYER_ATK_TIMER] = (p->weapon_speed > 0) + ? (float)p->attack_timer / (float)p->weapon_speed : 0.0f; + player[FC_OBS_PLAYER_PRAY_MEL] = (p->prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + player[FC_OBS_PLAYER_PRAY_RNG] = (p->prayer == PRAYER_PROTECT_RANGE) ? 1.0f : 0.0f; + player[FC_OBS_PLAYER_PRAY_MAG] = (p->prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + player[FC_OBS_PLAYER_SHARKS] = (float)p->sharks_remaining / (float)FC_MAX_SHARKS; + player[FC_OBS_PLAYER_DOSES] = (float)p->prayer_doses_remaining / (float)FC_MAX_PRAYER_DOSES; + player[FC_OBS_PLAYER_IN_MEL_1T] = normalize_incoming_count(incoming_counts[0][0]); + player[FC_OBS_PLAYER_IN_RNG_1T] = normalize_incoming_count(incoming_counts[0][1]); + player[FC_OBS_PLAYER_IN_MAG_1T] = normalize_incoming_count(incoming_counts[0][2]); + player[FC_OBS_PLAYER_IN_MEL_2T] = normalize_incoming_count(incoming_counts[1][0]); + player[FC_OBS_PLAYER_IN_RNG_2T] = normalize_incoming_count(incoming_counts[1][1]); + player[FC_OBS_PLAYER_IN_MAG_2T] = normalize_incoming_count(incoming_counts[1][2]); + player[FC_OBS_PLAYER_TARGET] = 0.0f; /* filled after NPC slot computation below */ + player[FC_OBS_PLAYER_PRAY_DDL_MEL] = prayer_deadline_urgency[0]; + player[FC_OBS_PLAYER_PRAY_DDL_RNG] = prayer_deadline_urgency[1]; + player[FC_OBS_PLAYER_PRAY_DDL_MAG] = prayer_deadline_urgency[2]; + player[FC_OBS_PLAYER_PRAYER_LOST] = (p->max_prayer > 0) + ? clamp01((float)state->prayer_lost_this_tick / (float)p->max_prayer) + : 0.0f; + player[FC_OBS_PLAYER_OVERHEAD_PRAYER_LOST] = + state->overhead_prayer_lost_this_tick > 0 ? 1.0f : 0.0f; + player[FC_OBS_PLAYER_RUN_ENERGY] = + clamp01((float)p->run_energy / (float)FC_RUN_ENERGY_MAX); + + /* NPC slot selection: gather active NPCs, sort, take first 8 */ + int active_indices[FC_VISIBLE_NPCS]; + int visible = fc_visible_npc_indices(state, active_indices); + for (int slot = 0; slot < visible; slot++) { + const FcNpc* n = &state->npcs[active_indices[slot]]; + float* npc_out = out + FC_OBS_NPC_START + slot * FC_OBS_NPC_STRIDE; + + npc_out[FC_NPC_VALID] = 1.0f; + npc_out[FC_NPC_X] = (float)n->x / (float)FC_ARENA_WIDTH; + npc_out[FC_NPC_Y] = (float)n->y / (float)FC_ARENA_HEIGHT; + npc_out[FC_NPC_HP] = (n->max_hp > 0) ? (float)n->current_hp / (float)n->max_hp : 0.0f; + npc_out[FC_NPC_DISTANCE] = + (float)fc_distance_to_npc(p->x, p->y, n) / (float)FC_ARENA_WIDTH; + float has_los = (float)fc_has_los_between_areas( + p->x, p->y, 1, n->x, n->y, n->size, state->los_flags); + int tele = npc_telegraph_style(state, n); + npc_out[FC_NPC_TELE_MELEE] = (tele == ATTACK_MELEE) ? 1.0f : 0.0f; + npc_out[FC_NPC_TELE_RANGED] = (tele == ATTACK_RANGED) ? 1.0f : 0.0f; + npc_out[FC_NPC_TELE_MAGIC] = (tele == ATTACK_MAGIC) ? 1.0f : 0.0f; + npc_out[FC_NPC_ATK_TIMER] = (n->attack_speed > 0) ? (float)n->attack_timer / (float)n->attack_speed : 0.0f; + npc_out[FC_NPC_LOS] = has_los; + npc_out[FC_NPC_PRAYER_DRAIN_DEALT] = normalize_npc_prayer_drain(n); + npc_out[FC_NPC_HEAL_RECEIVED] = (n->max_hp > 0) + ? clamp01((float)n->healing_received_this_tick / (float)n->max_hp) + : 0.0f; + npc_out[FC_NPC_HEAL_GIVEN] = (n->heal_amount > 0) + ? clamp01((float)n->healing_given_this_tick / (float)n->heal_amount) + : 0.0f; + npc_out[FC_NPC_HEALED_BY_MEJKOT] = + n->healed_by_mejkot_this_tick ? 1.0f : 0.0f; + npc_out[FC_NPC_HEALED_BY_HURKOT] = + n->healed_by_hurkot_this_tick ? 1.0f : 0.0f; + npc_out[FC_NPC_HEALED_SELF] = n->healed_self_this_tick ? 1.0f : 0.0f; + npc_out[FC_NPC_TARGETS_PLAYER] = + (n->npc_type != NPC_YT_HURKOT || n->healer_distracted) ? 1.0f : 0.0f; + npc_out[FC_NPC_HEAL_COOLDOWN] = normalize_npc_heal_cooldown(n); + npc_out[FC_NPC_KILL_REWARD_ELIGIBLE] = + npc_kill_reward_eligible(n) ? 1.0f : 0.0f; + int type_offset = npc_type_obs_offset(n->npc_type); + if (type_offset >= 0) { + npc_out[type_offset] = 1.0f; + } + + /* Pending attack from this NPC — scan player's pending hits */ + npc_out[FC_NPC_PENDING_STYLE] = 0.0f; + npc_out[FC_NPC_PENDING_TICKS] = 0.0f; + npc_out[FC_NPC_PENDING_PRAYER_WINDOW] = 0.0f; + npc_out[FC_NPC_PENDING_PRAYER_DEADLINE] = 0.0f; + for (int hi = 0; hi < p->num_pending_hits; hi++) { + const FcPendingHit* ph = &p->pending_hits[hi]; + if (ph->active && ph->source_npc_idx == active_indices[slot]) { + npc_out[FC_NPC_PENDING_STYLE] = (float)ph->attack_style / 3.0f; + npc_out[FC_NPC_PENDING_TICKS] = (float)ph->ticks_remaining / 10.0f; + if (pending_hit_prayer_actionable(state, ph)) { + npc_out[FC_NPC_PENDING_PRAYER_WINDOW] = 1.0f; + npc_out[FC_NPC_PENDING_PRAYER_DEADLINE] = + pending_hit_prayer_deadline_urgency(state, ph); + } + break; /* report first pending hit from this NPC */ + } + } + } + /* Remaining NPC slots already zeroed by memset */ + + /* Player target: which visible NPC slot is the current attack target */ + if (p->attack_target_idx >= 0) { + for (int s = 0; s < visible; s++) { + if (active_indices[s] == p->attack_target_idx) { + player[FC_OBS_PLAYER_TARGET] = (float)(s + 1) / 8.0f; + break; + } + } + } + + /* Wave/meta features */ + float* meta = out + FC_OBS_META_START; + meta[FC_OBS_META_WAVE] = (float)state->current_wave / (float)FC_NUM_WAVES; + meta[FC_OBS_META_ROTATION] = (float)state->rotation_id / (float)FC_NUM_ROTATIONS; + meta[FC_OBS_META_REMAINING] = (float)state->npcs_remaining / (float)FC_MAX_NPCS; + meta[FC_OBS_META_PRAY_DRAIN] = normalize_prayer_drain_counter(p); + meta[FC_OBS_META_IN_MEL_3T] = normalize_incoming_count(incoming_counts[2][0]); + meta[FC_OBS_META_IN_RNG_3T] = normalize_incoming_count(incoming_counts[2][1]); + meta[FC_OBS_META_IN_MAG_3T] = normalize_incoming_count(incoming_counts[2][2]); + meta[FC_OBS_META_DMG_T_TICK] = (p->max_hp > 0) ? (float)state->damage_taken_this_tick / (float)p->max_hp : 0.0f; + meta[FC_OBS_META_WAVE_CLR] = (float)state->wave_just_cleared; + meta[FC_OBS_META_CAVE_PROG] = clamp01(state->progress_cave_progress); + meta[FC_OBS_META_WAVE_PROG] = clamp01(state->progress_current_wave_progress); + meta[FC_OBS_META_WORK_REM] = (state->progress_required_work_start > 0.0f) + ? clamp01(state->progress_required_work_remaining / + state->progress_required_work_start) + : 0.0f; + meta[FC_OBS_META_NO_PROG] = clamp01((float)state->progress_ticks_since_positive / 2400.0f); + meta[FC_OBS_META_NPC_HEALING] = (state->progress_required_work_start > 0.0f) + ? clamp01((float)state->npc_heal_amount_this_tick / + state->progress_required_work_start) + : 0.0f; + meta[FC_OBS_META_REWARDABLE_NPC_KILL] = + rewardable_npc_kills_this_tick(state) > 0 ? 1.0f : 0.0f; + + /* Reward features (at offset FC_REWARD_START) — written by fc_write_reward_features */ + fc_write_reward_features(state, out + FC_REWARD_START); +} + +void fc_apply_obs_ablation(float* out, + int ablate_npc_distance, + int ablate_incoming_aggregates, + int ablate_npc_valid) { + if (ablate_npc_distance) { + for (int s = 0; s < FC_OBS_NPC_SLOTS; s++) { + out[FC_OBS_NPC_START + s * FC_OBS_NPC_STRIDE + FC_NPC_DISTANCE] = 0.0f; + } + } + if (ablate_incoming_aggregates) { + out[FC_OBS_PLAYER_START + FC_OBS_PLAYER_IN_MEL_1T] = 0.0f; + out[FC_OBS_PLAYER_START + FC_OBS_PLAYER_IN_RNG_1T] = 0.0f; + out[FC_OBS_PLAYER_START + FC_OBS_PLAYER_IN_MAG_1T] = 0.0f; + out[FC_OBS_PLAYER_START + FC_OBS_PLAYER_IN_MEL_2T] = 0.0f; + out[FC_OBS_PLAYER_START + FC_OBS_PLAYER_IN_RNG_2T] = 0.0f; + out[FC_OBS_PLAYER_START + FC_OBS_PLAYER_IN_MAG_2T] = 0.0f; + out[FC_OBS_META_START + FC_OBS_META_IN_MEL_3T] = 0.0f; + out[FC_OBS_META_START + FC_OBS_META_IN_RNG_3T] = 0.0f; + out[FC_OBS_META_START + FC_OBS_META_IN_MAG_3T] = 0.0f; + } + if (ablate_npc_valid) { + for (int s = 0; s < FC_OBS_NPC_SLOTS; s++) { + out[FC_OBS_NPC_START + s * FC_OBS_NPC_STRIDE + FC_NPC_VALID] = 0.0f; + } + } +} + +void fc_write_reward_features(const FcState* state, float* out) { + memset(out, 0, sizeof(float) * FC_REWARD_FEATURES); + + out[FC_RWD_DAMAGE_DEALT] = (float)state->damage_dealt_this_tick / 1000.0f; + out[FC_RWD_DAMAGE_TAKEN] = (state->player.max_hp > 0) ? + (float)state->damage_taken_this_tick / (float)state->player.max_hp : 0.0f; + out[FC_RWD_NPC_KILL] = (float)rewardable_npc_kills_this_tick(state); + out[FC_RWD_WAVE_CLEAR] = (float)state->wave_just_cleared; + out[FC_RWD_JAD_DAMAGE] = (float)state->jad_damage_this_tick / 1000.0f; + out[FC_RWD_JAD_KILL] = (float)state->jad_killed; + out[FC_RWD_PLAYER_DEATH] = (state->terminal == TERMINAL_PLAYER_DEATH) ? 1.0f : 0.0f; + out[FC_RWD_CAVE_COMPLETE] = (state->terminal == TERMINAL_CAVE_COMPLETE) ? 1.0f : 0.0f; + out[FC_RWD_FOOD_USED] = (float)state->food_used_this_tick; + out[FC_RWD_PRAYER_POT_USED] = (float)state->prayer_potion_used_this_tick; + out[FC_RWD_CORRECT_JAD_PRAY] = (float)state->correct_jad_prayer; + out[FC_RWD_WRONG_JAD_PRAY] = (float)state->wrong_jad_prayer; + out[FC_RWD_INVALID_ACTION] = (float)state->invalid_action_this_tick; + out[FC_RWD_MOVEMENT] = (float)state->movement_this_tick; + out[FC_RWD_IDLE] = (float)state->idle_this_tick; + out[FC_RWD_TICK_PENALTY] = 1.0f; /* always fires */ + out[FC_RWD_CORRECT_DANGER_PRAY] = (float)state->correct_danger_prayer; + out[FC_RWD_WRONG_DANGER_PRAY] = (float)state->wrong_danger_prayer; + out[FC_RWD_ATTACK_ATTEMPT] = (float)state->attack_attempt_this_tick; + out[FC_RWD_PRAYER_LOST] = (float)state->prayer_lost_this_tick / 10.0f; +} + +void fc_action_invalid_classes(const FcState* state, + const int actions[FC_NUM_ACTION_HEADS], + int out_classes[FC_INVALID_ACTION_CLASS_COUNT]) { + /* Keep this aligned with the Puffer-facing policy mask surface: + * move, attack, and prayer only. Consumable and path-target heads remain + * canonical core actions, but are not emitted by the no-supplies policy. */ + out_classes[FC_INVALID_ACTION_MOVE] = !move_action_valid(state, actions[0]); + out_classes[FC_INVALID_ACTION_ATTACK] = !attack_action_valid(state, actions[1]); + out_classes[FC_INVALID_ACTION_PRAYER] = !prayer_action_valid(actions[2]); +} + +void fc_write_mask(const FcState* state, float* out) { + /* Set all to valid, then mask invalid */ + for (int i = 0; i < FC_ACTION_MASK_SIZE; i++) { + out[i] = 1.0f; + } + + /* MOVE: idle always valid. Walk/run directions masked if destination not walkable */ + for (int m = 1; m < FC_MOVE_DIM; m++) { + if (!move_action_valid(state, m)) { + out[FC_MASK_MOVE_START + m] = 0.0f; + } + } + + /* ATTACK: slot 0 (none) always valid. Slots 1-8 masked if no NPC in that slot */ + for (int attack = FC_ATTACK_NONE + 1; attack < FC_ATTACK_DIM; attack++) { + if (!attack_action_valid(state, attack)) { + out[FC_MASK_ATTACK_START + attack] = 0.0f; + } + } + + /* PRAYER: leave fully unmasked. + * Prayer toggles may be legal even when they are redundant or no-op, and + * the policy should learn those costs from the environment rather than + * having them hidden by the mask. */ + + /* EAT */ + if (!fc_eat_action_valid(state, FC_EAT_SHARK)) { + out[FC_MASK_EAT_START + FC_EAT_SHARK] = 0.0f; + } + if (!fc_eat_action_valid(state, FC_EAT_COMBO)) { + out[FC_MASK_EAT_START + FC_EAT_COMBO] = 0.0f; + } + + /* DRINK */ + if (!fc_drink_action_valid(state, FC_DRINK_PRAYER_POT)) { + out[FC_MASK_DRINK_START + FC_DRINK_PRAYER_POT] = 0.0f; + } +} + +int fc_is_terminal(const FcState* state) { + return state->terminal != TERMINAL_NONE; +} + +/* ======================================================================== */ +/* Render entities */ +/* ======================================================================== */ + +void fc_fill_render_entities(const FcState* state, FcRenderEntity* entities, int* count) { + int idx = 0; + + /* Entity 0: player */ + FcRenderEntity* pe = &entities[idx++]; + memset(pe, 0, sizeof(FcRenderEntity)); + pe->entity_type = ENTITY_PLAYER; + pe->x = state->player.x; + pe->y = state->player.y; + pe->size = 1; + pe->current_hp = state->player.current_hp; + pe->max_hp = state->player.max_hp; + pe->prayer = state->player.prayer; + pe->damage_taken_this_tick = state->player.damage_taken_this_tick; + pe->hit_landed_this_tick = state->player.hit_landed_this_tick; + + /* Active NPCs + NPCs that just died this tick (for death hitsplat visibility) */ + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc* n = &state->npcs[i]; + if (!n->active && !n->died_this_tick) continue; + + FcRenderEntity* ne = &entities[idx++]; + memset(ne, 0, sizeof(FcRenderEntity)); + ne->entity_type = ENTITY_NPC; + ne->npc_type = n->npc_type; + ne->x = n->x; + ne->y = n->y; + ne->size = n->size; + ne->current_hp = n->current_hp; + ne->max_hp = n->max_hp; + ne->attack_style = n->attack_style; + ne->is_dead = n->is_dead; + ne->damage_taken_this_tick = n->damage_taken_this_tick; + ne->healing_received_this_tick = n->healing_received_this_tick; + ne->died_this_tick = n->died_this_tick; + ne->npc_slot = i; + } + + *count = idx; +} + +void fc_fill_render_events(const FcState* state, FcRenderEvents* events) { + if (!state || !events) return; + *events = state->render_events; +} + + +/* Tick */ +#include +/* + * fc_tick.c — Main tick loop for Fight Caves simulation. + * + * Processing order (adapted from PufferLib PvP two-phase execution): + * + * 1. Clear per-tick event flags + * 2. Process player actions: + * a. Prayer toggle (instant) + * b. Eat food / drink potion (if timer ready) + * c. Attack initiation from the pre-movement tile + * d. Movement (route or directional head), unless an attack fired + * e. Run-energy drain or restoration from actual movement + * 3. Decrement player timers (attack, food, potion, combo) + * 4. Prayer drain (only if prayer stayed active across the tick boundary) + * 5. NPC AI tick (movement + attack) for all active NPCs + * 6. Resolve pending hits (NPC → player, player → NPC) + * 7. Check terminal conditions + * 8. Increment tick and lock prayer snapshots due at the new boundary + */ + +/* ======================================================================== */ +/* Clear per-tick flags */ +/* ======================================================================== */ + +static void clear_per_tick_flags(FcState* state) { + state->render_events = (FcRenderEvents){0}; + state->render_events.player_attack_target_npc_slot = -1; + state->render_events.player_move_start_x = state->player.x; + state->render_events.player_move_start_y = state->player.y; + state->damage_dealt_this_tick = 0; + state->hits_landed_this_tick = 0; + state->damage_taken_this_tick = 0; + state->prayer_lost_this_tick = 0; + state->overhead_prayer_lost_this_tick = 0; + state->tz_kih_prayer_drain_this_tick = 0; + state->npcs_killed_this_tick = 0; + state->respawned_jad_healers_killed_this_tick = 0; + state->wave_just_cleared = 0; + state->jad_damage_this_tick = 0; + state->jad_killed = 0; + state->correct_jad_prayer = 0; + state->wrong_jad_prayer = 0; + state->correct_danger_prayer = 0; + state->wrong_danger_prayer = 0; + state->attack_attempt_this_tick = 0; + state->invalid_action_this_tick = 0; + for (int i = 0; i < FC_INVALID_ACTION_CLASS_COUNT; i++) { + state->invalid_action_class_this_tick[i] = 0; + } + state->movement_this_tick = 0; + state->idle_this_tick = 0; + state->food_used_this_tick = 0; + state->prayer_potion_used_this_tick = 0; + state->jad_heal_procs_this_tick = 0; + state->npc_heal_procs_this_tick = 0; + state->npc_heal_amount_this_tick = 0; + state->mejkot_heal_amount_this_tick = 0; + state->jad_heal_amount_this_tick = 0; + + FcPlayer* p = &state->player; + p->damage_taken_this_tick = 0; + p->hit_style_this_tick = 0; + p->hit_source_npc_type = 0; + p->hit_locked_prayer_this_tick = 0; + p->hit_blocked_this_tick = 0; + p->hit_landed_this_tick = 0; + p->food_eaten_this_tick = 0; + p->potion_used_this_tick = 0; + p->prayer_changed_this_tick = 0; + + for (int i = 0; i < FC_MAX_NPCS; i++) { + state->npcs[i].damage_taken_this_tick = 0; + state->npcs[i].prayer_drain_dealt_this_tick = 0; + state->npcs[i].healing_received_this_tick = 0; + state->npcs[i].healing_given_this_tick = 0; + state->npcs[i].healed_by_mejkot_this_tick = 0; + state->npcs[i].healed_by_hurkot_this_tick = 0; + state->npcs[i].healed_self_this_tick = 0; + state->npcs[i].died_this_tick = 0; + } +} + +static void record_player_move_waypoint(FcState* state) { + FcRenderEvents* events = &state->render_events; + int index = events->player_move_waypoint_count; + if (index >= FC_MAX_RENDER_MOVE_WAYPOINTS) return; + events->player_move_waypoint_x[index] = state->player.x; + events->player_move_waypoint_y[index] = state->player.y; + events->player_move_waypoint_count++; +} + +static void set_player_facing_from_delta(FcPlayer* player, float dx, float dy) { + if (dx == 0.0f && dy == 0.0f) return; + player->facing_angle = atan2f(dx, -dy) * (180.0f / 3.14159f); +} + +/* ======================================================================== */ +/* Resolve NPC visible-slot index to NPC array index */ +/* ======================================================================== */ + +/* Same ordering as observation writer — must be identical for consistency */ +static int npc_slot_to_index(const FcState* state, int slot) { + int visible_indices[FC_VISIBLE_NPCS]; + int visible = fc_visible_npc_indices(state, visible_indices); + if (slot < 0 || slot >= visible) return -1; + return visible_indices[slot]; +} + +/* ======================================================================== */ +/* Process player actions */ +/* ======================================================================== */ + +static void record_player_action_selection( + FcState* state, const int actions[FC_NUM_ACTION_HEADS]) { + int act_move = actions[0]; + int act_attack = actions[1]; + int act_prayer = actions[2]; + int invalid_classes[FC_INVALID_ACTION_CLASS_COUNT]; + + if (state->npcs_remaining > 0) { + if (act_move == FC_MOVE_IDLE) { + state->ep_action_move_idle_ticks++; + } else if (act_move >= FC_MOVE_WALK_N && act_move < FC_MOVE_RUN_N) { + state->ep_action_move_walk_ticks++; + } else if (act_move >= FC_MOVE_RUN_N && act_move < FC_MOVE_DIM) { + state->ep_action_move_run_ticks++; + } + + if (act_attack == FC_ATTACK_NONE) { + state->ep_action_attack_none_ticks++; + } else { + state->ep_action_attack_target_ticks++; + } + + if (act_prayer == 0) { + state->ep_action_prayer_noop_ticks++; + } else { + state->ep_action_prayer_cmd_ticks++; + } + } + + fc_action_invalid_classes(state, actions, invalid_classes); + for (int i = 0; i < FC_INVALID_ACTION_CLASS_COUNT; i++) { + state->invalid_action_class_this_tick[i] = invalid_classes[i]; + if (invalid_classes[i]) { + state->invalid_action_this_tick = 1; + state->ep_invalid_action_classes[i]++; + } + } +} + +static void apply_player_prayer_action( + FcState* state, int action, FcPrayerTransition* transition) { + FcPlayer* player = &state->player; + + *transition = fc_prayer_apply_action(player, action); + state->render_events.prayer_prior = transition->prior_prayer; + state->render_events.prayer_final = transition->actual_final_prayer; + state->render_events.prayer_off_performed = transition->off_performed; + state->render_events.prayer_on_succeeded = transition->on_succeeded; + state->render_events.prayer_flick_performed = + transition->explicit_off_then_on && + transition->off_performed && + transition->on_succeeded; + if (transition->final_state_changed || + (transition->explicit_off_then_on && + transition->off_performed && transition->on_succeeded)) { + player->prayer_changed_this_tick = 1; + } +} + +static void apply_player_supplies(FcState* state, int eat_action, + int drink_action) { + FcPlayer* player = &state->player; + + if (eat_action != FC_EAT_NONE && + fc_eat_action_valid(state, eat_action)) { + int heal = eat_action == FC_EAT_SHARK ? 200 : 180; + int* cooldown_timer = eat_action == FC_EAT_SHARK + ? &player->food_timer : &player->combo_timer; + int cooldown = eat_action == FC_EAT_SHARK + ? FC_FOOD_COOLDOWN_TICKS : FC_COMBO_EAT_TICKS; + int pre_eat_hp = player->current_hp; + state->ep_food_pre_hp_sum += pre_eat_hp; + int hp_missing = player->max_hp - player->current_hp; + if (heal > hp_missing) state->ep_food_overhealed++; + state->ep_food_eaten++; + player->total_food_eaten++; + player->current_hp += heal; + if (player->current_hp > player->max_hp) { + player->current_hp = player->max_hp; + } + player->sharks_remaining--; + *cooldown_timer = cooldown; + player->food_eaten_this_tick = 1; + state->food_used_this_tick = 1; + } + + if (drink_action == FC_DRINK_PRAYER_POT && + fc_drink_action_valid(state, drink_action)) { + int pre_drink_prayer = player->current_prayer; + state->ep_pot_pre_prayer_sum += pre_drink_prayer; + int prayer_missing = player->max_prayer - player->current_prayer; + state->ep_pots_used++; + if (player->current_prayer > player->max_prayer / 5) { + state->ep_pots_wasted++; + } + player->total_potions_used++; + int restore = fc_prayer_potion_restore(FC_PLAYER_PRAYER_LVL); + if (restore > prayer_missing) state->ep_pots_overrestored++; + player->current_prayer += restore; + if (player->current_prayer > player->max_prayer) { + player->current_prayer = player->max_prayer; + } + player->prayer_doses_remaining--; + player->potion_timer = FC_POTION_COOLDOWN_TICKS; + player->potion_used_this_tick = 1; + state->prayer_potion_used_this_tick = 1; + } +} + +static void prepare_player_interaction(FcState* state, int explicit_move, + int explicit_attack, + int requested_attack_idx) { + FcPlayer* player = &state->player; + + /* Explicit movement starts a fresh movement intent before auto-attack can + * consume a stale route or combat approach. */ + if (explicit_move) { + player->route_len = 0; + player->route_idx = 0; + player->approach_target = 0; + player->approach_target_x = -1; + player->approach_target_y = -1; + player->approach_target_size = 0; + if (!explicit_attack) { + player->attack_target_idx = -1; + } + } + + /* Target selection precedes movement so movement cannot rebind a slot or + * make the selected attack valid retroactively. */ + if (explicit_attack && requested_attack_idx >= 0 && + state->npcs[requested_attack_idx].active && + !state->npcs[requested_attack_idx].is_dead) { + if (player->attack_target_idx != requested_attack_idx) { + player->approach_target_x = -1; + player->approach_target_y = -1; + player->approach_target_size = 0; + } + player->attack_target_idx = requested_attack_idx; + player->approach_target = explicit_move ? 0 : 1; + } +} + +static void launch_player_attack(FcState* state, FcNpc* target, int distance) { + FcPlayer* player = &state->player; + int att_roll = fc_player_ranged_attack_roll(player, target); + const FcNpcStats* target_stats = fc_npc_get_stats(target->npc_type); + int def_roll = fc_npc_def_roll(target_stats->def_level, + target_stats->ranged_def_bonus); + float chance = fc_hit_chance(att_roll, def_roll); + int hit = fc_rng_float(state) < chance ? 1 : 0; + int final_max_hit_hp = fc_player_ranged_final_max_hit_hp(player, target); + int damage = hit + ? fc_roll_player_damage_tenths(state, final_max_hit_hp) : 0; + int delay = fc_ranged_hit_delay(distance); + + fc_queue_pending_hit(target->pending_hits, &target->num_pending_hits, + FC_MAX_PENDING_HITS, damage, delay, + ATTACK_RANGED, -1, 0); + state->attack_attempt_this_tick = 1; + state->render_events.player_attack_fired = 1; + state->render_events.player_attack_source_x = player->x; + state->render_events.player_attack_source_y = player->y; + state->render_events.player_attack_target_npc_slot = + player->attack_target_idx; + state->render_events.player_attack_target_x = target->x; + state->render_events.player_attack_target_y = target->y; + state->render_events.player_attack_target_size = target->size; + state->render_events.player_attack_hit_delay_ticks = delay; + if (target->npc_type > NPC_NONE && target->npc_type < NPC_TYPE_COUNT) { + state->ep_attack_cycles_to_npc_type[target->npc_type]++; + } + player->attack_timer = player->weapon_speed; + if (player->weapon_uses_ammo && player->ammo_count > 0) { + player->ammo_count--; + } + player->hit_landed_this_tick = 1; +} + +static void record_player_target_held(FcState* state, const FcNpc* target) { + if (target->npc_type > NPC_NONE && target->npc_type < NPC_TYPE_COUNT) { + state->ep_target_ticks_by_npc_type[target->npc_type]++; + } + state->ep_target_held_ticks++; +} + +static int process_player_target(FcState* state, + int explicit_directional_move, + int explicit_tile_move) { + FcPlayer* player = &state->player; + int metrics_recorded = 0; + + /* Like Void CombatMovement: approach until the current target is in range, + * then attack on cooldown and remain stationary for this tick. */ + if (player->attack_target_idx < 0 || + (player->weapon_uses_ammo && player->ammo_count <= 0)) { + return metrics_recorded; + } + + FcNpc* target = &state->npcs[player->attack_target_idx]; + if (!target->active || target->is_dead) { + player->attack_target_idx = -1; + player->approach_target = 0; + player->approach_target_x = -1; + player->approach_target_y = -1; + player->approach_target_size = 0; + return metrics_recorded; + } + + int dist = fc_distance_to_npc(player->x, player->y, target); + int weapon_range = player->weapon_range; + int has_los = fc_has_los_between_areas( + player->x, player->y, 1, + target->x, target->y, target->size, state->los_flags); + int target_can_fire = dist > 0 && dist <= weapon_range && has_los; + int target_ready = player->attack_timer <= 0; + + record_player_target_held(state, target); + metrics_recorded = 1; + if (target_can_fire) { + state->ep_target_in_range_los_ticks++; + if (!target_ready) { + state->ep_attack_cooldown_wait_ticks++; + } + } else { + state->ep_target_out_of_range_or_los_ticks++; + } + + int route_endpoint_can_fire = 0; + int target_moved = + player->approach_target_x != target->x || + player->approach_target_y != target->y || + player->approach_target_size != target->size; + if (player->route_idx < player->route_len) { + int endpoint = player->route_len - 1; + int rx = player->route_x[endpoint]; + int ry = player->route_y[endpoint]; + int route_dist = fc_distance_between_areas( + rx, ry, 1, target->x, target->y, target->size); + route_endpoint_can_fire = route_dist > 0 && + route_dist <= weapon_range && + fc_has_los_between_areas( + rx, ry, 1, target->x, target->y, target->size, + state->los_flags); + } + + if (!target_can_fire && player->approach_target && + (target_moved || player->route_idx >= player->route_len || + !route_endpoint_can_fire) && + !explicit_directional_move && !explicit_tile_move) { + /* Rebuild against the target's current rectangle whenever the queued + * endpoint is no longer a valid firing tile. */ + player->route_len = fc_pathfind_attack_position( + player->x, player->y, target->x, target->y, target->size, + weapon_range, state->walkable, state->movement_flags, + state->los_flags, player->route_x, player->route_y, FC_MAX_ROUTE); + player->route_idx = 0; + player->approach_target_x = target->x; + player->approach_target_y = target->y; + player->approach_target_size = target->size; + } + + float target_x = (float)target->x + (float)target->size * 0.5f; + float target_y = (float)target->y + (float)target->size * 0.5f; + set_player_facing_from_delta( + player, target_x - ((float)player->x + 0.5f), + target_y - ((float)player->y + 0.5f)); + + if (target_can_fire && target_ready) { + launch_player_attack(state, target, dist); + } + + if (target_can_fire && target_ready && + !state->attack_attempt_this_tick) { + state->ep_ready_but_no_attack_ticks++; + } + return metrics_recorded; +} + +static int process_player_movement(FcState* state, int move_action, + int target_x_action, int target_y_action, + int explicit_move, int explicit_attack) { + FcPlayer* player = &state->player; + int moved_steps = 0; + + /* If no attack fired, explicit movement replaces the combat interaction. + * A fired attack wins the conflict and keeps its target for this tick. */ + if (explicit_move && !state->attack_attempt_this_tick) { + player->approach_target = 0; + if (explicit_attack) { + player->attack_target_idx = -1; + } + } + + if (!state->attack_attempt_this_tick && + target_x_action > 0 && target_y_action > 0) { + int target_x = target_x_action - 1; + int target_y = target_y_action - 1; + if (target_x < FC_ARENA_WIDTH && target_y < FC_ARENA_HEIGHT) { + player->route_len = fc_pathfind_bfs_move_near( + player->x, player->y, target_x, target_y, + state->walkable, state->movement_flags, + player->route_x, player->route_y, FC_MAX_ROUTE); + player->route_idx = 0; + player->attack_target_idx = -1; + player->approach_target = 0; + player->approach_target_x = -1; + player->approach_target_y = -1; + player->approach_target_size = 0; + } + } + + /* Routes take priority over the directional action. Attacking suppresses + * both forms of movement for this tick only. */ + if (state->attack_attempt_this_tick) { + player->route_len = 0; + player->route_idx = 0; + } else if (player->route_idx < player->route_len) { + int steps = player->is_running && player->run_energy > 0 ? 2 : 1; + for (int i = 0; + i < steps && player->route_idx < player->route_len; i++) { + int next_x = player->route_x[player->route_idx]; + int next_y = player->route_y[player->route_idx]; + int dx = next_x - player->x; + int dy = next_y - player->y; + if (fc_footprint_step_walkable( + player->x, player->y, dx, dy, 1, + state->walkable, state->movement_flags)) { + set_player_facing_from_delta(player, (float)dx, (float)dy); + player->x = next_x; + player->y = next_y; + state->movement_this_tick = 1; + record_player_move_waypoint(state); + moved_steps++; + } else { + player->route_len = player->route_idx; + break; + } + player->route_idx++; + } + } else if (move_action == FC_MOVE_IDLE) { + state->idle_this_tick = 1; + } else if (move_action >= FC_MOVE_WALK_N && + move_action <= FC_MOVE_RUN_NW) { + int dx = FC_MOVE_DX[move_action]; + int dy = FC_MOVE_DY[move_action]; + int wants_run = move_action >= FC_MOVE_RUN_N; + int max_steps = wants_run + ? (fc_player_can_run(player) ? 2 : 0) + : 1; + int old_x = player->x; + int old_y = player->y; + int step_x[FC_MAX_RENDER_MOVE_WAYPOINTS]; + int step_y[FC_MAX_RENDER_MOVE_WAYPOINTS]; + int moved = fc_move_toward_traced( + &player->x, &player->y, dx, dy, max_steps, + state->walkable, state->movement_flags, + step_x, step_y, FC_MAX_RENDER_MOVE_WAYPOINTS); + if (moved > 0) { + int recorded = moved; + if (recorded > FC_MAX_RENDER_MOVE_WAYPOINTS) { + recorded = FC_MAX_RENDER_MOVE_WAYPOINTS; + } + state->render_events.player_move_waypoint_count = recorded; + for (int i = 0; i < recorded; i++) { + state->render_events.player_move_waypoint_x[i] = step_x[i]; + state->render_events.player_move_waypoint_y[i] = step_y[i]; + } + set_player_facing_from_delta( + player, (float)(player->x - old_x), + (float)(player->y - old_y)); + state->movement_this_tick = 1; + player->is_running = moved >= 2 ? 1 : 0; + moved_steps = moved; + } + } + return moved_steps; +} + +static void update_player_run_energy(FcPlayer* player, int moved_steps) { + if (moved_steps >= 2) { + player->run_energy -= FC_RUN_ENERGY_DRAIN; + if (player->run_energy <= 0) { + player->run_energy = 0; + player->is_running = 0; + } + return; + } + + player->run_energy += FC_RUN_ENERGY_RESTORE; + if (player->run_energy > FC_RUN_ENERGY_MAX) { + player->run_energy = FC_RUN_ENERGY_MAX; + } +} + +static void record_player_action_outcome(FcState* state, int was_attack_ready, + int target_metrics_recorded) { + FcPlayer* player = &state->player; + + if (state->npcs_remaining > 0 && !target_metrics_recorded) { + if (player->attack_target_idx >= 0) { + FcNpc* target = &state->npcs[player->attack_target_idx]; + if (target->active && !target->is_dead) { + record_player_target_held(state, target); + } else { + state->ep_no_target_ticks++; + } + } else { + state->ep_no_target_ticks++; + } + } + + if (was_attack_ready) { + state->ep_attack_ready_ticks++; + if (state->attack_attempt_this_tick) { + state->ep_attack_attempt_ticks++; + } + } +} + +static void process_player_actions(FcState* state, + const int actions[FC_NUM_ACTION_HEADS], + FcPrayerTransition* prayer_transition) { + FcPlayer* p = &state->player; + int was_attack_ready = (p->attack_timer <= 0 && state->npcs_remaining > 0); + + int act_move = actions[0]; + int act_attack = actions[1]; + int act_prayer = actions[2]; + int act_eat = actions[3]; + int act_drink = actions[4]; + int act_target_x = actions[5]; + int act_target_y = actions[6]; + int explicit_directional_move = (act_move != FC_MOVE_IDLE); + int explicit_tile_move = (act_target_x > 0 && act_target_y > 0); + int explicit_move = explicit_directional_move || explicit_tile_move; + int explicit_attack = (act_attack > FC_ATTACK_NONE); + int requested_attack_idx = -1; + int target_metrics_recorded = 0; + record_player_action_selection(state, actions); + + /* Resolve attack slots against the pre-action NPC slot ordering. The action + * was chosen from the previous observation, so movement later in this tick + * must not rebind slot N to a different NPC identity. */ + if (explicit_attack) { + requested_attack_idx = npc_slot_to_index(state, act_attack - 1); + } + + /* Prayer remains instant and precedes supplies. */ + apply_player_prayer_action(state, act_prayer, prayer_transition); + apply_player_supplies(state, act_eat, act_drink); + + prepare_player_interaction(state, explicit_move, explicit_attack, + requested_attack_idx); + + target_metrics_recorded = process_player_target( + state, explicit_directional_move, explicit_tile_move); + + int moved_steps = process_player_movement( + state, act_move, act_target_x, act_target_y, + explicit_move, explicit_attack); + update_player_run_energy(p, moved_steps); + record_player_action_outcome(state, was_attack_ready, + target_metrics_recorded); +} + +/* ======================================================================== */ +/* Decrement player timers */ +/* ======================================================================== */ + +static void decrement_player_timers(FcPlayer* p) { + if (p->attack_timer > 0) p->attack_timer--; + if (p->food_timer > 0) p->food_timer--; + if (p->potion_timer > 0) p->potion_timer--; + if (p->combo_timer > 0) p->combo_timer--; +} + +/* ======================================================================== */ +/* Check terminal conditions */ +/* ======================================================================== */ + +/* ======================================================================== */ +/* Jad healer auto-spawn */ +/* ======================================================================== */ + +/* + * Jad healer spawn (from TzhaarFightCave.kt npcLevelChanged handler): + * Trigger: Jad HP drops below 150 HP. + * Spawns up to 4 Yt-HurKot in the five Fight Cave spawn regions other than + * north-east (fills missing slots: 4 - currently_alive). + * Respawn: Only after healers restore Jad to full HP and he crosses the + * threshold again. Crossing back above the threshold is not enough to re-arm. + */ +static void check_jad_healers(FcState* state) { + if (state->current_wave != FC_NUM_WAVES) return; /* only on wave 63 */ + + /* Find Jad */ + for (int i = 0; i < FC_MAX_NPCS; i++) { + FcNpc* jad = &state->npcs[i]; + if (jad->npc_type != NPC_TZTOK_JAD || !jad->active || jad->is_dead) continue; + + /* Re-arm respawns only after Jad has been healed all the way to full. */ + if (jad->current_hp >= jad->max_hp) { + state->jad_healers_spawned = 0; + return; + } + + if (jad->current_hp >= FC_JAD_HEALER_THRESHOLD_HP_TENTHS) return; + + /* Below threshold — spawn healers if not already spawned this cycle */ + if (state->jad_healers_spawned) return; + + /* Count currently alive healers */ + int alive_healers = 0; + for (int h = 0; h < FC_MAX_NPCS; h++) { + if (state->npcs[h].active && !state->npcs[h].is_dead && + state->npcs[h].npc_type == NPC_YT_HURKOT) { + alive_healers++; + } + } + + /* Spawn up to 4 total (fill missing slots) */ + int to_spawn = FC_JAD_NUM_HEALERS - alive_healers; + int spawn_dirs[5] = { + SPAWN_NORTH_WEST, + SPAWN_SOUTH_WEST, + SPAWN_SOUTH, + SPAWN_SOUTH_EAST, + SPAWN_CENTER, + }; + for (int i = 4; i > 0; i--) { + int j = fc_rng_int(state, i + 1); + int tmp = spawn_dirs[i]; + spawn_dirs[i] = spawn_dirs[j]; + spawn_dirs[j] = tmp; + } + const FcNpcStats* healer_stats = fc_npc_get_stats(NPC_YT_HURKOT); + int is_respawn_generation = state->jad_healer_spawn_generations > 0; + int spawned = 0; + for (int h = 0; h < to_spawn; h++) { + int hx, hy; + fc_spawn_position(spawn_dirs[h], &hx, &hy); + + if (!fc_spawn_find_available_footprint( + state, hx, hy, healer_stats->size, FC_ARENA_WIDTH - 1, + &hx, &hy)) { + continue; + } + + int slot = fc_spawn_npc_first_free(state, NPC_YT_HURKOT, hx, hy); + if (slot < 0) break; + state->npcs[slot].is_respawned_jad_healer = + is_respawn_generation; + state->npcs_remaining++; + spawned++; + } + if (spawned > 0) { + state->jad_healers_spawned = 1; + state->jad_healer_spawn_generations++; + } + return; + } +} + +/* ======================================================================== */ +/* Check terminal conditions */ +/* ======================================================================== */ + +static void check_terminal(FcState* state) { + if (state->terminal != TERMINAL_NONE) return; + + /* Player death */ + if (state->player.current_hp <= 0) { + state->terminal = TERMINAL_PLAYER_DEATH; + return; + } + + /* Wave advancement (handles wave-clear and cave-complete) */ + fc_wave_check_advance(state); + + /* Jad healer spawn check */ + check_jad_healers(state); + + /* Tick cap */ + if (state->tick >= FC_MAX_EPISODE_TICKS) { + state->terminal = TERMINAL_TICK_CAP; + } +} + +static void lock_pending_prayers_at_boundary(FcState* state) { + FcPlayer* p = &state->player; + for (int i = 0; i < p->num_pending_hits; i++) { + FcPendingHit* hit = &p->pending_hits[i]; + if (!hit->active || hit->prayer_snapshot >= 0 || + hit->prayer_lock_tick < 0 || + state->tick < hit->prayer_lock_tick) { + continue; + } + hit->prayer_snapshot = p->prayer; + } +} + +/* ======================================================================== */ +/* Main tick entry point */ +/* ======================================================================== */ + +void fc_tick(FcState* state, const int actions[FC_NUM_ACTION_HEADS]) { + state->player.prayer_at_tick_start = state->player.prayer; + FcPrayerTransition prayer_transition = {0}; + + /* 1. Clear per-tick flags */ + clear_per_tick_flags(state); + /* 2. Process player actions */ + process_player_actions(state, actions, &prayer_transition); + + /* 3. Decrement player timers */ + decrement_player_timers(&state->player); + + /* 4. Prayer drain */ + state->overhead_prayer_lost_this_tick = + fc_prayer_drain_tick(&state->player, + state->player.prayer_at_tick_start, + &prayer_transition); + state->prayer_lost_this_tick += state->overhead_prayer_lost_this_tick; + + /* 4b. HP regen (1 HP = 10 tenths every FC_HP_REGEN_INTERVAL ticks) */ + if (state->player.current_hp > 0 && state->player.current_hp < state->player.max_hp) { + state->player.hp_regen_counter++; + if (state->player.hp_regen_counter >= FC_HP_REGEN_INTERVAL) { + state->player.hp_regen_counter = 0; + state->player.current_hp += 10; /* 1 HP in tenths */ + if (state->player.current_hp > state->player.max_hp) { + state->player.current_hp = state->player.max_hp; + } + } + } + + /* 5. NPC AI tick */ + for (int i = 0; i < FC_MAX_NPCS; i++) { + fc_npc_tick(state, i); + } + + /* 6. Resolve pending hits */ + fc_resolve_player_pending_hits(state); + for (int i = 0; i < FC_MAX_NPCS; i++) { + if (state->npcs[i].active) { + fc_resolve_npc_pending_hits(state, i); + } + } + + /* 6b. Process death timers — dead NPCs stay visible briefly */ + for (int i = 0; i < FC_MAX_NPCS; i++) { + FcNpc* n = &state->npcs[i]; + if (n->is_dead && n->active) { + if (n->death_timer > 0) { + n->death_timer--; + } else { + n->active = 0; /* fully despawn */ + } + } + } + + /* 7. Check terminal */ + check_terminal(state); + + /* 8. Episode analytics */ + if (state->player.prayer_at_tick_start == PRAYER_PROTECT_MELEE) + state->ep_ticks_pray_melee++; + if (state->player.prayer_at_tick_start == PRAYER_PROTECT_RANGE) + state->ep_ticks_pray_range++; + if (state->player.prayer_at_tick_start == PRAYER_PROTECT_MAGIC) + state->ep_ticks_pray_magic++; + if (state->player.prayer_changed_this_tick) + state->ep_prayer_switches++; + if (state->current_wave >= 63) + state->ep_reached_wave_63 = 1; + + /* 9. Increment tick */ + state->tick++; + + /* This is the pre-action boundary for the next policy decision. Jad's + * T+2 lock must be visible before that observation and cannot be changed + * by the action selected from it. */ + lock_pending_prayers_at_boundary(state); +} + + +/* Wave */ +/* + * fc_wave.c — 63-wave Fight Caves spawn table with 15 rotations. + * + * Wave data sourced from OSRS wiki + Kotlin archive TOML: + * archive/kotlin-final:runescape-rl/src/headless-env/data/minigame/ + * tzhaar_fight_cave/tzhaar_fight_cave_waves.toml + * + * Pattern: each new NPC tier is introduced on waves 1,3,7,15,31,63. + * The binary pattern gives 2^tier - 1 waves before each new tier. + * + * Spawn directions map to arena coordinates: + * SPAWN_SOUTH → (32, 5) + * SPAWN_SOUTH_WEST → (8, 8) + * SPAWN_NORTH_WEST → (8, 55) + * SPAWN_SOUTH_EAST → (55, 8) + * SPAWN_CENTER → (32, 32) + */ + +/* ======================================================================== */ +/* Wave table (63 waves × up to 6 NPCs) */ +/* ======================================================================== */ + +static const FcWaveEntry WAVE_TABLE[FC_NUM_WAVES] = { + /* Wave 1 */ { {NPC_TZ_KIH, 0, 0, 0, 0, 0}, 1 }, + /* Wave 2 */ { {NPC_TZ_KIH, NPC_TZ_KIH, 0, 0, 0, 0}, 2 }, + /* Wave 3 */ { {NPC_TZ_KEK, 0, 0, 0, 0, 0}, 1 }, + /* Wave 4 */ { {NPC_TZ_KIH, NPC_TZ_KEK, 0, 0, 0, 0}, 2 }, + /* Wave 5 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TZ_KEK, 0, 0, 0}, 3 }, + /* Wave 6 */ { {NPC_TZ_KEK, NPC_TZ_KEK, 0, 0, 0, 0}, 2 }, + /* Wave 7 */ { {NPC_TOK_XIL, 0, 0, 0, 0, 0}, 1 }, + /* Wave 8 */ { {NPC_TZ_KIH, NPC_TOK_XIL, 0, 0, 0, 0}, 2 }, + /* Wave 9 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TOK_XIL, 0, 0, 0}, 3 }, + /* Wave 10 */ { {NPC_TZ_KEK, NPC_TOK_XIL, 0, 0, 0, 0}, 2 }, + /* Wave 11 */ { {NPC_TZ_KIH, NPC_TZ_KEK, NPC_TOK_XIL, 0, 0, 0}, 3 }, + /* Wave 12 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TZ_KEK, NPC_TOK_XIL, 0, 0}, 4 }, + /* Wave 13 */ { {NPC_TZ_KEK, NPC_TZ_KEK, NPC_TOK_XIL, 0, 0, 0}, 3 }, + /* Wave 14 */ { {NPC_TOK_XIL, NPC_TOK_XIL, 0, 0, 0, 0}, 2 }, + /* Wave 15 */ { {NPC_YT_MEJKOT, 0, 0, 0, 0, 0}, 1 }, + /* Wave 16 */ { {NPC_TZ_KIH, NPC_YT_MEJKOT, 0, 0, 0, 0}, 2 }, + /* Wave 17 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_YT_MEJKOT, 0, 0, 0}, 3 }, + /* Wave 18 */ { {NPC_TZ_KEK, NPC_YT_MEJKOT, 0, 0, 0, 0}, 2 }, + /* Wave 19 */ { {NPC_TZ_KIH, NPC_TZ_KEK, NPC_YT_MEJKOT, 0, 0, 0}, 3 }, + /* Wave 20 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TZ_KEK, NPC_YT_MEJKOT, 0, 0}, 4 }, + /* Wave 21 */ { {NPC_TZ_KEK, NPC_TZ_KEK, NPC_YT_MEJKOT, 0, 0, 0}, 3 }, + /* Wave 22 */ { {NPC_TOK_XIL, NPC_YT_MEJKOT, 0, 0, 0, 0}, 2 }, + /* Wave 23 */ { {NPC_TZ_KIH, NPC_TOK_XIL, NPC_YT_MEJKOT, 0, 0, 0}, 3 }, + /* Wave 24 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TOK_XIL, NPC_YT_MEJKOT, 0, 0}, 4 }, + /* Wave 25 */ { {NPC_TZ_KEK, NPC_TOK_XIL, NPC_YT_MEJKOT, 0, 0, 0}, 3 }, + /* Wave 26 */ { {NPC_TZ_KIH, NPC_TZ_KEK, NPC_TOK_XIL, NPC_YT_MEJKOT, 0, 0}, 4 }, + /* Wave 27 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TZ_KEK, NPC_TOK_XIL, NPC_YT_MEJKOT, 0}, 5 }, + /* Wave 28 */ { {NPC_TZ_KEK, NPC_TZ_KEK, NPC_TOK_XIL, NPC_YT_MEJKOT, 0, 0}, 4 }, + /* Wave 29 */ { {NPC_TOK_XIL, NPC_TOK_XIL, NPC_YT_MEJKOT, 0, 0, 0}, 3 }, + /* Wave 30 */ { {NPC_YT_MEJKOT, NPC_YT_MEJKOT, 0, 0, 0, 0}, 2 }, + /* Wave 31 */ { {NPC_KET_ZEK, 0, 0, 0, 0, 0}, 1 }, + /* Wave 32 */ { {NPC_TZ_KIH, NPC_KET_ZEK, 0, 0, 0, 0}, 2 }, + /* Wave 33 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_KET_ZEK, 0, 0, 0}, 3 }, + /* Wave 34 */ { {NPC_TZ_KEK, NPC_KET_ZEK, 0, 0, 0, 0}, 2 }, + /* Wave 35 */ { {NPC_TZ_KIH, NPC_TZ_KEK, NPC_KET_ZEK, 0, 0, 0}, 3 }, + /* Wave 36 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TZ_KEK, NPC_KET_ZEK, 0, 0}, 4 }, + /* Wave 37 */ { {NPC_TZ_KEK, NPC_TZ_KEK, NPC_KET_ZEK, 0, 0, 0}, 3 }, + /* Wave 38 */ { {NPC_TOK_XIL, NPC_KET_ZEK, 0, 0, 0, 0}, 2 }, + /* Wave 39 */ { {NPC_TZ_KIH, NPC_TOK_XIL, NPC_KET_ZEK, 0, 0, 0}, 3 }, + /* Wave 40 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TOK_XIL, NPC_KET_ZEK, 0, 0}, 4 }, + /* Wave 41 */ { {NPC_TZ_KEK, NPC_TOK_XIL, NPC_KET_ZEK, 0, 0, 0}, 3 }, + /* Wave 42 */ { {NPC_TZ_KIH, NPC_TZ_KEK, NPC_TOK_XIL, NPC_KET_ZEK, 0, 0}, 4 }, + /* Wave 43 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TZ_KEK, NPC_TOK_XIL, NPC_KET_ZEK, 0}, 5 }, + /* Wave 44 */ { {NPC_TZ_KEK, NPC_TZ_KEK, NPC_TOK_XIL, NPC_KET_ZEK, 0, 0}, 4 }, + /* Wave 45 */ { {NPC_TOK_XIL, NPC_TOK_XIL, NPC_KET_ZEK, 0, 0, 0}, 3 }, + /* Wave 46 */ { {NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0, 0, 0}, 2 }, + /* Wave 47 */ { {NPC_TZ_KIH, NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0, 0}, 3 }, + /* Wave 48 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0}, 4 }, + /* Wave 49 */ { {NPC_TZ_KEK, NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0, 0}, 3 }, + /* Wave 50 */ { {NPC_TZ_KIH, NPC_TZ_KEK, NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0}, 4 }, + /* Wave 51 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TZ_KEK, NPC_YT_MEJKOT, NPC_KET_ZEK, 0}, 5 }, + /* Wave 52 */ { {NPC_TZ_KEK, NPC_TZ_KEK, NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0}, 4 }, + /* Wave 53 */ { {NPC_TOK_XIL, NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0, 0}, 3 }, + /* Wave 54 */ { {NPC_TZ_KIH, NPC_TOK_XIL, NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0}, 4 }, + /* Wave 55 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TOK_XIL, NPC_YT_MEJKOT, NPC_KET_ZEK, 0}, 5 }, + /* Wave 56 */ { {NPC_TZ_KEK, NPC_TOK_XIL, NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0}, 4 }, + /* Wave 57 */ { {NPC_TZ_KIH, NPC_TZ_KEK, NPC_TOK_XIL, NPC_YT_MEJKOT, NPC_KET_ZEK, 0}, 5 }, + /* Wave 58 */ { {NPC_TZ_KIH, NPC_TZ_KIH, NPC_TZ_KEK, NPC_TOK_XIL, NPC_YT_MEJKOT, NPC_KET_ZEK}, 6 }, + /* Wave 59 */ { {NPC_TZ_KEK, NPC_TZ_KEK, NPC_TOK_XIL, NPC_YT_MEJKOT, NPC_KET_ZEK, 0}, 5 }, + /* Wave 60 */ { {NPC_TOK_XIL, NPC_TOK_XIL, NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0}, 4 }, + /* Wave 61 */ { {NPC_YT_MEJKOT, NPC_YT_MEJKOT, NPC_KET_ZEK, 0, 0, 0}, 3 }, + /* Wave 62 */ { {NPC_KET_ZEK, NPC_KET_ZEK, 0, 0, 0, 0}, 2 }, + /* Wave 63 */ { {NPC_TZTOK_JAD, 0, 0, 0, 0, 0}, 1 }, +}; + +static const int WAVE_ROTATIONS[FC_NUM_WAVES][FC_NUM_ROTATIONS][FC_MAX_SPAWNS_PER_WAVE] = { + { /* Wave 1 */ + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + }, + { /* Wave 2 */ + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + }, + { /* Wave 3 */ + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + }, + { /* Wave 4 */ + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + }, + { /* Wave 5 */ + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + }, + { /* Wave 6 */ + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + }, + { /* Wave 7 */ + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + }, + { /* Wave 8 */ + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + }, + { /* Wave 9 */ + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + }, + { /* Wave 10 */ + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + }, + { /* Wave 11 */ + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + }, + { /* Wave 12 */ + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + }, + { /* Wave 13 */ + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + }, + { /* Wave 14 */ + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + }, + { /* Wave 15 */ + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + }, + { /* Wave 16 */ + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + }, + { /* Wave 17 */ + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + }, + { /* Wave 18 */ + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + }, + { /* Wave 19 */ + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + }, + { /* Wave 20 */ + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + }, + { /* Wave 21 */ + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + }, + { /* Wave 22 */ + {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + }, + { /* Wave 23 */ + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + }, + { /* Wave 24 */ + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + }, + { /* Wave 25 */ + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, + }, + { /* Wave 26 */ + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + }, + { /* Wave 27 */ + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + }, + { /* Wave 28 */ + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + }, + { /* Wave 29 */ + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + }, + { /* Wave 30 */ + {SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + }, + { /* Wave 31 */ + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + }, + { /* Wave 32 */ + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + }, + { /* Wave 33 */ + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + }, + { /* Wave 34 */ + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + }, + { /* Wave 35 */ + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + }, + { /* Wave 36 */ + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + }, + { /* Wave 37 */ + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + }, + { /* Wave 38 */ + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + }, + { /* Wave 39 */ + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + }, + { /* Wave 40 */ + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + }, + { /* Wave 41 */ + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + }, + { /* Wave 42 */ + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + }, + { /* Wave 43 */ + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, + }, + { /* Wave 44 */ + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + }, + { /* Wave 45 */ + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + }, + { /* Wave 46 */ + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + }, + { /* Wave 47 */ + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + }, + { /* Wave 48 */ + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + }, + { /* Wave 49 */ + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + }, + { /* Wave 50 */ + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + }, + { /* Wave 51 */ + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + }, + { /* Wave 52 */ + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + }, + { /* Wave 53 */ + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + }, + { /* Wave 54 */ + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + }, + { /* Wave 55 */ + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, + }, + { /* Wave 56 */ + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + }, + { /* Wave 57 */ + {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, + {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, + {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + }, + { /* Wave 58 */ + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST}, + }, + { /* Wave 59 */ + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, + }, + { /* Wave 60 */ + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, + }, + { /* Wave 61 */ + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, + }, + { /* Wave 62 */ + {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + }, + { /* Wave 63 */ + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_CENTER, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, + {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + }, +}; + +/* ======================================================================== */ +/* Spawn position from direction */ +/* ======================================================================== */ + +/* + * Spawn positions derived from RSPS tzhaar_fight_cave.areas.toml. + * World coords converted to arena-local (world - 2368, world - 5056). + * Centers of each spawn area, verified walkable for all NPC sizes (1-5). + * + * NORTH_WEST: world [2378,2385]x[5102,5109] → local [10,17]x[46,53] center (13,49) + * SOUTH_WEST: world [2379,2385]x[5070,5076] → local [11,17]x[14,20] center (14,17) + * SOUTH: world [2402,2408]x[5070,5076] → local [34,40]x[14,20] center (37,17) + * SOUTH_EAST: world [2416,2422]x[5080,5086] → local [48,54]x[24,30] center (51,27) + * CENTER: world [2397,2403]x[5085,5091] → local [29,35]x[29,35] center (32,32) + */ +void fc_spawn_position(int spawn_dir, int* x, int* y) { + switch (spawn_dir) { + case SPAWN_SOUTH: *x = 37; *y = 17; break; + case SPAWN_SOUTH_WEST: *x = 14; *y = 17; break; + case SPAWN_NORTH_WEST: *x = 13; *y = 49; break; + case SPAWN_SOUTH_EAST: *x = 51; *y = 27; break; + case SPAWN_CENTER: *x = 32; *y = 32; break; + default: *x = 32; *y = 32; break; + } +} + +/* ======================================================================== */ +/* Wave table accessors */ +/* ======================================================================== */ + +static const FcWaveEntry* fc_wave_get(int wave_num) { + if (wave_num < 1 || wave_num > FC_NUM_WAVES) return &WAVE_TABLE[0]; + return &WAVE_TABLE[wave_num - 1]; +} + +static int fc_wave_spawn_dir(int wave_num, int rotation, int npc_index) { + if (wave_num < 1 || wave_num > FC_NUM_WAVES) return SPAWN_CENTER; + if (rotation < 0 || rotation >= FC_NUM_ROTATIONS) return SPAWN_CENTER; + if (npc_index < 0 || npc_index >= FC_MAX_SPAWNS_PER_WAVE) return SPAWN_CENTER; + return WAVE_ROTATIONS[wave_num - 1][rotation][npc_index]; +} + +/* ======================================================================== */ +/* Spawn wave NPCs into the arena */ +/* ======================================================================== */ + +void fc_wave_spawn(FcState* state, int wave_num) { + const FcWaveEntry* wave = fc_wave_get(wave_num); + int rotation = state->rotation_id; + + for (int i = 0; i < wave->num_spawns; i++) { + int npc_type = wave->npc_types[i]; + int dir = fc_wave_spawn_dir(wave_num, rotation, i); + int sx, sy; + fc_spawn_position(dir, &sx, &sy); + + const FcNpcStats* stats = fc_npc_get_stats(npc_type); + /* Wave spawning retains its radius-five fallback policy: if no valid + * footprint is found, the original regional tile is still used. */ + (void)fc_spawn_find_available_footprint( + state, sx, sy, stats->size, 5, &sx, &sy); + + if (fc_spawn_npc_first_free(state, npc_type, sx, sy) < 0) continue; + /* Tz-Kek counts as 2 in wave remaining (pre-counts the split). + * RSPS: ids.sumOf { if (it == "tz_kek") 2 else 1 } */ + state->npcs_remaining += (npc_type == NPC_TZ_KEK) ? 2 : 1; + } +} + +/* ======================================================================== */ +/* Wave advancement */ +/* ======================================================================== */ + +void fc_wave_record_current_duration(FcState* state) { + int wave_ticks = state->tick - state->wave_start_tick; + if (wave_ticks > state->ep_max_wave_ticks) { + state->ep_max_wave_ticks = wave_ticks; + state->ep_max_wave_ticks_wave = state->current_wave; + } +} + +int fc_wave_check_advance(FcState* state) { + /* Don't advance if wave hasn't started or NPCs still alive */ + if (state->current_wave <= 0) return 0; + if (state->npcs_remaining > 0) return 0; + if (state->terminal != TERMINAL_NONE) return 0; + + state->wave_just_cleared = 1; + + fc_wave_record_current_duration(state); + + /* Check if all waves complete */ + if (state->current_wave >= FC_NUM_WAVES) { + state->terminal = TERMINAL_CAVE_COMPLETE; + return 0; + } + + /* Advance to next wave */ + state->current_wave++; + state->jad_healers_spawned = 0; + state->jad_healer_spawn_generations = 0; + state->wave_start_tick = state->tick; + fc_wave_spawn(state, state->current_wave); + + return 1; +} + + +#endif diff --git a/ocean/fight_caves/tools.py b/ocean/fight_caves/tools.py new file mode 100644 index 0000000000..8b3162c14d --- /dev/null +++ b/ocean/fight_caves/tools.py @@ -0,0 +1,1526 @@ +#!/usr/bin/env python3 +"""Fight Caves asset installation, build checks, playable launch and checkpoint replay.""" + +from __future__ import annotations + +import argparse +import configparser +import ctypes +from dataclasses import dataclass +import glob +import gzip +import hashlib +import importlib.util +import json +import os +from pathlib import Path, PurePosixPath +import shlex +import shutil +import subprocess +import sys +import sysconfig +import tarfile +import tempfile +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +# Asset installation + +REPO_ROOT = Path(__file__).resolve().parents[2] +RESOURCE_ROOT = REPO_ROOT / "resources" / "fight_caves" +DEFAULT_MANIFEST = RESOURCE_ROOT / "asset_manifest.json" +BUFFER_SIZE = 1024 * 1024 + + +class AssetError(RuntimeError): + pass + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + while chunk := handle.read(BUFFER_SIZE): + digest.update(chunk) + return digest.hexdigest() + + +def load_manifest(path: Path) -> dict: + try: + manifest = json.loads(path.read_text(encoding="utf-8")) + except FileNotFoundError as exc: + raise AssetError(f"asset manifest not found: {path}") from exc + except (OSError, json.JSONDecodeError) as exc: + raise AssetError(f"could not read asset manifest {path}: {exc}") from exc + + if manifest.get("schema_version") != 1: + raise AssetError("unsupported Fight Caves asset manifest schema") + if not isinstance(manifest.get("bundles"), dict): + raise AssetError("asset manifest has no bundle definitions") + return manifest + + +def checked_relative_path(value: str) -> PurePosixPath: + path = PurePosixPath(value) + if path.is_absolute() or not path.parts or ".." in path.parts: + raise AssetError(f"unsafe path in asset manifest or archive: {value!r}") + return path + + +def expected_files(bundle: dict) -> dict[str, dict]: + result: dict[str, dict] = {} + for entry in bundle.get("files", []): + relative = checked_relative_path(entry.get("path", "")).as_posix() + if relative in result: + raise AssetError(f"duplicate file in asset manifest: {relative}") + if not isinstance(entry.get("size_bytes"), int) or not entry.get("sha256"): + raise AssetError(f"incomplete asset metadata for {relative}") + result[relative] = entry + if not result: + raise AssetError("asset bundle contains no files") + return result + + +def verify_tree(root: Path, bundle: dict, *, exact: bool) -> list[str]: + expected = expected_files(bundle) + errors: list[str] = [] + for relative, entry in expected.items(): + path = root.joinpath(*PurePosixPath(relative).parts) + if not path.is_file(): + errors.append(f"missing {relative}") + continue + actual_size = path.stat().st_size + if actual_size != entry["size_bytes"]: + errors.append( + f"wrong size for {relative}: expected {entry['size_bytes']}, " + f"got {actual_size}" + ) + continue + actual_hash = sha256_file(path) + if actual_hash != entry["sha256"]: + errors.append(f"checksum mismatch for {relative}") + + if exact: + actual = { + path.relative_to(root).as_posix() + for path in root.rglob("*") + if path.is_file() + } + for relative in sorted(actual - set(expected)): + errors.append(f"unexpected file in bundle: {relative}") + return errors + + +def download(url: str, destination: Path) -> None: + print(f"Downloading {url}") + request = Request(url, headers={"User-Agent": "PufferLib-Fight-Caves-assets/1"}) + try: + with urlopen(request, timeout=60) as response, destination.open("wb") as out: + shutil.copyfileobj(response, out, BUFFER_SIZE) + except (HTTPError, URLError, TimeoutError, OSError) as exc: + raise AssetError(f"download failed for {url}: {exc}") from exc + + +def extract_checked(archive: Path, destination: Path, bundle: dict) -> None: + expected = set(expected_files(bundle)) + archived_files: set[str] = set() + + try: + with tarfile.open(archive, "r:gz") as source: + for member in source.getmembers(): + relative = checked_relative_path(member.name) + relative_name = relative.as_posix() + if member.isdir(): + continue + if not member.isfile(): + raise AssetError( + f"unsupported non-file entry in asset archive: {relative_name}" + ) + if relative_name not in expected: + raise AssetError( + f"unexpected file in asset archive: {relative_name}" + ) + if relative_name in archived_files: + raise AssetError(f"duplicate file in asset archive: {relative_name}") + + target = destination.joinpath(*relative.parts) + target.parent.mkdir(parents=True, exist_ok=True) + extracted = source.extractfile(member) + if extracted is None: + raise AssetError(f"could not extract {relative_name}") + with extracted, target.open("wb") as out: + shutil.copyfileobj(extracted, out, BUFFER_SIZE) + target.chmod(0o644) + archived_files.add(relative_name) + except (OSError, tarfile.TarError) as exc: + raise AssetError(f"could not extract {archive}: {exc}") from exc + + missing = expected - archived_files + if missing: + raise AssetError(f"asset archive is missing {sorted(missing)[0]}") + + +def replace_bundle(staged_root: Path, bundle: dict) -> None: + prefix = checked_relative_path(bundle.get("install_prefix", "")) + if len(prefix.parts) != 1: + raise AssetError("bundle install prefix must be one directory name") + + staged = staged_root.joinpath(*prefix.parts) + destination = RESOURCE_ROOT.joinpath(*prefix.parts) + incoming = RESOURCE_ROOT / f".{prefix.name}.installing" + backup = RESOURCE_ROOT / f".{prefix.name}.backup" + + if not staged.is_dir(): + raise AssetError(f"archive did not contain expected {prefix}/ directory") + if incoming.exists() or backup.exists(): + raise AssetError( + f"stale installer directory found under {RESOURCE_ROOT}; " + "remove it and retry" + ) + + shutil.copytree(staged, incoming) + try: + if destination.exists(): + destination.rename(backup) + incoming.rename(destination) + if backup.exists(): + shutil.rmtree(backup) + except Exception: + if incoming.exists(): + shutil.rmtree(incoming) + if backup.exists() and not destination.exists(): + backup.rename(destination) + raise + + +def install_bundle(name: str, bundle: dict, *, force: bool) -> None: + if not force: + current_errors = verify_tree(RESOURCE_ROOT, bundle, exact=False) + if not current_errors: + print(f"Fight Caves {name} assets are already installed and verified.") + return + + archive_name = bundle.get("archive", "") + archive_hash = bundle.get("sha256", "") + archive_size = bundle.get("size_bytes") + url = bundle.get("url", "") + if not archive_name or not archive_hash or not url or not isinstance(archive_size, int): + raise AssetError(f"incomplete archive metadata for {name}") + + RESOURCE_ROOT.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="fight-caves-assets-") as temp_value: + temp = Path(temp_value) + archive = temp / archive_name + extracted = temp / "extracted" + extracted.mkdir() + download(url, archive) + + if archive.stat().st_size != archive_size: + raise AssetError( + f"wrong archive size for {archive_name}: expected {archive_size}, " + f"got {archive.stat().st_size}" + ) + if sha256_file(archive) != archive_hash: + raise AssetError(f"checksum mismatch for downloaded {archive_name}") + + extract_checked(archive, extracted, bundle) + staged_errors = verify_tree(extracted, bundle, exact=True) + if staged_errors: + raise AssetError(staged_errors[0]) + replace_bundle(extracted, bundle) + + installed_errors = verify_tree(RESOURCE_ROOT, bundle, exact=False) + if installed_errors: + raise AssetError(installed_errors[0]) + print(f"Installed and verified Fight Caves {name} assets.") + + +def setup_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Install pinned Fight Caves runtime and viewer assets." + ) + selection = parser.add_mutually_exclusive_group() + selection.add_argument("--core", action="store_true", help="select runtime maps") + selection.add_argument("--viewer", action="store_true", help="select viewer assets") + selection.add_argument("--all", action="store_true", help="select both bundles") + parser.add_argument( + "--verify-only", + action="store_true", + help="verify installed files without downloading or changing them", + ) + parser.add_argument( + "--force", action="store_true", help="download and reinstall selected bundles" + ) + parser.add_argument( + "--manifest", + type=Path, + default=DEFAULT_MANIFEST, + help=argparse.SUPPRESS, + ) + return parser.parse_args() + + +def setup_main() -> int: + args = setup_args() + try: + manifest = load_manifest(args.manifest) + names = ( + ["core", "viewer"] + if args.all or not (args.core or args.viewer) + else [] + ) + if args.core: + names = ["core"] + elif args.viewer: + names = ["viewer"] + + for name in names: + bundle = manifest["bundles"].get(name) + if not isinstance(bundle, dict): + raise AssetError(f"asset manifest has no {name} bundle") + if args.verify_only: + errors = verify_tree(RESOURCE_ROOT, bundle, exact=False) + if errors: + raise AssetError(f"{name}: {errors[0]}") + print(f"Fight Caves {name} assets are installed and verified.") + else: + install_bundle(name, bundle, force=args.force) + except (AssetError, KeyError, OSError) as exc: + print(f"Fight Caves asset setup failed: {exc}", file=sys.stderr) + return 1 + return 0 + + +# Release bundles + +RUNTIME_FILES = ( + "fightcaves.collision", + "fightcaves.movement", + "fightcaves.los", +) + + +@dataclass(frozen=True) +class BundleFile: + source: Path + archive_path: str + + +def collect_runtime(source: Path) -> list[BundleFile]: + result = [] + for name in RUNTIME_FILES: + path = source / name + if not path.is_file(): + raise SystemExit(f"missing runtime asset: {path}") + result.append(BundleFile(path, f"runtime/{name}")) + return result + + +def collect_viewer(source: Path) -> list[BundleFile]: + result = [ + BundleFile(path, f"viewer/{path.relative_to(source).as_posix()}") + for path in source.rglob("*") + if path.is_file() + ] + if not result: + raise SystemExit(f"no viewer assets found under {source}") + return sorted(result, key=lambda entry: entry.archive_path) + + +def write_archive(destination: Path, files: list[BundleFile]) -> None: + temporary = destination.with_suffix(destination.suffix + ".tmp") + destination.parent.mkdir(parents=True, exist_ok=True) + with temporary.open("wb") as raw: + with gzip.GzipFile(filename="", mode="wb", fileobj=raw, mtime=0) as compressed: + with tarfile.open( + mode="w", fileobj=compressed, format=tarfile.PAX_FORMAT + ) as archive: + for entry in sorted(files, key=lambda value: value.archive_path): + info = archive.gettarinfo(str(entry.source), entry.archive_path) + info.uid = 0 + info.gid = 0 + info.uname = "" + info.gname = "" + info.mode = 0o644 + info.mtime = 0 + with entry.source.open("rb") as source: + archive.addfile(info, source) + temporary.replace(destination) + + +def bundle_manifest( + archive: Path, + files: list[BundleFile], + repository: str, + release_tag: str, + install_prefix: str, +) -> dict: + return { + "archive": archive.name, + "url": ( + f"https://github.com/{repository}/releases/download/" + f"{release_tag}/{archive.name}" + ), + "size_bytes": archive.stat().st_size, + "sha256": sha256_file(archive), + "install_prefix": install_prefix, + "files": [ + { + "path": entry.archive_path, + "size_bytes": entry.source.stat().st_size, + "sha256": sha256_file(entry.source), + } + for entry in sorted(files, key=lambda value: value.archive_path) + ], + } + + +def bundle_args() -> argparse.Namespace: + parser = argparse.ArgumentParser() + parser.add_argument("--runtime-source", type=Path, required=True) + parser.add_argument("--viewer-source", type=Path, required=True) + parser.add_argument("--output-dir", type=Path, required=True) + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--release-repository", required=True) + parser.add_argument("--release-tag", required=True) + parser.add_argument("--source-revision", required=True) + parser.add_argument("--bundle-version", default="v2") + return parser.parse_args() + + +def bundle_main() -> int: + args = bundle_args() + runtime_files = collect_runtime(args.runtime_source) + viewer_files = collect_viewer(args.viewer_source) + runtime_archive = ( + args.output_dir / f"fight-caves-runtime-assets-{args.bundle_version}.tar.gz" + ) + viewer_archive = ( + args.output_dir / f"fight-caves-viewer-assets-{args.bundle_version}.tar.gz" + ) + + write_archive(runtime_archive, runtime_files) + write_archive(viewer_archive, viewer_files) + manifest = { + "schema_version": 1, + "release_tag": args.release_tag, + "source": { + "repository": f"https://github.com/{args.release_repository}", + "revision": args.source_revision, + }, + "bundles": { + "core": bundle_manifest( + runtime_archive, + runtime_files, + args.release_repository, + args.release_tag, + "runtime", + ), + "viewer": bundle_manifest( + viewer_archive, + viewer_files, + args.release_repository, + args.release_tag, + "viewer", + ), + }, + } + args.manifest.parent.mkdir(parents=True, exist_ok=True) + args.manifest.write_text( + json.dumps(manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8" + ) + print(runtime_archive) + print(viewer_archive) + print(args.manifest) + return 0 + + +# Dependency preflight + +ENV_ROOT = REPO_ROOT / "ocean" / "fight_caves" + + +def command_name(value: str | None, default: str) -> str: + words = shlex.split(value or default) + return words[0] if words else default + + +def command_words(value: str | None, default: str) -> list[str]: + words = shlex.split(value or default) + return words or [default] + + +def require_command(errors: list[str], name: str, purpose: str) -> None: + if shutil.which(name) is None: + errors.append(f"required command '{name}' is unavailable ({purpose})") + + +def require_python_module(errors: list[str], name: str, purpose: str) -> None: + if importlib.util.find_spec(name) is None: + errors.append( + f"required Python module '{name}' is unavailable ({purpose}); " + f"install the repository dependencies first" + ) + + +def verify_assets(errors: list[str], names: tuple[str, ...]) -> None: + try: + manifest = load_manifest(DEFAULT_MANIFEST) + for name in names: + bundle = manifest["bundles"].get(name) + if not isinstance(bundle, dict): + errors.append(f"asset manifest has no {name} bundle") + continue + failures = verify_tree( + RESOURCE_ROOT, bundle, exact=False + ) + if failures: + errors.append(f"{name} asset bundle is invalid: {failures[0]}") + except Exception as exc: + errors.append(f"could not verify Fight Caves assets: {exc}") + + +def check_linux_viewer_link( + errors: list[str], compiler_value: str | None +) -> None: + if sys.platform != "linux": + return + x11_header = Path("/usr/include/X11/Xlib.h") + if not x11_header.is_file(): + errors.append( + "X11 development headers are unavailable; on Ubuntu install " + "libx11-dev libxrandr-dev libxi-dev libxcursor-dev libxinerama-dev" + ) + return + compiler = command_words(compiler_value, "clang") + source = "int main(void) { return 0; }\n" + try: + with tempfile.TemporaryDirectory(prefix="fight-caves-preflight-") as value: + root = Path(value) + result = subprocess.run( + [*compiler, "-x", "c", "-", "-o", str(root / "link-test"), + "-lGL", "-lX11"], + input=source, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + except OSError as exc: + errors.append( + f"could not run viewer link check with {' '.join(compiler)}: {exc}" + ) + return + if result.returncode != 0: + detail = result.stderr.strip().splitlines() + suffix = f": {detail[-1]}" if detail else "" + errors.append( + "OpenGL/X11 development libraries cannot be linked; on Ubuntu " + "install libgl1-mesa-dev and the X11 development packages" + f"{suffix}" + ) + + +def check_openmp(errors: list[str], compiler_value: str | None, language: str) -> None: + compiler = command_words(compiler_value, "clang" if language == "c" else "g++") + suffix = ".c" if language == "c" else ".cpp" + source = "#include \nint main(void) { return omp_get_max_threads() < 1; }\n" + try: + with tempfile.TemporaryDirectory(prefix="fight-caves-openmp-") as value: + root = Path(value) + source_path = root / f"test{suffix}" + source_path.write_text(source, encoding="utf-8") + arguments = [ + *compiler, str(source_path), "-fopenmp", "-o", str(root / "test") + ] + if language == "c++": + omp_library = os.environ.get( + "PUFFER_OMP_LIB", "-lomp5" if sys.platform == "linux" else "-lomp" + ) + arguments.extend(shlex.split(omp_library)) + result = subprocess.run( + arguments, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + except OSError as exc: + errors.append(f"could not run OpenMP {language.upper()} check: {exc}") + return + if result.returncode != 0: + detail = result.stderr.strip().splitlines() + last_line = f": {detail[-1]}" if detail else "" + errors.append( + f"{language.upper()} compiler cannot build and link OpenMP; on Ubuntu " + f"install libomp-dev or select an OpenMP-capable compiler with " + f"{'CC' if language == 'c' else 'CXX'}{last_line}" + ) + + +def check_graphical_display(errors: list[str]) -> None: + if sys.platform != "linux": + return + display = os.environ.get("DISPLAY") + if not display: + errors.append( + "no graphical DISPLAY is configured; run under xvfb-run for " + "headless validation or launch from a graphical session" + ) + return + xdpyinfo = shutil.which("xdpyinfo") + if xdpyinfo is None: + errors.append( + "'xdpyinfo' is required to validate the X11 display; on Ubuntu " + "install x11-utils" + ) + return + result = subprocess.run( + [xdpyinfo, "-display", display], + text=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.PIPE, + check=False, + ) + if result.returncode != 0: + errors.append( + f"X11 display {display!r} is not accessible; run under xvfb-run " + "for headless validation or fix the display authorization" + ) + + +def preflight_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Check Fight Caves build dependencies and installed assets." + ) + parser.add_argument( + "--mode", + choices=("core", "native", "cpu", "cuda", "viewer", "viewer-runtime", "web"), + required=True, + help="build path to validate", + ) + return parser.parse_args() + + +def preflight_main() -> int: + args = preflight_args() + errors: list[str] = [] + if sys.version_info < (3, 10): + errors.append( + f"Python 3.10 or newer is required; found {sys.version.split()[0]}" + ) + + compiler = command_name(os.environ.get("CC"), "clang") + if args.mode != "viewer-runtime": + require_command(errors, compiler, "C compilation") + + if args.mode in ("core", "native", "cpu", "cuda", "web"): + verify_assets(errors, ("core",)) + elif args.mode in ("viewer", "viewer-runtime"): + verify_assets(errors, ("core", "viewer")) + + if args.mode in ("native", "cpu", "cuda"): + require_command(errors, "ar", "static library creation") + if args.mode in ("cpu", "cuda"): + cxx = command_name(os.environ.get("CXX"), "g++") + require_command(errors, cxx, "C++ extension compilation") + for module, purpose in ( + ("numpy", "Puffer observation buffers"), + ("pybind11", "Puffer Python extension bindings"), + ("torch", "Puffer policy execution"), + ): + require_python_module(errors, module, purpose) + check_openmp(errors, os.environ.get("CXX"), "c++") + if args.mode in ("native", "cpu", "cuda"): + check_openmp(errors, os.environ.get("CC"), "c") + if args.mode == "native" and sys.platform == "linux": + check_linux_viewer_link(errors, os.environ.get("CC")) + if args.mode == "cuda": + cuda_home = os.environ.get("CUDA_HOME") or os.environ.get("CUDA_PATH") + nvcc = str(Path(cuda_home) / "bin" / "nvcc") if cuda_home else "nvcc" + require_command(errors, nvcc, "CUDA backend compilation") + require_command(errors, "nvidia-smi", "CUDA device validation") + if args.mode == "viewer": + require_command(errors, "cmake", "viewer configuration") + check_linux_viewer_link(errors, os.environ.get("CC")) + if args.mode == "viewer-runtime": + check_graphical_display(errors) + if args.mode == "web": + require_command(errors, "emcc", "WebAssembly compilation") + + if errors: + print("Fight Caves preflight failed:", file=sys.stderr) + for error in errors: + print(f" - {error}", file=sys.stderr) + if any("asset bundle" in error or "verify Fight Caves assets" in error + for error in errors): + print( + "Install assets with: python3 ocean/fight_caves/tools.py setup --all", + file=sys.stderr, + ) + return 1 + + print(f"Fight Caves {args.mode} preflight passed.") + return 0 + + +# Checkpoint contract + +class ContractError(RuntimeError): + pass + + +REQUIRED_FIELDS = ( + "contract_dump_schema_version", + "policy_obs_size", + "puffer_obs_size", + "puffer_action_dims", + "puffer_mask_size", + "observation_version", + "action_version", + "reward_version", + "prayer_timing_version", + "state_hash_version", + "active_loadout", +) + + +def contract_identity(contract: dict[str, Any]) -> str: + encoded = json.dumps(contract, sort_keys=True, separators=(",", ":")).encode() + return hashlib.sha256(encoded).hexdigest() + + +def load_compiled_contract(backend_path: str | Path) -> dict[str, Any]: + backend = Path(backend_path).resolve() + if not backend.is_file(): + raise ContractError(f"compiled backend is unavailable: {backend}") + try: + library = ctypes.CDLL(str(backend)) + symbol = library.fc_training_contract_json + except (OSError, AttributeError) as exc: + raise ContractError( + f"compiled backend does not export the Fight Caves contract: {backend}" + ) from exc + symbol.argtypes = [] + symbol.restype = ctypes.c_char_p + raw = symbol() + if raw is None: + raise ContractError("compiled Fight Caves contract returned null") + try: + contract = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ContractError("compiled Fight Caves contract is invalid") from exc + if not isinstance(contract, dict): + raise ContractError("compiled Fight Caves contract is not an object") + return contract + + +def validate_compiled_contract( + contract: dict[str, Any], expected_active_loadout: str | None = None +) -> None: + missing = [field for field in REQUIRED_FIELDS if field not in contract] + if missing: + raise ContractError(f"compiled contract omits {missing[0]}") + if contract["contract_dump_schema_version"] != 1: + raise ContractError("unsupported compiled contract schema") + + policy_obs_size = contract["policy_obs_size"] + puffer_obs_size = contract["puffer_obs_size"] + mask_size = contract["puffer_mask_size"] + action_dims = contract["puffer_action_dims"] + if not all(isinstance(value, int) and value > 0 for value in ( + policy_obs_size, puffer_obs_size, mask_size + )): + raise ContractError("compiled contract contains invalid observation sizes") + if ( + not isinstance(action_dims, list) + or not action_dims + or not all(isinstance(value, int) and value > 0 for value in action_dims) + ): + raise ContractError("compiled contract contains invalid action dimensions") + if sum(action_dims) != mask_size: + raise ContractError("compiled action dimensions do not match mask size") + if policy_obs_size + mask_size != puffer_obs_size: + raise ContractError("compiled policy observation and mask sizes do not add up") + if expected_active_loadout is not None: + actual = contract["active_loadout"] + if actual != expected_active_loadout: + raise ContractError( + "compiled active loadout mismatch: " + f"expected={expected_active_loadout!r}, actual={actual!r}" + ) + + +def merged_config(default_path: Path, selected_path: Path) -> configparser.ConfigParser: + parser = configparser.ConfigParser() + loaded = parser.read([default_path, selected_path], encoding="utf-8") + if str(default_path) not in loaded or str(selected_path) not in loaded: + raise ContractError( + f"Puffer configuration is unavailable: {default_path}, {selected_path}" + ) + return parser + + +def validate_config_contract( + contract: dict[str, Any], default_path: Path, selected_path: Path +) -> None: + parser = merged_config(default_path, selected_path) + if not parser.has_section("run"): + raise ContractError(f"Fight Caves config has no [run] section: {selected_path}") + for field in ("observation_version", "action_version", "reward_version"): + if not parser.has_option("run", field): + raise ContractError(f"Fight Caves config omits [run].{field}") + configured = parser.get("run", field).strip().strip("'\"") + if configured != contract[field]: + raise ContractError( + f"Fight Caves config/compiled contract mismatch for {field}: " + f"configured={configured!r}, compiled={contract[field]!r}" + ) + + +def build_verified_preflight( + backend_path: str | Path, + selected_config: str | Path, + default_config: str | Path, + active_loadout: str, +) -> dict[str, Any]: + contract = load_compiled_contract(backend_path) + validate_compiled_contract(contract, active_loadout) + validate_config_contract( + contract, Path(default_config).resolve(), Path(selected_config).resolve() + ) + return { + "contract": contract, + "contract_identity": contract_identity(contract), + "backend_path": str(Path(backend_path).resolve()), + "config_path": str(Path(selected_config).resolve()), + } + + +def expected_checkpoint_parameter_bytes( + contract: dict[str, Any], + selected_config: str | Path, + default_config: str | Path, +) -> int: + parser = merged_config( + Path(default_config).resolve(), Path(selected_config).resolve() + ) + try: + hidden_size = parser.getint("policy", "hidden_size") + num_layers = parser.getint("policy", "num_layers") + network_name = parser.get("torch", "network").strip().strip("'\"") + except (configparser.Error, ValueError) as exc: + raise ContractError(f"cannot read checkpoint policy topology: {exc}") from exc + if network_name != "MinGRU": + raise ContractError( + f"unsupported raw checkpoint network for replay: {network_name!r}" + ) + if hidden_size <= 0 or num_layers <= 0: + raise ContractError("checkpoint policy topology must be positive") + + parameter_floats = ( + contract["puffer_obs_size"] * hidden_size + + (sum(contract["puffer_action_dims"]) + 1) * hidden_size + + num_layers * 3 * hidden_size * hidden_size + ) + return parameter_floats * 4 + + +def find_checkpoint_marker(checkpoint: Path, checkpoint_root: Path) -> Path | None: + adjacent = checkpoint.with_name(f"{checkpoint.name}.contract.json") + if adjacent.is_file(): + return adjacent + if checkpoint_root not in checkpoint.parents: + external_root = next( + (parent for parent in checkpoint.parents if parent.name == "checkpoints"), + None, + ) + if external_root is None: + return None + checkpoint_root = external_root + current = checkpoint.parent + while current == checkpoint_root or checkpoint_root in current.parents: + marker = current / "contract.json" + if marker.is_file(): + return marker + if current == checkpoint_root: + break + current = current.parent + return None + + +def validate_checkpoint_marker(marker: Path, preflight: dict[str, Any]) -> None: + try: + payload = json.loads(marker.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ContractError(f"invalid checkpoint contract sidecar: {marker}") from exc + if payload.get("contract") != preflight["contract"]: + raise ContractError( + f"checkpoint contract does not match compiled Fight Caves: {marker}" + ) + + +def checkpoint_format(checkpoint: Path, expected_raw_bytes: int) -> str | None: + if not checkpoint.is_file(): + return None + if checkpoint.stat().st_size == expected_raw_bytes: + return "raw" + try: + with checkpoint.open("rb") as handle: + if handle.read(4) == b"PK\x03\x04": + return "pytorch" + except OSError: + return None + return None + + +def compatible_checkpoint( + checkpoint: Path, + checkpoint_root: Path, + preflight: dict[str, Any], + expected_bytes: int, +) -> bool: + checkpoint_kind = checkpoint_format(checkpoint, expected_bytes) + if checkpoint_kind is None: + return False + marker = find_checkpoint_marker(checkpoint, checkpoint_root) + if marker is None: + return True + try: + validate_checkpoint_marker(marker, preflight) + except ContractError: + return False + return True + + +def resolve_checkpoint( + request_mode: str, + checkpoint_root: str | Path, + preflight: dict[str, Any], + expected_bytes: int, + checkpoint_path: str | Path | None = None, +) -> dict[str, Any]: + root = Path(checkpoint_root).resolve() + if request_mode == "explicit": + if checkpoint_path is None: + raise ContractError("explicit checkpoint replay requires a path") + checkpoint = Path(checkpoint_path).expanduser().resolve() + if not checkpoint.is_file(): + raise ContractError(f"checkpoint is unavailable: {checkpoint}") + checkpoint_kind = checkpoint_format(checkpoint, expected_bytes) + if checkpoint_kind is None: + raise ContractError( + "checkpoint is neither a compatible raw-weight file nor a " + "PyTorch state dictionary: " + f"expected_raw_bytes={expected_bytes}, " + f"actual_bytes={checkpoint.stat().st_size}" + ) + marker = find_checkpoint_marker(checkpoint, root) + if marker is not None: + validate_checkpoint_marker(marker, preflight) + return { + "resolved_path": str(checkpoint), + "sidecar_path": marker, + "format": checkpoint_kind, + } + + if request_mode != "latest": + raise ContractError(f"unsupported checkpoint request: {request_mode!r}") + if not root.is_dir(): + raise ContractError(f"checkpoint root is unavailable: {root}") + candidates = [ + checkpoint + for checkpoint in root.rglob("*.bin") + if compatible_checkpoint(checkpoint, root, preflight, expected_bytes) + ] + if not candidates: + raise ContractError( + f"no compatible checkpoint found under {root} for Fight Caves" + ) + checkpoint = max(candidates, key=lambda path: (path.stat().st_mtime_ns, str(path))) + marker = find_checkpoint_marker(checkpoint, root) + return { + "resolved_path": str(checkpoint), + "sidecar_path": marker, + "format": checkpoint_format(checkpoint, expected_bytes), + } + + +# Policy replay + +def script_dir(): + return os.path.dirname(os.path.abspath(__file__)) + + +def repo_root(): + return os.path.abspath(os.path.join(script_dir(), "..", "..")) + + +def ensure_local_pufferlib_on_path(): + default_puffer_dir = repo_root() + puffer_dir = os.environ.get("PUFFERLIB_DIR", default_puffer_dir) + if os.path.isdir(puffer_dir) and puffer_dir not in sys.path: + sys.path.insert(0, puffer_dir) + return puffer_dir + + +def find_compiled_backend(puffer_dir=None): + override = os.environ.get("FC_COMPILED_BACKEND_PATH") + if override: + if os.path.isfile(override): + return override + raise RuntimeError(f"FC_COMPILED_BACKEND_PATH is not a file: {override}") + + puffer_dir = puffer_dir or ensure_local_pufferlib_on_path() + extension_suffix = sysconfig.get_config_var("EXT_SUFFIX") or "" + preferred = os.path.join(puffer_dir, "pufferlib", f"_C{extension_suffix}") + if os.path.isfile(preferred): + return preferred + candidates = [] + for pattern in ("_C*.so", "_C*.dylib", "_C*.pyd"): + candidates.extend(glob.glob(os.path.join(puffer_dir, "pufferlib", pattern))) + if not candidates: + raise RuntimeError( + f"compiled Puffer backend not found under {puffer_dir}/pufferlib" + ) + return max(candidates, key=os.path.getmtime) + + +def load_evaluator_preflight(backend_path): + + source_config = os.environ.get( + "CONFIG_PATH", os.path.join(repo_root(), "config", "fight_caves.ini") + ) + default_config = os.path.join(repo_root(), "config", "default.ini") + active_loadout = os.environ.get("FC_ACTIVE_LOADOUT", "FC_LOADOUT_SOTA_TBOW") + return build_verified_preflight( + backend_path, source_config, default_config, active_loadout + ) + + +def expected_parameter_bytes(contract): + source_config = os.environ.get( + "CONFIG_PATH", os.path.join(repo_root(), "config", "fight_caves.ini") + ) + default_config = os.path.join(repo_root(), "config", "default.ini") + + return expected_checkpoint_parameter_bytes( + contract, source_config, default_config + ) + + +def verify_runtime_assets(): + preflight = os.path.join( + repo_root(), "ocean", "fight_caves", "tools.py" + ) + result = subprocess.run( + [sys.executable, preflight, "preflight", "--mode", "viewer-runtime"], + cwd=repo_root(), + check=False, + ) + if result.returncode != 0: + raise RuntimeError("Fight Caves runtime/viewer asset preflight failed") + + +def checkpoint_diagnostic(reason, checkpoint_path, expected_bytes, contract): + actual_bytes = ( + os.path.getsize(checkpoint_path) + if checkpoint_path and os.path.isfile(checkpoint_path) + else "missing" + ) + return ( + f"checkpoint rejected: {reason}\n" + f"expected_policy_obs={contract['policy_obs_size']} " + f"actual_policy_obs={contract['policy_obs_size']}\n" + f"expected_puffer_obs={contract['puffer_obs_size']} " + f"actual_puffer_obs={contract['puffer_obs_size']}\n" + f"expected_action_dims={contract['puffer_action_dims']} " + f"actual_action_dims={contract['puffer_action_dims']}\n" + f"expected_parameter_bytes={expected_bytes} " + f"actual_parameter_bytes={actual_bytes}\n" + f"observation_version={contract['observation_version']}\n" + f"action_version={contract['action_version']}\n" + f"reward_version={contract['reward_version']}\n" + f"prayer_timing_version={contract['prayer_timing_version']}\n" + f"state_hash_version={contract['state_hash_version']}" + ) + + +def latest_source_mtime(): + repo = repo_root() + patterns = [ + os.path.join(repo, "ocean", "fight_caves", "*.h"), + os.path.join(repo, "ocean", "fight_caves", "*.c"), + ] + files = [] + for pattern in patterns: + files.extend(glob.glob(pattern)) + return max((os.path.getmtime(path) for path in files), default=0.0) + + +def find_viewer(): + """Find the fc_viewer binary.""" + override = os.environ.get("FC_VIEWER_PATH") + if override: + if os.path.isfile(override): + return override + raise RuntimeError(f"FC_VIEWER_PATH does not point to a file: {override}") + + repo = repo_root() + source_mtime = latest_source_mtime() + preferred = [ + os.path.join(repo, "build", "fight_caves-viewer", "fc_viewer"), + ] + candidates = [path for path in preferred if os.path.isfile(path)] + + patterns = [ + os.path.join(repo, "build*", "fight_caves-viewer", "fc_viewer"), + ] + for pattern in patterns: + candidates.extend(glob.glob(pattern)) + candidates = [path for path in candidates if os.path.isfile(path)] + if not candidates: + return None + + seen = set() + unique = [] + for path in candidates: + if path in seen: + continue + seen.add(path) + unique.append(path) + + for path in unique: + if os.path.getmtime(path) >= source_mtime: + return path + return max(unique, key=os.path.getmtime) + + +def read_obs_line(proc, total_line_floats): + import numpy as np + """Read one line of space-separated floats from viewer stdout.""" + line = proc.stdout.readline() + if not line: + return None + values = line.strip().split() + if len(values) != total_line_floats: + print(f"[eval] Warning: expected {total_line_floats} floats, got {len(values)}", + file=sys.stderr) + return None + return np.array([float(v) for v in values], dtype=np.float32) + + +def send_actions(proc, actions): + """Write one action per Puffer action head to viewer stdin.""" + line = " ".join(str(int(a)) for a in actions) + "\n" + proc.stdin.write(line) + proc.stdin.flush() + + +def sample_masked(logits_list, mask, act_dims, deterministic=False): + import numpy as np + """Sample actions from logits with mask applied.""" + actions = [] + mask_offset = 0 + for head_idx, (logits, dim) in enumerate(zip(logits_list, act_dims)): + head_mask = mask[mask_offset:mask_offset + dim] + mask_offset += dim + + # Apply mask: set invalid actions to -inf + masked_logits = logits.copy() + for i in range(dim): + if head_mask[i] < 0.5: + masked_logits[i] = -1e9 + + if deterministic: + action = np.argmax(masked_logits) + else: + # Softmax + sample + logits_shifted = masked_logits - np.max(masked_logits) + probs = np.exp(logits_shifted) + probs = probs / (probs.sum() + 1e-8) + action = np.random.choice(dim, p=probs) + + actions.append(action) + return actions + + +def load_policy_weights(policy, checkpoint_path, checkpoint_kind, parameter_bytes): + import numpy as np + import torch + + if checkpoint_kind == "pytorch": + state_dict = torch.load( + checkpoint_path, map_location="cpu", weights_only=True + ) + if not isinstance(state_dict, dict) or not state_dict: + raise RuntimeError("PyTorch checkpoint does not contain a state dictionary") + state_dict = { + key.removeprefix("module."): value for key, value in state_dict.items() + } + expected = policy.state_dict() + if set(state_dict) != set(expected): + missing = sorted(set(expected) - set(state_dict)) + extra = sorted(set(state_dict) - set(expected)) + raise RuntimeError( + "PyTorch checkpoint keys do not match the configured policy: " + f"missing={missing[:1]}, extra={extra[:1]}" + ) + for key, tensor in state_dict.items(): + if not isinstance(tensor, torch.Tensor): + raise RuntimeError(f"PyTorch checkpoint value is not a tensor: {key}") + if tensor.shape != expected[key].shape: + raise RuntimeError( + f"PyTorch checkpoint shape mismatch for {key}: " + f"expected={list(expected[key].shape)}, actual={list(tensor.shape)}" + ) + policy.load_state_dict(state_dict, strict=True) + print( + f"[eval] Loaded PyTorch state dictionary ({len(state_dict)} tensors)", + file=sys.stderr, + ) + return + + if checkpoint_kind != "raw": + raise RuntimeError(f"unsupported checkpoint format: {checkpoint_kind!r}") + + # The CUDA trainer saves a flat float32 buffer in this order: + # encoder.weight, fused decoder/action+value weight, and recurrent layers. + weights = np.fromfile(checkpoint_path, dtype=np.float32) + print(f"[eval] Checkpoint: {len(weights)} floats", file=sys.stderr) + state_dict = policy.state_dict() + for key in state_dict: + if "bias" in key: + state_dict[key] = torch.zeros_like(state_dict[key]) + + offset = 0 + + def load_tensor(key): + nonlocal offset + if key not in state_dict: + raise KeyError(f"{key} not in model state_dict") + numel = state_dict[key].numel() + if offset + numel > len(weights): + raise RuntimeError(f"weights exhausted at {key}") + state_dict[key] = torch.from_numpy( + weights[offset:offset + numel].reshape(state_dict[key].shape).copy() + ) + offset += numel + print( + f" loaded {key}: {list(state_dict[key].shape)} ({numel})", + file=sys.stderr, + ) + + load_tensor("encoder.encoder.weight") + decoder_key = "decoder.decoder.weight" + value_key = "decoder.value_function.weight" + if decoder_key not in state_dict or value_key not in state_dict: + raise KeyError("decoder weights missing from model state_dict") + + decoder_rows = state_dict[decoder_key].shape[0] + hidden_size = state_dict[decoder_key].shape[1] + value_rows = state_dict[value_key].shape[0] + fused_rows = decoder_rows + value_rows + fused_numel = fused_rows * hidden_size + if offset + fused_numel > len(weights): + raise RuntimeError("weights exhausted at fused decoder") + fused_decoder = weights[offset:offset + fused_numel].reshape( + fused_rows, hidden_size + ).copy() + state_dict[decoder_key] = torch.from_numpy(fused_decoder[:decoder_rows]) + state_dict[value_key] = torch.from_numpy(fused_decoder[decoder_rows:]) + offset += fused_numel + print( + f" loaded fused decoder: {list(fused_decoder.shape)} " + f"-> {list(state_dict[decoder_key].shape)} + " + f"{list(state_dict[value_key].shape)}", + file=sys.stderr, + ) + + network_keys = sorted( + [ + key for key in state_dict + if key.startswith("network.layers.") and key.endswith(".weight") + ], + key=lambda key: int(key.split(".")[2]), + ) + model_parameter_floats = ( + state_dict["encoder.encoder.weight"].numel() + + state_dict[decoder_key].numel() + + state_dict[value_key].numel() + + sum(state_dict[key].numel() for key in network_keys) + ) + model_parameter_bytes = model_parameter_floats * np.dtype(np.float32).itemsize + if model_parameter_bytes != parameter_bytes: + raise RuntimeError( + "constructed model/raw layout mismatch: " + f"expected_parameter_bytes={parameter_bytes}, " + f"actual_parameter_bytes={model_parameter_bytes}" + ) + for key in network_keys: + load_tensor(key) + + policy.load_state_dict(state_dict) + if offset != len(weights): + raise RuntimeError( + f"unused supplied weights: loaded={offset}, actual={len(weights)}" + ) + print(f"[eval] Loaded {offset}/{len(weights)} raw weights", file=sys.stderr) + + +def eval_main(): + import numpy as np + # Parse our args FIRST, then clear sys.argv so PufferLib's + # load_config() doesn't choke on our flags. + parser = argparse.ArgumentParser(description="Watch trained policy in debug viewer") + parser.add_argument("--ckpt", type=str, default="latest", + help="Path to .bin checkpoint or 'latest'") + parser.add_argument("--deterministic", action="store_true", + help="Use argmax instead of sampling") + parser.add_argument("--random", action="store_true", + help="Use random valid actions (no checkpoint needed)") + parser.add_argument("--start-wave", type=int, default=0, + help="Start at this wave (0 = wave 1)") + parser.add_argument("--speed", type=int, choices=[1, 2, 4, 10], default=1, + help="Initial replay speed multiplier (buttons can switch to TPS presets)") + parser.add_argument("--episodes", type=int, default=0, + help="Stop after this many replay episodes (0 = unlimited)") + parser.add_argument("--max-ticks", type=int, default=0, + help=argparse.SUPPRESS) + args = parser.parse_args() + # Clear sys.argv so PufferLib doesn't see our flags + sys.argv = [sys.argv[0]] + puffer_dir = ensure_local_pufferlib_on_path() + + try: + verify_runtime_assets() + backend_path = find_compiled_backend(puffer_dir) + verified_preflight = load_evaluator_preflight(backend_path) + except Exception as exc: + print(f"Error: evaluator compiled-contract preflight failed: {exc}", file=sys.stderr) + return 1 + contract = verified_preflight["contract"] + policy_obs_size = contract["policy_obs_size"] + act_dims = contract["puffer_action_dims"] + mask_size = contract["puffer_mask_size"] + total_line_floats = contract["puffer_obs_size"] + + # Find viewer binary + viewer_path = find_viewer() + if not viewer_path: + print( + "Error: fc_viewer binary not found. Build with: " + "./build.sh fight_caves --viewer", + file=sys.stderr, + ) + sys.exit(1) + if os.path.getmtime(viewer_path) < latest_source_mtime(): + print( + f"Error: selected fc_viewer is older than current core/viewer sources: {viewer_path}", + file=sys.stderr, + ) + print( + "Rebuild it first with: ./build.sh fight_caves --viewer", + file=sys.stderr, + ) + sys.exit(1) + print(f"[eval] Viewer: {viewer_path}", file=sys.stderr) + print(f"[eval] Replay speed: {args.speed}x", file=sys.stderr) + if args.episodes > 0: + print(f"[eval] Episode limit: {args.episodes}", file=sys.stderr) + print( + f"[eval] Contract: policy_obs={policy_obs_size} mask={mask_size} " + f"heads={len(act_dims)} total={total_line_floats}", + file=sys.stderr, + ) + + # Load checkpoint (unless --random) + policy = None + if not args.random: + try: + parameter_bytes = expected_parameter_bytes(contract) + except Exception as exc: + print(f"Error: cannot derive expected checkpoint size: {exc}", file=sys.stderr) + return 1 + + + request_mode = "latest" if args.ckpt == "latest" else "explicit" + checkpoint_root = os.environ.get( + "FC_CHECKPOINT_ROOT", + os.path.join(repo_root(), "checkpoints"), + ) + try: + resolution = resolve_checkpoint( + request_mode, + checkpoint_root, + verified_preflight, + parameter_bytes, + checkpoint_path=None if request_mode == "latest" else args.ckpt, + ) + except ContractError as exc: + print( + checkpoint_diagnostic( + exc, None if args.ckpt == "latest" else args.ckpt, + parameter_bytes, contract, + ), + file=sys.stderr, + ) + return 1 + + checkpoint_path = resolution["resolved_path"] + checkpoint_kind = resolution["format"] + print( + f"[eval] Checkpoint: {checkpoint_path} ({checkpoint_kind})", + file=sys.stderr, + ) + + try: + import torch + import pufferlib.models + from pufferlib.pufferl import load_config + + eval_args = load_config("fight_caves") + policy_kwargs = eval_args["policy"] + network_cls = getattr(pufferlib.models, eval_args["torch"]["network"]) + encoder_cls = getattr(pufferlib.models, eval_args["torch"]["encoder"]) + decoder_cls = getattr(pufferlib.models, eval_args["torch"]["decoder"]) + + network = network_cls(**policy_kwargs) + encoder = encoder_cls(total_line_floats, policy_kwargs["hidden_size"]) + decoder = decoder_cls(act_dims, policy_kwargs["hidden_size"]) + policy = pufferlib.models.Policy(encoder, decoder, network) + policy = policy.cpu() + load_policy_weights( + policy, checkpoint_path, checkpoint_kind, parameter_bytes + ) + + policy = policy.cpu() + policy.eval() + print("[eval] Policy ready (CPU)", file=sys.stderr) + + except Exception as exc: + print( + checkpoint_diagnostic( + exc, checkpoint_path, parameter_bytes, contract + ), + file=sys.stderr, + ) + return 1 + + # Launch viewer subprocess from repo root so sprite paths resolve + print("[eval] Launching viewer...", file=sys.stderr) + viewer_env = os.environ.copy() + viewer_env.setdefault( + "FC_ASSET_ROOT", + os.path.join(repo_root(), "resources", "fight_caves", "viewer"), + ) + viewer_env.setdefault("FC_REPO_ROOT", repo_root()) + proc = subprocess.Popen( + [viewer_path, "--policy-pipe", "--speed", str(args.speed)] + + (["--episodes", str(args.episodes)] if args.episodes > 0 else []) + + (["--start-wave", str(args.start_wave)] if args.start_wave > 0 else []), + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=None, # let viewer stderr pass through to terminal + text=True, + bufsize=1, + cwd=repo_root(), + env=viewer_env, + ) + + # Hidden state for recurrent policy (MinGRU) + hidden = None + if policy is not None: + import torch + hidden = policy.initial_state(1, 'cpu') + + try: + tick = 0 + while True: + # Read observation from viewer + obs_data = read_obs_line(proc, total_line_floats) + if obs_data is None: + print("[eval] Viewer closed or read error", file=sys.stderr) + break + + obs = obs_data[:policy_obs_size] + mask = obs_data[policy_obs_size:] + + if args.random or policy is None: + # Random valid actions + actions = sample_masked( + [np.zeros(d) for d in act_dims], mask, act_dims, deterministic=False) + else: + # Policy inference: feed the same Puffer observation used in training. + import torch + with torch.no_grad(): + full_input = torch.from_numpy(obs_data).unsqueeze(0) + output = policy.forward_eval(full_input, hidden) + # forward_eval returns (logits, values, state) + logits_raw, _values, hidden = output + + # Extract per-head logits + if isinstance(logits_raw, (list, tuple)): + logits_list = [l.squeeze(0).numpy() for l in logits_raw] + else: + # Single tensor — split by action dims + lr = logits_raw.squeeze(0).numpy() + logits_list = [] + off = 0 + for d in act_dims: + logits_list.append(lr[off:off+d]) + off += d + + actions = sample_masked(logits_list, mask, act_dims, args.deterministic) + + # Send actions to viewer + send_actions(proc, actions) + tick += 1 + + if tick % 100 == 0: + print(f"[eval] Tick {tick}", file=sys.stderr) + if args.max_ticks > 0 and tick >= args.max_ticks: + print(f"[eval] Smoke limit reached at tick {tick}", file=sys.stderr) + break + + except (BrokenPipeError, KeyboardInterrupt): + print("[eval] Stopped", file=sys.stderr) + finally: + if proc.poll() is None: + proc.terminate() + proc.wait() + + +def play_main() -> int: + result = subprocess.run( + [sys.executable, __file__, "preflight", "--mode", "viewer-runtime"], + cwd=REPO_ROOT, + ) + if result.returncode: + return result.returncode + viewer = REPO_ROOT / "build/fight_caves-viewer/fc_viewer" + if not viewer.is_file() or not os.access(viewer, os.X_OK): + print(f"Fight Caves viewer is not built: {viewer}\n" + "Build it with: ./build.sh fight_caves --viewer", file=sys.stderr) + return 1 + os.chdir(REPO_ROOT) + os.execv(str(viewer), [str(viewer), *sys.argv[1:]]) + + +def main() -> int: + commands = {"setup": setup_main, "bundle": bundle_main, + "preflight": preflight_main, "play": play_main, "eval": eval_main} + if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"): + print("Usage: python3 ocean/fight_caves/tools.py " + "{setup,bundle,preflight,play,eval} [options]\n" + "Use COMMAND --help for command options (play forwards viewer options).") + return 0 if len(sys.argv) > 1 else 2 + command = sys.argv.pop(1) + if command not in commands: + print(f"Unknown Fight Caves command: {command}", file=sys.stderr) + return 2 + return commands[command]() or 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/ocean/fight_caves/ui.h b/ocean/fight_caves/ui.h new file mode 100644 index 0000000000..7570df925c --- /dev/null +++ b/ocean/fight_caves/ui.h @@ -0,0 +1,3538 @@ +#ifndef FIGHT_CAVES_UI_H +#define FIGHT_CAVES_UI_H + +/* Ui Assets */ + +#include "raylib.h" + +#define RUNEC_UI_ASSET_MAX 512 + +typedef struct RuneCUiAssets { + Texture2D textures[RUNEC_UI_ASSET_MAX]; + unsigned char loaded[RUNEC_UI_ASSET_MAX]; + Font font; + Font small_font; + int font_loaded; + int small_font_loaded; + int loaded_count; + int missing_count; + int required_count; + int missing_required_count; + int missing_optional_count; +} RuneCUiAssets; + +void runec_ui_assets_load(RuneCUiAssets *assets); +void runec_ui_assets_unload(RuneCUiAssets *assets); + +const Texture2D *runec_ui_asset(const RuneCUiAssets *assets, const char *name); +int runec_ui_asset_ready(const RuneCUiAssets *assets, const char *name); +void runec_ui_draw_asset(const RuneCUiAssets *assets, const char *name, + Rectangle dst, Color tint); +Font runec_ui_font(const RuneCUiAssets *assets); +Font runec_ui_font_for_size(const RuneCUiAssets *assets, float size); +void runec_ui_draw_text_shadow(const RuneCUiAssets *assets, const char *text, + float x, float y, float size, Color color); + + +/* Ui Reference */ + +#include "raylib.h" + +/* + * OSRS UI reference constants copied from the local rsmod/model_dump interface + * dumps. These mirror what Void/RSMod open server-side; the viewer should use + * these as layout authority instead of hand-tuned gameframe coordinates. + * + * Source files: + * - data/source/model_dump/osrs-dumps/interface/toplevel_osrs_stretch.if3 + * - data/source/model_dump/osrs-dumps/interface/orbs.if3 + * - data/source/model_dump/osrs-dumps/interface/stats.if3 + * - data/source/model_dump/osrs-dumps/interface/inventory.if3 + * - data/source/model_dump/osrs-dumps/interface/combat_interface.if3 + * - data/source/model_dump/osrs-dumps/interface/wornitems.if3 + */ + +#define RUNEC_IFACE_TOPLEVEL_OSRS_STRETCH 161 +#define RUNEC_IFACE_ORBS 160 +#define RUNEC_IFACE_INVENTORY 149 +#define RUNEC_IFACE_STATS 320 +#define RUNEC_IFACE_WORNITEMS 387 +#define RUNEC_IFACE_PRAYERBOOK 541 +#define RUNEC_IFACE_MAGIC_SPELLBOOK 218 +#define RUNEC_IFACE_COMBAT_INTERFACE 593 + +#define RUNEC_OSRS_MAP_CONTAINER_W 211.0f +#define RUNEC_OSRS_MAP_CONTAINER_H 207.0f +#define RUNEC_OSRS_MINIMAP_X 53.0f +#define RUNEC_OSRS_MINIMAP_Y 8.0f +#define RUNEC_OSRS_MINIMAP_W 152.0f +#define RUNEC_OSRS_MINIMAP_H 152.0f +#define RUNEC_OSRS_MAP_SURROUND_X 29.0f +#define RUNEC_OSRS_MAP_SURROUND_Y 0.0f +#define RUNEC_OSRS_MAP_SURROUND_W 182.0f +#define RUNEC_OSRS_MAP_SURROUND_H 166.0f +#define RUNEC_OSRS_COMPASS_X 34.0f +#define RUNEC_OSRS_COMPASS_Y 5.0f +#define RUNEC_OSRS_COMPASS_W 35.0f +#define RUNEC_OSRS_COMPASS_H 35.0f + +#define RUNEC_OSRS_ORBS_X 0.0f +#define RUNEC_OSRS_ORBS_Y 10.0f +#define RUNEC_OSRS_ORBS_W 207.0f +#define RUNEC_OSRS_ORBS_H 197.0f +#define RUNEC_OSRS_HP_X 0.0f +#define RUNEC_OSRS_HP_Y 37.0f +#define RUNEC_OSRS_PRAYER_X 0.0f +#define RUNEC_OSRS_PRAYER_Y 71.0f +#define RUNEC_OSRS_RUN_X 10.0f +#define RUNEC_OSRS_RUN_Y 103.0f +#define RUNEC_OSRS_SPEC_X 32.0f +#define RUNEC_OSRS_SPEC_Y 128.0f +#define RUNEC_OSRS_WORLDMAP_X 177.0f +#define RUNEC_OSRS_WORLDMAP_Y 137.0f + +#define RUNEC_OSRS_CHAT_W 519.0f +#define RUNEC_OSRS_CHAT_H 165.0f +#define RUNEC_OSRS_SIDE_MENU_W 241.0f +#define RUNEC_OSRS_SIDE_MENU_H 335.0f +#define RUNEC_OSRS_SIDE_CONTENT_X 25.0f +#define RUNEC_OSRS_SIDE_CONTENT_Y 37.0f +#define RUNEC_OSRS_SIDE_CONTENT_W 190.0f +#define RUNEC_OSRS_SIDE_CONTENT_H 261.0f +#define RUNEC_OSRS_SIDE_TOP_Y 0.0f +#define RUNEC_OSRS_SIDE_BOTTOM_Y 298.0f + +#define RUNEC_OSRS_INVENTORY_SLOT_COLS 4 +#define RUNEC_OSRS_INVENTORY_SLOT_COUNT 28 +#define RUNEC_OSRS_INVENTORY_SLOT_X 14.0f +#define RUNEC_OSRS_INVENTORY_SLOT_Y 8.0f +#define RUNEC_OSRS_INVENTORY_SLOT_STEP_X 42.0f +#define RUNEC_OSRS_INVENTORY_SLOT_STEP_Y 36.0f +#define RUNEC_OSRS_INVENTORY_SLOT_W 32.0f +#define RUNEC_OSRS_INVENTORY_SLOT_H 32.0f + +typedef struct RuneCUiStoneRef { + int logical_tab; + const char *stone_asset; + const char *icon_asset; + Rectangle rect; + Rectangle icon_rect; +} RuneCUiStoneRef; + +static const RuneCUiStoneRef RUNEC_OSRS_SIDE_STONES[] = { + {0, "side_stone_highlights_0", "side_icon_combat", {0, 0, 38, 36}, {4, 0, 33, 36}}, + {1, "side_stone_highlights_4", "side_icon_stats", {38, 0, 33, 36}, {38, 0, 33, 36}}, + {2, "side_stone_highlights_4", "side_icon_quests", {71, 0, 38, 36}, {71, 0, 33, 36}}, + {3, "side_stone_highlights_4", "side_icon_inventory", {104, 0, 33, 36}, {104, 0, 33, 36}}, + {4, "side_stone_highlights_4", "side_icon_equipment", {137, 0, 33, 36}, {137, 0, 33, 36}}, + {5, "side_stone_highlights_4", "side_icon_prayer", {170, 0, 33, 36}, {170, 0, 33, 36}}, + {6, "side_stone_highlights_1", "side_icon_magic", {203, 0, 38, 36}, {204, 0, 33, 36}}, + {8, "side_stone_highlights_2", "side_icon_clan", {0, 0, 38, 36}, {4, 0, 33, 36}}, + {9, "side_stone_highlights_4", "side_icon_friends", {38, 0, 33, 36}, {38, 0, 33, 36}}, + {-1, "side_stone_highlights_4", "side_icon_grouping", {71, 0, 33, 36}, {71, 0, 33, 36}}, + {-1, "side_stone_highlights_4", "side_icon_logout", {104, 0, 33, 36}, {104, 0, 33, 36}}, + {7, "side_stone_highlights_4", "side_icon_options", {137, 0, 33, 36}, {137, 0, 33, 36}}, + {-1, "side_stone_highlights_4", "side_icon_emotes", {170, 0, 33, 36}, {170, 0, 33, 36}}, + {-1, "side_stone_highlights_3", "side_icon_music", {203, 0, 38, 36}, {204, 0, 33, 36}}, +}; + +typedef struct RuneCUiSkillRef { + const char *name; + int icon_index; + Rectangle rect; +} RuneCUiSkillRef; + +static const RuneCUiSkillRef RUNEC_OSRS_SKILLS[] = { + {"Attack", 0, {1, 1, 62, 30}}, + {"Strength", 1, {1, 31, 62, 30}}, + {"Defence", 2, {1, 61, 62, 30}}, + {"Ranged", 3, {1, 91, 62, 30}}, + {"Prayer", 4, {1, 121, 62, 30}}, + {"Magic", 5, {1, 151, 62, 30}}, + {"Runecraft", 18, {1, 181, 62, 30}}, + {"Construction", 22, {1, 211, 62, 32}}, + {"Hitpoints", 6, {64, 1, 62, 30}}, + {"Agility", 7, {64, 31, 62, 30}}, + {"Herblore", 8, {64, 61, 62, 30}}, + {"Thieving", 9, {64, 91, 62, 30}}, + {"Crafting", 10, {64, 121, 62, 30}}, + {"Fletching", 11, {64, 151, 62, 30}}, + {"Slayer", 19, {64, 181, 62, 30}}, + {"Hunter", 21, {64, 211, 62, 32}}, + {"Mining", 12, {127, 1, 62, 30}}, + {"Smithing", 13, {127, 31, 62, 30}}, + {"Fishing", 14, {127, 61, 62, 30}}, + {"Cooking", 15, {127, 91, 62, 30}}, + {"Firemaking", 16, {127, 121, 62, 30}}, + {"Woodcutting", 17, {127, 151, 62, 30}}, + {"Farming", 20, {127, 181, 62, 30}}, + {"Sailing", 23, {127, 211, 62, 32}}, +}; + +static const Rectangle RUNEC_OSRS_STATS_TOTAL = {0, 241, 190, 19}; + +typedef struct RuneCUiCombatStyleRef { + const char *label; + const char *mode; + const char *icon_asset; + int visible; + Rectangle rect; + Rectangle icon_rect; + Rectangle text_rect; +} RuneCUiCombatStyleRef; + +static const RuneCUiCombatStyleRef RUNEC_OSRS_COMBAT_STYLES[] = { + {"Flick", "Accurate", "sideicons_interface_0", 1, {20, 46, 68, 47}, {37, 51, 34, 24}, {20, 67, 68, 13}}, + {"Lash", "Controlled", "sideicons_interface_1", 1, {102, 46, 68, 47}, {119, 51, 34, 24}, {102, 76, 68, 13}}, + {"Deflect", "Defensive", "sideicons_interface_2", 1, {20, 99, 68, 47}, {37, 104, 34, 24}, {20, 129, 68, 13}}, + {"", "", "sideicons_interface_3", 0, {102, 99, 68, 47}, {119,104, 34, 24}, {102,129, 68, 13}}, +}; + +static const Rectangle RUNEC_OSRS_COMBAT_HEADER = {10, 0, 170, 44}; +static const Rectangle RUNEC_OSRS_COMBAT_TITLE = {10, 6, 170, 14}; +static const Rectangle RUNEC_OSRS_COMBAT_LEVEL = {10, 26, 170, 12}; +static const Rectangle RUNEC_OSRS_COMBAT_RETALIATE = {20, 153, 150, 47}; +static const Rectangle RUNEC_OSRS_COMBAT_RETALIATE_ICON = {27, 156, 26, 39}; +static const Rectangle RUNEC_OSRS_COMBAT_RETALIATE_TEXT = {58, 156, 108, 39}; +static const Rectangle RUNEC_OSRS_COMBAT_SPECIAL_BAR = {20, 200, 150, 26}; +static const Rectangle RUNEC_OSRS_COMBAT_CATEGORY = {0, 233, 190, 28}; + +typedef struct RuneCUiWornButtonRef { + const char *asset; + Rectangle rect; + Rectangle icon_rect; +} RuneCUiWornButtonRef; + +static const RuneCUiWornButtonRef RUNEC_OSRS_WORN_BUTTONS[] = { + {"options_icons_16", {7, 208, 40, 40}, {10, 210, 32, 32}}, + {"options_icons_28", {52, 208, 40, 40}, {56, 212, 32, 32}}, + {"options_icons_18", {97, 208, 40, 40}, {99, 211, 34, 34}}, + {"whistle", {142, 208, 40, 40}, {145, 211, 32, 32}}, +}; + + +/* Osrs Text */ + +#include "raylib.h" + +int fc_osrs_text_init(void); +void fc_osrs_text_shutdown(void); +void fc_osrs_draw_text(const char* text, int x, int y, int font_size, + Color color); +int fc_osrs_measure_text(const char* text, int font_size); + +#include "simulation.h" + +/* Minimap */ + +#include "raylib.h" + +#define FC_MINIMAP_DISPLAY_SIZE 152 +#define FC_MINIMAP_DISPLAY_CENTER 76.0f +#define FC_MINIMAP_DISPLAY_RADIUS 75.0f +#define FC_MINIMAP_DISPLAY_PIXELS_PER_TILE 3.5f +#define FC_MINIMAP_SCENE_SIZE 512 +#define FC_MINIMAP_SCENE_PIXELS_PER_TILE 4.0f +#define FC_MINIMAP_SCENE_BORDER_TILES 32.0f + +typedef struct FcMinimapScene { + Color* pixels; + int ready; +} FcMinimapScene; + +int fc_minimap_scene_load_pixels(FcMinimapScene* scene, + const Color* pixels, int width, int height); +void fc_minimap_scene_free(FcMinimapScene* scene); + +void fc_minimap_render(const FcMinimapScene* scene, float player_x, + float player_y, float camera_yaw, Color* output); + +Vector2 fc_minimap_rotate_offset(float dx, float dy, float camera_yaw); +int fc_minimap_click_to_tile(float map_x, float map_y, float player_x, + float player_y, float camera_yaw, + int* tile_x, int* tile_y); + + +/* Ui */ + +#include "raylib.h" +#include +#include + +#define RUNEC_UI_INV_SLOT_COUNT 28 +#define RUNEC_UI_EQUIP_SLOT_COUNT 14 +#define RUNEC_UI_CHAT_INPUT_MAX 128 +#define RUNEC_UI_CONTEXT_ACTIONS 5 +#define RUNEC_UI_MINIMAP_DOTS 256 +#define RUNEC_UI_ITEM_ICON_CACHE 512 +#define RUNEC_UI_SKILL_COUNT 24 +#define RUNEC_UI_COMBAT_STYLE_COUNT 4 + +typedef enum RuneCUiTab { + RUNEC_UI_TAB_NONE = -1, + RUNEC_UI_TAB_COMBAT = 0, + RUNEC_UI_TAB_SKILLS, + RUNEC_UI_TAB_QUESTS, + RUNEC_UI_TAB_INVENTORY, + RUNEC_UI_TAB_EQUIPMENT, + RUNEC_UI_TAB_PRAYER, + RUNEC_UI_TAB_SPELLBOOK, + RUNEC_UI_TAB_SETTINGS, + RUNEC_UI_TAB_CLAN_CHAT, + RUNEC_UI_TAB_FRIENDS, + RUNEC_UI_TAB_COUNT +} RuneCUiTab; + +typedef enum RuneCUiIntentKind { + RUNEC_UI_INTENT_NONE = 0, + RUNEC_UI_INTENT_TAB, + RUNEC_UI_INTENT_INVENTORY_SLOT, + RUNEC_UI_INTENT_EQUIPMENT_SLOT, + RUNEC_UI_INTENT_PRAYER_SLOT, + RUNEC_UI_INTENT_QUICK_PRAYER_SLOT, + RUNEC_UI_INTENT_QUICK_PRAYER_TOGGLE, + RUNEC_UI_INTENT_AUTOCAST_SPELL, + RUNEC_UI_INTENT_SKILL_SLOT, + RUNEC_UI_INTENT_MINIMAP_CLICK, + RUNEC_UI_INTENT_RUN_TOGGLE, + RUNEC_UI_INTENT_COMBAT_STYLE, + RUNEC_UI_INTENT_AUTO_RETALIATE, + RUNEC_UI_INTENT_SPECIAL_ATTACK, + RUNEC_UI_INTENT_CONTEXT_ACTION, + RUNEC_UI_INTENT_INVENTORY_ACTION, + RUNEC_UI_INTENT_EQUIPMENT_ACTION, + RUNEC_UI_INTENT_INVENTORY_DRAG, + RUNEC_UI_INTENT_SELECTED_ITEM, + RUNEC_UI_INTENT_SELECTED_SPELL, + RUNEC_UI_INTENT_SELECTED_ITEM_ON_ITEM, + RUNEC_UI_INTENT_SELECTED_SPELL_ON_ITEM, + RUNEC_UI_INTENT_SELECTED_TARGET_CANCEL +} RuneCUiIntentKind; + +typedef struct RuneCUiIntent { + RuneCUiIntentKind kind; + int primary; + int secondary; + Vector2 position; + char text[RUNEC_UI_CHAT_INPUT_MAX]; +} RuneCUiIntent; + +typedef struct RuneCUiSlot { + uint32_t item_id; + uint32_t icon_item_id; + int quantity; + char label[24]; + int enabled; +} RuneCUiSlot; + +typedef enum RuneCUiMinimapDotKind { + RUNEC_UI_MINIMAP_DOT_NPC = 0, + RUNEC_UI_MINIMAP_DOT_PLAYER, + RUNEC_UI_MINIMAP_DOT_DESTINATION +} RuneCUiMinimapDotKind; + +typedef struct RuneCUiMinimapDot { + float dx; + float dy; + RuneCUiMinimapDotKind kind; +} RuneCUiMinimapDot; + +typedef struct RuneCUiItemIcon { + uint32_t item_id; + Texture2D texture; + int ready; +} RuneCUiItemIcon; + +typedef struct RuneCUiCombatStyleOption { + int visible; + int style_index; + char label[24]; + char mode[32]; + char icon_asset[32]; +} RuneCUiCombatStyleOption; + +typedef enum RuneCUiContextSourceKind { + RUNEC_UI_CONTEXT_NONE = 0, + RUNEC_UI_CONTEXT_INVENTORY, + RUNEC_UI_CONTEXT_EQUIPMENT, + RUNEC_UI_CONTEXT_PRAYER, + RUNEC_UI_CONTEXT_SPELL +} RuneCUiContextSourceKind; + +typedef enum RuneCUiSelectedTargetKind { + RUNEC_UI_SELECTED_NONE = 0, + RUNEC_UI_SELECTED_ITEM, + RUNEC_UI_SELECTED_SPELL +} RuneCUiSelectedTargetKind; + +typedef struct RuneCUiSelectedTarget { + RuneCUiSelectedTargetKind kind; + int source_slot; + uint32_t source_item_id; + char label[48]; + char verb[24]; +} RuneCUiSelectedTarget; + +typedef struct RuneCUiDragState { + int active; + RuneCUiContextSourceKind source_kind; + int source_slot; + Vector2 start; +} RuneCUiDragState; + +typedef struct RuneCUiState { + RuneCUiTab active_tab; + RuneCUiIntent last_intent; + float tab_press_timer[RUNEC_UI_TAB_COUNT]; + + RuneCUiSlot inventory[RUNEC_UI_INV_SLOT_COUNT]; + RuneCUiSlot equipment[RUNEC_UI_EQUIP_SLOT_COUNT]; + int selected_inventory_slot; + int selected_equipment_slot; + int selected_combat_style; + int auto_retaliate; + int special_attack_enabled; + int special_attack_energy; + int combat_weapon_category; + char combat_weapon_name[64]; + RuneCUiCombatStyleOption combat_styles[RUNEC_UI_COMBAT_STYLE_COUNT]; + + int hitpoints; + int hitpoints_max; + int prayer_points; + int prayer_points_max; + uint32_t active_prayers; + int run_energy; + int run_enabled; + int combat_level; + int skill_current[RUNEC_UI_SKILL_COUNT]; + int skill_base[RUNEC_UI_SKILL_COUNT]; + int skill_total; + + int context_open; + Vector2 context_pos; + char context_title[48]; + char context_actions[RUNEC_UI_CONTEXT_ACTIONS][32]; + int context_action_count; + RuneCUiContextSourceKind context_source_kind; + int context_source_slot; + uint32_t context_source_item_id; + + RuneCUiSelectedTarget selected_target; + RuneCUiDragState drag; + + RuneCUiMinimapDot minimap_dots[RUNEC_UI_MINIMAP_DOTS]; + int minimap_dot_count; + float minimap_rotation; + Texture2D minimap_texture; + int minimap_texture_ready; + RuneCUiItemIcon item_icons[RUNEC_UI_ITEM_ICON_CACHE]; + int item_icon_count; + + RuneCUiAssets assets; +} RuneCUiState; + +void runec_ui_init(RuneCUiState *ui); +void runec_ui_shutdown(RuneCUiState *ui); +void runec_ui_clear_minimap(RuneCUiState *ui); +void runec_ui_add_minimap_dot(RuneCUiState *ui, float dx, float dy, + RuneCUiMinimapDotKind kind); +void runec_ui_update_minimap(RuneCUiState *ui, const Color *pixels, + int width, int height); +void runec_ui_set_minimap_rotation(RuneCUiState *ui, float radians); +void runec_ui_set_item_icon(RuneCUiState *ui, uint32_t icon_item_id, Texture2D texture); +void runec_ui_set_combat_weapon_name(RuneCUiState *ui, const char *name); +void runec_ui_set_combat_style_profile(RuneCUiState *ui, int core_weapon_category); +void runec_ui_clear_selected_target(RuneCUiState *ui); +int runec_ui_handle_input(RuneCUiState *ui, int screen_w, int screen_h); +void runec_ui_draw(RuneCUiState *ui, int screen_w, int screen_h); +Rectangle runec_ui_chat_panel_rect(int screen_w, int screen_h); +const char *runec_ui_tab_name(RuneCUiTab tab); + +#include "assets.h" + +/* Ui Assets */ +#include +#include + +typedef struct RuneCUiAssetSpec { + const char *name; + const char *file; + int required; +} RuneCUiAssetSpec; + +#define UI_ASSET(name) { name, name ".png", 1 } +#define UI_OPTIONAL_ASSET(name, file) { name, file, 0 } + +static const RuneCUiAssetSpec g_ui_asset_specs[] = { + UI_ASSET("tradebacking_dark"), + UI_ASSET("chatbox_bg"), + UI_ASSET("main_stones_bottom"), + UI_ASSET("side_background"), + UI_ASSET("side_background_bottom"), + UI_ASSET("side_background_left1"), + UI_ASSET("side_background_left2"), + UI_ASSET("side_background_right"), + UI_ASSET("side_background_top"), + UI_ASSET("side_stone_highlights_0"), + UI_ASSET("side_stone_highlights_1"), + UI_ASSET("side_stone_highlights_2"), + UI_ASSET("side_stone_highlights_3"), + UI_ASSET("side_stone_highlights_4"), + UI_ASSET("osrs_stretch_side_topbottom_0"), + UI_ASSET("osrs_stretch_side_topbottom_1"), + UI_ASSET("osrs_stretch_side_columns_0"), + UI_ASSET("osrs_stretch_side_columns_1"), + UI_ASSET("compass"), + UI_ASSET("mini_left"), + UI_ASSET("mini_right"), + UI_ASSET("mini_topright"), + UI_ASSET("osrs_stretch_mapsurround"), + UI_ASSET("resize_map_mask"), + UI_ASSET("resize_compass_mask"), + UI_ASSET("fixed_minimap_cover"), + UI_ASSET("fixed_map_mask"), + UI_ASSET("fixed_compass_mask"), + UI_ASSET("fixed_map_clickmask"), + UI_ASSET("mini_bottom"), + UI_ASSET("compass_outline"), + UI_ASSET("border_map_compass"), + UI_ASSET("side_icon_combat"), + UI_ASSET("side_icon_stats"), + UI_ASSET("side_icon_quests"), + UI_ASSET("side_icon_inventory"), + UI_ASSET("side_icon_equipment"), + UI_ASSET("side_icon_prayer"), + UI_ASSET("side_icon_magic"), + UI_ASSET("side_icon_clan"), + UI_ASSET("side_icon_friends"), + UI_ASSET("side_icon_options"), + UI_ASSET("side_icon_logout"), + UI_ASSET("side_icon_emotes"), + UI_ASSET("side_icon_music"), + UI_ASSET("side_icon_grouping"), + UI_ASSET("side_icon_logout_modern"), + UI_ASSET("options_icons_16"), + UI_ASSET("options_icons_18"), + UI_ASSET("options_icons_28"), + UI_ASSET("whistle"), + UI_ASSET("orb_frame_0"), + UI_ASSET("orb_frame_1"), + UI_ASSET("orb_frame_2"), + UI_ASSET("orb_filler_0"), + UI_ASSET("orb_filler_1"), + UI_ASSET("orb_filler_4"), + UI_ASSET("orb_filler_5"), + UI_ASSET("orb_filler_6"), + UI_ASSET("orb_filler_9"), + UI_ASSET("orb_icon_0"), + UI_ASSET("orb_icon_1"), + UI_ASSET("orb_icon_2"), + UI_ASSET("orb_icon_3"), + UI_ASSET("orb_icon_6"), + UI_ASSET("tli_button01_orbinfo_65x34_0"), + UI_ASSET("tli_button01_orbinfo_65x34_1"), + UI_ASSET("tli_button01_orbinfo_65x34_2"), + UI_ASSET("ring_30"), + UI_ASSET("worldmap_icon_0"), + UI_ASSET("chat_tab_button_0"), + UI_ASSET("wornicons_0"), + UI_ASSET("wornicons_1"), + UI_ASSET("wornicons_2"), + UI_ASSET("wornicons_3"), + UI_ASSET("wornicons_4"), + UI_ASSET("wornicons_5"), + UI_ASSET("wornicons_6"), + UI_ASSET("wornicons_7"), + UI_ASSET("wornicons_8"), + UI_ASSET("wornicons_9"), + UI_ASSET("wornicons_10"), + UI_ASSET("wornicons_11"), + UI_ASSET("miscgraphics_2"), + UI_ASSET("miscgraphics_3"), + UI_ASSET("combatboxes_0"), + UI_ASSET("combatboxes_1"), + UI_ASSET("combatboxes_2"), + UI_ASSET("combatboxes_3"), + UI_ASSET("combatboxes_large_0"), + UI_ASSET("combatboxes_large_1"), + UI_ASSET("combatboxes_very_large_0"), + UI_ASSET("combatboxes_very_large_1"), + UI_ASSET("combatboxes_special_attack"), + UI_ASSET("combat_shield"), + UI_ASSET("combaticons_0"), + UI_ASSET("combaticons_1"), + UI_ASSET("combaticons_2"), + UI_ASSET("combaticons_3"), + UI_ASSET("combaticons_4"), + UI_ASSET("combaticons_5"), + UI_ASSET("combaticons_6"), + UI_ASSET("combaticons_7"), + UI_ASSET("combaticons_8"), + UI_ASSET("combaticons_9"), + UI_ASSET("combaticons_10"), + UI_ASSET("combaticons_11"), + UI_ASSET("combaticons_12"), + UI_ASSET("combaticons_13"), + UI_ASSET("combaticons_14"), + UI_ASSET("combaticons_15"), + UI_ASSET("combaticons_16"), + UI_ASSET("combaticons_17"), + UI_ASSET("combaticons_18"), + UI_ASSET("combaticons_19"), + UI_ASSET("combaticons2_0"), + UI_ASSET("combaticons2_1"), + UI_ASSET("combaticons2_2"), + UI_ASSET("combaticons2_3"), + UI_ASSET("combaticons2_4"), + UI_ASSET("combaticons2_5"), + UI_ASSET("combaticons2_6"), + UI_ASSET("combaticons2_7"), + UI_ASSET("combaticons2_8"), + UI_ASSET("combaticons2_9"), + UI_ASSET("combaticons2_10"), + UI_ASSET("combaticons2_11"), + UI_ASSET("combaticons2_12"), + UI_ASSET("combaticons2_13"), + UI_ASSET("combaticons2_14"), + UI_ASSET("combaticons2_15"), + UI_ASSET("combaticons2_16"), + UI_ASSET("combaticons2_17"), + UI_ASSET("combaticons2_18"), + UI_ASSET("combaticons2_19"), + UI_ASSET("combaticons3_0"), + UI_ASSET("combaticons3_1"), + UI_ASSET("combaticons3_2"), + UI_ASSET("combaticons3_3"), + UI_ASSET("combaticons3_4"), + UI_ASSET("combaticons3_5"), + UI_ASSET("combaticons3_6"), + UI_ASSET("combaticons3_7"), + UI_ASSET("combaticons3_8"), + UI_ASSET("combaticons3_9"), + UI_ASSET("combaticons3_10"), + UI_ASSET("combaticons3_11"), + UI_ASSET("combaticons3_12"), + UI_ASSET("combaticons3_13"), + UI_ASSET("combaticons3_14"), + UI_ASSET("combaticons3_15"), + UI_ASSET("combaticons3_16"), + UI_ASSET("combaticons3_17"), + UI_ASSET("combaticons3_18"), + UI_ASSET("combaticons3_19"), + UI_ASSET("sideicons_interface_0"), + UI_ASSET("sideicons_interface_1"), + UI_ASSET("sideicons_interface_2"), + UI_ASSET("sideicons_interface_3"), + UI_ASSET("sideicons_interface_4"), + UI_ASSET("sideicons_interface_5"), + UI_ASSET("sideicons_interface_6"), + UI_ASSET("sideicons_interface_7"), + UI_ASSET("sideicons_interface_8"), + UI_ASSET("sideicons_interface_9"), + UI_ASSET("sideicons_interface_10"), + UI_ASSET("sideicons_interface_11"), + UI_ASSET("sideicons_interface_12"), + UI_ASSET("sideicons_interface_13"), + UI_ASSET("sideicons_interface_14"), + UI_ASSET("sideicons_interface_15"), + UI_ASSET("sideicons_interface_16"), + UI_OPTIONAL_ASSET("item_995", "../items/item_995.png"), + UI_OPTIONAL_ASSET("item_995_1", "../items/item_995_1.png"), + UI_OPTIONAL_ASSET("item_995_2", "../items/item_995_2.png"), + UI_OPTIONAL_ASSET("item_995_3", "../items/item_995_3.png"), + UI_OPTIONAL_ASSET("item_995_4", "../items/item_995_4.png"), + UI_OPTIONAL_ASSET("item_995_5", "../items/item_995_5.png"), + UI_OPTIONAL_ASSET("item_995_25", "../items/item_995_25.png"), + UI_OPTIONAL_ASSET("item_995_100", "../items/item_995_100.png"), + UI_OPTIONAL_ASSET("item_995_250", "../items/item_995_250.png"), + UI_OPTIONAL_ASSET("item_995_1000", "../items/item_995_1000.png"), + UI_OPTIONAL_ASSET("item_995_10000", "../items/item_995_10000.png"), + UI_OPTIONAL_ASSET("item_1038", "../items/item_1038.png"), + UI_OPTIONAL_ASSET("item_1040", "../items/item_1040.png"), + UI_OPTIONAL_ASSET("item_1042", "../items/item_1042.png"), + UI_OPTIONAL_ASSET("item_1044", "../items/item_1044.png"), + UI_OPTIONAL_ASSET("item_1046", "../items/item_1046.png"), + UI_OPTIONAL_ASSET("item_1048", "../items/item_1048.png"), + UI_OPTIONAL_ASSET("item_4151", "../items/item_4151.png"), + UI_OPTIONAL_ASSET("item_554", "../items/item_554.png"), + UI_OPTIONAL_ASSET("item_555", "../items/item_555.png"), + UI_OPTIONAL_ASSET("item_556", "../items/item_556.png"), + UI_OPTIONAL_ASSET("item_557", "../items/item_557.png"), + UI_OPTIONAL_ASSET("item_558", "../items/item_558.png"), + UI_OPTIONAL_ASSET("item_560", "../items/item_560.png"), + UI_OPTIONAL_ASSET("item_562", "../items/item_562.png"), + UI_OPTIONAL_ASSET("item_861", "../items/item_861.png"), + UI_OPTIONAL_ASSET("item_892", "../items/item_892.png"), + UI_OPTIONAL_ASSET("item_1381", "../items/item_1381.png"), + UI_OPTIONAL_ASSET("item_10346", "../items/item_10346.png"), + UI_OPTIONAL_ASSET("item_10348", "../items/item_10348.png"), + UI_OPTIONAL_ASSET("item_10350", "../items/item_10350.png"), + UI_OPTIONAL_ASSET("item_10352", "../items/item_10352.png"), + UI_OPTIONAL_ASSET("item_11802", "../items/item_11802.png"), + UI_OPTIONAL_ASSET("item_11832", "../items/item_11832.png"), + UI_OPTIONAL_ASSET("item_11834", "../items/item_11834.png"), + UI_OPTIONAL_ASSET("item_26382", "../items/item_26382.png"), + UI_OPTIONAL_ASSET("item_26384", "../items/item_26384.png"), + UI_OPTIONAL_ASSET("item_26386", "../items/item_26386.png"), + UI_ASSET("skill_icon_0"), + UI_ASSET("skill_icon_1"), + UI_ASSET("skill_icon_2"), + UI_ASSET("skill_icon_3"), + UI_ASSET("skill_icon_4"), + UI_ASSET("skill_icon_5"), + UI_ASSET("skill_icon_6"), + UI_ASSET("skill_icon_7"), + UI_ASSET("skill_icon_8"), + UI_ASSET("skill_icon_9"), + UI_ASSET("skill_icon_10"), + UI_ASSET("skill_icon_11"), + UI_ASSET("skill_icon_12"), + UI_ASSET("skill_icon_13"), + UI_ASSET("skill_icon_14"), + UI_ASSET("skill_icon_15"), + UI_ASSET("skill_icon_16"), + UI_ASSET("skill_icon_17"), + UI_ASSET("skill_icon_18"), + UI_ASSET("skill_icon_19"), + UI_ASSET("skill_icon_20"), + UI_ASSET("skill_icon_21"), + UI_ASSET("skill_icon_22"), + UI_ASSET("skill_icon_23"), + UI_ASSET("prayeroff_0"), + UI_ASSET("prayeroff_1"), + UI_ASSET("prayeroff_2"), + UI_ASSET("prayeroff_3"), + UI_ASSET("prayeroff_4"), + UI_ASSET("prayeroff_5"), + UI_ASSET("prayeroff_6"), + UI_ASSET("prayeroff_7"), + UI_ASSET("prayeroff_8"), + UI_ASSET("prayeroff_9"), + UI_ASSET("prayeroff_10"), + UI_ASSET("prayeroff_11"), + UI_ASSET("prayeroff_12"), + UI_ASSET("prayeroff_13"), + UI_ASSET("prayeroff_14"), + UI_ASSET("prayeroff_15"), + UI_ASSET("prayeroff_16"), + UI_ASSET("prayeroff_17"), + UI_ASSET("prayeroff_18"), + UI_ASSET("prayeroff_19"), + UI_ASSET("prayeroff_20"), + UI_ASSET("prayeroff_21"), + UI_ASSET("prayeroff_22"), + UI_ASSET("prayeroff_23"), + UI_ASSET("prayeroff_24"), + UI_ASSET("magicon_0"), + UI_ASSET("magicon_1"), + UI_ASSET("magicon_2"), + UI_ASSET("magicon_3"), + UI_ASSET("magicon_4"), + UI_ASSET("magicon_5"), + UI_ASSET("magicon_6"), + UI_ASSET("magicon_7"), + UI_ASSET("magicon_8"), + UI_ASSET("magicon_9"), + UI_ASSET("magicon_10"), + UI_ASSET("magicon_11"), + UI_ASSET("magicon_12"), + UI_ASSET("magicon_13"), + UI_ASSET("magicon_14"), + UI_ASSET("magicon_15"), + UI_ASSET("magicon_16"), + UI_ASSET("magicon_17"), + UI_ASSET("magicon_18"), + UI_ASSET("magicon_19"), + UI_ASSET("magicon_20"), + UI_ASSET("magicon_21"), + UI_ASSET("magicon_22"), + UI_ASSET("magicon_23"), + UI_ASSET("magicon_24"), + UI_ASSET("standard_spell_on_0"), + UI_ASSET("standard_spell_on_1"), + UI_ASSET("standard_spell_on_2"), + UI_ASSET("standard_spell_on_3"), + UI_ASSET("standard_spell_on_4"), + UI_ASSET("standard_spell_on_5"), + UI_ASSET("standard_spell_on_6"), + UI_ASSET("standard_spell_on_7"), + UI_ASSET("standard_spell_on_8"), + UI_ASSET("standard_spell_on_9"), + UI_ASSET("standard_spell_on_10"), + UI_ASSET("standard_spell_on_11"), + UI_ASSET("standard_spell_on_12"), + UI_ASSET("standard_spell_on_13"), + UI_ASSET("standard_spell_on_14"), + UI_ASSET("standard_spell_on_15"), + UI_ASSET("standard_spell_on_16"), + UI_ASSET("standard_spell_on_17"), + UI_ASSET("standard_spell_on_18"), + UI_ASSET("standard_spell_on_19"), + UI_ASSET("standard_spell_on_20"), + UI_ASSET("standard_spell_on_21"), + UI_ASSET("standard_spell_on_22"), + UI_ASSET("standard_spell_on_23"), + UI_ASSET("standard_spell_on_24"), + UI_ASSET("standard_spell_on_25"), + UI_ASSET("standard_spell_on_26"), + UI_ASSET("standard_spell_on_27"), + UI_ASSET("standard_spell_on_28"), + UI_ASSET("standard_spell_on_29"), + UI_ASSET("standard_spell_on_30"), + UI_ASSET("standard_spell_on_31"), + UI_ASSET("standard_spell_on_32"), + UI_ASSET("standard_spell_on_33"), + UI_ASSET("standard_spell_on_34"), + UI_ASSET("standard_spell_on_35"), + UI_ASSET("standard_spell_on_36"), + UI_ASSET("standard_spell_on_37"), + UI_ASSET("standard_spell_on_38"), + UI_ASSET("standard_spell_on_39"), + UI_ASSET("standard_spell_on_40"), + UI_ASSET("standard_spell_on_41"), + UI_ASSET("standard_spell_on_42"), + UI_ASSET("standard_spell_on_43"), + UI_ASSET("standard_spell_on_44"), + UI_ASSET("standard_spell_on_45"), + UI_ASSET("standard_spell_on_46"), + UI_ASSET("standard_spell_on_47"), + UI_ASSET("standard_spell_on_48"), + UI_ASSET("standard_spell_on_49"), + UI_ASSET("standard_spell_on_50"), + UI_ASSET("standard_spell_on_51"), + UI_ASSET("standard_spell_on_52"), + UI_ASSET("standard_spell_on_53"), + UI_ASSET("standard_spell_on_54"), + UI_ASSET("standard_spell_on_55"), + UI_ASSET("standard_spell_on_56"), + UI_ASSET("standard_spell_on_57"), + UI_ASSET("standard_spell_on_58"), + UI_ASSET("standard_spell_on_59"), + UI_ASSET("standard_spell_on_60"), + UI_ASSET("standard_spell_on_61"), + UI_ASSET("standard_spell_on_62"), + UI_ASSET("standard_spell_on_63"), + UI_ASSET("standard_spell_on_64"), + UI_ASSET("standard_spell_on_65"), + UI_ASSET("standard_spell_on_66"), + UI_ASSET("standard_spell_on_67"), + UI_ASSET("standard_spell_on_68"), + UI_ASSET("standard_spell_on_69"), + UI_ASSET("standard_spell_on_70"), + UI_ASSET("standard_spell_on_71"), + UI_ASSET("standard_spell_on_72"), + UI_ASSET("standard_spell_on_73"), + UI_ASSET("standard_spell_on_74"), + UI_ASSET("standard_spell_on_75"), + UI_ASSET("standard_spell_on_76"), + UI_ASSET("standard_spell_on_77"), + UI_ASSET("standard_spell_on_78"), + UI_ASSET("standard_spell_on_79"), +}; + +static int ui_asset_count(void) { + return (int)(sizeof(g_ui_asset_specs) / sizeof(g_ui_asset_specs[0])); +} + +static int find_asset_index(const char *name) { + int count = ui_asset_count(); + for (int i = 0; i < count; i++) { + if (strcmp(g_ui_asset_specs[i].name, name) == 0) + return i; + } + return -1; +} + +static Texture2D load_compass_texture(const char *path) { + /* The OSRS client clips the rotating compass through sprite 1179 instead + * of drawing sprite 169 as an opaque square. Pre-applying that circular + * mask is equivalent because its shape is invariant under rotation. */ + Image compass = fc_load_image_asset(path); + Image mask = fc_load_image_asset("data/sprites/ui/resize_compass_mask.png"); + Texture2D texture = {0}; + + if (!compass.data || !mask.data) { + if (compass.data) UnloadImage(compass); + if (mask.data) UnloadImage(mask); + return texture; + } + + ImageResizeNN(&mask, compass.width, compass.height); + ImageFormat(&compass, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8); + ImageFormat(&mask, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8); + Color *compass_pixels = compass.data; + const Color *mask_pixels = mask.data; + int pixel_count = compass.width * compass.height; + for (int i = 0; i < pixel_count; i++) { + if (mask_pixels[i].a != 0) + compass_pixels[i].a = 0; + } + + texture = LoadTextureFromImage(compass); + UnloadImage(mask); + UnloadImage(compass); + return texture; +} + +void runec_ui_assets_load(RuneCUiAssets *assets) { + memset(assets, 0, sizeof(*assets)); + + int count = ui_asset_count(); + if (count > RUNEC_UI_ASSET_MAX) + count = RUNEC_UI_ASSET_MAX; + + for (int i = 0; i < count; i++) { + char path[256]; + snprintf(path, sizeof(path), "data/sprites/ui/%s", g_ui_asset_specs[i].file); + if (g_ui_asset_specs[i].required) + assets->required_count++; + if (!fc_asset_exists(path)) { + assets->missing_count++; + if (g_ui_asset_specs[i].required) { + assets->missing_required_count++; + fprintf(stderr, "ui_assets: missing required sprite %s (%s)\n", + g_ui_asset_specs[i].name, path); + } else { + assets->missing_optional_count++; + } + continue; + } + Texture2D tex = strcmp(g_ui_asset_specs[i].name, "compass") == 0 + ? load_compass_texture(path) + : fc_load_texture_asset(path); + if (tex.id == 0) { + assets->missing_count++; + if (g_ui_asset_specs[i].required) { + assets->missing_required_count++; + fprintf(stderr, "ui_assets: failed to load required sprite %s (%s)\n", + g_ui_asset_specs[i].name, path); + } else { + assets->missing_optional_count++; + } + continue; + } + SetTextureFilter(tex, TEXTURE_FILTER_POINT); + assets->textures[i] = tex; + assets->loaded[i] = 1; + assets->loaded_count++; + } + fprintf(stderr, + "ui_assets: loaded %d/%d required sprites, %d optional sprites missing\n", + assets->required_count - assets->missing_required_count, + assets->required_count, assets->missing_optional_count); + + const char *font_paths[] = { + "data/fonts/runescape.ttf", + }; + for (int i = 0; i < (int)(sizeof(font_paths) / sizeof(font_paths[0])); i++) { + const char *font_path = font_paths[i]; + if (!fc_asset_exists(font_path)) + continue; + assets->font = fc_load_font_asset(font_path, 14); + if (assets->font.texture.id != 0) { + SetTextureFilter(assets->font.texture, TEXTURE_FILTER_POINT); + assets->font_loaded = 1; + fprintf(stderr, "ui_assets: loaded font %s\n", font_path); + break; + } + } + const char *small_font_paths[] = { + "data/fonts/runescape_small.ttf", + }; + for (int i = 0; i < (int)(sizeof(small_font_paths) / sizeof(small_font_paths[0])); i++) { + const char *font_path = small_font_paths[i]; + if (!fc_asset_exists(font_path)) + continue; + assets->small_font = fc_load_font_asset(font_path, 12); + if (assets->small_font.texture.id != 0) { + SetTextureFilter(assets->small_font.texture, TEXTURE_FILTER_POINT); + assets->small_font_loaded = 1; + fprintf(stderr, "ui_assets: loaded small font %s\n", font_path); + break; + } + } +} + +void runec_ui_assets_unload(RuneCUiAssets *assets) { + int count = ui_asset_count(); + if (count > RUNEC_UI_ASSET_MAX) + count = RUNEC_UI_ASSET_MAX; + + for (int i = 0; i < count; i++) { + if (assets->loaded[i]) { + UnloadTexture(assets->textures[i]); + assets->loaded[i] = 0; + } + } + if (assets->font_loaded) { + UnloadFont(assets->font); + assets->font_loaded = 0; + } + if (assets->small_font_loaded) { + UnloadFont(assets->small_font); + assets->small_font_loaded = 0; + } + assets->loaded_count = 0; + assets->missing_count = 0; + assets->required_count = 0; + assets->missing_required_count = 0; + assets->missing_optional_count = 0; +} + +const Texture2D *runec_ui_asset(const RuneCUiAssets *assets, const char *name) { + int index = find_asset_index(name); + if (index < 0 || index >= RUNEC_UI_ASSET_MAX || !assets->loaded[index]) + return NULL; + return &assets->textures[index]; +} + +int runec_ui_asset_ready(const RuneCUiAssets *assets, const char *name) { + return runec_ui_asset(assets, name) != NULL; +} + +void runec_ui_draw_asset(const RuneCUiAssets *assets, const char *name, + Rectangle dst, Color tint) { + const Texture2D *tex = runec_ui_asset(assets, name); + if (!tex) + return; + Rectangle src = {0, 0, (float)tex->width, (float)tex->height}; + DrawTexturePro(*tex, src, dst, (Vector2){0, 0}, 0.0f, tint); +} + +Font runec_ui_font(const RuneCUiAssets *assets) { + if (assets->font_loaded) + return assets->font; + if (assets->small_font_loaded) + return assets->small_font; + return (Font){0}; +} + +Font runec_ui_font_for_size(const RuneCUiAssets *assets, float size) { + if (size <= 12.0f && assets->small_font_loaded) + return assets->small_font; + return runec_ui_font(assets); +} + +void runec_ui_draw_text_shadow(const RuneCUiAssets *assets, const char *text, + float x, float y, float size, Color color) { + if (!text || !text[0]) + return; + if (size < 12.0f) + size = 12.0f; + Font font = runec_ui_font_for_size(assets, size); + if (font.texture.id == 0) + return; + x = (float)((int)(x + 0.5f)); + y = (float)((int)(y + 0.5f)); + DrawTextEx(font, text, (Vector2){x + 1.0f, y + 1.0f}, size, 0.0f, BLACK); + DrawTextEx(font, text, (Vector2){x, y}, size, 0.0f, color); +} + +#undef UI_ASSET +#undef UI_OPTIONAL_ASSET + +/* Osrs Text */ +#include +#include + +#define FC_OSRS_FONT_ASSET "data/fonts/p11_full.png" +#define FC_OSRS_FONT_CELL_SIZE 20 +#define FC_OSRS_FONT_COLUMNS 16 +#define FC_OSRS_FONT_GLYPHS 256 + +typedef struct { + Rectangle source; + int offset_x; + int offset_y; + int advance; + int drawable; +} FcOsrsGlyph; + +static Texture2D g_font_texture; +static FcOsrsGlyph g_glyphs[FC_OSRS_FONT_GLYPHS]; +static int g_font_height; +static int g_font_ready; + +static int is_magenta(Color color) { + return color.r == 255 && color.g == 0 && color.b == 255; +} + +static int glyph_pixel_set(const Color* pixels, int image_width, + int cell_x, int cell_y, int x, int y) { + return !is_magenta( + pixels[(cell_y + y) * image_width + cell_x + x]); +} + +static void build_glyph_metrics(const Color* pixels, int image_width, + int codepoint) { + FcOsrsGlyph* glyph = &g_glyphs[codepoint]; + int cell_x = (codepoint % FC_OSRS_FONT_COLUMNS) * + FC_OSRS_FONT_CELL_SIZE; + int cell_y = (codepoint / FC_OSRS_FONT_COLUMNS) * + FC_OSRS_FONT_CELL_SIZE; + int left = FC_OSRS_FONT_CELL_SIZE; + int top = FC_OSRS_FONT_CELL_SIZE; + int right = -1; + int bottom = -1; + + for (int y = 0; y < FC_OSRS_FONT_CELL_SIZE; y++) { + for (int x = 0; x < FC_OSRS_FONT_CELL_SIZE; x++) { + if (!glyph_pixel_set(pixels, image_width, + cell_x, cell_y, x, y)) { + continue; + } + if (x < left) left = x; + if (x > right) right = x; + if (y < top) top = y; + if (y > bottom) bottom = y; + } + } + + if (right < left || bottom < top) { + glyph->advance = 0; + return; + } + + int width = right - left + 1; + int height = bottom - top + 1; + glyph->source = (Rectangle){ + (float)(cell_x + left), (float)(cell_y + top), + (float)width, (float)height, + }; + glyph->offset_x = 1; + glyph->offset_y = top; + glyph->advance = width + 2; + glyph->drawable = codepoint >= 33 && codepoint != 127; + + /* Exact cache-client PixFont advance calculation. */ + int edge_start = height / 7; + int edge_threshold = height / 7; + int edge_pixels = 0; + for (int y = edge_start; y < height; y++) { + edge_pixels += glyph_pixel_set( + pixels, image_width, cell_x, cell_y, + left, top + y); + } + if (edge_pixels <= edge_threshold) { + glyph->advance--; + glyph->offset_x = 0; + } + + edge_pixels = 0; + for (int y = edge_start; y < height; y++) { + edge_pixels += glyph_pixel_set( + pixels, image_width, cell_x, cell_y, + right, top + y); + } + if (edge_pixels <= edge_threshold) + glyph->advance--; + + if (codepoint < 128 && height > g_font_height) + g_font_height = height; +} + +void fc_osrs_text_shutdown(void) { + if (g_font_texture.id != 0) + UnloadTexture(g_font_texture); + g_font_texture = (Texture2D){0}; + memset(g_glyphs, 0, sizeof(g_glyphs)); + g_font_height = 0; + g_font_ready = 0; +} + +int fc_osrs_text_init(void) { + fc_osrs_text_shutdown(); + + Image image = fc_load_image_asset(FC_OSRS_FONT_ASSET); + if (!image.data || image.width != 320 || image.height != 320) { + if (image.data) + UnloadImage(image); + return 0; + } + ImageFormat(&image, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8); + Color* pixels = (Color*)image.data; + + for (int codepoint = 0; + codepoint < FC_OSRS_FONT_GLYPHS; codepoint++) { + build_glyph_metrics(pixels, image.width, codepoint); + } + if (g_font_height <= 0) { + UnloadImage(image); + fc_osrs_text_shutdown(); + return 0; + } + /* Native chat uses lowercase-i width for spaces and never blits the + * placeholder sprite stored in the source sheet. */ + g_glyphs[' '].advance = g_glyphs['i'].advance; + g_glyphs[' '].drawable = 0; + + for (int i = 0; i < image.width * image.height; i++) { + if (is_magenta(pixels[i])) + pixels[i] = BLANK; + else + pixels[i] = WHITE; + } + + g_font_texture = LoadTextureFromImage(image); + UnloadImage(image); + if (g_font_texture.id == 0) { + fc_osrs_text_shutdown(); + return 0; + } + SetTextureFilter(g_font_texture, TEXTURE_FILTER_POINT); + g_font_ready = 1; + return 1; +} + +static unsigned char next_osrs_character(const unsigned char* text, + size_t remaining, + size_t* consumed) { + *consumed = 1; + if (text[0] < 0x80) + return text[0]; + + /* Cache fonts are 8-bit. Normalize the Unicode punctuation used by the + * viewer diagnostics to the client's supported character repertoire. */ + if (remaining >= 3 && text[0] == 0xe2 && text[1] == 0x80 && + (text[2] == 0x93 || text[2] == 0x94)) { + *consumed = 3; + return '-'; + } + if (remaining >= 3 && text[0] == 0xe2 && text[1] == 0x86 && + text[2] == 0x92) { + *consumed = 3; + return '>'; + } + + if ((text[0] & 0xe0) == 0xc0 && remaining >= 2) + *consumed = 2; + else if ((text[0] & 0xf0) == 0xe0 && remaining >= 3) + *consumed = 3; + else if ((text[0] & 0xf8) == 0xf0 && remaining >= 4) + *consumed = 4; + return '?'; +} + +void fc_osrs_draw_text(const char* text, int x, int y, int font_size, + Color color) { + if (!text || !text[0] || font_size <= 0 || !g_font_ready) + return; + + const unsigned char* bytes = (const unsigned char*)text; + size_t remaining = strlen(text); + while (remaining > 0) { + size_t consumed = 1; + unsigned char codepoint = next_osrs_character( + bytes, remaining, &consumed); + const FcOsrsGlyph* glyph = &g_glyphs[codepoint]; + if (glyph->drawable) { + DrawTextureRec(g_font_texture, glyph->source, + (Vector2){(float)(x + glyph->offset_x), + (float)(y + glyph->offset_y)}, + color); + } + x += glyph->advance; + bytes += consumed; + remaining -= consumed; + } +} + +int fc_osrs_measure_text(const char* text, int font_size) { + if (!text || !text[0] || font_size <= 0 || !g_font_ready) + return 0; + + int width = 0; + const unsigned char* bytes = (const unsigned char*)text; + size_t remaining = strlen(text); + while (remaining > 0) { + size_t consumed = 1; + unsigned char codepoint = next_osrs_character( + bytes, remaining, &consumed); + width += g_glyphs[codepoint].advance; + bytes += consumed; + remaining -= consumed; + } + return width; +} + +#undef FC_OSRS_FONT_ASSET +#undef FC_OSRS_FONT_CELL_SIZE +#undef FC_OSRS_FONT_COLUMNS +#undef FC_OSRS_FONT_GLYPHS + +/* Minimap */ +#include +#include +#include + +int fc_minimap_scene_load_pixels(FcMinimapScene* scene, + const Color* pixels, int width, int height) { + if (!scene || !pixels || width != FC_MINIMAP_SCENE_SIZE || + height != FC_MINIMAP_SCENE_SIZE) { + return 0; + } + + size_t count = (size_t)width * (size_t)height; + Color* copy = (Color*)malloc(count * sizeof(*copy)); + if (!copy) return 0; + memcpy(copy, pixels, count * sizeof(*copy)); + + fc_minimap_scene_free(scene); + scene->pixels = copy; + scene->ready = 1; + return 1; +} + +void fc_minimap_scene_free(FcMinimapScene* scene) { + if (!scene) return; + free(scene->pixels); + memset(scene, 0, sizeof(*scene)); +} + +Vector2 fc_minimap_rotate_offset(float dx, float dy, float camera_yaw) { + float sine = sinf(camera_yaw); + float cosine = cosf(camera_yaw); + return (Vector2){ + dx * cosine + dy * sine, + -dx * sine + dy * cosine, + }; +} + +static Color sample_source(const FcMinimapScene* scene, float x, float y) { + const Color blank = {0, 0, 0, 0}; + if (!scene || !scene->ready || !scene->pixels || x < 0.0f || y < 0.0f || + x >= (float)FC_MINIMAP_SCENE_SIZE || + y >= (float)FC_MINIMAP_SCENE_SIZE) { + return blank; + } + + int source_x = (int)floorf(x); + int source_y = (int)floorf(y); + return scene->pixels[source_x + source_y * FC_MINIMAP_SCENE_SIZE]; +} + +void fc_minimap_render(const FcMinimapScene* scene, float player_x, + float player_y, float camera_yaw, Color* output) { + if (!output) return; + const Color blank = {0, 0, 0, 0}; + float sine = sinf(camera_yaw); + float cosine = cosf(camera_yaw); + float player_source_x = 0.0f; + float player_source_y = 0.0f; + float source_scale = + FC_MINIMAP_SCENE_PIXELS_PER_TILE / + FC_MINIMAP_DISPLAY_PIXELS_PER_TILE; + if (scene && scene->ready) { + player_source_x = + (player_x + FC_MINIMAP_SCENE_BORDER_TILES) * + FC_MINIMAP_SCENE_PIXELS_PER_TILE; + player_source_y = + (float)FC_MINIMAP_SCENE_SIZE - + (player_y + FC_MINIMAP_SCENE_BORDER_TILES) * + FC_MINIMAP_SCENE_PIXELS_PER_TILE; + } + + for (int y = 0; y < FC_MINIMAP_DISPLAY_SIZE; y++) { + for (int x = 0; x < FC_MINIMAP_DISPLAY_SIZE; x++) { + float screen_x = (float)x - FC_MINIMAP_DISPLAY_CENTER; + float screen_y = (float)y - FC_MINIMAP_DISPLAY_CENTER; + int output_index = x + y * FC_MINIMAP_DISPLAY_SIZE; + if (screen_x * screen_x + screen_y * screen_y > + FC_MINIMAP_DISPLAY_RADIUS * FC_MINIMAP_DISPLAY_RADIUS) { + output[output_index] = blank; + continue; + } + + float source_x = player_source_x + + (screen_x * cosine + screen_y * sine) * source_scale; + float source_y = player_source_y + + (-screen_x * sine + screen_y * cosine) * source_scale; + output[output_index] = sample_source(scene, source_x, source_y); + } + } +} + +int fc_minimap_click_to_tile(float map_x, float map_y, float player_x, + float player_y, float camera_yaw, + int* tile_x, int* tile_y) { + if (!tile_x || !tile_y) return 0; + float screen_x = map_x - FC_MINIMAP_DISPLAY_CENTER; + float screen_y = map_y - FC_MINIMAP_DISPLAY_CENTER; + if (screen_x * screen_x + screen_y * screen_y > + FC_MINIMAP_DISPLAY_RADIUS * FC_MINIMAP_DISPLAY_RADIUS) { + return 0; + } + + float sine = sinf(camera_yaw); + float cosine = cosf(camera_yaw); + float dx = (screen_x * cosine + screen_y * sine) / + FC_MINIMAP_DISPLAY_PIXELS_PER_TILE; + float dy = (screen_x * sine - screen_y * cosine) / + FC_MINIMAP_DISPLAY_PIXELS_PER_TILE; + int x = (int)floorf(player_x + dx); + int y = (int)floorf(player_y + dy); + if (x < 0 || y < 0 || x >= FC_ARENA_WIDTH || y >= FC_ARENA_HEIGHT) + return 0; + *tile_x = x; + *tile_y = y; + return 1; +} + + +/* Ui */ +#include +#include +#include +#include +#include + +#define OSRS_ORANGE ((Color){255, 152, 31, 255}) +#define OSRS_YELLOW ((Color){255, 255, 0, 255}) +#define OSRS_GREEN ((Color){0, 255, 0, 255}) +#define OSRS_RED ((Color){255, 40, 25, 255}) +#define OSRS_BLUE ((Color){90, 170, 255, 255}) +#define OSRS_PANEL ((Color){31, 25, 18, 232}) +#define OSRS_TAB_PRESS_SECONDS 0.12f +#define RUNEC_UI_SPELL_COUNT 80 +#define RUNEC_UI_SPELL_COLS 8 +#define RUNEC_UI_SPELL_X0 4 +#define RUNEC_UI_SPELL_Y0 6 +#define RUNEC_UI_SPELL_STEP_X 23 +#define RUNEC_UI_SPELL_STEP_Y 24 +#define RUNEC_UI_SPELL_ICON_SIZE 22 + +typedef struct RuneCUiLayout { + Rectangle chat; + Rectangle chat_messages; + Rectangle chat_controls; + Rectangle map; + Rectangle minimap; + Rectangle compass; + Rectangle worldmap_button; + Rectangle hp_orb; + Rectangle prayer_orb; + Rectangle run_orb; + Rectangle spec_orb; + Rectangle side; + Rectangle side_bg; + Rectangle side_content; + Rectangle tab[RUNEC_UI_TAB_COUNT]; +} RuneCUiLayout; + +static const char *g_prayer_names[25] = { + "Thick Skin", "Burst of Strength", "Clarity of Thought", "Sharp Eye", + "Mystic Will", "Rock Skin", "Superhuman Strength", "Improved Reflexes", + "Rapid Restore", "Rapid Heal", "Protect Item", "Hawk Eye", + "Mystic Lore", "Steel Skin", "Ultimate Strength", "Incredible Reflexes", + "Protect from Magic", "Protect from Missiles", "Protect from Melee", + "Eagle Eye", "Mystic Might", "Retribution", "Redemption", + "Smite", "Preserve", +}; + +typedef struct RuneCUiSpellSlotRef { + const char *name; + int standard_icon_frame; +} RuneCUiSpellSlotRef; + +static const RuneCUiSpellSlotRef g_standard_spell_slots[RUNEC_UI_SPELL_COUNT] = { + {"Lumbridge Home Teleport", 70}, + {"Wind Strike", 0}, + {"Confuse", 50}, + {"Crossbow Bolt Enchantments", 65}, + {"Water Strike", 1}, + {"Lvl-1 Enchant", 33}, + {"Earth Strike", 2}, + {"Weaken", 51}, + {"Fire Strike", 3}, + {"Bones to Bananas", 45}, + {"Wind Bolt", 4}, + {"Curse", 52}, + {"Bind", 56}, + {"Low Level Alchemy", 48}, + {"Water Bolt", 5}, + {"Varrock Teleport", 20}, + {"Lvl-2 Enchant", 34}, + {"Earth Bolt", 6}, + {"Lumbridge Teleport", 21}, + {"Telekinetic Grab", 46}, + {"Fire Bolt", 7}, + {"Falador Teleport", 22}, + {"Crumble Undead", 47}, + {"Teleport to House", 23}, + {"Wind Blast", 8}, + {"Superheat Item", 68}, + {"Camelot Teleport", 24}, + {"Water Blast", 9}, + {"Lvl-3 Enchant", 35}, + {"Iban Blast", 40}, + {"Snare", 57}, + {"Magic Dart", 66}, + {"Ardougne Teleport", 25}, + {"Earth Blast", 10}, + {"High Level Alchemy", 49}, + {"Charge Water Orb", 61}, + {"Lvl-4 Enchant", 36}, + {"Watchtower Teleport", 26}, + {"Fire Blast", 11}, + {"Charge Earth Orb", 62}, + {"Bones to Peaches", 69}, + {"Saradomin Strike", 43}, + {"Claws of Guthix", 42}, + {"Flames of Zamorak", 41}, + {"Trollheim Teleport", 27}, + {"Wind Wave", 12}, + {"Charge Fire Orb", 63}, + {"Water Wave", 13}, + {"Teleport to Ape Atoll", 28}, + {"Earth Wave", 14}, + {"Lvl-5 Enchant", 37}, + {"Kourend Castle Teleport", 29}, + {"Charge Air Orb", 64}, + {"Vulnerability", 53}, + {"Lvl-6 Enchant", 38}, + {"Teleport to Target", 67}, + {"Enfeeble", 54}, + {"Teleother Lumbridge", 30}, + {"Fire Wave", 15}, + {"Entangle", 58}, + {"Stun", 55}, + {"Charge", 44}, + {"Wind Surge", 16}, + {"Teleother Falador", 31}, + {"Water Surge", 17}, + {"Tele Block", 60}, + {"Lvl-7 Enchant", 39}, + {"Earth Surge", 18}, + {"Teleother Camelot", 32}, + {"Fire Surge", 19}, + {"Civitas illa Fortis Teleport", 72}, + {"Jewellery Enchantments", 71}, + {"Monster Inspect", 73}, + {"Summon Boat", 75}, + {"Teleport to Boat", 74}, + {"Alchemic Divergence", 77}, + {"Alchemic Convergence", 78}, + {"Minigame Teleport", 76}, + {"League Home Teleport", 79}, + {NULL, -1}, +}; + +static const char *spell_name(int slot) { + if (slot >= 0 + && slot < (int)(sizeof(g_standard_spell_slots) + / sizeof(g_standard_spell_slots[0])) + && g_standard_spell_slots[slot].name) { + return g_standard_spell_slots[slot].name; + } + return TextFormat("Spell %d", slot + 1); +} + +static const char *g_equipment_names[RUNEC_UI_EQUIP_SLOT_COUNT] = { + "Head", "Cape", "Neck", "Weapon", "Body", "Shield", "Ammo", + "Legs", "Unused", "Hands", "Feet", "Unused", "Ring", "Quiver", +}; + +static const Rectangle g_equipment_offsets[RUNEC_UI_EQUIP_SLOT_COUNT] = { + {77, 4, 36, 36}, + {36, 43, 36, 36}, + {77, 43, 36, 36}, + {21, 82, 36, 36}, + {77, 82, 36, 36}, + {133, 82, 36, 36}, + {133, 43, 36, 36}, + {77, 122, 36, 36}, + {-1000, -1000, 0, 0}, + {21, 162, 36, 36}, + {77, 162, 36, 36}, + {-1000, -1000, 0, 0}, + {133, 162, 36, 36}, + {118, 43, 36, 36}, +}; + +static const char *g_worn_icon_names[RUNEC_UI_EQUIP_SLOT_COUNT] = { + "wornicons_0", "wornicons_1", "wornicons_2", "wornicons_3", + "wornicons_4", "wornicons_5", "wornicons_10", "wornicons_6", + NULL, "wornicons_7", "wornicons_8", NULL, "wornicons_9", "wornicons_11", +}; + +typedef struct RuneCUiCombatStyleDef { + int visible; + int style_index; + const char *label; + const char *mode; + const char *icon_asset; +} RuneCUiCombatStyleDef; + +typedef struct RuneCUiCombatProfile { + int osrs_category; + const RuneCUiCombatStyleDef styles[RUNEC_UI_COMBAT_STYLE_COUNT]; +} RuneCUiCombatProfile; + +#define COMBAT_STYLE(style_index, label, mode, icon_asset) \ + {1, style_index, label, mode, icon_asset} +#define COMBAT_STYLE_HIDDEN \ + {0, 0, "", "", ""} + +/* + * Exact b237 combat_interface_weapon_category DB rows from the local + * Joshua-F dump. Core owns the selected style; the viewer owns this + * presentation mapping so weapon tabs use the same labels/icons as OSRS. + */ +static const RuneCUiCombatProfile g_combat_profiles[] = { + {0, { + COMBAT_STYLE(0, "Punch", "Accurate", "combaticons_14"), + COMBAT_STYLE(1, "Kick", "Aggressive", "combaticons_15"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons_16"), + COMBAT_STYLE_HIDDEN, + }}, + {1, { + COMBAT_STYLE(0, "Chop", "Accurate", "combaticons_1"), + COMBAT_STYLE(1, "Hack", "Aggressive", "combaticons_2"), + COMBAT_STYLE(2, "Smash", "Aggressive", "combaticons_3"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons_0"), + }}, + {2, { + COMBAT_STYLE(0, "Pound", "Accurate", "combaticons2_2"), + COMBAT_STYLE(1, "Pummel", "Aggressive", "combaticons2_3"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons2_0"), + COMBAT_STYLE_HIDDEN, + }}, + {3, { + COMBAT_STYLE(0, "Accurate", "Accurate", "combaticons2_15"), + COMBAT_STYLE(1, "Rapid", "Rapid", "combaticons2_16"), + COMBAT_STYLE(3, "Longrange", "Longrange", "combaticons2_17"), + COMBAT_STYLE_HIDDEN, + }}, + {4, { + COMBAT_STYLE(0, "Chop", "Accurate", "combaticons3_6"), + COMBAT_STYLE(1, "Slash", "Aggressive", "combaticons3_5"), + COMBAT_STYLE(2, "Lunge", "Controlled", "combaticons3_4"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons3_7"), + }}, + {5, { + COMBAT_STYLE(0, "Accurate", "Accurate", "combaticons2_5"), + COMBAT_STYLE(1, "Rapid", "Rapid", "combaticons2_6"), + COMBAT_STYLE(3, "Longrange", "Longrange", "combaticons2_7"), + COMBAT_STYLE_HIDDEN, + }}, + {6, { + COMBAT_STYLE(0, "Scorch", "Accurate", "combaticons3_16"), + COMBAT_STYLE(1, "Flare", "Aggressive", "combaticons3_17"), + COMBAT_STYLE(2, "Blaze", "Defensive", "combaticons3_18"), + COMBAT_STYLE_HIDDEN, + }}, + {7, { + COMBAT_STYLE(0, "Short fuse", "Accurate", "combaticons3_15"), + COMBAT_STYLE(1, "Medium fuse", "Rapid", "combaticons3_9"), + COMBAT_STYLE(3, "Long fuse", "Longrange", "combaticons3_8"), + COMBAT_STYLE_HIDDEN, + }}, + {8, { + COMBAT_STYLE(0, "Aim and Fire", "Accurate", "prayeron_13"), + COMBAT_STYLE(1, "Kick", "Aggressive", "combaticons_15"), + COMBAT_STYLE_HIDDEN, + COMBAT_STYLE_HIDDEN, + }}, + {9, { + COMBAT_STYLE(0, "Chop", "Accurate", "combaticons_6"), + COMBAT_STYLE(1, "Slash", "Aggressive", "combaticons_5"), + COMBAT_STYLE(2, "Lunge", "Controlled", "combaticons_7"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons_4"), + }}, + {10, { + COMBAT_STYLE(0, "Chop", "Accurate", "combaticons_6"), + COMBAT_STYLE(1, "Slash", "Aggressive", "combaticons_5"), + COMBAT_STYLE(2, "Smash", "Aggressive", "combaticons_5"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons_4"), + }}, + {11, { + COMBAT_STYLE(0, "Spike", "Accurate", "combaticons3_1"), + COMBAT_STYLE(1, "Impale", "Aggressive", "combaticons3_3"), + COMBAT_STYLE(2, "Smash", "Aggressive", "combaticons3_2"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons3_0"), + }}, + {12, { + COMBAT_STYLE(0, "Jab", "Controlled", "combaticons3_11"), + COMBAT_STYLE(1, "Swipe", "Aggressive", "combaticons3_12"), + COMBAT_STYLE(3, "Fend", "Defensive", "combaticons3_10"), + COMBAT_STYLE_HIDDEN, + }}, + {13, { + COMBAT_STYLE(0, "Bash", "Accurate", "combaticons2_13"), + COMBAT_STYLE(1, "Pound", "Aggressive", "combaticons2_14"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons_19"), + COMBAT_STYLE_HIDDEN, + }}, + {14, { + COMBAT_STYLE(0, "Reap", "Accurate", "combaticons2_19"), + COMBAT_STYLE(1, "Chop", "Aggressive", "combaticons2_9"), + COMBAT_STYLE(2, "Jab", "Controlled", "combaticons2_18"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons2_8"), + }}, + {15, { + COMBAT_STYLE(0, "Lunge", "Controlled", "combaticons_8"), + COMBAT_STYLE(1, "Swipe", "Controlled", "combaticons_18"), + COMBAT_STYLE(2, "Pound", "Controlled", "combaticons_9"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons_17"), + }}, + {16, { + COMBAT_STYLE(0, "Pound", "Accurate", "combaticons_13"), + COMBAT_STYLE(1, "Pummel", "Aggressive", "combaticons_11"), + COMBAT_STYLE(2, "Spike", "Controlled", "combaticons_12"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons_10"), + }}, + {17, { + COMBAT_STYLE(0, "Stab", "Accurate", "combaticons_7"), + COMBAT_STYLE(1, "Lunge", "Aggressive", "combaticons_6"), + COMBAT_STYLE(2, "Slash", "Controlled", "combaticons_5"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons_4"), + }}, + {18, { + COMBAT_STYLE(0, "Bash", "Accurate", "combaticons2_13"), + COMBAT_STYLE(1, "Pound", "Aggressive", "combaticons2_14"), + COMBAT_STYLE(3, "Focus", "Defensive", "combaticons_19"), + COMBAT_STYLE_HIDDEN, + }}, + {19, { + COMBAT_STYLE(0, "Accurate", "Accurate", "combaticons2_10"), + COMBAT_STYLE(1, "Rapid", "Rapid", "combaticons2_11"), + COMBAT_STYLE(3, "Longrange", "Longrange", "combaticons2_12"), + COMBAT_STYLE_HIDDEN, + }}, + {20, { + COMBAT_STYLE(0, "Flick", "Accurate", "combaticons3_13"), + COMBAT_STYLE(1, "Lash", "Controlled", "combaticons3_14"), + COMBAT_STYLE(3, "Deflect", "Defensive", "combaticons3_13"), + COMBAT_STYLE_HIDDEN, + }}, + {21, { + COMBAT_STYLE(0, "Jab", "Accurate", "combaticons2_13"), + COMBAT_STYLE(1, "Swipe", "Aggressive", "combaticons2_14"), + COMBAT_STYLE(3, "Fend", "Defensive", "combaticons_19"), + COMBAT_STYLE_HIDDEN, + }}, + {22, { + COMBAT_STYLE(0, "Jab", "Accurate", "combaticons2_13"), + COMBAT_STYLE(1, "Swipe", "Aggressive", "combaticons2_14"), + COMBAT_STYLE(3, "Fend", "Defensive", "combaticons_19"), + COMBAT_STYLE_HIDDEN, + }}, + {24, { + COMBAT_STYLE(0, "Accurate", "Accurate", "combaticons2_10"), + COMBAT_STYLE(1, "Accurate", "Accurate", "combaticons2_10"), + COMBAT_STYLE(3, "Longrange", "Longrange", "combaticons2_12"), + COMBAT_STYLE_HIDDEN, + }}, + {25, { + COMBAT_STYLE(0, "Lunge", "Controlled", "combaticons_8"), + COMBAT_STYLE(1, "Swipe", "Controlled", "combaticons_18"), + COMBAT_STYLE(2, "Pound", "Controlled", "combaticons_9"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons_17"), + }}, + {26, { + COMBAT_STYLE(0, "Jab", "Controlled", "combaticons3_11"), + COMBAT_STYLE(1, "Swipe", "Aggressive", "combaticons3_12"), + COMBAT_STYLE(3, "Fend", "Defensive", "combaticons3_10"), + COMBAT_STYLE_HIDDEN, + }}, + {27, { + COMBAT_STYLE(0, "Pound", "Accurate", "combaticons2_2"), + COMBAT_STYLE(1, "Pummel", "Aggressive", "combaticons2_3"), + COMBAT_STYLE(2, "Smash", "Aggressive", "combaticons2_0"), + COMBAT_STYLE_HIDDEN, + }}, + {28, { + COMBAT_STYLE(1, "Pummel", "Aggressive", "combaticons2_1"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons2_0"), + COMBAT_STYLE_HIDDEN, + COMBAT_STYLE_HIDDEN, + }}, + {29, { + COMBAT_STYLE(0, "Accurate", "Accurate", "combaticons2_10"), + COMBAT_STYLE(1, "Accurate", "Accurate", "combaticons2_10"), + COMBAT_STYLE(3, "Longrange", "Longrange", "combaticons2_12"), + COMBAT_STYLE_HIDDEN, + }}, + {30, { + COMBAT_STYLE(0, "Stab", "Accurate", "combaticons_7"), + COMBAT_STYLE(1, "Lunge", "Aggressive", "combaticons_6"), + COMBAT_STYLE(2, "Pound", "Controlled", "combaticons_5"), + COMBAT_STYLE(3, "Block", "Defensive", "combaticons_4"), + }}, +}; + +#undef COMBAT_STYLE +#undef COMBAT_STYLE_HIDDEN + +static void copy_text(char *dst, size_t cap, const char *src) { + if (cap == 0) + return; + if (!src) + src = ""; + snprintf(dst, cap, "%s", src); +} + +static const RuneCUiCombatProfile *combat_profile_for_osrs_category(int category) { + int count = (int)(sizeof(g_combat_profiles) / sizeof(g_combat_profiles[0])); + for (int i = 0; i < count; i++) { + if (g_combat_profiles[i].osrs_category == category) + return &g_combat_profiles[i]; + } + return &g_combat_profiles[0]; +} + +static int osrs_combat_category_from_core_weapon(int core_weapon_category) { + switch (core_weapon_category) { + case 0: return 0; /* unarmed */ + case 1: return 10; /* 2h sword */ + case 2: return 1; /* axe */ + case 4: return 2; /* blunt */ + case 5: return 27; /* bludgeon */ + case 6: return 28; /* bulwark */ + case 7: return 7; /* chinchompa/grenade */ + case 8: return 4; /* claw */ + case 9: return 5; /* crossbow */ + case 10: return 20; /* whip */ + case 11: return 6; /* fixed device */ + case 12: return 8; /* gun */ + case 13: return 11; /* pickaxe */ + case 14: return 12; /* polearm */ + case 15: return 13; /* polestaff */ + case 16: return 24; /* powered staff */ + case 17: return 14; /* scythe */ + case 18: return 9; /* slash sword */ + case 19: return 15; /* spear */ + case 20: return 16; /* spiked */ + case 21: return 17; /* stab sword */ + case 22: return 18; /* staff */ + case 23: return 19; /* thrown */ + case 24: return 10; /* two-handed sword */ + case 25: return 3; /* bow */ + case 26: return 6; /* salamander */ + case 27: return 6; /* multi-style fallback */ + case 28: return 29; /* powered wand */ + case 29: return 21; /* bladed staff */ + case 30: return 30; /* partisan */ + default: return 0; + } +} + +static const RuneCUiCombatStyleOption *combat_style_option_by_index( + const RuneCUiState *ui, + int style_index) { + if (!ui) + return NULL; + for (int i = 0; i < RUNEC_UI_COMBAT_STYLE_COUNT; i++) { + const RuneCUiCombatStyleOption *option = &ui->combat_styles[i]; + if (option->visible && option->style_index == style_index) + return option; + } + return NULL; +} + +static const RuneCUiCombatStyleOption *selected_combat_style_option( + const RuneCUiState *ui) { + const RuneCUiCombatStyleOption *option = + combat_style_option_by_index(ui, ui ? ui->selected_combat_style : 0); + if (option) + return option; + if (ui && ui->selected_combat_style == 2) + option = combat_style_option_by_index(ui, 3); + if (option) + return option; + if (!ui) + return NULL; + for (int i = 0; i < RUNEC_UI_COMBAT_STYLE_COUNT; i++) { + if (ui->combat_styles[i].visible) + return &ui->combat_styles[i]; + } + return NULL; +} + +static int combat_style_option_selected(const RuneCUiState *ui, + const RuneCUiCombatStyleOption *option) { + if (!ui || !option || !option->visible) + return 0; + if (ui->selected_combat_style == option->style_index) + return 1; + return ui->selected_combat_style == 2 + && option->style_index == 3 + && combat_style_option_by_index(ui, 2) == NULL; +} + +void runec_ui_set_combat_weapon_name(RuneCUiState *ui, const char *name) { + if (!ui) + return; + copy_text(ui->combat_weapon_name, sizeof(ui->combat_weapon_name), + name && name[0] ? name : "Unarmed"); +} + +void runec_ui_set_combat_style_profile(RuneCUiState *ui, int core_weapon_category) { + if (!ui) + return; + ui->combat_weapon_category = core_weapon_category; + int osrs_category = osrs_combat_category_from_core_weapon(core_weapon_category); + const RuneCUiCombatProfile *profile = + combat_profile_for_osrs_category(osrs_category); + for (int i = 0; i < RUNEC_UI_COMBAT_STYLE_COUNT; i++) { + const RuneCUiCombatStyleDef *src = &profile->styles[i]; + RuneCUiCombatStyleOption *dst = &ui->combat_styles[i]; + dst->visible = src->visible; + dst->style_index = src->style_index; + copy_text(dst->label, sizeof(dst->label), src->label); + copy_text(dst->mode, sizeof(dst->mode), src->mode); + copy_text(dst->icon_asset, sizeof(dst->icon_asset), src->icon_asset); + } + if (!selected_combat_style_option(ui)) { + for (int i = 0; i < RUNEC_UI_COMBAT_STYLE_COUNT; i++) { + if (ui->combat_styles[i].visible) { + ui->selected_combat_style = ui->combat_styles[i].style_index; + break; + } + } + } +} + +static float fmin2(float a, float b) { + return a < b ? a : b; +} + +const char *runec_ui_tab_name(RuneCUiTab tab) { + switch (tab) { + case RUNEC_UI_TAB_COMBAT: return "Combat"; + case RUNEC_UI_TAB_SKILLS: return "Skills"; + case RUNEC_UI_TAB_QUESTS: return "Quests"; + case RUNEC_UI_TAB_INVENTORY: return "Inventory"; + case RUNEC_UI_TAB_EQUIPMENT: return "Equipment"; + case RUNEC_UI_TAB_PRAYER: return "Prayer"; + case RUNEC_UI_TAB_SPELLBOOK: return "Spellbook"; + case RUNEC_UI_TAB_SETTINGS: return "Settings"; + case RUNEC_UI_TAB_CLAN_CHAT: return "Clan Chat"; + case RUNEC_UI_TAB_FRIENDS: return "Friends"; + default: return "Unknown"; + } +} + +static void ui_layout(int screen_w, int screen_h, RuneCUiLayout *out) { + memset(out, 0, sizeof(*out)); + + out->chat = (Rectangle){0, (float)screen_h - RUNEC_OSRS_CHAT_H, + RUNEC_OSRS_CHAT_W, RUNEC_OSRS_CHAT_H}; + out->chat_messages = (Rectangle){7, out->chat.y + 7, 506, 126}; + out->chat_controls = (Rectangle){0, out->chat.y + 142, 519, 23}; + + out->map = (Rectangle){(float)screen_w - RUNEC_OSRS_MAP_CONTAINER_W, 0, + RUNEC_OSRS_MAP_CONTAINER_W, RUNEC_OSRS_MAP_CONTAINER_H}; + out->minimap = (Rectangle){out->map.x + RUNEC_OSRS_MINIMAP_X, + out->map.y + RUNEC_OSRS_MINIMAP_Y, + RUNEC_OSRS_MINIMAP_W, RUNEC_OSRS_MINIMAP_H}; + out->compass = (Rectangle){out->map.x + RUNEC_OSRS_COMPASS_X, + out->map.y + RUNEC_OSRS_COMPASS_Y, + RUNEC_OSRS_COMPASS_W, RUNEC_OSRS_COMPASS_H}; + out->hp_orb = (Rectangle){out->map.x + RUNEC_OSRS_ORBS_X + RUNEC_OSRS_HP_X, + out->map.y + RUNEC_OSRS_ORBS_Y + RUNEC_OSRS_HP_Y, 57, 34}; + out->prayer_orb = (Rectangle){out->map.x + RUNEC_OSRS_ORBS_X + RUNEC_OSRS_PRAYER_X, + out->map.y + RUNEC_OSRS_ORBS_Y + RUNEC_OSRS_PRAYER_Y, 57, 34}; + out->run_orb = (Rectangle){out->map.x + RUNEC_OSRS_ORBS_X + RUNEC_OSRS_RUN_X, + out->map.y + RUNEC_OSRS_ORBS_Y + RUNEC_OSRS_RUN_Y, 57, 34}; + out->spec_orb = (Rectangle){out->map.x + RUNEC_OSRS_ORBS_X + RUNEC_OSRS_SPEC_X, + out->map.y + RUNEC_OSRS_ORBS_Y + RUNEC_OSRS_SPEC_Y, 57, 34}; + out->worldmap_button = (Rectangle){out->map.x + RUNEC_OSRS_ORBS_X + RUNEC_OSRS_WORLDMAP_X, + out->map.y + RUNEC_OSRS_ORBS_Y + RUNEC_OSRS_WORLDMAP_Y, + 30, 30}; + + out->side = (Rectangle){(float)screen_w - RUNEC_OSRS_SIDE_MENU_W, + (float)screen_h - RUNEC_OSRS_SIDE_MENU_H, + RUNEC_OSRS_SIDE_MENU_W, RUNEC_OSRS_SIDE_MENU_H}; + out->side_bg = out->side; + out->side_content = (Rectangle){out->side.x + RUNEC_OSRS_SIDE_CONTENT_X, + out->side.y + RUNEC_OSRS_SIDE_CONTENT_Y, + RUNEC_OSRS_SIDE_CONTENT_W, + RUNEC_OSRS_SIDE_CONTENT_H}; + + for (int i = 0; i < (int)(sizeof(RUNEC_OSRS_SIDE_STONES) / sizeof(RUNEC_OSRS_SIDE_STONES[0])); i++) { + const RuneCUiStoneRef *ref = &RUNEC_OSRS_SIDE_STONES[i]; + if (ref->logical_tab < 0 || ref->logical_tab >= RUNEC_UI_TAB_COUNT) + continue; + float row_y = out->side.y + (i < 7 ? RUNEC_OSRS_SIDE_TOP_Y : RUNEC_OSRS_SIDE_BOTTOM_Y); + out->tab[ref->logical_tab] = + (Rectangle){out->side.x + ref->rect.x, row_y + ref->rect.y, + ref->rect.width, ref->rect.height}; + } +} + +static int mouse_over_ui(const RuneCUiLayout *layout, Vector2 mouse) { + if (CheckCollisionPointRec(mouse, layout->chat) + || CheckCollisionPointRec(mouse, layout->side) + || CheckCollisionPointRec(mouse, layout->map)) + return 1; + for (int i = 0; i < RUNEC_UI_TAB_COUNT; i++) { + if (CheckCollisionPointRec(mouse, layout->tab[i])) + return 1; + } + return 0; +} + +static void clear_intent(RuneCUiState *ui) { + memset(&ui->last_intent, 0, sizeof(ui->last_intent)); +} + +static void set_context(RuneCUiState *ui, Vector2 pos, const char *title, + const char **actions, int action_count) { + ui->context_open = 1; + ui->context_pos = pos; + copy_text(ui->context_title, sizeof(ui->context_title), title); + ui->context_source_kind = RUNEC_UI_CONTEXT_NONE; + ui->context_source_slot = -1; + ui->context_source_item_id = 0; + if (action_count > RUNEC_UI_CONTEXT_ACTIONS) + action_count = RUNEC_UI_CONTEXT_ACTIONS; + ui->context_action_count = action_count; + for (int i = 0; i < action_count; i++) { + copy_text(ui->context_actions[i], sizeof(ui->context_actions[i]), actions[i]); + } +} + +static void set_context_source(RuneCUiState *ui, + RuneCUiContextSourceKind source_kind, + int source_slot, + uint32_t source_item_id) { + ui->context_source_kind = source_kind; + ui->context_source_slot = source_slot; + ui->context_source_item_id = source_item_id; +} + +void runec_ui_clear_selected_target(RuneCUiState *ui) { + if (!ui) + return; + memset(&ui->selected_target, 0, sizeof(ui->selected_target)); + ui->selected_target.source_slot = -1; +} + +static void set_selected_item_target(RuneCUiState *ui, int slot) { + if (!ui || slot < 0 || slot >= RUNEC_UI_INV_SLOT_COUNT + || !ui->inventory[slot].enabled) + return; + ui->selected_target.kind = RUNEC_UI_SELECTED_ITEM; + ui->selected_target.source_slot = slot; + ui->selected_target.source_item_id = ui->inventory[slot].item_id; + copy_text(ui->selected_target.label, sizeof(ui->selected_target.label), + ui->inventory[slot].label); + copy_text(ui->selected_target.verb, sizeof(ui->selected_target.verb), "Use"); +} + +static void set_selected_spell_target(RuneCUiState *ui, int slot, + const char *name) { + if (!ui || slot < 0) + return; + ui->selected_target.kind = RUNEC_UI_SELECTED_SPELL; + ui->selected_target.source_slot = slot; + ui->selected_target.source_item_id = 0; + copy_text(ui->selected_target.label, sizeof(ui->selected_target.label), name); + copy_text(ui->selected_target.verb, sizeof(ui->selected_target.verb), "Cast"); +} + +void runec_ui_init(RuneCUiState *ui) { + memset(ui, 0, sizeof(*ui)); + ui->active_tab = RUNEC_UI_TAB_SKILLS; + ui->selected_inventory_slot = -1; + ui->selected_equipment_slot = -1; + ui->context_source_slot = -1; + ui->selected_target.source_slot = -1; + ui->drag.source_slot = -1; + ui->selected_combat_style = 0; + ui->auto_retaliate = 1; + ui->special_attack_enabled = 0; + ui->special_attack_energy = 100; + runec_ui_set_combat_weapon_name(ui, "Abyssal whip"); + runec_ui_set_combat_style_profile(ui, 10); + ui->hitpoints = 99; + ui->hitpoints_max = 99; + ui->prayer_points = 77; + ui->prayer_points_max = 77; + ui->run_energy = 100; + ui->run_enabled = 1; + ui->combat_level = 126; + for (int i = 0; i < RUNEC_UI_SKILL_COUNT; i++) { + ui->skill_current[i] = 1; + ui->skill_base[i] = 1; + } + ui->skill_current[8] = 10; + ui->skill_base[8] = 10; + ui->skill_total = 33; + const char *start_tab = getenv("RUNEC_UI_START_TAB"); + if (start_tab && start_tab[0]) { + for (int i = 0; i < RUNEC_UI_TAB_COUNT; i++) { + if (strcmp(start_tab, runec_ui_tab_name((RuneCUiTab)i)) == 0) { + ui->active_tab = (RuneCUiTab)i; + break; + } + } + if (start_tab[0] >= '0' && start_tab[0] <= '9') { + int tab_index = atoi(start_tab); + if (tab_index >= 0 && tab_index < RUNEC_UI_TAB_COUNT) + ui->active_tab = (RuneCUiTab)tab_index; + } + } + + runec_ui_assets_load(&ui->assets); + Image minimap = GenImageColor(152, 152, BLANK); + ui->minimap_texture = LoadTextureFromImage(minimap); + UnloadImage(minimap); + if (ui->minimap_texture.id != 0) { + SetTextureFilter(ui->minimap_texture, TEXTURE_FILTER_POINT); + ui->minimap_texture_ready = 1; + } + + ui->inventory[0] = (RuneCUiSlot){6570, 6570, 1, "Fire cape", 1}; + ui->inventory[1] = (RuneCUiSlot){21295, 21295, 1, "Infernal cape", 1}; + ui->inventory[2] = (RuneCUiSlot){1042, 1042, 1, "Blue partyhat", 1}; + ui->inventory[3] = (RuneCUiSlot){1044, 1044, 1, "Green partyhat", 1}; + ui->inventory[4] = (RuneCUiSlot){1046, 1046, 1, "Purple partyhat", 1}; + ui->inventory[5] = (RuneCUiSlot){1048, 1048, 1, "White partyhat", 1}; + ui->inventory[6] = (RuneCUiSlot){4151, 4151, 1, "Abyssal whip", 1}; + ui->inventory[7] = (RuneCUiSlot){11802, 11802, 1, "Armadyl godsword", 1}; + ui->inventory[8] = (RuneCUiSlot){11832, 11832, 1, "Bandos chestplate", 1}; + ui->inventory[9] = (RuneCUiSlot){11834, 11834, 1, "Bandos tassets", 1}; + ui->inventory[10] = (RuneCUiSlot){26382, 26382, 1, "Torva full helm", 1}; + ui->inventory[11] = (RuneCUiSlot){26384, 26384, 1, "Torva platebody", 1}; + ui->inventory[12] = (RuneCUiSlot){26386, 26386, 1, "Torva platelegs", 1}; + ui->inventory[13] = (RuneCUiSlot){10350, 10350, 1, "3a full helmet", 1}; + ui->inventory[14] = (RuneCUiSlot){10348, 10348, 1, "3a platebody", 1}; + ui->inventory[15] = (RuneCUiSlot){10346, 10346, 1, "3a platelegs", 1}; + ui->inventory[16] = (RuneCUiSlot){10352, 10352, 1, "3a kiteshield", 1}; + ui->inventory[17] = (RuneCUiSlot){995, 1004, 10000000, "Coins", 1}; + ui->equipment[0] = (RuneCUiSlot){11826, 11826, 1, "Helm", 1}; + ui->equipment[3] = (RuneCUiSlot){4151, 4151, 1, "Abyssal whip", 1}; + ui->equipment[4] = (RuneCUiSlot){11828, 11828, 1, "Body", 1}; + ui->equipment[7] = (RuneCUiSlot){11830, 11830, 1, "Legs", 1}; + +} + +void runec_ui_shutdown(RuneCUiState *ui) { + if (ui->minimap_texture_ready) { + UnloadTexture(ui->minimap_texture); + ui->minimap_texture_ready = 0; + } + for (int i = 0; i < ui->item_icon_count; i++) { + if (ui->item_icons[i].ready && ui->item_icons[i].texture.id != 0) + UnloadTexture(ui->item_icons[i].texture); + } + ui->item_icon_count = 0; + runec_ui_assets_unload(&ui->assets); +} + +void runec_ui_clear_minimap(RuneCUiState *ui) { + ui->minimap_dot_count = 0; +} + +void runec_ui_add_minimap_dot(RuneCUiState *ui, float dx, float dy, + RuneCUiMinimapDotKind kind) { + if (ui->minimap_dot_count >= RUNEC_UI_MINIMAP_DOTS) + return; + ui->minimap_dots[ui->minimap_dot_count++] = + (RuneCUiMinimapDot){dx, dy, kind}; +} + +void runec_ui_update_minimap(RuneCUiState *ui, const Color *pixels, + int width, int height) { + if (!ui->minimap_texture_ready || !pixels || width != 152 || height != 152) + return; + UpdateTexture(ui->minimap_texture, pixels); +} + +void runec_ui_set_minimap_rotation(RuneCUiState *ui, float radians) { + if (!ui) return; + ui->minimap_rotation = radians; +} + +void runec_ui_set_item_icon(RuneCUiState *ui, uint32_t icon_item_id, Texture2D texture) { + if (!ui || icon_item_id == 0 || texture.id == 0) + return; + for (int i = 0; i < ui->item_icon_count; i++) { + if (ui->item_icons[i].item_id == icon_item_id) { + if (ui->item_icons[i].ready && ui->item_icons[i].texture.id != 0) + UnloadTexture(ui->item_icons[i].texture); + ui->item_icons[i].texture = texture; + ui->item_icons[i].ready = 1; + return; + } + } + if (ui->item_icon_count >= RUNEC_UI_ITEM_ICON_CACHE) { + UnloadTexture(texture); + return; + } + ui->item_icons[ui->item_icon_count++] = + (RuneCUiItemIcon){icon_item_id, texture, 1}; +} + +static int handle_context_click(RuneCUiState *ui, Vector2 mouse) { + if (!ui->context_open) + return 0; + + Rectangle box = {ui->context_pos.x, ui->context_pos.y, + 158.0f, 24.0f + ui->context_action_count * 20.0f}; + if (!CheckCollisionPointRec(mouse, box)) { + ui->context_open = 0; + ui->context_source_kind = RUNEC_UI_CONTEXT_NONE; + ui->context_source_slot = -1; + ui->context_source_item_id = 0; + return 0; + } + + for (int i = 0; i < ui->context_action_count; i++) { + Rectangle item = {box.x + 4, box.y + 22 + i * 20.0f, box.width - 8, 18}; + if (CheckCollisionPointRec(mouse, item)) { + const char *action = ui->context_actions[i]; + if (ui->context_source_kind == RUNEC_UI_CONTEXT_INVENTORY) { + if (strcmp(action, "Use") == 0) { + set_selected_item_target(ui, ui->context_source_slot); + ui->last_intent.kind = RUNEC_UI_INTENT_SELECTED_ITEM; + ui->last_intent.primary = ui->context_source_slot; + ui->last_intent.secondary = (int)ui->context_source_item_id; + } else { + ui->last_intent.kind = RUNEC_UI_INTENT_INVENTORY_ACTION; + ui->last_intent.primary = ui->context_source_slot; + ui->last_intent.secondary = i; + } + } else if (ui->context_source_kind == RUNEC_UI_CONTEXT_EQUIPMENT) { + ui->last_intent.kind = RUNEC_UI_INTENT_EQUIPMENT_ACTION; + ui->last_intent.primary = ui->context_source_slot; + ui->last_intent.secondary = i; + } else if (ui->context_source_kind == RUNEC_UI_CONTEXT_PRAYER) { + if (strcmp(action, "Activate") == 0) { + ui->last_intent.kind = RUNEC_UI_INTENT_PRAYER_SLOT; + ui->last_intent.primary = ui->context_source_slot; + copy_text(ui->last_intent.text, + sizeof(ui->last_intent.text), + ui->context_title); + } else if (strcmp(action, "Quick-prayer") == 0) { + ui->last_intent.kind = RUNEC_UI_INTENT_QUICK_PRAYER_SLOT; + ui->last_intent.primary = ui->context_source_slot; + copy_text(ui->last_intent.text, + sizeof(ui->last_intent.text), + ui->context_title); + } else { + ui->last_intent.kind = RUNEC_UI_INTENT_CONTEXT_ACTION; + ui->last_intent.primary = i; + ui->last_intent.secondary = ui->context_source_slot; + } + } else if (ui->context_source_kind == RUNEC_UI_CONTEXT_SPELL + && strcmp(action, "Cast") == 0) { + set_selected_spell_target(ui, ui->context_source_slot, + ui->context_title); + ui->last_intent.kind = RUNEC_UI_INTENT_SELECTED_SPELL; + ui->last_intent.primary = ui->context_source_slot; + ui->last_intent.secondary = 0; + } else if (ui->context_source_kind == RUNEC_UI_CONTEXT_SPELL + && strcmp(action, "Autocast") == 0) { + ui->last_intent.kind = RUNEC_UI_INTENT_AUTOCAST_SPELL; + ui->last_intent.primary = ui->context_source_slot; + ui->last_intent.secondary = 0; + copy_text(ui->last_intent.text, + sizeof(ui->last_intent.text), + ui->context_title); + } else { + ui->last_intent.kind = RUNEC_UI_INTENT_CONTEXT_ACTION; + ui->last_intent.primary = i; + ui->last_intent.secondary = ui->context_source_slot; + } + ui->last_intent.position = mouse; + if (!ui->last_intent.text[0]) + copy_text(ui->last_intent.text, sizeof(ui->last_intent.text), + action); + ui->context_open = 0; + ui->context_source_kind = RUNEC_UI_CONTEXT_NONE; + ui->context_source_slot = -1; + ui->context_source_item_id = 0; + return 1; + } + } + + return 1; +} + +static Rectangle inv_slot_rect(const RuneCUiLayout *layout, int slot) { + int col = slot % 4; + int row = slot / 4; + return (Rectangle){ + layout->side_content.x + RUNEC_OSRS_INVENTORY_SLOT_X + col * RUNEC_OSRS_INVENTORY_SLOT_STEP_X, + layout->side_content.y + RUNEC_OSRS_INVENTORY_SLOT_Y + row * RUNEC_OSRS_INVENTORY_SLOT_STEP_Y, + RUNEC_OSRS_INVENTORY_SLOT_W, + RUNEC_OSRS_INVENTORY_SLOT_H, + }; +} + +static int inv_slot_at(const RuneCUiLayout *layout, Vector2 mouse) { + for (int i = 0; i < RUNEC_UI_INV_SLOT_COUNT; i++) { + if (CheckCollisionPointRec(mouse, inv_slot_rect(layout, i))) + return i; + } + return -1; +} + +static Rectangle equip_slot_rect(const RuneCUiLayout *layout, int slot) { + if (slot < 0 || slot >= RUNEC_UI_EQUIP_SLOT_COUNT) + return (Rectangle){0, 0, 0, 0}; + Rectangle off = g_equipment_offsets[slot]; + if (off.width <= 0 || off.height <= 0) + return off; + return (Rectangle){ + layout->side_content.x + off.x, + layout->side_content.y + off.y, + off.width, + off.height, + }; +} + +static int equipment_slot_at(const RuneCUiLayout *layout, Vector2 mouse) { + for (int i = 0; i < RUNEC_UI_EQUIP_SLOT_COUNT; i++) { + Rectangle r = equip_slot_rect(layout, i); + if (r.width > 0 && CheckCollisionPointRec(mouse, r)) + return i; + } + return -1; +} + +static int ui_inventory_slot_at(const RuneCUiLayout *layout, + Vector2 mouse) { + return inv_slot_at(layout, mouse); +} + +static int ui_equipment_slot_at(const RuneCUiLayout *layout, + Vector2 mouse) { + return equipment_slot_at(layout, mouse); +} + +static Rectangle skill_slot_rect(const RuneCUiLayout *layout, int slot) { + if (slot < 0 || slot >= (int)(sizeof(RUNEC_OSRS_SKILLS) / sizeof(RUNEC_OSRS_SKILLS[0]))) + return (Rectangle){0, 0, 0, 0}; + Rectangle r = RUNEC_OSRS_SKILLS[slot].rect; + return (Rectangle){layout->side_content.x + r.x, layout->side_content.y + r.y, + r.width, r.height}; +} + +static int skill_slot_at(const RuneCUiLayout *layout, Vector2 mouse) { + int count = (int)(sizeof(RUNEC_OSRS_SKILLS) / sizeof(RUNEC_OSRS_SKILLS[0])); + for (int i = 0; i < count; i++) { + if (CheckCollisionPointRec(mouse, skill_slot_rect(layout, i))) + return i; + } + Rectangle total = {layout->side_content.x + RUNEC_OSRS_STATS_TOTAL.x, + layout->side_content.y + RUNEC_OSRS_STATS_TOTAL.y, + RUNEC_OSRS_STATS_TOTAL.width, RUNEC_OSRS_STATS_TOTAL.height}; + if (CheckCollisionPointRec(mouse, total)) + return count; + return -1; +} + +static Rectangle side_ref_rect(const RuneCUiLayout *layout, Rectangle ref) { + return (Rectangle){layout->side_content.x + ref.x, layout->side_content.y + ref.y, + ref.width, ref.height}; +} + +static const RuneCUiCombatStyleOption *combat_style_at( + const RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + if (!ui) + return NULL; + int visible_slot = 0; + int layout_count = (int)(sizeof(RUNEC_OSRS_COMBAT_STYLES) + / sizeof(RUNEC_OSRS_COMBAT_STYLES[0])); + for (int i = 0; i < RUNEC_UI_COMBAT_STYLE_COUNT && visible_slot < layout_count; i++) { + const RuneCUiCombatStyleOption *option = &ui->combat_styles[i]; + if (!option->visible) + continue; + const RuneCUiCombatStyleRef *slot = + &RUNEC_OSRS_COMBAT_STYLES[visible_slot++]; + if (CheckCollisionPointRec(mouse, side_ref_rect(layout, slot->rect))) + return option; + } + return NULL; +} + +static Rectangle grid_cell_rect(const RuneCUiLayout *layout, int index, + int cols, float x0, float y0, + float step_x, float step_y, + float w, float h) { + int col = index % cols; + int row = index / cols; + return (Rectangle){ + layout->side_content.x + x0 + col * step_x, + layout->side_content.y + y0 + row * step_y, + w, + h, + }; +} + +static int grid_index_at(const RuneCUiLayout *layout, Vector2 mouse, + int count, int cols, float x0, float y0, + float step_x, float step_y, float w, float h) { + for (int i = 0; i < count; i++) { + if (CheckCollisionPointRec(mouse, + grid_cell_rect(layout, i, cols, x0, y0, step_x, step_y, w, h))) + return i; + } + return -1; +} + +static void update_tab_press_timers(RuneCUiState *ui, float dt) { + for (int i = 0; i < RUNEC_UI_TAB_COUNT; i++) { + if (ui->tab_press_timer[i] <= 0.0f) + continue; + ui->tab_press_timer[i] -= dt; + if (ui->tab_press_timer[i] < 0.0f) + ui->tab_press_timer[i] = 0.0f; + } +} + +static int handle_selected_target_cancel(RuneCUiState *ui) { + if (ui->selected_target.kind == RUNEC_UI_SELECTED_NONE || + !IsKeyPressed(KEY_ESCAPE)) + return 0; + runec_ui_clear_selected_target(ui); + ui->last_intent.kind = RUNEC_UI_INTENT_SELECTED_TARGET_CANCEL; + return 1; +} + +static int handle_drag_release(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + if (!ui->drag.active || !IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) + return 0; + + RuneCUiDragState drag = ui->drag; + ui->drag.active = 0; + ui->drag.source_kind = RUNEC_UI_CONTEXT_NONE; + ui->drag.source_slot = -1; + + if (drag.source_kind == RUNEC_UI_CONTEXT_INVENTORY) { + int target = ui_inventory_slot_at(layout, mouse); + float dx = mouse.x - drag.start.x; + float dy = mouse.y - drag.start.y; + int moved = fabsf(dx) > 3.0f || fabsf(dy) > 3.0f; + if (target >= 0 && moved) { + ui->last_intent.kind = RUNEC_UI_INTENT_INVENTORY_DRAG; + ui->last_intent.primary = drag.source_slot; + ui->last_intent.secondary = target; + ui->last_intent.position = mouse; + return 1; + } + int previous = ui->selected_inventory_slot; + ui->selected_inventory_slot = drag.source_slot; + ui->last_intent.kind = RUNEC_UI_INTENT_INVENTORY_SLOT; + ui->last_intent.primary = drag.source_slot; + ui->last_intent.secondary = previous; + ui->last_intent.position = mouse; + return 1; + } + + if (drag.source_kind == RUNEC_UI_CONTEXT_EQUIPMENT) { + ui->selected_equipment_slot = drag.source_slot; + ui->last_intent.kind = RUNEC_UI_INTENT_EQUIPMENT_SLOT; + ui->last_intent.primary = drag.source_slot; + ui->last_intent.position = mouse; + return 1; + } + return 0; +} + +static int handle_tab_click(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + for (int i = 0; i < RUNEC_UI_TAB_COUNT; i++) { + if (!CheckCollisionPointRec(mouse, layout->tab[i])) + continue; + ui->active_tab = (RuneCUiTab)i; + ui->tab_press_timer[i] = OSRS_TAB_PRESS_SECONDS; + ui->last_intent.kind = RUNEC_UI_INTENT_TAB; + ui->last_intent.primary = i; + ui->last_intent.position = mouse; + return 1; + } + return 0; +} + +static int handle_orb_or_minimap_click(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + Rectangle run_button = { + layout->run_orb.x + 3, + layout->run_orb.y + 5, + 50, + 26, + }; + if (CheckCollisionPointRec(mouse, run_button)) { + ui->last_intent.kind = RUNEC_UI_INTENT_RUN_TOGGLE; + ui->last_intent.position = mouse; + return 1; + } + if (CheckCollisionPointRec(mouse, layout->prayer_orb)) { + ui->last_intent.kind = RUNEC_UI_INTENT_QUICK_PRAYER_TOGGLE; + ui->last_intent.primary = 1; + ui->last_intent.position = mouse; + copy_text(ui->last_intent.text, sizeof(ui->last_intent.text), + "Quick-prayer"); + return 1; + } + if (CheckCollisionPointRec(mouse, layout->spec_orb)) { + ui->special_attack_enabled = !ui->special_attack_enabled; + ui->last_intent.kind = RUNEC_UI_INTENT_SPECIAL_ATTACK; + ui->last_intent.primary = ui->special_attack_enabled; + ui->last_intent.secondary = ui->special_attack_energy; + ui->last_intent.position = mouse; + return 1; + } + + Vector2 center = { + layout->minimap.x + layout->minimap.width * 0.5f, + layout->minimap.y + layout->minimap.height * 0.5f, + }; + float dx = mouse.x - center.x; + float dy = mouse.y - center.y; + if (!CheckCollisionPointRec(mouse, layout->minimap) || + dx * dx + dy * dy > 75.0f * 75.0f) + return 0; + ui->last_intent.kind = RUNEC_UI_INTENT_MINIMAP_CLICK; + ui->last_intent.primary = (int)(mouse.x - layout->minimap.x); + ui->last_intent.secondary = (int)(mouse.y - layout->minimap.y); + ui->last_intent.position = mouse; + return 1; +} + +static int handle_combat_click(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + const RuneCUiCombatStyleOption *style = + combat_style_at(ui, layout, mouse); + if (style) { + ui->selected_combat_style = style->style_index; + ui->last_intent.kind = RUNEC_UI_INTENT_COMBAT_STYLE; + ui->last_intent.primary = style->style_index; + ui->last_intent.position = mouse; + copy_text(ui->last_intent.text, sizeof(ui->last_intent.text), + style->label); + return 1; + } + if (CheckCollisionPointRec(mouse, + side_ref_rect(layout, RUNEC_OSRS_COMBAT_RETALIATE))) { + ui->auto_retaliate = !ui->auto_retaliate; + ui->last_intent.kind = RUNEC_UI_INTENT_AUTO_RETALIATE; + ui->last_intent.primary = ui->auto_retaliate; + ui->last_intent.position = mouse; + return 1; + } + if (CheckCollisionPointRec(mouse, + side_ref_rect(layout, RUNEC_OSRS_COMBAT_SPECIAL_BAR))) { + ui->special_attack_enabled = !ui->special_attack_enabled; + ui->last_intent.kind = RUNEC_UI_INTENT_SPECIAL_ATTACK; + ui->last_intent.primary = ui->special_attack_enabled; + ui->last_intent.secondary = ui->special_attack_energy; + ui->last_intent.position = mouse; + return 1; + } + return 0; +} + +static int handle_inventory_click(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + int slot = ui_inventory_slot_at(layout, mouse); + if (slot < 0) + return 0; + if (ui->selected_target.kind == RUNEC_UI_SELECTED_ITEM || + ui->selected_target.kind == RUNEC_UI_SELECTED_SPELL) { + ui->last_intent.kind = ui->selected_target.kind == RUNEC_UI_SELECTED_ITEM + ? RUNEC_UI_INTENT_SELECTED_ITEM_ON_ITEM + : RUNEC_UI_INTENT_SELECTED_SPELL_ON_ITEM; + ui->last_intent.primary = ui->selected_target.source_slot; + ui->last_intent.secondary = slot; + ui->last_intent.position = mouse; + snprintf(ui->last_intent.text, sizeof(ui->last_intent.text), + "%s -> %s", ui->selected_target.label, + ui->inventory[slot].enabled + ? ui->inventory[slot].label : "slot"); + runec_ui_clear_selected_target(ui); + return 1; + } + ui->drag.active = 1; + ui->drag.source_kind = RUNEC_UI_CONTEXT_INVENTORY; + ui->drag.source_slot = slot; + ui->drag.start = mouse; + return 1; +} + +static int handle_equipment_click(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + int slot = ui_equipment_slot_at(layout, mouse); + if (slot < 0) + return 0; + ui->drag.active = 1; + ui->drag.source_kind = RUNEC_UI_CONTEXT_EQUIPMENT; + ui->drag.source_slot = slot; + ui->drag.start = mouse; + return 1; +} + +static int handle_prayer_click(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + int slot = grid_index_at(layout, mouse, 25, 5, 8, 8, 36, 36, 34, 34); + if (slot < 0) + return 0; + ui->last_intent.kind = RUNEC_UI_INTENT_PRAYER_SLOT; + ui->last_intent.primary = slot; + ui->last_intent.position = mouse; + copy_text(ui->last_intent.text, sizeof(ui->last_intent.text), + g_prayer_names[slot]); + return 1; +} + +static int handle_spellbook_click(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + int slot = grid_index_at(layout, mouse, RUNEC_UI_SPELL_COUNT, + RUNEC_UI_SPELL_COLS, RUNEC_UI_SPELL_X0, RUNEC_UI_SPELL_Y0, + RUNEC_UI_SPELL_STEP_X, RUNEC_UI_SPELL_STEP_Y, + RUNEC_UI_SPELL_ICON_SIZE, RUNEC_UI_SPELL_ICON_SIZE); + if (slot < 0) + return 0; + set_selected_spell_target(ui, slot, spell_name(slot)); + ui->last_intent.kind = RUNEC_UI_INTENT_SELECTED_SPELL; + ui->last_intent.primary = slot; + ui->last_intent.position = mouse; + copy_text(ui->last_intent.text, sizeof(ui->last_intent.text), + spell_name(slot)); + return 1; +} + +static int handle_skills_click(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + int slot = skill_slot_at(layout, mouse); + if (slot < 0) + return 0; + ui->last_intent.kind = RUNEC_UI_INTENT_SKILL_SLOT; + ui->last_intent.primary = slot; + ui->last_intent.position = mouse; + copy_text(ui->last_intent.text, sizeof(ui->last_intent.text), + slot < (int)(sizeof(RUNEC_OSRS_SKILLS) / + sizeof(RUNEC_OSRS_SKILLS[0])) + ? RUNEC_OSRS_SKILLS[slot].name : "Total level"); + return 1; +} + +static int handle_active_tab_click(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + switch (ui->active_tab) { + case RUNEC_UI_TAB_COMBAT: + return handle_combat_click(ui, layout, mouse); + case RUNEC_UI_TAB_INVENTORY: + return handle_inventory_click(ui, layout, mouse); + case RUNEC_UI_TAB_EQUIPMENT: + return handle_equipment_click(ui, layout, mouse); + case RUNEC_UI_TAB_PRAYER: + return handle_prayer_click(ui, layout, mouse); + case RUNEC_UI_TAB_SPELLBOOK: + return handle_spellbook_click(ui, layout, mouse); + case RUNEC_UI_TAB_SKILLS: + return handle_skills_click(ui, layout, mouse); + default: + return 0; + } +} + +static int handle_primary_click(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + if (!IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) + return 0; + if (handle_context_click(ui, mouse) || handle_tab_click(ui, layout, mouse) || + handle_orb_or_minimap_click(ui, layout, mouse) || + handle_active_tab_click(ui, layout, mouse)) + return 1; + return 0; +} + +static int handle_context_menu_open(RuneCUiState *ui, + const RuneCUiLayout *layout, + Vector2 mouse) { + if (!IsMouseButtonPressed(MOUSE_BUTTON_MIDDLE) || + !mouse_over_ui(layout, mouse)) + return 0; + runec_ui_clear_selected_target(ui); + + if (ui->active_tab == RUNEC_UI_TAB_COMBAT) { + const RuneCUiCombatStyleOption *style = + combat_style_at(ui, layout, mouse); + if (style) { + static const char *actions[] = {"Select", "Examine"}; + set_context(ui, mouse, style->label, actions, 2); + return 1; + } + if (CheckCollisionPointRec(mouse, + side_ref_rect(layout, RUNEC_OSRS_COMBAT_RETALIATE))) { + static const char *actions[] = {"Toggle", "Examine"}; + set_context(ui, mouse, "Auto Retaliate", actions, 2); + return 1; + } + if (CheckCollisionPointRec(mouse, + side_ref_rect(layout, RUNEC_OSRS_COMBAT_SPECIAL_BAR))) { + static const char *actions[] = {"Use", "Examine"}; + set_context(ui, mouse, "Special Attack", actions, 2); + return 1; + } + } + if (ui->active_tab == RUNEC_UI_TAB_INVENTORY) { + int slot = ui_inventory_slot_at(layout, mouse); + if (slot >= 0) { + static const char *actions[] = {"Use", "Examine", "Drop"}; + static const char *empty_actions[] = {"Cancel"}; + const char *title = ui->inventory[slot].enabled + ? ui->inventory[slot].label : "Empty inventory slot"; + if (ui->inventory[slot].enabled) { + set_context(ui, mouse, title, actions, 3); + set_context_source(ui, RUNEC_UI_CONTEXT_INVENTORY, slot, + ui->inventory[slot].item_id); + } else { + set_context(ui, mouse, title, empty_actions, 1); + } + return 1; + } + } + if (ui->active_tab == RUNEC_UI_TAB_EQUIPMENT) { + int slot = ui_equipment_slot_at(layout, mouse); + if (slot >= 0) { + static const char *actions[] = {"Remove", "Examine"}; + set_context(ui, mouse, g_equipment_names[slot], actions, 2); + set_context_source(ui, RUNEC_UI_CONTEXT_EQUIPMENT, slot, + ui->equipment[slot].item_id); + return 1; + } + } + if (ui->active_tab == RUNEC_UI_TAB_PRAYER) { + int slot = grid_index_at(layout, mouse, 25, 5, + 8, 8, 36, 36, 34, 34); + if (slot >= 0) { + static const char *actions[] = { + "Activate", "Quick-prayer", "Examine" + }; + set_context(ui, mouse, g_prayer_names[slot], actions, 3); + set_context_source(ui, RUNEC_UI_CONTEXT_PRAYER, slot, 0); + return 1; + } + } + if (ui->active_tab == RUNEC_UI_TAB_SPELLBOOK) { + int slot = grid_index_at(layout, mouse, RUNEC_UI_SPELL_COUNT, + RUNEC_UI_SPELL_COLS, RUNEC_UI_SPELL_X0, RUNEC_UI_SPELL_Y0, + RUNEC_UI_SPELL_STEP_X, RUNEC_UI_SPELL_STEP_Y, + RUNEC_UI_SPELL_ICON_SIZE, RUNEC_UI_SPELL_ICON_SIZE); + if (slot >= 0) { + static const char *actions[] = {"Cast", "Autocast", "Examine"}; + set_context(ui, mouse, spell_name(slot), actions, 3); + set_context_source(ui, RUNEC_UI_CONTEXT_SPELL, slot, 0); + return 1; + } + } + + static const char *actions[] = {"Cancel"}; + set_context(ui, mouse, "RuneC", actions, 1); + return 1; +} + +int runec_ui_handle_input(RuneCUiState *ui, int screen_w, int screen_h) { + RuneCUiLayout layout; + ui_layout(screen_w, screen_h, &layout); + Vector2 mouse = GetMousePosition(); + clear_intent(ui); + update_tab_press_timers(ui, GetFrameTime()); + + if (handle_selected_target_cancel(ui) || + handle_drag_release(ui, &layout, mouse) || + handle_primary_click(ui, &layout, mouse) || + handle_context_menu_open(ui, &layout, mouse)) + return 1; + + if (IsMouseButtonDown(MOUSE_BUTTON_RIGHT) && !ui->context_open) + return 0; + return mouse_over_ui(&layout, mouse); +} + +static void draw_text_shadow(const RuneCUiState *ui, const char *text, + float x, float y, float size, Color color) { + runec_ui_draw_text_shadow(&ui->assets, text, x, y, size, color); +} + +static void draw_centered_text(const RuneCUiState *ui, const char *text, + Rectangle rect, float size, Color color) { + if (size < 12.0f) + size = 12.0f; + Font font = runec_ui_font_for_size(&ui->assets, size); + Vector2 m = MeasureTextEx(font, text, size, 0); + draw_text_shadow(ui, text, rect.x + (rect.width - m.x) * 0.5f, + rect.y + (rect.height - m.y) * 0.5f, size, color); +} + +static int draw_asset_centered(const RuneCUiState *ui, const char *name, + Rectangle rect, float max_w, float max_h, Color tint) { + const Texture2D *tex = runec_ui_asset(&ui->assets, name); + if (!tex) + return 0; + float scale = fmin2(max_w / (float)tex->width, max_h / (float)tex->height); + if (scale > 1.0f) + scale = 1.0f; + Rectangle dst = { + rect.x + (rect.width - tex->width * scale) * 0.5f, + rect.y + (rect.height - tex->height * scale) * 0.5f, + tex->width * scale, + tex->height * scale, + }; + runec_ui_draw_asset(&ui->assets, name, dst, tint); + return 1; +} + +static void draw_asset_tiled(const RuneCUiState *ui, const char *name, + Rectangle dst, Color tint) { + const Texture2D *tex = runec_ui_asset(&ui->assets, name); + if (!tex) { + DrawRectangleRec(dst, OSRS_PANEL); + return; + } + for (float y = dst.y; y < dst.y + dst.height; y += (float)tex->height) { + for (float x = dst.x; x < dst.x + dst.width; x += (float)tex->width) { + float w = fmin2((float)tex->width, dst.x + dst.width - x); + float h = fmin2((float)tex->height, dst.y + dst.height - y); + DrawTexturePro(*tex, (Rectangle){0, 0, w, h}, + (Rectangle){x, y, w, h}, (Vector2){0, 0}, 0, tint); + } + } +} + +static void draw_side_chrome(const RuneCUiState *ui, const RuneCUiLayout *layout) { + Rectangle backing = {layout->side.x + 20, layout->side.y + 27, 200, 281}; + draw_asset_tiled(ui, "tradebacking_dark", backing, WHITE); + if (!runec_ui_asset_ready(&ui->assets, "tradebacking_dark")) + DrawRectangleRec(backing, OSRS_PANEL); + + runec_ui_draw_asset(&ui->assets, "osrs_stretch_side_topbottom_0", + (Rectangle){layout->side.x, layout->side.y + RUNEC_OSRS_SIDE_TOP_Y, + 241, 37}, WHITE); + runec_ui_draw_asset(&ui->assets, "osrs_stretch_side_topbottom_1", + (Rectangle){layout->side.x, layout->side.y + RUNEC_OSRS_SIDE_BOTTOM_Y, + 241, 37}, WHITE); + runec_ui_draw_asset(&ui->assets, "osrs_stretch_side_columns_0", + (Rectangle){layout->side.x + 2, layout->side.y + 37, 26, 261}, WHITE); + runec_ui_draw_asset(&ui->assets, "osrs_stretch_side_columns_1", + (Rectangle){layout->side.x + 212, layout->side.y + 37, 26, 261}, WHITE); + + for (int i = 0; i < (int)(sizeof(RUNEC_OSRS_SIDE_STONES) / sizeof(RUNEC_OSRS_SIDE_STONES[0])); i++) { + const RuneCUiStoneRef *ref = &RUNEC_OSRS_SIDE_STONES[i]; + if (ref->logical_tab != ui->active_tab) + continue; + float row_y = layout->side.y + (i < 7 ? RUNEC_OSRS_SIDE_TOP_Y : RUNEC_OSRS_SIDE_BOTTOM_Y); + float pressed = ui->tab_press_timer[ui->active_tab] > 0.0f ? 1.0f : 0.0f; + Rectangle stone = {layout->side.x + ref->rect.x, + row_y + ref->rect.y + pressed, + ref->rect.width, ref->rect.height}; + draw_asset_tiled(ui, ref->stone_asset, stone, WHITE); + DrawRectangleRec(stone, (Color){145, 22, 18, pressed > 0.0f ? 72 : 44}); + break; + } + + for (int i = 0; i < (int)(sizeof(RUNEC_OSRS_SIDE_STONES) / sizeof(RUNEC_OSRS_SIDE_STONES[0])); i++) { + const RuneCUiStoneRef *ref = &RUNEC_OSRS_SIDE_STONES[i]; + float row_y = layout->side.y + (i < 7 ? RUNEC_OSRS_SIDE_TOP_Y : RUNEC_OSRS_SIDE_BOTTOM_Y); + float pressed = ref->logical_tab == ui->active_tab + && ui->tab_press_timer[ui->active_tab] > 0.0f ? 1.0f : 0.0f; + Rectangle icon = {layout->side.x + ref->icon_rect.x, row_y + ref->icon_rect.y, + ref->icon_rect.width, ref->icon_rect.height}; + icon.y += pressed; + runec_ui_draw_asset(&ui->assets, ref->icon_asset, icon, WHITE); + } +} + +static void draw_orb(const RuneCUiState *ui, Rectangle rect, const char *filler, + const char *icon, int value, int max_value, Color color) { + runec_ui_draw_asset(&ui->assets, "orb_frame_0", rect, WHITE); + if (!runec_ui_asset_ready(&ui->assets, "orb_frame_0")) + DrawRectangleRounded((Rectangle){rect.x + 0, rect.y + 7, 34, 20}, 0.22f, 5, + (Color){50, 46, 37, 235}); + Rectangle fill = {rect.x + 27, rect.y + 4, 26, 26}; + runec_ui_draw_asset(&ui->assets, "orb_filler_0", fill, WHITE); + runec_ui_draw_asset(&ui->assets, filler, fill, WHITE); + if (!runec_ui_asset_ready(&ui->assets, filler)) + DrawCircle((int)(fill.x + 14), (int)(fill.y + 14), 12, color); + draw_asset_centered(ui, icon, fill, 22, 22, WHITE); + + char text[16]; + snprintf(text, sizeof(text), "%d", value); + Color text_color = max_value > 0 && value < max_value / 3 ? OSRS_RED : OSRS_GREEN; + draw_centered_text(ui, text, (Rectangle){rect.x + 3, rect.y + 14, 24, 13}, 12, text_color); +} + +static Color orb_value_color(int value, int max_value) { + if (max_value <= 0) + max_value = 1; + if (value < 0) + value = 0; + if (value > max_value) + value = max_value; + + int half = max_value / 2; + if (half <= 0) + return value >= max_value ? (Color){0, 255, 0, 255} + : (Color){255, 0, 0, 255}; + if (value > half) { + int red = 255 - 255 * (value - half) / half; + return (Color){(unsigned char)red, 255, 0, 255}; + } + int green = 255 * value / half; + return (Color){255, (unsigned char)green, 0, 255}; +} + +static void draw_run_orb(const RuneCUiState *ui, Rectangle rect) { + Rectangle button = {rect.x + 3, rect.y + 5, 50, 26}; + const char *frame = CheckCollisionPointRec(GetMousePosition(), button) + ? "orb_frame_1" : "orb_frame_0"; + runec_ui_draw_asset(&ui->assets, frame, rect, WHITE); + + Rectangle fill = {rect.x + 27, rect.y + 4, 26, 26}; + const char *filler = ui->run_enabled ? "orb_filler_6" : "orb_filler_5"; + const char *icon = ui->run_enabled ? "orb_icon_3" : "orb_icon_2"; + runec_ui_draw_asset(&ui->assets, filler, fill, + (Color){255, 255, 255, 230}); + + int energy = ui->run_energy; + if (energy < 0) energy = 0; + if (energy > 100) energy = 100; + int empty_height = 26 * (100 - energy) / 100; + const Texture2D *empty = runec_ui_asset(&ui->assets, "orb_filler_0"); + if (empty && empty_height > 0) { + float source_height = (float)empty->height * (float)empty_height / + fill.height; + DrawTexturePro(*empty, + (Rectangle){0, 0, (float)empty->width, source_height}, + (Rectangle){fill.x, fill.y, fill.width, + (float)empty_height}, + (Vector2){0, 0}, 0.0f, WHITE); + } + draw_asset_centered(ui, icon, fill, 26, 26, WHITE); + + char text[16]; + snprintf(text, sizeof(text), "%d", energy); + draw_centered_text(ui, text, + (Rectangle){rect.x + 3, rect.y + 14, 24, 13}, + 12, orb_value_color(energy, 100)); +} + +static void draw_minimap(const RuneCUiState *ui, const RuneCUiLayout *layout) { + Vector2 center = {layout->minimap.x + layout->minimap.width * 0.5f, + layout->minimap.y + layout->minimap.height * 0.5f}; + if (ui->minimap_texture_ready) { + DrawTexturePro(ui->minimap_texture, + (Rectangle){0, 0, 152, 152}, + layout->minimap, (Vector2){0, 0}, 0, WHITE); + } else { + DrawCircle((int)center.x, (int)center.y, 72.0f, (Color){85, 124, 52, 255}); + } + + for (int i = 0; i < ui->minimap_dot_count; i++) { + const RuneCUiMinimapDot *dot = &ui->minimap_dots[i]; + float px = center.x + + dot->dx * FC_MINIMAP_DISPLAY_PIXELS_PER_TILE; + float py = center.y - + dot->dy * FC_MINIMAP_DISPLAY_PIXELS_PER_TILE; + float dx = px - center.x; + float dy = py - center.y; + if (dx * dx + dy * dy > 68.0f * 68.0f) + continue; + Color c = OSRS_YELLOW; + float radius = 2.0f; + if (dot->kind == RUNEC_UI_MINIMAP_DOT_PLAYER) { + c = WHITE; + radius = 3.0f; + } else if (dot->kind == RUNEC_UI_MINIMAP_DOT_DESTINATION) { + c = OSRS_RED; + radius = 3.0f; + } + DrawCircle((int)px, (int)py, radius, c); + } + + const Texture2D *compass = runec_ui_asset(&ui->assets, "compass"); + if (compass) { + Rectangle source = {0, 0, (float)compass->width, + (float)compass->height}; + Rectangle destination = { + layout->compass.x + layout->compass.width * 0.5f, + layout->compass.y + layout->compass.height * 0.5f, + layout->compass.width, + layout->compass.height, + }; + Vector2 origin = {layout->compass.width * 0.5f, + layout->compass.height * 0.5f}; + DrawTexturePro(*compass, source, destination, origin, + ui->minimap_rotation * + (180.0f / 3.14159265358979323846f), + WHITE); + } else { + runec_ui_draw_asset(&ui->assets, "resize_compass_mask", layout->compass, WHITE); + draw_text_shadow(ui, "N", layout->compass.x + 16, layout->compass.y + 12, 14, OSRS_ORANGE); + } + + Rectangle cover = {layout->map.x + RUNEC_OSRS_MAP_SURROUND_X, + layout->map.y + RUNEC_OSRS_MAP_SURROUND_Y, + RUNEC_OSRS_MAP_SURROUND_W, RUNEC_OSRS_MAP_SURROUND_H}; + runec_ui_draw_asset(&ui->assets, "osrs_stretch_mapsurround", cover, WHITE); + if (!runec_ui_asset_ready(&ui->assets, "osrs_stretch_mapsurround")) { + DrawCircleLines((int)center.x, (int)center.y, 73.0f, (Color){51, 48, 35, 255}); + DrawCircleLines((int)center.x, (int)center.y, 70.0f, (Color){180, 166, 104, 255}); + } + + draw_orb(ui, layout->hp_orb, "orb_filler_1", "orb_icon_0", + ui->hitpoints, ui->hitpoints_max, OSRS_RED); + draw_orb(ui, layout->prayer_orb, "orb_filler_4", "orb_icon_1", + ui->prayer_points, ui->prayer_points_max, OSRS_BLUE); + draw_run_orb(ui, layout->run_orb); + draw_orb(ui, layout->spec_orb, "orb_filler_9", "orb_icon_6", + ui->special_attack_energy, 100, OSRS_YELLOW); + + runec_ui_draw_asset(&ui->assets, "ring_30", layout->worldmap_button, WHITE); + draw_asset_centered(ui, "worldmap_icon_0", layout->worldmap_button, 22, 22, WHITE); + if (!runec_ui_asset_ready(&ui->assets, "worldmap_icon_0")) + draw_centered_text(ui, "?", layout->worldmap_button, 14, OSRS_YELLOW); +} + +static void draw_chat_panel_chrome(const RuneCUiState *ui, + const RuneCUiLayout *layout) { + runec_ui_draw_asset(&ui->assets, "chatbox_bg", layout->chat, WHITE); + if (!runec_ui_asset_ready(&ui->assets, "chatbox_bg")) + DrawRectangleRec(layout->chat, (Color){22, 19, 15, 235}); + + DrawRectangleRec(layout->chat_messages, (Color){0, 0, 0, 104}); + runec_ui_draw_asset(&ui->assets, "main_stones_bottom", + layout->chat_controls, WHITE); + if (!runec_ui_asset_ready(&ui->assets, "main_stones_bottom")) + DrawRectangleRec(layout->chat_controls, (Color){74, 62, 48, 235}); +} + +static Color item_color(uint32_t item_id) { + switch (item_id) { + case 4151: return (Color){176, 178, 164, 255}; + case 385: return (Color){80, 154, 178, 255}; + case 2434: return (Color){110, 190, 70, 255}; + case 995: return (Color){228, 188, 58, 255}; + default: return (Color){128, 92, 52, 255}; + } +} + +static int coin_stack_visual_quantity(int quantity) { + if (quantity <= 1) return 1; + if (quantity == 2) return 2; + if (quantity == 3) return 3; + if (quantity == 4) return 4; + if (quantity < 25) return 5; + if (quantity < 100) return 25; + if (quantity < 250) return 100; + if (quantity < 1000) return 250; + if (quantity < 10000) return 1000; + return 10000; +} + +static void item_icon_asset_name(const RuneCUiSlot *slot, char *dst, size_t cap) { + uint32_t icon_item_id = slot->icon_item_id ? slot->icon_item_id : slot->item_id; + if (icon_item_id != slot->item_id) { + snprintf(dst, cap, "item_%u", icon_item_id); + return; + } + if (slot->item_id == 995) { + snprintf(dst, cap, "item_995_%d", coin_stack_visual_quantity(slot->quantity)); + return; + } + snprintf(dst, cap, "item_%u", slot->item_id); +} + +static int label_word_is_filler(const char *word, int len) { + return (len == 2 && strncmp(word, "of", 2) == 0) || + (len == 3 && strncmp(word, "the", 3) == 0) || + (len == 3 && strncmp(word, "and", 3) == 0); +} + +static void slot_label_abbrev(const RuneCUiSlot *slot, char *dst, size_t cap) { + if (!dst || cap == 0) + return; + dst[0] = '\0'; + if (!slot || !slot->label[0]) + return; + + int out = 0; + const char *p = slot->label; + while (*p && out < (int)cap - 1) { + while (*p && !isalnum((unsigned char)*p)) + p++; + if (!*p) + break; + + char word[16]; + int len = 0; + unsigned char first = (unsigned char)*p; + while (*p && isalnum((unsigned char)*p)) { + if (len < (int)sizeof(word) - 1) + word[len++] = (char)tolower((unsigned char)*p); + p++; + } + word[len] = '\0'; + if (len > 0 && !label_word_is_filler(word, len)) + dst[out++] = (char)toupper(first); + } + dst[out] = '\0'; +} + +static void draw_slot_label_fallback(const RuneCUiState *ui, const RuneCUiSlot *slot, + Rectangle r) { + char abbr[4]; + slot_label_abbrev(slot, abbr, sizeof(abbr)); + if (!abbr[0]) + return; + + float size = 10.0f; + Font font = runec_ui_font_for_size(&ui->assets, size); + Vector2 m = MeasureTextEx(font, abbr, size, 0); + if (m.x > r.width - 4.0f) { + size = 8.0f; + font = runec_ui_font_for_size(&ui->assets, size); + m = MeasureTextEx(font, abbr, size, 0); + } + draw_text_shadow(ui, abbr, r.x + (r.width - m.x) * 0.5f, + r.y + (r.height - m.y) * 0.5f, size, + (Color){238, 218, 162, 245}); +} + +static Color stack_text_color(int quantity) { + if (quantity >= 10000000) + return OSRS_GREEN; + if (quantity >= 100000) + return WHITE; + return OSRS_YELLOW; +} + +static void format_stack_quantity(int quantity, char *dst, size_t cap) { + if (quantity >= 10000000) { + snprintf(dst, cap, "%dM", quantity / 1000000); + } else if (quantity >= 100000) { + snprintf(dst, cap, "%dK", quantity / 1000); + } else { + snprintf(dst, cap, "%d", quantity); + } +} + +static const Texture2D *ui_item_icon_texture(const RuneCUiState *ui, + uint32_t icon_item_id) { + for (int i = 0; i < ui->item_icon_count; i++) { + if (ui->item_icons[i].ready && ui->item_icons[i].item_id == icon_item_id) + return &ui->item_icons[i].texture; + } + return NULL; +} + +static void draw_inventory_item(const RuneCUiState *ui, const RuneCUiSlot *slot, Rectangle r) { + uint32_t icon_item_id = slot->icon_item_id ? slot->icon_item_id : slot->item_id; + const Texture2D *runtime_icon = ui_item_icon_texture(ui, icon_item_id); + if (runtime_icon && runtime_icon->id != 0) { + float sx = r.width / (float)runtime_icon->width; + float sy = r.height / (float)runtime_icon->height; + float scale = sx < sy ? sx : sy; + Rectangle dst = { + r.x + (r.width - (float)runtime_icon->width * scale) * 0.5f, + r.y + (r.height - (float)runtime_icon->height * scale) * 0.5f, + (float)runtime_icon->width * scale, + (float)runtime_icon->height * scale + }; + DrawTexturePro(*runtime_icon, + (Rectangle){0, 0, (float)runtime_icon->width, + (float)runtime_icon->height}, + dst, (Vector2){0, 0}, 0.0f, WHITE); + return; + } + + char icon_name[32]; + item_icon_asset_name(slot, icon_name, sizeof(icon_name)); + if (draw_asset_centered(ui, icon_name, r, RUNEC_OSRS_INVENTORY_SLOT_W, + RUNEC_OSRS_INVENTORY_SLOT_H, WHITE)) + return; + + Color c = item_color(slot->item_id); + if (slot->item_id == 4151) { + DrawLineEx((Vector2){r.x + 9, r.y + 25}, (Vector2){r.x + 24, r.y + 6}, 4.0f, + (Color){42, 40, 38, 255}); + DrawLineEx((Vector2){r.x + 11, r.y + 23}, (Vector2){r.x + 25, r.y + 7}, 2.0f, c); + DrawCircle((int)(r.x + 9), (int)(r.y + 25), 4.0f, (Color){83, 50, 38, 255}); + } else if (slot->item_id == 385) { + DrawEllipse((int)(r.x + 16), (int)(r.y + 17), 12.0f, 7.0f, c); + DrawTriangle((Vector2){r.x + 5, r.y + 17}, (Vector2){r.x + 1, r.y + 11}, + (Vector2){r.x + 1, r.y + 23}, c); + DrawCircle((int)(r.x + 23), (int)(r.y + 15), 1.5f, BLACK); + } else if (slot->item_id == 2434) { + DrawRectangleRounded((Rectangle){r.x + 11, r.y + 5, 10, 22}, 0.35f, 4, + (Color){52, 42, 34, 255}); + DrawRectangleRounded((Rectangle){r.x + 12, r.y + 10, 8, 15}, 0.35f, 4, c); + DrawRectangleRec((Rectangle){r.x + 11, r.y + 4, 10, 4}, (Color){196, 196, 182, 255}); + } else if (slot->item_id == 995) { + DrawCircle((int)(r.x + 14), (int)(r.y + 14), 7.0f, c); + DrawCircle((int)(r.x + 19), (int)(r.y + 18), 7.0f, (Color){210, 156, 40, 255}); + DrawCircle((int)(r.x + 13), (int)(r.y + 21), 6.0f, (Color){238, 204, 72, 255}); + } else { + DrawRectangleRounded((Rectangle){r.x + 6, r.y + 6, 20, 20}, 0.22f, 4, c); + draw_slot_label_fallback(ui, slot, r); + } + (void)ui; +} + +static void draw_inventory(const RuneCUiState *ui, const RuneCUiLayout *layout) { + for (int i = 0; i < RUNEC_UI_INV_SLOT_COUNT; i++) { + Rectangle r = inv_slot_rect(layout, i); + if (ui->selected_inventory_slot == i) + DrawRectangleLinesEx((Rectangle){r.x - 1, r.y - 1, r.width + 2, r.height + 2}, + 2.0f, OSRS_YELLOW); + if (ui->inventory[i].enabled) { + draw_inventory_item(ui, &ui->inventory[i], r); + if (ui->inventory[i].quantity > 1) { + char q[16]; + format_stack_quantity(ui->inventory[i].quantity, q, sizeof(q)); + draw_text_shadow(ui, q, r.x + 1, r.y - 1, 10, + stack_text_color(ui->inventory[i].quantity)); + } + } + } +} + +static void draw_equipment(const RuneCUiState *ui, const RuneCUiLayout *layout) { + draw_asset_tiled(ui, "miscgraphics_2", + side_ref_rect(layout, (Rectangle){77, 39, 36, 124}), WHITE); + draw_asset_tiled(ui, "miscgraphics_2", + side_ref_rect(layout, (Rectangle){21, 118, 36, 45}), WHITE); + draw_asset_tiled(ui, "miscgraphics_2", + side_ref_rect(layout, (Rectangle){133, 118, 36, 45}), WHITE); + draw_asset_tiled(ui, "miscgraphics_3", + side_ref_rect(layout, (Rectangle){56, 81, 78, 36}), WHITE); + draw_asset_tiled(ui, "miscgraphics_3", + side_ref_rect(layout, (Rectangle){71, 42, 48, 36}), WHITE); + + for (int i = 0; i < RUNEC_UI_EQUIP_SLOT_COUNT; i++) { + Rectangle r = equip_slot_rect(layout, i); + if (r.width <= 0) + continue; + if (ui->equipment[i].enabled) { + draw_inventory_item(ui, &ui->equipment[i], r); + if (ui->equipment[i].quantity > 1) { + char q[16]; + format_stack_quantity(ui->equipment[i].quantity, q, sizeof(q)); + draw_text_shadow(ui, q, r.x + 1, r.y - 1, 10, + stack_text_color(ui->equipment[i].quantity)); + } + } else if (g_worn_icon_names[i]) { + draw_asset_centered(ui, g_worn_icon_names[i], r, 28, 28, (Color){190, 178, 150, 175}); + } + if (ui->selected_equipment_slot == i) { + DrawRectangleLinesEx((Rectangle){r.x - 1, r.y - 1, r.width + 2, r.height + 2}, + 2.0f, OSRS_YELLOW); + } + } + + for (int i = 0; i < (int)(sizeof(RUNEC_OSRS_WORN_BUTTONS) / sizeof(RUNEC_OSRS_WORN_BUTTONS[0])); i++) { + const RuneCUiWornButtonRef *ref = &RUNEC_OSRS_WORN_BUTTONS[i]; + Rectangle b = {layout->side_content.x + ref->rect.x, + layout->side_content.y + ref->rect.y, + ref->rect.width, ref->rect.height}; + Rectangle icon = {layout->side_content.x + ref->icon_rect.x, + layout->side_content.y + ref->icon_rect.y, + ref->icon_rect.width, ref->icon_rect.height}; + runec_ui_draw_asset(&ui->assets, "combatboxes_0", b, WHITE); + if (!runec_ui_asset_ready(&ui->assets, "combatboxes_0")) { + DrawRectangleRounded(b, 0.18f, 5, (Color){54, 46, 35, 235}); + DrawRectangleLinesEx(b, 1, (Color){119, 99, 68, 255}); + } + draw_asset_centered(ui, ref->asset, icon, icon.width, icon.height, WHITE); + } +} + +static void draw_prayer(const RuneCUiState *ui, const RuneCUiLayout *layout) { + for (int i = 0; i < 25; i++) { + Rectangle r = grid_cell_rect(layout, i, 5, 8, 8, 36, 36, 34, 34); + DrawRectangleRec(r, (Color){16, 13, 10, 95}); + char name[32]; + snprintf(name, sizeof(name), "prayer%s_%d", + (ui->active_prayers & (1u << i)) ? "on" : "off", i); + if (!draw_asset_centered(ui, name, r, 30, 30, WHITE)) + draw_centered_text(ui, TextFormat("%d", i + 1), r, 10, OSRS_ORANGE); + } +} + +static void draw_spellbook(const RuneCUiState *ui, const RuneCUiLayout *layout) { + for (int i = 0; i < RUNEC_UI_SPELL_COUNT; i++) { + Rectangle r = grid_cell_rect(layout, i, RUNEC_UI_SPELL_COLS, + RUNEC_UI_SPELL_X0, RUNEC_UI_SPELL_Y0, + RUNEC_UI_SPELL_STEP_X, + RUNEC_UI_SPELL_STEP_Y, + RUNEC_UI_SPELL_ICON_SIZE, + RUNEC_UI_SPELL_ICON_SIZE); + if (!g_standard_spell_slots[i].name) + continue; + DrawRectangleRec(r, (Color){12, 12, 28, 105}); + char name[32]; + snprintf(name, sizeof(name), "standard_spell_on_%d", + g_standard_spell_slots[i].standard_icon_frame); + int drew = draw_asset_centered(ui, name, r, RUNEC_UI_SPELL_ICON_SIZE, + RUNEC_UI_SPELL_ICON_SIZE, WHITE); + if (!drew) + draw_centered_text(ui, TextFormat("%d", i + 1), r, 10, OSRS_ORANGE); + } +} + +static void draw_skills(const RuneCUiState *ui, const RuneCUiLayout *layout) { + int count = (int)(sizeof(RUNEC_OSRS_SKILLS) / sizeof(RUNEC_OSRS_SKILLS[0])); + for (int i = 0; i < count; i++) { + Rectangle r = skill_slot_rect(layout, i); + DrawRectangleRec(r, (Color){72, 70, 60, 232}); + DrawLineEx((Vector2){r.x, r.y}, (Vector2){r.x + r.width - 1, r.y}, 1, + (Color){139, 130, 104, 255}); + DrawLineEx((Vector2){r.x, r.y}, (Vector2){r.x, r.y + r.height - 1}, 1, + (Color){139, 130, 104, 255}); + DrawLineEx((Vector2){r.x, r.y + r.height - 1}, + (Vector2){r.x + r.width - 1, r.y + r.height - 1}, 1, + (Color){28, 25, 21, 255}); + DrawLineEx((Vector2){r.x + r.width - 1, r.y}, + (Vector2){r.x + r.width - 1, r.y + r.height - 1}, 1, + (Color){28, 25, 21, 255}); + char icon[32]; + snprintf(icon, sizeof(icon), "skill_icon_%d", RUNEC_OSRS_SKILLS[i].icon_index); + draw_asset_centered(ui, icon, (Rectangle){r.x + 3, r.y + 3, 24, 24}, 24, 24, WHITE); + int current_level = i < RUNEC_UI_SKILL_COUNT && ui->skill_current[i] > 0 + ? ui->skill_current[i] : 1; + int base_level = i < RUNEC_UI_SKILL_COUNT && ui->skill_base[i] > 0 + ? ui->skill_base[i] : current_level; + char cur[16]; + char base[16]; + snprintf(cur, sizeof(cur), "%d", current_level); + snprintf(base, sizeof(base), "%d", base_level); + Color cur_color = current_level < base_level ? (Color){220, 45, 31, 255} + : current_level > base_level ? OSRS_GREEN : OSRS_YELLOW; + draw_text_shadow(ui, cur, r.x + 39, r.y + 2, 10, cur_color); + draw_text_shadow(ui, base, r.x + 39, r.y + 17, 9, OSRS_GREEN); + } + + Rectangle total = {layout->side_content.x + RUNEC_OSRS_STATS_TOTAL.x, + layout->side_content.y + RUNEC_OSRS_STATS_TOTAL.y, + RUNEC_OSRS_STATS_TOTAL.width, RUNEC_OSRS_STATS_TOTAL.height}; + DrawRectangleRec(total, (Color){7, 7, 7, 238}); + DrawRectangleLinesEx(total, 1, (Color){99, 91, 68, 255}); + char total_text[32]; + snprintf(total_text, sizeof(total_text), "Total level: %d", + ui->skill_total > 0 ? ui->skill_total : 0); + draw_centered_text(ui, total_text, total, 10, OSRS_YELLOW); +} + +static void draw_combat_box(const RuneCUiState *ui, Rectangle r, int selected) { + const char *asset = selected ? "combatboxes_1" : "combatboxes_0"; + runec_ui_draw_asset(&ui->assets, asset, r, WHITE); + if (!runec_ui_asset_ready(&ui->assets, asset)) { + DrawRectangleRec(r, selected ? (Color){83, 61, 43, 245} : (Color){45, 39, 31, 235}); + DrawRectangleLinesEx(r, 1, selected ? OSRS_YELLOW : (Color){103, 89, 63, 255}); + } + if (selected) + DrawRectangleRec(r, (Color){120, 27, 20, 54}); +} + +static void draw_combat(const RuneCUiState *ui, const RuneCUiLayout *layout) { + Rectangle header = side_ref_rect(layout, RUNEC_OSRS_COMBAT_HEADER); + const char *weapon_name = ui->combat_weapon_name[0] + ? ui->combat_weapon_name : "Unarmed"; + draw_centered_text(ui, weapon_name, side_ref_rect(layout, RUNEC_OSRS_COMBAT_TITLE), + 13, OSRS_ORANGE); + char level[32]; + snprintf(level, sizeof(level), "Combat Lvl: %d", ui->combat_level); + draw_centered_text(ui, level, side_ref_rect(layout, RUNEC_OSRS_COMBAT_LEVEL), + 12, OSRS_ORANGE); + DrawLineEx((Vector2){header.x + 10, header.y + 42}, + (Vector2){header.x + header.width - 10, header.y + 42}, 1, + (Color){75, 64, 45, 180}); + + int visible_slot = 0; + int layout_count = (int)(sizeof(RUNEC_OSRS_COMBAT_STYLES) + / sizeof(RUNEC_OSRS_COMBAT_STYLES[0])); + for (int i = 0; i < RUNEC_UI_COMBAT_STYLE_COUNT && visible_slot < layout_count; i++) { + const RuneCUiCombatStyleOption *option = &ui->combat_styles[i]; + if (!option->visible) + continue; + const RuneCUiCombatStyleRef *style = + &RUNEC_OSRS_COMBAT_STYLES[visible_slot++]; + int selected = combat_style_option_selected(ui, option); + Rectangle button = side_ref_rect(layout, style->rect); + draw_combat_box(ui, button, selected); + draw_asset_centered(ui, option->icon_asset, side_ref_rect(layout, style->icon_rect), + 34, 24, WHITE); + draw_centered_text(ui, option->label, side_ref_rect(layout, style->text_rect), + 10, selected ? OSRS_YELLOW : OSRS_ORANGE); + } + + Rectangle retaliate = side_ref_rect(layout, RUNEC_OSRS_COMBAT_RETALIATE); + draw_combat_box(ui, retaliate, ui->auto_retaliate); + draw_asset_centered(ui, "combat_shield", side_ref_rect(layout, RUNEC_OSRS_COMBAT_RETALIATE_ICON), + 26, 39, WHITE); + draw_centered_text(ui, ui->auto_retaliate ? "Auto Retaliate" : "Retaliate Off", + side_ref_rect(layout, RUNEC_OSRS_COMBAT_RETALIATE_TEXT), 11, OSRS_ORANGE); + + Rectangle spec = side_ref_rect(layout, RUNEC_OSRS_COMBAT_SPECIAL_BAR); + runec_ui_draw_asset(&ui->assets, "combatboxes_special_attack", spec, WHITE); + if (!runec_ui_asset_ready(&ui->assets, "combatboxes_special_attack")) + DrawRectangleRec(spec, (Color){32, 28, 22, 235}); + Rectangle empty = {spec.x + 2, spec.y + 7, spec.width - 4, 12}; + DrawRectangleRec(empty, (Color){115, 6, 6, 255}); + Rectangle fill = empty; + fill.width *= (float)ui->special_attack_energy / 100.0f; + DrawRectangleRec(fill, ui->special_attack_enabled ? OSRS_GREEN : (Color){57, 125, 59, 255}); + DrawRectangleLinesEx((Rectangle){spec.x + 2, spec.y + 6, spec.width - 4, 14}, 1, + (Color){44, 42, 35, 255}); + char spec_text[32]; + snprintf(spec_text, sizeof(spec_text), "Special Attack: %d%%", ui->special_attack_energy); + draw_centered_text(ui, spec_text, spec, 10, OSRS_YELLOW); + + const RuneCUiCombatStyleOption *selected_style = + selected_combat_style_option(ui); + const char *mode = selected_style ? selected_style->mode : "Accurate"; + char category[64]; + snprintf(category, sizeof(category), "Attack style: %s", mode); + draw_centered_text(ui, category, side_ref_rect(layout, RUNEC_OSRS_COMBAT_CATEGORY), + 12, OSRS_ORANGE); +} + +static void draw_placeholder_tab(const RuneCUiState *ui, const RuneCUiLayout *layout, + const char *title, const char *body) { + draw_centered_text(ui, title, (Rectangle){layout->side_content.x, layout->side_content.y + 26, 190, 20}, + 15, OSRS_YELLOW); + draw_centered_text(ui, body, (Rectangle){layout->side_content.x + 10, layout->side_content.y + 105, 170, 36}, + 12, OSRS_ORANGE); +} + +static void draw_side(RuneCUiState *ui, const RuneCUiLayout *layout) { + draw_side_chrome(ui, layout); + + if (ui->active_tab == RUNEC_UI_TAB_COMBAT) { + draw_combat(ui, layout); + return; + } + + switch (ui->active_tab) { + case RUNEC_UI_TAB_INVENTORY: + draw_inventory(ui, layout); + break; + case RUNEC_UI_TAB_EQUIPMENT: + draw_equipment(ui, layout); + break; + case RUNEC_UI_TAB_PRAYER: + draw_prayer(ui, layout); + break; + case RUNEC_UI_TAB_SPELLBOOK: + draw_spellbook(ui, layout); + break; + case RUNEC_UI_TAB_SKILLS: + draw_skills(ui, layout); + break; + case RUNEC_UI_TAB_COMBAT: + draw_combat(ui, layout); + break; + case RUNEC_UI_TAB_QUESTS: + draw_placeholder_tab(ui, layout, "Quest List", "Quest journal surface."); + break; + case RUNEC_UI_TAB_SETTINGS: + draw_placeholder_tab(ui, layout, "Settings", "Viewer options surface."); + break; + case RUNEC_UI_TAB_CLAN_CHAT: + break; + case RUNEC_UI_TAB_FRIENDS: + break; + default: + break; + } +} + +static void draw_context(const RuneCUiState *ui) { + if (!ui->context_open) + return; + Rectangle box = {ui->context_pos.x, ui->context_pos.y, + 158.0f, 24.0f + ui->context_action_count * 20.0f}; + DrawRectangleRec(box, (Color){53, 44, 31, 244}); + DrawRectangleLinesEx(box, 1, (Color){170, 137, 72, 255}); + draw_text_shadow(ui, ui->context_title, box.x + 5, box.y + 4, 11, OSRS_YELLOW); + for (int i = 0; i < ui->context_action_count; i++) { + Rectangle item = {box.x + 4, box.y + 22 + i * 20.0f, box.width - 8, 18}; + DrawRectangleRec(item, (Color){28, 23, 17, 215}); + draw_text_shadow(ui, ui->context_actions[i], item.x + 4, item.y + 3, 11, OSRS_ORANGE); + } +} + +static void draw_selected_target(const RuneCUiState *ui) { + if (ui->drag.active && ui->drag.source_kind == RUNEC_UI_CONTEXT_INVENTORY + && ui->drag.source_slot >= 0 + && ui->drag.source_slot < RUNEC_UI_INV_SLOT_COUNT + && ui->inventory[ui->drag.source_slot].enabled) { + Vector2 mouse = GetMousePosition(); + Rectangle r = {mouse.x - 16, mouse.y - 16, + RUNEC_OSRS_INVENTORY_SLOT_W, + RUNEC_OSRS_INVENTORY_SLOT_H}; + DrawRectangleRec(r, (Color){0, 0, 0, 80}); + draw_inventory_item(ui, &ui->inventory[ui->drag.source_slot], r); + } + if (ui->selected_target.kind == RUNEC_UI_SELECTED_NONE) + return; + Vector2 mouse = GetMousePosition(); + char text[96]; + snprintf(text, sizeof(text), "%s %s ->", ui->selected_target.verb, + ui->selected_target.label); + Font font = runec_ui_font_for_size(&ui->assets, 12.0f); + int width = (int)ceilf(MeasureTextEx( + font, text, 12.0f, 0.0f).x) + 10; + Rectangle box = {mouse.x + 12, mouse.y + 12, (float)width, 20}; + DrawRectangleRec(box, (Color){28, 23, 17, 230}); + DrawRectangleLinesEx(box, 1, (Color){170, 137, 72, 255}); + draw_text_shadow(ui, text, box.x + 5, box.y + 4, 11, OSRS_YELLOW); +} + +void runec_ui_draw(RuneCUiState *ui, int screen_w, int screen_h) { + RuneCUiLayout layout; + ui_layout(screen_w, screen_h, &layout); + + draw_chat_panel_chrome(ui, &layout); + draw_minimap(ui, &layout); + draw_side(ui, &layout); + draw_selected_target(ui); + draw_context(ui); +} + +Rectangle runec_ui_chat_panel_rect(int screen_w, int screen_h) { + RuneCUiLayout layout; + ui_layout(screen_w, screen_h, &layout); + return layout.chat; +} + +#undef OSRS_ORANGE +#undef OSRS_YELLOW +#undef OSRS_GREEN +#undef OSRS_RED +#undef OSRS_BLUE +#undef OSRS_PANEL +#undef OSRS_TAB_PRESS_SECONDS +#undef RUNEC_UI_SPELL_COUNT +#undef RUNEC_UI_SPELL_COLS +#undef RUNEC_UI_SPELL_X0 +#undef RUNEC_UI_SPELL_Y0 +#undef RUNEC_UI_SPELL_STEP_X +#undef RUNEC_UI_SPELL_STEP_Y +#undef RUNEC_UI_SPELL_ICON_SIZE +#undef COMBAT_STYLE +#undef COMBAT_STYLE_HIDDEN + +#endif diff --git a/ocean/fight_caves/viewer.c b/ocean/fight_caves/viewer.c new file mode 100644 index 0000000000..bd11ed9cca --- /dev/null +++ b/ocean/fight_caves/viewer.c @@ -0,0 +1,2695 @@ +/* + * viewer.c — Fight Caves playable debug viewer. + * + * Phase 8: Human-playable Fight Caves with all backend systems connected. + * + * Controls: + * WASD — move (N/W/S/E) Space — pause/resume + * 1/2/3 — protect melee/range/magic Right — single-step tick + * F — eat shark Console — targets/wave/TPS + * P — drink prayer potion R — reset episode + * Tab — cycle attack target A — toggle auto/manual + * O or D* — toggle debug overlay G — grid C — collision + * 4/5 — camera presets L — toggle camera lock + * Scroll — zoom Right-drag — orbit camera + * + * * D only toggles the overlay when not being used for east movement. + * Policy replay mode (`--policy-pipe`) also adds 1/2/4/0 playback presets. + * In replay mode, use Shift+4 / 5 for camera presets. + */ + +#include "raylib.h" +#include "rlgl.h" +#include "simulation.h" +#include "assets.h" +#include "render.h" +#include "ui.h" +#include +#include +#include +#include +#include +#include + +#define DEFAULT_WINDOW_W 1244 +#define DEFAULT_WINDOW_H 1064 +#define MAX_TPS 60.0f +#define MIN_TPS 0.25f +#define HALF_TPS 0.50f +#define NORMAL_TPS (5.0f / 3.0f) +#define POLICY_REPLAY_BASE_TPS NORMAL_TPS + +#define FC_UI_ITEM_VIAL 229u +#define FC_UI_ITEM_SHARK 385u +#define FC_UI_ITEM_PRAYER_POT_3 139u +#define FC_UI_ITEM_PRAYER_POT_2 141u +#define FC_UI_ITEM_PRAYER_POT_1 143u +#define FC_UI_ITEM_PRAYER_POT_4 2434u + +/* Colors */ +#define COL_BG CLITERAL(Color){ 80, 80, 85, 255 } +#define COL_PANEL CLITERAL(Color){ 62, 53, 41, 255 } +#define COL_PANEL_BORDER CLITERAL(Color){ 42, 36, 28, 255 } +#define COL_TEXT_YELLOW CLITERAL(Color){ 255, 255, 0, 255 } +#define COL_TEXT_WHITE CLITERAL(Color){ 255, 255, 255, 255 } +#define COL_TEXT_SHADOW CLITERAL(Color){ 0, 0, 0, 255 } +#define COL_TEXT_DIM CLITERAL(Color){ 130, 130, 140, 255 } +#define COL_TEXT_GREEN CLITERAL(Color){ 100, 255, 100, 255 } +#define COL_HP_GREEN CLITERAL(Color){ 30, 255, 30, 255 } +#define COL_HP_RED CLITERAL(Color){ 120, 0, 0, 255 } +#define COL_PRAY_BLUE CLITERAL(Color){ 50, 120, 210, 255 } +#define COL_PLAYER CLITERAL(Color){ 80, 140, 255, 255 } +#define COL_GRID CLITERAL(Color){ 30, 30, 30, 80 } +#define COL_BLOCKED CLITERAL(Color){ 180, 30, 30, 60 } +#define COL_WALKABLE CLITERAL(Color){ 30, 120, 30, 30 } +#define COL_HIT_RED CLITERAL(Color){ 255, 50, 50, 255 } +#define COL_HIT_BLUE CLITERAL(Color){ 50, 100, 255, 255 } + +#define FC_REWARD_CONFIG_PATH_MAX 256 +#define FC_WORLD_ORIGIN_X 2368 +#define FC_WORLD_ORIGIN_Y 5056 +typedef struct { + AnimModelState* anim_state; + uint16_t anim_seq; + int anim_frame; + float anim_timer; +} ObjectAnimRuntime; + +/* NPC colors by type */ +static const Color NPC_COLORS[] = { + {128,128,128,255}, /* 0: none */ + {180,160,60,255}, /* 1: Tz-Kih (yellow) */ + {100,180,60,255}, /* 2: Tz-Kek (green) */ + {80,150,50,255}, /* 3: Tz-Kek small */ + {60,60,200,255}, /* 4: Tok-Xil (blue) */ + {200,100,60,255}, /* 5: Yt-MejKot (orange) */ + {160,40,160,255}, /* 6: Ket-Zek (purple) */ + {200,40,40,255}, /* 7: TzTok-Jad (RED) */ + {60,200,200,255}, /* 8: Yt-HurKot (cyan) */ +}; + +/* Viewer state */ +typedef struct { + FcState state; + FcRenderEvents render_events; + FcActorAnimation actor_animation; + FcCombatPresentation* combat_presentation; + RuneCUiState ui; + FcRenderEntity entities[FC_MAX_RENDER_ENTITIES]; + int entity_count; + int paused, step_once; + float tps; + float tick_acc; + int show_grid, show_collision; + Camera3D camera; + float cam_yaw, cam_pitch, cam_dist; + int camera_locked; + int actions[FC_NUM_ACTION_HEADS]; + uint32_t seed, last_hash; + int episode_count; + int attack_target; /* NPC slot index for attack (-1 = none) */ + /* Terrain + Objects + NPC models */ + TerrainMesh* terrain; + FcMinimapScene minimap_scene; + ObjectMesh* objects; + ObjectAnimSet* object_anims; + FcAnimatedAtlas shared_model_atlas; + NpcModelSet* object_anim_models; + ObjectAnimRuntime* object_anim_runtimes; + int object_anim_runtime_count; + NpcModelSet* npc_models; + NpcModelSet* player_model; + /* Animation cache (shared by player + all NPCs) */ + AnimCache* anim_cache; + /* Buffered key inputs (captured every frame, consumed on tick) */ + int pending_prayer, pending_eat, pending_drink; + int pending_attack_npc; + int pending_tile_x, pending_tile_y; + FcClickFeedback click_feedback; + int console_tab; /* controls, player, obs, mask, reward, log */ + int console_wave_dropdown_open; + int console_scroll[4]; /* player/obs/mask/reward vertical offsets */ + int console_content_height[4]; + /* Prayer overhead icon textures */ + Texture2D pray_melee_tex, pray_missiles_tex, pray_magic_tex; + Texture2D click_cross_tex[FC_CLICK_CROSS_FRAME_COUNT * 2]; + int active_loadout; /* index into FC_LOADOUTS[] */ + int combat_style; /* 0=accurate, 1=rapid, 2=long range */ + /* Prayer-tab sprites used by the active RuneC side interface. */ + Texture2D tex_pray_melee_on, tex_pray_melee_off; + Texture2D tex_pray_range_on, tex_pray_range_off; + Texture2D tex_pray_magic_on, tex_pray_magic_off; + /* Debug overlay (Phase 9c) — toggled with O key */ + int dbg_flags; /* bitmask of DBG_* flags from fc_debug_overlay.h */ + /* Debug toggles */ + int godmode; /* 1 = player can't die */ + int policy_pipe; /* 1 = read actions from stdin, write obs to stdout */ + int policy_episode_limit; /* 0 = unlimited auto-reset, >0 = stop after N episodes */ + int policy_episode_count; /* number of completed policy-pipe episodes */ + int start_wave; /* 0 = wave 1 (default), >0 = skip to this wave on reset */ + int initial_sharks; + int initial_prayer_doses; + FcRewardParams reward_params; + FcRewardRuntime reward_runtime; + FcRewardBreakdown reward_breakdown; + int reward_breakdown_tick; + int reward_config_loaded; + char reward_config_path[FC_REWARD_CONFIG_PATH_MAX]; + /* Obs ablation flags (matches FightCaves env). Applied AFTER fc_write_obs + * in write_obs_to_pipe so policy replay sees the same obs distribution it + * was trained on. See fc_apply_obs_ablation in the core fc_state.c. */ + int obs_ablate_npc_distance; + int obs_ablate_incoming_aggregates; + int obs_ablate_npc_valid; +} ViewerState; + +/* Forward declarations */ +static void draw_tex_fit(Texture2D tex, int dx, int dy, int dw, int dh, + Color tint); + +static void viewer_trace_log_to_stderr(int log_level, const char* text, + va_list args) { + (void)log_level; + vfprintf(stderr, text, args); + fputc('\n', stderr); +} + +static void set_ui_slot(RuneCUiSlot* slot, uint32_t item_id, + uint32_t icon_item_id, int quantity, + const char* label) { + if (!slot) return; + memset(slot, 0, sizeof(*slot)); + if (item_id == 0 || quantity <= 0) return; + slot->item_id = item_id; + slot->icon_item_id = icon_item_id ? icon_item_id : item_id; + slot->quantity = quantity; + snprintf(slot->label, sizeof(slot->label), "%s", label ? label : "Item"); + slot->enabled = 1; +} + +static uint32_t prayer_potion_item_id_for_doses(int doses) { + switch (doses) { + case 4: return FC_UI_ITEM_PRAYER_POT_4; + case 3: return FC_UI_ITEM_PRAYER_POT_3; + case 2: return FC_UI_ITEM_PRAYER_POT_2; + case 1: return FC_UI_ITEM_PRAYER_POT_1; + default: return FC_UI_ITEM_VIAL; + } +} + +static const char* prayer_potion_label_for_doses(int doses) { + switch (doses) { + case 4: return "Prayer potion(4)"; + case 3: return "Prayer potion(3)"; + case 2: return "Prayer potion(2)"; + case 1: return "Prayer potion(1)"; + default: return "Vial"; + } +} + +static uint32_t fc_ui_active_prayer_bits(int prayer) { + switch (prayer) { + case PRAYER_PROTECT_MAGIC: return 1u << 16; + case PRAYER_PROTECT_RANGE: return 1u << 17; + case PRAYER_PROTECT_MELEE: return 1u << 18; + default: return 0; + } +} + +static int fc_ui_prayer_action_for_slot(const FcPlayer* p, int slot) { + int prayer = PRAYER_NONE; + int action = FC_PRAYER_OFF; + if (slot == 16) { + prayer = PRAYER_PROTECT_MAGIC; + action = FC_PRAYER_MAGIC; + } else if (slot == 17) { + prayer = PRAYER_PROTECT_RANGE; + action = FC_PRAYER_RANGE; + } else if (slot == 18) { + prayer = PRAYER_PROTECT_MELEE; + action = FC_PRAYER_MELEE; + } else { + return 0; + } + if (!p || p->current_prayer <= 0) return 0; + return p->prayer == prayer ? FC_PRAYER_OFF : action; +} + +static void queue_viewer_prayer_button(ViewerState* v, int prayer, int action) { + if (!v) return; + FcPlayer* p = &v->state.player; + if (p->current_prayer <= 0) + return; + v->pending_prayer = (p->prayer == prayer) ? FC_PRAYER_OFF : action; +} + +static int load_ui_item_icon(RuneCUiState* ui, uint32_t item_id) { + if (!ui || item_id == 0) return 0; + char path[128]; + snprintf(path, sizeof(path), "data/sprites/items/item_%u.png", item_id); + if (!fc_asset_exists(path)) return 0; + Texture2D tex = fc_load_texture_asset(path); + if (tex.id == 0) return 0; + SetTextureFilter(tex, TEXTURE_FILTER_POINT); + runec_ui_set_item_icon(ui, item_id, tex); + return 1; +} + +static int load_fc_ui_item_icons(ViewerState* v) { + static const uint32_t ids[] = { + FC_UI_ITEM_VIAL, FC_UI_ITEM_SHARK, + FC_UI_ITEM_PRAYER_POT_1, FC_UI_ITEM_PRAYER_POT_2, + FC_UI_ITEM_PRAYER_POT_3, FC_UI_ITEM_PRAYER_POT_4, + }; + if (!v) return 0; + int ready = 1; + for (int i = 0; i < (int)(sizeof(ids) / sizeof(ids[0])); i++) + ready &= load_ui_item_icon(&v->ui, ids[i]); + for (int li = 0; li < FC_NUM_LOADOUTS; li++) { + const FcLoadout* lo = &FC_LOADOUTS[li]; + for (int ei = 0; ei < lo->equipment_count; ei++) { + uint32_t icon_id = lo->equipment[ei].icon_item_id + ? lo->equipment[ei].icon_item_id + : lo->equipment[ei].item_id; + ready &= load_ui_item_icon(&v->ui, icon_id); + } + } + return ready; +} + +static void sync_fc_ui_items(ViewerState* v) { + if (!v) return; + FcPlayer* p = &v->state.player; + for (int i = 0; i < RUNEC_UI_INV_SLOT_COUNT; i++) + memset(&v->ui.inventory[i], 0, sizeof(v->ui.inventory[i])); + + int doses = p->prayer_doses_remaining; + if (doses < 0) doses = 0; + if (doses > FC_MAX_PRAYER_DOSES) doses = FC_MAX_PRAYER_DOSES; + int full_pots = doses / 4; + int partial = doses % 4; + for (int slot = 0; slot < 8; slot++) { + int slot_doses = 0; + if (slot < full_pots) slot_doses = 4; + else if (slot == full_pots && partial > 0) slot_doses = partial; + uint32_t item_id = prayer_potion_item_id_for_doses(slot_doses); + set_ui_slot(&v->ui.inventory[slot], item_id, item_id, 1, + prayer_potion_label_for_doses(slot_doses)); + } + for (int slot = 8; slot < RUNEC_UI_INV_SLOT_COUNT; slot++) { + if (slot - 8 < p->sharks_remaining) { + set_ui_slot(&v->ui.inventory[slot], FC_UI_ITEM_SHARK, + FC_UI_ITEM_SHARK, 1, "Shark"); + } + } + + for (int i = 0; i < RUNEC_UI_EQUIP_SLOT_COUNT; i++) + memset(&v->ui.equipment[i], 0, sizeof(v->ui.equipment[i])); + + int loadout = v->active_loadout; + if (loadout < 0 || loadout >= FC_NUM_LOADOUTS) + loadout = FC_ACTIVE_LOADOUT; + const FcLoadout* lo = &FC_LOADOUTS[loadout]; + for (int i = 0; i < lo->equipment_count; i++) { + const FcLoadoutEquipmentItem* equip = &lo->equipment[i]; + if (equip->slot >= 0 && equip->slot < RUNEC_UI_EQUIP_SLOT_COUNT) { + uint32_t icon_id = equip->icon_item_id ? equip->icon_item_id : equip->item_id; + int quantity = equip->slot == FC_EQUIP_SLOT_AMMO ? p->ammo_count : 1; + set_ui_slot(&v->ui.equipment[equip->slot], equip->item_id, + icon_id, quantity, equip->label); + } + } +} + +static void sync_fc_ui_status(ViewerState* v) { + if (!v) return; + FcPlayer* p = &v->state.player; + v->ui.hitpoints = p->current_hp > 0 ? (p->current_hp + 9) / 10 : 0; + v->ui.hitpoints_max = p->max_hp > 0 ? (p->max_hp + 9) / 10 : 0; + v->ui.prayer_points = p->current_prayer > 0 ? (p->current_prayer + 9) / 10 : 0; + v->ui.prayer_points_max = p->max_prayer > 0 ? (p->max_prayer + 9) / 10 : 0; + v->ui.active_prayers = fc_ui_active_prayer_bits( + fc_actor_animation_render_prayer(&v->actor_animation, &v->state)); + v->ui.run_energy = p->run_energy / 100; + if (v->ui.run_energy < 0) v->ui.run_energy = 0; + if (v->ui.run_energy > 100) v->ui.run_energy = 100; + v->ui.run_enabled = p->is_running != 0; + v->ui.selected_combat_style = v->combat_style == 2 ? 3 : v->combat_style; + v->ui.auto_retaliate = 1; + v->ui.special_attack_energy = 100; + v->ui.combat_level = 126; + int loadout = v->active_loadout; + if (loadout < 0 || loadout >= FC_NUM_LOADOUTS) + loadout = FC_ACTIVE_LOADOUT; + const FcLoadout* lo = &FC_LOADOUTS[loadout]; + runec_ui_set_combat_weapon_name(&v->ui, lo->weapon_name); + runec_ui_set_combat_style_profile(&v->ui, lo->combat_style_profile); + + for (int i = 0; i < RUNEC_UI_SKILL_COUNT; i++) { + v->ui.skill_current[i] = 1; + v->ui.skill_base[i] = 1; + } + v->ui.skill_current[0] = v->ui.skill_base[0] = p->attack_level; + v->ui.skill_current[1] = v->ui.skill_base[1] = p->strength_level; + v->ui.skill_current[2] = v->ui.skill_base[2] = p->defence_level; + v->ui.skill_current[3] = v->ui.skill_base[3] = p->ranged_level; + v->ui.skill_current[4] = v->ui.skill_base[4] = p->prayer_level; + v->ui.skill_current[5] = v->ui.skill_base[5] = p->magic_level; + v->ui.skill_current[8] = v->ui.skill_base[8] = p->max_hp / 10; + int total = 0; + for (int i = 0; i < RUNEC_UI_SKILL_COUNT; i++) + total += v->ui.skill_base[i]; + v->ui.skill_total = total; +} + +static void sync_fc_ui_minimap(ViewerState* v) { + if (!v) return; + FcPlayer* p = &v->state.player; + FcVisualPose player_pose = + fc_visual_scene_player_pose(&v->actor_animation.scene); + float player_x = v->actor_animation.scene.player.active + ? player_pose.x : (float)p->x + 0.5f; + float player_y = v->actor_animation.scene.player.active + ? player_pose.y : (float)p->y + 0.5f; + Color pixels[FC_MINIMAP_DISPLAY_SIZE * FC_MINIMAP_DISPLAY_SIZE]; + fc_minimap_render(&v->minimap_scene, player_x, player_y, v->cam_yaw, + pixels); + runec_ui_update_minimap(&v->ui, pixels, FC_MINIMAP_DISPLAY_SIZE, + FC_MINIMAP_DISPLAY_SIZE); + runec_ui_set_minimap_rotation(&v->ui, v->cam_yaw); + + runec_ui_clear_minimap(&v->ui); + runec_ui_add_minimap_dot(&v->ui, 0.0f, 0.0f, RUNEC_UI_MINIMAP_DOT_PLAYER); + if (v->click_feedback.destination_active) { + int tx = v->click_feedback.destination_x; + int ty = v->click_feedback.destination_y; + Vector2 offset = fc_minimap_rotate_offset( + (float)tx + 0.5f - player_x, + (float)ty + 0.5f - player_y, v->cam_yaw); + runec_ui_add_minimap_dot(&v->ui, offset.x, offset.y, + RUNEC_UI_MINIMAP_DOT_DESTINATION); + } else if (p->route_idx < p->route_len && p->route_len > 0) { + int tx = p->route_x[p->route_len - 1]; + int ty = p->route_y[p->route_len - 1]; + Vector2 offset = fc_minimap_rotate_offset( + (float)tx + 0.5f - player_x, + (float)ty + 0.5f - player_y, v->cam_yaw); + runec_ui_add_minimap_dot(&v->ui, offset.x, offset.y, + RUNEC_UI_MINIMAP_DOT_DESTINATION); + } + for (int i = 0; i < FC_MAX_NPCS; i++) { + FcNpc* n = &v->state.npcs[i]; + if (!n->active || n->is_dead) continue; + FcVisualPose npc_pose = + fc_visual_scene_npc_pose(&v->actor_animation.scene, i); + float npc_x = v->actor_animation.scene.npcs[i].active + ? npc_pose.x : (float)n->x + (float)n->size * 0.5f; + float npc_y = v->actor_animation.scene.npcs[i].active + ? npc_pose.y : (float)n->y + (float)n->size * 0.5f; + Vector2 offset = fc_minimap_rotate_offset( + npc_x - player_x, npc_y - player_y, v->cam_yaw); + runec_ui_add_minimap_dot(&v->ui, offset.x, offset.y, + RUNEC_UI_MINIMAP_DOT_NPC); + } +} + +static void sync_fc_ui(ViewerState* v) { + sync_fc_ui_items(v); + sync_fc_ui_status(v); + sync_fc_ui_minimap(v); +} + +static void queue_player_tile_request(ViewerState* v, int tx, int ty, + float screen_x, float screen_y) { + if (!v || tx < 0 || tx >= FC_ARENA_WIDTH || ty < 0 || ty >= FC_ARENA_HEIGHT) + return; + v->pending_tile_x = tx; + v->pending_tile_y = ty; + v->pending_attack_npc = -1; + fc_click_feedback_select_move(&v->click_feedback, &v->state, tx, ty, + screen_x, screen_y); +} + +static void queue_player_attack_request(ViewerState* v, int npc_idx, + float screen_x, float screen_y) { + if (!v || npc_idx < 0 || npc_idx >= FC_MAX_NPCS) return; + v->pending_attack_npc = npc_idx; + v->pending_tile_x = -1; + v->pending_tile_y = -1; + fc_click_feedback_select_interaction(&v->click_feedback, + screen_x, screen_y); +} + +static void handle_runec_ui_intent(ViewerState* v) { + if (!v) return; + RuneCUiIntent* intent = &v->ui.last_intent; + FcPlayer* p = &v->state.player; + switch (intent->kind) { + case RUNEC_UI_INTENT_INVENTORY_SLOT: + if (intent->primary >= 0 && intent->primary < 8) { + int full_pots = p->prayer_doses_remaining / 4; + int partial = p->prayer_doses_remaining % 4; + if (intent->primary < full_pots || + (intent->primary == full_pots && partial > 0)) + v->pending_drink = FC_DRINK_PRAYER_POT; + } else if (intent->primary >= 8 && intent->primary < 28) { + if (intent->primary - 8 < p->sharks_remaining) + v->pending_eat = FC_EAT_SHARK; + } + break; + case RUNEC_UI_INTENT_INVENTORY_ACTION: + if (strcmp(intent->text, "Use") == 0 || strcmp(intent->text, "Drink") == 0) + v->pending_drink = FC_DRINK_PRAYER_POT; + else if (strcmp(intent->text, "Eat") == 0) + v->pending_eat = FC_EAT_SHARK; + break; + case RUNEC_UI_INTENT_PRAYER_SLOT: { + int action = fc_ui_prayer_action_for_slot(p, intent->primary); + if (action) v->pending_prayer = action; + break; + } + case RUNEC_UI_INTENT_COMBAT_STYLE: + v->combat_style = intent->primary == 3 ? 2 : intent->primary; + if (v->combat_style < 0) v->combat_style = 0; + if (v->combat_style > 2) v->combat_style = 2; + break; + case RUNEC_UI_INTENT_RUN_TOGGLE: + if (!v->policy_pipe) { + fc_request_set_running(&v->state, !p->is_running); + v->ui.run_enabled = p->is_running != 0; + } + break; + case RUNEC_UI_INTENT_MINIMAP_CLICK: { + FcVisualPose player_pose = + fc_visual_scene_player_pose(&v->actor_animation.scene); + float player_x = v->actor_animation.scene.player.active + ? player_pose.x : (float)p->x + 0.5f; + float player_y = v->actor_animation.scene.player.active + ? player_pose.y : (float)p->y + 0.5f; + int tile_x = -1; + int tile_y = -1; + Vector2 mouse = GetMousePosition(); + if (fc_minimap_click_to_tile( + (float)intent->primary, (float)intent->secondary, + player_x, player_y, v->cam_yaw, &tile_x, &tile_y)) { + queue_player_tile_request(v, tile_x, tile_y, + mouse.x, mouse.y); + } + break; + } + default: + break; + } +} + +static void text_s(const char* t, int x, int y, int sz, Color c) { + fc_osrs_draw_text(t, x+1, y+1, sz, COL_TEXT_SHADOW); + fc_osrs_draw_text(t, x, y, sz, c); +} + +static const char* fc_terminal_name(int terminal) { + switch (terminal) { + case TERMINAL_PLAYER_DEATH: return "player_death"; + case TERMINAL_CAVE_COMPLETE: return "cave_complete"; + case TERMINAL_TICK_CAP: return "tick_cap"; + default: return "none"; + } +} + +static int float_near(float a, float b) { + return fabsf(a - b) < 0.0001f; +} + +static char* trim_ascii(char* s) { + while (*s && isspace((unsigned char)*s)) s++; + if (*s == '\0') return s; + + char* end = s + strlen(s) - 1; + while (end >= s && isspace((unsigned char)*end)) { + *end = '\0'; + end--; + } + return s; +} + +static void reward_params_apply_key(FcRewardParams* params, + const char* key, + const char* value) { + if (strcmp(key, "w_damage_dealt") == 0) params->w_damage_dealt = strtof(value, NULL); + else if (strcmp(key, "w_progress") == 0) params->w_progress = strtof(value, NULL); + else if (strcmp(key, "negative_progress_multiplier") == 0) params->negative_progress_multiplier = strtof(value, NULL); + else if (strcmp(key, "w_damage_taken") == 0) params->w_damage_taken = strtof(value, NULL); + else if (strcmp(key, "w_npc_kill") == 0) params->w_npc_kill = strtof(value, NULL); + else if (strcmp(key, "w_wave_clear") == 0) params->w_wave_clear = strtof(value, NULL); + else if (strcmp(key, "w_jad_kill") == 0) params->w_jad_kill = strtof(value, NULL); + else if (strcmp(key, "w_cave_complete") == 0) params->w_cave_complete = strtof(value, NULL); + else if (strcmp(key, "w_player_death") == 0) params->w_player_death = strtof(value, NULL); + else if (strcmp(key, "scale_player_death_with_progress") == 0) params->scale_player_death_with_progress = (int)strtol(value, NULL, 10); + else if (strcmp(key, "player_death_min_scale") == 0) params->player_death_min_scale = strtof(value, NULL); + else if (strcmp(key, "w_correct_jad_prayer") == 0) params->w_correct_jad_prayer = strtof(value, NULL); + else if (strcmp(key, "w_correct_danger_prayer") == 0) params->w_correct_danger_prayer = strtof(value, NULL); + else if (strcmp(key, "w_prayer_lost") == 0) params->w_prayer_lost = strtof(value, NULL); + else if (strcmp(key, "w_invalid_action") == 0) params->w_invalid_action = strtof(value, NULL); + else if (strcmp(key, "w_tick_penalty") == 0) params->w_tick_penalty = strtof(value, NULL); + else if (strcmp(key, "shape_unnecessary_prayer_penalty") == 0) params->shape_unnecessary_prayer_penalty = strtof(value, NULL); + else if (strcmp(key, "shape_wave_stall_base_penalty") == 0) params->shape_wave_stall_base_penalty = strtof(value, NULL); + else if (strcmp(key, "shape_wave_stall_cap") == 0) params->shape_wave_stall_cap = strtof(value, NULL); + else if (strcmp(key, "shape_wave_stall_start") == 0) params->shape_wave_stall_start = (int)strtol(value, NULL, 10); + else if (strcmp(key, "shape_wave_stall_ramp_interval") == 0) params->shape_wave_stall_ramp_interval = (int)strtol(value, NULL, 10); + else if (strcmp(key, "shape_jad_heal_penalty") == 0) params->shape_jad_heal_penalty = strtof(value, NULL); + else if (strcmp(key, "shape_npc_heal_penalty") == 0) params->shape_npc_heal_penalty = strtof(value, NULL); + else if (strcmp(key, "shape_no_progress_penalty_1") == 0) params->shape_no_progress_penalty_1 = strtof(value, NULL); + else if (strcmp(key, "shape_no_progress_penalty_2") == 0) params->shape_no_progress_penalty_2 = strtof(value, NULL); + else if (strcmp(key, "shape_no_progress_penalty_3") == 0) params->shape_no_progress_penalty_3 = strtof(value, NULL); + else if (strcmp(key, "shape_no_attack_base_penalty") == 0) params->shape_no_attack_base_penalty = strtof(value, NULL); + else if (strcmp(key, "shape_no_attack_wave_scale") == 0) params->shape_no_attack_wave_scale = strtof(value, NULL); + else if (strcmp(key, "shape_no_progress_start_1") == 0) params->shape_no_progress_start_1 = (int)strtol(value, NULL, 10); + else if (strcmp(key, "shape_no_progress_start_2") == 0) params->shape_no_progress_start_2 = (int)strtol(value, NULL, 10); + else if (strcmp(key, "shape_no_progress_start_3") == 0) params->shape_no_progress_start_3 = (int)strtol(value, NULL, 10); + else if (strcmp(key, "shape_no_attack_start") == 0) params->shape_no_attack_start = (int)strtol(value, NULL, 10); +} + +static void obs_ablation_apply_key(ViewerState* v, + const char* key, + const char* value) { + if (strcmp(key, "obs_ablate_npc_distance") == 0) + v->obs_ablate_npc_distance = (int)strtol(value, NULL, 10); + else if (strcmp(key, "obs_ablate_incoming_aggregates") == 0) + v->obs_ablate_incoming_aggregates = (int)strtol(value, NULL, 10); + else if (strcmp(key, "obs_ablate_npc_valid") == 0) + v->obs_ablate_npc_valid = (int)strtol(value, NULL, 10); +} + +static void initial_supplies_apply_key(ViewerState* v, + const char* key, + const char* value) { + if (strcmp(key, "initial_sharks") == 0) + v->initial_sharks = (int)strtol(value, NULL, 10); + else if (strcmp(key, "initial_prayer_doses") == 0) + v->initial_prayer_doses = (int)strtol(value, NULL, 10); +} + +static void apply_initial_supplies(ViewerState* v) { + if (v->initial_sharks < 0) v->initial_sharks = 0; + if (v->initial_sharks > FC_MAX_SHARKS) v->initial_sharks = FC_MAX_SHARKS; + if (v->initial_prayer_doses < 0) v->initial_prayer_doses = 0; + if (v->initial_prayer_doses > FC_MAX_PRAYER_DOSES) + v->initial_prayer_doses = FC_MAX_PRAYER_DOSES; + v->state.player.sharks_remaining = v->initial_sharks; + v->state.player.prayer_doses_remaining = v->initial_prayer_doses; +} + +static void load_reward_params(ViewerState* v) { + v->reward_params = fc_reward_default_params(); + v->initial_sharks = 0; + v->initial_prayer_doses = 0; + v->obs_ablate_npc_distance = 0; + v->obs_ablate_incoming_aggregates = 0; + v->obs_ablate_npc_valid = 0; + v->reward_config_loaded = 0; + snprintf(v->reward_config_path, sizeof(v->reward_config_path), "%s", "defaults"); + + { + char config_path[FC_ASSET_PATH_MAX]; + FILE* f; + if (!fc_repo_resolve_path("config/fight_caves.ini", + config_path, sizeof(config_path))) { + return; + } + f = fopen(config_path, "r"); + if (!f) return; + + char line[512]; + int in_env = 0; + while (fgets(line, sizeof(line), f)) { + char* comment = strchr(line, '#'); + if (comment) *comment = '\0'; + + char* text = trim_ascii(line); + if (*text == '\0') continue; + + if (*text == '[') { + char* close = strchr(text, ']'); + if (!close) continue; + *close = '\0'; + in_env = (strcmp(text + 1, "env") == 0); + continue; + } + + if (!in_env) continue; + + char* eq = strchr(text, '='); + if (!eq) continue; + *eq = '\0'; + + char* key = trim_ascii(text); + char* value = trim_ascii(eq + 1); + if (*key == '\0' || *value == '\0') continue; + + reward_params_apply_key(&v->reward_params, key, value); + obs_ablation_apply_key(v, key, value); + initial_supplies_apply_key(v, key, value); + } + + fclose(f); + v->reward_config_loaded = 1; + strncpy(v->reward_config_path, config_path, sizeof(v->reward_config_path) - 1); + v->reward_config_path[sizeof(v->reward_config_path) - 1] = '\0'; + return; + } +} + +static void reset_reward_tracking(ViewerState* v) { + fc_reward_runtime_reset(&v->reward_runtime); + memset(&v->reward_breakdown, 0, sizeof(v->reward_breakdown)); + v->reward_breakdown_tick = -1; +} + +static void update_reward_breakdown(ViewerState* v) { + if (v->reward_breakdown_tick == v->state.tick) return; + v->reward_breakdown = fc_reward_compute_breakdown( + &v->state, &v->reward_params, &v->reward_runtime); + fc_reward_sync_progress_state(&v->state, &v->reward_runtime); + v->reward_breakdown_tick = v->state.tick; +} + +static const float MANUAL_TPS_PRESETS[] = { + 0.25f, 0.50f, NORMAL_TPS, 4.0f, 10.0f, 15.0f, 30.0f, 60.0f +}; +static const char* MANUAL_TPS_LABELS[] = { + "0.25", "0.5", "5/3", "4", "10", "15", "30", "60" +}; +#define NUM_MANUAL_TPS_PRESETS ((int)(sizeof(MANUAL_TPS_PRESETS) / sizeof(MANUAL_TPS_PRESETS[0]))) + +static float policy_replay_multiplier_to_tps(int multiplier) { + switch (multiplier) { + case 1: return (float)POLICY_REPLAY_BASE_TPS; + case 2: return (float)(POLICY_REPLAY_BASE_TPS * 2); + case 4: return (float)(POLICY_REPLAY_BASE_TPS * 4); + case 10: return (float)(POLICY_REPLAY_BASE_TPS * 10); + default: return (float)POLICY_REPLAY_BASE_TPS; + } +} + +static int policy_replay_tps_to_multiplier(float tps) { + if (float_near(tps, (float)POLICY_REPLAY_BASE_TPS)) return 1; + if (float_near(tps, (float)(POLICY_REPLAY_BASE_TPS * 2))) return 2; + if (float_near(tps, (float)(POLICY_REPLAY_BASE_TPS * 4))) return 4; + if (float_near(tps, (float)(POLICY_REPLAY_BASE_TPS * 10))) return 10; + return 0; +} + +static int policy_replay_normalize_multiplier(int multiplier) { + switch (multiplier) { + case 1: + case 2: + case 4: + case 10: + return multiplier; + default: + return 1; + } +} + +static void set_viewer_tps(ViewerState* v, float tps) { + if (tps < MIN_TPS) tps = MIN_TPS; + if (tps > MAX_TPS) tps = MAX_TPS; + v->tps = tps; + if (v->tick_acc >= 1.0f) + v->tick_acc = fmodf(v->tick_acc, 1.0f); +} + +static void set_policy_replay_speed(ViewerState* v, int multiplier) { + int normalized = policy_replay_normalize_multiplier(multiplier); + set_viewer_tps(v, policy_replay_multiplier_to_tps(normalized)); + fprintf(stderr, "[policy-pipe] Replay speed set to %dx (%.2f TPS)\n", + normalized, v->tps); +} + +static void cycle_policy_replay_speed(ViewerState* v, int direction) { + static const int presets[] = {1, 2, 4, 10}; + int current = policy_replay_tps_to_multiplier(v->tps); + int idx = 0; + + for (int i = 0; i < 4; i++) { + if (presets[i] == current) { + idx = i; + break; + } + } + + idx += direction; + if (idx < 0) idx = 0; + if (idx > 3) idx = 3; + set_policy_replay_speed(v, presets[idx]); +} + +static void set_manual_speed(ViewerState* v, float tps) { + float best = MANUAL_TPS_PRESETS[0]; + float best_diff = fabsf(tps - best); + for (int i = 1; i < NUM_MANUAL_TPS_PRESETS; i++) { + float diff = fabsf(tps - MANUAL_TPS_PRESETS[i]); + if (diff < best_diff) { + best = MANUAL_TPS_PRESETS[i]; + best_diff = diff; + } + } + set_viewer_tps(v, best); +} + +static void toggle_debug_overlay(ViewerState* v) { + if (!v) return; + v->dbg_flags = v->dbg_flags ? 0 : DBG_ALL; +} + +static void toggle_godmode(ViewerState* v) { + if (!v) return; + v->godmode = !v->godmode; + fprintf(stderr, "GODMODE: %s\n", v->godmode ? "ON" : "OFF"); +} + +static void print_policy_episode_summary(const ViewerState* v) { + const FcState* s = &v->state; + FcEpisodeSummary summary; + fc_episode_summary_build(s, s->tick, &summary); + + fprintf(stderr, + "[policy-pipe] episode_summary " + "{\"episode\":%d,\"seed\":%u,\"terminal\":\"%s\"," + "\"env/episode_length\":%d," + "\"env/wave_reached\":%d," + "\"env/most_npcs_slayed\":%d," + "\"env/prayer_uptime_melee\":%.6f," + "\"env/prayer_uptime_range\":%.6f," + "\"env/prayer_uptime_magic\":%.6f," + "\"env/correct_prayer\":%d," + "\"env/wrong_prayer_hits\":%d," + "\"env/no_prayer_hits\":%d," + "\"env/prayer_switches\":%d," + "\"env/damage_blocked\":%d," + "\"env/dmg_taken_avg\":%d," + "\"env/attack_when_ready_rate\":%.6f," + "\"env/tokxil_melee_ticks\":%d," + "\"env/ketzek_melee_ticks\":%d," + "\"env/max_wave_ticks\":%d," + "\"env/max_wave_ticks_wave\":%d," + "\"env/reached_wave_63\":%d," + "\"env/jad_kill_rate\":%d," + "\"env/target_held_ticks\":%d," + "\"env/no_target_ticks\":%d," + "\"env/target_in_range_los_ticks\":%d," + "\"env/target_out_of_range_or_los_ticks\":%d," + "\"env/attack_cooldown_wait_ticks\":%d," + "\"env/ready_but_no_attack_ticks\":%d," + "\"env/action_move_idle_ticks\":%d," + "\"env/action_move_walk_ticks\":%d," + "\"env/action_move_run_ticks\":%d," + "\"env/action_attack_none_ticks\":%d," + "\"env/action_attack_target_ticks\":%d," + "\"env/action_prayer_noop_ticks\":%d," + "\"env/action_prayer_cmd_ticks\":%d", + v->policy_episode_count + 1, + v->seed, + fc_terminal_name(s->terminal), + summary.episode_length, + summary.wave_reached, + summary.npcs_slayed, + summary.prayer_uptime_melee, + summary.prayer_uptime_range, + summary.prayer_uptime_magic, + summary.correct_prayer, + summary.wrong_prayer_hits, + summary.no_prayer_hits, + summary.prayer_switches, + summary.damage_blocked, + summary.damage_taken, + summary.attack_when_ready_rate, + summary.tokxil_melee_ticks, + summary.ketzek_melee_ticks, + summary.max_wave_ticks, + summary.max_wave_ticks_wave, + summary.reached_wave_63, + summary.jad_killed, + summary.target_held_ticks, + summary.no_target_ticks, + summary.target_in_range_los_ticks, + summary.target_out_of_range_or_los_ticks, + summary.attack_cooldown_wait_ticks, + summary.ready_but_no_attack_ticks, + summary.action_move_idle_ticks, + summary.action_move_walk_ticks, + summary.action_move_run_ticks, + summary.action_attack_none_ticks, + summary.action_attack_target_ticks, + summary.action_prayer_noop_ticks, + summary.action_prayer_cmd_ticks); + + for (int i = 1; i < NPC_TYPE_COUNT; i++) { + const char* npc = fc_episode_npc_metric_name(i); + fprintf(stderr, + ",\"env/dmg_to_%s\":%d" + ",\"env/resolved_hits_to_%s\":%d" + ",\"env/damaging_hits_to_%s\":%d" + ",\"env/attack_cycles_to_%s\":%d" + ",\"env/target_ticks_%s\":%d", + npc, summary.damage_to_npc_type[i], + npc, summary.resolved_hits_to_npc_type[i], + npc, summary.damaging_hits_to_npc_type[i], + npc, summary.attack_cycles_to_npc_type[i], + npc, summary.target_ticks_by_npc_type[i]); + } + + fprintf(stderr, ",\"env/n\":1.0}\n"); +} + +static void reset_ep(ViewerState* v) { + load_reward_params(v); + reset_reward_tracking(v); + v->seed = (uint32_t)GetRandomValue(1, 999999); + fc_reset(&v->state, v->seed); + /* Skip to start_wave if set */ + if (v->start_wave > 1 && v->start_wave <= FC_NUM_WAVES) { + for (int i = 0; i < FC_MAX_NPCS; i++) { + v->state.npcs[i].active = 0; + v->state.npcs[i].is_dead = 0; + } + v->state.npcs_remaining = 0; + v->state.current_wave = v->start_wave; + fc_wave_spawn(&v->state, v->start_wave); + v->state.player.current_hp = v->state.player.max_hp; + v->state.player.current_prayer = v->state.player.max_prayer; + } + apply_initial_supplies(v); + fc_reward_runtime_begin_episode(&v->reward_runtime, &v->state); + fc_fill_render_entities(&v->state, v->entities, &v->entity_count); + fc_fill_render_events(&v->state, &v->render_events); + v->last_hash = fc_state_hash(&v->state); + v->episode_count++; + v->attack_target = -1; + memset(v->actions, 0, sizeof(v->actions)); + fc_combat_presentation_reset(v->combat_presentation); + fc_actor_animation_reset(&v->actor_animation, &v->state, + v->player_model, v->active_loadout); + v->pending_prayer = 0; + v->pending_eat = 0; + v->pending_drink = 0; + v->pending_attack_npc = -1; + v->pending_tile_x = -1; + v->pending_tile_y = -1; + fc_click_feedback_reset(&v->click_feedback); + dbg_log_clear(); +} + +static void viewer_jump_to_wave(ViewerState* v, int wave) { + if (!v) return; + if (wave < 1) wave = 1; + if (wave > FC_NUM_WAVES) wave = FC_NUM_WAVES; + + for (int i = 0; i < FC_MAX_NPCS; i++) { + v->state.npcs[i].active = 0; + v->state.npcs[i].is_dead = 0; + } + v->state.npcs_remaining = 0; + v->state.current_wave = wave; + fc_wave_spawn(&v->state, wave); + v->state.player.current_hp = v->state.player.max_hp; + v->state.player.current_prayer = v->state.player.max_prayer; + v->state.terminal = TERMINAL_NONE; + v->state.jad_healers_spawned = 0; + reset_reward_tracking(v); + fc_reward_runtime_begin_episode(&v->reward_runtime, &v->state); + fc_fill_render_entities(&v->state, v->entities, &v->entity_count); + fc_fill_render_events(&v->state, &v->render_events); + fc_combat_presentation_reset(v->combat_presentation); + fc_actor_animation_reset(&v->actor_animation, &v->state, + v->player_model, v->active_loadout); + v->attack_target = -1; + fc_click_feedback_reset(&v->click_feedback); + dbg_log_clear(); +} +/* Terrain loader — the terrain mesh is the floor heightmap. + * The red/black lava pattern comes from the objects mesh (fightcaves.objects), + * not the terrain. The terrain is just the ground surface. + * We keep original cache colors (dark base) without modification. */ +static TerrainMesh* load_terrain(ViewerState* v) { + (void)v; + TerrainMesh* tm = terrain_load("fightcaves.terrain"); + if (tm && tm->loaded) { + terrain_offset(tm, FC_WORLD_ORIGIN_X, FC_WORLD_ORIGIN_Y); + return tm; + } + return NULL; +} + +/* Objects loader — no modifications */ +static ObjectMesh* load_objects_with_terrain(TerrainMesh* tm) { + (void)tm; + ObjectMesh* om = objects_load("fightcaves.objects"); + if (om && om->loaded) { + objects_offset(om, FC_WORLD_ORIGIN_X, FC_WORLD_ORIGIN_Y); + + /* No modifications to objects mesh — original cache data */ + + return om; + } + return NULL; +} + +/* Forward declaration */ +static float ground_y(ViewerState* v, int tile_x, int tile_y); + +/* ======================================================================== */ +/* Human input → action heads */ +/* ======================================================================== */ + +/* Raycast from mouse position to find the tile coordinate on the ground plane */ +static int raycast_to_tile(ViewerState* v, int* out_x, int* out_y) { + Ray ray = GetScreenToWorldRay(GetMousePosition(), v->camera); + /* Intersect with Y = ground_y plane */ + float gy = ground_y(v, 32, 32); + if (fabsf(ray.direction.y) < 0.001f) return 0; /* ray parallel to ground */ + float t = (gy - ray.position.y) / ray.direction.y; + if (t < 0) return 0; /* behind camera */ + float wx = ray.position.x + ray.direction.x * t; + float wz = ray.position.z + ray.direction.z * t; + /* Convert to tile coords: X = world X, tile Y = -world Z */ + int tx = (int)floorf(wx); + int ty = (int)floorf(-wz); + if (tx < 0 || tx >= FC_ARENA_WIDTH || ty < 0 || ty >= FC_ARENA_HEIGHT) return 0; + *out_x = tx; + *out_y = ty; + return 1; +} + +/* Find NPC at clicked tile — checks LIVE state, not render snapshot. + * Returns NPC array index (0..FC_MAX_NPCS-1) or -1 if no NPC there. */ +static int find_clicked_npc_idx(ViewerState* v, int tile_x, int tile_y) { + int best = -1; + int best_dist = 999; + for (int i = 0; i < FC_MAX_NPCS; i++) { + FcNpc* n = &v->state.npcs[i]; + if (!n->active || n->is_dead) continue; + /* Check if tile is within the NPC's footprint (or 1 tile adjacent) */ + if (tile_x >= n->x - 1 && tile_x <= n->x + n->size && + tile_y >= n->y - 1 && tile_y <= n->y + n->size) { + /* Prefer the closest NPC center */ + int cx = n->x + n->size/2; + int cy = n->y + n->size/2; + int d = abs(tile_x - cx) + abs(tile_y - cy); + if (d < best_dist) { best_dist = d; best = i; } + } + } + return best; +} + +/* Called EVERY FRAME to capture clicks (which only fire once at 60fps). + * Buffers authoritative actions and starts presentation-only feedback. */ +static void process_human_clicks(ViewerState* v, int ui_capture) { + FcPlayer* p = &v->state.player; + + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + if (ui_capture) return; + Vector2 mpos = GetMousePosition(); + int tx = -1; + int ty = -1; + int rc = raycast_to_tile(v, &tx, &ty); + fprintf(stderr, "CLICK mouse=(%.0f,%.0f) raycast=%d tile=(%d,%d) player=(%d,%d)", + mpos.x, mpos.y, rc, tx, ty, p->x, p->y); + if (rc) { + int npc_idx = find_clicked_npc_idx(v, tx, ty); + if (npc_idx >= 0) { + queue_player_attack_request(v, npc_idx, mpos.x, mpos.y); + fprintf(stderr, " → ATTACK npc_idx=%d\n", npc_idx); + } else { + int walkable = v->state.walkable[tx][ty]; + fprintf(stderr, " walkable=%d", walkable); + queue_player_tile_request(v, tx, ty, mpos.x, mpos.y); + fprintf(stderr, " → MOVE%s\n", walkable ? "" : "-NEAR"); + } + } else { + fprintf(stderr, " → MISS (raycast failed)\n"); + } + } +} + +/* Called EVERY FRAME for key presses. Buffers actions for next tick. */ +static void process_human_keys(ViewerState* v) { + FcPlayer* p = &v->state.player; + if (IsKeyPressed(KEY_ONE)) v->pending_prayer = (p->prayer == PRAYER_PROTECT_MELEE) ? FC_PRAYER_OFF : FC_PRAYER_MELEE; + if (IsKeyPressed(KEY_TWO)) v->pending_prayer = (p->prayer == PRAYER_PROTECT_RANGE) ? FC_PRAYER_OFF : FC_PRAYER_RANGE; + if (IsKeyPressed(KEY_THREE)) v->pending_prayer = (p->prayer == PRAYER_PROTECT_MAGIC) ? FC_PRAYER_OFF : FC_PRAYER_MAGIC; + if (IsKeyPressed(KEY_F)) v->pending_eat = FC_EAT_SHARK; + if (IsKeyPressed(KEY_P)) v->pending_drink = FC_DRINK_PRAYER_POT; + if (IsKeyPressed(KEY_X)) + fc_request_set_running(&v->state, !p->is_running); + + /* --- Debug toggles (testing only) --- */ + /* F9: toggle godmode (player can't die) */ + if (IsKeyPressed(KEY_F9)) toggle_godmode(v); + /* F1-F8: spawn NPC type 1-8 near the player */ + for (int fk = 0; fk < 8; fk++) { + if (IsKeyPressed(KEY_F1 + fk)) { + int npc_type = fk + 1; + /* Find free NPC slot */ + for (int si = 0; si < FC_MAX_NPCS; si++) { + if (!v->state.npcs[si].active) { + int sx = p->x + 5, sy = p->y; + const FcNpcStats* stats = fc_npc_get_stats(npc_type); + /* Find nearby walkable tile */ + for (int r = 0; r < 10; r++) { + for (int dx = -r; dx <= r; dx++) { + int ty = p->y + r, tx = p->x + dx + 3; + if (tx >= 0 && tx < FC_ARENA_WIDTH && ty >= 0 && ty < FC_ARENA_HEIGHT && + fc_footprint_walkable(tx, ty, stats->size, v->state.walkable)) { + sx = tx; sy = ty; r = 99; break; + } + } + } + fc_npc_spawn(&v->state.npcs[si], npc_type, sx, sy, + v->state.next_spawn_index++); + /* Don't increment npcs_remaining — debug spawns shouldn't + * affect wave progression. Wave clear checks npcs_remaining. */ + fprintf(stderr, "DEBUG SPAWN: NPC type %d at (%d,%d)\n", npc_type, sx, sy); + break; + } + } + } + } +} + +/* Called on TICK frames: build action array from buffered inputs. */ +static void build_human_actions(ViewerState* v) { + memset(v->actions, 0, sizeof(v->actions)); + v->actions[0] = FC_MOVE_IDLE; + v->actions[1] = FC_ATTACK_NONE; + if (v->pending_attack_npc >= 0) { + int visible[FC_VISIBLE_NPCS]; + int count = fc_visible_npc_indices(&v->state, visible); + for (int slot = 0; slot < count; slot++) { + if (visible[slot] == v->pending_attack_npc) { + v->actions[1] = slot + 1; + break; + } + } + } + if (v->pending_tile_x >= 0 && v->pending_tile_y >= 0) { + v->actions[5] = v->pending_tile_x + 1; + v->actions[6] = v->pending_tile_y + 1; + } + /* Buffered prayer/eat/drink */ + v->actions[2] = v->pending_prayer; + v->actions[3] = v->pending_eat; + v->actions[4] = v->pending_drink; + /* Clear buffers */ + v->pending_prayer = 0; + v->pending_eat = 0; + v->pending_drink = 0; + v->pending_attack_npc = -1; + v->pending_tile_x = -1; + v->pending_tile_y = -1; +} + +/* ======================================================================== */ +/* Policy pipe mode — read actions from stdin, write obs to stdout */ +/* ======================================================================== */ + +static int read_policy_actions(ViewerState* v) { + for (int i = 0; i < FC_PUFFER_NUM_ATNS; i++) { + int action; + if (scanf("%d", &action) != 1) + return 0; + v->actions[i] = action; + } + for (int i = FC_PUFFER_NUM_ATNS; i < FC_NUM_ACTION_HEADS; i++) v->actions[i] = 0; + return 1; +} + +static void write_obs_to_pipe(ViewerState* v) { + /* Write the same policy obs + action mask contract used by Puffer training. */ + float obs_buf[FC_OBS_SIZE]; + fc_write_obs(&v->state, obs_buf); + /* Mirror training-time obs ablation so the policy sees the distribution + * it was trained on (no-op when all flags are 0). */ + fc_apply_obs_ablation(obs_buf, + v->obs_ablate_npc_distance, + v->obs_ablate_incoming_aggregates, + v->obs_ablate_npc_valid); + float mask_buf[FC_ACTION_MASK_SIZE]; + fc_write_mask(&v->state, mask_buf); + + /* Policy obs: first FC_POLICY_OBS_SIZE floats */ + for (int i = 0; i < FC_POLICY_OBS_SIZE; i++) + printf("%.6f ", obs_buf[i]); + for (int i = 0; i < FC_PUFFER_MASK_SIZE; i++) + printf("%.6f ", mask_buf[i]); + printf("\n"); + fflush(stdout); +} + +/* Entity ground Y — slightly above terrain so entities stand on the flattened cracks */ +static float ground_y(ViewerState* v, int tile_x, int tile_y) { + if (v->terrain && v->terrain->loaded) { + return terrain_height_at(v->terrain, tile_x, tile_y) + 0.1f; + } + return 0.0f; +} + +static float ground_y_smooth(ViewerState* v, float tile_x, float tile_y) { + int x0 = (int)floorf(tile_x); + int y0 = (int)floorf(tile_y); + if (x0 < 0) x0 = 0; + if (y0 < 0) y0 = 0; + if (x0 >= FC_ARENA_WIDTH) x0 = FC_ARENA_WIDTH - 1; + if (y0 >= FC_ARENA_HEIGHT) y0 = FC_ARENA_HEIGHT - 1; + int x1 = x0 + 1 < FC_ARENA_WIDTH ? x0 + 1 : x0; + int y1 = y0 + 1 < FC_ARENA_HEIGHT ? y0 + 1 : y0; + float tx = tile_x - floorf(tile_x); + float ty = tile_y - floorf(tile_y); + float h00 = ground_y(v, x0, y0); + float h10 = ground_y(v, x1, y0); + float h01 = ground_y(v, x0, y1); + float h11 = ground_y(v, x1, y1); + float h0 = h00 + (h10 - h00) * tx; + float h1 = h01 + (h11 - h01) * tx; + return h0 + (h1 - h0) * ty; +} + +typedef struct { + float x; + float y; + float face_angle; + int moving; + FcVisualLocomotion locomotion; +} EntityRenderPose; + +static EntityRenderPose entity_render_pose(const ViewerState* v, + const FcRenderEntity* e) { + EntityRenderPose pose = {0}; + if (!v || !e) return pose; + FcVisualPose visual = e->entity_type == ENTITY_PLAYER + ? fc_visual_scene_player_pose(&v->actor_animation.scene) + : fc_visual_scene_npc_pose(&v->actor_animation.scene, e->npc_slot); + pose.x = visual.x; + pose.y = visual.y; + pose.face_angle = visual.yaw_degrees; + pose.moving = visual.moving; + pose.locomotion = visual.locomotion; + return pose; +} + +static Vector2 click_route_point_to_screen(ViewerState* v, + float tile_x, float tile_y) { + Vector3 world = { + tile_x, + ground_y_smooth(v, tile_x, tile_y) + 0.08f, + -tile_y, + }; + return GetWorldToScreen(world, v->camera); +} + +static void draw_click_destination_3d(ViewerState* v) { + if (!v || !v->click_feedback.destination_active) return; + int tx = v->click_feedback.destination_x; + int ty = v->click_feedback.destination_y; + if (tx < 0 || tx >= FC_ARENA_WIDTH || + ty < 0 || ty >= FC_ARENA_HEIGHT) { + return; + } + + float y = ground_y(v, tx, ty) + 0.025f; + Vector3 center = {(float)tx + 0.5f, y, -((float)ty + 0.5f)}; + Color fill = {255, 215, 0, 70}; + Color edge = {255, 235, 70, 230}; + DrawCube(center, 0.92f, 0.025f, 0.92f, fill); + DrawCubeWires(center, 0.92f, 0.025f, 0.92f, edge); +} + +static void draw_click_route_2d(ViewerState* v) { + if (!v) return; + const int* route_x = NULL; + const int* route_y = NULL; + int start = 0; + int len = 0; + if (!fc_click_feedback_route(&v->click_feedback, &v->state, + &route_x, &route_y, &start, &len)) { + return; + } + + FcVisualPose player = + fc_visual_scene_player_pose(&v->actor_animation.scene); + Vector2 previous = click_route_point_to_screen(v, player.x, player.y); + Color line = {255, 220, 30, 210}; + Color point = {255, 245, 120, 240}; + for (int i = start; i < len; i++) { + Vector2 next = click_route_point_to_screen( + v, (float)route_x[i] + 0.5f, (float)route_y[i] + 0.5f); + DrawLineEx(previous, next, 2.0f, line); + DrawCircleV(next, 2.5f, point); + previous = next; + } +} + +static void draw_click_cross(ViewerState* v) { + if (!v) return; + int frame = fc_click_feedback_cross_frame(&v->click_feedback); + if (frame < 0) return; + int base = v->click_feedback.cross_kind == FC_CLICK_CROSS_INTERACTION + ? FC_CLICK_CROSS_FRAME_COUNT : 0; + Texture2D texture = v->click_cross_tex[base + frame]; + if (texture.id == 0) return; + DrawTexture(texture, + (int)roundf(v->click_feedback.cross_screen_x) - + texture.width / 2, + (int)roundf(v->click_feedback.cross_screen_y) - + texture.height / 2, + WHITE); +} + +static Vector3 camera_follow_target(const ViewerState* v) { + float cx = FC_ARENA_WIDTH * 0.5f; + float cy = -(FC_ARENA_HEIGHT * 0.5f); + if (v->entity_count > 0) { + EntityRenderPose pose = entity_render_pose(v, &v->entities[0]); + cx = pose.x; + cy = -pose.y; + } + return (Vector3){cx, 0.5f, cy}; +} + +static void draw_npc_prayer_window_indicators(ViewerState* v) { + if (!v || !v->dbg_flags) return; + + for (int i = 0; i < v->entity_count; i++) { + const FcRenderEntity* entity = &v->entities[i]; + int npc_idx = entity->npc_slot; + if (entity->entity_type != ENTITY_NPC || entity->is_dead || + npc_idx < 0 || npc_idx >= FC_MAX_NPCS || + !fc_actor_animation_prayer_window_active( + &v->actor_animation, npc_idx, v->state.tick)) { + continue; + } + + EntityRenderPose pose = entity_render_pose(v, entity); + Vector3 anchor = { + pose.x, + ground_y_smooth(v, pose.x, pose.y) + + 2.2f + (float)entity->size * 0.8f, + -pose.y + }; + dbg_draw_prayer_window_indicator(anchor, v->camera); + } +} + +/* ======================================================================== */ +/* Scene drawing */ +/* ======================================================================== */ + +static int object_anim_row_visible(const ObjectAnimPlacement* row) { + if (!row) return 0; + return (row->flags & OANM_FLAG_DYNAMIC_REPLACEMENT) == 0; +} + +static void draw_animated_objects(ViewerState* v) { + if (!v || !v->object_anims || !v->object_anims->loaded || + !v->object_anim_models || !v->object_anim_runtimes) + return; + + float dt = GetFrameTime(); + rlDisableBackfaceCulling(); + for (int i = 0; i < v->object_anims->count; i++) { + ObjectAnimPlacement* row = &v->object_anims->rows[i]; + if (!object_anim_row_visible(row)) continue; + + NpcModelEntry* entry = fc_npc_model_find(v->object_anim_models, + row->model_id); + if (!entry || !entry->loaded) continue; + + if (row->animation_id >= 0 && v->anim_cache) { + ObjectAnimRuntime* rt = &v->object_anim_runtimes[i]; + fc_model_animation_update(entry, v->anim_cache, &rt->anim_state, + &rt->anim_seq, &rt->anim_frame, + &rt->anim_timer, row->animation_id, dt, + row->phase_ticks); + } + + DrawModelEx(entry->model, + (Vector3){row->pos_x, row->pos_y, row->pos_z}, + (Vector3){0, 1, 0}, 0.0f, + (Vector3){1, 1, 1}, WHITE); + } + rlEnableBackfaceCulling(); +} + +static void draw_actor_footprint(ViewerState* v, + const FcRenderEntity* entity) { + if (!v || !entity || entity->size <= 0) { + return; + } + if (entity->is_dead && + (entity->entity_type != ENTITY_NPC || + !fc_combat_presentation_npc_death_deferred( + v->combat_presentation, &v->state, entity->npc_slot))) { + return; + } + + const int is_player = entity->entity_type == ENTITY_PLAYER; + const Color fill = is_player + ? CLITERAL(Color){50, 220, 100, 72} + : CLITERAL(Color){50, 140, 255, 62}; + const Color outline = is_player + ? CLITERAL(Color){80, 255, 130, 225} + : CLITERAL(Color){80, 180, 255, 210}; + for (int offset_x = 0; offset_x < entity->size; offset_x++) { + for (int offset_y = 0; offset_y < entity->size; offset_y++) { + float tile_x = (float)(entity->x + offset_x) + 0.5f; + float tile_y = (float)(entity->y + offset_y) + 0.5f; + float height = ground_y_smooth(v, tile_x, tile_y) + 0.035f; + Vector3 center = {tile_x, height, -tile_y}; + DrawCube(center, 0.94f, 0.02f, 0.94f, fill); + } + } + + float min_x = (float)entity->x + 0.03f; + float max_x = (float)(entity->x + entity->size) - 0.03f; + float min_y = (float)entity->y + 0.03f; + float max_y = (float)(entity->y + entity->size) - 0.03f; + Vector3 northwest = { + min_x, ground_y_smooth(v, min_x, min_y) + 0.055f, -min_y}; + Vector3 northeast = { + max_x, ground_y_smooth(v, max_x, min_y) + 0.055f, -min_y}; + Vector3 southeast = { + max_x, ground_y_smooth(v, max_x, max_y) + 0.055f, -max_y}; + Vector3 southwest = { + min_x, ground_y_smooth(v, min_x, max_y) + 0.055f, -max_y}; + DrawLine3D(northwest, northeast, outline); + DrawLine3D(northeast, southeast, outline); + DrawLine3D(southeast, southwest, outline); + DrawLine3D(southwest, northwest, outline); +} + +static void draw_scene(ViewerState* v) { + if (v->camera_locked) { + v->camera.target = camera_follow_target(v); + } + v->camera.position = (Vector3){ + v->camera.target.x + v->cam_dist*cosf(v->cam_pitch)*sinf(v->cam_yaw), + v->cam_dist*sinf(v->cam_pitch), + v->camera.target.z + v->cam_dist*cosf(v->cam_pitch)*cosf(v->cam_yaw) }; + BeginMode3D(v->camera); + + /* Terrain + objects */ + if (v->terrain && v->terrain->loaded) { + rlDisableBackfaceCulling(); + DrawModel(v->terrain->model, (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } + if (v->objects && v->objects->loaded) { + rlDisableBackfaceCulling(); + DrawModel(v->objects->model, (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } + draw_animated_objects(v); + + /* Grid overlay */ + if (v->show_grid) { + for (int x = 0; x <= FC_ARENA_WIDTH; x++) + DrawLine3D((Vector3){(float)x,0.01f,0}, (Vector3){(float)x,0.01f,-(float)FC_ARENA_HEIGHT}, COL_GRID); + for (int z = 0; z <= FC_ARENA_HEIGHT; z++) + DrawLine3D((Vector3){0,0.01f,-(float)z}, (Vector3){(float)FC_ARENA_WIDTH,0.01f,-(float)z}, COL_GRID); + } + + /* Collision overlay */ + if (v->show_collision) { + for (int tx = 0; tx < FC_ARENA_WIDTH; tx++) { + for (int ty = 0; ty < FC_ARENA_HEIGHT; ty++) { + Color c = v->state.walkable[tx][ty] ? COL_WALKABLE : COL_BLOCKED; + DrawCube((Vector3){tx+0.5f, 0.02f, -(ty+0.5f)}, 0.9f, 0.02f, 0.9f, c); + } + } + } + + /* Movement feedback is immediate, even though the selected action remains + * buffered until the next authoritative simulation tick. */ + draw_click_destination_3d(v); + + /* Entities */ + for (int i = 0; i < v->entity_count; i++) { + FcRenderEntity* e = &v->entities[i]; + + EntityRenderPose pose = entity_render_pose(v, e); + float ex = pose.x; + float ey = -pose.y; + + /* Sample terrain continuously along the interpolated movement path. */ + float gy = ground_y_smooth(v, pose.x, pose.y); + + if (e->entity_type == ENTITY_PLAYER) { + draw_actor_footprint(v, e); + + /* Player model or fallback cylinder */ + NpcModelEntry* pm = fc_actor_player_model_entry( + v->player_model, v->active_loadout); + if (pm && pm->loaded) { + Vector3 pos = {ex, gy, ey}; + float face_angle = pose.face_angle; + rlDisableBackfaceCulling(); + DrawModelEx(pm->model, pos, (Vector3){0,1,0}, face_angle, (Vector3){1,1,1}, WHITE); + rlEnableBackfaceCulling(); + } else { + DrawCylinder((Vector3){ex, gy, ey}, 0.4f, 0.4f, 2.0f, 8, COL_PLAYER); + DrawCylinderWires((Vector3){ex, gy, ey}, 0.4f, 0.4f, 2.0f, 8, WHITE); + } + + /* Prayer icon above player — rendered as 2D text after EndMode3D */ + /* (handled below in the 2D overlay section) */ + } else { + draw_actor_footprint(v, e); + + /* NPC: try to render actual model, fallback to colored cube */ + uint32_t mid = fc_npc_type_to_model_id(e->npc_type); + NpcModelEntry* nme = v->npc_models ? fc_npc_model_find(v->npc_models, mid) : NULL; + + if (nme) { + /* Facing is maintained by the client-style actor runtime. */ + Vector3 pos = {ex, gy, ey}; + float face_angle = pose.face_angle; + if (e->npc_slot >= 0 && e->npc_slot < FC_MAX_NPCS && + v->actor_animation.npc_states[e->npc_slot]) { + /* Same-type NPCs share an asset mesh. Upload this actor's + * transformed vertices immediately before its draw call. */ + fc_actor_animation_upload_npc( + &v->actor_animation, e->npc_slot, nme); + } + rlDisableBackfaceCulling(); + DrawModelEx(nme->model, pos, (Vector3){0,1,0}, face_angle, (Vector3){1,1,1}, WHITE); + rlEnableBackfaceCulling(); + } else { + /* Fallback: colored cube */ + float s = (float)e->size * 0.45f; + float h = 1.0f + (float)e->size * 0.5f; + Color col = (e->npc_type > 0 && e->npc_type < 9) ? NPC_COLORS[e->npc_type] : GRAY; + if (e->died_this_tick && + !fc_combat_presentation_npc_death_deferred( + v->combat_presentation, &v->state, e->npc_slot)) { + h *= 0.3f; + col.a = 100; + } + DrawCube((Vector3){ex, gy + h*0.5f, ey}, s*2, h, s*2, col); + DrawCubeWires((Vector3){ex, gy + h*0.5f, ey}, s*2, h, s*2, WHITE); + } + + } + } + + FcCombatPresentationContext combat_context = { + .state = &v->state, + .events = &v->render_events, + .scene = &v->actor_animation.scene, + .terrain = v->terrain, + .anim_cache = v->anim_cache, + .player_profile = fc_player_visual_profile(v->active_loadout), + .tps = v->tps, + }; + fc_combat_presentation_draw_world(v->combat_presentation, + &combat_context, GetFrameTime()); + /* Debug overlays — 3D collision tiles (before EndMode3D) */ + if (v->dbg_flags) debug_overlay_3d(&v->state, v->dbg_flags); + + EndMode3D(); + + FcCombatPresentationDrawContext combat_draw_context = { + .presentation = combat_context, + .entities = v->entities, + .entity_count = v->entity_count, + .player_models = v->player_model, + .npc_models = v->npc_models, + .active_loadout = v->active_loadout, + .ui_assets = &v->ui.assets, + .camera = v->camera, + }; + /* Native client actor overheads are fixed-size screen-space sprites. */ + fc_combat_presentation_draw_healthbars(v->combat_presentation, + &combat_draw_context); + + /* Debug overlays — 2D screen-space (LOS, path, range — after EndMode3D) */ + if (v->dbg_flags) { + int debug_flags = v->dbg_flags; + if (v->click_feedback.destination_active) + debug_flags &= ~DBG_PATH; + debug_overlay_screen(&v->state, v->camera, debug_flags); + draw_npc_prayer_window_indicators(v); + } + + draw_click_route_2d(v); + + fc_combat_presentation_draw_hitsplats(v->combat_presentation, + &combat_draw_context); + + /* Prayer overhead icon — 2D projected from player head position */ + int rendered_prayer = fc_actor_animation_render_prayer( + &v->actor_animation, &v->state); + if (v->entity_count > 0 && rendered_prayer != PRAYER_NONE) { + EntityRenderPose pose = entity_render_pose(v, &v->entities[0]); + float p_gy = ground_y_smooth(v, pose.x, pose.y); + Vector3 head_pos = {pose.x, p_gy + 3.0f, -pose.y}; + Vector2 scr = GetWorldToScreen(head_pos, v->camera); + int px = (int)scr.x, py = (int)scr.y; + + /* Draw actual prayer sprite texture */ + Texture2D tex = {0}; + if (rendered_prayer == PRAYER_PROTECT_MELEE && v->pray_melee_tex.id > 0) + tex = v->pray_melee_tex; + else if (rendered_prayer == PRAYER_PROTECT_RANGE && v->pray_missiles_tex.id > 0) + tex = v->pray_missiles_tex; + else if (rendered_prayer == PRAYER_PROTECT_MAGIC && v->pray_magic_tex.id > 0) + tex = v->pray_magic_tex; + + if (tex.id > 0) { + /* Scale sprite to ~28x28 pixels and center on projected position */ + float scale = 28.0f / (float)tex.width; + int dw = (int)(tex.width * scale); + int dh = (int)(tex.height * scale); + DrawTextureEx(tex, (Vector2){(float)(px - dw/2), (float)(py - dh/2)}, + 0.0f, scale, WHITE); + } else { + /* Fallback: letter if textures not loaded */ + const char* icon_txt; + if (rendered_prayer == PRAYER_PROTECT_MELEE) icon_txt = "M"; + else if (rendered_prayer == PRAYER_PROTECT_RANGE) icon_txt = "R"; + else icon_txt = "W"; + DrawCircle(px, py, 14, (Color){255,255,255,220}); + int itw = fc_osrs_measure_text(icon_txt, 18); + fc_osrs_draw_text(icon_txt, px - itw/2, py - 9, 18, (Color){0,0,0,255}); + } + } +} + +/* ======================================================================== */ +/* UI drawing */ +/* ======================================================================== */ + +static Rectangle runec_side_content_rect(void) { + int screen_w = GetScreenWidth(); + int screen_h = GetScreenHeight(); + + Rectangle side = { + (float)screen_w - RUNEC_OSRS_SIDE_MENU_W, + (float)screen_h - RUNEC_OSRS_SIDE_MENU_H, + RUNEC_OSRS_SIDE_MENU_W, + RUNEC_OSRS_SIDE_MENU_H + }; + return (Rectangle){ + side.x + RUNEC_OSRS_SIDE_CONTENT_X, + side.y + RUNEC_OSRS_SIDE_CONTENT_Y, + RUNEC_OSRS_SIDE_CONTENT_W, + RUNEC_OSRS_SIDE_CONTENT_H + }; +} + +#define RUNEC_CONSOLE_TAB_COUNT 6 +#define RUNEC_CONSOLE_NPC_ROWS 8 +#define RUNEC_CONSOLE_NPC_ROW_H 13 +#define RUNEC_CONSOLE_TPS_COLS 4 + +static Rectangle runec_console_panel_rect(void) { + return runec_ui_chat_panel_rect(GetScreenWidth(), GetScreenHeight()); +} + +static Rectangle runec_console_body_rect(Rectangle panel) { + return (Rectangle){panel.x + 7.0f, panel.y + 7.0f, + panel.width - 13.0f, panel.height - 39.0f}; +} + +static Rectangle runec_console_tab_rect(Rectangle panel, int index) { + float width = (panel.width - 6.0f) / (float)RUNEC_CONSOLE_TAB_COUNT; + return (Rectangle){panel.x + 3.0f + width * (float)index, + panel.y + panel.height - 23.0f, + width, 21.0f}; +} + +static Rectangle runec_console_debug_button_rect(Rectangle body) { + float right_x = body.x + 286.0f; + float width = body.x + body.width - right_x; + return (Rectangle){right_x, body.y + 15.0f, + (width - 4.0f) * 0.5f, 18.0f}; +} + +static Rectangle runec_console_god_button_rect(Rectangle body) { + Rectangle debug = runec_console_debug_button_rect(body); + return (Rectangle){debug.x + debug.width + 4.0f, debug.y, + debug.width, debug.height}; +} + +static Rectangle runec_console_wave_button_rect(Rectangle body) { + return (Rectangle){body.x + 286.0f, body.y + 37.0f, + body.width - 286.0f, 20.0f}; +} + +static Rectangle runec_console_tps_button_rect(Rectangle body, + int index) { + float box_x = body.x + 286.0f; + float box_w = body.width - 286.0f; + const float gap = 3.0f; + float button_w = (box_w - gap * (RUNEC_CONSOLE_TPS_COLS - 1)) / + RUNEC_CONSOLE_TPS_COLS; + int row = index / RUNEC_CONSOLE_TPS_COLS; + int col = index % RUNEC_CONSOLE_TPS_COLS; + return (Rectangle){box_x + col * (button_w + gap), + body.y + 78.0f + row * 20.0f, + button_w, 17.0f}; +} + +static Rectangle runec_console_target_row_rect(Rectangle body, + int row) { + return (Rectangle){body.x + 1.0f, + body.y + 16.0f + row * RUNEC_CONSOLE_NPC_ROW_H, + 277.0f, RUNEC_CONSOLE_NPC_ROW_H}; +} + +static Rectangle runec_console_wave_cell_rect(Rectangle body, + int wave) { + const int columns = 9; + const float gap = 1.0f; + float cell_w = (body.width - 6.0f - gap * (columns - 1)) / columns; + int index = wave - 1; + int row = index / columns; + int col = index % columns; + return (Rectangle){body.x + 3.0f + col * (cell_w + gap), + body.y + 18.0f + row * 15.0f, + cell_w, 14.0f}; +} + +static void draw_runec_console_button(Rectangle rect, const char* label, + int selected) { + int hovered = CheckCollisionPointRec(GetMousePosition(), rect); + Color background = selected ? CLITERAL(Color){82, 73, 61, 245} + : (hovered ? CLITERAL(Color){72, 63, 51, 245} + : CLITERAL(Color){42, 36, 28, 235}); + Color text = selected ? COL_TEXT_YELLOW : COL_TEXT_WHITE; + DrawRectangleRec(rect, background); + DrawRectangleLinesEx(rect, 1, COL_PANEL_BORDER); + int font_size = 8; + fc_osrs_draw_text(label, + (int)(rect.x + (rect.width - fc_osrs_measure_text(label, font_size)) * 0.5f), + (int)(rect.y + (rect.height - font_size) * 0.5f), + font_size, text); +} + +static void draw_runec_console_wave_grid(ViewerState* v, Rectangle body) { + Vector2 mouse = GetMousePosition(); + DrawRectangleRec(body, CLITERAL(Color){18, 16, 13, 252}); + DrawRectangleLinesEx(body, 1, COL_PANEL_BORDER); + fc_osrs_draw_text("Select Wave", (int)body.x + 4, (int)body.y + 3, + 9, COL_TEXT_YELLOW); + fc_osrs_draw_text("click current selection to close", + (int)(body.x + body.width) - 155, (int)body.y + 4, + 7, COL_TEXT_DIM); + for (int wave = 1; wave <= FC_NUM_WAVES; wave++) { + Rectangle cell = runec_console_wave_cell_rect(body, wave); + int current = wave == v->state.current_wave; + int hovered = CheckCollisionPointRec(mouse, cell); + Color bg = current ? CLITERAL(Color){60, 80, 40, 250} + : (hovered ? CLITERAL(Color){72, 63, 51, 245} + : CLITERAL(Color){35, 30, 24, 240}); + DrawRectangleRec(cell, bg); + DrawRectangleLinesEx(cell, 1, COL_PANEL_BORDER); + char label[8]; + snprintf(label, sizeof(label), "%d", wave); + fc_osrs_draw_text(label, + (int)(cell.x + (cell.width - fc_osrs_measure_text(label, 8)) * 0.5f), + (int)cell.y + 3, 8, + current ? COL_TEXT_YELLOW : COL_TEXT_WHITE); + } +} + +static void draw_runec_console_controls(ViewerState* v, Rectangle body) { + static const char* NPC_SHORT[] = { + "?", "Tz-Kih", "Tz-Kek", "Kek-Sm", "Tok-Xil", + "MejKot", "Ket-Zek", "Jad", "HurKot" + }; + Vector2 mouse = GetMousePosition(); + int x = (int)body.x + 4; + char text[128]; + + fc_osrs_draw_text("NPC Targets", x, (int)body.y + 3, 9, COL_TEXT_YELLOW); + int shown = 0; + for (int ni = 0; ni < FC_MAX_NPCS && shown < RUNEC_CONSOLE_NPC_ROWS; ni++) { + FcNpc* npc = &v->state.npcs[ni]; + if (!npc->active || npc->is_dead) continue; + Rectangle row = runec_console_target_row_rect(body, shown); + int selected = ni == v->state.player.attack_target_idx; + int hovered = CheckCollisionPointRec(mouse, row); + if (selected || hovered) { + DrawRectangleRec(row, selected + ? CLITERAL(Color){82, 73, 61, 205} + : CLITERAL(Color){52, 45, 35, 190}); + } + const char* name = npc->npc_type > 0 && npc->npc_type < 9 + ? NPC_SHORT[npc->npc_type] : "?"; + snprintf(text, sizeof(text), "%s%s[%d] d:%d", + selected ? ">" : " ", name, ni, + fc_distance_to_npc(v->state.player.x, + v->state.player.y, npc)); + fc_osrs_draw_text(text, (int)row.x + 2, (int)row.y + 3, 8, + selected ? COL_TEXT_YELLOW : COL_TEXT_WHITE); + + int bar_x = (int)row.x + 116; + int bar_w = 116; + float hp = npc->max_hp > 0 + ? (float)npc->current_hp / (float)npc->max_hp : 0.0f; + if (hp < 0.0f) hp = 0.0f; + if (hp > 1.0f) hp = 1.0f; + DrawRectangle(bar_x, (int)row.y + 3, bar_w, 7, COL_HP_RED); + DrawRectangle(bar_x, (int)row.y + 3, + (int)((float)bar_w * hp), 7, COL_HP_GREEN); + snprintf(text, sizeof(text), "%d", npc->current_hp / 10); + fc_osrs_draw_text(text, bar_x + bar_w + 4, (int)row.y + 2, + 8, COL_TEXT_DIM); + shown++; + } + if (shown == 0) + fc_osrs_draw_text("No NPCs alive", x + 2, (int)body.y + 20, + 8, COL_TEXT_DIM); + + int right_x = (int)body.x + 286; + DrawLine(right_x - 5, (int)body.y + 2, + right_x - 5, (int)(body.y + body.height) - 2, + COL_PANEL_BORDER); + fc_osrs_draw_text("Viewer Controls", right_x, (int)body.y + 3, + 9, COL_TEXT_YELLOW); + + char debug_label[24]; + snprintf(debug_label, sizeof(debug_label), "Debug: %s", + v->dbg_flags ? "ON" : "OFF"); + draw_runec_console_button(runec_console_debug_button_rect(body), + debug_label, v->dbg_flags != 0); + char god_label[24]; + snprintf(god_label, sizeof(god_label), "God: %s", + v->godmode ? "ON" : "OFF"); + draw_runec_console_button(runec_console_god_button_rect(body), + god_label, v->godmode); + + Rectangle wave = runec_console_wave_button_rect(body); + snprintf(text, sizeof(text), "Jump to Wave: %d v", + v->state.current_wave); + draw_runec_console_button(wave, text, 0); + + fc_osrs_draw_text(v->policy_pipe ? "Replay TPS" : "TPS Presets", + right_x, (int)body.y + 62, 8, COL_TEXT_DIM); + for (int i = 0; i < NUM_MANUAL_TPS_PRESETS; i++) { + draw_runec_console_button(runec_console_tps_button_rect(body, i), + MANUAL_TPS_LABELS[i], + float_near(v->tps, MANUAL_TPS_PRESETS[i])); + } + + if (v->console_wave_dropdown_open) + draw_runec_console_wave_grid(v, body); +} + +static void draw_runec_console_diagnostics(ViewerState* v, Rectangle body) { + int debug_tab = v->console_tab - 1; + int available_height = (int)body.height - 6; + int scroll = 0; + if (debug_tab >= 0 && debug_tab < 4) { + int max_scroll = v->console_content_height[debug_tab] - available_height; + if (max_scroll < 0) max_scroll = 0; + if (v->console_scroll[debug_tab] < 0) + v->console_scroll[debug_tab] = 0; + if (v->console_scroll[debug_tab] > max_scroll) + v->console_scroll[debug_tab] = max_scroll; + scroll = v->console_scroll[debug_tab]; + } + + int content_y = (int)body.y + 3 - scroll; + BeginScissorMode((int)body.x, (int)body.y, + (int)body.width, (int)body.height); + int end_y = dbg_draw_panel_tabs( + &v->state, + &v->reward_breakdown, &v->reward_runtime, + v->reward_config_loaded, v->reward_config_path, + (int)body.x, (int)body.x + 4, content_y, + (int)body.width, debug_tab, 0, available_height); + EndScissorMode(); + + if (debug_tab >= 0 && debug_tab < 4) { + int height = end_y - content_y; + v->console_content_height[debug_tab] = height > 0 ? height : 0; + int max_scroll = height - available_height; + if (max_scroll < 0) max_scroll = 0; + if (v->console_scroll[debug_tab] > max_scroll) + v->console_scroll[debug_tab] = max_scroll; + if (max_scroll > 0) { + int track_x = (int)(body.x + body.width) - 6; + int track_y = (int)body.y + 3; + int track_h = available_height; + DrawRectangle(track_x, track_y, 4, track_h, + CLITERAL(Color){30, 26, 20, 230}); + float visible_fraction = + (float)available_height / (float)height; + int thumb_h = (int)((float)track_h * visible_fraction); + if (thumb_h < 12) thumb_h = 12; + float scroll_fraction = max_scroll > 0 + ? (float)v->console_scroll[debug_tab] / (float)max_scroll + : 0.0f; + int thumb_y = track_y + + (int)((float)(track_h - thumb_h) * scroll_fraction); + DrawRectangle(track_x, thumb_y, 4, thumb_h, + CLITERAL(Color){120, 110, 90, 255}); + } + } +} + +static void draw_runec_console(ViewerState* v) { + static const char* labels[RUNEC_CONSOLE_TAB_COUNT] = { + "Controls", "Player", "Obs", "Mask", "Reward", "Log" + }; + Rectangle panel = runec_console_panel_rect(); + Rectangle body = runec_console_body_rect(panel); + DrawRectangleRec(body, CLITERAL(Color){0, 0, 0, 76}); + if (v->console_tab == 0) + draw_runec_console_controls(v, body); + else + draw_runec_console_diagnostics(v, body); + + for (int tab = 0; tab < RUNEC_CONSOLE_TAB_COUNT; tab++) { + Rectangle rect = runec_console_tab_rect(panel, tab); + runec_ui_draw_asset(&v->ui.assets, "chat_tab_button_0", rect, WHITE); + int selected = tab == v->console_tab; + int hovered = CheckCollisionPointRec(GetMousePosition(), rect); + DrawRectangleRec(rect, selected + ? CLITERAL(Color){82, 73, 61, 96} + : (hovered ? CLITERAL(Color){90, 78, 62, 72} + : CLITERAL(Color){0, 0, 0, 18})); + if (selected) + DrawLine((int)rect.x + 2, (int)(rect.y + rect.height) - 2, + (int)(rect.x + rect.width) - 2, + (int)(rect.y + rect.height) - 2, + COL_TEXT_YELLOW); + fc_osrs_draw_text(labels[tab], + (int)(rect.x + + (rect.width - fc_osrs_measure_text(labels[tab], 8)) * 0.5f), + (int)rect.y + 7, 8, + selected ? COL_TEXT_YELLOW : COL_TEXT_WHITE); + } +} + +static int process_runec_console_input(ViewerState* v) { + if (!v) return 0; + Rectangle panel = runec_console_panel_rect(); + Vector2 mouse = GetMousePosition(); + if (!CheckCollisionPointRec(mouse, panel)) + return 0; + + int clicked = IsMouseButtonPressed(MOUSE_BUTTON_LEFT); + if (clicked) { + for (int tab = 0; tab < RUNEC_CONSOLE_TAB_COUNT; tab++) { + if (!CheckCollisionPointRec(mouse, + runec_console_tab_rect(panel, tab))) + continue; + v->console_tab = tab; + v->console_wave_dropdown_open = 0; + return 1; + } + } + + Rectangle body = runec_console_body_rect(panel); + if (v->console_tab == 0 && clicked) { + if (v->console_wave_dropdown_open) { + for (int wave = 1; wave <= FC_NUM_WAVES; wave++) { + if (!CheckCollisionPointRec( + mouse, runec_console_wave_cell_rect(body, wave))) + continue; + if (wave != v->state.current_wave) + viewer_jump_to_wave(v, wave); + v->console_wave_dropdown_open = 0; + return 1; + } + v->console_wave_dropdown_open = 0; + return 1; + } + + if (CheckCollisionPointRec(mouse, + runec_console_debug_button_rect(body))) { + toggle_debug_overlay(v); + return 1; + } + if (CheckCollisionPointRec(mouse, + runec_console_god_button_rect(body))) { + toggle_godmode(v); + return 1; + } + if (CheckCollisionPointRec(mouse, + runec_console_wave_button_rect(body))) { + v->console_wave_dropdown_open = 1; + return 1; + } + for (int i = 0; i < NUM_MANUAL_TPS_PRESETS; i++) { + if (!CheckCollisionPointRec( + mouse, runec_console_tps_button_rect(body, i))) + continue; + set_manual_speed(v, MANUAL_TPS_PRESETS[i]); + return 1; + } + + int shown = 0; + for (int ni = 0; ni < FC_MAX_NPCS && + shown < RUNEC_CONSOLE_NPC_ROWS; ni++) { + FcNpc* npc = &v->state.npcs[ni]; + if (!npc->active || npc->is_dead) continue; + if (CheckCollisionPointRec( + mouse, runec_console_target_row_rect(body, shown))) { + queue_player_attack_request(v, ni, mouse.x, mouse.y); + fprintf(stderr, "CONSOLE CLICK -> ATTACK npc_idx=%d\n", ni); + return 1; + } + shown++; + } + } + + if (v->console_tab >= 1 && v->console_tab <= 4 && + CheckCollisionPointRec(mouse, body)) { + float wheel = GetMouseWheelMove(); + if (wheel != 0.0f) { + int index = v->console_tab - 1; + int max_scroll = v->console_content_height[index] - + ((int)body.height - 6); + if (max_scroll < 0) max_scroll = 0; + v->console_scroll[index] -= (int)wheel * 24; + if (v->console_scroll[index] < 0) + v->console_scroll[index] = 0; + if (v->console_scroll[index] > max_scroll) + v->console_scroll[index] = max_scroll; + } + } + return 1; +} + +static void draw_tex_fit(Texture2D tex, int dx, int dy, int dw, int dh, + Color tint) { + if (tex.id == 0) return; + Rectangle src = {0, 0, (float)tex.width, (float)tex.height}; + float sx = (float)dw / (float)tex.width; + float sy = (float)dh / (float)tex.height; + float scale = sx < sy ? sx : sy; + int rw = (int)(tex.width * scale); + int rh = (int)(tex.height * scale); + Rectangle dst = { + (float)(dx + (dw - rw) / 2), + (float)(dy + (dh - rh) / 2), + (float)rw, + (float)rh, + }; + DrawTexturePro(tex, src, dst, (Vector2){0, 0}, 0, tint); +} + +static Rectangle runec_prayer_button_rect(Rectangle content, int index) { + const int btn_h = 34; + const int gap = 3; + int x = (int)content.x + 8; + int y = (int)content.y + 8 + 34 + index * (btn_h + gap); + int w = (int)content.width - 16; + if (w < 120) w = 120; + return (Rectangle){(float)x, (float)y, (float)w, (float)btn_h}; +} + +static void draw_runec_prayer_tab(ViewerState* v, Rectangle content) { + BeginScissorMode((int)content.x, (int)content.y, + (int)content.width, (int)content.height); + + DrawRectangleRec(content, COL_PANEL); + + FcPlayer* p = &v->state.player; + int rendered_prayer = fc_actor_animation_render_prayer( + &v->actor_animation, &v->state); + int x = (int)content.x + 8; + int by = (int)content.y + 8; + int right = (int)(content.x + content.width) - 4; + char b[64]; + + snprintf(b, sizeof(b), "Prayer: %d / %d", + p->current_prayer / 10, p->max_prayer / 10); + text_s(b, x, by, 10, COL_PRAY_BLUE); + by += 16; + + if (rendered_prayer != PRAYER_NONE) { + int resistance = 60 + 2 * p->prayer_bonus; + snprintf(b, sizeof(b), "Drain rate: 12 / %d resist", resistance); + text_s(b, x, by, 8, COL_TEXT_DIM); + } else { + text_s("No prayer active", x, by, 8, COL_TEXT_DIM); + } + by += 14; + + DrawLine((int)content.x + 4, by - 2, right, by - 2, COL_PANEL_BORDER); + + static const char* pray_names[] = { + "Prot. Melee", "Prot. Range", "Prot. Magic" + }; + static const int pray_vals[] = { + PRAYER_PROTECT_MELEE, PRAYER_PROTECT_RANGE, PRAYER_PROTECT_MAGIC + }; + Color pray_colors[] = { COL_TEXT_YELLOW, COL_TEXT_GREEN, COL_PRAY_BLUE }; + Texture2D tex_on[] = { + v->tex_pray_melee_on, v->tex_pray_range_on, v->tex_pray_magic_on + }; + Texture2D tex_off[] = { + v->tex_pray_melee_off, v->tex_pray_range_off, v->tex_pray_magic_off + }; + + int no_points = (p->current_prayer <= 0); + Vector2 mouse = GetMousePosition(); + const Color slot_empty = CLITERAL(Color){30, 26, 20, 255}; + const Color pray_active = CLITERAL(Color){60, 120, 200, 200}; + const Color tab_hover = CLITERAL(Color){72, 63, 51, 255}; + const Color pray_button = CLITERAL(Color){50, 44, 36, 255}; + for (int i = 0; i < 3; i++) { + Rectangle br = runec_prayer_button_rect(content, i); + int is_active = (rendered_prayer == pray_vals[i]); + int hovered = CheckCollisionPointRec(mouse, br); + + Color bg; + if (no_points) { + bg = slot_empty; + } else if (is_active) { + bg = pray_active; + } else if (hovered) { + bg = tab_hover; + } else { + bg = pray_button; + } + DrawRectangleRec(br, bg); + DrawRectangleLinesEx(br, is_active ? 2 : 1, + is_active ? pray_colors[i] : COL_PANEL_BORDER); + + Texture2D icon = is_active ? tex_on[i] : tex_off[i]; + Color icon_tint = no_points ? CLITERAL(Color){80,80,80,255} : WHITE; + draw_tex_fit(icon, (int)br.x + 4, (int)br.y + 2, 30, 30, icon_tint); + + Color tc = no_points ? COL_TEXT_DIM + : (is_active ? COL_TEXT_WHITE : pray_colors[i]); + text_s(pray_names[i], (int)br.x + 38, (int)br.y + 7, 10, tc); + + snprintf(b, sizeof(b), "[%d]", i + 1); + text_s(b, (int)br.x + 38, (int)br.y + 21, 8, COL_TEXT_DIM); + if (is_active) { + text_s("ACTIVE", (int)(br.x + br.width) - 44, + (int)br.y + 21, 8, COL_TEXT_WHITE); + } + } + + by = (int)runec_prayer_button_rect(content, 2).y + 34 + 9; + snprintf(b, sizeof(b), "Prayer bonus: +%d", p->prayer_bonus); + text_s(b, x, by, 8, COL_TEXT_DIM); + + EndScissorMode(); +} + +static void draw_runec_side_overrides(ViewerState* v) { + Rectangle content = runec_side_content_rect(); + if (v->ui.active_tab == RUNEC_UI_TAB_PRAYER) + draw_runec_prayer_tab(v, content); +} + +static int process_runec_prayer_click(ViewerState* v) { + if (!v || v->ui.active_tab != RUNEC_UI_TAB_PRAYER) + return 0; + int left_click = IsMouseButtonPressed(MOUSE_BUTTON_LEFT); + int right_click = IsMouseButtonPressed(MOUSE_BUTTON_RIGHT); + if (!left_click && !right_click) + return 0; + + Rectangle content = runec_side_content_rect(); + Vector2 mouse = GetMousePosition(); + if (!CheckCollisionPointRec(mouse, content)) + return 0; + + if (right_click) + return 1; + + static const struct { + int prayer; + int action; + } buttons[] = { + {PRAYER_PROTECT_MELEE, FC_PRAYER_MELEE}, + {PRAYER_PROTECT_RANGE, FC_PRAYER_RANGE}, + {PRAYER_PROTECT_MAGIC, FC_PRAYER_MAGIC}, + }; + + for (int i = 0; i < (int)(sizeof(buttons) / sizeof(buttons[0])); i++) { + Rectangle r = runec_prayer_button_rect(content, i); + if (!CheckCollisionPointRec(mouse, r)) + continue; + queue_viewer_prayer_button(v, buttons[i].prayer, buttons[i].action); + return 1; + } + + return 1; +} +/* ======================================================================== */ +/* Main */ +/* ======================================================================== */ + +int main(int argc, char** argv) { + int exit_code = 0; + int screenshot_mode = 0; + const char* screenshot_path = NULL; + int policy_pipe_flag = 0; + int policy_speed_flag = 1; + int policy_episode_limit_flag = 0; + int start_wave_flag = 0; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--screenshot") == 0 && i+1 < argc) { + screenshot_mode = 1; + screenshot_path = argv[++i]; + } else if (strcmp(argv[i], "--policy-pipe") == 0) { + policy_pipe_flag = 1; + } else if (strcmp(argv[i], "--speed") == 0 && i+1 < argc) { + policy_speed_flag = atoi(argv[++i]); + } else if (strcmp(argv[i], "--episodes") == 0 && i+1 < argc) { + policy_episode_limit_flag = atoi(argv[++i]); + } else if (strcmp(argv[i], "--start-wave") == 0 && i+1 < argc) { + start_wave_flag = atoi(argv[++i]); + } + } + fprintf(stderr,"=== Fight Caves Viewer (Phase 8 — Playable) ===\n"); + /* In policy-pipe mode, suppress Raylib's INFO logs which go to stdout + * and would corrupt the pipe protocol. */ + if (policy_pipe_flag) { + SetTraceLogCallback(viewer_trace_log_to_stderr); + SetTraceLogLevel(LOG_WARNING); + } + SetConfigFlags(FLAG_WINDOW_RESIZABLE|FLAG_MSAA_4X_HINT); + InitWindow(DEFAULT_WINDOW_W, DEFAULT_WINDOW_H, + "Fight Caves RL — Playable Viewer"); + if (!IsWindowReady()) { + fprintf(stderr, + "error: viewer window initialization failed; verify the " + "graphical display and OpenGL driver\n"); + return 1; + } + SetTargetFPS(60); + + ViewerState v; memset(&v, 0, sizeof(v)); + fc_init(&v.state); + fc_actor_animation_init(&v.actor_animation); + runec_ui_init(&v.ui); + if (!fc_osrs_text_init()) { + fprintf(stderr, + "error: required OSRS viewer fonts failed to load\n"); + runec_ui_shutdown(&v.ui); + CloseWindow(); + return 1; + } + int item_icons_ready = load_fc_ui_item_icons(&v); + v.paused = 1; v.tps = NORMAL_TPS; + v.active_loadout = FC_ACTIVE_LOADOUT; + v.attack_target = -1; + v.cam_yaw = 0; v.cam_pitch = 0.8f; v.cam_dist = 30; + v.camera_locked = 1; + v.camera.up = (Vector3){0,1,0}; v.camera.fovy = 32; + v.camera.projection = CAMERA_PERSPECTIVE; + v.camera.target = (Vector3){FC_ARENA_WIDTH * 0.5f, 0.5f, -(FC_ARENA_HEIGHT * 0.5f)}; + + v.terrain = load_terrain(&v); + /* OSRS rasterizes the current 104x104 scene from cache terrain and + * locations into a 512x512 minimap. This asset contains the Fight Caves + * mapsquare centered in that same scene format; runtime only crops and + * rotates it around the player. */ + Image minimap_image = fc_load_image_asset("fightcaves.minimap.png"); + if (minimap_image.data) { + Color* minimap_pixels = LoadImageColors(minimap_image); + if (!minimap_pixels || !fc_minimap_scene_load_pixels( + &v.minimap_scene, minimap_pixels, + minimap_image.width, minimap_image.height)) { + fprintf(stderr, "error: Fight Caves minimap failed to load\n"); + } else { + fprintf(stderr, + "minimap: loaded cache scene raster %dx%d\n", + minimap_image.width, minimap_image.height); + } + UnloadImageColors(minimap_pixels); + UnloadImage(minimap_image); + } else { + fprintf(stderr, "error: missing fightcaves.minimap.png\n"); + } + v.objects = load_objects_with_terrain(v.terrain); + if (fc_asset_exists("fightcaves.oanim")) + v.object_anims = object_anims_load("fightcaves.oanim"); + if (v.object_anims) + object_anims_offset(v.object_anims, FC_WORLD_ORIGIN_X, FC_WORLD_ORIGIN_Y); + if (!fc_animated_atlas_load(&v.shared_model_atlas, "fightcaves.atlas", 0)) + fprintf(stderr, "error: shared model atlas failed to load\n"); + if (fc_asset_exists("fightcaves.object_anim.models")) + v.object_anim_models = fc_npc_models_load( + "fightcaves.object_anim.models", v.shared_model_atlas.texture); + if (v.object_anims && v.object_anims->count > 0) { + v.object_anim_runtimes = (ObjectAnimRuntime*)calloc( + (size_t)v.object_anims->count, sizeof(*v.object_anim_runtimes)); + if (v.object_anim_runtimes) + v.object_anim_runtime_count = v.object_anims->count; + } + if (!v.terrain || !v.terrain->loaded) v.show_grid = 1; + + /* Load NPC models */ + { + if (fc_asset_exists("fc_npcs.models")) + v.npc_models = fc_npc_models_load("fc_npcs.models", (Texture2D){0}); + if (!v.npc_models) fprintf(stderr, "error: NPC models failed to load\n"); + } + + /* Load player model */ + { + if (fc_asset_exists("fc_player.models")) + v.player_model = fc_npc_models_load("fc_player.models", (Texture2D){0}); + } + + /* Load the animation cache shared by actor and combat presentation. */ + if (fc_asset_exists("fc_all.anims")) + v.anim_cache = anim_cache_load("fc_all.anims"); + v.combat_presentation = fc_combat_presentation_create( + v.shared_model_atlas.texture); + if (!v.combat_presentation) + fprintf(stderr, "error: combat presentation initialization failed\n"); + + /* Load prayer overhead icon textures */ + { + if (fc_asset_exists("data/sprites/ui/prayeron_14.png")) { + v.pray_melee_tex = fc_load_texture_asset("data/sprites/ui/prayeron_14.png"); + v.pray_missiles_tex = fc_load_texture_asset("data/sprites/ui/prayeron_13.png"); + v.pray_magic_tex = fc_load_texture_asset("data/sprites/ui/prayeron_12.png"); + fprintf(stderr, "Prayer icons loaded from %s\n", fc_asset_root()); + } else { + fprintf(stderr, "error: prayer icons not found under asset root %s\n", + fc_asset_root()); + } + } + + /* Native b237 click crosses: frames 0-3 are movement (yellow), frames + * 4-7 are interaction (red). RuneC advances one frame every 100 ms. */ + int click_cross_loaded = 0; + { + for (int i = 0; i < FC_CLICK_CROSS_FRAME_COUNT * 2; i++) { + char path[64]; + snprintf(path, sizeof(path), + "data/sprites/ui/cross_%d.png", i); + v.click_cross_tex[i] = fc_load_texture_asset(path); + if (v.click_cross_tex[i].id > 0) { + SetTextureFilter(v.click_cross_tex[i], TEXTURE_FILTER_POINT); + click_cross_loaded++; + } + } + fprintf(stderr, "Click cross sprites loaded: %d/8\n", + click_cross_loaded); + if (click_cross_loaded != FC_CLICK_CROSS_FRAME_COUNT * 2) + fprintf(stderr, "error: required click cross sprites failed to load\n"); + } + + /* Load prayer icons used by the active RuneC prayer override. */ + { + v.tex_pray_melee_on = fc_load_texture_asset( + "data/sprites/ui/prayeron_14.png"); + v.tex_pray_melee_off = fc_load_texture_asset( + "data/sprites/ui/prayeroff_14.png"); + v.tex_pray_range_on = fc_load_texture_asset( + "data/sprites/ui/prayeron_13.png"); + v.tex_pray_range_off = fc_load_texture_asset( + "data/sprites/ui/prayeroff_13.png"); + v.tex_pray_magic_on = fc_load_texture_asset( + "data/sprites/ui/prayeron_12.png"); + v.tex_pray_magic_off = fc_load_texture_asset( + "data/sprites/ui/prayeroff_12.png"); + } + + int required_resources_ready = 1; +#define REQUIRE_VIEWER_RESOURCE(condition, description) do { \ + if (!(condition)) { \ + fprintf(stderr, "error: required viewer resource failed: %s\n", \ + description); \ + required_resources_ready = 0; \ + } \ + } while (0) + REQUIRE_VIEWER_RESOURCE(v.ui.assets.missing_required_count == 0, + "RuneC UI sprites"); + REQUIRE_VIEWER_RESOURCE(v.ui.assets.font_loaded && + v.ui.assets.small_font_loaded, + "RuneC UI fonts"); + REQUIRE_VIEWER_RESOURCE(v.ui.minimap_texture_ready, + "minimap render texture"); + REQUIRE_VIEWER_RESOURCE(item_icons_ready, "Fight Caves item icons"); + REQUIRE_VIEWER_RESOURCE(v.terrain && v.terrain->loaded, "terrain mesh"); + REQUIRE_VIEWER_RESOURCE(v.minimap_scene.ready, "minimap scene raster"); + REQUIRE_VIEWER_RESOURCE(v.objects && v.objects->loaded && + v.objects->atlas.texture.id > 0, + "terrain objects and atlas"); + REQUIRE_VIEWER_RESOURCE(v.object_anims && v.object_anims->loaded, + "object animation placements"); + REQUIRE_VIEWER_RESOURCE(v.shared_model_atlas.texture.id > 0, + "shared model atlas"); + REQUIRE_VIEWER_RESOURCE(v.object_anim_models && + v.object_anim_models->loaded, + "animated object models"); + REQUIRE_VIEWER_RESOURCE(!v.object_anims || v.object_anims->count == 0 || + v.object_anim_runtimes, + "object animation runtime allocation"); + REQUIRE_VIEWER_RESOURCE(v.npc_models && v.npc_models->loaded, + "NPC models"); + REQUIRE_VIEWER_RESOURCE(v.player_model && v.player_model->loaded, + "player models"); + REQUIRE_VIEWER_RESOURCE(v.anim_cache, "actor animation data"); + REQUIRE_VIEWER_RESOURCE( + fc_combat_presentation_ready(v.combat_presentation), + "projectile, spot-animation, healthbar, and hitsplat data"); + REQUIRE_VIEWER_RESOURCE(v.pray_melee_tex.id > 0 && + v.pray_missiles_tex.id > 0 && + v.pray_magic_tex.id > 0, + "overhead Prayer icons"); + REQUIRE_VIEWER_RESOURCE( + click_cross_loaded == FC_CLICK_CROSS_FRAME_COUNT * 2, + "click cross sprites"); + REQUIRE_VIEWER_RESOURCE(v.tex_pray_melee_on.id > 0 && + v.tex_pray_melee_off.id > 0 && + v.tex_pray_range_on.id > 0 && + v.tex_pray_range_off.id > 0 && + v.tex_pray_magic_on.id > 0 && + v.tex_pray_magic_off.id > 0, + "Prayer interface icons"); +#undef REQUIRE_VIEWER_RESOURCE + if (!required_resources_ready) { + fprintf(stderr, + "error: viewer startup aborted instead of using reduced " + "graphics; reinstall and verify assets with: python3 " + "ocean/fight_caves/tools.py setup --all --force\n"); + exit_code = 1; + goto cleanup; + } + + v.combat_style = 1; /* Rapid default */ + v.policy_pipe = policy_pipe_flag; + v.policy_episode_limit = policy_episode_limit_flag; + v.start_wave = start_wave_flag; + if (v.policy_pipe) + set_policy_replay_speed(&v, policy_speed_flag); + + reset_ep(&v); + + /* Policy pipe: write initial obs so Python can send first action */ + if (v.policy_pipe) { + v.paused = 0; + fprintf(stderr, "[policy-pipe] Mode active. Reading actions from stdin.\n"); + write_obs_to_pipe(&v); + } + + int frame_count = 0; + + while (!WindowShouldClose()) { + int quit_after_tick = 0; + int ui_capture = 0; + /* Screenshot mode */ + if (screenshot_mode && frame_count == 5) { + TakeScreenshot(screenshot_path); + fprintf(stderr, "Screenshot saved to %s\n", screenshot_path); + break; + } + frame_count++; + + /* Global keys (always active) */ + if (IsKeyPressed(KEY_Q)) break; + if (IsKeyPressed(KEY_SPACE)) v.paused = !v.paused; + if (IsKeyPressed(KEY_RIGHT)) v.step_once = 1; + if (v.policy_pipe) { + if (IsKeyPressed(KEY_ONE)) set_policy_replay_speed(&v, 1); + if (IsKeyPressed(KEY_TWO)) set_policy_replay_speed(&v, 2); + if (!IsKeyDown(KEY_LEFT_SHIFT) && !IsKeyDown(KEY_RIGHT_SHIFT) && + IsKeyPressed(KEY_FOUR)) set_policy_replay_speed(&v, 4); + if (IsKeyPressed(KEY_ZERO)) set_policy_replay_speed(&v, 10); + if (IsKeyPressed(KEY_UP)) cycle_policy_replay_speed(&v, +1); + if (IsKeyPressed(KEY_DOWN)) cycle_policy_replay_speed(&v, -1); + } + if (IsKeyPressed(KEY_R)) reset_ep(&v); + if (IsKeyPressed(KEY_L)) { + if (v.camera_locked) { + v.camera.target = camera_follow_target(&v); + } + v.camera_locked = !v.camera_locked; + } + + /* Toggle keys */ + if (IsKeyPressed(KEY_G)) v.show_grid = !v.show_grid; + if (IsKeyPressed(KEY_C)) v.show_collision = !v.show_collision; + /* O: cycle debug overlay modes. O=all on/off, Shift+O=cycle sub-modes */ + if (IsKeyPressed(KEY_O)) { + if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) { + /* Cycle through individual modes */ + if (v.dbg_flags == 0) v.dbg_flags = DBG_COLLISION; + else if (v.dbg_flags == DBG_COLLISION) v.dbg_flags = DBG_LOS; + else if (v.dbg_flags == DBG_LOS) v.dbg_flags = DBG_PATH | DBG_RANGE; + else v.dbg_flags = 0; + } else { + /* Toggle all on/off */ + toggle_debug_overlay(&v); + } + } + /* D: match the on-screen controls without interfering with east movement */ + if (IsKeyPressed(KEY_D) && !IsKeyDown(KEY_W) && !IsKeyDown(KEY_A) && !IsKeyDown(KEY_S)) { + toggle_debug_overlay(&v); + } + /* Camera presets */ + if ((!v.policy_pipe && IsKeyPressed(KEY_FOUR)) || + (v.policy_pipe && + (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) && + IsKeyPressed(KEY_FOUR))) { + v.cam_yaw=0; v.cam_pitch=1.35f; v.cam_dist=120; + } + if ((!v.policy_pipe && IsKeyPressed(KEY_FIVE)) || + (v.policy_pipe && + (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) && + IsKeyPressed(KEY_FIVE))) { + v.cam_yaw=0; v.cam_pitch=0.6f; v.cam_dist=50; + } + + sync_fc_ui(&v); + ui_capture = process_runec_prayer_click(&v); + if (!ui_capture) + ui_capture = process_runec_console_input(&v); + if (!ui_capture) { + ui_capture = runec_ui_handle_input(&v.ui, GetScreenWidth(), GetScreenHeight()); + handle_runec_ui_intent(&v); + } else { + v.ui.last_intent.kind = RUNEC_UI_INTENT_NONE; + } + + /* Camera orbit + zoom */ + if (!ui_capture && IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) { + Vector2 d = GetMouseDelta(); + v.cam_yaw += d.x*0.005f; v.cam_pitch -= d.y*0.005f; + if (v.cam_pitch < 0.1f) v.cam_pitch = 0.1f; + if (v.cam_pitch > 1.4f) v.cam_pitch = 1.4f; + } + float wh = GetMouseWheelMove(); + if (!ui_capture && wh != 0) { + v.cam_dist *= (wh > 0) ? (1.0f/1.15f) : 1.15f; + if (v.cam_dist < 5) v.cam_dist = 5; + if (v.cam_dist > 300) v.cam_dist = 300; + } + + /* Tick processing */ + int tick = 0; + if (!v.paused) { + v.tick_acc += GetFrameTime() * (float)v.tps; + if (v.tick_acc >= 1.0f) { + v.tick_acc = fmodf(v.tick_acc, 1.0f); + tick = 1; + } + } + if (v.step_once) { tick = 1; v.step_once = 0; } + + /* Capture clicks and key presses EVERY frame (60fps). + * These set routes/targets/buffers on the player struct. + * The tick loop reads them when the next tick fires. */ + if (!v.policy_pipe && v.state.terminal == TERMINAL_NONE) { + process_human_clicks(&v, ui_capture); + process_human_keys(&v); + } + + if (tick && v.state.terminal == TERMINAL_NONE) { + int used_human_actions = 0; + /* Build action array for this tick */ + if (v.policy_pipe) { + if (!read_policy_actions(&v)) { + fprintf(stderr, "[policy-pipe] EOF on stdin, stopping.\n"); + break; + } + } else { + build_human_actions(&v); + used_human_actions = 1; + } + + fc_actor_animation_capture_tick_start(&v.actor_animation, + &v.state); + + /* Step simulation */ + fc_step(&v.state, v.actions); + if (used_human_actions && v.actions[5] > 0 && v.actions[6] > 0) + fc_click_feedback_accept_move_tick(&v.click_feedback, + &v.state); + fc_click_feedback_sync(&v.click_feedback, &v.state); + + /* Playable-viewer test aid only. The simulator has already + * resolved the hit; keep the local session alive at one HP. */ + if (v.godmode && + v.state.terminal == TERMINAL_PLAYER_DEATH) { + v.state.player.current_hp = 10; + v.state.terminal = TERMINAL_NONE; + } + fc_fill_render_events(&v.state, &v.render_events); + fc_actor_animation_ingest_tick(&v.actor_animation, &v.state, + &v.render_events); + update_reward_breakdown(&v); + fc_actor_animation_ingest_events( + &v.actor_animation, &v.render_events, v.anim_cache, + v.active_loadout, v.tps); + + /* Debug event log — record events from this tick */ + dbg_log_tick(&v.state); + + /* Snap prev positions for newly spawned NPCs so they don't fly. + * An NPC that wasn't active last tick but is now = new spawn. */ + for (int ni = 0; ni < FC_MAX_NPCS; ni++) { + if (v.state.npcs[ni].active && + !fc_actor_animation_previous_npc_active( + &v.actor_animation, ni)) { + fc_combat_presentation_clear_npc_healthbar( + v.combat_presentation, ni); + } + } + + fc_fill_render_entities(&v.state, v.entities, &v.entity_count); + v.last_hash = fc_state_hash(&v.state); + + FcCombatPresentationContext combat_context = { + .state = &v.state, + .events = &v.render_events, + .scene = &v.actor_animation.scene, + .terrain = v.terrain, + .anim_cache = v.anim_cache, + .player_profile = fc_player_visual_profile(v.active_loadout), + .tps = v.tps, + }; + fc_combat_presentation_ingest_tick(v.combat_presentation, + &combat_context); + /* Sync viewer attack_target with player's backend target */ + v.attack_target = v.state.player.attack_target_idx; + /* Auto-clear if target NPC died */ + if (v.state.player.attack_target_idx >= 0) { + FcNpc* tn = &v.state.npcs[v.state.player.attack_target_idx]; + if (!tn->active || tn->is_dead) { + v.attack_target = -1; + } + } + + if (v.state.terminal != TERMINAL_NONE) { + if (v.policy_pipe) { + print_policy_episode_summary(&v); + v.policy_episode_count++; + /* Write terminal obs, then auto-reset unless a fixed episode limit was requested. */ + write_obs_to_pipe(&v); + if (v.policy_episode_limit > 0 && + v.policy_episode_count >= v.policy_episode_limit) { + quit_after_tick = 1; + } else { + reset_ep(&v); + } + } else { + v.paused = 1; + } + } else if (v.policy_pipe) { + write_obs_to_pipe(&v); + } + } + + if (quit_after_tick) { + fprintf(stderr, "[policy-pipe] Episode limit reached, exiting viewer.\n"); + break; + } + + float frame_dt = GetFrameTime(); + fc_click_feedback_update(&v.click_feedback, frame_dt); + FcCombatPresentationContext combat_context = { + .state = &v.state, + .events = &v.render_events, + .scene = &v.actor_animation.scene, + .terrain = v.terrain, + .anim_cache = v.anim_cache, + .player_profile = fc_player_visual_profile(v.active_loadout), + .tps = v.tps, + }; + unsigned char deferred_deaths[FC_MAX_NPCS]; + fc_combat_presentation_deferred_deaths( + v.combat_presentation, &v.state, deferred_deaths); + fc_actor_animation_update_scene( + &v.actor_animation, &v.state, v.anim_cache, v.tps, frame_dt, + !v.paused || v.policy_pipe, deferred_deaths); + if (v.objects) + fc_animated_atlas_update(&v.objects->atlas, frame_dt); + fc_combat_presentation_update(v.combat_presentation, + &combat_context, frame_dt); + fc_combat_presentation_deferred_deaths( + v.combat_presentation, &v.state, deferred_deaths); + for (int i = 0; i < FC_MAX_NPCS; i++) { + if (!v.state.npcs[i].active && !v.state.npcs[i].died_this_tick) + fc_combat_presentation_clear_npc_healthbar( + v.combat_presentation, i); + } + fc_actor_animation_update_models( + &v.actor_animation, &v.state, v.player_model, v.npc_models, + v.anim_cache, v.active_loadout, v.tps, frame_dt, deferred_deaths); + /* Draw */ + BeginDrawing(); + ClearBackground(COL_BG); + draw_scene(&v); + sync_fc_ui(&v); + runec_ui_draw(&v.ui, GetScreenWidth(), GetScreenHeight()); + draw_runec_side_overrides(&v); + draw_runec_console(&v); + draw_click_cross(&v); + + EndDrawing(); + } + +cleanup: + if (v.pray_melee_tex.id > 0) UnloadTexture(v.pray_melee_tex); + if (v.pray_missiles_tex.id > 0) UnloadTexture(v.pray_missiles_tex); + if (v.pray_magic_tex.id > 0) UnloadTexture(v.pray_magic_tex); + for (int i = 0; i < FC_CLICK_CROSS_FRAME_COUNT * 2; i++) { + if (v.click_cross_tex[i].id > 0) + UnloadTexture(v.click_cross_tex[i]); + } + if (v.tex_pray_melee_on.id > 0) UnloadTexture(v.tex_pray_melee_on); + if (v.tex_pray_melee_off.id > 0) UnloadTexture(v.tex_pray_melee_off); + if (v.tex_pray_range_on.id > 0) UnloadTexture(v.tex_pray_range_on); + if (v.tex_pray_range_off.id > 0) UnloadTexture(v.tex_pray_range_off); + if (v.tex_pray_magic_on.id > 0) UnloadTexture(v.tex_pray_magic_on); + if (v.tex_pray_magic_off.id > 0) UnloadTexture(v.tex_pray_magic_off); + fc_combat_presentation_destroy(v.combat_presentation); + fc_actor_animation_shutdown(&v.actor_animation); + if (v.object_anim_runtimes) { + for (int i = 0; i < v.object_anim_runtime_count; i++) { + if (v.object_anim_runtimes[i].anim_state) + anim_model_state_free(v.object_anim_runtimes[i].anim_state); + } + free(v.object_anim_runtimes); + } + if (v.anim_cache) anim_cache_free(v.anim_cache); + if (v.player_model) fc_npc_models_unload(v.player_model); + if (v.npc_models) fc_npc_models_unload(v.npc_models); + if (v.object_anim_models) fc_npc_models_unload(v.object_anim_models); + fc_animated_atlas_unload(&v.shared_model_atlas); + if (v.object_anims) object_anims_free(v.object_anims); + objects_free(v.objects); + fc_minimap_scene_free(&v.minimap_scene); + terrain_free(v.terrain); + fc_osrs_text_shutdown(); + runec_ui_shutdown(&v.ui); + CloseWindow(); + return exit_code; +} diff --git a/resources/fight_caves/.gitignore b/resources/fight_caves/.gitignore new file mode 100644 index 0000000000..03e4c4639a --- /dev/null +++ b/resources/fight_caves/.gitignore @@ -0,0 +1,6 @@ +/runtime/ +/viewer/ +/.runtime.installing/ +/.runtime.backup/ +/.viewer.installing/ +/.viewer.backup/ diff --git a/resources/fight_caves/ASSET_NOTICE.md b/resources/fight_caves/ASSET_NOTICE.md new file mode 100644 index 0000000000..b72c7f0c4d --- /dev/null +++ b/resources/fight_caves/ASSET_NOTICE.md @@ -0,0 +1,9 @@ +# Asset notice + +The Fight Caves asset bundles contain data exported from Old School RuneScape +cache material for environment simulation and visualization. RuneScape and Old +School RuneScape are trademarks of Jagex Limited. These exported assets are not +covered by PufferLib's software license. + +The bundles contain only the files required at runtime. Cache-export tools and +raw game caches are not included. diff --git a/resources/fight_caves/README.md b/resources/fight_caves/README.md new file mode 100644 index 0000000000..7722d531b7 --- /dev/null +++ b/resources/fight_caves/README.md @@ -0,0 +1,42 @@ +# Fight Caves assets + +Fight Caves uses two independently versioned asset bundles: + +- `core` contains the collision, movement, and line-of-sight maps required by + training, evaluation, and the viewer. +- `viewer` contains the models, animations, terrain, textures, sprites, fonts, + and minimap used only by the graphical viewer. + +The archives are pinned in `asset_manifest.json` by URL, byte size, and SHA-256. +Every installed file is also checked by byte size and SHA-256 before it is +accepted. Installed asset directories are intentionally excluded from Git. + +From the PufferLib repository root, install only the simulator data: + +```bash +python3 ocean/fight_caves/tools.py setup --core +``` + +Install only the graphical assets, or install both bundles: + +```bash +python3 ocean/fight_caves/tools.py setup --viewer +python3 ocean/fight_caves/tools.py setup --all +``` + +Running the script without a bundle option is equivalent to `--all`. Verify an +existing installation without downloading or changing it with: + +```bash +python3 ocean/fight_caves/tools.py setup --all --verify-only +``` + +Fight Caves build, viewer-launch, and policy-replay entry points invoke this +verification automatically and fail with a nonzero exit status when required +data is absent or corrupt. They do not fall back to open arena maps or an +incomplete graphical asset set. + +The simulator retains the `FC_COLLISION_PATH`, `FC_MOVEMENT_PATH`, and +`FC_LOS_PATH` environment-variable overrides for controlled development and +testing. The integrated viewer uses `resources/fight_caves/viewer` as its +default asset root. diff --git a/resources/fight_caves/asset_manifest.json b/resources/fight_caves/asset_manifest.json new file mode 100644 index 0000000000..db02a75a50 --- /dev/null +++ b/resources/fight_caves/asset_manifest.json @@ -0,0 +1,3423 @@ +{ + "bundles": { + "core": { + "archive": "fight-caves-runtime-assets-v2.tar.gz", + "files": [ + { + "path": "runtime/fightcaves.collision", + "sha256": "f1db791e4864fb4555138a59bbf3d0974d6113242fdd376ca6871ceb4ea3cc32", + "size_bytes": 4096 + }, + { + "path": "runtime/fightcaves.los", + "sha256": "918493183142c5cc8f11b871a7d2a2b9ba13bdb2a1847b0f9664416d6c1e85ff", + "size_bytes": 4096 + }, + { + "path": "runtime/fightcaves.movement", + "sha256": "c1420936d0cd4b9539f7b91a1733232ecea17f2623e90dda9bb7121192d7db42", + "size_bytes": 4096 + } + ], + "install_prefix": "runtime", + "sha256": "8d65e184bb101af15642a1117353d22eed930f3b8e612ace7ee31c313e6123b3", + "size_bytes": 1397, + "url": "https://github.com/jordanbailey00/fc-rl/releases/download/fight-caves-assets-v2/fight-caves-runtime-assets-v2.tar.gz" + }, + "viewer": { + "archive": "fight-caves-viewer-assets-v2.tar.gz", + "files": [ + { + "path": "viewer/data/fonts/p11_full.png", + "sha256": "1d059f2a5bb5c97cd9148e44bccaedb0e5ce2548bda57b645892499043c71a49", + "size_bytes": 4679 + }, + { + "path": "viewer/data/fonts/runescape.ttf", + "sha256": "7477bbfd998b8e16e8a1e898fd49fb736a6fdfa00e7eb364d4d441f10290f03a", + "size_bytes": 22500 + }, + { + "path": "viewer/data/fonts/runescape_small.ttf", + "sha256": "fe43104752ad819d5b037bb544a194f7dc8fdf1c2dfe7f4cdf20a4f98896ed8a", + "size_bytes": 19900 + }, + { + "path": "viewer/data/sprites/items/item_10499.png", + "sha256": "e6d9df67483eff738a8e02ed59f2320e0ad997e15818ebce559e8b68f5115df1", + "size_bytes": 880 + }, + { + "path": "viewer/data/sprites/items/item_11126.png", + "sha256": "11023cb73224552663f2cc9513086a5885704cd708eb04ff302f162632a2a573", + "size_bytes": 996 + }, + { + "path": "viewer/data/sprites/items/item_11212.png", + "sha256": "4da05e584bbc1913da06d2a08b14d914085b255180b0f5dd9535f4cceda0bda3", + "size_bytes": 260 + }, + { + "path": "viewer/data/sprites/items/item_1169.png", + "sha256": "118543e3043d39d4ddc13398e3c39cdb7b38aecdd887fed557dccc2c6920d6ab", + "size_bytes": 580 + }, + { + "path": "viewer/data/sprites/items/item_11785.png", + "sha256": "d6f906c69c2773df93993fefbb94b031d1c528a125cbc6c067a297a778bacc23", + "size_bytes": 647 + }, + { + "path": "viewer/data/sprites/items/item_11826.png", + "sha256": "9610037d6ae1103fab55655ca56f2e82d86ae7845b04526244452697d2e3d631", + "size_bytes": 821 + }, + { + "path": "viewer/data/sprites/items/item_11828.png", + "sha256": "e4675bc01af16e4941e70122f79139ef5ff34fd472522c634c5b4ed963825f1f", + "size_bytes": 1054 + }, + { + "path": "viewer/data/sprites/items/item_11830.png", + "sha256": "f6ea43c79f54b5494773ede94fabd0d86d668022a025f87486a08015c0743543", + "size_bytes": 738 + }, + { + "path": "viewer/data/sprites/items/item_12596.png", + "sha256": "69d6b7ef2ff772e42b1ee28ae9ce56f2ee6e58c5f937ff203e70bdc723aee4d9", + "size_bytes": 1120 + }, + { + "path": "viewer/data/sprites/items/item_12610.png", + "sha256": "2ee2c650032a60deea3c01b5996e0e3f85740b009ec8bd581e1154444d0c0f33", + "size_bytes": 814 + }, + { + "path": "viewer/data/sprites/items/item_12788.png", + "sha256": "f284415426323a321b6b2687807db66ad2448a0d7c0a8e794757d24a9ba791dc", + "size_bytes": 410 + }, + { + "path": "viewer/data/sprites/items/item_12926.png", + "sha256": "b03bfce20a003d4c74ba49f3af75780b565ac2b65b22ed391c7307f9738b8962", + "size_bytes": 749 + }, + { + "path": "viewer/data/sprites/items/item_13237.png", + "sha256": "aba5eef2dbab9a296f4235fab8cd363eae9a0decec489d89eaa91a012bc806ce", + "size_bytes": 822 + }, + { + "path": "viewer/data/sprites/items/item_139.png", + "sha256": "30ca3c63da07bd39e7390b46a1425808898b34607474a5b163c20f49247ae6cc", + "size_bytes": 827 + }, + { + "path": "viewer/data/sprites/items/item_141.png", + "sha256": "4993ef7beafddafe34362e6ce00d83d6eb559e2ac94aedd6d56e86b639b9e52d", + "size_bytes": 904 + }, + { + "path": "viewer/data/sprites/items/item_143.png", + "sha256": "5f1ca89a7449bfc8b1d917c339297a953f802ced9ef3062d4cf1dd012d92999a", + "size_bytes": 795 + }, + { + "path": "viewer/data/sprites/items/item_1704.png", + "sha256": "4fad7a768f47b941b2c00524030eee256744155d6959abe0d2f9f6bd403f5bd5", + "size_bytes": 499 + }, + { + "path": "viewer/data/sprites/items/item_19547.png", + "sha256": "0e29a17f1d8bab1012348fad4eeda761333dbe7cfca3e4f08fd0a7440385d3f5", + "size_bytes": 520 + }, + { + "path": "viewer/data/sprites/items/item_20997.png", + "sha256": "4cb543e526fa6bffcf2e36752d05d5c2362643066b03ed7bc91d578affcce8fd", + "size_bytes": 603 + }, + { + "path": "viewer/data/sprites/items/item_21902.png", + "sha256": "2581586ef0b7154dfe862b5a145d4a03f29b338d80ef9a7337fef2a22415cbfd", + "size_bytes": 791 + }, + { + "path": "viewer/data/sprites/items/item_21946.png", + "sha256": "9c4b05da3ae50dd7a0b77b3748e9f321f9eb42b41ddfd064138f621038e7383c", + "size_bytes": 497 + }, + { + "path": "viewer/data/sprites/items/item_22109.png", + "sha256": "d736c54971fc6733bbf3180a3fc3df3c3c9fc64db88aad7d8d0ebdda3f9ccfed", + "size_bytes": 1080 + }, + { + "path": "viewer/data/sprites/items/item_229.png", + "sha256": "ecb3d9a3efc21b86dd706b5e180851d054f4ddd36254516d450c323a7b47ff7b", + "size_bytes": 832 + }, + { + "path": "viewer/data/sprites/items/item_23971.png", + "sha256": "fa5367463b5370b507383f0abbdd9283adecee19ee20ba9dcc354a2cfd4fc1b1", + "size_bytes": 853 + }, + { + "path": "viewer/data/sprites/items/item_23975.png", + "sha256": "d049832c203b5570471405b36f6b41e284480084828a5be7e1741b23e7b3563f", + "size_bytes": 1089 + }, + { + "path": "viewer/data/sprites/items/item_23979.png", + "sha256": "34039fc0b48e58eb8ab10c785cad581b0403382864142d2dfb20df1376ffa527", + "size_bytes": 626 + }, + { + "path": "viewer/data/sprites/items/item_2434.png", + "sha256": "1ce9b08635f6ae78f0e27c8c909c0b567c1adaec073405679c3ca7eb41fc6d79", + "size_bytes": 827 + }, + { + "path": "viewer/data/sprites/items/item_2491.png", + "sha256": "a713c2c93ce579c401119a63dc1e8f3baad9e5eb3be194186d6cd16055621309", + "size_bytes": 310 + }, + { + "path": "viewer/data/sprites/items/item_2495.png", + "sha256": "ff7051ad83ec8cd8a18268eff75ff6a4f62a125005bc596a0fda3d57d5196c17", + "size_bytes": 1079 + }, + { + "path": "viewer/data/sprites/items/item_2497.png", + "sha256": "7d32964ded6799730f21558dc065d414b94d14caaf87c1c4b6991ee8f63f21f4", + "size_bytes": 312 + }, + { + "path": "viewer/data/sprites/items/item_2503.png", + "sha256": "d02e085d21a8c7395af888f84803ccadd01eb32751a3477979725ccad12f14d0", + "size_bytes": 356 + }, + { + "path": "viewer/data/sprites/items/item_25487.png", + "sha256": "737efd24af57fdf658bebd9e181f26dc4b0ff327a896f808b18ff840e4e6e81d", + "size_bytes": 1324 + }, + { + "path": "viewer/data/sprites/items/item_2577.png", + "sha256": "b66101760d14d4663bb4cd2de59b8000b08fe32a9faf71ab8e74b5067a7f4425", + "size_bytes": 807 + }, + { + "path": "viewer/data/sprites/items/item_2581.png", + "sha256": "a68b4dfba58069399a67309b49c108d1df0d0f2f8a3844463da31e6119952b01", + "size_bytes": 747 + }, + { + "path": "viewer/data/sprites/items/item_25867.png", + "sha256": "75e444d9e3616b07e242a8fad5b6c2276675ff89af24194d86e70df8ff11a440", + "size_bytes": 477 + }, + { + "path": "viewer/data/sprites/items/item_26235.png", + "sha256": "5b7a06c111428df15b2e104a946a27403be626863acdf889331773d614aed42f", + "size_bytes": 652 + }, + { + "path": "viewer/data/sprites/items/item_27226.png", + "sha256": "160c42595c63758c6e0baf8be1334fc49f1952114d9d9b5e6ccbe20a4cafc724", + "size_bytes": 586 + }, + { + "path": "viewer/data/sprites/items/item_27229.png", + "sha256": "f523d610f8e8f75968909260fe0c2997454a419252ff1f44ee9c695f302b87a6", + "size_bytes": 1098 + }, + { + "path": "viewer/data/sprites/items/item_27232.png", + "sha256": "c1656091d430c87c82f2bf45dedcfbc52d660b6206b13a91a0fe50654b01e2d2", + "size_bytes": 579 + }, + { + "path": "viewer/data/sprites/items/item_27235.png", + "sha256": "122aa40122cc1a9f104e74fcde1cb16643daac169987310c30ba9c9c785d25e8", + "size_bytes": 978 + }, + { + "path": "viewer/data/sprites/items/item_27238.png", + "sha256": "79d31197dba977c9ad0b1af21516ab0f667aa3be74f5972082dbaa654ee5b575", + "size_bytes": 1161 + }, + { + "path": "viewer/data/sprites/items/item_27241.png", + "sha256": "dfd1a6c43d54a3e741db5174acfc0423d59822a984ea994478fb4333b432bb1b", + "size_bytes": 658 + }, + { + "path": "viewer/data/sprites/items/item_27614.png", + "sha256": "e8765f799ff6b6534d273e155e0b24b5dc5717c5b523d79623e3e1a4c4912a5c", + "size_bytes": 708 + }, + { + "path": "viewer/data/sprites/items/item_385.png", + "sha256": "a73db2538fab30a4900330bec9503f42435ccacb5def2974ea62100ab401ca19", + "size_bytes": 1012 + }, + { + "path": "viewer/data/sprites/items/item_6328.png", + "sha256": "f1479c84c1d60e255e56c0d172782428c96b103fe4e41f21ce74d4a170a510dd", + "size_bytes": 571 + }, + { + "path": "viewer/data/sprites/items/item_7462.png", + "sha256": "94c6e56b26b7a75e70cc2695b5b59c33f9c07d4fc2a5b7c91efab8155bfc6e95", + "size_bytes": 711 + }, + { + "path": "viewer/data/sprites/items/item_810.png", + "sha256": "740af957022cda1493511e4938b1af8182c55d4a86cf5a82e6797f2e7168fad5", + "size_bytes": 553 + }, + { + "path": "viewer/data/sprites/items/item_892.png", + "sha256": "f47a0c8dc5e61e7216b9c13d8c6d37459d74f9268fc208202167c26f01a9c616", + "size_bytes": 247 + }, + { + "path": "viewer/data/sprites/items/item_9143.png", + "sha256": "29b55c03aef2f8e36e6aeb0ab12bd58e70bfd0a7d6856aadd60e2de1a9637bfe", + "size_bytes": 266 + }, + { + "path": "viewer/data/sprites/items/item_9185.png", + "sha256": "a96b0bdd427ee4d7ee116d36b1f22929565ad0fd97a18424dd20b6e7e284e589", + "size_bytes": 721 + }, + { + "path": "viewer/data/sprites/ui/2420.png", + "sha256": "2c587b227b8276f3adaff1354cb0008b706cdf91464896b8e63691b151289c1f", + "size_bytes": 743 + }, + { + "path": "viewer/data/sprites/ui/299_0.png", + "sha256": "72c11f419fe5a83e2f442e8582b3fbcb5fff6079fd179cbea558ec41bf7cd2ea", + "size_bytes": 157 + }, + { + "path": "viewer/data/sprites/ui/299_1.png", + "sha256": "a74ce00dab36d9daec1636c59c4e1f72b66e24940b25b02da50b22cac2f07930", + "size_bytes": 185 + }, + { + "path": "viewer/data/sprites/ui/299_2.png", + "sha256": "8fe9960a096a2e25f30b5ce2b88e6d8f884e5838bc3b1cf73805408415a56ba5", + "size_bytes": 174 + }, + { + "path": "viewer/data/sprites/ui/299_3.png", + "sha256": "3d7ee84fcd059cdf84b702912b16f2bea1143334e246ae004cd862f0e0a88491", + "size_bytes": 145 + }, + { + "path": "viewer/data/sprites/ui/299_4.png", + "sha256": "cd17e745555884512814964509de0743c4939e8ecf4050fea6198fa54ac84db8", + "size_bytes": 158 + }, + { + "path": "viewer/data/sprites/ui/299_5.png", + "sha256": "30168eacec24ae23e00095789932b2416ad189fe23ff4a1b3c3909ed20e8a0e3", + "size_bytes": 183 + }, + { + "path": "viewer/data/sprites/ui/299_6.png", + "sha256": "862630c1da209a3d69ac9af1e5bbe32941608d5efef392646c193ed2263d03be", + "size_bytes": 173 + }, + { + "path": "viewer/data/sprites/ui/299_7.png", + "sha256": "453275728a4847c029a386ab23480a44b721975360ccd576d65d272bf10aac94", + "size_bytes": 145 + }, + { + "path": "viewer/data/sprites/ui/border_map_compass.png", + "sha256": "96f572e5aed547e69e90b39d075923ac0f8a379ad1d4eaed996c61d44530dfe1", + "size_bytes": 4815 + }, + { + "path": "viewer/data/sprites/ui/chat_tab_button_0.png", + "sha256": "9d8c16bccd7d4df410ccd03e8a405765c8eef6e7c0c7a429602a28bffd3ca7fa", + "size_bytes": 1400 + }, + { + "path": "viewer/data/sprites/ui/chat_tab_button_1.png", + "sha256": "77c2d6c49e97015e473a857a4c502829927fb28f33b5038b493cd1259ef8e371", + "size_bytes": 1536 + }, + { + "path": "viewer/data/sprites/ui/chat_tab_button_2.png", + "sha256": "4da5cbc2240d5875ef6c8b33a84d6712125f432215324d70dab181e628174a83", + "size_bytes": 984 + }, + { + "path": "viewer/data/sprites/ui/chat_tab_button_3.png", + "sha256": "23b310850d0179ea549849b64be4696df3b10d250e7a54fe8daeb2ca6dd754cd", + "size_bytes": 1130 + }, + { + "path": "viewer/data/sprites/ui/chat_tab_button_4.png", + "sha256": "b522a1515e8752eb1674dcbde282b1008daf57f2ff8aeb2dc0a462b7dbb8a096", + "size_bytes": 1307 + }, + { + "path": "viewer/data/sprites/ui/chat_tab_button_5.png", + "sha256": "b281f6e24194177aa257b921d5b6364dbac422d9aff195bffcfd26fddf2c8f9f", + "size_bytes": 758 + }, + { + "path": "viewer/data/sprites/ui/chatbox_bg.png", + "sha256": "12a70acc7598da901f4c4380837b981c15cd288107ce7e9638711c579bc78e68", + "size_bytes": 55875 + }, + { + "path": "viewer/data/sprites/ui/combat_shield.png", + "sha256": "7493416b58b2dbd715005980ea9a71cc3cbaf80335394039e9db792a0ee8ad02", + "size_bytes": 1115 + }, + { + "path": "viewer/data/sprites/ui/combatboxes_0.png", + "sha256": "c984266abe004fe2a0d6a7f73e991576501b1157db503c1f2d01a41610ded30b", + "size_bytes": 2346 + }, + { + "path": "viewer/data/sprites/ui/combatboxes_1.png", + "sha256": "875d766ef65282607866bf3c2ca15f5e6d96d841dead00bd0de69192cb63ea45", + "size_bytes": 2159 + }, + { + "path": "viewer/data/sprites/ui/combatboxes_2.png", + "sha256": "ded3d6a3d5b6c9ffbd9a4ec6a43bbec6d895cbddb3652056b58da73b05ca146a", + "size_bytes": 1627 + }, + { + "path": "viewer/data/sprites/ui/combatboxes_3.png", + "sha256": "7637dad3975e96c06daeba850479ad0cc03bbf058cfdb506c9d22047ee460101", + "size_bytes": 1519 + }, + { + "path": "viewer/data/sprites/ui/combatboxes_large_0.png", + "sha256": "2d7e668ed91cc3e094bc1aeb9a94b58eca27b85dec1f22b4639e8e92f2444a28", + "size_bytes": 2570 + }, + { + "path": "viewer/data/sprites/ui/combatboxes_large_1.png", + "sha256": "70216118aba2ffe59be18cf814c51d19d6831f67f7ebdbcbec2e5428566178a2", + "size_bytes": 2655 + }, + { + "path": "viewer/data/sprites/ui/combatboxes_special_attack.png", + "sha256": "c878505831b5f37c7b7d994ca2c26be03e8ee4df05bb08e787a978313fdeb68e", + "size_bytes": 2114 + }, + { + "path": "viewer/data/sprites/ui/combatboxes_very_large_0.png", + "sha256": "6e5b595b381a29c042bc1eed058c28845c965000e22e39f0e4c3508fdb427a7b", + "size_bytes": 4208 + }, + { + "path": "viewer/data/sprites/ui/combatboxes_very_large_1.png", + "sha256": "160cc88ed92383dae7ae0a35aa8e1f86c201c653c9aacd1e199ce7985fdcdbd6", + "size_bytes": 4178 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_0.png", + "sha256": "d8eab0f9105aa1d1b7318f582a4796608ef5155dcfa39096af5d49bb38134b47", + "size_bytes": 627 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_1.png", + "sha256": "ab8da0735f8a2cf893ce3b93097dc894b4ed43a1f4aadeb5dd3c65d52dcca6be", + "size_bytes": 489 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_10.png", + "sha256": "d0886cd8f9db4f099ea8c7e5409791903d5cce3274299c6b6d2ede9cbc3ac39f", + "size_bytes": 367 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_11.png", + "sha256": "1df562ea17909ac422c53cb482f79ae7946d84585d5a4d63e9c135e03d6e9b99", + "size_bytes": 355 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_12.png", + "sha256": "584ae5d9c95464dc8e4a341221585475fc0298b2facdd1c4b61ddd01fd4d800c", + "size_bytes": 368 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_13.png", + "sha256": "e4f04015838f0bf9695bf716dcdc23b6a136768a4cd49e25d65a6ae79794ef34", + "size_bytes": 182 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_14.png", + "sha256": "ee23fe71b824160f7eeeef83e3219e768d16d64e2eb0ead0652f624d846e5f5e", + "size_bytes": 278 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_15.png", + "sha256": "11fbeab34656eecd6992ecfc0788e05d9e08ebbc04a1d43f3519f4e4d1eb3262", + "size_bytes": 434 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_16.png", + "sha256": "f1557bd9d98f976d805a91bee8d08d2a6a023dba887e7b4a2e1d1f23413f0281", + "size_bytes": 436 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_17.png", + "sha256": "1e90d4725e957dcff84d60085cbf2725727949db5e78e8adc49c32e437dd2739", + "size_bytes": 442 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_18.png", + "sha256": "a3f82e534dde1e28d7ee3c7aa9d7e9f2a0a7a430b19aaf979f894d7823363c39", + "size_bytes": 462 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_19.png", + "sha256": "fad6baad2c356d4af3921e0de15b5cf6df82ce62f7f62e248084050e475dace4", + "size_bytes": 437 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_2.png", + "sha256": "bece9dd1cc4e43768e44855c04657102594faef6293e849b8128eeb4eec219c9", + "size_bytes": 478 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_3.png", + "sha256": "c6bb9a972d5ffcfb7b07bf19461072a93c62da7c8ba7884663925694b75753ac", + "size_bytes": 474 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_4.png", + "sha256": "f7845bb02720c6ae39f1b94a0cd03a3bb633f5a29eaae018e67ed1eaa09c1156", + "size_bytes": 326 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_5.png", + "sha256": "a1d666aaace8927049f8023df92556766a8fa440108db950e42b5de14fa361b6", + "size_bytes": 485 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_6.png", + "sha256": "68622a5cfa80a83663569f42e39ea0aff9830ece790611dd37604dc81a22b7d8", + "size_bytes": 491 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_7.png", + "sha256": "4c62b9b8107e3db548ed9a4cb96b226d77227f23dd39ff87aee0136f5d3abac0", + "size_bytes": 485 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_8.png", + "sha256": "1e23733d668cb8f0676ab4aa3c3b56f98598348f1d694a7f9ae907cb3a686850", + "size_bytes": 568 + }, + { + "path": "viewer/data/sprites/ui/combaticons2_9.png", + "sha256": "b1cec1a22d542dad6240248bcd8b09a33309d1ca7193aba0c19393178c02bf80", + "size_bytes": 443 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_0.png", + "sha256": "7302296e50110e3cb3b3b4ba310a5cee67a37a4762c894a4cf31bb5adf8a89c8", + "size_bytes": 473 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_1.png", + "sha256": "9669f7952d8ce212f163c8744c46a573503bc8cb9f5ed7a44757a8f4092e5432", + "size_bytes": 417 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_10.png", + "sha256": "dfd1d6acb4f3d857a432ba4b3f3b59593222d18b0207178bac0e0212a42322be", + "size_bytes": 435 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_11.png", + "sha256": "39b785831c41189cce2b4a8e21496fbb35ef6bc74c18955f16c196f8f5691ced", + "size_bytes": 266 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_12.png", + "sha256": "e3b3f0da727efd829aec1c4c165edd60730d23ac7fb73ce54b282bead37e156a", + "size_bytes": 307 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_13.png", + "sha256": "5ae5d0eeb65da334a98bae6ebc59c1aeaedcb9d3827dc7965b83fd7f72995038", + "size_bytes": 267 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_14.png", + "sha256": "bb48207f30dba4134516ca66c70dc6febf6ebe319e735db191de5ef828e796fc", + "size_bytes": 279 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_15.png", + "sha256": "786c10d23dabf8ddd763032c7f4a48a2f113f6e3a06dc120745bdd6dd2d448e8", + "size_bytes": 731 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_16.png", + "sha256": "9498034041ff1deba154760084512dbf18cc8d3031ed427bab42149325f355b8", + "size_bytes": 596 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_17.png", + "sha256": "9bb99af10d6702f18a70820977e217cefd3f50e33c901b28955faa40c145429f", + "size_bytes": 644 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_18.png", + "sha256": "390313861ce72ff97f9a8d39e920658167213190fb2a29458718b85951cd5d43", + "size_bytes": 556 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_19.png", + "sha256": "f43e3ac28d4d8a6607b94a0f206b9f6a2b230941f22a549bbeffbcc4df499866", + "size_bytes": 83 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_2.png", + "sha256": "c625e0002c65b5a0aa426504c728680bda2f5ffb9ac775b6dddfeaee4df01c9c", + "size_bytes": 403 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_3.png", + "sha256": "328068871a93da1325f4bcb92df40defeac25c0cabd50ebe970b4b955a2b223c", + "size_bytes": 396 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_4.png", + "sha256": "4820d3cbba0c21e85c56dcbd290b0154d2df7b9c2ef2402e7152e4b89cb1cc80", + "size_bytes": 274 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_5.png", + "sha256": "10c6e17fdfc15ba2902cbd1a3387e7b68c36857665497828f643afa49f5365f2", + "size_bytes": 310 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_6.png", + "sha256": "f7b274aa70d318f8111a4533710f090c6baef7125a0f159f2dc700881e32f50a", + "size_bytes": 303 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_7.png", + "sha256": "1e82fbd51bc1bcda82ff4436b6b3f5d03ed5acab75e76b9fedadea98e1361bb5", + "size_bytes": 359 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_8.png", + "sha256": "d27d415de033ffdf774bdd2f87b2bfecadb9a39ed22d815b9f2f4c6fd8c23a58", + "size_bytes": 815 + }, + { + "path": "viewer/data/sprites/ui/combaticons3_9.png", + "sha256": "83f5eadc7e529b68b2c6090e190d85d8b3b6f45b56a58df7ce4caf156855f76b", + "size_bytes": 796 + }, + { + "path": "viewer/data/sprites/ui/combaticons_0.png", + "sha256": "56f9a06c28377120e0f6486c497f2899818af98ae4a74901d06c00476e34519d", + "size_bytes": 553 + }, + { + "path": "viewer/data/sprites/ui/combaticons_1.png", + "sha256": "f6996e7ab96080fb8ece54c516bdf8bbae7f7f1275968f9c54b1af478980ccac", + "size_bytes": 463 + }, + { + "path": "viewer/data/sprites/ui/combaticons_10.png", + "sha256": "a88933c0aa07948cb918662899c223d652ae5fdce379150518f51c917d48ce0a", + "size_bytes": 352 + }, + { + "path": "viewer/data/sprites/ui/combaticons_11.png", + "sha256": "03fa55a78532b552e60ffc4a28690b8ff9d42ee9eb997774d2c0972ca1330f0d", + "size_bytes": 314 + }, + { + "path": "viewer/data/sprites/ui/combaticons_12.png", + "sha256": "71afeeace2b57cccec1bb490e600623645345b68830bbb07ce4a0999b5377a79", + "size_bytes": 354 + }, + { + "path": "viewer/data/sprites/ui/combaticons_13.png", + "sha256": "2180badf8eb4e2a2853cc793efbc3fd7767a6e3a9af4e7487a35b0008e9f9052", + "size_bytes": 332 + }, + { + "path": "viewer/data/sprites/ui/combaticons_14.png", + "sha256": "2e63817d70d3c6372d9a89d528bc013b3e4b5e8258917cb5bfc5007ff32a8d71", + "size_bytes": 391 + }, + { + "path": "viewer/data/sprites/ui/combaticons_15.png", + "sha256": "651950a48134cbe0047340628117e3e201f5872d7f1636721e04d424fc5c4a22", + "size_bytes": 334 + }, + { + "path": "viewer/data/sprites/ui/combaticons_16.png", + "sha256": "13a5f360d74ec08108416cca7a6ee676fadca12e9f087520ff68ef1f7bf0c009", + "size_bytes": 496 + }, + { + "path": "viewer/data/sprites/ui/combaticons_17.png", + "sha256": "67dae7c1350e692c4e9c048ef5ead96a4dcdb13ea27df9c1014d8df74b030e20", + "size_bytes": 282 + }, + { + "path": "viewer/data/sprites/ui/combaticons_18.png", + "sha256": "7ae8ca2fd19c9c2f46cb2048c4a1bd273dd55431be024c786dced43b27ad1dff", + "size_bytes": 273 + }, + { + "path": "viewer/data/sprites/ui/combaticons_19.png", + "sha256": "a04d077fff404017944a693537ebde0c2b0e1b01afbd5ca78503817f93809c32", + "size_bytes": 232 + }, + { + "path": "viewer/data/sprites/ui/combaticons_2.png", + "sha256": "acc6d86ffc83d0f25ad9bd41b8e2299d90e33aaa4817ab5b030bea506b8bfbbb", + "size_bytes": 454 + }, + { + "path": "viewer/data/sprites/ui/combaticons_3.png", + "sha256": "8f842d133c5e945d0e9f191a6923ac49a9e0550d723a186b0aeac47d03149fe3", + "size_bytes": 452 + }, + { + "path": "viewer/data/sprites/ui/combaticons_4.png", + "sha256": "053386cbeed8805624272b06a9d19dcc97d6209a8748fedfaa09fb5894d832e5", + "size_bytes": 432 + }, + { + "path": "viewer/data/sprites/ui/combaticons_5.png", + "sha256": "1801e3da89f221f23f3881397651ae4789ac3cc2fd61b6fed4b807feb6a73af1", + "size_bytes": 360 + }, + { + "path": "viewer/data/sprites/ui/combaticons_6.png", + "sha256": "1a0b4024f7e7fb245dcd0868ce6641c9588730069ba34bdbd30a84e7436bf4ff", + "size_bytes": 251 + }, + { + "path": "viewer/data/sprites/ui/combaticons_7.png", + "sha256": "a5f6089624d0a29655c033e5d98ebf58382cb977130962750ca813ba6e11119e", + "size_bytes": 291 + }, + { + "path": "viewer/data/sprites/ui/combaticons_8.png", + "sha256": "cc6373a5e975705a17e444681165ab75cab7a0e6edd9b3f3dfff195822966735", + "size_bytes": 225 + }, + { + "path": "viewer/data/sprites/ui/combaticons_9.png", + "sha256": "12694601176ff8d1eebd0578576371b231ab674f9ccecc58f72792caa002e6c3", + "size_bytes": 242 + }, + { + "path": "viewer/data/sprites/ui/compass.png", + "sha256": "f66713c4aeada87ad8721ab734387863a4348c95a7f80b2a1b788ad90e490472", + "size_bytes": 2186 + }, + { + "path": "viewer/data/sprites/ui/compass_outline.png", + "sha256": "c8ddd63aca630c9832d026053947170e8ab6a11619fb30efd84b1c692f4c02bd", + "size_bytes": 943 + }, + { + "path": "viewer/data/sprites/ui/cross_0.png", + "sha256": "72c11f419fe5a83e2f442e8582b3fbcb5fff6079fd179cbea558ec41bf7cd2ea", + "size_bytes": 157 + }, + { + "path": "viewer/data/sprites/ui/cross_1.png", + "sha256": "a74ce00dab36d9daec1636c59c4e1f72b66e24940b25b02da50b22cac2f07930", + "size_bytes": 185 + }, + { + "path": "viewer/data/sprites/ui/cross_2.png", + "sha256": "8fe9960a096a2e25f30b5ce2b88e6d8f884e5838bc3b1cf73805408415a56ba5", + "size_bytes": 174 + }, + { + "path": "viewer/data/sprites/ui/cross_3.png", + "sha256": "3d7ee84fcd059cdf84b702912b16f2bea1143334e246ae004cd862f0e0a88491", + "size_bytes": 145 + }, + { + "path": "viewer/data/sprites/ui/cross_4.png", + "sha256": "cd17e745555884512814964509de0743c4939e8ecf4050fea6198fa54ac84db8", + "size_bytes": 158 + }, + { + "path": "viewer/data/sprites/ui/cross_5.png", + "sha256": "30168eacec24ae23e00095789932b2416ad189fe23ff4a1b3c3909ed20e8a0e3", + "size_bytes": 183 + }, + { + "path": "viewer/data/sprites/ui/cross_6.png", + "sha256": "862630c1da209a3d69ac9af1e5bbe32941608d5efef392646c193ed2263d03be", + "size_bytes": 173 + }, + { + "path": "viewer/data/sprites/ui/cross_7.png", + "sha256": "453275728a4847c029a386ab23480a44b721975360ccd576d65d272bf10aac94", + "size_bytes": 145 + }, + { + "path": "viewer/data/sprites/ui/fixed_compass_mask.png", + "sha256": "73465476e997c2d8e11b672da5618ca40e1b80c4906a5839ea747d814b2b7998", + "size_bytes": 213 + }, + { + "path": "viewer/data/sprites/ui/fixed_map_clickmask.png", + "sha256": "57da7782bd2d840e15730e41d53926ec6e30da97ddd224a6e04a4b3ed52b51c9", + "size_bytes": 490 + }, + { + "path": "viewer/data/sprites/ui/fixed_map_mask.png", + "sha256": "682beca802075ef78a1e040fae76b248afbd5064776f468370dc01f7873d517e", + "size_bytes": 1066 + }, + { + "path": "viewer/data/sprites/ui/fixed_minimap_cover.png", + "sha256": "aafdcfe6835997cf2632c24be4fb0a3030274cbf60638b978f3525edb94219ed", + "size_bytes": 12474 + }, + { + "path": "viewer/data/sprites/ui/healthbar_empty_30.png", + "sha256": "9ebb6a98f5873acf92b922e0579db3c8425e85de2e6c7f984d8c16f287a4ebbf", + "size_bytes": 81 + }, + { + "path": "viewer/data/sprites/ui/healthbar_full_30.png", + "sha256": "0fcc41392687f4efa9f977341735f70b41b55a421426b8647503a66d99bf144b", + "size_bytes": 81 + }, + { + "path": "viewer/data/sprites/ui/hitsplat_damage.png", + "sha256": "30f58fc57cfc944bd0f7a35b82efaf69ec93e80d1d6c82a32ac9491ae2886aa8", + "size_bytes": 382 + }, + { + "path": "viewer/data/sprites/ui/hitsplat_heal.png", + "sha256": "b76034de81d0c5436ed20d312be9b3125066f69743d1618dffbc10f11e07cb8d", + "size_bytes": 301 + }, + { + "path": "viewer/data/sprites/ui/hitsplat_prayer_drain.png", + "sha256": "bca5b24a817214e4addb74ae3bb16480d1f6691ad342f92d4754f0fd526f6c42", + "size_bytes": 230 + }, + { + "path": "viewer/data/sprites/ui/hitsplat_zero.png", + "sha256": "5ff955546e478e26f916b5a4b6712809f16d6b1c308bccfcfbf5781e70e49a6d", + "size_bytes": 413 + }, + { + "path": "viewer/data/sprites/ui/magicoff_0.png", + "sha256": "fddc7f0ef1de6fd5ced7f58dcb70bbc8b90749616a3f54db44c1b83bf5c00f7a", + "size_bytes": 187 + }, + { + "path": "viewer/data/sprites/ui/magicoff_1.png", + "sha256": "bec9f9f257804583ccfcd1be4c672f29e1e642bfeee20596f279ffd9fb98dbf1", + "size_bytes": 288 + }, + { + "path": "viewer/data/sprites/ui/magicoff_10.png", + "sha256": "f28d2da3e9b02020d5ff4e72b17fc0ae2d0e9d0751a9ec8dfb4df1bfb37a6c72", + "size_bytes": 273 + }, + { + "path": "viewer/data/sprites/ui/magicoff_11.png", + "sha256": "c79106549222d90ab83dc06cc82b6c10e8d8145465b8b208e615a0eda6b449c8", + "size_bytes": 270 + }, + { + "path": "viewer/data/sprites/ui/magicoff_12.png", + "sha256": "4e7ce7a2f0cd13a22535a28ef82c81c046e9a38807d4d4a68dd1fc4e9e82d377", + "size_bytes": 328 + }, + { + "path": "viewer/data/sprites/ui/magicoff_13.png", + "sha256": "576f869f53935621abcf170ff67db918a900ef0a7e4a23eaaa1bf143ccd94e66", + "size_bytes": 217 + }, + { + "path": "viewer/data/sprites/ui/magicoff_14.png", + "sha256": "75af688f87184d7ae3492262467d89f91ae8f3c6bc0ae6fdea18c0aa583e2a2b", + "size_bytes": 261 + }, + { + "path": "viewer/data/sprites/ui/magicoff_15.png", + "sha256": "a9deff351f519a8c9199d5f553c99a96c59df73a3df367697fc130ad5e8fec34", + "size_bytes": 321 + }, + { + "path": "viewer/data/sprites/ui/magicoff_16.png", + "sha256": "ffe95bb26ea19ce60a77ad9d3fcd8ec3b95ad2f53e2b49aa10abd35abc69c9f2", + "size_bytes": 243 + }, + { + "path": "viewer/data/sprites/ui/magicoff_17.png", + "sha256": "3b5ddb9a72b55b23e3b5ee16b2807775293f3f4acaa2c375f13deef3e969d988", + "size_bytes": 275 + }, + { + "path": "viewer/data/sprites/ui/magicoff_18.png", + "sha256": "f25565f0d20db04c6b918ac59b8ccc37a5071970744b24818af6d8f594f64645", + "size_bytes": 334 + }, + { + "path": "viewer/data/sprites/ui/magicoff_19.png", + "sha256": "3a773b8cdd7c12efcbbc8dabea996c22ce50edb313a77dc215996e986088c87b", + "size_bytes": 308 + }, + { + "path": "viewer/data/sprites/ui/magicoff_2.png", + "sha256": "8d49aea3f34ccde410eff8bf41bd8fe31784926e7821fcbbd47e72da03921c7d", + "size_bytes": 167 + }, + { + "path": "viewer/data/sprites/ui/magicoff_20.png", + "sha256": "29d36a29b26fa214c3077a0fc2aa43184c64a63f90ea0e2fb6da066a5361572b", + "size_bytes": 237 + }, + { + "path": "viewer/data/sprites/ui/magicoff_21.png", + "sha256": "697921eb9c532d5a7398824dcc7d1439d618f47ab6bf689ef70b7fda2ae5141a", + "size_bytes": 377 + }, + { + "path": "viewer/data/sprites/ui/magicoff_22.png", + "sha256": "b36870871442a713e49302f60c0fcb38d0468b2f31e335e915a9d1d4b1997c5e", + "size_bytes": 335 + }, + { + "path": "viewer/data/sprites/ui/magicoff_23.png", + "sha256": "3a7432c3980708023884befb738b5e8538a01288d12e95b08757b1a5ba4f37cf", + "size_bytes": 240 + }, + { + "path": "viewer/data/sprites/ui/magicoff_24.png", + "sha256": "7731c8f1284b685293b58683d92f57388d0b567642cd39e64d6f9c23e42f6789", + "size_bytes": 231 + }, + { + "path": "viewer/data/sprites/ui/magicoff_25.png", + "sha256": "a1b05361aa1f9b9de81711cf014ee770c0d53182f2474ce1bc1f6090217bcf31", + "size_bytes": 237 + }, + { + "path": "viewer/data/sprites/ui/magicoff_26.png", + "sha256": "19d685a8175336528336f1aac60269f1a00ace49213adf3241250283c9dbc9e8", + "size_bytes": 258 + }, + { + "path": "viewer/data/sprites/ui/magicoff_27.png", + "sha256": "bdc7931d726e178212042b162d5930033735c63d91a686f277c16655f8835156", + "size_bytes": 277 + }, + { + "path": "viewer/data/sprites/ui/magicoff_28.png", + "sha256": "3a55fd164daa05cda77f0e7794c2a2b3b7a9d75788fd50b691870422bc0235b1", + "size_bytes": 231 + }, + { + "path": "viewer/data/sprites/ui/magicoff_29.png", + "sha256": "9a8f846e7d93976e352e183d570f94fa5fcbb75f0daa975df7b2c91423edc226", + "size_bytes": 235 + }, + { + "path": "viewer/data/sprites/ui/magicoff_3.png", + "sha256": "dc40b570911281afb3c2d65f744e106ae82c41851842cf1c5e038eb552ac91c7", + "size_bytes": 237 + }, + { + "path": "viewer/data/sprites/ui/magicoff_30.png", + "sha256": "69156a839a91196c8d484409a4cc0e72899d5b3b80c17bfd693a91ad35275540", + "size_bytes": 254 + }, + { + "path": "viewer/data/sprites/ui/magicoff_31.png", + "sha256": "7b790cb553851b3aa86f3c86e5009b682cd9cf1540358e79ce8a0ccc08b0b6c1", + "size_bytes": 286 + }, + { + "path": "viewer/data/sprites/ui/magicoff_32.png", + "sha256": "d2a43f520127dbcdf2e521ba479df1d30e554261c45484edb310961290b272fa", + "size_bytes": 257 + }, + { + "path": "viewer/data/sprites/ui/magicoff_33.png", + "sha256": "45a46f2840b26ed2c150c0242f21aa5f357999e548cd9ae0093d7d8a1089cd61", + "size_bytes": 279 + }, + { + "path": "viewer/data/sprites/ui/magicoff_34.png", + "sha256": "75eb1fb1a986a37862fc95294af43776179a067e3eb1919e1b438425a1078749", + "size_bytes": 244 + }, + { + "path": "viewer/data/sprites/ui/magicoff_35.png", + "sha256": "538a50d3e6ec55729fc34e71c989de3b4b77e8101408c2ba357106f8b5566ac7", + "size_bytes": 236 + }, + { + "path": "viewer/data/sprites/ui/magicoff_36.png", + "sha256": "874c57bd90629071e9b5fa1ccd3e619a03fc1ceec0030c58d3c32bbcccc7d932", + "size_bytes": 281 + }, + { + "path": "viewer/data/sprites/ui/magicoff_37.png", + "sha256": "5714ea07bb3ca2710ec00fc3785f89a76652239015237a761a2dd57f8005e45b", + "size_bytes": 283 + }, + { + "path": "viewer/data/sprites/ui/magicoff_38.png", + "sha256": "cf1f3260cf9619d9f74d43537bd11222e31f3b77e6efe35fa0b0a8c8c9e5e1eb", + "size_bytes": 242 + }, + { + "path": "viewer/data/sprites/ui/magicoff_39.png", + "sha256": "52316f04547d8e8ec70229a9ca56bda4303d36a1095a8271720f983c85b58f2e", + "size_bytes": 332 + }, + { + "path": "viewer/data/sprites/ui/magicoff_4.png", + "sha256": "33ede854073985eace2f8a8abd78a436500d5e8756aca99e7dff954d5876ac87", + "size_bytes": 166 + }, + { + "path": "viewer/data/sprites/ui/magicoff_40.png", + "sha256": "237031e1ab13d356f31f67b29eba8b919d74e4042191fd52c928717ace0821a4", + "size_bytes": 343 + }, + { + "path": "viewer/data/sprites/ui/magicoff_41.png", + "sha256": "60d2e6d46e22e2572394406fa70df828a986b31dd6f1c0eeedfd84e1276c1c7c", + "size_bytes": 258 + }, + { + "path": "viewer/data/sprites/ui/magicoff_42.png", + "sha256": "9556f2965eb10930bb3aa8d9e1570cef447ccbc8b642f8e7e8b84c7acd90de7e", + "size_bytes": 263 + }, + { + "path": "viewer/data/sprites/ui/magicoff_43.png", + "sha256": "a2cb3f07ae5ae981d8da86bd188f72b6b145cc53774f83835bdb9165f8e53bb4", + "size_bytes": 232 + }, + { + "path": "viewer/data/sprites/ui/magicoff_44.png", + "sha256": "bcca1347499ea788f188db93fbfb369e22aabaa50261697f922438902c4f56b2", + "size_bytes": 269 + }, + { + "path": "viewer/data/sprites/ui/magicoff_45.png", + "sha256": "f3bbc6852633acb3463be680d69d711652b71f8ec33261905fc42cd889f14516", + "size_bytes": 221 + }, + { + "path": "viewer/data/sprites/ui/magicoff_46.png", + "sha256": "13cbeaea49145d0c78d05fe6db6ce5bacb1d66ff839c4cf07cf423391027ed64", + "size_bytes": 281 + }, + { + "path": "viewer/data/sprites/ui/magicoff_47.png", + "sha256": "0a272d4d5627c0c3d91ce808c774259efa0d6975b4f60d7cfa8df6aa2fa915b7", + "size_bytes": 262 + }, + { + "path": "viewer/data/sprites/ui/magicoff_48.png", + "sha256": "e45cb78038d1c2bdee3eb6c20e9ba2fbbb2a279d4b632c071cb96a1146db12e4", + "size_bytes": 313 + }, + { + "path": "viewer/data/sprites/ui/magicoff_49.png", + "sha256": "9d4fdb2d9c35cfa8cf8235b556bc19a3380bdaa2a1fec464d248c868597f661b", + "size_bytes": 308 + }, + { + "path": "viewer/data/sprites/ui/magicoff_5.png", + "sha256": "0e6cdd6441e224db61e41d629c3da8a31e54694611104dddd0eeea239e84170e", + "size_bytes": 278 + }, + { + "path": "viewer/data/sprites/ui/magicoff_6.png", + "sha256": "fa431e80677c176fa6417839fb3c29627086ba22ada6a338a39aa71c255570e4", + "size_bytes": 185 + }, + { + "path": "viewer/data/sprites/ui/magicoff_7.png", + "sha256": "710b96ecbc54086f9f40a07d620d578ab43be66a9dbd18c77c3fcbbe50c41440", + "size_bytes": 322 + }, + { + "path": "viewer/data/sprites/ui/magicoff_8.png", + "sha256": "ab8347e52b534ff972fa5712b0700b04b56bf2eec3a5cb1e54d2fd3049ec4606", + "size_bytes": 261 + }, + { + "path": "viewer/data/sprites/ui/magicoff_9.png", + "sha256": "7751928c5cbbef673ada0913f98474097dacc93eb4590b454a2d0ebc9232c2cf", + "size_bytes": 255 + }, + { + "path": "viewer/data/sprites/ui/magicon_0.png", + "sha256": "9b4e61e85ec53a897389e524b6f6a5e2df7b9278253d1024f9af635d03de065d", + "size_bytes": 194 + }, + { + "path": "viewer/data/sprites/ui/magicon_1.png", + "sha256": "f5fde5672baef4ce31f604416eb637f2cbc7489927e9ff91c785366523bf5d94", + "size_bytes": 278 + }, + { + "path": "viewer/data/sprites/ui/magicon_10.png", + "sha256": "14694d1abcccd470f4b86e933232116e3dc4e4bcf2ba4dd91307b1fd21b2397d", + "size_bytes": 327 + }, + { + "path": "viewer/data/sprites/ui/magicon_11.png", + "sha256": "0f317a31dd417ed9b0148f1328a8641909905ac550df7f8a9b9e3c00d940afd1", + "size_bytes": 309 + }, + { + "path": "viewer/data/sprites/ui/magicon_12.png", + "sha256": "af1adb242ddb9442af9acbd8ad350c2773585ccbd450f3d53d2a5107e24c36d4", + "size_bytes": 398 + }, + { + "path": "viewer/data/sprites/ui/magicon_13.png", + "sha256": "d7e167dce35cdd6678abb2f1aaa0a28cd61ba92ba108c66566608c1fb5ecd988", + "size_bytes": 239 + }, + { + "path": "viewer/data/sprites/ui/magicon_14.png", + "sha256": "ff9ef39dbeda3886f510e44901095e90636e9baee8d3e26a36263447dcdab16a", + "size_bytes": 295 + }, + { + "path": "viewer/data/sprites/ui/magicon_15.png", + "sha256": "e9e103ba11f012b690799ec3abf686560690d911b702780b9830a41918490886", + "size_bytes": 388 + }, + { + "path": "viewer/data/sprites/ui/magicon_16.png", + "sha256": "97c66f968e5316f2a001e8bf5aa0ace0622c8e19ac160887f88f0c2cce665442", + "size_bytes": 248 + }, + { + "path": "viewer/data/sprites/ui/magicon_17.png", + "sha256": "2d26e736ea9c82f2313220f1466a60053670b1d9dce6b1bd5ed4f9054f3b0485", + "size_bytes": 319 + }, + { + "path": "viewer/data/sprites/ui/magicon_18.png", + "sha256": "41785a9094dcd6808fc5f7f85e954dc96bcaa795c951b321fad5ae7389601f84", + "size_bytes": 389 + }, + { + "path": "viewer/data/sprites/ui/magicon_19.png", + "sha256": "7f0d9ceb3f826bd89bd44881db8079983ec2b15fe09d2704be66f03960c8b992", + "size_bytes": 356 + }, + { + "path": "viewer/data/sprites/ui/magicon_2.png", + "sha256": "d75047eeb3112078537ca486bf242467a459d7dd796bba2c3534a02b477aeff8", + "size_bytes": 193 + }, + { + "path": "viewer/data/sprites/ui/magicon_20.png", + "sha256": "a99954f851ab33231cd2a826627e7af0f902e6ade2a1e21546604de1db412e71", + "size_bytes": 236 + }, + { + "path": "viewer/data/sprites/ui/magicon_21.png", + "sha256": "7c9eee366138efa99387c7aa160120c5a9a2c45f1ad0cf93d2d8f3651d78b49f", + "size_bytes": 478 + }, + { + "path": "viewer/data/sprites/ui/magicon_22.png", + "sha256": "9175ec0038a8f57281fac3fddd89ca2acaaa5ca33b3ac1657c6f387e699413df", + "size_bytes": 400 + }, + { + "path": "viewer/data/sprites/ui/magicon_23.png", + "sha256": "ccdd613395044dd2c0f07c62cbe963be6e5f9607bf93fb84be165f97a2aa0657", + "size_bytes": 236 + }, + { + "path": "viewer/data/sprites/ui/magicon_24.png", + "sha256": "0963b44d876f3cfb904484c1d24e60ed6ee4c813da4e1f8e7b150b57d6b8e2b5", + "size_bytes": 245 + }, + { + "path": "viewer/data/sprites/ui/magicon_25.png", + "sha256": "28b2c9d67e3362ab9d18c15414db5ddd5def46af1e8afe2f9cd415e96e583e36", + "size_bytes": 235 + }, + { + "path": "viewer/data/sprites/ui/magicon_26.png", + "sha256": "af34808c948ee8e25de8498cace3dc1564a993657a3d167ce0fe2efa8e895b31", + "size_bytes": 306 + }, + { + "path": "viewer/data/sprites/ui/magicon_27.png", + "sha256": "b9d14c82fac6860044a21f0c60f32fb19fd0688b0aac8a6910692cfe857e5399", + "size_bytes": 329 + }, + { + "path": "viewer/data/sprites/ui/magicon_28.png", + "sha256": "dea6843a5269e660dc900924cf37f9aa7cd6419969496388a21a4ac2a9f1fd45", + "size_bytes": 229 + }, + { + "path": "viewer/data/sprites/ui/magicon_29.png", + "sha256": "db374287d863cb2448ec7a93e96ce5d59eafa6e32d4db2e6c742dbe15b0f4a56", + "size_bytes": 234 + }, + { + "path": "viewer/data/sprites/ui/magicon_3.png", + "sha256": "19f74ae3b910e3b35b4b8cd69801423d9fb8baff14414fc81aa5c21146734952", + "size_bytes": 251 + }, + { + "path": "viewer/data/sprites/ui/magicon_30.png", + "sha256": "916acdceefc906308749e24bb36c4925556e4b52e4bcecf9124aff47a9228e15", + "size_bytes": 317 + }, + { + "path": "viewer/data/sprites/ui/magicon_31.png", + "sha256": "9b1cd30a5c5a45f215e016bbe6d7461e5fe3f27f2a7e455c9612e83e9c30764f", + "size_bytes": 275 + }, + { + "path": "viewer/data/sprites/ui/magicon_32.png", + "sha256": "02c1986f4c891b736f3879623b6239f77b8ed9f9329d2f539a986e8449e88bd4", + "size_bytes": 363 + }, + { + "path": "viewer/data/sprites/ui/magicon_33.png", + "sha256": "e8230c847df824afe7ae9a467241006f52c28348e752e413612163dfbe54e079", + "size_bytes": 270 + }, + { + "path": "viewer/data/sprites/ui/magicon_34.png", + "sha256": "202f245b32b427f4ddcf4b4f13bd19a21ef402d744ed5aedfbf462df92f43d85", + "size_bytes": 325 + }, + { + "path": "viewer/data/sprites/ui/magicon_35.png", + "sha256": "841f07aafa4fcb2e892d659ccb74f0b49e7d05ec57890de4447b11786d49edc6", + "size_bytes": 252 + }, + { + "path": "viewer/data/sprites/ui/magicon_36.png", + "sha256": "3ad112dbb56c9a476308c217df441ecd58e2dfa1a7f6a70efe85533343686b2d", + "size_bytes": 273 + }, + { + "path": "viewer/data/sprites/ui/magicon_37.png", + "sha256": "a68155eae68932578e994a84cf578df30b8b2253db3a307fb55e38c47092d373", + "size_bytes": 271 + }, + { + "path": "viewer/data/sprites/ui/magicon_38.png", + "sha256": "29f6ce88ff8bce9481953b9fd93779e56d39a0a501be84a37db97914c023f5d5", + "size_bytes": 204 + }, + { + "path": "viewer/data/sprites/ui/magicon_39.png", + "sha256": "c5f379652c3d2ae946713edeadf5d1281c5bc68bb4f72d35be1c9772b06656cb", + "size_bytes": 403 + }, + { + "path": "viewer/data/sprites/ui/magicon_4.png", + "sha256": "41303dd41f1ab246d6428bc03e718d1bca3fedbfd2433aebc09ea7cb50f99988", + "size_bytes": 190 + }, + { + "path": "viewer/data/sprites/ui/magicon_40.png", + "sha256": "a23bcdedf679449f6f8b2b69aa5e378261a34364f04535d8d28cb093e0094dff", + "size_bytes": 407 + }, + { + "path": "viewer/data/sprites/ui/magicon_41.png", + "sha256": "7495831437a7ac4d641b23ecb2f5c4d6fcbff7afbc181b5f25d9c6b4f38a152f", + "size_bytes": 211 + }, + { + "path": "viewer/data/sprites/ui/magicon_42.png", + "sha256": "a0bfcd78d8216b0da35b21484824279a11049472e9712a5ce316c7043578fe40", + "size_bytes": 238 + }, + { + "path": "viewer/data/sprites/ui/magicon_43.png", + "sha256": "49a2b0f3d3e877fbf5a350268535053daf9ed72997130ddfabfc345f72c1e481", + "size_bytes": 221 + }, + { + "path": "viewer/data/sprites/ui/magicon_44.png", + "sha256": "5526e4164ad892c84cc50e0fb1901eb7ae587243ff6e6c8256ccbe9cf97e86fc", + "size_bytes": 270 + }, + { + "path": "viewer/data/sprites/ui/magicon_45.png", + "sha256": "9f2a41ae6824b39ea26f1db62a642375aa621a0841def82b7589f5a3bbd3b517", + "size_bytes": 216 + }, + { + "path": "viewer/data/sprites/ui/magicon_46.png", + "sha256": "94d0e3acfbacf0cd2aba41979e6cb09052822e9f094b19d90a2efabdd1e3a73c", + "size_bytes": 278 + }, + { + "path": "viewer/data/sprites/ui/magicon_47.png", + "sha256": "328053ed1f057f554f904d986b8df437b0658f00cef47a66920c3277542a5d38", + "size_bytes": 338 + }, + { + "path": "viewer/data/sprites/ui/magicon_48.png", + "sha256": "ea7d2e15850e1f4cf24f03e36c273f9bf911d3a7edf757d27a0797a80a971c80", + "size_bytes": 370 + }, + { + "path": "viewer/data/sprites/ui/magicon_49.png", + "sha256": "28fd3fe6ca5ca67d2a5911f7c3cff5dac8d63c53e2104199864959e8faecb01a", + "size_bytes": 320 + }, + { + "path": "viewer/data/sprites/ui/magicon_5.png", + "sha256": "4e4b3ff74db6a2086fcc0c9a139ab7b3701e33b3486e94253ab0f619d0920e7d", + "size_bytes": 280 + }, + { + "path": "viewer/data/sprites/ui/magicon_6.png", + "sha256": "0bb5a534f52efb27e370e27c45ed7856398ebe23c23965de4f414ba291cc257a", + "size_bytes": 204 + }, + { + "path": "viewer/data/sprites/ui/magicon_7.png", + "sha256": "14363e40812e643d49f42ee92fe397f94e1fa175f32ac77a5e5feffd1fac65e2", + "size_bytes": 316 + }, + { + "path": "viewer/data/sprites/ui/magicon_8.png", + "sha256": "f58689cdaf71974cafc70b747e16c116b5b58a13ce1e518ef551ac671c80f154", + "size_bytes": 304 + }, + { + "path": "viewer/data/sprites/ui/magicon_9.png", + "sha256": "6c7617cecdee814716a1fc8c5e6be37e133bb14af7c90d1b6a3719b0458bd874", + "size_bytes": 278 + }, + { + "path": "viewer/data/sprites/ui/main_stones_bottom.png", + "sha256": "e37e1bea523ad7edee350240a77cc2742a24e2452e3d5220ad1b3db8f1ff0e47", + "size_bytes": 8402 + }, + { + "path": "viewer/data/sprites/ui/mini_bottom.png", + "sha256": "e80183c57be911373e536166c91715ceed113cbc97d2ffa2326805e4408605c6", + "size_bytes": 2020 + }, + { + "path": "viewer/data/sprites/ui/mini_left.png", + "sha256": "1b901abd71f1f17747a07e93dcece163f4e8515f6d507922c23e722c3658d1fb", + "size_bytes": 3982 + }, + { + "path": "viewer/data/sprites/ui/mini_right.png", + "sha256": "afbca7c5011217388846f7a182d29ed4baeb9d36ff45cd99cba88e4aab46e1d0", + "size_bytes": 6281 + }, + { + "path": "viewer/data/sprites/ui/mini_topright.png", + "sha256": "40aa9afd64f8bfa395dad5c40432f8f9de125544bca67048616130ce6c45c2b0", + "size_bytes": 249 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_0.png", + "sha256": "d198e78818b53f82a5398c0cd6817f8321f2f9ef7d962f1b48c1e7e98d6e8145", + "size_bytes": 1722 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_1.png", + "sha256": "05409167537388f06586b3205712a78305fd09778eb4b250a11eee4457361a01", + "size_bytes": 1088 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_10.png", + "sha256": "6029883234577351094059d8544ff63f9c4950ea5c8ebfd2905f9e6c48c9b140", + "size_bytes": 283 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_11.png", + "sha256": "42283604e77843cff56004f0d5ffc9af097caa26a60379b5417080ef5de7d15e", + "size_bytes": 385 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_12.png", + "sha256": "07c243b1046dad393647d562d642016bf05b43e254eb3a3d34eed5fd7c467117", + "size_bytes": 1286 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_13.png", + "sha256": "33ab6ba9f50a0f019088da473ee49da7647e61e960eb4daaa62cb99b7a8130ae", + "size_bytes": 241 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_14.png", + "sha256": "5a09e011e9ffd18a5aaad4d135aaab978f72f41d8cf57c4bf460f2690104c425", + "size_bytes": 235 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_2.png", + "sha256": "ea0d7f9ee983d3c4e734a7b7a21d4548f1babfe5022ad981058fa05e36024ac5", + "size_bytes": 376 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_3.png", + "sha256": "91026272b4f94d30f973d65cb4f88d6f87b30cdc3211b896e44a4e1e11c70222", + "size_bytes": 293 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_4.png", + "sha256": "c95370b3d95763793585874cdffdcfe4dd9ac2a29f30647e2ccc59475416e7e9", + "size_bytes": 1280 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_5.png", + "sha256": "a494b0ac6036f2e3f3231384608a339132d1973e8c3d535d0ca7999b9fb2f94c", + "size_bytes": 1361 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_6.png", + "sha256": "7784e267ee896d5b0b438c999d35d1ddf8dd9e11697cbb8f63a1c46ccc0acb85", + "size_bytes": 1284 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_7.png", + "sha256": "08b35936c432e7a4d1c81f0ac6ee22d4fff65e3c4af510973c22e1d338898cc4", + "size_bytes": 1282 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_8.png", + "sha256": "8e451c6bb964bef5338432b6caf9917a88291cd7348b7fc10a59c5e9dda6d84c", + "size_bytes": 1207 + }, + { + "path": "viewer/data/sprites/ui/miscgraphics_9.png", + "sha256": "f58b2be51ae615412018ef40de01078e63c5bf5dfa50fdadfc44fa4d9102eddf", + "size_bytes": 1607 + }, + { + "path": "viewer/data/sprites/ui/options_icons_16.png", + "sha256": "f779b2d1f12a8750addc7d913128d02851f5e8687aa474e029077f6ce1349170", + "size_bytes": 927 + }, + { + "path": "viewer/data/sprites/ui/options_icons_18.png", + "sha256": "2e1e81faf7d9dd07bb7ca42ab50de0333b34291c0744d71f9659751179653138", + "size_bytes": 1496 + }, + { + "path": "viewer/data/sprites/ui/options_icons_28.png", + "sha256": "d35d4465457120c9d74348a3adb54cabca7cf03a54fa263f58fb3ebbd1f94a85", + "size_bytes": 1033 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_0.png", + "sha256": "baad664a40f98e308a96a5771383e3e0d23054d99a597aa56d8f2bea22b66a7a", + "size_bytes": 261 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_1.png", + "sha256": "ef4f1ed5a0a6879fc18ccbebee05084bd1637da5e9429ab167634088d2fea02a", + "size_bytes": 411 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_10.png", + "sha256": "52a8d1f201796988d75815fcc49777a93e8b7c2822ea495cdea218eaf7c39712", + "size_bytes": 546 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_11.png", + "sha256": "dc2db685a7b6c11f8f3b065c4118d458613db06ef26336a50b9e3f63256cc7d5", + "size_bytes": 732 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_12.png", + "sha256": "15b1d7b8a2babfe3ec25c36873b7803d1dc2f4e02c1caf62d81eb901adebb959", + "size_bytes": 354 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_13.png", + "sha256": "a6a79dc68f62570463a1e9a7b5ad026c17c68d70a8b9121d0496b002cec6fe4a", + "size_bytes": 503 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_14.png", + "sha256": "f408b7c473dc345b02c881320d4c48828097e80660502977de24f9a9eb866247", + "size_bytes": 476 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_2.png", + "sha256": "0da6c93d07d74677528d78127a9482f95dd2e746ca7fa7a338a937b3224a301c", + "size_bytes": 364 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_3.png", + "sha256": "fd6afd283c02aa982da3b1f298a9c69ea719faf2389f1dbc39fd9a8a6321f96b", + "size_bytes": 466 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_4.png", + "sha256": "fda5b12c7a280fa34e97453bf73ce3b804d3541e473b79950b10d9e8901f7300", + "size_bytes": 471 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_5.png", + "sha256": "e2b9c5cf89afe65feb78b0c6a597bb17f77e489bd76b0adff13b7099cdbbba40", + "size_bytes": 739 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_6.png", + "sha256": "f6921f19229190001213b551b639852c6f449620a218f34ab5c2973c8b10e7f3", + "size_bytes": 525 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_7.png", + "sha256": "0517240ba2075740b3c7762cf73f860bca756f90d5f3c2dcc51da3260f145ce1", + "size_bytes": 606 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_8.png", + "sha256": "2691ef80ffac509387610235ef65a0fbe04ab3055cb7fcf645aacefe47880020", + "size_bytes": 392 + }, + { + "path": "viewer/data/sprites/ui/orb_filler_9.png", + "sha256": "3c6f00c553c8bccf6814a0059c65fd7befadf28973588a2ea3d60ab7ede2a0a8", + "size_bytes": 632 + }, + { + "path": "viewer/data/sprites/ui/orb_frame_0.png", + "sha256": "894c738e424d6a812a00223620d514642bef04acf4d65ecc442425b0be91499e", + "size_bytes": 632 + }, + { + "path": "viewer/data/sprites/ui/orb_frame_1.png", + "sha256": "3a1ed95f7c2ed0f547db5a79cc5baec6de155825e5667740e7ec679a01304d55", + "size_bytes": 769 + }, + { + "path": "viewer/data/sprites/ui/orb_frame_2.png", + "sha256": "fb1b14eb9b29958b3ec64065e3d982ed43c154edacfd799ac065c97b0c0e2f31", + "size_bytes": 779 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_0.png", + "sha256": "9af0ff528f5907171c4e75262984c6700eadc5c492b01f9ffa90e9301801cf8c", + "size_bytes": 308 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_1.png", + "sha256": "1f2ad09a1ce17bfb0a59434330c00669e0211a3e35f7e6212c24438ccc3dcf95", + "size_bytes": 325 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_10.png", + "sha256": "3ff663c0a89508f22ad88dae0d0d08e7a7386d2f03ac1191db13f7d5b1615be1", + "size_bytes": 392 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_11.png", + "sha256": "3eb0266d7e1244024dc3d829a8ab4f51a7b781ce7b87711327c97607c1a608b4", + "size_bytes": 581 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_12.png", + "sha256": "c1e0301835af9ff4749945bde1760ee8473e89d02b980c544c3070e953fc08ae", + "size_bytes": 491 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_13.png", + "sha256": "9383c34deedd024c2db7c35fbc54821cb8035cf26de226d1b7a1cd19be3673fd", + "size_bytes": 1202 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_14.png", + "sha256": "b68b99151da3e80c0b8104cb7129a1cb2fd6af8f99b8b47ca9f05741dea986c0", + "size_bytes": 498 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_15.png", + "sha256": "68d68993b7bd13fa1a0d3b7a1e5e1b0d5dfc2cc334793a1d54d08a3431e52d2f", + "size_bytes": 1207 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_2.png", + "sha256": "b7de703a835009b12df5749cc17b6453527e34b19a9001b3945ec822f7a52713", + "size_bytes": 431 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_3.png", + "sha256": "baf5f794dc656bc61de24a0e82163a0feafe79db2ecf3a4e961c4e48202049cf", + "size_bytes": 387 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_4.png", + "sha256": "ed011a19445d400d2a20a8949785a841e2a8dd9fc80bcdc580f8e64bfbf3554c", + "size_bytes": 335 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_5.png", + "sha256": "ed67bcf8fb0cfd307df36cdfe99c47a432364d880804de3fdba839904eaeb592", + "size_bytes": 389 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_6.png", + "sha256": "f349f0bb28de4683401244b3b07fd85171a6169c7c8984d4861d1f6f3d0c9b68", + "size_bytes": 426 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_7.png", + "sha256": "103c1f098fbd69f03bc1cc96abc74fcda8de03195cd358abb5b198b55557abb1", + "size_bytes": 770 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_8.png", + "sha256": "4edb01167b1368a84de89d4e1130d38221f86b025fa61385edf813396d25ccf0", + "size_bytes": 386 + }, + { + "path": "viewer/data/sprites/ui/orb_icon_9.png", + "sha256": "80d5c1076be07c2c505e62a51fecede6dc853a1e758777d3ee5333763fdf77e7", + "size_bytes": 381 + }, + { + "path": "viewer/data/sprites/ui/osrs_stretch_mapsurround.png", + "sha256": "a7caf2600645fa1379cf5419e7ddbb236b7498f2c84177dac5ccb3894f5f3a26", + "size_bytes": 4267 + }, + { + "path": "viewer/data/sprites/ui/osrs_stretch_side_columns_0.png", + "sha256": "7f47d29e67b25748c34490f2838d5a96c9c9024831de6072e2e48a738cc9ea5c", + "size_bytes": 2949 + }, + { + "path": "viewer/data/sprites/ui/osrs_stretch_side_columns_1.png", + "sha256": "7ceddcefec5251c82c5ba505141a478262a11a4b95f20c379920c3e6b886a4c4", + "size_bytes": 2876 + }, + { + "path": "viewer/data/sprites/ui/osrs_stretch_side_topbottom_0.png", + "sha256": "7f251f366329f83c0f8227a6800b3639ddc34c782464b6d99a87ffe61d7b7c1f", + "size_bytes": 3967 + }, + { + "path": "viewer/data/sprites/ui/osrs_stretch_side_topbottom_1.png", + "sha256": "f351f8f281765e6aad5c178e4084beaf770b682afc3c39beec4e785aa581350b", + "size_bytes": 4261 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_0.png", + "sha256": "69cd0b7d6c59fe4466a383496d5bb3c38e290f98f67a89905251b14166b2e305", + "size_bytes": 353 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_1.png", + "sha256": "394260a46adf9b075c75318e07fcad1f5cabcfc90959a7f9dd7d035a189c61aa", + "size_bytes": 501 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_10.png", + "sha256": "abf9762f3dd1d90d178404b7177222a3c83894878162cea19eb1ab7892df5bd2", + "size_bytes": 544 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_11.png", + "sha256": "e0770b0293ce36da7ac5d8638d7877336838af405bb73f298a2f3e510358448f", + "size_bytes": 487 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_12.png", + "sha256": "4ee841e02ac7a5574e9cd7b0198c02da582260771888efb7185279dbd81d78d6", + "size_bytes": 369 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_13.png", + "sha256": "28e0d9f1728951e9a8b4c3de97980857fa0007447c27fad01d198157c09e35bf", + "size_bytes": 328 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_14.png", + "sha256": "b5e261f01b8e249dd80a0374ce50de10a3f5c7ea0873d14a84d6e19435dedbc8", + "size_bytes": 422 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_15.png", + "sha256": "e9fca9753667c7cd5e3b403f0781372f3f6b67df72a5d6256ede70b395b0ce00", + "size_bytes": 379 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_16.png", + "sha256": "975b530bf62bdb0c17445685479fe26d6685a8fa16dc0f00459c8cc8692e9f7c", + "size_bytes": 385 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_17.png", + "sha256": "6e9eac32fead3235f6e43d5ed97126c87aeec3c12a5227401c53259caed0269d", + "size_bytes": 400 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_18.png", + "sha256": "bc7fc5a78c2e0d33924000e110dec846a93691dc04367f9610095b7fd21513e8", + "size_bytes": 494 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_19.png", + "sha256": "43264854387585172a5b4bb48af1018e2f1f4ef8d578c6ef4549fc92368a1bbd", + "size_bytes": 454 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_2.png", + "sha256": "55461a8c8db4da420aeca4b7ec9830ebb148226a183d44a1c23517c46f1dbc0b", + "size_bytes": 454 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_20.png", + "sha256": "0931ac74eba15781096c2ebbe0c9144b0950c7322fd4cebc25cb3d69bac2060d", + "size_bytes": 522 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_21.png", + "sha256": "03a2c63543594de0e60fc7c89e7df1af865036b000d49a28001f7f1b3dded321", + "size_bytes": 486 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_22.png", + "sha256": "e6132cc2899903144c31d9777bf0a16abf5c4e9b55c6ae84b629143dc99a4984", + "size_bytes": 515 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_23.png", + "sha256": "0d84cc47adb7eafccaf9482489a1e3caee97279b5aaa60ebaf391f9961995c87", + "size_bytes": 505 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_24.png", + "sha256": "5ce38dc2d3ea2810e84fb490f56fe2cabc1ca916c346de935868b3ea2f1b2737", + "size_bytes": 812 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_3.png", + "sha256": "1f754bfaea9fbef5f4070f9c8bca05b79ef006a1f8635588471e9ca9f6a6cd0c", + "size_bytes": 387 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_4.png", + "sha256": "bffcf0465ad3a74d951cc4a10788c3dc9b54701334eaf2eb79e7be0cf4202eb8", + "size_bytes": 539 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_5.png", + "sha256": "f5af474ba32a7f90afcb1ab1af5a2f85c97b906b2f2b58912cea51b412d393ed", + "size_bytes": 479 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_6.png", + "sha256": "fb653f5958247dce3cf7745c3492819b2026fbe66a2ca016d9ffe3155b33e10f", + "size_bytes": 343 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_7.png", + "sha256": "d533aca9e4e21b241bb1bba8c1938669560dbdcbc69885c714d1d4899d7d822e", + "size_bytes": 372 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_8.png", + "sha256": "fdeca3521e53cbd77a1b9b9326d2dd681bc5efa7822dfe7567b5b381c9e29cb1", + "size_bytes": 716 + }, + { + "path": "viewer/data/sprites/ui/prayeroff_9.png", + "sha256": "569306ff124737cbbdfa89a3a361a9baa2a1bcf3bcc99e55c17a7ef38eb4745c", + "size_bytes": 386 + }, + { + "path": "viewer/data/sprites/ui/prayeron_0.png", + "sha256": "09f3ad646d136918c8b40e22c445d60fa2666331f6d3111e197b0669adffce40", + "size_bytes": 358 + }, + { + "path": "viewer/data/sprites/ui/prayeron_1.png", + "sha256": "6a0968fe460b2fe39cd71edd2b8d545b7779f4a2ff7656cdb7af1cb94f33bfc0", + "size_bytes": 540 + }, + { + "path": "viewer/data/sprites/ui/prayeron_10.png", + "sha256": "196281783110fd8c8711c6415c856d75837abd2dc462ce0d5f126e5e0173d966", + "size_bytes": 566 + }, + { + "path": "viewer/data/sprites/ui/prayeron_11.png", + "sha256": "4c4539f555b4af6b3e21379dae27163cb174c52c85d4da4052602eaca78cae1a", + "size_bytes": 544 + }, + { + "path": "viewer/data/sprites/ui/prayeron_12.png", + "sha256": "c4b4b633a52fdb09813d43efc82e695f044f611c1099d95c1af3cd0c592001cd", + "size_bytes": 398 + }, + { + "path": "viewer/data/sprites/ui/prayeron_13.png", + "sha256": "c9077e14c57d237024e38f3d6bd785d0e2fdba81a3c555461563e60b82a9c8b4", + "size_bytes": 335 + }, + { + "path": "viewer/data/sprites/ui/prayeron_14.png", + "sha256": "d660d21611c228b4ea9ac0f7bd4c8c1aeeb47c71966f3f252fd06edb820de957", + "size_bytes": 457 + }, + { + "path": "viewer/data/sprites/ui/prayeron_15.png", + "sha256": "71adf5bbcf4b23b6349931b9f508dc946d697a532ea23aecb1f342aadab5c3c8", + "size_bytes": 397 + }, + { + "path": "viewer/data/sprites/ui/prayeron_16.png", + "sha256": "146d01aef73880730ce684b220bf02c22fc6cbdd689b00362854c0c830060387", + "size_bytes": 390 + }, + { + "path": "viewer/data/sprites/ui/prayeron_17.png", + "sha256": "8c35a8cf63b438a93e22bf8edc1f7275a053dd5617831e85a487731f4f8a3200", + "size_bytes": 413 + }, + { + "path": "viewer/data/sprites/ui/prayeron_18.png", + "sha256": "80eccc2746efd4a27d922d74177ff1e176902157b8bd8e3b940fc78ed3b1cd3a", + "size_bytes": 556 + }, + { + "path": "viewer/data/sprites/ui/prayeron_19.png", + "sha256": "b5a59d85d5aea913c7126b0e5cf5b60215d1d535536b6504a3c0e74de1f81c01", + "size_bytes": 512 + }, + { + "path": "viewer/data/sprites/ui/prayeron_2.png", + "sha256": "505b40e417f176e6e6b9250b2912e7e51a4f500692f680a31e4093208255887f", + "size_bytes": 513 + }, + { + "path": "viewer/data/sprites/ui/prayeron_20.png", + "sha256": "7893e3e80101a3ed6eaeae272096bc576add90b224c8efc5ee44cb5c456ed981", + "size_bytes": 508 + }, + { + "path": "viewer/data/sprites/ui/prayeron_21.png", + "sha256": "08d22b3de8553035033f46bf96ee521b902e9b97f4e690ceb70c830862220ad0", + "size_bytes": 525 + }, + { + "path": "viewer/data/sprites/ui/prayeron_22.png", + "sha256": "2cade658668dd0b96be0194e81ab7d41a888757a4f905c2c2490a029244aae93", + "size_bytes": 577 + }, + { + "path": "viewer/data/sprites/ui/prayeron_23.png", + "sha256": "55dee13e170abb4f20010076e2ba1ba17b31d298c3b3b58e9b3f100018f940b0", + "size_bytes": 554 + }, + { + "path": "viewer/data/sprites/ui/prayeron_24.png", + "sha256": "f3a72cec7d389ea528df0663d64aeef88f260f166697f0cdc8461ef47d8d988c", + "size_bytes": 1013 + }, + { + "path": "viewer/data/sprites/ui/prayeron_3.png", + "sha256": "1ff3c8e3869243e2b78793fbc5cb9f1910865a72f059996f9b3a9fad132fea88", + "size_bytes": 383 + }, + { + "path": "viewer/data/sprites/ui/prayeron_4.png", + "sha256": "7f038de9ae018ff726fcef62a005c3e554ab2144974f92d327f51fc2b6a7d98e", + "size_bytes": 574 + }, + { + "path": "viewer/data/sprites/ui/prayeron_5.png", + "sha256": "82d44823b260cda8fc4643045dd8bce0eca0521ecd6b910b6fde61c8a5f044e0", + "size_bytes": 543 + }, + { + "path": "viewer/data/sprites/ui/prayeron_6.png", + "sha256": "0e5d136f478062a06711e784ae736ff9564afe4dd4cfa0f829fd9f68994d9cf6", + "size_bytes": 356 + }, + { + "path": "viewer/data/sprites/ui/prayeron_7.png", + "sha256": "d4f10fc5562d47396bfaaedd1e9eb041b15d51f760a5e3e97b7cdefd4192b08e", + "size_bytes": 390 + }, + { + "path": "viewer/data/sprites/ui/prayeron_8.png", + "sha256": "896740c3725c485cc9f276b7f147b7f3a0298e9d5cdb7eb871dac30a9b1fafaf", + "size_bytes": 1077 + }, + { + "path": "viewer/data/sprites/ui/prayeron_9.png", + "sha256": "129cb1fa599aa1fc4d3cb6f3ee67c82f395d1322dfcba60dc92b59fc9d3d8ce4", + "size_bytes": 394 + }, + { + "path": "viewer/data/sprites/ui/resize_compass_mask.png", + "sha256": "27098511eae0c06aee9948e5ba910e00c8bd8d90475a09156a44acd14ad29b7b", + "size_bytes": 236 + }, + { + "path": "viewer/data/sprites/ui/resize_map_mask.png", + "sha256": "1ac01df702eb612f7c7d6dc2e28040103a820c4cfa5abb00c2a332bd9defac76", + "size_bytes": 979 + }, + { + "path": "viewer/data/sprites/ui/ring_30.png", + "sha256": "7b6ff1ce81d77d9bd9c4e3ad9748703f3764f40ce7060ba66b9e7fab68e88b14", + "size_bytes": 369 + }, + { + "path": "viewer/data/sprites/ui/side_background.png", + "sha256": "6c4a24862de9220ff07d09ae467f9218c45bccd3e91752eb83ead4fd62207bac", + "size_bytes": 36249 + }, + { + "path": "viewer/data/sprites/ui/side_background_bottom.png", + "sha256": "3ed2ed614ad44773fb2fd9f3a2928d4ed280bac97235ae9df4798cbcca4788bf", + "size_bytes": 4888 + }, + { + "path": "viewer/data/sprites/ui/side_background_left1.png", + "sha256": "5c33c080d554714b02dc258f5464cc6aec98a9be7d07046ba6f07b3e66bc95bc", + "size_bytes": 3687 + }, + { + "path": "viewer/data/sprites/ui/side_background_left2.png", + "sha256": "63af41aad07ad03570dbe08afc9f5ef876f9d11b6b1bbe508e5c48429fd036df", + "size_bytes": 2627 + }, + { + "path": "viewer/data/sprites/ui/side_background_right.png", + "sha256": "4782e25f9561b9906403758a410e32659e2639cba2cf65ac80de9020a8664a5d", + "size_bytes": 4701 + }, + { + "path": "viewer/data/sprites/ui/side_background_top.png", + "sha256": "30c1ec0136ac426bff10d0b21fbb9eac5078daba84649b8863736bdef3262f77", + "size_bytes": 5304 + }, + { + "path": "viewer/data/sprites/ui/side_icon_clan.png", + "sha256": "f338d8d954fc76da1938a49fa39b7a7952af2af4fffcffcd39e2deb8268793c1", + "size_bytes": 952 + }, + { + "path": "viewer/data/sprites/ui/side_icon_combat.png", + "sha256": "c8467c31f4298320c867fb2556866ff33f356ddcdd208a6f44b4aaf11e62a55e", + "size_bytes": 697 + }, + { + "path": "viewer/data/sprites/ui/side_icon_emotes.png", + "sha256": "54d2e4b8007c01feca8de2b8f50188e02577898f42b315527927110e225b8af2", + "size_bytes": 635 + }, + { + "path": "viewer/data/sprites/ui/side_icon_equipment.png", + "sha256": "b63e9f4f4e535466f12368679f7d21cfb0ed7b43f7abb5f21f4fbf6bc3c99ad1", + "size_bytes": 744 + }, + { + "path": "viewer/data/sprites/ui/side_icon_friends.png", + "sha256": "82e02a8bcd30be71caedd63ca0eed7fc29b48b3c77f082377f17865bed228478", + "size_bytes": 585 + }, + { + "path": "viewer/data/sprites/ui/side_icon_grouping.png", + "sha256": "51a1cdfcd9a4cf8c8a77381dff93f9531e2184cd32f3073a234b8eb1cb366dd6", + "size_bytes": 888 + }, + { + "path": "viewer/data/sprites/ui/side_icon_inventory.png", + "sha256": "74bf82e04daf5250664c810ffb6947b922362fa50c9043ea54adedfbf6f72753", + "size_bytes": 821 + }, + { + "path": "viewer/data/sprites/ui/side_icon_logout.png", + "sha256": "79e99130dae8d98993dd7a985c39b9d215c5ea9044e3e1097baac193f942cb05", + "size_bytes": 711 + }, + { + "path": "viewer/data/sprites/ui/side_icon_logout_modern.png", + "sha256": "7b4cb0de859b8d55da27cecc3578729e6cec44da2d5b34a02a5404d9b2ce5bc1", + "size_bytes": 1177 + }, + { + "path": "viewer/data/sprites/ui/side_icon_magic.png", + "sha256": "911e4e87b15d0b20a3a18f801abc26a9c503e374b688229114806a7f79a89313", + "size_bytes": 822 + }, + { + "path": "viewer/data/sprites/ui/side_icon_music.png", + "sha256": "39b3b64ed53541307642ce38b4d16fbedf0682eb8c913a17befaeab297b71669", + "size_bytes": 598 + }, + { + "path": "viewer/data/sprites/ui/side_icon_options.png", + "sha256": "1657d009fe382c462d57cfb31dc5f52261632fbb4954aa0a1371f51a5977ab2f", + "size_bytes": 408 + }, + { + "path": "viewer/data/sprites/ui/side_icon_prayer.png", + "sha256": "6c34ced587f671fdc5de850e4db74551beeee3728e3eed559ab9a31a537e520a", + "size_bytes": 431 + }, + { + "path": "viewer/data/sprites/ui/side_icon_quests.png", + "sha256": "dc4416db3a052add30222a8fb4f430ab63e2da29daa04c4b4123b87f5cd58517", + "size_bytes": 666 + }, + { + "path": "viewer/data/sprites/ui/side_icon_stats.png", + "sha256": "a1fe5f71558b2b3b98394ea117a31c1b35c78943fde53ff66a00a167eefb0e14", + "size_bytes": 472 + }, + { + "path": "viewer/data/sprites/ui/side_icons_0.png", + "sha256": "c8467c31f4298320c867fb2556866ff33f356ddcdd208a6f44b4aaf11e62a55e", + "size_bytes": 697 + }, + { + "path": "viewer/data/sprites/ui/side_icons_1.png", + "sha256": "a1fe5f71558b2b3b98394ea117a31c1b35c78943fde53ff66a00a167eefb0e14", + "size_bytes": 472 + }, + { + "path": "viewer/data/sprites/ui/side_icons_10.png", + "sha256": "79e99130dae8d98993dd7a985c39b9d215c5ea9044e3e1097baac193f942cb05", + "size_bytes": 711 + }, + { + "path": "viewer/data/sprites/ui/side_icons_11.png", + "sha256": "1657d009fe382c462d57cfb31dc5f52261632fbb4954aa0a1371f51a5977ab2f", + "size_bytes": 408 + }, + { + "path": "viewer/data/sprites/ui/side_icons_12.png", + "sha256": "54d2e4b8007c01feca8de2b8f50188e02577898f42b315527927110e225b8af2", + "size_bytes": 635 + }, + { + "path": "viewer/data/sprites/ui/side_icons_13.png", + "sha256": "39b3b64ed53541307642ce38b4d16fbedf0682eb8c913a17befaeab297b71669", + "size_bytes": 598 + }, + { + "path": "viewer/data/sprites/ui/side_icons_2.png", + "sha256": "dc4416db3a052add30222a8fb4f430ab63e2da29daa04c4b4123b87f5cd58517", + "size_bytes": 666 + }, + { + "path": "viewer/data/sprites/ui/side_icons_22.png", + "sha256": "51a1cdfcd9a4cf8c8a77381dff93f9531e2184cd32f3073a234b8eb1cb366dd6", + "size_bytes": 888 + }, + { + "path": "viewer/data/sprites/ui/side_icons_3.png", + "sha256": "74bf82e04daf5250664c810ffb6947b922362fa50c9043ea54adedfbf6f72753", + "size_bytes": 821 + }, + { + "path": "viewer/data/sprites/ui/side_icons_39.png", + "sha256": "7b4cb0de859b8d55da27cecc3578729e6cec44da2d5b34a02a5404d9b2ce5bc1", + "size_bytes": 1177 + }, + { + "path": "viewer/data/sprites/ui/side_icons_4.png", + "sha256": "b63e9f4f4e535466f12368679f7d21cfb0ed7b43f7abb5f21f4fbf6bc3c99ad1", + "size_bytes": 744 + }, + { + "path": "viewer/data/sprites/ui/side_icons_5.png", + "sha256": "6c34ced587f671fdc5de850e4db74551beeee3728e3eed559ab9a31a537e520a", + "size_bytes": 431 + }, + { + "path": "viewer/data/sprites/ui/side_icons_6.png", + "sha256": "911e4e87b15d0b20a3a18f801abc26a9c503e374b688229114806a7f79a89313", + "size_bytes": 822 + }, + { + "path": "viewer/data/sprites/ui/side_icons_7.png", + "sha256": "f338d8d954fc76da1938a49fa39b7a7952af2af4fffcffcd39e2deb8268793c1", + "size_bytes": 952 + }, + { + "path": "viewer/data/sprites/ui/side_icons_8.png", + "sha256": "82e02a8bcd30be71caedd63ca0eed7fc29b48b3c77f082377f17865bed228478", + "size_bytes": 585 + }, + { + "path": "viewer/data/sprites/ui/side_stone_highlights_0.png", + "sha256": "b12ce736955190f3a46bd79c02c889c1c8ed8659e8d824786852ba7f980e9f07", + "size_bytes": 1045 + }, + { + "path": "viewer/data/sprites/ui/side_stone_highlights_1.png", + "sha256": "2eb269e5f0f62c5100343c7f1f7324ffe3bc9cea4748f8ae50975c99d29aadb5", + "size_bytes": 1048 + }, + { + "path": "viewer/data/sprites/ui/side_stone_highlights_2.png", + "sha256": "7f4c71b089f431c6d1754bcaa41fa361eeb829e1d9c790f1aa18ea9cddcbb683", + "size_bytes": 961 + }, + { + "path": "viewer/data/sprites/ui/side_stone_highlights_3.png", + "sha256": "794044fa55ca113ff9a535fd2a77afc169e7c9b90519e65a4015e8f265d42d49", + "size_bytes": 1029 + }, + { + "path": "viewer/data/sprites/ui/side_stone_highlights_4.png", + "sha256": "360da99806c1cce369579e1677d8722676268461e69b17a17058c207dc75e0c8", + "size_bytes": 1039 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_0.png", + "sha256": "c8467c31f4298320c867fb2556866ff33f356ddcdd208a6f44b4aaf11e62a55e", + "size_bytes": 697 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_1.png", + "sha256": "a1fe5f71558b2b3b98394ea117a31c1b35c78943fde53ff66a00a167eefb0e14", + "size_bytes": 472 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_10.png", + "sha256": "79e99130dae8d98993dd7a985c39b9d215c5ea9044e3e1097baac193f942cb05", + "size_bytes": 711 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_11.png", + "sha256": "1657d009fe382c462d57cfb31dc5f52261632fbb4954aa0a1371f51a5977ab2f", + "size_bytes": 408 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_12.png", + "sha256": "54d2e4b8007c01feca8de2b8f50188e02577898f42b315527927110e225b8af2", + "size_bytes": 635 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_13.png", + "sha256": "39b3b64ed53541307642ce38b4d16fbedf0682eb8c913a17befaeab297b71669", + "size_bytes": 598 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_14.png", + "sha256": "77d1e08927c6611ebc0ccd9ed49038a56dba0ec06145b4c2aa4e90e4fa374dcd", + "size_bytes": 1043 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_15.png", + "sha256": "3888d9148e1532c44ec3f917132680fc095b9c0ce3da334ff7509fb964255da5", + "size_bytes": 733 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_16.png", + "sha256": "9fb1835915d8505c5642d9debb9bf9fdc1940b857a2f2999cdbdfe68caabdabc", + "size_bytes": 742 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_2.png", + "sha256": "dc4416db3a052add30222a8fb4f430ab63e2da29daa04c4b4123b87f5cd58517", + "size_bytes": 666 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_3.png", + "sha256": "74bf82e04daf5250664c810ffb6947b922362fa50c9043ea54adedfbf6f72753", + "size_bytes": 821 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_4.png", + "sha256": "b63e9f4f4e535466f12368679f7d21cfb0ed7b43f7abb5f21f4fbf6bc3c99ad1", + "size_bytes": 744 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_5.png", + "sha256": "6c34ced587f671fdc5de850e4db74551beeee3728e3eed559ab9a31a537e520a", + "size_bytes": 431 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_6.png", + "sha256": "911e4e87b15d0b20a3a18f801abc26a9c503e374b688229114806a7f79a89313", + "size_bytes": 822 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_7.png", + "sha256": "f338d8d954fc76da1938a49fa39b7a7952af2af4fffcffcd39e2deb8268793c1", + "size_bytes": 952 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_8.png", + "sha256": "82e02a8bcd30be71caedd63ca0eed7fc29b48b3c77f082377f17865bed228478", + "size_bytes": 585 + }, + { + "path": "viewer/data/sprites/ui/sideicons_interface_9.png", + "sha256": "b2c39107506d325964a88e0dcfe70f564f9a4278cd42810019514868d2017f33", + "size_bytes": 595 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_0.png", + "sha256": "c5744d2a231c429eac580a6c68e906f1e50a2dd6bca67238ad5ebda4d49d6dcf", + "size_bytes": 316 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_1.png", + "sha256": "7cbc9737c2223c0c4ad7a3ddda2a1a86c56a05e39c16fb55d2ebf0162f07f59b", + "size_bytes": 289 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_10.png", + "sha256": "35c74779d5c0e6267b540b48738450bdb1f0b3accf42c32bda47f88132cafcb4", + "size_bytes": 343 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_11.png", + "sha256": "e5ce5c9c1efaa1664a9003200a6a1b89efae1c8f6a277b59474e0b3a1bbabe89", + "size_bytes": 311 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_12.png", + "sha256": "bfa9f25356ea830830d69cade09f7b4f7d388910b772884f2b131e89bfa0d573", + "size_bytes": 328 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_13.png", + "sha256": "d1a278f3299f09b15d0d683850b63dc05a3fdeec8bf92f19dae05eb5596ab7f7", + "size_bytes": 328 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_14.png", + "sha256": "a8b4e9c0e14a787c712746f910c177d125f6535e0b55fe2cce8c9eba0740c8b2", + "size_bytes": 426 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_15.png", + "sha256": "9fd77fa16ebd32380c77350860ba974b03980b0414662fdd8f8f1c407e1a21fb", + "size_bytes": 335 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_16.png", + "sha256": "c86bcea10e3d4b9b67c1d03cf2655d4eac7578c2fc2aef917df2c24bbd962761", + "size_bytes": 357 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_17.png", + "sha256": "060f605c4b2f3625afeec6fa329752e7d3a1e6b45065e0320aa9d9ad5f53eef6", + "size_bytes": 367 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_18.png", + "sha256": "d2ccdb1d9b1cb90de4680b4156d3b7108ff9cac678821f970f66890ff5f41edd", + "size_bytes": 457 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_19.png", + "sha256": "751fae2bac26cb6ccff86f6dcf2e0ef327b77f4e221163540c24d6678978e98d", + "size_bytes": 561 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_2.png", + "sha256": "4efb1014e9a113cfe770f4f1b928a66f3470ea31f86e9068a6a5716a664eb812", + "size_bytes": 245 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_20.png", + "sha256": "21867a26c58e07e32db691630eb9ed75cea7edf2c39464db89ea1a246a559ef7", + "size_bytes": 564 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_21.png", + "sha256": "7532f9b905e2cc267267ead6cccb3760e6d510348e02f6fae8b2fbc21a291972", + "size_bytes": 449 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_22.png", + "sha256": "5148539e73a4d7132cc31f31f988e1d14c5e93695524d32b48b92a623b22df72", + "size_bytes": 769 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_23.png", + "sha256": "4457a743916609923d167304265deeb4ae65862df37593eb9b2e5bd4559b550f", + "size_bytes": 448 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_3.png", + "sha256": "3b704c00f19af0be4c759e32fe9ed75336a2573f15926e9f064b4df3d4214c44", + "size_bytes": 440 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_4.png", + "sha256": "b7add8ec27d340178c2058727736e2b7da3d0b7eb43eac543c719ca0fabc19e5", + "size_bytes": 297 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_5.png", + "sha256": "5ce20c6dacda82c798d7642b10bece741df6360e93ae3171f8a8513925d4bc5f", + "size_bytes": 391 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_6.png", + "sha256": "f6aa3007628d249d427d25eb6713ea5ce055dc17195efbfda8fd76a927278b67", + "size_bytes": 304 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_7.png", + "sha256": "65e79b2e4e86e0e04c3e90d2f23f7542791fc1e4831fab8a94000489603c11a0", + "size_bytes": 221 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_8.png", + "sha256": "353908d824f0885451e02130840a45618343260af31475265675617ab77d5b15", + "size_bytes": 283 + }, + { + "path": "viewer/data/sprites/ui/skill_icon_9.png", + "sha256": "68dcdd85e8d0291a68d31865ea4a31366919e487b873208437ac450951036247", + "size_bytes": 167 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_0.png", + "sha256": "53097c9b978fcd5faab60da885bc2baf07d5d2d477ddc596124c258572de8853", + "size_bytes": 291 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_1.png", + "sha256": "c540f9ea3a2847f6d055f569512345a7a17d5a9bbab8b987dfc57ea678c311ba", + "size_bytes": 295 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_10.png", + "sha256": "0286e950f6bd89a0b586fcd9c7886b8b2bcd6fa9e79c2f101033da3d2b4399e2", + "size_bytes": 439 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_11.png", + "sha256": "296bb98540742bd2d2f224051f28e4a73e49a0a7b873840ef898cc7ab24e7843", + "size_bytes": 448 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_12.png", + "sha256": "c06adde5fe4482f94fc5f5acf5a92ca10ac4223640a7d5ec6f467c543426acce", + "size_bytes": 433 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_13.png", + "sha256": "dce846376076fd0baee71f1ab0546ee0826d2110139002cc8982edb13b23a7fb", + "size_bytes": 433 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_14.png", + "sha256": "88d5dfa6c51886ea1ea3bd6036feec205de7829b8d6090a1852fd8237390b24b", + "size_bytes": 427 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_15.png", + "sha256": "c70c8437451113d1ab3e502493e8cd7ccfdddc72925b330838739fb824e5680a", + "size_bytes": 426 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_16.png", + "sha256": "b4fd68d23f0b4523edcec09a08f94a39c0fcd1ca49a1cd480ae372d826ba45cf", + "size_bytes": 514 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_17.png", + "sha256": "5747a5c5860f8ce6160c8e7455735d67e0765130968a7de5ecd53dea8821db8b", + "size_bytes": 516 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_18.png", + "sha256": "b7f4625a980ca5a84057fd371ba8a86568875dae7b1c56fd7289acf7149bc711", + "size_bytes": 500 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_19.png", + "sha256": "44b8fd9642e45d21a086550b372cd8e266b0eec050439581dfdf0bc264b80d37", + "size_bytes": 512 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_2.png", + "sha256": "9b88548ae841ade4c8cbaf1dac431b172074c8e97c280684450edd41a520e82c", + "size_bytes": 243 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_20.png", + "sha256": "5a8833e735eebfbcbac8544bf6a56fabb63731e3fecbc51d115d749a2f35a0fd", + "size_bytes": 537 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_21.png", + "sha256": "1f815300b789d6810d000bef41a5b351bfbda62a07719371322f827c482000cb", + "size_bytes": 497 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_22.png", + "sha256": "3d0920ea62dc035ecee8e551a6764077567c1535e93534fca0f6a073ecb4be66", + "size_bytes": 513 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_23.png", + "sha256": "fdcf2eafa455228d3581cc9b0792769b85187e1beab9791e8c9990a9b5624f39", + "size_bytes": 510 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_24.png", + "sha256": "e708f518015ca13f32bf3c6ea977798b8f143b8756506961afde0c7672d414d5", + "size_bytes": 530 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_25.png", + "sha256": "66ac27d7648242918893ff9c7459f5a0e692b6ac802fc63322c9750c0c46ee0d", + "size_bytes": 536 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_26.png", + "sha256": "8cfb68e23d23afc0155990fcd97a0aaf86a14a39dcbb1979cc9a29daaed71b51", + "size_bytes": 512 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_27.png", + "sha256": "d9f3f187ff48f1ea2af91328899bd5b9cbb3a1dd1607a7e4f08527b070eff079", + "size_bytes": 498 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_28.png", + "sha256": "39f038ec2c0b4635d014151969421394199f104c536a6a23d4b28a2187a18392", + "size_bytes": 588 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_29.png", + "sha256": "a3ee11d4674da4dd0bae5b90684c133bf40bc37933a955fc5664d9c714925c35", + "size_bytes": 527 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_3.png", + "sha256": "45ff94b9026e5f1e4554c5e68dc0941056aeb0ce68094fe0e7a904c0c20fd23f", + "size_bytes": 298 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_30.png", + "sha256": "dffb0e946f4b04a3b2bcd427b0e014aa43f548683e7551674ad2bdac750ee0ea", + "size_bytes": 592 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_31.png", + "sha256": "f7f9d8cc771a14830030ec2c7f1ad74c0bbdfe6dd50107c1b54a3bdfab672e99", + "size_bytes": 593 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_32.png", + "sha256": "9e6d8b4e498b0f097d7624ec5927d02c94cc8073eca9165e78951a9d4f679b4b", + "size_bytes": 600 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_33.png", + "sha256": "96c376934622b109ff145af050be9bad17faad62cd2c0f5f98a7792469a1a4db", + "size_bytes": 383 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_34.png", + "sha256": "35c4baf955799e5836cf57acdd2da551effef5d31b894299626739e4b362c7e9", + "size_bytes": 360 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_35.png", + "sha256": "69b83a5c5b5349e2f0ff4db8beda723f11b59d4c4d9233c9320ab40b95053a86", + "size_bytes": 369 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_36.png", + "sha256": "b06be46db938227740028b5a9d7c66f01156a289b63e043e9c72017827674b96", + "size_bytes": 370 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_37.png", + "sha256": "c91ae669ca917d128d79981f4b989f37237349b660534d7c58672196aca64ace", + "size_bytes": 390 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_38.png", + "sha256": "72b70e2a564277c8818c4dd495e36be01817fb33ab9abbad313607f21594ffa4", + "size_bytes": 345 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_39.png", + "sha256": "85ed27a2cc6d47af86c4665b21e29d4708c3ae1dddc22fa62df3b868484b421d", + "size_bytes": 377 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_4.png", + "sha256": "9e3ee4f120136c323fb7ca688fd0fcc3e1d2f438f0f12713589a6e2d6ce58281", + "size_bytes": 386 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_40.png", + "sha256": "2f0a28c5900acf97b1b5c78fa915d244afc01170c8df61128a1f4653f262d36e", + "size_bytes": 326 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_41.png", + "sha256": "bd6bdf523e8e76bc674dfe7ff06e4c597881fa32fb3e32672b325e47dcb62f48", + "size_bytes": 459 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_42.png", + "sha256": "74033fe83d90f803ae6e6f647a9dc540fc270141628f2c503c53e41461f285b4", + "size_bytes": 367 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_43.png", + "sha256": "1ef26cfaf98a5e44a6c7b23453bef8242d94ee8a4c20eb82ca15af5e3e46b5b9", + "size_bytes": 484 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_44.png", + "sha256": "13891ee2e86b0d29850e8646040a48c9c69d752125a7c200efcbb9651765ed30", + "size_bytes": 465 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_45.png", + "sha256": "a2f6d4580edf21d711eb5805fbb579dd1afbac520c37f1f713a59628bf6fc5f9", + "size_bytes": 512 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_46.png", + "sha256": "da2c2e77a7ded6927836a85240b70d1bf5db625685f792945232bd113c293db4", + "size_bytes": 382 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_47.png", + "sha256": "0c11a490d71b1710e9ac27aafbef6f13f2ca937ed9364a891c84f9eb3f7d79dc", + "size_bytes": 479 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_48.png", + "sha256": "4a9a9d02ae9e13600e5223297cf1d428280f40d5ab3c500deca64154557558f9", + "size_bytes": 423 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_49.png", + "sha256": "61cbe61031ef8b4932266700d082ad2a337762c9cd5bd38b4a2f758d6897f321", + "size_bytes": 382 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_5.png", + "sha256": "afb5d70ce53abd6f9f2092548c289574fe6637f2fcd05e225e0679fd8b67e0f5", + "size_bytes": 429 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_50.png", + "sha256": "a16736e9f115d04a94fd82ed42dae2fd1f0b8a13153e8f4b7c077601729726af", + "size_bytes": 481 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_51.png", + "sha256": "044cf313da813fe4c4f7ab8ab7ca9712a0c3f913b629159705e3ae258bbd5578", + "size_bytes": 425 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_52.png", + "sha256": "f0e48f81897b336ec0cd9a25c7bcf154fb89b186bc57725799ad4a8b28ed6a25", + "size_bytes": 439 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_53.png", + "sha256": "23fb546a17747e7934a7e49d594b9502ab4af5351a3e6bae8a6e37dac1b2d2d4", + "size_bytes": 358 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_54.png", + "sha256": "7732936370a79144de5bba70c1f21d803a9d2279151efd764c8b04d485800701", + "size_bytes": 431 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_55.png", + "sha256": "3595cfdc50609cee739f982d54f881ab68c3a15899ed2e15ef55b49d41feabce", + "size_bytes": 396 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_56.png", + "sha256": "87fd5d3cb158354bbf45a12c28fdcece2aa1b704ed43c5ad2cdab50d11e475a6", + "size_bytes": 379 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_57.png", + "sha256": "ed2fdd751c929c77eeffb03d1c06c0ccd9eacf070709f7fb0b81078acac1e4e4", + "size_bytes": 380 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_58.png", + "sha256": "1aacd04251c4404364af80ce07a1f3310b35c4d2229b9b7d72d525139315209f", + "size_bytes": 376 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_59.png", + "sha256": "a92f235500114debf3953c02b803f383fbe51b74c0fc237d4a3e955adfd4d97c", + "size_bytes": 376 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_6.png", + "sha256": "f3e9283e6171a3159d6c9b02ccc4884d589ff4402e74c0f2927b1d8aa6c0f9c9", + "size_bytes": 388 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_60.png", + "sha256": "29bb6f32fd55a747bcd0efeacd7a8d111dbe6e2ca6c5e7e6e435f6701eb6c1b2", + "size_bytes": 540 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_61.png", + "sha256": "5b2420d6b6fde05484901b747c4b1feed32c3bea260683bb73c025adcdaebae6", + "size_bytes": 393 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_62.png", + "sha256": "602cbbf8b885f5b61bb861bd81b5a2e3000c8f29abf095c5e93b93c59b9e6b9a", + "size_bytes": 365 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_63.png", + "sha256": "f1f2dc7aa70c3e71e02817a02d51d25b37c5d03a6bf9dc1f60e480c85b6901b5", + "size_bytes": 384 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_64.png", + "sha256": "f142b045c905db32bb3713e7aee42751663428e6560f6f11b3f8386d47fc7638", + "size_bytes": 374 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_65.png", + "sha256": "e5a1e76b8eac5c504804e2e01769d398aaaf0609eb83da3ace88a21e44282d03", + "size_bytes": 602 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_66.png", + "sha256": "91c59747b7168308767d373d23bfd61febee603f2410d7513c062afa7fd3829e", + "size_bytes": 404 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_67.png", + "sha256": "6aa737c1bfd30a4f3760681558af3729c66cf919b22d0c0d0e2bd27b99be3f11", + "size_bytes": 677 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_68.png", + "sha256": "54bfd3e56af13b61654373085c99fe1aa38f9c3646f6f599aa5ccabca45a58ba", + "size_bytes": 618 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_69.png", + "sha256": "c2b5dfbc85ac862f84f075c55d195571486ec4325be737c1adcffdf7a3c62a0c", + "size_bytes": 608 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_7.png", + "sha256": "7fd92d702f4efbffc5209aa49e103300e754751b0b66856d6dcd87e5275d2a2b", + "size_bytes": 414 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_70.png", + "sha256": "a27b319b7d7dbc10d72cbf51031dcfb70768c7bf9d65faa36f0cc3a21ff6c95a", + "size_bytes": 502 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_71.png", + "sha256": "210cf22b5f211beed2df7cc10584fc32e470c75e5502acd0a83936303dc469c2", + "size_bytes": 331 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_72.png", + "sha256": "7635494f11ff057e47a8ea7b8b5ab62f9404251ed646c84f752cf57c175b0d3d", + "size_bytes": 570 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_73.png", + "sha256": "85fd6ac5207f6025b0477ee8b3d6a7ff3e862520a5c28676fdf8211f475dbb29", + "size_bytes": 578 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_74.png", + "sha256": "970639432683416a2203e2f8eff7334d8074c9fa539881a45df23d6771cdf875", + "size_bytes": 551 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_75.png", + "sha256": "5a9e9912d33ade1f1ebd890f5a637ae5028235dc9ff9eb7ca3c9b81ca24cfca2", + "size_bytes": 349 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_76.png", + "sha256": "19496d7c253ca2df9db3096784ccfcd65f27d050e84ee30592bac0383d802f21", + "size_bytes": 510 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_77.png", + "sha256": "5003bed590151e17e884e1e150a5a5bcc33c8c212f099843df6c604aafd18dfe", + "size_bytes": 593 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_78.png", + "sha256": "e1c6b567415edcebcb1a3b0a03308d274877aa0bebff6e83b22c171c53961607", + "size_bytes": 568 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_79.png", + "sha256": "eb707f8b2138b54b9540b6c158f43db9caa2652849d76552e9f2d9a3a5031919", + "size_bytes": 535 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_8.png", + "sha256": "e52a57cec8201f8136d0e38fded608993643894c4ea6908e99e8e19f5dd71fe6", + "size_bytes": 459 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_off_9.png", + "sha256": "fad24a203e134b7b6f00c0c486138a767af3b0e3f7014b8b0e648d1d283071ac", + "size_bytes": 460 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_0.png", + "sha256": "fb8f310aa6ade09222a6806adb65fb5047cb850e902090c55447d1d97f20a6da", + "size_bytes": 300 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_1.png", + "sha256": "13ddb6d8e958be0a026c6f1dbefc995734a7adb810228395b2c6a9bc55d87606", + "size_bytes": 295 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_10.png", + "sha256": "2c2718c058de4db675512839a0fd56aacc575b72fce3bad67b0af6aa6e9421a6", + "size_bytes": 452 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_11.png", + "sha256": "cea3977e166f35565fc22b7a63f2bac2f899240356fec42e4b7deaef03083446", + "size_bytes": 464 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_12.png", + "sha256": "209b1641182e63feb350c75d2dc62f9b81ee5b60777d9cc52b6c4ceaaeac9626", + "size_bytes": 455 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_13.png", + "sha256": "6c088f4bf08f5d5832d162802aad11a6cfb6ca4377ab8e7c5788a1c7dbaa549a", + "size_bytes": 461 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_14.png", + "sha256": "be3127e435265aa0c3675ca768c1196863e2b16391d6c43b1e3b8e67f8452af0", + "size_bytes": 454 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_15.png", + "sha256": "fc1e0e0b2c9dcceb6db6cdd71564206caa388b77f28cf0df441ef5eee4ce539d", + "size_bytes": 450 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_16.png", + "sha256": "09e51f405d9272d20fcc4dc2fd2500a2be6a64c7b10b5450f420420ae80ef642", + "size_bytes": 543 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_17.png", + "sha256": "87092329bcd52d36e0397f888c67305e064bc12e6ab1be26579f97a8b9d34bcd", + "size_bytes": 523 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_18.png", + "sha256": "b84ed934c4fb4b7a8123980ccdbe382990f9712a9843bdd3d4a3a021a2e93cc6", + "size_bytes": 529 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_19.png", + "sha256": "7fd50f458a0363371cb8d85367bc6104091d6d7c40a7acd7b5be33667b1494fc", + "size_bytes": 524 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_2.png", + "sha256": "53ebc757bd36530a7d38ca91ac9f95439334ee77824f573c94975018b092ce7a", + "size_bytes": 310 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_20.png", + "sha256": "825dd0734b82576353480f864e0b30242c2ea1431785e3c135e1e03db946a965", + "size_bytes": 566 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_21.png", + "sha256": "635e46090a6bc39b0193374938b1e6ec254cb03d41742d24a66738a87ae19145", + "size_bytes": 529 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_22.png", + "sha256": "f805e52fcdb7eab43570112a9f4c414b3d1c9fbe0b512744bf6133a8a2ea65ab", + "size_bytes": 537 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_23.png", + "sha256": "af41d95d9713089d69f986ad4deef6368916425e66bb4896367ff0134d2e6e7a", + "size_bytes": 552 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_24.png", + "sha256": "cbcad3e854a53ad3a506f38265200fdc47eabc3edf642347a5c35b711ef9a232", + "size_bytes": 543 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_25.png", + "sha256": "2ed65bd94e30802fab8ca0240323d1f5defd15b91f106a04a0cf7540ef8a90c3", + "size_bytes": 560 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_26.png", + "sha256": "8326554ffdabe59af725eaa2f5aa6d437551d0730c2cb1bfd440d523c40c5a61", + "size_bytes": 550 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_27.png", + "sha256": "277ae6f89113e6d203736f97c8eecf0c769d80188dac3960ad601ef058d4b84c", + "size_bytes": 525 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_28.png", + "sha256": "187c07e1deae507f9827a9eacca09561e3733e6a28861845e0538f481044aeed", + "size_bytes": 637 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_29.png", + "sha256": "6d8fdd4e45536dd6655bc2eedc4fd06527701ed8de6ea8dbf8e41391f8920265", + "size_bytes": 562 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_3.png", + "sha256": "b95779a6c622431e074e404842ef0e7de1d15bb9453a0ade7444bbc5e5f344bf", + "size_bytes": 267 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_30.png", + "sha256": "59e6ebdc5512e841f74c12bed5209e00eafa4142bdc656dd72e9d78f583d3583", + "size_bytes": 694 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_31.png", + "sha256": "0afb72e1d5020cb4d5fdb7edab33859536017b1d7a88f68fab46b5ea7fc1cc96", + "size_bytes": 699 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_32.png", + "sha256": "b8ac93818d5a7574979c5ce6ea88db26fd02755f9a2c097a53b315d1a639a9e9", + "size_bytes": 700 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_33.png", + "sha256": "ef3dccd683f5b27bba725acc47855ac4a4badf0808a8cac23c4d8df7d030a4e9", + "size_bytes": 401 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_34.png", + "sha256": "768c9c87360faf4425293b9132620bedbc68deae6229f44b42eee85af36e5f4f", + "size_bytes": 395 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_35.png", + "sha256": "286c2180ff7b3a5ff4f5f831406addab08ca14f035a4d8a18f79b9f1c083a23d", + "size_bytes": 400 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_36.png", + "sha256": "fde921e4c3a7cba633edcfbb74f15fe491047794c1ac60c05211b1beae65b5f7", + "size_bytes": 370 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_37.png", + "sha256": "1953de5ef8c863f42bac73c0120661db190c85ff9a22d78c70575adee0a6dfbe", + "size_bytes": 402 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_38.png", + "sha256": "3c4bc18e62d7dff80afe63e4096b0a34b2b8f8fdf2fe02ed13aa8be47b36bfc6", + "size_bytes": 398 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_39.png", + "sha256": "42d851864edd21795b344b47d32642eb248116b968d3456fd6f40bba089f0bc8", + "size_bytes": 395 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_4.png", + "sha256": "d1067237dc566421f03c2f4d34ef04ffad5f071192b0a3216ab1a46414b22317", + "size_bytes": 459 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_40.png", + "sha256": "e07707fc1118ee3d0445481d3b4cfcecd4ef9d6fb7c8a9a78638d1441f8c0a0b", + "size_bytes": 340 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_41.png", + "sha256": "bcdd15f5a4ccbf676068b0253a7f6d03c621bb8f19619ec11746dc2a9f65fc60", + "size_bytes": 464 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_42.png", + "sha256": "89ae83fae444e97f4ebc72e9fdda6ec9c2ba36fd7d2bc6ad74a284eab779e4b7", + "size_bytes": 366 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_43.png", + "sha256": "3d2510544d95e2f8007d2e7a26267578b81c58174ee53ad11fed2b225db9ffa9", + "size_bytes": 477 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_44.png", + "sha256": "64c370f25fce05d4b505886e3d331e4a0694126f1a202ca57e8c04a8d0536170", + "size_bytes": 561 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_45.png", + "sha256": "e68a2c225ad0009f85f5914477c0e0ed6832f1f102221af7143c0197cac5469c", + "size_bytes": 534 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_46.png", + "sha256": "4ffe0ef81446767a02dc8692bae86bf0b4fcfe49917cf2f49317d0680419411b", + "size_bytes": 387 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_47.png", + "sha256": "75276a7f56a8f362624b057b3a994bec1a7551daf6cf5992cfe4a15375db1501", + "size_bytes": 525 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_48.png", + "sha256": "1ca0bdc1423f2115d9f52c6a3e070b4e7807cfd61516591ea447d33110c459c8", + "size_bytes": 487 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_49.png", + "sha256": "d274b9f2fd54f6b8d34aef4d7675b142810f909b6d30541d7ae51a5f0207c662", + "size_bytes": 435 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_5.png", + "sha256": "48f2aead1005ed7dd3794942c03bfb860f4220f9c9d55e100a5aa1f43f9a30e4", + "size_bytes": 414 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_50.png", + "sha256": "1cb6b95a61e19751b2f7afabfdce4085f7f17d65195a03e9a2812c877fa8c90f", + "size_bytes": 484 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_51.png", + "sha256": "d0a7ad18dd947b0fb91fc3bb7d2dc1b1235f65759a778f02e664e57950b996e0", + "size_bytes": 428 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_52.png", + "sha256": "f5f6939513555d128cf1c03a99b7a8bde30b1d2b94dee23d97e6e31e49c110c2", + "size_bytes": 463 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_53.png", + "sha256": "243fbecc4f99e3117b98dd10c4433ecf7432ceb27cfed0f96be1aadaaae5f4f8", + "size_bytes": 355 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_54.png", + "sha256": "f2e746310859ad830f632e105fa39300767f954a2b7c35ccceb687bb24a61ff9", + "size_bytes": 429 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_55.png", + "sha256": "425676ab47fb5b5541385141e87d508c2cf061a341a69576c8034d0a5ce9cd29", + "size_bytes": 393 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_56.png", + "sha256": "abe81353afb9b455078dc727c880a125598ea5959f1a0f4c98eaf8097ff3f70e", + "size_bytes": 379 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_57.png", + "sha256": "e888ea3b1ab9f52bef373deff2fe4d95656baeb2c8ce50fc66ca406e6a9363d9", + "size_bytes": 375 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_58.png", + "sha256": "e178a5306e7fa0373f863e2c38aef8d32072e06db2c2c82edce69cea7373ce3e", + "size_bytes": 379 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_59.png", + "sha256": "e38b4196732692415f1b0305372e8d5e3ab3614c8277f053abfee955beb84fbc", + "size_bytes": 380 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_6.png", + "sha256": "2782e107ff535d54dbb36c635a93732857dcbf73b9edb11c715b60d0b22d473e", + "size_bytes": 491 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_60.png", + "sha256": "8ab009bacec88a9989749ac77bcf9bd2b02fbf787bb6ac6976ba3c882d8ff864", + "size_bytes": 580 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_61.png", + "sha256": "fd6f2d487857245c236f5cbace3145a542f42ad86b06bc46825a2a807261b7e8", + "size_bytes": 467 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_62.png", + "sha256": "0e7e6ba719bdf5bfbbc6435206aaf284b1ec4e0f4923730ba1b7cf18da48bc30", + "size_bytes": 470 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_63.png", + "sha256": "29a3e247e2acf20fc40d9316fad90464f03f31af95b34e6fb0b8764c735715cf", + "size_bytes": 422 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_64.png", + "sha256": "c6f9c78591451328dab528e8ea13ee5a81f6eef04bfc34591898616daa4f7f61", + "size_bytes": 419 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_65.png", + "sha256": "49b601578a4b386fcced2cec1d05c9eb54a46e97b24ee4add3e089fcd32ad0cd", + "size_bytes": 675 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_66.png", + "sha256": "2bd621d4df81e3a946bef9e67c727f6d3d4ef9584a30cccf52f857e0d18a2bb2", + "size_bytes": 387 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_67.png", + "sha256": "7dab63cb996dd096369da71a6eff1e3b57cc1ab6af26071093be7431a0165a2d", + "size_bytes": 963 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_68.png", + "sha256": "beac1f70a0f655b19391b10bef5a55aae9decc55a409126b7e0d0e1d192e2404", + "size_bytes": 672 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_69.png", + "sha256": "c91f42b3e8040a0a8d6418f8ad1c39782d5d45b509a24d0ccc63de5338bb3a2b", + "size_bytes": 846 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_7.png", + "sha256": "4009e05188fb193c3e8e20e4f43ea0e6b35c304dc1b793da8f848d5cf9e39ebb", + "size_bytes": 513 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_70.png", + "sha256": "bedb00c1660eef14a9950b3768f0ce82303c82cbe2467856edfedfdda799a961", + "size_bytes": 541 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_71.png", + "sha256": "23f1ad690ec820b570bd7a8974c0d8342c34644b3d417ae3af8f8c82c9d2c0eb", + "size_bytes": 355 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_72.png", + "sha256": "e265c8a29759c43ffad930aefe015760ca39d9ed63513d52d181621a070e1522", + "size_bytes": 608 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_73.png", + "sha256": "762e9d7669d5f51d50e396ec098f49f87ad98112b07707eeb96a6c9459077885", + "size_bytes": 612 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_74.png", + "sha256": "934a004dd071ab0a4966822d5e3431be7c71f7cb3be860ecb9de65249333b2ef", + "size_bytes": 570 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_75.png", + "sha256": "32738ed5065881cbfe27d9dd4fad9c29aa3ead0991c6e0a29bd8af7e70ff8e97", + "size_bytes": 346 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_76.png", + "sha256": "a559c171632abefc808c740bc9fbc3763120fb0e07491f02e0de080c2a4bc1d6", + "size_bytes": 584 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_77.png", + "sha256": "e8cdffb73ee3d8dc05a936a46e97ae27a7d2d0a5f91fc89b4a9789937be21a39", + "size_bytes": 799 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_78.png", + "sha256": "5d814ebf5d58c1fbe10cf4af96d77ce8c43413ff776a674fec602e3d484ed05a", + "size_bytes": 752 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_79.png", + "sha256": "e76ff473b70feebe652ce677d8771d2d2dc3577a148d74bdb77e6132e328ed76", + "size_bytes": 580 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_8.png", + "sha256": "9ad180f4fc78ae90926b81f91dfe5a357b7f575d5689dca675aa31f72296cff1", + "size_bytes": 463 + }, + { + "path": "viewer/data/sprites/ui/standard_spell_on_9.png", + "sha256": "9897c231394e6a41cc09576900152a441eb359cea84adf2b886dc90e6a794688", + "size_bytes": 381 + }, + { + "path": "viewer/data/sprites/ui/staticons2_0.png", + "sha256": "d2ccdb1d9b1cb90de4680b4156d3b7108ff9cac678821f970f66890ff5f41edd", + "size_bytes": 457 + }, + { + "path": "viewer/data/sprites/ui/staticons2_1.png", + "sha256": "751fae2bac26cb6ccff86f6dcf2e0ef327b77f4e221163540c24d6678978e98d", + "size_bytes": 561 + }, + { + "path": "viewer/data/sprites/ui/staticons2_2.png", + "sha256": "21867a26c58e07e32db691630eb9ed75cea7edf2c39464db89ea1a246a559ef7", + "size_bytes": 564 + }, + { + "path": "viewer/data/sprites/ui/staticons2_3.png", + "sha256": "6482cc2c0ea98ccece151a097d1f17e59adece10c07e89b274a141e3cc58c184", + "size_bytes": 436 + }, + { + "path": "viewer/data/sprites/ui/staticons2_4.png", + "sha256": "3bc75adb9f01cde83c2c233b5a03d64cd27951afe9982d4a39920310d8e553cd", + "size_bytes": 528 + }, + { + "path": "viewer/data/sprites/ui/staticons2_5.png", + "sha256": "7532f9b905e2cc267267ead6cccb3760e6d510348e02f6fae8b2fbc21a291972", + "size_bytes": 449 + }, + { + "path": "viewer/data/sprites/ui/staticons2_6.png", + "sha256": "5148539e73a4d7132cc31f31f988e1d14c5e93695524d32b48b92a623b22df72", + "size_bytes": 769 + }, + { + "path": "viewer/data/sprites/ui/staticons2_7.png", + "sha256": "4457a743916609923d167304265deeb4ae65862df37593eb9b2e5bd4559b550f", + "size_bytes": 448 + }, + { + "path": "viewer/data/sprites/ui/staticons_0.png", + "sha256": "c5744d2a231c429eac580a6c68e906f1e50a2dd6bca67238ad5ebda4d49d6dcf", + "size_bytes": 316 + }, + { + "path": "viewer/data/sprites/ui/staticons_1.png", + "sha256": "7cbc9737c2223c0c4ad7a3ddda2a1a86c56a05e39c16fb55d2ebf0162f07f59b", + "size_bytes": 289 + }, + { + "path": "viewer/data/sprites/ui/staticons_10.png", + "sha256": "35c74779d5c0e6267b540b48738450bdb1f0b3accf42c32bda47f88132cafcb4", + "size_bytes": 343 + }, + { + "path": "viewer/data/sprites/ui/staticons_11.png", + "sha256": "e5ce5c9c1efaa1664a9003200a6a1b89efae1c8f6a277b59474e0b3a1bbabe89", + "size_bytes": 311 + }, + { + "path": "viewer/data/sprites/ui/staticons_12.png", + "sha256": "bfa9f25356ea830830d69cade09f7b4f7d388910b772884f2b131e89bfa0d573", + "size_bytes": 328 + }, + { + "path": "viewer/data/sprites/ui/staticons_13.png", + "sha256": "d1a278f3299f09b15d0d683850b63dc05a3fdeec8bf92f19dae05eb5596ab7f7", + "size_bytes": 328 + }, + { + "path": "viewer/data/sprites/ui/staticons_14.png", + "sha256": "a8b4e9c0e14a787c712746f910c177d125f6535e0b55fe2cce8c9eba0740c8b2", + "size_bytes": 426 + }, + { + "path": "viewer/data/sprites/ui/staticons_15.png", + "sha256": "9fd77fa16ebd32380c77350860ba974b03980b0414662fdd8f8f1c407e1a21fb", + "size_bytes": 335 + }, + { + "path": "viewer/data/sprites/ui/staticons_16.png", + "sha256": "c86bcea10e3d4b9b67c1d03cf2655d4eac7578c2fc2aef917df2c24bbd962761", + "size_bytes": 357 + }, + { + "path": "viewer/data/sprites/ui/staticons_17.png", + "sha256": "060f605c4b2f3625afeec6fa329752e7d3a1e6b45065e0320aa9d9ad5f53eef6", + "size_bytes": 367 + }, + { + "path": "viewer/data/sprites/ui/staticons_2.png", + "sha256": "4efb1014e9a113cfe770f4f1b928a66f3470ea31f86e9068a6a5716a664eb812", + "size_bytes": 245 + }, + { + "path": "viewer/data/sprites/ui/staticons_3.png", + "sha256": "3b704c00f19af0be4c759e32fe9ed75336a2573f15926e9f064b4df3d4214c44", + "size_bytes": 440 + }, + { + "path": "viewer/data/sprites/ui/staticons_4.png", + "sha256": "b7add8ec27d340178c2058727736e2b7da3d0b7eb43eac543c719ca0fabc19e5", + "size_bytes": 297 + }, + { + "path": "viewer/data/sprites/ui/staticons_5.png", + "sha256": "5ce20c6dacda82c798d7642b10bece741df6360e93ae3171f8a8513925d4bc5f", + "size_bytes": 391 + }, + { + "path": "viewer/data/sprites/ui/staticons_6.png", + "sha256": "f6aa3007628d249d427d25eb6713ea5ce055dc17195efbfda8fd76a927278b67", + "size_bytes": 304 + }, + { + "path": "viewer/data/sprites/ui/staticons_7.png", + "sha256": "65e79b2e4e86e0e04c3e90d2f23f7542791fc1e4831fab8a94000489603c11a0", + "size_bytes": 221 + }, + { + "path": "viewer/data/sprites/ui/staticons_8.png", + "sha256": "353908d824f0885451e02130840a45618343260af31475265675617ab77d5b15", + "size_bytes": 283 + }, + { + "path": "viewer/data/sprites/ui/staticons_9.png", + "sha256": "68dcdd85e8d0291a68d31865ea4a31366919e487b873208437ac450951036247", + "size_bytes": 167 + }, + { + "path": "viewer/data/sprites/ui/stats_total_left.png", + "sha256": "05e61b63855f61ef4bb188a50ad531f14595e95942e7c5234452a411e3ea76dd", + "size_bytes": 206 + }, + { + "path": "viewer/data/sprites/ui/stats_total_middle.png", + "sha256": "84e5f0bb7eb5f19ba4dbc96268fdcd1b2276ba19d83256647944aaf3354f89eb", + "size_bytes": 144 + }, + { + "path": "viewer/data/sprites/ui/stats_total_right.png", + "sha256": "47100b390aebdab06fbafb42ad7a5a1bd5bbbd094a39b21ca9c8bd23340167dd", + "size_bytes": 201 + }, + { + "path": "viewer/data/sprites/ui/tli_button01_orbinfo_65x34_0.png", + "sha256": "688892cb6bac47c567ba9f3d23cd1dd85fb05a82d300cbbe9caedb766dae29db", + "size_bytes": 1496 + }, + { + "path": "viewer/data/sprites/ui/tli_button01_orbinfo_65x34_1.png", + "sha256": "16791f84b1eb6ec0499114106a59b5de6c1f68f2e7bf8c8dafdfcb327cb8e677", + "size_bytes": 1418 + }, + { + "path": "viewer/data/sprites/ui/tli_button01_orbinfo_65x34_2.png", + "sha256": "25bda5ea35ba2419a4b6d65760f7f5473f71501c9c5b4b9ec3240363b0ef81f8", + "size_bytes": 1493 + }, + { + "path": "viewer/data/sprites/ui/tradebacking_dark.png", + "sha256": "173a1936c05a3938aa0a79efdb4017dbd21f7bae643c5609663d1e7af6d05d39", + "size_bytes": 511 + }, + { + "path": "viewer/data/sprites/ui/whistle.png", + "sha256": "678923e0e266880c1d0fc4b0575675a387d8243f0f1d3f8200b666fefb49d47d", + "size_bytes": 1097 + }, + { + "path": "viewer/data/sprites/ui/worldmap_icon_0.png", + "sha256": "54be6685771b49e52e7c2891e863e55b46e52fce7253b3eb8ba4e07ccaf5f927", + "size_bytes": 1266 + }, + { + "path": "viewer/data/sprites/ui/wornicons_0.png", + "sha256": "ed7512e5426d54585c6be6f23dc5d5b9b4065f0ce00357342a1df83db91f63f5", + "size_bytes": 261 + }, + { + "path": "viewer/data/sprites/ui/wornicons_1.png", + "sha256": "6c301d33b5ce2f9b988525dc6335f4f9d6d33b5477cb40f5c1253fabde5f5f31", + "size_bytes": 237 + }, + { + "path": "viewer/data/sprites/ui/wornicons_10.png", + "sha256": "7051d87cc92d256903bf8acd9ccec695748b713f62236d5413e961cf58b6e30c", + "size_bytes": 232 + }, + { + "path": "viewer/data/sprites/ui/wornicons_11.png", + "sha256": "b0775cba09eadf2fde241b35daa28cdcd03af6d23e99e0decd32b42f13f6d220", + "size_bytes": 83 + }, + { + "path": "viewer/data/sprites/ui/wornicons_2.png", + "sha256": "c14308ab216298c35ad011324b8a9dc9a8c2a9aa00218e6feb25de939c50edd5", + "size_bytes": 193 + }, + { + "path": "viewer/data/sprites/ui/wornicons_3.png", + "sha256": "498958b5154e4ac3e2f40a247f2740fe6e8163eefe083408fe954e5f0884cd8b", + "size_bytes": 218 + }, + { + "path": "viewer/data/sprites/ui/wornicons_4.png", + "sha256": "176a342509d1567eb73931c0e340f44068709aa259ee0c059df0b2f3b178a291", + "size_bytes": 207 + }, + { + "path": "viewer/data/sprites/ui/wornicons_5.png", + "sha256": "41ba96ecd0d5a032564885de944cd9262b9161740a681b29cd8b84d1ec0e80f4", + "size_bytes": 228 + }, + { + "path": "viewer/data/sprites/ui/wornicons_6.png", + "sha256": "b695ccb8fe2794e4b93b223444112dc7b6b7aa5a2fe0cd27424b7af3e112f5d6", + "size_bytes": 290 + }, + { + "path": "viewer/data/sprites/ui/wornicons_7.png", + "sha256": "838bbd621f9d7d087144d8bf168195dbf3ae551f6e24030553fa99f9381873a1", + "size_bytes": 278 + }, + { + "path": "viewer/data/sprites/ui/wornicons_8.png", + "sha256": "bbc647ab316643930a1b299b34e11ebed0ccecc230f843d0db0b03f77019e998", + "size_bytes": 329 + }, + { + "path": "viewer/data/sprites/ui/wornicons_9.png", + "sha256": "01b96e855df5b9a9cc993f37c6420ee224583c0d156e0cb186a7ceefb9a92f80", + "size_bytes": 275 + }, + { + "path": "viewer/fc_all.anims", + "sha256": "d0f62162ff4bc9f9741583c6f0c532e196017d9d48d5f2b7075510915651c63d", + "size_bytes": 628271 + }, + { + "path": "viewer/fc_npcs.models", + "sha256": "a6e432a91b89e080303a74904b08663b0a334e61c9436c4b2f1353c100b5535e", + "size_bytes": 307950 + }, + { + "path": "viewer/fc_player.models", + "sha256": "c86451aacacd3b9f715ac1264f09003e83c6803538c5636972d7ba6b3e32cd1f", + "size_bytes": 1117473 + }, + { + "path": "viewer/fc_projectiles.models", + "sha256": "2f6ff97268aad5432223a5d9e955857b9017814db966a05377533d4271da621a", + "size_bytes": 698441 + }, + { + "path": "viewer/fc_spotanims.bin", + "sha256": "034b4357f0245f1d933978fb87c42aa670ce4fdd0f403392732611b656a254c1", + "size_bytes": 620 + }, + { + "path": "viewer/fightcaves.atlas", + "sha256": "e74a74b371865d2f08108f57cc6fd914bf7a095877ef3414aebb8c482ee55fed", + "size_bytes": 14680076 + }, + { + "path": "viewer/fightcaves.minimap.png", + "sha256": "41ff5d66f24c12332233d0e1eaf1c4f9b97e2c2dc62348c6c964801b1d766949", + "size_bytes": 4143 + }, + { + "path": "viewer/fightcaves.oanim", + "sha256": "89c3d68e523918828aff2591532b79c2bc7f76299b55301dc1c0fa15173d418c", + "size_bytes": 4852 + }, + { + "path": "viewer/fightcaves.object_anim.models", + "sha256": "0f4fb7ec73d9398997c687f816c7641cfbdedbde06b0417a7b75d866f7c54e2a", + "size_bytes": 141665 + }, + { + "path": "viewer/fightcaves.objects", + "sha256": "26656332f30f2c7c3b6b68b7dd3ccc1a0bea677deaea9626cd9e951f8bcd9366", + "size_bytes": 28576964 + }, + { + "path": "viewer/fightcaves.tanim", + "sha256": "cf09271bdd9e695089259fa87279cef516de8079aaafc6f9ba465abc5d2f8875", + "size_bytes": 364 + }, + { + "path": "viewer/fightcaves.terrain", + "sha256": "6aaffb61f6c1efca8fac74b97b8d1c0c6fe76a3c3ce6086f5fe2a26d2f7f30d5", + "size_bytes": 409636 + } + ], + "install_prefix": "viewer", + "sha256": "0871fc747270e94896069f408841be1f69d40befc8a808cf4222df24775c1e0f", + "size_bytes": 6442598, + "url": "https://github.com/jordanbailey00/fc-rl/releases/download/fight-caves-assets-v2/fight-caves-viewer-assets-v2.tar.gz" + } + }, + "release_tag": "fight-caves-assets-v2", + "schema_version": 1, + "source": { + "repository": "https://github.com/jordanbailey00/fc-rl", + "revision": "2c5b7641a3ed56f9b1bf8febea59a7f9d2f4a86f" + } +} diff --git a/tests/fight_caves.c b/tests/fight_caves.c new file mode 100644 index 0000000000..b7e67d0a28 --- /dev/null +++ b/tests/fight_caves.c @@ -0,0 +1,75 @@ +#include "simulation.h" +#include +#include +#include +#include +#include + +static void fail(const char* message) { + fprintf(stderr, "core_contract_test: %s\n", message); + exit(EXIT_FAILURE); +} + +static void check_observation(const FcState* state) { + float obs[FC_TOTAL_OBS]; + float mask[FC_ACTION_MASK_SIZE]; + fc_write_obs(state, obs); + fc_write_mask(state, mask); + for (int i = 0; i < FC_TOTAL_OBS; i++) { + if (!isfinite(obs[i])) fail("observation contains a non-finite value"); + } + for (int i = 0; i < FC_ACTION_MASK_SIZE; i++) { + if (mask[i] != 0.0f && mask[i] != 1.0f) + fail("action mask contains a value other than zero or one"); + } + int offset = 0; + for (int head = 0; head < FC_NUM_ACTION_HEADS; head++) { + int legal = 0; + for (int action = 0; action < FC_ACTION_DIMS[head]; action++) + legal += mask[offset + action] == 1.0f; + if (legal == 0) fail("an action head has no legal action"); + offset += FC_ACTION_DIMS[head]; + } +} + +int main(void) { + _Static_assert(FC_POLICY_OBS_SIZE == 286, "policy observation contract drifted"); + _Static_assert(FC_PUFFER_OBS_SIZE == 320, "Puffer observation contract drifted"); + _Static_assert(FC_PUFFER_MASK_SIZE == 34, "Puffer mask contract drifted"); + _Static_assert(FC_PUFFER_NUM_ATNS == 3, "Puffer action-head count drifted"); + + FcState first; + FcState second; + fc_init(&first); + fc_init(&second); + fc_reset(&first, 0x12345678u); + fc_reset(&second, 0x12345678u); + if (fc_state_hash(&first) != fc_state_hash(&second)) + fail("same-seed resets are not deterministic"); + check_observation(&first); + + int steps = 0; + for (; steps < 4096 && !fc_is_terminal(&first); steps++) { + int actions[FC_NUM_ACTION_HEADS] = {0}; + actions[0] = steps % FC_PUFFER_ACTION_DIMS[0]; + actions[1] = (steps / 3) % FC_PUFFER_ACTION_DIMS[1]; + actions[2] = (steps / 7) % FC_PUFFER_ACTION_DIMS[2]; + fc_step(&first, actions); + fc_step(&second, actions); + if (fc_state_hash(&first) != fc_state_hash(&second)) + fail("same-seed trajectories diverged"); + check_observation(&first); + } + if (steps == 0) fail("simulation did not advance"); + if (!fc_is_terminal(&first)) + fail("test trajectory did not exercise a terminal transition"); + + if (steps != 483 || fc_state_hash(&first) != 0xa361005cu) + fail("fixed-seed trajectory changed; review and update the contract fixture intentionally"); + + printf("core_contract_test: passed (%d steps, hash=%08x)\n", + steps, fc_state_hash(&first)); + fc_destroy(&first); + fc_destroy(&second); + return EXIT_SUCCESS; +} diff --git a/tests/fight_caves.sh b/tests/fight_caves.sh new file mode 100644 index 0000000000..dd3267ec24 --- /dev/null +++ b/tests/fight_caves.sh @@ -0,0 +1,232 @@ +#!/bin/bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PYTHON=${PYTHON:-python3} + +test_environment() ( +TEST_ROOT="$REPO_ROOT/build/fight_caves-tests" +MODE="${1:---core}" + +case "$MODE" in + --core|--puffer|--all) ;; + *) + echo "Usage: bash tests/fight_caves.sh test [--core|--puffer|--all]" >&2 + exit 2 + ;; +esac + +cd "$REPO_ROOT" +"$PYTHON" -m pytest -q \ + tests/test_fight_caves.py +"$PYTHON" ocean/fight_caves/tools.py preflight --mode core + +mkdir -p "$TEST_ROOT" + +"${CC:-clang}" -std=c11 -O2 -Wall -Wextra -Werror \ + -Iocean/fight_caves \ + tests/fight_caves.c \ + -lm -o "$TEST_ROOT/core_contract_test" +"$TEST_ROOT/core_contract_test" + +if [ "$MODE" = "--puffer" ] || [ "$MODE" = "--all" ]; then + "$PYTHON" ocean/fight_caves/tools.py preflight --mode cpu + ./build.sh fight_caves --cpu + "$PYTHON" tests/test_fight_caves.py +fi + +if [ "$MODE" = "--all" ]; then + "$PYTHON" ocean/fight_caves/tools.py preflight --mode viewer + ./build.sh fight_caves --viewer +fi + +echo "Fight Caves environment tests passed ($MODE)." +) + +validate_checkout() ( +VALIDATION_ROOT="$REPO_ROOT/build/fight_caves-acceptance" + +fail() { + echo "Fight Caves checkout validation failed: $*" >&2 + exit 1 +} + +expect_failure() { + local description=$1 + local expected=$2 + shift 2 + local log="$VALIDATION_ROOT/expected-failure.log" + if "$@" >"$log" 2>&1; then + fail "$description unexpectedly succeeded" + fi + if ! grep -F "$expected" "$log" >/dev/null; then + echo "Expected failure output:" >&2 + sed -n '1,120p' "$log" >&2 + fail "$description did not explain how it failed" + fi + echo "Expected failure passed: $description" +} + +cd "$REPO_ROOT" +mkdir -p "$VALIDATION_ROOT" +RUN_ROOT=$(mktemp -d "$VALIDATION_ROOT/run.XXXXXX") + +for variable in FC_ASSET_ROOT FC_REPO_ROOT FC_COLLISION_PATH FC_MOVEMENT_PATH FC_LOS_PATH FC_COMPILED_BACKEND_PATH FC_CHECKPOINT_ROOT; do + unset "$variable" || true +done + +expect_failure \ + "missing asset bundle" \ + "Install assets with: python3 ocean/fight_caves/tools.py setup --all" \ + "$PYTHON" ocean/fight_caves/tools.py preflight --mode core + +"$PYTHON" ocean/fight_caves/tools.py setup --all +"$PYTHON" ocean/fight_caves/tools.py setup --all --verify-only + +bash tests/fight_caves.sh test --all +./build.sh fight_caves --fast +./fight_caves >"$VALIDATION_ROOT/native-smoke.log" +grep -F "Episodes: 100" "$VALIDATION_ROOT/native-smoke.log" >/dev/null \ + || fail "standalone environment did not finish its smoke run" +mv fight_caves "$VALIDATION_ROOT/fight_caves" + +CORE_MAP="resources/fight_caves/runtime/fightcaves.collision" +mv "$CORE_MAP" "$VALIDATION_ROOT/fightcaves.collision" +expect_failure \ + "missing core map at runtime" \ + "required Fight Caves arena asset 'fightcaves.collision' is missing" \ + build/fight_caves-tests/core_contract_test +mv "$VALIDATION_ROOT/fightcaves.collision" "$CORE_MAP" + +expect_failure \ + "invalid explicit core map override" \ + "FC_COLLISION_PATH points to an unreadable" \ + env FC_COLLISION_PATH="$VALIDATION_ROOT/not-a-map" \ + build/fight_caves-tests/core_contract_test + +VIEWER_ASSET="resources/fight_caves/viewer/fightcaves.minimap.png" +mv "$VIEWER_ASSET" "$VALIDATION_ROOT/fightcaves.minimap.png" +expect_failure \ + "missing viewer asset" \ + "viewer asset bundle is invalid: missing viewer/fightcaves.minimap.png" \ + "$PYTHON" ocean/fight_caves/tools.py play --screenshot "$VALIDATION_ROOT/missing.png" +mv "$VALIDATION_ROOT/fightcaves.minimap.png" "$VIEWER_ASSET" + +"$PYTHON" -m pufferlib.pufferl train fight_caves \ + --slowly \ + --train.gpus 1 \ + --train.total-timesteps 4096 \ + --train.horizon 32 \ + --train.minibatch-size 512 \ + --train.replay-ratio 0.25 \ + --vec.total-agents 64 \ + --vec.num-buffers 1 \ + --vec.num-threads 1 \ + --checkpoint-dir "$RUN_ROOT/checkpoints" \ + --log-dir "$RUN_ROOT/logs" \ + --checkpoint-interval 1000000 \ + >"$VALIDATION_ROOT/training-smoke.log" 2>&1 + +CHECKPOINT=$(FC_VALIDATION_RUN_ROOT="$RUN_ROOT" "$PYTHON" - <<'PY' +import os +from pathlib import Path +paths = list( + (Path(os.environ["FC_VALIDATION_RUN_ROOT"]) / "checkpoints" / "fight_caves") + .glob("**/*.bin") +) +if not paths: + raise SystemExit("training smoke did not create a checkpoint") +print(max(paths, key=lambda path: path.stat().st_mtime)) +PY +) + +cp "$CHECKPOINT" "$VALIDATION_ROOT/wrong-size.bin" +truncate -s 64 "$VALIDATION_ROOT/wrong-size.bin" +expect_failure \ + "incompatible checkpoint" \ + "checkpoint rejected" \ + "$PYTHON" ocean/fight_caves/tools.py eval \ + --ckpt "$VALIDATION_ROOT/wrong-size.bin" --max-ticks 1 + +if command -v xvfb-run >/dev/null 2>&1; then + DISPLAY_PREFIX=(xvfb-run -a) +elif [ -n "${DISPLAY:-}" ]; then + DISPLAY_PREFIX=() +else + fail "viewer validation needs xvfb-run or an existing DISPLAY" +fi + +SCREENSHOT_NAME="fight-caves-acceptance.png" +"${DISPLAY_PREFIX[@]}" "$PYTHON" ocean/fight_caves/tools.py play \ + --screenshot "$SCREENSHOT_NAME" \ + >"$VALIDATION_ROOT/viewer-smoke.log" 2>&1 +test -s "$SCREENSHOT_NAME" \ + || fail "playable viewer did not create a screenshot" +mv "$SCREENSHOT_NAME" "$VALIDATION_ROOT/playable.png" + +"${DISPLAY_PREFIX[@]}" "$PYTHON" ocean/fight_caves/tools.py eval \ + --ckpt "$CHECKPOINT" --speed 10 --max-ticks 25 \ + >"$VALIDATION_ROOT/replay-smoke.log" 2>&1 +grep -F "[eval] Policy ready (CPU)" "$VALIDATION_ROOT/replay-smoke.log" >/dev/null \ + || fail "checkpoint replay did not load the policy" +grep -F "[eval] Smoke limit reached at tick 25" "$VALIDATION_ROOT/replay-smoke.log" >/dev/null \ + || fail "checkpoint replay did not advance through the viewer" + +"$PYTHON" ocean/fight_caves/tools.py setup --all --verify-only +echo "Fight Caves clean-checkout validation passed." +) + +validate_clean_clone() ( +SOURCE=${FC_CLEAN_CLONE_SOURCE:-$(git -C "$REPO_ROOT" remote get-url origin)} +REF=${FC_CLEAN_CLONE_REF:-$(git -C "$REPO_ROOT" branch --show-current)} +KEEP=${FC_CLEAN_CLONE_KEEP:-0} +SYSTEM_SITE_PACKAGES=${FC_CLEAN_CLONE_SYSTEM_SITE_PACKAGES:-0} +SKIP_PIP=${FC_CLEAN_CLONE_SKIP_PIP:-0} + +TEMP_ROOT=$(mktemp -d -t fight-caves-clean-clone-XXXXXX) +cleanup() { + if [ "$KEEP" = "1" ]; then + echo "Kept clean-clone workspace: $TEMP_ROOT" + else + rm -rf "$TEMP_ROOT" + fi +} +trap cleanup EXIT + +echo "Cloning $SOURCE at $REF into isolated workspace" +git clone --quiet --branch "$REF" --single-branch "$SOURCE" "$TEMP_ROOT/PufferLib" +cd "$TEMP_ROOT/PufferLib" + +test -z "$(git status --porcelain)" \ + || { echo "Fresh checkout is unexpectedly dirty" >&2; exit 1; } +test ! -e resources/fight_caves/runtime +test ! -e resources/fight_caves/viewer + +VENV_ARGS=() +if [ "$SYSTEM_SITE_PACKAGES" = "1" ]; then + VENV_ARGS+=(--system-site-packages) +fi +python3 -m venv "${VENV_ARGS[@]}" .venv +if [ "$SKIP_PIP" != "1" ]; then + .venv/bin/python -m pip install --upgrade pip + .venv/bin/python -m pip install torch --index-url https://download.pytorch.org/whl/cpu + .venv/bin/python -m pip install -e . --no-build-isolation + .venv/bin/python -m pip install pytest +fi + +export PYTHON="$PWD/.venv/bin/python" +bash tests/fight_caves.sh checkout + +test -z "$(git status --porcelain)" \ + || { echo "Validation changed tracked files in the clean clone" >&2; git status --short >&2; exit 1; } +echo "Fight Caves isolated clean-clone validation passed." +) + +COMMAND=${1:-test} +if [ "$#" -gt 0 ]; then shift; fi +case "$COMMAND" in + test) test_environment "$@" ;; + checkout) validate_checkout "$@" ;; + clean-clone) validate_clean_clone "$@" ;; + *) echo "Usage: bash tests/fight_caves.sh {test [--core|--puffer|--all]|checkout|clean-clone}" >&2; exit 2 ;; +esac diff --git a/tests/test_fight_caves.py b/tests/test_fight_caves.py new file mode 100644 index 0000000000..54e31db7f5 --- /dev/null +++ b/tests/test_fight_caves.py @@ -0,0 +1,324 @@ +from __future__ import annotations + +import hashlib +import importlib.util +from io import BytesIO +import json +import os +from pathlib import Path +import subprocess +import sys +import tarfile + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[1] +SETUP_DATA = REPO_ROOT / "ocean" / "fight_caves" / "tools.py" + + +def load_setup_data(): + spec = importlib.util.spec_from_file_location("fight_caves_setup_data_test", SETUP_DATA) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def sha256(data: bytes) -> str: + return hashlib.sha256(data).hexdigest() + + +def make_archive(path: Path, members: dict[str, bytes]) -> bytes: + with tarfile.open(path, "w:gz") as archive: + for name, contents in members.items(): + info = tarfile.TarInfo(name) + info.size = len(contents) + info.mode = 0o644 + archive.addfile(info, BytesIO(contents)) + return path.read_bytes() + + +def bundle_for(archive: Path, archive_data: bytes, payload: bytes) -> dict: + return { + "archive": archive.name, + "url": archive.as_uri(), + "size_bytes": len(archive_data), + "sha256": sha256(archive_data), + "install_prefix": "runtime", + "files": [ + { + "path": "runtime/test.map", + "size_bytes": len(payload), + "sha256": sha256(payload), + } + ], + } + + +def test_install_bundle_is_verified_and_transactional(tmp_path, monkeypatch): + setup_data = load_setup_data() + install_root = tmp_path / "resources" + monkeypatch.setattr(setup_data, "RESOURCE_ROOT", install_root) + + payload = b"authoritative arena data" + archive = tmp_path / "bundle.tar.gz" + archive_data = make_archive(archive, {"runtime/test.map": payload}) + bundle = bundle_for(archive, archive_data, payload) + + setup_data.install_bundle("core", bundle, force=False) + installed = install_root / "runtime" / "test.map" + assert installed.read_bytes() == payload + assert setup_data.verify_tree(install_root, bundle, exact=True) == [] + + installed.write_bytes(b"corrupt") + assert "wrong size" in setup_data.verify_tree(install_root, bundle, exact=True)[0] + + +def test_bad_archive_checksum_does_not_replace_existing_assets(tmp_path, monkeypatch): + setup_data = load_setup_data() + install_root = tmp_path / "resources" + existing = install_root / "runtime" / "test.map" + existing.parent.mkdir(parents=True) + existing.write_bytes(b"existing valid installation") + monkeypatch.setattr(setup_data, "RESOURCE_ROOT", install_root) + + payload = b"replacement" + archive = tmp_path / "bundle.tar.gz" + archive_data = make_archive(archive, {"runtime/test.map": payload}) + bundle = bundle_for(archive, archive_data, payload) + bundle["sha256"] = "0" * 64 + + with pytest.raises(setup_data.AssetError, match="checksum mismatch"): + setup_data.install_bundle("core", bundle, force=True) + assert existing.read_bytes() == b"existing valid installation" + + +def test_unsafe_archive_path_is_rejected_without_partial_install(tmp_path, monkeypatch): + setup_data = load_setup_data() + install_root = tmp_path / "resources" + monkeypatch.setattr(setup_data, "RESOURCE_ROOT", install_root) + + payload = b"map" + archive = tmp_path / "bundle.tar.gz" + archive_data = make_archive( + archive, + {"runtime/test.map": payload, "../outside": b"must not escape"}, + ) + bundle = bundle_for(archive, archive_data, payload) + + with pytest.raises(setup_data.AssetError, match="unsafe path"): + setup_data.install_bundle("core", bundle, force=True) + assert not (install_root / "runtime").exists() + assert not (tmp_path / "outside").exists() + + +def test_manifest_rejects_unsafe_and_duplicate_file_paths(tmp_path): + setup_data = load_setup_data() + base = { + "size_bytes": 1, + "sha256": "a" * 64, + } + with pytest.raises(setup_data.AssetError, match="unsafe path"): + setup_data.expected_files({"files": [{"path": "../bad", **base}]}) + with pytest.raises(setup_data.AssetError, match="duplicate file"): + setup_data.expected_files( + {"files": [{"path": "runtime/a", **base}, {"path": "runtime/a", **base}]} + ) + + +def test_download_failure_is_actionable(tmp_path): + setup_data = load_setup_data() + missing = (tmp_path / "does-not-exist.tar.gz").as_uri() + with pytest.raises(setup_data.AssetError, match="download failed"): + setup_data.download(missing, tmp_path / "download") + + +def test_preflight_reports_missing_commands_instead_of_continuing(): + preflight = REPO_ROOT / "ocean" / "fight_caves" / "tools.py" + environment = os.environ.copy() + environment["PATH"] = "" + environment.pop("CC", None) + environment.pop("CXX", None) + result = subprocess.run( + [sys.executable, str(preflight), "preflight", "--mode", "core"], + cwd=REPO_ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + assert result.returncode != 0 + assert "required command 'clang' is unavailable" in result.stderr + +MODULE_PATH = REPO_ROOT / "ocean" / "fight_caves" / "tools.py" +SPEC = importlib.util.spec_from_file_location("fight_caves_eval_contract", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +EVAL_CONTRACT = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = EVAL_CONTRACT +SPEC.loader.exec_module(EVAL_CONTRACT) + + +def test_checkpoint_format_accepts_exact_raw_weight_size(tmp_path): + checkpoint = tmp_path / "raw.bin" + checkpoint.write_bytes(b"\x00" * 64) + assert EVAL_CONTRACT.checkpoint_format(checkpoint, 64) == "raw" + + +def test_checkpoint_format_accepts_pytorch_zip_container(tmp_path): + checkpoint = tmp_path / "cpu.bin" + checkpoint.write_bytes(b"PK\x03\x04state-dictionary-placeholder") + assert EVAL_CONTRACT.checkpoint_format(checkpoint, 64) == "pytorch" + + +def test_checkpoint_format_rejects_unknown_or_missing_file(tmp_path): + checkpoint = tmp_path / "wrong.bin" + checkpoint.write_bytes(b"not a supported checkpoint") + assert EVAL_CONTRACT.checkpoint_format(checkpoint, 64) is None + assert EVAL_CONTRACT.checkpoint_format(tmp_path / "missing.bin", 64) is None + +ENV_ROOT = REPO_ROOT / "ocean" / "fight_caves" +RESOURCE_ROOT = REPO_ROOT / "resources" / "fight_caves" + + +def test_environment_uses_flat_implementation_headers(): + for name in ("simulation.h", "fight_caves.h", "assets.h", "ui.h", "render.h", + "binding.c", "fight_caves.c", "viewer.c", "tools.py", "CMakeLists.txt"): + assert (ENV_ROOT / name).is_file() + assert not (ENV_ROOT / "sources.txt").exists() + assert '#include "simulation.h"' in (ENV_ROOT / "fight_caves.h").read_text() + assert 'void fc_step(' in (ENV_ROOT / "simulation.h").read_text() + + +def test_asset_manifest_is_complete_and_pinned(): + manifest = json.loads( + (RESOURCE_ROOT / "asset_manifest.json").read_text(encoding="utf-8") + ) + assert manifest["schema_version"] == 1 + assert set(manifest["bundles"]) == {"core", "viewer"} + for name, bundle in manifest["bundles"].items(): + assert bundle["url"].startswith("https://github.com/") + assert len(bundle["sha256"]) == 64 + assert bundle["size_bytes"] > 0 + assert bundle["install_prefix"] == ("runtime" if name == "core" else "viewer") + paths = [entry["path"] for entry in bundle["files"]] + assert paths + assert len(paths) == len(set(paths)) + assert all(not Path(path).is_absolute() and ".." not in Path(path).parts for path in paths) + assert all(len(entry["sha256"]) == 64 and entry["size_bytes"] > 0 for entry in bundle["files"]) + + +def test_fight_caves_sources_do_not_reference_local_development_trees(): + forbidden = ("/home/joe", "/v38/", "pufferlib_4", "runescape-reference") + roots = ( + ENV_ROOT, + RESOURCE_ROOT, + REPO_ROOT / "config" / "fight_caves.ini", + ) + for root in roots: + files = [root] if root.is_file() else [p for p in root.rglob("*") if p.is_file()] + for path in files: + if path.suffix in {".png", ".bin", ".models", ".atlas", ".anims"}: + continue + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + continue + for value in forbidden: + assert value not in text, f"{path} contains forbidden path marker {value!r}" + +def fail(message: str) -> None: + raise AssertionError(f"puffer_contract_test: {message}") + + +def puffer_main() -> int: + """Exercise the compiled CPU interface when explicitly invoked as a script.""" + import ctypes + import numpy as np + + sys.path.insert(0, str(REPO_ROOT)) + try: + from pufferlib import _C + except ImportError as exc: + raise RuntimeError( + "Fight Caves Puffer backend is unavailable; run " + "'./build.sh fight_caves --cpu' first" + ) from exc + if getattr(_C, "env_name", None) != "fight_caves": + fail(f"backend was built for {getattr(_C, 'env_name', None)!r}") + if getattr(_C, "gpu", None) != 0: + fail("acceptance test requires the CPU backend") + + from pufferlib.pufferl import load_config + + previous_argv = sys.argv[:] + try: + sys.argv = ["puffer_contract_test"] + args = load_config("fight_caves") + finally: + sys.argv = previous_argv + args["vec"].update(total_agents=8, num_buffers=1, num_threads=1) + + vec = _C.create_vec(args, 0) + try: + if vec.obs_size != 320: + fail(f"expected 320 observations, got {vec.obs_size}") + if vec.num_atns != 3: + fail(f"expected 3 action heads, got {vec.num_atns}") + if list(vec.act_sizes) != [17, 9, 8]: + fail(f"unexpected action dimensions: {list(vec.act_sizes)}") + if vec.obs_dtype != "FloatTensor" or vec.obs_elem_size != 4: + fail( + f"unexpected observation type: {vec.obs_dtype}/{vec.obs_elem_size}" + ) + + obs_storage = (ctypes.c_float * (vec.total_agents * vec.obs_size)).from_address( + vec.obs_ptr + ) + reward_storage = (ctypes.c_float * vec.total_agents).from_address( + vec.rewards_ptr + ) + terminal_storage = (ctypes.c_float * vec.total_agents).from_address( + vec.terminals_ptr + ) + observations = np.ctypeslib.as_array(obs_storage).reshape( + vec.total_agents, vec.obs_size + ) + rewards = np.ctypeslib.as_array(reward_storage) + terminals = np.ctypeslib.as_array(terminal_storage) + + vec.reset() + if not np.isfinite(observations).all(): + fail("reset observations contain non-finite values") + mask = observations[:, -34:] + if not np.logical_or(mask == 0.0, mask == 1.0).all(): + fail("float action mask contains a value other than zero or one") + for start, stop in ((0, 17), (17, 26), (26, 34)): + if not (mask[:, start:stop].sum(axis=1) >= 1).all(): + fail("an action head has no legal action") + + actions = np.zeros((vec.total_agents, vec.num_atns), dtype=np.float32) + terminal_count = 0 + for _ in range(6000): + vec.cpu_step(actions.ctypes.data) + if not np.isfinite(observations).all(): + fail("step observations contain non-finite values") + if not np.isfinite(rewards).all(): + fail("rewards contain non-finite values") + if not np.logical_or(terminals == 0.0, terminals == 1.0).all(): + fail("terminal buffer contains a value other than zero or one") + terminal_count += int(terminals.sum()) + if terminal_count: + break + if terminal_count == 0: + fail("no terminal/autoreset boundary was observed") + finally: + vec.close() + + print(f"puffer_contract_test: passed ({terminal_count} terminal transitions)") + return 0 + +if __name__ == "__main__": + raise SystemExit(puffer_main()) From 70dcb90660ce1042ceb884bfda19eb26e2db72e6 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:30:58 -0400 Subject: [PATCH 02/14] Keep Fight Caves build tooling environment-local Restore stock PufferLib 4.0 build.sh and remove the dedicated Fight Caves workflow. Move optional viewer building and Raylib setup into tools.py; update documentation, preflight checks, and acceptance commands. Validation: 19 Python tests, core/Puffer contract tests, CPU/native/viewer builds, 100 random native episodes, a 4096-step CPU training smoke run, playable startup, and 25-tick checkpoint replay passed. CUDA was not rerun. Gameplay, configuration, and v38 are unchanged. --- .github/workflows/fight-caves.yml | 50 ------------ build.sh | 63 +++------------ ocean/fight_caves/CMakeLists.txt | 2 +- ocean/fight_caves/README.md | 31 ++++++-- ocean/fight_caves/tools.py | 125 ++++++++++++++++++++++++------ resources/fight_caves/README.md | 11 ++- tests/fight_caves.sh | 5 +- tests/test_fight_caves.py | 90 ++++++++++++++++++++- 8 files changed, 238 insertions(+), 139 deletions(-) delete mode 100644 .github/workflows/fight-caves.yml diff --git a/.github/workflows/fight-caves.yml b/.github/workflows/fight-caves.yml deleted file mode 100644 index 7a894fffd3..0000000000 --- a/.github/workflows/fight-caves.yml +++ /dev/null @@ -1,50 +0,0 @@ -name: fight-caves - -on: - pull_request: - paths: - - "build.sh" - - "config/fight_caves.ini" - - "ocean/fight_caves/**" - - "resources/fight_caves/**" - - "tests/test_fight_caves.py" - - "tests/fight_caves.*" - - ".github/workflows/fight-caves.yml" - workflow_dispatch: - -jobs: - clean-checkout: - runs-on: ubuntu-24.04 - timeout-minutes: 30 - env: - CC: clang - CXX: clang++ - PUFFER_OMP_LIB: -lomp - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Install native dependencies - run: | - sudo apt-get update - sudo apt-get install -y \ - clang libomp-dev libomp5 cmake \ - libgl1-mesa-dev libx11-dev libxrandr-dev libxi-dev \ - libxcursor-dev libxinerama-dev x11-utils xvfb - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install Python dependencies - run: | - python -m pip install --upgrade pip - python -m pip install torch --index-url https://download.pytorch.org/whl/cpu - python -m pip install -e . --no-build-isolation - python -m pip install pytest - - - name: Validate clean checkout - env: - PYTHON: python - run: bash tests/fight_caves.sh checkout diff --git a/build.sh b/build.sh index ec4466f66e..19261e88c6 100755 --- a/build.sh +++ b/build.sh @@ -1,12 +1,6 @@ #!/bin/bash set -e -PYTHON=${PYTHON:-python3} -if ! command -v "$PYTHON" >/dev/null 2>&1; then - echo "Error: Python interpreter '$PYTHON' was not found. Set PYTHON to a Python 3.10+ executable." >&2 - exit 1 -fi - # Usage: # ./build.sh breakout # Build _C.so with breakout statically linked # ./build.sh breakout --float # float32 precision (required for --slowly) @@ -15,12 +9,11 @@ fi # ./build.sh breakout --local # Standalone executable (debug, sanitizers) # ./build.sh breakout --fast # Standalone executable (optimized) # ./build.sh breakout --web # Emscripten web build -# ./build.sh fight_caves --viewer # Optional environment viewer # ./build.sh breakout --profile # Kernel profiling binary # ./build.sh all # Build all envs with default and --float if [ -z "$1" ]; then - echo "Usage: ./build.sh ENV_NAME [--float] [--debug] [--local|--fast|--web|--viewer|--profile|--cpu|--all]" + echo "Usage: ./build.sh ENV_NAME [--float] [--debug] [--local|--fast|--web|--profile|--cpu|--all]" exit 1 fi ENV=$1 @@ -33,29 +26,12 @@ for arg in "$@"; do --local) MODE=local ;; --fast) MODE=fast ;; --web) MODE=web ;; - --viewer) MODE=viewer ;; --profile) MODE=profile ;; --cpu) MODE=cpu; PRECISION="-DPRECISION_FLOAT" ;; *) echo "Error: unknown argument '$arg'" && exit 1 ;; esac done -# Fight Caves ships authoritative runtime/viewer data separately. Validate its -# selected build path before downloading or compiling anything so a missing -# dependency or incomplete asset bundle cannot produce a degraded environment. -if [ "$ENV" = "fight_caves" ]; then - FC_PREFLIGHT_MODE=cuda - case "${MODE:-}" in - local|fast) FC_PREFLIGHT_MODE=native ;; - cpu) FC_PREFLIGHT_MODE=cpu ;; - viewer) FC_PREFLIGHT_MODE=viewer ;; - web) FC_PREFLIGHT_MODE=web ;; - profile) FC_PREFLIGHT_MODE=cuda ;; - esac - "$PYTHON" ocean/fight_caves/tools.py preflight \ - --mode "$FC_PREFLIGHT_MODE" -fi - if [ "$ENV" = "all" ]; then FAILED="" for env_dir in ocean/*/; do @@ -78,13 +54,13 @@ fi PLATFORM="$(uname -s)" if [ "$PLATFORM" = "Linux" ]; then RAYLIB_NAME='raylib-5.5_linux_amd64' - OMP_LIB=${PUFFER_OMP_LIB:--lomp5} + OMP_LIB=-lomp5 SANITIZE_FLAGS=(-fsanitize=address,undefined,bounds,pointer-overflow,leak -fno-omit-frame-pointer) STANDALONE_LDFLAGS=(-lGL) SHARED_LDFLAGS=(-Bsymbolic-functions) else RAYLIB_NAME='raylib-5.5_macos' - OMP_LIB=${PUFFER_OMP_LIB:--lomp} + OMP_LIB=-lomp SANITIZE_FLAGS=() STANDALONE_LDFLAGS=(-framework Cocoa -framework IOKit -framework CoreVideo -framework OpenGL) SHARED_LDFLAGS=(-framework Cocoa -framework OpenGL -framework IOKit -undefined dynamic_lookup) @@ -166,21 +142,6 @@ fi OUTPUT_NAME=${OUTPUT_NAME:-$ENV} -if [ "$MODE" = "viewer" ]; then - VIEWER_SOURCE_DIR="$SRC_DIR" - VIEWER_BUILD_DIR="build/$ENV-viewer" - if [ ! -f "$VIEWER_SOURCE_DIR/CMakeLists.txt" ]; then - echo "Error: environment '$ENV' does not provide a viewer build" - exit 1 - fi - cmake -S "$VIEWER_SOURCE_DIR" -B "$VIEWER_BUILD_DIR" \ - -DCMAKE_BUILD_TYPE=Release \ - -DRAYLIB_ROOT="$(pwd)/$RAYLIB_NAME" - cmake --build "$VIEWER_BUILD_DIR" --parallel - echo "Built: $VIEWER_BUILD_DIR/fc_viewer" - exit 0 -fi - # Standalone environment build # -mavx2 enables AVX2 intrinsics (__m256, _mm256_*) which drive.h and # src/bf16.h use directly. x86_64 only — strip if porting to ARM/Apple Silicon. @@ -246,10 +207,10 @@ for dir in /usr/local/cuda/lib64 /usr/lib/x86_64-linux-gnu; do fi done if [ -z "$CUDNN_IFLAG" ]; then - CUDNN_IFLAG=$("$PYTHON" -c "import nvidia.cudnn, os; print('-I' + os.path.join(nvidia.cudnn.__path__[0], 'include'))" 2>/dev/null || echo "") + CUDNN_IFLAG=$(python -c "import nvidia.cudnn, os; print('-I' + os.path.join(nvidia.cudnn.__path__[0], 'include'))" 2>/dev/null || echo "") fi if [ -z "$CUDNN_LFLAG" ]; then - CUDNN_LFLAG=$("$PYTHON" -c "import nvidia.cudnn, os; print('-L' + os.path.join(nvidia.cudnn.__path__[0], 'lib'))" 2>/dev/null || echo "") + CUDNN_LFLAG=$(python -c "import nvidia.cudnn, os; print('-L' + os.path.join(nvidia.cudnn.__path__[0], 'lib'))" 2>/dev/null || echo "") fi # NCCL include/lib fallback (mirrors the cuDNN fallback above). @@ -263,10 +224,10 @@ for dir in /usr/lib/x86_64-linux-gnu /usr/local/cuda/lib64; do if [ -f "$dir/libnccl.so" ] || [ -f "$dir/libnccl.so.2" ]; then NCCL_LFLAG="-L$dir"; break; fi done if [ -z "$NCCL_IFLAG" ]; then - NCCL_IFLAG=$("$PYTHON" -c "import nvidia.nccl, os; print('-I' + os.path.join(nvidia.nccl.__path__[0], 'include'))" 2>/dev/null || echo "") + NCCL_IFLAG=$(python -c "import nvidia.nccl, os; print('-I' + os.path.join(nvidia.nccl.__path__[0], 'include'))" 2>/dev/null || echo "") fi if [ -z "$NCCL_LFLAG" ]; then - NCCL_LFLAG=$("$PYTHON" -c "import nvidia.nccl, os; print('-L' + os.path.join(nvidia.nccl.__path__[0], 'lib'))" 2>/dev/null || echo "") + NCCL_LFLAG=$(python -c "import nvidia.nccl, os; print('-L' + os.path.join(nvidia.nccl.__path__[0], 'lib'))" 2>/dev/null || echo "") fi WHEEL_RPATH_FLAGS=() @@ -283,10 +244,10 @@ NVCC="ccache $CUDA_HOME/bin/nvcc" CC="${CC:-$(command -v ccache >/dev/null && echo 'ccache clang' || echo 'clang')}" ARCH=${NVCC_ARCH:-native} -PYTHON_INCLUDE=$("$PYTHON" -c "import sysconfig; print(sysconfig.get_path('include'))") -PYBIND_INCLUDE=$("$PYTHON" -c "import pybind11; print(pybind11.get_include())") -NUMPY_INCLUDE=$("$PYTHON" -c "import numpy; print(numpy.get_include())") -EXT_SUFFIX=$("$PYTHON" -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))") +PYTHON_INCLUDE=$(python -c "import sysconfig; print(sysconfig.get_path('include'))") +PYBIND_INCLUDE=$(python -c "import pybind11; print(pybind11.get_include())") +NUMPY_INCLUDE=$(python -c "import numpy; print(numpy.get_include())") +EXT_SUFFIX=$(python -c "import sysconfig; print(sysconfig.get_config_var('EXT_SUFFIX'))") OUTPUT="pufferlib/_C${EXT_SUFFIX}" BINDING_SRC="$SRC_DIR/binding.c" @@ -308,8 +269,6 @@ ${CC:-clang} -c "${CLANG_OPT[@]}" $EXTRA_CFLAGS \ -fno-semantic-interposition -fvisibility=hidden \ -fPIC -fopenmp \ "$BINDING_SRC" -o "$STATIC_OBJ" -# Discard members left by an older multi-object environment build. -rm -f "$STATIC_LIB" ar rcs "$STATIC_LIB" "$STATIC_OBJ" # Brittle hack: have to extract the tensor type from the static lib to build trainer diff --git a/ocean/fight_caves/CMakeLists.txt b/ocean/fight_caves/CMakeLists.txt index 4997f8e5b2..354b094c86 100644 --- a/ocean/fight_caves/CMakeLists.txt +++ b/ocean/fight_caves/CMakeLists.txt @@ -14,7 +14,7 @@ endif() set(RAYLIB_ROOT "${RAYLIB_ROOT}" CACHE PATH "Raylib distribution root") if(NOT EXISTS "${RAYLIB_ROOT}/include/raylib.h" OR NOT EXISTS "${RAYLIB_ROOT}/lib/libraylib.a") - message(FATAL_ERROR "Raylib unavailable at ${RAYLIB_ROOT}. Run ./build.sh fight_caves --viewer") + message(FATAL_ERROR "Raylib unavailable at ${RAYLIB_ROOT}. Run python3 ocean/fight_caves/tools.py build-viewer") endif() add_executable(fc_viewer viewer.c) diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index 326d2a03d4..c4b9f6f136 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -18,7 +18,7 @@ The environment uses flat implementation headers, with no separate `src/`, - `ui.h`: OSRS interfaces, sprites, fonts, minimap and orbs. - `render.h`: actor motion, animation selection, combat effects and debug overlays. - `tools.py`: asset installation/verification, bundle creation, preflight, - playable launch and checkpoint replay. + optional viewer build, playable launch and checkpoint replay. - `CMakeLists.txt`: optional viewer build using Puffer's pinned Raylib 5.5. Acceptance tests live in the repository's `tests/` directory. The full graphical @@ -28,6 +28,8 @@ change the simulation, policy contract, or configuration. ## Requirements Python 3.10 or newer and the normal PufferLib Python dependencies are required. +Activate your Python environment first: Puffer's stock `build.sh` invokes +`python` from `PATH`, which must be the same interpreter used for training. Native builds require Clang, `ar`, and an OpenMP development runtime. The viewer also requires CMake, OpenGL development libraries, and X11 development headers on Linux. @@ -40,8 +42,10 @@ sudo apt-get install clang libomp-dev libomp5 cmake \ libxcursor-dev libxinerama-dev x11-utils xvfb ``` -The build preflight exits with a nonzero status and names any missing -dependency. It never substitutes a reduced simulator or viewer. +The environment-local preflight exits with a nonzero status and names any +missing dependency. It never substitutes a reduced simulator or viewer. +The shared `build.sh` is unchanged and does not invoke Fight Caves preflight; +run the explicit check before building a backend as shown below. ## Install assets @@ -58,6 +62,17 @@ or installation error exits nonzero without replacing an existing installation. ## Build and test +Use Puffer's standard build commands for the training backend: + +```bash +python ocean/fight_caves/tools.py preflight --mode cpu +./build.sh fight_caves --cpu +``` + +For CUDA, use `preflight --mode cuda` followed by `./build.sh fight_caves`. +For the standalone simulator, use `preflight --mode native` followed by +`./build.sh fight_caves --fast`. + Build the CPU Puffer backend and run the environment acceptance tests: ```bash @@ -73,10 +88,16 @@ bash tests/fight_caves.sh test --all Run the playable viewer through its asset-verifying launcher: ```bash -./build.sh fight_caves --viewer +python3 ocean/fight_caves/tools.py build-viewer python3 ocean/fight_caves/tools.py play ``` +`build-viewer` checks dependencies and assets, reuses Puffer's Raylib 5.5 +installation if present, or downloads the same official release into `build/`. +Use `--raylib-root /path/to/raylib` to supply an existing installation, including +on platforms without a matching prebuilt release. An incomplete installation +fails explicitly. Viewer building does not require the Puffer backend or CUDA. + The launcher verifies all required assets and checks the graphical display. The viewer retains tile clicking and route previews, OSRS click indicators, camera controls, equipment/prayer/inventory tabs, run-energy and minimap orbs, @@ -106,7 +127,7 @@ The viewer defaults to `resources/fight_caves/viewer`; arena maps default to `resources/fight_caves/runtime`. Explicit `FC_ASSET_ROOT`, `FC_REPO_ROOT`, `FC_COLLISION_PATH`, `FC_MOVEMENT_PATH` and `FC_LOS_PATH` overrides remain available. Use `python3 ocean/fight_caves/tools.py COMMAND --help` for setup, bundle, -preflight and replay options. +preflight, viewer-build and replay options. ## Clean-clone acceptance diff --git a/ocean/fight_caves/tools.py b/ocean/fight_caves/tools.py index 8b3162c14d..f44c7fd0cf 100644 --- a/ocean/fight_caves/tools.py +++ b/ocean/fight_caves/tools.py @@ -534,10 +534,8 @@ def check_openmp(errors: list[str], compiler_value: str | None, language: str) - *compiler, str(source_path), "-fopenmp", "-o", str(root / "test") ] if language == "c++": - omp_library = os.environ.get( - "PUFFER_OMP_LIB", "-lomp5" if sys.platform == "linux" else "-lomp" - ) - arguments.extend(shlex.split(omp_library)) + # Match the unmodified Puffer 4.0 build.sh link flags. + arguments.append("-lomp5" if sys.platform == "linux" else "-lomp") result = subprocess.run( arguments, text=True, @@ -603,7 +601,10 @@ def preflight_args() -> argparse.Namespace: def preflight_main() -> int: - args = preflight_args() + return run_preflight(preflight_args().mode) + + +def run_preflight(mode: str) -> int: errors: list[str] = [] if sys.version_info < (3, 10): errors.append( @@ -611,17 +612,18 @@ def preflight_main() -> int: ) compiler = command_name(os.environ.get("CC"), "clang") - if args.mode != "viewer-runtime": + if mode != "viewer-runtime": require_command(errors, compiler, "C compilation") - if args.mode in ("core", "native", "cpu", "cuda", "web"): + if mode in ("core", "native", "cpu", "cuda", "web"): verify_assets(errors, ("core",)) - elif args.mode in ("viewer", "viewer-runtime"): + elif mode in ("viewer", "viewer-runtime"): verify_assets(errors, ("core", "viewer")) - if args.mode in ("native", "cpu", "cuda"): + if mode in ("native", "cpu", "cuda"): require_command(errors, "ar", "static library creation") - if args.mode in ("cpu", "cuda"): + if mode in ("cpu", "cuda"): + require_command(errors, "python", "Puffer build.sh; activate your Python environment") cxx = command_name(os.environ.get("CXX"), "g++") require_command(errors, cxx, "C++ extension compilation") for module, purpose in ( @@ -631,21 +633,21 @@ def preflight_main() -> int: ): require_python_module(errors, module, purpose) check_openmp(errors, os.environ.get("CXX"), "c++") - if args.mode in ("native", "cpu", "cuda"): + if mode in ("native", "cpu", "cuda"): check_openmp(errors, os.environ.get("CC"), "c") - if args.mode == "native" and sys.platform == "linux": + if mode == "native" and sys.platform == "linux": check_linux_viewer_link(errors, os.environ.get("CC")) - if args.mode == "cuda": + if mode == "cuda": cuda_home = os.environ.get("CUDA_HOME") or os.environ.get("CUDA_PATH") nvcc = str(Path(cuda_home) / "bin" / "nvcc") if cuda_home else "nvcc" require_command(errors, nvcc, "CUDA backend compilation") require_command(errors, "nvidia-smi", "CUDA device validation") - if args.mode == "viewer": + if mode == "viewer": require_command(errors, "cmake", "viewer configuration") check_linux_viewer_link(errors, os.environ.get("CC")) - if args.mode == "viewer-runtime": + if mode == "viewer-runtime": check_graphical_display(errors) - if args.mode == "web": + if mode == "web": require_command(errors, "emcc", "WebAssembly compilation") if errors: @@ -660,7 +662,85 @@ def preflight_main() -> int: ) return 1 - print(f"Fight Caves {args.mode} preflight passed.") + print(f"Fight Caves {mode} preflight passed.") + return 0 + + +# Optional viewer build; the shared Puffer build.sh remains unmodified. + +RAYLIB_FILES = ("include/raylib.h", "include/raymath.h", "include/rlgl.h", + "lib/libraylib.a") + + +def require_raylib(root: Path) -> Path: + missing = [name for name in RAYLIB_FILES if not (root / name).is_file()] + if missing: + raise AssetError(f"Raylib is incomplete at {root}: missing {', '.join(missing)}. " + "Supply a complete installation with --raylib-root.") + return root + + +def viewer_raylib(explicit_root: Path | None) -> Path: + if explicit_root is not None: + return require_raylib(explicit_root.expanduser().resolve()) + if sys.platform == "linux" and os.uname().machine in ("x86_64", "amd64"): + name = "raylib-5.5_linux_amd64" + elif sys.platform == "darwin": + name = "raylib-5.5_macos" + else: + raise AssetError("No bundled Raylib 5.5 for this platform. " + "Supply a compatible build with --raylib-root.") + + # Reuse Puffer's download when available; otherwise keep this optional + # dependency under build/, without creating a partial shared installation. + shared = REPO_ROOT / name + root = REPO_ROOT / "build" / name + for existing in (shared, root): + if existing.exists(): + return require_raylib(existing) + root.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="fight-caves-raylib-", dir=root.parent) as value: + staging = Path(value) + archive_path = staging / f"{name}.tar.gz" + download(f"https://github.com/raysan5/raylib/releases/download/5.5/{name}.tar.gz", + archive_path) + with tarfile.open(archive_path, "r:gz") as archive: + # Copy only the exact headers/static library used by this viewer. + # No archive paths or links are ever extracted to the filesystem. + for relative in RAYLIB_FILES: + member = archive.getmember(f"{name}/{relative}") + if not member.isfile(): + raise AssetError(f"Raylib archive contains a non-file: {member.name}") + source = archive.extractfile(member) + if source is None: + raise AssetError(f"Raylib archive cannot read {member.name}") + target = staging / name / relative + target.parent.mkdir(parents=True, exist_ok=True) + with source, target.open("wb") as output: + shutil.copyfileobj(source, output) + require_raylib(staging / name).rename(root) + return root + + +def build_viewer_main() -> int: + parser = argparse.ArgumentParser(description="Build the optional Fight Caves viewer.") + parser.add_argument("--raylib-root", type=Path, + help="use an existing Raylib installation instead of downloading 5.5") + args = parser.parse_args() + if run_preflight("viewer") != 0: + return 1 + try: + raylib = viewer_raylib(args.raylib_root) + build = REPO_ROOT / "build" / "fight_caves-viewer" + subprocess.run(["cmake", "-S", str(ENV_ROOT), "-B", str(build), + "-DCMAKE_BUILD_TYPE=Release", f"-DRAYLIB_ROOT={raylib}"], + cwd=REPO_ROOT, check=True) + subprocess.run(["cmake", "--build", str(build), "--parallel"], + cwd=REPO_ROOT, check=True) + except (AssetError, OSError, KeyError, tarfile.TarError, subprocess.CalledProcessError) as exc: + print(f"Fight Caves viewer build failed: {exc}", file=sys.stderr) + return 1 + print(f"Built: {build / 'fc_viewer'}") return 0 @@ -1310,7 +1390,7 @@ def eval_main(): if not viewer_path: print( "Error: fc_viewer binary not found. Build with: " - "./build.sh fight_caves --viewer", + "python3 ocean/fight_caves/tools.py build-viewer", file=sys.stderr, ) sys.exit(1) @@ -1320,7 +1400,7 @@ def eval_main(): file=sys.stderr, ) print( - "Rebuild it first with: ./build.sh fight_caves --viewer", + "Rebuild it first with: python3 ocean/fight_caves/tools.py build-viewer", file=sys.stderr, ) sys.exit(1) @@ -1501,7 +1581,7 @@ def play_main() -> int: viewer = REPO_ROOT / "build/fight_caves-viewer/fc_viewer" if not viewer.is_file() or not os.access(viewer, os.X_OK): print(f"Fight Caves viewer is not built: {viewer}\n" - "Build it with: ./build.sh fight_caves --viewer", file=sys.stderr) + "Build it with: python3 ocean/fight_caves/tools.py build-viewer", file=sys.stderr) return 1 os.chdir(REPO_ROOT) os.execv(str(viewer), [str(viewer), *sys.argv[1:]]) @@ -1509,10 +1589,11 @@ def play_main() -> int: def main() -> int: commands = {"setup": setup_main, "bundle": bundle_main, - "preflight": preflight_main, "play": play_main, "eval": eval_main} + "preflight": preflight_main, "build-viewer": build_viewer_main, + "play": play_main, "eval": eval_main} if len(sys.argv) < 2 or sys.argv[1] in ("-h", "--help"): print("Usage: python3 ocean/fight_caves/tools.py " - "{setup,bundle,preflight,play,eval} [options]\n" + "{setup,bundle,preflight,build-viewer,play,eval} [options]\n" "Use COMMAND --help for command options (play forwards viewer options).") return 0 if len(sys.argv) > 1 else 2 command = sys.argv.pop(1) diff --git a/resources/fight_caves/README.md b/resources/fight_caves/README.md index 7722d531b7..0b5b5b250e 100644 --- a/resources/fight_caves/README.md +++ b/resources/fight_caves/README.md @@ -31,10 +31,13 @@ existing installation without downloading or changing it with: python3 ocean/fight_caves/tools.py setup --all --verify-only ``` -Fight Caves build, viewer-launch, and policy-replay entry points invoke this -verification automatically and fail with a nonzero exit status when required -data is absent or corrupt. They do not fall back to open arena maps or an -incomplete graphical asset set. +The `tools.py build-viewer`, `play`, and `eval` commands verify required assets +automatically and fail with a nonzero exit status when data is absent or corrupt. +Before using Puffer's unchanged `build.sh`, run +`python3 ocean/fight_caves/tools.py preflight --mode cpu` (or `cuda`/`native` for +those builds). The simulator also refuses to start when required arena maps +cannot be loaded; it does not fall back to open maps. Viewer launch never +substitutes an incomplete graphical asset set. The simulator retains the `FC_COLLISION_PATH`, `FC_MOVEMENT_PATH`, and `FC_LOS_PATH` environment-variable overrides for controlled development and diff --git a/tests/fight_caves.sh b/tests/fight_caves.sh index dd3267ec24..85dceb4abb 100644 --- a/tests/fight_caves.sh +++ b/tests/fight_caves.sh @@ -36,8 +36,7 @@ if [ "$MODE" = "--puffer" ] || [ "$MODE" = "--all" ]; then fi if [ "$MODE" = "--all" ]; then - "$PYTHON" ocean/fight_caves/tools.py preflight --mode viewer - ./build.sh fight_caves --viewer + "$PYTHON" ocean/fight_caves/tools.py build-viewer fi echo "Fight Caves environment tests passed ($MODE)." @@ -84,6 +83,7 @@ expect_failure \ "$PYTHON" ocean/fight_caves/tools.py setup --all --verify-only bash tests/fight_caves.sh test --all +"$PYTHON" ocean/fight_caves/tools.py preflight --mode native ./build.sh fight_caves --fast ./fight_caves >"$VALIDATION_ROOT/native-smoke.log" grep -F "Episodes: 100" "$VALIDATION_ROOT/native-smoke.log" >/dev/null \ @@ -215,6 +215,7 @@ if [ "$SKIP_PIP" != "1" ]; then fi export PYTHON="$PWD/.venv/bin/python" +export PATH="$PWD/.venv/bin:$PATH" bash tests/fight_caves.sh checkout test -z "$(git status --porcelain)" \ diff --git a/tests/test_fight_caves.py b/tests/test_fight_caves.py index 54e31db7f5..479dcbe01d 100644 --- a/tests/test_fight_caves.py +++ b/tests/test_fight_caves.py @@ -135,14 +135,15 @@ def test_download_failure_is_actionable(tmp_path): setup_data.download(missing, tmp_path / "download") -def test_preflight_reports_missing_commands_instead_of_continuing(): +@pytest.mark.parametrize("mode, missing", [("core", "clang"), ("cpu", "python")]) +def test_preflight_reports_missing_commands_instead_of_continuing(mode, missing): preflight = REPO_ROOT / "ocean" / "fight_caves" / "tools.py" environment = os.environ.copy() environment["PATH"] = "" environment.pop("CC", None) environment.pop("CXX", None) result = subprocess.run( - [sys.executable, str(preflight), "preflight", "--mode", "core"], + [sys.executable, str(preflight), "preflight", "--mode", mode], cwd=REPO_ROOT, env=environment, text=True, @@ -151,7 +152,90 @@ def test_preflight_reports_missing_commands_instead_of_continuing(): check=False, ) assert result.returncode != 0 - assert "required command 'clang' is unavailable" in result.stderr + assert f"required command '{missing}' is unavailable" in result.stderr + +def test_viewer_build_stops_before_download_when_preflight_fails(monkeypatch): + tools = load_setup_data() + monkeypatch.setattr(sys, "argv", [str(SETUP_DATA)]) + monkeypatch.setattr(tools, "run_preflight", lambda mode: 1) + monkeypatch.setattr(tools, "viewer_raylib", lambda root: pytest.fail("unexpected download")) + assert tools.build_viewer_main() == 1 + + +def test_viewer_build_uses_local_cmake_not_shared_build(tmp_path, monkeypatch): + tools = load_setup_data() + monkeypatch.setattr(sys, "argv", [str(SETUP_DATA)]) + monkeypatch.setattr(tools, "REPO_ROOT", tmp_path) + monkeypatch.setattr(tools, "ENV_ROOT", tmp_path / "ocean" / "fight_caves") + monkeypatch.setattr(tools, "run_preflight", lambda mode: 0) + monkeypatch.setattr(tools, "viewer_raylib", lambda root: tmp_path / "raylib") + calls = [] + monkeypatch.setattr(tools.subprocess, "run", lambda args, **kwargs: calls.append(args)) + assert tools.build_viewer_main() == 0 + assert calls == [ + ["cmake", "-S", str(tools.ENV_ROOT), "-B", str(tmp_path / "build/fight_caves-viewer"), + "-DCMAKE_BUILD_TYPE=Release", f"-DRAYLIB_ROOT={tmp_path / 'raylib'}"], + ["cmake", "--build", str(tmp_path / "build/fight_caves-viewer"), "--parallel"], + ] + + +def test_viewer_build_reports_cmake_failure(tmp_path, monkeypatch, capsys): + tools = load_setup_data() + monkeypatch.setattr(sys, "argv", [str(SETUP_DATA)]) + monkeypatch.setattr(tools, "run_preflight", lambda mode: 0) + monkeypatch.setattr(tools, "viewer_raylib", lambda root: tmp_path) + + def fail(args, **kwargs): + raise subprocess.CalledProcessError(1, args) + + monkeypatch.setattr(tools.subprocess, "run", fail) + assert tools.build_viewer_main() == 1 + assert "viewer build failed" in capsys.readouterr().err + + +def test_explicit_raylib_is_validated_without_downloading(tmp_path, monkeypatch): + tools = load_setup_data() + monkeypatch.setattr(tools, "download", lambda *args: pytest.fail("unexpected download")) + with pytest.raises(tools.AssetError, match="Raylib is incomplete"): + tools.viewer_raylib(tmp_path) + for relative in tools.RAYLIB_FILES: + path = tmp_path / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"existing dependency") + assert tools.viewer_raylib(tmp_path) == tmp_path + + +@pytest.mark.parametrize("complete", [False, True]) +def test_raylib_download_is_staged_and_confined(tmp_path, monkeypatch, complete): + tools = load_setup_data() + monkeypatch.setattr(tools, "REPO_ROOT", tmp_path) + monkeypatch.setattr(tools.sys, "platform", "darwin") + name = "raylib-5.5_macos" + files = {f"{name}/{relative}": b"dependency" for relative in tools.RAYLIB_FILES} + files["../../outside"] = b"not part of the viewer dependency" + if not complete: + del files[f"{name}/lib/libraylib.a"] + + def download(url, destination): + assert url == f"https://github.com/raysan5/raylib/releases/download/5.5/{name}.tar.gz" + make_archive(destination, files) + + monkeypatch.setattr(tools, "download", download) + if complete: + root = tools.viewer_raylib(None) + assert root == tmp_path / "build" / name + for relative in tools.RAYLIB_FILES: + assert (root / relative).read_bytes() == b"dependency" + monkeypatch.setattr(tools, "download", lambda *args: pytest.fail("unexpected download")) + assert tools.viewer_raylib(None) == root + else: + with pytest.raises(KeyError): + tools.viewer_raylib(None) + assert not (tmp_path / "build" / name).exists() + assert not (tmp_path / "outside").exists() + assert not (tmp_path / name).exists() + assert not list((tmp_path / "build").glob("fight-caves-raylib-*")) + MODULE_PATH = REPO_ROOT / "ocean" / "fight_caves" / "tools.py" SPEC = importlib.util.spec_from_file_location("fight_caves_eval_contract", MODULE_PATH) From 4641910c2a20e11b5377713ebafc5f7a96d2c1a3 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Wed, 9 Sep 2026 21:03:29 -0400 Subject: [PATCH 03/14] Add equipment switching and RuneC viewer interactions Port v38 inventory and equipment transactions, dynamic player appearance, context menus, animated NPC picking, and click feedback into the existing flat Fight Caves layout. Include matching viewer assets manifest and regression coverage without changing training configuration or policy actions. Validation: PR run dyu9u9ix matched v38 run ybcsi6a4 at 749,731,840 training steps. All 17 checkpoints were byte-identical; all 49,980 logged environment and loss values matched. Final evaluation: 10,181 episodes, 93.4682% Jad completion, 993.1863 average damage taken. Playable and checkpoint replay launch checks passed. --- ocean/fight_caves/CMakeLists.txt | 9 + ocean/fight_caves/README.md | 27 +- ocean/fight_caves/assets.h | 214 ++++++++- ocean/fight_caves/fight_caves.h | 4 +- ocean/fight_caves/render.h | 23 +- ocean/fight_caves/simulation.h | 518 +++++++++++++++++++-- ocean/fight_caves/tools.py | 11 +- ocean/fight_caves/ui.h | 356 +++++++++++--- ocean/fight_caves/viewer.c | 387 +++++++++------ resources/fight_caves/README.md | 2 + resources/fight_caves/asset_manifest.json | 40 +- tests/fight_caves.c | 543 +++++++++++++++++++++- tests/fight_caves.sh | 18 + tests/test_fight_caves.py | 42 ++ 14 files changed, 1908 insertions(+), 286 deletions(-) diff --git a/ocean/fight_caves/CMakeLists.txt b/ocean/fight_caves/CMakeLists.txt index 354b094c86..7790284627 100644 --- a/ocean/fight_caves/CMakeLists.txt +++ b/ocean/fight_caves/CMakeLists.txt @@ -11,6 +11,7 @@ if(NOT RAYLIB_ROOT) set(RAYLIB_ROOT "${PUFFERLIB_ROOT}/raylib-5.5_linux_amd64") endif() endif() + set(RAYLIB_ROOT "${RAYLIB_ROOT}" CACHE PATH "Raylib distribution root") if(NOT EXISTS "${RAYLIB_ROOT}/include/raylib.h" OR NOT EXISTS "${RAYLIB_ROOT}/lib/libraylib.a") @@ -31,3 +32,11 @@ else() find_package(X11 REQUIRED) target_link_libraries(fc_viewer PRIVATE dl ${X11_LIBRARIES} GL) endif() + +# Explicit graphical regression target; ordinary viewer builds do not run it. +add_executable(fc_viewer_tests EXCLUDE_FROM_ALL "${PUFFERLIB_ROOT}/tests/fight_caves.c") +target_compile_definitions(fc_viewer_tests PRIVATE FC_VIEWER_TEST) +target_include_directories(fc_viewer_tests PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}" "${RAYLIB_ROOT}/include") +get_target_property(FC_VIEWER_TEST_LIBRARIES fc_viewer LINK_LIBRARIES) +target_link_libraries(fc_viewer_tests PRIVATE ${FC_VIEWER_TEST_LIBRARIES}) diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index c4b9f6f136..8267bad565 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -10,12 +10,14 @@ The environment uses flat implementation headers, with no separate `src/`, `include/`, or viewer source tree: - `fight_caves.h`: Puffer lifecycle, observations, rewards and episode logging. -- `simulation.h`: game state, contracts, combat, routing, waves and loadouts. +- `simulation.h`: game state, contracts, combat, routing, waves, loadouts and + inventory/equipment transactions. - `binding.c`: Puffer 4.0 binding, configuration and compiled-contract export. - `fight_caves.c`: standalone random-action simulator/benchmark. - `viewer.c`: playable and policy-pipe entry point, input and scene lifecycle. -- `assets.h`: asset readers, models, animations, terrain and animated atlases. -- `ui.h`: OSRS interfaces, sprites, fonts, minimap and orbs. +- `assets.h`: asset readers, models, animations, terrain, animated atlases and + equipment-based player appearance composition. +- `ui.h`: OSRS interfaces, sprites, fonts, minimap, orbs and context menus. - `render.h`: actor motion, animation selection, combat effects and debug overlays. - `tools.py`: asset installation/verification, bundle creation, preflight, optional viewer build, playable launch and checkpoint replay. @@ -79,7 +81,8 @@ Build the CPU Puffer backend and run the environment acceptance tests: bash tests/fight_caves.sh test --puffer ``` -Include the viewer build: +Include the viewer build and explicit graphical regression tests (using +`xvfb-run` when available, otherwise an existing display): ```bash bash tests/fight_caves.sh test --all @@ -104,6 +107,19 @@ camera controls, equipment/prayer/inventory tabs, run-energy and minimap orbs, wave/TPS/target controls, god mode, debug information, prayer-window indicators, projectiles, impacts, health bars and hitsplats. +In playable mode, click worn equipment to remove it and click equipment in the +inventory to equip it. The player model and bonuses update together. Inventory +capacity, two-handed weapon/shield swaps, requirements, ammunition compatibility, +stack limits, loaded-weapon charges and consumable slots are handled by the core. +These immediate transactions do not advance a tick or reset attack cooldowns. +Equipment switching is not a policy action: training keeps the same three heads, +320 model inputs (286 observations plus 34 mask values), rewards and config. + +Right-click items, NPCs or terrain to open the RuneC-style menu. It includes +NPC combat levels and relative-level colors, uses the bold OSRS menu font, and +picks NPCs against their animated models. Menu choices invoke existing viewer +actions; yellow/red click animations and right-drag camera control are retained. + Useful controls include `Space` to pause/resume, `Right Arrow` to step one tick, `O` for debug overlays, right-drag to orbit, the mouse wheel to zoom, and `Q`/`Escape` to quit. @@ -122,6 +138,9 @@ and size checks, masking, pause/speed controls and episode summaries. `--ckpt la selects the newest compatible checkpoint; `--random` uses random legal actions without loading a checkpoint. The existing dedicated policy-pipe evaluator is preserved, rather than changing inference or switching to a different renderer. +The state hash is version 5 for inventory/equipment state. Version-4 checkpoint +sidecars remain accepted only when every other contract field matches, since +policy weights do not serialize equipment state. The viewer defaults to `resources/fight_caves/viewer`; arena maps default to `resources/fight_caves/runtime`. Explicit `FC_ASSET_ROOT`, `FC_REPO_ROOT`, diff --git a/ocean/fight_caves/assets.h b/ocean/fight_caves/assets.h index 061bdcf912..725fbf81b5 100644 --- a/ocean/fight_caves/assets.h +++ b/ocean/fight_caves/assets.h @@ -211,6 +211,12 @@ typedef struct { ModelSet *models_load(const char *path, Texture2D atlas_texture); ModelEntry *model_find(ModelSet *set, uint32_t id); +/* Screen-space face picking at the drawn pose, with the client's 5px tolerance. + * Returns camera depth for overlap ordering, or -1 for a miss. posed_vertices + * uses animation/cache coordinates; NULL selects the asset's rest mesh. */ +float models_pick_depth(const ModelEntry *entry, const int16_t *posed_vertices, + Vector3 position, float yaw_degrees, Camera3D camera, + Vector2 mouse, int screen_width, int screen_height); void models_recompute_texture_uvs_from_vertices(ModelEntry *entry, const int16_t *verts); void models_free(ModelSet *set); @@ -348,6 +354,22 @@ SpotAnimSet *spotanims_load(const char *path); const SpotAnimDef *spotanim_find(const SpotAnimSet *set, int id); void spotanims_free(SpotAnimSet *set); +#include "simulation.h" + +/* Player Appearance */ +typedef struct { + ModelSet *parts; + ModelSet *model; + struct { uint32_t item_id, hide_mask; } records[64]; + int record_count; + int worn_ids[FC_EQUIPMENT_SLOTS]; +} FcPlayerAppearance; + +int fc_player_appearance_load(FcPlayerAppearance *appearance); +/* 1 = rebuilt, 0 = unchanged, -1 = missing/invalid asset or allocation failure. */ +int fc_player_appearance_sync(FcPlayerAppearance *appearance, + const FcPlayer *player, uint32_t model_id); +void fc_player_appearance_free(FcPlayerAppearance *appearance); /* Assets */ #include @@ -1818,6 +1840,8 @@ void anim_cache_free(AnimCache* cache) { // Fight Caves raylib model loader. #include "raylib.h" +#include "raymath.h" +#include #include #include #include @@ -1848,6 +1872,50 @@ ModelEntry *model_find(ModelSet *set, uint32_t id) { return NULL; } +float models_pick_depth(const ModelEntry *entry, const int16_t *posed_vertices, + Vector3 position, float yaw_degrees, Camera3D camera, + Vector2 mouse, int screen_width, int screen_height) { + if (!entry || !entry->loaded || !entry->rest_verts) return -1; + /* Same model -> yaw -> translation order as DrawModelEx. Never read the + * shared uploaded mesh: another NPC of this type may have a different pose. */ + Matrix transform = MatrixMultiply(entry->model.transform, + MatrixMultiply(MatrixRotateY(yaw_degrees * DEG2RAD), + MatrixTranslate(position.x, position.y, position.z))); + Vector3 forward = Vector3Normalize(Vector3Subtract(camera.target, camera.position)); + float nearest = FLT_MAX; + for (int face = 0; face < entry->face_count; face++) { + float min_x = FLT_MAX, min_y = FLT_MAX; + float max_x = -FLT_MAX, max_y = -FLT_MAX, depth = 0; + int visible = 1; + for (int corner = 0; corner < 3; corner++) { + int offset = (face * 3 + corner) * 3; + Vector3 local; + if (posed_vertices && entry->face_indices) { + int vertex = entry->face_indices[face * 3 + corner] * 3; + local = (Vector3){posed_vertices[vertex] / 128.0f, + -posed_vertices[vertex + 1] / 128.0f, + -posed_vertices[vertex + 2] / 128.0f}; + } else { + local = (Vector3){entry->rest_verts[offset], entry->rest_verts[offset + 1], + entry->rest_verts[offset + 2]}; + } + Vector3 world = Vector3Transform(local, transform); + float z = Vector3DotProduct(Vector3Subtract(world, camera.position), forward); + if (z <= 0.01f) { visible = 0; break; } + Vector2 screen = GetWorldToScreenEx(world, camera, screen_width, screen_height); + min_x = fminf(min_x, screen.x); max_x = fmaxf(max_x, screen.x); + min_y = fminf(min_y, screen.y); max_y = fmaxf(max_y, screen.y); + depth += z / 3.0f; + } + /* RuneLite Perspective.calculate2DBounds uses projected face rectangles + * padded by five pixels, not a ground-tile test or pixel-perfect triangles. */ + if (visible && mouse.x >= min_x - 5 && mouse.x <= max_x + 5 && + mouse.y >= min_y - 5 && mouse.y <= max_y + 5 && depth < nearest) + nearest = depth; + } + return nearest == FLT_MAX ? -1 : nearest; +} + static float model_clamp_uv(float v) { if (v < 0.0f) return 0.0f; if (v > 1.0f) return 1.0f; @@ -2237,14 +2305,14 @@ void models_free(ModelSet *set) { for (int i = 0; i < set->count; i++) { if (set->entries[i].loaded) { UnloadModel(set->entries[i].model); + } free(set->entries[i].base_verts); free(set->entries[i].rest_verts); free(set->entries[i].rest_texcoords); - free(set->entries[i].vertex_skins); - free(set->entries[i].face_indices); - free(set->entries[i].face_priorities); - free(set->entries[i].face_uvs); - } + free(set->entries[i].vertex_skins); + free(set->entries[i].face_indices); + free(set->entries[i].face_priorities); + free(set->entries[i].face_uvs); } free(set->entries); free(set->index_by_id); @@ -2901,4 +2969,140 @@ void spotanims_free(SpotAnimSet *set) { #undef SPOTANIM_MAGIC #undef SPOTANIM_VERSION +/* Player Appearance */ +#include +#include + +int fc_player_appearance_load(FcPlayerAppearance *appearance) { + memset(appearance, 0, sizeof(*appearance)); + const char *path = "fc_player.parts"; + FILE *file = fc_asset_fopen(path, "rb"); + if (!file) return 0; + uint32_t header[2]; + int ok = fc_read_exact(file, header, sizeof(uint32_t), 2, path, "header") && + header[0] == 0x31504346 && header[1] > 0 && header[1] <= 64; + if (ok) { + appearance->record_count = (int)header[1]; + for (int i = 0; i < appearance->record_count; i++) { + uint32_t row[2]; + if (!fc_read_exact(file, row, sizeof(uint32_t), 2, path, "item mapping") || + !fc_item_definition((int)row[0]) || row[1] > 127) { ok = 0; break; } + appearance->records[i].item_id = row[0]; + appearance->records[i].hide_mask = row[1]; + } + if (fgetc(file) != EOF) ok = 0; + } + fc_asset_close(file); + if (!ok) { fprintf(stderr, "Invalid player appearance map: %s\n", path); return 0; } + appearance->parts = models_load("fc_player.models", (Texture2D){0}); + if (!appearance->parts || appearance->parts->has_textures) return 0; + for (int i = 0; i < 7; i++) + if (!model_find(appearance->parts, 0xFC100000u + (uint32_t)i)) return 0; + for (int i = 0; i < appearance->record_count; i++) + if (!model_find(appearance->parts, appearance->records[i].item_id)) return 0; + return 1; +} + +static ModelSet *compose(ModelEntry *parts[], int count, uint32_t id) { + int vertices = 0, faces = 0; + for (int i = 0; i < count; i++) { + vertices += parts[i]->base_vert_count; + faces += parts[i]->face_count; + } + if (vertices <= 0 || vertices > UINT16_MAX || faces <= 0) return NULL; + ModelSet *set = calloc(1, sizeof(*set)); + if (!set) return NULL; + set->entries = calloc(1, sizeof(*set->entries)); + if (!set->entries) { free(set); return NULL; } + set->count = 1; + ModelEntry *out = set->entries; + out->model_id = id; + out->base_vert_count = vertices; + out->face_count = faces; + out->base_verts = malloc((size_t)vertices * 3 * sizeof(int16_t)); + out->vertex_skins = malloc((size_t)vertices); + out->face_indices = malloc((size_t)faces * 3 * sizeof(uint16_t)); + out->face_priorities = malloc((size_t)faces); + out->rest_verts = malloc((size_t)faces * 9 * sizeof(float)); + Mesh mesh = {.vertexCount = faces * 3, .triangleCount = faces}; + mesh.vertices = malloc((size_t)faces * 9 * sizeof(float)); + mesh.normals = malloc((size_t)faces * 9 * sizeof(float)); + mesh.colors = malloc((size_t)faces * 12); + if (!out->base_verts || !out->vertex_skins || !out->face_indices || + !out->face_priorities || !out->rest_verts || !mesh.vertices || + !mesh.normals || !mesh.colors) { + free(mesh.vertices); free(mesh.normals); free(mesh.colors); + models_free(set); + return NULL; + } + int vertex_offset = 0, face_offset = 0; + for (int i = 0; i < count; i++) { + const ModelEntry *part = parts[i]; + const Mesh *source = &part->model.meshes[0]; + memcpy(out->base_verts + vertex_offset * 3, part->base_verts, + (size_t)part->base_vert_count * 3 * sizeof(int16_t)); + memcpy(out->vertex_skins + vertex_offset, part->vertex_skins, + (size_t)part->base_vert_count); + for (int f = 0; f < part->face_count * 3; f++) + out->face_indices[face_offset * 3 + f] = + (uint16_t)(part->face_indices[f] + vertex_offset); + memcpy(out->face_priorities + face_offset, part->face_priorities, + (size_t)part->face_count); + memcpy(mesh.vertices + face_offset * 9, part->rest_verts, + (size_t)part->face_count * 9 * sizeof(float)); + memcpy(mesh.normals + face_offset * 9, source->normals, + (size_t)part->face_count * 9 * sizeof(float)); + memcpy(mesh.colors + face_offset * 12, source->colors, + (size_t)part->face_count * 12); + vertex_offset += part->base_vert_count; + face_offset += part->face_count; + } + memcpy(out->rest_verts, mesh.vertices, (size_t)faces * 9 * sizeof(float)); + UploadMesh(&mesh, true); + out->model = LoadModelFromMesh(mesh); + out->loaded = set->loaded = 1; + return set; +} + +int fc_player_appearance_sync(FcPlayerAppearance *appearance, + const FcPlayer *player, uint32_t model_id) { + int ids[FC_EQUIPMENT_SLOTS]; + for (int i = 0; i < FC_EQUIPMENT_SLOTS; i++) ids[i] = player->equipment[i].item_id; + if (appearance->model && appearance->model->entries[0].model_id == model_id && + memcmp(ids, appearance->worn_ids, sizeof(ids)) == 0) return 0; + if (!appearance->parts) return -1; + ModelEntry *selected[7 + FC_EQUIPMENT_SLOTS]; + int count = 0; + uint32_t hidden = 0; + for (int slot = 0; slot < FC_EQUIPMENT_SLOTS; slot++) { + if (!ids[slot] || slot == FC_EQUIP_SLOT_AMMO || slot == FC_EQUIP_SLOT_RING) continue; + int record = 0; + while (record < appearance->record_count && + appearance->records[record].item_id != (uint32_t)ids[slot]) record++; + if (record == appearance->record_count) return -1; + hidden |= appearance->records[record].hide_mask; + } + /* Identity kits precede equipped models, matching client composition. */ + for (int body = 0; body < 7; body++) + if (!(hidden & (1u << body))) + selected[count++] = model_find(appearance->parts, 0xFC100000u + (uint32_t)body); + for (int slot = 0; slot < FC_EQUIPMENT_SLOTS; slot++) + if (ids[slot] && slot != FC_EQUIP_SLOT_AMMO && slot != FC_EQUIP_SLOT_RING) + selected[count++] = model_find(appearance->parts, (uint32_t)ids[slot]); + for (int i = 0; i < count; i++) if (!selected[i]) return -1; + ModelSet *model = compose(selected, count, model_id); + if (!model) return -1; + models_free(appearance->model); + appearance->model = model; + memcpy(appearance->worn_ids, ids, sizeof(ids)); + return 1; +} + +void fc_player_appearance_free(FcPlayerAppearance *appearance) { + models_free(appearance->model); + models_free(appearance->parts); + memset(appearance, 0, sizeof(*appearance)); +} + + #endif diff --git a/ocean/fight_caves/fight_caves.h b/ocean/fight_caves/fight_caves.h index bb26b50552..462f62df8b 100644 --- a/ocean/fight_caves/fight_caves.h +++ b/ocean/fight_caves/fight_caves.h @@ -393,8 +393,8 @@ void c_reset(FightCaves* env) { if (env->initial_prayer_doses < 0) env->initial_prayer_doses = 0; if (env->initial_prayer_doses > FC_MAX_PRAYER_DOSES) env->initial_prayer_doses = FC_MAX_PRAYER_DOSES; - env->state.player.sharks_remaining = env->initial_sharks; - env->state.player.prayer_doses_remaining = env->initial_prayer_doses; + fc_set_initial_supplies(&env->state, env->initial_sharks, + env->initial_prayer_doses); env->ep_length = 0; fc_reward_runtime_begin_episode(&env->reward_runtime, &env->state); diff --git a/ocean/fight_caves/render.h b/ocean/fight_caves/render.h index ba2e1858db..96527124a4 100644 --- a/ocean/fight_caves/render.h +++ b/ocean/fight_caves/render.h @@ -187,6 +187,7 @@ void fc_actor_animation_update_models(FcActorAnimation *animation, const unsigned char deferred_deaths[FC_MAX_NPCS]); const FcPlayerVisualProfile *fc_player_visual_profile(int active_loadout); +int fc_player_equipment_visual_profile(const FcPlayer *player); NpcModelEntry *fc_actor_player_model_entry(NpcModelSet *player_models, int active_loadout); void fc_actor_animation_upload_npc(FcActorAnimation *animation, @@ -846,11 +847,22 @@ static const uint16_t NPC_ANIM_DEATH[] = { }; const FcPlayerVisualProfile *fc_player_visual_profile(int active_loadout) { + static const FcPlayerVisualProfile unarmed = { + .idle_anim=808, .walk_anim=819, .walk_back_anim=820, + .walk_left_anim=822, .walk_right_anim=821, .turn_anim=823, + .run_anim=824, .attack_anim=422, + }; + if (active_loadout == -1) return &unarmed; if (active_loadout < 0 || active_loadout >= FC_NUM_LOADOUTS) active_loadout = FC_ACTIVE_LOADOUT; return &PLAYER_VISUALS[active_loadout]; } +int fc_player_equipment_visual_profile(const FcPlayer *player) { + const FcItemDef *weapon = fc_item_definition(player->equipment[FC_EQUIP_SLOT_WEAPON].item_id); + return weapon ? weapon->visual_profile : -1; +} + NpcModelEntry *fc_actor_player_model_entry(NpcModelSet *player_models, int active_loadout) { if (!player_models) return NULL; @@ -858,8 +870,6 @@ NpcModelEntry *fc_actor_player_model_entry(NpcModelSet *player_models, active_loadout = FC_ACTIVE_LOADOUT; uint32_t model_id = FC_LOADOUTS[active_loadout].player_model_id; NpcModelEntry *entry = fc_npc_model_find(player_models, model_id); - if (!entry && player_models->count > 0) - entry = &player_models->entries[0]; return entry && entry->loaded ? entry : NULL; } @@ -1046,7 +1056,8 @@ void fc_actor_animation_reset(FcActorAnimation *animation, fc_visual_scene_reset_player(&animation->scene, state->player.x, state->player.y, 1, state->player.facing_angle); - const FcPlayerVisualProfile *profile = fc_player_visual_profile(active_loadout); + const FcPlayerVisualProfile *profile = fc_player_visual_profile( + fc_player_equipment_visual_profile(&state->player)); animation->player_pose_sequence = profile->idle_anim; animation->player_pose_frame = 0; animation->player_pose_timer = 0.0f; @@ -1294,10 +1305,11 @@ void fc_actor_animation_update_models(FcActorAnimation *animation, float anim_dt = fc_actor_animation_scaled_dt(tps, dt); NpcModelEntry *player_entry = fc_actor_player_model_entry(player_models, active_loadout); - recreate_player_state(animation, player_entry, active_loadout); + int visual_profile = fc_player_equipment_visual_profile(&state->player); + recreate_player_state(animation, player_entry, visual_profile); if (animation->player_state && player_entry) { const FcPlayerVisualProfile *profile = - fc_player_visual_profile(active_loadout); + fc_player_visual_profile(visual_profile); FcVisualPose pose = fc_visual_scene_player_pose(&animation->scene); uint16_t pose_sequence = player_pose_sequence(profile, pose.locomotion); uint16_t action_sequence = player_action_sequence(animation, state); @@ -2241,6 +2253,7 @@ static void ingest_player_attack(FcCombatPresentation *presentation, const FcCombatPresentationContext *context) { const FcRenderEvents *events = context->events; const FcPlayerVisualProfile *profile = context->player_profile; + if (!profile->projectile_travel_spot) return; /* unarmed: no projectile */ int sx = events->player_attack_source_x; int sy = events->player_attack_source_y; int tx = events->player_attack_target_x; diff --git a/ocean/fight_caves/simulation.h b/ocean/fight_caves/simulation.h index a4ce95ab33..a5009d0c22 100644 --- a/ocean/fight_caves/simulation.h +++ b/ocean/fight_caves/simulation.h @@ -39,7 +39,7 @@ typedef enum { FC_EQUIP_SLOT_WEAPON = 3, FC_EQUIP_SLOT_BODY = 4, FC_EQUIP_SLOT_SHIELD = 5, - FC_EQUIP_SLOT_AMMO = 6, + FC_EQUIP_SLOT_AMMO = 13, FC_EQUIP_SLOT_LEGS = 7, FC_EQUIP_SLOT_HANDS = 9, FC_EQUIP_SLOT_FEET = 10, @@ -97,7 +97,8 @@ typedef struct { typedef enum { FC_WEAPON_GENERIC_RANGED = 0, FC_WEAPON_TWISTED_BOW = 1, - FC_WEAPON_BOW_OF_FAERDHINEN = 2 + FC_WEAPON_BOW_OF_FAERDHINEN = 2, + FC_WEAPON_UNARMED = 3 } FcWeaponKind; #define FC_NUM_LOADOUTS FC_LOADOUT_COUNT @@ -314,6 +315,15 @@ typedef struct { /* Player */ /* ======================================================================== */ +#define FC_INVENTORY_SLOTS 28 +#define FC_EQUIPMENT_SLOTS 14 + +typedef struct { + int item_id; /* 0 denotes an empty slot */ + int quantity; + int charges; /* loaded darts remain with the blowpipe */ +} FcItemStack; + typedef struct { /* Position */ int x, y; @@ -412,6 +422,10 @@ typedef struct { int total_damage_taken; int total_food_eaten; int total_potions_used; + FcItemStack inventory[FC_INVENTORY_SLOTS]; + FcItemStack equipment[FC_EQUIPMENT_SLOTS]; + int melee_attack_bonus, melee_strength_bonus; + int selected_food_slot, selected_potion_slot; } FcPlayer; /* ======================================================================== */ @@ -1259,6 +1273,9 @@ void fc_npc_tz_kek_split(FcState* state, int dead_x, int dead_y); /* Pathfinding */ +/* Attack-route range 0 means cardinal melee contact, not ranged LOS. */ +#define FC_ROUTE_MELEE_RANGE 0 + /* ======================================================================== */ /* Tile queries */ /* ======================================================================== */ @@ -1676,9 +1693,9 @@ int fc_is_terminal(const FcState* state); /* Determinism */ /* ======================================================================== */ -/* Version 4 removes redundant compatibility/temporary fields from the - * complete core-owned fixed-width FcState serialization. */ -#define FC_STATE_HASH_VERSION 4u +/* Version 5 includes inventory, equipment, selected consumable slots and + * unarmed bonuses. Policy observations and action dimensions are unchanged. */ +#define FC_STATE_HASH_VERSION 5u /* * Compute a deterministic hash of the game state. @@ -1727,6 +1744,40 @@ int fc_rng_int(FcState* state, int max); float fc_rng_float(FcState* state); +/* Items */ + +typedef struct { + int id; + const char *name; + int slot; /* -1 for inventory-only items */ + int stackable, two_handed; + int ranged_level, defence_level, hitpoints_level; + int ranged_attack, ranged_strength; + int defence[5]; /* stab, slash, crush, magic, ranged */ + int prayer, melee_attack, melee_strength; + int weapon_kind, speed, range, ammo_kind; + int ammo_tier; /* supported ammunition: 0 standard, 1 dragon */ + int crystal_piece; + int visual_profile; +} FcItemDef; + +typedef enum { + FC_ITEM_OK, FC_ITEM_INVALID, FC_ITEM_NO_SPACE, FC_ITEM_REQUIREMENTS, + FC_ITEM_BUSY +} FcItemResult; + +const FcItemDef *fc_item_definition(int item_id); +const char *fc_item_result_message(FcItemResult result); +/* Immediate inventory transactions between ticks. They do not advance time, + * reset cooldowns, roll RNG, or alter already-launched attacks. */ +FcItemResult fc_equip_item(FcState *state, int inventory_slot); +FcItemResult fc_unequip_item(FcState *state, int equipment_slot); +FcItemResult fc_inventory_swap(FcState *state, int first, int second); +/* Select the actual slot consumed by the next canonical food/potion action. */ +FcItemResult fc_select_consumable(FcState *state, int inventory_slot); +void fc_set_initial_supplies(FcState *state, int sharks, int prayer_doses); + + /* Action Internal */ /* An already-active run may consume its remaining energy below 1%. Starting @@ -1741,6 +1792,11 @@ int fc_eat_action_valid(const FcState* state, int action); int fc_drink_action_valid(const FcState* state, int action); +/* Items Internal */ +void fc_items_init(FcPlayer *player, const FcLoadout *loadout); +void fc_items_consume(FcPlayer *player, int potion); +void fc_items_spend_ammo(FcPlayer *player); + /* Spawn Internal */ int fc_spawn_find_available_footprint(const FcState* state, @@ -2518,6 +2574,20 @@ static uint32_t fc_hash_player(uint32_t hash, const FcPlayer* player) { FC_HASH_I32(player->total_damage_taken); FC_HASH_I32(player->total_food_eaten); FC_HASH_I32(player->total_potions_used); + for (int i = 0; i < FC_INVENTORY_SLOTS; i++) { + FC_HASH_I32(player->inventory[i].item_id); + FC_HASH_I32(player->inventory[i].quantity); + FC_HASH_I32(player->inventory[i].charges); + } + for (int i = 0; i < FC_EQUIPMENT_SLOTS; i++) { + FC_HASH_I32(player->equipment[i].item_id); + FC_HASH_I32(player->equipment[i].quantity); + FC_HASH_I32(player->equipment[i].charges); + } + FC_HASH_I32(player->melee_attack_bonus); + FC_HASH_I32(player->melee_strength_bonus); + FC_HASH_I32(player->selected_food_slot); + FC_HASH_I32(player->selected_potion_slot); return hash; } @@ -2690,6 +2760,387 @@ uint32_t fc_state_hash(const FcState* state) { #undef FC_HASH_U32 #undef FC_HASH_F32 +/* Items */ +#include +#include + +/* Pinned to the existing FcLoadout balance, including legacy d'hide defence + * and Pegasian strength. Equipment switching must not rebalance training. + * Only the items supplied by our presets and their consumables are supported. */ +enum { AMMO_NONE, AMMO_ARROW, AMMO_BOLT, AMMO_LOADED_DART }; +static const FcItemDef ITEMS[] = { + {.id=1169, .name="Coif", .slot=FC_EQUIP_SLOT_HEAD, + .ranged_attack=2, .ranged_strength=0, .defence={4,6,8,4,4}, .prayer=0, + .ranged_level=20, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=22109, .name="Ava's assembler", .slot=FC_EQUIP_SLOT_CAPE, + .ranged_attack=8, .ranged_strength=2, .defence={1,1,1,8,2}, .prayer=0, + .ranged_level=70, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=19547, .name="Necklace of anguish", .slot=FC_EQUIP_SLOT_NECK, + .ranged_attack=15, .ranged_strength=5, .defence={0,0,0,0,0}, .prayer=2, + .ranged_level=0, .defence_level=0, .hitpoints_level=75, .melee_attack=0, .melee_strength=0}, + {.id=27235, .name="Masori mask (f)", .slot=FC_EQUIP_SLOT_HEAD, + .ranged_attack=12, .ranged_strength=2, .defence={8,10,12,12,9}, .prayer=1, + .ranged_level=80, .defence_level=80, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=27238, .name="Masori body (f)", .slot=FC_EQUIP_SLOT_BODY, + .ranged_attack=43, .ranged_strength=4, .defence={59,52,64,74,60}, .prayer=1, + .ranged_level=80, .defence_level=80, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=27241, .name="Masori chaps (f)", .slot=FC_EQUIP_SLOT_LEGS, + .ranged_attack=27, .ranged_strength=2, .defence={35,30,39,46,37}, .prayer=1, + .ranged_level=80, .defence_level=80, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=26235, .name="Zaryte vambraces", .slot=FC_EQUIP_SLOT_HANDS, + .ranged_attack=18, .ranged_strength=2, .defence={8,8,8,5,8}, .prayer=1, + .ranged_level=80, .defence_level=45, .hitpoints_level=0, .melee_attack=-8, .melee_strength=0}, + {.id=13237, .name="Pegasian boots", .slot=FC_EQUIP_SLOT_FEET, + .ranged_attack=12, .ranged_strength=0, .defence={5,5,5,5,5}, .prayer=0, + .ranged_level=75, .defence_level=75, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=28310, .name="Venator ring", .slot=FC_EQUIP_SLOT_RING, + .ranged_attack=10, .ranged_strength=2, .defence={0,0,0,0,0}, .prayer=0, + .ranged_level=0, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=2503, .name="Black d'hide body", .slot=FC_EQUIP_SLOT_BODY, + .ranged_attack=30, .ranged_strength=0, .defence={55,47,60,50,55}, .prayer=0, + .ranged_level=70, .defence_level=40, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=2497, .name="Black d'hide chaps", .slot=FC_EQUIP_SLOT_LEGS, + .ranged_attack=17, .ranged_strength=0, .defence={31,25,33,28,31}, .prayer=0, + .ranged_level=70, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=2491, .name="Black d'hide vambraces", .slot=FC_EQUIP_SLOT_HANDS, + .ranged_attack=11, .ranged_strength=0, .defence={6,5,7,8,0}, .prayer=0, + .ranged_level=70, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=6328, .name="Snakeskin boots", .slot=FC_EQUIP_SLOT_FEET, + .ranged_attack=3, .ranged_strength=0, .defence={1,1,2,1,0}, .prayer=0, + .ranged_level=30, .defence_level=30, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=2581, .name="Robin hood hat", .slot=FC_EQUIP_SLOT_HEAD, + .ranged_attack=8, .ranged_strength=0, .defence={4,6,8,4,4}, .prayer=0, + .ranged_level=40, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=10499, .name="Ava's accumulator", .slot=FC_EQUIP_SLOT_CAPE, + .ranged_attack=4, .ranged_strength=0, .defence={0,1,0,4,0}, .prayer=0, + .ranged_level=50, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=1704, .name="Amulet of glory", .slot=FC_EQUIP_SLOT_NECK, + .ranged_attack=10, .ranged_strength=0, .defence={3,3,3,3,3}, .prayer=3, + .ranged_level=0, .defence_level=0, .hitpoints_level=0, .melee_attack=10, .melee_strength=6}, + {.id=12596, .name="Rangers' tunic", .slot=FC_EQUIP_SLOT_BODY, + .ranged_attack=15, .ranged_strength=0, .defence={6,9,12,6,6}, .prayer=0, + .ranged_level=40, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=12610, .name="Book of law", .slot=FC_EQUIP_SLOT_SHIELD, + .ranged_attack=10, .ranged_strength=0, .defence={0,0,0,0,0}, .prayer=5, + .ranged_level=0, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=2495, .name="Red d'hide chaps", .slot=FC_EQUIP_SLOT_LEGS, + .ranged_attack=14, .ranged_strength=0, .defence={28,22,30,20,28}, .prayer=0, + .ranged_level=60, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=11126, .name="Combat bracelet", .slot=FC_EQUIP_SLOT_HANDS, + .ranged_attack=7, .ranged_strength=0, .defence={5,5,5,3,5}, .prayer=0, + .ranged_level=0, .defence_level=0, .hitpoints_level=0, .melee_attack=7, .melee_strength=6}, + {.id=2577, .name="Ranger boots", .slot=FC_EQUIP_SLOT_FEET, + .ranged_attack=8, .ranged_strength=0, .defence={2,3,4,2,0}, .prayer=0, + .ranged_level=40, .defence_level=0, .hitpoints_level=0, .melee_attack=0, .melee_strength=0}, + {.id=11826, .name="Armadyl helmet", .slot=FC_EQUIP_SLOT_HEAD, + .ranged_attack=10, .ranged_strength=0, .defence={6,8,10,10,8}, .prayer=1, + .ranged_level=70, .defence_level=70, .hitpoints_level=0, .melee_attack=-5, .melee_strength=0}, + {.id=11828, .name="Armadyl chestplate", .slot=FC_EQUIP_SLOT_BODY, + .ranged_attack=33, .ranged_strength=0, .defence={56,48,61,70,57}, .prayer=1, + .ranged_level=70, .defence_level=70, .hitpoints_level=0, .melee_attack=-7, .melee_strength=0}, + {.id=11830, .name="Armadyl chainskirt", .slot=FC_EQUIP_SLOT_LEGS, + .ranged_attack=20, .ranged_strength=0, .defence={32,26,34,40,33}, .prayer=1, + .ranged_level=70, .defence_level=70, .hitpoints_level=0, .melee_attack=-6, .melee_strength=0}, + {.id=7462, .name="Barrows gloves", .slot=FC_EQUIP_SLOT_HANDS, + .ranged_attack=12, .ranged_strength=0, .defence={12,12,12,6,12}, .prayer=0, + .ranged_level=0, .defence_level=0, .hitpoints_level=0, .melee_attack=12, .melee_strength=12}, + {.id=23971, .name="Crystal helm", .slot=FC_EQUIP_SLOT_HEAD, + .ranged_attack=9, .ranged_strength=0, .defence={12,8,14,10,18}, .prayer=2, + .ranged_level=70, .defence_level=70, .hitpoints_level=0, .melee_attack=0, .melee_strength=0, .crystal_piece=FC_CRYSTAL_PIECE_HELM}, + {.id=23975, .name="Crystal body", .slot=FC_EQUIP_SLOT_BODY, + .ranged_attack=31, .ranged_strength=0, .defence={46,38,48,44,68}, .prayer=3, + .ranged_level=70, .defence_level=70, .hitpoints_level=0, .melee_attack=0, .melee_strength=0, .crystal_piece=FC_CRYSTAL_PIECE_BODY}, + {.id=23979, .name="Crystal legs", .slot=FC_EQUIP_SLOT_LEGS, + .ranged_attack=18, .ranged_strength=0, .defence={26,21,30,34,38}, .prayer=2, + .ranged_level=70, .defence_level=70, .hitpoints_level=0, .melee_attack=0, .melee_strength=0, .crystal_piece=FC_CRYSTAL_PIECE_LEGS}, + {.id=9185, .name="Rune crossbow", .slot=FC_EQUIP_SLOT_WEAPON, + .ranged_attack=90, .ranged_strength=0, .two_handed=0, .ranged_level=61, + .weapon_kind=0, .speed=5, .range=7, .ammo_kind=AMMO_BOLT, .visual_profile=0}, + {.id=20997, .name="Twisted bow", .slot=FC_EQUIP_SLOT_WEAPON, + .ranged_attack=70, .ranged_strength=20, .two_handed=1, .ranged_level=85, + .weapon_kind=1, .speed=5, .range=10, .ammo_kind=AMMO_ARROW, .ammo_tier=1, .visual_profile=1}, + {.id=12788, .name="Magic shortbow (i)", .slot=FC_EQUIP_SLOT_WEAPON, + .ranged_attack=75, .ranged_strength=0, .two_handed=1, .ranged_level=50, + .weapon_kind=0, .speed=3, .range=7, .ammo_kind=AMMO_ARROW, .visual_profile=4}, + {.id=12926, .name="Toxic blowpipe", .slot=FC_EQUIP_SLOT_WEAPON, + .ranged_attack=30, .ranged_strength=20, .two_handed=1, .ranged_level=75, + .weapon_kind=0, .speed=2, .range=5, .ammo_kind=AMMO_LOADED_DART, .visual_profile=5}, + {.id=11785, .name="Armadyl crossbow", .slot=FC_EQUIP_SLOT_WEAPON, + .ranged_attack=100, .ranged_strength=0, .two_handed=0, .ranged_level=70, + .weapon_kind=0, .speed=5, .range=8, .ammo_kind=AMMO_BOLT, .ammo_tier=1, .visual_profile=6, .prayer=1}, + {.id=25867, .name="Bow of faerdhinen (c)", .slot=FC_EQUIP_SLOT_WEAPON, + .ranged_attack=128, .ranged_strength=106, .two_handed=1, .ranged_level=80, + .weapon_kind=2, .speed=4, .range=10, .ammo_kind=AMMO_NONE, .visual_profile=7}, + {.id=9143, .name="Adamant bolts", .slot=FC_EQUIP_SLOT_AMMO, + .stackable=1, .ranged_strength=100, .ammo_kind=AMMO_BOLT}, + {.id=11212, .name="Dragon arrow", .slot=FC_EQUIP_SLOT_AMMO, + .stackable=1, .ranged_strength=60, .ammo_kind=AMMO_ARROW, .ammo_tier=1}, + {.id=892, .name="Rune arrow", .slot=FC_EQUIP_SLOT_AMMO, + .stackable=1, .ranged_strength=49, .ammo_kind=AMMO_ARROW}, + {.id=21946, .name="Diamond dragon bolts (e)", .slot=FC_EQUIP_SLOT_AMMO, + .stackable=1, .ranged_strength=122, .ammo_kind=AMMO_BOLT, .ammo_tier=1}, + {.id=385, .name="Shark", .slot=-1}, + {.id=2434, .name="Prayer potion(4)", .slot=-1}, + {.id=139, .name="Prayer potion(3)", .slot=-1}, + {.id=141, .name="Prayer potion(2)", .slot=-1}, + {.id=143, .name="Prayer potion(1)", .slot=-1}, + {.id=229, .name="Vial", .slot=-1}, +}; + +const FcItemDef *fc_item_definition(int item_id) { + for (unsigned i = 0; i < sizeof(ITEMS) / sizeof(ITEMS[0]); i++) + if (ITEMS[i].id == item_id) return &ITEMS[i]; + return NULL; +} + +static void recalculate_equipment(FcPlayer *p) { + p->ranged_attack_bonus = p->ranged_strength_bonus = 0; + p->defence_stab = p->defence_slash = p->defence_crush = 0; + p->defence_magic = p->defence_ranged = p->prayer_bonus = 0; + p->melee_attack_bonus = p->melee_strength_bonus = p->crystal_piece_mask = 0; + const FcItemDef *weapon = fc_item_definition(p->equipment[FC_EQUIP_SLOT_WEAPON].item_id); + const FcItemDef *ammo = fc_item_definition(p->equipment[FC_EQUIP_SLOT_AMMO].item_id); + /* Quiver items may be worn with any weapon, but firing requires both the + * correct category and a supported tier (RSMod validateArrows/Bolts). */ + int usable_ammo = weapon && ammo && weapon->ammo_kind == ammo->ammo_kind && + ammo->ammo_tier <= weapon->ammo_tier; + for (int i = 0; i < FC_EQUIPMENT_SLOTS; i++) { + const FcItemDef *item = fc_item_definition(p->equipment[i].item_id); + if (!item) continue; + if (i == FC_EQUIP_SLOT_AMMO && !usable_ammo) + continue; + p->ranged_attack_bonus += item->ranged_attack; + p->ranged_strength_bonus += item->ranged_strength; + p->defence_stab += item->defence[0]; + p->defence_slash += item->defence[1]; + p->defence_crush += item->defence[2]; + p->defence_magic += item->defence[3]; + p->defence_ranged += item->defence[4]; + p->prayer_bonus += item->prayer; + p->melee_attack_bonus += item->melee_attack; + p->melee_strength_bonus += item->melee_strength; + p->crystal_piece_mask |= item->crystal_piece; + } + p->weapon_kind = weapon ? weapon->weapon_kind : FC_WEAPON_UNARMED; + p->weapon_speed = weapon ? weapon->speed : 4; + p->weapon_range = weapon ? weapon->range : 1; + p->weapon_uses_ammo = weapon && weapon->ammo_kind != AMMO_NONE; + p->ammo_count = 0; + if (weapon && weapon->ammo_kind == AMMO_LOADED_DART) { + p->ammo_count = p->equipment[FC_EQUIP_SLOT_WEAPON].charges; + p->ranged_strength_bonus += 17; /* preset's loaded adamant darts */ + } else if (usable_ammo) { + p->ammo_count = p->equipment[FC_EQUIP_SLOT_AMMO].quantity; + } +} + +static int potion_doses(int id) { + switch (id) { + case 2434: return 4; + case 139: return 3; + case 141: return 2; + case 143: return 1; + default: return 0; + } +} + +static void reset_supplies(FcPlayer *p, int sharks, int doses) { + if (sharks < 0) sharks = 0; + if (sharks > FC_MAX_SHARKS) sharks = FC_MAX_SHARKS; + if (doses < 0) doses = 0; + if (doses > FC_MAX_PRAYER_DOSES) doses = FC_MAX_PRAYER_DOSES; + memset(p->inventory, 0, sizeof(p->inventory)); + p->sharks_remaining = sharks; + p->prayer_doses_remaining = doses; + const int pots[] = {0, 143, 141, 139, 2434}; + int slot = 0; + while (doses > 0) { + int count = doses > 4 ? 4 : doses; + p->inventory[slot++] = (FcItemStack){pots[count], 1, 0}; + doses -= count; + } + for (int i = 0; i < sharks; i++) + p->inventory[slot++] = (FcItemStack){385, 1, 0}; + p->selected_food_slot = p->selected_potion_slot = -1; +} + +void fc_set_initial_supplies(FcState *state, int sharks, int prayer_doses) { + /* Reset-time configuration only, not a gameplay inventory refill API. */ + if (state && state->tick == 0) + reset_supplies(&state->player, sharks, prayer_doses); +} + +void fc_items_init(FcPlayer *p, const FcLoadout *loadout) { + memset(p->equipment, 0, sizeof(p->equipment)); + for (int i = 0; i < loadout->equipment_count; i++) { + const FcLoadoutEquipmentItem *item = &loadout->equipment[i]; + if (item->item_id == 810) continue; /* darts are loaded in the blowpipe */ + p->equipment[item->slot] = (FcItemStack){ + (int)item->item_id, item->slot == FC_EQUIP_SLOT_AMMO ? loadout->ammo : 1, + item->item_id == 12926 ? loadout->ammo : 0 + }; + } + recalculate_equipment(p); + reset_supplies(p, FC_MAX_SHARKS, FC_MAX_PRAYER_DOSES); +} + +const char *fc_item_result_message(FcItemResult result) { + switch (result) { + case FC_ITEM_OK: return ""; + case FC_ITEM_NO_SPACE: return "You don't have enough inventory space."; + case FC_ITEM_REQUIREMENTS: return "Your levels are too low to wear this item."; + case FC_ITEM_BUSY: return "You can't change equipment right now."; + default: return "You can't use that item here."; + } +} + +static int free_slot(const FcItemStack inventory[FC_INVENTORY_SLOTS]) { + for (int i = 0; i < FC_INVENTORY_SLOTS; i++) + if (!inventory[i].item_id) return i; + return -1; +} + +static int add_to_inventory(FcItemStack inventory[FC_INVENTORY_SLOTS], FcItemStack item) { + if (!item.item_id) return 1; + const FcItemDef *def = fc_item_definition(item.item_id); + if (!def || item.quantity <= 0) return 0; + if (def->stackable) { + for (int i = 0; i < FC_INVENTORY_SLOTS; i++) { + if (inventory[i].item_id != item.item_id) continue; + if (item.quantity > INT_MAX - inventory[i].quantity) return 0; + inventory[i].quantity += item.quantity; + return 1; + } + } + int index = free_slot(inventory); + if (index < 0) return 0; + inventory[index] = item; + return 1; +} + +static int can_change_items(const FcState *state) { + return state && !state->terminal && state->player.current_hp > 0; +} + +FcItemResult fc_equip_item(FcState *state, int index) { + if (!can_change_items(state)) return FC_ITEM_BUSY; + FcPlayer *p = &state->player; + if (index < 0 || index >= FC_INVENTORY_SLOTS) return FC_ITEM_INVALID; + FcItemStack incoming = p->inventory[index]; + const FcItemDef *item = fc_item_definition(incoming.item_id); + if (!item || item->slot < 0 || incoming.quantity <= 0 || + (!item->stackable && incoming.quantity != 1)) return FC_ITEM_INVALID; + if (p->ranged_level < item->ranged_level || p->defence_level < item->defence_level || + p->max_hp / 10 < item->hitpoints_level) return FC_ITEM_REQUIREMENTS; + /* Plan on copies, including both hands. A failed secondary transfer cannot + * partially equip an item, remove a shield, or interrupt combat. */ + FcItemStack inventory[FC_INVENTORY_SLOTS], equipment[FC_EQUIPMENT_SLOTS]; + memcpy(inventory, p->inventory, sizeof(inventory)); + memcpy(equipment, p->equipment, sizeof(equipment)); + FcItemStack *worn = &equipment[item->slot]; + if (item->stackable && worn->item_id == incoming.item_id) { + int amount = INT_MAX - worn->quantity; + if (amount > incoming.quantity) amount = incoming.quantity; + if (!amount) return FC_ITEM_NO_SPACE; + worn->quantity += amount; + inventory[index].quantity -= amount; + if (!inventory[index].quantity) inventory[index] = (FcItemStack){0}; + } else { + inventory[index] = *worn; + *worn = incoming; + } + const FcItemDef *weapon = fc_item_definition(equipment[FC_EQUIP_SLOT_WEAPON].item_id); + int displaced = item->two_handed ? FC_EQUIP_SLOT_SHIELD : + item->slot == FC_EQUIP_SLOT_SHIELD && weapon && weapon->two_handed ? + FC_EQUIP_SLOT_WEAPON : -1; + if (displaced >= 0 && equipment[displaced].item_id) { + /* RSMod returns the conflict to the source slot when it wasn't needed + * for a primary swap, otherwise to the first free inventory slot. */ + if (!inventory[index].item_id) inventory[index] = equipment[displaced]; + else if (!add_to_inventory(inventory, equipment[displaced])) return FC_ITEM_NO_SPACE; + equipment[displaced] = (FcItemStack){0}; + } + memcpy(p->inventory, inventory, sizeof(inventory)); + memcpy(p->equipment, equipment, sizeof(equipment)); + recalculate_equipment(p); + p->attack_target_idx = -1; /* held-item action interrupts interaction */ + p->approach_target = 0; + p->approach_target_x = p->approach_target_y = -1; + p->approach_target_size = 0; + return FC_ITEM_OK; +} + +FcItemResult fc_unequip_item(FcState *state, int slot) { + if (!can_change_items(state)) return FC_ITEM_BUSY; + if (slot < 0 || slot >= FC_EQUIPMENT_SLOTS || + !state->player.equipment[slot].item_id) return FC_ITEM_INVALID; + FcPlayer *p = &state->player; + FcItemStack inventory[FC_INVENTORY_SLOTS]; + memcpy(inventory, p->inventory, sizeof(inventory)); + if (!add_to_inventory(inventory, p->equipment[slot])) return FC_ITEM_NO_SPACE; + memcpy(p->inventory, inventory, sizeof(inventory)); + p->equipment[slot] = (FcItemStack){0}; + recalculate_equipment(p); + /* Worn-item Remove does not cancel the existing interaction. */ + return FC_ITEM_OK; +} + +FcItemResult fc_inventory_swap(FcState *state, int first, int second) { + if (!can_change_items(state)) return FC_ITEM_BUSY; + if (first < 0 || first >= FC_INVENTORY_SLOTS || second < 0 || + second >= FC_INVENTORY_SLOTS) return FC_ITEM_INVALID; + FcItemStack temp = state->player.inventory[first]; + state->player.inventory[first] = state->player.inventory[second]; + state->player.inventory[second] = temp; + state->player.selected_food_slot = state->player.selected_potion_slot = -1; + return FC_ITEM_OK; +} + +FcItemResult fc_select_consumable(FcState *state, int slot) { + if (!can_change_items(state)) return FC_ITEM_BUSY; + if (slot < 0 || slot >= FC_INVENTORY_SLOTS) return FC_ITEM_INVALID; + int id = state->player.inventory[slot].item_id; + if (id == 385) state->player.selected_food_slot = slot; + else if (potion_doses(id)) state->player.selected_potion_slot = slot; + else return FC_ITEM_INVALID; + return FC_ITEM_OK; +} + +void fc_items_consume(FcPlayer *p, int potion) { + int selected = potion ? p->selected_potion_slot : p->selected_food_slot; + for (int n = -1; n < FC_INVENTORY_SLOTS; n++) { + int slot = n < 0 ? selected : n; + if (slot < 0 || slot >= FC_INVENTORY_SLOTS) continue; + FcItemStack *item = &p->inventory[slot]; + int doses = potion_doses(item->item_id); + if (potion && doses) { + const int replacement[] = {229, 143, 141, 139}; + item->item_id = replacement[doses - 1]; + break; + } + if (!potion && item->item_id == 385) { + *item = (FcItemStack){0}; + break; + } + } + if (potion) p->prayer_doses_remaining--; + else p->sharks_remaining--; +} + +void fc_items_spend_ammo(FcPlayer *p) { + p->ammo_count--; + FcItemStack *weapon = &p->equipment[FC_EQUIP_SLOT_WEAPON]; + if (weapon->item_id == 12926) weapon->charges--; + else { + FcItemStack *ammo = &p->equipment[FC_EQUIP_SLOT_AMMO]; + if (ammo->quantity > 0 && --ammo->quantity == 0) { + *ammo = (FcItemStack){0}; + recalculate_equipment(p); + } + } +} + + /* Loadouts */ /* * LOADOUT A: Mid-level — Black D'hide + Rune Crossbow @@ -2806,7 +3257,7 @@ const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { {FC_EQUIP_SLOT_LEGS, 27241, 0, "Masori chaps (f)"}, {FC_EQUIP_SLOT_HANDS, 26235, 0, "Zaryte vambraces"}, {FC_EQUIP_SLOT_FEET, 13237, 0, "Pegasian boots"}, - {FC_EQUIP_SLOT_RING, 25487, 0, "Venator ring"}, + {FC_EQUIP_SLOT_RING, 28310, 0, "Venator ring"}, }, .model_item_count = 8, .model_item_ids = {27235, 22109, 19547, 20997, 27238, 27241, 26235, 13237}, @@ -4359,8 +4810,13 @@ typedef enum { static int fc_route_goal_reached( FcRouteGoalKind kind, int x, int y, int dst_x, int dst_y, int dst_size, int attack_range, + const uint8_t walkable[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], + const uint8_t movement_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT], const uint8_t los_flags[FC_ARENA_WIDTH][FC_ARENA_HEIGHT]) { if (kind == FC_ROUTE_EXACT) return x == dst_x && y == dst_y; + if (attack_range == FC_ROUTE_MELEE_RANGE) + return fc_npc_can_melee_player(x, y, dst_x, dst_y, dst_size, + walkable, movement_flags); int distance = fc_area_distance(x, y, dst_x, dst_y, dst_size); return distance > 0 && distance <= attack_range && fc_has_los_between_areas(x, y, 1, dst_x, dst_y, dst_size, @@ -4421,7 +4877,7 @@ static int fc_bfs_route( while (qh < qt) { int cx = qx[qh], cy = qy[qh]; qh++; if (fc_route_goal_reached(goal_kind, cx, cy, dst_x, dst_y, dst_size, - attack_range, los_flags)) { + attack_range, walkable, movement_flags, los_flags)) { found_x = cx; found_y = cy; break; @@ -5265,20 +5721,7 @@ static void apply_loadout_combat_fields(FcPlayer* p, p->ranged_level = loadout->ranged_lvl; p->prayer_level = loadout->prayer_lvl; p->magic_level = loadout->magic_lvl; - p->weapon_kind = loadout->weapon_kind; - p->weapon_uses_ammo = loadout->weapon_uses_ammo; - p->crystal_piece_mask = loadout->crystal_piece_mask; - p->weapon_speed = loadout->weapon_speed; - p->weapon_range = loadout->weapon_range; - p->ranged_attack_bonus = loadout->ranged_atk; - p->ranged_strength_bonus = loadout->ranged_str; - p->defence_stab = loadout->def_stab; - p->defence_slash = loadout->def_slash; - p->defence_crush = loadout->def_crush; - p->defence_magic = loadout->def_magic; - p->defence_ranged = loadout->def_ranged; - p->prayer_bonus = loadout->prayer_bonus; - p->ammo_count = loadout->ammo; + fc_items_init(p, loadout); } static void init_player(FcPlayer* p) { @@ -5290,8 +5733,6 @@ static void init_player(FcPlayer* p) { p->current_prayer = p->max_prayer; p->prayer = PRAYER_NONE; p->prayer_at_tick_start = PRAYER_NONE; - p->sharks_remaining = FC_MAX_SHARKS; - p->prayer_doses_remaining = FC_MAX_PRAYER_DOSES; p->attack_timer = 0; p->food_timer = 0; p->potion_timer = 0; @@ -6197,7 +6638,7 @@ static void apply_player_supplies(FcState* state, int eat_action, if (player->current_hp > player->max_hp) { player->current_hp = player->max_hp; } - player->sharks_remaining--; + fc_items_consume(player, 0); *cooldown_timer = cooldown; player->food_eaten_this_tick = 1; state->food_used_this_tick = 1; @@ -6219,11 +6660,12 @@ static void apply_player_supplies(FcState* state, int eat_action, if (player->current_prayer > player->max_prayer) { player->current_prayer = player->max_prayer; } - player->prayer_doses_remaining--; + fc_items_consume(player, 1); player->potion_timer = FC_POTION_COOLDOWN_TICKS; player->potion_used_this_tick = 1; state->prayer_potion_used_this_tick = 1; } + player->selected_food_slot = player->selected_potion_slot = -1; } static void prepare_player_interaction(FcState* state, int explicit_move, @@ -6262,20 +6704,26 @@ static void prepare_player_interaction(FcState* state, int explicit_move, static void launch_player_attack(FcState* state, FcNpc* target, int distance) { FcPlayer* player = &state->player; - int att_roll = fc_player_ranged_attack_roll(player, target); + int melee = player->weapon_kind == FC_WEAPON_UNARMED; + /* Unarmed Punch is accurate/crush (+3 Attack). Fight Caves NPCs all + * have zero crush defence bonus, including the ranged-resistant healers. */ + int att_roll = melee ? (player->attack_level + 11) * + (player->melee_attack_bonus + 64) : fc_player_ranged_attack_roll(player, target); const FcNpcStats* target_stats = fc_npc_get_stats(target->npc_type); int def_roll = fc_npc_def_roll(target_stats->def_level, - target_stats->ranged_def_bonus); + melee ? 0 : target_stats->ranged_def_bonus); float chance = fc_hit_chance(att_roll, def_roll); int hit = fc_rng_float(state) < chance ? 1 : 0; - int final_max_hit_hp = fc_player_ranged_final_max_hit_hp(player, target); + int final_max_hit_hp = melee ? (320 + (player->strength_level + 8) * + (player->melee_strength_bonus + 64)) / 640 : + fc_player_ranged_final_max_hit_hp(player, target); int damage = hit ? fc_roll_player_damage_tenths(state, final_max_hit_hp) : 0; - int delay = fc_ranged_hit_delay(distance); + int delay = melee ? 1 : fc_ranged_hit_delay(distance); fc_queue_pending_hit(target->pending_hits, &target->num_pending_hits, FC_MAX_PENDING_HITS, damage, delay, - ATTACK_RANGED, -1, 0); + melee ? ATTACK_MELEE : ATTACK_RANGED, -1, 0); state->attack_attempt_this_tick = 1; state->render_events.player_attack_fired = 1; state->render_events.player_attack_source_x = player->x; @@ -6291,7 +6739,7 @@ static void launch_player_attack(FcState* state, FcNpc* target, int distance) { } player->attack_timer = player->weapon_speed; if (player->weapon_uses_ammo && player->ammo_count > 0) { - player->ammo_count--; + fc_items_spend_ammo(player); } player->hit_landed_this_tick = 1; } @@ -6332,6 +6780,11 @@ static int process_player_target(FcState* state, player->x, player->y, 1, target->x, target->y, target->size, state->los_flags); int target_can_fire = dist > 0 && dist <= weapon_range && has_los; + if (player->weapon_kind == FC_WEAPON_UNARMED) { + weapon_range = FC_ROUTE_MELEE_RANGE; + target_can_fire = fc_npc_can_melee_player(player->x, player->y, + target->x, target->y, target->size, state->walkable, state->movement_flags); + } int target_ready = player->attack_timer <= 0; record_player_target_held(state, target); @@ -6361,6 +6814,9 @@ static int process_player_target(FcState* state, fc_has_los_between_areas( rx, ry, 1, target->x, target->y, target->size, state->los_flags); + if (player->weapon_kind == FC_WEAPON_UNARMED) + route_endpoint_can_fire = fc_npc_can_melee_player(rx, ry, + target->x, target->y, target->size, state->walkable, state->movement_flags); } if (!target_can_fire && player->approach_target && diff --git a/ocean/fight_caves/tools.py b/ocean/fight_caves/tools.py index f44c7fd0cf..c05d8ebbdf 100644 --- a/ocean/fight_caves/tools.py +++ b/ocean/fight_caves/tools.py @@ -934,7 +934,16 @@ def validate_checkpoint_marker(marker: Path, preflight: dict[str, Any]) -> None: payload = json.loads(marker.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise ContractError(f"invalid checkpoint contract sidecar: {marker}") from exc - if payload.get("contract") != preflight["contract"]: + actual = payload.get("contract") + expected = preflight["contract"] + # v5 only adds manually controlled inventory/equipment to the state hash. + # Policy weights do not serialize that state; allow only v4 -> v5 when + # every other contract field is identical, just as in the v38 evaluator. + if isinstance(actual, dict): + actual = dict(actual) + if actual.get("state_hash_version") == 4 and expected.get("state_hash_version") == 5: + actual["state_hash_version"] = 5 + if actual != expected: raise ContractError( f"checkpoint contract does not match compiled Fight Caves: {marker}" ) diff --git a/ocean/fight_caves/ui.h b/ocean/fight_caves/ui.h index 7570df925c..d35f87eafb 100644 --- a/ocean/fight_caves/ui.h +++ b/ocean/fight_caves/ui.h @@ -215,8 +215,11 @@ static const RuneCUiWornButtonRef RUNEC_OSRS_WORN_BUTTONS[] = { #include "raylib.h" +#define FC_OSRS_MENU_FONT_SIZE 16.0f + int fc_osrs_text_init(void); void fc_osrs_text_shutdown(void); +Font fc_osrs_menu_font(void); void fc_osrs_draw_text(const char* text, int x, int y, int font_size, Color color); int fc_osrs_measure_text(const char* text, int font_size); @@ -253,6 +256,27 @@ int fc_minimap_click_to_tile(float map_x, float map_y, float player_x, int* tile_x, int* tile_y); +/* Context Menu */ + +#include + +/* RuneC's client-style geometry, shared by menu drawing and hit testing. */ +#define FC_MENU_HEADER_HEIGHT 24 +#define FC_MENU_ROW_HEIGHT 19 +#define FC_MENU_DISMISS_MARGIN 10 + +typedef struct { int x, y, width, height; } FcMenuLayout; +typedef struct { const char *name; int level; } FcNpcMenuInfo; + +FcNpcMenuInfo fc_menu_npc_info(int npc_type); +uint32_t fc_menu_level_color(int player_level, int npc_level); + +FcMenuLayout fc_menu_layout(int x, int y, int screen_width, int screen_height, + int text_width, int rows); +int fc_menu_contains(FcMenuLayout menu, int x, int y, int margin); +int fc_menu_action_at(FcMenuLayout menu, int rows, int x, int y); + + /* Ui */ #include "raylib.h" @@ -299,6 +323,7 @@ typedef enum RuneCUiIntentKind { RUNEC_UI_INTENT_AUTO_RETALIATE, RUNEC_UI_INTENT_SPECIAL_ATTACK, RUNEC_UI_INTENT_CONTEXT_ACTION, + RUNEC_UI_INTENT_WORLD_ACTION, RUNEC_UI_INTENT_INVENTORY_ACTION, RUNEC_UI_INTENT_EQUIPMENT_ACTION, RUNEC_UI_INTENT_INVENTORY_DRAG, @@ -323,6 +348,7 @@ typedef struct RuneCUiSlot { int quantity; char label[24]; int enabled; + const char *action; /* supplied by the inventory owner */ } RuneCUiSlot; typedef enum RuneCUiMinimapDotKind { @@ -356,7 +382,8 @@ typedef enum RuneCUiContextSourceKind { RUNEC_UI_CONTEXT_INVENTORY, RUNEC_UI_CONTEXT_EQUIPMENT, RUNEC_UI_CONTEXT_PRAYER, - RUNEC_UI_CONTEXT_SPELL + RUNEC_UI_CONTEXT_SPELL, + RUNEC_UI_CONTEXT_WORLD } RuneCUiContextSourceKind; typedef enum RuneCUiSelectedTargetKind { @@ -387,8 +414,6 @@ typedef struct RuneCUiState { RuneCUiSlot inventory[RUNEC_UI_INV_SLOT_COUNT]; RuneCUiSlot equipment[RUNEC_UI_EQUIP_SLOT_COUNT]; - int selected_inventory_slot; - int selected_equipment_slot; int selected_combat_style; int auto_retaliate; int special_attack_enabled; @@ -411,6 +436,8 @@ typedef struct RuneCUiState { int context_open; Vector2 context_pos; + Color context_target_color; + int context_combat_level; char context_title[48]; char context_actions[RUNEC_UI_CONTEXT_ACTIONS][32]; int context_action_count; @@ -445,6 +472,10 @@ void runec_ui_set_combat_weapon_name(RuneCUiState *ui, const char *name); void runec_ui_set_combat_style_profile(RuneCUiState *ui, int core_weapon_category); void runec_ui_clear_selected_target(RuneCUiState *ui); int runec_ui_handle_input(RuneCUiState *ui, int screen_w, int screen_h); +void runec_ui_open_world_context(RuneCUiState *ui, Vector2 pos, int npc_type, int can_walk); +void runec_ui_open_prayer_context(RuneCUiState *ui, Vector2 pos, int slot); +void runec_ui_close_context(RuneCUiState *ui); +void runec_ui_draw_context(const RuneCUiState *ui); void runec_ui_draw(RuneCUiState *ui, int screen_w, int screen_h); Rectangle runec_ui_chat_panel_rect(int screen_w, int screen_h); const char *runec_ui_tab_name(RuneCUiTab tab); @@ -1034,9 +1065,11 @@ void runec_ui_draw_text_shadow(const RuneCUiAssets *assets, const char *text, /* Osrs Text */ #include +#include #include #define FC_OSRS_FONT_ASSET "data/fonts/p11_full.png" +#define FC_OSRS_MENU_FONT_ASSET "data/fonts/runescape_bold.ttf" #define FC_OSRS_FONT_CELL_SIZE 20 #define FC_OSRS_FONT_COLUMNS 16 #define FC_OSRS_FONT_GLYPHS 256 @@ -1050,6 +1083,7 @@ typedef struct { } FcOsrsGlyph; static Texture2D g_font_texture; +static Font g_menu_font; static FcOsrsGlyph g_glyphs[FC_OSRS_FONT_GLYPHS]; static int g_font_height; static int g_font_ready; @@ -1133,6 +1167,9 @@ static void build_glyph_metrics(const Color* pixels, int image_width, } void fc_osrs_text_shutdown(void) { + if (g_menu_font.texture.id != 0) + UnloadFont(g_menu_font); + g_menu_font = (Font){0}; if (g_font_texture.id != 0) UnloadTexture(g_font_texture); g_font_texture = (Texture2D){0}; @@ -1141,13 +1178,45 @@ void fc_osrs_text_shutdown(void) { g_font_ready = 0; } +static int init_menu_font(void) { + g_menu_font = fc_load_font_asset(FC_OSRS_MENU_FONT_ASSET, + (int)FC_OSRS_MENU_FONT_SIZE); + if (g_menu_font.texture.id == 0) return 0; + /* RuneC ui_assets.c:runec_make_font_texture_crisp. Rasterize at the + * displayed size, then threshold alpha and use point sampling. */ + Image image = LoadImageFromTexture(g_menu_font.texture); + if (!image.data) return 0; + ImageFormat(&image, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8); + Color *pixels = image.data; + for (int i = 0; i < image.width * image.height; i++) + pixels[i].a = pixels[i].a >= 160 ? 255 : 0; + Texture2D texture = LoadTextureFromImage(image); + UnloadImage(image); + if (texture.id == 0) return 0; + UnloadTexture(g_menu_font.texture); + g_menu_font.texture = texture; + SetTextureFilter(g_menu_font.texture, TEXTURE_FILTER_POINT); + return 1; +} + +Font fc_osrs_menu_font(void) { + return g_menu_font; +} + int fc_osrs_text_init(void) { fc_osrs_text_shutdown(); + if (!init_menu_font()) { + fprintf(stderr, "error: failed to load required menu font %s\n", + FC_OSRS_MENU_FONT_ASSET); + fc_osrs_text_shutdown(); + return 0; + } Image image = fc_load_image_asset(FC_OSRS_FONT_ASSET); if (!image.data || image.width != 320 || image.height != 320) { if (image.data) UnloadImage(image); + fc_osrs_text_shutdown(); return 0; } ImageFormat(&image, PIXELFORMAT_UNCOMPRESSED_R8G8B8A8); @@ -1257,6 +1326,7 @@ int fc_osrs_measure_text(const char* text, int font_size) { } #undef FC_OSRS_FONT_ASSET +#undef FC_OSRS_MENU_FONT_ASSET #undef FC_OSRS_FONT_CELL_SIZE #undef FC_OSRS_FONT_COLUMNS #undef FC_OSRS_FONT_GLYPHS @@ -1380,6 +1450,62 @@ int fc_minimap_click_to_tile(float map_x, float map_y, float player_x, } +/* Context Menu */ +FcNpcMenuInfo fc_menu_npc_info(int npc_type) { + /* Fight Cave NPC display levels, not attack-skill levels or Inferno healers. */ + static const FcNpcMenuInfo npcs[] = { + {"", 0}, {"Tz-Kih", 22}, {"Tz-Kek", 45}, {"Tz-Kek", 22}, + {"Tok-Xil", 90}, {"Yt-MejKot", 180}, {"Ket-Zek", 360}, + {"TzTok-Jad", 702}, {"Yt-HurKot", 108} + }; + if (npc_type < 0 || npc_type >= (int)(sizeof(npcs) / sizeof(npcs[0]))) npc_type = 0; + return npcs[npc_type]; +} + +uint32_t fc_menu_level_color(int player_level, int npc_level) { + /* Client3 entry/client.c:getCombatLevelColorTag and defines.h RGB ramps. */ + int difference = player_level - npc_level; + if (difference < -9) return 0xff0000; + if (difference < -6) return 0xff3000; + if (difference < -3) return 0xff7000; + if (difference < 0) return 0xffb000; + if (difference > 9) return 0x00ff00; + if (difference > 6) return 0x40ff00; + if (difference > 3) return 0x80ff00; + if (difference > 0) return 0xc0ff00; + return 0xffff00; +} + +FcMenuLayout fc_menu_layout(int x, int y, int screen_width, int screen_height, + int text_width, int rows) { + if (rows < 0) rows = 0; + FcMenuLayout menu = {0, 0, text_width + 12, + FC_MENU_HEADER_HEIGHT + rows * FC_MENU_ROW_HEIGHT + 4}; + if (menu.width < 1) menu.width = 1; + if (screen_width > 0 && menu.width > screen_width) menu.width = screen_width; + menu.x = x - menu.width / 2; + menu.y = y; + if (menu.x + menu.width > screen_width) menu.x = screen_width - menu.width; + if (menu.y + menu.height > screen_height) menu.y = screen_height - menu.height; + if (menu.x < 0) menu.x = 0; + if (menu.y < 0) menu.y = 0; + return menu; +} + +int fc_menu_contains(FcMenuLayout menu, int x, int y, int margin) { + return x >= menu.x - margin && x < menu.x + menu.width + margin && + y >= menu.y - margin && y < menu.y + menu.height + margin; +} + +int fc_menu_action_at(FcMenuLayout menu, int rows, int x, int y) { + if (!fc_menu_contains(menu, x, y, 0)) return -1; + int offset = y - menu.y - FC_MENU_HEADER_HEIGHT; + if (offset < 0) return -1; + int row = offset / FC_MENU_ROW_HEIGHT; + return row < rows ? row : -1; +} + + /* Ui */ #include #include @@ -1529,8 +1655,8 @@ static const char *spell_name(int slot) { } static const char *g_equipment_names[RUNEC_UI_EQUIP_SLOT_COUNT] = { - "Head", "Cape", "Neck", "Weapon", "Body", "Shield", "Ammo", - "Legs", "Unused", "Hands", "Feet", "Unused", "Ring", "Quiver", + "Head", "Cape", "Neck", "Weapon", "Body", "Shield", "Unused", + "Legs", "Unused", "Hands", "Feet", "Unused", "Ring", "Ammo", }; static const Rectangle g_equipment_offsets[RUNEC_UI_EQUIP_SLOT_COUNT] = { @@ -1540,14 +1666,14 @@ static const Rectangle g_equipment_offsets[RUNEC_UI_EQUIP_SLOT_COUNT] = { {21, 82, 36, 36}, {77, 82, 36, 36}, {133, 82, 36, 36}, - {133, 43, 36, 36}, + {-1000, -1000, 0, 0}, {77, 122, 36, 36}, {-1000, -1000, 0, 0}, {21, 162, 36, 36}, {77, 162, 36, 36}, {-1000, -1000, 0, 0}, {133, 162, 36, 36}, - {118, 43, 36, 36}, + {133, 43, 36, 36}, }; static const char *g_worn_icon_names[RUNEC_UI_EQUIP_SLOT_COUNT] = { @@ -1982,12 +2108,16 @@ static void set_context(RuneCUiState *ui, Vector2 pos, const char *title, const char **actions, int action_count) { ui->context_open = 1; ui->context_pos = pos; + ui->context_target_color = OSRS_ORANGE; + ui->context_combat_level = 0; + ui->drag.active = 0; copy_text(ui->context_title, sizeof(ui->context_title), title); ui->context_source_kind = RUNEC_UI_CONTEXT_NONE; ui->context_source_slot = -1; ui->context_source_item_id = 0; if (action_count > RUNEC_UI_CONTEXT_ACTIONS) action_count = RUNEC_UI_CONTEXT_ACTIONS; + if (action_count < 0) action_count = 0; ui->context_action_count = action_count; for (int i = 0; i < action_count; i++) { copy_text(ui->context_actions[i], sizeof(ui->context_actions[i]), actions[i]); @@ -2003,6 +2133,64 @@ static void set_context_source(RuneCUiState *ui, ui->context_source_item_id = source_item_id; } +void runec_ui_close_context(RuneCUiState *ui) { + ui->context_open = 0; + ui->context_source_kind = RUNEC_UI_CONTEXT_NONE; + ui->context_source_slot = -1; + ui->context_source_item_id = 0; +} + +void runec_ui_open_world_context(RuneCUiState *ui, Vector2 pos, int npc_type, int can_walk) { + FcNpcMenuInfo npc = fc_menu_npc_info(npc_type); + const char *actions[4]; + int count = 0; + if (npc.level) actions[count++] = "Attack"; + if (can_walk) actions[count++] = "Walk here"; + if (npc.level) actions[count++] = "Examine"; + actions[count++] = "Cancel"; + set_context(ui, pos, npc.name, actions, count); + ui->context_combat_level = npc.level; + ui->context_source_kind = RUNEC_UI_CONTEXT_WORLD; + ui->context_target_color = OSRS_YELLOW; + runec_ui_clear_selected_target(ui); +} + +void runec_ui_open_prayer_context(RuneCUiState *ui, Vector2 pos, int slot) { + if (slot < 0 || slot >= 25) return; + const char *actions[] = {ui->active_prayers & (1u << slot) ? "Deactivate" : "Activate", "Cancel"}; + set_context(ui, pos, g_prayer_names[slot], actions, 2); + set_context_source(ui, RUNEC_UI_CONTEXT_PRAYER, slot, 0); +} + +static int context_has_target(const RuneCUiState *ui, const char *action) { + return ui->context_title[0] && strcmp(action, "Cancel") && strcmp(action, "Walk here"); +} + +static void context_level_suffix(const RuneCUiState *ui, char suffix[32]) { + suffix[0] = '\0'; + if (ui->context_combat_level > 0) + snprintf(suffix, 32, " (level-%d)", ui->context_combat_level); +} + +static FcMenuLayout context_layout(const RuneCUiState *ui) { + Font font = fc_osrs_menu_font(); + float width = MeasureTextEx(font, "Choose Option", FC_OSRS_MENU_FONT_SIZE, 0).x; + char suffix[32]; + context_level_suffix(ui, suffix); + for (int i = 0; i < ui->context_action_count; i++) { + char text[128]; + const char *action = ui->context_actions[i]; + snprintf(text, sizeof(text), "%s%s%s%s", action, + context_has_target(ui, action) ? " " : "", + context_has_target(ui, action) ? ui->context_title : "", + context_has_target(ui, action) ? suffix : ""); + float row_width = MeasureTextEx(font, text, FC_OSRS_MENU_FONT_SIZE, 0).x; + if (row_width > width) width = row_width; + } + return fc_menu_layout((int)ui->context_pos.x, (int)ui->context_pos.y, + GetScreenWidth(), GetScreenHeight(), (int)ceilf(width), ui->context_action_count); +} + void runec_ui_clear_selected_target(RuneCUiState *ui) { if (!ui) return; @@ -2036,8 +2224,6 @@ static void set_selected_spell_target(RuneCUiState *ui, int slot, void runec_ui_init(RuneCUiState *ui) { memset(ui, 0, sizeof(*ui)); ui->active_tab = RUNEC_UI_TAB_SKILLS; - ui->selected_inventory_slot = -1; - ui->selected_equipment_slot = -1; ui->context_source_slot = -1; ui->selected_target.source_slot = -1; ui->drag.source_slot = -1; @@ -2085,28 +2271,6 @@ void runec_ui_init(RuneCUiState *ui) { ui->minimap_texture_ready = 1; } - ui->inventory[0] = (RuneCUiSlot){6570, 6570, 1, "Fire cape", 1}; - ui->inventory[1] = (RuneCUiSlot){21295, 21295, 1, "Infernal cape", 1}; - ui->inventory[2] = (RuneCUiSlot){1042, 1042, 1, "Blue partyhat", 1}; - ui->inventory[3] = (RuneCUiSlot){1044, 1044, 1, "Green partyhat", 1}; - ui->inventory[4] = (RuneCUiSlot){1046, 1046, 1, "Purple partyhat", 1}; - ui->inventory[5] = (RuneCUiSlot){1048, 1048, 1, "White partyhat", 1}; - ui->inventory[6] = (RuneCUiSlot){4151, 4151, 1, "Abyssal whip", 1}; - ui->inventory[7] = (RuneCUiSlot){11802, 11802, 1, "Armadyl godsword", 1}; - ui->inventory[8] = (RuneCUiSlot){11832, 11832, 1, "Bandos chestplate", 1}; - ui->inventory[9] = (RuneCUiSlot){11834, 11834, 1, "Bandos tassets", 1}; - ui->inventory[10] = (RuneCUiSlot){26382, 26382, 1, "Torva full helm", 1}; - ui->inventory[11] = (RuneCUiSlot){26384, 26384, 1, "Torva platebody", 1}; - ui->inventory[12] = (RuneCUiSlot){26386, 26386, 1, "Torva platelegs", 1}; - ui->inventory[13] = (RuneCUiSlot){10350, 10350, 1, "3a full helmet", 1}; - ui->inventory[14] = (RuneCUiSlot){10348, 10348, 1, "3a platebody", 1}; - ui->inventory[15] = (RuneCUiSlot){10346, 10346, 1, "3a platelegs", 1}; - ui->inventory[16] = (RuneCUiSlot){10352, 10352, 1, "3a kiteshield", 1}; - ui->inventory[17] = (RuneCUiSlot){995, 1004, 10000000, "Coins", 1}; - ui->equipment[0] = (RuneCUiSlot){11826, 11826, 1, "Helm", 1}; - ui->equipment[3] = (RuneCUiSlot){4151, 4151, 1, "Abyssal whip", 1}; - ui->equipment[4] = (RuneCUiSlot){11828, 11828, 1, "Body", 1}; - ui->equipment[7] = (RuneCUiSlot){11830, 11830, 1, "Legs", 1}; } @@ -2171,21 +2335,32 @@ static int handle_context_click(RuneCUiState *ui, Vector2 mouse) { if (!ui->context_open) return 0; - Rectangle box = {ui->context_pos.x, ui->context_pos.y, - 158.0f, 24.0f + ui->context_action_count * 20.0f}; - if (!CheckCollisionPointRec(mouse, box)) { - ui->context_open = 0; - ui->context_source_kind = RUNEC_UI_CONTEXT_NONE; - ui->context_source_slot = -1; - ui->context_source_item_id = 0; - return 0; + FcMenuLayout menu = context_layout(ui); + if (!fc_menu_contains(menu, (int)mouse.x, (int)mouse.y, 0)) { + runec_ui_close_context(ui); + return 1; /* Dismissal must not click through into the scene/UI. */ } for (int i = 0; i < ui->context_action_count; i++) { - Rectangle item = {box.x + 4, box.y + 22 + i * 20.0f, box.width - 8, 18}; - if (CheckCollisionPointRec(mouse, item)) { + if (fc_menu_action_at(menu, ui->context_action_count, (int)mouse.x, (int)mouse.y) == i) { const char *action = ui->context_actions[i]; - if (ui->context_source_kind == RUNEC_UI_CONTEXT_INVENTORY) { + if (strcmp(action, "Cancel") == 0) { + runec_ui_close_context(ui); + return 1; + } + /* Do not act on a different item if the original slot changed + * while its menu was open (consumption, ammo depletion, etc.). */ + if ((ui->context_source_kind == RUNEC_UI_CONTEXT_INVENTORY && + ui->inventory[ui->context_source_slot].item_id != ui->context_source_item_id) || + (ui->context_source_kind == RUNEC_UI_CONTEXT_EQUIPMENT && + ui->equipment[ui->context_source_slot].item_id != ui->context_source_item_id)) { + runec_ui_close_context(ui); + return 1; + } + if (ui->context_source_kind == RUNEC_UI_CONTEXT_WORLD) { + ui->last_intent.kind = RUNEC_UI_INTENT_WORLD_ACTION; + ui->last_intent.primary = i; + } else if (ui->context_source_kind == RUNEC_UI_CONTEXT_INVENTORY) { if (strcmp(action, "Use") == 0) { set_selected_item_target(ui, ui->context_source_slot); ui->last_intent.kind = RUNEC_UI_INTENT_SELECTED_ITEM; @@ -2201,9 +2376,10 @@ static int handle_context_click(RuneCUiState *ui, Vector2 mouse) { ui->last_intent.primary = ui->context_source_slot; ui->last_intent.secondary = i; } else if (ui->context_source_kind == RUNEC_UI_CONTEXT_PRAYER) { - if (strcmp(action, "Activate") == 0) { + if (strcmp(action, "Activate") == 0 || strcmp(action, "Deactivate") == 0) { ui->last_intent.kind = RUNEC_UI_INTENT_PRAYER_SLOT; ui->last_intent.primary = ui->context_source_slot; + ui->last_intent.secondary = strcmp(action, "Deactivate") == 0 ? -1 : 1; copy_text(ui->last_intent.text, sizeof(ui->last_intent.text), ui->context_title); @@ -2242,10 +2418,7 @@ static int handle_context_click(RuneCUiState *ui, Vector2 mouse) { if (!ui->last_intent.text[0]) copy_text(ui->last_intent.text, sizeof(ui->last_intent.text), action); - ui->context_open = 0; - ui->context_source_kind = RUNEC_UI_CONTEXT_NONE; - ui->context_source_slot = -1; - ui->context_source_item_id = 0; + runec_ui_close_context(ui); return 1; } } @@ -2420,17 +2593,13 @@ static int handle_drag_release(RuneCUiState *ui, ui->last_intent.position = mouse; return 1; } - int previous = ui->selected_inventory_slot; - ui->selected_inventory_slot = drag.source_slot; ui->last_intent.kind = RUNEC_UI_INTENT_INVENTORY_SLOT; ui->last_intent.primary = drag.source_slot; - ui->last_intent.secondary = previous; ui->last_intent.position = mouse; return 1; } if (drag.source_kind == RUNEC_UI_CONTEXT_EQUIPMENT) { - ui->selected_equipment_slot = drag.source_slot; ui->last_intent.kind = RUNEC_UI_INTENT_EQUIPMENT_SLOT; ui->last_intent.primary = drag.source_slot; ui->last_intent.position = mouse; @@ -2651,7 +2820,7 @@ static int handle_primary_click(RuneCUiState *ui, Vector2 mouse) { if (!IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) return 0; - if (handle_context_click(ui, mouse) || handle_tab_click(ui, layout, mouse) || + if (handle_tab_click(ui, layout, mouse) || handle_orb_or_minimap_click(ui, layout, mouse) || handle_active_tab_click(ui, layout, mouse)) return 1; @@ -2661,7 +2830,7 @@ static int handle_primary_click(RuneCUiState *ui, static int handle_context_menu_open(RuneCUiState *ui, const RuneCUiLayout *layout, Vector2 mouse) { - if (!IsMouseButtonPressed(MOUSE_BUTTON_MIDDLE) || + if (!IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) || !mouse_over_ui(layout, mouse)) return 0; runec_ui_clear_selected_target(ui); @@ -2690,7 +2859,8 @@ static int handle_context_menu_open(RuneCUiState *ui, if (ui->active_tab == RUNEC_UI_TAB_INVENTORY) { int slot = ui_inventory_slot_at(layout, mouse); if (slot >= 0) { - static const char *actions[] = {"Use", "Examine", "Drop"}; + const char *actions[] = {ui->inventory[slot].action ? + ui->inventory[slot].action : "Use", "Examine", "Cancel"}; static const char *empty_actions[] = {"Cancel"}; const char *title = ui->inventory[slot].enabled ? ui->inventory[slot].label : "Empty inventory slot"; @@ -2707,10 +2877,15 @@ static int handle_context_menu_open(RuneCUiState *ui, if (ui->active_tab == RUNEC_UI_TAB_EQUIPMENT) { int slot = ui_equipment_slot_at(layout, mouse); if (slot >= 0) { - static const char *actions[] = {"Remove", "Examine"}; - set_context(ui, mouse, g_equipment_names[slot], actions, 2); - set_context_source(ui, RUNEC_UI_CONTEXT_EQUIPMENT, slot, - ui->equipment[slot].item_id); + static const char *actions[] = {"Remove", "Examine", "Cancel"}; + static const char *empty_actions[] = {"Cancel"}; + if (ui->equipment[slot].enabled) { + set_context(ui, mouse, ui->equipment[slot].label, actions, 3); + set_context_source(ui, RUNEC_UI_CONTEXT_EQUIPMENT, slot, + ui->equipment[slot].item_id); + } else { + set_context(ui, mouse, "", empty_actions, 1); + } return 1; } } @@ -2751,6 +2926,18 @@ int runec_ui_handle_input(RuneCUiState *ui, int screen_w, int screen_h) { clear_intent(ui); update_tab_press_timers(ui, GetFrameTime()); + if (ui->context_open) { + if (IsKeyPressed(KEY_ESCAPE)) { + runec_ui_close_context(ui); + } else if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + handle_context_click(ui, mouse); + } else if (!fc_menu_contains(context_layout(ui), (int)mouse.x, (int)mouse.y, + FC_MENU_DISMISS_MARGIN)) { + runec_ui_close_context(ui); + } + return 1; + } + if (handle_selected_target_cancel(ui) || handle_drag_release(ui, &layout, mouse) || handle_primary_click(ui, &layout, mouse) || @@ -3194,9 +3381,6 @@ static void draw_inventory_item(const RuneCUiState *ui, const RuneCUiSlot *slot, static void draw_inventory(const RuneCUiState *ui, const RuneCUiLayout *layout) { for (int i = 0; i < RUNEC_UI_INV_SLOT_COUNT; i++) { Rectangle r = inv_slot_rect(layout, i); - if (ui->selected_inventory_slot == i) - DrawRectangleLinesEx((Rectangle){r.x - 1, r.y - 1, r.width + 2, r.height + 2}, - 2.0f, OSRS_YELLOW); if (ui->inventory[i].enabled) { draw_inventory_item(ui, &ui->inventory[i], r); if (ui->inventory[i].quantity > 1) { @@ -3236,10 +3420,6 @@ static void draw_equipment(const RuneCUiState *ui, const RuneCUiLayout *layout) } else if (g_worn_icon_names[i]) { draw_asset_centered(ui, g_worn_icon_names[i], r, 28, 28, (Color){190, 178, 150, 175}); } - if (ui->selected_equipment_slot == i) { - DrawRectangleLinesEx((Rectangle){r.x - 1, r.y - 1, r.width + 2, r.height + 2}, - 2.0f, OSRS_YELLOW); - } } for (int i = 0; i < (int)(sizeof(RUNEC_OSRS_WORN_BUTTONS) / sizeof(RUNEC_OSRS_WORN_BUTTONS[0])); i++) { @@ -3459,19 +3639,46 @@ static void draw_side(RuneCUiState *ui, const RuneCUiLayout *layout) { } } -static void draw_context(const RuneCUiState *ui) { +void runec_ui_draw_context(const RuneCUiState *ui) { if (!ui->context_open) return; - Rectangle box = {ui->context_pos.x, ui->context_pos.y, - 158.0f, 24.0f + ui->context_action_count * 20.0f}; - DrawRectangleRec(box, (Color){53, 44, 31, 244}); - DrawRectangleLinesEx(box, 1, (Color){170, 137, 72, 255}); - draw_text_shadow(ui, ui->context_title, box.x + 5, box.y + 4, 11, OSRS_YELLOW); + FcMenuLayout menu = context_layout(ui); + Color brown = {93, 84, 71, 255}; + DrawRectangle(menu.x, menu.y, menu.width, menu.height, brown); + DrawRectangle(menu.x + 1, menu.y + 1, menu.width - 2, FC_MENU_HEADER_HEIGHT - 3, BLACK); + DrawRectangleLinesEx((Rectangle){menu.x + 1, menu.y + FC_MENU_HEADER_HEIGHT - 1, + menu.width - 2, menu.height - FC_MENU_HEADER_HEIGHT}, 1, BLACK); + Font font = fc_osrs_menu_font(); + DrawTextEx(font, "Choose Option", (Vector2){menu.x + 4, menu.y + 2}, + FC_OSRS_MENU_FONT_SIZE, 0, brown); + Vector2 mouse = GetMousePosition(); + int hovered = fc_menu_action_at(menu, ui->context_action_count, (int)mouse.x, (int)mouse.y); + BeginScissorMode(menu.x, menu.y, menu.width, menu.height); for (int i = 0; i < ui->context_action_count; i++) { - Rectangle item = {box.x + 4, box.y + 22 + i * 20.0f, box.width - 8, 18}; - DrawRectangleRec(item, (Color){28, 23, 17, 215}); - draw_text_shadow(ui, ui->context_actions[i], item.x + 4, item.y + 3, 11, OSRS_ORANGE); + float x = menu.x + 4; + float y = menu.y + FC_MENU_HEADER_HEIGHT + i * FC_MENU_ROW_HEIGHT + 1; + const char *action = ui->context_actions[i]; + DrawTextEx(font, action, (Vector2){x + 1, y + 1}, FC_OSRS_MENU_FONT_SIZE, 0, BLACK); + DrawTextEx(font, action, (Vector2){x, y}, FC_OSRS_MENU_FONT_SIZE, 0, + hovered == i ? OSRS_YELLOW : WHITE); + if (context_has_target(ui, action)) { + char target[52]; + snprintf(target, sizeof(target), " %s", ui->context_title); + x += MeasureTextEx(font, action, FC_OSRS_MENU_FONT_SIZE, 0).x; + DrawTextEx(font, target, (Vector2){x + 1, y + 1}, FC_OSRS_MENU_FONT_SIZE, 0, BLACK); + DrawTextEx(font, target, (Vector2){x, y}, FC_OSRS_MENU_FONT_SIZE, 0, ui->context_target_color); + if (ui->context_combat_level > 0) { + char suffix[32]; + context_level_suffix(ui, suffix); + x += MeasureTextEx(font, target, FC_OSRS_MENU_FONT_SIZE, 0).x; + Color color = GetColor((fc_menu_level_color(ui->combat_level, + ui->context_combat_level) << 8) | 0xffu); + DrawTextEx(font, suffix, (Vector2){x + 1, y + 1}, FC_OSRS_MENU_FONT_SIZE, 0, BLACK); + DrawTextEx(font, suffix, (Vector2){x, y}, FC_OSRS_MENU_FONT_SIZE, 0, color); + } + } } + EndScissorMode(); } static void draw_selected_target(const RuneCUiState *ui) { @@ -3509,7 +3716,6 @@ void runec_ui_draw(RuneCUiState *ui, int screen_w, int screen_h) { draw_minimap(ui, &layout); draw_side(ui, &layout); draw_selected_target(ui); - draw_context(ui); } Rectangle runec_ui_chat_panel_rect(int screen_w, int screen_h) { diff --git a/ocean/fight_caves/viewer.c b/ocean/fight_caves/viewer.c index bd11ed9cca..227ef66f6a 100644 --- a/ocean/fight_caves/viewer.c +++ b/ocean/fight_caves/viewer.c @@ -12,6 +12,7 @@ * O or D* — toggle debug overlay G — grid C — collision * 4/5 — camera presets L — toggle camera lock * Scroll — zoom Right-drag — orbit camera + * Right click — context menu (dragging dismisses it and orbits) * * * D only toggles the overlay when not being used for east movement. * Policy replay mode (`--policy-pipe`) also adds 1/2/4/0 playback presets. @@ -22,8 +23,8 @@ #include "rlgl.h" #include "simulation.h" #include "assets.h" -#include "render.h" #include "ui.h" +#include "render.h" #include #include #include @@ -118,7 +119,9 @@ typedef struct { ObjectAnimRuntime* object_anim_runtimes; int object_anim_runtime_count; NpcModelSet* npc_models; - NpcModelSet* player_model; + FcPlayerAppearance appearance; + char item_message[96]; + float item_message_seconds; /* Animation cache (shared by player + all NPCs) */ AnimCache* anim_cache; /* Buffered key inputs (captured every frame, consumed on tick) */ @@ -126,6 +129,10 @@ typedef struct { int pending_attack_npc; int pending_tile_x, pending_tile_y; FcClickFeedback click_feedback; + int context_npc_slot, context_npc_spawn_index; + int context_tile_x, context_tile_y; + Vector2 scene_right_start; + int scene_right_tracking, scene_right_dragged; int console_tab; /* controls, player, obs, mask, reward, log */ int console_wave_dropdown_open; int console_scroll[4]; /* player/obs/mask/reward vertical offsets */ @@ -187,26 +194,6 @@ static void set_ui_slot(RuneCUiSlot* slot, uint32_t item_id, slot->enabled = 1; } -static uint32_t prayer_potion_item_id_for_doses(int doses) { - switch (doses) { - case 4: return FC_UI_ITEM_PRAYER_POT_4; - case 3: return FC_UI_ITEM_PRAYER_POT_3; - case 2: return FC_UI_ITEM_PRAYER_POT_2; - case 1: return FC_UI_ITEM_PRAYER_POT_1; - default: return FC_UI_ITEM_VIAL; - } -} - -static const char* prayer_potion_label_for_doses(int doses) { - switch (doses) { - case 4: return "Prayer potion(4)"; - case 3: return "Prayer potion(3)"; - case 2: return "Prayer potion(2)"; - case 1: return "Prayer potion(1)"; - default: return "Vial"; - } -} - static uint32_t fc_ui_active_prayer_bits(int prayer) { switch (prayer) { case PRAYER_PROTECT_MAGIC: return 1u << 16; @@ -277,48 +264,22 @@ static int load_fc_ui_item_icons(ViewerState* v) { return ready; } -static void sync_fc_ui_items(ViewerState* v) { - if (!v) return; - FcPlayer* p = &v->state.player; - for (int i = 0; i < RUNEC_UI_INV_SLOT_COUNT; i++) - memset(&v->ui.inventory[i], 0, sizeof(v->ui.inventory[i])); - - int doses = p->prayer_doses_remaining; - if (doses < 0) doses = 0; - if (doses > FC_MAX_PRAYER_DOSES) doses = FC_MAX_PRAYER_DOSES; - int full_pots = doses / 4; - int partial = doses % 4; - for (int slot = 0; slot < 8; slot++) { - int slot_doses = 0; - if (slot < full_pots) slot_doses = 4; - else if (slot == full_pots && partial > 0) slot_doses = partial; - uint32_t item_id = prayer_potion_item_id_for_doses(slot_doses); - set_ui_slot(&v->ui.inventory[slot], item_id, item_id, 1, - prayer_potion_label_for_doses(slot_doses)); - } - for (int slot = 8; slot < RUNEC_UI_INV_SLOT_COUNT; slot++) { - if (slot - 8 < p->sharks_remaining) { - set_ui_slot(&v->ui.inventory[slot], FC_UI_ITEM_SHARK, - FC_UI_ITEM_SHARK, 1, "Shark"); - } +static void sync_item_slots(RuneCUiSlot *slots, const FcItemStack *items, int count) { + for (int i = 0; i < count; i++) { + memset(&slots[i], 0, sizeof(slots[i])); + const FcItemDef *item = fc_item_definition(items[i].item_id); + if (!item) continue; + set_ui_slot(&slots[i], (uint32_t)item->id, (uint32_t)item->id, + items[i].quantity, item->name); + slots[i].action = item->slot == FC_EQUIP_SLOT_WEAPON ? "Wield" : + item->slot >= 0 ? "Wear" : item->id == 385 ? "Eat" : + item->id == 229 ? "Use" : "Drink"; } +} - for (int i = 0; i < RUNEC_UI_EQUIP_SLOT_COUNT; i++) - memset(&v->ui.equipment[i], 0, sizeof(v->ui.equipment[i])); - - int loadout = v->active_loadout; - if (loadout < 0 || loadout >= FC_NUM_LOADOUTS) - loadout = FC_ACTIVE_LOADOUT; - const FcLoadout* lo = &FC_LOADOUTS[loadout]; - for (int i = 0; i < lo->equipment_count; i++) { - const FcLoadoutEquipmentItem* equip = &lo->equipment[i]; - if (equip->slot >= 0 && equip->slot < RUNEC_UI_EQUIP_SLOT_COUNT) { - uint32_t icon_id = equip->icon_item_id ? equip->icon_item_id : equip->item_id; - int quantity = equip->slot == FC_EQUIP_SLOT_AMMO ? p->ammo_count : 1; - set_ui_slot(&v->ui.equipment[equip->slot], equip->item_id, - icon_id, quantity, equip->label); - } - } +static void sync_fc_ui_items(ViewerState* v) { + sync_item_slots(v->ui.inventory, v->state.player.inventory, FC_INVENTORY_SLOTS); + sync_item_slots(v->ui.equipment, v->state.player.equipment, FC_EQUIPMENT_SLOTS); } static void sync_fc_ui_status(ViewerState* v) { @@ -334,16 +295,14 @@ static void sync_fc_ui_status(ViewerState* v) { if (v->ui.run_energy < 0) v->ui.run_energy = 0; if (v->ui.run_energy > 100) v->ui.run_energy = 100; v->ui.run_enabled = p->is_running != 0; - v->ui.selected_combat_style = v->combat_style == 2 ? 3 : v->combat_style; v->ui.auto_retaliate = 1; v->ui.special_attack_energy = 100; v->ui.combat_level = 126; - int loadout = v->active_loadout; - if (loadout < 0 || loadout >= FC_NUM_LOADOUTS) - loadout = FC_ACTIVE_LOADOUT; - const FcLoadout* lo = &FC_LOADOUTS[loadout]; - runec_ui_set_combat_weapon_name(&v->ui, lo->weapon_name); - runec_ui_set_combat_style_profile(&v->ui, lo->combat_style_profile); + const FcItemDef *weapon = fc_item_definition(p->equipment[FC_EQUIP_SLOT_WEAPON].item_id); + v->ui.selected_combat_style = weapon ? (v->combat_style == 2 ? 3 : v->combat_style) : 0; + runec_ui_set_combat_weapon_name(&v->ui, weapon ? weapon->name : "Unarmed"); + runec_ui_set_combat_style_profile(&v->ui, + weapon ? FC_LOADOUTS[weapon->visual_profile].combat_style_profile : 0); for (int i = 0; i < RUNEC_UI_SKILL_COUNT; i++) { v->ui.skill_current[i] = 1; @@ -440,31 +399,86 @@ static void queue_player_attack_request(ViewerState* v, int npc_idx, screen_x, screen_y); } +static void show_item_result(ViewerState *v, FcItemResult result) { + snprintf(v->item_message, sizeof(v->item_message), "%s", fc_item_result_message(result)); + v->item_message_seconds = result == FC_ITEM_OK ? 0.0f : 4.0f; +} + +static void use_inventory_slot(ViewerState *v, int slot) { + if (v->policy_pipe || slot < 0 || slot >= FC_INVENTORY_SLOTS) return; + const FcItemDef *item = fc_item_definition(v->state.player.inventory[slot].item_id); + if (!item) return; + FcItemResult result; + if (item->slot >= 0) { + result = fc_equip_item(&v->state, slot); + if (result == FC_ITEM_OK) v->pending_attack_npc = v->attack_target = -1; + } else { + result = fc_select_consumable(&v->state, slot); + if (result == FC_ITEM_OK) { + if (item->id == 385) v->pending_eat = FC_EAT_SHARK; + else v->pending_drink = FC_DRINK_PRAYER_POT; + } + } + show_item_result(v, result); +} + static void handle_runec_ui_intent(ViewerState* v) { if (!v) return; RuneCUiIntent* intent = &v->ui.last_intent; FcPlayer* p = &v->state.player; switch (intent->kind) { - case RUNEC_UI_INTENT_INVENTORY_SLOT: - if (intent->primary >= 0 && intent->primary < 8) { - int full_pots = p->prayer_doses_remaining / 4; - int partial = p->prayer_doses_remaining % 4; - if (intent->primary < full_pots || - (intent->primary == full_pots && partial > 0)) - v->pending_drink = FC_DRINK_PRAYER_POT; - } else if (intent->primary >= 8 && intent->primary < 28) { - if (intent->primary - 8 < p->sharks_remaining) - v->pending_eat = FC_EAT_SHARK; + case RUNEC_UI_INTENT_WORLD_ACTION: { + int slot = v->context_npc_slot; + const FcNpc *npc = slot >= 0 && slot < FC_MAX_NPCS ? &v->state.npcs[slot] : NULL; + int same_npc = npc && npc->active && !npc->is_dead && + npc->spawn_index == v->context_npc_spawn_index; + if (strcmp(intent->text, "Examine") == 0 && same_npc) { + snprintf(v->item_message, sizeof(v->item_message), "%s", fc_menu_npc_info(npc->npc_type).name); + v->item_message_seconds = 4.0f; + } else if (!v->policy_pipe && v->state.terminal == TERMINAL_NONE) { + if (strcmp(intent->text, "Attack") == 0 && same_npc) + queue_player_attack_request(v, slot, intent->position.x, intent->position.y); + else if (strcmp(intent->text, "Walk here") == 0) + queue_player_tile_request(v, v->context_tile_x, v->context_tile_y, + intent->position.x, intent->position.y); } break; + } + case RUNEC_UI_INTENT_INVENTORY_SLOT: + use_inventory_slot(v, intent->primary); + break; case RUNEC_UI_INTENT_INVENTORY_ACTION: - if (strcmp(intent->text, "Use") == 0 || strcmp(intent->text, "Drink") == 0) - v->pending_drink = FC_DRINK_PRAYER_POT; - else if (strcmp(intent->text, "Eat") == 0) - v->pending_eat = FC_EAT_SHARK; + if (strcmp(intent->text, "Examine") == 0) { + snprintf(v->item_message, sizeof(v->item_message), "%s", + v->ui.inventory[intent->primary].label); + v->item_message_seconds = 4.0f; + } + if (strcmp(intent->text, "Wear") == 0 || strcmp(intent->text, "Wield") == 0 || + strcmp(intent->text, "Eat") == 0 || strcmp(intent->text, "Drink") == 0) + use_inventory_slot(v, intent->primary); + break; + case RUNEC_UI_INTENT_EQUIPMENT_ACTION: + if (strcmp(intent->text, "Examine") == 0) { + snprintf(v->item_message, sizeof(v->item_message), "%s", + v->ui.equipment[intent->primary].label); + v->item_message_seconds = 4.0f; + } + if (strcmp(intent->text, "Remove") != 0) break; + /* fall through */ + case RUNEC_UI_INTENT_EQUIPMENT_SLOT: + if (!v->policy_pipe) + show_item_result(v, fc_unequip_item(&v->state, intent->primary)); + break; + case RUNEC_UI_INTENT_INVENTORY_DRAG: + if (!v->policy_pipe) + show_item_result(v, fc_inventory_swap(&v->state, intent->primary, intent->secondary)); break; case RUNEC_UI_INTENT_PRAYER_SLOT: { int action = fc_ui_prayer_action_for_slot(p, intent->primary); + /* A context-menu verb is explicit, not a toggle of whatever + * prayer happens to be active when the option is selected. */ + if (intent->secondary == 1 && action == FC_PRAYER_OFF) action = 0; + if (intent->secondary == -1 && action != FC_PRAYER_OFF) action = 0; if (action) v->pending_prayer = action; break; } @@ -595,8 +609,7 @@ static void apply_initial_supplies(ViewerState* v) { if (v->initial_prayer_doses < 0) v->initial_prayer_doses = 0; if (v->initial_prayer_doses > FC_MAX_PRAYER_DOSES) v->initial_prayer_doses = FC_MAX_PRAYER_DOSES; - v->state.player.sharks_remaining = v->initial_sharks; - v->state.player.prayer_doses_remaining = v->initial_prayer_doses; + fc_set_initial_supplies(&v->state, v->initial_sharks, v->initial_prayer_doses); } static void load_reward_params(ViewerState* v) { @@ -862,7 +875,23 @@ static void print_policy_episode_summary(const ViewerState* v) { fprintf(stderr, ",\"env/n\":1.0}\n"); } +static void sync_player_appearance(ViewerState *v) { + int changed = fc_player_appearance_sync(&v->appearance, &v->state.player, + FC_LOADOUTS[v->active_loadout].player_model_id); + if (changed < 0) { + fprintf(stderr, "Cannot compose player appearance from local equipment assets.\n"); + exit(EXIT_FAILURE); + } + if (changed && v->actor_animation.player_state) { + anim_model_state_free(v->actor_animation.player_state); + v->actor_animation.player_state = NULL; + /* Keep pose/action clocks: a gear change isn't an animation restart. */ + } +} + static void reset_ep(ViewerState* v) { + runec_ui_close_context(&v->ui); + v->scene_right_tracking = v->scene_right_dragged = 0; load_reward_params(v); reset_reward_tracking(v); v->seed = (uint32_t)GetRandomValue(1, 999999); @@ -880,6 +909,8 @@ static void reset_ep(ViewerState* v) { v->state.player.current_prayer = v->state.player.max_prayer; } apply_initial_supplies(v); + sync_player_appearance(v); + v->item_message_seconds = 0.0f; fc_reward_runtime_begin_episode(&v->reward_runtime, &v->state); fc_fill_render_entities(&v->state, v->entities, &v->entity_count); fc_fill_render_events(&v->state, &v->render_events); @@ -889,7 +920,7 @@ static void reset_ep(ViewerState* v) { memset(v->actions, 0, sizeof(v->actions)); fc_combat_presentation_reset(v->combat_presentation); fc_actor_animation_reset(&v->actor_animation, &v->state, - v->player_model, v->active_loadout); + v->appearance.model, v->active_loadout); v->pending_prayer = 0; v->pending_eat = 0; v->pending_drink = 0; @@ -902,6 +933,7 @@ static void reset_ep(ViewerState* v) { static void viewer_jump_to_wave(ViewerState* v, int wave) { if (!v) return; + runec_ui_close_context(&v->ui); if (wave < 1) wave = 1; if (wave > FC_NUM_WAVES) wave = FC_NUM_WAVES; @@ -922,7 +954,7 @@ static void viewer_jump_to_wave(ViewerState* v, int wave) { fc_fill_render_events(&v->state, &v->render_events); fc_combat_presentation_reset(v->combat_presentation); fc_actor_animation_reset(&v->actor_animation, &v->state, - v->player_model, v->active_loadout); + v->appearance.model, v->active_loadout); v->attack_target = -1; fc_click_feedback_reset(&v->click_feedback); dbg_log_clear(); @@ -957,6 +989,7 @@ static ObjectMesh* load_objects_with_terrain(TerrainMesh* tm) { /* Forward declaration */ static float ground_y(ViewerState* v, int tile_x, int tile_y); +static float ground_y_smooth(ViewerState* v, float tile_x, float tile_y); /* ======================================================================== */ /* Human input → action heads */ @@ -981,33 +1014,60 @@ static int raycast_to_tile(ViewerState* v, int* out_x, int* out_y) { return 1; } -/* Find NPC at clicked tile — checks LIVE state, not render snapshot. - * Returns NPC array index (0..FC_MAX_NPCS-1) or -1 if no NPC there. */ -static int find_clicked_npc_idx(ViewerState* v, int tile_x, int tile_y) { +/* Pick what was drawn, including the individual animation and interpolated + * position. Core state only decides whether the picked NPC is still targetable. */ +static int find_clicked_npc_idx(ViewerState* v) { int best = -1; - int best_dist = 999; - for (int i = 0; i < FC_MAX_NPCS; i++) { - FcNpc* n = &v->state.npcs[i]; + float best_depth = INFINITY; + Vector2 mouse = GetMousePosition(); + for (int i = 0; i < v->entity_count; i++) { + const FcRenderEntity *entity = &v->entities[i]; + int slot = entity->npc_slot; + if (entity->entity_type == ENTITY_PLAYER || slot < 0 || slot >= FC_MAX_NPCS) continue; + const FcNpc *n = &v->state.npcs[slot]; if (!n->active || n->is_dead) continue; - /* Check if tile is within the NPC's footprint (or 1 tile adjacent) */ - if (tile_x >= n->x - 1 && tile_x <= n->x + n->size && - tile_y >= n->y - 1 && tile_y <= n->y + n->size) { - /* Prefer the closest NPC center */ - int cx = n->x + n->size/2; - int cy = n->y + n->size/2; - int d = abs(tile_x - cx) + abs(tile_y - cy); - if (d < best_dist) { best_dist = d; best = i; } + FcVisualPose pose = fc_visual_scene_npc_pose(&v->actor_animation.scene, slot); + Vector3 position = {pose.x, ground_y_smooth(v, pose.x, pose.y), -pose.y}; + const NpcModelEntry *model = fc_npc_model_find(v->npc_models, + fc_npc_type_to_model_id(entity->npc_type)); + float depth = -1; + if (model) { + const AnimModelState *animation = v->actor_animation.npc_states[slot]; + depth = models_pick_depth(model, animation ? animation->verts : NULL, + position, pose.yaw_degrees, v->camera, mouse, GetScreenWidth(), GetScreenHeight()); + } else { + /* Match the existing missing-model cube, not an invisible tile halo. */ + float s = entity->size * 0.45f, h = 1.0f + entity->size * 0.5f; + BoundingBox box = {{position.x - s, position.y, position.z - s}, + {position.x + s, position.y + h, position.z + s}}; + RayCollision hit = GetRayCollisionBox(GetScreenToWorldRay(mouse, v->camera), box); + if (hit.hit) depth = hit.distance; } + if (depth >= 0 && depth < best_depth) { best_depth = depth; best = slot; } } return best; } +static void open_scene_context_menu(ViewerState *v) { + int tx = -1, ty = -1; + int can_walk = raycast_to_tile(v, &tx, &ty); + int slot = find_clicked_npc_idx(v); + if (slot < 0 && !can_walk) return; + v->context_npc_slot = slot; + v->context_npc_spawn_index = slot >= 0 ? v->state.npcs[slot].spawn_index : -1; + v->context_tile_x = tx; + v->context_tile_y = ty; + runec_ui_open_world_context(&v->ui, GetMousePosition(), + slot >= 0 ? v->state.npcs[slot].npc_type : 0, can_walk); +} + /* Called EVERY FRAME to capture clicks (which only fire once at 60fps). * Buffers authoritative actions and starts presentation-only feedback. */ static void process_human_clicks(ViewerState* v, int ui_capture) { FcPlayer* p = &v->state.player; - if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT) && + !IsMouseButtonDown(MOUSE_BUTTON_RIGHT) && !IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) { if (ui_capture) return; Vector2 mpos = GetMousePosition(); int tx = -1; @@ -1015,17 +1075,15 @@ static void process_human_clicks(ViewerState* v, int ui_capture) { int rc = raycast_to_tile(v, &tx, &ty); fprintf(stderr, "CLICK mouse=(%.0f,%.0f) raycast=%d tile=(%d,%d) player=(%d,%d)", mpos.x, mpos.y, rc, tx, ty, p->x, p->y); - if (rc) { - int npc_idx = find_clicked_npc_idx(v, tx, ty); - if (npc_idx >= 0) { - queue_player_attack_request(v, npc_idx, mpos.x, mpos.y); - fprintf(stderr, " → ATTACK npc_idx=%d\n", npc_idx); - } else { - int walkable = v->state.walkable[tx][ty]; - fprintf(stderr, " walkable=%d", walkable); - queue_player_tile_request(v, tx, ty, mpos.x, mpos.y); - fprintf(stderr, " → MOVE%s\n", walkable ? "" : "-NEAR"); - } + int npc_idx = find_clicked_npc_idx(v); + if (npc_idx >= 0) { + queue_player_attack_request(v, npc_idx, mpos.x, mpos.y); + fprintf(stderr, " → ATTACK npc_idx=%d\n", npc_idx); + } else if (rc) { + int walkable = v->state.walkable[tx][ty]; + fprintf(stderr, " walkable=%d", walkable); + queue_player_tile_request(v, tx, ty, mpos.x, mpos.y); + fprintf(stderr, " → MOVE%s\n", walkable ? "" : "-NEAR"); } else { fprintf(stderr, " → MISS (raycast failed)\n"); } @@ -1448,7 +1506,7 @@ static void draw_scene(ViewerState* v) { /* Player model or fallback cylinder */ NpcModelEntry* pm = fc_actor_player_model_entry( - v->player_model, v->active_loadout); + v->appearance.model, v->active_loadout); if (pm && pm->loaded) { Vector3 pos = {ex, gy, ey}; float face_angle = pose.face_angle; @@ -1507,7 +1565,7 @@ static void draw_scene(ViewerState* v) { .scene = &v->actor_animation.scene, .terrain = v->terrain, .anim_cache = v->anim_cache, - .player_profile = fc_player_visual_profile(v->active_loadout), + .player_profile = fc_player_visual_profile(fc_player_equipment_visual_profile(&v->state.player)), .tps = v->tps, }; fc_combat_presentation_draw_world(v->combat_presentation, @@ -1521,7 +1579,7 @@ static void draw_scene(ViewerState* v) { .presentation = combat_context, .entities = v->entities, .entity_count = v->entity_count, - .player_models = v->player_model, + .player_models = v->appearance.model, .npc_models = v->npc_models, .active_loadout = v->active_loadout, .ui_assets = &v->ui.assets, @@ -2123,23 +2181,24 @@ static int process_runec_prayer_click(ViewerState* v) { if (!CheckCollisionPointRec(mouse, content)) return 0; - if (right_click) - return 1; - static const struct { int prayer; int action; + int ui_slot; } buttons[] = { - {PRAYER_PROTECT_MELEE, FC_PRAYER_MELEE}, - {PRAYER_PROTECT_RANGE, FC_PRAYER_RANGE}, - {PRAYER_PROTECT_MAGIC, FC_PRAYER_MAGIC}, + {PRAYER_PROTECT_MELEE, FC_PRAYER_MELEE, 18}, + {PRAYER_PROTECT_RANGE, FC_PRAYER_RANGE, 17}, + {PRAYER_PROTECT_MAGIC, FC_PRAYER_MAGIC, 16}, }; for (int i = 0; i < (int)(sizeof(buttons) / sizeof(buttons[0])); i++) { Rectangle r = runec_prayer_button_rect(content, i); if (!CheckCollisionPointRec(mouse, r)) continue; - queue_viewer_prayer_button(v, buttons[i].prayer, buttons[i].action); + if (right_click) + runec_ui_open_prayer_context(&v->ui, mouse, buttons[i].ui_slot); + else + queue_viewer_prayer_button(v, buttons[i].prayer, buttons[i].action); return 1; } @@ -2181,6 +2240,7 @@ int main(int argc, char** argv) { SetConfigFlags(FLAG_WINDOW_RESIZABLE|FLAG_MSAA_4X_HINT); InitWindow(DEFAULT_WINDOW_W, DEFAULT_WINDOW_H, "Fight Caves RL — Playable Viewer"); + SetExitKey(KEY_NULL); /* Escape first dismisses menus; handled below. */ if (!IsWindowReady()) { fprintf(stderr, "error: viewer window initialization failed; verify the " @@ -2257,10 +2317,11 @@ int main(int argc, char** argv) { if (!v.npc_models) fprintf(stderr, "error: NPC models failed to load\n"); } - /* Load player model */ - { - if (fc_asset_exists("fc_player.models")) - v.player_model = fc_npc_models_load("fc_player.models", (Texture2D){0}); + /* Load composable player body and equipment models. */ + if (!fc_player_appearance_load(&v.appearance)) { + fprintf(stderr, "Required player appearance assets are missing or invalid.\n"); + fc_player_appearance_free(&v.appearance); + return 1; } /* Load the animation cache shared by actor and combat presentation. */ @@ -2353,8 +2414,8 @@ int main(int argc, char** argv) { "object animation runtime allocation"); REQUIRE_VIEWER_RESOURCE(v.npc_models && v.npc_models->loaded, "NPC models"); - REQUIRE_VIEWER_RESOURCE(v.player_model && v.player_model->loaded, - "player models"); + REQUIRE_VIEWER_RESOURCE(v.appearance.parts && v.appearance.parts->loaded, + "player equipment and body models"); REQUIRE_VIEWER_RESOURCE(v.anim_cache, "actor animation data"); REQUIRE_VIEWER_RESOURCE( fc_combat_presentation_ready(v.combat_presentation), @@ -2411,9 +2472,13 @@ int main(int argc, char** argv) { break; } frame_count++; + /* Age the previous click before capturing this frame's input. A newly + * clicked cross must start at frame zero, even after a slow frame. */ + fc_click_feedback_update(&v.click_feedback, GetFrameTime()); /* Global keys (always active) */ if (IsKeyPressed(KEY_Q)) break; + if (IsKeyPressed(KEY_ESCAPE) && !v.ui.context_open) break; if (IsKeyPressed(KEY_SPACE)) v.paused = !v.paused; if (IsKeyPressed(KEY_RIGHT)) v.step_once = 1; if (v.policy_pipe) { @@ -2468,22 +2533,45 @@ int main(int argc, char** argv) { } sync_fc_ui(&v); - ui_capture = process_runec_prayer_click(&v); - if (!ui_capture) - ui_capture = process_runec_console_input(&v); - if (!ui_capture) { + if (v.ui.context_open) { ui_capture = runec_ui_handle_input(&v.ui, GetScreenWidth(), GetScreenHeight()); handle_runec_ui_intent(&v); } else { - v.ui.last_intent.kind = RUNEC_UI_INTENT_NONE; + ui_capture = process_runec_prayer_click(&v); + if (!ui_capture) + ui_capture = process_runec_console_input(&v); + if (!ui_capture) { + ui_capture = runec_ui_handle_input(&v.ui, GetScreenWidth(), GetScreenHeight()); + handle_runec_ui_intent(&v); + } else { + v.ui.last_intent.kind = RUNEC_UI_INTENT_NONE; + } } - /* Camera orbit + zoom */ - if (!ui_capture && IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) { - Vector2 d = GetMouseDelta(); - v.cam_yaw += d.x*0.005f; v.cam_pitch -= d.y*0.005f; - if (v.cam_pitch < 0.1f) v.cam_pitch = 0.1f; - if (v.cam_pitch > 1.4f) v.cam_pitch = 1.4f; + /* RuneC: open on right press; a real drag dismisses the menu and + * retains the viewer's existing camera gesture. No game action fires. */ + if (!ui_capture && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) { + v.scene_right_tracking = 1; + v.scene_right_dragged = 0; + v.scene_right_start = GetMousePosition(); + open_scene_context_menu(&v); + ui_capture = 1; + } + if (v.scene_right_tracking && IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) { + Vector2 mouse = GetMousePosition(); + float dx = mouse.x - v.scene_right_start.x; + float dy = mouse.y - v.scene_right_start.y; + if (dx * dx + dy * dy > 9.0f) v.scene_right_dragged = 1; + if (v.scene_right_dragged) { + runec_ui_close_context(&v.ui); + Vector2 d = GetMouseDelta(); + v.cam_yaw += d.x*0.005f; v.cam_pitch -= d.y*0.005f; + if (v.cam_pitch < 0.1f) v.cam_pitch = 0.1f; + if (v.cam_pitch > 1.4f) v.cam_pitch = 1.4f; + } + } + if (IsMouseButtonReleased(MOUSE_BUTTON_RIGHT)) { + v.scene_right_tracking = v.scene_right_dragged = 0; } float wh = GetMouseWheelMove(); if (!ui_capture && wh != 0) { @@ -2547,7 +2635,7 @@ int main(int argc, char** argv) { update_reward_breakdown(&v); fc_actor_animation_ingest_events( &v.actor_animation, &v.render_events, v.anim_cache, - v.active_loadout, v.tps); + fc_player_equipment_visual_profile(&v.state.player), v.tps); /* Debug event log — record events from this tick */ dbg_log_tick(&v.state); @@ -2572,7 +2660,7 @@ int main(int argc, char** argv) { .scene = &v.actor_animation.scene, .terrain = v.terrain, .anim_cache = v.anim_cache, - .player_profile = fc_player_visual_profile(v.active_loadout), + .player_profile = fc_player_visual_profile(fc_player_equipment_visual_profile(&v.state.player)), .tps = v.tps, }; fc_combat_presentation_ingest_tick(v.combat_presentation, @@ -2613,14 +2701,15 @@ int main(int argc, char** argv) { } float frame_dt = GetFrameTime(); - fc_click_feedback_update(&v.click_feedback, frame_dt); + sync_player_appearance(&v); + if (v.item_message_seconds > 0) v.item_message_seconds -= frame_dt; FcCombatPresentationContext combat_context = { .state = &v.state, .events = &v.render_events, .scene = &v.actor_animation.scene, .terrain = v.terrain, .anim_cache = v.anim_cache, - .player_profile = fc_player_visual_profile(v.active_loadout), + .player_profile = fc_player_visual_profile(fc_player_equipment_visual_profile(&v.state.player)), .tps = v.tps, }; unsigned char deferred_deaths[FC_MAX_NPCS]; @@ -2641,7 +2730,7 @@ int main(int argc, char** argv) { v.combat_presentation, i); } fc_actor_animation_update_models( - &v.actor_animation, &v.state, v.player_model, v.npc_models, + &v.actor_animation, &v.state, v.appearance.model, v.npc_models, v.anim_cache, v.active_loadout, v.tps, frame_dt, deferred_deaths); /* Draw */ BeginDrawing(); @@ -2652,6 +2741,12 @@ int main(int argc, char** argv) { draw_runec_side_overrides(&v); draw_runec_console(&v); draw_click_cross(&v); + if (v.item_message_seconds > 0) { + DrawRectangle(8, GetScreenHeight() - 34, 490, 26, (Color){20, 16, 12, 240}); + text_s(v.item_message, 16, GetScreenHeight() - 29, 16, YELLOW); + } + /* Menus must cover the console and prayer overrides, not sit behind them. */ + runec_ui_draw_context(&v.ui); EndDrawing(); } @@ -2680,7 +2775,7 @@ int main(int argc, char** argv) { free(v.object_anim_runtimes); } if (v.anim_cache) anim_cache_free(v.anim_cache); - if (v.player_model) fc_npc_models_unload(v.player_model); + fc_player_appearance_free(&v.appearance); if (v.npc_models) fc_npc_models_unload(v.npc_models); if (v.object_anim_models) fc_npc_models_unload(v.object_anim_models); fc_animated_atlas_unload(&v.shared_model_atlas); diff --git a/resources/fight_caves/README.md b/resources/fight_caves/README.md index 0b5b5b250e..4c9f868fde 100644 --- a/resources/fight_caves/README.md +++ b/resources/fight_caves/README.md @@ -6,6 +6,8 @@ Fight Caves uses two independently versioned asset bundles: training, evaluation, and the viewer. - `viewer` contains the models, animations, terrain, textures, sprites, fonts, and minimap used only by the graphical viewer. + Version 3 includes composable player equipment/body parts, their visibility + map, the corrected Venator ring icon and the bold RuneC context-menu font. The archives are pinned in `asset_manifest.json` by URL, byte size, and SHA-256. Every installed file is also checked by byte size and SHA-256 before it is diff --git a/resources/fight_caves/asset_manifest.json b/resources/fight_caves/asset_manifest.json index db02a75a50..52040d2678 100644 --- a/resources/fight_caves/asset_manifest.json +++ b/resources/fight_caves/asset_manifest.json @@ -1,7 +1,7 @@ { "bundles": { "core": { - "archive": "fight-caves-runtime-assets-v2.tar.gz", + "archive": "fight-caves-runtime-assets-v3.tar.gz", "files": [ { "path": "runtime/fightcaves.collision", @@ -22,10 +22,10 @@ "install_prefix": "runtime", "sha256": "8d65e184bb101af15642a1117353d22eed930f3b8e612ace7ee31c313e6123b3", "size_bytes": 1397, - "url": "https://github.com/jordanbailey00/fc-rl/releases/download/fight-caves-assets-v2/fight-caves-runtime-assets-v2.tar.gz" + "url": "https://github.com/jordanbailey00/fc-rl/releases/download/fight-caves-assets-v3/fight-caves-runtime-assets-v3.tar.gz" }, "viewer": { - "archive": "fight-caves-viewer-assets-v2.tar.gz", + "archive": "fight-caves-viewer-assets-v3.tar.gz", "files": [ { "path": "viewer/data/fonts/p11_full.png", @@ -37,6 +37,11 @@ "sha256": "7477bbfd998b8e16e8a1e898fd49fb736a6fdfa00e7eb364d4d441f10290f03a", "size_bytes": 22500 }, + { + "path": "viewer/data/fonts/runescape_bold.ttf", + "sha256": "c8b9afefe105a78717fde9692deafbf2361b54bcf9c8019b6d817de94f1c6168", + "size_bytes": 22436 + }, { "path": "viewer/data/fonts/runescape_small.ttf", "sha256": "fe43104752ad819d5b037bb544a194f7dc8fdf1c2dfe7f4cdf20a4f98896ed8a", @@ -197,11 +202,6 @@ "sha256": "d02e085d21a8c7395af888f84803ccadd01eb32751a3477979725ccad12f14d0", "size_bytes": 356 }, - { - "path": "viewer/data/sprites/items/item_25487.png", - "sha256": "737efd24af57fdf658bebd9e181f26dc4b0ff327a896f808b18ff840e4e6e81d", - "size_bytes": 1324 - }, { "path": "viewer/data/sprites/items/item_2577.png", "sha256": "b66101760d14d4663bb4cd2de59b8000b08fe32a9faf71ab8e74b5067a7f4425", @@ -257,6 +257,11 @@ "sha256": "e8765f799ff6b6534d273e155e0b24b5dc5717c5b523d79623e3e1a4c4912a5c", "size_bytes": 708 }, + { + "path": "viewer/data/sprites/items/item_28310.png", + "sha256": "737efd24af57fdf658bebd9e181f26dc4b0ff327a896f808b18ff840e4e6e81d", + "size_bytes": 1324 + }, { "path": "viewer/data/sprites/items/item_385.png", "sha256": "a73db2538fab30a4900330bec9503f42435ccacb5def2974ea62100ab401ca19", @@ -3359,8 +3364,13 @@ }, { "path": "viewer/fc_player.models", - "sha256": "c86451aacacd3b9f715ac1264f09003e83c6803538c5636972d7ba6b3e32cd1f", - "size_bytes": 1117473 + "sha256": "d056dd8db14d53a804bbe5a216f1168816d4a947c862a4db9075b9a3b1196641", + "size_bytes": 521341 + }, + { + "path": "viewer/fc_player.parts", + "sha256": "8606aabd0ef16d5e4c803994f63a98885435364536e8bf5d2971edcbf69caa11", + "size_bytes": 272 }, { "path": "viewer/fc_projectiles.models", @@ -3409,15 +3419,15 @@ } ], "install_prefix": "viewer", - "sha256": "0871fc747270e94896069f408841be1f69d40befc8a808cf4222df24775c1e0f", - "size_bytes": 6442598, - "url": "https://github.com/jordanbailey00/fc-rl/releases/download/fight-caves-assets-v2/fight-caves-viewer-assets-v2.tar.gz" + "sha256": "7f43e3c1a55b43fc791097f3e25c925250bd939d2adc9aa7438018fced247943", + "size_bytes": 6150984, + "url": "https://github.com/jordanbailey00/fc-rl/releases/download/fight-caves-assets-v3/fight-caves-viewer-assets-v3.tar.gz" } }, - "release_tag": "fight-caves-assets-v2", + "release_tag": "fight-caves-assets-v3", "schema_version": 1, "source": { "repository": "https://github.com/jordanbailey00/fc-rl", - "revision": "2c5b7641a3ed56f9b1bf8febea59a7f9d2f4a86f" + "revision": "73129580cb7f7aaae9955511ea377b77ab853026" } } diff --git a/tests/fight_caves.c b/tests/fight_caves.c index b7e67d0a28..02fbb08ab0 100644 --- a/tests/fight_caves.c +++ b/tests/fight_caves.c @@ -1,3 +1,6 @@ +#ifdef NDEBUG +#undef NDEBUG +#endif #include "simulation.h" #include #include @@ -32,7 +35,8 @@ static void check_observation(const FcState* state) { } } -int main(void) { +static int core_contract_test(void) { + _Static_assert(FC_STATE_HASH_VERSION == 5, "equipment state hash version drifted"); _Static_assert(FC_POLICY_OBS_SIZE == 286, "policy observation contract drifted"); _Static_assert(FC_PUFFER_OBS_SIZE == 320, "Puffer observation contract drifted"); _Static_assert(FC_PUFFER_MASK_SIZE == 34, "Puffer mask contract drifted"); @@ -64,7 +68,7 @@ int main(void) { if (!fc_is_terminal(&first)) fail("test trajectory did not exercise a terminal transition"); - if (steps != 483 || fc_state_hash(&first) != 0xa361005cu) + if (steps != 483 || fc_state_hash(&first) != 0x5fcadd73u) fail("fixed-seed trajectory changed; review and update the contract fixture intentionally"); printf("core_contract_test: passed (%d steps, hash=%08x)\n", @@ -73,3 +77,538 @@ int main(void) { fc_destroy(&second); return EXIT_SUCCESS; } + +/* The same equipment transaction scenarios used to validate v38. */ +#include +#include +#include + +#define CHECK(test) do { if (!(test)) { \ + fprintf(stderr, "equipment: line %d: %s\n", __LINE__, #test); return 1; \ +} } while (0) + +static void reset(FcState *s, int empty_inventory) { + fc_init(s); + fc_reset(s, 101); + if (empty_inventory) fc_set_initial_supplies(s, 0, 0); +} + +static int item_slot(const FcPlayer *p, int id) { + for (int i = 0; i < FC_INVENTORY_SLOTS; i++) + if (p->inventory[i].item_id == id) return i; + return -1; +} + +static int loadout_totals(void) { + /* All existing presets, not only the compiled training preset. This is + * the real reset helper, not a duplicated test stat calculator. */ + for (int i = 0; i < FC_NUM_LOADOUTS; i++) { + FcPlayer p = {0}; + const FcLoadout *l = &FC_LOADOUTS[i]; + fc_items_init(&p, l); + CHECK(p.ranged_attack_bonus == l->ranged_atk); + CHECK(p.ranged_strength_bonus == l->ranged_str); + CHECK(p.defence_stab == l->def_stab); + CHECK(p.defence_slash == l->def_slash); + CHECK(p.defence_crush == l->def_crush); + CHECK(p.defence_magic == l->def_magic); + CHECK(p.defence_ranged == l->def_ranged); + CHECK(p.prayer_bonus == l->prayer_bonus); + CHECK(p.weapon_kind == l->weapon_kind); + CHECK(p.weapon_speed == l->weapon_speed); + CHECK(p.weapon_range == l->weapon_range); + CHECK(p.weapon_uses_ammo == l->weapon_uses_ammo); + CHECK(p.crystal_piece_mask == l->crystal_piece_mask); + CHECK(p.ammo_count == l->ammo); + } + return 0; +} + +static int transactions(void) { + FcState s; + reset(&s, 0); + uint32_t before = fc_state_hash(&s); + CHECK(fc_unequip_item(&s, FC_EQUIP_SLOT_HEAD) == FC_ITEM_NO_SPACE); + CHECK(fc_state_hash(&s) == before); + CHECK(fc_equip_item(&s, -1) == FC_ITEM_INVALID); + CHECK(fc_equip_item(&s, 28) == FC_ITEM_INVALID); + CHECK(fc_equip_item(&s, 8) == FC_ITEM_INVALID); /* food is not equipment */ + CHECK(fc_unequip_item(&s, 6) == FC_ITEM_INVALID); /* client-only arms */ + CHECK(fc_state_hash(&s) == before); + reset(&s, 1); + s.player.current_hp = 400; + s.player.current_prayer = 370; + s.player.attack_timer = 4; + s.player.prayer_drain_counter = 21; + s.player.attack_target_idx = 0; + CHECK(fc_unequip_item(&s, FC_EQUIP_SLOT_HEAD) == FC_ITEM_OK); + CHECK(s.player.inventory[0].item_id == 27235); + CHECK(s.player.ranged_attack_bonus == 203 && s.player.ranged_strength_bonus == 97); + CHECK(s.player.defence_stab == 108 && s.player.prayer_bonus == 5); + CHECK(s.player.attack_target_idx == 0); + uint32_t rng = s.rng_state; + s.player.defence_level = 79; + before = fc_state_hash(&s); + CHECK(fc_equip_item(&s, 0) == FC_ITEM_REQUIREMENTS); + CHECK(fc_state_hash(&s) == before); + s.player.defence_level = 99; + CHECK(fc_equip_item(&s, 0) == FC_ITEM_OK); + CHECK(s.player.attack_target_idx == -1); + CHECK(s.player.ranged_attack_bonus == 215 && s.player.ranged_strength_bonus == 99); + CHECK(s.player.current_hp == 400 && s.player.current_prayer == 370); + CHECK(s.player.attack_timer == 4 && s.player.prayer_drain_counter == 21); + CHECK(s.rng_state == rng && s.tick == 0); + for (int i = 0; i < FC_EQUIPMENT_SLOTS; i++) + if (s.player.equipment[i].item_id) CHECK(fc_unequip_item(&s, i) == FC_ITEM_OK); + CHECK(s.player.weapon_kind == FC_WEAPON_UNARMED); + CHECK(s.player.ranged_attack_bonus == 0 && s.player.defence_stab == 0); + CHECK(s.player.prayer_bonus == 0 && s.player.ammo_count == 0); + for (int i = 0; i < FC_INVENTORY_SLOTS; i++) + if (s.player.inventory[i].item_id) CHECK(fc_equip_item(&s, i) == FC_ITEM_OK); + CHECK(s.player.ranged_attack_bonus == 215 && s.player.ranged_strength_bonus == 99); + CHECK(s.player.equipment[13].quantity == 50000); + s.terminal = TERMINAL_PLAYER_DEATH; + before = fc_state_hash(&s); + CHECK(fc_unequip_item(&s, 0) == FC_ITEM_BUSY); + CHECK(fc_state_hash(&s) == before); + return 0; +} + +static int two_handed_and_stacks(void) { + FcState s; + reset(&s, 0); + /* Full inventory: a one-for-one shield swap may use the source slot for + * the two-handed bow when the old shield slot is empty. */ + s.player.inventory[8] = (FcItemStack){12610, 1, 0}; + CHECK(fc_equip_item(&s, 8) == FC_ITEM_OK); + CHECK(s.player.inventory[8].item_id == 20997); + CHECK(s.player.equipment[FC_EQUIP_SLOT_SHIELD].item_id == 12610); + CHECK(s.player.weapon_kind == FC_WEAPON_UNARMED); + /* With a crossbow AND shield worn, bow needs a second inventory slot. */ + s.player.inventory[9] = (FcItemStack){9185, 1, 0}; + CHECK(fc_equip_item(&s, 9) == FC_ITEM_OK); + s.player.inventory[9] = (FcItemStack){385, 1, 0}; + uint32_t before = fc_state_hash(&s); + CHECK(fc_equip_item(&s, 8) == FC_ITEM_NO_SPACE); + CHECK(fc_state_hash(&s) == before); + s.player.inventory[14] = (FcItemStack){0}; + CHECK(fc_equip_item(&s, 8) == FC_ITEM_OK); + CHECK(s.player.inventory[8].item_id == 9185); + CHECK(s.player.inventory[14].item_id == 12610); + CHECK(s.player.equipment[FC_EQUIP_SLOT_SHIELD].item_id == 0); + reset(&s, 0); + s.player.inventory[8] = (FcItemStack){11212, 7, 0}; + CHECK(fc_unequip_item(&s, 13) == FC_ITEM_OK); /* merge despite full inventory */ + CHECK(s.player.inventory[8].quantity == 50007); + CHECK(s.player.ammo_count == 0); + CHECK(fc_equip_item(&s, 8) == FC_ITEM_OK); + CHECK(s.player.ammo_count == 50007); + s.player.inventory[8] = (FcItemStack){11212, INT_MAX - 50006, 0}; + before = fc_state_hash(&s); + CHECK(fc_unequip_item(&s, 13) == FC_ITEM_NO_SPACE); /* whole-stack overflow */ + CHECK(fc_state_hash(&s) == before); + CHECK(fc_equip_item(&s, 8) == FC_ITEM_OK); /* equip only the portion that fits */ + CHECK(s.player.equipment[13].quantity == INT_MAX); + CHECK(s.player.inventory[8].quantity == 1); + before = fc_state_hash(&s); + CHECK(fc_equip_item(&s, 8) == FC_ITEM_NO_SPACE); + CHECK(fc_state_hash(&s) == before); + s.player.inventory[8] = (FcItemStack){9143, 100, 0}; + CHECK(fc_equip_item(&s, 8) == FC_ITEM_OK); + CHECK(s.player.ammo_count == 0); /* bolts do not work in a bow */ + CHECK(s.player.inventory[8].item_id == 11212); + s.player.inventory[9] = (FcItemStack){12788, 1, 0}; + CHECK(fc_equip_item(&s, 9) == FC_ITEM_OK); + CHECK(fc_equip_item(&s, 8) == FC_ITEM_OK); /* dragon arrows in quiver */ + CHECK(s.player.ammo_count == 0); /* MSB cannot fire dragon arrows */ + s.player.inventory[8] = (FcItemStack){892, 10, 0}; + CHECK(fc_equip_item(&s, 8) == FC_ITEM_OK); + CHECK(s.player.ammo_count == 10); + s.player.inventory[9] = (FcItemStack){9185, 1, 0}; + CHECK(fc_equip_item(&s, 9) == FC_ITEM_OK); + s.player.inventory[8] = (FcItemStack){21946, 10, 0}; + CHECK(fc_equip_item(&s, 8) == FC_ITEM_OK); + CHECK(s.player.ammo_count == 0); /* rune crossbow cannot fire dragon bolts */ + s.player.inventory[9] = (FcItemStack){11785, 1, 0}; + CHECK(fc_equip_item(&s, 9) == FC_ITEM_OK); + CHECK(s.player.ammo_count == 10); + return 0; +} + +static int supplies_and_loaded_weapon(void) { + FcState s; + reset(&s, 1); + fc_set_initial_supplies(&s, 2, 5); + CHECK(s.player.inventory[0].item_id == 2434 && s.player.inventory[1].item_id == 143); + CHECK(s.player.inventory[2].item_id == 385 && s.player.inventory[3].item_id == 385); + CHECK(fc_inventory_swap(&s, 1, 27) == FC_ITEM_OK); + CHECK(fc_select_consumable(&s, 27) == FC_ITEM_OK); + CHECK(fc_select_consumable(&s, 3) == FC_ITEM_OK); + s.player.current_hp = 400; + s.player.current_prayer = 100; + int actions[FC_NUM_ACTION_HEADS] = {0}; + actions[3] = FC_EAT_SHARK; + actions[4] = FC_DRINK_PRAYER_POT; + fc_step(&s, actions); + CHECK(s.player.inventory[27].item_id == 229); + CHECK(s.player.inventory[0].item_id == 2434); + CHECK(s.player.inventory[3].item_id == 0 && s.player.inventory[2].item_id == 385); + CHECK(s.player.sharks_remaining == 1 && s.player.prayer_doses_remaining == 4); + CHECK(s.player.selected_food_slot == -1 && s.player.selected_potion_slot == -1); + reset(&s, 1); + fc_items_init(&s.player, &FC_LOADOUTS[FC_LOADOUT_BLOWPIPE_PURE]); + fc_set_initial_supplies(&s, 0, 0); + fc_items_spend_ammo(&s.player); + CHECK(s.player.equipment[3].charges == 49999); + CHECK(s.player.equipment[13].item_id == 0); + CHECK(fc_unequip_item(&s, 3) == FC_ITEM_OK); + CHECK(s.player.inventory[0].charges == 49999); + CHECK(fc_equip_item(&s, 0) == FC_ITEM_OK); + CHECK(s.player.ammo_count == 49999); + return 0; +} + +static int combat(void) { + FcState s; + reset(&s, 1); + memset(s.npcs, 0, sizeof(s.npcs)); + memset(s.walkable, 1, sizeof(s.walkable)); + memset(s.movement_flags, 0, sizeof(s.movement_flags)); + memset(s.los_flags, 0, sizeof(s.los_flags)); + s.player.x = 20; s.player.y = 20; + fc_npc_spawn(&s.npcs[0], NPC_YT_MEJKOT, 20, 25, 1); + s.npcs_remaining = 1; + s.npcs[0].attack_timer = 100; + s.player.attack_target_idx = 0; + int actions[FC_NUM_ACTION_HEADS] = {0}; + fc_step(&s, actions); + CHECK(s.render_events.player_attack_fired); + CHECK(s.player.ammo_count == 49999 && s.player.equipment[13].quantity == 49999); + FcPendingHit hit = s.npcs[0].pending_hits[0]; + int cooldown = s.player.attack_timer; + CHECK(fc_unequip_item(&s, 3) == FC_ITEM_OK); + CHECK(s.player.attack_target_idx == 0 && s.player.attack_timer == cooldown); + CHECK(memcmp(&hit, &s.npcs[0].pending_hits[0], sizeof(hit)) == 0); + CHECK(s.player.weapon_range == 1 && s.player.weapon_speed == 4 && !s.player.weapon_uses_ammo); + s.player.attack_timer = 0; + s.npcs[0].x = 21; s.npcs[0].y = 21; + fc_step(&s, actions); + CHECK(!s.render_events.player_attack_fired); /* diagonal is not melee contact */ + s.npcs[0].x = 21; s.npcs[0].y = 20; + fc_step(&s, actions); + CHECK(s.render_events.player_attack_fired); + CHECK(s.render_events.player_attack_hit_delay_ticks == 1); + CHECK(s.player.attack_timer == 3 && s.player.ammo_count == 0); /* end-of-tick decrement */ + int melee_hit = 0; + for (int i = 0; i < s.render_events.hit_count; i++) + if (s.render_events.hits[i].target_entity_type == ENTITY_NPC && + s.render_events.hits[i].attack_style == ATTACK_MELEE) melee_hit = 1; + CHECK(melee_hit); /* delay-one queues resolve during this tick's hit phase */ + CHECK(fc_equip_item(&s, item_slot(&s.player, 20997)) == FC_ITEM_OK); + CHECK(s.player.attack_timer == 3 && s.player.attack_target_idx == -1); + int route_x[64], route_y[64]; + int len = fc_pathfind_attack_position(20, 20, 25, 25, 5, FC_ROUTE_MELEE_RANGE, + s.walkable, s.movement_flags, s.los_flags, route_x, route_y, 64); + CHECK(len > 0); + CHECK(fc_npc_can_melee_player(route_x[len-1], route_y[len-1], 25, 25, 5, + s.walkable, s.movement_flags)); + return 0; +} + +static int equipment_test(void) { + if (loadout_totals() || transactions() || two_handed_and_stacks() || + supplies_and_loaded_weapon() || combat()) return 1; + puts("equipment: preset preservation, atomic transfers, stacks, requirements, supplies and combat passed"); + return 0; +} + +#undef CHECK + +#ifdef FC_VIEWER_TEST +#include "render.h" +#include "ui.h" + +#include +#include +#include + +static int context_menu_test(void) { + FcMenuLayout menu = fc_menu_layout(400, 200, 800, 600, 150, 4); + assert(menu.x == 319 && menu.y == 200 && menu.width == 162); + assert(menu.height == FC_MENU_HEADER_HEIGHT + 4 * FC_MENU_ROW_HEIGHT + 4); + assert(fc_menu_action_at(menu, 4, 330, 200) == -1); /* title is not a choice */ + for (int row = 0; row < 4; row++) { + int y = menu.y + FC_MENU_HEADER_HEIGHT + row * FC_MENU_ROW_HEIGHT; + assert(fc_menu_action_at(menu, 4, 330, y) == row); + assert(fc_menu_action_at(menu, 4, 330, y + FC_MENU_ROW_HEIGHT - 1) == row); + } + assert(fc_menu_action_at(menu, 4, 330, menu.y + menu.height - 1) == -1); + assert(fc_menu_action_at(menu, 4, menu.x - 1, 230) == -1); + assert(fc_menu_action_at(menu, 4, menu.x + menu.width, 230) == -1); + assert(fc_menu_contains(menu, menu.x - 9, 230, FC_MENU_DISMISS_MARGIN)); + assert(!fc_menu_contains(menu, menu.x - 11, 230, FC_MENU_DISMISS_MARGIN)); + menu = fc_menu_layout(799, 599, 800, 600, 220, 3); + assert(menu.x + menu.width == 800 && menu.y + menu.height == 600); + assert(fc_menu_action_at(menu, 3, 790, menu.y + FC_MENU_HEADER_HEIGHT) == 0); + menu = fc_menu_layout(0, 0, 800, 600, 150, 4); + assert(menu.x == 0 && menu.y == 0); + menu = fc_menu_layout(0, 0, 80, 200, 150, 4); + assert(menu.width == 80 && menu.x == 0); + const int levels[] = {0, 22, 45, 22, 90, 180, 360, 702, 108}; + for (int type = 1; type <= 8; type++) + assert(fc_menu_npc_info(type).level == levels[type]); + assert(strcmp(fc_menu_npc_info(7).name, "TzTok-Jad") == 0); + assert(strcmp(fc_menu_npc_info(2).name, fc_menu_npc_info(3).name) == 0); + assert(fc_menu_npc_info(0).level == 0 && fc_menu_npc_info(-1).level == 0); + assert(fc_menu_npc_info(9).level == 0); + const uint32_t colors[] = { + 0xff0000, 0xff3000, 0xff3000, 0xff3000, 0xff7000, 0xff7000, + 0xff7000, 0xffb000, 0xffb000, 0xffb000, 0xffff00, + 0xc0ff00, 0xc0ff00, 0xc0ff00, 0x80ff00, 0x80ff00, 0x80ff00, + 0x40ff00, 0x40ff00, 0x40ff00, 0x00ff00 + }; + for (int difference = -10; difference <= 10; difference++) + assert(fc_menu_level_color(100 + difference, 100) == colors[difference + 10]); + assert(fc_menu_level_color(126, 702) == 0xff0000); + assert(fc_menu_level_color(126, 108) == 0x00ff00); + puts("context menu: anchoring, screen edges, rows, title and dismissal margin passed"); + return 0; +} + + +#include +#include +#include + +static void make_open_state(FcState* state) { + memset(state, 0, sizeof(*state)); + for (int x = 0; x < FC_ARENA_WIDTH; x++) { + for (int y = 0; y < FC_ARENA_HEIGHT; y++) { + state->walkable[x][y] = 1; + } + } + state->player.x = 1; + state->player.y = 1; +} + +static int click_feedback_test(void) { + FcState state; + FcClickFeedback feedback; + const int* route_x = NULL; + const int* route_y = NULL; + int start = -1; + int len = -1; + + make_open_state(&state); + FcState unchanged = state; + fc_click_feedback_reset(&feedback); + fc_click_feedback_select_move(&feedback, &state, 5, 3, 120.0f, 80.0f); + + assert(feedback.destination_active); + assert(feedback.destination_x == 5 && feedback.destination_y == 3); + assert(feedback.preview_pending); + assert(feedback.preview_route_len > 0); + assert(feedback.preview_route_x[feedback.preview_route_len - 1] == 5); + assert(feedback.preview_route_y[feedback.preview_route_len - 1] == 3); + assert(feedback.cross_kind == FC_CLICK_CROSS_MOVE); + assert(fc_click_feedback_cross_frame(&feedback) == 0); + assert(fc_click_feedback_route(&feedback, &state, &route_x, &route_y, + &start, &len)); + assert(route_x == feedback.preview_route_x); + assert(route_y == feedback.preview_route_y); + assert(start == 0 && len == feedback.preview_route_len); + + fc_click_feedback_update(&feedback, 0.11f); + assert(fc_click_feedback_cross_frame(&feedback) == 1); + fc_click_feedback_update(&feedback, 0.30f); + assert(feedback.cross_kind == FC_CLICK_CROSS_NONE); + + state.player.route_x[0] = 2; + state.player.route_y[0] = 2; + state.player.route_x[1] = 3; + state.player.route_y[1] = 3; + state.player.route_len = 2; + state.player.route_idx = 1; + fc_click_feedback_accept_move_tick(&feedback, &state); + assert(!feedback.preview_pending); + assert(feedback.destination_active); + assert(fc_click_feedback_route(&feedback, &state, &route_x, &route_y, + &start, &len)); + assert(route_x == state.player.route_x && route_y == state.player.route_y); + assert(start == 1 && len == 2); + + state.player.route_idx = state.player.route_len; + fc_click_feedback_sync(&feedback, &state); + assert(!feedback.destination_active); + + fc_click_feedback_select_move(&feedback, &state, 7, 7, 10.0f, 20.0f); + fc_click_feedback_select_interaction(&feedback, 30.0f, 40.0f); + assert(!feedback.destination_active); + assert(!feedback.preview_pending); + assert(feedback.preview_route_len == 0); + assert(feedback.cross_kind == FC_CLICK_CROSS_INTERACTION); + assert(feedback.cross_screen_x == 30.0f && feedback.cross_screen_y == 40.0f); + assert(fc_click_feedback_cross_frame(&feedback) == 0); + fc_click_feedback_update(&feedback, 0.099f); + assert(fc_click_feedback_cross_frame(&feedback) == 0); + fc_click_feedback_update(&feedback, 0.002f); + assert(fc_click_feedback_cross_frame(&feedback) == 1); + /* Test only the presentation operation against a complete state snapshot. */ + FcState before_interaction = state; + fc_click_feedback_select_interaction(&feedback, 70.0f, 80.0f); + assert(memcmp(&state, &before_interaction, sizeof(state)) == 0); + fc_click_feedback_select_move(&feedback, &unchanged, 3, 5, 10, 20); + FcState fresh; + make_open_state(&fresh); + assert(memcmp(&fresh, &unchanged, sizeof(fresh)) == 0); + + puts("click feedback tests passed"); + return 0; +} + +#include "raymath.h" +#include +#include +#include + +static int model_picking_test(void) { + /* Tall two-triangle actor. The uploaded mesh deliberately belongs to a + * different actor: picking must use this instance's pose or the rest mesh. */ + float rest[] = {-0.6f,0,0, 0.6f,0,0, 0.6f,4,0, -0.6f,0,0, 0.6f,4,0, -0.6f,4,0}; + float other_actor[18] = {100,100,100}; + uint16_t faces[] = {0,1,2,0,2,3}; + int16_t pose[] = {-77,0,0, 77,0,0, 77,-512,0, -77,-512,0}; + float original_rest[18]; int16_t original_pose[12]; + memcpy(original_rest, rest, sizeof(rest)); + memcpy(original_pose, pose, sizeof(pose)); + Mesh uploaded = {.vertices=other_actor}; + ModelEntry entry = {.loaded=1, .rest_verts=rest, .face_count=2, + .face_indices=faces, .base_vert_count=4}; + entry.model.transform = MatrixIdentity(); + entry.model.meshes = &uploaded; + Camera3D camera = {.position={0,4,10}, .target={0,2,0}, .up={0,1,0}, + .fovy=50, .projection=CAMERA_PERSPECTIVE}; + Vector3 origin = {0}; + Vector2 head = GetWorldToScreenEx((Vector3){0,3.8f,0}, camera, 800, 600); + assert(models_pick_depth(&entry, NULL, origin, 0, camera, head, 800, 600) > 0); + assert(models_pick_depth(&entry, pose, origin, 0, camera, head, 800, 600) > 0); + /* A head click projects onto ground far behind the actor: the old tile + * halo would miss, even though the pointer is inside the visible model. */ + Vector3 ray = Vector3Subtract((Vector3){0,3.8f,0}, camera.position); + float ground_z = camera.position.z + ray.z * (-camera.position.y / ray.y); + assert(fabsf(ground_z) > 2); + + Vector2 top = GetWorldToScreenEx((Vector3){0,4,0}, camera, 800, 600); + assert(models_pick_depth(&entry, pose, origin, 0, camera, + (Vector2){top.x, top.y - 4}, 800, 600) > 0); + assert(models_pick_depth(&entry, pose, origin, 0, camera, + (Vector2){top.x, top.y - 6}, 800, 600) < 0); + assert(models_pick_depth(&entry, pose, origin, 0, camera, (Vector2){10,10}, 800,600) < 0); + + /* Moving and turned models are picked where drawn, not at their old tile. */ + Vector3 moved = {4,0,0}; + Vector2 moved_head = GetWorldToScreenEx((Vector3){4,3.8f,0}, camera, 800,600); + assert(models_pick_depth(&entry, pose, moved, 90, camera, moved_head, 800,600) > 0); + assert(models_pick_depth(&entry, pose, moved, 90, camera, head, 800,600) < 0); + /* Two instances of the same model can be in different animation poses. */ + int16_t shifted[12]; memcpy(shifted, pose, sizeof(pose)); + for (int i = 0; i < 4; i++) shifted[i*3] += 512; + assert(models_pick_depth(&entry, shifted, origin, 0, camera, moved_head, 800,600) > 0); + assert(models_pick_depth(&entry, shifted, origin, 0, camera, head, 800,600) < 0); + assert(models_pick_depth(&entry, pose, origin, 0, camera, head, 800,600) > 0); + + Vector2 center = GetWorldToScreenEx((Vector3){0,2,0}, camera, 800,600); + float front = models_pick_depth(&entry, pose, origin, 0, camera, center, 800,600); + float back = models_pick_depth(&entry, pose, (Vector3){0,0,-4}, 0, camera, center, 800,600); + assert(front > 0 && back > front); /* overlap ordering */ + assert(models_pick_depth(&entry, pose, (Vector3){0,0,30}, 0, camera, center, 800,600) < 0); + assert(models_pick_depth(NULL, pose, origin, 0, camera, center, 800,600) < 0); + assert(memcmp(rest, original_rest, sizeof(rest)) == 0); + assert(memcmp(pose, original_pose, sizeof(pose)) == 0); + assert(other_actor[0] == 100 && other_actor[3] == 0); + puts("model picking: head, padding, rotation, movement, per-instance pose and depth passed"); + return 0; +} + +#include + +/* Explicit graphics test, not part of headless CTest. Captures a contact sheet + * in the working directory: outfit, no helmet/body/legs/gloves/boots/bow, bare. */ +static int equipment_appearance_test(void) { + SetTraceLogLevel(LOG_WARNING); + SetConfigFlags(FLAG_WINDOW_HIDDEN); + InitWindow(1200, 640, "Equipment appearance validation"); + if (!IsWindowReady()) return 1; + FcPlayerAppearance appearance; + if (!fc_player_appearance_load(&appearance)) return 1; + AnimCache *cache = anim_cache_load("fc_all.anims"); + if (!cache || !anim_get_sequence(cache, 422)) return 1; + RenderTexture2D target = LoadRenderTexture(1200, 640); + BeginTextureMode(target); + ClearBackground((Color){45, 45, 45, 255}); + const int removed[] = {-1, 0, 4, 7, 9, 10, 3, -2}; + FcState state; + fc_init(&state); + for (int variant = 0; variant < 8; variant++) { + fc_reset(&state, 101); + fc_set_initial_supplies(&state, 0, 0); + if (removed[variant] == -2) { + for (int slot = 0; slot < FC_EQUIPMENT_SLOTS; slot++) + if (state.player.equipment[slot].item_id && + fc_unequip_item(&state, slot) != FC_ITEM_OK) return 1; + } else if (removed[variant] >= 0 && + fc_unequip_item(&state, removed[variant]) != FC_ITEM_OK) return 1; + uint32_t before = fc_state_hash(&state); + if (fc_player_appearance_sync(&appearance, &state.player, FC_PLAYER_MODEL_BASE) != 1) + return 1; + if (fc_player_appearance_sync(&appearance, &state.player, FC_PLAYER_MODEL_BASE) != 0) + return 1; + ModelEntry *entry = appearance.model->entries; + for (int f = 0; f < entry->face_count * 3; f++) + if (entry->face_indices[f] >= entry->base_vert_count) return 1; + AnimModelState *pose = NULL; + uint16_t sequence = 0; + int frame = 0; + float timer = 0; + fc_model_animation_update(entry, cache, &pose, &sequence, &frame, + &timer, 808, 0, 0); + Camera3D camera = {.position={0, 1.6f, -7}, .target={0, 1.0f, 0}, + .up={0, 1, 0}, .fovy=4.4f, .projection=CAMERA_ORTHOGRAPHIC}; + BeginScissorMode((variant % 4) * 300, (variant / 4) * 320, 300, 320); + /* Move the model through one common camera to avoid changing meshes + * or animation coordinates for the contact-sheet layout. */ + camera.position.x = camera.target.x = ((variant % 4) - 1.5f) * 2.0625f; + camera.position.y += (variant / 4 ? 1 : -1) * 1.1f; + camera.target.y += (variant / 4 ? 1 : -1) * 1.1f; + BeginMode3D(camera); + DrawModelEx(entry->model, (Vector3){0, 0, 0}, (Vector3){0, 1, 0}, + 180, (Vector3){1, 1, 1}, WHITE); + EndMode3D(); + EndScissorMode(); + anim_model_state_free(pose); + if (fc_state_hash(&state) != before) return 1; + printf("appearance variant %d: %d vertices, %d faces\n", + variant, entry->base_vert_count, entry->face_count); + } + EndTextureMode(); + Image image = LoadImageFromTexture(target.texture); + ImageFlipVertical(&image); + int ok = ExportImage(image, "equipment-appearance.png"); + UnloadImage(image); + UnloadRenderTexture(target); + anim_cache_free(cache); + fc_player_appearance_free(&appearance); + CloseWindow(); + return ok ? 0 : 1; +} + +#endif + +int main(void) { + if (core_contract_test() || equipment_test()) return 1; +#ifdef FC_VIEWER_TEST + if (context_menu_test() || click_feedback_test() || model_picking_test() || + equipment_appearance_test()) return 1; +#endif + return 0; +} diff --git a/tests/fight_caves.sh b/tests/fight_caves.sh index 85dceb4abb..d97b3e9943 100644 --- a/tests/fight_caves.sh +++ b/tests/fight_caves.sh @@ -37,6 +37,24 @@ fi if [ "$MODE" = "--all" ]; then "$PYTHON" ocean/fight_caves/tools.py build-viewer + cmake --build build/fight_caves-viewer --target fc_viewer_tests --parallel + if command -v xvfb-run >/dev/null 2>&1; then + DISPLAY_PREFIX=(xvfb-run -a) + elif [ -n "${DISPLAY:-}" ]; then + DISPLAY_PREFIX=() + else + echo "Viewer tests require xvfb-run or an existing DISPLAY." >&2 + exit 1 + fi + ( + cd "$TEST_ROOT" + export FC_REPO_ROOT="$REPO_ROOT" + export FC_ASSET_ROOT="$REPO_ROOT/resources/fight_caves/viewer" + export FC_COLLISION_PATH="$REPO_ROOT/resources/fight_caves/runtime/fightcaves.collision" + export FC_MOVEMENT_PATH="$REPO_ROOT/resources/fight_caves/runtime/fightcaves.movement" + export FC_LOS_PATH="$REPO_ROOT/resources/fight_caves/runtime/fightcaves.los" + "${DISPLAY_PREFIX[@]}" "$REPO_ROOT/build/fight_caves-viewer/fc_viewer_tests" + ) fi echo "Fight Caves environment tests passed ($MODE)." diff --git a/tests/test_fight_caves.py b/tests/test_fight_caves.py index 479dcbe01d..c5cf4235b8 100644 --- a/tests/test_fight_caves.py +++ b/tests/test_fight_caves.py @@ -263,6 +263,39 @@ def test_checkpoint_format_rejects_unknown_or_missing_file(tmp_path): assert EVAL_CONTRACT.checkpoint_format(checkpoint, 64) is None assert EVAL_CONTRACT.checkpoint_format(tmp_path / "missing.bin", 64) is None + +@pytest.mark.parametrize("saved,current,accepted", [ + (4, 5, True), (5, 5, True), (5, 4, False), (3, 5, False), (6, 5, False), +]) +def test_equipment_hash_checkpoint_migration_is_directional(tmp_path, saved, current, accepted): + expected = {"state_hash_version": current, "puffer_obs_size": 320, + "puffer_action_dims": [17, 9, 8], "reward_version": "unchanged"} + actual = dict(expected, state_hash_version=saved) + marker = tmp_path / "contract.json" + marker.write_text(json.dumps({"contract": actual})) + preflight = {"contract": expected} + if accepted: + EVAL_CONTRACT.validate_checkpoint_marker(marker, preflight) + else: + with pytest.raises(EVAL_CONTRACT.ContractError, match="does not match"): + EVAL_CONTRACT.validate_checkpoint_marker(marker, preflight) + assert expected["state_hash_version"] == current + + +@pytest.mark.parametrize("field,value", [ + ("puffer_obs_size", 319), ("puffer_action_dims", [17, 9, 8, 14]), + ("reward_version", "different"), ("unknown_field", 1), +]) +def test_equipment_hash_migration_does_not_hide_other_contract_changes(tmp_path, field, value): + expected = {"state_hash_version": 5, "puffer_obs_size": 320, + "puffer_action_dims": [17, 9, 8], "reward_version": "unchanged"} + actual = dict(expected, state_hash_version=4) + actual[field] = value + marker = tmp_path / "contract.json" + marker.write_text(json.dumps({"contract": actual})) + with pytest.raises(EVAL_CONTRACT.ContractError, match="does not match"): + EVAL_CONTRACT.validate_checkpoint_marker(marker, {"contract": expected}) + ENV_ROOT = REPO_ROOT / "ocean" / "fight_caves" RESOURCE_ROOT = REPO_ROOT / "resources" / "fight_caves" @@ -294,6 +327,15 @@ def test_asset_manifest_is_complete_and_pinned(): assert all(len(entry["sha256"]) == 64 and entry["size_bytes"] > 0 for entry in bundle["files"]) +def test_asset_manifest_includes_equipment_parts_and_menu_font(): + manifest = json.loads((RESOURCE_ROOT / "asset_manifest.json").read_text()) + files = {entry["path"] for entry in manifest["bundles"]["viewer"]["files"]} + assert {"viewer/fc_player.parts", "viewer/fc_player.models", + "viewer/data/fonts/runescape_bold.ttf", + "viewer/data/sprites/items/item_28310.png"} <= files + assert "viewer/data/sprites/items/item_25487.png" not in files + + def test_fight_caves_sources_do_not_reference_local_development_trees(): forbidden = ("/home/joe", "/v38/", "pufferlib_4", "runescape-reference") roots = ( From 1021be379e53092b2ad2da320b8fab2de9b3adee Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:11:11 -0400 Subject: [PATCH 04/14] Include optional Docker setup in Fight Caves PR --- ocean/fight_caves/Dockerfile | 67 +++++++++++++++ ocean/fight_caves/Dockerfile.dockerignore | 56 +++++++++++++ ocean/fight_caves/README.md | 99 +++++++++++++++++++++++ ocean/fight_caves/docker-constraints.txt | 8 ++ 4 files changed, 230 insertions(+) create mode 100644 ocean/fight_caves/Dockerfile create mode 100644 ocean/fight_caves/Dockerfile.dockerignore create mode 100644 ocean/fight_caves/docker-constraints.txt diff --git a/ocean/fight_caves/Dockerfile b/ocean/fight_caves/Dockerfile new file mode 100644 index 0000000000..ada1fe10a3 --- /dev/null +++ b/ocean/fight_caves/Dockerfile @@ -0,0 +1,67 @@ +# syntax=docker/dockerfile:1 +# Build from the repository root: +# docker build --platform linux/amd64 -f ocean/fight_caves/Dockerfile -t fight-caves:local . +# The CUDA backend is compiled inside the running container; see README.md. +FROM ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 + +ARG DEBIAN_FRONTEND=noninteractive +ARG CUDNN_VERSION=9.13.0.50-1 +ARG NCCL_VERSION=2.27.7-1+cuda13.0 + +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + +RUN test "$(dpkg --print-architecture)" = amd64 \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + binutils build-essential ca-certificates ccache clang cmake curl git \ + libgl1-mesa-dev libgl1-mesa-dri libomp-dev libomp5 \ + libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev \ + python3-dev python3-venv x11-utils xauth xvfb \ + && curl -fsSL \ + https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/cuda-keyring_1.1-1_all.deb \ + -o /tmp/cuda-keyring.deb \ + && dpkg -i /tmp/cuda-keyring.deb \ + && rm /tmp/cuda-keyring.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + cuda-cudart-dev-13-0 cuda-nvcc-13-0 cuda-nvml-dev-13-0 \ + cuda-nvtx-13-0 cuda-profiler-api-13-0 \ + libcublas-dev-13-0 libcurand-dev-13-0 libcusolver-dev-13-0 \ + "libcudnn9-cuda-13=${CUDNN_VERSION}" \ + "libcudnn9-dev-cuda-13=${CUDNN_VERSION}" \ + "libcudnn9-headers-cuda-13=${CUDNN_VERSION}" \ + "libnccl2=${NCCL_VERSION}" "libnccl-dev=${NCCL_VERSION}" \ + && rm -rf /var/lib/apt/lists/* + +ENV CUDA_HOME=/usr/local/cuda-13.0 \ + VIRTUAL_ENV=/workspace/PufferLib/.venv \ + NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics,display +ENV PATH="${VIRTUAL_ENV}/bin:${CUDA_HOME}/bin:${PATH}" + +# The host driver is injected by NVIDIA Container Toolkit at runtime. Puffer's +# -lnvidia-ml also needs this unversioned linker name; it is dangling at build time. +RUN ln -s libnvidia-ml.so.1 /usr/lib/x86_64-linux-gnu/libnvidia-ml.so \ + && printf '%s\n' "${CUDA_HOME}/lib64" > /etc/ld.so.conf.d/fight-caves-cuda.conf \ + && ldconfig \ + && printf '%s\n' 'export PATH="$VIRTUAL_ENV/bin:$CUDA_HOME/bin:$PATH"' \ + > /etc/profile.d/fight-caves-env.sh + +WORKDIR /workspace/PufferLib +COPY ocean/fight_caves/docker-constraints.txt /opt/fight-caves/constraints.txt +RUN python3 -m venv --prompt fight-caves "$VIRTUAL_ENV" \ + && python -m pip install --no-cache-dir --upgrade pip setuptools wheel \ + && python -m pip install --no-cache-dir \ + -c /opt/fight-caves/constraints.txt \ + --index-url https://download.pytorch.org/whl/cu130 torch + +# Dockerfile.dockerignore limits this context to Puffer and Fight Caves sources. +COPY . . +RUN python -m pip install --no-cache-dir -c /opt/fight-caves/constraints.txt -e . pytest \ + && python -m pip check \ + && python ocean/fight_caves/tools.py setup --all \ + && python ocean/fight_caves/tools.py setup --all --verify-only \ + && python ocean/fight_caves/tools.py build-viewer \ + && ln -s build/raylib-5.5_linux_amd64 raylib-5.5_linux_amd64 \ + && bash tests/fight_caves.sh test --core + +CMD ["bash"] diff --git a/ocean/fight_caves/Dockerfile.dockerignore b/ocean/fight_caves/Dockerfile.dockerignore new file mode 100644 index 0000000000..0593466284 --- /dev/null +++ b/ocean/fight_caves/Dockerfile.dockerignore @@ -0,0 +1,56 @@ +# Paths are relative to the repository root, used as the build context. +# Include only the code and manifests needed by Puffer and Fight Caves. +** +!README.md +!LICENSE +!pyproject.toml +!build.sh +!pufferlib/ +!pufferlib/** +!src/ +!src/** +!vendor/ +!vendor/** +!config/ +config/* +!config/default.ini +!config/fight_caves.ini +!ocean/ +ocean/* +!ocean/fight_caves/ +!ocean/fight_caves/** +# Puffer's shared CUDA encoder unconditionally includes this generated table. +!ocean/nethack/ +ocean/nethack/* +!ocean/nethack/glyph_map.h +!resources/ +resources/* +!resources/fight_caves/ +resources/fight_caves/* +!resources/fight_caves/asset_manifest.json +!resources/fight_caves/ASSET_NOTICE.md +!resources/fight_caves/README.md +!tests/ +tests/* +!tests/fight_caves.sh +!tests/fight_caves.c +!tests/test_fight_caves.py + +# Never import local binaries, caches, credentials or generated assets. +**/__pycache__/ +**/*.pyc +**/*.so +**/*.o +**/*.a +**/*.egg-info/ +**/.git +**/.env +**/.env.* +**/.netrc +**/.venv/ +**/build/ +**/checkpoints/ +**/logs/ +**/wandb/ +vendor/fast-nle/ +vendor/nle/ diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index 8267bad565..de5735ebea 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -22,6 +22,8 @@ The environment uses flat implementation headers, with no separate `src/`, - `tools.py`: asset installation/verification, bundle creation, preflight, optional viewer build, playable launch and checkpoint replay. - `CMakeLists.txt`: optional viewer build using Puffer's pinned Raylib 5.5. +- `Dockerfile`, `Dockerfile.dockerignore`, `docker-constraints.txt`: optional + Ubuntu/CUDA setup with pinned Python dependencies. Acceptance tests live in the repository's `tests/` directory. The full graphical viewer is retained; the flat layout does not substitute a minimal renderer or @@ -36,6 +38,8 @@ Native builds require Clang, `ar`, and an OpenMP development runtime. The viewer also requires CMake, OpenGL development libraries, and X11 development headers on Linux. +For a container setup, see [Docker (Ubuntu / NVIDIA)](#docker-ubuntu--nvidia). + On Ubuntu, the relevant system packages are: ```bash @@ -165,3 +169,98 @@ the isolated checkout after a failure for inspection. This validates the committed branch, not uncommitted local changes. The `checkout` subcommand runs acceptance directly in a fresh checkout without installed assets, and `test --core` runs just the asset/contract and C checks. + +## Docker (Ubuntu / NVIDIA) + +The optional `Dockerfile` provides an Ubuntu 24.04 x86-64 environment with CUDA +13.0 development libraries, a Python virtual environment, Raylib 5.5, verified +Fight Caves assets, the compiled viewer, and test dependencies. Core Python +versions are recorded in `docker-constraints.txt`, including PyTorch 2.9.1 with +CUDA 13.0 and W&B 0.28.1. This branch uses a W&B helper removed in newer releases. + +The host needs Docker, an NVIDIA GPU with a driver compatible with CUDA 13.0, +and [NVIDIA Container Toolkit configured for Docker](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). +The image build needs network access to Ubuntu, NVIDIA, Python package indexes, +and the GitHub asset/Raylib releases. This branch requires the +`fight-caves-assets-v3` release pinned in `resources/fight_caves/asset_manifest.json`. +That release has not been published yet, so a fresh image build currently stops +at the asset download. The local setup has the same dependency. + +Build from the repository root: + +```bash +docker build --platform linux/amd64 \ + -f ocean/fight_caves/Dockerfile -t fight-caves:local . +``` + +The image builds the viewer and runs the core tests without a GPU. The CUDA +backend is built once inside each new container, where Puffer's default +`NVCC_ARCH=native` can detect the GPU. No host virtual environment or compiled +backend is copied into the image. + +For training or headless tests, start a container with persistent output volumes: + +```bash +docker run -it --name fight-caves-test --gpus all --shm-size=1g \ + --mount type=volume,source=fight-caves-checkpoints,target=/workspace/PufferLib/checkpoints \ + --mount type=volume,source=fight-caves-logs,target=/workspace/PufferLib/logs \ + fight-caves:local +``` + +For desktop play/replay, use this launch command instead. Run it from the host's +graphical session with `DISPLAY` set and `xauth` installed. X11 or XWayland must +be available. It shares the display socket and a temporary X authorization file: + +```bash +FC_XAUTH=$(mktemp /tmp/fight-caves-xauth.XXXXXX) +xauth -f "${XAUTHORITY:-$HOME/.Xauthority}" nlist "$DISPLAY" \ + | sed 's/^..../ffff/' | xauth -f "$FC_XAUTH" nmerge - + +docker run -it --name fight-caves-test --gpus all --shm-size=1g \ + -e DISPLAY -e XAUTHORITY=/tmp/fight-caves.Xauthority \ + --mount type=bind,source=/tmp/.X11-unix,target=/tmp/.X11-unix,readonly \ + --mount "type=bind,source=$FC_XAUTH,target=/tmp/fight-caves.Xauthority,readonly" \ + --mount type=volume,source=fight-caves-checkpoints,target=/workspace/PufferLib/checkpoints \ + --mount type=volume,source=fight-caves-logs,target=/workspace/PufferLib/logs \ + fight-caves:local +``` + +Keep the temporary authorization file while that container is in use. A new +desktop login may require a fresh authorization file and container. Both launch +examples use the same container name; choose one. The named volumes preserve +checkpoints and training logs when a container is replaced. + +Inside the container, the working directory is `/workspace/PufferLib` and +`python` already uses `.venv`. Build the backend and check the environment: + +```bash +python ocean/fight_caves/tools.py preflight --mode cuda +./build.sh fight_caves +bash tests/fight_caves.sh test --core +``` + +Train with the existing config (750 million timesteps) and W&B logging: + +```bash +wandb login +python -m pufferlib.pufferl train fight_caves --wandb --wandb-project fight-caves +``` + +Launch the playable viewer or replay the newest compatible saved checkpoint: + +```bash +python ocean/fight_caves/tools.py play +python ocean/fight_caves/tools.py eval --ckpt latest --episodes 1 +``` + +For a headless viewer check, use: + +```bash +xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ + python ocean/fight_caves/tools.py play --screenshot playable.png +``` + +Use `docker exec -it -w /workspace/PufferLib fight-caves-test bash` for another +shell in the running container, or `docker start -ai fight-caves-test` to resume +it after exiting. Rebuilding the image incorporates source changes from the +local checkout; create a new container to use that rebuilt image. diff --git a/ocean/fight_caves/docker-constraints.txt b/ocean/fight_caves/docker-constraints.txt new file mode 100644 index 0000000000..2ba91a3b65 --- /dev/null +++ b/ocean/fight_caves/docker-constraints.txt @@ -0,0 +1,8 @@ +# Core versions verified with Ubuntu 24.04, CUDA 13.0 and the Fight Caves config. +# Install torch from https://download.pytorch.org/whl/cu130 before PufferLib. +torch==2.9.1+cu130 +numpy==2.5.3 +pybind11==3.1.0 +pytest==9.1.1 +# This branch calls wandb.util.generate_id(), removed by newer W&B releases. +wandb==0.28.1 From c9e5f8423aa774cc4ebf499c7cfc8c0905a15e01 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:22:10 -0400 Subject: [PATCH 05/14] Trim redundant Docker setup explanations --- ocean/fight_caves/README.md | 45 +++++++++++++------------------------ 1 file changed, 15 insertions(+), 30 deletions(-) diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index de5735ebea..137b1fbbca 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -38,7 +38,7 @@ Native builds require Clang, `ar`, and an OpenMP development runtime. The viewer also requires CMake, OpenGL development libraries, and X11 development headers on Linux. -For a container setup, see [Docker (Ubuntu / NVIDIA)](#docker-ubuntu--nvidia). +[Docker instructions](#docker-ubuntu--nvidia). On Ubuntu, the relevant system packages are: @@ -172,19 +172,13 @@ installed assets, and `test --core` runs just the asset/contract and C checks. ## Docker (Ubuntu / NVIDIA) -The optional `Dockerfile` provides an Ubuntu 24.04 x86-64 environment with CUDA -13.0 development libraries, a Python virtual environment, Raylib 5.5, verified -Fight Caves assets, the compiled viewer, and test dependencies. Core Python -versions are recorded in `docker-constraints.txt`, including PyTorch 2.9.1 with -CUDA 13.0 and W&B 0.28.1. This branch uses a W&B helper removed in newer releases. +Ubuntu 24.04 x86-64, CUDA 13.0 and Raylib 5.5. `docker-constraints.txt` pins +PyTorch 2.9.1+cu130 and W&B 0.28.1 for this branch's `wandb.util.generate_id()` call. -The host needs Docker, an NVIDIA GPU with a driver compatible with CUDA 13.0, +The host needs an NVIDIA GPU with a driver compatible with CUDA 13.0, and [NVIDIA Container Toolkit configured for Docker](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). -The image build needs network access to Ubuntu, NVIDIA, Python package indexes, -and the GitHub asset/Raylib releases. This branch requires the -`fight-caves-assets-v3` release pinned in `resources/fight_caves/asset_manifest.json`. -That release has not been published yet, so a fresh image build currently stops -at the asset download. The local setup has the same dependency. +Fresh installs and Docker builds are blocked until the `fight-caves-assets-v3` +release pinned in `resources/fight_caves/asset_manifest.json` is published. Build from the repository root: @@ -195,10 +189,9 @@ docker build --platform linux/amd64 \ The image builds the viewer and runs the core tests without a GPU. The CUDA backend is built once inside each new container, where Puffer's default -`NVCC_ARCH=native` can detect the GPU. No host virtual environment or compiled -backend is copied into the image. +`NVCC_ARCH=native` can detect the GPU. -For training or headless tests, start a container with persistent output volumes: +Training or headless tests: ```bash docker run -it --name fight-caves-test --gpus all --shm-size=1g \ @@ -207,9 +200,8 @@ docker run -it --name fight-caves-test --gpus all --shm-size=1g \ fight-caves:local ``` -For desktop play/replay, use this launch command instead. Run it from the host's -graphical session with `DISPLAY` set and `xauth` installed. X11 or XWayland must -be available. It shares the display socket and a temporary X authorization file: +Desktop play/replay requires X11 or XWayland and `xauth`. Run from the host's +graphical session with `DISPLAY` set: ```bash FC_XAUTH=$(mktemp /tmp/fight-caves-xauth.XXXXXX) @@ -227,11 +219,9 @@ docker run -it --name fight-caves-test --gpus all --shm-size=1g \ Keep the temporary authorization file while that container is in use. A new desktop login may require a fresh authorization file and container. Both launch -examples use the same container name; choose one. The named volumes preserve -checkpoints and training logs when a container is replaced. +examples use the same container name; choose one. -Inside the container, the working directory is `/workspace/PufferLib` and -`python` already uses `.venv`. Build the backend and check the environment: +Inside the container, in `/workspace/PufferLib`: ```bash python ocean/fight_caves/tools.py preflight --mode cuda @@ -239,28 +229,23 @@ python ocean/fight_caves/tools.py preflight --mode cuda bash tests/fight_caves.sh test --core ``` -Train with the existing config (750 million timesteps) and W&B logging: +750M-step training with W&B: ```bash wandb login python -m pufferlib.pufferl train fight_caves --wandb --wandb-project fight-caves ``` -Launch the playable viewer or replay the newest compatible saved checkpoint: +Viewer and checkpoint replay: ```bash python ocean/fight_caves/tools.py play python ocean/fight_caves/tools.py eval --ckpt latest --episodes 1 ``` -For a headless viewer check, use: +Headless viewer check: ```bash xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ python ocean/fight_caves/tools.py play --screenshot playable.png ``` - -Use `docker exec -it -w /workspace/PufferLib fight-caves-test bash` for another -shell in the running container, or `docker start -ai fight-caves-test` to resume -it after exiting. Rebuilding the image incorporates source changes from the -local checkout; create a new container to use that rebuilt image. From d7088d70058c858e40fe76294288fd45957649a3 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:56:07 -0400 Subject: [PATCH 06/14] Link published Fight Caves v3 assets --- ocean/fight_caves/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index 137b1fbbca..7f2e17b534 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -177,8 +177,8 @@ PyTorch 2.9.1+cu130 and W&B 0.28.1 for this branch's `wandb.util.generate_id()` The host needs an NVIDIA GPU with a driver compatible with CUDA 13.0, and [NVIDIA Container Toolkit configured for Docker](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). -Fresh installs and Docker builds are blocked until the `fight-caves-assets-v3` -release pinned in `resources/fight_caves/asset_manifest.json` is published. +Assets: [fight-caves-assets-v3](https://github.com/jordanbailey00/fc-rl/releases/tag/fight-caves-assets-v3), +pinned in `resources/fight_caves/asset_manifest.json`. Build from the repository root: From d44f8265ca4e0f8f98cacf844e504e641c4fa006 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Thu, 10 Sep 2026 17:18:20 -0400 Subject: [PATCH 07/14] Use standard Puffer workflows for Fight Caves play and evaluation Connect the full viewer to c_render and the standalone executable, install pinned assets during the standard build, and retain optional CPU compatibility replay. Keep simulation, policy contracts, configuration and trainer code unchanged. Validation: 29 Python tests; clean-source acceptance with deliberate asset/checkpoint failures; graphical regressions; CPU and CUDA training/replay smoke tests. Original, headless and rendered adapters match over 2048 steps (digest aeaaf52e). --- .gitignore | 3 + build.sh | 5 + ocean/fight_caves/CMakeLists.txt | 8 +- ocean/fight_caves/Dockerfile | 5 +- ocean/fight_caves/README.md | 233 ++++---- ocean/fight_caves/fight_caves.c | 14 +- ocean/fight_caves/fight_caves.h | 58 +- ocean/fight_caves/tools.py | 150 +---- ocean/fight_caves/viewer.c | 960 +++++++++++++++++-------------- resources/fight_caves/README.md | 55 +- tests/fight_caves.sh | 21 +- tests/fight_caves_integration.c | 122 ++++ tests/test_fight_caves.py | 99 +--- 13 files changed, 897 insertions(+), 836 deletions(-) create mode 100644 tests/fight_caves_integration.c diff --git a/.gitignore b/.gitignore index 9517198506..b303ad3963 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,6 @@ resources/drive/binaries/* vendor/nle/ vendor/fast-nle/ + +# Fight Caves standalone executable (standard --fast/--local build). +/fight_caves diff --git a/build.sh b/build.sh index 19261e88c6..ec1e96bcd6 100755 --- a/build.sh +++ b/build.sh @@ -134,6 +134,11 @@ elif [ "$ENV" = "nethack" ]; then INCLUDES+=(-I./$NLE_DIR/include -I./$NLE_DIR/build/_deps/deboost_context-src/include) EXTRA_LDFLAGS+=(-L"$NETHACK_LIB_DIR" -lnethack -Wl,-rpath,"$NETHACK_LIB_DIR" -ldl) +elif [ "$ENV" = "fight_caves" ]; then + SRC_DIR="ocean/$ENV" + # The standard build also prepares the full viewer for puffer eval. + # Verified bundles are reused; no setup command or runtime download. + python3 "$SRC_DIR/tools.py" setup --all elif [ -d "ocean/$ENV" ]; then SRC_DIR="ocean/$ENV" else diff --git a/ocean/fight_caves/CMakeLists.txt b/ocean/fight_caves/CMakeLists.txt index 7790284627..b493494b0a 100644 --- a/ocean/fight_caves/CMakeLists.txt +++ b/ocean/fight_caves/CMakeLists.txt @@ -15,7 +15,7 @@ endif() set(RAYLIB_ROOT "${RAYLIB_ROOT}" CACHE PATH "Raylib distribution root") if(NOT EXISTS "${RAYLIB_ROOT}/include/raylib.h" OR NOT EXISTS "${RAYLIB_ROOT}/lib/libraylib.a") - message(FATAL_ERROR "Raylib unavailable at ${RAYLIB_ROOT}. Run python3 ocean/fight_caves/tools.py build-viewer") + message(FATAL_ERROR "Raylib unavailable at ${RAYLIB_ROOT}. Run ./build.sh fight_caves --fast") endif() add_executable(fc_viewer viewer.c) @@ -40,3 +40,9 @@ target_include_directories(fc_viewer_tests PRIVATE "${CMAKE_CURRENT_SOURCE_DIR}" "${RAYLIB_ROOT}/include") get_target_property(FC_VIEWER_TEST_LIBRARIES fc_viewer LINK_LIBRARIES) target_link_libraries(fc_viewer_tests PRIVATE ${FC_VIEWER_TEST_LIBRARIES}) + +add_executable(fc_integration_tests EXCLUDE_FROM_ALL + "${PUFFERLIB_ROOT}/tests/fight_caves_integration.c") +target_include_directories(fc_integration_tests PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}" "${RAYLIB_ROOT}/include") +target_link_libraries(fc_integration_tests PRIVATE ${FC_VIEWER_TEST_LIBRARIES}) diff --git a/ocean/fight_caves/Dockerfile b/ocean/fight_caves/Dockerfile index ada1fe10a3..0e6013f5d1 100644 --- a/ocean/fight_caves/Dockerfile +++ b/ocean/fight_caves/Dockerfile @@ -58,10 +58,7 @@ RUN python3 -m venv --prompt fight-caves "$VIRTUAL_ENV" \ COPY . . RUN python -m pip install --no-cache-dir -c /opt/fight-caves/constraints.txt -e . pytest \ && python -m pip check \ - && python ocean/fight_caves/tools.py setup --all \ - && python ocean/fight_caves/tools.py setup --all --verify-only \ - && python ocean/fight_caves/tools.py build-viewer \ - && ln -s build/raylib-5.5_linux_amd64 raylib-5.5_linux_amd64 \ + && ./build.sh fight_caves --fast \ && bash tests/fight_caves.sh test --core CMD ["bash"] diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index 7f2e17b534..c78280b6a1 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -1,174 +1,138 @@ # Fight Caves -Fight Caves is a single-agent native C environment for PufferLib 4.0. Its -training adapter, standalone simulator, and full Raylib viewer all compile the -same `simulation.h` implementation. Presentation remains separate from gameplay. - -## Layout - -The environment uses flat implementation headers, with no separate `src/`, -`include/`, or viewer source tree: - -- `fight_caves.h`: Puffer lifecycle, observations, rewards and episode logging. -- `simulation.h`: game state, contracts, combat, routing, waves, loadouts and - inventory/equipment transactions. -- `binding.c`: Puffer 4.0 binding, configuration and compiled-contract export. -- `fight_caves.c`: standalone random-action simulator/benchmark. -- `viewer.c`: playable and policy-pipe entry point, input and scene lifecycle. -- `assets.h`: asset readers, models, animations, terrain, animated atlases and - equipment-based player appearance composition. -- `ui.h`: OSRS interfaces, sprites, fonts, minimap, orbs and context menus. -- `render.h`: actor motion, animation selection, combat effects and debug overlays. -- `tools.py`: asset installation/verification, bundle creation, preflight, - optional viewer build, playable launch and checkpoint replay. -- `CMakeLists.txt`: optional viewer build using Puffer's pinned Raylib 5.5. -- `Dockerfile`, `Dockerfile.dockerignore`, `docker-constraints.txt`: optional - Ubuntu/CUDA setup with pinned Python dependencies. - -Acceptance tests live in the repository's `tests/` directory. The full graphical -viewer is retained; the flat layout does not substitute a minimal renderer or -change the simulation, policy contract, or configuration. - -## Requirements - -Python 3.10 or newer and the normal PufferLib Python dependencies are required. -Activate your Python environment first: Puffer's stock `build.sh` invokes -`python` from `PATH`, which must be the same interpreter used for training. -Native builds require Clang, `ar`, and an OpenMP development runtime. The viewer -also requires CMake, OpenGL development libraries, and X11 development headers -on Linux. - -[Docker instructions](#docker-ubuntu--nvidia). - -On Ubuntu, the relevant system packages are: +A single-agent native C Fight Caves environment for PufferLib 4.0. Training, +human play, and checkpoint evaluation share the same simulation and full Raylib +viewer. Gameplay, observations, rewards, action heads, and the default 750M-step +configuration are unchanged by the standard-workflow integration. -```bash -sudo apt-get install clang libomp-dev libomp5 cmake \ - libgl1-mesa-dev libx11-dev libxrandr-dev libxi-dev \ - libxcursor-dev libxinerama-dev x11-utils xvfb -``` - -The environment-local preflight exits with a nonzero status and names any -missing dependency. It never substitutes a reduced simulator or viewer. -The shared `build.sh` is unchanged and does not invoke Fight Caves preflight; -run the explicit check before building a backend as shown below. +## Setup and training -## Install assets +Use a normal Puffer 4.0 development environment: Python 3.10+, Clang/OpenMP, +Raylib's system/OpenGL dependencies, and the CUDA/cuDNN/NCCL development stack +for native GPU training. These are shared Puffer prerequisites, not a separate +Fight Caves installation. An optional reproducible Docker setup is below. -The runtime maps and graphical viewer data are published as versioned GitHub -release bundles. Install and verify both bundles from the repository root: +From the repository root, in your activated Python environment: ```bash -python3 ocean/fight_caves/tools.py setup --all +python -m pip install -e . +./build.sh fight_caves +puffer train fight_caves --wandb ``` -The installer verifies the archive and every installed file against -`resources/fight_caves/asset_manifest.json`. A download, checksum, extraction, -or installation error exits nonzero without replacing an existing installation. - -## Build and test +The build **automatically installs and verifies both asset bundles**. There is +no separate Fight Caves setup, preflight, or viewer-build command to remember. +Verified assets are reused on subsequent builds, including offline builds. +The first installation requires access to the pinned GitHub release. -Use Puffer's standard build commands for the training backend: +CPU/PyTorch training follows Puffer's ordinary alternative: ```bash -python ocean/fight_caves/tools.py preflight --mode cpu ./build.sh fight_caves --cpu +puffer train fight_caves --slowly ``` -For CUDA, use `preflight --mode cuda` followed by `./build.sh fight_caves`. -For the standalone simulator, use `preflight --mode native` followed by -`./build.sh fight_caves --fast`. +Puffer compiles one selected environment/backend into `pufferlib/_C`. Rebuild +when switching environments or between native CUDA and CPU backends. -Build the CPU Puffer backend and run the environment acceptance tests: +## Play manually ```bash -bash tests/fight_caves.sh test --puffer +./build.sh fight_caves --fast +./fight_caves ``` -Include the viewer build and explicit graphical regression tests (using -`xvfb-run` when available, otherwise an existing display): +Use `--local` instead of `--fast` for Puffer's debug/sanitizer build. Human play +does not require CUDA or a compiled Python backend. A graphical desktop is +required. The game starts paused; press Space to begin. -```bash -bash tests/fight_caves.sh test --all -``` +The full viewer is retained: tile clicking and path previews, camera controls, +equipment switching and right-click menus, inventory/prayer interfaces, minimap +and run-energy orbs, wave/TPS/target controls, god mode, diagnostics, projectiles, +animations, health bars, hitsplats, and Prayer-window indicators. + +Space pauses; Right Arrow single-steps; O toggles the debug overlay; right-drag +orbits; the mouse wheel zooms; Q quits. The console contains wave, target, +speed, and god-mode controls. `./fight_caves --benchmark` retains the optional +headless random-action benchmark. + +## Watch a checkpoint -Run the playable viewer through its asset-verifying launcher: +After building the same backend used to train the checkpoint: ```bash -python3 ocean/fight_caves/tools.py build-viewer -python3 ocean/fight_caves/tools.py play +puffer eval fight_caves --load-model-path latest ``` -`build-viewer` checks dependencies and assets, reuses Puffer's Raylib 5.5 -installation if present, or downloads the same official release into `build/`. -Use `--raylib-root /path/to/raylib` to supply an existing installation, including -on platforms without a matching prebuilt release. An incomplete installation -fails explicitly. Viewer building does not require the Puffer backend or CUDA. +Or pass a specific checkpoint path. PyTorch checkpoints use Puffer's `--slowly` +backend. Standard evaluation uses Puffer's own policy inference, masking, and +environment stepping; the viewer only displays snapshots of the evaluated +environment. Graphics are initialized lazily by `c_render()`, never by ordinary +headless training. Terminal snapshots are retained before same-step autoreset. -The launcher verifies all required assets and checks the graphical display. -The viewer retains tile clicking and route previews, OSRS click indicators, -camera controls, equipment/prayer/inventory tabs, run-energy and minimap orbs, -wave/TPS/target controls, god mode, debug information, prayer-window indicators, -projectiles, impacts, health bars and hitsplats. +The same viewer supports camera/debug/pause/speed controls during evaluation. +Gameplay-changing controls are disabled in replay. Keyboard 1/2/4/0 selects +1x/2x/4x/10x playback. Closing the window or pressing Q ends evaluation. +Puffer's standard `latest` means newest by file time, not highest-scoring. -In playable mode, click worn equipment to remove it and click equipment in the -inventory to equip it. The player model and bonuses update together. Inventory -capacity, two-handed weapon/shield swaps, requirements, ammunition compatibility, -stack limits, loaded-weapon charges and consumable slots are handled by the core. -These immediate transactions do not advance a tick or reset attack cooldowns. -Equipment switching is not a policy action: training keeps the same three heads, -320 model inputs (286 observations plus 34 mask values), rewards and config. +### Optional CPU-only compatibility replay -Right-click items, NPCs or terrain to open the RuneC-style menu. It includes -NPC combat levels and relative-level colors, uses the bold OSRS menu font, and -picks NPCs against their animated models. Menu choices invoke existing viewer -actions; yellow/red click animations and right-drag camera control are retained. +Puffer's native CUDA and PyTorch backends use different checkpoint formats. +The optional compatibility reader is retained for replaying native CUDA weights +on the CPU, deterministic sampling, or a fixed episode limit: -Useful controls include `Space` to pause/resume, `Right Arrow` to step one tick, -`O` for debug overlays, right-drag to orbit, the mouse wheel to zoom, and -`Q`/`Escape` to quit. +```bash +./build.sh fight_caves --cpu +./build.sh fight_caves --fast +python ocean/fight_caves/tools.py eval --ckpt /path/to/checkpoint.bin --episodes 1 +``` -## Checkpoint replay +It uses the same `./fight_caves` executable and the existing contract checks. +It is not required for ordinary `puffer eval`. Rebuild the CUDA backend before +resuming native training after a CPU build. -Build the Fight Caves backend (`./build.sh fight_caves --cpu`, or the normal CUDA -build), then replay a checkpoint in the same viewer: +## Assets -```bash -python3 ocean/fight_caves/tools.py eval --ckpt /absolute/path/to/checkpoint.bin --episodes 1 -``` +`build.sh` invokes the existing pinned installer automatically for Fight Caves. +Archive and individual-file SHA-256 checks precede transactional installation: -Replay retains the raw CUDA and PyTorch CPU checkpoint readers, compiled-contract -and size checks, masking, pause/speed controls and episode summaries. `--ckpt latest` -selects the newest compatible checkpoint; `--random` uses random legal actions -without loading a checkpoint. The existing dedicated policy-pipe evaluator is -preserved, rather than changing inference or switching to a different renderer. -The state hash is version 5 for inventory/equipment state. Version-4 checkpoint -sidecars remain accepted only when every other contract field matches, since -policy weights do not serialize equipment state. +- `resources/fight_caves/runtime/`: collision, movement, and LOS maps. +- `resources/fight_caves/viewer/`: models, equipment parts, animations, terrain, + textures, sprites, fonts, and the minimap raster. -The viewer defaults to `resources/fight_caves/viewer`; arena maps default to -`resources/fight_caves/runtime`. Explicit `FC_ASSET_ROOT`, `FC_REPO_ROOT`, -`FC_COLLISION_PATH`, `FC_MOVEMENT_PATH` and `FC_LOS_PATH` overrides remain available. -Use `python3 ocean/fight_caves/tools.py COMMAND --help` for setup, bundle, -preflight, viewer-build and replay options. +The simulator and viewer load these local paths directly; no cache export, +reference repository, external codebase, or runtime network call is needed. +Missing or invalid required data fails rather than substituting open maps or +reduced graphics. Rerunning the build repairs missing/corrupt installed bundles; +download or validation failure stops the build. -## Clean-clone acceptance +`tools.py setup`, `bundle`, and `preflight` remain explicit maintenance tools. +The old `tools.py build-viewer` and `play` commands are compatibility aliases +for the standard standalone build and executable. -Maintainers can reproduce installation, native and Puffer builds, a short -training run, viewer startup, checkpoint replay, and deliberate failure cases -from a new checkout with: +## Maintainer checks and layout ```bash -bash tests/fight_caves.sh clean-clone +bash tests/fight_caves.sh test --core +bash tests/fight_caves.sh test --all ``` -The command clones the current origin branch into a temporary directory, creates -a new virtual environment, downloads only the published pinned asset bundles, -and runs the complete acceptance sequence. Set `FC_CLEAN_CLONE_KEEP=1` to retain -the isolated checkout after a failure for inspection. -This validates the committed branch, not uncommitted local changes. The -`checkout` subcommand runs acceptance directly in a fresh checkout without -installed assets, and `test --core` runs just the asset/contract and C checks. +The optional graphical test target uses CMake/Xvfb. Neither is required to +build or launch the ordinary viewer. Tests cover assets/failure handling, +contracts, equipment, graphics, and rendering-versus-headless trajectory parity. + +- `simulation.h`: unchanged combat, movement, waves, items, contracts and state. +- `fight_caves.h`, `binding.c`: Puffer integration and lazy renderer connection. +- `fight_caves.c`: conventional playable entry point and optional benchmark. +- `viewer.c`: shared viewer lifecycle, input, frame rendering and compatibility pipe. +- `assets.h`, `ui.h`, `render.h`: existing assets, interface and presentation. +- `tools.py`: asset maintenance and optional cross-backend checkpoint replay. +- `CMakeLists.txt`: optional graphical regression builds. +- `Dockerfile` and constraints: optional reproducible development environment. + +`bash tests/fight_caves.sh clean-clone` tests a committed branch, not uncommitted +working-copy changes. Use `checkout` in an isolated source copy to validate +uncommitted work. ## Docker (Ubuntu / NVIDIA) @@ -224,7 +188,6 @@ examples use the same container name; choose one. Inside the container, in `/workspace/PufferLib`: ```bash -python ocean/fight_caves/tools.py preflight --mode cuda ./build.sh fight_caves bash tests/fight_caves.sh test --core ``` @@ -233,19 +196,19 @@ bash tests/fight_caves.sh test --core ```bash wandb login -python -m pufferlib.pufferl train fight_caves --wandb --wandb-project fight-caves +puffer train fight_caves --wandb --wandb-project fight-caves ``` Viewer and checkpoint replay: ```bash -python ocean/fight_caves/tools.py play -python ocean/fight_caves/tools.py eval --ckpt latest --episodes 1 +./fight_caves +puffer eval fight_caves --load-model-path latest ``` Headless viewer check: ```bash xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ - python ocean/fight_caves/tools.py play --screenshot playable.png + ./fight_caves --screenshot playable.png ``` diff --git a/ocean/fight_caves/fight_caves.c b/ocean/fight_caves/fight_caves.c index 628111a070..86d24671ee 100644 --- a/ocean/fight_caves/fight_caves.c +++ b/ocean/fight_caves/fight_caves.c @@ -1,15 +1,15 @@ /* - * fight_caves.c — Standalone entry point for testing without PufferLib. + * fight_caves.c — Puffer's standalone playable Fight Caves executable. * - * Compiled with: ./build.sh --local (debug) or ./build.sh --fast (optimized) - * Runs N episodes with random actions and prints stats. + * Compiled with: ./build.sh fight_caves --local (debug) or --fast (optimized). + * --benchmark retains the optional random-action, headless smoke test. */ #include "fight_caves.h" #include #include -int main(void) { +static int benchmark(void) { FightCaves env = {0}; env.num_agents = 1; env.observations = (float*)calloc(FC_PUFFER_OBS_SIZE, sizeof(float)); @@ -80,3 +80,9 @@ int main(void) { free(env.terminals); return 0; } + +int main(int argc, char** argv) { + if (argc == 2 && strcmp(argv[1], "--benchmark") == 0) + return benchmark(); + return fc_viewer_main(argc, argv); +} diff --git a/ocean/fight_caves/fight_caves.h b/ocean/fight_caves/fight_caves.h index 462f62df8b..facd882822 100644 --- a/ocean/fight_caves/fight_caves.h +++ b/ocean/fight_caves/fight_caves.h @@ -7,7 +7,7 @@ * - FightCaves struct with PufferLib-required fields * - c_reset: init game state, compute initial obs * - c_step: read actions, step game, compute reward+obs, handle terminal - * - c_render: required no-op; evaluation uses the external viewer + * - c_render: lazy full viewer for Puffer's standard evaluation loop * - c_close: cleanup * * Single-agent environment (num_agents=1 always for Fight Caves). @@ -19,6 +19,9 @@ /* Shared simulation and contract implementation. */ #include "simulation.h" +#define FC_VIEWER_EMBEDDED +#include "viewer.c" +#undef FC_VIEWER_EMBEDDED /* ======================================================================== */ /* PufferLib Log struct (required fields) */ /* ======================================================================== */ @@ -175,6 +178,7 @@ typedef struct FightCaves { /* Game state */ FcState state; + ViewerState* viewer; /* NULL throughout headless training */ /* Reward weights and shaping configuration, initialized once per env. */ FcRewardParams reward_params; @@ -262,6 +266,7 @@ static float fc_puffer_compute_reward(FightCaves* env) { FcRewardBreakdown breakdown = fc_reward_compute_breakdown( &env->state, &env->reward_params, &env->reward_runtime); + if (env->viewer) env->viewer->pending_reward_breakdown = breakdown; fc_reward_sync_progress_state(&env->state, &env->reward_runtime); if (breakdown.threat_ctx.tokxil_melee) env->state.ep_tokxil_melee_ticks++; @@ -406,6 +411,7 @@ void c_reset(FightCaves* env) { /* Compute initial observations */ fc_puffer_write_obs(env); + if (env->viewer) env->viewer->reset_state = env->state; } void c_step(FightCaves* env) { @@ -436,6 +442,15 @@ void c_step(FightCaves* env) { * below replaces it with the next episode's initial observation. */ fc_puffer_write_obs(env); + /* A value snapshot survives same-step autoreset. Worker threads only copy + * data here; all graphics and frame pacing remain inside c_render(). */ + if (env->viewer) { + env->viewer->pending_state = env->state; + env->viewer->pending_reward_runtime = env->reward_runtime; + memcpy(env->viewer->pending_actions, actions, sizeof(actions)); + env->viewer->pending_frame = 1; + } + /* Check terminal */ if (fc_is_terminal(&env->state)) { FcEpisodeSummary summary; @@ -495,11 +510,46 @@ void c_step(FightCaves* env) { } void c_render(FightCaves* env) { - /* Rendering handled by external viewer via --policy-pipe mode. - * See tools.py's eval command for the eval pipeline. */ - (void)env; + if (!env->viewer) { + env->viewer = fc_viewer_create(1); + if (!env->viewer) exit(EXIT_FAILURE); + ViewerState* v = env->viewer; + v->state = v->reset_state = env->state; + v->reward_params = env->reward_params; + v->reward_runtime = env->reward_runtime; + v->active_loadout = env->state.active_loadout; + v->obs_ablate_npc_distance = env->obs_ablate_npc_distance; + v->obs_ablate_incoming_aggregates = env->obs_ablate_incoming_aggregates; + v->obs_ablate_npc_valid = env->obs_ablate_npc_valid; + snprintf(v->reward_config_path, sizeof(v->reward_config_path), + "Puffer environment configuration"); + v->reward_config_loaded = 1; + fc_viewer_reset_presentation(v); + } + ViewerState* v = env->viewer; + if (!v->pending_frame && + (v->state.rng_seed != env->state.rng_seed || + v->state.tick != env->state.tick)) { + /* Also support an explicit VecEnv.reset() between render calls. */ + v->state = env->state; + v->reward_runtime = env->reward_runtime; + memset(&v->reward_breakdown, 0, sizeof(v->reward_breakdown)); + fc_viewer_reset_presentation(v); + } + fc_viewer_present_pending(v); + int frame; + do { + frame = fc_viewer_frame(env->viewer, 1); + } while (frame == 0); + if (frame < 0) { + fc_viewer_destroy(env->viewer); + env->viewer = NULL; + exit(EXIT_SUCCESS); + } } void c_close(FightCaves* env) { + fc_viewer_destroy(env->viewer); + env->viewer = NULL; fc_destroy(&env->state); } diff --git a/ocean/fight_caves/tools.py b/ocean/fight_caves/tools.py index c05d8ebbdf..fa3a8d18b0 100644 --- a/ocean/fight_caves/tools.py +++ b/ocean/fight_caves/tools.py @@ -668,80 +668,10 @@ def run_preflight(mode: str) -> int: # Optional viewer build; the shared Puffer build.sh remains unmodified. -RAYLIB_FILES = ("include/raylib.h", "include/raymath.h", "include/rlgl.h", - "lib/libraylib.a") - - -def require_raylib(root: Path) -> Path: - missing = [name for name in RAYLIB_FILES if not (root / name).is_file()] - if missing: - raise AssetError(f"Raylib is incomplete at {root}: missing {', '.join(missing)}. " - "Supply a complete installation with --raylib-root.") - return root - - -def viewer_raylib(explicit_root: Path | None) -> Path: - if explicit_root is not None: - return require_raylib(explicit_root.expanduser().resolve()) - if sys.platform == "linux" and os.uname().machine in ("x86_64", "amd64"): - name = "raylib-5.5_linux_amd64" - elif sys.platform == "darwin": - name = "raylib-5.5_macos" - else: - raise AssetError("No bundled Raylib 5.5 for this platform. " - "Supply a compatible build with --raylib-root.") - - # Reuse Puffer's download when available; otherwise keep this optional - # dependency under build/, without creating a partial shared installation. - shared = REPO_ROOT / name - root = REPO_ROOT / "build" / name - for existing in (shared, root): - if existing.exists(): - return require_raylib(existing) - root.parent.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(prefix="fight-caves-raylib-", dir=root.parent) as value: - staging = Path(value) - archive_path = staging / f"{name}.tar.gz" - download(f"https://github.com/raysan5/raylib/releases/download/5.5/{name}.tar.gz", - archive_path) - with tarfile.open(archive_path, "r:gz") as archive: - # Copy only the exact headers/static library used by this viewer. - # No archive paths or links are ever extracted to the filesystem. - for relative in RAYLIB_FILES: - member = archive.getmember(f"{name}/{relative}") - if not member.isfile(): - raise AssetError(f"Raylib archive contains a non-file: {member.name}") - source = archive.extractfile(member) - if source is None: - raise AssetError(f"Raylib archive cannot read {member.name}") - target = staging / name / relative - target.parent.mkdir(parents=True, exist_ok=True) - with source, target.open("wb") as output: - shutil.copyfileobj(source, output) - require_raylib(staging / name).rename(root) - return root - - def build_viewer_main() -> int: - parser = argparse.ArgumentParser(description="Build the optional Fight Caves viewer.") - parser.add_argument("--raylib-root", type=Path, - help="use an existing Raylib installation instead of downloading 5.5") - args = parser.parse_args() - if run_preflight("viewer") != 0: - return 1 - try: - raylib = viewer_raylib(args.raylib_root) - build = REPO_ROOT / "build" / "fight_caves-viewer" - subprocess.run(["cmake", "-S", str(ENV_ROOT), "-B", str(build), - "-DCMAKE_BUILD_TYPE=Release", f"-DRAYLIB_ROOT={raylib}"], - cwd=REPO_ROOT, check=True) - subprocess.run(["cmake", "--build", str(build), "--parallel"], - cwd=REPO_ROOT, check=True) - except (AssetError, OSError, KeyError, tarfile.TarError, subprocess.CalledProcessError) as exc: - print(f"Fight Caves viewer build failed: {exc}", file=sys.stderr) - return 1 - print(f"Built: {build / 'fc_viewer'}") - return 0 + """Compatibility alias; the standard standalone build owns dependencies.""" + argparse.ArgumentParser(description="Alias for ./build.sh fight_caves --fast").parse_args() + return subprocess.call(["bash", "build.sh", "fight_caves", "--fast"], cwd=REPO_ROOT) # Checkpoint contract @@ -1099,16 +1029,13 @@ def expected_parameter_bytes(contract): def verify_runtime_assets(): - preflight = os.path.join( - repo_root(), "ocean", "fight_caves", "tools.py" - ) - result = subprocess.run( - [sys.executable, preflight, "preflight", "--mode", "viewer-runtime"], - cwd=repo_root(), - check=False, - ) - if result.returncode != 0: - raise RuntimeError("Fight Caves runtime/viewer asset preflight failed") + # Asset/checkpoint validation does not need an X11 utility or a window. + # Raylib checks the display when the actual viewer is launched. + errors: list[str] = [] + verify_assets(errors, ("core", "viewer")) + if errors: + raise AssetError("; ".join(errors) + + ". Restore assets with: ./build.sh fight_caves --fast") def checkpoint_diagnostic(reason, checkpoint_path, expected_bytes, contract): @@ -1148,41 +1075,8 @@ def latest_source_mtime(): def find_viewer(): - """Find the fc_viewer binary.""" - override = os.environ.get("FC_VIEWER_PATH") - if override: - if os.path.isfile(override): - return override - raise RuntimeError(f"FC_VIEWER_PATH does not point to a file: {override}") - - repo = repo_root() - source_mtime = latest_source_mtime() - preferred = [ - os.path.join(repo, "build", "fight_caves-viewer", "fc_viewer"), - ] - candidates = [path for path in preferred if os.path.isfile(path)] - - patterns = [ - os.path.join(repo, "build*", "fight_caves-viewer", "fc_viewer"), - ] - for pattern in patterns: - candidates.extend(glob.glob(pattern)) - candidates = [path for path in candidates if os.path.isfile(path)] - if not candidates: - return None - - seen = set() - unique = [] - for path in candidates: - if path in seen: - continue - seen.add(path) - unique.append(path) - - for path in unique: - if os.path.getmtime(path) >= source_mtime: - return path - return max(unique, key=os.path.getmtime) + viewer = Path(repo_root()) / "fight_caves" + return str(viewer) if viewer.is_file() and os.access(viewer, os.X_OK) else None def read_obs_line(proc, total_line_floats): @@ -1399,7 +1293,7 @@ def eval_main(): if not viewer_path: print( "Error: fc_viewer binary not found. Build with: " - "python3 ocean/fight_caves/tools.py build-viewer", + "./build.sh fight_caves --fast", file=sys.stderr, ) sys.exit(1) @@ -1409,7 +1303,7 @@ def eval_main(): file=sys.stderr, ) print( - "Rebuild it first with: python3 ocean/fight_caves/tools.py build-viewer", + "Rebuild it first with: ./build.sh fight_caves --fast", file=sys.stderr, ) sys.exit(1) @@ -1581,19 +1475,13 @@ def eval_main(): def play_main() -> int: - result = subprocess.run( - [sys.executable, __file__, "preflight", "--mode", "viewer-runtime"], - cwd=REPO_ROOT, - ) - if result.returncode: - return result.returncode - viewer = REPO_ROOT / "build/fight_caves-viewer/fc_viewer" - if not viewer.is_file() or not os.access(viewer, os.X_OK): - print(f"Fight Caves viewer is not built: {viewer}\n" - "Build it with: python3 ocean/fight_caves/tools.py build-viewer", file=sys.stderr) + """Compatibility alias; ordinary human play is simply ./fight_caves.""" + viewer = find_viewer() + if not viewer: + print("Fight Caves is not built. Run: ./build.sh fight_caves --fast", file=sys.stderr) return 1 os.chdir(REPO_ROOT) - os.execv(str(viewer), [str(viewer), *sys.argv[1:]]) + os.execv(viewer, [viewer, *sys.argv[1:]]) def main() -> int: diff --git a/ocean/fight_caves/viewer.c b/ocean/fight_caves/viewer.c index 227ef66f6a..ed2b2ffda1 100644 --- a/ocean/fight_caves/viewer.c +++ b/ocean/fight_caves/viewer.c @@ -92,6 +92,12 @@ static const Color NPC_COLORS[] = { /* Viewer state */ typedef struct { FcState state; + /* CPU snapshots only: worker-thread stepping must never call Raylib. */ + FcState pending_state, reset_state; + FcRewardRuntime pending_reward_runtime; + FcRewardBreakdown pending_reward_breakdown; + int pending_actions[FC_NUM_ACTION_HEADS]; + int pending_frame; FcRenderEvents render_events; FcActorAnimation actor_animation; FcCombatPresentation* combat_presentation; @@ -380,7 +386,8 @@ static void sync_fc_ui(ViewerState* v) { static void queue_player_tile_request(ViewerState* v, int tx, int ty, float screen_x, float screen_y) { - if (!v || tx < 0 || tx >= FC_ARENA_WIDTH || ty < 0 || ty >= FC_ARENA_HEIGHT) + if (!v || v->policy_pipe || tx < 0 || tx >= FC_ARENA_WIDTH || + ty < 0 || ty >= FC_ARENA_HEIGHT) return; v->pending_tile_x = tx; v->pending_tile_y = ty; @@ -391,7 +398,7 @@ static void queue_player_tile_request(ViewerState* v, int tx, int ty, static void queue_player_attack_request(ViewerState* v, int npc_idx, float screen_x, float screen_y) { - if (!v || npc_idx < 0 || npc_idx >= FC_MAX_NPCS) return; + if (!v || v->policy_pipe || npc_idx < 0 || npc_idx >= FC_MAX_NPCS) return; v->pending_attack_npc = npc_idx; v->pending_tile_x = -1; v->pending_tile_y = -1; @@ -479,10 +486,11 @@ static void handle_runec_ui_intent(ViewerState* v) { * prayer happens to be active when the option is selected. */ if (intent->secondary == 1 && action == FC_PRAYER_OFF) action = 0; if (intent->secondary == -1 && action != FC_PRAYER_OFF) action = 0; - if (action) v->pending_prayer = action; + if (action && !v->policy_pipe) v->pending_prayer = action; break; } case RUNEC_UI_INTENT_COMBAT_STYLE: + if (v->policy_pipe) break; v->combat_style = intent->primary == 3 ? 2 : intent->primary; if (v->combat_style < 0) v->combat_style = 0; if (v->combat_style > 2) v->combat_style = 2; @@ -776,7 +784,7 @@ static void toggle_debug_overlay(ViewerState* v) { } static void toggle_godmode(ViewerState* v) { - if (!v) return; + if (!v || v->policy_pipe) return; v->godmode = !v->godmode; fprintf(stderr, "GODMODE: %s\n", v->godmode ? "ON" : "OFF"); } @@ -889,6 +897,29 @@ static void sync_player_appearance(ViewerState *v) { } } +static void fc_viewer_reset_presentation(ViewerState* v) { + v->seed = v->state.rng_seed; + sync_player_appearance(v); + v->item_message_seconds = 0.0f; + fc_fill_render_entities(&v->state, v->entities, &v->entity_count); + fc_fill_render_events(&v->state, &v->render_events); + v->last_hash = fc_state_hash(&v->state); + v->episode_count++; + v->attack_target = -1; + memset(v->actions, 0, sizeof(v->actions)); + fc_combat_presentation_reset(v->combat_presentation); + fc_actor_animation_reset(&v->actor_animation, &v->state, + v->appearance.model, v->active_loadout); + v->pending_prayer = 0; + v->pending_eat = 0; + v->pending_drink = 0; + v->pending_attack_npc = -1; + v->pending_tile_x = -1; + v->pending_tile_y = -1; + fc_click_feedback_reset(&v->click_feedback); + dbg_log_clear(); +} + static void reset_ep(ViewerState* v) { runec_ui_close_context(&v->ui); v->scene_right_tracking = v->scene_right_dragged = 0; @@ -909,30 +940,12 @@ static void reset_ep(ViewerState* v) { v->state.player.current_prayer = v->state.player.max_prayer; } apply_initial_supplies(v); - sync_player_appearance(v); - v->item_message_seconds = 0.0f; fc_reward_runtime_begin_episode(&v->reward_runtime, &v->state); - fc_fill_render_entities(&v->state, v->entities, &v->entity_count); - fc_fill_render_events(&v->state, &v->render_events); - v->last_hash = fc_state_hash(&v->state); - v->episode_count++; - v->attack_target = -1; - memset(v->actions, 0, sizeof(v->actions)); - fc_combat_presentation_reset(v->combat_presentation); - fc_actor_animation_reset(&v->actor_animation, &v->state, - v->appearance.model, v->active_loadout); - v->pending_prayer = 0; - v->pending_eat = 0; - v->pending_drink = 0; - v->pending_attack_npc = -1; - v->pending_tile_x = -1; - v->pending_tile_y = -1; - fc_click_feedback_reset(&v->click_feedback); - dbg_log_clear(); + fc_viewer_reset_presentation(v); } static void viewer_jump_to_wave(ViewerState* v, int wave) { - if (!v) return; + if (!v || v->policy_pipe) return; runec_ui_close_context(&v->ui); if (wave < 1) wave = 1; if (wave > FC_NUM_WAVES) wave = FC_NUM_WAVES; @@ -2208,69 +2221,95 @@ static int process_runec_prayer_click(ViewerState* v) { /* Main */ /* ======================================================================== */ -int main(int argc, char** argv) { - int exit_code = 0; - int screenshot_mode = 0; - const char* screenshot_path = NULL; - int policy_pipe_flag = 0; - int policy_speed_flag = 1; - int policy_episode_limit_flag = 0; - int start_wave_flag = 0; - for (int i = 1; i < argc; i++) { - if (strcmp(argv[i], "--screenshot") == 0 && i+1 < argc) { - screenshot_mode = 1; - screenshot_path = argv[++i]; - } else if (strcmp(argv[i], "--policy-pipe") == 0) { - policy_pipe_flag = 1; - } else if (strcmp(argv[i], "--speed") == 0 && i+1 < argc) { - policy_speed_flag = atoi(argv[++i]); - } else if (strcmp(argv[i], "--episodes") == 0 && i+1 < argc) { - policy_episode_limit_flag = atoi(argv[++i]); - } else if (strcmp(argv[i], "--start-wave") == 0 && i+1 < argc) { - start_wave_flag = atoi(argv[++i]); +/* The same viewer serves the standalone game, optional CPU compatibility + * replay, and Puffer's c_render(). No graphics are allocated by training. */ +static void fc_viewer_destroy(ViewerState* v) { + if (!v) return; + if (v->pray_melee_tex.id > 0) UnloadTexture(v->pray_melee_tex); + if (v->pray_missiles_tex.id > 0) UnloadTexture(v->pray_missiles_tex); + if (v->pray_magic_tex.id > 0) UnloadTexture(v->pray_magic_tex); + for (int i = 0; i < FC_CLICK_CROSS_FRAME_COUNT * 2; i++) { + if (v->click_cross_tex[i].id > 0) + UnloadTexture(v->click_cross_tex[i]); + } + if (v->tex_pray_melee_on.id > 0) UnloadTexture(v->tex_pray_melee_on); + if (v->tex_pray_melee_off.id > 0) UnloadTexture(v->tex_pray_melee_off); + if (v->tex_pray_range_on.id > 0) UnloadTexture(v->tex_pray_range_on); + if (v->tex_pray_range_off.id > 0) UnloadTexture(v->tex_pray_range_off); + if (v->tex_pray_magic_on.id > 0) UnloadTexture(v->tex_pray_magic_on); + if (v->tex_pray_magic_off.id > 0) UnloadTexture(v->tex_pray_magic_off); + fc_combat_presentation_destroy(v->combat_presentation); + fc_actor_animation_shutdown(&v->actor_animation); + if (v->object_anim_runtimes) { + for (int i = 0; i < v->object_anim_runtime_count; i++) { + if (v->object_anim_runtimes[i].anim_state) + anim_model_state_free(v->object_anim_runtimes[i].anim_state); } - } + free(v->object_anim_runtimes); + } + if (v->anim_cache) anim_cache_free(v->anim_cache); + fc_player_appearance_free(&v->appearance); + if (v->npc_models) fc_npc_models_unload(v->npc_models); + if (v->object_anim_models) fc_npc_models_unload(v->object_anim_models); + fc_animated_atlas_unload(&v->shared_model_atlas); + if (v->object_anims) object_anims_free(v->object_anims); + objects_free(v->objects); + fc_minimap_scene_free(&v->minimap_scene); + terrain_free(v->terrain); + fc_osrs_text_shutdown(); + runec_ui_shutdown(&v->ui); + CloseWindow(); + fc_destroy(&v->state); + free(v); +} + +static ViewerState* fc_viewer_create(int replay) { fprintf(stderr,"=== Fight Caves Viewer (Phase 8 — Playable) ===\n"); /* In policy-pipe mode, suppress Raylib's INFO logs which go to stdout * and would corrupt the pipe protocol. */ - if (policy_pipe_flag) { + if (replay) { SetTraceLogCallback(viewer_trace_log_to_stderr); SetTraceLogLevel(LOG_WARNING); } SetConfigFlags(FLAG_WINDOW_RESIZABLE|FLAG_MSAA_4X_HINT); - InitWindow(DEFAULT_WINDOW_W, DEFAULT_WINDOW_H, - "Fight Caves RL — Playable Viewer"); + InitWindow(DEFAULT_WINDOW_W, DEFAULT_WINDOW_H, replay + ? "Fight Caves RL — Checkpoint Viewer" + : "Fight Caves RL — Playable Viewer"); SetExitKey(KEY_NULL); /* Escape first dismisses menus; handled below. */ if (!IsWindowReady()) { fprintf(stderr, "error: viewer window initialization failed; verify the " "graphical display and OpenGL driver\n"); - return 1; + return NULL; } SetTargetFPS(60); - ViewerState v; memset(&v, 0, sizeof(v)); - fc_init(&v.state); - fc_actor_animation_init(&v.actor_animation); - runec_ui_init(&v.ui); + ViewerState* v = calloc(1, sizeof(*v)); + if (!v) { + fprintf(stderr, "error: cannot allocate Fight Caves viewer state\n"); + CloseWindow(); + return NULL; + } + fc_init(&v->state); + fc_actor_animation_init(&v->actor_animation); + runec_ui_init(&v->ui); if (!fc_osrs_text_init()) { fprintf(stderr, "error: required OSRS viewer fonts failed to load\n"); - runec_ui_shutdown(&v.ui); - CloseWindow(); - return 1; + fc_viewer_destroy(v); + return NULL; } - int item_icons_ready = load_fc_ui_item_icons(&v); - v.paused = 1; v.tps = NORMAL_TPS; - v.active_loadout = FC_ACTIVE_LOADOUT; - v.attack_target = -1; - v.cam_yaw = 0; v.cam_pitch = 0.8f; v.cam_dist = 30; - v.camera_locked = 1; - v.camera.up = (Vector3){0,1,0}; v.camera.fovy = 32; - v.camera.projection = CAMERA_PERSPECTIVE; - v.camera.target = (Vector3){FC_ARENA_WIDTH * 0.5f, 0.5f, -(FC_ARENA_HEIGHT * 0.5f)}; - - v.terrain = load_terrain(&v); + int item_icons_ready = load_fc_ui_item_icons(v); + v->paused = 1; v->tps = NORMAL_TPS; + v->active_loadout = FC_ACTIVE_LOADOUT; + v->attack_target = -1; + v->cam_yaw = 0; v->cam_pitch = 0.8f; v->cam_dist = 30; + v->camera_locked = 1; + v->camera.up = (Vector3){0,1,0}; v->camera.fovy = 32; + v->camera.projection = CAMERA_PERSPECTIVE; + v->camera.target = (Vector3){FC_ARENA_WIDTH * 0.5f, 0.5f, -(FC_ARENA_HEIGHT * 0.5f)}; + + v->terrain = load_terrain(v); /* OSRS rasterizes the current 104x104 scene from cache terrain and * locations into a 512x512 minimap. This asset contains the Fight Caves * mapsquare centered in that same scene format; runtime only crops and @@ -2279,7 +2318,7 @@ int main(int argc, char** argv) { if (minimap_image.data) { Color* minimap_pixels = LoadImageColors(minimap_image); if (!minimap_pixels || !fc_minimap_scene_load_pixels( - &v.minimap_scene, minimap_pixels, + &v->minimap_scene, minimap_pixels, minimap_image.width, minimap_image.height)) { fprintf(stderr, "error: Fight Caves minimap failed to load\n"); } else { @@ -2292,52 +2331,52 @@ int main(int argc, char** argv) { } else { fprintf(stderr, "error: missing fightcaves.minimap.png\n"); } - v.objects = load_objects_with_terrain(v.terrain); + v->objects = load_objects_with_terrain(v->terrain); if (fc_asset_exists("fightcaves.oanim")) - v.object_anims = object_anims_load("fightcaves.oanim"); - if (v.object_anims) - object_anims_offset(v.object_anims, FC_WORLD_ORIGIN_X, FC_WORLD_ORIGIN_Y); - if (!fc_animated_atlas_load(&v.shared_model_atlas, "fightcaves.atlas", 0)) + v->object_anims = object_anims_load("fightcaves.oanim"); + if (v->object_anims) + object_anims_offset(v->object_anims, FC_WORLD_ORIGIN_X, FC_WORLD_ORIGIN_Y); + if (!fc_animated_atlas_load(&v->shared_model_atlas, "fightcaves.atlas", 0)) fprintf(stderr, "error: shared model atlas failed to load\n"); if (fc_asset_exists("fightcaves.object_anim.models")) - v.object_anim_models = fc_npc_models_load( - "fightcaves.object_anim.models", v.shared_model_atlas.texture); - if (v.object_anims && v.object_anims->count > 0) { - v.object_anim_runtimes = (ObjectAnimRuntime*)calloc( - (size_t)v.object_anims->count, sizeof(*v.object_anim_runtimes)); - if (v.object_anim_runtimes) - v.object_anim_runtime_count = v.object_anims->count; + v->object_anim_models = fc_npc_models_load( + "fightcaves.object_anim.models", v->shared_model_atlas.texture); + if (v->object_anims && v->object_anims->count > 0) { + v->object_anim_runtimes = (ObjectAnimRuntime*)calloc( + (size_t)v->object_anims->count, sizeof(*v->object_anim_runtimes)); + if (v->object_anim_runtimes) + v->object_anim_runtime_count = v->object_anims->count; } - if (!v.terrain || !v.terrain->loaded) v.show_grid = 1; + if (!v->terrain || !v->terrain->loaded) v->show_grid = 1; /* Load NPC models */ { if (fc_asset_exists("fc_npcs.models")) - v.npc_models = fc_npc_models_load("fc_npcs.models", (Texture2D){0}); - if (!v.npc_models) fprintf(stderr, "error: NPC models failed to load\n"); + v->npc_models = fc_npc_models_load("fc_npcs.models", (Texture2D){0}); + if (!v->npc_models) fprintf(stderr, "error: NPC models failed to load\n"); } /* Load composable player body and equipment models. */ - if (!fc_player_appearance_load(&v.appearance)) { + if (!fc_player_appearance_load(&v->appearance)) { fprintf(stderr, "Required player appearance assets are missing or invalid.\n"); - fc_player_appearance_free(&v.appearance); - return 1; + fc_viewer_destroy(v); + return NULL; } /* Load the animation cache shared by actor and combat presentation. */ if (fc_asset_exists("fc_all.anims")) - v.anim_cache = anim_cache_load("fc_all.anims"); - v.combat_presentation = fc_combat_presentation_create( - v.shared_model_atlas.texture); - if (!v.combat_presentation) + v->anim_cache = anim_cache_load("fc_all.anims"); + v->combat_presentation = fc_combat_presentation_create( + v->shared_model_atlas.texture); + if (!v->combat_presentation) fprintf(stderr, "error: combat presentation initialization failed\n"); /* Load prayer overhead icon textures */ { if (fc_asset_exists("data/sprites/ui/prayeron_14.png")) { - v.pray_melee_tex = fc_load_texture_asset("data/sprites/ui/prayeron_14.png"); - v.pray_missiles_tex = fc_load_texture_asset("data/sprites/ui/prayeron_13.png"); - v.pray_magic_tex = fc_load_texture_asset("data/sprites/ui/prayeron_12.png"); + v->pray_melee_tex = fc_load_texture_asset("data/sprites/ui/prayeron_14.png"); + v->pray_missiles_tex = fc_load_texture_asset("data/sprites/ui/prayeron_13.png"); + v->pray_magic_tex = fc_load_texture_asset("data/sprites/ui/prayeron_12.png"); fprintf(stderr, "Prayer icons loaded from %s\n", fc_asset_root()); } else { fprintf(stderr, "error: prayer icons not found under asset root %s\n", @@ -2353,9 +2392,9 @@ int main(int argc, char** argv) { char path[64]; snprintf(path, sizeof(path), "data/sprites/ui/cross_%d.png", i); - v.click_cross_tex[i] = fc_load_texture_asset(path); - if (v.click_cross_tex[i].id > 0) { - SetTextureFilter(v.click_cross_tex[i], TEXTURE_FILTER_POINT); + v->click_cross_tex[i] = fc_load_texture_asset(path); + if (v->click_cross_tex[i].id > 0) { + SetTextureFilter(v->click_cross_tex[i], TEXTURE_FILTER_POINT); click_cross_loaded++; } } @@ -2367,17 +2406,17 @@ int main(int argc, char** argv) { /* Load prayer icons used by the active RuneC prayer override. */ { - v.tex_pray_melee_on = fc_load_texture_asset( + v->tex_pray_melee_on = fc_load_texture_asset( "data/sprites/ui/prayeron_14.png"); - v.tex_pray_melee_off = fc_load_texture_asset( + v->tex_pray_melee_off = fc_load_texture_asset( "data/sprites/ui/prayeroff_14.png"); - v.tex_pray_range_on = fc_load_texture_asset( + v->tex_pray_range_on = fc_load_texture_asset( "data/sprites/ui/prayeron_13.png"); - v.tex_pray_range_off = fc_load_texture_asset( + v->tex_pray_range_off = fc_load_texture_asset( "data/sprites/ui/prayeroff_13.png"); - v.tex_pray_magic_on = fc_load_texture_asset( + v->tex_pray_magic_on = fc_load_texture_asset( "data/sprites/ui/prayeron_12.png"); - v.tex_pray_magic_off = fc_load_texture_asset( + v->tex_pray_magic_off = fc_load_texture_asset( "data/sprites/ui/prayeroff_12.png"); } @@ -2389,402 +2428,429 @@ int main(int argc, char** argv) { required_resources_ready = 0; \ } \ } while (0) - REQUIRE_VIEWER_RESOURCE(v.ui.assets.missing_required_count == 0, + REQUIRE_VIEWER_RESOURCE(v->ui.assets.missing_required_count == 0, "RuneC UI sprites"); - REQUIRE_VIEWER_RESOURCE(v.ui.assets.font_loaded && - v.ui.assets.small_font_loaded, + REQUIRE_VIEWER_RESOURCE(v->ui.assets.font_loaded && + v->ui.assets.small_font_loaded, "RuneC UI fonts"); - REQUIRE_VIEWER_RESOURCE(v.ui.minimap_texture_ready, + REQUIRE_VIEWER_RESOURCE(v->ui.minimap_texture_ready, "minimap render texture"); REQUIRE_VIEWER_RESOURCE(item_icons_ready, "Fight Caves item icons"); - REQUIRE_VIEWER_RESOURCE(v.terrain && v.terrain->loaded, "terrain mesh"); - REQUIRE_VIEWER_RESOURCE(v.minimap_scene.ready, "minimap scene raster"); - REQUIRE_VIEWER_RESOURCE(v.objects && v.objects->loaded && - v.objects->atlas.texture.id > 0, + REQUIRE_VIEWER_RESOURCE(v->terrain && v->terrain->loaded, "terrain mesh"); + REQUIRE_VIEWER_RESOURCE(v->minimap_scene.ready, "minimap scene raster"); + REQUIRE_VIEWER_RESOURCE(v->objects && v->objects->loaded && + v->objects->atlas.texture.id > 0, "terrain objects and atlas"); - REQUIRE_VIEWER_RESOURCE(v.object_anims && v.object_anims->loaded, + REQUIRE_VIEWER_RESOURCE(v->object_anims && v->object_anims->loaded, "object animation placements"); - REQUIRE_VIEWER_RESOURCE(v.shared_model_atlas.texture.id > 0, + REQUIRE_VIEWER_RESOURCE(v->shared_model_atlas.texture.id > 0, "shared model atlas"); - REQUIRE_VIEWER_RESOURCE(v.object_anim_models && - v.object_anim_models->loaded, + REQUIRE_VIEWER_RESOURCE(v->object_anim_models && + v->object_anim_models->loaded, "animated object models"); - REQUIRE_VIEWER_RESOURCE(!v.object_anims || v.object_anims->count == 0 || - v.object_anim_runtimes, + REQUIRE_VIEWER_RESOURCE(!v->object_anims || v->object_anims->count == 0 || + v->object_anim_runtimes, "object animation runtime allocation"); - REQUIRE_VIEWER_RESOURCE(v.npc_models && v.npc_models->loaded, + REQUIRE_VIEWER_RESOURCE(v->npc_models && v->npc_models->loaded, "NPC models"); - REQUIRE_VIEWER_RESOURCE(v.appearance.parts && v.appearance.parts->loaded, + REQUIRE_VIEWER_RESOURCE(v->appearance.parts && v->appearance.parts->loaded, "player equipment and body models"); - REQUIRE_VIEWER_RESOURCE(v.anim_cache, "actor animation data"); + REQUIRE_VIEWER_RESOURCE(v->anim_cache, "actor animation data"); REQUIRE_VIEWER_RESOURCE( - fc_combat_presentation_ready(v.combat_presentation), + fc_combat_presentation_ready(v->combat_presentation), "projectile, spot-animation, healthbar, and hitsplat data"); - REQUIRE_VIEWER_RESOURCE(v.pray_melee_tex.id > 0 && - v.pray_missiles_tex.id > 0 && - v.pray_magic_tex.id > 0, + REQUIRE_VIEWER_RESOURCE(v->pray_melee_tex.id > 0 && + v->pray_missiles_tex.id > 0 && + v->pray_magic_tex.id > 0, "overhead Prayer icons"); REQUIRE_VIEWER_RESOURCE( click_cross_loaded == FC_CLICK_CROSS_FRAME_COUNT * 2, "click cross sprites"); - REQUIRE_VIEWER_RESOURCE(v.tex_pray_melee_on.id > 0 && - v.tex_pray_melee_off.id > 0 && - v.tex_pray_range_on.id > 0 && - v.tex_pray_range_off.id > 0 && - v.tex_pray_magic_on.id > 0 && - v.tex_pray_magic_off.id > 0, + REQUIRE_VIEWER_RESOURCE(v->tex_pray_melee_on.id > 0 && + v->tex_pray_melee_off.id > 0 && + v->tex_pray_range_on.id > 0 && + v->tex_pray_range_off.id > 0 && + v->tex_pray_magic_on.id > 0 && + v->tex_pray_magic_off.id > 0, "Prayer interface icons"); #undef REQUIRE_VIEWER_RESOURCE if (!required_resources_ready) { fprintf(stderr, "error: viewer startup aborted instead of using reduced " - "graphics; reinstall and verify assets with: python3 " - "ocean/fight_caves/tools.py setup --all --force\n"); - exit_code = 1; - goto cleanup; + "graphics; restore assets with: ./build.sh fight_caves --fast\n"); + fc_viewer_destroy(v); + return NULL; } - v.combat_style = 1; /* Rapid default */ - v.policy_pipe = policy_pipe_flag; - v.policy_episode_limit = policy_episode_limit_flag; - v.start_wave = start_wave_flag; - if (v.policy_pipe) - set_policy_replay_speed(&v, policy_speed_flag); - - reset_ep(&v); + v->combat_style = 1; + v->policy_pipe = replay; + v->paused = !replay; + return v; +} - /* Policy pipe: write initial obs so Python can send first action */ - if (v.policy_pipe) { - v.paused = 0; - fprintf(stderr, "[policy-pipe] Mode active. Reading actions from stdin.\n"); - write_obs_to_pipe(&v); +static void fc_viewer_ingest_tick(ViewerState* v) { + fc_fill_render_events(&v->state, &v->render_events); + fc_actor_animation_ingest_tick(&v->actor_animation, &v->state, + &v->render_events); + fc_actor_animation_ingest_events( + &v->actor_animation, &v->render_events, v->anim_cache, + fc_player_equipment_visual_profile(&v->state.player), v->tps); + + /* Debug event log — record events from this tick */ + dbg_log_tick(&v->state); + + /* Snap prev positions for newly spawned NPCs so they don't fly. + * An NPC that wasn't active last tick but is now = new spawn. */ + for (int ni = 0; ni < FC_MAX_NPCS; ni++) { + if (v->state.npcs[ni].active && + !fc_actor_animation_previous_npc_active( + &v->actor_animation, ni)) { + fc_combat_presentation_clear_npc_healthbar( + v->combat_presentation, ni); + } } - int frame_count = 0; + fc_fill_render_entities(&v->state, v->entities, &v->entity_count); + v->last_hash = fc_state_hash(&v->state); - while (!WindowShouldClose()) { - int quit_after_tick = 0; - int ui_capture = 0; - /* Screenshot mode */ - if (screenshot_mode && frame_count == 5) { - TakeScreenshot(screenshot_path); - fprintf(stderr, "Screenshot saved to %s\n", screenshot_path); - break; - } - frame_count++; - /* Age the previous click before capturing this frame's input. A newly - * clicked cross must start at frame zero, even after a slow frame. */ - fc_click_feedback_update(&v.click_feedback, GetFrameTime()); - - /* Global keys (always active) */ - if (IsKeyPressed(KEY_Q)) break; - if (IsKeyPressed(KEY_ESCAPE) && !v.ui.context_open) break; - if (IsKeyPressed(KEY_SPACE)) v.paused = !v.paused; - if (IsKeyPressed(KEY_RIGHT)) v.step_once = 1; - if (v.policy_pipe) { - if (IsKeyPressed(KEY_ONE)) set_policy_replay_speed(&v, 1); - if (IsKeyPressed(KEY_TWO)) set_policy_replay_speed(&v, 2); - if (!IsKeyDown(KEY_LEFT_SHIFT) && !IsKeyDown(KEY_RIGHT_SHIFT) && - IsKeyPressed(KEY_FOUR)) set_policy_replay_speed(&v, 4); - if (IsKeyPressed(KEY_ZERO)) set_policy_replay_speed(&v, 10); - if (IsKeyPressed(KEY_UP)) cycle_policy_replay_speed(&v, +1); - if (IsKeyPressed(KEY_DOWN)) cycle_policy_replay_speed(&v, -1); - } - if (IsKeyPressed(KEY_R)) reset_ep(&v); - if (IsKeyPressed(KEY_L)) { - if (v.camera_locked) { - v.camera.target = camera_follow_target(&v); - } - v.camera_locked = !v.camera_locked; + FcCombatPresentationContext combat_context = { + .state = &v->state, + .events = &v->render_events, + .scene = &v->actor_animation.scene, + .terrain = v->terrain, + .anim_cache = v->anim_cache, + .player_profile = fc_player_visual_profile(fc_player_equipment_visual_profile(&v->state.player)), + .tps = v->tps, + }; + fc_combat_presentation_ingest_tick(v->combat_presentation, + &combat_context); + /* Sync viewer attack_target with player's backend target */ + v->attack_target = v->state.player.attack_target_idx; + /* Auto-clear if target NPC died */ + if (v->state.player.attack_target_idx >= 0) { + FcNpc* tn = &v->state.npcs[v->state.player.attack_target_idx]; + if (!tn->active || tn->is_dead) { + v->attack_target = -1; } + } - /* Toggle keys */ - if (IsKeyPressed(KEY_G)) v.show_grid = !v.show_grid; - if (IsKeyPressed(KEY_C)) v.show_collision = !v.show_collision; - /* O: cycle debug overlay modes. O=all on/off, Shift+O=cycle sub-modes */ - if (IsKeyPressed(KEY_O)) { - if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) { - /* Cycle through individual modes */ - if (v.dbg_flags == 0) v.dbg_flags = DBG_COLLISION; - else if (v.dbg_flags == DBG_COLLISION) v.dbg_flags = DBG_LOS; - else if (v.dbg_flags == DBG_LOS) v.dbg_flags = DBG_PATH | DBG_RANGE; - else v.dbg_flags = 0; - } else { - /* Toggle all on/off */ - toggle_debug_overlay(&v); - } - } - /* D: match the on-screen controls without interfering with east movement */ - if (IsKeyPressed(KEY_D) && !IsKeyDown(KEY_W) && !IsKeyDown(KEY_A) && !IsKeyDown(KEY_S)) { - toggle_debug_overlay(&v); - } - /* Camera presets */ - if ((!v.policy_pipe && IsKeyPressed(KEY_FOUR)) || - (v.policy_pipe && - (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) && - IsKeyPressed(KEY_FOUR))) { - v.cam_yaw=0; v.cam_pitch=1.35f; v.cam_dist=120; +} + +/* Return 1 when a simulation tick is due, 0 for another display frame, + * and -1 when the window closes. External evaluation never steps a copy. */ +static int fc_viewer_frame(ViewerState* v, int external) { + if (WindowShouldClose()) return -1; + int quit_after_tick = 0; + int ui_capture = 0; + /* Age the previous click before capturing this frame's input. A newly + * clicked cross must start at frame zero, even after a slow frame. */ + fc_click_feedback_update(&v->click_feedback, GetFrameTime()); + + /* Global keys (always active) */ + if (IsKeyPressed(KEY_Q)) return -1; + if (IsKeyPressed(KEY_ESCAPE) && !v->ui.context_open) return -1; + if (IsKeyPressed(KEY_SPACE)) v->paused = !v->paused; + if (IsKeyPressed(KEY_RIGHT)) v->step_once = 1; + if (v->policy_pipe) { + if (IsKeyPressed(KEY_ONE)) set_policy_replay_speed(v, 1); + if (IsKeyPressed(KEY_TWO)) set_policy_replay_speed(v, 2); + if (!IsKeyDown(KEY_LEFT_SHIFT) && !IsKeyDown(KEY_RIGHT_SHIFT) && + IsKeyPressed(KEY_FOUR)) set_policy_replay_speed(v, 4); + if (IsKeyPressed(KEY_ZERO)) set_policy_replay_speed(v, 10); + if (IsKeyPressed(KEY_UP)) cycle_policy_replay_speed(v, +1); + if (IsKeyPressed(KEY_DOWN)) cycle_policy_replay_speed(v, -1); + } + if (!v->policy_pipe && IsKeyPressed(KEY_R)) reset_ep(v); + if (IsKeyPressed(KEY_L)) { + if (v->camera_locked) { + v->camera.target = camera_follow_target(v); } - if ((!v.policy_pipe && IsKeyPressed(KEY_FIVE)) || - (v.policy_pipe && - (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) && - IsKeyPressed(KEY_FIVE))) { - v.cam_yaw=0; v.cam_pitch=0.6f; v.cam_dist=50; + v->camera_locked = !v->camera_locked; + } + + /* Toggle keys */ + if (IsKeyPressed(KEY_G)) v->show_grid = !v->show_grid; + if (IsKeyPressed(KEY_C)) v->show_collision = !v->show_collision; + /* O: cycle debug overlay modes. O=all on/off, Shift+O=cycle sub-modes */ + if (IsKeyPressed(KEY_O)) { + if (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) { + /* Cycle through individual modes */ + if (v->dbg_flags == 0) v->dbg_flags = DBG_COLLISION; + else if (v->dbg_flags == DBG_COLLISION) v->dbg_flags = DBG_LOS; + else if (v->dbg_flags == DBG_LOS) v->dbg_flags = DBG_PATH | DBG_RANGE; + else v->dbg_flags = 0; + } else { + /* Toggle all on/off */ + toggle_debug_overlay(v); } + } + /* D: match the on-screen controls without interfering with east movement */ + if (IsKeyPressed(KEY_D) && !IsKeyDown(KEY_W) && !IsKeyDown(KEY_A) && !IsKeyDown(KEY_S)) { + toggle_debug_overlay(v); + } + /* Camera presets */ + if ((!v->policy_pipe && IsKeyPressed(KEY_FOUR)) || + (v->policy_pipe && + (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) && + IsKeyPressed(KEY_FOUR))) { + v->cam_yaw=0; v->cam_pitch=1.35f; v->cam_dist=120; + } + if ((!v->policy_pipe && IsKeyPressed(KEY_FIVE)) || + (v->policy_pipe && + (IsKeyDown(KEY_LEFT_SHIFT) || IsKeyDown(KEY_RIGHT_SHIFT)) && + IsKeyPressed(KEY_FIVE))) { + v->cam_yaw=0; v->cam_pitch=0.6f; v->cam_dist=50; + } - sync_fc_ui(&v); - if (v.ui.context_open) { - ui_capture = runec_ui_handle_input(&v.ui, GetScreenWidth(), GetScreenHeight()); - handle_runec_ui_intent(&v); + sync_fc_ui(v); + if (v->ui.context_open) { + ui_capture = runec_ui_handle_input(&v->ui, GetScreenWidth(), GetScreenHeight()); + handle_runec_ui_intent(v); + } else { + ui_capture = process_runec_prayer_click(v); + if (!ui_capture) + ui_capture = process_runec_console_input(v); + if (!ui_capture) { + ui_capture = runec_ui_handle_input(&v->ui, GetScreenWidth(), GetScreenHeight()); + handle_runec_ui_intent(v); } else { - ui_capture = process_runec_prayer_click(&v); - if (!ui_capture) - ui_capture = process_runec_console_input(&v); - if (!ui_capture) { - ui_capture = runec_ui_handle_input(&v.ui, GetScreenWidth(), GetScreenHeight()); - handle_runec_ui_intent(&v); - } else { - v.ui.last_intent.kind = RUNEC_UI_INTENT_NONE; - } + v->ui.last_intent.kind = RUNEC_UI_INTENT_NONE; } + } - /* RuneC: open on right press; a real drag dismisses the menu and - * retains the viewer's existing camera gesture. No game action fires. */ - if (!ui_capture && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) { - v.scene_right_tracking = 1; - v.scene_right_dragged = 0; - v.scene_right_start = GetMousePosition(); - open_scene_context_menu(&v); - ui_capture = 1; - } - if (v.scene_right_tracking && IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) { - Vector2 mouse = GetMousePosition(); - float dx = mouse.x - v.scene_right_start.x; - float dy = mouse.y - v.scene_right_start.y; - if (dx * dx + dy * dy > 9.0f) v.scene_right_dragged = 1; - if (v.scene_right_dragged) { - runec_ui_close_context(&v.ui); - Vector2 d = GetMouseDelta(); - v.cam_yaw += d.x*0.005f; v.cam_pitch -= d.y*0.005f; - if (v.cam_pitch < 0.1f) v.cam_pitch = 0.1f; - if (v.cam_pitch > 1.4f) v.cam_pitch = 1.4f; - } - } - if (IsMouseButtonReleased(MOUSE_BUTTON_RIGHT)) { - v.scene_right_tracking = v.scene_right_dragged = 0; - } - float wh = GetMouseWheelMove(); - if (!ui_capture && wh != 0) { - v.cam_dist *= (wh > 0) ? (1.0f/1.15f) : 1.15f; - if (v.cam_dist < 5) v.cam_dist = 5; - if (v.cam_dist > 300) v.cam_dist = 300; + /* RuneC: open on right press; a real drag dismisses the menu and + * retains the viewer's existing camera gesture. No game action fires. */ + if (!ui_capture && IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) { + v->scene_right_tracking = 1; + v->scene_right_dragged = 0; + v->scene_right_start = GetMousePosition(); + open_scene_context_menu(v); + ui_capture = 1; + } + if (v->scene_right_tracking && IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) { + Vector2 mouse = GetMousePosition(); + float dx = mouse.x - v->scene_right_start.x; + float dy = mouse.y - v->scene_right_start.y; + if (dx * dx + dy * dy > 9.0f) v->scene_right_dragged = 1; + if (v->scene_right_dragged) { + runec_ui_close_context(&v->ui); + Vector2 d = GetMouseDelta(); + v->cam_yaw += d.x*0.005f; v->cam_pitch -= d.y*0.005f; + if (v->cam_pitch < 0.1f) v->cam_pitch = 0.1f; + if (v->cam_pitch > 1.4f) v->cam_pitch = 1.4f; } + } + if (IsMouseButtonReleased(MOUSE_BUTTON_RIGHT)) { + v->scene_right_tracking = v->scene_right_dragged = 0; + } + float wh = GetMouseWheelMove(); + if (!ui_capture && wh != 0) { + v->cam_dist *= (wh > 0) ? (1.0f/1.15f) : 1.15f; + if (v->cam_dist < 5) v->cam_dist = 5; + if (v->cam_dist > 300) v->cam_dist = 300; + } - /* Tick processing */ - int tick = 0; - if (!v.paused) { - v.tick_acc += GetFrameTime() * (float)v.tps; - if (v.tick_acc >= 1.0f) { - v.tick_acc = fmodf(v.tick_acc, 1.0f); - tick = 1; - } - } - if (v.step_once) { tick = 1; v.step_once = 0; } - - /* Capture clicks and key presses EVERY frame (60fps). - * These set routes/targets/buffers on the player struct. - * The tick loop reads them when the next tick fires. */ - if (!v.policy_pipe && v.state.terminal == TERMINAL_NONE) { - process_human_clicks(&v, ui_capture); - process_human_keys(&v); + /* Tick processing */ + int tick = 0; + if (!v->paused) { + v->tick_acc += GetFrameTime() * (float)v->tps; + if (v->tick_acc >= 1.0f) { + v->tick_acc = fmodf(v->tick_acc, 1.0f); + tick = 1; } + } + if (v->step_once) { tick = 1; v->step_once = 0; } - if (tick && v.state.terminal == TERMINAL_NONE) { - int used_human_actions = 0; - /* Build action array for this tick */ - if (v.policy_pipe) { - if (!read_policy_actions(&v)) { - fprintf(stderr, "[policy-pipe] EOF on stdin, stopping.\n"); - break; - } - } else { - build_human_actions(&v); - used_human_actions = 1; - } - - fc_actor_animation_capture_tick_start(&v.actor_animation, - &v.state); - - /* Step simulation */ - fc_step(&v.state, v.actions); - if (used_human_actions && v.actions[5] > 0 && v.actions[6] > 0) - fc_click_feedback_accept_move_tick(&v.click_feedback, - &v.state); - fc_click_feedback_sync(&v.click_feedback, &v.state); - - /* Playable-viewer test aid only. The simulator has already - * resolved the hit; keep the local session alive at one HP. */ - if (v.godmode && - v.state.terminal == TERMINAL_PLAYER_DEATH) { - v.state.player.current_hp = 10; - v.state.terminal = TERMINAL_NONE; - } - fc_fill_render_events(&v.state, &v.render_events); - fc_actor_animation_ingest_tick(&v.actor_animation, &v.state, - &v.render_events); - update_reward_breakdown(&v); - fc_actor_animation_ingest_events( - &v.actor_animation, &v.render_events, v.anim_cache, - fc_player_equipment_visual_profile(&v.state.player), v.tps); - - /* Debug event log — record events from this tick */ - dbg_log_tick(&v.state); - - /* Snap prev positions for newly spawned NPCs so they don't fly. - * An NPC that wasn't active last tick but is now = new spawn. */ - for (int ni = 0; ni < FC_MAX_NPCS; ni++) { - if (v.state.npcs[ni].active && - !fc_actor_animation_previous_npc_active( - &v.actor_animation, ni)) { - fc_combat_presentation_clear_npc_healthbar( - v.combat_presentation, ni); - } - } + /* Capture clicks and key presses EVERY frame (60fps). + * These set routes/targets/buffers on the player struct. + * The tick loop reads them when the next tick fires. */ + if (!v->policy_pipe && v->state.terminal == TERMINAL_NONE) { + process_human_clicks(v, ui_capture); + process_human_keys(v); + } - fc_fill_render_entities(&v.state, v.entities, &v.entity_count); - v.last_hash = fc_state_hash(&v.state); - - FcCombatPresentationContext combat_context = { - .state = &v.state, - .events = &v.render_events, - .scene = &v.actor_animation.scene, - .terrain = v.terrain, - .anim_cache = v.anim_cache, - .player_profile = fc_player_visual_profile(fc_player_equipment_visual_profile(&v.state.player)), - .tps = v.tps, - }; - fc_combat_presentation_ingest_tick(v.combat_presentation, - &combat_context); - /* Sync viewer attack_target with player's backend target */ - v.attack_target = v.state.player.attack_target_idx; - /* Auto-clear if target NPC died */ - if (v.state.player.attack_target_idx >= 0) { - FcNpc* tn = &v.state.npcs[v.state.player.attack_target_idx]; - if (!tn->active || tn->is_dead) { - v.attack_target = -1; - } + if (!external && tick && v->state.terminal == TERMINAL_NONE) { + int used_human_actions = 0; + /* Build action array for this tick */ + if (v->policy_pipe) { + if (!read_policy_actions(v)) { + fprintf(stderr, "[policy-pipe] EOF on stdin, stopping.\n"); + return -1; } + } else { + build_human_actions(v); + used_human_actions = 1; + } - if (v.state.terminal != TERMINAL_NONE) { - if (v.policy_pipe) { - print_policy_episode_summary(&v); - v.policy_episode_count++; - /* Write terminal obs, then auto-reset unless a fixed episode limit was requested. */ - write_obs_to_pipe(&v); - if (v.policy_episode_limit > 0 && - v.policy_episode_count >= v.policy_episode_limit) { - quit_after_tick = 1; - } else { - reset_ep(&v); - } + fc_actor_animation_capture_tick_start(&v->actor_animation, + &v->state); + + /* Step simulation */ + fc_step(&v->state, v->actions); + if (used_human_actions && v->actions[5] > 0 && v->actions[6] > 0) + fc_click_feedback_accept_move_tick(&v->click_feedback, + &v->state); + fc_click_feedback_sync(&v->click_feedback, &v->state); + + /* Playable-viewer test aid only. The simulator has already + * resolved the hit; keep the local session alive at one HP. */ + if (v->godmode && + v->state.terminal == TERMINAL_PLAYER_DEATH) { + v->state.player.current_hp = 10; + v->state.terminal = TERMINAL_NONE; + } + update_reward_breakdown(v); + fc_viewer_ingest_tick(v); + + if (v->state.terminal != TERMINAL_NONE) { + if (v->policy_pipe) { + print_policy_episode_summary(v); + v->policy_episode_count++; + /* Write terminal obs, then auto-reset unless a fixed episode limit was requested. */ + write_obs_to_pipe(v); + if (v->policy_episode_limit > 0 && + v->policy_episode_count >= v->policy_episode_limit) { + quit_after_tick = 1; } else { - v.paused = 1; + reset_ep(v); } - } else if (v.policy_pipe) { - write_obs_to_pipe(&v); + } else { + v->paused = 1; } + } else if (v->policy_pipe) { + write_obs_to_pipe(v); } + } - if (quit_after_tick) { - fprintf(stderr, "[policy-pipe] Episode limit reached, exiting viewer.\n"); - break; - } + if (quit_after_tick) { + fprintf(stderr, "[policy-pipe] Episode limit reached, exiting viewer.\n"); + return -1; + } - float frame_dt = GetFrameTime(); - sync_player_appearance(&v); - if (v.item_message_seconds > 0) v.item_message_seconds -= frame_dt; - FcCombatPresentationContext combat_context = { - .state = &v.state, - .events = &v.render_events, - .scene = &v.actor_animation.scene, - .terrain = v.terrain, - .anim_cache = v.anim_cache, - .player_profile = fc_player_visual_profile(fc_player_equipment_visual_profile(&v.state.player)), - .tps = v.tps, - }; - unsigned char deferred_deaths[FC_MAX_NPCS]; - fc_combat_presentation_deferred_deaths( - v.combat_presentation, &v.state, deferred_deaths); - fc_actor_animation_update_scene( - &v.actor_animation, &v.state, v.anim_cache, v.tps, frame_dt, - !v.paused || v.policy_pipe, deferred_deaths); - if (v.objects) - fc_animated_atlas_update(&v.objects->atlas, frame_dt); - fc_combat_presentation_update(v.combat_presentation, - &combat_context, frame_dt); - fc_combat_presentation_deferred_deaths( - v.combat_presentation, &v.state, deferred_deaths); - for (int i = 0; i < FC_MAX_NPCS; i++) { - if (!v.state.npcs[i].active && !v.state.npcs[i].died_this_tick) - fc_combat_presentation_clear_npc_healthbar( - v.combat_presentation, i); + float frame_dt = GetFrameTime(); + sync_player_appearance(v); + if (v->item_message_seconds > 0) v->item_message_seconds -= frame_dt; + FcCombatPresentationContext combat_context = { + .state = &v->state, + .events = &v->render_events, + .scene = &v->actor_animation.scene, + .terrain = v->terrain, + .anim_cache = v->anim_cache, + .player_profile = fc_player_visual_profile(fc_player_equipment_visual_profile(&v->state.player)), + .tps = v->tps, + }; + unsigned char deferred_deaths[FC_MAX_NPCS]; + fc_combat_presentation_deferred_deaths( + v->combat_presentation, &v->state, deferred_deaths); + fc_actor_animation_update_scene( + &v->actor_animation, &v->state, v->anim_cache, v->tps, frame_dt, + !v->paused || v->policy_pipe, deferred_deaths); + if (v->objects) + fc_animated_atlas_update(&v->objects->atlas, frame_dt); + fc_combat_presentation_update(v->combat_presentation, + &combat_context, frame_dt); + fc_combat_presentation_deferred_deaths( + v->combat_presentation, &v->state, deferred_deaths); + for (int i = 0; i < FC_MAX_NPCS; i++) { + if (!v->state.npcs[i].active && !v->state.npcs[i].died_this_tick) + fc_combat_presentation_clear_npc_healthbar( + v->combat_presentation, i); + } + fc_actor_animation_update_models( + &v->actor_animation, &v->state, v->appearance.model, v->npc_models, + v->anim_cache, v->active_loadout, v->tps, frame_dt, deferred_deaths); + /* Draw */ + BeginDrawing(); + ClearBackground(COL_BG); + draw_scene(v); + sync_fc_ui(v); + runec_ui_draw(&v->ui, GetScreenWidth(), GetScreenHeight()); + draw_runec_side_overrides(v); + draw_runec_console(v); + draw_click_cross(v); + if (v->item_message_seconds > 0) { + DrawRectangle(8, GetScreenHeight() - 34, 490, 26, (Color){20, 16, 12, 240}); + text_s(v->item_message, 16, GetScreenHeight() - 29, 16, YELLOW); + } + /* Menus must cover the console and prayer overrides, not sit behind them. */ + runec_ui_draw_context(&v->ui); + + EndDrawing(); + return tick; +} + +#ifdef FC_VIEWER_EMBEDDED + +/* Consume the latest authoritative transition, including a terminal snapshot + * saved before Puffer's same-step autoreset. State is copied, never advanced. */ +static void fc_viewer_present_pending(ViewerState* v) { + if (!v->pending_frame) return; + if (v->pending_state.rng_seed != v->state.rng_seed || + v->pending_state.tick <= v->state.tick) { + v->state = v->reset_state; + fc_viewer_reset_presentation(v); + } + fc_actor_animation_capture_tick_start(&v->actor_animation, &v->state); + v->state = v->pending_state; + v->reward_runtime = v->pending_reward_runtime; + v->reward_breakdown = v->pending_reward_breakdown; + v->reward_breakdown_tick = v->state.tick; + memcpy(v->actions, v->pending_actions, sizeof(v->actions)); + fc_viewer_ingest_tick(v); + if (fc_is_terminal(&v->state)) { + print_policy_episode_summary(v); + v->policy_episode_count++; + } + v->pending_frame = 0; +} + +#endif + +int fc_viewer_main(int argc, char** argv) { + int screenshot_mode = 0; + const char* screenshot_path = NULL; + int policy_pipe_flag = 0; + int policy_speed_flag = 1; + int policy_episode_limit_flag = 0; + int start_wave_flag = 0; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--screenshot") == 0 && i+1 < argc) { + screenshot_mode = 1; + screenshot_path = argv[++i]; + } else if (strcmp(argv[i], "--policy-pipe") == 0) { + policy_pipe_flag = 1; + } else if (strcmp(argv[i], "--speed") == 0 && i+1 < argc) { + policy_speed_flag = atoi(argv[++i]); + } else if (strcmp(argv[i], "--episodes") == 0 && i+1 < argc) { + policy_episode_limit_flag = atoi(argv[++i]); + } else if (strcmp(argv[i], "--start-wave") == 0 && i+1 < argc) { + start_wave_flag = atoi(argv[++i]); } - fc_actor_animation_update_models( - &v.actor_animation, &v.state, v.appearance.model, v.npc_models, - v.anim_cache, v.active_loadout, v.tps, frame_dt, deferred_deaths); - /* Draw */ - BeginDrawing(); - ClearBackground(COL_BG); - draw_scene(&v); - sync_fc_ui(&v); - runec_ui_draw(&v.ui, GetScreenWidth(), GetScreenHeight()); - draw_runec_side_overrides(&v); - draw_runec_console(&v); - draw_click_cross(&v); - if (v.item_message_seconds > 0) { - DrawRectangle(8, GetScreenHeight() - 34, 490, 26, (Color){20, 16, 12, 240}); - text_s(v.item_message, 16, GetScreenHeight() - 29, 16, YELLOW); + } + ViewerState* v = fc_viewer_create(policy_pipe_flag); + if (!v) return EXIT_FAILURE; + v->policy_episode_limit = policy_episode_limit_flag; + v->start_wave = start_wave_flag; + if (v->policy_pipe) set_policy_replay_speed(v, policy_speed_flag); + reset_ep(v); + if (v->policy_pipe) { + v->paused = 0; + fprintf(stderr, "[policy-pipe] Mode active. Reading actions from stdin.\n"); + write_obs_to_pipe(v); + } + int frame_count = 0; + while (fc_viewer_frame(v, 0) >= 0) { + if (screenshot_mode && ++frame_count == 6) { + TakeScreenshot(screenshot_path); + break; } - /* Menus must cover the console and prayer overrides, not sit behind them. */ - runec_ui_draw_context(&v.ui); - - EndDrawing(); } + fc_viewer_destroy(v); + return EXIT_SUCCESS; +} -cleanup: - if (v.pray_melee_tex.id > 0) UnloadTexture(v.pray_melee_tex); - if (v.pray_missiles_tex.id > 0) UnloadTexture(v.pray_missiles_tex); - if (v.pray_magic_tex.id > 0) UnloadTexture(v.pray_magic_tex); - for (int i = 0; i < FC_CLICK_CROSS_FRAME_COUNT * 2; i++) { - if (v.click_cross_tex[i].id > 0) - UnloadTexture(v.click_cross_tex[i]); - } - if (v.tex_pray_melee_on.id > 0) UnloadTexture(v.tex_pray_melee_on); - if (v.tex_pray_melee_off.id > 0) UnloadTexture(v.tex_pray_melee_off); - if (v.tex_pray_range_on.id > 0) UnloadTexture(v.tex_pray_range_on); - if (v.tex_pray_range_off.id > 0) UnloadTexture(v.tex_pray_range_off); - if (v.tex_pray_magic_on.id > 0) UnloadTexture(v.tex_pray_magic_on); - if (v.tex_pray_magic_off.id > 0) UnloadTexture(v.tex_pray_magic_off); - fc_combat_presentation_destroy(v.combat_presentation); - fc_actor_animation_shutdown(&v.actor_animation); - if (v.object_anim_runtimes) { - for (int i = 0; i < v.object_anim_runtime_count; i++) { - if (v.object_anim_runtimes[i].anim_state) - anim_model_state_free(v.object_anim_runtimes[i].anim_state); - } - free(v.object_anim_runtimes); - } - if (v.anim_cache) anim_cache_free(v.anim_cache); - fc_player_appearance_free(&v.appearance); - if (v.npc_models) fc_npc_models_unload(v.npc_models); - if (v.object_anim_models) fc_npc_models_unload(v.object_anim_models); - fc_animated_atlas_unload(&v.shared_model_atlas); - if (v.object_anims) object_anims_free(v.object_anims); - objects_free(v.objects); - fc_minimap_scene_free(&v.minimap_scene); - terrain_free(v.terrain); - fc_osrs_text_shutdown(); - runec_ui_shutdown(&v.ui); - CloseWindow(); - return exit_code; +#ifndef FC_VIEWER_EMBEDDED +int main(int argc, char** argv) { + return fc_viewer_main(argc, argv); } +#endif diff --git a/resources/fight_caves/README.md b/resources/fight_caves/README.md index 4c9f868fde..ae7e3355c1 100644 --- a/resources/fight_caves/README.md +++ b/resources/fight_caves/README.md @@ -1,47 +1,34 @@ # Fight Caves assets -Fight Caves uses two independently versioned asset bundles: - -- `core` contains the collision, movement, and line-of-sight maps required by - training, evaluation, and the viewer. -- `viewer` contains the models, animations, terrain, textures, sprites, fonts, - and minimap used only by the graphical viewer. - Version 3 includes composable player equipment/body parts, their visibility - map, the corrected Venator ring icon and the bold RuneC context-menu font. - -The archives are pinned in `asset_manifest.json` by URL, byte size, and SHA-256. -Every installed file is also checked by byte size and SHA-256 before it is -accepted. Installed asset directories are intentionally excluded from Git. - -From the PufferLib repository root, install only the simulator data: +Normal users do not need a separate setup command. Every standard Fight Caves +build installs/verifies the pinned bundles automatically: ```bash -python3 ocean/fight_caves/tools.py setup --core +./build.sh fight_caves # Native CUDA backend, also ready for puffer eval +./build.sh fight_caves --cpu # CPU/PyTorch backend +./build.sh fight_caves --fast # Playable ./fight_caves executable ``` -Install only the graphical assets, or install both bundles: +The first build downloads the archives named in `asset_manifest.json` from the +versioned GitHub release. Existing valid installations are reused without +network access. Archive and per-file sizes/SHA-256 are verified before replacing +an installed bundle; failure aborts the build with a diagnostic. -```bash -python3 ocean/fight_caves/tools.py setup --viewer -python3 ocean/fight_caves/tools.py setup --all -``` +- `runtime/` contains the three collision, movement, and LOS maps (12 KiB total). +- `viewer/` contains models, equipment parts, animations, terrain, textures, + sprites, fonts, and the minimap raster. + +These installed directories are ignored by Git. Runtime loads local assets +directly from these paths; no reference repository, export tools, setup wrapper, +or runtime download is required. Headless training never initializes graphics. -Running the script without a bundle option is equivalent to `--all`. Verify an -existing installation without downloading or changing it with: +For maintenance only: ```bash python3 ocean/fight_caves/tools.py setup --all --verify-only +python3 ocean/fight_caves/tools.py setup --all --force ``` -The `tools.py build-viewer`, `play`, and `eval` commands verify required assets -automatically and fail with a nonzero exit status when data is absent or corrupt. -Before using Puffer's unchanged `build.sh`, run -`python3 ocean/fight_caves/tools.py preflight --mode cpu` (or `cuda`/`native` for -those builds). The simulator also refuses to start when required arena maps -cannot be loaded; it does not fall back to open maps. Viewer launch never -substitutes an incomplete graphical asset set. - -The simulator retains the `FC_COLLISION_PATH`, `FC_MOVEMENT_PATH`, and -`FC_LOS_PATH` environment-variable overrides for controlled development and -testing. The integrated viewer uses `resources/fight_caves/viewer` as its -default asset root. +Explicit `FC_COLLISION_PATH`, `FC_MOVEMENT_PATH`, `FC_LOS_PATH`, +`FC_ASSET_ROOT`, and `FC_REPO_ROOT` overrides remain available for development +and tests. Run standard commands from the Puffer repository root. diff --git a/tests/fight_caves.sh b/tests/fight_caves.sh index d97b3e9943..744be21597 100644 --- a/tests/fight_caves.sh +++ b/tests/fight_caves.sh @@ -36,8 +36,11 @@ if [ "$MODE" = "--puffer" ] || [ "$MODE" = "--all" ]; then fi if [ "$MODE" = "--all" ]; then - "$PYTHON" ocean/fight_caves/tools.py build-viewer - cmake --build build/fight_caves-viewer --target fc_viewer_tests --parallel + ./build.sh fight_caves --fast + cmake -S ocean/fight_caves -B build/fight_caves-standard-tests \ + -DCMAKE_BUILD_TYPE=Release + cmake --build build/fight_caves-standard-tests \ + --target fc_viewer_tests fc_integration_tests --parallel if command -v xvfb-run >/dev/null 2>&1; then DISPLAY_PREFIX=(xvfb-run -a) elif [ -n "${DISPLAY:-}" ]; then @@ -53,8 +56,10 @@ if [ "$MODE" = "--all" ]; then export FC_COLLISION_PATH="$REPO_ROOT/resources/fight_caves/runtime/fightcaves.collision" export FC_MOVEMENT_PATH="$REPO_ROOT/resources/fight_caves/runtime/fightcaves.movement" export FC_LOS_PATH="$REPO_ROOT/resources/fight_caves/runtime/fightcaves.los" - "${DISPLAY_PREFIX[@]}" "$REPO_ROOT/build/fight_caves-viewer/fc_viewer_tests" + "${DISPLAY_PREFIX[@]}" "$REPO_ROOT/build/fight_caves-standard-tests/fc_viewer_tests" ) + build/fight_caves-standard-tests/fc_integration_tests + "${DISPLAY_PREFIX[@]}" build/fight_caves-standard-tests/fc_integration_tests --render fi echo "Fight Caves environment tests passed ($MODE)." @@ -97,16 +102,16 @@ expect_failure \ "Install assets with: python3 ocean/fight_caves/tools.py setup --all" \ "$PYTHON" ocean/fight_caves/tools.py preflight --mode core -"$PYTHON" ocean/fight_caves/tools.py setup --all +# Public build path: asset acquisition is automatic. +./build.sh fight_caves --fast "$PYTHON" ocean/fight_caves/tools.py setup --all --verify-only bash tests/fight_caves.sh test --all "$PYTHON" ocean/fight_caves/tools.py preflight --mode native ./build.sh fight_caves --fast -./fight_caves >"$VALIDATION_ROOT/native-smoke.log" +./fight_caves --benchmark >"$VALIDATION_ROOT/native-smoke.log" grep -F "Episodes: 100" "$VALIDATION_ROOT/native-smoke.log" >/dev/null \ || fail "standalone environment did not finish its smoke run" -mv fight_caves "$VALIDATION_ROOT/fight_caves" CORE_MAP="resources/fight_caves/runtime/fightcaves.collision" mv "$CORE_MAP" "$VALIDATION_ROOT/fightcaves.collision" @@ -127,7 +132,7 @@ mv "$VIEWER_ASSET" "$VALIDATION_ROOT/fightcaves.minimap.png" expect_failure \ "missing viewer asset" \ "viewer asset bundle is invalid: missing viewer/fightcaves.minimap.png" \ - "$PYTHON" ocean/fight_caves/tools.py play --screenshot "$VALIDATION_ROOT/missing.png" + "$PYTHON" ocean/fight_caves/tools.py preflight --mode viewer-runtime mv "$VALIDATION_ROOT/fightcaves.minimap.png" "$VIEWER_ASSET" "$PYTHON" -m pufferlib.pufferl train fight_caves \ @@ -175,7 +180,7 @@ else fi SCREENSHOT_NAME="fight-caves-acceptance.png" -"${DISPLAY_PREFIX[@]}" "$PYTHON" ocean/fight_caves/tools.py play \ +"${DISPLAY_PREFIX[@]}" ./fight_caves \ --screenshot "$SCREENSHOT_NAME" \ >"$VALIDATION_ROOT/viewer-smoke.log" 2>&1 test -s "$SCREENSHOT_NAME" \ diff --git a/tests/fight_caves_integration.c b/tests/fight_caves_integration.c new file mode 100644 index 0000000000..4b2a525632 --- /dev/null +++ b/tests/fight_caves_integration.c @@ -0,0 +1,122 @@ +/* Run normally for headless trace parity, or with --render for the standard + * Puffer render hook. FC_INTEGRATION_ORIGINAL allows the same trace to be + * compiled against the unmodified adapter for an independent comparison. */ +#ifdef NDEBUG +#undef NDEBUG +#endif +#include "fight_caves.h" +#include + +static FightCaves* test_env(void) { + FightCaves* env = calloc(1, sizeof(*env)); + assert(env); + env->num_agents = 1; + env->rng = 73; + env->observations = calloc(FC_PUFFER_OBS_SIZE, sizeof(float)); + env->actions = calloc(FC_PUFFER_NUM_ATNS, sizeof(float)); + env->rewards = calloc(1, sizeof(float)); + env->terminals = calloc(1, sizeof(float)); + env->action_mask = calloc(FC_PUFFER_MASK_SIZE, 1); + env->reward_params = fc_reward_default_params(); + fc_init(&env->state); + c_reset(env); + return env; +} + +static void test_free(FightCaves* env) { + c_close(env); + free(env->observations); + free(env->actions); + free(env->rewards); + free(env->terminals); + free(env->action_mask); + free(env); +} + +static uint32_t digest(uint32_t hash, const void* data, size_t size) { + const unsigned char* bytes = data; + for (size_t i = 0; i < size; i++) hash = (hash ^ bytes[i]) * 16777619u; + return hash; +} + +int main(int argc, char** argv) { + int graphical = argc > 1 && strcmp(argv[1], "--render") == 0; + FightCaves* env = test_env(); + uint32_t trace = 2166136261u; + int episodes = 0; +#ifndef FC_INTEGRATION_ORIGINAL + FightCaves* rendered = test_env(); + if (graphical) { + c_render(rendered); + rendered->viewer->tps = 60.0f; + } else { + /* Exercise snapshot capture without allocating graphics in a test. */ + rendered->viewer = calloc(1, sizeof(*rendered->viewer)); + assert(rendered->viewer); + } +#else + (void)graphical; +#endif + for (int tick = 0; tick < 2048; tick++) { + env->actions[0] = tick % 17; + env->actions[1] = (tick / 3) % 9; + env->actions[2] = (tick / 7) % 8; +#ifndef FC_INTEGRATION_ORIGINAL + memcpy(rendered->actions, env->actions, sizeof(float) * FC_PUFFER_NUM_ATNS); +#endif + c_step(env); + uint32_t hash = fc_state_hash(&env->state); + trace = digest(trace, &hash, sizeof(hash)); + trace = digest(trace, env->observations, sizeof(float) * FC_PUFFER_OBS_SIZE); + trace = digest(trace, env->action_mask, FC_PUFFER_MASK_SIZE); + trace = digest(trace, env->rewards, sizeof(float)); + trace = digest(trace, env->terminals, sizeof(float)); + episodes += env->terminals[0] != 0; +#ifndef FC_INTEGRATION_ORIGINAL + c_step(rendered); + assert(hash == fc_state_hash(&rendered->state)); + assert(memcmp(env->observations, rendered->observations, + sizeof(float) * FC_PUFFER_OBS_SIZE) == 0); + assert(memcmp(env->action_mask, rendered->action_mask, + FC_PUFFER_MASK_SIZE) == 0); + assert(env->rewards[0] == rendered->rewards[0]); + assert(env->terminals[0] == rendered->terminals[0]); + assert(rendered->viewer->pending_frame); + if (env->terminals[0]) { + assert(rendered->state.tick == 0); + assert(fc_is_terminal(&rendered->viewer->pending_state)); + } + if (graphical) { + c_render(rendered); + assert(hash == fc_state_hash(&rendered->state)); + assert(!rendered->viewer->pending_frame); + if (env->terminals[0]) + assert(fc_is_terminal(&rendered->viewer->state)); + if (tick == 8) { + Image frame = LoadImageFromScreen(); + assert(ExportImage(frame, "build/fight-caves-standard-eval.png")); + UnloadImage(frame); + } + } +#endif + } + assert(episodes > 0); +#ifndef FC_INTEGRATION_ORIGINAL + c_reset(env); + c_reset(rendered); + if (graphical) { + c_render(rendered); + assert(fc_state_hash(&rendered->viewer->state) == fc_state_hash(&env->state)); + } +#endif + printf("adapter trace: steps=2048 episodes=%d digest=%08x\n", episodes, trace); +#ifndef FC_INTEGRATION_ORIGINAL + if (!graphical) { + free(rendered->viewer); + rendered->viewer = NULL; + } + test_free(rendered); +#endif + test_free(env); + return 0; +} diff --git a/tests/test_fight_caves.py b/tests/test_fight_caves.py index c5cf4235b8..311fe72db2 100644 --- a/tests/test_fight_caves.py +++ b/tests/test_fight_caves.py @@ -154,87 +154,50 @@ def test_preflight_reports_missing_commands_instead_of_continuing(mode, missing) assert result.returncode != 0 assert f"required command '{missing}' is unavailable" in result.stderr -def test_viewer_build_stops_before_download_when_preflight_fails(monkeypatch): - tools = load_setup_data() - monkeypatch.setattr(sys, "argv", [str(SETUP_DATA)]) - monkeypatch.setattr(tools, "run_preflight", lambda mode: 1) - monkeypatch.setattr(tools, "viewer_raylib", lambda root: pytest.fail("unexpected download")) - assert tools.build_viewer_main() == 1 - - -def test_viewer_build_uses_local_cmake_not_shared_build(tmp_path, monkeypatch): +@pytest.mark.parametrize("status", [0, 1]) +def test_legacy_viewer_build_delegates_to_standard_build(tmp_path, monkeypatch, status): tools = load_setup_data() monkeypatch.setattr(sys, "argv", [str(SETUP_DATA)]) monkeypatch.setattr(tools, "REPO_ROOT", tmp_path) - monkeypatch.setattr(tools, "ENV_ROOT", tmp_path / "ocean" / "fight_caves") - monkeypatch.setattr(tools, "run_preflight", lambda mode: 0) - monkeypatch.setattr(tools, "viewer_raylib", lambda root: tmp_path / "raylib") calls = [] - monkeypatch.setattr(tools.subprocess, "run", lambda args, **kwargs: calls.append(args)) - assert tools.build_viewer_main() == 0 - assert calls == [ - ["cmake", "-S", str(tools.ENV_ROOT), "-B", str(tmp_path / "build/fight_caves-viewer"), - "-DCMAKE_BUILD_TYPE=Release", f"-DRAYLIB_ROOT={tmp_path / 'raylib'}"], - ["cmake", "--build", str(tmp_path / "build/fight_caves-viewer"), "--parallel"], - ] + def build(args, **kwargs): + calls.append((args, kwargs)) + return status + monkeypatch.setattr(tools.subprocess, "call", build) + assert tools.build_viewer_main() == status + assert calls == [(["bash", "build.sh", "fight_caves", "--fast"], {"cwd": tmp_path})] -def test_viewer_build_reports_cmake_failure(tmp_path, monkeypatch, capsys): +def test_compatibility_replay_uses_standard_executable(tmp_path, monkeypatch): tools = load_setup_data() - monkeypatch.setattr(sys, "argv", [str(SETUP_DATA)]) - monkeypatch.setattr(tools, "run_preflight", lambda mode: 0) - monkeypatch.setattr(tools, "viewer_raylib", lambda root: tmp_path) + monkeypatch.setattr(tools, "repo_root", lambda: str(tmp_path)) + assert tools.find_viewer() is None + viewer = tmp_path / "fight_caves" + viewer.write_text("#!/bin/sh\nexit 0\n") + viewer.chmod(0o755) + assert tools.find_viewer() == str(viewer) - def fail(args, **kwargs): - raise subprocess.CalledProcessError(1, args) - monkeypatch.setattr(tools.subprocess, "run", fail) - assert tools.build_viewer_main() == 1 - assert "viewer build failed" in capsys.readouterr().err +def test_standard_build_prepares_both_asset_bundles(): + build = (REPO_ROOT / "build.sh").read_text() + branch = build.split('elif [ "$ENV" = "fight_caves" ]; then', 1)[1].split("elif ", 1)[0] + assert 'tools.py" setup --all' in branch -def test_explicit_raylib_is_validated_without_downloading(tmp_path, monkeypatch): - tools = load_setup_data() - monkeypatch.setattr(tools, "download", lambda *args: pytest.fail("unexpected download")) - with pytest.raises(tools.AssetError, match="Raylib is incomplete"): - tools.viewer_raylib(tmp_path) - for relative in tools.RAYLIB_FILES: - path = tmp_path / relative - path.parent.mkdir(parents=True, exist_ok=True) - path.write_bytes(b"existing dependency") - assert tools.viewer_raylib(tmp_path) == tmp_path - - -@pytest.mark.parametrize("complete", [False, True]) -def test_raylib_download_is_staged_and_confined(tmp_path, monkeypatch, complete): +@pytest.mark.parametrize("broken", [False, True]) +def test_replay_asset_check_is_independent_of_display(monkeypatch, broken): tools = load_setup_data() - monkeypatch.setattr(tools, "REPO_ROOT", tmp_path) - monkeypatch.setattr(tools.sys, "platform", "darwin") - name = "raylib-5.5_macos" - files = {f"{name}/{relative}": b"dependency" for relative in tools.RAYLIB_FILES} - files["../../outside"] = b"not part of the viewer dependency" - if not complete: - del files[f"{name}/lib/libraylib.a"] - - def download(url, destination): - assert url == f"https://github.com/raysan5/raylib/releases/download/5.5/{name}.tar.gz" - make_archive(destination, files) - - monkeypatch.setattr(tools, "download", download) - if complete: - root = tools.viewer_raylib(None) - assert root == tmp_path / "build" / name - for relative in tools.RAYLIB_FILES: - assert (root / relative).read_bytes() == b"dependency" - monkeypatch.setattr(tools, "download", lambda *args: pytest.fail("unexpected download")) - assert tools.viewer_raylib(None) == root + monkeypatch.delenv("DISPLAY", raising=False) + def verify(errors, names): + assert names == ("core", "viewer") + if broken: + errors.append("missing viewer asset") + monkeypatch.setattr(tools, "verify_assets", verify) + if broken: + with pytest.raises(tools.AssetError, match="Restore assets"): + tools.verify_runtime_assets() else: - with pytest.raises(KeyError): - tools.viewer_raylib(None) - assert not (tmp_path / "build" / name).exists() - assert not (tmp_path / "outside").exists() - assert not (tmp_path / name).exists() - assert not list((tmp_path / "build").glob("fight-caves-raylib-*")) + tools.verify_runtime_assets() MODULE_PATH = REPO_ROOT / "ocean" / "fight_caves" / "tools.py" From 6015cbff98c2b6c54897f176644bc426bc363657 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Thu, 10 Sep 2026 23:01:45 -0400 Subject: [PATCH 08/14] Use official PufferTank 4.0 for Fight Caves setup Remove the environment-specific Docker image and dependency constraints. Document standard asset-installing build, train, play and replay commands in the supplied PufferTank environment. Verified official image 4ffc8947bc8d: Breakout CUDA control (524288 steps), Fight Caves CUDA training (2097152 steps, offline W&B 4kjsw4ma), playable startup, native checkpoint replay, matching headless/rendered traces (aeaaf52e), and core tests. Existing 29 Python tests pass locally. No simulation, policy, reward, config or shared trainer changes. --- ocean/fight_caves/Dockerfile | 64 ------ ocean/fight_caves/Dockerfile.dockerignore | 56 ----- ocean/fight_caves/README.md | 239 +++++++--------------- ocean/fight_caves/docker-constraints.txt | 8 - ocean/fight_caves/tools.py | 2 +- 5 files changed, 80 insertions(+), 289 deletions(-) delete mode 100644 ocean/fight_caves/Dockerfile delete mode 100644 ocean/fight_caves/Dockerfile.dockerignore delete mode 100644 ocean/fight_caves/docker-constraints.txt diff --git a/ocean/fight_caves/Dockerfile b/ocean/fight_caves/Dockerfile deleted file mode 100644 index 0e6013f5d1..0000000000 --- a/ocean/fight_caves/Dockerfile +++ /dev/null @@ -1,64 +0,0 @@ -# syntax=docker/dockerfile:1 -# Build from the repository root: -# docker build --platform linux/amd64 -f ocean/fight_caves/Dockerfile -t fight-caves:local . -# The CUDA backend is compiled inside the running container; see README.md. -FROM ubuntu:24.04@sha256:33ceb71981b602c1a7443a53469e4dba065f7503eab3078a2d7a57a2ab987517 - -ARG DEBIAN_FRONTEND=noninteractive -ARG CUDNN_VERSION=9.13.0.50-1 -ARG NCCL_VERSION=2.27.7-1+cuda13.0 - -SHELL ["/bin/bash", "-o", "pipefail", "-c"] - -RUN test "$(dpkg --print-architecture)" = amd64 \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - binutils build-essential ca-certificates ccache clang cmake curl git \ - libgl1-mesa-dev libgl1-mesa-dri libomp-dev libomp5 \ - libx11-dev libxcursor-dev libxi-dev libxinerama-dev libxrandr-dev \ - python3-dev python3-venv x11-utils xauth xvfb \ - && curl -fsSL \ - https://developer.download.nvidia.com/compute/cuda/repos/ubuntu2404/x86_64/cuda-keyring_1.1-1_all.deb \ - -o /tmp/cuda-keyring.deb \ - && dpkg -i /tmp/cuda-keyring.deb \ - && rm /tmp/cuda-keyring.deb \ - && apt-get update \ - && apt-get install -y --no-install-recommends \ - cuda-cudart-dev-13-0 cuda-nvcc-13-0 cuda-nvml-dev-13-0 \ - cuda-nvtx-13-0 cuda-profiler-api-13-0 \ - libcublas-dev-13-0 libcurand-dev-13-0 libcusolver-dev-13-0 \ - "libcudnn9-cuda-13=${CUDNN_VERSION}" \ - "libcudnn9-dev-cuda-13=${CUDNN_VERSION}" \ - "libcudnn9-headers-cuda-13=${CUDNN_VERSION}" \ - "libnccl2=${NCCL_VERSION}" "libnccl-dev=${NCCL_VERSION}" \ - && rm -rf /var/lib/apt/lists/* - -ENV CUDA_HOME=/usr/local/cuda-13.0 \ - VIRTUAL_ENV=/workspace/PufferLib/.venv \ - NVIDIA_DRIVER_CAPABILITIES=compute,utility,graphics,display -ENV PATH="${VIRTUAL_ENV}/bin:${CUDA_HOME}/bin:${PATH}" - -# The host driver is injected by NVIDIA Container Toolkit at runtime. Puffer's -# -lnvidia-ml also needs this unversioned linker name; it is dangling at build time. -RUN ln -s libnvidia-ml.so.1 /usr/lib/x86_64-linux-gnu/libnvidia-ml.so \ - && printf '%s\n' "${CUDA_HOME}/lib64" > /etc/ld.so.conf.d/fight-caves-cuda.conf \ - && ldconfig \ - && printf '%s\n' 'export PATH="$VIRTUAL_ENV/bin:$CUDA_HOME/bin:$PATH"' \ - > /etc/profile.d/fight-caves-env.sh - -WORKDIR /workspace/PufferLib -COPY ocean/fight_caves/docker-constraints.txt /opt/fight-caves/constraints.txt -RUN python3 -m venv --prompt fight-caves "$VIRTUAL_ENV" \ - && python -m pip install --no-cache-dir --upgrade pip setuptools wheel \ - && python -m pip install --no-cache-dir \ - -c /opt/fight-caves/constraints.txt \ - --index-url https://download.pytorch.org/whl/cu130 torch - -# Dockerfile.dockerignore limits this context to Puffer and Fight Caves sources. -COPY . . -RUN python -m pip install --no-cache-dir -c /opt/fight-caves/constraints.txt -e . pytest \ - && python -m pip check \ - && ./build.sh fight_caves --fast \ - && bash tests/fight_caves.sh test --core - -CMD ["bash"] diff --git a/ocean/fight_caves/Dockerfile.dockerignore b/ocean/fight_caves/Dockerfile.dockerignore deleted file mode 100644 index 0593466284..0000000000 --- a/ocean/fight_caves/Dockerfile.dockerignore +++ /dev/null @@ -1,56 +0,0 @@ -# Paths are relative to the repository root, used as the build context. -# Include only the code and manifests needed by Puffer and Fight Caves. -** -!README.md -!LICENSE -!pyproject.toml -!build.sh -!pufferlib/ -!pufferlib/** -!src/ -!src/** -!vendor/ -!vendor/** -!config/ -config/* -!config/default.ini -!config/fight_caves.ini -!ocean/ -ocean/* -!ocean/fight_caves/ -!ocean/fight_caves/** -# Puffer's shared CUDA encoder unconditionally includes this generated table. -!ocean/nethack/ -ocean/nethack/* -!ocean/nethack/glyph_map.h -!resources/ -resources/* -!resources/fight_caves/ -resources/fight_caves/* -!resources/fight_caves/asset_manifest.json -!resources/fight_caves/ASSET_NOTICE.md -!resources/fight_caves/README.md -!tests/ -tests/* -!tests/fight_caves.sh -!tests/fight_caves.c -!tests/test_fight_caves.py - -# Never import local binaries, caches, credentials or generated assets. -**/__pycache__/ -**/*.pyc -**/*.so -**/*.o -**/*.a -**/*.egg-info/ -**/.git -**/.env -**/.env.* -**/.netrc -**/.venv/ -**/build/ -**/checkpoints/ -**/logs/ -**/wandb/ -vendor/fast-nle/ -vendor/nle/ diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index c78280b6a1..91c5bdfea3 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -1,39 +1,47 @@ # Fight Caves -A single-agent native C Fight Caves environment for PufferLib 4.0. Training, -human play, and checkpoint evaluation share the same simulation and full Raylib -viewer. Gameplay, observations, rewards, action heads, and the default 750M-step -configuration are unchanged by the standard-workflow integration. +A native C Fight Caves environment for PufferLib 4.0, with all 63 waves, +training, a playable Raylib viewer, and checkpoint replay. The trainer and +viewer share the same simulation. -## Setup and training +## Setup in PufferTank 4.0 -Use a normal Puffer 4.0 development environment: Python 3.10+, Clang/OpenMP, -Raylib's system/OpenGL dependencies, and the CUDA/cuDNN/NCCL development stack -for native GPU training. These are shared Puffer prerequisites, not a separate -Fight Caves installation. An optional reproducible Docker setup is below. +Use the official [PufferTank 4.0 environment](https://github.com/PufferAI/PufferTank/tree/4.0), +following [Puffer's installation instructions](https://puffer.ai/docs.html#installation). +Fight Caves does not have its own Docker image or Python/CUDA dependency stack. +For play and replay, start PufferTank with the display forwarding described by +Puffer; a headless container can train, but cannot show an interactive window. -From the repository root, in your activated Python environment: +Inside PufferTank's interactive shell, use its already-activated Python +environment. While this PR is under review, clone its source branch: ```bash -python -m pip install -e . -./build.sh fight_caves -puffer train fight_caves --wandb +git clone --branch fight-caves-puffertank-4.0 https://github.com/jordanbailey00/PufferLib.git PufferLib-fight-caves +cd PufferLib-fight-caves +uv pip install --no-deps -e . ``` -The build **automatically installs and verifies both asset bundles**. There is -no separate Fight Caves setup, preflight, or viewer-build command to remember. -Verified assets are reused on subsequent builds, including offline builds. -The first installation requires access to the pinned GitHub release. +The editable installation points the existing `puffer` command at this checkout. +`--no-deps` preserves the dependencies supplied by PufferTank. Do not create +another virtual environment or replace its PyTorch/CUDA packages for Fight Caves. +Run the following commands from this checkout's root. -CPU/PyTorch training follows Puffer's ordinary alternative: +## Train ```bash -./build.sh fight_caves --cpu -puffer train fight_caves --slowly +./build.sh fight_caves +puffer train fight_caves ``` -Puffer compiles one selected environment/backend into `pufferlib/_C`. Rebuild -when switching environments or between native CUDA and CPU backends. +The build automatically downloads and verifies all required assets. There is +no separate setup or viewer-build command. `config/fight_caves.ini` supplies the +750M-step configuration. For W&B logging, run `wandb login` once and train with: + +```bash +puffer train fight_caves --wandb --wandb-project fight-caves +``` + +Ordinary training is headless; it does not create a graphical window. ## Play manually @@ -42,173 +50,84 @@ when switching environments or between native CUDA and CPU backends. ./fight_caves ``` -Use `--local` instead of `--fast` for Puffer's debug/sanitizer build. Human play -does not require CUDA or a compiled Python backend. A graphical desktop is -required. The game starts paused; press Space to begin. +You can play without training a policy first. Press Space to start or pause, +Right Arrow to advance one tick, O to toggle debug overlays, and Q to quit. +Right-drag rotates the camera; the mouse wheel zooms. Use `--local` instead of +`--fast` for Puffer's debug/sanitizer build. -The full viewer is retained: tile clicking and path previews, camera controls, -equipment switching and right-click menus, inventory/prayer interfaces, minimap -and run-energy orbs, wave/TPS/target controls, god mode, diagnostics, projectiles, -animations, health bars, hitsplats, and Prayer-window indicators. +The full viewer includes tile clicks and route previews, equipment switching, +inventory and prayer controls, right-click menus, minimap/run-energy controls, +animations, projectiles, impacts, health bars, and hitsplats. The console has +wave/target/TPS selection, god mode, observations, rewards, and an event log. -Space pauses; Right Arrow single-steps; O toggles the debug overlay; right-drag -orbits; the mouse wheel zooms; Q quits. The console contains wave, target, -speed, and god-mode controls. `./fight_caves --benchmark` retains the optional -headless random-action benchmark. +## Replay a checkpoint -## Watch a checkpoint - -After building the same backend used to train the checkpoint: +With the same backend and policy architecture used for training: ```bash puffer eval fight_caves --load-model-path latest ``` -Or pass a specific checkpoint path. PyTorch checkpoints use Puffer's `--slowly` -backend. Standard evaluation uses Puffer's own policy inference, masking, and -environment stepping; the viewer only displays snapshots of the evaluated -environment. Graphics are initialized lazily by `c_render()`, never by ordinary -headless training. Terminal snapshots are retained before same-step autoreset. - -The same viewer supports camera/debug/pause/speed controls during evaluation. -Gameplay-changing controls are disabled in replay. Keyboard 1/2/4/0 selects -1x/2x/4x/10x playback. Closing the window or pressing Q ends evaluation. -Puffer's standard `latest` means newest by file time, not highest-scoring. - -### Optional CPU-only compatibility replay - -Puffer's native CUDA and PyTorch backends use different checkpoint formats. -The optional compatibility reader is retained for replaying native CUDA weights -on the CPU, deterministic sampling, or a fixed episode limit: - -```bash -./build.sh fight_caves --cpu -./build.sh fight_caves --fast -python ocean/fight_caves/tools.py eval --ckpt /path/to/checkpoint.bin --episodes 1 -``` +Replace `latest` with a checkpoint path to select a specific model. `latest` +selects the newest file, not the highest-scoring policy. Evaluation opens the +same full viewer, using Puffer's policy inference and environment stepping. +Camera, debug, pause, and speed controls remain available; gameplay-changing +controls are disabled. Keys 1/2/4/0 select 1x/2x/4x/10x. Q closes evaluation. -It uses the same `./fight_caves` executable and the existing contract checks. -It is not required for ordinary `puffer eval`. Rebuild the CUDA backend before -resuming native training after a CPU build. +Puffer compiles one selected environment/backend into `pufferlib/_C`. Rebuild +when switching environments or between native CUDA and CPU backends. Building +the standalone viewer with `--fast` does not replace the training backend. ## Assets -`build.sh` invokes the existing pinned installer automatically for Fight Caves. -Archive and individual-file SHA-256 checks precede transactional installation: +The first build installs the pinned [Fight Caves v3 bundles](https://github.com/jordanbailey00/fc-rl/releases/tag/fight-caves-assets-v3): -- `resources/fight_caves/runtime/`: collision, movement, and LOS maps. +- `resources/fight_caves/runtime/`: collision, movement, and line-of-sight maps. - `resources/fight_caves/viewer/`: models, equipment parts, animations, terrain, - textures, sprites, fonts, and the minimap raster. - -The simulator and viewer load these local paths directly; no cache export, -reference repository, external codebase, or runtime network call is needed. -Missing or invalid required data fails rather than substituting open maps or -reduced graphics. Rerunning the build repairs missing/corrupt installed bundles; -download or validation failure stops the build. + textures, UI sprites/fonts, and the minimap raster. -`tools.py setup`, `bundle`, and `preflight` remain explicit maintenance tools. -The old `tools.py build-viewer` and `play` commands are compatibility aliases -for the standard standalone build and executable. +Archive and individual-file sizes/SHA-256 hashes are checked before installation. +Valid assets are reused, including offline. Rerunning the build repairs missing +or corrupt bundles; download or verification failure stops the build. Runtime +loads local files only, without another repository or raw OSRS cache. Missing +required data produces an error, not an open-map or reduced-graphics fallback. -## Maintainer checks and layout +Optional one-time installation or verification: ```bash -bash tests/fight_caves.sh test --core -bash tests/fight_caves.sh test --all +python ocean/fight_caves/tools.py setup --all +python ocean/fight_caves/tools.py setup --all --verify-only ``` -The optional graphical test target uses CMake/Xvfb. Neither is required to -build or launch the ordinary viewer. Tests cover assets/failure handling, -contracts, equipment, graphics, and rendering-versus-headless trajectory parity. - -- `simulation.h`: unchanged combat, movement, waves, items, contracts and state. -- `fight_caves.h`, `binding.c`: Puffer integration and lazy renderer connection. -- `fight_caves.c`: conventional playable entry point and optional benchmark. -- `viewer.c`: shared viewer lifecycle, input, frame rendering and compatibility pipe. -- `assets.h`, `ui.h`, `render.h`: existing assets, interface and presentation. -- `tools.py`: asset maintenance and optional cross-backend checkpoint replay. -- `CMakeLists.txt`: optional graphical regression builds. -- `Dockerfile` and constraints: optional reproducible development environment. - -`bash tests/fight_caves.sh clean-clone` tests a committed branch, not uncommitted -working-copy changes. Use `checkout` in an isolated source copy to validate -uncommitted work. +OSRS assets are distributed separately and are not covered by PufferLib's +software license; see `resources/fight_caves/ASSET_NOTICE.md`. -## Docker (Ubuntu / NVIDIA) +## Optional tools -Ubuntu 24.04 x86-64, CUDA 13.0 and Raylib 5.5. `docker-constraints.txt` pins -PyTorch 2.9.1+cu130 and W&B 0.28.1 for this branch's `wandb.util.generate_id()` call. - -The host needs an NVIDIA GPU with a driver compatible with CUDA 13.0, -and [NVIDIA Container Toolkit configured for Docker](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). -Assets: [fight-caves-assets-v3](https://github.com/jordanbailey00/fc-rl/releases/tag/fight-caves-assets-v3), -pinned in `resources/fight_caves/asset_manifest.json`. - -Build from the repository root: +Puffer's CPU/PyTorch path uses `./build.sh fight_caves --cpu` followed by +`puffer train fight_caves --slowly` or `puffer eval fight_caves --slowly`. +Native CUDA and PyTorch checkpoints use different formats. To replay native +CUDA weights on the CPU, or stop after one episode, the compatibility tool is +still available: ```bash -docker build --platform linux/amd64 \ - -f ocean/fight_caves/Dockerfile -t fight-caves:local . -``` - -The image builds the viewer and runs the core tests without a GPU. The CUDA -backend is built once inside each new container, where Puffer's default -`NVCC_ARCH=native` can detect the GPU. - -Training or headless tests: - -```bash -docker run -it --name fight-caves-test --gpus all --shm-size=1g \ - --mount type=volume,source=fight-caves-checkpoints,target=/workspace/PufferLib/checkpoints \ - --mount type=volume,source=fight-caves-logs,target=/workspace/PufferLib/logs \ - fight-caves:local -``` - -Desktop play/replay requires X11 or XWayland and `xauth`. Run from the host's -graphical session with `DISPLAY` set: - -```bash -FC_XAUTH=$(mktemp /tmp/fight-caves-xauth.XXXXXX) -xauth -f "${XAUTHORITY:-$HOME/.Xauthority}" nlist "$DISPLAY" \ - | sed 's/^..../ffff/' | xauth -f "$FC_XAUTH" nmerge - - -docker run -it --name fight-caves-test --gpus all --shm-size=1g \ - -e DISPLAY -e XAUTHORITY=/tmp/fight-caves.Xauthority \ - --mount type=bind,source=/tmp/.X11-unix,target=/tmp/.X11-unix,readonly \ - --mount "type=bind,source=$FC_XAUTH,target=/tmp/fight-caves.Xauthority,readonly" \ - --mount type=volume,source=fight-caves-checkpoints,target=/workspace/PufferLib/checkpoints \ - --mount type=volume,source=fight-caves-logs,target=/workspace/PufferLib/logs \ - fight-caves:local +./build.sh fight_caves --cpu +./build.sh fight_caves --fast +python ocean/fight_caves/tools.py eval --ckpt /path/to/checkpoint.bin --episodes 1 ``` -Keep the temporary authorization file while that container is in use. A new -desktop login may require a fresh authorization file and container. Both launch -examples use the same container name; choose one. +It uses the same viewer executable. Rebuild the CUDA backend before returning +to native training. `./fight_caves --benchmark` runs the headless benchmark. -Inside the container, in `/workspace/PufferLib`: +Maintainer tests are explicitly invoked, not part of normal setup: ```bash -./build.sh fight_caves bash tests/fight_caves.sh test --core +bash tests/fight_caves.sh test --all ``` -750M-step training with W&B: - -```bash -wandb login -puffer train fight_caves --wandb --wandb-project fight-caves -``` - -Viewer and checkpoint replay: - -```bash -./fight_caves -puffer eval fight_caves --load-model-path latest -``` - -Headless viewer check: - -```bash -xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ - ./fight_caves --screenshot playable.png -``` +Tests additionally need pytest; graphical regression builds use CMake and an +existing DISPLAY or Xvfb. These are test tools, not extra gameplay dependencies. +`test --all` builds the CPU backend; rebuild CUDA afterward for native training. +`clean-clone` tests a committed branch, while `checkout` tests an isolated source +copy. Neither command is required to use the environment. diff --git a/ocean/fight_caves/docker-constraints.txt b/ocean/fight_caves/docker-constraints.txt deleted file mode 100644 index 2ba91a3b65..0000000000 --- a/ocean/fight_caves/docker-constraints.txt +++ /dev/null @@ -1,8 +0,0 @@ -# Core versions verified with Ubuntu 24.04, CUDA 13.0 and the Fight Caves config. -# Install torch from https://download.pytorch.org/whl/cu130 before PufferLib. -torch==2.9.1+cu130 -numpy==2.5.3 -pybind11==3.1.0 -pytest==9.1.1 -# This branch calls wandb.util.generate_id(), removed by newer W&B releases. -wandb==0.28.1 diff --git a/ocean/fight_caves/tools.py b/ocean/fight_caves/tools.py index fa3a8d18b0..8272874064 100644 --- a/ocean/fight_caves/tools.py +++ b/ocean/fight_caves/tools.py @@ -666,7 +666,7 @@ def run_preflight(mode: str) -> int: return 0 -# Optional viewer build; the shared Puffer build.sh remains unmodified. +# Compatibility entry point; standard build.sh owns assets and compilation. def build_viewer_main() -> int: """Compatibility alias; the standard standalone build owns dependencies.""" From c24ff7e8c75efbf04bb685b68dfc78fca93231e9 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:52:12 -0400 Subject: [PATCH 09/14] Document normal graphics driver selection in PufferTank --- ocean/fight_caves/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index 91c5bdfea3..17c4d2df54 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -47,9 +47,14 @@ Ordinary training is headless; it does not create a graphical window. ```bash ./build.sh fight_caves --fast +unset __GLX_VENDOR_LIBRARY_NAME ./fight_caves ``` +The `unset` command removes PufferTank's forced Mesa selection and lets the +display choose its normal graphics driver. It does not assume a GPU brand. +Run it in each new container shell before play or replay; it requires no rebuild. + You can play without training a policy first. Press Space to start or pause, Right Arrow to advance one tick, O to toggle debug overlays, and Q to quit. Right-drag rotates the camera; the mouse wheel zooms. Use `--local` instead of @@ -65,6 +70,7 @@ wave/target/TPS selection, god mode, observations, rewards, and an event log. With the same backend and policy architecture used for training: ```bash +unset __GLX_VENDOR_LIBRARY_NAME puffer eval fight_caves --load-model-path latest ``` From a648850c24eafeea58577959689cdd70c1d38687 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:11:16 -0400 Subject: [PATCH 10/14] Highlight PufferTank software-rendering workaround --- ocean/fight_caves/README.md | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index 17c4d2df54..c813556e33 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -12,11 +12,29 @@ Fight Caves does not have its own Docker image or Python/CUDA dependency stack. For play and replay, start PufferTank with the display forwarding described by Puffer; a headless container can train, but cannot show an interactive window. +> **Important: a very laggy viewer may be rendering on the CPU, even when a GPU is available.** +> PufferTank 4.0 forces the Mesa GLX driver in its shell startup. On some systems +> this selects software rendering (`llvmpipe`) instead of the available GPU. +> **Before play or replay, run the command below in each new container shell.** +> Removing this override is the first fix to try and resolved the problem in +> our PufferTank test. No rebuild or extra package installation is required. + +```bash +unset __GLX_VENDOR_LIBRARY_NAME +``` + +This restores automatic graphics-driver selection; it does not force NVIDIA or +any particular GPU. Mesa can also render on GPUs, so it is not inherently a CPU +renderer. The command cannot fix missing GPU drivers or container graphics +access. In the playable viewer's startup output, check the `Renderer` line: +`llvmpipe` means CPU rendering. Working CUDA training alone does not prove that +the viewer is using the GPU. + Inside PufferTank's interactive shell, use its already-activated Python -environment. While this PR is under review, clone its source branch: +environment. Clone the fork's merged Fight Caves branch: ```bash -git clone --branch fight-caves-puffertank-4.0 https://github.com/jordanbailey00/PufferLib.git PufferLib-fight-caves +git clone --branch 4.0.4 https://github.com/jordanbailey00/PufferLib.git PufferLib-fight-caves cd PufferLib-fight-caves uv pip install --no-deps -e . ``` From eb855a8d3d26c3a42f72de99cd4883545b3af32c Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:12:48 -0400 Subject: [PATCH 11/14] Prepare current Fight Caves integration for 4.0.5 practice PR --- ocean/fight_caves/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index c813556e33..f170b39fae 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -31,10 +31,12 @@ access. In the playable viewer's startup output, check the `Renderer` line: the viewer is using the GPU. Inside PufferTank's interactive shell, use its already-activated Python -environment. Clone the fork's merged Fight Caves branch: +environment. Until this PR is merged, clone its source branch below. After +merging, use `--branch 4.0.5` instead; the base branch has no Fight Caves code +before the merge. ```bash -git clone --branch 4.0.4 https://github.com/jordanbailey00/PufferLib.git PufferLib-fight-caves +git clone --branch fight-caves-4.0.5 https://github.com/jordanbailey00/PufferLib.git PufferLib-fight-caves cd PufferLib-fight-caves uv pip install --no-deps -e . ``` From 557f611165b6e3c10e9fef70433a86ad515cdc91 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:41:52 -0400 Subject: [PATCH 12/14] Factor Fight Caves wave spawn rotation tables Replace the expanded table with 15 shared spawn patterns and a per-wave pattern map. Preserve slot order and unused/invalid lookup behavior. Remove 908 production lines and add exhaustive wave lookup regression fixtures. Validation: 29 Python tests, core and equipment tests, ASan/UBSan, compiled pre/post parity across all waves and rotations, and native viewer/CUDA builds passed. The 100M-budget run 418mhvg9 exactly matched baseline 20rjpbhn across 6,580 history metrics, 70 final metrics, and all four saved checkpoints. --- ocean/fight_caves/simulation.h | 1208 ++++---------------------------- tests/fight_caves.c | 47 ++ 2 files changed, 197 insertions(+), 1058 deletions(-) diff --git a/ocean/fight_caves/simulation.h b/ocean/fight_caves/simulation.h index a5009d0c22..0e95bfc60b 100644 --- a/ocean/fight_caves/simulation.h +++ b/ocean/fight_caves/simulation.h @@ -7349,1080 +7349,169 @@ static const FcWaveEntry WAVE_TABLE[FC_NUM_WAVES] = { /* Wave 63 */ { {NPC_TZTOK_JAD, 0, 0, 0, 0, 0}, 1 }, }; -static const int WAVE_ROTATIONS[FC_NUM_WAVES][FC_NUM_ROTATIONS][FC_MAX_SPAWNS_PER_WAVE] = { - { /* Wave 1 */ - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, +/* Each pattern gives one NPC slot's spawn direction across all 15 rotations. + * Wave slots reuse these 15 patterns. Pattern IDs index the rows below; + * they are independent of rotation IDs and preserve the original slot order. */ +static const uint8_t SPAWN_PATTERNS[15][FC_NUM_ROTATIONS] = { + { /* Pattern 0 */ + SPAWN_CENTER, SPAWN_CENTER, SPAWN_CENTER, SPAWN_NORTH_WEST, + SPAWN_NORTH_WEST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH, + SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_EAST, + SPAWN_SOUTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_WEST, }, - { /* Wave 2 */ - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, + { /* Pattern 1 */ + SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, + SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_NORTH_WEST, + SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_WEST, + SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, }, - { /* Wave 3 */ - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + { /* Pattern 2 */ + SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, + SPAWN_SOUTH_EAST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_CENTER, + SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, + SPAWN_NORTH_WEST, SPAWN_NORTH_WEST, SPAWN_SOUTH, }, - { /* Wave 4 */ - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + { /* Pattern 3 */ + SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, + SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, + SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, + SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, }, - { /* Wave 5 */ - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, + { /* Pattern 4 */ + SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, + SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH, SPAWN_SOUTH_EAST, + SPAWN_CENTER, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_NORTH_WEST, + SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, }, - { /* Wave 6 */ - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, + { /* Pattern 5 */ + SPAWN_NORTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_CENTER, + SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH, + SPAWN_SOUTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, + SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_EAST, }, - { /* Wave 7 */ - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, + { /* Pattern 6 */ + SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_NORTH_WEST, + SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, + SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH, SPAWN_SOUTH_EAST, + SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_WEST, }, - { /* Wave 8 */ - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, + { /* Pattern 7 */ + SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, + SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH, + SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_CENTER, SPAWN_CENTER, + SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_NORTH_WEST, }, - { /* Wave 9 */ - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, + { /* Pattern 8 */ + SPAWN_SOUTH_EAST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, + SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, + SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, + SPAWN_CENTER, SPAWN_CENTER, SPAWN_SOUTH, }, - { /* Wave 10 */ - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, + { /* Pattern 9 */ + SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, + SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_EAST, + SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, + SPAWN_SOUTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, }, - { /* Wave 11 */ - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, + { /* Pattern 10 */ + SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_NORTH_WEST, + SPAWN_CENTER, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, + SPAWN_SOUTH_EAST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_WEST, + SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH, }, - { /* Wave 12 */ - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, + { /* Pattern 11 */ + SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_EAST, + SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, + SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, + SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, }, - { /* Wave 13 */ - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, + { /* Pattern 12 */ + SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, + SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_CENTER, + SPAWN_SOUTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, + SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, }, - { /* Wave 14 */ - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, + { /* Pattern 13 */ + SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH, SPAWN_SOUTH_WEST, + SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, + SPAWN_NORTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, + SPAWN_SOUTH, SPAWN_CENTER, SPAWN_CENTER, }, - { /* Wave 15 */ - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - }, - { /* Wave 16 */ - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - }, - { /* Wave 17 */ - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - }, - { /* Wave 18 */ - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - }, - { /* Wave 19 */ - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - }, - { /* Wave 20 */ - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - }, - { /* Wave 21 */ - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - }, - { /* Wave 22 */ - {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - }, - { /* Wave 23 */ - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - }, - { /* Wave 24 */ - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - }, - { /* Wave 25 */ - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, - }, - { /* Wave 26 */ - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - }, - { /* Wave 27 */ - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - }, - { /* Wave 28 */ - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - }, - { /* Wave 29 */ - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - }, - { /* Wave 30 */ - {SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - }, - { /* Wave 31 */ - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - }, - { /* Wave 32 */ - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - }, - { /* Wave 33 */ - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - }, - { /* Wave 34 */ - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - }, - { /* Wave 35 */ - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - }, - { /* Wave 36 */ - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - }, - { /* Wave 37 */ - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - }, - { /* Wave 38 */ - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - }, - { /* Wave 39 */ - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - }, - { /* Wave 40 */ - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - }, - { /* Wave 41 */ - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, - }, - { /* Wave 42 */ - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - }, - { /* Wave 43 */ - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, - }, - { /* Wave 44 */ - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - }, - { /* Wave 45 */ - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - }, - { /* Wave 46 */ - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - }, - { /* Wave 47 */ - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - }, - { /* Wave 48 */ - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - }, - { /* Wave 49 */ - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - }, - { /* Wave 50 */ - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - }, - { /* Wave 51 */ - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - }, - { /* Wave 52 */ - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - }, - { /* Wave 53 */ - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - }, - { /* Wave 54 */ - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - }, - { /* Wave 55 */ - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, - }, - { /* Wave 56 */ - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - }, - { /* Wave 57 */ - {SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, - {SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, - {SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - }, - { /* Wave 58 */ - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST}, - }, - { /* Wave 59 */ - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_CENTER, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0}, - }, - { /* Wave 60 */ - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, 0, 0}, - }, - { /* Wave 61 */ - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, SPAWN_SOUTH_WEST, 0, 0, 0}, - }, - { /* Wave 62 */ - {SPAWN_NORTH_WEST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_CENTER, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, SPAWN_SOUTH, 0, 0, 0, 0}, - {SPAWN_CENTER, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH, SPAWN_NORTH_WEST, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, SPAWN_SOUTH, 0, 0, 0, 0}, - }, - { /* Wave 63 */ - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_EAST, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_CENTER, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_NORTH_WEST, 0, 0, 0, 0, 0}, - {SPAWN_SOUTH, 0, 0, 0, 0, 0}, + { /* Pattern 14 */ + SPAWN_SOUTH_WEST, SPAWN_NORTH_WEST, SPAWN_NORTH_WEST, SPAWN_SOUTH, + SPAWN_SOUTH, SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH_EAST, + SPAWN_CENTER, SPAWN_SOUTH_WEST, SPAWN_SOUTH, SPAWN_CENTER, + SPAWN_SOUTH_EAST, SPAWN_SOUTH_EAST, SPAWN_NORTH_WEST, }, }; +/* One pattern ID per active NPC slot; unused slots are zero-initialized. */ +static const uint8_t WAVE_SPAWN_PATTERNS[FC_NUM_WAVES][FC_MAX_SPAWNS_PER_WAVE] = { + /* Wave 1 */ {0}, + /* Wave 2 */ {1, 2}, + /* Wave 3 */ {2}, + /* Wave 4 */ {3, 4}, + /* Wave 5 */ {5, 6, 3}, + /* Wave 6 */ {5, 6}, + /* Wave 7 */ {6}, + /* Wave 8 */ {7, 8}, + /* Wave 9 */ {9, 10, 7}, + /* Wave 10 */ {10, 9}, + /* Wave 11 */ {11, 12, 10}, + /* Wave 12 */ {13, 14, 11, 12}, + /* Wave 13 */ {13, 14, 11}, + /* Wave 14 */ {13, 14}, + /* Wave 15 */ {14}, + /* Wave 16 */ {1, 0}, + /* Wave 17 */ {2, 4, 1}, + /* Wave 18 */ {4, 2}, + /* Wave 19 */ {5, 3, 4}, + /* Wave 20 */ {6, 8, 5, 3}, + /* Wave 21 */ {6, 8, 5}, + /* Wave 22 */ {8, 6}, + /* Wave 23 */ {9, 7, 8}, + /* Wave 24 */ {10, 12, 9, 7}, + /* Wave 25 */ {12, 10, 9}, + /* Wave 26 */ {13, 11, 12, 10}, + /* Wave 27 */ {14, 0, 13, 11, 12}, + /* Wave 28 */ {14, 0, 13, 11}, + /* Wave 29 */ {14, 0, 13}, + /* Wave 30 */ {14, 0}, + /* Wave 31 */ {0}, + /* Wave 32 */ {2, 1}, + /* Wave 33 */ {4, 3, 2}, + /* Wave 34 */ {3, 4}, + /* Wave 35 */ {6, 5, 3}, + /* Wave 36 */ {8, 7, 6, 5}, + /* Wave 37 */ {8, 7, 6}, + /* Wave 38 */ {7, 8}, + /* Wave 39 */ {10, 9, 7}, + /* Wave 40 */ {12, 11, 10, 9}, + /* Wave 41 */ {11, 12, 10}, + /* Wave 42 */ {14, 13, 11, 12}, + /* Wave 43 */ {0, 1, 14, 13, 11}, + /* Wave 44 */ {0, 1, 14, 13}, + /* Wave 45 */ {0, 1, 14}, + /* Wave 46 */ {1, 0}, + /* Wave 47 */ {4, 2, 1}, + /* Wave 48 */ {3, 5, 4, 2}, + /* Wave 49 */ {5, 3, 4}, + /* Wave 50 */ {8, 6, 5, 3}, + /* Wave 51 */ {7, 9, 8, 6, 5}, + /* Wave 52 */ {7, 9, 8, 6}, + /* Wave 53 */ {9, 7, 8}, + /* Wave 54 */ {12, 10, 9, 7}, + /* Wave 55 */ {11, 13, 12, 10, 9}, + /* Wave 56 */ {13, 11, 12, 10}, + /* Wave 57 */ {0, 14, 13, 11, 12}, + /* Wave 58 */ {1, 2, 0, 14, 13, 11}, + /* Wave 59 */ {1, 2, 0, 14, 13}, + /* Wave 60 */ {1, 2, 0, 14}, + /* Wave 61 */ {1, 2, 0}, + /* Wave 62 */ {1, 2}, + /* Wave 63 */ {2}, +}; + /* ======================================================================== */ /* Spawn position from direction */ /* ======================================================================== */ @@ -8462,7 +7551,10 @@ static int fc_wave_spawn_dir(int wave_num, int rotation, int npc_index) { if (wave_num < 1 || wave_num > FC_NUM_WAVES) return SPAWN_CENTER; if (rotation < 0 || rotation >= FC_NUM_ROTATIONS) return SPAWN_CENTER; if (npc_index < 0 || npc_index >= FC_MAX_SPAWNS_PER_WAVE) return SPAWN_CENTER; - return WAVE_ROTATIONS[wave_num - 1][rotation][npc_index]; + /* The expanded table returned zero (SPAWN_SOUTH) for unused slots. */ + if (npc_index >= WAVE_TABLE[wave_num - 1].num_spawns) return SPAWN_SOUTH; + int pattern = WAVE_SPAWN_PATTERNS[wave_num - 1][npc_index]; + return SPAWN_PATTERNS[pattern][rotation]; } /* ======================================================================== */ diff --git a/tests/fight_caves.c b/tests/fight_caves.c index 02fbb08ab0..dcc4a26dbf 100644 --- a/tests/fight_caves.c +++ b/tests/fight_caves.c @@ -13,6 +13,52 @@ static void fail(const char* message) { exit(EXIT_FAILURE); } +static void wave_rotation_test(void) { + /* FNV-1a fingerprints captured from the expanded table before factoring. + * Each wave covers all 15 rotations and all six slots, including padding. */ + static const uint32_t expected[FC_NUM_WAVES] = { + 0x94a6ae61u, 0xb43e53bdu, 0x5ae991d9u, 0xb87f83cdu, + 0x7ae7bc55u, 0xc2eb4881u, 0x6f091049u, 0x2c80a9b9u, + 0xff5bb385u, 0x0460e561u, 0x3fd38821u, 0x70d9d565u, + 0x3033fbb1u, 0x39ff4ee5u, 0xfb88abe1u, 0x42db8009u, + 0xcd6d9305u, 0x75d5d409u, 0xecd94691u, 0xe481f925u, + 0x047e78e1u, 0xbcec175du, 0x28ee1f11u, 0xe225e405u, + 0xee2ea345u, 0x6e841145u, 0x12b64e1du, 0xf7a76349u, + 0xf41e3f01u, 0xf9d9e13du, 0x94a6ae61u, 0x7b92c141u, + 0xe6d41269u, 0xb87f83cdu, 0x4efe59e1u, 0xd0ef2aa9u, + 0x7cf6df61u, 0x2c80a9b9u, 0xf9ba9da9u, 0xffa136e9u, + 0x3fd38821u, 0x179231cdu, 0x45be2b59u, 0x1c645eddu, + 0x1ea54635u, 0x42db8009u, 0x538afaf1u, 0x8c6cad2du, + 0xecd94691u, 0x0cf73279u, 0xa339fc69u, 0xfeddd769u, + 0x28ee1f11u, 0x67f6d72du, 0x1b3ebe39u, 0x6e841145u, + 0xbc40ffe9u, 0x66ac8f75u, 0xf9552f4du, 0x3de75731u, + 0x4a20ae1du, 0xb43e53bdu, 0x5ae991d9u, + }; + for (int wave = 1; wave <= FC_NUM_WAVES; wave++) { + uint32_t hash = 2166136261u; + for (int rotation = 0; rotation < FC_NUM_ROTATIONS; rotation++) { + for (int slot = 0; slot < FC_MAX_SPAWNS_PER_WAVE; slot++) { + int direction = fc_wave_spawn_dir(wave, rotation, slot); + if (direction < SPAWN_SOUTH || direction > SPAWN_CENTER) + fail("wave spawn direction is out of range"); + hash = (hash ^ (uint32_t)direction) * 16777619u; + } + } + if (hash != expected[wave - 1]) { + fprintf(stderr, "wave_rotation_test: wave %d changed\n", wave); + fail("wave rotation fixture mismatch"); + } + } + if (fc_wave_spawn_dir(0, 0, 0) != SPAWN_CENTER || + fc_wave_spawn_dir(FC_NUM_WAVES + 1, 0, 0) != SPAWN_CENTER || + fc_wave_spawn_dir(1, -1, 0) != SPAWN_CENTER || + fc_wave_spawn_dir(1, FC_NUM_ROTATIONS, 0) != SPAWN_CENTER || + fc_wave_spawn_dir(1, 0, -1) != SPAWN_CENTER || + fc_wave_spawn_dir(1, 0, FC_MAX_SPAWNS_PER_WAVE) != SPAWN_CENTER) + fail("invalid wave lookup fallback changed"); + puts("wave_rotation_test: all waves, rotations, slots and invalid inputs passed"); +} + static void check_observation(const FcState* state) { float obs[FC_TOTAL_OBS]; float mask[FC_ACTION_MASK_SIZE]; @@ -605,6 +651,7 @@ static int equipment_appearance_test(void) { #endif int main(void) { + wave_rotation_test(); if (core_contract_test() || equipment_test()) return 1; #ifdef FC_VIEWER_TEST if (context_menu_test() || click_feedback_test() || model_picking_test() || From a42881f0e06236d6a6abca594f9214b2ce1b1ca3 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:55:52 -0400 Subject: [PATCH 13/14] Derive Fight Caves loadouts from item definitions Remove duplicated preset combat totals, equipment labels, icon overrides, model-item lists, and unused compatibility macros. Keep independent combat regression fixtures for all nine presets. Validation: core tests, 29 Python tests, native viewer build, and 16,065 pre/post simulation snapshots passed. 100M run xm3d45fk exactly matched 20rjpbhn across 6,580 history values, 70 final metrics, and all four checkpoint hashes. --- ocean/fight_caves/simulation.h | 418 ++++++++------------------------- ocean/fight_caves/viewer.c | 5 +- tests/fight_caves.c | 99 ++++++-- 3 files changed, 184 insertions(+), 338 deletions(-) diff --git a/ocean/fight_caves/simulation.h b/ocean/fight_caves/simulation.h index 0e95bfc60b..65aa0ae328 100644 --- a/ocean/fight_caves/simulation.h +++ b/ocean/fight_caves/simulation.h @@ -7,8 +7,7 @@ /* * Player skill, equipment, and consumable configuration shared by the core, - * training adapter, viewer, and asset tooling. The immutable table itself is - * defined once in fc_loadouts.c. + * training adapter and viewer. Combat bonuses are derived from item definitions. */ typedef enum { @@ -29,7 +28,6 @@ typedef enum { #endif #define FC_LOADOUT_EQUIP_MAX 12 -#define FC_LOADOUT_MODEL_ITEM_MAX 12 #define FC_PLAYER_MODEL_BASE 0xFC000000u typedef enum { @@ -46,13 +44,6 @@ typedef enum { FC_EQUIP_SLOT_RING = 12, } FcEquipmentSlot; -typedef struct { - int slot; - uint32_t item_id; - uint32_t icon_item_id; - const char* label; -} FcLoadoutEquipmentItem; - typedef enum { FC_CRYSTAL_PIECE_NONE = 0, FC_CRYSTAL_PIECE_HELM = 1 << 0, @@ -73,25 +64,14 @@ typedef enum { typedef struct { const char* name; - const char* weapon_name; uint32_t player_model_id; int combat_style_profile; int max_hp, max_prayer; int attack_lvl, strength_lvl, defence_lvl; int ranged_lvl, prayer_lvl, magic_lvl; - int weapon_kind; - int weapon_uses_ammo; - int crystal_piece_mask; - int weapon_speed; - int weapon_range; - int ranged_atk, ranged_str; - int def_stab, def_slash, def_crush, def_magic, def_ranged; - int prayer_bonus; int ammo; int equipment_count; - FcLoadoutEquipmentItem equipment[FC_LOADOUT_EQUIP_MAX]; - int model_item_count; - int model_item_ids[FC_LOADOUT_MODEL_ITEM_MAX]; + int equipment[FC_LOADOUT_EQUIP_MAX]; } FcLoadout; typedef enum { @@ -105,23 +85,6 @@ typedef enum { extern const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS]; -#define FC_PLAYER_MAX_HP (FC_LOADOUTS[FC_ACTIVE_LOADOUT].max_hp) -#define FC_PLAYER_MAX_PRAYER (FC_LOADOUTS[FC_ACTIVE_LOADOUT].max_prayer) -#define FC_PLAYER_DEFENCE_LVL (FC_LOADOUTS[FC_ACTIVE_LOADOUT].defence_lvl) -#define FC_PLAYER_RANGED_LVL (FC_LOADOUTS[FC_ACTIVE_LOADOUT].ranged_lvl) -#define FC_PLAYER_PRAYER_LVL (FC_LOADOUTS[FC_ACTIVE_LOADOUT].prayer_lvl) -#define FC_PLAYER_MAGIC_LVL (FC_LOADOUTS[FC_ACTIVE_LOADOUT].magic_lvl) -#define FC_PLAYER_WEAPON_USES_AMMO \ - (FC_LOADOUTS[FC_ACTIVE_LOADOUT].weapon_uses_ammo) -#define FC_PLAYER_WEAPON_SPEED (FC_LOADOUTS[FC_ACTIVE_LOADOUT].weapon_speed) -#define FC_PLAYER_WEAPON_RANGE (FC_LOADOUTS[FC_ACTIVE_LOADOUT].weapon_range) -#define FC_EQUIP_RANGED_ATK (FC_LOADOUTS[FC_ACTIVE_LOADOUT].ranged_atk) -#define FC_EQUIP_RANGED_STR (FC_LOADOUTS[FC_ACTIVE_LOADOUT].ranged_str) -#define FC_EQUIP_DEF_CRUSH (FC_LOADOUTS[FC_ACTIVE_LOADOUT].def_crush) -#define FC_EQUIP_DEF_MAGIC (FC_LOADOUTS[FC_ACTIVE_LOADOUT].def_magic) -#define FC_EQUIP_DEF_RANGED (FC_LOADOUTS[FC_ACTIVE_LOADOUT].def_ranged) - - /* Types */ #include @@ -2973,11 +2936,10 @@ void fc_set_initial_supplies(FcState *state, int sharks, int prayer_doses) { void fc_items_init(FcPlayer *p, const FcLoadout *loadout) { memset(p->equipment, 0, sizeof(p->equipment)); for (int i = 0; i < loadout->equipment_count; i++) { - const FcLoadoutEquipmentItem *item = &loadout->equipment[i]; - if (item->item_id == 810) continue; /* darts are loaded in the blowpipe */ + const FcItemDef *item = fc_item_definition(loadout->equipment[i]); p->equipment[item->slot] = (FcItemStack){ - (int)item->item_id, item->slot == FC_EQUIP_SLOT_AMMO ? loadout->ammo : 1, - item->item_id == 12926 ? loadout->ammo : 0 + item->id, item->slot == FC_EQUIP_SLOT_AMMO ? loadout->ammo : 1, + item->id == 12926 ? loadout->ammo : 0 }; } recalculate_equipment(p); @@ -3142,47 +3104,10 @@ void fc_items_spend_ammo(FcPlayer *p) { /* Loadouts */ -/* - * LOADOUT A: Mid-level — Black D'hide + Rune Crossbow - * - * Slot Item Rng Atk Rng Str Stab Slash Crush Magic Ranged Prayer - * ---- ---- ------- ------- ---- ----- ----- ----- ------ ------ - * Head Coif 2 0 4 6 8 4 4 0 - * Weapon Rune Crossbow 90 0 0 0 0 0 0 0 - * Body Black D'hide Body 30 0 55 47 60 50 55 0 - * Legs Black D'hide Chaps 17 0 31 25 33 28 31 0 - * Hands Black D'hide Vambraces 11 0 6 5 7 8 0 0 - * Feet Snakeskin Boots 3 0 1 1 2 1 0 0 - * Ammo Adamant Bolts 0 100 0 0 0 0 0 0 - * ---- ---- ---- ---- ---- ---- ---- ---- - * TOTAL 153 100 97 84 110 91 90 0 - */ - -/* - * LOADOUT B: End-game — Masori (f) + Twisted Bow - * - * Slot Item Rng Atk Rng Str Stab Slash Crush Magic Ranged Prayer - * ---- ---- ------- ------- ---- ----- ----- ----- ------ ------ - * Head Masori mask (f) 12 2 8 10 12 12 9 1 - * Cape Ava's assembler 8 2 0 0 0 0 0 0 - * Neck Necklace of anguish 15 5 0 0 0 0 0 2 - * Weapon Twisted bow 70 20 0 0 0 0 0 0 - * Body Masori body (f) 43 4 59 52 64 74 60 1 - * Legs Masori chaps (f) 27 2 35 30 39 46 37 1 - * Hands Zaryte vambraces 18 2 8 8 8 5 8 1 - * Feet Pegasian boots 12 0 5 5 5 5 5 0 - * Ring Venator ring 10 2 0 0 0 0 0 0 - * Ammo Dragon arrows 0 60 0 0 0 0 0 0 - * Shield (none) 0 0 0 0 0 0 0 0 - * ---- ---- ---- ---- ---- ---- ---- ---- - * TOTAL 215 99 116 106 129 150 121 6 - */ - const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { /* [FC_LOADOUT_BLACK_DHIDE_RCB] Mid-level — Black D'hide + Rune Crossbow */ { .name = "A: Black D'hide + RCB", - .weapon_name = "Rune crossbow", .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_BLACK_DHIDE_RCB, .combat_style_profile = 9, .max_hp = 700, /* 70 HP */ @@ -3193,36 +3118,21 @@ const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { .ranged_lvl = 70, .prayer_lvl = 43, .magic_lvl = 1, - .weapon_kind = FC_WEAPON_GENERIC_RANGED, - .weapon_uses_ammo = 1, - .weapon_speed = 5, - .weapon_range = 7, - .ranged_atk = 153, /* 2+90+30+17+11+3 */ - .ranged_str = 100, /* adamant bolts */ - .def_stab = 97, /* 4+0+55+31+6+1 */ - .def_slash = 84, /* 6+0+47+25+5+1 */ - .def_crush = 110, /* 8+0+60+33+7+2 */ - .def_magic = 91, /* 4+0+50+28+8+1 */ - .def_ranged = 90, /* 4+0+55+31+0+0 */ - .prayer_bonus = 0, .ammo = 50000, .equipment_count = 7, .equipment = { - {FC_EQUIP_SLOT_HEAD, 1169, 0, "Coif"}, - {FC_EQUIP_SLOT_WEAPON, 9185, 0, "Rune crossbow"}, - {FC_EQUIP_SLOT_BODY, 2503, 0, "Black d'hide body"}, - {FC_EQUIP_SLOT_AMMO, 9143, 0, "Adamant bolts"}, - {FC_EQUIP_SLOT_LEGS, 2497, 0, "Black d'hide chaps"}, - {FC_EQUIP_SLOT_HANDS, 2491, 0, "Black d'hide vambraces"}, - {FC_EQUIP_SLOT_FEET, 6328, 0, "Snakeskin boots"}, + 1169, + 9185, + 2503, + 9143, + 2497, + 2491, + 6328, }, - .model_item_count = 6, - .model_item_ids = {1169, 9185, 2503, 2497, 2491, 6328}, }, /* [FC_LOADOUT_SOTA_TBOW] End-game — Masori (f) + Twisted Bow */ { .name = "B: Masori (f) + TBow", - .weapon_name = "Twisted bow", .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_SOTA_TBOW, .combat_style_profile = 25, .max_hp = 990, /* 99 HP */ @@ -3233,39 +3143,24 @@ const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { .ranged_lvl = 99, .prayer_lvl = 99, .magic_lvl = 1, - .weapon_kind = FC_WEAPON_TWISTED_BOW, - .weapon_uses_ammo = 1, - .weapon_speed = 5, /* rapid */ - .weapon_range = 10, - .ranged_atk = 215, /* 12+8+15+70+43+27+18+12+10 */ - .ranged_str = 99, /* 2+2+5+20+4+2+2+0+2+60(dragon arrows) */ - .def_stab = 116, /* 8+1+0+0+59+35+8+5+0+0 */ - .def_slash = 106, /* 10+1+0+0+52+30+8+5+0+0 */ - .def_crush = 129, /* 12+1+0+0+64+39+8+5+0+0 */ - .def_magic = 150, /* 12+8+0+0+74+46+5+5+0+0 */ - .def_ranged = 121, /* 9+2+0+0+60+37+8+5+0+0 */ - .prayer_bonus = 6, /* 1+0+2+0+1+1+1+0+0+0 */ .ammo = 50000, .equipment_count = 10, .equipment = { - {FC_EQUIP_SLOT_HEAD, 27235, 0, "Masori mask (f)"}, - {FC_EQUIP_SLOT_CAPE, 22109, 0, "Ava's assembler"}, - {FC_EQUIP_SLOT_NECK, 19547, 0, "Necklace of anguish"}, - {FC_EQUIP_SLOT_WEAPON, 20997, 0, "Twisted bow"}, - {FC_EQUIP_SLOT_BODY, 27238, 0, "Masori body (f)"}, - {FC_EQUIP_SLOT_AMMO, 11212, 0, "Dragon arrows"}, - {FC_EQUIP_SLOT_LEGS, 27241, 0, "Masori chaps (f)"}, - {FC_EQUIP_SLOT_HANDS, 26235, 0, "Zaryte vambraces"}, - {FC_EQUIP_SLOT_FEET, 13237, 0, "Pegasian boots"}, - {FC_EQUIP_SLOT_RING, 28310, 0, "Venator ring"}, + 27235, + 22109, + 19547, + 20997, + 27238, + 11212, + 27241, + 26235, + 13237, + 28310, }, - .model_item_count = 8, - .model_item_ids = {27235, 22109, 19547, 20997, 27238, 27241, 26235, 13237}, }, /* [FC_LOADOUT_LOW_DEF_RCB] Low-defence — Robin Hood + Red D'hide + RCB */ { .name = "C: 1 Def Robin + RCB", - .weapon_name = "Rune crossbow", .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_LOW_DEF_RCB, .combat_style_profile = 9, .max_hp = 550, /* 55 HP */ @@ -3276,39 +3171,24 @@ const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { .ranged_lvl = 61, .prayer_lvl = 43, .magic_lvl = 1, - .weapon_kind = FC_WEAPON_GENERIC_RANGED, - .weapon_uses_ammo = 1, - .weapon_speed = 5, /* rapid rune crossbow */ - .weapon_range = 7, - .ranged_atk = 166, /* 8+4+10+90+15+14+7+8+10 */ - .ranged_str = 100, /* adamant bolts */ - .def_stab = 48, /* 4+0+3+0+6+28+5+2+0 */ - .def_slash = 49, /* 6+1+3+0+9+22+5+3+0 */ - .def_crush = 62, /* 8+0+3+0+12+30+5+4+0 */ - .def_magic = 42, /* 4+4+3+0+6+20+3+2+0 */ - .def_ranged = 46, /* 4+0+3+0+6+28+5+0+0 */ - .prayer_bonus = 8, /* glory + book of law */ .ammo = 50000, .equipment_count = 10, .equipment = { - {FC_EQUIP_SLOT_HEAD, 2581, 0, "Robin hood hat"}, - {FC_EQUIP_SLOT_CAPE, 10499, 0, "Ava's accumulator"}, - {FC_EQUIP_SLOT_NECK, 1704, 0, "Amulet of glory"}, - {FC_EQUIP_SLOT_WEAPON, 9185, 0, "Rune crossbow"}, - {FC_EQUIP_SLOT_BODY, 12596, 0, "Rangers' tunic"}, - {FC_EQUIP_SLOT_SHIELD, 12610, 0, "Book of law"}, - {FC_EQUIP_SLOT_AMMO, 9143, 0, "Adamant bolts"}, - {FC_EQUIP_SLOT_LEGS, 2495, 0, "Red d'hide chaps"}, - {FC_EQUIP_SLOT_HANDS, 11126, 0, "Combat bracelet"}, - {FC_EQUIP_SLOT_FEET, 2577, 0, "Ranger boots"}, + 2581, + 10499, + 1704, + 9185, + 12596, + 12610, + 9143, + 2495, + 11126, + 2577, }, - .model_item_count = 9, - .model_item_ids = {2581, 10499, 1704, 9185, 12596, 12610, 2495, 11126, 2577}, }, /* [FC_LOADOUT_RCB_PURE] Low-level 1-def Fight Caves rune crossbow pure */ { .name = "RCB Pure", - .weapon_name = "Rune crossbow", .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_RCB_PURE, .combat_style_profile = 9, .max_hp = 550, @@ -3319,39 +3199,24 @@ const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { .ranged_lvl = 61, .prayer_lvl = 43, .magic_lvl = 1, - .weapon_kind = FC_WEAPON_GENERIC_RANGED, - .weapon_uses_ammo = 1, - .weapon_speed = 5, - .weapon_range = 7, - .ranged_atk = 166, - .ranged_str = 100, - .def_stab = 48, - .def_slash = 49, - .def_crush = 62, - .def_magic = 42, - .def_ranged = 46, - .prayer_bonus = 8, .ammo = 50000, .equipment_count = 10, .equipment = { - {FC_EQUIP_SLOT_HEAD, 2581, 0, "Robin hood hat"}, - {FC_EQUIP_SLOT_CAPE, 10499, 0, "Ava's accumulator"}, - {FC_EQUIP_SLOT_NECK, 1704, 0, "Amulet of glory"}, - {FC_EQUIP_SLOT_WEAPON, 9185, 0, "Rune crossbow"}, - {FC_EQUIP_SLOT_BODY, 12596, 0, "Rangers' tunic"}, - {FC_EQUIP_SLOT_SHIELD, 12610, 0, "Book of law"}, - {FC_EQUIP_SLOT_AMMO, 9143, 0, "Adamant bolts"}, - {FC_EQUIP_SLOT_LEGS, 2495, 0, "Red d'hide chaps"}, - {FC_EQUIP_SLOT_HANDS, 11126, 0, "Combat bracelet"}, - {FC_EQUIP_SLOT_FEET, 2577, 0, "Ranger boots"}, + 2581, + 10499, + 1704, + 9185, + 12596, + 12610, + 9143, + 2495, + 11126, + 2577, }, - .model_item_count = 9, - .model_item_ids = {2581, 10499, 1704, 9185, 12596, 12610, 2495, 11126, 2577}, }, /* [FC_LOADOUT_MSBI_PURE] Faster but weaker 1-def magic shortbow pure */ { .name = "MSB(i) Pure", - .weapon_name = "Magic shortbow (i)", .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_MSBI_PURE, .combat_style_profile = 25, .max_hp = 600, @@ -3362,38 +3227,23 @@ const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { .ranged_lvl = 70, .prayer_lvl = 43, .magic_lvl = 1, - .weapon_kind = FC_WEAPON_GENERIC_RANGED, - .weapon_uses_ammo = 1, - .weapon_speed = 3, - .weapon_range = 7, - .ranged_atk = 141, - .ranged_str = 49, - .def_stab = 48, - .def_slash = 49, - .def_crush = 62, - .def_magic = 42, - .def_ranged = 46, - .prayer_bonus = 3, .ammo = 50000, .equipment_count = 9, .equipment = { - {FC_EQUIP_SLOT_HEAD, 2581, 0, "Robin hood hat"}, - {FC_EQUIP_SLOT_CAPE, 10499, 0, "Ava's accumulator"}, - {FC_EQUIP_SLOT_NECK, 1704, 0, "Amulet of glory"}, - {FC_EQUIP_SLOT_WEAPON, 12788, 0, "Magic shortbow (i)"}, - {FC_EQUIP_SLOT_AMMO, 892, 0, "Rune arrow"}, - {FC_EQUIP_SLOT_BODY, 12596, 0, "Rangers' tunic"}, - {FC_EQUIP_SLOT_LEGS, 2495, 0, "Red d'hide chaps"}, - {FC_EQUIP_SLOT_HANDS, 11126, 0, "Combat bracelet"}, - {FC_EQUIP_SLOT_FEET, 2577, 0, "Ranger boots"}, + 2581, + 10499, + 1704, + 12788, + 892, + 12596, + 2495, + 11126, + 2577, }, - .model_item_count = 8, - .model_item_ids = {2581, 10499, 1704, 12788, 12596, 2495, 11126, 2577}, }, /* [FC_LOADOUT_BLOWPIPE_PURE] Fast 1-def toxic blowpipe pure with loaded adamant darts */ { .name = "Blowpipe Pure", - .weapon_name = "Toxic blowpipe", .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_BLOWPIPE_PURE, .combat_style_profile = 23, .max_hp = 750, @@ -3404,38 +3254,22 @@ const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { .ranged_lvl = 75, .prayer_lvl = 43, .magic_lvl = 1, - .weapon_kind = FC_WEAPON_GENERIC_RANGED, - .weapon_uses_ammo = 1, - .weapon_speed = 2, - .weapon_range = 5, - .ranged_atk = 101, - .ranged_str = 42, - .def_stab = 45, - .def_slash = 46, - .def_crush = 59, - .def_magic = 39, - .def_ranged = 43, - .prayer_bonus = 2, .ammo = 50000, - .equipment_count = 9, + .equipment_count = 8, .equipment = { - {FC_EQUIP_SLOT_HEAD, 2581, 0, "Robin hood hat"}, - {FC_EQUIP_SLOT_CAPE, 10499, 0, "Ava's accumulator"}, - {FC_EQUIP_SLOT_NECK, 19547, 0, "Necklace of anguish"}, - {FC_EQUIP_SLOT_WEAPON, 12926, 0, "Toxic blowpipe"}, - {FC_EQUIP_SLOT_AMMO, 810, 0, "Adamant dart"}, - {FC_EQUIP_SLOT_BODY, 12596, 0, "Rangers' tunic"}, - {FC_EQUIP_SLOT_LEGS, 2495, 0, "Red d'hide chaps"}, - {FC_EQUIP_SLOT_HANDS, 11126, 0, "Combat bracelet"}, - {FC_EQUIP_SLOT_FEET, 2577, 0, "Ranger boots"}, + 2581, + 10499, + 19547, + 12926, + 12596, + 2495, + 11126, + 2577, }, - .model_item_count = 8, - .model_item_ids = {2581, 10499, 19547, 12926, 12596, 2495, 11126, 2577}, }, /* [FC_LOADOUT_ACB_ARMADYL] Tankier high-level Armadyl crossbow + Armadyl armour */ { .name = "ACB Armadyl", - .weapon_name = "Armadyl crossbow", .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_ACB_ARMADYL, .combat_style_profile = 9, .max_hp = 800, @@ -3446,39 +3280,24 @@ const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { .ranged_lvl = 80, .prayer_lvl = 70, .magic_lvl = 1, - .weapon_kind = FC_WEAPON_GENERIC_RANGED, - .weapon_uses_ammo = 1, - .weapon_speed = 5, - .weapon_range = 8, - .ranged_atk = 220, - .ranged_str = 129, - .def_stab = 112, - .def_slash = 100, - .def_crush = 123, - .def_magic = 139, - .def_ranged = 117, - .prayer_bonus = 11, .ammo = 50000, .equipment_count = 10, .equipment = { - {FC_EQUIP_SLOT_HEAD, 11826, 0, "Armadyl helmet"}, - {FC_EQUIP_SLOT_CAPE, 22109, 0, "Ava's assembler"}, - {FC_EQUIP_SLOT_NECK, 19547, 0, "Necklace of anguish"}, - {FC_EQUIP_SLOT_WEAPON, 11785, 0, "Armadyl crossbow"}, - {FC_EQUIP_SLOT_BODY, 11828, 0, "Armadyl chestplate"}, - {FC_EQUIP_SLOT_SHIELD, 12610, 0, "Book of law"}, - {FC_EQUIP_SLOT_AMMO, 21946, 0, "Diamond dragon bolts (e)"}, - {FC_EQUIP_SLOT_LEGS, 11830, 0, "Armadyl chainskirt"}, - {FC_EQUIP_SLOT_HANDS, 7462, 0, "Barrows gloves"}, - {FC_EQUIP_SLOT_FEET, 13237, 0, "Pegasian boots"}, + 11826, + 22109, + 19547, + 11785, + 11828, + 12610, + 21946, + 11830, + 7462, + 13237, }, - .model_item_count = 9, - .model_item_ids = {11826, 22109, 19547, 11785, 11828, 12610, 11830, 7462, 13237}, }, /* [FC_LOADOUT_BOWFA_CRYSTAL] Bowfa + crystal armour. */ { .name = "Bowfa Crystal", - .weapon_name = "Bow of faerdhinen (c)", .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_BOWFA_CRYSTAL, .combat_style_profile = 25, .max_hp = 850, @@ -3489,38 +3308,22 @@ const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { .ranged_lvl = 85, .prayer_lvl = 70, .magic_lvl = 1, - .weapon_kind = FC_WEAPON_BOW_OF_FAERDHINEN, - .weapon_uses_ammo = 0, - .crystal_piece_mask = FC_CRYSTAL_PIECE_ALL, - .weapon_speed = 4, - .weapon_range = 10, - .ranged_atk = 233, - .ranged_str = 113, - .def_stab = 102, - .def_slash = 85, - .def_crush = 110, - .def_magic = 107, - .def_ranged = 143, - .prayer_bonus = 9, .ammo = 0, .equipment_count = 8, .equipment = { - {FC_EQUIP_SLOT_HEAD, 23971, 0, "Crystal helm"}, - {FC_EQUIP_SLOT_CAPE, 22109, 0, "Ava's assembler"}, - {FC_EQUIP_SLOT_NECK, 19547, 0, "Necklace of anguish"}, - {FC_EQUIP_SLOT_WEAPON, 25867, 0, "Bow of faerdhinen (c)"}, - {FC_EQUIP_SLOT_BODY, 23975, 0, "Crystal body"}, - {FC_EQUIP_SLOT_LEGS, 23979, 0, "Crystal legs"}, - {FC_EQUIP_SLOT_HANDS, 7462, 0, "Barrows gloves"}, - {FC_EQUIP_SLOT_FEET, 13237, 0, "Pegasian boots"}, + 23971, + 22109, + 19547, + 25867, + 23975, + 23979, + 7462, + 13237, }, - .model_item_count = 8, - .model_item_ids = {23971, 22109, 19547, 25867, 23975, 23979, 7462, 13237}, }, /* [FC_LOADOUT_TBOW_MASORI] Max-ish Twisted bow + fortified Masori loadout */ { .name = "Tbow Masori", - .weapon_name = "Twisted bow", .player_model_id = FC_PLAYER_MODEL_BASE + FC_LOADOUT_TBOW_MASORI, .combat_style_profile = 25, .max_hp = 990, @@ -3531,33 +3334,19 @@ const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS] = { .ranged_lvl = 99, .prayer_lvl = 77, .magic_lvl = 1, - .weapon_kind = FC_WEAPON_TWISTED_BOW, - .weapon_uses_ammo = 1, - .weapon_speed = 5, - .weapon_range = 10, - .ranged_atk = 205, - .ranged_str = 97, - .def_stab = 116, - .def_slash = 106, - .def_crush = 129, - .def_magic = 150, - .def_ranged = 121, - .prayer_bonus = 6, .ammo = 50000, .equipment_count = 9, .equipment = { - {FC_EQUIP_SLOT_HEAD, 27235, 0, "Masori mask (f)"}, - {FC_EQUIP_SLOT_CAPE, 22109, 0, "Ava's assembler"}, - {FC_EQUIP_SLOT_NECK, 19547, 0, "Necklace of anguish"}, - {FC_EQUIP_SLOT_WEAPON, 20997, 0, "Twisted bow"}, - {FC_EQUIP_SLOT_BODY, 27238, 0, "Masori body (f)"}, - {FC_EQUIP_SLOT_AMMO, 11212, 0, "Dragon arrow"}, - {FC_EQUIP_SLOT_LEGS, 27241, 0, "Masori chaps (f)"}, - {FC_EQUIP_SLOT_HANDS, 26235, 0, "Zaryte vambraces"}, - {FC_EQUIP_SLOT_FEET, 13237, 0, "Pegasian boots"}, + 27235, + 22109, + 19547, + 20997, + 27238, + 11212, + 27241, + 26235, + 13237, }, - .model_item_count = 8, - .model_item_ids = {27235, 22109, 19547, 20997, 27238, 27241, 26235, 13237}, }, }; @@ -5780,16 +5569,18 @@ static void validate_loadout_table_or_abort(void) { loadout->attack_lvl >= 1 && loadout->strength_lvl >= 1 && loadout->defence_lvl >= 1 && loadout->ranged_lvl >= 1 && loadout->prayer_lvl >= 1 && loadout->magic_lvl >= 1 && - loadout->weapon_kind >= FC_WEAPON_GENERIC_RANGED && - loadout->weapon_kind <= FC_WEAPON_BOW_OF_FAERDHINEN && - (loadout->weapon_uses_ammo == 0 || - loadout->weapon_uses_ammo == 1) && - loadout->ammo >= 0 && - (loadout->crystal_piece_mask & ~FC_CRYSTAL_PIECE_ALL) == 0 && - loadout->equipment_count >= 0 && - loadout->equipment_count <= FC_LOADOUT_EQUIP_MAX && - loadout->model_item_count >= 0 && - loadout->model_item_count <= FC_LOADOUT_MODEL_ITEM_MAX; + loadout->ammo >= 0 && loadout->equipment_count > 0 && + loadout->equipment_count <= FC_LOADOUT_EQUIP_MAX; + int occupied[FC_EQUIPMENT_SLOTS] = {0}; + for (int i = 0; valid && i < loadout->equipment_count; i++) { + const FcItemDef *item = fc_item_definition(loadout->equipment[i]); + valid = item && item->slot >= 0 && item->slot < FC_EQUIPMENT_SLOTS; + if (valid && occupied[item->slot]++) valid = 0; + } + if (!valid) { + fprintf(stderr, "fc_init: invalid skills or equipment in loadout %d\n", loadout_id); + abort(); + } FcPlayer player = {0}; apply_loadout_combat_fields(&player, loadout); @@ -5803,13 +5594,7 @@ static void validate_loadout_table_or_abort(void) { } if (valid) continue; - fprintf(stderr, - "fc_init: invalid loadout %d (skills=%d/%d/%d/%d/%d/%d weapon=%d ammo=%d/%d crystal=%d)\n", - loadout_id, loadout->attack_lvl, loadout->strength_lvl, - loadout->defence_lvl, loadout->ranged_lvl, - loadout->prayer_lvl, loadout->magic_lvl, - loadout->weapon_kind, loadout->weapon_uses_ammo, - loadout->ammo, loadout->crystal_piece_mask); + fprintf(stderr, "fc_init: invalid combat stats in loadout %d\n", loadout_id); abort(); } } @@ -6654,7 +6439,8 @@ static void apply_player_supplies(FcState* state, int eat_action, state->ep_pots_wasted++; } player->total_potions_used++; - int restore = fc_prayer_potion_restore(FC_PLAYER_PRAYER_LVL); + int restore = fc_prayer_potion_restore( + FC_LOADOUTS[FC_ACTIVE_LOADOUT].prayer_lvl); if (restore > prayer_missing) state->ep_pots_overrestored++; player->current_prayer += restore; if (player->current_prayer > player->max_prayer) { diff --git a/ocean/fight_caves/viewer.c b/ocean/fight_caves/viewer.c index ed2b2ffda1..13f9fc4724 100644 --- a/ocean/fight_caves/viewer.c +++ b/ocean/fight_caves/viewer.c @@ -261,10 +261,7 @@ static int load_fc_ui_item_icons(ViewerState* v) { for (int li = 0; li < FC_NUM_LOADOUTS; li++) { const FcLoadout* lo = &FC_LOADOUTS[li]; for (int ei = 0; ei < lo->equipment_count; ei++) { - uint32_t icon_id = lo->equipment[ei].icon_item_id - ? lo->equipment[ei].icon_item_id - : lo->equipment[ei].item_id; - ready &= load_ui_item_icon(&v->ui, icon_id); + ready &= load_ui_item_icon(&v->ui, (uint32_t)lo->equipment[ei]); } } return ready; diff --git a/tests/fight_caves.c b/tests/fight_caves.c index dcc4a26dbf..44ca7669c8 100644 --- a/tests/fight_caves.c +++ b/tests/fight_caves.c @@ -146,26 +146,89 @@ static int item_slot(const FcPlayer *p, int id) { } static int loadout_totals(void) { - /* All existing presets, not only the compiled training preset. This is - * the real reset helper, not a duplicated test stat calculator. */ + /* Pre-cleanup preset totals, independent of the item definitions. */ + static const FcPlayer expected[FC_NUM_LOADOUTS] = { + { /* Preset 0 */ + .ranged_attack_bonus = 153, .ranged_strength_bonus = 100, .defence_stab = 97, + .defence_slash = 84, .defence_crush = 110, .defence_magic = 91, + .defence_ranged = 90, .prayer_bonus = 0, .weapon_kind = 0, + .weapon_speed = 5, .weapon_range = 7, .weapon_uses_ammo = 1, + .crystal_piece_mask = 0, .ammo_count = 50000, + }, + { /* Preset 1 */ + .ranged_attack_bonus = 215, .ranged_strength_bonus = 99, .defence_stab = 116, + .defence_slash = 106, .defence_crush = 129, .defence_magic = 150, + .defence_ranged = 121, .prayer_bonus = 6, .weapon_kind = 1, + .weapon_speed = 5, .weapon_range = 10, .weapon_uses_ammo = 1, + .crystal_piece_mask = 0, .ammo_count = 50000, + }, + { /* Preset 2 */ + .ranged_attack_bonus = 166, .ranged_strength_bonus = 100, .defence_stab = 48, + .defence_slash = 49, .defence_crush = 62, .defence_magic = 42, + .defence_ranged = 46, .prayer_bonus = 8, .weapon_kind = 0, + .weapon_speed = 5, .weapon_range = 7, .weapon_uses_ammo = 1, + .crystal_piece_mask = 0, .ammo_count = 50000, + }, + { /* Preset 3 */ + .ranged_attack_bonus = 166, .ranged_strength_bonus = 100, .defence_stab = 48, + .defence_slash = 49, .defence_crush = 62, .defence_magic = 42, + .defence_ranged = 46, .prayer_bonus = 8, .weapon_kind = 0, + .weapon_speed = 5, .weapon_range = 7, .weapon_uses_ammo = 1, + .crystal_piece_mask = 0, .ammo_count = 50000, + }, + { /* Preset 4 */ + .ranged_attack_bonus = 141, .ranged_strength_bonus = 49, .defence_stab = 48, + .defence_slash = 49, .defence_crush = 62, .defence_magic = 42, + .defence_ranged = 46, .prayer_bonus = 3, .weapon_kind = 0, + .weapon_speed = 3, .weapon_range = 7, .weapon_uses_ammo = 1, + .crystal_piece_mask = 0, .ammo_count = 50000, + }, + { /* Preset 5 */ + .ranged_attack_bonus = 101, .ranged_strength_bonus = 42, .defence_stab = 45, + .defence_slash = 46, .defence_crush = 59, .defence_magic = 39, + .defence_ranged = 43, .prayer_bonus = 2, .weapon_kind = 0, + .weapon_speed = 2, .weapon_range = 5, .weapon_uses_ammo = 1, + .crystal_piece_mask = 0, .ammo_count = 50000, + }, + { /* Preset 6 */ + .ranged_attack_bonus = 220, .ranged_strength_bonus = 129, .defence_stab = 112, + .defence_slash = 100, .defence_crush = 123, .defence_magic = 139, + .defence_ranged = 117, .prayer_bonus = 11, .weapon_kind = 0, + .weapon_speed = 5, .weapon_range = 8, .weapon_uses_ammo = 1, + .crystal_piece_mask = 0, .ammo_count = 50000, + }, + { /* Preset 7 */ + .ranged_attack_bonus = 233, .ranged_strength_bonus = 113, .defence_stab = 102, + .defence_slash = 85, .defence_crush = 110, .defence_magic = 107, + .defence_ranged = 143, .prayer_bonus = 9, .weapon_kind = 2, + .weapon_speed = 4, .weapon_range = 10, .weapon_uses_ammo = 0, + .crystal_piece_mask = 7, .ammo_count = 0, + }, + { /* Preset 8 */ + .ranged_attack_bonus = 205, .ranged_strength_bonus = 97, .defence_stab = 116, + .defence_slash = 106, .defence_crush = 129, .defence_magic = 150, + .defence_ranged = 121, .prayer_bonus = 6, .weapon_kind = 1, + .weapon_speed = 5, .weapon_range = 10, .weapon_uses_ammo = 1, + .crystal_piece_mask = 0, .ammo_count = 50000, + }, + }; for (int i = 0; i < FC_NUM_LOADOUTS; i++) { FcPlayer p = {0}; - const FcLoadout *l = &FC_LOADOUTS[i]; - fc_items_init(&p, l); - CHECK(p.ranged_attack_bonus == l->ranged_atk); - CHECK(p.ranged_strength_bonus == l->ranged_str); - CHECK(p.defence_stab == l->def_stab); - CHECK(p.defence_slash == l->def_slash); - CHECK(p.defence_crush == l->def_crush); - CHECK(p.defence_magic == l->def_magic); - CHECK(p.defence_ranged == l->def_ranged); - CHECK(p.prayer_bonus == l->prayer_bonus); - CHECK(p.weapon_kind == l->weapon_kind); - CHECK(p.weapon_speed == l->weapon_speed); - CHECK(p.weapon_range == l->weapon_range); - CHECK(p.weapon_uses_ammo == l->weapon_uses_ammo); - CHECK(p.crystal_piece_mask == l->crystal_piece_mask); - CHECK(p.ammo_count == l->ammo); + fc_items_init(&p, &FC_LOADOUTS[i]); + CHECK(p.ranged_attack_bonus == expected[i].ranged_attack_bonus); + CHECK(p.ranged_strength_bonus == expected[i].ranged_strength_bonus); + CHECK(p.defence_stab == expected[i].defence_stab); + CHECK(p.defence_slash == expected[i].defence_slash); + CHECK(p.defence_crush == expected[i].defence_crush); + CHECK(p.defence_magic == expected[i].defence_magic); + CHECK(p.defence_ranged == expected[i].defence_ranged); + CHECK(p.prayer_bonus == expected[i].prayer_bonus); + CHECK(p.weapon_kind == expected[i].weapon_kind); + CHECK(p.weapon_speed == expected[i].weapon_speed); + CHECK(p.weapon_range == expected[i].weapon_range); + CHECK(p.weapon_uses_ammo == expected[i].weapon_uses_ammo); + CHECK(p.crystal_piece_mask == expected[i].crystal_piece_mask); + CHECK(p.ammo_count == expected[i].ammo_count); } return 0; } From eaa3c776eef355c555dd6ca3ab26cfdc60c83b00 Mon Sep 17 00:00:00 2001 From: jordanbailey00 <190142445+jordanbailey00@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:15:07 -0400 Subject: [PATCH 14/14] Keep only the requested Fight Caves episode analytics Remove retired metric fields, counters, classifiers, reward-channel accumulation, and training/viewer output. Share the retained 11 metrics through the episode summary; preserve required reward and observation events. Accept compatible older checkpoint sidecars after the state hash advances to v6. Validation: full regression suite (42 Python cases plus C, CPU and graphical integration), ASan/UBSan core tests, and 945 independent pre/post wave/rotation traces passed. The 100M-budget run frh9mzyd matched baseline 20rjpbhn at all 94 logged steps: 1,786 retained metric/loss values, 19 final values, and all four checkpoint files were identical. Effective training configuration matched; only the requested metric removals were excluded from comparison. --- ocean/fight_caves/README.md | 10 + ocean/fight_caves/binding.c | 66 +--- ocean/fight_caves/fight_caves.h | 326 +---------------- ocean/fight_caves/render.h | 4 +- ocean/fight_caves/simulation.h | 600 +++----------------------------- ocean/fight_caves/tools.py | 11 +- ocean/fight_caves/viewer.c | 98 +----- tests/fight_caves.c | 73 +++- tests/test_fight_caves.py | 25 +- 9 files changed, 207 insertions(+), 1006 deletions(-) diff --git a/ocean/fight_caves/README.md b/ocean/fight_caves/README.md index f170b39fae..26954d7614 100644 --- a/ocean/fight_caves/README.md +++ b/ocean/fight_caves/README.md @@ -63,6 +63,16 @@ puffer train fight_caves --wandb --wandb-project fight-caves Ordinary training is headless; it does not create a graphical window. +Episode analytics are limited to `zero_progress_ticks`, `wave_reached`, +`wrong_prayer_hits`, `reached_wave_63`, `jad_kill_rate`, `prayer_uptime_range`, +`prayer_uptime_melee`, `prayer_uptime_magic`, `npc_healing_total`, +`jad_healing_total`, and `episode_length`. Puffer reports episode averages under +`env/`, plus its required `env/n` episode count. Prayer uptime is a fraction of +episode ticks; healing totals count effective HP restored in simulation units. +Zero-progress ticks exclude both positive progress and healing-driven negative +progress. Wrong-prayer hits require an active, incorrect protection prayer. +Generic trainer statistics such as losses and SPS are unchanged. + ## Play manually ```bash diff --git a/ocean/fight_caves/binding.c b/ocean/fight_caves/binding.c index cc1b10d539..3ca037e6a5 100644 --- a/ocean/fight_caves/binding.c +++ b/ocean/fight_caves/binding.c @@ -190,71 +190,15 @@ void my_init(Env* env, Dict* kwargs) { } void my_log(Log* log, Dict* out) { - dict_set(out, "episode_length", log->episode_length); + dict_set(out, "zero_progress_ticks", log->zero_progress_ticks); dict_set(out, "wave_reached", log->wave_reached); - dict_set(out, "npcs_slayed", log->npcs_slayed); - dict_set(out, "prayer_uptime_melee", log->prayer_uptime_melee); - dict_set(out, "prayer_uptime_range", log->prayer_uptime_range); - dict_set(out, "prayer_uptime_magic", log->prayer_uptime_magic); - dict_set(out, "correct_prayer", log->correct_prayer); dict_set(out, "wrong_prayer_hits", log->wrong_prayer_hits); - dict_set(out, "no_prayer_hits", log->no_prayer_hits); - dict_set(out, "prayer_switches", log->prayer_switches); - dict_set(out, "damage_blocked", log->damage_blocked); - dict_set(out, "dmg_taken_avg", log->dmg_taken_avg); - dict_set(out, "attack_when_ready_rate", log->attack_when_ready_rate); - dict_set(out, "tokxil_melee_ticks", log->tokxil_melee_ticks); - dict_set(out, "ketzek_melee_ticks", log->ketzek_melee_ticks); - dict_set(out, "max_wave_ticks", log->max_wave_ticks); - dict_set(out, "max_wave_ticks_wave", log->max_wave_ticks_wave); dict_set(out, "reached_wave_63", log->reached_wave_63); dict_set(out, "jad_kill_rate", log->jad_kill_rate); - dict_set(out, "player_death_rate", log->player_death_rate); - dict_set(out, "target_held_ticks", log->target_held_ticks); - dict_set(out, "no_target_ticks", log->no_target_ticks); - dict_set(out, "target_in_range_los_ticks", log->target_in_range_los_ticks); - dict_set(out, "target_out_of_range_or_los_ticks", log->target_out_of_range_or_los_ticks); - dict_set(out, "attack_cooldown_wait_ticks", log->attack_cooldown_wait_ticks); - dict_set(out, "ready_but_no_attack_ticks", log->ready_but_no_attack_ticks); - dict_set(out, "action_move_idle_ticks", log->action_move_idle_ticks); - dict_set(out, "action_move_walk_ticks", log->action_move_walk_ticks); - dict_set(out, "action_move_run_ticks", log->action_move_run_ticks); - dict_set(out, "action_attack_none_ticks", log->action_attack_none_ticks); - dict_set(out, "action_attack_target_ticks", log->action_attack_target_ticks); - dict_set(out, "action_prayer_noop_ticks", log->action_prayer_noop_ticks); - dict_set(out, "action_prayer_cmd_ticks", log->action_prayer_cmd_ticks); - dict_set(out, "no_progress_ticks", log->no_progress_ticks); - dict_set(out, "required_work_remaining", log->required_work_remaining); - dict_set(out, "required_work_start", log->required_work_start); - dict_set(out, "cave_progress", log->cave_progress); - dict_set(out, "current_wave_progress", log->current_wave_progress); - dict_set(out, "progress_delta", log->progress_delta); - dict_set(out, "progress_reward", log->progress_reward); - dict_set(out, "ticks_since_positive_progress", log->ticks_since_positive_progress); - dict_set(out, "positive_progress_ticks", log->positive_progress_ticks); - dict_set(out, "zero_progress_ticks", log->zero_progress_ticks); - dict_set(out, "negative_progress_ticks", log->negative_progress_ticks); - dict_set(out, "gross_damage_dealt", log->gross_damage_dealt); - dict_set(out, "net_required_work_removed", log->net_required_work_removed); - dict_set(out, "gross_damage_to_net_progress_ratio", log->gross_damage_to_net_progress_ratio); + dict_set(out, "prayer_uptime_range", log->prayer_uptime_range); + dict_set(out, "prayer_uptime_melee", log->prayer_uptime_melee); + dict_set(out, "prayer_uptime_magic", log->prayer_uptime_magic); dict_set(out, "npc_healing_total", log->npc_healing_total); - dict_set(out, "mejkot_healing_total", log->mejkot_healing_total); dict_set(out, "jad_healing_total", log->jad_healing_total); - dict_set(out, "preclip_reward", log->preclip_reward); - dict_set(out, "postclip_reward", log->postclip_reward); - dict_set(out, "positive_clip_count", log->positive_clip_count); - dict_set(out, "negative_clip_count", log->negative_clip_count); - - static char npc_dmg_keys[NPC_TYPE_COUNT][48]; - static int npc_keys_built = 0; - if (!npc_keys_built) { - for (int i = 1; i < NPC_TYPE_COUNT; i++) { - const char* npc_name = fc_episode_npc_metric_name(i); - snprintf(npc_dmg_keys[i], 48, "dmg_to_%s", npc_name); - } - npc_keys_built = 1; - } - for (int i = 1; i < NPC_TYPE_COUNT; i++) { - dict_set(out, npc_dmg_keys[i], log->dmg_to_npc_type[i]); - } + dict_set(out, "episode_length", log->episode_length); } diff --git a/ocean/fight_caves/fight_caves.h b/ocean/fight_caves/fight_caves.h index facd882822..bf0d4274a7 100644 --- a/ocean/fight_caves/fight_caves.h +++ b/ocean/fight_caves/fight_caves.h @@ -27,139 +27,33 @@ /* ======================================================================== */ typedef struct { - float episode_length; + float zero_progress_ticks; float wave_reached; - float npcs_slayed; - float prayer_uptime_melee; - float prayer_uptime_range; - float prayer_uptime_magic; - float correct_prayer; float wrong_prayer_hits; - float no_prayer_hits; - float prayer_switches; - float damage_blocked; - float dmg_taken_avg; - float attack_when_ready_rate; - float invalid_move; - float invalid_attack; - float invalid_prayer; - float tokxil_melee_ticks; - float ketzek_melee_ticks; - float max_wave_ticks; - float max_wave_ticks_wave; float reached_wave_63; float jad_kill_rate; - float player_death_rate; - float dmg_to_npc_type[NPC_TYPE_COUNT]; - float resolved_hits_to_npc_type[NPC_TYPE_COUNT]; - float damaging_hits_to_npc_type[NPC_TYPE_COUNT]; - float attack_cycles_to_npc_type[NPC_TYPE_COUNT]; - float target_ticks_by_npc_type[NPC_TYPE_COUNT]; - float target_held_ticks; - float no_target_ticks; - float target_in_range_los_ticks; - float target_out_of_range_or_los_ticks; - float attack_cooldown_wait_ticks; - float ready_but_no_attack_ticks; - float action_move_idle_ticks; - float action_move_walk_ticks; - float action_move_run_ticks; - float action_attack_none_ticks; - float action_attack_target_ticks; - float action_prayer_noop_ticks; - float action_prayer_cmd_ticks; - float no_progress_ticks; - float no_progress_idle_move_ticks; - float no_progress_move_cmd_ticks; - float no_progress_attack_none_ticks; - float no_progress_attack_target_ticks; - float no_progress_has_target_ticks; - float no_progress_no_target_ticks; - float no_progress_prayer_cmd_ticks; - float no_progress_invalid_action_ticks; - float required_work_remaining; - float required_work_start; - float cave_progress; - float current_wave_progress; - float progress_delta; - float progress_reward; - float ticks_since_positive_progress; - float positive_progress_ticks; - float zero_progress_ticks; - float negative_progress_ticks; - float gross_damage_dealt; - float net_required_work_removed; - float gross_damage_to_net_progress_ratio; + float prayer_uptime_range; + float prayer_uptime_melee; + float prayer_uptime_magic; float npc_healing_total; - float mejkot_healing_total; float jad_healing_total; - float preclip_reward; - float postclip_reward; - float positive_clip_count; - float negative_clip_count; - float rwd_sum[FC_CH_COUNT]; - float rwd_fires[FC_CH_COUNT]; - float n; /* must be last */ + float episode_length; + float n; /* PufferLib episode count; must be last. */ } Log; static void fc_puffer_accumulate_episode_summary( Log* log, const FcEpisodeSummary* summary) { - log->episode_length += (float)summary->episode_length; + log->zero_progress_ticks += (float)summary->zero_progress_ticks; log->wave_reached += (float)summary->wave_reached; - log->npcs_slayed += (float)summary->npcs_slayed; - log->prayer_uptime_melee += summary->prayer_uptime_melee; - log->prayer_uptime_range += summary->prayer_uptime_range; - log->prayer_uptime_magic += summary->prayer_uptime_magic; - log->correct_prayer += (float)summary->correct_prayer; log->wrong_prayer_hits += (float)summary->wrong_prayer_hits; - log->no_prayer_hits += (float)summary->no_prayer_hits; - log->prayer_switches += (float)summary->prayer_switches; - log->damage_blocked += (float)summary->damage_blocked; - log->dmg_taken_avg += (float)summary->damage_taken; - log->attack_when_ready_rate += summary->attack_when_ready_rate; - log->invalid_move += (float)summary->invalid_move; - log->invalid_attack += (float)summary->invalid_attack; - log->invalid_prayer += (float)summary->invalid_prayer; - log->tokxil_melee_ticks += (float)summary->tokxil_melee_ticks; - log->ketzek_melee_ticks += (float)summary->ketzek_melee_ticks; - log->max_wave_ticks += (float)summary->max_wave_ticks; - log->max_wave_ticks_wave += (float)summary->max_wave_ticks_wave; log->reached_wave_63 += (float)summary->reached_wave_63; - log->jad_kill_rate += (float)summary->jad_killed; - log->player_death_rate += (float)summary->player_died; - for (int i = 0; i < NPC_TYPE_COUNT; i++) { - log->dmg_to_npc_type[i] += - (float)summary->damage_to_npc_type[i]; - log->resolved_hits_to_npc_type[i] += - (float)summary->resolved_hits_to_npc_type[i]; - log->damaging_hits_to_npc_type[i] += - (float)summary->damaging_hits_to_npc_type[i]; - log->attack_cycles_to_npc_type[i] += - (float)summary->attack_cycles_to_npc_type[i]; - log->target_ticks_by_npc_type[i] += - (float)summary->target_ticks_by_npc_type[i]; - } - log->target_held_ticks += (float)summary->target_held_ticks; - log->no_target_ticks += (float)summary->no_target_ticks; - log->target_in_range_los_ticks += - (float)summary->target_in_range_los_ticks; - log->target_out_of_range_or_los_ticks += - (float)summary->target_out_of_range_or_los_ticks; - log->attack_cooldown_wait_ticks += - (float)summary->attack_cooldown_wait_ticks; - log->ready_but_no_attack_ticks += - (float)summary->ready_but_no_attack_ticks; - log->action_move_idle_ticks += (float)summary->action_move_idle_ticks; - log->action_move_walk_ticks += (float)summary->action_move_walk_ticks; - log->action_move_run_ticks += (float)summary->action_move_run_ticks; - log->action_attack_none_ticks += - (float)summary->action_attack_none_ticks; - log->action_attack_target_ticks += - (float)summary->action_attack_target_ticks; - log->action_prayer_noop_ticks += - (float)summary->action_prayer_noop_ticks; - log->action_prayer_cmd_ticks += - (float)summary->action_prayer_cmd_ticks; + log->jad_kill_rate += (float)summary->jad_kill_rate; + log->prayer_uptime_range += (float)summary->prayer_uptime_range; + log->prayer_uptime_melee += (float)summary->prayer_uptime_melee; + log->prayer_uptime_magic += (float)summary->prayer_uptime_magic; + log->npc_healing_total += (float)summary->npc_healing_total; + log->jad_healing_total += (float)summary->jad_healing_total; + log->episode_length += (float)summary->episode_length; } /* ======================================================================== */ @@ -196,36 +90,6 @@ typedef struct FightCaves { int ep_length; - /* Per-episode reward-channel analytics. Reset at c_reset, transferred to - * the per-env PufferLib Log on terminal. See FcRwdChannel enum in - * fc_reward.h for channel indices and names. */ - float ep_rwd_sum[FC_CH_COUNT]; - int ep_rwd_fires[FC_CH_COUNT]; - - /* Puffer-action no-progress diagnostics. These are adapter-level metrics - * for stall-like ticks: no movement, no attack cycle, no damage, no kill, - * no wave clear, and not just normal in-range weapon cooldown waiting. */ - float ep_no_progress_ticks; - float ep_no_progress_idle_move_ticks; - float ep_no_progress_move_cmd_ticks; - float ep_no_progress_attack_none_ticks; - float ep_no_progress_attack_target_ticks; - float ep_no_progress_has_target_ticks; - float ep_no_progress_no_target_ticks; - float ep_no_progress_prayer_cmd_ticks; - float ep_no_progress_invalid_action_ticks; - float ep_progress_delta; - float ep_progress_reward; - float ep_gross_damage_dealt; - float ep_net_required_work_removed; - float ep_npc_healing_total; - float ep_mejkot_healing_total; - float ep_jad_healing_total; - float ep_preclip_reward; - float ep_postclip_reward; - float ep_positive_clip_count; - float ep_negative_clip_count; - /* RNG seed counter (increments each episode for variety) */ uint32_t seed_counter; } FightCaves; @@ -263,117 +127,13 @@ static void fc_puffer_write_obs(FightCaves* env) { /* ======================================================================== */ static float fc_puffer_compute_reward(FightCaves* env) { - FcRewardBreakdown breakdown = - fc_reward_compute_breakdown( - &env->state, &env->reward_params, &env->reward_runtime); + FcRewardBreakdown breakdown = fc_reward_compute_breakdown( + &env->state, &env->reward_params, &env->reward_runtime); if (env->viewer) env->viewer->pending_reward_breakdown = breakdown; fc_reward_sync_progress_state(&env->state, &env->reward_runtime); - - if (breakdown.threat_ctx.tokxil_melee) env->state.ep_tokxil_melee_ticks++; - if (breakdown.threat_ctx.ketzek_melee) env->state.ep_ketzek_melee_ticks++; - - /* Per-channel analytics: accumulate value and fire count per reward channel. - * Drains into the per-env PufferLib Log on terminal; see c_step. */ - float ch[FC_CH_COUNT]; - fc_reward_breakdown_channels(&breakdown, ch); - for (int i = 0; i < FC_CH_COUNT; i++) { - env->ep_rwd_sum[i] += ch[i]; - if (ch[i] != 0.0f) env->ep_rwd_fires[i]++; - } - - env->ep_progress_delta += env->reward_runtime.last_progress_delta; - env->ep_progress_reward += breakdown.progress; - env->ep_gross_damage_dealt += (float)env->state.damage_dealt_this_tick; - env->ep_net_required_work_removed += env->reward_runtime.last_net_required_work_removed; - env->ep_npc_healing_total += (float)env->state.npc_heal_amount_this_tick; - env->ep_mejkot_healing_total += (float)env->state.mejkot_heal_amount_this_tick; - env->ep_jad_healing_total += (float)env->state.jad_heal_amount_this_tick; - env->ep_preclip_reward += breakdown.total; - { - float clipped = breakdown.total; - if (clipped > 1.0f) { - clipped = 1.0f; - env->ep_positive_clip_count += 1.0f; - } else if (clipped < -1.0f) { - clipped = -1.0f; - env->ep_negative_clip_count += 1.0f; - } - env->ep_postclip_reward += clipped; - } - return breakdown.total; } -static void fc_puffer_reset_episode_action_diagnostics(FightCaves* env) { - env->ep_no_progress_ticks = 0.0f; - env->ep_no_progress_idle_move_ticks = 0.0f; - env->ep_no_progress_move_cmd_ticks = 0.0f; - env->ep_no_progress_attack_none_ticks = 0.0f; - env->ep_no_progress_attack_target_ticks = 0.0f; - env->ep_no_progress_has_target_ticks = 0.0f; - env->ep_no_progress_no_target_ticks = 0.0f; - env->ep_no_progress_prayer_cmd_ticks = 0.0f; - env->ep_no_progress_invalid_action_ticks = 0.0f; - env->ep_progress_delta = 0.0f; - env->ep_progress_reward = 0.0f; - env->ep_gross_damage_dealt = 0.0f; - env->ep_net_required_work_removed = 0.0f; - env->ep_npc_healing_total = 0.0f; - env->ep_mejkot_healing_total = 0.0f; - env->ep_jad_healing_total = 0.0f; - env->ep_preclip_reward = 0.0f; - env->ep_postclip_reward = 0.0f; - env->ep_positive_clip_count = 0.0f; - env->ep_negative_clip_count = 0.0f; -} - -static void fc_puffer_record_no_progress_diagnostics( - FightCaves* env, - const int actions[FC_NUM_ACTION_HEADS]) { - const FcState* state = &env->state; - const FcPlayer* player = &state->player; - int target_active = 0; - - if (state->terminal != TERMINAL_NONE || state->npcs_remaining <= 0) return; - if (state->movement_this_tick || state->attack_attempt_this_tick) return; - if (state->damage_dealt_this_tick > 0 || state->npcs_killed_this_tick > 0) return; - if (state->wave_just_cleared) return; - - if (player->attack_target_idx >= 0) { - const FcNpc* target = &state->npcs[player->attack_target_idx]; - target_active = target->active && !target->is_dead; - if (target_active && player->attack_timer > 0) { - int dist = fc_distance_to_npc(player->x, player->y, target); - int has_los = fc_has_los_between_areas( - player->x, player->y, 1, - target->x, target->y, target->size, state->los_flags); - if (dist <= player->weapon_range && has_los) return; - } - } - - env->ep_no_progress_ticks += 1.0f; - if (actions[0] == FC_MOVE_IDLE) { - env->ep_no_progress_idle_move_ticks += 1.0f; - } else { - env->ep_no_progress_move_cmd_ticks += 1.0f; - } - if (actions[1] == FC_ATTACK_NONE) { - env->ep_no_progress_attack_none_ticks += 1.0f; - } else { - env->ep_no_progress_attack_target_ticks += 1.0f; - } - if (target_active) { - env->ep_no_progress_has_target_ticks += 1.0f; - } else { - env->ep_no_progress_no_target_ticks += 1.0f; - } - if (actions[2] != 0) { - env->ep_no_progress_prayer_cmd_ticks += 1.0f; - } - if (state->invalid_action_this_tick) { - env->ep_no_progress_invalid_action_ticks += 1.0f; - } -} /* ======================================================================== */ /* PufferLib interface: c_reset, c_step, c_render, c_close */ @@ -403,12 +163,6 @@ void c_reset(FightCaves* env) { env->ep_length = 0; fc_reward_runtime_begin_episode(&env->reward_runtime, &env->state); - for (int i = 0; i < FC_CH_COUNT; i++) { - env->ep_rwd_sum[i] = 0.0f; - env->ep_rwd_fires[i] = 0; - } - fc_puffer_reset_episode_action_diagnostics(env); - /* Compute initial observations */ fc_puffer_write_obs(env); if (env->viewer) env->viewer->reset_state = env->state; @@ -431,7 +185,6 @@ void c_step(FightCaves* env) { /* Step the game simulation */ fc_step(&env->state, actions); - fc_puffer_record_no_progress_diagnostics(env, actions); /* Compute reward */ float reward = fc_puffer_compute_reward(env); @@ -454,53 +207,10 @@ void c_step(FightCaves* env) { /* Check terminal */ if (fc_is_terminal(&env->state)) { FcEpisodeSummary summary; - fc_episode_summary_build(&env->state, env->ep_length, &summary); + fc_episode_summary_build(&env->state, &env->reward_runtime, + env->ep_length, &summary); env->terminals[0] = 1.0f; fc_puffer_accumulate_episode_summary(&env->log, &summary); - env->log.no_progress_ticks += env->ep_no_progress_ticks; - env->log.no_progress_idle_move_ticks += env->ep_no_progress_idle_move_ticks; - env->log.no_progress_move_cmd_ticks += env->ep_no_progress_move_cmd_ticks; - env->log.no_progress_attack_none_ticks += env->ep_no_progress_attack_none_ticks; - env->log.no_progress_attack_target_ticks += env->ep_no_progress_attack_target_ticks; - env->log.no_progress_has_target_ticks += env->ep_no_progress_has_target_ticks; - env->log.no_progress_no_target_ticks += env->ep_no_progress_no_target_ticks; - env->log.no_progress_prayer_cmd_ticks += env->ep_no_progress_prayer_cmd_ticks; - env->log.no_progress_invalid_action_ticks += env->ep_no_progress_invalid_action_ticks; - env->log.required_work_remaining += - env->reward_runtime.last_required_work_remaining; - env->log.required_work_start += - env->reward_runtime.required_work_at_wave_start; - env->log.cave_progress += env->reward_runtime.last_cave_progress; - env->log.current_wave_progress += - env->reward_runtime.last_current_wave_progress; - env->log.progress_delta += env->ep_progress_delta; - env->log.progress_reward += env->ep_progress_reward; - env->log.ticks_since_positive_progress += - (float)env->reward_runtime.ticks_since_positive_progress; - env->log.positive_progress_ticks += - (float)env->reward_runtime.positive_progress_ticks; - env->log.zero_progress_ticks += - (float)env->reward_runtime.zero_progress_ticks; - env->log.negative_progress_ticks += - (float)env->reward_runtime.negative_progress_ticks; - env->log.gross_damage_dealt += env->ep_gross_damage_dealt; - env->log.net_required_work_removed += env->ep_net_required_work_removed; - env->log.gross_damage_to_net_progress_ratio += - env->ep_gross_damage_dealt / - ((env->ep_net_required_work_removed > 1.0f) - ? env->ep_net_required_work_removed : 1.0f); - env->log.npc_healing_total += env->ep_npc_healing_total; - env->log.mejkot_healing_total += env->ep_mejkot_healing_total; - env->log.jad_healing_total += env->ep_jad_healing_total; - env->log.preclip_reward += env->ep_preclip_reward; - env->log.postclip_reward += env->ep_postclip_reward; - env->log.positive_clip_count += env->ep_positive_clip_count; - env->log.negative_clip_count += env->ep_negative_clip_count; - - for (int i = 0; i < FC_CH_COUNT; i++) { - env->log.rwd_sum[i] += env->ep_rwd_sum[i]; - env->log.rwd_fires[i] += (float)env->ep_rwd_fires[i]; - } env->log.n += 1.0f; /* Same-step autoreset: return the completed episode's reward and diff --git a/ocean/fight_caves/render.h b/ocean/fight_caves/render.h index 96527124a4..7de27a8e85 100644 --- a/ocean/fight_caves/render.h +++ b/ocean/fight_caves/render.h @@ -3411,9 +3411,7 @@ int dbg_draw_panel_tabs(const FcState* state, b->total, reward_runtime->ticks_since_attack); fc_osrs_draw_text(buf, x, by, 8, dbg_reward_color(b->total)); by += sh; - snprintf(buf, sizeof(buf), "threat any:%d melee:%d", - b->threat_ctx.any_threat, - b->threat_ctx.melee_pressure_npcs); + snprintf(buf, sizeof(buf), "threat:%d", b->any_threat); fc_osrs_draw_text(buf, x, by, 8, DBG_COL_LABEL); by += sh + 2; { diff --git a/ocean/fight_caves/simulation.h b/ocean/fight_caves/simulation.h index 65aa0ae328..a8b970ed07 100644 --- a/ocean/fight_caves/simulation.h +++ b/ocean/fight_caves/simulation.h @@ -163,13 +163,6 @@ typedef enum { TERMINAL_TICK_CAP = 3 } FcTerminalCode; -/* Invalid-action diagnostic classes for Puffer-facing heads 0-2. */ -typedef enum { - FC_INVALID_ACTION_MOVE = 0, - FC_INVALID_ACTION_ATTACK = 1, - FC_INVALID_ACTION_PRAYER = 2, - FC_INVALID_ACTION_CLASS_COUNT = 3 -} FcInvalidActionClass; /* NPC spawn direction for wave rotations */ typedef enum { @@ -372,19 +365,11 @@ typedef struct { /* Per-tick event flags (cleared each tick, used for obs/reward/hitsplats) */ int damage_taken_this_tick; - int hit_style_this_tick; /* FcAttackStyle of the last hit that resolved this tick */ - int hit_source_npc_type; /* FcNpcType of the NPC that landed the last hit this tick */ - int hit_locked_prayer_this_tick; /* FcPrayer snapshot used for the last resolved hit */ - int hit_blocked_this_tick; /* 1 if the last resolved hit this tick was prayer-blocked */ int hit_landed_this_tick; int food_eaten_this_tick; int potion_used_this_tick; int prayer_changed_this_tick; - /* Cumulative stats (for reward/logging) */ - int total_damage_taken; - int total_food_eaten; - int total_potions_used; FcItemStack inventory[FC_INVENTORY_SLOTS]; FcItemStack equipment[FC_EQUIPMENT_SLOTS]; int melee_attack_bonus, melee_strength_bonus; @@ -578,7 +563,6 @@ typedef struct { int current_wave; /* 1-indexed: 1..63. 0 = not started */ int rotation_id; /* 0..14, selected at episode start */ int npcs_remaining; /* count of active (alive) NPCs in current wave */ - int total_npcs_killed; int next_spawn_index; /* monotonic counter for NPC spawn ordering */ /* Tick */ @@ -622,7 +606,6 @@ typedef struct { int wrong_danger_prayer; int attack_attempt_this_tick; int invalid_action_this_tick; - int invalid_action_class_this_tick[FC_INVALID_ACTION_CLASS_COUNT]; int movement_this_tick; int idle_this_tick; int food_used_this_tick; @@ -630,7 +613,6 @@ typedef struct { int jad_heal_procs_this_tick; /* number of Yt-HurKot heal procs that restored Jad HP */ int npc_heal_procs_this_tick; /* number of NPC heal procs that restored any NPC HP */ int npc_heal_amount_this_tick; /* total NPC HP restored this tick */ - int mejkot_heal_amount_this_tick; /* total HP restored by Yt-MejKot this tick */ int jad_heal_amount_this_tick; /* total HP restored to Jad this tick */ /* Derived progression state, maintained by reward/runtime code for obs. */ @@ -644,46 +626,9 @@ typedef struct { int ep_ticks_pray_melee; /* ticks with protect melee active */ int ep_ticks_pray_range; /* ticks with protect range active */ int ep_ticks_pray_magic; /* ticks with protect magic active */ - int ep_correct_blocks; /* hits correctly blocked by matching prayer */ int ep_wrong_prayer_hits; /* hits where prayer active but wrong type */ - int ep_no_prayer_hits; /* hits where no prayer was active */ - int ep_damage_blocked; /* total damage prevented by correct prayer */ - int ep_prayer_switches; /* number of prayer changes */ - int ep_pots_used; /* prayer pot doses consumed */ - int ep_pots_wasted; /* doses consumed when prayer was above 20% */ - int ep_pot_pre_prayer_sum; /* prayer points before each potion use */ - int ep_food_eaten; /* sharks consumed */ - int ep_food_pre_hp_sum; /* HP before each food use */ - int ep_food_overhealed; /* sharks that overhealed (wasted HP) */ - int ep_pots_overrestored; /* doses that over-restored (wasted prayer) */ - int ep_tokxil_melee_ticks; /* ticks with any Tok-Xil at melee distance */ - int ep_ketzek_melee_ticks; /* ticks with any Ket-Zek at melee distance */ - int ep_attack_ready_ticks; /* ticks where attack cooldown was ready */ - int ep_attack_attempt_ticks;/* ready ticks where a real attack fired */ - int ep_invalid_action_classes[FC_INVALID_ACTION_CLASS_COUNT]; - int ep_damage_to_npc_type[NPC_TYPE_COUNT]; /* player damage by NPC type */ - int ep_resolved_hits_to_npc_type[NPC_TYPE_COUNT];/* all resolved player hitsplats, including 0s */ - int ep_damaging_hits_to_npc_type[NPC_TYPE_COUNT];/* resolved player hitsplats with damage > 0 */ - int ep_attack_cycles_to_npc_type[NPC_TYPE_COUNT];/* actual attack cycles fired by target type */ - int ep_target_ticks_by_npc_type[NPC_TYPE_COUNT]; /* ticks with active attack target by type */ - int ep_target_held_ticks; /* ticks with any active attack target */ - int ep_no_target_ticks; /* ticks with NPCs alive and no active attack target */ - int ep_target_in_range_los_ticks;/* target held, in range, and line of sight available */ - int ep_target_out_of_range_or_los_ticks; /* target held but cannot currently fire */ - int ep_attack_cooldown_wait_ticks; /* target held and fireable, but weapon cooling down */ - int ep_ready_but_no_attack_ticks; /* target held/fireable/ready but no attack cycle launched */ - int ep_action_move_idle_ticks; - int ep_action_move_walk_ticks; - int ep_action_move_run_ticks; - int ep_action_attack_none_ticks; - int ep_action_attack_target_ticks; - int ep_action_prayer_noop_ticks; - int ep_action_prayer_cmd_ticks; int ep_reached_wave_63; /* 1 if episode reached Jad wave */ int ep_jad_killed; /* 1 if Jad died at any point this episode */ - int wave_start_tick; /* tick when current wave was spawned */ - int ep_max_wave_ticks; /* longest single wave duration in ticks */ - int ep_max_wave_ticks_wave; /* which wave number that was */ } FcState; @@ -1076,64 +1021,6 @@ static const int FC_PUFFER_ACTION_DIMS[FC_PUFFER_NUM_ATNS] = FC_PUFFER_ACT_SIZES */ -/* Episode Summary */ - -/* Read-only, consumer-neutral episode metrics derived from FcState. Training - * may aggregate these values and evaluators may serialize them, but neither - * consumer should independently reproduce their formulas. */ -typedef struct { - int episode_length; - int wave_reached; - int npcs_slayed; - float prayer_uptime_melee; - float prayer_uptime_range; - float prayer_uptime_magic; - int correct_prayer; - int wrong_prayer_hits; - int no_prayer_hits; - int prayer_switches; - int damage_blocked; - int damage_taken; - float attack_when_ready_rate; - int invalid_move; - int invalid_attack; - int invalid_prayer; - int tokxil_melee_ticks; - int ketzek_melee_ticks; - int max_wave_ticks; - int max_wave_ticks_wave; - int reached_wave_63; - int jad_killed; - int player_died; - int damage_to_npc_type[NPC_TYPE_COUNT]; - int resolved_hits_to_npc_type[NPC_TYPE_COUNT]; - int damaging_hits_to_npc_type[NPC_TYPE_COUNT]; - int attack_cycles_to_npc_type[NPC_TYPE_COUNT]; - int target_ticks_by_npc_type[NPC_TYPE_COUNT]; - int target_held_ticks; - int no_target_ticks; - int target_in_range_los_ticks; - int target_out_of_range_or_los_ticks; - int attack_cooldown_wait_ticks; - int ready_but_no_attack_ticks; - int action_move_idle_ticks; - int action_move_walk_ticks; - int action_move_run_ticks; - int action_attack_none_ticks; - int action_attack_target_ticks; - int action_prayer_noop_ticks; - int action_prayer_cmd_ticks; -} FcEpisodeSummary; - -/* episode_length is supplied by the consumer because standalone simulation - * ticks and adapter step counts can intentionally differ in tests/tools. */ -void fc_episode_summary_build(const FcState* state, int episode_length, - FcEpisodeSummary* summary); - -/* Stable lowercase suffix shared by training and evaluator metric keys. */ -const char* fc_episode_npc_metric_name(int npc_type); - - /* Combat */ /* OSRS accuracy formula: returns hit probability in [0,1] */ @@ -1444,21 +1331,30 @@ typedef struct { float last_required_work_remaining; float last_current_wave_progress; float last_cave_progress; - float last_progress_delta; - float last_progress_reward; - float last_net_required_work_removed; int ticks_since_positive_progress; - int positive_progress_ticks; int zero_progress_ticks; - int negative_progress_ticks; + float npc_healing_total; + float jad_healing_total; } FcRewardRuntime; +/* Episode analytics shared by training and checkpoint evaluation. */ typedef struct { - int melee_pressure_npcs; - int any_threat; - int tokxil_melee; - int ketzek_melee; -} FcRewardThreatContext; + int zero_progress_ticks; + int wave_reached; + int wrong_prayer_hits; + int reached_wave_63; + int jad_kill_rate; + float prayer_uptime_range; + float prayer_uptime_melee; + float prayer_uptime_magic; + float npc_healing_total; + float jad_healing_total; + int episode_length; +} FcEpisodeSummary; + +void fc_episode_summary_build(const FcState* state, const FcRewardRuntime* runtime, + int episode_length, FcEpisodeSummary* summary); + typedef struct { float raw[FC_REWARD_FEATURES]; @@ -1486,37 +1382,9 @@ typedef struct { float tick_penalty; float total; - FcRewardThreatContext threat_ctx; + int any_threat; } FcRewardBreakdown; -/* One slot per named breakdown field, excluding raw inputs and the total. */ -typedef enum { - FC_CH_DAMAGE_DEALT = 0, - FC_CH_PROGRESS, - FC_CH_DAMAGE_TAKEN, - FC_CH_NPC_KILL, - FC_CH_WAVE_CLEAR, - FC_CH_JAD_KILL, - FC_CH_CAVE_COMPLETE, - FC_CH_PLAYER_DEATH, - FC_CH_CORRECT_JAD_PRAYER, - FC_CH_CORRECT_DANGER_PRAYER, - FC_CH_PRAYER_LOST, - FC_CH_UNNECESSARY_PRAYER, - FC_CH_WAVE_STALL, - FC_CH_NO_PROGRESS, - FC_CH_NO_ATTACK, - FC_CH_JAD_HEAL, - FC_CH_NPC_HEAL, - FC_CH_INVALID_ACTION, - FC_CH_TICK_PENALTY, - FC_CH_COUNT -} FcRwdChannel; - -extern const char* const FC_CH_NAMES[FC_CH_COUNT]; - -void fc_reward_breakdown_channels(const FcRewardBreakdown* breakdown, - float out[FC_CH_COUNT]); FcRewardParams fc_reward_default_params(void); void fc_reward_runtime_reset(FcRewardRuntime* runtime); float fc_reward_player_death_scale(const FcRewardParams* params, @@ -1637,13 +1505,6 @@ int fc_visible_npc_indices(const FcState* state, int out_indices[FC_VISIBLE_NPCS * 1.0 = valid action, 0.0 = invalid. */ void fc_write_mask(const FcState* state, float* out); -/* Fill out_classes with 0/1 invalid-action diagnostics for Puffer-facing heads - * 0-2 only: move, attack, prayer. Core consumable/path-target heads stay - * excluded because the no-supplies policy does not emit them. */ -void fc_action_invalid_classes(const FcState* state, - const int actions[FC_NUM_ACTION_HEADS], - int out_classes[FC_INVALID_ACTION_CLASS_COUNT]); - /* Compute and write reward features for the current tick. * out must have room for FC_REWARD_FEATURES floats. * These are raw feature values (not weighted). Python applies shaping weights. */ @@ -1656,9 +1517,9 @@ int fc_is_terminal(const FcState* state); /* Determinism */ /* ======================================================================== */ -/* Version 5 includes inventory, equipment, selected consumable slots and - * unarmed bonuses. Policy observations and action dimensions are unchanged. */ -#define FC_STATE_HASH_VERSION 5u +/* Version 6 removes retired analytics from the serialized state. + * Gameplay, policy observations, actions and rewards are unchanged. */ +#define FC_STATE_HASH_VERSION 6u /* * Compute a deterministic hash of the game state. @@ -1770,11 +1631,6 @@ int fc_spawn_find_available_footprint(const FcState* state, int fc_spawn_npc_first_free(FcState* state, int npc_type, int x, int y); -/* Wave Internal */ - -void fc_wave_record_current_duration(FcState* state); - - /* Combat */ #include #include @@ -2133,12 +1989,7 @@ void fc_resolve_player_pending_hits(FcState* state) { if (p->current_hp < 0) p->current_hp = 0; p->damage_taken_this_tick += final_damage; - p->hit_style_this_tick = h->attack_style; - p->hit_source_npc_type = state->npcs[h->source_npc_idx].npc_type; - p->hit_locked_prayer_this_tick = locked_prayer; - p->hit_blocked_this_tick = blocked; state->damage_taken_this_tick += final_damage; - p->total_damage_taken += final_damage; p->hit_landed_this_tick = 1; record_render_hit(state, ENTITY_PLAYER, -1, h->source_npc_idx, h->attack_style, @@ -2191,17 +2042,8 @@ void fc_resolve_player_pending_hits(FcState* state) { else state->wrong_danger_prayer = 1; } - /* Episode-level hit analytics */ - if (locked_prayer != PRAYER_NONE) { - if (blocked) { - state->ep_correct_blocks++; - state->ep_damage_blocked += h->damage; - } else { - state->ep_wrong_prayer_hits++; - } - } else { - state->ep_no_prayer_hits++; - } + if (locked_prayer != PRAYER_NONE && !blocked) + state->ep_wrong_prayer_hits++; h->active = 0; /* consumed */ } else { @@ -2231,7 +2073,6 @@ static void complete_fight_caves(FcState* state) { } state->wave_just_cleared = 1; state->terminal = TERMINAL_CAVE_COMPLETE; - fc_wave_record_current_duration(state); } static void resolve_npc_death(FcState* state, FcNpc* npc) { @@ -2246,7 +2087,6 @@ static void resolve_npc_death(FcState* state, FcNpc* npc) { npc->is_respawned_jad_healer) { state->respawned_jad_healers_killed_this_tick++; } - state->total_npcs_killed++; if (npc->npc_type == NPC_TZTOK_JAD) { complete_fight_caves(state); @@ -2277,13 +2117,7 @@ void fc_resolve_npc_pending_hits(FcState* state, int npc_idx) { npc->damage_taken_this_tick += h->damage; state->damage_dealt_this_tick += h->damage; - if (npc->npc_type > NPC_NONE && npc->npc_type < NPC_TYPE_COUNT) { - state->ep_resolved_hits_to_npc_type[npc->npc_type]++; - state->ep_damage_to_npc_type[npc->npc_type] += h->damage; - if (h->damage > 0) { - state->ep_damaging_hits_to_npc_type[npc->npc_type]++; - } - } + if (h->damage > 0) { state->hits_landed_this_tick++; } @@ -2320,15 +2154,17 @@ void fc_resolve_npc_pending_hits(FcState* state, int npc_idx) { /* Episode Summary */ #include -void fc_episode_summary_build(const FcState* state, int episode_length, - FcEpisodeSummary* summary) { - if (!summary) return; +void fc_episode_summary_build(const FcState* state, const FcRewardRuntime* runtime, + int episode_length, FcEpisodeSummary* summary) { memset(summary, 0, sizeof(*summary)); - if (!state) return; - summary->episode_length = episode_length; summary->wave_reached = state->current_wave; - summary->npcs_slayed = state->total_npcs_killed; + summary->wrong_prayer_hits = state->ep_wrong_prayer_hits; + summary->reached_wave_63 = state->ep_reached_wave_63; + summary->jad_kill_rate = state->ep_jad_killed; + summary->zero_progress_ticks = runtime->zero_progress_ticks; + summary->npc_healing_total = runtime->npc_healing_total; + summary->jad_healing_total = runtime->jad_healing_total; if (episode_length > 0) { summary->prayer_uptime_melee = (float)state->ep_ticks_pray_melee / (float)episode_length; @@ -2337,79 +2173,6 @@ void fc_episode_summary_build(const FcState* state, int episode_length, summary->prayer_uptime_magic = (float)state->ep_ticks_pray_magic / (float)episode_length; } - summary->correct_prayer = state->ep_correct_blocks; - summary->wrong_prayer_hits = state->ep_wrong_prayer_hits; - summary->no_prayer_hits = state->ep_no_prayer_hits; - summary->prayer_switches = state->ep_prayer_switches; - summary->damage_blocked = state->ep_damage_blocked; - summary->damage_taken = state->player.total_damage_taken; - if (state->ep_attack_ready_ticks > 0) { - summary->attack_when_ready_rate = - (float)state->ep_attack_attempt_ticks / - (float)state->ep_attack_ready_ticks; - } - summary->invalid_move = - state->ep_invalid_action_classes[FC_INVALID_ACTION_MOVE]; - summary->invalid_attack = - state->ep_invalid_action_classes[FC_INVALID_ACTION_ATTACK]; - summary->invalid_prayer = - state->ep_invalid_action_classes[FC_INVALID_ACTION_PRAYER]; - summary->tokxil_melee_ticks = state->ep_tokxil_melee_ticks; - summary->ketzek_melee_ticks = state->ep_ketzek_melee_ticks; - summary->max_wave_ticks = state->ep_max_wave_ticks; - summary->max_wave_ticks_wave = state->ep_max_wave_ticks_wave; - summary->reached_wave_63 = state->ep_reached_wave_63; - summary->jad_killed = state->ep_jad_killed; - summary->player_died = state->terminal == TERMINAL_PLAYER_DEATH; - - memcpy(summary->damage_to_npc_type, state->ep_damage_to_npc_type, - sizeof(summary->damage_to_npc_type)); - memcpy(summary->resolved_hits_to_npc_type, - state->ep_resolved_hits_to_npc_type, - sizeof(summary->resolved_hits_to_npc_type)); - memcpy(summary->damaging_hits_to_npc_type, - state->ep_damaging_hits_to_npc_type, - sizeof(summary->damaging_hits_to_npc_type)); - memcpy(summary->attack_cycles_to_npc_type, - state->ep_attack_cycles_to_npc_type, - sizeof(summary->attack_cycles_to_npc_type)); - memcpy(summary->target_ticks_by_npc_type, - state->ep_target_ticks_by_npc_type, - sizeof(summary->target_ticks_by_npc_type)); - - summary->target_held_ticks = state->ep_target_held_ticks; - summary->no_target_ticks = state->ep_no_target_ticks; - summary->target_in_range_los_ticks = - state->ep_target_in_range_los_ticks; - summary->target_out_of_range_or_los_ticks = - state->ep_target_out_of_range_or_los_ticks; - summary->attack_cooldown_wait_ticks = - state->ep_attack_cooldown_wait_ticks; - summary->ready_but_no_attack_ticks = - state->ep_ready_but_no_attack_ticks; - summary->action_move_idle_ticks = state->ep_action_move_idle_ticks; - summary->action_move_walk_ticks = state->ep_action_move_walk_ticks; - summary->action_move_run_ticks = state->ep_action_move_run_ticks; - summary->action_attack_none_ticks = state->ep_action_attack_none_ticks; - summary->action_attack_target_ticks = - state->ep_action_attack_target_ticks; - summary->action_prayer_noop_ticks = state->ep_action_prayer_noop_ticks; - summary->action_prayer_cmd_ticks = state->ep_action_prayer_cmd_ticks; -} - -const char* fc_episode_npc_metric_name(int npc_type) { - switch (npc_type) { - case NPC_NONE: return "none"; - case NPC_TZ_KIH: return "tz_kih"; - case NPC_TZ_KEK: return "tz_kek"; - case NPC_TZ_KEK_SM: return "tz_kek_sm"; - case NPC_TOK_XIL: return "tok_xil"; - case NPC_YT_MEJKOT: return "yt_mejkot"; - case NPC_KET_ZEK: return "ket_zek"; - case NPC_TZTOK_JAD: return "tztok_jad"; - case NPC_YT_HURKOT: return "yt_hurkot"; - default: return "unknown"; - } } @@ -2418,7 +2181,7 @@ const char* fc_episode_npc_metric_name(int npc_type) { #include /* - * Version 2 serializes every FcState field explicitly in the documented order + * The state hash serializes every FcState field explicitly in the documented order * below, including whole-tile, directional movement, and projectile collision * maps. Signed integers and floats are represented by 32 bits, then fed * least-significant byte first. Arena bytes are fed directly. This order is @@ -2526,17 +2289,10 @@ static uint32_t fc_hash_player(uint32_t hash, const FcPlayer* player) { } FC_HASH_I32(player->num_pending_hits); FC_HASH_I32(player->damage_taken_this_tick); - FC_HASH_I32(player->hit_style_this_tick); - FC_HASH_I32(player->hit_source_npc_type); - FC_HASH_I32(player->hit_locked_prayer_this_tick); - FC_HASH_I32(player->hit_blocked_this_tick); FC_HASH_I32(player->hit_landed_this_tick); FC_HASH_I32(player->food_eaten_this_tick); FC_HASH_I32(player->potion_used_this_tick); FC_HASH_I32(player->prayer_changed_this_tick); - FC_HASH_I32(player->total_damage_taken); - FC_HASH_I32(player->total_food_eaten); - FC_HASH_I32(player->total_potions_used); for (int i = 0; i < FC_INVENTORY_SLOTS; i++) { FC_HASH_I32(player->inventory[i].item_id); FC_HASH_I32(player->inventory[i].quantity); @@ -2602,7 +2358,6 @@ uint32_t fc_state_hash(const FcState* state) { FC_HASH_I32(state->current_wave); FC_HASH_I32(state->rotation_id); FC_HASH_I32(state->npcs_remaining); - FC_HASH_I32(state->total_npcs_killed); FC_HASH_I32(state->next_spawn_index); FC_HASH_I32(state->tick); FC_HASH_I32(state->terminal); @@ -2644,9 +2399,6 @@ uint32_t fc_state_hash(const FcState* state) { FC_HASH_I32(state->wrong_danger_prayer); FC_HASH_I32(state->attack_attempt_this_tick); FC_HASH_I32(state->invalid_action_this_tick); - for (int i = 0; i < FC_INVALID_ACTION_CLASS_COUNT; ++i) { - FC_HASH_I32(state->invalid_action_class_this_tick[i]); - } FC_HASH_I32(state->movement_this_tick); FC_HASH_I32(state->idle_this_tick); FC_HASH_I32(state->food_used_this_tick); @@ -2654,7 +2406,6 @@ uint32_t fc_state_hash(const FcState* state) { FC_HASH_I32(state->jad_heal_procs_this_tick); FC_HASH_I32(state->npc_heal_procs_this_tick); FC_HASH_I32(state->npc_heal_amount_this_tick); - FC_HASH_I32(state->mejkot_heal_amount_this_tick); FC_HASH_I32(state->jad_heal_amount_this_tick); FC_HASH_F32(state->progress_required_work_start); @@ -2666,50 +2417,9 @@ uint32_t fc_state_hash(const FcState* state) { FC_HASH_I32(state->ep_ticks_pray_melee); FC_HASH_I32(state->ep_ticks_pray_range); FC_HASH_I32(state->ep_ticks_pray_magic); - FC_HASH_I32(state->ep_correct_blocks); FC_HASH_I32(state->ep_wrong_prayer_hits); - FC_HASH_I32(state->ep_no_prayer_hits); - FC_HASH_I32(state->ep_damage_blocked); - FC_HASH_I32(state->ep_prayer_switches); - FC_HASH_I32(state->ep_pots_used); - FC_HASH_I32(state->ep_pots_wasted); - FC_HASH_I32(state->ep_pot_pre_prayer_sum); - FC_HASH_I32(state->ep_food_eaten); - FC_HASH_I32(state->ep_food_pre_hp_sum); - FC_HASH_I32(state->ep_food_overhealed); - FC_HASH_I32(state->ep_pots_overrestored); - FC_HASH_I32(state->ep_tokxil_melee_ticks); - FC_HASH_I32(state->ep_ketzek_melee_ticks); - FC_HASH_I32(state->ep_attack_ready_ticks); - FC_HASH_I32(state->ep_attack_attempt_ticks); - for (int i = 0; i < FC_INVALID_ACTION_CLASS_COUNT; ++i) { - FC_HASH_I32(state->ep_invalid_action_classes[i]); - } - for (int i = 0; i < NPC_TYPE_COUNT; ++i) { - FC_HASH_I32(state->ep_damage_to_npc_type[i]); - FC_HASH_I32(state->ep_resolved_hits_to_npc_type[i]); - FC_HASH_I32(state->ep_damaging_hits_to_npc_type[i]); - FC_HASH_I32(state->ep_attack_cycles_to_npc_type[i]); - FC_HASH_I32(state->ep_target_ticks_by_npc_type[i]); - } - FC_HASH_I32(state->ep_target_held_ticks); - FC_HASH_I32(state->ep_no_target_ticks); - FC_HASH_I32(state->ep_target_in_range_los_ticks); - FC_HASH_I32(state->ep_target_out_of_range_or_los_ticks); - FC_HASH_I32(state->ep_attack_cooldown_wait_ticks); - FC_HASH_I32(state->ep_ready_but_no_attack_ticks); - FC_HASH_I32(state->ep_action_move_idle_ticks); - FC_HASH_I32(state->ep_action_move_walk_ticks); - FC_HASH_I32(state->ep_action_move_run_ticks); - FC_HASH_I32(state->ep_action_attack_none_ticks); - FC_HASH_I32(state->ep_action_attack_target_ticks); - FC_HASH_I32(state->ep_action_prayer_noop_ticks); - FC_HASH_I32(state->ep_action_prayer_cmd_ticks); FC_HASH_I32(state->ep_reached_wave_63); FC_HASH_I32(state->ep_jad_killed); - FC_HASH_I32(state->wave_start_tick); - FC_HASH_I32(state->ep_max_wave_ticks); - FC_HASH_I32(state->ep_max_wave_ticks_wave); return hash; } @@ -3890,9 +3600,7 @@ static int apply_npc_heal(FcState* state, FcNpc* source, FcNpc* target, state->npc_heal_procs_this_tick++; state->npc_heal_amount_this_tick += amount; - if (source->npc_type == NPC_YT_MEJKOT) { - state->mejkot_heal_amount_this_tick += amount; - } + if (target->npc_type == NPC_TZTOK_JAD) { state->jad_heal_amount_this_tick += amount; } @@ -4897,39 +4605,6 @@ int fc_prayer_potion_restore(int prayer_level) { /* Reward */ #include -const char* const FC_CH_NAMES[FC_CH_COUNT] = { - "damage_dealt", "progress", "damage_taken", "npc_kill", "wave_clear", - "jad_kill", "cave_complete", "player_death", "correct_jad_prayer", - "correct_danger_prayer", "prayer_lost", "unnecessary_prayer", "wave_stall", - "no_progress", "no_attack", "jad_heal", "npc_heal", "invalid_action", - "tick_penalty" -}; - -/* Populate a contiguous array view of the breakdown channels for iteration. - * Order matches FcRwdChannel enum above. */ -void fc_reward_breakdown_channels(const FcRewardBreakdown* b, - float out[FC_CH_COUNT]) { - out[FC_CH_DAMAGE_DEALT] = b->damage_dealt; - out[FC_CH_PROGRESS] = b->progress; - out[FC_CH_DAMAGE_TAKEN] = b->damage_taken; - out[FC_CH_NPC_KILL] = b->npc_kill; - out[FC_CH_WAVE_CLEAR] = b->wave_clear; - out[FC_CH_JAD_KILL] = b->jad_kill; - out[FC_CH_CAVE_COMPLETE] = b->cave_complete; - out[FC_CH_PLAYER_DEATH] = b->player_death; - out[FC_CH_CORRECT_JAD_PRAYER] = b->correct_jad_prayer; - out[FC_CH_CORRECT_DANGER_PRAYER] = b->correct_danger_prayer; - out[FC_CH_PRAYER_LOST] = b->prayer_lost; - out[FC_CH_UNNECESSARY_PRAYER] = b->unnecessary_prayer; - out[FC_CH_WAVE_STALL] = b->wave_stall; - out[FC_CH_NO_PROGRESS] = b->no_progress; - out[FC_CH_NO_ATTACK] = b->no_attack; - out[FC_CH_JAD_HEAL] = b->jad_heal; - out[FC_CH_NPC_HEAL] = b->npc_heal; - out[FC_CH_INVALID_ACTION] = b->invalid_action; - out[FC_CH_TICK_PENALTY] = b->tick_penalty; -} - FcRewardParams fc_reward_default_params(void) { FcRewardParams params; memset(¶ms, 0, sizeof(params)); @@ -5073,36 +4748,17 @@ void fc_reward_runtime_begin_episode( fc_reward_sync_progress_state(state, runtime); } -static FcRewardThreatContext reward_collect_threat_context( - const FcState* state) { - FcRewardThreatContext ctx; +static int reward_has_threat(const FcState* state) { const FcPlayer* p = &state->player; - - memset(&ctx, 0, sizeof(ctx)); - for (int i = 0; i < FC_MAX_NPCS; i++) { const FcNpc* n = &state->npcs[i]; - if (!n->active || n->is_dead) continue; - - int dist = fc_distance_to_npc(p->x, p->y, n); - if (dist <= 1) { - ctx.melee_pressure_npcs++; - if (n->npc_type == NPC_TOK_XIL) ctx.tokxil_melee = 1; - if (n->npc_type == NPC_KET_ZEK) ctx.ketzek_melee = 1; - } - - if (dist <= n->attack_range) { - ctx.any_threat = 1; - } + if (n->active && !n->is_dead && + fc_distance_to_npc(p->x, p->y, n) <= n->attack_range) return 1; } - for (int i = 0; i < p->num_pending_hits; i++) { - const FcPendingHit* ph = &p->pending_hits[i]; - if (!ph->active) continue; - ctx.any_threat = 1; + if (p->pending_hits[i].active) return 1; } - - return ctx; + return 0; } FcRewardBreakdown fc_reward_compute_breakdown( @@ -5113,7 +4769,7 @@ FcRewardBreakdown fc_reward_compute_breakdown( memset(&out, 0, sizeof(out)); fc_write_reward_features(state, out.raw); - out.threat_ctx = reward_collect_threat_context(state); + out.any_threat = reward_has_threat(state); prayer_reward_idle = (runtime->ticks_since_attack >= 1 && out.raw[FC_RWD_ATTACK_ATTEMPT] <= 0.0f); @@ -5128,7 +4784,7 @@ FcRewardBreakdown fc_reward_compute_breakdown( ((start_work > 0.0f) ? start_work : 0.0f); /* Scalar reward uses raw net required-work removed. The cave-progress - * delta stays normalized for observations/logs, while this channel pays + * delta stays normalized for observations, while this channel pays * for actual HP/work removed and goes negative when healing restores * work. The optional negative multiplier makes restored work more costly * without changing positive progress. Multiplying by the wave's start @@ -5143,20 +4799,12 @@ FcRewardBreakdown fc_reward_compute_breakdown( runtime->last_required_work_remaining = work_remaining; runtime->last_current_wave_progress = wave_progress; runtime->last_cave_progress = cave_progress; - runtime->last_progress_delta = progress_delta; - runtime->last_progress_reward = out.progress; - runtime->last_net_required_work_removed = net_work_removed; if (net_work_removed > 0.0001f) { runtime->ticks_since_positive_progress = 0; - runtime->positive_progress_ticks++; } else { runtime->ticks_since_positive_progress++; - if (net_work_removed < -0.0001f) { - runtime->negative_progress_ticks++; - } else { - runtime->zero_progress_ticks++; - } + if (net_work_removed >= -0.0001f) runtime->zero_progress_ticks++; } if (params->shape_no_progress_start_1 > 0 && @@ -5207,7 +4855,7 @@ FcRewardBreakdown fc_reward_compute_breakdown( out.raw[FC_RWD_CORRECT_DANGER_PRAY] * params->w_correct_danger_prayer; } out.prayer_lost = out.raw[FC_RWD_PRAYER_LOST] * params->w_prayer_lost; - if (p->prayer != PRAYER_NONE && !out.threat_ctx.any_threat) { + if (p->prayer != PRAYER_NONE && !out.any_threat) { out.unnecessary_prayer = params->shape_unnecessary_prayer_penalty; } @@ -5300,6 +4948,8 @@ FcRewardBreakdown fc_reward_compute_breakdown( out.invalid_action + out.tick_penalty; + runtime->npc_healing_total += (float)state->npc_heal_amount_this_tick; + runtime->jad_healing_total += (float)state->jad_heal_amount_this_tick; return out; } @@ -5629,7 +5279,6 @@ void fc_reset(FcState* state, uint32_t seed) { /* Spawn wave 1 NPCs */ state->current_wave = 1; state->next_spawn_index = 0; - state->wave_start_tick = 0; fc_wave_spawn(state, 1); } @@ -6124,16 +5773,6 @@ void fc_write_reward_features(const FcState* state, float* out) { out[FC_RWD_PRAYER_LOST] = (float)state->prayer_lost_this_tick / 10.0f; } -void fc_action_invalid_classes(const FcState* state, - const int actions[FC_NUM_ACTION_HEADS], - int out_classes[FC_INVALID_ACTION_CLASS_COUNT]) { - /* Keep this aligned with the Puffer-facing policy mask surface: - * move, attack, and prayer only. Consumable and path-target heads remain - * canonical core actions, but are not emitted by the no-supplies policy. */ - out_classes[FC_INVALID_ACTION_MOVE] = !move_action_valid(state, actions[0]); - out_classes[FC_INVALID_ACTION_ATTACK] = !attack_action_valid(state, actions[1]); - out_classes[FC_INVALID_ACTION_PRAYER] = !prayer_action_valid(actions[2]); -} void fc_write_mask(const FcState* state, float* out) { /* Set all to valid, then mask invalid */ @@ -6277,9 +5916,6 @@ static void clear_per_tick_flags(FcState* state) { state->wrong_danger_prayer = 0; state->attack_attempt_this_tick = 0; state->invalid_action_this_tick = 0; - for (int i = 0; i < FC_INVALID_ACTION_CLASS_COUNT; i++) { - state->invalid_action_class_this_tick[i] = 0; - } state->movement_this_tick = 0; state->idle_this_tick = 0; state->food_used_this_tick = 0; @@ -6287,15 +5923,10 @@ static void clear_per_tick_flags(FcState* state) { state->jad_heal_procs_this_tick = 0; state->npc_heal_procs_this_tick = 0; state->npc_heal_amount_this_tick = 0; - state->mejkot_heal_amount_this_tick = 0; state->jad_heal_amount_this_tick = 0; FcPlayer* p = &state->player; p->damage_taken_this_tick = 0; - p->hit_style_this_tick = 0; - p->hit_source_npc_type = 0; - p->hit_locked_prayer_this_tick = 0; - p->hit_blocked_this_tick = 0; p->hit_landed_this_tick = 0; p->food_eaten_this_tick = 0; p->potion_used_this_tick = 0; @@ -6343,44 +5974,6 @@ static int npc_slot_to_index(const FcState* state, int slot) { /* Process player actions */ /* ======================================================================== */ -static void record_player_action_selection( - FcState* state, const int actions[FC_NUM_ACTION_HEADS]) { - int act_move = actions[0]; - int act_attack = actions[1]; - int act_prayer = actions[2]; - int invalid_classes[FC_INVALID_ACTION_CLASS_COUNT]; - - if (state->npcs_remaining > 0) { - if (act_move == FC_MOVE_IDLE) { - state->ep_action_move_idle_ticks++; - } else if (act_move >= FC_MOVE_WALK_N && act_move < FC_MOVE_RUN_N) { - state->ep_action_move_walk_ticks++; - } else if (act_move >= FC_MOVE_RUN_N && act_move < FC_MOVE_DIM) { - state->ep_action_move_run_ticks++; - } - - if (act_attack == FC_ATTACK_NONE) { - state->ep_action_attack_none_ticks++; - } else { - state->ep_action_attack_target_ticks++; - } - - if (act_prayer == 0) { - state->ep_action_prayer_noop_ticks++; - } else { - state->ep_action_prayer_cmd_ticks++; - } - } - - fc_action_invalid_classes(state, actions, invalid_classes); - for (int i = 0; i < FC_INVALID_ACTION_CLASS_COUNT; i++) { - state->invalid_action_class_this_tick[i] = invalid_classes[i]; - if (invalid_classes[i]) { - state->invalid_action_this_tick = 1; - state->ep_invalid_action_classes[i]++; - } - } -} static void apply_player_prayer_action( FcState* state, int action, FcPrayerTransition* transition) { @@ -6413,12 +6006,6 @@ static void apply_player_supplies(FcState* state, int eat_action, ? &player->food_timer : &player->combo_timer; int cooldown = eat_action == FC_EAT_SHARK ? FC_FOOD_COOLDOWN_TICKS : FC_COMBO_EAT_TICKS; - int pre_eat_hp = player->current_hp; - state->ep_food_pre_hp_sum += pre_eat_hp; - int hp_missing = player->max_hp - player->current_hp; - if (heal > hp_missing) state->ep_food_overhealed++; - state->ep_food_eaten++; - player->total_food_eaten++; player->current_hp += heal; if (player->current_hp > player->max_hp) { player->current_hp = player->max_hp; @@ -6431,17 +6018,8 @@ static void apply_player_supplies(FcState* state, int eat_action, if (drink_action == FC_DRINK_PRAYER_POT && fc_drink_action_valid(state, drink_action)) { - int pre_drink_prayer = player->current_prayer; - state->ep_pot_pre_prayer_sum += pre_drink_prayer; - int prayer_missing = player->max_prayer - player->current_prayer; - state->ep_pots_used++; - if (player->current_prayer > player->max_prayer / 5) { - state->ep_pots_wasted++; - } - player->total_potions_used++; int restore = fc_prayer_potion_restore( FC_LOADOUTS[FC_ACTIVE_LOADOUT].prayer_lvl); - if (restore > prayer_missing) state->ep_pots_overrestored++; player->current_prayer += restore; if (player->current_prayer > player->max_prayer) { player->current_prayer = player->max_prayer; @@ -6520,9 +6098,7 @@ static void launch_player_attack(FcState* state, FcNpc* target, int distance) { state->render_events.player_attack_target_y = target->y; state->render_events.player_attack_target_size = target->size; state->render_events.player_attack_hit_delay_ticks = delay; - if (target->npc_type > NPC_NONE && target->npc_type < NPC_TYPE_COUNT) { - state->ep_attack_cycles_to_npc_type[target->npc_type]++; - } + player->attack_timer = player->weapon_speed; if (player->weapon_uses_ammo && player->ammo_count > 0) { fc_items_spend_ammo(player); @@ -6530,24 +6106,17 @@ static void launch_player_attack(FcState* state, FcNpc* target, int distance) { player->hit_landed_this_tick = 1; } -static void record_player_target_held(FcState* state, const FcNpc* target) { - if (target->npc_type > NPC_NONE && target->npc_type < NPC_TYPE_COUNT) { - state->ep_target_ticks_by_npc_type[target->npc_type]++; - } - state->ep_target_held_ticks++; -} -static int process_player_target(FcState* state, - int explicit_directional_move, - int explicit_tile_move) { +static void process_player_target(FcState* state, + int explicit_directional_move, + int explicit_tile_move) { FcPlayer* player = &state->player; - int metrics_recorded = 0; /* Like Void CombatMovement: approach until the current target is in range, * then attack on cooldown and remain stationary for this tick. */ if (player->attack_target_idx < 0 || (player->weapon_uses_ammo && player->ammo_count <= 0)) { - return metrics_recorded; + return; } FcNpc* target = &state->npcs[player->attack_target_idx]; @@ -6557,7 +6126,7 @@ static int process_player_target(FcState* state, player->approach_target_x = -1; player->approach_target_y = -1; player->approach_target_size = 0; - return metrics_recorded; + return; } int dist = fc_distance_to_npc(player->x, player->y, target); @@ -6573,17 +6142,6 @@ static int process_player_target(FcState* state, } int target_ready = player->attack_timer <= 0; - record_player_target_held(state, target); - metrics_recorded = 1; - if (target_can_fire) { - state->ep_target_in_range_los_ticks++; - if (!target_ready) { - state->ep_attack_cooldown_wait_ticks++; - } - } else { - state->ep_target_out_of_range_or_los_ticks++; - } - int route_endpoint_can_fire = 0; int target_moved = player->approach_target_x != target->x || @@ -6630,12 +6188,6 @@ static int process_player_target(FcState* state, if (target_can_fire && target_ready) { launch_player_attack(state, target, dist); } - - if (target_can_fire && target_ready && - !state->attack_attempt_this_tick) { - state->ep_ready_but_no_attack_ticks++; - } - return metrics_recorded; } static int process_player_movement(FcState* state, int move_action, @@ -6754,36 +6306,11 @@ static void update_player_run_energy(FcPlayer* player, int moved_steps) { } } -static void record_player_action_outcome(FcState* state, int was_attack_ready, - int target_metrics_recorded) { - FcPlayer* player = &state->player; - - if (state->npcs_remaining > 0 && !target_metrics_recorded) { - if (player->attack_target_idx >= 0) { - FcNpc* target = &state->npcs[player->attack_target_idx]; - if (target->active && !target->is_dead) { - record_player_target_held(state, target); - } else { - state->ep_no_target_ticks++; - } - } else { - state->ep_no_target_ticks++; - } - } - - if (was_attack_ready) { - state->ep_attack_ready_ticks++; - if (state->attack_attempt_this_tick) { - state->ep_attack_attempt_ticks++; - } - } -} static void process_player_actions(FcState* state, const int actions[FC_NUM_ACTION_HEADS], FcPrayerTransition* prayer_transition) { FcPlayer* p = &state->player; - int was_attack_ready = (p->attack_timer <= 0 && state->npcs_remaining > 0); int act_move = actions[0]; int act_attack = actions[1]; @@ -6797,8 +6324,10 @@ static void process_player_actions(FcState* state, int explicit_move = explicit_directional_move || explicit_tile_move; int explicit_attack = (act_attack > FC_ATTACK_NONE); int requested_attack_idx = -1; - int target_metrics_recorded = 0; - record_player_action_selection(state, actions); + /* Invalid-action reward is binary across the three policy heads. */ + state->invalid_action_this_tick = + !move_action_valid(state, act_move) || + !attack_action_valid(state, act_attack) || !prayer_action_valid(act_prayer); /* Resolve attack slots against the pre-action NPC slot ordering. The action * was chosen from the previous observation, so movement later in this tick @@ -6814,15 +6343,13 @@ static void process_player_actions(FcState* state, prepare_player_interaction(state, explicit_move, explicit_attack, requested_attack_idx); - target_metrics_recorded = process_player_target( + process_player_target( state, explicit_directional_move, explicit_tile_move); int moved_steps = process_player_movement( state, act_move, act_target_x, act_target_y, explicit_move, explicit_attack); update_player_run_energy(p, moved_steps); - record_player_action_outcome(state, was_attack_ready, - target_metrics_recorded); } /* ======================================================================== */ @@ -7031,8 +6558,6 @@ void fc_tick(FcState* state, const int actions[FC_NUM_ACTION_HEADS]) { state->ep_ticks_pray_range++; if (state->player.prayer_at_tick_start == PRAYER_PROTECT_MAGIC) state->ep_ticks_pray_magic++; - if (state->player.prayer_changed_this_tick) - state->ep_prayer_switches++; if (state->current_wave >= 63) state->ep_reached_wave_63 = 1; @@ -7374,13 +6899,6 @@ void fc_wave_spawn(FcState* state, int wave_num) { /* Wave advancement */ /* ======================================================================== */ -void fc_wave_record_current_duration(FcState* state) { - int wave_ticks = state->tick - state->wave_start_tick; - if (wave_ticks > state->ep_max_wave_ticks) { - state->ep_max_wave_ticks = wave_ticks; - state->ep_max_wave_ticks_wave = state->current_wave; - } -} int fc_wave_check_advance(FcState* state) { /* Don't advance if wave hasn't started or NPCs still alive */ @@ -7390,7 +6908,6 @@ int fc_wave_check_advance(FcState* state) { state->wave_just_cleared = 1; - fc_wave_record_current_duration(state); /* Check if all waves complete */ if (state->current_wave >= FC_NUM_WAVES) { @@ -7402,7 +6919,6 @@ int fc_wave_check_advance(FcState* state) { state->current_wave++; state->jad_healers_spawned = 0; state->jad_healer_spawn_generations = 0; - state->wave_start_tick = state->tick; fc_wave_spawn(state, state->current_wave); return 1; diff --git a/ocean/fight_caves/tools.py b/ocean/fight_caves/tools.py index 8272874064..268889f9a1 100644 --- a/ocean/fight_caves/tools.py +++ b/ocean/fight_caves/tools.py @@ -866,13 +866,14 @@ def validate_checkpoint_marker(marker: Path, preflight: dict[str, Any]) -> None: raise ContractError(f"invalid checkpoint contract sidecar: {marker}") from exc actual = payload.get("contract") expected = preflight["contract"] - # v5 only adds manually controlled inventory/equipment to the state hash. - # Policy weights do not serialize that state; allow only v4 -> v5 when - # every other contract field is identical, just as in the v38 evaluator. + # v5 adds inventory/equipment to the hash; v6 removes retired analytics. + # Neither changes policy weights. Accept these forward migrations only + # when every other contract field is identical. if isinstance(actual, dict): actual = dict(actual) - if actual.get("state_hash_version") == 4 and expected.get("state_hash_version") == 5: - actual["state_hash_version"] = 5 + migration = (actual.get("state_hash_version"), expected.get("state_hash_version")) + if migration in ((4, 5), (4, 6), (5, 6)): + actual["state_hash_version"] = expected["state_hash_version"] if actual != expected: raise ContractError( f"checkpoint contract does not match compiled Fight Caves: {marker}" diff --git a/ocean/fight_caves/viewer.c b/ocean/fight_caves/viewer.c index 13f9fc4724..18c67511da 100644 --- a/ocean/fight_caves/viewer.c +++ b/ocean/fight_caves/viewer.c @@ -787,97 +787,35 @@ static void toggle_godmode(ViewerState* v) { } static void print_policy_episode_summary(const ViewerState* v) { - const FcState* s = &v->state; FcEpisodeSummary summary; - fc_episode_summary_build(s, s->tick, &summary); - + fc_episode_summary_build(&v->state, &v->reward_runtime, v->state.tick, &summary); fprintf(stderr, "[policy-pipe] episode_summary " "{\"episode\":%d,\"seed\":%u,\"terminal\":\"%s\"," - "\"env/episode_length\":%d," + "\"env/zero_progress_ticks\":%d," "\"env/wave_reached\":%d," - "\"env/most_npcs_slayed\":%d," - "\"env/prayer_uptime_melee\":%.6f," - "\"env/prayer_uptime_range\":%.6f," - "\"env/prayer_uptime_magic\":%.6f," - "\"env/correct_prayer\":%d," "\"env/wrong_prayer_hits\":%d," - "\"env/no_prayer_hits\":%d," - "\"env/prayer_switches\":%d," - "\"env/damage_blocked\":%d," - "\"env/dmg_taken_avg\":%d," - "\"env/attack_when_ready_rate\":%.6f," - "\"env/tokxil_melee_ticks\":%d," - "\"env/ketzek_melee_ticks\":%d," - "\"env/max_wave_ticks\":%d," - "\"env/max_wave_ticks_wave\":%d," "\"env/reached_wave_63\":%d," "\"env/jad_kill_rate\":%d," - "\"env/target_held_ticks\":%d," - "\"env/no_target_ticks\":%d," - "\"env/target_in_range_los_ticks\":%d," - "\"env/target_out_of_range_or_los_ticks\":%d," - "\"env/attack_cooldown_wait_ticks\":%d," - "\"env/ready_but_no_attack_ticks\":%d," - "\"env/action_move_idle_ticks\":%d," - "\"env/action_move_walk_ticks\":%d," - "\"env/action_move_run_ticks\":%d," - "\"env/action_attack_none_ticks\":%d," - "\"env/action_attack_target_ticks\":%d," - "\"env/action_prayer_noop_ticks\":%d," - "\"env/action_prayer_cmd_ticks\":%d", - v->policy_episode_count + 1, - v->seed, - fc_terminal_name(s->terminal), - summary.episode_length, + "\"env/prayer_uptime_range\":%.6f," + "\"env/prayer_uptime_melee\":%.6f," + "\"env/prayer_uptime_magic\":%.6f," + "\"env/npc_healing_total\":%.6f," + "\"env/jad_healing_total\":%.6f," + "\"env/episode_length\":%d," + "\"env/n\":1.0}\n", + v->policy_episode_count + 1, v->seed, fc_terminal_name(v->state.terminal), + summary.zero_progress_ticks, summary.wave_reached, - summary.npcs_slayed, - summary.prayer_uptime_melee, - summary.prayer_uptime_range, - summary.prayer_uptime_magic, - summary.correct_prayer, summary.wrong_prayer_hits, - summary.no_prayer_hits, - summary.prayer_switches, - summary.damage_blocked, - summary.damage_taken, - summary.attack_when_ready_rate, - summary.tokxil_melee_ticks, - summary.ketzek_melee_ticks, - summary.max_wave_ticks, - summary.max_wave_ticks_wave, summary.reached_wave_63, - summary.jad_killed, - summary.target_held_ticks, - summary.no_target_ticks, - summary.target_in_range_los_ticks, - summary.target_out_of_range_or_los_ticks, - summary.attack_cooldown_wait_ticks, - summary.ready_but_no_attack_ticks, - summary.action_move_idle_ticks, - summary.action_move_walk_ticks, - summary.action_move_run_ticks, - summary.action_attack_none_ticks, - summary.action_attack_target_ticks, - summary.action_prayer_noop_ticks, - summary.action_prayer_cmd_ticks); - - for (int i = 1; i < NPC_TYPE_COUNT; i++) { - const char* npc = fc_episode_npc_metric_name(i); - fprintf(stderr, - ",\"env/dmg_to_%s\":%d" - ",\"env/resolved_hits_to_%s\":%d" - ",\"env/damaging_hits_to_%s\":%d" - ",\"env/attack_cycles_to_%s\":%d" - ",\"env/target_ticks_%s\":%d", - npc, summary.damage_to_npc_type[i], - npc, summary.resolved_hits_to_npc_type[i], - npc, summary.damaging_hits_to_npc_type[i], - npc, summary.attack_cycles_to_npc_type[i], - npc, summary.target_ticks_by_npc_type[i]); - } - - fprintf(stderr, ",\"env/n\":1.0}\n"); + summary.jad_kill_rate, + summary.prayer_uptime_range, + summary.prayer_uptime_melee, + summary.prayer_uptime_magic, + summary.npc_healing_total, + summary.jad_healing_total, + summary.episode_length); } static void sync_player_appearance(ViewerState *v) { diff --git a/tests/fight_caves.c b/tests/fight_caves.c index 44ca7669c8..5c0b0fb276 100644 --- a/tests/fight_caves.c +++ b/tests/fight_caves.c @@ -82,7 +82,7 @@ static void check_observation(const FcState* state) { } static int core_contract_test(void) { - _Static_assert(FC_STATE_HASH_VERSION == 5, "equipment state hash version drifted"); + _Static_assert(FC_STATE_HASH_VERSION == 6, "analytics state hash version drifted"); _Static_assert(FC_POLICY_OBS_SIZE == 286, "policy observation contract drifted"); _Static_assert(FC_PUFFER_OBS_SIZE == 320, "Puffer observation contract drifted"); _Static_assert(FC_PUFFER_MASK_SIZE == 34, "Puffer mask contract drifted"); @@ -114,7 +114,7 @@ static int core_contract_test(void) { if (!fc_is_terminal(&first)) fail("test trajectory did not exercise a terminal transition"); - if (steps != 483 || fc_state_hash(&first) != 0x5fcadd73u) + if (steps != 483 || fc_state_hash(&first) != 0x5c53c26du) fail("fixed-seed trajectory changed; review and update the contract fixture intentionally"); printf("core_contract_test: passed (%d steps, hash=%08x)\n", @@ -145,6 +145,73 @@ static int item_slot(const FcPlayer *p, int id) { return -1; } +static int episode_analytics_test(void) { + FcState s; + FcRewardRuntime runtime; + FcRewardParams params = fc_reward_default_params(); + FcEpisodeSummary summary; + reset(&s, 0); + memset(s.npcs, 0, sizeof(s.npcs)); + s.current_wave = 63; + int jad = fc_spawn_npc_first_free(&s, NPC_TZTOK_JAD, 10, 10); + CHECK(jad >= 0); + s.npcs[jad].current_hp -= 100; + fc_reward_runtime_begin_episode(&runtime, &s); + + /* Idle, damage, healing, idle: only the two idle ticks count as zero + * progress. Healing totals use effective HP restored, capped at max HP. */ + fc_reward_compute_breakdown(&s, ¶ms, &runtime); + s.npcs[jad].current_hp -= 10; + fc_reward_compute_breakdown(&s, ¶ms, &runtime); + CHECK(apply_npc_heal(&s, &s.npcs[jad], &s.npcs[jad], 200) == 110); + fc_reward_compute_breakdown(&s, ¶ms, &runtime); + clear_per_tick_flags(&s); + fc_reward_compute_breakdown(&s, ¶ms, &runtime); + CHECK(runtime.zero_progress_ticks == 2); + CHECK(runtime.npc_healing_total == 110 && runtime.jad_healing_total == 110); + + /* No prayer and correct prayer are excluded from wrong-prayer hits, + * including when an attack rolls zero damage. */ + const int prayers[] = {PRAYER_NONE, PRAYER_PROTECT_MAGIC, PRAYER_PROTECT_RANGE}; + for (int i = 0; i < 3; i++) { + CHECK(fc_queue_pending_hit(s.player.pending_hits, &s.player.num_pending_hits, + FC_MAX_PENDING_HITS, 0, 1, ATTACK_RANGED, jad, 0)); + s.player.pending_hits[i].prayer_snapshot = prayers[i]; + } + fc_resolve_player_pending_hits(&s); + CHECK(s.ep_wrong_prayer_hits == 1); + + const int actions[FC_NUM_ACTION_HEADS] = {0}; + for (int prayer = PRAYER_PROTECT_MELEE; prayer <= PRAYER_PROTECT_MAGIC; prayer++) { + s.player.prayer = prayer; + fc_step(&s, actions); + } + complete_fight_caves(&s); + fc_episode_summary_build(&s, &runtime, 3, &summary); + CHECK(summary.wave_reached == 63 && summary.reached_wave_63 == 1); + CHECK(summary.jad_kill_rate == 1 && summary.wrong_prayer_hits == 1); + CHECK(summary.prayer_uptime_melee == 1.0f / 3.0f); + CHECK(summary.prayer_uptime_range == 1.0f / 3.0f); + CHECK(summary.prayer_uptime_magic == 1.0f / 3.0f); + + fc_reset(&s, 101); + fc_reward_runtime_begin_episode(&runtime, &s); + fc_episode_summary_build(&s, &runtime, 0, &summary); + CHECK(summary.wave_reached == 1 && summary.episode_length == 0); + CHECK(summary.zero_progress_ticks == 0 && summary.wrong_prayer_hits == 0); + CHECK(summary.reached_wave_63 == 0 && summary.jad_kill_rate == 0); + CHECK(summary.npc_healing_total == 0 && summary.jad_healing_total == 0); + CHECK(summary.prayer_uptime_melee == 0 && summary.prayer_uptime_range == 0 && + summary.prayer_uptime_magic == 0); + /* Multiple invalid heads still incur one invalid-action penalty. */ + const int invalid_actions[FC_NUM_ACTION_HEADS] = {999, 999, 999}; + fc_step(&s, invalid_actions); + CHECK(s.invalid_action_this_tick == 1); + fc_destroy(&s); + puts("episode_analytics_test: progress, healing, prayers, Jad and reset passed"); + return 0; +} + static int loadout_totals(void) { /* Pre-cleanup preset totals, independent of the item definitions. */ static const FcPlayer expected[FC_NUM_LOADOUTS] = { @@ -715,7 +782,7 @@ static int equipment_appearance_test(void) { int main(void) { wave_rotation_test(); - if (core_contract_test() || equipment_test()) return 1; + if (core_contract_test() || equipment_test() || episode_analytics_test()) return 1; #ifdef FC_VIEWER_TEST if (context_menu_test() || click_feedback_test() || model_picking_test() || equipment_appearance_test()) return 1; diff --git a/tests/test_fight_caves.py b/tests/test_fight_caves.py index 311fe72db2..b797c3071b 100644 --- a/tests/test_fight_caves.py +++ b/tests/test_fight_caves.py @@ -229,8 +229,9 @@ def test_checkpoint_format_rejects_unknown_or_missing_file(tmp_path): @pytest.mark.parametrize("saved,current,accepted", [ (4, 5, True), (5, 5, True), (5, 4, False), (3, 5, False), (6, 5, False), + (4, 6, True), (5, 6, True), (6, 6, True), (3, 6, False), (7, 6, False), ]) -def test_equipment_hash_checkpoint_migration_is_directional(tmp_path, saved, current, accepted): +def test_state_hash_checkpoint_migration_is_directional(tmp_path, saved, current, accepted): expected = {"state_hash_version": current, "puffer_obs_size": 320, "puffer_action_dims": [17, 9, 8], "reward_version": "unchanged"} actual = dict(expected, state_hash_version=saved) @@ -249,10 +250,11 @@ def test_equipment_hash_checkpoint_migration_is_directional(tmp_path, saved, cur ("puffer_obs_size", 319), ("puffer_action_dims", [17, 9, 8, 14]), ("reward_version", "different"), ("unknown_field", 1), ]) -def test_equipment_hash_migration_does_not_hide_other_contract_changes(tmp_path, field, value): - expected = {"state_hash_version": 5, "puffer_obs_size": 320, +@pytest.mark.parametrize("saved,current", [(4, 5), (4, 6), (5, 6)]) +def test_state_hash_migration_does_not_hide_other_contract_changes(tmp_path, saved, current, field, value): + expected = {"state_hash_version": current, "puffer_obs_size": 320, "puffer_action_dims": [17, 9, 8], "reward_version": "unchanged"} - actual = dict(expected, state_hash_version=4) + actual = dict(expected, state_hash_version=saved) actual[field] = value marker = tmp_path / "contract.json" marker.write_text(json.dumps({"contract": actual})) @@ -403,6 +405,21 @@ def puffer_main() -> int: break if terminal_count == 0: fail("no terminal/autoreset boundary was observed") + metrics = vec.log() + expected_metrics = { + "zero_progress_ticks", "wave_reached", "wrong_prayer_hits", + "reached_wave_63", "jad_kill_rate", "prayer_uptime_range", + "prayer_uptime_melee", "prayer_uptime_magic", "npc_healing_total", + "jad_healing_total", "episode_length", "n", + } + if set(metrics) != expected_metrics: + fail(f"unexpected episode metrics: {set(metrics) ^ expected_metrics}") + if metrics["n"] != terminal_count or metrics["episode_length"] <= 0: + fail("completed episode metrics were lost during autoreset") + if not all(np.isfinite(value) for value in metrics.values()): + fail("episode metrics contain non-finite values") + if vec.log(): + fail("episode metrics were not drained after logging") finally: vec.close()