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/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..b493494b0a --- /dev/null +++ b/ocean/fight_caves/CMakeLists.txt @@ -0,0 +1,48 @@ +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 --fast") +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() + +# 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}) + +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/README.md b/ocean/fight_caves/README.md new file mode 100644 index 0000000000..26954d7614 --- /dev/null +++ b/ocean/fight_caves/README.md @@ -0,0 +1,169 @@ +# Fight Caves + +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 in PufferTank 4.0 + +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. + +> **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. 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 fight-caves-4.0.5 https://github.com/jordanbailey00/PufferLib.git PufferLib-fight-caves +cd PufferLib-fight-caves +uv pip install --no-deps -e . +``` + +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. + +## Train + +```bash +./build.sh fight_caves +puffer train fight_caves +``` + +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. + +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 +./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 +`--fast` for Puffer's debug/sanitizer build. + +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. + +## Replay a checkpoint + +With the same backend and policy architecture used for training: + +```bash +unset __GLX_VENDOR_LIBRARY_NAME +puffer eval fight_caves --load-model-path latest +``` + +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. + +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 + +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 line-of-sight maps. +- `resources/fight_caves/viewer/`: models, equipment parts, animations, terrain, + textures, UI sprites/fonts, and the minimap raster. + +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. + +Optional one-time installation or verification: + +```bash +python ocean/fight_caves/tools.py setup --all +python ocean/fight_caves/tools.py setup --all --verify-only +``` + +OSRS assets are distributed separately and are not covered by PufferLib's +software license; see `resources/fight_caves/ASSET_NOTICE.md`. + +## Optional tools + +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 +./build.sh fight_caves --cpu +./build.sh fight_caves --fast +python ocean/fight_caves/tools.py eval --ckpt /path/to/checkpoint.bin --episodes 1 +``` + +It uses the same viewer executable. Rebuild the CUDA backend before returning +to native training. `./fight_caves --benchmark` runs the headless benchmark. + +Maintainer tests are explicitly invoked, not part of normal setup: + +```bash +bash tests/fight_caves.sh test --core +bash tests/fight_caves.sh test --all +``` + +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/assets.h b/ocean/fight_caves/assets.h new file mode 100644 index 0000000000..725fbf81b5 --- /dev/null +++ b/ocean/fight_caves/assets.h @@ -0,0 +1,3108 @@ +#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); +/* 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); + + +/* 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); + +#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 +#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 "raymath.h" +#include +#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; +} + +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; + 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 + +/* 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/binding.c b/ocean/fight_caves/binding.c new file mode 100644 index 0000000000..3ca037e6a5 --- /dev/null +++ b/ocean/fight_caves/binding.c @@ -0,0 +1,204 @@ +/* + * 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, "zero_progress_ticks", log->zero_progress_ticks); + dict_set(out, "wave_reached", log->wave_reached); + dict_set(out, "wrong_prayer_hits", log->wrong_prayer_hits); + dict_set(out, "reached_wave_63", log->reached_wave_63); + dict_set(out, "jad_kill_rate", log->jad_kill_rate); + 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, "jad_healing_total", log->jad_healing_total); + dict_set(out, "episode_length", log->episode_length); +} diff --git a/ocean/fight_caves/fight_caves.c b/ocean/fight_caves/fight_caves.c new file mode 100644 index 0000000000..86d24671ee --- /dev/null +++ b/ocean/fight_caves/fight_caves.c @@ -0,0 +1,88 @@ +/* + * fight_caves.c — Puffer's standalone playable Fight Caves executable. + * + * 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 + +static int benchmark(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; +} + +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 new file mode 100644 index 0000000000..bf0d4274a7 --- /dev/null +++ b/ocean/fight_caves/fight_caves.h @@ -0,0 +1,265 @@ +/* + * 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: lazy full viewer for Puffer's standard evaluation loop + * - c_close: cleanup + * + * Single-agent environment (num_agents=1 always for Fight Caves). + */ + +#include +#include +#include + +/* Shared simulation and contract implementation. */ +#include "simulation.h" +#define FC_VIEWER_EMBEDDED +#include "viewer.c" +#undef FC_VIEWER_EMBEDDED +/* ======================================================================== */ +/* PufferLib Log struct (required fields) */ +/* ======================================================================== */ + +typedef struct { + float zero_progress_ticks; + float wave_reached; + float wrong_prayer_hits; + float reached_wave_63; + float jad_kill_rate; + float prayer_uptime_range; + float prayer_uptime_melee; + float prayer_uptime_magic; + float npc_healing_total; + float jad_healing_total; + 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->zero_progress_ticks += (float)summary->zero_progress_ticks; + log->wave_reached += (float)summary->wave_reached; + log->wrong_prayer_hits += (float)summary->wrong_prayer_hits; + log->reached_wave_63 += (float)summary->reached_wave_63; + 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; +} + +/* ======================================================================== */ +/* 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; + ViewerState* viewer; /* NULL throughout headless training */ + + /* 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; + + /* 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); + if (env->viewer) env->viewer->pending_reward_breakdown = breakdown; + fc_reward_sync_progress_state(&env->state, &env->reward_runtime); + return breakdown.total; +} + + +/* ======================================================================== */ +/* 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; + 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); + /* Compute initial observations */ + fc_puffer_write_obs(env); + if (env->viewer) env->viewer->reset_state = env->state; +} + +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); + + /* 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); + + /* 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; + 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.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) { + 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/render.h b/ocean/fight_caves/render.h new file mode 100644 index 0000000000..7de27a8e85 --- /dev/null +++ b/ocean/fight_caves/render.h @@ -0,0 +1,3544 @@ +#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); +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, + 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) { + 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; + 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); + 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( + 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; + 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); + 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(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); + 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; + 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; + 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:%d", b->any_threat); + 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..a8b970ed07 --- /dev/null +++ b/ocean/fight_caves/simulation.h @@ -0,0 +1,6928 @@ +#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 and viewer. Combat bonuses are derived from item definitions. + */ + +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_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 = 13, + FC_EQUIP_SLOT_LEGS = 7, + FC_EQUIP_SLOT_HANDS = 9, + FC_EQUIP_SLOT_FEET = 10, + FC_EQUIP_SLOT_RING = 12, +} FcEquipmentSlot; + +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; + 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 ammo; + int equipment_count; + int equipment[FC_LOADOUT_EQUIP_MAX]; +} FcLoadout; + +typedef enum { + FC_WEAPON_GENERIC_RANGED = 0, + FC_WEAPON_TWISTED_BOW = 1, + FC_WEAPON_BOW_OF_FAERDHINEN = 2, + FC_WEAPON_UNARMED = 3 +} FcWeaponKind; + +#define FC_NUM_LOADOUTS FC_LOADOUT_COUNT + +extern const FcLoadout FC_LOADOUTS[FC_NUM_LOADOUTS]; + +/* 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; + + +/* 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 */ +/* ======================================================================== */ + +#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; + + /* 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_landed_this_tick; + int food_eaten_this_tick; + int potion_used_this_tick; + int prayer_changed_this_tick; + + 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; + +/* ======================================================================== */ +/* 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 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 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 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_wrong_prayer_hits; /* hits where prayer active but wrong type */ + int ep_reached_wave_63; /* 1 if episode reached Jad wave */ + int ep_jad_killed; /* 1 if Jad died at any point this episode */ +} 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. + */ + + +/* 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 */ + +/* Attack-route range 0 means cardinal melee contact, not ranged LOS. */ +#define FC_ROUTE_MELEE_RANGE 0 + +/* ======================================================================== */ +/* 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; + int ticks_since_positive_progress; + int zero_progress_ticks; + float npc_healing_total; + float jad_healing_total; +} FcRewardRuntime; + +/* Episode analytics shared by training and checkpoint evaluation. */ +typedef struct { + 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]; + + 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; + int any_threat; +} FcRewardBreakdown; + +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); + +/* 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 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. + * + * 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); + + +/* 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 + * 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); + + +/* 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, + 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); + + +/* 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; + state->damage_taken_this_tick += 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; + } + + if (locked_prayer != PRAYER_NONE && !blocked) + state->ep_wrong_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; +} + +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++; + } + + 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 (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, const FcRewardRuntime* runtime, + int episode_length, FcEpisodeSummary* summary) { + memset(summary, 0, sizeof(*summary)); + summary->episode_length = episode_length; + summary->wave_reached = state->current_wave; + 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; + 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; + } +} + + +/* Hash */ +#include +#include + +/* + * 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 + * 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_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); + 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; +} + +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->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); + 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->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_wrong_prayer_hits); + FC_HASH_I32(state->ep_reached_wave_63); + FC_HASH_I32(state->ep_jad_killed); + 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 + +/* 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 FcItemDef *item = fc_item_definition(loadout->equipment[i]); + p->equipment[item->slot] = (FcItemStack){ + item->id, item->slot == FC_EQUIP_SLOT_AMMO ? loadout->ammo : 1, + 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 */ +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", + .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, + .ammo = 50000, + .equipment_count = 7, + .equipment = { + 1169, + 9185, + 2503, + 9143, + 2497, + 2491, + 6328, + }, + }, + /* [FC_LOADOUT_SOTA_TBOW] End-game — Masori (f) + Twisted Bow */ + { + .name = "B: Masori (f) + TBow", + .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, + .ammo = 50000, + .equipment_count = 10, + .equipment = { + 27235, + 22109, + 19547, + 20997, + 27238, + 11212, + 27241, + 26235, + 13237, + 28310, + }, + }, + /* [FC_LOADOUT_LOW_DEF_RCB] Low-defence — Robin Hood + Red D'hide + RCB */ + { + .name = "C: 1 Def Robin + RCB", + .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, + .ammo = 50000, + .equipment_count = 10, + .equipment = { + 2581, + 10499, + 1704, + 9185, + 12596, + 12610, + 9143, + 2495, + 11126, + 2577, + }, + }, + /* [FC_LOADOUT_RCB_PURE] Low-level 1-def Fight Caves rune crossbow pure */ + { + .name = "RCB Pure", + .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, + .ammo = 50000, + .equipment_count = 10, + .equipment = { + 2581, + 10499, + 1704, + 9185, + 12596, + 12610, + 9143, + 2495, + 11126, + 2577, + }, + }, + /* [FC_LOADOUT_MSBI_PURE] Faster but weaker 1-def magic shortbow pure */ + { + .name = "MSB(i) Pure", + .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, + .ammo = 50000, + .equipment_count = 9, + .equipment = { + 2581, + 10499, + 1704, + 12788, + 892, + 12596, + 2495, + 11126, + 2577, + }, + }, + /* [FC_LOADOUT_BLOWPIPE_PURE] Fast 1-def toxic blowpipe pure with loaded adamant darts */ + { + .name = "Blowpipe Pure", + .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, + .ammo = 50000, + .equipment_count = 8, + .equipment = { + 2581, + 10499, + 19547, + 12926, + 12596, + 2495, + 11126, + 2577, + }, + }, + /* [FC_LOADOUT_ACB_ARMADYL] Tankier high-level Armadyl crossbow + Armadyl armour */ + { + .name = "ACB Armadyl", + .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, + .ammo = 50000, + .equipment_count = 10, + .equipment = { + 11826, + 22109, + 19547, + 11785, + 11828, + 12610, + 21946, + 11830, + 7462, + 13237, + }, + }, + /* [FC_LOADOUT_BOWFA_CRYSTAL] Bowfa + crystal armour. */ + { + .name = "Bowfa Crystal", + .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, + .ammo = 0, + .equipment_count = 8, + .equipment = { + 23971, + 22109, + 19547, + 25867, + 23975, + 23979, + 7462, + 13237, + }, + }, + /* [FC_LOADOUT_TBOW_MASORI] Max-ish Twisted bow + fortified Masori loadout */ + { + .name = "Tbow Masori", + .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, + .ammo = 50000, + .equipment_count = 9, + .equipment = { + 27235, + 22109, + 19547, + 20997, + 27238, + 11212, + 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 (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 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, + 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, walkable, movement_flags, 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 + +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 int reward_has_threat(const FcState* state) { + const FcPlayer* p = &state->player; + for (int i = 0; i < FC_MAX_NPCS; i++) { + const FcNpc* n = &state->npcs[i]; + 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++) { + if (p->pending_hits[i].active) return 1; + } + return 0; +} + +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.any_threat = reward_has_threat(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, 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; + + if (net_work_removed > 0.0001f) { + runtime->ticks_since_positive_progress = 0; + } else { + runtime->ticks_since_positive_progress++; + if (net_work_removed >= -0.0001f) 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.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; + + runtime->npc_healing_total += (float)state->npc_heal_amount_this_tick; + runtime->jad_healing_total += (float)state->jad_heal_amount_this_tick; + 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; + fc_items_init(p, loadout); +} + +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->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->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); + 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 combat stats in loadout %d\n", loadout_id); + 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; + 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_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; + 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->jad_heal_amount_this_tick = 0; + + FcPlayer* p = &state->player; + p->damage_taken_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 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; + player->current_hp += heal; + if (player->current_hp > player->max_hp) { + player->current_hp = player->max_hp; + } + fc_items_consume(player, 0); + *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 restore = fc_prayer_potion_restore( + FC_LOADOUTS[FC_ACTIVE_LOADOUT].prayer_lvl); + player->current_prayer += restore; + if (player->current_prayer > player->max_prayer) { + player->current_prayer = player->max_prayer; + } + 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, + 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 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, + 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 = 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 = melee ? 1 : fc_ranged_hit_delay(distance); + + fc_queue_pending_hit(target->pending_hits, &target->num_pending_hits, + FC_MAX_PENDING_HITS, damage, delay, + 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; + 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; + + player->attack_timer = player->weapon_speed; + if (player->weapon_uses_ammo && player->ammo_count > 0) { + fc_items_spend_ammo(player); + } + player->hit_landed_this_tick = 1; +} + + +static void process_player_target(FcState* state, + int explicit_directional_move, + int explicit_tile_move) { + FcPlayer* player = &state->player; + + /* 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; + } + + 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; + } + + 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; + 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; + + 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 (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 && + (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); + } +} + +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 process_player_actions(FcState* state, + const int actions[FC_NUM_ACTION_HEADS], + FcPrayerTransition* prayer_transition) { + FcPlayer* p = &state->player; + + 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; + /* 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 + * 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); + + 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); +} + +/* ======================================================================== */ +/* 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->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 }, +}; + +/* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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, + }, + { /* 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 */ +/* ======================================================================== */ + +/* + * 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; + /* 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]; +} + +/* ======================================================================== */ +/* 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 */ +/* ======================================================================== */ + + +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; + + + /* 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; + 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..268889f9a1 --- /dev/null +++ b/ocean/fight_caves/tools.py @@ -0,0 +1,1505 @@ +#!/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++": + # 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, + 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: + return run_preflight(preflight_args().mode) + + +def run_preflight(mode: str) -> int: + 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 mode != "viewer-runtime": + require_command(errors, compiler, "C compilation") + + if mode in ("core", "native", "cpu", "cuda", "web"): + verify_assets(errors, ("core",)) + elif mode in ("viewer", "viewer-runtime"): + verify_assets(errors, ("core", "viewer")) + + if mode in ("native", "cpu", "cuda"): + require_command(errors, "ar", "static library creation") + 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 ( + ("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 mode in ("native", "cpu", "cuda"): + check_openmp(errors, os.environ.get("CC"), "c") + if mode == "native" and sys.platform == "linux": + check_linux_viewer_link(errors, os.environ.get("CC")) + 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 mode == "viewer": + require_command(errors, "cmake", "viewer configuration") + check_linux_viewer_link(errors, os.environ.get("CC")) + if mode == "viewer-runtime": + check_graphical_display(errors) + if 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 {mode} preflight passed.") + return 0 + + +# Compatibility entry point; standard build.sh owns assets and compilation. + +def build_viewer_main() -> int: + """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 + +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 + actual = payload.get("contract") + expected = preflight["contract"] + # 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) + 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}" + ) + + +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(): + # 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): + 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(): + 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): + 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 --fast", + 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 --fast", + 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: + """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(viewer, [viewer, *sys.argv[1:]]) + + +def main() -> int: + commands = {"setup": setup_main, "bundle": bundle_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,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) + 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..d35f87eafb --- /dev/null +++ b/ocean/fight_caves/ui.h @@ -0,0 +1,3744 @@ +#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" + +#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); + +#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); + + +/* 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" +#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_WORLD_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; + const char *action; /* supplied by the inventory owner */ +} 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, + RUNEC_UI_CONTEXT_WORLD +} 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_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; + Color context_target_color; + int context_combat_level; + 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_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); + +#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 +#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 + +typedef struct { + Rectangle source; + int offset_x; + int offset_y; + int advance; + int drawable; +} 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; + +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_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}; + memset(g_glyphs, 0, sizeof(g_glyphs)); + g_font_height = 0; + 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); + 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_MENU_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; +} + + +/* 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 +#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", "Unused", + "Legs", "Unused", "Hands", "Feet", "Unused", "Ring", "Ammo", +}; + +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}, + {-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}, + {133, 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; + 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]); + } +} + +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_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; + 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->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; + } + + +} + +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; + + 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++) { + 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 (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; + 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 || 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); + } 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); + runec_ui_close_context(ui); + 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; + } + ui->last_intent.kind = RUNEC_UI_INTENT_INVENTORY_SLOT; + ui->last_intent.primary = drag.source_slot; + ui->last_intent.position = mouse; + return 1; + } + + if (drag.source_kind == RUNEC_UI_CONTEXT_EQUIPMENT) { + 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_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_RIGHT) || + !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) { + 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"; + 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", "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; + } + } + 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 (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) || + 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->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}); + } + } + + 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; + } +} + +void runec_ui_draw_context(const RuneCUiState *ui) { + if (!ui->context_open) + return; + 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++) { + 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) { + 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); +} + +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..18c67511da --- /dev/null +++ b/ocean/fight_caves/viewer.c @@ -0,0 +1,2791 @@ +/* + * 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 + * 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. + * In replay mode, use Shift+4 / 5 for camera presets. + */ + +#include "raylib.h" +#include "rlgl.h" +#include "simulation.h" +#include "assets.h" +#include "ui.h" +#include "render.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; + /* 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; + 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; + 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) */ + int pending_prayer, pending_eat, pending_drink; + 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 */ + 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 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++) { + ready &= load_ui_item_icon(&v->ui, (uint32_t)lo->equipment[ei]); + } + } + return ready; +} + +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"; + } +} + +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) { + 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.auto_retaliate = 1; + v->ui.special_attack_energy = 100; + v->ui.combat_level = 126; + 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; + 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 || 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; + 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 || 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; + fc_click_feedback_select_interaction(&v->click_feedback, + 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_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, "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->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; + 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; + fc_set_initial_supplies(&v->state, v->initial_sharks, 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 || v->policy_pipe) return; + v->godmode = !v->godmode; + fprintf(stderr, "GODMODE: %s\n", v->godmode ? "ON" : "OFF"); +} + +static void print_policy_episode_summary(const ViewerState* v) { + FcEpisodeSummary 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/zero_progress_ticks\":%d," + "\"env/wave_reached\":%d," + "\"env/wrong_prayer_hits\":%d," + "\"env/reached_wave_63\":%d," + "\"env/jad_kill_rate\":%d," + "\"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.wrong_prayer_hits, + summary.reached_wave_63, + 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) { + 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 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; + 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_viewer_reset_presentation(v); +} + +static void viewer_jump_to_wave(ViewerState* v, int wave) { + 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; + + 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->appearance.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); +static float ground_y_smooth(ViewerState* v, float tile_x, float 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; +} + +/* 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; + 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; + 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) && + !IsMouseButtonDown(MOUSE_BUTTON_RIGHT) && !IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) { + 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); + 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"); + } + } +} + +/* 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->appearance.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(fc_player_equipment_visual_profile(&v->state.player)), + .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->appearance.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; + + static const struct { + int prayer; + int action; + int ui_slot; + } buttons[] = { + {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; + 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; + } + + return 1; +} +/* ======================================================================== */ +/* Main */ +/* ======================================================================== */ + +/* 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 (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, 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 NULL; + } + SetTargetFPS(60); + + 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"); + 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); + /* 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 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_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) + 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->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), + "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; restore assets with: ./build.sh fight_caves --fast\n"); + fc_viewer_destroy(v); + return NULL; + } + + v->combat_style = 1; + v->policy_pipe = replay; + v->paused = !replay; + return 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); + } + } + + 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; + } + } + +} + +/* 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); + } + 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); + } 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; + } + } + + /* 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); + } + + 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; + } + + 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 { + 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"); + 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); + } + 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]); + } + } + 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; + } + } + fc_viewer_destroy(v); + return EXIT_SUCCESS; +} + +#ifndef FC_VIEWER_EMBEDDED +int main(int argc, char** argv) { + return fc_viewer_main(argc, argv); +} +#endif 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..ae7e3355c1 --- /dev/null +++ b/resources/fight_caves/README.md @@ -0,0 +1,34 @@ +# Fight Caves assets + +Normal users do not need a separate setup command. Every standard Fight Caves +build installs/verifies the pinned bundles automatically: + +```bash +./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 +``` + +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. + +- `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. + +For maintenance only: + +```bash +python3 ocean/fight_caves/tools.py setup --all --verify-only +python3 ocean/fight_caves/tools.py setup --all --force +``` + +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/resources/fight_caves/asset_manifest.json b/resources/fight_caves/asset_manifest.json new file mode 100644 index 0000000000..52040d2678 --- /dev/null +++ b/resources/fight_caves/asset_manifest.json @@ -0,0 +1,3433 @@ +{ + "bundles": { + "core": { + "archive": "fight-caves-runtime-assets-v3.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-v3/fight-caves-runtime-assets-v3.tar.gz" + }, + "viewer": { + "archive": "fight-caves-viewer-assets-v3.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_bold.ttf", + "sha256": "c8b9afefe105a78717fde9692deafbf2361b54bcf9c8019b6d817de94f1c6168", + "size_bytes": 22436 + }, + { + "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_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_28310.png", + "sha256": "737efd24af57fdf658bebd9e181f26dc4b0ff327a896f808b18ff840e4e6e81d", + "size_bytes": 1324 + }, + { + "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": "d056dd8db14d53a804bbe5a216f1168816d4a947c862a4db9075b9a3b1196641", + "size_bytes": 521341 + }, + { + "path": "viewer/fc_player.parts", + "sha256": "8606aabd0ef16d5e4c803994f63a98885435364536e8bf5d2971edcbf69caa11", + "size_bytes": 272 + }, + { + "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": "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-v3", + "schema_version": 1, + "source": { + "repository": "https://github.com/jordanbailey00/fc-rl", + "revision": "73129580cb7f7aaae9955511ea377b77ab853026" + } +} diff --git a/tests/fight_caves.c b/tests/fight_caves.c new file mode 100644 index 0000000000..5c0b0fb276 --- /dev/null +++ b/tests/fight_caves.c @@ -0,0 +1,791 @@ +#ifdef NDEBUG +#undef NDEBUG +#endif +#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 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]; + 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]; + } +} + +static int core_contract_test(void) { + _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"); + _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) != 0x5c53c26du) + 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; +} + +/* 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 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] = { + { /* 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}; + 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; +} + +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) { + wave_rotation_test(); + 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; +#endif + return 0; +} diff --git a/tests/fight_caves.sh b/tests/fight_caves.sh new file mode 100644 index 0000000000..744be21597 --- /dev/null +++ b/tests/fight_caves.sh @@ -0,0 +1,256 @@ +#!/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 + ./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 + 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-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)." +) + +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 + +# 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 --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" + +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 preflight --mode viewer-runtime +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[@]}" ./fight_caves \ + --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" +export PATH="$PWD/.venv/bin:$PATH" +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/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 new file mode 100644 index 0000000000..b797c3071b --- /dev/null +++ b/tests/test_fight_caves.py @@ -0,0 +1,430 @@ +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") + + +@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", mode], + cwd=REPO_ROOT, + env=environment, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + assert result.returncode != 0 + assert f"required command '{missing}' is unavailable" in result.stderr + +@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) + calls = [] + 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_compatibility_replay_uses_standard_executable(tmp_path, monkeypatch): + tools = load_setup_data() + 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 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 + + +@pytest.mark.parametrize("broken", [False, True]) +def test_replay_asset_check_is_independent_of_display(monkeypatch, broken): + tools = load_setup_data() + 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: + tools.verify_runtime_assets() + + +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 + + +@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_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) + 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), +]) +@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=saved) + 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" + + +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_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 = ( + 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") + 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() + + print(f"puffer_contract_test: passed ({terminal_count} terminal transitions)") + return 0 + +if __name__ == "__main__": + raise SystemExit(puffer_main())