diff --git a/config/ocean/osrs_inferno.ini b/config/ocean/osrs_inferno.ini new file mode 100644 index 0000000000..40a61e532f --- /dev/null +++ b/config/ocean/osrs_inferno.ini @@ -0,0 +1,186 @@ +# Metal osrs_inferno config. +# long episodes (300-2000+ ticks), 7 action heads (76 logits), 380+76 obs. +# vf_coef must stay low (< 0.15) — fused decoder amplifies value gradients +# into policy logits via MinGRU scan backward. replay_ratio < 1.0 to avoid +# stale target drift. + +[base] +env_name = osrs_inferno +score_metric = episode_return + +[env] +start_wave = 0.0 +mask_in_obs = 1.0 + +[vec] +total_agents = 2048 +num_buffers = 4 +num_threads = 4 + +[policy] +hidden_size = 256 +num_layers = 2 + +[train] +# anchor from sweep trial #33 (score 74.6, wave 17+, prayer 60%) +total_timesteps = 400000000 +horizon = 32 +min_lr_ratio = 0.003872 +learning_rate = 0.003069 +beta1 = 0.95 +eps = 0.000004 +ent_coef = 0.0017 +gamma = 0.998319 +gae_lambda = 0.8 +vtrace_rho_clip = 2.243133 +vtrace_c_clip = 1.971016 +prio_alpha = 0.0 +prio_beta0 = 0.275787 +clip_coef = 0.611932 +vf_coef = 0.063963 +vf_clip_coef = 0.404894 +max_grad_norm = 0.997781 +replay_ratio = 0.790328 +minibatch_size = 4096 +ns_iters = 5 +weight_decay = 0.089232 + +[sweep] +min_sps = 50000 +max_suggestion_cost = 3600 +metric = episode_return +metric_distribution = linear + +[sweep.train.horizon] +distribution = uniform_pow2 +min = 32 +max = 256 +scale = auto + +[sweep.train.learning_rate] +distribution = log_normal +min = 0.0003 +max = 0.01 +scale = 0.5 + +[sweep.train.ent_coef] +distribution = log_normal +min = 0.001 +max = 0.03 +scale = auto + +[sweep.train.gamma] +distribution = logit_normal +min = 0.99 +max = 0.9999 +scale = auto + +[sweep.train.min_lr_ratio] +distribution = uniform +min = 0.0 +max = 0.3 +scale = auto + +[sweep.train.beta1] +distribution = uniform +min = 0.8 +max = 0.99 +scale = auto + +[sweep.train.eps] +distribution = log_normal +min = 1e-6 +max = 1e-4 +scale = auto + +[sweep.train.gae_lambda] +distribution = logit_normal +min = 0.5 +max = 0.999 +scale = auto + +[sweep.train.vtrace_rho_clip] +distribution = uniform +min = 1.0 +max = 3.0 +scale = auto + +[sweep.train.vtrace_c_clip] +distribution = uniform +min = 1.0 +max = 2.5 +scale = auto + +[sweep.train.prio_alpha] +distribution = logit_normal +min = 0.0 +max = 0.8 +scale = auto + +[sweep.train.prio_beta0] +distribution = logit_normal +min = 0.01 +max = 0.8 +scale = auto + +[sweep.train.clip_coef] +distribution = uniform +min = 0.2 +max = 1.0 +scale = auto + +[sweep.train.vf_coef] +distribution = log_normal +min = 0.005 +max = 0.5 +scale = auto + +[sweep.train.vf_clip_coef] +distribution = uniform +min = 0.1 +max = 2.0 +scale = auto + +[sweep.train.max_grad_norm] +distribution = uniform +min = 0.5 +max = 3.0 +scale = auto + +[sweep.train.replay_ratio] +distribution = uniform +min = 0.1 +max = 1.0 +scale = auto + +[sweep.train.weight_decay] +distribution = log_normal +min = 0.001 +max = 0.3 +scale = auto + +[sweep.train.minibatch_size] +distribution = uniform_pow2 +min = 2048 +max = 8192 +scale = auto + +[sweep.train.num_buffers] +distribution = uniform_pow2 +min = 1 +max = 4 +scale = auto + +[sweep.policy.hidden_size] +distribution = uniform_pow2 +min = 128 +max = 512 +scale = auto + +[sweep.policy.num_layers] +distribution = uniform +min = 2 +max = 5.0 +scale = auto + + diff --git a/config/ocean/osrs_pvp.ini b/config/ocean/osrs_pvp.ini new file mode 100644 index 0000000000..8a38808214 --- /dev/null +++ b/config/ocean/osrs_pvp.ini @@ -0,0 +1,28 @@ +[base] +env_name = osrs_pvp +score_metric = score + +[env] +start_wave = 0.0 +mask_in_obs = 1.0 + +[vec] +total_agents = 2048 +num_buffers = 4 +num_threads = 4 + +[policy] +hidden_size = 256 +num_layers = 2 + +[train] +total_timesteps = 200000000 +horizon = 32 +learning_rate = 0.003 +gamma = 0.998 +ent_coef = 0.001 +clip_coef = 0.6 +vf_coef = 0.1 +replay_ratio = 0.5 +minibatch_size = 4096 +weight_decay = 0.05 diff --git a/config/ocean/osrs_zulrah.ini b/config/ocean/osrs_zulrah.ini new file mode 100644 index 0000000000..c0b65a543e --- /dev/null +++ b/config/ocean/osrs_zulrah.ini @@ -0,0 +1,28 @@ +[base] +env_name = osrs_zulrah +score_metric = score + +[env] +start_wave = 0.0 +mask_in_obs = 1.0 + +[vec] +total_agents = 2048 +num_buffers = 4 +num_threads = 4 + +[policy] +hidden_size = 256 +num_layers = 2 + +[train] +total_timesteps = 200000000 +horizon = 32 +learning_rate = 0.003 +gamma = 0.998 +ent_coef = 0.001 +clip_coef = 0.6 +vf_coef = 0.1 +replay_ratio = 0.5 +minibatch_size = 4096 +weight_decay = 0.05 diff --git a/ocean/osrs/Makefile b/ocean/osrs/Makefile new file mode 100644 index 0000000000..e4dd48b775 --- /dev/null +++ b/ocean/osrs/Makefile @@ -0,0 +1,43 @@ +# OSRS PvP C Environment Makefile +# +# standalone targets (no PufferLib dependency): +# make — headless benchmark binary +# make visual — headed raylib viewer with human input +# make debug — debug build with sanitizers +# +# PufferLib training uses setup.py build_osrs instead. + +CC = clang +CFLAGS = -Wall -Wextra -O3 -ffast-math -flto -fPIC -std=c11 +DEBUG_FLAGS = -Wall -Wextra -g -O0 -fPIC -std=c11 -DDEBUG +LDFLAGS = -lm + +TARGET = osrs_pvp +DEMO_SRC = osrs_pvp.c +HEADERS = osrs_pvp.h + +# Raylib (for visual target). download from https://github.com/raysan5/raylib/releases +RAYLIB_DIR = raylib-5.5_macos +UNAME_S := $(shell uname -s) +ifeq ($(UNAME_S),Darwin) +RAYLIB_FLAGS = -I$(RAYLIB_DIR)/include $(RAYLIB_DIR)/lib/libraylib.a \ + -framework Cocoa -framework OpenGL -framework IOKit -framework CoreVideo +else +RAYLIB_FLAGS = -I$(RAYLIB_DIR)/include -L$(RAYLIB_DIR)/lib -lraylib -lGL -lpthread -ldl -lrt +endif + +.PHONY: all clean debug visual + +all: $(TARGET) + +$(TARGET): $(DEMO_SRC) $(HEADERS) + $(CC) $(CFLAGS) -o $@ $(DEMO_SRC) $(LDFLAGS) + +visual: $(DEMO_SRC) $(HEADERS) osrs_pvp_render.h osrs_pvp_gui.h + $(CC) $(CFLAGS) -DOSRS_PVP_VISUAL $(RAYLIB_FLAGS) -o $(TARGET)_visual $(DEMO_SRC) $(LDFLAGS) + +debug: $(DEMO_SRC) $(HEADERS) + $(CC) $(DEBUG_FLAGS) -o $(TARGET)_debug $(DEMO_SRC) $(LDFLAGS) + +clean: + rm -f $(TARGET) $(TARGET)_debug $(TARGET)_visual *.o diff --git a/ocean/osrs/README.md b/ocean/osrs/README.md new file mode 100644 index 0000000000..465e44922b --- /dev/null +++ b/ocean/osrs/README.md @@ -0,0 +1,52 @@ +# OSRS PvP Environment + +C implementation of Old School RuneScape NH PvP for reinforcement learning. +~1.1M env steps/sec standalone, ~235K+ training SPS on Metal. + +## Build and train + +```bash +python setup.py build_osrs_pvp --inplace --force +python train_pvp.py --no-wandb --total-timesteps 50000000 + +# zulrah (separate build, overwrites _C.so) +python setup.py build_osrs_zulrah --inplace --force +python train_zulrah.py --no-wandb --total-timesteps 500000000 +``` + +Two bindings: `binding.c` (metal vecenv.h) and `ocean_binding.c` (PufferLib env_binding.h). + +## Data assets + +Not in git. Exported from the OSRS game cache: + +1. Download a modern cache from https://archive.openrs2.org/ ("flat file" export) +2. `cd pufferlib/ocean/osrs_pvp && ./scripts/export_all.sh /path/to/cache` + +Pure Python, no deps. + +## Spaces + +**Obs:** 373 = 334 features + 39 action mask, normalized in C. + +**Actions:** MultiDiscrete `[9, 13, 6, 2, 5, 2, 2]` — loadout, combat, prayer, food, potion, karambwan, veng. + +**Timing:** tick N actions apply at tick N+1 (OSRS-accurate async). + +## Opponents + +28 scripted policies from trivial (`true_random`) to boss (`nightmare_nh` — onetick + 50% action reading). Curriculum mixes and PFSP supported. + +## Encounters + +Vtable interface (`osrs_encounter.h`). Current: NH PvP, Zulrah (81 obs, 6 heads, 3 forms, venom, clouds, collision). + +## Files + +Core env: `osrs_types/items + osrs_pvp_gear/combat/collision/pathfinding/movement/observations/actions/opponents/api.h` + +Visual: `osrs_pvp_render/gui/anim/models/terrain/objects/effects/human_input.h` + +Encounters: `encounters/encounter_nh_pvp.h`, `encounters/encounter_zulrah.h` + +Data: `data/` (gitignored binaries + C model headers), `scripts/` (cache exporters) diff --git a/ocean/osrs/binding.c b/ocean/osrs/binding.c new file mode 100644 index 0000000000..ee23153027 --- /dev/null +++ b/ocean/osrs/binding.c @@ -0,0 +1,217 @@ +/** + * @file binding.c + * @brief Metal static-native binding for OSRS PVP environment + * + * Bridges vecenv.h's contract (float actions, float terminals) with the PVP + * env's internal types (int actions, unsigned char terminals) using a wrapper + * struct. PVP source headers are untouched. + */ + +#include "osrs_pvp.h" + +/* Wrapper struct: vecenv-compatible fields at top + embedded OsrsPvp. + * vecenv.h's create_static_vec assigns to env->observations, env->actions, + * env->rewards, env->terminals directly. These fields must match vecenv's + * expected types (void*, float*, float*, float*). The embedded OsrsPvp has + * its own identically-named fields with different types — pvp_init sets those + * to internal inline buffers, so there's no conflict. */ +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + OsrsPvp pvp; + + /* staging buffers for type conversion */ + int ocean_acts_staging[NUM_ACTION_HEADS]; + unsigned char ocean_term_staging; +} MetalPvpEnv; + +#define OBS_SIZE OCEAN_OBS_SIZE +#define NUM_ATNS NUM_ACTION_HEADS +#define ACT_SIZES {LOADOUT_DIM, COMBAT_DIM, OVERHEAD_DIM, FOOD_DIM, POTION_DIM, KARAMBWAN_DIM, VENG_DIM} +#define OBS_TENSOR_T FloatTensor +#define Env MetalPvpEnv + +/* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h + * because vecenv.h calls them inside its implementation section without + * forward-declaring them (they're expected to come from the env header). */ + +void c_step(Env* env) { + /* float actions from vecenv → int staging for PVP */ + for (int i = 0; i < NUM_ATNS; i++) { + env->ocean_acts_staging[i] = (int)env->actions[i]; + } + + pvp_step(&env->pvp); + + /* terminal: unsigned char → float for vecenv */ + env->terminals[0] = (float)env->ocean_term_staging; + + /* copy PVP log to wrapper log on episode end */ + if (env->ocean_term_staging) { + env->log.episode_return = env->pvp.log.episode_return; + env->log.episode_length = env->pvp.log.episode_length; + env->log.wins = env->pvp.log.wins; + env->log.damage_dealt = env->pvp.log.damage_dealt; + env->log.damage_received = env->pvp.log.damage_received; + env->log.n = env->pvp.log.n; + memset(&env->pvp.log, 0, sizeof(env->pvp.log)); + } + + if (env->ocean_term_staging && env->pvp.auto_reset) { + ocean_write_obs(&env->pvp); + } +} + +void c_reset(Env* env) { + /* Wire ocean pointers to vecenv shared buffers (deferred from my_init because + * create_static_vec assigns env->observations/rewards AFTER my_vec_init). */ + env->pvp.ocean_obs = (float*)env->observations; + env->pvp.ocean_rew = env->rewards; + env->pvp.ocean_term = &env->ocean_term_staging; + env->pvp.ocean_acts = env->ocean_acts_staging; + + pvp_reset(&env->pvp); + ocean_write_obs(&env->pvp); + env->pvp.ocean_rew[0] = 0.0f; + env->pvp.ocean_term[0] = 0; + env->terminals[0] = 0.0f; +} + +void c_close(Env* env) { pvp_close(&env->pvp); } +void c_render(Env* env) { (void)env; } + +#include "vecenv.h" + +void my_init(Env* env, Dict* kwargs) { + env->num_agents = 1; + + pvp_init(&env->pvp); + + /* Ocean pointer wiring is DEFERRED to c_reset because my_init runs inside + * my_vec_init BEFORE create_static_vec assigns the shared buffer pointers + * (env->observations, env->actions, env->rewards, env->terminals are NULL + * at this point). c_reset runs after buffer assignment and does the wiring. + * + * For now, point ocean pointers at internal staging so pvp_reset doesn't + * crash on writes to ocean_term/ocean_rew. */ + env->pvp.ocean_obs = NULL; + env->pvp.ocean_rew = env->pvp._rews_buf; + env->pvp.ocean_term = &env->ocean_term_staging; + env->pvp.ocean_acts = env->ocean_acts_staging; + env->pvp.ocean_obs_p1 = NULL; + env->pvp.ocean_selfplay_mask = NULL; + + /* config from Dict (all values are double) */ + env->pvp.use_c_opponent = 1; + env->pvp.auto_reset = 1; + env->pvp.is_lms = 1; + + DictItem* opp = dict_get_unsafe(kwargs, "opponent_type"); + env->pvp.opponent.type = opp ? (OpponentType)(int)opp->value : OPP_IMPROVED; + + DictItem* shaping_scale = dict_get_unsafe(kwargs, "shaping_scale"); + env->pvp.shaping.shaping_scale = shaping_scale ? (float)shaping_scale->value : 0.0f; + + DictItem* shaping_en = dict_get_unsafe(kwargs, "shaping_enabled"); + env->pvp.shaping.enabled = shaping_en ? (int)shaping_en->value : 0; + + /* reward shaping coefficients (same defaults as ocean_binding.c) */ + env->pvp.shaping.damage_dealt_coef = 0.005f; + env->pvp.shaping.damage_received_coef = -0.005f; + env->pvp.shaping.correct_prayer_bonus = 0.03f; + env->pvp.shaping.wrong_prayer_penalty = -0.02f; + env->pvp.shaping.prayer_switch_no_attack_penalty = -0.01f; + env->pvp.shaping.off_prayer_hit_bonus = 0.03f; + env->pvp.shaping.melee_frozen_penalty = -0.05f; + env->pvp.shaping.wasted_eat_penalty = -0.001f; + env->pvp.shaping.premature_eat_penalty = -0.02f; + env->pvp.shaping.magic_no_staff_penalty = -0.05f; + env->pvp.shaping.gear_mismatch_penalty = -0.05f; + env->pvp.shaping.spec_off_prayer_bonus = 0.02f; + env->pvp.shaping.spec_low_defence_bonus = 0.01f; + env->pvp.shaping.spec_low_hp_bonus = 0.02f; + env->pvp.shaping.smart_triple_eat_bonus = 0.05f; + env->pvp.shaping.wasted_triple_eat_penalty = -0.0005f; + env->pvp.shaping.damage_burst_bonus = 0.002f; + env->pvp.shaping.damage_burst_threshold = 30; + env->pvp.shaping.premature_eat_threshold = 0.7071f; + env->pvp.shaping.ko_bonus = 0.15f; + env->pvp.shaping.wasted_resources_penalty = -0.07f; + env->pvp.shaping.prayer_penalty_enabled = 1; + env->pvp.shaping.click_penalty_enabled = 0; + env->pvp.shaping.click_penalty_threshold = 5; + env->pvp.shaping.click_penalty_coef = -0.003f; + + /* gear: default tier 0 (basic LMS) */ + env->pvp.gear_tier_weights[0] = 1.0f; + env->pvp.gear_tier_weights[1] = 0.0f; + env->pvp.gear_tier_weights[2] = 0.0f; + env->pvp.gear_tier_weights[3] = 0.0f; + + /* pvp_reset sets up game state (players, positions, gear, etc.) + * but does NOT write to ocean buffers — that happens in c_reset. */ + pvp_reset(&env->pvp); +} + +void my_log(Log* log, Dict* out) { + dict_set(out, "episode_return", log->episode_return); + dict_set(out, "episode_length", log->episode_length); + dict_set(out, "wins", log->wins); + dict_set(out, "damage_dealt", log->damage_dealt); + dict_set(out, "damage_received", log->damage_received); +} + +/* ======================================================================== + * PFSP: set/get opponent pool weights across all envs + * Called from Python via pybind11 wrappers in metal_bindings.mm + * ======================================================================== */ + +void binding_set_pfsp_weights(StaticVec* vec, int* pool, int* cum_weights, int pool_size) { + Env* envs = (Env*)vec->envs; + if (pool_size > MAX_OPPONENT_POOL) pool_size = MAX_OPPONENT_POOL; + for (int e = 0; e < vec->size; e++) { + int was_unconfigured = (envs[e].pvp.pfsp.pool_size == 0); + envs[e].pvp.pfsp.pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + envs[e].pvp.pfsp.pool[i] = (OpponentType)pool[i]; + envs[e].pvp.pfsp.cum_weights[i] = cum_weights[i]; + } + /* Only reset on first configuration — restarts the episode that was started + * during env creation before the pool was set (would have used fallback opponent). + * Periodic weight updates must NOT reset: that would corrupt PufferLib's rollout. */ + if (was_unconfigured) { + c_reset(&envs[e]); + } + } +} + +void binding_get_pfsp_stats(StaticVec* vec, float* out_wins, float* out_episodes, int* out_pool_size) { + Env* envs = (Env*)vec->envs; + int pool_size = 0; + + for (int e = 0; e < vec->size; e++) { + if (envs[e].pvp.pfsp.pool_size > pool_size) + pool_size = envs[e].pvp.pfsp.pool_size; + } + *out_pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + out_wins[i] = 0.0f; + out_episodes[i] = 0.0f; + } + + /* Aggregate and reset (read-and-reset pattern) */ + for (int e = 0; e < vec->size; e++) { + for (int i = 0; i < envs[e].pvp.pfsp.pool_size; i++) { + out_wins[i] += envs[e].pvp.pfsp.wins[i]; + out_episodes[i] += envs[e].pvp.pfsp.episodes[i]; + } + memset(envs[e].pvp.pfsp.wins, 0, sizeof(envs[e].pvp.pfsp.wins)); + memset(envs[e].pvp.pfsp.episodes, 0, sizeof(envs[e].pvp.pfsp.episodes)); + } +} diff --git a/ocean/osrs/data/.gitignore b/ocean/osrs/data/.gitignore new file mode 100644 index 0000000000..21056be28e --- /dev/null +++ b/ocean/osrs/data/.gitignore @@ -0,0 +1,11 @@ +# all binary assets — regenerated from OSRS cache via scripts/ +# run: scripts/export_all.sh +*.models +*.anims +*.objects +*.terrain +*.atlas +*.npcs +*.cmap +*.bin +sprites/ diff --git a/ocean/osrs/data/item_models.h b/ocean/osrs/data/item_models.h new file mode 100644 index 0000000000..0a3cf62e28 --- /dev/null +++ b/ocean/osrs/data/item_models.h @@ -0,0 +1,118 @@ +/* generated by scripts/export_models.py — do not edit */ +#ifndef ITEM_MODELS_H +#define ITEM_MODELS_H + +#include + +typedef struct { + uint16_t item_id; + uint32_t inv_model; + uint32_t wield_model; + uint8_t has_sleeves; +} ItemModelMapping; + +#define ITEM_MODEL_COUNT 99 + +static const ItemModelMapping ITEM_MODEL_MAP[] = { + { 10828, 21938, 917504, 0 }, + { 21795, 34166, 917505, 0 }, + { 1712, 2796, 917506, 0 }, + { 2503, 2745, 917507, 0 }, + { 4091, 5043, 917508, 1 }, + { 1079, 2582, 917509, 0 }, + { 4093, 5042, 917510, 0 }, + { 4151, 5412, 917511, 0 }, + { 9185, 16876, 917512, 0 }, + { 4710, 6590, 917513, 0 }, + { 5698, 2718, 917514, 0 }, + { 12954, 10422, 917515, 0 }, + { 12829, 11308, 917516, 0 }, + { 7462, 13631, 917517, 0 }, + { 3105, 2837, 917518, 0 }, + { 6737, 9931, 4294967295, 0 }, + { 9243, 16856, 4294967295, 0 }, + { 22324, 35739, 917521, 0 }, + { 24417, 39068, 917522, 0 }, + { 11791, 2810, 917523, 0 }, + { 21006, 32789, 917524, 0 }, + { 24424, 39072, 917525, 0 }, + { 11785, 19967, 917527, 0 }, + { 26374, 43246, 917528, 0 }, + { 13652, 32784, 917529, 0 }, + { 11802, 28075, 917530, 0 }, + { 25730, 4845, 4294967295, 0 }, + { 4153, 5413, 917532, 0 }, + { 21003, 32792, 917533, 0 }, + { 11235, 26386, 917534, 0 }, + { 19481, 31523, 917535, 0 }, + { 22613, 35995, 917536, 0 }, + { 27690, 47422, 917537, 0 }, + { 22622, 35986, 917538, 0 }, + { 22636, 35997, 917539, 0 }, + { 21018, 32794, 917540, 0 }, + { 21021, 32790, 917541, 1 }, + { 21024, 32787, 917542, 0 }, + { 4712, 6578, 917543, 1 }, + { 4714, 6577, 917544, 0 }, + { 4736, 6588, 917545, 1 }, + { 11834, 28047, 917546, 0 }, + { 12831, 11307, 917547, 0 }, + { 6585, 9633, 917548, 0 }, + { 12002, 28438, 917549, 0 }, + { 21295, 33144, 917550, 0 }, + { 13235, 29394, 917551, 0 }, + { 11770, 21850, 4294967295, 0 }, + { 25975, 46473, 4294967295, 0 }, + { 6889, 10573, 917554, 0 }, + { 11212, 26306, 4294967295, 0 }, + { 4751, 6584, 917556, 0 }, + { 4722, 6581, 917557, 0 }, + { 4759, 6595, 917558, 0 }, + { 4745, 6592, 917559, 0 }, + { 4716, 6580, 917560, 0 }, + { 4753, 6597, 917561, 0 }, + { 4724, 6583, 917562, 0 }, + { 21932, 16856, 4294967295, 0 }, + { 21791, 34261, 917564, 0 }, + { 31113, 56713, 917565, 0 }, + { 27251, 46472, 917566, 0 }, + { 31106, 56703, 917567, 0 }, + { 31097, 56694, 917568, 0 }, + { 20657, 31519, 4294967295, 0 }, + { 20997, 32799, 917570, 0 }, + { 27235, 46466, 917571, 0 }, + { 27238, 46469, 917572, 0 }, + { 27241, 46475, 917573, 0 }, + { 19547, 31510, 917574, 0 }, + { 28947, 52244, 917575, 0 }, + { 26235, 43237, 917576, 0 }, + { 12926, 19219, 917577, 0 }, + { 4708, 5419, 917578, 0 }, + { 19544, 31515, 917579, 0 }, + { 22481, 35744, 917580, 0 }, + { 6920, 10580, 917581, 0 }, + { 20220, 31976, 4294967295, 0 }, + { 2550, 2677, 4294967295, 0 }, + { 23971, 38761, 917584, 0 }, + { 22109, 35041, 917585, 0 }, + { 23975, 38766, 917586, 0 }, + { 23979, 38765, 917587, 0 }, + { 25865, 42605, 917588, 0 }, + { 19921, 32033, 917589, 0 }, + { 4089, 5040, 917590, 0 }, + { 12899, 19223, 917591, 0 }, + { 12612, 2543, 917592, 0 }, + { 21326, 2711, 4294967295, 0 }, + { 4097, 5038, 917594, 0 }, + { 10382, 20231, 917595, 1 }, + { 2497, 2507, 917596, 0 }, + { 12788, 48061, 917597, 0 }, + { 10499, 20454, 917598, 0 }, + { 22326, 35751, 917599, 0 }, + { 22327, 35750, 917600, 0 }, + { 22328, 35752, 917601, 0 }, + { 4224, 5198, 917602, 0 }, + { 13237, 29396, 917603, 0 }, +}; + +#endif /* ITEM_MODELS_H */ diff --git a/ocean/osrs/data/npc_models.h b/ocean/osrs/data/npc_models.h new file mode 100644 index 0000000000..5288ff765d --- /dev/null +++ b/ocean/osrs/data/npc_models.h @@ -0,0 +1,140 @@ +/** + * @fileoverview NPC model/animation mappings for encounter rendering. + * + * Maps NPC definition IDs to cache model IDs and animation sequence IDs. + * Generated by scripts/export_inferno_npcs.py — do not edit. + */ + +#ifndef NPC_MODELS_H +#define NPC_MODELS_H + +#include + +typedef struct { + uint16_t npc_id; + uint32_t model_id; + uint32_t idle_anim; + uint32_t attack_anim; + uint32_t walk_anim; /* walk cycle animation; 65535 = use idle_anim */ +} NpcModelMapping; + +/* zulrah forms + snakeling */ +static const NpcModelMapping NPC_MODEL_MAP_ZULRAH[] = { + {2042, 14408, 5069, 5068, 65535}, /* green zulrah (ranged) */ + {2043, 14409, 5069, 5068, 65535}, /* red zulrah (melee) */ + {2044, 14407, 5069, 5068, 65535}, /* blue zulrah (magic) */ +}; + +/* snakeling model + animations (NPC 2045 melee, 2046 magic — same model) */ +#define SNAKELING_MODEL_ID 10415 +#define SNAKELING_ANIM_IDLE 1721 +#define SNAKELING_ANIM_MELEE 140 /* NPC 2045 melee attack */ +#define SNAKELING_ANIM_MAGIC 185 /* NPC 2046 magic attack */ +#define SNAKELING_ANIM_DEATH 138 /* NPC 2045 death */ +#define SNAKELING_ANIM_WALK 2405 /* walk cycle */ + +/* zulrah spotanim (projectile/cloud) model IDs */ +#define GFX_RANGED_PROJ_MODEL 20390 /* GFX 1044 ranged projectile */ +#define GFX_CLOUD_PROJ_MODEL 11221 /* GFX 1045 cloud projectile */ +#define GFX_MAGIC_PROJ_MODEL 26593 /* GFX 1046 magic projectile */ +#define GFX_TOXIC_CLOUD_MODEL 4086 /* object 11700 */ +#define GFX_SNAKELING_SPAWN_MODEL 20390 /* GFX 1047 spawn orb */ + +/* zulrah animation sequence IDs */ +#define ZULRAH_ANIM_ATTACK 5068 +#define ZULRAH_ANIM_IDLE 5069 +#define ZULRAH_ANIM_DIVE 5072 +#define ZULRAH_ANIM_SURFACE 5071 +#define ZULRAH_ANIM_RISE 5073 +#define ZULRAH_ANIM_5070 5070 +#define ZULRAH_ANIM_5806 5806 +#define ZULRAH_ANIM_5807 5807 +#define GFX_SNAKELING_SPAWN_ANIM 5358 + +/* ================================================================ */ +/* inferno NPC model/animation mappings */ +/* ================================================================ */ + +static const NpcModelMapping NPC_MODEL_MAP_INFERNO[] = { + {7691, 0xC1E0B, 7573, 7574, 7572}, /* Jal-Nib (nibbler) */ + {7692, 0xC1E0C, 7577, 7578, 7577}, /* Jal-MejRah (bat) */ + {7693, 0xC1E0D, 7586, 7581, 7587}, /* Jal-Ak (blob) */ + {7694, 0xC1E0E, 7586, 65535, 7587}, /* Jal-Ak-Rek-Ket (blob melee split) */ + {7695, 0xC1E0F, 7586, 65535, 7587}, /* Jal-Ak-Rek-Xil (blob range split) */ + {7696, 0xC1E10, 7586, 65535, 7587}, /* Jal-Ak-Rek-Mej (blob mage split) */ + {7697, 0xC1E11, 7595, 7597, 7596}, /* Jal-ImKot (meleer) */ + {7698, 0xC1E12, 7602, 7605, 7603}, /* Jal-Xil (ranger) */ + {7699, 0xC1E13, 7609, 7610, 7608}, /* Jal-Zek (mager) */ + {7700, 0xC1E14, 7589, 7593, 7588}, /* JalTok-Jad */ + {7701, 0xC1E15, 2636, 65535, 2634}, /* Yt-HurKot (jad healer) */ + {7706, 0xC1E1A, 7564, 7566, 65535}, /* TzKal-Zuk */ + {7707, 0xC1E1B, 7567, 65535, 7567}, /* Zuk shield */ + {7708, 0xC1E1C, 2867, 65535, 2863}, /* Jal-MejJak (zuk healer) */ +}; + +/* inferno NPC walk animation IDs */ +#define INF_WALK_ANIM_NIBBLER 7572 +#define INF_WALK_ANIM_BAT 7577 +#define INF_WALK_ANIM_BLOB 7587 +#define INF_WALK_ANIM_BLOB_MELEE_SPLIT 7587 +#define INF_WALK_ANIM_BLOB_RANGE_SPLIT 7587 +#define INF_WALK_ANIM_BLOB_MAGE_SPLIT 7587 +#define INF_WALK_ANIM_MELEER 7596 +#define INF_WALK_ANIM_RANGER 7603 +#define INF_WALK_ANIM_MAGER 7608 +#define INF_WALK_ANIM_JALTOK_JAD 7588 +#define INF_WALK_ANIM_JAD_HEALER 2634 +#define INF_WALK_ANIM_ZUK_SHIELD 7567 +#define INF_WALK_ANIM_ZUK_HEALER 2863 + +/* inferno spotanim (projectile/effect) model + animation IDs */ +#define INF_GFX_157_MODEL 3116 /* Jad magic hit */ +#define INF_GFX_157_ANIM 693 +#define INF_GFX_447_MODEL 9334 /* Jad ranged projectile (fireball) */ +#define INF_GFX_447_ANIM 2658 +#define INF_GFX_448_MODEL 9337 /* Jad magic projectile */ +#define INF_GFX_448_ANIM 2659 +#define INF_GFX_451_MODEL 9342 /* Jad ranged hit */ +#define INF_GFX_451_ANIM 2660 +#define INF_GFX_942_MODEL 19374 /* Dragon arrow projectile (twisted bow) */ +#define INF_GFX_942_ANIM 5233 +#define INF_GFX_1374_MODEL 853342 /* Bat ranged projectile */ +#define INF_GFX_1374_ANIM 660 +#define INF_GFX_1375_MODEL 33006 /* Zuk magic projectile */ +#define INF_GFX_1375_ANIM 7571 +#define INF_GFX_1376_MODEL 33007 /* Zuk ranged projectile */ +#define INF_GFX_1376_ANIM 7571 +#define INF_GFX_1377_MODEL 33013 /* Ranger ranged projectile */ +#define INF_GFX_1378_MODEL 33015 /* Ranger ranged hit */ +#define INF_GFX_1378_ANIM 7615 +#define INF_GFX_1379_MODEL 33016 /* Mager magic projectile */ +#define INF_GFX_1379_ANIM 7614 +#define INF_GFX_1380_MODEL 33008 /* Mager magic hit */ +#define INF_GFX_1380_ANIM 7616 +#define INF_GFX_1381_MODEL 33009 /* Zuk typeless hit (falling rocks?) */ +#define INF_GFX_1381_ANIM 7616 +#define INF_GFX_1382_MODEL 33017 /* Blob melee */ +#define INF_GFX_1382_ANIM 7614 +#define INF_GFX_1383_MODEL 853351 /* Blob ranged */ +#define INF_GFX_1383_ANIM 366 +#define INF_GFX_1384_MODEL 853352 /* Blob magic */ +#define INF_GFX_1385_MODEL 853353 /* Healer magic attack */ +#define INF_GFX_1385_ANIM 366 + +/* inferno pillar models — Rocky support objects 30284-30287 */ +#define INF_PILLAR_MODEL_100 33044 /* object 30284 — full health */ +#define INF_PILLAR_MODEL_75 33043 /* object 30285 — 75% HP */ +#define INF_PILLAR_MODEL_50 33042 /* object 30286 — 50% HP */ +#define INF_PILLAR_MODEL_25 33045 /* object 30287 — 25% HP */ + +static const NpcModelMapping* npc_model_lookup(uint16_t npc_id) { + for (int i = 0; i < (int)(sizeof(NPC_MODEL_MAP_ZULRAH) / sizeof(NPC_MODEL_MAP_ZULRAH[0])); i++) { + if (NPC_MODEL_MAP_ZULRAH[i].npc_id == npc_id) return &NPC_MODEL_MAP_ZULRAH[i]; + } + for (int i = 0; i < (int)(sizeof(NPC_MODEL_MAP_INFERNO) / sizeof(NPC_MODEL_MAP_INFERNO[0])); i++) { + if (NPC_MODEL_MAP_INFERNO[i].npc_id == npc_id) return &NPC_MODEL_MAP_INFERNO[i]; + } + return NULL; +} + +#endif /* NPC_MODELS_H */ diff --git a/ocean/osrs/data/player_models.h b/ocean/osrs/data/player_models.h new file mode 100644 index 0000000000..c66816d018 --- /dev/null +++ b/ocean/osrs/data/player_models.h @@ -0,0 +1,28 @@ +/* generated by scripts/export_models.py — do not edit */ +#ifndef PLAYER_MODELS_H +#define PLAYER_MODELS_H + +#include + +/* body part indices (male) */ +#define BODY_PART_HEAD 0 +#define BODY_PART_JAW 1 +#define BODY_PART_TORSO 2 +#define BODY_PART_ARMS 3 +#define BODY_PART_HANDS 4 +#define BODY_PART_LEGS 5 +#define BODY_PART_FEET 6 +#define BODY_PART_COUNT 7 + +/* default male body part model IDs (synthetic: 0xF0000 + part_id) */ +static const uint32_t DEFAULT_BODY_MODELS[BODY_PART_COUNT] = { + 0xF0000, /* HEAD */ + 0xF0001, /* JAW */ + 0xF0002, /* TORSO */ + 0xF0003, /* ARMS */ + 0xF0004, /* HANDS */ + 0xF0005, /* LEGS */ + 0xF0006, /* FEET */ +}; + +#endif /* PLAYER_MODELS_H */ diff --git a/ocean/osrs/encounters/encounter_inferno.h b/ocean/osrs/encounters/encounter_inferno.h new file mode 100644 index 0000000000..31707ed128 --- /dev/null +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -0,0 +1,2962 @@ +/** + * @file encounter_inferno.h + * @brief The Inferno — 69-wave PvM challenge with prayer switching and pillar safespotting. + * + * core mechanic: 3 destructible pillars block NPC projectiles. the player must + * position behind pillars to limit incoming attacks to one prayer style at a time. + * nibblers eat pillars, meleer can dig through them. losing all pillars = death spiral. + * + * monster types: nibbler (pillar eater), bat (short-range ranger), blob (prayer reader, + * splits into 3 on death), meleer (burrows to player), ranger, mager (resurrects dead mobs), + * jad (random 50/50 range/mage), zuk (final boss with shield mechanic). + * + * reference: InfernoTrainer TypeScript, runelite inferno plugin + */ + +#ifndef ENCOUNTER_INFERNO_H +#define ENCOUNTER_INFERNO_H + +#include "../osrs_types.h" +#include "../osrs_items.h" +#include "../osrs_collision.h" +#include "../osrs_combat_shared.h" +#include "../osrs_encounter.h" +#include "../data/npc_models.h" +#include +#include + +/* ======================================================================== */ +/* arena constants */ +/* ======================================================================== */ + +#define INF_ARENA_MIN_X 11 +#define INF_ARENA_MAX_X 39 +#define INF_ARENA_MIN_Y 14 +#define INF_ARENA_MAX_Y 43 +#define INF_ARENA_WIDTH (INF_ARENA_MAX_X - INF_ARENA_MIN_X + 1) /* 29 */ +#define INF_ARENA_HEIGHT (INF_ARENA_MAX_Y - INF_ARENA_MIN_Y + 1) /* 30 */ + +#define INF_PLAYER_START_X 25 +#define INF_PLAYER_START_Y 16 +#define INF_ZUK_PLAYER_START_X 25 +#define INF_ZUK_PLAYER_START_Y 42 + +#define INF_NUM_PILLARS 3 +#define INF_PILLAR_SIZE 3 +#define INF_PILLAR_HP 255 + +static const int INF_PILLAR_POS[INF_NUM_PILLARS][2] = { + { 21, 20 }, /* south pillar */ + { 11, 34 }, /* west pillar */ + { 28, 36 }, /* north pillar */ +}; + +/* 9 mob spawn positions (shuffled per wave) */ +#define INF_NUM_SPAWN_POS 9 +static const int INF_SPAWN_POS[INF_NUM_SPAWN_POS][2] = { + {12, 38}, {33, 38}, {14, 32}, {34, 31}, {27, 26}, + {16, 20}, {34, 18}, {12, 15}, {26, 15}, +}; + +/* nibbler spawn position (near pillars) */ +#define INF_NIBBLER_SPAWN_X 20 +#define INF_NIBBLER_SPAWN_Y 32 + +#define INF_MAX_TICKS 18000 /* 3 hours at 0.6s/tick */ +#define INF_NUM_WAVES 69 + +/* ======================================================================== */ +/* NPC types */ +/* ======================================================================== */ + +typedef enum { + INF_NPC_NIBBLER = 0, /* Jal-Nib: melee, eats pillars */ + INF_NPC_BAT, /* Jal-MejRah: short-range ranged, drains run */ + INF_NPC_BLOB, /* Jal-Ak: prayer reader, splits into 3 on death */ + INF_NPC_BLOB_MELEE, /* Jal-Ak-Rek-Ket: melee split from blob */ + INF_NPC_BLOB_RANGE, /* Jal-Ak-Rek-Xil: range split from blob */ + INF_NPC_BLOB_MAGE, /* Jal-Ak-Rek-Mej: mage split from blob */ + INF_NPC_MELEER, /* Jal-ImKot: melee, can dig */ + INF_NPC_RANGER, /* Jal-Xil: ranged, can melee if close */ + INF_NPC_MAGER, /* Jal-Zek: magic, resurrects dead mobs, can melee if close */ + INF_NPC_JAD, /* JalTok-Jad: random 50/50 range/mage */ + INF_NPC_ZUK, /* TzKal-Zuk: final boss */ + INF_NPC_HEALER_JAD, /* Yt-HurKot: jad healer */ + INF_NPC_HEALER_ZUK, /* Jal-MejJak: zuk healer */ + INF_NPC_ZUK_SHIELD, /* shield NPC during Zuk */ + INF_NUM_NPC_TYPES +} InfNPCType; + +/* OSRS NPC definition IDs — maps InfNPCType enum to actual cache NPC IDs + * used by the renderer to look up models/animations in npc_models.h */ +static const int INF_NPC_DEF_IDS[INF_NUM_NPC_TYPES] = { + [INF_NPC_NIBBLER] = 7691, /* Jal-Nib */ + [INF_NPC_BAT] = 7692, /* Jal-MejRah */ + [INF_NPC_BLOB] = 7693, /* Jal-Ak */ + [INF_NPC_BLOB_MELEE] = 7694, /* Jal-AkRek-Ket (melee split) */ + [INF_NPC_BLOB_RANGE] = 7695, /* Jal-AkRek-Xil (range split) */ + [INF_NPC_BLOB_MAGE] = 7696, /* Jal-AkRek-Mej (mage split) */ + [INF_NPC_MELEER] = 7697, /* Jal-ImKot */ + [INF_NPC_RANGER] = 7698, /* Jal-Xil */ + [INF_NPC_MAGER] = 7699, /* Jal-Zek */ + [INF_NPC_JAD] = 7700, /* JalTok-Jad */ + [INF_NPC_ZUK] = 7706, /* TzKal-Zuk */ + [INF_NPC_HEALER_JAD] = 7701, /* Yt-HurKot */ + [INF_NPC_HEALER_ZUK] = 7708, /* Jal-MejJak */ + [INF_NPC_ZUK_SHIELD] = 7707, /* Ancestral Glyph */ +}; + +typedef struct { + int hp; + int attack_speed; + int attack_range; + int size; + int default_style; /* ATTACK_STYLE_* */ + int can_melee; /* 1 if can switch to melee when close */ + + /* combat levels (used for attack rolls and max hit computation) */ + int att_level, str_level, def_level, range_level, magic_level; + + /* attack bonuses (for NPC attack roll: (level + 9) * (bonus + 64)) */ + int melee_att_bonus; /* best of stab/slash/crush */ + int range_att_bonus; + int magic_att_bonus; + + /* strength bonuses (for max hit formulas) */ + int melee_str_bonus; /* bonuses.other.meleeStrength */ + int ranged_str_bonus; /* bonuses.other.rangedStrength */ + int magic_base_dmg; /* base spell damage (magicMaxHit() in InfernoTrainer) */ + int magic_dmg_pct; /* magic damage multiplier as % (100 = 1.0x) */ + + /* defence bonuses (for player hit chance against this NPC) */ + int stab_def, slash_def, crush_def; + int magic_def_bonus; + int ranged_def_bonus; + + /* wiki max hit cap: 0 = no cap (use formula), >0 = clamp to this value. + needed for Jad/Zuk where InfernoTrainer multipliers overshoot wiki values. */ + int max_hit_cap; + + int stun_on_spawn; /* ticks of stun when first spawned */ + int can_move; /* 0 = cannot move (zuk, zuk healers) */ +} InfNPCStats; + +/* stats from InfernoTrainer TypeScript reference + OSRS wiki. + max hits are computed from levels + bonuses via osrs_npc_*_max_hit() formulas. */ +static const InfNPCStats INF_NPC_STATS[INF_NUM_NPC_TYPES] = { + /* NIBBLER (JalNib): attacks pillars not player, bypasses combat formula (hardcoded 0-4) */ + [INF_NPC_NIBBLER] = { .hp = 10, .attack_speed = 4, .attack_range = 1, .size = 1, + .default_style = ATTACK_STYLE_MELEE, .can_melee = 0, + .att_level = 1, .str_level = 1, .def_level = 15, .range_level = 0, .magic_level = 15, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = -20, .slash_def = -20, .crush_def = -20, .magic_def_bonus = -20, .ranged_def_bonus = -20, + .stun_on_spawn = 1, .can_move = 1 }, + + /* BAT (JalMejRah): ranged, drains run energy on hit. computed max hit = 19 */ + [INF_NPC_BAT] = { .hp = 25, .attack_speed = 3, .attack_range = 4, .size = 2, + .default_style = ATTACK_STYLE_RANGED, .can_melee = 0, + .att_level = 0, .str_level = 0, .def_level = 55, .range_level = 120, .magic_level = 120, + .melee_att_bonus = 0, .range_att_bonus = 25, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 30, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 30, .slash_def = 30, .crush_def = 30, .magic_def_bonus = -20, .ranged_def_bonus = 45, + .stun_on_spawn = 0, .can_move = 1 }, + + /* BLOB (JalAk): prayer reader, can melee if close. computed max hit = 29. + attack_speed = 3: InfernoTrainer JalAk.ts — "6 tick cycle = scan exits early, + so it always is double the cooldown between actual attacks." */ + [INF_NPC_BLOB] = { .hp = 40, .attack_speed = 3, .attack_range = 15, .size = 3, + .default_style = ATTACK_STYLE_MAGIC, .can_melee = 1, + .att_level = 160, .str_level = 160, .def_level = 95, .range_level = 160, .magic_level = 160, + .melee_att_bonus = 0, .range_att_bonus = 40, .magic_att_bonus = 45, + .melee_str_bonus = 45, .ranged_str_bonus = 45, .magic_base_dmg = 29, .magic_dmg_pct = 100, + .stab_def = 25, .slash_def = 25, .crush_def = 25, .magic_def_bonus = 25, .ranged_def_bonus = 25, + .stun_on_spawn = 0, .can_move = 1 }, + + /* BLOB_MELEE (JalAkRekKet): melee split. computed max hit = 18 */ + [INF_NPC_BLOB_MELEE] = { .hp = 15, .attack_speed = 4, .attack_range = 1, .size = 1, + .default_style = ATTACK_STYLE_MELEE, .can_melee = 0, + .att_level = 120, .str_level = 120, .def_level = 95, .range_level = 0, .magic_level = 0, + .melee_att_bonus = 0, .range_att_bonus = 25, .magic_att_bonus = 0, + .melee_str_bonus = 25, .ranged_str_bonus = 0, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 25, .slash_def = 25, .crush_def = 25, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 1 }, + + /* BLOB_RANGE (JalAkRekXil): ranged split. computed max hit = 18 */ + [INF_NPC_BLOB_RANGE] = { .hp = 15, .attack_speed = 4, .attack_range = 15, .size = 1, + .default_style = ATTACK_STYLE_RANGED, .can_melee = 0, + .att_level = 0, .str_level = 0, .def_level = 95, .range_level = 120, .magic_level = 0, + .melee_att_bonus = 0, .range_att_bonus = 25, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 25, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 25, + .stun_on_spawn = 0, .can_move = 1 }, + + /* BLOB_MAGE (JalAkRekMej): magic split. wiki max hit = 25. InfernoTrainer has + magicMaxHit()=0 (base Mob) but wiki clearly shows max hit 25 — use wiki value. */ + [INF_NPC_BLOB_MAGE] = { .hp = 15, .attack_speed = 4, .attack_range = 15, .size = 1, + .default_style = ATTACK_STYLE_MAGIC, .can_melee = 0, + .att_level = 0, .str_level = 0, .def_level = 95, .range_level = 0, .magic_level = 120, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 25, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 25, .magic_dmg_pct = 100, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 25, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 1 }, + + /* MELEER (JalImKot): melee slash, dig mechanic. computed max hit = 48 (wiki: 49) */ + [INF_NPC_MELEER] = { .hp = 75, .attack_speed = 4, .attack_range = 1, .size = 4, + .default_style = ATTACK_STYLE_MELEE, .can_melee = 0, + .att_level = 210, .str_level = 290, .def_level = 120, .range_level = 220, .magic_level = 120, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 0, + .melee_str_bonus = 40, .ranged_str_bonus = 0, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 65, .slash_def = 65, .crush_def = 65, .magic_def_bonus = 30, .ranged_def_bonus = 50, + .stun_on_spawn = 0, .can_move = 1 }, + + /* RANGER (JalXil): ranged, can melee if close. computed max hit = 46 */ + [INF_NPC_RANGER] = { .hp = 125, .attack_speed = 4, .attack_range = 15, .size = 3, + .default_style = ATTACK_STYLE_RANGED, .can_melee = 1, + .att_level = 140, .str_level = 180, .def_level = 60, .range_level = 250, .magic_level = 90, + .melee_att_bonus = 0, .range_att_bonus = 40, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 50, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 1 }, + + /* MAGER (JalZek): magic, resurrects dead mobs, can melee. computed max hit = 70 */ + [INF_NPC_MAGER] = { .hp = 220, .attack_speed = 4, .attack_range = 15, .size = 4, + .default_style = ATTACK_STYLE_MAGIC, .can_melee = 1, + .att_level = 370, .str_level = 510, .def_level = 260, .range_level = 510, .magic_level = 300, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 80, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 70, .magic_dmg_pct = 100, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 1 }, + + /* JAD (JalTokJad): 50/50 range/mage. wiki max hit = 113. formula gives 231 ranged + (due to very high range level + bonus), capped to wiki value. */ + [INF_NPC_JAD] = { .hp = 350, .attack_speed = 8, .attack_range = 50, .size = 5, + .default_style = ATTACK_STYLE_RANGED, .can_melee = 0, + .att_level = 750, .str_level = 1020, .def_level = 480, .range_level = 1020, .magic_level = 510, + .melee_att_bonus = 0, .range_att_bonus = 80, .magic_att_bonus = 100, + .melee_str_bonus = 0, .ranged_str_bonus = 80, .magic_base_dmg = 113, .magic_dmg_pct = 100, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .max_hit_cap = 113, + .stun_on_spawn = 0, .can_move = 1 }, + + /* ZUK (TzKalZuk): typeless attacks, wiki max hit = 148 */ + [INF_NPC_ZUK] = { .hp = 1200, .attack_speed = 10, .attack_range = 99, .size = 7, + .default_style = ATTACK_STYLE_MAGIC, .can_melee = 0, + .att_level = 350, .str_level = 600, .def_level = 260, .range_level = 400, .magic_level = 150, + .melee_att_bonus = 0, .range_att_bonus = 550, .magic_att_bonus = 550, + .melee_str_bonus = 200, .ranged_str_bonus = 200, .magic_base_dmg = 148, .magic_dmg_pct = 100, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 350, .ranged_def_bonus = 100, + .stun_on_spawn = 8, .can_move = 0 }, + + /* HEALER_JAD (YtHurKot): melee, heals its Jad */ + [INF_NPC_HEALER_JAD] = { .hp = 90, .attack_speed = 4, .attack_range = 1, .size = 1, + .default_style = ATTACK_STYLE_MELEE, .can_melee = 0, + .att_level = 165, .str_level = 125, .def_level = 100, .range_level = 0, .magic_level = 150, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 130, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 1 }, + + /* HEALER_ZUK (JalMejJak): AOE magic sparks, cannot move */ + [INF_NPC_HEALER_ZUK] = { .hp = 75, .attack_speed = 4, .attack_range = 99, .size = 1, + .default_style = ATTACK_STYLE_MAGIC, .can_melee = 0, + .att_level = 0, .str_level = 0, .def_level = 100, .range_level = 0, .magic_level = 0, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 24, .magic_dmg_pct = 100, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .stun_on_spawn = 0, .can_move = 0 }, + + /* ZUK_SHIELD: no attacks, oscillates left-right */ + [INF_NPC_ZUK_SHIELD] = { .hp = 600, .attack_speed = 0, .attack_range = 0, .size = 5, + .default_style = ATTACK_STYLE_NONE, .can_melee = 0, + .att_level = 0, .str_level = 0, .def_level = 0, .range_level = 0, .magic_level = 0, + .melee_att_bonus = 0, .range_att_bonus = 0, .magic_att_bonus = 0, + .melee_str_bonus = 0, .ranged_str_bonus = 0, .magic_base_dmg = 0, .magic_dmg_pct = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def_bonus = 0, .ranged_def_bonus = 0, + .stun_on_spawn = 1, .can_move = 0 }, +}; + +/* ======================================================================== */ +/* wave compositions */ +/* ======================================================================== */ + +#define INF_MAX_NPCS_PER_WAVE 9 /* wave 62: NNN BB BL M R MA = 9 */ + +typedef struct { + uint8_t types[INF_MAX_NPCS_PER_WAVE]; + int count; +} InfWaveDef; + +static const InfWaveDef INF_WAVES[INF_NUM_WAVES] = { + #define N INF_NPC_NIBBLER + #define B INF_NPC_BAT + #define BL INF_NPC_BLOB + #define M INF_NPC_MELEER + #define R INF_NPC_RANGER + #define MA INF_NPC_MAGER + #define J INF_NPC_JAD + #define Z INF_NPC_ZUK + #define W(...) { .types = { __VA_ARGS__ }, .count = sizeof((uint8_t[]){__VA_ARGS__}) } + + /* waves 1-8: bats + nibblers introduction */ + [0] = W(N,N,N, B), + [1] = W(N,N,N, B,B), + [2] = W(N,N,N, N,N,N), + [3] = W(N,N,N, BL), + [4] = W(N,N,N, B,BL), + [5] = W(N,N,N, B,B,BL), + [6] = W(N,N,N, BL,BL), + [7] = W(N,N,N, N,N,N), + + /* waves 9-17: meleer introduction */ + [8] = W(N,N,N, M), + [9] = W(N,N,N, B,M), + [10] = W(N,N,N, B,B,M), + [11] = W(N,N,N, BL,M), + [12] = W(N,N,N, B,BL,M), + [13] = W(N,N,N, B,B,BL,M), + [14] = W(N,N,N, BL,BL,M), + [15] = W(N,N,N, M,M), + [16] = W(N,N,N, N,N,N), + + /* waves 18-34: ranger introduction */ + [17] = W(N,N,N, R), + [18] = W(N,N,N, B,R), + [19] = W(N,N,N, B,B,R), + [20] = W(N,N,N, BL,R), + [21] = W(N,N,N, B,BL,R), + [22] = W(N,N,N, B,B,BL,R), + [23] = W(N,N,N, BL,BL,R), + [24] = W(N,N,N, M,R), + [25] = W(N,N,N, B,M,R), + [26] = W(N,N,N, B,B,M,R), + [27] = W(N,N,N, BL,M,R), + [28] = W(N,N,N, B,BL,M,R), + [29] = W(N,N,N, B,B,BL,M,R), + [30] = W(N,N,N, BL,BL,M,R), + [31] = W(N,N,N, M,M,R), + [32] = W(N,N,N, R,R), + [33] = W(N,N,N, N,N,N), + + /* waves 35-66: mager introduction (all combinations) */ + [34] = W(N,N,N, MA), + [35] = W(N,N,N, B,MA), + [36] = W(N,N,N, B,B,MA), + [37] = W(N,N,N, BL,MA), + [38] = W(N,N,N, B,BL,MA), + [39] = W(N,N,N, B,B,BL,MA), + [40] = W(N,N,N, BL,BL,MA), + [41] = W(N,N,N, M,MA), + [42] = W(N,N,N, B,M,MA), + [43] = W(N,N,N, B,B,M,MA), + [44] = W(N,N,N, BL,M,MA), + [45] = W(N,N,N, B,BL,M,MA), + [46] = W(N,N,N, B,B,BL,M,MA), + [47] = W(N,N,N, BL,BL,M,MA), + [48] = W(N,N,N, M,M,MA), + [49] = W(N,N,N, R,MA), + [50] = W(N,N,N, B,R,MA), + [51] = W(N,N,N, B,B,R,MA), + [52] = W(N,N,N, BL,R,MA), + [53] = W(N,N,N, B,BL,R,MA), + [54] = W(N,N,N, B,B,BL,R,MA), + [55] = W(N,N,N, BL,BL,R,MA), + [56] = W(N,N,N, M,R,MA), + [57] = W(N,N,N, B,M,R,MA), + [58] = W(N,N,N, B,B,M,R,MA), + [59] = W(N,N,N, BL,M,R,MA), + [60] = W(N,N,N, B,BL,M,R,MA), + [61] = W(N,N,N, B,B,BL,M,R,MA), + [62] = W(N,N,N, BL,BL,M,R,MA), + [63] = W(N,N,N, M,M,R,MA), + [64] = W(N,N,N, R,R,MA), + [65] = W(N,N,N, MA,MA), + + /* waves 67-69: jads + zuk */ + [66] = W(J), + [67] = W(J,J,J), + [68] = W(Z), + + #undef N + #undef B + #undef BL + #undef M + #undef R + #undef MA + #undef J + #undef Z + #undef W +}; + +/* ======================================================================== */ +/* NPC state */ +/* ======================================================================== */ + +/* max active NPCs: wave 62 has 9 + blob splits (3 per blob, up to 2 blobs = 6) + healers */ +#define INF_MAX_NPCS 32 + +/* dead mob store for mager resurrection */ +#define INF_MAX_DEAD_MOBS 16 + +typedef struct { + InfNPCType type; + int x, y; + int hp, max_hp; +} InfDeadMob; + +typedef struct { + InfNPCType type; + int x, y; + int hp, max_hp; + int size; + int attack_timer; /* ticks until next attack */ + int attack_style; /* current attack style (may differ from default for blobs) */ + int active; + int target_x, target_y; /* movement destination */ + int stun_timer; /* ticks of stun remaining (cannot act) */ + + /* type-specific state */ + int no_los_ticks; /* meleer: consecutive ticks without LOS to player */ + int dig_freeze_timer; /* meleer: ticks remaining in dig animation */ + int dig_attack_delay; /* meleer: ticks after emerging before first attack */ + + /* blob prayer-reading state */ + int blob_scan_timer; /* blob: ticks remaining in scan phase (reads prayer) */ + int blob_scanned_prayer; /* blob: prayer read during scan (OverheadPrayer value) */ + + /* jad state */ + int jad_attack_style; /* jad: current attack style (random 50/50) */ + int jad_healer_spawned; /* jad: 1 if healers have been spawned */ + int jad_owner_idx; /* healer: which jad this healer belongs to (-1 = none) */ + + /* mager resurrection state */ + int resurrect_cooldown; /* mager: ticks until next resurrection attempt */ + + /* freeze state (ice barrage) */ + int frozen_ticks; /* ticks remaining in ice barrage freeze */ + + /* heal state */ + int heal_target; /* healer: NPC index being healed (-1 = none) */ + int heal_timer; /* healer: ticks until next heal tick */ + + /* pending hit from player attack (projectile in flight) */ + EncounterPendingHit pending_hit; + + /* death linger: NPC stays visible for death animation + final hitsplat. + >0 means dying (decremented each tick), 0 = alive or fully removed. */ + int death_ticks; + + /* per-tick render flags (cleared at start of each tick) */ + int attacked_this_tick; /* 1 when NPC attacks this tick */ + int moved_this_tick; /* 1 when NPC moves this tick */ + int hit_landed_this_tick; /* 1 when this NPC was hit by player */ + int hit_damage; /* damage dealt to this NPC this tick */ + int hit_spell_type; /* ENCOUNTER_SPELL_* from the pending hit that just landed */ +} InfNPC; + +/* ======================================================================== */ +/* pillar state */ +/* ======================================================================== */ + +typedef struct { + int x, y; + int hp; + int active; +} InfPillar; + +/* ======================================================================== */ +/* zuk state */ +/* ======================================================================== */ + +typedef struct { + /* shield */ + int shield_idx; /* NPC index of shield (-1 if dead) */ + int shield_dir; /* +1 or -1 */ + int shield_freeze; /* ticks of freeze at boundary */ + + /* spawn timers */ + int initial_delay; /* 14 ticks before first attack */ + int set_timer; /* ticks until next set spawn (starts at 72) */ + int set_interval; /* 350 ticks between set spawns */ + int enraged; /* 1 when HP < 240 */ + + int healer_spawned; /* 1 when healers have been spawned */ + int jad_spawned; /* 1 when jad has been spawned during shield phase */ +} InfZukState; + +/* ======================================================================== */ +/* weapon sets and pre-computed stats */ +/* ======================================================================== */ + +typedef enum { + INF_GEAR_MAGE = 0, + INF_GEAR_TBOW, + INF_GEAR_BP, + INF_NUM_WEAPON_SETS +} InfWeaponSet; + +/* gear loadout arrays per weapon set */ +static const uint8_t INF_MAGE_LOADOUT[NUM_GEAR_SLOTS] = { + [GEAR_SLOT_HEAD] = ITEM_MASORI_MASK_F, + [GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER, + [GEAR_SLOT_NECK] = ITEM_OCCULT_NECKLACE, + [GEAR_SLOT_AMMO] = ITEM_DRAGON_ARROWS, + [GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND, + [GEAR_SLOT_SHIELD] = ITEM_CRYSTAL_SHIELD, + [GEAR_SLOT_BODY] = ITEM_ANCESTRAL_TOP, + [GEAR_SLOT_LEGS] = ITEM_ANCESTRAL_BOTTOM, + [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, + [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, + [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, +}; + +static const uint8_t INF_RANGE_TBOW_LOADOUT[NUM_GEAR_SLOTS] = { + [GEAR_SLOT_HEAD] = ITEM_MASORI_MASK_F, + [GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER, + [GEAR_SLOT_NECK] = ITEM_NECKLACE_OF_ANGUISH, + [GEAR_SLOT_AMMO] = ITEM_DRAGON_ARROWS, + [GEAR_SLOT_WEAPON] = ITEM_TWISTED_BOW, + [GEAR_SLOT_SHIELD] = ITEM_NONE, /* tbow is 2h */ + [GEAR_SLOT_BODY] = ITEM_MASORI_BODY_F, + [GEAR_SLOT_LEGS] = ITEM_MASORI_CHAPS_F, + [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, + [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, + [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, +}; + +static const uint8_t INF_RANGE_BP_LOADOUT[NUM_GEAR_SLOTS] = { + [GEAR_SLOT_HEAD] = ITEM_MASORI_MASK_F, + [GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER, + [GEAR_SLOT_NECK] = ITEM_NECKLACE_OF_ANGUISH, + [GEAR_SLOT_AMMO] = ITEM_NONE, /* bp uses darts (baked into weapon ranged_strength=55) */ + [GEAR_SLOT_WEAPON] = ITEM_TOXIC_BLOWPIPE, + [GEAR_SLOT_SHIELD] = ITEM_NONE, /* bp is 2h */ + [GEAR_SLOT_BODY] = ITEM_MASORI_BODY_F, + [GEAR_SLOT_LEGS] = ITEM_MASORI_CHAPS_F, + [GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES, + [GEAR_SLOT_FEET] = ITEM_PEGASIAN_BOOTS, + [GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI, +}; + +/* pointer array for loadout switching */ +static const uint8_t* const INF_LOADOUTS[INF_NUM_WEAPON_SETS] = { + INF_MAGE_LOADOUT, + INF_RANGE_TBOW_LOADOUT, + INF_RANGE_BP_LOADOUT, +}; + +/* tank overlay items (justiciar) */ +#define INF_TANK_HEAD ITEM_JUSTICIAR_FACEGUARD +#define INF_TANK_BODY ITEM_JUSTICIAR_CHESTGUARD +#define INF_TANK_LEGS ITEM_JUSTICIAR_LEGGUARDS + +/* ======================================================================== */ +/* encounter state */ +/* ======================================================================== */ + +typedef struct { + Player player; + + InfNPC npcs[INF_MAX_NPCS]; + InfPillar pillars[INF_NUM_PILLARS]; + InfZukState zuk; + + /* dead mob store for mager resurrection */ + InfDeadMob dead_mobs[INF_MAX_DEAD_MOBS]; + int dead_mob_count; + + /* LOS blockers (rebuilt when pillars change) */ + LOSBlocker los_blockers[INF_NUM_PILLARS]; + int los_blocker_count; + + /* wave tracking */ + int wave; /* current wave (0-indexed, 0-68) */ + int tick; + int wave_spawn_delay; /* ticks until first wave spawns (0 = spawn immediately) */ + int episode_over; + int winner; /* 0 = player won (zuk dead), 1 = player died */ + + /* reward tracking */ + float reward; + float episode_return; /* accumulated reward over entire episode */ + float damage_dealt_this_tick; + float damage_received_this_tick; + int prayer_correct_this_tick; /* count of NPC attacks blocked by prayer this tick */ + int wave_completed_this_tick; + int pillar_lost_this_tick; /* -1 = none, 0-2 = which pillar was destroyed */ + + /* cumulative stats for diagnostics */ + float total_damage_dealt; + float total_damage_received; + int total_waves_cleared; + int ticks_without_action; /* consecutive ticks with no attack or movement */ + int total_prayer_correct; /* times prayer blocked an NPC attack */ + int total_npc_attacks; /* total NPC attacks on player (for prayer_correct_rate) */ + int total_unavoidable_off; /* off-prayer hits where a different style was correctly prayed */ + /* per-tick tracking for multi-style analysis */ + int tick_styles_fired; /* bitmask of styles that fired this tick (bit0=mel,1=rng,2=mag) */ + int tick_attacks_fired; /* count of NPC attacks that fired this tick */ + int total_idle_ticks; /* cumulative ticks of ticks_without_action > 0 */ + int total_brews_used; /* brew doses consumed this episode */ + int total_blood_healed; /* HP healed via blood barrage this episode */ + int total_npc_kills; /* NPCs killed this episode */ + int total_gear_switches; /* gear switch actions this episode */ + + /* per-tick reward event flags (cleared each tick) */ + int brewed_this_tick; /* 1 if player drank a brew this tick */ + int blood_heal_this_tick; /* HP healed from blood barrage this tick */ + + /* player combat state */ + OverheadPrayer active_prayer; + int prayer_drain_counter; /* shared drain system counter (see encounter_drain_prayer) */ + int player_attack_timer; + int player_attack_target; /* NPC index or -1 */ + int player_brew_doses; + int player_restore_doses; + int player_bastion_doses; + int player_stamina_doses; + int player_potion_timer; + + /* gear state */ + InfWeaponSet weapon_set; + EncounterLoadoutStats loadout_stats[INF_NUM_WEAPON_SETS]; + int armor_tank; /* 1 = justiciar overlay active */ + int stamina_active_ticks; /* countdown for stamina effect */ + int spell_choice; /* 0 = blood barrage, 1 = ice barrage */ + + /* per-tick player attack event for renderer projectiles */ + int player_attacked_this_tick; /* 1 if player fired an attack this tick */ + int player_attack_npc_idx; /* NPC index targeted by player attack */ + int player_attack_dmg; /* total damage dealt */ + int player_attack_style_id; /* ATTACK_STYLE_* of the player attack */ + + /* pending hits on player from NPC attacks (projectiles in flight) */ + EncounterPendingHit player_pending_hits[ENCOUNTER_MAX_PENDING_HITS]; + int player_pending_hit_count; + + /* nibbler pillar target: random pillar chosen per wave, all nibblers attack it */ + int nibbler_target_pillar; + + /* spawn position shuffle buffer */ + int spawn_order[INF_NUM_SPAWN_POS]; + + /* collision map (loaded from cache, passed via put_ptr) */ + const CollisionMap* collision_map; + int world_offset_x, world_offset_y; + + /* human click-to-move destination (-1 = no dest) */ + int player_dest_x, player_dest_y; + + /* config */ + int start_wave; /* for curriculum: start from a later wave */ + uint32_t rng_state; + + /* reward shaping config (sweepable via env kwargs) */ + float wave_reward_base; /* base multiplier for exponential wave reward (default 0.001) */ + float wave_reward_scale; /* exponential base: reward = base * scale^wave (default 1.1) */ + float brew_penalty; /* penalty per brew dose in early waves (default 0.03) */ + float brew_penalty_midpoint; /* sigmoid midpoint wave (default 35) */ + float brew_penalty_width; /* sigmoid transition width (default 5.0) */ + float blood_heal_reward; /* reward per 20 HP healed via blood barrage (default 0.01) */ + float prayer_reward; /* reward per NPC attack blocked by correct prayer (default 0.01) */ + + Log log; +} InfernoState; + +/* prayer check and RNG: use shared encounter_prayer_correct_for_style(), + encounter_rand_int(), encounter_rand_float() from osrs_combat_shared.h */ + +static void inf_shuffle_spawns(InfernoState* s) { + for (int i = 0; i < INF_NUM_SPAWN_POS; i++) + s->spawn_order[i] = i; + encounter_shuffle(s->spawn_order, INF_NUM_SPAWN_POS, &s->rng_state); +} + +/* ======================================================================== */ +/* LOS helper: rebuild blocker array from active pillars */ +/* ======================================================================== */ + +static void inf_rebuild_los(InfernoState* s) { + s->los_blocker_count = 0; + for (int i = 0; i < INF_NUM_PILLARS; i++) { + if (s->pillars[i].active) { + LOSBlocker* b = &s->los_blockers[s->los_blocker_count++]; + b->x = s->pillars[i].x; + b->y = s->pillars[i].y; + b->size = INF_PILLAR_SIZE; + b->los_mask = LOS_FULL_MASK; + } + } +} + +/* check if NPC at index i has LOS to player */ +static int inf_npc_has_los(InfernoState* s, int i) { + InfNPC* npc = &s->npcs[i]; + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + return npc_has_line_of_sight(s->los_blockers, s->los_blocker_count, + npc->x, npc->y, npc->size, + s->player.x, s->player.y, + stats->attack_range); +} + +/* ======================================================================== */ +/* dead mob store for mager resurrection */ +/* ======================================================================== */ + +static void inf_store_dead_mob(InfernoState* s, InfNPC* npc) { + if (s->dead_mob_count >= INF_MAX_DEAD_MOBS) return; + /* only store resurrectable types (not healers, not shield, not jad/zuk) */ + if (npc->type == INF_NPC_HEALER_JAD || npc->type == INF_NPC_HEALER_ZUK || + npc->type == INF_NPC_ZUK_SHIELD || npc->type == INF_NPC_ZUK || + npc->type == INF_NPC_JAD) return; + + InfDeadMob* dm = &s->dead_mobs[s->dead_mob_count++]; + dm->type = npc->type; + dm->x = npc->x; + dm->y = npc->y; + dm->hp = npc->max_hp / 2; /* resurrect at 50% HP */ + dm->max_hp = npc->max_hp; +} + +/* ======================================================================== */ +/* forward declarations */ +/* ======================================================================== */ + +static float inf_compute_reward(InfernoState* s); +static void inf_spawn_wave(InfernoState* s); +static void inf_tick_npcs(InfernoState* s); +static void inf_tick_player(InfernoState* s, const int* actions); +static void inf_apply_npc_death(InfernoState* s, int npc_idx); + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static EncounterState* inf_create(void) { + InfernoState* s = (InfernoState*)calloc(1, sizeof(InfernoState)); + s->rng_state = 12345; + return (EncounterState*)s; +} + +static void inf_destroy(EncounterState* state) { + free(state); +} + +static void inf_reset(EncounterState* state, uint32_t seed) { + InfernoState* s = (InfernoState*)state; + Log saved_log = s->log; + int saved_start = s->start_wave; + uint32_t saved_rng = s->rng_state; + const CollisionMap* saved_cmap = s->collision_map; + int saved_wox = s->world_offset_x; + int saved_woy = s->world_offset_y; + /* save reward shaping config across reset (set once via put_float) */ + float saved_wrb = s->wave_reward_base; + float saved_wrs = s->wave_reward_scale; + float saved_bp = s->brew_penalty; + float saved_bpm = s->brew_penalty_midpoint; + float saved_bpw = s->brew_penalty_width; + float saved_bhr = s->blood_heal_reward; + float saved_pr = s->prayer_reward; + memset(s, 0, sizeof(InfernoState)); + s->log = saved_log; + s->start_wave = saved_start; + s->collision_map = saved_cmap; + s->world_offset_x = saved_wox; + s->world_offset_y = saved_woy; + s->rng_state = encounter_resolve_seed(saved_rng, seed); + s->wave_reward_base = saved_wrb; + s->wave_reward_scale = saved_wrs; + s->brew_penalty = saved_bp; + s->brew_penalty_midpoint = saved_bpm; + s->brew_penalty_width = saved_bpw; + s->blood_heal_reward = saved_bhr; + s->prayer_reward = saved_pr; + + /* human click-to-move: no destination after reset */ + s->player_dest_x = -1; + s->player_dest_y = -1; + + /* player */ + s->player.entity_type = ENTITY_PLAYER; + s->player.base_hitpoints = 99; + s->player.current_hitpoints = 99; + s->player.base_prayer = 99; + s->player.current_prayer = 99; + s->player.base_attack = 99; + s->player.base_strength = 99; + s->player.base_defence = 99; + s->player.base_ranged = 99; + s->player.base_magic = 99; + s->player.current_ranged = 99; + s->player.current_magic = 99; + s->player.current_attack = 99; + s->player.current_strength = 99; + s->player.current_defence = 99; + /* start in mage gear (kodai + crystal shield + ancestral) */ + s->weapon_set = INF_GEAR_MAGE; + s->armor_tank = 0; + encounter_apply_loadout(&s->player, INF_MAGE_LOADOUT, GEAR_MAGE); + { + uint8_t tank_extra[NUM_GEAR_SLOTS]; + memset(tank_extra, ITEM_NONE, NUM_GEAR_SLOTS); + tank_extra[GEAR_SLOT_HEAD] = INF_TANK_HEAD; + tank_extra[GEAR_SLOT_BODY] = INF_TANK_BODY; + tank_extra[GEAR_SLOT_LEGS] = INF_TANK_LEGS; + encounter_populate_inventory(&s->player, INF_LOADOUTS, INF_NUM_WEAPON_SETS, tank_extra); + } + s->player_brew_doses = 32; /* 8 pots x 4 doses */ + s->player_restore_doses = 40; /* 10 pots x 4 doses */ + s->player_bastion_doses = 4; /* 1 pot x 4 doses */ + s->player_stamina_doses = 4; /* 1 pot x 4 doses */ + s->stamina_active_ticks = 0; + s->active_prayer = PRAYER_NONE; + s->player_attack_target = -1; + s->player.special_energy = 100; + s->player.special_regen_ticks = 0; + + /* compute loadout stats from item database (replaces old hardcoded INF_WEAPON_STATS) */ + encounter_compute_loadout_stats(INF_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, + ENCOUNTER_PRAYER_AUGURY, 99, 0, 30, &s->loadout_stats[INF_GEAR_MAGE]); + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, 99, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, 99, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + + /* spawn position depends on wave */ + int is_zuk_wave = (saved_start >= 68); + s->player.x = is_zuk_wave ? INF_ZUK_PLAYER_START_X : INF_PLAYER_START_X; + s->player.y = is_zuk_wave ? INF_ZUK_PLAYER_START_Y : INF_PLAYER_START_Y; + + /* pillars */ + for (int i = 0; i < INF_NUM_PILLARS; i++) { + s->pillars[i].x = INF_PILLAR_POS[i][0]; + s->pillars[i].y = INF_PILLAR_POS[i][1]; + s->pillars[i].hp = INF_PILLAR_HP; + s->pillars[i].active = 1; + } + inf_rebuild_los(s); + + /* dead mob store */ + s->dead_mob_count = 0; + + /* start at configured wave (for curriculum). + delay first wave spawn by 10 ticks — in-game there's a delay + after entering the inferno before wave 1 begins. */ + s->wave = s->start_wave; + s->wave_spawn_delay = 10; +} + +/* ======================================================================== */ +/* spawn: place NPCs for current wave */ +/* ======================================================================== */ + +/* find a free NPC slot, return index or -1 */ +static int inf_find_free_npc(InfernoState* s) { + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (!s->npcs[i].active) return i; + } + return -1; +} + +/* initialize an NPC at a given slot */ +static void inf_init_npc(InfernoState* s, int idx, InfNPCType type, int x, int y) { + InfNPC* npc = &s->npcs[idx]; + const InfNPCStats* stats = &INF_NPC_STATS[type]; + memset(npc, 0, sizeof(InfNPC)); + + npc->type = type; + npc->hp = stats->hp; + npc->max_hp = stats->hp; + npc->size = stats->size; + npc->attack_timer = stats->attack_speed; + npc->attack_style = stats->default_style; + npc->active = 1; + npc->x = x; + npc->y = y; + npc->target_x = x; + npc->target_y = y; + npc->heal_target = -1; + npc->jad_owner_idx = -1; + npc->blob_scanned_prayer = -1; + npc->stun_timer = stats->stun_on_spawn; +} + +static void inf_spawn_wave(InfernoState* s) { + if (s->wave >= INF_NUM_WAVES) return; + + const InfWaveDef* w = &INF_WAVES[s->wave]; + + /* clear all NPCs and pending hits */ + for (int i = 0; i < INF_MAX_NPCS; i++) s->npcs[i].active = 0; + s->player_pending_hit_count = 0; + + /* clear dead mob store each wave */ + s->dead_mob_count = 0; + + /* shuffle spawn positions */ + inf_shuffle_spawns(s); + + /* pick random active pillar for nibblers this wave */ + { + int active_pillars[INF_NUM_PILLARS]; + int num_active = 0; + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (s->pillars[p].active) active_pillars[num_active++] = p; + } + s->nibbler_target_pillar = (num_active > 0) + ? active_pillars[encounter_rand_int(&s->rng_state, num_active)] : -1; + } + + /* zuk wave (wave 69, index 68) is special */ + if (s->wave == 68) { + /* spawn Zuk — fixed position, cannot move */ + int zuk_idx = inf_find_free_npc(s); + if (zuk_idx >= 0) { + inf_init_npc(s, zuk_idx, INF_NPC_ZUK, 20, 52); + s->npcs[zuk_idx].stun_timer = 14; /* initial delay */ + } + + /* spawn shield */ + int shield_idx = inf_find_free_npc(s); + if (shield_idx >= 0) { + inf_init_npc(s, shield_idx, INF_NPC_ZUK_SHIELD, 23, 44); + s->zuk.shield_idx = shield_idx; + s->zuk.shield_dir = (encounter_rand_int(&s->rng_state, 2) == 0) ? 1 : -1; + s->zuk.shield_freeze = 1; /* 1-tick freeze on spawn */ + } + + /* zuk state */ + s->zuk.initial_delay = 14; + s->zuk.set_timer = 72; + s->zuk.set_interval = 350; + s->zuk.enraged = 0; + s->zuk.healer_spawned = 0; + s->zuk.jad_spawned = 0; + + /* player starts at zuk position */ + s->player.x = INF_ZUK_PLAYER_START_X; + s->player.y = INF_ZUK_PLAYER_START_Y; + return; + } + + /* regular waves: spawn NPCs at shuffled positions */ + int spawn_idx = 0; + for (int i = 0; i < w->count && i < INF_MAX_NPCS; i++) { + InfNPCType type = (InfNPCType)w->types[i]; + int slot = inf_find_free_npc(s); + if (slot < 0) break; + + int sx, sy; + if (type == INF_NPC_NIBBLER) { + /* nibblers spawn near pillars with small random offset */ + sx = INF_NIBBLER_SPAWN_X + encounter_rand_int(&s->rng_state, 3) - 1; + sy = INF_NIBBLER_SPAWN_Y + encounter_rand_int(&s->rng_state, 3) - 1; + } else { + int pi = s->spawn_order[spawn_idx % INF_NUM_SPAWN_POS]; + sx = INF_SPAWN_POS[pi][0]; + sy = INF_SPAWN_POS[pi][1]; + spawn_idx++; + } + + inf_init_npc(s, slot, type, sx, sy); + } +} + +/* ======================================================================== */ +/* NPC AI: movement */ +/* ======================================================================== */ + +static int inf_in_arena(int x, int y) { + return x >= INF_ARENA_MIN_X && x <= INF_ARENA_MAX_X && + y >= INF_ARENA_MIN_Y && y <= INF_ARENA_MAX_Y; +} + +static int inf_blocked_by_pillar(InfernoState* s, int x, int y, int size) { + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (!s->pillars[p].active) continue; + if (los_aabb_overlap(x, y, size, + s->pillars[p].x, s->pillars[p].y, INF_PILLAR_SIZE)) + return 1; + } + return 0; +} + +/* BFS dynamic obstacle callback — pillars block pathfinding. + receives absolute world coords, converts to local for pillar check. */ +static int inf_pathfind_blocked(void* ctx, int abs_x, int abs_y) { + InfernoState* s = (InfernoState*)ctx; + int lx = abs_x - s->world_offset_x; + int ly = abs_y - s->world_offset_y; + return inf_blocked_by_pillar(s, lx, ly, 1); +} + +/* NPC movement blocked callback for encounter_npc_step_toward. + checks arena bounds, pillars, collision map, and NPC-vs-NPC collision. + ctx is a temporary struct with state + current NPC index. */ +typedef struct { InfernoState* s; int self_idx; } InfMoveCtx; + +static int inf_npc_blocked(void* ctx, int x, int y, int size) { + InfMoveCtx* mc = (InfMoveCtx*)ctx; + InfernoState* s = mc->s; + if (!inf_in_arena(x, y)) return 1; + if (inf_blocked_by_pillar(s, x, y, size)) return 1; + if (s->collision_map && + !collision_tile_walkable(s->collision_map, 0, + x + s->world_offset_x, y + s->world_offset_y)) + return 1; + /* NPC-vs-NPC collision: check all active NPCs except self and nibblers + (nibblers don't consume space — other mobs walk through them) */ + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (i == mc->self_idx) continue; + InfNPC* other = &s->npcs[i]; + if (!other->active) continue; + if (other->type == INF_NPC_NIBBLER) continue; /* nibblers transparent */ + if (los_aabb_overlap(x, y, size, other->x, other->y, other->size)) + return 1; + } + return 0; +} + +/* forward declaration — defined after potions/food section */ +static int inf_tile_walkable(void* ctx, int x, int y); + +static void inf_npc_move(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (!npc->active) return; + if (npc->stun_timer > 0) return; + if (npc->dig_freeze_timer > 0) return; + if (npc->frozen_ticks > 0) return; + + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + if (!stats->can_move) return; + + /* OSRS: NPC shuffles off player tile when overlapping (Mob.ts:109-153). + if the NPC steps out, skip further movement this tick. */ + if (npc->type != INF_NPC_NIBBLER) { + int stepped = encounter_npc_step_out_from_under( + &npc->x, &npc->y, npc->size, + s->player.x, s->player.y, + inf_tile_walkable, s, &s->rng_state); + if (stepped) { + npc->moved_this_tick = 1; + return; + } + } + + /* ranged/magic NPCs stop moving when they have LOS to the player. + this is the core OSRS mechanic: NPCs only walk toward their target + while they cannot see it. once LOS is established, they attack. */ + if (npc->type != INF_NPC_NIBBLER && stats->attack_range > 1) { + if (inf_npc_has_los(s, idx)) return; + } + + /* target selection */ + int tx, ty; + if (npc->type == INF_NPC_NIBBLER) { + int p = s->nibbler_target_pillar; + if (p >= 0 && p < INF_NUM_PILLARS && s->pillars[p].active) { + tx = s->pillars[p].x; + ty = s->pillars[p].y; + } else { + int found = 0; + for (int pp = 0; pp < INF_NUM_PILLARS; pp++) { + if (s->pillars[pp].active) { + tx = s->pillars[pp].x; + ty = s->pillars[pp].y; + found = 1; + break; + } + } + if (!found) { tx = s->player.x; ty = s->player.y; } + } + } else { + tx = s->player.x; + ty = s->player.y; + } + + /* greedy step toward target using shared helper. + target_size=1 for player, attack_range from NPC stats. + the shared function stops automatically when within attack range. */ + int ox = npc->x, oy = npc->y; + InfMoveCtx mc = { s, idx }; + int target_size = (npc->type == INF_NPC_NIBBLER) ? INF_PILLAR_SIZE : 1; + encounter_npc_step_toward(&npc->x, &npc->y, tx, ty, npc->size, + target_size, stats->attack_range, + inf_npc_blocked, &mc); + if (npc->x != ox || npc->y != oy) + npc->moved_this_tick = 1; +} + +/* ======================================================================== */ +/* NPC AI: meleer dig mechanic */ +/* ======================================================================== */ + +/* meleer digs when no LOS for 38+ ticks, 10% per tick, forced at 50 */ +static void inf_meleer_dig_check(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (npc->type != INF_NPC_MELEER || !npc->active) return; + if (npc->dig_freeze_timer > 0) { + npc->dig_freeze_timer--; + if (npc->dig_freeze_timer == 0 && npc->dig_attack_delay == 0) { + /* emerge: place near player */ + npc->x = s->player.x + (encounter_rand_int(&s->rng_state, 3) - 1); + npc->y = s->player.y + (encounter_rand_int(&s->rng_state, 3) - 1); + npc->stun_timer = 2; /* 2-tick freeze after emerging */ + npc->dig_attack_delay = 6; /* 6-tick delay before attacking */ + npc->no_los_ticks = 0; + } + return; + } + if (npc->dig_attack_delay > 0) { + npc->dig_attack_delay--; + return; + } + + /* track LOS absence */ + if (!inf_npc_has_los(s, idx)) { + npc->no_los_ticks++; + } else { + npc->no_los_ticks = 0; + return; + } + + /* check dig trigger */ + if (npc->no_los_ticks >= 50) { + /* forced dig */ + npc->dig_freeze_timer = 6; + } else if (npc->no_los_ticks >= 38) { + /* 10% chance per tick */ + if (encounter_rand_int(&s->rng_state, 10) == 0) { + npc->dig_freeze_timer = 6; + } + } +} + +/* ======================================================================== */ +/* NPC AI: attacks */ +/* ======================================================================== */ + +static void inf_npc_attack(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (!npc->active) return; + if (npc->stun_timer > 0) { npc->stun_timer--; return; } + if (npc->dig_freeze_timer > 0) return; + if (npc->dig_attack_delay > 0) return; + /* decrement first, then check — matches SDK (Unit.ts:237 attackDelay-- then + Mob.ts:326 attackDelay <= 0). without this, NPCs attack 1 tick slower. */ + if (npc->attack_timer > 0) npc->attack_timer--; + if (npc->attack_timer > 0) return; + + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + + /* shield and zuk healers don't attack normally (handled separately) */ + if (npc->type == INF_NPC_ZUK_SHIELD) return; + + /* nibbler attacks pillars, not player */ + if (npc->type == INF_NPC_NIBBLER) { + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (!s->pillars[p].active) continue; + int ddx = npc->x - s->pillars[p].x; + int ddy = npc->y - s->pillars[p].y; + if (ddx >= -1 && ddx <= INF_PILLAR_SIZE && ddy >= -1 && ddy <= INF_PILLAR_SIZE) { + /* nibblers deal 0-4 damage per hit (ref: InfernoTrainer JalNib.ts). + bypasses combat formula — custom weapon directly rolls rand(0..4). */ + int dmg = encounter_rand_int(&s->rng_state, 5); + s->pillars[p].hp -= dmg; + if (s->pillars[p].hp <= 0) { + s->pillars[p].active = 0; + s->pillar_lost_this_tick = p; + inf_rebuild_los(s); + /* pillar death AOE: deals damage to all mobs + player within 1 tile */ + for (int n = 0; n < INF_MAX_NPCS; n++) { + if (!s->npcs[n].active) continue; + int ndx = s->npcs[n].x - s->pillars[p].x; + int ndy = s->npcs[n].y - s->pillars[p].y; + if (ndx >= -1 && ndx <= INF_PILLAR_SIZE && ndy >= -1 && ndy <= INF_PILLAR_SIZE) { + encounter_damage_npc(&s->npcs[n].hp, &s->npcs[n].hit_landed_this_tick, &s->npcs[n].hit_damage, 12); + inf_apply_npc_death(s, n); + } + } + /* also damages the player if standing next to the pillar */ + { + int pdx = s->player.x - s->pillars[p].x; + int pdy = s->player.y - s->pillars[p].y; + if (pdx >= -1 && pdx <= INF_PILLAR_SIZE && pdy >= -1 && pdy <= INF_PILLAR_SIZE) { + /* TODO: find actual pillar collapse damage formula (server-side). + 49 observed in-game, likely scales with something. */ + encounter_damage_player(&s->player, 49, &s->damage_received_this_tick); + } + } + } + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + return; + } + } + return; + } + + /* zuk healer: heals zuk + AOE sparks on player (instant, no projectile delay) */ + if (npc->type == INF_NPC_HEALER_ZUK) { + /* heal Zuk */ + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active && s->npcs[i].type == INF_NPC_ZUK) { + int heal = encounter_rand_int(&s->rng_state, 25); /* 0-24 HP */ + s->npcs[i].hp += heal; + if (s->npcs[i].hp > s->npcs[i].max_hp) + s->npcs[i].hp = s->npcs[i].max_hp; + break; + } + } + /* AOE sparks on player — queue as pending hit with magic delay */ + int max_hit = osrs_npc_magic_max_hit(stats->magic_base_dmg, stats->magic_dmg_pct); + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + if (s->player_pending_hit_count < ENCOUNTER_MAX_PENDING_HITS) { + int d = encounter_dist_to_npc(npc->x, npc->y, s->player.x, s->player.y, 1); + EncounterPendingHit* ph = &s->player_pending_hits[s->player_pending_hit_count++]; + ph->active = 1; + ph->damage = dmg; + ph->ticks_remaining = encounter_magic_hit_delay(d, 0); + ph->attack_style = ATTACK_STYLE_MAGIC; + ph->check_prayer = 0; + } + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + return; + } + + /* jad healer: heals its jad, can be pulled to attack player */ + if (npc->type == INF_NPC_HEALER_JAD) { + int jad_idx = npc->jad_owner_idx; + if (jad_idx >= 0 && s->npcs[jad_idx].active && + s->npcs[jad_idx].type == INF_NPC_JAD) { + /* heal jad 0-19 HP with 3 tick delay (simplified: heal every attack tick) */ + int heal = encounter_rand_int(&s->rng_state, 20); + s->npcs[jad_idx].hp += heal; + if (s->npcs[jad_idx].hp > s->npcs[jad_idx].max_hp) + s->npcs[jad_idx].hp = s->npcs[jad_idx].max_hp; + } + /* if player has targeted this healer, attack player in melee range. + melee = instant (delay 0), apply immediately. */ + if (encounter_dist_to_npc(s->player.x, s->player.y, npc->x, npc->y, 1) == 1) { + int max_hit = osrs_npc_melee_max_hit(stats->str_level, stats->melee_str_bonus); + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + /* accuracy roll */ + int att_roll = osrs_npc_attack_roll(stats->att_level, stats->melee_att_bonus); + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + int def_bonus = ls->def_stab; /* melee: use stab def as approximation */ + int def_roll = osrs_player_def_roll_vs_npc(s->player.current_defence, s->player.current_magic, def_bonus, ATTACK_STYLE_MELEE); + if (encounter_rand_float(&s->rng_state) >= osrs_hit_chance(att_roll, def_roll)) dmg = 0; + int prayer_matches = (s->active_prayer == PRAYER_PROTECT_MELEE); + if (prayer_matches) { dmg = 0; s->prayer_correct_this_tick = 1; } + encounter_damage_player(&s->player, dmg, &s->damage_received_this_tick); + } + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + return; + } + + /* check LOS for ranged/magic attackers */ + if (stats->attack_range > 1 && !inf_npc_has_los(s, idx)) return; + + /* compute distance to player */ + int dist = encounter_dist_to_npc(s->player.x, s->player.y, + npc->x, npc->y, npc->size); + if (dist == 0 || dist > stats->attack_range) return; + + /* blob prayer reading: 2-phase attack with attack_speed = 3. + scan tick: read player prayer, set timer, return (shows scan animation). + fire tick: determine style from scanned prayer, fall through to common attack. + total cycle = 6 ticks (3 scan + 3 cooldown). + ref: InfernoTrainer JalAk.ts attackIfPossible() */ + if (npc->type == INF_NPC_BLOB) { + if (npc->blob_scanned_prayer < 0) { + /* no pending scan → start scan phase */ + npc->blob_scanned_prayer = (int)s->active_prayer; + npc->attacked_this_tick = 1; /* triggers scan animation */ + npc->attack_timer = stats->attack_speed; /* 3 */ + return; + } + /* has pending scan → determine style and fall through to fire */ + OverheadPrayer read_prayer = (OverheadPrayer)npc->blob_scanned_prayer; + if (read_prayer == PRAYER_PROTECT_MAGIC) + npc->attack_style = ATTACK_STYLE_RANGED; + else if (read_prayer == PRAYER_PROTECT_RANGED) + npc->attack_style = ATTACK_STYLE_MAGIC; + else + npc->attack_style = (encounter_rand_int(&s->rng_state, 2) == 0) + ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + npc->blob_scanned_prayer = -1; + /* fall through to common attack code */ + } + + /* determine actual attack style */ + int actual_style = npc->attack_style; + + /* jad: random 50/50 range or magic each attack */ + if (npc->type == INF_NPC_JAD) { + actual_style = (encounter_rand_int(&s->rng_state, 2) == 0) ? ATTACK_STYLE_RANGED : ATTACK_STYLE_MAGIC; + npc->jad_attack_style = actual_style; + } + + /* zuk: typeless attack (not blockable by prayer) */ + if (npc->type == INF_NPC_ZUK) { + /* check if shield blocks the attack */ + int shield_idx = s->zuk.shield_idx; + if (shield_idx >= 0 && s->npcs[shield_idx].active) { + InfNPC* shield = &s->npcs[shield_idx]; + int shield_left = shield->x; + int shield_right = shield->x + shield->size; + /* shield blocks if player within shield x range AND y >= 41 */ + if (s->player.x >= shield_left && s->player.x < shield_right && + s->player.y >= 41) { + /* shield absorbs the hit (typeless — no accuracy roll) */ + int max_hit = osrs_npc_magic_max_hit(stats->magic_base_dmg, stats->magic_dmg_pct); + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + encounter_damage_npc(&shield->hp, &shield->hit_landed_this_tick, &shield->hit_damage, dmg); + if (shield->hp <= 0) { + shield->active = 0; + s->zuk.shield_idx = -1; + } + npc->attacked_this_tick = 1; + npc->attack_timer = s->zuk.enraged ? 7 : stats->attack_speed; + return; + } + } + + /* typeless hit — not blockable by prayer, no accuracy roll, instant */ + int max_hit = osrs_npc_magic_max_hit(stats->magic_base_dmg, stats->magic_dmg_pct); + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + encounter_damage_player(&s->player, dmg, &s->damage_received_this_tick); + npc->attacked_this_tick = 1; + npc->attack_timer = s->zuk.enraged ? 7 : stats->attack_speed; + return; + } + + /* melee switchover for ranger/mager: when close */ + if (stats->can_melee && dist == 1) { + actual_style = ATTACK_STYLE_MELEE; + } + + /* max hit from stats + bonuses via OSRS combat formulas */ + int max_hit = osrs_npc_max_hit(actual_style, + stats->str_level, stats->range_level, + stats->melee_str_bonus, stats->ranged_str_bonus, + stats->magic_base_dmg, stats->magic_dmg_pct); + if (stats->max_hit_cap > 0 && max_hit > stats->max_hit_cap) + max_hit = stats->max_hit_cap; + int dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + + /* accuracy roll: NPC attack roll vs player defence roll */ + { + int att_lvl, att_bonus; + if (actual_style == ATTACK_STYLE_MELEE) { + att_lvl = stats->att_level; att_bonus = stats->melee_att_bonus; + } else if (actual_style == ATTACK_STYLE_RANGED) { + att_lvl = stats->range_level; att_bonus = stats->range_att_bonus; + } else { + att_lvl = stats->magic_level; att_bonus = stats->magic_att_bonus; + } + int att_roll = osrs_npc_attack_roll(att_lvl, att_bonus); + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + int def_bonus; + if (actual_style == ATTACK_STYLE_RANGED) def_bonus = ls->def_ranged; + else if (actual_style == ATTACK_STYLE_MAGIC) def_bonus = ls->def_magic; + else def_bonus = ls->def_stab; /* melee: stab as approximation */ + int def_roll = osrs_player_def_roll_vs_npc(s->player.current_defence, s->player.current_magic, def_bonus, actual_style); + if (encounter_rand_float(&s->rng_state) >= osrs_hit_chance(att_roll, def_roll)) + dmg = 0; /* missed */ + } + + /* bat special: drain run energy on every hit */ + if (npc->type == INF_NPC_BAT && dmg > 0) { + s->player.run_energy -= 300; + if (s->player.run_energy < 0) s->player.run_energy = 0; + } + + /* compute hit delay based on attack style */ + int hit_delay = 0; + if (actual_style == ATTACK_STYLE_MAGIC) + hit_delay = encounter_magic_hit_delay(dist, 0); + else if (actual_style == ATTACK_STYLE_RANGED) + hit_delay = encounter_ranged_hit_delay(dist, 0); + /* melee: delay = 0 */ + + /* track which styles fired this tick for multi-style analysis */ + if (actual_style == ATTACK_STYLE_MELEE) s->tick_styles_fired |= 1; + else if (actual_style == ATTACK_STYLE_RANGED) s->tick_styles_fired |= 2; + else if (actual_style == ATTACK_STYLE_MAGIC) s->tick_styles_fired |= 4; + s->tick_attacks_fired++; + + if (hit_delay == 0) { + /* melee: instant damage, check prayer now */ + int prayer_matches = encounter_prayer_correct_for_style(s->active_prayer, actual_style); + if (prayer_matches) { dmg = 0; s->prayer_correct_this_tick++; } + encounter_damage_player(&s->player, dmg, &s->damage_received_this_tick); + } else { + /* ranged/magic: queue pending hit on player */ + if (s->player_pending_hit_count < ENCOUNTER_MAX_PENDING_HITS) { + int is_jad = (npc->type == INF_NPC_JAD); + if (!is_jad) { + int prayer_matches = encounter_prayer_correct_for_style(s->active_prayer, actual_style); + if (prayer_matches) { dmg = 0; s->prayer_correct_this_tick++; } + } + EncounterPendingHit* ph = &s->player_pending_hits[s->player_pending_hit_count++]; + ph->active = 1; + ph->damage = dmg; + ph->ticks_remaining = hit_delay; + ph->attack_style = actual_style; + ph->check_prayer = is_jad ? 1 : 0; + } + } + + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + + /* jad attack speed varies by wave */ + if (npc->type == INF_NPC_JAD) { + if (s->wave == 66) npc->attack_timer = 8; /* wave 67 */ + else if (s->wave == 67) npc->attack_timer = 9; /* wave 68 */ + else npc->attack_timer = 8; /* zuk wave */ + } +} + +/* ======================================================================== */ +/* NPC AI: mager resurrection */ +/* ======================================================================== */ + +/* mager resurrects dead mobs: 10% chance per attack, 8-tick cooldown. + only on waves 1-68 (indices 0-67), NOT during Zuk wave. */ +static void inf_mager_resurrect(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (npc->type != INF_NPC_MAGER || !npc->active) return; + if (s->wave >= 68) return; /* no resurrection during Zuk wave */ + if (npc->resurrect_cooldown > 0) { npc->resurrect_cooldown--; return; } + if (s->dead_mob_count == 0) return; + + /* 10% chance per attack tick */ + if (encounter_rand_int(&s->rng_state, 10) != 0) return; + + /* pick a random dead mob */ + int di = encounter_rand_int(&s->rng_state, s->dead_mob_count); + InfDeadMob* dm = &s->dead_mobs[di]; + + int slot = inf_find_free_npc(s); + if (slot < 0) return; + + /* spawn near mager */ + int rx = npc->x + encounter_rand_int(&s->rng_state, 3) - 1; + int ry = npc->y + encounter_rand_int(&s->rng_state, 3) - 1; + inf_init_npc(s, slot, dm->type, rx, ry); + s->npcs[slot].hp = dm->hp; /* 50% of max HP */ + s->npcs[slot].max_hp = dm->max_hp; + + /* remove from dead store (swap with last) */ + s->dead_mobs[di] = s->dead_mobs[s->dead_mob_count - 1]; + s->dead_mob_count--; + + /* 8-tick cooldown */ + npc->resurrect_cooldown = 8; +} + +/* ======================================================================== */ +/* NPC AI: jad healer spawning */ +/* ======================================================================== */ + +static void inf_jad_check_healers(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (npc->type != INF_NPC_JAD || !npc->active) return; + if (npc->jad_healer_spawned) return; + + /* spawn healers when below 50% HP */ + if (npc->hp > npc->max_hp / 2) return; + npc->jad_healer_spawned = 1; + + int num_healers; + if (s->wave == 66) num_healers = 5; /* wave 67: 5 healers */ + else if (s->wave == 67) num_healers = 3; /* wave 68: 3 per jad */ + else num_healers = 3; /* zuk wave: 3 */ + + for (int h = 0; h < num_healers; h++) { + int slot = inf_find_free_npc(s); + if (slot < 0) break; + int hx = npc->x + encounter_rand_int(&s->rng_state, 5) - 2; + int hy = npc->y + encounter_rand_int(&s->rng_state, 5) - 2; + inf_init_npc(s, slot, INF_NPC_HEALER_JAD, hx, hy); + s->npcs[slot].jad_owner_idx = idx; + } +} + +/* ======================================================================== */ +/* NPC AI: zuk phases */ +/* ======================================================================== */ + +static void inf_zuk_tick(InfernoState* s) { + if (s->wave != 68) return; + + /* find zuk NPC */ + int zuk_idx = -1; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active && s->npcs[i].type == INF_NPC_ZUK) { + zuk_idx = i; + break; + } + } + if (zuk_idx < 0) return; + InfNPC* zuk = &s->npcs[zuk_idx]; + + /* shield oscillation */ + int si = s->zuk.shield_idx; + if (si >= 0 && s->npcs[si].active) { + InfNPC* shield = &s->npcs[si]; + if (s->zuk.shield_freeze > 0) { + s->zuk.shield_freeze--; + } else { + shield->x += s->zuk.shield_dir; + /* boundary check: 5-tick freeze at edges */ + if (shield->x < 11) { + shield->x = 11; + s->zuk.shield_freeze = 5; + s->zuk.shield_dir = 1; + } else if (shield->x > 35) { + shield->x = 35; + s->zuk.shield_freeze = 5; + s->zuk.shield_dir = -1; + } + } + } + + /* set timer: spawns JalZek + JalXil periodically */ + if (s->zuk.set_timer > 0) { + s->zuk.set_timer--; + } else { + /* spawn mager at {20,36} and ranger at {29,36} */ + int m_slot = inf_find_free_npc(s); + if (m_slot >= 0) inf_init_npc(s, m_slot, INF_NPC_MAGER, 20, 36); + int r_slot = inf_find_free_npc(s); + if (r_slot >= 0) inf_init_npc(s, r_slot, INF_NPC_RANGER, 29, 36); + + /* when shield dies, these switch aggro to player (default behavior) */ + s->zuk.set_timer = s->zuk.set_interval; + } + + /* jad spawn at HP < 480 (with shield still alive) */ + if (!s->zuk.jad_spawned && zuk->hp < 480 && + si >= 0 && s->npcs[si].active) { + s->zuk.jad_spawned = 1; + int j_slot = inf_find_free_npc(s); + if (j_slot >= 0) { + inf_init_npc(s, j_slot, INF_NPC_JAD, 24, 32); + } + } + + /* healer spawn at HP < 240: 4 JalMejJak, sets enraged */ + if (!s->zuk.healer_spawned && zuk->hp < 240) { + s->zuk.healer_spawned = 1; + s->zuk.enraged = 1; + static const int healer_pos[4][2] = { + {16, 48}, {20, 48}, {30, 48}, {34, 48} + }; + for (int h = 0; h < 4; h++) { + int slot = inf_find_free_npc(s); + if (slot >= 0) { + inf_init_npc(s, slot, INF_NPC_HEALER_ZUK, + healer_pos[h][0], healer_pos[h][1]); + } + } + } + + /* on zuk death: all other mobs die */ + if (zuk->hp <= 0) { + for (int i = 0; i < INF_MAX_NPCS; i++) { + s->npcs[i].active = 0; + } + } +} + +/* ======================================================================== */ +/* NPC AI: tick all NPCs */ +/* ======================================================================== */ + +static void inf_tick_npcs(InfernoState* s) { + /* NPC per-tick flags are cleared in inf_step BEFORE inf_tick_player, + so player hit flags survive through both tick functions into render_post_tick. */ + + /* zuk-specific phases first */ + inf_zuk_tick(s); + + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (!s->npcs[i].active) continue; + + /* death linger: decrement and deactivate when done */ + if (s->npcs[i].death_ticks > 0) { + s->npcs[i].death_ticks--; + if (s->npcs[i].death_ticks == 0) s->npcs[i].active = 0; + continue; /* dying NPCs don't move or attack */ + } + + /* decrement ice barrage freeze timer */ + if (s->npcs[i].frozen_ticks > 0) s->npcs[i].frozen_ticks--; + + /* meleer dig check */ + if (s->npcs[i].type == INF_NPC_MELEER) + inf_meleer_dig_check(s, i); + + inf_npc_move(s, i); + inf_npc_attack(s, i); + + /* mager resurrection */ + if (s->npcs[i].type == INF_NPC_MAGER) + inf_mager_resurrect(s, i); + + /* jad healer spawning */ + if (s->npcs[i].type == INF_NPC_JAD) + inf_jad_check_healers(s, i); + } +} + +/* ======================================================================== */ +/* player actions */ +/* ======================================================================== */ + +#define INF_HEAD_MOVE 0 /* 25: idle + 8 walk + 16 run */ +#define INF_HEAD_PRAYER 1 /* 5: no_change, off, melee, range, mage (ENCOUNTER_PRAYER_DIM) */ +#define INF_HEAD_TARGET 2 /* INF_MAX_NPCS+1: none or NPC index */ +#define INF_HEAD_GEAR 3 /* 5: no_switch, mage, tbow, bp, tank */ +#define INF_HEAD_EAT 4 /* 2: none, brew */ +#define INF_HEAD_POTION 5 /* 4: none, restore, bastion, stamina */ +#define INF_HEAD_SPELL 6 /* 3: no_change, blood_barrage, ice_barrage */ +#define INF_HEAD_SPEC 7 /* 2: no_spec, spec (blowpipe only) */ +#define INF_NUM_ACTION_HEADS 8 + +static const int INF_ACTION_DIMS[INF_NUM_ACTION_HEADS] = { ENCOUNTER_MOVE_ACTIONS, 5, INF_MAX_NPCS+1, 5, 2, 4, 3, 2 }; +#define INF_ACTION_MASK_SIZE (ENCOUNTER_MOVE_ACTIONS + 5 + INF_MAX_NPCS+1 + 5 + 2 + 4 + 3 + 2) + +/* movement uses shared encounter_move_to_target from osrs_encounter.h */ + +/* walkability callback for encounter_move_to_target */ +static int inf_tile_walkable(void* ctx, int x, int y) { + InfernoState* s = (InfernoState*)ctx; + if (!inf_in_arena(x, y)) return 0; + if (inf_blocked_by_pillar(s, x, y, 1)) return 0; + if (s->collision_map) + return collision_tile_walkable(s->collision_map, 0, + x + s->world_offset_x, y + s->world_offset_y); + return 1; +} + +#define INF_BREW_HEAL 16 /* sara brew heals 16, can overcap to base+16 */ +#define INF_RESTORE_AMOUNT (99/4 + 8) /* floor(base/4)+8 = 32 points per stat */ + +/* apply NPC death: blob split, mager resurrection store, jad healer cleanup. + call after reducing npc->hp. checks if hp <= 0 and handles death effects. */ +static void inf_apply_npc_death(InfernoState* s, int npc_idx) { + InfNPC* npc = &s->npcs[npc_idx]; + if (npc->hp > 0 || !npc->active || npc->death_ticks > 0) return; + /* keep active=1 for death_ticks so renderer shows final hitsplat + death anim. + inf_tick_npcs decrements death_ticks and sets active=0 when it reaches 0. */ + npc->death_ticks = 2; + s->total_npc_kills++; + + if (npc->type == INF_NPC_BLOB) { + InfNPCType split_types[3] = { + INF_NPC_BLOB_MELEE, INF_NPC_BLOB_RANGE, INF_NPC_BLOB_MAGE + }; + for (int sp = 0; sp < 3; sp++) { + int slot = inf_find_free_npc(s); + if (slot < 0) break; + inf_init_npc(s, slot, split_types[sp], npc->x + (sp - 1), npc->y); + } + } else { + inf_store_dead_mob(s, npc); + } + + if (npc->type == INF_NPC_JAD) { + for (int j = 0; j < INF_MAX_NPCS; j++) { + if (s->npcs[j].active && + s->npcs[j].type == INF_NPC_HEALER_JAD && + s->npcs[j].jad_owner_idx == npc_idx) { + s->npcs[j].active = 0; + } + } + } +} + +static void inf_tick_player(InfernoState* s, const int* actions) { + encounter_clear_tick_flags(&s->player); + + /* prayer: uses shared 5-value encoding from osrs_encounter.h */ + encounter_apply_prayer_action(&s->active_prayer, actions[INF_HEAD_PRAYER]); + /* drain prayer at OSRS rate. drain_effect = overhead (12) + offensive prayer (24 for rigour/augury). + inferno players always have an offensive prayer active. prayer_bonus = 0 (no prayer bonus gear). */ + { + int drain = encounter_prayer_drain_effect(s->active_prayer) + 24; + encounter_drain_prayer(&s->player.current_prayer, &s->active_prayer, 0, &s->prayer_drain_counter, drain); + } + s->player.prayer = s->active_prayer; + + /* gear switching */ + int gear_act = actions[INF_HEAD_GEAR]; + if (gear_act >= 1) s->total_gear_switches++; + if (gear_act >= 1 && gear_act <= 3) { + /* 1=mage, 2=tbow, 3=bp */ + InfWeaponSet new_set = (InfWeaponSet)(gear_act - 1); + s->weapon_set = new_set; + s->armor_tank = 0; + GearSet gs = (new_set == INF_GEAR_MAGE) ? GEAR_MAGE : GEAR_RANGED; + encounter_apply_loadout(&s->player, INF_LOADOUTS[new_set], gs); + } else if (gear_act == 4) { + /* tank overlay: justiciar head/body/legs on current loadout */ + s->armor_tank = 1; + s->player.equipped[GEAR_SLOT_HEAD] = INF_TANK_HEAD; + s->player.equipped[GEAR_SLOT_BODY] = INF_TANK_BODY; + s->player.equipped[GEAR_SLOT_LEGS] = INF_TANK_LEGS; + } + + /* auto-detect gear switch from direct inventory equip (human mode). + gui_inv_click mutates p->equipped directly, bypassing the action head. + detect weapon mismatch and sync weapon_set + full loadout. */ + { + uint8_t current_weapon = s->player.equipped[GEAR_SLOT_WEAPON]; + if (current_weapon != INF_LOADOUTS[s->weapon_set][GEAR_SLOT_WEAPON]) { + for (int g = 0; g < INF_NUM_WEAPON_SETS; g++) { + if (INF_LOADOUTS[g][GEAR_SLOT_WEAPON] == current_weapon) { + s->weapon_set = (InfWeaponSet)g; + GearSet gs = (g == INF_GEAR_MAGE) ? GEAR_MAGE : GEAR_RANGED; + encounter_apply_loadout(&s->player, INF_LOADOUTS[g], gs); + break; + } + } + } + } + + /* spell choice for mage attacks — normalize to ENCOUNTER_SPELL_*. + human sends ATTACK_ICE=2 / ATTACK_BLOOD=3, RL sends 0=blood / 1=ice. */ + /* spell: 0=no change, 1=blood barrage, 2=ice barrage */ + int spell_act = actions[INF_HEAD_SPELL]; + if (spell_act == 2) + s->spell_choice = ENCOUNTER_SPELL_ICE; + else if (spell_act == 1) + s->spell_choice = ENCOUNTER_SPELL_BLOOD; + + /* special energy regen: 10 energy every 50 ticks (30 seconds) */ + encounter_tick_spec_regen(&s->player, 0); + + /* blowpipe special attack: 2x accuracy, 1.5x max hit, heal 50% of damage. + only available in BP gear with 50+ spec energy and an active target. */ + { + int spec_act = actions[INF_HEAD_SPEC]; + if (spec_act == 1 && s->weapon_set == INF_GEAR_BP && + s->player_attack_target >= 0 && s->player_attack_timer == 0 && + encounter_use_spec(&s->player, BLOWPIPE_SPEC_COST)) { + InfNPC* target_npc = &s->npcs[s->player_attack_target]; + if (target_npc->active && target_npc->death_ticks == 0) { + const EncounterLoadoutStats* ls = &s->loadout_stats[INF_GEAR_BP]; + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + int base_att_roll = ls->eff_level * (ls->attack_bonus + 64); + int dmg = osrs_blowpipe_spec_resolve( + base_att_roll, ls->max_hit, + ns->def_level, ns->ranged_def_bonus, &s->rng_state); + /* heal 50% of damage dealt */ + int heal = dmg * BLOWPIPE_SPEC_HEAL_PCT / 100; + s->player.current_hitpoints += heal; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + /* queue pending hit */ + int target_dist = encounter_dist_to_npc(s->player.x, s->player.y, + target_npc->x, target_npc->y, target_npc->size); + EncounterPendingHit* ph = &target_npc->pending_hit; + ph->active = 1; + ph->damage = dmg; + ph->ticks_remaining = encounter_blowpipe_hit_delay(target_dist, 1); + ph->attack_style = ATTACK_STYLE_RANGED; + ph->check_prayer = 0; + ph->spell_type = 0; + s->player_attack_timer = ls->attack_speed; + s->damage_dealt_this_tick += (float)dmg; + s->player_attacked_this_tick = 1; + s->player_attack_npc_idx = s->player_attack_target; + s->player_attack_dmg = dmg; + s->player_attack_style_id = ATTACK_STYLE_RANGED; + s->total_blood_healed += heal; + } + } + } + + /* stat decay/restore: every 60 ticks, boosted stats decay by 1 toward base, + drained stats restore by 1 toward base. core OSRS mechanic. */ + if (s->tick > 0 && s->tick % 60 == 0) { + int* stats[] = { &s->player.current_ranged, &s->player.current_magic, + &s->player.current_attack, &s->player.current_strength, + &s->player.current_defence }; + int bases[] = { 99, 99, 99, 99, 99 }; + for (int si = 0; si < 5; si++) { + if (*stats[si] > bases[si]) (*stats[si])--; + else if (*stats[si] < bases[si]) (*stats[si])++; + } + /* recompute max hit after stat change */ + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + } + + /* consumables — shared 3-tick potion timer */ + if (s->player_potion_timer > 0) s->player_potion_timer--; + if (s->stamina_active_ticks > 0) s->stamina_active_ticks--; + + /* brew (INF_HEAD_EAT): heals 16 HP, can overcap to base+16 */ + int eat_act = actions[INF_HEAD_EAT]; + if (eat_act == 1 && s->player_brew_doses > 0 && s->player_potion_timer == 0 + && s->player.current_hitpoints < s->player.base_hitpoints) { + s->player.current_hitpoints += INF_BREW_HEAL; + if (s->player.current_hitpoints > s->player.base_hitpoints + 16) + s->player.current_hitpoints = s->player.base_hitpoints + 16; + s->player_brew_doses--; + s->player_potion_timer = 3; + s->player.ate_food_this_tick = 1; + s->brewed_this_tick = 1; + /* sara brew stat drain: floor(current/10)+2 per stat, floor(def/5)+2 def boost. + drain uses CURRENT level (not base), so boosted stats drain more. + ref: OSRS wiki Saradomin brew */ + { + int att_drain = s->player.current_attack / 10 + 2; + int str_drain = s->player.current_strength / 10 + 2; + int rng_drain = s->player.current_ranged / 10 + 2; + int mag_drain = s->player.current_magic / 10 + 2; + s->player.current_attack -= att_drain; + if (s->player.current_attack < 0) s->player.current_attack = 0; + s->player.current_strength -= str_drain; + if (s->player.current_strength < 0) s->player.current_strength = 0; + s->player.current_ranged -= rng_drain; + if (s->player.current_ranged < 0) s->player.current_ranged = 0; + s->player.current_magic -= mag_drain; + if (s->player.current_magic < 0) s->player.current_magic = 0; + int def_boost = s->player.current_defence / 5 + 2; + s->player.current_defence += def_boost; + int def_cap = 99 + (99 / 5 + 2); /* cap at base + max boost from base */ + if (s->player.current_defence > def_cap) s->player.current_defence = def_cap; + } + /* recompute loadout stats with drained levels */ + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + encounter_compute_loadout_stats(INF_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, + ENCOUNTER_PRAYER_AUGURY, s->player.current_magic, 0, 30, &s->loadout_stats[INF_GEAR_MAGE]); + } + + /* potions (INF_HEAD_POTION): 1=restore, 2=bastion, 3=stamina */ + int pot_act = actions[INF_HEAD_POTION]; + if (pot_act == 1 && s->player_restore_doses > 0 && s->player_potion_timer == 0) { + /* super restore: floor(base/4)+8 per stat, caps at base. restores all combat stats + prayer. + ref: OSRS wiki Super restore */ + s->player.current_prayer += INF_RESTORE_AMOUNT; + if (s->player.current_prayer > s->player.base_prayer) + s->player.current_prayer = s->player.base_prayer; + s->player.current_attack += INF_RESTORE_AMOUNT; + if (s->player.current_attack > 99) s->player.current_attack = 99; + s->player.current_strength += INF_RESTORE_AMOUNT; + if (s->player.current_strength > 99) s->player.current_strength = 99; + s->player.current_defence += INF_RESTORE_AMOUNT; + if (s->player.current_defence > 99) s->player.current_defence = 99; + s->player.current_ranged += INF_RESTORE_AMOUNT; + if (s->player.current_ranged > 99) s->player.current_ranged = 99; + s->player.current_magic += INF_RESTORE_AMOUNT; + if (s->player.current_magic > 99) s->player.current_magic = 99; + s->player_restore_doses--; + s->player_potion_timer = 3; + /* recompute loadout stats with restored levels */ + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + encounter_compute_loadout_stats(INF_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, + ENCOUNTER_PRAYER_AUGURY, s->player.current_magic, 0, 30, &s->loadout_stats[INF_GEAR_MAGE]); + } else if (pot_act == 2 && s->player_bastion_doses > 0 && s->player_potion_timer == 0) { + /* bastion potion: boosts ranged by 4 + floor(base/10) = 13 at base 99. + also boosts defence by 5 + floor(base*15/100) = 19. can exceed base. */ + int rng_boost = 4 + s->player.current_ranged / 10; + s->player.current_ranged += rng_boost; + int max_rng = 99 + (4 + 99 / 10); /* cap at base + max boost = 112 */ + if (s->player.current_ranged > max_rng) s->player.current_ranged = max_rng; + int def_boost = 5 + s->player.current_defence * 15 / 100; + s->player.current_defence += def_boost; + int max_def = 99 + (5 + 99 * 15 / 100); + if (s->player.current_defence > max_def) s->player.current_defence = max_def; + s->player_bastion_doses--; + s->player_potion_timer = 3; + /* recompute ranged loadout stats with boosted level */ + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + ENCOUNTER_PRAYER_RIGOUR, s->player.current_ranged, 0, 0, &s->loadout_stats[INF_GEAR_BP]); + } else if (pot_act == 3 && s->player_stamina_doses > 0 && s->player_potion_timer == 0) { + s->stamina_active_ticks = 200; + s->player_stamina_doses--; + s->player_potion_timer = 3; + } + + /* attack target selection: persistent until NPC dies or player clicks ground. + target=0 means "no new target this tick" (preserves existing target). */ + int target = actions[INF_HEAD_TARGET]; + int has_new_target = 0; + if (target > 0 && target <= INF_MAX_NPCS) { + int npc_idx = target - 1; + if (s->npcs[npc_idx].active && s->npcs[npc_idx].death_ticks == 0) { + s->player_attack_target = npc_idx; + has_new_target = 1; + } + } + /* explicit movement (ground click or RL move) cancels attack target, + but only if no new target was set this tick. auto-chase movement + does NOT cancel — only explicit user actions do. */ + int has_explicit_move = (actions[INF_HEAD_MOVE] > 0 || s->player_dest_x >= 0); + if (!has_new_target && has_explicit_move) { + s->player_attack_target = -1; + } + /* clear target if NPC died or is dying */ + if (s->player_attack_target >= 0 && + (!s->npcs[s->player_attack_target].active || + s->npcs[s->player_attack_target].death_ticks > 0)) { + s->player_attack_target = -1; + } + + /* movement: explicit move, auto-chase toward target, or idle. + OSRS order: target selection → movement → attack check. */ + int chasing = 0; + if (has_explicit_move && s->player_attack_target < 0) { + /* explicit movement (ground click or RL agent) — no attack target */ + if (s->player_dest_x >= 0) { + encounter_move_toward_dest(&s->player, &s->player_dest_x, &s->player_dest_y, + s->collision_map, s->world_offset_x, s->world_offset_y, + inf_tile_walkable, s, inf_pathfind_blocked, s); + } else { + int move_act = actions[INF_HEAD_MOVE]; + s->player.is_running = 0; + if (move_act > 0 && move_act < ENCOUNTER_MOVE_ACTIONS) { + encounter_move_to_target(&s->player, + ENCOUNTER_MOVE_TARGET_DX[move_act], ENCOUNTER_MOVE_TARGET_DY[move_act], + inf_tile_walkable, s); + } + } + } else if (s->player_attack_target >= 0) { + /* auto-chase: pathfind toward attack target when out of range */ + InfNPC* chase_npc = &s->npcs[s->player_attack_target]; + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + chasing = encounter_chase_attack_target(&s->player, + chase_npc->x, chase_npc->y, INF_NPC_STATS[chase_npc->type].size, + ls->attack_range, + s->collision_map, s->world_offset_x, s->world_offset_y, + inf_tile_walkable, s, inf_pathfind_blocked, s, + s->los_blockers, s->los_blocker_count); + } + + /* player attacks targeted NPC */ + if (s->player_attack_timer > 0) s->player_attack_timer--; + if (s->player_attack_target >= 0 && s->player_attack_timer == 0) { + InfNPC* target_npc = &s->npcs[s->player_attack_target]; + if (target_npc->active) { + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + + /* range + LOS check: must have line of sight through pillars */ + int target_dist = encounter_dist_to_npc(s->player.x, s->player.y, + target_npc->x, target_npc->y, target_npc->size); + + if (encounter_player_can_attack(s->player.x, s->player.y, + target_npc->x, target_npc->y, target_npc->size, + ls->attack_range, s->los_blockers, s->los_blocker_count)) { + /* compute hit delay for projectile flight */ + int hit_delay; + if (ls->style == ATTACK_STYLE_MAGIC) + hit_delay = encounter_magic_hit_delay(target_dist, 1); + else if (s->weapon_set == INF_GEAR_BP) + hit_delay = encounter_blowpipe_hit_delay(target_dist, 1); + else + hit_delay = encounter_ranged_hit_delay(target_dist, 1); + + int total_dmg = 0; + + if (s->weapon_set == INF_GEAR_MAGE) { + /* barrage spells: 3x3 AoE via shared osrs_barrage_resolve. + ice barrage: freeze on hit (including 0 dmg), not on splash. + blood barrage: heal 25% of total AoE damage (applied when hits land). */ + int mage_att_roll = ls->eff_level * (ls->attack_bonus + 64); + + /* build target array: primary target first, then all other active NPCs */ + BarrageTarget btargets[INF_MAX_NPCS + 1]; + int bt_count = 0; + { + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + btargets[bt_count++] = (BarrageTarget){ + .active = 1, .x = target_npc->x, .y = target_npc->y, + .def_level = ns->def_level, .magic_def_bonus = ns->magic_def_bonus, + .npc_idx = s->player_attack_target, + .frozen_ticks = &s->npcs[s->player_attack_target].frozen_ticks, + .hit = 0, .damage = 0 + }; + } + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (i == s->player_attack_target || !s->npcs[i].active) continue; + const InfNPCStats* ns2 = &INF_NPC_STATS[s->npcs[i].type]; + btargets[bt_count++] = (BarrageTarget){ + .active = 1, .x = s->npcs[i].x, .y = s->npcs[i].y, + .def_level = ns2->def_level, .magic_def_bonus = ns2->magic_def_bonus, + .npc_idx = i, + .frozen_ticks = &s->npcs[i].frozen_ticks, + .hit = 0, .damage = 0 + }; + } + + /* resolve barrage: accuracy/damage rolls + instant freeze for ice. + freeze is applied by the shared function at cast time. */ + BarrageResult br = osrs_barrage_resolve( + btargets, bt_count, mage_att_roll, ls->max_hit, + &s->rng_state, s->spell_choice); + total_dmg = br.total_damage; + + /* queue pending hits for delayed damage */ + for (int i = 0; i < bt_count; i++) { + if (!btargets[i].active || !btargets[i].hit) continue; + int nidx = btargets[i].npc_idx; + EncounterPendingHit* ph = &s->npcs[nidx].pending_hit; + ph->active = 1; + ph->damage = btargets[i].damage; + ph->ticks_remaining = hit_delay; + ph->attack_style = ATTACK_STYLE_MAGIC; + ph->check_prayer = 0; + ph->spell_type = s->spell_choice; + } + + } else if (s->weapon_set == INF_GEAR_TBOW) { + /* tbow: single target, scale by target magic level. + ls->max_hit is BASE max hit before tbow scaling. */ + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + int tbow_m = ns->magic_level > ns->magic_def_bonus + ? ns->magic_level : ns->magic_def_bonus; + if (tbow_m > 250) tbow_m = 250; + float acc_mult = osrs_tbow_acc_mult(tbow_m); + float dmg_mult = osrs_tbow_dmg_mult(tbow_m); + int att_roll = (int)(ls->eff_level * (ls->attack_bonus + 64) * acc_mult); + int def_roll = (ns->def_level + 8) * (ns->ranged_def_bonus + 64); + int max_hit = (int)(ls->max_hit * dmg_mult); + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll)) { + total_dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + } + EncounterPendingHit* ph = &target_npc->pending_hit; + ph->active = 1; + ph->damage = total_dmg; + ph->ticks_remaining = hit_delay; + ph->attack_style = ATTACK_STYLE_RANGED; + ph->check_prayer = 0; + ph->spell_type = 0; + + } else { + /* blowpipe: single target */ + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + int att_roll = ls->eff_level * (ls->attack_bonus + 64); + int def_roll = (ns->def_level + 8) * (ns->ranged_def_bonus + 64); + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll)) { + total_dmg = encounter_rand_int(&s->rng_state, ls->max_hit + 1); + } + EncounterPendingHit* ph = &target_npc->pending_hit; + ph->active = 1; + ph->damage = total_dmg; + ph->ticks_remaining = hit_delay; + ph->attack_style = ATTACK_STYLE_RANGED; + ph->check_prayer = 0; + ph->spell_type = 0; + } + + s->player_attack_timer = ls->attack_speed; + + /* player projectile event for renderer */ + s->player_attacked_this_tick = 1; + s->player_attack_npc_idx = s->player_attack_target; + s->player_attack_dmg = total_dmg; + s->player_attack_style_id = ls->style; + + /* player attack animation + spell type for renderer effect system */ + s->player.attack_style_this_tick = ls->style; + if (s->weapon_set == INF_GEAR_MAGE) { + /* 0=none, 1=ice, 2=blood */ + s->player.magic_type_this_tick = (s->spell_choice == ENCOUNTER_SPELL_ICE) ? 1 : 2; + } + } + } + } +} + +/* ======================================================================== */ +/* reward */ +/* ======================================================================== */ + +static float inf_compute_reward(InfernoState* s) { + if (s->episode_over) + return (s->winner == 0) ? 1.0f : 0.0f; + + float r = 0.0f; + + if (s->damage_dealt_this_tick > 0.0f) + r += 0.01f * s->damage_dealt_this_tick; + + /* accumulate diagnostic stats */ + s->total_damage_dealt += s->damage_dealt_this_tick; + s->total_damage_received += s->damage_received_this_tick; + + return r; +} + +/* ======================================================================== */ +/* step */ +/* ======================================================================== */ + +static void inf_step(EncounterState* state, const int* actions) { + InfernoState* s = (InfernoState*)state; + if (s->episode_over) return; + + /* clear per-tick state */ + s->reward = 0.0f; + s->damage_dealt_this_tick = 0.0f; + s->damage_received_this_tick = 0.0f; + s->prayer_correct_this_tick = 0; + s->tick_styles_fired = 0; + s->tick_attacks_fired = 0; + s->wave_completed_this_tick = 0; + s->pillar_lost_this_tick = -1; + s->player_attacked_this_tick = 0; + s->brewed_this_tick = 0; + s->blood_heal_this_tick = 0; + /* clear NPC per-tick flags BEFORE player actions, so hit flags set by + inf_tick_player survive through inf_tick_npcs into render_post_tick */ + for (int i = 0; i < INF_MAX_NPCS; i++) { + s->npcs[i].attacked_this_tick = 0; + s->npcs[i].moved_this_tick = 0; + s->npcs[i].hit_landed_this_tick = 0; + s->npcs[i].hit_damage = 0; + s->npcs[i].hit_spell_type = 0; + } + s->tick++; + + /* initial wave spawn delay */ + if (s->wave_spawn_delay > 0) { + s->wave_spawn_delay--; + if (s->wave_spawn_delay == 0) { + inf_spawn_wave(s); + } + /* player can still move/pray during delay */ + inf_tick_player(s, actions); + s->reward = inf_compute_reward(s); + return; + } + + /* player actions */ + inf_tick_player(s, actions); + + /* ------------------------------------------------------------------ */ + /* process pending hits: NPC pending hits (player attacks landing) */ + /* runs BEFORE inf_tick_npcs so that ice barrage freeze takes effect */ + /* on the same tick the projectile lands (NPC can't act while frozen). */ + /* ------------------------------------------------------------------ */ + { + int blood_heal_acc = 0; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (!s->npcs[i].active || s->npcs[i].death_ticks > 0) continue; + int spell = s->npcs[i].pending_hit.spell_type; + int landed = encounter_resolve_npc_pending_hit( + &s->npcs[i].pending_hit, + &s->npcs[i].hp, &s->npcs[i].hit_landed_this_tick, &s->npcs[i].hit_damage, + &s->npcs[i].frozen_ticks, &blood_heal_acc, &s->damage_dealt_this_tick); + if (landed) { + s->npcs[i].hit_spell_type = spell; + inf_apply_npc_death(s, i); + } + } + if (blood_heal_acc > 0) { + int healed = blood_heal_acc / 4; + s->player.current_hitpoints += healed; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + s->blood_heal_this_tick = healed; + } + } + + /* NPC AI: runs after pending hits so ice barrage freeze is already active */ + inf_tick_npcs(s); + + /* ------------------------------------------------------------------ */ + /* process pending hits: player pending hits (NPC attacks landing) */ + /* ------------------------------------------------------------------ */ + encounter_resolve_player_pending_hits( + s->player_pending_hits, &s->player_pending_hit_count, + &s->player, s->active_prayer, + &s->damage_received_this_tick, &s->prayer_correct_this_tick); + + /* check player death */ + if (s->player.current_hitpoints <= 0) { + s->episode_over = 1; + s->winner = 1; + s->reward = inf_compute_reward(s); + return; + } + + /* check wave completion */ + int all_dead = 1; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active) { all_dead = 0; break; } + } + if (all_dead) { + s->wave++; + s->wave_completed_this_tick = 1; + if (s->wave >= INF_NUM_WAVES) { + s->episode_over = 1; + s->winner = 0; + } else { + s->wave_spawn_delay = 5; + } + } + + /* timeout */ + if (s->tick >= INF_MAX_TICKS) { + s->episode_over = 1; + s->winner = 1; + } + + /* idle penalty counter: consecutive ticks where player could attack but didn't */ + { + int has_alive_npc = 0; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active && s->npcs[i].death_ticks == 0) { + has_alive_npc = 1; break; + } + } + if (has_alive_npc && s->player_attack_timer == 0 && !s->player_attacked_this_tick) + s->ticks_without_action++; + else + s->ticks_without_action = 0; + } + + /* accumulate diagnostic counters. + prayer_correct_this_tick is a count (multiple NPCs can attack same tick). + total_npc_attacks counts attacks directed at the player (not nibbler→pillar). */ + s->total_prayer_correct += s->prayer_correct_this_tick; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].attacked_this_tick && s->npcs[i].type != INF_NPC_NIBBLER) + s->total_npc_attacks++; + } + /* multi-style analysis: count off-prayer hits that were unavoidable because + a different style was correctly prayed on the same tick. popcount of + tick_styles_fired tells us how many distinct styles fired. if 2+, the + off-prayer hits from non-prayed styles are "unavoidable" (can only pray one). */ + if (s->tick_attacks_fired > 0) { + int styles = s->tick_styles_fired; + int n_styles = ((styles >> 0) & 1) + ((styles >> 1) & 1) + ((styles >> 2) & 1); + if (n_styles >= 2 && s->prayer_correct_this_tick > 0) { + /* we prayed correctly against at least one style, but other styles + also fired — those off-prayer hits were unavoidable */ + int off_prayer = s->tick_attacks_fired - s->prayer_correct_this_tick; + s->total_unavoidable_off += off_prayer; + } + } + if (s->ticks_without_action > 0) s->total_idle_ticks++; + s->total_brews_used += s->brewed_this_tick; + s->total_blood_healed += s->blood_heal_this_tick; + + s->reward = inf_compute_reward(s); + s->episode_return += s->reward; +} + +/* ======================================================================== */ +/* observations */ +/* ======================================================================== */ + +/* obs layout: 26 player + 12 pillar + 27*32 NPC + 5*8 pending hits = 942 */ +#define INF_FEATURES_PER_NPC 29 +#define INF_FEATURES_PER_HIT 5 +#define INF_NUM_OBS (37 + 12 + INF_FEATURES_PER_NPC * INF_MAX_NPCS + INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS) + +/* max hit per NPC type, normalized by mager max (70). for prayer priority obs. */ +static const float INF_NPC_MAX_HIT_NORM[INF_NUM_NPC_TYPES] = { + [INF_NPC_NIBBLER] = 0.0f, + [INF_NPC_BAT] = 19.0f / 70.0f, + [INF_NPC_BLOB] = 29.0f / 70.0f, + [INF_NPC_BLOB_MELEE] = 18.0f / 70.0f, + [INF_NPC_BLOB_RANGE] = 18.0f / 70.0f, + [INF_NPC_BLOB_MAGE] = 25.0f / 70.0f, + [INF_NPC_MELEER] = 49.0f / 70.0f, + [INF_NPC_RANGER] = 46.0f / 70.0f, + [INF_NPC_MAGER] = 70.0f / 70.0f, + [INF_NPC_JAD] = 113.0f / 70.0f, + [INF_NPC_ZUK] = 148.0f / 70.0f, + [INF_NPC_HEALER_JAD] = 13.0f / 70.0f, + [INF_NPC_HEALER_ZUK] = 24.0f / 70.0f, + [INF_NPC_ZUK_SHIELD] = 0.0f, +}; + +static void inf_write_obs(EncounterState* state, float* obs) { + InfernoState* s = (InfernoState*)state; + memset(obs, 0, INF_NUM_OBS * sizeof(float)); + int i = 0; + int px = s->player.x, py = s->player.y; + + /* player state (26 features) */ + obs[i++] = (float)s->player.current_hitpoints / 99.0f; + obs[i++] = (float)(px - INF_ARENA_MIN_X) / (float)INF_ARENA_WIDTH; + obs[i++] = (float)(py - INF_ARENA_MIN_Y) / (float)INF_ARENA_HEIGHT; + obs[i++] = (s->active_prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + obs[i++] = (s->active_prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + obs[i++] = (s->active_prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + obs[i++] = (float)s->player_brew_doses / 32.0f; + obs[i++] = (float)s->player_restore_doses / 40.0f; + obs[i++] = (float)s->player.current_prayer / 99.0f; + obs[i++] = (float)s->wave / (float)INF_NUM_WAVES; + obs[i++] = (float)s->tick / (float)INF_MAX_TICKS; + obs[i++] = (s->weapon_set == INF_GEAR_MAGE) ? 1.0f : 0.0f; + obs[i++] = (s->weapon_set == INF_GEAR_TBOW) ? 1.0f : 0.0f; + obs[i++] = (s->weapon_set == INF_GEAR_BP) ? 1.0f : 0.0f; + obs[i++] = s->armor_tank ? 1.0f : 0.0f; + obs[i++] = (float)s->player_bastion_doses / 4.0f; + obs[i++] = (float)s->player_stamina_doses / 4.0f; + obs[i++] = (s->stamina_active_ticks > 0) ? 1.0f : 0.0f; + obs[i++] = (float)s->player_potion_timer / 3.0f; + obs[i++] = (float)s->player_attack_timer / 8.0f; + /* new: combat stats, target, weapon range, dead mob pool */ + obs[i++] = (float)s->player.current_defence / 99.0f; + obs[i++] = (float)s->player.current_ranged / 99.0f; + obs[i++] = (float)s->player.current_magic / 99.0f; + obs[i++] = (s->player_attack_target >= 0) + ? (float)(s->player_attack_target + 1) / (float)INF_MAX_NPCS : 0.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].attack_range / 15.0f; + obs[i++] = (float)s->dead_mob_count / (float)INF_MAX_DEAD_MOBS; + /* gear stats: current loadout combat performance */ + obs[i++] = (float)s->loadout_stats[s->weapon_set].max_hit / 80.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].attack_speed / 6.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].def_stab / 300.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].def_magic / 300.0f; + obs[i++] = (float)s->loadout_stats[s->weapon_set].def_ranged / 300.0f; + obs[i++] = (float)s->player.special_energy / 100.0f; + + /* prayer-critical: distilled from NPC array so the agent doesn't have to + scan 32 slots to figure out what to pray. these directly answer: + "what style should I pray?" and "how urgent is it?" */ + { + int min_timer = 999; + int min_style = 0; /* style of the NPC with lowest timer */ + int styles_within_2 = 0; /* bitmask of styles firing within 2 ticks */ + int has_melee_2 = 0, has_ranged_2 = 0, has_magic_2 = 0; + for (int n = 0; n < INF_MAX_NPCS; n++) { + InfNPC* npc = &s->npcs[n]; + if (!npc->active || npc->death_ticks > 0) continue; + const InfNPCStats* st = &INF_NPC_STATS[npc->type]; + if (st->attack_range <= 1 && npc->type != INF_NPC_MELEER) continue; /* skip nibblers */ + /* only count NPCs that can actually attack: has LOS, in range, not frozen/stunned */ + if (npc->frozen_ticks > 0 || npc->stun_timer > 0) continue; + if (st->attack_range > 1 && !inf_npc_has_los(s, n)) continue; + int dist = encounter_dist_to_npc(s->player.x, s->player.y, + npc->x, npc->y, npc->size); + if (dist == 0 || dist > st->attack_range) continue; + int style = (npc->type == INF_NPC_JAD) ? npc->jad_attack_style : npc->attack_style; + if (npc->attack_timer < min_timer) { + min_timer = npc->attack_timer; + min_style = style; + } + if (npc->attack_timer <= 2) { + if (style == ATTACK_STYLE_MELEE) has_melee_2 = 1; + if (style == ATTACK_STYLE_RANGED) has_ranged_2 = 1; + if (style == ATTACK_STYLE_MAGIC) has_magic_2 = 1; + } + } + int conflict_count = has_melee_2 + has_ranged_2 + has_magic_2; + /* ticks until next enemy attack (0 = firing this tick, 1 = imminent) */ + obs[i++] = (min_timer < 999) ? (float)min_timer / 10.0f : 1.0f; + /* style of most imminent attacker (one-hot) */ + obs[i++] = (min_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[i++] = (min_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[i++] = (min_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + /* how many distinct styles fire within 2 ticks (0=safe, 1=single pray, 2+=conflict) */ + obs[i++] = (float)conflict_count / 3.0f; + } + + /* pillars (12 features: active, hp, relative dx, relative dy per pillar) */ + for (int p = 0; p < INF_NUM_PILLARS; p++) { + obs[i++] = s->pillars[p].active ? 1.0f : 0.0f; + obs[i++] = (float)s->pillars[p].hp / (float)INF_PILLAR_HP; + obs[i++] = (float)(s->pillars[p].x - px) / (float)INF_ARENA_WIDTH; + obs[i++] = (float)(s->pillars[p].y - py) / (float)INF_ARENA_HEIGHT; + } + + /* NPCs: INF_FEATURES_PER_NPC (29) features each, up to INF_MAX_NPCS */ + for (int n = 0; n < INF_MAX_NPCS; n++) { + InfNPC* npc = &s->npcs[n]; + if (npc->active && npc->death_ticks == 0) { + obs[i++] = 1.0f; + /* type one-hot (14 features) */ + for (int t = 0; t < INF_NUM_NPC_TYPES; t++) + obs[i++] = (npc->type == t) ? 1.0f : 0.0f; + obs[i++] = (float)npc->hp / (float)npc->max_hp; + /* relative position to player */ + obs[i++] = (float)(npc->x - px) / (float)INF_ARENA_WIDTH; + obs[i++] = (float)(npc->y - py) / (float)INF_ARENA_HEIGHT; + obs[i++] = (float)npc->attack_timer / 10.0f; + /* attack style: for jad, use jad_attack_style (the actual per-attack style) + since npc->attack_style stays at the default forever */ + { + int style = (npc->type == INF_NPC_JAD) ? npc->jad_attack_style : npc->attack_style; + obs[i++] = (style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[i++] = (style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[i++] = (style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + } + obs[i++] = inf_npc_has_los(s, n) ? 1.0f : 0.0f; + obs[i++] = (float)npc->frozen_ticks / 32.0f; + obs[i++] = INF_NPC_MAX_HIT_NORM[npc->type]; + /* blob scan state */ + if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) { + OverheadPrayer scanned = (OverheadPrayer)npc->blob_scanned_prayer; + if (scanned == PRAYER_PROTECT_MAGIC) + obs[i++] = 1.0f; + else if (scanned == PRAYER_PROTECT_RANGED) + obs[i++] = -1.0f; + else + obs[i++] = 0.5f; + } else { + obs[i++] = 0.0f; + } + obs[i++] = (float)INF_NPC_STATS[npc->type].attack_range / 100.0f; + obs[i++] = (float)INF_NPC_STATS[npc->type].magic_def_bonus / 350.0f; + /* barrage AoE count: how many other active NPCs have SW corner within 1 tile */ + { + int aoe_count = 0; + for (int j = 0; j < INF_MAX_NPCS; j++) { + if (j == n || !s->npcs[j].active) continue; + int dx = s->npcs[j].x - npc->x; + int dy = s->npcs[j].y - npc->y; + if (dx >= -1 && dx <= 1 && dy >= -1 && dy <= 1) aoe_count++; + } + obs[i++] = (float)aoe_count / 8.0f; + } + } else { + for (int j = 0; j < INF_FEATURES_PER_NPC; j++) obs[i++] = 0.0f; + } + } + + /* assert NPC section wrote exactly the right number of features. + if this fires, INF_FEATURES_PER_NPC doesn't match the actual feature count. */ + { + int expected_npc_end = 37 + 12 + INF_FEATURES_PER_NPC * INF_MAX_NPCS; + if (i != expected_npc_end) { + fprintf(stderr, "FATAL: obs misaligned after NPC section: i=%d expected=%d " + "(INF_FEATURES_PER_NPC=%d, actual=%d per slot)\n", + i, expected_npc_end, INF_FEATURES_PER_NPC, + (i - 31 - 12) / INF_MAX_NPCS); + abort(); + } + } + + /* pending hits on player (INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS) */ + for (int h = 0; h < ENCOUNTER_MAX_PENDING_HITS; h++) { + if (h < s->player_pending_hit_count) { + EncounterPendingHit* ph = &s->player_pending_hits[h]; + obs[i++] = 1.0f; /* active */ + obs[i++] = (ph->attack_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[i++] = (ph->attack_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[i++] = (float)ph->ticks_remaining / 10.0f; + obs[i++] = (ph->damage > 0) ? 1.0f : 0.0f; /* unprotected hit incoming */ + } else { + for (int j = 0; j < INF_FEATURES_PER_HIT; j++) obs[i++] = 0.0f; + } + } + +} + +static void inf_write_mask(EncounterState* state, float* mask) { + InfernoState* s = (InfernoState*)state; + int offset = 0; + + /* HEAD_MOVE (25): idle always valid, walk/run valid if target tile reachable */ + mask[offset++] = 1.0f; /* idle always valid */ + for (int d = 1; d < ENCOUNTER_MOVE_ACTIONS; d++) { + int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[d]; + int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[d]; + mask[offset++] = (inf_in_arena(nx, ny) && !inf_blocked_by_pillar(s, nx, ny, 1)) + ? 1.0f : 0.0f; + } + + /* HEAD_PRAYER (5): 0=no change (always valid), 1-4=switch (mask out current) */ + mask[offset++] = 1.0f; /* no change — always valid */ + mask[offset++] = (s->active_prayer != PRAYER_NONE) ? 1.0f : 0.0f; + mask[offset++] = (s->active_prayer != PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + mask[offset++] = (s->active_prayer != PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + mask[offset++] = (s->active_prayer != PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + + /* HEAD_TARGET (INF_MAX_NPCS+1): none always valid, NPC valid only if alive (not dying) */ + mask[offset++] = 1.0f; /* no target */ + for (int n = 0; n < INF_MAX_NPCS; n++) { + mask[offset++] = (s->npcs[n].active && s->npcs[n].death_ticks == 0) ? 1.0f : 0.0f; + } + + /* HEAD_GEAR (5): no_switch, mage, tbow, bp, tank */ + mask[offset++] = 1.0f; /* no_switch always valid */ + mask[offset++] = (s->weapon_set != INF_GEAR_MAGE || s->armor_tank) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set != INF_GEAR_TBOW || s->armor_tank) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set != INF_GEAR_BP || s->armor_tank) ? 1.0f : 0.0f; + mask[offset++] = 1.0f; /* tank toggle always allowed */ + + /* HEAD_EAT (2): none, brew */ + mask[offset++] = 1.0f; /* none always valid */ + mask[offset++] = (s->player_brew_doses > 0 && + s->player_potion_timer == 0 && + s->player.current_hitpoints < s->player.base_hitpoints) + ? 1.0f : 0.0f; + + /* HEAD_POTION (4): none, restore, bastion, stamina */ + mask[offset++] = 1.0f; /* none always valid */ + /* restore: unmask if any stat is drained or prayer is low enough to not waste. + "stats drained" = any combat stat below base 99. */ + { + int pray_missing = s->player.base_prayer - s->player.current_prayer; + int stats_drained = s->player.current_attack < 99 || s->player.current_strength < 99 || + s->player.current_defence < 99 || s->player.current_ranged < 99 || + s->player.current_magic < 99; + int pray_worth = pray_missing >= (INF_RESTORE_AMOUNT + 1) / 2; + mask[offset++] = (s->player_restore_doses > 0 && + s->player_potion_timer == 0 && + (stats_drained || pray_worth)) + ? 1.0f : 0.0f; + } + /* bastion: only worth drinking at 99-105 ranged (drained = restore first, >105 = still boosted) */ + mask[offset++] = (s->player_bastion_doses > 0 && s->player_potion_timer == 0 && + s->player.current_ranged >= 99 && s->player.current_ranged <= 105) + ? 1.0f : 0.0f; + /* stamina: mask if no doses, timer active, or already active */ + mask[offset++] = (s->player_stamina_doses > 0 && + s->player_potion_timer == 0 && + s->stamina_active_ticks == 0) + ? 1.0f : 0.0f; + + /* HEAD_SPELL (3): no_change, blood_barrage, ice_barrage. + noop always valid. blood masked at full HP. both spells masked when not in mage gear. */ + mask[offset++] = 1.0f; /* no_change always valid */ + mask[offset++] = (s->weapon_set == INF_GEAR_MAGE && + s->player.current_hitpoints < s->player.base_hitpoints) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set == INF_GEAR_MAGE) ? 1.0f : 0.0f; + + /* HEAD_SPEC (2): no_spec, spec. spec only when in BP gear with enough energy. */ + mask[offset++] = 1.0f; /* no_spec always valid */ + mask[offset++] = (s->weapon_set == INF_GEAR_BP && + s->player.special_energy >= BLOWPIPE_SPEC_COST && + s->player_attack_timer == 0 && + s->player_attack_target >= 0) + ? 1.0f : 0.0f; +} + +/* ======================================================================== */ +/* query functions */ +/* ======================================================================== */ + +static float inf_get_reward(EncounterState* state) { + return ((InfernoState*)state)->reward; +} + +static int inf_is_terminal(EncounterState* state) { + return ((InfernoState*)state)->episode_over; +} + +static int inf_get_entity_count(EncounterState* state) { + InfernoState* s = (InfernoState*)state; + int count = 1; + for (int i = 0; i < INF_MAX_NPCS; i++) + if (s->npcs[i].active) count++; + return count; +} + +static void* inf_get_entity(EncounterState* state, int index) { + InfernoState* s = (InfernoState*)state; + /* only index 0 (player) returns a valid Player*. + * NPC indices can't return Player* since InfNPC is a different struct. + * GUI/human input code must NULL-check. */ + if (index == 0) return &s->player; + return NULL; +} + +/* render entity population */ +static void inf_fill_render_entities(EncounterState* state, RenderEntity* out, int max_entities, int* count) { + InfernoState* s = (InfernoState*)state; + int n = 0; + + /* sync all consumable counts + GUI stats to Player struct */ + s->player.food_count = 0; + s->player.brew_doses = s->player_brew_doses; + s->player.restore_doses = s->player_restore_doses; + s->player.combat_potion_doses = s->player_bastion_doses; + s->player.ranged_potion_doses = s->player_stamina_doses; + { + const EncounterLoadoutStats* ls = &s->loadout_stats[s->weapon_set]; + s->player.gui_max_hit = ls->max_hit; + s->player.gui_attack_speed = ls->attack_speed; + s->player.gui_attack_range = ls->attack_range; + s->player.gui_strength_bonus = ls->strength_bonus; + } + + /* index 0: the player */ + if (n < max_entities) { + render_entity_from_player(&s->player, &out[n++]); + } + + /* active NPCs: manually fill since InfNPC is not a Player */ + for (int i = 0; i < INF_MAX_NPCS && n < max_entities; i++) { + InfNPC* npc = &s->npcs[i]; + if (!npc->active) continue; + + RenderEntity* re = &out[n++]; + memset(re, 0, sizeof(RenderEntity)); + memset(re->equipped, ITEM_NONE, NUM_GEAR_SLOTS); + re->entity_type = ENTITY_NPC; + re->npc_def_id = INF_NPC_DEF_IDS[npc->type]; + re->npc_slot = i; + /* non-nibbler NPCs target the player (entity 0) for facing. + nibblers target pillars (not an entity) — leave at -1 so the + renderer falls back to dest-based facing toward the pillar. */ + re->attack_target_entity_idx = (npc->type == INF_NPC_NIBBLER) ? -1 : 0; + re->npc_visible = npc->active; + re->npc_size = npc->size; + { + const NpcModelMapping* nm = npc_model_lookup(INF_NPC_DEF_IDS[npc->type]); + if (npc->death_ticks > 0) { + /* dying: hold idle pose while hitsplat + health bar display */ + re->npc_anim_id = nm ? (int)nm->idle_anim : -1; + } else if (npc->attacked_this_tick && nm && nm->attack_anim != 65535) { + re->npc_anim_id = (int)nm->attack_anim; + } else { + /* walk/idle handled by secondary track in render_client_tick. + setting walk as primary causes stall (interleave_count==0) + which freezes movement and creates tile-to-tile teleporting. */ + re->npc_anim_id = -1; + } + } + re->x = npc->x; + re->y = npc->y; + /* nibblers: set dest to pillar center so renderer faces them toward + the pillar instead of the player when idle/attacking */ + if (npc->type == INF_NPC_NIBBLER) { + int tp = s->nibbler_target_pillar; + if (tp >= 0 && tp < INF_NUM_PILLARS && s->pillars[tp].active) { + re->dest_x = s->pillars[tp].x + INF_PILLAR_SIZE / 2; + re->dest_y = s->pillars[tp].y + INF_PILLAR_SIZE / 2; + } else { + re->dest_x = npc->x; + re->dest_y = npc->y; + } + } else { + re->dest_x = npc->target_x; + re->dest_y = npc->target_y; + } + re->current_hitpoints = npc->hp; + re->base_hitpoints = npc->max_hp; + re->attack_style_this_tick = npc->attacked_this_tick + ? (AttackStyle)npc->attack_style : ATTACK_STYLE_NONE; + re->hit_landed_this_tick = npc->hit_landed_this_tick; + re->hit_damage = npc->hit_damage; + /* barrage hits that pass accuracy are queued; splashes never enter the queue. + so any NPC with hit_landed_this_tick from a pending hit was a successful hit. */ + re->hit_was_successful = npc->hit_landed_this_tick; + re->hit_spell_type = npc->hit_spell_type; + } + + encounter_resolve_attack_target(out, n, s->player_attack_target); + *count = n; +} + +static void inf_put_int(EncounterState* state, const char* key, int value) { + InfernoState* s = (InfernoState*)state; + if (strcmp(key, "start_wave") == 0) s->start_wave = value; + else if (strcmp(key, "seed") == 0) s->rng_state = (uint32_t)value; + else if (strcmp(key, "world_offset_x") == 0) s->world_offset_x = value; + else if (strcmp(key, "world_offset_y") == 0) s->world_offset_y = value; + else if (strcmp(key, "player_dest_x") == 0) s->player_dest_x = value; + else if (strcmp(key, "player_dest_y") == 0) s->player_dest_y = value; +} + +static void inf_put_float(EncounterState* state, const char* key, float value) { + InfernoState* s = (InfernoState*)state; + if (strcmp(key, "wave_reward_base") == 0) s->wave_reward_base = value; + else if (strcmp(key, "wave_reward_scale") == 0) s->wave_reward_scale = value; + else if (strcmp(key, "brew_penalty") == 0) s->brew_penalty = value; + else if (strcmp(key, "brew_penalty_midpoint") == 0) s->brew_penalty_midpoint = value; + else if (strcmp(key, "brew_penalty_width") == 0) s->brew_penalty_width = value; + else if (strcmp(key, "blood_heal_reward") == 0) s->blood_heal_reward = value; + else if (strcmp(key, "prayer_reward") == 0) s->prayer_reward = value; +} + +static void inf_put_ptr(EncounterState* state, const char* key, void* value) { + InfernoState* s = (InfernoState*)state; + if (strcmp(key, "collision_map") == 0) s->collision_map = (const CollisionMap*)value; +} + +static int inf_get_tick(EncounterState* state) { + return ((InfernoState*)state)->tick; +} + +static int inf_get_winner(EncounterState* state) { + return ((InfernoState*)state)->winner; +} + +static void* inf_get_log(EncounterState* state) { + InfernoState* s = (InfernoState*)state; + if (s->episode_over) { + s->log.episode_return += s->episode_return; + s->log.episode_length += (float)s->tick; + s->log.wins += (s->winner == 0) ? 1.0f : 0.0f; + s->log.damage_dealt += s->total_damage_dealt; + s->log.damage_received += s->total_damage_received; + s->log.wave += (float)s->wave; + s->log.prayer_correct += (float)s->total_prayer_correct; + s->log.prayer_total += (float)s->total_npc_attacks; + s->log.idle_ticks += (float)s->total_idle_ticks; + s->log.brews_used += (float)s->total_brews_used; + s->log.blood_healed += (float)s->total_blood_healed; + s->log.n += 1.0f; + s->log.npc_kills += (float)s->total_npc_kills; + s->log.gear_switches += (float)s->total_gear_switches; + s->log.current_ranged += (float)s->player.current_ranged; + s->log.current_magic += (float)s->player.current_magic; + } + return &s->log; +} + +/* ======================================================================== */ +/* render post-tick: populate overlay projectiles for renderer */ +/* ======================================================================== */ + + +static void inf_render_post_tick(EncounterState* state, EncounterOverlay* ov) { + InfernoState* s = (InfernoState*)state; + ov->projectile_count = 0; + + /* NPC attack projectiles — per-NPC-type flight parameters */ + for (int i = 0; i < INF_MAX_NPCS; i++) { + InfNPC* npc = &s->npcs[i]; + if (!npc->active || !npc->attacked_this_tick) continue; + if (ov->projectile_count >= ENCOUNTER_MAX_OVERLAY_PROJECTILES) break; + + /* nibblers attack pillars, not worth showing as projectile */ + if (npc->type == INF_NPC_NIBBLER) continue; + + /* blob scan animation (no projectile) — only emit on the actual fire tick. + blob_scanned_prayer >= 0 means scan just happened, -1 means fire. */ + if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) continue; + + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + int actual_style = stats->default_style; + + /* blob uses per-attack style from prayer reading (ranged vs magic) */ + if (npc->type == INF_NPC_BLOB) + actual_style = npc->attack_style; + + /* jad uses its per-attack random style */ + if (npc->type == INF_NPC_JAD) + actual_style = npc->jad_attack_style; + + /* zuk is typeless — show as magic for visual purposes */ + if (npc->type == INF_NPC_ZUK) + actual_style = ATTACK_STYLE_MAGIC; + + /* melee attacks are instant — no in-flight projectile */ + if (actual_style == ATTACK_STYLE_MELEE) continue; + + int proj_style = encounter_attack_style_to_proj_style(actual_style); + int npc_size = stats->size; + int start_h = (int)(npc_size * 0.75f * 128); + int end_h = 64; /* player: size 1 * 0.5 * 128 */ + int dist = encounter_dist_to_npc(s->player.x, s->player.y, + npc->x, npc->y, npc_size); + int hit_delay; + if (actual_style == ATTACK_STYLE_MAGIC) + hit_delay = encounter_magic_hit_delay(dist, 0); + else + hit_delay = encounter_ranged_hit_delay(dist, 0); + int duration = hit_delay * 30; + int curve = 16; + float arc = 0.0f; + int tracks = 1; + + /* per-NPC-type projectile GFX model ID */ + uint32_t proj_model_id = 0; + switch (npc->type) { + case INF_NPC_BAT: proj_model_id = INF_GFX_1374_MODEL; break; + case INF_NPC_BLOB: proj_model_id = (actual_style == ATTACK_STYLE_RANGED) ? INF_GFX_1383_MODEL : INF_GFX_1384_MODEL; break; + case INF_NPC_BLOB_RANGE: proj_model_id = INF_GFX_1383_MODEL; break; + case INF_NPC_BLOB_MAGE: proj_model_id = INF_GFX_1384_MODEL; break; + case INF_NPC_BLOB_MELEE: proj_model_id = INF_GFX_1382_MODEL; break; + case INF_NPC_RANGER: proj_model_id = INF_GFX_1377_MODEL; break; + case INF_NPC_MAGER: proj_model_id = INF_GFX_1379_MODEL; break; + case INF_NPC_JAD: + proj_model_id = (actual_style == ATTACK_STYLE_MAGIC) ? INF_GFX_448_MODEL : INF_GFX_447_MODEL; + break; + case INF_NPC_ZUK: proj_model_id = INF_GFX_1375_MODEL; break; + case INF_NPC_HEALER_ZUK: proj_model_id = INF_GFX_1385_MODEL; break; + default: break; + } + + /* NPC-specific flight overrides */ + switch (npc->type) { + case INF_NPC_MAGER: + duration += 60; /* visual delay ~2 ticks */ + break; + case INF_NPC_RANGER: + /* SDK: reduceDelay=-2 (adds 2 ticks to hit), visualDelayTicks=3 + (projectile invisible for first 3 ticks). net visual effect: + +2 ticks to flight - 3 ticks hidden = -1 tick visual duration. */ + duration += 60 - 90; /* +2 ticks hit delay, -3 ticks visual delay */ + if (duration < 30) duration = 30; /* minimum 1 game tick visible */ + break; + case INF_NPC_JAD: + if (actual_style == ATTACK_STYLE_MAGIC) { + arc = 1.0f; /* arcing magic projectile */ + } + break; + case INF_NPC_HEALER_ZUK: + arc = 3.0f; /* high arcing spark */ + duration = 4 * 30; /* fixed 4 tick delay */ + break; + case INF_NPC_ZUK: + duration = 4 * 30; /* fixed 4 tick delay */ + break; + default: break; + } + + encounter_emit_projectile(ov, + npc->x, npc->y, s->player.x, s->player.y, + proj_style, (int)s->damage_received_this_tick, + duration, start_h, end_h, curve, arc, tracks, npc_size, 1, proj_model_id); + } + + /* player attack projectile (ranged/magic only — melee has no projectile) */ + if (s->player_attacked_this_tick && + s->player_attack_style_id != ATTACK_STYLE_MELEE && + ov->projectile_count < ENCOUNTER_MAX_OVERLAY_PROJECTILES) { + int target_idx = s->player_attack_npc_idx; + if (target_idx >= 0 && target_idx < INF_MAX_NPCS) { + InfNPC* target = &s->npcs[target_idx]; + int target_size = INF_NPC_STATS[target->type].size; + int p_start_h = 64; /* player: size 1 * 0.5 * 128 */ + int p_end_h = (int)(target_size * 0.5f * 128); + int p_dist = encounter_dist_to_npc(s->player.x, s->player.y, + target->x, target->y, target_size); + int p_style = encounter_attack_style_to_proj_style(s->player_attack_style_id); + float p_arc = 0.0f; + int p_tracks = 0; /* don't track — tracking loop targets entity 0 (player) */ + int p_duration; + + uint32_t player_proj_model = 0; + if (s->weapon_set == INF_GEAR_MAGE) { + p_duration = encounter_magic_hit_delay(p_dist, 1) * 30; + p_arc = 0.0f; + /* barrage: no projectile model (effect system handles it) */ + } else if (s->weapon_set == INF_GEAR_TBOW) { + p_duration = encounter_ranged_hit_delay(p_dist, 1) * 30; + p_arc = 1.0f; + player_proj_model = 3136; /* rune arrow (GFX 15) — dragon arrow visually similar */ + } else { + /* blowpipe */ + p_duration = encounter_ranged_hit_delay(p_dist, 1) * 30; + p_arc = 0.5f; + player_proj_model = 26379; /* dragon dart */ + } + + /* barrage has no in-flight projectile in OSRS (only hit splash) */ + if (player_proj_model > 0) { + encounter_emit_projectile(ov, + s->player.x, s->player.y, target->x, target->y, + p_style, s->player_attack_dmg, + p_duration, p_start_h, p_end_h, 16, p_arc, p_tracks, 1, target_size, player_proj_model); + } + } + } +} + +/* ======================================================================== */ +/* human input translator */ +/* ======================================================================== */ + +static void* inf_get_player_for_input(void* state, int idx) { + InfernoState* s = (InfernoState*)state; + return (idx == 0) ? (void*)&s->player : NULL; +} + +static void inf_translate_human_input(HumanInput* hi, int* actions, EncounterState* state) { + for (int h = 0; h < INF_NUM_ACTION_HEADS; h++) actions[h] = 0; + + encounter_translate_movement(hi, actions, INF_HEAD_MOVE, inf_get_player_for_input, state); + encounter_translate_prayer(hi, actions, INF_HEAD_PRAYER); + encounter_translate_target(hi, actions, INF_HEAD_TARGET); + + /* gear switch */ + if (hi->pending_gear > 0) actions[INF_HEAD_GEAR] = hi->pending_gear; + + /* eat: brew */ + if (hi->pending_food || hi->pending_potion == POTION_BREW) + actions[INF_HEAD_EAT] = 1; + + /* potions: restore=1, bastion=2, stamina=3 */ + if (hi->pending_potion == POTION_RESTORE) actions[INF_HEAD_POTION] = 1; + + /* spell: 0=no change, 1=blood, 2=ice */ + if (hi->pending_spell == ATTACK_BLOOD) actions[INF_HEAD_SPELL] = 1; + else if (hi->pending_spell == ATTACK_ICE) actions[INF_HEAD_SPELL] = 2; + + /* spec */ + if (hi->pending_spec) actions[INF_HEAD_SPEC] = 1; +} + +/* ======================================================================== */ +/* encounter definition */ +/* ======================================================================== */ + +static const EncounterDef ENCOUNTER_INFERNO = { + .name = "inferno", + .obs_size = INF_NUM_OBS, + .num_action_heads = INF_NUM_ACTION_HEADS, + .action_head_dims = INF_ACTION_DIMS, + .mask_size = INF_ACTION_MASK_SIZE, + + .create = inf_create, + .destroy = inf_destroy, + .reset = inf_reset, + .step = inf_step, + + .write_obs = inf_write_obs, + .write_mask = inf_write_mask, + .get_reward = inf_get_reward, + .is_terminal = inf_is_terminal, + + .get_entity_count = inf_get_entity_count, + .get_entity = inf_get_entity, + .fill_render_entities = inf_fill_render_entities, + + .put_int = inf_put_int, + .put_float = inf_put_float, + .put_ptr = inf_put_ptr, + + .arena_base_x = INF_ARENA_MIN_X, + .arena_base_y = INF_ARENA_MIN_Y, + .arena_width = INF_ARENA_WIDTH, + .arena_height = INF_ARENA_HEIGHT, + + .render_post_tick = inf_render_post_tick, + .get_log = inf_get_log, + .get_tick = inf_get_tick, + .get_winner = inf_get_winner, + + .translate_human_input = inf_translate_human_input, + .head_move = INF_HEAD_MOVE, + .head_prayer = INF_HEAD_PRAYER, + .head_target = INF_HEAD_TARGET, +}; + +__attribute__((constructor)) +static void inf_register(void) { + encounter_register(&ENCOUNTER_INFERNO); +} + +#endif /* ENCOUNTER_INFERNO_H */ diff --git a/ocean/osrs/encounters/encounter_nh_pvp.h b/ocean/osrs/encounters/encounter_nh_pvp.h new file mode 100644 index 0000000000..bad8f38723 --- /dev/null +++ b/ocean/osrs/encounters/encounter_nh_pvp.h @@ -0,0 +1,229 @@ +/** + * @file encounter_nh_pvp.h + * @brief NH (No Honor) PvP encounter — the original 1v1 LMS-style fight. + * + * Wraps the existing osrs_pvp_api.h (pvp_init/pvp_reset/pvp_step) as an + * EncounterDef implementation. This is the first encounter and serves as + * the reference for how to add new encounters. + * + * Entity layout: 2 players (agent + opponent). + * Obs: 334 features. Actions: 7 heads [9,13,6,2,5,2,2]. Mask: 39. + */ + +#ifndef ENCOUNTER_NH_PVP_H +#define ENCOUNTER_NH_PVP_H + +#include "../osrs_encounter.h" +#include "../osrs_pvp.h" + +/* obs/action dimensions from osrs_types.h */ +static const int NH_PVP_ACTION_DIMS[] = { + LOADOUT_DIM, COMBAT_DIM, OVERHEAD_DIM, + FOOD_DIM, POTION_DIM, KARAMBWAN_DIM, VENG_DIM +}; + +/* ======================================================================== */ +/* encounter state: just wraps OsrsPvp */ +/* ======================================================================== */ + +typedef struct { + OsrsPvp env; +} NhPvpState; + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static EncounterState* nh_pvp_create(void) { + NhPvpState* s = (NhPvpState*)calloc(1, sizeof(NhPvpState)); + pvp_init(&s->env); + /* pvp_init sets internal buf pointers for game logic (observations, actions, etc.). + also wire the ocean pointers to internal buffers so pvp_step can write obs/rewards + without needing the PufferLib binding. */ + s->env.ocean_obs = s->env._obs_buf; + s->env.ocean_acts = s->env._acts_buf; + s->env.ocean_rew = s->env._rews_buf; + s->env.ocean_term = s->env._terms_buf; + return (EncounterState*)s; +} + +static void nh_pvp_destroy(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + pvp_close(&s->env); + free(s); +} + +static void nh_pvp_reset(EncounterState* state, uint32_t seed) { + NhPvpState* s = (NhPvpState*)state; + if (seed != 0) { + s->env.has_rng_seed = 1; + s->env.rng_seed = seed; + } + pvp_reset(&s->env); +} + +static void nh_pvp_step(EncounterState* state, const int* actions) { + NhPvpState* s = (NhPvpState*)state; + /* pvp_step reads agent 0 actions from ocean_acts (line 481 of osrs_pvp_api.h) */ + memcpy(s->env.ocean_acts, actions, NUM_ACTION_HEADS * sizeof(int)); + pvp_step(&s->env); +} + +/* ======================================================================== */ +/* RL interface */ +/* ======================================================================== */ + +static void nh_pvp_write_obs(EncounterState* state, float* obs_out) { + NhPvpState* s = (NhPvpState*)state; + /* observations are already computed by pvp_step into _obs_buf. + copy agent 0's observations (SLOT_NUM_OBSERVATIONS floats). */ + memcpy(obs_out, s->env._obs_buf, SLOT_NUM_OBSERVATIONS * sizeof(float)); +} + +static void nh_pvp_write_mask(EncounterState* state, float* mask_out) { + NhPvpState* s = (NhPvpState*)state; + /* masks are in _masks_buf, ACTION_MASK_SIZE bytes for agent 0. + convert to float for the encounter interface. */ + for (int i = 0; i < ACTION_MASK_SIZE; i++) { + mask_out[i] = (float)s->env._masks_buf[i]; + } +} + +static float nh_pvp_get_reward(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env._rews_buf[0]; +} + +static int nh_pvp_is_terminal(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env.episode_over; +} + +/* ======================================================================== */ +/* entity access */ +/* ======================================================================== */ + +static int nh_pvp_get_entity_count(EncounterState* state) { + (void)state; + return NUM_AGENTS; /* always 2 for NH PvP */ +} + +static void* nh_pvp_get_entity(EncounterState* state, int index) { + NhPvpState* s = (NhPvpState*)state; + return &s->env.players[index]; +} + +/* ======================================================================== */ +/* render entity population */ +/* ======================================================================== */ + +static void nh_pvp_fill_render_entities(EncounterState* state, RenderEntity* out, int max_entities, int* count) { + NhPvpState* s = (NhPvpState*)state; + int n = NUM_AGENTS < max_entities ? NUM_AGENTS : max_entities; + for (int i = 0; i < n; i++) { + render_entity_from_player(&s->env.players[i], &out[i]); + } + *count = n; +} + +/* ======================================================================== */ +/* config */ +/* ======================================================================== */ + +static void nh_pvp_put_int(EncounterState* state, const char* key, int value) { + NhPvpState* s = (NhPvpState*)state; + if (strcmp(key, "opponent_type") == 0) { + s->env.opponent.type = (OpponentType)value; + } else if (strcmp(key, "is_lms") == 0) { + s->env.is_lms = value; + } else if (strcmp(key, "use_c_opponent") == 0) { + s->env.use_c_opponent = value; + } else if (strcmp(key, "auto_reset") == 0) { + s->env.auto_reset = value; + } else if (strcmp(key, "seed") == 0) { + s->env.has_rng_seed = 1; + s->env.rng_seed = (uint32_t)value; + } +} + +static void nh_pvp_put_float(EncounterState* state, const char* key, float value) { + NhPvpState* s = (NhPvpState*)state; + if (strcmp(key, "shaping_scale") == 0) { + s->env.shaping.shaping_scale = value; + } +} + +static void nh_pvp_put_ptr(EncounterState* state, const char* key, void* value) { + NhPvpState* s = (NhPvpState*)state; + if (strcmp(key, "collision_map") == 0) { + s->env.collision_map = value; + } +} + +/* ======================================================================== */ +/* logging and state queries */ +/* ======================================================================== */ + +static void* nh_pvp_get_log(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return &s->env.log; +} + +static int nh_pvp_get_tick(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env.tick; +} + +static int nh_pvp_get_winner(EncounterState* state) { + NhPvpState* s = (NhPvpState*)state; + return s->env.winner; +} + +/* ======================================================================== */ +/* encounter definition */ +/* ======================================================================== */ + +static const EncounterDef ENCOUNTER_NH_PVP = { + .name = "nh_pvp", + .obs_size = SLOT_NUM_OBSERVATIONS, + .num_action_heads = NUM_ACTION_HEADS, + .action_head_dims = NH_PVP_ACTION_DIMS, + .mask_size = ACTION_MASK_SIZE, + + .create = nh_pvp_create, + .destroy = nh_pvp_destroy, + .reset = nh_pvp_reset, + .step = nh_pvp_step, + + .write_obs = nh_pvp_write_obs, + .write_mask = nh_pvp_write_mask, + .get_reward = nh_pvp_get_reward, + .is_terminal = nh_pvp_is_terminal, + + .get_entity_count = nh_pvp_get_entity_count, + .get_entity = nh_pvp_get_entity, + .fill_render_entities = nh_pvp_fill_render_entities, + + .put_int = nh_pvp_put_int, + .put_float = nh_pvp_put_float, + .put_ptr = nh_pvp_put_ptr, + + .render_post_tick = NULL, /* NH PvP uses existing render_post_tick for now */ + .get_log = nh_pvp_get_log, + .get_tick = nh_pvp_get_tick, + .get_winner = nh_pvp_get_winner, + + /* NH PvP uses its own human_to_pvp_actions translator via the PvP code path. */ + .translate_human_input = NULL, + .head_move = -1, + .head_prayer = -1, + .head_target = -1, +}; + +/* auto-register on include */ +__attribute__((constructor)) +static void nh_pvp_register(void) { + encounter_register(&ENCOUNTER_NH_PVP); +} + +#endif /* ENCOUNTER_NH_PVP_H */ diff --git a/ocean/osrs/encounters/encounter_zulrah.h b/ocean/osrs/encounters/encounter_zulrah.h new file mode 100644 index 0000000000..5b4c0bc8fa --- /dev/null +++ b/ocean/osrs/encounters/encounter_zulrah.h @@ -0,0 +1,2375 @@ +/** + * @file encounter_zulrah.h + * @brief Zulrah boss encounter — real OSRS mechanics with 4 fixed rotations. + * + * Implements the actual OSRS Zulrah fight from the wiki: + * - 4 predetermined rotations (11-13 phases each) + * - 4 positions: middle, south, east, west + * - 3 forms: green/serpentine (ranged), red/magma (melee), blue/tanzanite (magic+ranged) + * - Jad phase: serpentine alternating ranged/magic or magic/ranged + * - Fixed action sequences per phase (exact # attacks, clouds, snakelings) + * - Dive transitions between phases (~5 ticks) + * + * Form mechanics (from OSRS wiki): + * Green (2042): ranged attacks with accuracy roll, max hit 41. def_magic -45, def_ranged +50. + * Red (2043): melee — stares at player tile, whips tail after 3-tick delay. + * accuracy roll + max hit 41 + stun if hit. def_magic 0, def_ranged +300. + * Blue (2044): random magic+ranged attacks (75% magic, 25% ranged). + * Magic always accurate. def_magic +300, def_ranged 0. + * + * Damage cap: hits over 50 → random 45-50 (Mod Ash confirmed). + * Clouds: 3x3 area, 1-5 damage per tick. + * Venom: 25% chance per ranged/magic attack (even through prayer). + * Snakelings: 1 HP, melee max 15 / magic max 13, random type at spawn. + * NPC size: 5x5. Attack speed: 3 ticks. Melee interval: 6 ticks total. + * Gear tiers: 3 tiers (budget/mid/BIS) with precomputed stats. + * Blowpipe spec: 50% energy, heals 50% of damage dealt. + * Antivenom: extended anti-venom+ grants 300 ticks immunity. + * + * Entity layout: player (0), zulrah (1), up to 4 snakelings (2-5). + */ + +#ifndef ENCOUNTER_ZULRAH_H +#define ENCOUNTER_ZULRAH_H + +#include "../osrs_encounter.h" +#include "../osrs_types.h" +#include "../osrs_items.h" +#include "../osrs_combat_shared.h" +#include "../osrs_collision.h" +#include "../osrs_pathfinding.h" +#include "../data/npc_models.h" +#include +#include + +/* ======================================================================== */ +/* constants */ +/* ======================================================================== */ + +#define ZUL_ARENA_SIZE 28 +#define ZUL_NPC_SIZE 5 + +/* player platform bounds (walkable tiles) — centered on the shrine area */ +#define ZUL_PLATFORM_MIN 5 +#define ZUL_PLATFORM_MAX 22 + +/* 4 zulrah positions (relative coords on 28x28 grid). + mapped from OSRS world coords via RuneLite plugin ZulrahLocation.java, + anchored to NORTH=(2266,3073). base offset: (2254, 3060). */ +#define ZUL_POS_NORTH 0 /* RuneLite: NORTH / "middle" */ +#define ZUL_POS_SOUTH 1 +#define ZUL_POS_EAST 2 +#define ZUL_POS_WEST 3 +#define ZUL_NUM_POSITIONS 4 + +static const int ZUL_POSITIONS[ZUL_NUM_POSITIONS][2] = { + { 10, 12 }, /* NORTH: center/north */ + { 10, 1 }, /* SOUTH: bottom edge */ + { 20, 10 }, /* EAST: right edge */ + { 0, 10 }, /* WEST: left edge */ +}; + +/* player starting position — shrine entry (2267,3068) per RuneLite StandLocation */ +#define ZUL_PLAYER_START_X 11 +#define ZUL_PLAYER_START_Y 7 + +/* zulrah combat stats (from monster JSON 2042/2043/2044) */ +#define ZUL_BASE_HP 500 +#define ZUL_MAX_HIT 41 +#define ZUL_ATTACK_SPEED 3 +#define ZUL_DEF_LEVEL 300 + +/* per-form defence (from monster JSON) */ +#define ZUL_GREEN_DEF_MAGIC (-45) +#define ZUL_GREEN_DEF_RANGED 50 +#define ZUL_RED_DEF_MAGIC 0 +#define ZUL_RED_DEF_RANGED 300 +#define ZUL_BLUE_DEF_MAGIC 300 +#define ZUL_BLUE_DEF_RANGED 0 + +/* melee form: stares then whips. accuracy roll + max hit 41. stun if hit. + wiki: melee attack speed 6. RuneLite plugin sets attackTicks=8 on melee anims + (5806/5807) but that counter likely includes animation delay + display offset. + stare 3 ticks, then whip (interval 3+3=6). */ +#define ZUL_MELEE_STARE_TICKS 3 /* ticks before tail whip */ +#define ZUL_MELEE_INTERVAL 6 /* total ticks between melee attack starts (wiki: attack speed 6) */ +#define ZUL_MELEE_STUN_TICKS 5 /* stun duration */ + +/* damage cap: hits over 50 → random 45-50 */ +#define ZUL_DAMAGE_CAP 50 +#define ZUL_DAMAGE_CAP_MIN 45 + +/* phase transition timing (from video analysis): + surface anim plays at start of phase, dive anim plays at end. + phaseTicks covers the entire duration including both animations. + no gap between phases — dive ends, next phase surfaces immediately. */ +#define ZUL_SURFACE_TICKS_INITIAL 3 /* first phase: initial rise (anim 5071) */ +#define ZUL_SURFACE_TICKS 2 /* subsequent phases: rise (anim 5073) */ +#define ZUL_DIVE_ANIM_TICKS 3 /* dig animation at end of phase */ + +/* hazards */ +#define ZUL_MAX_CLOUDS 7 /* observed server-side cap: 4 spits * 2 = 8 but only 7 persist */ +#define ZUL_MAX_SNAKELINGS 4 +#define ZUL_CLOUD_SIZE 3 /* 3x3 area (wiki confirmed) */ +#define ZUL_CLOUD_DURATION 30 /* ticks before cloud fades (from RuneLite Zulrah plugin: toxicCloudsMap.put(obj, 30)) */ +#define ZUL_CLOUD_DAMAGE_MIN 1 +#define ZUL_CLOUD_DAMAGE_MAX 5 /* wiki: 1-5 per tick */ + +/* snakelings — differentiated max hit by type (wiki) */ +#define ZUL_SNAKELING_HP 1 +#define ZUL_SNAKELING_MELEE_MAX_HIT 15 /* NPC 2045 */ +#define ZUL_SNAKELING_MAGIC_MAX_HIT 13 /* NPC 2046 */ +#define ZUL_SNAKELING_SPEED 3 +#define ZUL_SNAKELING_LIFESPAN 67 /* ~40 seconds = 40/0.6 ticks */ + +/* venom: escalating 6->8->10->...->20 every 30 ticks (~18 seconds) */ +#define ZUL_VENOM_INTERVAL 30 +#define ZUL_VENOM_START 6 +#define ZUL_VENOM_MAX 20 + +/* NPC attack rolls — precomputed from wiki NPC stats. + formula: (npc_level + 8) * (npc_att_bonus + 64) */ +#define ZUL_NPC_RANGED_ATT_ROLL 35112 /* (300+8) * (50+64) */ + +/* spawn timing for clouds/snakelings during phase actions */ +#define ZUL_SPAWN_INTERVAL 3 /* ticks between each cloud/snakeling spit (same as attack speed) */ +#define ZUL_CLOUD_FLIGHT_1 3 /* ticks for first cloud projectile to land */ +#define ZUL_CLOUD_FLIGHT_2 4 /* ticks for second cloud projectile to land */ + +/* antivenom */ +#define ZUL_ANTIVENOM_DURATION 300 /* extended anti-venom+: 3 minutes = 300 ticks */ +#define ZUL_ANTIVENOM_DOSES 4 + +/* blowpipe spec */ +#define ZUL_SPEC_COST 50 /* 50% special energy */ +#define ZUL_SPEC_HEAL_PCT 50 /* heal 50% of damage dealt */ + +/* thrall: greater ghost (arceuus spellbook, always hits, ignores armour). + * max hit 3, attack speed 4 ticks. duration = 0.6 * magic_level seconds + * = magic_level ticks (at 99 magic = 99 ticks ≈ 59.4s, then resummon). */ +#define ZUL_THRALL_MAX_HIT 3 +#define ZUL_THRALL_SPEED 4 /* attacks every 4 ticks */ +#define ZUL_THRALL_DURATION 99 /* ticks (0.6 * 99 magic = 59.4s) */ +#define ZUL_THRALL_COOLDOWN 17 /* 10 second resummon cooldown */ + +/* player starting stats */ +#define ZUL_PLAYER_HP 99 +#define ZUL_PLAYER_PRAYER 77 +#define ZUL_PLAYER_FOOD 10 /* sharks */ +#define ZUL_PLAYER_KARAMBWAN 4 +#define ZUL_PLAYER_RESTORE_DOSES 8 /* prayer potion doses (4 per pot = 2 pots) */ +#define ZUL_FOOD_HEAL 20 /* shark heals 20 */ +#define ZUL_KARAMBWAN_HEAL 18 +#define ZUL_PRAYER_RESTORE (7 + (77 * 25) / 100) /* prayer pot: 7 + floor(prayer_lvl/4) = 26 */ +#define ZUL_MAX_TICKS 600 + +/* ======================================================================== */ +/* observation and action space */ +/* ======================================================================== */ + +#define ZUL_NUM_OBS 81 +#define ZUL_NUM_ACTION_HEADS 6 + +#define ZUL_MOVE_DIM ENCOUNTER_MOVE_ACTIONS +#define ZUL_ATTACK_DIM 3 +#define ZUL_PRAYER_DIM ENCOUNTER_PRAYER_DIM +#define ZUL_FOOD_DIM 3 /* none, shark, karambwan */ +#define ZUL_POTION_DIM 3 /* none, restore, antivenom */ +#define ZUL_SPEC_DIM 2 + +#define ZUL_ACTION_MASK_SIZE (ZUL_MOVE_DIM + ZUL_ATTACK_DIM + ZUL_PRAYER_DIM + \ + ZUL_FOOD_DIM + ZUL_POTION_DIM + ZUL_SPEC_DIM) + +#define ZUL_HEAD_MOVE 0 +#define ZUL_HEAD_ATTACK 1 +#define ZUL_HEAD_PRAYER 2 +#define ZUL_HEAD_FOOD 3 +#define ZUL_HEAD_POTION 4 +#define ZUL_HEAD_SPEC 5 + +#define ZUL_MOVE_STAY 0 +#define ZUL_ATK_NONE 0 +#define ZUL_ATK_MAGE 1 +#define ZUL_ATK_RANGE 2 + +/* ======================================================================== */ +/* enums */ +/* ======================================================================== */ + +typedef enum { + ZUL_FORM_GREEN = 0, /* 2042: serpentine, ranged */ + ZUL_FORM_RED, /* 2043: magma, melee */ + ZUL_FORM_BLUE, /* 2044: tanzanite, magic+ranged */ +} ZulrahForm; + +typedef enum { + ZUL_GEAR_MAGE = 0, + ZUL_GEAR_RANGE, +} ZulrahGearStyle; + +/* ======================================================================== */ +/* rotation data: action types for phase sequences */ +/* ======================================================================== */ + +typedef enum { + ZA_END = 0, /* sentinel — end of action list */ + ZA_RANGED, /* green form ranged attacks */ + ZA_MAGIC_RANGED, /* blue form random magic/ranged (magic more frequent) */ + ZA_MELEE, /* red form melee (stare + tail whip) */ + ZA_JAD_RM, /* jad: alternating, starting with ranged */ + ZA_JAD_MR, /* jad: alternating, starting with magic */ + ZA_CLOUDS, /* venom cloud barrages */ + ZA_SNAKELINGS, /* snakeling orbs */ + ZA_SNAKECLOUD_ALT, /* alternating: snakeling, cloud, snakeling, cloud... */ + ZA_CLOUDSNAKE_ALT, /* alternating: cloud, snakeling, cloud, snakeling... */ +} ZulActionType; + +typedef struct { + uint8_t type; /* ZulActionType */ + uint8_t count; +} ZulAction; + +#define ZUL_MAX_PHASE_ACTIONS 6 + +typedef struct { + uint8_t position; /* ZUL_POS_NORTH etc. */ + uint8_t form; /* ZUL_FORM_GREEN etc. */ + uint8_t stand; /* ZUL_STAND_* — safe tile for this phase */ + uint8_t stall; /* ZUL_STAND_* — stall tile (or ZUL_STAND_NONE) */ + uint8_t phase_ticks; /* total ticks at this position (from RuneLite plugin phaseTicks) */ + ZulAction actions[ZUL_MAX_PHASE_ACTIONS]; +} ZulRotationPhase; + +#define ZUL_MAX_ROT_PHASES 13 +#define ZUL_NUM_ROTATIONS 4 + +/* stand locations converted from RuneLite plugin StandLocation.java. + plugin uses OSRS local coords (128 units/tile). conversion: + grid_x = local_x/128 - 38, grid_y = local_y/128 - 44 + (derived from NORTH zulrah center (6720,7616) → grid (12,13)) */ +/* TODO: safe tile positions are adapted from a zulrah helper plugin and may not + * be perfectly accurate. need to verify these coordinates against actual game + * behavior, especially the pillar-adjacent positions which serve as safespots + * where zulrah's ranged/magic attacks should be blocked by line of sight. */ +#define ZUL_STAND_SOUTHWEST 0 /* (10, 9) */ +#define ZUL_STAND_WEST 1 /* ( 8, 15) */ +#define ZUL_STAND_CENTER 2 /* (15, 10) */ +#define ZUL_STAND_NORTHEAST_TOP 3 /* (20, 17) */ +#define ZUL_STAND_NORTHEAST_BOT 4 /* (19, 17) */ +#define ZUL_STAND_NORTHWEST_TOP 5 /* ( 8, 16) */ +#define ZUL_STAND_NORTHWEST_BOT 6 /* (10, 17) */ +#define ZUL_STAND_EAST_PILLAR_S 7 /* (18, 10) */ +#define ZUL_STAND_EAST_PILLAR 8 /* (18, 11) */ +#define ZUL_STAND_EAST_PILLAR_N 9 /* (18, 13) */ +#define ZUL_STAND_EAST_PILLAR_N2 10 /* (18, 14) */ +#define ZUL_STAND_WEST_PILLAR_S 11 /* (10, 10) */ +#define ZUL_STAND_WEST_PILLAR 12 /* (10, 11) */ +#define ZUL_STAND_WEST_PILLAR_N 13 /* (10, 13) */ +#define ZUL_STAND_WEST_PILLAR_N2 14 /* (10, 14) */ +#define ZUL_NUM_STAND_LOCATIONS 15 +#define ZUL_STAND_NONE 255 /* no stall location */ + +static const int ZUL_STAND_COORDS[ZUL_NUM_STAND_LOCATIONS][2] = { + { 8, 8 }, /* SOUTHWEST */ + { 6, 14 }, /* WEST */ + { 13, 9 }, /* CENTER */ + { 18, 16 }, /* NORTHEAST_TOP */ + { 17, 16 }, /* NORTHEAST_BOTTOM */ + { 6, 15 }, /* NORTHWEST_TOP */ + { 8, 16 }, /* NORTHWEST_BOTTOM */ + { 16, 9 }, /* EAST_PILLAR_S */ + { 16, 10 }, /* EAST_PILLAR */ + { 16, 12 }, /* EAST_PILLAR_N */ + { 16, 13 }, /* EAST_PILLAR_N2 */ + { 8, 9 }, /* WEST_PILLAR_S */ + { 8, 10 }, /* WEST_PILLAR */ + { 8, 12 }, /* WEST_PILLAR_N */ + { 8, 13 }, /* WEST_PILLAR_N2 */ +}; + +#define ZA(t,c) { (uint8_t)(t), (uint8_t)(c) } +#define ZE { 0, 0 } /* ZA_END sentinel */ +#define _N ZUL_STAND_NONE /* no stall location shorthand */ + +/* rotation 1: "Magma A" — 11 phases + stand/stall/phaseTicks from RuneLite plugin RotationType.java ROT_A */ +static const ZulRotationPhase ZUL_ROT1[11] = { + /* 1 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_CLOUDS,4), ZE } }, + /* 2 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_NORTHEAST_TOP, _N, 21, { ZA(ZA_MELEE,2), ZE } }, + /* 3 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR_N, ZUL_STAND_EAST_PILLAR_S, 18, { ZA(ZA_MAGIC_RANGED,4), ZE } }, + /* 4 */ { ZUL_POS_SOUTH, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_N, ZUL_STAND_WEST_PILLAR_N2, 39, { ZA(ZA_RANGED,5), ZA(ZA_SNAKELINGS,2), ZA(ZA_CLOUDS,2), ZA(ZA_SNAKELINGS,2), ZE } }, + /* 5 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_WEST_PILLAR_N, _N, 22, { ZA(ZA_MELEE,2), ZE } }, + /* 6 */ { ZUL_POS_WEST, ZUL_FORM_BLUE, ZUL_STAND_WEST_PILLAR_S, ZUL_STAND_EAST_PILLAR_S, 20, { ZA(ZA_MAGIC_RANGED,5), ZE } }, + /* 7 */ { ZUL_POS_SOUTH, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR, _N, 28, { ZA(ZA_CLOUDS,3), ZA(ZA_SNAKELINGS,4), ZE } }, + /* 8 */ { ZUL_POS_SOUTH, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR, ZUL_STAND_EAST_PILLAR_N2, 36, { ZA(ZA_MAGIC_RANGED,5), ZA(ZA_SNAKECLOUD_ALT,5), ZE } }, + /* 9 */ { ZUL_POS_WEST, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_S, ZUL_STAND_EAST_PILLAR_S, 48, { ZA(ZA_JAD_RM,10), ZA(ZA_CLOUDS,4), ZE } }, + /* 10 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_NORTHEAST_TOP, _N, 21, { ZA(ZA_MELEE,2), ZE } }, + /* 11 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_RANGED,5), ZA(ZA_CLOUDS,4), ZE } }, +}; + +/* rotation 2: "Magma B" — 11 phases + stand/stall/phaseTicks from RuneLite plugin RotationType.java ROT_B */ +static const ZulRotationPhase ZUL_ROT2[11] = { + /* 1 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_CLOUDS,4), ZE } }, + /* 2 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_NORTHEAST_TOP, _N, 21, { ZA(ZA_MELEE,2), ZE } }, + /* 3 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR_N, ZUL_STAND_EAST_PILLAR_S, 18, { ZA(ZA_MAGIC_RANGED,4), ZE } }, + /* 4 */ { ZUL_POS_WEST, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_S, _N, 28, { ZA(ZA_CLOUDS,3), ZA(ZA_SNAKELINGS,4), ZE } }, + /* 5 */ { ZUL_POS_SOUTH, ZUL_FORM_BLUE, ZUL_STAND_WEST_PILLAR_N, ZUL_STAND_WEST_PILLAR_N2, 39, { ZA(ZA_MAGIC_RANGED,5), ZA(ZA_SNAKELINGS,2), ZA(ZA_CLOUDS,2), ZA(ZA_SNAKELINGS,2), ZE } }, + /* 6 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_WEST_PILLAR_N, _N, 21, { ZA(ZA_MELEE,2), ZE } }, + /* 7 */ { ZUL_POS_EAST, ZUL_FORM_GREEN, ZUL_STAND_CENTER, ZUL_STAND_WEST_PILLAR_S, 20, { ZA(ZA_RANGED,5), ZE } }, + /* 8 */ { ZUL_POS_SOUTH, ZUL_FORM_BLUE, ZUL_STAND_WEST_PILLAR_S, ZUL_STAND_WEST_PILLAR_N2, 36, { ZA(ZA_MAGIC_RANGED,5), ZA(ZA_SNAKECLOUD_ALT,5), ZE } }, + /* 9 */ { ZUL_POS_WEST, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_S, ZUL_STAND_EAST_PILLAR_S, 48, { ZA(ZA_JAD_RM,10), ZA(ZA_CLOUDS,4), ZE } }, + /* 10 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_NORTHEAST_TOP, _N, 21, { ZA(ZA_MELEE,2), ZE } }, + /* 11 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_RANGED,5), ZA(ZA_CLOUDS,4), ZE } }, +}; + +/* rotation 3: "Serp" — 12 phases + stand/stall/phaseTicks from RuneLite plugin RotationType.java ROT_C */ +static const ZulRotationPhase ZUL_ROT3[12] = { + /* 1 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_CLOUDS,4), ZE } }, + /* 2 */ { ZUL_POS_EAST, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 30, { ZA(ZA_RANGED,5), ZA(ZA_SNAKELINGS,3), ZE } }, + /* 3 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_WEST, _N, 40, { ZA(ZA_CLOUDSNAKE_ALT,6), ZA(ZA_MELEE,2), ZE } }, + /* 4 */ { ZUL_POS_WEST, ZUL_FORM_BLUE, ZUL_STAND_WEST, ZUL_STAND_EAST_PILLAR_S, 20, { ZA(ZA_MAGIC_RANGED,5), ZE } }, + /* 5 */ { ZUL_POS_SOUTH, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR_S, ZUL_STAND_EAST_PILLAR_N2, 20, { ZA(ZA_RANGED,5), ZE } }, + /* 6 */ { ZUL_POS_EAST, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR_S, ZUL_STAND_WEST_PILLAR_S, 20, { ZA(ZA_MAGIC_RANGED,5), ZE } }, + /* 7 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_N, _N, 25, { ZA(ZA_CLOUDS,3), ZA(ZA_SNAKELINGS,3), ZE } }, + /* 8 */ { ZUL_POS_WEST, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_N, _N, 20, { ZA(ZA_RANGED,5), ZE } }, + /* 9 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR_N, ZUL_STAND_EAST_PILLAR_S, 36, { ZA(ZA_MAGIC_RANGED,5), ZA(ZA_CLOUDS,2), ZA(ZA_SNAKELINGS,3), ZE } }, + /* 10 */ { ZUL_POS_EAST, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR_N, _N, 35, { ZA(ZA_JAD_MR,10), ZE } }, + /* 11 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_NORTHEAST_TOP, _N, 18, { ZA(ZA_SNAKELINGS,4), ZE } }, + /* 12 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_RANGED,5), ZA(ZA_CLOUDS,4), ZE } }, +}; + +/* rotation 4: "Tanz" — 13 phases + stand/stall/phaseTicks from RuneLite plugin RotationType.java ROT_D */ +static const ZulRotationPhase ZUL_ROT4[13] = { + /* 1 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_CLOUDS,4), ZE } }, + /* 2 */ { ZUL_POS_EAST, ZUL_FORM_BLUE, ZUL_STAND_NORTHEAST_TOP, _N, 36, { ZA(ZA_SNAKELINGS,4), ZA(ZA_MAGIC_RANGED,6), ZE } }, + /* 3 */ { ZUL_POS_SOUTH, ZUL_FORM_GREEN, ZUL_STAND_WEST_PILLAR_N, ZUL_STAND_WEST_PILLAR_N2, 24, { ZA(ZA_RANGED,4), ZA(ZA_CLOUDS,2), ZE } }, + /* 4 */ { ZUL_POS_WEST, ZUL_FORM_BLUE, ZUL_STAND_WEST_PILLAR_N, _N, 30, { ZA(ZA_SNAKELINGS,4), ZA(ZA_MAGIC_RANGED,4), ZE } }, + /* 5 */ { ZUL_POS_NORTH, ZUL_FORM_RED, ZUL_STAND_EAST_PILLAR_N, _N, 28, { ZA(ZA_MELEE,2), ZA(ZA_CLOUDS,2), ZE } }, + /* 6 */ { ZUL_POS_EAST, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR, _N, 17, { ZA(ZA_RANGED,4), ZE } }, + /* 7 */ { ZUL_POS_SOUTH, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR, _N, 34, { ZA(ZA_SNAKELINGS,6), ZA(ZA_CLOUDS,3), ZE } }, + /* 8 */ { ZUL_POS_WEST, ZUL_FORM_BLUE, ZUL_STAND_WEST_PILLAR_S, _N, 33, { ZA(ZA_MAGIC_RANGED,5), ZA(ZA_SNAKELINGS,4), ZE } }, + /* 9 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR_N, ZUL_STAND_EAST_PILLAR_S, 20, { ZA(ZA_RANGED,4), ZE } }, + /* 10 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_EAST_PILLAR_N, ZUL_STAND_EAST_PILLAR_S, 27, { ZA(ZA_MAGIC_RANGED,4), ZA(ZA_CLOUDS,3), ZE } }, + /* 11 */ { ZUL_POS_EAST, ZUL_FORM_GREEN, ZUL_STAND_EAST_PILLAR_N, _N, 29, { ZA(ZA_JAD_MR,8), ZE } }, + /* 12 */ { ZUL_POS_NORTH, ZUL_FORM_BLUE, ZUL_STAND_NORTHEAST_TOP, _N, 18, { ZA(ZA_SNAKELINGS,4), ZE } }, + /* 13 */ { ZUL_POS_NORTH, ZUL_FORM_GREEN, ZUL_STAND_NORTHEAST_TOP, _N, 28, { ZA(ZA_RANGED,5), ZA(ZA_CLOUDS,4), ZE } }, +}; + +#undef _N + +/* rotation table: pointers + lengths */ +static const ZulRotationPhase* const ZUL_ROTATIONS[ZUL_NUM_ROTATIONS] = { + ZUL_ROT1, ZUL_ROT2, ZUL_ROT3, ZUL_ROT4, +}; +static const int ZUL_ROT_LENGTHS[ZUL_NUM_ROTATIONS] = { 11, 11, 12, 13 }; + +#undef ZA +#undef ZE + +/* ======================================================================== */ +/* static arrays */ +/* ======================================================================== */ + +static const int ZUL_ACTION_HEAD_DIMS[ZUL_NUM_ACTION_HEADS] = { + ZUL_MOVE_DIM, ZUL_ATTACK_DIM, ZUL_PRAYER_DIM, + ZUL_FOOD_DIM, ZUL_POTION_DIM, ZUL_SPEC_DIM, +}; + +/* movement uses shared encounter_move_to_target + ENCOUNTER_MOVE_TARGET_DX/DY from osrs_encounter.h */ + +/* ======================================================================== */ +/* gear tier precomputed stats — from wiki strategy guide loadouts */ +/* ======================================================================== */ + +#define ZUL_NUM_GEAR_TIERS 3 + +typedef struct { + /* player attacking zulrah (offensive) */ + int mage_att_bonus; /* total magic attack bonus in mage gear */ + int range_att_bonus; /* total ranged attack bonus in range gear (tbow for BIS) */ + int bp_att_bonus; /* ranged attack bonus when using blowpipe (for spec) */ + int mage_max_hit; /* max hit with mage weapon */ + int range_max_hit; /* max hit with ranged weapon (tbow scaled at zulrah) */ + int bp_max_hit; /* blowpipe max hit (for spec) */ + int eff_mage_level; /* effective magic level: floor((99+boost)*prayer) + 8 */ + int eff_range_level; /* effective range level: floor((99+boost)*prayer) + 8 */ + int range_speed; /* ranged weapon attack speed: tbow=5, blowpipe=3 */ + /* player defending vs zulrah (per active gear style) */ + int def_level; /* defence level (99) */ + int magic_def_eff; /* precomputed: floor(0.7*(magic+8) + 0.3*(def*prayer+8)) */ + int mage_def_melee; /* melee def bonus in mage gear */ + int mage_def_ranged; /* ranged def bonus in mage gear */ + int mage_def_magic; /* magic def bonus in mage gear */ + int range_def_melee; /* melee def bonus in range gear */ + int range_def_ranged; /* ranged def bonus in range gear */ + int range_def_magic; /* magic def bonus in range gear */ +} ZulGearTierStats; + +/* tier 0: trident + mystic + god d'hide + blowpipe, no rigour/augury + tier 1: sang staff + ahrim's + blessed d'hide + blowpipe, rigour/augury + tier 2 (BIS from wiki): eye of ayak + ancestral + tbow + masori + rigour/augury + all values computed from exact wiki item stats (march 2026) */ +static const ZulGearTierStats ZUL_GEAR_TIERS[ZUL_NUM_GEAR_TIERS] = { + /* tier 0 (budget): trident + mystic + god d'hide + blowpipe, no rigour/augury + eff levels: floor(99*1.0)+8 = 107 (no prayer boost) + magic_def_eff: floor(0.7*(99+8) + 0.3*(99+8)) = floor(107) = 107 */ + { + .mage_att_bonus = 68, .range_att_bonus = 42, .bp_att_bonus = 42, + .mage_max_hit = 28, .range_max_hit = 25, .bp_max_hit = 25, + .eff_mage_level = 107, .eff_range_level = 107, .range_speed = 3, + .def_level = 99, .magic_def_eff = 107, + .mage_def_melee = 20, .mage_def_ranged = 25, .mage_def_magic = 65, + .range_def_melee = 60, .range_def_ranged = 70, .range_def_magic = -10, + }, + /* tier 1 (mid): sang staff + ahrim's + bowfa + crystal armor, rigour/augury + eff mage: floor(112*1.25)+8 = 148, eff range: floor(112*1.20)+8 = 142 + magic_def_eff: floor(0.7*(112+8) + 0.3*(floor(99*1.25)+8)) + = floor(0.7*120 + 0.3*131) = floor(84+39.3) = 123 + sang max: floor(112/3)-1 = 36, * 1.15 (occult+tormented) = floor(41.4) = 41 + bowfa: +128 att, +106 str. crystal set: +30% acc (applied in code), +15% dmg. + range att: 128+9+31+18+15+8+12+7 = 228. range str: 106+5+2 = 113. + base range max: floor(0.5 + 142*(113+64)/640) = 39, *1.15 = floor(44.85) = 44 + bp_att/max for blowpipe spec (amethyst darts) */ + { + .mage_att_bonus = 105, .range_att_bonus = 228, .bp_att_bonus = 80, + .mage_max_hit = 41, .range_max_hit = 44, .bp_max_hit = 28, + .eff_mage_level = 148, .eff_range_level = 142, .range_speed = 4, + .def_level = 99, .magic_def_eff = 123, + .mage_def_melee = 40, .mage_def_ranged = 50, .mage_def_magic = 95, + .range_def_melee = 75, .range_def_ranged = 95, .range_def_magic = 5, + }, + /* tier 2 (BIS): eye of ayak + ancestral + confliction + elidinis ward + + tbow + masori + zaryte + dizana's quiver + dragon arrows + mage att: 30+8+35+26+12+20+15+25+0+11 = 182, mag dmg +30% + range att: 70+12+43+27+15+18+18 = 203 (with tbow) + bp att: 203 - 70 (tbow) + 30 (blowpipe) = 163 + range str: 20+2+4+2+5+2+3+60 = 98 (dragon arrows) + eff mage: floor(112*1.25)+8 = 148, eff range: floor(112*1.20)+8 = 142 + mage max: floor(floor(112/3)-6)*1.30) = floor(31*1.30) = 40 + range max: floor(37*2.1385) = 79 (tbow at magic 250 cap, 213.85% dmg mult) + bp max: floor((145*(55+64)+320)/640) = 27 (dragon darts) + magic_def_eff: same as tier 1 = 123 (same prayers/boosts) */ + { + .mage_att_bonus = 182, .range_att_bonus = 203, .bp_att_bonus = 163, + .mage_max_hit = 40, .range_max_hit = 79, .bp_max_hit = 27, + .eff_mage_level = 148, .eff_range_level = 142, .range_speed = 5, + .def_level = 99, .magic_def_eff = 123, + .mage_def_melee = 194, .mage_def_ranged = 87, .mage_def_magic = 115, + .range_def_melee = 151, .range_def_ranged = 144, .range_def_magic = 167, + }, +}; + +/* per-tier equipped item loadouts: [tier][slot] = ItemIndex. + mage_loadout is worn while casting mage. range_loadout while ranging. + slots: HEAD CAPE NECK AMMO WEAPON SHIELD BODY LEGS HANDS FEET RING */ +static const uint8_t ZUL_MAGE_LOADOUT[ZUL_NUM_GEAR_TIERS][NUM_GEAR_SLOTS] = { + /* tier 0: mystic + trident + book of darkness */ + { ITEM_MYSTIC_HAT, ITEM_GOD_CAPE, ITEM_GLORY, ITEM_AMETHYST_ARROW, + ITEM_TRIDENT_OF_SWAMP, ITEM_BOOK_OF_DARKNESS, ITEM_MYSTIC_TOP, ITEM_MYSTIC_BOTTOM, + ITEM_BARROWS_GLOVES, ITEM_MYSTIC_BOOTS, ITEM_RING_OF_RECOIL }, + /* tier 1: ahrim's + sang staff + mage's book */ + { ITEM_AHRIMS_HOOD, ITEM_GOD_CAPE, ITEM_OCCULT_NECKLACE, ITEM_GOD_BLESSING, + ITEM_SANGUINESTI_STAFF, ITEM_MAGES_BOOK, ITEM_AHRIMS_ROBETOP, ITEM_AHRIMS_ROBESKIRT, + ITEM_TORMENTED_BRACELET, ITEM_INFINITY_BOOTS, ITEM_RING_OF_RECOIL }, + /* tier 2: ancestral + eye of ayak + elidinis' ward */ + { ITEM_ANCESTRAL_HAT, ITEM_IMBUED_SARA_CAPE, ITEM_OCCULT_NECKLACE, ITEM_DRAGON_ARROWS, + ITEM_EYE_OF_AYAK, ITEM_ELIDINIS_WARD_F, ITEM_ANCESTRAL_TOP, ITEM_ANCESTRAL_BOTTOM, + ITEM_CONFLICTION_GAUNTLETS, ITEM_AVERNIC_TREADS, ITEM_RING_OF_SUFFERING_RI }, +}; + +static const uint8_t ZUL_RANGE_LOADOUT[ZUL_NUM_GEAR_TIERS][NUM_GEAR_SLOTS] = { + /* tier 0: black d'hide + magic shortbow (i) */ + { ITEM_BLESSED_COIF, ITEM_AVAS_ACCUMULATOR, ITEM_GLORY, ITEM_AMETHYST_ARROW, + ITEM_MAGIC_SHORTBOW_I, ITEM_NONE, ITEM_BLACK_DHIDE_BODY, ITEM_BLACK_DHIDE_CHAPS, + ITEM_BARROWS_GLOVES, ITEM_MYSTIC_BOOTS, ITEM_RING_OF_RECOIL }, + /* tier 1: crystal + bow of faerdhinen */ + { ITEM_CRYSTAL_HELM, ITEM_AVAS_ASSEMBLER, ITEM_NECKLACE_OF_ANGUISH, ITEM_GOD_BLESSING, + ITEM_BOW_OF_FAERDHINEN, ITEM_NONE, ITEM_CRYSTAL_BODY, ITEM_CRYSTAL_LEGS, + ITEM_BARROWS_GLOVES, ITEM_BLESSED_DHIDE_BOOTS, ITEM_RING_OF_RECOIL }, + /* tier 2: masori + twisted bow */ + { ITEM_MASORI_MASK_F, ITEM_DIZANAS_QUIVER, ITEM_NECKLACE_OF_ANGUISH, ITEM_DRAGON_ARROWS, + ITEM_TWISTED_BOW, ITEM_NONE, ITEM_MASORI_BODY_F, ITEM_MASORI_CHAPS_F, + ITEM_ZARYTE_VAMBRACES, ITEM_AVERNIC_TREADS, ITEM_RING_OF_SUFFERING_RI }, +}; + +/* gear switching uses shared helpers from osrs_encounter.h: + encounter_apply_loadout() and encounter_populate_inventory(). */ +static void zul_populate_player_inventory(Player* p, int gear_tier) { + const uint8_t* loadouts[] = { + ZUL_MAGE_LOADOUT[gear_tier], + ZUL_RANGE_LOADOUT[gear_tier], + }; + encounter_populate_inventory(p, loadouts, 2, NULL); +} + +/* snakeling spawn positions (shifted +6x for new base offset 2254,3060) */ +#define ZUL_NUM_SNAKELING_POSITIONS 5 +static const int ZUL_SNAKELING_POSITIONS[ZUL_NUM_SNAKELING_POSITIONS][2] = { + { 7, 14 }, { 7, 10 }, { 12, 8 }, { 17, 10 }, { 17, 16 }, +}; + +/* cloud spawn: pick random platform tile that isn't a safe tile for this phase */ +static int zul_tile_is_safe(int x, int y, int stand_id, int stall_id) { + /* safe tiles: the stand location and stall location for this phase */ + if (stand_id < ZUL_NUM_STAND_LOCATIONS) { + int sx = ZUL_STAND_COORDS[stand_id][0]; + int sy = ZUL_STAND_COORDS[stand_id][1]; + /* 2-tile radius around stand spot is "safe enough" for the agent */ + if (abs(x - sx) <= 1 && abs(y - sy) <= 1) return 1; + } + if (stall_id < ZUL_NUM_STAND_LOCATIONS) { + int sx = ZUL_STAND_COORDS[stall_id][0]; + int sy = ZUL_STAND_COORDS[stall_id][1]; + if (abs(x - sx) <= 1 && abs(y - sy) <= 1) return 1; + } + return 0; +} + +/* ======================================================================== */ +/* structs */ +/* ======================================================================== */ + +typedef struct { + int x, y; + int active; + int ticks_remaining; +} ZulrahCloud; + +/* cloud projectile in flight — becomes a ZulrahCloud when delay reaches 0 */ +#define ZUL_MAX_PENDING_CLOUDS 16 +typedef struct { + int x, y; + int delay; /* ticks until cloud spawns (0 = inactive) */ +} ZulrahPendingCloud; + +typedef struct { + Player entity; + int active; + int attack_timer; + int is_magic; /* 1=magic attacks, 0=melee attacks (random at spawn) */ + int lifespan; /* ticks until auto-death */ +} ZulrahSnakeling; + +typedef struct { + /* entities */ + Player player; + Player zulrah; + + /* rotation tracking */ + int rotation_index; /* which of 4 rotations (0-3) */ + int phase_index; /* current phase within rotation (0-based) */ + + /* phase action execution */ + int action_index; /* which action in current phase's action list */ + int action_progress; /* how many of the current action's count completed */ + int action_timer; /* ticks until next action fires */ + int jad_is_magic_next; /* for jad phase: 1 if next attack is magic */ + + /* zulrah state */ + ZulrahForm current_form; + int zulrah_visible; + int zulrah_attacking; /* currently in an attacking phase (not spawning/diving) */ + + /* melee state: stare at tile, then whip */ + int melee_target_x, melee_target_y; + int melee_pending; + int melee_stare_timer; + + /* phase timing: phaseTicks covers surface + actions + dive. + phase_timer counts down each tick. surface_timer delays actions at start. */ + int phase_timer; + int surface_timer; /* ticks of surface animation before actions start */ + int is_diving; /* set when phase_timer <= ZUL_DIVE_ANIM_TICKS */ + + /* player stun (from melee hit) */ + int player_stunned_ticks; + + /* hazards */ + ZulrahCloud clouds[ZUL_MAX_CLOUDS]; + ZulrahPendingCloud pending_clouds[ZUL_MAX_PENDING_CLOUDS]; + ZulrahSnakeling snakelings[ZUL_MAX_SNAKELINGS]; + + /* player combat */ + ZulrahGearStyle player_gear; + int player_attack_timer; + int player_food_count; /* sharks */ + int player_karambwan_count; + int player_restore_doses; /* prayer potion doses */ + int player_food_timer; + int player_potion_timer; + OverheadPrayer player_prayer; + int prayer_drain_counter; /* shared drain system counter (see encounter_drain_prayer) */ + int player_special_energy; + int player_dest_x, player_dest_y; /* click destination for 2-tile run clamping */ + int player_dest_explicit; /* 1 = dest set via put_int (human click), skip direction-based override */ + + /* venom + antivenom */ + int venom_counter; + int venom_timer; + int antivenom_timer; /* ticks remaining of anti-venom immunity */ + int antivenom_doses; /* doses remaining (4 per potion) */ + + /* gear tier */ + int gear_tier; /* 0=budget, 1=mid, 2=BIS */ + + /* eye of ayak soul rend: cumulative magic defence drain on zulrah. + * carries over between forms (magic defence is a stat, not a level). */ + int magic_def_drain; + + /* confliction gauntlets: primed after a magic miss, next magic attack + * rolls accuracy twice (like osmumten's fang). cleared on next magic attack. */ + int confliction_primed; + + /* thrall (arceuus greater ghost): auto-attacks zulrah every 4 ticks, + * always hits 0-3, ignores armour. auto-resummons after expiry + cooldown. */ + int thrall_active; + int thrall_attack_timer; + int thrall_duration_remaining; + int thrall_cooldown; + + /* collision */ + void* collision_map; /* CollisionMap* for walkability checks */ + int world_offset_x; /* local (0,0) = world (offset_x, offset_y) */ + int world_offset_y; + + /* episode */ + int tick; + int episode_over; + int winner; + uint32_t rng_state; + + /* reward tracking */ + float reward; + float episode_return; /* running sum of reward across all ticks */ + float damage_dealt_this_tick; + float damage_received_this_tick; + int prayer_blocked_this_tick; + float total_damage_dealt; + float total_damage_received; + + /* visual: attack events this tick for projectile rendering */ + struct { + int src_x, src_y, dst_x, dst_y; + int style; /* 0=ranged, 1=magic, 2=melee */ + int damage; + } attack_events[8]; + int attack_event_count; + + /* visual: cloud projectile events this tick (style=3, fly from zulrah to landing) */ + struct { + int src_x, src_y, dst_x, dst_y; + int flight_ticks; /* how many game ticks the projectile flies */ + } cloud_events[4]; + int cloud_event_count; + + Log log; +} ZulrahState; + +/* RNG: use shared encounter_rand_int(), encounter_rand_float() from osrs_combat_shared.h */ + +/** Sync encounter consumable counts into Player struct for GUI display. + The GUI reads Player.food_count/brew_doses/etc — encounters that track + these separately must call this after reset and each step. */ +static void zul_sync_player_consumables(ZulrahState* s) { + s->player.food_count = s->player_food_count; + s->player.karambwan_count = s->player_karambwan_count; + s->player.prayer_pot_doses = s->player_restore_doses; + s->player.special_energy = s->player_special_energy; + s->player.antivenom_doses = s->antivenom_doses; +} + +/* ======================================================================== */ +/* helpers */ +/* ======================================================================== */ + +static inline int zul_on_platform_bounds(int x, int y) { + return x >= ZUL_PLATFORM_MIN && x <= ZUL_PLATFORM_MAX && + y >= ZUL_PLATFORM_MIN && y <= ZUL_PLATFORM_MAX; +} + +/* check if local tile (x,y) is walkable via collision map, fallback to bbox */ +static inline int zul_on_platform(ZulrahState* s, int x, int y) { + if (!s->collision_map) return zul_on_platform_bounds(x, y); + int wx = x + s->world_offset_x; + int wy = y + s->world_offset_y; + return collision_tile_walkable((const CollisionMap*)s->collision_map, 0, wx, wy); +} + +/* BFS pathfinding uses shared encounter_pathfind from osrs_encounter.h */ +#define zul_pathfind(s, sx, sy, dx, dy) \ + encounter_pathfind((const CollisionMap*)(s)->collision_map, \ + (s)->world_offset_x, (s)->world_offset_y, (sx), (sy), (dx), (dy), NULL, NULL) + +/* walkability callback for encounter_move_toward_dest */ +static int zul_tile_walkable(void* ctx, int x, int y) { + return zul_on_platform((ZulrahState*)ctx, x, y); +} + +/* cloud overlap: player (1x1) inside cloud (3x3) */ +static inline int zul_player_in_cloud(int cx, int cy, int px, int py) { + return px >= cx && px < cx + ZUL_CLOUD_SIZE && + py >= cy && py < cy + ZUL_CLOUD_SIZE; +} + + +static int zul_form_npc_id(ZulrahForm f) { + return (f == ZUL_FORM_GREEN) ? 2042 : (f == ZUL_FORM_RED) ? 2043 : 2044; +} + +/* apply damage cap: hits over 50 → random 45-50 */ +static inline int zul_cap_damage(ZulrahState* s, int damage) { + if (damage > ZUL_DAMAGE_CAP) { + return ZUL_DAMAGE_CAP_MIN + encounter_rand_int(&s->rng_state, ZUL_DAMAGE_CAP - ZUL_DAMAGE_CAP_MIN + 1); + } + return damage; +} + +/* ======================================================================== */ +/* damage application */ +/* ======================================================================== */ + +static inline int zul_has_recoil_effect(Player* p) { + int ring = p->equipped[GEAR_SLOT_RING]; + return ring == ITEM_RING_OF_RECOIL || ring == ITEM_RING_OF_SUFFERING_RI; +} + +/** Apply damage to the player. If attacker is non-NULL and player has a recoil + ring equipped, reflects floor(damage * 0.1) + 1 back to the attacker. + Pass NULL for environmental damage (clouds, venom) where recoil doesn't apply. */ +static void zul_apply_player_damage(ZulrahState* s, int damage, AttackStyle style, + Player* attacker) { + if (damage <= 0) return; + encounter_damage_player(&s->player, damage, &s->damage_received_this_tick); + s->total_damage_received += damage; + s->player.hit_style = style; + + /* ring of recoil / ring of suffering (i) */ + if (attacker && zul_has_recoil_effect(&s->player) && s->player.recoil_charges > 0) { + int recoil = damage / 10 + 1; + if (recoil > s->player.recoil_charges) { + recoil = s->player.recoil_charges; + } + encounter_damage_player(attacker, recoil, NULL); + + if (s->player.equipped[GEAR_SLOT_RING] == ITEM_RING_OF_RECOIL) { + s->player.recoil_charges -= recoil; + if (s->player.recoil_charges <= 0) { + s->player.recoil_charges = 0; + s->player.equipped[GEAR_SLOT_RING] = ITEM_NONE; + } + } + } +} + +/* venom from ranged/magic attacks — 25% chance per hit (wiki/deob confirmed). + applied even through prayer (unless miss). starts venom counter if not already venomed. */ +static void zul_try_envenom(ZulrahState* s) { + if (s->venom_counter > 0) return; /* already venomed */ + if (s->antivenom_timer > 0) return; /* anti-venom active */ + if (encounter_rand_int(&s->rng_state, 4) != 0) return; /* 25% chance */ + s->venom_counter = 1; + s->venom_timer = ZUL_VENOM_INTERVAL; +} + +/* ======================================================================== */ +/* NPC accuracy roll */ +/* ======================================================================== */ + +/* OSRS accuracy formula: if att > def: 1 - (def+2)/(2*(att+1)), else att/(2*(def+1)) */ +/* hit chance: use shared OSRS accuracy formula from osrs_combat_shared.h */ + +/* confliction gauntlets double accuracy roll (same formula as osmumten's fang). + * on a primed magic attack, accuracy is rolled twice — hitting if either roll succeeds. */ +static float zul_hit_chance_double(int a, int d) { + float fa = (float)a, fd = (float)d; + if (a >= d) { + float num = (fd + 2.0f) * (2.0f * fd + 3.0f); + float den = 6.0f * (fa + 1.0f) * (fa + 1.0f); + return 1.0f - num / den; + } + return fa * (4.0f * fa + 5.0f) / (6.0f * (fa + 1.0f) * (fd + 1.0f)); +} + +/* compute player's defence roll against a specific NPC attack style. + uses current gear (mage/range) and gear tier for defence bonuses. + magic defence uses 70% magic level + 30% defence level per OSRS formula. */ +static int zul_player_def_roll(ZulrahState* s, int attack_style) { + const ZulGearTierStats* t = &ZUL_GEAR_TIERS[s->gear_tier]; + int in_mage = (s->player_gear == ZUL_GEAR_MAGE); + int def_bonus; + int eff_level; + + if (attack_style == ATTACK_STYLE_MAGIC) { + /* magic defence: precomputed floor(0.7*(magic+8) + 0.3*(def*prayer+8)) */ + eff_level = t->magic_def_eff; + def_bonus = in_mage ? t->mage_def_magic : t->range_def_magic; + } else if (attack_style == ATTACK_STYLE_RANGED) { + eff_level = t->def_level + 8; + def_bonus = in_mage ? t->mage_def_ranged : t->range_def_ranged; + } else { + /* melee */ + eff_level = t->def_level + 8; + def_bonus = in_mage ? t->mage_def_melee : t->range_def_melee; + } + int roll = eff_level * (def_bonus + 64); + return roll > 0 ? roll : 0; +} + +/* ======================================================================== */ +/* zulrah attack dispatch */ +/* ======================================================================== */ + +/* record a visual attack event for projectile rendering */ +static void zul_record_attack(ZulrahState* s, int src_x, int src_y, + int dst_x, int dst_y, int style, int damage) { + s->zulrah.npc_anim_id = ZULRAH_ANIM_ATTACK; + if (s->attack_event_count >= 8) return; + int i = s->attack_event_count++; + s->attack_events[i].src_x = src_x; + s->attack_events[i].src_y = src_y; + s->attack_events[i].dst_x = dst_x; + s->attack_events[i].dst_y = dst_y; + s->attack_events[i].style = style; + s->attack_events[i].damage = damage; +} + +/* ranged attack (green form, or blue form ranged variant). + unlike magic, ranged CAN miss (accuracy roll required). + wiki: "ranged and magic attacks will envenom the player unless they miss, + even if blocked by a protection prayer." so venom only on hit. */ +/* TODO: ranged and magic attacks currently ignore line of sight. in the real game, + * standing behind the east/west pillars blocks ranged and magic projectiles too, + * not just melee. we only check pillar safespots for melee (zul_on_pillar_safespot). + * need to investigate whether the game uses proper LOS raycasting from zulrah's + * tile to the player tile, or just checks specific safespot coordinates. this also + * applies to the PvP encounter — entities may currently shoot through walls. */ +static void zul_attack_ranged(ZulrahState* s) { + int dmg = 0; + int did_hit = 0; + if (encounter_prayer_correct_for_style(s->player_prayer, ATTACK_STYLE_RANGED)) { + /* prayer blocks damage but venom still applies (unless miss) */ + int def_roll = zul_player_def_roll(s, ATTACK_STYLE_RANGED); + float chance = osrs_hit_chance(ZUL_NPC_RANGED_ATT_ROLL, def_roll); + did_hit = (encounter_rand_float(&s->rng_state) < chance); + if (did_hit) { + s->prayer_blocked_this_tick = 1; + /* damage blocked by prayer, but attack didn't "miss" */ + } + } else { + /* accuracy roll: NPC ranged att vs player ranged def */ + int def_roll = zul_player_def_roll(s, ATTACK_STYLE_RANGED); + float chance = osrs_hit_chance(ZUL_NPC_RANGED_ATT_ROLL, def_roll); + if (encounter_rand_float(&s->rng_state) < chance) { + did_hit = 1; + dmg = encounter_rand_int(&s->rng_state, ZUL_MAX_HIT + 1); + zul_apply_player_damage(s, dmg, ATTACK_STYLE_RANGED, &s->zulrah); + } + } + if (did_hit) zul_try_envenom(s); + zul_record_attack(s, s->zulrah.x, s->zulrah.y, + s->player.x, s->player.y, 0, dmg); +} + +/* magic attack (blue form, always accurate per wiki). + wiki: "ranged and magic attacks will envenom the player unless they miss, + even if blocked by a protection prayer." magic never misses → always envenoms. */ +static void zul_attack_magic(ZulrahState* s) { + int dmg = 0; + if (encounter_prayer_correct_for_style(s->player_prayer, ATTACK_STYLE_MAGIC)) { + s->prayer_blocked_this_tick = 1; + } else { + dmg = encounter_rand_int(&s->rng_state, ZUL_MAX_HIT + 1); + zul_apply_player_damage(s, dmg, ATTACK_STYLE_MAGIC, &s->zulrah); + } + /* magic always hits → always try envenom (even if prayer blocked damage) */ + zul_try_envenom(s); + zul_record_attack(s, s->zulrah.x, s->zulrah.y, + s->player.x, s->player.y, 1, dmg); +} + +/* blue/tanzanite form: random magic or ranged. wiki says magic more frequent. */ +static void zul_attack_magic_ranged(ZulrahState* s) { + if (encounter_rand_int(&s->rng_state, 4) < 3) { /* 75% magic, 25% ranged */ + zul_attack_magic(s); + } else { + zul_attack_ranged(s); + } +} + +/* pillar safespot: the east and west pillars block Zulrah's melee tail whip. + pillars at (15,10) east and (9,10) west. + safe tile is 2 tiles east/west + 1 tile north of each pillar: + east safespot: (17, 11) + west safespot: ( 7, 11) */ +static int zul_on_pillar_safespot(int px, int py) { + if (py != 11) return 0; + return (px == 17 || px == 7); +} + +/* red/magma melee: initiate stare at player's tile */ +static void zul_melee_start(ZulrahState* s) { + s->melee_target_x = s->player.x; + s->melee_target_y = s->player.y; + s->melee_pending = 1; + s->melee_stare_timer = ZUL_MELEE_STARE_TICKS; +} + +/* melee hit lands after stare completes. + wiki: "If the player does not move away from the targeted area in time, + they will be dealt 20-30 damage and be stunned for several seconds." + no accuracy roll — guaranteed hit if player is on the targeted tile. */ +static void zul_melee_hit(ZulrahState* s) { + s->melee_pending = 0; + int dmg = 0; + if (s->player.x == s->melee_target_x && s->player.y == s->melee_target_y + && !zul_on_pillar_safespot(s->player.x, s->player.y)) { + if (encounter_prayer_correct_for_style(s->player_prayer, ATTACK_STYLE_MELEE)) { + s->prayer_blocked_this_tick = 1; + } else { + dmg = 20 + encounter_rand_int(&s->rng_state, 11); /* 20-30 per wiki */ + zul_apply_player_damage(s, dmg, ATTACK_STYLE_MELEE, &s->zulrah); + s->player_stunned_ticks = ZUL_MELEE_STUN_TICKS; + } + } + zul_record_attack(s, s->zulrah.x, s->zulrah.y, + s->melee_target_x, s->melee_target_y, 2, dmg); +} + +/* jad phase: alternating ranged/magic */ +static void zul_attack_jad(ZulrahState* s) { + if (s->jad_is_magic_next) { + zul_attack_magic(s); + } else { + zul_attack_ranged(s); + } + s->jad_is_magic_next = !s->jad_is_magic_next; +} + +/* ======================================================================== */ +/* player attacks zulrah */ +/* ======================================================================== */ + +/* per-form defence bonuses — called from normal attacks and spec handler */ +static inline void zul_form_def_bonuses(ZulrahForm form, int* def_magic, int* def_ranged) { + switch (form) { + case ZUL_FORM_GREEN: *def_magic = ZUL_GREEN_DEF_MAGIC; *def_ranged = ZUL_GREEN_DEF_RANGED; break; + case ZUL_FORM_RED: *def_magic = ZUL_RED_DEF_MAGIC; *def_ranged = ZUL_RED_DEF_RANGED; break; + case ZUL_FORM_BLUE: *def_magic = ZUL_BLUE_DEF_MAGIC; *def_ranged = ZUL_BLUE_DEF_RANGED; break; + } +} + +static int zul_player_attack_hits(ZulrahState* s, int is_mage) { + const ZulGearTierStats* t = &ZUL_GEAR_TIERS[s->gear_tier]; + int eff_level = is_mage ? t->eff_mage_level : t->eff_range_level; + int att_bonus = is_mage ? t->mage_att_bonus : t->range_att_bonus; + int att_roll = eff_level * (att_bonus + 64); + /* crystal armor set bonus: +30% ranged accuracy with bowfa (tier 1 only) */ + if (!is_mage && s->gear_tier == 1) + att_roll = att_roll * 130 / 100; + + int def_magic = 0, def_ranged = 0; + zul_form_def_bonuses(s->current_form, &def_magic, &def_ranged); + /* apply eye of ayak magic defence drain (carries across forms) */ + if (is_mage) { + def_magic -= s->magic_def_drain; + if (def_magic < -64) def_magic = -64; /* can't go below -64 (makes def_roll 0) */ + } + int def_bonus = is_mage ? def_magic : def_ranged; + int def_roll = (ZUL_DEF_LEVEL + 8) * (def_bonus + 64); + if (def_roll < 0) def_roll = 0; + + /* confliction gauntlets: double accuracy roll on primed magic attacks (tier 2 only). + * primed = previous magic attack missed. eye of ayak is one-handed so effect applies. */ + if (is_mage && s->confliction_primed && s->gear_tier == 2) { + s->confliction_primed = 0; + return encounter_rand_float(&s->rng_state) < zul_hit_chance_double(att_roll, def_roll); + } + + return encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll); +} + +static void zul_player_attack(ZulrahState* s, int is_mage) { + if (!s->zulrah_visible || s->is_diving) return; + if (s->player_attack_timer > 0) return; + if (s->player_stunned_ticks > 0) return; + + int gear_ok = (is_mage && s->player_gear == ZUL_GEAR_MAGE) || + (!is_mage && s->player_gear == ZUL_GEAR_RANGE); + const ZulGearTierStats* t = &ZUL_GEAR_TIERS[s->gear_tier]; + s->player_attack_timer = is_mage ? 4 : t->range_speed; + if (!gear_ok) return; + + int max_hit = is_mage ? t->mage_max_hit : t->range_max_hit; + int dmg = 0; + int hit = zul_player_attack_hits(s, is_mage); + if (hit) { + dmg = encounter_rand_int(&s->rng_state, max_hit + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, &s->damage_dealt_this_tick); + s->total_damage_dealt += dmg; + /* sang staff passive (tier 1 mage): 1/6 chance to heal 50% of damage dealt */ + if (is_mage && s->gear_tier == 1 && dmg > 0 && encounter_rand_int(&s->rng_state, 6) == 0) { + int heal = dmg / 2; + s->player.current_hitpoints += heal; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } + } + /* confliction gauntlets: prime on magic miss, clear on magic hit */ + if (is_mage && s->gear_tier == 2) { + s->confliction_primed = !hit; + } + s->player.just_attacked = 1; + s->player.last_attack_style = is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + s->player.attack_style_this_tick = is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + + /* visual: hit splat + HP bar on Zulrah */ + s->zulrah.hit_landed_this_tick = 1; + s->zulrah.hit_damage = dmg; + s->zulrah.hit_was_successful = (dmg > 0); +} + +/* special attack: weapon-dependent, all cost 50% spec energy. + tier 0: MSB(i) "Snapshot" — 2 arrows, ~43% accuracy boost, ranged only. + tier 1: blowpipe — 1 hit, heals 50% of damage dealt, ranged only. + tier 2: eye of ayak "Soul Rend" — 5-tick mage attack, 2x accuracy, 1.3x max hit, + drains target magic defence by damage dealt. mage gear only. */ +static void zul_player_spec(ZulrahState* s) { + if (!s->zulrah_visible || s->is_diving) return; + if (s->player_attack_timer > 0) return; + if (s->player_stunned_ticks > 0) return; + if (s->player_special_energy < ZUL_SPEC_COST) return; + + /* tier 2 specs from mage gear (eye of ayak), tier 0-1 from ranged gear */ + if (s->gear_tier == 2) { + if (s->player_gear != ZUL_GEAR_MAGE) return; + } else { + if (s->player_gear != ZUL_GEAR_RANGE) return; + } + + s->player_special_energy -= ZUL_SPEC_COST; + s->player.just_attacked = 1; + s->player.used_special_this_tick = 1; + + const ZulGearTierStats* t = &ZUL_GEAR_TIERS[s->gear_tier]; + int total_dmg = 0; + + if (s->gear_tier == 0) { + /* MSB(i) Snapshot: 2 arrows, 10/7 accuracy boost. + max hit = floor(0.5 + (visible_level + 10) * (ammo_str + 64) / 640) + with amethyst arrows (str 55): floor(0.5 + 109*119/640) = 20 */ + s->player.last_attack_style = ATTACK_STYLE_RANGED; + s->player.attack_style_this_tick = ATTACK_STYLE_RANGED; + s->player_attack_timer = 3; + + int _dm1 = 0, def_ranged = 0; + zul_form_def_bonuses(s->current_form, &_dm1, &def_ranged); + int def_roll = (ZUL_DEF_LEVEL + 8) * (def_ranged + 64); + if (def_roll < 0) def_roll = 0; + + int msb_max_hit = (int)(0.5f + (float)(99 + 10) * (55 + 64) / 640.0f); + int att_roll_base = t->eff_range_level * (t->range_att_bonus + 64); + int att_roll_spec = att_roll_base * 10 / 7; + + for (int arrow = 0; arrow < 2; arrow++) { + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll_spec, def_roll)) { + int dmg = encounter_rand_int(&s->rng_state, msb_max_hit + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, NULL); + total_dmg += dmg; + } + } + } else if (s->gear_tier == 1) { + /* blowpipe spec: 1 hit, heals 50% of damage dealt */ + s->player.last_attack_style = ATTACK_STYLE_RANGED; + s->player.attack_style_this_tick = ATTACK_STYLE_RANGED; + s->player_attack_timer = 3; + + int _dm2 = 0, def_ranged = 0; + zul_form_def_bonuses(s->current_form, &_dm2, &def_ranged); + int def_roll = (ZUL_DEF_LEVEL + 8) * (def_ranged + 64); + if (def_roll < 0) def_roll = 0; + + int att_roll = t->eff_range_level * (t->bp_att_bonus + 64); + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll)) { + int dmg = encounter_rand_int(&s->rng_state, t->bp_max_hit + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, NULL); + total_dmg = dmg; + int heal = dmg * ZUL_SPEC_HEAL_PCT / 100; + s->player.current_hitpoints += heal; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } + } else { + /* eye of ayak Soul Rend: 5-tick mage attack, 2x accuracy, 1.3x max hit. + on hit: drain target magic defence by damage dealt (carries across forms). */ + s->player.last_attack_style = ATTACK_STYLE_MAGIC; + s->player.attack_style_this_tick = ATTACK_STYLE_MAGIC; + s->player_attack_timer = 5; /* slower than normal 3-tick */ + + int def_magic = 0, _dr3 = 0; + zul_form_def_bonuses(s->current_form, &def_magic, &_dr3); + def_magic -= s->magic_def_drain; + if (def_magic < -64) def_magic = -64; + int def_roll = (ZUL_DEF_LEVEL + 8) * (def_magic + 64); + if (def_roll < 0) def_roll = 0; + + int att_roll = t->eff_mage_level * (t->mage_att_bonus + 64) * 2; /* 2x accuracy */ + int spec_max_hit = t->mage_max_hit * 130 / 100; /* 1.3x max hit */ + + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(att_roll, def_roll)) { + int dmg = encounter_rand_int(&s->rng_state, spec_max_hit + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, NULL); + total_dmg = dmg; + /* drain target magic defence by damage dealt */ + s->magic_def_drain += dmg; + } + } + + s->damage_dealt_this_tick += total_dmg; + s->total_damage_dealt += total_dmg; + s->zulrah.hit_landed_this_tick = 1; + s->zulrah.hit_damage = total_dmg; + s->zulrah.hit_was_successful = (total_dmg > 0); +} + +/* ======================================================================== */ +/* snakelings */ +/* ======================================================================== */ + +/* pick a walkable spawn position for a snakeling, falling back to player's tile */ +static void zul_pick_snakeling_pos(ZulrahState* s, int* ox, int* oy) { + /* try predefined positions in random order */ + int order[ZUL_NUM_SNAKELING_POSITIONS]; + for (int i = 0; i < ZUL_NUM_SNAKELING_POSITIONS; i++) order[i] = i; + encounter_shuffle(order, ZUL_NUM_SNAKELING_POSITIONS, &s->rng_state); + for (int i = 0; i < ZUL_NUM_SNAKELING_POSITIONS; i++) { + int px = ZUL_SNAKELING_POSITIONS[order[i]][0]; + int py = ZUL_SNAKELING_POSITIONS[order[i]][1]; + if (zul_on_platform(s, px, py) && + !(px == s->player.x && py == s->player.y)) { + *ox = px; *oy = py; return; + } + } + /* fallback: spawn near player */ + *ox = s->player.x; + *oy = s->player.y; +} + +static void zul_spawn_snakeling(ZulrahState* s) { + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { + if (s->snakelings[i].active) continue; + ZulrahSnakeling* sn = &s->snakelings[i]; + memset(sn, 0, sizeof(ZulrahSnakeling)); + sn->active = 1; + sn->entity.entity_type = ENTITY_NPC; + sn->entity.npc_size = 1; + sn->entity.npc_visible = 1; + sn->is_magic = encounter_rand_int(&s->rng_state, 2); + sn->entity.npc_def_id = sn->is_magic ? 2046 : 2045; + sn->entity.npc_anim_id = SNAKELING_ANIM_IDLE; + zul_pick_snakeling_pos(s, &sn->entity.x, &sn->entity.y); + sn->entity.current_hitpoints = ZUL_SNAKELING_HP; + sn->entity.base_hitpoints = ZUL_SNAKELING_HP; + sn->attack_timer = ZUL_SNAKELING_SPEED; + sn->lifespan = ZUL_SNAKELING_LIFESPAN; + + /* emit spawn orb projectile event (style=4) */ + if (s->attack_event_count < 8) { + int ei = s->attack_event_count++; + s->attack_events[ei].src_x = s->zulrah.x; + s->attack_events[ei].src_y = s->zulrah.y; + s->attack_events[ei].dst_x = sn->entity.x; + s->attack_events[ei].dst_y = sn->entity.y; + s->attack_events[ei].style = 4; /* snakeling spawn orb */ + s->attack_events[ei].damage = 0; + } + return; + } +} + +static void zul_snakeling_tick(ZulrahState* s) { + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { + ZulrahSnakeling* sn = &s->snakelings[i]; + if (!sn->active) continue; + + /* lifespan: die after ~40 seconds */ + sn->lifespan--; + if (sn->lifespan <= 0) { sn->active = 0; continue; } + + /* move toward player — stop when within attack range (Chebyshev ≤ 1). + NPCs use greedy single-step movement, not full BFS. */ + int adx = abs_int(sn->entity.x - s->player.x); + int ady = abs_int(sn->entity.y - s->player.y); + int in_range = (adx <= 1 && ady <= 1); + int moved = 0; + if (!in_range) { + PathResult pr = zul_pathfind(s, sn->entity.x, sn->entity.y, + s->player.x, s->player.y); + if (pr.found && (pr.next_dx != 0 || pr.next_dy != 0)) { + int nx = sn->entity.x + pr.next_dx; + int ny = sn->entity.y + pr.next_dy; + if (zul_on_platform(s, nx, ny)) { + sn->entity.x = nx; sn->entity.y = ny; moved = 1; + } + } + } + sn->entity.npc_anim_id = moved ? SNAKELING_ANIM_WALK : SNAKELING_ANIM_IDLE; + + /* attack — recheck range after movement */ + if (sn->attack_timer > 0) { sn->attack_timer--; continue; } + adx = abs_int(sn->entity.x - s->player.x); + ady = abs_int(sn->entity.y - s->player.y); + if (adx > 1 || ady > 1) continue; + + sn->attack_timer = ZUL_SNAKELING_SPEED; + sn->entity.npc_anim_id = sn->is_magic ? SNAKELING_ANIM_MAGIC : SNAKELING_ANIM_MELEE; + AttackStyle sn_style = sn->is_magic ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_MELEE; + if (encounter_prayer_correct_for_style(s->player_prayer, sn_style)) { + s->prayer_blocked_this_tick = 1; continue; + } + int sn_max = sn->is_magic ? ZUL_SNAKELING_MAGIC_MAX_HIT : ZUL_SNAKELING_MELEE_MAX_HIT; + int dmg = encounter_rand_int(&s->rng_state, sn_max + 1); + AttackStyle st = sn->is_magic ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_MELEE; + zul_apply_player_damage(s, dmg, st, &sn->entity); + + /* recoil may have killed the snakeling — check and deactivate */ + if (sn->entity.current_hitpoints <= 0) { + sn->entity.current_hitpoints = 0; + sn->active = 0; + } + } +} + +/* ======================================================================== */ +/* clouds */ +/* ======================================================================== */ + +/* forward: needed by zul_spawn_cloud to get current phase's safe tiles */ +static const ZulRotationPhase* zul_current_phase(ZulrahState* s) { + return &ZUL_ROTATIONS[s->rotation_index][s->phase_index]; +} + +/* check if a 3x3 cloud area starting at (x,y) is fully within walkable arena. + all 9 tiles must be walkable so the cloud doesn't extend outside the platform. */ +static int zul_cloud_fits(ZulrahState* s, int x, int y) { + for (int dx = 0; dx < ZUL_CLOUD_SIZE; dx++) { + for (int dy = 0; dy < ZUL_CLOUD_SIZE; dy++) { + if (!zul_on_platform(s, x + dx, y + dy)) return 0; + } + } + return 1; +} + +/* pick a valid cloud position: walkable 3x3, not safe, not overlapping. + * TODO: cloud landing positions are currently random within the walkable area. + * in the real game, zulrah targets specific positions based on the current phase + * and player location. need to reverse-engineer the actual targeting formula + * (possibly from runelite source or game observation). */ +static int zul_pick_cloud_pos(ZulrahState* s, int stand, int stall, int* ox, int* oy) { + int attempts = 0; + while (attempts++ < 100) { + int x = ZUL_PLATFORM_MIN + encounter_rand_int(&s->rng_state, ZUL_PLATFORM_MAX - ZUL_PLATFORM_MIN + 1); + int y = ZUL_PLATFORM_MIN + encounter_rand_int(&s->rng_state, ZUL_PLATFORM_MAX - ZUL_PLATFORM_MIN + 1); + + if (!zul_cloud_fits(s, x, y)) continue; + if (zul_tile_is_safe(x, y, stand, stall)) continue; + + /* check 3x3 bounding box overlap with active and pending clouds. + two 3x3 clouds overlap if their anchor tiles are within 2 in each axis. */ + int overlap = 0; + for (int j = 0; j < ZUL_MAX_CLOUDS && !overlap; j++) { + if (s->clouds[j].active && + abs(s->clouds[j].x - x) < ZUL_CLOUD_SIZE && + abs(s->clouds[j].y - y) < ZUL_CLOUD_SIZE) + overlap = 1; + } + for (int j = 0; j < ZUL_MAX_PENDING_CLOUDS && !overlap; j++) { + if (s->pending_clouds[j].delay > 0 && + abs(s->pending_clouds[j].x - x) < ZUL_CLOUD_SIZE && + abs(s->pending_clouds[j].y - y) < ZUL_CLOUD_SIZE) + overlap = 1; + } + if (overlap) continue; + + *ox = x; *oy = y; + return 1; + } + return 0; +} + +/* queue a pending cloud with a flight delay */ +static void zul_queue_pending_cloud(ZulrahState* s, int x, int y, int delay) { + for (int i = 0; i < ZUL_MAX_PENDING_CLOUDS; i++) { + if (s->pending_clouds[i].delay <= 0) { + s->pending_clouds[i].x = x; + s->pending_clouds[i].y = y; + s->pending_clouds[i].delay = delay; + return; + } + } +} + +/* activate a pending cloud into the first free cloud slot */ +static void zul_activate_cloud(ZulrahState* s, int x, int y) { + for (int i = 0; i < ZUL_MAX_CLOUDS; i++) { + if (!s->clouds[i].active) { + s->clouds[i].x = x; + s->clouds[i].y = y; + s->clouds[i].active = 1; + s->clouds[i].ticks_remaining = ZUL_CLOUD_DURATION; + return; + } + } + /* all slots full — cloud doesn't spawn (observed 7-cloud cap) */ +} + +/* emit a cloud projectile event for the renderer */ +static void zul_emit_cloud_event(ZulrahState* s, int dst_x, int dst_y, int flight_ticks) { + if (s->cloud_event_count >= 4) return; + int i = s->cloud_event_count++; + s->cloud_events[i].src_x = s->zulrah.x; + s->cloud_events[i].src_y = s->zulrah.y; + s->cloud_events[i].dst_x = dst_x; + s->cloud_events[i].dst_y = dst_y; + s->cloud_events[i].flight_ticks = flight_ticks; +} + +/* spit: pick 2 positions now, queue them with staggered flight times */ +static void zul_spawn_cloud(ZulrahState* s) { + const ZulRotationPhase* phase = zul_current_phase(s); + int stand = phase->stand; + int stall = phase->stall; + int x, y; + if (zul_pick_cloud_pos(s, stand, stall, &x, &y)) { + zul_queue_pending_cloud(s, x, y, ZUL_CLOUD_FLIGHT_1); + zul_emit_cloud_event(s, x, y, ZUL_CLOUD_FLIGHT_1); + } + if (zul_pick_cloud_pos(s, stand, stall, &x, &y)) { + zul_queue_pending_cloud(s, x, y, ZUL_CLOUD_FLIGHT_2); + zul_emit_cloud_event(s, x, y, ZUL_CLOUD_FLIGHT_2); + } +} + +/* tick pending clouds: decrement delay, activate when ready */ +static void zul_pending_cloud_tick(ZulrahState* s) { + for (int i = 0; i < ZUL_MAX_PENDING_CLOUDS; i++) { + if (s->pending_clouds[i].delay <= 0) continue; + s->pending_clouds[i].delay--; + if (s->pending_clouds[i].delay <= 0) { + zul_activate_cloud(s, s->pending_clouds[i].x, s->pending_clouds[i].y); + } + } +} + +static void zul_cloud_tick(ZulrahState* s) { + for (int i = 0; i < ZUL_MAX_CLOUDS; i++) { + if (!s->clouds[i].active) continue; + s->clouds[i].ticks_remaining--; + if (s->clouds[i].ticks_remaining <= 0) { s->clouds[i].active = 0; continue; } + + /* wiki: "varying damage per tick" if player in 3x3 area */ + if (zul_player_in_cloud(s->clouds[i].x, s->clouds[i].y, + s->player.x, s->player.y)) { + int dmg = ZUL_CLOUD_DAMAGE_MIN + + encounter_rand_int(&s->rng_state, ZUL_CLOUD_DAMAGE_MAX - ZUL_CLOUD_DAMAGE_MIN + 1); + zul_apply_player_damage(s, dmg, ATTACK_STYLE_MAGIC, NULL); + } + } +} + +/* ======================================================================== */ +/* venom */ +/* ======================================================================== */ + +static void zul_venom_tick(ZulrahState* s) { + /* antivenom timer ticks down */ + if (s->antivenom_timer > 0) s->antivenom_timer--; + + if (s->venom_counter == 0) return; + if (s->antivenom_timer > 0) return; /* immune while antivenom active */ + if (s->venom_timer > 0) { s->venom_timer--; return; } + int dmg = ZUL_VENOM_START + 2 * (s->venom_counter - 1); + if (dmg > ZUL_VENOM_MAX) dmg = ZUL_VENOM_MAX; + zul_apply_player_damage(s, dmg, ATTACK_STYLE_MAGIC, NULL); + s->venom_counter++; + s->venom_timer = ZUL_VENOM_INTERVAL; +} + +/* ======================================================================== */ +/* thrall: arceuus greater ghost */ +/* ======================================================================== */ + +static void zul_thrall_tick(ZulrahState* s) { + if (!s->thrall_active) { + /* resummon after cooldown */ + if (s->thrall_cooldown > 0) { s->thrall_cooldown--; return; } + s->thrall_active = 1; + s->thrall_duration_remaining = ZUL_THRALL_DURATION; + s->thrall_attack_timer = 1; /* attacks on next tick */ + return; + } + + s->thrall_duration_remaining--; + if (s->thrall_duration_remaining <= 0) { + /* despawn + cooldown before resummon */ + s->thrall_active = 0; + s->thrall_cooldown = ZUL_THRALL_COOLDOWN; + return; + } + + /* attack: always hits, ignores armour, only when zulrah is targetable */ + if (s->thrall_attack_timer > 0) { s->thrall_attack_timer--; return; } + s->thrall_attack_timer = ZUL_THRALL_SPEED; + + if (!s->zulrah_visible || s->is_diving) return; + + int dmg = encounter_rand_int(&s->rng_state, ZUL_THRALL_MAX_HIT + 1); + dmg = zul_cap_damage(s, dmg); + encounter_damage_player(&s->zulrah, dmg, &s->damage_dealt_this_tick); + s->total_damage_dealt += dmg; +} + +/* ======================================================================== */ +/* phase machine: execute current action in rotation table */ +/* ======================================================================== */ + +/* fire one instance of the current action (attack/cloud/snakeling) */ +static void zul_fire_action(ZulrahState* s, ZulActionType type) { + switch (type) { + case ZA_RANGED: zul_attack_ranged(s); break; + case ZA_MAGIC_RANGED: zul_attack_magic_ranged(s); break; + case ZA_MELEE: zul_melee_start(s); break; + case ZA_JAD_RM: + case ZA_JAD_MR: zul_attack_jad(s); break; + case ZA_CLOUDS: zul_spawn_cloud(s); break; + case ZA_SNAKELINGS: zul_spawn_snakeling(s); break; + case ZA_SNAKECLOUD_ALT: + if (s->action_progress % 2 == 0) zul_spawn_snakeling(s); + else zul_spawn_cloud(s); + break; + case ZA_CLOUDSNAKE_ALT: + if (s->action_progress % 2 == 0) zul_spawn_cloud(s); + else zul_spawn_snakeling(s); + break; + case ZA_END: break; + } +} + +/* get ticks between fires for an action type */ +static int zul_action_interval(ZulActionType type) { + switch (type) { + case ZA_RANGED: + case ZA_MAGIC_RANGED: + case ZA_JAD_RM: + case ZA_JAD_MR: return ZUL_ATTACK_SPEED; + case ZA_MELEE: return ZUL_MELEE_INTERVAL; + case ZA_CLOUDS: + case ZA_SNAKELINGS: + case ZA_SNAKECLOUD_ALT: + case ZA_CLOUDSNAKE_ALT: return ZUL_SPAWN_INTERVAL; + default: return 1; + } +} + +/* is this action type an attack (vs spawn)? */ +static int zul_action_is_attack(ZulActionType type) { + return type == ZA_RANGED || type == ZA_MAGIC_RANGED || type == ZA_MELEE || + type == ZA_JAD_RM || type == ZA_JAD_MR; +} + +/* compute total ticks needed for all actions in a phase */ +static int zul_phase_action_ticks(const ZulRotationPhase* phase) { + int total = 0; + for (int i = 0; i < ZUL_MAX_PHASE_ACTIONS; i++) { + if (phase->actions[i].type == ZA_END) break; + total += phase->actions[i].count * zul_action_interval((ZulActionType)phase->actions[i].type); + } + return total; +} + +/* start a new phase: set form, position, reset action tracking. + surface animation plays for first N ticks before actions begin. + initial action delay is computed so actions + dive fill the remaining window. */ +static void zul_enter_phase(ZulrahState* s) { + const ZulRotationPhase* phase = zul_current_phase(s); + s->current_form = (ZulrahForm)phase->form; + s->zulrah.npc_def_id = zul_form_npc_id(s->current_form); + s->zulrah.x = ZUL_POSITIONS[phase->position][0]; + s->zulrah.y = ZUL_POSITIONS[phase->position][1]; + s->zulrah_visible = 1; + s->zulrah.npc_visible = 1; + s->is_diving = 0; + + /* surface animation: initial rise is longer (3 ticks) than subsequent (2 ticks) */ + int is_initial = (s->phase_index == 0 && s->tick <= 1); + s->zulrah.npc_anim_id = is_initial ? ZULRAH_ANIM_SURFACE : ZULRAH_ANIM_RISE; + int surface_ticks = is_initial ? ZUL_SURFACE_TICKS_INITIAL : ZUL_SURFACE_TICKS; + s->surface_timer = surface_ticks; + + s->phase_timer = phase->phase_ticks; /* total phase duration incl. surface + dive */ + + /* compute initial action delay: fill the idle window between surface and first action. + available = phase_ticks - surface - dive - action_ticks. first action fires after delay. */ + int action_ticks = zul_phase_action_ticks(phase); + int available = phase->phase_ticks - surface_ticks - ZUL_DIVE_ANIM_TICKS - action_ticks; + int initial_delay = (available > 1) ? available : 1; + + s->action_index = 0; + s->action_progress = 0; + s->action_timer = initial_delay; + + /* jad init */ + ZulActionType first_type = (ZulActionType)phase->actions[0].type; + if (first_type == ZA_JAD_RM) s->jad_is_magic_next = 0; + else if (first_type == ZA_JAD_MR) s->jad_is_magic_next = 1; + + /* not attacking during surface animation */ + s->zulrah_attacking = 0; +} + +/* enter dive visual state — called when phase_timer reaches ZUL_DIVE_ANIM_TICKS. + Zulrah stays visible playing dig anim for these last ticks of the phase. */ +static void zul_enter_dive(ZulrahState* s) { + s->is_diving = 1; + s->zulrah_attacking = 0; + s->zulrah.npc_anim_id = ZULRAH_ANIM_DIVE; +} + +/* advance to next phase after dive completes */ +static void zul_next_phase(ZulrahState* s) { + int rot_len = ZUL_ROT_LENGTHS[s->rotation_index]; + s->phase_index++; + + if (s->phase_index >= rot_len) { + /* rotation complete — pick new random rotation, start from phase 1. + the last phase already did ranged+clouds which counts as phase 1 + of the next rotation, so we skip to phase 1 (index 1). */ + s->rotation_index = encounter_rand_int(&s->rng_state, ZUL_NUM_ROTATIONS); + s->phase_index = 1; /* skip cloud-only phase 1 since last phase covered it */ + } + + zul_enter_phase(s); +} + +/* tick the phase machine. + phase_timer is the single source of truth — covers surface + actions + dive. + timeline: [surface_timer ticks] [actions] [idle] [ZUL_DIVE_ANIM_TICKS dive] → next phase */ +static void zul_phase_tick(ZulrahState* s) { + if (!s->zulrah_visible) return; + + /* decrement phase timer every tick */ + if (s->phase_timer > 0) s->phase_timer--; + + /* phase complete — immediately enter next phase */ + if (s->phase_timer <= 0) { + s->zulrah_visible = 0; + s->zulrah.npc_visible = 0; + zul_next_phase(s); + return; + } + + /* dive animation: last N ticks of the phase */ + if (s->phase_timer <= ZUL_DIVE_ANIM_TICKS && !s->is_diving) { + zul_enter_dive(s); + } + if (s->is_diving) return; + + /* surface animation: first N ticks of the phase, no actions fire. + player CAN attack during this window (free hits). */ + if (s->surface_timer > 0) { + s->surface_timer--; + return; + } + + /* active period: process actions */ + const ZulRotationPhase* phase = zul_current_phase(s); + const ZulAction* act = &phase->actions[s->action_index]; + + /* end sentinel — all actions done, idle until dive kicks in */ + if (act->type == ZA_END) { + s->zulrah_attacking = 0; + return; + } + + /* wait for action timer */ + s->action_timer--; + if (s->action_timer > 0) return; + + /* fire the action */ + zul_fire_action(s, (ZulActionType)act->type); + s->action_progress++; + + /* check if this action segment is complete */ + if (s->action_progress >= act->count) { + s->action_index++; + s->action_progress = 0; + + /* check if next action exists */ + const ZulAction* next = &phase->actions[s->action_index]; + if (next->type == ZA_END) { + s->zulrah_attacking = 0; + return; + } + + /* set attacking flag */ + s->zulrah_attacking = zul_action_is_attack((ZulActionType)next->type); + + /* jad init for new action */ + if (next->type == ZA_JAD_RM) s->jad_is_magic_next = 0; + else if (next->type == ZA_JAD_MR) s->jad_is_magic_next = 1; + + s->action_timer = zul_action_interval((ZulActionType)next->type); + } else { + s->action_timer = zul_action_interval((ZulActionType)act->type); + } +} + +/* ======================================================================== */ +/* player action processing */ +/* ======================================================================== */ + +static void zul_process_movement(ZulrahState* s) { + if (s->player_dest_x < 0 || s->player_dest_y < 0) return; + if (s->player_stunned_ticks > 0) return; + + /* shared BFS click-to-move: runs (2 steps) when dest > 1 tile away */ + encounter_move_toward_dest(&s->player, &s->player_dest_x, &s->player_dest_y, + (const CollisionMap*)s->collision_map, s->world_offset_x, s->world_offset_y, + zul_tile_walkable, s, NULL, NULL); +} + +static void zul_process_prayer(ZulrahState* s, int p) { + encounter_apply_prayer_action(&s->player_prayer, p); + s->player.prayer = s->player_prayer; +} + +static void zul_process_food(ZulrahState* s, int a) { + if (a == 0 || s->player_food_timer > 0) return; + if (a == 1) { + /* shark */ + if (s->player_food_count <= 0) return; + s->player_food_count--; + s->player_food_timer = 3; + s->player.current_hitpoints += ZUL_FOOD_HEAL; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } else if (a == 2) { + /* karambwan */ + if (s->player_karambwan_count <= 0) return; + s->player_karambwan_count--; + s->player_food_timer = 3; + s->player.current_hitpoints += ZUL_KARAMBWAN_HEAL; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } +} + +static void zul_process_potion(ZulrahState* s, int a) { + if (a == 0 || s->player_potion_timer > 0) return; + if (a == 1) { + /* prayer potion */ + if (s->player_restore_doses <= 0) return; + s->player_restore_doses--; + s->player_potion_timer = 3; + s->player.current_prayer += ZUL_PRAYER_RESTORE; + if (s->player.current_prayer > s->player.base_prayer) + s->player.current_prayer = s->player.base_prayer; + } else if (a == 2) { + /* antivenom: cures venom + grants immunity */ + if (s->antivenom_doses <= 0) return; + s->antivenom_doses--; + s->player_potion_timer = 3; + s->venom_counter = 0; + s->venom_timer = 0; + s->antivenom_timer = ZUL_ANTIVENOM_DURATION; + } +} + +static void zul_process_gear(ZulrahState* s, int atk) { + if (atk == ZUL_ATK_MAGE && s->player_gear != ZUL_GEAR_MAGE) { + s->player_gear = ZUL_GEAR_MAGE; + encounter_apply_loadout(&s->player, ZUL_MAGE_LOADOUT[s->gear_tier], GEAR_MAGE); + } else if (atk == ZUL_ATK_RANGE && s->player_gear != ZUL_GEAR_RANGE) { + s->player_gear = ZUL_GEAR_RANGE; + encounter_apply_loadout(&s->player, ZUL_RANGE_LOADOUT[s->gear_tier], GEAR_RANGED); + } +} + + +/* ======================================================================== */ +/* observations */ +/* ======================================================================== */ + +static void zul_write_obs(EncounterState* state, float* obs) { + ZulrahState* s = (ZulrahState*)state; + memset(obs, 0, ZUL_NUM_OBS * sizeof(float)); + int i = 0; + + /* player (0-15) */ + obs[i++] = (float)s->player.current_hitpoints / s->player.base_hitpoints; + obs[i++] = (float)s->player.current_prayer / s->player.base_prayer; + obs[i++] = (float)s->player.x / ZUL_ARENA_SIZE; + obs[i++] = (float)s->player.y / ZUL_ARENA_SIZE; + obs[i++] = (float)s->player_attack_timer / 5.0f; + obs[i++] = (float)s->player_food_count / ZUL_PLAYER_FOOD; + obs[i++] = (float)s->player_karambwan_count / ZUL_PLAYER_KARAMBWAN; + obs[i++] = (float)s->player_restore_doses / ZUL_PLAYER_RESTORE_DOSES; + obs[i++] = (float)s->player_food_timer / 3.0f; + obs[i++] = (float)s->player_potion_timer / 3.0f; + obs[i++] = (s->player_gear == ZUL_GEAR_MAGE) ? 1.0f : 0.0f; + obs[i++] = (s->player_gear == ZUL_GEAR_RANGE) ? 1.0f : 0.0f; + obs[i++] = (s->player_prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + obs[i++] = (s->player_prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + obs[i++] = (s->player_prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + obs[i++] = (float)s->player_stunned_ticks / ZUL_MELEE_STUN_TICKS; + + /* zulrah (16-29) */ + obs[i++] = (float)s->zulrah.current_hitpoints / ZUL_BASE_HP; + obs[i++] = (float)s->zulrah.x / ZUL_ARENA_SIZE; + obs[i++] = (float)s->zulrah.y / ZUL_ARENA_SIZE; + obs[i++] = (s->current_form == ZUL_FORM_GREEN) ? 1.0f : 0.0f; + obs[i++] = (s->current_form == ZUL_FORM_RED) ? 1.0f : 0.0f; + obs[i++] = (s->current_form == ZUL_FORM_BLUE) ? 1.0f : 0.0f; + obs[i++] = s->zulrah_visible ? 1.0f : 0.0f; + obs[i++] = s->is_diving ? 1.0f : 0.0f; + obs[i++] = s->zulrah_attacking ? 1.0f : 0.0f; + obs[i++] = (float)s->action_timer / ZUL_ATTACK_SPEED; + obs[i++] = (float)encounter_dist_to_npc(s->player.x, s->player.y, s->zulrah.x, s->zulrah.y, ZUL_NPC_SIZE) / ZUL_ARENA_SIZE; + obs[i++] = (float)s->rotation_index / (ZUL_NUM_ROTATIONS - 1); + obs[i++] = (float)s->phase_index / 12.0f; + obs[i++] = (s->melee_pending) ? 1.0f : 0.0f; + + /* venom (30-31) */ + obs[i++] = (s->venom_counter > 0) ? 1.0f : 0.0f; + obs[i++] = (float)s->venom_timer / ZUL_VENOM_INTERVAL; + + /* clouds (32-52): 7 clouds * 3 */ + for (int c = 0; c < ZUL_MAX_CLOUDS; c++) { + obs[i++] = s->clouds[c].active ? (float)s->clouds[c].x / ZUL_ARENA_SIZE : 0.0f; + obs[i++] = s->clouds[c].active ? (float)s->clouds[c].y / ZUL_ARENA_SIZE : 0.0f; + obs[i++] = s->clouds[c].active ? 1.0f : 0.0f; + } + + /* snakelings (44-59): 4 * 4 */ + for (int n = 0; n < ZUL_MAX_SNAKELINGS; n++) { + ZulrahSnakeling* sn = &s->snakelings[n]; + obs[i++] = sn->active ? (float)sn->entity.x / ZUL_ARENA_SIZE : 0.0f; + obs[i++] = sn->active ? (float)sn->entity.y / ZUL_ARENA_SIZE : 0.0f; + obs[i++] = sn->active ? 1.0f : 0.0f; + obs[i++] = sn->active ? (float)chebyshev_distance( + s->player.x, s->player.y, sn->entity.x, sn->entity.y) / ZUL_ARENA_SIZE : 0.0f; + } + + /* meta (60-63) */ + obs[i++] = (float)s->tick / ZUL_MAX_TICKS; + obs[i++] = s->damage_dealt_this_tick / 50.0f; + obs[i++] = s->damage_received_this_tick / 50.0f; + obs[i++] = s->total_damage_dealt / ZUL_BASE_HP; + + /* new features (64-67) */ + obs[i++] = (float)s->player_special_energy / 100.0f; + obs[i++] = (s->antivenom_timer > 0) ? 1.0f : 0.0f; + obs[i++] = (float)s->antivenom_timer / ZUL_ANTIVENOM_DURATION; + obs[i++] = (float)s->gear_tier / (ZUL_NUM_GEAR_TIERS - 1); + + /* safe tile positions for this phase (68-71) */ + const ZulRotationPhase* phase = zul_current_phase(s); + if (phase->stand < ZUL_NUM_STAND_LOCATIONS) { + obs[i++] = (float)ZUL_STAND_COORDS[phase->stand][0] / ZUL_ARENA_SIZE; + obs[i++] = (float)ZUL_STAND_COORDS[phase->stand][1] / ZUL_ARENA_SIZE; + } else { + obs[i++] = 0.0f; obs[i++] = 0.0f; + } + if (phase->stall < ZUL_NUM_STAND_LOCATIONS) { + obs[i++] = (float)ZUL_STAND_COORDS[phase->stall][0] / ZUL_ARENA_SIZE; + obs[i++] = (float)ZUL_STAND_COORDS[phase->stall][1] / ZUL_ARENA_SIZE; + } else { + obs[i++] = 0.0f; obs[i++] = 0.0f; + } + + while (i < ZUL_NUM_OBS) obs[i++] = 0.0f; +} + +/* ======================================================================== */ +/* action masks */ +/* ======================================================================== */ + +static void zul_write_mask(EncounterState* state, float* mask) { + ZulrahState* s = (ZulrahState*)state; + for (int i = 0; i < ZUL_ACTION_MASK_SIZE; i++) mask[i] = 1.0f; + int off = 0; + + /* movement: 25-action system (idle + 8 walk + 16 run) */ + for (int m = 0; m < ZUL_MOVE_DIM; m++) { + if (m > 0) { + if (s->player_stunned_ticks > 0) { mask[off] = 0.0f; } + else { + int nx = s->player.x + ENCOUNTER_MOVE_TARGET_DX[m]; + int ny = s->player.y + ENCOUNTER_MOVE_TARGET_DY[m]; + if (!zul_on_platform(s, nx, ny)) mask[off] = 0.0f; + } + } + off++; + } + /* attack — can't attack while Zulrah is hidden or diving */ + for (int a = 0; a < ZUL_ATTACK_DIM; a++) { + if (a > 0 && (!s->zulrah_visible || s->is_diving || s->player_attack_timer > 0 || s->player_stunned_ticks > 0)) + mask[off] = 0.0f; + off++; + } + /* prayer: 0=no_change (always valid), 1=off (always valid), + 2-4=melee/ranged/magic (require prayer points) */ + for (int p = 0; p < ZUL_PRAYER_DIM; p++) { + if (p >= ENCOUNTER_PRAYER_MELEE && s->player.current_prayer <= 0) + mask[off] = 0.0f; + off++; + } + /* food (none=0, shark=1, karambwan=2) */ + off++; /* none always valid */ + /* shark: masked if no food, food timer active, or would overheal (HP > 79) */ + if (s->player_food_count <= 0 || s->player_food_timer > 0 || + s->player.current_hitpoints > s->player.base_hitpoints - ZUL_FOOD_HEAL) + mask[off] = 0.0f; + off++; + /* karambwan: masked if no karambwan, food timer active, or would overheal (HP > 81) */ + if (s->player_karambwan_count <= 0 || s->player_food_timer > 0 || + s->player.current_hitpoints > s->player.base_hitpoints - ZUL_KARAMBWAN_HEAL) + mask[off] = 0.0f; + off++; + /* potion (none=0, prayer_pot=1, antivenom=2) */ + off++; /* none always valid */ + /* prayer pot: masked if no doses, potion timer active, or prayer already full */ + if (s->player_restore_doses <= 0 || s->player_potion_timer > 0 || + s->player.current_prayer >= s->player.base_prayer) + mask[off] = 0.0f; + off++; + /* antivenom: masked if no doses, potion timer active, or already immune */ + if (s->antivenom_doses <= 0 || s->player_potion_timer > 0 || + s->antivenom_timer > 0) + mask[off] = 0.0f; + off++; + /* spec: only when in range gear with enough energy */ + off++; /* none always valid */ + if (s->player_special_energy < ZUL_SPEC_COST || s->player_gear != ZUL_GEAR_RANGE || + !s->zulrah_visible || s->is_diving || s->player_attack_timer > 0 || s->player_stunned_ticks > 0) + mask[off] = 0.0f; + off++; +} + +/* ======================================================================== */ +/* reward */ +/* ======================================================================== */ + +static float zul_compute_reward(ZulrahState* s) { + /* terminal: +1 kill, -1 death */ + if (s->episode_over) + return (s->winner == 0) ? 1.0f : -1.0f; + + /* per-tick shaping (small signals to bootstrap learning). + * rewards are clamped to [-1, 1] by the training backend, + * so keep individual components well under that. */ + float r = 0.0f; + + /* damage dealt + correct attack style bonus (green/red -> mage, blue -> range) */ + if (s->damage_dealt_this_tick > 0.0f) { + float norm_dmg = s->damage_dealt_this_tick / 50.0f; + r += 0.02f * norm_dmg; + int correct = (s->current_form == ZUL_FORM_BLUE && + s->player.attack_style_this_tick == ATTACK_STYLE_RANGED) || + ((s->current_form == ZUL_FORM_GREEN || + s->current_form == ZUL_FORM_RED) && + s->player.attack_style_this_tick == ATTACK_STYLE_MAGIC); + if (correct) r += 0.05f * norm_dmg; + } + + /* damage taken penalty */ + if (s->damage_received_this_tick > 0.0f) + r -= 0.01f * (s->damage_received_this_tick / 50.0f); + + return r; +} + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static EncounterState* zul_create(void) { + return (EncounterState*)calloc(1, sizeof(ZulrahState)); +} + +static void zul_destroy(EncounterState* state) { free(state); } + +static void zul_reset(EncounterState* state, uint32_t seed) { + ZulrahState* s = (ZulrahState*)state; + Log saved_log = s->log; + void* saved_cmap = s->collision_map; + int saved_wx = s->world_offset_x; + int saved_wy = s->world_offset_y; + int saved_tier = s->gear_tier; + uint32_t saved_rng = s->rng_state; + memset(s, 0, sizeof(ZulrahState)); + s->log = saved_log; + s->collision_map = saved_cmap; + s->world_offset_x = saved_wx; + s->world_offset_y = saved_wy; + s->gear_tier = saved_tier; + s->rng_state = encounter_resolve_seed(saved_rng, seed); + + /* player */ + s->player.entity_type = ENTITY_PLAYER; + memset(s->player.equipped, ITEM_NONE, NUM_GEAR_SLOTS); + s->player.base_hitpoints = ZUL_PLAYER_HP; + s->player.current_hitpoints = ZUL_PLAYER_HP; + s->player.base_prayer = ZUL_PLAYER_PRAYER; + s->player.current_prayer = ZUL_PLAYER_PRAYER; + s->player.x = ZUL_PLAYER_START_X; + s->player.y = ZUL_PLAYER_START_Y; + s->player_food_count = ZUL_PLAYER_FOOD; + s->player_karambwan_count = ZUL_PLAYER_KARAMBWAN; + s->player_restore_doses = ZUL_PLAYER_RESTORE_DOSES; + s->player_special_energy = 100; + s->antivenom_doses = ZUL_ANTIVENOM_DOSES; + /* thrall: tier 1+ only (budget gear doesn't have arceuus access) */ + if (s->gear_tier >= 1) { + s->thrall_active = 1; + s->thrall_duration_remaining = ZUL_THRALL_DURATION; + s->thrall_attack_timer = ZUL_THRALL_SPEED; + } + s->player_gear = ZUL_GEAR_MAGE; + encounter_apply_loadout(&s->player, ZUL_MAGE_LOADOUT[s->gear_tier], GEAR_MAGE); + zul_populate_player_inventory(&s->player, s->gear_tier); + s->player.recoil_charges = + zul_has_recoil_effect(&s->player) ? RECOIL_MAX_CHARGES : 0; + + /* zulrah */ + s->zulrah.entity_type = ENTITY_NPC; + s->zulrah.npc_def_id = 2042; + s->zulrah.npc_size = ZUL_NPC_SIZE; + s->zulrah.npc_anim_id = ZULRAH_ANIM_IDLE; + s->zulrah.base_hitpoints = ZUL_BASE_HP; + s->zulrah.current_hitpoints = ZUL_BASE_HP; + + /* pick random rotation, start at phase 0 (cloud-only intro) */ + s->rotation_index = encounter_rand_int(&s->rng_state, ZUL_NUM_ROTATIONS); + s->phase_index = 0; + + zul_enter_phase(s); + zul_sync_player_consumables(s); +} + +static void zul_step(EncounterState* state, const int* actions) { + ZulrahState* s = (ZulrahState*)state; + if (s->episode_over) return; + + s->reward = 0.0f; + s->damage_dealt_this_tick = 0.0f; + s->damage_received_this_tick = 0.0f; + s->prayer_blocked_this_tick = 0; + s->player.just_attacked = 0; + s->player.hit_landed_this_tick = 0; + s->player.attack_style_this_tick = ATTACK_STYLE_NONE; + s->player.used_special_this_tick = 0; + s->zulrah.hit_landed_this_tick = 0; + s->attack_event_count = 0; + s->cloud_event_count = 0; + /* default to idle anim — but don't overwrite dive/surface animations */ + if (s->zulrah_visible && !s->is_diving && s->surface_timer <= 0) + s->zulrah.npc_anim_id = ZULRAH_ANIM_IDLE; + s->tick++; + + /* timers */ + if (s->player_attack_timer > 0) s->player_attack_timer--; + if (s->player_food_timer > 0) s->player_food_timer--; + if (s->player_potion_timer > 0) s->player_potion_timer--; + if (s->player_stunned_ticks > 0) s->player_stunned_ticks--; + + /* pending melee hit */ + if (s->melee_pending) { + s->melee_stare_timer--; + if (s->melee_stare_timer <= 0) zul_melee_hit(s); + } + + /* player actions */ + zul_process_prayer(s, actions[ZUL_HEAD_PRAYER]); + zul_process_food(s, actions[ZUL_HEAD_FOOD]); + zul_process_potion(s, actions[ZUL_HEAD_POTION]); + zul_process_gear(s, actions[ZUL_HEAD_ATTACK]); + + /* set dest: explicit (human click or heuristic) takes priority, + then RL action offset, then idle (action 0) clears dest. */ + if (s->player_dest_explicit) { + s->player_dest_explicit = 0; + } else { + int m = actions[ZUL_HEAD_MOVE]; + if (m > 0 && m < ZUL_MOVE_DIM) { + s->player_dest_x = s->player.x + ENCOUNTER_MOVE_TARGET_DX[m]; + s->player_dest_y = s->player.y + ENCOUNTER_MOVE_TARGET_DY[m]; + } else { + /* idle: clear destination */ + s->player_dest_x = -1; + s->player_dest_y = -1; + } + } + zul_process_movement(s); + + /* spec takes priority over normal attack if requested */ + if (actions[ZUL_HEAD_SPEC] == 1) zul_player_spec(s); + else if (actions[ZUL_HEAD_ATTACK] == ZUL_ATK_MAGE) zul_player_attack(s, 1); + else if (actions[ZUL_HEAD_ATTACK] == ZUL_ATK_RANGE) zul_player_attack(s, 0); + + if (s->zulrah.current_hitpoints <= 0) { + s->episode_over = 1; s->winner = 0; + s->reward = zul_compute_reward(s); s->episode_return += s->reward; return; + } + + /* resolve pending cloud projectiles, then tick active clouds */ + zul_pending_cloud_tick(s); + zul_cloud_tick(s); + if (s->player.current_hitpoints <= 0) { + s->episode_over = 1; s->winner = 1; + s->reward = zul_compute_reward(s); s->episode_return += s->reward; return; + } + + /* phase machine */ + zul_phase_tick(s); + + /* snakelings */ + zul_snakeling_tick(s); + + /* thrall (arceuus greater ghost) */ + zul_thrall_tick(s); + + /* venom */ + zul_venom_tick(s); + + /* prayer drain (shared OSRS formula) */ + encounter_drain_prayer(&s->player.current_prayer, &s->player_prayer, 0, + &s->prayer_drain_counter, encounter_prayer_drain_effect(s->player_prayer)); + s->player.prayer = s->player_prayer; + + if (s->player.current_hitpoints <= 0) { + s->episode_over = 1; s->winner = 1; + s->reward = zul_compute_reward(s); s->episode_return += s->reward; return; + } + if (s->tick >= ZUL_MAX_TICKS) { + s->episode_over = 1; s->winner = 1; + s->reward = zul_compute_reward(s); s->episode_return += s->reward; return; + } + s->reward = zul_compute_reward(s); + s->episode_return += s->reward; + zul_sync_player_consumables(s); + +} + +/* ======================================================================== */ +/* heuristic policy (for visual debug + sanity checks) */ +/* ======================================================================== */ + +static void zul_heuristic_actions(ZulrahState* s, int* actions) { + /* zero all heads */ + for (int i = 0; i < ZUL_NUM_ACTION_HEADS; i++) actions[i] = 0; + + int hp = s->player.current_hitpoints; + + /* prayer: match form. GREEN=ranged, BLUE=magic, RED=melee */ + if (s->zulrah_visible && !s->is_diving) { + switch (s->current_form) { + case ZUL_FORM_GREEN: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_RANGED; break; + case ZUL_FORM_BLUE: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_MAGIC; break; + case ZUL_FORM_RED: actions[ZUL_HEAD_PRAYER] = ENCOUNTER_PRAYER_MELEE; break; + } + } + + /* antivenom on first tick or when timer about to expire */ + if (s->player_potion_timer <= 0 && s->antivenom_doses > 0 && + s->antivenom_timer <= 5 && (s->tick <= 1 || s->antivenom_timer <= 5)) { + actions[ZUL_HEAD_POTION] = 2; /* antivenom */ + return; /* potion consumes the tick */ + } + + /* eat shark at <60 HP (only if won't overheal) */ + if (hp < 60 && s->player_food_timer <= 0 && s->player_food_count > 0 && + hp <= s->player.base_hitpoints - ZUL_FOOD_HEAL) { + actions[ZUL_HEAD_FOOD] = 1; /* shark */ + } + /* karambwan combo eat at <40 HP (emergency) */ + else if (hp < 40 && s->player_food_timer <= 0 && s->player_karambwan_count > 0 && + hp <= s->player.base_hitpoints - ZUL_KARAMBWAN_HEAL) { + actions[ZUL_HEAD_FOOD] = 2; /* karambwan */ + } + + /* restore prayer if getting low (and not already full) */ + if (s->player.current_prayer < 30 && s->player_potion_timer <= 0 && + s->player_restore_doses > 0 && s->player.current_prayer < s->player.base_prayer) { + actions[ZUL_HEAD_POTION] = 1; /* prayer pot */ + } + + /* movement: set dest to current phase's safe spot. + zul_process_movement BFS-paths there, running when > 1 tile away. */ + { + const ZulRotationPhase* phase = zul_current_phase(s); + int stand = phase->stand; + if (stand < ZUL_NUM_STAND_LOCATIONS) { + int tx = ZUL_STAND_COORDS[stand][0]; + int ty = ZUL_STAND_COORDS[stand][1]; + if (tx != s->player.x || ty != s->player.y) { + s->player_dest_x = tx; + s->player_dest_y = ty; + s->player_dest_explicit = 1; + } + } + } + + /* attack: mage vs green/red (weak to magic), range vs blue (weak to range) */ + if (s->zulrah_visible && !s->is_diving && + s->player_attack_timer <= 0 && s->player_stunned_ticks <= 0) { + if (s->current_form == ZUL_FORM_BLUE) { + actions[ZUL_HEAD_ATTACK] = ZUL_ATK_RANGE; + /* use spec when in range gear with enough energy */ + if (s->player_special_energy >= ZUL_SPEC_COST) { + actions[ZUL_HEAD_SPEC] = 1; + } + } else { + actions[ZUL_HEAD_ATTACK] = ZUL_ATK_MAGE; + } + } +} + +/* ======================================================================== */ +/* RL interface */ +/* ======================================================================== */ + +static float zul_get_reward(EncounterState* state) { + return ((ZulrahState*)state)->reward; +} +static int zul_is_terminal(EncounterState* state) { + return ((ZulrahState*)state)->episode_over; +} + +/* entity access */ +static int zul_get_entity_count(EncounterState* state) { + ZulrahState* s = (ZulrahState*)state; + int n = 2; + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) + if (s->snakelings[i].active) n++; + return n; +} +static void* zul_get_entity(EncounterState* state, int index) { + ZulrahState* s = (ZulrahState*)state; + if (index == 0) return &s->player; + if (index == 1) return &s->zulrah; + int si = 0; + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) { + if (s->snakelings[i].active) { + if (si + 2 == index) return &s->snakelings[i].entity; + si++; + } + } + return &s->player; +} + +/* render entity population */ +static void zul_fill_render_entities(EncounterState* state, RenderEntity* out, int max_entities, int* count) { + ZulrahState* s = (ZulrahState*)state; + int n = 0; + if (n < max_entities) render_entity_from_player(&s->player, &out[n++]); + if (n < max_entities) render_entity_from_player(&s->zulrah, &out[n++]); + for (int i = 0; i < ZUL_MAX_SNAKELINGS && n < max_entities; i++) { + if (s->snakelings[i].active) { + render_entity_from_player(&s->snakelings[i].entity, &out[n++]); + } + } + *count = n; +} + +/* config */ +static void zul_put_int(EncounterState* state, const char* key, int value) { + ZulrahState* s = (ZulrahState*)state; + if (strcmp(key, "seed") == 0) s->rng_state = (uint32_t)value; + else if (strcmp(key, "world_offset_x") == 0) s->world_offset_x = value; + else if (strcmp(key, "world_offset_y") == 0) s->world_offset_y = value; + else if (strcmp(key, "gear_tier") == 0) { + if (value >= 0 && value < ZUL_NUM_GEAR_TIERS) s->gear_tier = value; + } + else if (strcmp(key, "player_dest_x") == 0) { s->player_dest_x = value; s->player_dest_explicit = 1; } + else if (strcmp(key, "player_dest_y") == 0) { s->player_dest_y = value; s->player_dest_explicit = 1; } +} +static void zul_put_float(EncounterState* st, const char* k, float v) { (void)st;(void)k;(void)v; } +static void zul_put_ptr(EncounterState* st, const char* k, void* v) { + ZulrahState* s = (ZulrahState*)st; + if (strcmp(k, "collision_map") == 0) s->collision_map = v; +} + +/* logging */ +static void* zul_get_log(EncounterState* state) { + ZulrahState* s = (ZulrahState*)state; + if (s->episode_over) { + s->log.episode_return += s->episode_return; + s->log.episode_length += (float)s->tick; + s->log.wins += (s->winner == 0) ? 1.0f : 0.0f; + s->log.damage_dealt += s->total_damage_dealt; + s->log.damage_received += s->total_damage_received; + s->log.n += 1.0f; + } + return &s->log; +} +static int zul_get_tick(EncounterState* state) { return ((ZulrahState*)state)->tick; } + +/* render overlay: expose clouds and Zulrah state to the renderer */ +static void zul_render_post_tick(EncounterState* state, EncounterOverlay* ov) { + ZulrahState* s = (ZulrahState*)state; + + /* clouds */ + ov->cloud_count = 0; + for (int i = 0; i < ZUL_MAX_CLOUDS && ov->cloud_count < ENCOUNTER_MAX_OVERLAY_TILES; i++) { + if (!s->clouds[i].active) continue; + ov->clouds[ov->cloud_count].x = s->clouds[i].x; + ov->clouds[ov->cloud_count].y = s->clouds[i].y; + ov->clouds[ov->cloud_count].active = 1; + ov->cloud_count++; + } + + /* boss state: zulrah.x/y is the SW anchor tile of the NxN footprint. + the 3D model renders centered on the footprint (x + size/2). + hitbox spans [x, x+size) in both axes. */ + ov->boss_x = s->zulrah.x; + ov->boss_y = s->zulrah.y; + ov->boss_visible = s->zulrah_visible; + ov->boss_form = (int)s->current_form; + ov->boss_size = ZUL_NPC_SIZE; + + /* snakelings */ + ov->snakeling_count = 0; + for (int i = 0; i < ZUL_MAX_SNAKELINGS && ov->snakeling_count < ENCOUNTER_MAX_OVERLAY_SNAKES; i++) { + if (!s->snakelings[i].active) continue; + int si = ov->snakeling_count++; + ov->snakelings[si].x = s->snakelings[i].entity.x; + ov->snakelings[si].y = s->snakelings[i].entity.y; + ov->snakelings[si].active = 1; + ov->snakelings[si].is_magic = s->snakelings[i].is_magic; + } + + /* melee targeting indicator */ + ov->melee_target_active = s->melee_pending; + ov->melee_target_x = s->melee_target_x; + ov->melee_target_y = s->melee_target_y; + + /* projectile events this tick (attacks + cloud spits). + zulrah is size 5: start_h = 5*0.75*128 = 480, end_h = 64 (player size 1) */ + ov->projectile_count = 0; + for (int i = 0; i < s->attack_event_count && ov->projectile_count < ENCOUNTER_MAX_OVERLAY_PROJECTILES; i++) { + if (s->attack_events[i].style == 4) { + /* snakeling spawn orb: flies to spawn point, no tracking */ + encounter_emit_projectile(ov, + s->attack_events[i].src_x, s->attack_events[i].src_y, + s->attack_events[i].dst_x, s->attack_events[i].dst_y, + 4, 0, + 40, 100, 0, 12, 0.0f, 0, ZUL_NPC_SIZE, 1, 0); + } else { + /* ranged/magic attack: tracks player, zulrah height → player height */ + uint32_t zul_proj_model = (s->attack_events[i].style == 0) + ? GFX_RANGED_PROJ_MODEL : GFX_MAGIC_PROJ_MODEL; + encounter_emit_projectile(ov, + s->attack_events[i].src_x, s->attack_events[i].src_y, + s->attack_events[i].dst_x, s->attack_events[i].dst_y, + s->attack_events[i].style, s->attack_events[i].damage, + 35, 480, 64, 16, 0.0f, 1, ZUL_NPC_SIZE, 1, zul_proj_model); + } + } + for (int i = 0; i < s->cloud_event_count && ov->projectile_count < ENCOUNTER_MAX_OVERLAY_PROJECTILES; i++) { + encounter_emit_projectile(ov, + s->cloud_events[i].src_x, s->cloud_events[i].src_y, + s->cloud_events[i].dst_x, s->cloud_events[i].dst_y, + 3, 0, /* style=cloud, damage=0 */ + /* duration from flight_ticks * 30, high arc start, ground end, + curve=10, arc_height=3.0 (high sinusoidal), no tracking, src_size=5 */ + s->cloud_events[i].flight_ticks * 30, 200, 0, 10, 3.0f, 0, ZUL_NPC_SIZE, 1, + GFX_CLOUD_PROJ_MODEL); + } +} +static int zul_get_winner(EncounterState* state) { return ((ZulrahState*)state)->winner; } + +/* ======================================================================== */ +/* human input translator */ +/* ======================================================================== */ + +static void zul_translate_human_input(HumanInput* hi, int* actions, EncounterState* state) { + for (int h = 0; h < ZUL_NUM_ACTION_HEADS; h++) actions[h] = 0; + + encounter_translate_movement(hi, actions, ZUL_HEAD_MOVE, + (void*(*)(void*,int))zul_get_entity, state); + encounter_translate_prayer(hi, actions, ZUL_HEAD_PRAYER); + + /* attack style: mage or range */ + if (hi->pending_attack) { + if (hi->pending_spell == ATTACK_ICE || hi->pending_spell == ATTACK_BLOOD) + actions[ZUL_HEAD_ATTACK] = 1; /* mage */ + else + actions[ZUL_HEAD_ATTACK] = 2; /* range */ + } + + /* food: shark on food head */ + if (hi->pending_food) actions[ZUL_HEAD_FOOD] = 1; + + /* potions: brew→food head, restore→potion 1, antivenom→potion 2 */ + if (hi->pending_potion == POTION_BREW) actions[ZUL_HEAD_FOOD] = 1; + else if (hi->pending_potion == POTION_RESTORE) actions[ZUL_HEAD_POTION] = 1; + else if (hi->pending_potion == POTION_ANTIVENOM) actions[ZUL_HEAD_POTION] = 2; + + /* spec */ + if (hi->pending_spec) actions[ZUL_HEAD_SPEC] = 1; + + (void)state; +} + +/* ======================================================================== */ +/* encounter definition */ +/* ======================================================================== */ + +static const EncounterDef ENCOUNTER_ZULRAH = { + .name = "zulrah", + .obs_size = ZUL_NUM_OBS, + .num_action_heads = ZUL_NUM_ACTION_HEADS, + .action_head_dims = ZUL_ACTION_HEAD_DIMS, + .mask_size = ZUL_ACTION_MASK_SIZE, + .create = zul_create, + .destroy = zul_destroy, + .reset = zul_reset, + .step = zul_step, + .write_obs = zul_write_obs, + .write_mask = zul_write_mask, + .get_reward = zul_get_reward, + .is_terminal = zul_is_terminal, + .get_entity_count = zul_get_entity_count, + .get_entity = zul_get_entity, + .fill_render_entities = zul_fill_render_entities, + .put_int = zul_put_int, + .put_float = zul_put_float, + .put_ptr = zul_put_ptr, + .arena_base_x = 0, + .arena_base_y = 0, + .arena_width = ZUL_ARENA_SIZE, + .arena_height = ZUL_ARENA_SIZE, + .render_post_tick = zul_render_post_tick, + .get_log = zul_get_log, + .get_tick = zul_get_tick, + .get_winner = zul_get_winner, + + .translate_human_input = zul_translate_human_input, + .head_move = ZUL_HEAD_MOVE, + .head_prayer = ZUL_HEAD_PRAYER, + .head_target = -1, +}; + +__attribute__((constructor)) +static void zul_register(void) { + encounter_register(&ENCOUNTER_ZULRAH); +} + +#endif /* ENCOUNTER_ZULRAH_H */ diff --git a/ocean/osrs/ocean_binding.c b/ocean/osrs/ocean_binding.c new file mode 100644 index 0000000000..34dde84ba7 --- /dev/null +++ b/ocean/osrs/ocean_binding.c @@ -0,0 +1,746 @@ +/** + * @file ocean_binding.c + * @brief PufferLib ocean-compatible C binding for OSRS PvP environment + * + * Replaces the legacy binding.c with PufferLib's native env_binding.h + * template. All N environments are managed in C with a single vec_step + * call per batch, eliminating per-env Python overhead. + */ + +#define PY_SSIZE_T_CLEAN +#include + +#include "osrs_pvp.h" +#include "osrs_encounter.h" +#include "encounters/encounter_nh_pvp.h" +#include "encounters/encounter_zulrah.h" + +#define Env OsrsPvp + +/* shared collision map: loaded once, read-only, shared across all envs */ +static CollisionMap* g_collision_map = NULL; + +static void dict_set_int(PyObject* dict, const char* key, int value) { + PyObject* obj = PyLong_FromLong(value); + PyDict_SetItemString(dict, key, obj); + Py_DECREF(obj); +} + +static void dict_set_bool(PyObject* dict, const char* key, int value) { + PyObject* obj = PyBool_FromLong(value ? 1 : 0); + PyDict_SetItemString(dict, key, obj); + Py_DECREF(obj); +} + +static PyObject* build_inventory_list(Player* p, int slot) { + int count = p->num_items_in_slot[slot]; + if (count < 0) count = 0; + if (count > MAX_ITEMS_PER_SLOT) count = MAX_ITEMS_PER_SLOT; + PyObject* list = PyList_New(count); + for (int i = 0; i < count; i++) { + PyList_SetItem(list, i, PyLong_FromLong(p->inventory[slot][i])); + } + return list; +} + +static PyObject* build_obs_with_mask(Env* env) { + ensure_obs_norm_initialized(); + PyObject* obs_list = PyList_New(NUM_AGENTS); + + for (int agent = 0; agent < NUM_AGENTS; agent++) { + PyObject* agent_obs = PyList_New(OCEAN_OBS_SIZE); + float* obs = env->observations + agent * SLOT_NUM_OBSERVATIONS; + unsigned char* mask = env->action_masks + agent * ACTION_MASK_SIZE; + + for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) { + PyList_SetItem(agent_obs, i, PyFloat_FromDouble((double)(obs[i] / OBS_NORM_DIVISORS[i]))); + } + for (int i = 0; i < ACTION_MASK_SIZE; i++) { + PyList_SetItem(agent_obs, SLOT_NUM_OBSERVATIONS + i, PyFloat_FromDouble((double)mask[i])); + } + PyList_SetItem(obs_list, agent, agent_obs); + } + + return obs_list; +} + +static PyObject* build_pending_actions(Env* env) { + PyObject* actions = PyList_New(NUM_AGENTS); + for (int agent = 0; agent < NUM_AGENTS; agent++) { + PyObject* agent_actions = PyList_New(NUM_ACTION_HEADS); + int* src = env->pending_actions + agent * NUM_ACTION_HEADS; + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + PyList_SetItem(agent_actions, i, PyLong_FromLong(src[i])); + } + PyList_SetItem(actions, agent, agent_actions); + } + return actions; +} + +static PyObject* build_executed_actions(Env* env) { + PyObject* actions = PyList_New(NUM_AGENTS); + for (int agent = 0; agent < NUM_AGENTS; agent++) { + PyObject* agent_actions = PyList_New(NUM_ACTION_HEADS); + int* src = env->last_executed_actions + agent * NUM_ACTION_HEADS; + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + PyList_SetItem(agent_actions, i, PyLong_FromLong(src[i])); + } + PyList_SetItem(actions, agent, agent_actions); + } + return actions; +} + +static PyObject* build_player_state(Player* p) { + PyObject* dict = PyDict_New(); + + dict_set_int(dict, "x", p->x); + dict_set_int(dict, "y", p->y); + dict_set_int(dict, "hp", p->current_hitpoints); + dict_set_int(dict, "max_hp", p->base_hitpoints); + dict_set_int(dict, "prayer", p->current_prayer); + dict_set_int(dict, "max_prayer", p->base_prayer); + dict_set_int(dict, "spec_energy", p->special_energy); + dict_set_int(dict, "frozen_ticks", p->frozen_ticks); + dict_set_int(dict, "freeze_immunity_ticks", p->freeze_immunity_ticks); + dict_set_int(dict, "gear", p->visible_gear); + dict_set_int(dict, "overhead_prayer", p->prayer); + dict_set_int(dict, "offensive_prayer", p->offensive_prayer); + dict_set_int(dict, "attack_timer", p->attack_timer); + dict_set_int(dict, "food_timer", p->food_timer); + dict_set_int(dict, "potion_timer", p->potion_timer); + dict_set_int(dict, "karambwan_timer", p->karambwan_timer); + dict_set_int(dict, "veng_active", p->veng_active); + dict_set_int(dict, "veng_cooldown", p->veng_cooldown); + dict_set_int(dict, "food_count", p->food_count); + dict_set_int(dict, "karambwan_count", p->karambwan_count); + dict_set_int(dict, "brew_doses", p->brew_doses); + dict_set_int(dict, "restore_doses", p->restore_doses); + dict_set_int(dict, "ranged_potion_doses", p->ranged_potion_doses); + dict_set_int(dict, "combat_potion_doses", p->combat_potion_doses); + dict_set_int(dict, "damage_this_tick", p->damage_applied_this_tick); + dict_set_int(dict, "last_attack_style", p->last_attack_style); + dict_set_int(dict, "just_attacked", p->just_attacked); + dict_set_int(dict, "attack_was_on_prayer", p->attack_was_on_prayer); + dict_set_int(dict, "last_queued_hit_damage", p->last_queued_hit_damage); + + dict_set_int(dict, "hit_landed_this_tick", p->hit_landed_this_tick); + dict_set_int(dict, "hit_attacker_idx", p->hit_attacker_idx); + dict_set_int(dict, "hit_style", p->hit_style); + dict_set_int(dict, "hit_damage", p->hit_damage); + dict_set_int(dict, "hit_was_successful", p->hit_was_successful); + dict_set_int(dict, "hit_was_on_prayer", p->hit_was_on_prayer); + dict_set_int(dict, "freeze_applied_this_tick", p->freeze_applied_this_tick); + + dict_set_int(dict, "weapon", p->equipped[GEAR_SLOT_WEAPON]); + dict_set_int(dict, "helm", p->equipped[GEAR_SLOT_HEAD]); + dict_set_int(dict, "body", p->equipped[GEAR_SLOT_BODY]); + dict_set_int(dict, "legs", p->equipped[GEAR_SLOT_LEGS]); + dict_set_int(dict, "shield", p->equipped[GEAR_SLOT_SHIELD]); + dict_set_int(dict, "cape", p->equipped[GEAR_SLOT_CAPE]); + dict_set_int(dict, "neck", p->equipped[GEAR_SLOT_NECK]); + dict_set_int(dict, "ring", p->equipped[GEAR_SLOT_RING]); + dict_set_int(dict, "feet", p->equipped[GEAR_SLOT_FEET]); + + // Export inventory for all dynamic gear slots + static const int EXPORT_SLOTS[] = { + GEAR_SLOT_WEAPON, GEAR_SLOT_SHIELD, GEAR_SLOT_BODY, GEAR_SLOT_LEGS, + GEAR_SLOT_HEAD, GEAR_SLOT_CAPE, GEAR_SLOT_NECK, GEAR_SLOT_RING + }; + static const char* EXPORT_NAMES[] = { + "weapon_inventory", "shield_inventory", "body_inventory", "legs_inventory", + "head_inventory", "cape_inventory", "neck_inventory", "ring_inventory" + }; + for (int i = 0; i < 8; i++) { + PyObject* inv = build_inventory_list(p, EXPORT_SLOTS[i]); + PyDict_SetItemString(dict, EXPORT_NAMES[i], inv); + Py_DECREF(inv); + } + + dict_set_int(dict, "base_attack", p->base_attack); + dict_set_int(dict, "base_strength", p->base_strength); + dict_set_int(dict, "base_defence", p->base_defence); + dict_set_int(dict, "base_ranged", p->base_ranged); + dict_set_int(dict, "base_magic", p->base_magic); + dict_set_int(dict, "current_attack", p->current_attack); + dict_set_int(dict, "current_strength", p->current_strength); + dict_set_int(dict, "current_defence", p->current_defence); + dict_set_int(dict, "current_ranged", p->current_ranged); + dict_set_int(dict, "current_magic", p->current_magic); + + return dict; +} + +static PyObject* my_get(PyObject* dict, Env* env) { + dict_set_int(dict, "tick", env->tick); + dict_set_bool(dict, "episode_over", env->episode_over); + dict_set_int(dict, "winner", env->winner); + dict_set_int(dict, "pid_holder", env->pid_holder); + dict_set_bool(dict, "opponent_read_this_tick", env->opponent.has_read_this_tick); + dict_set_int(dict, "opponent_read_style", env->opponent.read_agent_style); + + PyObject* p0 = build_player_state(&env->players[0]); + PyObject* p1 = build_player_state(&env->players[1]); + PyDict_SetItemString(dict, "player0", p0); + PyDict_SetItemString(dict, "player1", p1); + Py_DECREF(p0); + Py_DECREF(p1); + + PyObject* obs_with_mask = build_obs_with_mask(env); + PyDict_SetItemString(dict, "obs_with_mask", obs_with_mask); + Py_DECREF(obs_with_mask); + + PyObject* pending_actions = build_pending_actions(env); + PyDict_SetItemString(dict, "pending_actions", pending_actions); + Py_DECREF(pending_actions); + + PyObject* executed_actions = build_executed_actions(env); + PyDict_SetItemString(dict, "executed_actions", executed_actions); + Py_DECREF(executed_actions); + + return dict; +} + +/* ── reward shaping annealing ───────────────────────────────────────────── + * MY_PUT: enables env_put(handle, shaping_scale=X) for single-env updates + * MY_METHODS: registers vec_set_shaping_scale for batch updates from Python + * + * vec_set_shaping_scale is defined before env_binding.h because MY_METHODS + * is expanded inside the method table (which lives inside the include). + * we inline a local VecEnv typedef since the real one isn't available yet. + */ + +#define MY_PUT +#define MY_GET + +static int my_put(Env* env, PyObject* args, PyObject* kwargs) { + (void)args; + PyObject* val = PyDict_GetItemString(kwargs, "shaping_scale"); + if (val) { + env->shaping.shaping_scale = (float)PyFloat_AsDouble(val); + } + PyObject* use_c_opponent = PyDict_GetItemString(kwargs, "use_c_opponent"); + if (use_c_opponent) { + env->use_c_opponent = PyObject_IsTrue(use_c_opponent) ? 1 : 0; + } + PyObject* use_c_opponent_p0 = PyDict_GetItemString(kwargs, "use_c_opponent_p0"); + if (use_c_opponent_p0) { + env->use_c_opponent_p0 = PyObject_IsTrue(use_c_opponent_p0) ? 1 : 0; + } + PyObject* use_external = PyDict_GetItemString(kwargs, "use_external_opponent_actions"); + if (use_external) { + env->use_external_opponent_actions = PyObject_IsTrue(use_external) ? 1 : 0; + } + PyObject* opp0_type = PyDict_GetItemString(kwargs, "opponent0_type"); + if (opp0_type) { + env->opponent_p0.type = (OpponentType)PyLong_AsLong(opp0_type); + opponent_reset(env, &env->opponent_p0); + } + // click_budget removed (loadout system doesn't need click budgets) + PyObject* auto_reset = PyDict_GetItemString(kwargs, "auto_reset"); + if (auto_reset) { + env->auto_reset = PyObject_IsTrue(auto_reset) ? 1 : 0; + } + PyObject* seed_obj = PyDict_GetItemString(kwargs, "seed"); + if (seed_obj) { + uint32_t seed = (uint32_t)PyLong_AsUnsignedLong(seed_obj); + if (!PyErr_Occurred()) { + pvp_seed(env, seed); + } + } + PyObject* tier_weights = PyDict_GetItemString(kwargs, "gear_tier_weights"); + if (tier_weights) { + PyObject* seq = PySequence_Fast(tier_weights, "gear_tier_weights must be a sequence"); + if (!seq) return -1; + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + if (n >= 4) { + PyObject** items = PySequence_Fast_ITEMS(seq); + for (int i = 0; i < 4; i++) { + env->gear_tier_weights[i] = (float)PyFloat_AsDouble(items[i]); + } + } + Py_DECREF(seq); + } + PyObject* opp_actions = PyDict_GetItemString(kwargs, "opponent_actions"); + if (opp_actions) { + PyObject* seq = PySequence_Fast(opp_actions, "opponent_actions must be a sequence"); + if (!seq) return -1; + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + PyObject** items = PySequence_Fast_ITEMS(seq); + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + if (i < n) { + long val_int = PyLong_AsLong(items[i]); + if (PyErr_Occurred()) { + Py_DECREF(seq); + return -1; + } + env->external_opponent_actions[i] = (int)val_int; + } else { + env->external_opponent_actions[i] = 0; + } + } + env->use_external_opponent_actions = 1; + Py_DECREF(seq); + } + return 0; +} + +static PyObject* vec_set_shaping_scale(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + if (!handle_obj) return NULL; + + /* VecEnv layout: {Env** envs; int num_envs;} — matches env_binding.h */ + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + PyObject* scale_obj = PyTuple_GetItem(args, 1); + if (!scale_obj) return NULL; + float s = (float)PyFloat_AsDouble(scale_obj); + if (PyErr_Occurred()) return NULL; + + for (int i = 0; i < vec->num_envs; i++) { + vec->envs[i]->shaping.shaping_scale = s; + } + Py_RETURN_NONE; +} + +/* ── PFSP opponent scheduling ──────────────────────────────────────────── + * vec_set_pfsp_weights: push new pool + cumulative weights to all envs + * vec_get_pfsp_stats: aggregate win/episode counters across all envs, then reset + */ + +static PyObject* vec_set_pfsp_weights(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + if (!handle_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + PyObject* pool_obj = PyTuple_GetItem(args, 1); /* list of int (OpponentType) */ + PyObject* weights_obj = PyTuple_GetItem(args, 2); /* list of int (cum weights * 1000) */ + if (!pool_obj || !weights_obj) return NULL; + + int pool_size = (int)PyList_Size(pool_obj); + if (pool_size > MAX_OPPONENT_POOL) pool_size = MAX_OPPONENT_POOL; + + OpponentType pool[MAX_OPPONENT_POOL]; + int cum_weights[MAX_OPPONENT_POOL]; + for (int i = 0; i < pool_size; i++) { + pool[i] = (OpponentType)PyLong_AsLong(PyList_GetItem(pool_obj, i)); + cum_weights[i] = (int)PyLong_AsLong(PyList_GetItem(weights_obj, i)); + } + if (PyErr_Occurred()) return NULL; + + for (int e = 0; e < vec->num_envs; e++) { + Env* env = vec->envs[e]; + int was_unconfigured = (env->pfsp.pool_size == 0); + env->pfsp.pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + env->pfsp.pool[i] = pool[i]; + env->pfsp.cum_weights[i] = cum_weights[i]; + } + // Only reset on first configuration — restarts the episode that was started + // during env creation before the pool was set (would have used fallback opponent). + // Periodic weight updates must NOT reset mid-episode. + if (was_unconfigured) { + pvp_reset(env); + ocean_write_obs(env); + } + } + Py_RETURN_NONE; +} + +static PyObject* vec_get_pfsp_stats(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + if (!handle_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + /* Aggregate across all envs */ + float total_wins[MAX_OPPONENT_POOL] = {0}; + float total_episodes[MAX_OPPONENT_POOL] = {0}; + int pool_size = 0; + + for (int e = 0; e < vec->num_envs; e++) { + Env* env = vec->envs[e]; + if (env->pfsp.pool_size > pool_size) pool_size = env->pfsp.pool_size; + for (int i = 0; i < env->pfsp.pool_size; i++) { + total_wins[i] += env->pfsp.wins[i]; + total_episodes[i] += env->pfsp.episodes[i]; + } + /* Reset per-env counters (read-and-reset pattern) */ + memset(env->pfsp.wins, 0, sizeof(env->pfsp.wins)); + memset(env->pfsp.episodes, 0, sizeof(env->pfsp.episodes)); + } + + /* Build return tuple: (wins_list, episodes_list) */ + PyObject* wins_list = PyList_New(pool_size); + PyObject* eps_list = PyList_New(pool_size); + for (int i = 0; i < pool_size; i++) { + PyList_SetItem(wins_list, i, PyFloat_FromDouble((double)total_wins[i])); + PyList_SetItem(eps_list, i, PyFloat_FromDouble((double)total_episodes[i])); + } + return PyTuple_Pack(2, wins_list, eps_list); +} + +/* ── Self-play support ─────────────────────────────────────────────────── + * vec_enable_selfplay: set p1 obs buffer pointer for all envs + * vec_set_opponent_actions: push opponent actions to all envs + */ + +static PyObject* vec_enable_selfplay(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + PyObject* buf_obj = PyTuple_GetItem(args, 1); + PyObject* mask_obj = PyTuple_GetItem(args, 2); + if (!handle_obj || !buf_obj || !mask_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + Py_buffer obs_view; + if (PyObject_GetBuffer(buf_obj, &obs_view, PyBUF_C_CONTIGUOUS | PyBUF_WRITABLE) < 0) { + return NULL; + } + Py_buffer mask_view; + if (PyObject_GetBuffer(mask_obj, &mask_view, PyBUF_C_CONTIGUOUS | PyBUF_WRITABLE) < 0) { + PyBuffer_Release(&obs_view); + return NULL; + } + float* buf = (float*)obs_view.buf; + unsigned char* mask = (unsigned char*)mask_view.buf; + + for (int e = 0; e < vec->num_envs; e++) { + vec->envs[e]->ocean_obs_p1 = buf + e * OCEAN_OBS_SIZE; + vec->envs[e]->ocean_selfplay_mask = &mask[e]; + } + + PyBuffer_Release(&obs_view); + PyBuffer_Release(&mask_view); + Py_RETURN_NONE; +} + +static PyObject* vec_set_opponent_actions(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + PyObject* acts_obj = PyTuple_GetItem(args, 1); + if (!handle_obj || !acts_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + Py_buffer view; + if (PyObject_GetBuffer(acts_obj, &view, PyBUF_C_CONTIGUOUS) < 0) { + return NULL; + } + int* acts = (int*)view.buf; + + for (int e = 0; e < vec->num_envs; e++) { + memcpy( + vec->envs[e]->external_opponent_actions, + acts + e * NUM_ACTION_HEADS, + NUM_ACTION_HEADS * sizeof(int) + ); + } + + PyBuffer_Release(&view); + Py_RETURN_NONE; +} + +static PyObject* get_encounter_info(PyObject* self, PyObject* args) { + (void)self; + const char* name; + if (!PyArg_ParseTuple(args, "s", &name)) return NULL; + const EncounterDef* def = encounter_find(name); + if (!def) { + PyErr_Format(PyExc_ValueError, "unknown encounter: '%s'", name); + return NULL; + } + PyObject* dims = PyList_New(def->num_action_heads); + for (int i = 0; i < def->num_action_heads; i++) { + PyList_SetItem(dims, i, PyLong_FromLong(def->action_head_dims[i])); + } + PyObject* result = Py_BuildValue("{s:i,s:i,s:i,s:N}", + "obs_size", def->obs_size, + "mask_size", def->mask_size, + "num_action_heads", def->num_action_heads, + "action_head_dims", dims); + return result; +} + +#define MY_METHODS \ + {"vec_set_shaping_scale", vec_set_shaping_scale, METH_VARARGS, \ + "Set shaping_scale for all envs in the vec"}, \ + {"vec_set_pfsp_weights", vec_set_pfsp_weights, METH_VARARGS, \ + "Set PFSP pool and cumulative weights for all envs"}, \ + {"vec_get_pfsp_stats", vec_get_pfsp_stats, METH_VARARGS, \ + "Get aggregated PFSP win/episode stats and reset counters"}, \ + {"vec_enable_selfplay", vec_enable_selfplay, METH_VARARGS, \ + "Set p1 obs buffer for self-play in all envs"}, \ + {"vec_set_opponent_actions", vec_set_opponent_actions, METH_VARARGS, \ + "Push opponent actions to all envs for self-play"}, \ + {"get_encounter_info", get_encounter_info, METH_VARARGS, \ + "Get obs/action space dimensions for a named encounter"} + +// Wrappers for 3.0 ocean template (expects c_step/c_reset/c_close/c_render). +// When encounter_def is set, dispatch through the encounter interface. +// Otherwise fall through to legacy pvp_step (backwards compat). +static void c_step(Env* env) { + if (env->encounter_def) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + def->step(env->encounter_state, env->ocean_acts); + /* write terminal obs + mask before auto-reset */ + def->write_obs(env->encounter_state, env->ocean_obs); + float mask_buf[256]; + def->write_mask(env->encounter_state, mask_buf); + for (int i = 0; i < def->mask_size; i++) { + env->ocean_obs[def->obs_size + i] = mask_buf[i]; + } + env->ocean_rew[0] = def->get_reward(env->encounter_state); + int is_term = def->is_terminal(env->encounter_state); + env->ocean_term[0] = is_term ? 1 : 0; + /* auto-reset: write terminal obs (above), then reset for next episode */ + if (is_term) { + if (def->get_log) def->get_log(env->encounter_state); + def->reset(env->encounter_state, 0); + } + } else { + pvp_step(env); + // After auto-reset, pvp_step wrote terminal obs then reset game state. + // Overwrite with initial-state obs so policy sees correct first observation + // on the next step. Matches 4.0 training binding behavior. + if (env->ocean_term[0] && env->auto_reset) { + ocean_write_obs(env); + } + } +} +static void c_reset(Env* env) { + if (env->encounter_def) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + def->reset(env->encounter_state, env->rng_seed); + } else { + pvp_reset(env); + } +} +static void c_close(Env* env) { + if (env->encounter_def && env->encounter_state) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + def->destroy(env->encounter_state); + env->encounter_state = NULL; + } + pvp_close(env); +} +static void c_render(Env* env) { pvp_render(env); } + +#include "../PufferLib/pufferlib/ocean/env_binding.h" + +static int my_init(Env* env, PyObject* args, PyObject* kwargs) { + (void)args; + + // Save PufferLib shared buffer pointers (set by env_binding.h template) + // before pvp_init redirects them to internal 2-agent buffers + float* shared_obs = (float*)env->observations; + int* shared_acts = (int*)env->actions; + float* shared_rew = (float*)env->rewards; + unsigned char* shared_term = (unsigned char*)env->terminals; + + // Encounter dispatch: check early before PvP-specific kwargs. + // Non-PvP encounters (e.g. zulrah) don't need opponent_type, is_lms, etc. + PyObject* encounter_name = PyDict_GetItemString(kwargs, "encounter"); + if (encounter_name && encounter_name != Py_None) { + const char* name = PyUnicode_AsUTF8(encounter_name); + if (!name) return -1; + const EncounterDef* def = encounter_find(name); + if (!def) { + PyErr_Format(PyExc_ValueError, "unknown encounter: '%s'", name); + return -1; + } + EncounterState* es = def->create(); + if (!es) { + PyErr_SetString(PyExc_RuntimeError, "encounter create() failed"); + return -1; + } + + // Optional seed + PyObject* seed_obj = PyDict_GetItemString(kwargs, "seed"); + uint32_t seed = 0; + if (seed_obj && seed_obj != Py_None) { + seed = (uint32_t)PyLong_AsLong(seed_obj); + if (PyErr_Occurred()) { def->destroy(es); return -1; } + def->put_int(es, "seed", (int)seed); + } + + def->reset(es, seed); + + // Wire encounter to env + env->encounter_def = def; + env->encounter_state = es; + env->ocean_obs = shared_obs; + env->ocean_acts = shared_acts; + env->ocean_rew = shared_rew; + env->ocean_term = shared_term; + + // Write initial obs through encounter interface + def->write_obs(es, env->ocean_obs); + float enc_mask_buf[256]; /* enough for any encounter mask */ + def->write_mask(es, enc_mask_buf); + for (int i = 0; i < def->mask_size; i++) { + env->ocean_obs[def->obs_size + i] = enc_mask_buf[i]; + } + env->ocean_rew[0] = 0.0f; + env->ocean_term[0] = 0; + return 0; + } + + // Legacy PvP path: initialize internal buffers and parse PvP-specific kwargs + pvp_init(env); + + // Set ocean pointers to PufferLib shared buffers + env->ocean_obs = shared_obs; + env->ocean_acts = shared_acts; + env->ocean_rew = shared_rew; + env->ocean_term = shared_term; + + // Parse kwargs (required for PvP mode) + int seed = (int)unpack(kwargs, "seed"); + if (PyErr_Occurred()) return -1; + + int opponent_type = (int)unpack(kwargs, "opponent_type"); + if (PyErr_Occurred()) return -1; + + int is_lms = (int)unpack(kwargs, "is_lms"); + if (PyErr_Occurred()) return -1; + + double shaping_scale = unpack(kwargs, "shaping_scale"); + if (PyErr_Occurred()) return -1; + + int shaping_enabled = (int)unpack(kwargs, "shaping_enabled"); + if (PyErr_Occurred()) return -1; + + // Configure environment + env->rng_seed = (uint32_t)(seed > 0 ? seed : 0); + env->has_rng_seed = (seed > 0) ? 1 : 0; + env->is_lms = is_lms; + + // Configure opponent + env->use_c_opponent = 1; + env->opponent.type = (OpponentType)opponent_type; + + // Configure reward shaping + env->shaping.shaping_scale = (float)shaping_scale; + env->shaping.enabled = shaping_enabled; + env->shaping.damage_dealt_coef = 0.005f; + env->shaping.damage_received_coef = -0.005f; + env->shaping.correct_prayer_bonus = 0.03f; + env->shaping.wrong_prayer_penalty = -0.02f; + env->shaping.prayer_switch_no_attack_penalty = -0.01f; + env->shaping.off_prayer_hit_bonus = 0.03f; + env->shaping.melee_frozen_penalty = -0.05f; + env->shaping.wasted_eat_penalty = -0.001f; + env->shaping.premature_eat_penalty = -0.02f; + env->shaping.magic_no_staff_penalty = -0.05f; + env->shaping.gear_mismatch_penalty = -0.05f; + env->shaping.spec_off_prayer_bonus = 0.02f; + env->shaping.spec_low_defence_bonus = 0.01f; + env->shaping.spec_low_hp_bonus = 0.02f; + env->shaping.smart_triple_eat_bonus = 0.05f; + env->shaping.wasted_triple_eat_penalty = -0.0005f; + env->shaping.damage_burst_bonus = 0.002f; + env->shaping.damage_burst_threshold = 30; + env->shaping.premature_eat_threshold = 0.7071f; + env->shaping.ko_bonus = 0.15f; + env->shaping.wasted_resources_penalty = -0.07f; + // Always-on behavioral penalties + env->shaping.prayer_penalty_enabled = 1; + env->shaping.click_penalty_enabled = 0; + env->shaping.click_penalty_threshold = 5; + env->shaping.click_penalty_coef = -0.003f; + + // Override behavioral penalty config from kwargs + PyObject* prayer_pen = PyDict_GetItemString(kwargs, "prayer_penalty_enabled"); + if (prayer_pen) env->shaping.prayer_penalty_enabled = PyObject_IsTrue(prayer_pen); + PyObject* click_pen = PyDict_GetItemString(kwargs, "click_penalty_enabled"); + if (click_pen) env->shaping.click_penalty_enabled = PyObject_IsTrue(click_pen); + PyObject* click_thresh = PyDict_GetItemString(kwargs, "click_penalty_threshold"); + if (click_thresh) env->shaping.click_penalty_threshold = (int)PyLong_AsLong(click_thresh); + PyObject* click_coef = PyDict_GetItemString(kwargs, "click_penalty_coef"); + if (click_coef) env->shaping.click_penalty_coef = (float)PyFloat_AsDouble(click_coef); + + // Collision map: load once from path kwarg, share across all envs + PyObject* cmap_path = PyDict_GetItemString(kwargs, "collision_map_path"); + if (cmap_path && cmap_path != Py_None && g_collision_map == NULL) { + const char* path = PyUnicode_AsUTF8(cmap_path); + if (path) { + g_collision_map = collision_map_load(path); + if (g_collision_map) { + fprintf(stderr, "collision map loaded: %d regions from %s\n", + g_collision_map->count, path); + } + } + } + env->collision_map = g_collision_map; + + // Gear tier weights: default 100% basic (tier 0) + env->gear_tier_weights[0] = 1.0f; + env->gear_tier_weights[1] = 0.0f; + env->gear_tier_weights[2] = 0.0f; + env->gear_tier_weights[3] = 0.0f; + + // Override gear tier weights if provided in kwargs + PyObject* tier_w = PyDict_GetItemString(kwargs, "gear_tier_weights"); + if (tier_w) { + PyObject* seq = PySequence_Fast(tier_w, "gear_tier_weights must be a sequence"); + if (seq) { + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + if (n >= 4) { + PyObject** items = PySequence_Fast_ITEMS(seq); + for (int i = 0; i < 4; i++) { + env->gear_tier_weights[i] = (float)PyFloat_AsDouble(items[i]); + } + } + Py_DECREF(seq); + } + } + + // Legacy path: no encounter, direct pvp_step + pvp_reset(env); + + // Write initial observations to shared buffer + ocean_write_obs(env); + env->ocean_rew[0] = 0.0f; + env->ocean_term[0] = 0; + + return 0; +} + +static int my_log(PyObject* dict, Log* log) { + assign_to_dict(dict, "episode_return", log->episode_return); + assign_to_dict(dict, "episode_length", log->episode_length); + assign_to_dict(dict, "wins", log->wins); + assign_to_dict(dict, "damage_dealt", log->damage_dealt); + assign_to_dict(dict, "damage_received", log->damage_received); + return 0; +} diff --git a/ocean/osrs/osrs_collision.h b/ocean/osrs/osrs_collision.h new file mode 100644 index 0000000000..f04a9d6991 --- /dev/null +++ b/ocean/osrs/osrs_collision.h @@ -0,0 +1,586 @@ +/** + * @file osrs_collision.h + * @brief Tile collision flag system for OSRS world simulation + * + * Based on OSRS collision system (TraversalConstants + TraversalMap). + * Implements the OSRS collision flag bitmask system: + * - Per-tile int flags storing wall directions, blocked state, impenetrability + * - Region-based storage (64x64 tiles, 4 height planes per region) + * - Directional traversal checks (N/S/E/W + diagonals) + * - Hash-map region manager with lazy allocation + * + * When collision_map is NULL, all traversal checks return true (backwards + * compatible with the flat arena). + */ + +#ifndef OSRS_COLLISION_H +#define OSRS_COLLISION_H + +#include +#include +#include + +/* ========================================================================= + * COLLISION FLAG CONSTANTS (from TraversalConstants.java) + * ========================================================================= */ + +#define COLLISION_NONE 0x000000 +#define COLLISION_WALL_NORTH_WEST 0x000001 +#define COLLISION_WALL_NORTH 0x000002 +#define COLLISION_WALL_NORTH_EAST 0x000004 +#define COLLISION_WALL_EAST 0x000008 +#define COLLISION_WALL_SOUTH_EAST 0x000010 +#define COLLISION_WALL_SOUTH 0x000020 +#define COLLISION_WALL_SOUTH_WEST 0x000040 +#define COLLISION_WALL_WEST 0x000080 + +#define COLLISION_IMPENETRABLE_WALL_NORTH_WEST 0x000200 +#define COLLISION_IMPENETRABLE_WALL_NORTH 0x000400 +#define COLLISION_IMPENETRABLE_WALL_NORTH_EAST 0x000800 +#define COLLISION_IMPENETRABLE_WALL_EAST 0x001000 +#define COLLISION_IMPENETRABLE_WALL_SOUTH_EAST 0x002000 +#define COLLISION_IMPENETRABLE_WALL_SOUTH 0x004000 +#define COLLISION_IMPENETRABLE_WALL_SOUTH_WEST 0x008000 +#define COLLISION_IMPENETRABLE_WALL_WEST 0x010000 + +#define COLLISION_IMPENETRABLE_BLOCKED 0x020000 +#define COLLISION_BRIDGE 0x040000 +#define COLLISION_BLOCKED 0x200000 + +/* ========================================================================= + * REGION DATA STRUCTURE + * ========================================================================= */ + +#define REGION_SIZE 64 +#define REGION_HEIGHT_LEVELS 4 + +/** A single 64x64 OSRS region with 4 height planes of collision flags. */ +typedef struct { + int flags[REGION_HEIGHT_LEVELS][REGION_SIZE][REGION_SIZE]; +} CollisionRegion; + +/* ========================================================================= + * REGION MAP (hash map of regions keyed by region hash) + * ========================================================================= */ + +#define REGION_MAP_CAPACITY 256 /* power of 2, enough for wilderness + surroundings */ + +typedef struct { + int key; /* region hash: (regionX << 8) | regionY, or -1 if empty */ + CollisionRegion* region; +} RegionMapEntry; + +typedef struct { + RegionMapEntry entries[REGION_MAP_CAPACITY]; + int count; +} CollisionMap; + +/* ========================================================================= + * COORDINATE HELPERS + * ========================================================================= */ + +/** Compute region hash from global tile coordinates. */ +static inline int collision_region_hash(int x, int y) { + return ((x >> 6) << 8) | (y >> 6); +} + +/** Extract local coordinate within a 64x64 region. */ +static inline int collision_local(int coord) { + return coord & 0x3F; +} + +/* ========================================================================= + * REGION MAP OPERATIONS + * ========================================================================= */ + +/** Initialize a collision map (all slots empty). */ +static inline void collision_map_init(CollisionMap* map) { + map->count = 0; + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + map->entries[i].key = -1; + map->entries[i].region = NULL; + } +} + +/** Allocate and initialize a new collision map. */ +static inline CollisionMap* collision_map_create(void) { + CollisionMap* map = (CollisionMap*)malloc(sizeof(CollisionMap)); + collision_map_init(map); + return map; +} + +/** Look up a region by hash. Returns NULL if not present. */ +static inline CollisionRegion* collision_map_get(const CollisionMap* map, int key) { + int idx = key & (REGION_MAP_CAPACITY - 1); + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + int slot = (idx + i) & (REGION_MAP_CAPACITY - 1); + if (map->entries[slot].key == key) { + return map->entries[slot].region; + } + if (map->entries[slot].key == -1) { + return NULL; + } + } + return NULL; +} + +/** Insert a region into the map. Overwrites if key exists. */ +static inline void collision_map_put(CollisionMap* map, int key, CollisionRegion* region) { + int idx = key & (REGION_MAP_CAPACITY - 1); + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + int slot = (idx + i) & (REGION_MAP_CAPACITY - 1); + if (map->entries[slot].key == key) { + map->entries[slot].region = region; + return; + } + if (map->entries[slot].key == -1) { + map->entries[slot].key = key; + map->entries[slot].region = region; + map->count++; + return; + } + } + /* map full — shouldn't happen with 256 capacity for wilderness */ + fprintf(stderr, "collision_map_put: map full (capacity %d)\n", REGION_MAP_CAPACITY); +} + +/** Get or lazily create a region for the given global tile coordinates. */ +static inline CollisionRegion* collision_map_get_or_create(CollisionMap* map, int x, int y) { + int key = collision_region_hash(x, y); + CollisionRegion* region = collision_map_get(map, key); + if (region == NULL) { + region = (CollisionRegion*)calloc(1, sizeof(CollisionRegion)); + collision_map_put(map, key, region); + } + return region; +} + +/** Free all regions and the map itself. */ +static inline void collision_map_free(CollisionMap* map) { + if (map == NULL) return; + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + if (map->entries[i].region != NULL) { + free(map->entries[i].region); + } + } + free(map); +} + +/* ========================================================================= + * FLAG READ/WRITE + * ========================================================================= */ + +/** Get collision flags for a global tile coordinate. Returns 0 if region not loaded. */ +static inline int collision_get_flags(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return COLLISION_NONE; + int key = collision_region_hash(x, y); + const CollisionRegion* region = collision_map_get(map, key); + if (region == NULL) return COLLISION_NONE; + int lx = collision_local(x); + int ly = collision_local(y); + int h = height < 0 ? 0 : (height >= REGION_HEIGHT_LEVELS ? REGION_HEIGHT_LEVELS - 1 : height); + return region->flags[h][lx][ly]; +} + +/** Set (OR) collision flags on a global tile coordinate. */ +static inline void collision_set_flag(CollisionMap* map, int height, int x, int y, int flag) { + CollisionRegion* region = collision_map_get_or_create(map, x, y); + int lx = collision_local(x); + int ly = collision_local(y); + int h = height < 0 ? 0 : (height >= REGION_HEIGHT_LEVELS ? REGION_HEIGHT_LEVELS - 1 : height); + region->flags[h][lx][ly] |= flag; +} + +/** Unset (clear) collision flags on a global tile coordinate. */ +static inline void collision_unset_flag(CollisionMap* map, int height, int x, int y, int flag) { + int key = collision_region_hash(x, y); + CollisionRegion* region = collision_map_get(map, key); + if (region == NULL) return; + int lx = collision_local(x); + int ly = collision_local(y); + int h = height < 0 ? 0 : (height >= REGION_HEIGHT_LEVELS ? REGION_HEIGHT_LEVELS - 1 : height); + region->flags[h][lx][ly] &= ~flag; +} + +/** Mark a tile as fully blocked (terrain). */ +static inline void collision_mark_blocked(CollisionMap* map, int height, int x, int y) { + collision_set_flag(map, height, x, y, COLLISION_BLOCKED); +} + +/** Mark a multi-tile occupant (game object) as blocked + optionally impenetrable. */ +static inline void collision_mark_occupant(CollisionMap* map, int height, int x, int y, + int width, int length, int impenetrable) { + int flag = COLLISION_BLOCKED; + if (impenetrable) flag |= COLLISION_IMPENETRABLE_BLOCKED; + for (int xi = x; xi < x + width; xi++) { + for (int yi = y; yi < y + length; yi++) { + collision_set_flag(map, height, xi, yi, flag); + } + } +} + +/* ========================================================================= + * TRAVERSAL CHECKS (ported from TraversalMap.java) + * + * Each check tests the DESTINATION tile for incoming wall flags + BLOCKED. + * For diagonals: also checks the two cardinal intermediate tiles. + * + * All functions take a CollisionMap* which may be NULL (= all traversable). + * Height is always 0 for PvP (single plane). The height param is kept for + * future multi-plane support. + * ========================================================================= */ + +/** Check if flag bits are INACTIVE (none set) on a tile. */ +static inline int collision_is_inactive(const CollisionMap* map, int height, int x, int y, int flag) { + return (collision_get_flags(map, height, x, y) & flag) == 0; +} + +static inline int collision_traversable_north(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x, y + 1, + COLLISION_WALL_SOUTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_south(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x, y - 1, + COLLISION_WALL_NORTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_east(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x + 1, y, + COLLISION_WALL_WEST | COLLISION_BLOCKED); +} + +static inline int collision_traversable_west(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x - 1, y, + COLLISION_WALL_EAST | COLLISION_BLOCKED); +} + +static inline int collision_traversable_north_east(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + /* diagonal tile: no SW wall, not blocked */ + /* east tile: no west wall, not blocked */ + /* north tile: no south wall, not blocked */ + return collision_is_inactive(map, height, x + 1, y + 1, + COLLISION_WALL_WEST | COLLISION_WALL_SOUTH | COLLISION_WALL_SOUTH_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x + 1, y, + COLLISION_WALL_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y + 1, + COLLISION_WALL_SOUTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_north_west(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x - 1, y + 1, + COLLISION_WALL_EAST | COLLISION_WALL_SOUTH | COLLISION_WALL_SOUTH_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x - 1, y, + COLLISION_WALL_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y + 1, + COLLISION_WALL_SOUTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_south_east(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x + 1, y - 1, + COLLISION_WALL_WEST | COLLISION_WALL_NORTH | COLLISION_WALL_NORTH_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x + 1, y, + COLLISION_WALL_WEST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y - 1, + COLLISION_WALL_NORTH | COLLISION_BLOCKED); +} + +static inline int collision_traversable_south_west(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return collision_is_inactive(map, height, x - 1, y - 1, + COLLISION_WALL_EAST | COLLISION_WALL_NORTH | COLLISION_WALL_NORTH_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x - 1, y, + COLLISION_WALL_EAST | COLLISION_BLOCKED) + && collision_is_inactive(map, height, x, y - 1, + COLLISION_WALL_NORTH | COLLISION_BLOCKED); +} + +/** + * Check if a tile is walkable (not blocked, no incoming walls from any direction). + * Simple check — just tests the BLOCKED flag on the tile itself. + */ +static inline int collision_tile_walkable(const CollisionMap* map, int height, int x, int y) { + if (map == NULL) return 1; + return (collision_get_flags(map, height, x, y) & COLLISION_BLOCKED) == 0; +} + +/** + * Check directional traversability given dx/dy step direction. + * dx and dy should each be -1, 0, or 1. + */ +static inline int collision_traversable_step(const CollisionMap* map, int height, + int x, int y, int dx, int dy) { + if (map == NULL) return 1; + + if (dx == 0 && dy == 1) return collision_traversable_north(map, height, x, y); + if (dx == 0 && dy == -1) return collision_traversable_south(map, height, x, y); + if (dx == 1 && dy == 0) return collision_traversable_east(map, height, x, y); + if (dx == -1 && dy == 0) return collision_traversable_west(map, height, x, y); + if (dx == 1 && dy == 1) return collision_traversable_north_east(map, height, x, y); + if (dx == -1 && dy == 1) return collision_traversable_north_west(map, height, x, y); + if (dx == 1 && dy == -1) return collision_traversable_south_east(map, height, x, y); + if (dx == -1 && dy == -1) return collision_traversable_south_west(map, height, x, y); + + /* dx == 0 && dy == 0: no movement */ + return 1; +} + +/* ========================================================================= + * BINARY COLLISION MAP I/O + * + * Format: + * 4 bytes: magic "CMAP" + * 4 bytes: version (1) + * 4 bytes: region_count + * For each region: + * 4 bytes: region_hash (key) + * REGION_HEIGHT_LEVELS * REGION_SIZE * REGION_SIZE * 4 bytes: flags + * ========================================================================= */ + +#define COLLISION_MAP_MAGIC 0x50414D43 /* "CMAP" in little-endian */ +#define COLLISION_MAP_VERSION 1 + +/** Load collision map from a binary file. Returns NULL on failure. */ +static inline CollisionMap* collision_map_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (f == NULL) { + fprintf(stderr, "collision_map_load: cannot open %s\n", path); + return NULL; + } + + uint32_t magic, version, region_count; + if (fread(&magic, 4, 1, f) != 1 || magic != COLLISION_MAP_MAGIC) { + fprintf(stderr, "collision_map_load: bad magic in %s\n", path); + fclose(f); + return NULL; + } + if (fread(&version, 4, 1, f) != 1 || version != COLLISION_MAP_VERSION) { + fprintf(stderr, "collision_map_load: unsupported version %u in %s\n", version, path); + fclose(f); + return NULL; + } + if (fread(®ion_count, 4, 1, f) != 1) { + fprintf(stderr, "collision_map_load: truncated header in %s\n", path); + fclose(f); + return NULL; + } + + CollisionMap* map = collision_map_create(); + + for (uint32_t i = 0; i < region_count; i++) { + int32_t key; + if (fread(&key, 4, 1, f) != 1) { + fprintf(stderr, "collision_map_load: truncated at region %u in %s\n", i, path); + collision_map_free(map); + fclose(f); + return NULL; + } + + CollisionRegion* region = (CollisionRegion*)calloc(1, sizeof(CollisionRegion)); + size_t flags_size = sizeof(region->flags); + if (fread(region->flags, 1, flags_size, f) != flags_size) { + fprintf(stderr, "collision_map_load: truncated flags at region %u in %s\n", i, path); + free(region); + collision_map_free(map); + fclose(f); + return NULL; + } + + collision_map_put(map, key, region); + } + + fclose(f); + return map; +} + +/** Save collision map to a binary file. Returns 0 on success, -1 on failure. */ +static inline int collision_map_save(const CollisionMap* map, const char* path) { + FILE* f = fopen(path, "wb"); + if (f == NULL) return -1; + + uint32_t magic = COLLISION_MAP_MAGIC; + uint32_t version = COLLISION_MAP_VERSION; + uint32_t region_count = (uint32_t)map->count; + + fwrite(&magic, 4, 1, f); + fwrite(&version, 4, 1, f); + fwrite(®ion_count, 4, 1, f); + + for (int i = 0; i < REGION_MAP_CAPACITY; i++) { + if (map->entries[i].key == -1) continue; + int32_t key = map->entries[i].key; + fwrite(&key, 4, 1, f); + fwrite(map->entries[i].region->flags, 1, sizeof(map->entries[i].region->flags), f); + } + + fclose(f); + return 0; +} + +/* ========================================================================= + * LINE OF SIGHT — fixed-point ray tracing with directional masks + * + * used by inferno pillars, zulrah safespots, and any future encounter + * that needs projectile blocking around obstacles. + * + * algorithm: Bresenham-style ray trace in Q16 fixed-point from tile center. + * each blocker has a directional bitmask indicating which sides block sight. + * FULL_MASK blocks from all directions. + * + * reference: osrs-sdk LineOfSight.ts + * ========================================================================= */ + +#define LOS_FULL_MASK 0x20000 +#define LOS_EAST_MASK 0x01000 +#define LOS_WEST_MASK 0x10000 +#define LOS_NORTH_MASK 0x00400 +#define LOS_SOUTH_MASK 0x04000 + +/* an entity that blocks line of sight. pillars, walls, etc. + * the los_mask indicates which directions are blocked. */ +typedef struct { + int x, y; /* top-left tile of the blocker */ + int size; /* NxN footprint */ + uint32_t los_mask; /* bitmask: which directions block LOS */ +} LOSBlocker; + +/* check if point (px,py) overlaps any blocker, return its mask or 0 */ +static uint32_t los_check_tile(const LOSBlocker* blockers, int count, + int px, int py) { + for (int i = 0; i < count; i++) { + const LOSBlocker* b = &blockers[i]; + if (px >= b->x && px < b->x + b->size && + py >= b->y && py < b->y + b->size) { + return b->los_mask; + } + } + return 0; +} + +/* AABB overlap check for two entities */ +static int los_aabb_overlap(int x1, int y1, int s1, int x2, int y2, int s2) { + return !(x1 >= x2 + s2 || x1 + s1 <= x2 || y1 >= y2 + s2 || y1 + s1 <= y2); +} + +/* fixed-point Q16 ray trace. returns 1 if clear LOS, 0 if blocked. + * traces from (x1,y1) to (x2,y2). src_size is the source entity size (1 for player). + * range is max tile distance (-1 = unlimited). */ +static int has_line_of_sight(const LOSBlocker* blockers, int blocker_count, + int x1, int y1, int x2, int y2, + int src_size, int range) { + int dx = x2 - x1; + int dy = y2 - y1; + + /* reject if either endpoint is inside a blocker */ + if (los_check_tile(blockers, blocker_count, x1, y1)) return 0; + if (los_check_tile(blockers, blocker_count, x2, y2)) return 0; + + /* self-overlap check */ + if (los_aabb_overlap(x1, y1, src_size, x2, y2, 1)) return 0; + + /* range check */ + if (range > 0) { + int adx = dx < 0 ? -dx : dx; + int ady = dy < 0 ? -dy : dy; + if (adx > range || ady > range) return 0; + } + + int adx = dx < 0 ? -dx : dx; + int ady = dy < 0 ? -dy : dy; + + if (adx > ady) { + /* x-dominant ray */ + int x_tile = x1; + int y_fp = (y1 << 16) + 0x8000; + int slope = (adx > 0) ? ((dy << 16) / adx) : 0; + int x_inc = (dx > 0) ? 1 : -1; + uint32_t x_mask = (dx > 0) ? (LOS_WEST_MASK | LOS_FULL_MASK) + : (LOS_EAST_MASK | LOS_FULL_MASK); + uint32_t y_mask = (dy < 0) ? (LOS_NORTH_MASK | LOS_FULL_MASK) + : (LOS_SOUTH_MASK | LOS_FULL_MASK); + if (dy < 0) y_fp -= 1; + + while (x_tile != x2) { + x_tile += x_inc; + int y_tile = y_fp >> 16; + if (los_check_tile(blockers, blocker_count, x_tile, y_tile) & x_mask) + return 0; + y_fp += slope; + int new_y = y_fp >> 16; + if (new_y != y_tile) { + if (los_check_tile(blockers, blocker_count, x_tile, new_y) & y_mask) + return 0; + } + } + } else if (ady > 0) { + /* y-dominant ray */ + int y_tile = y1; + int x_fp = (x1 << 16) + 0x8000; + int slope = (ady > 0) ? ((dx << 16) / ady) : 0; + int y_inc = (dy > 0) ? 1 : -1; + uint32_t y_mask = (dy > 0) ? (LOS_SOUTH_MASK | LOS_FULL_MASK) + : (LOS_NORTH_MASK | LOS_FULL_MASK); + uint32_t x_mask = (dx < 0) ? (LOS_EAST_MASK | LOS_FULL_MASK) + : (LOS_WEST_MASK | LOS_FULL_MASK); + if (dx < 0) x_fp -= 1; + + while (y_tile != y2) { + y_tile += y_inc; + int x_tile = x_fp >> 16; + if (los_check_tile(blockers, blocker_count, x_tile, y_tile) & y_mask) + return 0; + x_fp += slope; + int new_x = x_fp >> 16; + if (new_x != x_tile) { + if (los_check_tile(blockers, blocker_count, new_x, y_tile) & x_mask) + return 0; + } + } + } + /* else dx==0 && dy==0: same tile, always has LOS */ + + return 1; +} + +/* NPC LOS: for size>1 NPCs, check from target's closest point back to NPC. + * npc is at (nx,ny) SW corner with npc_size. target is at (tx,ty) size 1. + * for melee (range==1): pure cardinal adjacency — no ray-trace, no pillar check. + * ref: osrs-sdk LineOfSight.ts:88-89 (translated to our SW-anchor, Y-up coords). */ +static int npc_has_line_of_sight(const LOSBlocker* blockers, int blocker_count, + int nx, int ny, int npc_size, + int tx, int ty, int range) { + /* melee range: player must be on a cardinal side of the NPC bounding box + (north/south/east/west edge tiles). diagonal corners do NOT count. + NPC occupies [nx, nx+s-1] x [ny, ny+s-1]. cardinal-adjacent tiles: + north: y = ny+s, x in [nx, nx+s-1] + south: y = ny-1, x in [nx, nx+s-1] + east: x = nx+s, y in [ny, ny+s-1] + west: x = nx-1, y in [ny, ny+s-1] */ + if (range == 1) { + if (los_check_tile(blockers, blocker_count, tx, ty)) return 0; + if (los_aabb_overlap(nx, ny, npc_size, tx, ty, 1)) return 0; + int dx = tx - nx; + int dy = ty - ny; + return (dx >= 0 && dx < npc_size && (dy == npc_size || dy == -1)) || + (dy >= 0 && dy < npc_size && (dx == npc_size || dx == -1)); + } + + /* ranged/magic: find closest point on NPC footprint, ray-trace */ + int cx = tx; + if (cx < nx) cx = nx; + if (cx >= nx + npc_size) cx = nx + npc_size - 1; + int cy = ty; + if (cy < ny) cy = ny; + if (cy >= ny + npc_size) cy = ny + npc_size - 1; + + return has_line_of_sight(blockers, blocker_count, tx, ty, cx, cy, 1, range); +} + +#endif /* OSRS_COLLISION_H */ diff --git a/ocean/osrs/osrs_combat_shared.h b/ocean/osrs/osrs_combat_shared.h new file mode 100644 index 0000000000..81e3eaa53e --- /dev/null +++ b/ocean/osrs/osrs_combat_shared.h @@ -0,0 +1,335 @@ +/** + * @fileoverview osrs_combat_shared.h — pure combat math shared by all encounters. + * + * stateless functions with no dependencies beyond . use these instead + * of reimplementing combat formulas per encounter. + * + * SHARED FUNCTIONS: + * osrs_hit_chance(att_roll, def_roll) standard OSRS accuracy formula + * osrs_tbow_acc_mult(target_magic) twisted bow accuracy multiplier + * osrs_tbow_dmg_mult(target_magic) twisted bow damage multiplier + * osrs_barrage_resolve(targets, ...) barrage 3x3 AoE with independent rolls + * osrs_npc_melee_max_hit(str, bonus) NPC melee max hit from stats + * osrs_npc_ranged_max_hit(range, bonus) NPC ranged max hit from stats + * osrs_npc_magic_max_hit(base, pct) NPC magic max hit from stats + * osrs_npc_max_hit(style, ...) dispatches to style-specific formula + * osrs_npc_attack_roll(att, bonus) NPC attack roll + * osrs_player_def_roll_vs_npc(def,mag,b,s) player defence roll vs NPC + * encounter_xorshift(state) xorshift32 RNG step + * encounter_rand_int(state, max) random int in [0, max) + * encounter_rand_float(state) random float in [0, 1) + * encounter_npc_roll_attack(att,def,mh,rng) NPC accuracy+damage in one call + * encounter_prayer_correct_for_style(p, s) prayer blocks attack style check + * encounter_magic_hit_delay(dist, is_p) magic projectile flight delay (ticks) + * encounter_ranged_hit_delay(dist, is_p) ranged projectile flight delay (ticks) + * encounter_dist_to_npc(px,py,nx,ny,sz) chebyshev dist to multi-tile NPC + * + * SEE ALSO: + * osrs_encounter.h encounter-level abstractions (damage, movement, gear, etc.) + * osrs_pvp_combat.h PvP-specific combat (prayer, veng, recoil, pending hits) + */ + +#ifndef OSRS_COMBAT_SHARED_H +#define OSRS_COMBAT_SHARED_H + +#include +#include + +/* standard OSRS accuracy formula. + att_roll and def_roll are pre-computed: eff_level * (bonus + 64). + returns hit probability in [0, 1]. */ +static inline float osrs_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)); +} + +/* twisted bow accuracy multiplier. + target_magic = min(max(npc_magic_level, npc_magic_attack_bonus), 250). + formula from RuneLite TwistedBow._accuracyMultiplier. */ +static inline float osrs_tbow_acc_mult(int target_magic) { + int m = target_magic < 250 ? target_magic : 250; + /* ref: osrs-sdk TwistedBow.ts _accuracyMultiplier + linear term uses 3*magic, quadratic uses 3*magic/10 */ + float lin = (float)(3 * m); + float quad = lin / 10.0f; + float mult = (140.0f + (lin - 10.0f) / 100.0f - (quad - 100.0f) * (quad - 100.0f) / 100.0f) / 100.0f; + if (mult > 1.4f) mult = 1.4f; + if (mult < 0.0f) mult = 0.0f; + return mult; +} + +/* twisted bow damage multiplier. + same input as accuracy multiplier. + ref: osrs-sdk TwistedBow.ts _damageMultiplier */ +static inline float osrs_tbow_dmg_mult(int target_magic) { + int m = target_magic < 250 ? target_magic : 250; + float lin = (float)(3 * m); + float quad = lin / 10.0f; + float mult = (250.0f + (lin - 14.0f) / 100.0f - (quad - 140.0f) * (quad - 140.0f) / 100.0f) / 100.0f; + if (mult > 2.5f) mult = 2.5f; + if (mult < 0.0f) mult = 0.0f; + return mult; +} + +/* ======================================================================== */ +/* shared encounter RNG (xorshift32) */ +/* ======================================================================== */ + +/* all encounters should use these instead of reimplementing. + state must be non-zero. */ +static inline uint32_t encounter_xorshift(uint32_t* state) { + *state ^= *state << 13; + *state ^= *state >> 17; + *state ^= *state << 5; + return *state; +} + +static inline int encounter_rand_int(uint32_t* rng_state, int max) { + if (max <= 0) return 0; + return (int)(encounter_xorshift(rng_state) % (unsigned)max); +} + +static inline float encounter_rand_float(uint32_t* rng_state) { + return (float)(encounter_xorshift(rng_state) & 0xFFFF) / 65536.0f; +} + +/* ======================================================================== */ +/* barrage AoE (3x3) */ +/* ======================================================================== */ + +#define BARRAGE_MAX_HITS 9 +#define BARRAGE_FREEZE_TICKS 32 + +/* per-target info for barrage AoE. caller fills in the target array, + osrs_barrage_resolve does accuracy/damage rolls and writes results back. */ +typedef struct { + int active; /* in: 1 if this target slot is valid */ + int x, y; /* in: NPC SW corner tile position */ + int def_level; /* in: NPC defence level */ + int magic_def_bonus; /* in: NPC magic defence bonus */ + int npc_idx; /* in: index into caller's NPC array (for callbacks) */ + int* frozen_ticks; /* in: pointer to NPC's frozen_ticks (NULL = no freeze tracking) */ + int hit; /* out: 1 = accuracy passed, 0 = splashed */ + int damage; /* out: damage rolled (0 if splashed) */ +} BarrageTarget; + +/* result from a barrage cast */ +typedef struct { + int total_damage; /* sum of all damage across AoE */ + int num_hits; /* number of targets that were rolled against */ + int num_successful; /* number that passed accuracy (hit=1) */ +} BarrageResult; + +/* resolve a barrage spell against a primary target + 3x3 AoE. + - targets[0] is the primary target (always rolled first) + - targets[1..max_targets-1] are potential AoE targets (only those within + 1 tile of primary are rolled against) + - att_roll: pre-computed attacker magic roll (eff_level * (bonus + 64)) + - max_hit: barrage spell max hit + - rng_state: pointer to RNG state for rolls + - max_targets: size of targets array + + the function sets hit/damage on each target. if spell_type is ICE and + a target's frozen_ticks pointer is set, freeze is applied immediately + at cast time (ref: osrs-sdk IceBarrageSpell.ts). caller is responsible + for queueing damage as pending hits with appropriate delay. + + returns aggregate result for reward/heal calculations. */ +static inline BarrageResult osrs_barrage_resolve( + BarrageTarget* targets, int max_targets, + int att_roll, int max_hit, uint32_t* rng_state, + int spell_type +) { + BarrageResult result = { 0, 0, 0 }; + + if (max_targets < 1 || !targets[0].active) return result; + + /* primary target (index 0) always gets rolled */ + int px = targets[0].x, py = targets[0].y; + { + int def_roll = (targets[0].def_level + 8) * (targets[0].magic_def_bonus + 64); + float chance = osrs_hit_chance(att_roll, def_roll); + targets[0].hit = encounter_rand_float(rng_state) < chance; + targets[0].damage = targets[0].hit ? encounter_rand_int(rng_state, max_hit + 1) : 0; + result.total_damage += targets[0].damage; + result.num_hits++; + if (targets[0].hit) { + result.num_successful++; + /* ice barrage: freeze immediately at cast time */ + if (spell_type == 1 /* ENCOUNTER_SPELL_ICE */ && targets[0].frozen_ticks) + *targets[0].frozen_ticks = BARRAGE_FREEZE_TICKS; + } + } + + /* AoE: roll against all other active targets within 1 tile of primary */ + for (int i = 1; i < max_targets && result.num_hits < BARRAGE_MAX_HITS; i++) { + if (!targets[i].active) continue; + int dx = targets[i].x - px; + int dy = targets[i].y - py; + if (dx < -1 || dx > 1 || dy < -1 || dy > 1) continue; + + int def_roll = (targets[i].def_level + 8) * (targets[i].magic_def_bonus + 64); + float chance = osrs_hit_chance(att_roll, def_roll); + targets[i].hit = encounter_rand_float(rng_state) < chance; + targets[i].damage = targets[i].hit ? encounter_rand_int(rng_state, max_hit + 1) : 0; + result.total_damage += targets[i].damage; + result.num_hits++; + if (targets[i].hit) { + result.num_successful++; + if (spell_type == 1 /* ENCOUNTER_SPELL_ICE */ && targets[i].frozen_ticks) + *targets[i].frozen_ticks = BARRAGE_FREEZE_TICKS; + } + } + + return result; +} + +/* ======================================================================== */ +/* NPC combat formulas (from InfernoTrainer/osrs-sdk) */ +/* ======================================================================== */ + +/* NPC melee max hit: floor((str + 8) * (melee_str_bonus + 64) + 320) / 640) */ +static inline int osrs_npc_melee_max_hit(int str_level, int melee_str_bonus) { + return ((str_level + 8) * (melee_str_bonus + 64) + 320) / 640; +} + +/* NPC ranged max hit: floor(0.5 + (range + 8) * (ranged_str_bonus + 64) / 640) */ +static inline int osrs_npc_ranged_max_hit(int range_level, int ranged_str_bonus) { + return (int)(0.5 + (double)(range_level + 8) * (ranged_str_bonus + 64) / 640.0); +} + +/* NPC magic max hit: floor(base_spell_dmg * magic_dmg_pct / 100). + magic_dmg_pct=100 means 1.0x multiplier, 175 means 1.75x. */ +static inline int osrs_npc_magic_max_hit(int base_spell_dmg, int magic_dmg_pct) { + return base_spell_dmg * magic_dmg_pct / 100; +} + +/* NPC attack roll: (att_level + 9) * (att_bonus + 64). + NPCs don't have prayer or void bonuses — just level + invisible +9. */ +static inline int osrs_npc_attack_roll(int att_level, int att_bonus) { + return (att_level + 9) * (att_bonus + 64); +} + +/* player defence roll against NPC attack. + OSRS formula: eff_def = level + stance_bonus + 8. players don't have the + hidden +1 that NPCs get (that's why NPC attack roll uses +9). + our sim doesn't model stance bonuses, so stance_bonus = 0. + vs melee/ranged: (def_level + 8) * (def_bonus + 64). + vs magic: (floor(magic_level * 0.7 + def_level * 0.3) + 8) * (def_bonus + 64). + ref: osrs-sdk MeleeWeapon.ts:164, OSRS wiki combat formulas. */ +static inline int osrs_player_def_roll_vs_npc( + int def_level, int magic_level, int def_bonus, int attack_style +) { + int eff_def; + if (attack_style == 3) { /* ATTACK_STYLE_MAGIC = 3 */ + eff_def = (int)(magic_level * 0.7 + def_level * 0.3) + 8; + } else { + eff_def = def_level + 8; + } + return eff_def * (def_bonus + 64); +} + +/* NPC max hit by style: dispatches to melee/ranged/magic formula. + for magic, uses magic_base_dmg * magic_dmg_pct / 100. */ +static inline int osrs_npc_max_hit( + int attack_style, + int str_level, int range_level, + int melee_str_bonus, int ranged_str_bonus, + int magic_base_dmg, int magic_dmg_pct +) { + if (attack_style == 1) /* ATTACK_STYLE_MELEE = 1 */ + return osrs_npc_melee_max_hit(str_level, melee_str_bonus); + if (attack_style == 2) /* ATTACK_STYLE_RANGED = 2 */ + return osrs_npc_ranged_max_hit(range_level, ranged_str_bonus); + if (attack_style == 3) /* ATTACK_STYLE_MAGIC = 3 */ + return osrs_npc_magic_max_hit(magic_base_dmg, magic_dmg_pct); + return 0; +} + +/* NPC attack roll: accuracy check + damage roll in one call. + returns damage (0 on miss). caller handles prayer separately. */ +static inline int encounter_npc_roll_attack( + int att_roll, int def_roll, int max_hit, uint32_t* rng_state +) { + int dmg = encounter_rand_int(rng_state, max_hit + 1); + if (encounter_rand_float(rng_state) >= osrs_hit_chance(att_roll, def_roll)) + dmg = 0; + return dmg; +} + +/* check if overhead prayer blocks the given attack style. + uses int values directly: ATTACK_STYLE_MELEE(1) matches PRAYER_PROTECT_MELEE(3), etc. + prayer enum: NONE=0, MAGIC=1, RANGED=2, MELEE=3. + attack style enum: NONE=0, MELEE=1, RANGED=2, MAGIC=3. + mapping: melee attack(1)->protect melee(3), ranged(2)->protect ranged(2), magic(3)->protect magic(1). */ +static inline int encounter_prayer_correct_for_style(int prayer, int attack_style) { + return (attack_style == 1 /* ATTACK_STYLE_MELEE */ && prayer == 3 /* PRAYER_PROTECT_MELEE */) || + (attack_style == 2 /* ATTACK_STYLE_RANGED */ && prayer == 2 /* PRAYER_PROTECT_RANGED */) || + (attack_style == 3 /* ATTACK_STYLE_MAGIC */ && prayer == 1 /* PRAYER_PROTECT_MAGIC */); +} + +/* ======================================================================== */ +/* hit delay formulas (matching PvP + InfernoTrainer SDK) */ +/* ======================================================================== */ + +/* magic hit delay: floor((1 + distance) / 3) + 1, +1 if attacker is player */ +static inline int encounter_magic_hit_delay(int distance, int is_player) { + return (1 + distance) / 3 + 1 + (is_player ? 1 : 0); +} + +/* ranged hit delay: floor((3 + distance) / 6) + 1, +1 if attacker is player */ +static inline int encounter_ranged_hit_delay(int distance, int is_player) { + return (3 + distance) / 6 + 1 + (is_player ? 1 : 0); +} + +/* blowpipe hit delay: floor(distance / 6) + 1, +1 if attacker is player. + blowpipe overrides the generic ranged formula — faster at longer range. + ref: InfernoTrainer Blowpipe.ts:56-58. */ +static inline int encounter_blowpipe_hit_delay(int distance, int is_player) { + return distance / 6 + 1 + (is_player ? 1 : 0); +} + +/* blowpipe special attack: 2x accuracy, 1.5x max hit. + ref: osrs-sdk Blowpipe.ts _accuracyMultiplier=2, _damageMultiplier=1.5. + returns damage dealt. caller applies heal (50% of dmg) and queues pending hit. */ +#define BLOWPIPE_SPEC_ACC_MULT 2 +#define BLOWPIPE_SPEC_DMG_NUM 3 /* 1.5x = 3/2 */ +#define BLOWPIPE_SPEC_DMG_DEN 2 +#define BLOWPIPE_SPEC_HEAL_PCT 50 +#define BLOWPIPE_SPEC_COST 50 + +static inline int osrs_blowpipe_spec_resolve( + int base_att_roll, int base_max_hit, + int target_def_level, int target_ranged_def_bonus, + uint32_t* rng_state +) { + int att_roll = base_att_roll * BLOWPIPE_SPEC_ACC_MULT; + int def_roll = (target_def_level + 8) * (target_ranged_def_bonus + 64); + int spec_max = base_max_hit * BLOWPIPE_SPEC_DMG_NUM / BLOWPIPE_SPEC_DMG_DEN; + if (encounter_rand_float(rng_state) < osrs_hit_chance(att_roll, def_roll)) + return encounter_rand_int(rng_state, spec_max + 1); + return 0; +} + +/* chebyshev distance from point (px,py) to nearest tile of NPC footprint + at (nx,ny) with given npc_size. accounts for multi-tile NPCs. */ +static inline int encounter_dist_to_npc(int px, int py, int nx, int ny, int npc_size) { + int cx = px < nx ? nx : (px > nx + npc_size - 1 ? nx + npc_size - 1 : px); + int cy = py < ny ? ny : (py > ny + npc_size - 1 ? ny + npc_size - 1 : py); + int dx = px - cx; if (dx < 0) dx = -dx; + int dy = py - cy; if (dy < 0) dy = -dy; + return dx > dy ? dx : dy; +} + +/* fisher-yates shuffle for int arrays. used for spawn position randomization, + snakeling placement, etc. encounters should use this instead of inlining. */ +static inline void encounter_shuffle(int* arr, int n, uint32_t* rng) { + for (int i = n - 1; i > 0; i--) { + int j = encounter_rand_int(rng, i + 1); + int tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; + } +} + +#endif /* OSRS_COMBAT_SHARED_H */ diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h new file mode 100644 index 0000000000..0151eae7af --- /dev/null +++ b/ocean/osrs/osrs_encounter.h @@ -0,0 +1,1420 @@ +/** + * @fileoverview osrs_encounter.h — shared encounter interface and core game mechanics. + * + * this is the single source of truth for shared OSRS mechanics. all encounters + * MUST use these abstractions instead of reimplementing their own. adding a + * new encounter = one header file that calls into these shared systems. + * + * SHARED SYSTEMS (in order of appearance in this file): + * + * rendering: + * RenderEntity value struct for renderer (not Player*) + * render_entity_from_player() copy Player fields to RenderEntity + * encounter_resolve_attack_target() match npc_slot to render entity index + * EncounterOverlay visual overlay (clouds, projectiles, boss) + * + * prayer: + * ENCOUNTER_PRAYER_* canonical 5-value prayer action encoding + * encounter_apply_prayer_action() apply prayer action to OverheadPrayer state + * + * movement: + * ENCOUNTER_MOVE_TARGET_DX/DY[25] direction tables (idle + 8 walk + 16 run) + * encounter_move_to_target() player movement: walk 1 tile or run 2 + * encounter_move_toward_dest() BFS click-to-move toward destination + * encounter_pathfind() shared BFS pathfind wrapper + * + * NPC pathfinding: + * encounter_npc_step_out_from_under() shuffle NPC off player tile (OSRS overlap rule) + * encounter_npc_step_toward() greedy 1-tile step (diagonal > x > y) + * + * damage: + * encounter_damage_player() apply damage to player (HP, clamp, splat, tracker) + * encounter_damage_npc() apply damage to NPC (HP, splat flags) + * + * per-tick flags: + * encounter_clear_tick_flags() reset animation/event flags each tick + * + * gear switching: + * encounter_apply_loadout() memcpy loadout + set gear state + * encounter_populate_inventory() dedup items from multiple loadouts for GUI + * + * combat stats: + * EncounterLoadoutStats derived stats (att bonus, max hit, eff level...) + * EncounterPrayer prayer multiplier enum + * encounter_compute_loadout_stats() derive all stats from ITEM_DATABASE + loadout + * + * hit delays: + * EncounterPendingHit queued damage with tick countdown + * + * ALSO SEE: + * osrs_combat_shared.h hit chance, tbow formula, barrage AoE, delay formulas + * osrs_pvp_combat.h PvP-specific damage (prayer, veng, recoil, smite) + */ + +#ifndef OSRS_ENCOUNTER_H +#define OSRS_ENCOUNTER_H + +#include +#include +#include "osrs_types.h" +#include "osrs_items.h" +#include "osrs_pathfinding.h" +#include "osrs_combat_shared.h" +#include "osrs_pvp_human_input_types.h" + +/* opaque encounter state — each encounter defines its own struct */ +typedef struct EncounterState EncounterState; + +/* ======================================================================== */ +/* shared pending hit system for delayed projectile damage */ +/* ======================================================================== */ + +#define ENCOUNTER_MAX_PENDING_HITS 8 + +/* spell types for barrage freeze/heal effects on pending hits */ +#define ENCOUNTER_SPELL_NONE 0 +#define ENCOUNTER_SPELL_ICE 1 /* ice barrage: freeze on hit */ +#define ENCOUNTER_SPELL_BLOOD 2 /* blood barrage: heal 25% of AoE damage */ + +typedef struct { + int active; + int damage; + int ticks_remaining; /* countdown to landing */ + int attack_style; /* ATTACK_STYLE_* for prayer check at land time */ + int check_prayer; /* 1 = re-check prayer when hit lands (jad) */ + int spell_type; /* ENCOUNTER_SPELL_* for freeze/heal effects */ +} EncounterPendingHit; + +/* visual overlay data: shared between encounter and renderer. + encounter's render_post_tick populates this, renderer reads it. */ +#define ENCOUNTER_MAX_OVERLAY_TILES 16 +#define ENCOUNTER_MAX_OVERLAY_SNAKES 4 +#define ENCOUNTER_MAX_OVERLAY_PROJECTILES 8 + +typedef struct { + /* venom clouds */ + struct { int x, y, active; } clouds[ENCOUNTER_MAX_OVERLAY_TILES]; + int cloud_count; + + /* boss state */ + int boss_x, boss_y, boss_visible; + int boss_form; /* encounter-specific form/phase index */ + int boss_size; /* NPC size in tiles (e.g. 5 for Zulrah) */ + + /* snakelings / adds */ + struct { int x, y, active, is_magic; } snakelings[ENCOUNTER_MAX_OVERLAY_SNAKES]; + int snakeling_count; + + /* visual projectiles: brief flash from source to target. + encounters fire attacks instantly, but we show a 1-tick projectile + for visual clarity. the renderer draws these and auto-expires them. */ + struct { + int active; + int src_x, src_y; /* source tile (e.g. Zulrah position) */ + int dst_x, dst_y; /* target tile (e.g. player position) */ + int style; /* 0=ranged, 1=magic, 2=melee, 3=cloud, 4=spawn_orb */ + int damage; /* for hit splat at destination */ + /* flight parameters — encounters set these, renderer reads them */ + int duration_ticks; /* flight duration in client ticks (0 = use default 35) */ + int start_h; /* start height in OSRS units /128 (0 = use default) */ + int end_h; /* end height in OSRS units /128 (0 = use default) */ + int curve; /* OSRS slope param (0 = use default 16) */ + float arc_height; /* sinusoidal arc peak in tiles (0 = quadratic/straight) */ + int tracks_target; /* 1 = re-aim toward target each tick */ + int src_size; /* source entity size for center offset (0 = use boss_size) */ + int dst_size; /* target entity size for center offset (1 = player) */ + uint32_t model_id; /* GFX model from cache (0 = style-based fallback) */ + } projectiles[ENCOUNTER_MAX_OVERLAY_PROJECTILES]; + int projectile_count; + + /* melee targeting: shows which tile Zulrah is staring at */ + int melee_target_active; + int melee_target_x, melee_target_y; +} EncounterOverlay; + +/* map AttackStyle enum to overlay projectile style index. + used by encounter_emit_projectile and render overlay systems. */ +static inline int encounter_attack_style_to_proj_style(int attack_style) { + switch (attack_style) { + case ATTACK_STYLE_RANGED: return 0; + case ATTACK_STYLE_MAGIC: return 1; + case ATTACK_STYLE_MELEE: return 2; + default: return 0; + } +} + +/* populate an overlay projectile slot with flight parameters. + encounters should call this instead of filling fields manually. */ +static inline int encounter_emit_projectile( + EncounterOverlay* ov, + int src_x, int src_y, int dst_x, int dst_y, + int style, int damage, + int duration_ticks, int start_h, int end_h, int curve, + float arc_height, int tracks_target, int src_size, int dst_size, + uint32_t model_id +) { + if (ov->projectile_count >= ENCOUNTER_MAX_OVERLAY_PROJECTILES) return -1; + int i = ov->projectile_count++; + ov->projectiles[i].active = 1; + ov->projectiles[i].src_x = src_x; + ov->projectiles[i].src_y = src_y; + ov->projectiles[i].dst_x = dst_x; + ov->projectiles[i].dst_y = dst_y; + ov->projectiles[i].style = style; + ov->projectiles[i].damage = damage; + ov->projectiles[i].duration_ticks = duration_ticks; + ov->projectiles[i].start_h = start_h; + ov->projectiles[i].end_h = end_h; + ov->projectiles[i].curve = curve; + ov->projectiles[i].arc_height = arc_height; + ov->projectiles[i].tracks_target = tracks_target; + ov->projectiles[i].src_size = src_size; + ov->projectiles[i].dst_size = dst_size; + ov->projectiles[i].model_id = model_id; + return i; +} + +/* ======================================================================== */ +/* render entity: shared abstraction for renderer (value type, not pointer) */ +/* ======================================================================== */ + +typedef struct { + EntityType entity_type; + int npc_def_id; + int npc_visible; + int npc_size; + int npc_anim_id; + int x, y; + int dest_x, dest_y; + int current_hitpoints, base_hitpoints; + int special_energy; + OverheadPrayer prayer; + GearSet visible_gear; + int frozen_ticks; + int veng_active; + int is_running; + AttackStyle attack_style_this_tick; + int magic_type_this_tick; + int hit_landed_this_tick; + int hit_damage; + int hit_was_successful; + int hit_spell_type; /* ENCOUNTER_SPELL_* for barrage impact effects on NPCs */ + int cast_veng_this_tick; + int ate_food_this_tick; + int ate_karambwan_this_tick; + int used_special_this_tick; + uint8_t equipped[NUM_GEAR_SLOTS]; + int npc_slot; /* source slot index in encounter's NPC array; -1 for player */ + int attack_target_entity_idx; /* render entity index of attack target, -1 = none */ +} RenderEntity; + +/** Fill a RenderEntity from a Player struct (PvP, Zulrah, snakelings). */ +static inline void render_entity_from_player(const Player* p, RenderEntity* out) { + out->entity_type = p->entity_type; + out->npc_def_id = p->npc_def_id; + out->npc_visible = p->npc_visible; + out->npc_size = p->npc_size; + out->npc_anim_id = p->npc_anim_id; + out->x = p->x; + out->y = p->y; + out->dest_x = p->dest_x; + out->dest_y = p->dest_y; + out->current_hitpoints = p->current_hitpoints; + out->base_hitpoints = p->base_hitpoints; + out->special_energy = p->special_energy; + out->prayer = p->prayer; + out->visible_gear = p->visible_gear; + out->frozen_ticks = p->frozen_ticks; + out->veng_active = p->veng_active; + out->is_running = p->is_running; + out->attack_style_this_tick = p->attack_style_this_tick; + out->magic_type_this_tick = p->magic_type_this_tick; + out->hit_landed_this_tick = p->hit_landed_this_tick; + out->hit_damage = p->hit_damage; + out->hit_was_successful = p->hit_was_successful; + out->cast_veng_this_tick = p->cast_veng_this_tick; + out->ate_food_this_tick = p->ate_food_this_tick; + out->ate_karambwan_this_tick = p->ate_karambwan_this_tick; + out->used_special_this_tick = p->used_special_this_tick; + memcpy(out->equipped, p->equipped, NUM_GEAR_SLOTS); + out->npc_slot = -1; /* player, not an NPC */ + out->attack_target_entity_idx = -1; +} + +/** Resolve attack_target_entity_idx for entity 0 (player) by matching npc_slot. + call after fill_render_entities populates the entity array. any encounter with + NPC targeting should call this so the renderer faces the correct target. */ +static inline void encounter_resolve_attack_target( + RenderEntity* entities, int count, int target_npc_slot +) { + entities[0].attack_target_entity_idx = -1; + if (target_npc_slot < 0) return; + for (int i = 1; i < count; i++) { + if (entities[i].npc_slot == target_npc_slot) { + entities[0].attack_target_entity_idx = i; + return; + } + } +} + +/* ======================================================================== */ +/* canonical prayer action encoding */ +/* ======================================================================== */ + +/* all encounters MUST use this encoding for the prayer action head. + 0 = no change (prayer persists from previous tick) + 1 = turn off prayer (PRAYER_NONE) + 2 = protect melee + 3 = protect ranged + 4 = protect magic + action dim = 5 for any encounter using this encoding. */ +#define ENCOUNTER_PRAYER_NO_CHANGE 0 +#define ENCOUNTER_PRAYER_OFF 1 +#define ENCOUNTER_PRAYER_MELEE 2 +#define ENCOUNTER_PRAYER_RANGED 3 +#define ENCOUNTER_PRAYER_MAGIC 4 +#define ENCOUNTER_PRAYER_DIM 5 + +/* apply a prayer action to the active prayer state. 0=no change. */ +static inline void encounter_apply_prayer_action(OverheadPrayer* prayer, int action) { + switch (action) { + case ENCOUNTER_PRAYER_NO_CHANGE: break; + case ENCOUNTER_PRAYER_OFF: *prayer = PRAYER_NONE; break; + case ENCOUNTER_PRAYER_MELEE: *prayer = PRAYER_PROTECT_MELEE; break; + case ENCOUNTER_PRAYER_RANGED: *prayer = PRAYER_PROTECT_RANGED; break; + case ENCOUNTER_PRAYER_MAGIC: *prayer = PRAYER_PROTECT_MAGIC; break; + } +} + +/* ======================================================================== */ +/* shared movement: 25-action system (idle + 8 walk + 16 run) */ +/* ======================================================================== */ + +/* 25 movement actions: idle(0), walk(1-8), run(9-24) */ +#define ENCOUNTER_MOVE_ACTIONS 25 + +/* target offsets: (dx, dy) relative to player position */ +static const int ENCOUNTER_MOVE_TARGET_DX[25] = { + 0, /* 0: idle */ + -1, -1, -1, 0, 0, 1, 1, 1, /* 1-8: walk (dist 1) */ + -2, -2, -2, -2, -2, /* 9-13: run west edge */ + -1, -1, /* 14-15: run inner */ + 0, 0, /* 16-17: run N/S 2 tiles */ + 1, 1, /* 18-19: run inner */ + 2, 2, 2, 2, 2 /* 20-24: run east edge */ +}; +static const int ENCOUNTER_MOVE_TARGET_DY[25] = { + 0, + -1, 0, 1, -1, 1, -1, 0, 1, + -2, -1, 0, 1, 2, + -2, 2, + -2, 2, + -2, 2, + -2, -1, 0, 1, 2 +}; + +/* callback: returns 1 if tile (x, y) is walkable for the encounter. + ctx is encounter-specific state (InfernoState*, ZulrahState*, etc.) */ +typedef int (*encounter_walkable_fn)(void* ctx, int x, int y); + +/** move player toward target offset via up to 2 greedy steps. + walk actions (dist 1) take 1 step, run actions (dist 2) take up to 2. + sets is_running = 1 if 2 steps were taken. + returns number of tiles moved (0, 1, or 2). */ +static inline int encounter_move_to_target( + Player* p, int target_dx, int target_dy, + encounter_walkable_fn is_walkable, void* ctx +) { + int tx = p->x + target_dx; + int ty = p->y + target_dy; + int dist = abs(target_dx) > abs(target_dy) ? abs(target_dx) : abs(target_dy); + int max_steps = dist; /* 1 for walk, 2 for run */ + int steps = 0; + + for (int step = 0; step < max_steps; step++) { + if (p->x == tx && p->y == ty) break; + /* greedy step toward target */ + int dx = 0, dy = 0; + if (tx > p->x) dx = 1; else if (tx < p->x) dx = -1; + if (ty > p->y) dy = 1; else if (ty < p->y) dy = -1; + + /* try diagonal, x-only, y-only */ + int moved = 0; + if (dx != 0 && dy != 0 && is_walkable(ctx, p->x + dx, p->y + dy)) { + p->x += dx; p->y += dy; moved = 1; + } else if (dx != 0 && is_walkable(ctx, p->x + dx, p->y)) { + p->x += dx; moved = 1; + } else if (dy != 0 && is_walkable(ctx, p->x, p->y + dy)) { + p->y += dy; moved = 1; + } + if (!moved) break; + steps++; + } + + p->is_running = (steps == 2); + p->dest_x = p->x; + p->dest_y = p->y; + return steps; +} + +/* ======================================================================== */ +/* shared BFS click-to-move (human mode + destination-based movement) */ +/* ======================================================================== */ + +/* shared BFS pathfind wrapper — translates local coords to world coords for pathfind_step. + extra_blocked/blocked_ctx: optional callback for dynamic obstacles (pillars etc.). + pass NULL/NULL for encounters with no dynamic obstacles. */ +static inline PathResult encounter_pathfind( + const CollisionMap* cmap, int world_offset_x, int world_offset_y, + int src_x, int src_y, int dst_x, int dst_y, + pathfind_blocked_fn extra_blocked, void* blocked_ctx +) { + /* always run BFS, even without a collision map. when cmap is NULL, + pathfind_step treats all static tiles as traversable — dynamic obstacles + (pillars) are still handled by extra_blocked. matches real OSRS where + pillars are entities checked separately from the static collision map. */ + return pathfind_step(cmap, 0, + src_x + world_offset_x, src_y + world_offset_y, + dst_x + world_offset_x, dst_y + world_offset_y, + extra_blocked, blocked_ctx); +} + +/* shared click-to-move: BFS toward destination, take up to 2 steps (run). + call each tick when player_dest is set. clears dest when arrived. + extra_blocked/blocked_ctx: optional dynamic obstacle callback for BFS. + returns steps taken (0, 1, or 2). */ +static inline int encounter_move_toward_dest( + Player* p, int* dest_x, int* dest_y, + const CollisionMap* cmap, int world_offset_x, int world_offset_y, + encounter_walkable_fn is_walkable, void* ctx, + pathfind_blocked_fn extra_blocked, void* blocked_ctx +) { + if (*dest_x < 0 || *dest_y < 0) return 0; + if (p->x == *dest_x && p->y == *dest_y) { + *dest_x = -1; *dest_y = -1; + return 0; + } + int steps = 0; + for (int step = 0; step < 2; step++) { + if (p->x == *dest_x && p->y == *dest_y) break; + PathResult pr = encounter_pathfind(cmap, world_offset_x, world_offset_y, + p->x, p->y, *dest_x, *dest_y, + extra_blocked, blocked_ctx); + if (!pr.found || (pr.next_dx == 0 && pr.next_dy == 0)) break; + int nx = p->x + pr.next_dx, ny = p->y + pr.next_dy; + if (!is_walkable(ctx, nx, ny)) break; + p->x = nx; p->y = ny; + steps++; + } + p->is_running = (steps == 2); + p->dest_x = p->x; p->dest_y = p->y; + return steps; +} + +/* ======================================================================== */ +/* shared attack-target chase (auto-walk toward out-of-range NPC) */ +/* ======================================================================== */ + +/* check if player can attack: in range AND has LOS (if blockers present). + returns 1 if ready to attack, 0 if blocked or out of range. + encounters without LOS blockers pass NULL/0 for unconditional range check. */ +static inline int encounter_player_can_attack( + int player_x, int player_y, + int target_x, int target_y, int target_size, int attack_range, + const LOSBlocker* los_blockers, int los_blocker_count +) { + int dist = encounter_dist_to_npc(player_x, player_y, target_x, target_y, target_size); + if (dist < 1 || dist > attack_range) return 0; + if (!los_blockers || los_blocker_count == 0) return 1; + return npc_has_line_of_sight(los_blockers, los_blocker_count, + target_x, target_y, target_size, + player_x, player_y, attack_range); +} + +/* auto-walk toward attack target: handles out-of-range, blocked LOS, and under-NPC. + OSRS: player pathfinds toward NPC every tick until in weapon range AND has LOS. + when player is under the NPC (dist=0), scans for nearest walkable tile outside + the NPC footprint and pathfinds there. + ref: InfernoTrainer Player.ts determineDestination + attackIfPossible. + los_blockers/los_blocker_count: LOS blocking entities (pillars). NULL/0 = no LOS check. + returns 1 if player moved (chasing), 0 if ready to attack or stuck. */ +static inline int encounter_chase_attack_target( + Player* p, int target_x, int target_y, int target_size, int attack_range, + const CollisionMap* cmap, int world_offset_x, int world_offset_y, + encounter_walkable_fn is_walkable, void* ctx, + pathfind_blocked_fn extra_blocked, void* blocked_ctx, + const LOSBlocker* los_blockers, int los_blocker_count +) { + int dist = encounter_dist_to_npc(p->x, p->y, target_x, target_y, target_size); + + /* player under NPC (dist=0): walk to nearest tile outside NPC footprint. + ref: InfernoTrainer Player.ts:447-468 isUnderAggrodMob. */ + if (dist == 0) { + int max_r = (target_size + 1) / 2 + 1; + int best_dsq = 9999, bx = -1, by = -1; + for (int dy = -max_r; dy <= max_r; dy++) { + for (int dx = -max_r; dx <= max_r; dx++) { + if (dx == 0 && dy == 0) continue; + int nx = p->x + dx, ny = p->y + dy; + if (!is_walkable(ctx, nx, ny)) continue; + if (nx >= target_x && nx < target_x + target_size && + ny >= target_y && ny < target_y + target_size) continue; + int d = dx * dx + dy * dy; + if (d < best_dsq) { best_dsq = d; bx = nx; by = ny; } + } + } + if (bx < 0) return 0; + int steps = 0; + for (int step = 0; step < 2; step++) { + if (p->x == bx && p->y == by) break; + PathResult pr = encounter_pathfind(cmap, world_offset_x, world_offset_y, + p->x, p->y, bx, by, + extra_blocked, blocked_ctx); + if (!pr.found || (pr.next_dx == 0 && pr.next_dy == 0)) break; + int nx = p->x + pr.next_dx, ny = p->y + pr.next_dy; + if (!is_walkable(ctx, nx, ny)) break; + p->x = nx; p->y = ny; + steps++; + } + p->is_running = (steps == 2); + p->dest_x = p->x; p->dest_y = p->y; + return steps > 0 ? 1 : 0; + } + + /* in range + LOS: ready to attack, no movement needed */ + if (encounter_player_can_attack(p->x, p->y, target_x, target_y, + target_size, attack_range, + los_blockers, los_blocker_count)) + return 0; + + /* pathfind target selection: when in range but LOS blocked by a pillar, + seek nearest melee-adjacent tile around the NPC that isn't inside a blocker. + ref: InfernoTrainer Player.ts:469-504 seekingTiles. + when out of range, path toward closest NPC tile (standard OSRS behavior). + the per-step can_attack check (below) stops the player as soon as LOS + range. */ + int cx, cy; + int dist_now = encounter_dist_to_npc(p->x, p->y, target_x, target_y, target_size); + if (dist_now > 0 && dist_now <= attack_range && + los_blockers && los_blocker_count > 0) { + /* in range but no LOS — scan NPC-adjacent tiles that have ACTUAL LOS to + the NPC. only tiles where encounter_player_can_attack would return true + are valid candidates. BFS then pathfinds to the nearest one. + ref: osrs-sdk Player.ts "seekingTiles" — filters by LOS, not just pillar overlap. */ + int best_dsq = 999999; + cx = -1; cy = -1; + /* scan cardinal-adjacent tiles (N/S rows + E/W columns of NPC footprint) */ + for (int xx = -1; xx <= target_size; xx++) { + for (int yy = -1; yy <= target_size; yy++) { + /* skip interior tiles (inside NPC footprint) */ + if (xx >= 0 && xx < target_size && yy >= 0 && yy < target_size) continue; + /* skip far corners (only cardinal adjacency matters for melee/range) */ + int px = target_x + xx; + int py = target_y + yy; + if (!is_walkable(ctx, px, py)) continue; + /* check if this tile has actual LOS + range to the NPC */ + if (!encounter_player_can_attack(px, py, target_x, target_y, + target_size, attack_range, los_blockers, los_blocker_count)) + continue; + int ddx = px - p->x, ddy = py - p->y; + int dsq = ddx * ddx + ddy * ddy; + if (dsq < best_dsq) { best_dsq = dsq; cx = px; cy = py; } + } + } + /* fallback: no unblocked adjacent tile, path toward closest NPC tile */ + if (cx < 0) { + cx = p->x < target_x ? target_x : + (p->x > target_x + target_size - 1 ? target_x + target_size - 1 : p->x); + cy = p->y < target_y ? target_y : + (p->y > target_y + target_size - 1 ? target_y + target_size - 1 : p->y); + } + } else { + /* out of range: path toward closest NPC tile */ + cx = p->x < target_x ? target_x : + (p->x > target_x + target_size - 1 ? target_x + target_size - 1 : p->x); + cy = p->y < target_y ? target_y : + (p->y > target_y + target_size - 1 ? target_y + target_size - 1 : p->y); + } + + int steps = 0; + for (int step = 0; step < 2; step++) { + if (encounter_player_can_attack(p->x, p->y, target_x, target_y, + target_size, attack_range, + los_blockers, los_blocker_count)) + break; + PathResult pr = encounter_pathfind(cmap, world_offset_x, world_offset_y, + p->x, p->y, cx, cy, + extra_blocked, blocked_ctx); + if (!pr.found || (pr.next_dx == 0 && pr.next_dy == 0)) break; + int nx = p->x + pr.next_dx, ny = p->y + pr.next_dy; + if (!is_walkable(ctx, nx, ny)) break; + p->x = nx; p->y = ny; + steps++; + } + p->is_running = (steps == 2); + p->dest_x = p->x; p->dest_y = p->y; + return steps > 0 ? 1 : 0; +} + +/* ======================================================================== */ +/* shared NPC step-out-from-under (OSRS: NPC shuffles off player tile) */ +/* ======================================================================== */ + +/* when an NPC overlaps the player (AABB overlap), it shuffles one tile in a + random cardinal direction. matches osrs-sdk Mob.ts:109-153 behavior: + 50% pick X-axis vs Y-axis, then 50% +1 or -1 on that axis. + returns 1 if the NPC moved, 0 if stuck or no overlap. */ +static inline int encounter_npc_step_out_from_under( + int* npc_x, int* npc_y, int npc_size, + int player_x, int player_y, + encounter_walkable_fn is_walkable, void* ctx, uint32_t* rng +) { + /* AABB overlap check (handles multi-tile NPCs) */ + int overlap = !(*npc_x >= player_x + 1 || *npc_x + npc_size <= player_x || + *npc_y >= player_y + 1 || *npc_y + npc_size <= player_y); + if (!overlap) return 0; + + /* 4 cardinal directions: +x, -x, +y, -y */ + int dirs[4][2] = {{1,0}, {-1,0}, {0,1}, {0,-1}}; + + /* random start: 50% X-axis first (dirs 0,1) vs Y-axis first (dirs 2,3), + then 50% positive vs negative on that axis */ + int axis = encounter_rand_int(rng, 2); /* 0=X, 1=Y */ + int sign = encounter_rand_int(rng, 2); /* 0=positive, 1=negative */ + int order[4]; + order[0] = axis * 2 + sign; /* primary: chosen axis+sign */ + order[1] = axis * 2 + (1 - sign); /* secondary: chosen axis, other sign */ + order[2] = (1 - axis) * 2 + sign; /* tertiary: other axis, same sign */ + order[3] = (1 - axis) * 2 + (1 - sign); /* last: other axis, other sign */ + + for (int i = 0; i < 4; i++) { + int nx = *npc_x + dirs[order[i]][0]; + int ny = *npc_y + dirs[order[i]][1]; + /* InfernoTrainer Mob.ts:128-142: 1-tile shuffle per tick, validated + via normal edge-tile movement system. for size>1 NPCs, full escape + takes multiple ticks. anchor walkability matches InfernoTrainer's + canTileBePathedTo check on the leading edge. */ + if (is_walkable(ctx, nx, ny)) { + *npc_x = nx; + *npc_y = ny; + return 1; + } + } + return 0; +} + +/* ======================================================================== */ +/* shared NPC greedy pathfinding */ +/* ======================================================================== */ + +/* callback: returns 1 if tile (x, y) is blocked for an NPC of given size */ +typedef int (*encounter_npc_blocked_fn)(void* ctx, int x, int y, int size); + +/** check if the leading edge tiles are clear for an NPC moving in direction (dx, dy). + for size>1 NPCs, OSRS checks the tiles along the leading edge that the NPC + sweeps through — not the full destination footprint. for diagonal moves, each + edge strip extends by 1 tile to cover the corner. + ref: InfernoTrainer Mob.ts:229-270 getXMovementTiles/getYMovementTiles. + is_blocked is called with size=1 for each individual edge tile. */ +static inline int encounter_npc_x_edge_clear( + int x, int y, int size, int dx, int dy, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + if (dx == 0) return 1; + int ex = (dx == 1) ? x + size : x - 1; + int y_start = (dy == -1) ? y - 1 : y; + int y_end = (dy == 1) ? y + size : y + size - 1; + for (int ey = y_start; ey <= y_end; ey++) + if (is_blocked(ctx, ex, ey, 1)) return 0; + return 1; +} + +static inline int encounter_npc_y_edge_clear( + int x, int y, int size, int dx, int dy, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + if (dy == 0) return 1; + int ey = (dy == 1) ? y + size : y - 1; + int x_start = (dx == -1) ? x - 1 : x; + int x_end = (dx == 1) ? x + size : x + size - 1; + for (int ex = x_start; ex <= x_end; ex++) + if (is_blocked(ctx, ex, ey, 1)) return 0; + return 1; +} + +/** greedy NPC step toward target. tries diagonal first, then x-only, then y-only. + this is the standard OSRS NPC movement algorithm — 99.9% of NPCs use this. + + for size>1 NPCs, validates movement by checking EDGE TILES the NPC sweeps + through, not just the destination footprint. for diagonal moves, both the + x-edge and y-edge must be clear (each extended by 1 tile for the corner). + ref: InfernoTrainer Mob.ts:160-270 movementStep + getX/YMovementTiles. + + corner safespot: if diagonal would land NPC on player, cancel Y component. + ref: InfernoTrainer Mob.ts:143-146. + + returns 1 if moved, 0 if blocked or already at target. */ +static inline int encounter_npc_step_toward( + int* x, int* y, int tx, int ty, int npc_size, + int target_size, int attack_range, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + /* stop if NPC's nearest footprint edge is within attack range of target. + measure from target TO NPC footprint (not from NPC SW corner to target), + so multi-tile NPCs stop when their edge is adjacent. */ + int dist = encounter_dist_to_npc(tx, ty, *x, *y, npc_size); + if (dist >= 1 && dist <= attack_range) return 0; + + int size = npc_size; + int dx = 0, dy = 0; + if (tx > *x) dx = 1; + else if (tx < *x) dx = -1; + if (ty > *y) dy = 1; + else if (ty < *y) dy = -1; + if (dx == 0 && dy == 0) return 0; + + /* corner safespot cancellation: if a diagonal step would place the NPC + on top of the target (player), cancel the Y component and take X-only. + this enables pillar corner safespotting in inferno. + ref: InfernoTrainer Mob.ts:143-146. */ + if (dx != 0 && dy != 0) { + int nx = *x + dx, ny = *y + dy; + if (tx >= nx && tx < nx + size && ty >= ny && ty < ny + size) { + dy = 0; + } + } + + /* size-1 NPCs: simple destination check (edge tiles = destination tile) */ + if (size <= 1) { + if (dx != 0 && dy != 0 && !is_blocked(ctx, *x + dx, *y + dy, 1)) { + *x += dx; *y += dy; return 1; + } + if (dx != 0 && !is_blocked(ctx, *x + dx, *y, 1)) { + *x += dx; return 1; + } + if (dy != 0 && !is_blocked(ctx, *x, *y + dy, 1)) { + *y += dy; return 1; + } + return 0; + } + + /* size>1 NPCs: edge-tile validation per InfernoTrainer. + diagonal: both x-edge AND y-edge must be clear (each extended by 1 for corner). + cardinal: just the leading edge (size tiles). */ + if (dx != 0 && dy != 0) { + int x_clear = encounter_npc_x_edge_clear(*x, *y, size, dx, dy, is_blocked, ctx); + int y_clear = encounter_npc_y_edge_clear(*x, *y, size, dx, dy, is_blocked, ctx); + if (x_clear && y_clear) { + *x += dx; *y += dy; return 1; + } + /* diagonal failed — fall through to try cardinal with dy=0 edge strips */ + } + /* x-only: check leading x-edge (size tiles, no diagonal extension) */ + if (dx != 0 && encounter_npc_x_edge_clear(*x, *y, size, dx, 0, is_blocked, ctx)) { + *x += dx; return 1; + } + /* y-only: check leading y-edge (size tiles, no diagonal extension) */ + if (dy != 0 && encounter_npc_y_edge_clear(*x, *y, size, 0, dy, is_blocked, ctx)) { + *y += dy; return 1; + } + return 0; +} + +/* ======================================================================== */ +/* shared damage application helpers */ +/* */ +/* ENCOUNTERS: use these instead of manually subtracting HP, clamping, */ +/* and setting hit splat flags. prevents bugs from forgetting a step. */ +/* ======================================================================== */ + +/** apply damage to a player. updates HP (clamped to 0), sets hit splat flags, + and accumulates damage into a per-tick tracker (for reward calculation). + damage_tracker can be NULL if not needed. + always sets hit_landed_this_tick so the renderer shows a splat — + 0 damage produces a blue "miss" splat (standard OSRS behavior). */ +static inline void encounter_damage_player( + Player* p, int damage, float* damage_tracker +) { + if (damage > 0) { + p->current_hitpoints -= damage; + if (p->current_hitpoints < 0) p->current_hitpoints = 0; + if (damage_tracker) *damage_tracker += (float)damage; + } + p->hit_landed_this_tick = 1; + p->hit_damage = damage > 0 ? damage : 0; +} + +/** apply damage to an NPC-like entity via raw field pointers. + works with any struct that has hp/hit_landed/hit_damage int fields. + always sets hit_landed so the renderer shows a splat — + 0 damage produces a blue "miss" splat (standard OSRS behavior). */ +static inline void encounter_damage_npc( + int* hp, int* hit_landed, int* hit_damage, int damage +) { + if (damage > 0) { + *hp -= damage; + } + *hit_landed = 1; + *hit_damage = damage > 0 ? damage : 0; +} + +/* ======================================================================== */ +/* shared NPC pending hit resolution (barrage freeze + blood heal) */ +/* ======================================================================== */ + +/** resolve a single NPC's pending hit. tick down, apply damage when it lands. + ice barrage: sets *frozen_ticks = BARRAGE_FREEZE_TICKS on hit. + blood barrage: accumulates landed damage into *blood_heal_acc for 25% heal. + returns 1 if hit landed this call, 0 otherwise. */ +static inline int encounter_resolve_npc_pending_hit( + EncounterPendingHit* ph, + int* npc_hp, int* hit_landed, int* hit_damage, + int* frozen_ticks, int* blood_heal_acc, float* damage_dealt_acc +) { + if (!ph->active) return 0; + ph->ticks_remaining--; + if (ph->ticks_remaining > 0) return 0; + + /* hit landed */ + int dmg = ph->damage; + encounter_damage_npc(npc_hp, hit_landed, hit_damage, dmg); + if (damage_dealt_acc) *damage_dealt_acc += dmg; + + /* ice barrage freeze: applied at CAST TIME in the encounter (not here at land time). + ref: osrs-sdk IceBarrageSpell.ts — to.freeze() called immediately after attack(). */ + + /* blood barrage: accumulate damage for 25% heal (at land time — heal depends on damage) */ + if (ph->spell_type == ENCOUNTER_SPELL_BLOOD && blood_heal_acc) + *blood_heal_acc += dmg; + + ph->active = 0; + return 1; +} + +/** resolve player pending hits (NPC attacks landing on the player). + ticks down each hit, applies damage when it lands, handles deferred + prayer checks (jad-style: check_prayer=1 re-checks at land time). + encounters MUST call this each tick for projectile-based NPC attacks. + prayer_correct_count: incremented for each deferred prayer check that succeeds. + multiple hits can land on the same tick (e.g. mager + ranger). */ +static inline void encounter_resolve_player_pending_hits( + EncounterPendingHit* hits, int* hit_count, + Player* player, OverheadPrayer active_prayer, + float* damage_received_acc, int* prayer_correct_count +) { + for (int i = 0; i < *hit_count; i++) { + hits[i].ticks_remaining--; + if (hits[i].ticks_remaining <= 0) { + int dmg = hits[i].damage; + if (hits[i].check_prayer) { + if (encounter_prayer_correct_for_style(active_prayer, hits[i].attack_style)) { + dmg = 0; + if (prayer_correct_count) (*prayer_correct_count)++; + } + } + encounter_damage_player(player, dmg, damage_received_acc); + hits[i] = hits[--(*hit_count)]; + i--; + } + } +} + +/* ======================================================================== */ +/* shared per-tick flag clearing for encounters */ +/* ======================================================================== */ + +/** clear all per-tick animation/event flags on a player. + call at the start of each encounter tick, then set flags as events happen. + the renderer reads these once per frame via RenderEntity. */ +static inline void encounter_clear_tick_flags(Player* p) { + p->attack_style_this_tick = ATTACK_STYLE_NONE; + p->magic_type_this_tick = 0; + p->hit_landed_this_tick = 0; + p->hit_damage = 0; + p->hit_was_successful = 0; + p->cast_veng_this_tick = 0; + p->ate_food_this_tick = 0; + p->ate_karambwan_this_tick = 0; + p->used_special_this_tick = 0; +} + +/* ======================================================================== */ +/* shared reset helpers */ +/* ======================================================================== */ + +/** resolve RNG seed for encounter reset. priority: explicit seed > saved state > default. + all encounters MUST use this to ensure consistent RNG initialization. */ +static inline uint32_t encounter_resolve_seed(uint32_t saved_rng, uint32_t explicit_seed) { + uint32_t rng = 12345; + if (saved_rng != 0) rng = saved_rng; + if (explicit_seed != 0) rng = explicit_seed; + return rng; +} + +/* ======================================================================== */ +/* shared prayer drain */ +/* */ +/* ENCOUNTERS: call encounter_drain_prayer() each tick to drain prayer */ +/* points at the correct OSRS rate. all encounters with overhead prayers */ +/* MUST use this — do not hand-roll prayer drain logic. */ +/* */ +/* OSRS drain formula: each prayer has a "drain effect" value. */ +/* drain rate = 1 point per floor((18 + floor(bonus/4)) / drain_effect) */ +/* seconds. the drain counter increments each tick; when it reaches the */ +/* threshold, 1 prayer point is drained and the counter resets. */ +/* */ +/* protection prayers (melee/ranged/magic): drain_effect = 12 */ +/* rigour: drain_effect = 24, augury: drain_effect = 24 */ +/* ======================================================================== */ + +/** drain effect values for overhead prayers. + from the OSRS prayer table — higher values drain faster. + used by both PvE encounters and PvP. */ +static inline int encounter_prayer_drain_effect(OverheadPrayer prayer) { + switch (prayer) { + case PRAYER_PROTECT_MELEE: return 12; + case PRAYER_PROTECT_RANGED: return 12; + case PRAYER_PROTECT_MAGIC: return 12; + case PRAYER_SMITE: return 12; + case PRAYER_REDEMPTION: return 6; + default: return 0; + } +} + +/** drain prayer points at the correct OSRS rate. call once per game tick. + drain_effect: total drain from all active prayers (overhead + offensive). + callers compute this by summing encounter_prayer_drain_effect() for overhead + and any offensive prayer drain (piety=24, rigour=24, augury=24, low=6). + prayer_bonus: player's total prayer equipment bonus (typically 0-30). + drain_counter: persistent state, must be zero-initialized. uses the osrs-sdk + incrementing approach (PrayerController.ts:50-53). + deactivates overhead prayer when points reach 0. caller is responsible for + deactivating offensive prayers if applicable (PvP). */ +static inline void encounter_drain_prayer( + int* current_prayer, OverheadPrayer* active_prayer, + int prayer_bonus, int* drain_counter, int drain_effect +) { + if (*active_prayer == PRAYER_NONE || drain_effect <= 0) return; + + /* OSRS prayer drain: counter increments by drain_effect each tick. + when counter >= drain_resistance, a prayer point drains. + ref: osrs-sdk PrayerController.ts:50-53, RuneLite PrayerPlugin.java:387. */ + int drain_resistance = 60 + prayer_bonus * 2; + *drain_counter += drain_effect; + while (*drain_counter >= drain_resistance) { + (*current_prayer)--; + *drain_counter -= drain_resistance; + if (*current_prayer <= 0) { + *current_prayer = 0; + *drain_counter = 0; + *active_prayer = PRAYER_NONE; + break; + } + } +} + +/* ======================================================================== */ +/* shared loadout stat computation */ +/* */ +/* ENCOUNTERS: do NOT manually compute attack bonuses, max hits, or */ +/* effective levels. call encounter_compute_loadout_stats() with a loadout */ +/* array and it derives everything from ITEM_DATABASE automatically. */ +/* */ +/* available structs/functions: */ +/* EncounterLoadoutStats — computed combat stats for one gear loadout */ +/* EncounterPrayer — prayer enum (NONE, AUGURY, RIGOUR, PIETY) */ +/* encounter_compute_loadout_stats() — derive stats from loadout + prayer */ +/* ======================================================================== */ + +/** combat stats derived from a gear loadout + prayer + style. + computed once at reset, read during combat. + prayer multipliers and style_bonus are stored for dynamic recomputation + when stats change (brew drain, potion boost). */ +typedef struct { + int attack_bonus; /* primary attack bonus for the style */ + int strength_bonus; /* ranged_strength, magic_damage %, or melee_strength */ + int eff_level; /* effective attack level (floor(base*prayer) + style + 8) */ + int max_hit; /* base max hit (before tbow/set bonuses) */ + int attack_speed; /* ticks between attacks */ + int attack_range; /* max chebyshev distance */ + AttackStyle style; + /* defence bonuses from gear */ + int def_stab, def_slash, def_crush, def_magic, def_ranged; + /* stored for dynamic max hit recomputation after brew drain / potion boost */ + float att_prayer_mult; + float str_prayer_mult; + int style_bonus; + int spell_base_damage; +} EncounterLoadoutStats; + +/** overhead prayer multipliers for effective level computation. */ +typedef enum { + ENCOUNTER_PRAYER_NONE = 0, + ENCOUNTER_PRAYER_AUGURY, /* +25% magic attack, +25% magic defence */ + ENCOUNTER_PRAYER_RIGOUR, /* +20% ranged attack, +23% ranged strength */ + ENCOUNTER_PRAYER_PIETY, /* +20% melee attack, +23% melee strength, +25% defence */ +} EncounterPrayer; + +/** derive all combat stats from a loadout array + prayer + style. + sums equipment bonuses from ITEM_DATABASE, applies prayer multiplier, + computes effective level and max hit. + + @param loadout gear array indexed by GEAR_SLOT_* (ITEM_NONE=255 for empty) + @param style ATTACK_STYLE_MAGIC, ATTACK_STYLE_RANGED, or ATTACK_STYLE_MELEE + @param prayer prayer enum for level multiplier + @param base_level base combat level (usually 99) + @param style_bonus +0 for rapid/autocast, +3 for accurate, +1 for controlled + @param spell_base_damage 0 for ranged/melee, 30 for ice/blood barrage + @param out output struct to fill */ +static void encounter_compute_loadout_stats( + const uint8_t loadout[NUM_GEAR_SLOTS], + AttackStyle style, + EncounterPrayer prayer, + int base_level, + int style_bonus, + int spell_base_damage, + EncounterLoadoutStats* out +) { + memset(out, 0, sizeof(*out)); + out->style = style; + + /* sum equipment bonuses from all gear slots */ + int sum_attack_stab = 0, sum_attack_slash = 0, sum_attack_crush = 0; + int sum_attack_magic = 0, sum_attack_ranged = 0; + int sum_melee_strength = 0, sum_ranged_strength = 0, sum_magic_damage = 0; + int sum_def_stab = 0, sum_def_slash = 0, sum_def_crush = 0; + int sum_def_magic = 0, sum_def_ranged = 0; + + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t item_idx = loadout[slot]; + if (item_idx == 255) continue; /* ITEM_NONE */ + const Item* item = &ITEM_DATABASE[item_idx]; + sum_attack_stab += item->attack_stab; + sum_attack_slash += item->attack_slash; + sum_attack_crush += item->attack_crush; + sum_attack_magic += item->attack_magic; + sum_attack_ranged += item->attack_ranged; + sum_melee_strength += item->melee_strength; + sum_ranged_strength += item->ranged_strength; + sum_magic_damage += item->magic_damage; + sum_def_stab += item->defence_stab; + sum_def_slash += item->defence_slash; + sum_def_crush += item->defence_crush; + sum_def_magic += item->defence_magic; + sum_def_ranged += item->defence_ranged; + } + + out->def_stab = sum_def_stab; + out->def_slash = sum_def_slash; + out->def_crush = sum_def_crush; + out->def_magic = sum_def_magic; + out->def_ranged = sum_def_ranged; + + /* weapon slot determines attack_speed and attack_range */ + uint8_t weapon_idx = loadout[GEAR_SLOT_WEAPON]; + if (weapon_idx != 255) { + const Item* weapon = &ITEM_DATABASE[weapon_idx]; + out->attack_speed = weapon->attack_speed; + out->attack_range = weapon->attack_range; + } + + /* primary attack bonus based on style */ + if (style == ATTACK_STYLE_MAGIC) { + out->attack_bonus = sum_attack_magic; + } else if (style == ATTACK_STYLE_RANGED) { + out->attack_bonus = sum_attack_ranged; + } else { + /* melee: best of stab/slash/crush */ + out->attack_bonus = sum_attack_stab; + if (sum_attack_slash > out->attack_bonus) out->attack_bonus = sum_attack_slash; + if (sum_attack_crush > out->attack_bonus) out->attack_bonus = sum_attack_crush; + } + + /* prayer multipliers */ + float att_prayer_mult = 1.0f; + float str_prayer_mult = 1.0f; + switch (prayer) { + case ENCOUNTER_PRAYER_AUGURY: + att_prayer_mult = 1.25f; + break; + case ENCOUNTER_PRAYER_RIGOUR: + att_prayer_mult = 1.20f; + str_prayer_mult = 1.23f; + break; + case ENCOUNTER_PRAYER_PIETY: + att_prayer_mult = 1.20f; + str_prayer_mult = 1.23f; + break; + case ENCOUNTER_PRAYER_NONE: + break; + } + + /* store for dynamic recomputation after brew drain / potion boost */ + out->att_prayer_mult = att_prayer_mult; + out->str_prayer_mult = str_prayer_mult; + out->style_bonus = style_bonus; + out->spell_base_damage = spell_base_damage; + + /* effective attack level: floor(base * prayer_mult) + style_bonus + 8 */ + out->eff_level = (int)(base_level * att_prayer_mult) + style_bonus + 8; + + /* effective strength level (for max hit): floor(base * str_prayer_mult) + style_bonus + 8 + note: style_bonus for strength is typically 0 for rapid/autocast, +3 for aggressive */ + int eff_str_level = (int)(base_level * str_prayer_mult) + style_bonus + 8; + + /* max hit and strength bonus depend on combat style */ + if (style == ATTACK_STYLE_RANGED) { + out->strength_bonus = sum_ranged_strength; + out->max_hit = (int)(0.5 + eff_str_level * (sum_ranged_strength + 64) / 640.0); + } else if (style == ATTACK_STYLE_MAGIC) { + out->strength_bonus = sum_magic_damage; + out->max_hit = (int)(spell_base_damage * (1.0 + sum_magic_damage / 100.0)); + } else { + out->strength_bonus = sum_melee_strength; + out->max_hit = (int)(0.5 + eff_str_level * (sum_melee_strength + 64) / 640.0); + } +} + +/* ======================================================================== */ +/* dynamic max hit recomputation (after brew drain / potion boost) */ +/* */ +/* ENCOUNTERS: call encounter_update_loadout_level() whenever the player's */ +/* current combat level changes (brew drain, restore, bastion boost). */ +/* this recomputes eff_level and max_hit using the stored prayer multiplier */ +/* and strength bonus from the initial encounter_compute_loadout_stats(). */ +/* ======================================================================== */ + +/** recompute eff_level and max_hit for a loadout using a (possibly drained/boosted) + current combat level. call after brew drain, super restore, or bastion boost. + current_att_level: the player's current attack/ranged/magic level (for accuracy). + current_str_level: the player's current strength/ranged/magic level (for max hit). + for ranged: both are current_ranged. for melee: att=current_attack, str=current_strength. + for magic: max hit doesn't depend on level (spell base damage), but eff_level does. */ +static inline void encounter_update_loadout_level( + EncounterLoadoutStats* ls, int current_att_level, int current_str_level +) { + ls->eff_level = (int)(current_att_level * ls->att_prayer_mult) + ls->style_bonus + 8; + if (ls->style == ATTACK_STYLE_MAGIC) { + ls->max_hit = (int)(ls->spell_base_damage * (1.0 + ls->strength_bonus / 100.0)); + } else { + int eff_str = (int)(current_str_level * ls->str_prayer_mult) + ls->style_bonus + 8; + ls->max_hit = (int)(0.5 + eff_str * (ls->strength_bonus + 64) / 640.0); + } +} + +/* ======================================================================== */ +/* shared potion stat effects (brew drain, restore, bastion boost) */ +/* */ +/* ENCOUNTERS: call these when the player drinks a potion. they modify the */ +/* player's current combat levels and recompute max hit for affected loadouts.*/ +/* these implement the real OSRS formulas for stat modification. */ +/* */ +/* sara brew: heals HP, boosts def, drains att/str/ranged/magic */ +/* super restore: restores all drained stats toward base (caps at base) */ +/* bastion: boosts ranged above base, boosts def */ +/* ======================================================================== */ + +/** sara brew stat drain. call AFTER healing HP (which is encounter-specific). + drains att/str/ranged/magic by floor(current/10)+2 each (uses CURRENT level). + boosts defence by floor(current_def/5)+2, capped at base + max boost from base. + floors at 0 for drained stats. + ref: OSRS wiki Saradomin brew. */ +static inline void encounter_brew_drain_stats(Player* p) { + int att_drain = p->current_attack / 10 + 2; + int str_drain = p->current_strength / 10 + 2; + int rng_drain = p->current_ranged / 10 + 2; + int mag_drain = p->current_magic / 10 + 2; + int def_boost = p->current_defence / 5 + 2; + + p->current_attack -= att_drain; + if (p->current_attack < 0) p->current_attack = 0; + p->current_strength -= str_drain; + if (p->current_strength < 0) p->current_strength = 0; + p->current_ranged -= rng_drain; + if (p->current_ranged < 0) p->current_ranged = 0; + p->current_magic -= mag_drain; + if (p->current_magic < 0) p->current_magic = 0; + + p->current_defence += def_boost; + int def_cap = p->base_defence + (p->base_defence / 5 + 2); + if (p->current_defence > def_cap) p->current_defence = def_cap; +} + +/** super restore stat recovery. restores all combat stats toward base level. + each dose restores floor(base * 0.25) + 8 per stat. caps at base level. + ref: OSRS wiki Super restore. */ +static inline void encounter_restore_stats(Player* p) { + int restore = 8 + p->base_attack / 4; /* same formula for all stats at 99 base */ + p->current_attack += restore; + if (p->current_attack > p->base_attack) p->current_attack = p->base_attack; + restore = 8 + p->base_strength / 4; + p->current_strength += restore; + if (p->current_strength > p->base_strength) p->current_strength = p->base_strength; + restore = 8 + p->base_defence / 4; + p->current_defence += restore; + if (p->current_defence > p->base_defence) p->current_defence = p->base_defence; + restore = 8 + p->base_ranged / 4; + p->current_ranged += restore; + if (p->current_ranged > p->base_ranged) p->current_ranged = p->base_ranged; + restore = 8 + p->base_magic / 4; + p->current_magic += restore; + if (p->current_magic > p->base_magic) p->current_magic = p->base_magic; +} + +/** bastion potion boost. boosts ranged by floor(base * 0.10) + 4. can exceed base. + also boosts defence by floor(base * 0.15) + 5. can exceed base. + ref: OSRS wiki Bastion potion. */ +static inline void encounter_bastion_boost(Player* p) { + int rng_boost = 4 + p->base_ranged / 10; + int def_boost = 5 + p->base_defence * 15 / 100; + p->current_ranged += rng_boost; + int rng_cap = p->base_ranged + rng_boost; + if (p->current_ranged > rng_cap) p->current_ranged = rng_cap; + p->current_defence += def_boost; + int def_cap = p->base_defence + def_boost; + if (p->current_defence > def_cap) p->current_defence = def_cap; +} + +/** recompute max hit for all loadouts after a stat change. + encounters should call this after brew_drain_stats, restore_stats, or bastion_boost. + ranged loadouts use current_ranged, magic uses current_magic, melee uses + current_attack/current_strength. */ +static inline void encounter_recompute_loadout_max_hits( + EncounterLoadoutStats* loadouts, int num_loadouts, Player* p +) { + for (int i = 0; i < num_loadouts; i++) { + EncounterLoadoutStats* ls = &loadouts[i]; + if (ls->style == ATTACK_STYLE_RANGED) { + encounter_update_loadout_level(ls, p->current_ranged, p->current_ranged); + } else if (ls->style == ATTACK_STYLE_MAGIC) { + encounter_update_loadout_level(ls, p->current_magic, p->current_magic); + } else { + encounter_update_loadout_level(ls, p->current_attack, p->current_strength); + } + } +} + +/* ======================================================================== */ +/* shared special attack energy */ +/* */ +/* ENCOUNTERS: call encounter_tick_spec_regen() every game tick. call */ +/* encounter_use_spec() when the player activates a special attack. */ +/* OSRS: energy 0-100, starts at 100, regens +10 every 50 ticks (30s). */ +/* lightbearer halves regen interval to 25 ticks. */ +/* ======================================================================== */ + +#define SPEC_REGEN_INTERVAL 50 /* ticks between +10% regen (normal) */ +#define SPEC_REGEN_LIGHTBEARER 25 /* with lightbearer equipped */ +#define SPEC_REGEN_AMOUNT 10 /* energy restored per regen tick */ + +/** tick special attack energy regeneration. call once per game tick. + lightbearer: set to 1 if player has lightbearer ring equipped. */ +static inline void encounter_tick_spec_regen(Player* p, int has_lightbearer) { + if (p->special_energy >= 100) { + p->special_regen_ticks = 0; + return; + } + int interval = has_lightbearer ? SPEC_REGEN_LIGHTBEARER : SPEC_REGEN_INTERVAL; + p->special_regen_ticks++; + if (p->special_regen_ticks >= interval) { + p->special_energy += SPEC_REGEN_AMOUNT; + if (p->special_energy > 100) p->special_energy = 100; + p->special_regen_ticks = 0; + } +} + +/** attempt to use special attack energy. returns 1 if successful (enough energy), + 0 if not enough energy. drains on success. */ +static inline int encounter_use_spec(Player* p, int cost) { + if (p->special_energy < cost) return 0; + p->special_energy -= cost; + return 1; +} + +/* ======================================================================== */ +/* shared gear switching helpers for encounters */ +/* ======================================================================== */ + +/** apply a full static loadout to player equipment and set gear state. + used by Zulrah, Inferno, and future boss encounters with fixed loadouts. */ +static inline void encounter_apply_loadout( + Player* p, const uint8_t loadout[NUM_GEAR_SLOTS], GearSet gear_set +) { + memcpy(p->equipped, loadout, NUM_GEAR_SLOTS); + p->current_gear = gear_set; + p->visible_gear = gear_set; +} + +/** populate player inventory from multiple loadouts (deduped per slot). + extra_items is an optional overlay array (e.g. justiciar for tank), NULL to skip. + the GUI reads p->inventory[][] to display available gear switches. */ +static void encounter_populate_inventory( + Player* p, + const uint8_t* const* loadouts, int num_loadouts, + const uint8_t extra_items[NUM_GEAR_SLOTS] +) { + memset(p->inventory, 255 /* ITEM_NONE */, sizeof(p->inventory)); + memset(p->num_items_in_slot, 0, sizeof(p->num_items_in_slot)); + + for (int s = 0; s < NUM_GEAR_SLOTS; s++) { + int n = 0; + for (int l = 0; l < num_loadouts && n < MAX_ITEMS_PER_SLOT; l++) { + uint8_t item = loadouts[l][s]; + if (item == 255 /* ITEM_NONE */) continue; + int dup = 0; + for (int j = 0; j < n; j++) { if (p->inventory[s][j] == item) { dup = 1; break; } } + if (dup) continue; + p->inventory[s][n++] = item; + } + if (extra_items && extra_items[s] != 255 /* ITEM_NONE */ && n < MAX_ITEMS_PER_SLOT) { + int dup = 0; + for (int j = 0; j < n; j++) { if (p->inventory[s][j] == extra_items[s]) { dup = 1; break; } } + if (!dup) p->inventory[s][n++] = extra_items[s]; + } + p->num_items_in_slot[s] = n; + } +} + +/* ======================================================================== */ +/* shared human input translate helpers */ +/* ======================================================================== */ + +/** translate movement: convert absolute tile to 8-directional walk action. + writes to actions[head_move]. head_move < 0 = skip. */ +static inline void encounter_translate_movement(HumanInput* hi, int* actions, + int head_move, + void* (*get_entity)(void*, int), + void* state) { + if (hi->pending_move_x < 0 || hi->pending_move_y < 0 || head_move < 0) return; + Player* player = (Player*)get_entity(state, 0); + if (!player) return; + int dx = hi->pending_move_x - player->x; + int dy = hi->pending_move_y - player->y; + int sx = (dx > 0) ? 1 : (dx < 0) ? -1 : 0; + int sy = (dy > 0) ? 1 : (dy < 0) ? -1 : 0; + static const int DX8[9] = { 0, 0, 1, 1, 1, 0, -1, -1, -1 }; + static const int DY8[9] = { 0, 1, 1, 0, -1, -1, -1, 0, 1 }; + for (int m = 1; m < 9; m++) { + if (DX8[m] == sx && DY8[m] == sy) { + actions[head_move] = m; + break; + } + } +} + +/** translate prayer: 0=no change, 1=off, 2=melee, 3=ranged, 4=magic. + writes to actions[head_prayer]. head_prayer < 0 = skip. */ +static inline void encounter_translate_prayer(HumanInput* hi, int* actions, int head_prayer) { + if (hi->pending_prayer < 0 || head_prayer < 0) return; + switch (hi->pending_prayer) { + case OVERHEAD_NONE: actions[head_prayer] = 1; break; + case OVERHEAD_MELEE: actions[head_prayer] = 2; break; + case OVERHEAD_RANGED: actions[head_prayer] = 3; break; + case OVERHEAD_MAGE: actions[head_prayer] = 4; break; + default: break; + } +} + +/** translate NPC target: 0=none, 1+=NPC index. + writes to actions[head_target]. head_target < 0 = skip. */ +static inline void encounter_translate_target(HumanInput* hi, int* actions, int head_target) { + if (hi->pending_target_idx < 0 || head_target < 0) return; + actions[head_target] = hi->pending_target_idx + 1; +} + +/* ======================================================================== */ +/* encounter definition (vtable) */ +/* ======================================================================== */ + +typedef struct { + const char* name; /* "nh_pvp", "cerberus", "jad", etc. */ + + /* observation/action space dimensions */ + int obs_size; /* raw observation features (before mask) */ + int num_action_heads; + const int* action_head_dims; /* array of per-head dimensions */ + int mask_size; /* sum of action_head_dims */ + + /* lifecycle: create/destroy encounter state */ + EncounterState* (*create)(void); + void (*destroy)(EncounterState* state); + + /* episode lifecycle */ + void (*reset)(EncounterState* state, uint32_t seed); + void (*step)(EncounterState* state, const int* actions); + + /* RL interface */ + void (*write_obs)(EncounterState* state, float* obs_out); + void (*write_mask)(EncounterState* state, float* mask_out); + float (*get_reward)(EncounterState* state); + int (*is_terminal)(EncounterState* state); + + /* entity access for renderer (returns entity count, writes entity pointers). + renderer uses this to draw all entities generically. */ + int (*get_entity_count)(EncounterState* state); + void* (*get_entity)(EncounterState* state, int index); /* returns Player* */ + + /* render entity population: fills array of RenderEntity structs for the renderer. + replaces get_entity casting for rendering. NULL = renderer falls back to get_entity. */ + void (*fill_render_entities)(EncounterState* state, RenderEntity* out, int max_entities, int* count); + + /* encounter-specific config (key-value put/get for binding kwargs) */ + void (*put_int)(EncounterState* state, const char* key, int value); + void (*put_float)(EncounterState* state, const char* key, float value); + void (*put_ptr)(EncounterState* state, const char* key, void* value); + + /* arena bounds for renderer (0 = use FIGHT_AREA_* defaults) */ + int arena_base_x, arena_base_y; + int arena_width, arena_height; + + /* human mode input translation (per-encounter, NULL = no human mode). + translates semantic HumanInput intents to encounter-specific action arrays. + each encounter owns its own mapping since action head layouts differ. */ + void (*translate_human_input)(struct HumanInput* hi, int* actions, EncounterState* state); + + /* action head indices used by shared translate helpers and renderer. + set to -1 if the encounter doesn't have that action head. */ + int head_move; /* movement (walk/run) */ + int head_prayer; /* prayer switching */ + int head_target; /* NPC target selection (index into NPC array) */ + + /* render hooks (optional — NULL if not implemented). + populates visual overlay data for the renderer. */ + void (*render_post_tick)(EncounterState* state, EncounterOverlay* overlay); + + /* logging (returns pointer to encounter's Log struct, or NULL) */ + void* (*get_log)(EncounterState* state); + + /* tick access */ + int (*get_tick)(EncounterState* state); + int (*get_winner)(EncounterState* state); +} EncounterDef; + +/* ======================================================================== */ +/* encounter registry */ +/* ======================================================================== */ + +#define MAX_ENCOUNTERS 32 + +typedef struct { + const EncounterDef* defs[MAX_ENCOUNTERS]; + int count; +} EncounterRegistry; + +static EncounterRegistry g_encounter_registry = { .count = 0 }; + +static void encounter_register(const EncounterDef* def) { + if (g_encounter_registry.count < MAX_ENCOUNTERS) { + g_encounter_registry.defs[g_encounter_registry.count++] = def; + } +} + +static const EncounterDef* encounter_find(const char* name) { + for (int i = 0; i < g_encounter_registry.count; i++) { + if (strcmp(g_encounter_registry.defs[i]->name, name) == 0) { + return g_encounter_registry.defs[i]; + } + } + return NULL; +} + +#endif /* OSRS_ENCOUNTER_H */ diff --git a/ocean/osrs/osrs_items.h b/ocean/osrs/osrs_items.h new file mode 100644 index 0000000000..6542446640 --- /dev/null +++ b/ocean/osrs/osrs_items.h @@ -0,0 +1,1374 @@ +/** + * @file osrs_items.h + * @brief Item database with OSRS item IDs and equipment stats + * + * Provides a static database of LMS items with real OSRS item IDs. + * Each item has complete equipment stats sourced from OSRS wiki/game data. + * Indices 0-16 are basic LMS loadout; 17-51 are loot progression items; + * 52-59 are barrows armor and opal dragon bolts. + */ + +#ifndef OSRS_ITEMS_H +#define OSRS_ITEMS_H + +#include +#include + +// ============================================================================ +// EQUIPMENT SLOTS +// ============================================================================ + +typedef enum { + SLOT_HEAD = 0, + SLOT_CAPE = 1, + SLOT_NECK = 2, + SLOT_WEAPON = 3, + SLOT_BODY = 4, + SLOT_SHIELD = 5, + SLOT_LEGS = 6, + SLOT_HANDS = 7, + SLOT_FEET = 8, + SLOT_RING = 9, + SLOT_AMMO = 10, + NUM_EQUIPMENT_SLOTS = 11 +} EquipmentSlot; + +// ============================================================================ +// ITEM DATABASE INDICES +// ============================================================================ + +// Database indices (0-51), NOT OSRS item IDs. +// Use ITEM_DATABASE[index].item_id to get real OSRS ID. +typedef enum { + // === Basic LMS loadout (indices 0-16) === + ITEM_HELM_NEITIZNOT = 0, + ITEM_GOD_CAPE = 1, + ITEM_GLORY = 2, + ITEM_BLACK_DHIDE_BODY = 3, + ITEM_MYSTIC_TOP = 4, + ITEM_RUNE_PLATELEGS = 5, + ITEM_MYSTIC_BOTTOM = 6, + ITEM_WHIP = 7, + ITEM_RUNE_CROSSBOW = 8, + ITEM_AHRIM_STAFF = 9, + ITEM_DRAGON_DAGGER = 10, + ITEM_DRAGON_DEFENDER = 11, + ITEM_SPIRIT_SHIELD = 12, + ITEM_BARROWS_GLOVES = 13, + ITEM_CLIMBING_BOOTS = 14, + ITEM_BERSERKER_RING = 15, + ITEM_DIAMOND_BOLTS_E = 16, + + // === Weapons (indices 17-35) === + ITEM_GHRAZI_RAPIER = 17, + ITEM_INQUISITORS_MACE = 18, + ITEM_STAFF_OF_DEAD = 19, + ITEM_KODAI_WAND = 20, + ITEM_VOLATILE_STAFF = 21, + ITEM_ZURIELS_STAFF = 22, + ITEM_ARMADYL_CROSSBOW = 23, + ITEM_ZARYTE_CROSSBOW = 24, + ITEM_DRAGON_CLAWS = 25, + ITEM_AGS = 26, + ITEM_ANCIENT_GS = 27, + ITEM_GRANITE_MAUL = 28, + ITEM_ELDER_MAUL = 29, + ITEM_DARK_BOW = 30, + ITEM_HEAVY_BALLISTA = 31, + ITEM_VESTAS = 32, + ITEM_VOIDWAKER = 33, + ITEM_STATIUS_WARHAMMER = 34, + ITEM_MORRIGANS_JAVELIN = 35, + + // === Armor and accessories (indices 36-51) === + ITEM_ANCESTRAL_HAT = 36, + ITEM_ANCESTRAL_TOP = 37, + ITEM_ANCESTRAL_BOTTOM = 38, + ITEM_AHRIMS_ROBETOP = 39, + ITEM_AHRIMS_ROBESKIRT = 40, + ITEM_KARILS_TOP = 41, + ITEM_BANDOS_TASSETS = 42, + ITEM_BLESSED_SPIRIT_SHIELD = 43, + ITEM_FURY = 44, + ITEM_OCCULT_NECKLACE = 45, + ITEM_INFERNAL_CAPE = 46, + ITEM_ETERNAL_BOOTS = 47, + ITEM_SEERS_RING_I = 48, + ITEM_LIGHTBEARER = 49, + ITEM_MAGES_BOOK = 50, + ITEM_DRAGON_ARROWS = 51, + + // === Barrows armor + opal bolts (indices 52-59) === + ITEM_TORAGS_PLATELEGS = 52, + ITEM_DHAROKS_PLATELEGS = 53, + ITEM_VERACS_PLATESKIRT = 54, + ITEM_TORAGS_HELM = 55, + ITEM_DHAROKS_HELM = 56, + ITEM_VERACS_HELM = 57, + ITEM_GUTHANS_HELM = 58, + ITEM_OPAL_DRAGON_BOLTS = 59, + + // === Zulrah encounter items (indices 60-94) === + // tier 2 (BIS) mage + ITEM_IMBUED_SARA_CAPE = 60, + ITEM_EYE_OF_AYAK = 61, + ITEM_ELIDINIS_WARD_F = 62, + ITEM_CONFLICTION_GAUNTLETS = 63, + ITEM_AVERNIC_TREADS = 64, + ITEM_RING_OF_SUFFERING_RI = 65, + // tier 2 (BIS) range switches + ITEM_TWISTED_BOW = 66, + ITEM_MASORI_MASK_F = 67, + ITEM_MASORI_BODY_F = 68, + ITEM_MASORI_CHAPS_F = 69, + ITEM_NECKLACE_OF_ANGUISH = 70, + ITEM_DIZANAS_QUIVER = 71, + ITEM_ZARYTE_VAMBRACES = 72, + // tier 2 spec + ITEM_TOXIC_BLOWPIPE = 73, + // tier 1 (mid) mage + ITEM_AHRIMS_HOOD = 74, + ITEM_TORMENTED_BRACELET = 75, + ITEM_SANGUINESTI_STAFF = 76, + ITEM_INFINITY_BOOTS = 77, + ITEM_GOD_BLESSING = 78, + ITEM_RING_OF_RECOIL = 79, + // tier 1 range switches + ITEM_CRYSTAL_HELM = 80, + ITEM_AVAS_ASSEMBLER = 81, + ITEM_CRYSTAL_BODY = 82, + ITEM_CRYSTAL_LEGS = 83, + ITEM_BOW_OF_FAERDHINEN = 84, + ITEM_BLESSED_DHIDE_BOOTS = 85, + // tier 0 (budget) mage + ITEM_MYSTIC_HAT = 86, + ITEM_TRIDENT_OF_SWAMP = 87, + ITEM_BOOK_OF_DARKNESS = 88, + ITEM_AMETHYST_ARROW = 89, + ITEM_MYSTIC_BOOTS = 90, + // tier 0 range switches + ITEM_BLESSED_COIF = 91, + ITEM_BLACK_DHIDE_CHAPS = 92, + ITEM_MAGIC_SHORTBOW_I = 93, + ITEM_AVAS_ACCUMULATOR = 94, + // inferno gear + ITEM_CRYSTAL_SHIELD = 95, + ITEM_PEGASIAN_BOOTS = 96, + ITEM_JUSTICIAR_FACEGUARD = 97, + ITEM_JUSTICIAR_CHESTGUARD = 98, + ITEM_JUSTICIAR_LEGGUARDS = 99, + + NUM_ITEMS = 100, + ITEM_NONE = 255 +} ItemIndex; + +// ============================================================================ +// ITEM STRUCT +// ============================================================================ + +typedef struct { + uint16_t item_id; // Real OSRS item ID + char name[32]; // Human-readable name + uint8_t slot; // Equipment slot (EquipmentSlot enum) + uint8_t attack_speed; // Weapon attack speed (ticks) + uint8_t attack_range; // Weapon attack range (tiles) + int16_t attack_stab; + int16_t attack_slash; + int16_t attack_crush; + int16_t attack_magic; + int16_t attack_ranged; + int16_t defence_stab; + int16_t defence_slash; + int16_t defence_crush; + int16_t defence_magic; + int16_t defence_ranged; + int16_t melee_strength; + int16_t ranged_strength; + int16_t magic_damage; // Magic damage % bonus + int16_t prayer; +} Item; + +// ============================================================================ +// STATIC ITEM DATABASE +// ============================================================================ + +// Stats sourced from OSRS wiki. int16_t for items with bonuses > 127. +static const Item ITEM_DATABASE[NUM_ITEMS] = { + // === Basic LMS loadout (0-16) === + + [ITEM_HELM_NEITIZNOT] = { + .item_id = 10828, .name = "Helm of Neitiznot", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 31, .defence_slash = 29, .defence_crush = 34, + .defence_magic = 3, .defence_ranged = 30, + .melee_strength = 3, .ranged_strength = 0, .magic_damage = 0, .prayer = 3 + }, + [ITEM_GOD_CAPE] = { + .item_id = 21795, .name = "Imbued god cape", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 3, .defence_slash = 3, .defence_crush = 3, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_GLORY] = { + .item_id = 1712, .name = "Amulet of glory", .slot = SLOT_NECK, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 10, .attack_slash = 10, .attack_crush = 10, + .attack_magic = 10, .attack_ranged = 10, + .defence_stab = 3, .defence_slash = 3, .defence_crush = 3, + .defence_magic = 3, .defence_ranged = 3, + .melee_strength = 6, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_BLACK_DHIDE_BODY] = { + .item_id = 2503, .name = "Black d'hide body", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -15, .attack_ranged = 30, + .defence_stab = 55, .defence_slash = 47, .defence_crush = 62, + .defence_magic = 50, .defence_ranged = 57, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MYSTIC_TOP] = { + .item_id = 4091, .name = "Mystic robe top", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 20, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 20, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_RUNE_PLATELEGS] = { + .item_id = 1079, .name = "Rune platelegs", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -11, + .defence_stab = 51, .defence_slash = 49, .defence_crush = 47, + .defence_magic = -4, .defence_ranged = 49, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MYSTIC_BOTTOM] = { + .item_id = 4093, .name = "Mystic robe bottom", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_WHIP] = { + .item_id = 4151, .name = "Abyssal whip", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 0, .attack_slash = 82, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 82, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_RUNE_CROSSBOW] = { + .item_id = 9185, .name = "Rune crossbow", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 90, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_AHRIM_STAFF] = { + .item_id = 4710, .name = "Ahrim's staff", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 12, .attack_slash = -1, .attack_crush = 65, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 3, .defence_slash = 5, .defence_crush = 2, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 68, .ranged_strength = 0, .magic_damage = 5, .prayer = 0 + }, + [ITEM_DRAGON_DAGGER] = { + .item_id = 5698, .name = "Dragon dagger", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 40, .attack_slash = 25, .attack_crush = -4, + .attack_magic = 1, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 1, .defence_ranged = 0, + .melee_strength = 40, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DRAGON_DEFENDER] = { + .item_id = 12954, .name = "Dragon defender", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 25, .attack_slash = 24, .attack_crush = 23, + .attack_magic = -3, .attack_ranged = -2, + .defence_stab = 25, .defence_slash = 24, .defence_crush = 23, + .defence_magic = -3, .defence_ranged = 24, + .melee_strength = 6, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_SPIRIT_SHIELD] = { + .item_id = 12829, .name = "Spirit shield", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 39, .defence_slash = 41, .defence_crush = 50, + .defence_magic = 1, .defence_ranged = 45, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + [ITEM_BARROWS_GLOVES] = { + .item_id = 7462, .name = "Barrows gloves", .slot = SLOT_HANDS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 12, .attack_slash = 12, .attack_crush = 12, + .attack_magic = 6, .attack_ranged = 12, + .defence_stab = 12, .defence_slash = 12, .defence_crush = 12, + .defence_magic = 6, .defence_ranged = 12, + .melee_strength = 12, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_CLIMBING_BOOTS] = { + .item_id = 3105, .name = "Climbing boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 2, .defence_crush = 2, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 2, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_BERSERKER_RING] = { + .item_id = 6737, .name = "Berserker ring", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 4, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 4, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DIAMOND_BOLTS_E] = { + .item_id = 9243, .name = "Diamond bolts (e)", .slot = SLOT_AMMO, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 105, .magic_damage = 0, .prayer = 0 + }, + + // === Weapons (17-35) === + + [ITEM_GHRAZI_RAPIER] = { + .item_id = 22324, .name = "Ghrazi rapier", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 94, .attack_slash = 55, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 89, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_INQUISITORS_MACE] = { + .item_id = 24417, .name = "Inquisitor's mace", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 95, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 89, .ranged_strength = 0, .magic_damage = 0, .prayer = 2 + }, + [ITEM_STAFF_OF_DEAD] = { + .item_id = 11791, .name = "Staff of the dead", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 55, .attack_slash = -1, .attack_crush = 70, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 3, .defence_slash = 1, .defence_crush = 0, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 72, .ranged_strength = 0, .magic_damage = 15, .prayer = 0 + }, + [ITEM_KODAI_WAND] = { + .item_id = 21006, .name = "Kodai wand", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 28, .attack_ranged = 0, + .defence_stab = 3, .defence_slash = 3, .defence_crush = 3, + .defence_magic = 20, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 15, .prayer = 0 + }, + [ITEM_VOLATILE_STAFF] = { + .item_id = 24424, .name = "Volatile nightmare staff", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 16, .attack_ranged = 0, + .defence_stab = 5, .defence_slash = 5, .defence_crush = 5, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 15, .prayer = 0 + }, + [ITEM_ZURIELS_STAFF] = { + .item_id = 13867, .name = "Zuriel's staff", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 13, .attack_slash = -1, .attack_crush = 65, + .attack_magic = 18, .attack_ranged = 0, + .defence_stab = 5, .defence_slash = 7, .defence_crush = 4, + .defence_magic = 18, .defence_ranged = 0, + .melee_strength = 72, .ranged_strength = 0, .magic_damage = 10, .prayer = 0 + }, + [ITEM_ARMADYL_CROSSBOW] = { + .item_id = 11785, .name = "Armadyl crossbow", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 100, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_ZARYTE_CROSSBOW] = { + .item_id = 26374, .name = "Zaryte crossbow", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 110, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DRAGON_CLAWS] = { + .item_id = 13652, .name = "Dragon claws", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 41, .attack_slash = 57, .attack_crush = -4, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 13, .defence_slash = 26, .defence_crush = -1, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 56, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_AGS] = { + .item_id = 11802, .name = "Armadyl godsword", .slot = SLOT_WEAPON, + .attack_speed = 6, .attack_range = 1, + .attack_stab = 0, .attack_slash = 132, .attack_crush = 80, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 132, .ranged_strength = 0, .magic_damage = 0, .prayer = 8 + }, + [ITEM_ANCIENT_GS] = { + .item_id = 25730, .name = "Ancient godsword", .slot = SLOT_WEAPON, + .attack_speed = 6, .attack_range = 1, + .attack_stab = 0, .attack_slash = 132, .attack_crush = 80, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 132, .ranged_strength = 0, .magic_damage = 0, .prayer = 8 + }, + [ITEM_GRANITE_MAUL] = { + .item_id = 4153, .name = "Granite maul", .slot = SLOT_WEAPON, + .attack_speed = 7, .attack_range = 1, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 81, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 79, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_ELDER_MAUL] = { + .item_id = 21003, .name = "Elder maul", .slot = SLOT_WEAPON, + .attack_speed = 6, .attack_range = 1, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 135, + .attack_magic = -4, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 147, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DARK_BOW] = { + .item_id = 11235, .name = "Dark bow", .slot = SLOT_WEAPON, + .attack_speed = 9, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 95, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_HEAVY_BALLISTA] = { + .item_id = 19481, .name = "Heavy ballista", .slot = SLOT_WEAPON, + .attack_speed = 7, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 125, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_VESTAS] = { + .item_id = 22613, .name = "Vesta's longsword", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 1, + .attack_stab = 106, .attack_slash = 121, .attack_crush = -2, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 1, .defence_slash = 4, .defence_crush = 3, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 118, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_VOIDWAKER] = { + .item_id = 27690, .name = "Voidwaker", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 70, .attack_slash = 80, .attack_crush = -2, + .attack_magic = 5, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 1, .defence_crush = 0, + .defence_magic = 2, .defence_ranged = 0, + .melee_strength = 80, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_STATIUS_WARHAMMER] = { + .item_id = 22622, .name = "Statius's warhammer", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 1, + .attack_stab = -4, .attack_slash = -4, .attack_crush = 123, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 114, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MORRIGANS_JAVELIN] = { + .item_id = 22636, .name = "Morrigan's javelin", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 5, // rapid style (6 on accurate/longrange) + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 105, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 145, .magic_damage = 0, .prayer = 0 + }, + + // === Armor and accessories (36-51) === + + [ITEM_ANCESTRAL_HAT] = { + .item_id = 21018, .name = "Ancestral hat", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 8, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 8, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_ANCESTRAL_TOP] = { + .item_id = 21021, .name = "Ancestral robe top", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 35, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 35, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_ANCESTRAL_BOTTOM] = { + .item_id = 21024, .name = "Ancestral robe bottom", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 26, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 26, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_AHRIMS_ROBETOP] = { + .item_id = 4712, .name = "Ahrim's robetop", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 30, .attack_ranged = 0, + .defence_stab = 52, .defence_slash = 37, .defence_crush = 63, + .defence_magic = 30, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_AHRIMS_ROBESKIRT] = { + .item_id = 4714, .name = "Ahrim's robeskirt", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 22, .attack_ranged = 0, + .defence_stab = 33, .defence_slash = 30, .defence_crush = 36, + .defence_magic = 22, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_KARILS_TOP] = { + .item_id = 4736, .name = "Karil's leathertop", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -15, .attack_ranged = 30, + .defence_stab = 57, .defence_slash = 48, .defence_crush = 63, + .defence_magic = 65, .defence_ranged = 57, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_BANDOS_TASSETS] = { + .item_id = 11834, .name = "Bandos tassets", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -7, + .defence_stab = 71, .defence_slash = 63, .defence_crush = 66, + .defence_magic = -4, .defence_ranged = 93, + .melee_strength = 2, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + [ITEM_BLESSED_SPIRIT_SHIELD] = { + .item_id = 12831, .name = "Blessed spirit shield", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 53, .defence_slash = 55, .defence_crush = 73, + .defence_magic = 2, .defence_ranged = 52, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 3 + }, + [ITEM_FURY] = { + .item_id = 6585, .name = "Amulet of fury", .slot = SLOT_NECK, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 10, .attack_slash = 10, .attack_crush = 10, + .attack_magic = 10, .attack_ranged = 10, + .defence_stab = 15, .defence_slash = 15, .defence_crush = 15, + .defence_magic = 15, .defence_ranged = 15, + .melee_strength = 8, .ranged_strength = 0, .magic_damage = 0, .prayer = 5 + }, + [ITEM_OCCULT_NECKLACE] = { + .item_id = 12002, .name = "Occult necklace", .slot = SLOT_NECK, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 12, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 10, .prayer = 0 + }, + [ITEM_INFERNAL_CAPE] = { + .item_id = 21295, .name = "Infernal cape", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 4, .attack_slash = 4, .attack_crush = 4, + .attack_magic = 1, .attack_ranged = 1, + .defence_stab = 12, .defence_slash = 12, .defence_crush = 12, + .defence_magic = 1, .defence_ranged = 12, + .melee_strength = 8, .ranged_strength = 0, .magic_damage = 0, .prayer = 2 + }, + [ITEM_ETERNAL_BOOTS] = { + .item_id = 13235, .name = "Eternal boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 8, .attack_ranged = 0, + .defence_stab = 5, .defence_slash = 5, .defence_crush = 5, + .defence_magic = 8, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_SEERS_RING_I] = { + .item_id = 11770, .name = "Seers ring (i)", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 12, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 12, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_LIGHTBEARER] = { + .item_id = 25975, .name = "Lightbearer", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MAGES_BOOK] = { + .item_id = 6889, .name = "Mage's book", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_DRAGON_ARROWS] = { + .item_id = 11212, .name = "Dragon arrows", .slot = SLOT_AMMO, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 60, .magic_damage = 0, .prayer = 0 + }, + + // === Barrows armor + opal bolts (52-59) === + + [ITEM_TORAGS_PLATELEGS] = { + .item_id = 4751, .name = "Torag's platelegs", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -11, + .defence_stab = 85, .defence_slash = 82, .defence_crush = 83, + .defence_magic = -4, .defence_ranged = 92, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DHAROKS_PLATELEGS] = { + .item_id = 4722, .name = "Dharok's platelegs", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -11, + .defence_stab = 85, .defence_slash = 82, .defence_crush = 83, + .defence_magic = -4, .defence_ranged = 92, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_VERACS_PLATESKIRT] = { + .item_id = 4759, .name = "Verac's plateskirt", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -11, + .defence_stab = 85, .defence_slash = 82, .defence_crush = 83, + .defence_magic = 0, .defence_ranged = 84, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 4 + }, + [ITEM_TORAGS_HELM] = { + .item_id = 4745, .name = "Torag's helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -6, .attack_ranged = -2, + .defence_stab = 55, .defence_slash = 58, .defence_crush = 54, + .defence_magic = -1, .defence_ranged = 62, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_DHAROKS_HELM] = { + .item_id = 4716, .name = "Dharok's helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -3, .attack_ranged = -1, + .defence_stab = 45, .defence_slash = 48, .defence_crush = 44, + .defence_magic = -1, .defence_ranged = 51, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_VERACS_HELM] = { + .item_id = 4753, .name = "Verac's helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -6, .attack_ranged = -2, + .defence_stab = 55, .defence_slash = 58, .defence_crush = 54, + .defence_magic = 0, .defence_ranged = 56, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 3 + }, + [ITEM_GUTHANS_HELM] = { + .item_id = 4724, .name = "Guthan's helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -6, .attack_ranged = -2, + .defence_stab = 55, .defence_slash = 58, .defence_crush = 54, + .defence_magic = -1, .defence_ranged = 62, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_OPAL_DRAGON_BOLTS] = { + .item_id = 21932, .name = "Opal dragon bolts (e)", .slot = SLOT_AMMO, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 122, .magic_damage = 0, .prayer = 0 + }, + + // === Zulrah encounter items (60-94) === + + // --- tier 2 (BIS) mage --- + [ITEM_IMBUED_SARA_CAPE] = { + .item_id = 21791, .name = "Imbued saradomin cape", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 15, .attack_ranged = 0, + .defence_stab = 3, .defence_slash = 3, .defence_crush = 3, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0 + }, + [ITEM_EYE_OF_AYAK] = { + .item_id = 31113, .name = "Eye of ayak", .slot = SLOT_WEAPON, + .attack_speed = 3, .attack_range = 6, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 30, .attack_ranged = 0, + .defence_stab = 1, .defence_slash = 5, .defence_crush = 5, + .defence_magic = 10, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 2 + }, + [ITEM_ELIDINIS_WARD_F] = { + .item_id = 27251, .name = "Elidinis' ward (f)", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 25, .attack_ranged = 0, + .defence_stab = 53, .defence_slash = 55, .defence_crush = 73, + .defence_magic = 2, .defence_ranged = 52, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 5, .prayer = 4 + }, + [ITEM_CONFLICTION_GAUNTLETS] = { + .item_id = 31106, .name = "Confliction gauntlets", .slot = SLOT_HANDS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 20, .attack_ranged = -4, + .defence_stab = 15, .defence_slash = 18, .defence_crush = 7, + .defence_magic = 5, .defence_ranged = 5, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 7, .prayer = 2 + }, + [ITEM_AVERNIC_TREADS] = { + .item_id = 31097, .name = "Avernic treads (max)", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 5, .attack_slash = 5, .attack_crush = 5, + .attack_magic = 11, .attack_ranged = 15, + .defence_stab = 21, .defence_slash = 25, .defence_crush = 25, + .defence_magic = 10, .defence_ranged = 10, + .melee_strength = 6, .ranged_strength = 3, .magic_damage = 2, .prayer = 0 + }, + [ITEM_RING_OF_SUFFERING_RI] = { + .item_id = 20657, .name = "Ring of suffering (ri)", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 20, .defence_slash = 20, .defence_crush = 20, + .defence_magic = 20, .defence_ranged = 20, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 4 + }, + + // --- tier 2 (BIS) range switches --- + [ITEM_TWISTED_BOW] = { + .item_id = 20997, .name = "Twisted bow", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 70, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 20, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MASORI_MASK_F] = { + .item_id = 27235, .name = "Masori mask (f)", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -1, .attack_ranged = 12, + .defence_stab = 8, .defence_slash = 10, .defence_crush = 12, + .defence_magic = 12, .defence_ranged = 9, + .melee_strength = 0, .ranged_strength = 2, .magic_damage = 0, .prayer = 1 + }, + [ITEM_MASORI_BODY_F] = { + .item_id = 27238, .name = "Masori body (f)", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -4, .attack_ranged = 43, + .defence_stab = 59, .defence_slash = 52, .defence_crush = 64, + .defence_magic = 74, .defence_ranged = 60, + .melee_strength = 0, .ranged_strength = 4, .magic_damage = 0, .prayer = 1 + }, + [ITEM_MASORI_CHAPS_F] = { + .item_id = 27241, .name = "Masori chaps (f)", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -2, .attack_ranged = 27, + .defence_stab = 35, .defence_slash = 30, .defence_crush = 39, + .defence_magic = 46, .defence_ranged = 37, + .melee_strength = 0, .ranged_strength = 2, .magic_damage = 0, .prayer = 1 + }, + [ITEM_NECKLACE_OF_ANGUISH] = { + .item_id = 19547, .name = "Necklace of anguish", .slot = SLOT_NECK, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 15, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 5, .magic_damage = 0, .prayer = 2 + }, + [ITEM_DIZANAS_QUIVER] = { + .item_id = 28947, .name = "Dizana's quiver", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 18, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 3, .magic_damage = 0, .prayer = 0 + }, + [ITEM_ZARYTE_VAMBRACES] = { + .item_id = 26235, .name = "Zaryte vambraces", .slot = SLOT_HANDS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = -8, .attack_slash = -8, .attack_crush = -8, + .attack_magic = 0, .attack_ranged = 18, + .defence_stab = 8, .defence_slash = 8, .defence_crush = 8, + .defence_magic = 5, .defence_ranged = 8, + .melee_strength = 0, .ranged_strength = 2, .magic_damage = 0, .prayer = 1 + }, + + // --- tier 2 spec --- + [ITEM_TOXIC_BLOWPIPE] = { + .item_id = 12926, .name = "Toxic blowpipe", .slot = SLOT_WEAPON, + .attack_speed = 2, .attack_range = 5, /* rapid style (base 3, -1 for rapid) */ + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 30, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + /* ranged_strength = 20 (blowpipe) + 35 (dragon darts, hardcoded for now) */ + .melee_strength = 0, .ranged_strength = 55, .magic_damage = 0, .prayer = 0 + }, + + // --- tier 1 (mid) mage --- + [ITEM_AHRIMS_HOOD] = { + .item_id = 4708, .name = "Ahrim's hood", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 6, .attack_ranged = -2, + .defence_stab = 15, .defence_slash = 13, .defence_crush = 16, + .defence_magic = 6, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 1, .prayer = 0 + }, + [ITEM_TORMENTED_BRACELET] = { + .item_id = 19544, .name = "Tormented bracelet", .slot = SLOT_HANDS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 10, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 5, .prayer = 2 + }, + [ITEM_SANGUINESTI_STAFF] = { + .item_id = 22481, .name = "Sanguinesti staff", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 25, .attack_ranged = -4, + .defence_stab = 2, .defence_slash = 3, .defence_crush = 1, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_INFINITY_BOOTS] = { + .item_id = 6920, .name = "Infinity boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 5, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 5, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_GOD_BLESSING] = { + .item_id = 20220, .name = "Holy blessing", .slot = SLOT_AMMO, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + [ITEM_RING_OF_RECOIL] = { + .item_id = 2550, .name = "Ring of recoil", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + + // --- tier 1 range switches --- + [ITEM_CRYSTAL_HELM] = { + .item_id = 23971, .name = "Crystal helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -10, .attack_ranged = 9, + .defence_stab = 12, .defence_slash = 8, .defence_crush = 14, + .defence_magic = 10, .defence_ranged = 18, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 2 + }, + [ITEM_AVAS_ASSEMBLER] = { + .item_id = 22109, .name = "Ava's assembler", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 8, + .defence_stab = 1, .defence_slash = 1, .defence_crush = 1, + .defence_magic = 8, .defence_ranged = 2, + .melee_strength = 0, .ranged_strength = 2, .magic_damage = 0, .prayer = 0 + }, + [ITEM_CRYSTAL_BODY] = { + .item_id = 23975, .name = "Crystal body", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -18, .attack_ranged = 31, + .defence_stab = 46, .defence_slash = 38, .defence_crush = 48, + .defence_magic = 44, .defence_ranged = 68, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 3 + }, + [ITEM_CRYSTAL_LEGS] = { + .item_id = 23979, .name = "Crystal legs", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -12, .attack_ranged = 18, + .defence_stab = 26, .defence_slash = 21, .defence_crush = 30, + .defence_magic = 34, .defence_ranged = 38, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 2 + }, + [ITEM_BOW_OF_FAERDHINEN] = { + .item_id = 25865, .name = "Bow of faerdhinen (c)", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 128, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 106, .magic_damage = 0, .prayer = 0 + }, + [ITEM_BLESSED_DHIDE_BOOTS] = { + .item_id = 19921, .name = "Blessed d'hide boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -10, .attack_ranged = 7, + .defence_stab = 4, .defence_slash = 4, .defence_crush = 4, + .defence_magic = 4, .defence_ranged = 4, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + + // --- tier 0 (budget) mage --- + [ITEM_MYSTIC_HAT] = { + .item_id = 4089, .name = "Mystic hat", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 4, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 4, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_TRIDENT_OF_SWAMP] = { + .item_id = 12899, .name = "Trident of the swamp", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 25, .attack_ranged = 0, + .defence_stab = 2, .defence_slash = 3, .defence_crush = 1, + .defence_magic = 15, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_BOOK_OF_DARKNESS] = { + .item_id = 12612, .name = "Book of darkness", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 10, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 5 + }, + [ITEM_AMETHYST_ARROW] = { + .item_id = 21326, .name = "Amethyst arrow", .slot = SLOT_AMMO, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 55, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MYSTIC_BOOTS] = { + .item_id = 4097, .name = "Mystic boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 3, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 3, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + + // --- tier 0 range switches --- + [ITEM_BLESSED_COIF] = { + .item_id = 10382, .name = "Blessed coif", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -1, .attack_ranged = 7, + .defence_stab = 4, .defence_slash = 7, .defence_crush = 10, + .defence_magic = 4, .defence_ranged = 8, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + [ITEM_BLACK_DHIDE_CHAPS] = { + .item_id = 2497, .name = "Black d'hide chaps", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -10, .attack_ranged = 17, + .defence_stab = 18, .defence_slash = 20, .defence_crush = 26, + .defence_magic = 23, .defence_ranged = 26, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_MAGIC_SHORTBOW_I] = { + .item_id = 12788, .name = "Magic shortbow (i)", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 75, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_AVAS_ACCUMULATOR] = { + .item_id = 10499, .name = "Ava's accumulator", .slot = SLOT_CAPE, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 4, + .defence_stab = 0, .defence_slash = 1, .defence_crush = 0, + .defence_magic = 4, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_CRYSTAL_SHIELD] = { + .item_id = 4224, .name = "Crystal shield", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 51, .defence_slash = 54, .defence_crush = 53, + .defence_magic = 0, .defence_ranged = 80, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_PEGASIAN_BOOTS] = { + .item_id = 13237, .name = "Pegasian boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -12, .attack_ranged = 12, + .defence_stab = 5, .defence_slash = 3, .defence_crush = 5, + .defence_magic = -3, .defence_ranged = 5, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0 + }, + [ITEM_JUSTICIAR_FACEGUARD] = { + .item_id = 22326, .name = "Justiciar faceguard", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -6, .attack_ranged = -2, + .defence_stab = 56, .defence_slash = 59, .defence_crush = 63, + .defence_magic = -4, .defence_ranged = 59, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1 + }, + [ITEM_JUSTICIAR_CHESTGUARD] = { + .item_id = 22327, .name = "Justiciar chestguard", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -16, .attack_ranged = -7, + .defence_stab = 132, .defence_slash = 130, .defence_crush = 107, + .defence_magic = -16, .defence_ranged = 69, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 4 + }, + [ITEM_JUSTICIAR_LEGGUARDS] = { + .item_id = 22328, .name = "Justiciar legguards", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -21, .attack_ranged = -14, + .defence_stab = 95, .defence_slash = 92, .defence_crush = 83, + .defence_magic = -9, .defence_ranged = 65, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 3 + }, +}; + +// ============================================================================ +// LOOKUP TABLES +// ============================================================================ + +// Max items per slot (inventory width for dynamic gear) +#define MAX_ITEMS_PER_SLOT_DB 10 + +// Items available per slot (for masking and inventory) +// 255 = end marker (slot has fewer than MAX_ITEMS_PER_SLOT_DB options) +static const uint8_t ITEMS_BY_SLOT[NUM_EQUIPMENT_SLOTS][MAX_ITEMS_PER_SLOT_DB] = { + [SLOT_HEAD] = {ITEM_HELM_NEITIZNOT, ITEM_ANCESTRAL_HAT, + ITEM_TORAGS_HELM, ITEM_DHAROKS_HELM, ITEM_VERACS_HELM, ITEM_GUTHANS_HELM, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_CAPE] = {ITEM_GOD_CAPE, ITEM_INFERNAL_CAPE, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_NECK] = {ITEM_GLORY, ITEM_FURY, ITEM_OCCULT_NECKLACE, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_WEAPON] = {ITEM_WHIP, ITEM_RUNE_CROSSBOW, ITEM_AHRIM_STAFF, ITEM_DRAGON_DAGGER, + ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, ITEM_STAFF_OF_DEAD, ITEM_KODAI_WAND, + ITEM_VOLATILE_STAFF, ITEM_ZURIELS_STAFF}, + [SLOT_BODY] = {ITEM_BLACK_DHIDE_BODY, ITEM_MYSTIC_TOP, ITEM_ANCESTRAL_TOP, ITEM_AHRIMS_ROBETOP, + ITEM_KARILS_TOP, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_SHIELD] = {ITEM_DRAGON_DEFENDER, ITEM_SPIRIT_SHIELD, ITEM_BLESSED_SPIRIT_SHIELD, ITEM_MAGES_BOOK, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_LEGS] = {ITEM_RUNE_PLATELEGS, ITEM_MYSTIC_BOTTOM, ITEM_ANCESTRAL_BOTTOM, ITEM_AHRIMS_ROBESKIRT, + ITEM_BANDOS_TASSETS, ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, ITEM_VERACS_PLATESKIRT, + ITEM_NONE, ITEM_NONE}, + [SLOT_HANDS] = {ITEM_BARROWS_GLOVES, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_FEET] = {ITEM_CLIMBING_BOOTS, ITEM_ETERNAL_BOOTS, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_RING] = {ITEM_BERSERKER_RING, ITEM_SEERS_RING_I, ITEM_LIGHTBEARER, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_AMMO] = {ITEM_DIAMOND_BOLTS_E, ITEM_DRAGON_ARROWS, ITEM_OPAL_DRAGON_BOLTS, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, +}; + +// Number of items per slot in the static DB table above +static const uint8_t NUM_ITEMS_IN_SLOT[NUM_EQUIPMENT_SLOTS] = { + [SLOT_HEAD] = 6, // neitiznot, ancestral hat, torag/dharok/verac/guthan helms + [SLOT_CAPE] = 2, // god cape, infernal + [SLOT_NECK] = 3, // glory, fury, occult + [SLOT_WEAPON] = 10, // whip, rcb, ahrim, dds, rapier, inq mace, sotd, kodai, volatile, zuriel + [SLOT_BODY] = 5, // dhide, mystic, ancestral, ahrim, karil + [SLOT_SHIELD] = 4, // defender, spirit, blessed spirit, mages book + [SLOT_LEGS] = 8, // rune, mystic, ancestral, ahrim, bandos, torag/dharok/verac legs + [SLOT_HANDS] = 1, // barrows gloves + [SLOT_FEET] = 2, // climbing boots, eternal boots + [SLOT_RING] = 3, // berserker, seers (i), lightbearer + [SLOT_AMMO] = 3, // diamond bolts (e), dragon arrows, opal dragon bolts (e) +}; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +/** Get item from database by index. Returns NULL if invalid. */ +static inline const Item* get_item(uint8_t item_index) { + if (item_index >= NUM_ITEMS) return NULL; + return &ITEM_DATABASE[item_index]; +} + +/** Check if item is a weapon. */ +static inline int item_is_weapon(uint8_t item_index) { + if (item_index >= NUM_ITEMS) return 0; + return ITEM_DATABASE[item_index].slot == SLOT_WEAPON; +} + +/** Check if item is a shield. */ +static inline int item_is_shield(uint8_t item_index) { + if (item_index >= NUM_ITEMS) return 0; + return ITEM_DATABASE[item_index].slot == SLOT_SHIELD; +} + +/** Get attack style for a weapon item (1=melee, 2=ranged, 3=magic). */ +static inline int get_item_attack_style(uint8_t item_index) { + switch (item_index) { + // Melee weapons + case ITEM_WHIP: + case ITEM_DRAGON_DAGGER: + case ITEM_GHRAZI_RAPIER: + case ITEM_INQUISITORS_MACE: + case ITEM_DRAGON_CLAWS: + case ITEM_AGS: + case ITEM_ANCIENT_GS: + case ITEM_GRANITE_MAUL: + case ITEM_ELDER_MAUL: + case ITEM_VESTAS: + case ITEM_VOIDWAKER: + case ITEM_STATIUS_WARHAMMER: + return 1; // ATTACK_STYLE_MELEE + // Ranged weapons + case ITEM_RUNE_CROSSBOW: + case ITEM_ARMADYL_CROSSBOW: + case ITEM_ZARYTE_CROSSBOW: + case ITEM_DARK_BOW: + case ITEM_HEAVY_BALLISTA: + case ITEM_MORRIGANS_JAVELIN: + return 2; // ATTACK_STYLE_RANGED + // Magic weapons + case ITEM_AHRIM_STAFF: + case ITEM_STAFF_OF_DEAD: + case ITEM_KODAI_WAND: + case ITEM_VOLATILE_STAFF: + case ITEM_ZURIELS_STAFF: + return 3; // ATTACK_STYLE_MAGIC + default: + return 0; // ATTACK_STYLE_NONE + } +} + +/** Check if weapon is two-handed. */ +static inline int item_is_two_handed(uint8_t item_index) { + switch (item_index) { + case ITEM_AGS: + case ITEM_ANCIENT_GS: + case ITEM_DRAGON_CLAWS: + case ITEM_GRANITE_MAUL: + case ITEM_ELDER_MAUL: + case ITEM_DARK_BOW: + case ITEM_HEAVY_BALLISTA: + return 1; + default: + return 0; + } +} + +// ============================================================================ +// ITEM STATS EXTRACTION (for observations) +// ============================================================================ + +/** Normalization constants for item stats (max observed values in game). */ +#define STAT_NORM_ATTACK 150.0f +#define STAT_NORM_DEFENCE 100.0f +#define STAT_NORM_STRENGTH 150.0f +#define STAT_NORM_MAGIC_DMG 30.0f +#define STAT_NORM_PRAYER 10.0f +#define STAT_NORM_SPEED 10.0f +#define STAT_NORM_RANGE 15.0f + +/** + * Extract normalized item stats for observations. + * + * Writes 18 floats to output buffer: + * [0-4] attack bonuses (stab, slash, crush, magic, ranged) + * [5-9] defence bonuses (stab, slash, crush, magic, ranged) + * [10-12] strength bonuses (melee, ranged, magic damage %) + * [13] prayer bonus + * [14] attack speed + * [15] attack range + * [16] is_weapon flag (for quick filtering) + * [17] is_empty flag (1 if item_index >= NUM_ITEMS) + * + * @param item_index Item database index + * @param out Output buffer (must have space for 18 floats) + */ +static inline void get_item_stats_normalized(uint8_t item_index, float* out) { + if (item_index >= NUM_ITEMS) { + // Empty slot - all zeros except is_empty flag + for (int i = 0; i < 17; i++) out[i] = 0.0f; + out[17] = 1.0f; // is_empty + return; + } + + const Item* item = &ITEM_DATABASE[item_index]; + + // Attack bonuses (normalized by max expected values) + out[0] = (float)item->attack_stab / STAT_NORM_ATTACK; + out[1] = (float)item->attack_slash / STAT_NORM_ATTACK; + out[2] = (float)item->attack_crush / STAT_NORM_ATTACK; + out[3] = (float)item->attack_magic / STAT_NORM_ATTACK; + out[4] = (float)item->attack_ranged / STAT_NORM_ATTACK; + + // Defence bonuses + out[5] = (float)item->defence_stab / STAT_NORM_DEFENCE; + out[6] = (float)item->defence_slash / STAT_NORM_DEFENCE; + out[7] = (float)item->defence_crush / STAT_NORM_DEFENCE; + out[8] = (float)item->defence_magic / STAT_NORM_DEFENCE; + out[9] = (float)item->defence_ranged / STAT_NORM_DEFENCE; + + // Strength bonuses + out[10] = (float)item->melee_strength / STAT_NORM_STRENGTH; + out[11] = (float)item->ranged_strength / STAT_NORM_STRENGTH; + out[12] = (float)item->magic_damage / STAT_NORM_MAGIC_DMG; + + // Other bonuses + out[13] = (float)item->prayer / STAT_NORM_PRAYER; + out[14] = (float)item->attack_speed / STAT_NORM_SPEED; + out[15] = (float)item->attack_range / STAT_NORM_RANGE; + + // Flags + out[16] = (item->slot == SLOT_WEAPON) ? 1.0f : 0.0f; + out[17] = 0.0f; // not empty +} + +/** + * Get item index from slot inventory. + * + * Maps GearSlotIndex to EquipmentSlot and looks up item. + * Returns 255 (ITEM_NONE) if slot doesn't have that item. + */ +static inline uint8_t get_item_for_slot(int gear_slot, int item_idx) { + if (item_idx < 0 || item_idx >= MAX_ITEMS_PER_SLOT_DB) return ITEM_NONE; + + // Map GearSlotIndex to EquipmentSlot (they're slightly different) + int eq_slot; + switch (gear_slot) { + case 0: eq_slot = SLOT_HEAD; break; // GEAR_SLOT_HEAD + case 1: eq_slot = SLOT_CAPE; break; // GEAR_SLOT_CAPE + case 2: eq_slot = SLOT_NECK; break; // GEAR_SLOT_NECK + case 3: return ITEM_NONE; // GEAR_SLOT_AMMO (not in LMS) + case 4: eq_slot = SLOT_WEAPON; break; // GEAR_SLOT_WEAPON + case 5: eq_slot = SLOT_SHIELD; break; // GEAR_SLOT_SHIELD + case 6: eq_slot = SLOT_BODY; break; // GEAR_SLOT_BODY + case 7: eq_slot = SLOT_LEGS; break; // GEAR_SLOT_LEGS + case 8: eq_slot = SLOT_HANDS; break; // GEAR_SLOT_HANDS + case 9: eq_slot = SLOT_FEET; break; // GEAR_SLOT_FEET + case 10: eq_slot = SLOT_RING; break; // GEAR_SLOT_RING + default: return ITEM_NONE; + } + + return ITEMS_BY_SLOT[eq_slot][item_idx]; +} + +/** + * Get number of available items for a gear slot. + */ +static inline int get_num_items_for_slot(int gear_slot) { + int eq_slot; + switch (gear_slot) { + case 0: eq_slot = SLOT_HEAD; break; + case 1: eq_slot = SLOT_CAPE; break; + case 2: eq_slot = SLOT_NECK; break; + case 3: return 0; // AMMO + case 4: eq_slot = SLOT_WEAPON; break; + case 5: eq_slot = SLOT_SHIELD; break; + case 6: eq_slot = SLOT_BODY; break; + case 7: eq_slot = SLOT_LEGS; break; + case 8: eq_slot = SLOT_HANDS; break; + case 9: eq_slot = SLOT_FEET; break; + case 10: eq_slot = SLOT_RING; break; + default: return 0; + } + return NUM_ITEMS_IN_SLOT[eq_slot]; +} + +#endif // OSRS_ITEMS_H diff --git a/ocean/osrs/osrs_pathfinding.h b/ocean/osrs/osrs_pathfinding.h new file mode 100644 index 0000000000..a26068b81a --- /dev/null +++ b/ocean/osrs/osrs_pathfinding.h @@ -0,0 +1,329 @@ +/** + * @file osrs_pathfinding.h + * @brief BFS pathfinder for OSRS tile-based movement + * + * BFS pathfinder (OSRS uses BFS despite some implementations naming it "Dijkstra"). + * Operates on a 104x104 local grid (OSRS client scene size). + * Uses collision_traversable_step() from osrs_collision.h for wall/block checks. + * + * Returns the next step direction for the agent to take. Full path reconstruction + * is not needed since agents re-plan every tick. + * + * All working arrays are stack-allocated (no malloc per query). + * Worst case: ~10k tiles visited for 104x104 grid = fast enough at ~1M steps/sec. + */ + +#ifndef OSRS_PATHFINDING_H +#define OSRS_PATHFINDING_H + +#include "osrs_collision.h" + +#define PATHFIND_GRID_SIZE 104 +#define PATHFIND_MAX_QUEUE 9000 +#define PATHFIND_MAX_FALLBACK_RADIUS 10 + +/** + * BFS direction encoding (OSRS bitfield convention). + * + * Bits encode which cardinal components the path came FROM: + * 1 = S, 2 = W, 4 = N, 8 = E + * 3 = SW, 6 = NW, 9 = SE, 12 = NE + */ +#define VIA_NONE 0 +#define VIA_S 1 +#define VIA_W 2 +#define VIA_SW 3 /* S | W */ +#define VIA_N 4 +#define VIA_NW 6 /* N | W */ +#define VIA_E 8 +#define VIA_SE 9 /* S | E */ +#define VIA_NE 12 /* N | E */ +#define VIA_START 99 /* sentinel for source tile */ + +/** + * Result of a pathfinding query. + * + * next_dx/next_dy: the direction of the FIRST step from source toward dest. + * Each is -1, 0, or 1. If found==0, no path exists and dx/dy are 0. + */ +typedef struct { + int found; /* 1 = path found (exact or fallback), 0 = no reachable tile */ + int next_dx; /* first step x direction (-1, 0, 1) */ + int next_dy; /* first step y direction (-1, 0, 1) */ + int dest_x; /* actual destination reached (may differ from requested if fallback) */ + int dest_y; +} PathResult; + +/** + * Find the first step direction from (src_x, src_y) toward (dest_x, dest_y) + * using BFS on a 104x104 local grid with collision checks. + * + * If the exact destination is unreachable, falls back to the closest reachable + * tile within PATHFIND_MAX_FALLBACK_RADIUS of the destination. + * + * All working memory is stack-allocated. + * + * @param map Collision map (may be NULL = no obstacles) + * @param height Height plane (0 for standard PvP) + * @param src_x Source global x + * @param src_y Source global y + * @param dest_x Destination global x + * @param dest_y Destination global y + * @param extra_blocked Optional callback: returns 1 if tile is blocked by dynamic + * objects (pillars, etc.). NULL = no extra checks. + * @param blocked_ctx Context pointer passed to extra_blocked + * @return PathResult with first step direction + */ +typedef int (*pathfind_blocked_fn)(void* ctx, int abs_x, int abs_y); + +static inline PathResult pathfind_step(const CollisionMap* map, int height, + int src_x, int src_y, int dest_x, int dest_y, + pathfind_blocked_fn extra_blocked, void* blocked_ctx) { + PathResult result = {0, 0, 0, dest_x, dest_y}; + + /* already there */ + if (src_x == dest_x && src_y == dest_y) { + result.found = 1; + return result; + } + + /* don't pathfind across huge distances */ + int dist = abs(src_x - dest_x); + int dy_abs = abs(src_y - dest_y); + if (dy_abs > dist) dist = dy_abs; + if (dist > 64) { + return result; + } + + /* compute region origin for local coordinate conversion. + * OSRS uses chunkX << 3 as the origin: + * regionX = (src_x >> 3) - 6, then origin = regionX << 3. + * this centers the 104x104 grid roughly around the source. */ + int origin_x = ((src_x >> 3) - 6) << 3; + int origin_y = ((src_y >> 3) - 6) << 3; + + /* convert to local coordinates */ + int local_src_x = src_x - origin_x; + int local_src_y = src_y - origin_y; + int local_dest_x = dest_x - origin_x; + int local_dest_y = dest_y - origin_y; + + /* bounds check: dest must be within the 104x104 grid */ + if (local_dest_x < 0 || local_dest_x >= PATHFIND_GRID_SIZE || + local_dest_y < 0 || local_dest_y >= PATHFIND_GRID_SIZE) { + return result; + } + + /* BFS working arrays (stack allocated, ~43KB each for int[104][104]) */ + int via[PATHFIND_GRID_SIZE][PATHFIND_GRID_SIZE]; + int cost[PATHFIND_GRID_SIZE][PATHFIND_GRID_SIZE]; + memset(via, 0, sizeof(via)); + memset(cost, 0, sizeof(cost)); + + /* BFS queue (circular buffer) */ + int queue_x[PATHFIND_MAX_QUEUE]; + int queue_y[PATHFIND_MAX_QUEUE]; + int head = 0; + int tail = 0; + + /* seed the source */ + via[local_src_x][local_src_y] = VIA_START; + cost[local_src_x][local_src_y] = 1; + queue_x[tail] = local_src_x; + queue_y[tail] = local_src_y; + tail++; + + int found_path = 0; + int cur_x, cur_y; + + /* BFS expansion */ + while (head < tail && tail < PATHFIND_MAX_QUEUE) { + cur_x = queue_x[head]; + cur_y = queue_y[head]; + head++; + + /* reached exact destination? */ + if (cur_x == local_dest_x && cur_y == local_dest_y) { + found_path = 1; + break; + } + + int abs_x = origin_x + cur_x; + int abs_y = origin_y + cur_y; + int next_cost = cost[cur_x][cur_y] + 1; + + /* macro: check extra_blocked callback for dynamic obstacles (pillars etc.) */ + #define EB(ax, ay) (extra_blocked && extra_blocked(blocked_ctx, (ax), (ay))) + + /* try south (y - 1) */ + if (cur_y > 0 && via[cur_x][cur_y - 1] == 0 + && collision_traversable_south(map, height, abs_x, abs_y) + && !EB(abs_x, abs_y - 1)) { + queue_x[tail] = cur_x; + queue_y[tail] = cur_y - 1; + tail++; + via[cur_x][cur_y - 1] = VIA_S; + cost[cur_x][cur_y - 1] = next_cost; + } + + /* try west (x - 1) */ + if (cur_x > 0 && via[cur_x - 1][cur_y] == 0 + && collision_traversable_west(map, height, abs_x, abs_y) + && !EB(abs_x - 1, abs_y)) { + queue_x[tail] = cur_x - 1; + queue_y[tail] = cur_y; + tail++; + via[cur_x - 1][cur_y] = VIA_W; + cost[cur_x - 1][cur_y] = next_cost; + } + + /* try north (y + 1) */ + if (cur_y < PATHFIND_GRID_SIZE - 1 && via[cur_x][cur_y + 1] == 0 + && collision_traversable_north(map, height, abs_x, abs_y) + && !EB(abs_x, abs_y + 1)) { + queue_x[tail] = cur_x; + queue_y[tail] = cur_y + 1; + tail++; + via[cur_x][cur_y + 1] = VIA_N; + cost[cur_x][cur_y + 1] = next_cost; + } + + /* try east (x + 1) */ + if (cur_x < PATHFIND_GRID_SIZE - 1 && via[cur_x + 1][cur_y] == 0 + && collision_traversable_east(map, height, abs_x, abs_y) + && !EB(abs_x + 1, abs_y)) { + queue_x[tail] = cur_x + 1; + queue_y[tail] = cur_y; + tail++; + via[cur_x + 1][cur_y] = VIA_E; + cost[cur_x + 1][cur_y] = next_cost; + } + + /* try south-west */ + if (cur_x > 0 && cur_y > 0 && via[cur_x - 1][cur_y - 1] == 0 + && collision_traversable_south_west(map, height, abs_x, abs_y) + && collision_traversable_south(map, height, abs_x, abs_y) + && collision_traversable_west(map, height, abs_x, abs_y) + && !EB(abs_x - 1, abs_y - 1) && !EB(abs_x, abs_y - 1) && !EB(abs_x - 1, abs_y)) { + queue_x[tail] = cur_x - 1; + queue_y[tail] = cur_y - 1; + tail++; + via[cur_x - 1][cur_y - 1] = VIA_SW; + cost[cur_x - 1][cur_y - 1] = next_cost; + } + + /* try north-west */ + if (cur_x > 0 && cur_y < PATHFIND_GRID_SIZE - 1 && via[cur_x - 1][cur_y + 1] == 0 + && collision_traversable_north_west(map, height, abs_x, abs_y) + && collision_traversable_north(map, height, abs_x, abs_y) + && collision_traversable_west(map, height, abs_x, abs_y) + && !EB(abs_x - 1, abs_y + 1) && !EB(abs_x, abs_y + 1) && !EB(abs_x - 1, abs_y)) { + queue_x[tail] = cur_x - 1; + queue_y[tail] = cur_y + 1; + tail++; + via[cur_x - 1][cur_y + 1] = VIA_NW; + cost[cur_x - 1][cur_y + 1] = next_cost; + } + + /* try south-east */ + if (cur_x < PATHFIND_GRID_SIZE - 1 && cur_y > 0 && via[cur_x + 1][cur_y - 1] == 0 + && collision_traversable_south_east(map, height, abs_x, abs_y) + && collision_traversable_south(map, height, abs_x, abs_y) + && collision_traversable_east(map, height, abs_x, abs_y) + && !EB(abs_x + 1, abs_y - 1) && !EB(abs_x, abs_y - 1) && !EB(abs_x + 1, abs_y)) { + queue_x[tail] = cur_x + 1; + queue_y[tail] = cur_y - 1; + tail++; + via[cur_x + 1][cur_y - 1] = VIA_SE; + cost[cur_x + 1][cur_y - 1] = next_cost; + } + + /* try north-east */ + if (cur_x < PATHFIND_GRID_SIZE - 1 && cur_y < PATHFIND_GRID_SIZE - 1 + && via[cur_x + 1][cur_y + 1] == 0 + && collision_traversable_north_east(map, height, abs_x, abs_y) + && collision_traversable_north(map, height, abs_x, abs_y) + && collision_traversable_east(map, height, abs_x, abs_y) + && !EB(abs_x + 1, abs_y + 1) && !EB(abs_x, abs_y + 1) && !EB(abs_x + 1, abs_y)) { + queue_x[tail] = cur_x + 1; + queue_y[tail] = cur_y + 1; + tail++; + via[cur_x + 1][cur_y + 1] = VIA_NE; + cost[cur_x + 1][cur_y + 1] = next_cost; + } + + #undef EB + } + + /* fallback: if no exact path, find closest reachable tile near dest */ + if (!found_path) { + int best_dist_sq = PATHFIND_MAX_FALLBACK_RADIUS * PATHFIND_MAX_FALLBACK_RADIUS + 1; + int best_cost = 999999; + int best_x = -1, best_y = -1; + int r = PATHFIND_MAX_FALLBACK_RADIUS; + + for (int fx = local_dest_x - r; fx <= local_dest_x + r; fx++) { + for (int fy = local_dest_y - r; fy <= local_dest_y + r; fy++) { + if (fx < 0 || fx >= PATHFIND_GRID_SIZE || fy < 0 || fy >= PATHFIND_GRID_SIZE) + continue; + if (cost[fx][fy] == 0) continue; /* not reached by BFS */ + + int ddx = fx - local_dest_x; + int ddy = fy - local_dest_y; + int dist_sq = ddx * ddx + ddy * ddy; + + if (dist_sq < best_dist_sq || + (dist_sq == best_dist_sq && cost[fx][fy] < best_cost)) { + best_dist_sq = dist_sq; + best_cost = cost[fx][fy]; + best_x = fx; + best_y = fy; + } + } + } + + if (best_x == -1) { + return result; /* completely unreachable */ + } + + cur_x = best_x; + cur_y = best_y; + found_path = 1; + result.dest_x = origin_x + best_x; + result.dest_y = origin_y + best_y; + } + + /* backtrack from cur to source to find the FIRST step */ + while (1) { + int v = via[cur_x][cur_y]; + /* trace back one step */ + int prev_x = cur_x; + int prev_y = cur_y; + + if (v & VIA_W) prev_x++; /* came from east, step back east */ + else if (v & VIA_E) prev_x--; /* came from west, step back west */ + + if (v & VIA_S) prev_y++; /* came from north, step back north */ + else if (v & VIA_N) prev_y--; /* came from south, step back south */ + + if (prev_x == local_src_x && prev_y == local_src_y) { + /* cur is the first step from source */ + result.found = 1; + result.next_dx = cur_x - local_src_x; + result.next_dy = cur_y - local_src_y; + return result; + } + + cur_x = prev_x; + cur_y = prev_y; + + /* safety: shouldn't happen, but prevent infinite loop */ + if (via[cur_x][cur_y] == VIA_NONE || via[cur_x][cur_y] == VIA_START) { + break; + } + } + + return result; +} + +#endif /* OSRS_PATHFINDING_H */ diff --git a/ocean/osrs/osrs_pvp.c b/ocean/osrs/osrs_pvp.c new file mode 100644 index 0000000000..d2210096b6 --- /dev/null +++ b/ocean/osrs/osrs_pvp.c @@ -0,0 +1,611 @@ +/** + * @fileoverview Standalone demo for OSRS PvP C Environment + * + * Demonstrates environment initialization, stepping, and basic performance. + * Compile: make + * Run: ./osrs_pvp + */ + +#include +#include +#include +#include +#include "osrs_pvp.h" +#include "osrs_encounter.h" +#include "encounters/encounter_nh_pvp.h" +#include "encounters/encounter_zulrah.h" +#include "encounters/encounter_inferno.h" + +#ifdef OSRS_PVP_VISUAL +#include "osrs_pvp_render.h" +#endif + +static void print_player_state(Player* p, int idx) { + printf("Player %d: HP=%d/%d Prayer=%d Gear=%d Pos=(%d,%d) Frozen=%d\n", + idx, p->current_hitpoints, p->base_hitpoints, + p->current_prayer, p->current_gear, p->x, p->y, p->frozen_ticks); +} + +static void print_env_state(OsrsPvp* env) { + printf("\n=== Tick %d ===\n", env->tick); + print_player_state(&env->players[0], 0); + print_player_state(&env->players[1], 1); + printf("PID holder: %d\n", env->pid_holder); +} + +static void run_random_episode(OsrsPvp* env, int verbose) { + pvp_reset(env); + + while (!env->episode_over) { + for (int agent = 0; agent < NUM_AGENTS; agent++) { + int* actions = env->actions + agent * NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + actions[h] = rand() % ACTION_HEAD_DIMS[h]; + } + } + + pvp_step(env); + + if (verbose && env->tick % 50 == 0) { + print_env_state(env); + } + } + + if (verbose) { + printf("\n=== Episode End ===\n"); + printf("Winner: Player %d\n", env->winner); + printf("Length: %d ticks\n", env->tick); + printf("P0 damage dealt: %.0f\n", env->players[0].total_damage_dealt); + printf("P1 damage dealt: %.0f\n", env->players[1].total_damage_dealt); + } +} + +static void benchmark(OsrsPvp* env, int num_steps) { + printf("Benchmarking %d steps...\n", num_steps); + + clock_t start = clock(); + int episodes = 0; + int total_steps = 0; + + while (total_steps < num_steps) { + pvp_reset(env); + episodes++; + + while (!env->episode_over && total_steps < num_steps) { + for (int agent = 0; agent < NUM_AGENTS; agent++) { + int* actions = env->actions + agent * NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + actions[h] = rand() % ACTION_HEAD_DIMS[h]; + } + } + + pvp_step(env); + total_steps++; + } + } + + clock_t end = clock(); + double elapsed = (double)(end - start) / CLOCKS_PER_SEC; + + printf("Results:\n"); + printf(" Total steps: %d\n", total_steps); + printf(" Episodes: %d\n", episodes); + printf(" Time: %.3f seconds\n", elapsed); + printf(" Steps/sec: %.0f\n", total_steps / elapsed); + printf(" Avg episode length: %.1f ticks\n", (float)total_steps / episodes); +} + +#ifdef OSRS_PVP_VISUAL +/* replay file: binary format for pre-recorded actions. + header: [int32 num_ticks] [uint32 rng_state], then num_ticks * num_heads int32 values. */ +typedef struct { + int* actions; /* flat array: actions[tick * num_heads + head] */ + int num_ticks; + int num_heads; + int current_tick; + uint32_t rng_seed; /* RNG state at episode start — needed for deterministic replay */ +} ReplayFile; + +static ReplayFile* replay_load(const char* path, int num_heads) { + FILE* f = fopen(path, "rb"); + if (!f) { fprintf(stderr, "replay: can't open %s\n", path); return NULL; } + int num_ticks = 0; + uint32_t rng_seed = 12345; + if (fread(&num_ticks, 4, 1, f) != 1) { fclose(f); return NULL; } + if (fread(&rng_seed, 4, 1, f) != 1) { fclose(f); return NULL; } + ReplayFile* rf = (ReplayFile*)malloc(sizeof(ReplayFile)); + rf->num_ticks = num_ticks; + rf->num_heads = num_heads; + rf->current_tick = 0; + rf->rng_seed = rng_seed; + rf->actions = (int*)malloc(num_ticks * num_heads * sizeof(int)); + size_t n = fread(rf->actions, sizeof(int), num_ticks * num_heads, f); + fclose(f); + if ((int)n != num_ticks * num_heads) { + fprintf(stderr, "replay: short read (%d/%d)\n", (int)n, num_ticks * num_heads); + free(rf->actions); free(rf); return NULL; + } + fprintf(stderr, "replay loaded: %d ticks, rng=%u from %s\n", num_ticks, rng_seed, path); + return rf; +} + +static int replay_get_actions(ReplayFile* rf, int* out) { + if (rf->current_tick >= rf->num_ticks) return 0; + int base = rf->current_tick * rf->num_heads; + for (int h = 0; h < rf->num_heads; h++) out[h] = rf->actions[base + h]; + rf->current_tick++; + return 1; +} + +static void replay_free(ReplayFile* rf) { + if (rf) { free(rf->actions); free(rf); } +} + +static void run_visual(OsrsPvp* env, const char* encounter_name, const char* replay_path) { + env->client = NULL; + + /* set up encounter if specified, otherwise default to PvP */ + if (encounter_name) { + const EncounterDef* edef = encounter_find(encounter_name); + if (!edef) { + fprintf(stderr, "unknown encounter: %s\n", encounter_name); + return; + } + env->encounter_def = (void*)edef; + env->encounter_state = edef->create(); + /* seed=0 matches training binding (uses default RNG, not explicit seed) */ + + /* load encounter-specific collision map. + world offset translates encounter-local (0,0) → world coords for cmap lookup. + the Zulrah island collision data has ~69 walkable tiles forming the + irregular island shape (narrow south, wide north, pillar alcoves). */ + if (strcmp(encounter_name, "zulrah") == 0) { + CollisionMap* cmap = collision_map_load("data/zulrah.cmap"); + if (cmap) { + edef->put_ptr(env->encounter_state, "collision_map", cmap); + edef->put_int(env->encounter_state, "world_offset_x", 2256); + edef->put_int(env->encounter_state, "world_offset_y", 3061); + env->collision_map = cmap; + fprintf(stderr, "zulrah collision map: %d regions, offset (2256, 3061)\n", + cmap->count); + } + } else if (strcmp(encounter_name, "inferno") == 0) { + CollisionMap* cmap = collision_map_load("data/inferno.cmap"); + if (cmap) { + edef->put_ptr(env->encounter_state, "collision_map", cmap); + edef->put_int(env->encounter_state, "world_offset_x", 2246); + edef->put_int(env->encounter_state, "world_offset_y", 5315); + env->collision_map = cmap; + fprintf(stderr, "inferno collision map: %d regions, offset (2246, 5315)\n", + cmap->count); + } + } + + edef->reset(env->encounter_state, 0); + fprintf(stderr, "encounter: %s (obs=%d, heads=%d)\n", + edef->name, edef->obs_size, edef->num_action_heads); + } else { + env->use_c_opponent = 1; + env->opponent.type = OPP_IMPROVED; + env->is_lms = 1; + pvp_reset(env); + } + + /* load collision map from env var if set */ + const char* cmap_path = getenv("OSRS_COLLISION_MAP"); + if (cmap_path && cmap_path[0]) { + env->collision_map = collision_map_load(cmap_path); + if (env->collision_map) { + fprintf(stderr, "collision map loaded: %d regions\n", + ((CollisionMap*)env->collision_map)->count); + } + } + + /* init window before main loop (WindowShouldClose needs a window) */ + pvp_render(env); + RenderClient* rc = (RenderClient*)env->client; + + /* share collision map pointer with renderer for overlays */ + if (env->collision_map) { + rc->collision_map = (const CollisionMap*)env->collision_map; + } + + /* load 3D assets if available */ + rc->model_cache = model_cache_load("data/equipment.models"); + if (rc->model_cache) { + rc->show_models = 1; + } + rc->anim_cache = anim_cache_load("data/equipment.anims"); + render_init_overlay_models(rc); + /* load terrain/objects per encounter */ + if (!encounter_name) { + rc->terrain = terrain_load("data/wilderness.terrain"); + rc->objects = objects_load("data/wilderness.objects"); + rc->npcs = objects_load("data/wilderness.npcs"); + } else if (strcmp(encounter_name, "zulrah") == 0) { + rc->terrain = terrain_load("data/zulrah.terrain"); + rc->objects = objects_load("data/zulrah.objects"); + + /* Zulrah coordinate alignment + ============================ + three coordinate spaces are in play: + + 1. OSRS world coords: absolute tile positions (e.g. 2256, 3061). + terrain, objects, and collision maps are all authored in this space. + + 2. encounter-local coords: the encounter arena uses (0,0) as origin. + the encounter state, entity positions, and arena bounds all use this. + + 3. raylib world coords: X = east, Y = up, Z = -north (right-handed). + terrain_offset/objects_offset subtract the world origin so that + encounter-local (0,0) maps to raylib (0,0). + + terrain/objects offset: subtract (2256, 3061) from world coords. + regions (35,47)+(35,48) start at world (2240, 3008). + the island platform is at world ~(2256, 3061), so offset = 2240+16, 3008+53. + + collision map offset: ADD (2254, 3060) to encounter-local coords. + collision_get_flags expects world coords, so when the renderer or + encounter queries tile (x, y) in local space, it looks up + (x + 2254, y + 3060) in the collision map. */ + int zul_off_x = 2240 + 16; + int zul_off_y = 3008 + 53; + if (rc->terrain) + terrain_offset(rc->terrain, zul_off_x, zul_off_y); + if (rc->objects) + objects_offset(rc->objects, zul_off_x, zul_off_y); + + rc->collision_map = (const CollisionMap*)env->collision_map; + rc->collision_world_offset_x = 2256; + rc->collision_world_offset_y = 3061; + } else if (encounter_name && strcmp(encounter_name, "inferno") == 0) { + rc->terrain = terrain_load("data/inferno.terrain"); + rc->objects = objects_load("data/inferno.objects"); + /* inferno region (35,83) starts at world (2240, 5312). + encounter uses region-local coords (10-40, 13-44). + offset terrain/objects so local coord 0 maps to world 2240. */ + if (rc->terrain) + terrain_offset(rc->terrain, 2246, 5315); + if (rc->objects) + objects_offset(rc->objects, 2246, 5315); + + rc->npc_model_cache = model_cache_load("data/inferno_npcs.models"); + rc->npc_anim_cache = anim_cache_load("data/inferno_npcs.anims"); + + /* collision map for debug overlay (C key) */ + if (env->collision_map) { + rc->collision_map = (const CollisionMap*)env->collision_map; + rc->collision_world_offset_x = 2246; + rc->collision_world_offset_y = 5315; + } + + fprintf(stderr, "inferno: terrain=%s, cmap=%s, npc_models=%d, npc_anims=%d seqs\n", + rc->terrain ? "loaded" : "MISSING", + rc->collision_map ? "loaded" : "MISSING", + rc->npc_model_cache ? rc->npc_model_cache->count : 0, + rc->npc_anim_cache ? rc->npc_anim_cache->seq_count : 0); + } + + /* populate entity pointers (also sets arena bounds from encounter) */ + render_populate_entities(rc, env); + + /* update camera target to center on the (possibly new) arena */ + rc->cam_target_x = (float)rc->arena_base_x + (float)rc->arena_width / 2.0f; + rc->cam_target_z = -((float)rc->arena_base_y + (float)rc->arena_height / 2.0f); + + for (int i = 0; i < rc->entity_count; i++) { + int size = rc->entities[i].npc_size > 1 ? rc->entities[i].npc_size : 1; + rc->sub_x[i] = rc->entities[i].x * 128 + size * 64; + rc->sub_y[i] = rc->entities[i].y * 128 + size * 64; + rc->dest_x[i] = rc->sub_x[i]; + rc->dest_y[i] = rc->sub_y[i]; + } + + /* load replay file if specified */ + ReplayFile* replay = NULL; + if (replay_path && env->encounter_def) { + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + replay = replay_load(replay_path, edef->num_action_heads); + /* restore RNG state from replay so sim matches training exactly */ + if (replay && edef->put_int) { + edef->reset(env->encounter_state, 0); + edef->put_int(env->encounter_state, "seed", (int)replay->rng_seed); + render_populate_entities(rc, env); + for (int i = 0; i < rc->entity_count; i++) { + int size = rc->entities[i].npc_size > 1 ? rc->entities[i].npc_size : 1; + rc->sub_x[i] = rc->entities[i].x * 128 + size * 64; + rc->sub_y[i] = rc->entities[i].y * 128 + size * 64; + rc->dest_x[i] = rc->sub_x[i]; + rc->dest_y[i] = rc->sub_y[i]; + } + } + } + + /* save initial state as first snapshot */ + render_save_snapshot(rc, env); + + while (!WindowShouldClose()) { + + /* rewind: restore historical state and re-render */ + if (rc->step_back) { + rc->step_back = 0; + render_restore_snapshot(rc, env); + /* if we restored the latest snapshot, exit rewind mode */ + if (rc->history_cursor >= rc->history_count - 1) { + rc->history_cursor = -1; + } + pvp_render(env); + continue; + } + + /* in rewind mode viewing history: just render, don't step */ + if (rc->history_cursor >= 0) { + pvp_render(env); + continue; + } + + /* paused: render but don't step */ + if (rc->is_paused && !rc->step_once) { + pvp_render(env); + continue; + } + rc->step_once = 0; + + /* tick pacing: keep rendering while waiting */ + if (rc->ticks_per_second > 0.0f) { + double interval = 1.0 / rc->ticks_per_second; + while (GetTime() - rc->last_tick_time < interval) { + pvp_render(env); + if (WindowShouldClose()) return; + } + } + rc->last_tick_time = GetTime(); + + /* step the simulation */ + render_pre_tick(rc, env); + + if (env->encounter_def && env->encounter_state) { + /* encounter mode */ + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + int enc_actions[16] = {0}; + + if (rc->human_input.enabled) { + /* human control: per-encounter translator */ + if (edef->translate_human_input) + edef->translate_human_input(&rc->human_input, enc_actions, + env->encounter_state); + /* set encounter destination from human click for proper pathfinding. + attacking an NPC cancels movement (OSRS: server stops walking + to old dest and auto-walks toward target instead). */ + if (rc->human_input.pending_move_x >= 0 && edef->put_int) { + edef->put_int(env->encounter_state, "player_dest_x", + rc->human_input.pending_move_x); + edef->put_int(env->encounter_state, "player_dest_y", + rc->human_input.pending_move_y); + } else if (rc->human_input.pending_attack && edef->put_int) { + edef->put_int(env->encounter_state, "player_dest_x", -1); + edef->put_int(env->encounter_state, "player_dest_y", -1); + } + human_input_clear_pending(&rc->human_input); + } else if (replay && replay_get_actions(replay, enc_actions)) { + /* replay mode: actions come from pre-recorded file */ + } else if (strcmp(edef->name, "zulrah") == 0) { + zul_heuristic_actions((ZulrahState*)env->encounter_state, enc_actions); + } else { + for (int h = 0; h < edef->num_action_heads; h++) { + enc_actions[h] = rand() % edef->action_head_dims[h]; + } + } + edef->step(env->encounter_state, enc_actions); + /* sync env->tick so renderer HP bars/splats use correct tick */ + env->tick = edef->get_tick(env->encounter_state); + + /* clear human move when player arrived at clicked destination */ + if (rc->human_input.enabled && rc->human_input.pending_move_x >= 0) { + Player* ply = edef->get_entity(env->encounter_state, 0); + if (ply && ply->x == rc->human_input.pending_move_x && + ply->y == rc->human_input.pending_move_y) { + human_input_clear_move(&rc->human_input); + } + } + + } else { + /* PvP mode */ + if (rc->human_input.enabled) { + /* human control: translate staged clicks to PvP actions for agent 0 */ + human_to_pvp_actions(&rc->human_input, + env->actions, &env->players[0], &env->players[1]); + /* opponent still gets random actions */ + int* opp = env->actions + NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + opp[h] = rand() % ACTION_HEAD_DIMS[h]; + } + human_input_clear_pending(&rc->human_input); + } else { + for (int agent = 0; agent < NUM_AGENTS; agent++) { + int* actions = env->actions + agent * NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + actions[h] = rand() % ACTION_HEAD_DIMS[h]; + } + } + } + pvp_step(env); + + /* clear human move when player arrived at clicked destination */ + if (rc->human_input.enabled && rc->human_input.pending_move_x >= 0) { + Player* p0 = &env->players[0]; + if (p0->x == rc->human_input.pending_move_x && + p0->y == rc->human_input.pending_move_y) { + human_input_clear_move(&rc->human_input); + } + } + } + + render_post_tick(rc, env); + render_save_snapshot(rc, env); + pvp_render(env); + + /* auto-reset on episode end */ + int is_over = env->encounter_def + ? ((const EncounterDef*)env->encounter_def)->is_terminal(env->encounter_state) + : env->episode_over; + if (is_over) { + /* hold final frame for 2 seconds */ + double end_time = GetTime(); + while (GetTime() - end_time < 2.0 && !WindowShouldClose()) { + pvp_render(env); + } + render_clear_history(rc); + effect_clear_all(rc->effects); + rc->gui.inv_grid_dirty = 1; + if (env->encounter_def) { + ((const EncounterDef*)env->encounter_def)->reset( + env->encounter_state, (uint32_t)rand()); + } else { + pvp_reset(env); + } + render_populate_entities(rc, env); + for (int i = 0; i < rc->entity_count; i++) { + rc->sub_x[i] = rc->entities[i].x * 128 + 64; + rc->sub_y[i] = rc->entities[i].y * 128 + 64; + rc->dest_x[i] = rc->sub_x[i]; + rc->dest_y[i] = rc->sub_y[i]; + } + render_save_snapshot(rc, env); + } + } + + replay_free(replay); + + if (env->client) { + render_destroy_client((RenderClient*)env->client); + env->client = NULL; + } + if (env->encounter_def && env->encounter_state) { + ((const EncounterDef*)env->encounter_def)->destroy(env->encounter_state); + env->encounter_state = NULL; + } +} +#endif + +int main(int argc, char** argv) { + int use_visual = 1; /* default to visual mode */ + int gear_tier = -1; /* -1 = random (default LMS distribution) */ + const char* encounter_name __attribute__((unused)) = NULL; + const char* replay_path __attribute__((unused)) = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--visual") == 0) use_visual = 1; + else if (strcmp(argv[i], "--encounter") == 0 && i + 1 < argc) + encounter_name = argv[++i]; + else if (strcmp(argv[i], "--replay") == 0 && i + 1 < argc) + replay_path = argv[++i]; + else if (strcmp(argv[i], "--tier") == 0 && i + 1 < argc) + gear_tier = atoi(argv[++i]); + } + + srand((unsigned int)time(NULL)); + + /* verify encounter registry + lifecycle */ + { + const EncounterDef* nh = encounter_find("nh_pvp"); + if (!nh) { fprintf(stderr, "FATAL: nh_pvp encounter not registered\n"); return 1; } + printf("encounter registry: '%s' (obs=%d, heads=%d, mask=%d)\n", + nh->name, nh->obs_size, nh->num_action_heads, nh->mask_size); + + /* smoke test: create → reset → step → check terminal → destroy */ + EncounterState* es = nh->create(); + nh->put_int(es, "use_c_opponent", 1); + nh->put_int(es, "opponent_type", OPP_IMPROVED); + nh->reset(es, 42); + int actions[7] = {0}; + for (int t = 0; t < 300 && !nh->is_terminal(es); t++) { + nh->step(es, actions); + } + printf("encounter smoke test: tick=%d, terminal=%d, winner=%d\n", + nh->get_tick(es), nh->is_terminal(es), nh->get_winner(es)); + nh->destroy(es); + } + + OsrsPvp env; + memset(&env, 0, sizeof(OsrsPvp)); + + if (use_visual) { +#ifdef OSRS_PVP_VISUAL + /* pvp_init uses internal buffers — no malloc needed */ + pvp_init(&env); + /* set gear tier: --tier N forces both players to tier N, + otherwise default LMS distribution (mostly tier 0) */ + if (gear_tier >= 0 && gear_tier <= 3) { + for (int t = 0; t < 4; t++) env.gear_tier_weights[t] = 0.0f; + env.gear_tier_weights[gear_tier] = 1.0f; + } else { + /* default LMS: 60% tier 0, 25% tier 1, 10% tier 2, 5% tier 3 */ + env.gear_tier_weights[0] = 0.60f; + env.gear_tier_weights[1] = 0.25f; + env.gear_tier_weights[2] = 0.10f; + env.gear_tier_weights[3] = 0.05f; + } + env.ocean_acts = env.actions; + env.ocean_obs = env._obs_buf; + env.ocean_rew = env.rewards; + env.ocean_term = env.terminals; + run_visual(&env, encounter_name, replay_path); + pvp_close(&env); +#else + fprintf(stderr, "not compiled with visual support (use: make visual)\n"); + return 1; +#endif + } else { + /* headless: allocate external buffers (matches original demo) */ + env.observations = (float*)calloc(NUM_AGENTS * SLOT_NUM_OBSERVATIONS, sizeof(float)); + env.actions = (int*)calloc(NUM_AGENTS * NUM_ACTION_HEADS, sizeof(int)); + env.rewards = (float*)calloc(NUM_AGENTS, sizeof(float)); + env.terminals = (unsigned char*)calloc(NUM_AGENTS, sizeof(unsigned char)); + env.action_masks = (unsigned char*)calloc(NUM_AGENTS * ACTION_MASK_SIZE, sizeof(unsigned char)); + env.action_masks_agents = (1 << NUM_AGENTS) - 1; + env.ocean_acts = env.actions; + env.ocean_obs = (float*)calloc(OCEAN_OBS_SIZE, sizeof(float)); + env.ocean_rew = env.rewards; + env.ocean_term = env.terminals; + + printf("OSRS PvP C Environment Demo\n"); + printf("===========================\n\n"); + + printf("Running single verbose episode...\n"); + run_random_episode(&env, 1); + + printf("\n"); + benchmark(&env, 100000); + + printf("\nVerifying observations...\n"); + pvp_reset(&env); + printf("Observation count per agent: %d\n", SLOT_NUM_OBSERVATIONS); + printf("First 10 observations (agent 0): "); + for (int i = 0; i < 10; i++) { + printf("%.2f ", env.observations[i]); + } + printf("\n"); + + printf("\nAction heads: %d\n", NUM_ACTION_HEADS); + printf("Action dims: ["); + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + printf("%d", ACTION_HEAD_DIMS[i]); + if (i < NUM_ACTION_HEADS - 1) { + printf(", "); + } + } + printf("]\n"); + + printf("\nDemo complete.\n"); + + free(env.observations); + free(env.actions); + free(env.rewards); + free(env.terminals); + free(env.action_masks); + free(env.ocean_obs); + pvp_close(&env); + } + + return 0; +} diff --git a/ocean/osrs/osrs_pvp.h b/ocean/osrs/osrs_pvp.h new file mode 100644 index 0000000000..7be119d37a --- /dev/null +++ b/ocean/osrs/osrs_pvp.h @@ -0,0 +1,44 @@ +/** + * @file osrs_pvp.h + * @brief OSRS PvP environment - high-performance C simulation + * + * Include this file for all environment access. It pulls in all + * subsystem headers in the correct order: + * + * - osrs_types.h Core types, enums, structs, constants + * - osrs_pvp_gear.h Gear bonus tables and computation + * - osrs_pvp_combat.h Damage calculations and special attacks + * - osrs_pvp_movement.h Tile selection and pathfinding + * - osrs_pvp_observations.h Observation generation and action masks + * - osrs_pvp_actions.h Action processing (slot-based mode) + * - osrs_pvp_api.h Public API (pvp_reset, pvp_step, pvp_seed, pvp_close) + * + * Performance: ~1.1M steps/sec in pure C, ~200k steps/sec via Python binding (no opponent), + * ~56-90k steps/sec with scripted opponents. + * + * Usage: + * OsrsPvp env = {0}; + * pvp_reset(&env); + * while (!env.episode_over) { + * // set actions + * pvp_step(&env); + * } + * pvp_close(&env); + */ + +#ifndef OSRS_PVP_H +#define OSRS_PVP_H + +#include "osrs_types.h" +#include "osrs_collision.h" +#include "osrs_pathfinding.h" +#include "osrs_encounter.h" +#include "osrs_pvp_gear.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_movement.h" +#include "osrs_pvp_observations.h" +#include "osrs_pvp_actions.h" +#include "osrs_pvp_opponents.h" +#include "osrs_pvp_api.h" + +#endif // OSRS_PVP_H diff --git a/ocean/osrs/osrs_pvp_actions.h b/ocean/osrs/osrs_pvp_actions.h new file mode 100644 index 0000000000..8833dfa313 --- /dev/null +++ b/ocean/osrs/osrs_pvp_actions.h @@ -0,0 +1,1002 @@ +/** + * @file osrs_pvp_actions.h + * @brief Action processing for loadout-based action space + * + * Handles player actions including: + * - Food and potion consumption + * - Timer updates + * - Loadout-based action processing (8 heads) + * - Reward calculation + */ + +#ifndef OSRS_PVP_ACTIONS_H +#define OSRS_PVP_ACTIONS_H + +#include "osrs_types.h" +#include "osrs_items.h" +#include "osrs_pvp_gear.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_movement.h" +#include "osrs_pvp_observations.h" // For can_eat_food, can_use_potion, etc. + +// ============================================================================ +// PRAYER DRAIN +// ============================================================================ +// overhead drain: use encounter_prayer_drain_effect() from osrs_encounter.h. +// offensive drain: get_offensive_drain_effect() below. +// drain math: encounter_drain_prayer() from osrs_encounter.h. + +// NH gear prayer bonus: fury amulet +3, neitiznot helm +3 = 6 total. +// hardcoded because these are always equipped regardless of gear set. +#define PRAYER_BONUS 6 + +/** get drain effect for an offensive prayer (PvP only — encounters don't use these yet). */ +static inline int get_offensive_drain_effect(OffensivePrayer prayer) { + switch (prayer) { + case OFFENSIVE_PRAYER_NONE: return 0; + case OFFENSIVE_PRAYER_MELEE_LOW: return 6; /* 1 point per 6 seconds */ + case OFFENSIVE_PRAYER_RANGED_LOW: return 6; + case OFFENSIVE_PRAYER_MAGIC_LOW: return 6; + case OFFENSIVE_PRAYER_PIETY: return 24; /* 1 point per 1.5 seconds */ + case OFFENSIVE_PRAYER_RIGOUR: return 24; + case OFFENSIVE_PRAYER_AUGURY: return 24; + default: return 0; + } +} + +// ============================================================================ +// CONSUMABLE ACTIONS +// ============================================================================ + +/** + * Eat food (regular or karambwan). + * + * Food: heals based on HP level (capped at 22), no overheal. + * Karambwan: heals 18, shares timer with food. + * + * @param p Player eating + * @param is_karambwan 1 for karambwan, 0 for regular food + */ +static void eat_food(Player* p, int is_karambwan) { + if (is_karambwan) { + if (p->karambwan_count <= 0 || p->karambwan_timer > 0) return; + p->karambwan_count--; + int hp_before = p->current_hitpoints; + int heal_amount = 18; + int max_hp = p->base_hitpoints; + int actual_heal = max_int(0, min_int(heal_amount, max_hp - hp_before)); + int waste = heal_amount - actual_heal; + p->current_hitpoints = clamp(hp_before + heal_amount, 0, max_hp); + p->last_karambwan_heal = actual_heal; + p->last_karambwan_waste = waste; + p->karambwan_timer = 2; // 2-tick self-cooldown: karam, 1, Ready + p->food_timer = 3; // 3-tick cross-lockout on food + p->potion_timer = 3; // 3-tick cross-lockout on potions + p->ate_karambwan_this_tick = 1; // Track for reward shaping + // Eating always delays attack timer (clamp to 0 so idle-negative timer doesn't skip delay) + int combat_ticks = p->has_attack_timer ? max_int(p->attack_timer_uncapped, 0) : 0; + p->attack_timer = combat_ticks + 2; + p->attack_timer_uncapped = combat_ticks + 2; + p->has_attack_timer = 1; + } else { + if (p->food_count <= 0 || p->food_timer > 0) return; + p->food_count--; + // Sharks heal 20, capped by missing HP + int heal_amount = 20; + int hp_before = p->current_hitpoints; + int max_hp = p->base_hitpoints; + int actual_heal = min_int(heal_amount, max_hp - hp_before); + int waste = heal_amount - actual_heal; + p->current_hitpoints = hp_before + actual_heal; + p->last_food_heal = actual_heal; + p->last_food_waste = waste; + p->food_timer = 3; + p->ate_food_this_tick = 1; // Track for reward shaping + // Eating always delays attack timer (clamp to 0 so idle-negative timer doesn't skip delay) + int combat_ticks = p->has_attack_timer ? max_int(p->attack_timer_uncapped, 0) : 0; + p->attack_timer = combat_ticks + 3; + p->attack_timer_uncapped = combat_ticks + 3; + p->has_attack_timer = 1; + } +} + +/** + * Drink potion. + * + * Types: + * 1 = Saradomin brew (heals HP, boosts defence, drains attack/str/magic/ranged) + * 2 = Super restore (restores all stats + prayer) + * 3 = Super combat (boosts attack/strength/defence 15%+5) + * 4 = Ranged potion (boosts ranged 10%+4) + * + * @param p Player drinking + * @param potion_type Potion type (1-4) + */ +static void drink_potion(Player* p, int potion_type) { + if (p->potion_timer > 0) return; + + switch (potion_type) { + case 1: { + if (p->brew_doses <= 0) return; + p->brew_doses--; + int def_boost = (int)floorf(2.0f + (0.20f * p->base_defence)); + int hp_boost = (int)floorf(2.0f + (0.15f * p->base_hitpoints)); + int hp_before = p->current_hitpoints; + int max_hp = p->base_hitpoints + hp_boost; + int actual_heal = max_int(0, min_int(hp_boost, max_hp - hp_before)); + int waste = hp_boost - actual_heal; + int def_before = p->current_defence; + int max_def = p->is_lms ? p->base_defence : p->base_defence + def_boost; + p->current_defence = clamp(def_before + def_boost, 0, max_def); + p->current_hitpoints = clamp(hp_before + hp_boost, 0, max_hp); + p->last_brew_heal = actual_heal; + p->last_brew_waste = waste; + int att_down = (int)floorf(0.10f * p->current_attack) + 2; + int str_down = (int)floorf(0.10f * p->current_strength) + 2; + int mag_down = (int)floorf(0.10f * p->current_magic) + 2; + int rng_down = (int)floorf(0.10f * p->current_ranged) + 2; + p->current_attack = clamp(p->current_attack - att_down, 0, 255); + p->current_strength = clamp(p->current_strength - str_down, 0, 255); + p->current_magic = clamp(p->current_magic - mag_down, 0, 255); + p->current_ranged = clamp(p->current_ranged - rng_down, 0, 255); + p->last_potion_type = potion_type; + p->ate_brew_this_tick = 1; // Track for reward shaping + break; + } + + case 2: { + if (p->restore_doses <= 0) return; + p->restore_doses--; + int had_restore_need = ( + p->current_attack < p->base_attack || + p->current_strength < p->base_strength || + p->current_defence < p->base_defence || + p->current_ranged < p->base_ranged || + p->current_magic < p->base_magic || + p->current_prayer < p->base_prayer + ); + int prayer_restore = 8 + (p->base_prayer / 4); + p->current_prayer = clamp(p->current_prayer + prayer_restore, 0, p->base_prayer); + int atk_restore = 8 + (p->base_attack / 4); + int str_restore = 8 + (p->base_strength / 4); + int def_restore = 8 + (p->base_defence / 4); + int rng_restore = 8 + (p->base_ranged / 4); + int mag_restore = 8 + (p->base_magic / 4); + if (p->current_attack < p->base_attack) { + p->current_attack = clamp(p->current_attack + atk_restore, 0, p->base_attack); + } + if (p->current_strength < p->base_strength) { + p->current_strength = clamp(p->current_strength + str_restore, 0, p->base_strength); + } + if (p->current_defence < p->base_defence) { + p->current_defence = clamp(p->current_defence + def_restore, 0, p->base_defence); + } + if (p->current_ranged < p->base_ranged) { + p->current_ranged = clamp(p->current_ranged + rng_restore, 0, p->base_ranged); + } + if (p->current_magic < p->base_magic) { + p->current_magic = clamp(p->current_magic + mag_restore, 0, p->base_magic); + } + p->last_potion_type = potion_type; + p->last_potion_was_waste = had_restore_need ? 0 : 1; + break; + } + + case 3: { + if (p->combat_potion_doses <= 0) return; + p->combat_potion_doses--; + int atk_boost = (int)floorf(p->base_attack * 0.15f) + 5; + int str_boost = (int)floorf(p->base_strength * 0.15f) + 5; + int def_boost = (int)floorf(p->base_defence * 0.15f) + 5; + int atk_cap = p->base_attack + atk_boost; + int str_cap = p->base_strength + str_boost; + int def_cap = p->is_lms ? p->base_defence : p->base_defence + def_boost; + int had_boost_need = ( + p->current_attack < atk_cap || + p->current_strength < str_cap || + p->current_defence < def_cap + ); + if (p->current_attack < atk_cap) { + p->current_attack = clamp(p->current_attack + atk_boost, 0, atk_cap); + } + if (p->current_strength < str_cap) { + p->current_strength = clamp(p->current_strength + str_boost, 0, str_cap); + } + if (p->current_defence < def_cap) { + p->current_defence = clamp(p->current_defence + def_boost, 0, def_cap); + } + p->last_potion_type = potion_type; + p->last_potion_was_waste = had_boost_need ? 0 : 1; + break; + } + + case 4: { + if (p->ranged_potion_doses <= 0) return; + p->ranged_potion_doses--; + int rng_boost = (int)floorf(p->base_ranged * 0.10f) + 4; + int rng_cap = p->base_ranged + rng_boost; + int had_boost_need = p->current_ranged < rng_cap; + if (p->current_ranged < rng_cap) { + p->current_ranged = clamp(p->current_ranged + rng_boost, 0, rng_cap); + } + p->last_potion_type = potion_type; + p->last_potion_was_waste = had_boost_need ? 0 : 1; + break; + } + } + + p->potion_timer = 3; + p->food_timer = 3; +} + +// ============================================================================ +// TIMER UPDATES +// ============================================================================ + +/** Update all per-tick timers for a player. */ +static void update_timers(Player* p) { + p->damage_applied_this_tick = 0; + + if (p->has_attack_timer) { + p->attack_timer_uncapped -= 1; + if (p->attack_timer >= 0) { + p->attack_timer -= 1; + } + } + // food/potion/karambwan timers are decremented AFTER execute_switches in c_step + // so that observations show the correct countdown (2, 1, Ready instead of 3, 2, 1) + if (p->frozen_ticks > 0) p->frozen_ticks--; + if (p->freeze_immunity_ticks > 0) p->freeze_immunity_ticks--; + if (p->veng_cooldown > 0) p->veng_cooldown--; + + /* prayer drain — uses shared encounter_drain_prayer for the counter math. + LMS has no prayer drain (prayer points are unlimited). */ + if (p->current_prayer > 0 && !p->is_lms) { + int drain_effect = encounter_prayer_drain_effect(p->prayer) + + get_offensive_drain_effect(p->offensive_prayer); + encounter_drain_prayer(&p->current_prayer, &p->prayer, + PRAYER_BONUS, &p->prayer_drain_counter, drain_effect); + /* shared function deactivates overhead prayer; PvP also needs to + deactivate offensive prayer when prayer points run out. */ + if (p->current_prayer <= 0) + p->offensive_prayer = OFFENSIVE_PRAYER_NONE; + } + + if (p->run_energy < 100 && (!p->is_moving || !p->is_running)) { + p->run_recovery_ticks += 1; + if (p->run_recovery_ticks >= RUN_ENERGY_RECOVER_TICKS) { + p->run_energy = clamp(p->run_energy + 1, 0, 100); + p->run_recovery_ticks = 0; + } + } else { + p->run_recovery_ticks = 0; + } + + int has_lightbearer = is_lightbearer_equipped(p); + if (has_lightbearer != p->was_lightbearer_equipped) { + if (has_lightbearer) { + if (p->special_regen_ticks > 25) { + p->special_regen_ticks = 0; + } + } else { + p->special_regen_ticks = 0; + } + p->was_lightbearer_equipped = has_lightbearer; + } + if (p->spec_regen_active && p->special_energy < 100) { + int regen_interval = has_lightbearer ? 25 : 50; + p->special_regen_ticks += 1; + if (p->special_regen_ticks >= regen_interval) { + p->special_energy = clamp(p->special_energy + 10, 0, 100); + p->special_regen_ticks = 0; + } + } else if (p->spec_regen_active) { + p->special_regen_ticks = 0; + } +} + +/** Reset per-tick flags at end of tick. */ +static void reset_tick_flags(Player* p) { + p->just_attacked = 0; + p->last_queued_hit_damage = 0; + p->attack_was_on_prayer = 0; + p->player_prayed_correct = 0; + p->target_prayed_correct = 0; + p->tick_damage_scale = 0.0f; + p->damage_dealt_scale = 0.0f; + p->damage_received_scale = 0.0f; + p->last_food_heal = 0; + p->last_food_waste = 0; + p->last_karambwan_heal = 0; + p->last_karambwan_waste = 0; + p->last_brew_heal = 0; + p->last_brew_waste = 0; + p->last_potion_type = 0; + p->last_potion_was_waste = 0; + p->attack_click_canceled = 0; + p->attack_click_ready = 0; + // Reset reward shaping action flags + p->attack_style_this_tick = ATTACK_STYLE_NONE; + p->magic_type_this_tick = 0; + p->used_special_this_tick = 0; + p->ate_food_this_tick = 0; + p->ate_karambwan_this_tick = 0; + p->ate_brew_this_tick = 0; + p->cast_veng_this_tick = 0; + p->clicks_this_tick = 0; +} + +// ============================================================================ +// LOADOUT-BASED ACTION EXECUTION +// ============================================================================ + +// Forward declarations for phased execution +static void execute_switches(OsrsPvp* env, int agent_idx, int* actions); +static void execute_attacks(OsrsPvp* env, int agent_idx, int* actions); + +/** Resolve attack style from attack action value. */ +static inline AttackStyle resolve_attack_style_for_action(Player* p, int attack_action) { + switch (attack_action) { + case ATTACK_ATK: + return get_slot_weapon_attack_style(p); + case ATTACK_ICE: + case ATTACK_BLOOD: + return ATTACK_STYLE_MAGIC; + default: + return ATTACK_STYLE_NONE; + } +} + +/** + * Execute switch-phase actions for an agent (Phase 1). + * + * Execution order: overhead prayer → loadout → auto-offensive prayer → + * consumables → movement → vengeance. + * + * CRITICAL: Prayer switches MUST be processed for BOTH players BEFORE + * any attacks are processed. This ensures attacks check the correct + * prayer state (the state after this tick's switches, not before). + */ +static void execute_switches(OsrsPvp* env, int agent_idx, int* actions) { + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + const CollisionMap* cmap = (const CollisionMap*)env->collision_map; + + p->consumable_used_this_tick = 0; + + // ========================================================================= + // PHASE 1: OVERHEAD PRAYER - must happen first so attacks see new prayer + // ========================================================================= + + int overhead_action = actions[HEAD_OVERHEAD]; + OverheadPrayer prev_prayer = p->prayer; + switch (overhead_action) { + case OVERHEAD_MAGE: + if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_MAGIC; + break; + case OVERHEAD_RANGED: + if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_RANGED; + break; + case OVERHEAD_MELEE: + if (p->current_prayer > 0) p->prayer = PRAYER_PROTECT_MELEE; + break; + case OVERHEAD_SMITE: + if (p->current_prayer > 0 && !env->is_lms) p->prayer = PRAYER_SMITE; + break; + case OVERHEAD_REDEMPTION: + if (p->current_prayer > 0 && !env->is_lms) p->prayer = PRAYER_REDEMPTION; + break; + } + if (p->prayer != prev_prayer) p->clicks_this_tick++; + + // ========================================================================= + // PHASE 2: LOADOUT SWITCH - equips dynamic gear slots, returns # changed + // ========================================================================= + + int loadout_action = actions[HEAD_LOADOUT]; + p->clicks_this_tick += apply_loadout(p, loadout_action); + + // ========================================================================= + // PHASE 3: AUTO-OFFENSIVE PRAYER + // Loadout determines prayer if switching, attack head is fallback for KEEP + // ========================================================================= + + if (p->current_prayer > 0 && p->base_prayer >= 70) { + AttackStyle pray_style = ATTACK_STYLE_NONE; + if (loadout_action != LOADOUT_KEEP) { + switch (loadout_action) { + case LOADOUT_MELEE: + case LOADOUT_SPEC_MELEE: + case LOADOUT_GMAUL: + pray_style = ATTACK_STYLE_MELEE; + break; + case LOADOUT_RANGE: + case LOADOUT_SPEC_RANGE: + pray_style = ATTACK_STYLE_RANGED; + break; + case LOADOUT_MAGE: + case LOADOUT_TANK: + case LOADOUT_SPEC_MAGIC: + pray_style = ATTACK_STYLE_MAGIC; + break; + } + } else { + int combat_action_val = actions[HEAD_COMBAT]; + pray_style = resolve_attack_style_for_action(p, combat_action_val); + } + switch (pray_style) { + case ATTACK_STYLE_MELEE: + p->offensive_prayer = OFFENSIVE_PRAYER_PIETY; + break; + case ATTACK_STYLE_RANGED: + p->offensive_prayer = OFFENSIVE_PRAYER_RIGOUR; + break; + case ATTACK_STYLE_MAGIC: + p->offensive_prayer = OFFENSIVE_PRAYER_AUGURY; + break; + default: + break; + } + } + + // ========================================================================= + // PHASE 4: CONSUMABLES - eating delays attack timer + // ========================================================================= + + int food_action = actions[HEAD_FOOD]; + if (food_action == FOOD_EAT && can_eat_food(p)) { + eat_food(p, 0); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + + int potion_action = actions[HEAD_POTION]; + switch (potion_action) { + case POTION_BREW: + if (can_use_potion(p, 1) && can_use_brew_boost(p)) { + drink_potion(p, 1); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + break; + case POTION_RESTORE: + if (can_use_potion(p, 2) && can_restore_stats(p)) { + drink_potion(p, 2); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + break; + case POTION_COMBAT: + if (can_use_potion(p, 3) && can_boost_combat_skills(p)) { + drink_potion(p, 3); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + break; + case POTION_RANGED: + if (can_use_potion(p, 4) && can_boost_ranged(p)) { + drink_potion(p, 4); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + break; + default: + break; + } + + int karam_action = actions[HEAD_KARAMBWAN]; + if (karam_action == KARAM_EAT && can_eat_karambwan(p)) { + eat_food(p, 1); + p->consumable_used_this_tick = 1; + p->clicks_this_tick++; + } + + // ========================================================================= + // PHASE 5: MOVEMENT + // ========================================================================= + + int combat_action = actions[HEAD_COMBAT]; + int is_spec_loadout = (loadout_action == LOADOUT_SPEC_MELEE || + loadout_action == LOADOUT_SPEC_RANGE || + loadout_action == LOADOUT_SPEC_MAGIC || + loadout_action == LOADOUT_GMAUL); + int move_action = (!is_spec_loadout && is_move_action(combat_action)) + ? combat_action : MOVE_NONE; + + int farcast_dist = 0; + switch (move_action) { + case MOVE_ADJACENT: + process_movement(p, t, 1, 0, cmap); + p->clicks_this_tick++; + break; + case MOVE_UNDER: + process_movement(p, t, 2, 0, cmap); + p->clicks_this_tick++; + break; + case MOVE_DIAGONAL: + process_movement(p, t, 4, 0, cmap); + p->clicks_this_tick++; + break; + case MOVE_FARCAST_2: + case MOVE_FARCAST_3: + case MOVE_FARCAST_4: + case MOVE_FARCAST_5: + case MOVE_FARCAST_6: + case MOVE_FARCAST_7: + farcast_dist = move_action - MOVE_FARCAST_2 + 2; + process_movement(p, t, 3, farcast_dist, cmap); + p->clicks_this_tick++; + break; + default: + break; + } + + // ========================================================================= + // PHASE 6: VENGEANCE + // ========================================================================= + + int veng_action = actions[HEAD_VENG]; + if (veng_action == VENG_CAST && p->is_lunar_spellbook && + !p->veng_active && remaining_ticks(p->veng_cooldown) == 0 && + p->current_magic >= 94) { + p->veng_active = 1; + p->veng_cooldown = 50; + p->cast_veng_this_tick = 1; + p->clicks_this_tick++; + } +} + +/** + * Execute attack-phase actions for an agent (Phase 2). + * + * Processes attacks AFTER all switches have been applied for BOTH players. + * SPEC loadout overrides the ATTACK head (atomic spec = equip + enable + attack). + */ +/** + * Attack movement phase: auto-walk to melee range + step out from same tile. + * Called for ALL players before any attack combat checks, so positions are + * fully resolved before range checks happen (matches OSRS tick processing). + */ +static void execute_attack_movement(OsrsPvp* env, int agent_idx, int* actions) { + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + const CollisionMap* cmap = (const CollisionMap*)env->collision_map; + + int loadout_action = actions[HEAD_LOADOUT]; + int combat_action = actions[HEAD_COMBAT]; + int attack_action = is_attack_action(combat_action) ? combat_action : ATTACK_NONE; + int move_action = is_move_action(combat_action) ? combat_action : MOVE_NONE; + + int is_spec_attack = (loadout_action == LOADOUT_SPEC_MELEE || + loadout_action == LOADOUT_SPEC_RANGE || + loadout_action == LOADOUT_SPEC_MAGIC || + loadout_action == LOADOUT_GMAUL); + if (is_spec_attack) { + attack_action = ATTACK_ATK; + move_action = MOVE_NONE; + } + + int current_loadout = get_current_loadout(p); + int in_mage_loadout = (current_loadout == LOADOUT_MAGE); + int in_tank_loadout = (current_loadout == LOADOUT_TANK); + if (attack_action == ATTACK_ATK && (in_mage_loadout || in_tank_loadout) && !is_spec_attack) { + attack_action = ATTACK_NONE; + } + + int has_attack = (attack_action != ATTACK_NONE); + int dist = chebyshev_distance(p->x, p->y, t->x, t->y); + + AttackStyle attack_style = ATTACK_STYLE_NONE; + switch (attack_action) { + case ATTACK_ATK: + attack_style = get_slot_weapon_attack_style(p); + break; + case ATTACK_ICE: + attack_style = ATTACK_STYLE_MAGIC; + break; + case ATTACK_BLOOD: + attack_style = ATTACK_STYLE_MAGIC; + break; + default: + break; + } + if (attack_action == ATTACK_ICE && !can_cast_ice_spell(p)) { + attack_style = ATTACK_STYLE_NONE; + } + if (attack_action == ATTACK_BLOOD && !can_cast_blood_spell(p)) { + attack_style = ATTACK_STYLE_NONE; + } + + p->did_attack_auto_move = 0; + + // Auto-move into melee range if melee attack queued + if (has_attack && move_action == MOVE_NONE && can_move(p)) { + if (attack_style == ATTACK_STYLE_MELEE && !is_in_melee_range(p, t)) { + int adj_x, adj_y; + if (select_closest_adjacent_tile(p, t->x, t->y, &adj_x, &adj_y, cmap)) { + set_destination(p, adj_x, adj_y, cmap); + } + p->did_attack_auto_move = 1; + dist = chebyshev_distance(p->x, p->y, t->x, t->y); + } + } + + // Step out from same tile (OSRS behavior: can't attack from same tile) + if (has_attack && dist == 0 && can_move(p)) { + step_out_from_same_tile(p, t, cmap); + } +} + +/** + * Attack combat phase: range check + perform attack + post-attack chase. + * Called for ALL players AFTER all attack movements have resolved, so + * dist is computed from final positions (fixes PID-dependent same-tile bug). + */ +static void execute_attack_combat(OsrsPvp* env, int agent_idx, int* actions) { + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + const CollisionMap* cmap = (const CollisionMap*)env->collision_map; + + int loadout_action = actions[HEAD_LOADOUT]; + int combat_action = actions[HEAD_COMBAT]; + int attack_action = is_attack_action(combat_action) ? combat_action : ATTACK_NONE; + int move_action = is_move_action(combat_action) ? combat_action : MOVE_NONE; + + int is_spec_attack = (loadout_action == LOADOUT_SPEC_MELEE || + loadout_action == LOADOUT_SPEC_RANGE || + loadout_action == LOADOUT_SPEC_MAGIC || + loadout_action == LOADOUT_GMAUL); + if (is_spec_attack) { + attack_action = ATTACK_ATK; + move_action = MOVE_NONE; + } + + int current_loadout = get_current_loadout(p); + int in_mage_loadout = (current_loadout == LOADOUT_MAGE); + int in_tank_loadout = (current_loadout == LOADOUT_TANK); + if (attack_action == ATTACK_ATK && (in_mage_loadout || in_tank_loadout) && !is_spec_attack) { + attack_action = ATTACK_NONE; + } + + int attack_ready = can_attack_now(p); + int has_attack = (attack_action != ATTACK_NONE); + // Recompute dist from CURRENT positions (after all movements resolved) + int dist = chebyshev_distance(p->x, p->y, t->x, t->y); + + AttackStyle attack_style = ATTACK_STYLE_NONE; + int magic_type = 0; + + switch (attack_action) { + case ATTACK_ATK: + attack_style = get_slot_weapon_attack_style(p); + break; + case ATTACK_ICE: + attack_style = ATTACK_STYLE_MAGIC; + magic_type = 1; + break; + case ATTACK_BLOOD: + attack_style = ATTACK_STYLE_MAGIC; + magic_type = 2; + break; + default: + break; + } + if (attack_action == ATTACK_ICE && !can_cast_ice_spell(p)) { + attack_style = ATTACK_STYLE_NONE; + } + if (attack_action == ATTACK_BLOOD && !can_cast_blood_spell(p)) { + attack_style = ATTACK_STYLE_NONE; + } + + // Gmaul is instant: bypasses attack timer + int is_gmaul = (loadout_action == LOADOUT_GMAUL); + int can_attack = attack_ready || (is_gmaul && is_granite_maul_attack_available(p)); + + switch (attack_action) { + case ATTACK_ATK: + if (can_attack && attack_style != ATTACK_STYLE_NONE) { + // ATK with magic staff uses melee (staff bash) + AttackStyle actual_style = (attack_style == ATTACK_STYLE_MAGIC) + ? ATTACK_STYLE_MELEE + : attack_style; + // Melee uses cardinal adjacency check; ranged uses Chebyshev range + int in_attack_range = 0; + if (actual_style == ATTACK_STYLE_MELEE) { + in_attack_range = is_in_melee_range(p, t); + } else { + int range = get_attack_range(p, actual_style); + in_attack_range = (dist > 0 && dist <= range); + } + if (in_attack_range) { + int is_special = is_spec_attack && is_special_ready(p, actual_style); + perform_attack(env, agent_idx, 1 - agent_idx, actual_style, is_special, 0, dist); + p->clicks_this_tick++; + } + } + break; + case ATTACK_ICE: + case ATTACK_BLOOD: + if (attack_ready && attack_style == ATTACK_STYLE_MAGIC) { + int can_cast = (attack_action == ATTACK_ICE) + ? can_cast_ice_spell(p) + : can_cast_blood_spell(p); + if (!can_cast) break; + int range = get_attack_range(p, ATTACK_STYLE_MAGIC); + if (dist > 0 && dist <= range) { + perform_attack(env, agent_idx, 1 - agent_idx, ATTACK_STYLE_MAGIC, 0, magic_type, dist); + p->clicks_this_tick++; + } + } + break; + default: + break; + } + + // Auto-walk to target if attack queued but out of range (post-attack chase) + if (has_attack && move_action == MOVE_NONE && can_move(p) && !p->did_attack_auto_move) { + int in_range = 0; + switch (attack_style) { + case ATTACK_STYLE_MELEE: + in_range = is_in_melee_range(p, t); + break; + case ATTACK_STYLE_RANGED: { + int range = get_attack_range(p, ATTACK_STYLE_RANGED); + in_range = (dist <= range); + break; + } + case ATTACK_STYLE_MAGIC: { + int range = get_attack_range(p, ATTACK_STYLE_MAGIC); + in_range = (dist <= range); + break; + } + default: + in_range = 1; + break; + } + if (!in_range) { + move_toward_target(p, t, cmap); + } + } +} + +/** + * Legacy wrapper: runs both attack phases sequentially for a single player. + * Used by execute_actions (scripted opponent convenience function). + * For correct PID-independent behavior in pvp_step, call execute_attack_movement + * for ALL players first, then execute_attack_combat for ALL players. + */ +static void execute_attacks(OsrsPvp* env, int agent_idx, int* actions) { + execute_attack_movement(env, agent_idx, actions); + execute_attack_combat(env, agent_idx, actions); +} + +/** + * Execute all actions for an agent (convenience for opponents). + * For correct prayer timing, c_step calls execute_switches for both + * players FIRST, then execute_attacks for both players. + */ +static void execute_actions(OsrsPvp* env, int agent_idx, int* actions) { + execute_switches(env, agent_idx, actions); + execute_attacks(env, agent_idx, actions); +} + +// ============================================================================ +// REWARD CALCULATION +// ============================================================================ + +/** + * Calculate reward for an agent. + * + * Sparse terminal signal: + * - +1.0 for win + * - -0.5 for loss + * - 0 for ongoing ticks + * + * When shaping is enabled, adds per-tick reward shaping (scaled by shaping_scale): + * - Damage dealt/received + * - Defensive prayer correctness + * - Off-prayer hits + * - Eating penalties (premature, wasted) + * - Spec timing bonuses + * - Bad behavior penalties (melee frozen, magic no staff) + * - Terminal shaping (KO bonus, wasted resources penalty) + */ +static float calculate_reward(OsrsPvp* env, int agent_idx) { + float reward = 0.0f; + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + const RewardShapingConfig* cfg = &env->shaping; + + // Sparse terminal reward: +1 win, -1 loss + if (env->episode_over) { + if (env->winner == agent_idx) { + reward += 1.0f; + } else if (env->winner == (1 - agent_idx)) { + reward += -1.0f; + } + } + + // Always-on behavioral penalties (independent of reward_shaping toggle) + + // Prayer switch penalty: switched protection prayer but opponent didn't attack + if (cfg->prayer_penalty_enabled && !t->just_attacked) { + int overhead = env->last_executed_actions[agent_idx * NUM_ACTION_HEADS + HEAD_OVERHEAD]; + if (overhead == OVERHEAD_MAGE || overhead == OVERHEAD_RANGED || overhead == OVERHEAD_MELEE) { + reward += cfg->prayer_switch_no_attack_penalty; + } + } + + // Progressive click penalty: linear ramp above threshold + if (cfg->click_penalty_enabled && p->clicks_this_tick > cfg->click_penalty_threshold) { + int excess = p->clicks_this_tick - cfg->click_penalty_threshold; + reward += cfg->click_penalty_coef * (float)excess; + } + + if (!cfg->enabled) { + return reward; + } + + // Terminal shaping bonuses (only when shaping enabled) + if (env->episode_over) { + if (env->winner == agent_idx) { + // KO bonus: opponent still had food — we killed through supplies + if (t->food_count > 0 || t->karambwan_count > 0 || t->brew_doses > 0) { + reward += cfg->ko_bonus; + } + } else if (env->winner == (1 - agent_idx)) { + // Wasted resources: we died with food left — failed to use supplies + if (p->food_count > 0 || p->karambwan_count > 0 || p->brew_doses > 0) { + reward += cfg->wasted_resources_penalty; + } + } + } + + // ========================================================================== + // Per-tick reward shaping + // ========================================================================== + float tick_shaping = 0.0f; + float base_hp = (float)p->base_hitpoints; + + // Damage dealt: reward aggression + if (p->damage_dealt_scale > 0.0f) { + float damage_hp = p->damage_dealt_scale * base_hp; + tick_shaping += damage_hp * cfg->damage_dealt_coef; + // Burst bonus: reward big hits that set up KOs + if (damage_hp >= (float)cfg->damage_burst_threshold) { + tick_shaping += (damage_hp - (float)cfg->damage_burst_threshold + 1.0f) + * cfg->damage_burst_bonus; + } + } + + // Damage received: small penalty + if (p->damage_received_scale > 0.0f) { + tick_shaping += p->damage_received_scale * base_hp * cfg->damage_received_coef; + } + + // Correct defensive prayer: opponent attacked and we prayed correctly + if (t->just_attacked) { + if (p->player_prayed_correct) { + tick_shaping += cfg->correct_prayer_bonus; + } else { + tick_shaping += cfg->wrong_prayer_penalty; + } + } + + // NOTE: prayer switch penalty moved above !cfg->enabled gate (always-on). + // Not duplicated here to avoid double-counting when shaping is enabled. + + // Off-prayer hit and offensive prayer checks: we attacked + if (p->just_attacked) { + if (!p->target_prayed_correct) { + tick_shaping += cfg->off_prayer_hit_bonus; + } + + // Bad behavior: melee attack when frozen and out of range + if (p->attack_style_this_tick == ATTACK_STYLE_MELEE + && p->frozen_ticks > 0 && !is_in_melee_range(p, t)) { + tick_shaping += cfg->melee_frozen_penalty; + } + + // Spec timing rewards + if (p->used_special_this_tick) { + // Off prayer: target NOT praying melee + if (t->prayer != PRAYER_PROTECT_MELEE) { + tick_shaping += cfg->spec_off_prayer_bonus; + } + // Low defence: target in mage gear (mystic has no melee def) + AttackStyle target_style = get_slot_weapon_attack_style(t); + if (target_style == ATTACK_STYLE_MAGIC) { + tick_shaping += cfg->spec_low_defence_bonus; + } + // Low HP: target below 50% + float target_hp_pct = (float)t->current_hitpoints / (float)t->base_hitpoints; + if (target_hp_pct < 0.5f) { + tick_shaping += cfg->spec_low_hp_bonus; + } + } + + // Bad behavior: magic attack without staff equipped + if (p->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + AttackStyle weapon_style = get_slot_weapon_attack_style(p); + if (weapon_style != ATTACK_STYLE_MAGIC) { + tick_shaping += cfg->magic_no_staff_penalty; + } + } + + // Gear mismatch penalty: attacking with negative attack bonus for the style + GearBonuses* gear = get_slot_gear_bonuses(p); + int attack_bonus = 0; + switch (p->attack_style_this_tick) { + case ATTACK_STYLE_MAGIC: + attack_bonus = gear->magic_attack; + break; + case ATTACK_STYLE_RANGED: + attack_bonus = gear->ranged_attack; + break; + case ATTACK_STYLE_MELEE: + // Use max of stab/slash/crush for melee + attack_bonus = gear->slash_attack; + if (gear->stab_attack > attack_bonus) attack_bonus = gear->stab_attack; + if (gear->crush_attack > attack_bonus) attack_bonus = gear->crush_attack; + break; + default: + break; + } + if (attack_bonus < 0) { + tick_shaping += cfg->gear_mismatch_penalty; + } + } + + // Eating penalties (not attack-related) + int ate_food = p->ate_food_this_tick; + int ate_karam = p->ate_karambwan_this_tick; + int ate_brew = p->ate_brew_this_tick; + + if (ate_food || ate_karam) { + float hp_before = p->prev_hp_percent; + // Premature eating: penalize eating above threshold + if (hp_before > cfg->premature_eat_threshold) { + tick_shaping += cfg->premature_eat_penalty; + } + // Wasted healing: penalize overflow past max HP + float max_heal; + if (ate_food) { + max_heal = 20.0f / base_hp; // Sharks heal 20 + } else { + max_heal = 18.0f / base_hp; // Karambwan heals 18 + } + float wasted = hp_before + max_heal - 1.0f; + if (wasted > 0.0f) { + float wasted_hp = wasted * base_hp; + tick_shaping += cfg->wasted_eat_penalty * wasted_hp; + } + } + + // Triple eat timing (shark + brew + karam = 54 HP) + if (ate_food && ate_brew && ate_karam) { + float hp_before = p->prev_hp_percent; + float hp_threshold = 45.0f / base_hp; + if (hp_before <= hp_threshold) { + tick_shaping += cfg->smart_triple_eat_bonus; + } else { + float food_brew_heal = (20.0f + 16.0f) / base_hp; + float hp_after_food_brew = hp_before + food_brew_heal; + if (hp_after_food_brew > 1.0f) hp_after_food_brew = 1.0f; + float missing_after = 1.0f - hp_after_food_brew; + float karam_heal_norm = 18.0f / base_hp; + float wasted_karam = karam_heal_norm - missing_after; + if (wasted_karam > 0.0f) { + float wasted_karam_hp = wasted_karam * base_hp; + tick_shaping += cfg->wasted_triple_eat_penalty * wasted_karam_hp; + } + } + } + + reward += tick_shaping * cfg->shaping_scale; + + // KO bonus and wasted resources are in the base terminal reward (not shaped) + + return reward; +} + +#endif // OSRS_PVP_ACTIONS_H diff --git a/ocean/osrs/osrs_pvp_anim.h b/ocean/osrs/osrs_pvp_anim.h new file mode 100644 index 0000000000..9c618cd202 --- /dev/null +++ b/ocean/osrs/osrs_pvp_anim.h @@ -0,0 +1,681 @@ +/** + * @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 scripts/export_animations.py: + * header: uint32 magic ("ANIM"), uint16 framebase_count, uint16 sequence_count + * framebases section, sequences section with inlined frame data. + */ + +#ifndef OSRS_PVP_ANIM_H +#define OSRS_PVP_ANIM_H + +#include +#include +#include +#include +#include + +#define ANIM_MAGIC 0x414E494D /* "ANIM" */ +#define ANIM_MAX_SLOTS 256 +#define ANIM_MAX_LABELS 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; +} + +/* ======================================================================== */ +/* data structures */ +/* ======================================================================== */ + +typedef struct { + uint16_t base_id; + uint8_t slot_count; + uint8_t* types; /* [slot_count] transform type per slot */ + uint8_t* map_lengths; /* [slot_count] label count per slot */ + uint8_t** frame_maps; /* [slot_count][map_lengths[i]] label indices */ +} AnimFrameBase; + +typedef struct { + uint8_t slot_index; + int16_t dx, dy, dz; +} AnimTransform; + +typedef struct { + uint16_t framebase_id; + uint8_t transform_count; + AnimTransform* transforms; +} AnimFrameData; + +typedef struct { + uint16_t delay; /* game ticks (600ms each) */ + AnimFrameData frame; +} AnimSequenceFrame; + +typedef struct { + uint16_t seq_id; + uint16_t frame_count; + uint8_t interleave_count; + uint8_t* interleave_order; + int8_t walk_flag; /* -1=default (no stall), 0=stall movement during anim */ + AnimSequenceFrame* frames; +} AnimSequence; + +typedef struct { + AnimFrameBase* bases; + int base_count; + uint16_t* base_ids; /* for lookup by id */ + + AnimSequence* sequences; + int seq_count; +} AnimCache; + +/* per-model animation working state */ +typedef struct { + /* transformed vertex positions (working copy of base_vertices) */ + int16_t* verts; /* [base_vert_count * 3] */ + int vert_count; + + /* vertex group lookup: groups[label] = { vertex indices } */ + int** groups; /* [ANIM_MAX_LABELS] arrays of vertex indices */ + int* group_counts; /* [ANIM_MAX_LABELS] count per group */ +} AnimModelState; + +/* ======================================================================== */ +/* loading */ +/* ======================================================================== */ + +static uint8_t anim_read_u8(const uint8_t** p) { + uint8_t v = **p; (*p)++; + return v; +} + +static uint16_t anim_read_u16(const uint8_t** p) { + uint16_t v = (uint16_t)((*p)[0]) | ((uint16_t)((*p)[1]) << 8); + *p += 2; + return v; +} + +static int16_t anim_read_i16(const uint8_t** p) { + int16_t v = (int16_t)((uint16_t)((*p)[0]) | ((uint16_t)((*p)[1]) << 8)); + *p += 2; + return v; +} + +static uint32_t anim_read_u32(const uint8_t** p) { + uint32_t v = (uint32_t)((*p)[0]) + | ((uint32_t)((*p)[1]) << 8) + | ((uint32_t)((*p)[2]) << 16) + | ((uint32_t)((*p)[3]) << 24); + *p += 4; + return v; +} + +static AnimCache* anim_cache_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (!f) { + fprintf(stderr, "anim_cache_load: cannot open %s\n", path); + return NULL; + } + + fseek(f, 0, SEEK_END); + long size = ftell(f); + fseek(f, 0, SEEK_SET); + + uint8_t* buf = (uint8_t*)malloc(size); + fread(buf, 1, size, f); + fclose(f); + + const uint8_t* p = buf; + + uint32_t magic = anim_read_u32(&p); + if (magic != ANIM_MAGIC) { + fprintf(stderr, "anim_cache_load: bad magic 0x%08X\n", magic); + free(buf); + return NULL; + } + + AnimCache* cache = (AnimCache*)calloc(1, sizeof(AnimCache)); + cache->base_count = anim_read_u16(&p); + cache->seq_count = anim_read_u16(&p); + + /* load framebases */ + cache->bases = (AnimFrameBase*)calloc(cache->base_count, sizeof(AnimFrameBase)); + cache->base_ids = (uint16_t*)malloc(cache->base_count * sizeof(uint16_t)); + + for (int i = 0; i < cache->base_count; i++) { + AnimFrameBase* fb = &cache->bases[i]; + fb->base_id = anim_read_u16(&p); + cache->base_ids[i] = fb->base_id; + fb->slot_count = anim_read_u8(&p); + + fb->types = (uint8_t*)malloc(fb->slot_count); + for (int s = 0; s < fb->slot_count; s++) { + fb->types[s] = anim_read_u8(&p); + } + + fb->map_lengths = (uint8_t*)malloc(fb->slot_count); + fb->frame_maps = (uint8_t**)malloc(fb->slot_count * sizeof(uint8_t*)); + for (int s = 0; s < fb->slot_count; s++) { + uint8_t ml = anim_read_u8(&p); + fb->map_lengths[s] = ml; + fb->frame_maps[s] = (uint8_t*)malloc(ml); + for (int j = 0; j < ml; j++) { + fb->frame_maps[s][j] = anim_read_u8(&p); + } + } + } + + /* load sequences */ + cache->sequences = (AnimSequence*)calloc(cache->seq_count, sizeof(AnimSequence)); + for (int i = 0; i < cache->seq_count; i++) { + AnimSequence* seq = &cache->sequences[i]; + seq->seq_id = anim_read_u16(&p); + seq->frame_count = anim_read_u16(&p); + + seq->interleave_count = anim_read_u8(&p); + if (seq->interleave_count > 0) { + seq->interleave_order = (uint8_t*)malloc(seq->interleave_count); + for (int j = 0; j < seq->interleave_count; j++) { + seq->interleave_order[j] = anim_read_u8(&p); + } + } + + seq->walk_flag = (int8_t)anim_read_u8(&p); + + seq->frames = (AnimSequenceFrame*)calloc(seq->frame_count, sizeof(AnimSequenceFrame)); + for (int fi = 0; fi < seq->frame_count; fi++) { + AnimSequenceFrame* sf = &seq->frames[fi]; + sf->delay = anim_read_u16(&p); + sf->frame.framebase_id = anim_read_u16(&p); + sf->frame.transform_count = anim_read_u8(&p); + + if (sf->frame.transform_count > 0) { + sf->frame.transforms = (AnimTransform*)malloc( + sf->frame.transform_count * sizeof(AnimTransform)); + for (int t = 0; t < sf->frame.transform_count; t++) { + sf->frame.transforms[t].slot_index = anim_read_u8(&p); + sf->frame.transforms[t].dx = anim_read_i16(&p); + sf->frame.transforms[t].dy = anim_read_i16(&p); + sf->frame.transforms[t].dz = anim_read_i16(&p); + } + } + } + } + + 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 */ +/* ======================================================================== */ + +static 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; +} + +static 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 */ +/* ======================================================================== */ + +static 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; +} + +static 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) */ +/* ======================================================================== */ + +static 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 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 = (vx * cos_y - vz * sin_y) >> 16; + rz = (vx * sin_y + vz * cos_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 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 = (vx * cos_y - vz * sin_y) >> 16; + rz = (vx * sin_y + vz * cos_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); + } + } +} + +/* ======================================================================== */ +/* 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). + */ +static 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 */ +/* ======================================================================== */ + +static 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); +} + +#endif /* OSRS_PVP_ANIM_H */ diff --git a/ocean/osrs/osrs_pvp_api.h b/ocean/osrs/osrs_pvp_api.h new file mode 100644 index 0000000000..a98331099f --- /dev/null +++ b/ocean/osrs/osrs_pvp_api.h @@ -0,0 +1,777 @@ +/** + * @file osrs_pvp_api.h + * @brief Public API for OSRS PvP simulation + * + * Provides the public interface for: + * - Player initialization + * - Environment reset (pvp_reset) + * - Environment step (pvp_step) + * - Seeding for deterministic runs (pvp_seed) + * - Cleanup (pvp_close) + */ + +#ifndef OSRS_PVP_API_H +#define OSRS_PVP_API_H + +#include "osrs_types.h" +#include "osrs_pvp_gear.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_movement.h" +#include "osrs_pvp_observations.h" +#include "osrs_pvp_actions.h" + +// ============================================================================ +// PLAYER INITIALIZATION +// ============================================================================ + +/** + * Initialize a player with default pure build stats and gear. + * + * Sets up: + * - Base stats (75 attack, 99 strength, etc.) + * - Current stats equal to base + * - Starting gear (mage setup) + * - Consumables (food, brews, restores) + * - All timers reset to 0 + * - Sequential mode equipment state + * + * @param p Player to initialize + */ +static void init_player(Player* p) { + p->base_attack = MAXED_BASE_ATTACK; + p->base_strength = MAXED_BASE_STRENGTH; + p->base_defence = MAXED_BASE_DEFENCE; + p->base_ranged = MAXED_BASE_RANGED; + p->base_magic = MAXED_BASE_MAGIC; + p->base_prayer = MAXED_BASE_PRAYER; + p->base_hitpoints = MAXED_BASE_HITPOINTS; + + p->current_attack = p->base_attack; + p->current_strength = p->base_strength; + p->current_defence = p->base_defence; + p->current_ranged = p->base_ranged; + p->current_magic = p->base_magic; + p->current_prayer = p->base_prayer; + p->current_hitpoints = p->base_hitpoints; + + p->special_energy = 100; + p->special_regen_ticks = 0; + p->spec_regen_active = 0; + p->was_lightbearer_equipped = 0; + p->special_active = 0; + + p->current_gear = GEAR_MAGE; + p->visible_gear = GEAR_MAGE; + + p->food_count = MAXED_FOOD_COUNT; + p->karambwan_count = MAXED_KARAMBWAN_COUNT; + p->brew_doses = MAXED_BREW_DOSES; + p->restore_doses = MAXED_RESTORE_DOSES; + p->combat_potion_doses = MAXED_COMBAT_POTION_DOSES; + p->ranged_potion_doses = MAXED_RANGED_POTION_DOSES; + + p->attack_timer = 0; + p->attack_timer_uncapped = 0; + p->has_attack_timer = 0; + p->food_timer = 0; + p->potion_timer = 0; + p->karambwan_timer = 0; + p->consumable_used_this_tick = 0; + p->last_food_heal = 0; + p->last_food_waste = 0; + p->last_karambwan_heal = 0; + p->last_karambwan_waste = 0; + p->last_brew_heal = 0; + p->last_brew_waste = 0; + p->last_potion_type = 0; + p->last_potion_was_waste = 0; + + p->frozen_ticks = 0; + p->freeze_immunity_ticks = 0; + + p->veng_active = 0; + p->veng_cooldown = 0; + p->recoil_charges = 0; + + p->prayer = PRAYER_NONE; + p->offensive_prayer = OFFENSIVE_PRAYER_NONE; + p->fight_style = FIGHT_STYLE_ACCURATE; + p->prayer_drain_counter = 0; + p->morr_dot_remaining = 0; + p->morr_dot_tick_counter = 0; + + p->x = 0; + p->y = 0; + p->dest_x = 0; + p->dest_y = 0; + p->is_moving = 0; + p->is_running = 0; + p->run_energy = 100; + p->run_recovery_ticks = 0; + p->last_obs_target_x = 0; + p->last_obs_target_y = 0; + + p->just_attacked = 0; + p->last_attack_style = ATTACK_STYLE_NONE; + p->attack_was_on_prayer = 0; + p->last_attack_dx = 0; + p->last_attack_dy = 0; + p->last_attack_dist = 0; + p->attack_click_canceled = 0; + p->attack_click_ready = 0; + + memset(p->pending_hits, 0, sizeof(p->pending_hits)); + p->num_pending_hits = 0; + p->damage_applied_this_tick = 0; + p->did_attack_auto_move = 0; + + // Hit event tracking + p->hit_landed_this_tick = 0; + p->hit_was_successful = 0; + p->hit_damage = 0; + p->hit_style = ATTACK_STYLE_NONE; + p->hit_defender_prayer = PRAYER_NONE; + p->hit_was_on_prayer = 0; + p->hit_attacker_idx = -1; + p->freeze_applied_this_tick = 0; + + p->last_target_health_percent = 0.0f; + p->tick_damage_scale = 0.0f; + p->damage_dealt_scale = 0.0f; + p->damage_received_scale = 0.0f; + + p->total_target_hit_count = 0; + p->target_hit_melee_count = 0; + p->target_hit_ranged_count = 0; + p->target_hit_magic_count = 0; + p->target_hit_off_prayer_count = 0; + p->target_hit_correct_count = 0; + + p->total_target_pray_count = 0; + p->target_pray_melee_count = 0; + p->target_pray_ranged_count = 0; + p->target_pray_magic_count = 0; + p->target_pray_correct_count = 0; + + p->player_hit_melee_count = 0; + p->player_hit_ranged_count = 0; + p->player_hit_magic_count = 0; + + p->player_pray_melee_count = 0; + p->player_pray_ranged_count = 0; + p->player_pray_magic_count = 0; + + memset(p->recent_target_attack_styles, 0, sizeof(p->recent_target_attack_styles)); + memset(p->recent_player_attack_styles, 0, sizeof(p->recent_player_attack_styles)); + memset(p->recent_target_prayer_styles, 0, sizeof(p->recent_target_prayer_styles)); + memset(p->recent_player_prayer_styles, 0, sizeof(p->recent_player_prayer_styles)); + memset(p->recent_target_prayer_correct, 0, sizeof(p->recent_target_prayer_correct)); + memset(p->recent_target_hit_correct, 0, sizeof(p->recent_target_hit_correct)); + p->recent_target_attack_index = 0; + p->recent_player_attack_index = 0; + p->recent_target_prayer_index = 0; + p->recent_player_prayer_index = 0; + p->recent_target_prayer_correct_index = 0; + p->recent_target_hit_correct_index = 0; + + p->target_magic_accuracy = -1; + p->target_magic_strength = -1; + p->target_ranged_accuracy = -1; + p->target_ranged_strength = -1; + p->target_melee_accuracy = -1; + p->target_melee_strength = -1; + p->target_magic_gear_magic_defence = -1; + p->target_magic_gear_ranged_defence = -1; + p->target_magic_gear_melee_defence = -1; + p->target_ranged_gear_magic_defence = -1; + p->target_ranged_gear_ranged_defence = -1; + p->target_ranged_gear_melee_defence = -1; + p->target_melee_gear_magic_defence = -1; + p->target_melee_gear_ranged_defence = -1; + p->target_melee_gear_melee_defence = -1; + + p->player_prayed_correct = 0; + p->target_prayed_correct = 0; + + p->total_damage_dealt = 0; + p->total_damage_received = 0; + + p->is_lunar_spellbook = 0; + p->observed_target_lunar_spellbook = 0; + p->has_blood_fury = 1; + p->has_dharok = 0; + + p->melee_spec_weapon = MELEE_SPEC_NONE; + p->ranged_spec_weapon = RANGED_SPEC_NONE; + p->magic_spec_weapon = MAGIC_SPEC_NONE; + + p->bolt_proc_damage = 0.2f; + p->bolt_ignores_defense = 0; + + p->prev_hp_percent = 1.0f; // Full HP at start +} + +// ============================================================================ +// FIGHT POSITIONING +// ============================================================================ + +/** + * Set initial fight positions for both players. + * + * In seeded mode: deterministic positions. + * Otherwise: random positions within fight area, nearby each other. + * + * @param env Environment + */ +static void set_fight_positions(OsrsPvp* env) { + if (env->has_rng_seed) { + int x0 = FIGHT_AREA_BASE_X; + int y0 = FIGHT_AREA_BASE_Y; + int x1 = FIGHT_AREA_BASE_X + FIGHT_NEARBY_RADIUS; + int y1 = FIGHT_AREA_BASE_Y; + + env->players[0].x = x0; + env->players[0].y = y0; + env->players[0].dest_x = x0; + env->players[0].dest_y = y0; + env->players[0].is_moving = 0; + + env->players[1].x = x1; + env->players[1].y = y1; + env->players[1].dest_x = x1; + env->players[1].dest_y = y1; + env->players[1].is_moving = 0; + return; + } + + int base_x = FIGHT_AREA_BASE_X; + int base_y = FIGHT_AREA_BASE_Y; + int max_x = base_x + FIGHT_AREA_WIDTH; + int max_y = base_y + FIGHT_AREA_HEIGHT; + + int x0 = base_x + rand_int(env, FIGHT_AREA_WIDTH); + int y0 = base_y + rand_int(env, FIGHT_AREA_HEIGHT); + + int near_min_x = max_int(base_x, x0 - FIGHT_NEARBY_RADIUS); + int near_min_y = max_int(base_y, y0 - FIGHT_NEARBY_RADIUS); + int near_max_x = min_int(max_x, x0 + FIGHT_NEARBY_RADIUS); + int near_max_y = min_int(max_y, y0 + FIGHT_NEARBY_RADIUS); + + int x1 = near_min_x + rand_int(env, near_max_x - near_min_x); + int y1 = near_min_y + rand_int(env, near_max_y - near_min_y); + + env->players[0].x = x0; + env->players[0].y = y0; + env->players[0].dest_x = x0; + env->players[0].dest_y = y0; + env->players[0].is_moving = 0; + + env->players[1].x = x1; + env->players[1].y = y1; + env->players[1].dest_x = x1; + env->players[1].dest_y = y1; + env->players[1].is_moving = 0; +} + +// ============================================================================ +// PUBLIC API +// ============================================================================ + +/** + * Initialize internal buffer pointers for ocean pattern. + * + * Points observations/actions/rewards/terminals/action_masks at the internal + * _*_buf arrays so game logic writes to local storage. PufferLib shared + * buffers are accessed via ocean_* pointers set by the binding. + * + * @param env Environment to initialize + */ +void pvp_init(OsrsPvp* env) { + env->observations = env->_obs_buf; + env->actions = env->_acts_buf; + env->rewards = env->_rews_buf; + env->terminals = env->_terms_buf; + env->action_masks = env->_masks_buf; + env->action_masks_agents = 0x3; // Both agents get masks + + memset(env->_obs_buf, 0, sizeof(env->_obs_buf)); + memset(env->_acts_buf, 0, sizeof(env->_acts_buf)); + memset(env->_rews_buf, 0, sizeof(env->_rews_buf)); + memset(env->_terms_buf, 0, sizeof(env->_terms_buf)); + memset(env->_masks_buf, 0, sizeof(env->_masks_buf)); + + env->_episode_return = 0.0f; + env->has_rng_seed = 0; + env->is_lms = 1; + env->is_pvp_arena = 0; + env->auto_reset = 1; + env->use_c_opponent = 0; + env->use_c_opponent_p0 = 0; + env->use_external_opponent_actions = 0; + env->ocean_obs_p1 = NULL; + env->ocean_selfplay_mask = NULL; + memset(env->external_opponent_actions, 0, sizeof(env->external_opponent_actions)); + memset(&env->opponent, 0, sizeof(env->opponent)); + memset(&env->opponent_p0, 0, sizeof(env->opponent_p0)); + memset(&env->shaping, 0, sizeof(env->shaping)); + memset(&env->log, 0, sizeof(env->log)); +} + +/** + * Render stub (required by PufferLib ocean template). + * When OSRS_PVP_VISUAL is defined, osrs_pvp_render.h provides the real implementation. + */ +#ifndef OSRS_PVP_VISUAL +void pvp_render(OsrsPvp* env) { + (void)env; +} +#endif + +/** + * Reset the environment to initial state. + * + * Initializes both players, sets fight positions, resets tick counter, + * and generates initial observations. + * + * @param env Environment to reset + */ +void pvp_reset(OsrsPvp* env) { + if (env->has_rng_seed) { + if (env->rng_seed == 0) { + fprintf(stderr, "Error: seed must be non-zero (use seed=1 or higher in reset())\n"); + abort(); + } + env->rng_state = env->rng_seed; + } else { + env->rng_state = (uint32_t)(size_t)env ^ 0xDEADBEEF; + } + + init_player(&env->players[0]); + init_player(&env->players[1]); + + // LMS overrides: defence capped at 75, prayer is 99 (no drain in LMS) + for (int i = 0; i < NUM_AGENTS; i++) { + env->players[i].is_lms = env->is_lms; + if (env->is_lms) { + env->players[i].base_defence = LMS_BASE_DEFENCE; + env->players[i].current_defence = LMS_BASE_DEFENCE; + env->players[i].base_prayer = 99; + env->players[i].current_prayer = 99; + } + } + + set_fight_positions(env); + + // Initialize last_obs_target to actual opponent positions + // (needed for first-tick movement commands like farcast) + env->players[0].last_obs_target_x = env->players[1].x; + env->players[0].last_obs_target_y = env->players[1].y; + env->players[1].last_obs_target_x = env->players[0].x; + env->players[1].last_obs_target_y = env->players[0].y; + + env->tick = 0; + env->episode_over = 0; + env->winner = -1; + if (env->has_rng_seed) { + env->pid_holder = 1 - (int)(env->rng_seed & 1u); + } else { + env->pid_holder = rand_int(env, 2); + } + env->pid_shuffle_countdown = 100 + rand_int(env, 51); // 100-150 ticks + + // NOTE: is_lms is NOT reset here - it's controlled by set_lms() from Python + // env->is_lms = 0; + env->is_pvp_arena = 0; + + env->_episode_return = 0.0f; + + memset(env->rewards, 0, NUM_AGENTS * sizeof(float)); + memset(env->terminals, 0, NUM_AGENTS); + + // Clear action buffers. With immediate application, pending is not used + // for timing - actions are applied in the same step they're input. + // This gives OSRS-correct 1-tick delay: action at tick N → effects at tick N+1. + memset(env->pending_actions, 0, sizeof(env->pending_actions)); + memset(env->last_executed_actions, 0, sizeof(env->last_executed_actions)); + env->has_pending_actions = 0; // Not used with immediate application + + // Initialize slot mode equipment with correlated per-episode gear randomization + // LMS tier distribution: 80% same, 15% ±1 tier, 5% ±2 tiers + int base_tier = sample_gear_tier(env->gear_tier_weights, &env->rng_state); + int p1_tier = base_tier; + + float tier_roll = (float)xorshift32(&env->rng_state) / (float)UINT32_MAX; + if (tier_roll >= 0.80f && tier_roll < 0.95f) { + int dir = (xorshift32(&env->rng_state) & 1) ? 1 : -1; + p1_tier = base_tier + dir; + } else if (tier_roll >= 0.95f) { + int dir = (xorshift32(&env->rng_state) & 1) ? 1 : -1; + p1_tier = base_tier + dir * 2; + } + if (p1_tier < 0) p1_tier = 0; + if (p1_tier > 3) p1_tier = 3; + + int tiers[NUM_AGENTS] = { base_tier, p1_tier }; + for (int i = 0; i < NUM_AGENTS; i++) { + init_player_gear_randomized(&env->players[i], tiers[i], &env->rng_state); + env->players[i].food_count = compute_food_count(&env->players[i]); + env->players[i].recoil_charges = + has_recoil_effect(&env->players[i]) ? RECOIL_MAX_CHARGES : 0; + } + + // Reset C-side opponent state for new episode + // Always reset when PFSP is configured (selfplay toggle happens inside opponent_reset) + if (env->use_c_opponent || env->opponent.type == OPP_PFSP) { + opponent_reset(env, &env->opponent); + } + if (env->use_c_opponent_p0) { + opponent_reset(env, &env->opponent_p0); + } + + for (int i = 0; i < NUM_AGENTS; i++) { + generate_slot_observations(env, i); + if (env->action_masks != NULL && (env->action_masks_agents & (1 << i))) { + compute_action_masks(env, i); + } + } +} + +/** + * Execute one game tick with OSRS-accurate 1-tick delay timing. + * + * Actions submitted on tick N are applied IMMEDIATELY in the same step, + * producing tick N+1 state. This gives proper 1-tick delay: + * action at tick N → effects visible at tick N+1. + * + * Flow: + * 1. Copy model/external actions to env->actions + * 2. Generate C opponent actions into env->actions + * 3. Apply actions immediately (execute switches, then attacks) + * 4. Increment tick + * 5. Check win conditions + * 6. Calculate rewards + * 7. Generate observations + * + * @param env Environment + */ +void pvp_step(OsrsPvp* env) { + memset(env->rewards, 0, NUM_AGENTS * sizeof(float)); + memset(env->terminals, 0, NUM_AGENTS); + + // Reset per-tick flags at START (clears flags from PREVIOUS tick) + // This allows get_state() to read flags after pvp_step() returns + for (int i = 0; i < NUM_AGENTS; i++) { + env->players[i].hit_landed_this_tick = 0; + env->players[i].hit_was_successful = 0; + env->players[i].hit_damage = 0; + env->players[i].hit_style = ATTACK_STYLE_NONE; + env->players[i].hit_defender_prayer = PRAYER_NONE; + env->players[i].hit_was_on_prayer = 0; + env->players[i].hit_attacker_idx = -1; + env->players[i].freeze_applied_this_tick = 0; + } + reset_tick_flags(&env->players[0]); + reset_tick_flags(&env->players[1]); + + // ======================================================================== + // PHASE 1: Gather actions from all sources into env->actions + // ======================================================================== + + // Copy model's actions (player 0) or clear if C opponent controls p0 + if (env->use_c_opponent_p0) { + memset(env->actions, 0, NUM_ACTION_HEADS * sizeof(int)); + } else { + memcpy(env->actions, env->ocean_acts, NUM_ACTION_HEADS * sizeof(int)); + } + + // Copy external opponent actions (player 1) or clear for C opponent + if (env->use_external_opponent_actions) { + memcpy( + env->actions + NUM_ACTION_HEADS, + env->external_opponent_actions, + NUM_ACTION_HEADS * sizeof(int) + ); + } else { + memset(env->actions + NUM_ACTION_HEADS, 0, NUM_ACTION_HEADS * sizeof(int)); + } + + // Generate C opponent actions (writes to pending_actions, then copy to actions) + if (env->use_c_opponent && !env->use_external_opponent_actions) { + generate_opponent_action(env, &env->opponent); + // Copy C opponent's action from pending to actions buffer + memcpy( + env->actions + NUM_ACTION_HEADS, + env->pending_actions + NUM_ACTION_HEADS, + NUM_ACTION_HEADS * sizeof(int) + ); + } + if (env->use_c_opponent_p0) { + generate_opponent_action_for_player0(env, &env->opponent_p0); + // Copy C opponent's action from pending to actions buffer + memcpy( + env->actions, + env->pending_actions, + NUM_ACTION_HEADS * sizeof(int) + ); + } + + int first = env->pid_holder; + int second = 1 - env->pid_holder; + + // ======================================================================== + // PHASE 2: Apply actions IMMEDIATELY (not pending from previous step) + // ======================================================================== + + // Copy actions to local arrays for each agent + int actions_p0[NUM_ACTION_HEADS]; + int actions_p1[NUM_ACTION_HEADS]; + memcpy(actions_p0, env->actions, NUM_ACTION_HEADS * sizeof(int)); + memcpy(actions_p1, env->actions + NUM_ACTION_HEADS, NUM_ACTION_HEADS * sizeof(int)); + + // Clamp impossible cross-head combos: + // - MELEE/RANGE/SPEC_MELEE/SPEC_RANGE/GMAUL cannot cast spells + // - MAGE/TANK cannot use ATK (except SPEC_MAGIC which forces ATK internally) + for (int i = 0; i < NUM_AGENTS; i++) { + int* a = (i == 0) ? actions_p0 : actions_p1; + int lo = a[HEAD_LOADOUT]; + int cv = a[HEAD_COMBAT]; + if (lo == LOADOUT_MAGE || lo == LOADOUT_TANK || lo == LOADOUT_SPEC_MAGIC) { + if (cv == ATTACK_ATK) { + a[HEAD_COMBAT] = ATTACK_NONE; + } + } + } + + // Write clamped actions back for recording and read functions + memcpy(env->actions, actions_p0, NUM_ACTION_HEADS * sizeof(int)); + memcpy(env->actions + NUM_ACTION_HEADS, actions_p1, NUM_ACTION_HEADS * sizeof(int)); + + // Save executed actions for recording + memcpy( + env->last_executed_actions, + env->actions, + NUM_AGENTS * NUM_ACTION_HEADS * sizeof(int) + ); + + update_timers(&env->players[0]); + update_timers(&env->players[1]); + + // Save HP percent BEFORE actions execute (for reward shaping eat checks) + for (int i = 0; i < NUM_AGENTS; i++) { + Player* pi = &env->players[i]; + pi->prev_hp_percent = (float)pi->current_hitpoints / (float)pi->base_hitpoints; + } + + // Resolve local action arrays by PID order + int* agent_actions[NUM_AGENTS]; + agent_actions[0] = actions_p0; + agent_actions[1] = actions_p1; + + // Save positions before movement for walk/run detection + int pre_move_x[NUM_AGENTS], pre_move_y[NUM_AGENTS]; + for (int i = 0; i < NUM_AGENTS; i++) { + pre_move_x[i] = env->players[i].x; + pre_move_y[i] = env->players[i].y; + } + + // CRITICAL: Two-phase execution for correct prayer timing + // Phase 2A: Apply switches (gear, prayer, consumables, movement) for BOTH players + // This ensures attacks will see the correct prayer state + execute_switches(env, first, agent_actions[first]); + execute_switches(env, second, agent_actions[second]); + + // Decrement consumable timers AFTER eating so obs shows correct countdown + // (eat → 2, 1, Ready instead of eat → 3, 2, 1 with Ready never visible) + for (int i = 0; i < NUM_AGENTS; i++) { + Player* pi = &env->players[i]; + if (pi->food_timer > 0) pi->food_timer--; + if (pi->potion_timer > 0) pi->potion_timer--; + if (pi->karambwan_timer > 0) pi->karambwan_timer--; + } + + // Resolve same-tile stacking (OSRS prevents unfrozen players from sharing a tile) + if (env->players[0].x == env->players[1].x && + env->players[0].y == env->players[1].y) { + resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); + } + + // Phase 2B: Attack movement for BOTH players (auto-walk to melee range, step-out) + // All movements resolve before any range checks, matching OSRS tick processing. + // This prevents PID-dependent behavior where one player's movement check depends + // on whether the other player has already stepped out. + execute_attack_movement(env, first, agent_actions[first]); + execute_attack_movement(env, second, agent_actions[second]); + + // Resolve same-tile after attack movements (step-out may have caused overlap) + if (env->players[0].x == env->players[1].x && + env->players[0].y == env->players[1].y) { + resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); + } + + // Phase 2C: Attack combat for BOTH players (range check + attack + chase) + // dist is recomputed from CURRENT positions after all movements resolved. + execute_attack_combat(env, first, agent_actions[first]); + execute_attack_combat(env, second, agent_actions[second]); + + // Resolve same-tile after attack-phase chase movement + if (env->players[0].x == env->players[1].x && + env->players[0].y == env->players[1].y) { + resolve_same_tile(&env->players[second], &env->players[first], (const CollisionMap*)env->collision_map); + } + + // Compute walk vs run: Chebyshev distance moved this tick + // 1 tile = walk, 2+ tiles = run (OSRS sends 1 waypoint for walk, 2 for run) + for (int i = 0; i < NUM_AGENTS; i++) { + int dx = abs(env->players[i].x - pre_move_x[i]); + int dy = abs(env->players[i].y - pre_move_y[i]); + int dist = (dx > dy) ? dx : dy; + env->players[i].is_running = (dist >= 2) ? 1 : 0; + } + + process_pending_hits(env, 0, 1); + process_pending_hits(env, 1, 0); + + // Morrigan's javelin DoT: 5 HP every 3 ticks from calc tick + for (int i = 0; i < NUM_AGENTS; i++) { + Player* p = &env->players[i]; + if (p->morr_dot_remaining > 0) { + p->morr_dot_tick_counter--; + if (p->morr_dot_tick_counter <= 0) { + int dot_dmg = (p->morr_dot_remaining >= 5) ? 5 : p->morr_dot_remaining; + p->current_hitpoints -= dot_dmg; + p->morr_dot_remaining -= dot_dmg; + p->damage_applied_this_tick += dot_dmg; + if (p->current_hitpoints < 0) p->current_hitpoints = 0; + p->morr_dot_tick_counter = 3; + } + } + } + + if (env->players[0].veng_active) { + env->players[1].observed_target_lunar_spellbook = 1; + } + if (env->players[1].veng_active) { + env->players[0].observed_target_lunar_spellbook = 1; + } + + // ======================================================================== + // PHASE 3: Increment tick + // ======================================================================== + env->tick++; + + if (!env->has_rng_seed) { + env->pid_shuffle_countdown--; + if (env->pid_shuffle_countdown <= 0) { + env->pid_holder = 1 - env->pid_holder; + env->pid_shuffle_countdown = 100 + rand_int(env, 51); // 100-150 ticks + } + } + + // Keep pending in sync for compatibility (not used for timing anymore) + memcpy(env->pending_actions, env->actions, + NUM_AGENTS * NUM_ACTION_HEADS * sizeof(int)); + env->has_pending_actions = 1; + + // ======================================================================== + // PHASE 4: Check win conditions + // ======================================================================== + for (int i = 0; i < NUM_AGENTS; i++) { + if (env->players[i].current_hitpoints <= 0) { + env->episode_over = 1; + env->winner = 1 - i; + } + } + + // Tick limit: treat timeout as agent 0 loss + if (!env->episode_over && env->tick >= MAX_EPISODE_TICKS) { + env->episode_over = 1; + env->winner = 1; + } + + // ======================================================================== + // PHASE 5: Calculate rewards + // ======================================================================== + for (int i = 0; i < NUM_AGENTS; i++) { + env->rewards[i] = calculate_reward(env, i); + + if (env->episode_over) { + env->terminals[i] = 1; + } + } + + // Accumulate agent 0's episode return (written to log at episode end) + env->_episode_return += env->rewards[0]; + + // ======================================================================== + // PHASE 6: Generate observations (current state, BEFORE new actions apply) + // ======================================================================== + for (int i = 0; i < NUM_AGENTS; i++) { + generate_slot_observations(env, i); + if (env->action_masks != NULL && (env->action_masks_agents & (1 << i))) { + compute_action_masks(env, i); + } + } + + // NOTE: reset_tick_flags() moved to START of pvp_step() so flags survive + // for get_state() to read after step returns + + // Write observations to PufferLib shared buffer + ocean_write_obs(env); + if (env->ocean_obs_p1 != NULL) { + ocean_write_obs_p1(env); + } + env->ocean_rew[0] = env->rewards[0]; + + if (env->episode_over) { + env->ocean_term[0] = 1; + + // PFSP win tracking (all in C, zero Python overhead). + // Skip if pool_idx is -1 (sentinel for pre-pool-config first episode). + if (env->opponent.type == OPP_PFSP && env->pfsp.active_pool_idx >= 0) { + int idx = env->pfsp.active_pool_idx; + env->pfsp.episodes[idx] += 1.0f; + if (env->winner == 0) { + env->pfsp.wins[idx] += 1.0f; + } + } + + // Write final episode stats to log + env->log.episode_return = env->_episode_return; + env->log.episode_length = (float)env->tick; + env->log.damage_dealt = env->players[0].total_damage_dealt; + env->log.damage_received = env->players[0].total_damage_received; + env->log.wins = (env->winner == 0) ? 1.0f : 0.0f; + env->log.n = 1.0f; + + // Auto-reset for next episode + if (env->auto_reset) { + pvp_reset(env); + } + } else { + env->ocean_term[0] = 0; + } +} + +/** + * Set RNG seed for deterministic runs. + * + * @param env Environment + * @param seed Seed value (must be non-zero) + */ +void pvp_seed(OsrsPvp* env, uint32_t seed) { + env->rng_seed = seed; + env->has_rng_seed = 1; +} + +/** + * Cleanup environment resources. + * + * Currently a no-op since all memory is statically allocated. + * + * @param env Environment + */ +void pvp_close(OsrsPvp* env) { + (void)env; +} + +#endif // OSRS_PVP_API_H diff --git a/ocean/osrs/osrs_pvp_combat.h b/ocean/osrs/osrs_pvp_combat.h new file mode 100644 index 0000000000..bb414fe247 --- /dev/null +++ b/ocean/osrs/osrs_pvp_combat.h @@ -0,0 +1,1596 @@ +/** + * @file osrs_pvp_combat.h + * @brief Combat damage calculations and special attacks + * + * Implements OSRS combat formulas: + * - Effective level calculations with prayer/style bonuses + * - Hit chance formula (attack roll vs defence roll) + * - Max hit calculations + * - Special attack damage multipliers + * - Dragon claws cascade algorithm + * + * Reference: OSRS wiki formulas (combat and special attacks) + */ + +#ifndef OSRS_PVP_COMBAT_H +#define OSRS_PVP_COMBAT_H + +#include "osrs_types.h" +#include "osrs_pvp_gear.h" + +/** Check if ring of recoil or ring of suffering (i) is equipped. */ +static inline int has_recoil_effect(Player* p) { + int ring = p->equipped[GEAR_SLOT_RING]; + return ring == ITEM_RING_OF_RECOIL || ring == ITEM_RING_OF_SUFFERING_RI; +} + +// ============================================================================ +// FORWARD DECLARATIONS +// ============================================================================ + +static void register_hit_calculated(OsrsPvp* env, int attacker_idx, int defender_idx, + AttackStyle style, int total_damage); + +// ============================================================================ +// SPEC WEAPON COSTS +// ============================================================================ + +static int get_melee_spec_cost(MeleeSpecWeapon weapon) { + switch (weapon) { + case MELEE_SPEC_AGS: return 50; + case MELEE_SPEC_DRAGON_CLAWS: return 50; + case MELEE_SPEC_GRANITE_MAUL: return 50; + case MELEE_SPEC_DRAGON_DAGGER: return 25; + case MELEE_SPEC_VOIDWAKER: return 50; + case MELEE_SPEC_DWH: return 35; + case MELEE_SPEC_BGS: return 100; + case MELEE_SPEC_ZGS: return 50; + case MELEE_SPEC_SGS: return 50; + case MELEE_SPEC_ANCIENT_GS: return 50; + case MELEE_SPEC_VESTAS: return 25; + case MELEE_SPEC_ABYSSAL_DAGGER: return 50; + case MELEE_SPEC_DRAGON_LONGSWORD:return 25; + case MELEE_SPEC_DRAGON_MACE: return 25; + case MELEE_SPEC_ABYSSAL_BLUDGEON:return 50; + default: return 50; + } +} + +static int get_ranged_spec_cost(RangedSpecWeapon weapon) { + switch (weapon) { + case RANGED_SPEC_DARK_BOW: return 55; + case RANGED_SPEC_BALLISTA: return 65; + case RANGED_SPEC_ACB: return 50; + case RANGED_SPEC_ZCB: return 75; + case RANGED_SPEC_DRAGON_KNIFE:return 25; + case RANGED_SPEC_MSB: return 55; + case RANGED_SPEC_MORRIGANS: return 50; + default: return 50; + } +} + +static int get_magic_spec_cost(MagicSpecWeapon weapon) { + switch (weapon) { + case MAGIC_SPEC_VOLATILE_STAFF: return 55; + default: return 50; + } +} + +// ============================================================================ +// SPEC WEAPON MULTIPLIERS +// ============================================================================ + +static float get_melee_spec_str_mult(MeleeSpecWeapon weapon) { + switch (weapon) { + case MELEE_SPEC_AGS: return 1.375f; + case MELEE_SPEC_DRAGON_CLAWS: return 1.0f; + case MELEE_SPEC_GRANITE_MAUL: return 1.0f; + case MELEE_SPEC_DRAGON_DAGGER: return 1.15f; + case MELEE_SPEC_VOIDWAKER: return 1.0f; + case MELEE_SPEC_DWH: return 1.25f; + case MELEE_SPEC_BGS: return 1.21f; + case MELEE_SPEC_ZGS: return 1.1f; + case MELEE_SPEC_SGS: return 1.1f; + case MELEE_SPEC_ANCIENT_GS: return 1.1f; + case MELEE_SPEC_VESTAS: return 1.20f; + case MELEE_SPEC_ABYSSAL_DAGGER: return 0.85f; + case MELEE_SPEC_DRAGON_LONGSWORD:return 1.15f; + case MELEE_SPEC_DRAGON_MACE: return 1.5f; + case MELEE_SPEC_ABYSSAL_BLUDGEON:return 1.20f; + default: return 1.0f; + } +} + +static float get_melee_spec_acc_mult(MeleeSpecWeapon weapon) { + switch (weapon) { + case MELEE_SPEC_AGS: return 2.0f; + case MELEE_SPEC_DRAGON_CLAWS: return 1.35f; + case MELEE_SPEC_GRANITE_MAUL: return 1.0f; + case MELEE_SPEC_DRAGON_DAGGER: return 1.20f; + case MELEE_SPEC_VOIDWAKER: return 1.0f; + case MELEE_SPEC_DWH: return 1.0f; + case MELEE_SPEC_BGS: return 1.5f; + case MELEE_SPEC_ZGS: return 2.0f; + case MELEE_SPEC_SGS: return 1.5f; + case MELEE_SPEC_ANCIENT_GS: return 2.0f; + case MELEE_SPEC_VESTAS: return 1.0f; + case MELEE_SPEC_ABYSSAL_DAGGER: return 1.25f; + case MELEE_SPEC_DRAGON_LONGSWORD:return 1.25f; + case MELEE_SPEC_DRAGON_MACE: return 1.25f; + case MELEE_SPEC_ABYSSAL_BLUDGEON:return 1.0f; + default: return 1.0f; + } +} + +static float get_ranged_spec_str_mult(RangedSpecWeapon weapon) { + switch (weapon) { + case RANGED_SPEC_DARK_BOW: return 1.5f; + case RANGED_SPEC_BALLISTA: return 1.25f; + case RANGED_SPEC_ACB: return 1.0f; + case RANGED_SPEC_ZCB: return 1.0f; + case RANGED_SPEC_DRAGON_KNIFE:return 1.0f; + case RANGED_SPEC_MSB: return 1.0f; + case RANGED_SPEC_MORRIGANS: return 1.0f; + default: return 1.0f; + } +} +static float get_ranged_spec_acc_mult(RangedSpecWeapon weapon) { + switch (weapon) { + case RANGED_SPEC_DARK_BOW: return 1.0f; + case RANGED_SPEC_BALLISTA: return 1.25f; + case RANGED_SPEC_ACB: return 2.0f; + case RANGED_SPEC_ZCB: return 2.0f; + case RANGED_SPEC_DRAGON_KNIFE:return 1.0f; + case RANGED_SPEC_MSB: return 1.0f; + case RANGED_SPEC_MORRIGANS: return 1.0f; + default: return 1.0f; + } +} + +static float get_magic_spec_acc_mult(MagicSpecWeapon weapon) { + switch (weapon) { + case MAGIC_SPEC_VOLATILE_STAFF: return 1.5f; + default: return 1.0f; + } +} + +// ============================================================================ +// PRAYER MULTIPLIERS +// ============================================================================ + +static inline float get_defence_prayer_mult(Player* p) { + switch (p->offensive_prayer) { + case OFFENSIVE_PRAYER_MELEE_LOW: + case OFFENSIVE_PRAYER_RANGED_LOW: + case OFFENSIVE_PRAYER_MAGIC_LOW: + return 1.15f; + case OFFENSIVE_PRAYER_PIETY: + case OFFENSIVE_PRAYER_RIGOUR: + case OFFENSIVE_PRAYER_AUGURY: + return 1.25f; + default: + return 1.0f; + } +} + +// ============================================================================ +// EFFECTIVE LEVEL CALCULATIONS +// ============================================================================ + +static int calculate_effective_attack(Player* p, AttackStyle style) { + int base_level; + float prayer_mult = 1.0f; + int style_bonus = 0; + + switch (style) { + case ATTACK_STYLE_MELEE: + base_level = p->current_attack; + if (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) { + prayer_mult = 1.20f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_MELEE_LOW) { + prayer_mult = 1.15f; + } + break; + case ATTACK_STYLE_RANGED: + base_level = p->current_ranged; + if (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) { + prayer_mult = 1.20f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_RANGED_LOW) { + prayer_mult = 1.15f; + } + break; + case ATTACK_STYLE_MAGIC: + base_level = p->current_magic; + if (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) { + prayer_mult = 1.25f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_MAGIC_LOW) { + prayer_mult = 1.15f; + } + break; + default: + return 0; + } + + float effective = base_level * prayer_mult; + effective = floorf(effective); + + if (style == ATTACK_STYLE_MELEE) { + if (p->fight_style == FIGHT_STYLE_ACCURATE) { + style_bonus = 3; + } else if (p->fight_style == FIGHT_STYLE_CONTROLLED) { + style_bonus = 1; + } + } else if (style == ATTACK_STYLE_RANGED) { + if (p->fight_style == FIGHT_STYLE_ACCURATE) { + style_bonus = 3; + } + } + + if (style == ATTACK_STYLE_MAGIC) { + return (int)effective + 9; + } + return (int)effective + style_bonus + 8; +} + +static int calculate_effective_strength(Player* p, AttackStyle style) { + int base_level; + float prayer_mult = 1.0f; + int style_bonus = 0; + + switch (style) { + case ATTACK_STYLE_MELEE: + base_level = p->current_strength; + if (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) { + prayer_mult = 1.23f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_MELEE_LOW) { + prayer_mult = 1.15f; + } + break; + case ATTACK_STYLE_RANGED: + base_level = p->current_ranged; + if (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) { + prayer_mult = 1.23f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_RANGED_LOW) { + prayer_mult = 1.15f; + } + break; + case ATTACK_STYLE_MAGIC: + base_level = p->current_magic; + break; + default: + return 0; + } + + float effective = floorf(base_level * prayer_mult); + + if (style == ATTACK_STYLE_MELEE && p->fight_style == FIGHT_STYLE_AGGRESSIVE) { + style_bonus = 3; + } else if (style == ATTACK_STYLE_MELEE && p->fight_style == FIGHT_STYLE_CONTROLLED) { + style_bonus = 1; + } + // NOTE: ranged accurate only boosts accuracy, not strength + + return (int)effective + style_bonus + 8; +} + +static int calculate_effective_defence(Player* p, AttackStyle incoming_style) { + int base_level = p->current_defence; + float prayer_mult = get_defence_prayer_mult(p); + int style_bonus = 0; + + if (p->fight_style == FIGHT_STYLE_DEFENSIVE) { + style_bonus = 3; + } else if (p->fight_style == FIGHT_STYLE_CONTROLLED) { + style_bonus = 1; + } + + if (incoming_style == ATTACK_STYLE_MAGIC) { + float magic_prayer_mult = 1.0f; + if (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) { + magic_prayer_mult = 1.25f; + } else if (p->offensive_prayer == OFFENSIVE_PRAYER_MAGIC_LOW) { + magic_prayer_mult = 1.15f; + } + int magic_level = (int)floorf(p->current_magic * magic_prayer_mult); + int def_level = (int)floorf(p->current_defence * prayer_mult) + style_bonus + 8; + int magic_part = (int)floorf(magic_level * 0.7f); + int def_part = (int)floorf(def_level * 0.3f); + return magic_part + def_part; + } + + float effective = floorf(base_level * prayer_mult); + return (int)effective + style_bonus + 8; +} + +// ============================================================================ +// ATTACK/DEFENCE BONUS LOOKUPS +// ============================================================================ + +static MeleeBonusType get_melee_bonus_type(Player* p) { + if (p->current_gear == GEAR_SPEC) { + return MELEE_SPEC_BONUS_TYPES[p->melee_spec_weapon]; + } + return MELEE_BONUS_SLASH; +} + +static int get_attack_bonus(Player* p, AttackStyle style) { + GearBonuses* g = get_slot_gear_bonuses(p); + switch (style) { + case ATTACK_STYLE_MELEE: { + MeleeBonusType bonus = get_melee_bonus_type(p); + switch (bonus) { + case MELEE_BONUS_STAB: return g->stab_attack; + case MELEE_BONUS_SLASH: return g->slash_attack; + case MELEE_BONUS_CRUSH: return g->crush_attack; + default: return g->slash_attack; + } + } + case ATTACK_STYLE_RANGED: + return g->ranged_attack; + case ATTACK_STYLE_MAGIC: + return g->magic_attack; + default: + return 0; + } +} + +static int get_defence_bonus_for_melee_type(Player* p, MeleeBonusType melee_type) { + GearBonuses* g = get_slot_gear_bonuses(p); + switch (melee_type) { + case MELEE_BONUS_STAB: return g->stab_defence; + case MELEE_BONUS_SLASH: return g->slash_defence; + case MELEE_BONUS_CRUSH: return g->crush_defence; + default: return g->slash_defence; + } +} + +static int get_defence_bonus(Player* defender, AttackStyle style, Player* attacker) { + GearBonuses* g = get_slot_gear_bonuses(defender); + switch (style) { + case ATTACK_STYLE_MELEE: { + MeleeBonusType bonus = get_melee_bonus_type(attacker); + return get_defence_bonus_for_melee_type(defender, bonus); + } + case ATTACK_STYLE_RANGED: + return g->ranged_defence; + case ATTACK_STYLE_MAGIC: + return g->magic_defence; + default: + return 0; + } +} + +static int get_strength_bonus(Player* p, AttackStyle style) { + GearBonuses* g = get_slot_gear_bonuses(p); + switch (style) { + case ATTACK_STYLE_MELEE: + return g->melee_strength; + case ATTACK_STYLE_RANGED: + return g->ranged_strength; + case ATTACK_STYLE_MAGIC: + return g->magic_strength; + default: + return 0; + } +} + +// ============================================================================ +// HIT CHANCE AND MAX HIT FORMULAS +// ============================================================================ + +/** + * OSRS accuracy formula. + * if (attRoll > defRoll) hitChance = 1 - (defRoll+2)/(2*(attRoll+1)) + * else hitChance = attRoll/(2*(defRoll+1)) + */ +static float calculate_hit_chance(OsrsPvp* env, Player* attacker, Player* defender, + AttackStyle style, float acc_mult) { + (void)env; + int eff_attack = calculate_effective_attack(attacker, style); + int attack_bonus = get_attack_bonus(attacker, style); + int attack_roll = (int)(eff_attack * (attack_bonus + 64) * acc_mult); + + int eff_defence = calculate_effective_defence(defender, style); + int defence_bonus = get_defence_bonus(defender, style, attacker); + int defence_roll = eff_defence * (defence_bonus + 64); + + float hit_chance; + if (attack_roll > defence_roll) { + hit_chance = 1.0f - ((float)(defence_roll + 2) / (2.0f * (attack_roll + 1))); + } else { + hit_chance = (float)attack_roll / (2.0f * (defence_roll + 1)); + } + + return clampf(hit_chance, 0.0f, 1.0f); +} + +static int calculate_max_hit(Player* p, AttackStyle style, float str_mult, int magic_base_hit) { + int eff_strength = calculate_effective_strength(p, style); + int strength_bonus = get_strength_bonus(p, style); + + int max_hit; + if (style == ATTACK_STYLE_MAGIC) { + int base_damage = magic_base_hit; + float magic_mult = 1.0f; + // Augury provides +4% magic damage + if (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) { + magic_mult = 1.04f; + } + max_hit = (int)(base_damage * (1.0f + strength_bonus / 100.0f) * str_mult * magic_mult); + } else { + max_hit = (int)(((eff_strength * (strength_bonus + 64) + 320) / 640.0f) * str_mult); + } + + if (p->has_dharok && style == ATTACK_STYLE_MELEE) { + float hp_ratio = 1.0f - ((float)p->current_hitpoints / p->base_hitpoints); + max_hit = (int)(max_hit * (1.0f + hp_ratio * hp_ratio)); + } + + return max_hit; +} + +// ============================================================================ +// MAGIC SPELL HELPERS +// ============================================================================ + +static inline int get_ice_freeze_ticks(int current_magic) { + if (current_magic >= ICE_BARRAGE_LEVEL) return 32; + if (current_magic >= ICE_BLITZ_LEVEL) return 24; + if (current_magic >= ICE_BURST_LEVEL) return 16; + return 8; +} + +static inline int get_ice_base_hit(int current_magic) { + if (current_magic >= ICE_BARRAGE_LEVEL) return ICE_BARRAGE_MAX_HIT; + if (current_magic >= ICE_BLITZ_LEVEL) return ICE_BLITZ_MAX_HIT; + if (current_magic >= ICE_BURST_LEVEL) return ICE_BURST_MAX_HIT; + return ICE_RUSH_MAX_HIT; +} + +static inline int get_blood_base_hit(int current_magic) { + if (current_magic >= BLOOD_BARRAGE_LEVEL) return BLOOD_BARRAGE_MAX_HIT; + if (current_magic >= BLOOD_BLITZ_LEVEL) return BLOOD_BLITZ_MAX_HIT; + if (current_magic >= BLOOD_BURST_LEVEL) return BLOOD_BURST_MAX_HIT; + return BLOOD_RUSH_MAX_HIT; +} + +static inline int get_blood_heal_percent(int current_magic) { + if (current_magic >= BLOOD_BARRAGE_LEVEL) return 25; + if (current_magic >= BLOOD_BLITZ_LEVEL) return 20; + if (current_magic >= BLOOD_BURST_LEVEL) return 15; + return 10; +} + +// ============================================================================ +// HIT DELAY CALCULATIONS +// ============================================================================ + +static inline int magic_hit_delay(int distance) { + return 1 + ((1 + distance) / 3); +} + +static inline int ranged_hit_delay_default(int distance) { + return 1 + ((3 + distance) / 6); +} + +static inline int ranged_hit_delay_fast(int distance) { + return 1 + (distance / 6); +} + +static inline int ranged_hit_delay_ballista(int distance) { + return 2 + ((1 + distance) / 6); +} + +static inline int ranged_hit_delay_dbow_second(int distance) { + return 1 + ((2 + distance) / 3); +} + +static inline int ranged_hit_delay(int distance, int is_special, RangedSpecWeapon weapon) { + if (!is_special) { + return ranged_hit_delay_default(distance); + } + switch (weapon) { + case RANGED_SPEC_DRAGON_KNIFE: + case RANGED_SPEC_MORRIGANS: + return ranged_hit_delay_fast(distance); + case RANGED_SPEC_BALLISTA: + return ranged_hit_delay_ballista(distance); + case RANGED_SPEC_DARK_BOW: + case RANGED_SPEC_MSB: + case RANGED_SPEC_ACB: + case RANGED_SPEC_ZCB: + case RANGED_SPEC_NONE: + default: + return ranged_hit_delay_default(distance); + } +} + +// ============================================================================ +// PRAYER CHECK +// ============================================================================ + +static int is_prayer_correct(Player* defender, AttackStyle incoming_style) { + switch (incoming_style) { + case ATTACK_STYLE_MELEE: + return defender->prayer == PRAYER_PROTECT_MELEE; + case ATTACK_STYLE_RANGED: + return defender->prayer == PRAYER_PROTECT_RANGED; + case ATTACK_STYLE_MAGIC: + return defender->prayer == PRAYER_PROTECT_MAGIC; + default: + return 0; + } +} + +// ============================================================================ +// HIT QUEUE +// ============================================================================ + +static void queue_hit(Player* attacker, Player* defender, int damage, + AttackStyle style, int delay, int is_special, int hit_success, + int freeze_ticks, int heal_percent, int drain_type, int drain_percent, + int flat_heal) { + if (attacker->num_pending_hits >= MAX_PENDING_HITS) return; + + PendingHit* hit = &attacker->pending_hits[attacker->num_pending_hits++]; + hit->damage = damage; + hit->ticks_until_hit = delay; + hit->attack_type = style; + hit->is_special = is_special; + hit->hit_success = hit_success; + hit->freeze_ticks = freeze_ticks; + hit->heal_percent = heal_percent; + hit->drain_type = drain_type; + hit->drain_percent = drain_percent; + hit->flat_heal = flat_heal; + hit->is_morr_bleed = 0; + hit->defender_prayer_at_attack = defender->prayer; + + // Track damage for XP drop equivalent (actual damage after prayer reduction) + int actual_damage = damage; + if (is_prayer_correct(defender, style)) { + actual_damage = (int)(damage * 0.6f); + } + attacker->last_queued_hit_damage += actual_damage; +} + +// ============================================================================ +// DAMAGE APPLICATION +// ============================================================================ + +static void apply_damage(OsrsPvp* env, int attacker_idx, int defender_idx, + PendingHit* hit) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + int damage = hit->damage; + AttackStyle style = hit->attack_type; + + OverheadPrayer recorded_prayer = hit->defender_prayer_at_attack; + int prayer_protects = 0; + switch (style) { + case ATTACK_STYLE_MELEE: + prayer_protects = (recorded_prayer == PRAYER_PROTECT_MELEE); + break; + case ATTACK_STYLE_RANGED: + prayer_protects = (recorded_prayer == PRAYER_PROTECT_RANGED); + break; + case ATTACK_STYLE_MAGIC: + prayer_protects = (recorded_prayer == PRAYER_PROTECT_MAGIC); + break; + default: + break; + } + if (prayer_protects) { + damage = (int)(damage * 0.6f); + } + + // Record hit event for event log + defender->hit_landed_this_tick = 1; + defender->hit_was_successful = hit->hit_success; + defender->hit_damage += damage; + defender->hit_style = style; + defender->hit_defender_prayer = recorded_prayer; + defender->hit_was_on_prayer = prayer_protects; + defender->hit_attacker_idx = attacker_idx; + defender->damage_applied_this_tick = damage; + + if (defender->veng_active && damage > 0) { + int reflect_damage = (int)(damage * 0.75f); + attacker->current_hitpoints -= reflect_damage; + if (attacker->current_hitpoints < 0) { + attacker->current_hitpoints = 0; + } + float reflect_scale = (float)reflect_damage / (float)attacker->base_hitpoints; + attacker->total_damage_received += reflect_scale; + defender->total_damage_dealt += reflect_scale; + attacker->damage_received_scale += reflect_scale; + defender->damage_dealt_scale += reflect_scale; + defender->veng_active = 0; + } + + /* ring of recoil / ring of suffering (i): reflects floor(damage * 0.1) + 1 + back to attacker. charges track total reflected damage (starts at 40). + ring of recoil shatters at 0 charges; ring of suffering (i) never shatters. */ + if (has_recoil_effect(defender) && damage > 0 && defender->recoil_charges > 0) { + int recoil = damage / 10 + 1; + if (recoil > defender->recoil_charges) { + recoil = defender->recoil_charges; + } + attacker->current_hitpoints -= recoil; + if (attacker->current_hitpoints < 0) attacker->current_hitpoints = 0; + float recoil_scale = (float)recoil / (float)attacker->base_hitpoints; + attacker->total_damage_received += recoil_scale; + defender->total_damage_dealt += recoil_scale; + attacker->damage_received_scale += recoil_scale; + defender->damage_dealt_scale += recoil_scale; + + /* ring of suffering (i) has infinite charges; ring of recoil shatters */ + if (defender->equipped[GEAR_SLOT_RING] == ITEM_RING_OF_RECOIL) { + defender->recoil_charges -= recoil; + if (defender->recoil_charges <= 0) { + defender->recoil_charges = 0; + defender->equipped[GEAR_SLOT_RING] = ITEM_NONE; + } + } + } + + defender->current_hitpoints -= damage; + if (defender->current_hitpoints < 0) { + defender->current_hitpoints = 0; + } + float damage_scale = (float)damage / (float)defender->base_hitpoints; + defender->total_damage_received += damage_scale; + attacker->total_damage_dealt += damage_scale; + defender->damage_received_scale += damage_scale; + attacker->damage_dealt_scale += damage_scale; + attacker->last_target_health_percent = + (float)defender->current_hitpoints / (float)defender->base_hitpoints; + + if (hit->hit_success) { + // Defence drain: percentage drain requires damage > 0 (not just accuracy success) + if (hit->drain_type == 1 && damage > 0) { + int drain = (int)(defender->current_defence * hit->drain_percent / 100.0f); + defender->current_defence = clamp(defender->current_defence - drain, 1, 255); + } else if (hit->drain_type == 2 && damage > 0) { + defender->current_defence = clamp(defender->current_defence - damage, 1, 255); + } + + if (hit->freeze_ticks > 0 && hit->hit_success && defender->freeze_immunity_ticks == 0 && defender->frozen_ticks == 0) { + defender->frozen_ticks = hit->freeze_ticks; + defender->freeze_immunity_ticks = hit->freeze_ticks + 5; + defender->freeze_applied_this_tick = 1; + } + + if (hit->heal_percent > 0) { + int heal = (damage * hit->heal_percent) / 100; + attacker->current_hitpoints = clamp(attacker->current_hitpoints + heal, 0, attacker->base_hitpoints); + } + if (hit->flat_heal > 0) { + attacker->current_hitpoints = clamp(attacker->current_hitpoints + hit->flat_heal, 0, attacker->base_hitpoints); + } + } + + // Morrigan's javelin: set bleed amount when spec hit lands (post-prayer damage) + if (hit->is_morr_bleed && hit->hit_success && damage > 0) { + defender->morr_dot_remaining = damage; + } + + if (attacker->prayer == PRAYER_SMITE && damage > 0 && !defender->is_lms) { + int prayer_drain = damage / 4; + defender->current_prayer = clamp(defender->current_prayer - prayer_drain, 0, defender->base_prayer); + } +} + +static void process_pending_hits(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + + for (int i = 0; i < attacker->num_pending_hits; i++) { + PendingHit* hit = &attacker->pending_hits[i]; + hit->ticks_until_hit--; + + if (hit->ticks_until_hit < 0) { + apply_damage(env, attacker_idx, defender_idx, hit); + + for (int j = i; j < attacker->num_pending_hits - 1; j++) { + attacker->pending_hits[j] = attacker->pending_hits[j + 1]; + } + attacker->num_pending_hits--; + i--; + } + } +} + +// ============================================================================ +// HIT STATISTICS TRACKING +// ============================================================================ + +static inline void push_recent_attack(AttackStyle* buffer, int* index, AttackStyle style) { + buffer[*index] = style; + *index = (*index + 1) % HISTORY_SIZE; +} + +static inline void push_recent_prayer(AttackStyle* buffer, int* index, OverheadPrayer prayer) { + AttackStyle style = ATTACK_STYLE_NONE; + if (prayer == PRAYER_PROTECT_MAGIC) { + style = ATTACK_STYLE_MAGIC; + } else if (prayer == PRAYER_PROTECT_RANGED) { + style = ATTACK_STYLE_RANGED; + } else if (prayer == PRAYER_PROTECT_MELEE) { + style = ATTACK_STYLE_MELEE; + } + if (style == ATTACK_STYLE_NONE) { + return; + } + buffer[*index] = style; + *index = (*index + 1) % HISTORY_SIZE; +} + +static inline void push_recent_bool(int* buffer, int* index, int value) { + buffer[*index] = value ? 1 : 0; + *index = (*index + 1) % HISTORY_SIZE; +} + +static void register_hit_calculated( + OsrsPvp* env, + int attacker_idx, + int defender_idx, + AttackStyle style, + int total_damage +) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + GearBonuses* atk_gear = get_slot_gear_bonuses(attacker); + VisibleGearBonuses visible_buf = { + .magic_attack = atk_gear->magic_attack, + .magic_strength = atk_gear->magic_strength, + .ranged_attack = atk_gear->ranged_attack, + .ranged_strength = atk_gear->ranged_strength, + .melee_attack = max_int(atk_gear->stab_attack, max_int(atk_gear->slash_attack, atk_gear->crush_attack)), + .melee_strength = atk_gear->melee_strength, + .magic_defence = atk_gear->magic_defence, + .ranged_defence = atk_gear->ranged_defence, + .melee_defence = max_int(atk_gear->stab_defence, max_int(atk_gear->slash_defence, atk_gear->crush_defence)), + }; + const VisibleGearBonuses* visible = &visible_buf; + + defender->total_target_hit_count += 1; + push_recent_attack(defender->recent_target_attack_styles, &defender->recent_target_attack_index, style); + + if (style == ATTACK_STYLE_MAGIC) { + defender->target_hit_magic_count += 1; + defender->target_magic_accuracy = visible->magic_attack; + defender->target_magic_strength = visible->magic_strength; + defender->target_magic_gear_magic_defence = visible->magic_defence; + defender->target_magic_gear_ranged_defence = visible->ranged_defence; + defender->target_magic_gear_melee_defence = visible->melee_defence; + } else if (style == ATTACK_STYLE_RANGED) { + defender->target_hit_ranged_count += 1; + defender->target_ranged_accuracy = visible->ranged_attack; + defender->target_ranged_strength = visible->ranged_strength; + defender->target_ranged_gear_magic_defence = visible->magic_defence; + defender->target_ranged_gear_ranged_defence = visible->ranged_defence; + defender->target_ranged_gear_melee_defence = visible->melee_defence; + } else if (style == ATTACK_STYLE_MELEE) { + defender->target_hit_melee_count += 1; + if (visible->melee_strength >= defender->target_melee_strength) { + defender->target_melee_accuracy = visible->melee_attack; + defender->target_melee_strength = visible->melee_strength; + defender->target_melee_gear_magic_defence = visible->magic_defence; + defender->target_melee_gear_ranged_defence = visible->ranged_defence; + defender->target_melee_gear_melee_defence = visible->melee_defence; + } + } + + if (defender->prayer == PRAYER_PROTECT_MAGIC) { + defender->player_pray_magic_count += 1; + push_recent_prayer(defender->recent_player_prayer_styles, &defender->recent_player_prayer_index, defender->prayer); + } else if (defender->prayer == PRAYER_PROTECT_RANGED) { + defender->player_pray_ranged_count += 1; + push_recent_prayer(defender->recent_player_prayer_styles, &defender->recent_player_prayer_index, defender->prayer); + } else if (defender->prayer == PRAYER_PROTECT_MELEE) { + defender->player_pray_melee_count += 1; + push_recent_prayer(defender->recent_player_prayer_styles, &defender->recent_player_prayer_index, defender->prayer); + } + + int defender_prayed_correctly = is_prayer_correct(defender, style); + if (!defender_prayed_correctly) { + defender->target_hit_correct_count += 1; + push_recent_bool(defender->recent_target_hit_correct, &defender->recent_target_hit_correct_index, 1); + } else { + defender->player_prayed_correct = 1; + push_recent_bool(defender->recent_target_hit_correct, &defender->recent_target_hit_correct_index, 0); + } + + // Track whether attack was "on prayer" for event logging + attacker->attack_was_on_prayer = defender_prayed_correctly; + + push_recent_attack(attacker->recent_player_attack_styles, &attacker->recent_player_attack_index, style); + if (style == ATTACK_STYLE_MAGIC) { + attacker->player_hit_magic_count += 1; + } else if (style == ATTACK_STYLE_RANGED) { + attacker->player_hit_ranged_count += 1; + } else if (style == ATTACK_STYLE_MELEE) { + attacker->player_hit_melee_count += 1; + } + attacker->tick_damage_scale = (float)total_damage / (float)defender->base_hitpoints; + attacker->total_target_pray_count += 1; + + if (defender->prayer == PRAYER_PROTECT_MAGIC) { + attacker->target_pray_magic_count += 1; + push_recent_prayer(attacker->recent_target_prayer_styles, &attacker->recent_target_prayer_index, defender->prayer); + } else if (defender->prayer == PRAYER_PROTECT_RANGED) { + attacker->target_pray_ranged_count += 1; + push_recent_prayer(attacker->recent_target_prayer_styles, &attacker->recent_target_prayer_index, defender->prayer); + } else if (defender->prayer == PRAYER_PROTECT_MELEE) { + attacker->target_pray_melee_count += 1; + push_recent_prayer(attacker->recent_target_prayer_styles, &attacker->recent_target_prayer_index, defender->prayer); + } + + if (is_prayer_correct(defender, style)) { + attacker->target_pray_correct_count += 1; + attacker->target_prayed_correct = 1; + push_recent_bool(attacker->recent_target_prayer_correct, &attacker->recent_target_prayer_correct_index, 1); + } else { + push_recent_bool(attacker->recent_target_prayer_correct, &attacker->recent_target_prayer_correct_index, 0); + } +} + +// ============================================================================ +// SPECIAL ATTACK IMPLEMENTATIONS +// ============================================================================ + +/** Dragon claws 4-hit cascade algorithm. */ +static void perform_dragon_claws_spec(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + float acc_mult = get_melee_spec_acc_mult(MELEE_SPEC_DRAGON_CLAWS); + float hit_chance = calculate_hit_chance(env, attacker, defender, ATTACK_STYLE_MELEE, acc_mult); + int max_hit = calculate_max_hit(attacker, ATTACK_STYLE_MELEE, 1.0f, 30); + /* prayer reduction is handled uniformly in apply_damage() for all attacks */ + + int first, second, third, fourth; + + int roll1 = rand_float(env) < hit_chance; + int roll2 = rand_float(env) < hit_chance; + int roll3 = rand_float(env) < hit_chance; + int roll4 = rand_float(env) < hit_chance; + + if (roll1) { + int min_first = (int)(max_hit * 0.5f); + first = min_first + rand_int(env, max_hit - min_first); + second = first / 2; + third = second / 2; + fourth = third + rand_int(env, 2); + } else if (roll2) { + first = 0; + int min_second = (int)(max_hit * 0.375f); + int max_second = (int)(max_hit * 0.875f); + second = min_second + rand_int(env, max_second - min_second + 1); + third = second / 2; + fourth = third + rand_int(env, 2); + } else if (roll3) { + first = 0; + second = 0; + int min_third = (int)(max_hit * 0.25f); + int max_third = (int)(max_hit * 0.75f); + third = min_third + rand_int(env, max_third - min_third + 1); + fourth = third + rand_int(env, 2); + } else if (roll4) { + first = 0; + second = 0; + third = 0; + int min_fourth = (int)(max_hit * 0.25f); + int max_fourth = (int)(max_hit * 1.25f); + fourth = min_fourth + rand_int(env, max_fourth - min_fourth + 1); + } else { + first = 0; + second = 0; + third = rand_int(env, 2); + fourth = third; + } + + queue_hit(attacker, defender, first, ATTACK_STYLE_MELEE, 0, 1, first > 0, 0, 0, 0, 0, 0); + queue_hit(attacker, defender, second, ATTACK_STYLE_MELEE, 0, 1, second > 0, 0, 0, 0, 0, 0); + queue_hit(attacker, defender, third, ATTACK_STYLE_MELEE, 0, 1, third > 0, 0, 0, 0, 0, 0); + queue_hit(attacker, defender, fourth, ATTACK_STYLE_MELEE, 0, 1, fourth > 0, 0, 0, 0, 0, 0); + + int total_damage = first + second + third + fourth; + register_hit_calculated(env, attacker_idx, defender_idx, ATTACK_STYLE_MELEE, total_damage); +} + +/** Voidwaker: guaranteed magic damage 50-150% of melee max hit. */ +static void perform_voidwaker_spec(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + int max_melee_hit = calculate_max_hit(attacker, ATTACK_STYLE_MELEE, 1.0f, 30); + int min_damage = (int)(max_melee_hit * 0.5f); + int max_damage = (int)(max_melee_hit * 1.5f); + int damage = min_damage + rand_int(env, max_damage - min_damage + 1); + + // prayer reduction handled uniformly in apply_damage() + queue_hit(attacker, defender, damage, ATTACK_STYLE_MAGIC, 0, 1, damage > 0, 0, 0, 0, 0, 0); + register_hit_calculated(env, attacker_idx, defender_idx, ATTACK_STYLE_MAGIC, damage); +} + +/** VLS "Feint": 20-120% of base max hit, accuracy vs 25% of def. */ +static void perform_vestas_spec(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + int base_max = calculate_max_hit(attacker, ATTACK_STYLE_MELEE, 1.0f, 30); + int max_hit = (int)(base_max * 1.20f); + int min_hit = (int)(base_max * 0.20f); + + // Accuracy rolled against 25% of opponent's defence roll + int eff_attack = calculate_effective_attack(attacker, ATTACK_STYLE_MELEE); + int attack_bonus = get_attack_bonus(attacker, ATTACK_STYLE_MELEE); + int attack_roll = eff_attack * (attack_bonus + 64); + + int eff_defence = calculate_effective_defence(defender, ATTACK_STYLE_MELEE); + int defence_bonus = get_defence_bonus(defender, ATTACK_STYLE_MELEE, attacker); + int defence_roll = (int)(eff_defence * (defence_bonus + 64) * 0.25f); + + float hit_chance; + if (attack_roll > defence_roll) { + hit_chance = 1.0f - ((float)(defence_roll + 2) / (2.0f * (attack_roll + 1))); + } else { + hit_chance = (float)attack_roll / (2.0f * (defence_roll + 1)); + } + hit_chance = clampf(hit_chance, 0.0f, 1.0f); + + int damage = 0; + int hit_success = 0; + if (rand_float(env) < hit_chance) { + damage = min_hit + rand_int(env, max_hit - min_hit + 1); + hit_success = 1; + } + + queue_hit(attacker, defender, damage, ATTACK_STYLE_MELEE, 0, 1, hit_success, 0, 0, 0, 0, 0); + register_hit_calculated(env, attacker_idx, defender_idx, ATTACK_STYLE_MELEE, damage); +} + +/** Statius warhammer "Smash": 35% cost, 25-125% of max hit, 30% defence drain on damage > 0. */ +static void perform_statius_spec(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + float str_mult = get_melee_spec_str_mult(MELEE_SPEC_DWH); + int max_hit = calculate_max_hit(attacker, ATTACK_STYLE_MELEE, str_mult, 30); + int min_hit = (int)(max_hit * 0.25f); + + float acc_mult = get_melee_spec_acc_mult(MELEE_SPEC_DWH); + float hit_chance = calculate_hit_chance(env, attacker, defender, ATTACK_STYLE_MELEE, acc_mult); + + int damage = 0; + int hit_success = 0; + if (rand_float(env) < hit_chance) { + damage = min_hit + rand_int(env, max_hit - min_hit + 1); + hit_success = 1; + } + + // drain_type=1, drain_percent=30: 30% of current defence drained on damage > 0 + queue_hit(attacker, defender, damage, ATTACK_STYLE_MELEE, 0, 1, hit_success, 0, 0, 1, 30, 0); + register_hit_calculated(env, attacker_idx, defender_idx, ATTACK_STYLE_MELEE, damage); +} + +/** Dark bow: 2 hits, each min 8 max 48, 1.5x str mult. */ +static void perform_dark_bow_spec(OsrsPvp* env, int attacker_idx, int defender_idx) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + float acc_mult = get_ranged_spec_acc_mult(RANGED_SPEC_DARK_BOW); + float str_mult = get_ranged_spec_str_mult(RANGED_SPEC_DARK_BOW); + float hit_chance = calculate_hit_chance(env, attacker, defender, ATTACK_STYLE_RANGED, acc_mult); + int max_hit = calculate_max_hit(attacker, ATTACK_STYLE_RANGED, str_mult, 30); + int distance = chebyshev_distance(attacker->x, attacker->y, defender->x, defender->y); + int first_delay = ranged_hit_delay_default(distance); + int second_delay = ranged_hit_delay_dbow_second(distance); + + int total_damage = 0; + for (int i = 0; i < 2; i++) { + int damage; + if (rand_float(env) < hit_chance) { + damage = rand_int(env, max_hit + 1); + damage = clamp(damage, 8, 48); + } else { + damage = 8; + } + total_damage += damage; + int hit_delay = (i == 0) ? first_delay : second_delay; + queue_hit(attacker, defender, damage, ATTACK_STYLE_RANGED, hit_delay, 1, damage > 0, 0, 0, 0, 0, 0); + } + register_hit_calculated(env, attacker_idx, defender_idx, ATTACK_STYLE_RANGED, total_damage); +} + +// ============================================================================ +// ATTACK AVAILABILITY CHECKS +// ============================================================================ + +/** Check if attack is available (respects config). */ +static inline int is_attack_available(Player* p) { + if (ONLY_SWITCH_GEAR_WHEN_ATTACK_SOON && remaining_ticks(p->attack_timer) > 0) { + return 0; + } + return 1; +} + +static inline int is_melee_weapon_equipped(Player* p) { + return get_slot_weapon_attack_style(p) == ATTACK_STYLE_MELEE; +} + +static inline int is_ranged_weapon_equipped(Player* p) { + return get_slot_weapon_attack_style(p) == ATTACK_STYLE_RANGED; +} + +static inline int is_melee_spec_weapon_equipped(Player* p) { + return p->melee_spec_weapon != MELEE_SPEC_NONE; +} + +static inline int is_ranged_spec_weapon_equipped(Player* p) { + return p->ranged_spec_weapon != RANGED_SPEC_NONE; +} + +static inline int is_magic_spec_weapon_equipped(Player* p) { + return p->magic_spec_weapon != MAGIC_SPEC_NONE; +} + +/** Check if player can cast ice spells. */ +static inline int can_cast_ice_spell(Player* p) { + if (p->is_lunar_spellbook) { + return 0; + } + return p->current_magic >= ICE_RUSH_LEVEL; +} + +/** Check if player can cast blood spells. */ +static inline int can_cast_blood_spell(Player* p) { + if (p->is_lunar_spellbook) { + return 0; + } + return p->current_magic >= BLOOD_RUSH_LEVEL; +} + +/** Check if ranged attack is available. */ +static inline int is_ranged_attack_available(Player* p) { + if (!is_attack_available(p)) { + return 0; + } + return is_ranged_weapon_equipped(p); +} + +/** Check if melee is possible (in range or can move). */ +static inline int can_melee(Player* p, Player* t) { + return is_in_melee_range(p, t) || can_move(p); +} + +/** Check if melee attack is available. */ +static inline int is_melee_attack_available(Player* p, Player* t) { + if (!is_attack_available(p)) { + return 0; + } + (void)t; + return is_melee_weapon_equipped(p); +} + +/** Check if melee spec weapon is two-handed. */ +static inline int is_melee_spec_two_handed(MeleeSpecWeapon weapon) { + switch (weapon) { + case MELEE_SPEC_AGS: + case MELEE_SPEC_DRAGON_CLAWS: + case MELEE_SPEC_BGS: + case MELEE_SPEC_ZGS: + case MELEE_SPEC_SGS: + case MELEE_SPEC_ANCIENT_GS: + case MELEE_SPEC_ABYSSAL_BLUDGEON: + return 1; + case MELEE_SPEC_NONE: + case MELEE_SPEC_GRANITE_MAUL: + case MELEE_SPEC_DRAGON_DAGGER: + case MELEE_SPEC_VOIDWAKER: + case MELEE_SPEC_DWH: + case MELEE_SPEC_VESTAS: + case MELEE_SPEC_ABYSSAL_DAGGER: + case MELEE_SPEC_DRAGON_LONGSWORD: + case MELEE_SPEC_DRAGON_MACE: + return 0; + } + return 0; +} + +/** Check if player has free inventory slot (for 2h weapon). */ +static inline int has_free_inventory_slot(Player* p) { + int food_slots = p->food_count + p->karambwan_count; + int max_food_slots = MAXED_FOOD_COUNT + MAXED_KARAMBWAN_COUNT; + return food_slots < max_food_slots; +} + +/** Check if player can equip two-handed weapon. */ +static inline int can_equip_two_handed_weapon(Player* p) { + return has_free_inventory_slot(p) || p->equipped[GEAR_SLOT_SHIELD] == ITEM_NONE; +} + +/** Check if melee spec is usable. */ +static inline int can_spec(Player* p) { + int cost = get_melee_spec_cost(p->melee_spec_weapon); + return p->melee_spec_weapon != MELEE_SPEC_NONE && p->special_energy >= cost; +} + +/** Check if granite maul spec is available. */ +static inline int is_granite_maul_attack_available(Player* p) { + if (p->melee_spec_weapon != MELEE_SPEC_GRANITE_MAUL) { + return 0; + } + return p->special_energy >= get_melee_spec_cost(MELEE_SPEC_GRANITE_MAUL); +} + +/** Check if melee spec attack is available. */ +static inline int is_melee_spec_attack_available(Player* p, Player* t) { + (void)t; + if (!is_granite_maul_attack_available(p) && !is_attack_available(p)) { + return 0; + } + if (is_melee_spec_two_handed(p->melee_spec_weapon) && !can_equip_two_handed_weapon(p)) { + return 0; + } + if (!is_melee_weapon_equipped(p) || !is_melee_spec_weapon_equipped(p)) { + return 0; + } + return can_spec(p); +} + +/** Check if ranged spec attack is available. */ +static inline int is_ranged_spec_attack_available(Player* p) { + if (!is_attack_available(p)) { + return 0; + } + if (!is_ranged_attack_available(p)) { + return 0; + } + if (p->ranged_spec_weapon == RANGED_SPEC_NONE) { + return 0; + } + if (!is_ranged_spec_weapon_equipped(p)) { + return 0; + } + return p->special_energy >= get_ranged_spec_cost(p->ranged_spec_weapon); +} + +/** Check if ice attack is available. */ +static inline int is_ice_attack_available(Player* p) { + if (p->is_lunar_spellbook) { + return 0; + } + return can_cast_ice_spell(p) && is_attack_available(p); +} + +/** Check if blood attack is available. */ +static inline int is_blood_attack_available(Player* p) { + if (p->is_lunar_spellbook) { + return 0; + } + return can_cast_blood_spell(p) && is_attack_available(p); +} + +/** Check if special attack energy is available for any equipped spec weapon. */ +static inline int can_toggle_spec(Player* p) { + if (is_melee_spec_weapon_equipped(p) && p->melee_spec_weapon != MELEE_SPEC_NONE) { + if (is_melee_spec_two_handed(p->melee_spec_weapon) && !can_equip_two_handed_weapon(p)) { + return 0; + } + return p->special_energy >= get_melee_spec_cost(p->melee_spec_weapon); + } + if (is_ranged_spec_weapon_equipped(p) && p->ranged_spec_weapon != RANGED_SPEC_NONE) { + return p->special_energy >= get_ranged_spec_cost(p->ranged_spec_weapon); + } + if (is_magic_spec_weapon_equipped(p) && p->magic_spec_weapon != MAGIC_SPEC_NONE) { + return p->special_energy >= get_magic_spec_cost(p->magic_spec_weapon); + } + return 0; +} + +/** Check if special attack should fire for the current weapon style. + * Auto-specs when a spec weapon is equipped and energy is sufficient. */ +static inline int is_special_ready(Player* p, AttackStyle style) { + switch (style) { + case ATTACK_STYLE_MELEE: + if (!is_melee_spec_weapon_equipped(p) || p->melee_spec_weapon == MELEE_SPEC_NONE) { + return 0; + } + if (is_melee_spec_two_handed(p->melee_spec_weapon) && !can_equip_two_handed_weapon(p)) { + return 0; + } + return p->special_energy >= get_melee_spec_cost(p->melee_spec_weapon); + case ATTACK_STYLE_RANGED: + if (!is_ranged_spec_weapon_equipped(p) || p->ranged_spec_weapon == RANGED_SPEC_NONE) { + return 0; + } + return p->special_energy >= get_ranged_spec_cost(p->ranged_spec_weapon); + case ATTACK_STYLE_MAGIC: + if (!is_magic_spec_weapon_equipped(p) || p->magic_spec_weapon == MAGIC_SPEC_NONE) { + return 0; + } + return p->special_energy >= get_magic_spec_cost(p->magic_spec_weapon); + default: + return 0; + } +} + +/** Get ticks until next hit lands on opponent. */ +static inline int get_ticks_until_next_hit(Player* p) { + int min_ticks = -1; + for (int i = 0; i < p->num_pending_hits; i++) { + if (min_ticks < 0 || p->pending_hits[i].ticks_until_hit < min_ticks) { + min_ticks = p->pending_hits[i].ticks_until_hit; + } + } + return min_ticks; +} + +// ============================================================================ +// WEAPON RANGE +// ============================================================================ + +/** Weapon type for attack ranges. */ +typedef enum { + WEAPON_TYPE_STANDARD = 0, + WEAPON_TYPE_HALBERD +} WeaponType; + +/** Check if melee spec weapon is halberd-type (2-tile range). */ +static inline int is_halberd_weapon(MeleeSpecWeapon weapon) { + (void)weapon; + return 0; +} + +/** + * Get effective attack range for player based on current gear and weapon. + * + * @param p Player + * @param style Attack style + * @return Attack range in tiles + */ +static inline int get_attack_range(Player* p, AttackStyle style) { + switch (style) { + case ATTACK_STYLE_MELEE: + if (is_halberd_weapon(p->melee_spec_weapon)) { + return 2; + } + return 1; + case ATTACK_STYLE_RANGED: + return get_slot_gear_bonuses(p)->attack_range; + case ATTACK_STYLE_MAGIC: + return get_slot_gear_bonuses(p)->attack_range; + default: + return 1; + } +} + +// ============================================================================ +// ATTACK EXECUTION +// ============================================================================ + +/** + * Perform attack on defender. + * + * Handles all attack types (melee/ranged/magic), special attacks, + * and weapon-specific mechanics (dragon claws, voidwaker, dark bow, etc.). + * + * @param env Environment state + * @param attacker_idx Attacker player index + * @param defender_idx Defender player index + * @param style Attack style (melee/ranged/magic) + * @param is_special 1 if using special attack + * @param magic_type Magic spell type (1=ice, 2=blood, 0=none) + * @param distance Distance to target in tiles + */ +static void perform_attack(OsrsPvp* env, int attacker_idx, int defender_idx, + AttackStyle style, int is_special, int magic_type, int distance) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + int dx = abs_int(attacker->x - defender->x); + int dy = abs_int(attacker->y - defender->y); + attacker->last_attack_dx = dx; + attacker->last_attack_dy = dy; + attacker->last_attack_dist = (dx > dy) ? dx : dy; + + if (style == ATTACK_STYLE_MELEE && !is_in_melee_range(attacker, defender)) { + return; + } + + float acc_mult = 1.0f; + float str_mult = 1.0f; + int hit_count = 1; + int spec_cost = 0; + int is_instant = 0; + int applies_freeze = 0; + int drains_def = 0; + int def_drain_percent = 0; + int heals_attacker = 0; + int bleed_damage = 0; + int use_dragon_claws = 0; + int use_voidwaker = 0; + int use_vestas = 0; + int use_statius = 0; + int use_dark_bow = 0; + int was_special_requested = is_special; + + if (is_special) { + switch (style) { + case ATTACK_STYLE_MELEE: { + MeleeSpecWeapon weapon = attacker->melee_spec_weapon; + spec_cost = get_melee_spec_cost(weapon); + acc_mult = get_melee_spec_acc_mult(weapon); + str_mult = get_melee_spec_str_mult(weapon); + + switch (weapon) { + case MELEE_SPEC_DRAGON_CLAWS: + use_dragon_claws = 1; + break; + case MELEE_SPEC_VOIDWAKER: + use_voidwaker = 1; + break; + case MELEE_SPEC_VESTAS: + use_vestas = 1; + break; + case MELEE_SPEC_DRAGON_DAGGER: + case MELEE_SPEC_ABYSSAL_DAGGER: + hit_count = 2; + break; + case MELEE_SPEC_GRANITE_MAUL: + is_instant = 1; + break; + case MELEE_SPEC_DWH: + use_statius = 1; + break; + case MELEE_SPEC_BGS: + drains_def = 2; + break; + case MELEE_SPEC_ZGS: + applies_freeze = 1; + break; + case MELEE_SPEC_SGS: + heals_attacker = 1; + break; + case MELEE_SPEC_ANCIENT_GS: + bleed_damage = 25; + break; + default: + break; + } + break; + } + case ATTACK_STYLE_RANGED: { + RangedSpecWeapon weapon = attacker->ranged_spec_weapon; + spec_cost = get_ranged_spec_cost(weapon); + acc_mult = get_ranged_spec_acc_mult(weapon); + str_mult = get_ranged_spec_str_mult(weapon); + + switch (weapon) { + case RANGED_SPEC_DARK_BOW: + use_dark_bow = 1; + break; + case RANGED_SPEC_DRAGON_KNIFE: + case RANGED_SPEC_MSB: + hit_count = 2; + break; + default: + break; + } + break; + } + case ATTACK_STYLE_MAGIC: { + MagicSpecWeapon weapon = attacker->magic_spec_weapon; + spec_cost = get_magic_spec_cost(weapon); + acc_mult = get_magic_spec_acc_mult(weapon); + break; + } + default: + break; + } + + if (attacker->special_energy < spec_cost) { + is_special = 0; + acc_mult = 1.0f; + str_mult = 1.0f; + hit_count = 1; + use_dragon_claws = 0; + use_voidwaker = 0; + use_vestas = 0; + use_statius = 0; + use_dark_bow = 0; + attacker->special_active = 0; + } else if (is_special) { + attacker->special_energy -= spec_cost; + if (!attacker->spec_regen_active && attacker->special_energy < 100) { + attacker->spec_regen_active = 1; + attacker->special_regen_ticks = 0; + } + attacker->special_active = 0; + } + } + + // Update gear based on attack style + // When spec fails (insufficient energy), the player is still holding the + // spec weapon with spec gear equipped — use GEAR_SPEC bonuses, not GEAR_MELEE. + // Using GEAR_MELEE here gave whip-level str bonus (115) to DDS pokes (should be 73). + if (style == ATTACK_STYLE_MELEE) { + attacker->current_gear = was_special_requested ? GEAR_SPEC : GEAR_MELEE; + } else if (style == ATTACK_STYLE_RANGED) { + attacker->current_gear = GEAR_RANGED; + } else if (style == ATTACK_STYLE_MAGIC) { + attacker->current_gear = GEAR_MAGE; + } + + if (is_special && use_dragon_claws) { + perform_dragon_claws_spec(env, attacker_idx, defender_idx); + goto post_attack; + } + if (is_special && use_voidwaker) { + perform_voidwaker_spec(env, attacker_idx, defender_idx); + goto post_attack; + } + if (is_special && use_vestas) { + perform_vestas_spec(env, attacker_idx, defender_idx); + goto post_attack; + } + if (is_special && use_statius) { + perform_statius_spec(env, attacker_idx, defender_idx); + goto post_attack; + } + if (is_special && use_dark_bow) { + perform_dark_bow_spec(env, attacker_idx, defender_idx); + goto post_attack; + } + + // Zuriel's staff passive: 10% increased accuracy on ice spells + int has_zuriels = (attacker->equipped[GEAR_SLOT_WEAPON] == ITEM_ZURIELS_STAFF); + if (has_zuriels && style == ATTACK_STYLE_MAGIC && !is_special && magic_type == 1) { + acc_mult *= 1.10f; + } + + float hit_chance = calculate_hit_chance(env, attacker, defender, style, acc_mult); + int magic_base_hit = 30; + if (style == ATTACK_STYLE_MAGIC && !is_special) { + if (magic_type == 1) { + magic_base_hit = get_ice_base_hit(attacker->current_magic); + } else if (magic_type == 2) { + magic_base_hit = get_blood_base_hit(attacker->current_magic); + } + } + int max_hit = calculate_max_hit(attacker, style, str_mult, magic_base_hit); + + int hit_delay; + switch (style) { + case ATTACK_STYLE_MELEE: + hit_delay = 0; + break; + case ATTACK_STYLE_RANGED: + hit_delay = ranged_hit_delay(distance, is_special, attacker->ranged_spec_weapon); + break; + case ATTACK_STYLE_MAGIC: + hit_delay = magic_hit_delay(distance); + break; + default: + hit_delay = 0; + } + + int freeze_ticks = 0; + int heal_percent = 0; + int drain_type = 0; + int drain_percent = 0; + + if (is_special) { + if (drains_def == 1) { + drain_type = 1; + drain_percent = def_drain_percent; + } else if (drains_def == 2) { + drain_type = 2; + } + if (applies_freeze) { + freeze_ticks = 32; + } + if (heals_attacker) { + heal_percent = 50; + } + } + + if (style == ATTACK_STYLE_MAGIC && !is_special) { + if (magic_type == 1) { + freeze_ticks = get_ice_freeze_ticks(attacker->current_magic); + // Zuriel's staff passive: 10% increased freeze duration + if (has_zuriels) { + freeze_ticks = (int)(freeze_ticks * 1.10f); + } + } else if (magic_type == 2) { + heal_percent = get_blood_heal_percent(attacker->current_magic); + // Zuriel's staff passive: 50% increased blood spell healing + if (has_zuriels) { + heal_percent = (int)(heal_percent * 1.50f); + } + } + } + + int total_damage = 0; + int any_hit_success = 0; + int apply_magic_freeze_on_calc = (style == ATTACK_STYLE_MAGIC && !is_special && magic_type == 1); + + // Diamond bolt (e) armor piercing: 11% chance (Kandarin hard diary active in LMS) + // Effect: guaranteed hit, 0-115% max hit + int has_diamond_bolts = (attacker->equipped[GEAR_SLOT_AMMO] == ITEM_DIAMOND_BOLTS_E); + + for (int i = 0; i < hit_count; i++) { + int damage = 0; + int hit_success = 0; + + // Check for diamond bolt armor piercing proc on ranged non-special attacks + int armor_piercing_proc = 0; + if (style == ATTACK_STYLE_RANGED && !is_special && has_diamond_bolts) { + if (rand_float(env) < 0.11f) { + armor_piercing_proc = 1; + } + } + + if (armor_piercing_proc) { + // Armor piercing: guaranteed hit, 0-115% max hit + hit_success = 1; + any_hit_success = 1; + int boosted_max = (int)(max_hit * 1.15f); + damage = rand_int(env, boosted_max + 1); + total_damage += damage; + } else if (rand_float(env) < hit_chance) { + hit_success = 1; + any_hit_success = 1; + damage = rand_int(env, max_hit + 1); + total_damage += damage; + } + int queued_freeze_ticks = freeze_ticks; + if (apply_magic_freeze_on_calc) { + if (hit_success && defender->freeze_immunity_ticks == 0 && defender->frozen_ticks == 0) { + defender->frozen_ticks = freeze_ticks; + defender->freeze_immunity_ticks = freeze_ticks + 5; + defender->freeze_applied_this_tick = 1; + defender->hit_attacker_idx = attacker_idx; + } + queued_freeze_ticks = 0; + } + queue_hit(attacker, defender, damage, style, hit_delay, is_special, + hit_success, queued_freeze_ticks, heal_percent, drain_type, drain_percent, 0); + } + register_hit_calculated(env, attacker_idx, defender_idx, style, total_damage); + + // Ancient GS Blood Sacrifice: 25 magic damage at 8 ticks + heal attacker 15% target max HP (cap 15 PvP) + if (is_special && bleed_damage > 0 && any_hit_success) { + int ags_heal = clamp((int)(defender->base_hitpoints * 0.15f), 0, 15); + queue_hit(attacker, defender, bleed_damage, ATTACK_STYLE_MAGIC, 8, 1, 1, 0, 0, 0, 0, ags_heal); + } + + // Morrigan's javelin Phantom Strike: timer starts at calc, bleed amount set on hit landing + if (is_special && style == ATTACK_STYLE_RANGED && + attacker->ranged_spec_weapon == RANGED_SPEC_MORRIGANS && + any_hit_success && total_damage > 0) { + attacker->pending_hits[attacker->num_pending_hits - 1].is_morr_bleed = 1; + defender->morr_dot_tick_counter = 3; + } + +post_attack: + attacker->just_attacked = 1; + // Voidwaker deals magic damage despite being a melee spec weapon + attacker->last_attack_style = (is_special && use_voidwaker) ? ATTACK_STYLE_MAGIC : style; + // Track actual attack style for reward shaping (set AFTER attack fires) + attacker->attack_style_this_tick = (is_special && use_voidwaker) ? ATTACK_STYLE_MAGIC : style; + attacker->magic_type_this_tick = magic_type; // 0=none, 1=ice, 2=blood + attacker->used_special_this_tick = is_special; + + int attack_speed = get_slot_gear_bonuses(attacker)->attack_speed; + if (!is_instant) { + attacker->attack_timer = attack_speed - 1; + attacker->attack_timer_uncapped = attack_speed - 1; + attacker->has_attack_timer = 1; + } +} + +#endif // OSRS_PVP_COMBAT_H diff --git a/ocean/osrs/osrs_pvp_effects.h b/ocean/osrs/osrs_pvp_effects.h new file mode 100644 index 0000000000..c4db411e27 --- /dev/null +++ b/ocean/osrs/osrs_pvp_effects.h @@ -0,0 +1,366 @@ +/** + * @fileoverview Visual effect system for spell impacts and projectiles. + * + * Manages animated spotanim effects (ice barrage splash, blood barrage) and + * traveling projectiles (crossbow bolts, ice barrage orb). Each effect has a + * model, animation, position, and lifetime. Projectiles follow parabolic arcs + * matching OSRS SceneProjectile.java trajectory math. + * + * Effects are spawned from game state in render_post_tick and drawn as 3D + * models in the render pipeline. Animation advances at 50 Hz client ticks. + */ + +#ifndef OSRS_PVP_EFFECTS_H +#define OSRS_PVP_EFFECTS_H + +#include "osrs_pvp_models.h" +#include "osrs_pvp_anim.h" +#include + +#define MAX_ACTIVE_EFFECTS 16 + +/* ======================================================================== */ +/* spotanim metadata (hardcoded for the effects we care about) */ +/* ======================================================================== */ + +/* GFX IDs from spotanim.dat */ +#define GFX_BOLT 27 +#define GFX_SPLASH 85 /* blue splash on spell miss */ +#define GFX_ICE_BARRAGE_PROJ 368 +#define GFX_ICE_BARRAGE_HIT 369 +#define GFX_BLOOD_BARRAGE_HIT 377 +#define GFX_DRAGON_BOLT 1468 + +/* player weapon projectiles (zulrah encounter) */ +#define GFX_TRIDENT_CAST 665 /* casting effect on player */ +#define GFX_TRIDENT_PROJ 1040 /* trident projectile in flight */ +#define GFX_TRIDENT_IMPACT 1042 /* trident hit splash on target */ +#define GFX_RUNE_ARROW 15 /* rune arrow projectile (MSB) */ +#define GFX_DRAGON_DART 1122 /* dragon dart projectile (blowpipe) */ +#define GFX_RUNE_DART 231 /* rune dart projectile */ +#define GFX_BLOWPIPE_SPEC 1043 /* blowpipe special attack effect */ +/* TODO: add voidwaker lightning on-hit GFX (spotanim on opponent). + * TODO: add VLS special attack on-hit effect. + * combat mechanics for both work correctly, just missing visual effects. */ + +typedef struct { + int gfx_id; + uint32_t model_id; + int anim_seq_id; /* -1 = no animation (static model) */ + int resize_xy; /* 128 = 1.0x */ + int resize_z; +} SpotAnimMeta; + +/* parsed from spotanim.dat via export_spotanims.py */ +static const SpotAnimMeta SPOTANIM_TABLE[] = { + { GFX_BOLT, 3135, -1, 128, 128 }, + { GFX_SPLASH, 3080, 653, 128, 128 }, + { GFX_ICE_BARRAGE_PROJ, 14215, 1964, 128, 128 }, + { GFX_ICE_BARRAGE_HIT, 6381, 1965, 128, 128 }, + { GFX_BLOOD_BARRAGE_HIT, 6375, 1967, 128, 128 }, + { GFX_DRAGON_BOLT, 0xD0001, -1, 128, 128 }, /* synthetic recolored model */ + /* player weapon projectiles (zulrah encounter) */ + { GFX_TRIDENT_CAST, 20823, 5460, 128, 128 }, + { GFX_TRIDENT_PROJ, 20825, 5462, 128, 128 }, + { GFX_TRIDENT_IMPACT, 20824, 5461, 128, 128 }, + { GFX_RUNE_ARROW, 3136, -1, 128, 128 }, + { GFX_DRAGON_DART, 26379, 6622, 128, 128 }, + { GFX_RUNE_DART, 3131, -1, 128, 128 }, + { GFX_BLOWPIPE_SPEC, 29421, 876, 128, 128 }, +}; +#define SPOTANIM_TABLE_SIZE (sizeof(SPOTANIM_TABLE) / sizeof(SPOTANIM_TABLE[0])) + +static const SpotAnimMeta* spotanim_lookup(int gfx_id) { + for (int i = 0; i < (int)SPOTANIM_TABLE_SIZE; i++) { + if (SPOTANIM_TABLE[i].gfx_id == gfx_id) return &SPOTANIM_TABLE[i]; + } + return NULL; +} + +/* ======================================================================== */ +/* effect types */ +/* ======================================================================== */ + +typedef enum { + EFFECT_NONE = 0, + EFFECT_SPOTANIM, /* plays at a fixed position (impact effects) */ + EFFECT_PROJECTILE, /* travels from source to target with parabolic arc */ +} EffectType; + +typedef struct { + EffectType type; + int gfx_id; + const SpotAnimMeta* meta; + + /* world position in sub-tile coords (128 units per tile) */ + double src_x, src_y; /* start (projectiles) */ + double dst_x, dst_y; /* end (projectiles) or position (spotanims) */ + double cur_x, cur_y; /* current interpolated position */ + double height; /* current height in sub-tile units */ + + /* projectile trajectory (from SceneProjectile.java) */ + double x_increment; + double y_increment; + double diagonal_increment; + double height_increment; + double height_accel; /* aDouble1578: parabolic curvature */ + int start_height; /* sub-tile units */ + int end_height; + int initial_slope; /* trajectory arc angle */ + + /* timing in client ticks (50 Hz) */ + int start_tick; + int stop_tick; + int started; /* has calculateIncrements been called? */ + + /* animation state */ + int anim_frame; + int anim_tick_counter; + AnimModelState* anim_state; /* per-effect vertex transform state (heap) */ + + /* orientation */ + int turn_value; /* 0-2047 OSRS angle units */ + int tilt_angle; +} ActiveEffect; + +/* ======================================================================== */ +/* internal helpers */ +/* ======================================================================== */ + +/** Free an effect's animation state and mark it inactive. */ +static void effect_free(ActiveEffect* e) { + if (e->anim_state) { + anim_model_state_free(e->anim_state); + e->anim_state = NULL; + } + e->type = EFFECT_NONE; +} + +/** Find a free effect slot, evicting the oldest if full. */ +static int effect_find_slot(ActiveEffect effects[MAX_ACTIVE_EFFECTS]) { + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + if (effects[i].type == EFFECT_NONE) return i; + } + /* evict oldest */ + int oldest = 0; + for (int i = 1; i < MAX_ACTIVE_EFFECTS; i++) { + if (effects[i].start_tick < effects[oldest].start_tick) oldest = i; + } + effect_free(&effects[oldest]); + return oldest; +} + +/** Create AnimModelState for an effect's model (if it has animation data). */ +static void effect_init_anim_state( + ActiveEffect* e, + ModelCache* model_cache +) { + if (!e->meta || e->meta->anim_seq_id < 0 || !model_cache) return; + + OsrsModel* om = model_cache_get(model_cache, e->meta->model_id); + if (!om || !om->vertex_skins || om->base_vert_count == 0) return; + + e->anim_state = anim_model_state_create( + om->vertex_skins, om->base_vert_count); +} + +/* ======================================================================== */ +/* effect lifecycle */ +/* ======================================================================== */ + +/** + * Spawn a spotanim effect at a world position (impact splash, etc). + * Duration is determined by the animation length, or a fixed 30 client ticks + * for static models. + */ +static int effect_spawn_spotanim_subtile( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int gfx_id, + float subtile_x, float subtile_y, + int current_client_tick, + AnimCache* anim_cache, + ModelCache* model_cache +) { + const SpotAnimMeta* meta = spotanim_lookup(gfx_id); + if (!meta) return -1; + + int slot = effect_find_slot(effects); + ActiveEffect* e = &effects[slot]; + memset(e, 0, sizeof(ActiveEffect)); + e->type = EFFECT_SPOTANIM; + e->gfx_id = gfx_id; + e->meta = meta; + + e->cur_x = subtile_x; + e->cur_y = subtile_y; + e->height = 0; + + e->start_tick = current_client_tick; + + /* duration from animation, or 30 client ticks default */ + int duration = 30; + if (meta->anim_seq_id >= 0 && anim_cache) { + AnimSequence* seq = anim_get_sequence(anim_cache, meta->anim_seq_id); + if (seq) { + duration = 0; + for (int f = 0; f < seq->frame_count; f++) { + duration += seq->frames[f].delay; + } + } + } + e->stop_tick = current_client_tick + duration; + + effect_init_anim_state(e, model_cache); + return slot; +} + +/* convenience wrapper: integer tile coords → sub-tile center */ +static int effect_spawn_spotanim( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int gfx_id, int world_x, int world_y, + int current_client_tick, AnimCache* anim_cache, ModelCache* model_cache +) { + return effect_spawn_spotanim_subtile(effects, gfx_id, + world_x * 128.0f + 64.0f, world_y * 128.0f + 64.0f, + current_client_tick, anim_cache, model_cache); +} + +/** + * Spawn a traveling projectile from source to target position. + * + * Trajectory math from SceneProjectile.java calculateIncrements/progressCycles: + * - parabolic height arc controlled by initialSlope + * - position advances linearly per client tick + * - height has quadratic acceleration term + */ +static int effect_spawn_projectile( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int gfx_id, + int src_world_x, int src_world_y, + int dst_world_x, int dst_world_y, + int delay_client_ticks, + int duration_client_ticks, + int start_height_subtile, + int end_height_subtile, + int slope, + int current_client_tick, + ModelCache* model_cache +) { + const SpotAnimMeta* meta = spotanim_lookup(gfx_id); + if (!meta) return -1; + + int slot = effect_find_slot(effects); + ActiveEffect* e = &effects[slot]; + memset(e, 0, sizeof(ActiveEffect)); + e->type = EFFECT_PROJECTILE; + e->gfx_id = gfx_id; + e->meta = meta; + + e->src_x = src_world_x * 128.0 + 64.0; + e->src_y = src_world_y * 128.0 + 64.0; + e->dst_x = dst_world_x * 128.0 + 64.0; + e->dst_y = dst_world_y * 128.0 + 64.0; + e->cur_x = e->src_x; + e->cur_y = e->src_y; + e->start_height = start_height_subtile; + e->end_height = end_height_subtile; + e->height = start_height_subtile; + e->initial_slope = slope; + e->started = 0; + + e->start_tick = current_client_tick + delay_client_ticks; + e->stop_tick = current_client_tick + delay_client_ticks + duration_client_ticks; + + effect_init_anim_state(e, model_cache); + return slot; +} + +/** + * Advance all active effects by one client tick (20ms). + * Call this from the 50 Hz client-tick loop. + */ +static void effect_client_tick( + ActiveEffect effects[MAX_ACTIVE_EFFECTS], + int current_client_tick, + AnimCache* anim_cache +) { + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + ActiveEffect* e = &effects[i]; + if (e->type == EFFECT_NONE) continue; + + /* expired? */ + if (current_client_tick >= e->stop_tick) { + effect_free(e); + continue; + } + + /* not started yet (delayed projectile) */ + if (current_client_tick < e->start_tick) continue; + + if (e->type == EFFECT_PROJECTILE) { + if (!e->started) { + /* calculateIncrements (SceneProjectile.java:37-58) */ + e->cur_x = e->src_x; + e->cur_y = e->src_y; + e->height = e->start_height; + + double cycles_left = (double)(e->stop_tick + 1 - current_client_tick); + e->x_increment = (e->dst_x - e->cur_x) / cycles_left; + e->y_increment = (e->dst_y - e->cur_y) / cycles_left; + e->diagonal_increment = sqrt( + e->x_increment * e->x_increment + + e->y_increment * e->y_increment + ); + + e->height_increment = -e->diagonal_increment * + tan((double)e->initial_slope * 0.02454369); + e->height_accel = 2.0 * ( + (double)e->end_height - e->height - + e->height_increment * cycles_left + ) / (cycles_left * cycles_left); + + e->started = 1; + } + + /* progressCycles (SceneProjectile.java:100-118) */ + e->cur_x += e->x_increment; + e->cur_y += e->y_increment; + e->height += e->height_increment + 0.5 * e->height_accel; + e->height_increment += e->height_accel; + + /* update orientation */ + e->turn_value = (int)(atan2(e->x_increment, e->y_increment) * + 325.949) + 1024; + e->turn_value &= 0x7FF; + e->tilt_angle = (int)(atan2(e->height_increment, + e->diagonal_increment) * 325.949); + e->tilt_angle &= 0x7FF; + } + + /* advance animation */ + if (e->meta && e->meta->anim_seq_id >= 0 && anim_cache) { + AnimSequence* seq = anim_get_sequence(anim_cache, e->meta->anim_seq_id); + if (seq && seq->frame_count > 0) { + e->anim_tick_counter++; + while (e->anim_tick_counter >= seq->frames[e->anim_frame].delay) { + e->anim_tick_counter -= seq->frames[e->anim_frame].delay; + e->anim_frame++; + if (e->anim_frame >= seq->frame_count) { + e->anim_frame = 0; + } + } + } + } + } +} + +/** + * Clear all active effects (on episode reset). + */ +static void effect_clear_all(ActiveEffect effects[MAX_ACTIVE_EFFECTS]) { + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + effect_free(&effects[i]); + } +} + +#endif /* OSRS_PVP_EFFECTS_H */ diff --git a/ocean/osrs/osrs_pvp_gear.h b/ocean/osrs/osrs_pvp_gear.h new file mode 100644 index 0000000000..9fa02f32ed --- /dev/null +++ b/ocean/osrs/osrs_pvp_gear.h @@ -0,0 +1,1123 @@ +/** + * @file osrs_pvp_gear.h + * @brief Dynamic loadout resolution and gear management + * + * Priority-based loadout system: each loadout queries the player's inventory + * for best available items. No hardcoded loadout definitions. + */ + +#ifndef OSRS_PVP_GEAR_H +#define OSRS_PVP_GEAR_H + +#include "osrs_types.h" +#include "osrs_items.h" + +// ============================================================================ +// MELEE SPEC WEAPON BONUS TYPES +// ============================================================================ + +static const MeleeBonusType MELEE_SPEC_BONUS_TYPES[] = { + [MELEE_SPEC_NONE] = MELEE_BONUS_SLASH, + [MELEE_SPEC_AGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_DRAGON_CLAWS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_GRANITE_MAUL] = MELEE_BONUS_CRUSH, + [MELEE_SPEC_DRAGON_DAGGER] = MELEE_BONUS_STAB, + [MELEE_SPEC_VOIDWAKER] = MELEE_BONUS_SLASH, + [MELEE_SPEC_DWH] = MELEE_BONUS_CRUSH, + [MELEE_SPEC_BGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_ZGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_SGS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_ANCIENT_GS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_VESTAS] = MELEE_BONUS_SLASH, + [MELEE_SPEC_ABYSSAL_DAGGER] = MELEE_BONUS_STAB, + [MELEE_SPEC_DRAGON_LONGSWORD] = MELEE_BONUS_SLASH, + [MELEE_SPEC_DRAGON_MACE] = MELEE_BONUS_CRUSH, + [MELEE_SPEC_ABYSSAL_BLUDGEON] = MELEE_BONUS_CRUSH, +}; + +// ============================================================================ +// WEAPON PRIORITY TABLES (best to worst within each style) +// ============================================================================ + +static const uint8_t MELEE_WEAPON_PRIORITY[] = { + ITEM_VESTAS, ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, ITEM_ELDER_MAUL, + ITEM_VOIDWAKER, ITEM_ANCIENT_GS, ITEM_AGS, ITEM_STATIUS_WARHAMMER, ITEM_WHIP +}; +#define MELEE_WEAPON_PRIORITY_LEN 9 + +static const uint8_t RANGE_WEAPON_PRIORITY[] = { + ITEM_MORRIGANS_JAVELIN, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, ITEM_RUNE_CROSSBOW +}; +#define RANGE_WEAPON_PRIORITY_LEN 4 + +static const uint8_t MAGE_WEAPON_PRIORITY[] = { + ITEM_ZURIELS_STAFF, ITEM_KODAI_WAND, ITEM_VOLATILE_STAFF, + ITEM_STAFF_OF_DEAD, ITEM_AHRIM_STAFF +}; +#define MAGE_WEAPON_PRIORITY_LEN 5 + +// ============================================================================ +// SPEC WEAPON PRIORITY TABLES +// ============================================================================ + +static const uint8_t MELEE_SPEC_PRIORITY[] = { + ITEM_VESTAS, ITEM_ANCIENT_GS, ITEM_AGS, ITEM_DRAGON_CLAWS, + ITEM_VOIDWAKER, ITEM_STATIUS_WARHAMMER, ITEM_DRAGON_DAGGER +}; +#define MELEE_SPEC_PRIORITY_LEN 7 + +static const uint8_t RANGE_SPEC_PRIORITY[] = { + ITEM_MORRIGANS_JAVELIN, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, + ITEM_DARK_BOW, ITEM_HEAVY_BALLISTA +}; +#define RANGE_SPEC_PRIORITY_LEN 5 + +// Magic spec: only volatile nightmare staff +static const uint8_t MAGIC_SPEC_PRIORITY[] = { + ITEM_VOLATILE_STAFF +}; +#define MAGIC_SPEC_PRIORITY_LEN 1 + +// ============================================================================ +// ARMOR PRIORITY TABLES (per style) +// ============================================================================ + +// Body armor +static const uint8_t TANK_BODY_PRIORITY[] = { + ITEM_KARILS_TOP, ITEM_BLACK_DHIDE_BODY +}; +#define TANK_BODY_PRIORITY_LEN 2 + +static const uint8_t MAGE_BODY_PRIORITY[] = { + ITEM_ANCESTRAL_TOP, ITEM_AHRIMS_ROBETOP, ITEM_MYSTIC_TOP +}; +#define MAGE_BODY_PRIORITY_LEN 3 + +// Legs armor +static const uint8_t TANK_LEGS_PRIORITY[] = { + ITEM_BANDOS_TASSETS, ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, + ITEM_VERACS_PLATESKIRT, ITEM_RUNE_PLATELEGS +}; +#define TANK_LEGS_PRIORITY_LEN 5 + +static const uint8_t MAGE_LEGS_PRIORITY[] = { + ITEM_ANCESTRAL_BOTTOM, ITEM_AHRIMS_ROBESKIRT, ITEM_MYSTIC_BOTTOM +}; +#define MAGE_LEGS_PRIORITY_LEN 3 + +// Shield +static const uint8_t MELEE_SHIELD_PRIORITY[] = { + ITEM_DRAGON_DEFENDER +}; +#define MELEE_SHIELD_PRIORITY_LEN 1 + +static const uint8_t TANK_SHIELD_PRIORITY[] = { + ITEM_BLESSED_SPIRIT_SHIELD, ITEM_SPIRIT_SHIELD +}; +#define TANK_SHIELD_PRIORITY_LEN 2 + +static const uint8_t MAGE_SHIELD_PRIORITY[] = { + ITEM_MAGES_BOOK, ITEM_BLESSED_SPIRIT_SHIELD, ITEM_SPIRIT_SHIELD +}; +#define MAGE_SHIELD_PRIORITY_LEN 3 + +// Head +static const uint8_t TANK_HEAD_PRIORITY[] = { + ITEM_TORAGS_HELM, ITEM_GUTHANS_HELM, ITEM_VERACS_HELM, + ITEM_DHAROKS_HELM, ITEM_HELM_NEITIZNOT +}; +#define TANK_HEAD_PRIORITY_LEN 5 + +static const uint8_t MAGE_HEAD_PRIORITY[] = {ITEM_ANCESTRAL_HAT, ITEM_HELM_NEITIZNOT}; +#define MAGE_HEAD_PRIORITY_LEN 2 + +// Cape +static const uint8_t MELEE_CAPE_PRIORITY[] = {ITEM_INFERNAL_CAPE, ITEM_GOD_CAPE}; +#define MELEE_CAPE_PRIORITY_LEN 2 + +static const uint8_t MAGE_CAPE_PRIORITY[] = {ITEM_GOD_CAPE}; +#define MAGE_CAPE_PRIORITY_LEN 1 + +// Neck +static const uint8_t MELEE_NECK_PRIORITY[] = {ITEM_FURY, ITEM_GLORY}; +#define MELEE_NECK_PRIORITY_LEN 2 + +static const uint8_t MAGE_NECK_PRIORITY[] = {ITEM_OCCULT_NECKLACE, ITEM_GLORY}; +#define MAGE_NECK_PRIORITY_LEN 2 + +// Ring +static const uint8_t MELEE_RING_PRIORITY[] = {ITEM_BERSERKER_RING}; +#define MELEE_RING_PRIORITY_LEN 1 + +static const uint8_t MAGE_RING_PRIORITY[] = {ITEM_LIGHTBEARER, ITEM_SEERS_RING_I, ITEM_BERSERKER_RING}; +#define MAGE_RING_PRIORITY_LEN 3 + +// ============================================================================ +// SLOT-BASED GEAR COMPUTATION FROM EQUIPPED[] ARRAY +// ============================================================================ + +/** + * Compute total gear bonuses from equipped[] array. + * Sums all equipped item bonuses using the Item database. + */ +static inline GearBonuses compute_slot_gear_bonuses(Player* p) { + GearBonuses total = {0}; + + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t item_idx = p->equipped[slot]; + if (item_idx >= NUM_ITEMS) continue; + + const Item* item = &ITEM_DATABASE[item_idx]; + + total.stab_attack += item->attack_stab; + total.slash_attack += item->attack_slash; + total.crush_attack += item->attack_crush; + total.magic_attack += item->attack_magic; + total.ranged_attack += item->attack_ranged; + + total.stab_defence += item->defence_stab; + total.slash_defence += item->defence_slash; + total.crush_defence += item->defence_crush; + total.magic_defence += item->defence_magic; + total.ranged_defence += item->defence_ranged; + + total.melee_strength += item->melee_strength; + total.ranged_strength += item->ranged_strength; + total.magic_strength += item->magic_damage; + + if (item->slot == SLOT_WEAPON) { + total.attack_speed = item->attack_speed; + total.attack_range = item->attack_range; + } + } + + return total; +} + +/** Get cached slot-based gear bonuses, recomputing if dirty. */ +static inline GearBonuses* get_slot_gear_bonuses(Player* p) { + if (p->slot_gear_dirty) { + p->slot_cached_bonuses = compute_slot_gear_bonuses(p); + p->slot_gear_dirty = 0; + } + return &p->slot_cached_bonuses; +} + +// ============================================================================ +// SPEC WEAPON MAPPING +// ============================================================================ + +/** Set spec weapon enums based on equipped weapon. */ +static inline void update_spec_weapons_for_weapon(Player* p, uint8_t weapon_item) { + p->melee_spec_weapon = MELEE_SPEC_NONE; + p->ranged_spec_weapon = RANGED_SPEC_NONE; + p->magic_spec_weapon = MAGIC_SPEC_NONE; + + switch (weapon_item) { + case ITEM_DRAGON_DAGGER: + p->melee_spec_weapon = MELEE_SPEC_DRAGON_DAGGER; break; + case ITEM_DRAGON_CLAWS: + p->melee_spec_weapon = MELEE_SPEC_DRAGON_CLAWS; break; + case ITEM_AGS: + p->melee_spec_weapon = MELEE_SPEC_AGS; break; + case ITEM_ANCIENT_GS: + p->melee_spec_weapon = MELEE_SPEC_ANCIENT_GS; break; + case ITEM_GRANITE_MAUL: + p->melee_spec_weapon = MELEE_SPEC_GRANITE_MAUL; break; + case ITEM_VESTAS: + p->melee_spec_weapon = MELEE_SPEC_VESTAS; break; + case ITEM_VOIDWAKER: + p->melee_spec_weapon = MELEE_SPEC_VOIDWAKER; break; + case ITEM_STATIUS_WARHAMMER: + p->melee_spec_weapon = MELEE_SPEC_DWH; break; + case ITEM_ELDER_MAUL: + break; // Elder maul has no spec + case ITEM_DARK_BOW: + p->ranged_spec_weapon = RANGED_SPEC_DARK_BOW; break; + case ITEM_HEAVY_BALLISTA: + p->ranged_spec_weapon = RANGED_SPEC_BALLISTA; break; + case ITEM_ARMADYL_CROSSBOW: + p->ranged_spec_weapon = RANGED_SPEC_ACB; break; + case ITEM_ZARYTE_CROSSBOW: + p->ranged_spec_weapon = RANGED_SPEC_ZCB; break; + case ITEM_MORRIGANS_JAVELIN: + p->ranged_spec_weapon = RANGED_SPEC_MORRIGANS; break; + case ITEM_VOLATILE_STAFF: + p->magic_spec_weapon = MAGIC_SPEC_VOLATILE_STAFF; break; + default: + break; + } +} + +/** Check if a weapon is a spec weapon (any spec enum becomes non-NONE). */ +static inline int item_is_spec_weapon(uint8_t weapon_item) { + // Quick check without modifying player state + switch (weapon_item) { + case ITEM_DRAGON_DAGGER: + case ITEM_DRAGON_CLAWS: + case ITEM_AGS: + case ITEM_ANCIENT_GS: + case ITEM_GRANITE_MAUL: + case ITEM_VESTAS: + case ITEM_VOIDWAKER: + case ITEM_STATIUS_WARHAMMER: + case ITEM_DARK_BOW: + case ITEM_HEAVY_BALLISTA: + case ITEM_ARMADYL_CROSSBOW: + case ITEM_ZARYTE_CROSSBOW: + case ITEM_MORRIGANS_JAVELIN: + case ITEM_VOLATILE_STAFF: + return 1; + default: + return 0; + } +} + +// ============================================================================ +// EQUIP AND GEAR DETECTION +// ============================================================================ + +/** + * Equip item in slot-based mode. + * Returns 1 if equipment changed, 0 if already equipped. + */ +static inline int slot_equip_item(Player* p, int gear_slot, uint8_t item_idx) { + if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) return 0; + if (p->equipped[gear_slot] == item_idx) return 0; + + p->equipped[gear_slot] = item_idx; + p->slot_gear_dirty = 1; + + // Update gear state based on weapon + if (gear_slot == GEAR_SLOT_WEAPON && item_idx < NUM_ITEMS) { + update_spec_weapons_for_weapon(p, item_idx); + int style = get_item_attack_style(item_idx); + + // current_gear: internal, used for gear_bonuses[] index (GEAR_SPEC for spec weapons) + if (item_is_spec_weapon(item_idx)) { + p->current_gear = GEAR_SPEC; + } else if (style == ATTACK_STYLE_MELEE) { + p->current_gear = GEAR_MELEE; + } else if (style == ATTACK_STYLE_RANGED) { + p->current_gear = GEAR_RANGED; + } else if (style == ATTACK_STYLE_MAGIC) { + p->current_gear = GEAR_MAGE; + } + + // visible_gear: external, actual damage type (no GEAR_SPEC) + // voidwaker deals magic damage despite being a melee weapon + if (item_idx == ITEM_VOIDWAKER) { + p->visible_gear = GEAR_MAGE; + } else if (style == ATTACK_STYLE_MELEE) { + p->visible_gear = GEAR_MELEE; + } else if (style == ATTACK_STYLE_RANGED) { + p->visible_gear = GEAR_RANGED; + } else if (style == ATTACK_STYLE_MAGIC) { + p->visible_gear = GEAR_MAGE; + } + } + + // Handle 2H weapons: unequip shield + if (gear_slot == GEAR_SLOT_WEAPON && item_is_two_handed(item_idx)) { + p->equipped[GEAR_SLOT_SHIELD] = ITEM_NONE; + } + + return 1; +} + +// ============================================================================ +// INVENTORY SEARCH HELPERS +// ============================================================================ + +/** Check if player has an item in the given slot's inventory. */ +static inline int player_has_item_in_slot(Player* p, int gear_slot, uint8_t item_idx) { + for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { + if (p->inventory[gear_slot][i] == item_idx) return 1; + } + return 0; +} + +/** + * Find best available item from a priority list in the player's inventory. + * Returns ITEM_NONE if no item from the list is available. + */ +static inline uint8_t find_best_available( + Player* p, int gear_slot, + const uint8_t* priority, int priority_len +) { + for (int i = 0; i < priority_len; i++) { + if (player_has_item_in_slot(p, gear_slot, priority[i])) { + return priority[i]; + } + } + return ITEM_NONE; +} + +/** Find best melee spec weapon available in weapon inventory. */ +static inline uint8_t find_best_melee_spec(Player* p) { + return find_best_available(p, GEAR_SLOT_WEAPON, MELEE_SPEC_PRIORITY, MELEE_SPEC_PRIORITY_LEN); +} + +/** Find best ranged spec weapon available in weapon inventory. */ +static inline uint8_t find_best_ranged_spec(Player* p) { + return find_best_available(p, GEAR_SLOT_WEAPON, RANGE_SPEC_PRIORITY, RANGE_SPEC_PRIORITY_LEN); +} + +/** Find best magic spec weapon available in weapon inventory. */ +static inline uint8_t find_best_magic_spec(Player* p) { + return find_best_available(p, GEAR_SLOT_WEAPON, MAGIC_SPEC_PRIORITY, MAGIC_SPEC_PRIORITY_LEN); +} + +/** Check if player has granite maul in weapon inventory. */ +static inline int player_has_gmaul(Player* p) { + return player_has_item_in_slot(p, GEAR_SLOT_WEAPON, ITEM_GRANITE_MAUL); +} + +// ============================================================================ +// DYNAMIC LOADOUT RESOLUTION +// ============================================================================ + +/** + * Resolve loadout for a given style from available inventory. + * + * Writes item indices to out[8] (one per dynamic gear slot). + * Any slot without a matching item keeps its current equipment. + * + * @param p Player (for inventory lookup) + * @param loadout Style to resolve (MELEE/RANGE/MAGE/TANK/SPEC_*) + * @param out Output array of 8 item indices (NUM_DYNAMIC_GEAR_SLOTS) + */ +static inline void resolve_loadout(Player* p, int loadout, uint8_t out[NUM_DYNAMIC_GEAR_SLOTS]) { + // Initialize all outputs to current equipment + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + out[i] = p->equipped[DYNAMIC_GEAR_SLOTS[i]]; + } + + // Slot order in DYNAMIC_GEAR_SLOTS: weapon(0), shield(1), body(2), legs(3), + // head(4), cape(5), neck(6), ring(7) + + switch (loadout) { + case LOADOUT_MELEE: { + uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, MELEE_WEAPON_PRIORITY, MELEE_WEAPON_PRIORITY_LEN); + if (weapon != ITEM_NONE) out[0] = weapon; + if (!item_is_two_handed(out[0])) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MELEE_SHIELD_PRIORITY, MELEE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + } else { + out[1] = ITEM_NONE; + } + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MELEE_CAPE_PRIORITY, MELEE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MELEE_RING_PRIORITY, MELEE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_RANGE: { + uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, RANGE_WEAPON_PRIORITY, RANGE_WEAPON_PRIORITY_LEN); + if (weapon != ITEM_NONE) out[0] = weapon; + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, TANK_SHIELD_PRIORITY, TANK_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MAGE_RING_PRIORITY, MAGE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_MAGE: + case LOADOUT_TANK: { + // MAGE uses best magic weapon + magic gear + // TANK uses best magic weapon + defensive body/legs/shield + uint8_t weapon = find_best_available(p, GEAR_SLOT_WEAPON, MAGE_WEAPON_PRIORITY, MAGE_WEAPON_PRIORITY_LEN); + if (weapon != ITEM_NONE) out[0] = weapon; + + if (loadout == LOADOUT_MAGE) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MAGE_SHIELD_PRIORITY, MAGE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, MAGE_BODY_PRIORITY, MAGE_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, MAGE_LEGS_PRIORITY, MAGE_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, MAGE_HEAD_PRIORITY, MAGE_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MAGE_NECK_PRIORITY, MAGE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + } else { + // TANK: defensive shield/body/legs/head/neck + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, TANK_SHIELD_PRIORITY, TANK_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + } + + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MAGE_RING_PRIORITY, MAGE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_SPEC_MELEE: { + uint8_t weapon = find_best_melee_spec(p); + if (weapon != ITEM_NONE) out[0] = weapon; + // If 2H, shield gets cleared by slot_equip_item + if (!item_is_two_handed(out[0])) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MELEE_SHIELD_PRIORITY, MELEE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + } else { + out[1] = ITEM_NONE; + } + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MELEE_CAPE_PRIORITY, MELEE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MELEE_RING_PRIORITY, MELEE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_SPEC_RANGE: { + uint8_t weapon = find_best_ranged_spec(p); + if (weapon != ITEM_NONE) out[0] = weapon; + if (!item_is_two_handed(out[0])) { + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, TANK_SHIELD_PRIORITY, TANK_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + } else { + out[1] = ITEM_NONE; + } + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, TANK_BODY_PRIORITY, TANK_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, TANK_LEGS_PRIORITY, TANK_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, TANK_HEAD_PRIORITY, TANK_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MELEE_NECK_PRIORITY, MELEE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MELEE_RING_PRIORITY, MELEE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_SPEC_MAGIC: { + uint8_t weapon = find_best_magic_spec(p); + if (weapon != ITEM_NONE) out[0] = weapon; + uint8_t shield = find_best_available(p, GEAR_SLOT_SHIELD, MAGE_SHIELD_PRIORITY, MAGE_SHIELD_PRIORITY_LEN); + if (shield != ITEM_NONE) out[1] = shield; + uint8_t body = find_best_available(p, GEAR_SLOT_BODY, MAGE_BODY_PRIORITY, MAGE_BODY_PRIORITY_LEN); + if (body != ITEM_NONE) out[2] = body; + uint8_t legs = find_best_available(p, GEAR_SLOT_LEGS, MAGE_LEGS_PRIORITY, MAGE_LEGS_PRIORITY_LEN); + if (legs != ITEM_NONE) out[3] = legs; + uint8_t head = find_best_available(p, GEAR_SLOT_HEAD, MAGE_HEAD_PRIORITY, MAGE_HEAD_PRIORITY_LEN); + if (head != ITEM_NONE) out[4] = head; + uint8_t cape = find_best_available(p, GEAR_SLOT_CAPE, MAGE_CAPE_PRIORITY, MAGE_CAPE_PRIORITY_LEN); + if (cape != ITEM_NONE) out[5] = cape; + uint8_t neck = find_best_available(p, GEAR_SLOT_NECK, MAGE_NECK_PRIORITY, MAGE_NECK_PRIORITY_LEN); + if (neck != ITEM_NONE) out[6] = neck; + uint8_t ring = find_best_available(p, GEAR_SLOT_RING, MAGE_RING_PRIORITY, MAGE_RING_PRIORITY_LEN); + if (ring != ITEM_NONE) out[7] = ring; + break; + } + case LOADOUT_GMAUL: { + // GMAUL: 2H weapon, must clear shield + out[0] = ITEM_GRANITE_MAUL; + out[1] = ITEM_NONE; + break; + } + default: + break; + } +} + +/** + * Apply a loadout to a player using dynamic resolution. + * Returns number of slots that actually changed. + */ +static inline int apply_loadout(Player* p, int loadout) { + if (loadout <= LOADOUT_KEEP || loadout > LOADOUT_GMAUL) return 0; + + uint8_t resolved[NUM_DYNAMIC_GEAR_SLOTS]; + resolve_loadout(p, loadout, resolved); + + int changed = 0; + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + int gear_slot = DYNAMIC_GEAR_SLOTS[i]; + changed += slot_equip_item(p, gear_slot, resolved[i]); + } + + return changed; +} + +/** + * Check if current equipment matches a resolved loadout. + */ +static inline int is_loadout_active(Player* p, int loadout) { + if (loadout <= LOADOUT_KEEP || loadout > LOADOUT_GMAUL) return 0; + + uint8_t resolved[NUM_DYNAMIC_GEAR_SLOTS]; + resolve_loadout(p, loadout, resolved); + + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + int gear_slot = DYNAMIC_GEAR_SLOTS[i]; + if (p->equipped[gear_slot] != resolved[i]) return 0; + } + return 1; +} + +/** + * Get current active loadout (1-8), or 0 if no loadout matches. + */ +static inline int get_current_loadout(Player* p) { + for (int l = 1; l <= LOADOUT_GMAUL; l++) { + if (is_loadout_active(p, l)) return l; + } + return 0; +} + +// ============================================================================ +// LOADOUT-TO-GEAR MAPPING +// ============================================================================ + +/** Visible GearSet for each loadout (actual damage type, no GEAR_SPEC). */ +static inline GearSet loadout_to_gear_set(int loadout) { + switch (loadout) { + case LOADOUT_MELEE: return GEAR_MELEE; + case LOADOUT_RANGE: return GEAR_RANGED; + case LOADOUT_MAGE: return GEAR_MAGE; + case LOADOUT_TANK: return GEAR_TANK; + case LOADOUT_SPEC_MELEE: return GEAR_MELEE; + case LOADOUT_SPEC_RANGE: return GEAR_RANGED; + case LOADOUT_SPEC_MAGIC: return GEAR_MAGE; + case LOADOUT_GMAUL: return GEAR_MELEE; + default: return GEAR_MELEE; + } +} + +// ============================================================================ +// EQUIPMENT INIT +// ============================================================================ + +/** Get attack style for currently equipped weapon. */ +static inline AttackStyle get_slot_weapon_attack_style(Player* p) { + uint8_t weapon = p->equipped[GEAR_SLOT_WEAPON]; + if (weapon >= NUM_ITEMS) return ATTACK_STYLE_NONE; + return (AttackStyle)get_item_attack_style(weapon); +} + +/** + * Initialize basic LMS equipment (tier 0). + * Sets equipped[] and inventory[] arrays for the basic loadout. + */ +static inline void init_slot_equipment_lms(Player* p) { + // Clear all inventory + memset(p->inventory, ITEM_NONE, sizeof(p->inventory)); + memset(p->num_items_in_slot, 0, sizeof(p->num_items_in_slot)); + + // Default to melee style starting gear + p->equipped[GEAR_SLOT_HEAD] = ITEM_HELM_NEITIZNOT; + p->equipped[GEAR_SLOT_CAPE] = ITEM_GOD_CAPE; + p->equipped[GEAR_SLOT_NECK] = ITEM_GLORY; + p->equipped[GEAR_SLOT_AMMO] = ITEM_DIAMOND_BOLTS_E; + p->equipped[GEAR_SLOT_WEAPON] = ITEM_WHIP; + p->equipped[GEAR_SLOT_SHIELD] = ITEM_DRAGON_DEFENDER; + p->equipped[GEAR_SLOT_BODY] = ITEM_BLACK_DHIDE_BODY; + p->equipped[GEAR_SLOT_LEGS] = ITEM_RUNE_PLATELEGS; + p->equipped[GEAR_SLOT_HANDS] = ITEM_BARROWS_GLOVES; + p->equipped[GEAR_SLOT_FEET] = ITEM_CLIMBING_BOOTS; + p->equipped[GEAR_SLOT_RING] = ITEM_BERSERKER_RING; + update_spec_weapons_for_weapon(p, p->equipped[GEAR_SLOT_WEAPON]); + + // HEAD + p->inventory[GEAR_SLOT_HEAD][0] = ITEM_HELM_NEITIZNOT; + p->num_items_in_slot[GEAR_SLOT_HEAD] = 1; + + // CAPE + p->inventory[GEAR_SLOT_CAPE][0] = ITEM_GOD_CAPE; + p->num_items_in_slot[GEAR_SLOT_CAPE] = 1; + + // NECK + p->inventory[GEAR_SLOT_NECK][0] = ITEM_GLORY; + p->num_items_in_slot[GEAR_SLOT_NECK] = 1; + + // AMMO + p->inventory[GEAR_SLOT_AMMO][0] = ITEM_DIAMOND_BOLTS_E; + p->num_items_in_slot[GEAR_SLOT_AMMO] = 1; + + // WEAPON: whip, rcb, staff, dds + p->inventory[GEAR_SLOT_WEAPON][0] = ITEM_WHIP; + p->inventory[GEAR_SLOT_WEAPON][1] = ITEM_RUNE_CROSSBOW; + p->inventory[GEAR_SLOT_WEAPON][2] = ITEM_AHRIM_STAFF; + p->inventory[GEAR_SLOT_WEAPON][3] = ITEM_DRAGON_DAGGER; + p->num_items_in_slot[GEAR_SLOT_WEAPON] = 4; + + // SHIELD: defender, spirit + p->inventory[GEAR_SLOT_SHIELD][0] = ITEM_DRAGON_DEFENDER; + p->inventory[GEAR_SLOT_SHIELD][1] = ITEM_SPIRIT_SHIELD; + p->num_items_in_slot[GEAR_SLOT_SHIELD] = 2; + + // BODY: dhide, mystic + p->inventory[GEAR_SLOT_BODY][0] = ITEM_BLACK_DHIDE_BODY; + p->inventory[GEAR_SLOT_BODY][1] = ITEM_MYSTIC_TOP; + p->num_items_in_slot[GEAR_SLOT_BODY] = 2; + + // LEGS: rune, mystic + p->inventory[GEAR_SLOT_LEGS][0] = ITEM_RUNE_PLATELEGS; + p->inventory[GEAR_SLOT_LEGS][1] = ITEM_MYSTIC_BOTTOM; + p->num_items_in_slot[GEAR_SLOT_LEGS] = 2; + + // HANDS + p->inventory[GEAR_SLOT_HANDS][0] = ITEM_BARROWS_GLOVES; + p->num_items_in_slot[GEAR_SLOT_HANDS] = 1; + + // FEET + p->inventory[GEAR_SLOT_FEET][0] = ITEM_CLIMBING_BOOTS; + p->num_items_in_slot[GEAR_SLOT_FEET] = 1; + + // RING + p->inventory[GEAR_SLOT_RING][0] = ITEM_BERSERKER_RING; + p->num_items_in_slot[GEAR_SLOT_RING] = 1; + + p->slot_gear_dirty = 1; + p->current_gear = GEAR_MELEE; +} + +/** + * Add an item to a player's slot inventory. + * Returns 1 if added, 0 if slot is full or item already present. + */ +static inline int add_item_to_inventory(Player* p, int gear_slot, uint8_t item_idx) { + if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) return 0; + if (p->num_items_in_slot[gear_slot] >= MAX_ITEMS_PER_SLOT) return 0; + + // Check duplicate + for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { + if (p->inventory[gear_slot][i] == item_idx) return 0; + } + + p->inventory[gear_slot][p->num_items_in_slot[gear_slot]] = item_idx; + p->num_items_in_slot[gear_slot]++; + return 1; +} + +// ============================================================================ +// UPGRADE REPLACEMENT TABLE +// ============================================================================ + +// Maps each loot item to the basic item it replaces (ITEM_NONE = doesn't replace) +static const uint8_t UPGRADE_REPLACES[NUM_ITEMS] = { + [ITEM_HELM_NEITIZNOT] = ITEM_NONE, + [ITEM_GOD_CAPE] = ITEM_NONE, + [ITEM_GLORY] = ITEM_NONE, + [ITEM_BLACK_DHIDE_BODY] = ITEM_NONE, + [ITEM_MYSTIC_TOP] = ITEM_NONE, + [ITEM_RUNE_PLATELEGS] = ITEM_NONE, + [ITEM_MYSTIC_BOTTOM] = ITEM_NONE, + [ITEM_WHIP] = ITEM_NONE, + [ITEM_RUNE_CROSSBOW] = ITEM_NONE, + [ITEM_AHRIM_STAFF] = ITEM_NONE, + [ITEM_DRAGON_DAGGER] = ITEM_NONE, + [ITEM_DRAGON_DEFENDER] = ITEM_NONE, + [ITEM_SPIRIT_SHIELD] = ITEM_NONE, + [ITEM_BARROWS_GLOVES] = ITEM_NONE, + [ITEM_CLIMBING_BOOTS] = ITEM_NONE, + [ITEM_BERSERKER_RING] = ITEM_NONE, + [ITEM_DIAMOND_BOLTS_E] = ITEM_NONE, + // Weapons + [ITEM_GHRAZI_RAPIER] = ITEM_WHIP, + [ITEM_INQUISITORS_MACE] = ITEM_WHIP, + [ITEM_STAFF_OF_DEAD] = ITEM_AHRIM_STAFF, + [ITEM_KODAI_WAND] = ITEM_AHRIM_STAFF, + [ITEM_VOLATILE_STAFF] = ITEM_AHRIM_STAFF, + [ITEM_ZURIELS_STAFF] = ITEM_AHRIM_STAFF, + [ITEM_ARMADYL_CROSSBOW] = ITEM_RUNE_CROSSBOW, + [ITEM_ZARYTE_CROSSBOW] = ITEM_RUNE_CROSSBOW, + [ITEM_DRAGON_CLAWS] = ITEM_DRAGON_DAGGER, + [ITEM_AGS] = ITEM_DRAGON_DAGGER, + [ITEM_ANCIENT_GS] = ITEM_DRAGON_DAGGER, + [ITEM_GRANITE_MAUL] = ITEM_NONE, + [ITEM_ELDER_MAUL] = ITEM_WHIP, + [ITEM_DARK_BOW] = ITEM_NONE, + [ITEM_HEAVY_BALLISTA] = ITEM_NONE, + [ITEM_VESTAS] = ITEM_DRAGON_DAGGER, + [ITEM_VOIDWAKER] = ITEM_DRAGON_DAGGER, + [ITEM_STATIUS_WARHAMMER] = ITEM_DRAGON_DAGGER, + [ITEM_MORRIGANS_JAVELIN] = ITEM_RUNE_CROSSBOW, + // Armor and accessories + [ITEM_ANCESTRAL_HAT] = ITEM_NONE, + [ITEM_ANCESTRAL_TOP] = ITEM_MYSTIC_TOP, + [ITEM_ANCESTRAL_BOTTOM] = ITEM_MYSTIC_BOTTOM, + [ITEM_AHRIMS_ROBETOP] = ITEM_MYSTIC_TOP, + [ITEM_AHRIMS_ROBESKIRT] = ITEM_MYSTIC_BOTTOM, + [ITEM_KARILS_TOP] = ITEM_BLACK_DHIDE_BODY, + [ITEM_BANDOS_TASSETS] = ITEM_RUNE_PLATELEGS, + [ITEM_BLESSED_SPIRIT_SHIELD]= ITEM_SPIRIT_SHIELD, + [ITEM_FURY] = ITEM_GLORY, + [ITEM_OCCULT_NECKLACE] = ITEM_NONE, + [ITEM_INFERNAL_CAPE] = ITEM_NONE, + [ITEM_ETERNAL_BOOTS] = ITEM_CLIMBING_BOOTS, + [ITEM_SEERS_RING_I] = ITEM_NONE, + [ITEM_LIGHTBEARER] = ITEM_NONE, + [ITEM_MAGES_BOOK] = ITEM_NONE, + [ITEM_DRAGON_ARROWS] = ITEM_NONE, + // Barrows armor + [ITEM_TORAGS_PLATELEGS] = ITEM_RUNE_PLATELEGS, + [ITEM_DHAROKS_PLATELEGS] = ITEM_RUNE_PLATELEGS, + [ITEM_VERACS_PLATESKIRT] = ITEM_RUNE_PLATELEGS, + [ITEM_TORAGS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_DHAROKS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_VERACS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_GUTHANS_HELM] = ITEM_HELM_NEITIZNOT, + [ITEM_OPAL_DRAGON_BOLTS] = ITEM_NONE, // conditional, handled in add_loot_item +}; + +/** + * Remove an item from a player's slot inventory. + * Returns 1 if removed, 0 if item not found. + */ +static inline int remove_item_from_inventory(Player* p, int gear_slot, uint8_t item_idx) { + for (int i = 0; i < p->num_items_in_slot[gear_slot]; i++) { + if (p->inventory[gear_slot][i] == item_idx) { + for (int j = i; j < p->num_items_in_slot[gear_slot] - 1; j++) { + p->inventory[gear_slot][j] = p->inventory[gear_slot][j + 1]; + } + p->num_items_in_slot[gear_slot]--; + p->inventory[gear_slot][p->num_items_in_slot[gear_slot]] = ITEM_NONE; + return 1; + } + } + return 0; +} + +/** + * Map item database index to the correct GearSlotIndex. + * Returns -1 if item not found or slot not mapped. + */ +static inline int item_to_gear_slot(uint8_t item_idx) { + if (item_idx >= NUM_ITEMS) return -1; + switch (ITEM_DATABASE[item_idx].slot) { + case SLOT_HEAD: return GEAR_SLOT_HEAD; + case SLOT_CAPE: return GEAR_SLOT_CAPE; + case SLOT_NECK: return GEAR_SLOT_NECK; + case SLOT_WEAPON: return GEAR_SLOT_WEAPON; + case SLOT_BODY: return GEAR_SLOT_BODY; + case SLOT_SHIELD: return GEAR_SLOT_SHIELD; + case SLOT_LEGS: return GEAR_SLOT_LEGS; + case SLOT_HANDS: return GEAR_SLOT_HANDS; + case SLOT_FEET: return GEAR_SLOT_FEET; + case SLOT_RING: return GEAR_SLOT_RING; + case SLOT_AMMO: return GEAR_SLOT_AMMO; + default: return -1; + } +} + +// ============================================================================ +// LOOT UPGRADE + 28-SLOT INVENTORY MODEL +// ============================================================================ + +// Chain upgrades: loot items that also obsolete other loot items. +// UPGRADE_REPLACES handles basic→loot, these handle loot→loot chains. +// {new_item, obsolete_item} — when new_item is added, obsolete_item is dropped. +static const uint8_t CHAIN_REPLACES[][2] = { + // VLS is a better primary melee weapon than whip + { ITEM_VESTAS, ITEM_WHIP }, + // Zuriel's is strictly better than SotD and volatile + { ITEM_ZURIELS_STAFF, ITEM_STAFF_OF_DEAD }, + { ITEM_ZURIELS_STAFF, ITEM_VOLATILE_STAFF }, + // Kodai is the best mage weapon — replaces all lesser mage weapons + { ITEM_KODAI_WAND, ITEM_STAFF_OF_DEAD }, + { ITEM_KODAI_WAND, ITEM_VOLATILE_STAFF }, + { ITEM_KODAI_WAND, ITEM_ZURIELS_STAFF }, + // Volatile replaces SotD (both are magic weapons, volatile has spec) + { ITEM_VOLATILE_STAFF, ITEM_STAFF_OF_DEAD }, + // ZCB is strictly better than ACB + { ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW }, + // Morr javelin is the best ranged weapon — replaces all lesser ranged weapons + { ITEM_MORRIGANS_JAVELIN, ITEM_ZARYTE_CROSSBOW }, + { ITEM_MORRIGANS_JAVELIN, ITEM_ARMADYL_CROSSBOW }, + { ITEM_MORRIGANS_JAVELIN, ITEM_HEAVY_BALLISTA }, + { ITEM_MORRIGANS_JAVELIN, ITEM_DARK_BOW }, + // ZCB replaces ballista and dark bow + { ITEM_ZARYTE_CROSSBOW, ITEM_HEAVY_BALLISTA }, + { ITEM_ZARYTE_CROSSBOW, ITEM_DARK_BOW }, + // ACB replaces ballista and dark bow + { ITEM_ARMADYL_CROSSBOW, ITEM_HEAVY_BALLISTA }, + { ITEM_ARMADYL_CROSSBOW, ITEM_DARK_BOW }, + // Ancestral is strictly better than Ahrim's + { ITEM_ANCESTRAL_TOP, ITEM_AHRIMS_ROBETOP }, + { ITEM_ANCESTRAL_BOTTOM, ITEM_AHRIMS_ROBESKIRT }, + // Bandos tassets replaces all barrows legs + { ITEM_BANDOS_TASSETS, ITEM_TORAGS_PLATELEGS }, + { ITEM_BANDOS_TASSETS, ITEM_DHAROKS_PLATELEGS }, + { ITEM_BANDOS_TASSETS, ITEM_VERACS_PLATESKIRT }, + // Rapier and inq mace are equivalent; rapier preferred, replaces inq mace + { ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE }, + // Rapier/inq mace/elder maul all replace whip as primary + { ITEM_GHRAZI_RAPIER, ITEM_WHIP }, + { ITEM_INQUISITORS_MACE, ITEM_WHIP }, + { ITEM_ELDER_MAUL, ITEM_WHIP }, + // Rapier/inq mace replace elder maul (4-tick > 6-tick for primary DPS) + { ITEM_GHRAZI_RAPIER, ITEM_ELDER_MAUL }, + { ITEM_INQUISITORS_MACE, ITEM_ELDER_MAUL }, + // VLS replaces all lesser melee primaries + { ITEM_VESTAS, ITEM_ELDER_MAUL }, + { ITEM_VESTAS, ITEM_GHRAZI_RAPIER }, + { ITEM_VESTAS, ITEM_INQUISITORS_MACE }, + // Voidwaker replaces all lesser melee weapons (best spec + solid primary) + { ITEM_VOIDWAKER, ITEM_WHIP }, + { ITEM_VOIDWAKER, ITEM_GHRAZI_RAPIER }, + { ITEM_VOIDWAKER, ITEM_INQUISITORS_MACE }, + { ITEM_VOIDWAKER, ITEM_ELDER_MAUL }, + // SWH replaces everything below it: primary + spec in one weapon + { ITEM_STATIUS_WARHAMMER, ITEM_WHIP }, + { ITEM_STATIUS_WARHAMMER, ITEM_GHRAZI_RAPIER }, + { ITEM_STATIUS_WARHAMMER, ITEM_INQUISITORS_MACE }, + { ITEM_STATIUS_WARHAMMER, ITEM_ELDER_MAUL }, + { ITEM_STATIUS_WARHAMMER, ITEM_AGS }, + { ITEM_STATIUS_WARHAMMER, ITEM_ANCIENT_GS }, + { ITEM_STATIUS_WARHAMMER, ITEM_DRAGON_CLAWS }, + // Godswords/claws replace whip (strong enough as primary despite 6-tick) + { ITEM_AGS, ITEM_WHIP }, + { ITEM_ANCIENT_GS, ITEM_WHIP }, + // Ancient GS > AGS > claws for mid-tier melee spec + { ITEM_ANCIENT_GS, ITEM_AGS }, + { ITEM_ANCIENT_GS, ITEM_DRAGON_CLAWS }, + { ITEM_AGS, ITEM_DRAGON_CLAWS }, + // Lightbearer replaces seers ring (spec regen universally useful) + { ITEM_LIGHTBEARER, ITEM_SEERS_RING_I }, + // Barrows helms: only keep the best one (torag > guthan > verac > dharok) + { ITEM_TORAGS_HELM, ITEM_GUTHANS_HELM }, + { ITEM_TORAGS_HELM, ITEM_VERACS_HELM }, + { ITEM_TORAGS_HELM, ITEM_DHAROKS_HELM }, + { ITEM_GUTHANS_HELM, ITEM_VERACS_HELM }, + { ITEM_GUTHANS_HELM, ITEM_DHAROKS_HELM }, + { ITEM_VERACS_HELM, ITEM_DHAROKS_HELM }, +}; +#define CHAIN_REPLACES_LEN (sizeof(CHAIN_REPLACES) / sizeof(CHAIN_REPLACES[0])) + +/** + * Add a loot item with upgrade replacement logic. + * + * 1. UPGRADE_REPLACES: removes the basic item this loot replaces + * 2. CHAIN_REPLACES: removes lesser loot items made obsolete by this one + * 3. Crossbow bolt trigger: ACB/ZCB + opal bolts → swap diamond bolts + */ +static inline void add_loot_item(Player* p, uint8_t item_idx) { + int gear_slot = item_to_gear_slot(item_idx); + if (gear_slot < 0) return; + + // Reverse chain check: if a strictly better item already exists, skip this one + for (int i = 0; i < (int)CHAIN_REPLACES_LEN; i++) { + if (CHAIN_REPLACES[i][1] == item_idx) { + uint8_t better = CHAIN_REPLACES[i][0]; + int better_slot = item_to_gear_slot(better); + if (better_slot >= 0 && player_has_item_in_slot(p, better_slot, better)) { + return; // better item already owned, skip adding inferior one + } + } + } + + // Primary replacement: new loot replaces a basic item + uint8_t replaces = UPGRADE_REPLACES[item_idx]; + if (replaces != ITEM_NONE) { + int replace_slot = item_to_gear_slot(replaces); + if (replace_slot >= 0) { + remove_item_from_inventory(p, replace_slot, replaces); + } + } + + // Chain replacement: new loot also obsoletes lesser loot items + for (int i = 0; i < (int)CHAIN_REPLACES_LEN; i++) { + if (CHAIN_REPLACES[i][0] == item_idx) { + uint8_t obsolete = CHAIN_REPLACES[i][1]; + int obs_slot = item_to_gear_slot(obsolete); + if (obs_slot >= 0) { + remove_item_from_inventory(p, obs_slot, obsolete); + } + } + } + + add_item_to_inventory(p, gear_slot, item_idx); + + // Crossbow bolt trigger: ACB/ZCB + opal bolts in inventory → swap bolts + if ((item_idx == ITEM_ARMADYL_CROSSBOW || item_idx == ITEM_ZARYTE_CROSSBOW) + && player_has_item_in_slot(p, GEAR_SLOT_AMMO, ITEM_OPAL_DRAGON_BOLTS)) { + remove_item_from_inventory(p, GEAR_SLOT_AMMO, ITEM_DIAMOND_BOLTS_E); + p->equipped[GEAR_SLOT_AMMO] = ITEM_OPAL_DRAGON_BOLTS; + } + +} + +// ============================================================================ +// DYNAMIC FOOD COUNT (28-slot inventory model) +// ============================================================================ + +#define FIXED_INVENTORY_SLOTS 11 // 4 brews + 2 restores + 1 combat + 1 ranged + 2 karambwan + 1 rune pouch + +/** Count switch items: items beyond the first in each gear slot. */ +static inline int count_switch_items(Player* p) { + int switches = 0; + for (int s = 0; s < NUM_GEAR_SLOTS; s++) { + if (p->num_items_in_slot[s] > 1) { + switches += p->num_items_in_slot[s] - 1; + } + } + return switches; +} + +/** Compute food count from 28-slot inventory model. */ +static inline int compute_food_count(Player* p) { + int switches = count_switch_items(p); + int food = 28 - FIXED_INVENTORY_SLOTS - switches; + return food > 1 ? food : 1; +} + +// ============================================================================ +// GEAR TIER RANDOMIZATION +// ============================================================================ + +// Loot tables for gear tiers (items that can drop from LMS chests) +// Each chest gives 2 rolls from the same combined pool +static const uint8_t CHEST_LOOT[] = { + // offensive + ITEM_DRAGON_CLAWS, ITEM_AGS, ITEM_ANCIENT_GS, ITEM_GRANITE_MAUL, + ITEM_VOLATILE_STAFF, ITEM_ZARYTE_CROSSBOW, ITEM_ARMADYL_CROSSBOW, + ITEM_DARK_BOW, ITEM_GHRAZI_RAPIER, ITEM_INQUISITORS_MACE, + ITEM_KODAI_WAND, ITEM_STAFF_OF_DEAD, ITEM_ELDER_MAUL, + ITEM_HEAVY_BALLISTA, ITEM_OCCULT_NECKLACE, ITEM_INFERNAL_CAPE, + ITEM_SEERS_RING_I, ITEM_MAGES_BOOK, + // defensive + ITEM_ANCESTRAL_HAT, ITEM_ANCESTRAL_TOP, ITEM_ANCESTRAL_BOTTOM, + ITEM_AHRIMS_ROBETOP, ITEM_AHRIMS_ROBESKIRT, ITEM_KARILS_TOP, + ITEM_BANDOS_TASSETS, ITEM_BLESSED_SPIRIT_SHIELD, + ITEM_FURY, ITEM_ETERNAL_BOOTS, + // barrows armor + opal bolts + ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, ITEM_VERACS_PLATESKIRT, + ITEM_TORAGS_HELM, ITEM_DHAROKS_HELM, ITEM_VERACS_HELM, ITEM_GUTHANS_HELM, + ITEM_OPAL_DRAGON_BOLTS, +}; +#define CHEST_LOOT_LEN 36 + +static const uint8_t BLOODIER_LOOT[] = { + ITEM_VESTAS, ITEM_VOIDWAKER, ITEM_STATIUS_WARHAMMER, + ITEM_MORRIGANS_JAVELIN, ITEM_ZURIELS_STAFF, ITEM_LIGHTBEARER +}; +#define BLOODIER_LOOT_LEN 6 + +/** + * Initialize player gear for a given tier (randomized loot). + * + * Each chest = 2 rolls from a single combined loot pool. + * Tier 0: basic LMS (17 items), no chests + * Tier 1: basic + 1 own chest (2 rolls) + * Tier 2: basic + 2 own chests + 1 killed player's chest (6 rolls) + * Tier 3: basic + 2 own chests + 2 killed players' chests (8 rolls) + 1 bloodier key item + * + * Duplicates are handled by add_loot_item() (dedup + chain replacement). + * + * @param p Player to initialize + * @param tier Gear tier (0-3) + * @param rng RNG state pointer + */ +static inline void init_player_gear_randomized(Player* p, int tier, uint32_t* rng) { + // Start with basic LMS loadout + init_slot_equipment_lms(p); + + if (tier <= 0) return; + + // Helper: add a random item from a loot table with upgrade logic + #define ADD_RANDOM_LOOT(table, len) do { \ + uint32_t _r = xorshift32(rng); \ + uint8_t _item = (table)[_r % (len)]; \ + add_loot_item(p, _item); \ + } while(0) + + // Tier 1: 1 own chest = 2 rolls + if (tier >= 1) { + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + } + + // Tier 2: 1 more own chest (2 rolls) + 1 killed player's chest (2 rolls) + if (tier >= 2) { + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + } + + // Tier 3: 1 more killed player's chest (2 rolls) + 1 bloodier key item + if (tier >= 3) { + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(CHEST_LOOT, CHEST_LOOT_LEN); + ADD_RANDOM_LOOT(BLOODIER_LOOT, BLOODIER_LOOT_LEN); + } + + #undef ADD_RANDOM_LOOT + + // Tier 3 only: drop defender if no 1-handed melee weapon exists. + // At lower tiers future loot might add a 1H melee (VLS, SWH, voidwaker). + if (tier >= 3 && player_has_item_in_slot(p, GEAR_SLOT_SHIELD, ITEM_DRAGON_DEFENDER)) { + int has_1h_melee = 0; + for (int i = 0; i < p->num_items_in_slot[GEAR_SLOT_WEAPON]; i++) { + uint8_t w = p->inventory[GEAR_SLOT_WEAPON][i]; + if (get_item_attack_style(w) == ATTACK_STYLE_MELEE && !item_is_two_handed(w)) { + has_1h_melee = 1; + break; + } + } + if (!has_1h_melee) { + remove_item_from_inventory(p, GEAR_SLOT_SHIELD, ITEM_DRAGON_DEFENDER); + } + } + + // Re-resolve starting equipment in melee loadout + uint8_t resolved[NUM_DYNAMIC_GEAR_SLOTS]; + resolve_loadout(p, LOADOUT_MELEE, resolved); + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + slot_equip_item(p, DYNAMIC_GEAR_SLOTS[i], resolved[i]); + } + + p->slot_gear_dirty = 1; + p->current_gear = GEAR_MELEE; +} + +/** + * Sample gear tier from weights using RNG. + * Returns tier 0-3. + */ +static inline int sample_gear_tier(float weights[4], uint32_t* rng) { + float r = (float)xorshift32(rng) / (float)UINT32_MAX; + float cumulative = 0.0f; + for (int i = 0; i < 4; i++) { + cumulative += weights[i]; + if (r < cumulative) return i; + } + return 0; // Fallback to tier 0 +} + +#endif // OSRS_PVP_GEAR_H diff --git a/ocean/osrs/osrs_pvp_gui.h b/ocean/osrs/osrs_pvp_gui.h new file mode 100644 index 0000000000..cef1872619 --- /dev/null +++ b/ocean/osrs/osrs_pvp_gui.h @@ -0,0 +1,1983 @@ +/** + * @fileoverview OSRS-style GUI panel system for the debug viewer. + * + * Renders inventory, equipment, prayer, combat, and spellbook panels + * using real sprites exported from the OSRS cache (index 8). Tab bar + * at the TOP matches the real OSRS fixed-mode client (7 tabs). + * + * Sprite sources (exported by scripts/export_sprites_modern.py): + * - equipment slot backgrounds: sprite IDs 156-165, 170 + * - prayer icons (enabled/disabled): sprite IDs 115-154, 502-509, 945-951, 1420-1425 + * - tab icons: sprite IDs 168, 898, 899, 900, 901, 779, 780 + * - spell icons: sprite IDs 325-336, 375-386, 557, 561, 564, 607, 611, 614 + * - special attack bar: sprite ID 657 + * + * Layout constants derived from OSRS client widget definitions: + * - inventory: 4 columns x 7 rows, 36x32 item sprites + * - equipment: 11 slots in paperdoll layout (interface 387) + * - prayer: 5 columns x 6 rows grid (interface 541) + * - combat: 4 attack style buttons + special bar (interface 593) + * - spellbook: grid layout (interface 218) + */ + +#ifndef OSRS_PVP_GUI_H +#define OSRS_PVP_GUI_H + +#include "osrs_pvp_human_input_types.h" + +#include "raylib.h" +#include "osrs_types.h" +#include "osrs_items.h" +#include "osrs_pvp_gear.h" + +/* ======================================================================== */ +/* OSRS color palette (from client widget rendering) */ +/* ======================================================================== */ + +#define GUI_BG_DARK CLITERAL(Color){ 62, 53, 41, 255 } +#define GUI_BG_MEDIUM CLITERAL(Color){ 75, 67, 54, 255 } +#define GUI_BG_SLOT CLITERAL(Color){ 56, 48, 38, 255 } +#define GUI_BG_SLOT_HL CLITERAL(Color){ 90, 80, 60, 255 } +#define GUI_BORDER CLITERAL(Color){ 42, 36, 28, 255 } +#define GUI_BORDER_LT CLITERAL(Color){ 100, 90, 70, 255 } +#define GUI_TEXT_YELLOW CLITERAL(Color){ 255, 255, 0, 255 } +#define GUI_TEXT_ORANGE CLITERAL(Color){ 255, 152, 31, 255 } +#define GUI_TEXT_WHITE CLITERAL(Color){ 255, 255, 255, 255 } +#define GUI_TEXT_GREEN CLITERAL(Color){ 0, 255, 0, 255 } +#define GUI_TEXT_RED CLITERAL(Color){ 255, 0, 0, 255 } +#define GUI_TEXT_CYAN CLITERAL(Color){ 0, 255, 255, 255 } +#define GUI_TAB_ACTIVE CLITERAL(Color){ 100, 90, 70, 255 } +#define GUI_TAB_INACTIVE CLITERAL(Color){ 50, 44, 35, 255 } +#define GUI_PRAYER_ON CLITERAL(Color){ 200, 200, 100, 80 } +#define GUI_SPEC_GREEN CLITERAL(Color){ 0, 180, 0, 255 } +#define GUI_SPEC_DARK CLITERAL(Color){ 30, 30, 20, 255 } +#define GUI_HP_GREEN CLITERAL(Color){ 0, 146, 0, 255 } +#define GUI_HP_RED CLITERAL(Color){ 160, 0, 0, 255 } + +/* OSRS text shadow: draw black at (+1,+1) then color on top */ +#define GUI_TEXT_SHADOW CLITERAL(Color){ 0, 0, 0, 255 } + +/* ======================================================================== */ +/* tab system — 7 tabs matching OSRS fixed-mode, drawn at TOP of panel */ +/* ======================================================================== */ + +typedef enum { + GUI_TAB_COMBAT = 0, + GUI_TAB_STATS = 1, /* empty (no content) */ + GUI_TAB_QUESTS = 2, /* empty (no content) */ + GUI_TAB_INVENTORY = 3, + GUI_TAB_EQUIPMENT = 4, + GUI_TAB_PRAYER = 5, + GUI_TAB_SPELLBOOK = 6, + GUI_TAB_COUNT = 7 +} GuiTab; + +/* ======================================================================== */ +/* equipment slot sprite indices (maps GEAR_SLOT_* to sprite array index) */ +/* ======================================================================== */ + +/* slot background sprite IDs from cache index 8: + head=156, cape=157, neck=158, weapon=159, ring=160, + body=161, shield=162, legs=163, hands=164, feet=165, tile=170 */ +#define GUI_NUM_SLOT_SPRITES 12 /* 11 slots + tile background */ + +/* ======================================================================== */ +/* prayer icon indices */ +/* ======================================================================== */ + +/* prayer icons relevant to PvP/PvE. indices into gui prayer sprite arrays. + ordered to match the real OSRS prayer book (5 cols, top-to-bottom). */ +typedef enum { + GUI_PRAY_THICK_SKIN = 0, /* sprite 115 / 135 */ + GUI_PRAY_BURST_STR, /* 116 / 136 */ + GUI_PRAY_CLARITY, /* 117 / 137 */ + GUI_PRAY_SHARP_EYE, /* 118 / 138 */ + GUI_PRAY_MYSTIC_WILL, /* 119 / 139 */ + GUI_PRAY_ROCK_SKIN, /* 120 / 140 */ + GUI_PRAY_SUPERHUMAN, /* 121 / 141 */ + GUI_PRAY_IMPROVED_REFLEX, /* 122 / 142 */ + GUI_PRAY_RAPID_RESTORE, /* 123 / 143 */ + GUI_PRAY_RAPID_HEAL, /* 124 / 144 */ + GUI_PRAY_PROTECT_ITEM, /* 125 / 145 */ + GUI_PRAY_HAWK_EYE, /* 126 / 146 — actually sprite 502/506 */ + GUI_PRAY_PROTECT_MAGIC, /* 127 / 147 */ + GUI_PRAY_PROTECT_MISSILES, /* 128 / 148 */ + GUI_PRAY_PROTECT_MELEE, /* 129 / 149 */ + GUI_PRAY_REDEMPTION, /* 130 / 150 */ + GUI_PRAY_RETRIBUTION, /* 131 / 151 */ + GUI_PRAY_SMITE, /* 132 / 152 */ + GUI_PRAY_CHIVALRY, /* 133 / 153 — actually sprite 945/949 */ + GUI_PRAY_PIETY, /* 134 / 154 — actually sprite 946/950 */ + /* additional prayers with non-contiguous sprite IDs */ + GUI_PRAY_EAGLE_EYE, /* sprite 504 / 508 */ + GUI_PRAY_MYSTIC_MIGHT, /* sprite 505 / 509 */ + GUI_PRAY_PRESERVE, /* sprite 947 / 951 */ + GUI_PRAY_RIGOUR, /* sprite 1420 / 1424 */ + GUI_PRAY_AUGURY, /* sprite 1421 / 1425 */ + GUI_NUM_PRAYERS +} GuiPrayerIdx; + +/* ======================================================================== */ +/* spell icon indices */ +/* ======================================================================== */ + +typedef enum { + GUI_SPELL_ICE_RUSH = 0, /* sprite 325 / 375 */ + GUI_SPELL_ICE_BURST, /* 326 / 376 */ + GUI_SPELL_ICE_BLITZ, /* 327 / 377 */ + GUI_SPELL_ICE_BARRAGE, /* 328 / 378 */ + GUI_SPELL_BLOOD_RUSH, /* 333 / 383 */ + GUI_SPELL_BLOOD_BURST, /* 334 / 384 */ + GUI_SPELL_BLOOD_BLITZ, /* 335 / 385 */ + GUI_SPELL_BLOOD_BARRAGE, /* 336 / 386 */ + GUI_SPELL_VENGEANCE, /* 564 */ + GUI_NUM_SPELLS +} GuiSpellIdx; + +/* ======================================================================== */ +/* inventory slot system — unified grid for equipment + consumables */ +/* ======================================================================== */ + +/* inventory slot types: either an equipment item (ITEM_DATABASE index) or a consumable. + consumables are tracked as counts in Player, not as individual ITEM_DATABASE entries, + so we use dedicated types with known OSRS item IDs for sprite lookup. */ +typedef enum { + INV_SLOT_EMPTY = 0, + INV_SLOT_EQUIPMENT, /* item_db_idx holds ITEM_DATABASE index */ + INV_SLOT_FOOD, /* shark (OSRS ID 385) */ + INV_SLOT_KARAMBWAN, /* cooked karambwan (OSRS ID 3144) */ + INV_SLOT_BREW, /* saradomin brew (OSRS IDs 6685/6687/6689/6691 for 4/3/2/1 dose) */ + INV_SLOT_RESTORE, /* super restore (OSRS IDs 3024/3026/3028/3030) */ + INV_SLOT_COMBAT_POT, /* super combat (OSRS IDs 12695/12697/12699/12701) */ + INV_SLOT_RANGED_POT, /* ranging potion (OSRS IDs 2444/169/171/173) */ + INV_SLOT_ANTIVENOM, /* anti-venom+ (OSRS IDs 12913/12915/12917/12919) */ + INV_SLOT_PRAYER_POT, /* prayer potion (OSRS IDs 2434/139/141/143 for 4/3/2/1 dose) */ +} InvSlotType; + +/* OSRS item IDs for consumable sprites (4-dose shown by default) */ +#define OSRS_ID_SHARK 385 +#define OSRS_ID_KARAMBWAN 3144 +#define OSRS_ID_BREW_4 6685 +#define OSRS_ID_BREW_3 6687 +#define OSRS_ID_BREW_2 6689 +#define OSRS_ID_BREW_1 6691 +#define OSRS_ID_RESTORE_4 3024 +#define OSRS_ID_RESTORE_3 3026 +#define OSRS_ID_RESTORE_2 3028 +#define OSRS_ID_RESTORE_1 3030 +#define OSRS_ID_COMBAT_4 12695 +#define OSRS_ID_COMBAT_3 12697 +#define OSRS_ID_COMBAT_2 12699 +#define OSRS_ID_COMBAT_1 12701 +#define OSRS_ID_RANGED_4 2444 +#define OSRS_ID_RANGED_3 169 +#define OSRS_ID_RANGED_2 171 +#define OSRS_ID_RANGED_1 173 +#define OSRS_ID_ANTIVENOM_4 12913 +#define OSRS_ID_ANTIVENOM_3 12915 +#define OSRS_ID_ANTIVENOM_2 12917 +#define OSRS_ID_ANTIVENOM_1 12919 +#define OSRS_ID_PRAYER_POT_4 2434 +#define OSRS_ID_PRAYER_POT_3 139 +#define OSRS_ID_PRAYER_POT_2 141 +#define OSRS_ID_PRAYER_POT_1 143 + +#define INV_GRID_SLOTS 28 /* 4 columns x 7 rows */ + +typedef struct { + InvSlotType type; + uint8_t item_db_idx; /* ITEM_DATABASE index (for INV_SLOT_EQUIPMENT) */ + int osrs_id; /* OSRS item ID (for sprite lookup, all types) */ +} InvSlot; + +/* click/drag interaction state */ +#define INV_DIM_TICKS 15 /* client ticks (50 Hz) to show dim after click */ +#define INV_DRAG_DEAD_ZONE 5 /* pixels before drag activates */ + +typedef enum { + INV_ACTION_NONE = 0, + INV_ACTION_EQUIP, + INV_ACTION_EAT, + INV_ACTION_DRINK, +} InvAction; + +/* ======================================================================== */ +/* gui state: textures + layout */ +/* ======================================================================== */ + +typedef struct { + GuiTab active_tab; + int panel_x, panel_y; + int panel_w, panel_h; + int tab_h; + int status_bar_h; /* compact HP/prayer/spec bar height */ + + /* multi-entity cycling (G key) */ + int gui_entity_idx; + int gui_entity_count; + + /* encounter state (for boss info display below panel) */ + void* encounter_state; + const void* encounter_def; + + /* textures loaded from exported cache sprites */ + int sprites_loaded; + + /* equipment slot background sprites (indexed by GEAR_SLOT_*) */ + Texture2D slot_sprites[GUI_NUM_SLOT_SPRITES]; + Texture2D slot_tile_bg; /* sprite 170: tile/background */ + + /* tab icons: 7 tabs (combat, stats, quests, inventory, equipment, prayer, spellbook) */ + Texture2D tab_icons[GUI_TAB_COUNT]; + + /* prayer icons: enabled and disabled variants */ + Texture2D prayer_on[GUI_NUM_PRAYERS]; + Texture2D prayer_off[GUI_NUM_PRAYERS]; + + /* spell icons: enabled and disabled variants */ + Texture2D spell_on[GUI_NUM_SPELLS]; + Texture2D spell_off[GUI_NUM_SPELLS]; + + /* special attack bar sprite */ + Texture2D spec_bar; + int spec_bar_loaded; + + /* interface chrome sprites */ + Texture2D side_panel_bg; /* 1031: stone background tile */ + Texture2D tabs_row_bottom; /* 1032: bottom tab row strip */ + Texture2D tabs_row_top; /* 1036: top tab row strip */ + Texture2D tab_stone_sel[5]; /* 1026-1030: selected tab corners + middle */ + Texture2D slanted_tab; /* 952: inactive tab button */ + Texture2D slanted_tab_hover; /* 953: hovered tab button */ + Texture2D slot_tile; /* 170: equipment slot background */ + Texture2D slot_selected; /* 179: equipment slot selected */ + Texture2D orb_frame; /* 1071: minimap orb frame */ + int chrome_loaded; + + /* skill icons for stats tab (25x25 from RuneLite skill_icons) */ + #define GUI_NUM_SKILL_ICONS 7 + Texture2D skill_icons[7]; /* attack, strength, defence, ranged, prayer, magic, hitpoints */ + int skill_icons_loaded; + + /* item sprites: keyed by OSRS item ID (from data/sprites/items/{id}.png) */ + #define GUI_MAX_ITEM_SPRITES 256 + int item_sprite_ids[GUI_MAX_ITEM_SPRITES]; /* OSRS item ID, 0 = empty */ + Texture2D item_sprite_tex[GUI_MAX_ITEM_SPRITES]; /* corresponding texture */ + int item_sprite_count; + + /* inventory grid: 28 slots (4x7). initialized once at reset, then updated + incrementally — items stay in their assigned slots (no compaction on eat). + positions are user-rearrangeable via drag-and-drop. */ + InvSlot inv_grid[INV_GRID_SLOTS]; + int inv_grid_dirty; /* 1 = needs full rebuild from player state */ + + /* previous player state for incremental inventory updates. + compared each tick to detect gear switches and consumable use. */ + uint8_t inv_prev_equipped[NUM_GEAR_SLOTS]; + int inv_prev_food_count; + int inv_prev_karambwan_count; + int inv_prev_brew_doses; + int inv_prev_restore_doses; + int inv_prev_prayer_pot_doses; + int inv_prev_combat_doses; + int inv_prev_ranged_doses; + int inv_prev_antivenom_doses; + + /* human-clicked inventory slot: when a human clicks a consumable, this records + the exact slot so gui_update_inventory removes from that slot instead of the + last one. -1 = no human click pending, use default last-slot removal. */ + int human_clicked_inv_slot; + + /* click dim animation: slot index and countdown (50 Hz client ticks) */ + int inv_dim_slot; /* -1 = none */ + int inv_dim_timer; /* counts down from INV_DIM_TICKS */ + + /* drag state */ + int inv_drag_active; /* 1 = currently dragging */ + int inv_drag_src_slot; /* slot being dragged */ + int inv_drag_start_x; /* mouse position at drag start */ + int inv_drag_start_y; + int inv_drag_mouse_x; /* current mouse position during drag */ + int inv_drag_mouse_y; +} GuiState; + +/* ======================================================================== */ +/* sprite loading (called after InitWindow in render_make_client) */ +/* ======================================================================== */ + +/** Try loading a texture, returns 1 on success. */ +static int gui_try_load(Texture2D* tex, const char* path) { + if (FileExists(path)) { + *tex = LoadTexture(path); + return 1; + } + return 0; +} + +/** Load all GUI sprites from data/sprites/gui/. */ +static void gui_load_sprites(GuiState* gs) { + gs->sprites_loaded = 1; + int ok = 1; + + /* equipment slot backgrounds: sprite IDs mapped to GEAR_SLOT_* order. + GEAR_SLOT: HEAD=0, CAPE=1, NECK=2, AMMO=3, WEAPON=4, SHIELD=5, + BODY=6, LEGS=7, HANDS=8, FEET=9, RING=10 */ + static const char* slot_files[] = { + "data/sprites/gui/slot_head.png", /* GEAR_SLOT_HEAD */ + "data/sprites/gui/slot_cape.png", /* GEAR_SLOT_CAPE */ + "data/sprites/gui/slot_neck.png", /* GEAR_SLOT_NECK */ + "data/sprites/gui/slot_tile.png", /* GEAR_SLOT_AMMO (use tile bg) */ + "data/sprites/gui/slot_weapon.png", /* GEAR_SLOT_WEAPON */ + "data/sprites/gui/slot_shield.png", /* GEAR_SLOT_SHIELD */ + "data/sprites/gui/slot_body.png", /* GEAR_SLOT_BODY */ + "data/sprites/gui/slot_legs.png", /* GEAR_SLOT_LEGS */ + "data/sprites/gui/slot_hands.png", /* GEAR_SLOT_HANDS */ + "data/sprites/gui/slot_feet.png", /* GEAR_SLOT_FEET */ + "data/sprites/gui/slot_ring.png", /* GEAR_SLOT_RING */ + "data/sprites/gui/slot_tile.png", /* spare tile bg */ + }; + for (int i = 0; i < GUI_NUM_SLOT_SPRITES; i++) { + ok &= gui_try_load(&gs->slot_sprites[i], slot_files[i]); + } + gui_try_load(&gs->slot_tile_bg, "data/sprites/gui/slot_tile.png"); + + /* tab icons: mapped to GuiTab enum order (7 tabs) */ + static const char* tab_files[] = { + "data/sprites/gui/tab_combat.png", /* GUI_TAB_COMBAT */ + "data/sprites/gui/tab_stats.png", /* GUI_TAB_STATS */ + "data/sprites/gui/tab_quests.png", /* GUI_TAB_QUESTS */ + "data/sprites/gui/tab_inventory.png", /* GUI_TAB_INVENTORY */ + "data/sprites/gui/tab_equipment.png", /* GUI_TAB_EQUIPMENT */ + "data/sprites/gui/tab_prayer.png", /* GUI_TAB_PRAYER */ + "data/sprites/gui/tab_magic.png", /* GUI_TAB_SPELLBOOK */ + }; + for (int i = 0; i < GUI_TAB_COUNT; i++) { + ok &= gui_try_load(&gs->tab_icons[i], tab_files[i]); + } + + /* skill icons for stats tab (OSRS skill_icons from RuneLite resources) */ + static const char* skill_icon_files[] = { + "data/sprites/gui/skill_attack.png", + "data/sprites/gui/skill_strength.png", + "data/sprites/gui/skill_defence.png", + "data/sprites/gui/skill_ranged.png", + "data/sprites/gui/skill_prayer.png", + "data/sprites/gui/skill_magic.png", + "data/sprites/gui/skill_hitpoints.png", + }; + gs->skill_icons_loaded = 1; + for (int i = 0; i < 7; i++) { + gs->skill_icons_loaded &= gui_try_load(&gs->skill_icons[i], skill_icon_files[i]); + } + + /* prayer icons: enabled (base sprite) and disabled (+20 for base range). + base prayers 115-134 (enabled), 135-154 (disabled). + then non-contiguous: 502-509, 945-951, 1420-1425. */ + static const int pray_on_ids[] = { + 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, + 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, + 504, 505, 947, 1420, 1421, + }; + static const int pray_off_ids[] = { + 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, + 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, + 508, 509, 951, 1424, 1425, + }; + for (int i = 0; i < GUI_NUM_PRAYERS; i++) { + const char* on_path = TextFormat("data/sprites/gui/%d.png", pray_on_ids[i]); + const char* off_path = TextFormat("data/sprites/gui/%d.png", pray_off_ids[i]); + gui_try_load(&gs->prayer_on[i], on_path); + gui_try_load(&gs->prayer_off[i], off_path); + } + + /* spell icons */ + static const int spell_on_ids[] = { + 325, 326, 327, 328, 333, 334, 335, 336, 564, + }; + static const int spell_off_ids[] = { + 375, 376, 377, 378, 383, 384, 385, 386, 614, + }; + for (int i = 0; i < GUI_NUM_SPELLS; i++) { + const char* on_path = TextFormat("data/sprites/gui/%d.png", spell_on_ids[i]); + const char* off_path = TextFormat("data/sprites/gui/%d.png", spell_off_ids[i]); + gui_try_load(&gs->spell_on[i], on_path); + gui_try_load(&gs->spell_off[i], off_path); + } + + /* special attack bar */ + gs->spec_bar_loaded = gui_try_load(&gs->spec_bar, "data/sprites/gui/special_attack.png"); + + /* interface chrome */ + gs->chrome_loaded = 1; + gs->chrome_loaded &= gui_try_load(&gs->side_panel_bg, "data/sprites/gui/side_panel_bg.png"); + gs->chrome_loaded &= gui_try_load(&gs->tabs_row_bottom, "data/sprites/gui/tabs_row_bottom.png"); + gs->chrome_loaded &= gui_try_load(&gs->tabs_row_top, "data/sprites/gui/tabs_row_top.png"); + gui_try_load(&gs->slanted_tab, "data/sprites/gui/slanted_tab.png"); + gui_try_load(&gs->slanted_tab_hover, "data/sprites/gui/slanted_tab_hover.png"); + gui_try_load(&gs->slot_tile, "data/sprites/gui/slot_tile.png"); + gui_try_load(&gs->slot_selected, "data/sprites/gui/slot_selected.png"); + gui_try_load(&gs->orb_frame, "data/sprites/gui/orb_frame.png"); + + static const char* tab_sel_files[] = { + "data/sprites/gui/tab_stone_tl_sel.png", + "data/sprites/gui/tab_stone_tr_sel.png", + "data/sprites/gui/tab_stone_bl_sel.png", + "data/sprites/gui/tab_stone_br_sel.png", + "data/sprites/gui/tab_stone_mid_sel.png", + }; + for (int i = 0; i < 5; i++) gui_try_load(&gs->tab_stone_sel[i], tab_sel_files[i]); + + if (!ok) { + TraceLog(LOG_WARNING, "GUI: some sprites missing from data/sprites/gui/"); + } + + /* load item sprites from data/sprites/items/{item_id}.png */ + gs->item_sprite_count = 0; + for (int i = 0; i < NUM_ITEMS && gs->item_sprite_count < GUI_MAX_ITEM_SPRITES; i++) { + int item_id = ITEM_DATABASE[i].item_id; + if (item_id <= 0) continue; + const char* path = TextFormat("data/sprites/items/%d.png", item_id); + if (FileExists(path)) { + int idx = gs->item_sprite_count; + gs->item_sprite_ids[idx] = item_id; + gs->item_sprite_tex[idx] = LoadTexture(path); + gs->item_sprite_count++; + } + } + + /* consumable sprites: not in ITEM_DATABASE, load by OSRS item ID directly */ + static const int consumable_ids[] = { + OSRS_ID_SHARK, OSRS_ID_KARAMBWAN, + OSRS_ID_BREW_4, OSRS_ID_BREW_3, OSRS_ID_BREW_2, OSRS_ID_BREW_1, + OSRS_ID_RESTORE_4, OSRS_ID_RESTORE_3, OSRS_ID_RESTORE_2, OSRS_ID_RESTORE_1, + OSRS_ID_COMBAT_4, OSRS_ID_COMBAT_3, OSRS_ID_COMBAT_2, OSRS_ID_COMBAT_1, + OSRS_ID_RANGED_4, OSRS_ID_RANGED_3, OSRS_ID_RANGED_2, OSRS_ID_RANGED_1, + OSRS_ID_ANTIVENOM_4, OSRS_ID_ANTIVENOM_3, OSRS_ID_ANTIVENOM_2, OSRS_ID_ANTIVENOM_1, + OSRS_ID_PRAYER_POT_4, OSRS_ID_PRAYER_POT_3, OSRS_ID_PRAYER_POT_2, OSRS_ID_PRAYER_POT_1, + }; + for (int i = 0; i < (int)(sizeof(consumable_ids)/sizeof(consumable_ids[0])); i++) { + if (gs->item_sprite_count >= GUI_MAX_ITEM_SPRITES) break; + int cid = consumable_ids[i]; + const char* path = TextFormat("data/sprites/items/%d.png", cid); + if (FileExists(path)) { + int idx = gs->item_sprite_count; + gs->item_sprite_ids[idx] = cid; + gs->item_sprite_tex[idx] = LoadTexture(path); + gs->item_sprite_count++; + } + } + TraceLog(LOG_INFO, "GUI: loaded %d item sprites (incl consumables)", gs->item_sprite_count); +} + +/** Look up item sprite texture by item database index. Returns NULL texture (id=0) if not found. */ +static Texture2D gui_get_item_sprite(GuiState* gs, uint8_t item_idx) { + Texture2D empty = { 0 }; + if (item_idx == ITEM_NONE || item_idx >= NUM_ITEMS) return empty; + int item_id = ITEM_DATABASE[item_idx].item_id; + for (int i = 0; i < gs->item_sprite_count; i++) { + if (gs->item_sprite_ids[i] == item_id) return gs->item_sprite_tex[i]; + } + return empty; +} + +/** Look up item sprite texture by OSRS item ID directly (for consumables). */ +static Texture2D gui_get_sprite_by_osrs_id(GuiState* gs, int osrs_id) { + Texture2D empty = { 0 }; + if (osrs_id <= 0) return empty; + for (int i = 0; i < gs->item_sprite_count; i++) { + if (gs->item_sprite_ids[i] == osrs_id) return gs->item_sprite_tex[i]; + } + return empty; +} + +/** Unload all GUI textures. */ +static void gui_unload_sprites(GuiState* gs) { + if (!gs->sprites_loaded) return; + for (int i = 0; i < GUI_NUM_SLOT_SPRITES; i++) UnloadTexture(gs->slot_sprites[i]); + UnloadTexture(gs->slot_tile_bg); + for (int i = 0; i < GUI_TAB_COUNT; i++) UnloadTexture(gs->tab_icons[i]); + for (int i = 0; i < GUI_NUM_PRAYERS; i++) { + UnloadTexture(gs->prayer_on[i]); + UnloadTexture(gs->prayer_off[i]); + } + for (int i = 0; i < GUI_NUM_SPELLS; i++) { + UnloadTexture(gs->spell_on[i]); + UnloadTexture(gs->spell_off[i]); + } + if (gs->spec_bar_loaded) UnloadTexture(gs->spec_bar); + if (gs->chrome_loaded) { + UnloadTexture(gs->side_panel_bg); + UnloadTexture(gs->tabs_row_bottom); + UnloadTexture(gs->tabs_row_top); + for (int i = 0; i < 5; i++) UnloadTexture(gs->tab_stone_sel[i]); + } + if (gs->slanted_tab.id) UnloadTexture(gs->slanted_tab); + if (gs->slanted_tab_hover.id) UnloadTexture(gs->slanted_tab_hover); + if (gs->slot_tile.id) UnloadTexture(gs->slot_tile); + if (gs->slot_selected.id) UnloadTexture(gs->slot_selected); + if (gs->orb_frame.id) UnloadTexture(gs->orb_frame); + for (int i = 0; i < gs->item_sprite_count; i++) UnloadTexture(gs->item_sprite_tex[i]); + gs->item_sprite_count = 0; + gs->sprites_loaded = 0; +} + +/* ======================================================================== */ +/* short item names for slot display */ +/* ======================================================================== */ + +static const char* gui_item_short_name(uint8_t item_idx) { + if (item_idx == ITEM_NONE || item_idx >= NUM_ITEMS) return ""; + const char* full = ITEM_DATABASE[item_idx].name; + switch (item_idx) { + case ITEM_HELM_NEITIZNOT: return "Neit helm"; + case ITEM_GOD_CAPE: return "God cape"; + case ITEM_GLORY: return "Glory"; + case ITEM_BLACK_DHIDE_BODY: return "Dhide body"; + case ITEM_MYSTIC_TOP: return "Mystic top"; + case ITEM_RUNE_PLATELEGS: return "Rune legs"; + case ITEM_MYSTIC_BOTTOM: return "Mystic bot"; + case ITEM_WHIP: return "Whip"; + case ITEM_RUNE_CROSSBOW: return "Rune cbow"; + case ITEM_AHRIM_STAFF: return "Ahrim stf"; + case ITEM_DRAGON_DAGGER: return "DDS"; + case ITEM_DRAGON_DEFENDER: return "D defender"; + case ITEM_SPIRIT_SHIELD: return "Spirit sh"; + case ITEM_BARROWS_GLOVES: return "B gloves"; + case ITEM_CLIMBING_BOOTS: return "Climb boot"; + case ITEM_BERSERKER_RING: return "B ring"; + case ITEM_DIAMOND_BOLTS_E: return "D bolts(e)"; + case ITEM_GHRAZI_RAPIER: return "Rapier"; + case ITEM_INQUISITORS_MACE: return "Inq mace"; + case ITEM_STAFF_OF_DEAD: return "SOTD"; + case ITEM_KODAI_WAND: return "Kodai"; + case ITEM_VOLATILE_STAFF: return "Volatile"; + case ITEM_ZURIELS_STAFF: return "Zuriel stf"; + case ITEM_ARMADYL_CROSSBOW: return "ACB"; + case ITEM_ZARYTE_CROSSBOW: return "ZCB"; + case ITEM_DRAGON_CLAWS: return "D claws"; + case ITEM_AGS: return "AGS"; + case ITEM_ANCIENT_GS: return "Anc GS"; + case ITEM_GRANITE_MAUL: return "G maul"; + case ITEM_ELDER_MAUL: return "Elder maul"; + case ITEM_DARK_BOW: return "Dark bow"; + case ITEM_HEAVY_BALLISTA: return "Ballista"; + case ITEM_VESTAS: return "Vesta's"; + case ITEM_VOIDWAKER: return "Voidwaker"; + case ITEM_STATIUS_WARHAMMER: return "SWH"; + case ITEM_MORRIGANS_JAVELIN: return "Morr jav"; + case ITEM_ANCESTRAL_HAT: return "Anc hat"; + case ITEM_ANCESTRAL_TOP: return "Anc top"; + case ITEM_ANCESTRAL_BOTTOM: return "Anc bot"; + case ITEM_AHRIMS_ROBETOP: return "Ahrim top"; + case ITEM_AHRIMS_ROBESKIRT: return "Ahrim skrt"; + case ITEM_KARILS_TOP: return "Karil top"; + case ITEM_BANDOS_TASSETS: return "Tassets"; + case ITEM_BLESSED_SPIRIT_SHIELD: return "BSS"; + case ITEM_FURY: return "Fury"; + case ITEM_OCCULT_NECKLACE: return "Occult"; + case ITEM_INFERNAL_CAPE: return "Infernal"; + case ITEM_ETERNAL_BOOTS: return "Eternal"; + case ITEM_SEERS_RING_I: return "Seers (i)"; + case ITEM_LIGHTBEARER: return "Lightbear"; + case ITEM_MAGES_BOOK: return "Mage book"; + case ITEM_DRAGON_ARROWS: return "D arrows"; + case ITEM_TORAGS_PLATELEGS: return "Torag legs"; + case ITEM_DHAROKS_PLATELEGS: return "DH legs"; + case ITEM_VERACS_PLATESKIRT: return "Verac skrt"; + case ITEM_TORAGS_HELM: return "Torag helm"; + case ITEM_DHAROKS_HELM: return "DH helm"; + case ITEM_VERACS_HELM: return "Verac helm"; + case ITEM_GUTHANS_HELM: return "Guth helm"; + case ITEM_OPAL_DRAGON_BOLTS: return "Opal bolt"; + case ITEM_IMBUED_SARA_CAPE: return "Sara cape"; + case ITEM_EYE_OF_AYAK: return "Eye Ayak"; + case ITEM_ELIDINIS_WARD_F: return "Eld ward"; + case ITEM_CONFLICTION_GAUNTLETS: return "Confl gnt"; + case ITEM_AVERNIC_TREADS: return "Avernic bt"; + case ITEM_RING_OF_SUFFERING_RI: return "Suff (ri)"; + case ITEM_TWISTED_BOW: return "T bow"; + case ITEM_MASORI_MASK_F: return "Masori msk"; + case ITEM_MASORI_BODY_F: return "Masori bod"; + case ITEM_MASORI_CHAPS_F: return "Masori chp"; + case ITEM_NECKLACE_OF_ANGUISH: return "Anguish"; + case ITEM_DIZANAS_QUIVER: return "Dizana qvr"; + case ITEM_ZARYTE_VAMBRACES: return "Zaryte vam"; + case ITEM_TOXIC_BLOWPIPE: return "Blowpipe"; + case ITEM_AHRIMS_HOOD: return "Ahrim hood"; + case ITEM_TORMENTED_BRACELET: return "Tormented"; + case ITEM_SANGUINESTI_STAFF: return "Sang staff"; + case ITEM_INFINITY_BOOTS: return "Inf boots"; + case ITEM_GOD_BLESSING: return "Blessing"; + case ITEM_RING_OF_RECOIL: return "Recoil"; + case ITEM_CRYSTAL_HELM: return "Crystal hm"; + case ITEM_AVAS_ASSEMBLER: return "Assembler"; + case ITEM_CRYSTAL_BODY: return "Crystal bd"; + case ITEM_CRYSTAL_LEGS: return "Crystal lg"; + case ITEM_BOW_OF_FAERDHINEN: return "Fbow"; + case ITEM_BLESSED_DHIDE_BOOTS: return "Bless boot"; + case ITEM_MYSTIC_HAT: return "Mystic hat"; + case ITEM_TRIDENT_OF_SWAMP: return "Trident"; + case ITEM_BOOK_OF_DARKNESS: return "Book dark"; + case ITEM_AMETHYST_ARROW: return "Ameth arw"; + case ITEM_MYSTIC_BOOTS: return "Myst boots"; + case ITEM_BLESSED_COIF: return "Bless coif"; + case ITEM_BLACK_DHIDE_CHAPS: return "Dhide chap"; + case ITEM_MAGIC_SHORTBOW_I: return "MSB (i)"; + case ITEM_AVAS_ACCUMULATOR: return "Accumulate"; + default: return full; + } +} + +/* ======================================================================== */ +/* drawing helpers */ +/* ======================================================================== */ + +/** Draw text with OSRS-style shadow (black at +1,+1, then color). */ +static void gui_text_shadow(const char* text, int x, int y, int size, Color color) { + DrawText(text, x + 1, y + 1, size, GUI_TEXT_SHADOW); + DrawText(text, x, y, size, color); +} + +/** Draw an OSRS-style beveled slot rectangle. */ +static void gui_draw_slot(int x, int y, int w, int h, Color fill) { + DrawRectangle(x, y, w, h, fill); + DrawRectangleLines(x, y, w, h, GUI_BORDER); + DrawLine(x + 1, y + 1, x + w - 2, y + 1, GUI_BORDER_LT); + DrawLine(x + 1, y + 1, x + 1, y + h - 2, GUI_BORDER_LT); +} + +/** Draw texture centered within a box, scaled to fit. */ +static void gui_draw_tex_centered(Texture2D tex, int bx, int by, int bw, int bh) { + if (tex.id == 0) return; + /* scale to fit while maintaining aspect ratio */ + float sx = (float)(bw - 4) / (float)tex.width; + float sy = (float)(bh - 4) / (float)tex.height; + float s = (sx < sy) ? sx : sy; + int dw = (int)(tex.width * s); + int dh = (int)(tex.height * s); + int dx = bx + (bw - dw) / 2; + int dy = by + (bh - dh) / 2; + DrawTextureEx(tex, (Vector2){ (float)dx, (float)dy }, 0.0f, s, WHITE); +} + +/** Draw an equipment slot using real OSRS slot tile sprite + item/silhouette sprite. */ +static void gui_draw_equip_slot(GuiState* gs, int x, int y, int w, int h, + int gear_slot, uint8_t item_idx) { + /* draw slot_tile (real OSRS 36x36 stone square) as background */ + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)x, (float)y, (float)w, (float)h }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } else { + gui_draw_slot(x, y, w, h, GUI_BG_SLOT); + } + + /* draw item sprite if equipped, else slot silhouette */ + if (item_idx != ITEM_NONE && item_idx < NUM_ITEMS) { + Texture2D item_tex = gui_get_item_sprite(gs, item_idx); + if (item_tex.id != 0) { + gui_draw_tex_centered(item_tex, x, y, w, h); + } else { + const char* name = gui_item_short_name(item_idx); + gui_text_shadow(name, x + 2, y + h / 2 - 4, 7, GUI_TEXT_YELLOW); + } + } else if (gs->sprites_loaded && gear_slot >= 0 && gear_slot < GUI_NUM_SLOT_SPRITES) { + Texture2D bg = gs->slot_sprites[gear_slot]; + if (bg.id != 0) { + gui_draw_tex_centered(bg, x, y, w, h); + } + } +} + +/* ======================================================================== */ +/* status bar — compact HP/prayer/spec display above tab row */ +/* ======================================================================== */ + +static void gui_draw_status_bar(GuiState* gs, Player* p) { + int sx = gs->panel_x + 6; + int sy = gs->panel_y; + int bar_w = gs->panel_w - 12; + int bar_h = 12; + int gap = 2; + + /* HP bar */ + float hp_pct = (p->base_hitpoints > 0) ? + (float)p->current_hitpoints / (float)p->base_hitpoints : 0.0f; + DrawRectangle(sx, sy, bar_w, bar_h, GUI_HP_RED); + DrawRectangle(sx, sy, (int)(bar_w * hp_pct), bar_h, GUI_HP_GREEN); + DrawRectangleLines(sx, sy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("HP %d/%d", p->current_hitpoints, p->base_hitpoints), + sx + 4, sy + 1, 8, GUI_TEXT_WHITE); + sy += bar_h + gap; + + /* prayer bar */ + float pray_pct = (p->base_prayer > 0) ? + (float)p->current_prayer / (float)p->base_prayer : 0.0f; + DrawRectangle(sx, sy, bar_w, bar_h, GUI_SPEC_DARK); + DrawRectangle(sx, sy, (int)(bar_w * pray_pct), bar_h, GUI_TEXT_CYAN); + DrawRectangleLines(sx, sy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("Pray %d/%d", p->current_prayer, p->base_prayer), + sx + 4, sy + 1, 8, GUI_TEXT_WHITE); + sy += bar_h + gap; + + /* spec bar */ + float spec_pct = (float)p->special_energy / 100.0f; + DrawRectangle(sx, sy, bar_w, bar_h, GUI_SPEC_DARK); + DrawRectangle(sx, sy, (int)(bar_w * spec_pct), bar_h, GUI_SPEC_GREEN); + DrawRectangleLines(sx, sy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("Spec %d%%", p->special_energy), + sx + 4, sy + 1, 8, GUI_TEXT_WHITE); +} + +/* ======================================================================== */ +/* tab bar — 7 tabs at TOP of panel (real OSRS fixed-mode layout) */ +/* ======================================================================== */ + +static void gui_draw_tab_bar(GuiState* gs) { + /* tabs drawn at top: right after the status bar */ + int ty = gs->panel_y + gs->status_bar_h; + + /* draw the tab row background strip (real OSRS asset) */ + if (gs->chrome_loaded && gs->tabs_row_top.id != 0) { + Rectangle src = { 0, 0, (float)gs->tabs_row_top.width, (float)gs->tabs_row_top.height }; + Rectangle dst = { (float)gs->panel_x, (float)ty, (float)gs->panel_w, (float)gs->tab_h }; + DrawTexturePro(gs->tabs_row_top, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } else { + DrawRectangle(gs->panel_x, ty, gs->panel_w, gs->tab_h, GUI_TAB_INACTIVE); + } + + /* draw individual tabs using slanted tab sprites */ + int tab_w = gs->panel_w / GUI_TAB_COUNT; + for (int i = 0; i < GUI_TAB_COUNT; i++) { + int tx = gs->panel_x + i * tab_w; + int is_active = (i == (int)gs->active_tab); + + /* active tab: draw selected tab stone pieces */ + if (is_active && gs->tab_stone_sel[4].id != 0) { + /* use middle selected piece stretched to fill tab area */ + Rectangle src = { 0, 0, (float)gs->tab_stone_sel[4].width, (float)gs->tab_stone_sel[4].height }; + Rectangle dst = { (float)tx, (float)ty, (float)tab_w, (float)gs->tab_h }; + DrawTexturePro(gs->tab_stone_sel[4], src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + /* draw tab icon sprite centered in tab */ + if (gs->sprites_loaded && gs->tab_icons[i].id != 0) { + Color tint = is_active ? WHITE : CLITERAL(Color){ 160, 160, 160, 255 }; + Texture2D tex = gs->tab_icons[i]; + float ssx = (float)(tab_w - 8) / (float)tex.width; + float ssy = (float)(gs->tab_h - 6) / (float)tex.height; + float s = (ssx < ssy) ? ssx : ssy; + int dw = (int)(tex.width * s); + int dh = (int)(tex.height * s); + int dx = tx + (tab_w - dw) / 2; + int dy = ty + (gs->tab_h - dh) / 2; + DrawTextureEx(tex, (Vector2){ (float)dx, (float)dy }, 0.0f, s, tint); + } + } +} + +static int gui_handle_tab_click(GuiState* gs, int mouse_x, int mouse_y) { + /* tabs are at top, right after status bar */ + int ty = gs->panel_y + gs->status_bar_h; + if (mouse_y < ty || mouse_y > ty + gs->tab_h) return 0; + if (mouse_x < gs->panel_x || mouse_x >= gs->panel_x + gs->panel_w) return 0; + + int tab_w = gs->panel_w / GUI_TAB_COUNT; + int idx = (mouse_x - gs->panel_x) / tab_w; + if (idx >= 0 && idx < GUI_TAB_COUNT) { + gs->active_tab = (GuiTab)idx; + return 1; + } + return 0; +} + +/* ======================================================================== */ +/* content area Y: below status bar + tab row */ +/* ======================================================================== */ + +static int gui_content_y(GuiState* gs) { + return gs->panel_y + gs->status_bar_h + gs->tab_h; +} + +/* ======================================================================== */ +/* inventory panel (interface 149) — 4x7 grid with equipment + consumables */ +/* ======================================================================== */ + +/* inventory grid dimensions — scaled to fill the 320px panel width. + OSRS native: 42x36 cell pitch, 36x32 sprites. we scale ~1.81x so + the 4-column grid (304px) fills the panel with 8px padding each side. */ +#define INV_COLS 4 +#define INV_ROWS 7 +#define INV_CELL_W 76 +#define INV_CELL_H 65 +#define INV_SPRITE_W 65 /* 36 * 76/42 */ +#define INV_SPRITE_H 57 /* 32 * 76/42 */ + +/** Get the OSRS item ID for a consumable based on remaining doses/count. */ +static int gui_consumable_osrs_id(InvSlotType type, int doses) { + switch (type) { + case INV_SLOT_FOOD: return OSRS_ID_SHARK; + case INV_SLOT_KARAMBWAN: return OSRS_ID_KARAMBWAN; + case INV_SLOT_BREW: + if (doses >= 4) return OSRS_ID_BREW_4; + if (doses == 3) return OSRS_ID_BREW_3; + if (doses == 2) return OSRS_ID_BREW_2; + return OSRS_ID_BREW_1; + case INV_SLOT_RESTORE: + if (doses >= 4) return OSRS_ID_RESTORE_4; + if (doses == 3) return OSRS_ID_RESTORE_3; + if (doses == 2) return OSRS_ID_RESTORE_2; + return OSRS_ID_RESTORE_1; + case INV_SLOT_COMBAT_POT: + if (doses >= 4) return OSRS_ID_COMBAT_4; + if (doses == 3) return OSRS_ID_COMBAT_3; + if (doses == 2) return OSRS_ID_COMBAT_2; + return OSRS_ID_COMBAT_1; + case INV_SLOT_RANGED_POT: + if (doses >= 4) return OSRS_ID_RANGED_4; + if (doses == 3) return OSRS_ID_RANGED_3; + if (doses == 2) return OSRS_ID_RANGED_2; + return OSRS_ID_RANGED_1; + case INV_SLOT_ANTIVENOM: + if (doses >= 4) return OSRS_ID_ANTIVENOM_4; + if (doses == 3) return OSRS_ID_ANTIVENOM_3; + if (doses == 2) return OSRS_ID_ANTIVENOM_2; + return OSRS_ID_ANTIVENOM_1; + case INV_SLOT_PRAYER_POT: + if (doses >= 4) return OSRS_ID_PRAYER_POT_4; + if (doses == 3) return OSRS_ID_PRAYER_POT_3; + if (doses == 2) return OSRS_ID_PRAYER_POT_2; + return OSRS_ID_PRAYER_POT_1; + default: return 0; + } +} + +/** Find first empty slot in inventory grid (scanning left→right, top→bottom). + Returns -1 if inventory is full. */ +static int gui_inv_first_empty(GuiState* gs) { + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == INV_SLOT_EMPTY) return i; + } + return -1; +} + +/** Find the slot index of an equipment item in the inventory grid. + Returns -1 if not found. */ +static int gui_inv_find_equipment(GuiState* gs, uint8_t item_db_idx) { + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == INV_SLOT_EQUIPMENT && + gs->inv_grid[i].item_db_idx == item_db_idx) return i; + } + return -1; +} + +/** Remove the last occurrence of a consumable type from the inventory grid. + In OSRS, eating removes from the slot the item is in — we remove from the + last slot of that type (bottom-right first) since that's where the cursor + typically is when spam-eating. Returns 1 if removed, 0 if not found. */ +static int gui_inv_remove_last_consumable(GuiState* gs, InvSlotType type) { + for (int i = INV_GRID_SLOTS - 1; i >= 0; i--) { + if (gs->inv_grid[i].type == type) { + gs->inv_grid[i].type = INV_SLOT_EMPTY; + gs->inv_grid[i].item_db_idx = 0; + gs->inv_grid[i].osrs_id = 0; + return 1; + } + } + return 0; +} + +/** Place an equipment item into the inventory grid at the first empty slot. + Returns the slot index, or -1 if full. */ +static int gui_inv_place_equipment(GuiState* gs, uint8_t item_db_idx) { + int slot = gui_inv_first_empty(gs); + if (slot < 0) return -1; + gs->inv_grid[slot].type = INV_SLOT_EQUIPMENT; + gs->inv_grid[slot].item_db_idx = item_db_idx; + gs->inv_grid[slot].osrs_id = ITEM_DATABASE[item_db_idx].item_id; + return slot; +} + +/** Full inventory grid build from player state. Called once at reset. + Equipment items go first (unequipped gear), then consumables. + After this, use gui_update_inventory() for incremental changes. */ +static void gui_populate_inventory(GuiState* gs, Player* p) { + memset(gs->inv_grid, 0, sizeof(gs->inv_grid)); + int n = 0; + + /* unequipped gear items from the slot inventory */ + for (int s = 0; s < NUM_GEAR_SLOTS && n < INV_GRID_SLOTS; s++) { + for (int i = 0; i < p->num_items_in_slot[s] && n < INV_GRID_SLOTS; i++) { + uint8_t item = p->inventory[s][i]; + if (item == ITEM_NONE) continue; + /* skip if currently equipped */ + int is_equipped = 0; + for (int e = 0; e < NUM_GEAR_SLOTS; e++) { + if (p->equipped[e] == item) { is_equipped = 1; break; } + } + if (is_equipped) continue; + /* skip duplicates */ + int dup = 0; + for (int j = 0; j < n; j++) { + if (gs->inv_grid[j].type == INV_SLOT_EQUIPMENT && + gs->inv_grid[j].item_db_idx == item) { dup = 1; break; } + } + if (dup) continue; + gs->inv_grid[n].type = INV_SLOT_EQUIPMENT; + gs->inv_grid[n].item_db_idx = item; + gs->inv_grid[n].osrs_id = ITEM_DATABASE[item].item_id; + n++; + } + } + + /* consumables: food/potions are NOT stackable in OSRS. + each shark = 1 slot. each potion vial = 1 slot (with dose-specific sprite). + total doses are split into individual vials: e.g. 7 brew doses = 1x3-dose + 1x4-dose. */ + + /* food: each unit = 1 slot */ + for (int i = 0; i < p->food_count && n < INV_GRID_SLOTS; i++) { + gs->inv_grid[n].type = INV_SLOT_FOOD; + gs->inv_grid[n].osrs_id = OSRS_ID_SHARK; + n++; + } + for (int i = 0; i < p->karambwan_count && n < INV_GRID_SLOTS; i++) { + gs->inv_grid[n].type = INV_SLOT_KARAMBWAN; + gs->inv_grid[n].osrs_id = OSRS_ID_KARAMBWAN; + n++; + } + + /* potions: split doses into individual vials (4-dose first, remainder last) */ + #define ADD_POTION_VIALS(doses_total, slot_type) do { \ + int _rem = (doses_total); \ + while (_rem > 0 && n < INV_GRID_SLOTS) { \ + int _d = (_rem >= 4) ? 4 : _rem; \ + gs->inv_grid[n].type = (slot_type); \ + gs->inv_grid[n].osrs_id = gui_consumable_osrs_id((slot_type), _d); \ + _rem -= _d; \ + n++; \ + } \ + } while(0) + + ADD_POTION_VIALS(p->brew_doses, INV_SLOT_BREW); + ADD_POTION_VIALS(p->restore_doses, INV_SLOT_RESTORE); + ADD_POTION_VIALS(p->combat_potion_doses, INV_SLOT_COMBAT_POT); + ADD_POTION_VIALS(p->ranged_potion_doses, INV_SLOT_RANGED_POT); + ADD_POTION_VIALS(p->antivenom_doses, INV_SLOT_ANTIVENOM); + ADD_POTION_VIALS(p->prayer_pot_doses, INV_SLOT_PRAYER_POT); + #undef ADD_POTION_VIALS + + /* snapshot player state for incremental change detection */ + memcpy(gs->inv_prev_equipped, p->equipped, NUM_GEAR_SLOTS); + gs->inv_prev_food_count = p->food_count; + gs->inv_prev_karambwan_count = p->karambwan_count; + gs->inv_prev_brew_doses = p->brew_doses; + gs->inv_prev_restore_doses = p->restore_doses; + gs->inv_prev_prayer_pot_doses = p->prayer_pot_doses; + gs->inv_prev_combat_doses = p->combat_potion_doses; + gs->inv_prev_ranged_doses = p->ranged_potion_doses; + gs->inv_prev_antivenom_doses = p->antivenom_doses; +} + +/** Update potion vial doses in-place when doses change. + E.g. drinking 1 dose from a 4-dose brew changes it to 3-dose (different sprite). + When human_clicked_inv_slot targets a vial of this type, that specific vial loses + the dose first (OSRS behavior: you drink from the vial you clicked). */ +static void gui_inv_update_potion_doses(GuiState* gs, InvSlotType type, + int total_doses) { + /* collect existing vials of this type */ + int vial_slots[INV_GRID_SLOTS]; + int vial_count = 0; + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == type) { + vial_slots[vial_count++] = i; + } + } + if (vial_count == 0) return; + + /* figure out how many doses were lost */ + int old_total = 0; + for (int v = 0; v < vial_count; v++) { + /* reverse-lookup current dose count from OSRS ID */ + int oid = gs->inv_grid[vial_slots[v]].osrs_id; + int d4 = gui_consumable_osrs_id(type, 4); + int d3 = gui_consumable_osrs_id(type, 3); + int d2 = gui_consumable_osrs_id(type, 2); + int d1 = gui_consumable_osrs_id(type, 1); + if (oid == d4) old_total += 4; + else if (oid == d3) old_total += 3; + else if (oid == d2) old_total += 2; + else if (oid == d1) old_total += 1; + } + int doses_lost = old_total - total_doses; + + /* if a human clicked a specific vial of this type, decrement that one first */ + int clicked = gs->human_clicked_inv_slot; + if (doses_lost > 0 && clicked >= 0 && clicked < INV_GRID_SLOTS && + gs->inv_grid[clicked].type == type) { + /* find current dose count of clicked vial */ + int oid = gs->inv_grid[clicked].osrs_id; + int cur_dose = 0; + for (int d = 4; d >= 1; d--) { + if (oid == gui_consumable_osrs_id(type, d)) { cur_dose = d; break; } + } + if (cur_dose > 0) { + int take = (doses_lost < cur_dose) ? doses_lost : cur_dose; + cur_dose -= take; + doses_lost -= take; + if (cur_dose <= 0) { + gs->inv_grid[clicked].type = INV_SLOT_EMPTY; + gs->inv_grid[clicked].item_db_idx = 0; + gs->inv_grid[clicked].osrs_id = 0; + } else { + gs->inv_grid[clicked].osrs_id = gui_consumable_osrs_id(type, cur_dose); + } + } + } + + /* if doses still need removing (non-human or multiple doses lost), + take from remaining vials in reverse order (last first) */ + for (int v = vial_count - 1; v >= 0 && doses_lost > 0; v--) { + int slot = vial_slots[v]; + if (slot == clicked) continue; /* already handled */ + if (gs->inv_grid[slot].type != type) continue; + int oid = gs->inv_grid[slot].osrs_id; + int cur_dose = 0; + for (int d = 4; d >= 1; d--) { + if (oid == gui_consumable_osrs_id(type, d)) { cur_dose = d; break; } + } + if (cur_dose <= 0) continue; + int take = (doses_lost < cur_dose) ? doses_lost : cur_dose; + cur_dose -= take; + doses_lost -= take; + if (cur_dose <= 0) { + gs->inv_grid[slot].type = INV_SLOT_EMPTY; + gs->inv_grid[slot].item_db_idx = 0; + gs->inv_grid[slot].osrs_id = 0; + } else { + gs->inv_grid[slot].osrs_id = gui_consumable_osrs_id(type, cur_dose); + } + } +} + +/** Incremental inventory update. Detects gear switches and consumable changes + by comparing against the previous snapshot, then modifies only affected slots. + Items stay in their assigned positions — no compaction on eat/drink. + + OSRS gear swap rule: when you click an inventory item to equip it, the + previously equipped item goes into that exact inventory slot (direct swap). + Exception: equipping a 2H weapon while a shield is equipped — the shield + goes to the first empty inventory slot since it wasn't directly clicked. */ +static void gui_update_inventory(GuiState* gs, Player* p) { + /* --- gear switches: direct slot swaps --- */ + for (int s = 0; s < NUM_GEAR_SLOTS; s++) { + uint8_t prev = gs->inv_prev_equipped[s]; + uint8_t curr = p->equipped[s]; + if (prev == curr) continue; + + if (curr != ITEM_NONE && prev != ITEM_NONE) { + /* swap: new item was in inventory, old item takes its exact slot */ + int src = gui_inv_find_equipment(gs, curr); + if (src >= 0) { + /* check if old item is still a valid swap item */ + int in_loadout = 0; + for (int g = 0; g < NUM_GEAR_SLOTS; g++) { + for (int i = 0; i < p->num_items_in_slot[g]; i++) { + if (p->inventory[g][i] == prev) { in_loadout = 1; break; } + } + if (in_loadout) break; + } + if (in_loadout) { + /* direct swap: old item goes into the slot the new item came from */ + gs->inv_grid[src].type = INV_SLOT_EQUIPMENT; + gs->inv_grid[src].item_db_idx = prev; + gs->inv_grid[src].osrs_id = ITEM_DATABASE[prev].item_id; + } else { + /* old item not in loadout — just clear the slot */ + gs->inv_grid[src].type = INV_SLOT_EMPTY; + gs->inv_grid[src].item_db_idx = 0; + gs->inv_grid[src].osrs_id = 0; + } + } + } else if (curr != ITEM_NONE) { + /* equipping from inventory, nothing was in this gear slot before */ + int src = gui_inv_find_equipment(gs, curr); + if (src >= 0) { + gs->inv_grid[src].type = INV_SLOT_EMPTY; + gs->inv_grid[src].item_db_idx = 0; + gs->inv_grid[src].osrs_id = 0; + } + } else if (prev != ITEM_NONE) { + /* gear slot cleared (e.g. shield removed by 2H weapon equip). + the old item goes to the first empty inventory slot. */ + int in_loadout = 0; + for (int g = 0; g < NUM_GEAR_SLOTS; g++) { + for (int i = 0; i < p->num_items_in_slot[g]; i++) { + if (p->inventory[g][i] == prev) { in_loadout = 1; break; } + } + if (in_loadout) break; + } + if (in_loadout && gui_inv_find_equipment(gs, prev) < 0) { + gui_inv_place_equipment(gs, prev); + } + } + } + + /* --- consumable changes: remove clicked slot or fall back to last --- */ + + /* if a human clicked a specific consumable slot, remove that exact slot first */ + int clicked = gs->human_clicked_inv_slot; + int clicked_used = 0; + + /* food */ + int food_diff = gs->inv_prev_food_count - p->food_count; + for (int i = 0; i < food_diff; i++) { + if (!clicked_used && clicked >= 0 && clicked < INV_GRID_SLOTS && + gs->inv_grid[clicked].type == INV_SLOT_FOOD) { + gs->inv_grid[clicked].type = INV_SLOT_EMPTY; + gs->inv_grid[clicked].item_db_idx = 0; + gs->inv_grid[clicked].osrs_id = 0; + clicked_used = 1; + } else { + gui_inv_remove_last_consumable(gs, INV_SLOT_FOOD); + } + } + + /* karambwan */ + int karam_diff = gs->inv_prev_karambwan_count - p->karambwan_count; + for (int i = 0; i < karam_diff; i++) { + if (!clicked_used && clicked >= 0 && clicked < INV_GRID_SLOTS && + gs->inv_grid[clicked].type == INV_SLOT_KARAMBWAN) { + gs->inv_grid[clicked].type = INV_SLOT_EMPTY; + gs->inv_grid[clicked].item_db_idx = 0; + gs->inv_grid[clicked].osrs_id = 0; + clicked_used = 1; + } else { + gui_inv_remove_last_consumable(gs, INV_SLOT_KARAMBWAN); + } + } + + /* potions: dose changes update existing vials in-place (sprite change), + and remove empty vials when a full vial is consumed. + human_clicked_inv_slot is still set here so the clicked vial loses the dose. */ + if (p->brew_doses != gs->inv_prev_brew_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_BREW, p->brew_doses); + } + if (p->restore_doses != gs->inv_prev_restore_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_RESTORE, p->restore_doses); + } + if (p->combat_potion_doses != gs->inv_prev_combat_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_COMBAT_POT, p->combat_potion_doses); + } + if (p->ranged_potion_doses != gs->inv_prev_ranged_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_RANGED_POT, p->ranged_potion_doses); + } + if (p->antivenom_doses != gs->inv_prev_antivenom_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_ANTIVENOM, p->antivenom_doses); + } + if (p->prayer_pot_doses != gs->inv_prev_prayer_pot_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_PRAYER_POT, p->prayer_pot_doses); + } + + /* only clear human click when a consumable was actually used this frame. + if no diff happened yet, keep it for the next tick when the sim processes the action. */ + int any_consumable_changed = clicked_used + || (p->brew_doses != gs->inv_prev_brew_doses) + || (p->restore_doses != gs->inv_prev_restore_doses) + || (p->prayer_pot_doses != gs->inv_prev_prayer_pot_doses) + || (p->combat_potion_doses != gs->inv_prev_combat_doses) + || (p->ranged_potion_doses != gs->inv_prev_ranged_doses) + || (p->antivenom_doses != gs->inv_prev_antivenom_doses); + if (any_consumable_changed) { + gs->human_clicked_inv_slot = -1; + } + + /* update snapshot */ + memcpy(gs->inv_prev_equipped, p->equipped, NUM_GEAR_SLOTS); + gs->inv_prev_food_count = p->food_count; + gs->inv_prev_karambwan_count = p->karambwan_count; + gs->inv_prev_brew_doses = p->brew_doses; + gs->inv_prev_restore_doses = p->restore_doses; + gs->inv_prev_prayer_pot_doses = p->prayer_pot_doses; + gs->inv_prev_combat_doses = p->combat_potion_doses; + gs->inv_prev_ranged_doses = p->ranged_potion_doses; + gs->inv_prev_antivenom_doses = p->antivenom_doses; +} + +/** Get the inventory grid screen position for a slot index. */ +static void gui_inv_slot_pos(GuiState* gs, int slot, int* out_x, int* out_y) { + int grid_w = INV_COLS * INV_CELL_W; + int grid_x = gs->panel_x + (gs->panel_w - grid_w) / 2; + int grid_y = gui_content_y(gs) + 4; + int col = slot % INV_COLS; + int row = slot / INV_COLS; + *out_x = grid_x + col * INV_CELL_W; + *out_y = grid_y + row * INV_CELL_H; +} + +/** Hit test: return inventory slot index at screen position, or -1. */ +static int gui_inv_slot_at(GuiState* gs, int mx, int my) { + for (int i = 0; i < INV_GRID_SLOTS; i++) { + int sx, sy; + gui_inv_slot_pos(gs, i, &sx, &sy); + if (mx >= sx && mx < sx + INV_CELL_W && my >= sy && my < sy + INV_CELL_H) { + return i; + } + } + return -1; +} + +/** Handle inventory click: equip gear items, eat/drink consumables. + hi is a HumanInput* (from osrs_pvp_human_input_types.h, included above). + When non-NULL and enabled, food/potion clicks set pending_* fields instead of + directly mutating player state, so the action system handles timers. */ + +static InvAction gui_inv_click(GuiState* gs, Player* p, int slot, + HumanInput* hi) { + if (slot < 0 || slot >= INV_GRID_SLOTS) return INV_ACTION_NONE; + InvSlot* inv = &gs->inv_grid[slot]; + if (inv->type == INV_SLOT_EMPTY) return INV_ACTION_NONE; + + /* start dim animation */ + gs->inv_dim_slot = slot; + gs->inv_dim_timer = INV_DIM_TICKS; + + /* when human control is active, route food/potion through action system + instead of directly mutating player state (respects timers) */ + int human_active = (hi && hi->enabled); + + switch (inv->type) { + case INV_SLOT_EQUIPMENT: { + /* equipment clicks always directly equip (more faithful than RL loadout presets) */ + int gear_slot = item_to_gear_slot(inv->item_db_idx); + if (gear_slot >= 0) { + slot_equip_item(p, gear_slot, inv->item_db_idx); + } + return INV_ACTION_EQUIP; + } + case INV_SLOT_FOOD: + if (human_active) { hi->pending_food = 1; gs->human_clicked_inv_slot = slot; } + else { eat_food(p, 0); } + return INV_ACTION_EAT; + case INV_SLOT_KARAMBWAN: + if (human_active) { hi->pending_karambwan = 1; gs->human_clicked_inv_slot = slot; } + else { eat_food(p, 1); } + return INV_ACTION_EAT; + case INV_SLOT_BREW: + if (human_active) { hi->pending_potion = POTION_BREW; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_RESTORE: + if (human_active) { hi->pending_potion = POTION_RESTORE; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_COMBAT_POT: + if (human_active) { hi->pending_potion = POTION_COMBAT; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_RANGED_POT: + if (human_active) { hi->pending_potion = POTION_RANGED; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_ANTIVENOM: + if (human_active) { hi->pending_potion = POTION_ANTIVENOM; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + case INV_SLOT_PRAYER_POT: + if (human_active) { hi->pending_potion = POTION_RESTORE; gs->human_clicked_inv_slot = slot; } + return INV_ACTION_DRINK; + default: + return INV_ACTION_NONE; + } +} + +/** Handle inventory mouse input: clicks, drag start/move/release. + When hi is non-NULL and enabled, food/potion clicks route through the + action system instead of directly mutating player state. */ +static void gui_inv_handle_mouse(GuiState* gs, Player* p, HumanInput* hi) { + if (gs->active_tab != GUI_TAB_INVENTORY) return; + + int mx = GetMouseX(); + int my = GetMouseY(); + + /* drag in progress */ + if (gs->inv_drag_active) { + gs->inv_drag_mouse_x = mx; + gs->inv_drag_mouse_y = my; + + if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT)) { + /* drop: swap src and target slots */ + int target = gui_inv_slot_at(gs, mx, my); + if (target >= 0 && target != gs->inv_drag_src_slot) { + InvSlot tmp = gs->inv_grid[target]; + gs->inv_grid[target] = gs->inv_grid[gs->inv_drag_src_slot]; + gs->inv_grid[gs->inv_drag_src_slot] = tmp; + } + gs->inv_drag_active = 0; + gs->inv_drag_src_slot = -1; + } + return; + } + + /* new click */ + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + int slot = gui_inv_slot_at(gs, mx, my); + if (slot >= 0 && gs->inv_grid[slot].type != INV_SLOT_EMPTY) { + gs->inv_drag_start_x = mx; + gs->inv_drag_start_y = my; + gs->inv_drag_src_slot = slot; + } + } + + /* check if held mouse has moved past dead zone → start drag */ + if (IsMouseButtonDown(MOUSE_BUTTON_LEFT) && gs->inv_drag_src_slot >= 0 && !gs->inv_drag_active) { + int dx = mx - gs->inv_drag_start_x; + int dy = my - gs->inv_drag_start_y; + if (dx > INV_DRAG_DEAD_ZONE || dx < -INV_DRAG_DEAD_ZONE || + dy > INV_DRAG_DEAD_ZONE || dy < -INV_DRAG_DEAD_ZONE) { + gs->inv_drag_active = 1; + gs->inv_drag_mouse_x = mx; + gs->inv_drag_mouse_y = my; + /* dim the source slot during drag */ + gs->inv_dim_slot = gs->inv_drag_src_slot; + gs->inv_dim_timer = 9999; /* stays dim during entire drag */ + } + } + + /* click release without drag = activate item */ + if (IsMouseButtonReleased(MOUSE_BUTTON_LEFT) && gs->inv_drag_src_slot >= 0 && !gs->inv_drag_active) { + gui_inv_click(gs, p, gs->inv_drag_src_slot, hi); + gs->inv_drag_src_slot = -1; + } +} + +/** Tick the inventory dim timer (call at 50 Hz). */ +static void gui_inv_tick(GuiState* gs) { + if (gs->inv_dim_timer > 0 && !gs->inv_drag_active) { + gs->inv_dim_timer--; + if (gs->inv_dim_timer <= 0) { + gs->inv_dim_slot = -1; + } + } +} + +static void gui_draw_inventory(GuiState* gs, Player* p) { + /* full rebuild on first frame or reset, incremental updates after */ + if (gs->inv_grid_dirty) { + gui_populate_inventory(gs, p); + gs->inv_grid_dirty = 0; + } else { + gui_update_inventory(gs, p); + } + + /* draw 4x7 slot backgrounds (subtle dark rectangles matching OSRS inventory) */ + for (int slot = 0; slot < INV_GRID_SLOTS; slot++) { + int cx, cy; + gui_inv_slot_pos(gs, slot, &cx, &cy); + /* OSRS inventory slots have a very subtle dark border/tint. + draw a 36x32 centered slot background to delineate cells. */ + int sx = cx + (INV_CELL_W - INV_SPRITE_W) / 2; + int sy = cy + (INV_CELL_H - INV_SPRITE_H) / 2; + DrawRectangle(sx, sy, INV_SPRITE_W, INV_SPRITE_H, + CLITERAL(Color){ 0, 0, 0, 30 }); + } + + /* draw items (sprites are 36x32 native, scaled to INV_SPRITE_W x INV_SPRITE_H) */ + for (int slot = 0; slot < INV_GRID_SLOTS; slot++) { + int cx, cy; + gui_inv_slot_pos(gs, slot, &cx, &cy); + InvSlot* inv = &gs->inv_grid[slot]; + + if (inv->type == INV_SLOT_EMPTY) continue; + + /* determine sprite */ + Texture2D tex = { 0 }; + if (inv->type == INV_SLOT_EQUIPMENT) { + tex = gui_get_item_sprite(gs, inv->item_db_idx); + } else { + tex = gui_get_sprite_by_osrs_id(gs, inv->osrs_id); + } + + /* dim tint: 50% alpha when clicked/dragged (matches OSRS var17=128) */ + int is_dimmed = (gs->inv_dim_slot == slot && gs->inv_dim_timer > 0); + Color tint = is_dimmed ? CLITERAL(Color){ 255, 255, 255, 128 } : WHITE; + + int dx = cx + (INV_CELL_W - INV_SPRITE_W) / 2; + int dy = cy + (INV_CELL_H - INV_SPRITE_H) / 2; + + /* skip drawing at grid position if being dragged (drawn at cursor instead) */ + if (gs->inv_drag_active && slot == gs->inv_drag_src_slot) { + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)dx, (float)dy, (float)INV_SPRITE_W, (float)INV_SPRITE_H }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, + CLITERAL(Color){ 255, 255, 255, 80 }); + } + continue; + } + + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)dx, (float)dy, (float)INV_SPRITE_W, (float)INV_SPRITE_H }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, tint); + } else { + const char* name = (inv->type == INV_SLOT_EQUIPMENT) + ? gui_item_short_name(inv->item_db_idx) : "???"; + gui_text_shadow(name, cx + 2, cy + 12, 7, GUI_TEXT_YELLOW); + } + } + + /* draw dragged item at cursor position */ + if (gs->inv_drag_active && gs->inv_drag_src_slot >= 0) { + InvSlot* drag = &gs->inv_grid[gs->inv_drag_src_slot]; + Texture2D tex = { 0 }; + if (drag->type == INV_SLOT_EQUIPMENT) { + tex = gui_get_item_sprite(gs, drag->item_db_idx); + } else { + tex = gui_get_sprite_by_osrs_id(gs, drag->osrs_id); + } + if (tex.id != 0) { + int dx = gs->inv_drag_mouse_x - INV_SPRITE_W / 2; + int dy = gs->inv_drag_mouse_y - INV_SPRITE_H / 2; + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)dx, (float)dy, (float)INV_SPRITE_W, (float)INV_SPRITE_H }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, + CLITERAL(Color){ 255, 255, 255, 200 }); + } + + /* highlight target slot under cursor */ + int target = gui_inv_slot_at(gs, gs->inv_drag_mouse_x, gs->inv_drag_mouse_y); + if (target >= 0 && target != gs->inv_drag_src_slot) { + int tx, ty; + gui_inv_slot_pos(gs, target, &tx, &ty); + DrawRectangle(tx, ty, INV_CELL_W, INV_CELL_H, + CLITERAL(Color){ 255, 255, 255, 40 }); + } + } +} + +/* ======================================================================== */ +/* equipment panel (interface 387: paperdoll layout only) */ +/* ======================================================================== */ + +static void gui_draw_equipment(GuiState* gs, Player* p) { + int oy = gui_content_y(gs) + 8; + + gui_text_shadow("Worn Equipment", gs->panel_x + 8, oy, 12, GUI_TEXT_ORANGE); + oy += 22; + + /* OSRS paperdoll: 5 rows, 3-column layout centered in panel. + slot sizes scaled to fill panel width (320px - 16px padding = 304px). */ + int gap = 6; + int sw = (gs->panel_w - 16 - gap * 2) / 3; /* ~97px per slot */ + int sh = (int)(sw * 0.75f); /* maintain ~4:3 aspect ratio (~73px) */ + int cx = gs->panel_x + gs->panel_w / 2; + int r3_w = sw * 3 + gap * 2; + int r3_x = cx - r3_w / 2; + + /* row 0: head (centered) */ + gui_draw_equip_slot(gs, cx - sw / 2, oy, sw, sh, GEAR_SLOT_HEAD, p->equipped[GEAR_SLOT_HEAD]); + oy += sh + gap; + + /* row 1: cape, neck, ammo */ + gui_draw_equip_slot(gs, r3_x, oy, sw, sh, GEAR_SLOT_CAPE, p->equipped[GEAR_SLOT_CAPE]); + gui_draw_equip_slot(gs, r3_x + sw + gap, oy, sw, sh, GEAR_SLOT_NECK, p->equipped[GEAR_SLOT_NECK]); + gui_draw_equip_slot(gs, r3_x + 2 * (sw + gap), oy, sw, sh, GEAR_SLOT_AMMO, p->equipped[GEAR_SLOT_AMMO]); + oy += sh + gap; + + /* row 2: weapon, body, shield */ + gui_draw_equip_slot(gs, r3_x, oy, sw, sh, GEAR_SLOT_WEAPON, p->equipped[GEAR_SLOT_WEAPON]); + gui_draw_equip_slot(gs, r3_x + sw + gap, oy, sw, sh, GEAR_SLOT_BODY, p->equipped[GEAR_SLOT_BODY]); + gui_draw_equip_slot(gs, r3_x + 2 * (sw + gap), oy, sw, sh, GEAR_SLOT_SHIELD, p->equipped[GEAR_SLOT_SHIELD]); + oy += sh + gap; + + /* row 3: legs (centered) */ + gui_draw_equip_slot(gs, cx - sw / 2, oy, sw, sh, GEAR_SLOT_LEGS, p->equipped[GEAR_SLOT_LEGS]); + oy += sh + gap; + + /* row 4: hands, feet, ring */ + gui_draw_equip_slot(gs, r3_x, oy, sw, sh, GEAR_SLOT_HANDS, p->equipped[GEAR_SLOT_HANDS]); + gui_draw_equip_slot(gs, r3_x + sw + gap, oy, sw, sh, GEAR_SLOT_FEET, p->equipped[GEAR_SLOT_FEET]); + gui_draw_equip_slot(gs, r3_x + 2 * (sw + gap), oy, sw, sh, GEAR_SLOT_RING, p->equipped[GEAR_SLOT_RING]); +} + +/* ======================================================================== */ +/* prayer panel (interface 541) — single 5-column grid, all 25 prayers */ +/* ======================================================================== */ + +/* OSRS prayer book order: 5 columns, 5 rows = 25 prayers. + each entry maps a grid position to a GuiPrayerIdx. */ +static const GuiPrayerIdx GUI_PRAYER_GRID[25] = { + GUI_PRAY_THICK_SKIN, GUI_PRAY_BURST_STR, GUI_PRAY_CLARITY, + GUI_PRAY_SHARP_EYE, GUI_PRAY_MYSTIC_WILL, + GUI_PRAY_ROCK_SKIN, GUI_PRAY_SUPERHUMAN, GUI_PRAY_IMPROVED_REFLEX, + GUI_PRAY_RAPID_RESTORE, GUI_PRAY_RAPID_HEAL, + GUI_PRAY_PROTECT_ITEM, GUI_PRAY_HAWK_EYE, GUI_PRAY_PROTECT_MAGIC, + GUI_PRAY_PROTECT_MISSILES, GUI_PRAY_PROTECT_MELEE, + GUI_PRAY_REDEMPTION, GUI_PRAY_RETRIBUTION, GUI_PRAY_SMITE, + GUI_PRAY_CHIVALRY, GUI_PRAY_PIETY, + GUI_PRAY_EAGLE_EYE, GUI_PRAY_MYSTIC_MIGHT, GUI_PRAY_PRESERVE, + GUI_PRAY_RIGOUR, GUI_PRAY_AUGURY, +}; +#define GUI_PRAYER_GRID_COUNT 25 + +/** Check if a prayer grid slot is currently active based on player state. */ +static int gui_prayer_is_active(GuiPrayerIdx pidx, Player* p) { + switch (pidx) { + case GUI_PRAY_PROTECT_MAGIC: return p->prayer == PRAYER_PROTECT_MAGIC; + case GUI_PRAY_PROTECT_MISSILES: return p->prayer == PRAYER_PROTECT_RANGED; + case GUI_PRAY_PROTECT_MELEE: return p->prayer == PRAYER_PROTECT_MELEE; + case GUI_PRAY_REDEMPTION: return p->prayer == PRAYER_REDEMPTION; + case GUI_PRAY_SMITE: return p->prayer == PRAYER_SMITE; + case GUI_PRAY_PIETY: return p->offensive_prayer == OFFENSIVE_PRAYER_PIETY; + case GUI_PRAY_RIGOUR: return p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR; + case GUI_PRAY_AUGURY: return p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY; + default: return 0; + } +} + +static void gui_draw_prayer(GuiState* gs, Player* p) { + int oy = gui_content_y(gs) + 4; + + /* prayer points bar at top */ + int bar_x = gs->panel_x + 8; + int bar_w = gs->panel_w - 16; + int bar_h = 18; + float pray_pct = (p->base_prayer > 0) ? + (float)p->current_prayer / (float)p->base_prayer : 0.0f; + DrawRectangle(bar_x, oy, bar_w, bar_h, GUI_SPEC_DARK); + DrawRectangle(bar_x, oy, (int)(bar_w * pray_pct), bar_h, GUI_TEXT_CYAN); + DrawRectangleLines(bar_x, oy, bar_w, bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("%d / %d", p->current_prayer, p->base_prayer), + bar_x + bar_w / 2 - 20, oy + 3, 10, GUI_TEXT_WHITE); + oy += bar_h + 6; + + /* 5-column grid of all 25 prayers. + OSRS native: 37x37 pitch, scaled to fill 320px panel (~60px cells). */ + int cols = 5; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; /* ~60px */ + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + for (int i = 0; i < GUI_PRAYER_GRID_COUNT; i++) { + int col = i % cols; + int row = i / cols; + int ix = gx + col * (icon_sz + gap); + int iy = oy + row * (icon_sz + gap); + + GuiPrayerIdx pidx = GUI_PRAYER_GRID[i]; + int active = gui_prayer_is_active(pidx, p); + + /* draw slot_tile background */ + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + /* active prayer: yellow highlight overlay */ + if (active) { + DrawRectangle(ix, iy, icon_sz, icon_sz, GUI_PRAYER_ON); + } + + /* draw prayer sprite (scaled to cell) */ + if (gs->sprites_loaded) { + Texture2D tex = active ? gs->prayer_on[pidx] : gs->prayer_off[pidx]; + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + } + } +} + +/* ======================================================================== */ +/* combat panel (interface 593) — weapon + 4 style buttons + spec bar */ +/* ======================================================================== */ + +static void gui_draw_combat(GuiState* gs, Player* p) { + int ox = gs->panel_x + 8; + int oy = gui_content_y(gs) + 8; + + /* weapon name + sprite (scaled to match panel) */ + const char* wpn_name = "Unarmed"; + if (p->equipped[GEAR_SLOT_WEAPON] != ITEM_NONE && + p->equipped[GEAR_SLOT_WEAPON] < NUM_ITEMS) { + wpn_name = gui_item_short_name(p->equipped[GEAR_SLOT_WEAPON]); + } + + Texture2D wpn_tex = gui_get_item_sprite(gs, p->equipped[GEAR_SLOT_WEAPON]); + if (wpn_tex.id != 0) { + Rectangle src = { 0, 0, (float)wpn_tex.width, (float)wpn_tex.height }; + Rectangle dst = { (float)ox, (float)oy, 60.0f, 54.0f }; + DrawTexturePro(wpn_tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + gui_text_shadow(wpn_name, ox + 66, oy + 16, 14, GUI_TEXT_ORANGE); + oy += 60; + } else { + gui_text_shadow(wpn_name, ox, oy, 14, GUI_TEXT_ORANGE); + oy += 22; + } + + /* 4 attack style buttons (2x2 grid) scaled to fill panel width */ + static const char* style_names[] = { "Accurate", "Aggressive", "Controlled", "Defensive" }; + int btn_gap = 6; + int btn_w = (gs->panel_w - 16 - btn_gap) / 2; /* ~151px */ + int btn_h = 60; + + for (int i = 0; i < 4; i++) { + int col = i % 2; + int row = i / 2; + int bx = ox + col * (btn_w + btn_gap); + int by = oy + row * (btn_h + btn_gap); + + int active = ((int)p->fight_style == i); + + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)bx, (float)by, (float)btn_w, (float)btn_h }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + if (active) { + DrawRectangle(bx, by, btn_w, btn_h, GUI_PRAYER_ON); + } + DrawRectangleLines(bx, by, btn_w, btn_h, GUI_BORDER); + + Color txt_c = active ? GUI_TEXT_YELLOW : GUI_TEXT_WHITE; + int txt_w = MeasureText(style_names[i], 11); + gui_text_shadow(style_names[i], bx + btn_w / 2 - txt_w / 2, by + btn_h / 2 - 5, 11, txt_c); + } + oy += 2 * (btn_h + btn_gap) + 10; + + /* special attack bar — clickable. yellow border when spec is queued (OSRS-style). */ + Color spec_label_color = p->special_active ? GUI_TEXT_YELLOW : GUI_TEXT_WHITE; + gui_text_shadow("Special Attack", ox, oy, 11, spec_label_color); + oy += 16; + + int spec_w = gs->panel_w - 16; + int spec_h = 26; + float spec_pct = (float)p->special_energy / 100.0f; + + /* background: draw spec bar sprite if available, else dark rect */ + if (gs->spec_bar_loaded && gs->spec_bar.id != 0) { + /* stretch spec bar sprite to fill */ + Rectangle src = { 0, 0, (float)gs->spec_bar.width, (float)gs->spec_bar.height }; + Rectangle dst = { (float)ox, (float)oy, (float)spec_w, (float)spec_h }; + DrawTexturePro(gs->spec_bar, src, dst, (Vector2){0, 0}, 0.0f, CLITERAL(Color){80, 80, 80, 255}); + /* green fill overlay */ + DrawRectangle(ox, oy, (int)(spec_w * spec_pct), spec_h, + CLITERAL(Color){ 0, 180, 0, 160 }); + } else { + DrawRectangle(ox, oy, spec_w, spec_h, GUI_SPEC_DARK); + DrawRectangle(ox, oy, (int)(spec_w * spec_pct), spec_h, GUI_SPEC_GREEN); + } + /* active highlight: bright yellow-green border when spec is queued */ + if (p->special_active) { + DrawRectangle(ox, oy, spec_w, spec_h, CLITERAL(Color){ 200, 200, 50, 60 }); + DrawRectangleLines(ox, oy, spec_w, spec_h, GUI_TEXT_YELLOW); + } else { + DrawRectangleLines(ox, oy, spec_w, spec_h, GUI_BORDER); + } + gui_text_shadow(TextFormat("%d%%", p->special_energy), + ox + spec_w / 2 - 10, oy + 4, 10, GUI_TEXT_WHITE); +} + +/* ======================================================================== */ +/* spellbook panel (interface 218: ancient magicks + vengeance) */ +/* ======================================================================== */ + +typedef struct { + const char* name; + GuiSpellIdx idx; +} GuiSpellEntry; + +static const GuiSpellEntry GUI_SPELL_GRID[] = { + { "Ice Rush", GUI_SPELL_ICE_RUSH }, + { "Ice Burst", GUI_SPELL_ICE_BURST }, + { "Ice Blitz", GUI_SPELL_ICE_BLITZ }, + { "Ice Barrage", GUI_SPELL_ICE_BARRAGE }, + { "Blood Rush", GUI_SPELL_BLOOD_RUSH }, + { "Blood Burst", GUI_SPELL_BLOOD_BURST }, + { "Blood Blitz", GUI_SPELL_BLOOD_BLITZ }, + { "Blood Barrage", GUI_SPELL_BLOOD_BARRAGE }, + { "Vengeance", GUI_SPELL_VENGEANCE }, +}; +#define GUI_SPELL_GRID_COUNT 9 + +static void gui_draw_spellbook(GuiState* gs, Player* p) { + int oy = gui_content_y(gs) + 8; + + /* 4-column grid of all spells, scaled to fill panel width. + OSRS ancient spellbook native is ~26x26 icons. we scale up to match + the inventory cell size for visual consistency. */ + int cols = 4; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; /* ~76px */ + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + for (int i = 0; i < GUI_SPELL_GRID_COUNT; i++) { + int col = i % cols; + int row = i / cols; + int ix = gx + col * (icon_sz + gap); + int iy = oy + row * (icon_sz + gap); + + /* active highlight for vengeance */ + int active = (i == 8 && p->veng_active); + + /* slot_tile background */ + if (gs->slot_tile.id != 0) { + Rectangle src = { 0, 0, (float)gs->slot_tile.width, (float)gs->slot_tile.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(gs->slot_tile, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + if (active) { + DrawRectangle(ix, iy, icon_sz, icon_sz, GUI_PRAYER_ON); + } + + /* draw spell sprite (scaled to cell) */ + GuiSpellIdx sidx = GUI_SPELL_GRID[i].idx; + if (gs->sprites_loaded) { + Texture2D tex = gs->spell_on[sidx]; + if (tex.id != 0) { + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)ix, (float)iy, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + } + } + oy += ((GUI_SPELL_GRID_COUNT + cols - 1) / cols) * (icon_sz + gap) + 12; + + /* veng cooldown below the grid */ + int ox = gs->panel_x + 8; + gui_text_shadow(TextFormat("Veng cooldown: %d", p->veng_cooldown), + ox, oy, 10, p->veng_cooldown > 0 ? GUI_TEXT_RED : GUI_TEXT_GREEN); + (void)p; +} + +/* ======================================================================== */ +/* stats panel — OSRS-authentic skills tab layout */ +/* */ +/* matches the real OSRS fixed-mode skills interface: 3-column grid, */ +/* skill icon on left, current level center, base level right. */ +/* only combat skills are shown; non-combat rows are empty. */ +/* below the skill grid: combat info (max hit, gear, prayer, consumables). */ +/* */ +/* OSRS skill grid order (3 columns, 8 rows): */ +/* col 0: Attack, Strength, Defence, Ranged, Prayer, Magic, RC, Constr */ +/* col 1: Hitpoints, Agility, Herblore, Thieving, Crafting, Fletch, ... */ +/* col 2: Mining, Smithing, Fishing, Cooking, Firemaking, WC, ... */ +/* we show rows 0-5 (the 7 combat skills) and leave col 2 empty. */ +/* ======================================================================== */ + +/* skill icon indices (matches skill_icon_files load order) */ +#define SKILL_ICON_ATTACK 0 +#define SKILL_ICON_STRENGTH 1 +#define SKILL_ICON_DEFENCE 2 +#define SKILL_ICON_RANGED 3 +#define SKILL_ICON_PRAYER 4 +#define SKILL_ICON_MAGIC 5 +#define SKILL_ICON_HITPOINTS 6 + +/* draw one skill cell: icon + current level (left) + base level (right). + OSRS style: yellow if current == base, green if boosted, red if drained. + cell dimensions match the real client scaled to our panel width. */ +static void gui_draw_skill_cell(GuiState* gs, int cx, int cy, int cw, int ch, + int icon_idx, int current, int base) { + /* dark cell background with border (matches OSRS skill cell) */ + DrawRectangle(cx, cy, cw, ch, (Color){30, 27, 20, 255}); + DrawRectangleLines(cx, cy, cw, ch, (Color){60, 54, 42, 255}); + + /* skill icon (scaled to fit cell height with padding) */ + int icon_sz = ch - 6; + int icon_x = cx + 3; + int icon_y = cy + 3; + if (gs->skill_icons_loaded && icon_idx >= 0 && icon_idx < 7 && + gs->skill_icons[icon_idx].id != 0) { + Texture2D tex = gs->skill_icons[icon_idx]; + Rectangle src = { 0, 0, (float)tex.width, (float)tex.height }; + Rectangle dst = { (float)icon_x, (float)icon_y, (float)icon_sz, (float)icon_sz }; + DrawTexturePro(tex, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } + + /* level color: yellow=normal, green=boosted, red=drained */ + Color lvl_color = GUI_TEXT_YELLOW; + if (current > base) lvl_color = GUI_TEXT_GREEN; + else if (current < base) lvl_color = (Color){255, 60, 60, 255}; + + /* current level (left side, after icon) */ + int text_y = cy + ch / 2 - 5; + gui_text_shadow(TextFormat("%d", current), icon_x + icon_sz + 4, text_y, 10, lvl_color); + + /* base level (right-aligned) */ + const char* base_str = TextFormat("%d", base); + int bw = MeasureText(base_str, 10); + gui_text_shadow(base_str, cx + cw - bw - 4, text_y, 10, lvl_color); +} + +static const char* gui_gear_name(GearSet g) { + switch (g) { + case GEAR_MAGE: return "Mage"; + case GEAR_RANGED: return "Ranged"; + case GEAR_MELEE: return "Melee"; + case GEAR_SPEC: return "Spec"; + case GEAR_TANK: return "Tank"; + default: return "???"; + } +} + +static const char* gui_prayer_name(OverheadPrayer pr) { + switch (pr) { + case PRAYER_PROTECT_MAGIC: return "Protect Magic"; + case PRAYER_PROTECT_RANGED: return "Protect Ranged"; + case PRAYER_PROTECT_MELEE: return "Protect Melee"; + case PRAYER_SMITE: return "Smite"; + case PRAYER_REDEMPTION: return "Redemption"; + default: return "None"; + } +} + +static void gui_draw_stats(GuiState* gs, Player* p) { + int ox = gs->panel_x + 4; + int oy = gui_content_y(gs) + 4; + + /* OSRS skill grid: 3 columns, 6 visible rows for combat skills. + cell dimensions scale to panel width. OSRS original: 62x32 in 190px panel. */ + int gap = 2; + int cols = 3; + int cw = (gs->panel_w - 8 - gap * (cols - 1)) / cols; + int ch = 32; + + /* OSRS grid: row x col → (icon_idx, current, base) or -1 for empty. + col 0: attack, strength, defence, ranged, prayer, magic + col 1: hitpoints, then empty + col 2: empty (non-combat) */ + int grid_icon[6][3]; + int grid_cur[6][3]; + int grid_base[6][3]; + memset(grid_icon, -1, sizeof(grid_icon)); + memset(grid_cur, 0, sizeof(grid_cur)); + memset(grid_base, 0, sizeof(grid_base)); + + /* col 0: combat stats in OSRS order */ + grid_icon[0][0] = SKILL_ICON_ATTACK; grid_cur[0][0] = p->current_attack; grid_base[0][0] = p->base_attack; + grid_icon[1][0] = SKILL_ICON_STRENGTH; grid_cur[1][0] = p->current_strength; grid_base[1][0] = p->base_strength; + grid_icon[2][0] = SKILL_ICON_DEFENCE; grid_cur[2][0] = p->current_defence; grid_base[2][0] = p->base_defence; + grid_icon[3][0] = SKILL_ICON_RANGED; grid_cur[3][0] = p->current_ranged; grid_base[3][0] = p->base_ranged; + grid_icon[4][0] = SKILL_ICON_PRAYER; grid_cur[4][0] = p->current_prayer; grid_base[4][0] = p->base_prayer; + grid_icon[5][0] = SKILL_ICON_MAGIC; grid_cur[5][0] = p->current_magic; grid_base[5][0] = p->base_magic; + + /* col 1: hitpoints at row 0, rest stays -1 (empty) */ + grid_icon[0][1] = SKILL_ICON_HITPOINTS; grid_cur[0][1] = p->current_hitpoints; grid_base[0][1] = p->base_hitpoints; + + /* draw the grid */ + for (int r = 0; r < 6; r++) { + for (int c = 0; c < cols; c++) { + int cx = ox + c * (cw + gap); + int cy = oy + r * (ch + gap); + if (grid_icon[r][c] >= 0) { + gui_draw_skill_cell(gs, cx, cy, cw, ch, + grid_icon[r][c], grid_cur[r][c], grid_base[r][c]); + } else { + /* empty cell: just dark bg */ + DrawRectangle(cx, cy, cw, ch, (Color){20, 18, 14, 255}); + DrawRectangleLines(cx, cy, cw, ch, (Color){40, 36, 28, 255}); + } + } + } + oy += 6 * (ch + gap) + 6; + + /* separator */ + int bar_w = gs->panel_w - 8; + DrawLine(ox, oy, ox + bar_w, oy, GUI_BORDER); + oy += 6; + + /* combat info below the skill grid */ + int lh = 17; + gui_text_shadow(TextFormat("Gear: %s", gui_gear_name(p->current_gear)), + ox + 2, oy, 10, GUI_TEXT_ORANGE); + oy += lh; + gui_text_shadow(TextFormat("Max Hit: %d Str Bonus: %d", p->gui_max_hit, p->gui_strength_bonus), + ox + 2, oy, 10, GUI_TEXT_YELLOW); + oy += lh; + gui_text_shadow(TextFormat("Speed: %d Range: %d", p->gui_attack_speed, p->gui_attack_range), + ox + 2, oy, 10, GUI_TEXT_WHITE); + oy += lh; + gui_text_shadow(TextFormat("Prayer: %s", gui_prayer_name(p->prayer)), + ox + 2, oy, 10, GUI_TEXT_CYAN); + oy += lh + 4; + + /* separator */ + DrawLine(ox, oy, ox + bar_w, oy, GUI_BORDER); + oy += 6; + + /* consumables */ + gui_text_shadow(TextFormat("Brews: %d Restores: %d", p->brew_doses, p->restore_doses), + ox + 2, oy, 10, GUI_TEXT_WHITE); + oy += lh; + gui_text_shadow(TextFormat("Bastion: %d Stamina: %d", + p->combat_potion_doses, p->ranged_potion_doses), + ox + 2, oy, 10, GUI_TEXT_WHITE); + oy += lh; + + /* special attack energy bar */ + int spec_bar_w = bar_w; + int spec_bar_h = 14; + float spec_pct = (float)p->special_energy / 100.0f; + DrawRectangle(ox, oy, spec_bar_w, spec_bar_h, GUI_SPEC_DARK); + DrawRectangle(ox, oy, (int)(spec_bar_w * spec_pct), spec_bar_h, GUI_SPEC_GREEN); + DrawRectangleLines(ox, oy, spec_bar_w, spec_bar_h, GUI_BORDER); + gui_text_shadow(TextFormat("Spec: %d%%", p->special_energy), + ox + 4, oy + 1, 10, GUI_TEXT_WHITE); +} + +/* ======================================================================== */ +/* main GUI draw (dispatches to active tab) */ +/* ======================================================================== */ + +static void gui_cycle_entity(GuiState* gs) { + if (gs->gui_entity_count <= 0) return; + gs->gui_entity_idx = (gs->gui_entity_idx + 1) % gs->gui_entity_count; +} + +static void gui_draw(GuiState* gs, Player* p) { + int px = gs->panel_x; + int py = gs->panel_y + gs->status_bar_h + gs->tab_h; /* content area starts after status + tabs */ + int pw = gs->panel_w; + int ph = gs->panel_h - gs->status_bar_h - gs->tab_h; /* remaining height for content */ + + /* draw real OSRS stone panel background, stretched to fill content area (single draw, not tiled) */ + if (gs->chrome_loaded && gs->side_panel_bg.id != 0) { + Rectangle src = { 0, 0, (float)gs->side_panel_bg.width, (float)gs->side_panel_bg.height }; + Rectangle dst = { (float)px, (float)py, (float)pw, (float)ph }; + DrawTexturePro(gs->side_panel_bg, src, dst, (Vector2){0,0}, 0.0f, WHITE); + } else { + DrawRectangle(px, py, pw, ph, GUI_BG_DARK); + } + + /* entity selector header */ + if (gs->gui_entity_count > 1) { + int hx = px + 4; + int hy = py + 2; + const char* etype = (p->entity_type == ENTITY_NPC) ? "NPC" : "Player"; + gui_text_shadow(TextFormat("[G] %s %d/%d", etype, + gs->gui_entity_idx + 1, gs->gui_entity_count), + hx, hy, 8, GUI_TEXT_ORANGE); + } + + /* draw status bar (HP/prayer/spec) above the tab row */ + gui_draw_status_bar(gs, p); + + /* draw tab bar at top (after status bar) */ + gui_draw_tab_bar(gs); + + /* dispatch to active tab content */ + switch (gs->active_tab) { + case GUI_TAB_COMBAT: gui_draw_combat(gs, p); break; + case GUI_TAB_INVENTORY: gui_draw_inventory(gs, p); break; + case GUI_TAB_EQUIPMENT: gui_draw_equipment(gs, p); break; + case GUI_TAB_PRAYER: gui_draw_prayer(gs, p); break; + case GUI_TAB_SPELLBOOK: gui_draw_spellbook(gs, p); break; + case GUI_TAB_STATS: gui_draw_stats(gs, p); break; + case GUI_TAB_QUESTS: /* empty tab */ break; + default: break; + } +} + +#endif /* OSRS_PVP_GUI_H */ diff --git a/ocean/osrs/osrs_pvp_human_input.h b/ocean/osrs/osrs_pvp_human_input.h new file mode 100644 index 0000000000..9f23b3343b --- /dev/null +++ b/ocean/osrs/osrs_pvp_human_input.h @@ -0,0 +1,486 @@ +/** + * @file osrs_pvp_human_input.h + * @brief Interactive human control for the visual debug viewer. + * + * Collects mouse/keyboard input as semantic intents between render frames, + * then translates them to encounter-specific action arrays at tick boundary. + * Toggle human control with H key. Works across PvP and encounter modes. + * + * Architecture: clicks at 60Hz → HumanInput staging buffer → per-encounter + * translator at tick rate → int[] action array fed to step(). + */ + +#ifndef OSRS_PVP_HUMAN_INPUT_H +#define OSRS_PVP_HUMAN_INPUT_H + +#include "osrs_types.h" +#include "osrs_pvp_human_input_types.h" +#include "osrs_encounter.h" + +/* forward declare — full struct lives in osrs_pvp_render.h */ +struct RenderClient; + +/* ======================================================================== */ +/* init / reset */ +/* ======================================================================== */ + +static void human_input_init(HumanInput* hi) { + memset(hi, 0, sizeof(*hi)); + hi->pending_move_x = -1; + hi->pending_move_y = -1; + hi->pending_prayer = -1; + hi->pending_offensive_prayer = -1; + hi->pending_target_idx = -1; + hi->click_cross_active = 0; +} + +/** Clear pending actions after they've been consumed at tick boundary. + Movement is NOT cleared here — it persists until the player reaches the + destination or a new click overrides it. Use human_input_clear_move() + for that. */ +static void human_input_clear_pending(HumanInput* hi) { + /* pending_move_x/y intentionally NOT cleared — movement is persistent */ + hi->pending_attack = 0; + hi->pending_prayer = -1; + hi->pending_offensive_prayer = -1; + hi->pending_food = 0; + hi->pending_karambwan = 0; + hi->pending_potion = 0; + hi->pending_veng = 0; + hi->pending_spec = 0; + hi->pending_spell = 0; + hi->pending_target_idx = -1; + hi->pending_gear = 0; + /* don't clear cursor_mode or selected_spell — those persist until cancelled */ + /* don't clear click_tile — visual feedback fades on its own */ +} + +/** Clear persistent movement destination. Call when player reaches target tile. */ +static void human_input_clear_move(HumanInput* hi) { + hi->pending_move_x = -1; + hi->pending_move_y = -1; +} + +/* ======================================================================== */ +/* screen-to-world conversion */ +/* ======================================================================== */ + +/** Convert screen X to world tile X (inverse of render_world_to_screen_x_rc). + tile_size = RENDER_TILE_SIZE (passed to avoid header ordering issues). */ +static inline int human_screen_to_world_x(int screen_x, int arena_base_x, + int tile_size) { + return screen_x / tile_size + arena_base_x; +} + +/** Convert screen Y to world tile Y (inverse of render_world_to_screen_y_rc). + OSRS Y increases north, screen Y increases down. */ +static inline int human_screen_to_world_y(int screen_y, int arena_base_y, + int arena_height, int header_h, + int tile_size) { + int flipped = (screen_y - header_h) / tile_size; + return arena_base_y + (arena_height - 1) - flipped; +} + +/* ======================================================================== */ +/* click handlers — set semantic intents on HumanInput */ +/* ======================================================================== */ + +/** Check if world tile (wx,wy) is within an NPC's bounding box. + OSRS NPCs occupy npc_size x npc_size tiles anchored at (x,y) as southwest corner. + Players have npc_size 0 or 1, occupying just their tile. */ +static int human_tile_hits_entity(RenderEntity* ent, int wx, int wy) { + int size = ent->npc_size > 1 ? ent->npc_size : 1; + return wx >= ent->x && wx < ent->x + size && + wy >= ent->y && wy < ent->y + size; +} + +/** Set click cross at screen position (2D overlay, like real OSRS client). */ +static void human_set_click_cross(HumanInput* hi, int screen_x, int screen_y, int is_attack) { + hi->click_screen_x = screen_x; + hi->click_screen_y = screen_y; + hi->click_cross_timer = 0; + hi->click_cross_active = 1; + hi->click_is_attack = is_attack; +} + +/** Process a world-tile click: attack entity if hit, otherwise move. + screen_x/y is the raw mouse position for the click cross overlay. */ +static void human_process_tile_click(HumanInput* hi, + int wx, int wy, + int screen_x, int screen_y, + RenderEntity* entities, int entity_count, + int gui_entity_idx) { + /* check if an attackable entity occupies this tile (bounding box) */ + for (int i = 0; i < entity_count; i++) { + if (i == gui_entity_idx) continue; /* can't attack self */ + if (!entities[i].npc_visible && entities[i].entity_type == ENTITY_NPC) continue; + if (human_tile_hits_entity(&entities[i], wx, wy)) { + hi->pending_attack = 1; + hi->pending_target_idx = entities[i].npc_slot; + /* attack cancels movement — server stops walking to old dest + and auto-walks toward target instead (OSRS server behavior) */ + hi->pending_move_x = -1; + hi->pending_move_y = -1; + if (hi->cursor_mode == CURSOR_SPELL_TARGET) { + hi->pending_spell = hi->selected_spell; + hi->cursor_mode = CURSOR_NORMAL; + } + human_set_click_cross(hi, screen_x, screen_y, 1); + return; + } + } + + /* no entity — movement click */ + if (hi->cursor_mode == CURSOR_SPELL_TARGET) { + hi->cursor_mode = CURSOR_NORMAL; + return; + } + + hi->pending_move_x = wx; + hi->pending_move_y = wy; + human_set_click_cross(hi, screen_x, screen_y, 0); +} + +/** Handle ground/entity click in 2D grid mode. + tile_size and header_h are RENDER_TILE_SIZE/RENDER_HEADER_HEIGHT. */ +static void human_handle_ground_click(HumanInput* hi, + int mouse_x, int mouse_y, + int arena_base_x, int arena_base_y, + int arena_width, int arena_height, + RenderEntity* entities, int entity_count, + int gui_entity_idx, + int tile_size, int header_h) { + if (mouse_y < header_h) return; + int grid_pixel_w = arena_width * tile_size; + int grid_pixel_h = arena_height * tile_size; + if (mouse_x < 0 || mouse_x >= grid_pixel_w) return; + if (mouse_y >= header_h + grid_pixel_h) return; + + int wx = human_screen_to_world_x(mouse_x, arena_base_x, tile_size); + int wy = human_screen_to_world_y(mouse_y, arena_base_y, arena_height, + header_h, tile_size); + human_process_tile_click(hi, wx, wy, mouse_x, mouse_y, + entities, entity_count, gui_entity_idx); +} + +/** Handle prayer icon click. Hit-tests the 5-col prayer grid. + Reuses the same layout math as gui_draw_prayer(). */ +static void human_handle_prayer_click(HumanInput* hi, GuiState* gs, Player* p, + int mouse_x, int mouse_y) { + int oy = gui_content_y(gs) + 4; + /* skip prayer points bar */ + int bar_h = 18; + oy += bar_h + 6; + + int cols = 5; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + /* hit-test: which cell was clicked? */ + if (mouse_x < gx || mouse_y < oy) return; + int col = (mouse_x - gx) / (icon_sz + gap); + int row = (mouse_y - oy) / (icon_sz + gap); + if (col < 0 || col >= cols) return; + + int idx = row * cols + col; + if (idx < 0 || idx >= GUI_PRAYER_GRID_COUNT) return; + + /* check click is within the cell bounds (not in the gap) */ + int cell_x = gx + col * (icon_sz + gap); + int cell_y = oy + row * (icon_sz + gap); + if (mouse_x > cell_x + icon_sz || mouse_y > cell_y + icon_sz) return; + + GuiPrayerIdx pidx = GUI_PRAYER_GRID[idx]; + + /* map prayer to action — only actionable prayers */ + switch (pidx) { + case GUI_PRAY_PROTECT_MAGIC: + hi->pending_prayer = (p->prayer == PRAYER_PROTECT_MAGIC) + ? OVERHEAD_NONE : OVERHEAD_MAGE; + break; + case GUI_PRAY_PROTECT_MISSILES: + hi->pending_prayer = (p->prayer == PRAYER_PROTECT_RANGED) + ? OVERHEAD_NONE : OVERHEAD_RANGED; + break; + case GUI_PRAY_PROTECT_MELEE: + hi->pending_prayer = (p->prayer == PRAYER_PROTECT_MELEE) + ? OVERHEAD_NONE : OVERHEAD_MELEE; + break; + case GUI_PRAY_SMITE: + hi->pending_prayer = (p->prayer == PRAYER_SMITE) + ? OVERHEAD_NONE : OVERHEAD_SMITE; + break; + case GUI_PRAY_REDEMPTION: + hi->pending_prayer = (p->prayer == PRAYER_REDEMPTION) + ? OVERHEAD_NONE : OVERHEAD_REDEMPTION; + break; + case GUI_PRAY_PIETY: + hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) + ? 0 : 1; + break; + case GUI_PRAY_RIGOUR: + hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) + ? 0 : 2; + break; + case GUI_PRAY_AUGURY: + hi->pending_offensive_prayer = (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) + ? 0 : 3; + break; + default: + break; /* non-actionable prayer */ + } +} + +/** Handle spell icon click. Hit-tests the 4-col spell grid. + Ice/blood spells enter CURSOR_SPELL_TARGET mode; vengeance is instant. */ +static void human_handle_spell_click(HumanInput* hi, GuiState* gs, + int mouse_x, int mouse_y) { + int oy = gui_content_y(gs) + 8; + int cols = 4; + int gap = 2; + int icon_sz = (gs->panel_w - 16 - gap * (cols - 1)) / cols; + int grid_w = cols * icon_sz + (cols - 1) * gap; + int gx = gs->panel_x + (gs->panel_w - grid_w) / 2; + + if (mouse_x < gx || mouse_y < oy) return; + int col = (mouse_x - gx) / (icon_sz + gap); + int row = (mouse_y - oy) / (icon_sz + gap); + if (col < 0 || col >= cols) return; + + int idx = row * cols + col; + if (idx < 0 || idx >= GUI_SPELL_GRID_COUNT) return; + + int cell_x = gx + col * (icon_sz + gap); + int cell_y = oy + row * (icon_sz + gap); + if (mouse_x > cell_x + icon_sz || mouse_y > cell_y + icon_sz) return; + + GuiSpellIdx sidx = GUI_SPELL_GRID[idx].idx; + + if (sidx == GUI_SPELL_VENGEANCE) { + /* vengeance is instant — no targeting needed */ + hi->pending_veng = 1; + } else if (sidx >= GUI_SPELL_ICE_RUSH && sidx <= GUI_SPELL_ICE_BARRAGE) { + /* ice spell — enter targeting mode */ + hi->cursor_mode = CURSOR_SPELL_TARGET; + hi->selected_spell = ATTACK_ICE; + } else if (sidx >= GUI_SPELL_BLOOD_RUSH && sidx <= GUI_SPELL_BLOOD_BARRAGE) { + /* blood spell — enter targeting mode */ + hi->cursor_mode = CURSOR_SPELL_TARGET; + hi->selected_spell = ATTACK_BLOOD; + } +} + +/** Handle combat panel click (fight style buttons + spec bar). + Fight style is set directly on Player (not in the action space). + Spec bar click sets pending_spec. */ +static void human_handle_combat_click(HumanInput* hi, GuiState* gs, Player* p, + int mouse_x, int mouse_y) { + int ox = gs->panel_x + 8; + int oy = gui_content_y(gs) + 8; + + /* skip weapon name/sprite area */ + Texture2D wpn_tex = gui_get_item_sprite(gs, p->equipped[GEAR_SLOT_WEAPON]); + oy += (wpn_tex.id != 0) ? 60 : 22; + + /* 2x2 fight style buttons */ + int btn_gap = 6; + int btn_w = (gs->panel_w - 16 - btn_gap) / 2; + int btn_h = 60; + + for (int i = 0; i < 4; i++) { + int col = i % 2; + int row = i / 2; + int bx = ox + col * (btn_w + btn_gap); + int by = oy + row * (btn_h + btn_gap); + if (mouse_x >= bx && mouse_x < bx + btn_w && + mouse_y >= by && mouse_y < by + btn_h) { + p->fight_style = (FightStyle)i; + return; + } + } + oy += 2 * (btn_h + btn_gap) + 10; + + /* skip "Special Attack" label */ + oy += 16; + + /* spec bar */ + int spec_w = gs->panel_w - 16; + int spec_h = 26; + if (mouse_x >= ox && mouse_x < ox + spec_w && + mouse_y >= oy && mouse_y < oy + spec_h) { + hi->pending_spec = 1; + } +} + +/* ======================================================================== */ +/* action translators — convert semantic intents to action arrays */ +/* ======================================================================== */ + +/** Translate human input to PvP 7-head action array for agent 0. + Movement is target-relative (ADJACENT/UNDER/DIAGONAL/FARCAST_N). */ +static void human_to_pvp_actions(HumanInput* hi, int* actions, + Player* agent, Player* target) { + /* zero all heads */ + for (int h = 0; h < NUM_ACTION_HEADS; h++) actions[h] = 0; + + /* HEAD_LOADOUT: keep current gear (human equips items via inventory clicks) */ + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + + /* HEAD_COMBAT: attack or movement */ + if (hi->pending_attack) { + if (hi->pending_spell == ATTACK_ICE) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (hi->pending_spell == ATTACK_BLOOD) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (hi->pending_move_x >= 0 && hi->pending_move_y >= 0) { + /* convert absolute tile to target-relative movement */ + int dx = hi->pending_move_x - target->x; + int dy = hi->pending_move_y - target->y; + int dist = (abs(dx) > abs(dy)) ? abs(dx) : abs(dy); /* chebyshev */ + + if (dist == 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (dist == 1) { + /* check if cardinal (adjacent) or diagonal */ + if (dx == 0 || dy == 0) { + actions[HEAD_COMBAT] = MOVE_ADJACENT; + } else { + actions[HEAD_COMBAT] = MOVE_DIAGONAL; + } + } else { + /* farcast: clamp to 2-7 */ + int fc = dist; + if (fc < 2) fc = 2; + if (fc > 7) fc = 7; + actions[HEAD_COMBAT] = MOVE_FARCAST_2 + (fc - 2); + } + } + + /* HEAD_OVERHEAD: prayer */ + if (hi->pending_prayer >= 0) { + actions[HEAD_OVERHEAD] = hi->pending_prayer; + } + + /* HEAD_FOOD */ + if (hi->pending_food) { + actions[HEAD_FOOD] = FOOD_EAT; + } + + /* HEAD_POTION */ + if (hi->pending_potion > 0) { + actions[HEAD_POTION] = hi->pending_potion; + } + + /* HEAD_KARAMBWAN */ + if (hi->pending_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + } + + /* HEAD_VENG */ + if (hi->pending_veng) { + actions[HEAD_VENG] = VENG_CAST; + } + + /* spec: use LOADOUT_SPEC_MELEE/RANGE/MAGIC based on current weapon style */ + if (hi->pending_spec) { + AttackStyle style = get_item_attack_style(agent->equipped[GEAR_SLOT_WEAPON]); + switch (style) { + case ATTACK_STYLE_MELEE: actions[HEAD_LOADOUT] = LOADOUT_SPEC_MELEE; break; + case ATTACK_STYLE_RANGED: actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; break; + case ATTACK_STYLE_MAGIC: actions[HEAD_LOADOUT] = LOADOUT_SPEC_MAGIC; break; + default: break; + } + } + + /* offensive prayer: set via loadout-aware mechanism. + piety/rigour/augury are auto-set by the action system based on loadout, + so we handle this by setting the appropriate loadout if prayer changed. + for human play we just directly mutate the player's offensive prayer. */ + if (hi->pending_offensive_prayer >= 0) { + switch (hi->pending_offensive_prayer) { + case 0: agent->offensive_prayer = OFFENSIVE_PRAYER_NONE; break; + case 1: agent->offensive_prayer = OFFENSIVE_PRAYER_PIETY; break; + case 2: agent->offensive_prayer = OFFENSIVE_PRAYER_RIGOUR; break; + case 3: agent->offensive_prayer = OFFENSIVE_PRAYER_AUGURY; break; + default: break; + } + } +} + +/* shared translate helpers (encounter_translate_movement/prayer/target) + live in osrs_encounter.h so encounter headers can use them directly. */ + +/* ======================================================================== */ +/* visual feedback drawing */ +/* ======================================================================== */ + +/* click cross sprite textures: 4 yellow (move) + 4 red (attack) animation frames. + loaded from data/sprites/gui/cross_*.png, indexed [0..3] yellow, [4..7] red. */ +#define CLICK_CROSS_NUM_FRAMES 4 +#define CLICK_CROSS_ANIM_TICKS 20 /* total animation duration in client ticks (50Hz) */ + +/** Draw click cross at screen-space position using sprite animation. + cross_sprites must point to 8 loaded Texture2D (4 yellow + 4 red). + Falls back to line drawing if sprites aren't loaded. */ +static void human_draw_click_cross(HumanInput* hi, Texture2D* cross_sprites, int sprites_loaded) { + if (!hi->click_cross_active) return; + if (hi->click_cross_timer >= CLICK_CROSS_ANIM_TICKS) { + hi->click_cross_active = 0; + return; + } + + int frame = hi->click_cross_timer * CLICK_CROSS_NUM_FRAMES / CLICK_CROSS_ANIM_TICKS; + if (frame >= CLICK_CROSS_NUM_FRAMES) frame = CLICK_CROSS_NUM_FRAMES - 1; + int sprite_idx = hi->click_is_attack ? frame + CLICK_CROSS_NUM_FRAMES : frame; + + int cx = hi->click_screen_x; + int cy = hi->click_screen_y; + + if (sprites_loaded && cross_sprites[sprite_idx].id > 0) { + Texture2D tex = cross_sprites[sprite_idx]; + /* center sprite on click position (OSRS draws at mouseX-8, mouseY-8 for 16px) */ + DrawTexture(tex, cx - tex.width / 2, cy - tex.height / 2, WHITE); + } else { + /* fallback: simple X lines */ + float progress = 1.0f - (float)hi->click_cross_timer / CLICK_CROSS_ANIM_TICKS; + int alpha = (int)(progress * 255); + Color c = hi->click_is_attack + ? CLITERAL(Color){ 255, 50, 50, (unsigned char)alpha } + : CLITERAL(Color){ 255, 255, 0, (unsigned char)alpha }; + DrawLine(cx - 6, cy - 6, cx + 6, cy + 6, c); + DrawLine(cx + 6, cy - 6, cx - 6, cy + 6, c); + } +} + +/** Draw HUD indicators for human control mode. + Call from the header rendering section. */ +static void human_draw_hud(HumanInput* hi) { + if (!hi->enabled) return; + + /* "HUMAN" indicator in header */ + DrawText("HUMAN", 8, 8, 16, YELLOW); + + /* spell targeting mode indicator */ + if (hi->cursor_mode == CURSOR_SPELL_TARGET) { + const char* spell = (hi->selected_spell == ATTACK_ICE) ? "[ICE]" : + (hi->selected_spell == ATTACK_BLOOD) ? "[BLOOD]" : "[SPELL]"; + DrawText(spell, 80, 8, 14, CLITERAL(Color){100, 200, 255, 255}); + } +} + +/** Tick the click cross animation timer. Call at 50Hz (client tick rate). */ +static void human_tick_visuals(HumanInput* hi) { + if (hi->click_cross_active) { + hi->click_cross_timer++; + if (hi->click_cross_timer >= CLICK_CROSS_ANIM_TICKS) { + hi->click_cross_active = 0; + } + } +} + +#endif /* OSRS_PVP_HUMAN_INPUT_H */ diff --git a/ocean/osrs/osrs_pvp_human_input_types.h b/ocean/osrs/osrs_pvp_human_input_types.h new file mode 100644 index 0000000000..30b82a45c0 --- /dev/null +++ b/ocean/osrs/osrs_pvp_human_input_types.h @@ -0,0 +1,43 @@ +/** + * @file osrs_pvp_human_input_types.h + * @brief HumanInput struct and CursorMode enum — separated from human_input.h + * to break circular include dependency (gui.h needs HumanInput, but + * human_input.h needs gui.h for prayer/spell grid constants). + */ + +#ifndef OSRS_PVP_HUMAN_INPUT_TYPES_H +#define OSRS_PVP_HUMAN_INPUT_TYPES_H + +typedef enum { + CURSOR_NORMAL = 0, + CURSOR_SPELL_TARGET, /* clicked a combat spell, waiting for target click */ +} CursorMode; + +typedef struct HumanInput { + int enabled; /* H key toggle: 1 = human controls active */ + + /* semantic action staging (set by clicks, consumed at tick boundary) */ + int pending_move_x, pending_move_y; /* world tile coords, -1 = none */ + int pending_attack; /* 1 = attack target entity */ + int pending_prayer; /* OverheadAction value, -1 = no change */ + int pending_offensive_prayer; /* 0=none, 1=piety, 2=rigour, 3=augury, -1=no change */ + int pending_food; /* 1 = eat food */ + int pending_karambwan; /* 1 = eat karambwan */ + int pending_potion; /* PotionAction value, 0 = none */ + int pending_veng; /* 1 = cast vengeance */ + int pending_spec; /* 1 = use special attack */ + int pending_spell; /* 0=none, ATTACK_ICE or ATTACK_BLOOD */ + int pending_target_idx; /* NPC entity index to attack, -1 = none */ + int pending_gear; /* gear switch action value, 0 = none */ + + CursorMode cursor_mode; + int selected_spell; /* ATTACK_ICE or ATTACK_BLOOD for targeting */ + + /* visual feedback: click cross at screen-space position (like real OSRS client) */ + int click_screen_x, click_screen_y; /* screen pixel where click occurred */ + int click_cross_timer; /* counts up from 0, animation progresses over time */ + int click_cross_active; /* 1 = cross is visible */ + int click_is_attack; /* 1 = red cross (attack), 0 = yellow cross (move) */ +} HumanInput; + +#endif /* OSRS_PVP_HUMAN_INPUT_TYPES_H */ diff --git a/ocean/osrs/osrs_pvp_models.h b/ocean/osrs/osrs_pvp_models.h new file mode 100644 index 0000000000..dcf34d8532 --- /dev/null +++ b/ocean/osrs/osrs_pvp_models.h @@ -0,0 +1,213 @@ +/** + * @fileoverview Loads OSRS 3D models from .models v2 binary and converts to raylib meshes. + * + * Binary format produced by scripts/export_models.py (MDL2): + * header: uint32 magic ("MDL2"), uint32 count, uint32 offsets[count] + * per model: + * uint32 model_id + * uint16 expanded_vert_count (face_count * 3) + * uint16 face_count + * uint16 base_vert_count (original indexed vertex count) + * float expanded_verts[expanded_vert_count * 3] + * uint8 colors[expanded_vert_count * 4] + * int16 base_verts[base_vert_count * 3] (original OSRS coords, y NOT negated) + * uint8 vertex_skins[base_vert_count] (label group per vertex for animation) + * uint16 face_indices[face_count * 3] (a,b,c per face into base verts) + * + * Expanded vertices + colors are used directly by raylib Mesh for rendering. + * Base vertices, skins, and face indices are used by the animation system to + * transform the original geometry and re-expand for GPU upload. + */ + +#ifndef OSRS_PVP_MODELS_H +#define OSRS_PVP_MODELS_H + +#include "raylib.h" +#include "data/item_models.h" +#include +#include +#include + +#define MDL2_MAGIC 0x4D444C32 /* "MDL2" */ + +typedef struct { + uint32_t model_id; + Mesh mesh; + Model model; + + /* animation data (from base indexed geometry) */ + int16_t* base_vertices; /* [base_vert_count * 3] original OSRS coords */ + uint8_t* vertex_skins; /* [base_vert_count] label group per vertex */ + uint16_t* face_indices; /* [face_count * 3] triangle index buffer */ + uint8_t* face_priorities; /* [face_count] render priority per face (0-11) */ + uint16_t base_vert_count; + uint8_t min_priority; /* minimum face priority in this model */ + +} OsrsModel; + +typedef struct { + OsrsModel* models; + int count; +} ModelCache; + +/* ======================================================================== */ +/* loading */ +/* ======================================================================== */ + +static ModelCache* model_cache_load(const char* path) { + FILE* f = fopen(path, "rb"); + if (!f) { + fprintf(stderr, "model_cache_load: cannot open %s\n", path); + return NULL; + } + + /* read header */ + uint32_t magic, count; + fread(&magic, 4, 1, f); + fread(&count, 4, 1, f); + + if (magic != MDL2_MAGIC) { + fprintf(stderr, "model_cache_load: bad magic 0x%08X (expected MDL2 0x%08X)\n", + magic, MDL2_MAGIC); + fclose(f); + return NULL; + } + + /* read offset table */ + uint32_t* offsets = (uint32_t*)malloc(count * sizeof(uint32_t)); + fread(offsets, 4, count, f); + + ModelCache* cache = (ModelCache*)calloc(1, sizeof(ModelCache)); + cache->models = (OsrsModel*)calloc(count, sizeof(OsrsModel)); + cache->count = (int)count; + + for (uint32_t i = 0; i < count; i++) { + fseek(f, (long)offsets[i], SEEK_SET); + + uint32_t model_id; + uint16_t vert_count, face_count, base_vert_count; + fread(&model_id, 4, 1, f); + fread(&vert_count, 2, 1, f); + fread(&face_count, 2, 1, f); + fread(&base_vert_count, 2, 1, f); + + cache->models[i].model_id = model_id; + cache->models[i].base_vert_count = base_vert_count; + + /* allocate raylib mesh for expanded rendering geometry */ + Mesh mesh = { 0 }; + mesh.vertexCount = vert_count; + mesh.triangleCount = face_count; + + mesh.vertices = (float*)RL_MALLOC(vert_count * 3 * sizeof(float)); + mesh.colors = (unsigned char*)RL_MALLOC(vert_count * 4); + + fread(mesh.vertices, sizeof(float), vert_count * 3, f); + fread(mesh.colors, 1, vert_count * 4, f); + + /* read animation data */ + cache->models[i].base_vertices = (int16_t*)malloc(base_vert_count * 3 * sizeof(int16_t)); + fread(cache->models[i].base_vertices, sizeof(int16_t), base_vert_count * 3, f); + + cache->models[i].vertex_skins = (uint8_t*)malloc(base_vert_count); + fread(cache->models[i].vertex_skins, 1, base_vert_count, f); + + cache->models[i].face_indices = (uint16_t*)malloc(face_count * 3 * sizeof(uint16_t)); + fread(cache->models[i].face_indices, sizeof(uint16_t), face_count * 3, f); + + cache->models[i].face_priorities = (uint8_t*)malloc(face_count); + fread(cache->models[i].face_priorities, 1, face_count, f); + + /* compute min priority for this model */ + uint8_t min_pri = 255; + for (uint16_t fp = 0; fp < face_count; fp++) { + if (cache->models[i].face_priorities[fp] < min_pri) + min_pri = cache->models[i].face_priorities[fp]; + } + cache->models[i].min_priority = min_pri; + + /* upload to GPU */ + UploadMesh(&mesh, false); + cache->models[i].mesh = mesh; + cache->models[i].model = LoadModelFromMesh(mesh); + } + + free(offsets); + fclose(f); + + fprintf(stderr, "model_cache_load: loaded %d models from %s\n", cache->count, path); + return cache; +} + +/* ======================================================================== */ +/* lookup */ +/* ======================================================================== */ + +static OsrsModel* model_cache_get(ModelCache* cache, uint32_t model_id) { + if (!cache) return NULL; + for (int i = 0; i < cache->count; i++) { + if (cache->models[i].model_id == model_id) { + return &cache->models[i]; + } + } + return NULL; +} + +/** + * Find the inv_model ID for a given OSRS item ID using the generated mapping. + * Returns 0xFFFFFFFF if not found. + */ +static uint32_t item_to_inv_model(uint16_t item_id) { + for (int i = 0; i < ITEM_MODEL_COUNT; i++) { + if (ITEM_MODEL_MAP[i].item_id == item_id) { + return ITEM_MODEL_MAP[i].inv_model; + } + } + return 0xFFFFFFFF; +} + +/** + * Find the wield_model ID for a given OSRS item ID using the generated mapping. + * Returns 0xFFFFFFFF if not found. + */ +static uint32_t item_to_wield_model(uint16_t item_id) { + for (int i = 0; i < ITEM_MODEL_COUNT; i++) { + if (ITEM_MODEL_MAP[i].item_id == item_id) { + return ITEM_MODEL_MAP[i].wield_model; + } + } + return 0xFFFFFFFF; +} + +/** + * Check if a body item provides its own arm model (has sleeves). + * When true, the default arm body parts should be hidden. + */ +static int item_has_sleeves(uint16_t item_id) { + for (int i = 0; i < ITEM_MODEL_COUNT; i++) { + if (ITEM_MODEL_MAP[i].item_id == item_id) { + return ITEM_MODEL_MAP[i].has_sleeves; + } + } + return 0; +} + +/* ======================================================================== */ +/* cleanup */ +/* ======================================================================== */ + +static void model_cache_free(ModelCache* cache) { + if (!cache) return; + for (int i = 0; i < cache->count; i++) { + UnloadModel(cache->models[i].model); + /* UnloadModel already frees the mesh */ + free(cache->models[i].base_vertices); + free(cache->models[i].vertex_skins); + free(cache->models[i].face_indices); + free(cache->models[i].face_priorities); + } + free(cache->models); + free(cache); +} + +#endif /* OSRS_PVP_MODELS_H */ diff --git a/ocean/osrs/osrs_pvp_movement.h b/ocean/osrs/osrs_pvp_movement.h new file mode 100644 index 0000000000..ec820ec751 --- /dev/null +++ b/ocean/osrs/osrs_pvp_movement.h @@ -0,0 +1,506 @@ +/** + * @file osrs_pvp_movement.h + * @brief Movement and tile selection for OSRS PvP simulation + * + * Handles player movement including: + * - Tile selection (adjacent, diagonal, farcast positions) + * - Pathfinding via step_toward_destination + * - Freeze mechanics integration + * - Wilderness boundary checking + * + * Movement actions: + * 0 = maintain current movement state + * 1 = move to adjacent tile (melee range) + * 2 = move to target's exact tile + * 3 = move to farcast tile (ranged/magic) + * 4 = move to diagonal tile + */ + +#ifndef OSRS_PVP_MOVEMENT_H +#define OSRS_PVP_MOVEMENT_H + +#include "osrs_types.h" +#include "osrs_collision.h" + +// is_in_wilderness and tile_hash are defined in osrs_types.h + +/** + * Select closest tile adjacent (cardinal) to target. + * + * Finds the north/east/south/west tile closest to the player. + * Tie-breaking: distance to agent > distance to target > tile hash. + * + * @param p Player seeking adjacent position + * @param target_x Target's x coordinate + * @param target_y Target's y coordinate + * @param out_x Output: selected tile x + * @param out_y Output: selected tile y + * @return 1 if valid tile found, 0 if all candidates out of bounds + */ +static int select_closest_adjacent_tile(Player* p, int target_x, int target_y, int* out_x, int* out_y, const CollisionMap* cmap) { + int candidates[4][2] = { + {target_x, target_y + 1}, + {target_x + 1, target_y}, + {target_x, target_y - 1}, + {target_x - 1, target_y} + }; + + int has_best = 0; + int best_x = 0; + int best_y = 0; + int best_dist_agent = 0; + int best_dist_target = 0; + int best_hash = 0; + + for (int i = 0; i < 4; i++) { + int cx = candidates[i][0]; + int cy = candidates[i][1]; + if (!is_in_wilderness(cx, cy)) { + continue; + } + if (!collision_tile_walkable(cmap, 0, cx, cy)) { + continue; + } + int dist_agent = chebyshev_distance(p->x, p->y, cx, cy); + int dist_target = chebyshev_distance(cx, cy, target_x, target_y); + int hash = tile_hash(cx, cy); + if (!has_best || + dist_agent < best_dist_agent || + (dist_agent == best_dist_agent && + (dist_target < best_dist_target || + (dist_target == best_dist_target && hash < best_hash)))) { + has_best = 1; + best_x = cx; + best_y = cy; + best_dist_agent = dist_agent; + best_dist_target = dist_target; + best_hash = hash; + } + } + + if (!has_best) { + return 0; + } + *out_x = best_x; + *out_y = best_y; + return 1; +} + +/** + * Select closest tile diagonal to target. + * + * Finds the NE/SE/SW/NW tile closest to the player. + * Useful for avoiding melee while maintaining attack range. + * + * @param p Player seeking diagonal position + * @param target_x Target's x coordinate + * @param target_y Target's y coordinate + * @param out_x Output: selected tile x + * @param out_y Output: selected tile y + * @return 1 if valid tile found, 0 if all candidates out of bounds + */ +static int select_closest_diagonal_tile(Player* p, int target_x, int target_y, int* out_x, int* out_y, const CollisionMap* cmap) { + int candidates[4][2] = { + {target_x + 1, target_y + 1}, + {target_x + 1, target_y - 1}, + {target_x - 1, target_y - 1}, + {target_x - 1, target_y + 1} + }; + + int has_best = 0; + int best_x = 0; + int best_y = 0; + int best_dist_agent = 0; + int best_dist_target = 0; + int best_hash = 0; + + for (int i = 0; i < 4; i++) { + int cx = candidates[i][0]; + int cy = candidates[i][1]; + if (!is_in_wilderness(cx, cy)) { + continue; + } + if (!collision_tile_walkable(cmap, 0, cx, cy)) { + continue; + } + int dist_agent = chebyshev_distance(p->x, p->y, cx, cy); + int dist_target = chebyshev_distance(cx, cy, target_x, target_y); + int hash = tile_hash(cx, cy); + if (!has_best || + dist_agent < best_dist_agent || + (dist_agent == best_dist_agent && + (dist_target < best_dist_target || + (dist_target == best_dist_target && hash < best_hash)))) { + has_best = 1; + best_x = cx; + best_y = cy; + best_dist_agent = dist_agent; + best_dist_target = dist_target; + best_hash = hash; + } + } + + if (!has_best) { + return 0; + } + *out_x = best_x; + *out_y = best_y; + return 1; +} + +/** + * Select closest tile at specified distance for farcasting. + * + * Searches ring of tiles at exact chebyshev distance from target. + * Used for ranged/magic attacks from safe distance. + * + * @param p Player seeking farcast position + * @param target_x Target's x coordinate + * @param target_y Target's y coordinate + * @param distance Desired chebyshev distance from target + * @param out_x Output: selected tile x + * @param out_y Output: selected tile y + * @return 1 if valid tile found, 0 otherwise + */ +static int select_farcast_tile(Player* p, int target_x, int target_y, int distance, int* out_x, int* out_y, const CollisionMap* cmap) { + /* O(1) closest point on chebyshev ring of radius `distance` centered at target. + * Clamp player->target delta to [-d, d], then push one axis to ±d if needed. */ + int raw_dx = p->x - target_x; + int raw_dy = p->y - target_y; + int d = distance; + + /* clamp to chebyshev ball */ + int dx = raw_dx < -d ? -d : (raw_dx > d ? d : raw_dx); + int dy = raw_dy < -d ? -d : (raw_dy > d ? d : raw_dy); + + /* ensure we're on the ring (max(|dx|,|dy|) == d) */ + int adx = abs_int(dx); + int ady = abs_int(dy); + if (adx < d && ady < d) { + /* push the axis with larger magnitude to ±d; if tied, push x */ + if (adx >= ady) { + dx = (raw_dx >= 0) ? d : -d; + } else { + dy = (raw_dy >= 0) ? d : -d; + } + } + + int cx = target_x + dx; + int cy = target_y + dy; + + if (is_in_wilderness(cx, cy) && collision_tile_walkable(cmap, 0, cx, cy)) { + *out_x = cx; + *out_y = cy; + return 1; + } + + /* wilderness boundary edge case: clamp to bounds and retry on ring */ + cx = cx < WILD_MIN_X ? WILD_MIN_X : (cx > WILD_MAX_X ? WILD_MAX_X : cx); + cy = cy < WILD_MIN_Y ? WILD_MIN_Y : (cy > WILD_MAX_Y ? WILD_MAX_Y : cy); + if (chebyshev_distance(cx, cy, target_x, target_y) == distance + && collision_tile_walkable(cmap, 0, cx, cy)) { + *out_x = cx; + *out_y = cy; + return 1; + } + + /* very rare edge: target near corner of wilderness, no valid tile on ring */ + return 0; +} + +/** + * Move player one tile toward their destination (collision-aware). + * + * Tries diagonal first when both dx and dy are non-zero. If the diagonal + * step is blocked by collision, falls back to cardinal x then cardinal y. + * If all directions are blocked, the player doesn't move. + * + * When cmap is NULL, all tiles are traversable (flat arena behavior). + * + * @param p Player to move + * @param cmap Collision map (may be NULL) + * @return 1 if moved, 0 if already at destination or blocked + */ +static int step_toward_destination(Player* p, const CollisionMap* cmap) { + int dx = p->dest_x - p->x; + int dy = p->dest_y - p->y; + if (dx == 0 && dy == 0) { + return 0; + } + + int step_x = (dx > 0) ? 1 : (dx < 0 ? -1 : 0); + int step_y = (dy > 0) ? 1 : (dy < 0 ? -1 : 0); + + /* diagonal movement: try diagonal first, then cardinal fallbacks */ + if (step_x != 0 && step_y != 0) { + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, step_y)) { + p->x += step_x; + p->y += step_y; + return 1; + } + /* diagonal blocked — try cardinal x */ + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, 0)) { + p->x += step_x; + return 1; + } + /* try cardinal y */ + if (collision_traversable_step(cmap, 0, p->x, p->y, 0, step_y)) { + p->y += step_y; + return 1; + } + /* all blocked */ + return 0; + } + + /* cardinal movement */ + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, step_y)) { + p->x += step_x; + p->y += step_y; + return 1; + } + + /* blocked */ + return 0; +} + +/** + * Set player destination and initiate movement. + * + * Running moves 2 tiles per tick (OSRS default for PvP). + * Takes first step, then second step if not at destination. + * + * @param p Player + * @param dest_x Destination x coordinate + * @param dest_y Destination y coordinate + * @param cmap Collision map (may be NULL) + */ +static void set_destination(Player* p, int dest_x, int dest_y, const CollisionMap* cmap) { + p->dest_x = dest_x; + p->dest_y = dest_y; + if (p->x == dest_x && p->y == dest_y) { + p->is_moving = 0; + return; + } + // First step (walk) + if (!step_toward_destination(p, cmap)) { + p->is_moving = 0; + return; + } + // Second step (run) - only if not at destination yet + if (p->x != dest_x || p->y != dest_y) { + step_toward_destination(p, cmap); + } + // Still moving if not at destination + p->is_moving = (p->x != dest_x || p->y != dest_y) ? 1 : 0; +} + +/** + * Process movement action for a player. + * + * Movement is blocked when frozen. Otherwise: + * 0 = maintain current movement state + * 1 = move to adjacent tile + * 2 = move to target's tile + * 3 = farcast (move to distance) + * 4 = move to diagonal tile + * + * @param p Player processing movement + * @param target Target player (for position reference) + * @param movement_action Action index (0-4) + * @param farcast_distance Distance for farcast action + */ +static void process_movement(Player* p, Player* target, int movement_action, int farcast_distance, const CollisionMap* cmap) { + if (p->frozen_ticks > 0) { + p->is_moving = 0; + return; + } + + int moved_before = p->is_moving; + p->is_moving = 0; + + (void)target; + int target_x = p->last_obs_target_x; + int target_y = p->last_obs_target_y; + + switch (movement_action) { + case 0: + p->is_moving = moved_before ? 1 : 0; + break; + case 1: { + int dest_x = 0; + int dest_y = 0; + if (select_closest_adjacent_tile(p, target_x, target_y, &dest_x, &dest_y, cmap)) { + set_destination(p, dest_x, dest_y, cmap); + } else { + set_destination(p, p->x, p->y, cmap); + } + break; + } + case 2: + set_destination(p, target_x, target_y, cmap); + break; + case 3: { + int dest_x = 0; + int dest_y = 0; + int target_dist = farcast_distance; + if (select_farcast_tile(p, target_x, target_y, target_dist, &dest_x, &dest_y, cmap)) { + set_destination(p, dest_x, dest_y, cmap); + } else { + set_destination(p, p->x, p->y, cmap); + } + break; + } + case 4: { + int dest_x = 0; + int dest_y = 0; + if (select_closest_diagonal_tile(p, target_x, target_y, &dest_x, &dest_y, cmap)) { + set_destination(p, dest_x, dest_y, cmap); + } else { + set_destination(p, p->x, p->y, cmap); + } + break; + } + } +} + +/** + * Simple chase movement - move toward target's position. + * + * Blocked by freeze. Used for basic follow behavior. + * + * @param p Player to move + * @param target Target to chase + */ +static void move_toward_target(Player* p, Player* target, const CollisionMap* cmap) { + if (p->frozen_ticks > 0) { + return; + } + set_destination(p, target->x, target->y, cmap); +} + +/** + * Step out from same tile as target to an adjacent tile. + * + * When on the same tile as target (distance=0), you cannot attack. + * This function steps to an adjacent tile so you can attack next tick. + * Tries directions in order: West, East, South, North (matches Java clippedStep). + * + * Blocked by freeze - if frozen on same tile, you're stuck. + * + * @param p Player to move + * @param target Target (used for position reference) + */ +static void step_out_from_same_tile(Player* p, Player* target, const CollisionMap* cmap) { + if (p->frozen_ticks > 0) { + return; + } + + // Try West (x-1, y) + int dest_x = target->x - 1; + int dest_y = target->y; + if (is_in_wilderness(dest_x, dest_y) && collision_tile_walkable(cmap, 0, dest_x, dest_y)) { + set_destination(p, dest_x, dest_y, cmap); + return; + } + // Try East (x+1, y) + dest_x = target->x + 1; + if (is_in_wilderness(dest_x, dest_y) && collision_tile_walkable(cmap, 0, dest_x, dest_y)) { + set_destination(p, dest_x, dest_y, cmap); + return; + } + // Try South (x, y-1) + dest_x = target->x; + dest_y = target->y - 1; + if (is_in_wilderness(dest_x, dest_y) && collision_tile_walkable(cmap, 0, dest_x, dest_y)) { + set_destination(p, dest_x, dest_y, cmap); + return; + } + // Try North (x, y+1) + dest_y = target->y + 1; + if (is_in_wilderness(dest_x, dest_y) && collision_tile_walkable(cmap, 0, dest_x, dest_y)) { + set_destination(p, dest_x, dest_y, cmap); + return; + } + // All directions blocked +} + +/** + * Resolve same-tile stacking after movement. + * + * OSRS prevents two unfrozen players from occupying the same tile. + * When both end up on the same tile, the second mover gets bumped + * to the nearest valid tile using OSRS BFS priority: + * W, E, S, N, SW, SE, NW, NE. + * + * Exception: walking under a frozen opponent is intentional OSRS + * strategy (frozen player can't attack you on their tile). Only + * resolve stacking when the blocker is NOT frozen. + * + * @param mover Player to move off the shared tile + * @param blocker The other player (checked for freeze status) + */ +static void resolve_same_tile(Player* mover, Player* blocker, const CollisionMap* cmap) { + // Walking under a frozen opponent is valid OSRS behavior — skip resolution + if (blocker->frozen_ticks > 0) { + return; + } + // Frozen mover can't be bumped + if (mover->frozen_ticks > 0) { + return; + } + + // OSRS BFS priority: W, E, S, N, SW, SE, NW, NE + static const int OFFSETS[8][2] = { + {-1, 0}, {1, 0}, {0, -1}, {0, 1}, + {-1, -1}, {1, -1}, {-1, 1}, {1, 1} + }; + + for (int i = 0; i < 8; i++) { + int nx = mover->x + OFFSETS[i][0]; + int ny = mover->y + OFFSETS[i][1]; + if (is_in_wilderness(nx, ny) + && collision_tile_walkable(cmap, 0, nx, ny) + && !(nx == blocker->x && ny == blocker->y)) { + mover->x = nx; + mover->y = ny; + mover->dest_x = nx; + mover->dest_y = ny; + mover->is_moving = 0; + return; + } + } +} + +/** + * Continue movement for a player who is already moving. + * + * Used in sequential mode where movement clicks set destination but + * don't immediately step. Each tick, players with is_moving=1 should + * continue moving toward their destination. + * + * Running moves 2 tiles per tick (OSRS default for PvP). + * + * @param p Player to continue moving + */ +static void continue_movement(Player* p, const CollisionMap* cmap) { + if (!p->is_moving) { + return; + } + if (p->frozen_ticks > 0) { + p->is_moving = 0; + return; + } + // First step (walk) + if (!step_toward_destination(p, cmap)) { + p->is_moving = 0; + return; + } + // Second step (run) - only if not at destination yet + if (p->x != p->dest_x || p->y != p->dest_y) { + step_toward_destination(p, cmap); + } + // Still moving if not at destination + p->is_moving = (p->x != p->dest_x || p->y != p->dest_y) ? 1 : 0; +} + +#endif // OSRS_PVP_MOVEMENT_H diff --git a/ocean/osrs/osrs_pvp_objects.h b/ocean/osrs/osrs_pvp_objects.h new file mode 100644 index 0000000000..ec8d7a5d01 --- /dev/null +++ b/ocean/osrs/osrs_pvp_objects.h @@ -0,0 +1,227 @@ +/** + * @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. + */ + +#ifndef OSRS_PVP_OBJECTS_H +#define OSRS_PVP_OBJECTS_H + +#include "raylib.h" +#include "rlgl.h" +#include +#include +#include +#include + +#define OBJS_MAGIC 0x4F424A53 /* "OBJS" v1 */ +#define OBJ2_MAGIC 0x4F424A32 /* "OBJ2" v2 with texcoords */ +#define ATLS_MAGIC 0x41544C53 /* "ATLS" texture atlas */ + +typedef struct { + Model model; + Texture2D atlas_texture; /* loaded from .atlas file (0 if none) */ + int placement_count; + int total_vertex_count; + int min_world_x; + int min_world_y; + int has_textures; + int loaded; +} ObjectMesh; + +/** + * Load texture atlas from .atlas binary file. + * Format: uint32 magic, uint32 width, uint32 height, uint8 pixels[w*h*4] (RGBA). + */ +static Texture2D objects_load_atlas(const char* atlas_path) { + Texture2D tex = { 0 }; + FILE* f = fopen(atlas_path, "rb"); + if (!f) { + fprintf(stderr, "objects_load_atlas: could not open %s\n", atlas_path); + return tex; + } + + uint32_t magic, width, height; + fread(&magic, 4, 1, f); + if (magic != ATLS_MAGIC) { + fprintf(stderr, "objects_load_atlas: bad magic %08x (expected ATLS)\n", magic); + fclose(f); + return tex; + } + fread(&width, 4, 1, f); + fread(&height, 4, 1, f); + + size_t pixel_size = (size_t)width * height * 4; + unsigned char* pixels = (unsigned char*)malloc(pixel_size); + size_t read = fread(pixels, 1, pixel_size, f); + fclose(f); + + if (read != pixel_size) { + fprintf(stderr, "objects_load_atlas: incomplete read (%zu/%zu)\n", read, pixel_size); + free(pixels); + return tex; + } + + /* create raylib Image from raw RGBA, then upload as texture */ + Image img = { + .data = pixels, + .width = (int)width, + .height = (int)height, + .mipmaps = 1, + .format = PIXELFORMAT_UNCOMPRESSED_R8G8B8A8, + }; + tex = LoadTextureFromImage(img); + /* set texture filtering for better quality at angles */ + SetTextureFilter(tex, TEXTURE_FILTER_BILINEAR); + free(pixels); + + fprintf(stderr, "objects_load_atlas: loaded %ux%u atlas texture\n", width, height); + return tex; +} + +static ObjectMesh* objects_load(const char* path) { + FILE* f = 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; + fread(&magic, 4, 1, f); + + 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); + fclose(f); + return NULL; + } + + fread(&placement_count, 4, 1, f); + fread(&min_wx, 4, 1, f); + fread(&min_wy, 4, 1, f); + fread(&total_verts, 4, 1, f); + + 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)); + fread(raw_verts, sizeof(float), total_verts * 3, f); + + /* read colors */ + unsigned char* raw_colors = (unsigned char*)malloc(total_verts * 4); + fread(raw_colors, 1, total_verts * 4, f); + + /* read texture coordinates (v2 only) */ + float* raw_texcoords = NULL; + if (has_textures) { + raw_texcoords = (float*)malloc(total_verts * 2 * sizeof(float)); + fread(raw_texcoords, sizeof(float), total_verts * 2, f); + } + fclose(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)); + 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); + } + + om->atlas_texture = objects_load_atlas(atlas_path); + if (om->atlas_texture.id > 0) { + /* assign atlas as diffuse map for the model's material */ + om->model.materials[0].maps[MATERIAL_MAP_DIFFUSE].texture = om->atlas_texture; + } + } + + return om; +} + +/* shift object vertices so world coordinates (wx, wy) become local (0, 0). + must match terrain_offset() values for alignment. */ +static 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); +} + +static void objects_free(ObjectMesh* om) { + if (!om) return; + if (om->loaded) { + if (om->atlas_texture.id > 0) { + UnloadTexture(om->atlas_texture); + } + UnloadModel(om->model); + } + free(om); +} + +#endif /* OSRS_PVP_OBJECTS_H */ diff --git a/ocean/osrs/osrs_pvp_observations.h b/ocean/osrs/osrs_pvp_observations.h new file mode 100644 index 0000000000..db36e4107f --- /dev/null +++ b/ocean/osrs/osrs_pvp_observations.h @@ -0,0 +1,783 @@ +/** + * @file osrs_pvp_observations.h + * @brief Observation generation and action mask computation + * + * Generates the observation vector for RL agents (334 features) + * and computes action masks to prevent invalid actions. + */ + +#ifndef OSRS_PVP_OBSERVATIONS_H +#define OSRS_PVP_OBSERVATIONS_H + +#include +#include "osrs_types.h" +#include "osrs_pvp_gear.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_movement.h" + +/** + * Get relative combat skill level (strength/attack/defence). + * Normalized to 0-1 range based on max possible boosted level. + */ +static inline float get_relative_level_combat(int current, int base) { + int max_level = base + (int)floorf(base * 0.15f) + 5; + return (float)current / (float)max_level; +} + +/** Get relative ranged level (10% boost formula). */ +static inline float get_relative_level_ranged(int current, int base) { + int max_level = base + (int)floorf(base * 0.10f) + 4; + return (float)current / (float)max_level; +} + +/** Get relative magic level (no boost available). */ +static inline float get_relative_level_magic(int current, int base) { + return (float)current / (float)base; +} + +/** + * Check if brew/defence boost would be beneficial. + * + * @param p Player to check + * @return 1 if defence not capped or HP not full + */ +static inline int can_use_brew_boost(Player* p) { + int def_boost = (int)floorf(2.0f + (0.20f * p->base_defence)); + int def_cap = p->is_lms ? p->base_defence : p->base_defence + def_boost; + if (p->current_defence < def_cap - 1) { + return 1; + } + return p->current_hitpoints <= p->base_hitpoints; +} + +/** Check if restore potion would be beneficial. + * Stats must be drained OR prayer below 90% of base. + */ +static inline int can_restore_stats(Player* p) { + int stats_drained = p->current_attack < p->base_attack || + p->current_defence < p->base_defence || + p->current_strength < p->base_strength || + p->current_ranged < p->base_ranged || + p->current_magic < p->base_magic; + int prayer_low = p->current_prayer < (int)(p->base_prayer * 0.9f); + return stats_drained || prayer_low; +} + +/** Check if combat potion boost would be beneficial. */ +static inline int can_boost_combat_skills(Player* p) { + int max_att = (int)floorf(p->base_attack * 0.15f) + 5 + p->base_attack; + int max_str = (int)floorf(p->base_strength * 0.15f) + 5 + p->base_strength; + int def_boost = (int)floorf(p->base_defence * 0.15f) + 5; + int max_def = p->is_lms ? p->base_defence : p->base_defence + def_boost; + return max_att > p->current_attack + 1 || + max_def > p->current_defence + 1 || + max_str > p->current_strength + 1; +} + +/** Check if ranged potion boost would be beneficial. */ +static inline int can_boost_ranged(Player* p) { + int max_ranged = (int)floorf(p->base_ranged * 0.10f) + 4 + p->base_ranged; + return max_ranged > p->current_ranged + 1; +} + +/** Check if potion type is available (timer + doses). */ +static inline int can_use_potion(Player* p, int potion_type) { + if (remaining_ticks(p->potion_timer) > 0) { + return 0; + } + switch (potion_type) { + case 1: return p->brew_doses > 0; + case 2: return p->restore_doses > 0; + case 3: return p->combat_potion_doses > 0; + case 4: return p->ranged_potion_doses > 0; + default: return 0; + } +} + +/** Check if food is available and player not at full HP. */ +static inline int can_eat_food(Player* p) { + if (remaining_ticks(p->food_timer) > 0) { + return 0; + } + if (p->food_count <= 0) { + return 0; + } + return p->current_hitpoints < p->base_hitpoints; +} + +/** Check if karambwan is available and player not at full HP. */ +static inline int can_eat_karambwan(Player* p) { + if (remaining_ticks(p->karambwan_timer) > 0) { + return 0; + } + if (p->karambwan_count <= 0) { + return 0; + } + return p->current_hitpoints < p->base_hitpoints; +} + +/** Check if target is about to attack (tank gear useful). */ +static inline int can_switch_to_tank_gear(Player* target) { + return remaining_ticks(target->attack_timer) <= 0; +} + +/** Check if prayer switch is available (respects timing config). */ +static inline int is_protected_prayer_action_available(Player* p, Player* target) { + if (ONLY_SWITCH_PRAYER_WHEN_ABOUT_TO_ATTACK && remaining_ticks(target->attack_timer) > 0) { + return 0; + } + return p->current_prayer > 0; +} + +/** Check if smite prayer is available (not in LMS). */ +static inline int is_smite_available(OsrsPvp* env, Player* p) { + if (!ALLOW_SMITE || env->is_lms) { + return 0; + } + if (remaining_ticks(p->attack_timer) > 0) { + return 0; + } + return p->current_prayer > 0; +} + +/** Check if redemption prayer is available (no supplies left). */ +static inline int is_redemption_available(OsrsPvp* env, Player* p, Player* target) { + if (!ALLOW_REDEMPTION || env->is_lms) { + return 0; + } + if (p->food_count > 0 || p->karambwan_count > 0 || p->brew_doses > 0) { + return 0; + } + int ticks_until_hit = get_ticks_until_next_hit(target); + if (ticks_until_hit < 0 && remaining_ticks(target->attack_timer) > 0) { + return 0; + } + return p->current_prayer > 0; +} + +/** Check if melee range is possible (can move or already in range). */ +static inline int is_melee_range_possible(Player* p, Player* target) { + return can_move(p) || can_move(target) || is_in_melee_range(p, target); +} + +/** Check if target can cast magic spells. */ +static inline int can_target_cast_magic_spells(Player* p) { + return !p->observed_target_lunar_spellbook; +} + +/** Check if movement action is allowed. */ +static inline int can_move_action(Player* p) { + if (!ALLOW_MOVING_IF_CAN_ATTACK && remaining_ticks(p->attack_timer) == 0) { + return 0; + } + return can_move(p); +} + +/** Check if moving to adjacent tile is useful. */ +static inline int can_move_adjacent(Player* p, Player* target, const CollisionMap* cmap) { + (void)target; + int dest_x = 0; + int dest_y = 0; + if (!select_closest_adjacent_tile(p, p->last_obs_target_x, p->last_obs_target_y, &dest_x, &dest_y, cmap)) { + return 0; + } + return !(dest_x == p->x && dest_y == p->y); +} + +/** Check if moving under target is useful (they're frozen). */ +static inline int can_move_under(Player* p, Player* target) { + int dist = chebyshev_distance(p->x, p->y, p->last_obs_target_x, p->last_obs_target_y); + return remaining_ticks(target->frozen_ticks) > 0 && dist != 0; +} + +/** Check if farcast tile at distance is reachable. */ +static inline int can_move_to_farcast(Player* p, Player* target, int distance, const CollisionMap* cmap) { + (void)target; + int dest_x = 0; + int dest_y = 0; + if (!select_farcast_tile(p, p->last_obs_target_x, p->last_obs_target_y, distance, &dest_x, &dest_y, cmap)) { + return 0; + } + return !(dest_x == p->x && dest_y == p->y); +} + +/** Check if moving to diagonal tile is useful. */ +static inline int can_move_diagonal(Player* p, Player* target, const CollisionMap* cmap) { + (void)target; + int dest_x = 0; + int dest_y = 0; + if (!select_closest_diagonal_tile(p, p->last_obs_target_x, p->last_obs_target_y, &dest_x, &dest_y, cmap)) { + return 0; + } + return !(dest_x == p->x && dest_y == p->y); +} + +// ============================================================================ +// OBSERVATION NORMALIZATION DIVISORS (matches _OBS_NORM_DIVISORS in osrs_pvp.py) +// ============================================================================ + +static void init_obs_norm_divisors(float* d) { + for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) d[i] = 1.0f; + + // Spec energy (0-100) + d[4] = 100.0f; // player spec + d[21] = 100.0f; // target spec + + // Consumable counts + d[22] = 10.0f; // ranged potion doses + d[23] = 10.0f; // combat potion doses + d[24] = 16.0f; // restore doses + d[25] = 20.0f; // brew doses + d[26] = 15.0f; // food count (dynamic, range 1-17) + d[27] = 4.0f; // karambwan count + + // Frozen/immunity ticks (0-32) + d[29] = 32.0f; // player frozen + d[30] = 32.0f; // target frozen + d[31] = 32.0f; // player freeze immunity + d[32] = 32.0f; // target freeze immunity + + // Timers + d[39] = 6.0f; // attack timer + d[40] = 3.0f; // food timer + d[41] = 3.0f; // potion timer + d[42] = 3.0f; // karambwan timer + d[43] = 4.0f; // attack delay normalized + d[44] = 6.0f; // target attack timer + d[45] = 3.0f; // target food timer + + // Pending damage ratio (can exceed 1.0 with stacked hits) + d[46] = 2.0f; + + // Ticks until hit + d[47] = 6.0f; + d[48] = 6.0f; + + // Damage scale ratio (clamped to [0.5, 2.0]) + d[65] = 2.0f; + + // Distances + d[60] = 7.0f; + d[61] = 7.0f; + d[62] = 7.0f; + + // Base stats (96-102) + for (int i = 96; i <= 102; i++) d[i] = 99.0f; + + // Spec weapon hit count + d[106] = 4.0f; + + // Gear bonuses (119-132): player bonuses + target visible defences + for (int i = 119; i <= 132; i++) d[i] = 170.0f; + // attack_speed and attack_range have small values (4-6, 1-10), override + d[123] = 6.0f; // attack_speed + d[124] = 10.0f; // attack_range + + // Veng cooldowns (139-140) + d[139] = 50.0f; + d[140] = 50.0f; + + // Dynamic slot item stats (182-325): 8 slots * 18 stats + for (int i = 182; i <= 325; i++) d[i] = 170.0f; +} + +static float OBS_NORM_DIVISORS[SLOT_NUM_OBSERVATIONS]; +static int _obs_norm_initialized = 0; + +static void ensure_obs_norm_initialized(void) { + if (!_obs_norm_initialized) { + init_obs_norm_divisors(OBS_NORM_DIVISORS); + _obs_norm_initialized = 1; + } +} + +/** + * Write normalized agent 0 observations + action mask to ocean buffer. + * + * Output layout: [normalized_obs(334), action_mask_as_float(40)] = 374 floats. + */ +static void ocean_write_obs(OsrsPvp* env) { + ensure_obs_norm_initialized(); + float* dst = env->ocean_obs; + float* src = env->observations; // agent 0 obs (internal buffer) + + // Normalize observations + for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) { + dst[i] = src[i] / OBS_NORM_DIVISORS[i]; + } + + // Append action mask as float (agent 0 only) + unsigned char* mask = env->action_masks; + for (int i = 0; i < ACTION_MASK_SIZE; i++) { + dst[SLOT_NUM_OBSERVATIONS + i] = (float)mask[i]; + } +} + +/** + * Write normalized agent 1 observations + action mask to self-play buffer. + * + * Mirrors ocean_write_obs() but reads from agent 1's internal buffer offsets. + * Only called when ocean_obs_p1 is set (self-play enabled). + */ +static void ocean_write_obs_p1(OsrsPvp* env) { + ensure_obs_norm_initialized(); + float* dst = env->ocean_obs_p1; + float* src = env->observations + SLOT_NUM_OBSERVATIONS; // agent 1 offset + + for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) { + dst[i] = src[i] / OBS_NORM_DIVISORS[i]; + } + + unsigned char* mask = env->action_masks + ACTION_MASK_SIZE; // agent 1 mask offset + for (int i = 0; i < ACTION_MASK_SIZE; i++) { + dst[SLOT_NUM_OBSERVATIONS + i] = (float)mask[i]; + } +} + +// ============================================================================ +// SLOT-BASED MODE - OBSERVATION GENERATION +// ============================================================================ + +/** + * Generate slot-mode observations with per-slot item stats. + * + * Observation layout (334 features): + * [0-118] Core observations (gear/prayer/hp/consumables/timers/combat history/stats) + * [119-132] Gear bonuses (player + target visible defences) + * [133-149] Game mode flags, ability checks, attack_timer_ready + * [150-181] Slot-specific features (weapon/style/prayer/equipped per slot) + * [182-325] Dynamic slot item stats (8 slots * 18 stats = 144 features) + * [326] Voidwaker magic damage flag + * [327-333] Reward shaping signals + */ +static void generate_slot_observations(OsrsPvp* env, int agent_idx) { + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + + float* obs = env->observations + agent_idx * SLOT_NUM_OBSERVATIONS; + + // Generate base observations (0-170) + p->last_obs_target_x = t->x; + p->last_obs_target_y = t->y; + + obs[0] = (p->visible_gear == GEAR_MELEE) ? 1.0f : 0.0f; + obs[1] = (p->visible_gear == GEAR_RANGED) ? 1.0f : 0.0f; + obs[2] = (p->visible_gear == GEAR_MAGE) ? 1.0f : 0.0f; + obs[3] = 0.0f; // was GEAR_SPEC, now unused (visible_gear is always MELEE/RANGED/MAGE) + obs[4] = (float)p->special_energy; + + obs[5] = (p->prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + obs[6] = (p->prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + obs[7] = (p->prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + obs[8] = (p->prayer == PRAYER_SMITE) ? 1.0f : 0.0f; + obs[9] = (p->prayer == PRAYER_REDEMPTION) ? 1.0f : 0.0f; + + obs[10] = (float)p->current_hitpoints / (float)p->base_hitpoints; + obs[11] = p->last_target_health_percent; + + // Target last attack style (more reliable than gear type — can't be faked) + obs[12] = (t->last_attack_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[13] = (t->last_attack_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[14] = (t->last_attack_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[15] = (t->last_attack_style == ATTACK_STYLE_NONE) ? 1.0f : 0.0f; + + obs[16] = (t->prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + obs[17] = (t->prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + obs[18] = (t->prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + obs[19] = (t->prayer == PRAYER_SMITE) ? 1.0f : 0.0f; + obs[20] = (t->prayer == PRAYER_REDEMPTION) ? 1.0f : 0.0f; + obs[21] = (float)t->special_energy; + + // Consumables and stats (22-45) + obs[22] = (float)p->ranged_potion_doses; + obs[23] = (float)p->combat_potion_doses; + obs[24] = (float)p->restore_doses; + obs[25] = (float)p->brew_doses; + obs[26] = (float)p->food_count; + obs[27] = (float)p->karambwan_count; + obs[28] = (float)p->current_prayer / (float)p->base_prayer; + + obs[29] = (float)remaining_ticks(p->frozen_ticks); + obs[30] = (float)remaining_ticks(t->frozen_ticks); + obs[31] = (float)remaining_ticks(p->freeze_immunity_ticks); + obs[32] = (float)remaining_ticks(t->freeze_immunity_ticks); + + obs[33] = is_in_melee_range(p, t) ? 1.0f : 0.0f; + + obs[34] = get_relative_level_combat(p->current_strength, p->base_strength); + obs[35] = get_relative_level_combat(p->current_attack, p->base_attack); + obs[36] = get_relative_level_combat(p->current_defence, p->base_defence); + obs[37] = get_relative_level_ranged(p->current_ranged, p->base_ranged); + obs[38] = get_relative_level_magic(p->current_magic, p->base_magic); + + obs[39] = (float)p->attack_timer; + obs[40] = (float)remaining_ticks(p->food_timer); + obs[41] = (float)remaining_ticks(p->potion_timer); + obs[42] = (float)remaining_ticks(p->karambwan_timer); + + int attack_delay = get_attack_timer_uncapped(p) - 1; + if (attack_delay < -3) attack_delay = -3; + else if (attack_delay > 0) attack_delay = 0; + obs[43] = (float)(attack_delay + 3); + + obs[44] = (float)remaining_ticks(t->attack_timer); + obs[45] = (float)remaining_ticks(t->food_timer); + + // Copy remaining base observations (46-170) + int pending_damage = 0; + for (int i = 0; i < p->num_pending_hits; i++) { + pending_damage += p->pending_hits[i].damage; + } + obs[46] = (float)pending_damage / (float)t->base_hitpoints; + + int ticks_until_hit_on_target = get_ticks_until_next_hit(p); + int ticks_until_hit_on_player = get_ticks_until_next_hit(t); + obs[47] = (float)ticks_until_hit_on_target; + obs[48] = (float)ticks_until_hit_on_player; + + obs[49] = p->just_attacked ? 1.0f : 0.0f; + obs[50] = t->just_attacked ? 1.0f : 0.0f; + + obs[51] = p->tick_damage_scale; + obs[52] = p->damage_received_scale; + obs[53] = p->damage_dealt_scale; + + obs[54] = (p->last_attack_style != ATTACK_STYLE_NONE) ? 1.0f : 0.0f; + obs[55] = p->is_moving ? 1.0f : 0.0f; + obs[56] = t->is_moving ? 1.0f : 0.0f; + + obs[57] = (agent_idx == env->pid_holder) ? 1.0f : 0.0f; + + obs[58] = (!p->is_lunar_spellbook && p->current_magic >= 94) ? 1.0f : 0.0f; + obs[59] = (!p->is_lunar_spellbook && p->current_magic >= 92) ? 1.0f : 0.0f; + + int dist = chebyshev_distance(p->x, p->y, t->x, t->y); + int destination_distance = p->is_moving + ? chebyshev_distance(p->dest_x, p->dest_y, t->x, t->y) : dist; + int distance_to_destination = p->is_moving + ? chebyshev_distance(p->x, p->y, p->dest_x, p->dest_y) : 0; + + if (destination_distance > 7) destination_distance = 7; + if (distance_to_destination > 7) distance_to_destination = 7; + if (dist > 7) dist = 7; + + obs[60] = (float)destination_distance; + obs[61] = (float)distance_to_destination; + obs[62] = (float)dist; + + obs[63] = p->player_prayed_correct ? 1.0f : 0.0f; + obs[64] = p->target_prayed_correct ? 1.0f : 0.0f; + + float damage_scale = (p->total_damage_dealt + 1.0f) / (p->total_damage_received + 1.0f); + obs[65] = clampf(damage_scale, 0.5f, 2.0f); + + // Combat history (66-95) - condensed for slot mode + obs[66] = confidence_scale(p->total_target_hit_count); + obs[67] = ratio_or_zero(p->target_hit_melee_count, p->total_target_hit_count); + obs[68] = ratio_or_zero(p->target_hit_magic_count, p->total_target_hit_count); + obs[69] = ratio_or_zero(p->target_hit_ranged_count, p->total_target_hit_count); + obs[70] = ratio_or_zero(p->player_hit_melee_count, p->total_target_pray_count); + obs[71] = ratio_or_zero(p->player_hit_magic_count, p->total_target_pray_count); + obs[72] = ratio_or_zero(p->player_hit_ranged_count, p->total_target_pray_count); + obs[73] = ratio_or_zero(p->target_hit_correct_count, p->total_target_hit_count); + obs[74] = confidence_scale(p->total_target_pray_count); + obs[75] = ratio_or_zero(p->target_pray_magic_count, p->total_target_pray_count); + obs[76] = ratio_or_zero(p->target_pray_ranged_count, p->total_target_pray_count); + obs[77] = ratio_or_zero(p->target_pray_melee_count, p->total_target_pray_count); + obs[78] = ratio_or_zero(p->player_pray_magic_count, p->total_target_hit_count); + obs[79] = ratio_or_zero(p->player_pray_ranged_count, p->total_target_hit_count); + obs[80] = ratio_or_zero(p->player_pray_melee_count, p->total_target_hit_count); + obs[81] = ratio_or_zero(p->target_pray_correct_count, p->total_target_pray_count); + + // Recent attack history (82-95) + int recent_target_hit_melee = 0, recent_target_hit_magic = 0, recent_target_hit_ranged = 0; + int recent_player_hit_melee = 0, recent_player_hit_magic = 0, recent_player_hit_ranged = 0; + int recent_target_pray_magic = 0, recent_target_pray_ranged = 0, recent_target_pray_melee = 0; + int recent_player_pray_magic = 0, recent_player_pray_ranged = 0, recent_player_pray_melee = 0; + int recent_target_hit_correct = 0, recent_target_pray_correct = 0; + + for (int i = 0; i < HISTORY_SIZE; i++) { + if (p->recent_target_attack_styles[i] == ATTACK_STYLE_MELEE) recent_target_hit_melee++; + else if (p->recent_target_attack_styles[i] == ATTACK_STYLE_MAGIC) recent_target_hit_magic++; + else if (p->recent_target_attack_styles[i] == ATTACK_STYLE_RANGED) recent_target_hit_ranged++; + + if (p->recent_player_attack_styles[i] == ATTACK_STYLE_MELEE) recent_player_hit_melee++; + else if (p->recent_player_attack_styles[i] == ATTACK_STYLE_MAGIC) recent_player_hit_magic++; + else if (p->recent_player_attack_styles[i] == ATTACK_STYLE_RANGED) recent_player_hit_ranged++; + + if (p->recent_target_prayer_styles[i] == ATTACK_STYLE_MAGIC) recent_target_pray_magic++; + else if (p->recent_target_prayer_styles[i] == ATTACK_STYLE_RANGED) recent_target_pray_ranged++; + else if (p->recent_target_prayer_styles[i] == ATTACK_STYLE_MELEE) recent_target_pray_melee++; + + if (p->recent_player_prayer_styles[i] == ATTACK_STYLE_MAGIC) recent_player_pray_magic++; + else if (p->recent_player_prayer_styles[i] == ATTACK_STYLE_RANGED) recent_player_pray_ranged++; + else if (p->recent_player_prayer_styles[i] == ATTACK_STYLE_MELEE) recent_player_pray_melee++; + + if (p->recent_target_hit_correct[i]) recent_target_hit_correct++; + if (p->recent_target_prayer_correct[i]) recent_target_pray_correct++; + } + + obs[82] = (float)recent_target_hit_melee / (float)HISTORY_SIZE; + obs[83] = (float)recent_target_hit_magic / (float)HISTORY_SIZE; + obs[84] = (float)recent_target_hit_ranged / (float)HISTORY_SIZE; + obs[85] = (float)recent_player_hit_melee / (float)HISTORY_SIZE; + obs[86] = (float)recent_player_hit_magic / (float)HISTORY_SIZE; + obs[87] = (float)recent_player_hit_ranged / (float)HISTORY_SIZE; + obs[88] = (float)recent_target_hit_correct / (float)HISTORY_SIZE; + obs[89] = (float)recent_target_pray_magic / (float)HISTORY_SIZE; + obs[90] = (float)recent_target_pray_ranged / (float)HISTORY_SIZE; + obs[91] = (float)recent_target_pray_melee / (float)HISTORY_SIZE; + obs[92] = (float)recent_player_pray_magic / (float)HISTORY_SIZE; + obs[93] = (float)recent_player_pray_ranged / (float)HISTORY_SIZE; + obs[94] = (float)recent_player_pray_melee / (float)HISTORY_SIZE; + obs[95] = (float)recent_target_pray_correct / (float)HISTORY_SIZE; + + // Base stats (96-102) + obs[96] = (float)p->base_attack; + obs[97] = (float)p->base_strength; + obs[98] = (float)p->base_defence; + obs[99] = (float)p->base_ranged; + obs[100] = (float)p->base_magic; + obs[101] = (float)p->base_prayer; + obs[102] = (float)p->base_hitpoints; + + // Spec weapon info (103-118) - same as base + int melee_spec_cost = get_melee_spec_cost(p->melee_spec_weapon); + obs[103] = (p->melee_spec_weapon == MELEE_SPEC_NONE) ? 0.5f : (float)melee_spec_cost / 100.0f; + obs[104] = get_melee_spec_str_mult(p->melee_spec_weapon); + obs[105] = get_melee_spec_acc_mult(p->melee_spec_weapon); + + int melee_hit_count = (p->melee_spec_weapon == MELEE_SPEC_DRAGON_CLAWS) ? 4 : + (p->melee_spec_weapon == MELEE_SPEC_DRAGON_DAGGER || + p->melee_spec_weapon == MELEE_SPEC_ABYSSAL_DAGGER) ? 2 : 1; + obs[106] = (float)melee_hit_count; + obs[107] = (p->melee_spec_weapon == MELEE_SPEC_VOIDWAKER) ? 1.0f : 0.0f; + obs[108] = (p->melee_spec_weapon == MELEE_SPEC_DWH || + p->melee_spec_weapon == MELEE_SPEC_BGS) ? 1.0f : 0.0f; + obs[109] = (p->melee_spec_weapon == MELEE_SPEC_GRANITE_MAUL) ? 1.0f : 0.0f; + + int ranged_spec_cost = get_ranged_spec_cost(p->ranged_spec_weapon); + obs[110] = (p->ranged_spec_weapon == RANGED_SPEC_NONE) ? 0.5f : (float)ranged_spec_cost / 100.0f; + obs[111] = get_ranged_spec_str_mult(p->ranged_spec_weapon); + obs[112] = get_ranged_spec_acc_mult(p->ranged_spec_weapon); + obs[113] = p->bolt_proc_damage; + obs[114] = p->bolt_ignores_defense ? 1.0f : 0.0f; + + obs[115] = (p->magic_spec_weapon != MAGIC_SPEC_NONE) ? 1.0f : 0.0f; + obs[116] = (p->ranged_spec_weapon != RANGED_SPEC_NONE) ? 1.0f : 0.0f; + obs[117] = p->has_blood_fury ? 1.0f : 0.0f; + obs[118] = p->has_dharok ? 1.0f : 0.0f; + + // Slot-based gear bonuses (119-129) using current equipped items + GearBonuses* slot_bonuses = get_slot_gear_bonuses(p); + obs[119] = (float)slot_bonuses->magic_attack; + obs[120] = (float)slot_bonuses->magic_strength; + obs[121] = (float)slot_bonuses->ranged_attack; + obs[122] = (float)slot_bonuses->ranged_strength; + obs[123] = (float)slot_bonuses->attack_speed; + obs[124] = (float)slot_bonuses->attack_range; + obs[125] = (float)slot_bonuses->slash_attack; + obs[126] = (float)slot_bonuses->melee_strength; + obs[127] = (float)slot_bonuses->ranged_defence; + obs[128] = (float)slot_bonuses->magic_defence; + obs[129] = (float)slot_bonuses->slash_defence; + + // Target visible gear defences (130-132) - computed from actual equipped items + GearBonuses* target_bonuses = get_slot_gear_bonuses(t); + obs[130] = (float)target_bonuses->ranged_defence; + obs[131] = (float)target_bonuses->magic_defence; + obs[132] = (float)target_bonuses->slash_defence; + + // Game mode flags and ability checks (133-148) + obs[133] = env->is_lms ? 1.0f : 0.0f; + obs[134] = env->is_pvp_arena ? 1.0f : 0.0f; + obs[135] = p->veng_active ? 1.0f : 0.0f; + obs[136] = t->veng_active ? 1.0f : 0.0f; + obs[137] = p->is_lunar_spellbook ? 1.0f : 0.0f; + obs[138] = p->observed_target_lunar_spellbook ? 1.0f : 0.0f; + obs[139] = (float)remaining_ticks(p->veng_cooldown); + obs[140] = (float)remaining_ticks(t->veng_cooldown); + obs[141] = is_blood_attack_available(p) ? 1.0f : 0.0f; + obs[142] = is_ice_attack_available(p) ? 1.0f : 0.0f; + obs[143] = can_toggle_spec(p) ? 1.0f : 0.0f; + obs[144] = is_ranged_attack_available(p) ? 1.0f : 0.0f; + obs[145] = is_ranged_spec_attack_available(p) ? 1.0f : 0.0f; + obs[146] = is_melee_attack_available(p, t) ? 1.0f : 0.0f; + obs[147] = is_melee_spec_attack_available(p, t) ? 1.0f : 0.0f; + obs[148] = (p->brew_doses > 0) ? 0.8f : 0.0f; + + // Attack timer ready boolean (149) - precomputed for agent convenience + obs[149] = (p->attack_timer <= 0) ? 1.0f : 0.0f; + + // Slot mode specific features (150-181) + obs[150] = (float)p->equipped[GEAR_SLOT_WEAPON] / 63.0f; // normalized weapon index + // actual attack style used THIS TICK (not current weapon) + obs[151] = (p->attack_style_this_tick == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[152] = (p->attack_style_this_tick == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[153] = (p->attack_style_this_tick == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + + AttackStyle target_style = get_slot_weapon_attack_style(t); + obs[154] = (target_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[155] = (target_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[156] = (target_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + + obs[157] = (p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) ? 1.0f : 0.0f; + obs[158] = (p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) ? 1.0f : 0.0f; + obs[159] = (p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY) ? 1.0f : 0.0f; + + // Current equipped gear per slot (160-170 = 11 slots) + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + obs[160 + slot] = (float)p->equipped[slot] / 63.0f; + } + + // Target equipped gear per slot (171-181) + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + obs[171 + slot] = (float)t->equipped[slot] / 63.0f; + } + + // Per-slot item stats for DYNAMIC gear slots (182-325) + // 8 dynamic slots (weapon, shield, body, legs, head, cape, neck, ring) x 18 stats = 144 features + int obs_idx = 182; + for (int i = 0; i < NUM_DYNAMIC_GEAR_SLOTS; i++) { + int slot = DYNAMIC_GEAR_SLOTS[i]; + uint8_t item = p->equipped[slot]; + float item_stats[NUM_ITEM_STATS]; + get_item_stats_normalized(item, item_stats); + memcpy(&obs[obs_idx], item_stats, NUM_ITEM_STATS * sizeof(float)); + obs_idx += NUM_ITEM_STATS; + } + + // Voidwaker magic damage flag (326) + // Tells agent: "my best melee spec bypasses melee prayer — hit when they're NOT praying magic" + uint8_t best_mspec = find_best_melee_spec(p); + obs[326] = (best_mspec == ITEM_VOIDWAKER) ? 1.0f : 0.0f; + + // Reward shaping signals (327-333) + // These track actions that happened THIS tick for accurate reward attribution + obs[327] = p->used_special_this_tick ? 1.0f : 0.0f; + obs[328] = p->ate_food_this_tick ? 1.0f : 0.0f; + obs[329] = p->ate_karambwan_this_tick ? 1.0f : 0.0f; + // Current weapon style (for magic-without-staff penalty) + AttackStyle current_weapon_style = get_slot_weapon_attack_style(p); + obs[330] = (current_weapon_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[331] = (current_weapon_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[332] = (current_weapon_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[333] = p->ate_brew_this_tick ? 1.0f : 0.0f; +} + +/** + * Compute action masks for loadout-based action space. + * + * Writes ACTION_MASK_SIZE (40) bytes: one per action value across all heads. + * mask[i] = 1 if action is valid, 0 if invalid. + */ +static void compute_action_masks(OsrsPvp* env, int agent_idx) { + Player* p = &env->players[agent_idx]; + Player* t = &env->players[1 - agent_idx]; + + unsigned char* mask = env->action_masks + agent_idx * ACTION_MASK_SIZE; + int offset = 0; + + // LOADOUT head (9 options: KEEP, MELEE, RANGE, MAGE, TANK, SPEC_MELEE, SPEC_RANGE, SPEC_MAGIC, GMAUL) + mask[offset + LOADOUT_KEEP] = 1; + // Non-spec loadouts: mask if already active + for (int l = LOADOUT_MELEE; l <= LOADOUT_TANK; l++) { + mask[offset + l] = is_loadout_active(p, l) ? 0 : 1; + } + + int attack_timer_ready = (remaining_ticks(p->attack_timer) == 0); + int frozen_no_melee = !can_move(p) && !is_in_melee_range(p, t); + + // SPEC_MELEE: available if melee spec weapon exists + enough energy + timer ready + uint8_t best_melee_spec = find_best_melee_spec(p); + int melee_spec_cost = 25; // Most melee specs cost 25% (DDS, claws, VLS, etc.) + if (best_melee_spec == ITEM_AGS || best_melee_spec == ITEM_ANCIENT_GS) melee_spec_cost = 50; + if (best_melee_spec == ITEM_STATIUS_WARHAMMER) melee_spec_cost = 35; + mask[offset + LOADOUT_SPEC_MELEE] = (best_melee_spec != ITEM_NONE) && + (p->special_energy >= melee_spec_cost) && attack_timer_ready && !frozen_no_melee; + + // SPEC_RANGE: available if ranged spec weapon exists + enough energy + timer ready + uint8_t best_range_spec = find_best_ranged_spec(p); + int range_spec_cost = 50; // Most ranged specs cost 50-60% + mask[offset + LOADOUT_SPEC_RANGE] = (best_range_spec != ITEM_NONE) && + (p->special_energy >= range_spec_cost) && attack_timer_ready; + + // SPEC_MAGIC: available if magic spec weapon (volatile) exists + enough energy + timer ready + uint8_t best_magic_spec = find_best_magic_spec(p); + mask[offset + LOADOUT_SPEC_MAGIC] = (best_magic_spec != ITEM_NONE) && + (p->special_energy >= 55) && attack_timer_ready; // Volatile costs 55% + + // GMAUL: available if granite maul in inventory + enough energy, NO timer requirement (instant) + mask[offset + LOADOUT_GMAUL] = player_has_gmaul(p) && + (p->special_energy >= 50) && !frozen_no_melee; + + // Mask MELEE when frozen and out of melee range + if (frozen_no_melee) { + mask[offset + LOADOUT_MELEE] = 0; + } + offset += LOADOUT_DIM; + + // COMBAT head (13 options: attacks 1-3, movement 4-12, idle 0) + int attack_ready = remaining_ticks(p->attack_timer) == 0; + int current_loadout = get_current_loadout(p); + int in_mage_loadout = (current_loadout == LOADOUT_MAGE); + int in_tank_loadout = (current_loadout == LOADOUT_TANK); + int weapon_style = get_slot_weapon_attack_style(p); + int melee_reachable = (weapon_style == ATTACK_STYLE_MELEE) + ? (is_in_melee_range(p, t) || can_move(p)) + : 1; + int can_move_now = can_move(p); + mask[offset + ATTACK_NONE] = 1; // NONE = idle (always valid) + mask[offset + ATTACK_ATK] = attack_ready && !in_mage_loadout && !in_tank_loadout && + weapon_style != ATTACK_STYLE_NONE && + melee_reachable; + mask[offset + ATTACK_ICE] = attack_ready && can_cast_ice_spell(p); + mask[offset + ATTACK_BLOOD] = attack_ready && can_cast_blood_spell(p); + const CollisionMap* cmap = (const CollisionMap*)env->collision_map; + mask[offset + MOVE_ADJACENT] = can_move_now && can_move_adjacent(p, t, cmap); + mask[offset + MOVE_UNDER] = can_move_now && can_move_under(p, t); + mask[offset + MOVE_DIAGONAL] = can_move_now && can_move_diagonal(p, t, cmap); + mask[offset + MOVE_FARCAST_2] = can_move_now && can_move_to_farcast(p, t, 2, cmap); + mask[offset + MOVE_FARCAST_3] = can_move_now && can_move_to_farcast(p, t, 3, cmap); + mask[offset + MOVE_FARCAST_4] = can_move_now && can_move_to_farcast(p, t, 4, cmap); + mask[offset + MOVE_FARCAST_5] = can_move_now && can_move_to_farcast(p, t, 5, cmap); + mask[offset + MOVE_FARCAST_6] = can_move_now && can_move_to_farcast(p, t, 6, cmap); + mask[offset + MOVE_FARCAST_7] = can_move_now && can_move_to_farcast(p, t, 7, cmap); + offset += COMBAT_DIM; + + // OVERHEAD head (6 options) + int has_prayer = p->current_prayer > 0; + mask[offset + OVERHEAD_NONE] = 1; + mask[offset + OVERHEAD_MAGE] = has_prayer && (p->prayer != PRAYER_PROTECT_MAGIC); + mask[offset + OVERHEAD_RANGED] = has_prayer && (p->prayer != PRAYER_PROTECT_RANGED); + mask[offset + OVERHEAD_MELEE] = has_prayer && (p->prayer != PRAYER_PROTECT_MELEE); + mask[offset + OVERHEAD_SMITE] = has_prayer && !env->is_lms && (p->prayer != PRAYER_SMITE); + mask[offset + OVERHEAD_REDEMPTION] = has_prayer && !env->is_lms && (p->prayer != PRAYER_REDEMPTION); + offset += OVERHEAD_DIM; + + // FOOD head (2 options) + mask[offset + FOOD_NONE] = 1; + mask[offset + FOOD_EAT] = can_eat_food(p); + offset += FOOD_DIM; + + // POTION head (5 options) + mask[offset + POTION_NONE] = 1; + mask[offset + POTION_BREW] = can_use_potion(p, 1) && can_use_brew_boost(p); + mask[offset + POTION_RESTORE] = can_use_potion(p, 2) && can_restore_stats(p); + mask[offset + POTION_COMBAT] = can_use_potion(p, 3) && can_boost_combat_skills(p); + mask[offset + POTION_RANGED] = can_use_potion(p, 4) && can_boost_ranged(p); + offset += POTION_DIM; + + // KARAMBWAN head (2 options) + mask[offset + KARAM_NONE] = 1; + mask[offset + KARAM_EAT] = can_eat_karambwan(p); + offset += KARAMBWAN_DIM; + + // VENG head (2 options) + mask[offset + VENG_NONE] = 1; + mask[offset + VENG_CAST] = !env->is_lms && p->is_lunar_spellbook && !p->veng_active && + (remaining_ticks(p->veng_cooldown) == 0) && p->current_magic >= 94; + offset += VENG_DIM; +} + +#endif // OSRS_PVP_OBSERVATIONS_H diff --git a/ocean/osrs/osrs_pvp_opponents.h b/ocean/osrs/osrs_pvp_opponents.h new file mode 100644 index 0000000000..b570e599e6 --- /dev/null +++ b/ocean/osrs/osrs_pvp_opponents.h @@ -0,0 +1,3668 @@ +/** + * @fileoverview Scripted opponent policies implemented in C. + * + * Ports the Python opponent policies (opponents/ *.py) to C for use within + * c_step(). Eliminates the Python round-trip for opponent action + * generation during training with scripted opponents. + * + * Opponent reads game state directly from Player structs instead of parsing + * observation arrays, which is both faster and avoids float normalization. + * + * Actions are direct head-value assignments: int actions[NUM_ACTION_HEADS]. + * Gear switches use loadout presets (LOADOUT_MELEE, LOADOUT_RANGE, etc.) + * instead of per-slot equip actions. + * + * Phase 1 policies: TrueRandom, Panicking, WeakRandom, SemiRandom, + * StickyPrayer, Beginner, BetterRandom, Improved. + * Phase 2 policies: Onetick, UnpredictableImproved, UnpredictableOnetick. + * Mixed wrappers: MixedEasy, MixedMedium, MixedHard, MixedHardBalanced. + */ + +#ifndef OSRS_PVP_OPPONENTS_H +#define OSRS_PVP_OPPONENTS_H + +/* This header is included from osrs_pvp.h AFTER all other headers, + * so osrs_types.h, osrs_items.h (via gear.h), and + * osrs_pvp_actions.h are already available. */ + +/* OpponentType enum and OpponentState struct are in osrs_types.h */ + +/* Attack style enum for opponent internal use */ +#define OPP_STYLE_MAGE 0 +#define OPP_STYLE_RANGED 1 +#define OPP_STYLE_MELEE 2 +#define OPP_STYLE_SPEC 3 + +/* ========================================================================= + * Utility: map OPP_STYLE_* to LOADOUT_* presets + * ========================================================================= */ + +static inline int opp_style_to_loadout(int style) { + switch (style) { + case OPP_STYLE_MAGE: return LOADOUT_MAGE; + case OPP_STYLE_RANGED: return LOADOUT_RANGE; + case OPP_STYLE_MELEE: return LOADOUT_MELEE; + case OPP_STYLE_SPEC: return LOADOUT_SPEC_MELEE; + default: return LOADOUT_KEEP; + } +} + +static inline void opp_apply_gear_switch(int* actions, int style) { + actions[HEAD_LOADOUT] = opp_style_to_loadout(style); +} + +/* Fake switch: same loadout set, no attack action follows */ +static inline void opp_apply_fake_switch(int* actions, int style) { + actions[HEAD_LOADOUT] = opp_style_to_loadout(style); +} + +/* Tank gear: LOADOUT_TANK equips dhide body, rune legs, spirit shield */ +static inline void opp_apply_tank_gear(int* actions) { + actions[HEAD_LOADOUT] = LOADOUT_TANK; +} + +/* ========================================================================= + * Consumable availability helpers + * ========================================================================= */ + +typedef struct { + int can_food; + int can_brew; + int can_karambwan; + int can_restore; + int can_combat_pot; + int can_ranged_pot; +} OppConsumables; + +static inline void opp_tick_cooldowns(OpponentState* opp) { + if (opp->food_cooldown > 0) opp->food_cooldown--; + if (opp->potion_cooldown > 0) opp->potion_cooldown--; + if (opp->karambwan_cooldown > 0) opp->karambwan_cooldown--; +} + +static inline OppConsumables opp_get_consumables(OpponentState* opp, Player* self) { + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + OppConsumables c; + c.can_food = (opp->food_cooldown <= 0 && self->food_count > 0 && hp_pct < 1.0f); + c.can_brew = (opp->potion_cooldown <= 0 && self->brew_doses > 0); + c.can_karambwan = (opp->karambwan_cooldown <= 0 && self->karambwan_count > 0 && hp_pct < 1.0f); + c.can_restore = (opp->potion_cooldown <= 0 && self->restore_doses > 0); + c.can_combat_pot = (opp->potion_cooldown <= 0 && self->combat_potion_doses > 0); + c.can_ranged_pot = (opp->potion_cooldown <= 0 && self->ranged_potion_doses > 0); + return c; +} + +/* (opp_apply_gear_switch is defined above as inline loadout assignment) */ + +/* ========================================================================= + * Prayer helpers + * ========================================================================= */ + +static inline AttackStyle opp_get_gear_style(Player* p) { + int s = get_item_attack_style(p->equipped[GEAR_SLOT_WEAPON]); + if (s == 3) return ATTACK_STYLE_MAGIC; + if (s == 2) return ATTACK_STYLE_RANGED; + if (s == 1) return ATTACK_STYLE_MELEE; + return ATTACK_STYLE_MAGIC; /* Default */ +} + +static inline int opp_get_defensive_prayer(Player* target) { + AttackStyle target_style = opp_get_gear_style(target); + if (target_style == ATTACK_STYLE_MAGIC) return OVERHEAD_MAGE; + if (target_style == ATTACK_STYLE_RANGED) return OVERHEAD_RANGED; + if (target_style == ATTACK_STYLE_MELEE) return OVERHEAD_MELEE; + return OVERHEAD_MAGE; /* Default to mage */ +} + +static inline int opp_has_prayer_active(Player* self, int prayer_action) { + if (prayer_action == OVERHEAD_MELEE) return self->prayer == PRAYER_PROTECT_MELEE; + if (prayer_action == OVERHEAD_RANGED) return self->prayer == PRAYER_PROTECT_RANGED; + if (prayer_action == OVERHEAD_MAGE) return self->prayer == PRAYER_PROTECT_MAGIC; + return 0; +} + +/* ========================================================================= + * Attack style helpers + * ========================================================================= */ + +static inline int opp_attack_ready(Player* self) { + return self->attack_timer <= 0; +} + +static inline int opp_can_reach_melee(Player* self, Player* target) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + return dist <= 1 || (self->frozen_ticks == 0 && dist <= 5); +} + +/** + * Get off-prayer attack styles (styles target isn't protecting). + * Returns bitmask: bit 0 = mage, bit 1 = ranged, bit 2 = melee + */ +static inline int opp_get_off_prayer_mask(Player* self, Player* target) { + int mask = 0; + if (target->prayer != PRAYER_PROTECT_MAGIC) mask |= (1 << OPP_STYLE_MAGE); + if (target->prayer != PRAYER_PROTECT_RANGED) mask |= (1 << OPP_STYLE_RANGED); + if (target->prayer != PRAYER_PROTECT_MELEE && opp_can_reach_melee(self, target)) + mask |= (1 << OPP_STYLE_MELEE); + if (mask == 0) mask = (1 << OPP_STYLE_MAGE); /* Fallback to mage */ + return mask; +} + +static inline int opp_pick_from_mask(OsrsPvp* env, int mask) { + /* Count set bits and pick random */ + int choices[3]; + int count = 0; + for (int i = 0; i < 3; i++) { + if (mask & (1 << i)) choices[count++] = i; + } + return choices[rand_int(env, count)]; +} + +static inline int opp_is_drained(Player* self) { + // Any combat stat below base = drained (brew drain, SWH, etc.) + return self->current_strength < self->base_strength || + self->current_attack < self->base_attack || + self->current_defence < self->base_defence || + self->current_ranged < self->base_ranged || + self->current_magic < self->base_magic; +} + +static inline int opp_should_fc3(Player* self, Player* target) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + return target->freeze_immunity_ticks > 1 && + self->frozen_ticks == 0 && + self->attack_timer <= 2 && + dist > 3; +} + +/* Anti-kite: update flee tracking based on distance trend */ +static inline void opp_update_flee_tracking(OpponentState* opp, Player* self, Player* target) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (dist > opp->prev_dist_to_target && dist > 1) { + opp->target_fleeing_ticks++; + } else { + opp->target_fleeing_ticks = 0; + } + opp->prev_dist_to_target = dist; +} + +/* ========================================================================= + * Per-episode randomization: ranges table for all opponent types + * ========================================================================= */ + +typedef struct { float base; float variance; } RandRange; + +typedef struct { + RandRange prayer_accuracy; + RandRange off_prayer_rate; + RandRange offensive_prayer_rate; + RandRange action_delay_chance; + RandRange mistake_rate; + RandRange offensive_prayer_miss; /* chance to attack without loadout switch (skips auto-prayer) */ +} OpponentRandRanges; + +#define RR(b, v) {(b), (v)} + +/* pray_acc off_pray off_pray_r act_delay mistake off_pray_miss */ +static const OpponentRandRanges OPP_RAND_RANGES[OPP_RANGE_KITER + 1] = { + [OPP_NONE] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_TRUE_RANDOM] = { RR(0.33,0), RR(0.33,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_PANICKING] = { RR(0.33,0.1), RR(0.33,0), RR(0,0), RR(0.10,0.05), RR(0,0), RR(0,0) }, + [OPP_WEAK_RANDOM] = { RR(0.40,0.1), RR(0.33,0.1), RR(0,0), RR(0.10,0.05), RR(0.05,0.03), RR(0,0) }, + [OPP_SEMI_RANDOM] = { RR(0.50,0.1), RR(0.40,0.1), RR(0.05,0.03),RR(0.08,0.04), RR(0.05,0.03), RR(0,0) }, + [OPP_STICKY_PRAYER] = { RR(0.33,0), RR(0.33,0), RR(0,0), RR(0.10,0.05), RR(0,0), RR(0,0) }, + [OPP_RANDOM_EATER] = { RR(0.40,0.1), RR(0.33,0.1), RR(0,0), RR(0.08,0.04), RR(0.05,0.03), RR(0,0) }, + [OPP_PRAYER_ROOKIE] = { RR(0.30,0.1), RR(0.20,0.1), RR(0,0), RR(0.12,0.05), RR(0.08,0.04), RR(0,0) }, + [OPP_IMPROVED] = { RR(0.95,0.05),RR(0.95,0.05),RR(0.80,0.10),RR(0.05,0.03), RR(0.03,0.02), RR(0.05,0.03) }, + [OPP_MIXED_EASY] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_MIXED_MEDIUM] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_ONETICK] = { RR(0.97,0.03),RR(0.97,0.03),RR(0.90,0.05),RR(0.03,0.02), RR(0.02,0.01), RR(0.03,0.02) }, + [OPP_UNPREDICTABLE_IMPROVED]= { RR(0.92,0.05),RR(0.90,0.05),RR(0.75,0.10),RR(0.08,0.04), RR(0.05,0.03), RR(0.08,0.04) }, + [OPP_UNPREDICTABLE_ONETICK] = { RR(0.95,0.03),RR(0.95,0.03),RR(0.85,0.08),RR(0.05,0.03), RR(0.03,0.02), RR(0.05,0.03) }, + [OPP_MIXED_HARD] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_MIXED_HARD_BALANCED] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_PFSP] = { RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0), RR(0,0) }, + [OPP_NOVICE_NH] = { RR(0.60,0.10),RR(0.10,0.05),RR(0.10,0.05),RR(0.15,0.05), RR(0.10,0.05), RR(0.30,0.10) }, + [OPP_APPRENTICE_NH] = { RR(0.60,0.10),RR(0.20,0.08),RR(0.20,0.08),RR(0.12,0.05), RR(0.08,0.04), RR(0.30,0.10) }, + [OPP_COMPETENT_NH] = { RR(0.75,0.08),RR(0.25,0.08),RR(0.25,0.08),RR(0.10,0.04), RR(0.06,0.03), RR(0.20,0.08) }, + [OPP_INTERMEDIATE_NH] = { RR(0.85,0.05),RR(0.70,0.08),RR(0.50,0.10),RR(0.08,0.04), RR(0.05,0.03), RR(0.20,0.08) }, + [OPP_ADVANCED_NH] = { RR(0.95,0.05),RR(0.90,0.05),RR(0.75,0.08),RR(0.05,0.03), RR(0.03,0.02), RR(0.10,0.05) }, + [OPP_PROFICIENT_NH] = { RR(0.95,0.03),RR(0.92,0.04),RR(0.80,0.08),RR(0.04,0.02), RR(0.03,0.02), RR(0.10,0.05) }, + [OPP_EXPERT_NH] = { RR(0.97,0.03),RR(0.95,0.03),RR(0.85,0.05),RR(0.03,0.02), RR(0.02,0.01), RR(0.10,0.05) }, + [OPP_MASTER_NH] = { RR(0.98,0.02),RR(0.97,0.03),RR(0.90,0.05),RR(0.02,0.01), RR(0.01,0.01), RR(0.01,0.01) }, + [OPP_SAVANT_NH] = { RR(0.98,0.02),RR(0.97,0.03),RR(0.90,0.05),RR(0.02,0.01), RR(0.01,0.01), RR(0.01,0.01) }, + [OPP_NIGHTMARE_NH] = { RR(0.99,0.01),RR(0.98,0.02),RR(0.95,0.03),RR(0.01,0.01), RR(0.005,0.005),RR(0.01,0.01) }, + [OPP_VENG_FIGHTER] = { RR(0.92,0.05),RR(0.90,0.05),RR(0.85,0.10),RR(0.03,0.02), RR(0.02,0.01), RR(0.05,0.03) }, + [OPP_BLOOD_HEALER] = { RR(0.90,0.05),RR(0.88,0.05),RR(0.80,0.10),RR(0.05,0.03), RR(0.04,0.02), RR(0.05,0.03) }, + [OPP_GMAUL_COMBO] = { RR(0.96,0.03),RR(0.95,0.03),RR(0.90,0.05),RR(0.03,0.02), RR(0.02,0.01), RR(0.02,0.01) }, + [OPP_RANGE_KITER] = { RR(0.93,0.04),RR(0.93,0.04),RR(0.85,0.08),RR(0.04,0.02), RR(0.03,0.02), RR(0.04,0.02) }, +}; + +#undef RR + +static inline float rand_range(OsrsPvp* env, RandRange r) { + float v = r.base + (rand_float(env) * 2.0f - 1.0f) * r.variance; + return v < 0.0f ? 0.0f : (v > 1.0f ? 1.0f : v); +} + +/* Tick-level action delay: skip prayer/attack/movement this tick (keep eating) */ +static inline int opp_should_skip_offensive(OsrsPvp* env, OpponentState* opp) { + return rand_float(env) < opp->action_delay_chance; +} + +/** + * Pick off-prayer attack style weighted by per-episode style bias. + * Uses style_bias[3] (mage/ranged/melee weights) to sample from the off-prayer mask. + * Falls back to uniform random if no bias styles are available off-prayer. + */ +static inline int opp_pick_off_prayer_style_biased(OsrsPvp* env, OpponentState* opp, + Player* self, Player* target) { + int off_mask = opp_get_off_prayer_mask(self, target); + float weights[3] = {0}; + float total = 0; + for (int i = 0; i < 3; i++) { + if (off_mask & (1 << i)) { + weights[i] = opp->style_bias[i]; + total += weights[i]; + } + } + if (total <= 0) return opp_pick_from_mask(env, off_mask); + + float r = rand_float(env) * total; + float cum = 0; + for (int i = 0; i < 3; i++) { + cum += weights[i]; + if (r < cum) return i; + } + return opp_pick_from_mask(env, off_mask); +} + +/* Prayer mistake: small chance to pick random prayer instead of optimal */ +static inline int opp_apply_prayer_mistake(OsrsPvp* env, OpponentState* opp, int correct_prayer) { + if (rand_float(env) < opp->mistake_rate) { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + return prayers[rand_int(env, 3)]; + } + return correct_prayer; +} + +/* ========================================================================= + * Phase 2: probability constants for unpredictable policies + * ========================================================================= */ + +/* unpredictable_improved prayer delays: 70% instant, 20% 1-tick, 8% 2-tick, 2% 3-tick */ +static const float UNPREDICTABLE_IMP_PRAYER_CUM[] = {0.70f, 0.90f, 0.98f, 1.00f}; +#define UNPREDICTABLE_IMP_PRAYER_CUM_LEN 4 + +/* unpredictable_improved action delays: 85% instant, 12% 1-tick, 3% 2-tick */ +static const float UNPREDICTABLE_IMP_ACTION_CUM[] = {0.85f, 0.97f, 1.00f}; +#define UNPREDICTABLE_IMP_ACTION_CUM_LEN 3 + +/* unpredictable_onetick prayer delays: 80% instant, 15% 1-tick, 4% 2-tick, 1% 3-tick */ +static const float UNPREDICTABLE_OT_PRAYER_CUM[] = {0.80f, 0.95f, 0.99f, 1.00f}; +#define UNPREDICTABLE_OT_PRAYER_CUM_LEN 4 + +/* unpredictable_onetick action delays: 90% instant, 8% 1-tick, 2% 2-tick */ +static const float UNPREDICTABLE_OT_ACTION_CUM[] = {0.90f, 0.98f, 1.00f}; +#define UNPREDICTABLE_OT_ACTION_CUM_LEN 3 + +/* mistake probabilities */ +#define UNPREDICTABLE_IMP_WRONG_PRAYER 0.05f +#define UNPREDICTABLE_IMP_SUBOPTIMAL_ATTACK 0.03f +#define UNPREDICTABLE_OT_FAKE_FAIL 0.12f +#define UNPREDICTABLE_OT_WRONG_PREDICT 0.08f + +/* ========================================================================= + * Phase 2: helper functions for onetick + unpredictable policies + * ========================================================================= */ + +/* Weighted delay sampling from cumulative weight array */ +static inline int opp_sample_delay(OsrsPvp* env, const float* cum_weights, int num_weights) { + float r = rand_float(env); + for (int i = 0; i < num_weights; i++) { + if (r < cum_weights[i]) return i; + } + return num_weights - 1; +} + +/* Defensive prayer based on visible gear (uses actual weapon damage type). */ +static inline int opp_get_defensive_prayer_with_spec(Player* target) { + if (target->visible_gear == GEAR_MELEE) return OVERHEAD_MELEE; + if (target->visible_gear == GEAR_RANGED) return OVERHEAD_RANGED; + if (target->visible_gear == GEAR_MAGE) return OVERHEAD_MAGE; + return opp_get_defensive_prayer(target); +} + +/* Get opponent's current prayer style as OPP_STYLE_* (-1 if none) */ +static inline int opp_get_opponent_prayer_style(Player* target) { + if (target->prayer == PRAYER_PROTECT_MAGIC) return OPP_STYLE_MAGE; + if (target->prayer == PRAYER_PROTECT_RANGED) return OPP_STYLE_RANGED; + if (target->prayer == PRAYER_PROTECT_MELEE) return OPP_STYLE_MELEE; + return -1; +} + +/* Get target's visible gear style as GearSet value. */ +static inline int opp_get_target_gear_style(Player* target) { + return (int)target->visible_gear; +} + +/* Choose ice vs blood barrage based on freeze state and HP */ +static inline int opp_get_mage_attack(Player* self, Player* target) { + int can_freeze = target->freeze_immunity_ticks <= 1 && target->frozen_ticks == 0; + if (can_freeze) return ATTACK_ICE; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + return (hp_pct > 0.98f) ? ATTACK_ICE : ATTACK_BLOOD; +} + +/* (opp_apply_tank_gear is defined above as inline loadout assignment) */ + +/* Boost/restore potion logic (before attack, used by onetick+ opponents) */ +static void opp_apply_boost_potion(OsrsPvp* env, OpponentState* opp, int* actions, + Player* self, int attack_style, int potion_used) { + if (potion_used) return; + if (opp->potion_cooldown > 0) return; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + /* If drained (brew drain / SWH) and HP safe, restore before boosting. + * 0.90 threshold ensures we finish brewing to full HP before restoring + * (one restore dose undoes ~3 brew doses of stat drain). */ + if (opp_is_drained(self) && hp_pct > 0.90f && self->restore_doses > 0) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + return; + } + + if (hp_pct <= 0.90f) return; /* eat/brew to 90%+ before boosting */ + + if (attack_style == OPP_STYLE_MELEE || attack_style == OPP_STYLE_SPEC) { + /* Boost when at or below base (covers brew-drained stats too) */ + if (self->current_strength <= self->base_strength && self->combat_potion_doses > 0) { + actions[HEAD_POTION] = POTION_COMBAT; + opp->potion_cooldown = 3; + } + } else if (attack_style == OPP_STYLE_RANGED) { + if (self->current_ranged <= self->base_ranged && self->ranged_potion_doses > 0) { + actions[HEAD_POTION] = POTION_RANGED; + opp->potion_cooldown = 3; + } + } +} + +/* Check if eating was queued in actions (food/karambwan cancel attacks) */ +static inline int opp_check_eating_queued(int* actions) { + return actions[HEAD_FOOD] != FOOD_NONE || actions[HEAD_KARAMBWAN] != KARAM_NONE; +} + +/* Improved-style consumable logic. Returns 1 if potion was used (for restore/boost tracking) */ +static int opp_apply_consumables(OsrsPvp* env, OpponentState* opp, int* actions, + Player* self) { + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + OppConsumables cons = opp_get_consumables(opp, self); + int potion_used = 0; + + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + /* Triple eat: shark + brew + karambwan */ + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; + opp->potion_cooldown = 3; + opp->karambwan_cooldown = 2; + potion_used = 1; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + /* Double eat: shark + brew */ + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; + opp->potion_cooldown = 3; + potion_used = 1; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + /* Double eat: shark + karambwan (no brew available) */ + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; + opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + /* Karambwan as fallback food (no sharks left) */ + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring (one restore undoes ~3 brews) */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + (void)env; + return potion_used; +} + +/* Process pending prayer delay: decrement, apply if ready. Returns 1 if applied. */ +static inline int opp_process_pending_prayer(OpponentState* opp, int* actions) { + if (opp->pending_prayer_value == 0) return 0; + if (opp->pending_prayer_delay > 0) { + opp->pending_prayer_delay--; + if (opp->pending_prayer_delay > 0) return 0; + } + /* Delay reached 0 or was already 0: apply */ + actions[HEAD_OVERHEAD] = opp->pending_prayer_value; + opp->pending_prayer_value = 0; + return 1; +} + +/* Handle prayer switch with delay for unpredictable policies. + * Detects target gear changes, samples delay, stores in pending state. + * include_spec: if 1, also detect spec weapon (onetick/unpredictable_onetick). */ +static void opp_handle_delayed_prayer(OsrsPvp* env, OpponentState* opp, int* actions, + Player* self, Player* target, + const float* cum_weights, int cum_len, + float wrong_prayer_prob, int include_spec) { + /* Detect target gear style change */ + int target_style = opp_get_target_gear_style(target); + if (target_style != opp->last_target_gear_style) { + opp->last_target_gear_style = target_style; + + /* Determine needed prayer */ + int needed_prayer = include_spec + ? opp_get_defensive_prayer_with_spec(target) + : opp_get_defensive_prayer(target); + + /* Check if we need to switch */ + int needs_switch = !opp_has_prayer_active(self, needed_prayer); + + if (needs_switch) { + /* Small chance to pick wrong prayer */ + if (rand_float(env) < wrong_prayer_prob) { + int wrong_options[2]; + int wcount = 0; + int all_prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + for (int i = 0; i < 3; i++) { + if (all_prayers[i] != needed_prayer) + wrong_options[wcount++] = all_prayers[i]; + } + needed_prayer = wrong_options[rand_int(env, wcount)]; + } + + int delay = opp_sample_delay(env, cum_weights, cum_len); + opp->pending_prayer_value = needed_prayer; + opp->pending_prayer_delay = delay; + } + } + + /* Process pending prayer (may apply this tick if delay=0) */ + opp_process_pending_prayer(opp, actions); +} + +/* (click budget no longer needed — direct action heads have fixed count) */ + +/* ========================================================================= + * Policy implementations + * ========================================================================= */ + +/* --- TrueRandom: random value per action head --- */ +static void opp_true_random(OsrsPvp* env, int* actions) { + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + actions[i] = rand_int(env, ACTION_HEAD_DIMS[i]); + } +} + +/* --- Panicking: fixed prayer, fixed style, 30% attack chance, panic eat --- */ +static void opp_panicking(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* Set prayer if not already active */ + if (!opp_has_prayer_active(self, opp->chosen_prayer)) { + actions[HEAD_OVERHEAD] = opp->chosen_prayer; + } + + /* Panic eat at 25% HP */ + int eating = 0; + if (hp_pct < 0.25f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } + if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 30% chance to attack */ + if (opp_attack_ready(self) && !eating && rand_float(env) < 0.30f) { + opp_apply_gear_switch(actions, opp->chosen_style); + + if (opp->chosen_style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- WeakRandom: random style, unreliable eating (50% skip) --- */ +static void opp_weak_random(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* Random prayer each tick (includes NONE) */ + int prayers[] = {OVERHEAD_NONE, OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + actions[HEAD_OVERHEAD] = prayers[rand_int(env, 4)]; + + /* Unreliable eating at 30% with 50% skip chance */ + int eating = 0; + if (hp_pct < 0.30f && rand_float(env) > 0.50f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + eating = 1; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* Random attack when ready */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); /* 0=mage, 1=ranged, 2=melee */ + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- SemiRandom: reliable eating at 30%, random everything else --- */ +static void opp_semi_random(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* Random prayer each tick (no NONE) */ + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + actions[HEAD_OVERHEAD] = prayers[rand_int(env, 3)]; + + /* Reliable eating at 30% */ + int eating = 0; + if (hp_pct < 0.30f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + eating = 1; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* Random attack when ready */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- StickyPrayer: sticky prayer (~12 tick avg), simple eating --- */ +static void opp_sticky_prayer(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* Sticky prayer: 8% chance to switch per tick (~12 tick avg) */ + if (!opp->current_prayer_set || rand_float(env) < 0.08f) { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + opp->current_prayer = prayers[rand_int(env, 3)]; + opp->current_prayer_set = 1; + } + if (!opp_has_prayer_active(self, opp->current_prayer)) { + actions[HEAD_OVERHEAD] = opp->current_prayer; + } + + /* Simple eating at 30% */ + int eating = 0; + if (hp_pct < 0.30f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + eating = 1; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + eating = 1; + } + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* Random attack when ready */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* --- Beginner: sticky prayer, multi-threshold eating, random spec --- */ +static void opp_random_eater(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Sticky random prayer */ + if (!opp->current_prayer_set || rand_float(env) < 0.08f) { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + opp->current_prayer = prayers[rand_int(env, 3)]; + opp->current_prayer_set = 1; + } + if (!opp_has_prayer_active(self, opp->current_prayer)) { + actions[HEAD_OVERHEAD] = opp->current_prayer; + } + + /* 2. Multi-threshold eating */ + int potion_used = 0; + if (hp_pct < 0.35f) { + /* Emergency: eat everything */ + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } + if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } + if (cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } + } else if (hp_pct < 0.55f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + potion_used = 1; + } + + /* 3. Restore if low prayer */ + if (!potion_used && prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 4. Attack when ready with random style */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + + /* 30% spec chance */ + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.30f) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } + + (void)target; +} + +/* --- BetterRandom: multi-threshold eating, random prayers, random spec --- */ +static void opp_prayer_rookie(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + actions[HEAD_OVERHEAD] = def_prayer; + + /* 2. Multi-threshold eating */ + if (hp_pct < 0.35f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } + if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + if (cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } + } else if (hp_pct < 0.55f) { + if (cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack with random style, random spec chance */ + if (opp_attack_ready(self) && !eating) { + int style = rand_int(env, 3); + + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.30f) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + opp_apply_gear_switch(actions, style); + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } +} + +/* --- Improved: full NH (correct prayer, off-prayer attacks, combo eating, + spec timing, offensive prayer, movement) --- */ +static void opp_improved(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer based on target's weapon */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Consumables: triple/double/single eat */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; + opp->potion_cooldown = 3; + opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; + opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; + opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating was queued (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay: skip offensive actions this tick */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack decision */ + if (opp_attack_ready(self) && !eating) { + /* Pick off-prayer style with bias */ + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Check spec */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; /* 0=ice, 1=blood, 2=atk, 3=spec */ + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low HP, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; /* ATK */ + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Attack action */ + if (actual_attack == 3) { + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement when not attacking */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Novice NH: learning player — 60% correct prayer, random attacks, good eating + * Bridges easy opponents to intermediate. No off-prayer logic, no offensive + * prayers, no movement. Just consistent attacking and sometimes-correct prayer. + * ========================================================================= */ + +static void opp_novice_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack: off-prayer based on off_prayer_rate. Random spec. Offensive prayer. */ + if (opp_attack_ready(self) && !eating) { + int style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + style = opp_pick_from_mask(env, off_mask); + } else { + style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, style, 0); + + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.15f) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (opp->target_fleeing_ticks >= 2 && dist > 1) { + /* Anti-kite: cancel spec, use mage */ + opp_apply_gear_switch(actions, OPP_STYLE_MAGE); + actions[HEAD_COMBAT] = ATTACK_ICE; + } else { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + if (style == OPP_STYLE_MAGE) { + int spell = (rand_int(env, 2) == 0) ? ATTACK_ICE : ATTACK_BLOOD; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } +} + +/* ========================================================================= + * Apprentice NH: 60% correct prayer, 20% off-prayer attacks, 20% offensive + * prayer, random 30% spec, drain restore. Bridges novice_nh to competent_nh. + * ========================================================================= */ + +static void opp_apprentice_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + style = opp_pick_from_mask(env, off_mask); + } else { + style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, style, 0); + + if (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && rand_float(env) < 0.30f) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (opp->target_fleeing_ticks >= 2 && dist > 1) { + opp_apply_gear_switch(actions, OPP_STYLE_MAGE); + actions[HEAD_COMBAT] = ATTACK_ICE; + } else { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + if (style == OPP_STYLE_MAGE) { + int spell = (hp_pct < 0.30f) ? ATTACK_BLOOD : ATTACK_ICE; + actions[HEAD_COMBAT] = spell; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } +} + +/* ========================================================================= + * Competent NH: 75% correct prayer, 25% off-prayer attacks, 25% offensive + * prayers, 50% conditional spec. Bridges apprentice_nh to intermediate_nh. + * ========================================================================= */ + +static void opp_competent_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + attack_style = opp_pick_from_mask(env, off_mask); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec check: same condition as intermediate_nh but 50% trigger rate */ + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target_hp_pct < 0.60f && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range && + rand_float(env) < 0.50f); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* ========================================================================= + * Intermediate NH: getting the hang of it — 85% correct prayer, 70% off-prayer + * attacks, 50% offensive prayers. No movement, no fakes. Bridges novice_nh + * to improved. + * ========================================================================= */ + +static void opp_intermediate_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + int off_mask = opp_get_off_prayer_mask(self, target); + attack_style = opp_pick_from_mask(env, off_mask); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec check: target HP < 60%, not on melee prayer, in range */ + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target_hp_pct < 0.60f && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } +} + +/* ========================================================================= + * Advanced NH: near-improved — 100% correct prayer, 90% off-prayer attacks, + * 75% offensive prayers, same spec as improved, farcast 3 but no step under. + * Bridges intermediate_nh to improved. + * ========================================================================= */ + +static void opp_advanced_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: same as improved (no HP threshold, just not praying melee + in range) */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if low, else ice */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: farcast 3 only (no step under) */ + int mv_dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (opp->target_fleeing_ticks >= 2 && mv_dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Proficient NH: 92% off-prayer, 80% offensive prayer, 25% step under. + * Introduces step under at low rate between advanced_nh (no step under) + * and expert_nh (50% step under). + * ========================================================================= */ + +static void opp_proficient_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: same as improved */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: farcast 3 + 25% step under */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0 && + rand_float(env) < 0.25f) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Expert NH: 95% off-prayer, 85% offensive prayer, 50% step under. + * Introduces step under mechanic at reduced rate while keeping attack + * parameters between advanced_nh and improved. + * ========================================================================= */ + +static void opp_expert_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) + drain restore */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + /* Check if eating (food/karambwan cancel attacks) */ + int eating = opp_check_eating_queued(actions); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: same as improved */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* Offensive prayer */ + if (rand_float(env) < opp->offensive_prayer_rate) { + + } + + /* Attack action */ + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: farcast 3 + 50% step under */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0 && + rand_float(env) < 0.50f) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Phase 2 Policy: Onetick + * Fake switches, tank gear, smart spec, boost pots, 1-tick attacks. + * ========================================================================= */ + +static void opp_onetick(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + + /* 0. Tank gear switch when not about to attack */ + if (!opp_attack_ready(self)) { + opp_apply_tank_gear(actions); + } + + /* 1. Defensive prayer with spec detection */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer_with_spec(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Consumables (same thresholds as improved) */ + int potion_used = opp_apply_consumables(env, opp, actions, self); + + /* Check if eating was queued */ + int eating_queued = opp_check_eating_queued(actions); + + /* 3. Get off-prayer mask */ + int off_mask = opp_get_off_prayer_mask(self, target); + + /* 4. Fake switch logic */ + if (opp->fake_switch_pending && opp_attack_ready(self)) { + /* Clear fake state when attack ready */ + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + } else if (!opp_attack_ready(self) && !opp->fake_switch_pending && rand_float(env) < 0.30f) { + /* Initiate fake switch */ + int current_style = (int)self->current_gear; + /* Don't fake melee if frozen at distance */ + int can_fake_melee = self->frozen_ticks <= 10 || + chebyshev_distance(self->x, self->y, target->x, target->y) <= 1; + + /* Build fake options: off-prayer, not current style, melee only if credible */ + int fake_options[3]; + int fake_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (s == current_style) continue; + if (s == OPP_STYLE_MELEE && !can_fake_melee) continue; + fake_options[fake_count++] = s; + } + + if (fake_count > 0) { + opp->fake_switch_pending = 1; + opp->fake_switch_style = fake_options[rand_int(env, fake_count)]; + opp->opponent_prayer_at_fake = opp_get_opponent_prayer_style(target); + + /* Fake switch: set loadout but no attack */ + opp_apply_fake_switch(actions, opp->fake_switch_style); + + /* Step under if target frozen */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } + + /* Early return -- fake switch done this tick */ + return; + } + } + + /* 5. Determine attack style */ + /* If we just faked, anticipate opponent's prayer switch */ + int preferred_style = -1; + if (opp->opponent_prayer_at_fake >= 0) { + preferred_style = opp->opponent_prayer_at_fake; + opp->opponent_prayer_at_fake = -1; + } + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_melee_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + + /* Spec checks: melee, ranged, magic */ + uint8_t ranged_spec = find_best_ranged_spec(self); + uint8_t magic_spec = find_best_magic_spec(self); + int has_ranged_or_magic_spec = (ranged_spec != ITEM_NONE || magic_spec != ITEM_NONE); + + /* If ranged/magic specs available, gate melee spec behind HP threshold too + * so the boss saves energy for ranged/magic finishing blows */ + int should_melee_spec = opp_attack_ready(self) && + self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_melee_spec_range && + (!has_ranged_or_magic_spec || target_hp_pct < 0.55f); + + int should_ranged_spec = opp_attack_ready(self) && ranged_spec != ITEM_NONE && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f; + + int should_magic_spec = opp_attack_ready(self) && magic_spec != ITEM_NONE && + self->special_energy >= get_magic_spec_cost(self->magic_spec_weapon) && + target->prayer != PRAYER_PROTECT_MAGIC && + target_hp_pct < 0.55f; + + /* Anti-kite: cancel melee spec if target fleeing */ + if (should_melee_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_melee_spec = 0; + } + + int actual_style; + int actual_attack; /* 0=ice, 1=blood, 2=atk, 3=spec */ + int spec_loadout = LOADOUT_SPEC_MELEE; /* default, overridden below */ + + /* Spec priority: ranged at distance > magic off-prayer > melee in range */ + if (should_ranged_spec && (dist >= 3 || target->frozen_ticks > 0)) { + actual_style = OPP_STYLE_RANGED; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_RANGE; + } else if (should_magic_spec) { + actual_style = OPP_STYLE_MAGE; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_MAGIC; + } else if (should_melee_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (target->frozen_ticks == 0 && (off_mask & (1 << OPP_STYLE_MAGE))) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.98f) + ? ((target->freeze_immunity_ticks <= 1 && target->frozen_ticks == 0) ? 0 : 1) + : 0; /* ice at full HP */ + /* Simplified: use opp_get_mage_attack for ice/blood decision */ + actual_attack = opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1; + } else { + /* Target frozen or mage not off-prayer — choose based on fake anticipation */ + int can_use_preferred = preferred_style >= 0 && + (preferred_style != OPP_STYLE_MELEE || self->frozen_ticks <= 10 || dist <= 1); + + if (can_use_preferred) { + actual_style = preferred_style; + if (preferred_style == OPP_STYLE_MAGE) { + actual_attack = (hp_pct < 0.98f) ? 1 : 0; /* blood if not full HP */ + } else { + actual_attack = 2; /* ATK */ + } + } else if (off_mask & (1 << OPP_STYLE_MAGE)) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.98f) ? 1 : 0; + } else { + /* Pick non-mage from off-prayer */ + int non_mage[2]; + int nm_count = 0; + for (int s = 1; s < 3; s++) { + if (off_mask & (1 << s)) non_mage[nm_count++] = s; + } + if (nm_count == 0) { + actual_style = OPP_STYLE_RANGED; + } else { + actual_style = non_mage[rand_int(env, nm_count)]; + } + actual_attack = 2; /* ATK */ + } + } + + /* 6. Boost potions (before attack) */ + opp_apply_boost_potion(env, opp, actions, self, actual_style, potion_used); + + /* Tick-level action delay: skip attack but keep prayer/eating/fakes */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 7. Gear + offensive prayer + attack */ + if (opp_attack_ready(self) && !eating_queued) { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack == 3) { + actions[HEAD_LOADOUT] = spec_loadout; + } else if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + if (actual_attack == 3) { + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement when not attacking */ + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } + + (void)prayer_pct; +} + +/* ========================================================================= + * Phase 2 Policy: RealisticImproved + * Improved with prayer delays, wrong prayer chance, attack delays. + * ========================================================================= */ + +static void opp_unpredictable_improved(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + + /* 1. Handle prayer switch with delay */ + opp_handle_delayed_prayer(env, opp, actions, self, target, + UNPREDICTABLE_IMP_PRAYER_CUM, UNPREDICTABLE_IMP_PRAYER_CUM_LEN, + UNPREDICTABLE_IMP_WRONG_PRAYER, 0 /* no spec detection */); + + /* 2. Consumables (no delay — survival instinct) */ + int potion_used = opp_apply_consumables(env, opp, actions, self); + + int eating_queued = opp_check_eating_queued(actions); + + /* Tick-level action delay (additional layer on top of built-in delays) */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack style decision (with small mistake chance + style bias) */ + int attack_style; + + if (rand_float(env) < UNPREDICTABLE_IMP_SUBOPTIMAL_ATTACK) { + /* Pick from all 3 styles (might be on-prayer) */ + attack_style = rand_int(env, 3); + } else { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } + + /* Boost potions before attack */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, potion_used); + + /* 4. Determine actual attack */ + if (opp_attack_ready(self) && !eating_queued) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range; + + /* Anti-kite: cancel melee spec if target fleeing, force mage to freeze */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + attack_style = OPP_STYLE_MAGE; + } + + int actual_style; + int actual_attack; + + if (should_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (attack_style == OPP_STYLE_MAGE) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.30f) ? 1 : 0; /* blood if very low */ + } else { + actual_style = attack_style; + actual_attack = 2; + } + + /* 5. Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (actual_attack != 3 && rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + /* 6. Offensive prayer */ + + + /* 7. Attack with delay — sample delay, skip if > 0 */ + int action_delay = opp_sample_delay(env, UNPREDICTABLE_IMP_ACTION_CUM, UNPREDICTABLE_IMP_ACTION_CUM_LEN); + if (action_delay == 0) { + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + /* else: missed attack window due to delay */ + } else if (!opp_attack_ready(self)) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } + + (void)potion_used; +} + +/* ========================================================================= + * Phase 2 Policy: RealisticOnetick + * Onetick + prayer delays + fake execution failures + wrong prediction. + * ========================================================================= */ + +static void opp_unpredictable_onetick(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + + /* 0. Tank gear when not about to attack */ + if (!opp_attack_ready(self)) { + opp_apply_tank_gear(actions); + } + + /* 1. Handle prayer switch with delay (with spec detection) */ + opp_handle_delayed_prayer(env, opp, actions, self, target, + UNPREDICTABLE_OT_PRAYER_CUM, UNPREDICTABLE_OT_PRAYER_CUM_LEN, + 0.0f /* no wrong prayer for onetick */, 1 /* include spec */); + + /* 2. Consumables */ + int potion_used = opp_apply_consumables(env, opp, actions, self); + + int eating_queued = opp_check_eating_queued(actions); + + /* 3. Get off-prayer mask */ + int off_mask = opp_get_off_prayer_mask(self, target); + + /* 4. Fake switch logic (same as onetick + failure chance) */ + if (opp->fake_switch_pending && opp_attack_ready(self)) { + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + opp->fake_switch_failed = 0; + } else if (!opp_attack_ready(self) && !opp->fake_switch_pending && rand_float(env) < 0.30f) { + int current_style = (int)self->current_gear; + int can_fake_melee = self->frozen_ticks <= 10 || + chebyshev_distance(self->x, self->y, target->x, target->y) <= 1; + + int fake_options[3]; + int fake_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (s == current_style) continue; + if (s == OPP_STYLE_MELEE && !can_fake_melee) continue; + fake_options[fake_count++] = s; + } + + if (fake_count > 0) { + opp->fake_switch_pending = 1; + opp->fake_switch_style = fake_options[rand_int(env, fake_count)]; + opp->opponent_prayer_at_fake = opp_get_opponent_prayer_style(target); + + /* Roll fake execution failure */ + opp->fake_switch_failed = (rand_float(env) < UNPREDICTABLE_OT_FAKE_FAIL) ? 1 : 0; + + /* Fake switch: set loadout but no attack */ + opp_apply_fake_switch(actions, opp->fake_switch_style); + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } + + return; /* Early return: fake switch done */ + } + } + + /* 5. Determine attack style with fake anticipation + failure/prediction errors */ + int preferred_style = -1; + + if (opp->opponent_prayer_at_fake >= 0 && !opp->fake_switch_failed) { + /* Fake succeeded — but small chance of wrong prediction */ + if (rand_float(env) < UNPREDICTABLE_OT_WRONG_PREDICT) { + preferred_style = rand_int(env, 3); /* random style */ + } else { + preferred_style = opp->opponent_prayer_at_fake; + } + opp->opponent_prayer_at_fake = -1; + } else if (opp->fake_switch_failed) { + /* Fake failed — no preferred style */ + opp->opponent_prayer_at_fake = -1; + opp->fake_switch_failed = 0; + } + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_melee_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + + /* Spec checks: melee, ranged, magic */ + uint8_t ranged_spec = find_best_ranged_spec(self); + uint8_t magic_spec = find_best_magic_spec(self); + int has_ranged_or_magic_spec = (ranged_spec != ITEM_NONE || magic_spec != ITEM_NONE); + + int should_melee_spec = opp_attack_ready(self) && + self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_melee_spec_range && + (!has_ranged_or_magic_spec || target_hp_pct < 0.55f); + + int should_ranged_spec = opp_attack_ready(self) && ranged_spec != ITEM_NONE && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f; + + int should_magic_spec = opp_attack_ready(self) && magic_spec != ITEM_NONE && + self->special_energy >= get_magic_spec_cost(self->magic_spec_weapon) && + target->prayer != PRAYER_PROTECT_MAGIC && + target_hp_pct < 0.55f; + + /* Anti-kite: cancel melee spec if target fleeing */ + if (should_melee_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_melee_spec = 0; + } + + int actual_style; + int actual_attack; + int spec_loadout = LOADOUT_SPEC_MELEE; + + /* Spec priority: ranged at distance > magic off-prayer > melee in range */ + if (should_ranged_spec && (dist >= 3 || target->frozen_ticks > 0)) { + actual_style = OPP_STYLE_RANGED; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_RANGE; + } else if (should_magic_spec) { + actual_style = OPP_STYLE_MAGE; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_MAGIC; + } else if (should_melee_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (target->frozen_ticks == 0 && (off_mask & (1 << OPP_STYLE_MAGE))) { + actual_style = OPP_STYLE_MAGE; + actual_attack = opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1; + } else { + int can_use_preferred = preferred_style >= 0 && + (preferred_style != OPP_STYLE_MELEE || self->frozen_ticks <= 10 || dist <= 1); + + if (can_use_preferred) { + actual_style = preferred_style; + actual_attack = (preferred_style == OPP_STYLE_MAGE) + ? ((hp_pct < 0.98f) ? 1 : 0) + : 2; + } else if (off_mask & (1 << OPP_STYLE_MAGE)) { + actual_style = OPP_STYLE_MAGE; + actual_attack = (hp_pct < 0.98f) ? 1 : 0; + } else { + int non_mage[2]; + int nm_count = 0; + for (int s = 1; s < 3; s++) { + if (off_mask & (1 << s)) non_mage[nm_count++] = s; + } + actual_style = (nm_count > 0) ? non_mage[rand_int(env, nm_count)] : OPP_STYLE_RANGED; + actual_attack = 2; + } + } + + /* 6. Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, actual_style, potion_used); + + /* Tick-level action delay (additional layer) */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 7. Gear + attack with delay chance */ + if (opp_attack_ready(self) && !eating_queued) { + int action_delay = opp_sample_delay(env, UNPREDICTABLE_OT_ACTION_CUM, UNPREDICTABLE_OT_ACTION_CUM_LEN); + if (action_delay == 0) { + /* Gear switch — spec uses spec_loadout directly */ + if (actual_attack == 3) { + actions[HEAD_LOADOUT] = spec_loadout; + } else if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + + if (actual_attack == 3) { + + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + /* else: missed attack window due to delay */ + } else if (!opp_attack_ready(self)) { + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Helper: decode agent's pending action to extract attack style and prayer + * Used by boss opponents (master_nh, savant_nh) for "reading" ability. + * ========================================================================= */ + +static void opp_read_agent_action(OsrsPvp* env, OpponentState* opp) { + opp->has_read_this_tick = 0; + opp->read_agent_style = ATTACK_STYLE_NONE; + opp->read_agent_prayer = PRAYER_NONE; + opp->read_agent_moving = 0; + + if (opp->read_chance <= 0.0f || rand_float(env) >= opp->read_chance) { + return; /* Read failed or no read ability */ + } + + /* Read succeeded - read agent's CURRENT tick actions (player 0) + * IMPORTANT: Read from env->actions, not pending_actions. + * pending_actions contains PREVIOUS tick's actions. + * env->actions is populated from ocean_acts before opponent generation. */ + int* agent_actions = &env->actions[0]; + + /* Extract attack style: loadout determines weapon, so it takes priority. + * Only fall back to attack head when loadout is KEEP/TANK (no switch). */ + int loadout = agent_actions[HEAD_LOADOUT]; + int attack = agent_actions[HEAD_COMBAT]; + + if (loadout != LOADOUT_KEEP && loadout != LOADOUT_TANK) { + /* Loadout switch — weapon determines what's physically possible */ + if (loadout == LOADOUT_MELEE || loadout == LOADOUT_SPEC_MELEE || loadout == LOADOUT_GMAUL) { + opp->read_agent_style = ATTACK_STYLE_MELEE; + } else if (loadout == LOADOUT_RANGE || loadout == LOADOUT_SPEC_RANGE) { + opp->read_agent_style = ATTACK_STYLE_RANGED; + } else if (loadout == LOADOUT_MAGE || loadout == LOADOUT_SPEC_MAGIC) { + opp->read_agent_style = ATTACK_STYLE_MAGIC; + } + opp->has_read_this_tick = 1; + } else if (attack == ATTACK_ICE || attack == ATTACK_BLOOD) { + /* KEEP/TANK + spell cast — must already be holding a staff */ + opp->read_agent_style = ATTACK_STYLE_MAGIC; + opp->has_read_this_tick = 1; + } else if (attack == ATTACK_ATK) { + /* KEEP/TANK + generic attack — use current equipped weapon */ + uint8_t weapon = env->players[0].equipped[GEAR_SLOT_WEAPON]; + int style = get_item_attack_style(weapon); + if (style == 1) opp->read_agent_style = ATTACK_STYLE_MELEE; + else if (style == 2) opp->read_agent_style = ATTACK_STYLE_RANGED; + else if (style == 3) opp->read_agent_style = ATTACK_STYLE_MAGIC; + opp->has_read_this_tick = 1; + } + + /* Extract overhead prayer */ + int overhead = agent_actions[HEAD_OVERHEAD]; + if (overhead == OVERHEAD_MAGE) opp->read_agent_prayer = PRAYER_PROTECT_MAGIC; + else if (overhead == OVERHEAD_RANGED) opp->read_agent_prayer = PRAYER_PROTECT_RANGED; + else if (overhead == OVERHEAD_MELEE) opp->read_agent_prayer = PRAYER_PROTECT_MELEE; + else if (overhead == OVERHEAD_SMITE) opp->read_agent_prayer = PRAYER_SMITE; + else if (overhead == OVERHEAD_REDEMPTION) opp->read_agent_prayer = PRAYER_REDEMPTION; + + /* Extract movement intent */ + opp->read_agent_moving = is_move_action(attack) ? 1 : 0; +} + +/* Get defensive prayer against agent's read attack style */ +static inline int opp_get_read_defensive_prayer(OpponentState* opp) { + if (opp->read_agent_style == ATTACK_STYLE_MAGIC) return OVERHEAD_MAGE; + if (opp->read_agent_style == ATTACK_STYLE_RANGED) return OVERHEAD_RANGED; + if (opp->read_agent_style == ATTACK_STYLE_MELEE) return OVERHEAD_MELEE; + return -1; /* No read or unknown */ +} + +/* Check if a style would hit agent off-prayer (using read info) */ +static inline int opp_style_off_read_prayer(OpponentState* opp, int style) { + if (opp->read_agent_prayer == PRAYER_NONE) return 1; /* No read, assume off */ + if (style == OPP_STYLE_MAGE && opp->read_agent_prayer != PRAYER_PROTECT_MAGIC) return 1; + if (style == OPP_STYLE_RANGED && opp->read_agent_prayer != PRAYER_PROTECT_RANGED) return 1; + if (style == OPP_STYLE_MELEE && opp->read_agent_prayer != PRAYER_PROTECT_MELEE) return 1; + return 0; /* Would hit on-prayer */ +} + +/* ========================================================================= + * Boss Policy: Master NH + * Onetick-perfect mechanics + 10% chance to "read" agent's pending action. + * When read succeeds: prays correctly against incoming attack, attacks off-prayer. + * ========================================================================= */ + +static void opp_master_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + + /* Attempt to read agent's pending action */ + opp_read_agent_action(env, opp); + + /* 0. Tank gear switch when not about to attack */ + if (!opp_attack_ready(self)) { + opp_apply_tank_gear(actions); + } + + /* 1. Defensive prayer - use read info if available, else detect from gear */ + int def_prayer = -1; + if (opp->has_read_this_tick && opp->read_agent_style != ATTACK_STYLE_NONE) { + def_prayer = opp_get_read_defensive_prayer(opp); + } + if (def_prayer < 0) { + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer_with_spec(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Consumables (same as onetick) */ + int potion_used = opp_apply_consumables(env, opp, actions, self); + int eating_queued = opp_check_eating_queued(actions); + + /* 3. Get off-prayer mask (normal) and check read info for better targeting */ + int off_mask = opp_get_off_prayer_mask(self, target); + + /* 4. Fake switch logic (same as onetick) */ + if (opp->fake_switch_pending && opp_attack_ready(self)) { + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + } else if (!opp_attack_ready(self) && !opp->fake_switch_pending && rand_float(env) < 0.30f) { + int current_style = (int)self->current_gear; + int can_fake_melee = self->frozen_ticks <= 10 || + chebyshev_distance(self->x, self->y, target->x, target->y) <= 1; + + int fake_options[3]; + int fake_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (s == current_style) continue; + if (s == OPP_STYLE_MELEE && !can_fake_melee) continue; + fake_options[fake_count++] = s; + } + + if (fake_count > 0) { + opp->fake_switch_pending = 1; + opp->fake_switch_style = fake_options[rand_int(env, fake_count)]; + opp->opponent_prayer_at_fake = opp_get_opponent_prayer_style(target); + + opp_apply_fake_switch(actions, opp->fake_switch_style); + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } + return; + } + } + + /* 5. Determine attack style - use read info if available */ + int preferred_style = -1; + if (opp->opponent_prayer_at_fake >= 0) { + preferred_style = opp->opponent_prayer_at_fake; + opp->opponent_prayer_at_fake = -1; + } + + /* If we read agent's prayer, pick a style they're NOT praying against */ + if (opp->has_read_this_tick && opp->read_agent_prayer != PRAYER_NONE) { + /* Find best off-prayer style using read info */ + int read_off_styles[3]; + int read_off_count = 0; + for (int s = 0; s < 3; s++) { + if (!(off_mask & (1 << s))) continue; + if (opp_style_off_read_prayer(opp, s)) { + read_off_styles[read_off_count++] = s; + } + } + if (read_off_count > 0) { + preferred_style = read_off_styles[rand_int(env, read_off_count)]; + } + } + + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_melee_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + + /* Spec checks: melee, ranged, magic */ + uint8_t ranged_spec = find_best_ranged_spec(self); + uint8_t magic_spec = find_best_magic_spec(self); + int has_ranged_or_magic_spec = (ranged_spec != ITEM_NONE || magic_spec != ITEM_NONE); + + int should_melee_spec = opp_attack_ready(self) && + self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_melee_spec_range && + (!has_ranged_or_magic_spec || target_hp_pct < 0.55f); + + int should_ranged_spec = opp_attack_ready(self) && ranged_spec != ITEM_NONE && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f; + + int should_magic_spec = opp_attack_ready(self) && magic_spec != ITEM_NONE && + self->special_energy >= get_magic_spec_cost(self->magic_spec_weapon) && + target->prayer != PRAYER_PROTECT_MAGIC && + target_hp_pct < 0.55f; + + /* With read, cancel specs the agent is praying against */ + if (opp->has_read_this_tick) { + if (should_melee_spec && opp->read_agent_prayer == PRAYER_PROTECT_MELEE) + should_melee_spec = 0; + if (should_ranged_spec && opp->read_agent_prayer == PRAYER_PROTECT_RANGED) + should_ranged_spec = 0; + if (should_magic_spec && opp->read_agent_prayer == PRAYER_PROTECT_MAGIC) + should_magic_spec = 0; + } + + /* Anti-kite: cancel melee spec if target fleeing */ + if (should_melee_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_melee_spec = 0; + } + + /* Read-based anti-kite: if agent about to move away, cancel melee spec */ + if (should_melee_spec && opp->has_read_this_tick && opp->read_agent_moving && dist > 1) { + should_melee_spec = 0; + } + + int actual_style; + int actual_attack; + int spec_loadout = LOADOUT_SPEC_MELEE; + + /* Spec priority: ranged at distance > magic off-prayer > melee in range */ + if (should_ranged_spec && (dist >= 3 || target->frozen_ticks > 0)) { + actual_style = OPP_STYLE_RANGED; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_RANGE; + } else if (should_magic_spec) { + actual_style = OPP_STYLE_MAGE; + actual_attack = 3; + spec_loadout = LOADOUT_SPEC_MAGIC; + } else if (should_melee_spec) { + actual_style = OPP_STYLE_SPEC; + actual_attack = 3; + } else if (preferred_style >= 0) { + actual_style = preferred_style; + actual_attack = (preferred_style == OPP_STYLE_MAGE) + ? (opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1) + : 2; + } else if (target->frozen_ticks == 0 && (off_mask & (1 << OPP_STYLE_MAGE))) { + actual_style = OPP_STYLE_MAGE; + actual_attack = opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1; + } else { + actual_style = opp_pick_from_mask(env, off_mask); + actual_attack = (actual_style == OPP_STYLE_MAGE) + ? (opp_get_mage_attack(self, target) == ATTACK_ICE ? 0 : 1) + : 2; + } + + /* 6. Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, actual_style, potion_used); + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 7. Gear + attack */ + if (opp_attack_ready(self) && !eating_queued) { + /* Spec: use spec_loadout directly; normal: gear switch with prayer miss */ + if (actual_attack == 3) { + actions[HEAD_LOADOUT] = spec_loadout; + } else if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, actual_style); + } + + if (actual_attack == 3) { + actions[HEAD_COMBAT] = ATTACK_ATK; + } else if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } + + (void)prayer_pct; +} + +/* ========================================================================= + * Boss Policy: Savant NH + * Onetick-perfect mechanics + 25% chance to "read" agent's pending action. + * Same as master_nh but with higher read chance. + * ========================================================================= */ + +static void opp_savant_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + /* Savant uses the same logic as master, just with higher read_chance (set in reset) */ + opp_master_nh(env, opp, actions); +} + +/* ========================================================================= + * Boss Policy: Nightmare NH + * Same as master/savant but with 50% read chance - extremely difficult. + * ========================================================================= */ + +static void opp_nightmare_nh(OsrsPvp* env, OpponentState* opp, int* actions) { + /* Nightmare uses the same logic as master, just with 50% read_chance (set in reset) */ + opp_master_nh(env, opp, actions); +} + +/* ========================================================================= + * Vengeance Fighter: lunar spellbook, melee/range only, no freeze/blood. + * Expert-level prayer/eating, veng on cooldown, melee spec only. + * ========================================================================= */ + +static void opp_veng_fighter(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer (same as expert_nh) */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as expert_nh) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + /* 3. Vengeance on cooldown */ + if (!self->veng_active && remaining_ticks(self->veng_cooldown) == 0) { + actions[HEAD_VENG] = VENG_CAST; + } + + /* Tick-level action delay */ + if (opp_should_skip_offensive(env, opp)) return; + + /* 4. Attack: melee/range only (no mage — lunar spellbook) */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + /* Off-prayer: pick melee or range based on what target ISN'T praying */ + int off_mask = opp_get_off_prayer_mask(self, target); + /* Remove mage from mask — lunar can't cast offensive spells */ + off_mask &= ~(1 << OPP_STYLE_MAGE); + if (off_mask == 0) off_mask = (1 << OPP_STYLE_MELEE) | (1 << OPP_STYLE_RANGED); + attack_style = opp_pick_from_mask(env, off_mask); + } else { + /* Random: melee or range only (OPP_STYLE_RANGED=1, OPP_STYLE_MELEE=2) */ + attack_style = rand_int(env, 2) + 1; + } + + /* Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Spec: melee spec only */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_spec = (self->special_energy >= get_melee_spec_cost(self->melee_spec_weapon) && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Anti-kite: cancel spec if target fleeing */ + if (should_spec && opp->target_fleeing_ticks >= 2 && dist > 1) { + should_spec = 0; + } + + if (should_spec) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: step under frozen target */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0 && + rand_float(env) < 0.40f) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Blood Healer: sustain fighter using blood barrage as primary healing. + * Works at all tiers — ahrim staff can cast blood spells regardless of gear. + * Farcast-5, reduced food reliance, blood barrage heavy when damaged. + * ========================================================================= */ + +static void opp_blood_healer(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Reduced eating — relies on blood barrage for sustain above ~35%. + * Emergency triple-eat below 35%, otherwise only brew/food below 25%. */ + if (hp_pct < 0.25f && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < 0.35f && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < 0.35f && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < 0.35f && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && hp_pct < 0.50f && cons.can_brew) { + /* Brew-batch: blood_healer uses lower threshold (relies on blood barrage above 50%) */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack: blood barrage emphasis for sustain */ + if (opp_attack_ready(self) && !eating) { + int attack_style; + int actual_attack; /* 0=ice, 1=blood, 2=atk */ + + if (hp_pct < 0.40f) { + /* Low HP: blood barrage for heal + triple-eat */ + attack_style = OPP_STYLE_MAGE; + actual_attack = 1; /* blood */ + } else if (hp_pct < 0.70f) { + /* Medium HP: strongly prefer blood barrage (~80%) for sustain */ + if (rand_float(env) < 0.80f) { + attack_style = OPP_STYLE_MAGE; + actual_attack = 1; /* blood */ + } else { + /* Off-prayer attack with style bias */ + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + if (attack_style == OPP_STYLE_MAGE) { + /* Ice to freeze, not blood (already handled blood above) */ + actual_attack = (target->frozen_ticks == 0 && target->freeze_immunity_ticks == 0) + ? 0 : 1; /* ice if can freeze, else blood */ + } else { + actual_attack = 2; /* ATK */ + } + } + } else { + /* High HP: normal off-prayer targeting with ice barrage for freeze */ + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + if (attack_style == OPP_STYLE_MAGE) { + actual_attack = (target->frozen_ticks == 0 && target->freeze_immunity_ticks == 0) + ? 0 : 1; /* ice if can freeze, else blood for sustain */ + } else { + actual_attack = 2; /* ATK */ + } + } + + /* Apply boost potions */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + + /* Tank gear when critically low and not casting blood */ + if (hp_pct < 0.35f && actual_attack != 1) { + actions[HEAD_LOADOUT] = LOADOUT_TANK; + } + + /* Attack action */ + if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } else if (!opp_attack_ready(self)) { + /* Movement: maintain farcast-5 distance */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (self->frozen_ticks == 0) { + if (target->frozen_ticks > 0 && dist < 5) { + /* Step back to range 5 from frozen target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (dist < 4 && target->frozen_ticks == 0) { + /* Maintain distance from unfrozen target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (opp->target_fleeing_ticks >= 2 && dist > 5) { + /* Anti-kite: close to farcast-5 range */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } + } + } +} + +/* ========================================================================= + * Gmaul Combo: KO specialist with spec → gmaul instant follow-up. + * Degrades to improved-style at tier 0 (DDS spec only, no gmaul combo). + * At tier 1+ with gmaul available, fires spec→gmaul for burst KO. + * ========================================================================= */ + +#define COMBO_IDLE 0 +#define COMBO_SPEC_FIRED 1 + +static void opp_gmaul_combo(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + int has_gmaul = player_has_gmaul(self); + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating (same as improved) */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Combo state machine: follow-up gmaul after spec fired */ + if (opp->combo_state == COMBO_SPEC_FIRED && has_gmaul && !eating) { + /* Gmaul follow-up — instant spec, bypasses attack timer */ + actions[HEAD_LOADOUT] = LOADOUT_GMAUL; + actions[HEAD_COMBAT] = ATTACK_ATK; + opp->combo_state = COMBO_IDLE; + return; + } + /* Reset combo if we ate (can't follow up) or don't have gmaul */ + opp->combo_state = COMBO_IDLE; + + /* 4. Attack decision */ + if (opp_attack_ready(self) && !eating) { + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + + /* KO opportunity: target in KO range and we have enough spec energy */ + int melee_spec_cost = get_melee_spec_cost(self->melee_spec_weapon); + int gmaul_cost = 50; /* granite maul always 50% */ + int can_spec_range = (self->frozen_ticks > 0) ? (dist <= 1) : (dist <= 3); + int should_combo = (has_gmaul && + target_hp_pct < opp->ko_threshold && + self->special_energy >= melee_spec_cost + gmaul_cost && + target->prayer != PRAYER_PROTECT_MELEE && + can_spec_range); + + /* Also check ranged spec for variety (no gmaul follow-up, just raw spec) */ + uint8_t ranged_spec = find_best_ranged_spec(self); + int should_ranged_spec = (ranged_spec != 0 && + target_hp_pct < opp->ko_threshold && + self->special_energy >= get_ranged_spec_cost(self->ranged_spec_weapon) && + target->prayer != PRAYER_PROTECT_RANGED && + rand_float(env) < 0.25f); /* 25% chance to use ranged spec */ + + /* Anti-kite: cancel melee combo if target fleeing */ + if ((should_combo || should_ranged_spec) && + opp->target_fleeing_ticks >= 2 && dist > 1) { + should_combo = 0; + should_ranged_spec = 0; + } + + if (should_combo) { + /* Fire melee spec → next tick gmaul follows */ + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + opp->combo_state = COMBO_SPEC_FIRED; + } else if (should_ranged_spec) { + /* Ranged spec (no gmaul follow-up) */ + actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Normal improved-style play */ + int attack_style; + if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Regular melee spec (DDS at tier 0, better at higher tiers) — no gmaul combo */ + int should_regular_spec = (!has_gmaul && + self->special_energy >= melee_spec_cost && + target->prayer != PRAYER_PROTECT_MELEE && + target_hp_pct < 0.50f && + can_spec_range); + if (should_regular_spec && opp->target_fleeing_ticks < 2) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + + if (attack_style == OPP_STYLE_MAGE) { + actions[HEAD_COMBAT] = (target->frozen_ticks == 0 && + target->freeze_immunity_ticks == 0) + ? ATTACK_ICE : ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } + } else if (!opp_attack_ready(self)) { + /* Movement: step under frozen target, farcast-3 for anti-kite */ + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + if (target->frozen_ticks > 0 && self->frozen_ticks == 0 && dist > 0) { + actions[HEAD_COMBAT] = MOVE_UNDER; + } else if (opp->target_fleeing_ticks >= 2 && dist > 3 && self->frozen_ticks == 0) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } else if (opp_should_fc3(self, target) && target->prayer != PRAYER_PROTECT_MELEE) { + actions[HEAD_COMBAT] = MOVE_FARCAST_3; + } + } +} + +/* ========================================================================= + * Range Kiter: ranged-dominant fighter who maintains distance. + * Works at all tiers — rune crossbow does ranged damage at tier 0. + * Gains spec capability at higher tiers (ACB/ZCB/dark bow/morr jav). + * Maintains farcast-5, ice barrage to freeze, ranged primary (~60-70%). + * ========================================================================= */ + +static void opp_range_kiter(OsrsPvp* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + float hp_pct = (float)self->current_hitpoints / (float)self->base_hitpoints; + float target_hp_pct = (float)target->current_hitpoints / (float)target->base_hitpoints; + float prayer_pct = (float)self->current_prayer / (float)self->base_prayer; + int dist = chebyshev_distance(self->x, self->y, target->x, target->y); + + opp_tick_cooldowns(opp); + OppConsumables cons = opp_get_consumables(opp, self); + + /* 1. Defensive prayer */ + int def_prayer; + if (rand_float(env) < opp->prayer_accuracy) { + def_prayer = opp_get_defensive_prayer(target); + } else { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + def_prayer = prayers[rand_int(env, 3)]; + } + def_prayer = opp_apply_prayer_mistake(env, opp, def_prayer); + if (!opp_has_prayer_active(self, def_prayer)) { + actions[HEAD_OVERHEAD] = def_prayer; + } + + /* 2. Multi-threshold eating + emergency blood barrage sustain */ + if (hp_pct < opp->eat_triple_threshold && cons.can_food && cons.can_brew && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->potion_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_brew) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_POTION] = POTION_BREW; + opp->food_cooldown = 3; opp->potion_cooldown = 3; + } else if (hp_pct < opp->eat_double_threshold && cons.can_food && cons.can_karambwan) { + actions[HEAD_FOOD] = FOOD_EAT; + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->food_cooldown = 3; opp->karambwan_cooldown = 2; + } else if (hp_pct < opp->eat_brew_threshold && cons.can_brew) { + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_food) { + actions[HEAD_FOOD] = FOOD_EAT; + opp->food_cooldown = 3; + } else if (hp_pct < 0.60f && cons.can_karambwan) { + actions[HEAD_KARAMBWAN] = KARAM_EAT; + opp->karambwan_cooldown = 2; + } else if (opp_is_drained(self) && hp_pct < 0.90f && cons.can_brew) { + /* Brew-batch: keep eating to 90%+ before restoring */ + actions[HEAD_POTION] = POTION_BREW; + opp->potion_cooldown = 3; + } else if (prayer_pct < 0.30f && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } else if (opp_is_drained(self) && cons.can_restore) { + actions[HEAD_POTION] = POTION_RESTORE; + opp->potion_cooldown = 3; + } + + int eating = opp_check_eating_queued(actions); + + if (opp_should_skip_offensive(env, opp)) return; + + /* 3. Attack: ranged-dominant with freeze support and ranged specs */ + if (opp_attack_ready(self) && !eating) { + /* Check ranged spec availability */ + uint8_t ranged_spec = find_best_ranged_spec(self); + int has_ranged_spec = (ranged_spec != 0); + int ranged_spec_cost = has_ranged_spec + ? get_ranged_spec_cost(self->ranged_spec_weapon) : 100; + + /* Ranged spec: freeze → spec from distance is primary KO pattern */ + int should_ranged_spec = (has_ranged_spec && + self->special_energy >= ranged_spec_cost && + target->prayer != PRAYER_PROTECT_RANGED && + target_hp_pct < 0.55f); + + /* Anti-kite not needed for ranged — we WANT distance */ + + if (should_ranged_spec && (target->frozen_ticks > 0 || dist >= 3)) { + /* Fire ranged spec from distance */ + actions[HEAD_LOADOUT] = LOADOUT_SPEC_RANGE; + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Style selection: ranged-biased via style_bias (initialized with range preference) + * + force ranged at distance, force melee only when adjacent and frozen */ + int attack_style; + int force_melee = (self->frozen_ticks > 0 && dist <= 1); + int prefer_ranged = (dist >= 3 || target->frozen_ticks > 0); + + if (force_melee) { + attack_style = OPP_STYLE_MELEE; + } else if (prefer_ranged && rand_float(env) < 0.80f) { + attack_style = OPP_STYLE_RANGED; + } else if (rand_float(env) < opp->off_prayer_rate) { + attack_style = opp_pick_off_prayer_style_biased(env, opp, self, target); + } else { + attack_style = rand_int(env, 3); + } + + /* Emergency blood barrage healing */ + int actual_attack; + if (hp_pct < 0.30f && attack_style == OPP_STYLE_MAGE) { + actual_attack = 1; /* blood */ + } else if (attack_style == OPP_STYLE_MAGE) { + actual_attack = (target->frozen_ticks == 0 && + target->freeze_immunity_ticks == 0) + ? 0 : 2; /* ice if can freeze, else just ATK (ranged fallback) */ + if (actual_attack == 2) attack_style = OPP_STYLE_RANGED; /* fallback to ranged */ + } else { + actual_attack = 2; /* ATK */ + } + + /* Boost potions */ + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + /* Melee spec (DDS etc) when close — fallback */ + int melee_spec_cost = get_melee_spec_cost(self->melee_spec_weapon); + int can_melee_spec = (self->special_energy >= melee_spec_cost && + target->prayer != PRAYER_PROTECT_MELEE && + dist <= 1 && self->frozen_ticks == 0); + if (can_melee_spec && target_hp_pct < 0.40f && !has_ranged_spec) { + opp_apply_gear_switch(actions, OPP_STYLE_SPEC); + actions[HEAD_COMBAT] = ATTACK_ATK; + } else { + /* Gear switch — offensive_prayer_miss: skip switch to omit auto-prayer */ + if (rand_float(env) < opp->offensive_prayer_miss) { + actions[HEAD_LOADOUT] = LOADOUT_KEEP; + } else { + opp_apply_gear_switch(actions, attack_style); + } + + if (actual_attack == 0) { + actions[HEAD_COMBAT] = ATTACK_ICE; + } else if (actual_attack == 1) { + actions[HEAD_COMBAT] = ATTACK_BLOOD; + } else { + actions[HEAD_COMBAT] = ATTACK_ATK; + } + } + } + } else if (!opp_attack_ready(self)) { + /* Movement: maintain farcast-5, step back after freeze */ + if (self->frozen_ticks == 0) { + if (target->frozen_ticks > 0 && dist < 5) { + /* Step back to range 5 from frozen target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (dist < 4) { + /* Maintain distance from approaching target */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } else if (dist > 7) { + /* Don't let them get too far — close to farcast-5 */ + actions[HEAD_COMBAT] = MOVE_FARCAST_5; + } + } + } +} + +/* ========================================================================= + * Mixed policy selection (MixedEasy/MixedMedium/MixedHard/MixedHardBalanced) + * ========================================================================= */ + +/* MixedEasy weights: panicking=0.18, true_random=0.18, weak_random=0.18, + semi_random=0.15, sticky_prayer=0.10, random_eater=0.10, prayer_rookie=0.06, + improved=0.05 */ +static const OpponentType MIXED_EASY_POOL[] = { + OPP_PANICKING, OPP_TRUE_RANDOM, OPP_WEAK_RANDOM, OPP_SEMI_RANDOM, + OPP_STICKY_PRAYER, OPP_RANDOM_EATER, OPP_PRAYER_ROOKIE, OPP_IMPROVED, +}; +/* Cumulative weights * 100 for integer comparison */ +static const int MIXED_EASY_CUM_WEIGHTS[] = {18, 36, 54, 69, 79, 89, 95, 100}; +#define MIXED_EASY_POOL_SIZE 8 + +/* MixedMedium weights: random_eater=0.25, prayer_rookie=0.20, sticky_prayer=0.20, + semi_random=0.15, improved=0.10, (patient deferred to Python) */ +static const OpponentType MIXED_MEDIUM_POOL[] = { + OPP_RANDOM_EATER, OPP_PRAYER_ROOKIE, OPP_STICKY_PRAYER, + OPP_SEMI_RANDOM, OPP_IMPROVED, +}; +static const int MIXED_MEDIUM_CUM_WEIGHTS[] = {25, 45, 65, 80, 100}; +#define MIXED_MEDIUM_POOL_SIZE 5 + +/* MixedHard: uniform over 5 policies (20% each) */ +static const OpponentType MIXED_HARD_POOL[] = { + OPP_IMPROVED, OPP_ONETICK, OPP_UNPREDICTABLE_IMPROVED, + OPP_UNPREDICTABLE_ONETICK, OPP_RANDOM_EATER, +}; +static const int MIXED_HARD_CUM_WEIGHTS[] = {20, 40, 60, 80, 100}; +#define MIXED_HARD_POOL_SIZE 5 + +/* MixedHardBalanced: random_eater=25%, improved=30%, unpredictable_improved=20%, + onetick=15%, unpredictable_onetick=10% */ +static const OpponentType MIXED_HARD_BALANCED_POOL[] = { + OPP_RANDOM_EATER, OPP_IMPROVED, OPP_UNPREDICTABLE_IMPROVED, + OPP_ONETICK, OPP_UNPREDICTABLE_ONETICK, +}; +static const int MIXED_HARD_BALANCED_CUM_WEIGHTS[] = {25, 55, 75, 90, 100}; +#define MIXED_HARD_BALANCED_POOL_SIZE 5 + +static OpponentType opp_select_from_pool( + OsrsPvp* env, const OpponentType* pool, const int* cum_weights, int pool_size +) { + int r = rand_int(env, 100); + for (int i = 0; i < pool_size; i++) { + if (r < cum_weights[i]) return pool[i]; + } + return pool[pool_size - 1]; +} + +/* ========================================================================= + * Main entry point: generate opponent action + * ========================================================================= */ + +static void opponent_reset(OsrsPvp* env, OpponentState* opp) { + opp->food_cooldown = 0; + opp->potion_cooldown = 0; + opp->karambwan_cooldown = 0; + opp->current_prayer_set = 0; + + /* Phase 2 state reset */ + opp->fake_switch_pending = 0; + opp->fake_switch_style = -1; + opp->opponent_prayer_at_fake = -1; + opp->fake_switch_failed = 0; + opp->pending_prayer_value = 0; + opp->pending_prayer_delay = 0; + opp->last_target_gear_style = -1; + + /* Per-episode eating thresholds with noise */ + opp->eat_triple_threshold = 0.30f + (rand_float(env) * 0.10f - 0.05f); + opp->eat_double_threshold = 0.50f + (rand_float(env) * 0.10f - 0.05f); + opp->eat_brew_threshold = 0.70f + (rand_float(env) * 0.10f - 0.05f); + + /* Boss opponent reading ability — reset per-tick state */ + opp->has_read_this_tick = 0; + opp->read_agent_style = ATTACK_STYLE_NONE; + opp->read_agent_prayer = PRAYER_NONE; + opp->read_chance = 0.0f; + opp->read_agent_moving = 0; + opp->prev_dist_to_target = 0; + opp->target_fleeing_ticks = 0; + + /* Per-episode resets for specific policies */ + if (opp->type == OPP_PANICKING) { + int prayers[] = {OVERHEAD_MELEE, OVERHEAD_RANGED, OVERHEAD_MAGE}; + opp->chosen_prayer = prayers[rand_int(env, 3)]; + opp->chosen_style = rand_int(env, 3); + } + + /* Mixed policies: select sub-policy */ + if (opp->type == OPP_MIXED_EASY) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_EASY_POOL, MIXED_EASY_CUM_WEIGHTS, MIXED_EASY_POOL_SIZE); + } else if (opp->type == OPP_MIXED_MEDIUM) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_MEDIUM_POOL, MIXED_MEDIUM_CUM_WEIGHTS, MIXED_MEDIUM_POOL_SIZE); + } else if (opp->type == OPP_MIXED_HARD) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_HARD_POOL, MIXED_HARD_CUM_WEIGHTS, MIXED_HARD_POOL_SIZE); + } else if (opp->type == OPP_MIXED_HARD_BALANCED) { + opp->active_sub_policy = opp_select_from_pool( + env, MIXED_HARD_BALANCED_POOL, MIXED_HARD_BALANCED_CUM_WEIGHTS, + MIXED_HARD_BALANCED_POOL_SIZE); + } else if (opp->type == OPP_PFSP && env->pfsp.pool_size > 0) { + int idx = 0; + int r = rand_int(env, 1000); + for (int i = 0; i < env->pfsp.pool_size; i++) { + if (r < env->pfsp.cum_weights[i]) { idx = i; break; } + } + env->pfsp.active_pool_idx = idx; + opp->active_sub_policy = env->pfsp.pool[idx]; + + // Toggle opponent mode: selfplay uses external Python actions, + // scripted opponents use C-generated actions + if (opp->active_sub_policy == OPP_SELFPLAY) { + env->use_c_opponent = 0; + env->use_external_opponent_actions = 1; + if (env->ocean_selfplay_mask) *env->ocean_selfplay_mask = 1; + } else { + env->use_c_opponent = 1; + env->use_external_opponent_actions = 0; + if (env->ocean_selfplay_mask) *env->ocean_selfplay_mask = 0; + } + } else if (opp->type == OPP_PFSP) { + // PFSP pool not yet configured (set_pfsp_weights called after env creation). + // Fall back to OPP_IMPROVED so the first episode isn't against a no-op opponent. + opp->active_sub_policy = OPP_IMPROVED; + env->pfsp.active_pool_idx = -1; // sentinel: don't track in PFSP stats + } + + /* Per-episode randomized decision parameters — resolved from sub-policy + * so PFSP and mixed pools get the correct ranges. */ + OpponentType resolved = opp->active_sub_policy ? opp->active_sub_policy : opp->type; + if (resolved > 0 && resolved <= OPP_RANGE_KITER) { + const OpponentRandRanges* r = &OPP_RAND_RANGES[resolved]; + opp->prayer_accuracy = rand_range(env, r->prayer_accuracy); + opp->off_prayer_rate = rand_range(env, r->off_prayer_rate); + opp->offensive_prayer_rate = rand_range(env, r->offensive_prayer_rate); + opp->action_delay_chance = rand_range(env, r->action_delay_chance); + opp->mistake_rate = rand_range(env, r->mistake_rate); + opp->offensive_prayer_miss = rand_range(env, r->offensive_prayer_miss); + } + + /* Boss reading ability */ + if (resolved == OPP_MASTER_NH) { + opp->read_chance = 0.10f; + } else if (resolved == OPP_SAVANT_NH) { + opp->read_chance = 0.25f; + } else if (resolved == OPP_NIGHTMARE_NH) { + opp->read_chance = 0.50f; + } + + /* Vengeance fighter: lunar spellbook (no freeze/blood, has veng) */ + if (resolved == OPP_VENG_FIGHTER) { + env->players[1].is_lunar_spellbook = 1; + } + + /* Per-episode style bias: weighted preference for mage/ranged/melee. + * Sampled for improved+ opponents that use off-prayer targeting. */ + if (resolved == OPP_IMPROVED || resolved == OPP_ONETICK || + resolved == OPP_UNPREDICTABLE_IMPROVED || resolved == OPP_UNPREDICTABLE_ONETICK || + (resolved >= OPP_ADVANCED_NH && resolved <= OPP_NIGHTMARE_NH) || + resolved == OPP_BLOOD_HEALER || resolved == OPP_GMAUL_COMBO || + resolved == OPP_RANGE_KITER) { + float raw[3]; + for (int i = 0; i < 3; i++) raw[i] = 0.33f + (rand_float(env) - 0.5f) * 0.4f; + float sum = raw[0] + raw[1] + raw[2]; + for (int i = 0; i < 3; i++) opp->style_bias[i] = raw[i] / sum; + } else { + opp->style_bias[0] = opp->style_bias[1] = opp->style_bias[2] = 0.333f; + } + + /* gmaul_combo: per-episode KO threshold + combo state reset */ + if (resolved == OPP_GMAUL_COMBO) { + opp->combo_state = 0; + opp->ko_threshold = 0.45f + rand_float(env) * 0.15f; /* 45-60% */ + } +} + +static void generate_opponent_action(OsrsPvp* env, OpponentState* opp) { + int* actions = &env->pending_actions[1 * NUM_ACTION_HEADS]; + + /* Clear actions to zero (KEEP/NONE for all heads) */ + memset(actions, 0, NUM_ACTION_HEADS * sizeof(int)); + + /* Update flee tracking for all opponents */ + opp_update_flee_tracking(opp, &env->players[1], &env->players[0]); + + /* Resolve active policy for mixed types */ + OpponentType active = opp->type; + if (active == OPP_MIXED_EASY || active == OPP_MIXED_MEDIUM || + active == OPP_MIXED_HARD || active == OPP_MIXED_HARD_BALANCED || + active == OPP_PFSP) { + active = opp->active_sub_policy; + } + + /* Dispatch to policy implementation */ + switch (active) { + case OPP_TRUE_RANDOM: + opp_true_random(env, actions); + break; + case OPP_PANICKING: + opp_panicking(env, opp, actions); + break; + case OPP_WEAK_RANDOM: + opp_weak_random(env, opp, actions); + break; + case OPP_SEMI_RANDOM: + opp_semi_random(env, opp, actions); + break; + case OPP_STICKY_PRAYER: + opp_sticky_prayer(env, opp, actions); + break; + case OPP_RANDOM_EATER: + opp_random_eater(env, opp, actions); + break; + case OPP_PRAYER_ROOKIE: + opp_prayer_rookie(env, opp, actions); + break; + case OPP_IMPROVED: + opp_improved(env, opp, actions); + break; + case OPP_ONETICK: + opp_onetick(env, opp, actions); + break; + case OPP_UNPREDICTABLE_IMPROVED: + opp_unpredictable_improved(env, opp, actions); + break; + case OPP_UNPREDICTABLE_ONETICK: + opp_unpredictable_onetick(env, opp, actions); + break; + case OPP_NOVICE_NH: + opp_novice_nh(env, opp, actions); + break; + case OPP_APPRENTICE_NH: + opp_apprentice_nh(env, opp, actions); + break; + case OPP_COMPETENT_NH: + opp_competent_nh(env, opp, actions); + break; + case OPP_INTERMEDIATE_NH: + opp_intermediate_nh(env, opp, actions); + break; + case OPP_ADVANCED_NH: + opp_advanced_nh(env, opp, actions); + break; + case OPP_PROFICIENT_NH: + opp_proficient_nh(env, opp, actions); + break; + case OPP_EXPERT_NH: + opp_expert_nh(env, opp, actions); + break; + case OPP_MASTER_NH: + opp_master_nh(env, opp, actions); + break; + case OPP_SAVANT_NH: + opp_savant_nh(env, opp, actions); + break; + case OPP_NIGHTMARE_NH: + opp_nightmare_nh(env, opp, actions); + break; + case OPP_VENG_FIGHTER: + opp_veng_fighter(env, opp, actions); + break; + case OPP_BLOOD_HEALER: + opp_blood_healer(env, opp, actions); + break; + case OPP_GMAUL_COMBO: + opp_gmaul_combo(env, opp, actions); + break; + case OPP_RANGE_KITER: + opp_range_kiter(env, opp, actions); + break; + default: + /* OPP_NONE or unsupported: leave NOOPs */ + break; + } +} + +static void swap_players_and_pending(OsrsPvp* env) { + Player tmp_player = env->players[0]; + env->players[0] = env->players[1]; + env->players[1] = tmp_player; + + int tmp_actions[NUM_ACTION_HEADS]; + memcpy(tmp_actions, env->pending_actions, NUM_ACTION_HEADS * sizeof(int)); + memcpy( + env->pending_actions, + env->pending_actions + NUM_ACTION_HEADS, + NUM_ACTION_HEADS * sizeof(int) + ); + memcpy( + env->pending_actions + NUM_ACTION_HEADS, + tmp_actions, + NUM_ACTION_HEADS * sizeof(int) + ); +} + +static void generate_opponent_action_for_player0(OsrsPvp* env, OpponentState* opp) { + swap_players_and_pending(env); + generate_opponent_action(env, opp); + swap_players_and_pending(env); +} + +#endif /* OSRS_PVP_OPPONENTS_H */ diff --git a/ocean/osrs/osrs_pvp_render.h b/ocean/osrs/osrs_pvp_render.h new file mode 100644 index 0000000000..92104d82ca --- /dev/null +++ b/ocean/osrs/osrs_pvp_render.h @@ -0,0 +1,3930 @@ +/** + * @fileoverview Raylib debug viewer for OSRS PvP simulation. + * + * Top-down 2D tile grid with full debug overlay: player state, HP bars, + * prayer icons, gear labels, hit splats, collision map visualization. + * Included conditionally via OSRS_PVP_VISUAL define. + * + * Follows PufferLib's Client + make_client + c_render pattern. + */ + +#ifndef OSRS_PVP_RENDER_H +#define OSRS_PVP_RENDER_H + +#include "raylib.h" +#include "rlgl.h" +#include "raymath.h" +#include "osrs_pvp_models.h" +#include "osrs_pvp_anim.h" +#include "osrs_pvp_effects.h" +#include "data/player_models.h" +#include "data/npc_models.h" +#include "osrs_pvp_terrain.h" +#include "osrs_pvp_objects.h" +#include "osrs_pvp_gui.h" +#include "osrs_pvp_human_input.h" +#include +#include + +/* ======================================================================== */ +/* constants */ +/* ======================================================================== */ + +#define RENDER_TILE_SIZE 20 +#define RENDER_PANEL_WIDTH 320 +#define RENDER_HEADER_HEIGHT 40 +#define RENDER_SPLATS_PER_PLAYER 4 /* OSRS max: 4 simultaneous splats per entity */ +#define RENDER_HISTORY_SIZE 2000 /* max ticks of rewind history */ +#define MAX_RENDER_ENTITIES 64 /* max entities rendered (players + NPCs/bosses/adds) */ + +#define RENDER_GRID_W (FIGHT_AREA_WIDTH * RENDER_TILE_SIZE) +#define RENDER_GRID_H (FIGHT_AREA_HEIGHT * RENDER_TILE_SIZE) +#define RENDER_WINDOW_W (RENDER_GRID_W + RENDER_PANEL_WIDTH) +#define RENDER_WINDOW_H (RENDER_GRID_H + RENDER_HEADER_HEIGHT) + +/* colors */ +#define COLOR_BG CLITERAL(Color){ 20, 20, 25, 255 } +#define COLOR_GRID CLITERAL(Color){ 45, 45, 55, 255 } +#define COLOR_HEADER_BG CLITERAL(Color){ 30, 30, 40, 255 } +#define COLOR_PANEL_BG CLITERAL(Color){ 25, 25, 35, 255 } +#define COLOR_P0 CLITERAL(Color){ 80, 140, 255, 255 } +#define COLOR_P1 CLITERAL(Color){ 255, 90, 90, 255 } +#define COLOR_P0_LIGHT CLITERAL(Color){ 80, 140, 255, 60 } +#define COLOR_P1_LIGHT CLITERAL(Color){ 255, 90, 90, 60 } +#define COLOR_FREEZE CLITERAL(Color){ 100, 170, 255, 90 } +#define COLOR_VENG CLITERAL(Color){ 255, 220, 50, 255 } +#define COLOR_BLOCKED CLITERAL(Color){ 200, 50, 50, 50 } +#define COLOR_WALL CLITERAL(Color){ 220, 150, 40, 50 } +#define COLOR_BRIDGE CLITERAL(Color){ 50, 120, 220, 50 } +#define COLOR_WALL_LINE CLITERAL(Color){ 255, 180, 50, 180 } +#define COLOR_HP_GREEN CLITERAL(Color){ 50, 200, 50, 255 } +#define COLOR_HP_RED CLITERAL(Color){ 200, 50, 50, 255 } +#define COLOR_HP_BG CLITERAL(Color){ 40, 40, 40, 200 } +#define COLOR_SPEC_BAR CLITERAL(Color){ 230, 170, 30, 255 } +#define COLOR_TEXT CLITERAL(Color){ 200, 200, 210, 255 } +#define COLOR_TEXT_DIM CLITERAL(Color){ 130, 130, 140, 255 } +#define COLOR_LABEL CLITERAL(Color){ 170, 170, 180, 255 } + +/* ======================================================================== */ +/* active projectile flights (sub-tick interpolation at 50 Hz) */ +/* ======================================================================== */ + +/* OSRS projectile flight parameters (from deob client Projectile.java): + * x/y: linear interpolation from source to target + * height: parabolic arc — initial slope from 'curve' param, + * quadratic correction to hit end_height exactly. + * Zulrah attacks: delay=1, duration=35 client ticks, startH=85, endH=40, + * curve=16 (~22.5 degree launch angle). + * 1 client tick = 20ms, 1 server tick = 600ms = 30 client ticks. + */ + +#define MAX_FLIGHT_PROJECTILES 16 +#define PROJ_OSRS_SLOPE_TO_RAD 0.02454369f /* pi/128, converts OSRS slope units to radians */ + +typedef struct { + int active; + float src_x, src_y; /* source tile position */ + float dst_x, dst_y; /* target tile position (updated each tick if tracking) */ + float x, y; /* current interpolated position */ + float progress; /* 0.0 (spawned) → 1.0 (arrived) */ + float speed; /* progress per client tick (1.0/duration) */ + float start_height; /* height at source (tiles above ground) */ + float end_height; /* height at target (tiles above ground) */ + float curve; /* OSRS slope param (16 = ~22.5 degrees) */ + int style; /* 0=ranged, 1=magic, 2=melee, 3=cloud */ + int damage; /* hit splat value at arrival */ + + /* OSRS tracking: projectiles re-aim toward target each sub-tick */ + float vel_x, vel_y; /* current horizontal velocity (tiles per progress unit) */ + float height_vel; /* current vertical velocity */ + float height_accel; /* quadratic height correction */ + float yaw; /* current facing direction (radians) */ + float pitch; /* current vertical tilt (radians) */ + float arc_height; /* sinusoidal arc peak in tiles (0 = use quadratic) */ + int tracks_target; /* 1 = re-aim toward target each tick */ + uint32_t model_id; /* GFX model from cache (0 = style-based fallback) */ +} FlightProjectile; + +/* ======================================================================== */ +/* per-player composite model */ +/* ======================================================================== */ + +/* OSRS composites all body parts + equipment into a single merged model + * before animating. this ensures vertex skin label groups span the full + * body, so origin/pivot transforms compute correct centroids. + * we replicate that here: one composite mesh per player. */ + +#define COMPOSITE_MAX_BASE_VERTS 12000 /* ~16 models * ~750 base verts each */ +#define COMPOSITE_MAX_FACES 8000 /* ~16 models * ~500 faces each */ +#define COMPOSITE_MAX_EXP_VERTS (COMPOSITE_MAX_FACES * 3) + +typedef struct { + /* merged base geometry for animation */ + int16_t base_vertices[COMPOSITE_MAX_BASE_VERTS * 3]; + uint8_t vertex_skins[COMPOSITE_MAX_BASE_VERTS]; + uint16_t face_indices[COMPOSITE_MAX_FACES * 3]; + uint8_t face_pri_delta[COMPOSITE_MAX_FACES]; /* per-face priority delta relative to source model min */ + int base_vert_count; + int face_count; + + /* raylib mesh (pre-allocated at max capacity, updated per frame) */ + Mesh mesh; + Model model; + int gpu_ready; + + /* animation working state (rebuilt on equipment change) */ + AnimModelState* anim_state; + + /* change detection: last-seen equipment (players) or NPC def ID (NPCs) */ + uint8_t last_equipped[NUM_GEAR_SLOTS]; + int last_npc_def_id; + int needs_rebuild; +} PlayerComposite; + +/* ======================================================================== */ +/* convex hull click detection (ported from RuneLite Jarvis.java) */ +/* ======================================================================== */ + +#define HULL_MAX_POINTS 256 /* max hull vertices (models rarely exceed 100) */ + +typedef struct { + int xs[HULL_MAX_POINTS]; + int ys[HULL_MAX_POINTS]; + int count; +} ConvexHull2D; + +/** Jarvis march: compute 2D convex hull from screen-space points. + xs/ys are input arrays of length n. out is populated with the hull. + ported from RuneLite Jarvis.java. */ +static void hull_compute(const int* xs, const int* ys, int n, ConvexHull2D* out) { + out->count = 0; + if (n < 3) return; + + /* find leftmost point */ + int left = 0; + for (int i = 1; i < n; i++) { + if (xs[i] < xs[left] || (xs[i] == xs[left] && ys[i] < ys[left])) + left = i; + } + + int current = left; + do { + int cx = xs[current], cy = ys[current]; + if (out->count >= HULL_MAX_POINTS) return; + out->xs[out->count] = cx; + out->ys[out->count] = cy; + out->count++; + + /* safety: hull can't have more points than input */ + if (out->count > n) { out->count = 0; return; } + + int next = 0; + int nx = xs[0], ny = ys[0]; + for (int i = 1; i < n; i++) { + /* cross product: positive means i is to the left of current→next */ + long long cp = (long long)(ys[i] - cy) * (nx - xs[i]) + - (long long)(xs[i] - cx) * (ny - ys[i]); + if (cp > 0) { + next = i; nx = xs[i]; ny = ys[i]; + } else if (cp == 0) { + /* collinear: pick the farther point */ + long long d_i = (long long)(cx - xs[i]) * (cx - xs[i]) + + (long long)(cy - ys[i]) * (cy - ys[i]); + long long d_n = (long long)(cx - nx) * (cx - nx) + + (long long)(cy - ny) * (cy - ny); + if (d_i > d_n) { next = i; nx = xs[i]; ny = ys[i]; } + } + } + current = next; + } while (current != left); +} + +/** Point-in-polygon test (ray casting method). + returns 1 if (px, py) is inside the convex hull. */ +static int hull_contains(const ConvexHull2D* hull, int px, int py) { + if (hull->count < 3) return 0; + int inside = 0; + for (int i = 0, j = hull->count - 1; i < hull->count; j = i++) { + int xi = hull->xs[i], yi = hull->ys[i]; + int xj = hull->xs[j], yj = hull->ys[j]; + if (((yi > py) != (yj > py)) && + (px < (xj - xi) * (py - yi) / (yj - yi) + xi)) + inside = !inside; + } + return inside; +} + +/* ======================================================================== */ +/* render client */ +/* ======================================================================== */ + +/* per-entity hitsplat slot matching OSRS Entity.java exactly: + - hitmarkMove starts at +5.0, decreases by 0.25/client-tick, clamps at -5.0 + - hitmarkTrans (opacity) starts at 230, stays there (mode 2 never fades) + - hitsLoopCycle: expires after 70 client ticks + - slot layout from Client.java:6052: slot 0=center, 1=up20, 2=left15+up10, 3=right15+up10 */ +typedef struct { + int active; + int damage; + double hitmark_move; /* OSRS hitmarkMove: starts +5, decrements to -5 */ + int hitmark_trans; /* OSRS hitmarkTrans: opacity 0-230, starts 230 */ + int ticks_remaining; /* counts down from 70 client ticks */ +} HitSplat; + +typedef struct { + /* viewer state */ + int is_paused; + float ticks_per_second; + int step_once; + int step_back; + + /* overlay toggles */ + int show_collision; + int show_pathfinding; + int show_models; + int show_safe_spots; + int show_debug; /* toggle raycast debug, hulls, hitboxes, projectile trails */ + + /* 3D model rendering */ + ModelCache* model_cache; + AnimCache* anim_cache; + ModelCache* npc_model_cache; /* secondary cache for encounter-specific NPC models */ + AnimCache* npc_anim_cache; /* secondary cache for encounter-specific NPC anims */ + float model_scale; + + /* overhead prayer icon textures (from headicons_prayer sprites) */ + Texture2D prayer_icons[6]; /* indexed by headIcon: 0=melee,1=ranged,2=magic,3=retri,4=smite,5=redemp */ + int prayer_icons_loaded; + + /* hitsplat sprite textures (from hitmarks sprites, 317 mode 0). + 0=blue(miss), 1=red(regular), 2=green(poison), 3=dark(venom), 4=yellow(shield) */ + Texture2D hitmark_sprites[5]; + int hitmark_sprites_loaded; + + /* click cross sprites: 4 yellow (move) + 4 red (attack) animation frames */ + Texture2D click_cross_sprites[8]; + int click_cross_loaded; + + /* debug: last raycast-selected tile (-1 = none) */ + int debug_hit_wx, debug_hit_wy; + float debug_ray_hit_x, debug_ray_hit_y, debug_ray_hit_z; + /* ray-plane comparison */ + int debug_plane_wx, debug_plane_wy; + /* ray info */ + Vector3 debug_ray_origin, debug_ray_dir; + + /* render entities: populated per-frame from env->players or encounter vtable. + index 0 = agent, 1+ = opponents/NPCs/bosses. + stored by value (not pointer) via fill_render_entities. */ + RenderEntity entities[MAX_RENDER_ENTITIES]; + int entity_count; + + /* per-entity composite model (merged body + equipment, animated as one) */ + PlayerComposite composites[MAX_RENDER_ENTITIES]; + + /* per-entity 2D convex hull for click detection (projected model vertices). + recomputed every frame after 3D rendering, used by click handler. */ + ConvexHull2D entity_hulls[MAX_RENDER_ENTITIES]; + + /* per-entity two-track animation (matches OSRS primary + secondary system) */ + struct { + /* primary track: action animation (attack, cast, eat, block, death) */ + int primary_seq_id; /* -1 = inactive */ + int primary_frame_idx; + int primary_ticks; + int primary_loops; /* how many times the anim has looped */ + + /* secondary track: pose animation (idle, walk, run — always active) */ + int secondary_seq_id; + int secondary_frame_idx; + int secondary_ticks; + } anim[MAX_RENDER_ENTITIES]; + + /* entity identity tracking — detect slot compaction shifts to reset stale anim/composite */ + int prev_npc_slot[MAX_RENDER_ENTITIES]; + int prev_entity_count; + + /* terrain */ + TerrainMesh* terrain; + + /* placed objects (walls, buildings, trees) */ + ObjectMesh* objects; + + /* NPC models at spawn positions */ + ObjectMesh* npcs; + + /* 3D camera mode (T to toggle) */ + int mode_3d; + float cam_yaw; /* radians, 0 = looking north */ + float cam_pitch; /* radians, clamped */ + float cam_dist; /* distance from target */ + float cam_target_x; /* world X (tile coords) */ + float cam_target_z; /* world Z (tile coords) */ + + /* camera zoom (scroll wheel zooms entire view) */ + float zoom; + + /* per-entity hit splats (4 slots each, OSRS style) */ + HitSplat splats[MAX_RENDER_ENTITIES][RENDER_SPLATS_PER_PLAYER]; + + /* per-entity sub-tile position and facing (OSRS: 128 units per tile) */ + int sub_x[MAX_RENDER_ENTITIES], sub_y[MAX_RENDER_ENTITIES]; + int dest_x[MAX_RENDER_ENTITIES], dest_y[MAX_RENDER_ENTITIES]; + int visual_moving[MAX_RENDER_ENTITIES]; + int visual_running[MAX_RENDER_ENTITIES]; + int step_tracker[MAX_RENDER_ENTITIES]; + float yaw[MAX_RENDER_ENTITIES]; + float target_yaw[MAX_RENDER_ENTITIES]; + int facing_opponent[MAX_RENDER_ENTITIES]; + + /* HP bar visibility timer: only shown after taking damage. + matches OSRS Entity.cycleStatus (300 client ticks = 6s). + in game ticks: set to env->tick + 10, visible while tick < this. */ + int hp_bar_visible_until[MAX_RENDER_ENTITIES]; + + /* visual effects: spell impacts, projectiles */ + ActiveEffect effects[MAX_ACTIVE_EFFECTS]; + int effect_client_tick_counter; /* monotonic 50 Hz counter for effect timing */ + + /* client-tick accumulator: OSRS runs both movement AND animation at 50 Hz + (20ms per client tick). we accumulate real time and process the correct + number of steps per render frame, matching the real client exactly. */ + double client_tick_accumulator; + + /* arena bounds (overridden by encounter, defaults to FIGHT_AREA_*) */ + int arena_base_x, arena_base_y; + int arena_width, arena_height; + + /* encounter visual overlay (populated by encounter's render_post_tick) */ + EncounterOverlay encounter_overlay; + + /* pre-built static models for overlay rendering (clouds, projectiles, snakelings). + built once at init from model cache, drawn at overlay positions each frame. */ + Model cloud_model; int cloud_model_ready; + Model snakeling_model; int snakeling_model_ready; + Model ranged_proj_model; int ranged_proj_model_ready; + Model magic_proj_model; int magic_proj_model_ready; + Model cloud_proj_model; int cloud_proj_model_ready; + Model pillar_models[4]; int pillar_models_ready; /* 0=100%, 1=75%, 2=50%, 3=25% HP */ + + /* active projectile flights: interpolated at 50Hz between game ticks. + spawned from encounter overlay events, auto-expired on arrival. */ + FlightProjectile flights[MAX_FLIGHT_PROJECTILES]; + + /* dynamic projectile model cache: lazily loads per-NPC-type projectile models */ +#define MAX_PROJ_MODELS 16 + struct { uint32_t id; Model model; int ready; } proj_models[MAX_PROJ_MODELS]; + int proj_model_count; + + /* collision map: pointer to env's CollisionMap (shared, not owned). + world offset translates arena coords to collision map world coords. */ + const CollisionMap* collision_map; + int collision_world_offset_x; + int collision_world_offset_y; + + /* tick pacing */ + double last_tick_time; + + /* rewind history: ring buffer of env snapshots */ + OsrsPvp* history; /* heap-allocated array of RENDER_HISTORY_SIZE snapshots */ + int history_count; /* how many snapshots stored (up to RENDER_HISTORY_SIZE) */ + int history_cursor; /* current position when rewinding (-1 = live) */ + + /* OSRS GUI panel system (inventory, equipment, prayer, combat, spellbook) */ + GuiState gui; + + /* interactive human control (H key toggle) */ + HumanInput human_input; +} RenderClient; + +/* forward declarations */ +static Camera3D render_build_3d_camera(RenderClient* rc); + +/** Get the raw Player* for a given entity index (for GUI functions that need full Player state). + Returns the Player* from get_entity for encounters that use Player structs (PvP, Zulrah). + Returns NULL if no encounter or index is out of range. GUI code must NULL-check. */ +static Player* render_get_player_ptr(OsrsPvp* env, int index) { + if (env->encounter_def && env->encounter_state) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + return (Player*)def->get_entity(env->encounter_state, index); + } + if (index >= 0 && index < NUM_AGENTS) + return &env->players[index]; + return NULL; +} + +/** Look up an animation sequence, checking secondary NPC cache as fallback. */ +static AnimSequence* render_get_anim_sequence(RenderClient* rc, uint16_t seq_id) { + AnimSequence* seq = NULL; + if (rc->anim_cache) seq = anim_get_sequence(rc->anim_cache, seq_id); + if (!seq && rc->npc_anim_cache) seq = anim_get_sequence(rc->npc_anim_cache, seq_id); + return seq; +} + +/** Look up an animation framebase, checking secondary NPC cache as fallback. */ +static AnimFrameBase* render_get_framebase(RenderClient* rc, uint16_t base_id) { + AnimFrameBase* fb = NULL; + if (rc->anim_cache) fb = anim_get_framebase(rc->anim_cache, base_id); + if (!fb && rc->npc_anim_cache) fb = anim_get_framebase(rc->npc_anim_cache, base_id); + return fb; +} + +/* ======================================================================== */ +/* coordinate helpers */ +/* ======================================================================== */ + +static inline int render_world_to_screen_x_rc(RenderClient* rc, int world_x) { + return (world_x - rc->arena_base_x) * RENDER_TILE_SIZE; +} + +static inline int render_world_to_screen_y_rc(RenderClient* rc, int world_y) { + /* flip Y: OSRS Y increases north, screen Y increases down */ + int local_y = world_y - rc->arena_base_y; + int flipped = (rc->arena_height - 1) - local_y; + return RENDER_HEADER_HEIGHT + flipped * RENDER_TILE_SIZE; +} + +/* legacy wrappers using default FIGHT_AREA bounds */ +static inline int render_world_to_screen_x(int world_x) { + return (world_x - FIGHT_AREA_BASE_X) * RENDER_TILE_SIZE; +} + +static inline int render_world_to_screen_y(int world_y) { + int local_y = world_y - FIGHT_AREA_BASE_Y; + int flipped = (FIGHT_AREA_HEIGHT - 1) - local_y; + return RENDER_HEADER_HEIGHT + flipped * RENDER_TILE_SIZE; +} + +/* forward declarations for composite model system (defined after lifecycle) */ +static void composite_free(PlayerComposite* comp); +static int render_select_secondary(RenderClient* rc, int player_idx); + +/* ======================================================================== */ +/* lifecycle */ +/* ======================================================================== */ + +static RenderClient* render_make_client(void) { + RenderClient* rc = (RenderClient*)calloc(1, sizeof(RenderClient)); + rc->ticks_per_second = 1.667f; /* OSRS game tick = 600ms */ + rc->last_tick_time = 0.0; + rc->model_scale = 0.15f; /* ~20px tile / ~150 model units */ + rc->zoom = 1.0f; + rc->arena_base_x = FIGHT_AREA_BASE_X; + rc->arena_base_y = FIGHT_AREA_BASE_Y; + rc->arena_width = FIGHT_AREA_WIDTH; + rc->arena_height = FIGHT_AREA_HEIGHT; + rc->mode_3d = 1; + rc->show_safe_spots = 0; + rc->show_debug = 0; + rc->cam_yaw = 0.0f; + rc->cam_pitch = 0.6f; /* ~34 degrees, similar to OSRS default */ + rc->cam_dist = 40.0f; + /* fight area center (Z negated: OSRS +Y = north maps to -Z) */ + rc->cam_target_x = (float)rc->arena_base_x + (float)rc->arena_width / 2.0f; + rc->cam_target_z = -((float)rc->arena_base_y + (float)rc->arena_height / 2.0f); + rc->history = (OsrsPvp*)calloc(RENDER_HISTORY_SIZE, sizeof(OsrsPvp)); + rc->history_count = 0; + rc->history_cursor = -1; /* -1 = live (not rewinding) */ + rc->entity_count = 0; /* populated by render_populate_entities */ + rc->prev_entity_count = 0; + for (int i = 0; i < MAX_RENDER_ENTITIES; i++) { + rc->anim[i].primary_seq_id = -1; + rc->anim[i].secondary_seq_id = 808; /* ANIM_SEQ_IDLE */ + rc->prev_npc_slot[i] = -1; + } + + InitWindow(RENDER_WINDOW_W, RENDER_WINDOW_H, "OSRS PvP Debug Viewer"); + SetTargetFPS(60); + + /* load overhead prayer icon textures from exported sprites. + OSRS headIcon index: 0=melee, 1=ranged, 2=magic, 3=retribution, 4=smite, 5=redemption */ + { + const char* paths[] = { + "data/sprites/gui/headicons_prayer_0.png", + "data/sprites/gui/headicons_prayer_1.png", + "data/sprites/gui/headicons_prayer_2.png", + "data/sprites/gui/headicons_prayer_3.png", + "data/sprites/gui/headicons_prayer_4.png", + "data/sprites/gui/headicons_prayer_5.png", + }; + rc->prayer_icons_loaded = 1; + for (int i = 0; i < 6; i++) { + if (FileExists(paths[i])) { + rc->prayer_icons[i] = LoadTexture(paths[i]); + } else { + rc->prayer_icons_loaded = 0; + } + } + } + + /* load hitsplat sprite textures (317 classic: hitmarks_0..4.png) */ + { + rc->hitmark_sprites_loaded = 1; + for (int i = 0; i < 5; i++) { + const char* path = TextFormat("data/sprites/gui/hitmarks_%d.png", i); + if (FileExists(path)) { + rc->hitmark_sprites[i] = LoadTexture(path); + } else { + rc->hitmark_sprites_loaded = 0; + } + } + } + + /* load click cross sprite textures (4 yellow + 4 red animation frames) */ + { + static const char* cross_names[8] = { + "cross_yellow_1", "cross_yellow_2", "cross_yellow_3", "cross_yellow_4", + "cross_red_1", "cross_red_2", "cross_red_3", "cross_red_4", + }; + rc->click_cross_loaded = 1; + for (int i = 0; i < 8; i++) { + const char* path = TextFormat("data/sprites/gui/%s.png", cross_names[i]); + if (FileExists(path)) { + rc->click_cross_sprites[i] = LoadTexture(path); + } else { + rc->click_cross_loaded = 0; + } + } + } + + rc->debug_hit_wx = -1; + rc->debug_hit_wy = -1; + + /* initialize GUI panel system */ + rc->gui.active_tab = GUI_TAB_INVENTORY; + rc->gui.panel_x = RENDER_GRID_W; + rc->gui.panel_y = RENDER_HEADER_HEIGHT; + rc->gui.panel_w = RENDER_PANEL_WIDTH; + rc->gui.panel_h = RENDER_GRID_H; /* full height — boss info moved to top-left overlay */ + rc->gui.tab_h = 28; + rc->gui.status_bar_h = 42; /* 3 bars x 12px + 2px gaps */ + rc->gui.gui_entity_idx = 0; + rc->gui.gui_entity_count = 0; + + /* inventory interaction state */ + rc->gui.inv_dim_slot = -1; + rc->gui.inv_drag_src_slot = -1; + rc->gui.inv_drag_active = 0; + rc->gui.inv_grid_dirty = 1; + rc->gui.human_clicked_inv_slot = -1; + + /* human input control */ + human_input_init(&rc->human_input); + + /* load GUI sprites from exported cache data */ + gui_load_sprites(&rc->gui); + + return rc; +} + +/** + * Build a static raylib Model from a cached OsrsModel. + * Copies expanded vertex + color data into a new Mesh and uploads to GPU. + * Returns 1 on success, 0 if model not found. + */ +static int render_build_static_model(ModelCache* cache, uint32_t model_id, Model* out) { + OsrsModel* om = model_cache_get(cache, model_id); + if (!om || om->mesh.vertexCount == 0) return 0; + + Mesh mesh = { 0 }; + mesh.vertexCount = om->mesh.vertexCount; + mesh.triangleCount = om->mesh.triangleCount; + mesh.vertices = (float*)RL_MALLOC(mesh.vertexCount * 3 * sizeof(float)); + mesh.colors = (unsigned char*)RL_MALLOC(mesh.vertexCount * 4); + memcpy(mesh.vertices, om->mesh.vertices, mesh.vertexCount * 3 * sizeof(float)); + memcpy(mesh.colors, om->mesh.colors, mesh.vertexCount * 4); + + UploadMesh(&mesh, false); + *out = LoadModelFromMesh(mesh); + return 1; +} + +/** Lazily load and cache a projectile model by GFX model ID. + * Searches both model_cache and npc_model_cache. Returns NULL if not found + * or if model_id is 0 (style-based fallback). */ +static Model* render_get_proj_model(RenderClient* rc, uint32_t model_id) { + if (model_id == 0) return NULL; + for (int i = 0; i < rc->proj_model_count; i++) { + if (rc->proj_models[i].id == model_id) + return rc->proj_models[i].ready ? &rc->proj_models[i].model : NULL; + } + if (rc->proj_model_count >= MAX_PROJ_MODELS) return NULL; + int idx = rc->proj_model_count++; + rc->proj_models[idx].id = model_id; + rc->proj_models[idx].ready = render_build_static_model( + rc->model_cache, model_id, &rc->proj_models[idx].model); + if (!rc->proj_models[idx].ready && rc->npc_model_cache) { + rc->proj_models[idx].ready = render_build_static_model( + rc->npc_model_cache, model_id, &rc->proj_models[idx].model); + } + return rc->proj_models[idx].ready ? &rc->proj_models[idx].model : NULL; +} + +/** + * Build all overlay models (clouds, projectiles, snakelings) from the model cache. + * Call after model_cache is loaded. + */ +static void render_init_overlay_models(RenderClient* rc) { + if (!rc->model_cache) return; + + rc->cloud_model_ready = render_build_static_model( + rc->model_cache, GFX_TOXIC_CLOUD_MODEL, &rc->cloud_model); + rc->snakeling_model_ready = render_build_static_model( + rc->model_cache, SNAKELING_MODEL_ID, &rc->snakeling_model); + rc->ranged_proj_model_ready = render_build_static_model( + rc->model_cache, GFX_RANGED_PROJ_MODEL, &rc->ranged_proj_model); + rc->magic_proj_model_ready = render_build_static_model( + rc->model_cache, GFX_MAGIC_PROJ_MODEL, &rc->magic_proj_model); + + rc->cloud_proj_model_ready = render_build_static_model( + rc->model_cache, GFX_CLOUD_PROJ_MODEL, &rc->cloud_proj_model); + { + uint32_t pillar_ids[4] = { INF_PILLAR_MODEL_100, INF_PILLAR_MODEL_75, + INF_PILLAR_MODEL_50, INF_PILLAR_MODEL_25 }; + rc->pillar_models_ready = 1; + for (int i = 0; i < 4; i++) { + if (!render_build_static_model(rc->model_cache, pillar_ids[i], &rc->pillar_models[i])) + rc->pillar_models_ready = 0; + } + } + + if (rc->cloud_model_ready) printf("overlay: cloud model loaded\n"); + if (rc->pillar_models_ready) printf("overlay: pillar models loaded (4 HP levels)\n"); + if (rc->snakeling_model_ready) printf("overlay: snakeling model loaded\n"); + if (rc->ranged_proj_model_ready) printf("overlay: ranged projectile model loaded\n"); + if (rc->magic_proj_model_ready) printf("overlay: magic projectile model loaded\n"); + if (rc->cloud_proj_model_ready) printf("overlay: cloud projectile model loaded\n"); +} + +/* ======================================================================== */ +/* projectile flight system */ +/* ======================================================================== */ + +/** + * Spawn a flight projectile with OSRS-accurate parabolic arc and target tracking. + * + * Matches Projectile.java setDestination(): + * - position re-computed each sub-tick toward current target + * - yaw/pitch updated from velocity vector each tick + * - height follows parabolic arc with quadratic correction + */ +static void flight_spawn(RenderClient* rc, + float src_x, float src_y, float dst_x, float dst_y, + int style, int damage, + int duration_ticks, int start_h, int end_h, int curve, + float arc_height, int tracks_target, uint32_t model_id) { + int slot = -1; + for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { + if (!rc->flights[i].active) { slot = i; break; } + } + if (slot < 0) return; + + FlightProjectile* fp = &rc->flights[slot]; + memset(fp, 0, sizeof(FlightProjectile)); + fp->active = 1; + fp->src_x = src_x; + fp->src_y = src_y; + fp->dst_x = dst_x; + fp->dst_y = dst_y; + fp->x = src_x; + fp->y = src_y; + fp->progress = 0.0f; + fp->speed = 1.0f / (float)duration_ticks; + fp->start_height = (float)start_h / 128.0f; + fp->end_height = (float)end_h / 128.0f; + fp->curve = (float)curve; + fp->style = style; + fp->damage = damage; + fp->arc_height = arc_height; + fp->tracks_target = tracks_target; + fp->model_id = model_id; + + /* height arc: OSRS SceneProjectile.calculateIncrements + skip quadratic computation when using sinusoidal arc */ + float dx = dst_x - src_x, dy = dst_y - src_y; + float dist = sqrtf(dx * dx + dy * dy); + if (dist < 0.01f) dist = 1.0f; + if (arc_height > 0.0f) { + fp->height_vel = 0.0f; + fp->height_accel = 0.0f; + } else { + fp->height_vel = -dist * tanf(curve * PROJ_OSRS_SLOPE_TO_RAD); + fp->height_accel = 2.0f * (fp->end_height - fp->start_height - fp->height_vel); + } + + /* initial facing */ + fp->yaw = atan2f(dx, dy); + fp->pitch = (arc_height > 0.0f) ? 0.0f : atan2f(fp->height_vel, dist); +} + +/** + * Advance all active flights by one client tick (20ms). + * + * Matches OSRS Projectile.setDestination() tracking: + * remaining = (cycleEnd - currentCycle) + * vel = (target - current) / remaining + * orientation = atan2(vel_x, vel_y) + * pitch = atan2(height_vel, horiz_speed) + */ +static void flight_client_tick(RenderClient* rc) { + for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { + FlightProjectile* fp = &rc->flights[i]; + if (!fp->active) continue; + + /* remaining sub-ticks (avoid div by zero) */ + float remaining = (1.0f - fp->progress) / fp->speed; + if (remaining < 0.5f) remaining = 0.5f; + + /* re-aim velocity toward current target (OSRS tracking) */ + float vx = (fp->dst_x - fp->x) / remaining; + float vy = (fp->dst_y - fp->y) / remaining; + float horiz_speed = sqrtf(vx * vx + vy * vy); + + fp->x += vx; + fp->y += vy; + + /* update facing from velocity vector */ + if (horiz_speed > 0.001f) { + fp->yaw = atan2f(vx, vy); + float h_vel = fp->height_vel + fp->height_accel * fp->progress; + fp->pitch = atan2f(h_vel, horiz_speed); + } + + fp->progress += fp->speed; + if (fp->progress >= 1.0f) { + fp->active = 0; + } + } +} + +/** + * Get the interpolated world position of a flight projectile. + */ +static Vector3 flight_get_position(const FlightProjectile* fp, float src_ground, float dst_ground) { + float t = fp->progress; + if (t < 0.0f) t = 0.0f; + if (t > 1.0f) t = 1.0f; + + float ground = src_ground + (dst_ground - src_ground) * t; + float h; + if (fp->arc_height > 0.0f) { + /* sinusoidal arc (from InfernoTrainer ArcProjectileMotionInterpolator) */ + h = sinf(t * 3.14159265f) * fp->arc_height + + fp->start_height + (fp->end_height - fp->start_height) * t; + } else { + /* quadratic arc (OSRS SceneProjectile) */ + h = fp->start_height + fp->height_vel * t + 0.5f * fp->height_accel * t * t; + } + + return (Vector3){ fp->x + 0.5f, ground + h, -(fp->y + 1.0f) + 0.5f }; +} + +static void render_destroy_client(RenderClient* rc) { + /* free GUI panel sprites */ + gui_unload_sprites(&rc->gui); + /* free prayer icon textures */ + if (rc->prayer_icons_loaded) { + for (int i = 0; i < 6; i++) { + UnloadTexture(rc->prayer_icons[i]); + } + } + /* free hitsplat sprite textures */ + if (rc->hitmark_sprites_loaded) { + for (int i = 0; i < 5; i++) { + UnloadTexture(rc->hitmark_sprites[i]); + } + } + /* free click cross sprite textures */ + if (rc->click_cross_loaded) { + for (int i = 0; i < 8; i++) { + UnloadTexture(rc->click_cross_sprites[i]); + } + } + /* free overlay models */ + if (rc->cloud_model_ready) UnloadModel(rc->cloud_model); + if (rc->snakeling_model_ready) UnloadModel(rc->snakeling_model); + if (rc->ranged_proj_model_ready) UnloadModel(rc->ranged_proj_model); + if (rc->magic_proj_model_ready) UnloadModel(rc->magic_proj_model); + if (rc->cloud_proj_model_ready) UnloadModel(rc->cloud_proj_model); + if (rc->pillar_models_ready) { + for (int i = 0; i < 4; i++) UnloadModel(rc->pillar_models[i]); + } + /* free dynamic projectile model cache */ + for (int i = 0; i < rc->proj_model_count; i++) { + if (rc->proj_models[i].ready) UnloadModel(rc->proj_models[i].model); + } + /* free per-entity composite models */ + for (int p = 0; p < MAX_RENDER_ENTITIES; p++) { + composite_free(&rc->composites[p]); + } + if (rc->model_cache) { + model_cache_free(rc->model_cache); + rc->model_cache = NULL; + } + if (rc->anim_cache) { + anim_cache_free(rc->anim_cache); + rc->anim_cache = NULL; + } + if (rc->terrain) { + terrain_free(rc->terrain); + rc->terrain = NULL; + } + if (rc->objects) { + objects_free(rc->objects); + rc->objects = NULL; + } + if (rc->npcs) { + objects_free(rc->npcs); + rc->npcs = NULL; + } + CloseWindow(); + free(rc->history); + free(rc); +} + +/* ======================================================================== */ +/* input */ +/* ======================================================================== */ + +static void render_handle_input(RenderClient* rc, OsrsPvp* env) { + if (IsKeyPressed(KEY_SPACE)) rc->is_paused = !rc->is_paused; + + if (IsKeyPressed(KEY_RIGHT) && rc->is_paused) { + if (rc->history_cursor >= 0) { + /* in rewind mode: advance through history */ + if (rc->history_cursor < rc->history_count - 1) { + rc->history_cursor++; + rc->step_back = 1; /* triggers restore in main loop */ + } else { + /* restore latest snapshot then return to live */ + rc->history_cursor = rc->history_count - 1; + rc->step_back = 1; + } + } else { + rc->step_once = 1; /* live mode: step sim forward */ + } + } + + if (IsKeyPressed(KEY_LEFT) && rc->is_paused) { + if (rc->history_cursor == -1 && rc->history_count > 1) { + /* enter rewind from live: go to second-to-last snapshot */ + rc->history_cursor = rc->history_count - 2; + } else if (rc->history_cursor > 0) { + rc->history_cursor--; + } + rc->step_back = 1; + } + + if (IsKeyPressed(KEY_TAB)) ToggleFullscreen(); + if (IsKeyPressed(KEY_C)) rc->show_collision = !rc->show_collision; + if (IsKeyPressed(KEY_P)) rc->show_pathfinding = !rc->show_pathfinding; + if (IsKeyPressed(KEY_M)) rc->show_models = !rc->show_models; + if (IsKeyPressed(KEY_S)) rc->show_safe_spots = !rc->show_safe_spots; + if (IsKeyPressed(KEY_D)) rc->show_debug = !rc->show_debug; + if (IsKeyPressed(KEY_T)) rc->mode_3d = !rc->mode_3d; + + float wheel = GetMouseWheelMove(); + + if (rc->mode_3d) { + /* 3D camera controls: right-drag to orbit, scroll to zoom */ + if (IsMouseButtonDown(MOUSE_BUTTON_RIGHT)) { + Vector2 delta = GetMouseDelta(); + rc->cam_yaw -= delta.x * 0.005f; + rc->cam_pitch -= delta.y * 0.005f; + if (rc->cam_pitch < 0.1f) rc->cam_pitch = 0.1f; + if (rc->cam_pitch > 1.4f) rc->cam_pitch = 1.4f; + } + /* middle-drag to pan (disabled during human camera follow) */ + if (!rc->human_input.enabled && + IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) { + Vector2 delta = GetMouseDelta(); + float cs = cosf(rc->cam_yaw), sn = sinf(rc->cam_yaw); + rc->cam_target_x -= (delta.x * cs - delta.y * sn) * 0.05f; + rc->cam_target_z -= (delta.x * sn + delta.y * cs) * 0.05f; + } + if (wheel != 0.0f) { + rc->cam_dist *= (wheel > 0) ? (1.0f / 1.15f) : 1.15f; + if (rc->cam_dist < 5.0f) rc->cam_dist = 5.0f; + if (rc->cam_dist > 200.0f) rc->cam_dist = 200.0f; + } + /* camera follow: lock onto controlled entity's sub-tile position */ + if (rc->human_input.enabled && rc->entity_count > 0) { + int eidx = rc->gui.gui_entity_idx; + if (eidx < rc->entity_count) { + float tx = (float)rc->sub_x[eidx] / 128.0f; + float tz = -(float)rc->sub_y[eidx] / 128.0f; + /* smooth follow: time-normalized exponential decay so camera + feel is consistent regardless of frame rate. decay rate + tuned for 0.15 per frame at 60fps baseline. */ + float dt = GetFrameTime(); + float lerp = 1.0f - powf(0.85f, dt * 60.0f); + rc->cam_target_x += (tx - rc->cam_target_x) * lerp; + rc->cam_target_z += (tz - rc->cam_target_z) * lerp; + } + } + } else { + /* 2D zoom */ + if (wheel != 0.0f) { + rc->zoom *= (wheel > 0) ? 1.15f : (1.0f / 1.15f); + if (rc->zoom < 0.3f) rc->zoom = 0.3f; + if (rc->zoom > 8.0f) rc->zoom = 8.0f; + } + } + + if (IsKeyPressed(KEY_ONE)) rc->ticks_per_second = 1.0f; + if (IsKeyPressed(KEY_TWO)) rc->ticks_per_second = 1.667f; /* OSRS speed (600ms) */ + if (IsKeyPressed(KEY_THREE)) rc->ticks_per_second = 5.0f; + if (IsKeyPressed(KEY_FOUR)) rc->ticks_per_second = 15.0f; + if (IsKeyPressed(KEY_FIVE)) rc->ticks_per_second = 0.0f; /* unlimited */ + + /* H key: toggle human control */ + if (IsKeyPressed(KEY_H)) { + rc->human_input.enabled = !rc->human_input.enabled; + if (!rc->human_input.enabled) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } + fprintf(stderr, "human control: %s\n", rc->human_input.enabled ? "ON" : "OFF"); + } + + /* ESC: cancel spell targeting */ + if (IsKeyPressed(KEY_ESCAPE) && rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } + + /* GUI: G cycles viewed entity, tab clicks switch panels */ + if (IsKeyPressed(KEY_G)) gui_cycle_entity(&rc->gui); + if (IsMouseButtonPressed(MOUSE_BUTTON_LEFT)) { + int mx = GetMouseX(); + int my = GetMouseY(); + int handled = 0; + + /* 1. tab bar click */ + handled = gui_handle_tab_click(&rc->gui, mx, my); + + /* 2. panel content area (when human control is on) */ + if (!handled && rc->human_input.enabled && + mx >= rc->gui.panel_x && mx < rc->gui.panel_x + rc->gui.panel_w && + my >= rc->gui.panel_y && my < rc->gui.panel_y + rc->gui.panel_h) { + + Player* viewed = (rc->entity_count > 0 && rc->gui.gui_entity_idx < rc->entity_count) + ? render_get_player_ptr(env, rc->gui.gui_entity_idx) : NULL; + + if (viewed) { + switch (rc->gui.active_tab) { + case GUI_TAB_PRAYER: + human_handle_prayer_click(&rc->human_input, &rc->gui, viewed, mx, my); + handled = 1; + break; + case GUI_TAB_SPELLBOOK: + human_handle_spell_click(&rc->human_input, &rc->gui, mx, my); + handled = 1; + break; + case GUI_TAB_COMBAT: + human_handle_combat_click(&rc->human_input, &rc->gui, viewed, mx, my); + handled = 1; + break; + default: + break; /* inventory handled separately by gui_inv_handle_mouse */ + } + } + } + + /* 3. ground/entity click (game grid area, left of panel) */ + if (!handled && rc->human_input.enabled && mx < rc->gui.panel_x) { + if (rc->mode_3d) { + /* 3D entity click: test mouse against convex hull of each entity's + projected model (ported from RuneLite RSNPCMixin.getConvexHull). + check entities FIRST before ground tiles. */ + int entity_hit = 0; + for (int ei = 0; ei < rc->entity_count; ei++) { + if (ei == rc->gui.gui_entity_idx) continue; + RenderEntity* ent = &rc->entities[ei]; + if (ent->entity_type == ENTITY_NPC && !ent->npc_visible) continue; + if (hull_contains(&rc->entity_hulls[ei], mx, my)) { + rc->human_input.pending_attack = 1; + rc->human_input.pending_target_idx = rc->entities[ei].npc_slot; + /* attack cancels movement — server stops walking to old dest */ + rc->human_input.pending_move_x = -1; + rc->human_input.pending_move_y = -1; + if (rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.pending_spell = rc->human_input.selected_spell; + rc->human_input.cursor_mode = CURSOR_NORMAL; + } + human_set_click_cross(&rc->human_input, mx, my, 1); + entity_hit = 1; + break; + } + } + + if (entity_hit) { /* already handled */ } + else { + /* 3D ground click: ray-box intersection against actual tile cube geometry */ + Camera3D cam = render_build_3d_camera(rc); + Ray ray = GetScreenToWorldRay((Vector2){ (float)mx, (float)my }, cam); + rc->debug_ray_origin = ray.position; + rc->debug_ray_dir = ray.direction; + + /* ray-box intersection against tile cubes at actual ground height. + when terrain is loaded, each tile sits at terrain_height_avg(). + when no terrain (flat encounters), tiles sit at plat_y=2.0. */ + float best_dist = 1e30f; + int best_wx = -1, best_wy = -1; + for (int dy = 0; dy < rc->arena_height; dy++) { + for (int dx = 0; dx < rc->arena_width; dx++) { + int wx = rc->arena_base_x + dx; + int wy = rc->arena_base_y + dy; + float tx = (float)wx; + float tz = -(float)(wy + 1); + float ground_y = rc->terrain + ? terrain_height_avg(rc->terrain, wx, wy) + : 2.0f; + BoundingBox box = { + .min = { tx, ground_y - 0.1f, tz }, + .max = { tx + 1.0f, ground_y, tz + 1.0f }, + }; + RayCollision col = GetRayCollisionBox(ray, box); + if (col.hit && col.distance < best_dist) { + best_dist = col.distance; + best_wx = wx; + best_wy = wy; + rc->debug_ray_hit_x = col.point.x; + rc->debug_ray_hit_y = col.point.y; + rc->debug_ray_hit_z = col.point.z; + } + } + } + rc->debug_hit_wx = best_wx; + rc->debug_hit_wy = best_wy; + rc->debug_plane_wx = -1; + rc->debug_plane_wy = -1; + if (best_wx >= 0) { + /* ground click: only movement, skip entity check (hull handles that) */ + if (rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } else { + rc->human_input.pending_move_x = best_wx; + rc->human_input.pending_move_y = best_wy; + human_set_click_cross(&rc->human_input, mx, my, 0); + } + } + } /* end else (ground click) */ + } else { + human_handle_ground_click(&rc->human_input, mx, my, + rc->arena_base_x, rc->arena_base_y, + rc->arena_width, rc->arena_height, + rc->entities, rc->entity_count, + rc->gui.gui_entity_idx, + RENDER_TILE_SIZE, RENDER_HEADER_HEIGHT); + } + } + } + + /* right-click cancels spell targeting */ + if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT) && + rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } +} + +/* ======================================================================== */ +/* rewind history */ +/* ======================================================================== */ + +/* save current env state to history ring buffer (call after each pvp_step) */ +static void render_save_snapshot(RenderClient* rc, OsrsPvp* env) { + if (rc->history_count < RENDER_HISTORY_SIZE) { + rc->history[rc->history_count] = *env; + rc->history_count++; + } + /* if buffer full, stop recording (2000 ticks is plenty for one episode) */ +} + +/* restore env state from history snapshot, preserving render-side pointers */ +static void render_restore_snapshot(RenderClient* rc, OsrsPvp* env) { + if (rc->history_cursor < 0 || rc->history_cursor >= rc->history_count) return; + + void* saved_client = env->client; + void* saved_cmap = env->collision_map; + float* saved_ocean_obs = env->ocean_obs; + int* saved_ocean_acts = env->ocean_acts; + float* saved_ocean_rew = env->ocean_rew; + unsigned char* saved_ocean_term = env->ocean_term; + + *env = rc->history[rc->history_cursor]; + + env->client = saved_client; + env->collision_map = saved_cmap; + env->ocean_obs = saved_ocean_obs; + env->ocean_acts = saved_ocean_acts; + env->ocean_rew = saved_ocean_rew; + env->ocean_term = saved_ocean_term; +} + +/* reset history (call on episode reset) */ +static void render_clear_history(RenderClient* rc) { + rc->history_count = 0; + rc->history_cursor = -1; +} + +/* forward declaration: render_push_splat used by render_post_tick, defined later */ +static void render_push_splat(RenderClient* rc, int damage, int pidx); + +/* ======================================================================== */ +/* entity population */ +/* ======================================================================== */ + +/* populate rc->entities from env->players (legacy) or encounter vtable. + call before render_post_tick and pvp_render so all draw code uses rc->entities. + uses fill_render_entities when available, falls back to get_entity + cast. */ +static void render_populate_entities(RenderClient* rc, OsrsPvp* env) { + if (env->encounter_def && env->encounter_state) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + if (def->fill_render_entities) { + int count = 0; + def->fill_render_entities(env->encounter_state, rc->entities, MAX_RENDER_ENTITIES, &count); + rc->entity_count = count; + /* debug: print entity info on first populate */ + static int debug_once = 1; + if (debug_once && count > 0) { + debug_once = 0; + fprintf(stderr, "render_populate: %d entities\n", count); + for (int di = 0; di < count && di < 5; di++) { + fprintf(stderr, " [%d] type=%d npc_id=%d visible=%d size=%d pos=(%d,%d) hp=%d/%d\n", + di, rc->entities[di].entity_type, rc->entities[di].npc_def_id, + rc->entities[di].npc_visible, rc->entities[di].npc_size, + rc->entities[di].x, rc->entities[di].y, + rc->entities[di].current_hitpoints, rc->entities[di].base_hitpoints); + } + } + } else { + /* legacy fallback: cast get_entity to Player* */ + int count = def->get_entity_count(env->encounter_state); + if (count > MAX_RENDER_ENTITIES) count = MAX_RENDER_ENTITIES; + rc->entity_count = count; + for (int i = 0; i < count; i++) { + Player* p = (Player*)def->get_entity(env->encounter_state, i); + if (p) render_entity_from_player(p, &rc->entities[i]); + } + } + /* override arena bounds from encounter if set */ + if (def->arena_width > 0 && def->arena_height > 0) { + rc->arena_base_x = def->arena_base_x; + rc->arena_base_y = def->arena_base_y; + rc->arena_width = def->arena_width; + rc->arena_height = def->arena_height; + } + } else { + rc->entity_count = NUM_AGENTS; + for (int i = 0; i < NUM_AGENTS; i++) { + render_entity_from_player(&env->players[i], &rc->entities[i]); + } + } +} + +/* ======================================================================== */ +/* tick notification: position tracking, facing, effects */ +/* ======================================================================== */ + +/** + * Call BEFORE pvp_step to record pre-tick positions for movement direction. + */ +static void render_pre_tick(RenderClient* rc, OsrsPvp* env) { + (void)rc; (void)env; + /* destination is updated in post_tick after positions change */ +} + +/** + * Call AFTER pvp_step to update movement destination and facing direction. + * + * Movement model matches OSRS client (Entity.java nextStep): + * - positions stored as sub-tile coords (128 units per tile) + * - each client frame, visual position moves toward destination at fixed speed + * - walk = 4 sub-units/frame, run = 8 sub-units/frame (at 50 FPS client ticks) + * - if distance > 256 sub-units (2 tiles), snap instantly (teleport) + * - animation stalls (walkFlag=0) pause movement, then catch up at double speed + */ +static void render_post_tick(RenderClient* rc, OsrsPvp* env) { + render_populate_entities(rc, env); + + /* detect entity identity changes from slot compaction (NPC deaths cause + remaining NPCs to shift to lower indices). reset stale animation and + composite state when a slot's NPC identity changes. */ + for (int i = 0; i < rc->entity_count; i++) { + if (rc->entities[i].npc_slot != rc->prev_npc_slot[i]) { + rc->anim[i].primary_seq_id = -1; + rc->anim[i].primary_frame_idx = 0; + rc->anim[i].primary_ticks = 0; + rc->anim[i].primary_loops = 0; + rc->anim[i].secondary_seq_id = -1; + rc->anim[i].secondary_frame_idx = 0; + rc->anim[i].secondary_ticks = 0; + rc->composites[i].needs_rebuild = 1; + /* flush hitsplat state so dying NPC's splats don't transfer + to the NPC that shifts into this slot after death compaction */ + for (int s = 0; s < RENDER_SPLATS_PER_PLAYER; s++) + rc->splats[i][s].active = 0; + rc->hp_bar_visible_until[i] = 0; + /* reset interpolation state — zeroed sub triggers the teleport-snap + guard below, which cleanly snaps to the new entity's actual tile */ + rc->sub_x[i] = 0; + rc->sub_y[i] = 0; + rc->dest_x[i] = 0; + rc->dest_y[i] = 0; + rc->step_tracker[i] = 0; + } + rc->prev_npc_slot[i] = rc->entities[i].npc_slot; + } + for (int i = rc->entity_count; i < rc->prev_entity_count; i++) + rc->prev_npc_slot[i] = -1; + rc->prev_entity_count = rc->entity_count; + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + + /* convert game tile to sub-tile destination (128 units/tile, centered). + the entity's (x,y) is the SW anchor tile. for size-1 entities, + center on that tile (+ 64 sub-units). for NxN NPCs, center on + the NxN footprint (offset by size/2 tiles from SW corner). */ + int size = p->npc_size > 1 ? p->npc_size : 1; + int new_dest_x = p->x * 128 + size * 64; + int new_dest_y = p->y * 128 + size * 64; + + /* NPC teleport: snap position when entity appears far from tracked position. + this handles Zulrah dive→surface, new NPC spawns, and entity slot reuse. + snap if distance > 2 tiles (matching deob client Canvas.method334 which + snaps at >256 sub-units = 2 tiles). the 1-tile threshold was too aggressive + and caused snapping during normal attack-anim stall catch-up. */ + if (p->entity_type == ENTITY_NPC && p->npc_visible) { + int tile_dx = (rc->sub_x[i] / 128) - p->x; + int tile_dy = (rc->sub_y[i] / 128) - p->y; + if (tile_dx < 0) tile_dx = -tile_dx; + if (tile_dy < 0) tile_dy = -tile_dy; + if (tile_dx > 2 || tile_dy > 2 || (rc->sub_x[i] == 0 && rc->sub_y[i] == 0)) { + rc->sub_x[i] = new_dest_x; + rc->sub_y[i] = new_dest_y; + rc->dest_x[i] = new_dest_x; + rc->dest_y[i] = new_dest_y; + } + } + + /* detect if player moved this tick (destination changed) */ + int moved = (new_dest_x != rc->dest_x[i] || new_dest_y != rc->dest_y[i]); + + /* update destination — NO snap-to-previous-dest. the real OSRS client + (Canvas.java:165-188) simply advances actor.x toward dest by speed + each client tick with no snap. sub smoothly interpolates from wherever + it currently is toward the new dest. the dynamic walk speed in + render_client_tick ensures arrival within one game tick. */ + rc->dest_x[i] = new_dest_x; + rc->dest_y[i] = new_dest_y; + + /* latch walk/run state from the game — this drives the secondary + animation selection in the client-tick loop. the game's is_running + flag tells us whether the player was running this tick. */ + rc->visual_running[i] = p->is_running; + + /* update target facing direction (gradual turn applied in client tick). + matches OSRS appendFocusDestination/nextStep priority: + attacking/dead → face opponent (recalculated every client tick) + moving → face movement direction (set once per step) + idle → face opponent (recalculated every client tick) */ + if (p->attack_style_this_tick != ATTACK_STYLE_NONE || + p->current_hitpoints <= 0) { + if (p->attack_target_entity_idx >= 0 || p->current_hitpoints <= 0) { + rc->facing_opponent[i] = 1; + } else { + /* attacking non-entity target (nibbler → pillar): face dest tile */ + int face_x = p->dest_x * 128 + 64; + int face_y = p->dest_y * 128 + 64; + float dx = (float)(face_x - rc->sub_x[i]); + float dy = (float)(face_y - rc->sub_y[i]); + if (dx != 0.0f || dy != 0.0f) + rc->target_yaw[i] = atan2f(-dx, dy); + rc->facing_opponent[i] = 0; + } + } else if (moved) { + float dx = (float)(new_dest_x - rc->sub_x[i]); + float dy = (float)(new_dest_y - rc->sub_y[i]); + if (dx != 0.0f || dy != 0.0f) { + rc->target_yaw[i] = atan2f(-dx, dy); + } + rc->facing_opponent[i] = 0; + } else { + if (p->attack_target_entity_idx >= 0) { + rc->facing_opponent[i] = 1; + } else { + /* idle, no entity target: face dest tile (nibblers near pillars) */ + int face_x = p->dest_x * 128 + 64; + int face_y = p->dest_y * 128 + 64; + float dx = (float)(face_x - rc->sub_x[i]); + float dy = (float)(face_y - rc->sub_y[i]); + if (dx != 0.0f || dy != 0.0f) + rc->target_yaw[i] = atan2f(-dx, dy); + rc->facing_opponent[i] = 0; + } + } + + /* HP bar + hitsplat: triggered once per game tick when a hit lands. + HP bar: OSRS cycleStatus = clientTick + 300 (6s = 10 game ticks). + hitsplat: one splat per hit, fills the next available slot (0-3). */ + if (p->hit_landed_this_tick) { + rc->hp_bar_visible_until[i] = env->tick + 10; + render_push_splat(rc, p->hit_damage, i); + } + } + + /* spawn visual effects (projectiles, spell impacts) based on this tick's events. + works for any entity count — uses attack_target_entity_idx for multi-entity encounters. */ + int ct = rc->effect_client_tick_counter; + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + /* resolve target: use attack_target_entity_idx if set, otherwise PvP fallback */ + int target_i; + if (p->attack_target_entity_idx >= 0) { + target_i = p->attack_target_entity_idx; + } else if (rc->entity_count == 2) { + target_i = 1 - i; + } else { + target_i = (i == 0) ? 1 : 0; + } + if (target_i < 0 || target_i >= rc->entity_count) continue; + RenderEntity* t = &rc->entities[target_i]; + + /* attacker projectile effects: only for PvP (no encounter overlay). + encounters with render_post_tick handle their own projectiles via + encounter_emit_projectile -> flight system. */ + int has_encounter_overlay = (env->encounter_def && + ((const EncounterDef*)env->encounter_def)->render_post_tick); + + if (!has_encounter_overlay) { + /* attacker cast a spell this tick — spawn projectile */ + if (p->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + uint8_t wpn = p->equipped[GEAR_SLOT_WEAPON]; + if (wpn == ITEM_TRIDENT_OF_SWAMP || wpn == ITEM_SANGUINESTI_STAFF || + wpn == ITEM_EYE_OF_AYAK) { + /* trident/sang/ayak: powered staff projectile */ + effect_spawn_projectile(rc->effects, GFX_TRIDENT_PROJ, + p->x, p->y, t->x, t->y, + 0, 40, 40 * 4, 30 * 4, 16, ct, rc->model_cache); + } else if (p->magic_type_this_tick == 1) { + /* ice barrage: projectile orb rises from target tile + heights *4 per reference (stream.readUnsignedByte() * 4) */ + effect_spawn_projectile(rc->effects, GFX_ICE_BARRAGE_PROJ, + t->x, t->y, t->x, t->y, /* src=dst (rises in place) */ + 0, 56, 43 * 4, 0, 16, ct, rc->model_cache); + } + /* blood barrage: no projectile, impact spawns on hit */ + } + + /* attacker fired a ranged attack this tick */ + if (p->attack_style_this_tick == ATTACK_STYLE_RANGED) { + uint8_t wpn = p->equipped[GEAR_SLOT_WEAPON]; + int gfx; + if (wpn == ITEM_TOXIC_BLOWPIPE) { + gfx = GFX_DRAGON_DART; + } else if (wpn == ITEM_MAGIC_SHORTBOW_I || wpn == ITEM_DARK_BOW || + wpn == ITEM_BOW_OF_FAERDHINEN || wpn == ITEM_TWISTED_BOW) { + gfx = GFX_RUNE_ARROW; + } else { + gfx = GFX_BOLT; /* crossbows, default */ + } + /* heights *4 per reference: 43*4=172 start, 31*4=124 end */ + effect_spawn_projectile(rc->effects, gfx, + p->x, p->y, t->x, t->y, + 0, 40, 43 * 4, 31 * 4, 16, ct, rc->model_cache); + } + } + + /* defender: check what landed on entity p this tick. + for NPC defenders, the attacker is entity 0 (the player). + for player (entity 0), attacker is the current target entity. */ + if (p->hit_landed_this_tick) { + RenderEntity* att; + if (i == 0) { + att = t; /* player was hit — attacker is target entity */ + } else { + att = &rc->entities[0]; /* NPC was hit — attacker is player */ + } + + /* check if attacker used a powered staff (trident/sang/ayak) */ + uint8_t att_wpn = att->equipped[GEAR_SLOT_WEAPON]; + int att_is_powered_staff = (att_wpn == ITEM_TRIDENT_OF_SWAMP || + att_wpn == ITEM_SANGUINESTI_STAFF || att_wpn == ITEM_EYE_OF_AYAK); + + if (att_is_powered_staff && att->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + /* powered staff hit: trident impact splash */ + if (p->hit_was_successful) { + effect_spawn_spotanim(rc->effects, GFX_TRIDENT_IMPACT, + p->x, p->y, ct, rc->anim_cache, rc->model_cache); + } else { + effect_spawn_spotanim(rc->effects, GFX_SPLASH, + p->x, p->y, ct, rc->anim_cache, rc->model_cache); + } + } else { + /* barrage impact: use hit_spell_type (set when pending hit resolves) + instead of magic_type_this_tick (stale by deferred hit landing). + ENCOUNTER_SPELL_ICE=1 -> ice barrage, ENCOUNTER_SPELL_BLOOD=2 -> blood. */ + /* use hit_spell_type from pending hit resolution only. the magic_type_this_tick + fallback caused blood/ice effects on tbow hits when barrage fired same tick. */ + int spell = p->hit_spell_type; + if (spell > 0) { + /* center effect on NPC footprint center using sub-tile precision. + for size 2: center at (x*128 + 128, y*128 + 128) = between 4 tiles. + for size 3: center at (x*128 + 192, y*128 + 192) = middle tile center. */ + float fx = (float)p->x * 128.0f + (float)p->npc_size * 64.0f; + float fy = (float)p->y * 128.0f + (float)p->npc_size * 64.0f; + if (p->hit_was_successful) { + int gfx = (spell == 1) /* ENCOUNTER_SPELL_ICE */ + ? GFX_ICE_BARRAGE_HIT : GFX_BLOOD_BARRAGE_HIT; + effect_spawn_spotanim_subtile(rc->effects, gfx, + fx, fy, ct, rc->anim_cache, rc->model_cache); + } else { + effect_spawn_spotanim_subtile(rc->effects, GFX_SPLASH, + fx, fy, ct, rc->anim_cache, rc->model_cache); + } + } + } + } + } + + /* update encounter overlay (clouds, boss state) */ + if (env->encounter_def && env->encounter_state) { + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + if (edef->render_post_tick) { + edef->render_post_tick(env->encounter_state, &rc->encounter_overlay); + + /* spawn flight projectiles from overlay events. + per-projectile params with backward-compat defaults. */ + EncounterOverlay* ov = &rc->encounter_overlay; + for (int i = 0; i < ov->projectile_count; i++) { + if (!ov->projectiles[i].active) continue; + int src_sz = ov->projectiles[i].src_size > 0 ? ov->projectiles[i].src_size : ov->boss_size; + int dst_sz = ov->projectiles[i].dst_size > 0 ? ov->projectiles[i].dst_size : 1; + float sx = (float)ov->projectiles[i].src_x + (float)(src_sz - 1) / 2.0f + 0.5f; + float sy = (float)ov->projectiles[i].src_y + (float)(src_sz - 1) / 2.0f + 0.5f; + float dx = (float)ov->projectiles[i].dst_x + (float)(dst_sz - 1) / 2.0f + 0.5f; + float dy = (float)ov->projectiles[i].dst_y + (float)(dst_sz - 1) / 2.0f + 0.5f; + + /* use per-projectile params, with defaults for backward compat */ + int dur = ov->projectiles[i].duration_ticks > 0 ? ov->projectiles[i].duration_ticks : 35; + int sh = ov->projectiles[i].start_h > 0 ? ov->projectiles[i].start_h : 85; + int eh = ov->projectiles[i].end_h > 0 ? ov->projectiles[i].end_h : 40; + int cv = ov->projectiles[i].curve > 0 ? ov->projectiles[i].curve : 16; + float arc = ov->projectiles[i].arc_height; + int trk = ov->projectiles[i].tracks_target; + + /* cloud/orb styles: offset dst to tile center */ + if (ov->projectiles[i].style == 3 || ov->projectiles[i].style == 4) { + dx += 0.5f; + dy += 0.5f; + } + + flight_spawn(rc, sx, sy, dx, dy, + ov->projectiles[i].style, ov->projectiles[i].damage, + dur, sh, eh, cv, arc, trk, ov->projectiles[i].model_id); + } + + /* update tracking projectile targets to player's current position */ + if (rc->entity_count > 0) { + float px = (float)rc->entities[0].x; + float py = (float)rc->entities[0].y; + for (int fi = 0; fi < MAX_FLIGHT_PROJECTILES; fi++) { + if (rc->flights[fi].active && rc->flights[fi].tracks_target) { + rc->flights[fi].dst_x = px; + rc->flights[fi].dst_y = py; + } + } + } + } + } +} + +/** + * One client-tick step: movement + animation advancement. + * + * Matches OSRS client processMovement() (Client.java:12996) which calls + * nextStep() then updateAnimation() once per 20ms client tick. By running + * both movement and animation at the same rate, they stay perfectly in sync. + * + * Movement: faithful to Entity.nextStep() (Client.java:13074) + * Animation: faithful to updateAnimation() (Client.java:13272) + */ +static void render_client_tick(RenderClient* rc, int player_idx) { + /* --- nextStep: advance sub-tile position toward destination --- + faithful to Entity.nextStep() (Client.java:13074-13213). + + when a non-melee animation is playing (walkFlag==0), sub-tile + movement stalls. stepTracker accumulates stalled frames, then + drives 2x catch-up speed once the animation ends. */ + int dx = rc->dest_x[player_idx] - rc->sub_x[player_idx]; + int dy = rc->dest_y[player_idx] - rc->sub_y[player_idx]; + + if (dx == 0 && dy == 0) { + /* not moving */ + rc->visual_moving[player_idx] = 0; + rc->step_tracker[player_idx] = 0; + } else { + /* check movement stall: animations without interleave_order (cast, + ranged, death) stall sub-tile movement. animations WITH interleave + (melee, eat, block) allow walking — the interleave blends upper body + attack with lower body walk. matches the real client behavior where + the stall correlates with having no interleave_order. + the 317 cache doesn't set walkFlag=0 for these, but modern OSRS + clearly stalls movement during cast/ranged (confirmed from footage). */ + int stall = 0; + if (rc->anim[player_idx].primary_seq_id >= 0 && + rc->anim[player_idx].primary_loops == 0 && rc->anim_cache) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].primary_seq_id); + if (seq && seq->interleave_count == 0) { + stall = 1; + } + } + + if (stall) { + rc->step_tracker[player_idx]++; + rc->visual_moving[player_idx] = 0; + } else { + rc->visual_moving[player_idx] = 1; + + /* base speed: floor division so entities NEVER arrive early. + real OSRS uses constant speed 4 (walk) / 8 (run) su/ct at 30 ct/gt. + floor(128/30)=4 means 32 ticks to traverse — entity is always in motion + and never stalls at tile center waiting for next game tick. */ + float tps = rc->ticks_per_second > 0.0f ? rc->ticks_per_second : 50.0f; + int ct_per_gt = (int)(50.0f / tps + 0.5f); /* round, not truncate */ + if (ct_per_gt < 1) ct_per_gt = 1; + int base_walk = 128 / ct_per_gt; /* floor, not ceil */ + if (base_walk < 1) base_walk = 1; + int speed = rc->visual_running[player_idx] ? base_walk * 2 : base_walk; + + /* catch-up: double speed while step_tracker > 0 (one stalled + frame recovered per catch-up frame). */ + if (rc->step_tracker[player_idx] > 0) { + speed *= 2; + rc->step_tracker[player_idx]--; + } + + if (dx > 0) rc->sub_x[player_idx] += (dx > speed) ? speed : dx; + else if (dx < 0) rc->sub_x[player_idx] += (dx < -speed) ? -speed : dx; + + if (dy > 0) rc->sub_y[player_idx] += (dy > speed) ? speed : dy; + else if (dy < 0) rc->sub_y[player_idx] += (dy < -speed) ? -speed : dy; + + /* when walking (not facing opponent), update target_yaw to movement + direction each client tick, matching nextStep's turnDirection + assignment from step delta. */ + if (!rc->facing_opponent[player_idx]) { + float fdx = (float)dx; + float fdy = (float)dy; + if (fdx != 0.0f || fdy != 0.0f) { + rc->target_yaw[player_idx] = atan2f(-fdx, fdy); + } + } + } + } + + /* --- appendFocusDestination: gradual turn toward target yaw --- + matches Entity.appendFocusDestination (Client.java:13215). + turn rate = 32 / 2048 of a full circle per client tick. + when facing opponent, recompute target_yaw from visual positions + every client tick (reference: interactingEntity != -1 path). */ + { + if (rc->facing_opponent[player_idx]) { + /* recompute target yaw from current visual positions each client tick, + matching how appendFocusDestination recalculates from live coords */ + int opp; + if (rc->entities[player_idx].attack_target_entity_idx >= 0) { + opp = rc->entities[player_idx].attack_target_entity_idx; + } else { + opp = (rc->entity_count == 2) ? (1 - player_idx) : (player_idx == 0 ? 1 : 0); + } + float dx = (float)(rc->sub_x[opp] - rc->sub_x[player_idx]); + float dy = (float)(rc->sub_y[opp] - rc->sub_y[player_idx]); + if (dx != 0.0f || dy != 0.0f) { + rc->target_yaw[player_idx] = atan2f(-dx, dy); + } + } + + /* step current yaw toward target by turn_speed per client tick. + 32 / 2048 * 2π ≈ 0.0982 radians. snap if within turn_speed. */ + float turn_speed = 32.0f / 2048.0f * 2.0f * 3.14159265f; + float diff = rc->target_yaw[player_idx] - rc->yaw[player_idx]; + + /* normalize to [-π, π] for shortest-path turning */ + while (diff > 3.14159265f) diff -= 2.0f * 3.14159265f; + while (diff < -3.14159265f) diff += 2.0f * 3.14159265f; + + if (fabsf(diff) <= turn_speed) { + rc->yaw[player_idx] = rc->target_yaw[player_idx]; + } else if (diff > 0.0f) { + rc->yaw[player_idx] += turn_speed; + } else { + rc->yaw[player_idx] -= turn_speed; + } + + /* normalize yaw to [-π, π] */ + while (rc->yaw[player_idx] > 3.14159265f) rc->yaw[player_idx] -= 2.0f * 3.14159265f; + while (rc->yaw[player_idx] < -3.14159265f) rc->yaw[player_idx] += 2.0f * 3.14159265f; + } + + /* --- updateAnimation: advance both animation tracks --- */ + + /* secondary (pose): select based on visual movement state. + NPCs switch between idle and walk animations on the secondary track + (matching real OSRS client — walk/idle are secondary, attacks are primary). + this prevents the stall mechanism from freezing movement during walk. */ + int new_secondary; + if (rc->entities[player_idx].entity_type == ENTITY_NPC) { + const NpcModelMapping* nm = npc_model_lookup( + (uint16_t)rc->entities[player_idx].npc_def_id); + if (nm) { + new_secondary = rc->visual_moving[player_idx] + ? (nm->walk_anim != 65535 ? (int)nm->walk_anim : (int)nm->idle_anim) + : (int)nm->idle_anim; + } else { + new_secondary = -1; + } + } else { + new_secondary = render_select_secondary(rc, player_idx); + } + if (rc->anim[player_idx].secondary_seq_id != new_secondary) { + rc->anim[player_idx].secondary_seq_id = new_secondary; + rc->anim[player_idx].secondary_frame_idx = 0; + rc->anim[player_idx].secondary_ticks = 0; + } + + /* advance secondary frame timing */ + if (rc->anim_cache && rc->anim[player_idx].secondary_seq_id >= 0) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].secondary_seq_id); + if (seq && seq->frame_count > 0) { + int fidx = rc->anim[player_idx].secondary_frame_idx % seq->frame_count; + int delay = seq->frames[fidx].delay > 0 ? seq->frames[fidx].delay : 1; + rc->anim[player_idx].secondary_ticks++; + if (rc->anim[player_idx].secondary_ticks >= delay) { + rc->anim[player_idx].secondary_ticks = 0; + rc->anim[player_idx].secondary_frame_idx = + (fidx + 1) % seq->frame_count; + } + } + } + + /* advance primary frame timing (if active) */ + if (rc->anim_cache && rc->anim[player_idx].primary_seq_id >= 0) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].primary_seq_id); + if (seq && seq->frame_count > 0) { + int fidx = rc->anim[player_idx].primary_frame_idx % seq->frame_count; + int delay = seq->frames[fidx].delay > 0 ? seq->frames[fidx].delay : 1; + rc->anim[player_idx].primary_ticks++; + if (rc->anim[player_idx].primary_ticks >= delay) { + rc->anim[player_idx].primary_ticks = 0; + int next = (fidx + 1) % seq->frame_count; + rc->anim[player_idx].primary_frame_idx = next; + /* detect loop completion (wrapped back to 0) */ + if (next == 0) { + rc->anim[player_idx].primary_loops++; + } + } + } + } +} + +/** + * Get world position from sub-tile coordinates (128 units = 1 tile). + */ +static void render_get_visual_pos( + RenderClient* rc, int player_idx, + float* out_x, float* out_z, float* out_ground +) { + /* convert sub-tile to world (128 units per tile) */ + float tile_x = (float)rc->sub_x[player_idx] / 128.0f; + float tile_y = (float)rc->sub_y[player_idx] / 128.0f; + + *out_x = tile_x; + *out_z = -tile_y; + + if (rc->terrain) { + *out_ground = terrain_height_avg(rc->terrain, + (int)tile_x, (int)tile_y); + } else { + *out_ground = 2.0f; + } +} + +/* ======================================================================== */ +/* hit splats */ +/* ======================================================================== */ + +/* advance splat animation by one client tick (20ms). + exact OSRS logic from Client.java:6107-6143 (mode 2 animated): + - hitmarkMove starts at +5.0, decrements by 0.25 until -5.0 (40 ticks to settle) + - hitmarkTrans starts at 230, stays there (mode 2 clamp means fade at -26 never fires) + - hitsLoopCycle expires after 70 client ticks → splat just disappears */ +static void render_update_splats_client_tick(RenderClient* rc) { + for (int p = 0; p < rc->entity_count; p++) { + for (int i = 0; i < RENDER_SPLATS_PER_PLAYER; i++) { + HitSplat* s = &rc->splats[p][i]; + if (!s->active) continue; + /* OSRS splats stay in place — no vertical drift */ + s->ticks_remaining--; + if (s->ticks_remaining <= 0) { + s->active = 0; + } + } + } +} + +/* OSRS Entity.damage(): find first expired slot, init with standard values */ +static void render_push_splat(RenderClient* rc, int damage, int pidx) { + for (int i = 0; i < RENDER_SPLATS_PER_PLAYER; i++) { + if (!rc->splats[pidx][i].active) { + rc->splats[pidx][i] = (HitSplat){ + .active = 1, + .damage = damage, + .hitmark_move = 5.0, + .hitmark_trans = 230, + .ticks_remaining = 70, + }; + return; + } + } + /* all 4 slots full: overwrite the one closest to expiry */ + int oldest = 0; + for (int i = 1; i < RENDER_SPLATS_PER_PLAYER; i++) { + if (rc->splats[pidx][i].ticks_remaining < rc->splats[pidx][oldest].ticks_remaining) + oldest = i; + } + rc->splats[pidx][oldest] = (HitSplat){ + .active = 1, + .damage = damage, + .hitmark_move = 5.0, + .hitmark_trans = 230, + .ticks_remaining = 70, + }; +} + + +/* ======================================================================== */ +/* drawing: grid */ +/* ======================================================================== */ + +static void render_draw_grid(RenderClient* rc, OsrsPvp* env) { + const CollisionMap* cmap = rc->collision_map; + int ts = RENDER_TILE_SIZE; + + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + int wx = rc->arena_base_x + dx; + int wy = rc->arena_base_y + dy; + int sx = render_world_to_screen_x_rc(rc, wx); + int sy = render_world_to_screen_y_rc(rc, wy); + + /* collision overlay */ + if (rc->show_collision && cmap != NULL) { + int flags = collision_get_flags(cmap, 0, + wx + rc->collision_world_offset_x, + wy + rc->collision_world_offset_y); + if (flags & COLLISION_BLOCKED) { + DrawRectangle(sx, sy, ts, ts, COLOR_BLOCKED); + } else if (flags & COLLISION_BRIDGE) { + DrawRectangle(sx, sy, ts, ts, COLOR_BRIDGE); + } else if (flags & 0x0FF) { + /* any wall flag in lower byte */ + DrawRectangle(sx, sy, ts, ts, COLOR_WALL); + } + + /* wall segment lines (2px on tile edges) */ + if (flags & (COLLISION_WALL_NORTH | COLLISION_IMPENETRABLE_WALL_NORTH)) + DrawRectangle(sx, sy, ts, 2, COLOR_WALL_LINE); + if (flags & (COLLISION_WALL_SOUTH | COLLISION_IMPENETRABLE_WALL_SOUTH)) + DrawRectangle(sx, sy + ts - 2, ts, 2, COLOR_WALL_LINE); + if (flags & (COLLISION_WALL_WEST | COLLISION_IMPENETRABLE_WALL_WEST)) + DrawRectangle(sx, sy, 2, ts, COLOR_WALL_LINE); + if (flags & (COLLISION_WALL_EAST | COLLISION_IMPENETRABLE_WALL_EAST)) + DrawRectangle(sx + ts - 2, sy, 2, ts, COLOR_WALL_LINE); + } + + /* encounter arena: color tiles based on encounter type */ + if (env->encounter_def && cmap == NULL) { + if (rc->npc_model_cache) { + /* inferno: dark cave floor */ + int shade = 18 + ((dx * 7 + dy * 13) % 10); + DrawRectangle(sx, sy, ts, ts, CLITERAL(Color){ + (unsigned char)(shade + 3), (unsigned char)(shade - 1), + (unsigned char)(shade - 3), 255 }); + } else { + /* zulrah: platform vs water */ + int on_plat = (dx >= ZUL_PLATFORM_MIN && dx <= ZUL_PLATFORM_MAX && + dy >= ZUL_PLATFORM_MIN && dy <= ZUL_PLATFORM_MAX); + if (on_plat) + DrawRectangle(sx, sy, ts, ts, CLITERAL(Color){ 30, 60, 30, 255 }); + else + DrawRectangle(sx, sy, ts, ts, CLITERAL(Color){ 20, 30, 50, 255 }); + } + } + + /* grid lines */ + DrawRectangleLines(sx, sy, ts, ts, COLOR_GRID); + } + } + + /* encounter overlay: toxic clouds in 2D */ + if (env->encounter_def) { + EncounterOverlay* ov = &rc->encounter_overlay; + + /* toxic clouds: 3x3 venom overlay per cloud (x,y = SW corner of 3x3 area) */ + for (int i = 0; i < ov->cloud_count; i++) { + if (!ov->clouds[i].active) continue; + for (int cdx = 0; cdx < 3; cdx++) { + for (int cdy = 0; cdy < 3; cdy++) { + int cx = ov->clouds[i].x + cdx; + int cy = ov->clouds[i].y + cdy; + int csx = render_world_to_screen_x_rc(rc, cx); + int csy = render_world_to_screen_y_rc(rc, cy); + DrawRectangle(csx, csy, ts, ts, CLITERAL(Color){ 60, 140, 40, 120 }); + /* darker border for tile definition */ + DrawRectangleLines(csx, csy, ts, ts, CLITERAL(Color){ 40, 100, 20, 150 }); + } + } + } + + /* boss hitbox: NxN form-colored tiles */ + if (rc->show_debug && ov->boss_visible && ov->boss_size > 0) { + Color form_col; + switch (ov->boss_form) { + case 0: form_col = CLITERAL(Color){ 50, 200, 50, 60 }; break; + case 1: form_col = CLITERAL(Color){ 200, 50, 50, 60 }; break; + case 2: form_col = CLITERAL(Color){ 50, 100, 255, 60 }; break; + default: form_col = CLITERAL(Color){ 200, 200, 200, 60 }; break; + } + for (int bx = 0; bx < ov->boss_size; bx++) { + for (int by = 0; by < ov->boss_size; by++) { + int bsx = render_world_to_screen_x_rc(rc, ov->boss_x + bx); + int bsy = render_world_to_screen_y_rc(rc, ov->boss_y + by); + DrawRectangle(bsx, bsy, ts, ts, form_col); + } + } + /* border */ + Color border = form_col; border.a = 200; + int bsx0 = render_world_to_screen_x_rc(rc, ov->boss_x); + int bsy0 = render_world_to_screen_y_rc(rc, ov->boss_y); + DrawRectangleLines(bsx0, bsy0, ts * ov->boss_size, ts * ov->boss_size, border); + } + + /* melee target tile */ + if (ov->melee_target_active) { + int msx = render_world_to_screen_x_rc(rc, ov->melee_target_x); + int msy = render_world_to_screen_y_rc(rc, ov->melee_target_y); + DrawRectangle(msx, msy, ts, ts, CLITERAL(Color){ 255, 50, 50, 120 }); + DrawRectangleLines(msx, msy, ts, ts, CLITERAL(Color){ 255, 50, 50, 220 }); + } + + /* snakelings: small colored squares */ + for (int i = 0; i < ov->snakeling_count; i++) { + if (!ov->snakelings[i].active) continue; + int ssx = render_world_to_screen_x_rc(rc, ov->snakelings[i].x); + int ssy = render_world_to_screen_y_rc(rc, ov->snakelings[i].y); + Color sc = ov->snakelings[i].is_magic + ? CLITERAL(Color){ 100, 150, 255, 200 } + : CLITERAL(Color){ 255, 150, 50, 200 }; + DrawRectangle(ssx + 3, ssy + 3, ts - 6, ts - 6, sc); + } + + /* in-flight projectiles (interpolated at 50 Hz) */ + for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { + FlightProjectile* fp = &rc->flights[i]; + if (!fp->active) continue; + float t = fp->progress; + float cur_x = fp->src_x + (fp->dst_x - fp->src_x) * t; + float cur_y = fp->src_y + (fp->dst_y - fp->src_y) * t; + int psx = render_world_to_screen_x_rc(rc, (int)fp->src_x) + ts / 2; + int psy = render_world_to_screen_y_rc(rc, (int)fp->src_y) + ts / 2; + int pcx = render_world_to_screen_x_rc(rc, (int)cur_x) + ts / 2; + int pcy = render_world_to_screen_y_rc(rc, (int)cur_y) + ts / 2; + Color pc; + switch (fp->style) { + case 0: pc = CLITERAL(Color){ 80, 220, 80, 255 }; break; + case 1: pc = CLITERAL(Color){ 80, 130, 255, 255 }; break; + case 2: pc = CLITERAL(Color){ 255, 80, 80, 255 }; break; + default: pc = WHITE; break; + } + DrawLine(psx, psy, pcx, pcy, pc); + DrawCircle(pcx, pcy, 4.0f, pc); + } + } +} + +/* ======================================================================== */ +/* drawing: players */ +/* ======================================================================== */ + +static const char* render_prayer_label(OverheadPrayer p) { + switch (p) { + case PRAYER_PROTECT_MAGIC: return "Ma"; + case PRAYER_PROTECT_RANGED: return "Ra"; + case PRAYER_PROTECT_MELEE: return "Me"; + case PRAYER_SMITE: return "Sm"; + case PRAYER_REDEMPTION: return "Re"; + default: return NULL; + } +} + +static const char* render_gear_label(GearSet g) { + switch (g) { + case GEAR_MELEE: return "M"; + case GEAR_RANGED: return "R"; + case GEAR_MAGE: return "Ma"; + case GEAR_SPEC: return "S"; + case GEAR_TANK: return "T"; + default: return "?"; + } +} + +/* draw zulrah safe spot markers on the 2D grid. + S key toggles. shows all 15 stand locations as colored diamonds, + with the current phase's active stand/stall highlighted. */ +static void render_draw_safe_spots(RenderClient* rc, OsrsPvp* env) { + if (!env->encounter_def || !env->encounter_state) return; + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + if (strcmp(edef->name, "zulrah") != 0) return; + + int ts = RENDER_TILE_SIZE; + ZulrahState* zs = (ZulrahState*)env->encounter_state; + + /* current phase's active stand + stall */ + const ZulRotationPhase* phase = zul_current_phase(zs); + int active_stand = phase->stand; + int active_stall = phase->stall; + + for (int i = 0; i < ZUL_NUM_STAND_LOCATIONS; i++) { + int lx = ZUL_STAND_COORDS[i][0]; + int ly = ZUL_STAND_COORDS[i][1]; + int sx = render_world_to_screen_x_rc(rc, rc->arena_base_x + lx); + int sy = render_world_to_screen_y_rc(rc, rc->arena_base_y + ly); + + /* color: green for active stand, yellow for active stall, + dim cyan for inactive spots */ + Color col; + if (i == active_stand) + col = (Color){0, 255, 0, 180}; + else if (i == active_stall) + col = (Color){255, 255, 0, 180}; + else + col = (Color){0, 180, 180, 80}; + + /* draw diamond shape */ + int cx = sx + ts / 2, cy = sy + ts / 2; + int r = ts / 2 - 1; + DrawTriangle( + (Vector2){(float)cx, (float)(cy - r)}, + (Vector2){(float)(cx - r), (float)cy}, + (Vector2){(float)(cx + r), (float)cy}, col); + DrawTriangle( + (Vector2){(float)(cx - r), (float)cy}, + (Vector2){(float)cx, (float)(cy + r)}, + (Vector2){(float)(cx + r), (float)cy}, col); + + /* label with index */ + DrawText(TextFormat("%d", i), sx + 2, sy + 2, 8, WHITE); + } +} + +static void render_draw_players(RenderClient* rc) { + int ts = RENDER_TILE_SIZE; + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + Color color = (i == 0) ? COLOR_P0 : COLOR_P1; + int sx = render_world_to_screen_x_rc(rc, p->x); + int sy = render_world_to_screen_y_rc(rc, p->y); + int inset = 3; + + /* NPC coloring: form-specific for Zulrah, red for snakelings */ + if (p->entity_type == ENTITY_NPC) { + if (p->npc_def_id == 2042) color = GREEN; + else if (p->npc_def_id == 2043) color = RED; + else if (p->npc_def_id == 2044) color = CLITERAL(Color){ 80, 140, 255, 255 }; + else color = CLITERAL(Color){ 200, 50, 50, 200 }; + + /* skip invisible NPCs (e.g. diving Zulrah) */ + if (!p->npc_visible) continue; + } + + /* player/entity body */ + DrawRectangle(sx + inset, sy + inset, ts - inset * 2, ts - inset * 2, color); + + /* freeze overlay */ + if (p->frozen_ticks > 0) { + DrawRectangle(sx, sy, ts, ts, COLOR_FREEZE); + } + + /* HP bar (above tile) */ + int bar_w = ts - 2; + int bar_h = 3; + int bar_x = sx + 1; + int bar_y = sy - bar_h - 1; + float hp_frac = (float)p->current_hitpoints / (float)p->base_hitpoints; + if (hp_frac < 0.0f) hp_frac = 0.0f; + if (hp_frac > 1.0f) hp_frac = 1.0f; + + Color hp_color = (hp_frac > 0.5f) ? COLOR_HP_GREEN : COLOR_HP_RED; + DrawRectangle(bar_x, bar_y, bar_w, bar_h, COLOR_HP_BG); + DrawRectangle(bar_x, bar_y, (int)(bar_w * hp_frac), bar_h, hp_color); + + /* spec energy bar (thin, below HP bar) */ + float spec_frac = p->special_energy / 100.0f; + DrawRectangle(bar_x, bar_y - 2, (int)(bar_w * spec_frac), 1, COLOR_SPEC_BAR); + + /* overhead prayer label (above HP bar) */ + const char* pray_lbl = render_prayer_label(p->prayer); + if (pray_lbl) { + DrawText(pray_lbl, sx + 2, bar_y - 10, 8, WHITE); + } + + /* gear label (inside tile, bottom) */ + const char* gear_lbl = render_gear_label(p->visible_gear); + DrawText(gear_lbl, sx + 2, sy + ts - 10, 8, WHITE); + + /* veng indicator */ + if (p->veng_active) { + DrawText("V", sx + ts - 8, sy + 1, 8, COLOR_VENG); + } + } +} + +/* ======================================================================== */ +/* drawing: destination markers */ +/* ======================================================================== */ + +static void render_draw_dest_markers(RenderClient* rc) { + int ts = RENDER_TILE_SIZE; + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + Color dest_color = (i == 0) ? COLOR_P0_LIGHT : COLOR_P1_LIGHT; + if (p->dest_x != p->x || p->dest_y != p->y) { + int sx = render_world_to_screen_x_rc(rc, p->dest_x); + int sy = render_world_to_screen_y_rc(rc, p->dest_y); + DrawRectangleLines(sx + 1, sy + 1, ts - 2, ts - 2, dest_color); + } + } +} + +/* ======================================================================== */ +/* drawing: splats */ +/* ======================================================================== */ + +/* draw a hitsplat using the actual cache sprites (317 mode 0). + Client.java:6052-6073: hitMarks[type].drawSprite(spriteDrawX - 12, spriteDrawY - 12) + then smallFont.drawText centered on the sprite. + sprite index: 0=blue(miss), 1=red(regular hit). sprites are 24x23px. */ +static void render_draw_hitmark(RenderClient* rc, int cx, int cy, int damage, int opacity) { + unsigned char a = (unsigned char)(opacity > 255 ? 255 : (opacity < 0 ? 0 : opacity)); + int sprite_idx = (damage > 0) ? 1 : 0; /* red for hits, blue for misses */ + + if (rc->hitmark_sprites_loaded) { + /* draw the actual cache sprite, centered at (cx, cy). + OSRS draws at spriteDrawX-12, spriteDrawY-12 (centering a 24x23 sprite) */ + Texture2D tex = rc->hitmark_sprites[sprite_idx]; + float draw_x = (float)cx - (float)tex.width / 2.0f; + float draw_y = (float)cy - (float)tex.height / 2.0f; + DrawTexture(tex, (int)draw_x, (int)draw_y, (Color){ 255, 255, 255, a }); + } else { + /* fallback: colored circle if sprites missing */ + Color bg = (damage > 0) ? (Color){ 175, 25, 25, a } : (Color){ 65, 105, 225, a }; + DrawCircle(cx, cy, 12.0f, bg); + } + + /* damage number: white text with black shadow, centered on the sprite. + OSRS Client.java:6070-6071: smallFont.drawText at spriteDrawY+5, spriteDrawX */ + const char* txt = TextFormat("%d", damage); + int tw = MeasureText(txt, 10); + DrawText(txt, cx - tw / 2 + 1, cy - 4, 10, (Color){ 0, 0, 0, a }); + DrawText(txt, cx - tw / 2, cy - 5, 10, (Color){ 255, 255, 255, a }); +} + +/* slot offset layout from Client.java:6052-6072 (mode 0, used across modes): + slot 0: center + slot 1: up 20px + slot 2: left 15px, up 10px + slot 3: right 15px, up 10px */ +static void render_splat_slot_offset(int slot, int* dx, int* dy) { + switch (slot) { + case 0: *dx = 0; *dy = 0; break; + case 1: *dx = 0; *dy = -20; break; + case 2: *dx = -15; *dy = -10; break; + case 3: *dx = 15; *dy = -10; break; + default: *dx = 0; *dy = 0; break; + } +} + +/* 2D mode: draw splats at entity tile positions */ +static void render_draw_splats_2d(RenderClient* rc) { + for (int p = 0; p < rc->entity_count; p++) { + RenderEntity* pl = &rc->entities[p]; + int base_x = render_world_to_screen_x_rc(rc, pl->x) + RENDER_TILE_SIZE / 2; + int base_y = render_world_to_screen_y_rc(rc, pl->y) + RENDER_TILE_SIZE / 2; + + for (int i = 0; i < RENDER_SPLATS_PER_PLAYER; i++) { + HitSplat* s = &rc->splats[p][i]; + if (!s->active) continue; + int slot_dx, slot_dy; + render_splat_slot_offset(i, &slot_dx, &slot_dy); + int sx = base_x + slot_dx; + int sy = base_y + slot_dy + (int)s->hitmark_move; + render_draw_hitmark(rc, sx, sy, s->damage, s->hitmark_trans); + } + } +} + +/* ======================================================================== */ +/* drawing: header */ +/* ======================================================================== */ + +static void render_draw_header(RenderClient* rc, OsrsPvp* env) { + DrawRectangle(0, 0, RENDER_WINDOW_W, RENDER_HEADER_HEIGHT, COLOR_HEADER_BG); + + /* left: tick + speed + pause/rewind */ + const char* speed_txt = (rc->ticks_per_second > 0.0f) + ? TextFormat("%.0f t/s", rc->ticks_per_second) + : "max"; + const char* mode_txt = ""; + if (rc->history_cursor >= 0) { + mode_txt = TextFormat(" [REWIND %d/%d]", rc->history_cursor + 1, rc->history_count); + } else if (rc->is_paused) { + mode_txt = " [PAUSED]"; + } + int hdr_tick = env->tick; + if (env->encounter_def && env->encounter_state) + hdr_tick = ((const EncounterDef*)env->encounter_def)->get_tick(env->encounter_state); + DrawText(TextFormat("Tick: %d | Speed: %s%s", hdr_tick, speed_txt, mode_txt), + 10, 12, 16, rc->history_cursor >= 0 ? COLOR_VENG : COLOR_TEXT); + + /* human control indicator */ + human_draw_hud(&rc->human_input); + + /* right: HP summary (show first 2 entities) */ + if (rc->entity_count >= 2) { + RenderEntity* p0 = &rc->entities[0]; + RenderEntity* p1 = &rc->entities[1]; + const char* hp_txt = TextFormat("P0: %d/%d P1: %d/%d", + p0->current_hitpoints, p0->base_hitpoints, + p1->current_hitpoints, p1->base_hitpoints); + int hp_w = MeasureText(hp_txt, 16); + DrawText(hp_txt, RENDER_GRID_W - hp_w - 10, 12, 16, COLOR_TEXT); + } else if (rc->entity_count == 1) { + RenderEntity* p0 = &rc->entities[0]; + const char* hp_txt = TextFormat("P0: %d/%d", + p0->current_hitpoints, p0->base_hitpoints); + int hp_w = MeasureText(hp_txt, 16); + DrawText(hp_txt, RENDER_GRID_W - hp_w - 10, 12, 16, COLOR_TEXT); + } +} + +/* ======================================================================== */ +/* drawing: NPC/boss info panel (below GUI tabs) */ +/* ======================================================================== */ + +/** Look up inferno NPC name from npc_def_id. returns NULL if not an inferno NPC. */ +static const char* inferno_npc_name(int npc_def_id) { + switch (npc_def_id) { + case 7691: return "Jal-Nib"; + case 7692: return "Jal-MejRah"; + case 7693: return "Jal-Ak"; + case 7694: return "Jal-AkRek-Ket"; + case 7695: return "Jal-AkRek-Xil"; + case 7696: return "Jal-AkRek-Mej"; + case 7697: return "Jal-ImKot"; + case 7698: return "Jal-Xil"; + case 7699: return "Jal-Zek"; + case 7700: return "JalTok-Jad"; + case 7701: return "Yt-HurKot"; + case 7706: return "TzKal-Zuk"; + case 7707: return "Ancestral Glyph"; + case 7708: return "Jal-MejJak"; + default: return NULL; + } +} + +static void render_draw_panel_npc(int x, int y, RenderEntity* p, OsrsPvp* env) { + int line_h = 14; + + /* determine NPC display name and color from npc_def_id */ + const char* npc_name = NULL; + Color name_color = COLOR_TEXT; + + /* zulrah forms */ + if (p->npc_def_id == 2042) { npc_name = "Zulrah [GREEN]"; name_color = GREEN; } + else if (p->npc_def_id == 2043) { npc_name = "Zulrah [RED]"; name_color = RED; } + else if (p->npc_def_id == 2044) { npc_name = "Zulrah [BLUE]"; name_color = CLITERAL(Color){ 80, 140, 255, 255 }; } + + /* inferno NPCs */ + if (!npc_name) { + const char* inf_name = inferno_npc_name(p->npc_def_id); + if (inf_name) { + npc_name = inf_name; + name_color = CLITERAL(Color){ 255, 120, 50, 255 }; /* inferno orange */ + } + } + + if (!npc_name) npc_name = TextFormat("NPC %d", p->npc_def_id); + + DrawText(npc_name, x, y, 14, name_color); + y += line_h + 4; + + DrawText(TextFormat("HP: %d / %d", p->current_hitpoints, p->base_hitpoints), x, y, 10, COLOR_TEXT); + y += line_h; + DrawText(TextFormat("Pos: (%d, %d)", p->x, p->y), x, y, 10, COLOR_TEXT_DIM); + y += line_h; + + /* encounter-specific state overlay */ + if (env->encounter_def && env->encounter_state) { + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + + if (strcmp(edef->name, "zulrah") == 0) { + /* zulrah-specific state */ + ZulrahState* zs = (ZulrahState*)env->encounter_state; + DrawText(TextFormat("Visible: %s", zs->zulrah_visible ? "yes" : "no"), x, y, 10, COLOR_TEXT_DIM); + y += line_h; + DrawText(TextFormat("Phase: %d Surface: %d %s", zs->phase_timer, zs->surface_timer, + zs->is_diving ? "DIVING" : ""), x, y, 10, zs->is_diving ? COLOR_FREEZE : COLOR_TEXT_DIM); + y += line_h; + const char* rot_names[] = { "Magma A", "Magma B", "Serp", "Tanz" }; + const char* rot_name = (zs->rotation_index >= 0 && zs->rotation_index < 4) + ? rot_names[zs->rotation_index] : "???"; + DrawText(TextFormat("Rotation: %s (phase %d/%d)", rot_name, + zs->phase_index + 1, + (zs->rotation_index >= 0 && zs->rotation_index < 4) + ? ZUL_ROT_LENGTHS[zs->rotation_index] : 0), + x, y, 10, COLOR_TEXT); + y += line_h; + DrawText(TextFormat("Action: %d/%d (timer %d)", zs->action_index, + zs->action_progress, zs->action_timer), x, y, 10, COLOR_TEXT_DIM); + y += line_h; + + int snakes = 0, clouds = 0; + for (int i = 0; i < ZUL_MAX_SNAKELINGS; i++) + if (zs->snakelings[i].active) snakes++; + for (int i = 0; i < ZUL_MAX_CLOUDS; i++) + if (zs->clouds[i].active) clouds++; + DrawText(TextFormat("Snakelings: %d Clouds: %d", snakes, clouds), x, y, 10, COLOR_TEXT_DIM); + + } else if (strcmp(edef->name, "inferno") == 0) { + /* inferno-specific state */ + InfernoState* is = (InfernoState*)env->encounter_state; + DrawText(TextFormat("Wave: %d / %d", is->wave + 1, INF_NUM_WAVES), x, y, 10, COLOR_TEXT); + y += line_h; + + int active_npcs = 0; + for (int i = 0; i < INF_MAX_NPCS; i++) + if (is->npcs[i].active) active_npcs++; + DrawText(TextFormat("NPCs: %d active", active_npcs), x, y, 10, COLOR_TEXT_DIM); + y += line_h; + + int pillars_alive = 0; + for (int i = 0; i < INF_NUM_PILLARS; i++) + if (is->pillars[i].active) pillars_alive++; + DrawText(TextFormat("Pillars: %d / %d", pillars_alive, INF_NUM_PILLARS), x, y, 10, COLOR_TEXT_DIM); + } + } + (void)y; +} + +/* render_draw_panel removed — replaced by gui_draw() in osrs_pvp_gui.h */ + +/* ======================================================================== */ +/* drawing: 3D world mode */ +/* ======================================================================== */ + +static Camera3D render_build_3d_camera(RenderClient* rc) { + Camera3D cam = { 0 }; + float cx = rc->cam_target_x; + float cz = rc->cam_target_z; + /* sample terrain height at camera target (heightmap uses OSRS coords, negate Z back) */ + float cy = (rc->terrain) ? terrain_height_at(rc->terrain, (int)cx, (int)(-cz)) : 2.0f; + + float d = rc->cam_dist; + float px = cx + d * cosf(rc->cam_pitch) * sinf(rc->cam_yaw); + float py = cy + d * sinf(rc->cam_pitch); + float pz = cz + d * cosf(rc->cam_pitch) * cosf(rc->cam_yaw); + + cam.position = (Vector3){ px, py, pz }; + cam.target = (Vector3){ cx, cy, cz }; + cam.up = (Vector3){ 0.0f, 1.0f, 0.0f }; + cam.fovy = 60.0f; + cam.projection = CAMERA_PERSPECTIVE; + return cam; +} + +/* ======================================================================== */ +/* animation selection */ +/* ======================================================================== */ + +/* animation sequence IDs (from OSRS 317 cache via export_animations.py) */ +#define ANIM_SEQ_IDLE 808 +#define ANIM_SEQ_WALK 819 +#define ANIM_SEQ_RUN 824 +#define ANIM_SEQ_EAT 829 +#define ANIM_SEQ_DEATH 836 +#define ANIM_SEQ_CAST_STANDARD 1162 +#define ANIM_SEQ_CAST_BARRAGE 1979 +#define ANIM_SEQ_CAST_VENG 4410 +#define ANIM_SEQ_BLOCK_SHIELD 1156 +#define ANIM_SEQ_BLOCK_MELEE 424 + +/** + * Get the attack animation ID for a weapon item (database index). + * Returns normal attack anim, or special attack anim if is_special. + */ +static int render_get_attack_anim(uint8_t weapon_db_idx, int is_special) { + if (weapon_db_idx >= NUM_ITEMS) return 422; /* generic punch */ + + switch (weapon_db_idx) { + case ITEM_WHIP: return 1658; + case ITEM_GHRAZI_RAPIER: return 8145; + case ITEM_INQUISITORS_MACE: return is_special ? 1060 : 400; + case ITEM_STAFF_OF_DEAD: return 414; + case ITEM_KODAI_WAND: return 414; + case ITEM_VOLATILE_STAFF: return is_special ? 8532 : 414; + case ITEM_AHRIM_STAFF: return 393; + case ITEM_ZURIELS_STAFF: return 393; + case ITEM_DRAGON_DAGGER: return is_special ? 1062 : 376; + case ITEM_DRAGON_CLAWS: return is_special ? 7514 : 393; + case ITEM_AGS: return is_special ? 7644 : 7045; + case ITEM_ANCIENT_GS: return is_special ? 7644 : 7045; + case ITEM_GRANITE_MAUL: return is_special ? 1667 : 1665; + case ITEM_ELDER_MAUL: return 7516; + case ITEM_STATIUS_WARHAMMER: return is_special ? 1378 : 401; + case ITEM_VOIDWAKER: return is_special ? 1378 : 401; + case ITEM_VESTAS: return is_special ? 7515 : 390; + case ITEM_RUNE_CROSSBOW: + case ITEM_ARMADYL_CROSSBOW: + case ITEM_ZARYTE_CROSSBOW: return 4230; + case ITEM_DARK_BOW: return 426; + case ITEM_HEAVY_BALLISTA: return 7218; + case ITEM_MORRIGANS_JAVELIN: return 806; + /* zulrah encounter weapons */ + case ITEM_TRIDENT_OF_SWAMP: return 1167; /* HUMAN_CASTWAVE_STAFF */ + case ITEM_SANGUINESTI_STAFF: return 1167; + case ITEM_EYE_OF_AYAK: return 1167; + case ITEM_MAGIC_SHORTBOW_I: return is_special ? 1074 : 426; /* snapshot / bow */ + case ITEM_BOW_OF_FAERDHINEN: return 426; /* shortbow */ + case ITEM_TWISTED_BOW: return 426; /* shortbow */ + case ITEM_TOXIC_BLOWPIPE: return is_special ? 5061 : 5061; /* blowpipe */ + default: return 422; + } +} + +/** + * Determine the primary (action) animation for this tick. + * Returns -1 if no action animation should play. + * Primary animations are server-driven in the real client: attacks, casts, etc. + * They play once then auto-expire (loopCount=1 effectively). + */ +static int render_select_primary(RenderEntity* p) { + if (p->current_hitpoints <= 0) return ANIM_SEQ_DEATH; + + if (p->attack_style_this_tick != ATTACK_STYLE_NONE) { + if (p->attack_style_this_tick == ATTACK_STYLE_MAGIC) { + /* powered staves (trident/sang/ayak) use their own cast anim */ + uint8_t wpn = p->equipped[GEAR_SLOT_WEAPON]; + if (wpn == ITEM_TRIDENT_OF_SWAMP || wpn == ITEM_SANGUINESTI_STAFF || + wpn == ITEM_EYE_OF_AYAK) + return 1167; /* HUMAN_CASTWAVE_STAFF */ + return ANIM_SEQ_CAST_BARRAGE; + } + return render_get_attack_anim( + p->equipped[GEAR_SLOT_WEAPON], p->used_special_this_tick); + } + + if (p->ate_food_this_tick || p->ate_karambwan_this_tick) { + return ANIM_SEQ_EAT; + } + + if (p->cast_veng_this_tick) { + return ANIM_SEQ_CAST_VENG; + } + + if (p->hit_landed_this_tick && p->equipped[GEAR_SLOT_SHIELD] < NUM_ITEMS) { + return ANIM_SEQ_BLOCK_SHIELD; + } + + return -1; /* no action this tick */ +} + +/** + * Determine the secondary (pose) animation based on VISUAL movement state. + * + * In the real client (nextStep), this is set based on the entity's sub-tile + * movement: idle when not moving, walk or run based on moveSpeed. We use the + * visual_moving/visual_running flags set by the client-tick loop. + */ +static int render_select_secondary(RenderClient* rc, int player_idx) { + if (!rc->visual_moving[player_idx]) return ANIM_SEQ_IDLE; + if (rc->visual_running[player_idx]) return ANIM_SEQ_RUN; + return ANIM_SEQ_WALK; +} + +/* ======================================================================== */ +/* composite model building */ +/* ======================================================================== */ + +/** + * Append a single OsrsModel's geometry into the player composite. + * Offsets face indices by the current base vertex count so the merged + * index buffer references the correct vertices. + */ +static void composite_add_model(PlayerComposite* comp, OsrsModel* om) { + if (!om->base_vertices || om->base_vert_count == 0) return; + + int bv_off = comp->base_vert_count; + int fc_off = comp->face_count; + + /* bounds check */ + if (bv_off + om->base_vert_count > COMPOSITE_MAX_BASE_VERTS) return; + if (fc_off + om->mesh.triangleCount > COMPOSITE_MAX_FACES) return; + + /* append base vertices */ + memcpy(comp->base_vertices + bv_off * 3, + om->base_vertices, om->base_vert_count * 3 * sizeof(int16_t)); + + /* append vertex skins */ + memcpy(comp->vertex_skins + bv_off, + om->vertex_skins, om->base_vert_count); + + /* append face indices (offset by base vertex count) */ + int nfi = om->mesh.triangleCount * 3; + for (int f = 0; f < nfi; f++) { + comp->face_indices[fc_off * 3 + f] = om->face_indices[f] + (uint16_t)bv_off; + } + + /* append face priority deltas (relative to this model's min, not composite global). + * prevents adjacent faces within a uniform-priority model from getting offset. */ + if (om->face_priorities) { + for (int f = 0; f < om->mesh.triangleCount; f++) { + int delta = (int)om->face_priorities[f] - (int)om->min_priority; + comp->face_pri_delta[fc_off + f] = (uint8_t)(delta > 0 ? delta : 0); + } + } else { + memset(comp->face_pri_delta + fc_off, 0, om->mesh.triangleCount); + } + + /* append expanded colors into the composite mesh color buffer */ + int exp_off = fc_off * 3; + memcpy(comp->mesh.colors + exp_off * 4, + om->mesh.colors, om->mesh.triangleCount * 3 * 4); + + comp->base_vert_count += om->base_vert_count; + comp->face_count += om->mesh.triangleCount; +} + +/** + * Rebuild a player's composite model from their visible body parts + equipment. + * Called when equipment changes or on first frame. + */ +static void composite_rebuild( + PlayerComposite* comp, ModelCache* cache, RenderEntity* p +) { + comp->base_vert_count = 0; + comp->face_count = 0; + + /* add visible body parts (hide parts covered by equipment) */ + for (int bp = 0; bp < BODY_PART_COUNT; bp++) { + int hide = 0; + if (bp == BODY_PART_HEAD) { + hide = (p->equipped[GEAR_SLOT_HEAD] < NUM_ITEMS); + } else if (bp == BODY_PART_TORSO) { + hide = (p->equipped[GEAR_SLOT_BODY] < NUM_ITEMS); + } else if (bp == BODY_PART_ARMS) { + uint8_t body_idx = p->equipped[GEAR_SLOT_BODY]; + if (body_idx < NUM_ITEMS) { + hide = item_has_sleeves(ITEM_DATABASE[body_idx].item_id); + } + } else if (bp == BODY_PART_LEGS) { + hide = (p->equipped[GEAR_SLOT_LEGS] < NUM_ITEMS); + } else if (bp == BODY_PART_HANDS) { + hide = (p->equipped[GEAR_SLOT_HANDS] < NUM_ITEMS); + } else if (bp == BODY_PART_FEET) { + hide = (p->equipped[GEAR_SLOT_FEET] < NUM_ITEMS); + } + if (hide) continue; + + OsrsModel* om = model_cache_get(cache, DEFAULT_BODY_MODELS[bp]); + if (om) composite_add_model(comp, om); + } + + /* add equipped item wield models */ + static const int VISIBLE_SLOTS[] = { + GEAR_SLOT_HEAD, GEAR_SLOT_CAPE, GEAR_SLOT_NECK, + GEAR_SLOT_WEAPON, GEAR_SLOT_SHIELD, GEAR_SLOT_BODY, + GEAR_SLOT_LEGS, GEAR_SLOT_HANDS, GEAR_SLOT_FEET, + }; + for (int s = 0; s < 9; s++) { + int slot = VISIBLE_SLOTS[s]; + uint8_t db_idx = p->equipped[slot]; + if (db_idx >= NUM_ITEMS) continue; + + uint16_t item_id = ITEM_DATABASE[db_idx].item_id; + uint32_t model_id = item_to_wield_model(item_id); + if (model_id == 0xFFFFFFFF) continue; + + OsrsModel* om = model_cache_get(cache, model_id); + if (om) composite_add_model(comp, om); + } + + /* rebuild animation state for the new composite geometry */ + if (comp->anim_state) { + anim_model_state_free(comp->anim_state); + comp->anim_state = NULL; + } + if (comp->base_vert_count > 0) { + comp->anim_state = anim_model_state_create( + comp->vertex_skins, comp->base_vert_count); + } + + /* save equipment state for change detection */ + memcpy(comp->last_equipped, p->equipped, NUM_GEAR_SLOTS); + comp->needs_rebuild = 0; +} + +/** + * Rebuild an NPC's composite from a single cache model (no equipment composition). + * Used for Zulrah forms, snakelings, and other encounter NPCs. + */ +static void composite_rebuild_npc( + PlayerComposite* comp, ModelCache* cache, ModelCache* npc_cache, int npc_def_id +) { + comp->base_vert_count = 0; + comp->face_count = 0; + + /* zero mesh buffers to prevent stale GPU data from showing as garbled geometry + if the model fails to load or exceeds composite limits */ + if (comp->mesh.vertices) + memset(comp->mesh.vertices, 0, COMPOSITE_MAX_EXP_VERTS * 3 * sizeof(float)); + if (comp->mesh.colors) + memset(comp->mesh.colors, 0, COMPOSITE_MAX_EXP_VERTS * 4); + + /* look up model ID from NPC definition */ + uint32_t model_id = 0; + const NpcModelMapping* mapping = npc_model_lookup((uint16_t)npc_def_id); + if (mapping) { + model_id = mapping->model_id; + } else { + /* snakelings and other NPCs without a mapping — try snakeling */ + model_id = SNAKELING_MODEL_ID; + } + + OsrsModel* om = model_cache_get(cache, model_id); + /* fallback: check secondary NPC model cache (inferno etc.) */ + if (!om && npc_cache) + om = model_cache_get(npc_cache, model_id); + if (om) composite_add_model(comp, om); + + /* rebuild animation state */ + if (comp->anim_state) { + anim_model_state_free(comp->anim_state); + comp->anim_state = NULL; + } + if (comp->base_vert_count > 0) { + comp->anim_state = anim_model_state_create( + comp->vertex_skins, comp->base_vert_count); + } + + comp->last_npc_def_id = npc_def_id; + comp->needs_rebuild = 0; +} + +/** + * Initialize the composite's GPU resources (once, at max capacity). + * Uses dynamic=true since we update vertices every frame. + */ +static void composite_init_gpu(PlayerComposite* comp) { + if (comp->gpu_ready) return; + + comp->mesh.vertexCount = COMPOSITE_MAX_EXP_VERTS; + comp->mesh.triangleCount = COMPOSITE_MAX_FACES; + comp->mesh.vertices = (float*)RL_CALLOC(COMPOSITE_MAX_EXP_VERTS * 3, sizeof(float)); + comp->mesh.colors = (unsigned char*)RL_CALLOC(COMPOSITE_MAX_EXP_VERTS, 4); + + UploadMesh(&comp->mesh, true); /* dynamic VBO for per-frame updates */ + comp->model = LoadModelFromMesh(comp->mesh); + comp->gpu_ready = 1; +} + +/** + * Apply per-face render priority offset to prevent z-fighting on coplanar faces. + * Pushes higher-priority faces slightly along their face normal (toward camera). + * Uses pre-computed per-face deltas (relative to each source model's min priority) + * so uniform-priority models get zero offset. + */ +static void composite_apply_priority_offset( + float* mesh_verts, const uint8_t* pri_deltas, int face_count +) { + for (int fi = 0; fi < face_count; fi++) { + int delta = (int)pri_deltas[fi]; + if (delta <= 0) continue; + + int vi = fi * 9; + float ax = mesh_verts[vi], ay = mesh_verts[vi+1], az = mesh_verts[vi+2]; + float bx = mesh_verts[vi+3], by = mesh_verts[vi+4], bz = mesh_verts[vi+5]; + float cx = mesh_verts[vi+6], cy = mesh_verts[vi+7], cz = mesh_verts[vi+8]; + + /* face normal via cross product */ + 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.001f) continue; + + /* 0.15 OSRS units per priority level (matches Python exporter) */ + float bias = (float)delta * 0.15f / len; + nx *= bias; ny *= bias; nz *= bias; + + /* push along -normal (matches exporter convention for OSRS CW winding) */ + mesh_verts[vi] -= nx; mesh_verts[vi+1] -= ny; mesh_verts[vi+2] -= nz; + mesh_verts[vi+3] -= nx; mesh_verts[vi+4] -= ny; mesh_verts[vi+5] -= nz; + mesh_verts[vi+6] -= nx; mesh_verts[vi+7] -= ny; mesh_verts[vi+8] -= nz; + } +} + +/** + * Animate composite model, upload to GPU, and draw. + */ +/** + * Apply animation(s), re-expand vertices, upload to GPU, and draw. + * + * When both primary and secondary are provided with an interleave_order, + * uses interleaved application (upper body from primary, legs from secondary). + * Otherwise falls back to single-frame application. + */ +static void composite_animate_and_draw( + PlayerComposite* comp, + const AnimFrameData* secondary_frame, const AnimFrameBase* secondary_fb, + const AnimFrameData* primary_frame, const AnimFrameBase* primary_fb, + const uint8_t* interleave_order, int interleave_count, + Matrix transform +) { + if (!comp->anim_state || comp->face_count == 0) return; + + /* apply animation transforms to base vertices */ + if (primary_frame && secondary_frame && interleave_order && interleave_count > 0) { + /* two-track: primary owns upper body, secondary owns legs */ + anim_apply_frame_interleaved( + comp->anim_state, comp->base_vertices, + secondary_frame, secondary_fb, + primary_frame, primary_fb, + interleave_order, interleave_count); + } else if (primary_frame) { + /* primary only (death, or anims without interleave_order) */ + anim_apply_frame(comp->anim_state, comp->base_vertices, + primary_frame, primary_fb); + } else if (secondary_frame) { + /* secondary only (walk/idle, no action) */ + anim_apply_frame(comp->anim_state, comp->base_vertices, + secondary_frame, secondary_fb); + } + + /* re-expand animated base verts into mesh vertex buffer */ + anim_update_mesh(comp->mesh.vertices, comp->anim_state, + comp->face_indices, comp->face_count); + + /* apply face priority offset to prevent z-fighting on coplanar faces */ + composite_apply_priority_offset( + comp->mesh.vertices, comp->face_pri_delta, comp->face_count); + + /* sanity clamp: catch degenerate animation frames that produce extreme + vertex positions (int16_t overflow in animation math). without this, + a single bad frame can create a screen-filling triangle. OSRS model + coords are typically ±2000; 10000 is already way beyond any real model. */ + { + int nv = comp->face_count * 3 * 3; + for (int i = 0; i < nv; i++) { + if (comp->mesh.vertices[i] > 10000.0f) comp->mesh.vertices[i] = 10000.0f; + else if (comp->mesh.vertices[i] < -10000.0f) comp->mesh.vertices[i] = -10000.0f; + } + } + + /* upload updated vertices and colors to GPU */ + int exp_verts = comp->face_count * 3; + UpdateMeshBuffer(comp->mesh, 0, comp->mesh.vertices, + exp_verts * 3 * sizeof(float), 0); + UpdateMeshBuffer(comp->mesh, 3, comp->mesh.colors, + exp_verts * 4, 0); + + /* draw with the current face count. CRITICAL: must set vertexCount on + model.meshes[0], NOT comp->mesh — LoadModelFromMesh copies the mesh + struct by value, so comp->mesh and model.meshes[0] are independent. + DrawModel reads model.meshes[0].vertexCount for glDrawArrays count. */ + comp->model.meshes[0].vertexCount = exp_verts; + comp->model.meshes[0].triangleCount = comp->face_count; + comp->model.transform = transform; + DrawModel(comp->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + + /* restore max counts so the VBO stays valid for next UpdateMeshBuffer */ + comp->model.meshes[0].vertexCount = COMPOSITE_MAX_EXP_VERTS; + comp->model.meshes[0].triangleCount = COMPOSITE_MAX_FACES; +} + +static void composite_free(PlayerComposite* comp) { + if (comp->gpu_ready) { + UnloadModel(comp->model); + comp->gpu_ready = 0; + } + anim_model_state_free(comp->anim_state); + comp->anim_state = NULL; +} + +/* ======================================================================== */ +/* per-player animation + composite orchestration */ +/* ======================================================================== */ + +/** + * Rebuild composite if equipment changed, run two-track animation, draw. + * + * Two-track animation system (matches OSRS client): + * - secondary: always running (idle/walk/run), loops forever + * - primary: triggered by actions (attack/cast/eat/block/death), plays + * once then expires. when active with interleave_order, overrides + * secondary for upper body groups. + */ +static void render_player_composite( + RenderClient* rc, int player_idx, Matrix transform +) { + if (!rc->model_cache) return; + + PlayerComposite* comp = &rc->composites[player_idx]; + RenderEntity* p = &rc->entities[player_idx]; + + composite_init_gpu(comp); + + /* branch on entity type: NPCs use single-model composites */ + if (p->entity_type == ENTITY_NPC) { + if (comp->needs_rebuild || comp->last_npc_def_id != p->npc_def_id) { + composite_rebuild_npc(comp, rc->model_cache, rc->npc_model_cache, p->npc_def_id); + } + } else { + if (comp->needs_rebuild || + memcmp(comp->last_equipped, p->equipped, NUM_GEAR_SLOTS) != 0) { + composite_rebuild(comp, rc->model_cache, p); + } + } + + if (!rc->anim_cache || !comp->anim_state) { + /* no animation: draw static */ + if (comp->face_count > 0) { + int exp_verts = comp->face_count * 3; + comp->model.meshes[0].vertexCount = exp_verts; + comp->model.meshes[0].triangleCount = comp->face_count; + comp->model.transform = transform; + DrawModel(comp->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + comp->model.meshes[0].vertexCount = COMPOSITE_MAX_EXP_VERTS; + comp->model.meshes[0].triangleCount = COMPOSITE_MAX_FACES; + } + return; + } + + /* --- primary track: trigger new actions and expire finished ones --- + primary is triggered per game tick (render_post_tick sets flags), + but frame advancement happens in render_client_tick at 50 Hz. + + bug fix: when the same anim fires again after expiry (e.g. two + consecutive whip attacks), we must restart it. check both seq_id + change AND whether the current one has already finished (loops > 0). */ + int new_primary; + if (p->entity_type == ENTITY_NPC) { + /* NPCs set their animation via npc_anim_id from the encounter. + idle is secondary (looping), attack/dive/surface are primary (play-once). */ + const NpcModelMapping* nm = npc_model_lookup((uint16_t)p->npc_def_id); + int idle = nm ? (int)nm->idle_anim : -1; + new_primary = (p->npc_anim_id >= 0 && p->npc_anim_id != idle) + ? p->npc_anim_id : -1; + } else { + new_primary = render_select_primary(p); + } + if (new_primary >= 0) { + int need_restart = (rc->anim[player_idx].primary_seq_id != new_primary) || + (rc->anim[player_idx].primary_loops > 0); + if (need_restart) { + rc->anim[player_idx].primary_seq_id = new_primary; + rc->anim[player_idx].primary_frame_idx = 0; + rc->anim[player_idx].primary_ticks = 0; + rc->anim[player_idx].primary_loops = 0; + } + } + + /* expire primary after one loop (death never expires) */ + if (rc->anim[player_idx].primary_seq_id >= 0 && + rc->anim[player_idx].primary_loops > 0 && + rc->anim[player_idx].primary_seq_id != ANIM_SEQ_DEATH) { + rc->anim[player_idx].primary_seq_id = -1; + } + + /* --- read current frame data (set by render_client_tick at 50 Hz) --- */ + AnimSequenceFrame *sec_sf = NULL, *pri_sf = NULL; + AnimFrameBase *sec_fb = NULL, *pri_fb = NULL; + + /* secondary frame */ + if (rc->anim[player_idx].secondary_seq_id >= 0) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].secondary_seq_id); + if (seq && seq->frame_count > 0) { + int fidx = rc->anim[player_idx].secondary_frame_idx % seq->frame_count; + AnimSequenceFrame* sf = &seq->frames[fidx]; + if (sf->frame.framebase_id != 0xFFFF) { + AnimFrameBase* fb = render_get_framebase(rc, sf->frame.framebase_id); + if (fb) { sec_sf = sf; sec_fb = fb; } + } + } + } + + /* primary frame */ + if (rc->anim[player_idx].primary_seq_id >= 0) { + AnimSequence* seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].primary_seq_id); + if (seq && seq->frame_count > 0) { + int fidx = rc->anim[player_idx].primary_frame_idx % seq->frame_count; + AnimSequenceFrame* sf = &seq->frames[fidx]; + if (sf->frame.framebase_id != 0xFFFF) { + AnimFrameBase* fb = render_get_framebase(rc, sf->frame.framebase_id); + if (fb) { pri_sf = sf; pri_fb = fb; } + } + } + } + + /* --- resolve interleave_order from the primary sequence --- */ + const uint8_t* interleave = NULL; + int interleave_count = 0; + if (pri_sf) { + AnimSequence* prim_seq = render_get_anim_sequence( + rc, (uint16_t)rc->anim[player_idx].primary_seq_id); + if (prim_seq && prim_seq->interleave_order) { + interleave = prim_seq->interleave_order; + interleave_count = prim_seq->interleave_count; + } + } + + /* --- animate and draw --- */ + composite_animate_and_draw( + comp, + sec_sf ? &sec_sf->frame : NULL, sec_fb, + pri_sf ? &pri_sf->frame : NULL, pri_fb, + interleave, interleave_count, + transform); +} + +static void render_draw_3d_world(RenderClient* rc) { + /* tighten near/far clip planes for depth buffer precision. + default 0.01/1000 = 100,000:1 ratio wastes precision and causes + z-fighting across the entire scene. 0.5/500 = 1000:1 is sufficient + for our tile-scale world (camera is never closer than ~1 tile). */ + rlSetClipPlanes(0.5, 500.0); + + Camera3D cam = render_build_3d_camera(rc); + BeginMode3D(cam); + + /* terrain mesh (PvP wilderness) or flat ground plane (encounters) */ + if (rc->terrain && rc->terrain->loaded) { + DrawModel(rc->terrain->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + + /* 3D collision overlay on terrain: semi-transparent quads at tile height */ + if (rc->show_collision && rc->collision_map) { + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + int wx = rc->arena_base_x + dx + rc->collision_world_offset_x; + int wy = rc->arena_base_y + dy + rc->collision_world_offset_y; + int flags = collision_get_flags(rc->collision_map, 0, wx, wy); + + Color col = { 0, 0, 0, 0 }; + if (flags & COLLISION_BLOCKED) { + col = CLITERAL(Color){ 200, 50, 50, 80 }; + } else if (flags & COLLISION_BRIDGE) { + col = CLITERAL(Color){ 50, 120, 220, 80 }; + } else if (flags & 0x0FF) { + col = CLITERAL(Color){ 220, 150, 40, 60 }; + } else { + col = CLITERAL(Color){ 50, 200, 50, 40 }; + } + + float tx = (float)(rc->arena_base_x + dx); + float tz = -(float)(rc->arena_base_y + dy + 1); + /* sample terrain height at tile */ + float ground = terrain_height_avg(rc->terrain, + rc->arena_base_x + dx, rc->arena_base_y + dy); + DrawCube((Vector3){ tx + 0.5f, ground + 0.05f, tz + 0.5f }, + 1.0f, 0.02f, 1.0f, col); + } + } + } + } else if (rc->npc_model_cache) { + /* inferno: dark cave floor. all tiles are walkable ground. */ + float plat_y = 2.0f; + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + float tx = (float)(rc->arena_base_x + dx); + float tz = -(float)(rc->arena_base_y + dy + 1); + + /* volcanic rock with subtle variation — bright enough to distinguish from background */ + int shade = 45 + ((dx * 7 + dy * 13) % 15); + int r = shade + ((dx * 3 + dy * 11) % 10); /* slight reddish tint */ + Color c = { (unsigned char)r, (unsigned char)(shade - 3), (unsigned char)(shade - 6), 255 }; + DrawCube((Vector3){ tx + 0.5f, plat_y - 0.05f, tz + 0.5f }, + 1.0f, 0.1f, 1.0f, c); + } + } + } else { + /* zulrah / generic encounter: raised green platform over blue water. + the real arena is instanced so it can't be exported from the cache. */ + float water_y = 1.5f; + float plat_y = 2.0f; + + for (int dx = 0; dx < rc->arena_width; dx++) { + for (int dy = 0; dy < rc->arena_height; dy++) { + float tx = (float)(rc->arena_base_x + dx); + float tz = -(float)(rc->arena_base_y + dy + 1); + + /* determine platform vs water: use collision map if available, + otherwise fall back to hardcoded platform bounds */ + int on_plat; + if (rc->collision_map) { + int wx = rc->arena_base_x + dx + rc->collision_world_offset_x; + int wy = rc->arena_base_y + dy + rc->collision_world_offset_y; + on_plat = collision_tile_walkable(rc->collision_map, 0, wx, wy); + } else { + on_plat = (dx >= ZUL_PLATFORM_MIN && dx <= ZUL_PLATFORM_MAX && + dy >= ZUL_PLATFORM_MIN && dy <= ZUL_PLATFORM_MAX); + } + + if (on_plat) { + int shade = 35 + ((dx * 7 + dy * 13) % 15); + Color c = { (unsigned char)shade, (unsigned char)(shade * 2), (unsigned char)shade, 255 }; + DrawCube((Vector3){ tx + 0.5f, plat_y - 0.05f, tz + 0.5f }, + 1.0f, 0.1f, 1.0f, c); + } else { + int shade = 15 + ((dx * 3 + dy * 5) % 10); + Color c = { (unsigned char)(shade / 2), (unsigned char)shade, (unsigned char)(shade * 3), 255 }; + DrawCube((Vector3){ tx + 0.5f, water_y - 0.05f, tz + 0.5f }, + 1.0f, 0.1f, 1.0f, c); + } + } + } + } + + /* inferno pillars: "Rocky support" objects with 4 HP-level models. + dynamically spawned (not in static objects file). */ + if (rc->npc_model_cache && rc->gui.encounter_state) { + InfernoState* is = (InfernoState*)rc->gui.encounter_state; + float plat_y = 2.0f; + float ms = 1.0f / 128.0f; + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (!is->pillars[p].active) continue; + float hp_frac = (float)is->pillars[p].hp / (float)INF_PILLAR_HP; + + float cx = (float)is->pillars[p].x + INF_PILLAR_SIZE / 2.0f; + float cz = -(float)(is->pillars[p].y + INF_PILLAR_SIZE / 2) - 0.5f; + + if (rc->pillar_models_ready) { + /* select model by HP: 100%, 75%, 50%, 25% */ + int mi = 0; + if (hp_frac <= 0.25f) mi = 3; + else if (hp_frac <= 0.50f) mi = 2; + else if (hp_frac <= 0.75f) mi = 1; + + rlDisableBackfaceCulling(); + rc->pillar_models[mi].transform = MatrixMultiply( + MatrixScale(-ms, ms, ms), + MatrixTranslate(cx, plat_y, cz)); + DrawModel(rc->pillar_models[mi], (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } else { + /* fallback: colored DrawCube blocks */ + int base_r = (int)(140 * hp_frac + 180 * (1.0f - hp_frac)); + int base_g = (int)(130 * hp_frac + 40 * (1.0f - hp_frac)); + int base_b = (int)(100 * hp_frac + 20 * (1.0f - hp_frac)); + Color pillar_col = { (unsigned char)base_r, (unsigned char)base_g, (unsigned char)base_b, 240 }; + for (int dx = 0; dx < INF_PILLAR_SIZE; dx++) { + for (int dy = 0; dy < INF_PILLAR_SIZE; dy++) { + float tx = (float)(is->pillars[p].x + dx); + float tz2 = -(float)(is->pillars[p].y + dy + 1); + for (int h = 0; h < 3; h++) { + DrawCube((Vector3){ tx + 0.5f, plat_y + 0.5f + (float)h, tz2 + 0.5f }, + 0.95f, 0.95f, 0.95f, pillar_col); + } + } + } + } + } + } + + /* debug: highlight the last raycast-selected tile */ + if (rc->show_debug && rc->debug_hit_wx >= 0) { + float dtx = (float)rc->debug_hit_wx; + float dtz = -(float)(rc->debug_hit_wy + 1); + float dgy = rc->terrain + ? terrain_height_avg(rc->terrain, rc->debug_hit_wx, rc->debug_hit_wy) + : 2.0f; + DrawCube((Vector3){ dtx + 0.5f, dgy + 0.02f, dtz + 0.5f }, + 1.0f, 0.02f, 1.0f, (Color){ 255, 0, 255, 180 }); + DrawSphere((Vector3){ rc->debug_ray_hit_x, rc->debug_ray_hit_y, rc->debug_ray_hit_z }, + 0.1f, RED); + } + /* draw ray as line from origin forward */ + if (rc->show_debug && rc->debug_hit_wx >= 0) { + Vector3 a = rc->debug_ray_origin; + Vector3 b = { a.x + rc->debug_ray_dir.x * 50.0f, + a.y + rc->debug_ray_dir.y * 50.0f, + a.z + rc->debug_ray_dir.z * 50.0f }; + DrawLine3D(a, b, YELLOW); + } + + /* debug: draw game-logic tile positions for all entities. + green = player, cyan = NPCs. shows where the game thinks entities are + vs where the 3D model renders (which uses sub_x/sub_y interpolation). */ + if (rc->show_debug) { + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* ep = &rc->entities[i]; + if (ep->entity_type == ENTITY_NPC && !ep->npc_visible) continue; + float tx = (float)ep->x; + float ty = (float)ep->y; + float tz = -(ty + 1.0f); + float ground = rc->terrain + ? terrain_height_avg(rc->terrain, ep->x, ep->y) : 2.0f; + int sz = ep->npc_size > 1 ? ep->npc_size : 1; + Color col = (ep->entity_type == ENTITY_PLAYER) + ? CLITERAL(Color){ 0, 255, 0, 100 } + : CLITERAL(Color){ 0, 200, 255, 80 }; + for (int dx = 0; dx < sz; dx++) { + for (int dy = 0; dy < sz; dy++) { + float mx = tx + (float)dx; + float mz = tz - (float)dy; + DrawCube((Vector3){ mx + 0.5f, ground + 0.08f, mz + 0.5f }, + 0.9f, 0.04f, 0.9f, col); + } + } + } + } + + /* entity click hitboxes are now drawn as 2D convex hulls after EndMode3D */ + + /* encounter overlay: drawn on top of terrain or procedural arena */ + { + EncounterOverlay* ov = &rc->encounter_overlay; + int has_terrain = rc->terrain && rc->terrain->loaded; + + /* helper: get ground height at a tile position */ + #define OV_GROUND(tile_x, tile_y) \ + (has_terrain ? terrain_height_avg(rc->terrain, (tile_x), (tile_y)) : 2.0f) + + /* toxic clouds: object 11700 model at center of 3x3 damage area */ + float ms = 1.0f / 128.0f; + for (int i = 0; i < ov->cloud_count; i++) { + if (!ov->clouds[i].active) continue; + float ground = OV_GROUND(ov->clouds[i].x + 1, ov->clouds[i].y + 1); + + if (rc->cloud_model_ready) { + float cx = (float)ov->clouds[i].x + 1.5f; + float cz = -(float)(ov->clouds[i].y + 2) + 0.5f; + rlDisableBackfaceCulling(); + rc->cloud_model.transform = MatrixMultiply( + MatrixScale(ms, ms, ms), + MatrixTranslate(cx, ground + 0.1f, cz)); + DrawModel(rc->cloud_model, (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } else { + /* fallback: semi-transparent tiles if model not loaded */ + for (int cdx = 0; cdx < 3; cdx++) { + for (int cdy = 0; cdy < 3; cdy++) { + float fx = (float)(ov->clouds[i].x + cdx); + float fz = -(float)(ov->clouds[i].y + cdy + 1); + float tg = OV_GROUND(ov->clouds[i].x + cdx, ov->clouds[i].y + cdy); + DrawCube((Vector3){ fx + 0.5f, tg + 0.08f, fz + 0.5f }, + 0.95f, 0.06f, 0.95f, + CLITERAL(Color){ 80, 180, 50, 100 }); + } + } + } + } + + /* boss hitbox: NxN form-colored tiles on the ground */ + if (rc->show_debug && ov->boss_visible && ov->boss_size > 0) { + Color form_col; + switch (ov->boss_form) { + case 0: form_col = CLITERAL(Color){ 50, 200, 50, 80 }; break; /* green */ + case 1: form_col = CLITERAL(Color){ 200, 50, 50, 80 }; break; /* red */ + case 2: form_col = CLITERAL(Color){ 50, 100, 255, 80 }; break; /* blue */ + default: form_col = CLITERAL(Color){ 200, 200, 200, 80 }; break; + } + Color border_col = form_col; + border_col.a = 200; + int sz = ov->boss_size; + for (int bx = 0; bx < sz; bx++) { + for (int by = 0; by < sz; by++) { + int tx = ov->boss_x + bx; + int ty = ov->boss_y + by; + float ground = OV_GROUND(tx, ty); + float fx = (float)tx; + float fz = -(float)(ty + 1); + DrawCube((Vector3){ fx + 0.5f, ground + 0.04f, fz + 0.5f }, + 1.0f, 0.02f, 1.0f, form_col); + } + } + /* border outline */ + float x0 = (float)ov->boss_x; + float x1 = (float)(ov->boss_x + sz); + float z0 = -(float)(ov->boss_y + sz); + float z1 = -(float)ov->boss_y; + float border_y = OV_GROUND(ov->boss_x + sz/2, ov->boss_y + sz/2) + 0.06f; + DrawLine3D((Vector3){x0, border_y, z0}, (Vector3){x1, border_y, z0}, border_col); + DrawLine3D((Vector3){x1, border_y, z0}, (Vector3){x1, border_y, z1}, border_col); + DrawLine3D((Vector3){x1, border_y, z1}, (Vector3){x0, border_y, z1}, border_col); + DrawLine3D((Vector3){x0, border_y, z1}, (Vector3){x0, border_y, z0}, border_col); + } + + /* melee targeting indicator: red tile where boss is aiming */ + if (ov->melee_target_active) { + float ground = OV_GROUND(ov->melee_target_x, ov->melee_target_y); + float mx = (float)ov->melee_target_x; + float mz = -(float)(ov->melee_target_y + 1); + DrawCube((Vector3){ mx + 0.5f, ground + 0.06f, mz + 0.5f }, + 1.0f, 0.04f, 1.0f, + CLITERAL(Color){ 255, 50, 50, 150 }); + } + + /* snakelings: render 3D model or fallback to cubes */ + for (int i = 0; i < ov->snakeling_count; i++) { + if (!ov->snakelings[i].active) continue; + float ground = OV_GROUND(ov->snakelings[i].x, ov->snakelings[i].y); + float sx = (float)ov->snakelings[i].x + 0.5f; + float sz = -(float)(ov->snakelings[i].y + 1) + 0.5f; + if (rc->snakeling_model_ready) { + rlDisableBackfaceCulling(); + rc->snakeling_model.transform = MatrixMultiply( + MatrixScale(ms, ms, ms), + MatrixTranslate(sx, ground, sz)); + DrawModel(rc->snakeling_model, (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } else { + Color sc = ov->snakelings[i].is_magic + ? CLITERAL(Color){ 100, 150, 255, 200 } + : CLITERAL(Color){ 255, 150, 50, 200 }; + DrawCube((Vector3){ sx, ground + 0.2f, sz }, + 0.6f, 0.3f, 0.6f, sc); + } + } + + /* projectiles: render in-flight projectiles with interpolated positions. + flight_spawn() creates flights from overlay events (in render_post_tick), + flight_client_tick() advances progress at 50Hz, we just draw here. */ + for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { + FlightProjectile* fp = &rc->flights[i]; + if (!fp->active) continue; + + float src_ground = OV_GROUND((int)fp->src_x, (int)fp->src_y); + float dst_ground = OV_GROUND((int)fp->dst_x, (int)fp->dst_y); + Vector3 pos = flight_get_position(fp, src_ground, dst_ground); + + Model* proj_model = NULL; + if (fp->model_id > 0) { + proj_model = render_get_proj_model(rc, fp->model_id); + } + if (!proj_model) { + /* style-based fallback for backward compatibility */ + if (fp->style == 0 && rc->ranged_proj_model_ready) + proj_model = &rc->ranged_proj_model; + else if (fp->style == 1 && rc->magic_proj_model_ready) + proj_model = &rc->magic_proj_model; + else if (fp->style == 3 && rc->cloud_proj_model_ready) + proj_model = &rc->cloud_proj_model; + else if (fp->style == 4 && rc->ranged_proj_model_ready) + proj_model = &rc->ranged_proj_model; /* spawn orb reuses ranged mesh */ + } + + if (proj_model) { + rlDisableBackfaceCulling(); + float pms = 1.0f / 128.0f; + proj_model->transform = MatrixMultiply( + MatrixMultiply( + MatrixScale(-pms, pms, pms), + MatrixMultiply(MatrixRotateY(fp->yaw + 1.5707963f), MatrixRotateX(fp->pitch))), + MatrixTranslate(pos.x, pos.y, pos.z)); + DrawModel(*proj_model, (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } + + /* trail line from source to current position */ + if (rc->show_debug) { + Color pc; + switch (fp->style) { + case 0: pc = CLITERAL(Color){ 80, 220, 80, 150 }; break; + case 1: pc = CLITERAL(Color){ 80, 130, 255, 150 }; break; + case 2: pc = CLITERAL(Color){ 255, 80, 80, 150 }; break; + case 3: pc = CLITERAL(Color){ 50, 180, 50, 150 }; break; + case 4: pc = CLITERAL(Color){ 230, 230, 230, 150 }; break; + default: pc = WHITE; break; + } + Vector3 src_pos = { fp->src_x + 0.5f, src_ground + fp->start_height, + -(fp->src_y + 1.0f) + 0.5f }; + DrawLine3D(src_pos, pos, pc); + DrawSphere(pos, 0.12f, pc); + } + } + + /* safe spot markers: colored quads on ground at each stand location */ + if (rc->show_safe_spots && rc->gui.encounter_state) { + const EncounterDef* edef_ss = (const EncounterDef*)rc->gui.encounter_def; + if (edef_ss && strcmp(edef_ss->name, "zulrah") == 0) { + ZulrahState* zs_ss = (ZulrahState*)rc->gui.encounter_state; + const ZulRotationPhase* phase_ss = zul_current_phase(zs_ss); + int act_stand = phase_ss->stand; + int act_stall = phase_ss->stall; + + for (int si = 0; si < ZUL_NUM_STAND_LOCATIONS; si++) { + int lx = ZUL_STAND_COORDS[si][0]; + int ly = ZUL_STAND_COORDS[si][1]; + float sx = (float)(rc->arena_base_x + lx) + 0.5f; + float sz = -(float)(rc->arena_base_y + ly + 1) + 0.5f; + float gy = OV_GROUND(rc->arena_base_x + lx, rc->arena_base_y + ly); + + Color col; + if (si == act_stand) + col = (Color){0, 255, 0, 160}; + else if (si == act_stall) + col = (Color){255, 255, 0, 160}; + else + col = (Color){0, 180, 180, 80}; + + DrawCube((Vector3){ sx, gy + 0.08f, sz }, + 0.7f, 0.04f, 0.7f, col); + } + } + } + + #undef OV_GROUND + } + + /* placed objects — disable backface culling since OSRS uses flat + billboard-style quads for trees/plants (two crossing planes) */ + if (rc->objects && rc->objects->loaded) { + rlDisableBackfaceCulling(); + DrawModel(rc->objects->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } + + /* NPC models at spawn positions */ + if (rc->npcs && rc->npcs->loaded) { + rlDisableBackfaceCulling(); + DrawModel(rc->npcs->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } + + /* entity 3D models: composite body + equipment, animated as one unit */ + if (rc->model_cache) { + float ms = 1.0f / 128.0f; + + rlDisableBackfaceCulling(); + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* ep = &rc->entities[i]; + + /* skip invisible NPCs (diving, dead, etc.) */ + if (ep->entity_type == ENTITY_NPC && !ep->npc_visible) continue; + + float px, pz, ground; + render_get_visual_pos(rc, i, &px, &pz, &ground); + + /* negate X scale to fix model mirroring: OSRS models are authored + in a left-handed coordinate system but we render in right-handed + (raylib/OpenGL). negating X flips the handedness so weapons + appear in the correct (right) hand. */ + Matrix base = MatrixScale(-ms, ms, ms); + base = MatrixMultiply(base, MatrixRotateY(rc->yaw[i])); + base = MatrixMultiply(base, MatrixTranslate(px, ground, pz)); + + /* rebuild composite if equipment changed, animate, upload, draw */ + render_player_composite(rc, i, base); + + + /* project animated mesh vertices to 2D screen for convex hull click detection. + ported from RuneLite RSModelMixin.getConvexHull → Perspective.modelToCanvas. + we sample every Nth vertex for performance (full hull is overkill). */ + PlayerComposite* comp = &rc->composites[i]; + Camera3D hull_cam = render_build_3d_camera(rc); + int nv = comp->face_count * 3; /* actual used verts, not pre-allocated capacity */ + int stride = (nv > 200) ? (nv / 100) : 1; /* sample ~100 verts max */ + int hull_n = 0; + /* stack arrays for projection — max 200 sampled points */ + int hull_xs[256], hull_ys[256]; + for (int vi = 0; vi < nv && hull_n < 256; vi += stride) { + float vx = comp->mesh.vertices[vi * 3 + 0]; + float vy = comp->mesh.vertices[vi * 3 + 1]; + float vz = comp->mesh.vertices[vi * 3 + 2]; + /* transform model-space vertex by the same matrix used for drawing */ + Vector3 wv = Vector3Transform((Vector3){ vx, vy, vz }, base); + Vector2 sv = GetWorldToScreen(wv, hull_cam); + /* skip off-screen / behind camera */ + if (sv.x < -1000 || sv.x > 5000 || sv.y < -1000 || sv.y > 5000) continue; + hull_xs[hull_n] = (int)sv.x; + hull_ys[hull_n] = (int)sv.y; + hull_n++; + } + hull_compute(hull_xs, hull_ys, hull_n, &rc->entity_hulls[i]); + } + rlEnableBackfaceCulling(); + } + + /* visual effects: spell impacts, projectiles */ + if (rc->model_cache) { + rlDisableBackfaceCulling(); + float eff_scale = 1.0f / 128.0f; + int eff_ct = rc->effect_client_tick_counter; + + for (int i = 0; i < MAX_ACTIVE_EFFECTS; i++) { + ActiveEffect* e = &rc->effects[i]; + if (e->type == EFFECT_NONE) continue; + if (!e->meta) continue; + + /* look up model */ + OsrsModel* om = model_cache_get(rc->model_cache, e->meta->model_id); + if (!om) continue; + + /* position: sub-tile coords -> tile coords -> raylib world */ + float ex = (float)(e->cur_x / 128.0); + float ez = -(float)(e->cur_y / 128.0); + float ground = rc->terrain + ? terrain_height_avg(rc->terrain, (int)ex, (int)(e->cur_y / 128.0)) + : 2.0f; + float ey = ground + (float)(e->height / 128.0); + + /* apply scale from spotanim def */ + float sx = eff_scale * (float)e->meta->resize_xy / 128.0f; + float sz = eff_scale * (float)e->meta->resize_z / 128.0f; + + /* animate: apply current frame to per-effect anim state, + then write transformed vertices into the shared mesh. + note: this temporarily modifies the shared OsrsModel mesh, + which is fine since effects render sequentially. */ + if (e->anim_state && e->meta->anim_seq_id >= 0 && rc->anim_cache + && om->face_indices) { + AnimSequence* seq = render_get_anim_sequence(rc, e->meta->anim_seq_id); + if (seq && e->anim_frame < seq->frame_count) { + AnimSequenceFrame* sf = &seq->frames[e->anim_frame]; + AnimFrameBase* fb = render_get_framebase(rc, + sf->frame.framebase_id); + if (fb) { + anim_apply_frame(e->anim_state, om->base_vertices, + &sf->frame, fb); + anim_update_mesh(om->mesh.vertices, e->anim_state, + om->face_indices, om->mesh.triangleCount); + UpdateMeshBuffer(om->mesh, 0, om->mesh.vertices, + om->mesh.triangleCount * 9 * sizeof(float), 0); + } + } + } + + /* build transform */ + Matrix t = MatrixScale(-sx, sx, sz); /* negate X for handedness */ + + /* projectile orientation: yaw + pitch from trajectory direction. + uses atan2 on the velocity vector (same approach as the flight + system) to orient the model from source toward target. */ + if (e->type == EFFECT_PROJECTILE && e->started) { + /* direction in raylib coords: x_increment maps to world X, + y_increment maps to world -Z (OSRS Y → raylib -Z) */ + float dx = (float)e->x_increment; + float dz = -(float)e->y_increment; + float horiz = sqrtf(dx * dx + dz * dz); + float yaw = atan2f(dx, dz); + float pitch = atan2f((float)e->height_increment, horiz > 0.001f ? horiz : 0.001f); + t = MatrixMultiply(t, MatrixMultiply( + MatrixRotateX(pitch), MatrixRotateY(yaw))); + } + + t = MatrixMultiply(t, MatrixTranslate(ex, ey, ez)); + om->model.transform = t; + + /* spotanim fade: 20% fade in, 60% full, 20% fade out */ + Color tint = WHITE; + if (e->type == EFFECT_SPOTANIM && e->stop_tick > e->start_tick) { + int total = e->stop_tick - e->start_tick; + int elapsed = eff_ct - e->start_tick; + float progress = (float)elapsed / (float)total; + float alpha = 1.0f; + if (progress < 0.2f) alpha = progress / 0.2f; + else if (progress > 0.8f) alpha = (1.0f - progress) / 0.2f; + if (alpha < 0.0f) alpha = 0.0f; + if (alpha > 1.0f) alpha = 1.0f; + tint = (Color){ 255, 255, 255, (unsigned char)(alpha * 255) }; + } + DrawModel(om->model, (Vector3){ 0, 0, 0 }, 1.0f, tint); + } + rlEnableBackfaceCulling(); + } + + /* fight area boundary wireframe (Z negated) */ + float fa_x = (float)rc->arena_base_x; + float fa_z = -(float)rc->arena_base_y; + float fa_w = (float)rc->arena_width; + float fa_h = -(float)rc->arena_height; /* negative because Z is negated */ + float bh = rc->terrain ? terrain_height_at(rc->terrain, rc->arena_base_x, rc->arena_base_y) : 2.0f; + DrawLine3D( + (Vector3){ fa_x, bh, fa_z }, + (Vector3){ fa_x + fa_w, bh, fa_z }, YELLOW); + DrawLine3D( + (Vector3){ fa_x + fa_w, bh, fa_z }, + (Vector3){ fa_x + fa_w, bh, fa_z + fa_h }, YELLOW); + DrawLine3D( + (Vector3){ fa_x + fa_w, bh, fa_z + fa_h }, + (Vector3){ fa_x, bh, fa_z + fa_h }, YELLOW); + DrawLine3D( + (Vector3){ fa_x, bh, fa_z + fa_h }, + (Vector3){ fa_x, bh, fa_z }, YELLOW); + + /* click cross is now drawn as 2D overlay in pvp_render, not in 3D world */ + + /* debug: player→NPC LOS lines (green=can attack, red=blocked/out of range) */ + if (rc->show_debug && rc->gui.encounter_state) { + InfernoState* is = (InfernoState*)rc->gui.encounter_state; + float plat_y = 2.0f; + float ph = plat_y + 1.0f; /* player line height */ + float player_wx = (float)is->player.x + 0.5f; + float player_wz = -(float)is->player.y - 0.5f; + const EncounterLoadoutStats* ls = &is->loadout_stats[is->weapon_set]; + for (int ni = 0; ni < INF_MAX_NPCS; ni++) { + InfNPC* npc = &is->npcs[ni]; + if (!npc->active || npc->death_ticks > 0) continue; + float half = (float)(npc->size - 1) / 2.0f; + float npc_wx = (float)npc->x + half + 0.5f; + float npc_wz = -(float)npc->y - half - 0.5f; + int can_atk = encounter_player_can_attack( + is->player.x, is->player.y, + npc->x, npc->y, npc->size, + ls->attack_range, is->los_blockers, is->los_blocker_count); + Color lc = can_atk ? GREEN : RED; + DrawLine3D( + (Vector3){ player_wx, ph, player_wz }, + (Vector3){ npc_wx, ph, npc_wz }, + lc); + } + } + + EndMode3D(); +} + +/* ======================================================================== */ +/* drawing: 2D overlay models (for 2D mode) */ +/* ======================================================================== */ + +static void render_draw_models_2d_overlay(RenderClient* rc) { + if (!rc->model_cache) return; + + float hw = (float)RENDER_WINDOW_W / 2.0f; + float hh = (float)RENDER_WINDOW_H / 2.0f; + + Camera3D cam = { 0 }; + cam.position = (Vector3){ hw, hh, 1000.0f }; + cam.target = (Vector3){ hw, hh, 0.0f }; + cam.up = (Vector3){ 0.0f, 1.0f, 0.0f }; + cam.fovy = (float)RENDER_WINDOW_H; + cam.projection = CAMERA_ORTHOGRAPHIC; + + float zoom_cx = (float)RENDER_GRID_W / 2.0f; + float zoom_cy = (float)(RENDER_HEADER_HEIGHT + RENDER_GRID_H / 2.0f); + + BeginMode3D(cam); + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + + uint8_t slot_idx = p->equipped[GEAR_SLOT_WEAPON]; + uint8_t db_idx = get_item_for_slot(GEAR_SLOT_WEAPON, slot_idx); + if (db_idx == ITEM_NONE || db_idx >= NUM_ITEMS) continue; + + uint16_t item_id = ITEM_DATABASE[db_idx].item_id; + uint32_t model_id = item_to_inv_model(item_id); + if (model_id == 0xFFFFFFFF) continue; + + OsrsModel* om = model_cache_get(rc->model_cache, model_id); + if (!om) continue; + + float sx = (float)render_world_to_screen_x_rc(rc, p->x) + (float)RENDER_TILE_SIZE / 2.0f; + float sy = (float)render_world_to_screen_y_rc(rc, p->y) + (float)RENDER_TILE_SIZE / 2.0f; + + float zsx = zoom_cx + (sx - zoom_cx) * rc->zoom; + float zsy = zoom_cy + (sy - zoom_cy) * rc->zoom; + + float wx = zsx; + float wy = (float)RENDER_WINDOW_H - zsy; + + float scale = rc->model_scale * rc->zoom; + + Matrix transform = MatrixScale(scale, scale, scale); + transform = MatrixMultiply(transform, MatrixTranslate(wx, wy, 0.0f)); + + om->model.transform = transform; + DrawModel(om->model, (Vector3){ 0, 0, 0 }, 1.0f, WHITE); + } + + EndMode3D(); +} + +/* ======================================================================== */ +/* overhead status: prayer icons + HP bar (2D overlay on 3D scene) */ +/* ======================================================================== */ + +/** + * Draw overhead prayer icons and HP bars above players in 3D mode. + * + * Layout matches OSRS client (Client.java:6011-6049): + * - HP bar: 30px wide, 5px tall, green fill + red remainder, drawn at + * entity height + 15 via npcScreenPos. visible for 6s after taking damage. + * - Prayer icon: drawn above the HP bar. + * + * Both are 2D sprites/rects drawn at screen-projected head position. + */ +static void render_draw_overhead_status(RenderClient* rc, OsrsPvp* env) { + Camera3D cam = render_build_3d_camera(rc); + + /* map our OverheadPrayer enum → OSRS headIcon sprite index */ + static const int prayer_to_headicon[] = { + -1, /* PRAYER_NONE */ + 2, /* PRAYER_PROTECT_MAGIC → headIcon 2 (magic) */ + 1, /* PRAYER_PROTECT_RANGED → headIcon 1 (ranged) */ + 0, /* PRAYER_PROTECT_MELEE → headIcon 0 (melee) */ + 4, /* PRAYER_SMITE → headIcon 4 (smite) */ + 5, /* PRAYER_REDEMPTION → headIcon 5 (redemption) */ + }; + + for (int i = 0; i < rc->entity_count; i++) { + RenderEntity* p = &rc->entities[i]; + + /* skip invisible NPCs */ + if (p->entity_type == ENTITY_NPC && !p->npc_visible) continue; + + /* project entity positions to screen coordinates. + OSRS draws splats at entity.height/2 (abdomen), HP bar + prayer at top. */ + float px, pz, ground; + render_get_visual_pos(rc, i, &px, &pz, &ground); + float head_y = ground + 2.0f; + float abdomen_y = ground + 1.0f; + Vector2 screen_head = GetWorldToScreen((Vector3){ px, head_y, pz }, cam); + Vector2 screen_abdomen = GetWorldToScreen((Vector3){ px, abdomen_y, pz }, cam); + + /* skip if off screen */ + if (screen_head.x < -50 || screen_head.x > RENDER_WINDOW_W + 50 || + screen_head.y < -50 || screen_head.y > RENDER_WINDOW_H + 50) continue; + + /* hitsplats: drawn at entity.height/2 (abdomen) with slot-based layout. + OSRS Client.java:6107 — npcScreenPos(entity, entity.height / 2). + splats stay in place (no vertical drift in OSRS). */ + for (int si = 0; si < RENDER_SPLATS_PER_PLAYER; si++) { + HitSplat* s = &rc->splats[i][si]; + if (!s->active) continue; + int slot_dx, slot_dy; + render_splat_slot_offset(si, &slot_dx, &slot_dy); + int sx = (int)screen_abdomen.x + slot_dx; + int sy = (int)screen_abdomen.y + slot_dy; + render_draw_hitmark(rc, sx, sy, s->damage, s->hitmark_trans); + } + + /* track vertical offset for stacking elements above the player. + screen Y increases downward, so we go negative to go up. */ + float cursor_y = screen_head.y; + + /* HP bar: 30px wide, 5px tall, green + red (Client.java:6032-6034) + only visible for 6s after taking damage (cycleStatus timer) */ + if (env->tick < rc->hp_bar_visible_until[i]) { + int bar_w = 30; + int bar_h = 5; + float hp_frac = (float)p->current_hitpoints / (float)p->base_hitpoints; + if (hp_frac < 0.0f) hp_frac = 0.0f; + if (hp_frac > 1.0f) hp_frac = 1.0f; + int green_w = (int)(hp_frac * bar_w); + + int bar_x = (int)screen_head.x - bar_w / 2; + int bar_y = (int)cursor_y - bar_h / 2; + DrawRectangle(bar_x, bar_y, green_w, bar_h, COLOR_HP_GREEN); + DrawRectangle(bar_x + green_w, bar_y, bar_w - green_w, bar_h, COLOR_HP_RED); + cursor_y -= (float)(bar_h + 2); + } + + /* prayer icon: drawn above the HP bar */ + if (rc->prayer_icons_loaded && + p->prayer > PRAYER_NONE && p->prayer <= PRAYER_REDEMPTION) { + int icon_idx = prayer_to_headicon[p->prayer]; + if (icon_idx >= 0 && icon_idx < 6) { + Texture2D tex = rc->prayer_icons[icon_idx]; + float scale = 1.0f; + float draw_x = screen_head.x - (float)tex.width * scale / 2.0f; + float draw_y = cursor_y - (float)tex.height * scale; + DrawTextureEx(tex, (Vector2){ draw_x, draw_y }, 0.0f, scale, WHITE); + } + } + + /* debug: per-NPC combat state below the entity (only for NPCs) */ + if (rc->show_debug && p->entity_type == ENTITY_NPC && rc->gui.encounter_state) { + InfernoState* is = (InfernoState*)rc->gui.encounter_state; + int slot = p->npc_slot; + if (slot >= 0 && slot < INF_MAX_NPCS && is->npcs[slot].active) { + InfNPC* npc = &is->npcs[slot]; + int dy = (int)screen_head.y + 10; + int dx = (int)screen_head.x; + int fs = 10; + + /* attack timer + style */ + const char* style_str = "???"; + Color style_col = WHITE; + int style = (npc->type == INF_NPC_JAD) ? npc->jad_attack_style : npc->attack_style; + if (style == ATTACK_STYLE_MAGIC) { style_str = "MAG"; style_col = BLUE; } + if (style == ATTACK_STYLE_RANGED) { style_str = "RNG"; style_col = GREEN; } + if (style == ATTACK_STYLE_MELEE) { style_str = "MEL"; style_col = RED; } + + const char* atk_txt = TextFormat("ATK:%d %s", npc->attack_timer, style_str); + int tw = MeasureText(atk_txt, fs); + DrawText(atk_txt, dx - tw/2, dy, fs, style_col); + dy += fs + 1; + + /* frozen ticks */ + if (npc->frozen_ticks > 0) { + const char* frz_txt = TextFormat("FRZ:%d", npc->frozen_ticks); + int fw = MeasureText(frz_txt, fs); + DrawText(frz_txt, dx - fw/2, dy, fs, (Color){100, 200, 255, 255}); + dy += fs + 1; + } + + /* NPC→player LOS (skip nibblers — they target pillars, not player) */ + if (npc->type != INF_NPC_NIBBLER) { + int npc_los = inf_npc_has_los(is, slot); + const char* los_txt = npc_los ? "NPC>P" : "NPC>P X"; + Color los_col = npc_los ? GREEN : RED; + int lw = MeasureText(los_txt, fs); + DrawText(los_txt, dx - lw/2, dy, fs, los_col); + dy += fs + 1; + } + + /* player→NPC LOS + range */ + { + const EncounterLoadoutStats* ls = &is->loadout_stats[is->weapon_set]; + int can_atk = encounter_player_can_attack( + is->player.x, is->player.y, + npc->x, npc->y, npc->size, + ls->attack_range, is->los_blockers, is->los_blocker_count); + const char* patk_txt = can_atk ? "P>NPC" : "P>NPC X"; + Color patk_col = can_atk ? GREEN : RED; + int pw = MeasureText(patk_txt, fs); + DrawText(patk_txt, dx - pw/2, dy, fs, patk_col); + dy += fs + 1; + } + + /* blob scan state */ + if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) { + const char* scan = "SCAN:???"; + if (npc->blob_scanned_prayer == PRAYER_PROTECT_MAGIC) scan = "SCAN>RNG"; + else if (npc->blob_scanned_prayer == PRAYER_PROTECT_RANGED) scan = "SCAN>MAG"; + int sw = MeasureText(scan, fs); + DrawText(scan, dx - sw/2, dy, fs, YELLOW); + } + } + } + } +} + +/* ======================================================================== */ +/* main render entry point */ +/* ======================================================================== */ + +void pvp_render(OsrsPvp* env) { + RenderClient* rc = (RenderClient*)env->client; + if (rc == NULL) { + rc = render_make_client(); + env->client = rc; + } + + /* ensure entity pointers are current (may be called without render_post_tick + during pause, rewind, or initial frame) */ + render_populate_entities(rc, env); + + render_handle_input(rc, env); + + /* inventory mouse interaction (clicks, drags) — runs every frame. + gui functions need the full Player* (inventory, stats, etc.) */ + if (rc->entity_count > 0 && rc->gui.gui_entity_idx < rc->entity_count) { + Player* gui_p = render_get_player_ptr(env, rc->gui.gui_entity_idx); + if (gui_p) gui_inv_handle_mouse(&rc->gui, gui_p, &rc->human_input); + } + + /* run client ticks at 50 Hz (20ms each), matching the real OSRS client's + processMovement() rate. both movement AND animation advance together + in each client tick, keeping them perfectly in sync. */ + { + double dt = GetFrameTime(); + rc->client_tick_accumulator += dt; + double client_tick = 0.020; /* 20ms = OSRS client tick */ + int steps = (int)(rc->client_tick_accumulator / client_tick); + if (steps > 0) { + rc->client_tick_accumulator -= steps * client_tick; + /* cap to avoid spiral if frame rate drops badly */ + if (steps > 60) steps = 60; + for (int s = 0; s < steps; s++) { + for (int i = 0; i < rc->entity_count; i++) { + render_client_tick(rc, i); + } + /* advance visual effects, hitsplats, and projectile flights at 50 Hz */ + render_update_splats_client_tick(rc); + flight_client_tick(rc); + rc->effect_client_tick_counter++; + effect_client_tick(rc->effects, rc->effect_client_tick_counter, + rc->anim_cache); + gui_inv_tick(&rc->gui); + human_tick_visuals(&rc->human_input); + } + } + } + + BeginDrawing(); + ClearBackground(COLOR_BG); + + if (rc->mode_3d) { + /* full 3D world view */ + render_draw_3d_world(rc); + + /* overhead prayer icons (2D overlay after 3D scene) */ + render_draw_overhead_status(rc, env); + + /* debug: player/global state panel (bottom-left) */ + if (rc->show_debug && env->encounter_state) { + InfernoState* is = (InfernoState*)env->encounter_state; + int dy = RENDER_WINDOW_H - 160; + int dx = 10; + int fs = 12; + Color dc = (Color){220, 220, 220, 255}; + + /* target */ + if (is->player_attack_target >= 0 && is->player_attack_target < INF_MAX_NPCS) { + InfNPC* tn = &is->npcs[is->player_attack_target]; + const char* tname = inferno_npc_name(INF_NPC_DEF_IDS[tn->type]); + DrawText(TextFormat("TARGET: %s [#%d]", tname, is->player_attack_target), dx, dy, fs, dc); + } else { + DrawText("TARGET: none", dx, dy, fs, (Color){180, 80, 80, 255}); + } + dy += fs + 2; + + /* weapon + attack timer */ + const char* gear = is->weapon_set == INF_GEAR_MAGE ? "mage" : + is->weapon_set == INF_GEAR_TBOW ? "tbow" : "bp"; + const char* spell = is->spell_choice == ENCOUNTER_SPELL_ICE ? "ice" : + is->spell_choice == ENCOUNTER_SPELL_BLOOD ? "blood" : "none"; + DrawText(TextFormat("GEAR: %s ATK: %d/%d SPELL: %s", gear, + is->player_attack_timer, is->loadout_stats[is->weapon_set].attack_speed, spell), + dx, dy, fs, dc); + dy += fs + 2; + + /* stats */ + DrawText(TextFormat("RNG:%d MAG:%d DEF:%d", + is->player.current_ranged, is->player.current_magic, is->player.current_defence), + dx, dy, fs, dc); + dy += fs + 2; + + /* consumables */ + DrawText(TextFormat("BREW:%d REST:%d BAST:%d STAM:%d", + is->player_brew_doses, is->player_restore_doses, + is->player_bastion_doses, is->player_stamina_doses), + dx, dy, fs, dc); + dy += fs + 2; + + /* pending hits */ + int mag_hits = 0, rng_hits = 0; + for (int h = 0; h < is->player_pending_hit_count; h++) { + if (is->player_pending_hits[h].attack_style == ATTACK_STYLE_MAGIC) mag_hits++; + else rng_hits++; + } + if (is->player_pending_hit_count > 0) { + DrawText(TextFormat("INCOMING: %d (%dM %dR)", + is->player_pending_hit_count, mag_hits, rng_hits), + dx, dy, fs, (Color){255, 150, 150, 255}); + } + } + + /* debug: draw entity convex hulls as 2D outlines */ + if (rc->show_debug) { + for (int ei = 0; ei < rc->entity_count; ei++) { + ConvexHull2D* h = &rc->entity_hulls[ei]; + if (h->count < 3) continue; + Color col = (ei == rc->gui.gui_entity_idx) + ? (Color){ 0, 255, 0, 180 } : (Color){ 255, 0, 0, 180 }; + for (int hi = 0; hi < h->count; hi++) { + int ni = (hi + 1) % h->count; + DrawLine(h->xs[hi], h->ys[hi], h->xs[ni], h->ys[ni], col); + } + } + } + + /* 3D mode HUD */ + int display_tick = env->tick; + if (env->encounter_def && env->encounter_state) + display_tick = ((const EncounterDef*)env->encounter_def)->get_tick(env->encounter_state); + DrawText(TextFormat("Tick: %d [3D MODE - T to toggle]", display_tick), + 10, 12, 16, COLOR_TEXT); + + /* entity HP summary top-right */ + if (rc->entity_count >= 2) { + RenderEntity* p0 = &rc->entities[0]; + RenderEntity* p1 = &rc->entities[1]; + const char* hp_txt = TextFormat("P0: %d/%d P1: %d/%d", + p0->current_hitpoints, p0->base_hitpoints, + p1->current_hitpoints, p1->base_hitpoints); + int hp_w = MeasureText(hp_txt, 16); + DrawText(hp_txt, RENDER_WINDOW_W - hp_w - 10, 12, 16, COLOR_TEXT); + } + + /* controls reminder */ + DrawText("Right-drag: orbit Mid-drag: pan Scroll: zoom SPACE: pause S: safe spots D: debug G: cycle entity H: human", + 10, RENDER_WINDOW_H - 20, 10, COLOR_TEXT_DIM); + } else { + /* 2D mode: existing grid view */ + Camera2D cam2d = { 0 }; + cam2d.offset = (Vector2){ (float)RENDER_GRID_W / 2.0f, + (float)(RENDER_HEADER_HEIGHT + RENDER_GRID_H / 2.0f) }; + cam2d.target = cam2d.offset; + /* camera follow in 2D: center on controlled entity */ + if (rc->human_input.enabled && rc->entity_count > 0) { + int eidx = rc->gui.gui_entity_idx; + if (eidx < rc->entity_count) { + float px = ((float)rc->sub_x[eidx] / 128.0f - (float)rc->arena_base_x) * + RENDER_TILE_SIZE + RENDER_TILE_SIZE / 2.0f; + float py = ((float)(rc->arena_height - 1) - + ((float)rc->sub_y[eidx] / 128.0f - (float)rc->arena_base_y)) * + RENDER_TILE_SIZE + RENDER_HEADER_HEIGHT + RENDER_TILE_SIZE / 2.0f; + cam2d.target = (Vector2){ px, py }; + } + } + cam2d.zoom = rc->zoom; + + BeginMode2D(cam2d); + render_draw_grid(rc, env); + if (rc->show_safe_spots) render_draw_safe_spots(rc, env); + render_draw_dest_markers(rc); + render_draw_players(rc); + render_draw_splats_2d(rc); + EndMode2D(); + + if (rc->show_models) { + render_draw_models_2d_overlay(rc); + } + + render_draw_header(rc, env); + DrawText("SPACE: pause C: collision S: safe spots G: cycle entity H: human control", + 10, RENDER_WINDOW_H - 20, 10, COLOR_TEXT_DIM); + } + + /* OSRS GUI panel system: shows selected entity's state. + Renders in both 2D and 3D mode as a side panel overlay. + G key cycles through entities (player 0, player 1, NPCs, etc). */ + rc->gui.gui_entity_count = rc->entity_count; + rc->gui.encounter_state = env->encounter_state; + rc->gui.encounter_def = env->encounter_def; + if (rc->gui.gui_entity_idx >= rc->entity_count) + rc->gui.gui_entity_idx = 0; + /* draw click cross at screen-space position (2D overlay, like real OSRS) */ + human_draw_click_cross(&rc->human_input, + rc->click_cross_sprites, + rc->click_cross_loaded); + + /* debug: show raycast tile selection info */ + if (rc->show_debug) { + char dbg[256]; + snprintf(dbg, sizeof(dbg), "box: (%d,%d) plane: (%d,%d) hit3d: (%.1f,%.1f,%.1f)", + rc->debug_hit_wx, rc->debug_hit_wy, + rc->debug_plane_wx, rc->debug_plane_wy, + rc->debug_ray_hit_x, rc->debug_ray_hit_y, rc->debug_ray_hit_z); + DrawText(dbg, 10, 30, 16, MAGENTA); + snprintf(dbg, sizeof(dbg), "ray org: (%.1f,%.1f,%.1f) dir: (%.3f,%.3f,%.3f)", + rc->debug_ray_origin.x, rc->debug_ray_origin.y, rc->debug_ray_origin.z, + rc->debug_ray_dir.x, rc->debug_ray_dir.y, rc->debug_ray_dir.z); + DrawText(dbg, 10, 48, 16, MAGENTA); + } + + if (rc->entity_count > 0) { + /* gui_draw needs full Player* for inventory/stats/prayers. + render_get_player_ptr fetches from encounter vtable. */ + Player* gui_player = render_get_player_ptr(env, rc->gui.gui_entity_idx); + if (gui_player) gui_draw(&rc->gui, gui_player); + + /* boss/NPC info: top-left overlay (instead of below panel) */ + RenderEntity* gui_re = &rc->entities[rc->gui.gui_entity_idx]; + if (gui_re->entity_type != ENTITY_NPC && rc->entity_count > 1) { + for (int ei = 0; ei < rc->entity_count; ei++) { + if (rc->entities[ei].entity_type == ENTITY_NPC) { + render_draw_panel_npc(10, RENDER_HEADER_HEIGHT + 8, + &rc->entities[ei], env); + break; + } + } + } + } + + EndDrawing(); +} + +#endif /* OSRS_PVP_RENDER_H */ diff --git a/ocean/osrs/osrs_pvp_terrain.h b/ocean/osrs/osrs_pvp_terrain.h new file mode 100644 index 0000000000..1788f37a5b --- /dev/null +++ b/ocean/osrs/osrs_pvp_terrain.h @@ -0,0 +1,186 @@ +/** + * @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] + */ + +#ifndef OSRS_PVP_TERRAIN_H +#define OSRS_PVP_TERRAIN_H + +#include "raylib.h" +#include +#include +#include + +#define TERR_MAGIC 0x54455252 + +typedef struct { + Model model; + int vertex_count; + int region_count; + int min_world_x; + int min_world_y; + int loaded; + /* heightmap for ground-level queries */ + float* heightmap; + int hm_min_x; + int hm_min_y; + int hm_width; + int hm_height; +} TerrainMesh; + +static TerrainMesh* terrain_load(const char* path) { + FILE* f = 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; + fread(&magic, 4, 1, f); + if (magic != TERR_MAGIC) { + fprintf(stderr, "terrain_load: bad magic %08x\n", magic); + fclose(f); + return NULL; + } + fread(&vert_count, 4, 1, f); + fread(®ion_count, 4, 1, f); + fread(&min_wx, 4, 1, f); + fread(&min_wy, 4, 1, f); + + 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)); + fread(raw_verts, sizeof(float), vert_count * 3, f); + + /* read colors */ + unsigned char* raw_colors = (unsigned char*)malloc(vert_count * 4); + fread(raw_colors, 1, vert_count * 4, f); + + /* 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)); + 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; + if (fread(&hm_min_x, 4, 1, f) == 1 && + fread(&hm_min_y, 4, 1, f) == 1 && + fread(&hm_w, 4, 1, f) == 1 && + fread(&hm_h, 4, 1, f) == 1 && + hm_w > 0 && hm_h > 0 && hm_w <= 4096 && hm_h <= 4096) { + 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)); + fread(tm->heightmap, sizeof(float), hm_w * hm_h, f); + fprintf(stderr, "terrain heightmap: %dx%d, origin (%d, %d)\n", + tm->hm_width, tm->hm_height, tm->hm_min_x, tm->hm_min_y); + } + + fclose(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. */ +static 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) */ +static 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]; +} + +/** + * Average height of a tile's 4 corners. matches how OSRS places players + * on sloped terrain (average of SW, SE, NW, NE corner heights). + */ +static float terrain_height_avg(TerrainMesh* tm, int world_x, int world_y) { + float h00 = terrain_height_at(tm, world_x, world_y); + float h10 = terrain_height_at(tm, world_x + 1, world_y); + float h01 = terrain_height_at(tm, world_x, world_y + 1); + float h11 = terrain_height_at(tm, world_x + 1, world_y + 1); + return (h00 + h10 + h01 + h11) * 0.25f; +} + +static void terrain_free(TerrainMesh* tm) { + if (!tm) return; + if (tm->loaded) { + UnloadModel(tm->model); + } + free(tm->heightmap); + free(tm); +} + +#endif /* OSRS_PVP_TERRAIN_H */ diff --git a/ocean/osrs/osrs_pvp_visual b/ocean/osrs/osrs_pvp_visual new file mode 100755 index 0000000000..c27d83bdef Binary files /dev/null and b/ocean/osrs/osrs_pvp_visual differ diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h new file mode 100644 index 0000000000..a69b4a072e --- /dev/null +++ b/ocean/osrs/osrs_types.h @@ -0,0 +1,1144 @@ +/** + * @file osrs_types.h + * @brief Core type definitions for OSRS PvP environment + * + * Contains all enums, structs, and constants used throughout the simulation. + * This is the base header - all other headers depend on this. + */ + +/* ============================================================================ + * CRITICAL: OSRS TICK-BASED TIMING MODEL + * ============================================================================ + * + * READ THIS BEFORE MODIFYING ANY COMBAT OR MOVEMENT CODE. + * + * OSRS runs on a 600ms tick cycle. ALL actions are queued and execute on the + * NEXT tick, not immediately. This has major implications for how combat works. + * + * --------------------------------------------------------------------------- + * TICK TIMING OVERVIEW + * --------------------------------------------------------------------------- + * + * TICK N (current state): + * - Player SEES: positions, HP, gear, prayers, everything visible + * - Player QUEUES: their reaction to what they see (actions for next tick) + * - Actions execute: NOTHING YET - actions are just queued + * + * TICK N+1 (next tick): + * - Queued actions from tick N EXECUTE (movement first, then attacks) + * - New state becomes visible + * - Player queues new reaction + * + * --------------------------------------------------------------------------- + * MOVEMENT + ATTACK IN SAME TICK + * --------------------------------------------------------------------------- + * + * When you queue an attack, the game automatically handles movement: + * + * Example: dist=3, melee weapon (range=1), queue "attack" + * - Tick N+1: move 2 tiles (running), now dist=1, attack fires + * - Both movement and attack happen in the SAME tick + * + * Example: dist=0 (under target), queue "attack" + * - Tick N+1: auto-step to adjacent tile (dist=1), attack fires + * - The step-out is IMPLICIT - part of the attack action + * + * --------------------------------------------------------------------------- + * CONFLICTING ACTIONS (IMPORTANT!) + * --------------------------------------------------------------------------- + * + * When EXPLICIT movement conflicts with IMPLICIT attack movement: + * + * Example: dist=0, queue BOTH "attack" AND "move under" + * - Attack needs: step out to dist=1 + * - Movement wants: stay at dist=0 + * - RESULT: Explicit movement wins, attack is CANCELLED + * + * This is because the end state cannot be BOTH dist=1 (for attack) AND + * dist=0 (from explicit movement). Explicit actions override implicit ones. + * + * --------------------------------------------------------------------------- + * STEP UNDER STRATEGY (NH PVP) + * --------------------------------------------------------------------------- + * + * Common tactic when opponent is frozen: + * + * Tick 9: You're under frozen opponent (dist=0), queue "attack" ONLY + * Tick 10: Step out to dist=1, attack fires, queue "move under" ONLY + * Tick 11: Move back under (dist=0), opponent couldn't hit you + * + * The frozen opponent can only hit you if they ALSO queue attack on tick 9. + * Both attacks would fire on tick 10 when you're both effectively at dist=1. + * + * --------------------------------------------------------------------------- + * RECORDING FORMAT + * --------------------------------------------------------------------------- + * + * Fight recordings show for each tick: + * - STATE: What the player sees RIGHT NOW (positions, HP, gear, etc.) + * - ACTIONS: What the player QUEUED as reaction (executes NEXT tick) + * + * Example (valid sequence): + * Tick 9 state: dist=3, actions=["RNG"] + * Tick 10 state: dist=1, attack fires (moved 2 tiles + attacked) + * + * Counter-example (conflicting sequence - attack cancelled): + * Tick 9 state: dist=0, actions=["RNG", "under"] + * Tick 10 state: dist=0, NO attack (explicit "under" cancelled the attack) + * The attack needed dist=1, but "under" forced dist=0. Conflict resolved + * by cancelling the attack - explicit movement wins over implicit step-out. + * + * ========================================================================= */ + +#ifndef OSRS_TYPES_H +#define OSRS_TYPES_H + +#include +#include +#include +#include +#include + +// ============================================================================ +// ENVIRONMENT CONSTANTS +// ============================================================================ + +#define NUM_AGENTS 2 +#define MAX_PENDING_HITS 8 +#define HISTORY_SIZE 5 + +#define TICK_DURATION_MS 600 +#define MAX_EPISODE_TICKS 300 + +// ============================================================================ +// WILDERNESS AREA BOUNDS +// ============================================================================ + +#define WILD_MIN_X 2940 +#define WILD_MAX_X 3392 +#define WILD_MIN_Y 3525 +#define WILD_MAX_Y 3968 +#define FIGHT_AREA_BASE_X 3041 +#define FIGHT_AREA_BASE_Y 3530 +#define FIGHT_AREA_WIDTH 61 +#define FIGHT_AREA_HEIGHT 28 +#define FIGHT_NEARBY_RADIUS 5 + +// ============================================================================ +// GAMEPLAY FLAGS +// ============================================================================ + +#define ONLY_SWITCH_PRAYER_WHEN_ABOUT_TO_ATTACK 1 +#define ONLY_SWITCH_GEAR_WHEN_ATTACK_SOON 1 +#define ALLOW_SMITE 1 +#define ALLOW_REDEMPTION 1 +#define ALLOW_MOVING_IF_CAN_ATTACK 0 + +// ============================================================================ +// MAGIC SPELL LEVELS AND DAMAGE +// ============================================================================ + +#define ICE_RUSH_LEVEL 58 +#define ICE_BURST_LEVEL 70 +#define ICE_BLITZ_LEVEL 82 +#define ICE_BARRAGE_LEVEL 94 + +#define BLOOD_RUSH_LEVEL 56 +#define BLOOD_BURST_LEVEL 68 +#define BLOOD_BLITZ_LEVEL 80 +#define BLOOD_BARRAGE_LEVEL 92 + +#define ICE_RUSH_MAX_HIT 18 +#define ICE_BURST_MAX_HIT 22 +#define ICE_BLITZ_MAX_HIT 26 +#define ICE_BARRAGE_MAX_HIT 30 + +#define BLOOD_RUSH_MAX_HIT 15 +#define BLOOD_BURST_MAX_HIT 21 +#define BLOOD_BLITZ_MAX_HIT 25 +#define BLOOD_BARRAGE_MAX_HIT 29 + +#define ATTACK_TIMER_INACTIVE -1000000 + +// ============================================================================ +// EQUIPMENT SLOTS +// ============================================================================ + +// Number of equipment slots (HEAD, CAPE, NECK, AMMO, WEAPON, SHIELD, BODY, LEGS, HANDS, FEET, RING) +#define NUM_GEAR_SLOTS 11 + +// ============================================================================ +// LOADOUT-BASED ACTION SPACE +// ============================================================================ +// 8 action heads: one decision per head per tick. no click encoding. +// the agent picks a loadout preset + independent decisions for attack/prayer/etc. + +#define NUM_ACTION_HEADS 7 + +// Action head indices +#define HEAD_LOADOUT 0 +#define HEAD_COMBAT 1 // merged attack + movement (mutually exclusive per tick) +#define HEAD_OVERHEAD 2 +#define HEAD_FOOD 3 +#define HEAD_POTION 4 +#define HEAD_KARAMBWAN 5 +#define HEAD_VENG 6 + +// Per-head action dimensions +#define LOADOUT_DIM 9 // KEEP, MELEE, RANGE, MAGE, TANK, SPEC_MELEE, SPEC_RANGE, SPEC_MAGIC, GMAUL +#define COMBAT_DIM 13 // NONE, ATK, ICE, BLOOD, ADJACENT, UNDER, DIAGONAL, FARCAST_2..7 +#define OVERHEAD_DIM 6 // NONE, MAGE, RANGED, MELEE, SMITE, REDEMPTION +#define FOOD_DIM 2 // NONE, EAT +#define POTION_DIM 5 // NONE, BREW, RESTORE, COMBAT, RANGED +#define KARAMBWAN_DIM 2 // NONE, EAT +#define VENG_DIM 2 // NONE, CAST + +// Total action mask size: sum of all head dims = 39 +#define ACTION_MASK_SIZE (LOADOUT_DIM + COMBAT_DIM + OVERHEAD_DIM + \ + FOOD_DIM + POTION_DIM + KARAMBWAN_DIM + VENG_DIM) + +// Per-head action dims array +static const int ACTION_HEAD_DIMS[NUM_ACTION_HEADS] = { + LOADOUT_DIM, + COMBAT_DIM, + OVERHEAD_DIM, + FOOD_DIM, + POTION_DIM, + KARAMBWAN_DIM, + VENG_DIM, +}; + +// Number of item stats per item (for observations) +#define NUM_ITEM_STATS 18 + +// Maximum items per slot for observation padding +#define MAX_ITEMS_PER_SLOT 10 + +// Dynamic gear slots that change during combat +// 8 slots: weapon, shield, body, legs, head, cape, neck, ring +#define NUM_DYNAMIC_GEAR_SLOTS 8 + +// Observation size: 182 base + (8 dynamic slots * 18 stats) + 7 reward signals + 1 voidwaker flag = 334 +#define SLOT_NUM_OBSERVATIONS 334 + +// ============================================================================ +// PLAYER BASE STATS (NH maxed accounts - 99 all combat) +// ============================================================================ + +#define MAXED_BASE_ATTACK 99 +#define MAXED_BASE_STRENGTH 99 +#define MAXED_BASE_DEFENCE 99 +#define LMS_BASE_DEFENCE 75 +#define MAXED_BASE_RANGED 99 +#define MAXED_BASE_MAGIC 99 +#define MAXED_BASE_PRAYER 77 +#define MAXED_BASE_HITPOINTS 99 + +// LMS supply counts (1 brew, 2 restores, 1 combat pot, 1 ranged pot, 2 karams, 11 sharks) +#define MAXED_FOOD_COUNT 11 +#define MAXED_KARAMBWAN_COUNT 2 +#define MAXED_BREW_DOSES 4 // 1 saradomin brew (4 doses) +#define MAXED_RESTORE_DOSES 8 // 2 super restores (4 doses each) +#define MAXED_COMBAT_POTION_DOSES 4 +#define MAXED_RANGED_POTION_DOSES 4 + +#define MAXED_MELEE_ATTACK_SPEED_OBS 4 +#define MAXED_RANGED_ATTACK_SPEED_OBS 5 +#define RUN_ENERGY_RECOVER_TICKS 3 + +// ============================================================================ +// CORE ENUMS +// ============================================================================ + +typedef enum { + ATTACK_STYLE_NONE = 0, + ATTACK_STYLE_MELEE, + ATTACK_STYLE_RANGED, + ATTACK_STYLE_MAGIC +} AttackStyle; + +typedef enum { + PRAYER_NONE = 0, + PRAYER_PROTECT_MAGIC, + PRAYER_PROTECT_RANGED, + PRAYER_PROTECT_MELEE, + PRAYER_SMITE, + PRAYER_REDEMPTION +} OverheadPrayer; + +typedef enum { + GEAR_MAGE = 0, + GEAR_RANGED, + GEAR_MELEE, + GEAR_SPEC, + GEAR_TANK +} GearSet; + +typedef enum { + OFFENSIVE_PRAYER_NONE = 0, + OFFENSIVE_PRAYER_MELEE_LOW, + OFFENSIVE_PRAYER_RANGED_LOW, + OFFENSIVE_PRAYER_MAGIC_LOW, + OFFENSIVE_PRAYER_PIETY, + OFFENSIVE_PRAYER_RIGOUR, + OFFENSIVE_PRAYER_AUGURY +} OffensivePrayer; + +typedef enum { + FIGHT_STYLE_ACCURATE = 0, + FIGHT_STYLE_AGGRESSIVE, + FIGHT_STYLE_CONTROLLED, + FIGHT_STYLE_DEFENSIVE +} FightStyle; + +typedef enum { + MELEE_BONUS_STAB = 0, + MELEE_BONUS_SLASH, + MELEE_BONUS_CRUSH +} MeleeBonusType; + +// ============================================================================ +// SPECIAL ATTACK WEAPON ENUMS +// ============================================================================ + +typedef enum { + MELEE_SPEC_NONE = 0, + MELEE_SPEC_AGS, + MELEE_SPEC_DRAGON_CLAWS, + MELEE_SPEC_GRANITE_MAUL, + MELEE_SPEC_DRAGON_DAGGER, + MELEE_SPEC_VOIDWAKER, + MELEE_SPEC_DWH, + MELEE_SPEC_BGS, + MELEE_SPEC_ZGS, + MELEE_SPEC_SGS, + MELEE_SPEC_ANCIENT_GS, + MELEE_SPEC_VESTAS, + MELEE_SPEC_ABYSSAL_DAGGER, + MELEE_SPEC_DRAGON_LONGSWORD, + MELEE_SPEC_DRAGON_MACE, + MELEE_SPEC_ABYSSAL_BLUDGEON +} MeleeSpecWeapon; + +typedef enum { + RANGED_SPEC_NONE = 0, + RANGED_SPEC_DARK_BOW, + RANGED_SPEC_BALLISTA, + RANGED_SPEC_ACB, + RANGED_SPEC_ZCB, + RANGED_SPEC_DRAGON_KNIFE, + RANGED_SPEC_MSB, + RANGED_SPEC_MORRIGANS +} RangedSpecWeapon; + +typedef enum { + MAGIC_SPEC_NONE = 0, + MAGIC_SPEC_VOLATILE_STAFF +} MagicSpecWeapon; + +// ============================================================================ +// LOADOUT-BASED ACTION ENUMS +// ============================================================================ + +/** Equipment slot indices. */ +typedef enum { + GEAR_SLOT_HEAD = 0, + GEAR_SLOT_CAPE, + GEAR_SLOT_NECK, + GEAR_SLOT_AMMO, + GEAR_SLOT_WEAPON, + GEAR_SLOT_SHIELD, + GEAR_SLOT_BODY, + GEAR_SLOT_LEGS, + GEAR_SLOT_HANDS, + GEAR_SLOT_FEET, + GEAR_SLOT_RING, +} GearSlotIndex; + +/** Dynamic gear slots that change during combat. */ +static const int DYNAMIC_GEAR_SLOTS[NUM_DYNAMIC_GEAR_SLOTS] = { + GEAR_SLOT_WEAPON, GEAR_SLOT_SHIELD, GEAR_SLOT_BODY, GEAR_SLOT_LEGS, + GEAR_SLOT_HEAD, GEAR_SLOT_CAPE, GEAR_SLOT_NECK, GEAR_SLOT_RING +}; + +/** Loadout action head options. */ +typedef enum { + LOADOUT_KEEP = 0, + LOADOUT_MELEE, + LOADOUT_RANGE, + LOADOUT_MAGE, + LOADOUT_TANK, + LOADOUT_SPEC_MELEE, + LOADOUT_SPEC_RANGE, + LOADOUT_SPEC_MAGIC, + LOADOUT_GMAUL, +} LoadoutAction; + +/** + * Combat action head values (merged attack + movement, dim=13). + * Attacks and movement are mutually exclusive per tick. + * OSRS melee requires cardinal adjacency; auto-walk handles positioning. + */ +#define ATTACK_NONE 0 +#define ATTACK_ATK 1 +#define ATTACK_ICE 2 +#define ATTACK_BLOOD 3 +#define MOVE_ADJACENT 4 +#define MOVE_UNDER 5 +#define MOVE_DIAGONAL 6 +#define MOVE_FARCAST_2 7 +#define MOVE_FARCAST_3 8 +#define MOVE_FARCAST_4 9 +#define MOVE_FARCAST_5 10 +#define MOVE_FARCAST_6 11 +#define MOVE_FARCAST_7 12 +#define MOVE_NONE ATTACK_NONE + +static inline int is_attack_action(int v) { return v >= ATTACK_ATK && v <= ATTACK_BLOOD; } +static inline int is_move_action(int v) { return v >= MOVE_ADJACENT && v <= MOVE_FARCAST_7; } + +/** Overhead prayer action head options. */ +typedef enum { + OVERHEAD_NONE = 0, + OVERHEAD_MAGE, + OVERHEAD_RANGED, + OVERHEAD_MELEE, + OVERHEAD_SMITE, + OVERHEAD_REDEMPTION, +} OverheadAction; + +/** Food action head options. */ +typedef enum { + FOOD_NONE = 0, + FOOD_EAT, +} FoodAction; + +/** Potion action head options. */ +typedef enum { + POTION_NONE = 0, + POTION_BREW, + POTION_RESTORE, + POTION_COMBAT, + POTION_RANGED, + POTION_ANTIVENOM, +} PotionAction; + +/** Karambwan action head options. */ +typedef enum { + KARAM_NONE = 0, + KARAM_EAT, +} KaramAction; + +/** Vengeance action head options. */ +typedef enum { + VENG_NONE = 0, + VENG_CAST, +} VengAction; + +// ============================================================================ +// GEAR BONUS STRUCTS +// ============================================================================ + +typedef struct { + int stab_attack; + int slash_attack; + int crush_attack; + int magic_attack; + int ranged_attack; + int stab_defence; + int slash_defence; + int crush_defence; + int magic_defence; + int ranged_defence; + int melee_strength; + int ranged_strength; + int magic_strength; + int attack_speed; + int attack_range; +} GearBonuses; + +typedef struct { + int magic_attack; + int magic_strength; + int ranged_attack; + int ranged_strength; + int melee_attack; + int melee_strength; + int magic_defence; + int ranged_defence; + int melee_defence; +} VisibleGearBonuses; + +// ============================================================================ +// COMBAT STRUCTS +// ============================================================================ + +typedef struct { + int damage; + int ticks_until_hit; + AttackStyle attack_type; + int is_special; + int hit_success; + int freeze_ticks; + int heal_percent; + int drain_type; + int drain_percent; + int flat_heal; // fixed HP heal for attacker (e.g. ancient GS blood sacrifice) + int is_morr_bleed; // when this hit lands, set morr_dot_remaining to damage dealt + OverheadPrayer defender_prayer_at_attack; +} PendingHit; + +// ============================================================================ +// ENTITY TYPE (player vs NPC — used by renderer and encounter system) +// ============================================================================ + +typedef enum { + ENTITY_PLAYER = 0, + ENTITY_NPC = 1, +} EntityType; + +// ============================================================================ +// PLAYER / ENTITY STRUCT +// ============================================================================ + +typedef struct { + EntityType entity_type; /* ENTITY_PLAYER or ENTITY_NPC */ + int npc_def_id; /* NPC definition ID (unused for players) */ + int npc_visible; /* render visibility flag (NPCs only, e.g. Zulrah dive) */ + int npc_size; /* NPC hitbox size in tiles (1 for players, 5 for Zulrah, etc.) */ + int npc_anim_id; /* current animation seq ID (-1 = use idle from NpcModelMapping) */ + + // Game mode flags (set per-player during c_reset) + int is_lms; + + // Base stats + int base_attack; + int base_strength; + int base_defence; + int base_ranged; + int base_magic; + int base_prayer; + int base_hitpoints; + + // Current stats + int current_attack; + int current_strength; + int current_defence; + int current_ranged; + int current_magic; + int current_prayer; + int current_hitpoints; + + // Special attack state + int special_energy; + int special_regen_ticks; + int spec_regen_active; + int was_lightbearer_equipped; + int special_active; + int spec_queued; /* 1 = next attack uses special (shared across encounters) */ + + // Gear + GearSet current_gear; // tracks active combat style for visible_gear and style checks + GearSet visible_gear; // external: actual weapon damage type (MELEE/RANGED/MAGE only, no GEAR_SPEC) + + // Consumables + int food_count; + int karambwan_count; + int brew_doses; + int restore_doses; + int prayer_pot_doses; + int combat_potion_doses; + int ranged_potion_doses; + int antivenom_doses; + + // Timers + int attack_timer; + int attack_timer_uncapped; + int has_attack_timer; + int food_timer; + int potion_timer; + int karambwan_timer; + + // Consumable tracking (used for timing/metrics) + // Set to 1 during execute_slot_switches if any consumable succeeded this tick + uint8_t consumable_used_this_tick; + int last_food_heal; + int last_food_waste; + int last_karambwan_heal; + int last_karambwan_waste; + int last_brew_heal; + int last_brew_waste; + int last_potion_type; + int last_potion_was_waste; + + // Freeze state + int frozen_ticks; + int freeze_immunity_ticks; + + // Vengeance + int veng_active; + int veng_cooldown; + + // Ring of recoil: reflects floor(damage * 0.1) + 1 back to attacker. + // charges track remaining recoil damage the ring can deal (starts at 40). + // at 0 the ring shatters (ring of suffering (i) never shatters). + int recoil_charges; + + // Prayer and style + OverheadPrayer prayer; + OffensivePrayer offensive_prayer; + FightStyle fight_style; + int prayer_drain_counter; // Accumulates drain, triggers at drain_resistance + + // Position + int x, y; + int dest_x, dest_y; + int is_moving; + int is_running; + int run_energy; + int run_recovery_ticks; + int last_obs_target_x; + int last_obs_target_y; + + // Attack tracking + int just_attacked; + AttackStyle last_attack_style; + int last_queued_hit_damage; // Damage of most recent attack (XP drop equivalent) + int attack_was_on_prayer; // 1 if defender had correct prayer when attack processed + int attack_click_canceled; + int attack_click_ready; + int last_attack_dx; + int last_attack_dy; + int last_attack_dist; + + // Pending hits queue + PendingHit pending_hits[MAX_PENDING_HITS]; + int num_pending_hits; + int damage_applied_this_tick; + int did_attack_auto_move; // set in attack movement phase, read in attack combat phase + + // Hit event tracking for event log + int hit_landed_this_tick; + int hit_was_successful; + int hit_damage; + AttackStyle hit_style; + OverheadPrayer hit_defender_prayer; + int hit_was_on_prayer; + int hit_attacker_idx; + int freeze_applied_this_tick; + + // Morrigan's javelin DoT (Phantom Strike): 5 HP every 3 ticks from calc tick + int morr_dot_remaining; // remaining bleed damage to deliver + int morr_dot_tick_counter; // ticks until next bleed (counts down from 3) + + // Damage tracking + float last_target_health_percent; + float tick_damage_scale; + float damage_dealt_scale; + float damage_received_scale; + + // Hit statistics + int total_target_hit_count; + int target_hit_melee_count; + int target_hit_ranged_count; + int target_hit_magic_count; + int target_hit_off_prayer_count; + int target_hit_correct_count; + + int total_target_pray_count; + int target_pray_melee_count; + int target_pray_ranged_count; + int target_pray_magic_count; + int target_pray_correct_count; + + int player_hit_melee_count; + int player_hit_ranged_count; + int player_hit_magic_count; + + int player_pray_melee_count; + int player_pray_ranged_count; + int player_pray_magic_count; + + // History buffers + AttackStyle recent_target_attack_styles[HISTORY_SIZE]; + AttackStyle recent_player_attack_styles[HISTORY_SIZE]; + AttackStyle recent_target_prayer_styles[HISTORY_SIZE]; + AttackStyle recent_player_prayer_styles[HISTORY_SIZE]; + int recent_target_prayer_correct[HISTORY_SIZE]; + int recent_target_hit_correct[HISTORY_SIZE]; + int recent_target_attack_index; + int recent_player_attack_index; + int recent_target_prayer_index; + int recent_player_prayer_index; + int recent_target_prayer_correct_index; + int recent_target_hit_correct_index; + + // Observed target stats + int target_magic_accuracy; + int target_magic_strength; + int target_ranged_accuracy; + int target_ranged_strength; + int target_melee_accuracy; + int target_melee_strength; + int target_magic_gear_magic_defence; + int target_magic_gear_ranged_defence; + int target_magic_gear_melee_defence; + int target_ranged_gear_magic_defence; + int target_ranged_gear_ranged_defence; + int target_ranged_gear_melee_defence; + int target_melee_gear_magic_defence; + int target_melee_gear_ranged_defence; + int target_melee_gear_melee_defence; + + // Prayer correctness flags + int player_prayed_correct; + int target_prayed_correct; + + // Total damage + float total_damage_dealt; + float total_damage_received; + + // Equipment flags + int is_lunar_spellbook; + int observed_target_lunar_spellbook; + int has_blood_fury; + int has_dharok; + + // Spec weapons + MeleeSpecWeapon melee_spec_weapon; + RangedSpecWeapon ranged_spec_weapon; + MagicSpecWeapon magic_spec_weapon; + + // Bolt procs + float bolt_proc_damage; + int bolt_ignores_defense; + + // Slot-based mode equipment (per-slot item indices, 255 = empty) + // equipped[GEAR_SLOT_*] = item index from ITEMS_BY_SLOT table, or 255 if empty + uint8_t equipped[NUM_GEAR_SLOTS]; + + // Available items per slot (for action masking and observations) + // inventory[slot][item_idx] = item database index, 255 = no item + uint8_t inventory[NUM_GEAR_SLOTS][MAX_ITEMS_PER_SLOT]; + + // Number of items available per slot + uint8_t num_items_in_slot[NUM_GEAR_SLOTS]; + + // Cached bonuses for slot-based mode + GearBonuses slot_cached_bonuses; + int slot_gear_dirty; + + // Per-tick action tracking for reward shaping + // These are set when actions actually execute (not when queued) + AttackStyle attack_style_this_tick; // Actual attack style used (NONE if no attack) + int magic_type_this_tick; // 0=none, 1=ice, 2=blood (for visual effects) + int used_special_this_tick; // 1 if special attack was used + int ate_food_this_tick; // 1 if regular food was consumed + int ate_karambwan_this_tick; // 1 if karambwan was consumed + int ate_brew_this_tick; // 1 if saradomin brew was consumed + int cast_veng_this_tick; // 1 if vengeance was cast (for animation) + int clicks_this_tick; // accumulated click count for progressive penalty + + // Previous tick HP percent for reward shaping (premature/wasted eat checks) + float prev_hp_percent; + + // GUI stats panel fields (synced from encounter state each render tick) + int gui_max_hit; + int gui_attack_speed; + int gui_attack_range; + int gui_strength_bonus; +} Player; + +// ============================================================================ +// LOGGING STRUCT +// ============================================================================ + +typedef struct { + float episode_return; + float episode_length; + float wins; + float damage_dealt; + float damage_received; + float wave; + float prayer_correct; + float prayer_total; + float idle_ticks; + float brews_used; + float blood_healed; + /* behavioral metrics */ + float npc_kills; + float gear_switches; + float current_ranged; + float current_magic; + /* per-component reward accumulators (sum across episodes, divide by n) */ + float rw_wave; + float rw_damage; + float rw_idle; + float rw_brew; + float rw_blood; + float rw_prayer; + float rw_pillar; + float rw_dmg_taken; + float rw_terminal; + float unavoidable_off_prayer; /* off-prayer hits where correct prayer was on a different style */ + float brews_remaining; /* brew doses left at end of episode */ + float restores_remaining; /* restore doses left at end of episode */ + float prayer_at_death; /* prayer points at end of episode */ + float n; +} Log; + +// ============================================================================ +// REWARD SHAPING CONFIG +// ============================================================================ + +typedef struct { + // Per-tick shaping coefficients + float damage_dealt_coef; // per-HP dealt + float damage_received_coef; // per-HP received (negative) + float correct_prayer_bonus; // blocked attack with correct prayer + float wrong_prayer_penalty; // got hit off-prayer + float prayer_switch_no_attack_penalty; // switched protection prayer but opponent didn't attack + float off_prayer_hit_bonus; // hit opponent off-prayer + float melee_frozen_penalty; // melee while frozen and out of range + float wasted_eat_penalty; // per wasted HP of healing overflow + float premature_eat_penalty; // eating above premature threshold + float magic_no_staff_penalty; // casting magic without staff (deprecated, use gear_mismatch) + float gear_mismatch_penalty; // attacking with negative bonus for the attack style + float spec_off_prayer_bonus; // spec when target not praying melee + float spec_low_defence_bonus; // spec when target in mage gear + float spec_low_hp_bonus; // spec when target below 50% HP + float smart_triple_eat_bonus; // triple eat at low HP + float wasted_triple_eat_penalty; // per wasted karam HP at high HP + float damage_burst_bonus; // per HP above burst threshold + int damage_burst_threshold; // minimum damage for burst bonus + float premature_eat_threshold; // HP percent above which eating is premature (70/99) + // Terminal shaping + float ko_bonus; // bonus for KO (opponent had food left) + float wasted_resources_penalty; // dying with food/brews left + // Scale (annealed from Python during training) + float shaping_scale; // 1.0 → floor over training + int enabled; // 0 = sparse only, 1 = full shaping + // Always-on behavioral penalties (independent of `enabled`) + int prayer_penalty_enabled; // wasteful prayer switch penalty + int click_penalty_enabled; // progressive excess-click penalty + int click_penalty_threshold; // free clicks before penalty kicks in + float click_penalty_coef; // penalty per excess click (negative) +} RewardShapingConfig; + +// ============================================================================ +// OPPONENT TYPES (used by osrs_pvp_opponents.h functions) +// ============================================================================ + +typedef enum { + OPP_NONE = 0, + OPP_TRUE_RANDOM, + OPP_PANICKING, + OPP_WEAK_RANDOM, + OPP_SEMI_RANDOM, + OPP_STICKY_PRAYER, + OPP_RANDOM_EATER, + OPP_PRAYER_ROOKIE, + OPP_IMPROVED, + OPP_MIXED_EASY, + OPP_MIXED_MEDIUM, + OPP_ONETICK, + OPP_UNPREDICTABLE_IMPROVED, + OPP_UNPREDICTABLE_ONETICK, + OPP_MIXED_HARD, + OPP_MIXED_HARD_BALANCED, + OPP_PFSP, + OPP_NOVICE_NH, + OPP_APPRENTICE_NH, + OPP_COMPETENT_NH, + OPP_INTERMEDIATE_NH, + OPP_ADVANCED_NH, + OPP_PROFICIENT_NH, + OPP_EXPERT_NH, + OPP_MASTER_NH, + OPP_SAVANT_NH, + OPP_NIGHTMARE_NH, + OPP_VENG_FIGHTER, + OPP_BLOOD_HEALER, + OPP_GMAUL_COMBO, + OPP_RANGE_KITER, + OPP_SELFPLAY, +} OpponentType; + +#define MAX_OPPONENT_POOL 32 + +typedef struct { + OpponentType pool[MAX_OPPONENT_POOL]; + int cum_weights[MAX_OPPONENT_POOL]; /* cumulative weights * 1000 */ + int pool_size; + int active_pool_idx; /* which pool entry is active this episode */ + float wins[MAX_OPPONENT_POOL]; /* per-opponent wins (read+reset by Python) */ + float episodes[MAX_OPPONENT_POOL]; /* per-opponent episode count */ +} PFSPState; + +typedef struct { + OpponentType type; + OpponentType active_sub_policy; + int chosen_prayer; + int chosen_style; + int current_prayer; + int current_prayer_set; + int food_cooldown; + int potion_cooldown; + int karambwan_cooldown; + + /* Phase 2: onetick + realistic policy state */ + int fake_switch_pending; /* 0/1 */ + int fake_switch_style; /* OPP_STYLE_* or -1 */ + int opponent_prayer_at_fake; /* OPP_STYLE_* or -1 (style they were praying) */ + int fake_switch_failed; /* 0/1 (unpredictable_onetick only) */ + int pending_prayer_value; /* OVERHEAD_* value, 0 = none */ + int pending_prayer_delay; /* ticks remaining before applying */ + int last_target_gear_style; /* OPP_STYLE_* or -1, tracks previous tick */ + + /* Per-episode eating thresholds (randomized with noise) */ + float eat_triple_threshold; /* base 0.30, range [0.25, 0.35] */ + float eat_double_threshold; /* base 0.50, range [0.45, 0.55] */ + float eat_brew_threshold; /* base 0.70, range [0.65, 0.75] */ + + /* Per-episode randomized decision parameters */ + float prayer_accuracy; /* chance of correct defensive prayer [0,1] */ + float off_prayer_rate; /* chance of attacking off-prayer [0,1] */ + float offensive_prayer_rate; /* chance of using offensive prayer [0,1] */ + float action_delay_chance; /* per-tick chance to skip prayer+attack [0,0.3] */ + float mistake_rate; /* per-tick chance to pick random prayer [0,0.15] */ + + /* Boss opponent reading ability (master_nh, savant_nh) */ + float read_chance; /* 0.0-1.0, chance to "read" agent action each tick */ + int has_read_this_tick; /* 1 if read succeeded this tick */ + AttackStyle read_agent_style; /* agent's pending attack style (if read) */ + OverheadPrayer read_agent_prayer;/* agent's pending overhead prayer (if read) */ + int read_agent_moving; /* boss read: 1 if agent is moving (not attacking) */ + + /* Anti-kite flee tracking */ + int prev_dist_to_target; /* previous tick distance for flee tracking */ + int target_fleeing_ticks; /* consecutive ticks distance has been increasing */ + + /* gmaul_combo state */ + int combo_state; /* 0=idle, 1=spec_fired (follow with gmaul next tick) */ + float ko_threshold; /* target HP fraction to trigger KO sequence */ + + /* Offensive prayer miss: chance to attack without switching loadout (skipping auto-prayer) */ + float offensive_prayer_miss; + + /* Per-episode style bias: weighted preference for melee/ranged/mage */ + float style_bias[3]; +} OpponentState; + +// Combined observation size: raw obs + action masks (for ocean mode) +#define OCEAN_OBS_SIZE (SLOT_NUM_OBSERVATIONS + ACTION_MASK_SIZE) + +// ============================================================================ +// MAIN ENVIRONMENT STRUCT +// ============================================================================ + +typedef struct { + Log log; + + float* observations; + int* actions; + float* rewards; + unsigned char* terminals; + unsigned char* action_masks; + unsigned char action_masks_agents; + int num_agents; + + Player players[NUM_AGENTS]; + + int tick; + int episode_over; + int winner; + int auto_reset; + int pid_holder; + int pid_shuffle_countdown; // ticks until next PID swap (100-150) + + int is_lms; + int is_pvp_arena; + + uint32_t rng_state; + uint32_t rng_seed; + int has_rng_seed; + + // Async action processing (OSRS-accurate timing) + // Actions submitted on tick N take effect at START of tick N+1 + int pending_actions[NUM_AGENTS * NUM_ACTION_HEADS]; + int last_executed_actions[NUM_AGENTS * NUM_ACTION_HEADS]; + int has_pending_actions; // 0 on first step after reset + + // Reward shaping configuration (coefficients + annealing scale) + RewardShapingConfig shaping; + + // C-side opponent configuration + int use_c_opponent; // 1 = generate opponent actions in C + int use_c_opponent_p0; + int use_external_opponent_actions; // 1 = use external actions for player 1 + int external_opponent_actions[NUM_ACTION_HEADS]; + OpponentState opponent; + OpponentState opponent_p0; + PFSPState pfsp; // PFSP dynamic opponent pool (used when opponent.type == OPP_PFSP) + + // Gear tier randomization weights (4 tiers, sum to 1.0) + float gear_tier_weights[4]; + + // Encounter dispatch (NULL = legacy pvp_step path for backwards compat). + // When set, c_step/c_reset dispatch through these instead of pvp_step/pvp_reset. + const void* encounter_def; /* EncounterDef* — void* to avoid include dependency */ + void* encounter_state; /* EncounterState* — owned by this env */ + + // Collision map (shared across envs, read-only after init). NULL = flat arena. + void* collision_map; /* CollisionMap* — void* to avoid forward-decl dependency */ + + // Raylib render client (NULL = headless). Initialized on first pvp_render() call. + void* client; + + // PufferLib shared buffer pointers (single-agent, set by ocean binding) + float* ocean_obs; // OCEAN_OBS_SIZE floats (normalized obs + mask) + float* ocean_obs_p1; // OCEAN_OBS_SIZE floats for player 1 (self-play, NULL when disabled) + unsigned char* ocean_selfplay_mask; // 1 byte: 1 if this env is in selfplay mode this episode + int* ocean_acts; // NUM_ACTION_HEADS ints (agent 0 actions) + float* ocean_rew; // 1 float (agent 0 reward) + unsigned char* ocean_term; // 1 byte (agent 0 terminal) + float _episode_return; // Running episode return accumulator + + // Internal 2-agent buffers for game logic + float _obs_buf[NUM_AGENTS * SLOT_NUM_OBSERVATIONS]; + int _acts_buf[NUM_AGENTS * NUM_ACTION_HEADS]; + float _rews_buf[NUM_AGENTS]; + unsigned char _terms_buf[NUM_AGENTS]; + unsigned char _masks_buf[NUM_AGENTS * ACTION_MASK_SIZE]; + +} OsrsPvp; + +// ============================================================================ +// HELPER FUNCTIONS +// ============================================================================ + +static inline int abs_int(int val) { + return val < 0 ? -val : val; +} + +static inline int min_int(int a, int b) { + return a < b ? a : b; +} + +static inline int max_int(int a, int b) { + return a > b ? a : b; +} + +static inline int clamp(int val, int min, int max) { + if (val < min) return min; + if (val > max) return max; + return val; +} + +static inline float clampf(float val, float min, float max) { + if (val < min) return min; + if (val > max) return max; + return val; +} + +/** Chebyshev distance - OSRS uses this for range checks. */ +static inline int chebyshev_distance(int x1, int y1, int x2, int y2) { + int dx = x1 - x2; + int dy = y1 - y2; + if (dx < 0) dx = -dx; + if (dy < 0) dy = -dy; + return (dx > dy) ? dx : dy; +} + +/** + * OSRS melee range check: CARDINAL ADJACENCY ONLY (N/S/E/W). + * + * Standard melee weapons (range=1) can ONLY hit from cardinal-adjacent tiles. + * Diagonal tiles (Chebyshev dist=1) are NOT valid melee positions. + * Auto-walk for melee attacks also paths to cardinal adjacency, not diagonal. + * + * This applies to all current weapons. Halberds (range=2, e.g. noxious halberd) + * will need a separate range model when added — they can hit from 2 tiles away + * including some diagonal positions. + */ +static inline int is_in_melee_range(Player* p, Player* t) { + int dx = abs_int(p->x - t->x); + int dy = abs_int(p->y - t->y); + return (dx == 1 && dy == 0) || (dx == 0 && dy == 1); +} + +// ============================================================================ +// RNG FUNCTIONS +// ============================================================================ + +static inline uint32_t xorshift32(uint32_t* state) { + uint32_t x = *state; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + *state = x; + return x; +} + +static inline int rand_int(OsrsPvp* env, int max) { + if (max <= 0) return 0; + return xorshift32(&env->rng_state) % max; +} + +static inline float rand_float(OsrsPvp* env) { + return (float)xorshift32(&env->rng_state) / (float)UINT32_MAX; +} + +static inline int is_in_wilderness(int x, int y) { + return x >= WILD_MIN_X && x <= WILD_MAX_X && y >= WILD_MIN_Y && y <= WILD_MAX_Y; +} + +static inline int tile_hash(int x, int y) { + return (x << 15) | y; +} + +// ============================================================================ +// TIMER HELPERS +// ============================================================================ + +static inline int remaining_ticks(int ticks) { + return ticks > 0 ? ticks : 0; +} + +static inline int get_attack_timer_uncapped(Player* p) { + return p->has_attack_timer ? p->attack_timer_uncapped : -100; +} + +static inline int can_attack_now(Player* p) { + if (!p->has_attack_timer) return 1; + return p->attack_timer < 0; +} + +static inline int can_move(Player* p) { + return p->frozen_ticks <= 0; +} + +/** Safe ratio calculation (returns 0 if denominator is 0). */ +static inline float ratio_or_zero(int numerator, int denominator) { + if (denominator == 0) { + return 0.0f; + } + return (float)numerator / (float)denominator; +} + +/** Scale confidence based on sample count (saturates at 10). */ +static inline float confidence_scale(int count) { + if (count >= 10) { + return 1.0f; + } + return (float)count / 10.0f; +} + +/** Check if lightbearer ring is equipped (ITEM_LIGHTBEARER = 49). */ +static inline int is_lightbearer_equipped(Player* p) { + return p->equipped[GEAR_SLOT_RING] == 49; +} + +#define RECOIL_MAX_CHARGES 40 + +#endif // OSRS_TYPES_H diff --git a/ocean/osrs/pfsp.py b/ocean/osrs/pfsp.py new file mode 100644 index 0000000000..38a257c443 --- /dev/null +++ b/ocean/osrs/pfsp.py @@ -0,0 +1,68 @@ +"""PFSP (prioritized fictitious self-play) for osrs_pvp. + +opponent pool definitions, weight initialization, and adaptive weight recomputation +based on per-opponent win rates. used by sweep trials to train against a diverse +pool of opponents with difficulty-proportional sampling. +""" + +from __future__ import annotations + +# opponent name -> enum value (from osrs_types.h OpponentType) +OPP_PFSP = 16 # special opponent type that samples from the pool + +POOL = { + "true_random": 1, "panicking": 2, "weak_random": 3, "semi_random": 4, + "sticky_prayer": 5, "random_eater": 6, "prayer_rookie": 7, "improved": 8, + "onetick": 11, "unpredictable_improved": 12, "unpredictable_onetick": 13, + "novice_nh": 17, "apprentice_nh": 18, "competent_nh": 19, + "intermediate_nh": 20, "advanced_nh": 21, "proficient_nh": 22, + "expert_nh": 23, "master_nh": 24, "savant_nh": 25, + "nightmare_nh": 26, "veng_fighter": 27, "blood_healer": 28, + "gmaul_combo": 29, +} +POOL_NAMES = list(POOL.keys()) +POOL_TYPES = list(POOL.values()) + +# PFSP tuning constants +WEIGHT_EXPONENT = 1.5 # (1-winrate)^p +WEIGHT_FLOOR = 0.02 +UPDATE_INTERVAL = 2_000_000 # steps between weight recomputation +WARMUP_EPISODES = 50 # min episodes per opponent before reweighting + + +def init_pfsp(pufferl_handle: object, total_agents: int) -> dict: + """Initialize PFSP pool with uniform weights. Returns PFSP state dict.""" + pool_size = len(POOL_TYPES) + cum_weights = [int((i + 1) / pool_size * 1000) for i in range(pool_size)] + cum_weights[-1] = 1000 + pufferl_handle.set_pfsp_weights(POOL_TYPES, cum_weights) + return { + "cum_episodes": [0.0] * pool_size, + "last_update_step": 0, + } + + +def update_pfsp(pufferl_handle: object, pfsp_state: dict, global_step: int) -> None: + """Recompute PFSP weights based on per-opponent win rates.""" + if (global_step - pfsp_state["last_update_step"]) < UPDATE_INTERVAL: + return + + wins_delta, episodes_delta = pufferl_handle.get_pfsp_stats() + pool_size = len(POOL_TYPES) + + for i in range(pool_size): + pfsp_state["cum_episodes"][i] += episodes_delta[i] + + if min(pfsp_state["cum_episodes"]) < WARMUP_EPISODES: + pfsp_state["last_update_step"] = global_step + return + + raw_weights = [] + for i in range(pool_size): + wr = wins_delta[i] / max(episodes_delta[i], 1) + raw_weights.append(max((1.0 - wr) ** WEIGHT_EXPONENT, WEIGHT_FLOOR)) + total_w = sum(raw_weights) + cum_weights = [int(sum(raw_weights[:i + 1]) / total_w * 1000) for i in range(pool_size)] + cum_weights[-1] = 1000 + pufferl_handle.set_pfsp_weights(POOL_TYPES, cum_weights) + pfsp_state["last_update_step"] = global_step diff --git a/ocean/osrs/scripts/ExportItemSprites.class b/ocean/osrs/scripts/ExportItemSprites.class new file mode 100644 index 0000000000..12bb934020 Binary files /dev/null and b/ocean/osrs/scripts/ExportItemSprites.class differ diff --git a/ocean/osrs/scripts/ExportItemSprites.java b/ocean/osrs/scripts/ExportItemSprites.java new file mode 100644 index 0000000000..6fe57424e2 --- /dev/null +++ b/ocean/osrs/scripts/ExportItemSprites.java @@ -0,0 +1,222 @@ +/** + * Export item inventory sprites from OpenRS2 flat cache using RuneLite's + * ItemSpriteFactory (3D model → 2D sprite rendering). + * + * Reads the modern cache at reference/osrs-cache-modern/ (OpenRS2 flat format: + * {index}/{group}.dat files). Renders each requested item ID to a 36x32 PNG + * matching the real OSRS inventory icon exactly. + * + * Usage: + * javac -cp : scripts/ExportItemSprites.java + * java -cp ::scripts ExportItemSprites \ + * --cache ../reference/osrs-cache-modern \ + * --output data/sprites/items \ + * --ids 4151,10828,21795,... + */ + +import java.awt.image.BufferedImage; +import java.io.*; +import java.nio.file.*; +import java.util.*; +import javax.imageio.ImageIO; + +import net.runelite.cache.*; +import net.runelite.cache.definitions.*; +import net.runelite.cache.definitions.loaders.*; +import net.runelite.cache.definitions.providers.*; +import net.runelite.cache.fs.*; +import net.runelite.cache.index.*; +import net.runelite.cache.item.ItemSpriteFactory; + +/** + * Custom Storage that reads OpenRS2 flat cache format ({index}/{group}.dat). + * Reference tables are at 255/{index}.dat. + */ +class OpenRS2Storage implements Storage { + private final File cacheDir; + + OpenRS2Storage(File cacheDir) { + this.cacheDir = cacheDir; + } + + @Override + public void init(Store store) throws IOException { + // discover index IDs from directories under cache root + // (exclude 255 which is the meta-index) + File[] dirs = cacheDir.listFiles(File::isDirectory); + if (dirs == null) throw new IOException("cache dir not readable: " + cacheDir); + + List indexIds = new ArrayList<>(); + for (File d : dirs) { + try { + int id = Integer.parseInt(d.getName()); + if (id != 255) indexIds.add(id); + } catch (NumberFormatException ignored) {} + } + Collections.sort(indexIds); + for (int id : indexIds) { + store.addIndex(id); + } + } + + @Override + public void load(Store store) throws IOException { + for (Index index : store.getIndexes()) { + loadIndex(index); + } + } + + private void loadIndex(Index index) throws IOException { + // reference table for this index is at 255/{indexId}.dat + File refFile = new File(cacheDir, "255/" + index.getId() + ".dat"); + if (!refFile.exists()) { + System.err.println("warning: no reference table for index " + index.getId()); + return; + } + + byte[] refData = Files.readAllBytes(refFile.toPath()); + Container container = Container.decompress(refData, null); + byte[] data = container.data; + + IndexData indexData = new IndexData(); + indexData.load(data); + + index.setProtocol(indexData.getProtocol()); + index.setRevision(indexData.getRevision()); + index.setNamed(indexData.isNamed()); + + for (ArchiveData ad : indexData.getArchives()) { + Archive archive = index.addArchive(ad.getId()); + archive.setNameHash(ad.getNameHash()); + archive.setCrc(ad.getCrc()); + archive.setRevision(ad.getRevision()); + archive.setFileData(ad.getFiles()); + } + } + + @Override + public byte[] load(int index, int archive) throws IOException { + File f = new File(cacheDir, index + "/" + archive + ".dat"); + if (!f.exists()) return null; + return Files.readAllBytes(f.toPath()); + } + + @Override + public void save(Store store) throws IOException { + throw new UnsupportedOperationException("read-only"); + } + + @Override + public void store(int index, int archive, byte[] data) throws IOException { + throw new UnsupportedOperationException("read-only"); + } + + @Override + public void close() throws IOException {} +} + +public class ExportItemSprites { + public static void main(String[] args) throws Exception { + String cachePath = "../reference/osrs-cache-modern"; + String outputPath = "data/sprites/items"; + String idsArg = null; + + for (int i = 0; i < args.length; i++) { + switch (args[i]) { + case "--cache": cachePath = args[++i]; break; + case "--output": outputPath = args[++i]; break; + case "--ids": idsArg = args[++i]; break; + } + } + + File cacheDir = new File(cachePath); + File outDir = new File(outputPath); + outDir.mkdirs(); + + System.out.println("opening cache: " + cacheDir.getAbsolutePath()); + + try (Store store = new Store(new OpenRS2Storage(cacheDir))) { + store.load(); + + // load items + ItemManager itemManager = new ItemManager(store); + itemManager.load(); + itemManager.link(); + + // model provider: reads from index 7 + ModelProvider modelProvider = modelId -> { + Index models = store.getIndex(IndexType.MODELS); + Archive archive = models.getArchive(modelId); + if (archive == null) return null; + byte[] data = archive.decompress(store.getStorage().loadArchive(archive)); + return new ModelLoader().load(modelId, data); + }; + + // sprite manager + SpriteManager spriteManager = new SpriteManager(store); + spriteManager.load(); + + // texture manager — may fail on modern cache format, potions don't need textures + TextureManager textureManager = new TextureManager(store); + try { + textureManager.load(); + } catch (Exception e) { + System.err.println("warning: TextureManager.load() failed: " + e.getMessage()); + System.err.println(" continuing without textures (potions render fine without them)"); + } + + // parse item IDs to export + Set targetIds = new HashSet<>(); + if (idsArg != null) { + for (String s : idsArg.split(",")) { + targetIds.add(Integer.parseInt(s.trim())); + } + } + + int exported = 0, failed = 0; + + Collection items; + if (targetIds.isEmpty()) { + // export all items with valid models + items = itemManager.getItems(); + } else { + items = new ArrayList<>(); + for (int id : targetIds) { + ItemDefinition def = itemManager.getItem(id); + if (def != null) ((ArrayList) items).add(def); + else System.err.println(" item " + id + ": not found"); + } + } + + for (ItemDefinition itemDef : items) { + if (itemDef.name == null || itemDef.name.equalsIgnoreCase("null")) continue; + if (targetIds.isEmpty() && itemDef.inventoryModel <= 0) continue; + + try { + BufferedImage sprite = ItemSpriteFactory.createSprite( + itemManager, modelProvider, spriteManager, textureManager, + itemDef.id, 1, 1, 0x303030, false); + + if (sprite == null) { + System.err.println(" item " + itemDef.id + " (" + itemDef.name + "): null sprite"); + failed++; + continue; + } + + File out = new File(outDir, itemDef.id + ".png"); + ImageIO.write(sprite, "PNG", out); + exported++; + + System.out.println(" " + itemDef.id + " (" + itemDef.name + "): " + + sprite.getWidth() + "x" + sprite.getHeight()); + } catch (Exception ex) { + System.err.println(" item " + itemDef.id + " (" + itemDef.name + "): " + ex.getMessage()); + failed++; + } + } + + System.out.println("\nexported " + exported + " item sprites, " + failed + " failed"); + System.out.println("output: " + outDir.getAbsolutePath()); + } + } +} diff --git a/ocean/osrs/scripts/OpenRS2Storage.class b/ocean/osrs/scripts/OpenRS2Storage.class new file mode 100644 index 0000000000..1f9f4375f3 Binary files /dev/null and b/ocean/osrs/scripts/OpenRS2Storage.class differ diff --git a/ocean/osrs/scripts/export_all.sh b/ocean/osrs/scripts/export_all.sh new file mode 100755 index 0000000000..b112e511e4 --- /dev/null +++ b/ocean/osrs/scripts/export_all.sh @@ -0,0 +1,105 @@ +#!/usr/bin/env bash +# export all binary assets from an OSRS modern cache (OpenRS2 flat file format). +# +# usage: +# ./scripts/export_all.sh +# +# the cache can be downloaded from https://archive.openrs2.org/ — pick any +# recent OSRS revision, download the "flat file" export. the directory should +# contain numbered subdirectories (0/, 1/, 2/, 7/, 255/) and a keys.json. +# +# this script produces everything needed for training and the visual debug +# viewer. all output goes to data/. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DATA_DIR="$SCRIPT_DIR/../data" +mkdir -p "$DATA_DIR" "$DATA_DIR/sprites" + +if [ $# -lt 1 ]; then + echo "usage: $0 " + echo "" + echo "download a cache from https://archive.openrs2.org/" + echo "pick any recent OSRS revision, use the 'flat file' export." + exit 1 +fi + +CACHE="$1" +KEYS="$CACHE/keys.json" + +if [ ! -d "$CACHE/2" ]; then + echo "error: $CACHE doesn't look like a modern cache (missing 2/ subdir)" + exit 1 +fi +if [ ! -f "$KEYS" ]; then + echo "warning: no keys.json found — XTEA-encrypted regions will be skipped" + KEYS="" +fi + +cd "$SCRIPT_DIR" + +echo "=== exporting zulrah collision map ===" +python export_collision_map_modern.py \ + --cache "$CACHE" --keys "$KEYS" \ + --output "$DATA_DIR/zulrah.cmap" \ + --regions 35,47 35,48 + +echo "" +echo "=== exporting zulrah terrain ===" +python export_terrain.py \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/zulrah.terrain" \ + --regions 35,47 35,48 + +echo "" +echo "=== exporting zulrah objects ===" +python export_objects.py \ + --modern-cache "$CACHE" --keys "$KEYS" \ + --output "$DATA_DIR/zulrah.objects" \ + --regions 35,47 35,48 + +echo "" +echo "=== exporting equipment models ===" +python export_models.py \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/equipment.models" \ + --extra-models 14407,14408,14409,10415,20390,11221,26593,4086 + +echo "" +echo "=== exporting animations ===" +python export_animations.py \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/equipment.anims" + +echo "" +echo "=== exporting GUI sprites (prayer icons, hitsplats) ===" +python export_sprites_modern.py \ + --cache "$CACHE" \ + --output "$DATA_DIR/sprites/gui" + +echo "" +echo "=== exporting wilderness collision map ===" +python export_collision_map_modern.py \ + --cache "$CACHE" --keys "$KEYS" \ + --output "$DATA_DIR/wilderness.cmap" \ + --wilderness + +echo "" +echo "=== exporting wilderness terrain ===" +python export_terrain.py \ + --modern-cache "$CACHE" \ + --output "$DATA_DIR/wilderness.terrain" \ + --wilderness + +echo "" +echo "done. all assets exported to $DATA_DIR/" +echo "" +echo "notes:" +echo " - wilderness.objects (685MB+) is not exported by default." +echo " run manually if needed:" +echo " python scripts/export_objects.py --modern-cache $CACHE --keys $KEYS --output data/wilderness.objects --wilderness" +echo "" +echo " - item sprites (inventory icons) require the Java exporter:" +echo " javac -cp scripts/ExportItemSprites.java" +echo " java -cp .:scripts: ExportItemSprites data/sprites/items/" diff --git a/ocean/osrs/scripts/export_animations.py b/ocean/osrs/scripts/export_animations.py new file mode 100644 index 0000000000..1a0027282e --- /dev/null +++ b/ocean/osrs/scripts/export_animations.py @@ -0,0 +1,882 @@ +"""Export OSRS animation data from cache to a binary .anims file. + +Supports both 317-format (tarnish) and modern OpenRS2 flat file caches. +Reads framebases, frame archives, and sequence (animation) definitions. +Outputs a compact binary consumable by osrs_pvp_anim.h. + +317 cache sources: + - framebases.dat: from config archive (index 0, file 2) + - seq.dat: from config archive + - frame archives: from cache index 2 + +Modern cache sources: + - frame bases: index 1 (each group is a framebase) + - sequences: config index 2, group 12 + - frame archives: index 0 + +Usage (317 cache): + uv run python scripts/export_animations.py \ + --cache ../reference/tarnish/game-server/data/cache \ + --output data/equipment.anims + +Usage (modern cache): + uv run python scripts/export_animations.py \ + --modern-cache ../reference/osrs-cache-modern \ + --output data/equipment.anims +""" + +import argparse +import gzip +import io +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from export_collision_map import ( + CONFIG_INDEX, + CacheReader, + decode_archive, + hash_archive_name, +) +from modern_cache_reader import ( + ModernCacheReader, + decompress_container, + parse_sequence as parse_modern_sequence, +) + +CONFIG_ARCHIVE = 2 +FRAME_CACHE_INDEX = 2 # cache index for animation frame archives (317) +MODERN_FRAME_INDEX = 0 # modern cache: frame archives +MODERN_FRAMEBASE_INDEX = 1 # modern cache: frame bases +MODERN_SEQ_CONFIG_GROUP = 12 # modern cache: config index 2, group 12 + + +# --- binary reading helpers --- + + +def read_ubyte(buf: io.BytesIO) -> int: + """Read unsigned byte from stream.""" + b = buf.read(1) + if not b: + return 0 + return b[0] + + +def read_ushort(buf: io.BytesIO) -> int: + """Read big-endian unsigned short from stream.""" + b = buf.read(2) + if len(b) < 2: + return 0 + return (b[0] << 8) | b[1] + + +def read_short_smart(buf: io.BytesIO) -> int: + """Read signed short smart (same as Java Buffer.readShortSmart). + + Single byte (peek < 128): value - 64 (range -64 to 63) + Two bytes: value - 0xC000 (range -16384 to 16383) + """ + pos = buf.tell() + peek = buf.read(1) + if not peek: + return 0 + val = peek[0] + if val < 128: + return val - 64 + buf.seek(pos) + raw = struct.unpack(">H", buf.read(2))[0] + return raw - 0xC000 + + +def read_medium(buf: io.BytesIO) -> int: + """Read 3-byte big-endian medium int.""" + b = buf.read(3) + if len(b) < 3: + return 0 + return (b[0] << 16) | (b[1] << 8) | b[2] + + +def read_int(buf: io.BytesIO) -> int: + """Read big-endian signed 32-bit int.""" + b = buf.read(4) + if len(b) < 4: + return 0 + val = (b[0] << 24) | (b[1] << 16) | (b[2] << 8) | b[3] + if val >= 0x80000000: + val -= 0x100000000 + return val + + +# --- data structures --- + + +@dataclass +class FrameBaseDef: + """Transform slot layout — defines which vertex groups each slot operates on. + + Each slot has a type (0=origin, 1=translate, 2=rotate, 3=scale, 5=alpha) + and a list of vertex group label indices (frameMaps). + """ + + base_id: int = 0 + slot_count: int = 0 + types: list[int] = field(default_factory=list) + frame_maps: list[list[int]] = field(default_factory=list) + + +@dataclass +class FrameDef: + """Single animation frame — a list of transforms to apply. + + Each entry references a slot in the FrameBase and provides dx/dy/dz values. + Origin slots (type 0) auto-inserted before non-origin transforms. + """ + + framebase_id: int = 0 + translator_count: int = 0 + slot_indices: list[int] = field(default_factory=list) + dx: list[int] = field(default_factory=list) + dy: list[int] = field(default_factory=list) + dz: list[int] = field(default_factory=list) + + +@dataclass +class SequenceDef: + """Animation sequence — ordered frames with timing and blend metadata. + + primaryFrameIds encode (groupId << 16 | fileId) for cache frame lookup. + interleaveOrder defines which slots come from secondary (idle/walk) animation. + """ + + seq_id: int = 0 + frame_count: int = 0 + frame_delays: list[int] = field(default_factory=list) + primary_frame_ids: list[int] = field(default_factory=list) + frame_step: int = -1 + interleave_order: list[int] = field(default_factory=list) + priority: int = 5 + loop_count: int = 99 + walk_flag: int = -1 # opcode 10: 0=stall movement, -1=default (derive from interleave) + run_flag: int = -1 # opcode 9: 0=stall pre-anim steps, -1=default + + +# --- parsing --- + + +def parse_framebases(data: bytes) -> dict[int, FrameBaseDef]: + """Parse framebases.dat from config archive. + + Format: u16 count, then for each: u16 id, u16 size, [size bytes of framebase data]. + """ + buf = io.BytesIO(data) + count = read_ushort(buf) + bases: dict[int, FrameBaseDef] = {} + + for _ in range(count): + base_id = read_ushort(buf) + file_size = read_ushort(buf) + file_data = buf.read(file_size) + + fb = FrameBaseDef(base_id=base_id) + fbuf = io.BytesIO(file_data) + + fb.slot_count = read_ubyte(fbuf) + fb.types = [read_ubyte(fbuf) for _ in range(fb.slot_count)] + + map_lengths = [read_ubyte(fbuf) for _ in range(fb.slot_count)] + fb.frame_maps = [] + for length in map_lengths: + fb.frame_maps.append([read_ubyte(fbuf) for _ in range(length)]) + + bases[base_id] = fb + + return bases + + +def parse_frame_archive( + group_id: int, data: bytes, framebases: dict[int, FrameBaseDef], +) -> dict[int, FrameDef]: + """Parse a frame archive from cache index 2. + + Format: u16 highestFileId, then for each: u16 fileId, u24 fileSize, [fileSize bytes]. + Each frame is a NormalFrame referencing a FrameBase. + """ + buf = io.BytesIO(data) + highest_file_id = read_ushort(buf) + frames: dict[int, FrameDef] = {} + + for _ in range(highest_file_id + 1): + if buf.tell() >= len(data): + break + + file_id = read_ushort(buf) + file_size = read_medium(buf) + + if file_size <= 0 or buf.tell() + file_size > len(data): + break + + file_data = buf.read(file_size) + frame = _parse_normal_frame(group_id, file_id, file_data, framebases) + if frame is not None: + frames[file_id] = frame + + if file_id >= highest_file_id: + break + + return frames + + +def _parse_normal_frame( + group_id: int, + file_id: int, + data: bytes, + framebases: dict[int, FrameBaseDef], +) -> FrameDef | None: + """Parse a NormalFrame from raw bytes. Mirrors Java NormalFrame constructor.""" + fbuf = io.BytesIO(data) + + framebase_id = read_ushort(fbuf) + slot_count = read_ubyte(fbuf) + + fb = framebases.get(framebase_id) + if fb is None: + return None + + types = fb.types + + # read attribute bytes (one per slot) + attr_start = fbuf.tell() + attributes = [read_ubyte(fbuf) for _ in range(slot_count)] + + # data stream starts after attributes + dbuf = io.BytesIO(data) + dbuf.seek(slot_count + attr_start) + + slot_indices: list[int] = [] + dx_list: list[int] = [] + dy_list: list[int] = [] + dz_list: list[int] = [] + + last_i = -1 + for i in range(slot_count): + attr = attributes[i] + if attr <= 0: + continue + + # get the slot type from the framebase + slot_type = types[i] if i < len(types) else 0 + + # auto-insert preceding origin slot (type 0) if this slot isn't an origin + if slot_type != 0: + for j in range(i - 1, last_i, -1): + if j < len(types) and types[j] == 0: + slot_indices.append(j) + dx_list.append(0) + dy_list.append(0) + dz_list.append(0) + break + + slot_indices.append(i) + + default_val = 128 if slot_type == 3 else 0 + dx_list.append(read_short_smart(dbuf) if (attr & 1) else default_val) + dy_list.append(read_short_smart(dbuf) if (attr & 2) else default_val) + dz_list.append(read_short_smart(dbuf) if (attr & 4) else default_val) + + last_i = i + + frame = FrameDef( + framebase_id=framebase_id, + translator_count=len(slot_indices), + slot_indices=slot_indices, + dx=dx_list, + dy=dy_list, + dz=dz_list, + ) + return frame + + +def parse_sequences(data: bytes) -> dict[int, SequenceDef]: + """Parse seq.dat from config archive. Mirrors Java Animation.unpackConfig.""" + buf = io.BytesIO(data) + highest_file_id = read_ushort(buf) + seqs: dict[int, SequenceDef] = {} + + for _ in range(highest_file_id): + if buf.tell() >= len(data): + break + + seq_id = read_ushort(buf) + if seq_id == 0xFFFF or seq_id >= 32767: + continue + + anim_length = read_ushort(buf) + anim_data = buf.read(anim_length) + + seq = _parse_sequence(seq_id, anim_data) + if seq is not None: + seqs[seq_id] = seq + + if seq_id >= highest_file_id: + break + + return seqs + + +def _parse_sequence(seq_id: int, data: bytes) -> SequenceDef | None: + """Parse a single animation sequence from opcode stream.""" + seq = SequenceDef(seq_id=seq_id) + buf = io.BytesIO(data) + + while True: + opcode = read_ubyte(buf) + if opcode == 0: + break + elif opcode == 1: + seq.frame_count = read_ushort(buf) + seq.frame_delays = [read_ushort(buf) for _ in range(seq.frame_count)] + file_ids = [read_ushort(buf) for _ in range(seq.frame_count)] + group_ids = [read_ushort(buf) for _ in range(seq.frame_count)] + seq.primary_frame_ids = [ + (group_ids[i] << 16) | file_ids[i] for i in range(seq.frame_count) + ] + elif opcode == 2: + seq.frame_step = read_ushort(buf) + elif opcode == 3: + n = read_ubyte(buf) + seq.interleave_order = [read_ubyte(buf) for _ in range(n)] + elif opcode == 4: + pass # allowsRotation = true + elif opcode == 5: + seq.priority = read_ubyte(buf) + elif opcode == 6: + read_ushort(buf) # shield + elif opcode == 7: + read_ushort(buf) # weapon + elif opcode == 8: + seq.loop_count = read_ubyte(buf) + elif opcode == 9: + seq.run_flag = read_ubyte(buf) + elif opcode == 10: + seq.walk_flag = read_ubyte(buf) + elif opcode == 11: + read_ubyte(buf) # type + elif opcode == 12: + n = read_ubyte(buf) + for _ in range(n): + read_ushort(buf) + for _ in range(n): + read_ushort(buf) + elif opcode == 13: + n = read_ubyte(buf) + for _ in range(n): + read_medium(buf) + elif opcode == 14: + read_int(buf) # skeletalFrameId + elif opcode == 15: + n = read_ushort(buf) + for _ in range(n): + read_ushort(buf) + read_medium(buf) + elif opcode == 16: + read_ushort(buf) # rangeBegin + read_ushort(buf) # rangeEnd + elif opcode == 17: + n = read_ubyte(buf) + for _ in range(n): + read_ubyte(buf) + else: + print(f" warning: unknown seq opcode {opcode} for id {seq_id}", file=sys.stderr) + break + + if seq.frame_count == 0: + seq.frame_count = 1 + seq.primary_frame_ids = [-1] + seq.frame_delays = [-1] + + return seq + + +# --- binary output --- + + +ANIM_MAGIC = 0x414E494D # "ANIM" + + +def write_animations_binary( + output_path: Path, + framebases: dict[int, FrameBaseDef], + all_frames: dict[int, dict[int, FrameDef]], + sequences: dict[int, SequenceDef], + needed_seq_ids: set[int], +) -> None: + """Write animation data to .anims binary format. + + Only exports sequences in needed_seq_ids and their referenced framebases/frames. + + Binary layout: + header: + uint32 magic ("ANIM") + uint16 framebase_count + uint16 sequence_count + + framebases section (sorted by id): + for each framebase: + uint16 base_id + uint8 slot_count + uint8[slot_count] types + for each slot: + uint8 map_length + uint8[map_length] frame_map entries + + sequences section: + for each sequence: + uint16 seq_id + uint16 frame_count + uint8 interleave_count (0 if none) + uint8[interleave_count] interleave_order + int8 walk_flag (-1=default, 0=stall movement during anim) + for each frame in sequence: + uint16 delay (game ticks) + uint16 framebase_id + uint8 translator_count + for each translator: + uint8 slot_index + int16 dx + int16 dy + int16 dz + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + + # collect needed framebases from sequences + needed_bases: set[int] = set() + valid_seqs: list[SequenceDef] = [] + for seq_id in sorted(needed_seq_ids): + seq = sequences.get(seq_id) + if seq is None: + continue + + # check all frames exist + has_frames = True + for fid in seq.primary_frame_ids: + if fid == -1: + continue + group_id = fid >> 16 + file_id = fid & 0xFFFF + group = all_frames.get(group_id) + if group is None or file_id not in group: + has_frames = False + break + needed_bases.add(group[file_id].framebase_id) + + if has_frames: + valid_seqs.append(seq) + + # remap framebase IDs to compact indices + sorted_bases = sorted(needed_bases) + base_id_to_idx = {bid: idx for idx, bid in enumerate(sorted_bases)} + + with open(output_path, "wb") as f: + # header + f.write(struct.pack("> 16 + file_id = fid & 0xFFFF + frame = all_frames[group_id][file_id] + + f.write(struct.pack(" FrameBaseDef: + """Parse a single framebase from modern cache (index 1). + + Modern framebases are stored as individual entries. The format inside + is the same as 317: u8 slot_count, u8[slot_count] types, + u8[slot_count] map_lengths, then map entries. + """ + fb = FrameBaseDef(base_id=base_id) + fbuf = io.BytesIO(data) + + fb.slot_count = read_ubyte(fbuf) + fb.types = [read_ubyte(fbuf) for _ in range(fb.slot_count)] + + map_lengths = [read_ubyte(fbuf) for _ in range(fb.slot_count)] + fb.frame_maps = [] + for length in map_lengths: + fb.frame_maps.append([read_ubyte(fbuf) for _ in range(length)]) + + return fb + + +def load_modern_framebases( + reader: ModernCacheReader, needed_base_ids: set[int], +) -> dict[int, FrameBaseDef]: + """Load framebases from modern cache index 1. + + Each framebase is a separate group in index 1. Groups may contain + multiple files — we use file 0 as the framebase data. + """ + framebases: dict[int, FrameBaseDef] = {} + + for base_id in sorted(needed_base_ids): + raw = reader.read_container(MODERN_FRAMEBASE_INDEX, base_id) + if raw is None: + print(f" warning: framebase {base_id} not found in index {MODERN_FRAMEBASE_INDEX}") + continue + + fb = parse_modern_framebase(base_id, raw) + framebases[base_id] = fb + + return framebases + + +def load_modern_frame_archive( + reader: ModernCacheReader, + group_id: int, + framebases: dict[int, FrameBaseDef], +) -> dict[int, FrameDef]: + """Load a frame archive from modern cache index 0. + + In modern cache, frame archives are in index 0. Each group contains + multiple files (one per frame). We use read_group to get all files, + then parse each as a NormalFrame. + """ + try: + files = reader.read_group(MODERN_FRAME_INDEX, group_id) + except (KeyError, FileNotFoundError): + return {} + + frames: dict[int, FrameDef] = {} + for file_id, file_data in files.items(): + if len(file_data) < 3: + continue + frame = _parse_normal_frame(group_id, file_id, file_data, framebases) + if frame is not None: + frames[file_id] = frame + + return frames + + +def main() -> None: + """Export animation data from OSRS cache.""" + parser = argparse.ArgumentParser(description="export OSRS animations from cache") + cache_group = parser.add_mutually_exclusive_group(required=True) + cache_group.add_argument("--cache", type=Path, help="path to 317 tarnish cache directory") + cache_group.add_argument("--modern-cache", type=Path, help="path to modern OpenRS2 cache directory") + parser.add_argument("--output", required=True, help="output .anims file path") + args = parser.parse_args() + + output_path = Path(args.output) + use_modern = args.modern_cache is not None + cache_path = args.modern_cache if use_modern else args.cache + + print(f"reading {'modern' if use_modern else '317'} cache from {cache_path}") + + if use_modern: + modern_reader = ModernCacheReader(cache_path) + else: + cache = CacheReader(cache_path) + + # 1. load sequences + print("loading sequences...") + if use_modern: + seq_files = modern_reader.read_group(2, MODERN_SEQ_CONFIG_GROUP) + sequences: dict[int, SequenceDef] = {} + for seq_id, entry_data in seq_files.items(): + modern_seq = parse_modern_sequence(seq_id, entry_data) + # convert modern SequenceDef to our local SequenceDef + seq = SequenceDef( + seq_id=modern_seq.seq_id, + frame_count=modern_seq.frame_count, + frame_delays=modern_seq.frame_delays, + primary_frame_ids=modern_seq.primary_frame_ids, + frame_step=modern_seq.frame_step, + interleave_order=modern_seq.interleave_order, + priority=modern_seq.forced_priority, + loop_count=modern_seq.max_loops, + walk_flag=modern_seq.priority, # modern opcode 10 = priority (walk_flag equivalent) + run_flag=modern_seq.precedence_animating, # modern opcode 9 + ) + sequences[seq_id] = seq + print(f" loaded {len(sequences)} sequences") + else: + raw = cache.get(CONFIG_INDEX, CONFIG_ARCHIVE) + if raw is None: + sys.exit("could not read config archive") + + archive = decode_archive(raw) + seq_hash = hash_archive_name("seq.dat") & 0xFFFFFFFF + seq_data = archive.get(seq_hash) or archive.get(hash_archive_name("seq.dat")) + if seq_data is None: + sys.exit("seq.dat not found in config archive") + + sequences = parse_sequences(seq_data) + print(f" loaded {len(sequences)} sequences") + + # filter to needed animations + available = NEEDED_ANIMATIONS & set(sequences.keys()) + missing = NEEDED_ANIMATIONS - set(sequences.keys()) + if missing: + print(f" warning: {len(missing)} animations not found in cache: {sorted(missing)}") + print(f" {len(available)} needed animations available") + + # 2. collect needed frame group IDs from sequences + needed_groups: set[int] = set() + for seq_id in available: + seq = sequences[seq_id] + for fid in seq.primary_frame_ids: + if fid != -1: + needed_groups.add(fid >> 16) + + print(f"loading {len(needed_groups)} frame archives from cache...") + + if use_modern: + # 3a. modern path: first load frame archives to discover needed framebases, + # then load framebases, then re-parse frames with framebases available + + # first pass: discover framebase IDs from frame data headers + needed_base_ids: set[int] = set() + raw_frame_data: dict[int, dict[int, bytes]] = {} + for group_id in sorted(needed_groups): + try: + files = modern_reader.read_group(MODERN_FRAME_INDEX, group_id) + except (KeyError, FileNotFoundError): + print(f" warning: frame archive group {group_id} not found in index {MODERN_FRAME_INDEX}") + continue + raw_frame_data[group_id] = files + # each frame file starts with u16 framebase_id + for file_data in files.values(): + if len(file_data) >= 2: + fb_id = (file_data[0] << 8) | file_data[1] + needed_base_ids.add(fb_id) + + print(f" discovered {len(needed_base_ids)} needed framebases") + print("loading framebases from modern cache index 1...") + framebases = load_modern_framebases(modern_reader, needed_base_ids) + print(f" loaded {len(framebases)} framebases") + + # second pass: parse frames with framebases available + all_frames: dict[int, dict[int, FrameDef]] = {} + loaded = 0 + errors = 0 + for group_id, files in raw_frame_data.items(): + frames: dict[int, FrameDef] = {} + for file_id, file_data in files.items(): + if len(file_data) < 3: + continue + frame = _parse_normal_frame(group_id, file_id, file_data, framebases) + if frame is not None: + frames[file_id] = frame + if frames: + all_frames[group_id] = frames + loaded += 1 + + else: + # 3b. 317 path: load framebases from config archive first + print("loading framebases...") + raw = cache.get(CONFIG_INDEX, CONFIG_ARCHIVE) + if raw is None: + sys.exit("could not read config archive") + + archive = decode_archive(raw) + fb_hash = hash_archive_name("framebases.dat") & 0xFFFFFFFF + fb_data = archive.get(fb_hash) or archive.get(hash_archive_name("framebases.dat")) + if fb_data is None: + sys.exit("framebases.dat not found in config archive") + + framebases = parse_framebases(fb_data) + print(f" loaded {len(framebases)} framebases") + + # 4. load frame archives + all_frames = {} + loaded = 0 + errors = 0 + for group_id in sorted(needed_groups): + raw = cache.get(FRAME_CACHE_INDEX, group_id) + if raw is None: + print(f" warning: frame archive group {group_id} not found in cache index {FRAME_CACHE_INDEX}") + errors += 1 + continue + + # frame archives are gzip-compressed in the cache + if raw[:2] == b"\x1f\x8b": + raw = gzip.decompress(raw) + + frames = parse_frame_archive(group_id, raw, framebases) + if frames: + all_frames[group_id] = frames + loaded += 1 + + print(f" loaded {loaded} frame archives ({sum(len(v) for v in all_frames.values())} total frames), {errors} errors") + + # 5. write output + write_animations_binary(output_path, framebases, all_frames, sequences, available) + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_collision_map.py b/ocean/osrs/scripts/export_collision_map.py new file mode 100644 index 0000000000..8a21a5ad87 --- /dev/null +++ b/ocean/osrs/scripts/export_collision_map.py @@ -0,0 +1,834 @@ +"""Export collision data from a 317-format OSRS cache to our binary .cmap format. + +Reads tarnish's cache files (main_file_cache.dat + .idx*), parses the map_index +manifest, then for each region: + 1. Parse terrain data (gzipped) — mark tiles with attribute flag & 1 as BLOCKED + 2. Parse object data (gzipped) — mark walls and occupants using object definitions + +The object definitions (solid, impenetrable, width, length) are also loaded from +the cache. This replicates what tarnish's RegionDecoder + ObjectDefinitionDecoder +do at server startup, ported to Python. + +Output: binary .cmap file consumable by osrs_collision.h's collision_map_load(). + +Usage: + uv run python scripts/export_collision_map.py \ + --cache ../reference/tarnish/game-server/data/cache \ + --output data/wilderness.cmap +""" + +import argparse +import gzip +import io +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# --- collision flag constants (from TraversalConstants.java) --- + +WALL_NORTH_WEST = 0x000001 +WALL_NORTH = 0x000002 +WALL_NORTH_EAST = 0x000004 +WALL_EAST = 0x000008 +WALL_SOUTH_EAST = 0x000010 +WALL_SOUTH = 0x000020 +WALL_SOUTH_WEST = 0x000040 +WALL_WEST = 0x000080 + +IMPENETRABLE_WALL_NORTH_WEST = 0x000200 +IMPENETRABLE_WALL_NORTH = 0x000400 +IMPENETRABLE_WALL_NORTH_EAST = 0x000800 +IMPENETRABLE_WALL_EAST = 0x001000 +IMPENETRABLE_WALL_SOUTH_EAST = 0x002000 +IMPENETRABLE_WALL_SOUTH = 0x004000 +IMPENETRABLE_WALL_SOUTH_WEST = 0x008000 +IMPENETRABLE_WALL_WEST = 0x010000 + +IMPENETRABLE_BLOCKED = 0x020000 +BLOCKED = 0x200000 + +# --- 317 cache format reader --- + +INDEX_SIZE = 6 +SECTOR_HEADER_SIZE = 8 +SECTOR_SIZE = 520 +SECTOR_DATA_SIZE = 512 + +CONFIG_INDEX = 0 +MAP_INDEX = 4 +MANIFEST_ARCHIVE = 5 + + +def read_medium(data: bytes, offset: int) -> int: + """Read a 3-byte big-endian unsigned integer.""" + return (data[offset] << 16) | (data[offset + 1] << 8) | data[offset + 2] + + +def read_smart(buf: io.BytesIO) -> int: + """Read a 'smart' value (1 or 2 bytes depending on MSB).""" + pos = buf.tell() + peek = buf.read(1) + if not peek: + return 0 + buf.seek(pos) + val = peek[0] + if val < 128: + return buf.read(1)[0] & 0xFF + raw = struct.unpack(">H", buf.read(2))[0] + return raw - 32768 + + +class CacheReader: + """Reads the 317-format main_file_cache.dat + .idx files.""" + + def __init__(self, cache_dir: Path) -> None: + self.data_path = cache_dir / "main_file_cache.dat" + self.data_bytes = self.data_path.read_bytes() + + self.idx_bytes: dict[int, bytes] = {} + for idx_path in sorted(cache_dir.glob("main_file_cache.idx*")): + idx_id = int(idx_path.suffix.replace(".idx", "")) + self.idx_bytes[idx_id] = idx_path.read_bytes() + + def get(self, cache_id: int, file_id: int) -> bytes | None: + """Read a file from a cache index, following the sector chain.""" + idx_data = self.idx_bytes.get(cache_id) + if idx_data is None: + return None + + idx_offset = file_id * INDEX_SIZE + if idx_offset + INDEX_SIZE > len(idx_data): + return None + + length = read_medium(idx_data, idx_offset) + sector_id = read_medium(idx_data, idx_offset + 3) + + if length <= 0 or sector_id <= 0: + return None + + result = bytearray() + chunk = 0 + + while len(result) < length: + read_size = min(length - len(result), SECTOR_DATA_SIZE) + file_offset = sector_id * SECTOR_SIZE + + if file_offset + SECTOR_HEADER_SIZE + read_size > len(self.data_bytes): + return None + + # sector header: 2 fileID, 2 chunk, 3 nextSector, 1 cacheID + hdr = self.data_bytes[file_offset : file_offset + SECTOR_HEADER_SIZE] + _file_id = (hdr[0] << 8) | hdr[1] + _chunk = (hdr[2] << 8) | hdr[3] + next_sector = (hdr[4] << 16) | (hdr[5] << 8) | hdr[6] + _cache_id = hdr[7] + + data_start = file_offset + SECTOR_HEADER_SIZE + result.extend(self.data_bytes[data_start : data_start + read_size]) + + sector_id = next_sector + chunk += 1 + + return bytes(result[:length]) + + +def hash_archive_name(name: str) -> int: + """Compute archive name hash (same as StringUtils.hashArchive in tarnish). + + Java uses 32-bit signed int arithmetic with wraparound, so we mask to 32 bits + and convert to signed at the end. + """ + h = 0 + for c in name.upper(): + h = (h * 61 + ord(c) - 32) & 0xFFFFFFFF + # convert to signed 32-bit (Java int semantics) + if h >= 0x80000000: + h -= 0x100000000 + return h + + +def decode_archive(raw: bytes) -> dict[int, bytes]: + """Decode a 317-format archive (bzip2 compressed sectors).""" + buf = io.BytesIO(raw) + length = read_medium(raw, 0) + compressed_length = read_medium(raw, 3) + buf.seek(6) + + if compressed_length != length: + # bzip2 compressed (headerless — need to add BZ header) + compressed = raw[6 : 6 + compressed_length] + import bz2 + + # 317 bzip2 is headerless — prepend the standard bzip2 header + decompressed = bz2.decompress(b"BZh1" + compressed) + data = decompressed + else: + data = raw[6:] + + view = io.BytesIO(data) + total = struct.unpack(">H", view.read(2))[0] & 0xFF + header_start = view.tell() + data_offset = header_start + total * 10 + + sectors: dict[int, bytes] = {} + for _ in range(total): + name_hash = struct.unpack(">I", view.read(4))[0] + sec_length = read_medium(view.read(3), 0) + sec_compressed = read_medium(view.read(3), 0) + + if sec_length != sec_compressed: + import bz2 + + compressed = data[data_offset : data_offset + sec_compressed] + sector_data = bz2.decompress(b"BZh1" + compressed) + else: + sector_data = data[data_offset : data_offset + sec_length] + + sectors[name_hash] = sector_data + data_offset += sec_compressed + + return sectors + + +# --- object definition decoder --- + + +@dataclass +class ObjDef: + """Minimal object definition for collision marking.""" + + obj_id: int = 0 + width: int = 1 + length: int = 1 + solid: bool = True + impenetrable: bool = True + has_actions: bool = False + is_decoration: bool = False + actions: list[str | None] = field(default_factory=lambda: [None] * 5) + + +CONFIG_ARCHIVE = 2 + + +def decode_object_definitions(cache: CacheReader) -> dict[int, ObjDef]: + """Decode object definitions from cache config archive.""" + raw = cache.get(CONFIG_INDEX, CONFIG_ARCHIVE) + if raw is None: + print("warning: could not read config archive", file=sys.stderr) + return {} + + archive = decode_archive(raw) + loc_hash = hash_archive_name("loc.dat") + idx_hash = hash_archive_name("loc.idx") + + loc_data = archive.get(loc_hash) + loc_idx = archive.get(idx_hash) + + if loc_data is None or loc_idx is None: + print("warning: loc.dat/loc.idx not found in config archive", file=sys.stderr) + return {} + + buf = io.BytesIO(loc_data) + idx_buf = io.BytesIO(loc_idx) + + total = struct.unpack(">H", idx_buf.read(2))[0] + defs: dict[int, ObjDef] = {} + + for obj_id in range(total): + d = ObjDef(obj_id=obj_id) + + while True: + opcode = buf.read(1) + if not opcode: + break + opcode = opcode[0] + + if opcode == 0: + break + elif opcode == 1: + model_count = buf.read(1)[0] + for _ in range(model_count): + buf.read(2) # model id + buf.read(1) # model type + elif opcode == 2: + _name = _read_string(buf) + elif opcode == 3: + _desc = _read_string(buf) + elif opcode == 5: + model_count = buf.read(1)[0] + for _ in range(model_count): + buf.read(2) # model id + elif opcode == 14: + d.width = buf.read(1)[0] + elif opcode == 15: + d.length = buf.read(1)[0] + elif opcode == 17: + d.solid = False + elif opcode == 18: + d.impenetrable = False + elif opcode == 19: + val = buf.read(1)[0] + d.has_actions = val == 1 + elif opcode == 21: + pass # contouredGround + elif opcode == 22: + pass # nonFlatShading + elif opcode == 23: + pass # modelClipped + elif opcode == 24: + buf.read(2) # animation id + elif opcode == 28: + buf.read(1) # decorDisplacement + elif opcode == 29: + buf.read(1) # ambient + elif opcode == 39: + buf.read(1) # contrast + elif 30 <= opcode <= 34: + action = _read_string(buf) + d.actions[opcode - 30] = action if action != "hidden" else None + if action and action != "hidden": + d.has_actions = True + elif opcode == 40: + count = buf.read(1)[0] + for _ in range(count): + buf.read(2) # old color + buf.read(2) # new color + elif opcode == 60: + buf.read(2) # mapAreaId + elif opcode == 62: + pass # isRotated + elif opcode == 64: + pass # shadow = false + elif opcode == 65: + buf.read(2) # modelSizeX + elif opcode == 66: + buf.read(2) # modelSizeH + elif opcode == 67: + buf.read(2) # modelSizeY + elif opcode == 68: + buf.read(2) # mapsceneID + elif opcode == 69: + buf.read(1) # surroundings + elif opcode == 70: + buf.read(2) # translateX + elif opcode == 71: + buf.read(2) # translateH + elif opcode == 72: + buf.read(2) # translateY + elif opcode == 73: + pass # obstructsGround + elif opcode == 74: + pass # isHollow (= not solid) + d.solid = False + elif opcode == 75: + buf.read(1) # supportItems + elif opcode == 77: + # varbit / varp / transforms + buf.read(2) # varpID + buf.read(2) # varbitID + count = buf.read(1)[0] + for _ in range(count + 1): + buf.read(2) # transform id + else: + # unknown opcode — can't safely skip, break + break + + defs[obj_id] = d + + return defs + + +def _read_string(buf: io.BytesIO) -> str: + """Read a null-terminated string.""" + chars = [] + while True: + b = buf.read(1) + if not b or b[0] == 0: + break + chars.append(chr(b[0])) + return "".join(chars) + + +# --- region / collision data --- + + +@dataclass +class RegionDef: + """Region definition from map_index.""" + + region_hash: int + terrain_file: int + object_file: int + + @property + def region_x(self) -> int: + return (self.region_hash >> 8) & 0xFF + + @property + def region_y(self) -> int: + return self.region_hash & 0xFF + + +def load_map_index(cache: CacheReader) -> list[RegionDef]: + """Load region definitions from the manifest archive's map_index.""" + # manifest archive is idx0, file 5 + raw = cache.get(CONFIG_INDEX, MANIFEST_ARCHIVE) + if raw is None: + sys.exit("could not read manifest archive (idx0, file 5)") + + archive = decode_archive(raw) + map_idx_hash = hash_archive_name("map_index") + map_idx_data = archive.get(map_idx_hash) + + if map_idx_data is None: + sys.exit("map_index not found in manifest archive") + + buf = io.BytesIO(map_idx_data) + count = struct.unpack(">H", buf.read(2))[0] + + regions = [] + for _ in range(count): + region_hash = struct.unpack(">H", buf.read(2))[0] + terrain_file = struct.unpack(">H", buf.read(2))[0] + object_file = struct.unpack(">H", buf.read(2))[0] + regions.append(RegionDef(region_hash, terrain_file, object_file)) + + return regions + + +# collision flag storage: flags[height][local_x][local_y] +CollisionFlags = list[list[list[int]]] # [4][64][64] + + +def new_collision_flags() -> CollisionFlags: + return [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + + +def parse_terrain(data: bytes) -> tuple[CollisionFlags, set[tuple[int, int, int]]]: + """Parse terrain data, return (collision_flags, down_heights_set). + + Terrain attribute & 1 marks tiles as BLOCKED. + Terrain attribute & 2 marks tiles for height-plane adjustment (downHeights). + """ + flags = new_collision_flags() + down_heights: set[tuple[int, int, int]] = set() + attributes = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + + buf = io.BytesIO(data) + + # phase 1: read attributes + for height in range(4): + for local_x in range(64): + for local_y in range(64): + while True: + raw = buf.read(2) + if len(raw) < 2: + break + attr_id = struct.unpack(">H", raw)[0] + + if attr_id == 0: + break + elif attr_id == 1: + buf.read(1) # tile height + break + elif attr_id <= 49: + buf.read(2) # overlay id + elif attr_id <= 81: + attributes[height][local_x][local_y] = attr_id - 49 + + # phase 2: apply terrain flags + for height in range(4): + for local_x in range(64): + for local_y in range(64): + attr = attributes[height][local_x][local_y] + + if attr & 2: + down_heights.add((local_x, local_y, height)) + + if attr & 1: + plane = height + if attributes[1][local_x][local_y] & 2: + down_heights.add((local_x, local_y, 1)) + plane -= 1 + if plane >= 0: + flags[plane][local_x][local_y] |= BLOCKED + + return flags, down_heights + + +# --- wall marking (from TraversalMap.java markWall) --- + +# object type IDs +OBJ_STRAIGHT_WALL = 0 +OBJ_DIAGONAL_CORNER = 1 +OBJ_ENTIRE_WALL = 2 +OBJ_WALL_CORNER = 3 +OBJ_DIAGONAL_WALL = 9 +OBJ_GENERAL_PROP = 10 +OBJ_WALKABLE_PROP = 11 +OBJ_GROUND_PROP = 22 + +# object direction +DIR_WEST = 0 +DIR_NORTH = 1 +DIR_EAST = 2 +DIR_SOUTH = 3 + + +def mark_wall( + flags: CollisionFlags, + direction: int, + height: int, + lx: int, + ly: int, + obj_type: int, + impenetrable: bool, +) -> None: + """Mark wall collision flags on the tile and its neighbor (from TraversalMap.markWall).""" + if obj_type == OBJ_STRAIGHT_WALL: + if direction == DIR_WEST: + _flag(flags, height, lx, ly, WALL_WEST) + _flag_safe(flags, height, lx - 1, ly, WALL_EAST) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_WEST) + _flag_safe(flags, height, lx - 1, ly, IMPENETRABLE_WALL_EAST) + elif direction == DIR_NORTH: + _flag(flags, height, lx, ly, WALL_NORTH) + _flag_safe(flags, height, lx, ly + 1, WALL_SOUTH) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_NORTH) + _flag_safe(flags, height, lx, ly + 1, IMPENETRABLE_WALL_SOUTH) + elif direction == DIR_EAST: + _flag(flags, height, lx, ly, WALL_EAST) + _flag_safe(flags, height, lx + 1, ly, WALL_WEST) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_EAST) + _flag_safe(flags, height, lx + 1, ly, IMPENETRABLE_WALL_WEST) + elif direction == DIR_SOUTH: + _flag(flags, height, lx, ly, WALL_SOUTH) + _flag_safe(flags, height, lx, ly - 1, WALL_NORTH) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_SOUTH) + _flag_safe(flags, height, lx, ly - 1, IMPENETRABLE_WALL_NORTH) + + elif obj_type == OBJ_ENTIRE_WALL: + if direction == DIR_WEST: + _flag(flags, height, lx, ly, WALL_WEST | WALL_NORTH) + _flag_safe(flags, height, lx - 1, ly, WALL_EAST) + _flag_safe(flags, height, lx, ly + 1, WALL_SOUTH) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_WEST | IMPENETRABLE_WALL_NORTH) + _flag_safe(flags, height, lx - 1, ly, IMPENETRABLE_WALL_EAST) + _flag_safe(flags, height, lx, ly + 1, IMPENETRABLE_WALL_SOUTH) + elif direction == DIR_NORTH: + _flag(flags, height, lx, ly, WALL_EAST | WALL_NORTH) + _flag_safe(flags, height, lx, ly + 1, WALL_SOUTH) + _flag_safe(flags, height, lx + 1, ly, WALL_WEST) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_EAST | IMPENETRABLE_WALL_NORTH) + _flag_safe(flags, height, lx, ly + 1, IMPENETRABLE_WALL_SOUTH) + _flag_safe(flags, height, lx + 1, ly, IMPENETRABLE_WALL_WEST) + elif direction == DIR_EAST: + _flag(flags, height, lx, ly, WALL_EAST | WALL_SOUTH) + _flag_safe(flags, height, lx + 1, ly, WALL_WEST) + _flag_safe(flags, height, lx, ly - 1, WALL_NORTH) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_EAST | IMPENETRABLE_WALL_SOUTH) + _flag_safe(flags, height, lx + 1, ly, IMPENETRABLE_WALL_WEST) + _flag_safe(flags, height, lx, ly - 1, IMPENETRABLE_WALL_NORTH) + elif direction == DIR_SOUTH: + _flag(flags, height, lx, ly, WALL_WEST | WALL_SOUTH) + _flag_safe(flags, height, lx - 1, ly, WALL_EAST) + _flag_safe(flags, height, lx, ly - 1, WALL_NORTH) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_WEST | IMPENETRABLE_WALL_SOUTH) + _flag_safe(flags, height, lx - 1, ly, IMPENETRABLE_WALL_EAST) + _flag_safe(flags, height, lx, ly - 1, IMPENETRABLE_WALL_NORTH) + + elif obj_type == OBJ_DIAGONAL_CORNER: + if direction == DIR_WEST: + _flag(flags, height, lx, ly, WALL_NORTH_WEST) + _flag_safe(flags, height, lx - 1, ly + 1, WALL_SOUTH_EAST) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_NORTH_WEST) + _flag_safe(flags, height, lx - 1, ly + 1, IMPENETRABLE_WALL_SOUTH_EAST) + elif direction == DIR_NORTH: + _flag(flags, height, lx, ly, WALL_NORTH_EAST) + _flag_safe(flags, height, lx + 1, ly + 1, WALL_SOUTH_WEST) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_NORTH_EAST) + _flag_safe(flags, height, lx + 1, ly + 1, IMPENETRABLE_WALL_SOUTH_WEST) + elif direction == DIR_EAST: + _flag(flags, height, lx, ly, WALL_SOUTH_EAST) + _flag_safe(flags, height, lx + 1, ly - 1, WALL_NORTH_WEST) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_SOUTH_EAST) + _flag_safe(flags, height, lx + 1, ly - 1, IMPENETRABLE_WALL_NORTH_WEST) + elif direction == DIR_SOUTH: + _flag(flags, height, lx, ly, WALL_SOUTH_WEST) + _flag_safe(flags, height, lx - 1, ly - 1, WALL_NORTH_EAST) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_SOUTH_WEST) + _flag_safe(flags, height, lx - 1, ly - 1, IMPENETRABLE_WALL_NORTH_EAST) + + elif obj_type == OBJ_WALL_CORNER: + if direction == DIR_WEST: + _flag(flags, height, lx, ly, WALL_WEST) + _flag_safe(flags, height, lx - 1, ly, WALL_EAST) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_WEST) + _flag_safe(flags, height, lx - 1, ly, IMPENETRABLE_WALL_EAST) + elif direction == DIR_NORTH: + _flag(flags, height, lx, ly, WALL_NORTH) + _flag_safe(flags, height, lx, ly + 1, WALL_SOUTH) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_NORTH) + _flag_safe(flags, height, lx, ly + 1, IMPENETRABLE_WALL_SOUTH) + elif direction == DIR_EAST: + _flag(flags, height, lx, ly, WALL_EAST) + _flag_safe(flags, height, lx + 1, ly, WALL_WEST) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_EAST) + _flag_safe(flags, height, lx + 1, ly, IMPENETRABLE_WALL_WEST) + elif direction == DIR_SOUTH: + _flag(flags, height, lx, ly, WALL_SOUTH) + _flag_safe(flags, height, lx, ly - 1, WALL_NORTH) + if impenetrable: + _flag(flags, height, lx, ly, IMPENETRABLE_WALL_SOUTH) + _flag_safe(flags, height, lx, ly - 1, IMPENETRABLE_WALL_NORTH) + + +def mark_occupant( + flags: CollisionFlags, + height: int, + lx: int, + ly: int, + width: int, + length: int, + impenetrable: bool, +) -> None: + """Mark a multi-tile occupant as BLOCKED + optionally IMPENETRABLE_BLOCKED.""" + flag = BLOCKED + if impenetrable: + flag |= IMPENETRABLE_BLOCKED + for xi in range(lx, lx + width): + for yi in range(ly, ly + length): + _flag_safe(flags, height, xi, yi, flag) + + +def _flag(flags: CollisionFlags, height: int, lx: int, ly: int, flag: int) -> None: + """Set flag bits on a local tile (no bounds check).""" + flags[height][lx][ly] |= flag + + +def _flag_safe(flags: CollisionFlags, height: int, lx: int, ly: int, flag: int) -> None: + """Set flag bits with bounds check (neighbor might be outside 64x64 region).""" + if 0 <= lx < 64 and 0 <= ly < 64 and 0 <= height < 4: + flags[height][lx][ly] |= flag + + +def parse_objects( + data: bytes, + flags: CollisionFlags, + base_x: int, + base_y: int, + down_heights: set[tuple[int, int, int]], + obj_defs: dict[int, ObjDef], +) -> None: + """Parse object data and mark collision flags (walls, occupants).""" + buf = io.BytesIO(data) + obj_id = -1 + + while True: + obj_id_offset = read_smart(buf) + if obj_id_offset == 0: + break + + obj_id += obj_id_offset + obj_pos_info = 0 + + while True: + pos_offset = read_smart(buf) + if pos_offset == 0: + break + obj_pos_info += pos_offset - 1 + + raw_byte = buf.read(1) + if not raw_byte: + return + obj_other_info = raw_byte[0] + local_y = obj_pos_info & 0x3F + local_x = (obj_pos_info >> 6) & 0x3F + height = (obj_pos_info >> 12) & 0x3 + + obj_type = obj_other_info >> 2 + direction = obj_other_info & 0x3 + + # downHeights adjustment (from RegionDecoder.java) + if (local_x, local_y, 1) in down_heights: + height -= 1 + if height < 0: + continue + elif (local_x, local_y, height) in down_heights: + height -= 1 + + if height < 0: + continue + + d = obj_defs.get(obj_id) + if d is None: + continue + + if not d.solid: + continue + + # swap width/length for N/S rotation (from TraversalMap.markObject) + if direction == DIR_NORTH or direction == DIR_SOUTH: + size_x = d.length + size_y = d.width + else: + size_x = d.width + size_y = d.length + + if obj_type == OBJ_GROUND_PROP: + if d.has_actions: + mark_occupant(flags, height, local_x, local_y, size_x, size_y, False) + elif obj_type in (OBJ_GENERAL_PROP, OBJ_WALKABLE_PROP) or obj_type >= 12: + mark_occupant(flags, height, local_x, local_y, size_x, size_y, d.impenetrable) + elif obj_type == OBJ_DIAGONAL_WALL: + mark_occupant(flags, height, local_x, local_y, size_x, size_y, d.impenetrable) + elif 0 <= obj_type <= 3: + mark_wall(flags, direction, height, local_x, local_y, obj_type, d.impenetrable) + + +# --- .cmap binary writer --- + +CMAP_MAGIC = 0x50414D43 # "CMAP" little-endian +CMAP_VERSION = 1 + + +def write_cmap(output_path: Path, regions: dict[int, CollisionFlags]) -> None: + """Write regions to our binary .cmap format.""" + with open(output_path, "wb") as f: + f.write(struct.pack(" None: + parser = argparse.ArgumentParser(description="export OSRS collision map from 317 cache") + parser.add_argument( + "--cache", + type=Path, + default=Path("../reference/tarnish/game-server/data/cache"), + help="path to cache directory containing main_file_cache.dat + .idx files", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("data/wilderness.cmap"), + help="output .cmap binary file", + ) + parser.add_argument( + "--all-regions", + action="store_true", + help="export all regions (default: wilderness only)", + ) + args = parser.parse_args() + + if not args.cache.exists(): + sys.exit(f"cache directory not found: {args.cache}") + + args.output.parent.mkdir(parents=True, exist_ok=True) + + print(f"reading cache from {args.cache}") + cache = CacheReader(args.cache) + + print("loading object definitions from cache...") + obj_defs = decode_object_definitions(cache) + print(f" loaded {len(obj_defs)} object definitions") + + print("loading map index...") + region_defs = load_map_index(cache) + print(f" {len(region_defs)} regions in map index") + + # wilderness is roughly regionX=46-53, regionY=48-62 + # world coords: x=2944-3392, y=3072-3968 + # our arena is centered at (3222, 3544) → regionX=50, regionY=55 + if not args.all_regions: + wilderness_defs = [ + rd for rd in region_defs if 44 <= rd.region_x <= 56 and 48 <= rd.region_y <= 62 + ] + print(f" filtering to wilderness: {len(wilderness_defs)} regions") + else: + wilderness_defs = region_defs + print(f" exporting all {len(wilderness_defs)} regions") + + output_regions: dict[int, CollisionFlags] = {} + decoded = 0 + errors = 0 + + for rd in wilderness_defs: + base_x = rd.region_x << 6 + base_y = rd.region_y << 6 + + terrain_data = cache.get(MAP_INDEX, rd.terrain_file) + if terrain_data is None: + errors += 1 + continue + + try: + terrain_data = gzip.decompress(terrain_data) + except Exception: + errors += 1 + continue + + flags, down_heights = parse_terrain(terrain_data) + + # parse objects + obj_data = cache.get(MAP_INDEX, rd.object_file) + if obj_data is not None: + try: + obj_data = gzip.decompress(obj_data) + parse_objects(obj_data, flags, base_x, base_y, down_heights, obj_defs) + except Exception as e: + print(f" warning: object parse failed for region ({rd.region_x}, {rd.region_y}): {e}") + + # compute the region map key the same way as osrs_collision.h + key = (rd.region_x << 8) | rd.region_y + output_regions[key] = flags + decoded += 1 + + print(f"\ndecoded {decoded} regions, {errors} skipped") + + # count non-zero tiles + blocked_count = 0 + wall_count = 0 + for flags in output_regions.values(): + for x in range(64): + for y in range(64): + f = flags[0][x][y] # height 0 only for stats + if f & BLOCKED: + blocked_count += 1 + if f & (WALL_NORTH | WALL_SOUTH | WALL_EAST | WALL_WEST): + wall_count += 1 + + print(f"height-0 stats: {blocked_count} blocked tiles, {wall_count} tiles with walls") + + write_cmap(args.output, output_regions) + file_size = args.output.stat().st_size + print(f"\nwrote {file_size:,} bytes to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_collision_map_modern.py b/ocean/osrs/scripts/export_collision_map_modern.py new file mode 100644 index 0000000000..960efc0aa1 --- /dev/null +++ b/ocean/osrs/scripts/export_collision_map_modern.py @@ -0,0 +1,759 @@ +"""Export collision data from modern OpenRS2 OSRS cache to .cmap binary format. + +Reads modern cache (flat file format from OpenRS2), parses terrain and object +data for specified regions, and outputs collision flags compatible with +osrs_collision.h's collision_map_load(). + +Modern cache differences from 317: + - Map data in index 5, groups identified by djb2 name hash + - Object data (l_X_Y) is XTEA encrypted, keys from OpenRS2 archive + - Object IDs in map data use read_big_smart (2 or 4 bytes) instead of read_smart + - Object definitions in index 2 group 6, modern opcode set + +Usage: + uv run python scripts/export_collision_map_modern.py \ + --cache ../reference/osrs-cache-modern \ + --keys ../reference/osrs-cache-modern/keys.json \ + --output data/zulrah.cmap \ + --regions 35,48 + + # export multiple regions + uv run python scripts/export_collision_map_modern.py \ + --cache ../reference/osrs-cache-modern \ + --keys ../reference/osrs-cache-modern/keys.json \ + --output data/world.cmap \ + --regions 35,48 34,48 36,48 + + # export wilderness regions + uv run python scripts/export_collision_map_modern.py \ + --cache ../reference/osrs-cache-modern \ + --keys ../reference/osrs-cache-modern/keys.json \ + --output data/wilderness.cmap \ + --wilderness +""" + +import argparse +import bz2 +import io +import json +import struct +import sys +import zlib +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from export_collision_map import ( + BLOCKED, + IMPENETRABLE_BLOCKED, + WALL_EAST, + WALL_NORTH, + WALL_SOUTH, + WALL_WEST, + CollisionFlags, + mark_occupant, + mark_wall, + new_collision_flags, + parse_terrain, + write_cmap, +) +from modern_cache_reader import ( + ModernCacheReader, + read_big_smart, + read_smart, + read_string, + read_u16, + read_u8, +) + +# object type IDs (same as 317) +OBJ_STRAIGHT_WALL = 0 +OBJ_DIAGONAL_CORNER = 1 +OBJ_ENTIRE_WALL = 2 +OBJ_WALL_CORNER = 3 +OBJ_DIAGONAL_WALL = 9 +OBJ_GENERAL_PROP = 10 +OBJ_WALKABLE_PROP = 11 +OBJ_GROUND_PROP = 22 + +# directions +DIR_WEST = 0 +DIR_NORTH = 1 +DIR_EAST = 2 +DIR_SOUTH = 3 + +# modern cache config group for object definitions +MODERN_OBJ_CONFIG_GROUP = 6 + + +# --- djb2 name hash (OSRS modern cache uses this for map group names) --- + + +def djb2(name: str) -> int: + """Compute djb2 hash for modern cache group name lookup. + + Returns signed 32-bit int matching Java's djb2 semantics. + """ + h = 0 + for c in name.lower(): + h = (h * 31 + ord(c)) & 0xFFFFFFFF + if h >= 0x80000000: + h -= 0x100000000 + return h + + +# --- XTEA decryption --- + + +def xtea_decrypt(data: bytes, key: list[int]) -> bytes: + """Decrypt XTEA-encrypted data using 4 int32 key. + + OSRS uses 32 rounds of XTEA in big-endian mode. + Only processes complete 8-byte blocks; trailing bytes pass through. + """ + delta = 0x9E3779B9 + result = bytearray() + + for i in range(len(data) // 8): + v0, v1 = struct.unpack(">II", data[i * 8 : (i + 1) * 8]) + total = (delta * 32) & 0xFFFFFFFF + + # convert key to unsigned for arithmetic + ukey = [k & 0xFFFFFFFF for k in key] + + for _ in range(32): + v1 = ( + v1 - (((v0 << 4 ^ v0 >> 5) + v0) ^ (total + ukey[(total >> 11) & 3])) + ) & 0xFFFFFFFF + total = (total - delta) & 0xFFFFFFFF + v0 = ( + v0 - (((v1 << 4 ^ v1 >> 5) + v1) ^ (total + ukey[total & 3])) + ) & 0xFFFFFFFF + + result.extend(struct.pack(">II", v0, v1)) + + # pass through any trailing bytes (< 8) + result.extend(data[(len(data) // 8) * 8 :]) + return bytes(result) + + +# --- modern object definition decoder --- + + +@dataclass +class ModernObjDef: + """Object definition from modern OSRS cache, with collision-relevant fields.""" + + obj_id: int = 0 + width: int = 1 + length: int = 1 + solid: bool = True + impenetrable: bool = True + has_actions: bool = False + actions: list[str | None] = field(default_factory=lambda: [None] * 5) + + +def _read_modern_obj_string(buf: io.BytesIO) -> str: + """Read null-terminated string from object definition data.""" + chars = [] + while True: + b = buf.read(1) + if not b or b[0] == 0: + break + chars.append(chr(b[0])) + return "".join(chars) + + +def decode_modern_obj_def(obj_id: int, data: bytes) -> ModernObjDef: + """Parse a single modern object definition from opcode stream. + + Modern opcodes that affect collision: 14 (width), 15 (length), + 17 (not solid), 18 (not impenetrable), 19 (interactType/hasActions), + 30-34 (action strings), 74 (isHollow = not solid). + + All other opcodes are skipped but must be parsed correctly to avoid + desynchronizing the stream. + """ + d = ModernObjDef(obj_id=obj_id) + buf = io.BytesIO(data) + + while True: + raw = buf.read(1) + if not raw: + break + opcode = raw[0] + + if opcode == 0: + break + elif opcode == 1: + # models: u8 count, then (u16 model_id, u8 type) per entry + count = read_u8(buf) + for _ in range(count): + read_u16(buf) # model id + read_u8(buf) # model type + elif opcode == 2: + _read_modern_obj_string(buf) # name + elif opcode == 5: + # models without types: u8 count, then u16 model_id per entry + count = read_u8(buf) + for _ in range(count): + read_u16(buf) + elif opcode == 14: + d.width = read_u8(buf) + elif opcode == 15: + d.length = read_u8(buf) + elif opcode == 17: + d.solid = False + elif opcode == 18: + d.impenetrable = False + elif opcode == 19: + val = read_u8(buf) + d.has_actions = val == 1 + elif opcode == 21: + pass # contouredGround = 0 + elif opcode == 22: + pass # nonFlatShading + elif opcode == 23: + pass # modelClipped + elif opcode == 24: + read_u16(buf) # animation id + elif opcode == 27: + pass # clipType = 1 + elif opcode == 28: + read_u8(buf) # decorDisplacement + elif opcode == 29: + buf.read(1) # ambient (signed byte) + elif opcode in range(30, 35): + action = _read_modern_obj_string(buf) + d.actions[opcode - 30] = action if action != "hidden" else None + if action and action != "hidden": + d.has_actions = True + elif opcode == 39: + buf.read(1) # contrast (signed byte) + elif opcode == 40: + # recolor: u8 count, then (u16 old, u16 new) per entry + count = read_u8(buf) + for _ in range(count): + read_u16(buf) + read_u16(buf) + elif opcode == 41: + # retexture: u8 count, then (u16 old, u16 new) per entry + count = read_u8(buf) + for _ in range(count): + read_u16(buf) + read_u16(buf) + elif opcode == 60: + read_u16(buf) # mapAreaId + elif opcode == 61: + read_u16(buf) # category + elif opcode == 62: + pass # isRotated + elif opcode == 64: + pass # shadow = false + elif opcode == 65: + read_u16(buf) # modelSizeX + elif opcode == 66: + read_u16(buf) # modelSizeH + elif opcode == 67: + read_u16(buf) # modelSizeY + elif opcode == 68: + read_u16(buf) # mapsceneID + elif opcode == 69: + read_u8(buf) # surroundings + elif opcode == 70: + read_u16(buf) # translateX (signed) + elif opcode == 71: + read_u16(buf) # translateH (signed) + elif opcode == 72: + read_u16(buf) # translateY (signed) + elif opcode == 73: + pass # obstructsGround + elif opcode == 74: + d.solid = False # isHollow + elif opcode == 75: + read_u8(buf) # supportItems + elif opcode == 77: + # transforms: u16 varbit, u16 varp, u8 count, u16[count+1] ids + read_u16(buf) # varbitID + read_u16(buf) # varpID + count = read_u8(buf) + for _ in range(count + 1): + read_u16(buf) + elif opcode == 78: + # bgsound: u16 soundId, u8 distance, u8 retain (rev220+) + read_u16(buf) # sound id + read_u8(buf) # distance + read_u8(buf) # retain + elif opcode == 79: + # randomsound: u16 tick1, u16 tick2, u8 distance, u8 retain, u8 count, u16[count] ids + read_u16(buf) # anInt2112 + read_u16(buf) # anInt2113 + read_u8(buf) # distance + read_u8(buf) # retain (rev220+) + count = read_u8(buf) + for _ in range(count): + read_u16(buf) # sound ids + elif opcode == 81: + # treeskew / contouredGround = value * 256 + read_u8(buf) + elif opcode == 82: + read_u16(buf) # mapIconId + elif opcode == 89: + pass # randomAnimStart + elif opcode == 90: + pass # fixLocAnimAfterLocChange = true + elif opcode == 91: + read_u8(buf) # bgsoundDropoffEasing + elif opcode == 92: + # transforms with default: u16 varbit, u16 varp, u16 default, u8 count, u16[count+1] ids + read_u16(buf) # varbitID + read_u16(buf) # varpID + default_val = read_u16(buf) # default transform + count = read_u8(buf) + for _ in range(count + 1): + read_u16(buf) + elif opcode == 93: + # bgsoundFade: u8 curve1, u16 duration1, u8 curve2, u16 duration2 + read_u8(buf) + read_u16(buf) + read_u8(buf) + read_u16(buf) + elif opcode == 94: + pass # unknown94 = true + elif opcode == 95: + read_u8(buf) # crossWorldSound + elif opcode == 96: + read_u8(buf) # thickness/raise + elif opcode == 249: + # params map: u8 count, then (u8 is_string, u24 key, string|i32 value) + count = read_u8(buf) + for _ in range(count): + is_string = read_u8(buf) + # key is 3 bytes (u24) + buf.read(3) + if is_string == 1: + _read_modern_obj_string(buf) + else: + buf.read(4) # i32 value + else: + # unknown opcode — we can't safely skip, so stop parsing this def + print( + f" warning: unknown obj opcode {opcode} at pos {buf.tell()} " + f"for obj {obj_id}, stopping parse", + file=sys.stderr, + ) + break + + return d + + +def decode_modern_obj_defs(reader: ModernCacheReader) -> dict[int, ModernObjDef]: + """Decode all object definitions from modern cache (index 2, group 6).""" + files = reader.read_group(2, MODERN_OBJ_CONFIG_GROUP) + defs: dict[int, ModernObjDef] = {} + + for obj_id, data in files.items(): + d = decode_modern_obj_def(obj_id, data) + defs[obj_id] = d + + return defs + + +# --- modern map data parsing --- + + +def _read_extended_smart(buf: io.BytesIO) -> int: + """Read extended smart: chains multiple read_smart calls for values > 32767. + + Modern OSRS map data uses this for object ID deltas to support IDs > 32767. + If a smart value is exactly 32767, accumulate and read the next smart. + """ + total = 0 + val = read_smart(buf) + while val == 32767: + total += 32767 + val = read_smart(buf) + return total + val + + +def parse_objects_modern( + data: bytes, + flags: CollisionFlags, + down_heights: set[tuple[int, int, int]], + obj_defs: dict[int, ModernObjDef], +) -> int: + """Parse modern-format object data and mark collision flags. + + Uses extended smart for object ID deltas (chains read_smart for IDs > 32767). + Position deltas still use regular read_smart (max position < 16384). + Returns count of collision-marked objects for diagnostics. + """ + buf = io.BytesIO(data) + obj_id = -1 + marked_count = 0 + + while True: + obj_id_offset = _read_extended_smart(buf) + if obj_id_offset == 0: + break + + obj_id += obj_id_offset + obj_pos_info = 0 + + while True: + pos_offset = read_smart(buf) + if pos_offset == 0: + break + obj_pos_info += pos_offset - 1 + + raw_byte = buf.read(1) + if not raw_byte: + return marked_count + obj_other_info = raw_byte[0] + + local_y = obj_pos_info & 0x3F + local_x = (obj_pos_info >> 6) & 0x3F + height = (obj_pos_info >> 12) & 0x3 + + obj_type = obj_other_info >> 2 + direction = obj_other_info & 0x3 + + # downHeights adjustment + if (local_x, local_y, 1) in down_heights: + height -= 1 + if height < 0: + continue + elif (local_x, local_y, height) in down_heights: + height -= 1 + + if height < 0: + continue + + d = obj_defs.get(obj_id) + if d is None: + continue + + if not d.solid: + continue + + # swap width/length for N/S rotation + if direction == DIR_NORTH or direction == DIR_SOUTH: + size_x = d.length + size_y = d.width + else: + size_x = d.width + size_y = d.length + + if obj_type == OBJ_GROUND_PROP: + if d.has_actions: + mark_occupant(flags, height, local_x, local_y, size_x, size_y, False) + marked_count += 1 + elif obj_type in (OBJ_GENERAL_PROP, OBJ_WALKABLE_PROP) or obj_type >= 12: + mark_occupant( + flags, height, local_x, local_y, size_x, size_y, d.impenetrable + ) + marked_count += 1 + elif obj_type == OBJ_DIAGONAL_WALL: + mark_occupant( + flags, height, local_x, local_y, size_x, size_y, d.impenetrable + ) + marked_count += 1 + elif 0 <= obj_type <= 3: + mark_wall( + flags, direction, height, local_x, local_y, obj_type, d.impenetrable + ) + marked_count += 1 + + return marked_count + + +# --- map group lookup --- + + +def find_map_groups( + reader: ModernCacheReader, +) -> dict[int, tuple[int | None, int | None]]: + """Build mapping of mapsquare -> (terrain_group_id, object_group_id). + + Scans the index 5 manifest for groups whose djb2 name hashes match + m{rx}_{ry} (terrain) or l{rx}_{ry} (objects) patterns. + + Returns dict mapping mapsquare (rx<<8|ry) to (terrain_gid, obj_gid). + """ + manifest = reader.read_index_manifest(5) + + # build reverse lookup: name_hash -> group_id + hash_to_gid: dict[int, int] = {} + for gid in manifest.group_ids: + nh = manifest.group_name_hashes.get(gid) + if nh is not None: + hash_to_gid[nh] = gid + + # try all plausible region coordinates + result: dict[int, tuple[int | None, int | None]] = {} + for rx in range(256): + for ry in range(256): + terrain_hash = djb2(f"m{rx}_{ry}") + obj_hash = djb2(f"l{rx}_{ry}") + + terrain_gid = hash_to_gid.get(terrain_hash) + obj_gid = hash_to_gid.get(obj_hash) + + if terrain_gid is not None or obj_gid is not None: + mapsquare = (rx << 8) | ry + result[mapsquare] = (terrain_gid, obj_gid) + + return result + + +def load_xtea_keys(keys_path: Path) -> dict[int, list[int]]: + """Load XTEA keys from OpenRS2 JSON export. + + Returns dict mapping mapsquare -> [k0, k1, k2, k3]. + """ + with open(keys_path) as f: + keys_data = json.load(f) + + keys: dict[int, list[int]] = {} + for entry in keys_data: + ms = entry["mapsquare"] + keys[ms] = entry["key"] + + return keys + + +# --- main --- + + +def main() -> None: + """Export collision maps from modern OSRS cache.""" + parser = argparse.ArgumentParser( + description="export collision data from modern OpenRS2 OSRS cache" + ) + parser.add_argument( + "--cache", + type=Path, + default=Path("../reference/osrs-cache-modern"), + help="path to modern cache directory", + ) + parser.add_argument( + "--keys", + type=Path, + default=Path("../reference/osrs-cache-modern/keys.json"), + help="path to XTEA keys JSON from OpenRS2", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("data/zulrah.cmap"), + help="output .cmap binary file", + ) + parser.add_argument( + "--regions", + nargs="+", + type=str, + help="region coordinates as rx,ry pairs (e.g. 35,48 34,48)", + ) + parser.add_argument( + "--wilderness", + action="store_true", + help="export wilderness regions (rx=44-56, ry=48-62)", + ) + parser.add_argument( + "--all-regions", + action="store_true", + help="export all available regions", + ) + parser.add_argument( + "--ascii", + action="store_true", + help="print ASCII visualization of collision map", + ) + args = parser.parse_args() + + if not args.cache.exists(): + sys.exit(f"cache directory not found: {args.cache}") + if not args.keys.exists(): + sys.exit(f"XTEA keys file not found: {args.keys}") + + args.output.parent.mkdir(parents=True, exist_ok=True) + + print(f"reading modern cache from {args.cache}") + reader = ModernCacheReader(args.cache) + + print("loading XTEA keys...") + xtea_keys = load_xtea_keys(args.keys) + print(f" {len(xtea_keys)} region keys loaded") + + print("loading modern object definitions...") + obj_defs = decode_modern_obj_defs(reader) + print(f" {len(obj_defs)} object definitions parsed") + + print("scanning index 5 for map groups...") + map_groups = find_map_groups(reader) + print(f" {len(map_groups)} regions found in index 5") + + # determine which regions to export + target_mapsquares: set[int] = set() + + if args.regions: + for coord in args.regions: + parts = coord.split(",") + rx, ry = int(parts[0]), int(parts[1]) + ms = (rx << 8) | ry + target_mapsquares.add(ms) + elif args.wilderness: + for rx in range(44, 57): + for ry in range(48, 63): + ms = (rx << 8) | ry + if ms in map_groups: + target_mapsquares.add(ms) + elif args.all_regions: + target_mapsquares = set(map_groups.keys()) + else: + # default: Zulrah region + target_mapsquares.add((35 << 8) | 48) + + print(f"\nexporting {len(target_mapsquares)} regions...") + + output_regions: dict[int, CollisionFlags] = {} + decoded = 0 + errors = 0 + total_obj_marked = 0 + + for ms in sorted(target_mapsquares): + rx = (ms >> 8) & 0xFF + ry = ms & 0xFF + + if ms not in map_groups: + print(f" region ({rx},{ry}): not found in index 5") + errors += 1 + continue + + terrain_gid, obj_gid = map_groups[ms] + + # parse terrain + if terrain_gid is None: + print(f" region ({rx},{ry}): no terrain group") + errors += 1 + continue + + terrain_data = reader.read_container(5, terrain_gid) + if terrain_data is None: + print(f" region ({rx},{ry}): failed to read terrain") + errors += 1 + continue + + flags, down_heights = parse_terrain(terrain_data) + + # parse objects (XTEA encrypted) + obj_marked = 0 + if obj_gid is not None: + key = xtea_keys.get(ms) + if key is None: + print(f" region ({rx},{ry}): no XTEA key, skipping objects") + else: + raw_obj = reader._read_raw(5, obj_gid) + if raw_obj is not None and len(raw_obj) >= 5: + # container: compression(1) + compressed_len(4) + encrypted_payload + # XTEA starts at byte 5 (decompressed_len is also encrypted) + compression = raw_obj[0] + compressed_len = struct.unpack(">I", raw_obj[1:5])[0] + decrypted = xtea_decrypt(raw_obj[5:], key) + + if compression == 0: + obj_data = decrypted[:compressed_len] + else: + # decrypted[0:4] = decompressed_len, decrypted[4:] = compressed data + gzip_data = decrypted[4 : 4 + compressed_len] + if compression == 2: + # gzip — use raw inflate to avoid CRC issues from XTEA padding + obj_data = zlib.decompress(gzip_data[10:], -zlib.MAX_WBITS) + elif compression == 1: + # bzip2 — strip 'BZ' header + obj_data = bz2.decompress(b"BZh1" + gzip_data) + + obj_marked = parse_objects_modern( + obj_data, flags, down_heights, obj_defs + ) + total_obj_marked += obj_marked + + output_regions[ms] = flags + decoded += 1 + + # per-region stats + blocked = sum( + 1 + for x in range(64) + for y in range(64) + if flags[0][x][y] & BLOCKED + ) + walled = sum( + 1 + for x in range(64) + for y in range(64) + if flags[0][x][y] & (WALL_NORTH | WALL_SOUTH | WALL_EAST | WALL_WEST) + ) + print( + f" region ({rx},{ry}): " + f"{blocked} blocked, {walled} walled, {obj_marked} objects marked" + ) + + print(f"\ndecoded {decoded} regions, {errors} skipped") + print(f"total objects marked for collision: {total_obj_marked}") + + # overall stats + total_blocked = 0 + total_walled = 0 + for region_flags in output_regions.values(): + for x in range(64): + for y in range(64): + f = region_flags[0][x][y] + if f & BLOCKED: + total_blocked += 1 + if f & (WALL_NORTH | WALL_SOUTH | WALL_EAST | WALL_WEST): + total_walled += 1 + + print(f"height-0 totals: {total_blocked} blocked, {total_walled} walled") + + # ASCII visualization + if args.ascii and len(output_regions) == 1: + ms = next(iter(output_regions)) + rx = (ms >> 8) & 0xFF + ry = ms & 0xFF + region_flags = output_regions[ms] + + print(f"\n--- collision map for region ({rx},{ry}) height 0 ---") + print(" legend: . = walkable, # = blocked (terrain), W = walled (object)") + print(" B = blocked (object), X = blocked + walled") + print() + + for local_y in range(63, -1, -1): + row = [] + for local_x in range(64): + f = region_flags[0][local_x][local_y] + has_block = bool(f & BLOCKED) + has_wall = bool( + f & (WALL_NORTH | WALL_SOUTH | WALL_EAST | WALL_WEST) + ) + has_imp_block = bool(f & IMPENETRABLE_BLOCKED) + + if has_block and has_wall: + row.append("X") + elif has_wall: + row.append("W") + elif has_imp_block: + row.append("B") + elif has_block: + row.append("#") + else: + row.append(".") + print("".join(row)) + + write_cmap(args.output, output_regions) + file_size = args.output.stat().st_size + print(f"\nwrote {file_size:,} bytes to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_inferno_npcs.py b/ocean/osrs/scripts/export_inferno_npcs.py new file mode 100644 index 0000000000..049dcef55c --- /dev/null +++ b/ocean/osrs/scripts/export_inferno_npcs.py @@ -0,0 +1,862 @@ +"""Export inferno NPC models, animations, and spotanim GFX from modern OSRS cache. + +Reads NPC definitions for all inferno monsters (nibblers through Zuk), extracts +their model IDs and animation sequence IDs, exports 3D meshes to .models binary, +exports animations to .anims binary, and updates npc_models.h with mappings. + +Also reads SpotAnim (GFX) configs for inferno projectiles. + +Usage: + uv run python scripts/export_inferno_npcs.py \ + --modern-cache /path/to/osrs-cache-modern \ + --output-dir data +""" + +import argparse +import copy +import io +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from modern_cache_reader import ( + ModernCacheReader, + read_big_smart, + read_i32, + read_string, + read_u8, + read_u16, + read_u24, + read_u32, +) +from export_models import ( + MDL2_MAGIC, + ModelData, + _merge_models, + decode_model, + expand_model, + load_model_modern, + write_models_binary, +) +from export_animations import ( + ANIM_MAGIC, + FrameBaseDef, + FrameDef, + SequenceDef, + _parse_normal_frame, + load_modern_framebases, + parse_modern_framebase, + write_animations_binary, +) +from modern_cache_reader import parse_sequence as parse_modern_sequence + +# modern cache layout +MODERN_NPC_CONFIG_GROUP = 9 # config index 2, group 9 = NPC definitions +MODERN_SPOTANIM_CONFIG_GROUP = 13 # config index 2, group 13 = SpotAnim/GFX +MODERN_SEQ_CONFIG_GROUP = 12 # config index 2, group 12 = sequences +MODERN_FRAME_INDEX = 0 # frame archives +MODERN_FRAMEBASE_INDEX = 1 # frame bases + +# inferno NPC IDs from the OSRS wiki +INFERNO_NPC_IDS = { + 7691: "Jal-Nib (nibbler)", + 7692: "Jal-MejRah (bat)", + 7693: "Jal-Ak (blob)", + 7694: "Jal-Ak-Rek-Ket (blob melee split)", + 7695: "Jal-Ak-Rek-Xil (blob range split)", + 7696: "Jal-Ak-Rek-Mej (blob mage split)", + 7697: "Jal-ImKot (meleer)", + 7698: "Jal-Xil (ranger)", + 7699: "Jal-Zek (mager)", + 7700: "JalTok-Jad", + 7701: "Yt-HurKot (jad healer)", + 7706: "TzKal-Zuk", + 7707: "Zuk shield", + 7708: "Jal-MejJak (zuk healer)", +} + +# attack anims are NOT in cache NPC config — they come from CombatAnimationData +# which is a separate client table. hardcoded from wiki/runelite/deob client. +INFERNO_ATTACK_ANIMS: dict[int, int] = { + 7691: 7574, # nibbler + 7692: 7578, # bat + 7693: 7581, # blob + 7694: 65535, # blob melee split (no attack anim) + 7695: 65535, # blob range split (no attack anim) + 7696: 65535, # blob mage split (no attack anim) + 7697: 7597, # meleer + 7698: 7605, # ranger + 7699: 7610, # mager + 7700: 7593, # jad + 7701: 65535, # healer jad (no attack anim) + 7706: 7566, # zuk + 7707: 65535, # zuk shield (no attack anim) + 7708: 65535, # zuk healer (no attack anim) +} + +# known inferno projectile/effect GFX IDs to check +# from OSRS wiki inferno page and runelite inferno plugin +INFERNO_SPOTANIM_IDS = { + # jad attacks + 447: "Jad ranged projectile (fireball)", + 448: "Jad magic projectile", + 451: "Jad ranged hit", + 157: "Jad magic hit", + # mager + 1379: "Mager magic projectile", + 1380: "Mager magic hit", + # ranger + 1377: "Ranger ranged projectile", + 1378: "Ranger ranged hit", + # zuk + 1375: "Zuk magic projectile", + 1376: "Zuk ranged projectile", + 1381: "Zuk typeless hit (falling rocks?)", + # bat + 1374: "Bat ranged projectile", + # blob + 1382: "Blob melee", + 1383: "Blob ranged", + 1384: "Blob magic", + # healer + 1385: "Healer magic attack", + # player projectiles (needed for tbow in inferno) + 942: "Dragon arrow projectile (twisted bow)", +} + + +@dataclass +class NpcDef: + """NPC definition from modern OSRS cache.""" + + npc_id: int = 0 + name: str = "" + model_ids: list[int] = field(default_factory=list) + chathead_model_ids: list[int] = field(default_factory=list) + size: int = 1 + idle_anim: int = -1 + walk_anim: int = -1 + run_anim: int = -1 + turn_180_anim: int = -1 + turn_cw_anim: int = -1 + turn_ccw_anim: int = -1 + attack_anim: int = -1 # from wiki/runelite, not in def directly + death_anim: int = -1 # from wiki/runelite, not in def directly + combat_level: int = 0 + width_scale: int = 128 + height_scale: int = 128 + recolor_src: list[int] = field(default_factory=list) + recolor_dst: list[int] = field(default_factory=list) + retexture_src: list[int] = field(default_factory=list) + retexture_dst: list[int] = field(default_factory=list) + + +@dataclass +class SpotAnimDef: + """SpotAnim (GFX) definition from modern OSRS cache.""" + + id: int = 0 + model_id: int = -1 + seq_id: int = -1 + recolor_src: list[int] = field(default_factory=list) + recolor_dst: list[int] = field(default_factory=list) + width_scale: int = 128 + height_scale: int = 128 + rotation: int = 0 + ambient: int = 0 + contrast: int = 0 + + +def parse_modern_npc_def(npc_id: int, data: bytes) -> NpcDef: + """Parse modern OSRS NPC definition from opcode stream. + + Opcode reference from RuneLite NpcLoader (modern revisions): + 1: model IDs (u8 count, u16[count]) + 2: name (string) + 12: size (u8) + 13: idle animation (u16) + 14: walk animation (u16) + 15: turn 180 animation (u16) + 16: turn CW animation (u16, modern split from old 17) + 17: turn CCW animation (u16) + 18: unused / walk backward (u16) + 19: unused (u8 from modern, or actions in old) + 30-34: actions (string each) + 40: recolor pairs (u8 count, u16+u16 per pair) + 41: retexture pairs (u8 count, u16+u16 per pair) + 60: chathead model IDs (u8 count, u16[count]) + 93: drawMapDot = false (flag) + 95: combat level (u16) + 97: width scale (u16) + 98: height scale (u16) + 99: hasRenderPriority (flag) + 100: ambient (u8) + 101: contrast (u8) + 102: head icon (bitfield + smart pairs) + 103: rotation (u16) + 106: morph (varbit+varp+count+children) + 107: isInteractable = false (flag) + 108: isPet = false (modern) + 109: isClickable = false (flag) + 111: isFollower (flag) + 114-118: various transform/morph opcodes + 249: params map + """ + d = NpcDef(npc_id=npc_id) + buf = io.BytesIO(data) + + while True: + opcode_byte = buf.read(1) + if not opcode_byte: + break + opcode = opcode_byte[0] + + if opcode == 0: + break + elif opcode == 1: + count = read_u8(buf) + d.model_ids = [read_u16(buf) for _ in range(count)] + elif opcode == 2: + d.name = read_string(buf) + elif opcode == 3: + read_string(buf) # description (removed in modern, but handle gracefully) + elif opcode == 5: + # pre-modern: another model list? skip + count = read_u8(buf) + for _ in range(count): + read_u16(buf) + elif opcode == 12: + d.size = read_u8(buf) + elif opcode == 13: + d.idle_anim = read_u16(buf) + elif opcode == 14: + d.walk_anim = read_u16(buf) + elif opcode == 15: + d.turn_180_anim = read_u16(buf) # idleRotateLeftAnimation + elif opcode == 16: + d.turn_cw_anim = read_u16(buf) # idleRotateRightAnimation + elif opcode == 17: + # walk + rotate180 + rotateLeft + rotateRight (4 x u16) + d.walk_anim = read_u16(buf) + d.turn_180_anim = read_u16(buf) + d.turn_cw_anim = read_u16(buf) + d.turn_ccw_anim = read_u16(buf) + elif opcode == 18: + read_u16(buf) # category + elif 30 <= opcode <= 34: + read_string(buf) # actions[0..4] + elif opcode == 40: + count = read_u8(buf) + for _ in range(count): + d.recolor_src.append(read_u16(buf)) + d.recolor_dst.append(read_u16(buf)) + elif opcode == 41: + count = read_u8(buf) + for _ in range(count): + d.retexture_src.append(read_u16(buf)) + d.retexture_dst.append(read_u16(buf)) + elif opcode == 60: + count = read_u8(buf) + d.chathead_model_ids = [read_u16(buf) for _ in range(count)] + elif 74 <= opcode <= 79: + read_u16(buf) # stats[opcode - 74] (attack/def/str/range/magic/hp) + elif opcode == 93: + pass # drawMapDot = false + elif opcode == 95: + d.combat_level = read_u16(buf) + elif opcode == 97: + d.width_scale = read_u16(buf) + elif opcode == 98: + d.height_scale = read_u16(buf) + elif opcode == 99: + pass # hasRenderPriority + elif opcode == 100: + read_u8(buf) # ambient + elif opcode == 101: + read_u8(buf) # contrast + elif opcode == 102: + # head icon sprite — u8 bitfield, per set bit: BigSmart2 + UnsignedShortSmartMinusOne + bitfield = read_u8(buf) + bit_count = 0 + tmp = bitfield + while tmp != 0: + bit_count += 1 + tmp >>= 1 + for i in range(bit_count): + if bitfield & (1 << i): + # BigSmart2: if first byte < 128, read u16; else read i32 & 0x7FFFFFFF + pos = buf.tell() + peek = buf.read(1) + if peek and peek[0] < 128: + buf.seek(pos) + read_u16(buf) + else: + buf.seek(pos) + read_i32(buf) + # UnsignedShortSmartMinusOne: same as big_smart but -1 + pos2 = buf.tell() + peek2 = buf.read(1) + if peek2 and peek2[0] < 128: + buf.seek(pos2) + read_u16(buf) + else: + buf.seek(pos2) + read_i32(buf) + elif opcode == 103: + read_u16(buf) # rotation + elif opcode == 106: + # morph: u16 varbit, u16 varp, u8 length, (length+1) u16 configs + read_u16(buf) # varbitId + read_u16(buf) # varpIndex + length = read_u8(buf) + for _ in range(length + 1): + read_u16(buf) # configs + elif opcode == 107: + pass # isInteractable = false + elif opcode == 108: + pass # isPet (modern) + elif opcode == 109: + pass # isClickable = false + elif opcode == 111: + pass # isFollower + elif opcode == 114: + read_u16(buf) # runSequence + elif opcode == 115: + read_u16(buf) # runSequence + read_u16(buf) # runBackSequence + read_u16(buf) # runRightSequence + read_u16(buf) # runLeftSequence + elif opcode == 116: + read_u16(buf) # crawlSequence + elif opcode == 117: + read_u16(buf) # crawlBackSequence + read_u16(buf) # crawlRightSequence + read_u16(buf) # crawlLeftSequence + elif opcode == 118: + # morph2: u16 varbit, u16 varp, u16 default, u8 length, (length+1) u16 configs + read_u16(buf) # varbitId + read_u16(buf) # varpIndex + read_u16(buf) # default child (var) + length = read_u8(buf) + for _ in range(length + 1): + read_u16(buf) # configs + elif opcode == 122: + pass # isFollower + elif opcode == 123: + pass # lowPriorityFollowerOps + elif opcode == 124: + read_u16(buf) # height + elif opcode == 125: + read_u8(buf) # unknown + elif opcode == 126: + read_u16(buf) # footprintSize + elif opcode == 128: + read_u8(buf) # unknown + elif opcode == 129: + pass # unknown flag + elif opcode == 130: + pass # idleAnimRestart + elif opcode == 145: + pass # canHideForOverlap + elif opcode == 146: + read_u16(buf) # overlapTintHSL + elif opcode == 147: + pass # zbuf = false + elif opcode == 249: + count_val = read_u8(buf) + for _ in range(count_val): + is_string = read_u8(buf) + read_u24(buf) # key (medium) + if is_string: + read_string(buf) + else: + read_u32(buf) + else: + print(f" warning: unknown npc opcode {opcode} at npc {npc_id}, pos {buf.tell()}", file=sys.stderr) + break + + return d + + +def parse_modern_spotanim(spotanim_id: int, data: bytes) -> SpotAnimDef: + """Parse modern SpotAnim/GFX definition from opcode stream. + + Opcode reference from RuneLite SpotAnimLoader: + 1: model ID (u16) + 2: sequence ID (u16) + 4: width scale (u16) + 5: height scale (u16) + 6: rotation (u16) + 7: ambient (u8) + 8: contrast (u8) + 40: recolor pairs (u8 count, u16+u16) + 41: retexture pairs (u8 count, u16+u16) + """ + d = SpotAnimDef(id=spotanim_id) + buf = io.BytesIO(data) + + while True: + opcode_byte = buf.read(1) + if not opcode_byte: + break + opcode = opcode_byte[0] + + if opcode == 0: + break + elif opcode == 1: + d.model_id = read_u16(buf) + elif opcode == 2: + d.seq_id = read_u16(buf) + elif opcode == 4: + d.width_scale = read_u16(buf) + elif opcode == 5: + d.height_scale = read_u16(buf) + elif opcode == 6: + d.rotation = read_u16(buf) + elif opcode == 7: + d.ambient = read_u8(buf) + elif opcode == 8: + d.contrast = read_u8(buf) + elif opcode == 40: + count = read_u8(buf) + for _ in range(count): + d.recolor_src.append(read_u16(buf)) + d.recolor_dst.append(read_u16(buf)) + elif opcode == 41: + count = read_u8(buf) + for _ in range(count): + read_u16(buf) + read_u16(buf) + else: + print(f" warning: unknown spotanim opcode {opcode} at gfx {spotanim_id}", file=sys.stderr) + break + + return d + + +def apply_recolors(md: ModelData, src: list[int], dst: list[int]) -> None: + """Apply recolor pairs to model face colors in-place.""" + for i, color in enumerate(md.face_colors): + for s, d in zip(src, dst): + if color == s: + md.face_colors[i] = d + break + + +def apply_scale(md: ModelData, width_scale: int, height_scale: int) -> None: + """Apply NPC width/height scale to vertex positions in-place.""" + if width_scale == 128 and height_scale == 128: + return + ws = width_scale / 128.0 + hs = height_scale / 128.0 + for i in range(md.vertex_count): + md.vertices_x[i] = int(md.vertices_x[i] * ws) + md.vertices_y[i] = int(md.vertices_y[i] * hs) + md.vertices_z[i] = int(md.vertices_z[i] * ws) + + +def main() -> None: + """Export inferno NPC data from modern OSRS cache.""" + parser = argparse.ArgumentParser(description="export inferno NPC models + animations from modern cache") + parser.add_argument( + "--modern-cache", type=Path, required=True, + help="path to modern OpenRS2 flat-file cache", + ) + parser.add_argument( + "--output-dir", type=Path, default=Path("data"), + help="output directory for generated files", + ) + args = parser.parse_args() + + reader = ModernCacheReader(args.modern_cache) + output_dir = args.output_dir + output_dir.mkdir(parents=True, exist_ok=True) + + # ================================================================ + # step 1: read NPC definitions from config index 2, group 9 + # ================================================================ + print("reading NPC definitions from modern cache (index 2, group 9)...") + npc_files = reader.read_group(2, MODERN_NPC_CONFIG_GROUP) + print(f" {len(npc_files)} total NPC entries in group 9") + + npc_defs: dict[int, NpcDef] = {} + all_model_ids: set[int] = set() + all_anim_ids: set[int] = set() + + for npc_id, label in sorted(INFERNO_NPC_IDS.items()): + if npc_id not in npc_files: + print(f" NPC {npc_id} ({label}): NOT FOUND in cache") + continue + + npc = parse_modern_npc_def(npc_id, npc_files[npc_id]) + npc_defs[npc_id] = npc + + print(f"\n NPC {npc_id} ({label}):") + print(f" name: {npc.name}") + print(f" models: {npc.model_ids}") + print(f" size: {npc.size}") + print(f" idle_anim: {npc.idle_anim}") + print(f" walk_anim: {npc.walk_anim}") + print(f" scale: {npc.width_scale}x{npc.height_scale}") + if npc.recolor_src: + print(f" recolors: {list(zip(npc.recolor_src, npc.recolor_dst))}") + if npc.retexture_src: + print(f" retextures: {list(zip(npc.retexture_src, npc.retexture_dst))}") + + all_model_ids.update(npc.model_ids) + for anim_id in [npc.idle_anim, npc.walk_anim, npc.turn_180_anim, npc.turn_cw_anim, npc.turn_ccw_anim]: + if anim_id >= 0: + all_anim_ids.add(anim_id) + + # ================================================================ + # step 2: read SpotAnim/GFX definitions + # ================================================================ + print("\n\nreading SpotAnim/GFX definitions (index 2, group 13)...") + spotanim_files = reader.read_group(2, MODERN_SPOTANIM_CONFIG_GROUP) + print(f" {len(spotanim_files)} total spotanim entries") + + spotanim_defs: dict[int, SpotAnimDef] = {} + for gfx_id, label in sorted(INFERNO_SPOTANIM_IDS.items()): + if gfx_id not in spotanim_files: + print(f" GFX {gfx_id} ({label}): NOT FOUND in cache") + continue + + sa = parse_modern_spotanim(gfx_id, spotanim_files[gfx_id]) + spotanim_defs[gfx_id] = sa + + print(f" GFX {gfx_id} ({label}): model={sa.model_id}, seq={sa.seq_id}, " + f"scale={sa.width_scale}x{sa.height_scale}") + + if sa.model_id >= 0: + all_model_ids.add(sa.model_id) + if sa.seq_id >= 0: + all_anim_ids.add(sa.seq_id) + + print(f"\ntotal unique model IDs to export: {len(all_model_ids)}") + print(f" {sorted(all_model_ids)}") + print(f"total unique animation IDs to export: {len(all_anim_ids)}") + print(f" {sorted(all_anim_ids)}") + + # ================================================================ + # step 3: export NPC models + # ================================================================ + print("\n\nexporting NPC + GFX models...") + all_models: list[ModelData] = [] + + # for each NPC, merge sub-models, apply recolors/scale + for npc_id, npc in sorted(npc_defs.items()): + sub_models: list[ModelData] = [] + for mid in npc.model_ids: + raw = load_model_modern(reader, mid) + if raw is None: + print(f" warning: model {mid} not found for NPC {npc_id}") + continue + md = decode_model(mid, raw) + if md is None: + print(f" warning: failed to decode model {mid} for NPC {npc_id}") + continue + sub_models.append(md) + + if not sub_models: + print(f" NPC {npc_id}: no models decoded") + continue + + if len(sub_models) == 1: + merged = sub_models[0] + else: + merged = _merge_models(sub_models) + + # apply recolors + if npc.recolor_src: + apply_recolors(merged, npc.recolor_src, npc.recolor_dst) + + # apply scale + apply_scale(merged, npc.width_scale, npc.height_scale) + + # use NPC ID as model ID for lookup (synthetic: 0xC0000 + npc_id) + merged.model_id = 0xC0000 + npc_id + all_models.append(merged) + print(f" NPC {npc_id} ({npc.name}): {merged.vertex_count} verts, {merged.face_count} faces") + + # export GFX projectile models, applying spotanim recolors where needed. + # recolored models get synthetic IDs (0xD0000 | gfx_id) so the cache binary + # can hold both the raw and recolored variants of the same base model. + exported_gfx_models: set[int] = set() + for gfx_id, sa in sorted(spotanim_defs.items()): + if sa.model_id < 0: + continue + raw = load_model_modern(reader, sa.model_id) + if raw is None: + print(f" warning: GFX {gfx_id} model {sa.model_id} not found") + continue + md = decode_model(sa.model_id, raw) + if md is None: + print(f" warning: failed to decode GFX {gfx_id} model {sa.model_id}") + continue + if sa.recolor_src: + apply_recolors(md, sa.recolor_src, sa.recolor_dst) + md.model_id = 0xD0000 | gfx_id + print(f" GFX {gfx_id} model {sa.model_id} -> 0x{md.model_id:X} (recolored): {md.vertex_count} verts") + else: + if sa.model_id in exported_gfx_models: + continue # already exported this raw model + print(f" GFX {gfx_id} model {sa.model_id}: {md.vertex_count} verts") + exported_gfx_models.add(md.model_id) + all_models.append(md) + + # write models binary + models_path = output_dir / "inferno_npcs.models" + write_models_binary(models_path, all_models) + file_size = models_path.stat().st_size + print(f"\nwrote {len(all_models)} models ({file_size:,} bytes) to {models_path}") + + # ================================================================ + # step 4: export animations + # ================================================================ + print("\n\nexporting animations...") + seq_files = reader.read_group(2, MODERN_SEQ_CONFIG_GROUP) + + sequences: dict[int, SequenceDef] = {} + for seq_id in sorted(all_anim_ids): + if seq_id not in seq_files: + print(f" warning: sequence {seq_id} not found in cache") + continue + modern_seq = parse_modern_sequence(seq_id, seq_files[seq_id]) + seq = SequenceDef( + seq_id=modern_seq.seq_id, + frame_count=modern_seq.frame_count, + frame_delays=modern_seq.frame_delays, + primary_frame_ids=modern_seq.primary_frame_ids, + frame_step=modern_seq.frame_step, + interleave_order=modern_seq.interleave_order, + priority=modern_seq.forced_priority, + loop_count=modern_seq.max_loops, + walk_flag=modern_seq.priority, + run_flag=modern_seq.precedence_animating, + ) + sequences[seq_id] = seq + print(f" seq {seq_id}: {seq.frame_count} frames, delays={seq.frame_delays[:5]}{'...' if len(seq.frame_delays) > 5 else ''}") + + # collect needed frame groups + needed_groups: set[int] = set() + for seq_id in all_anim_ids & set(sequences.keys()): + seq = sequences[seq_id] + for fid in seq.primary_frame_ids: + if fid != -1: + needed_groups.add(fid >> 16) + + print(f" loading {len(needed_groups)} frame archives...") + + # first pass: discover framebase IDs from frame data headers + needed_base_ids: set[int] = set() + raw_frame_data: dict[int, dict[int, bytes]] = {} + for group_id in sorted(needed_groups): + try: + files = reader.read_group(MODERN_FRAME_INDEX, group_id) + except (KeyError, FileNotFoundError): + print(f" warning: frame archive {group_id} not found") + continue + raw_frame_data[group_id] = files + for file_data in files.values(): + if len(file_data) >= 2: + fb_id = (file_data[0] << 8) | file_data[1] + needed_base_ids.add(fb_id) + + print(f" loading {len(needed_base_ids)} framebases...") + framebases = load_modern_framebases(reader, needed_base_ids) + print(f" loaded {len(framebases)} framebases") + + # second pass: parse frames + all_frames: dict[int, dict[int, FrameDef]] = {} + for group_id, files in raw_frame_data.items(): + frames: dict[int, FrameDef] = {} + for file_id, file_data in files.items(): + if len(file_data) < 3: + continue + frame = _parse_normal_frame(group_id, file_id, file_data, framebases) + if frame is not None: + frames[file_id] = frame + if frames: + all_frames[group_id] = frames + + total_frames = sum(len(v) for v in all_frames.values()) + print(f" {len(all_frames)} frame archives, {total_frames} total frames") + + # write animations binary + anims_path = output_dir / "inferno_npcs.anims" + available_seqs = all_anim_ids & set(sequences.keys()) + write_animations_binary(anims_path, framebases, all_frames, sequences, available_seqs) + + # ================================================================ + # step 5: update npc_models.h + # ================================================================ + print("\n\nupdating npc_models.h...") + header_path = output_dir / "npc_models.h" + + # build NPC model mapping entries + npc_entries = [] + for npc_id, npc in sorted(npc_defs.items()): + synth_model = 0xC0000 + npc_id + idle = npc.idle_anim if npc.idle_anim >= 0 else 0xFFFF + attack = INFERNO_ATTACK_ANIMS.get(npc_id, 0xFFFF) + walk = npc.walk_anim if npc.walk_anim >= 0 else 0xFFFF + label = INFERNO_NPC_IDS.get(npc_id, npc.name) + npc_entries.append((npc_id, synth_model, idle, attack, walk, label)) + + # build spotanim entries for C header. + # recolored spotanims get synthetic model IDs (0xD0000 | gfx_id) so the + # recolored variant is distinct from the raw model in the binary cache. + spotanim_entries = [] + for gfx_id, sa in sorted(spotanim_defs.items()): + if sa.model_id >= 0: + label = INFERNO_SPOTANIM_IDS.get(gfx_id, "unknown") + if sa.recolor_src: + emit_model_id = 0xD0000 | gfx_id + else: + emit_model_id = sa.model_id + spotanim_entries.append((gfx_id, emit_model_id, sa.seq_id, label)) + + # write C header + with open(header_path, "w") as f: + f.write("/**\n") + f.write(" * @fileoverview NPC model/animation mappings for encounter rendering.\n") + f.write(" *\n") + f.write(" * Maps NPC definition IDs to cache model IDs and animation sequence IDs.\n") + f.write(" * Generated by scripts/export_inferno_npcs.py — do not edit.\n") + f.write(" */\n\n") + f.write("#ifndef NPC_MODELS_H\n") + f.write("#define NPC_MODELS_H\n\n") + f.write("#include \n\n") + + f.write("typedef struct {\n") + f.write(" uint16_t npc_id;\n") + f.write(" uint32_t model_id;\n") + f.write(" uint32_t idle_anim;\n") + f.write(" uint32_t attack_anim;\n") + f.write(" uint32_t walk_anim; /* walk cycle animation; 65535 = use idle_anim */\n") + f.write("} NpcModelMapping;\n\n") + + # zulrah entries + f.write("/* zulrah forms + snakeling */\n") + f.write("static const NpcModelMapping NPC_MODEL_MAP_ZULRAH[] = {\n") + f.write(" {2042, 14408, 5069, 5068, 65535}, /* green zulrah (ranged) */\n") + f.write(" {2043, 14409, 5069, 5068, 65535}, /* red zulrah (melee) */\n") + f.write(" {2044, 14407, 5069, 5068, 65535}, /* blue zulrah (magic) */\n") + f.write("};\n\n") + + # snakeling defines (keep existing) + f.write("/* snakeling model + animations (NPC 2045 melee, 2046 magic — same model) */\n") + f.write("#define SNAKELING_MODEL_ID 10415\n") + f.write("#define SNAKELING_ANIM_IDLE 1721\n") + f.write("#define SNAKELING_ANIM_MELEE 140 /* NPC 2045 melee attack */\n") + f.write("#define SNAKELING_ANIM_MAGIC 185 /* NPC 2046 magic attack */\n") + f.write("#define SNAKELING_ANIM_DEATH 138 /* NPC 2045 death */\n") + f.write("#define SNAKELING_ANIM_WALK 2405 /* walk cycle */\n\n") + + # zulrah spotanim defines (keep existing) + f.write("/* zulrah spotanim (projectile/cloud) model IDs */\n") + f.write("#define GFX_RANGED_PROJ_MODEL 20390 /* GFX 1044 ranged projectile */\n") + f.write("#define GFX_CLOUD_PROJ_MODEL 11221 /* GFX 1045 cloud projectile */\n") + f.write("#define GFX_MAGIC_PROJ_MODEL 26593 /* GFX 1046 magic projectile */\n") + f.write("#define GFX_TOXIC_CLOUD_MODEL 4086 /* object 11700 */\n") + f.write("#define GFX_SNAKELING_SPAWN_MODEL 20390 /* GFX 1047 spawn orb */\n\n") + + # zulrah animation defines (keep existing) + f.write("/* zulrah animation sequence IDs */\n") + f.write("#define ZULRAH_ANIM_ATTACK 5068\n") + f.write("#define ZULRAH_ANIM_IDLE 5069\n") + f.write("#define ZULRAH_ANIM_DIVE 5072\n") + f.write("#define ZULRAH_ANIM_SURFACE 5071\n") + f.write("#define ZULRAH_ANIM_RISE 5073\n") + f.write("#define ZULRAH_ANIM_5070 5070\n") + f.write("#define ZULRAH_ANIM_5806 5806\n") + f.write("#define ZULRAH_ANIM_5807 5807\n") + f.write("#define GFX_SNAKELING_SPAWN_ANIM 5358\n\n") + + # inferno NPC model mappings + f.write("/* ================================================================ */\n") + f.write("/* inferno NPC model/animation mappings */\n") + f.write("/* ================================================================ */\n\n") + + f.write("static const NpcModelMapping NPC_MODEL_MAP_INFERNO[] = {\n") + for npc_id, synth_model, idle, attack, walk, label in npc_entries: + f.write(f" {{{npc_id}, 0x{synth_model:X}, {idle}, {attack}, {walk}}}, /* {label} */\n") + f.write("};\n\n") + + # inferno NPC defines for walk anims and other useful data + f.write("/* inferno NPC walk animation IDs */\n") + for npc_id, npc in sorted(npc_defs.items()): + safe_name = INFERNO_NPC_IDS[npc_id].split("(")[1].rstrip(")") if "(" in INFERNO_NPC_IDS[npc_id] else INFERNO_NPC_IDS[npc_id] + safe_name = safe_name.replace(" ", "_").replace("-", "_").upper() + if npc.walk_anim >= 0: + f.write(f"#define INF_WALK_ANIM_{safe_name} {npc.walk_anim}\n") + f.write("\n") + + # inferno spotanim/GFX defines + f.write("/* inferno spotanim (projectile/effect) model + animation IDs */\n") + for gfx_id, model_id, seq_id, label in spotanim_entries: + safe_label = label.replace(" ", "_").replace("(", "").replace(")", "").replace("?", "").upper() + f.write(f"#define INF_GFX_{gfx_id}_MODEL {model_id} /* {label} */\n") + if seq_id >= 0: + f.write(f"#define INF_GFX_{gfx_id}_ANIM {seq_id}\n") + f.write("\n") + + # inferno pillar models — "Rocky support" objects 30284-30287, 4 HP levels + f.write("/* inferno pillar models — Rocky support objects 30284-30287 */\n") + f.write("#define INF_PILLAR_MODEL_100 33044 /* object 30284 — full health */\n") + f.write("#define INF_PILLAR_MODEL_75 33043 /* object 30285 — 75% HP */\n") + f.write("#define INF_PILLAR_MODEL_50 33042 /* object 30286 — 50% HP */\n") + f.write("#define INF_PILLAR_MODEL_25 33045 /* object 30287 — 25% HP */\n\n") + + # combined lookup function that searches both zulrah and inferno tables + f.write("static const NpcModelMapping* npc_model_lookup(uint16_t npc_id) {\n") + f.write(" for (int i = 0; i < (int)(sizeof(NPC_MODEL_MAP_ZULRAH) / sizeof(NPC_MODEL_MAP_ZULRAH[0])); i++) {\n") + f.write(" if (NPC_MODEL_MAP_ZULRAH[i].npc_id == npc_id) return &NPC_MODEL_MAP_ZULRAH[i];\n") + f.write(" }\n") + f.write(" for (int i = 0; i < (int)(sizeof(NPC_MODEL_MAP_INFERNO) / sizeof(NPC_MODEL_MAP_INFERNO[0])); i++) {\n") + f.write(" if (NPC_MODEL_MAP_INFERNO[i].npc_id == npc_id) return &NPC_MODEL_MAP_INFERNO[i];\n") + f.write(" }\n") + f.write(" return NULL;\n") + f.write("}\n\n") + + f.write("#endif /* NPC_MODELS_H */\n") + + print(f"wrote {header_path}") + + # ================================================================ + # step 6: print encounter_inferno.h mapping table + # ================================================================ + print("\n\n========================================") + print("INF_NPC_DEF_IDS mapping table for encounter_inferno.h:") + print("========================================") + print("static const int INF_NPC_DEF_IDS[INF_NUM_NPC_TYPES] = {") + + inf_type_to_npc = { + "INF_NPC_NIBBLER": 7691, + "INF_NPC_BAT": 7692, + "INF_NPC_BLOB": 7693, + "INF_NPC_BLOB_MELEE": 7694, + "INF_NPC_BLOB_RANGE": 7695, + "INF_NPC_BLOB_MAGE": 7696, + "INF_NPC_MELEER": 7697, + "INF_NPC_RANGER": 7698, + "INF_NPC_MAGER": 7699, + "INF_NPC_JAD": 7700, + "INF_NPC_ZUK": 7706, + "INF_NPC_HEALER_JAD": 7701, + "INF_NPC_HEALER_ZUK": 7708, + "INF_NPC_ZUK_SHIELD": 7707, + } + for enum_name, npc_id in inf_type_to_npc.items(): + npc = npc_defs.get(npc_id) + name = npc.name if npc else "UNKNOWN" + print(f" [{enum_name}] = {npc_id}, /* {name} */") + print("};") + + print("\ndone.") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_models.py b/ocean/osrs/scripts/export_models.py new file mode 100644 index 0000000000..b9c21cb38f --- /dev/null +++ b/ocean/osrs/scripts/export_models.py @@ -0,0 +1,2152 @@ +"""Export OSRS 3D models from cache to a binary .models file. + +Supports both 317-format (tarnish) and modern OpenRS2 flat file caches. +Reads item definitions to find model IDs (inventory + male wield), then +decodes model geometry. Outputs a binary file consumable by osrs_pvp_models.h +and a generated C header mapping item IDs to model IDs. + +Three model format variants are supported: + - decodeOldFormat: 18-byte footer + - decodeType2: 23-byte footer, magic 0xFF,0xFE at end-2 + - decodeType3: 26-byte footer, magic 0xFF,0xFD at end-2 + +Usage (317 cache): + uv run python scripts/export_models.py \ + --cache ../reference/tarnish/game-server/data/cache \ + --output data/equipment.models + +Usage (modern cache): + uv run python scripts/export_models.py \ + --modern-cache ../reference/osrs-cache-modern \ + --output data/equipment.models +""" + +import argparse +import gzip +import io +import math +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# reuse cache reader from collision exporter +sys.path.insert(0, str(Path(__file__).parent)) +from export_collision_map import ( + CONFIG_INDEX, + CacheReader, + _read_string, + decode_archive, + hash_archive_name, +) +from modern_cache_reader import ModernCacheReader, decompress_container + +# --- constants --- + +CONFIG_ARCHIVE = 2 +MODEL_INDEX = 1 +MODERN_MODEL_INDEX = 7 +MODERN_CONFIG_OBJ_GROUP = 10 +MODERN_CONFIG_IDK_GROUP = 3 + + +def load_texture_average_colors(cache: CacheReader) -> dict[int, int]: + """Load per-texture averageRGB (15-bit HSL) from textures.dat. + + Mirrors TextureProvider constructor: reads textures.dat from config archive, + then for each texture reads its averageRGB (first ushort in entry data). + Used as solid-color fallback for textured model faces. + """ + raw = cache.get(CONFIG_INDEX, CONFIG_ARCHIVE) + if raw is None: + return {} + archive = decode_archive(raw) + key = hash_archive_name("textures.dat") + tex_dat = archive.get(key) + if tex_dat is None: + return {} + + buf = io.BytesIO(tex_dat) + highest_id = struct.unpack(">H", buf.read(2))[0] + result: dict[int, int] = {} + + for _ in range(highest_id + 1): + tex_id = struct.unpack(">H", buf.read(2))[0] + size = struct.unpack(">H", buf.read(2))[0] + entry_data = buf.read(size) + if len(entry_data) >= 2: + average_rgb = struct.unpack(">H", entry_data[:2])[0] + result[tex_id] = average_rgb + if tex_id >= highest_id: + break + + return result + + +@dataclass +class ItemDef: + """Minimal item definition for model extraction.""" + + item_id: int = 0 + name: str = "" + inv_model: int = -1 + male_wield: int = -1 + male_wield2: int = -1 + male_offset: int = 0 + recolor_src: list[int] = field(default_factory=list) + recolor_dst: list[int] = field(default_factory=list) + + +@dataclass +class IdentityKitDef: + """Identity kit definition — a single body part mesh for player rendering. + + Body part IDs (male): 0=head, 1=jaw/beard, 2=torso, 3=arms, 4=hands, 5=legs, 6=feet. + Female body parts are 7-13 (same order, offset by 7). + """ + + kit_id: int = 0 + body_part_id: int = -1 + body_models: list[int] = field(default_factory=list) + original_colors: list[int] = field(default_factory=lambda: [0] * 6) + replacement_colors: list[int] = field(default_factory=lambda: [0] * 6) + valid_style: bool = False + + +# default male player appearance (kit indices from Config.DEFAULT_APPEARANCE) +DEFAULT_MALE_KITS = { + 0: 0, # head (hair) + 1: 10, # jaw (beard) + 2: 18, # torso + 3: 26, # arms + 4: 34, # hands + 5: 36, # legs + 6: 42, # feet +} + +# body part name labels for C header +BODY_PART_NAMES = ["HEAD", "JAW", "TORSO", "ARMS", "HANDS", "LEGS", "FEET"] + + +def decode_identity_kits(cache: CacheReader) -> dict[int, IdentityKitDef]: + """Decode identity kit definitions from idk.dat in the config archive.""" + raw = cache.get(CONFIG_INDEX, CONFIG_ARCHIVE) + if raw is None: + sys.exit("could not read config archive") + + archive = decode_archive(raw) + idk_hash = hash_archive_name("idk.dat") & 0xFFFFFFFF + idk_data = archive.get(idk_hash) or archive.get(hash_archive_name("idk.dat")) + + if idk_data is None: + sys.exit("idk.dat not found in config archive") + + buf = io.BytesIO(idk_data) + count = struct.unpack(">H", buf.read(2))[0] + kits: dict[int, IdentityKitDef] = {} + + for kit_id in range(count): + kit = IdentityKitDef(kit_id=kit_id) + while True: + opcode = buf.read(1) + if not opcode: + break + op = opcode[0] + if op == 0: + break + elif op == 1: + kit.body_part_id = buf.read(1)[0] + elif op == 2: + n = buf.read(1)[0] + kit.body_models = [ + struct.unpack(">H", buf.read(2))[0] for _ in range(n) + ] + elif op == 3: + kit.valid_style = True + elif 40 <= op < 50: + kit.original_colors[op - 40] = struct.unpack(">H", buf.read(2))[0] + elif 50 <= op < 60: + kit.replacement_colors[op - 50] = struct.unpack(">H", buf.read(2))[0] + elif 60 <= op < 70: + buf.read(2) # head model (not needed for body rendering) + + kits[kit_id] = kit + + return kits + + +def decode_identity_kits_modern(reader: ModernCacheReader) -> dict[int, IdentityKitDef]: + """Decode identity kit definitions from modern cache config group 3. + + Same opcode format as 317: opcode 1=body_part, 2=body_models, + 3=valid_style, 40-49=recolor src, 50-59=recolor dst, 60-69=head models. + """ + manifest = reader.read_index_manifest(2) + if MODERN_CONFIG_IDK_GROUP not in manifest.group_ids: + print(" warning: identity kit config group not found in modern cache") + return {} + + file_ids = manifest.group_file_ids.get(MODERN_CONFIG_IDK_GROUP, []) + kits: dict[int, IdentityKitDef] = {} + + for kit_id in sorted(file_ids): + try: + data = reader.read_config_entry(MODERN_CONFIG_IDK_GROUP, kit_id) + except Exception: + continue + + kit = IdentityKitDef(kit_id=kit_id) + buf = io.BytesIO(data) + + while True: + opcode = buf.read(1) + if not opcode: + break + op = opcode[0] + if op == 0: + break + elif op == 1: + kit.body_part_id = buf.read(1)[0] + elif op == 2: + n = buf.read(1)[0] + kit.body_models = [ + struct.unpack(">H", buf.read(2))[0] for _ in range(n) + ] + elif op == 3: + kit.valid_style = True + elif 40 <= op < 50: + kit.original_colors[op - 40] = struct.unpack(">H", buf.read(2))[0] + elif 50 <= op < 60: + kit.replacement_colors[op - 50] = struct.unpack(">H", buf.read(2))[0] + elif 60 <= op < 70: + buf.read(2) # head model (not needed for body rendering) + + kits[kit_id] = kit + + return kits + + +def decode_item_definitions(cache: CacheReader) -> dict[int, ItemDef]: + """Decode item definitions from cache config archive (obj.dat/obj.idx).""" + raw = cache.get(CONFIG_INDEX, CONFIG_ARCHIVE) + if raw is None: + sys.exit("could not read config archive") + + archive = decode_archive(raw) + + # the archive keys are stored as uint32 from struct.unpack(">I", ...), + # but hash_archive_name returns signed int. convert to unsigned for lookup. + obj_hash = hash_archive_name("obj.dat") & 0xFFFFFFFF + idx_hash = hash_archive_name("obj.idx") & 0xFFFFFFFF + + obj_data = archive.get(obj_hash) + obj_idx = archive.get(idx_hash) + + if obj_data is None or obj_idx is None: + # try signed keys as fallback + obj_data = obj_data or archive.get(hash_archive_name("obj.dat")) + obj_idx = obj_idx or archive.get(hash_archive_name("obj.idx")) + + if obj_data is None or obj_idx is None: + print("archive keys present:", list(archive.keys()), file=sys.stderr) + sys.exit("obj.dat/obj.idx not found in config archive") + + buf = io.BytesIO(obj_data) + idx_buf = io.BytesIO(obj_idx) + + total = struct.unpack(">H", idx_buf.read(2))[0] + defs: dict[int, ItemDef] = {} + + for item_id in range(total): + d = ItemDef(item_id=item_id) + + while True: + opcode_byte = buf.read(1) + if not opcode_byte: + break + opcode = opcode_byte[0] + + if opcode == 0: + break + elif opcode == 1: + d.inv_model = struct.unpack(">H", buf.read(2))[0] + elif opcode == 2: + d.name = _read_string(buf) + elif opcode == 3: + _read_string(buf) # description + elif opcode == 4: + buf.read(2) # modelZoom + elif opcode == 5: + buf.read(2) # modelRotationY + elif opcode == 6: + buf.read(2) # modelRotationX + elif opcode == 7: + buf.read(2) # modelOffset1 + elif opcode == 8: + buf.read(2) # modelOffset2 + elif opcode == 9: + _read_string(buf) # unknown + elif opcode == 10: + buf.read(2) # unknown + elif opcode == 11: + pass # stackable + elif opcode == 12: + buf.read(4) # value (int) + elif opcode == 13: + buf.read(1) # wearPos1 + elif opcode == 14: + buf.read(1) # wearPos2 + elif opcode == 16: + pass # membersObject + elif opcode == 23: + d.male_wield = struct.unpack(">H", buf.read(2))[0] + d.male_offset = struct.unpack(">b", buf.read(1))[0] + elif opcode == 24: + d.male_wield2 = struct.unpack(">H", buf.read(2))[0] + elif opcode == 25: + buf.read(2) # femaleWield + buf.read(1) # femaleOffset + elif opcode == 26: + buf.read(2) # femaleWield2 + elif opcode == 27: + buf.read(1) # wearPos3 + elif 30 <= opcode < 35: + _read_string(buf) # groundActions + elif 35 <= opcode < 40: + _read_string(buf) # itemActions + elif opcode == 40: + count = buf.read(1)[0] + for _ in range(count): + src = struct.unpack(">H", buf.read(2))[0] + dst = struct.unpack(">H", buf.read(2))[0] + d.recolor_src.append(src) + d.recolor_dst.append(dst) + elif opcode == 41: + count = buf.read(1)[0] + for _ in range(count): + buf.read(4) # retextureFrom + retextureTo + elif opcode == 42: + buf.read(1) # shiftClickDropIndex + elif opcode == 65: + pass # isTradeable + elif opcode == 75: + buf.read(2) # weight (short) + elif opcode == 78: + buf.read(2) # maleModel2 + elif opcode == 79: + buf.read(2) # femaleModel2 + elif opcode == 90: + buf.read(2) # maleHeadModel + elif opcode == 91: + buf.read(2) # femaleHeadModel + elif opcode == 92: + buf.read(2) # maleHeadModel2 + elif opcode == 93: + buf.read(2) # femaleHeadModel2 + elif opcode == 94: + buf.read(2) # category + elif opcode == 95: + buf.read(2) # zan2d + elif opcode == 97: + buf.read(2) # certID + elif opcode == 98: + buf.read(2) # certTemplateID + elif 100 <= opcode < 110: + buf.read(4) # stackIDs + stackAmounts (2+2) + elif opcode == 110: + buf.read(2) # resizeX + elif opcode == 111: + buf.read(2) # resizeY + elif opcode == 112: + buf.read(2) # resizeZ + elif opcode == 113: + buf.read(1) # brightness + elif opcode == 114: + buf.read(1) # contrast + elif opcode == 115: + buf.read(1) # team + elif opcode == 139: + buf.read(2) # unnotedId + elif opcode == 140: + buf.read(2) # notedId + elif opcode == 148: + buf.read(2) # placeholderId + elif opcode == 149: + buf.read(2) # placeholderTemplateId + elif opcode == 249: + # params map + count = buf.read(1)[0] + for _ in range(count): + is_string = buf.read(1)[0] + buf.read(3) # key (medium) + if is_string: + _read_string(buf) + else: + buf.read(4) # int value + else: + # unknown opcode — can't safely skip + print( + f" warning: unknown item opcode {opcode} at item {item_id}, " + f"pos {buf.tell()}", + file=sys.stderr, + ) + break + + if d.inv_model >= 0 or d.name: + defs[item_id] = d + + return defs + + +def _parse_modern_item_entry(item_id: int, data: bytes) -> ItemDef: + """Parse a single modern item definition from opcode stream. + + Modern OSRS cache (rev226+) has many additional opcodes beyond the 317 set. + We handle all known opcodes from RuneLite's ItemLoader plus modern additions. + Unknown opcodes cause a break since we can't determine their byte length. + + Opcode reference (modern OSRS, compiled from RuneLite + rev226 analysis): + 0: terminator + 1: inv_model (u16) + 2: name (string) + 4-6: zoom/rotation (u16 each) + 7-8: model offsets (u16 each) + 9: unknown string (removed in modern, but some revs have it) + 10: unknown u16 + 11: stackable (flag) + 12: value (i32) + 13-14: wearPos1/2 (u8 each) + 15: isTradeable (flag, modern addition) + 16: membersObject (flag) + 17: unknown u8 (modern) + 18: unknown u8 (modern) + 19: unknown u8 (modern, very common) + 20: unknown u8 (modern) + 21: groundScaleX (u16, modern) + 22: groundScaleY (u16, modern — very common placeholder item flag?) + 23: maleWield + offset (u16 + i8) + 24: maleWield2 (u16) + 25: femaleWield + offset (u16 + i8) + 26: femaleWield2 (u16) + 27: wearPos3 (u8) + 28: unknown u8 + 29: unknown u8 (very common, modern) + 30-34: ground options (strings) + 35-39: interface options (strings) + 40: recolor pairs (u8 count, then u16+u16 per pair) + 41: retexture pairs (u8 count, then u16+u16 per pair) + 42: shiftClickDropIndex (u8) + 43: sub-operations (u8 count, then u16+u16 per entry — modern) + 62: unknown u8 (modern) + 64: unknown flag (modern) + 65: isTradeable (flag) + 69: unknown u8 (modern) + 71: unknown u16 (modern) + 75: weight (u16) + 78-79: maleModel2/femaleModel2 (u16 each) + 90-93: head models (u16 each) + 94: category (u16) + 95: zan2d (u16) + 97-98: cert references (u16 each) + 100-109: count objects (u16+u16 each) + 110-112: resize (u16 each) + 113-114: ambient/contrast (u8 each) + 115: team (u8) + 139-140: unnoted/noted references (u16 each) + 148-149: placeholder references (u16 each) + 155: unknown u8 (modern) + 156: unknown u16 (modern) + 157: unknown u8 (modern) + 158: unknown u8 (modern) + 159: unknown u8 (modern) + 160: unknown u16 (modern, u8 count then u16s — like wearPos list) + 161: unknown u16 (modern) + 162: unknown u8 (modern) + 163: unknown u8 (modern) + 164: unknown string (modern) + 165: unknown u8 (modern) + 202: unknown u16 (modern, seen on some items) + 211: unknown u8 count then u16s (modern) + 249: params map (u8 count, then key-value pairs) + """ + d = ItemDef(item_id=item_id) + buf = io.BytesIO(data) + + while True: + opcode_byte = buf.read(1) + if not opcode_byte: + break + opcode = opcode_byte[0] + + if opcode == 0: + break + elif opcode == 1: + d.inv_model = struct.unpack(">H", buf.read(2))[0] + elif opcode == 2: + d.name = _read_string(buf) + elif opcode == 3: + _read_string(buf) # description + elif opcode == 4: + buf.read(2) # modelZoom + elif opcode == 5: + buf.read(2) # modelRotationY + elif opcode == 6: + buf.read(2) # modelRotationX + elif opcode == 7: + buf.read(2) # modelOffset1 + elif opcode == 8: + buf.read(2) # modelOffset2 + elif opcode == 9: + _read_string(buf) # unknown string + elif opcode == 10: + buf.read(2) # unknown u16 + elif opcode == 11: + pass # stackable + elif opcode == 12: + buf.read(4) # value + elif opcode == 13: + buf.read(1) # wearPos1 + elif opcode == 14: + buf.read(1) # wearPos2 + elif opcode == 15: + pass # isTradeable (modern flag, 0 bytes) + elif opcode == 16: + pass # membersObject + elif opcode == 17: + buf.read(1) # unknown u8 + elif opcode == 18: + buf.read(1) # unknown u8 + elif opcode == 19: + buf.read(1) # unknown u8 (very common in modern) + elif opcode == 20: + buf.read(1) # unknown u8 + elif opcode == 21: + buf.read(2) # groundScaleX + elif opcode == 22: + buf.read(2) # groundScaleY + elif opcode == 23: + d.male_wield = struct.unpack(">H", buf.read(2))[0] + d.male_offset = struct.unpack(">b", buf.read(1))[0] + elif opcode == 24: + d.male_wield2 = struct.unpack(">H", buf.read(2))[0] + elif opcode == 25: + buf.read(2) # femaleWield + buf.read(1) # femaleOffset + elif opcode == 26: + buf.read(2) # femaleWield2 + elif opcode == 27: + buf.read(1) # wearPos3 + elif opcode == 28: + buf.read(1) # unknown u8 + elif opcode == 29: + buf.read(1) # unknown u8 (very common in modern) + elif 30 <= opcode < 35: + _read_string(buf) # groundActions + elif 35 <= opcode < 40: + _read_string(buf) # itemActions + elif opcode == 40: + count = buf.read(1)[0] + for _ in range(count): + src = struct.unpack(">H", buf.read(2))[0] + dst = struct.unpack(">H", buf.read(2))[0] + d.recolor_src.append(src) + d.recolor_dst.append(dst) + elif opcode == 41: + count = buf.read(1)[0] + for _ in range(count): + buf.read(4) # retextureFrom + retextureTo + elif opcode == 42: + buf.read(1) # shiftClickDropIndex + elif opcode == 43: + count = buf.read(1)[0] + for _ in range(count): + buf.read(4) # sub-operation pairs (u16+u16) + elif opcode == 62: + buf.read(1) # unknown u8 + elif opcode == 64: + pass # unknown flag (0 bytes) + elif opcode == 65: + pass # isTradeable + elif opcode == 66: + buf.read(2) # unknown u16 + elif opcode == 67: + buf.read(2) # unknown u16 + elif opcode == 68: + buf.read(2) # unknown u16 + elif opcode == 69: + buf.read(1) # unknown u8 + elif opcode == 71: + buf.read(2) # unknown u16 + elif opcode == 73: + buf.read(2) # unknown u16 (modern) + elif opcode == 74: + buf.read(2) # unknown u16 (modern) + elif opcode == 75: + buf.read(2) # weight + elif opcode == 76: + buf.read(2) # unknown u16 (modern) + elif opcode == 77: + buf.read(2) # unknown u16 (modern) + elif opcode == 78: + buf.read(2) # maleModel2 + elif opcode == 79: + buf.read(2) # femaleModel2 + elif opcode == 80: + buf.read(2) # unknown u16 (modern) + elif opcode == 81: + buf.read(2) # unknown u16 (modern) + elif opcode == 82: + buf.read(2) # unknown u16 (modern) + elif opcode == 83: + buf.read(2) # unknown u16 (modern) + elif opcode == 84: + buf.read(2) # unknown u16 (modern) + elif opcode == 85: + buf.read(2) # unknown u16 (modern) + elif opcode == 86: + buf.read(2) # unknown u16 (modern) + elif opcode == 87: + buf.read(2) # unknown u16 (modern) + elif opcode == 90: + buf.read(2) # maleHeadModel + elif opcode == 91: + buf.read(2) # femaleHeadModel + elif opcode == 92: + buf.read(2) # maleHeadModel2 + elif opcode == 93: + buf.read(2) # femaleHeadModel2 + elif opcode == 94: + buf.read(2) # category + elif opcode == 95: + buf.read(2) # zan2d + elif opcode == 97: + buf.read(2) # certID + elif opcode == 98: + buf.read(2) # certTemplateID + elif 100 <= opcode < 110: + buf.read(4) # stackIDs + stackAmounts + elif opcode == 110: + buf.read(2) # resizeX + elif opcode == 111: + buf.read(2) # resizeY + elif opcode == 112: + buf.read(2) # resizeZ + elif opcode == 113: + buf.read(1) # brightness + elif opcode == 114: + buf.read(1) # contrast + elif opcode == 115: + buf.read(1) # team + elif opcode == 116: + buf.read(2) # unknown u16 (modern) + elif opcode == 117: + buf.read(2) # unknown u16 (modern) + elif opcode == 118: + buf.read(2) # unknown u16 (modern) + elif opcode == 119: + buf.read(1) # unknown u8 (modern) + elif opcode == 120: + buf.read(1) # unknown u8 (modern) + elif opcode == 121: + buf.read(1) # unknown u8 (modern) + elif opcode == 122: + buf.read(1) # unknown u8 (modern) + elif opcode == 139: + buf.read(2) # unnotedId + elif opcode == 140: + buf.read(2) # notedId + elif opcode == 148: + buf.read(2) # placeholderId + elif opcode == 149: + buf.read(2) # placeholderTemplateId + elif opcode == 155: + buf.read(1) # unknown u8 + elif opcode == 156: + buf.read(2) # unknown u16 + elif opcode == 157: + buf.read(1) # unknown u8 + elif opcode == 158: + buf.read(1) # unknown u8 + elif opcode == 159: + buf.read(1) # unknown u8 + elif opcode == 160: + count = buf.read(1)[0] + for _ in range(count): + buf.read(2) # u16 per entry + elif opcode == 161: + buf.read(2) # unknown u16 + elif opcode == 162: + buf.read(1) # unknown u8 + elif opcode == 163: + buf.read(1) # unknown u8 + elif opcode == 164: + _read_string(buf) # unknown string + elif opcode == 165: + buf.read(1) # unknown u8 + elif opcode == 202: + buf.read(2) # unknown u16 + elif opcode == 211: + count = buf.read(1)[0] + for _ in range(count): + buf.read(2) # u16 per entry + elif opcode == 249: + count = buf.read(1)[0] + for _ in range(count): + is_string = buf.read(1)[0] + buf.read(3) # key (medium) + if is_string: + _read_string(buf) + else: + buf.read(4) # int value + else: + print( + f" warning: unknown modern item opcode {opcode} at item {item_id}, " + f"pos {buf.tell()}", + file=sys.stderr, + ) + break + + return d + + +def decode_item_definitions_modern(reader: ModernCacheReader) -> dict[int, ItemDef]: + """Decode item definitions from modern cache (config index 2, group 6). + + Only parses items we actually need (SIM_ITEM_IDS) to avoid issues with + unknown opcodes in items we don't care about. + """ + files = reader.read_group(2, MODERN_CONFIG_OBJ_GROUP) + defs: dict[int, ItemDef] = {} + + for item_id in SIM_ITEM_IDS: + if item_id not in files: + print(f" warning: item {item_id} not in modern cache config group {MODERN_CONFIG_OBJ_GROUP}") + continue + d = _parse_modern_item_entry(item_id, files[item_id]) + if d.inv_model >= 0 or d.name: + defs[item_id] = d + + return defs + + +def load_model_modern(reader: ModernCacheReader, model_id: int) -> bytes | None: + """Load raw model bytes from modern cache (index 7, container-compressed).""" + raw = reader._read_raw(MODERN_MODEL_INDEX, model_id) + if raw is None: + return None + return decompress_container(raw) + + +# --- model geometry decoder --- + + +def read_smart_signed(buf: io.BytesIO) -> int: + """Read a signed smart (same as Java Buffer.readSmart in tarnish). + + Single byte: value - 64 (range -64 to 63) + Two bytes: value - 49152 (range -16384 to 16383) + """ + pos = buf.tell() + peek = buf.read(1) + if not peek: + return 0 + val = peek[0] + if val < 128: + return val - 64 + buf.seek(pos) + raw = struct.unpack(">H", buf.read(2))[0] + return raw - 49152 + + +@dataclass +class ModelData: + """Decoded model geometry.""" + + model_id: int = 0 + vertex_count: int = 0 + face_count: int = 0 + vertices_x: list[int] = field(default_factory=list) + vertices_y: list[int] = field(default_factory=list) + vertices_z: list[int] = field(default_factory=list) + face_a: list[int] = field(default_factory=list) + face_b: list[int] = field(default_factory=list) + face_c: list[int] = field(default_factory=list) + face_colors: list[int] = field(default_factory=list) # 15-bit HSL per face + face_textures: list[int] = field(default_factory=list) # texture ID per face (-1 = none) + vertex_skins: list[int] = field(default_factory=list) # label group per vertex (for animation) + face_priorities: list[int] = field(default_factory=list) # per-face render priority (0-11) + face_alphas: list[int] = field(default_factory=list) # per-face alpha (0=opaque) + face_tex_coords: list[int] = field(default_factory=list) + tex_u: list[int] = field(default_factory=list) + tex_v: list[int] = field(default_factory=list) + tex_w: list[int] = field(default_factory=list) + tex_face_count: int = 0 + + +def _read_ubyte(data: bytes, offset: int) -> int: + return data[offset] + + +def _read_ushort(data: bytes, offset: int) -> int: + return (data[offset] << 8) | data[offset + 1] + + +def decode_model(model_id: int, data: bytes) -> ModelData | None: + """Decode a model from raw cache data. Handles all 3 format variants.""" + if len(data) < 18: + return None + + # detect format from last 2 bytes (Java signed: -1=0xFF, -2=0xFE, -3=0xFD) + last2 = data[-2] + last1 = data[-1] + + if last2 == 0xFF and last1 == 0xFD: + return _decode_type3(model_id, data) + if last2 == 0xFF and last1 == 0xFE: + return _decode_type2(model_id, data) + if last2 == 0xFF and last1 == 0xFF: + return _decode_type1(model_id, data) + return _decode_old_format(model_id, data) + + +def _decode_vertices( + data: bytes, + flags_offset: int, + x_offset: int, + y_offset: int, + z_offset: int, + count: int, +) -> tuple[list[int], list[int], list[int]]: + """Decode vertex positions from delta-encoded streams.""" + vx, vy, vz = [], [], [] + fbuf = io.BytesIO(data) + fbuf.seek(flags_offset) + xbuf = io.BytesIO(data) + xbuf.seek(x_offset) + ybuf = io.BytesIO(data) + ybuf.seek(y_offset) + zbuf = io.BytesIO(data) + zbuf.seek(z_offset) + + cx, cy, cz = 0, 0, 0 + for _ in range(count): + flags = fbuf.read(1)[0] + dx = read_smart_signed(xbuf) if (flags & 1) else 0 + dy = read_smart_signed(ybuf) if (flags & 2) else 0 + dz = read_smart_signed(zbuf) if (flags & 4) else 0 + cx += dx + cy += dy + cz += dz + vx.append(cx) + vy.append(cy) + vz.append(cz) + + return vx, vy, vz + + +def _decode_faces( + data: bytes, + index_offset: int, + type_offset: int, + count: int, +) -> tuple[list[int], list[int], list[int]]: + """Decode triangle indices using strip encoding (4 face types).""" + fa, fb, fc = [], [], [] + ibuf = io.BytesIO(data) + ibuf.seek(index_offset) + tbuf = io.BytesIO(data) + tbuf.seek(type_offset) + + a, b, c, last = 0, 0, 0, 0 + for _ in range(count): + ftype = tbuf.read(1)[0] + if ftype == 1: + a = read_smart_signed(ibuf) + last + b = read_smart_signed(ibuf) + a + c = read_smart_signed(ibuf) + b + last = c + elif ftype == 2: + b = c + c = read_smart_signed(ibuf) + last + last = c + elif ftype == 3: + a = c + c = read_smart_signed(ibuf) + last + last = c + elif ftype == 4: + tmp = a + a = b + b = tmp + c = read_smart_signed(ibuf) + last + last = c + else: + # type 0 or unknown: skip + fa.append(0) + fb.append(0) + fc.append(0) + continue + fa.append(a) + fb.append(b) + fc.append(c) + + return fa, fb, fc + + +def _decode_vertex_skins( + data: bytes, offset: int, count: int, has_skins: int, +) -> list[int]: + """Read per-vertex skin labels (label group assignments for animation). + + If has_skins == 1, reads one byte per vertex at offset. + Otherwise all vertices get label 0 (single group). + """ + if has_skins == 1: + return [_read_ubyte(data, offset + i) for i in range(count)] + return [0] * count + + +def _decode_face_priorities( + data: bytes, offset: int, count: int, model_priority: int, +) -> list[int]: + """Read per-face render priorities. + + If model_priority == 255, priorities are stored per-face as bytes at offset. + Otherwise all faces share the model-level priority value. + """ + if model_priority == 255: + return [_read_ubyte(data, offset + i) for i in range(count)] + return [model_priority] * count + + +def _decode_face_colors(data: bytes, offset: int, count: int) -> list[int]: + """Read face colors as unsigned shorts.""" + colors = [] + for i in range(count): + colors.append(_read_ushort(data, offset + i * 2)) + return colors + + +def _decode_face_textures_from_stream( + data: bytes, offset: int, count: int, +) -> list[int]: + """Read face texture IDs as signed shorts (readUShort() - 1 in Java). + + Mirrors type1/type3 decoder: faceTextures[i] = readUShort() - 1. + Result: -1 means no texture, >= 0 is a valid texture ID. + """ + textures = [] + for i in range(count): + val = _read_ushort(data, offset + i * 2) - 1 + # unsigned 0 → -1 (no texture), unsigned N → N-1 (texture ID) + textures.append(val) + return textures + + +def _apply_render_type_textures( + data: bytes, + render_type_offset: int, + face_colors: list[int], + face_count: int, +) -> list[int]: + """Handle faceRenderType & 2 texture assignment (type2/oldFormat path). + + Mirrors Java: when renderType & 2, faceTextures[i] = faceColors[i], + faceColors[i] = 127. Returns the face_textures array. + """ + face_textures = [-1] * face_count + for i in range(face_count): + render_type = _read_ubyte(data, render_type_offset + i) + if render_type & 2: + face_textures[i] = face_colors[i] + face_colors[i] = 127 + return face_textures + + +def _decode_type2(model_id: int, data: bytes) -> ModelData | None: + """23-byte footer without textureRenderTypes (type2, magic 0xFF 0xFE). + + Mirrors Java decodeType2. Vertex flags start at offset 0 (no tex render types). + """ + n = len(data) + if n < 23: + return None + + off = n - 23 + var9 = _read_ushort(data, off) # verticesCount + var10 = _read_ushort(data, off + 2) # triangleFaceCount + var11 = _read_ubyte(data, off + 4) # texturesCount + var12 = _read_ubyte(data, off + 5) # has faceRenderType + var13 = _read_ubyte(data, off + 6) # face priority + var14 = _read_ubyte(data, off + 7) # has transparency + var15 = _read_ubyte(data, off + 8) # has face skins + var16 = _read_ubyte(data, off + 9) # has vertex skins + var17 = _read_ubyte(data, off + 10) # has animaya + var18 = _read_ushort(data, off + 11) # vertex X len + var19 = _read_ushort(data, off + 13) # vertex Y len + var20 = _read_ushort(data, off + 15) # vertex Z len + var21 = _read_ushort(data, off + 17) # face index len + # off+19..20 unused? Actually var22 = readUShort at off+19 + # Wait — the footer is 23 bytes. 2+2+1+1+1+1+1+1+1+2+2+2+2 = 19. Plus magic 2 = 21. + # Actually decodeType2 footer reads: 2+2+1+1+1+1+1+1+1+2+2+2+2+2 = 21 data + 2 magic = 23 + # Let me re-check: var9(2)+var10(2)+var11(1)+var12(1)+var13(1)+var14(1)+var15(1)+var16(1)+var17(1) + # +var18(2)+var19(2)+var20(2)+var21(2)+magic(2) = 4+7+8+2 = 21. Hmm that's 21 not 23. + # Actually: 2+2+1+1+1+1+1+1+1+2+2+2+2 = 19. So there must be one more ushort. + # Looking at Java: var22 = var4.readUShort() at the end of the footer. That's face tex len. + var22 = _read_ushort(data, off + 19) # tex len (= face_tex_len not used by us, but 0xFF-2) + # Actually off + 19 + 2 = off + 21, then magic at off+21..22. Total = 23. Correct. + + # section offsets (mirrors Java decodeType2) + # type2: vertex flags start at offset 0 (var23=0 in Java) + var23 = 0 + var24 = var23 + var9 # end of vertex flags + var25 = var24 # face strip type offset + var24 += var10 + + var26 = var24 # face priority offset + if var13 == 255: + var24 += var10 + + var27 = var24 # face skin offset + if var15 == 1: + var24 += var10 + + var28 = var24 # face render type offset + if var12 == 1: + var24 += var10 + + var29 = var24 # vertex skin offset + var24 += var22 # tex len + + var30 = var24 # face transparency offset + if var14 == 1: + var24 += var10 + + var31 = var24 # face index data offset + var24 += var21 + + var32 = var24 # face color offset + var24 += var10 * 2 + + var33 = var24 # texture coords offset + var24 += var11 * 6 + + var34 = var24 # vertex X offset + var24 += var18 + var35 = var24 # vertex Y offset + var24 += var19 + # vertex Z starts at var24 + + vx, vy, vz = _decode_vertices(data, var23, var34, var35, var24, var9) + fa, fb, fc = _decode_faces(data, var31, var25, var10) + colors = _decode_face_colors(data, var32, var10) + + # handle faceRenderType & 2 → face is textured (type2/oldFormat path) + face_textures: list[int] = [] + if var12 == 1: + face_textures = _apply_render_type_textures(data, var28, colors, var10) + + priorities = _decode_face_priorities(data, var26, var10, var13) + skins = _decode_vertex_skins(data, var29, var9, var16) + + return ModelData( + model_id=model_id, + vertex_count=var9, + face_count=var10, + vertices_x=vx, vertices_y=vy, vertices_z=vz, + face_a=fa, face_b=fb, face_c=fc, + face_colors=colors, + face_textures=face_textures, + face_priorities=priorities, + vertex_skins=skins, + ) + + +def _decode_old_format(model_id: int, data: bytes) -> ModelData | None: + """18-byte footer format (original 317). Mirrors Java decodeOldFormat exactly.""" + n = len(data) + if n < 18: + return None + + off = n - 18 + var9 = _read_ushort(data, off) # verticesCount + var10 = _read_ushort(data, off + 2) # triangleFaceCount + var11 = _read_ubyte(data, off + 4) # texturesCount + var12 = _read_ubyte(data, off + 5) # has faceRenderType + var13 = _read_ubyte(data, off + 6) # face priority + var14 = _read_ubyte(data, off + 7) # has transparency + var15 = _read_ubyte(data, off + 8) # has face skins + var16 = _read_ubyte(data, off + 9) # has vertex skins + var17 = _read_ushort(data, off + 10) # vertex X len + var18 = _read_ushort(data, off + 12) # vertex Y len + var19 = _read_ushort(data, off + 14) # vertex Z len + var20 = _read_ushort(data, off + 16) # face index len + + # section offset calculation (exact mirror of Java) + var21 = 0 + var22 = var21 + var9 # vertex flags end + var23 = var22 # face type offset + var22 += var10 # face types + + var24 = var22 # face priority offset + if var13 == 255: + var22 += var10 + + var25 = var22 # face skin offset + if var15 == 1: + var22 += var10 + + var26 = var22 # face render type offset + if var12 == 1: + var22 += var10 + + var27 = var22 # vertex skin offset + if var16 == 1: + var22 += var9 + + var28 = var22 # face transparency offset + if var14 == 1: + var22 += var10 + + var29 = var22 # face index offset + var22 += var20 + + var30 = var22 # face color offset + var22 += var10 * 2 + + var31 = var22 # texture coords offset + var22 += var11 * 6 + + var32 = var22 # vertex X offset + var22 += var17 + var33 = var22 # vertex Y offset + var22 += var18 + # vertex Z starts here + + vx, vy, vz = _decode_vertices(data, var21, var32, var33, var22, var9) + fa, fb, fc = _decode_faces(data, var29, var23, var10) + colors = _decode_face_colors(data, var30, var10) + + # handle faceRenderType & 2 → face is textured (type2/oldFormat path) + face_textures: list[int] = [] + if var12 == 1: + face_textures = _apply_render_type_textures(data, var26, colors, var10) + + priorities = _decode_face_priorities(data, var24, var10, var13) + skins = _decode_vertex_skins(data, var27, var9, var16) + + return ModelData( + model_id=model_id, + vertex_count=var9, + face_count=var10, + vertices_x=vx, vertices_y=vy, vertices_z=vz, + face_a=fa, face_b=fb, face_c=fc, + face_colors=colors, + face_textures=face_textures, + face_priorities=priorities, + vertex_skins=skins, + ) + + +def _decode_type1(model_id: int, data: bytes) -> ModelData | None: + """23-byte footer with textureRenderTypes (type1). Mirrors Java decodeType1.""" + n = len(data) + if n < 23: + return None + + off = n - 23 + var9 = _read_ushort(data, off) # verticesCount + var10 = _read_ushort(data, off + 2) # triangleFaceCount + var11 = _read_ubyte(data, off + 4) # texturesCount + var12 = _read_ubyte(data, off + 5) # has faceRenderType + var13 = _read_ubyte(data, off + 6) # face priority + var14 = _read_ubyte(data, off + 7) # has transparency + var15 = _read_ubyte(data, off + 8) # has face skins + var16 = _read_ubyte(data, off + 9) # has face textures + var17 = _read_ubyte(data, off + 10) # has vertex skins + # note: no animaya byte in type1 footer (that's in type3) + var18 = _read_ushort(data, off + 11) # vertex X len + var19 = _read_ushort(data, off + 13) # vertex Y len + var20 = _read_ushort(data, off + 15) # vertex Z len + var21 = _read_ushort(data, off + 17) # face index len + var22 = _read_ushort(data, off + 19) # tex index len + + # count texture render types + tex_type0 = 0 + tex_type13 = 0 + tex_type2 = 0 + if var11 > 0: + for i in range(var11): + t = data[i] + if t == 0: + tex_type0 += 1 + if 1 <= t <= 3: + tex_type13 += 1 + if t == 2: + tex_type2 += 1 + + # section offsets (exact mirror of Java decodeType1) + var26 = var11 + var9 + var56 = var26 # face render type offset + if var12 == 1: + var26 += var10 + + var28 = var26 # face strip type offset + var26 += var10 + + var29 = var26 # face priority offset + if var13 == 255: + var26 += var10 + + var30 = var26 # face skin offset + if var15 == 1: + var26 += var10 + + var31 = var26 # vertex skin offset + if var17 == 1: + var26 += var9 + + var32 = var26 # face transparency offset + if var14 == 1: + var26 += var10 + + var33 = var26 # face index data offset + var26 += var21 + + var34 = var26 # face texture offset + if var16 == 1: + var26 += var10 * 2 + + var35 = var26 # tex map offset + var26 += var22 + + var36 = var26 # face color offset + var26 += var10 * 2 + + var37 = var26 # vertex X offset + var26 += var18 + var38 = var26 # vertex Y offset + var26 += var19 + var39 = var26 # vertex Z offset + var26 += var20 + + # texture coordinates + var40 = var26 # tex type 0 coords + var26 += tex_type0 * 6 + var41 = var26 # tex type 1-3 coords + var26 += tex_type13 * 6 + # ... (more tex data, but we don't need it) + + # vertex flags start at offset var11 (after textureRenderTypes) + vx, vy, vz = _decode_vertices(data, var11, var37, var38, var39, var9) + fa, fb, fc = _decode_faces(data, var33, var28, var10) + colors = _decode_face_colors(data, var36, var10) + + # type1: faceTextures read from dedicated stream (readUShort() - 1) + face_textures: list[int] = [] + if var16 == 1: + face_textures = _decode_face_textures_from_stream(data, var34, var10) + + priorities = _decode_face_priorities(data, var29, var10, var13) + skins = _decode_vertex_skins(data, var31, var9, var17) + + return ModelData( + model_id=model_id, + vertex_count=var9, + face_count=var10, + vertices_x=vx, vertices_y=vy, vertices_z=vz, + face_a=fa, face_b=fb, face_c=fc, + face_colors=colors, + face_textures=face_textures, + face_priorities=priorities, + vertex_skins=skins, + ) + + +def _decode_type3(model_id: int, data: bytes) -> ModelData | None: + """26-byte footer (type3, magic 0xFF 0xFD). Mirrors Java decodeType3.""" + n = len(data) + if n < 26: + return None + + off = n - 26 + var9 = _read_ushort(data, off) # verticesCount + var10 = _read_ushort(data, off + 2) # triangleFaceCount + var11 = _read_ubyte(data, off + 4) # texturesCount + var12 = _read_ubyte(data, off + 5) # has faceRenderType + var13 = _read_ubyte(data, off + 6) # face priority + var14 = _read_ubyte(data, off + 7) # has transparency + var15 = _read_ubyte(data, off + 8) # has face skins + var16 = _read_ubyte(data, off + 9) # has face textures + var17 = _read_ubyte(data, off + 10) # has vertex skins + var18 = _read_ubyte(data, off + 11) # has animaya + var19 = _read_ushort(data, off + 12) # vertex X len + var20 = _read_ushort(data, off + 14) # vertex Y len + var21 = _read_ushort(data, off + 16) # vertex Z len + var22 = _read_ushort(data, off + 18) # face index len + var23 = _read_ushort(data, off + 20) # tex map len + var24 = _read_ushort(data, off + 22) # tex index len + # off+24..25 = magic (0xFF, 0xFD) + + # count texture render types + tex_type0 = 0 + tex_type13 = 0 + tex_type2 = 0 + if var11 > 0: + for i in range(var11): + t = data[i] + if t == 0: + tex_type0 += 1 + if 1 <= t <= 3: + tex_type13 += 1 + if t == 2: + tex_type2 += 1 + + # section offsets (exact mirror of Java decodeType3) + var28 = var11 + var9 + var58 = var28 # face render type offset + if var12 == 1: + var28 += var10 + + var30 = var28 # face strip type offset + var28 += var10 + + var31 = var28 # face priority offset + if var13 == 255: + var28 += var10 + + var32 = var28 # face skin offset + if var15 == 1: + var28 += var10 + + var33 = var28 # tex index / vertex skin region + var28 += var24 # tex_index_len + + var34 = var28 # face transparency offset + if var14 == 1: + var28 += var10 + + var35 = var28 # face index data offset + var28 += var22 # face_index_len + + var36 = var28 # face texture offset + if var16 == 1: + var28 += var10 * 2 + + var37 = var28 # tex map offset + var28 += var23 # tex_map_len + + var38 = var28 # FACE COLOR offset + var28 += var10 * 2 + + var39 = var28 # vertex X offset + var28 += var19 + var40 = var28 # vertex Y offset + var28 += var20 + var41 = var28 # vertex Z offset + var28 += var21 + + # texture coordinates section (we skip but need for total size check) + var42 = var28 # tex type 0 coords + var28 += tex_type0 * 6 + var43 = var28 # tex type 1-3 coords + var28 += tex_type13 * 6 + # ... (more tex data for type 1-3 and type 2) + + # vertex flags start at offset var11 (after textureRenderTypes) + vx, vy, vz = _decode_vertices(data, var11, var39, var40, var41, var9) + fa, fb, fc = _decode_faces(data, var35, var30, var10) + colors = _decode_face_colors(data, var38, var10) + + # type3: faceTextures read from dedicated stream (readUShort() - 1) + face_textures: list[int] = [] + if var16 == 1: + face_textures = _decode_face_textures_from_stream(data, var36, var10) + + priorities = _decode_face_priorities(data, var31, var10, var13) + skins = _decode_vertex_skins(data, var33, var9, var17) + + return ModelData( + model_id=model_id, + vertex_count=var9, + face_count=var10, + vertices_x=vx, vertices_y=vy, vertices_z=vz, + face_a=fa, face_b=fb, face_c=fc, + face_colors=colors, + face_textures=face_textures, + face_priorities=priorities, + vertex_skins=skins, + ) + + +# --- HSL to RGB conversion --- + + +def hsl15_to_rgb(hsl: int) -> tuple[int, int, int]: + """Convert OSRS 15-bit HSL face color to RGB. + + HSL packing: (hue_sat << 7) | lightness + where hue_sat = hue * 8 + saturation (0..511) + hue: 0..63 (6 bits), saturation: 0..7 (3 bits), lightness: 0..127 (7 bits) + + Reimplements Rasterizer3D.Rasterizer3D_buildPalette without brightness adjustment. + """ + hue_sat = (hsl >> 7) & 0x1FF + lightness = hsl & 0x7F + + hue_f = (hue_sat >> 3) / 64.0 + 0.0078125 + sat_f = (hue_sat & 7) / 8.0 + 0.0625 + light_f = lightness / 128.0 + + # HSL to RGB (same algorithm as Rasterizer3D) + r, g, b = light_f, light_f, light_f + + if sat_f != 0.0: + if light_f < 0.5: + q = light_f * (1.0 + sat_f) + else: + q = light_f + sat_f - light_f * sat_f + + p = 2.0 * light_f - q + + h_r = hue_f + 1.0 / 3.0 + if h_r > 1.0: + h_r -= 1.0 + h_g = hue_f + h_b = hue_f - 1.0 / 3.0 + if h_b < 0.0: + h_b += 1.0 + + r = _hue_to_channel(p, q, h_r) + g = _hue_to_channel(p, q, h_g) + b = _hue_to_channel(p, q, h_b) + + ri = min(255, int(r * 256.0)) + gi = min(255, int(g * 256.0)) + bi = min(255, int(b * 256.0)) + return (max(0, ri), max(0, gi), max(0, bi)) + + +def _hue_to_channel(p: float, q: float, t: float) -> float: + """HSL hue-to-channel helper (same as Rasterizer3D).""" + if 6.0 * t < 1.0: + return p + (q - p) * 6.0 * t + if 2.0 * t < 1.0: + return q + if 3.0 * t < 2.0: + return p + (q - p) * (2.0 / 3.0 - t) * 6.0 + return p + + +# --- binary output --- + +MDLS_MAGIC = 0x4D444C53 # "MDLS" (v1) +MDL2_MAGIC = 0x4D444C32 # "MDL2" (v2, adds animation data) + + +def _merge_models(models: list[ModelData]) -> ModelData: + """Merge multiple ModelData into a single model (concatenate geometry). + + Face vertex indices are offset by accumulated vertex counts. + Used for identity kit body parts that consist of multiple sub-models. + """ + merged = ModelData(model_id=models[0].model_id) + + vert_offset = 0 + for md in models: + merged.vertices_x.extend(md.vertices_x) + merged.vertices_y.extend(md.vertices_y) + merged.vertices_z.extend(md.vertices_z) + + merged.face_a.extend(a + vert_offset for a in md.face_a) + merged.face_b.extend(b + vert_offset for b in md.face_b) + merged.face_c.extend(c + vert_offset for c in md.face_c) + merged.face_colors.extend(md.face_colors) + merged.face_priorities.extend(md.face_priorities) + merged.face_alphas.extend(md.face_alphas) + merged.face_textures.extend(md.face_textures) + merged.face_tex_coords.extend(md.face_tex_coords) + merged.tex_u.extend(md.tex_u) + merged.tex_v.extend(md.tex_v) + merged.tex_w.extend(md.tex_w) + merged.vertex_skins.extend(md.vertex_skins) + + vert_offset += md.vertex_count + merged.vertex_count += md.vertex_count + merged.face_count += md.face_count + merged.tex_face_count += md.tex_face_count + + return merged + + +def expand_model( + model: ModelData, + tex_colors: dict[int, int] | None = None, + atlas: "TextureAtlas | None" = None, +) -> tuple[list[float], list[tuple[int, int, int, int]], list[float]]: + """Expand indexed model to per-vertex (3 verts per face, no index buffer). + + Returns (flat_vertices[face_count*3*3], colors[face_count*3], uvs[face_count*3*2]). + Each color is (r, g, b, 255). + + When atlas is provided: + - Textured faces get UV coordinates mapped to atlas slots, vertex color = white + - Non-textured faces get UV pointing to atlas white pixel, vertex color = HSL + When atlas is None (legacy path): + - Textured faces use averageRGB from tex_colors as vertex color + - UVs are all zeros + """ + verts: list[float] = [] + colors: list[tuple[int, int, int, int]] = [] + uvs: list[float] = [] + + # compute minimum priority so we only offset faces that differ from baseline. + # a model with all faces at priority 10 needs zero offset (no coplanar conflict). + min_pri = min(model.face_priorities) if model.face_priorities else 0 + + for fi in range(model.face_count): + a = model.face_a[fi] + b = model.face_b[fi] + c = model.face_c[fi] + + # bounds check + if ( + a < 0 + or a >= model.vertex_count + or b < 0 + or b >= model.vertex_count + or c < 0 + or c >= model.vertex_count + ): + # degenerate face, emit zero triangle + verts.extend([0.0] * 9) + colors.extend([(128, 128, 128, 255)] * 3) + uvs.extend([0.0] * 6) + continue + + # vertex positions (OSRS Y is negative-up, we negate) + ax, ay, az = float(model.vertices_x[a]), float(-model.vertices_y[a]), float(model.vertices_z[a]) + bx, by, bz = float(model.vertices_x[b]), float(-model.vertices_y[b]), float(model.vertices_z[b]) + cx, cy, cz = float(model.vertices_x[c]), float(-model.vertices_y[c]), float(model.vertices_z[c]) + + # priority-based normal offset to prevent z-fighting on coplanar faces. + # OSRS uses a painter's algorithm with per-face priorities (0-9 drawn in + # strict ascending order). we only offset faces above the model's minimum + # priority — if all faces share the same priority, no offset is needed. + pri = model.face_priorities[fi] if fi < len(model.face_priorities) else 0 + pri_delta = pri - min_pri + if pri_delta > 0: + # face normal via cross product of edges + e1x, e1y, e1z = bx - ax, by - ay, bz - az + e2x, e2y, e2z = cx - ax, cy - ay, cz - az + nx = e1y * e2z - e1z * e2y + ny = e1z * e2x - e1x * e2z + nz = e1x * e2y - e1y * e2x + length = math.sqrt(nx * nx + ny * ny + nz * nz) + if length > 0.001: + # 0.15 OSRS units per priority level — small enough to be invisible, + # large enough to resolve depth buffer ambiguity at 1/128 scale + bias = pri_delta * 0.15 / length + nx *= bias + ny *= bias + nz *= bias + ax -= nx; ay -= ny; az -= nz + bx -= nx; by -= ny; bz -= nz + cx -= nx; cy -= ny; cz -= nz + + verts.extend([ax, ay, az, bx, by, bz, cx, cy, cz]) + + tex_id = ( + model.face_textures[fi] + if fi < len(model.face_textures) + else -1 + ) + + if atlas and tex_id >= 0 and tex_id in atlas.uv_map: + # textured face: compute UV in atlas space + u_off, v_off, u_size, v_size = atlas.uv_map[tex_id] + + # face vertices ARE the UV basis (textureCoords = -1 case): + # vertex A → (0,0), B → (1,0), C → (0,1) in texture space + # mapped to atlas: offset + fraction * size + uv_a = (u_off + 0.0 * u_size, v_off + 0.0 * v_size) + uv_b = (u_off + 1.0 * u_size, v_off + 0.0 * v_size) + uv_c = (u_off + 0.0 * u_size, v_off + 1.0 * v_size) + uvs.extend([uv_a[0], uv_a[1], uv_b[0], uv_b[1], uv_c[0], uv_c[1]]) + + # vertex color = white (texture provides color) + colors.extend([(255, 255, 255, 255)] * 3) + else: + # non-textured face: UV points to white pixel, vertex color = HSL + if atlas: + uvs.extend([atlas.white_u, atlas.white_v] * 3) + else: + uvs.extend([0.0] * 6) + + if tex_id >= 0 and tex_colors and tex_id in tex_colors: + hsl = tex_colors[tex_id] + else: + hsl = model.face_colors[fi] if fi < len(model.face_colors) else 0 + r, g, b_col = hsl15_to_rgb(hsl) + color = (r, g, b_col, 255) + colors.extend([color, color, color]) + + return verts, colors, uvs + + +def write_models_binary( + output_path: Path, models: list[ModelData], +) -> None: + """Write models to .models v2 binary format (MDL2). + + V2 format adds animation data per model: + - base vertex positions (indexed, pre-animation reference pose) + - vertex skin labels (label group per base vertex, for animation transforms) + - face indices (triangle index buffer into base vertices) + + Per-model binary layout: + uint32 model_id + uint16 expanded_vert_count (face_count * 3, for rendering) + uint16 face_count + uint16 base_vert_count (original indexed vertex count) + float expanded_verts[expanded_vert_count * 3] (x,y,z) + uint8 colors[expanded_vert_count * 4] (r,g,b,a) + int16 base_verts[base_vert_count * 3] (x,y,z — original OSRS coords, y NOT negated) + uint8 vertex_skins[base_vert_count] (label group per vertex) + uint16 face_indices[face_count * 3] (a,b,c per face) + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, "wb") as f: + # header + f.write(struct.pack(" None: + """Write C header with item → model ID mapping. + + Each entry: (item_id, inv_model_id, wield_model_id, has_sleeves). + has_sleeves indicates whether the body item provides its own arm model + (male_wield2), meaning default arm body parts should be hidden. + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, "w") as f: + f.write("/* generated by scripts/export_models.py — do not edit */\n") + f.write("#ifndef ITEM_MODELS_H\n") + f.write("#define ITEM_MODELS_H\n\n") + f.write("#include \n\n") + f.write("typedef struct {\n") + f.write(" uint16_t item_id;\n") + f.write(" uint32_t inv_model;\n") + f.write(" uint32_t wield_model;\n") + f.write(" uint8_t has_sleeves;\n") + f.write("} ItemModelMapping;\n\n") + f.write( + f"#define ITEM_MODEL_COUNT {len(mappings)}\n\n" + ) + f.write( + "static const ItemModelMapping ITEM_MODEL_MAP[] = {\n" + ) + for item_id, inv, wield, sleeves in mappings: + f.write(f" {{ {item_id}, {inv}, {wield}, {sleeves} }},\n") + f.write("};\n\n") + f.write("#endif /* ITEM_MODELS_H */\n") + + +def write_player_model_header( + output_path: Path, + body_part_model_ids: dict[int, int], + item_mappings: list[tuple[int, int, int, int]], + item_defs: dict[int, ItemDef], +) -> None: + """Write C header for player model rendering. + + Provides: + - Default body part model IDs (from identity kits) + - Item ID to wield model lookup (for equipped items) + - Equipment slot coverage info (which body parts to hide) + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + + with open(output_path, "w") as f: + f.write("/* generated by scripts/export_models.py — do not edit */\n") + f.write("#ifndef PLAYER_MODELS_H\n") + f.write("#define PLAYER_MODELS_H\n\n") + f.write("#include \n\n") + + f.write("/* body part indices (male) */\n") + for i, name in enumerate(BODY_PART_NAMES): + f.write(f"#define BODY_PART_{name} {i}\n") + f.write(f"#define BODY_PART_COUNT {len(BODY_PART_NAMES)}\n\n") + + f.write("/* default male body part model IDs (synthetic: 0xF0000 + part_id) */\n") + f.write("static const uint32_t DEFAULT_BODY_MODELS[BODY_PART_COUNT] = {\n") + for i in range(len(BODY_PART_NAMES)): + mid = body_part_model_ids.get(i, 0xFFFFFFFF) + f.write(f" 0x{mid:X}, /* {BODY_PART_NAMES[i]} */\n") + f.write("};\n\n") + + f.write("#endif /* PLAYER_MODELS_H */\n") + + +# --- item IDs from our simulation (osrs_items.h) --- + +SIM_ITEM_IDS = [ + 10828, # Helm of Neitiznot + 21795, # Imbued god cape + 1712, # Amulet of glory + 2503, # Black d'hide body + 4091, # Mystic robe top + 1079, # Rune platelegs + 4093, # Mystic robe bottom + 4151, # Abyssal whip + 9185, # Rune crossbow + 4710, # Ahrim's staff + 5698, # Dragon dagger + 12954, # Dragon defender + 12829, # Spirit shield + 7462, # Barrows gloves + 3105, # Climbing boots + 6737, # Berserker ring + 9243, # Diamond bolts (e) + 22324, # Ghrazi rapier + 24417, # Inquisitor's mace + 11791, # Staff of the dead + 21006, # Kodai wand + 24424, # Volatile nightmare staff + 13867, # Zuriel's staff + 11785, # Armadyl crossbow + 26374, # Zaryte crossbow + 13652, # Dragon claws + 11802, # Armadyl godsword + 25730, # Ancient godsword + 4153, # Granite maul + 21003, # Elder maul + 11235, # Dark bow + 19481, # Heavy ballista + 22613, # Vesta's longsword + 27690, # Voidwaker + 22622, # Statius's warhammer + 22636, # Morrigan's javelin + 21018, # Ancestral hat + 21021, # Ancestral robe top + 21024, # Ancestral robe bottom + 4712, # Ahrim's robetop + 4714, # Ahrim's robeskirt + 4736, # Karil's leathertop + 11834, # Bandos tassets + 12831, # Blessed spirit shield + 6585, # Amulet of fury + 12002, # Occult necklace + 21295, # Infernal cape + 13235, # Eternal boots + 11770, # Seers ring (i) + 25975, # Lightbearer + 6889, # Mage's book + 11212, # Dragon arrows + 4751, # Torag's platelegs + 4722, # Dharok's platelegs + 4759, # Verac's plateskirt + 4745, # Torag's helm + 4716, # Dharok's helm + 4753, # Verac's helm + 4724, # Guthan's helm + 21932, # Opal dragon bolts (e) + # --- Zulrah encounter items --- + # tier 2 (BIS) + 21791, # Imbued saradomin cape + 31113, # Eye of ayak (charged) + 27251, # Elidinis' ward (f) + 31106, # Confliction gauntlets + 31097, # Avernic treads (max) + 20657, # Ring of suffering (ri) + 20997, # Twisted bow + 27235, # Masori mask (f) + 27238, # Masori body (f) + 27241, # Masori chaps (f) + 19547, # Necklace of anguish + 28947, # Dizana's quiver (uncharged) + 26235, # Zaryte vambraces + 12926, # Toxic blowpipe + # tier 1 (mid) + 4708, # Ahrim's hood + 19544, # Tormented bracelet + 22481, # Sanguinesti staff + 6920, # Infinity boots + 20220, # Holy blessing (god blessing) + 2550, # Ring of recoil + 23971, # Crystal helm + 22109, # Ava's assembler + 23975, # Crystal body + 23979, # Crystal legs + 25865, # Bow of faerdhinen (c) + 19921, # Blessed d'hide boots + # tier 0 (budget) + 4089, # Mystic hat + 12899, # Trident of the swamp + 12612, # Book of darkness + 21326, # Amethyst arrow + 4097, # Mystic boots + 10382, # Blessed coif (Guthix) + 2497, # Black d'hide chaps + 12788, # Magic shortbow (i) + 10499, # Ava's accumulator + # --- Inferno encounter items --- + 22326, # Justiciar faceguard + 22327, # Justiciar chestguard + 22328, # Justiciar legguards + 4224, # Crystal shield + 13237, # Pegasian boots +] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="export OSRS 3D models from cache" + ) + cache_group = parser.add_mutually_exclusive_group(required=True) + cache_group.add_argument( + "--cache", + type=Path, + help="path to 317 tarnish cache directory", + ) + cache_group.add_argument( + "--modern-cache", + type=Path, + help="path to modern OpenRS2 flat file cache directory", + ) + parser.add_argument( + "--output", + type=Path, + default=Path("data/equipment.models"), + help="output .models binary file", + ) + parser.add_argument( + "--header", + type=Path, + default=Path("data/item_models.h"), + help="output C header with item→model mapping", + ) + parser.add_argument( + "--extra-models", + type=str, + default=None, + help="comma-separated extra model IDs to export (e.g. NPC models)", + ) + args = parser.parse_args() + + use_modern = args.modern_cache is not None + cache_path = args.modern_cache if use_modern else args.cache + + if not cache_path.exists(): + sys.exit(f"cache directory not found: {cache_path}") + + print(f"reading {'modern' if use_modern else '317'} cache from {cache_path}") + + if use_modern: + modern_reader = ModernCacheReader(cache_path) + cache = None # not used in modern path + else: + cache = CacheReader(cache_path) + modern_reader = None # not used in 317 path + + print("loading item definitions...") + if use_modern: + item_defs = decode_item_definitions_modern(modern_reader) + else: + item_defs = decode_item_definitions(cache) + print(f" loaded {len(item_defs)} item definitions") + + # build per-item wield models with recolors + maleWield2 merged + print("building wield models...") + needed_models: set[int] = set() # raw inv models (no recolor needed for inventory icons) + mappings: list[tuple[int, int, int]] = [] # (item_id, inv_model, wield_synth_id) + wield_models: list[ModelData] = [] # recolored + merged wield models + + def _load_model(mid: int) -> ModelData | None: + if use_modern: + raw_m = load_model_modern(modern_reader, mid) + else: + raw_m = cache.get(MODEL_INDEX, mid) + if raw_m is not None: + try: + raw_m = gzip.decompress(raw_m) + except Exception: + pass + if raw_m is None: + return None + return decode_model(mid, raw_m) + + for idx, item_id in enumerate(SIM_ITEM_IDS): + item = item_defs.get(item_id) + if item is None: + print(f" warning: item {item_id} not found in cache") + continue + + inv = item.inv_model if item.inv_model >= 0 else 0xFFFFFFFF + if inv != 0xFFFFFFFF: + needed_models.add(inv) + + # build wield model: merge maleWield + maleWield2, apply recolors + wield_synth = 0xFFFFFFFF + wield_parts: list[ModelData] = [] + if item.male_wield >= 0: + md = _load_model(item.male_wield) + if md: + wield_parts.append(md) + if item.male_wield2 >= 0: + md2 = _load_model(item.male_wield2) + if md2: + wield_parts.append(md2) + + if wield_parts: + if len(wield_parts) == 1: + merged = wield_parts[0] + else: + merged = _merge_models(wield_parts) + + # apply item recolors + for src, dst in zip(item.recolor_src, item.recolor_dst): + for fi in range(merged.face_count): + if merged.face_colors[fi] == src: + merged.face_colors[fi] = dst + + wield_synth = 0xE0000 + idx + merged.model_id = wield_synth + wield_models.append(merged) + + has_sleeves = 1 if item.male_wield2 >= 0 else 0 + mappings.append((item_id, inv, wield_synth, has_sleeves)) + rc_info = f", {len(item.recolor_src)} recolors" if item.recolor_src else "" + w2_info = f" + wield2={item.male_wield2}" if item.male_wield2 >= 0 else "" + print( + f" {item.name} (id={item_id}): inv={inv}, " + f"wield={item.male_wield}{w2_info}{rc_info}" + ) + + # decode identity kits for player body parts + print("loading identity kits...") + if use_modern: + idk_defs = decode_identity_kits_modern(modern_reader) + else: + idk_defs = decode_identity_kits(cache) + print(f" loaded {len(idk_defs)} identity kits") + + # merge body part sub-models into single models for each default kit + # uses synthetic model IDs 0xF0000 + body_part_id to avoid collisions + body_part_model_ids: dict[int, int] = {} # body_part_id -> synthetic model_id + body_models: list[ModelData] = [] + + for body_part_id, kit_idx in sorted(DEFAULT_MALE_KITS.items()): + kit = idk_defs.get(kit_idx) + if kit is None or not kit.body_models: + print(f" warning: kit {kit_idx} ({BODY_PART_NAMES[body_part_id]}) has no body models") + continue + + # decode and merge sub-models + sub_models: list[ModelData] = [] + for mid in kit.body_models: + md = _load_model(mid) + if md: + sub_models.append(md) + + if not sub_models: + print(f" warning: could not decode body models for kit {kit_idx}") + continue + + # merge into single model + if len(sub_models) == 1: + merged = sub_models[0] + else: + merged = _merge_models(sub_models) + + # apply kit recolors + for i in range(6): + if kit.original_colors[i] == 0: + break + for fi in range(merged.face_count): + if merged.face_colors[fi] == kit.original_colors[i]: + merged.face_colors[fi] = kit.replacement_colors[i] + + # assign synthetic model ID + synth_id = 0xF0000 + body_part_id + merged.model_id = synth_id + body_part_model_ids[body_part_id] = synth_id + body_models.append(merged) + print(f" {BODY_PART_NAMES[body_part_id]}: kit {kit_idx}, " + f"{len(kit.body_models)} sub-models -> {merged.vertex_count} verts") + + # add spotanim models (spell effects, projectiles) + # parsed from spotanim.dat — model IDs for GFX 27/368/369/377/1468 + SPOTANIM_MODELS = {3080, 3135, 6375, 6381, 14215} + needed_models |= SPOTANIM_MODELS + print(f"added {len(SPOTANIM_MODELS)} spotanim models") + + # zulrah encounter: NPC models, snakelings, projectiles, clouds + # from data/npc_models.h model IDs + ENCOUNTER_MODELS = { + 14407, # blue zulrah (magic form) + 14408, # green zulrah (ranged form) + 14409, # red zulrah (melee form) + 10415, # snakeling + 20390, # GFX 1044 ranged projectile (zulrah) + 11221, # GFX 1045 cloud projectile + 26593, # GFX 1046 magic projectile (zulrah) + 4086, # object 11700 toxic cloud on ground + # inferno pillars — "Rocky support" objects 30284-30287 (4 HP levels) + 33044, # object 30284 — Rocky support (100% HP) + 33043, # object 30285 — Rocky support (75% HP) + 33042, # object 30286 — Rocky support (50% HP) + 33045, # object 30287 — Rocky support (25% HP) + # player weapon projectiles + 20825, # GFX 1040 trident of swamp projectile + 20824, # GFX 1042 trident impact + 20823, # GFX 665 trident casting + 3136, # GFX 15 rune arrow projectile + 26379, # GFX 1122 dragon dart projectile (blowpipe) + 3131, # GFX 231 rune dart projectile + 29421, # GFX 1043 blowpipe special attack + } + needed_models |= ENCOUNTER_MODELS + print(f"added {len(ENCOUNTER_MODELS)} encounter NPC/projectile models") + + # add extra models from CLI (NPC models, etc.) + if args.extra_models: + extra = {int(x.strip()) for x in args.extra_models.split(",")} + needed_models |= extra + print(f"added {len(extra)} extra models from --extra-models: {sorted(extra)}") + + # dragon bolt (GFX 1468) is model 3135 with recolors: 41->1692, 61->670, 57->1825 + # build it as a synthetic recolored model like wield models + dragon_bolt_base = _load_model(3135) + if dragon_bolt_base: + for src, dst in [(41, 1692), (61, 670), (57, 1825)]: + for fi in range(dragon_bolt_base.face_count): + if dragon_bolt_base.face_colors[fi] == src: + dragon_bolt_base.face_colors[fi] = dst + dragon_bolt_base.model_id = 0xD0001 # synthetic ID for dragon bolt + wield_models.append(dragon_bolt_base) + print(f" built dragon bolt model (recolored 3135 -> 0xD0001)") + + print(f"\n{len(needed_models)} unique equipment + spotanim models to export") + + # read and decode models from cache index 1 + decoded_models: list[ModelData] = [] + errors = 0 + + for model_id in sorted(needed_models): + if use_modern: + raw = load_model_modern(modern_reader, model_id) + else: + raw = cache.get(MODEL_INDEX, model_id) + if raw is not None: + try: + raw = gzip.decompress(raw) + except Exception: + pass + + if raw is None: + print(f" warning: model {model_id} not in cache") + errors += 1 + continue + + model = decode_model(model_id, raw) + if model is None: + print(f" warning: failed to decode model {model_id}") + errors += 1 + continue + + decoded_models.append(model) + + # combine body models + wield models (recolored) + inv models (raw) + all_models = body_models + wield_models + decoded_models + + print( + f"\ndecoded {len(decoded_models)} inv + {len(wield_models)} wield " + f"+ {len(body_models)} body models, {errors} errors" + ) + + # print stats + total_verts = sum(m.vertex_count for m in all_models) + total_faces = sum(m.face_count for m in all_models) + print(f"total: {total_verts} vertices, {total_faces} faces") + + # write binary output (body + equipment models) + write_models_binary(args.output, all_models) + file_size = args.output.stat().st_size + print(f"\nwrote {file_size:,} bytes to {args.output}") + + # write C headers + write_item_model_header(args.header, mappings) + print(f"wrote {args.header}") + + write_player_model_header( + args.header.parent / "player_models.h", + body_part_model_ids, + mappings, + item_defs, + ) + print(f"wrote {args.header.parent / 'player_models.h'}") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_npcs.py b/ocean/osrs/scripts/export_npcs.py new file mode 100644 index 0000000000..9edd830a37 --- /dev/null +++ b/ocean/osrs/scripts/export_npcs.py @@ -0,0 +1,547 @@ +"""Export NPC models at their spawn positions from a 317-format OSRS cache. + +Reads NPC definitions from npc.dat/npc.idx in the config archive (model IDs, +size, recolors), spawn positions from npc_spawns.json, decodes 3D models from +cache index 1, merges multi-model NPCs, and outputs a binary .npcs file for +the raylib viewer. + +NPCs use the same OBJ2 binary format as objects (with texture atlas support). + +Usage: + uv run python scripts/export_npcs.py \ + --cache ../reference/tarnish/game-server/data/cache \ + --output data/wilderness.npcs +""" + +import argparse +import copy +import gzip +import io +import json +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from export_collision_map import ( + CONFIG_INDEX, + CacheReader, + _read_string, + decode_archive, + hash_archive_name, +) +from export_models import ( + MODEL_INDEX, + ModelData, + decode_model, + expand_model, + hsl15_to_rgb, + load_texture_average_colors, +) +from export_objects import sample_height_bilinear +from export_terrain import RegionTerrain, build_heightmap, parse_terrain_full +from export_textures import ( + TextureAtlas, + build_atlas, + load_all_texture_sprites, + write_atlas_binary, +) + +# tarnish server data paths (relative to cache dir) +NPC_SPAWNS_PATH = Path("def/npc/npc_spawns.json") + +# binary format constants +OBJ2_MAGIC = 0x4F424A32 # same format as objects v2 + + +@dataclass +class NpcDef: + """NPC definition decoded from cache npc.dat/npc.idx.""" + + npc_id: int = 0 + name: str = "" + model_ids: list[int] = field(default_factory=list) + size: int = 1 + standing_anim: int = -1 + walk_anim: int = -1 + combat_level: int = 0 + width_scale: int = 128 + height_scale: int = 128 + recolor_src: list[int] = field(default_factory=list) + recolor_dst: list[int] = field(default_factory=list) + + +@dataclass +class NpcSpawn: + """NPC spawn position from npc_spawns.json.""" + + npc_id: int = 0 + world_x: int = 0 + world_y: int = 0 + height: int = 0 + facing: str = "SOUTH" + + +def decode_npc_definitions(cache: CacheReader) -> dict[int, NpcDef]: + """Decode NPC definitions from cache config archive (npc.dat/npc.idx).""" + raw = cache.get(CONFIG_INDEX, 2) + if raw is None: + sys.exit("could not read config archive") + + archive = decode_archive(raw) + + npc_hash = hash_archive_name("npc.dat") & 0xFFFFFFFF + idx_hash = hash_archive_name("npc.idx") & 0xFFFFFFFF + + npc_data = archive.get(npc_hash) or archive.get(hash_archive_name("npc.dat")) + npc_idx = archive.get(idx_hash) or archive.get(hash_archive_name("npc.idx")) + + if npc_data is None or npc_idx is None: + sys.exit("npc.dat/npc.idx not found in config archive") + + buf = io.BytesIO(npc_data) + idx_buf = io.BytesIO(npc_idx) + + total = struct.unpack(">H", idx_buf.read(2))[0] + defs: dict[int, NpcDef] = {} + + for npc_id in range(total): + d = NpcDef(npc_id=npc_id) + + while True: + opcode_byte = buf.read(1) + if not opcode_byte: + break + opcode = opcode_byte[0] + + if opcode == 0: + break + elif opcode == 1: + count = buf.read(1)[0] + d.model_ids = [struct.unpack(">H", buf.read(2))[0] for _ in range(count)] + elif opcode == 2: + d.name = _read_string(buf) + elif opcode == 3: + _read_string(buf) # description + elif opcode == 12: + d.size = struct.unpack(">b", buf.read(1))[0] + elif opcode == 13: + d.standing_anim = struct.unpack(">H", buf.read(2))[0] + elif opcode == 14: + d.walk_anim = struct.unpack(">H", buf.read(2))[0] + elif opcode == 15: + buf.read(2) # unknown + elif opcode == 16: + buf.read(2) # unknown + elif opcode == 17: + # walk + turn animations (4 ushorts) + buf.read(8) + elif opcode == 18: + buf.read(2) # unknown + elif 30 <= opcode <= 34: + _read_string(buf) # actions[0..4] + elif opcode == 40: + count = buf.read(1)[0] + for _ in range(count): + d.recolor_src.append(struct.unpack(">H", buf.read(2))[0]) + d.recolor_dst.append(struct.unpack(">H", buf.read(2))[0]) + elif opcode == 41: + count = buf.read(1)[0] + for _ in range(count): + buf.read(4) # retexture pairs + elif opcode == 60: + count = buf.read(1)[0] + for _ in range(count): + buf.read(2) # chathead model IDs + elif 90 <= opcode <= 92: + buf.read(2) # unknown ushorts + elif opcode == 93: + pass # drawMapDot = false + elif opcode == 95: + d.combat_level = struct.unpack(">H", buf.read(2))[0] + elif opcode == 97: + d.width_scale = struct.unpack(">H", buf.read(2))[0] + elif opcode == 98: + d.height_scale = struct.unpack(">H", buf.read(2))[0] + elif opcode == 99: + pass # isVisible + elif opcode == 100: + buf.read(1) # lightModifier + elif opcode == 101: + buf.read(1) # shadowModifier * 5... actually just 1 byte in 317 + elif opcode == 102: + # head icon sprite — read bitfield then per-set data + bitfield = buf.read(1)[0] + for bit in range(8): + if bitfield & (1 << bit): + # BigSmart2 for archive ID + peek = buf.read(1)[0] + if peek < 128: + pass # 1-byte smart, already consumed + else: + buf.read(1) # second byte of 2-byte smart + # sprite index (unsigned smart) + peek2 = buf.read(1)[0] + if peek2 < 128: + pass + else: + buf.read(1) + elif opcode == 103: + buf.read(2) # rotation + elif opcode == 106: + # morph: varbit(2) + varp(2) + count(1) + children(count+1 * 2) + buf.read(2) # varbit + buf.read(2) # varp + count = buf.read(1)[0] + for _ in range(count + 1): + buf.read(2) + elif opcode == 107: + pass # isInteractable + elif opcode == 109: + pass # rotation flag + elif opcode == 111: + pass # pet flag + elif opcode == 114: + buf.read(2) # unknown + elif opcode == 115: + buf.read(8) # 4x ushort + elif opcode == 116: + buf.read(2) # unknown + elif opcode == 117: + buf.read(8) # 4x ushort + elif opcode == 118: + # morph variant with default child + buf.read(2) # varbit + buf.read(2) # varp + buf.read(2) # default child + count = buf.read(1)[0] + for _ in range(count + 1): + buf.read(2) + elif opcode == 249: + count = buf.read(1)[0] + for _ in range(count): + is_string = buf.read(1)[0] + buf.read(3) # key + if is_string: + _read_string(buf) + else: + buf.read(4) + else: + print(f" warning: unknown npc opcode {opcode} at npc {npc_id}", file=sys.stderr) + break + + if d.model_ids: + defs[npc_id] = d + + return defs + + +def load_npc_spawns(data_dir: Path) -> list[NpcSpawn]: + """Load NPC spawn positions from npc_spawns.json.""" + spawns_path = data_dir / NPC_SPAWNS_PATH + if not spawns_path.exists(): + sys.exit(f"npc_spawns.json not found at {spawns_path}") + + with open(spawns_path) as f: + raw = json.load(f) + + spawns = [] + for entry in raw: + spawns.append(NpcSpawn( + npc_id=entry["id"], + world_x=entry["position"]["x"], + world_y=entry["position"]["y"], + height=entry["position"]["height"], + facing=entry.get("facing", "SOUTH"), + )) + return spawns + + +def merge_models(models: list[ModelData]) -> ModelData: + """Merge multiple ModelData into a single composite model. + + Concatenates vertices, faces, and colors. Face indices are offset + by the cumulative vertex count of preceding models. + """ + if len(models) == 1: + return models[0] + + merged = ModelData(model_id=models[0].model_id) + vert_offset = 0 + + for md in models: + merged.vertices_x.extend(md.vertices_x) + merged.vertices_y.extend(md.vertices_y) + merged.vertices_z.extend(md.vertices_z) + merged.face_a.extend(a + vert_offset for a in md.face_a) + merged.face_b.extend(b + vert_offset for b in md.face_b) + merged.face_c.extend(c + vert_offset for c in md.face_c) + merged.face_colors.extend(md.face_colors) + merged.face_textures.extend(md.face_textures) + merged.vertex_count += md.vertex_count + merged.face_count += md.face_count + vert_offset += md.vertex_count + + return merged + + +def apply_recolors(md: ModelData, src: list[int], dst: list[int]) -> None: + """Apply recolor pairs to a model's face colors (in-place).""" + for i, color in enumerate(md.face_colors): + for s, d in zip(src, dst): + if color == s: + md.face_colors[i] = d + break + + +def apply_scale(md: ModelData, width_scale: int, height_scale: int) -> None: + """Apply NPC width/height scale to model vertices (in-place). + + Default scale is 128 = 1.0x. Values >128 enlarge, <128 shrink. + Width scale affects X and Z, height scale affects Y. + """ + if width_scale == 128 and height_scale == 128: + return + + ws = width_scale / 128.0 + hs = height_scale / 128.0 + + for i in range(md.vertex_count): + md.vertices_x[i] = int(md.vertices_x[i] * ws) + md.vertices_y[i] = int(md.vertices_y[i] * hs) + md.vertices_z[i] = int(md.vertices_z[i] * ws) + + +def facing_to_rotation(facing: str) -> int: + """Convert facing direction string to OSRS rotation (0=W, 1=N, 2=E, 3=S).""" + return {"WEST": 0, "NORTH": 1, "EAST": 2, "SOUTH": 3}.get(facing, 3) + + +def rotate_model_90(md: ModelData) -> None: + """Rotate model 90 degrees clockwise around Y axis (in-place). + + Maps (x, z) -> (z, -x). One 90-degree CW step. + """ + for i in range(md.vertex_count): + x = md.vertices_x[i] + z = md.vertices_z[i] + md.vertices_x[i] = z + md.vertices_z[i] = -x + + +def main() -> None: + parser = argparse.ArgumentParser(description="export OSRS NPC models from 317 cache") + parser.add_argument("--cache", type=Path, default=Path("../reference/tarnish/game-server/data/cache")) + parser.add_argument("--output", type=Path, default=Path("data/wilderness.npcs")) + parser.add_argument("--radius", type=int, default=3, help="region radius around fight area center") + args = parser.parse_args() + + if not args.cache.exists(): + sys.exit(f"cache directory not found: {args.cache}") + + # data dir is parent of cache dir (game-server/data/) + data_dir = args.cache.parent + + print(f"reading cache from {args.cache}") + cache = CacheReader(args.cache) + + print("decoding NPC definitions...") + npc_defs = decode_npc_definitions(cache) + print(f" {len(npc_defs)} NPCs with models") + + print("loading NPC spawns...") + all_spawns = load_npc_spawns(data_dir) + print(f" {len(all_spawns)} total spawns") + + # filter to our export area (same region logic as objects) + center_rx, center_ry = 47, 55 + r = args.radius + min_wx = (center_rx - r) * 64 + max_wx = (center_rx + r + 1) * 64 + min_wy = (center_ry - r) * 64 + max_wy = (center_ry + r + 1) * 64 + + spawns = [ + s for s in all_spawns + if min_wx <= s.world_x < max_wx and min_wy <= s.world_y < max_wy + and s.height == 0 # plane 0 only for now + ] + print(f" {len(spawns)} spawns in export area") + + # load map index for terrain heightmap + from export_collision_map import MAP_INDEX, load_map_index + region_defs = load_map_index(cache) + target_regions = { + (rd.region_x, rd.region_y): rd + for rd in region_defs + if center_rx - r <= rd.region_x <= center_rx + r + and center_ry - r <= rd.region_y <= center_ry + r + } + + # parse terrain for heightmap + print("parsing terrain for ground heights...") + terrain_parsed: dict[tuple[int, int], RegionTerrain] = {} + for (rx, ry), rd in sorted(target_regions.items()): + terrain_data = cache.get(MAP_INDEX, rd.terrain_file) + if terrain_data is None: + continue + try: + terrain_data = gzip.decompress(terrain_data) + except Exception: + continue + rt = parse_terrain_full(terrain_data, rx * 64, ry * 64) + rt.region_x = rx + rt.region_y = ry + terrain_parsed[(rx, ry)] = rt + + heightmap = build_heightmap(terrain_parsed) if terrain_parsed else None + + # load texture data for atlas + tex_colors = load_texture_average_colors(cache) + sprites = load_all_texture_sprites(cache) + atlas = build_atlas(sprites) if sprites else None + + if atlas: + atlas_path = args.output.with_suffix(".atlas") + write_atlas_binary(atlas_path, atlas) + print(f" atlas: {atlas.width}x{atlas.height}") + + # decode and place NPC models + print("decoding NPC models...") + model_cache: dict[int, ModelData] = {} + all_verts: list[float] = [] + all_colors: list[int] = [] + all_uvs: list[float] = [] + npc_count = 0 + missing_def = 0 + missing_model = 0 + + model_scale = 1.0 / 128.0 # OSRS model units to tile units + + for spawn in spawns: + npc_def = npc_defs.get(spawn.npc_id) + if npc_def is None: + missing_def += 1 + continue + + if not npc_def.model_ids: + missing_def += 1 + continue + + # decode all sub-models for this NPC + sub_models: list[ModelData] = [] + all_found = True + for mid in npc_def.model_ids: + if mid not in model_cache: + raw = cache.get(MODEL_INDEX, mid) + if raw is None: + all_found = False + break + try: + raw = gzip.decompress(raw) + except Exception: + all_found = False + break + md = decode_model(mid, raw) + if md is None: + all_found = False + break + model_cache[mid] = md + sub_models.append(copy.deepcopy(model_cache[mid])) + + if not all_found or not sub_models: + missing_model += 1 + continue + + # merge multi-model NPC into single model + merged = merge_models(sub_models) + + # apply recolors + if npc_def.recolor_src: + apply_recolors(merged, npc_def.recolor_src, npc_def.recolor_dst) + + # apply NPC scale + apply_scale(merged, npc_def.width_scale, npc_def.height_scale) + + # apply facing rotation + rotation = facing_to_rotation(spawn.facing) + for _ in range(rotation): + rotate_model_90(merged) + + # expand to per-vertex arrays (same as object exporter) + verts, colors, face_uvs = expand_model(merged, tex_colors, atlas) + + # position in world space + # NPC center: tile + 0.5 (or + size/2 for larger NPCs) + npc_size = max(1, npc_def.size) + center_off = npc_size / 2.0 + wx = float(spawn.world_x) + center_off + wz = -(float(spawn.world_y) + center_off) # negate for our coord system + + # sample height at NPC center (bilinear for smooth terrain like GE bowl) + center_world_x = float(spawn.world_x) + center_off + center_world_y = float(spawn.world_y) + center_off + ground_y = sample_height_bilinear(heightmap, center_world_x, center_world_y) if heightmap else 0.0 + + # winding fix: swap vertex 0 and 2 per triangle (same as objects) + for i in range(0, len(verts), 9): + for c in range(3): + verts[i + c], verts[i + 6 + c] = verts[i + 6 + c], verts[i + c] + colors[i // 3], colors[i // 3 + 2] = colors[i // 3 + 2], colors[i // 3] + uv_base = (i // 3) * 2 + face_uvs[uv_base], face_uvs[uv_base + 4] = face_uvs[uv_base + 4], face_uvs[uv_base] + face_uvs[uv_base + 1], face_uvs[uv_base + 5] = face_uvs[uv_base + 5], face_uvs[uv_base + 1] + + # transform to world coordinates + for i in range(0, len(verts), 3): + verts[i] = verts[i] * model_scale + wx + verts[i + 1] = verts[i + 1] * model_scale + ground_y + verts[i + 2] = -verts[i + 2] * model_scale + wz + + all_verts.extend(verts) + # flatten color tuples to flat RGBA bytes + for rgba in colors: + all_colors.extend(rgba) + all_uvs.extend(face_uvs) + npc_count += 1 + + total_verts = len(all_verts) // 3 + total_tris = total_verts // 3 + print(f" {npc_count} NPCs placed, {missing_def} missing defs, {missing_model} missing models") + print(f" {total_verts:,} vertices, {total_tris:,} triangles") + print(f" {len(model_cache)} unique models decoded") + + # write binary (same OBJ2 format as objects) + args.output.parent.mkdir(parents=True, exist_ok=True) + + min_wx_out = min((s.world_x for s in spawns), default=0) + min_wy_out = min((s.world_y for s in spawns), default=0) + has_textures = atlas is not None + + with open(args.output, "wb") as f: + f.write(struct.pack(" dict[int, LocDef]: + """Decode object definitions from loc.dat/loc.idx, capturing model IDs. + + Uses the idx file to build an offset table for each definition (matching + the Java client's streamIndices approach). Each definition is read from + its own slice of loc.dat, preventing any opcode parsing bug from + corrupting subsequent definitions. + """ + raw = cache.get(CONFIG_INDEX, CONFIG_ARCHIVE) + if raw is None: + sys.exit("could not read config archive") + + archive = decode_archive(raw) + loc_hash = hash_archive_name("loc.dat") & 0xFFFFFFFF + idx_hash = hash_archive_name("loc.idx") & 0xFFFFFFFF + + loc_data = archive.get(loc_hash) or archive.get(hash_archive_name("loc.dat")) + loc_idx = archive.get(idx_hash) or archive.get(hash_archive_name("loc.idx")) + + if loc_data is None or loc_idx is None: + sys.exit("loc.dat/loc.idx not found in config archive") + + idx_buf = io.BytesIO(loc_idx) + total = struct.unpack(">H", idx_buf.read(2))[0] + + # build offset table from idx (each entry is uint16 size) + offsets: list[int] = [] + pos = 0 + for _ in range(total): + size_bytes = idx_buf.read(2) + if len(size_bytes) < 2: + break + size = struct.unpack(">H", size_bytes)[0] + if size == 0xFFFF: + break + offsets.append(pos) + pos += size + + defs: dict[int, LocDef] = {} + + for obj_id in range(len(offsets)): + # read this definition's slice from loc.dat + start = offsets[obj_id] + end = offsets[obj_id + 1] if obj_id + 1 < len(offsets) else len(loc_data) + buf = io.BytesIO(loc_data[start:end]) + + d = LocDef(obj_id=obj_id) + + while True: + opcode = buf.read(1) + if not opcode: + break + opcode = opcode[0] + + if opcode == 0: + break + elif opcode == 1: + # model IDs with type contexts (first opcode wins, skip if already set) + count = buf.read(1)[0] + if not d.model_ids: + d.has_typed_models = True + for _ in range(count): + mid = struct.unpack(">H", buf.read(2))[0] + mtype = buf.read(1)[0] + d.model_ids.append(mid) + d.model_types.append(mtype) + else: + buf.read(count * 3) # skip + elif opcode == 2: + d.name = _read_string(buf) + elif opcode == 3: + pass # description: Java reads 0 bytes for this opcode + elif opcode == 5: + # model IDs without types (first opcode wins, skip if already set) + count = buf.read(1)[0] + if not d.model_ids: + d.has_typed_models = False + for _ in range(count): + mid = struct.unpack(">H", buf.read(2))[0] + d.model_ids.append(mid) + d.model_types.append(10) + else: + buf.read(count * 2) # skip + elif opcode == 14: + d.width = buf.read(1)[0] + elif opcode == 15: + d.length = buf.read(1)[0] + elif opcode == 17: + d.solid = False + elif opcode == 18: + pass # impenetrable + elif opcode == 19: + buf.read(1) # hasActions + elif opcode == 21: + d.contoured_ground = True + elif opcode == 22: + pass # nonFlatShading + elif opcode == 23: + pass # modelClipped + elif opcode == 24: + buf.read(2) # animation id + elif opcode == 28: + d.decor_offset = buf.read(1)[0] + elif opcode == 29: + buf.read(1) # ambient + elif opcode == 39: + buf.read(1) # contrast + elif 30 <= opcode <= 34: + _read_string(buf) # actions + elif opcode == 40: + # recolors: first short = modifiedModelColors (color to FIND in model) + # second short = originalModelColors (color to REPLACE WITH) + # naming is backwards in the OSRS client — "modified" is the source, + # "original" is the replacement. confirmed via Model.recolor(found, replace) + # call: model.recolor(modifiedModelColors[i], originalModelColors[i]) + count = buf.read(1)[0] + for _ in range(count): + d.recolor_from.append(struct.unpack(">H", buf.read(2))[0]) + d.recolor_to.append(struct.unpack(">H", buf.read(2))[0]) + elif opcode == 41: + count = buf.read(1)[0] + for _ in range(count): + buf.read(2) # texture from + buf.read(2) # texture to + elif opcode == 60: + buf.read(2) # mapAreaId + elif opcode == 61: + buf.read(2) # category + elif opcode == 62: + d.rotated = True + elif opcode == 64: + pass # shadow=false + elif opcode == 65: + d.model_size_x = struct.unpack(">H", buf.read(2))[0] + elif opcode == 66: + d.model_size_h = struct.unpack(">H", buf.read(2))[0] + elif opcode == 67: + d.model_size_y = struct.unpack(">H", buf.read(2))[0] + elif opcode == 68: + buf.read(2) # mapscene + elif opcode == 69: + buf.read(1) # surroundings + elif opcode == 70: + d.offset_x = struct.unpack(">H", buf.read(2))[0] + elif opcode == 71: + d.offset_h = struct.unpack(">H", buf.read(2))[0] + elif opcode == 72: + d.offset_y = struct.unpack(">H", buf.read(2))[0] + elif opcode == 73: + pass # obstructsGround + elif opcode == 74: + d.solid = False + elif opcode == 75: + buf.read(1) # supportItems + elif opcode == 77: + buf.read(2) # varbit + buf.read(2) # varp + count = buf.read(1)[0] + for _ in range(count + 1): + buf.read(2) + elif opcode == 78: + buf.read(2) # ambient sound + buf.read(1) + elif opcode == 79: + buf.read(2) + buf.read(2) + buf.read(1) + count = buf.read(1)[0] + for _ in range(count): + buf.read(2) + elif opcode == 81: + buf.read(1) # contoured ground percent + elif opcode == 82: + buf.read(2) # map icon + elif opcode == 89: + pass # randomize animation + elif opcode == 92: + buf.read(2) # varbit + buf.read(2) # varp + buf.read(2) # default + count = buf.read(1)[0] + for _ in range(count + 1): + buf.read(2) + elif opcode == 249: + count = buf.read(1)[0] + for _ in range(count): + is_string = buf.read(1)[0] == 1 + buf.read(3) # 3-byte medium + if is_string: + _read_string(buf) + else: + buf.read(4) + # unknown opcodes: skip (read 0 bytes), matching Java behavior + + if d.model_ids: + defs[obj_id] = d + + return defs + + +def _read_modern_obj_string(buf: io.BytesIO) -> str: + """Read null-terminated string from modern object definition.""" + chars = [] + while True: + b = buf.read(1) + if not b or b[0] == 0: + break + chars.append(chr(b[0])) + return "".join(chars) + + +def decode_loc_definitions_modern(reader: ModernCacheReader) -> dict[int, LocDef]: + """Decode object definitions from modern cache, capturing model IDs and transforms. + + Group 6 in config index (2) contains object definitions. Each definition is + stored as a separate file within the group, keyed by object ID. + """ + files = reader.read_group(2, 6) + if files is None: + sys.exit("could not read object definitions from modern cache") + + defs: dict[int, LocDef] = {} + + for obj_id, data in files.items(): + d = LocDef(obj_id=obj_id) + buf = io.BytesIO(data) + + while True: + raw = buf.read(1) + if not raw: + break + opcode = raw[0] + + if opcode == 0: + break + elif opcode == 1: + count = buf.read(1)[0] + d.has_typed_models = True + for _ in range(count): + mid = struct.unpack(">H", buf.read(2))[0] + mtype = buf.read(1)[0] + d.model_ids.append(mid) + d.model_types.append(mtype) + elif opcode == 2: + d.name = _read_modern_obj_string(buf) + elif opcode == 5: + count = buf.read(1)[0] + d.has_typed_models = False + for _ in range(count): + mid = struct.unpack(">H", buf.read(2))[0] + d.model_ids.append(mid) + d.model_types.append(10) + elif opcode == 14: + d.width = buf.read(1)[0] + elif opcode == 15: + d.length = buf.read(1)[0] + elif opcode == 17: + d.solid = False + elif opcode == 18: + pass # impenetrable + elif opcode == 19: + buf.read(1) # interactType + elif opcode == 21: + d.contoured_ground = True + elif opcode == 22: + pass # nonFlatShading + elif opcode == 23: + pass # modelClipped + elif opcode == 24: + struct.unpack(">H", buf.read(2)) # animation id + elif opcode == 27: + pass # clipType = 1 + elif opcode == 28: + d.decor_offset = buf.read(1)[0] + elif opcode == 29: + buf.read(1) # ambient + elif opcode in range(30, 35): + _read_modern_obj_string(buf) # actions + elif opcode == 39: + buf.read(1) # contrast + elif opcode == 40: + count = buf.read(1)[0] + for _ in range(count): + d.recolor_from.append(struct.unpack(">H", buf.read(2))[0]) + d.recolor_to.append(struct.unpack(">H", buf.read(2))[0]) + elif opcode == 41: + count = buf.read(1)[0] + for _ in range(count): + buf.read(2) # texture from + buf.read(2) # texture to + elif opcode == 60: + buf.read(2) # mapAreaId + elif opcode == 61: + buf.read(2) # category + elif opcode == 62: + d.rotated = True + elif opcode == 64: + pass # shadow=false + elif opcode == 65: + d.model_size_x = struct.unpack(">H", buf.read(2))[0] + elif opcode == 66: + d.model_size_h = struct.unpack(">H", buf.read(2))[0] + elif opcode == 67: + d.model_size_y = struct.unpack(">H", buf.read(2))[0] + elif opcode == 68: + buf.read(2) # mapscene + elif opcode == 69: + buf.read(1) # surroundings + elif opcode == 70: + d.offset_x = struct.unpack(">h", buf.read(2))[0] + elif opcode == 71: + d.offset_h = struct.unpack(">h", buf.read(2))[0] + elif opcode == 72: + d.offset_y = struct.unpack(">h", buf.read(2))[0] + elif opcode == 73: + pass # obstructsGround + elif opcode == 74: + d.solid = False + elif opcode == 75: + buf.read(1) # supportItems + elif opcode == 77: + buf.read(2) # varbit + buf.read(2) # varp + count = buf.read(1)[0] + for _ in range(count + 1): + buf.read(2) + elif opcode == 78: + buf.read(2) # ambient sound + buf.read(1) # distance + buf.read(1) # retain + elif opcode == 79: + buf.read(2) + buf.read(2) + buf.read(1) # distance + buf.read(1) # retain + count = buf.read(1)[0] + for _ in range(count): + buf.read(2) + elif opcode == 81: + buf.read(1) # contoured ground percent + elif opcode == 82: + buf.read(2) # map icon + elif opcode == 89: + pass # randomize animation + elif opcode == 90: + pass # fixLocAnimAfterLocChange + elif opcode == 91: + buf.read(1) # bgsoundDropoffEasing + elif opcode == 92: + buf.read(2) # varbit + buf.read(2) # varp + buf.read(2) # default + count = buf.read(1)[0] + for _ in range(count + 1): + buf.read(2) + elif opcode == 93: + buf.read(1) + buf.read(2) + buf.read(1) + buf.read(2) + elif opcode == 94: + pass # unknown94 + elif opcode == 95: + buf.read(1) # crossWorldSound + elif opcode == 96: + buf.read(1) # thickness/raise + elif opcode == 249: + count = buf.read(1)[0] + for _ in range(count): + is_string = buf.read(1)[0] == 1 + buf.read(3) # 3-byte key + if is_string: + _read_modern_obj_string(buf) + else: + buf.read(4) + else: + # unknown opcode — stop parsing to avoid desync + break + + if d.model_ids: + defs[obj_id] = d + + print(f" {len(defs)} definitions with models (from {len(files)} total)") + return defs + + + +@dataclass +class PlacedObject: + """A single placed object in the world.""" + + obj_id: int = 0 + world_x: int = 0 + world_y: int = 0 + height: int = 0 + obj_type: int = 0 # 0-22 + rotation: int = 0 # 0-3 (W/N/E/S = 0/90/180/270 degrees) + + +def parse_object_placements( + data: bytes, + base_x: int, + base_y: int, +) -> list[PlacedObject]: + """Parse object placement binary for one region.""" + buf = io.BytesIO(data) + obj_id = -1 + placements: list[PlacedObject] = [] + + while True: + obj_id_offset = read_smart(buf) + if obj_id_offset == 0: + break + obj_id += obj_id_offset + obj_pos_info = 0 + + while True: + pos_offset = read_smart(buf) + if pos_offset == 0: + break + obj_pos_info += pos_offset - 1 + + raw_byte = buf.read(1) + if not raw_byte: + return placements + info = raw_byte[0] + + local_y = obj_pos_info & 0x3F + local_x = (obj_pos_info >> 6) & 0x3F + height = (obj_pos_info >> 12) & 0x3 + + obj_type = info >> 2 + rotation = info & 0x3 + + if obj_type not in EXPORTED_TYPES: + continue + + # only export plane 0 for now — plane 1+ objects (upper floors, roofing) + # need corresponding floor/ceiling geometry to look right. + # the heightmaps dict already supports multi-plane; just widen this + # filter when floor rendering is added. + if height != 0: + continue + + placements.append( + PlacedObject( + obj_id=obj_id, + world_x=base_x + local_x, + world_y=base_y + local_y, + height=height, + obj_type=obj_type, + rotation=rotation, + ) + ) + + return placements + + +def parse_object_placements_modern( + data: bytes, + base_x: int, + base_y: int, +) -> list[PlacedObject]: + """Parse modern-format object placement data for one region. + + Uses extended smart for object ID deltas (IDs > 32767). + """ + buf = io.BytesIO(data) + obj_id = -1 + placements: list[PlacedObject] = [] + + while True: + obj_id_offset = _read_extended_smart(buf) + if obj_id_offset == 0: + break + obj_id += obj_id_offset + obj_pos_info = 0 + + while True: + pos_offset = read_smart(buf) + if pos_offset == 0: + break + obj_pos_info += pos_offset - 1 + + raw_byte = buf.read(1) + if not raw_byte: + return placements + info = raw_byte[0] + + local_y = obj_pos_info & 0x3F + local_x = (obj_pos_info >> 6) & 0x3F + height = (obj_pos_info >> 12) & 0x3 + + obj_type = info >> 2 + rotation = info & 0x3 + + if obj_type not in EXPORTED_TYPES: + continue + if height != 0: + continue + + placements.append( + PlacedObject( + obj_id=obj_id, + world_x=base_x + local_x, + world_y=base_y + local_y, + height=height, + obj_type=obj_type, + rotation=rotation, + ) + ) + + return placements + + +# --- model transform helpers --- + + +def rotate_model_90(model: ModelData) -> None: + """Rotate model 90 degrees clockwise (OSRS rotation direction). + + In OSRS coordinate space: new_x = z, new_z = -x (clockwise when viewed from above). + """ + for i in range(model.vertex_count): + old_x = model.vertices_x[i] + old_z = model.vertices_z[i] + model.vertices_x[i] = old_z + model.vertices_z[i] = -old_x + + +def mirror_model(model: ModelData) -> None: + """Mirror model on Z axis and swap face winding. Matches OSRS Model.mirror().""" + for i in range(model.vertex_count): + model.vertices_z[i] = -model.vertices_z[i] + for i in range(model.face_count): + model.face_a[i], model.face_c[i] = model.face_c[i], model.face_a[i] + + +def apply_recolors(model: ModelData, loc: LocDef) -> None: + """Apply color remapping from object definition to model face colors.""" + if not loc.recolor_from: + return + remap = dict(zip(loc.recolor_from, loc.recolor_to)) + for i in range(model.face_count): + if i < len(model.face_colors) and model.face_colors[i] in remap: + model.face_colors[i] = remap[model.face_colors[i]] + + +def scale_model(model: ModelData, size_x: int, size_h: int, size_y: int) -> None: + """Scale model vertices (128 = 1.0x). + + Matches the OSRS client call: model.scale(modelSizeX, modelSizeY, modelHeight) + where Model.scale(x, z, y) — second param is Z, third is Y. + So: size_x→X, size_y→Z(depth), size_h→Y(height). + """ + if size_x == 128 and size_h == 128 and size_y == 128: + return + for i in range(model.vertex_count): + model.vertices_x[i] = model.vertices_x[i] * size_x // 128 + model.vertices_y[i] = model.vertices_y[i] * size_h // 128 + model.vertices_z[i] = model.vertices_z[i] * size_y // 128 + + +def placement_type_to_model_type(obj_type: int) -> int: + """Map placement type to the model type requested from the definition. + + Matches the OSRS client's addLocation → getModelSharelight calls: + - Types 4-8 (wall decorations) all request model type 4 + - Type 11 requests model type 10 (same model, different scene height) + - All other types request their own type number + """ + if 4 <= obj_type <= 8: + return 4 + if obj_type == 11: + return 10 + return obj_type + + +def get_model_for_type(loc: LocDef, obj_type: int) -> int | None: + """Get the model ID for a given placement type from the object definition. + + Matches the OSRS client's ObjectDefinition.model() logic: + - If modelTypes is null (opcode 5 only), model is for type 10 (props) only. + - If modelTypes exists (opcode 1), requires exact type match. Returns None otherwise. + """ + if not loc.model_ids: + return None + + model_type = placement_type_to_model_type(obj_type) + + if not loc.has_typed_models: + # opcode 5: untyped models, only valid for model type 10 (props) + if model_type != 10: + return None + return loc.model_ids[0] + + # opcode 1: typed models, require exact match + for mid, mtype in zip(loc.model_ids, loc.model_types): + if mtype == model_type: + return mid + + return None + + +# --- binary output format --- + +OBJS_MAGIC = 0x4F424A53 # "OBJS" + + +@dataclass +class ExpandedPlacement: + """A placed object with its expanded vertex data ready for output.""" + + world_x: int = 0 + world_y: int = 0 + obj_type: int = 0 + vertex_count: int = 0 + face_count: int = 0 + vertices: list[float] = field(default_factory=list) # flat x,y,z + colors: list[tuple[int, int, int, int]] = field(default_factory=list) + uvs: list[float] = field(default_factory=list) # flat u,v per vertex + + +OBJ2_MAGIC = 0x4F424A32 # "OBJ2" — v2 format with texture coordinates + + +def write_objects_binary( + output_path: Path, + placements: list[ExpandedPlacement], + min_world_x: int, + min_world_y: int, + has_textures: bool = False, +) -> None: + """Write placed objects to binary .objects file. + + v2 format (OBJ2, when has_textures=True): + magic: uint32 "OBJ2" + placement_count: uint32 + min_world_x: int32 + min_world_y: int32 + total_vertex_count: uint32 + vertices: float32[total_vertex_count * 3] + colors: uint8[total_vertex_count * 4] + texcoords: float32[total_vertex_count * 2] + + v1 format (OBJS, when has_textures=False): + same as above without texcoords + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + + total_verts = sum(p.vertex_count for p in placements) + magic = OBJ2_MAGIC if has_textures else OBJS_MAGIC + + with open(output_path, "wb") as f: + f.write(struct.pack(" float: + """Sample terrain height at a world tile corner. Returns height in world units.""" + hm_min_x, hm_min_y, hm_w, hm_h, heights = hm + lx = world_x - hm_min_x + ly = world_y - hm_min_y + if 0 <= lx < hm_w and 0 <= ly < hm_h: + return heights[lx + ly * hm_w] + return 0.0 + + +def sample_height_bilinear( + hm: tuple[int, int, int, int, list[float]], + world_x: float, + world_y: float, +) -> float: + """Bilinear interpolation of terrain height at fractional world coords. + + Matches the OSRS client's Model.hillskew() which interpolates between + 4 tile corner heights using sub-tile fractions. + """ + hm_min_x, hm_min_y, hm_w, hm_h, heights = hm + fx = world_x - hm_min_x + fy = world_y - hm_min_y + + tx = int(fx) + ty = int(fy) + frac_x = fx - tx + frac_y = fy - ty + + # clamp to valid range (edge tiles use nearest neighbor) + tx = max(0, min(tx, hm_w - 2)) + ty = max(0, min(ty, hm_h - 2)) + + h00 = heights[tx + ty * hm_w] + h10 = heights[(tx + 1) + ty * hm_w] + h01 = heights[tx + (ty + 1) * hm_w] + h11 = heights[(tx + 1) + (ty + 1) * hm_w] + + h_south = h00 * (1.0 - frac_x) + h10 * frac_x + h_north = h01 * (1.0 - frac_x) + h11 * frac_x + return h_south * (1.0 - frac_y) + h_north * frac_y + + +def _load_model_317(cache: CacheReader, model_id: int) -> bytes | None: + """Load raw model bytes from 317 cache.""" + raw = cache.get(MODEL_INDEX, model_id) + if raw is None: + return None + try: + return gzip.decompress(raw) + except Exception: + return None + + +def process_placements( + placements: list[PlacedObject], + loc_defs: dict[int, LocDef], + model_loader: "Callable[[int], bytes | None]", + model_cache: dict[int, ModelData], + heightmaps: dict[int, tuple[int, int, int, int, list[float]]] | None = None, + tex_colors: dict[int, int] | None = None, + atlas: TextureAtlas | None = None, +) -> list[ExpandedPlacement]: + """Process raw placements into expanded vertex data ready for rendering. + + model_loader: callable(model_id) -> raw bytes or None. abstracts 317 vs modern cache. + """ + results: list[ExpandedPlacement] = [] + skipped = 0 + model_miss = 0 + + for po in placements: + loc = loc_defs.get(po.obj_id) + if loc is None: + skipped += 1 + continue + + model_id = get_model_for_type(loc, po.obj_type) + if model_id is None: + skipped += 1 + continue + + # get or decode model + if model_id not in model_cache: + raw = model_loader(model_id) + if raw is None: + model_miss += 1 + continue + md = decode_model(model_id, raw) + if md is None: + model_miss += 1 + continue + model_cache[model_id] = md + + # deep copy the model so we can transform it without affecting cache + src = model_cache[model_id] + md = ModelData( + model_id=src.model_id, + vertex_count=src.vertex_count, + face_count=src.face_count, + vertices_x=list(src.vertices_x), + vertices_y=list(src.vertices_y), + vertices_z=list(src.vertices_z), + face_a=list(src.face_a), + face_b=list(src.face_b), + face_c=list(src.face_c), + face_colors=list(src.face_colors), + face_textures=list(src.face_textures), + ) + + # apply color remapping + apply_recolors(md, loc) + + # apply scale + scale_model(md, loc.model_size_x, loc.model_size_h, loc.model_size_y) + + # for type 2 (diagonal walls), OSRS renders two model halves: + # half 1: rotation = 4 + r (mirrored + r rotations) + # half 2: rotation = (r + 1) & 3 (next rotation, no mirror) + # for all other types: single model with rotation r + if po.obj_type == 2: + rotations_to_emit = [4 + po.rotation, (po.rotation + 1) & 3] + else: + rotations_to_emit = [po.rotation] + + all_verts: list[float] = [] + all_colors: list[tuple[int, int, int, int]] = [] + all_uvs: list[float] = [] + + for eff_rot in rotations_to_emit: + # deep copy per rotation variant + md_copy = ModelData( + model_id=md.model_id, + vertex_count=md.vertex_count, + face_count=md.face_count, + vertices_x=list(md.vertices_x), + vertices_y=list(md.vertices_y), + vertices_z=list(md.vertices_z), + face_a=list(md.face_a), + face_b=list(md.face_b), + face_c=list(md.face_c), + face_colors=list(md.face_colors), + face_textures=list(md.face_textures), + ) + + # mirror if rotation > 3 (XOR with definition's rotated flag) + if loc.rotated ^ (eff_rot > 3): + mirror_model(md_copy) + + # apply N * 90-degree rotations + for _ in range(eff_rot % 4): + rotate_model_90(md_copy) + + v, c, uv = expand_model(md_copy, tex_colors, atlas) + all_verts.extend(v) + all_colors.extend(c) + all_uvs.extend(uv) + + verts = all_verts + colors = all_colors + face_uvs = all_uvs + + if not verts: + continue + + # position in world: center model on its footprint + # OSRS client: localX = (x << 7) + (sizeX << 6), i.e. x*128 + sizeX*64 + # in tile units: x + sizeX/2.0. Rotation 1 or 3 swaps width/length. + model_scale = 1.0 / 128.0 + w = loc.width + l = loc.length + if po.rotation == 1 or po.rotation == 3: + w, l = l, w + wx = float(po.world_x) + w / 2.0 + # negate Z for right-handed coords (OSRS +Y north = -Z) + wz = -(float(po.world_y) + l / 2.0) + + # type 5 wall decorations: offset away from parent wall + # direction vectors from MapRegion: {1,0,-1,0} for X, {0,-1,0,1} for Y + # default decorOffset=16 OSRS units; ideally from parent wall def + if po.obj_type == 5: + _deco_dir_x = (1, 0, -1, 0) + _deco_dir_y = (0, -1, 0, 1) + deco_off = loc.decor_offset * model_scale + wx += _deco_dir_x[po.rotation] * deco_off + wz -= _deco_dir_y[po.rotation] * deco_off + + # select heightmap for this object's plane + heightmap = heightmaps.get(po.height) if heightmaps else None + + # sample terrain height at placement center + ground_y = sample_heightmap(heightmap, po.world_x, po.world_y) if heightmap else 0.0 + + # contoured ground: per-vertex terrain height interpolation + # matches OSRS Model.hillskew() — bilinear interpolation at each vertex + use_contouring = heightmap and (loc.contoured_ground or po.obj_type == 22) + + # apply offsets from definition (in model units, scale them) + ox = float(loc.offset_x) * model_scale + oh = float(loc.offset_h) * model_scale + oy = float(loc.offset_y) * model_scale + + # transform vertices to world space + # negate model Z to match our world coords (OSRS +Z → our -Z) + # and swap triangle winding to preserve face normals after Z flip + for i in range(0, len(verts), 9): + # swap vertex 0 and vertex 2 within each triangle (reverses winding) + for c in range(3): + verts[i + c], verts[i + 6 + c] = verts[i + 6 + c], verts[i + c] + colors[i // 3], colors[i // 3 + 2] = colors[i // 3 + 2], colors[i // 3] + # swap UVs for vertex 0 and vertex 2 (same winding fix) + uv_base = (i // 3) * 2 + face_uvs[uv_base], face_uvs[uv_base + 4] = face_uvs[uv_base + 4], face_uvs[uv_base] + face_uvs[uv_base + 1], face_uvs[uv_base + 5] = face_uvs[uv_base + 5], face_uvs[uv_base + 1] + + for i in range(0, len(verts), 3): + vx = verts[i] * model_scale + wx + ox + vz = -verts[i + 2] * model_scale + wz - oy + verts[i] = vx + verts[i + 2] = vz + + if use_contouring: + # bilinear terrain height at this vertex's world position + # our world X maps directly to OSRS tile X + # our world -Z maps to OSRS tile Y (north) + vy_ground = sample_height_bilinear(heightmap, vx, -vz) + # ground decorations (type 22) get a small Y offset to prevent + # z-fighting with coplanar terrain — baked into vertex data since + # glPolygonOffset isn't reliable through raylib's draw pipeline + decal_offset = 0.01 if po.obj_type == 22 else 0.0 + verts[i + 1] = verts[i + 1] * model_scale + vy_ground + oh + decal_offset + else: + verts[i + 1] = verts[i + 1] * model_scale + ground_y + oh + + results.append( + ExpandedPlacement( + world_x=po.world_x, + world_y=po.world_y, + obj_type=po.obj_type, + vertex_count=len(verts) // 3, + face_count=len(verts) // 9, + vertices=verts, + colors=colors, + uvs=face_uvs, + ) + ) + + if skipped: + print(f" skipped {skipped} placements (no definition/model)", file=sys.stderr) + if model_miss: + print(f" {model_miss} model decode failures", file=sys.stderr) + + return results + + +def _build_and_write( + args: argparse.Namespace, + all_placements: list[PlacedObject], + loc_defs: dict[int, LocDef], + model_loader: Callable[[int], bytes | None], + terrain_parsed: dict[tuple[int, int], RegionTerrain], + tex_colors: dict[int, int], + atlas: TextureAtlas | None = None, +) -> None: + """Build geometry and write .objects binary (shared by 317 and modern paths).""" + # stitch region edges for smooth heightmap + if terrain_parsed: + stitch_region_edges(terrain_parsed) + + # build heightmaps per plane + heightmaps: dict[int, tuple[int, int, int, int, list[float]]] = {} + if terrain_parsed: + for plane in range(2): + hm = build_heightmap(terrain_parsed, target_plane=plane) + heightmaps[plane] = hm + hm0 = heightmaps[0] + print(f" heightmap: {hm0[2]}x{hm0[3]} tiles, origin ({hm0[0]}, {hm0[1]})") + + # count by type + type_counts: dict[int, int] = {} + for po in all_placements: + type_counts[po.obj_type] = type_counts.get(po.obj_type, 0) + 1 + for t in sorted(type_counts): + print(f" type {t:2d}: {type_counts[t]}") + + # process placements into expanded vertex data + print("decoding models and building geometry...") + model_geom_cache: dict[int, ModelData] = {} + expanded = process_placements( + all_placements, loc_defs, model_loader, model_geom_cache, + heightmaps=heightmaps or None, tex_colors=tex_colors, atlas=atlas, + ) + print(f" {len(expanded)} objects with geometry, {len(model_geom_cache)} unique models decoded") + + total_verts = sum(p.vertex_count for p in expanded) + total_tris = sum(p.face_count for p in expanded) + print(f" {total_verts:,} vertices, {total_tris:,} triangles") + + # compute bounds + min_wx = min((p.world_x for p in expanded), default=0) + min_wy = min((p.world_y for p in expanded), default=0) + + # write output + has_textures = atlas is not None + write_objects_binary(args.output, expanded, min_wx, min_wy, has_textures=has_textures) + file_size = args.output.stat().st_size + print(f"\nwrote {file_size:,} bytes to {args.output}") + + +def _main_317(args: argparse.Namespace) -> None: + """Export objects from 317-format cache.""" + if not args.cache.exists(): + sys.exit(f"cache directory not found: {args.cache}") + + print(f"reading cache from {args.cache}") + cache_reader = CacheReader(args.cache) + + print("loading object definitions...") + loc_defs = decode_loc_definitions(cache_reader) + print(f" {len(loc_defs)} definitions with models") + + print("loading map index...") + region_defs = load_map_index(cache_reader) + print(f" {len(region_defs)} regions in map index") + + # fight area center: region (47, 55) + center_rx, center_ry = 47, 55 + r = args.radius + + target_regions = { + (rd.region_x, rd.region_y): rd + for rd in region_defs + if center_rx - r <= rd.region_x <= center_rx + r + and center_ry - r <= rd.region_y <= center_ry + r + } + print(f" exporting {len(target_regions)} regions around ({center_rx}, {center_ry})") + + # parse all object placements + all_placements: list[PlacedObject] = [] + errors = 0 + + for (rx, ry), rd in sorted(target_regions.items()): + obj_data = cache_reader.get(MAP_INDEX, rd.object_file) + if obj_data is None: + errors += 1 + continue + + try: + obj_data = gzip.decompress(obj_data) + except Exception: + errors += 1 + continue + + base_x = rx * 64 + base_y = ry * 64 + placements = parse_object_placements(obj_data, base_x, base_y) + all_placements.extend(placements) + + print(f" {len(all_placements)} placements parsed, {errors} region errors") + + # parse terrain for heightmap (same regions) + print("parsing terrain for ground heights...") + terrain_parsed: dict[tuple[int, int], RegionTerrain] = {} + for (rx, ry), rd in sorted(target_regions.items()): + terrain_data = cache_reader.get(MAP_INDEX, rd.terrain_file) + if terrain_data is None: + continue + try: + terrain_data = gzip.decompress(terrain_data) + except Exception: + continue + rt = parse_terrain_full(terrain_data, rx * 64, ry * 64) + rt.region_x = rx + rt.region_y = ry + terrain_parsed[(rx, ry)] = rt + + # load texture average colors for textured face rendering (fallback) + tex_colors = load_texture_average_colors(cache_reader) + print(f" loaded {len(tex_colors)} texture average colors") + + # load texture sprites and build atlas + print("loading texture sprites...") + sprites = load_all_texture_sprites(cache_reader) + print(f" loaded {len(sprites)} texture sprites from cache") + + atlas = None + atlas_path = args.output.with_suffix(".atlas") + if sprites: + atlas = build_atlas(sprites) + print(f" atlas: {atlas.width}x{atlas.height}, {len(atlas.uv_map)} textures mapped") + write_atlas_binary(atlas_path, atlas) + atlas_size = atlas_path.stat().st_size + print(f" wrote {atlas_size:,} bytes to {atlas_path}") + + loader_317 = lambda mid: _load_model_317(cache_reader, mid) + _build_and_write(args, all_placements, loc_defs, loader_317, terrain_parsed, + tex_colors, atlas) + + +def _main_modern(args: argparse.Namespace) -> None: + """Export objects from modern OpenRS2 cache.""" + if not args.modern_cache.exists(): + sys.exit(f"modern cache directory not found: {args.modern_cache}") + + print(f"reading modern cache from {args.modern_cache}") + reader = ModernCacheReader(args.modern_cache) + + # load XTEA keys for object data decryption + xtea_keys: dict[int, list[int]] = {} + if args.keys and args.keys.exists(): + xtea_keys = load_xtea_keys(args.keys) + print(f" {len(xtea_keys)} XTEA keys loaded") + + print("loading object definitions from modern cache...") + loc_defs = decode_loc_definitions_modern(reader) + + print("scanning index 5 for map groups...") + map_groups = find_map_groups(reader) + print(f" {len(map_groups)} regions found in index 5") + + # determine target regions + if not args.regions: + sys.exit("--regions is required when using --modern-cache") + + target_coords: set[tuple[int, int]] = set() + for coord in args.regions.split(): + parts = coord.split(",") + target_coords.add((int(parts[0]), int(parts[1]))) + print(f" exporting {len(target_coords)} specified regions") + + # parse object placements and terrain + all_placements: list[PlacedObject] = [] + terrain_parsed: dict[tuple[int, int], RegionTerrain] = {} + errors = 0 + + for rx, ry in sorted(target_coords): + ms = (rx << 8) | ry + if ms not in map_groups: + print(f" region ({rx},{ry}): not found in index 5") + errors += 1 + continue + + terrain_gid, loc_gid = map_groups[ms] + + # parse terrain + if terrain_gid is not None: + terrain_data = reader.read_container(5, terrain_gid) + if terrain_data: + rt = parse_terrain_full(terrain_data, rx * 64, ry * 64) + rt.region_x = rx + rt.region_y = ry + terrain_parsed[(rx, ry)] = rt + + # parse object placements (XTEA encrypted in modern cache) + if loc_gid is not None: + import bz2 + import zlib + + from export_collision_map_modern import xtea_decrypt + + ms = (rx << 8) | ry + key = xtea_keys.get(ms) + if key is None: + print(f" region ({rx},{ry}): no XTEA key, skipping objects") + else: + raw_obj = reader._read_raw(5, loc_gid) + loc_data = None + if raw_obj is not None and len(raw_obj) >= 5: + # container: compression(1) + compressed_len(4) + encrypted payload + compression = raw_obj[0] + compressed_len = struct.unpack(">I", raw_obj[1:5])[0] + decrypted = xtea_decrypt(raw_obj[5:], key) + + if compression == 0: + loc_data = decrypted[:compressed_len] + else: + # decrypted[0:4] = decompressed_len, then compressed data + gzip_data = decrypted[4:4 + compressed_len] + if compression == 2: + loc_data = zlib.decompress(gzip_data[10:], -zlib.MAX_WBITS) + elif compression == 1: + loc_data = bz2.decompress(b"BZh1" + gzip_data) + + if loc_data: + placements = parse_object_placements_modern( + loc_data, rx * 64, ry * 64, + ) + all_placements.extend(placements) + print(f" region ({rx},{ry}): {len(placements)} placements") + else: + print(f" region ({rx},{ry}): could not read/decrypt loc data") + errors += 1 + + print(f" {len(all_placements)} placements parsed, {errors} region errors") + + # no texture atlas for modern cache (textures not yet supported) + tex_colors: dict[int, int] = {} + + loader_modern = lambda mid: load_model_modern(reader, mid) + _build_and_write(args, all_placements, loc_defs, loader_modern, terrain_parsed, + tex_colors) + + +def main() -> None: + parser = argparse.ArgumentParser(description="export OSRS placed objects from cache") + parser.add_argument( + "--cache", + type=Path, + default=Path("../reference/tarnish/game-server/data/cache"), + help="path to 317-format cache directory", + ) + parser.add_argument( + "--modern-cache", + type=Path, + default=None, + help="path to modern OpenRS2 cache directory (overrides --cache)", + ) + parser.add_argument( + "--keys", + type=Path, + default=None, + help="path to XTEA keys JSON (for modern cache object data decryption)", + ) + parser.add_argument( + "--regions", + type=str, + default=None, + help='space-separated region coordinates as rx,ry pairs (e.g. "35,47 35,48")', + ) + parser.add_argument( + "--output", + type=Path, + default=Path("data/wilderness.objects"), + help="output .objects binary file", + ) + parser.add_argument( + "--radius", + type=int, + default=3, + help="number of regions around the fight area center to export (default: 3)", + ) + args = parser.parse_args() + + if args.modern_cache: + _main_modern(args) + else: + _main_317(args) + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_spotanims.py b/ocean/osrs/scripts/export_spotanims.py new file mode 100644 index 0000000000..125a0338b6 --- /dev/null +++ b/ocean/osrs/scripts/export_spotanims.py @@ -0,0 +1,291 @@ +"""Parse spotanim data from OSRS cache and export spotanim metadata. + +Supports both 317-format (tarnish) and modern OpenRS2 flat file caches. + +SpotAnimations (GFX effects) are visual effects like spell impacts, projectiles, +and special attack graphics. Each has a model ID, animation sequence ID, scale, +and optional recolors. + +Usage (317 cache): + uv run python scripts/export_spotanims.py \ + --cache ../reference/tarnish/game-server/data/cache + +Usage (modern cache): + uv run python scripts/export_spotanims.py \ + --modern-cache ../reference/osrs-cache-modern + + # export specific GFX IDs only + uv run python scripts/export_spotanims.py \ + --cache ../reference/tarnish/game-server/data/cache \ + --ids 27,368,369,377 +""" + +import argparse +import io +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from export_collision_map import ( + CONFIG_INDEX, + CacheReader, + decode_archive, + hash_archive_name, +) +from modern_cache_reader import ModernCacheReader + +CONFIG_ARCHIVE = 2 +MODERN_SPOTANIM_CONFIG_GROUP = 13 + + +@dataclass +class SpotAnimDef: + """SpotAnimation definition from spotanim.dat.""" + + gfx_id: int + model_id: int = 0 + animation_id: int = -1 + resize_xy: int = 128 # 128 = 1.0x scale + resize_z: int = 128 + rotation: int = 0 + brightness: int = 0 + shadow: int = 0 + recolor_src: list[int] = field(default_factory=list) + recolor_dst: list[int] = field(default_factory=list) + + +def decode_spotanims(cache: CacheReader) -> dict[int, SpotAnimDef]: + """Parse spotanim.dat from config archive, return dict of gfx_id -> SpotAnimDef.""" + raw = cache.get(CONFIG_INDEX, CONFIG_ARCHIVE) + if raw is None: + sys.exit("could not read config archive") + + archive = decode_archive(raw) + key = hash_archive_name("spotanim.dat") & 0xFFFFFFFF + data = archive.get(key) + if data is None: + # try signed hash + data = archive.get(hash_archive_name("spotanim.dat")) + if data is None: + sys.exit("spotanim.dat not found in config archive") + + buf = io.BytesIO(data) + count = struct.unpack(">H", buf.read(2))[0] + print(f"spotanim.dat: {count + 1} entries declared") + + spotanims: dict[int, SpotAnimDef] = {} + + for _ in range(count + 1): + gfx_id_raw = buf.read(2) + if len(gfx_id_raw) < 2: + break + gfx_id = struct.unpack(">H", gfx_id_raw)[0] + if gfx_id == 65535: + break + + data_size = struct.unpack(">H", buf.read(2))[0] + entry_data = buf.read(data_size) + + sa = SpotAnimDef(gfx_id=gfx_id) + entry_buf = io.BytesIO(entry_data) + + while True: + opcode_raw = entry_buf.read(1) + if len(opcode_raw) == 0: + break + opcode = opcode_raw[0] + + if opcode == 0: + break + elif opcode == 1: + sa.model_id = struct.unpack(">H", entry_buf.read(2))[0] + elif opcode == 2: + anim_id = struct.unpack(">H", entry_buf.read(2))[0] + sa.animation_id = anim_id if anim_id != 65535 else -1 + elif opcode == 4: + sa.resize_xy = struct.unpack(">H", entry_buf.read(2))[0] + elif opcode == 5: + sa.resize_z = struct.unpack(">H", entry_buf.read(2))[0] + elif opcode == 6: + sa.rotation = struct.unpack(">H", entry_buf.read(2))[0] + elif opcode == 7: + sa.brightness = entry_buf.read(1)[0] + elif opcode == 8: + sa.shadow = entry_buf.read(1)[0] + elif opcode == 40: + length = entry_buf.read(1)[0] + for _ in range(length): + src = struct.unpack(">H", entry_buf.read(2))[0] + dst = struct.unpack(">H", entry_buf.read(2))[0] + sa.recolor_src.append(src) + sa.recolor_dst.append(dst) + elif opcode == 41: + length = entry_buf.read(1)[0] + for _ in range(length): + entry_buf.read(4) # skip retexture pairs + else: + print(f" warning: unknown opcode {opcode} for GFX {gfx_id}") + break + + spotanims[gfx_id] = sa + + return spotanims + + +def _parse_modern_spotanim_entry(gfx_id: int, data: bytes) -> SpotAnimDef: + """Parse a single spotanim from modern cache opcode stream. + + Opcodes are the same between 317 and modern for spotanims. + """ + sa = SpotAnimDef(gfx_id=gfx_id) + entry_buf = io.BytesIO(data) + + while True: + opcode_raw = entry_buf.read(1) + if len(opcode_raw) == 0: + break + opcode = opcode_raw[0] + + if opcode == 0: + break + elif opcode == 1: + sa.model_id = struct.unpack(">H", entry_buf.read(2))[0] + elif opcode == 2: + anim_id = struct.unpack(">H", entry_buf.read(2))[0] + sa.animation_id = anim_id if anim_id != 65535 else -1 + elif opcode == 4: + sa.resize_xy = struct.unpack(">H", entry_buf.read(2))[0] + elif opcode == 5: + sa.resize_z = struct.unpack(">H", entry_buf.read(2))[0] + elif opcode == 6: + sa.rotation = struct.unpack(">H", entry_buf.read(2))[0] + elif opcode == 7: + sa.brightness = entry_buf.read(1)[0] + elif opcode == 8: + sa.shadow = entry_buf.read(1)[0] + elif opcode == 40: + length = entry_buf.read(1)[0] + for _ in range(length): + src = struct.unpack(">H", entry_buf.read(2))[0] + dst = struct.unpack(">H", entry_buf.read(2))[0] + sa.recolor_src.append(src) + sa.recolor_dst.append(dst) + elif opcode == 41: + length = entry_buf.read(1)[0] + for _ in range(length): + entry_buf.read(4) # skip retexture pairs + else: + print(f" warning: unknown modern spotanim opcode {opcode} for GFX {gfx_id}") + break + + return sa + + +def decode_spotanims_modern(reader: ModernCacheReader) -> dict[int, SpotAnimDef]: + """Parse spotanim entries from modern cache (config index 2, group 13).""" + files = reader.read_group(2, MODERN_SPOTANIM_CONFIG_GROUP) + spotanims: dict[int, SpotAnimDef] = {} + + for gfx_id, entry_data in files.items(): + sa = _parse_modern_spotanim_entry(gfx_id, entry_data) + spotanims[gfx_id] = sa + + return spotanims + + +# GFX IDs we need for the PvP viewer +TARGET_GFX_IDS = { + 27, # crossbow bolt projectile + 368, # ice barrage projectile (orb) + 369, # ice barrage impact (freeze splash) + 377, # blood barrage impact + 1468, # dragon bolt projectile +} + + +def main() -> None: + """Parse spotanim data and print metadata for target GFX IDs.""" + parser = argparse.ArgumentParser(description="parse spotanim data from OSRS cache") + cache_group = parser.add_mutually_exclusive_group(required=True) + cache_group.add_argument( + "--cache", + type=Path, + help="path to 317 tarnish cache directory", + ) + cache_group.add_argument( + "--modern-cache", + type=Path, + help="path to modern OpenRS2 cache directory", + ) + parser.add_argument( + "--ids", + type=str, + default=None, + help="comma-separated GFX IDs to show (default: all PvP-relevant)", + ) + parser.add_argument( + "--all", + action="store_true", + help="print all spotanims (not just targets)", + ) + args = parser.parse_args() + + use_modern = args.modern_cache is not None + cache_path = args.modern_cache if use_modern else args.cache + + print(f"reading {'modern' if use_modern else '317'} cache from {cache_path}") + + if use_modern: + modern_reader = ModernCacheReader(cache_path) + spotanims = decode_spotanims_modern(modern_reader) + else: + cache = CacheReader(cache_path) + spotanims = decode_spotanims(cache) + print(f"parsed {len(spotanims)} spotanims total\n") + + if args.ids: + target_ids = {int(x) for x in args.ids.split(",")} + elif args.all: + target_ids = set(spotanims.keys()) + else: + target_ids = TARGET_GFX_IDS + + print(f"{'GFX':>5} {'model':>6} {'anim':>5} {'scaleXY':>7} {'scaleZ':>6} {'rot':>4} recolors") + print("-" * 70) + + for gfx_id in sorted(target_ids): + sa = spotanims.get(gfx_id) + if sa is None: + print(f"{gfx_id:>5} (not found)") + continue + + recolors = "" + if sa.recolor_src: + recolors = ", ".join( + f"{s}->{d}" for s, d in zip(sa.recolor_src, sa.recolor_dst) + ) + + print( + f"{gfx_id:>5} {sa.model_id:>6} {sa.animation_id:>5} " + f"{sa.resize_xy:>7} {sa.resize_z:>6} {sa.rotation:>4} {recolors}" + ) + + # print summary for integration + model_ids = set() + anim_ids = set() + for gfx_id in target_ids: + sa = spotanims.get(gfx_id) + if sa: + if sa.model_id > 0: + model_ids.add(sa.model_id) + if sa.animation_id >= 0: + anim_ids.add(sa.animation_id) + + print(f"\nmodel IDs to add to export_models.py: {sorted(model_ids)}") + print(f"anim seq IDs to add to export_animations.py: {sorted(anim_ids)}") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_sprites.py b/ocean/osrs/scripts/export_sprites.py new file mode 100644 index 0000000000..d9549b7c82 --- /dev/null +++ b/ocean/osrs/scripts/export_sprites.py @@ -0,0 +1,200 @@ +"""Export sprite images from a 317-format OSRS cache. + +Reads the media archive (cache index 0, file 4) and decodes indexed-color +sprites using the same format as tarnish's Sprite(StreamLoader, name, index) +constructor. + +Sprite format (317 era): + - .dat: per-sprite pixel data (palette indices) + offset table + - index.dat: shared index with palette, dimensions, and frame layout + - Palette index 0 = transparent, remaining entries = 24-bit RGB + +Usage: + uv run python scripts/export_sprites.py \ + --cache ../reference/tarnish/game-server/data/cache \ + --sprite headicons_prayer \ + --output data/sprites/ +""" + +import argparse +import io +import struct +import sys +from pathlib import Path + +# reuse cache infrastructure from export_collision_map.py +sys.path.insert(0, str(Path(__file__).parent)) +from export_collision_map import CacheReader, decode_archive, hash_archive_name + + +def decode_sprite( + dat: bytes, index_dat: bytes, frame_index: int +) -> tuple[bytes, int, int] | None: + """Decode a single sprite frame from .dat + index.dat. + + Returns (rgba_bytes, width, height) or None on failure. + + Faithful to tarnish Sprite.java constructor (lines 376-417): + 1. Read 2-byte offset from .dat → seek index.dat to that offset + 2. From index.dat: resizeWidth(2), resizeHeight(2), palette_size(1) + 3. Read palette_size-1 RGB entries (3 bytes each), palette[0] = 0 (transparent) + 4. Skip through frame_index prior frames + 5. Read offsetX(1), offsetY(1), width(2), height(2), flags(1) + 6. Read width*height palette indices from .dat + 7. flags==0: row-major, flags==1: column-major + """ + dat_buf = io.BytesIO(dat) + idx_buf = io.BytesIO(index_dat) + + # read offset from .dat (2 bytes at position frame_index * 2... no, it's at the start) + # actually, the offset is the FIRST 2 bytes of the .dat file + dat_buf.seek(0) + idx_offset = struct.unpack(">H", dat_buf.read(2))[0] + + # seek index.dat to that offset + idx_buf.seek(idx_offset) + + # read resize dimensions and palette + resize_w = struct.unpack(">H", idx_buf.read(2))[0] + resize_h = struct.unpack(">H", idx_buf.read(2))[0] + palette_size = idx_buf.read(1)[0] & 0xFF + + # palette: index 0 = transparent (0x000000), rest are 24-bit RGB + palette = [0] * palette_size + for i in range(1, palette_size): + r, g, b = idx_buf.read(3) + palette[i] = (r << 16) | (g << 8) | b + + # skip through prior frames + for _ in range(frame_index): + _ox = idx_buf.read(1)[0] # noqa: F841 + _oy = idx_buf.read(1)[0] # noqa: F841 + fw = struct.unpack(">H", idx_buf.read(2))[0] + fh = struct.unpack(">H", idx_buf.read(2))[0] + _flags = idx_buf.read(1)[0] # noqa: F841 + # skip pixel data in .dat + dat_buf.seek(dat_buf.tell() + fw * fh) + + # read this frame's header from index.dat + offset_x = idx_buf.read(1)[0] # noqa: F841 + offset_y = idx_buf.read(1)[0] # noqa: F841 + width = struct.unpack(">H", idx_buf.read(2))[0] + height = struct.unpack(">H", idx_buf.read(2))[0] + flags = idx_buf.read(1)[0] + + if width == 0 or height == 0: + return None + + # read palette indices from .dat + pixel_count = width * height + indices = list(dat_buf.read(pixel_count)) + + # convert to RGBA + rgba = bytearray(width * height * 4) + + if flags == 0: + # row-major + for i in range(pixel_count): + rgb = palette[indices[i]] + pos = i * 4 + rgba[pos] = (rgb >> 16) & 0xFF + rgba[pos + 1] = (rgb >> 8) & 0xFF + rgba[pos + 2] = rgb & 0xFF + rgba[pos + 3] = 0 if indices[i] == 0 else 255 + elif flags == 1: + # column-major: stored column by column + for x in range(width): + for y in range(height): + idx = indices[x * height + y] + pos = (y * width + x) * 4 + rgb = palette[idx] + rgba[pos] = (rgb >> 16) & 0xFF + rgba[pos + 1] = (rgb >> 8) & 0xFF + rgba[pos + 2] = rgb & 0xFF + rgba[pos + 3] = 0 if idx == 0 else 255 + + return bytes(rgba), width, height + + +def save_png(rgba: bytes, width: int, height: int, path: Path) -> None: + """Save RGBA pixel data as a PNG file.""" + try: + from PIL import Image + + img = Image.frombytes("RGBA", (width, height), rgba) + img.save(str(path)) + except ImportError: + # fallback: write raw RGBA + dimensions as a simple binary + with open(path.with_suffix(".rgba"), "wb") as f: + f.write(struct.pack(" None: + parser = argparse.ArgumentParser(description="Export sprites from 317 OSRS cache") + parser.add_argument("--cache", required=True, help="Path to cache directory") + parser.add_argument( + "--sprite", + default="headicons_prayer", + help="Sprite archive name (default: headicons_prayer)", + ) + parser.add_argument( + "--count", type=int, default=8, help="Number of frames to export (default: 8)" + ) + parser.add_argument( + "--output", default="data/sprites/", help="Output directory for PNGs" + ) + args = parser.parse_args() + + cache = CacheReader(Path(args.cache)) + + # media archive is cache index 0, file 4 + media_raw = cache.get(0, 4) + if not media_raw: + print("ERROR: could not read media archive (cache 0, file 4)") + sys.exit(1) + + media_files = decode_archive(media_raw) + print(f"media archive: {len(media_files)} files") + + # look up the .dat and index.dat + # hash_archive_name returns signed int (Java semantics), but decode_archive + # reads hashes as unsigned (struct ">I"). convert to unsigned for lookup. + dat_hash = hash_archive_name(f"{args.sprite}.dat") & 0xFFFFFFFF + idx_hash = hash_archive_name("index.dat") & 0xFFFFFFFF + + dat_data = media_files.get(dat_hash) + idx_data = media_files.get(idx_hash) + + if not dat_data: + print(f"ERROR: '{args.sprite}.dat' not found in media archive (hash={dat_hash})") + # list available hashes for debugging + print(f" available hashes: {sorted(media_files.keys())[:20]}...") + sys.exit(1) + + if not idx_data: + print(f"ERROR: 'index.dat' not found in media archive (hash={idx_hash})") + sys.exit(1) + + print(f"found {args.sprite}.dat ({len(dat_data)} bytes) + index.dat ({len(idx_data)} bytes)") + + # export each frame + out_dir = Path(args.output) + out_dir.mkdir(parents=True, exist_ok=True) + + for i in range(args.count): + result = decode_sprite(dat_data, idx_data, i) + if result is None: + print(f" frame {i}: empty/invalid, skipping") + continue + rgba, w, h = result + out_path = out_dir / f"{args.sprite}_{i}.png" + save_png(rgba, w, h, out_path) + print(f" frame {i}: {w}x{h} -> {out_path}") + + print("done!") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_sprites_modern.py b/ocean/osrs/scripts/export_sprites_modern.py new file mode 100644 index 0000000000..350bca9d6d --- /dev/null +++ b/ocean/osrs/scripts/export_sprites_modern.py @@ -0,0 +1,373 @@ +"""Export sprites from modern OSRS cache (OpenRS2 flat format) to PNG files. + +Reads sprite archives from cache index 8 and decodes them using the +SpriteLoader format from the deobfuscated client (trailer-based format +with palette, per-frame offsets, and optional alpha channel). + +Exports specific sprite IDs needed for the debug viewer GUI: + - equipment slot backgrounds (156-165, 170) + - prayer icons enabled/disabled (115-154, 502-509, 945-951, 1420-1427) + - tab icons (168, 776, 779, 780, 900, 901) + - spell icons (325-336, 375-386, 557, 561, 564, 607, 611, 614) + - combat interface sprites (657) + +Usage: + uv run python scripts/export_sprites_modern.py \ + --cache ../reference/osrs-cache-modern \ + --output data/sprites/gui +""" + +import argparse +import io +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# add parent for modern_cache_reader import +sys.path.insert(0, str(Path(__file__).parent)) +from modern_cache_reader import ModernCacheReader + + +@dataclass +class SpriteFrame: + """Single sprite frame decoded from cache archive.""" + + group_id: int = 0 + frame: int = 0 + offset_x: int = 0 + offset_y: int = 0 + width: int = 0 + height: int = 0 + max_width: int = 0 + max_height: int = 0 + pixels: list[int] = field(default_factory=list) # ARGB int array + + +def decode_sprites(group_id: int, data: bytes) -> list[SpriteFrame]: + """Decode sprite archive using SpriteLoader format from deob client. + + Ported from SpriteLoader.java (runelite-cache). Format is trailer-based: + [pixel data for all frames] + [palette: (palette_len - 1) x 3 bytes RGB] + [per-frame: offset_x, offset_y, width, height as u16 arrays] x frame_count + [max_width u16, max_height u16, palette_len_minus1 u8] (5 bytes) + [frame_count u16] (last 2 bytes) + """ + if len(data) < 2: + return [] + + buf = io.BytesIO(data) + + # trailer: frame_count at very end (SpriteLoader.java line 41) + buf.seek(len(data) - 2) + frame_count = struct.unpack(">H", buf.read(2))[0] + if frame_count == 0: + return [] + + # header block: 5 bytes before per-frame data before frame_count + # (SpriteLoader.java line 48) + header_start = len(data) - 7 - frame_count * 8 + buf.seek(header_start) + + max_width = struct.unpack(">H", buf.read(2))[0] + max_height = struct.unpack(">H", buf.read(2))[0] + # SpriteLoader.java line 53: paletteLength = readUnsignedByte() + 1 + palette_len = buf.read(1)[0] + 1 + + # per-frame dimensions: 4 arrays of frame_count u16 values + # (SpriteLoader.java lines 64-82) + offsets_x = [struct.unpack(">H", buf.read(2))[0] for _ in range(frame_count)] + offsets_y = [struct.unpack(">H", buf.read(2))[0] for _ in range(frame_count)] + widths = [struct.unpack(">H", buf.read(2))[0] for _ in range(frame_count)] + heights = [struct.unpack(">H", buf.read(2))[0] for _ in range(frame_count)] + + # palette: (palette_len - 1) RGB entries before the header block + # (SpriteLoader.java line 85) + palette_start = header_start - (palette_len - 1) * 3 + buf.seek(palette_start) + palette = [0] * palette_len # index 0 = transparent + for i in range(1, palette_len): + r = buf.read(1)[0] + g = buf.read(1)[0] + b = buf.read(1)[0] + rgb = (r << 16) | (g << 8) | b + palette[i] = rgb if rgb != 0 else 1 + + # pixel data from start of file (SpriteLoader.java line 98) + buf.seek(0) + frames = [] + for fi in range(frame_count): + w = widths[fi] + h = heights[fi] + dimension = w * h + + # per-frame arrays matching Java's byte[] layout + indices = [0] * dimension + alphas = [0] * dimension + + flags = buf.read(1)[0] + + # read palette indices (SpriteLoader.java lines 113-131) + if not (flags & 0x01): + # horizontal + for j in range(dimension): + indices[j] = buf.read(1)[0] + else: + # vertical: iterate columns then rows + for j in range(w): + for k in range(h): + indices[w * k + j] = buf.read(1)[0] + + # read alpha channel if FLAG_ALPHA (SpriteLoader.java lines 134-155) + if flags & 0x02: + if not (flags & 0x01): + for j in range(dimension): + alphas[j] = buf.read(1)[0] + else: + for j in range(w): + for k in range(h): + alphas[w * k + j] = buf.read(1)[0] + + # force opaque for all non-zero palette indices + # (SpriteLoader.java lines 157-166 — runs AFTER alpha read) + for j in range(dimension): + if indices[j] != 0: + alphas[j] = 0xFF + + # build ARGB pixels (SpriteLoader.java lines 168-176) + pixels = [0] * dimension + for j in range(dimension): + idx = indices[j] & 0xFF + pixels[j] = palette[idx] | (alphas[j] << 24) + + frame = SpriteFrame( + group_id=group_id, + frame=fi, + offset_x=offsets_x[fi], + offset_y=offsets_y[fi], + width=w, + height=h, + max_width=max_width, + max_height=max_height, + pixels=pixels, + ) + frames.append(frame) + + return frames + + +def save_sprite_png(sprite: SpriteFrame, path: Path) -> None: + """Save a sprite frame as RGBA PNG using pure Python (no PIL dependency).""" + try: + from PIL import Image + except ImportError: + print(f" pillow not available, skipping {path}", file=sys.stderr) + return + + img = Image.new("RGBA", (sprite.width, sprite.height)) + rgba_data = [] + for argb in sprite.pixels: + a = (argb >> 24) & 0xFF + r = (argb >> 16) & 0xFF + g = (argb >> 8) & 0xFF + b = argb & 0xFF + rgba_data.append((r, g, b, a)) + img.putdata(rgba_data) + img.save(str(path)) + + +# sprite IDs to export, organized by category +SPRITE_IDS: dict[str, list[int]] = { + # equipment slot background icons + "equip": [156, 157, 158, 159, 160, 161, 162, 163, 164, 165, 170], + # prayer icons (enabled) + "prayer": [ + 115, 116, 117, 118, 119, 120, 121, 122, 123, 124, 125, 126, + 127, 128, 129, 130, 131, 132, 133, 134, # base prayers + 502, 503, 504, 505, # hawk eye, mystic lore, eagle eye, mystic might + 945, 946, 947, # chivalry, piety, preserve + 1420, 1421, # rigour, augury + ], + # prayer icons (disabled/greyed) + "prayer_off": [ + 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, + 147, 148, 149, 150, 151, 152, 153, 154, # base disabled + 506, 507, 508, 509, # hawk/mystic disabled + 949, 950, 951, # chivalry/piety/preserve disabled + 1424, 1425, # rigour/augury disabled + ], + # tab icons (combat, stats, quests, inventory, equipment, prayer, magic) + "tab": [168, 776, 779, 780, 898, 899, 900, 901], + # ancient spell icons (enabled + disabled) + "spell_ancient": [ + 325, 326, 327, 328, # ice rush/burst/blitz/barrage + 333, 334, 335, 336, # blood rush/burst/blitz/barrage + 375, 376, 377, 378, # ice disabled + 383, 384, 385, 386, # blood disabled + ], + # lunar spell icons + "spell_lunar": [557, 561, 564, 607, 611, 614], + # combat interface + "combat": [657], + # interface chrome: side panel background, tab stones, equipment slot chrome + "chrome": [ + 1031, # FIXED_MODE_SIDE_PANEL_BACKGROUND + 1032, # FIXED_MODE_TABS_ROW_BOTTOM + 1036, # FIXED_MODE_TABS_ROW_TOP + 1026, 1027, 1028, 1029, 1030, # TAB_STONE_*_SELECTED corners + middle + 179, # EQUIPMENT_SLOT_SELECTED + 952, 953, # SLANTED_TAB, SLANTED_TAB_HOVERED + 1071, 1072, # MINIMAP_ORB_FRAME, _HOVERED + 1017, # CHATBOX background + 1018, # CHATBOX_BUTTONS_BACKGROUND_STONES + 166, # EQUIPMENT_SLOT_AMMUNITION (we had 156-165, was missing 166) + 171, 172, 173, # iron rivets (square, vertical, horizontal) + ], + # click cross animations (4 yellow move + 4 red attack, 16x16 each) + "click_cross": [515, 516, 517, 518, 519, 520, 521, 522], + # overhead prayer headicons (multi-frame: 0=melee, 1=ranged, 2=magic, 3=retribution, 4=smite, 5=redemption) + "headicons_prayer": [440], + # hitsplat sprites (each is a single-frame sprite group) + "hitmarks": [1358, 1359, 1360, 1361, 1362], +} + +# human-readable names for specific sprite IDs +SPRITE_NAMES: dict[int, str] = { + 156: "slot_head", 157: "slot_cape", 158: "slot_neck", 159: "slot_weapon", + 160: "slot_ring", 161: "slot_body", 162: "slot_shield", 163: "slot_legs", + 164: "slot_hands", 165: "slot_feet", 170: "slot_tile", + 168: "tab_combat", 776: "tab_quests", 779: "tab_prayer", + 780: "tab_magic", 898: "tab_stats", 899: "tab_quests2", + 900: "tab_inventory", 901: "tab_equipment", + 127: "pray_mage", 128: "pray_range", 129: "pray_melee", + 130: "pray_redemption", 131: "pray_retribution", 132: "pray_smite", + 504: "pray_eagle_eye", 505: "pray_mystic_might", + 945: "pray_chivalry", 946: "pray_piety", 947: "pray_preserve", + 1420: "pray_rigour", 1421: "pray_augury", + 325: "spell_ice_rush", 326: "spell_ice_burst", + 327: "spell_ice_blitz", 328: "spell_ice_barrage", + 333: "spell_blood_rush", 334: "spell_blood_burst", + 335: "spell_blood_blitz", 336: "spell_blood_barrage", + 564: "spell_vengeance", + 657: "special_attack", + # interface chrome + 1031: "side_panel_bg", + 1032: "tabs_row_bottom", + 1036: "tabs_row_top", + 1026: "tab_stone_tl_sel", + 1027: "tab_stone_tr_sel", + 1028: "tab_stone_bl_sel", + 1029: "tab_stone_br_sel", + 1030: "tab_stone_mid_sel", + 179: "slot_selected", + 952: "slanted_tab", + 953: "slanted_tab_hover", + 1071: "orb_frame", + 1072: "orb_frame_hover", + 1017: "chatbox_bg", + 1018: "chatbox_stones", + 166: "slot_ammo", + 171: "rivets_square", + 172: "rivets_vertical", + 173: "rivets_horizontal", + 515: "cross_yellow_1", 516: "cross_yellow_2", + 517: "cross_yellow_3", 518: "cross_yellow_4", + 519: "cross_red_1", 520: "cross_red_2", + 521: "cross_red_3", 522: "cross_red_4", + # overhead prayer headicons (group 440, 6 frames) + 440: "headicons_prayer", + # hitsplats: 0=blue miss, 1=red damage, 2=green poison, 3=disease, 4=venom + 1358: "hitmarks_0", 1359: "hitmarks_1", + 1360: "hitmarks_2", 1361: "hitmarks_3", 1362: "hitmarks_4", +} + + +def main() -> None: + """Export GUI sprites from modern OSRS cache.""" + parser = argparse.ArgumentParser(description="Export OSRS GUI sprites") + parser.add_argument( + "--cache", default="../reference/osrs-cache-modern", + help="Path to modern cache directory", + ) + parser.add_argument( + "--output", default="data/sprites/gui", + help="Output directory for PNGs", + ) + parser.add_argument( + "--list-all", action="store_true", + help="List all sprite group IDs in index 8 and exit", + ) + args = parser.parse_args() + + reader = ModernCacheReader(args.cache) + + if args.list_all: + manifest = reader.read_index_manifest(8) + print(f"index 8 has {len(manifest.group_ids)} sprite groups") + print(f" range: {min(manifest.group_ids)} - {max(manifest.group_ids)}") + return + + out_dir = Path(args.output) + out_dir.mkdir(parents=True, exist_ok=True) + + # collect all sprite IDs to export + all_ids: set[int] = set() + for ids in SPRITE_IDS.values(): + all_ids.update(ids) + + # check which IDs exist in the cache + manifest = reader.read_index_manifest(8) + available = set(manifest.group_ids) + + exported = 0 + failed = 0 + + for sprite_id in sorted(all_ids): + if sprite_id not in available: + print(f" sprite {sprite_id}: NOT in cache index 8") + failed += 1 + continue + + data = reader.read_container(8, sprite_id) + if data is None: + print(f" sprite {sprite_id}: failed to read container") + failed += 1 + continue + + try: + frames = decode_sprites(sprite_id, data) + except Exception as e: + print(f" sprite {sprite_id}: decode error: {e}", file=sys.stderr) + failed += 1 + continue + + if not frames: + print(f" sprite {sprite_id}: no frames decoded") + failed += 1 + continue + + for frame in frames: + name = SPRITE_NAMES.get(sprite_id, str(sprite_id)) + if len(frames) > 1: + filename = f"{name}_{frame.frame}.png" + else: + filename = f"{name}.png" + path = out_dir / filename + save_sprite_png(frame, path) + exported += 1 + + if len(frames) == 1: + f = frames[0] + print(f" sprite {sprite_id} ({SPRITE_NAMES.get(sprite_id, '?')}): " + f"{f.width}x{f.height}") + else: + print(f" sprite {sprite_id} ({SPRITE_NAMES.get(sprite_id, '?')}): " + f"{len(frames)} frames") + + print(f"\nexported {exported} sprite frames, {failed} failed") + print(f"output: {out_dir}/") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_terrain.py b/ocean/osrs/scripts/export_terrain.py new file mode 100644 index 0000000000..be72f63409 --- /dev/null +++ b/ocean/osrs/scripts/export_terrain.py @@ -0,0 +1,1137 @@ +"""Export terrain mesh from OSRS cache to a binary .terrain file. + +Supports both 317-format (tarnish) and modern OpenRS2 flat file caches. +For each region in the export area: + 1. Parse terrain data: heightmap, underlay IDs, overlay IDs, shapes, settings + 2. Decode floor definitions (underlays/overlays) for tile colors + 3. Compute per-vertex lighting (directional light + shadow map blur) + 4. Blend underlay colors in an 11x11 window (smooth terrain gradients) + 5. Output vertex-colored terrain mesh as binary .terrain file + +The terrain binary is loaded by osrs_pvp_terrain.h into raylib meshes. + +Usage (317 cache): + uv run python scripts/export_terrain.py \ + --cache ../reference/tarnish/game-server/data/cache \ + --output data/wilderness.terrain + +Usage (modern cache): + uv run python scripts/export_terrain.py \ + --modern-cache ../reference/osrs-cache-modern \ + --keys ../reference/osrs-cache-modern/keys.json \ + --regions "35,47 35,48" \ + --output data/zulrah.terrain +""" + +import argparse +import gzip +import io +import math +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from export_collision_map import ( + CONFIG_INDEX, + MAP_INDEX, + MANIFEST_ARCHIVE, + CacheReader, + _read_string, + decode_archive, + hash_archive_name, + load_map_index, +) +from modern_cache_reader import ModernCacheReader + +# --- OSRS noise functions (from MapRegion.java) --- + +JAGEX_CIRCULAR_ANGLE = 2048 +JAGEX_RADIAN = 2.0 * math.pi / JAGEX_CIRCULAR_ANGLE +COS_TABLE = [int(65536.0 * math.cos(i * JAGEX_RADIAN)) for i in range(JAGEX_CIRCULAR_ANGLE)] + + +def noise(x: int, y: int) -> int: + """OSRS procedural noise (MapRegion.noise).""" + n = x + y * 57 + n ^= (n << 13) & 0xFFFFFFFF + n &= 0xFFFFFFFF + val = (n * (n * n * 15731 + 789221) + 1376312589) & 0x7FFFFFFF + return (val >> 19) & 0xFF + + +def smoothed_noise(x: int, y: int) -> int: + """Smoothed noise with corner/side/center weighting.""" + corners = noise(x - 1, y - 1) + noise(x + 1, y - 1) + noise(x - 1, y + 1) + noise(x + 1, y + 1) + sides = noise(x - 1, y) + noise(x + 1, y) + noise(x, y - 1) + noise(x, y + 1) + center = noise(x, y) + return center // 4 + sides // 8 + corners // 16 + + +def interpolate(a: int, b: int, x: int, y: int) -> int: + """Cosine interpolation using Jagex COS table.""" + f = (0x10000 - COS_TABLE[(1024 * x // y) % JAGEX_CIRCULAR_ANGLE]) >> 1 + return (f * b >> 16) + (a * (0x10000 - f) >> 16) + + +def interpolate_noise(x: int, y: int, frequency: int) -> int: + """Multi-octave interpolated noise.""" + int_x = x // frequency + frac_x = x % frequency + int_y = y // frequency + frac_y = y % frequency + v1 = smoothed_noise(int_x, int_y) + v2 = smoothed_noise(int_x + 1, int_y) + v3 = smoothed_noise(int_x, int_y + 1) + v4 = smoothed_noise(int_x + 1, int_y + 1) + i1 = interpolate(v1, v2, frac_x, frequency) + i2 = interpolate(v3, v4, frac_x, frequency) + return interpolate(i1, i2, frac_y, frequency) + + +def calculate_height(x: int, y: int) -> int: + """Procedural height for tiles without explicit height (MapRegion.calculate).""" + n = ( + interpolate_noise(x + 45365, y + 91923, 4) + - 128 + + ((interpolate_noise(10294 + x, y + 37821, 2) - 128) >> 1) + + ((interpolate_noise(x, y, 1) - 128) >> 2) + ) + n = 35 + int(n * 0.3) + if n < 10: + n = 10 + elif n > 60: + n = 60 + return n + + +# --- floor definition decoder --- + + +@dataclass +class FloorDef: + """Floor definition (underlay or overlay).""" + + floor_id: int = 0 + rgb: int = 0 + texture: int = -1 + hide_underlay: bool = True + secondary_rgb: int = -1 + # computed HSL fields + hue: int = 0 + saturation: int = 0 + lightness: int = 0 + secondary_hue: int = 0 + secondary_saturation: int = 0 + secondary_lightness: int = 0 + # blend fields (for underlays) + blend_hue: int = 0 + blend_hue_multiplier: int = 0 + luminance: int = 0 + hsl16: int = 0 + + +def _rgb_to_hsl(rgb: int, flo: FloorDef) -> None: + """Convert RGB to HSL fields on a FloorDef (MapRegion.rgbToHsl / FloorDefinition.setHsl).""" + r = ((rgb >> 16) & 0xFF) / 256.0 + g = ((rgb >> 8) & 0xFF) / 256.0 + b = (rgb & 0xFF) / 256.0 + + mn = min(r, g, b) + mx = max(r, g, b) + + h = 0.0 + s = 0.0 + l = (mn + mx) / 2.0 + + if mn != mx: + if l < 0.5: + s = (mx - mn) / (mx + mn) + else: + s = (mx - mn) / (2.0 - mx - mn) + + if r == mx: + h = (g - b) / (mx - mn) + elif g == mx: + h = 2.0 + (b - r) / (mx - mn) + elif b == mx: + h = 4.0 + (r - g) / (mx - mn) + + h /= 6.0 + + flo.hue = max(0, min(255, int(h * 256.0))) + flo.saturation = max(0, min(255, int(s * 256.0))) + flo.lightness = max(0, min(255, int(l * 256.0))) + flo.luminance = flo.lightness + + if l > 0.5: + flo.blend_hue_multiplier = int((1.0 - l) * s * 512.0) + else: + flo.blend_hue_multiplier = int(l * s * 512.0) + + if flo.blend_hue_multiplier < 1: + flo.blend_hue_multiplier = 1 + + flo.blend_hue = int(h * flo.blend_hue_multiplier) + flo.hsl16 = _hsl24to16(flo.hue, flo.saturation, flo.luminance) + + +def _hsl24to16(h: int, s: int, l: int) -> int: + """Convert 24-bit HSL to 16-bit packed HSL (FloorDefinition.hsl24to16).""" + if l > 179: + s //= 2 + if l > 192: + s //= 2 + if l > 217: + s //= 2 + if l > 243: + s //= 2 + return ((h // 4) << 10) + ((s // 32) << 7) + l // 2 + + +def decode_floor_definitions(cache: CacheReader) -> tuple[dict[int, FloorDef], dict[int, FloorDef]]: + """Decode underlay and overlay floor definitions from cache.""" + raw = cache.get(CONFIG_INDEX, 2) # CONFIG_ARCHIVE + if raw is None: + sys.exit("could not read config archive") + + archive = decode_archive(raw) + + underlays: dict[int, FloorDef] = {} + overlays: dict[int, FloorDef] = {} + + # decode underlays + ukey = hash_archive_name("underlays.dat") & 0xFFFFFFFF + udata = archive.get(ukey) or archive.get(hash_archive_name("underlays.dat")) + if udata: + buf = io.BytesIO(udata) + count = struct.unpack(">H", buf.read(2))[0] + for _ in range(count + 1): + raw_id = buf.read(2) + if len(raw_id) < 2: + break + fid = struct.unpack(">H", raw_id)[0] + if fid == 0xFFFF: + continue + raw_len = buf.read(2) + if len(raw_len) < 2: + break + length = struct.unpack(">H", raw_len)[0] + entry_data = buf.read(length) + + flo = FloorDef(floor_id=fid) + # parse underlay opcodes + ebuf = io.BytesIO(entry_data) + while True: + op = ebuf.read(1) + if not op: + break + op = op[0] + if op == 0: + break + elif op == 1: + flo.rgb = (ebuf.read(1)[0] << 16) + (ebuf.read(1)[0] << 8) + ebuf.read(1)[0] + + # generate HSL + blend fields + if flo.secondary_rgb != -1: + _rgb_to_hsl(flo.secondary_rgb, flo) + _rgb_to_hsl(flo.rgb, flo) + underlays[fid] = flo + + # decode overlays + okey = hash_archive_name("overlays.dat") & 0xFFFFFFFF + odata = archive.get(okey) or archive.get(hash_archive_name("overlays.dat")) + if odata: + buf = io.BytesIO(odata) + count = struct.unpack(">H", buf.read(2))[0] + for _ in range(count + 1): + raw_id = buf.read(2) + if len(raw_id) < 2: + break + fid = struct.unpack(">H", raw_id)[0] + if fid == 0xFFFF: + continue + raw_len = buf.read(2) + if len(raw_len) < 2: + break + length = struct.unpack(">H", raw_len)[0] + entry_data = buf.read(length) + + flo = FloorDef(floor_id=fid) + ebuf = io.BytesIO(entry_data) + while True: + op = ebuf.read(1) + if not op: + break + op = op[0] + if op == 0: + break + elif op == 1: + flo.rgb = (ebuf.read(1)[0] << 16) + (ebuf.read(1)[0] << 8) + ebuf.read(1)[0] + elif op == 2: + flo.texture = ebuf.read(1)[0] + elif op == 5: + flo.hide_underlay = False + elif op == 7: + flo.secondary_rgb = (ebuf.read(1)[0] << 16) + (ebuf.read(1)[0] << 8) + ebuf.read(1)[0] + + # post-decode HSL + if flo.secondary_rgb != -1: + _rgb_to_hsl(flo.secondary_rgb, flo) + flo.secondary_hue = flo.hue + flo.secondary_saturation = flo.saturation + flo.secondary_lightness = flo.lightness + _rgb_to_hsl(flo.rgb, flo) + overlays[fid] = flo + + return underlays, overlays + + +# --- modern cache floor definition + texture color decoders --- + +# modern cache config index groups for floor definitions +MODERN_UNDERLAY_GROUP = 1 +MODERN_OVERLAY_GROUP = 4 + + +def _decode_underlay_entry(fid: int, data: bytes) -> FloorDef: + """Decode a single underlay floor definition from modern cache opcode stream. + + Underlay opcodes: 1=rgb(3 bytes), 0=terminator. + """ + flo = FloorDef(floor_id=fid) + buf = io.BytesIO(data) + while True: + op = buf.read(1) + if not op or op[0] == 0: + break + if op[0] == 1: + flo.rgb = (buf.read(1)[0] << 16) + (buf.read(1)[0] << 8) + buf.read(1)[0] + if flo.secondary_rgb != -1: + _rgb_to_hsl(flo.secondary_rgb, flo) + _rgb_to_hsl(flo.rgb, flo) + return flo + + +def _decode_overlay_entry(fid: int, data: bytes) -> FloorDef: + """Decode a single overlay floor definition from modern cache opcode stream. + + Overlay opcodes (matching RuneLite OverlayDefinition): + 1=rgb(3 bytes), 2=texture(1 byte), 5=hide_underlay=false (no data), + 7=secondary_rgb(3 bytes), 0=terminator. + """ + flo = FloorDef(floor_id=fid) + buf = io.BytesIO(data) + while True: + op = buf.read(1) + if not op or op[0] == 0: + break + opcode = op[0] + if opcode == 1: + flo.rgb = (buf.read(1)[0] << 16) + (buf.read(1)[0] << 8) + buf.read(1)[0] + elif opcode == 2: + flo.texture = buf.read(1)[0] + elif opcode == 5: + flo.hide_underlay = False + elif opcode == 7: + flo.secondary_rgb = (buf.read(1)[0] << 16) + (buf.read(1)[0] << 8) + buf.read(1)[0] + # post-decode HSL + if flo.secondary_rgb != -1: + _rgb_to_hsl(flo.secondary_rgb, flo) + flo.secondary_hue = flo.hue + flo.secondary_saturation = flo.saturation + flo.secondary_lightness = flo.lightness + _rgb_to_hsl(flo.rgb, flo) + return flo + + +def decode_floor_definitions_modern( + reader: ModernCacheReader, +) -> tuple[dict[int, FloorDef], dict[int, FloorDef]]: + """Decode underlay and overlay floor definitions from modern cache. + + Modern cache stores floor defs in config index 2: + group 1 = underlays, group 4 = overlays. + Each entry is a separate file within the group. + """ + underlays: dict[int, FloorDef] = {} + overlays: dict[int, FloorDef] = {} + + # underlays: config index 2, group 1 + underlay_files = reader.read_group(2, MODERN_UNDERLAY_GROUP) + for fid, data in underlay_files.items(): + underlays[fid] = _decode_underlay_entry(fid, data) + + # overlays: config index 2, group 4 + overlay_files = reader.read_group(2, MODERN_OVERLAY_GROUP) + for fid, data in overlay_files.items(): + overlays[fid] = _decode_overlay_entry(fid, data) + + return underlays, overlays + + +def load_texture_average_colors_modern(reader: ModernCacheReader) -> dict[int, int]: + """Load per-texture average color (HSL16) from modern cache. + + Modern OSRS (rev233+) texture format in index 9, group 0: + u16 fileId (sprite group in index 8) + u16 missingColor (HSL16 fallback — used as the texture's average color) + u8 field1778 + u8 animationDirection + u8 animationSpeed + + The missingColor field is the precomputed average color of the texture + sprite, stored as 16-bit packed HSL. This is what the OSRS client uses + for terrain tile coloring. + """ + tex_files = reader.read_group(9, 0) + result: dict[int, int] = {} + for tex_id, data in tex_files.items(): + if len(data) < 4: + continue + missing_color = struct.unpack(">H", data[2:4])[0] + result[tex_id] = missing_color + return result + + +def djb2(name: str) -> int: + """Compute djb2 hash for modern cache group name lookup.""" + h = 0 + for c in name.lower(): + h = (h * 31 + ord(c)) & 0xFFFFFFFF + if h >= 0x80000000: + h -= 0x100000000 + return h + + +def find_map_groups( + reader: ModernCacheReader, +) -> dict[int, tuple[int | None, int | None]]: + """Build mapping of mapsquare -> (terrain_group_id, object_group_id). + + Scans the index 5 manifest for groups whose djb2 name hashes match + m{rx}_{ry} (terrain) patterns. + """ + manifest = reader.read_index_manifest(5) + + hash_to_gid: dict[int, int] = {} + for gid in manifest.group_ids: + nh = manifest.group_name_hashes.get(gid) + if nh is not None: + hash_to_gid[nh] = gid + + result: dict[int, tuple[int | None, int | None]] = {} + for rx in range(256): + for ry in range(256): + terrain_hash = djb2(f"m{rx}_{ry}") + obj_hash = djb2(f"l{rx}_{ry}") + + terrain_gid = hash_to_gid.get(terrain_hash) + obj_gid = hash_to_gid.get(obj_hash) + + if terrain_gid is not None or obj_gid is not None: + mapsquare = (rx << 8) | ry + result[mapsquare] = (terrain_gid, obj_gid) + + return result + + +# --- terrain parser --- + + +@dataclass +class RegionTerrain: + """Parsed terrain data for one 64x64 region.""" + + region_x: int = 0 + region_y: int = 0 + # [4][64+1][64+1] - tile corner heights (need +1 for neighbor corners) + heights: list = field(default_factory=list) + # [4][64][64] + underlay_ids: list = field(default_factory=list) + overlay_ids: list = field(default_factory=list) + shapes: list = field(default_factory=list) + rotations: list = field(default_factory=list) + settings: list = field(default_factory=list) + + +def parse_terrain_full( + data: bytes, region_chunk_x: int, region_chunk_y: int +) -> RegionTerrain: + """Parse terrain binary to extract heights, underlays, overlays, shapes, settings.""" + rt = RegionTerrain() + rt.heights = [[[0 for _ in range(65)] for _ in range(65)] for _ in range(4)] + rt.underlay_ids = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + rt.overlay_ids = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + rt.shapes = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + rt.rotations = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + rt.settings = [[[0 for _ in range(64)] for _ in range(64)] for _ in range(4)] + + buf = io.BytesIO(data) + + for plane in range(4): + for x in range(64): + for y in range(64): + rt.settings[plane][x][y] = 0 + while True: + raw = buf.read(2) + if len(raw) < 2: + break + attr = struct.unpack(">H", raw)[0] + + if attr == 0: + # procedural height + if plane == 0: + rt.heights[0][x][y] = -calculate_height( + 0xE3B7B + x + region_chunk_x, + 0x87CCE + y + region_chunk_y, + ) * 8 + else: + rt.heights[plane][x][y] = rt.heights[plane - 1][x][y] - 240 + break + elif attr == 1: + height = buf.read(1)[0] + if height == 1: + height = 0 + if plane == 0: + rt.heights[0][x][y] = -height * 8 + else: + rt.heights[plane][x][y] = rt.heights[plane - 1][x][y] - height * 8 + break + elif attr <= 49: + rt.overlay_ids[plane][x][y] = struct.unpack(">h", buf.read(2))[0] + rt.shapes[plane][x][y] = (attr - 2) // 4 + rt.rotations[plane][x][y] = (attr - 2) & 3 + elif attr <= 81: + rt.settings[plane][x][y] = attr - 49 + else: + rt.underlay_ids[plane][x][y] = attr - 81 + + return rt + + +# --- lighting + color blending (mirrors method171) --- + + +def hsl_encode(hue: int, sat: int, light: int) -> int: + """Encode HSL to 16-bit (method177 / hsl24to16).""" + if light > 179: + sat //= 2 + if light > 192: + sat //= 2 + if light > 217: + sat //= 2 + if light > 243: + sat //= 2 + return ((hue // 4) << 10) + ((sat // 32) << 7) + light // 2 + + +def apply_light(hsl16: int, light: int) -> int: + """Apply lighting intensity to an HSL16 color (method187).""" + if hsl16 == -1: + return 0xBC614E + l = (light * (hsl16 & 0x7F)) // 128 + if l < 2: + l = 2 + elif l > 126: + l = 126 + return (hsl16 & 0xFF80) + l + + +def hsl16_to_rgb(hsl16: int) -> tuple[int, int, int]: + """Convert 16-bit packed HSL to RGB. + + 16-bit HSL: (hue << 10) | (sat << 7) | lightness + hue: 6 bits (0-63), sat: 3 bits (0-7), lightness: 7 bits (0-127) + Same as the Rasterizer3D palette lookup. + """ + if hsl16 == 0xBC614E or hsl16 < 0: + return (0, 0, 0) + + h_raw = (hsl16 >> 10) & 0x3F + s_raw = (hsl16 >> 7) & 0x7 + l_raw = hsl16 & 0x7F + + hue_f = h_raw / 64.0 + 0.0078125 + sat_f = s_raw / 8.0 + 0.0625 + light_f = l_raw / 128.0 + + r, g, b = light_f, light_f, light_f + + if sat_f != 0.0: + if light_f < 0.5: + q = light_f * (1.0 + sat_f) + else: + q = light_f + sat_f - light_f * sat_f + p = 2.0 * light_f - q + + h_r = hue_f + 1.0 / 3.0 + if h_r > 1.0: + h_r -= 1.0 + h_g = hue_f + h_b = hue_f - 1.0 / 3.0 + if h_b < 0.0: + h_b += 1.0 + + r = _hue_channel(p, q, h_r) + g = _hue_channel(p, q, h_g) + b = _hue_channel(p, q, h_b) + + ri = max(0, min(255, int(r * 256.0))) + gi = max(0, min(255, int(g * 256.0))) + bi = max(0, min(255, int(b * 256.0))) + return (ri, gi, bi) + + +def _hue_channel(p: float, q: float, t: float) -> float: + if 6.0 * t < 1.0: + return p + (q - p) * 6.0 * t + if 2.0 * t < 1.0: + return q + if 3.0 * t < 2.0: + return p + (q - p) * (2.0 / 3.0 - t) * 6.0 + return p + + +# --- terrain mesh builder --- + + +@dataclass +class TerrainVertex: + """A single terrain vertex with position and color.""" + + x: float = 0.0 + y: float = 0.0 # height (OSRS Y = up) + z: float = 0.0 + r: int = 0 + g: int = 0 + b: int = 0 + + +def stitch_region_edges( + regions: dict[tuple[int, int], RegionTerrain], +) -> None: + """Stitch height values at region boundaries. + + Each region's height array is [65][65] but only indices 0..63 are computed. + Index 64 (the far edge) defaults to 0, causing visible seams. Fix by copying + heights from the neighboring region's index 0 to the current region's index 64. + + Also computes edge heights for regions without a neighbor by extrapolating + from the nearest interior values. + """ + for (rx, ry), rt in regions.items(): + for plane in range(4): + # stitch X=64 edge from neighbor (rx+1, ry) + neighbor_x = regions.get((rx + 1, ry)) + for y in range(65): + if neighbor_x and y < 65: + # use neighbor's x=0 column + ny = min(y, 64) + rt.heights[plane][64][y] = neighbor_x.heights[plane][0][ny] + elif y <= 63: + # no neighbor: extrapolate from x=63 + rt.heights[plane][64][y] = rt.heights[plane][63][y] + + # stitch Y=64 edge from neighbor (rx, ry+1) + neighbor_y = regions.get((rx, ry + 1)) + for x in range(65): + if neighbor_y and x < 65: + nx = min(x, 64) + rt.heights[plane][x][64] = neighbor_y.heights[plane][nx][0] + elif x <= 63: + rt.heights[plane][x][64] = rt.heights[plane][x][63] + + # corner (64, 64): prefer diagonal neighbor + diag = regions.get((rx + 1, ry + 1)) + if diag: + rt.heights[plane][64][64] = diag.heights[plane][0][0] + elif neighbor_x: + rt.heights[plane][64][64] = neighbor_x.heights[plane][0][min(64, 63)] + elif neighbor_y: + rt.heights[plane][64][64] = neighbor_y.heights[plane][min(64, 63)][0] + else: + rt.heights[plane][64][64] = rt.heights[plane][63][63] + + +def build_terrain_mesh( + regions: dict[tuple[int, int], RegionTerrain], + underlays: dict[int, FloorDef], + overlays: dict[int, FloorDef], + target_plane: int = 0, + tex_colors: dict[int, int] | None = None, + brightness: float = 1.0, +) -> tuple[list[float], list[int]]: + """Build a vertex-colored terrain mesh from parsed regions. + + Returns (vertices[N*3], colors[N*4]) as flat lists. + Each tile = 2 triangles = 6 vertices. + """ + verts: list[float] = [] + colors: list[int] = [] + + # we work region by region. for each tile, emit 2 triangles. + # world coords: base_x = region_x * 64, base_y = region_y * 64 + # OSRS tile = 128 world units. we'll use 1 tile = 1 unit for simplicity. + + for (rx, ry), rt in sorted(regions.items()): + base_wx = rx * 64 + base_wy = ry * 64 + + z = target_plane + + # compute lighting per vertex (mirrors method171 lighting pass) + # intensity[65][65] for tile corners + base_intensity = int(96 * brightness) + intensity = [[base_intensity] * 65 for _ in range(65)] + + light_x, light_y, light_z = -50, -10, -50 + light_len = int(math.sqrt(light_x * light_x + light_y * light_y + light_z * light_z)) + distribution = (768 * light_len) >> 8 + + for ty in range(65): + for tx in range(65): + dx = rt.heights[z][min(tx + 1, 64)][ty] - rt.heights[z][max(tx - 1, 0)][ty] + dy = rt.heights[z][tx][min(ty + 1, 64)] - rt.heights[z][tx][max(ty - 1, 0)] + length = int(math.sqrt(dx * dx + 256 * 256 + dy * dy)) + if length == 0: + length = 1 + nx = (dx << 8) // length + ny = (256 << 8) // length + nz = (dy << 8) // length + intensity[tx][ty] = base_intensity + (light_x * nx + light_y * ny + light_z * nz) // distribution + + # compute blended underlay colors using 11x11 window + # for simplicity, compute per-tile center color + for ty in range(64): + for tx in range(64): + uid = rt.underlay_ids[z][tx][ty] + oid = rt.overlay_ids[z][tx][ty] & 0xFFFF + + # default: dark ground color + tile_rgb = (40, 50, 30) + + if uid > 0 or oid > 0: + # get 4 corner heights + h_sw = rt.heights[z][tx][ty] + h_se = rt.heights[z][min(tx + 1, 64)][ty] + h_ne = rt.heights[z][min(tx + 1, 64)][min(ty + 1, 64)] + h_nw = rt.heights[z][tx][min(ty + 1, 64)] + + # underlay color: blend in 11x11 window + if uid > 0: + blend_h, blend_s, blend_l, blend_m, blend_c = 0, 0, 0, 0, 0 + for bx in range(max(0, tx - 5), min(64, tx + 6)): + for by in range(max(0, ty - 5), min(64, ty + 6)): + uid2 = rt.underlay_ids[z][bx][by] + if uid2 > 0: + flo = underlays.get(uid2 - 1) + if flo: + blend_h += flo.blend_hue + blend_s += flo.saturation + blend_l += flo.luminance + blend_m += flo.blend_hue_multiplier + blend_c += 1 + + if blend_c > 0 and blend_m > 0: + avg_hue = (blend_h * 256) // blend_m + avg_sat = blend_s // blend_c + avg_lum = blend_l // blend_c + underlay_hsl16 = hsl_encode(avg_hue, avg_sat, avg_lum) + else: + underlay_hsl16 = -1 + else: + underlay_hsl16 = -1 + + # corner lighting + l_sw = intensity[tx][ty] + l_se = intensity[min(tx + 1, 64)][ty] + l_ne = intensity[min(tx + 1, 64)][min(ty + 1, 64)] + l_nw = intensity[tx][min(ty + 1, 64)] + avg_light = (l_sw + l_se + l_ne + l_nw) // 4 + + if oid > 0: + # overlay tile + oflo = overlays.get(oid - 1) + if oflo and oflo.rgb == 0xFF00FF: + continue # void tile — skip geometry so objects show through + elif oflo and oflo.texture >= 0 and tex_colors: + # textured overlay: use texture average color (water, dirt, stone) + tex_hsl = tex_colors.get(oflo.texture) + if tex_hsl is not None: + lit = apply_light(tex_hsl, avg_light) + tile_rgb = hsl16_to_rgb(lit) + elif underlay_hsl16 >= 0: + lit = apply_light(underlay_hsl16, avg_light) + tile_rgb = hsl16_to_rgb(lit) + elif oflo and oflo.texture < 0: + overlay_hsl16 = hsl_encode(oflo.hue, oflo.saturation, oflo.lightness) + lit = apply_light(overlay_hsl16, avg_light) + tile_rgb = hsl16_to_rgb(lit) + else: + # no overlay def — use underlay + if underlay_hsl16 >= 0: + lit = apply_light(underlay_hsl16, avg_light) + tile_rgb = hsl16_to_rgb(lit) + else: + # pure underlay tile + if underlay_hsl16 >= 0: + lit = apply_light(underlay_hsl16, avg_light) + tile_rgb = hsl16_to_rgb(lit) + + # emit 2 triangles (SW, SE, NE) and (SW, NE, NW) + # world position: tile (tx, ty) in region (rx, ry) + wx = float(base_wx + tx) + wz = float(base_wy + ty) + + # OSRS heights are negative (higher = more negative) + # negate so mountains go up, scale by 1/128 to get tile units + scale_h = -1.0 / 128.0 + + y_sw = float(h_sw) * scale_h + y_se = float(h_se) * scale_h + y_ne = float(h_ne) * scale_h + y_nw = float(h_nw) * scale_h + + r, g, b = tile_rgb + + # negate Z so OSRS north (+Y) maps to -Z (correct for right-handed coords) + nz = -wz + + # triangle 1: SW, SE, NE (with negated Z, gives upward normals) + verts.extend([wx, y_sw, nz, wx + 1, y_se, nz, wx + 1, y_ne, nz - 1]) + colors.extend([r, g, b, 255] * 3) + + # triangle 2: SW, NE, NW + verts.extend([wx, y_sw, nz, wx + 1, y_ne, nz - 1, wx, y_nw, nz - 1]) + colors.extend([r, g, b, 255] * 3) + + else: + # empty tile (no underlay or overlay) — skip entirely + pass + + return verts, colors + + +# --- binary output --- + +TERR_MAGIC = 0x54455252 # "TERR" + + +def build_heightmap( + regions: dict[tuple[int, int], RegionTerrain], + target_plane: int = 0, +) -> tuple[int, int, int, int, list[float]]: + """Build a flat heightmap grid from parsed regions. + + Returns (min_x, min_y, width, height, heights[width*height]). + Heights are in terrain units (OSRS height / 128.0). + """ + min_rx = min(rx for rx, _ in regions.keys()) + max_rx = max(rx for rx, _ in regions.keys()) + min_ry = min(ry for _, ry in regions.keys()) + max_ry = max(ry for _, ry in regions.keys()) + + min_wx = min_rx * 64 + min_wy = min_ry * 64 + width = (max_rx - min_rx + 1) * 64 + height = (max_ry - min_ry + 1) * 64 + + # default to 0 (flat) for uncovered tiles + hmap = [0.0] * (width * height) + scale_h = -1.0 / 128.0 + + for (rx, ry), rt in regions.items(): + base_x = rx * 64 - min_wx + base_y = ry * 64 - min_wy + for tx in range(64): + for ty in range(64): + idx = (base_x + tx) + (base_y + ty) * width + hmap[idx] = float(rt.heights[target_plane][tx][ty]) * scale_h + + return min_wx, min_wy, width, height, hmap + + +def write_terrain_binary( + output_path: Path, + verts: list[float], + colors: list[int], + regions: dict[tuple[int, int], RegionTerrain], + heightmap: tuple[int, int, int, int, list[float]], +) -> None: + """Write terrain mesh to binary .terrain file. + + Format: + magic: uint32 "TERR" + vertex_count: uint32 + region_count: uint32 + min_world_x: int32 (for coordinate offset) + min_world_y: int32 + vertices: float32[vertex_count * 3] (x, y, z) + colors: uint8[vertex_count * 4] (r, g, b, a) + heightmap_min_x: int32 + heightmap_min_y: int32 + heightmap_width: uint32 + heightmap_height: uint32 + heightmap: float32[width * height] + """ + output_path.parent.mkdir(parents=True, exist_ok=True) + vert_count = len(verts) // 3 + + # find min world coords for centering + min_wx = min(rx * 64 for rx, _ in regions.keys()) + min_wy = min(ry * 64 for _, ry in regions.keys()) + + hm_min_x, hm_min_y, hm_w, hm_h, hm_data = heightmap + + with open(output_path, "wb") as f: + f.write(struct.pack(" None: + """Export terrain from 317-format cache.""" + if not args.cache.exists(): + sys.exit(f"cache directory not found: {args.cache}") + + print(f"reading cache from {args.cache}") + cache = CacheReader(args.cache) + + print("loading floor definitions...") + underlays, overlays_defs = decode_floor_definitions(cache) + print(f" {len(underlays)} underlays, {len(overlays_defs)} overlays") + + print("loading texture average colors...") + from export_models import load_texture_average_colors + tex_colors = load_texture_average_colors(cache) + print(f" {len(tex_colors)} texture colors") + + print("loading map index...") + region_defs = load_map_index(cache) + print(f" {len(region_defs)} regions in map index") + + # determine target regions + if args.regions: + target_coords = set() + for coord in args.regions.split(): + parts = coord.split(",") + target_coords.add((int(parts[0]), int(parts[1]))) + target_regions = { + (rd.region_x, rd.region_y): rd + for rd in region_defs + if (rd.region_x, rd.region_y) in target_coords + } + print(f" exporting {len(target_regions)} specified regions") + else: + # fight area center: world (3071, 3544) -> regionX=47, regionY=55 + center_rx, center_ry = 47, 55 + r = args.radius + target_regions = { + (rd.region_x, rd.region_y): rd + for rd in region_defs + if center_rx - r <= rd.region_x <= center_rx + r + and center_ry - r <= rd.region_y <= center_ry + r + } + print(f" exporting {len(target_regions)} regions around ({center_rx}, {center_ry})") + + # parse terrain for each region + parsed: dict[tuple[int, int], RegionTerrain] = {} + errors = 0 + + for (rx, ry), rd in sorted(target_regions.items()): + terrain_data = cache.get(MAP_INDEX, rd.terrain_file) + if terrain_data is None: + errors += 1 + continue + + try: + terrain_data = gzip.decompress(terrain_data) + except Exception: + errors += 1 + continue + + region_chunk_x = rx * 64 + region_chunk_y = ry * 64 + + rt = parse_terrain_full(terrain_data, region_chunk_x, region_chunk_y) + rt.region_x = rx + rt.region_y = ry + parsed[(rx, ry)] = rt + + print(f" parsed {len(parsed)} regions, {errors} errors") + _build_and_write(args, parsed, underlays, overlays_defs, tex_colors) + + +def _main_modern(args: argparse.Namespace) -> None: + """Export terrain from modern OpenRS2 cache.""" + if not args.modern_cache.exists(): + sys.exit(f"modern cache directory not found: {args.modern_cache}") + + print(f"reading modern cache from {args.modern_cache}") + reader = ModernCacheReader(args.modern_cache) + + print("loading floor definitions from modern cache...") + underlays, overlays_defs = decode_floor_definitions_modern(reader) + print(f" {len(underlays)} underlays, {len(overlays_defs)} overlays") + + print("loading texture average colors from modern cache...") + tex_colors = load_texture_average_colors_modern(reader) + print(f" {len(tex_colors)} texture colors") + + print("scanning index 5 for map groups...") + map_groups = find_map_groups(reader) + print(f" {len(map_groups)} regions found in index 5") + + # determine target regions + target_coords: set[tuple[int, int]] = set() + if args.regions: + for coord in args.regions.split(): + parts = coord.split(",") + target_coords.add((int(parts[0]), int(parts[1]))) + else: + sys.exit("--regions is required when using --modern-cache") + + print(f" exporting {len(target_coords)} specified regions") + + parsed: dict[tuple[int, int], RegionTerrain] = {} + errors = 0 + + for rx, ry in sorted(target_coords): + ms = (rx << 8) | ry + if ms not in map_groups: + print(f" region ({rx},{ry}): not found in index 5") + errors += 1 + continue + + terrain_gid, _ = map_groups[ms] + if terrain_gid is None: + print(f" region ({rx},{ry}): no terrain group") + errors += 1 + continue + + terrain_data = reader.read_container(5, terrain_gid) + if terrain_data is None: + print(f" region ({rx},{ry}): failed to read terrain") + errors += 1 + continue + + region_chunk_x = rx * 64 + region_chunk_y = ry * 64 + + rt = parse_terrain_full(terrain_data, region_chunk_x, region_chunk_y) + rt.region_x = rx + rt.region_y = ry + parsed[(rx, ry)] = rt + + print(f" parsed {len(parsed)} regions, {errors} errors") + _build_and_write(args, parsed, underlays, overlays_defs, tex_colors) + + +def _build_and_write( + args: argparse.Namespace, + parsed: dict[tuple[int, int], RegionTerrain], + underlays: dict[int, FloorDef], + overlays_defs: dict[int, FloorDef], + tex_colors: dict[int, int], +) -> None: + """Build terrain mesh and heightmap, then write to binary output.""" + if not parsed: + sys.exit("no regions parsed successfully") + + print("stitching region edges...") + stitch_region_edges(parsed) + + print("building terrain mesh...") + brightness = getattr(args, 'brightness', 1.0) + verts, colors = build_terrain_mesh(parsed, underlays, overlays_defs, tex_colors=tex_colors, brightness=brightness) + vert_count = len(verts) // 3 + tri_count = vert_count // 3 + print(f" {vert_count} vertices, {tri_count} triangles") + + print("building heightmap...") + heightmap = build_heightmap(parsed) + hm_min_x, hm_min_y, hm_w, hm_h, _ = heightmap + print(f" {hm_w}x{hm_h} tiles, origin ({hm_min_x}, {hm_min_y})") + + write_terrain_binary(args.output, verts, colors, parsed, heightmap) + file_size = args.output.stat().st_size + print(f"\nwrote {file_size:,} bytes to {args.output}") + + +def main() -> None: + parser = argparse.ArgumentParser(description="export OSRS terrain mesh from cache") + parser.add_argument( + "--cache", + type=Path, + default=Path("../reference/tarnish/game-server/data/cache"), + help="path to 317-format cache directory", + ) + parser.add_argument( + "--modern-cache", + type=Path, + default=None, + help="path to modern OpenRS2 cache directory (overrides --cache)", + ) + parser.add_argument( + "--keys", + type=Path, + default=None, + help="path to XTEA keys JSON (for modern cache object data)", + ) + parser.add_argument( + "--regions", + type=str, + default=None, + help='space-separated region coordinates as rx,ry pairs (e.g. "35,47 35,48")', + ) + parser.add_argument( + "--output", + type=Path, + default=Path("data/wilderness.terrain"), + help="output .terrain binary file", + ) + parser.add_argument( + "--radius", + type=int, + default=2, + help="regions around wilderness center to export (317 mode only, default: 2)", + ) + parser.add_argument( + "--brightness", + type=float, + default=1.0, + help="brightness multiplier for terrain lighting (e.g. 1.8 for caves, default: 1.0)", + ) + args = parser.parse_args() + + if args.modern_cache: + _main_modern(args) + else: + _main_317(args) + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/scripts/export_textures.py b/ocean/osrs/scripts/export_textures.py new file mode 100644 index 0000000000..c2f92143bf --- /dev/null +++ b/ocean/osrs/scripts/export_textures.py @@ -0,0 +1,416 @@ +"""Build a texture atlas for raylib rendering from OSRS 317 cache sprite data. + +Decodes IndexedImage sprites from cache index 5 (gzip-compressed), which contain +the actual vanilla OSRS texture pixel data. Each texture definition in textures.dat +references one or more sprite group IDs; the first sprite in each group is used. + +The atlas is exported as raw RGBA bytes alongside a mapping of texture ID +to atlas UV coordinates, consumed by the object/model exporters. + +Key discovery: sprite data lives in idx5, NOT idx4. The tarnish client's SwiftFUP +integration adds +1 to the index ID when making file requests — so texture sprites +requested as type 4 are stored in idx5. + +Usage: + from export_textures import build_atlas, load_all_texture_sprites +""" + +import gzip +import io +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from export_collision_map import ( + CONFIG_INDEX, + CacheReader, + decode_archive, + hash_archive_name, +) + +TEXTURE_SIZE = 128 # atlas cell size (vanilla sprites are 64x64 or 128x128) +ATLAS_COLS = 16 # textures per row in atlas +SPRITE_CACHE_INDEX = 5 # idx5 holds IndexedImage sprite data (not idx4) + +# IndexedImage format flags +FLAG_VERTICAL = 0b01 +FLAG_ALPHA = 0b10 + + +@dataclass +class SpriteData: + """Decoded sprite pixel data.""" + + width: int = 0 + height: int = 0 + pixels: bytes = b"" # RGBA, row-major, width*height*4 bytes + + +@dataclass +class TextureAtlas: + """Built texture atlas with UV mapping info.""" + + width: int = 0 + height: int = 0 + pixels: bytes = b"" # RGBA, width*height*4 + # mapping: texture_id -> (u_offset, v_offset, u_size, v_size) in [0,1] range + uv_map: dict[int, tuple[float, float, float, float]] = field(default_factory=dict) + # white pixel UV for non-textured faces + white_u: float = 0.0 + white_v: float = 0.0 + + +def _parse_textures_dat(cache: CacheReader) -> dict[int, list[int]]: + """Parse textures.dat to get texture_id -> list of sprite fileIds. + + Mirrors the Texture constructor in tarnish: reads averageRGB, isTransparent, + count, fileIds[], and other fields from each entry. + """ + raw = cache.get(CONFIG_INDEX, 2) + if raw is None: + sys.exit("could not read config archive") + archive = decode_archive(raw) + key = hash_archive_name("textures.dat") + tex_dat = archive.get(key) + if tex_dat is None: + sys.exit("textures.dat not found in archive") + + buf = io.BytesIO(tex_dat) + highest_id = struct.unpack(">H", buf.read(2))[0] + result: dict[int, list[int]] = {} + + for _ in range(highest_id + 1): + tex_id = struct.unpack(">H", buf.read(2))[0] + size = struct.unpack(">H", buf.read(2))[0] + entry_data = buf.read(size) + if len(entry_data) < 4: + if tex_id >= highest_id: + break + continue + + # entry format: averageRGB(2) + isTransparent(1) + count(1) + fileIds(count*2) + ... + eb = io.BytesIO(entry_data) + _avg_rgb = struct.unpack(">H", eb.read(2))[0] + _is_transparent = struct.unpack(">b", eb.read(1))[0] + count = struct.unpack(">B", eb.read(1))[0] + file_ids = [] + for _ in range(count): + fid = struct.unpack(">H", eb.read(2))[0] + file_ids.append(fid) + result[tex_id] = file_ids + + if tex_id >= highest_id: + break + + return result + + +def _decode_indexed_image(data: bytes) -> SpriteData | None: + """Decode IndexedImage format from raw bytes (already decompressed). + + IndexedImage stores metadata at the END of the buffer: + - last 2 bytes: sprite_count + - before that: maxWidth(2), maxHeight(2), paletteLength(1), per-sprite offsets(8*count) + - palette entries (3 bytes each, RGB) before the per-sprite metadata + - pixel data at the START of the buffer + + Returns the first sprite as RGBA SpriteData, or None if invalid. + """ + if len(data) < 9: + return None + + # sprite_count from last 2 bytes + sprite_count = (data[-2] << 8) | data[-1] + if sprite_count == 0 or sprite_count > 100: + return None + + # metadata position + meta_pos = len(data) - 7 - sprite_count * 8 + if meta_pos < 0: + return None + + max_width = (data[meta_pos] << 8) | data[meta_pos + 1] + max_height = (data[meta_pos + 2] << 8) | data[meta_pos + 3] + palette_length = (data[meta_pos + 4] & 0xFF) + 1 + + if max_width < 1 or max_width > 512 or max_height < 1 or max_height > 512: + return None + if palette_length < 2 or palette_length > 256: + return None + + # read per-sprite metadata (only first sprite used) + # offsets: xOffset(2*count), yOffset(2*count), subWidth(2*count), subHeight(2*count) + sprite_meta_start = meta_pos + 5 + buf = io.BytesIO(data) + + # x offsets + buf.seek(sprite_meta_start) + x_offsets = [struct.unpack(">H", buf.read(2))[0] for _ in range(sprite_count)] + + # y offsets + y_offsets = [struct.unpack(">H", buf.read(2))[0] for _ in range(sprite_count)] + + # sub widths + sub_widths = [struct.unpack(">H", buf.read(2))[0] for _ in range(sprite_count)] + + # sub heights + sub_heights = [struct.unpack(">H", buf.read(2))[0] for _ in range(sprite_count)] + + # palette: positioned before the per-sprite metadata + palette_start = meta_pos - (palette_length - 1) * 3 + if palette_start < 0: + return None + + palette = [0] * palette_length # index 0 = transparent + buf.seek(palette_start) + for i in range(1, palette_length): + r = data[buf.tell()] + g = data[buf.tell() + 1] + b = data[buf.tell() + 2] + buf.seek(buf.tell() + 3) + rgb = (r << 16) | (g << 8) | b + palette[i] = rgb if rgb != 0 else 1 # 0 means transparent, remap to 1 + + # decode first sprite's pixel data from start of buffer + sub_w = sub_widths[0] + sub_h = sub_heights[0] + dimension = sub_w * sub_h + if dimension == 0: + return None + + buf.seek(0) + flags = struct.unpack(">B", buf.read(1))[0] + + palette_indices = bytearray(dimension) + pixel_alphas = bytearray(dimension) + + # read palette indices + if (flags & FLAG_VERTICAL) == 0: + # horizontal + for j in range(dimension): + palette_indices[j] = struct.unpack(">b", buf.read(1))[0] & 0xFF + else: + # vertical + for x in range(sub_w): + for y in range(sub_h): + palette_indices[sub_w * y + x] = struct.unpack(">b", buf.read(1))[0] & 0xFF + + # read alpha channel + if (flags & FLAG_ALPHA) != 0: + if (flags & FLAG_VERTICAL) == 0: + for j in range(dimension): + pixel_alphas[j] = struct.unpack(">b", buf.read(1))[0] & 0xFF + else: + for x in range(sub_w): + for y in range(sub_h): + pixel_alphas[sub_w * y + x] = struct.unpack(">b", buf.read(1))[0] & 0xFF + else: + # non-zero palette index = fully opaque + for j in range(dimension): + if palette_indices[j] != 0: + pixel_alphas[j] = 0xFF + + # build RGBA pixels, normalized to max_width x max_height + # (sub-sprite may be smaller with offsets) + x_off = x_offsets[0] + y_off = y_offsets[0] + rgba = bytearray(max_width * max_height * 4) # starts as transparent black + + for y in range(sub_h): + for x in range(sub_w): + src_idx = y * sub_w + x + pi = palette_indices[src_idx] + alpha = pixel_alphas[src_idx] + + dst_x = x + x_off + dst_y = y + y_off + if dst_x >= max_width or dst_y >= max_height: + continue + + dst_idx = (dst_y * max_width + dst_x) * 4 + rgb = palette[pi] + rgba[dst_idx] = (rgb >> 16) & 0xFF # R + rgba[dst_idx + 1] = (rgb >> 8) & 0xFF # G + rgba[dst_idx + 2] = rgb & 0xFF # B + rgba[dst_idx + 3] = alpha # A + + return SpriteData(width=max_width, height=max_height, pixels=bytes(rgba)) + + +def load_all_texture_sprites( + cache: CacheReader, + texture_ids: set[int] | None = None, +) -> dict[int, SpriteData]: + """Load texture sprites by decoding IndexedImage data from cache idx5. + + For each texture ID defined in textures.dat: + 1. Look up the sprite group fileId(s) + 2. Read from idx5, gzip decompress, decode as IndexedImage + 3. Convert to RGBA SpriteData + + Args: + cache: CacheReader for reading textures.dat and sprite data. + texture_ids: optional set of texture IDs to load. If None, loads all. + + Returns: + texture_id -> SpriteData mapping for all successfully decoded textures. + """ + tex_defs = _parse_textures_dat(cache) + + if texture_ids is not None: + ids_to_load = texture_ids + else: + ids_to_load = set(tex_defs.keys()) + + sprites: dict[int, SpriteData] = {} + decoded_count = 0 + failed_count = 0 + + for tex_id in sorted(ids_to_load): + file_ids = tex_defs.get(tex_id) + if not file_ids: + continue + + # use first sprite group ID (most textures have just one) + sprite_group_id = file_ids[0] + raw = cache.get(SPRITE_CACHE_INDEX, sprite_group_id) + if raw is None: + failed_count += 1 + continue + + # gzip decompress + try: + decompressed = gzip.decompress(raw) + except Exception: + failed_count += 1 + continue + + sprite = _decode_indexed_image(decompressed) + if sprite is None: + failed_count += 1 + continue + + sprites[tex_id] = sprite + decoded_count += 1 + + print( + f" textures: {decoded_count} decoded from cache idx5, {failed_count} failed", + file=sys.stderr, + ) + return sprites + + +def build_atlas( + sprites: dict[int, SpriteData], + cell_size: int = TEXTURE_SIZE, +) -> TextureAtlas: + """Build a texture atlas from decoded sprites. + + Layout: grid of cell_size x cell_size cells. + Slot 0: solid white (for non-textured faces -- vertex colors show through). + Slots 1..N: actual texture sprites, resized to cell_size if needed. + + Returns TextureAtlas with RGBA pixel data and UV mapping. + """ + # sort texture IDs for deterministic layout + tex_ids = sorted(sprites.keys()) + total_slots = 1 + len(tex_ids) # slot 0 = white + + cols = ATLAS_COLS + rows = (total_slots + cols - 1) // cols + + atlas_w = cols * cell_size + atlas_h = rows * cell_size + atlas_pixels = bytearray(atlas_w * atlas_h * 4) + + # slot 0: solid white + for y in range(cell_size): + for x in range(cell_size): + idx = (y * atlas_w + x) * 4 + atlas_pixels[idx] = 255 + atlas_pixels[idx + 1] = 255 + atlas_pixels[idx + 2] = 255 + atlas_pixels[idx + 3] = 255 + + uv_map: dict[int, tuple[float, float, float, float]] = {} + + for slot_idx, tex_id in enumerate(tex_ids, start=1): + sprite = sprites[tex_id] + col = slot_idx % cols + row = slot_idx // cols + ax = col * cell_size + ay = row * cell_size + + # copy sprite pixels into atlas cell, resizing if needed + _blit_sprite_to_atlas( + atlas_pixels, atlas_w, ax, ay, cell_size, sprite, + ) + + # UV mapping: normalized coordinates + u_off = ax / atlas_w + v_off = ay / atlas_h + u_size = cell_size / atlas_w + v_size = cell_size / atlas_h + uv_map[tex_id] = (u_off, v_off, u_size, v_size) + + # white pixel UV (center of slot 0) + white_u = 0.5 * cell_size / atlas_w + white_v = 0.5 * cell_size / atlas_h + + return TextureAtlas( + width=atlas_w, + height=atlas_h, + pixels=bytes(atlas_pixels), + uv_map=uv_map, + white_u=white_u, + white_v=white_v, + ) + + +def _blit_sprite_to_atlas( + atlas: bytearray, + atlas_w: int, + ax: int, + ay: int, + cell_size: int, + sprite: SpriteData, +) -> None: + """Copy sprite pixels into an atlas cell, nearest-neighbor resize if needed.""" + sw = sprite.width + sh = sprite.height + sp = sprite.pixels + + for dy in range(cell_size): + for dx in range(cell_size): + # source pixel (nearest neighbor) + sx = dx * sw // cell_size + sy = dy * sh // cell_size + si = (sy * sw + sx) * 4 + + # destination in atlas + di = ((ay + dy) * atlas_w + (ax + dx)) * 4 + + if si + 3 < len(sp): + atlas[di] = sp[si] + atlas[di + 1] = sp[si + 1] + atlas[di + 2] = sp[si + 2] + atlas[di + 3] = sp[si + 3] + + +def write_atlas_binary(path: Path, atlas: TextureAtlas) -> None: + """Write texture atlas as raw RGBA binary with header. + + Format: + uint32 magic = 0x41544C53 ("ATLS") + uint32 width + uint32 height + uint8 pixels[width * height * 4] (RGBA) + """ + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "wb") as f: + f.write(struct.pack(".dat) +""" + +import bz2 +import gzip +import io +import struct +import sys +from dataclasses import dataclass, field +from pathlib import Path + + +# --- binary reading helpers --- + + +def read_u8(buf: io.BytesIO) -> int: + """Read unsigned 8-bit integer.""" + b = buf.read(1) + if not b: + return 0 + return b[0] + + +def read_u16(buf: io.BytesIO) -> int: + """Read big-endian unsigned 16-bit integer.""" + b = buf.read(2) + if len(b) < 2: + return 0 + return struct.unpack(">H", b)[0] + + +def read_u32(buf: io.BytesIO) -> int: + """Read big-endian unsigned 32-bit integer.""" + b = buf.read(4) + if len(b) < 4: + return 0 + return struct.unpack(">I", b)[0] + + +def read_i32(buf: io.BytesIO) -> int: + """Read big-endian signed 32-bit integer.""" + b = buf.read(4) + if len(b) < 4: + return 0 + return struct.unpack(">i", b)[0] + + +def read_u24(buf: io.BytesIO) -> int: + """Read big-endian unsigned 24-bit (medium) integer.""" + b = buf.read(3) + if len(b) < 3: + return 0 + return (b[0] << 16) | (b[1] << 8) | b[2] + + +def read_smart(buf: io.BytesIO) -> int: + """Read RS2 smart value (1 or 2 bytes, unsigned). + + If first byte < 128: returns byte value. + If first byte >= 128: reads 2 bytes, returns value - 32768. + """ + pos = buf.tell() + peek = buf.read(1) + if not peek: + return 0 + if peek[0] < 128: + return peek[0] + buf.seek(pos) + return read_u16(buf) - 32768 + + +def read_big_smart(buf: io.BytesIO) -> int: + """Read RS2 big smart (2 or 4 bytes, unsigned). + + If first byte < 128 (sign bit clear): reads u16. + Otherwise: reads i32 & 0x7FFFFFFF. + """ + pos = buf.tell() + peek = buf.read(1) + if not peek: + return 0 + buf.seek(pos) + if peek[0] < 128: + return read_u16(buf) + return read_i32(buf) & 0x7FFFFFFF + + +def read_string(buf: io.BytesIO) -> str: + """Read null-terminated string.""" + chars = [] + while True: + b = buf.read(1) + if not b or b[0] == 0: + break + chars.append(chr(b[0])) + return "".join(chars) + + +# --- container decompression --- + +COMPRESSION_NONE = 0 +COMPRESSION_BZIP2 = 1 +COMPRESSION_GZIP = 2 + + +def decompress_container(data: bytes) -> bytes: + """Decompress an RS2 container (.dat file). + + Container format: + byte 0: compression type (0=none, 1=bzip2, 2=gzip) + bytes 1-4: compressed length (big-endian u32) + if compressed: bytes 5-8: decompressed length (big-endian u32) + remaining: payload + + For bzip2: RS2 strips the 'BZ' magic header. We prepend 'BZh1' before + feeding to the standard bz2 decompressor. + """ + if len(data) < 5: + msg = f"container too small: {len(data)} bytes" + raise ValueError(msg) + + compression = data[0] + compressed_len = struct.unpack(">I", data[1:5])[0] + + if compression == COMPRESSION_NONE: + return data[5 : 5 + compressed_len] + + if len(data) < 9: + msg = f"compressed container too small: {len(data)} bytes" + raise ValueError(msg) + + decompressed_len = struct.unpack(">I", data[5:9])[0] + payload = data[9 : 9 + compressed_len] + + if compression == COMPRESSION_BZIP2: + # RS2 strips the full 4-byte bzip2 header ('BZh' + block size digit). + # the payload starts directly at the block magic (31 41 59 26...). + # prepend 'BZh1' to reconstruct a valid bzip2 stream. + bz2_data = b"BZh1" + payload + result = bz2.decompress(bz2_data) + elif compression == COMPRESSION_GZIP: + result = gzip.decompress(payload) + else: + msg = f"unknown compression type: {compression}" + raise ValueError(msg) + + if len(result) != decompressed_len: + msg = ( + f"decompressed size mismatch: expected {decompressed_len}, " + f"got {len(result)}" + ) + raise ValueError(msg) + + return result + + +# --- index manifest (RS2 reference table) --- + + +@dataclass +class IndexManifest: + """Parsed RS2 reference table for a cache index. + + Contains metadata about all groups in the index: which file IDs each + group contains, CRCs, revisions, and optional name hashes. + """ + + protocol: int = 0 + revision: int = 0 + has_names: bool = False + group_ids: list[int] = field(default_factory=list) + group_name_hashes: dict[int, int] = field(default_factory=dict) + group_crcs: dict[int, int] = field(default_factory=dict) + group_revisions: dict[int, int] = field(default_factory=dict) + group_file_ids: dict[int, list[int]] = field(default_factory=dict) + group_file_name_hashes: dict[int, dict[int, int]] = field(default_factory=dict) + + +def parse_index_manifest(data: bytes) -> IndexManifest: + """Parse an RS2 reference table from decompressed container data. + + Reference table format (protocol 5-7): + u8 protocol + if protocol >= 6: u32 revision + u8 flags: + bit 0 = has names + bit 1 = has whirlpool digests + bit 2 = has lengths (compressed + decompressed sizes per group) + bit 3 = has uncompressed CRC32s + group count: big_smart if protocol >= 7, else u16 + group IDs: delta-encoded (big_smart if >= 7, else u16) + if has_names: i32[group_count] name hashes + u32[group_count] CRC32s + if has_whirlpool: 64 bytes per group (whirlpool digests, skipped) + if has_lengths: u32[group_count] compressed sizes, u32[group_count] decompressed sizes + if has_uncompressed_crc: u32[group_count] uncompressed CRC32s + u32[group_count] revisions + file counts per group: big_smart if >= 7, else u16 + file IDs per group: delta-encoded (same width) + if has_names: i32[total_files] file name hashes + """ + buf = io.BytesIO(data) + manifest = IndexManifest() + + manifest.protocol = read_u8(buf) + if manifest.protocol < 5 or manifest.protocol > 7: + msg = f"unsupported reference table protocol: {manifest.protocol}" + raise ValueError(msg) + + if manifest.protocol >= 6: + manifest.revision = read_u32(buf) + + flags = read_u8(buf) + manifest.has_names = bool(flags & 0x01) + has_whirlpool = bool(flags & 0x02) + has_lengths = bool(flags & 0x04) + has_uncompressed_crc = bool(flags & 0x08) + + # group count + if manifest.protocol >= 7: + group_count = read_big_smart(buf) + else: + group_count = read_u16(buf) + + # group IDs (delta-encoded) + manifest.group_ids = [] + accumulator = 0 + for _ in range(group_count): + if manifest.protocol >= 7: + delta = read_big_smart(buf) + else: + delta = read_u16(buf) + accumulator += delta + manifest.group_ids.append(accumulator) + + # name hashes + if manifest.has_names: + for gid in manifest.group_ids: + manifest.group_name_hashes[gid] = read_i32(buf) + + # CRC32s + for gid in manifest.group_ids: + manifest.group_crcs[gid] = read_u32(buf) + + # whirlpool digests (64 bytes each, skip) + if has_whirlpool: + buf.read(64 * group_count) + + # compressed + decompressed sizes per group (skip, metadata only) + if has_lengths: + buf.read(4 * group_count) # compressed sizes + buf.read(4 * group_count) # decompressed sizes + + # uncompressed CRC32s (skip) + if has_uncompressed_crc: + buf.read(4 * group_count) + + # revisions + for gid in manifest.group_ids: + manifest.group_revisions[gid] = read_u32(buf) + + # file counts per group + file_counts: dict[int, int] = {} + for gid in manifest.group_ids: + if manifest.protocol >= 7: + file_counts[gid] = read_big_smart(buf) + else: + file_counts[gid] = read_u16(buf) + + # file IDs per group (delta-encoded) + for gid in manifest.group_ids: + count = file_counts[gid] + file_ids = [] + acc = 0 + for _ in range(count): + if manifest.protocol >= 7: + delta = read_big_smart(buf) + else: + delta = read_u16(buf) + acc += delta + file_ids.append(acc) + manifest.group_file_ids[gid] = file_ids + + # file name hashes + if manifest.has_names: + for gid in manifest.group_ids: + fids = manifest.group_file_ids[gid] + name_map: dict[int, int] = {} + for fid in fids: + name_map[fid] = read_i32(buf) + manifest.group_file_name_hashes[gid] = name_map + + return manifest + + +# --- group splitting (multi-file groups) --- + + +def split_group(data: bytes, file_ids: list[int]) -> dict[int, bytes]: + """Split a decompressed group into individual files. + + If the group has only one file, the entire data IS that file. + + Multi-file groups use a trailer format: + - last byte of data = chunk_count + - before that: chunk_count * file_count * 4 bytes of delta-encoded + sizes (big-endian i32) + - data region: chunks concatenated, each chunk has one segment per file + - final file bytes = concatenation of that file's segment from each chunk + + Size encoding (matching RuneLite GroupDecompressor): for each chunk, iterate + through files reading i32 deltas. A running accumulator (reset per chunk) + tracks the current size. The accumulated value IS the segment size for that + file in that chunk. + """ + if len(file_ids) == 1: + return {file_ids[0]: data} + + file_count = len(file_ids) + + # last byte = chunk count + chunk_count = data[-1] + if chunk_count < 1: + msg = f"invalid chunk count: {chunk_count}" + raise ValueError(msg) + + # read the size table from the trailer + # size table: chunk_count * file_count * 4 bytes, right before the last byte + size_table_len = chunk_count * file_count * 4 + size_table_start = len(data) - 1 - size_table_len + if size_table_start < 0: + msg = f"group data too small for {chunk_count} chunks x {file_count} files" + raise ValueError(msg) + + size_buf = io.BytesIO(data[size_table_start : len(data) - 1]) + + # parse delta-encoded sizes: for each chunk, accumulate deltas across files. + # the accumulator resets to 0 at the start of each chunk. the accumulated + # value is the segment size for that file in that chunk. + chunk_sizes: list[list[int]] = [] + for _chunk in range(chunk_count): + sizes = [] + chunk_size = 0 + for _f in range(file_count): + delta = struct.unpack(">i", size_buf.read(4))[0] + chunk_size += delta + sizes.append(chunk_size) + chunk_sizes.append(sizes) + + # extract file data by concatenating each file's segment from each chunk + file_buffers: dict[int, bytearray] = {fid: bytearray() for fid in file_ids} + offset = 0 + for chunk_idx in range(chunk_count): + for f_idx, fid in enumerate(file_ids): + size = chunk_sizes[chunk_idx][f_idx] + file_buffers[fid].extend(data[offset : offset + size]) + offset += size + + return {fid: bytes(buf) for fid, buf in file_buffers.items()} + + +# --- main reader class --- + + +class ModernCacheReader: + """Read OSRS cache in OpenRS2 flat file format. + + Expects a directory with numbered subdirectories (0/, 1/, 2/, ..., 255/) + each containing .dat files named by group ID. + """ + + def __init__(self, cache_dir: str | Path) -> None: + """Initialize with path to cache root directory.""" + self.cache_dir = Path(cache_dir) + if not self.cache_dir.is_dir(): + msg = f"cache directory not found: {self.cache_dir}" + raise FileNotFoundError(msg) + self._manifest_cache: dict[int, IndexManifest] = {} + + def _read_raw(self, index_id: int, group_id: int) -> bytes | None: + """Read raw container bytes from disk.""" + path = self.cache_dir / str(index_id) / f"{group_id}.dat" + if not path.exists(): + return None + return path.read_bytes() + + def read_container(self, index_id: int, group_id: int) -> bytes | None: + """Read and decompress a container from the cache.""" + raw = self._read_raw(index_id, group_id) + if raw is None: + return None + return decompress_container(raw) + + def read_index_manifest(self, index_id: int) -> IndexManifest: + """Read and parse the reference table (manifest) for an index. + + The manifest for index N is stored at 255/N.dat. + """ + if index_id in self._manifest_cache: + return self._manifest_cache[index_id] + + data = self.read_container(255, index_id) + if data is None: + msg = f"manifest not found for index {index_id}" + raise FileNotFoundError(msg) + + manifest = parse_index_manifest(data) + self._manifest_cache[index_id] = manifest + return manifest + + def read_group(self, index_id: int, group_id: int) -> dict[int, bytes]: + """Read a group and split into individual file entries. + + Returns dict mapping file_id -> decompressed file bytes. + """ + manifest = self.read_index_manifest(index_id) + + if group_id not in manifest.group_file_ids: + msg = f"group {group_id} not in index {index_id} manifest" + raise KeyError(msg) + + data = self.read_container(index_id, group_id) + if data is None: + msg = f"group data not found: index={index_id} group={group_id}" + raise FileNotFoundError(msg) + + file_ids = manifest.group_file_ids[group_id] + return split_group(data, file_ids) + + def read_config_entry(self, group_id: int, entry_id: int) -> bytes: + """Read a single config entry from index 2. + + Convenience method: configs live in index 2, where group_id selects + the config type (6=obj/items, 12=seq/animations, 13=spotanim) and + entry_id selects the specific entry. + """ + files = self.read_group(2, group_id) + if entry_id not in files: + msg = f"config entry {entry_id} not found in group {group_id}" + raise KeyError(msg) + return files[entry_id] + + +# --- sequence parsing (modern OSRS cache format, rev226+) --- + + +def _read_frame_sound_rev226(buf: io.BytesIO) -> None: + """Skip a frame sound entry (rev226 format with rev220FrameSounds). + + Format: u16 id, u8 weight, u8 loops, u8 location, u8 retain. + """ + read_u16(buf) # sound id + read_u8(buf) # weight + read_u8(buf) # loops + read_u8(buf) # location + read_u8(buf) # retain + + +@dataclass +class SequenceDef: + """Animation sequence definition parsed from modern OSRS cache. + + Field names match RuneLite's SequenceDefinition. Opcode mapping is for + the modern (rev226+) format used by current OSRS. + """ + + seq_id: int = 0 + frame_count: int = 0 + frame_delays: list[int] = field(default_factory=list) + primary_frame_ids: list[int] = field(default_factory=list) + frame_step: int = -1 + interleave_order: list[int] = field(default_factory=list) + stretches: bool = False + forced_priority: int = 5 + left_hand_item: int = -1 + right_hand_item: int = -1 + max_loops: int = 99 + precedence_animating: int = -1 + priority: int = -1 + reply_mode: int = -1 + + +def parse_sequence(seq_id: int, data: bytes) -> SequenceDef: + """Parse a single sequence from opcode stream (modern OSRS, rev226+). + + Opcode layout from RuneLite's SequenceLoader.decodeValues with rev226=true + and rev220FrameSounds=true (modern OSRS cache revisions are well above + both thresholds of 1141 and 1268). + + Opcode map (rev226=true): + 1: frame data (delays, file IDs, group IDs) + 2: frame step + 3: interleave order + 4: stretches flag + 5: forced priority + 6: left hand item + 7: right hand item + 8: max loops + 9: precedence animating + 10: priority + 11: reply mode + 12: chat frame IDs + 13: animMayaID (i32) — rev226 remaps this from old opcode 14 + 14: frame sounds (u16 count) — rev226 remaps this from old opcode 15 + 15: animMayaStart/End — rev226 remaps this from old opcode 16 + 16: vertical offset (i8) + 17: animMayaMasks + 18: debug name (string) + 19: sounds cross world view flag + """ + seq = SequenceDef(seq_id=seq_id) + buf = io.BytesIO(data) + + while True: + opcode = read_u8(buf) + if opcode == 0: + break + elif opcode == 1: + seq.frame_count = read_u16(buf) + seq.frame_delays = [read_u16(buf) for _ in range(seq.frame_count)] + file_ids = [read_u16(buf) for _ in range(seq.frame_count)] + group_ids = [read_u16(buf) for _ in range(seq.frame_count)] + seq.primary_frame_ids = [ + (group_ids[i] << 16) | file_ids[i] for i in range(seq.frame_count) + ] + elif opcode == 2: + seq.frame_step = read_u16(buf) + elif opcode == 3: + n = read_u8(buf) + seq.interleave_order = [read_u8(buf) for _ in range(n)] + elif opcode == 4: + seq.stretches = True + elif opcode == 5: + seq.forced_priority = read_u8(buf) + elif opcode == 6: + seq.left_hand_item = read_u16(buf) + elif opcode == 7: + seq.right_hand_item = read_u16(buf) + elif opcode == 8: + seq.max_loops = read_u8(buf) + elif opcode == 9: + seq.precedence_animating = read_u8(buf) + elif opcode == 10: + seq.priority = read_u8(buf) + elif opcode == 11: + seq.reply_mode = read_u8(buf) + elif opcode == 12: + # chat frame IDs: u8 count, then u16[count] + u16[count] (low + high) + n = read_u8(buf) + for _ in range(n): + read_u16(buf) + for _ in range(n): + read_u16(buf) + elif opcode == 13: + # rev226: animMayaID (remapped from old opcode 14) + read_i32(buf) + elif opcode == 14: + # rev226: frame sounds (remapped from old opcode 15) + n = read_u16(buf) + for _ in range(n): + read_u16(buf) # frame index + _read_frame_sound_rev226(buf) + elif opcode == 15: + # rev226: animMayaStart + animMayaEnd (remapped from old opcode 16) + read_u16(buf) + read_u16(buf) + elif opcode == 16: + # vertical offset (signed byte) + read_u8(buf) + elif opcode == 17: + # animMayaMasks: u8 count, then u8[count] mask indices + n = read_u8(buf) + for _ in range(n): + read_u8(buf) + elif opcode == 18: + # debug name (null-terminated string) + read_string(buf) + elif opcode == 19: + pass # soundsCrossWorldView = true + else: + print( + f" warning: unknown seq opcode {opcode} for id {seq_id}", + file=sys.stderr, + ) + break + + if seq.frame_count == 0: + seq.frame_count = 1 + seq.primary_frame_ids = [-1] + seq.frame_delays = [-1] + + return seq + + +# --- test --- + + +def main() -> None: + """Test the modern cache reader against a local cache.""" + cache_path = "../reference/osrs-cache-modern/" + print(f"opening cache at {cache_path}") + + reader = ModernCacheReader(cache_path) + + # parse index 2 manifest (configs) + print("\nparsing index 2 (configs) manifest...") + manifest = reader.read_index_manifest(2) + print(f" protocol: {manifest.protocol}") + print(f" revision: {manifest.revision}") + print(f" has_names: {manifest.has_names}") + print(f" total groups: {len(manifest.group_ids)}") + + # report key config groups + key_groups = {6: "obj/items", 12: "seq/animations", 13: "spotanim"} + for gid, name in key_groups.items(): + if gid in manifest.group_file_ids: + file_count = len(manifest.group_file_ids[gid]) + print(f" group {gid} ({name}): {file_count} entries") + else: + print(f" group {gid} ({name}): NOT FOUND") + + # read and parse sequences from group 12 + print("\nreading seq group 12...") + seq_files = reader.read_group(2, 12) + print(f" loaded {len(seq_files)} sequence entries") + + # parse known sequences + test_seqs = {808: "idle", 1979: "cast_barrage"} + for seq_id, name in test_seqs.items(): + if seq_id not in seq_files: + print(f" seq {seq_id} ({name}): NOT FOUND in group") + continue + seq = parse_sequence(seq_id, seq_files[seq_id]) + print( + f" seq {seq_id} ({name}): " + f"frames={seq.frame_count}, " + f"priority={seq.priority}, " + f"forced_priority={seq.forced_priority}, " + f"precedence_animating={seq.precedence_animating}, " + f"delays={seq.frame_delays[:5]}{'...' if len(seq.frame_delays) > 5 else ''}" + ) + + # also check a spotanim entry from group 13 + print("\nreading spotanim group 13...") + spotanim_files = reader.read_group(2, 13) + print(f" loaded {len(spotanim_files)} spotanim entries") + + # check a few obj entries from group 6 + print("\nreading obj group 6 (sampling first 3 entries)...") + obj_files = reader.read_group(2, 6) + print(f" loaded {len(obj_files)} obj/item entries") + sample_ids = sorted(obj_files.keys())[:3] + for oid in sample_ids: + print(f" obj {oid}: {len(obj_files[oid])} bytes") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/test_collision.c b/ocean/osrs/test_collision.c new file mode 100644 index 0000000000..f6868ae6ea --- /dev/null +++ b/ocean/osrs/test_collision.c @@ -0,0 +1,422 @@ +/** + * @file test_collision.c + * @brief Tests for the collision system and BFS pathfinder + * + * Validates that collision flags block movement correctly, that the pathfinder + * routes around obstacles, and that NULL collision map preserves flat arena behavior. + * + * Compile: cc -O2 -o test_collision test_collision.c -lm + * Run: ./test_collision + */ + +#include +#include +#include +#include "osrs_collision.h" +#include "osrs_pathfinding.h" + +static int tests_passed = 0; +static int tests_failed = 0; + +#define TEST(name) static void name(void) +#define RUN(name) do { \ + printf(" %-50s", #name); \ + name(); \ + printf("PASS\n"); \ + tests_passed++; \ +} while(0) + +#define ASSERT(cond) do { \ + if (!(cond)) { \ + printf("FAIL at %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + tests_failed++; \ + return; \ + } \ +} while(0) + +/* ========================================================================= + * collision map basics + * ========================================================================= */ + +TEST(test_null_map_all_traversable) { + ASSERT(collision_traversable_north(NULL, 0, 100, 100)); + ASSERT(collision_traversable_south(NULL, 0, 100, 100)); + ASSERT(collision_traversable_east(NULL, 0, 100, 100)); + ASSERT(collision_traversable_west(NULL, 0, 100, 100)); + ASSERT(collision_traversable_north_east(NULL, 0, 100, 100)); + ASSERT(collision_traversable_north_west(NULL, 0, 100, 100)); + ASSERT(collision_traversable_south_east(NULL, 0, 100, 100)); + ASSERT(collision_traversable_south_west(NULL, 0, 100, 100)); + ASSERT(collision_tile_walkable(NULL, 0, 100, 100)); + ASSERT(collision_traversable_step(NULL, 0, 100, 100, 1, 1)); +} + +TEST(test_empty_map_all_traversable) { + CollisionMap* map = collision_map_create(); + ASSERT(collision_traversable_north(map, 0, 100, 100)); + ASSERT(collision_traversable_south(map, 0, 100, 100)); + ASSERT(collision_tile_walkable(map, 0, 100, 100)); + collision_map_free(map); +} + +TEST(test_region_create_and_lookup) { + CollisionMap* map = collision_map_create(); + ASSERT(map->count == 0); + + /* setting a flag auto-creates the region */ + collision_set_flag(map, 0, 3200, 3200, COLLISION_BLOCKED); + ASSERT(map->count == 1); + + int flags = collision_get_flags(map, 0, 3200, 3200); + ASSERT(flags == COLLISION_BLOCKED); + + /* different tile in same region doesn't add another region */ + collision_set_flag(map, 0, 3201, 3201, COLLISION_WALL_NORTH); + ASSERT(map->count == 1); + + /* tile in a different region increments count */ + collision_set_flag(map, 0, 3264, 3200, COLLISION_BLOCKED); + ASSERT(map->count == 2); + + collision_map_free(map); +} + +TEST(test_flag_set_and_unset) { + CollisionMap* map = collision_map_create(); + collision_set_flag(map, 0, 3200, 3200, COLLISION_WALL_NORTH | COLLISION_WALL_SOUTH); + int flags = collision_get_flags(map, 0, 3200, 3200); + ASSERT(flags == (COLLISION_WALL_NORTH | COLLISION_WALL_SOUTH)); + + collision_unset_flag(map, 0, 3200, 3200, COLLISION_WALL_NORTH); + flags = collision_get_flags(map, 0, 3200, 3200); + ASSERT(flags == COLLISION_WALL_SOUTH); + + collision_map_free(map); +} + +/* ========================================================================= + * directional traversal checks + * ========================================================================= */ + +TEST(test_blocked_tile_not_traversable) { + CollisionMap* map = collision_map_create(); + + /* block tile (100, 101) — north of (100, 100) */ + collision_mark_blocked(map, 0, 100, 101); + + /* can't walk north from (100, 100) because (100, 101) is blocked */ + ASSERT(!collision_traversable_north(map, 0, 100, 100)); + + /* can still walk south, east, west from (100, 100) */ + ASSERT(collision_traversable_south(map, 0, 100, 100)); + ASSERT(collision_traversable_east(map, 0, 100, 100)); + ASSERT(collision_traversable_west(map, 0, 100, 100)); + + collision_map_free(map); +} + +TEST(test_wall_blocks_direction) { + CollisionMap* map = collision_map_create(); + + /* place a south-facing wall on tile (100, 101). + * this means: entering (100, 101) from the south is blocked. */ + collision_set_flag(map, 0, 100, 101, COLLISION_WALL_SOUTH); + + /* walking north from (100, 100) to (100, 101) is blocked (wall on south side of dest) */ + ASSERT(!collision_traversable_north(map, 0, 100, 100)); + + /* walking south from (100, 102) to (100, 101) is fine — no north wall on dest */ + ASSERT(collision_traversable_south(map, 0, 100, 102)); + + collision_map_free(map); +} + +TEST(test_diagonal_blocked_by_intermediate) { + CollisionMap* map = collision_map_create(); + + /* block the east intermediate tile (101, 100) — this should block NE diagonal */ + collision_mark_blocked(map, 0, 101, 100); + + /* NE from (100, 100): checks (101, 101) + (101, 100) + (100, 101) */ + /* (101, 100) is blocked, so diagonal should fail */ + ASSERT(!collision_traversable_north_east(map, 0, 100, 100)); + + /* NW from (100, 100) should still work (different intermediate tiles) */ + ASSERT(collision_traversable_north_west(map, 0, 100, 100)); + + collision_map_free(map); +} + +TEST(test_multi_tile_occupant) { + CollisionMap* map = collision_map_create(); + + /* place a 2x2 object at (100, 100) */ + collision_mark_occupant(map, 0, 100, 100, 2, 2, 0); + + ASSERT(!collision_tile_walkable(map, 0, 100, 100)); + ASSERT(!collision_tile_walkable(map, 0, 101, 100)); + ASSERT(!collision_tile_walkable(map, 0, 100, 101)); + ASSERT(!collision_tile_walkable(map, 0, 101, 101)); + ASSERT(collision_tile_walkable(map, 0, 102, 100)); /* outside the object */ + + collision_map_free(map); +} + +/* ========================================================================= + * binary save/load + * ========================================================================= */ + +TEST(test_save_and_load) { + CollisionMap* map = collision_map_create(); + collision_mark_blocked(map, 0, 3200, 3520); + collision_set_flag(map, 0, 3201, 3520, COLLISION_WALL_NORTH | COLLISION_WALL_EAST); + collision_mark_blocked(map, 0, 3264, 3520); /* different region */ + + const char* path = "/tmp/test_collision.cmap"; + int rc = collision_map_save(map, path); + ASSERT(rc == 0); + + CollisionMap* loaded = collision_map_load(path); + ASSERT(loaded != NULL); + ASSERT(loaded->count == 2); + + ASSERT(collision_get_flags(loaded, 0, 3200, 3520) == COLLISION_BLOCKED); + ASSERT(collision_get_flags(loaded, 0, 3201, 3520) == (COLLISION_WALL_NORTH | COLLISION_WALL_EAST)); + ASSERT(collision_get_flags(loaded, 0, 3264, 3520) == COLLISION_BLOCKED); + ASSERT(collision_get_flags(loaded, 0, 3202, 3520) == COLLISION_NONE); /* untouched tile */ + + collision_map_free(map); + collision_map_free(loaded); + remove(path); +} + +/* ========================================================================= + * pathfinding + * ========================================================================= */ + +TEST(test_pathfind_already_at_dest) { + PathResult r = pathfind_step(NULL, 0, 100, 100, 100, 100); + ASSERT(r.found == 1); + ASSERT(r.next_dx == 0 && r.next_dy == 0); +} + +TEST(test_pathfind_straight_line_no_obstacles) { + /* no collision map — straight path east */ + PathResult r = pathfind_step(NULL, 0, 100, 100, 105, 100); + ASSERT(r.found == 1); + ASSERT(r.next_dx == 1); + ASSERT(r.next_dy == 0); +} + +TEST(test_pathfind_diagonal_no_obstacles) { + PathResult r = pathfind_step(NULL, 0, 100, 100, 105, 105); + ASSERT(r.found == 1); + ASSERT(r.next_dx == 1); + ASSERT(r.next_dy == 1); +} + +TEST(test_pathfind_around_wall) { + CollisionMap* map = collision_map_create(); + + /* create a vertical wall of blocked tiles at x=102, y=98..102 + * player at (100, 100) wants to reach (105, 100) + * direct east path is blocked — must go around */ + for (int y = 98; y <= 102; y++) { + collision_mark_blocked(map, 0, 102, y); + } + + PathResult r = pathfind_step(map, 0, 100, 100, 105, 100); + ASSERT(r.found == 1); + + /* first step should NOT be straight east into the wall. + * BFS will route north or south to go around. */ + /* it could be east (toward x=101 which is open) — that's fine */ + /* but it should never step to (102, any) since those are blocked */ + ASSERT(!(r.next_dx == 1 && r.next_dy == 0) + || (100 + r.next_dx != 102)); + + collision_map_free(map); +} + +TEST(test_pathfind_completely_blocked) { + CollisionMap* map = collision_map_create(); + + /* surround destination (105, 100) with blocked tiles */ + for (int dx = -1; dx <= 1; dx++) { + for (int dy = -1; dy <= 1; dy++) { + if (dx == 0 && dy == 0) continue; + collision_mark_blocked(map, 0, 105 + dx, 100 + dy); + } + } + /* also block the dest tile itself */ + collision_mark_blocked(map, 0, 105, 100); + + PathResult r = pathfind_step(map, 0, 100, 100, 105, 100); + /* should use fallback — find closest reachable tile */ + ASSERT(r.found == 1); + /* the actual destination should differ from requested since it's blocked */ + ASSERT(r.dest_x != 105 || r.dest_y != 100); + + collision_map_free(map); +} + +TEST(test_pathfind_respects_wall_flags) { + CollisionMap* map = collision_map_create(); + + /* place a south wall on tile (101, 101) — blocks entry from south. + * player at (101, 100) going north to (101, 101) should be blocked. */ + collision_set_flag(map, 0, 101, 101, COLLISION_WALL_SOUTH); + + /* pathfind from (100, 100) to (102, 102) — going NE */ + PathResult r = pathfind_step(map, 0, 100, 100, 102, 102); + ASSERT(r.found == 1); + + /* the BFS should find a path (there are many routes around one wall tile) */ + /* just verify it found something valid */ + ASSERT(r.next_dx >= -1 && r.next_dx <= 1); + ASSERT(r.next_dy >= -1 && r.next_dy <= 1); + + collision_map_free(map); +} + +/* ========================================================================= + * integration: step_toward_destination with collision + * + * we can't include the full osrs_pvp.h here (too many deps), so we + * replicate the core logic of step_toward_destination for testing. + * ========================================================================= */ + +typedef struct { int x, y, dest_x, dest_y; } TestPlayer; + +static int test_step_toward(TestPlayer* p, const CollisionMap* cmap) { + int dx = p->dest_x - p->x; + int dy = p->dest_y - p->y; + if (dx == 0 && dy == 0) return 0; + + int step_x = (dx > 0) ? 1 : (dx < 0 ? -1 : 0); + int step_y = (dy > 0) ? 1 : (dy < 0 ? -1 : 0); + + if (step_x != 0 && step_y != 0) { + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, step_y)) { + p->x += step_x; p->y += step_y; return 1; + } + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, 0)) { + p->x += step_x; return 1; + } + if (collision_traversable_step(cmap, 0, p->x, p->y, 0, step_y)) { + p->y += step_y; return 1; + } + return 0; + } + + if (collision_traversable_step(cmap, 0, p->x, p->y, step_x, step_y)) { + p->x += step_x; p->y += step_y; return 1; + } + return 0; +} + +TEST(test_step_blocked_by_wall) { + CollisionMap* map = collision_map_create(); + + /* block tile east of player */ + collision_mark_blocked(map, 0, 101, 100); + + TestPlayer p = {100, 100, 105, 100}; + int moved = test_step_toward(&p, map); + + /* east is blocked, no y component, should not move */ + ASSERT(!moved); + ASSERT(p.x == 100 && p.y == 100); + + collision_map_free(map); +} + +TEST(test_step_diagonal_falls_back_to_cardinal) { + CollisionMap* map = collision_map_create(); + + /* block the diagonal NE tile (101, 101) */ + collision_mark_blocked(map, 0, 101, 101); + + TestPlayer p = {100, 100, 105, 105}; + int moved = test_step_toward(&p, map); + + /* diagonal NE blocked, should fall back to east (101, 100) or north (100, 101) */ + ASSERT(moved); + /* should have moved in one cardinal direction only */ + ASSERT((p.x == 101 && p.y == 100) || (p.x == 100 && p.y == 101)); + + collision_map_free(map); +} + +TEST(test_step_no_collision_map) { + TestPlayer p = {100, 100, 105, 105}; + int moved = test_step_toward(&p, NULL); + ASSERT(moved); + /* diagonal step with no obstacles */ + ASSERT(p.x == 101 && p.y == 101); +} + +/* ========================================================================= + * wilderness coordinates test — verify the region hash works for real coords + * ========================================================================= */ + +TEST(test_wilderness_coordinates) { + CollisionMap* map = collision_map_create(); + + /* wilderness is roughly x=3008-3390, y=3520-3968. + * our arena center is around (3222, 3544). + * verify collision works at real coords. */ + int arena_x = 3222; + int arena_y = 3544; + + collision_mark_blocked(map, 0, arena_x + 5, arena_y); + ASSERT(!collision_tile_walkable(map, 0, arena_x + 5, arena_y)); + ASSERT(collision_tile_walkable(map, 0, arena_x + 4, arena_y)); + ASSERT(!collision_traversable_east(map, 0, arena_x + 4, arena_y)); + + collision_map_free(map); +} + +/* ========================================================================= + * main + * ========================================================================= */ + +int main(void) { + printf("collision system tests\n"); + printf("======================\n\n"); + + printf("collision map basics:\n"); + RUN(test_null_map_all_traversable); + RUN(test_empty_map_all_traversable); + RUN(test_region_create_and_lookup); + RUN(test_flag_set_and_unset); + + printf("\ndirectional traversal:\n"); + RUN(test_blocked_tile_not_traversable); + RUN(test_wall_blocks_direction); + RUN(test_diagonal_blocked_by_intermediate); + RUN(test_multi_tile_occupant); + + printf("\nbinary I/O:\n"); + RUN(test_save_and_load); + + printf("\npathfinding:\n"); + RUN(test_pathfind_already_at_dest); + RUN(test_pathfind_straight_line_no_obstacles); + RUN(test_pathfind_diagonal_no_obstacles); + RUN(test_pathfind_around_wall); + RUN(test_pathfind_completely_blocked); + RUN(test_pathfind_respects_wall_flags); + + printf("\nmovement integration:\n"); + RUN(test_step_blocked_by_wall); + RUN(test_step_diagonal_falls_back_to_cardinal); + RUN(test_step_no_collision_map); + + printf("\nwilderness coordinates:\n"); + RUN(test_wilderness_coordinates); + + printf("\n======================\n"); + printf("%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c new file mode 100644 index 0000000000..22abfba697 --- /dev/null +++ b/ocean/osrs_inferno/binding.c @@ -0,0 +1,225 @@ +/** + * @file binding.c + * @brief Static-native binding for OSRS Inferno encounter. + * + * Bridges vecenv.h's contract (double actions, float terminals) with the + * Inferno encounter's vtable interface. + */ + +#include +#include +#include + +#include "../osrs/osrs_encounter.h" +#include "../osrs/osrs_types.h" +#include "../osrs/encounters/encounter_inferno.h" + +#define INF_TOTAL_OBS (INF_NUM_OBS + INF_ACTION_MASK_SIZE) + +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + EncounterState* enc_state; + + int acts_staging[INF_NUM_ACTION_HEADS]; + unsigned char term_staging; + + /* best-episode replay recording: all envs buffer their current episode's actions. + on terminal, if the episode reached a new global best wave, flush to disk. + binary format: [int32 num_ticks] [uint32 rng_state] [num_heads int32 per tick] */ + int* episode_actions; /* buffer: episode_len * NUM_ATNS ints */ + int episode_action_cap; /* max ticks we can buffer */ + int episode_action_len; /* ticks buffered so far this episode */ + uint32_t episode_rng_start; /* RNG state at start of current episode */ +} InfernoEnv; + +#define OBS_SIZE INF_TOTAL_OBS +#define NUM_ATNS INF_NUM_ACTION_HEADS +#define ACT_SIZES { ENCOUNTER_MOVE_ACTIONS, 5, INF_MAX_NPCS+1, 5, 2, 4, 3, 2 } +#define OBS_TYPE FLOAT +#define ACT_TYPE FLOAT +#define Env InfernoEnv + +/* global best episode tracking — save if higher wave, or same wave but fewer ticks */ +static int g_best_wave = 0; +static int g_best_ticks = 999999; + +void c_step(Env* env) { + for (int i = 0; i < NUM_ATNS; i++) + env->acts_staging[i] = (int)env->actions[i]; + + /* buffer actions for best-episode recording */ + if (env->episode_actions) { + /* capture RNG state at the very start of the episode (before first action) */ + if (env->episode_action_len == 0) + env->episode_rng_start = ((InfernoState*)env->enc_state)->rng_state; + if (env->episode_action_len < env->episode_action_cap) { + memcpy(&env->episode_actions[env->episode_action_len * NUM_ATNS], + env->acts_staging, NUM_ATNS * sizeof(int)); + env->episode_action_len++; + } + } + + ENCOUNTER_INFERNO.step(env->enc_state, env->acts_staging); + + float* obs = (float*)env->observations; + ENCOUNTER_INFERNO.write_obs(env->enc_state, obs); + ENCOUNTER_INFERNO.write_mask(env->enc_state, obs + INF_NUM_OBS); + + env->rewards[0] = ENCOUNTER_INFERNO.get_reward(env->enc_state); + + int is_term = ENCOUNTER_INFERNO.is_terminal(env->enc_state); + env->term_staging = (unsigned char)is_term; + env->terminals[0] = (float)is_term; + + /* continuously update log with running stats so the sweep always has signal, + even mid-episode. vecenv clears env->log periodically via memset. */ + { + InfernoState* s = (InfernoState*)env->enc_state; + env->log.episode_return = s->episode_return; + env->log.episode_length = (float)s->tick; + env->log.damage_dealt = s->total_damage_dealt; + env->log.damage_received = s->total_damage_received; + env->log.wins = (is_term && s->winner == 0) ? 1.0f : 0.0f; + env->log.wave = (float)s->wave; + env->log.prayer_correct = (float)s->total_prayer_correct; + env->log.prayer_total = (float)s->total_npc_attacks; + env->log.idle_ticks = (float)s->total_idle_ticks; + env->log.brews_used = (float)s->total_brews_used; + env->log.blood_healed = (float)s->total_blood_healed; + env->log.unavoidable_off_prayer = (float)s->total_unavoidable_off; + env->log.brews_remaining = (float)s->player_brew_doses; + env->log.restores_remaining = (float)s->player_restore_doses; + env->log.prayer_at_death = (float)s->player.current_prayer; + env->log.n = 1.0f; /* always report so sweep has continuous signal */ + env->log.npc_kills = (float)s->total_npc_kills; + env->log.gear_switches = (float)s->total_gear_switches; + env->log.current_ranged = (float)s->player.current_ranged; + env->log.current_magic = (float)s->player.current_magic; + } + + if (is_term) { + /* check if this episode is a new global best — if so, flush replay to disk */ + if (env->episode_actions && env->episode_action_len > 0) { + InfernoState* st = (InfernoState*)env->enc_state; + int wave = st->wave; + int ticks = env->episode_action_len; + if (wave > g_best_wave || (wave == g_best_wave && ticks < g_best_ticks)) { + g_best_wave = wave; + g_best_ticks = ticks; + const char* rpath = getenv("RECORD_REPLAY"); + if (rpath && rpath[0]) { + FILE* fp = fopen(rpath, "wb"); + if (fp) { + fwrite(&env->episode_action_len, sizeof(int), 1, fp); + fwrite(&env->episode_rng_start, sizeof(uint32_t), 1, fp); + fwrite(env->episode_actions, sizeof(int), + env->episode_action_len * NUM_ATNS, fp); + fclose(fp); + fprintf(stderr, "replay: new best wave %d (%d ticks, rng=%u) saved to %s\n", + wave, env->episode_action_len, env->episode_rng_start, rpath); + } + } + } + } + env->episode_action_len = 0; + + ENCOUNTER_INFERNO.reset(env->enc_state, 0); + ENCOUNTER_INFERNO.write_obs(env->enc_state, obs); + ENCOUNTER_INFERNO.write_mask(env->enc_state, obs + INF_NUM_OBS); + } +} + +void c_reset(Env* env) { + ENCOUNTER_INFERNO.reset(env->enc_state, 0); + + float* obs = (float*)env->observations; + ENCOUNTER_INFERNO.write_obs(env->enc_state, obs); + ENCOUNTER_INFERNO.write_mask(env->enc_state, obs + INF_NUM_OBS); + + env->rewards[0] = 0.0f; + env->term_staging = 0; + env->terminals[0] = 0.0f; +} + +void c_close(Env* env) { + free(env->episode_actions); + env->episode_actions = NULL; + if (env->enc_state) { + ENCOUNTER_INFERNO.destroy(env->enc_state); + env->enc_state = NULL; + } +} + +void c_render(Env* env) { (void)env; } + +#include "vecenv.h" + +/* max episode length for action buffer (INF_MAX_TICKS from encounter) */ +#define REPLAY_MAX_TICKS INF_MAX_TICKS + +void my_init(Env* env, Dict* kwargs) { + env->num_agents = 1; + env->enc_state = ENCOUNTER_INFERNO.create(); + memset(&env->log, 0, sizeof(Log)); + + DictItem* start_wave = dict_get_unsafe(kwargs, "start_wave"); + if (start_wave) + ENCOUNTER_INFERNO.put_int(env->enc_state, "start_wave", (int)start_wave->value); + + /* allocate action buffer for best-episode recording (all envs buffer) */ + if (getenv("RECORD_REPLAY") && getenv("RECORD_REPLAY")[0]) { + env->episode_actions = (int*)malloc(REPLAY_MAX_TICKS * NUM_ATNS * sizeof(int)); + env->episode_action_cap = REPLAY_MAX_TICKS; + } else { + env->episode_actions = NULL; + env->episode_action_cap = 0; + } + env->episode_action_len = 0; +} + +void my_log(Log* log, Dict* out) { + dict_set(out, "episode_return", log->episode_return); + dict_set(out, "damage_dealt", log->damage_dealt); + dict_set(out, "damage_received", log->damage_received); + dict_set(out, "episode_length", log->episode_length); + dict_set(out, "wins", log->wins); + dict_set(out, "wave", log->wave); + dict_set(out, "idle_ticks", log->idle_ticks); + dict_set(out, "brews_used", log->brews_used); + dict_set(out, "blood_healed", log->blood_healed); + + /* prayer analysis: correct rate + unavoidable breakdown */ + float prayer_rate = (log->prayer_total > 0.0f) + ? log->prayer_correct / log->prayer_total : 0.0f; + dict_set(out, "prayer_correct_rate", prayer_rate); + /* what fraction of off-prayer hits were unavoidable (multi-style same tick) */ + float off_prayer = log->prayer_total - log->prayer_correct; + float unavoidable_rate = (off_prayer > 0.0f) + ? log->unavoidable_off_prayer / off_prayer : 0.0f; + dict_set(out, "unavoidable_off_prayer_rate", unavoidable_rate); + dict_set(out, "unavoidable_off_prayer", log->unavoidable_off_prayer); + + dict_set(out, "brews_remaining", log->brews_remaining); + dict_set(out, "restores_remaining", log->restores_remaining); + dict_set(out, "prayer_at_death", log->prayer_at_death); + + dict_set(out, "npc_kills", log->npc_kills); + dict_set(out, "gear_switches", log->gear_switches); + dict_set(out, "current_ranged", log->current_ranged); + dict_set(out, "current_magic", log->current_magic); + float gear_switch_rate = (log->episode_length > 0.0f) + ? log->gear_switches / log->episode_length : 0.0f; + dict_set(out, "gear_switch_rate", gear_switch_rate); + + float wr = log->wins; + float wave_progress = log->episode_length / (float)INF_MAX_TICKS; + float score = wr + (1.0f - wr) * wave_progress * 0.5f - (1.0f - wr); + dict_set(out, "score", score); +} diff --git a/ocean/osrs_inferno/data b/ocean/osrs_inferno/data new file mode 120000 index 0000000000..03f77fc4d5 --- /dev/null +++ b/ocean/osrs_inferno/data @@ -0,0 +1 @@ +../osrs/data \ No newline at end of file diff --git a/ocean/osrs_inferno/encounters b/ocean/osrs_inferno/encounters new file mode 120000 index 0000000000..0cd6c57b5f --- /dev/null +++ b/ocean/osrs_inferno/encounters @@ -0,0 +1 @@ +../osrs/encounters \ No newline at end of file diff --git a/ocean/osrs_inferno/osrs_collision.h b/ocean/osrs_inferno/osrs_collision.h new file mode 120000 index 0000000000..91d86e17f4 --- /dev/null +++ b/ocean/osrs_inferno/osrs_collision.h @@ -0,0 +1 @@ +../osrs/osrs_collision.h \ No newline at end of file diff --git a/ocean/osrs_inferno/osrs_combat_shared.h b/ocean/osrs_inferno/osrs_combat_shared.h new file mode 120000 index 0000000000..3e91318371 --- /dev/null +++ b/ocean/osrs_inferno/osrs_combat_shared.h @@ -0,0 +1 @@ +../osrs/osrs_combat_shared.h \ No newline at end of file diff --git a/ocean/osrs_inferno/osrs_encounter.h b/ocean/osrs_inferno/osrs_encounter.h new file mode 120000 index 0000000000..4a8829991a --- /dev/null +++ b/ocean/osrs_inferno/osrs_encounter.h @@ -0,0 +1 @@ +../osrs/osrs_encounter.h \ No newline at end of file diff --git a/ocean/osrs_inferno/osrs_items.h b/ocean/osrs_inferno/osrs_items.h new file mode 120000 index 0000000000..f5177f5167 --- /dev/null +++ b/ocean/osrs_inferno/osrs_items.h @@ -0,0 +1 @@ +../osrs/osrs_items.h \ No newline at end of file diff --git a/ocean/osrs_inferno/osrs_pathfinding.h b/ocean/osrs_inferno/osrs_pathfinding.h new file mode 120000 index 0000000000..4c1fd9309d --- /dev/null +++ b/ocean/osrs_inferno/osrs_pathfinding.h @@ -0,0 +1 @@ +../osrs/osrs_pathfinding.h \ No newline at end of file diff --git a/ocean/osrs_inferno/osrs_types.h b/ocean/osrs_inferno/osrs_types.h new file mode 120000 index 0000000000..d11a43651d --- /dev/null +++ b/ocean/osrs_inferno/osrs_types.h @@ -0,0 +1 @@ +../osrs/osrs_types.h \ No newline at end of file diff --git a/ocean/osrs_pvp/binding.c b/ocean/osrs_pvp/binding.c new file mode 100644 index 0000000000..4b36f322c1 --- /dev/null +++ b/ocean/osrs_pvp/binding.c @@ -0,0 +1,218 @@ +/** + * @file binding.c + * @brief Metal static-native binding for OSRS PVP environment + * + * Bridges vecenv.h's contract (double actions, float terminals) with the PVP + * env's internal types (int actions, unsigned char terminals) using a wrapper + * struct. PVP source headers are untouched. + */ + +#include "osrs_pvp.h" + +/* Wrapper struct: vecenv-compatible fields at top + embedded OsrsPvp. + * vecenv.h's create_static_vec assigns to env->observations, env->actions, + * env->rewards, env->terminals directly. These fields must match vecenv's + * expected types (void*, double*, float*, float*). The embedded OsrsPvp has + * its own identically-named fields with different types — pvp_init sets those + * to internal inline buffers, so there's no conflict. */ +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + OsrsPvp pvp; + + /* staging buffers for type conversion */ + int ocean_acts_staging[NUM_ACTION_HEADS]; + unsigned char ocean_term_staging; +} MetalPvpEnv; + +#define OBS_SIZE OCEAN_OBS_SIZE +#define NUM_ATNS NUM_ACTION_HEADS +#define ACT_SIZES {LOADOUT_DIM, COMBAT_DIM, OVERHEAD_DIM, FOOD_DIM, POTION_DIM, KARAMBWAN_DIM, VENG_DIM} +#define OBS_TYPE FLOAT +#define ACT_TYPE FLOAT +#define Env MetalPvpEnv + +/* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h + * because vecenv.h calls them inside its implementation section without + * forward-declaring them (they're expected to come from the env header). */ + +void c_step(Env* env) { + /* double actions from vecenv → int staging for PVP */ + for (int i = 0; i < NUM_ATNS; i++) { + env->ocean_acts_staging[i] = (int)env->actions[i]; + } + + pvp_step(&env->pvp); + + /* terminal: unsigned char → float for vecenv */ + env->terminals[0] = (float)env->ocean_term_staging; + + /* copy PVP log to wrapper log on episode end */ + if (env->ocean_term_staging) { + env->log.episode_return = env->pvp.log.episode_return; + env->log.episode_length = env->pvp.log.episode_length; + env->log.wins = env->pvp.log.wins; + env->log.damage_dealt = env->pvp.log.damage_dealt; + env->log.damage_received = env->pvp.log.damage_received; + env->log.n = env->pvp.log.n; + memset(&env->pvp.log, 0, sizeof(env->pvp.log)); + } + + if (env->ocean_term_staging && env->pvp.auto_reset) { + ocean_write_obs(&env->pvp); + } +} + +void c_reset(Env* env) { + /* Wire ocean pointers to vecenv shared buffers (deferred from my_init because + * create_static_vec assigns env->observations/rewards AFTER my_vec_init). */ + env->pvp.ocean_obs = (float*)env->observations; + env->pvp.ocean_rew = env->rewards; + env->pvp.ocean_term = &env->ocean_term_staging; + env->pvp.ocean_acts = env->ocean_acts_staging; + + pvp_reset(&env->pvp); + ocean_write_obs(&env->pvp); + env->pvp.ocean_rew[0] = 0.0f; + env->pvp.ocean_term[0] = 0; + env->terminals[0] = 0.0f; +} + +void c_close(Env* env) { pvp_close(&env->pvp); } +void c_render(Env* env) { (void)env; } + +#include "vecenv.h" + +void my_init(Env* env, Dict* kwargs) { + env->num_agents = 1; + + pvp_init(&env->pvp); + + /* Ocean pointer wiring is DEFERRED to c_reset because my_init runs inside + * my_vec_init BEFORE create_static_vec assigns the shared buffer pointers + * (env->observations, env->actions, env->rewards, env->terminals are NULL + * at this point). c_reset runs after buffer assignment and does the wiring. + * + * For now, point ocean pointers at internal staging so pvp_reset doesn't + * crash on writes to ocean_term/ocean_rew. */ + env->pvp.ocean_obs = NULL; + env->pvp.ocean_rew = env->pvp._rews_buf; + env->pvp.ocean_term = &env->ocean_term_staging; + env->pvp.ocean_acts = env->ocean_acts_staging; + env->pvp.ocean_obs_p1 = NULL; + env->pvp.ocean_selfplay_mask = NULL; + + /* config from Dict (all values are double) */ + env->pvp.use_c_opponent = 1; + env->pvp.auto_reset = 1; + env->pvp.is_lms = 1; + + DictItem* opp = dict_get_unsafe(kwargs, "opponent_type"); + env->pvp.opponent.type = opp ? (OpponentType)(int)opp->value : OPP_IMPROVED; + + DictItem* shaping_scale = dict_get_unsafe(kwargs, "shaping_scale"); + env->pvp.shaping.shaping_scale = shaping_scale ? (float)shaping_scale->value : 0.0f; + + DictItem* shaping_en = dict_get_unsafe(kwargs, "shaping_enabled"); + env->pvp.shaping.enabled = shaping_en ? (int)shaping_en->value : 0; + + /* reward shaping coefficients (same defaults as ocean_binding.c) */ + env->pvp.shaping.damage_dealt_coef = 0.005f; + env->pvp.shaping.damage_received_coef = -0.005f; + env->pvp.shaping.correct_prayer_bonus = 0.03f; + env->pvp.shaping.wrong_prayer_penalty = -0.02f; + env->pvp.shaping.prayer_switch_no_attack_penalty = -0.01f; + env->pvp.shaping.off_prayer_hit_bonus = 0.03f; + env->pvp.shaping.melee_frozen_penalty = -0.05f; + env->pvp.shaping.wasted_eat_penalty = -0.001f; + env->pvp.shaping.premature_eat_penalty = -0.02f; + env->pvp.shaping.magic_no_staff_penalty = -0.05f; + env->pvp.shaping.gear_mismatch_penalty = -0.05f; + env->pvp.shaping.spec_off_prayer_bonus = 0.02f; + env->pvp.shaping.spec_low_defence_bonus = 0.01f; + env->pvp.shaping.spec_low_hp_bonus = 0.02f; + env->pvp.shaping.smart_triple_eat_bonus = 0.05f; + env->pvp.shaping.wasted_triple_eat_penalty = -0.0005f; + env->pvp.shaping.damage_burst_bonus = 0.002f; + env->pvp.shaping.damage_burst_threshold = 30; + env->pvp.shaping.premature_eat_threshold = 0.7071f; + env->pvp.shaping.ko_bonus = 0.15f; + env->pvp.shaping.wasted_resources_penalty = -0.07f; + env->pvp.shaping.prayer_penalty_enabled = 1; + env->pvp.shaping.click_penalty_enabled = 0; + env->pvp.shaping.click_penalty_threshold = 5; + env->pvp.shaping.click_penalty_coef = -0.003f; + + /* gear: default tier 0 (basic LMS) */ + env->pvp.gear_tier_weights[0] = 1.0f; + env->pvp.gear_tier_weights[1] = 0.0f; + env->pvp.gear_tier_weights[2] = 0.0f; + env->pvp.gear_tier_weights[3] = 0.0f; + + /* pvp_reset sets up game state (players, positions, gear, etc.) + * but does NOT write to ocean buffers — that happens in c_reset. */ + pvp_reset(&env->pvp); +} + +void my_log(Log* log, Dict* out) { + dict_set(out, "episode_return", log->episode_return); + dict_set(out, "episode_length", log->episode_length); + dict_set(out, "wins", log->wins); + dict_set(out, "damage_dealt", log->damage_dealt); + dict_set(out, "damage_received", log->damage_received); +} + +/* ======================================================================== + * PFSP: set/get opponent pool weights across all envs + * Called from Python via pybind11 wrappers in metal_bindings.mm + * ======================================================================== */ + +void binding_set_pfsp_weights(StaticVec* vec, int* pool, int* cum_weights, int pool_size) { + Env* envs = (Env*)vec->envs; + if (pool_size > MAX_OPPONENT_POOL) pool_size = MAX_OPPONENT_POOL; + for (int e = 0; e < vec->size; e++) { + int was_unconfigured = (envs[e].pvp.pfsp.pool_size == 0); + envs[e].pvp.pfsp.pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + envs[e].pvp.pfsp.pool[i] = (OpponentType)pool[i]; + envs[e].pvp.pfsp.cum_weights[i] = cum_weights[i]; + } + /* Only reset on first configuration — restarts the episode that was started + * during env creation before the pool was set (would have used fallback opponent). + * Periodic weight updates must NOT reset: that would corrupt PufferLib's rollout. */ + if (was_unconfigured) { + c_reset(&envs[e]); + } + } +} + +void binding_get_pfsp_stats(StaticVec* vec, float* out_wins, float* out_episodes, int* out_pool_size) { + Env* envs = (Env*)vec->envs; + int pool_size = 0; + + for (int e = 0; e < vec->size; e++) { + if (envs[e].pvp.pfsp.pool_size > pool_size) + pool_size = envs[e].pvp.pfsp.pool_size; + } + *out_pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + out_wins[i] = 0.0f; + out_episodes[i] = 0.0f; + } + + /* Aggregate and reset (read-and-reset pattern) */ + for (int e = 0; e < vec->size; e++) { + for (int i = 0; i < envs[e].pvp.pfsp.pool_size; i++) { + out_wins[i] += envs[e].pvp.pfsp.wins[i]; + out_episodes[i] += envs[e].pvp.pfsp.episodes[i]; + } + memset(envs[e].pvp.pfsp.wins, 0, sizeof(envs[e].pvp.pfsp.wins)); + memset(envs[e].pvp.pfsp.episodes, 0, sizeof(envs[e].pvp.pfsp.episodes)); + } +} diff --git a/ocean/osrs_pvp/data b/ocean/osrs_pvp/data new file mode 120000 index 0000000000..03f77fc4d5 --- /dev/null +++ b/ocean/osrs_pvp/data @@ -0,0 +1 @@ +../osrs/data \ No newline at end of file diff --git a/ocean/osrs_pvp/encounters b/ocean/osrs_pvp/encounters new file mode 120000 index 0000000000..0cd6c57b5f --- /dev/null +++ b/ocean/osrs_pvp/encounters @@ -0,0 +1 @@ +../osrs/encounters \ No newline at end of file diff --git a/ocean/osrs_pvp/ocean_binding.c b/ocean/osrs_pvp/ocean_binding.c new file mode 100644 index 0000000000..34dde84ba7 --- /dev/null +++ b/ocean/osrs_pvp/ocean_binding.c @@ -0,0 +1,746 @@ +/** + * @file ocean_binding.c + * @brief PufferLib ocean-compatible C binding for OSRS PvP environment + * + * Replaces the legacy binding.c with PufferLib's native env_binding.h + * template. All N environments are managed in C with a single vec_step + * call per batch, eliminating per-env Python overhead. + */ + +#define PY_SSIZE_T_CLEAN +#include + +#include "osrs_pvp.h" +#include "osrs_encounter.h" +#include "encounters/encounter_nh_pvp.h" +#include "encounters/encounter_zulrah.h" + +#define Env OsrsPvp + +/* shared collision map: loaded once, read-only, shared across all envs */ +static CollisionMap* g_collision_map = NULL; + +static void dict_set_int(PyObject* dict, const char* key, int value) { + PyObject* obj = PyLong_FromLong(value); + PyDict_SetItemString(dict, key, obj); + Py_DECREF(obj); +} + +static void dict_set_bool(PyObject* dict, const char* key, int value) { + PyObject* obj = PyBool_FromLong(value ? 1 : 0); + PyDict_SetItemString(dict, key, obj); + Py_DECREF(obj); +} + +static PyObject* build_inventory_list(Player* p, int slot) { + int count = p->num_items_in_slot[slot]; + if (count < 0) count = 0; + if (count > MAX_ITEMS_PER_SLOT) count = MAX_ITEMS_PER_SLOT; + PyObject* list = PyList_New(count); + for (int i = 0; i < count; i++) { + PyList_SetItem(list, i, PyLong_FromLong(p->inventory[slot][i])); + } + return list; +} + +static PyObject* build_obs_with_mask(Env* env) { + ensure_obs_norm_initialized(); + PyObject* obs_list = PyList_New(NUM_AGENTS); + + for (int agent = 0; agent < NUM_AGENTS; agent++) { + PyObject* agent_obs = PyList_New(OCEAN_OBS_SIZE); + float* obs = env->observations + agent * SLOT_NUM_OBSERVATIONS; + unsigned char* mask = env->action_masks + agent * ACTION_MASK_SIZE; + + for (int i = 0; i < SLOT_NUM_OBSERVATIONS; i++) { + PyList_SetItem(agent_obs, i, PyFloat_FromDouble((double)(obs[i] / OBS_NORM_DIVISORS[i]))); + } + for (int i = 0; i < ACTION_MASK_SIZE; i++) { + PyList_SetItem(agent_obs, SLOT_NUM_OBSERVATIONS + i, PyFloat_FromDouble((double)mask[i])); + } + PyList_SetItem(obs_list, agent, agent_obs); + } + + return obs_list; +} + +static PyObject* build_pending_actions(Env* env) { + PyObject* actions = PyList_New(NUM_AGENTS); + for (int agent = 0; agent < NUM_AGENTS; agent++) { + PyObject* agent_actions = PyList_New(NUM_ACTION_HEADS); + int* src = env->pending_actions + agent * NUM_ACTION_HEADS; + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + PyList_SetItem(agent_actions, i, PyLong_FromLong(src[i])); + } + PyList_SetItem(actions, agent, agent_actions); + } + return actions; +} + +static PyObject* build_executed_actions(Env* env) { + PyObject* actions = PyList_New(NUM_AGENTS); + for (int agent = 0; agent < NUM_AGENTS; agent++) { + PyObject* agent_actions = PyList_New(NUM_ACTION_HEADS); + int* src = env->last_executed_actions + agent * NUM_ACTION_HEADS; + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + PyList_SetItem(agent_actions, i, PyLong_FromLong(src[i])); + } + PyList_SetItem(actions, agent, agent_actions); + } + return actions; +} + +static PyObject* build_player_state(Player* p) { + PyObject* dict = PyDict_New(); + + dict_set_int(dict, "x", p->x); + dict_set_int(dict, "y", p->y); + dict_set_int(dict, "hp", p->current_hitpoints); + dict_set_int(dict, "max_hp", p->base_hitpoints); + dict_set_int(dict, "prayer", p->current_prayer); + dict_set_int(dict, "max_prayer", p->base_prayer); + dict_set_int(dict, "spec_energy", p->special_energy); + dict_set_int(dict, "frozen_ticks", p->frozen_ticks); + dict_set_int(dict, "freeze_immunity_ticks", p->freeze_immunity_ticks); + dict_set_int(dict, "gear", p->visible_gear); + dict_set_int(dict, "overhead_prayer", p->prayer); + dict_set_int(dict, "offensive_prayer", p->offensive_prayer); + dict_set_int(dict, "attack_timer", p->attack_timer); + dict_set_int(dict, "food_timer", p->food_timer); + dict_set_int(dict, "potion_timer", p->potion_timer); + dict_set_int(dict, "karambwan_timer", p->karambwan_timer); + dict_set_int(dict, "veng_active", p->veng_active); + dict_set_int(dict, "veng_cooldown", p->veng_cooldown); + dict_set_int(dict, "food_count", p->food_count); + dict_set_int(dict, "karambwan_count", p->karambwan_count); + dict_set_int(dict, "brew_doses", p->brew_doses); + dict_set_int(dict, "restore_doses", p->restore_doses); + dict_set_int(dict, "ranged_potion_doses", p->ranged_potion_doses); + dict_set_int(dict, "combat_potion_doses", p->combat_potion_doses); + dict_set_int(dict, "damage_this_tick", p->damage_applied_this_tick); + dict_set_int(dict, "last_attack_style", p->last_attack_style); + dict_set_int(dict, "just_attacked", p->just_attacked); + dict_set_int(dict, "attack_was_on_prayer", p->attack_was_on_prayer); + dict_set_int(dict, "last_queued_hit_damage", p->last_queued_hit_damage); + + dict_set_int(dict, "hit_landed_this_tick", p->hit_landed_this_tick); + dict_set_int(dict, "hit_attacker_idx", p->hit_attacker_idx); + dict_set_int(dict, "hit_style", p->hit_style); + dict_set_int(dict, "hit_damage", p->hit_damage); + dict_set_int(dict, "hit_was_successful", p->hit_was_successful); + dict_set_int(dict, "hit_was_on_prayer", p->hit_was_on_prayer); + dict_set_int(dict, "freeze_applied_this_tick", p->freeze_applied_this_tick); + + dict_set_int(dict, "weapon", p->equipped[GEAR_SLOT_WEAPON]); + dict_set_int(dict, "helm", p->equipped[GEAR_SLOT_HEAD]); + dict_set_int(dict, "body", p->equipped[GEAR_SLOT_BODY]); + dict_set_int(dict, "legs", p->equipped[GEAR_SLOT_LEGS]); + dict_set_int(dict, "shield", p->equipped[GEAR_SLOT_SHIELD]); + dict_set_int(dict, "cape", p->equipped[GEAR_SLOT_CAPE]); + dict_set_int(dict, "neck", p->equipped[GEAR_SLOT_NECK]); + dict_set_int(dict, "ring", p->equipped[GEAR_SLOT_RING]); + dict_set_int(dict, "feet", p->equipped[GEAR_SLOT_FEET]); + + // Export inventory for all dynamic gear slots + static const int EXPORT_SLOTS[] = { + GEAR_SLOT_WEAPON, GEAR_SLOT_SHIELD, GEAR_SLOT_BODY, GEAR_SLOT_LEGS, + GEAR_SLOT_HEAD, GEAR_SLOT_CAPE, GEAR_SLOT_NECK, GEAR_SLOT_RING + }; + static const char* EXPORT_NAMES[] = { + "weapon_inventory", "shield_inventory", "body_inventory", "legs_inventory", + "head_inventory", "cape_inventory", "neck_inventory", "ring_inventory" + }; + for (int i = 0; i < 8; i++) { + PyObject* inv = build_inventory_list(p, EXPORT_SLOTS[i]); + PyDict_SetItemString(dict, EXPORT_NAMES[i], inv); + Py_DECREF(inv); + } + + dict_set_int(dict, "base_attack", p->base_attack); + dict_set_int(dict, "base_strength", p->base_strength); + dict_set_int(dict, "base_defence", p->base_defence); + dict_set_int(dict, "base_ranged", p->base_ranged); + dict_set_int(dict, "base_magic", p->base_magic); + dict_set_int(dict, "current_attack", p->current_attack); + dict_set_int(dict, "current_strength", p->current_strength); + dict_set_int(dict, "current_defence", p->current_defence); + dict_set_int(dict, "current_ranged", p->current_ranged); + dict_set_int(dict, "current_magic", p->current_magic); + + return dict; +} + +static PyObject* my_get(PyObject* dict, Env* env) { + dict_set_int(dict, "tick", env->tick); + dict_set_bool(dict, "episode_over", env->episode_over); + dict_set_int(dict, "winner", env->winner); + dict_set_int(dict, "pid_holder", env->pid_holder); + dict_set_bool(dict, "opponent_read_this_tick", env->opponent.has_read_this_tick); + dict_set_int(dict, "opponent_read_style", env->opponent.read_agent_style); + + PyObject* p0 = build_player_state(&env->players[0]); + PyObject* p1 = build_player_state(&env->players[1]); + PyDict_SetItemString(dict, "player0", p0); + PyDict_SetItemString(dict, "player1", p1); + Py_DECREF(p0); + Py_DECREF(p1); + + PyObject* obs_with_mask = build_obs_with_mask(env); + PyDict_SetItemString(dict, "obs_with_mask", obs_with_mask); + Py_DECREF(obs_with_mask); + + PyObject* pending_actions = build_pending_actions(env); + PyDict_SetItemString(dict, "pending_actions", pending_actions); + Py_DECREF(pending_actions); + + PyObject* executed_actions = build_executed_actions(env); + PyDict_SetItemString(dict, "executed_actions", executed_actions); + Py_DECREF(executed_actions); + + return dict; +} + +/* ── reward shaping annealing ───────────────────────────────────────────── + * MY_PUT: enables env_put(handle, shaping_scale=X) for single-env updates + * MY_METHODS: registers vec_set_shaping_scale for batch updates from Python + * + * vec_set_shaping_scale is defined before env_binding.h because MY_METHODS + * is expanded inside the method table (which lives inside the include). + * we inline a local VecEnv typedef since the real one isn't available yet. + */ + +#define MY_PUT +#define MY_GET + +static int my_put(Env* env, PyObject* args, PyObject* kwargs) { + (void)args; + PyObject* val = PyDict_GetItemString(kwargs, "shaping_scale"); + if (val) { + env->shaping.shaping_scale = (float)PyFloat_AsDouble(val); + } + PyObject* use_c_opponent = PyDict_GetItemString(kwargs, "use_c_opponent"); + if (use_c_opponent) { + env->use_c_opponent = PyObject_IsTrue(use_c_opponent) ? 1 : 0; + } + PyObject* use_c_opponent_p0 = PyDict_GetItemString(kwargs, "use_c_opponent_p0"); + if (use_c_opponent_p0) { + env->use_c_opponent_p0 = PyObject_IsTrue(use_c_opponent_p0) ? 1 : 0; + } + PyObject* use_external = PyDict_GetItemString(kwargs, "use_external_opponent_actions"); + if (use_external) { + env->use_external_opponent_actions = PyObject_IsTrue(use_external) ? 1 : 0; + } + PyObject* opp0_type = PyDict_GetItemString(kwargs, "opponent0_type"); + if (opp0_type) { + env->opponent_p0.type = (OpponentType)PyLong_AsLong(opp0_type); + opponent_reset(env, &env->opponent_p0); + } + // click_budget removed (loadout system doesn't need click budgets) + PyObject* auto_reset = PyDict_GetItemString(kwargs, "auto_reset"); + if (auto_reset) { + env->auto_reset = PyObject_IsTrue(auto_reset) ? 1 : 0; + } + PyObject* seed_obj = PyDict_GetItemString(kwargs, "seed"); + if (seed_obj) { + uint32_t seed = (uint32_t)PyLong_AsUnsignedLong(seed_obj); + if (!PyErr_Occurred()) { + pvp_seed(env, seed); + } + } + PyObject* tier_weights = PyDict_GetItemString(kwargs, "gear_tier_weights"); + if (tier_weights) { + PyObject* seq = PySequence_Fast(tier_weights, "gear_tier_weights must be a sequence"); + if (!seq) return -1; + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + if (n >= 4) { + PyObject** items = PySequence_Fast_ITEMS(seq); + for (int i = 0; i < 4; i++) { + env->gear_tier_weights[i] = (float)PyFloat_AsDouble(items[i]); + } + } + Py_DECREF(seq); + } + PyObject* opp_actions = PyDict_GetItemString(kwargs, "opponent_actions"); + if (opp_actions) { + PyObject* seq = PySequence_Fast(opp_actions, "opponent_actions must be a sequence"); + if (!seq) return -1; + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + PyObject** items = PySequence_Fast_ITEMS(seq); + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + if (i < n) { + long val_int = PyLong_AsLong(items[i]); + if (PyErr_Occurred()) { + Py_DECREF(seq); + return -1; + } + env->external_opponent_actions[i] = (int)val_int; + } else { + env->external_opponent_actions[i] = 0; + } + } + env->use_external_opponent_actions = 1; + Py_DECREF(seq); + } + return 0; +} + +static PyObject* vec_set_shaping_scale(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + if (!handle_obj) return NULL; + + /* VecEnv layout: {Env** envs; int num_envs;} — matches env_binding.h */ + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + PyObject* scale_obj = PyTuple_GetItem(args, 1); + if (!scale_obj) return NULL; + float s = (float)PyFloat_AsDouble(scale_obj); + if (PyErr_Occurred()) return NULL; + + for (int i = 0; i < vec->num_envs; i++) { + vec->envs[i]->shaping.shaping_scale = s; + } + Py_RETURN_NONE; +} + +/* ── PFSP opponent scheduling ──────────────────────────────────────────── + * vec_set_pfsp_weights: push new pool + cumulative weights to all envs + * vec_get_pfsp_stats: aggregate win/episode counters across all envs, then reset + */ + +static PyObject* vec_set_pfsp_weights(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + if (!handle_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + PyObject* pool_obj = PyTuple_GetItem(args, 1); /* list of int (OpponentType) */ + PyObject* weights_obj = PyTuple_GetItem(args, 2); /* list of int (cum weights * 1000) */ + if (!pool_obj || !weights_obj) return NULL; + + int pool_size = (int)PyList_Size(pool_obj); + if (pool_size > MAX_OPPONENT_POOL) pool_size = MAX_OPPONENT_POOL; + + OpponentType pool[MAX_OPPONENT_POOL]; + int cum_weights[MAX_OPPONENT_POOL]; + for (int i = 0; i < pool_size; i++) { + pool[i] = (OpponentType)PyLong_AsLong(PyList_GetItem(pool_obj, i)); + cum_weights[i] = (int)PyLong_AsLong(PyList_GetItem(weights_obj, i)); + } + if (PyErr_Occurred()) return NULL; + + for (int e = 0; e < vec->num_envs; e++) { + Env* env = vec->envs[e]; + int was_unconfigured = (env->pfsp.pool_size == 0); + env->pfsp.pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + env->pfsp.pool[i] = pool[i]; + env->pfsp.cum_weights[i] = cum_weights[i]; + } + // Only reset on first configuration — restarts the episode that was started + // during env creation before the pool was set (would have used fallback opponent). + // Periodic weight updates must NOT reset mid-episode. + if (was_unconfigured) { + pvp_reset(env); + ocean_write_obs(env); + } + } + Py_RETURN_NONE; +} + +static PyObject* vec_get_pfsp_stats(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + if (!handle_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + /* Aggregate across all envs */ + float total_wins[MAX_OPPONENT_POOL] = {0}; + float total_episodes[MAX_OPPONENT_POOL] = {0}; + int pool_size = 0; + + for (int e = 0; e < vec->num_envs; e++) { + Env* env = vec->envs[e]; + if (env->pfsp.pool_size > pool_size) pool_size = env->pfsp.pool_size; + for (int i = 0; i < env->pfsp.pool_size; i++) { + total_wins[i] += env->pfsp.wins[i]; + total_episodes[i] += env->pfsp.episodes[i]; + } + /* Reset per-env counters (read-and-reset pattern) */ + memset(env->pfsp.wins, 0, sizeof(env->pfsp.wins)); + memset(env->pfsp.episodes, 0, sizeof(env->pfsp.episodes)); + } + + /* Build return tuple: (wins_list, episodes_list) */ + PyObject* wins_list = PyList_New(pool_size); + PyObject* eps_list = PyList_New(pool_size); + for (int i = 0; i < pool_size; i++) { + PyList_SetItem(wins_list, i, PyFloat_FromDouble((double)total_wins[i])); + PyList_SetItem(eps_list, i, PyFloat_FromDouble((double)total_episodes[i])); + } + return PyTuple_Pack(2, wins_list, eps_list); +} + +/* ── Self-play support ─────────────────────────────────────────────────── + * vec_enable_selfplay: set p1 obs buffer pointer for all envs + * vec_set_opponent_actions: push opponent actions to all envs + */ + +static PyObject* vec_enable_selfplay(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + PyObject* buf_obj = PyTuple_GetItem(args, 1); + PyObject* mask_obj = PyTuple_GetItem(args, 2); + if (!handle_obj || !buf_obj || !mask_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + Py_buffer obs_view; + if (PyObject_GetBuffer(buf_obj, &obs_view, PyBUF_C_CONTIGUOUS | PyBUF_WRITABLE) < 0) { + return NULL; + } + Py_buffer mask_view; + if (PyObject_GetBuffer(mask_obj, &mask_view, PyBUF_C_CONTIGUOUS | PyBUF_WRITABLE) < 0) { + PyBuffer_Release(&obs_view); + return NULL; + } + float* buf = (float*)obs_view.buf; + unsigned char* mask = (unsigned char*)mask_view.buf; + + for (int e = 0; e < vec->num_envs; e++) { + vec->envs[e]->ocean_obs_p1 = buf + e * OCEAN_OBS_SIZE; + vec->envs[e]->ocean_selfplay_mask = &mask[e]; + } + + PyBuffer_Release(&obs_view); + PyBuffer_Release(&mask_view); + Py_RETURN_NONE; +} + +static PyObject* vec_set_opponent_actions(PyObject* self, PyObject* args) { + (void)self; + PyObject* handle_obj = PyTuple_GetItem(args, 0); + PyObject* acts_obj = PyTuple_GetItem(args, 1); + if (!handle_obj || !acts_obj) return NULL; + + typedef struct { Env** envs; int num_envs; } VecEnvLocal; + VecEnvLocal* vec = (VecEnvLocal*)PyLong_AsVoidPtr(handle_obj); + if (!vec) { + PyErr_SetString(PyExc_ValueError, "invalid vec env handle"); + return NULL; + } + + Py_buffer view; + if (PyObject_GetBuffer(acts_obj, &view, PyBUF_C_CONTIGUOUS) < 0) { + return NULL; + } + int* acts = (int*)view.buf; + + for (int e = 0; e < vec->num_envs; e++) { + memcpy( + vec->envs[e]->external_opponent_actions, + acts + e * NUM_ACTION_HEADS, + NUM_ACTION_HEADS * sizeof(int) + ); + } + + PyBuffer_Release(&view); + Py_RETURN_NONE; +} + +static PyObject* get_encounter_info(PyObject* self, PyObject* args) { + (void)self; + const char* name; + if (!PyArg_ParseTuple(args, "s", &name)) return NULL; + const EncounterDef* def = encounter_find(name); + if (!def) { + PyErr_Format(PyExc_ValueError, "unknown encounter: '%s'", name); + return NULL; + } + PyObject* dims = PyList_New(def->num_action_heads); + for (int i = 0; i < def->num_action_heads; i++) { + PyList_SetItem(dims, i, PyLong_FromLong(def->action_head_dims[i])); + } + PyObject* result = Py_BuildValue("{s:i,s:i,s:i,s:N}", + "obs_size", def->obs_size, + "mask_size", def->mask_size, + "num_action_heads", def->num_action_heads, + "action_head_dims", dims); + return result; +} + +#define MY_METHODS \ + {"vec_set_shaping_scale", vec_set_shaping_scale, METH_VARARGS, \ + "Set shaping_scale for all envs in the vec"}, \ + {"vec_set_pfsp_weights", vec_set_pfsp_weights, METH_VARARGS, \ + "Set PFSP pool and cumulative weights for all envs"}, \ + {"vec_get_pfsp_stats", vec_get_pfsp_stats, METH_VARARGS, \ + "Get aggregated PFSP win/episode stats and reset counters"}, \ + {"vec_enable_selfplay", vec_enable_selfplay, METH_VARARGS, \ + "Set p1 obs buffer for self-play in all envs"}, \ + {"vec_set_opponent_actions", vec_set_opponent_actions, METH_VARARGS, \ + "Push opponent actions to all envs for self-play"}, \ + {"get_encounter_info", get_encounter_info, METH_VARARGS, \ + "Get obs/action space dimensions for a named encounter"} + +// Wrappers for 3.0 ocean template (expects c_step/c_reset/c_close/c_render). +// When encounter_def is set, dispatch through the encounter interface. +// Otherwise fall through to legacy pvp_step (backwards compat). +static void c_step(Env* env) { + if (env->encounter_def) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + def->step(env->encounter_state, env->ocean_acts); + /* write terminal obs + mask before auto-reset */ + def->write_obs(env->encounter_state, env->ocean_obs); + float mask_buf[256]; + def->write_mask(env->encounter_state, mask_buf); + for (int i = 0; i < def->mask_size; i++) { + env->ocean_obs[def->obs_size + i] = mask_buf[i]; + } + env->ocean_rew[0] = def->get_reward(env->encounter_state); + int is_term = def->is_terminal(env->encounter_state); + env->ocean_term[0] = is_term ? 1 : 0; + /* auto-reset: write terminal obs (above), then reset for next episode */ + if (is_term) { + if (def->get_log) def->get_log(env->encounter_state); + def->reset(env->encounter_state, 0); + } + } else { + pvp_step(env); + // After auto-reset, pvp_step wrote terminal obs then reset game state. + // Overwrite with initial-state obs so policy sees correct first observation + // on the next step. Matches 4.0 training binding behavior. + if (env->ocean_term[0] && env->auto_reset) { + ocean_write_obs(env); + } + } +} +static void c_reset(Env* env) { + if (env->encounter_def) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + def->reset(env->encounter_state, env->rng_seed); + } else { + pvp_reset(env); + } +} +static void c_close(Env* env) { + if (env->encounter_def && env->encounter_state) { + const EncounterDef* def = (const EncounterDef*)env->encounter_def; + def->destroy(env->encounter_state); + env->encounter_state = NULL; + } + pvp_close(env); +} +static void c_render(Env* env) { pvp_render(env); } + +#include "../PufferLib/pufferlib/ocean/env_binding.h" + +static int my_init(Env* env, PyObject* args, PyObject* kwargs) { + (void)args; + + // Save PufferLib shared buffer pointers (set by env_binding.h template) + // before pvp_init redirects them to internal 2-agent buffers + float* shared_obs = (float*)env->observations; + int* shared_acts = (int*)env->actions; + float* shared_rew = (float*)env->rewards; + unsigned char* shared_term = (unsigned char*)env->terminals; + + // Encounter dispatch: check early before PvP-specific kwargs. + // Non-PvP encounters (e.g. zulrah) don't need opponent_type, is_lms, etc. + PyObject* encounter_name = PyDict_GetItemString(kwargs, "encounter"); + if (encounter_name && encounter_name != Py_None) { + const char* name = PyUnicode_AsUTF8(encounter_name); + if (!name) return -1; + const EncounterDef* def = encounter_find(name); + if (!def) { + PyErr_Format(PyExc_ValueError, "unknown encounter: '%s'", name); + return -1; + } + EncounterState* es = def->create(); + if (!es) { + PyErr_SetString(PyExc_RuntimeError, "encounter create() failed"); + return -1; + } + + // Optional seed + PyObject* seed_obj = PyDict_GetItemString(kwargs, "seed"); + uint32_t seed = 0; + if (seed_obj && seed_obj != Py_None) { + seed = (uint32_t)PyLong_AsLong(seed_obj); + if (PyErr_Occurred()) { def->destroy(es); return -1; } + def->put_int(es, "seed", (int)seed); + } + + def->reset(es, seed); + + // Wire encounter to env + env->encounter_def = def; + env->encounter_state = es; + env->ocean_obs = shared_obs; + env->ocean_acts = shared_acts; + env->ocean_rew = shared_rew; + env->ocean_term = shared_term; + + // Write initial obs through encounter interface + def->write_obs(es, env->ocean_obs); + float enc_mask_buf[256]; /* enough for any encounter mask */ + def->write_mask(es, enc_mask_buf); + for (int i = 0; i < def->mask_size; i++) { + env->ocean_obs[def->obs_size + i] = enc_mask_buf[i]; + } + env->ocean_rew[0] = 0.0f; + env->ocean_term[0] = 0; + return 0; + } + + // Legacy PvP path: initialize internal buffers and parse PvP-specific kwargs + pvp_init(env); + + // Set ocean pointers to PufferLib shared buffers + env->ocean_obs = shared_obs; + env->ocean_acts = shared_acts; + env->ocean_rew = shared_rew; + env->ocean_term = shared_term; + + // Parse kwargs (required for PvP mode) + int seed = (int)unpack(kwargs, "seed"); + if (PyErr_Occurred()) return -1; + + int opponent_type = (int)unpack(kwargs, "opponent_type"); + if (PyErr_Occurred()) return -1; + + int is_lms = (int)unpack(kwargs, "is_lms"); + if (PyErr_Occurred()) return -1; + + double shaping_scale = unpack(kwargs, "shaping_scale"); + if (PyErr_Occurred()) return -1; + + int shaping_enabled = (int)unpack(kwargs, "shaping_enabled"); + if (PyErr_Occurred()) return -1; + + // Configure environment + env->rng_seed = (uint32_t)(seed > 0 ? seed : 0); + env->has_rng_seed = (seed > 0) ? 1 : 0; + env->is_lms = is_lms; + + // Configure opponent + env->use_c_opponent = 1; + env->opponent.type = (OpponentType)opponent_type; + + // Configure reward shaping + env->shaping.shaping_scale = (float)shaping_scale; + env->shaping.enabled = shaping_enabled; + env->shaping.damage_dealt_coef = 0.005f; + env->shaping.damage_received_coef = -0.005f; + env->shaping.correct_prayer_bonus = 0.03f; + env->shaping.wrong_prayer_penalty = -0.02f; + env->shaping.prayer_switch_no_attack_penalty = -0.01f; + env->shaping.off_prayer_hit_bonus = 0.03f; + env->shaping.melee_frozen_penalty = -0.05f; + env->shaping.wasted_eat_penalty = -0.001f; + env->shaping.premature_eat_penalty = -0.02f; + env->shaping.magic_no_staff_penalty = -0.05f; + env->shaping.gear_mismatch_penalty = -0.05f; + env->shaping.spec_off_prayer_bonus = 0.02f; + env->shaping.spec_low_defence_bonus = 0.01f; + env->shaping.spec_low_hp_bonus = 0.02f; + env->shaping.smart_triple_eat_bonus = 0.05f; + env->shaping.wasted_triple_eat_penalty = -0.0005f; + env->shaping.damage_burst_bonus = 0.002f; + env->shaping.damage_burst_threshold = 30; + env->shaping.premature_eat_threshold = 0.7071f; + env->shaping.ko_bonus = 0.15f; + env->shaping.wasted_resources_penalty = -0.07f; + // Always-on behavioral penalties + env->shaping.prayer_penalty_enabled = 1; + env->shaping.click_penalty_enabled = 0; + env->shaping.click_penalty_threshold = 5; + env->shaping.click_penalty_coef = -0.003f; + + // Override behavioral penalty config from kwargs + PyObject* prayer_pen = PyDict_GetItemString(kwargs, "prayer_penalty_enabled"); + if (prayer_pen) env->shaping.prayer_penalty_enabled = PyObject_IsTrue(prayer_pen); + PyObject* click_pen = PyDict_GetItemString(kwargs, "click_penalty_enabled"); + if (click_pen) env->shaping.click_penalty_enabled = PyObject_IsTrue(click_pen); + PyObject* click_thresh = PyDict_GetItemString(kwargs, "click_penalty_threshold"); + if (click_thresh) env->shaping.click_penalty_threshold = (int)PyLong_AsLong(click_thresh); + PyObject* click_coef = PyDict_GetItemString(kwargs, "click_penalty_coef"); + if (click_coef) env->shaping.click_penalty_coef = (float)PyFloat_AsDouble(click_coef); + + // Collision map: load once from path kwarg, share across all envs + PyObject* cmap_path = PyDict_GetItemString(kwargs, "collision_map_path"); + if (cmap_path && cmap_path != Py_None && g_collision_map == NULL) { + const char* path = PyUnicode_AsUTF8(cmap_path); + if (path) { + g_collision_map = collision_map_load(path); + if (g_collision_map) { + fprintf(stderr, "collision map loaded: %d regions from %s\n", + g_collision_map->count, path); + } + } + } + env->collision_map = g_collision_map; + + // Gear tier weights: default 100% basic (tier 0) + env->gear_tier_weights[0] = 1.0f; + env->gear_tier_weights[1] = 0.0f; + env->gear_tier_weights[2] = 0.0f; + env->gear_tier_weights[3] = 0.0f; + + // Override gear tier weights if provided in kwargs + PyObject* tier_w = PyDict_GetItemString(kwargs, "gear_tier_weights"); + if (tier_w) { + PyObject* seq = PySequence_Fast(tier_w, "gear_tier_weights must be a sequence"); + if (seq) { + Py_ssize_t n = PySequence_Fast_GET_SIZE(seq); + if (n >= 4) { + PyObject** items = PySequence_Fast_ITEMS(seq); + for (int i = 0; i < 4; i++) { + env->gear_tier_weights[i] = (float)PyFloat_AsDouble(items[i]); + } + } + Py_DECREF(seq); + } + } + + // Legacy path: no encounter, direct pvp_step + pvp_reset(env); + + // Write initial observations to shared buffer + ocean_write_obs(env); + env->ocean_rew[0] = 0.0f; + env->ocean_term[0] = 0; + + return 0; +} + +static int my_log(PyObject* dict, Log* log) { + assign_to_dict(dict, "episode_return", log->episode_return); + assign_to_dict(dict, "episode_length", log->episode_length); + assign_to_dict(dict, "wins", log->wins); + assign_to_dict(dict, "damage_dealt", log->damage_dealt); + assign_to_dict(dict, "damage_received", log->damage_received); + return 0; +} diff --git a/ocean/osrs_pvp/osrs_collision.h b/ocean/osrs_pvp/osrs_collision.h new file mode 120000 index 0000000000..91d86e17f4 --- /dev/null +++ b/ocean/osrs_pvp/osrs_collision.h @@ -0,0 +1 @@ +../osrs/osrs_collision.h \ No newline at end of file diff --git a/ocean/osrs_pvp/osrs_combat_shared.h b/ocean/osrs_pvp/osrs_combat_shared.h new file mode 120000 index 0000000000..3e91318371 --- /dev/null +++ b/ocean/osrs_pvp/osrs_combat_shared.h @@ -0,0 +1 @@ +../osrs/osrs_combat_shared.h \ No newline at end of file diff --git a/ocean/osrs_pvp/osrs_encounter.h b/ocean/osrs_pvp/osrs_encounter.h new file mode 120000 index 0000000000..4a8829991a --- /dev/null +++ b/ocean/osrs_pvp/osrs_encounter.h @@ -0,0 +1 @@ +../osrs/osrs_encounter.h \ No newline at end of file diff --git a/ocean/osrs_pvp/osrs_items.h b/ocean/osrs_pvp/osrs_items.h new file mode 120000 index 0000000000..f5177f5167 --- /dev/null +++ b/ocean/osrs_pvp/osrs_items.h @@ -0,0 +1 @@ +../osrs/osrs_items.h \ No newline at end of file diff --git a/ocean/osrs_pvp/osrs_pathfinding.h b/ocean/osrs_pvp/osrs_pathfinding.h new file mode 120000 index 0000000000..4c1fd9309d --- /dev/null +++ b/ocean/osrs_pvp/osrs_pathfinding.h @@ -0,0 +1 @@ +../osrs/osrs_pathfinding.h \ No newline at end of file diff --git a/ocean/osrs_pvp/osrs_pvp.c b/ocean/osrs_pvp/osrs_pvp.c new file mode 100644 index 0000000000..d00b211a8c --- /dev/null +++ b/ocean/osrs_pvp/osrs_pvp.c @@ -0,0 +1,593 @@ +/** + * @fileoverview Standalone demo for OSRS PvP C Environment + * + * Demonstrates environment initialization, stepping, and basic performance. + * Compile: make + * Run: ./osrs_pvp + */ + +#include +#include +#include +#include +#include "osrs_pvp.h" +#include "osrs_encounter.h" +#include "encounters/encounter_nh_pvp.h" +#include "encounters/encounter_zulrah.h" +#include "encounters/encounter_inferno.h" + +#ifdef OSRS_PVP_VISUAL +#include "osrs_pvp_render.h" +#endif + +static void print_player_state(Player* p, int idx) { + printf("Player %d: HP=%d/%d Prayer=%d Gear=%d Pos=(%d,%d) Frozen=%d\n", + idx, p->current_hitpoints, p->base_hitpoints, + p->current_prayer, p->current_gear, p->x, p->y, p->frozen_ticks); +} + +static void print_env_state(OsrsPvp* env) { + printf("\n=== Tick %d ===\n", env->tick); + print_player_state(&env->players[0], 0); + print_player_state(&env->players[1], 1); + printf("PID holder: %d\n", env->pid_holder); +} + +static void run_random_episode(OsrsPvp* env, int verbose) { + pvp_reset(env); + + while (!env->episode_over) { + for (int agent = 0; agent < NUM_AGENTS; agent++) { + int* actions = env->actions + agent * NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + actions[h] = rand() % ACTION_HEAD_DIMS[h]; + } + } + + pvp_step(env); + + if (verbose && env->tick % 50 == 0) { + print_env_state(env); + } + } + + if (verbose) { + printf("\n=== Episode End ===\n"); + printf("Winner: Player %d\n", env->winner); + printf("Length: %d ticks\n", env->tick); + printf("P0 damage dealt: %.0f\n", env->players[0].total_damage_dealt); + printf("P1 damage dealt: %.0f\n", env->players[1].total_damage_dealt); + } +} + +static void benchmark(OsrsPvp* env, int num_steps) { + printf("Benchmarking %d steps...\n", num_steps); + + clock_t start = clock(); + int episodes = 0; + int total_steps = 0; + + while (total_steps < num_steps) { + pvp_reset(env); + episodes++; + + while (!env->episode_over && total_steps < num_steps) { + for (int agent = 0; agent < NUM_AGENTS; agent++) { + int* actions = env->actions + agent * NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + actions[h] = rand() % ACTION_HEAD_DIMS[h]; + } + } + + pvp_step(env); + total_steps++; + } + } + + clock_t end = clock(); + double elapsed = (double)(end - start) / CLOCKS_PER_SEC; + + printf("Results:\n"); + printf(" Total steps: %d\n", total_steps); + printf(" Episodes: %d\n", episodes); + printf(" Time: %.3f seconds\n", elapsed); + printf(" Steps/sec: %.0f\n", total_steps / elapsed); + printf(" Avg episode length: %.1f ticks\n", (float)total_steps / episodes); +} + +#ifdef OSRS_PVP_VISUAL +/* replay file: binary format for pre-recorded actions. + header: int32 num_ticks, then num_ticks * num_heads int32 values. */ +typedef struct { + int* actions; /* flat array: actions[tick * num_heads + head] */ + int num_ticks; + int num_heads; + int current_tick; +} ReplayFile; + +static ReplayFile* replay_load(const char* path, int num_heads) { + FILE* f = fopen(path, "rb"); + if (!f) { fprintf(stderr, "replay: can't open %s\n", path); return NULL; } + int num_ticks = 0; + if (fread(&num_ticks, 4, 1, f) != 1) { fclose(f); return NULL; } + ReplayFile* rf = (ReplayFile*)malloc(sizeof(ReplayFile)); + rf->num_ticks = num_ticks; + rf->num_heads = num_heads; + rf->current_tick = 0; + rf->actions = (int*)malloc(num_ticks * num_heads * sizeof(int)); + size_t n = fread(rf->actions, sizeof(int), num_ticks * num_heads, f); + fclose(f); + if ((int)n != num_ticks * num_heads) { + fprintf(stderr, "replay: short read (%d/%d)\n", (int)n, num_ticks * num_heads); + free(rf->actions); free(rf); return NULL; + } + fprintf(stderr, "replay loaded: %d ticks from %s\n", num_ticks, path); + return rf; +} + +static int replay_get_actions(ReplayFile* rf, int* out) { + if (rf->current_tick >= rf->num_ticks) return 0; + int base = rf->current_tick * rf->num_heads; + for (int h = 0; h < rf->num_heads; h++) out[h] = rf->actions[base + h]; + rf->current_tick++; + return 1; +} + +static void replay_free(ReplayFile* rf) { + if (rf) { free(rf->actions); free(rf); } +} + +static void run_visual(OsrsPvp* env, const char* encounter_name, const char* replay_path) { + env->client = NULL; + + /* set up encounter if specified, otherwise default to PvP */ + if (encounter_name) { + const EncounterDef* edef = encounter_find(encounter_name); + if (!edef) { + fprintf(stderr, "unknown encounter: %s\n", encounter_name); + return; + } + env->encounter_def = (void*)edef; + env->encounter_state = edef->create(); + edef->put_int(env->encounter_state, "seed", 42); + + /* load encounter-specific collision map. + world offset translates encounter-local (0,0) → world coords for cmap lookup. + the Zulrah island collision data has ~69 walkable tiles forming the + irregular island shape (narrow south, wide north, pillar alcoves). */ + if (strcmp(encounter_name, "zulrah") == 0) { + CollisionMap* cmap = collision_map_load("data/zulrah.cmap"); + if (cmap) { + edef->put_ptr(env->encounter_state, "collision_map", cmap); + edef->put_int(env->encounter_state, "world_offset_x", 2256); + edef->put_int(env->encounter_state, "world_offset_y", 3061); + env->collision_map = cmap; + fprintf(stderr, "zulrah collision map: %d regions, offset (2256, 3061)\n", + cmap->count); + } + } else if (strcmp(encounter_name, "inferno") == 0) { + CollisionMap* cmap = collision_map_load("data/inferno.cmap"); + if (cmap) { + edef->put_ptr(env->encounter_state, "collision_map", cmap); + edef->put_int(env->encounter_state, "world_offset_x", 2246); + edef->put_int(env->encounter_state, "world_offset_y", 5315); + env->collision_map = cmap; + fprintf(stderr, "inferno collision map: %d regions, offset (2246, 5315)\n", + cmap->count); + } + } + + edef->reset(env->encounter_state, 42); + fprintf(stderr, "encounter: %s (obs=%d, heads=%d)\n", + edef->name, edef->obs_size, edef->num_action_heads); + } else { + env->use_c_opponent = 1; + env->opponent.type = OPP_IMPROVED; + env->is_lms = 1; + pvp_reset(env); + } + + /* load collision map from env var if set */ + const char* cmap_path = getenv("OSRS_COLLISION_MAP"); + if (cmap_path && cmap_path[0]) { + env->collision_map = collision_map_load(cmap_path); + if (env->collision_map) { + fprintf(stderr, "collision map loaded: %d regions\n", + ((CollisionMap*)env->collision_map)->count); + } + } + + /* init window before main loop (WindowShouldClose needs a window) */ + pvp_render(env); + RenderClient* rc = (RenderClient*)env->client; + + /* share collision map pointer with renderer for overlays */ + if (env->collision_map) { + rc->collision_map = (const CollisionMap*)env->collision_map; + } + + /* load 3D assets if available */ + rc->model_cache = model_cache_load("data/equipment.models"); + if (rc->model_cache) { + rc->show_models = 1; + } + rc->anim_cache = anim_cache_load("data/equipment.anims"); + render_init_overlay_models(rc); + /* load terrain/objects per encounter */ + if (!encounter_name) { + rc->terrain = terrain_load("data/wilderness.terrain"); + rc->objects = objects_load("data/wilderness.objects"); + rc->npcs = objects_load("data/wilderness.npcs"); + } else if (strcmp(encounter_name, "zulrah") == 0) { + rc->terrain = terrain_load("data/zulrah.terrain"); + rc->objects = objects_load("data/zulrah.objects"); + + /* Zulrah coordinate alignment + ============================ + three coordinate spaces are in play: + + 1. OSRS world coords: absolute tile positions (e.g. 2256, 3061). + terrain, objects, and collision maps are all authored in this space. + + 2. encounter-local coords: the encounter arena uses (0,0) as origin. + the encounter state, entity positions, and arena bounds all use this. + + 3. raylib world coords: X = east, Y = up, Z = -north (right-handed). + terrain_offset/objects_offset subtract the world origin so that + encounter-local (0,0) maps to raylib (0,0). + + terrain/objects offset: subtract (2256, 3061) from world coords. + regions (35,47)+(35,48) start at world (2240, 3008). + the island platform is at world ~(2256, 3061), so offset = 2240+16, 3008+53. + + collision map offset: ADD (2254, 3060) to encounter-local coords. + collision_get_flags expects world coords, so when the renderer or + encounter queries tile (x, y) in local space, it looks up + (x + 2254, y + 3060) in the collision map. */ + int zul_off_x = 2240 + 16; + int zul_off_y = 3008 + 53; + if (rc->terrain) + terrain_offset(rc->terrain, zul_off_x, zul_off_y); + if (rc->objects) + objects_offset(rc->objects, zul_off_x, zul_off_y); + + rc->collision_map = (const CollisionMap*)env->collision_map; + rc->collision_world_offset_x = 2256; + rc->collision_world_offset_y = 3061; + } else if (encounter_name && strcmp(encounter_name, "inferno") == 0) { + rc->terrain = terrain_load("data/inferno.terrain"); + rc->objects = objects_load("data/inferno.objects"); + /* inferno region (35,83) starts at world (2240, 5312). + encounter uses region-local coords (10-40, 13-44). + offset terrain/objects so local coord 0 maps to world 2240. */ + if (rc->terrain) + terrain_offset(rc->terrain, 2246, 5315); + if (rc->objects) + objects_offset(rc->objects, 2246, 5315); + + rc->npc_model_cache = model_cache_load("data/inferno_npcs.models"); + rc->npc_anim_cache = anim_cache_load("data/inferno_npcs.anims"); + + /* collision map for debug overlay (C key) */ + if (env->collision_map) { + rc->collision_map = (const CollisionMap*)env->collision_map; + rc->collision_world_offset_x = 2246; + rc->collision_world_offset_y = 5315; + } + + fprintf(stderr, "inferno: terrain=%s, cmap=%s, npc_models=%d, npc_anims=%d seqs\n", + rc->terrain ? "loaded" : "MISSING", + rc->collision_map ? "loaded" : "MISSING", + rc->npc_model_cache ? rc->npc_model_cache->count : 0, + rc->npc_anim_cache ? rc->npc_anim_cache->seq_count : 0); + } + + /* populate entity pointers (also sets arena bounds from encounter) */ + render_populate_entities(rc, env); + + /* update camera target to center on the (possibly new) arena */ + rc->cam_target_x = (float)rc->arena_base_x + (float)rc->arena_width / 2.0f; + rc->cam_target_z = -((float)rc->arena_base_y + (float)rc->arena_height / 2.0f); + + for (int i = 0; i < rc->entity_count; i++) { + int size = rc->entities[i].npc_size > 1 ? rc->entities[i].npc_size : 1; + rc->sub_x[i] = rc->entities[i].x * 128 + size * 64; + rc->sub_y[i] = rc->entities[i].y * 128 + size * 64; + rc->dest_x[i] = rc->sub_x[i]; + rc->dest_y[i] = rc->sub_y[i]; + } + + /* load replay file if specified */ + ReplayFile* replay = NULL; + if (replay_path && env->encounter_def) { + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + replay = replay_load(replay_path, edef->num_action_heads); + } + + /* save initial state as first snapshot */ + render_save_snapshot(rc, env); + + while (!WindowShouldClose()) { + + /* rewind: restore historical state and re-render */ + if (rc->step_back) { + rc->step_back = 0; + render_restore_snapshot(rc, env); + /* if we restored the latest snapshot, exit rewind mode */ + if (rc->history_cursor >= rc->history_count - 1) { + rc->history_cursor = -1; + } + pvp_render(env); + continue; + } + + /* in rewind mode viewing history: just render, don't step */ + if (rc->history_cursor >= 0) { + pvp_render(env); + continue; + } + + /* paused: render but don't step */ + if (rc->is_paused && !rc->step_once) { + pvp_render(env); + continue; + } + rc->step_once = 0; + + /* tick pacing: keep rendering while waiting */ + if (rc->ticks_per_second > 0.0f) { + double interval = 1.0 / rc->ticks_per_second; + while (GetTime() - rc->last_tick_time < interval) { + pvp_render(env); + if (WindowShouldClose()) return; + } + } + rc->last_tick_time = GetTime(); + + /* step the simulation */ + render_pre_tick(rc, env); + + if (env->encounter_def && env->encounter_state) { + /* encounter mode */ + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + int enc_actions[16] = {0}; + + if (rc->human_input.enabled) { + /* human control: translate staged clicks to encounter actions */ + human_to_encounter_actions_generic(&rc->human_input, enc_actions, + edef, env->encounter_state); + /* set encounter destination from human click for proper pathfinding. + attacking an NPC cancels movement (OSRS: server stops walking + to old dest and auto-walks toward target instead). */ + if (rc->human_input.pending_move_x >= 0 && edef->put_int) { + edef->put_int(env->encounter_state, "player_dest_x", + rc->human_input.pending_move_x); + edef->put_int(env->encounter_state, "player_dest_y", + rc->human_input.pending_move_y); + } else if (rc->human_input.pending_attack && edef->put_int) { + edef->put_int(env->encounter_state, "player_dest_x", -1); + edef->put_int(env->encounter_state, "player_dest_y", -1); + } + human_input_clear_pending(&rc->human_input); + } else if (replay && replay_get_actions(replay, enc_actions)) { + /* replay mode: actions come from pre-recorded file */ + } else if (strcmp(edef->name, "zulrah") == 0) { + zul_heuristic_actions((ZulrahState*)env->encounter_state, enc_actions); + } else { + for (int h = 0; h < edef->num_action_heads; h++) { + enc_actions[h] = rand() % edef->action_head_dims[h]; + } + } + edef->step(env->encounter_state, enc_actions); + /* sync env->tick so renderer HP bars/splats use correct tick */ + env->tick = edef->get_tick(env->encounter_state); + + /* clear human move when player arrived at clicked destination */ + if (rc->human_input.enabled && rc->human_input.pending_move_x >= 0) { + Player* ply = edef->get_entity(env->encounter_state, 0); + if (ply && ply->x == rc->human_input.pending_move_x && + ply->y == rc->human_input.pending_move_y) { + human_input_clear_move(&rc->human_input); + } + } + + } else { + /* PvP mode */ + if (rc->human_input.enabled) { + /* human control: translate staged clicks to PvP actions for agent 0 */ + human_to_pvp_actions(&rc->human_input, + env->actions, &env->players[0], &env->players[1]); + /* opponent still gets random actions */ + int* opp = env->actions + NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + opp[h] = rand() % ACTION_HEAD_DIMS[h]; + } + human_input_clear_pending(&rc->human_input); + } else { + for (int agent = 0; agent < NUM_AGENTS; agent++) { + int* actions = env->actions + agent * NUM_ACTION_HEADS; + for (int h = 0; h < NUM_ACTION_HEADS; h++) { + actions[h] = rand() % ACTION_HEAD_DIMS[h]; + } + } + } + pvp_step(env); + + /* clear human move when player arrived at clicked destination */ + if (rc->human_input.enabled && rc->human_input.pending_move_x >= 0) { + Player* p0 = &env->players[0]; + if (p0->x == rc->human_input.pending_move_x && + p0->y == rc->human_input.pending_move_y) { + human_input_clear_move(&rc->human_input); + } + } + } + + render_post_tick(rc, env); + render_save_snapshot(rc, env); + pvp_render(env); + + /* auto-reset on episode end */ + int is_over = env->encounter_def + ? ((const EncounterDef*)env->encounter_def)->is_terminal(env->encounter_state) + : env->episode_over; + if (is_over) { + /* hold final frame for 2 seconds */ + double end_time = GetTime(); + while (GetTime() - end_time < 2.0 && !WindowShouldClose()) { + pvp_render(env); + } + render_clear_history(rc); + effect_clear_all(rc->effects); + rc->gui.inv_grid_dirty = 1; + if (env->encounter_def) { + ((const EncounterDef*)env->encounter_def)->reset( + env->encounter_state, (uint32_t)rand()); + } else { + pvp_reset(env); + } + render_populate_entities(rc, env); + for (int i = 0; i < rc->entity_count; i++) { + rc->sub_x[i] = rc->entities[i].x * 128 + 64; + rc->sub_y[i] = rc->entities[i].y * 128 + 64; + rc->dest_x[i] = rc->sub_x[i]; + rc->dest_y[i] = rc->sub_y[i]; + } + render_save_snapshot(rc, env); + } + } + + replay_free(replay); + + if (env->client) { + render_destroy_client((RenderClient*)env->client); + env->client = NULL; + } + if (env->encounter_def && env->encounter_state) { + ((const EncounterDef*)env->encounter_def)->destroy(env->encounter_state); + env->encounter_state = NULL; + } +} +#endif + +int main(int argc, char** argv) { + int use_visual = 0; + int gear_tier = -1; /* -1 = random (default LMS distribution) */ + const char* encounter_name __attribute__((unused)) = NULL; + const char* replay_path __attribute__((unused)) = NULL; + for (int i = 1; i < argc; i++) { + if (strcmp(argv[i], "--visual") == 0) use_visual = 1; + else if (strcmp(argv[i], "--encounter") == 0 && i + 1 < argc) + encounter_name = argv[++i]; + else if (strcmp(argv[i], "--replay") == 0 && i + 1 < argc) + replay_path = argv[++i]; + else if (strcmp(argv[i], "--tier") == 0 && i + 1 < argc) + gear_tier = atoi(argv[++i]); + } + + srand((unsigned int)time(NULL)); + + /* verify encounter registry + lifecycle */ + { + const EncounterDef* nh = encounter_find("nh_pvp"); + if (!nh) { fprintf(stderr, "FATAL: nh_pvp encounter not registered\n"); return 1; } + printf("encounter registry: '%s' (obs=%d, heads=%d, mask=%d)\n", + nh->name, nh->obs_size, nh->num_action_heads, nh->mask_size); + + /* smoke test: create → reset → step → check terminal → destroy */ + EncounterState* es = nh->create(); + nh->put_int(es, "use_c_opponent", 1); + nh->put_int(es, "opponent_type", OPP_IMPROVED); + nh->reset(es, 42); + int actions[7] = {0}; + for (int t = 0; t < 300 && !nh->is_terminal(es); t++) { + nh->step(es, actions); + } + printf("encounter smoke test: tick=%d, terminal=%d, winner=%d\n", + nh->get_tick(es), nh->is_terminal(es), nh->get_winner(es)); + nh->destroy(es); + } + + OsrsPvp env; + memset(&env, 0, sizeof(OsrsPvp)); + + if (use_visual) { +#ifdef OSRS_PVP_VISUAL + /* pvp_init uses internal buffers — no malloc needed */ + pvp_init(&env); + /* set gear tier: --tier N forces both players to tier N, + otherwise default LMS distribution (mostly tier 0) */ + if (gear_tier >= 0 && gear_tier <= 3) { + for (int t = 0; t < 4; t++) env.gear_tier_weights[t] = 0.0f; + env.gear_tier_weights[gear_tier] = 1.0f; + } else { + /* default LMS: 60% tier 0, 25% tier 1, 10% tier 2, 5% tier 3 */ + env.gear_tier_weights[0] = 0.60f; + env.gear_tier_weights[1] = 0.25f; + env.gear_tier_weights[2] = 0.10f; + env.gear_tier_weights[3] = 0.05f; + } + env.ocean_acts = env.actions; + env.ocean_obs = env._obs_buf; + env.ocean_rew = env.rewards; + env.ocean_term = env.terminals; + run_visual(&env, encounter_name, replay_path); + pvp_close(&env); +#else + fprintf(stderr, "not compiled with visual support (use: make visual)\n"); + return 1; +#endif + } else { + /* headless: allocate external buffers (matches original demo) */ + env.observations = (float*)calloc(NUM_AGENTS * SLOT_NUM_OBSERVATIONS, sizeof(float)); + env.actions = (int*)calloc(NUM_AGENTS * NUM_ACTION_HEADS, sizeof(int)); + env.rewards = (float*)calloc(NUM_AGENTS, sizeof(float)); + env.terminals = (unsigned char*)calloc(NUM_AGENTS, sizeof(unsigned char)); + env.action_masks = (unsigned char*)calloc(NUM_AGENTS * ACTION_MASK_SIZE, sizeof(unsigned char)); + env.action_masks_agents = (1 << NUM_AGENTS) - 1; + env.ocean_acts = env.actions; + env.ocean_obs = (float*)calloc(OCEAN_OBS_SIZE, sizeof(float)); + env.ocean_rew = env.rewards; + env.ocean_term = env.terminals; + + printf("OSRS PvP C Environment Demo\n"); + printf("===========================\n\n"); + + printf("Running single verbose episode...\n"); + run_random_episode(&env, 1); + + printf("\n"); + benchmark(&env, 100000); + + printf("\nVerifying observations...\n"); + pvp_reset(&env); + printf("Observation count per agent: %d\n", SLOT_NUM_OBSERVATIONS); + printf("First 10 observations (agent 0): "); + for (int i = 0; i < 10; i++) { + printf("%.2f ", env.observations[i]); + } + printf("\n"); + + printf("\nAction heads: %d\n", NUM_ACTION_HEADS); + printf("Action dims: ["); + for (int i = 0; i < NUM_ACTION_HEADS; i++) { + printf("%d", ACTION_HEAD_DIMS[i]); + if (i < NUM_ACTION_HEADS - 1) { + printf(", "); + } + } + printf("]\n"); + + printf("\nDemo complete.\n"); + + free(env.observations); + free(env.actions); + free(env.rewards); + free(env.terminals); + free(env.action_masks); + free(env.ocean_obs); + pvp_close(&env); + } + + return 0; +} diff --git a/ocean/osrs_pvp/osrs_types.h b/ocean/osrs_pvp/osrs_types.h new file mode 120000 index 0000000000..d11a43651d --- /dev/null +++ b/ocean/osrs_pvp/osrs_types.h @@ -0,0 +1 @@ +../osrs/osrs_types.h \ No newline at end of file diff --git a/ocean/osrs_pvp/pfsp.py b/ocean/osrs_pvp/pfsp.py new file mode 100644 index 0000000000..31cc7f3a2c --- /dev/null +++ b/ocean/osrs_pvp/pfsp.py @@ -0,0 +1,68 @@ +"""PFSP (prioritized fictitious self-play) for osrs_pvp. + +opponent pool definitions, weight initialization, and adaptive weight recomputation +based on per-opponent win rates. used by sweep trials to train against a diverse +pool of opponents with difficulty-proportional sampling. +""" + +from __future__ import annotations + +# opponent name -> enum value (from osrs_pvp_types.h OpponentType) +OPP_PFSP = 16 # special opponent type that samples from the pool + +POOL = { + "true_random": 1, "panicking": 2, "weak_random": 3, "semi_random": 4, + "sticky_prayer": 5, "random_eater": 6, "prayer_rookie": 7, "improved": 8, + "onetick": 11, "unpredictable_improved": 12, "unpredictable_onetick": 13, + "novice_nh": 17, "apprentice_nh": 18, "competent_nh": 19, + "intermediate_nh": 20, "advanced_nh": 21, "proficient_nh": 22, + "expert_nh": 23, "master_nh": 24, "savant_nh": 25, + "nightmare_nh": 26, "veng_fighter": 27, "blood_healer": 28, + "gmaul_combo": 29, +} +POOL_NAMES = list(POOL.keys()) +POOL_TYPES = list(POOL.values()) + +# PFSP tuning constants +WEIGHT_EXPONENT = 1.5 # (1-winrate)^p +WEIGHT_FLOOR = 0.02 +UPDATE_INTERVAL = 2_000_000 # steps between weight recomputation +WARMUP_EPISODES = 50 # min episodes per opponent before reweighting + + +def init_pfsp(pufferl_handle: object, total_agents: int) -> dict: + """Initialize PFSP pool with uniform weights. Returns PFSP state dict.""" + pool_size = len(POOL_TYPES) + cum_weights = [int((i + 1) / pool_size * 1000) for i in range(pool_size)] + cum_weights[-1] = 1000 + pufferl_handle.set_pfsp_weights(POOL_TYPES, cum_weights) + return { + "cum_episodes": [0.0] * pool_size, + "last_update_step": 0, + } + + +def update_pfsp(pufferl_handle: object, pfsp_state: dict, global_step: int) -> None: + """Recompute PFSP weights based on per-opponent win rates.""" + if (global_step - pfsp_state["last_update_step"]) < UPDATE_INTERVAL: + return + + wins_delta, episodes_delta = pufferl_handle.get_pfsp_stats() + pool_size = len(POOL_TYPES) + + for i in range(pool_size): + pfsp_state["cum_episodes"][i] += episodes_delta[i] + + if min(pfsp_state["cum_episodes"]) < WARMUP_EPISODES: + pfsp_state["last_update_step"] = global_step + return + + raw_weights = [] + for i in range(pool_size): + wr = wins_delta[i] / max(episodes_delta[i], 1) + raw_weights.append(max((1.0 - wr) ** WEIGHT_EXPONENT, WEIGHT_FLOOR)) + total_w = sum(raw_weights) + cum_weights = [int(sum(raw_weights[:i + 1]) / total_w * 1000) for i in range(pool_size)] + cum_weights[-1] = 1000 + pufferl_handle.set_pfsp_weights(POOL_TYPES, cum_weights) + pfsp_state["last_update_step"] = global_step diff --git a/ocean/osrs_zulrah/binding.c b/ocean/osrs_zulrah/binding.c new file mode 100644 index 0000000000..8041af27af --- /dev/null +++ b/ocean/osrs_zulrah/binding.c @@ -0,0 +1,142 @@ +/** + * @file binding.c + * @brief Static-native binding for OSRS Zulrah encounter. + * + * Bridges vecenv.h's contract (double actions, float terminals) with the + * Zulrah encounter's vtable interface. Uses the encounter system (EncounterDef) + * rather than OsrsPvp directly. + */ + +#include +#include +#include + +#include "../osrs/osrs_encounter.h" +#include "../osrs/osrs_types.h" +#include "../osrs/encounters/encounter_zulrah.h" + +/* total obs = raw obs + action mask */ +#define ZUL_TOTAL_OBS (ZUL_NUM_OBS + ZUL_ACTION_MASK_SIZE) + +/* wrapper struct: vecenv-compatible fields at top + encounter state. + * vecenv.h's create_static_vec assigns env->observations, env->actions, + * env->rewards, env->terminals directly. */ +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + EncounterState* enc_state; + + /* staging buffer for action type conversion */ + int acts_staging[ZUL_NUM_ACTION_HEADS]; + unsigned char term_staging; +} ZulrahEnv; + +#define OBS_SIZE ZUL_TOTAL_OBS +#define NUM_ATNS ZUL_NUM_ACTION_HEADS +#define ACT_SIZES {ZUL_MOVE_DIM, ZUL_ATTACK_DIM, ZUL_PRAYER_DIM, ZUL_FOOD_DIM, ZUL_POTION_DIM, ZUL_SPEC_DIM} +#define OBS_TYPE FLOAT +#define ACT_TYPE FLOAT +#define Env ZulrahEnv + +/* c_step/c_reset/c_close/c_render must be defined BEFORE including vecenv.h */ + +void c_step(Env* env) { + /* double actions -> int staging */ + for (int i = 0; i < NUM_ATNS; i++) { + env->acts_staging[i] = (int)env->actions[i]; + } + + ENCOUNTER_ZULRAH.step(env->enc_state, env->acts_staging); + + /* write obs + mask directly (mask appended after raw obs) */ + float* obs = (float*)env->observations; + ENCOUNTER_ZULRAH.write_obs(env->enc_state, obs); + ENCOUNTER_ZULRAH.write_mask(env->enc_state, obs + ZUL_NUM_OBS); + + /* reward */ + env->rewards[0] = ENCOUNTER_ZULRAH.get_reward(env->enc_state); + + /* terminal */ + int is_term = ENCOUNTER_ZULRAH.is_terminal(env->enc_state); + env->term_staging = (unsigned char)is_term; + env->terminals[0] = (float)is_term; + + /* log directly into env->log (vecenv accumulates + clears periodically). + bypass get_log vtable to avoid double-counting from encounter's own += */ + if (is_term) { + ZulrahState* zs = (ZulrahState*)env->enc_state; + env->log.episode_return += zs->reward; + env->log.episode_length += (float)zs->tick; + env->log.wins += (zs->winner == 0) ? 1.0f : 0.0f; + env->log.damage_dealt += zs->total_damage_dealt; + env->log.damage_received += zs->total_damage_received; + env->log.n += 1.0f; + + /* auto-reset */ + ENCOUNTER_ZULRAH.reset(env->enc_state, 0); + ENCOUNTER_ZULRAH.write_obs(env->enc_state, obs); + ENCOUNTER_ZULRAH.write_mask(env->enc_state, obs + ZUL_NUM_OBS); + } +} + +void c_reset(Env* env) { + ENCOUNTER_ZULRAH.reset(env->enc_state, 0); + + float* obs = (float*)env->observations; + ENCOUNTER_ZULRAH.write_obs(env->enc_state, obs); + ENCOUNTER_ZULRAH.write_mask(env->enc_state, obs + ZUL_NUM_OBS); + + env->rewards[0] = 0.0f; + env->term_staging = 0; + env->terminals[0] = 0.0f; +} + +void c_close(Env* env) { + if (env->enc_state) { + ENCOUNTER_ZULRAH.destroy(env->enc_state); + env->enc_state = NULL; + } +} + +void c_render(Env* env) { (void)env; } + +#include "vecenv.h" + +void my_init(Env* env, Dict* kwargs) { + env->num_agents = 1; + env->enc_state = ENCOUNTER_ZULRAH.create(); + memset(&env->log, 0, sizeof(Log)); + + /* gear tier config (default 0 = budget) */ + DictItem* gear = dict_get_unsafe(kwargs, "gear_tier"); + if (gear) { + ENCOUNTER_ZULRAH.put_int(env->enc_state, "gear_tier", (int)gear->value); + } +} + +void my_log(Log* log, Dict* out) { + dict_set(out, "episode_return", log->episode_return); + dict_set(out, "episode_length", log->episode_length); + dict_set(out, "wins", log->wins); + dict_set(out, "damage_dealt", log->damage_dealt); + dict_set(out, "damage_received", log->damage_received); + + /* composite score: winrate-gated efficiency. + * must win to score positive. among winners, faster kills and less + * damage taken score higher. winrate always dominates (~1.0 scale + * vs ~0.3 efficiency). vecenv divides all log fields by n before + * calling my_log, so log->wins is already a winrate in [0, 1]. */ + float wr = log->wins; + float speed_bonus = (wr > 0.1f) + ? (1.0f - log->episode_length / (float)ZUL_MAX_TICKS) * 0.3f : 0.0f; + float dmg_penalty = (wr > 0.1f) + ? (log->damage_received / (float)ZUL_BASE_HP) * 0.2f : 0.0f; + float score = wr + speed_bonus - dmg_penalty - (1.0f - wr); + dict_set(out, "score", score); +} diff --git a/ocean/osrs_zulrah/data b/ocean/osrs_zulrah/data new file mode 120000 index 0000000000..03f77fc4d5 --- /dev/null +++ b/ocean/osrs_zulrah/data @@ -0,0 +1 @@ +../osrs/data \ No newline at end of file diff --git a/ocean/osrs_zulrah/encounters b/ocean/osrs_zulrah/encounters new file mode 120000 index 0000000000..0cd6c57b5f --- /dev/null +++ b/ocean/osrs_zulrah/encounters @@ -0,0 +1 @@ +../osrs/encounters \ No newline at end of file diff --git a/ocean/osrs_zulrah/osrs_collision.h b/ocean/osrs_zulrah/osrs_collision.h new file mode 120000 index 0000000000..91d86e17f4 --- /dev/null +++ b/ocean/osrs_zulrah/osrs_collision.h @@ -0,0 +1 @@ +../osrs/osrs_collision.h \ No newline at end of file diff --git a/ocean/osrs_zulrah/osrs_combat_shared.h b/ocean/osrs_zulrah/osrs_combat_shared.h new file mode 120000 index 0000000000..3e91318371 --- /dev/null +++ b/ocean/osrs_zulrah/osrs_combat_shared.h @@ -0,0 +1 @@ +../osrs/osrs_combat_shared.h \ No newline at end of file diff --git a/ocean/osrs_zulrah/osrs_encounter.h b/ocean/osrs_zulrah/osrs_encounter.h new file mode 120000 index 0000000000..4a8829991a --- /dev/null +++ b/ocean/osrs_zulrah/osrs_encounter.h @@ -0,0 +1 @@ +../osrs/osrs_encounter.h \ No newline at end of file diff --git a/ocean/osrs_zulrah/osrs_items.h b/ocean/osrs_zulrah/osrs_items.h new file mode 120000 index 0000000000..f5177f5167 --- /dev/null +++ b/ocean/osrs_zulrah/osrs_items.h @@ -0,0 +1 @@ +../osrs/osrs_items.h \ No newline at end of file diff --git a/ocean/osrs_zulrah/osrs_pathfinding.h b/ocean/osrs_zulrah/osrs_pathfinding.h new file mode 120000 index 0000000000..4c1fd9309d --- /dev/null +++ b/ocean/osrs_zulrah/osrs_pathfinding.h @@ -0,0 +1 @@ +../osrs/osrs_pathfinding.h \ No newline at end of file diff --git a/ocean/osrs_zulrah/osrs_types.h b/ocean/osrs_zulrah/osrs_types.h new file mode 120000 index 0000000000..d11a43651d --- /dev/null +++ b/ocean/osrs_zulrah/osrs_types.h @@ -0,0 +1 @@ +../osrs/osrs_types.h \ No newline at end of file diff --git a/pufferlib/__init__.py b/pufferlib/__init__.py deleted file mode 100644 index e6002945e9..0000000000 --- a/pufferlib/__init__.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = 4.0 diff --git a/pufferlib/models.py b/pufferlib/models.py deleted file mode 100644 index 0c80a7c271..0000000000 --- a/pufferlib/models.py +++ /dev/null @@ -1,302 +0,0 @@ -import numpy as np - -import torch -import torch.nn as nn -import torch.nn.functional as F -from torch.nn import Linear - -class DefaultEncoder(nn.Module): - def __init__(self, obs_size, hidden_size=128): - super().__init__() - self.encoder = nn.Linear(obs_size, hidden_size) - - def forward(self, observations): - return self.encoder(observations.view(observations.shape[0], -1).float()) - -class DefaultDecoder(nn.Module): - def __init__(self, nvec, hidden_size=128): - super().__init__() - self.nvec = tuple(nvec) - self.is_continuous = sum(nvec) == len(nvec) - - if self.is_continuous: - num_atns = len(nvec) - self.decoder_mean = nn.Linear(hidden_size, num_atns) - self.decoder_logstd = nn.Parameter(torch.zeros(1, num_atns)) - else: - self.decoder = nn.Linear(hidden_size, int(np.sum(nvec))) - - self.value_function = nn.Linear(hidden_size, 1) - - def forward(self, hidden): - if self.is_continuous: - mean = self.decoder_mean(hidden) - logstd = self.decoder_logstd.expand_as(mean) - logits = torch.distributions.Normal(mean, torch.exp(logstd)) - else: - logits = self.decoder(hidden) - if len(self.nvec) > 1: - logits = logits.split(self.nvec, dim=1) - - values = self.value_function(hidden) - return logits, values - -class Policy(nn.Module): - def __init__(self, encoder, decoder, network): - super().__init__() - self.encoder = encoder - self.decoder = decoder - self.network = network - - def initial_state(self, batch_size, device): - return self.network.initial_state(batch_size, device) - - def forward_eval(self, x, state): - h = self.encoder(x) - h, state = self.network.forward_eval(h, state) - logits, values = self.decoder(h) - return logits, values, state - - def forward(self, x): - B, TT = x.shape[:2] - h = self.encoder(x.reshape(B*TT, *x.shape[2:])) - h = self.network.forward_train(h.reshape(B, TT, -1)) - logits, values = self.decoder(h.reshape(B*TT, -1)) - return logits, values.reshape(B, TT) - -class MinGRU(nn.Module): - # https://arxiv.org/abs/2410.01201v1 - def __init__(self, hidden_size, num_layers=1, **kwargs): - super().__init__() - self.hidden_size = hidden_size - self.num_layers = num_layers - self.layers = nn.ModuleList([ - Linear(hidden_size, 3 * hidden_size, bias=False) for _ in range(num_layers) - ]) - - def _g(self, x): - return torch.where(x >= 0, x + 0.5, x.sigmoid()) - - def _log_g(self, x): - return torch.where(x >= 0, (F.relu(x) + 0.5).log(), -F.softplus(-x)) - - def _highway(self, x, out, proj): - g = proj.sigmoid() - return g * out + (1.0 - g) * x - - def _heinsen_scan(self, log_coeffs, log_values): - a_star = log_coeffs.cumsum(dim=1) - return (a_star + (log_values - a_star).logcumsumexp(dim=1)).exp() - - def initial_state(self, batch_size, device): - return (torch.zeros(self.num_layers, batch_size, self.hidden_size, device=device),) - - def forward_eval(self, h, state): - state = state[0] - assert state.shape[1] == h.shape[0] - h = h.unsqueeze(1) - state_out = [] - for i in range(self.num_layers): - hidden, gate, proj = self.layers[i](h).chunk(3, dim=-1) - out = torch.lerp(state[i:i+1].transpose(0, 1), self._g(hidden), gate.sigmoid()) - h = self._highway(h, out, proj) - state_out.append(out[:, -1:]) - return h.squeeze(1), (torch.stack(state_out, 0).squeeze(2),) - - def forward_train(self, h): - T = h.shape[1] - for i in range(self.num_layers): - hidden, gate, proj = self.layers[i](h).chunk(3, dim=-1) - log_coeffs = -F.softplus(gate) - log_values = -F.softplus(-gate) + self._log_g(hidden) - out = self._heinsen_scan(log_coeffs, log_values)[:, -T:] - h = self._highway(h, out, proj) - return h - -class MLP(nn.Module): - def __init__(self, hidden_size, num_layers=1, **kwargs): - super().__init__() - - def initial_state(self, batch_size, device): - return () - - def forward_eval(self, h, state): - return h, state - - def forward_train(self, h): - return h - -class LSTM(nn.Module): - def __init__(self, hidden_size, num_layers=1, **kwargs): - super().__init__() - self.hidden_size = hidden_size - self.num_layers = num_layers - - self.lstm = nn.LSTM(hidden_size, hidden_size, num_layers=num_layers) - self.cell = nn.ModuleList([torch.nn.LSTMCell(hidden_size, hidden_size) for _ in range(num_layers)]) - - for i in range(num_layers): - cell = self.cell[i] - w_ih = getattr(self.lstm, f'weight_ih_l{i}') - w_hh = getattr(self.lstm, f'weight_hh_l{i}') - b_ih = getattr(self.lstm, f'bias_ih_l{i}') - b_hh = getattr(self.lstm, f'bias_hh_l{i}') - nn.init.orthogonal_(w_ih, 1.0) - nn.init.orthogonal_(w_hh, 1.0) - b_ih.data.zero_() - b_hh.data.zero_() - cell.weight_ih = w_ih - cell.weight_hh = w_hh - cell.bias_ih = b_ih - cell.bias_hh = b_hh - - def initial_state(self, batch_size, device): - h = torch.zeros(self.num_layers, batch_size, self.hidden_size, device=device) - c = torch.zeros(self.num_layers, batch_size, self.hidden_size, device=device) - return h, c - - def forward_eval(self, h, state): - assert state[0].shape[1] == state[1].shape[1] == h.shape[0] - lstm_h, lstm_c = state - for i in range(self.num_layers): - h, c = self.cell[i](h, (lstm_h[i], lstm_c[i])) - lstm_h[i] = h - lstm_c[i] = c - return h, (lstm_h, lstm_c) - - def forward_train(self, h): - # h: [B, T, H] - h = h.transpose(0, 1) - h, _ = self.lstm(h) - return h.transpose(0, 1) - -class GRU(nn.Module): - def __init__(self, hidden_size, num_layers=1, **kwargs): - super().__init__() - self.hidden_size = hidden_size - self.num_layers = num_layers - - self.gru = nn.GRU(hidden_size, hidden_size, num_layers=num_layers) - self.cell = nn.ModuleList([torch.nn.GRUCell(hidden_size, hidden_size) for _ in range(num_layers)]) - self.norm = torch.nn.RMSNorm(hidden_size) - - for i in range(num_layers): - cell = self.cell[i] - w_ih = getattr(self.gru, f'weight_ih_l{i}') - w_hh = getattr(self.gru, f'weight_hh_l{i}') - b_ih = getattr(self.gru, f'bias_ih_l{i}') - b_hh = getattr(self.gru, f'bias_hh_l{i}') - nn.init.orthogonal_(w_ih, 1.0) - nn.init.orthogonal_(w_hh, 1.0) - b_ih.data.zero_() - b_hh.data.zero_() - cell.weight_ih = w_ih - cell.weight_hh = w_hh - cell.bias_ih = b_ih - cell.bias_hh = b_hh - - def initial_state(self, batch_size, device): - h = torch.zeros(self.num_layers, batch_size, self.hidden_size, device=device) - return (h,) - - def forward_eval(self, h, state): - assert state[0].shape[1] == h.shape[0] - state = state[0] - for i in range(self.num_layers): - h_in = h - h = self.cell[i](h, state[i]) - state[i] = h - h = h + h_in - h = self.norm(h) - return h, (state,) - - def forward_train(self, h): - # h: [B, T, H] - h = h.transpose(0, 1) - h_in = h - h, _ = self.gru(h) - h = h + h_in - h = self.norm(h) - return h.transpose(0, 1) - -class NatureEncoder(nn.Module): - '''NatureCNN encoder (Mnih et al. 2015). Returns [batch, hidden_size].''' - def __init__(self, env, hidden_size=512, framestack=1, flat_size=64*7*7, - channels_last=False, downsample=1, **kwargs): - super().__init__() - self.channels_last = channels_last - self.downsample = downsample - self.network = nn.Sequential( - nn.Conv2d(framestack, 32, 8, stride=4), - nn.ReLU(), - nn.Conv2d(32, 64, 4, stride=2), - nn.ReLU(), - nn.Conv2d(64, 64, 3, stride=1), - nn.ReLU(), - nn.Flatten(), - nn.Linear(flat_size, hidden_size), - nn.ReLU(), - ) - - def forward(self, observations): - if self.channels_last: - observations = observations.permute(0, 3, 1, 2) - if self.downsample > 1: - observations = observations[:, :, ::self.downsample, ::self.downsample] - return self.network(observations.float() / 255.0) - -class ResidualBlock(nn.Module): - def __init__(self, channels): - super().__init__() - self.conv0 = nn.Conv2d(channels, channels, 3, padding=1) - self.conv1 = nn.Conv2d(channels, channels, 3, padding=1) - - def forward(self, x): - inputs = x - x = F.relu(x) - x = self.conv0(x) - x = F.relu(x) - x = self.conv1(x) - return x + inputs - -class ConvSequence(nn.Module): - def __init__(self, input_shape, out_channels): - super().__init__() - self._input_shape = input_shape - self._out_channels = out_channels - self.conv = nn.Conv2d(input_shape[0], out_channels, 3, padding=1) - self.res_block0 = ResidualBlock(out_channels) - self.res_block1 = ResidualBlock(out_channels) - - def forward(self, x): - x = self.conv(x) - x = F.max_pool2d(x, kernel_size=3, stride=2, padding=1) - x = self.res_block0(x) - x = self.res_block1(x) - return x - - def get_output_shape(self): - _c, h, w = self._input_shape - return (self._out_channels, (h + 1) // 2, (w + 1) // 2) - -class ImpalaEncoder(nn.Module): - '''IMPALA ResNet encoder (Espeholt et al. 2018). Returns [batch, hidden_size].''' - def __init__(self, env, hidden_size=256, cnn_width=16, **kwargs): - super().__init__() - h, w, c = env.single_observation_space.shape - shape = (c, h, w) - conv_seqs = [] - for out_channels in [cnn_width, 2*cnn_width, 2*cnn_width]: - conv_seq = ConvSequence(shape, out_channels) - shape = conv_seq.get_output_shape() - conv_seqs.append(conv_seq) - conv_seqs += [ - nn.Flatten(), - nn.ReLU(), - nn.Linear(shape[0] * shape[1] * shape[2], hidden_size), - nn.ReLU(), - ] - self.network = nn.Sequential(*conv_seqs) - - def forward(self, observations): - return self.network(observations.permute(0, 3, 1, 2).float() / 255.0) diff --git a/pufferlib/muon.py b/pufferlib/muon.py deleted file mode 100644 index bd56f2d1fb..0000000000 --- a/pufferlib/muon.py +++ /dev/null @@ -1,135 +0,0 @@ -"""Simple Muon optimizer numerically matched to Lukas's HeavyBall implementation.""" - -import torch -from torch import Tensor - -from torch.optim.optimizer import ( - _to_scalar, - Optimizer, - ParamsT, -) - -__all__ = ["Muon"] - -NS_COEFS = [ - (4.0848, -6.8946, 2.9270), - (3.9505, -6.3029, 2.6377), - (3.7418, -5.5913, 2.3037), - (2.8769, -3.1427, 1.2046), - (2.8366, -3.0525, 1.2012) -] - -def zeropower_via_newtonschulz5(G, eps=1e-7): - G = G.clone() - x = G - if G.size(-2) > G.size(-1): - x = x.mT - - x = x / torch.clamp(G.norm(dim=(-2, -1)), min=eps) - - for a, b, c in NS_COEFS: - s = x @ x.mT - y = c * s - y.diagonal(dim1=-2, dim2=-1).add_(b) - y = y @ s - y.diagonal(dim1=-2, dim2=-1).add_(a) - x = y @ x - - if G.size(-2) > G.size(-1): - x = x.mT - - return x.to(G.dtype) - -class Muon(Optimizer): - def __init__( - self, - params: ParamsT, - lr: float = 0.0025, - weight_decay: float = 0.0, - momentum: float = 0.9, - eps: float = 1e-8) -> None: - if isinstance(lr, Tensor) and lr.numel() != 1: - raise ValueError("Tensor lr must be 1-element") - if lr < 0.0: - raise ValueError(f"Learning rate should be >= 0 but is: {lr}") - if momentum < 0.0: - raise ValueError(f"momentum should be >= 0 but is: {momentum}") - if weight_decay < 0.0: - raise ValueError(f"weight decay should be >= 0 but is: {weight_decay}") - - defaults = { - "lr": lr, - "weight_decay": weight_decay, - "momentum": momentum, - "eps": eps, - } - super().__init__(params, defaults) - - def _init_group(self, group, params_with_grad, grads, muon_momentum_bufs): - for p in group["params"]: - if p.grad is None: - continue - - params_with_grad.append(p) - grads.append(p.grad) - - state = self.state[p] - - if "momentum_buffer" not in state: - state["momentum_buffer"] = torch.zeros_like( - p.grad, memory_format=torch.preserve_format - ) - muon_momentum_bufs.append(state["momentum_buffer"]) - - @torch.no_grad() - def step(self, closure=None): - """Performs a single optimization step.""" - loss = None - if closure is not None: - with torch.enable_grad(): - loss = closure() - - for group in self.param_groups: - lr = group["lr"] - weight_decay = group["weight_decay"] - momentum = group["momentum"] - eps = group["eps"] - - params_with_grad: list[Tensor] = [] - grads: list[Tensor] = [] - muon_momentum_bufs: list[Tensor] = [] - lr = _to_scalar(lr) - - for p in group["params"]: - if p.grad is None: - continue - - params_with_grad.append(p) - grads.append(p.grad) - - state = self.state[p] - - if "momentum_buffer" not in state: - state["momentum_buffer"] = torch.zeros_like( - p.grad, memory_format=torch.preserve_format - ) - muon_momentum_bufs.append(state["momentum_buffer"]) - - for i, param in enumerate(params_with_grad): - - grad = grads[i] - - buf = muon_momentum_bufs[i] - buf.mul_(momentum) - buf.add_(grad) - grad.add_(buf*momentum) - - if grad.ndim >= 2: - grad = grad.view(grad.shape[0], -1) - grad = zeropower_via_newtonschulz5(grad) # original has hardcoded steps and eps - grad *= max(1, grad.size(-2) / grad.size(-1)) ** 0.5 # Matches heavyball and Keller - - param.mul_(1 - lr * weight_decay) - param.sub_(lr*grad.view(param.shape)) - - return loss diff --git a/pufferlib/pufferl.py b/pufferlib/pufferl.py deleted file mode 100644 index 8d808fc9a7..0000000000 --- a/pufferlib/pufferl.py +++ /dev/null @@ -1,509 +0,0 @@ -## puffer [train | eval | sweep] [env_name] [optional args] -- See https://puffer.ai for full detail0 -# This is the same as python -m pufferlib.pufferl [train | eval | sweep] [env_name] [optional args] -# Distributed example: torchrun --standalone --nnodes=1 --nproc-per-node=6 -m pufferlib.pufferl train puffer_nmmo3 - -import warnings -warnings.filterwarnings('error', category=RuntimeWarning) - -import os -import sys -import glob -import json -import ast -import time -import argparse -import configparser -from collections import defaultdict -import multiprocessing as mp -from copy import deepcopy - -import numpy as np - -import torch -import pufferlib -try: - from pufferlib import _C -except ImportError: - raise ImportError('Failed to import PufferLib C++ backend. If you have non-default PyTorch, try installing with --no-build-isolation') - -import rich -import rich.traceback -from rich.table import Table -from rich_argparse import RichHelpFormatter -rich.traceback.install(show_locals=False) - -import signal # Aggressively exit on ctrl+c -signal.signal(signal.SIGINT, lambda sig, frame: os._exit(0)) - -def unroll_nested_dict(d): - if not isinstance(d, dict): - return d - - for k, v in d.items(): - if isinstance(v, dict): - for k2, v2 in unroll_nested_dict(v): - yield f"{k}/{k2}", v2 - else: - yield k, v - -def abbreviate(num, b2, c2): - prefixes = ['', 'K', 'M', 'B', 'T'] - for i, prefix in enumerate(prefixes): - if num < 1e3: break - num /= 1e3 - - return f'{b2}{num:.1f}{c2}{prefix}' - -def duration(seconds, b2, c2): - if seconds < 0: return f"{b2}0{c2}s" - if seconds < 1: return f"{b2}{seconds*1000:.0f}{c2}ms" - seconds = int(seconds) - d = f'{b2}{seconds // 86400}{c2}d ' - h = f'{b2}{(seconds // 3600) % 24}{c2}h ' - m = f'{b2}{(seconds // 60) % 60}{c2}m ' - s = f'{b2}{seconds % 60}{c2}s' - return d + h + m + s - -def fmt_perf(name, color, delta_ref, elapsed, b2, c2): - percent = 0 if delta_ref == 0 else int(100*elapsed/delta_ref - 1e-5) - return f'{color}{name}', duration(elapsed, b2, c2), f'{b2}{percent:2d}{c2}%' - -def print_dashboard(args, model_size, flat_logs, clear=False, idx=[0], - c1='[cyan]', c2='[white]', b1='[bright_cyan]', b2='[bright_white]'): - g = lambda k, d=0: flat_logs.get(k, d) - console = rich.console.Console() - dashboard = Table(box=rich.box.ROUNDED, expand=True, - show_header=False, border_style='bright_cyan') - table = Table(box=None, expand=True, show_header=False) - dashboard.add_row(table) - - table.add_column(justify="left", width=30) - table.add_column(justify="center", width=12) - table.add_column(justify="center", width=18) - table.add_column(justify="right", width=12) - - table.add_row( - f'{b1}PufferLib {b2}4.0 {idx[0]*" "}:blowfish:', - f'{c1}GPU: {b2}{g("util/gpu_percent"):.0f}{c2}%', - f'{c1}VRAM: {b2}{g("util/vram_used_gb"):.1f}{c2}/{b2}{g("util/vram_total_gb"):.0f}{c2}G', - f'{c1}RAM: {b2}{g("util/cpu_mem_gb"):.1f}{c2}G', - ) - idx[0] = (idx[0] - 1) % 10 - - s = Table(box=None, expand=True) - remaining = f'{b2}A hair past a freckle{c2}' - agent_steps = g('agent_steps') - if g('SPS') != 0: - remaining = duration((args['train']['total_timesteps']*args['train'].get('gpus', 1) - agent_steps)/g('SPS'), b2, c2) - - s.add_column(f"{c1}Summary", justify='left', vertical='top', width=10) - s.add_column(f"{c1}Value", justify='right', vertical='top', width=14) - s.add_row(f'{c2}Env', f'{b2}{args["env_name"]}') - s.add_row(f'{c2}Params', abbreviate(model_size, b2, c2)) - s.add_row(f'{c2}Steps', abbreviate(agent_steps, b2, c2)) - s.add_row(f'{c2}SPS', abbreviate(g('SPS'), b2, c2)) - s.add_row(f'{c2}Epoch', f'{b2}{g("epoch")}') - s.add_row(f'{c2}Uptime', duration(g('uptime'), b2, c2)) - s.add_row(f'{c2}Remaining', remaining) - - rollout = g('perf/rollout') - train = g('perf/train') - delta = rollout + train - p = Table(box=None, expand=True, show_header=False) - p.add_column(f"{c1}Performance", justify="left", width=10) - p.add_column(f"{c1}Time", justify="right", width=8) - p.add_column(f"{c1}%", justify="right", width=4) - p.add_row(*fmt_perf('Evaluate', b1, delta, rollout, b2, c2)) - p.add_row(*fmt_perf(' GPU', b2, delta, g('perf/eval_gpu'), b2, c2)) - p.add_row(*fmt_perf(' Env', b2, delta, g('perf/eval_env'), b2, c2)) - p.add_row(*fmt_perf('Train', b1, delta, train, b2, c2)) - p.add_row(*fmt_perf(' Misc', b2, delta, g('perf/train_misc'), b2, c2)) - p.add_row(*fmt_perf(' Forward', b2, delta, g('perf/train_forward'), b2, c2)) - - l = Table(box=None, expand=True) - l.add_column(f'{c1}Losses', justify="left", width=16) - l.add_column(f'{c1}Value', justify="right", width=8) - for k, v in flat_logs.items(): - if k.startswith('loss/'): - l.add_row(f'{b2}{k[5:]}', f'{b2}{v:.3f}') - - monitor = Table(box=None, expand=True, pad_edge=False) - monitor.add_row(s, p, l) - dashboard.add_row(monitor) - - table = Table(box=None, expand=True, pad_edge=False) - dashboard.add_row(table) - left = Table(box=None, expand=True) - right = Table(box=None, expand=True) - table.add_row(left, right) - left.add_column(f"{c1}User Stats", justify="left", width=20) - left.add_column(f"{c1}Value", justify="right", width=10) - right.add_column(f"{c1}User Stats", justify="left", width=20) - right.add_column(f"{c1}Value", justify="right", width=10) - - i = 0 - for k, v in flat_logs.items(): - if k.startswith('env/') and k != 'env/n': - u = left if i % 2 == 0 else right - u.add_row(f'{b2}{k[4:]}', f'{b2}{v:.3f}') - i += 1 - if i == 30: - break - - if clear: - console.clear() - - with console.capture() as capture: - console.print(dashboard) - - print('\033[0;0H' + capture.get()) - -def validate_config(args): - minibatch_size = args['train']['minibatch_size'] - horizon = args['train']['horizon'] - total_agents = args['vec']['total_agents'] - assert (minibatch_size % horizon) == 0, \ - f'minibatch_size {minibatch_size} must be divisible by horizon {horizon}' - assert minibatch_size <= horizon * total_agents, \ - f'minibatch_size {minibatch_size} > total_agents {total_agents} * horizon {horizon}' - -def _train_worker(args, backend=_C): - pufferl = backend.create_pufferl(args) - args.pop('nccl_id', None) - while pufferl.global_step < args['train']['total_timesteps']: - backend.rollouts(pufferl) - backend.train(pufferl) - - backend.close(pufferl) - -def _train(env_name, args, backend=_C, sweep_obj=None, result_queue=None, verbose=False): - '''Single-GPU training worker. Process target for both DDP ranks and sweep trials.''' - rank = args['rank'] - run_id = str(int(1000*time.time())) - if args['wandb']: - import wandb - run_id = wandb.util.generate_id() - wandb.init(id=run_id, config=args, - project=args['wandb_project'], group=args['wandb_group'], - tags=[args['tag']] if args['tag'] is not None else [], - settings=wandb.Settings(console="off"), - ) - - target_key = f'env/{args["sweep"]["metric"]}' - total_timesteps = args['train']['total_timesteps'] - all_logs = [] - - checkpoint_dir = os.path.join(args['checkpoint_dir'], args['env_name'], run_id) - os.makedirs(checkpoint_dir, exist_ok=True) - - log_dir = os.path.join(args['log_dir'], args['env_name']) - os.makedirs(log_dir, exist_ok=True) - - try: - pufferl = backend.create_pufferl(args) - except RuntimeError as e: - print(f'WARNING: {e}, skipping') - if result_queue is not None: - result_queue.put((args['gpu_id'], None, None, None)) - return - - args.pop('nccl_id', None) - model_size = pufferl.num_params() - if verbose: - flat_logs = dict(unroll_nested_dict(backend.log(pufferl))) - print_dashboard(args, model_size, flat_logs, clear=True) - - model_path = '' - flat_logs = {} - train_epochs = int(total_timesteps // (args['vec']['total_agents'] * args['train']['horizon'])) - eval_epochs = train_epochs // 2 - for epoch in range(train_epochs + eval_epochs): - backend.rollouts(pufferl) - - if epoch < train_epochs: - backend.train(pufferl) - - if (epoch % args['checkpoint_interval'] == 0 or epoch == train_epochs - 1) and sweep_obj is None: - model_path = os.path.join(checkpoint_dir, f'{pufferl.global_step:16d}.bin') - backend.save_weights(pufferl, model_path) - - # Rate limit, but always log for eval to maintain determinism - if time.time() < pufferl.last_log_time + 0.6 and epoch < train_epochs - 1: - continue - - logs = backend.eval_log(pufferl) if epoch >= train_epochs else backend.log(pufferl) - flat_logs = {**flat_logs, **dict(unroll_nested_dict(logs))} - - if verbose: - print_dashboard(args, model_size, flat_logs) - - if target_key not in flat_logs: - continue - - if args['wandb']: - wandb.log(flat_logs, step=flat_logs['agent_steps']) - - if epoch < train_epochs: - all_logs.append(flat_logs) - - if (sweep_obj is not None - and pufferl.global_step > min(0.20*total_timesteps, 100_000_000) and - sweep_obj.early_stop(logs, target_key)): - break - elif flat_logs['env/n'] > args['eval_episodes']: - break - - - print_dashboard(args, model_size, flat_logs) - backend.close(pufferl) - - if target_key not in flat_logs: - if result_queue is not None: - result_queue.put((args['gpu_id'], None, None, None)) - return - - # This version has the training perf logs and eval env logs - all_logs.append(flat_logs) - - # Downsample results - n = args['sweep']['downsample'] - metrics = {k: [[]] for k in all_logs[0]} - logged_timesteps = all_logs[-1]['agent_steps'] - next_bin = logged_timesteps / (n - 1) if n > 1 else np.inf - for log in all_logs: - for k, v in log.items(): - metrics[k][-1].append(v) - - if log['agent_steps'] < next_bin: - continue - - next_bin += logged_timesteps / (n - 1) - for k in metrics: - metrics[k][-1] = np.mean(metrics[k][-1]) - metrics[k].append([]) - - for k in metrics: - metrics[k][-1] = all_logs[-1][k] - - # Save own log: config + downsampled results - log_dir = os.path.join(args['log_dir'], args['env_name']) - os.makedirs(log_dir, exist_ok=True) - with open(os.path.join(log_dir, run_id + '.json'), 'w') as f: - json.dump({**args, 'metrics': metrics}, f) - - if args['wandb']: - if sweep_obj is None and model_path: # Don't spam uploads during sweeps - artifact = wandb.Artifact(run_id, type='model') - artifact.add_file(model_path) - wandb.run.log_artifact(artifact) - - wandb.run.finish() - - if result_queue is not None: - result_queue.put((args['gpu_id'], metrics['env/score'], metrics['uptime'], metrics['agent_steps'])) - -def train(env_name, args=None, gpus=None, backend=_C, **kwargs): - args = args or load_config(env_name) - validate_config(args) - - subprocess = gpus is not None - gpus = list(gpus or range(args['train']['gpus'])) - args['train']['total_timesteps'] //= len(gpus) - args['world_size'] = len(gpus) - args['nccl_id'] = _C.get_nccl_id() if len(gpus) > 1 else b'' - - if not subprocess: - gpus = gpus[-1:] + gpus[:-1] # Main process gets rank 0 - - ctx = mp.get_context('spawn') - for rank, gpu_id in reversed(list(enumerate(gpus))): - worker_args = deepcopy(args) - worker_args['rank'] = rank - worker_args['gpu_id'] = gpu_id - if rank == 0 and not subprocess: - _train(env_name, worker_args, backend=backend, verbose=True) - else: - ctx.Process(target=_train, args=(env_name, worker_args), - kwargs={**kwargs, 'backend': backend}).start() - -def sweep(env_name, args=None, pareto=False, backend=_C): - '''Train entry point. Handles single-GPU, multi-GPU DDP, and sweeps.''' - args = args or load_config(env_name) - exp_gpus = args['train']['gpus'] - sweep_gpus = args['sweep']['gpus'] or len(os.listdir('/proc/driver/nvidia/gpus')) - args['vec']['num_threads'] //= (sweep_gpus // exp_gpus) - args['no_model_upload'] = True - - sweep_config = args['sweep'] - method = sweep_config.pop('method') - import pufferlib.sweep - try: - sweep_cls = getattr(pufferlib.sweep, method) - except: - raise ValueError(f'Invalid sweep method {method}. See pufferlib.sweep') - - sweep_obj = sweep_cls(sweep_config) - num_experiments = args['sweep']['max_runs'] - ts_config = sweep_config['train']['total_timesteps'] - all_timesteps = np.geomspace(ts_config['min'], ts_config['max'], sweep_gpus) - result_queue = mp.get_context('spawn').Queue() - - active = {} - completed = 0 - while completed < num_experiments: - if len(active) >= sweep_gpus//exp_gpus: # Collect completed runs - gpu_id, scores, costs, timesteps = result_queue.get() - done_args = active.pop(gpu_id) - - if not scores: - sweep_obj.observe(done_args, 0, 0, is_failure=True) - else: - completed += 1 - - for s, c, t in zip(scores, costs, timesteps): - done_args['train']['total_timesteps'] = t - sweep_obj.observe(done_args, s, c, is_failure=False) - - idx = completed + len(active) - if idx >= num_experiments: - break # All experiments launched - - # TODO: only 1 per sweep etc - gpu_id = next(i for i in range(sweep_gpus) if i not in active) - timestep_total = all_timesteps[gpu_id] if pareto else None - if idx > 1: # First experiment uses defaults - sweep_obj.suggest(args, fixed_total_timesteps=timestep_total) - - try: - validate_config(args) - except (AssertionError, ValueError) as e: - print(f'WARNING: {e}, skipping') - sweep_obj.observe(args, 0, 0, is_failure=True) - continue - - exp_args = deepcopy(args) - active[gpu_id] = exp_args - train(env_name, exp_args, range(gpu_id, gpu_id + exp_gpus), - backend=backend, sweep_obj=sweep_obj, result_queue=result_queue) - -def eval(env_name, args=None, load_path=None): - '''Evaluate a trained policy using the native pipeline. - Creates a full PuffeRL instance, optionally loads weights, then - runs rollouts in a loop with rendering on env 0.''' - args = args or load_config(env_name) - - pufferl_cpp = _C.create_pufferl(args) - - # Resolve load path - load_path = load_path or args.get('load_model_path') - if load_path == 'latest': - checkpoint_dir = args['checkpoint_dir'] - pattern = os.path.join(checkpoint_dir, args['env_name'], '**', '*.bin') - candidates = glob.glob(pattern, recursive=True) - if not candidates: - raise FileNotFoundError(f'No .bin checkpoints found in {checkpoint_dir}/{args["env_name"]}/') - load_path = max(candidates, key=os.path.getctime) - - if load_path is not None: - _C.load_weights(pufferl_cpp, load_path) - print(f'Loaded weights from {load_path}') - - while True: - _C.render(pufferl_cpp, 0) - _C.rollouts(pufferl_cpp) - - _C.close(pufferl_cpp) - -def load_config(env_name): - parser = argparse.ArgumentParser(formatter_class=RichHelpFormatter, add_help=False) - parser.add_argument('--load-model-path', type=str, default=None, - help='Path to a pretrained checkpoint') - parser.add_argument('--load-id', type=str, - default=None, help='Kickstart/eval from from a finished Wandbrun') - parser.add_argument('--render-mode', type=str, default='auto', - choices=['auto', 'human', 'ansi', 'rgb_array', 'raylib', 'None']) - parser.add_argument('--wandb', action='store_true', help='Use wandb for logging') - parser.add_argument('--wandb-project', type=str, default='puffer4') - parser.add_argument('--wandb-group', type=str, default='debug') - parser.add_argument('--tag', type=str, default=None, help='Tag for experiment') - parser.add_argument('--slowly', action='store_true', help='Use PyTorch training backend') - parser.add_argument('--save-frames', type=int, default=0) - parser.add_argument('--gif-path', type=str, default='eval.gif') - parser.add_argument('--fps', type=float, default=15) - parser.description = f':blowfish: PufferLib [bright_cyan]{pufferlib.__version__}[/]' \ - ' demo options. Shows valid args for your env and policy' - - repo_dir = os.path.dirname(os.path.dirname(os.path.realpath(__file__))) - puffer_config_dir = os.path.join(repo_dir, 'config/**/*.ini') - puffer_default_config = os.path.join(repo_dir, 'config/default.ini') - #CC: Remove the default. Just raise an error on "puffer train" etc with no env (think we already do) - if env_name == 'default': - p = configparser.ConfigParser() - p.read(puffer_default_config) - else: - for path in glob.glob(puffer_config_dir, recursive=True): - p = configparser.ConfigParser() - p.read([puffer_default_config, path]) - if env_name in p['base']['env_name'].split(): break - else: - raise ValueError('No config for env_name {}'.format(env_name)) - - for section in p.sections(): - for key in p[section]: - try: - value = ast.literal_eval(p[section][key]) - except: - value = p[section][key] - - #TODO: Can clean up with default sections in 3.13+ - fmt = f'--{key}' if section == 'base' else f'--{section}.{key}' - dtype = type(value) - parser.add_argument( - fmt.replace('_', '-'), default=value, - type=lambda v, t=dtype: v if v == 'auto' else t(v), - ) - - parser.add_argument('-h', '--help', default=argparse.SUPPRESS, - action='help', help='Show this help message and exit') - - # Unpack to nested dict - parsed = vars(parser.parse_args()) - args = defaultdict(dict) - for key, value in parsed.items(): - next = args - for subkey in key.split('.'): - prev = next - next = next.setdefault(subkey, {}) - - prev[subkey] = value - - args['env_name'] = env_name - args['train']['use_rnn'] = args['rnn_name'] is not None - return dict(args) - -def main(): - err = 'Usage: puffer [train, eval, sweep, paretosweep] [env_name] [optional args]. --help for more info' - if len(sys.argv) < 3: - raise ValueError(err) - - mode = sys.argv.pop(1) - env_name = sys.argv.pop(1) - args = load_config(env_name) - - backend = _C - if args.get('slowly'): - from pufferlib.torch_pufferl import PuffeRL - backend = PuffeRL - - if 'train' in mode: - train(env_name=env_name, args=args, backend=backend) - elif 'eval' in mode: - eval(env_name=env_name, args=args) - elif 'sweep' in mode: - sweep(env_name=env_name, args=args, pareto='pareto' in mode, backend=backend) - else: - raise ValueError(err) - -if __name__ == '__main__': - main() - diff --git a/pufferlib/sweep.py b/pufferlib/sweep.py deleted file mode 100644 index f369bc0ca2..0000000000 --- a/pufferlib/sweep.py +++ /dev/null @@ -1,954 +0,0 @@ -import random -import math -import warnings -from collections import deque -from copy import deepcopy -from contextlib import contextmanager - -import numpy as np -import pufferlib - -import torch -import gpytorch -from gpytorch.models import ExactGP -from gpytorch.likelihoods import GaussianLikelihood -from gpytorch.kernels import MaternKernel, PolynomialKernel, ScaleKernel, AdditiveKernel -from gpytorch.means import ConstantMean -from gpytorch.mlls import ExactMarginalLogLikelihood -from gpytorch.priors import LogNormalPrior -from scipy.optimize import minimize -from scipy.stats.qmc import Sobol -from scipy.spatial import KDTree -from sklearn.linear_model import LogisticRegression - -EPSILON = 1e-6 - -@contextmanager -def default_tensor_dtype(dtype): - old_dtype = torch.get_default_dtype() - try: - torch.set_default_dtype(dtype) - yield - finally: - torch.set_default_dtype(old_dtype) - -class Space: - def __init__(self, min, max, scale, is_integer=False): - self.min = min - self.max = max - self.scale = scale - self.norm_min = self.normalize(min) - self.norm_max = self.normalize(max) - # Since min/max are normalized from -1 to 1, just use 0 as a mean - self.norm_mean = 0 - self.is_integer = is_integer - -class Linear(Space): - def __init__(self, min, max, scale, is_integer=False): - if scale == 'auto': - scale = 0.5 - - super().__init__(min, max, scale, is_integer) - - def normalize(self, value): - #assert isinstance(value, (int, float)) - zero_one = (value - self.min)/(self.max - self.min) - return 2*zero_one - 1 - - def unnormalize(self, value): - zero_one = (value + 1)/2 - value = zero_one * (self.max - self.min) + self.min - if self.is_integer: - value = round(value) - return value - -class Pow2(Space): - def __init__(self, min, max, scale, is_integer=False): - if scale == 'auto': - scale = 0.5 - #scale = 2 / (np.log2(max) - np.log2(min)) - - super().__init__(min, max, scale, is_integer) - - def normalize(self, value): - #assert isinstance(value, (int, float)) - #assert value != 0.0 - zero_one = (math.log(value, 2) - math.log(self.min, 2))/(math.log(self.max, 2) - math.log(self.min, 2)) - return 2*zero_one - 1 - - def unnormalize(self, value): - zero_one = (value + 1)/2 - log_spaced = zero_one*(math.log(self.max, 2) - math.log(self.min, 2)) + math.log(self.min, 2) - rounded = round(log_spaced) - return 2 ** rounded - -class Log(Space): - base: int = 10 - - def __init__(self, min, max, scale, is_integer=False): - if scale == 'time': - # TODO: Set scaling param intuitively based on number of jumps from min to max - scale = 1 / (np.log2(max) - np.log2(min)) - elif scale == 'auto': - scale = 0.5 - - super().__init__(min, max, scale, is_integer) - - def normalize(self, value): - #assert isinstance(value, (int, float)) - #assert value != 0.0 - zero_one = (math.log(value, self.base) - math.log(self.min, self.base))/(math.log(self.max, self.base) - math.log(self.min, self.base)) - return 2*zero_one - 1 - - def unnormalize(self, value): - zero_one = (value + 1)/2 - log_spaced = zero_one*(math.log(self.max, self.base) - math.log(self.min, self.base)) + math.log(self.min, self.base) - value = self.base ** log_spaced - if self.is_integer: - value = round(value) - return value - -class Logit(Space): - base: int = 10 - - def __init__(self, min, max, scale, is_integer=False): - if scale == 'auto': - scale = 0.5 - - super().__init__(min, max, scale, is_integer) - - def normalize(self, value): - value = max(self.min, min(value, self.max)) - zero_one = (math.log(1-value, self.base) - math.log(1-self.min, self.base))/(math.log(1-self.max, self.base) - math.log(1-self.min, self.base)) - return 2*zero_one - 1 - - def unnormalize(self, value): - zero_one = (value + 1)/2 - log_spaced = zero_one*(math.log(1-self.max, self.base) - math.log(1-self.min, self.base)) + math.log(1-self.min, self.base) - return 1 - self.base**log_spaced - -def _params_from_puffer_sweep(sweep_config, only_include=None): - param_spaces = {} - - if 'sweep_only' in sweep_config: - only_include = [p.strip() for p in sweep_config['sweep_only'].split(',')] - - for name, param in sweep_config.items(): - if name in ('method', 'metric', 'metric_distribution', 'goal', 'downsample', 'use_gpu', 'prune_pareto', - 'sweep_only', 'max_suggestion_cost', 'early_stop_quantile', 'gpus', 'max_runs'): - continue - - assert isinstance(param, dict), f'Param {name} is not a dict' - if any(isinstance(param[k], dict) for k in param): - param_spaces[name] = _params_from_puffer_sweep(param, only_include) - continue - - if only_include and not any(k in name for k in only_include): - continue - - assert 'distribution' in param - distribution = param['distribution'] - kwargs = dict( - min=param['min'], - max=param['max'], - scale=param['scale'], - ) - if distribution == 'uniform': - space = Linear(**kwargs) - elif distribution == 'int_uniform': - space = Linear(**kwargs, is_integer=True) - elif distribution == 'uniform_pow2': - space = Pow2(**kwargs, is_integer=True) - elif distribution == 'log_normal': - space = Log(**kwargs) - elif distribution == 'logit_normal': - space = Logit(**kwargs) - else: - raise ValueError(f'Invalid distribution: {distribution}') - - param_spaces[name] = space - - return param_spaces - -class Hyperparameters: - def __init__(self, config, verbose=True): - self.spaces = _params_from_puffer_sweep(config) - self.flat_spaces = dict(pufferlib.unroll_nested_dict(self.spaces)) - self.num = len(self.flat_spaces) - - self.metric = config['metric'] - goal = config['goal'] - assert goal in ('maximize', 'minimize') - self.optimize_direction = 1 if goal == 'maximize' else -1 - - self.search_centers = np.array([ - e.norm_mean for e in self.flat_spaces.values()]) - self.min_bounds = np.array([ - e.norm_min for e in self.flat_spaces.values()]) - self.max_bounds = np.array([ - e.norm_max for e in self.flat_spaces.values()]) - self.search_scales = np.array([ - e.scale for e in self.flat_spaces.values()]) - - if verbose: - print('Min random sample:') - for name, space in self.flat_spaces.items(): - print(f'\t{name}: {space.unnormalize(max(space.norm_mean - space.scale, space.norm_min))}') - - print('Max random sample:') - for name, space in self.flat_spaces.items(): - print(f'\t{name}: {space.unnormalize(min(space.norm_mean + space.scale, space.norm_max))}') - - def sample(self, n, mu=None, scale=1): - if mu is None: - mu = self.search_centers - - if len(mu.shape) == 1: - mu = mu[None, :] - - n_input, n_dim = mu.shape - scale = scale * self.search_scales - mu_idxs = np.random.randint(0, n_input, n) - samples = scale*(2*np.random.rand(n, n_dim) - 1) + mu[mu_idxs] - return np.clip(samples, self.min_bounds, self.max_bounds) - - def from_dict(self, params): - flat_params = dict(pufferlib.unroll_nested_dict(params)) - values = [] - for key, space in self.flat_spaces.items(): - assert key in flat_params, f'Missing hyperparameter {key}' - val = flat_params[key] - normed = space.normalize(val) - values.append(normed) - - return np.array(values) - - def to_dict(self, sample, fill=None): - params = deepcopy(self.spaces) if fill is None else fill - self._fill(params, self.spaces, sample) - return params - - def _fill(self, params, spaces, flat_sample, idx=0): - for name, space in spaces.items(): - if isinstance(space, dict): - idx = self._fill(params[name], spaces[name], flat_sample, idx=idx) - else: - params[name] = spaces[name].unnormalize(flat_sample[idx]) - idx += 1 - - return idx - - def get_flat_idx(self, flat_key): - keys = list(self.flat_spaces.keys()) - return keys.index(flat_key) if flat_key in keys else None - -def pareto_points(observations): - if not observations: - return [], [] - - scores = np.array([e['output'] for e in observations]) - costs = np.array([e['cost'] for e in observations]) - - # Sort by cost to find non-dominated points efficiently - sorted_indices = np.argsort(costs) - - pareto = [] - pareto_idxs = [] - max_score_so_far = -np.inf - - for idx in sorted_indices: - if scores[idx] > max_score_so_far + EPSILON: - pareto.append(observations[idx]) - pareto_idxs.append(idx) - max_score_so_far = scores[idx] - - return pareto, pareto_idxs - -def prune_pareto_front(pareto, efficiency_threshold=0.5, pruning_stop_score_fraction=0.98): - # Prune the high-cost long tail of a pareto front - # like (score 0.99, cost 100), (score 0.991, cost 200) - if not pareto or len(pareto) < 2: - return pareto - - sorted_pareto = sorted(pareto, key=lambda x: x['cost']) - scores = np.array([e['output'] for e in sorted_pareto]) - costs = np.array([e['cost'] for e in sorted_pareto]) - score_range = max(scores.max() - scores.min(), EPSILON) - cost_range = max(costs.max() - costs.min(), EPSILON) - - max_pareto_score = scores[-1] if scores.size > 0 else -np.inf - - for i in range(len(sorted_pareto) - 1, 1, -1): - if scores[i-1] < pruning_stop_score_fraction * max_pareto_score: - break - - norm_score_gain = (scores[i] - scores[i-1]) / score_range - norm_cost_increase = (costs[i] - costs[i-1]) / cost_range - efficiency = norm_score_gain / (norm_cost_increase + EPSILON) - - if efficiency < efficiency_threshold: - sorted_pareto.pop(i) - else: - # Stop pruning if we find an efficient point - break - - return sorted_pareto - - -class Random: - def __init__(self, - sweep_config, - global_search_scale = 1, - random_suggestions = 1024, - ): - - self.hyperparameters = Hyperparameters(sweep_config) - self.global_search_scale = global_search_scale - self.random_suggestions = random_suggestions - self.success_observations = [] - - def suggest(self, fill=None, fixed_total_timesteps=None): - suggestions = self.hyperparameters.sample(self.random_suggestions) - self.suggestion = random.choice(suggestions) - return self.hyperparameters.to_dict(self.suggestion, fill), {} - - def observe(self, hypers, score, cost, is_failure=False): - params = self.hyperparameters.from_dict(hypers) - self.success_observations.append(dict( - input=hypers, - output=score, - cost=cost, - is_failure=is_failure, - )) - - def early_stop(self, logs, target_key): - if any("loss/" in k and np.isnan(v) for k, v in logs.items()): - logs['is_loss_nan'] = True - return True - return False - - -class ParetoGenetic: - def __init__(self, - sweep_config, - global_search_scale = 1, - suggestions_per_pareto = 1, - bias_cost = True, - log_bias = False, - ): - - self.hyperparameters = Hyperparameters(sweep_config) - self.global_search_scale = global_search_scale - self.suggestions_per_pareto = suggestions_per_pareto - self.bias_cost = bias_cost - self.log_bias = log_bias - self.success_observations = [] - - def suggest(self, fill=None, fixed_total_timesteps=None): - if len(self.success_observations) == 0: - suggestion = self.hyperparameters.search_centers - return self.hyperparameters.to_dict(suggestion, fill), {} - - candidates, _ = pareto_points(self.success_observations) - pareto_costs = np.array([e['cost'] for e in candidates]) - - if self.bias_cost: - if self.log_bias: - cost_dists = np.abs(np.log(pareto_costs[:, None]) - np.log(pareto_costs[None, :])) - else: - cost_dists = np.abs(pareto_costs[:, None] - pareto_costs[None, :]) - - cost_dists += (np.max(pareto_costs) + 1)*np.eye(len(pareto_costs)) # mask self-distance - idx = np.argmax(np.min(cost_dists, axis=1)) - search_centers = candidates[idx]['input'] - else: - search_centers = np.stack([e['input'] for e in candidates]) - - suggestions = self.hyperparameters.sample( - len(candidates)*self.suggestions_per_pareto, mu=search_centers) - suggestion = suggestions[np.random.randint(0, len(suggestions))] - return self.hyperparameters.to_dict(suggestion, fill), {} - - def observe(self, hypers, score, cost, is_failure=False): - params = self.hyperparameters.from_dict(hypers) - self.success_observations.append(dict( - input=params, - output=score, - cost=cost, - is_failure=is_failure, - )) - - def early_stop(self, logs, target_key): - if any("loss/" in k and np.isnan(v) for k, v in logs.items()): - logs['is_loss_nan'] = True - return True - return False - - -class ExactGPModel(ExactGP): - def __init__(self, train_x, train_y, likelihood, x_dim): - super(ExactGPModel, self).__init__(train_x, train_y, likelihood) - self.mean_module = ConstantMean() - # Matern 3/2 kernel (equivalent to Pyro's Matern32) - matern_kernel = MaternKernel(nu=1.5, ard_num_dims=x_dim) - - # NOTE: setting this constraint changes GP behavior, including the lengthscale - # even though the lengthscale is well within the range ... Commenting out for now. - # lengthscale_constraint = gpytorch.constraints.Interval(0.01, 10.0) - # matern_kernel = MaternKernel(nu=1.5, ard_num_dims=x_dim, lengthscale_constraint=lengthscale_constraint) - - linear_kernel = PolynomialKernel(power=1) - self.covar_module = ScaleKernel(AdditiveKernel(linear_kernel, matern_kernel)) - - def forward(self, x): - mean_x = self.mean_module(x) - covar_x = self.covar_module(x) - return gpytorch.distributions.MultivariateNormal(mean_x, covar_x) - - @property - def lengthscale_range(self): - # Get lengthscale from MaternKernel - lengthscale = self.covar_module.base_kernel.kernels[1].lengthscale.tolist()[0] - return min(lengthscale), max(lengthscale) - -def train_gp_model(model, likelihood, mll, optimizer, train_x, train_y, training_iter=50): - model.train() - likelihood.train() - model.set_train_data(inputs=train_x, targets=train_y, strict=False) - - loss = None - for _ in range(training_iter): - try: - optimizer.zero_grad() - output = model(train_x) - loss = -mll(output, train_y) - loss.backward() - optimizer.step() - loss = loss.detach() - - except gpytorch.utils.errors.NotPSDError: - # It's rare but it does happen. Hope it's a transient issue. - break - - model.eval() - likelihood.eval() - return loss.item() if loss is not None else 0 - - -class RobustLogCostModel: - """ - Fits Score ~ A + B * log(Cost) using Quantile Regression (Median) - and provides a cost-only threshold for early stopping. - """ - def __init__(self, quantile=0.3, min_num_samples=30): - self.quantile = quantile # 0.5 = Median regression - self.min_num_samples = min_num_samples - self.is_fitted = False - self.A = None - self.B = None - self.max_score = None - self.max_cost = None - self.upper_cost_threshold = None - - def _quantile_loss(self, params, x, y, q): - # Pinball loss function for quantile regression - a, b = params - y_pred = a + b * x - residuals = y - y_pred - return np.sum(np.maximum(q * residuals, (q - 1) * residuals)) - - def fit(self, observations, upper_cost_threshold=None): - self.is_fitted = False - scores = np.array([e['output'] for e in observations]) - costs = np.array([e['cost'] for e in observations]) - self.max_score = scores.max() - self.upper_cost_threshold = upper_cost_threshold or costs.max() - - valid_indices = (costs > EPSILON) & np.isfinite(scores) - if np.sum(valid_indices) < self.min_num_samples: - return - - y = scores[valid_indices] - c = costs[valid_indices] - x_log_c = np.log(c) - - # Initial guess using standard Polyfit (OLS) just to get in the ballpark - try: - b_init, a_init = np.polyfit(x_log_c, y, 1) - except np.linalg.LinAlgError: - # Fallback guess - b_init, a_init = 0.0, np.mean(y) - - # Minimize the Quantile Loss (L1 for median) - res = minimize( - self._quantile_loss, - x0=[a_init, b_init], - args=(x_log_c, y, self.quantile), - method='Nelder-Mead', # Robust solver for non-differentiable functions - bounds=[(None, None), (0, None)] # B should be positive - ) - - self.A, self.B = res.x - self.is_fitted = True - - def get_threshold(self, cost, min_cost_fraction=0.3, abs_min_cost=10): - if not self.is_fitted or self.upper_cost_threshold is None: - return -np.inf - - # NOTE: min_allowed_cost seems vary a lot from env to env, so dynamically set here - min_allowed_cost = self.upper_cost_threshold * min_cost_fraction + abs_min_cost - if cost < min_allowed_cost: - return -np.inf - - # Stop long long train runs that don't do very well enough - if cost > 1.2 * self.upper_cost_threshold: - return 0.9 * self.max_score - - return self.A + self.B * np.log(cost) - - -# TODO: Eval defaults -class Protein: - def __init__(self, - sweep_config, - max_suggestion_cost = 3600, - resample_frequency = 0, - num_random_samples = 10, - num_keep_top_obs = 5, - global_search_scale = 1, - suggestions_per_pareto = 256, - expansion_rate = 0.1, - gp_training_iter = 50, - gp_learning_rate = 0.001, - gp_max_obs = 750, # gp train time jumps after 800 - infer_batch_size = 4096, - optimizer_reset_frequency = 50, - use_gpu = True, - cost_param = "train/total_timesteps", - prune_pareto = True, - ): - # Process sweep config. NOTE: sweep_config takes precedence. It's not good. - _use_gpu = sweep_config['use_gpu'] if 'use_gpu' in sweep_config else use_gpu - _prune_pareto = sweep_config['prune_pareto'] if 'prune_pareto' in sweep_config else prune_pareto - _max_suggestion_cost = sweep_config['max_suggestion_cost'] if 'max_suggestion_cost' in sweep_config else max_suggestion_cost - - self.device = torch.device("cuda:0" if _use_gpu and torch.cuda.is_available() else "cpu") - self.hyperparameters = Hyperparameters(sweep_config) - self.metric_distribution = sweep_config['metric_distribution'] - self.global_search_scale = global_search_scale - self.suggestions_per_pareto = suggestions_per_pareto - self.resample_frequency = resample_frequency - self.max_suggestion_cost = _max_suggestion_cost - self.expansion_rate = expansion_rate - self.gp_training_iter = gp_training_iter - self.gp_learning_rate = gp_learning_rate - self.optimizer_reset_frequency = optimizer_reset_frequency - self.prune_pareto = _prune_pareto - - self.success_observations = [] - self.failure_observations = [] - self.num_keep_top_obs = num_keep_top_obs - self.top_observations = [] - - self.suggestion_idx = 0 - self.min_score, self.max_score = math.inf, -math.inf - self.log_c_min, self.log_c_max = math.inf, -math.inf - - # Use Sobel seq for structured random exploration - self.sobol = Sobol(d=self.hyperparameters.num, scramble=True) - self.num_random_samples = num_random_samples - # NOTE: test if sobol sampling really helps - # points_per_run = sweep_config['downsample'] - # self.num_random_samples = 3 * points_per_run * self.hyperparameters.num - - self.cost_param_idx = self.hyperparameters.get_flat_idx(cost_param) - self.cost_space = None - self.cost_random_suggestion = None - if self.cost_param_idx is not None: - self.cost_space = list(self.hyperparameters.flat_spaces.values())[self.cost_param_idx] - self.cost_random_suggestion = -0.8 # In norm cost space. Make arg if necessary - self.target_cost_ratio = [] - self._running_target_buffer = deque(maxlen=30) - - self.gp_max_obs = gp_max_obs # train time bumps after 800? - self.infer_batch_size = infer_batch_size - - # Probably useful only when downsample=1 and each run is expensive. - self.use_success_prob = sweep_config['downsample'] == 1 - self.success_classifier = LogisticRegression(class_weight='balanced') - - # This model is conservative. Aggressive early stopping interferes with and hampers GP model learning. - self.stop_threshold_model = RobustLogCostModel(quantile=sweep_config['early_stop_quantile']) - self.upper_cost_threshold = -np.inf - - # Use 64 bit for GP regression - with default_tensor_dtype(torch.float64): - # Params taken from HEBO: https://arxiv.org/abs/2012.03826 - noise_prior = LogNormalPrior(math.log(1e-2), 0.5) - - # Create dummy data for model initialization on the selected device - dummy_x = torch.ones((1, self.hyperparameters.num), device=self.device) - dummy_y = torch.zeros(1, device=self.device) - # Score GP - self.likelihood_score = GaussianLikelihood(noise_prior=deepcopy(noise_prior)).to(self.device) - self.gp_score = ExactGPModel(dummy_x, dummy_y, self.likelihood_score, self.hyperparameters.num).to(self.device) - self.mll_score = ExactMarginalLogLikelihood(self.likelihood_score, self.gp_score).to(self.device) - self.score_opt = torch.optim.Adam(self.gp_score.parameters(), lr=self.gp_learning_rate, amsgrad=True) - - # Cost GP - self.likelihood_cost = GaussianLikelihood(noise_prior=deepcopy(noise_prior)).to(self.device) - self.gp_cost = ExactGPModel(dummy_x, dummy_y, self.likelihood_cost, self.hyperparameters.num).to(self.device) - self.mll_cost = ExactMarginalLogLikelihood(self.likelihood_cost, self.gp_cost).to(self.device) - self.cost_opt = torch.optim.Adam(self.gp_cost.parameters(), lr=self.gp_learning_rate, amsgrad=True) - - # Buffers for GP training and inference - self.gp_params_buffer = torch.empty(self.gp_max_obs, self.hyperparameters.num, device=self.device) - self.gp_score_buffer = torch.empty(self.gp_max_obs, device=self.device) - self.gp_cost_buffer = torch.empty(self.gp_max_obs, device=self.device) - self.infer_batch_buffer = torch.empty(self.infer_batch_size, self.hyperparameters.num, device=self.device) - - def _filter_near_duplicates(self, inputs, duplicate_threshold=EPSILON): - if len(inputs) < 2: - return np.arange(len(inputs)) - - tree = KDTree(inputs) - to_keep = np.ones(len(inputs), dtype=bool) - # Iterate from most recent to oldest - for i in range(len(inputs) - 1, -1, -1): - if to_keep[i]: - nearby_indices = tree.query_ball_point(inputs[i], r=duplicate_threshold) - # Exclude the point itself from being marked for removal - nearby_indices.remove(i) - if nearby_indices: - to_keep[nearby_indices] = False - - return np.where(to_keep)[0] - - def _sample_observations(self, max_size=None, recent_ratio=0.5): - if not self.success_observations: - return [] - - observations = self.success_observations.copy() - - # Update the stats using the full data - y = np.array([e['output'] for e in observations]) - self.min_score, self.max_score = y.min(), y.max() - - c = np.array([e['cost'] for e in observations]) - log_c = np.log(np.maximum(c, EPSILON)) - self.log_c_min = log_c.min() - self.log_c_max = np.quantile(log_c, 0.97) # Make it less sensitive to outlier points - - # When the data is scare, also use failed observations - if len(observations) < 100 and self.failure_observations: - # Give the min score for the failed obs, so this value will keep changing. - for e in self.failure_observations: - e['output'] = self.min_score - - # NOTE: the order of obs matters since recent obs are always fed into gp training - # So, putting the failure obs first. - observations = self.failure_observations + observations - - params = np.array([np.append(e['input'], [e['output'], e['cost']]) for e in observations]) - dedup_indices = self._filter_near_duplicates(params) - observations = [observations[i] for i in dedup_indices] - - if max_size is None: - max_size = self.gp_max_obs - - if len(observations) <= max_size: - return observations - - recent_size = int(recent_ratio*max_size) - recent_obs = observations[-recent_size:] - older_obs = observations[:-recent_size] - num_to_sample = max_size - recent_size - random_sample_obs = random.sample(older_obs, num_to_sample) - - return random_sample_obs + recent_obs - - def _train_gp_models(self): - if not self.success_observations: - return 0, 0 - - sampled_observations = self._sample_observations(max_size=self.gp_max_obs) - num_sampled = len(sampled_observations) - - # Prepare tensors using pre-allocated buffers - params = np.array([e['input'] for e in sampled_observations]) - params_tensor = self.gp_params_buffer[:num_sampled] - params_tensor.copy_(torch.from_numpy(params)) - - # Normalized scores and costs - y = np.array([e['output'] for e in sampled_observations]) - y_norm = (y - self.min_score) / (np.abs(self.max_score - self.min_score) + EPSILON) - y_norm_tensor = self.gp_score_buffer[:num_sampled] - y_norm_tensor.copy_(torch.from_numpy(y_norm)) - - c = np.array([e['cost'] for e in sampled_observations]) - log_c = np.log(np.maximum(c, EPSILON)) # Ensure log is not taken on zero - log_c_norm = (log_c - self.log_c_min) / (self.log_c_max - self.log_c_min + EPSILON) - log_c_norm_tensor = self.gp_cost_buffer[:num_sampled] - log_c_norm_tensor.copy_(torch.from_numpy(log_c_norm)) - - with warnings.catch_warnings(): - warnings.simplefilter("ignore", gpytorch.utils.warnings.NumericalWarning) - score_loss = train_gp_model(self.gp_score, self.likelihood_score, self.mll_score, self.score_opt, params_tensor, y_norm_tensor, training_iter=self.gp_training_iter) - cost_loss = train_gp_model(self.gp_cost, self.likelihood_cost, self.mll_cost, self.cost_opt, params_tensor, log_c_norm_tensor, training_iter=self.gp_training_iter) - - return score_loss, cost_loss - - def _get_top_obs_params(self): - if not self.top_observations: - return np.array([]) - - params = np.array([e['input'] for e in self.top_observations]) - if self.cost_param_idx is None: - return params - - # Add the same params with less cost to the search center, and not the original - original_costs_norm = params[:, self.cost_param_idx] - - params_1 = np.copy(params) - cost_norm_1 = original_costs_norm - (original_costs_norm - (-1)) / 2 - params_1[:, self.cost_param_idx] = cost_norm_1 - params_2 = np.copy(params) - cost_norm_2 = original_costs_norm - (original_costs_norm - (-1)) / 3 - params_2[:, self.cost_param_idx] = cost_norm_2 - - return np.vstack([params_1, params_2]) - - def _sample_target_cost_ratio(self, expansion_rate, target_ratios=(0.16, 0.32, 0.48, 0.64, 0.8, 1.0)): - if not self.target_cost_ratio: - self.target_cost_ratio = list(target_ratios) - random.shuffle(self.target_cost_ratio) - target_ratio = np.clip(self.target_cost_ratio.pop() + 0.1 * np.random.randn(), 0, 1) - return (1 + expansion_rate) * target_ratio - - def suggest(self, fill, fixed_total_timesteps=None): - info = {} - self.suggestion_idx += 1 - fixed_cost_norm = None - if fixed_total_timesteps is not None and self.cost_space is not None: - fixed_cost_norm = self.cost_space.normalize(fixed_total_timesteps) - - # NOTE: Changed pufferl to use the train args, NOT the sweep hyperparam search center - # if len(self.success_observations) == 0 and self.seed_with_search_center: - # suggestion = self.hyperparameters.search_centers - # return self.hyperparameters.to_dict(suggestion, fill), info - - if self.suggestion_idx <= self.num_random_samples: - # Suggest the next point in the Sobol sequence - zero_one = self.sobol.random(1)[0] - suggestion = 2*zero_one - 1 # Scale from [0, 1) to [-1, 1) - if fixed_cost_norm is not None: - suggestion[self.cost_param_idx] = fixed_cost_norm - elif self.cost_param_idx is not None: - cost_suggestion = self.cost_random_suggestion + 0.1 * np.random.randn() - suggestion[self.cost_param_idx] = np.clip(cost_suggestion, -1, 1) # limit the cost - return self.hyperparameters.to_dict(suggestion, fill), info - - elif self.resample_frequency and self.suggestion_idx % self.resample_frequency == 0: - candidates, _ = pareto_points(self.success_observations) - suggestions = np.stack([e['input'] for e in candidates]) - best_idx = np.random.randint(0, len(candidates)) - best = suggestions[best_idx] - return self.hyperparameters.to_dict(best, fill), info - - score_loss, cost_loss = self._train_gp_models() - - if self.optimizer_reset_frequency and self.suggestion_idx % self.optimizer_reset_frequency == 0: - print(f'Resetting GP optimizers at suggestion {self.suggestion_idx}') - self.score_opt = torch.optim.Adam(self.gp_score.parameters(), lr=self.gp_learning_rate, amsgrad=True) - self.cost_opt = torch.optim.Adam(self.gp_cost.parameters(), lr=self.gp_learning_rate, amsgrad=True) - - pareto_front, pareto_idxs = pareto_points(self.success_observations) - pruned_front = prune_pareto_front(pareto_front) - pareto_observations = pruned_front if self.prune_pareto else pareto_front - - # Use the max cost from the pruned pareto to avoid inefficiently long runs - if self.upper_cost_threshold < 0: - self.upper_cost_threshold = pruned_front[-1]['cost'] - # Try to change the threshold slowly - elif self.upper_cost_threshold < pruned_front[-1]['cost']: - self.upper_cost_threshold *= 1.01 - self.stop_threshold_model.fit(self.success_observations, self.upper_cost_threshold) - - ### Sample suggestions - search_centers = np.stack([e['input'] for e in pareto_observations]) - if self.top_observations: - # Add top observations by score to search centers for diversity - search_centers = np.vstack([search_centers, self._get_top_obs_params()]) - - suggestions = self.hyperparameters.sample( - len(search_centers)*self.suggestions_per_pareto, mu=search_centers) - - if fixed_cost_norm is not None: - suggestions[:, self.cost_param_idx] = fixed_cost_norm - - dedup_indices = self._filter_near_duplicates(suggestions) - suggestions = suggestions[dedup_indices] - - if len(suggestions) == 0: - return self.suggest(fill) # Fallback to random if all suggestions are filtered - - ### Predict scores and costs - # Batch predictions to avoid GPU OOM for large number of suggestions - gp_y_norm_list, gp_log_c_norm_list = [], [] - - with torch.no_grad(), gpytorch.settings.fast_pred_var(), warnings.catch_warnings(): - warnings.simplefilter("ignore", gpytorch.utils.warnings.NumericalWarning) - - # Create a reusable buffer on the device to avoid allocating a huge tensor - for i in range(0, len(suggestions), self.infer_batch_size): - batch_numpy = suggestions[i:i+self.infer_batch_size] - current_batch_size = len(batch_numpy) - - # Use a slice of the buffer if the current batch is smaller - batch_tensor = self.infer_batch_buffer[:current_batch_size] - batch_tensor.copy_(torch.from_numpy(batch_numpy)) - - try: - # Score and cost prediction - pred_y_mean = self.likelihood_score(self.gp_score(batch_tensor)).mean.cpu() - pred_c_mean = self.likelihood_cost(self.gp_cost(batch_tensor)).mean.cpu() - - except RuntimeError: - # Handle numerical errors during GP prediction - pred_y_mean, pred_c_mean = torch.zeros(current_batch_size) - - gp_y_norm_list.append(pred_y_mean.cpu()) - gp_log_c_norm_list.append(pred_c_mean.cpu()) - - del pred_y_mean, pred_c_mean - - # Concatenate results from all batches - gp_y_norm = torch.cat(gp_y_norm_list).numpy() - gp_log_c_norm = torch.cat(gp_log_c_norm_list).numpy() - - # Unlinearize - gp_y = gp_y_norm*(self.max_score - self.min_score) + self.min_score - gp_log_c = gp_log_c_norm*(self.log_c_max - self.log_c_min) + self.log_c_min - gp_c = np.exp(gp_log_c) - - # Maximize score. (Tried upper confidence bounds, but it did more harm because gp was noisy) - suggestion_scores = self.hyperparameters.optimize_direction * gp_y_norm - - # When cost is fixed (pareto mode), skip cost-based weighting - if fixed_cost_norm is None: - max_c_mask = gp_c < self.max_suggestion_cost - target_cost = self._sample_target_cost_ratio(self.expansion_rate) - weight = 1 - abs(target_cost - gp_log_c_norm) - suggestion_scores *= max_c_mask * weight - - # Then, consider the prob of training success, only when downsample = 1 - # NOTE: Useful only in limited scenarios, where each data point is expensive. So turn it off by default. - if self.use_success_prob and len(self.success_observations) > 9 and len(self.failure_observations) > 9: - success_params = np.array([e['input'] for e in self.success_observations]) - failure_params = np.array([e['input'] for e in self.failure_observations]) - X_train = np.vstack([success_params, failure_params]) - y_train = np.concatenate([ - np.ones(len(success_params)), - np.zeros(len(failure_params)) - ]) - if len(np.unique(y_train)) > 1: - self.success_classifier.fit(X_train, y_train) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", UserWarning) - p_success = self.success_classifier.predict_proba(suggestions)[:, 1] - suggestion_scores *= p_success - - best_idx = np.argmax(suggestion_scores) - info = dict( - cost = gp_c[best_idx].item(), - score = gp_y[best_idx].item(), - rating = suggestion_scores[best_idx].item(), - score_loss = score_loss, - cost_loss = cost_loss, - score_lengthscale = self.gp_score.lengthscale_range, - cost_lengthscale = self.gp_cost.lengthscale_range, - ) - print('Predicted -- ', - f'Score: {info["score"]:.3f}', - f'Cost: {info["cost"]:.3f}', - f'Rating: {info["rating"]:.3f}', - ) - - best = suggestions[best_idx] - return self.hyperparameters.to_dict(best, fill), info - - def logit_transform(self, value, epsilon=1e-9): - value = np.clip(value, epsilon, 1 - epsilon) - logit = math.log(value / (1 - value)) - return np.clip(logit, -5, 100) - - def observe(self, hypers, score, cost, is_failure=False): - params = self.hyperparameters.from_dict(hypers) - - if self.metric_distribution == 'percentile': - score = self.logit_transform(score) - - new_observation = dict( - input=params, - output=score, - cost=cost, - is_failure=is_failure, - ) - - if is_failure or not np.isfinite(score) or np.isnan(score): - new_observation['is_failure'] = True - self.failure_observations.append(new_observation) - return - - if self.success_observations: - success_params = np.stack([e['input'] for e in self.success_observations]) - dist = np.linalg.norm(params - success_params, axis=1) - same = np.where(dist < EPSILON)[0] - if len(same) > 0: - self.success_observations[same[0]] = new_observation - return - - # Ignore obs that are below the minimum cost - if self.cost_param_idx is not None and params[self.cost_param_idx] <= -1: - return - - self.success_observations.append(new_observation) - - # Update top_observations without sorting the full list every time - if len(self.top_observations) < self.num_keep_top_obs: - self.top_observations.append(new_observation) - self.top_observations.sort(key=lambda x: x['output'], reverse=True) - elif score > self.top_observations[-1]['output']: - self.top_observations.pop() - self.top_observations.append(new_observation) - self.top_observations.sort(key=lambda x: x['output'], reverse=True) - - def get_early_stop_threshold(self, cost): - return self.stop_threshold_model.get_threshold(cost) - - def should_stop(self, score, cost): - threshold = self.get_early_stop_threshold(cost) - - if self.metric_distribution == 'percentile': - score = self.logit_transform(score) - - return score < threshold - - def early_stop(self, logs, target_key): - for k, v in logs['loss'].items(): - if np.isnan(v): - logs['is_loss_nan'] = True - return True - - if 'uptime' not in logs or target_key not in logs: - return False - - metric_val, cost = logs['env'][target_key], logs['uptime'] - self._running_target_buffer.append(metric_val) - target_running_mean = np.mean(self._running_target_buffer) - threshold = self.get_early_stop_threshold(cost) - logs['early_stop_threshold'] = max(threshold, -5) - if self.should_stop(max(target_running_mean, metric_val), cost): - logs['is_loss_nan'] = False - return True - return False diff --git a/pufferlib/torch_pufferl.py b/pufferlib/torch_pufferl.py deleted file mode 100644 index 82ab87be18..0000000000 --- a/pufferlib/torch_pufferl.py +++ /dev/null @@ -1,502 +0,0 @@ -## puffer [train | eval | sweep] [env_name] [optional args] -- See https://puffer.ai for full detail0 -# This is the same as python -m pufferlib.pufferl [train | eval | sweep] [env_name] [optional args] -# Distributed example: torchrun --standalone --nnodes=1 --nproc-per-node=6 -m pufferlib.pufferl train puffer_nmmo3 - -import os -import glob -import time -import ctypes -from collections import defaultdict - -import numpy as np - -import torch -import torch.distributed -from torch.distributions.utils import logits_to_probs - -import pufferlib -import pufferlib.pufferl -from pufferlib.muon import Muon -from pufferlib import _C -if _C.precision_bytes != 4: - raise RuntimeError( - f'_C was compiled with bf16 precision (precision_bytes={_C.precision_bytes}). ' - 'The PyTorch backend requires float32. Rebuild with: pip install -e . --no-build-isolation --config-settings="--build-option=--precision=float"' - ) - -_OBS_DTYPE_MAP = { - 'ByteTensor': torch.uint8, - 'FloatTensor': torch.float32, -} - -_TORCH_TO_TYPESTR = { - torch.uint8: '|u1', - torch.float32: ' 1 else action.unsqueeze(-1)).to(dtype=torch.float32).contiguous() - if self.gpu: - actions_flat = actions_flat.cuda() - self._vec.gpu_step(actions_flat.data_ptr()) - torch.cuda.synchronize() - else: - self._vec.cpu_step(actions_flat.data_ptr()) - o, r, d = self.vec_obs, self.vec_rewards, self.vec_terminals - prof.mark(3) - - prof.elapsed(P.EVAL_GPU, 1, 2) - prof.elapsed(P.EVAL_ENV, 2, 3) - - prof.mark(1) - prof.elapsed(P.ROLLOUT, 0, 1) - - self.global_step += self.total_agents * horizon - - self.env_logs = self._vec.log() - - def train(self): - prof = self.profile - losses = defaultdict(float) - config = self.config - device = config['device'] - - b0 = config['prio_beta0'] - a = config['prio_alpha'] - clip_coef = config['clip_coef'] - vf_clip = config['vf_clip_coef'] - anneal_beta = b0 + (1 - b0)*a*self.epoch/self.total_epochs - self.ratio[:] = 1 - - learning_rate = config['learning_rate'] - if config['anneal_lr'] and self.epoch > 0: - lr_ratio = self.epoch / self.total_epochs - lr_min = config['learning_rate'] * config['min_lr_ratio'] - learning_rate = lr_min + 0.5*(learning_rate - lr_min) * (1 + np.cos(np.pi * lr_ratio)) - self.optimizer.param_groups[0]['lr'] = learning_rate - - # Transpose from [horizon, agents] (contiguous writes) to [agents, horizon] (minibatch indexing) - obs = self.observations.transpose(0, 1).contiguous() - act = self.actions.transpose(0, 1).contiguous() - val = self.values.T.contiguous() - lp = self.logprobs.T.contiguous() - rew = self.rewards.T.contiguous().clamp(-1, 1) - ter = self.terminals.T.contiguous() - - P = Profile - prof.mark(0) - num_minibatches = int(config['replay_ratio'] * self.batch_size / config['minibatch_size']) - for mb in range(num_minibatches): - shape = val.shape - advantages = torch.zeros(shape, device=device) - advantages = compute_puff_advantage(val, rew, - ter, self.ratio, advantages, config['gamma'], - config['gae_lambda'], config['vtrace_rho_clip'], config['vtrace_c_clip']) - - adv = advantages.abs().sum(axis=1) - prio_weights = torch.nan_to_num(adv**a, 0, 0, 0) - prio_probs = (prio_weights + 1e-6)/(prio_weights.sum() + 1e-6) - idx = torch.multinomial(prio_probs, - self.minibatch_segments, replacement=True) - mb_prio = (self.total_agents*prio_probs[idx, None])**-anneal_beta - - mb_obs = obs[idx] - mb_actions = act[idx] - mb_logprobs = lp[idx] - mb_values = val[idx] - mb_returns = advantages[idx] + mb_values - mb_advantages = advantages[idx] - - prof.mark(1) - logits, newvalue = self.policy(mb_obs) - actions, newlogprob, entropy = sample_logits(logits, action=mb_actions) - prof.mark(2) - prof.elapsed(P.TRAIN_FORWARD, 1, 2) - - newlogprob = newlogprob.reshape(mb_logprobs.shape) - logratio = newlogprob - mb_logprobs - ratio = logratio.exp() - self.ratio[idx] = ratio.detach() - - with torch.no_grad(): - old_approx_kl = (-logratio).mean() - approx_kl = ((ratio - 1) - logratio).mean() - clipfrac = ((ratio - 1.0).abs() > config['clip_coef']).float().mean() - - adv = mb_advantages - adv = mb_prio * (adv - adv.mean()) / (adv.std() + 1e-8) - - pg_loss1 = -adv * ratio - pg_loss2 = -adv * torch.clamp(ratio, 1 - clip_coef, 1 + clip_coef) - pg_loss = torch.max(pg_loss1, pg_loss2).mean() - - newvalue = newvalue.view(mb_returns.shape) - v_clipped = mb_values + torch.clamp(newvalue - mb_values, -vf_clip, vf_clip) - v_loss_unclipped = (newvalue - mb_returns) ** 2 - v_loss_clipped = (v_clipped - mb_returns) ** 2 - v_loss = 0.5*torch.max(v_loss_unclipped, v_loss_clipped).mean() - - entropy_loss = entropy.mean() - loss = pg_loss + config['vf_coef']*v_loss - config['ent_coef']*entropy_loss - val[idx] = newvalue.detach().float() - - losses['policy_loss'] += pg_loss - losses['value_loss'] += v_loss - losses['entropy'] += entropy_loss - losses['old_approx_kl'] += old_approx_kl - losses['approx_kl'] += approx_kl - losses['clipfrac'] += clipfrac - losses['importance'] += ratio.mean() - - loss.backward() - torch.nn.utils.clip_grad_norm_(self.policy.parameters(), config['max_grad_norm']) - self.optimizer.step() - self.optimizer.zero_grad() - - prof.mark(1) - prof.elapsed(P.TRAIN, 0, 1) - - losses = {k: v.item() / num_minibatches for k, v in losses.items()} - y_pred = val.flatten() - y_true = advantages.flatten() + val.flatten() - var_y = y_true.var() - explained_var = torch.nan if var_y == 0 else (1 - (y_true - y_pred).var() / var_y).item() - losses['explained_variance'] = explained_var - - self.losses = losses - self.epoch += 1 - - def log(self): - P = Profile - perf = self.profile.read_and_reset() - logs = { - 'SPS': self.sps * self.world_size, - 'agent_steps': self.global_step * self.world_size, - 'uptime': time.time() - self.start_time, - 'epoch': self.epoch, - 'env': dict(getattr(self, 'env_logs', {})), - 'loss': dict(getattr(self, 'losses', {})), - 'perf': { - 'rollout': perf[P.ROLLOUT], - 'eval_gpu': perf[P.EVAL_GPU], - 'eval_env': perf[P.EVAL_ENV], - 'train': perf[P.TRAIN], - 'train_misc': perf[P.TRAIN_MISC], - 'train_forward': perf[P.TRAIN_FORWARD], - }, - 'util': dict(_C.get_utilization(self.args.get('gpu_id', 0))) if self.gpu else {}, - } - self.last_log_time = time.time() - self.last_log_step = self.global_step - return logs - - eval_log = log - - def save_weights(self, path): - torch.save(self.policy.state_dict(), path) - - def close(self): - self._vec.close() - - @classmethod - def create_pufferl(cls, args): - '''Matches _C.create_pufferl(args) interface.''' - device = 'cuda' if torch.cuda.is_available() else 'mps' if torch.backends.mps.is_available() else 'cpu' - args['train']['device'] = device - - # DDP setup - if 'LOCAL_RANK' in os.environ: - world_size = int(os.environ.get('WORLD_SIZE', 1)) - local_rank = int(os.environ['LOCAL_RANK']) - torch.cuda.set_device(local_rank) - os.environ['CUDA_VISIBLE_DEVICES'] = str(local_rank) - - args['vec']['num_buffers'] = 1 - gpu = 1 if device == 'cuda' else 0 - vec = _C.create_vec(args, gpu) - policy = load_policy(args, vec) - - if 'LOCAL_RANK' in os.environ: - torch.distributed.init_process_group(backend='nccl', world_size=world_size) - policy = policy.to(local_rank) - model = torch.nn.parallel.DistributedDataParallel( - policy, device_ids=[local_rank], output_device=local_rank) - if hasattr(policy, 'lstm'): - model.hidden_size = policy.hidden_size - model.forward_eval = policy.forward_eval - model.initial_state = policy.initial_state - policy = model.to(local_rank) - - return cls(args, vec, policy) - -def compute_puff_advantage(values, rewards, terminals, - ratio, advantages, gamma, gae_lambda, vtrace_rho_clip, vtrace_c_clip): - num_steps, horizon = values.shape - fn = _C.puff_advantage if values.is_cuda else _C.puff_advantage_cpu - fn( - values.data_ptr(), rewards.data_ptr(), terminals.data_ptr(), - ratio.data_ptr(), advantages.data_ptr(), - num_steps, horizon, - gamma, gae_lambda, vtrace_rho_clip, vtrace_c_clip) - return advantages - -class Profile: - '''Matches pufferlib.cu profiling: accumulate ms, report seconds.''' - ROLLOUT, EVAL_GPU, EVAL_ENV, TRAIN, TRAIN_MISC, TRAIN_FORWARD, NUM = range(7) - - def __init__(self, gpu=True): - self.accum = [0.0] * Profile.NUM - self.gpu = gpu - if gpu: - self._events = [torch.cuda.Event(enable_timing=True) for _ in range(4)] - else: - self._stamps = [0.0] * 4 - - def mark(self, idx): - if self.gpu: - self._events[idx].record() - else: - self._stamps[idx] = time.perf_counter() - - def elapsed(self, idx, start_ev, end_ev): - if self.gpu: - self._events[end_ev].synchronize() - self.accum[idx] += self._events[start_ev].elapsed_time(self._events[end_ev]) - else: - self.accum[idx] += (self._stamps[end_ev] - self._stamps[start_ev]) * 1000.0 - - def read_and_reset(self): - out = [v / 1000.0 for v in self.accum] - self.accum = [0.0] * Profile.NUM - return out - -def load_policy(args, vec): - import pufferlib.models - policy_kwargs = args['policy'] - network_cls = getattr(pufferlib.models, policy_kwargs['network']) - - network = network_cls(**policy_kwargs) - encoder = pufferlib.models.DefaultEncoder(vec.obs_size, policy_kwargs['hidden_size']) - decoder = pufferlib.models.DefaultDecoder(vec.act_sizes, policy_kwargs['hidden_size']) - policy = pufferlib.models.Policy(encoder, decoder, network) - - device = args['train']['device'] - policy = policy.to(device) - - load_id = args['load_id'] - if load_id is not None: - if args['wandb']: - import wandb - artifact = wandb.use_artifact(f'{load_id}:latest') - data_dir = artifact.download() - path = f'{data_dir}/{max(os.listdir(data_dir))}' - else: - raise ValueError('load_id requires --wandb') - - state_dict = torch.load(path, map_location=device) - state_dict = {k.replace('module.', ''): v for k, v in state_dict.items()} - policy.load_state_dict(state_dict) - - load_path = args['load_model_path'] - if load_path == 'latest': - load_path = max(glob.glob(f"experiments/{env_name}*.pt"), key=os.path.getctime) - - if load_path is not None: - state_dict = torch.load(load_path, map_location=device) - state_dict = {k.replace('module.', ''): v for k, v in state_dict.items()} - policy.load_state_dict(state_dict) - - return policy -