diff --git a/build.sh b/build.sh index 0a4664756d..e7ffa82272 100755 --- a/build.sh +++ b/build.sh @@ -25,6 +25,7 @@ for arg in "$@"; do --debug) DEBUG=1 ;; --local) MODE=local ;; --fast) MODE=fast ;; + --gprof) MODE=fast; GPROF=1 ;; --web) MODE=web ;; --profile) MODE=profile ;; --cpu) MODE=cpu; PRECISION="-DPRECISION_FLOAT" ;; @@ -55,7 +56,8 @@ PLATFORM="$(uname -s)" if [ "$PLATFORM" = "Linux" ]; then RAYLIB_NAME='raylib-5.5_linux_amd64' OMP_LIB=-lomp5 - SANITIZE_FLAGS=(-fsanitize=address,undefined,bounds,pointer-overflow,leak -fno-omit-frame-pointer) + # SANITIZE_FLAGS=(-fsanitize=address,undefined,bounds,pointer-overflow,leak -fno-omit-frame-pointer) + SANITIZE_FLAGS=() STANDALONE_LDFLAGS=(-lGL) SHARED_LDFLAGS=(-Bsymbolic-functions) else @@ -116,6 +118,18 @@ elif [ "$ENV" = "impulse_wars" ]; then download "$BOX2D_NAME" "$BOX2D_URL/$BOX2D_NAME.tar.gz" INCLUDES+=(-I./$BOX2D_NAME/include -I./$BOX2D_NAME/src) LINK_ARCHIVES+=("./$BOX2D_NAME/libbox2d.a") +elif [[ "$ENV" == osrs_* ]]; then + SRC_DIR="ocean/$ENV" + INCLUDES+=(-I./ocean/osrs) + # download visual assets to data/ at repo root (where the binary looks). + # eval via puffer renders through the same _C.so, so assets must be present + # for any osrs build, not just --local. + if [ ! -f "data/equipment.models" ]; then + echo "Downloading OSRS visual assets..." + OSRS_ASSETS_URL="https://github.com/valtterivalo/PufferLib/releases/download/osrs-assets-v8/osrs-assets-v8.tar.gz" + mkdir -p data + curl -sL "$OSRS_ASSETS_URL" | tar xz --strip-components=1 -C data + fi elif [ -d "ocean/$ENV" ]; then SRC_DIR="ocean/$ENV" else @@ -131,17 +145,28 @@ if [ -n "$DEBUG" ] || [ "$MODE" = "local" ]; then LINK_OPT="-g" else CLANG_OPT=(-O2 -DNDEBUG "${CLANG_WARN[@]}") + if [ -n "$GPROF" ]; then + CLANG_OPT+=(-pg) + fi NVCC_OPT="-O2 --threads 0" LINK_OPT="-O2" fi if [ "$MODE" = "local" ] || [ "$MODE" = "fast" ]; then + # OSRS envs share a single visual binary + if [[ "$ENV" == osrs_* ]]; then + VISUAL_SRC="ocean/osrs/osrs_visual.c" + VISUAL_DEFS="-DOSRS_VISUAL" + else + VISUAL_SRC="$SRC_DIR/$ENV.c" + VISUAL_DEFS="" + fi FLAGS=( "${INCLUDES[@]}" - "$SRC_DIR/$ENV.c" $EXTRA_SRC -o "$OUTPUT_NAME" + "$VISUAL_SRC" $EXTRA_SRC -o "$OUTPUT_NAME" "${LINK_ARCHIVES[@]}" "${STANDALONE_LDFLAGS[@]}" -lm -lpthread -fopenmp - -DPLATFORM_DESKTOP + -DPLATFORM_DESKTOP $VISUAL_DEFS ) echo "Compiling $ENV..." ${CC:-clang} "${CLANG_OPT[@]}" "${FLAGS[@]}" @@ -216,8 +241,9 @@ fi echo "Compiling static library for $ENV..." ${CC:-clang} -c "${CLANG_OPT[@]}" \ + "${INCLUDES[@]}" \ -I. -Isrc -I$SRC_DIR -Ivendor \ - -I./$RAYLIB_NAME/include -I$CUDA_HOME/include \ + -I$CUDA_HOME/include \ -DPLATFORM_DESKTOP \ -fno-semantic-interposition -fvisibility=hidden \ -fPIC -fopenmp \ diff --git a/config/osrs_inferno.ini b/config/osrs_inferno.ini new file mode 100644 index 0000000000..2df58b091b --- /dev/null +++ b/config/osrs_inferno.ini @@ -0,0 +1,184 @@ +# OSRS Inferno encounter. +# 8 action heads (79 logits), 1058 obs, long episodes (300-8000+ ticks). + +[base] +env_name = osrs_inferno +score_metric = episode_return + +[env] +start_wave = 69 +damage_reward_coeff = 0.01 +shield_penalty_coeff = 0.01 +tag_reward_coeff = 0.25 +late_start_supply_profile_scale = 1.0 +mask_in_obs = 1.0 +record_best_replay_path = "" +play_replay_path = "" +# curriculum: fraction of agents starting at later waves (rest at start_wave) +curriculum_wave_1 = 20 +curriculum_frac_1 = 0.00 +curriculum_wave_2 = 40 +curriculum_frac_2 = 0.00 +curriculum_wave_3 = 60 +curriculum_frac_3 = 0.00 + +[vec] +total_agents = 8192 +num_buffers = 4 + +[policy] +hidden_size = 128 +num_layers = 3 + +[train] +total_timesteps = 400_000_000 + +[sweep] +min_sps = 50000 +max_suggestion_cost = 3600 +metric = episode_return +metric_distribution = linear + +[sweep.train.total_timesteps] +distribution = log_normal +min = 500000000 +max = 3200000000 +scale = time + +[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.1 +scale = auto + +[sweep.train.gamma] +distribution = logit_normal +min = 0.99 +max = 0.999999 +scale = auto + +[sweep.train.min_lr_ratio] +distribution = uniform +min = 0.0 +max = 0.5 +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.999 +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.05 +max = 1.5 +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 = 2.0 +scale = auto + +[sweep.train.weight_decay] +distribution = log_normal +min = 0.001 +max = 1.0 +scale = auto + +[sweep.train.minibatch_size] +distribution = uniform_pow2 +min = 2048 +max = 8192 +scale = auto + +[sweep.vec.num_buffers] +distribution = uniform_pow2 +min = 1 +max = 4 +scale = auto + +[sweep.vec.total_agents] +distribution = uniform_pow2 +min = 128 +max = 4096 +scale = auto + +[sweep.policy.hidden_size] +distribution = uniform_pow2 +min = 128 +max = 1024 +scale = auto + +[sweep.policy.num_layers] +distribution = uniform +min = 2 +max = 5.0 +scale = auto diff --git a/config/osrs_pvp.ini b/config/osrs_pvp.ini new file mode 100644 index 0000000000..c1023c150c --- /dev/null +++ b/config/osrs_pvp.ini @@ -0,0 +1,37 @@ +# OSRS NH PvP encounter. +# 7 action heads (39 logits), 334 obs + 39 mask = 373 total, short episodes (~300 ticks). + +[base] +env_name = osrs_pvp +policy_name = MinGRU +rnn_name = Recurrent +score_metric = episode_return + +[env] +mask_in_obs = 1.0 + +[vec] +total_agents = 512 +num_buffers = 2 + +[policy] +hidden_size = 256 +num_layers = 2 + +[train] +total_timesteps = 500000000 +horizon = 32 +learning_rate = 0.003 +beta1 = 0.95 +eps = 0.00001 +ent_coef = 0.01 +gamma = 0.997 +gae_lambda = 0.95 +clip_coef = 0.2 +vf_coef = 0.5 +vf_clip_coef = 0.5 +max_grad_norm = 1.0 +replay_ratio = 0.25 +minibatch_size = 4096 +ns_iters = 5 +weight_decay = 0.001 diff --git a/config/osrs_zulrah.ini b/config/osrs_zulrah.ini new file mode 100644 index 0000000000..cd95b5c1b2 --- /dev/null +++ b/config/osrs_zulrah.ini @@ -0,0 +1,38 @@ +# OSRS Zulrah encounter. +# 6 action heads (41 logits), 81 obs + 41 mask = 122 total, medium episodes (~600 ticks max). + +[base] +env_name = osrs_zulrah +policy_name = MinGRU +rnn_name = Recurrent +score_metric = episode_return + +[env] +mask_in_obs = 1.0 +gear_tier = 2.0 + +[vec] +total_agents = 512 +num_buffers = 2 + +[policy] +hidden_size = 256 +num_layers = 2 + +[train] +total_timesteps = 500000000 +horizon = 32 +learning_rate = 0.003 +beta1 = 0.95 +eps = 0.00001 +ent_coef = 0.01 +gamma = 0.999 +gae_lambda = 0.95 +clip_coef = 0.2 +vf_coef = 0.5 +vf_clip_coef = 0.5 +max_grad_norm = 1.0 +replay_ratio = 0.25 +minibatch_size = 4096 +ns_iters = 5 +weight_decay = 0.001 diff --git a/ocean/osrs/README.md b/ocean/osrs/README.md new file mode 100644 index 0000000000..a70a0c9229 --- /dev/null +++ b/ocean/osrs/README.md @@ -0,0 +1,35 @@ +# osrs envs + +build the compiled backend with the normal puffer flow: + +```bash +./build.sh osrs_inferno +./build.sh osrs_zulrah +./build.sh osrs_pvp +``` + +the active python entrypoint is `puffer`, backed by `pufferlib/pufferl.py`. + +```bash +puffer train osrs_inferno +puffer sweep osrs_inferno +puffer eval osrs_inferno +puffer eval osrs_inferno --load-model-path /path/to/checkpoint.bin +``` + +env configs live in `config/ocean/.ini`. + +inferno best replay recording is opt-in: + +```bash +puffer train osrs_inferno --env.record-best-replay-path checkpoints/osrs_inferno/best.replay +puffer eval osrs_inferno --env.play-replay-path checkpoints/osrs_inferno/best.replay +``` + +the standalone visual binary still exists for direct rendering: + +```bash +cd ocean/osrs +make visual +./osrs_visual --encounter inferno --replay /path/to/best.replay +``` diff --git a/ocean/osrs/binding.c b/ocean/osrs/binding.c new file mode 100644 index 0000000000..17bb4814e4 --- /dev/null +++ b/ocean/osrs/binding.c @@ -0,0 +1,182 @@ +/** + * @file binding.c + * @brief Metal static-native vec binding for the shared 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. The file lives under ocean/osrs because it sits on top of the shared + * OSRS subsystem stack, even though the bound env here is specifically PvP. + */ + +#include "osrs_env.h" + +/* vecenv-compatible header fields must stay first. */ +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + OsrsEnv pvp; + + 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 + +void c_step(Env* env) { + for (int i = 0; i < NUM_ATNS; i++) { + env->ocean_acts_staging[i] = (int)env->actions[i]; + } + + pvp_step(&env->pvp); + + env->terminals[0] = (float)env->ocean_term_staging; + + 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) { + env->pvp.ocean_io.agent_obs = (float*)env->observations; + env->pvp.ocean_io.agent_rewards = env->rewards; + env->pvp.ocean_io.agent_terminals = &env->ocean_term_staging; + env->pvp.ocean_io.agent_actions = env->ocean_acts_staging; + + pvp_reset(&env->pvp); + ocean_write_obs(&env->pvp); + env->pvp.ocean_io.agent_rewards[0] = 0.0f; + env->pvp.ocean_io.agent_terminals[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); + + env->pvp.ocean_io.agent_obs = NULL; + env->pvp.ocean_io.agent_rewards = env->pvp._rews_buf; + env->pvp.ocean_io.agent_terminals = &env->ocean_term_staging; + env->pvp.ocean_io.agent_actions = env->ocean_acts_staging; + env->pvp.ocean_io.agent_obs_p1 = NULL; + env->pvp.ocean_io.selfplay_mask = NULL; + + env->pvp.pvp_runtime.use_c_opponent = 1; + env->pvp.auto_reset = 1; + env->pvp.is_lms = 1; + + DictItem* opp = dict_get_unsafe(kwargs, "opponent_type"); + env->pvp.pvp_runtime.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; + + 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; + + env->pvp.pvp_runtime.gear_tier_weights[0] = 1.0f; + env->pvp.pvp_runtime.gear_tier_weights[1] = 0.0f; + env->pvp.pvp_runtime.gear_tier_weights[2] = 0.0f; + env->pvp.pvp_runtime.gear_tier_weights[3] = 0.0f; + + 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); +} + +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.pvp_runtime.pfsp.pool_size == 0); + envs[e].pvp.pvp_runtime.pfsp.pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + envs[e].pvp.pvp_runtime.pfsp.pool[i] = (OpponentType)pool[i]; + envs[e].pvp.pvp_runtime.pfsp.cum_weights[i] = cum_weights[i]; + } + 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.pvp_runtime.pfsp.pool_size > pool_size) + pool_size = envs[e].pvp.pvp_runtime.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; + } + + for (int e = 0; e < vec->size; e++) { + for (int i = 0; i < envs[e].pvp.pvp_runtime.pfsp.pool_size; i++) { + out_wins[i] += envs[e].pvp.pvp_runtime.pfsp.wins[i]; + out_episodes[i] += envs[e].pvp.pvp_runtime.pfsp.episodes[i]; + } + memset(envs[e].pvp.pvp_runtime.pfsp.wins, 0, sizeof(envs[e].pvp.pvp_runtime.pfsp.wins)); + memset(envs[e].pvp.pvp_runtime.pfsp.episodes, 0, sizeof(envs[e].pvp.pvp_runtime.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..4b2d92f249 --- /dev/null +++ b/ocean/osrs/data/item_models.h @@ -0,0 +1,122 @@ +/* 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 103 + +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 }, + { 12817, 11072, 917604, 0 }, + { 26243, 47655, 917605, 0 }, + { 26245, 47653, 917606, 0 }, + { 28310, 49435, 4294967295, 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..38ed8f741f --- /dev/null +++ b/ocean/osrs/data/npc_models.h @@ -0,0 +1,124 @@ +/** + * @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) */ + {2045, 10415, 1721, 140, 2405}, /* snakeling melee */ + {2046, 10415, 1721, 185, 2405}, /* snakeling 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 (generated) */ +#include "npc_models_inferno.h" + +/* alias: encounter code uses NPC_MODEL_MAP_INFERNO, generated uses _GEN suffix */ +#define NPC_MODEL_MAP_INFERNO NPC_MODEL_MAP_INFERNO_GEN + +/* alias: encounter code uses INF_GFX_*, generated uses INF_GEN_GFX_* */ +#define INF_GFX_157_MODEL INF_GEN_GFX_157_MODEL +#define INF_GFX_157_ANIM INF_GEN_GFX_157_ANIM +#define INF_GFX_448_MODEL INF_GEN_GFX_448_MODEL +#define INF_GFX_448_ANIM INF_GEN_GFX_448_ANIM +#define INF_GFX_449_MODEL INF_GEN_GFX_449_MODEL +#define INF_GFX_449_ANIM INF_GEN_GFX_449_ANIM +#define INF_GFX_450_MODEL INF_GEN_GFX_450_MODEL +#define INF_GFX_450_ANIM INF_GEN_GFX_450_ANIM +#define INF_GFX_659_MODEL INF_GEN_GFX_659_MODEL +#define INF_GFX_659_ANIM INF_GEN_GFX_659_ANIM +#define INF_GFX_660_MODEL INF_GEN_GFX_660_MODEL +#define INF_GFX_660_ANIM INF_GEN_GFX_660_ANIM +#define INF_GFX_451_MODEL INF_GEN_GFX_451_MODEL +#define INF_GFX_451_ANIM INF_GEN_GFX_451_ANIM +#define INF_GFX_1374_MODEL INF_GEN_GFX_1374_MODEL +#define INF_GFX_1374_ANIM INF_GEN_GFX_1374_ANIM +#define INF_GFX_1375_MODEL INF_GEN_GFX_1375_MODEL +#define INF_GFX_1375_ANIM INF_GEN_GFX_1375_ANIM +#define INF_GFX_1376_MODEL INF_GEN_GFX_1376_MODEL +#define INF_GFX_1376_ANIM INF_GEN_GFX_1376_ANIM +#define INF_GFX_1377_MODEL INF_GEN_GFX_1377_MODEL +#define INF_GFX_1378_MODEL INF_GEN_GFX_1378_MODEL +#define INF_GFX_1378_ANIM INF_GEN_GFX_1378_ANIM +#define INF_GFX_1379_MODEL INF_GEN_GFX_1379_MODEL +#define INF_GFX_1379_ANIM INF_GEN_GFX_1379_ANIM +#define INF_GFX_1380_MODEL INF_GEN_GFX_1380_MODEL +#define INF_GFX_1380_ANIM INF_GEN_GFX_1380_ANIM +#define INF_GFX_1381_MODEL INF_GEN_GFX_1381_MODEL +#define INF_GFX_1381_ANIM INF_GEN_GFX_1381_ANIM +#define INF_GFX_1382_MODEL INF_GEN_GFX_1382_MODEL +#define INF_GFX_1382_ANIM INF_GEN_GFX_1382_ANIM +#define INF_GFX_1383_MODEL INF_GEN_GFX_1383_MODEL +#define INF_GFX_1383_ANIM INF_GEN_GFX_1383_ANIM +#define INF_GFX_1384_MODEL INF_GEN_GFX_1384_MODEL +#define INF_GFX_1385_MODEL INF_GEN_GFX_1385_MODEL +#define INF_GFX_1385_ANIM INF_GEN_GFX_1385_ANIM +#define INF_GFX_1120_MODEL INF_GEN_GFX_1120_MODEL +#define INF_GFX_1120_ANIM INF_GEN_GFX_1120_ANIM + +/* inferno spotanim ids for shared render/event wiring */ +#define INF_GFX_659_ID 659 +#define INF_GFX_660_ID 660 +#define INF_GFX_1120_ID 1120 + +/* 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_GEN) / sizeof(NPC_MODEL_MAP_INFERNO_GEN[0])); i++) { + if (NPC_MODEL_MAP_INFERNO_GEN[i].npc_id == npc_id) return &NPC_MODEL_MAP_INFERNO_GEN[i]; + } + return NULL; +} + +#endif /* NPC_MODELS_H */ diff --git a/ocean/osrs/data/npc_models_inferno.h b/ocean/osrs/data/npc_models_inferno.h new file mode 100644 index 0000000000..2be2eeb644 --- /dev/null +++ b/ocean/osrs/data/npc_models_inferno.h @@ -0,0 +1,130 @@ +/* generated by scripts/export_inferno_npcs.py -- do not edit */ +#ifndef NPC_MODELS_INFERNO_H +#define NPC_MODELS_INFERNO_H + +#include +#include "npc_models.h" /* for NpcModelMapping typedef */ + +static const NpcModelMapping NPC_MODEL_MAP_INFERNO_GEN[] = { + {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 animation IDs */ +#define INF_GEN_ANIM_NIBBLER_IDLE 7573 +#define INF_GEN_ANIM_NIBBLER_ATTACK 7574 +#define INF_GEN_ANIM_NIBBLER_WALK 7572 +#define INF_GEN_ANIM_NIBBLER_DEFEND 7575 +#define INF_GEN_ANIM_NIBBLER_DEATH 7576 +#define INF_GEN_ANIM_BAT_IDLE 7577 +#define INF_GEN_ANIM_BAT_ATTACK 7578 +#define INF_GEN_ANIM_BAT_WALK 7577 +#define INF_GEN_ANIM_BAT_DEFEND 7579 +#define INF_GEN_ANIM_BAT_DEATH 7580 +#define INF_GEN_ANIM_BLOB_IDLE 7586 +#define INF_GEN_ANIM_BLOB_ATTACK 7581 +#define INF_GEN_ANIM_BLOB_WALK 7587 +#define INF_GEN_ANIM_BLOB_ATTACK_MELEE 7582 +#define INF_GEN_ANIM_BLOB_ATTACK_RANGED 7583 +#define INF_GEN_ANIM_BLOB_DEATH 7584 +#define INF_GEN_ANIM_BLOB_DEFEND 7585 +#define INF_GEN_ANIM_BLOB_MELEE_SPLIT_IDLE 7586 +#define INF_GEN_ANIM_BLOB_MELEE_SPLIT_WALK 7587 +#define INF_GEN_ANIM_BLOB_RANGE_SPLIT_IDLE 7586 +#define INF_GEN_ANIM_BLOB_RANGE_SPLIT_WALK 7587 +#define INF_GEN_ANIM_BLOB_MAGE_SPLIT_IDLE 7586 +#define INF_GEN_ANIM_BLOB_MAGE_SPLIT_WALK 7587 +#define INF_GEN_ANIM_MELEER_IDLE 7595 +#define INF_GEN_ANIM_MELEER_ATTACK 7597 +#define INF_GEN_ANIM_MELEER_WALK 7596 +#define INF_GEN_ANIM_MELEER_DEFEND 7598 +#define INF_GEN_ANIM_MELEER_DEATH 7599 +#define INF_GEN_ANIM_MELEER_DIG_DOWN 7600 +#define INF_GEN_ANIM_MELEER_DIG_UP 7601 +#define INF_GEN_ANIM_RANGER_IDLE 7602 +#define INF_GEN_ANIM_RANGER_ATTACK 7605 +#define INF_GEN_ANIM_RANGER_WALK 7603 +#define INF_GEN_ANIM_RANGER_ATTACK_MELEE 7604 +#define INF_GEN_ANIM_RANGER_DEATH 7606 +#define INF_GEN_ANIM_RANGER_DEFEND 7607 +#define INF_GEN_ANIM_MAGER_IDLE 7609 +#define INF_GEN_ANIM_MAGER_ATTACK 7610 +#define INF_GEN_ANIM_MAGER_WALK 7608 +#define INF_GEN_ANIM_MAGER_RESURRECT 7611 +#define INF_GEN_ANIM_MAGER_ATTACK_MELEE 7612 +#define INF_GEN_ANIM_MAGER_DEATH 7613 +#define INF_GEN_ANIM_JALTOK_JAD_IDLE 7589 +#define INF_GEN_ANIM_JALTOK_JAD_ATTACK 7593 +#define INF_GEN_ANIM_JALTOK_JAD_WALK 7588 +#define INF_GEN_ANIM_JALTOK_JAD_ATTACK_MELEE 7590 +#define INF_GEN_ANIM_JALTOK_JAD_DEFEND 7591 +#define INF_GEN_ANIM_JALTOK_JAD_ATTACK_MAGIC 7592 +#define INF_GEN_ANIM_JALTOK_JAD_ATTACK_RANGED 7593 +#define INF_GEN_ANIM_JALTOK_JAD_DEATH 7594 +#define INF_GEN_ANIM_JAD_HEALER_IDLE 2636 +#define INF_GEN_ANIM_JAD_HEALER_WALK 2634 +#define INF_GEN_ANIM_TZKAL_ZUK_IDLE 7564 +#define INF_GEN_ANIM_TZKAL_ZUK_ATTACK 7566 +#define INF_GEN_ANIM_TZKAL_ZUK_DEATH 7562 +#define INF_GEN_ANIM_TZKAL_ZUK_SPAWN 7563 +#define INF_GEN_ANIM_TZKAL_ZUK_DEFEND 7565 +#define INF_GEN_ANIM_ZUK_SHIELD_IDLE 7567 +#define INF_GEN_ANIM_ZUK_SHIELD_WALK 7567 +#define INF_GEN_ANIM_ZUK_SHIELD_HIT 7568 +#define INF_GEN_ANIM_ZUK_SHIELD_DEATH 7569 +#define INF_GEN_ANIM_ZUK_HEALER_IDLE 2867 +#define INF_GEN_ANIM_ZUK_HEALER_WALK 2863 + +/* inferno spotanim GFX model + animation IDs */ +#define INF_GEN_GFX_157_MODEL 3116 /* Jad magic hit */ +#define INF_GEN_GFX_157_ANIM 693 +#define INF_GEN_GFX_448_MODEL 9337 /* Jad magic projectile (front) */ +#define INF_GEN_GFX_448_ANIM 2659 +#define INF_GEN_GFX_449_MODEL 9338 /* Jad magic projectile (middle) */ +#define INF_GEN_GFX_449_ANIM 2659 +#define INF_GEN_GFX_450_MODEL 9339 /* Jad magic projectile (rear) */ +#define INF_GEN_GFX_450_ANIM 2659 +#define INF_GEN_GFX_451_MODEL 9342 /* Jad ranged projectile */ +#define INF_GEN_GFX_451_ANIM 2660 +#define INF_GEN_GFX_659_MODEL 14760 /* Tekton meteor splat */ +#define INF_GEN_GFX_659_ANIM 3941 +#define INF_GEN_GFX_660_MODEL 14759 /* Tekton meteor projectile */ +#define INF_GEN_GFX_660_ANIM 3942 +#define INF_GEN_GFX_1120_MODEL 26377 /* Dragon arrow projectile (twisted bow) */ +#define INF_GEN_GFX_1120_ANIM 6622 +#define INF_GEN_GFX_1374_MODEL 853342 /* Bat ranged projectile */ +#define INF_GEN_GFX_1374_ANIM 660 +#define INF_GEN_GFX_1375_MODEL 33006 /* Zuk magic projectile */ +#define INF_GEN_GFX_1375_ANIM 7571 +#define INF_GEN_GFX_1376_MODEL 33007 /* Zuk ranged projectile */ +#define INF_GEN_GFX_1376_ANIM 7571 +#define INF_GEN_GFX_1377_MODEL 33013 /* Ranger ranged projectile */ +#define INF_GEN_GFX_1378_MODEL 33015 /* Ranger ranged hit */ +#define INF_GEN_GFX_1378_ANIM 7615 +#define INF_GEN_GFX_1379_MODEL 33016 /* Mager magic projectile */ +#define INF_GEN_GFX_1379_ANIM 7614 +#define INF_GEN_GFX_1380_MODEL 33008 /* Mager magic hit */ +#define INF_GEN_GFX_1380_ANIM 7616 +#define INF_GEN_GFX_1381_MODEL 33009 /* Zuk typeless hit (falling rocks?) */ +#define INF_GEN_GFX_1381_ANIM 7616 +#define INF_GEN_GFX_1382_MODEL 33017 /* Blob melee */ +#define INF_GEN_GFX_1382_ANIM 7614 +#define INF_GEN_GFX_1383_MODEL 853351 /* Blob ranged */ +#define INF_GEN_GFX_1383_ANIM 366 +#define INF_GEN_GFX_1384_MODEL 853352 /* Blob magic */ +#define INF_GEN_GFX_1385_MODEL 853353 /* Healer magic attack */ +#define INF_GEN_GFX_1385_ANIM 366 + +#endif /* NPC_MODELS_INFERNO_H */ diff --git a/ocean/osrs/data/npc_models_zulrah.h b/ocean/osrs/data/npc_models_zulrah.h new file mode 100644 index 0000000000..edcb08628d --- /dev/null +++ b/ocean/osrs/data/npc_models_zulrah.h @@ -0,0 +1,39 @@ +/* generated by tools/export_encounter_npcs.py -- do not edit */ +#ifndef NPC_MODELS_ZULRAH_H +#define NPC_MODELS_ZULRAH_H + +#include +#include "npc_models.h" /* for NpcModelMapping typedef */ + +static const NpcModelMapping NPC_MODEL_MAP_ZULRAH_GEN[] = { + {2042, 0xC07FA, 5070, 5068, 5070}, /* Zulrah green/ranged form */ + {2043, 0xC07FB, 5070, 5806, 5070}, /* Zulrah red/melee form */ + {2044, 0xC07FC, 5070, 5068, 5070}, /* Zulrah blue/magic form */ + {2045, 0xC07FD, 1721, 65535, 2405}, /* Snakeling melee variant */ + {2046, 0xC07FE, 1721, 65535, 2405}, /* Snakeling magic variant */ +}; + +/* zulrah animation IDs */ +#define ZUL_GEN_ANIM_SNAKEBOSS_ATTACK_ACIDX3 5068 +#define ZUL_GEN_ANIM_SNAKEBOSS_ATTACK_ACIDX1 5069 +#define ZUL_GEN_ANIM_SNAKEBOSS_SPAWN 5071 +#define ZUL_GEN_ANIM_SNAKEBOSS_SINKFAST 5072 +#define ZUL_GEN_ANIM_SNAKEBOSS_EMERGEFAST 5073 +#define ZUL_GEN_ANIM_SNAKEBOSS_DEATH 5804 +#define ZUL_GEN_ANIM_SNAKEBOSS_ATTACK_TAIL_LEFT 5806 +#define ZUL_GEN_ANIM_SNAKEBOSS_ATTACK_TAIL_RIGHT 5807 +#define ZUL_GEN_ANIM_SNAKEBOSS_DEFEND 5808 + +/* zulrah spotanim GFX model + animation IDs */ +#define ZUL_GEN_GFX_1044_MODEL 853012 /* SNAKEBOSS_ORB */ +#define ZUL_GEN_GFX_1044_ANIM 5358 +#define ZUL_GEN_GFX_1045_MODEL 853013 /* SNAKEBOSS_DOUBLE_ORB */ +#define ZUL_GEN_GFX_1045_ANIM 3151 +#define ZUL_GEN_GFX_1046_MODEL 853014 /* SNAKEBOSS_FIREBALL */ +#define ZUL_GEN_GFX_1046_ANIM 6648 +#define ZUL_GEN_GFX_1047_MODEL 853015 /* SNAKEBOSS_EGG */ +#define ZUL_GEN_GFX_1047_ANIM 5358 +#define ZUL_GEN_GFX_1230_MODEL 853198 /* SNAKEBOSS_MINION_SPELL */ +#define ZUL_GEN_GFX_1230_ANIM 703 + +#endif /* NPC_MODELS_ZULRAH_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..5656a24b67 --- /dev/null +++ b/ocean/osrs/encounters/encounter_inferno.h @@ -0,0 +1,4429 @@ +/** + * @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_monsters_generated.h" +#include "../osrs_collision.h" +#include "../osrs_combat.h" +#include "../osrs_special_attacks.h" +#include "../osrs_pvp_gear.h" +#include "../osrs_encounter.h" +#include "../osrs_interaction.h" +#include "../data/npc_models.h" +#include +#include +#include +#include + + +#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 +#define INF_NUM_ACTION_HEADS 9 + + +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] = 7696, /* Jal-AkRek-Ket (melee split) */ + [INF_NPC_BLOB_RANGE] = 7695, /* Jal-AkRek-Xil (range split) */ + [INF_NPC_BLOB_MAGE] = 7694, /* 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 melee_style; /* MELEE_STYLE_STAB/SLASH/CRUSH for incoming defence bonus selection */ + 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; + +/* encounter-specific fields not in the generated monster database */ +typedef struct { + int attack_range; + int default_style; /* ATTACK_STYLE_* */ + int melee_style; /* MELEE_STYLE_STAB/SLASH/CRUSH for incoming defence bonus selection */ + int can_melee; /* 1 if can switch to melee when close */ + int magic_base_dmg; /* base spell damage (magicMaxHit() in InfernoTrainer) */ + int magic_dmg_pct; /* magic damage multiplier as % (100 = 1.0x) */ + int max_hit_cap; /* 0 = no cap, >0 = clamp formula result to this */ + int stun_on_spawn; /* ticks of stun when first spawned */ + int can_move; /* 0 = cannot move (zuk, zuk healers) */ +} InfNPCOverlay; + +/* maps InfNPCType -> MonsterIndex for MONSTER_DATABASE lookup */ +static const MonsterIndex INF_NPC_TO_MON[INF_NUM_NPC_TYPES] = { + [INF_NPC_NIBBLER] = MON_JAL_NIB, + [INF_NPC_BAT] = MON_JAL_MEJRAH, + [INF_NPC_BLOB] = MON_JAL_AK, + [INF_NPC_BLOB_MELEE] = MON_JAL_AKREK_KET, + [INF_NPC_BLOB_RANGE] = MON_JAL_AKREK_XIL, + [INF_NPC_BLOB_MAGE] = MON_JAL_AKREK_MEJ, + [INF_NPC_MELEER] = MON_JAL_IMKOT, + [INF_NPC_RANGER] = MON_JAL_XIL, + [INF_NPC_MAGER] = MON_JAL_ZEK, + [INF_NPC_JAD] = MON_JALTOK_JAD, + [INF_NPC_ZUK] = MON_TZKAL_ZUK, + [INF_NPC_HEALER_JAD] = MON_YT_HURKOT, + [INF_NPC_HEALER_ZUK] = MON_JAL_MEJJAK, + [INF_NPC_ZUK_SHIELD] = MON_ZUK_SHIELD, +}; + +/* encounter-specific overlay: fields the generated DB doesn't cover */ +static const InfNPCOverlay INF_NPC_OVERLAY[INF_NUM_NPC_TYPES] = { + [INF_NPC_NIBBLER] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_CRUSH, 0, 0, 0, 0, 1, 1 }, + [INF_NPC_BAT] = { 4, ATTACK_STYLE_RANGED, MELEE_STYLE_STAB, 0, 0, 0, 0, 0, 1 }, + [INF_NPC_BLOB] = { 15, ATTACK_STYLE_MAGIC, MELEE_STYLE_CRUSH, 1, 29, 100, 0, 0, 1 }, + [INF_NPC_BLOB_MELEE] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_CRUSH, 0, 0, 0, 0, 0, 1 }, + [INF_NPC_BLOB_RANGE] = { 15, ATTACK_STYLE_RANGED, MELEE_STYLE_STAB, 0, 0, 0, 0, 0, 1 }, + [INF_NPC_BLOB_MAGE] = { 15, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 18, 100, 0, 0, 1 }, + [INF_NPC_MELEER] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_SLASH, 0, 0, 0, 0, 0, 1 }, + [INF_NPC_RANGER] = { 15, ATTACK_STYLE_RANGED, MELEE_STYLE_CRUSH, 1, 0, 0, 0, 0, 1 }, + [INF_NPC_MAGER] = { 15, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 1, 70, 100, 0, 0, 1 }, + [INF_NPC_JAD] = { 50, ATTACK_STYLE_RANGED, MELEE_STYLE_STAB, 1, 113, 100, 113, 0, 1 }, + [INF_NPC_ZUK] = { 99, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 148, 100, 0, 8, 0 }, + [INF_NPC_HEALER_JAD] = { 1, ATTACK_STYLE_MELEE, MELEE_STYLE_CRUSH, 0, 0, 0, 0, 1, 1 }, /* stun_on_spawn=1 per YtHurKot.ts:50 */ + [INF_NPC_HEALER_ZUK] = { 99, ATTACK_STYLE_MAGIC, MELEE_STYLE_STAB, 0, 10, 100, 0, 1, 0 }, /* stun_on_spawn=1 per InfernoTrainer JalMejJak.ts SPAWN_DELAY */ + [INF_NPC_ZUK_SHIELD] = { 0, ATTACK_STYLE_NONE, MELEE_STYLE_STAB, 0, 0, 0, 0, 1, 0 }, +}; + +/* populated at startup by inf_build_npc_stats() */ +static InfNPCStats INF_NPC_STATS[INF_NUM_NPC_TYPES]; + +/* merge MONSTER_DATABASE + INF_NPC_OVERLAY into INF_NPC_STATS */ +static void inf_build_npc_stats(void) { + for (int i = 0; i < INF_NUM_NPC_TYPES; i++) { + const MonsterStats* m = &MONSTER_DATABASE[INF_NPC_TO_MON[i]]; + const InfNPCOverlay* o = &INF_NPC_OVERLAY[i]; + InfNPCStats* s = &INF_NPC_STATS[i]; + + /* from generated monster DB */ + s->hp = m->hp; + s->attack_speed = m->attack_speed; + s->size = m->size; + s->att_level = m->att_level; + s->str_level = m->str_level; + s->def_level = m->def_level; + s->range_level = m->range_level; + s->magic_level = m->magic_level; + s->melee_att_bonus = m->melee_att_bonus; + s->range_att_bonus = m->range_att_bonus; + s->magic_att_bonus = m->magic_att_bonus; + s->melee_str_bonus = m->melee_str_bonus; + s->ranged_str_bonus = m->ranged_str_bonus; + s->stab_def = m->stab_def; + s->slash_def = m->slash_def; + s->crush_def = m->crush_def; + s->magic_def_bonus = m->magic_def; /* name mapping */ + s->ranged_def_bonus = m->ranged_def; /* name mapping */ + + /* from encounter overlay */ + s->attack_range = o->attack_range; + s->default_style = o->default_style; + s->melee_style = o->melee_style; + s->can_melee = o->can_melee; + s->magic_base_dmg = o->magic_base_dmg; + s->magic_dmg_pct = o->magic_dmg_pct; + s->max_hit_cap = o->max_hit_cap; + s->stun_on_spawn = o->stun_on_spawn; + s->can_move = o->can_move; + } +} + + +#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 +}; + + +/* max active NPCs: wave 62 has 9 + blob splits (3 per blob, up to 2 blobs = 6) + healers */ +#define INF_MAX_NPCS 32 +#define INF_OBS_NPCS 37 + +/* dead mob store for mager resurrection */ +#define INF_MAX_DEAD_MOBS 16 + +typedef struct { + InfNPCType type; + int x, y; + int hp, max_hp; +} InfDeadMob; + +/* 4 zuk healers × 3 sparks per volley × 2 overlapping volleys = 24. + cap is 32 for headroom so volleys don't silently drop sparks. */ +#define INF_MAX_PENDING_SPARKS 32 + +typedef struct { + int active; + int src_x, src_y; + int x, y; + int damage; + int ticks_remaining; + int visual_emitted; +} InfPendingSpark; + +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) */ + int had_los_last_tick; /* blob: previous-tick LOS latch for immediate scans on LOS gain */ + + /* jad state */ + int jad_attack_style; /* jad: committed next ranged/magic style preview, or NONE if unknown */ + 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 */ + int resurrection_count; /* number of times this original mob has been resurrected */ + + /* 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; + + /* aggro target: -1 = player (default), >= 0 = NPC index. + used for set→shield targeting and healer→zuk healing. */ + int aggro_target; + + /* per-tick render flags (cleared at start of each tick) */ + int attacked_this_tick; /* 1 when NPC attacks this tick */ + int attack_style_this_tick; /* actual style that fired this tick */ + int attack_visual_target; /* NPC index this attack visually targets (-1 = player) */ + 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; + + +typedef struct { + int x, y; + int hp; + int active; +} InfPillar; + + +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 */ + + /* set timer pause: pauses between HP 600→480, resumes with +175 when jad spawns */ + int timer_paused; + int has_paused; +} InfZukState; + + +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_ELYSIAN_SPIRIT_SHIELD, + [GEAR_SLOT_BODY] = ITEM_VIRTUS_ROBE_TOP, + [GEAR_SLOT_LEGS] = ITEM_VIRTUS_ROBE_BOTTOM, + [GEAR_SLOT_HANDS] = ITEM_CONFLICTION_GAUNTLETS, + [GEAR_SLOT_FEET] = ITEM_AVERNIC_TREADS, + [GEAR_SLOT_RING] = ITEM_VENATOR_RING, +}; + +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_AVERNIC_TREADS, + [GEAR_SLOT_RING] = ITEM_VENATOR_RING, +}; + +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_DRAGON_DART, + [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_AVERNIC_TREADS, + [GEAR_SLOT_RING] = ITEM_VENATOR_RING, +}; + +/* 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) */ + +typedef struct { + int brew_doses; + int restore_doses; + int bastion_doses; + int stamina_doses; +} InfSupplyDoses; + +typedef struct { + float brew_fraction; + float restore_fraction; + float bastion_fraction; + float stamina_fraction; +} InfSupplyFractions; + +typedef struct { + int public_wave; + InfSupplyFractions fractions; +} InfSupplyProfileAnchor; + +typedef struct { + Player player; + + InfNPC npcs[INF_MAX_NPCS]; + int current_obs_slots[INF_OBS_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 wave_spawn_target; /* wave index queued to spawn when the inter-wave delay ends */ + 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 min_zuk_hp_seen; /* final-wave low watermark for irreversible Zuk progress */ + float damage_dealt_this_tick; + float damage_zuk_healers_this_tick; + float shield_damage_this_tick; + int healer_tags_this_tick; + float damage_received_this_tick; + float hp_restored_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_zuk_healer_damage; + float total_damage_received; + float total_hp_restored; /* cumulative HP restored to enemies this episode */ + 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 */ + int off_prayer_hits_this_tick; + /* 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 */ + /* per-NPC-type prayer and damage tracking (for wandb, not dashboard) */ + int prayer_correct_by_type[INF_NUM_NPC_TYPES]; + int attacks_by_type[INF_NUM_NPC_TYPES]; + float dmg_from_type[INF_NUM_NPC_TYPES]; + int last_hit_by_type; /* NPC type that last dealt damage to player (-1=none) */ + int killed_by_type[INF_NUM_NPC_TYPES]; /* count of deaths caused by each NPC type */ + 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 */ + + /* Zuk-specific diagnostics */ + int behind_shield_ticks; /* ticks spent behind shield during Zuk wave */ + int behind_shield_this_tick; /* 1 if behind shield this tick (for reward) */ + int total_zuk_ticks; /* total ticks during Zuk wave (for behind_shield_pct) */ + + /* action distribution: count of action-0 (noop) per head. + high noop_rate = policy collapsed to doing nothing on that head. */ + int action_noop_count[INF_NUM_ACTION_HEADS]; + int action_total_count; /* total ticks (denominator for noop rates) */ + + /* 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 */ + OsrsInteraction interaction; /* shared interaction state */ + int player_last_interaction_target_slot; + int player_last_interaction_age; + + /* gear state */ + InfWeaponSet weapon_set; + EncounterLoadoutStats loadout_stats[INF_NUM_WEAPON_SETS]; + int human_command_mode; + EncounterLoadoutStats human_loadout_stats; + const HumanCommand* human_commands; + int human_command_count; + int armor_tank; /* reserved loadout slot; justiciar overlay removed */ + 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; + InfPendingSpark pending_sparks[INF_MAX_PENDING_SPARKS]; + + /* 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; + + /* per-tick LOS cache: lazy — computed on first access, reused for rest of tick. + -1 = not yet computed, 0 = no LOS, 1 = has LOS. invalidated at tick start + and on pillar collapse. */ + int8_t npc_los_cache[INF_MAX_NPCS]; + + /* OSRS entity collision flags. pathfinding ignores these, movement application checks them. */ + uint8_t npc_collision_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT]; + uint8_t player_collision_flags[INF_ARENA_WIDTH][INF_ARENA_HEIGHT]; + + /* config */ + int start_wave; /* for curriculum: start from a later wave */ + uint32_t rng_state; + float damage_reward_coeff; + float shield_penalty_coeff; + float tag_reward_coeff; + float late_start_supply_profile_scale; + + Log log; +} InfernoState; + +/* prayer check and RNG: use shared encounter_prayer_correct_for_style(), + encounter_rand_int(), encounter_rand_float() from osrs_combat.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); +} + + +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 (uncached — direct ray-trace) */ +static int inf_npc_has_los_direct(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); +} + +/* cached LOS check — lazy: computes on first access per tick, caches for reuse. + cache entries: -1 = not yet computed, 0 = no LOS, 1 = has LOS. */ +static int inf_npc_has_los(InfernoState* s, int i) { + if (s->npc_los_cache[i] >= 0) + return s->npc_los_cache[i]; + int result = inf_npc_has_los_direct(s, i); + s->npc_los_cache[i] = (int8_t)result; + return result; +} + +/* invalidate entire LOS cache (call at start of tick and on pillar collapse) */ +static inline void inf_invalidate_los_cache(InfernoState* s) { + memset(s->npc_los_cache, -1, sizeof(s->npc_los_cache)); +} + +static inline int inf_is_final_wave(const InfernoState* s) { + return s->wave == INF_NUM_WAVES - 1; +} + +static int inf_find_live_zuk_idx(const InfernoState* s) { + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].active && s->npcs[i].type == INF_NPC_ZUK) return i; + } + return -1; +} + +enum { + INF_STYLE_MASK_MELEE = 1 << 0, + INF_STYLE_MASK_RANGED = 1 << 1, + INF_STYLE_MASK_MAGIC = 1 << 2, +}; + +static inline int inf_attack_style_mask_bit(int style) { + if (style == ATTACK_STYLE_MELEE) return INF_STYLE_MASK_MELEE; + if (style == ATTACK_STYLE_RANGED) return INF_STYLE_MASK_RANGED; + if (style == ATTACK_STYLE_MAGIC) return INF_STYLE_MASK_MAGIC; + return 0; +} + +static inline int inf_cardinal_contact_with_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 == 1; +} + +static inline int inf_melee_fallback_possible( + const InfernoState* s, const InfNPC* npc, const InfNPCStats* stats, + int planned_style, int dist +) { + if (!stats->can_melee || planned_style == ATTACK_STYLE_MELEE || dist != 1) + return 0; + + switch (npc->type) { + case INF_NPC_RANGER: + case INF_NPC_MAGER: + return 1; + case INF_NPC_BLOB: + case INF_NPC_JAD: + return inf_cardinal_contact_with_npc( + s->player.x, s->player.y, npc->x, npc->y, npc->size); + default: + return 0; + } +} + +static inline int inf_attack_style_options_mask( + const InfernoState* s, const InfNPC* npc, const InfNPCStats* stats, + int planned_style, int dist +) { + int mask = inf_attack_style_mask_bit(planned_style); + if (inf_melee_fallback_possible(s, npc, stats, planned_style, dist)) + mask |= INF_STYLE_MASK_MELEE; + return mask; +} + +static inline int inf_attack_style_from_mask(int style_mask) { + if (style_mask == INF_STYLE_MASK_MELEE) return ATTACK_STYLE_MELEE; + if (style_mask == INF_STYLE_MASK_RANGED) return ATTACK_STYLE_RANGED; + if (style_mask == INF_STYLE_MASK_MAGIC) return ATTACK_STYLE_MAGIC; + return ATTACK_STYLE_NONE; +} + +static inline int inf_attack_style_obs_preview(int style_mask) { + int primary_mask = style_mask & (INF_STYLE_MASK_RANGED | INF_STYLE_MASK_MAGIC); + int primary_style = inf_attack_style_from_mask(primary_mask); + if (primary_style != ATTACK_STYLE_NONE) + return primary_style; + return inf_attack_style_from_mask(style_mask); +} + +static inline int inf_attack_style_telegraph_mask( + const InfernoState* s, const InfNPC* npc, const InfNPCStats* stats, + int planned_style, int dist +) { + /* Jad only telegraphs its ranged/magic branch. Melee is an immediate + fallback choice at fire time, not a prayer-switch cue. */ + if (npc->type == INF_NPC_JAD) + return inf_attack_style_mask_bit(planned_style); + return inf_attack_style_options_mask(s, npc, stats, planned_style, dist); +} + +static inline int inf_pending_hit_obs_timer(const EncounterPendingHit* ph) { + if (ph->check_prayer && ph->prayer_check_delay > 0) + return ph->prayer_check_delay; + return ph->ticks_remaining; +} + +#define INF_JAD_PROJECTILE_DELAY 3 +#define INF_ANIM_JALTOK_JAD_MAGIC_ATTACK INF_GEN_ANIM_JALTOK_JAD_ATTACK_MAGIC +#define INF_ANIM_JALTOK_JAD_RANGED_ATTACK INF_GEN_ANIM_JALTOK_JAD_ATTACK_RANGED +#define INF_ANIM_JALTOK_JAD_MELEE_ATTACK INF_GEN_ANIM_JALTOK_JAD_ATTACK_MELEE + +static inline int inf_jad_post_prayer_flight_ticks(int hit_delay) { + int ticks = hit_delay - INF_JAD_PROJECTILE_DELAY; + return ticks > 1 ? ticks : 1; +} + +static inline int inf_jad_land_delay_from_fire(int hit_delay) { + return INF_JAD_PROJECTILE_DELAY + inf_jad_post_prayer_flight_ticks(hit_delay); +} + +static inline int inf_jad_visible_duration_ticks(int hit_delay) { + return (inf_jad_post_prayer_flight_ticks(hit_delay) + 1) * 30; +} + +static inline int inf_jad_roll_primary_style(uint32_t* rng_state) { + return (encounter_rand_int(rng_state, 2) == 0) + ? ATTACK_STYLE_RANGED + : ATTACK_STYLE_MAGIC; +} + +static inline int inf_npc_attack_anim_id(const InfNPC* npc, const NpcModelMapping* nm) { + if (npc->type == INF_NPC_JAD) { + switch (npc->attack_style_this_tick) { + case ATTACK_STYLE_MAGIC: + return INF_ANIM_JALTOK_JAD_MAGIC_ATTACK; + case ATTACK_STYLE_RANGED: + return INF_ANIM_JALTOK_JAD_RANGED_ATTACK; + case ATTACK_STYLE_MELEE: + return INF_ANIM_JALTOK_JAD_MELEE_ATTACK; + default: + return -1; + } + } + + if (!nm || nm->attack_anim == 65535) + return -1; + return (int)nm->attack_anim; +} + +static inline int inf_choose_attack_style_for_tick( + uint32_t* rng_state, int style_mask +) { + int primary_style = inf_attack_style_from_mask(style_mask & ~INF_STYLE_MASK_MELEE); + if ((style_mask & INF_STYLE_MASK_MELEE) && primary_style != ATTACK_STYLE_NONE) + return (encounter_rand_int(rng_state, 2) == 0) ? ATTACK_STYLE_MELEE : primary_style; + return inf_attack_style_from_mask(style_mask); +} + + +static inline int inf_dead_mob_is_resurrectable(InfNPCType type) { + switch (type) { + case INF_NPC_BAT: + case INF_NPC_BLOB: + case INF_NPC_MELEER: + case INF_NPC_RANGER: + case INF_NPC_MAGER: + return 1; + default: + return 0; + } +} + +static void inf_store_dead_mob(InfernoState* s, InfNPC* npc) { + if (s->dead_mob_count >= INF_MAX_DEAD_MOBS) return; + /* only store the exact types that register with InfernoMobDeathStore in + the reference: bat, blob parent, meleer, ranger, and mager. */ + if (!inf_dead_mob_is_resurrectable(npc->type)) return; + if (npc->resurrection_count != 0) 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; +} + + +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); +static int inf_mager_resurrect(InfernoState* s, int idx); +static void inf_queue_zuk_healer_sparks(InfernoState* s, const InfNPC* npc); +static void inf_resolve_pending_sparks(InfernoState* s); +static void inf_rebuild_player_collision_flags(InfernoState* s); + + +static EncounterState* inf_create(void) { + InfernoState* s = (InfernoState*)calloc(1, sizeof(InfernoState)); + s->rng_state = 12345; + s->late_start_supply_profile_scale = 1.0f; + return (EncounterState*)s; +} + +static void inf_destroy(EncounterState* state) { + free(state); +} + +static InfSupplyDoses inf_full_starting_supplies(void) { + return (InfSupplyDoses){ + .brew_doses = 24, + .restore_doses = 40, + .bastion_doses = 8, + .stamina_doses = 4, + }; +} + +static const InfSupplyProfileAnchor INF_SUPPLY_PROFILE_ANCHORS[] = { + { 1, { 1.0000f, 1.0000f, 1.0000f, 1.0000f } }, + { 20, { 1.0000f, 0.9500f, 1.0000f, 1.0000f } }, + { 40, { 0.9167f, 0.8750f, 1.0000f, 1.0000f } }, + { 61, { 0.8333f, 0.7500f, 1.0000f, 1.0000f } }, + { 64, { 0.5833f, 0.5000f, 0.7500f, 1.0000f } }, + { 68, { 0.5833f, 0.4250f, 0.6250f, 1.0000f } }, + { 69, { 0.5000f, 0.3000f, 0.3750f, 1.0000f } }, +}; + +static float inf_lerp_float(float a, float b, float t) { + return a + (b - a) * t; +} + +static void inf_require_valid_supply_scale(float scale) { + if (scale < 0.0f || scale > 1.0f) { + fprintf(stderr, "inferno late_start_supply_profile_scale must be in [0, 1], got %.6f\n", + scale); + abort(); + } +} + +static void inf_require_valid_public_wave(int public_wave) { + if (public_wave < 1 || public_wave > INF_NUM_WAVES) { + fprintf(stderr, "inferno start_wave must be in [1, %d], got %d\n", + INF_NUM_WAVES, public_wave); + abort(); + } +} + +static int inf_public_start_wave_to_internal(int public_wave) { + if (public_wave < 0 || public_wave > INF_NUM_WAVES) { + fprintf(stderr, "inferno start_wave must be in [0, %d], got %d\n", + INF_NUM_WAVES, public_wave); + abort(); + } + return (public_wave > 0) ? public_wave - 1 : 0; +} + +static InfSupplyFractions inf_supply_profile_fractions(int public_wave) { + inf_require_valid_public_wave(public_wave); + + const int n = (int)(sizeof(INF_SUPPLY_PROFILE_ANCHORS) / + sizeof(INF_SUPPLY_PROFILE_ANCHORS[0])); + for (int i = 1; i < n; i++) { + const InfSupplyProfileAnchor* lo = &INF_SUPPLY_PROFILE_ANCHORS[i - 1]; + const InfSupplyProfileAnchor* hi = &INF_SUPPLY_PROFILE_ANCHORS[i]; + if (public_wave <= hi->public_wave) { + float t = (float)(public_wave - lo->public_wave) / + (float)(hi->public_wave - lo->public_wave); + return (InfSupplyFractions){ + .brew_fraction = inf_lerp_float(lo->fractions.brew_fraction, + hi->fractions.brew_fraction, t), + .restore_fraction = inf_lerp_float(lo->fractions.restore_fraction, + hi->fractions.restore_fraction, t), + .bastion_fraction = inf_lerp_float(lo->fractions.bastion_fraction, + hi->fractions.bastion_fraction, t), + .stamina_fraction = inf_lerp_float(lo->fractions.stamina_fraction, + hi->fractions.stamina_fraction, t), + }; + } + } + + fprintf(stderr, "inferno supply profile has no anchor for wave %d\n", public_wave); + abort(); +} + +static int inf_profiled_supply_count(int full_doses, float profile_fraction, float scale) { + assert(full_doses >= 0); + + float effective_fraction = 1.0f - scale * (1.0f - profile_fraction); + int doses = (int)((float)full_doses * effective_fraction + 0.5f); + if (doses < 0) doses = 0; + if (doses > full_doses) doses = full_doses; + return doses; +} + +static InfSupplyDoses inf_supplies_for_start_wave(InfSupplyDoses full, + int internal_start_wave, + float scale) { + inf_require_valid_supply_scale(scale); + + int public_wave = internal_start_wave + 1; + + InfSupplyFractions fractions = inf_supply_profile_fractions(public_wave); + return (InfSupplyDoses){ + .brew_doses = inf_profiled_supply_count(full.brew_doses, + fractions.brew_fraction, scale), + .restore_doses = inf_profiled_supply_count(full.restore_doses, + fractions.restore_fraction, scale), + .bastion_doses = inf_profiled_supply_count(full.bastion_doses, + fractions.bastion_fraction, scale), + .stamina_doses = inf_profiled_supply_count(full.stamina_doses, + fractions.stamina_fraction, scale), + }; +} + +static void inf_reset(EncounterState* state, uint32_t seed) { + inf_build_npc_stats(); + 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; + float saved_damage_reward_coeff = s->damage_reward_coeff; + float saved_shield_penalty_coeff = s->shield_penalty_coeff; + float saved_tag_reward_coeff = s->tag_reward_coeff; + float saved_late_start_supply_profile_scale = s->late_start_supply_profile_scale; + 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->damage_reward_coeff = saved_damage_reward_coeff; + s->shield_penalty_coeff = saved_shield_penalty_coeff; + s->tag_reward_coeff = saved_tag_reward_coeff; + s->late_start_supply_profile_scale = saved_late_start_supply_profile_scale; + + /* human click-to-move: no destination after reset */ + s->player_dest_x = -1; + s->player_dest_y = -1; + s->player_last_interaction_target_slot = -1; + s->player_last_interaction_age = 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; + osrs_item_effect_state_init(&s->player.item_effect_state); + /* start in mage gear */ + s->weapon_set = INF_GEAR_MAGE; + s->armor_tank = 0; + encounter_apply_loadout(&s->player, INF_MAGE_LOADOUT, GEAR_MAGE); + { + encounter_populate_inventory(&s->player, INF_LOADOUTS, INF_NUM_WEAPON_SETS, NULL); + + /* Ammo slot items (dragon darts, dragon arrows) should NOT appear as + swappable inventory items — in real OSRS darts live inside the + blowpipe and arrows inside dizana's quiver. Clear them here; the + equipment panel still shows the correct ammo for the active weapon. */ + for (int i = 0; i < MAX_ITEMS_PER_SLOT; i++) { + s->player.inventory[GEAR_SLOT_AMMO][i] = ITEM_NONE; + } + s->player.num_items_in_slot[GEAR_SLOT_AMMO] = 0; + } + InfSupplyDoses full_supplies = inf_full_starting_supplies(); + InfSupplyDoses start_supplies = inf_supplies_for_start_wave( + full_supplies, s->start_wave, s->late_start_supply_profile_scale); + s->player.brew_doses = start_supplies.brew_doses; + s->player.restore_doses = start_supplies.restore_doses; + s->player.bastion_doses = start_supplies.bastion_doses; + s->player.stamina_doses = start_supplies.stamina_doses; + s->stamina_active_ticks = 0; + s->player.prayer = PRAYER_NONE; + osrs_interaction_init(&s->interaction); + s->player.spec_armed = 0; + s->player.special_energy = 100; + s->player.run_energy = 10000; /* full run energy (OSRS stores as 0-10000) */ + s->last_hit_by_type = -1; + + /* compute loadout stats from item database (replaces old hardcoded INF_WEAPON_STATS). + mage is kodai + barrage — pure autocast, no invisible bonus. + ranged loadouts run in rapid stance: -1 to attack_speed (BP 3→2, tbow 6→5). */ + /* offensive prayer is now agent-controlled runtime state (Player.offensive_prayer), + not baked into the loadout. pass NONE at reset; inf_player_pretick() calls + encounter_update_loadout_level() whenever the agent toggles a prayer. */ + encounter_compute_loadout_stats(INF_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, + OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, 30, &s->loadout_stats[INF_GEAR_MAGE]); + encounter_compute_loadout_stats(INF_RANGE_TBOW_LOADOUT, ATTACK_STYLE_RANGED, + OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_RAPID, 0, &s->loadout_stats[INF_GEAR_TBOW]); + encounter_compute_loadout_stats(INF_RANGE_BP_LOADOUT, ATTACK_STYLE_RANGED, + OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_RAPID, 0, &s->loadout_stats[INF_GEAR_BP]); + + /* spawn position depends on wave */ + int is_zuk_wave = (saved_start >= INF_NUM_WAVES - 1); + 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; + inf_rebuild_player_collision_flags(s); + + /* pillars are gone after public wave 66, so public waves 67-69 start without them. */ + 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]; + if (saved_start >= 66) { + s->pillars[i].hp = 0; + s->pillars[i].active = 0; + } else { + 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_target = s->start_wave; + s->wave_spawn_delay = 10; +} + + +/* 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; +} + +static int inf_grid_index(int x, int y, int* gx, int* gy) { + *gx = x - INF_ARENA_MIN_X; + *gy = y - INF_ARENA_MIN_Y; + return *gx >= 0 && *gx < INF_ARENA_WIDTH && *gy >= 0 && *gy < INF_ARENA_HEIGHT; +} + +static int inf_npc_sets_collision_flag(InfNPCType type) { + return type != INF_NPC_NIBBLER; +} + +static int inf_npc_effective_size(const InfNPC* npc) { + return npc->size > 0 ? npc->size : INF_NPC_STATS[npc->type].size; +} + +static void inf_clear_npc_collision_footprint(InfernoState* s, int x, int y, int size) { + for (int dx = 0; dx < size; dx++) { + for (int dy = 0; dy < size; dy++) { + int gx, gy; + if (inf_grid_index(x + dx, y + dy, &gx, &gy)) + s->npc_collision_flags[gx][gy] = 0; + } + } +} + +static void inf_stamp_npc_collision_footprint(InfernoState* s, int x, int y, int size) { + for (int dx = 0; dx < size; dx++) { + for (int dy = 0; dy < size; dy++) { + int gx, gy; + if (inf_grid_index(x + dx, y + dy, &gx, &gy)) + s->npc_collision_flags[gx][gy] = 1; + } + } +} + +static void inf_clear_player_collision_flags(InfernoState* s) { + memset(s->player_collision_flags, 0, sizeof(s->player_collision_flags)); +} + +static void inf_stamp_player_collision_flags(InfernoState* s) { + int gx, gy; + if (inf_grid_index(s->player.x, s->player.y, &gx, &gy)) + s->player_collision_flags[gx][gy] = 1; +} + +static void inf_rebuild_player_collision_flags(InfernoState* s) { + inf_clear_player_collision_flags(s); + inf_stamp_player_collision_flags(s); +} + +static void inf_rebuild_entity_collision_flags(InfernoState* s) { + memset(s->npc_collision_flags, 0, sizeof(s->npc_collision_flags)); + inf_rebuild_player_collision_flags(s); + for (int i = 0; i < INF_MAX_NPCS; i++) { + InfNPC* npc = &s->npcs[i]; + if (!npc->active) continue; + if (!inf_npc_sets_collision_flag(npc->type)) continue; + inf_stamp_npc_collision_footprint(s, npc->x, npc->y, inf_npc_effective_size(npc)); + } +} + +static void inf_update_npc_collision_flags( + InfernoState* s, int idx, int ox, int oy, int nx, int ny, int sz +) { + if (idx >= 0 && idx < INF_MAX_NPCS && + !inf_npc_sets_collision_flag(s->npcs[idx].type)) + return; + inf_clear_npc_collision_footprint(s, ox, oy, sz); + inf_stamp_npc_collision_footprint(s, nx, ny, sz); +} + +static void inf_deactivate_npc(InfernoState* s, int idx) { + if (idx < 0 || idx >= INF_MAX_NPCS) return; + InfNPC* npc = &s->npcs[idx]; + if (npc->active && inf_npc_sets_collision_flag(npc->type)) + inf_clear_npc_collision_footprint(s, npc->x, npc->y, inf_npc_effective_size(npc)); + npc->active = 0; +} + +/* 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->jad_attack_style = ATTACK_STYLE_NONE; + npc->attack_style_this_tick = ATTACK_STYLE_NONE; + npc->active = 1; + npc->x = x; + npc->y = y; + npc->target_x = x; + npc->target_y = y; + npc->attack_visual_target = -1; + npc->heal_target = -1; + npc->jad_owner_idx = -1; + npc->aggro_target = -1; + npc->blob_scanned_prayer = -1; + npc->had_los_last_tick = 0; + npc->stun_timer = stats->stun_on_spawn; + + if (inf_npc_sets_collision_flag(type)) + inf_stamp_npc_collision_footprint(s, x, y, stats->size); +} + +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; + memset(s->npc_collision_flags, 0, sizeof(s->npc_collision_flags)); + inf_rebuild_player_collision_flags(s); + + /* 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; + } + + /* waves 67-69 have no pillars — ref InfernoRegion.ts:359 + (`this.wave < 67 || this.wave >= 70` means pillars exist outside this range). + clear on spawn so mid-episode transitions also collapse any survivors. */ + if (s->wave >= 66) { + int pillars_changed = 0; + for (int p = 0; p < INF_NUM_PILLARS; p++) { + if (s->pillars[p].active) { + s->pillars[p].active = 0; + s->pillars[p].hp = 0; + pillars_changed = 1; + } + } + if (pillars_changed) inf_rebuild_los(s); + } + + /* wave 67 (index 66): single jad at fixed position. ref InfernoRegion.ts:441-451. + our coord system is Y-flipped vs reference (pillars confirm ours_y = 57 - ref_y). + ref: player (18, 25), jad (23, 27), stun=1, attackSpeed=8, healers=5. */ + if (s->wave == 66) { + s->player.x = 18; + s->player.y = 32; /* 57 - 25 */ + inf_rebuild_player_collision_flags(s); + int slot = inf_find_free_npc(s); + if (slot >= 0) { + inf_init_npc(s, slot, INF_NPC_JAD, 23, 30); /* 57 - 27 = 30 */ + s->npcs[slot].stun_timer = 1; + s->npcs[slot].attack_timer = 8; + } + return; + } + + /* wave 68 (index 67): three jads at fixed positions with staggered first attacks. + ref InfernoRegion.ts:452-479. stunTimers [1,4,7] shuffled across the 3 jads so + their first attacks stagger instead of landing on the same tick. + ref: player (25, 27), jads (18, 24), (28, 24), (23, 35). attackSpeed=9, healers=3. */ + if (s->wave == 67) { + s->player.x = 25; + s->player.y = 30; /* 57 - 27 */ + inf_rebuild_player_collision_flags(s); + /* shuffle [1, 4, 7] via Fisher-Yates */ + int stuns[3] = { 1, 4, 7 }; + for (int i = 2; i > 0; i--) { + int j = encounter_rand_int(&s->rng_state, i + 1); + int tmp = stuns[i]; stuns[i] = stuns[j]; stuns[j] = tmp; + } + static const int JAD_POS[3][2] = { + {18, 33}, /* 57 - 24 */ + {28, 33}, /* 57 - 24 */ + {23, 22}, /* 57 - 35 */ + }; + for (int i = 0; i < 3; i++) { + int slot = inf_find_free_npc(s); + if (slot < 0) break; + inf_init_npc(s, slot, INF_NPC_JAD, JAD_POS[i][0], JAD_POS[i][1]); + s->npcs[slot].stun_timer = stuns[i]; + s->npcs[slot].attack_timer = 9; + } + return; + } + + /* 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, 22, 50); + s->min_zuk_hp_seen = (float)s->npcs[zuk_idx].hp; + /* InfernoTrainer: stunned=8, attackDelay=14. stun counts down first, + then attackDelay ticks down to 0 before first attack fires. */ + s->npcs[zuk_idx].stun_timer = 8; + s->npcs[zuk_idx].attack_timer = 14; + } + + /* 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; + s->zuk.timer_paused = 0; + s->zuk.has_paused = 0; + + /* player starts at zuk position */ + s->player.x = INF_ZUK_PLAYER_START_X; + s->player.y = INF_ZUK_PLAYER_START_Y; + inf_rebuild_player_collision_flags(s); + 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); + } +} + + +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); +} + +static int inf_npc_collision_flag_blocked(InfernoState* s, int x, int y, int size) { + for (int dx = 0; dx < size; dx++) { + for (int dy = 0; dy < size; dy++) { + int gx, gy; + if (inf_grid_index(x + dx, y + dy, &gx, &gy) && + s->npc_collision_flags[gx][gy]) + return 1; + } + } + return 0; +} + +static int inf_player_collision_flag_blocked(InfernoState* s, int x, int y, int size) { + for (int dx = 0; dx < size; dx++) { + for (int dy = 0; dy < size; dy++) { + int gx, gy; + if (inf_grid_index(x + dx, y + dy, &gx, &gy) && + s->player_collision_flags[gx][gy]) + return 1; + } + } + return 0; +} + +/* NPC movement blocked callback for encounter_npc_step_toward. */ +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; + (void)mc->self_idx; + 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; + if (inf_player_collision_flag_blocked(s, x, y, size)) return 1; + return inf_npc_collision_flag_blocked(s, x, y, size); +} + +static int inf_npc_overlap_hold(void* ctx) { + const InfMoveCtx* mc = (const InfMoveCtx*)ctx; + const InfernoState* s = mc->s; + return s->player_last_interaction_age == 0 && + s->player_last_interaction_target_slot == mc->self_idx; +} + +/* forward declaration — defined after potions/food section */ +static int inf_tile_walkable(void* ctx, int x, int y); + +static int inf_npc_terrain_blocked(InfernoState* s, int x, int y, int size) { + if (!inf_in_arena(x, y)) return 1; + if (inf_blocked_by_pillar(s, x, y, size)) return 1; + if (!s->collision_map) return 0; + for (int dx = 0; dx < size; dx++) { + for (int dy = 0; dy < size; dy++) { + if (!collision_tile_walkable( + s->collision_map, 0, + x + dx + s->world_offset_x, + y + dy + s->world_offset_y)) { + return 1; + } + } + } + return 0; +} + +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; + int uses_collision_flag = inf_npc_sets_collision_flag(npc->type); + if (uses_collision_flag) + inf_clear_npc_collision_footprint(s, npc->x, npc->y, npc->size); + + /* 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) { + InfMoveCtx mc = { s, idx }; + int stepped = encounter_npc_step_out_from_under( + &npc->x, &npc->y, npc->size, + s->player.x, s->player.y, + inf_npc_blocked, &mc, inf_npc_overlap_hold, &s->rng_state); + if (stepped == ENCOUNTER_NPC_UNDER_PLAYER_MOVED) { + npc->moved_this_tick = 1; + if (uses_collision_flag) + inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); + return; + } + if (stepped == ENCOUNTER_NPC_UNDER_PLAYER_HELD) { + if (uses_collision_flag) + inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); + return; + } + } + + /* target selection: pillar (nibbler), aggroed NPC (shield/jad/zuk), or player */ + int tx, ty; + int target_size = 1; + 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; } + } + target_size = INF_PILLAR_SIZE; + } else if (npc->aggro_target >= 0 && npc->aggro_target < INF_MAX_NPCS && + s->npcs[npc->aggro_target].active) { + /* targeting another NPC (set→shield, jad→shield) */ + tx = s->npcs[npc->aggro_target].x; + ty = s->npcs[npc->aggro_target].y; + target_size = s->npcs[npc->aggro_target].size; + } else { + /* default: target player. clear stale aggro if target died. */ + if (npc->aggro_target >= 0) npc->aggro_target = -1; + tx = s->player.x; + ty = s->player.y; + } + npc->target_x = tx; + npc->target_y = ty; + + /* NPCs stop moving once they can attack their current target. + reference: InfernoTrainer Unit.ts:383 canMove = !hasLOS (where + hasLOS is relative to the NPC's current aggro target). */ + if (npc->type != INF_NPC_NIBBLER) { + if (entity_has_line_of_sight( + s->los_blockers, s->los_blocker_count, + npc->x, npc->y, npc->size, + tx, ty, target_size, + stats->attack_range)) { + if (uses_collision_flag) + inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); + return; + } + } + + int ox = npc->x, oy = npc->y; + InfMoveCtx mc = { s, idx }; + encounter_npc_step_toward(&npc->x, &npc->y, tx, ty, npc->size, + target_size, stats->attack_range == 1, + inf_npc_blocked, &mc); + if (npc->x != ox || npc->y != oy) { + npc->moved_this_tick = 1; + } + if (uses_collision_flag) + inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); +} + + +/* 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: use the reference ordered landing candidates around the + player, then fall back to the default NW corner if all preferred + tiles are blocked by arena terrain/entities. */ + int ox = npc->x, oy = npc->y; + if (inf_npc_sets_collision_flag(npc->type)) + inf_clear_npc_collision_footprint(s, ox, oy, npc->size); + int candidates[5][2] = { + { s->player.x - npc->size + 1, s->player.y - npc->size + 1 }, + { s->player.x, s->player.y }, + { s->player.x - npc->size + 1, s->player.y }, + { s->player.x, s->player.y - npc->size + 1 }, + { s->player.x - 1, s->player.y - 1 }, + }; + int landing_x = candidates[4][0]; + int landing_y = candidates[4][1]; + for (int i = 0; i < 4; i++) { + if (inf_npc_terrain_blocked(s, candidates[i][0], candidates[i][1], npc->size)) + continue; + landing_x = candidates[i][0]; + landing_y = candidates[i][1]; + break; + } + npc->x = landing_x; + npc->y = landing_y; + if (inf_npc_sets_collision_flag(npc->type)) + inf_stamp_npc_collision_footprint(s, npc->x, npc->y, npc->size); + 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; + } + } +} + +static AttackStyle inf_player_equipped_attack_style(const InfernoState* s) { + uint8_t weapon = s->player.equipped[GEAR_SLOT_WEAPON]; + AttackStyle style = (AttackStyle)get_item_attack_style(weapon); + if (style == ATTACK_STYLE_MAGIC || + style == ATTACK_STYLE_RANGED || + style == ATTACK_STYLE_MELEE) { + return style; + } + return ATTACK_STYLE_RANGED; +} + +static void inf_refresh_human_loadout_stats(InfernoState* s) { + AttackStyle style = inf_player_equipped_attack_style(s); + int spell_base_damage = (style == ATTACK_STYLE_MAGIC) ? 30 : 0; + encounter_compute_player_equipped_stats( + &s->player, style, s->player.fight_style, spell_base_damage, + &s->human_loadout_stats); +} + +static const EncounterLoadoutStats* inf_current_loadout_stats(InfernoState* s) { + if (s->human_command_mode) { + inf_refresh_human_loadout_stats(s); + return &s->human_loadout_stats; + } + return &s->loadout_stats[s->weapon_set]; +} + +static int inf_player_weapon_is(const InfernoState* s, uint8_t item) { + return s->player.equipped[GEAR_SLOT_WEAPON] == item; +} + + +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; + const InfNPCStats* stats = &INF_NPC_STATS[npc->type]; + + int has_los_now = 0; + if (stats->attack_range > 1) { + has_los_now = inf_npc_has_los(s, idx); + } + + if (npc->type == INF_NPC_BLOB && + npc->blob_scanned_prayer < 0 && + has_los_now && + !npc->had_los_last_tick) { + npc->blob_scanned_prayer = (int)s->player.prayer; + /* commit to opposite of scanned prayer; random if scanned prayer is neither */ + if (s->player.prayer == PRAYER_PROTECT_MAGIC) npc->attack_style = ATTACK_STYLE_RANGED; + else if (s->player.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->had_los_last_tick = has_los_now; + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + return; + } + + npc->had_los_last_tick = has_los_now; + + /* 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; + + /* shield doesn't attack */ + if (npc->type == INF_NPC_ZUK_SHIELD) return; + + /* NPC targeting another NPC (set/jad → shield): always hit, random damage. + both healer types excluded — their heal handlers below RESTORE HP + to their target instead of damaging it. */ + if (npc->aggro_target >= 0 && npc->aggro_target < INF_MAX_NPCS + && npc->type != INF_NPC_HEALER_ZUK + && npc->type != INF_NPC_HEALER_JAD) { + InfNPC* target = &s->npcs[npc->aggro_target]; + if (target->active) { + int max_hit = osrs_npc_magic_max_hit(stats->magic_base_dmg, stats->magic_dmg_pct); + if (stats->can_melee) { + 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); + int target_hp_before = target->hp; + encounter_damage_npc(&target->hp, &target->hit_landed_this_tick, + &target->hit_damage, dmg); + if (target->type == INF_NPC_ZUK_SHIELD) { + s->shield_damage_this_tick += (float)(target_hp_before - target->hp); + } + /* shield death: redirect all NPCs targeting it to the player */ + if (target->hp <= 0 && target->type == INF_NPC_ZUK_SHIELD) { + inf_deactivate_npc(s, npc->aggro_target); + s->zuk.shield_idx = -1; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (s->npcs[i].aggro_target == npc->aggro_target) + s->npcs[i].aggro_target = -1; + } + } + npc->attacked_this_tick = 1; + npc->attack_style_this_tick = stats->can_melee + ? ATTACK_STYLE_MELEE : stats->default_style; + npc->attack_visual_target = npc->aggro_target; + npc->attack_timer = stats->attack_speed; + return; + } else { + npc->aggro_target = -1; /* target died, fall through to player attack */ + } + } + + /* 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); + inf_invalidate_los_cache(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) { + /* pillar collapse: 49 damage (observed in-game, server-side formula unknown) */ + 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: heal Zuk while it is untapped, switch to player-facing + sparks after tag. */ + if (npc->type == INF_NPC_HEALER_ZUK) { + if (npc->aggro_target >= 0) { + /* heal Zuk with a 3-tick projectile */ + int ti = npc->aggro_target; + if (ti >= 0 && ti < INF_MAX_NPCS && s->npcs[ti].active && s->npcs[ti].type == INF_NPC_ZUK) { + npc->heal_target = ti; + npc->heal_timer = 3; + npc->attack_visual_target = ti; + } + } else { + /* tagged healer: stop healing and fire the 3-spark ground pattern. */ + npc->heal_target = -1; + npc->heal_timer = 0; + npc->attack_visual_target = -1; + inf_queue_zuk_healer_sparks(s, npc); + } + npc->attacked_this_tick = 1; + npc->attack_style_this_tick = ATTACK_STYLE_MAGIC; + npc->attack_timer = stats->attack_speed; + return; + } + + /* jad healer: heals its Jad while aggro_target points at the boss, then + switches to crush melee after a player tag. */ + if (npc->type == INF_NPC_HEALER_JAD) { + int jad_idx = npc->jad_owner_idx; + if (npc->aggro_target >= 0) { + if (jad_idx >= 0 && s->npcs[jad_idx].active && + s->npcs[jad_idx].type == INF_NPC_JAD) { + /* heal Jad with the reference 3-tick delay. */ + npc->heal_target = jad_idx; + npc->heal_timer = 3; + npc->attack_visual_target = jad_idx; + } + npc->attacked_this_tick = 1; + npc->attack_timer = stats->attack_speed; + return; + } + /* if player has tagged this healer, it only attacks on cardinal melee + contact. diagonal corners do not count. */ + if (entity_has_line_of_sight( + s->los_blockers, s->los_blocker_count, + npc->x, npc->y, 1, + s->player.x, s->player.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 = inf_current_loadout_stats(s); + int def_bonus = ls->def_crush; + 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->player.prayer == PRAYER_PROTECT_MELEE); + if (prayer_matches) { dmg = 0; s->prayer_correct_this_tick = 1; } + else if (dmg > 0) { s->off_prayer_hits_this_tick++; } + encounter_damage_player(&s->player, dmg, &s->damage_received_this_tick); + npc->attack_style_this_tick = ATTACK_STYLE_MELEE; + npc->attacked_this_tick = 1; + npc->attack_visual_target = -1; + npc->attack_timer = stats->attack_speed; + } + return; + } + + /* ranged/magic NPCs need LOS, except blobs once they have already stored + a prayer scan. ref: InfernoTrainer JalAk.ts attackIfPossible(). */ + if (stats->attack_range > 1 && + !(npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) && + !has_los_now) 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: read prayer, commit to opposite style */ + npc->blob_scanned_prayer = (int)s->player.prayer; + if (s->player.prayer == PRAYER_PROTECT_MAGIC) npc->attack_style = ATTACK_STYLE_RANGED; + else if (s->player.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->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 */ + npc->blob_scanned_prayer = -1; + /* fall through to common attack code */ + } + + /* determine actual attack style */ + int actual_style = npc->attack_style; + + /* jad: use the committed preview style if present. if the preview was not + seeded, fall back to the 50/50 primary-style roll. */ + if (npc->type == INF_NPC_JAD) { + actual_style = npc->jad_attack_style; + if (actual_style == ATTACK_STYLE_NONE) + actual_style = inf_jad_roll_primary_style(&s->rng_state); + } + + /* zuk: typeless attack (not blockable by prayer). + InfernoTrainer TzKalZuk.ts: Zuk always fires at the shield if the player is + behind it (shield absorbs, 0 damage). otherwise fires at the player. + hit delay = 4 ticks (ZukWeapon: setDelay=4). */ + if (npc->type == INF_NPC_ZUK) { + int si = s->zuk.shield_idx; + int player_behind_shield = 0; + if (si >= 0 && s->npcs[si].active) { + InfNPC* shield = &s->npcs[si]; + player_behind_shield = (s->player.x >= shield->x && + s->player.x < shield->x + shield->size && + s->player.y >= 41); + } + + if (player_behind_shield) { + /* shield absorbs — Zuk fires at shield, 0 damage */ + npc->attacked_this_tick = 1; + npc->attack_style_this_tick = ATTACK_STYLE_MAGIC; + npc->attack_visual_target = si; + } else { + /* typeless hit on player — not blockable by prayer, no accuracy roll. + rolled 0..max_hit, queued at T and lands at T+4 unchanged. */ + 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) { + EncounterPendingHit* ph = &s->player_pending_hits[s->player_pending_hit_count++]; + ph->active = 1; + ph->damage = dmg; + ph->ticks_remaining = 4; + ph->attack_style = ATTACK_STYLE_NONE; /* typeless — not blockable */ + ph->check_prayer = 0; + ph->prayer_check_delay = 0; + ph->source_npc_type = npc->type; + } + s->last_hit_by_type = INF_NPC_ZUK; + npc->attacked_this_tick = 1; + npc->attack_style_this_tick = ATTACK_STYLE_MAGIC; + /* attack_visual_target = -1 (player), already default */ + } + npc->attack_timer = s->zuk.enraged ? 7 : stats->attack_speed; + return; + } + + { + int style_mask = inf_attack_style_options_mask( + s, npc, stats, actual_style, dist); + actual_style = inf_choose_attack_style_for_tick( + &s->rng_state, style_mask); + } + + if (npc->type == INF_NPC_MAGER && + actual_style == ATTACK_STYLE_MAGIC && + inf_mager_resurrect(s, idx)) { + npc->attacked_this_tick = 1; + npc->attack_style_this_tick = ATTACK_STYLE_MAGIC; + npc->attack_timer = stats->attack_speed; + return; + } + + /* 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 is_delayed_jad = (npc->type == INF_NPC_JAD && + actual_style != ATTACK_STYLE_MELEE); + int dmg = is_delayed_jad ? max_hit : encounter_rand_int(&s->rng_state, max_hit + 1); + + /* accuracy roll: NPC attack roll vs player defence roll */ + if (!is_delayed_jad) { + 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 = inf_current_loadout_stats(s); + int def_bonus = encounter_player_def_bonus( + ls->def_stab, ls->def_slash, ls->def_crush, ls->def_magic, ls->def_ranged, + actual_style, stats->melee_style); + 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 */ + } + + /* 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++; + s->attacks_by_type[npc->type]++; + + /* bat (JalMejRah): drain 3 run energy (300 internal) on every attack. + ref: OSRS wiki Jal-MejRah, InfernoTrainer JalMejRah.ts */ + if (npc->type == INF_NPC_BAT) { + s->player.run_energy -= 300; + if (s->player.run_energy < 0) s->player.run_energy = 0; + } + + if (hit_delay == 0) { + /* melee: instant damage, check prayer now */ + int prayer_matches = encounter_prayer_correct_for_style(s->player.prayer, actual_style); + if (prayer_matches) { dmg = 0; s->prayer_correct_this_tick++; s->prayer_correct_by_type[npc->type]++; } + else if (dmg > 0) { s->off_prayer_hits_this_tick++; } + s->dmg_from_type[npc->type] += (float)dmg; + if (dmg > 0) s->last_hit_by_type = npc->type; + 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->player.prayer, actual_style); + if (prayer_matches) { dmg = 0; s->prayer_correct_this_tick++; s->prayer_correct_by_type[npc->type]++; } + else if (dmg > 0) { s->off_prayer_hits_this_tick++; } + } + if (!is_delayed_jad) { + s->dmg_from_type[npc->type] += (float)dmg; + if (dmg > 0) s->last_hit_by_type = npc->type; + } + /* bat stat drain: 50% chance on successful hit when not praying protect + from missiles, drain all combat stats by 1. ref: OSRS wiki Jal-MejRah */ + if (npc->type == INF_NPC_BAT && dmg > 0 && + encounter_rand_int(&s->rng_state, 2) == 0) { + if (s->player.current_attack > 0) s->player.current_attack--; + if (s->player.current_strength > 0) s->player.current_strength--; + if (s->player.current_defence > 0) s->player.current_defence--; + if (s->player.current_ranged > 0) s->player.current_ranged--; + if (s->player.current_magic > 0) s->player.current_magic--; + } + EncounterPendingHit* ph = &s->player_pending_hits[s->player_pending_hit_count++]; + ph->active = 1; + ph->damage = dmg; + ph->ticks_remaining = is_jad + ? inf_jad_land_delay_from_fire(hit_delay) + 1 + : hit_delay; + ph->attack_style = actual_style; + ph->check_prayer = is_jad ? 1 : 0; + ph->prayer_check_delay = is_jad ? INF_JAD_PROJECTILE_DELAY + 1 : 0; + ph->source_npc_type = npc->type; + } + } + + npc->attacked_this_tick = 1; + npc->attack_style_this_tick = actual_style; + if (npc->type == INF_NPC_JAD) + npc->jad_attack_style = ATTACK_STYLE_NONE; + 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 */ + } +} + + +static int inf_find_mager_respawn_tile( + InfernoState* s, int size, int* out_x, int* out_y +) { + InfMoveCtx mc = { s, -1 }; + for (int x = 26; x < 33; x++) { + for (int y = 24; y < 37; y++) { + if (inf_npc_blocked(&mc, x, y, size)) continue; + if (encounter_entity_footprints_overlap(x, y, size, + s->player.x, s->player.y, 1)) + continue; + *out_x = x; + *out_y = y; + return 1; + } + } + + *out_x = 21; + *out_y = 22; + return !inf_npc_blocked(&mc, *out_x, *out_y, size); +} + +/* mager resurrection is only evaluated on a real magic attack opportunity, + not every NPC tick. */ +static int inf_mager_resurrect(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + if (npc->type != INF_NPC_MAGER || !npc->active) return 0; + if (s->wave >= 68) return 0; /* no resurrection during Zuk wave */ + if (npc->resurrect_cooldown > 0) return 0; + if (s->dead_mob_count == 0) return 0; + + /* 10% chance when the mager converts a ready magic attack into a respawn. */ + if (encounter_rand_int(&s->rng_state, 10) != 0) return 0; + + /* 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 0; + + int rx = 21; + int ry = 22; + if (!inf_find_mager_respawn_tile(s, INF_NPC_STATS[dm->type].size, &rx, &ry)) + return 0; + + 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; + s->npcs[slot].resurrection_count = 1; + + /* 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; + return 1; +} + + +#define INF_JAD_HEALER_MAX_SPAWN_CANDIDATES 165 + +static void inf_sample_jad_healer_spawn(InfernoState* s, const InfNPC* jad, int* out_x, int* out_y) { + int min_dx = -5; + int max_dx = 5; + int min_dy = -4; + int max_dy = 10; + if (s->wave == 68) { + min_dx = 0; + max_dx = 5; + min_dy = 5; + max_dy = 8; + } + + int offsets[INF_JAD_HEALER_MAX_SPAWN_CANDIDATES][2]; + int order[INF_JAD_HEALER_MAX_SPAWN_CANDIDATES]; + int count = 0; + for (int dx = min_dx; dx <= max_dx; dx++) { + for (int dy = min_dy; dy <= max_dy; dy++) { + offsets[count][0] = dx; + offsets[count][1] = dy; + order[count] = count; + count++; + } + } + encounter_shuffle(order, count, &s->rng_state); + + for (int i = 0; i < count; i++) { + int hx = jad->x + offsets[order[i]][0]; + int hy = jad->y + offsets[order[i]][1]; + if (encounter_entity_footprints_overlap(hx, hy, 1, jad->x, jad->y, jad->size)) + continue; + if (inf_npc_terrain_blocked(s, hx, hy, 1)) + continue; + *out_x = hx; + *out_y = hy; + return; + } + + fprintf(stderr, "FATAL: no valid Jad healer spawn tile for Jad at (%d,%d) on wave %d\n", + jad->x, jad->y, s->wave + 1); + abort(); +} + +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; + int hy = npc->y; + inf_sample_jad_healer_spawn(s, npc, &hx, &hy); + inf_init_npc(s, slot, INF_NPC_HEALER_JAD, hx, hy); + s->npcs[slot].jad_owner_idx = idx; + s->npcs[slot].aggro_target = idx; + } +} + + +static void inf_zuk_tick(InfernoState* s) { + if (!inf_is_final_wave(s)) return; + + /* find zuk NPC */ + int zuk_idx = inf_find_live_zuk_idx(s); + 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 { + int ox = shield->x; + int oy = shield->y; + inf_clear_npc_collision_footprint(s, ox, oy, shield->size); + 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; + } + inf_stamp_npc_collision_footprint(s, shield->x, shield->y, shield->size); + } + } + + /* set timer pause: freeze at HP < 600, resume at HP < 480 with +175 ticks */ + if (!s->zuk.has_paused && zuk->hp < 600) { + s->zuk.timer_paused = 1; + s->zuk.has_paused = 1; + } + + /* jad spawn at HP < 480 (with shield alive, timer paused) */ + if (!s->zuk.jad_spawned && s->zuk.timer_paused && zuk->hp < 480 && + si >= 0 && s->npcs[si].active) { + s->zuk.jad_spawned = 1; + s->zuk.timer_paused = 0; + s->zuk.set_timer += 175; + int j_slot = inf_find_free_npc(s); + if (j_slot >= 0) { + inf_init_npc(s, j_slot, INF_NPC_JAD, 24, 32); + s->npcs[j_slot].aggro_target = si; /* target shield */ + s->npcs[j_slot].stun_timer = 7; /* spawn delay */ + } + } + + /* set timer: spawns JalZek + JalXil targeting the shield */ + if (!s->zuk.timer_paused) { + if (s->zuk.set_timer > 0) { + s->zuk.set_timer--; + } else { + int m_slot = inf_find_free_npc(s); + if (m_slot >= 0) { + inf_init_npc(s, m_slot, INF_NPC_MAGER, 20, 36); + if (si >= 0) s->npcs[m_slot].aggro_target = si; + s->npcs[m_slot].stun_timer = 7; /* spawn delay */ + } + int r_slot = inf_find_free_npc(s); + if (r_slot >= 0) { + inf_init_npc(s, r_slot, INF_NPC_RANGER, 29, 36); + if (si >= 0) s->npcs[r_slot].aggro_target = si; + s->npcs[r_slot].stun_timer = 9; /* spawn delay */ + } + s->zuk.set_timer = s->zuk.set_interval; + } + } + + /* healer spawn at HP < 240: 4 JalMejJak targeting Zuk, 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]); + s->npcs[slot].aggro_target = zuk_idx; /* heal Zuk until tagged */ + } + } + } + + /* on zuk death: all other mobs die */ + if (zuk->hp <= 0) { + for (int i = 0; i < INF_MAX_NPCS; i++) { + inf_deactivate_npc(s, i); + } + } +} + +static void inf_healer_apply_landed_heal(InfernoState* s, int idx) { + InfNPC* npc = &s->npcs[idx]; + int target_idx = npc->heal_target; + int heal_cap = (npc->type == INF_NPC_HEALER_ZUK) ? 25 : 20; + + npc->heal_target = -1; + if (target_idx < 0 || target_idx >= INF_MAX_NPCS) return; + if (!s->npcs[target_idx].active) return; + + int heal = encounter_rand_int(&s->rng_state, heal_cap); + int before = s->npcs[target_idx].hp; + s->npcs[target_idx].hp += heal; + if (s->npcs[target_idx].hp > s->npcs[target_idx].max_hp) + s->npcs[target_idx].hp = s->npcs[target_idx].max_hp; + /* effective heal (clamped at max_hp) — feeds hp_restored_this_tick so the + agent loses reward on ticks where its damage is being undone. */ + s->hp_restored_this_tick += (float)(s->npcs[target_idx].hp - before); +} + +static void inf_queue_pending_spark( + InfernoState* s, int src_x, int src_y, int x, int y, int damage, int ticks_remaining +) { + for (int i = 0; i < INF_MAX_PENDING_SPARKS; i++) { + if (s->pending_sparks[i].active) continue; + s->pending_sparks[i].active = 1; + s->pending_sparks[i].src_x = src_x; + s->pending_sparks[i].src_y = src_y; + s->pending_sparks[i].x = x; + s->pending_sparks[i].y = y; + s->pending_sparks[i].damage = damage; + s->pending_sparks[i].ticks_remaining = ticks_remaining; + s->pending_sparks[i].visual_emitted = 0; + return; + } +} + +static void inf_queue_zuk_healer_sparks(InfernoState* s, const InfNPC* npc) { + int clamped_x = s->player.x; + if (clamped_x < npc->x - 5) clamped_x = npc->x - 5; + if (clamped_x > npc->x + 4) clamped_x = npc->x + 4; + + /* InfernoTrainer JalMejJak AoeWeapon: 2 random sparks target y=14+rand(0..3) + in reference coords. our coord system is Y-flipped (ours_y = 57 - ref_y), + so ref y=14..17 maps to ours y=40..43 (near player/zuk band). */ + inf_queue_pending_spark(s, npc->x, npc->y, clamped_x, s->player.y, + 5 + encounter_rand_int(&s->rng_state, 6), 4); + inf_queue_pending_spark(s, + npc->x, npc->y, + npc->x + encounter_rand_int(&s->rng_state, 11) - 5, + 40 + encounter_rand_int(&s->rng_state, 4), + 5 + encounter_rand_int(&s->rng_state, 6), 4); + inf_queue_pending_spark(s, + npc->x, npc->y, + npc->x + encounter_rand_int(&s->rng_state, 11) - 5, + 40 + encounter_rand_int(&s->rng_state, 4), + 5 + encounter_rand_int(&s->rng_state, 6), 4); +} + +static void inf_resolve_pending_sparks(InfernoState* s) { + for (int i = 0; i < INF_MAX_PENDING_SPARKS; i++) { + if (!s->pending_sparks[i].active) continue; + s->pending_sparks[i].ticks_remaining--; + if (s->pending_sparks[i].ticks_remaining > 0) continue; + + if (encounter_entity_footprints_overlap( + s->pending_sparks[i].x - 1, s->pending_sparks[i].y - 1, 3, + s->player.x, s->player.y, 1)) { + encounter_damage_player(&s->player, s->pending_sparks[i].damage, + &s->damage_received_this_tick); + s->last_hit_by_type = INF_NPC_HEALER_ZUK; + } + s->pending_sparks[i].active = 0; + } +} + + +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; + + if ((s->npcs[i].type == INF_NPC_HEALER_JAD || s->npcs[i].type == INF_NPC_HEALER_ZUK) && + s->npcs[i].death_ticks == 0 && s->npcs[i].heal_timer > 0) { + s->npcs[i].heal_timer--; + if (s->npcs[i].heal_timer == 0) + inf_healer_apply_landed_heal(s, i); + } + + /* 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) inf_deactivate_npc(s, i); + 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--; + + if (s->npcs[i].type == INF_NPC_MAGER && s->npcs[i].resurrect_cooldown > 0) + s->npcs[i].resurrect_cooldown--; + + /* 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); + + /* jad healer spawning */ + if (s->npcs[i].type == INF_NPC_JAD) + inf_jad_check_healers(s, i); + } +} + + +#define INF_HEAD_MOVE 0 /* 25: idle + 8 walk + 16 run */ +#define INF_HEAD_PRAYER 1 /* 4: no_change, toggle_melee, toggle_ranged, toggle_magic (ENCOUNTER_OVERHEAD_DIM_PVE) */ +#define INF_HEAD_TARGET 2 /* INF_OBS_NPCS+1: none or observation slot */ +#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_change, toggle (arm/disarm blowpipe spec) */ +#define INF_HEAD_OFFENSIVE 8 /* 4: no_change, toggle_piety, toggle_rigour, toggle_augury (ENCOUNTER_OFFENSIVE_DIM) */ +static const int INF_ACTION_DIMS[INF_NUM_ACTION_HEADS] = { + ENCOUNTER_MOVE_ACTIONS, ENCOUNTER_OVERHEAD_DIM_PVE, INF_OBS_NPCS+1, 5, 2, 4, 3, 2, ENCOUNTER_OFFENSIVE_DIM +}; +#define INF_ACTION_MASK_SIZE (ENCOUNTER_MOVE_ACTIONS + ENCOUNTER_OVERHEAD_DIM_PVE + INF_OBS_NPCS+1 + 5 + 2 + 4 + 3 + 2 + ENCOUNTER_OFFENSIVE_DIM) + +/* 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) + if (!collision_tile_walkable(s->collision_map, 0, + x + s->world_offset_x, y + s->world_offset_y)) + return 0; + return !inf_npc_collision_flag_blocked(s, x, y, 1); +} + +/* sara brew heal at base HP 99: floor(99*0.15)+2 = 16. ref: osrs_consumables.h osrs_brew_effect */ +#define INF_BREW_HEAL (99 * 15 / 100 + 2) +/* super restore at base prayer 99: 8 + floor(99/4) = 32. ref: osrs_consumables.h osrs_drink_potion */ +#define INF_RESTORE_AMOUNT (8 + 99 / 4) + +/* 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) { + inf_deactivate_npc(s, j); + } + } + } +} + +static void inf_player_pretick(InfernoState* s, const int* actions) { + /* apply prayer actions. each helper returns 1 on OFF→ON transition so we + can skip that slot's drain this tick (wiki: no drain on activation tick). */ + OffensivePrayer prev_offensive = s->player.offensive_prayer; + if (encounter_apply_overhead_action(&s->player.prayer, actions[INF_HEAD_PRAYER])) { + s->player.prayer_just_activated = 1; + } + if (encounter_apply_offensive_action(&s->player.offensive_prayer, actions[INF_HEAD_OFFENSIVE])) { + s->player.offensive_prayer_just_activated = 1; + } + /* inferno loadouts have ~0 prayer bonus (armadyl/ancestral/torva); pass 0. + drain can also clear offensive_prayer (pp<=0 auto-clear), so recompute + AFTER drain to catch both the apply-side change and the drain-side clear. */ + encounter_drain_all_prayers(&s->player, 0); + /* offensive prayer is baked into eff_level/max_hit via the loadout cache. + recompute all loadouts on any change so combat math reflects current state. */ + if (s->player.offensive_prayer != prev_offensive) { + encounter_recompute_loadout_max_hits(s->loadout_stats, INF_NUM_WEAPON_SETS, &s->player); + if (s->human_command_mode) + inf_refresh_human_loadout_stats(s); + } +} + +static FightStyle inf_default_fight_style_for_style(AttackStyle style) { + if (style == ATTACK_STYLE_MAGIC) return FIGHT_STYLE_AUTOCAST; + if (style == ATTACK_STYLE_RANGED) return FIGHT_STYLE_RAPID; + return FIGHT_STYLE_ACCURATE; +} + +static void inf_note_human_weapon_set(InfernoState* s) { + uint8_t weapon = s->player.equipped[GEAR_SLOT_WEAPON]; + for (int g = 0; g < INF_NUM_WEAPON_SETS; g++) { + if (INF_LOADOUTS[g][GEAR_SLOT_WEAPON] == weapon) { + s->weapon_set = (InfWeaponSet)g; + return; + } + } +} + +static void inf_apply_human_player_commands(InfernoState* s) { + int did_change_equipment = 0; + for (int i = 0; i < s->human_command_count; i++) { + const HumanCommand* cmd = &s->human_commands[i]; + if (cmd->kind == HUMAN_COMMAND_EQUIP_INVENTORY_ITEM) { + if (cmd->gear_slot >= 0 && cmd->gear_slot < NUM_GEAR_SLOTS && + cmd->item_db_idx >= 0 && cmd->item_db_idx < NUM_ITEMS) { + int changed = slot_equip_item(&s->player, cmd->gear_slot, (uint8_t)cmd->item_db_idx); + if (changed) { + s->total_gear_switches++; + did_change_equipment = 1; + if (cmd->gear_slot == GEAR_SLOT_WEAPON) { + AttackStyle style = inf_player_equipped_attack_style(s); + s->player.fight_style = inf_default_fight_style_for_style(style); + inf_note_human_weapon_set(s); + } + } + } + } else if (cmd->kind == HUMAN_COMMAND_FIGHT_STYLE) { + if (cmd->fight_style >= FIGHT_STYLE_ACCURATE && + cmd->fight_style <= FIGHT_STYLE_DEFENSIVE_AUTOCAST) { + s->player.fight_style = (FightStyle)cmd->fight_style; + did_change_equipment = 1; + } + } + } + if (did_change_equipment) + inf_refresh_human_loadout_stats(s); +} + +static void inf_tick_player(InfernoState* s, const int* actions) { + if (s->player_last_interaction_age == 0) + s->player_last_interaction_age = 1; + + if (s->human_command_mode) { + inf_apply_human_player_commands(s); + } else { + int gear_act = actions[INF_HEAD_GEAR]; + if (gear_act >= 1) s->total_gear_switches++; + if (gear_act >= 1 && gear_act <= 3) { + 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) { + s->armor_tank = 0; + } + { + 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); + + /* spec toggle: arm/disarm (does NOT interrupt interaction) */ + if (actions[INF_HEAD_SPEC] == 1) + osrs_spec_toggle(&s->player.spec_armed); + + /* stat decay: every 60 ticks, boosted/drained stats move 1 toward base */ + 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 }; + for (int si = 0; si < 5; si++) { + if (*stats[si] > 99) (*stats[si])--; + else if (*stats[si] < 99) (*stats[si])++; + } + encounter_recompute_loadout_max_hits(s->loadout_stats, INF_NUM_WEAPON_SETS, &s->player); + if (s->human_command_mode) + inf_refresh_human_loadout_stats(s); + } + + /* 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 + INF_BREW_HEAL) + s->player.current_hitpoints = s->player.base_hitpoints + INF_BREW_HEAL; + s->player.brew_doses--; + s->player.potion_timer = 3; + s->player.ate_food_this_tick = 1; + s->brewed_this_tick = 1; + encounter_brew_drain_stats(&s->player); + encounter_recompute_loadout_max_hits(s->loadout_stats, INF_NUM_WEAPON_SETS, &s->player); + if (s->human_command_mode) + inf_refresh_human_loadout_stats(s); + } + + /* 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: restores prayer + all combat stats */ + s->player.current_prayer += INF_RESTORE_AMOUNT; + if (s->player.current_prayer > s->player.base_prayer) + s->player.current_prayer = s->player.base_prayer; + encounter_restore_stats(&s->player); + s->player.restore_doses--; + s->player.potion_timer = 3; + encounter_recompute_loadout_max_hits(s->loadout_stats, INF_NUM_WEAPON_SETS, &s->player); + if (s->human_command_mode) + inf_refresh_human_loadout_stats(s); + } else if (pot_act == 2 && s->player.bastion_doses > 0 && s->player.potion_timer == 0) { + encounter_bastion_boost(&s->player); + s->player.bastion_doses--; + s->player.potion_timer = 3; + encounter_recompute_loadout_max_hits(s->loadout_stats, INF_NUM_WEAPON_SETS, &s->player); + if (s->human_command_mode) + inf_refresh_human_loadout_stats(s); + } 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; + } + + /* inventory actions interrupt interaction (per OSRS entity interaction rules) */ + if (eat_act > 0) + osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_EAT); + if (pot_act > 0) + osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_DRINK); + + /* 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_OBS_NPCS) { + int obs_idx = target - 1; + int npc_idx = s->current_obs_slots[obs_idx]; + if (npc_idx >= 0 && npc_idx < INF_MAX_NPCS && + s->npcs[npc_idx].active && s->npcs[npc_idx].death_ticks == 0 && + s->npcs[npc_idx].type != INF_NPC_ZUK_SHIELD) { + osrs_interaction_set(&s->interaction, npc_idx); + s->player_last_interaction_target_slot = npc_idx; + s->player_last_interaction_age = 0; + 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) + osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_MOVE); + /* clear target if NPC died or is dying */ + if (osrs_interaction_active(&s->interaction) && + (!s->npcs[s->interaction.target_slot].active || + s->npcs[s->interaction.target_slot].death_ticks > 0)) { + osrs_interaction_clear(&s->interaction); + } + + /* movement: explicit move, auto-chase toward target, or idle. + OSRS order: target selection → movement → attack check. */ + if (has_explicit_move && !osrs_interaction_active(&s->interaction)) { + /* 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, + INF_ARENA_MIN_X, INF_ARENA_MIN_Y, INF_ARENA_WIDTH, INF_ARENA_HEIGHT); + } 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 (osrs_interaction_active(&s->interaction)) { + /* auto-chase: pathfind toward attack target when out of range */ + InfNPC* chase_npc = &s->npcs[s->interaction.target_slot]; + const EncounterLoadoutStats* ls = inf_current_loadout_stats(s); + 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, + INF_ARENA_MIN_X, INF_ARENA_MIN_Y, INF_ARENA_WIDTH, INF_ARENA_HEIGHT); + } + inf_rebuild_player_collision_flags(s); + + /* player attacks targeted NPC */ + if (s->player.attack_timer > 0) s->player.attack_timer--; + if (osrs_interaction_active(&s->interaction) && s->player.attack_timer == 0) { + InfNPC* target_npc = &s->npcs[s->interaction.target_slot]; + if (target_npc->active) { + const EncounterLoadoutStats* ls = inf_current_loadout_stats(s); + + /* 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); + + /* magic level gate: can't cast a barrage below its required level. + real OSRS greys out the spell button; here we skip the entire attack + so no damage lands, attack_timer does not reset, and the agent retries + next tick (or picks a different spell). */ + int is_magic_attack = (ls->style == ATTACK_STYLE_MAGIC); + int weapon_is_blowpipe = inf_player_weapon_is(s, ITEM_TOXIC_BLOWPIPE); + int weapon_is_tbow = inf_player_weapon_is(s, ITEM_TWISTED_BOW); + int mage_blocked = is_magic_attack && + (s->player.current_magic < ((s->spell_choice == ENCOUNTER_SPELL_ICE) + ? ICE_BARRAGE_LEVEL : BLOOD_BARRAGE_LEVEL)); + + if (!mage_blocked && 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 (weapon_is_blowpipe) + hit_delay = encounter_blowpipe_hit_delay(target_dist, 1); + else + hit_delay = encounter_ranged_hit_delay(target_dist, 1); + + int total_dmg = 0; + + if (is_magic_attack) { + /* 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). */ + OsrsTargetRef target_ref = { + .kind = OSRS_TARGET_NPC, + .id = s->interaction.target_slot, + }; + OsrsMagicAttackKind magic_kind = (s->spell_choice == ENCOUNTER_SPELL_ICE) + ? OSRS_MAGIC_ATTACK_ANCIENT_ICE + : OSRS_MAGIC_ATTACK_ANCIENT_BLOOD; + OsrsPreparedAttackEffects attack_effects = osrs_prepare_attack_effects( + &s->player.equipment_effect_profile, + &s->player.item_effect_state, + s->player.equipped[GEAR_SLOT_WEAPON], + ATTACK_STYLE_MAGIC, + magic_kind, + target_ref, + 1, + ls->eff_level * (ls->attack_bonus + 64), + ls->max_hit, + 0, + 0, + s->player.current_hitpoints, + s->player.base_hitpoints + ); + + /* 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->interaction.target_slot, + .frozen_ticks = &s->npcs[s->interaction.target_slot].frozen_ticks, + .hit = 0, .damage = 0 + }; + } + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (i == s->interaction.target_slot || !s->npcs[i].active) continue; + /* shield is invulnerable — no damage, no freeze from AoE */ + if (s->npcs[i].type == INF_NPC_ZUK_SHIELD) continue; + /* skip dying NPCs (2-tick death anim window) — already logged as killed */ + if (s->npcs[i].death_ticks > 0) 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, attack_effects.attack_roll, attack_effects.max_hit, + &s->rng_state, s->spell_choice, attack_effects.use_double_accuracy); + total_dmg = br.total_damage; + osrs_finalize_attack_effects( + &s->player.equipment_effect_profile, + &s->player.item_effect_state, + s->player.equipped[GEAR_SLOT_WEAPON], + ATTACK_STYLE_MAGIC, + magic_kind, + target_ref, + 1, + attack_effects.use_double_accuracy, + btargets[0].hit, + btargets[0].damage, + &s->rng_state + ); + + /* 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 (weapon_is_tbow) { + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + OsrsPreparedAttackEffects attack_effects = osrs_prepare_attack_effects( + &s->player.equipment_effect_profile, + &s->player.item_effect_state, + s->player.equipped[GEAR_SLOT_WEAPON], + ATTACK_STYLE_RANGED, + OSRS_MAGIC_ATTACK_NONE, + (OsrsTargetRef){ .kind = OSRS_TARGET_NPC, .id = s->interaction.target_slot }, + 1, + ls->eff_level * (ls->attack_bonus + 64), + ls->max_hit, + ns->magic_level, + ns->magic_att_bonus, + s->player.current_hitpoints, + s->player.base_hitpoints + ); + int def_roll = (ns->def_level + 8) * (ns->ranged_def_bonus + 64); + if (encounter_rand_float(&s->rng_state) < osrs_hit_chance(attack_effects.attack_roll, def_roll)) { + total_dmg = encounter_rand_int(&s->rng_state, attack_effects.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 if (weapon_is_blowpipe && s->player.spec_armed && + encounter_use_spec(&s->player, BLOWPIPE_SPEC_COST)) { + /* blowpipe spec: 2x accuracy, 1.5x max hit, heal 50% of damage */ + osrs_spec_disarm(&s->player.spec_armed); + const InfNPCStats* ns = &INF_NPC_STATS[target_npc->type]; + int base_att_roll = ls->eff_level * (ls->attack_bonus + 64); + total_dmg = osrs_blowpipe_spec_resolve( + base_att_roll, ls->max_hit, + ns->def_level, ns->ranged_def_bonus, &s->rng_state); + int heal = total_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; + 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, normal attack */ + 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->interaction.target_slot; + 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 (ls->style == ATTACK_STYLE_MAGIC) { + /* 0=none, 1=ice, 2=blood */ + s->player.magic_type_this_tick = (s->spell_choice == ENCOUNTER_SPELL_ICE) ? 1 : 2; + } + } + } + } +} + +static int inf_roll_delayed_jad_damage(InfernoState* s, int attack_style) { + const InfNPCStats* stats = &INF_NPC_STATS[INF_NPC_JAD]; + int max_hit = osrs_npc_max_hit(attack_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); + int att_lvl, att_bonus; + if (attack_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 = inf_current_loadout_stats(s); + int def_bonus = encounter_player_def_bonus( + ls->def_stab, ls->def_slash, ls->def_crush, ls->def_magic, ls->def_ranged, + attack_style, stats->melee_style); + int def_roll = osrs_player_def_roll_vs_npc( + s->player.current_defence, s->player.current_magic, def_bonus, attack_style); + if (encounter_rand_float(&s->rng_state) >= osrs_hit_chance(att_roll, def_roll)) + dmg = 0; + return dmg; +} + +static void inf_apply_delayed_prayer_check(InfernoState* s, EncounterPendingHit* hit) { + if (encounter_prayer_correct_for_style(s->player.prayer, hit->attack_style)) { + hit->damage = 0; + s->prayer_correct_this_tick++; + if (hit->source_npc_type >= 0 && hit->source_npc_type < INF_NUM_NPC_TYPES) + s->prayer_correct_by_type[hit->source_npc_type]++; + } else if (hit->source_npc_type == INF_NPC_JAD) { + hit->damage = inf_roll_delayed_jad_damage(s, hit->attack_style); + s->dmg_from_type[INF_NPC_JAD] += (float)hit->damage; + if (hit->damage > 0) { + s->last_hit_by_type = INF_NPC_JAD; + s->off_prayer_hits_this_tick++; + } + } else if (hit->damage > 0 && hit->attack_style != ATTACK_STYLE_NONE) { + s->off_prayer_hits_this_tick++; + } + hit->check_prayer = 0; +} + +static void inf_resolve_player_pending_hits(InfernoState* s) { + for (int i = 0; i < s->player_pending_hit_count; i++) { + EncounterPendingHit* hit = &s->player_pending_hits[i]; + + if (hit->check_prayer && hit->prayer_check_delay > 0 && + hit->source_npc_type != INF_NPC_JAD) { + hit->prayer_check_delay--; + if (hit->prayer_check_delay == 0) { + inf_apply_delayed_prayer_check(s, hit); + } + } + + hit->ticks_remaining--; + if (hit->ticks_remaining <= 0) { + int dmg = hit->damage; + if (hit->check_prayer) { + if (encounter_prayer_correct_for_style(s->player.prayer, hit->attack_style)) { + dmg = 0; + s->prayer_correct_this_tick++; + if (hit->source_npc_type >= 0 && hit->source_npc_type < INF_NUM_NPC_TYPES) + s->prayer_correct_by_type[hit->source_npc_type]++; + } else if (dmg > 0 && hit->attack_style != ATTACK_STYLE_NONE) { + s->off_prayer_hits_this_tick++; + } + } else if (dmg > 0 && hit->attack_style != ATTACK_STYLE_NONE && + hit->source_npc_type != INF_NPC_JAD) { + s->off_prayer_hits_this_tick++; + } + + encounter_damage_player( + &s->player, dmg, &s->damage_received_this_tick); + s->player_pending_hits[i] = + s->player_pending_hits[--s->player_pending_hit_count]; + i--; + } + } +} + +static void inf_resolve_jad_prayer_checks_after_player(InfernoState* s) { + for (int i = 0; i < s->player_pending_hit_count; i++) { + EncounterPendingHit* hit = &s->player_pending_hits[i]; + if (!hit->check_prayer || + hit->source_npc_type != INF_NPC_JAD || + hit->prayer_check_delay <= 0) { + continue; + } + hit->prayer_check_delay--; + if (hit->prayer_check_delay == 0) + inf_apply_delayed_prayer_check(s, hit); + } +} + + +static int inf_healer_is_actively_healing(const InfernoState* s, const InfNPC* npc) { + if (!npc->active || npc->death_ticks > 0) return 0; + if (npc->type != INF_NPC_HEALER_JAD && npc->type != INF_NPC_HEALER_ZUK) return 0; + if (npc->aggro_target < 0 || npc->aggro_target >= INF_MAX_NPCS) return 0; + return s->npcs[npc->aggro_target].active; +} + +static float inf_compute_reward(InfernoState* s) { + s->total_damage_dealt += s->damage_dealt_this_tick; + s->total_zuk_healer_damage += s->damage_zuk_healers_this_tick; + s->total_damage_received += s->damage_received_this_tick; + s->total_hp_restored += s->hp_restored_this_tick; + + if (s->episode_over) + return (s->winner == 0) ? 1.0f : 0.0f; + + int healer_is_actively_healing = 0; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (inf_healer_is_actively_healing(s, &s->npcs[i])) { + healer_is_actively_healing = 1; + break; + } + } + + float reward = 0.0f; + if (healer_is_actively_healing) { + reward = s->tag_reward_coeff * (float)s->healer_tags_this_tick; + } else if (inf_is_final_wave(s)) { + int zuk_idx = inf_find_live_zuk_idx(s); + if (zuk_idx >= 0) { + float zuk_hp = (float)s->npcs[zuk_idx].hp; + if (zuk_hp < s->min_zuk_hp_seen) { + reward = s->damage_reward_coeff * (s->min_zuk_hp_seen - zuk_hp); + s->min_zuk_hp_seen = zuk_hp; + } + } + } else { + reward = s->damage_reward_coeff * + fmaxf(0.0f, s->damage_dealt_this_tick - s->hp_restored_this_tick); + } + reward -= s->shield_penalty_coeff * s->shield_damage_this_tick; + return reward; +} + + +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_zuk_healers_this_tick = 0.0f; + s->shield_damage_this_tick = 0.0f; + s->healer_tags_this_tick = 0; + s->damage_received_this_tick = 0.0f; + s->hp_restored_this_tick = 0.0f; + s->prayer_correct_this_tick = 0; + s->off_prayer_hits_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; + s->behind_shield_this_tick = 0; + encounter_clear_tick_flags(&s->player); + /* 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].attack_style_this_tick = ATTACK_STYLE_NONE; + s->npcs[i].attack_visual_target = -1; + 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++; + inf_player_pretick(s, actions); + + /* inter-wave countdowns resolve before the player phase, but the queued wave + does not spawn until the END of the tick. */ + int spawn_wave_now = 0; + if (s->wave_spawn_delay > 0) { + s->wave_spawn_delay--; + if (s->wave_spawn_delay == 0) + spawn_wave_now = 1; + } + int in_wave_gap = (s->wave_spawn_delay > 0 || spawn_wave_now); + /* OSRS-style tick order: NPCs move/attack first, then projectile landings, + then the player's movement/attack phase. */ + if (!in_wave_gap) { + inf_rebuild_player_collision_flags(s); + inf_invalidate_los_cache(s); + inf_tick_npcs(s); + } + + { + 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; + float dmg_before = s->damage_dealt_this_tick; + 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) { + float landed_damage = s->damage_dealt_this_tick - dmg_before; + if (s->npcs[i].type == INF_NPC_HEALER_ZUK) { + s->damage_zuk_healers_this_tick += landed_damage; + } + s->npcs[i].hit_spell_type = spell; + /* tagging: aggro switches on projectile LAND, not FIRE. matches + osrs-sdk Unit.ts:645 shouldChangeAggro, called post-damage. + without this, healers/set-spawns clear aggro on the fire tick + and never land a heal/shield-hit before switching to player. */ + if (s->npcs[i].aggro_target != -1) { + if (s->npcs[i].type == INF_NPC_HEALER_ZUK + || s->npcs[i].type == INF_NPC_HEALER_JAD) { + s->healer_tags_this_tick++; + } + s->npcs[i].aggro_target = -1; + s->npcs[i].stun_timer = 2; /* flinch: 2-tick delay on aggro switch */ + } + 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; + } + } + + inf_resolve_player_pending_hits(s); + inf_resolve_pending_sparks(s); + + /* if npc damage killed the player, stop the tick here — a corpse can't + eat, attack, or move. without this check the hp-clamp-at-0 in + encounter_damage_player combined with a subsequent brew in + inf_tick_player would resurrect lethal hits (observed: zuk 140 lands + on 115 hp → clamp to 0 → brew +16 → alive at 16). the episode_return + stays valid because inf_compute_reward's episode_over branch returns + the end-of-episode shape and no further damage is accrued. */ + if (s->player.current_hitpoints <= 0) { + if (s->last_hit_by_type >= 0 && s->last_hit_by_type < INF_NUM_NPC_TYPES) + s->killed_by_type[s->last_hit_by_type]++; + s->episode_over = 1; + s->winner = 1; + s->reward = 0.0f; + return; + } + + /* player actions */ + inf_tick_player(s, actions); + inf_resolve_jad_prayer_checks_after_player(s); + + /* idle penalty counter: consecutive ticks where player could attack but didn't */ + { + int has_alive_npc = 0; + if (!in_wave_gap) { + 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].aggro_target < 0 && s->npcs[i].type != INF_NPC_NIBBLER && !(s->npcs[i].type == INF_NPC_BLOB && s->npcs[i].blob_scanned_prayer >= 0)) + 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; + + /* Zuk shield tracking: are we behind the shield this tick? */ + if (!in_wave_gap && s->wave == 68) { + s->total_zuk_ticks++; + int si = s->zuk.shield_idx; + if (si >= 0 && s->npcs[si].active) { + int sx = s->npcs[si].x; + int sz = INF_NPC_STATS[INF_NPC_ZUK_SHIELD].size; + if (s->player.x >= sx && s->player.x < sx + sz && s->player.y >= 41) { + s->behind_shield_ticks++; + s->behind_shield_this_tick = 1; + } + } + } + + /* action noop tracking */ + s->action_total_count++; + for (int h = 0; h < INF_NUM_ACTION_HEADS; h++) { + if (actions[h] == 0) s->action_noop_count[h]++; + } + + /* bank the tick's irreversible HP progress before computing reward. all + damage landings and healer applies have resolved by this point. */ + s->reward = inf_compute_reward(s); + s->episode_return += s->reward; + + /* check player death */ + if (s->player.current_hitpoints <= 0) { + if (s->last_hit_by_type >= 0 && s->last_hit_by_type < INF_NUM_NPC_TYPES) + s->killed_by_type[s->last_hit_by_type]++; + s->episode_over = 1; + s->winner = 1; + /* terminal reward: override the per-tick reward already computed above. + do NOT call inf_compute_reward again (would double-count damage stats). */ + s->reward = 0.0f; /* player died. Do not negative reward to avoid stalling.*/ + return; + } + + if (spawn_wave_now) { + s->wave = s->wave_spawn_target; + inf_spawn_wave(s); + inf_invalidate_los_cache(s); + return; + } + if (s->wave_spawn_delay > 0) 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_completed_this_tick = 1; + s->total_waves_cleared++; + s->reward = 1.0f; + s->episode_return += 1.0f; + if (s->wave + 1 >= INF_NUM_WAVES) { + s->episode_over = 1; + s->winner = 0; + } else { + s->wave_spawn_target = s->wave + 1; + s->wave_spawn_delay = 9; + } + } + + /* timeout — zero reward so agent isn't rewarded/penalized for running out of time */ + if (s->tick >= INF_MAX_TICKS) { + s->episode_over = 1; + s->winner = 1; + s->reward = 0.0f; + } +} + + +/* obs layout: 49 player + 12 pillar + 33*32 NPC + 5*8 pending hits = 1157 */ +#define INF_PLAYER_OBS_SIZE 52 /* +3 for offensive prayer one-hot (piety/rigour/augury) */ +#define INF_TOTAL_NPC_OBS_SIZE 282 +#define INF_FEATURES_PER_HIT 5 +#define INF_NUM_OBS (INF_PLAYER_OBS_SIZE + 12 + INF_TOTAL_NPC_OBS_SIZE + 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; + const EncounterLoadoutStats* ls = inf_current_loadout_stats(s); + InfSupplyDoses full_supplies = inf_full_starting_supplies(); + + /* 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; /* dist to west wall */ + obs[i++] = (float)(INF_ARENA_MAX_X - px) / (float)INF_ARENA_WIDTH; /* dist to east wall */ + obs[i++] = (float)(py - INF_ARENA_MIN_Y) / (float)INF_ARENA_HEIGHT; /* dist to south wall */ + obs[i++] = (float)(INF_ARENA_MAX_Y - py) / (float)INF_ARENA_HEIGHT; /* dist to north wall */ + obs[i++] = (s->player.prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + obs[i++] = (s->player.prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + obs[i++] = (s->player.prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + /* offensive prayer one-hot (none implied by all-three-zero). */ + obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_PIETY) ? 1.0f : 0.0f; + obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) ? 1.0f : 0.0f; + obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_AUGURY) ? 1.0f : 0.0f; + obs[i++] = (float)s->player.brew_doses / (float)full_supplies.brew_doses; + obs[i++] = (float)s->player.restore_doses / (float)full_supplies.restore_doses; + obs[i++] = (float)s->player.current_prayer / 99.0f; + obs[i++] = (float)s->wave / (float)INF_NUM_WAVES; + obs[i++] = 0.0f; + 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 / (float)full_supplies.bastion_doses; + obs[i++] = (float)s->player.stamina_doses / (float)full_supplies.stamina_doses; + 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++] = osrs_interaction_active(&s->interaction) ? 1.0f : 0.0f; + obs[i++] = (float)ls->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)ls->max_hit / 80.0f; + obs[i++] = (float)ls->attack_speed / 6.0f; + obs[i++] = (float)ls->def_stab / 300.0f; + obs[i++] = (float)ls->def_magic / 300.0f; + obs[i++] = (float)ls->def_ranged / 300.0f; + obs[i++] = (float)s->player.special_energy / 100.0f; + + /* prayer-critical: distilled from NPC array and pending hits */ + { + int min_timer = 999; + int min_style = 0; + int has_melee_2 = 0, has_ranged_2 = 0, has_magic_2 = 0; + + /* 1. Pending hits (handles Jad, which checks prayer on impact) */ + for (int h = 0; h < s->player_pending_hit_count; h++) { + EncounterPendingHit* ph = &s->player_pending_hits[h]; + if (ph->check_prayer) { + int t = inf_pending_hit_obs_timer(ph); + if (t < min_timer) { + min_timer = t; + min_style = ph->attack_style; + } + if (t <= 2) { + if (ph->attack_style == ATTACK_STYLE_MELEE) has_melee_2 = 1; + if (ph->attack_style == ATTACK_STYLE_RANGED) has_ranged_2 = 1; + if (ph->attack_style == ATTACK_STYLE_MAGIC) has_magic_2 = 1; + } + } + } + + /* 2. NPCs firing or telegraphing (non-Jad checks prayer on launch; + Jad enters here only once its committed preview style is visible). */ + for (int n = 0; n < INF_MAX_NPCS; n++) { + InfNPC* npc = &s->npcs[n]; + if (!npc->active || npc->death_ticks > 0) continue; + if (npc->type == INF_NPC_ZUK || + npc->type == INF_NPC_ZUK_SHIELD || npc->type == INF_NPC_NIBBLER || + npc->type == INF_NPC_HEALER_ZUK) continue; + + const InfNPCStats* st = &INF_NPC_STATS[npc->type]; + if (npc->frozen_ticks > 0 || npc->stun_timer > 0) continue; + + int dist = encounter_dist_to_npc(s->player.x, s->player.y, npc->x, npc->y, npc->size); + if (dist == 0) continue; + + int style = npc->attack_style; + if (npc->type == INF_NPC_BLOB && npc->blob_scanned_prayer >= 0) { + OverheadPrayer scanned = (OverheadPrayer)npc->blob_scanned_prayer; + if (scanned == PRAYER_PROTECT_MAGIC) style = ATTACK_STYLE_RANGED; + else if (scanned == PRAYER_PROTECT_RANGED) style = ATTACK_STYLE_MAGIC; + } else if (npc->type == INF_NPC_JAD) { + style = npc->jad_attack_style; + if (style == ATTACK_STYLE_NONE) continue; + } + int style_mask = inf_attack_style_telegraph_mask( + s, npc, st, style, dist); + int preview_style = inf_attack_style_obs_preview(style_mask); + + int t = npc->attack_timer; + if (t == 0) t = 1; + + if (t < min_timer) { + min_timer = t; + min_style = preview_style; + } + if (t <= 2) { + if (style_mask & INF_STYLE_MASK_MELEE) has_melee_2 = 1; + if (style_mask & INF_STYLE_MASK_RANGED) has_ranged_2 = 1; + if (style_mask & INF_STYLE_MASK_MAGIC) has_magic_2 = 1; + } + } + + int conflict_count = has_melee_2 + has_ranged_2 + has_magic_2; + obs[i++] = (min_timer < 999) ? (float)min_timer / 10.0f : 1.0f; + 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; + obs[i++] = (float)conflict_count / 3.0f; + } + + /* Zuk-phase features (10 features: 1 flag + 9 Zuk-specific) */ + { + int is_zuk = (s->wave == 68); + obs[i++] = is_zuk ? 1.0f : 0.0f; + + if (is_zuk) { + int si = s->zuk.shield_idx; + int shield_active = (si >= 0 && s->npcs[si].active); + + /* shield direction/freeze are only meaningful while the shield exists. + once the shield dies, zero these instead of leaking stale state. */ + obs[i++] = shield_active + ? ((s->zuk.shield_freeze > 0) ? 0.0f : (float)s->zuk.shield_dir) + : 0.0f; + obs[i++] = shield_active ? (float)s->zuk.shield_freeze / 5.0f : 0.0f; + /* am I behind the shield right now? + signed distance to shield center. + the binary tells the agent if it's safe. the signed distance gives a + gradient: negative = move east, positive = move west, 0 = centered. */ + int behind = 0; + float shield_offset = 0.0f; + if (shield_active) { + int sx = s->npcs[si].x; + int sz = INF_NPC_STATS[INF_NPC_ZUK_SHIELD].size; + int shield_center = sx + sz / 2; + shield_offset = (float)(px - shield_center) / 15.0f; /* normalized, ~[-1,1] range */ + behind = (px >= sx && px < sx + sz && py >= 41); + } + obs[i++] = behind ? 1.0f : 0.0f; + obs[i++] = shield_offset; + /* Zuk enraged (attack speed 7 instead of 8) */ + obs[i++] = s->zuk.enraged ? 1.0f : 0.0f; + /* set spawn timer / 350 */ + obs[i++] = (float)s->zuk.set_timer / 350.0f; + /* set timer paused (Jad spawn phase) */ + obs[i++] = s->zuk.timer_paused ? 1.0f : 0.0f; + /* Jad has spawned during Zuk fight */ + obs[i++] = s->zuk.jad_spawned ? 1.0f : 0.0f; + /* Zuk healers have spawned */ + obs[i++] = s->zuk.healer_spawned ? 1.0f : 0.0f; + } else { + for (int z = 0; z < 9; z++) obs[i++] = 0.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; + } + + int obs_slots[INF_OBS_NPCS]; + for (int j = 0; j < INF_OBS_NPCS; j++) obs_slots[j] = -1; + + int slot_counts[INF_NUM_NPC_TYPES] = {0}; + int slot_offsets[INF_NUM_NPC_TYPES]; + int slot_max[INF_NUM_NPC_TYPES]; + + slot_offsets[INF_NPC_MAGER] = 0; slot_max[INF_NPC_MAGER] = 2; + slot_offsets[INF_NPC_RANGER] = 2; slot_max[INF_NPC_RANGER] = 2; + slot_offsets[INF_NPC_MELEER] = 4; slot_max[INF_NPC_MELEER] = 2; + slot_offsets[INF_NPC_BLOB] = 6; slot_max[INF_NPC_BLOB] = 2; + slot_offsets[INF_NPC_BAT] = 8; slot_max[INF_NPC_BAT] = 2; + slot_offsets[INF_NPC_BLOB_MAGE] = 10; slot_max[INF_NPC_BLOB_MAGE] = 2; + slot_offsets[INF_NPC_BLOB_RANGE] = 12; slot_max[INF_NPC_BLOB_RANGE] = 2; + slot_offsets[INF_NPC_BLOB_MELEE] = 14; slot_max[INF_NPC_BLOB_MELEE] = 2; + slot_offsets[INF_NPC_NIBBLER] = 16; slot_max[INF_NPC_NIBBLER] = 6; + slot_offsets[INF_NPC_JAD] = 22; slot_max[INF_NPC_JAD] = 3; + slot_offsets[INF_NPC_ZUK] = 25; slot_max[INF_NPC_ZUK] = 1; + slot_offsets[INF_NPC_ZUK_SHIELD] = 26; slot_max[INF_NPC_ZUK_SHIELD] = 1; + slot_offsets[INF_NPC_HEALER_JAD] = 27; slot_max[INF_NPC_HEALER_JAD] = 6; + slot_offsets[INF_NPC_HEALER_ZUK] = 33; slot_max[INF_NPC_HEALER_ZUK] = 4; + + for (int n = 0; n < INF_MAX_NPCS; n++) { + InfNPC* npc = &s->npcs[n]; + if (npc->active && npc->death_ticks == 0) { + int t = npc->type; + if (slot_counts[t] < slot_max[t]) { + obs_slots[slot_offsets[t] + slot_counts[t]] = n; + slot_counts[t]++; + } + } + } + for (int j = 0; j < INF_OBS_NPCS; j++) { + s->current_obs_slots[j] = obs_slots[j]; + } + + /* NPCs: variable features per slot, fixed order */ + int slot_types[INF_OBS_NPCS]; + int st_idx = 0; + for (int j = 0; j < 2; j++) slot_types[st_idx++] = INF_NPC_MAGER; + for (int j = 0; j < 2; j++) slot_types[st_idx++] = INF_NPC_RANGER; + for (int j = 0; j < 2; j++) slot_types[st_idx++] = INF_NPC_MELEER; + for (int j = 0; j < 2; j++) slot_types[st_idx++] = INF_NPC_BLOB; + for (int j = 0; j < 2; j++) slot_types[st_idx++] = INF_NPC_BAT; + for (int j = 0; j < 2; j++) slot_types[st_idx++] = INF_NPC_BLOB_MAGE; + for (int j = 0; j < 2; j++) slot_types[st_idx++] = INF_NPC_BLOB_RANGE; + for (int j = 0; j < 2; j++) slot_types[st_idx++] = INF_NPC_BLOB_MELEE; + for (int j = 0; j < 6; j++) slot_types[st_idx++] = INF_NPC_NIBBLER; + for (int j = 0; j < 3; j++) slot_types[st_idx++] = INF_NPC_JAD; + for (int j = 0; j < 1; j++) slot_types[st_idx++] = INF_NPC_ZUK; + for (int j = 0; j < 1; j++) slot_types[st_idx++] = INF_NPC_ZUK_SHIELD; + for (int j = 0; j < 6; j++) slot_types[st_idx++] = INF_NPC_HEALER_JAD; + for (int j = 0; j < 4; j++) slot_types[st_idx++] = INF_NPC_HEALER_ZUK; + + for (int k = 0; k < INF_OBS_NPCS; k++) { + int n = obs_slots[k]; + int type = slot_types[k]; + + int has_style = (type == INF_NPC_BLOB || type == INF_NPC_JAD); + int has_scan = (type == INF_NPC_BLOB); + int has_los = (type != INF_NPC_NIBBLER && type != INF_NPC_MELEER && type != INF_NPC_HEALER_JAD && type != INF_NPC_ZUK_SHIELD); + int has_aggro = (type != INF_NPC_NIBBLER && type != INF_NPC_ZUK_SHIELD); + int has_timer = (type != INF_NPC_NIBBLER && type != INF_NPC_HEALER_JAD && type != INF_NPC_ZUK_SHIELD); + int has_targeted = 1; + + int num_features = 4; // HP, RelX, RelY, AoE + if (has_timer) num_features += 1; + if (has_style) num_features += 3; + if (has_los) num_features += 1; + if (has_scan) num_features += 3; + if (has_aggro) num_features += 1; + if (has_targeted) num_features += 1; + + if (n >= 0) { + InfNPC* npc = &s->npcs[n]; + obs[i++] = (float)npc->hp / (float)npc->max_hp; + obs[i++] = (float)(npc->x - px) / (float)INF_ARENA_WIDTH; + obs[i++] = (float)(npc->y - py) / (float)INF_ARENA_HEIGHT; + if (has_timer) obs[i++] = (float)npc->attack_timer / 10.0f; + + if (has_style) { + 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; + } + + if (has_los) obs[i++] = inf_npc_has_los(s, n) ? 1.0f : 0.0f; + + if (has_scan) { + if (npc->blob_scanned_prayer >= 0) { + OverheadPrayer scanned = (OverheadPrayer)npc->blob_scanned_prayer; + obs[i++] = (scanned == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + obs[i++] = (scanned == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + obs[i++] = (scanned != PRAYER_PROTECT_MAGIC && scanned != PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + } else { + obs[i++] = 0.0f; obs[i++] = 0.0f; obs[i++] = 0.0f; + } + } + + /* barrage AoE count: unique blocking NPCs in the 3x3 area */ + { + int aoe_count = 0; + for (int oidx = 0; oidx < INF_MAX_NPCS; oidx++) { + if (oidx == n) continue; + InfNPC* other = &s->npcs[oidx]; + if (!other->active) continue; + if (!inf_npc_sets_collision_flag(other->type)) continue; + if (encounter_entity_footprints_overlap( + other->x, other->y, inf_npc_effective_size(other), + npc->x - 1, npc->y - 1, 3)) { + aoe_count++; + } + } + obs[i++] = (float)aoe_count / 8.0f; + } + + if (has_aggro) obs[i++] = (npc->aggro_target < 0) ? 1.0f : 0.0f; + if (has_targeted) obs[i++] = (osrs_interaction_active(&s->interaction) && s->interaction.target_slot == n) ? 1.0f : 0.0f; + } else { + for (int j = 0; j < num_features; 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 = INF_PLAYER_OBS_SIZE + 12 + INF_TOTAL_NPC_OBS_SIZE; + if (i != expected_npc_end) { + fprintf(stderr, "FATAL: obs misaligned after NPC section: i=%d expected=%d\n", + i, expected_npc_end); + 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)inf_pending_hit_obs_timer(ph) / 10.0f; + obs[i++] = (float)ph->damage / 150.0f; /* normalized damage magnitude (Zuk max ~148) */ + } else { + for (int j = 0; j < INF_FEATURES_PER_HIT; j++) obs[i++] = 0.0f; + } + } + + if (i != INF_NUM_OBS) { + fprintf(stderr, "BUG: inf_write_obs wrote %d features, expected %d\n", i, INF_NUM_OBS); + abort(); + } +} + +static int inf_find_target_obs_slot(const InfernoState* s, int npc_slot) { + if (npc_slot < 0 || npc_slot >= INF_MAX_NPCS) return -1; + for (int j = 0; j < INF_OBS_NPCS; j++) { + if (s->current_obs_slots[j] == npc_slot) return j; + } + return -1; +} + +static int inf_obs_slot_is_targetable(const InfernoState* s, int obs_slot) { + if (obs_slot < 0 || obs_slot >= INF_OBS_NPCS) return 0; + int npc_slot = s->current_obs_slots[obs_slot]; + if (npc_slot < 0 || npc_slot >= INF_MAX_NPCS) return 0; + return s->npcs[npc_slot].active && + s->npcs[npc_slot].death_ticks == 0 && + s->npcs[npc_slot].type != INF_NPC_ZUK_SHIELD; +} + +static int inf_is_human_targetable_npc_slot(EncounterState* state, int npc_slot) { + const InfernoState* s = (const InfernoState*)state; + return inf_obs_slot_is_targetable(s, inf_find_target_obs_slot(s, npc_slot)); +} + +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 (4): 0=no_change always valid, 1-3=toggle_melee/ranged/magic. + toggles are always valid (the env decides on/off based on current state) + but require pp > 0 to activate; mask activation paths when pp=0. + since the agent can't know if the toggle will activate vs deactivate without + additional state, we keep toggles available when pp>0 and when the matching + prayer is already active (toggle-off is free). */ + mask[offset++] = 1.0f; /* no_change always valid */ + mask[offset++] = (s->player.current_prayer > 0 || s->player.prayer == PRAYER_PROTECT_MELEE) ? 1.0f : 0.0f; + mask[offset++] = (s->player.current_prayer > 0 || s->player.prayer == PRAYER_PROTECT_RANGED) ? 1.0f : 0.0f; + mask[offset++] = (s->player.current_prayer > 0 || s->player.prayer == PRAYER_PROTECT_MAGIC) ? 1.0f : 0.0f; + + /* HEAD_TARGET (INF_OBS_NPCS+1): none always valid, slot valid only if its + mapped NPC is alive (not dying) and not the Zuk shield (invulnerable). */ + mask[offset++] = 1.0f; /* no target */ + for (int n = 0; n < INF_OBS_NPCS; n++) { + mask[offset++] = inf_obs_slot_is_targetable(s, n) ? 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) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set != INF_GEAR_TBOW) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set != INF_GEAR_BP) ? 1.0f : 0.0f; + mask[offset++] = 0.0f; /* tank overlay removed */ + + /* 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 or if magic level is too low + to cast blood barrage (req 92). ice masked if magic level too low (req 94). + 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_magic >= BLOOD_BARRAGE_LEVEL && + s->player.current_hitpoints < s->player.base_hitpoints) ? 1.0f : 0.0f; + mask[offset++] = (s->weapon_set == INF_GEAR_MAGE && + s->player.current_magic >= ICE_BARRAGE_LEVEL) ? 1.0f : 0.0f; + + /* HEAD_SPEC (2): no_change, toggle. allow when blowpipe equipped + enough energy. */ + mask[offset++] = 1.0f; /* no_change always valid */ + mask[offset++] = (s->weapon_set == INF_GEAR_BP && + s->player.special_energy >= BLOWPIPE_SPEC_COST) + ? 1.0f : 0.0f; + + /* HEAD_OFFENSIVE (4): 0=no_change always valid, 1-3=toggle_piety/rigour/augury. + same rule as overhead — toggles need pp>0 to activate, free to deactivate. */ + mask[offset++] = 1.0f; /* no_change always valid */ + mask[offset++] = (s->player.current_prayer > 0 || s->player.offensive_prayer == OFFENSIVE_PRAYER_PIETY) ? 1.0f : 0.0f; + mask[offset++] = (s->player.current_prayer > 0 || s->player.offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) ? 1.0f : 0.0f; + mask[offset++] = (s->player.current_prayer > 0 || s->player.offensive_prayer == OFFENSIVE_PRAYER_AUGURY) ? 1.0f : 0.0f; +} + + +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; + + { + const EncounterLoadoutStats* ls = inf_current_loadout_stats(s); + 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; + /* facing: nibblers → pillar (dest-based), NPCs attacking shield → shield + (dest-based), all others → player (entity 0). */ + if (npc->type == INF_NPC_NIBBLER) { + re->attack_target_entity_idx = -1; + } else if (npc->aggro_target >= 0 && npc->aggro_target < INF_MAX_NPCS && + s->npcs[npc->aggro_target].active) { + /* attacking another NPC (e.g. shield) — use dest-based facing */ + re->attack_target_entity_idx = -1; + } else { + re->attack_target_entity_idx = 0; /* player */ + } + 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->type == INF_NPC_MELEER && + npc->dig_freeze_timer == 6) { + re->npc_anim_id = INF_GEN_ANIM_MELEER_DIG_DOWN; + } else if (npc->type == INF_NPC_MELEER && + npc->dig_attack_delay == 6) { + re->npc_anim_id = INF_GEN_ANIM_MELEER_DIG_UP; + } else if (npc->attacked_this_tick) { + re->npc_anim_id = inf_npc_attack_anim_id(npc, nm); + } 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 if (npc->aggro_target >= 0 && npc->aggro_target < INF_MAX_NPCS && + s->npcs[npc->aggro_target].active) { + /* attacking shield/other NPC — face toward target NPC center */ + InfNPC* at = &s->npcs[npc->aggro_target]; + re->dest_x = at->x + at->size / 2; + re->dest_y = at->y + at->size / 2; + } 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_this_tick : 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->interaction.target_slot); + *count = n; +} + +static void inf_put_int(EncounterState* state, const char* key, int value) { + InfernoState* s = (InfernoState*)state; + /* wave is 1-indexed externally (wave 1 = first, wave 69 = Zuk), 0-indexed internally. + public wave 0 is retained as an alias for a full run from wave 1. */ + if (strcmp(key, "start_wave") == 0) s->start_wave = inf_public_start_wave_to_internal(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; + else if (strcmp(key, "human_command_mode") == 0) s->human_command_mode = value; +} + +static void inf_put_float(EncounterState* state, const char* key, float value) { + InfernoState* s = (InfernoState*)state; + if (strcmp(key, "damage_reward_coeff") == 0) s->damage_reward_coeff = value; + else if (strcmp(key, "shield_penalty_coeff") == 0) s->shield_penalty_coeff = value; + else if (strcmp(key, "tag_reward_coeff") == 0) s->tag_reward_coeff = value; + else if (strcmp(key, "late_start_supply_profile_scale") == 0) { + inf_require_valid_supply_scale(value); + s->late_start_supply_profile_scale = value; + } + else assert(0 && "unknown inferno float config"); +} + +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.zuk_healer_damage += s->total_zuk_healer_damage; + 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; + s->log.min_zuk_hp_seen += (s->winner == 0) + ? 0.0f + : (s->min_zuk_hp_seen > 0.0f ? s->min_zuk_hp_seen : 1200.0f); + } + return &s->log; +} + + + +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 = npc->attack_style_this_tick; + + /* Zuk is typeless — show as magic for visual purposes. */ + if (actual_style == ATTACK_STYLE_NONE && npc->type == INF_NPC_ZUK) + actual_style = ATTACK_STYLE_MAGIC; + + /* tagged Zuk healers spawn their own 3-spark visuals from pending_sparks. */ + if (npc->type == INF_NPC_HEALER_ZUK && npc->attack_visual_target < 0) + continue; + + if (actual_style == ATTACK_STYLE_NONE) continue; + + /* 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; /* default: player size 1 * 0.5 * 128 */ + int curve = 16; + float arc = 0.0f; + int tracks = 1; + + /* projectile target: shield or player */ + int target_x = s->player.x, target_y = s->player.y; + if (npc->attack_visual_target >= 0 && npc->attack_visual_target < INF_MAX_NPCS) { + InfNPC* vt = &s->npcs[npc->attack_visual_target]; + target_x = vt->x + vt->size / 2; + target_y = vt->y + vt->size / 2; + end_h = (int)(vt->size * 0.5f * 128); + tracks = 0; /* don't track player — projectile targets shield/NPC */ + } + int dist = encounter_dist_to_npc(target_x, target_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; + + /* 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_1378_MODEL : INF_GFX_1380_MODEL; break; + case INF_NPC_BLOB_RANGE: proj_model_id = INF_GFX_1379_MODEL; break; + case INF_NPC_BLOB_MAGE: proj_model_id = INF_GFX_1381_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_1376_MODEL; break; + case INF_NPC_JAD: + proj_model_id = (actual_style == ATTACK_STYLE_MAGIC) + ? INF_GFX_448_MODEL + : INF_GFX_451_MODEL; + break; + case INF_NPC_ZUK: proj_model_id = INF_GFX_1375_MODEL; break; + case INF_NPC_HEALER_ZUK: proj_model_id = INF_GFX_660_MODEL; break; + default: break; + } + + /* NPC-specific flight overrides */ + switch (npc->type) { + case INF_NPC_MAGER: + /* InfernoTrainer JalZek MagicWeapon: visualDelayTicks=2, + visualHitEarlyTicks=-1. projectile invisible for 2 ticks, + then visual flies in and arrives 1 tick AFTER the hit lands. + visible duration = (hit_delay + 1) - 2 = hit_delay - 1 ticks. + start_delay=2 ticks is set after the emit below. */ + duration = (hit_delay - 1) * 30; + if (duration < 30) duration = 30; + break; + case INF_NPC_RANGER: + /* InfernoTrainer JalXil RangedWeapon: reduceDelay=-2 (hit + delay +2 ticks), visualDelayTicks=3. visible duration = + (hit_delay + 2) - 3 = hit_delay - 1 ticks. start_delay=3 + ticks is set after the emit below. the sim keeps the existing + JalXil damage timing here. */ + duration = (hit_delay - 1) * 30; + if (duration < 30) duration = 30; + break; + case INF_NPC_JAD: + if (actual_style == ATTACK_STYLE_MAGIC) { + arc = 1.0f; /* arcing magic projectile */ + } else { + start_h = end_h; + } + duration = inf_jad_visible_duration_ticks(hit_delay); + break; + case INF_NPC_HEALER_ZUK: + arc = 3.0f; /* high arcing spark */ + duration = (npc->attack_visual_target >= 0) ? 3 * 30 : 4 * 30; + break; + case INF_NPC_ZUK: + /* InfernoTrainer: setDelay=4, visualDelayTicks=2. + projectile invisible for 2 ticks, visible for 2 ticks. */ + duration = 2 * 30; /* 2-tick visible flight */ + break; + default: break; + } + + if (npc->type == INF_NPC_JAD && actual_style == ATTACK_STYLE_MAGIC) { + if (ov->projectile_count + 3 > ENCOUNTER_MAX_OVERLAY_PROJECTILES) break; + uint32_t model_ids[3] = { + INF_GFX_448_MODEL, INF_GFX_449_MODEL, INF_GFX_450_MODEL + }; + int anim_ids[3] = { + INF_GFX_448_ANIM, INF_GFX_449_ANIM, INF_GFX_450_ANIM + }; + float offsets[3] = {1.0f, 0.5f, 0.0f}; + for (int j = 0; j < 3; j++) { + int pi = encounter_emit_projectile(ov, + npc->x, npc->y, target_x, target_y, + proj_style, (int)s->damage_received_this_tick, + duration, start_h, end_h, curve, arc, tracks, npc_size, 1, + model_ids[j], 0); + if (pi >= 0) { + ov->projectiles[pi].start_delay = INF_JAD_PROJECTILE_DELAY * 30; + encounter_set_projectile_animation(ov, pi, anim_ids[j]); + encounter_set_projectile_offset(ov, pi, 0.0f, offsets[j], 0.0f); + } + } + continue; + } + + int impact_gfx_id = (npc->type == INF_NPC_HEALER_ZUK) ? INF_GFX_659_ID : 0; + int pi = encounter_emit_projectile(ov, + npc->x, npc->y, target_x, target_y, + proj_style, (int)s->damage_received_this_tick, + duration, start_h, end_h, curve, arc, tracks, npc_size, 1, + proj_model_id, impact_gfx_id); + + /* Zuk: 2-tick visual delay (projectile invisible until tick N+2) */ + if (pi >= 0 && npc->type == INF_NPC_ZUK) + ov->projectiles[pi].start_delay = 2 * 30; + + /* Jad: 3-tick visual delay (InfernoTrainer JAD_PROJECTILE_DELAY=3) */ + if (pi >= 0 && npc->type == INF_NPC_JAD) + ov->projectiles[pi].start_delay = INF_JAD_PROJECTILE_DELAY * 30; + + if (pi >= 0 && npc->type == INF_NPC_JAD && + actual_style == ATTACK_STYLE_RANGED) + encounter_set_projectile_motion_mode( + ov, pi, ENCOUNTER_PROJECTILE_MOTION_TARGET_ANCHORED); + + if (pi >= 0 && npc->type == INF_NPC_JAD && + actual_style == ATTACK_STYLE_RANGED) + encounter_set_projectile_animation(ov, pi, INF_GFX_451_ANIM); + + /* Mager: 2-tick visualDelayTicks (InfernoTrainer JalZek MagicWeapon) */ + if (pi >= 0 && npc->type == INF_NPC_MAGER) + ov->projectiles[pi].start_delay = 2 * 30; + + /* Ranger: 3-tick visualDelayTicks (InfernoTrainer JalXil RangedWeapon) */ + if (pi >= 0 && npc->type == INF_NPC_RANGER) + ov->projectiles[pi].start_delay = 3 * 30; + } + + for (int i = 0; i < INF_MAX_PENDING_SPARKS; i++) { + InfPendingSpark* spark = &s->pending_sparks[i]; + if (!spark->active || spark->visual_emitted) continue; + if (ov->projectile_count >= ENCOUNTER_MAX_OVERLAY_PROJECTILES) break; + + encounter_emit_projectile( + ov, + spark->src_x, spark->src_y, spark->x, spark->y, + encounter_attack_style_to_proj_style(ATTACK_STYLE_MAGIC), + spark->damage, + 4 * 30, 96, 64, 16, 3.0f, 0, 1, 1, + INF_GFX_660_MODEL, INF_GFX_659_ID); + spark->visual_emitted = 1; + } + + /* 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; + uint8_t weapon = s->player.equipped[GEAR_SLOT_WEAPON]; + + uint32_t player_proj_model = 0; + if (s->player_attack_style_id == ATTACK_STYLE_MAGIC) { + p_duration = encounter_magic_hit_delay(p_dist, 1) * 30; + p_arc = 0.0f; + /* barrage: no projectile model (effect system handles it) */ + } else if (weapon == ITEM_TWISTED_BOW) { + p_duration = encounter_ranged_hit_delay(p_dist, 1) * 30; + p_arc = 1.0f; + player_proj_model = INF_GFX_1120_MODEL; + } else { + /* blowpipe */ + p_duration = encounter_blowpipe_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, 0); + } + } + } +} + + +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_offensive_prayer(hi, actions, INF_HEAD_OFFENSIVE); + InfernoState* s = (InfernoState*)state; + /* map raw pending_target_idx to the observation slot the agent sees */ + if (hi->pending_target_idx >= 0) { + int found_slot = inf_find_target_obs_slot(s, hi->pending_target_idx); + if (inf_obs_slot_is_targetable(s, found_slot)) { + actions[INF_HEAD_TARGET] = found_slot + 1; + } else { + actions[INF_HEAD_TARGET] = 0; + } + } else { + actions[INF_HEAD_TARGET] = 0; + } + + /* 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; + else if (hi->pending_potion == POTION_BASTION) actions[INF_HEAD_POTION] = 2; + else if (hi->pending_potion == POTION_STAMINA) actions[INF_HEAD_POTION] = 3; + + /* 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; +} + +static void inf_translate_human_commands(HumanInput* hi, int* actions, InfernoState* s) { + for (int h = 0; h < INF_NUM_ACTION_HEADS; h++) actions[h] = 0; + + for (int i = 0; i < hi->commands.count; i++) { + const HumanCommand* cmd = &hi->commands.items[i]; + switch (cmd->kind) { + case HUMAN_COMMAND_WALK: + s->player_dest_x = cmd->world_x; + s->player_dest_y = cmd->world_y; + actions[INF_HEAD_TARGET] = 0; + actions[INF_HEAD_SPELL] = 0; + break; + case HUMAN_COMMAND_ATTACK_NPC: { + int found_slot = inf_find_target_obs_slot(s, cmd->npc_slot); + actions[INF_HEAD_TARGET] = inf_obs_slot_is_targetable(s, found_slot) + ? found_slot + 1 : 0; + s->player_dest_x = -1; + s->player_dest_y = -1; + break; + } + case HUMAN_COMMAND_SPELL_TARGET: { + int found_slot = inf_find_target_obs_slot(s, cmd->npc_slot); + actions[INF_HEAD_TARGET] = inf_obs_slot_is_targetable(s, found_slot) + ? found_slot + 1 : 0; + if (cmd->spell == ATTACK_BLOOD) actions[INF_HEAD_SPELL] = 1; + else if (cmd->spell == ATTACK_ICE) actions[INF_HEAD_SPELL] = 2; + s->player_dest_x = -1; + s->player_dest_y = -1; + break; + } + case HUMAN_COMMAND_OVERHEAD_PRAYER: + actions[INF_HEAD_PRAYER] = cmd->overhead_prayer; + break; + case HUMAN_COMMAND_OFFENSIVE_PRAYER: + actions[INF_HEAD_OFFENSIVE] = cmd->offensive_prayer; + break; + case HUMAN_COMMAND_EAT: + actions[INF_HEAD_EAT] = 1; + break; + case HUMAN_COMMAND_DRINK: + if (cmd->potion == POTION_BREW) actions[INF_HEAD_EAT] = 1; + else if (cmd->potion == POTION_RESTORE) actions[INF_HEAD_POTION] = 1; + else if (cmd->potion == POTION_BASTION) actions[INF_HEAD_POTION] = 2; + else if (cmd->potion == POTION_STAMINA) actions[INF_HEAD_POTION] = 3; + break; + case HUMAN_COMMAND_SPEC_TOGGLE: + actions[INF_HEAD_SPEC] = 1; + break; + case HUMAN_COMMAND_EQUIP_INVENTORY_ITEM: + case HUMAN_COMMAND_FIGHT_STYLE: + case HUMAN_COMMAND_NONE: + break; + } + } +} + +static void inf_step_human_commands(EncounterState* state, HumanInput* hi) { + InfernoState* s = (InfernoState*)state; + int actions[INF_NUM_ACTION_HEADS]; + s->human_command_mode = 1; + s->human_commands = hi->commands.items; + s->human_command_count = hi->commands.count; + inf_refresh_human_loadout_stats(s); + inf_translate_human_commands(hi, actions, s); + inf_step(state, actions); + s->human_commands = NULL; + s->human_command_count = 0; + human_input_clear_pending(hi); +} + + +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, + .step_human_commands = inf_step_human_commands, + + .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, + .is_human_targetable_npc_slot = inf_is_human_targetable_npc_slot, + .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..deb1a0b95c --- /dev/null +++ b/ocean/osrs/encounters/encounter_nh_pvp.h @@ -0,0 +1,204 @@ +/** + * @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_env.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 +}; + + +typedef struct { + OsrsEnv env; +} NhPvpState; + + +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_io.agent_obs = s->env._obs_buf; + s->env.ocean_io.agent_actions = s->env._acts_buf; + s->env.ocean_io.agent_rewards = s->env._rews_buf; + s->env.ocean_io.agent_terminals = 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_io.agent_actions. */ + memcpy(s->env.ocean_io.agent_actions, actions, NUM_ACTION_HEADS * sizeof(int)); + pvp_step(&s->env); +} + + +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; +} + + +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]; +} + + +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; +} + + +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.pvp_runtime.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.pvp_runtime.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; + } +} + + +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; +} + + +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, + .get_log = nh_pvp_get_log, + .get_tick = nh_pvp_get_tick, + .get_winner = nh_pvp_get_winner, + + .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..aca8603e56 --- /dev/null +++ b/ocean/osrs/encounters/encounter_zulrah.h @@ -0,0 +1,2465 @@ +/** + * @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_interaction.h" +#include "../osrs_types.h" +#include "../osrs_items.h" +#include "../osrs_combat.h" +#include "../osrs_special_attacks.h" +#include "../osrs_pvp_gear.h" +#include "../osrs_consumables.h" +#include "../osrs_damage.h" +#include "../osrs_collision.h" +#include "../osrs_monsters_generated.h" +#include "../data/npc_models.h" +#include +#include + + +#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 sourced from MONSTER_DATABASE (osrs_monsters_generated.h). + all three forms share: hp=500, def_level=300, attack_speed=3. + per-form defence: green magic_def=-45 ranged_def=50, red 0/300, blue 300/0. */ +#define ZUL_BASE_HP MONSTER_DATABASE[MON_ZULRAH_GREEN].hp /* convenience alias for binding.c */ + +/* 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: stats from MONSTER_DATABASE (MON_ZULRAH_SNAKELING_MELEE/MAGIC) */ +#define ZUL_SNAKELING_HP 1 +#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: computed from MONSTER_DATABASE via osrs_npc_attack_roll() */ + +/* 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 — cost looked up via osrs_spec_cost(weapon) at runtime */ + +/* 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_MAX_TICKS 600 + + +#define ZUL_NUM_OBS 84 /* +3 for offensive prayer one-hot (piety/rigour/augury) */ +#define ZUL_NUM_ACTION_HEADS 7 + +#define ZUL_MOVE_DIM ENCOUNTER_MOVE_ACTIONS +#define ZUL_ATTACK_DIM 3 +#define ZUL_PRAYER_DIM ENCOUNTER_OVERHEAD_DIM_PVE /* 4: no_change, toggle_melee/ranged/magic */ +#define ZUL_OFFENSIVE_DIM ENCOUNTER_OFFENSIVE_DIM /* 4: no_change, toggle_piety/rigour/augury */ +#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 + ZUL_OFFENSIVE_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_HEAD_OFFENSIVE 6 + +#define ZUL_MOVE_STAY 0 +#define ZUL_ATK_NONE 0 +#define ZUL_ATK_MAGE 1 +#define ZUL_ATK_RANGE 2 + + +typedef enum { + ZUL_FORM_GREEN = 0, /* 2042: serpentine, ranged */ + ZUL_FORM_RED, /* 2043: magma, melee */ + ZUL_FORM_BLUE, /* 2044: tanzanite, magic+ranged */ +} ZulrahForm; + +/* map zulrah form to MONSTER_DATABASE index */ +static const int ZUL_FORM_MONSTER_IDX[] = { + [ZUL_FORM_GREEN] = MON_ZULRAH_GREEN, + [ZUL_FORM_RED] = MON_ZULRAH_RED, + [ZUL_FORM_BLUE] = MON_ZULRAH_BLUE, +}; + +typedef enum { + ZUL_GEAR_MAGE = 0, + ZUL_GEAR_RANGE, +} ZulrahGearStyle; + + +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)) */ +/* safe tile positions adapted from zulrah helper plugin — approximate. */ +#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 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, ZUL_OFFENSIVE_DIM, +}; + +/* movement uses shared encounter_move_to_target + ENCOUNTER_MOVE_TARGET_DX/DY from osrs_encounter.h */ + +/* number of gear tiers for validation (used in put_int) */ +#define ZUL_NUM_GEAR_TIERS 3 + +/* 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; +} + + +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; + OsrsInteraction interaction; /* shared interaction state (persistent entity targeting) */ + 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 */ + + /* gear tier */ + int gear_tier; /* 0=budget, 1=mid, 2=BIS */ + + /* derived combat stats (computed from ITEM_DATABASE + loadout in zul_reset) */ + EncounterLoadoutStats mage_stats; + EncounterLoadoutStats range_stats; + int human_command_mode; + EncounterLoadoutStats human_loadout_stats; + const HumanCommand* human_commands; + int human_command_count; + + /* 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; + + /* 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; + int total_prayer_correct; + int total_prayer_total; + int total_gear_switches; + int total_food_eaten; + int total_potions_used; + int total_venom_ticks; /* ticks spent venomed */ + int total_phases_completed; + + /* 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.h */ +static const EncounterLoadoutStats* zul_current_loadout_stats(ZulrahState* s, int is_mage); + + +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 MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[f]].npc_id; +} + +/* 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; +} + + +/** 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; + + if (attacker) { + osrs_ensure_player_equipment(&s->player); + DamageResult damage_result = osrs_apply_passive_damage_pipeline( + damage, + style, + s->player.prayer, + /* is_pvp */ 0, + /* target_veng_active */ 0, + /* attacker_smite_active */ 0, + &s->player.equipment_effect_profile, + &s->player.item_effect_state, + &s->rng_state + ); + if (damage_result.recoil_damage > 0) { + int recoil = damage_result.recoil_damage; + if (s->player.equipment_effect_profile.recoil_source == OSRS_RECOIL_SOURCE_RING_OF_RECOIL && + recoil > s->player.item_effect_state.recoil_charges) { + recoil = s->player.item_effect_state.recoil_charges; + } + encounter_damage_player(attacker, recoil, NULL); + osrs_consume_recoil_charges(&s->player, recoil); + } + } +} + +/* 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; +} + + +/* 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.h */ + +/* compute player's defence roll against a specific NPC attack style. + uses current gear loadout stats (derived from ITEM_DATABASE). + magic defence uses 70% magic level + 30% defence level per OSRS formula. */ +static int zul_player_def_roll(ZulrahState* s, int attack_style) { + const EncounterLoadoutStats* ls = zul_current_loadout_stats( + s, s->player_gear == ZUL_GEAR_MAGE); + /* melee_style=2 (crush) for zulrah tail whip */ + int def_bonus = encounter_player_def_bonus( + ls->def_stab, ls->def_slash, ls->def_crush, ls->def_magic, ls->def_ranged, + attack_style, 2); + int roll = osrs_player_def_roll_vs_npc(99, 99, def_bonus, attack_style); + return roll > 0 ? roll : 0; +} + + +/* 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. */ +/* known sim gap: ranged/magic attacks ignore LOS (no pillar blocking for projectiles). */ +static void zul_attack_ranged(ZulrahState* s) { + const MonsterStats* m = &MONSTER_DATABASE[MON_ZULRAH_GREEN]; + int npc_att_roll = osrs_npc_attack_roll(m->range_level, m->range_att_bonus); + int dmg = 0; + int did_hit = 0; + s->total_prayer_total++; + if (encounter_prayer_correct_for_style(s->player.prayer, ATTACK_STYLE_RANGED)) { + s->total_prayer_correct++; + /* 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(npc_att_roll, def_roll); + did_hit = (encounter_rand_float(&s->rng_state) < chance); + if (did_hit) { + s->prayer_blocked_this_tick = 1; + } + } else { + int def_roll = zul_player_def_roll(s, ATTACK_STYLE_RANGED); + float chance = osrs_hit_chance(npc_att_roll, def_roll); + if (encounter_rand_float(&s->rng_state) < chance) { + did_hit = 1; + dmg = encounter_rand_int(&s->rng_state, m->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; + s->total_prayer_total++; + if (encounter_prayer_correct_for_style(s->player.prayer, ATTACK_STYLE_MAGIC)) { + s->total_prayer_correct++; + s->prayer_blocked_this_tick = 1; + } else { + dmg = encounter_rand_int(&s->rng_state, MONSTER_DATABASE[MON_ZULRAH_BLUE].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)) { + s->total_prayer_total++; + if (encounter_prayer_correct_for_style(s->player.prayer, ATTACK_STYLE_MELEE)) { + s->total_prayer_correct++; + 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; +} + + +/* per-form defence bonuses from MONSTER_DATABASE */ +static inline void zul_form_def_bonuses(ZulrahForm form, int* def_magic, int* def_ranged) { + const MonsterStats* m = &MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[form]]; + *def_magic = m->magic_def; + *def_ranged = m->ranged_def; +} + +static AttackStyle zul_player_equipped_attack_style(const ZulrahState* s) { + AttackStyle style = (AttackStyle)get_item_attack_style(s->player.equipped[GEAR_SLOT_WEAPON]); + if (style == ATTACK_STYLE_MAGIC || + style == ATTACK_STYLE_RANGED || + style == ATTACK_STYLE_MELEE) { + return style; + } + return ATTACK_STYLE_RANGED; +} + +static void zul_refresh_human_loadout_stats(ZulrahState* s) { + AttackStyle style = zul_player_equipped_attack_style(s); + FightStyle fight_style = s->player.fight_style; + int spell_base_damage = (style == ATTACK_STYLE_MAGIC) ? 30 : 0; + encounter_compute_player_equipped_stats( + &s->player, style, fight_style, spell_base_damage, + &s->human_loadout_stats); +} + +static const EncounterLoadoutStats* zul_current_loadout_stats(ZulrahState* s, int is_mage) { + if (s->human_command_mode) { + zul_refresh_human_loadout_stats(s); + return &s->human_loadout_stats; + } + return is_mage ? &s->mage_stats : &s->range_stats; +} + +static int zul_player_attack_hits( + ZulrahState* s, int is_mage, const OsrsPreparedAttackEffects* attack_effects +) { + int att_roll = attack_effects->attack_roll; + /* 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 = (MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[s->current_form]].def_level + 8) * (def_bonus + 64); + if (def_roll < 0) def_roll = 0; + + if (attack_effects->use_double_accuracy) { + return encounter_rand_float(&s->rng_state) < osrs_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; + + const EncounterLoadoutStats* ls = zul_current_loadout_stats(s, is_mage); + int gear_ok = s->human_command_mode + ? ((is_mage && ls->style == ATTACK_STYLE_MAGIC) || + (!is_mage && ls->style == ATTACK_STYLE_RANGED)) + : ((is_mage && s->player_gear == ZUL_GEAR_MAGE) || + (!is_mage && s->player_gear == ZUL_GEAR_RANGE)); + const MonsterStats* monster = &MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[s->current_form]]; + OsrsMagicAttackKind magic_kind = is_mage ? OSRS_MAGIC_ATTACK_POWERED_STAFF : OSRS_MAGIC_ATTACK_NONE; + OsrsPreparedAttackEffects attack_effects = osrs_prepare_attack_effects( + &s->player.equipment_effect_profile, + &s->player.item_effect_state, + s->player.equipped[GEAR_SLOT_WEAPON], + is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED, + magic_kind, + (OsrsTargetRef){ .kind = OSRS_TARGET_NPC, .id = 0 }, + 1, + osrs_player_att_roll(ls->eff_level, ls->attack_bonus), + ls->max_hit, + monster->magic_level, + monster->magic_att_bonus, + s->player.current_hitpoints, + s->player.base_hitpoints + ); + s->player.attack_timer = is_mage ? 4 : ls->attack_speed; + if (!gear_ok) return; + + int dmg = 0; + int hit = zul_player_attack_hits(s, is_mage, &attack_effects); + if (hit) { + dmg = encounter_rand_int(&s->rng_state, attack_effects.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; + } + { + OsrsPostAttackEffects post_effects = osrs_finalize_attack_effects( + &s->player.equipment_effect_profile, + &s->player.item_effect_state, + s->player.equipped[GEAR_SLOT_WEAPON], + is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED, + magic_kind, + (OsrsTargetRef){ .kind = OSRS_TARGET_NPC, .id = 0 }, + 1, + attack_effects.use_double_accuracy, + hit, + dmg, + &s->rng_state + ); + if (post_effects.heal_amount > 0) { + s->player.current_hitpoints += post_effects.heal_amount; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } + } + 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: dispatched via osrs_resolve_spec based on equipped weapon. + tier 0: MSB(i) from ranged gear. tier 1: bowfa has no spec (osrs_spec_cost=0). + tier 2: eye of ayak from mage gear. */ +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; + + int is_mage = (s->player_gear == ZUL_GEAR_MAGE); + const EncounterLoadoutStats* ls = zul_current_loadout_stats(s, is_mage); + int weapon = s->human_command_mode + ? s->player.equipped[GEAR_SLOT_WEAPON] + : (is_mage + ? ZUL_MAGE_LOADOUT[s->gear_tier][GEAR_SLOT_WEAPON] + : ZUL_RANGE_LOADOUT[s->gear_tier][GEAR_SLOT_WEAPON]); + + int cost = osrs_spec_cost(weapon); + if (cost == 0) return; /* weapon has no spec (e.g. bowfa) */ + if (s->player.special_energy < cost) return; + + /* compute defence roll for current form */ + const MonsterStats* m = &MONSTER_DATABASE[ZUL_FORM_MONSTER_IDX[s->current_form]]; + int def_bonus = is_mage ? (m->magic_def - s->magic_def_drain) : m->ranged_def; + if (is_mage && def_bonus < -64) def_bonus = -64; + int def_roll = (m->def_level + 8) * (def_bonus + 64); + if (def_roll < 0) def_roll = 0; + + int att_roll = osrs_player_att_roll(ls->eff_level, ls->attack_bonus); + SpecResult sr = osrs_resolve_spec(weapon, att_roll, ls->max_hit, + def_roll, m->def_level, &s->rng_state); + + s->player.special_energy -= sr.spec_cost; + s->player.special_energy = s->player.special_energy; + s->player.just_attacked = 1; + s->player.used_special_this_tick = 1; + s->player.last_attack_style = is_mage ? ATTACK_STYLE_MAGIC : ATTACK_STYLE_RANGED; + s->player.attack_style_this_tick = s->player.last_attack_style; + s->player.attack_timer = sr.attack_speed_override ? sr.attack_speed_override : ls->attack_speed; + + /* apply damage with per-hit capping */ + int total_dmg = 0; + for (int i = 0; i < sr.num_hits; i++) { + int dmg = zul_cap_damage(s, sr.damage[i]); + encounter_damage_player(&s->zulrah, dmg, NULL); + total_dmg += dmg; + } + + /* apply heal (blowpipe, SGS) */ + if (sr.heal > 0) { + s->player.current_hitpoints += sr.heal; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + } + + /* apply magic def drain (eye of ayak) */ + s->magic_def_drain += sr.magic_def_drain; + + 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); +} + + +/* 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 ? MONSTER_DATABASE[MON_ZULRAH_SNAKELING_MAGIC].max_hit + : MONSTER_DATABASE[MON_ZULRAH_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; + } + } +} + + +/* 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. + * known sim gap: positions are random; real game uses phase-specific targeting. */ +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); + } + } +} + + +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 */ + s->total_venom_ticks++; + 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; +} + + +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; +} + + +/* 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 MONSTER_DATABASE[MON_ZULRAH_GREEN].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++; + s->total_phases_completed++; + + 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); + } +} + + +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, + 0, 0, 0, 0); +} + +static void zul_process_prayer(ZulrahState* s, int overhead_action, int offensive_action) { + if (encounter_apply_overhead_action(&s->player.prayer, overhead_action)) { + s->player.prayer_just_activated = 1; + } + OffensivePrayer prev_offensive = s->player.offensive_prayer; + if (encounter_apply_offensive_action(&s->player.offensive_prayer, offensive_action)) { + s->player.offensive_prayer_just_activated = 1; + } + /* zulrah caches eff_level + max_hit in mage_stats / range_stats — recompute + whenever offensive prayer changes so subsequent attack rolls use current state. */ + if (s->player.offensive_prayer != prev_offensive) { + if (s->mage_stats.style == ATTACK_STYLE_MAGIC) { + encounter_update_loadout_level(&s->mage_stats, s->player.offensive_prayer, + s->player.current_magic, s->player.current_magic); + } + if (s->range_stats.style == ATTACK_STYLE_RANGED) { + encounter_update_loadout_level(&s->range_stats, s->player.offensive_prayer, + s->player.current_ranged, s->player.current_ranged); + } + if (s->human_command_mode) + zul_refresh_human_loadout_stats(s); + } +} + +static void zul_process_food(ZulrahState* s, int a) { + if (a == 0) return; + FoodType type = (a == 1) ? FOOD_SHARK : FOOD_KARAMBWAN; + int* count = (a == 1) ? &s->player.food_count : &s->player.karambwan_count; + if (*count <= 0) return; + EatResult r = osrs_eat_food(type, s->player.current_hitpoints, + s->player.base_hitpoints, s->player.food_timer); + if (!r.consumed) return; + (*count)--; + s->player.food_timer = 3; + s->player.current_hitpoints += r.hp_healed; + if (s->player.current_hitpoints > s->player.base_hitpoints) + s->player.current_hitpoints = s->player.base_hitpoints; + s->total_food_eaten++; +} + +static void zul_process_potion(ZulrahState* s, int a) { + if (a == 0) return; + if (a == 1) { + /* prayer potion */ + if (s->player.prayer_pot_doses <= 0) return; + DrinkResult r = osrs_drink_potion(POTION_PRAYER_RESTORE, s->player.current_prayer, + s->player.base_prayer, s->player.potion_timer); + if (!r.consumed) return; + s->player.prayer_pot_doses--; + s->player.potion_timer = 3; + s->player.current_prayer += r.prayer_restored; + if (s->player.current_prayer > s->player.base_prayer) + s->player.current_prayer = s->player.base_prayer; + s->total_potions_used++; + } else if (a == 2) { + /* antivenom: cures venom + grants immunity */ + if (s->player.antivenom_doses <= 0) return; + DrinkResult r = osrs_drink_potion(POTION_ANTIVENOM_PLUS, 0, 0, s->player.potion_timer); + if (!r.consumed) return; + s->player.antivenom_doses--; + s->player.potion_timer = 3; + s->venom_counter = 0; + s->venom_timer = 0; + s->antivenom_timer = r.antivenom_ticks; + s->total_potions_used++; + } +} + +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); + s->total_gear_switches++; + } 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); + s->total_gear_switches++; + } +} + +static FightStyle zul_default_fight_style_for_style(AttackStyle style) { + if (style == ATTACK_STYLE_MAGIC) return FIGHT_STYLE_ACCURATE; + if (style == ATTACK_STYLE_RANGED) return FIGHT_STYLE_RAPID; + return FIGHT_STYLE_ACCURATE; +} + +static void zul_sync_human_gear_style(ZulrahState* s) { + AttackStyle style = zul_player_equipped_attack_style(s); + s->player_gear = (style == ATTACK_STYLE_MAGIC) ? ZUL_GEAR_MAGE : ZUL_GEAR_RANGE; +} + +static void zul_apply_human_player_commands(ZulrahState* s) { + int did_change_stats = 0; + for (int i = 0; i < s->human_command_count; i++) { + const HumanCommand* cmd = &s->human_commands[i]; + if (cmd->kind == HUMAN_COMMAND_EQUIP_INVENTORY_ITEM) { + if (cmd->gear_slot >= 0 && cmd->gear_slot < NUM_GEAR_SLOTS && + cmd->item_db_idx >= 0 && cmd->item_db_idx < NUM_ITEMS) { + int changed = slot_equip_item(&s->player, cmd->gear_slot, (uint8_t)cmd->item_db_idx); + if (changed) { + s->total_gear_switches++; + did_change_stats = 1; + if (cmd->gear_slot == GEAR_SLOT_WEAPON) { + AttackStyle style = zul_player_equipped_attack_style(s); + s->player.fight_style = zul_default_fight_style_for_style(style); + zul_sync_human_gear_style(s); + } + } + } + } else if (cmd->kind == HUMAN_COMMAND_FIGHT_STYLE) { + if (cmd->fight_style >= FIGHT_STYLE_ACCURATE && + cmd->fight_style <= FIGHT_STYLE_DEFENSIVE_AUTOCAST) { + s->player.fight_style = (FightStyle)cmd->fight_style; + did_change_stats = 1; + } + } + } + if (did_change_stats) + zul_refresh_human_loadout_stats(s); +} + + + +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; + /* egocentric: distance to arena edges, normalized to [0,1] */ + obs[i++] = (float)s->player.x / ZUL_ARENA_SIZE; /* dist to west edge */ + obs[i++] = (float)s->player.y / ZUL_ARENA_SIZE; /* dist to south edge */ + 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.prayer_pot_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; + /* offensive prayer one-hot: piety, rigour, augury, (none implied by all three = 0) */ + obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_PIETY) ? 1.0f : 0.0f; + obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) ? 1.0f : 0.0f; + obs[i++] = (s->player.offensive_prayer == OFFENSIVE_PRAYER_AUGURY) ? 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 / MONSTER_DATABASE[MON_ZULRAH_GREEN].hp; + /* egocentric: zulrah position relative to player */ + obs[i++] = (float)(s->zulrah.x - s->player.x) / ZUL_ARENA_SIZE; + obs[i++] = (float)(s->zulrah.y - s->player.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 / MONSTER_DATABASE[MON_ZULRAH_GREEN].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 - s->player.x) / ZUL_ARENA_SIZE : 0.0f; + obs[i++] = s->clouds[c].active ? (float)(s->clouds[c].y - s->player.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 - s->player.x) / ZUL_ARENA_SIZE : 0.0f; + obs[i++] = sn->active ? (float)(sn->entity.y - s->player.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 / MONSTER_DATABASE[MON_ZULRAH_GREEN].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] - s->player.x) / ZUL_ARENA_SIZE; + obs[i++] = (float)(ZUL_STAND_COORDS[phase->stand][1] - s->player.y) / 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] - s->player.x) / ZUL_ARENA_SIZE; + obs[i++] = (float)(ZUL_STAND_COORDS[phase->stall][1] - s->player.y) / ZUL_ARENA_SIZE; + } else { + obs[i++] = 0.0f; obs[i++] = 0.0f; + } + + while (i < ZUL_NUM_OBS) obs[i++] = 0.0f; +} + + +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++; + } + /* overhead prayer: 0=no_change always valid, 1-3=toggle_melee/ranged/magic + require prayer points (deactivation also needs pp>0 because activations + are what cost — if pp=0 all prayers auto-clear anyway). */ + for (int p = 0; p < ZUL_PRAYER_DIM; p++) { + if (p >= ENCOUNTER_OVERHEAD_TOGGLE_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 - osrs_food_heal_amount(FOOD_SHARK)) + 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 - osrs_food_heal_amount(FOOD_KARAMBWAN)) + 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.prayer_pot_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->player.antivenom_doses <= 0 || s->player.potion_timer > 0 || + s->antivenom_timer > 0) + mask[off] = 0.0f; + off++; + /* spec toggle: allow when player has a spec weapon with cost > 0 */ + off++; /* none always valid */ + { + int weapon = s->player.equipped[GEAR_SLOT_WEAPON]; + int spec_cost = osrs_spec_cost(weapon); + if (spec_cost <= 0) + mask[off] = 0.0f; /* weapon has no spec */ + } + off++; + /* offensive prayer: 0=no_change always valid, 1-3=toggle_piety/rigour/augury + require prayer points. */ + for (int o = 0; o < ZUL_OFFENSIVE_DIM; o++) { + if (o >= ENCOUNTER_OFFENSIVE_TOGGLE_PIETY && s->player.current_prayer <= 0) + mask[off] = 0.0f; + off++; + } +} + + +static float zul_compute_reward(ZulrahState* s) { + /* terminal: +1 kill, 0 death (forfeiting future rewards is the penalty) */ + if (s->episode_over) + return (s->winner == 0) ? 1.0f : 0.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; +} + + +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.prayer_pot_doses = ZUL_PLAYER_RESTORE_DOSES; + s->player.special_energy = 100; + s->player.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; + } + osrs_interaction_init(&s->interaction); + s->player.spec_armed = 0; + 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); + /* derive combat stats from ITEM_DATABASE */ + OffensivePrayer mage_prayer = (s->gear_tier >= 1) ? OFFENSIVE_PRAYER_AUGURY : OFFENSIVE_PRAYER_NONE; + OffensivePrayer range_prayer = (s->gear_tier >= 1) ? OFFENSIVE_PRAYER_RIGOUR : OFFENSIVE_PRAYER_NONE; + /* mage loadout is trident / Eye of Ayak — powered staves, accurate stance gets +3 magic eff. + ranged runs in rapid stance for blowpipe/tbow (-1 attack_speed). */ + encounter_compute_loadout_stats(ZUL_MAGE_LOADOUT[s->gear_tier], ATTACK_STYLE_MAGIC, + mage_prayer, 99, FIGHT_STYLE_ACCURATE, 30, &s->mage_stats); + encounter_compute_loadout_stats(ZUL_RANGE_LOADOUT[s->gear_tier], ATTACK_STYLE_RANGED, + range_prayer, 99, FIGHT_STYLE_RAPID, 0, &s->range_stats); + /* 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 = MONSTER_DATABASE[MON_ZULRAH_GREEN].hp; + s->zulrah.current_hitpoints = MONSTER_DATABASE[MON_ZULRAH_GREEN].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); +} + +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); + } + + /* prayer doesn't interrupt interactions */ + zul_process_prayer(s, actions[ZUL_HEAD_PRAYER], actions[ZUL_HEAD_OFFENSIVE]); + + if (s->human_command_mode) + zul_apply_human_player_commands(s); + + /* spec toggle: arm/disarm (does NOT interrupt interaction) */ + if (actions[ZUL_HEAD_SPEC] == 1) { + osrs_spec_toggle(&s->player.spec_armed); + } + + /* inventory actions interrupt interaction */ + if (actions[ZUL_HEAD_FOOD] > 0) + osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_EAT); + if (actions[ZUL_HEAD_POTION] > 0) + osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_DRINK); + zul_process_food(s, actions[ZUL_HEAD_FOOD]); + zul_process_potion(s, actions[ZUL_HEAD_POTION]); + + /* gear switch from attack action — interrupts if actually switching */ + int atk_action = actions[ZUL_HEAD_ATTACK]; + if (!s->human_command_mode) { + if ((atk_action == ZUL_ATK_MAGE && s->player_gear != ZUL_GEAR_MAGE) || + (atk_action == ZUL_ATK_RANGE && s->player_gear != ZUL_GEAR_RANGE)) { + osrs_interaction_check_interrupt(&s->interaction, OSRS_IACT_EQUIP); + } + zul_process_gear(s, atk_action); + } + + /* attack action sets interaction target (zulrah is always entity slot 0) */ + if (atk_action == ZUL_ATK_MAGE || atk_action == ZUL_ATK_RANGE) { + osrs_interaction_set(&s->interaction, 0); + } + + /* explicit movement interrupts interaction */ + int has_explicit_move = 0; + if (s->player_dest_explicit) { + s->player_dest_explicit = 0; + has_explicit_move = 1; + } 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]; + has_explicit_move = 1; + } else { + s->player_dest_x = -1; + s->player_dest_y = -1; + } + } + if (has_explicit_move && !osrs_interaction_active(&s->interaction)) { + /* pure movement, no target */ + } + /* note: movement with active interaction = auto-chase */ + zul_process_movement(s); + + /* clear interaction if target not available */ + if (osrs_interaction_active(&s->interaction) && + (!s->zulrah_visible || s->is_diving)) { + osrs_interaction_clear(&s->interaction); + } + + /* auto-attack: fires when interaction is active + timer ready + target visible */ + if (osrs_interaction_active(&s->interaction) && + s->player.attack_timer == 0 && s->zulrah_visible && !s->is_diving && + s->player_stunned_ticks == 0) { + + if (s->player.spec_armed && s->player.special_energy >= osrs_spec_cost(s->player.equipped[GEAR_SLOT_WEAPON])) { + zul_player_spec(s); + osrs_spec_disarm(&s->player.spec_armed); + } else { + if (s->player_gear == ZUL_GEAR_MAGE) zul_player_attack(s, 1); + else 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 — both overhead and offensive, with activation-tick skip. + drain can clear offensive_prayer at pp<=0; refresh mage/range caches + afterwards so subsequent attacks don't use stale prayer-boosted stats. */ + OffensivePrayer prev_off_drain = s->player.offensive_prayer; + encounter_drain_all_prayers(&s->player, 0); + if (s->player.offensive_prayer != prev_off_drain) { + if (s->mage_stats.style == ATTACK_STYLE_MAGIC) { + encounter_update_loadout_level(&s->mage_stats, s->player.offensive_prayer, + s->player.current_magic, s->player.current_magic); + } + if (s->range_stats.style == ATTACK_STYLE_RANGED) { + encounter_update_loadout_level(&s->range_stats, s->player.offensive_prayer, + s->player.current_ranged, s->player.current_ranged); + } + if (s->human_command_mode) + zul_refresh_human_loadout_stats(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; + } + 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; +} + + +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. + heuristic picks target-prayer; toggle semantic means if target is already + on this call is a no-op (since actual state == target), and if wrong one + was on it replaces. only emit if player isn't already on target. */ + if (s->zulrah_visible && !s->is_diving) { + switch (s->current_form) { + case ZUL_FORM_GREEN: + if (s->player.prayer != PRAYER_PROTECT_RANGED) + actions[ZUL_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_TOGGLE_RANGED; + break; + case ZUL_FORM_BLUE: + if (s->player.prayer != PRAYER_PROTECT_MAGIC) + actions[ZUL_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_TOGGLE_MAGIC; + break; + case ZUL_FORM_RED: + if (s->player.prayer != PRAYER_PROTECT_MELEE) + actions[ZUL_HEAD_PRAYER] = ENCOUNTER_OVERHEAD_TOGGLE_MELEE; + break; + } + /* offensive prayer: match attack style. green/blue use mage/ranged → piety is wrong. + zulrah heuristic normally alternates mage/ranged so use augury when mage, rigour when ranged. */ + OffensivePrayer target_off = OFFENSIVE_PRAYER_NONE; + if (s->current_form == ZUL_FORM_BLUE) target_off = OFFENSIVE_PRAYER_AUGURY; + else if (s->current_form == ZUL_FORM_GREEN) target_off = OFFENSIVE_PRAYER_RIGOUR; + else if (s->current_form == ZUL_FORM_RED) target_off = OFFENSIVE_PRAYER_PIETY; + if (target_off != OFFENSIVE_PRAYER_NONE && s->player.offensive_prayer != target_off) { + if (target_off == OFFENSIVE_PRAYER_AUGURY) + actions[ZUL_HEAD_OFFENSIVE] = ENCOUNTER_OFFENSIVE_TOGGLE_AUGURY; + else if (target_off == OFFENSIVE_PRAYER_RIGOUR) + actions[ZUL_HEAD_OFFENSIVE] = ENCOUNTER_OFFENSIVE_TOGGLE_RIGOUR; + else if (target_off == OFFENSIVE_PRAYER_PIETY) + actions[ZUL_HEAD_OFFENSIVE] = ENCOUNTER_OFFENSIVE_TOGGLE_PIETY; + } + } + + /* antivenom on first tick or when timer about to expire */ + if (s->player.potion_timer <= 0 && s->player.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 - osrs_food_heal_amount(FOOD_SHARK)) { + 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 - osrs_food_heal_amount(FOOD_KARAMBWAN)) { + 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.prayer_pot_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) { + if (s->current_form == ZUL_FORM_BLUE) { + actions[ZUL_HEAD_ATTACK] = ZUL_ATK_RANGE; + /* arm spec when energy available and not already armed */ + int spec_cost = osrs_spec_cost(ZUL_RANGE_LOADOUT[s->gear_tier][GEAR_SLOT_WEAPON]); + if (spec_cost > 0 && s->player.special_energy >= spec_cost && !s->player.spec_armed) { + actions[ZUL_HEAD_SPEC] = 1; /* toggle arm */ + } + } else { + actions[ZUL_HEAD_ATTACK] = ZUL_ATK_MAGE; + } + } +} + + +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++]); + /* snakelings face player when in attack range */ + int adx = abs(s->snakelings[i].entity.x - s->player.x); + int ady = abs(s->snakelings[i].entity.y - s->player.y); + if (adx <= 1 && ady <= 1) + out[n - 1].attack_target_entity_idx = 0; + } + } + /* player faces zulrah when interaction is active (persistent until interrupted) */ + if (osrs_interaction_active(&s->interaction)) + out[0].attack_target_entity_idx = 1; + /* zulrah faces player during attack phases */ + if (s->zulrah_attacking && s->zulrah_visible && !s->is_diving) + out[1].attack_target_entity_idx = 0; + *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; + if (value >= 0) s->player_dest_explicit = 1; + } + else if (strcmp(key, "player_dest_y") == 0) { + s->player_dest_y = value; + if (value >= 0) s->player_dest_explicit = 1; + } + else if (strcmp(key, "human_command_mode") == 0) s->human_command_mode = value; +} +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; + + /* hazards */ + ov->hazard_count = 0; + for (int i = 0; i < ZUL_MAX_CLOUDS && ov->hazard_count < ENCOUNTER_MAX_OVERLAY_TILES; i++) { + if (!s->clouds[i].active) continue; + ov->hazards[ov->hazard_count].x = s->clouds[i].x; + ov->hazards[ov->hazard_count].y = s->clouds[i].y; + ov->hazards[ov->hazard_count].active = 1; + ov->hazard_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; + + /* adds */ + ov->add_count = 0; + for (int i = 0; i < ZUL_MAX_SNAKELINGS && ov->add_count < ENCOUNTER_MAX_OVERLAY_ADDS; i++) { + if (!s->snakelings[i].active) continue; + int si = ov->add_count++; + ov->adds[si].x = s->snakelings[i].entity.x; + ov->adds[si].y = s->snakelings[i].entity.y; + ov->adds[si].active = 1; + ov->adds[si].variant = 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, 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, 0); + } + } + 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, 0); + } +} +static int zul_get_winner(EncounterState* state) { return ((ZulrahState*)state)->winner; } + + +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); + encounter_translate_offensive_prayer(hi, actions, ZUL_HEAD_OFFENSIVE); + + /* 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; +} + +static int zul_attack_action_for_weapon(uint8_t weapon) { + AttackStyle style = (AttackStyle)get_item_attack_style(weapon); + return (style == ATTACK_STYLE_MAGIC) ? ZUL_ATK_MAGE : ZUL_ATK_RANGE; +} + +static void zul_translate_human_commands(HumanInput* hi, int* actions, ZulrahState* s) { + for (int h = 0; h < ZUL_NUM_ACTION_HEADS; h++) actions[h] = 0; + + uint8_t queued_weapon = s->player.equipped[GEAR_SLOT_WEAPON]; + for (int i = 0; i < hi->commands.count; i++) { + const HumanCommand* cmd = &hi->commands.items[i]; + if (cmd->kind == HUMAN_COMMAND_EQUIP_INVENTORY_ITEM && + cmd->gear_slot == GEAR_SLOT_WEAPON && + cmd->item_db_idx >= 0 && cmd->item_db_idx < NUM_ITEMS) { + queued_weapon = (uint8_t)cmd->item_db_idx; + } + + switch (cmd->kind) { + case HUMAN_COMMAND_WALK: + s->player_dest_x = cmd->world_x; + s->player_dest_y = cmd->world_y; + s->player_dest_explicit = 1; + actions[ZUL_HEAD_ATTACK] = ZUL_ATK_NONE; + osrs_interaction_clear(&s->interaction); + break; + case HUMAN_COMMAND_ATTACK_NPC: + actions[ZUL_HEAD_ATTACK] = zul_attack_action_for_weapon(queued_weapon); + s->player_dest_x = -1; + s->player_dest_y = -1; + s->player_dest_explicit = 0; + break; + case HUMAN_COMMAND_SPELL_TARGET: + actions[ZUL_HEAD_ATTACK] = ZUL_ATK_MAGE; + s->player_dest_x = -1; + s->player_dest_y = -1; + s->player_dest_explicit = 0; + break; + case HUMAN_COMMAND_OVERHEAD_PRAYER: + actions[ZUL_HEAD_PRAYER] = cmd->overhead_prayer; + break; + case HUMAN_COMMAND_OFFENSIVE_PRAYER: + actions[ZUL_HEAD_OFFENSIVE] = cmd->offensive_prayer; + break; + case HUMAN_COMMAND_EAT: + actions[ZUL_HEAD_FOOD] = cmd->food == 1 ? 2 : 1; + break; + case HUMAN_COMMAND_DRINK: + if (cmd->potion == POTION_BREW) actions[ZUL_HEAD_FOOD] = 1; + else if (cmd->potion == POTION_RESTORE || + cmd->potion == POTION_PRAYER_POT) actions[ZUL_HEAD_POTION] = 1; + else if (cmd->potion == POTION_ANTIVENOM) actions[ZUL_HEAD_POTION] = 2; + break; + case HUMAN_COMMAND_SPEC_TOGGLE: + actions[ZUL_HEAD_SPEC] = 1; + break; + case HUMAN_COMMAND_EQUIP_INVENTORY_ITEM: + case HUMAN_COMMAND_FIGHT_STYLE: + case HUMAN_COMMAND_NONE: + break; + } + } +} + +static void zul_step_human_commands(EncounterState* state, HumanInput* hi) { + ZulrahState* s = (ZulrahState*)state; + int actions[ZUL_NUM_ACTION_HEADS]; + s->human_command_mode = 1; + s->human_commands = hi->commands.items; + s->human_command_count = hi->commands.count; + zul_sync_human_gear_style(s); + zul_refresh_human_loadout_stats(s); + zul_translate_human_commands(hi, actions, s); + zul_step(state, actions); + s->human_commands = NULL; + s->human_command_count = 0; + human_input_clear_pending(hi); +} + + +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, + .step_human_commands = zul_step_human_commands, + .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/osrs_anim.h b/ocean/osrs/osrs_anim.h new file mode 100644 index 0000000000..8bb351288b --- /dev/null +++ b/ocean/osrs/osrs_anim.h @@ -0,0 +1,655 @@ +/** + * @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_ANIM_H +#define OSRS_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 + + +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; +} + + +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; + + +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; +} + + +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; +} + + +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); +} + + +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 — matches Model.java:1074-1080 + * new_x = cos_y*x + sin_y*z; new_z = cos_y*z - sin_y*x */ + rx = (vx * cos_y + vz * sin_y) >> 16; + rz = (vz * cos_y - vx * sin_y) >> 16; + vx = rx; vz = rz; + + state->verts[v * 3] = (int16_t)(vx + pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + pivot_y); + state->verts[v * 3 + 2] = (int16_t)(vz + pivot_z); + } + } + } else if (type == 3) { + /* scale: relative to pivot, 128 = 1.0x identity */ + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + /* label is uint8_t, always < 256 = ANIM_MAX_LABELS */ + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - pivot_x; + int vy = state->verts[v * 3 + 1] - pivot_y; + int vz = state->verts[v * 3 + 2] - pivot_z; + + vx = (vx * dx) / 128; + vy = (vy * dy) / 128; + vz = (vz * dz) / 128; + + state->verts[v * 3] = (int16_t)(vx + pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + pivot_y); + state->verts[v * 3 + 2] = (int16_t)(vz + pivot_z); + } + } + } + /* type 5 (alpha) skipped — we don't use face transparency in the viewer */ + } +} + + +/** + * 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 = (vz * cos_y - vx * sin_y) >> 16; + state->verts[v * 3] = (int16_t)(rx + *pivot_x); + state->verts[v * 3 + 1] = (int16_t)(vy + *pivot_y); + state->verts[v * 3 + 2] = (int16_t)(rz + *pivot_z); + } + } + } else if (type == 3) { + for (int m = 0; m < map_len; m++) { + uint8_t label = labels[m]; + for (int vi = 0; vi < state->group_counts[label]; vi++) { + int v = state->groups[label][vi]; + int vx = state->verts[v * 3] - *pivot_x; + int vy = state->verts[v * 3 + 1] - *pivot_y; + int vz = state->verts[v * 3 + 2] - *pivot_z; + state->verts[v * 3] = (int16_t)((vx * dx) / 128 + *pivot_x); + state->verts[v * 3 + 1] = (int16_t)((vy * dy) / 128 + *pivot_y); + state->verts[v * 3 + 2] = (int16_t)((vz * dz) / 128 + *pivot_z); + } + } + } +} + +/** + * Apply two animation frames with body-part interleaving. + * + * Mirrors OSRS Model.applyAnimationFrames(): + * - interleave_order lists framebase SLOT INDICES owned by SECONDARY (walk) + * - Pass 1: apply primary transforms for slots NOT in interleave_order + * - Pass 2: apply secondary transforms for slots IN interleave_order + * - Type-0 (pivot) transforms always execute in both passes + * + * CRITICAL: interleave_order contains framebase SLOT INDICES, not vertex labels! + * The reference code (Model.java:1322-1343) walks both the frame's slot list and + * the interleave_order simultaneously, comparing slot indices directly. + * + * Both passes operate on the same vertex state with independent pivot tracking, + * exactly as the reference does with transformTempX/Y/Z reset between passes. + */ +static void anim_apply_frame_interleaved( + AnimModelState* state, + const int16_t* base_verts_src, + const AnimFrameData* secondary_frame, const AnimFrameBase* secondary_fb, + const AnimFrameData* primary_frame, const AnimFrameBase* primary_fb, + const uint8_t* interleave_order, int interleave_count +) { + /* reset to base pose */ + memcpy(state->verts, base_verts_src, state->vert_count * 3 * sizeof(int16_t)); + + /* build boolean mask: interleave_order lists SLOT INDICES the SECONDARY owns. + index by slot index (0-244 for our 245-slot framebase), NOT vertex labels. */ + uint8_t secondary_slot[256]; + memset(secondary_slot, 0, sizeof(secondary_slot)); + for (int i = 0; i < interleave_count; i++) { + secondary_slot[interleave_order[i]] = 1; + } + + /* pass 1: primary frame — apply transforms for slots NOT in interleave_order. + * type-0 (pivot) always executes regardless of ownership. + * matches reference: if (k1 != i1 || class18.types[k1] == 0) */ + int pivot_x = 0, pivot_y = 0, pivot_z = 0; + for (int t = 0; t < primary_frame->transform_count; t++) { + uint8_t slot_idx = primary_frame->transforms[t].slot_index; + if (slot_idx >= primary_fb->slot_count) continue; + + int type = primary_fb->types[slot_idx]; + int in_interleave = secondary_slot[slot_idx]; + + if (!in_interleave || type == 0) { + anim_apply_single_transform( + state, type, + primary_fb->frame_maps[slot_idx], + primary_fb->map_lengths[slot_idx], + primary_frame->transforms[t].dx, + primary_frame->transforms[t].dy, + primary_frame->transforms[t].dz, + &pivot_x, &pivot_y, &pivot_z); + } + } + + /* pass 2: secondary frame — apply transforms for slots IN interleave_order. + * type-0 (pivot) always executes. + * matches reference: if (i2 == i1 || class18.types[i2] == 0) */ + pivot_x = 0; pivot_y = 0; pivot_z = 0; + for (int t = 0; t < secondary_frame->transform_count; t++) { + uint8_t slot_idx = secondary_frame->transforms[t].slot_index; + if (slot_idx >= secondary_fb->slot_count) continue; + + int type = secondary_fb->types[slot_idx]; + int in_interleave = secondary_slot[slot_idx]; + + if (in_interleave || type == 0) { + anim_apply_single_transform( + state, type, + secondary_fb->frame_maps[slot_idx], + secondary_fb->map_lengths[slot_idx], + secondary_frame->transforms[t].dx, + secondary_frame->transforms[t].dy, + secondary_frame->transforms[t].dz, + &pivot_x, &pivot_y, &pivot_z); + } + } +} + + +/** + * 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]; + } +} + + +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_ANIM_H */ diff --git a/ocean/osrs/osrs_bolt_procs.h b/ocean/osrs/osrs_bolt_procs.h new file mode 100644 index 0000000000..4a0fda64b8 --- /dev/null +++ b/ocean/osrs/osrs_bolt_procs.h @@ -0,0 +1,106 @@ +/** + * @file osrs_bolt_procs.h + * @brief enchanted crossbow bolt proc system (diamond, opal, ruby). + * + * bolt procs trigger on ranged crossbow hits with a % chance per hit. + * zaryte crossbow (ZCB) special attack guarantees the proc and uses + * enhanced multipliers. assumes kandarin hard diary completed (1.1x chance). + * + * SHARED FUNCTIONS: + * osrs_resolve_bolt_proc(bolt, dmg, acc, max, rlvl, hp, zcb, rng) + * + * REFERENCE: + * .refs/osrs-dps-calc/src/lib/dists/bolts.ts + * + * SUPPORTED BOLT TYPES: + * ITEM_DIAMOND_BOLTS_E / ITEM_DIAMOND_DRAGON_BOLTS_E + * - 11% chance, re-rolls damage from [0, floor(maxHit*115/100)] + * - ZCB: guaranteed, effectMax uses 126/100 + * - accurate hits only (ZCB spec bypasses) + * + * ITEM_OPAL_DRAGON_BOLTS + * - 5.5% chance, adds floor(rangedLvl/10) bonus damage + * - ZCB: guaranteed, divisor 9 instead of 10 + * - works on misses too + * + * ITEM_RUBY_DRAGON_BOLTS_E + * - 6.6% chance, deals floor(targetHP*20/100) capped at 100 + * - ZCB: guaranteed, 22/100 capped at 110 + * - accurate hits only + */ + +#ifndef OSRS_BOLT_PROCS_H +#define OSRS_BOLT_PROCS_H + +#include "osrs_combat.h" +#include "osrs_items.h" + +typedef struct { + int proc_triggered; /* 1 if bolt effect fired */ + int modified_damage; /* new damage value (replaces base_damage when proc fires) */ +} BoltProcResult; + +/* resolve bolt proc on a crossbow hit. + returns proc_triggered=0 if no proc (or bolt type not recognized). + when proc_triggered=1, modified_damage is the new damage to use. + + bolt_item_idx: ITEM_DIAMOND_BOLTS_E, ITEM_OPAL_DRAGON_BOLTS, etc. + base_damage: damage from the normal accuracy+damage roll + hit_accurate: 1 if the attack passed accuracy, 0 if miss + max_hit: base max hit before bolt effect (for diamond re-roll) + ranged_level: visible ranged level (for opal bonus) + target_current_hp: target's current HP (for ruby bolt) + is_zcb_spec: 1 if zaryte crossbow spec active (guaranteed proc + enhanced) + rng_state: xorshift32 state pointer */ +static inline BoltProcResult osrs_resolve_bolt_proc( + int bolt_item_idx, int base_damage, int hit_accurate, + int max_hit, int ranged_level, int target_current_hp, + int is_zcb_spec, uint32_t* rng_state +) { + BoltProcResult r = { 0, base_damage }; + + switch (bolt_item_idx) { + + case ITEM_DIAMOND_BOLTS_E: + case ITEM_DIAMOND_DRAGON_BOLTS_E: { + if (!hit_accurate && !is_zcb_spec) break; + float chance = 0.11f; /* 10% * 1.1 kandarin diary */ + if (is_zcb_spec || encounter_rand_float(rng_state) < chance) { + int effect_max = max_hit * (is_zcb_spec ? 126 : 115) / 100; + r.proc_triggered = 1; + r.modified_damage = encounter_rand_int(rng_state, effect_max + 1); + } + break; + } + + case ITEM_OPAL_DRAGON_BOLTS: { + float chance = 0.055f; /* 5% * 1.1 */ + if (is_zcb_spec || encounter_rand_float(rng_state) < chance) { + int bonus = ranged_level / (is_zcb_spec ? 9 : 10); + r.proc_triggered = 1; + r.modified_damage = base_damage + bonus; + } + break; + } + + case ITEM_RUBY_DRAGON_BOLTS_E: { + if (!hit_accurate) break; + float chance = 0.066f; /* 6% * 1.1 */ + if (is_zcb_spec || encounter_rand_float(rng_state) < chance) { + int cap = is_zcb_spec ? 110 : 100; + int effect_dmg = target_current_hp * (is_zcb_spec ? 22 : 20) / 100; + if (effect_dmg > cap) effect_dmg = cap; + r.proc_triggered = 1; + r.modified_damage = effect_dmg; + } + break; + } + + default: + break; + } + + return r; +} + +#endif /* OSRS_BOLT_PROCS_H */ diff --git a/ocean/osrs/osrs_collision.h b/ocean/osrs/osrs_collision.h new file mode 100644 index 0000000000..0c015a428d --- /dev/null +++ b/ocean/osrs/osrs_collision.h @@ -0,0 +1,552 @@ +/** + * @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 + +#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 + +#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; + +#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; + +/** 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; +} + +/** 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); +} + +/** 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); + } + } +} + +/** 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; +} + +#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; +} + +#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; +} + +static inline int los_intervals_overlap(int a0, int a1, int b0, int b1) { + return !(a1 < b0 || b1 < a0); +} + +/* generic LOS between two entity footprints. + * ref: osrs-sdk LineOfSight.ts playerHasLineOfSightOfMob() and + * mobHasLineOfSightToMob(). ranged/magic uses closest points on both + * footprints. melee is pure cardinal adjacency between footprint edges. */ +static inline int entity_has_line_of_sight( + const LOSBlocker* blockers, int blocker_count, + int ax, int ay, int a_size, + int tx, int ty, int t_size, + int range +) { + if (range == 1) { + if (los_aabb_overlap(ax, ay, a_size, tx, ty, t_size)) return 0; + + int a_x0 = ax; + int a_x1 = ax + a_size - 1; + int a_y0 = ay; + int a_y1 = ay + a_size - 1; + int t_x0 = tx; + int t_x1 = tx + t_size - 1; + int t_y0 = ty; + int t_y1 = ty + t_size - 1; + + return (a_x1 + 1 == t_x0 && los_intervals_overlap(a_y0, a_y1, t_y0, t_y1)) || + (t_x1 + 1 == a_x0 && los_intervals_overlap(a_y0, a_y1, t_y0, t_y1)) || + (a_y1 + 1 == t_y0 && los_intervals_overlap(a_x0, a_x1, t_x0, t_x1)) || + (t_y1 + 1 == a_y0 && los_intervals_overlap(a_x0, a_x1, t_x0, t_x1)); + } + + int a_px = tx; + if (a_px < ax) a_px = ax; + if (a_px >= ax + a_size) a_px = ax + a_size - 1; + int a_py = ty; + if (a_py < ay) a_py = ay; + if (a_py >= ay + a_size) a_py = ay + a_size - 1; + + int t_px = ax; + if (t_px < tx) t_px = tx; + if (t_px >= tx + t_size) t_px = tx + t_size - 1; + int t_py = ay; + if (t_py < ty) t_py = ty; + if (t_py >= ty + t_size) t_py = ty + t_size - 1; + + return has_line_of_sight(blockers, blocker_count, a_px, a_py, t_px, t_py, 1, range); +} + +/* NPC LOS wrapper for the common "NPC footprint to 1x1 target" case. */ +static inline 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) { + return entity_has_line_of_sight( + blockers, blocker_count, + nx, ny, npc_size, + tx, ty, 1, + range); +} + +#endif /* OSRS_COLLISION_H */ diff --git a/ocean/osrs/osrs_combat.h b/ocean/osrs/osrs_combat.h new file mode 100644 index 0000000000..3addfe2ff5 --- /dev/null +++ b/ocean/osrs/osrs_combat.h @@ -0,0 +1,499 @@ +/** + * @fileoverview osrs_combat.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 + * + * PLAYER COMBAT: + * osrs_player_eff_level(base,prayer,style) effective level calculation + * osrs_player_att_roll(eff,bonus) attack roll + * osrs_player_melee_max_hit(eff,str) melee max hit + * osrs_player_ranged_max_hit(eff,str) ranged max hit + * osrs_player_magic_max_hit(base,pct) magic max hit + * osrs_prayer_reduce_damage(dmg,pr,st,pvp) PvE 100% block vs PvP 40% reduction + * osrs_hit_chance_double(att,def) osmumten/confliction double roll + * osrs_sum_equipment_bonuses(loadout,out) sum gear stats from ITEM_DATABASE + * + * SEE ALSO: + * osrs_special_attacks.h weapon special attack dispatch (blowpipe spec moved here) + * 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_H +#define OSRS_COMBAT_H + +#include +#include +#include +#include "osrs_types.h" +#include "osrs_items.h" + +/* 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)); +} + +static inline float osrs_hit_chance_double(int att_roll, int def_roll); + +/* 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; +} + + +/* 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; +} + + +#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, + int primary_use_double_accuracy +) { + 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 = primary_use_double_accuracy + ? osrs_hit_chance_double(att_roll, def_roll) + : 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 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); +} + +/* pick the correct player defence bonus for an incoming NPC attack. + attack_style: 1=melee, 2=ranged, 3=magic. + melee_style: 0=stab, 1=slash, 2=crush (only used when attack_style == 1). */ +static inline int encounter_player_def_bonus( + int def_stab, int def_slash, int def_crush, int def_magic, int def_ranged, + int attack_style, int melee_style +) { + if (attack_style == 2) return def_ranged; /* ATTACK_STYLE_RANGED */ + if (attack_style == 3) return def_magic; /* ATTACK_STYLE_MAGIC */ + /* melee: select by sub-style */ + if (melee_style == 1) return def_slash; /* MELEE_STYLE_SLASH */ + if (melee_style == 2) return def_crush; /* MELEE_STYLE_CRUSH */ + return def_stab; /* MELEE_STYLE_STAB */ +} + +/* 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 */); +} + + +/* 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); +} + +/* 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; + } +} +/* player-side combat primitives */ +/* */ +/* pure math for player effective levels, attack rolls, and max hits. */ +/* ref: .refs/osrs-dps-calc/src/lib/PlayerVsNPCCalc.ts */ +/* .refs/osrs-dps-calc/src/lib/BaseCalc.ts:105-110 */ +/* player effective level: floor(base * prayer_mult) + style_bonus + 8. + prayer_mult: 1.0 (none), 1.20 (piety/rigour att), 1.23 (piety/rigour str), + 1.25 (augury). style_bonus: 0 (rapid/autocast), +3 (accurate), +1 (controlled). + ref: PlayerVsNPCCalc.ts lines 191-208 */ +static inline int osrs_player_eff_level(int base_level, float prayer_mult, int style_bonus) { + return (int)(base_level * prayer_mult) + style_bonus + 8; +} +/* stance (FightStyle) → combat modifiers. */ +/* */ +/* single source of truth for "what does this stance do". replaces the raw */ +/* `int style_bonus` that callers used to pass around — that discarded the */ +/* why (rapid? autocast? both 0 but speed differs), and forced each caller */ +/* to re-derive the mapping. */ +/* */ +/* ref: osrs wiki "Combat Options", .refs/osrs-dps-calc PlayerVsNPCCalc.ts */ +/* and Equipment.ts:245-270 (rapid speed -1). */ +/* attack level bonus for the stance. + melee: accurate +3, controlled +1. + ranged: accurate +3. + magic powered staff: accurate +3 (wiki) — passed only when style is MAGIC. + all autocast stances and rapid/longrange give 0. */ +static inline int osrs_stance_att_bonus(FightStyle fs, AttackStyle atk) { + switch (fs) { + case FIGHT_STYLE_ACCURATE: return 3; + case FIGHT_STYLE_CONTROLLED: return atk == ATTACK_STYLE_MELEE ? 1 : 0; + case FIGHT_STYLE_LONGRANGE: return atk == ATTACK_STYLE_MAGIC ? 1 : 0; /* powered staff longrange = +1 magic */ + default: return 0; + } +} + +/* strength level bonus (melee only). aggressive +3, controlled +1. */ +static inline int osrs_stance_str_bonus(FightStyle fs) { + switch (fs) { + case FIGHT_STYLE_AGGRESSIVE: return 3; + case FIGHT_STYLE_CONTROLLED: return 1; + default: return 0; + } +} + +/* defence level bonus. defensive/longrange +3, controlled +1. */ +static inline int osrs_stance_def_bonus(FightStyle fs) { + switch (fs) { + case FIGHT_STYLE_DEFENSIVE: + case FIGHT_STYLE_LONGRANGE: return 3; + case FIGHT_STYLE_CONTROLLED: return 1; + default: return 0; + } +} + +/* attack speed modifier (ticks to add to weapon base speed). + rapid is the only stance that changes speed (-1 tick) per dps-calc + Equipment.ts:248-249. everything else uses the weapon's base speed. */ +static inline int osrs_stance_speed_mod(FightStyle fs) { + return fs == FIGHT_STYLE_RAPID ? -1 : 0; +} + +/* attack range modifier (tiles to add to weapon base range). + longrange adds +2 tiles (e.g. blowpipe 5 → 7). */ +static inline int osrs_stance_range_mod(FightStyle fs) { + return fs == FIGHT_STYLE_LONGRANGE ? 2 : 0; +} + +/* player attack roll: eff_level * (equipment_bonus + 64). + ref: PlayerVsNPCCalc.ts line 212 */ +static inline int osrs_player_att_roll(int eff_level, int equipment_bonus) { + return eff_level * (equipment_bonus + 64); +} + +/* player melee max hit: floor((eff_str * (str_bonus + 64) + 320) / 640). + ref: BaseCalc.ts:107 trackMaxHitFromEffective */ +static inline int osrs_player_melee_max_hit(int eff_str_level, int str_bonus) { + return (eff_str_level * (str_bonus + 64) + 320) / 640; +} + +/* player ranged max hit: same formula as melee, different input stats. + ref: BaseCalc.ts:107 (same formula, ranged strength bonus instead of melee) */ +static inline int osrs_player_ranged_max_hit(int eff_range_level, int ranged_str_bonus) { + return (eff_range_level * (ranged_str_bonus + 64) + 320) / 640; +} + +/* player magic max hit: floor(spell_base_dmg * (100 + magic_dmg_pct) / 100). + magic_dmg_pct is the total % bonus from gear (e.g. 30 = +30%). + spell_base_dmg: 30 for ice/blood barrage, floor(magic/3)-6 for trident, etc. + ref: PlayerVsNPCCalc.ts lines 622-667 */ +static inline int osrs_player_magic_max_hit(int spell_base_dmg, int magic_dmg_pct) { + return spell_base_dmg * (100 + magic_dmg_pct) / 100; +} + +/* prayer damage reduction. + PvE (is_pvp=0): correct overhead prayer blocks 100% of damage → returns 0. + PvP (is_pvp=1): correct overhead prayer reduces by 40% → returns floor(dmg * 0.6). + wrong prayer or no prayer: returns damage unchanged. + ref: osrs wiki "protection prayers", osrs-dps-calc */ +static inline int osrs_prayer_reduce_damage(int damage, int prayer, int attack_style, int is_pvp) { + if (damage <= 0) return 0; + if (!encounter_prayer_correct_for_style(prayer, attack_style)) return damage; + if (is_pvp) return (int)(damage * 0.6f); + return 0; /* PvE: full block */ +} + +/* double accuracy roll (osmumten's fang, confliction gauntlets). + rolls accuracy twice — hit if EITHER roll succeeds. + effective chance: 1 - (1-p)^2 where p = single roll hit chance. + closed-form from wiki: + if att >= def: 1 - (def+2)(2*def+3) / (6*(att+1)^2) + if att < def: att*(4*att+5) / (6*(att+1)*(def+1)) + ref: osrs wiki "osmumten's fang", encounter_zulrah.h:782-789 */ +static inline float osrs_hit_chance_double(int att_roll, int def_roll) { + float fa = (float)att_roll, fd = (float)def_roll; + if (att_roll >= def_roll) { + 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)); +} + +/* sum equipment bonuses from a gear loadout using ITEM_DATABASE. + iterates all slots, sums all offensive + defensive bonuses. + attack_speed and attack_range come from the weapon slot only. + ITEM_NONE (255) slots are skipped. + same data as GearBonuses (osrs_types.h) but with different field naming + convention (attack_stab vs stab_attack). see osrs_pvp_gear.h adapter. */ +typedef struct { + int attack_stab, attack_slash, attack_crush, attack_magic, attack_ranged; + int defence_stab, defence_slash, defence_crush, defence_magic, defence_ranged; + int melee_strength, ranged_strength, magic_damage, prayer; + int attack_speed, attack_range; +} EquipmentBonuses; + +static inline void osrs_sum_equipment_bonuses(const uint8_t loadout[NUM_GEAR_SLOTS], + EquipmentBonuses* out) { + memset(out, 0, sizeof(*out)); + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t idx = loadout[slot]; + if (idx == 255) continue; /* ITEM_NONE */ + const Item* item = &ITEM_DATABASE[idx]; + out->attack_stab += item->attack_stab; + out->attack_slash += item->attack_slash; + out->attack_crush += item->attack_crush; + out->attack_magic += item->attack_magic; + out->attack_ranged += item->attack_ranged; + out->defence_stab += item->defence_stab; + out->defence_slash += item->defence_slash; + out->defence_crush += item->defence_crush; + out->defence_magic += item->defence_magic; + out->defence_ranged += item->defence_ranged; + out->melee_strength += item->melee_strength; + out->ranged_strength += item->ranged_strength; + out->magic_damage += item->magic_damage; + out->prayer += item->prayer; + } + /* weapon slot determines speed + range */ + uint8_t weapon = loadout[GEAR_SLOT_WEAPON]; + if (weapon != 255) { + out->attack_speed = ITEM_DATABASE[weapon].attack_speed; + out->attack_range = ITEM_DATABASE[weapon].attack_range; + } +} + +#endif /* OSRS_COMBAT_H */ diff --git a/ocean/osrs/osrs_consumables.h b/ocean/osrs/osrs_consumables.h new file mode 100644 index 0000000000..46dca1619a --- /dev/null +++ b/ocean/osrs/osrs_consumables.h @@ -0,0 +1,173 @@ +/** + * @fileoverview osrs_consumables.h — shared food, potion, and brew consumption. + * + * pure functions that compute the effect of consuming food/potions/brews. + * encounters call these instead of inlining eat/drink logic per encounter. + * functions do NOT mutate state — they return result structs that the caller + * applies. this keeps them testable and encounter-agnostic. + * + * SHARED FUNCTIONS: + * osrs_food_heal_amount(type) heal amount for a food type + * osrs_eat_food(type, hp, max, tmr) compute food eat result + * osrs_drink_potion(type, ...) compute potion drink result + * osrs_brew_effect(base levels) compute saradomin brew effect + * osrs_can_eat(timer) check if food timer allows eating + * osrs_can_drink(timer) check if potion timer allows drinking + * + * ref: OSRS wiki food/potion articles, osrs-dps-calc + */ + +#ifndef OSRS_CONSUMABLES_H +#define OSRS_CONSUMABLES_H + +#include + +/* food types */ +typedef enum { + FOOD_SHARK = 0, + FOOD_KARAMBWAN, + FOOD_MANTA_RAY, + FOOD_ANGLERFISH, + FOOD_SARADOMIN_BREW, + NUM_FOOD_TYPES +} FoodType; + +/* potion types */ +typedef enum { + POTION_PRAYER_RESTORE = 0, + POTION_SUPER_RESTORE, + POTION_ANTIVENOM_PLUS, + POTION_RANGING, + POTION_SUPER_COMBAT, + POTION_IMBUED_HEART, + NUM_POTION_TYPES +} PotionType; + +/* result from eating food */ +typedef struct { + int hp_healed; + int consumed; /* 1 if food was actually eaten */ +} EatResult; + +/* result from drinking a potion */ +typedef struct { + int prayer_restored; + int level_boost; + int venom_cured; + int antivenom_ticks; + int consumed; +} DrinkResult; + +/* result from saradomin brew */ +typedef struct { + int hp_healed; + int def_boost; + int att_drain; + int str_drain; + int range_drain; + int magic_drain; +} BrewResult; + +/* food heal amounts (wiki-sourced) */ +static inline int osrs_food_heal_amount(FoodType type) { + switch (type) { + case FOOD_SHARK: return 20; + case FOOD_KARAMBWAN: return 18; + case FOOD_MANTA_RAY: return 22; + case FOOD_ANGLERFISH: return 22; + default: return 0; + } +} + +/* timer checks */ +static inline int osrs_can_eat(int food_timer) { return food_timer <= 0; } +static inline int osrs_can_drink(int potion_timer) { return potion_timer <= 0; } + +/* eat food: compute result. caller applies hp change and timer. + anglerfish can overheal (eat at full HP). all others require HP < max. + heal is clamped so HP doesn't exceed max (except anglerfish overheal). */ +static inline EatResult osrs_eat_food(FoodType type, int current_hp, int max_hp, int food_timer) { + EatResult r = {0, 0}; + if (food_timer > 0) return r; + + int heal = osrs_food_heal_amount(type); + if (heal <= 0) return r; + + /* anglerfish can overheal — always consumable */ + if (type == FOOD_ANGLERFISH) { + r.consumed = 1; + /* overheal cap: max_hp + floor(base_hp * 0.1) + 2, but for simplicity + in our sim we just allow the full heal amount to overheal. + the encounter clamps to its own overheal cap if desired. */ + r.hp_healed = heal; + return r; + } + + /* normal food: can't eat at full HP */ + if (current_hp >= max_hp) return r; + + r.consumed = 1; + r.hp_healed = heal; + /* clamp so total doesn't exceed max */ + if (current_hp + heal > max_hp) r.hp_healed = max_hp - current_hp; + return r; +} + +/* drink potion: compute result. caller applies effect and timer. + prayer pots can't be drunk at full prayer. antivenom always drinkable. */ +static inline DrinkResult osrs_drink_potion(PotionType type, int current_prayer, + int prayer_level, int potion_timer) { + DrinkResult r = {0, 0, 0, 0, 0}; + if (potion_timer > 0) return r; + + switch (type) { + case POTION_PRAYER_RESTORE: + if (current_prayer >= prayer_level) return r; + r.consumed = 1; + r.prayer_restored = 7 + prayer_level / 4; + break; + case POTION_SUPER_RESTORE: + if (current_prayer >= prayer_level) return r; + r.consumed = 1; + r.prayer_restored = 8 + prayer_level / 4; + break; + case POTION_ANTIVENOM_PLUS: + r.consumed = 1; + r.venom_cured = 1; + r.antivenom_ticks = 300; + break; + case POTION_RANGING: + r.consumed = 1; + r.level_boost = 4 + prayer_level / 10; /* +4 + 10% of level. ref: osrs wiki "ranging potion" */ + break; + case POTION_SUPER_COMBAT: + r.consumed = 1; + r.level_boost = 5 + prayer_level * 15 / 100; /* +5 + 15% of level */ + break; + case POTION_IMBUED_HEART: + r.consumed = 1; + r.level_boost = 1 + prayer_level / 10; /* +1 + 10% of level */ + break; + default: + break; + } + return r; +} + +/* saradomin brew effect: heals HP, boosts def, drains att/str/range/magic. + all parameters are BASE levels (99 typically). + ref: osrs wiki "saradomin brew" */ +static inline BrewResult osrs_brew_effect(int base_hp, int base_att, + int base_str, int base_range, + int base_magic) { + BrewResult r; + r.hp_healed = base_hp * 15 / 100 + 2; /* floor(base*0.15) + 2 */ + r.def_boost = base_hp * 20 / 100 + 2; /* floor(base*0.20) + 2 (uses HP base for def) */ + r.att_drain = base_att * 10 / 100 + 2; + r.str_drain = base_str * 10 / 100 + 2; + r.range_drain = base_range * 10 / 100 + 2; + r.magic_drain = base_magic * 10 / 100 + 2; + return r; +} + +#endif /* OSRS_CONSUMABLES_H */ diff --git a/ocean/osrs/osrs_damage.h b/ocean/osrs/osrs_damage.h new file mode 100644 index 0000000000..2a23171b5d --- /dev/null +++ b/ocean/osrs/osrs_damage.h @@ -0,0 +1,116 @@ +/** + * @fileoverview osrs_damage.h — shared OSRS damage application pipeline. + * + * pure function that computes the full damage chain: prayer reduction, vengeance + * reflect, recoil reflect, smite drain. used by PvP (osrs_pvp_combat.h) and + * available for any encounter that needs the full pipeline. + * + * DAMAGE PIPELINE: + * osrs_apply_damage_pipeline(...) prayer -> veng -> recoil -> smite + * + * HELPERS: + * osrs_has_recoil_ring(equipped) check ring of recoil / suffering (i) + * + * the pipeline is a pure function — computes damage chain without modifying state. + * callers apply the returned DamageResult to their own game state. + * + * pending hits: each encounter manages its own pending hit queue since the queue + * struct varies by encounter (PvP needs drain/heal/morr fields, PvE needs + * spell_type/check_prayer). the damage pipeline runs when a hit lands, regardless + * of how it was queued. + * + * ref: osrs wiki "protection prayers", "vengeance", "ring of recoil", "smite" + * ref: osrs_pvp_combat.h:570-691 (PvP apply_damage) + * ref: encounter_zulrah.h:651-675 (zulrah recoil) + */ + +#ifndef OSRS_DAMAGE_H +#define OSRS_DAMAGE_H + +#include "osrs_combat.h" +#include "osrs_items.h" + + +typedef struct { + int final_damage; /* damage after prayer reduction */ + int veng_damage; /* reflected by vengeance (0 if inactive) */ + int recoil_damage; /* reflected by recoil ring (0 if no ring) */ + int smite_drain; /* prayer drained from target (0 if no smite) */ + int prayer_blocked; /* 1 if correct prayer was active */ + int elysian_reduced; /* 1 if elysian proc reduced post-prayer damage */ +} DamageResult; + +static inline DamageResult osrs_apply_post_mitigation_pipeline( + int mitigated_damage, + int prayer_blocked, + int target_veng_active, + int target_has_recoil, + int attacker_smite_active +) { + DamageResult r = {0, 0, 0, 0, prayer_blocked, 0}; + r.final_damage = mitigated_damage; + + if (target_veng_active && r.final_damage > 0) { + r.veng_damage = (int)(r.final_damage * 0.75f); + } + + if (target_has_recoil && r.final_damage > 0) { + r.recoil_damage = r.final_damage / 10 + 1; + } + + if (attacker_smite_active && r.final_damage > 0) { + r.smite_drain = r.final_damage / 4; + } + + return r; +} + +/* apply the full OSRS damage pipeline to a hit. + pure function — does NOT modify any state. caller applies the result. + + raw_damage: damage before any reduction + attack_style: ATTACK_STYLE_MELEE/RANGED/MAGIC + target_prayer: target's active overhead prayer (OverheadPrayer enum) + is_pvp: 1 = 40% prayer reduction, 0 = 100% block + target_veng_active: 1 if target has vengeance active + target_has_recoil: 1 if target has ring of recoil / ring of suffering (i) + attacker_smite_active: 1 if attacker has smite prayer active + + pipeline order: + 1. prayer reduction (osrs_prayer_reduce_damage from osrs_combat.h) + 2. vengeance: floor(final_damage * 0.75) reflected to attacker + 3. recoil: floor(final_damage * 0.1) + 1 reflected to attacker + 4. smite: floor(final_damage / 4) drained from target prayer + + ref: osrs_pvp_combat.h:570-691, encounter_zulrah.h:651-675 */ +static inline DamageResult osrs_apply_damage_pipeline( + int raw_damage, int attack_style, + int target_prayer, int is_pvp, + int target_veng_active, + int target_has_recoil, + int attacker_smite_active +) { + int prayer_correct = encounter_prayer_correct_for_style(target_prayer, attack_style); + int post_prayer_damage = osrs_prayer_reduce_damage( + raw_damage, target_prayer, attack_style, is_pvp + ); + return osrs_apply_post_mitigation_pipeline( + post_prayer_damage, + prayer_correct, + target_veng_active, + target_has_recoil, + attacker_smite_active + ); +} + + +/* check if player has a recoil-capable ring equipped. + ring of recoil (finite charges) or ring of suffering (i) (infinite). + replaces has_recoil_effect() in osrs_pvp_combat.h:22 and + zul_has_recoil_effect() in encounter_zulrah.h. */ +static inline int osrs_has_recoil_ring(const uint8_t* equipped) { + uint8_t ring = equipped[GEAR_SLOT_RING]; + return ring == ITEM_RING_OF_RECOIL || ring == ITEM_RING_OF_SUFFERING_RI; +} + +#endif /* OSRS_DAMAGE_H */ diff --git a/ocean/osrs/osrs_encounter.h b/ocean/osrs/osrs_encounter.h new file mode 100644 index 0000000000..c12467e6c0 --- /dev/null +++ b/ocean/osrs/osrs_encounter.h @@ -0,0 +1,1624 @@ +/** + * @fileoverview osrs_encounter.h — shared encounter mechanics for the current ocean OSRS envs. + * + * this header holds reusable mechanics that current encounters build on. + * encounter-specific policy should stay in the encounter header; shared helpers + * here should stay generic enough to be reused by future envs. + * + * 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 (hazards, projectiles, boss) + * + * prayer (toggle-semantic, matches real OSRS click behavior): + * ENCOUNTER_OVERHEAD_* canonical overhead action encoding (4/6 dim) + * ENCOUNTER_OFFENSIVE_* canonical offensive action encoding (4 dim) + * encounter_apply_overhead_action() apply overhead action, returns 1 on activation + * encounter_apply_offensive_action() apply offensive action, returns 1 on activation + * encounter_drain_all_prayers() drain both slots per tick (activation-tick skip) + * + * 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() OSRS size-aware chase step + * + * 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...) + * Player.offensive_prayer runtime state, source of truth for prayer multipliers + * encounter_compute_loadout_stats() derive all stats from ITEM_DATABASE + loadout + * + * hit delays: + * EncounterPendingHit queued damage with tick countdown + * + * ALSO SEE: + * osrs_combat.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.h" +#include "osrs_item_effects.h" +#include "osrs_human_input_types.h" + +/* opaque encounter state — each encounter defines its own struct */ +typedef struct EncounterState EncounterState; + + +#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 = prayer has NOT been checked yet (deferred) */ + int prayer_check_delay;/* ticks until prayer is checked (0 = check immediately on next resolve). + jad uses 3 to model its T+3 DelayedAction — prayer at T+3 decides + whether the hit is blocked, independent of projectile flight time. + ref: InfernoTrainer JalTokJad.ts:49-57. */ + int spell_type; /* ENCOUNTER_SPELL_* for freeze/heal effects */ + int source_npc_type; /* encounter-local NPC type for custom delayed rolls */ +} 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_ADDS 4 +/* inferno can legitimately exceed single-digit projectile counts in one tick, + especially during Zuk healer spark volleys. size this from real encounter + volume so the renderer never silently drops visual events. */ +#define ENCOUNTER_MAX_OVERLAY_PROJECTILES 48 + +typedef enum { + ENCOUNTER_PROJECTILE_MOTION_OSRS_FLIGHT = 0, + ENCOUNTER_PROJECTILE_MOTION_TARGET_ANCHORED = 1, +} EncounterProjectileMotionMode; + +typedef struct { + /* encounter-defined area hazards. current users write 3x3 poison clouds. */ + struct { int x, y, active; } hazards[ENCOUNTER_MAX_OVERLAY_TILES]; + int hazard_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) */ + + /* encounter adds or secondary mobs. variant is encounter-defined. */ + struct { int x, y, active, variant; } adds[ENCOUNTER_MAX_OVERLAY_ADDS]; + int add_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 start_delay; /* ticks before projectile becomes visible (0 = immediate) */ + int motion_mode; /* EncounterProjectileMotionMode */ + float offset_x, offset_y, offset_z; /* local multi-model offset */ + 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) */ + int anim_id; /* spotanim animation sequence (-1 = static model) */ + int impact_gfx_id; /* optional landing spotanim to spawn on arrival */ + } 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, int impact_gfx_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].start_delay = 0; + ov->projectiles[i].motion_mode = ENCOUNTER_PROJECTILE_MOTION_OSRS_FLIGHT; + ov->projectiles[i].offset_x = 0.0f; + ov->projectiles[i].offset_y = 0.0f; + ov->projectiles[i].offset_z = 0.0f; + 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; + ov->projectiles[i].anim_id = -1; + ov->projectiles[i].impact_gfx_id = impact_gfx_id; + return i; +} + +static inline void encounter_set_projectile_motion_mode( + EncounterOverlay* ov, int projectile_idx, int motion_mode +) { + if (projectile_idx < 0 || projectile_idx >= ov->projectile_count) return; + ov->projectiles[projectile_idx].motion_mode = motion_mode; +} + +static inline void encounter_set_projectile_animation( + EncounterOverlay* ov, int projectile_idx, int anim_id +) { + if (projectile_idx < 0 || projectile_idx >= ov->projectile_count) return; + ov->projectiles[projectile_idx].anim_id = anim_id; +} + +static inline void encounter_set_projectile_offset( + EncounterOverlay* ov, int projectile_idx, + float offset_x, float offset_y, float offset_z +) { + if (projectile_idx < 0 || projectile_idx >= ov->projectile_count) return; + ov->projectiles[projectile_idx].offset_x = offset_x; + ov->projectiles[projectile_idx].offset_y = offset_y; + ov->projectiles[projectile_idx].offset_z = offset_z; +} + + +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. */ +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 (toggle semantics, matches OSRS) */ +/* */ +/* real OSRS has no "turn off" button — clicking an active prayer icon */ +/* toggles it off. clicking a different prayer in the same slot replaces it. */ +/* our encoding mirrors that exactly: agent action either no-ops or targets */ +/* a specific prayer; target-already-active → off, otherwise activate. */ +/* */ +/* each encounter chooses its action-head dim based on which prayers it */ +/* exposes — PvE uses 4 (no smite/redemption), PvP uses 6. new encounters */ +/* wire up by: */ +/* 1. declaring two action heads with encounter_overhead_dim / */ +/* ENCOUNTER_OFFENSIVE_DIM */ +/* 2. calling encounter_apply_overhead_action() on pretick */ +/* 3. calling encounter_apply_offensive_action() on pretick */ +/* 4. calling encounter_drain_all_prayers() on pretick (handles both slots */ +/* + activation-tick skip + pp=0 auto-clear) */ +/* overhead action encoding. dim depends on encounter: + - PvE (inferno/zulrah): 4 dim, actions 0-3 only + - PvP: 6 dim, full range */ +#define ENCOUNTER_OVERHEAD_NO_CHANGE 0 +#define ENCOUNTER_OVERHEAD_TOGGLE_MELEE 1 +#define ENCOUNTER_OVERHEAD_TOGGLE_RANGED 2 +#define ENCOUNTER_OVERHEAD_TOGGLE_MAGIC 3 +#define ENCOUNTER_OVERHEAD_TOGGLE_SMITE 4 +#define ENCOUNTER_OVERHEAD_TOGGLE_REDEMPTION 5 +#define ENCOUNTER_OVERHEAD_DIM_PVE 4 +#define ENCOUNTER_OVERHEAD_DIM_PVP 6 + +/* offensive action encoding — 4 dim, shared by all encounters. */ +#define ENCOUNTER_OFFENSIVE_NO_CHANGE 0 +#define ENCOUNTER_OFFENSIVE_TOGGLE_PIETY 1 +#define ENCOUNTER_OFFENSIVE_TOGGLE_RIGOUR 2 +#define ENCOUNTER_OFFENSIVE_TOGGLE_AUGURY 3 +#define ENCOUNTER_OFFENSIVE_DIM 4 + +/** apply an overhead prayer action with toggle semantics. + target-already-active → set to PRAYER_NONE (toggle off). + target-not-active → activate target (replacing whatever was in the slot). + returns 1 on OFF→ON transition (caller should set prayer_just_activated), + 0 on no-op, toggle-off, or replace. */ +static inline int encounter_apply_overhead_action(OverheadPrayer* overhead, int action) { + OverheadPrayer target; + switch (action) { + case ENCOUNTER_OVERHEAD_NO_CHANGE: return 0; + case ENCOUNTER_OVERHEAD_TOGGLE_MELEE: target = PRAYER_PROTECT_MELEE; break; + case ENCOUNTER_OVERHEAD_TOGGLE_RANGED: target = PRAYER_PROTECT_RANGED; break; + case ENCOUNTER_OVERHEAD_TOGGLE_MAGIC: target = PRAYER_PROTECT_MAGIC; break; + case ENCOUNTER_OVERHEAD_TOGGLE_SMITE: target = PRAYER_SMITE; break; + case ENCOUNTER_OVERHEAD_TOGGLE_REDEMPTION: target = PRAYER_REDEMPTION; break; + default: return 0; + } + if (*overhead == target) { *overhead = PRAYER_NONE; return 0; } + int activating = (*overhead == PRAYER_NONE) ? 1 : 0; + *overhead = target; + return activating; +} + +/** apply an offensive prayer action with toggle semantics. + same rules as overhead: target-active → off, target-inactive → activate. + returns 1 on OFF→ON transition, 0 otherwise. */ +static inline int encounter_apply_offensive_action(OffensivePrayer* offensive, int action) { + OffensivePrayer target; + switch (action) { + case ENCOUNTER_OFFENSIVE_NO_CHANGE: return 0; + case ENCOUNTER_OFFENSIVE_TOGGLE_PIETY: target = OFFENSIVE_PRAYER_PIETY; break; + case ENCOUNTER_OFFENSIVE_TOGGLE_RIGOUR: target = OFFENSIVE_PRAYER_RIGOUR; break; + case ENCOUNTER_OFFENSIVE_TOGGLE_AUGURY: target = OFFENSIVE_PRAYER_AUGURY; break; + default: return 0; + } + if (*offensive == target) { *offensive = OFFENSIVE_PRAYER_NONE; return 0; } + int activating = (*offensive == OFFENSIVE_PRAYER_NONE) ? 1 : 0; + *offensive = target; + return activating; +} + + +/* 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 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 +) { + 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); +} + +/* arena-scoped BFS: same as encounter_pathfind but uses a smaller grid. + arena_base_x/y: world-space origin of the arena. + arena_w/h: arena dimensions in tiles (must be <= PATHFIND_ARENA_MAX). */ +static inline PathResult encounter_pathfind_arena( + 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, + int arena_base_x, int arena_base_y, int arena_w, int arena_h +) { + return pathfind_step_arena(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, + arena_base_x + world_offset_x, arena_base_y + world_offset_y, + arena_w, arena_h); +} + +/* 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, + int arena_base_x, int arena_base_y, int arena_w, int arena_h +) { + 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 = (arena_w > 0) + ? encounter_pathfind_arena(cmap, world_offset_x, world_offset_y, + p->x, p->y, *dest_x, *dest_y, + extra_blocked, blocked_ctx, + arena_base_x, arena_base_y, arena_w, arena_h) + : 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; +} + + +/* footprint helpers for player-vs-target chase and range checks. */ +static inline int encounter_entity_footprint_distance( + int ax, int ay, int a_size, + int bx, int by, int b_size +) { + int ax1 = ax + a_size - 1; + int ay1 = ay + a_size - 1; + int bx1 = bx + b_size - 1; + int by1 = by + b_size - 1; + + int dx = 0; + if (ax1 < bx) dx = bx - ax1; + else if (bx1 < ax) dx = ax - bx1; + + int dy = 0; + if (ay1 < by) dy = by - ay1; + else if (by1 < ay) dy = ay - by1; + + return dx > dy ? dx : dy; +} + +static inline int encounter_entity_footprints_overlap( + int ax, int ay, int a_size, + int bx, int by, int b_size +) { + return !(ax + a_size <= bx || bx + b_size <= ax || + ay + a_size <= by || by + b_size <= ay); +} + +/* 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_entity_footprint_distance(player_x, player_y, 1, + target_x, target_y, target_size); + if (dist < 1 || dist > attack_range) return 0; + if (!los_blockers || los_blocker_count == 0) return 1; + return entity_has_line_of_sight(los_blockers, los_blocker_count, + player_x, player_y, 1, + target_x, target_y, target_size, + attack_range); +} + +/* auto-walk toward attack target: handles out-of-range, blocked LOS, and under-NPC. + the caller owns the policy; this helper only computes the chase step. + 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 arena_base_x, int arena_base_y, int arena_w, int arena_h +) { + int dist = encounter_entity_footprint_distance(p->x, p->y, 1, + target_x, target_y, target_size); + + /* player under NPC (dist=0): walk to nearest tile outside the target footprint. */ + 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 (encounter_entity_footprints_overlap(nx, ny, 1, + target_x, 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 = (arena_w > 0) + ? encounter_pathfind_arena(cmap, world_offset_x, world_offset_y, + p->x, p->y, bx, by, + extra_blocked, blocked_ctx, + arena_base_x, arena_base_y, arena_w, arena_h) + : 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_entity_footprint_distance(p->x, p->y, 1, + 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 in the same row-first + order as osrs-sdk Player.ts seekingTiles. the destination set is + pathability-filtered only; LOS is checked after each movement step. */ + int best_dsq = 999999; + cx = -1; cy = -1; + for (int xx = 0; xx < target_size; xx++) { + int px = target_x + xx; + int north_py = target_y + target_size; + int south_py = target_y - 1; + + if (is_walkable(ctx, px, north_py)) { + int ddx = px - p->x, ddy = north_py - p->y; + int dsq = ddx * ddx + ddy * ddy; + if (dsq < best_dsq) { best_dsq = dsq; cx = px; cy = north_py; } + } + + if (is_walkable(ctx, px, south_py)) { + int ddx = px - p->x, ddy = south_py - p->y; + int dsq = ddx * ddx + ddy * ddy; + if (dsq < best_dsq) { best_dsq = dsq; cx = px; cy = south_py; } + } + } + for (int yy = 0; yy < target_size; yy++) { + int py = target_y + yy; + int west_px = target_x - 1; + int east_px = target_x + target_size; + + if (is_walkable(ctx, west_px, py)) { + int ddx = west_px - p->x, ddy = py - p->y; + int dsq = ddx * ddx + ddy * ddy; + if (dsq < best_dsq) { best_dsq = dsq; cx = west_px; cy = py; } + } + + if (is_walkable(ctx, east_px, py)) { + int ddx = east_px - p->x, ddy = py - p->y; + int dsq = ddx * ddx + ddy * ddy; + if (dsq < best_dsq) { best_dsq = dsq; cx = east_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 = (arena_w > 0) + ? encounter_pathfind_arena(cmap, world_offset_x, world_offset_y, + p->x, p->y, cx, cy, + extra_blocked, blocked_ctx, + arena_base_x, arena_base_y, arena_w, arena_h) + : 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; +} + + +typedef int (*encounter_npc_blocked_fn)(void* ctx, int x, int y, int size); +typedef int (*encounter_npc_overlap_hold_fn)(void* ctx); + +#define ENCOUNTER_NPC_UNDER_PLAYER_NONE 0 +#define ENCOUNTER_NPC_UNDER_PLAYER_MOVED 1 +#define ENCOUNTER_NPC_UNDER_PLAYER_HELD 2 + +/* 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. + hold_overlap lets the caller preserve the one-tick "player just clicked this + mob, so it cannot move off" rule. returns MOVED, HELD, or NONE. */ +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_npc_blocked_fn is_blocked, void* ctx, + encounter_npc_overlap_hold_fn hold_overlap, + 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 ENCOUNTER_NPC_UNDER_PLAYER_NONE; + if (hold_overlap && hold_overlap(ctx)) return ENCOUNTER_NPC_UNDER_PLAYER_HELD; + + /* 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_blocked(ctx, nx, ny, npc_size)) { + *npc_x = nx; + *npc_y = ny; + return ENCOUNTER_NPC_UNDER_PLAYER_MOVED; + } + } + return ENCOUNTER_NPC_UNDER_PLAYER_NONE; +} + + +/** 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; +} + +static inline int encounter_npc_axis_gap(int a, int a_size, int b, int b_size) { + int a_max = a + a_size - 1; + int b_max = b + b_size - 1; + if (a_max < b) return b - a_max; + if (b_max < a) return a - b_max; + return 0; +} + +static inline int encounter_npc_axis_dir(int a, int a_size, int b, int b_size) { + int a_max = a + a_size - 1; + int b_max = b + b_size - 1; + if (a_max < b) return 1; + if (b_max < a) return -1; + return 0; +} + +static inline int encounter_npc_try_step( + int* x, int* y, int size, int dx, int dy, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + if (dx == 0 && dy == 0) return 0; + if (size <= 1) { + if (!is_blocked(ctx, *x + dx, *y + dy, 1)) { + *x += dx; + *y += dy; + return 1; + } + return 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; + } + return 0; +} + +/** OSRS-shaped NPC step toward target. tries diagonal first, then x-only, + then y-only when RuneLite's travel rule allows the y fallback. + + 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. + + stop_at_melee_distance matches RuneLite WorldArea.calculateNextTravellingPoint: + overlap returns no normal step, cardinal melee contact returns no step, + and diagonal contact tries x-only. + + 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 stop_at_melee_distance, + encounter_npc_blocked_fn is_blocked, void* ctx +) { + int size = npc_size; + int x_gap = encounter_npc_axis_gap(*x, size, tx, target_size); + int y_gap = encounter_npc_axis_gap(*y, size, ty, target_size); + int dx = encounter_npc_axis_dir(*x, size, tx, target_size); + int dy = encounter_npc_axis_dir(*y, size, ty, target_size); + + if (stop_at_melee_distance && x_gap == 0 && y_gap == 0) return 0; + if (stop_at_melee_distance && x_gap + y_gap == 1) return 0; + if (dx == 0 && dy == 0) return 0; + + if (stop_at_melee_distance && x_gap == 1 && y_gap == 1) { + return encounter_npc_try_step(x, y, size, dx, 0, is_blocked, ctx); + } + + if (dx != 0 && dy != 0 && + encounter_npc_try_step(x, y, size, dx, dy, is_blocked, ctx)) + return 1; + if (dx != 0 && encounter_npc_try_step(x, y, size, dx, 0, is_blocked, ctx)) + return 1; + int max_gap = x_gap > y_gap ? x_gap : y_gap; + if (dy != 0 && max_gap > 1 && + encounter_npc_try_step(x, y, size, 0, dy, is_blocked, ctx)) + 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; +} + + +/** 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 +) { + (void)frozen_ticks; /* freeze applied at cast time, not land time */ + 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; + + /* 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, + int* off_prayer_hit_count +) { + for (int i = 0; i < *hit_count; i++) { + /* deferred prayer check (jad): lock in damage at T + prayer_check_delay. + runs BEFORE ticks_remaining decrement so the check happens on the exact + tick prayer_check_delay reaches 0, regardless of whether the hit lands + same tick or later. after the check, damage is frozen (possibly 0) + and further flicks don't affect this hit. */ + if (hits[i].check_prayer && hits[i].prayer_check_delay > 0) { + hits[i].prayer_check_delay--; + if (hits[i].prayer_check_delay == 0) { + if (encounter_prayer_correct_for_style(active_prayer, hits[i].attack_style)) { + hits[i].damage = 0; + if (prayer_correct_count) (*prayer_correct_count)++; + } else if (hits[i].damage > 0 && hits[i].attack_style != ATTACK_STYLE_NONE) { + if (off_prayer_hit_count) (*off_prayer_hit_count)++; + } + hits[i].check_prayer = 0; + } + } + 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)++; + } else if (dmg > 0 && hits[i].attack_style != ATTACK_STYLE_NONE) { + if (off_prayer_hit_count) (*off_prayer_hit_count)++; + } + } else if (dmg > 0 && hits[i].attack_style != ATTACK_STYLE_NONE) { + if (off_prayer_hit_count) (*off_prayer_hit_count)++; + } + + encounter_damage_player(player, dmg, damage_received_acc); + hits[i] = hits[--(*hit_count)]; + i--; + } + } +} + + +/** 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; +} + + +/** 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_all_prayers() 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_overhead_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 effect values for offensive prayers. ref: osrs wiki prayer table. */ +static inline int encounter_offensive_drain_effect(OffensivePrayer prayer) { + switch (prayer) { + case OFFENSIVE_PRAYER_PIETY: return 24; + case OFFENSIVE_PRAYER_RIGOUR: return 24; + case OFFENSIVE_PRAYER_AUGURY: return 24; + case OFFENSIVE_PRAYER_MELEE_LOW: return 6; + case OFFENSIVE_PRAYER_RANGED_LOW: return 6; + case OFFENSIVE_PRAYER_MAGIC_LOW: return 6; + default: return 0; + } +} + +/** drain both overhead and offensive prayer for one tick. + handles activation-tick skip (prayers activated this tick do not drain), + the shared drain counter, and pp=0 auto-clear (all prayers off when empty). + prayer_bonus: player's total prayer equipment bonus (typically 0-30). + ref: osrs-sdk PrayerController.ts:44-59, wiki "Prayer flicking" section. */ +static inline void encounter_drain_all_prayers(Player* p, int prayer_bonus) { + /* active-but-not-just-activated prayers contribute to drain this tick. + ref: wiki: "the game does not drain prayer for prayers on the tick + they are activated". this is what makes 1-tick flicking free. */ + int overhead_effect = p->prayer_just_activated + ? 0 : encounter_overhead_drain_effect(p->prayer); + int offensive_effect = p->offensive_prayer_just_activated + ? 0 : encounter_offensive_drain_effect(p->offensive_prayer); + int total = overhead_effect + offensive_effect; + + /* clear just-activated flags even on zero-drain paths so they don't leak + into next tick. */ + p->prayer_just_activated = 0; + p->offensive_prayer_just_activated = 0; + + /* pp already at/below 0 entering this tick — force prayers off and skip + drain math. covers: external drain (smite), or activation attempted + at pp=0 (the pretick apply_*_action helpers don't gate on pp, so the + enum may have been set this tick before we arrived). without this, + the prayer enum can stay active indefinitely at pp=0. */ + if (p->current_prayer <= 0) { + p->current_prayer = 0; + p->prayer_drain_counter = 0; + p->prayer = PRAYER_NONE; + p->offensive_prayer = OFFENSIVE_PRAYER_NONE; + return; + } + if (total <= 0) return; + + int drain_resistance = 60 + prayer_bonus * 2; + p->prayer_drain_counter += total; + while (p->prayer_drain_counter > drain_resistance) { + p->current_prayer--; + p->prayer_drain_counter -= drain_resistance; + if (p->current_prayer <= 0) { + p->current_prayer = 0; + p->prayer_drain_counter = 0; + p->prayer = PRAYER_NONE; + p->offensive_prayer = OFFENSIVE_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 */ +/* OffensivePrayer — prayer enum (NONE, PIETY, RIGOUR, AUGURY, low-tiers) */ +/* 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 (includes stance speed mod) */ + int attack_range; /* max chebyshev distance (includes stance range mod) */ + AttackStyle style; + FightStyle fight_style; /* stance picked for this loadout — drives stance bonuses + speed/range mods */ + /* 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 spell_base_damage; +} EncounterLoadoutStats; + +/** offensive prayer multipliers for effective level computation. + single source of truth: the multipliers used in encounter_compute_loadout_stats() + and encounter_update_loadout_level(). also used by PvP combat math (via + osrs_pvp_combat.h) so all combat paths agree on prayer effects. + ref: osrs wiki prayer table. */ +static inline void encounter_offensive_prayer_mults( + OffensivePrayer op, float* att_out, float* str_out +) { + float att = 1.0f, str = 1.0f; + switch (op) { + case OFFENSIVE_PRAYER_PIETY: att = 1.20f; str = 1.23f; break; + case OFFENSIVE_PRAYER_RIGOUR: att = 1.20f; str = 1.23f; break; + case OFFENSIVE_PRAYER_AUGURY: att = 1.25f; str = 1.00f; break; + case OFFENSIVE_PRAYER_MELEE_LOW: att = 1.15f; str = 1.15f; break; + case OFFENSIVE_PRAYER_RANGED_LOW: att = 1.15f; str = 1.15f; break; + case OFFENSIVE_PRAYER_MAGIC_LOW: att = 1.15f; str = 1.00f; break; + default: break; + } + *att_out = att; + *str_out = str; +} + +/** augury adds +4% magic damage on top of its accuracy mult (PvP parity). */ +static inline float encounter_offensive_magic_dmg_mult(OffensivePrayer op) { + return (op == OFFENSIVE_PRAYER_AUGURY) ? 1.04f : 1.0f; +} + +/** derive all combat stats from a loadout array + prayer + fight stance. + sums equipment bonuses from ITEM_DATABASE, applies prayer multiplier, + computes effective level and max hit. attack_speed and attack_range are + also adjusted for the stance (rapid -1 tick, longrange +2 tiles). + + @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 offensive_prayer current offensive prayer (piety/rigour/augury/none/low tiers) + @param base_level base combat level (usually 99) + @param fight_style stance — drives attack/str/def bonuses, attack speed, range + @param spell_base_damage 0 for ranged/melee, 30 for ice/blood barrage + @param out output struct to fill. prayer multipliers are stored so + encounter_update_loadout_level() can recompute eff/max without + needing the prayer arg again (callers must re-call update + whenever offensive prayer changes). */ +static inline void encounter_compute_loadout_stats( + const uint8_t loadout[NUM_GEAR_SLOTS], + AttackStyle style, + OffensivePrayer offensive_prayer, + int base_level, + FightStyle fight_style, + int spell_base_damage, + EncounterLoadoutStats* out +) { + memset(out, 0, sizeof(*out)); + out->style = style; + out->fight_style = fight_style; + + /* sum equipment bonuses using shared function */ + EquipmentBonuses eb; + osrs_sum_equipment_bonuses(loadout, &eb); + + out->def_stab = eb.defence_stab; + out->def_slash = eb.defence_slash; + out->def_crush = eb.defence_crush; + out->def_magic = eb.defence_magic; + out->def_ranged = eb.defence_ranged; + /* apply stance modifiers to weapon base speed/range. equipment.json stores + the base (accurate/longrange speed, non-longrange range). rapid and + longrange shift them. */ + out->attack_speed = eb.attack_speed + osrs_stance_speed_mod(fight_style); + out->attack_range = eb.attack_range + osrs_stance_range_mod(fight_style); + + /* primary attack bonus based on style */ + if (style == ATTACK_STYLE_MAGIC) { + out->attack_bonus = eb.attack_magic; + } else if (style == ATTACK_STYLE_RANGED) { + out->attack_bonus = eb.attack_ranged; + } else { + /* melee: best of stab/slash/crush */ + out->attack_bonus = eb.attack_stab; + if (eb.attack_slash > out->attack_bonus) out->attack_bonus = eb.attack_slash; + if (eb.attack_crush > out->attack_bonus) out->attack_bonus = eb.attack_crush; + } + + /* prayer multipliers — single source of truth in encounter_offensive_prayer_mults(). */ + float att_prayer_mult, str_prayer_mult; + encounter_offensive_prayer_mults(offensive_prayer, &att_prayer_mult, &str_prayer_mult); + + /* store for dynamic recomputation after brew drain / potion boost / prayer toggle */ + out->att_prayer_mult = att_prayer_mult; + out->str_prayer_mult = str_prayer_mult; + out->spell_base_damage = spell_base_damage; + + int att_stance_bonus = osrs_stance_att_bonus(fight_style, style); + int str_stance_bonus = osrs_stance_str_bonus(fight_style); + + /* effective attack level: floor(base * prayer_mult) + stance_att_bonus + 8. + magic uses +9 (OSRS invisible +1 boost) instead of +8. powered-staff stance + bonus (accurate +3, longrange +1) is picked up via osrs_stance_att_bonus(MAGIC). */ + if (style == ATTACK_STYLE_MAGIC) { + out->eff_level = (int)(base_level * att_prayer_mult) + att_stance_bonus + 9; + } else { + out->eff_level = (int)(base_level * att_prayer_mult) + att_stance_bonus + 8; + } + + /* effective strength level (for max hit): floor(base * str_prayer_mult) + str_stance_bonus + 8. + str_stance_bonus is non-zero only for melee (aggressive/controlled). */ + int eff_str_level = (int)(base_level * str_prayer_mult) + str_stance_bonus + 8; + + /* augury magic damage multiplier: +4% (matches PvP calculate_max_hit). */ + float magic_dmg_prayer_mult = encounter_offensive_magic_dmg_mult(offensive_prayer); + + /* max hit and strength bonus depend on combat style */ + if (style == ATTACK_STYLE_RANGED) { + out->strength_bonus = eb.ranged_strength; + out->max_hit = (int)(0.5 + eff_str_level * (eb.ranged_strength + 64) / 640.0); + } else if (style == ATTACK_STYLE_MAGIC) { + out->strength_bonus = eb.magic_damage; + out->max_hit = (int)(spell_base_damage * (1.0 + eb.magic_damage / 100.0) * magic_dmg_prayer_mult); + } else { + out->strength_bonus = eb.melee_strength; + out->max_hit = (int)(0.5 + eff_str_level * (eb.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 AND current offensive prayer. call whenever either changes: + - offensive prayer toggle (pretick action) + - brew drain / super restore / bastion boost (consumable effects) + 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. + offensive_prayer is the current Player.offensive_prayer — mults are rewritten from it. */ +static inline void encounter_update_loadout_level( + EncounterLoadoutStats* ls, OffensivePrayer offensive_prayer, + int current_att_level, int current_str_level +) { + float att_prayer_mult, str_prayer_mult; + encounter_offensive_prayer_mults(offensive_prayer, &att_prayer_mult, &str_prayer_mult); + ls->att_prayer_mult = att_prayer_mult; + ls->str_prayer_mult = str_prayer_mult; + + int att_stance_bonus = osrs_stance_att_bonus(ls->fight_style, ls->style); + int str_stance_bonus = osrs_stance_str_bonus(ls->fight_style); + /* magic uses +9 invisible boost (matches encounter_compute_loadout_stats) */ + if (ls->style == ATTACK_STYLE_MAGIC) { + ls->eff_level = (int)(current_att_level * att_prayer_mult) + att_stance_bonus + 9; + float magic_dmg_mult = encounter_offensive_magic_dmg_mult(offensive_prayer); + ls->max_hit = (int)(ls->spell_base_damage * (1.0 + ls->strength_bonus / 100.0) * magic_dmg_mult); + } else { + ls->eff_level = (int)(current_att_level * att_prayer_mult) + att_stance_bonus + 8; + int eff_str = (int)(current_str_level * str_prayer_mult) + str_stance_bonus + 8; + ls->max_hit = (int)(0.5 + eff_str * (ls->strength_bonus + 64) / 640.0); + } +} + +static inline void encounter_compute_player_equipped_stats( + Player* p, + AttackStyle style, + FightStyle fight_style, + int spell_base_damage, + EncounterLoadoutStats* out +) { + int current_att = p->current_attack; + int current_str = p->current_strength; + if (style == ATTACK_STYLE_RANGED) { + current_att = p->current_ranged; + current_str = p->current_ranged; + } else if (style == ATTACK_STYLE_MAGIC) { + current_att = p->current_magic; + current_str = p->current_magic; + } + encounter_compute_loadout_stats( + p->equipped, + style, + p->offensive_prayer, + current_att, + fight_style, + spell_base_damage, + out); + encounter_update_loadout_level(out, p->offensive_prayer, current_att, current_str); +} +/* 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 or prayer change. + encounters should call this after brew_drain_stats, restore_stats, bastion_boost, + or when Player.offensive_prayer toggles. ranged loadouts use current_ranged, magic + uses current_magic, melee uses current_attack/current_strength. prayer multipliers + are rewritten from p->offensive_prayer. */ +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->offensive_prayer, p->current_ranged, p->current_ranged); + } else if (ls->style == ATTACK_STYLE_MAGIC) { + encounter_update_loadout_level(ls, p->offensive_prayer, p->current_magic, p->current_magic); + } else { + encounter_update_loadout_level(ls, p->offensive_prayer, 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. */ +/** tick special attack energy regeneration from current equipped gear. */ +static inline void encounter_tick_spec_regen(Player* p) { + osrs_tick_special_regen(p); +} + +/** 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; +} + + +/** 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; + osrs_refresh_player_equipment(p); +} + +/** 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; + } +} + + +/** 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 overhead prayer: pending_prayer stores the new ENCOUNTER_OVERHEAD_* + value directly (set by GUI click handlers). 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; + actions[head_prayer] = hi->pending_prayer; +} + +/** translate offensive prayer: pending_offensive_prayer stores the new + ENCOUNTER_OFFENSIVE_* value directly. writes to actions[head_offensive]. + head_offensive < 0 = skip (encounter doesn't expose offensive as an action). */ +static inline void encounter_translate_offensive_prayer( + HumanInput* hi, int* actions, int head_offensive +) { + if (hi->pending_offensive_prayer < 0 || head_offensive < 0) return; + actions[head_offensive] = hi->pending_offensive_prayer; +} + +/** 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; +} + + +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); + void (*step_human_commands)(EncounterState* state, struct HumanInput* hi); + + /* 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); + int (*is_human_targetable_npc_slot)(EncounterState* state, int npc_slot); + + /* 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; + + +#define MAX_ENCOUNTERS 32 + +typedef struct { + const EncounterDef* defs[MAX_ENCOUNTERS]; + int count; +} EncounterRegistry; + +/* WARNING: static in header — each TU gets its own copy. only works correctly + when all encounter headers are included from a single compilation unit. */ +static EncounterRegistry g_encounter_registry = { .count = 0 }; + +static inline void encounter_register(const EncounterDef* def) { + if (g_encounter_registry.count < MAX_ENCOUNTERS) { + g_encounter_registry.defs[g_encounter_registry.count++] = def; + } +} + +static inline 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_env.h b/ocean/osrs/osrs_env.h new file mode 100644 index 0000000000..266b237210 --- /dev/null +++ b/ocean/osrs/osrs_env.h @@ -0,0 +1,28 @@ +/** + * @file osrs_env.h + * @brief OSRS environment — include aggregator for all subsystems. + * + * Include this file for full environment access. It pulls in shared modules + * and PvP subsystem headers in the correct order. + */ + +#ifndef OSRS_ENV_H +#define OSRS_ENV_H + +#include "osrs_types.h" +#include "osrs_collision.h" +#include "osrs_pathfinding.h" +#include "osrs_encounter.h" +#include "osrs_combat.h" +#include "osrs_special_attacks.h" +#include "osrs_damage.h" +#include "osrs_bolt_procs.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_ENV_H diff --git a/ocean/osrs/osrs_gui.h b/ocean/osrs/osrs_gui.h new file mode 100644 index 0000000000..bbc2a8774b --- /dev/null +++ b/ocean/osrs/osrs_gui.h @@ -0,0 +1,2109 @@ +/** + * @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_GUI_H +#define OSRS_GUI_H + +#include "osrs_human_input_types.h" + +#include "raylib.h" +#include "osrs_types.h" +#include "osrs_items.h" +#include "osrs_pvp_gear.h" + + +#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 } + + +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; + + +/* 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 icons — authoritative 29-entry standard book. + enum order IS display order (left→right, top→bottom) in the 5×6 grid. + sprite IDs match the real OSRS SpriteID.Prayeron / Prayeroff mapping. */ +typedef enum { + GUI_PRAY_THICK_SKIN = 0, /* row 0: sprite 115 / 135 */ + GUI_PRAY_BURST_STR, /* sprite 116 / 136 */ + GUI_PRAY_CLARITY, /* sprite 117 / 137 */ + GUI_PRAY_SHARP_EYE, /* sprite 133 / 153 */ + GUI_PRAY_MYSTIC_WILL, /* sprite 134 / 154 */ + GUI_PRAY_ROCK_SKIN, /* row 1: sprite 118 / 138 */ + GUI_PRAY_SUPERHUMAN, /* sprite 119 / 139 */ + GUI_PRAY_IMPROVED_REFLEX, /* sprite 120 / 140 */ + GUI_PRAY_RAPID_RESTORE, /* sprite 121 / 141 */ + GUI_PRAY_RAPID_HEAL, /* sprite 122 / 142 */ + GUI_PRAY_PROTECT_ITEM, /* row 2: sprite 123 / 143 */ + GUI_PRAY_HAWK_EYE, /* sprite 502 / 506 */ + GUI_PRAY_MYSTIC_LORE, /* sprite 503 / 507 */ + GUI_PRAY_STEEL_SKIN, /* sprite 124 / 144 */ + GUI_PRAY_ULTIMATE_STR, /* sprite 125 / 145 */ + GUI_PRAY_INCREDIBLE_REFLEX, /* row 3: sprite 126 / 146 */ + GUI_PRAY_PROTECT_MAGIC, /* sprite 127 / 147 */ + GUI_PRAY_PROTECT_MISSILES, /* sprite 128 / 148 */ + GUI_PRAY_PROTECT_MELEE, /* sprite 129 / 149 */ + GUI_PRAY_EAGLE_EYE, /* sprite 504 / 508 */ + GUI_PRAY_MYSTIC_MIGHT, /* row 4: sprite 505 / 509 */ + GUI_PRAY_RETRIBUTION, /* sprite 131 / 151 */ + GUI_PRAY_REDEMPTION, /* sprite 130 / 150 */ + GUI_PRAY_SMITE, /* sprite 132 / 152 */ + GUI_PRAY_PRESERVE, /* sprite 947 / 951 */ + GUI_PRAY_CHIVALRY, /* row 5: sprite 945 / 949 */ + GUI_PRAY_PIETY, /* sprite 946 / 950 */ + GUI_PRAY_RIGOUR, /* sprite 1420 / 1424 */ + GUI_PRAY_AUGURY, /* sprite 1421 / 1425 */ + GUI_NUM_PRAYERS /* = 29 */ +} GuiPrayerIdx; + + +/* Ancient spellbook sorted by level (Smoke→Shadow→Blood→Ice per row, + Rush→Burst→Blitz→Barrage per family). Only Ice/Blood/Vengeance are + castable in this env; Smoke/Shadow render greyed-out for authenticity. */ +typedef enum { + /* row 0: Rush spells (level 50/52/56/58) */ + GUI_SPELL_SMOKE_RUSH = 0, /* sprite 329 / 379 */ + GUI_SPELL_SHADOW_RUSH, /* sprite 337 / 387 */ + GUI_SPELL_BLOOD_RUSH, /* sprite 333 / 383 */ + GUI_SPELL_ICE_RUSH, /* sprite 325 / 375 */ + /* row 1: Burst spells (62/64/68/70) */ + GUI_SPELL_SMOKE_BURST, /* sprite 330 / 380 */ + GUI_SPELL_SHADOW_BURST, /* sprite 338 / 388 */ + GUI_SPELL_BLOOD_BURST, /* sprite 334 / 384 */ + GUI_SPELL_ICE_BURST, /* sprite 326 / 376 */ + /* row 2: Blitz spells (74/76/80/82) */ + GUI_SPELL_SMOKE_BLITZ, /* sprite 331 / 381 */ + GUI_SPELL_SHADOW_BLITZ, /* sprite 339 / 389 */ + GUI_SPELL_BLOOD_BLITZ, /* sprite 335 / 385 */ + GUI_SPELL_ICE_BLITZ, /* sprite 327 / 377 */ + /* row 3: Barrage spells (86/88/92/94) */ + GUI_SPELL_SMOKE_BARRAGE, /* sprite 332 / 382 */ + GUI_SPELL_SHADOW_BARRAGE, /* sprite 340 / 390 */ + GUI_SPELL_BLOOD_BARRAGE, /* sprite 336 / 386 */ + GUI_SPELL_ICE_BARRAGE, /* sprite 328 / 378 */ + /* lunar spell(s) we use */ + GUI_SPELL_VENGEANCE, /* sprite 564 */ + GUI_NUM_SPELLS +} GuiSpellIdx; + + +/* 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) */ + INV_SLOT_BASTION_POT, /* bastion potion (OSRS IDs 22461/22464/22467/22470) */ + INV_SLOT_STAMINA_POT, /* stamina potion (OSRS IDs 12625/12627/12629/12631) */ +} 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 OSRS_ID_BASTION_4 22461 +#define OSRS_ID_BASTION_3 22464 +#define OSRS_ID_BASTION_2 22467 +#define OSRS_ID_BASTION_1 22470 +#define OSRS_ID_STAMINA_4 12625 +#define OSRS_ID_STAMINA_3 12627 +#define OSRS_ID_STAMINA_2 12629 +#define OSRS_ID_STAMINA_1 12631 + +#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; + + +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_bastion_doses; + int inv_prev_stamina_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; + + /* spell targeting: GuiSpellIdx of the spell awaiting an enemy click, or + -1 when not targeting. render code sets this before calling gui_draw. */ + int pending_spell_highlight; +} GuiState; + + +/** 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: indexed by GuiPrayerIdx, sprite IDs match real OSRS. */ + static const int pray_on_ids[GUI_NUM_PRAYERS] = { + 115, 116, 117, 133, 134, /* row 0: ThickSkin, Burst, Clarity, SharpEye, MysticWill */ + 118, 119, 120, 121, 122, /* row 1: RockSkin, Superhuman, ImprovedReflex, RapidRestore, RapidHeal */ + 123, 502, 503, 124, 125, /* row 2: ProtectItem, HawkEye, MysticLore, SteelSkin, UltimateStr */ + 126, 127, 128, 129, 504, /* row 3: IncredibleReflex, ProtMagic, ProtMissiles, ProtMelee, EagleEye */ + 505, 131, 130, 132, 947, /* row 4: MysticMight, Retribution, Redemption, Smite, Preserve */ + 945, 946, 1420, 1421, /* row 5: Chivalry, Piety, Rigour, Augury (1 empty cell) */ + }; + static const int pray_off_ids[GUI_NUM_PRAYERS] = { + 135, 136, 137, 153, 154, + 138, 139, 140, 141, 142, + 143, 506, 507, 144, 145, + 146, 147, 148, 149, 508, + 509, 151, 150, 152, 951, + 949, 950, 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 — indexed by GuiSpellIdx. full ancient book + vengeance. */ + static const int spell_on_ids[GUI_NUM_SPELLS] = { + 329, 337, 333, 325, /* Rush: Smoke, Shadow, Blood, Ice */ + 330, 338, 334, 326, /* Burst: Smoke, Shadow, Blood, Ice */ + 331, 339, 335, 327, /* Blitz: Smoke, Shadow, Blood, Ice */ + 332, 340, 336, 328, /* Barrage: Smoke, Shadow, Blood, Ice */ + 564, /* Vengeance */ + }; + static const int spell_off_ids[GUI_NUM_SPELLS] = { + 379, 387, 383, 375, + 380, 388, 384, 376, + 381, 389, 385, 377, + 382, 390, 386, 378, + 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, + OSRS_ID_BASTION_4, OSRS_ID_BASTION_3, OSRS_ID_BASTION_2, OSRS_ID_BASTION_1, + OSRS_ID_STAMINA_4, OSRS_ID_STAMINA_3, OSRS_ID_STAMINA_2, OSRS_ID_STAMINA_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; +} + + +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_ELYSIAN_SPIRIT_SHIELD: return "Elysian"; + 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_VENATOR_RING: return "Venator"; + case ITEM_VIRTUS_MASK: return "Virtus msk"; + case ITEM_VIRTUS_ROBE_TOP: return "Virtus top"; + case ITEM_VIRTUS_ROBE_BOTTOM:return "Virtus bot"; + 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; + } +} + + +/** 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); + } + } +} + + +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); +} + + +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; +} + + +static int gui_content_y(GuiState* gs) { + return gs->panel_y + gs->status_bar_h + gs->tab_h; +} + + +/* 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 +/* OSRS native inventory cell pitch is ~42x36, scaled 1.5x to match window. */ +#define INV_CELL_W 63 +#define INV_CELL_H 54 +#define INV_SPRITE_W 54 +#define INV_SPRITE_H 48 + +/** 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; + case INV_SLOT_BASTION_POT: + if (doses >= 4) return OSRS_ID_BASTION_4; + if (doses == 3) return OSRS_ID_BASTION_3; + if (doses == 2) return OSRS_ID_BASTION_2; + return OSRS_ID_BASTION_1; + case INV_SLOT_STAMINA_POT: + if (doses >= 4) return OSRS_ID_STAMINA_4; + if (doses == 3) return OSRS_ID_STAMINA_3; + if (doses == 2) return OSRS_ID_STAMINA_2; + return OSRS_ID_STAMINA_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; +} + +/** Copy the player-side inventory snapshot that incremental GUI updates diff against. */ +static void gui_snapshot_inventory_state(GuiState* gs, const Player* p) { + 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_bastion_doses = p->bastion_doses; + gs->inv_prev_stamina_doses = p->stamina_doses; + gs->inv_prev_antivenom_doses = p->antivenom_doses; +} + +/** Return 1 when any inventory-tracked consumable count changed. */ +static int gui_inventory_consumables_changed(const GuiState* gs, const Player* p) { + return p->food_count != gs->inv_prev_food_count + || p->karambwan_count != gs->inv_prev_karambwan_count + || 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->bastion_doses != gs->inv_prev_bastion_doses + || p->stamina_doses != gs->inv_prev_stamina_doses + || p->antivenom_doses != gs->inv_prev_antivenom_doses; +} + +/** Clear inventory-only GUI state that must not leak across resets. */ +static void gui_reset_inventory_ui_state(GuiState* gs) { + gs->inv_grid_dirty = 1; + gs->human_clicked_inv_slot = -1; + gs->inv_dim_slot = -1; + gs->inv_dim_timer = 0; + gs->inv_drag_active = 0; + gs->inv_drag_src_slot = -1; + gs->inv_drag_start_x = 0; + gs->inv_drag_start_y = 0; + gs->inv_drag_mouse_x = 0; + gs->inv_drag_mouse_y = 0; +} + +/** 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->bastion_doses, INV_SLOT_BASTION_POT); + ADD_POTION_VIALS(p->stamina_doses, INV_SLOT_STAMINA_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 */ + gui_snapshot_inventory_state(gs, p); +} + +/** 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->bastion_doses != gs->inv_prev_bastion_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_BASTION_POT, p->bastion_doses); + } + if (p->stamina_doses != gs->inv_prev_stamina_doses) { + gui_inv_update_potion_doses(gs, INV_SLOT_STAMINA_POT, p->stamina_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. */ + if (clicked_used || gui_inventory_consumables_changed(gs, p)) { + gs->human_clicked_inv_slot = -1; + } + + /* update snapshot */ + gui_snapshot_inventory_state(gs, p); +} + +/** 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: { + int gear_slot = item_to_gear_slot(inv->item_db_idx); + if (gear_slot >= 0) { + if (human_active) { + human_input_queue_equip_inventory_item(hi, slot, inv->item_db_idx, gear_slot); + gs->human_clicked_inv_slot = slot; + } else { + 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; + human_input_queue_eat(hi, 0); + 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; + human_input_queue_eat(hi, 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; + human_input_queue_drink(hi, POTION_BREW, slot); + gs->human_clicked_inv_slot = slot; + } + return INV_ACTION_DRINK; + case INV_SLOT_RESTORE: + if (human_active) { + hi->pending_potion = POTION_RESTORE; + human_input_queue_drink(hi, POTION_RESTORE, slot); + gs->human_clicked_inv_slot = slot; + } + return INV_ACTION_DRINK; + case INV_SLOT_COMBAT_POT: + if (human_active) { + hi->pending_potion = POTION_COMBAT; + human_input_queue_drink(hi, POTION_COMBAT, slot); + gs->human_clicked_inv_slot = slot; + } + return INV_ACTION_DRINK; + case INV_SLOT_RANGED_POT: + if (human_active) { + hi->pending_potion = POTION_RANGED; + human_input_queue_drink(hi, POTION_RANGED, slot); + gs->human_clicked_inv_slot = slot; + } + return INV_ACTION_DRINK; + case INV_SLOT_ANTIVENOM: + if (human_active) { + hi->pending_potion = POTION_ANTIVENOM; + human_input_queue_drink(hi, POTION_ANTIVENOM, slot); + gs->human_clicked_inv_slot = slot; + } + return INV_ACTION_DRINK; + case INV_SLOT_PRAYER_POT: + if (human_active) { + hi->pending_potion = POTION_PRAYER_POT; + human_input_queue_drink(hi, POTION_PRAYER_POT, slot); + gs->human_clicked_inv_slot = slot; + } + return INV_ACTION_DRINK; + case INV_SLOT_BASTION_POT: + if (human_active) { + hi->pending_potion = POTION_BASTION; + human_input_queue_drink(hi, POTION_BASTION, slot); + gs->human_clicked_inv_slot = slot; + } + return INV_ACTION_DRINK; + case INV_SLOT_STAMINA_POT: + if (human_active) { + hi->pending_potion = POTION_STAMINA; + human_input_queue_drink(hi, POTION_STAMINA, slot); + 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 }); + } + } +} + + +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]); +} + + +/* enum order is already display order — 5 cols × 6 rows, 29 prayers + 1 empty. + grid is just the identity: position i maps to enum value i. */ +#define GUI_PRAYER_GRID_COUNT GUI_NUM_PRAYERS + +/** 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 = (GuiPrayerIdx)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); + } + } + } +} + + +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. + style names are weapon-specific in real OSRS (bow=Accurate/Rapid/Longrange, + scythe=Chop/Jab/Block, godsword=Chop/Slash/Smash/Block, etc.). */ + const char* style_names[4] = { "Accurate", "Aggressive", "Controlled", "Defensive" }; + int num_styles = 4; + switch (p->equipped[GEAR_SLOT_WEAPON]) { + case ITEM_TOXIC_BLOWPIPE: + case ITEM_ZARYTE_CROSSBOW: + case ITEM_TWISTED_BOW: + style_names[0] = "Accurate"; style_names[1] = "Rapid"; + style_names[2] = "Longrange"; num_styles = 3; break; + case ITEM_SCYTHE_OF_VITUR: + style_names[0] = "Chop"; style_names[1] = "Jab"; + style_names[2] = "Block"; num_styles = 3; break; + case ITEM_SGS: + style_names[0] = "Chop"; style_names[1] = "Slash"; + style_names[2] = "Smash"; style_names[3] = "Block"; break; + case ITEM_DRAGON_CLAWS: + style_names[0] = "Chop"; style_names[1] = "Slash"; + style_names[2] = "Lunge"; style_names[3] = "Block"; break; + case ITEM_KODAI_WAND: + style_names[0] = "Bash"; style_names[1] = "Pound"; + style_names[2] = "Focus"; style_names[3] = "Block"; break; + default: break; + } + int btn_gap = 6; + int btn_w = (gs->panel_w - 16 - btn_gap) / 2; + int btn_h = 60; + + for (int i = 0; i < num_styles; 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->spec_armed ? 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->spec_armed) { + 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); +} + + +typedef struct { + const char* name; + GuiSpellIdx idx; +} GuiSpellEntry; + +/* Ancient spellbook grid (4 cols × 4 rows = 16 combat spells, + vengeance). + sort is by level: rows go Rush/Burst/Blitz/Barrage top-to-bottom, and + within each row the order is Smoke / Shadow / Blood / Ice (ascending level). + only Ice/Blood/Vengeance are castable in this env — Smoke/Shadow render + greyed out with the "off" sprite and don't respond to clicks. */ +static const GuiSpellEntry GUI_SPELL_GRID[] = { + { "Smoke Rush", GUI_SPELL_SMOKE_RUSH }, + { "Shadow Rush", GUI_SPELL_SHADOW_RUSH }, + { "Blood Rush", GUI_SPELL_BLOOD_RUSH }, + { "Ice Rush", GUI_SPELL_ICE_RUSH }, + { "Smoke Burst", GUI_SPELL_SMOKE_BURST }, + { "Shadow Burst", GUI_SPELL_SHADOW_BURST }, + { "Blood Burst", GUI_SPELL_BLOOD_BURST }, + { "Ice Burst", GUI_SPELL_ICE_BURST }, + { "Smoke Blitz", GUI_SPELL_SMOKE_BLITZ }, + { "Shadow Blitz", GUI_SPELL_SHADOW_BLITZ }, + { "Blood Blitz", GUI_SPELL_BLOOD_BLITZ }, + { "Ice Blitz", GUI_SPELL_ICE_BLITZ }, + { "Smoke Barrage", GUI_SPELL_SMOKE_BARRAGE }, + { "Shadow Barrage",GUI_SPELL_SHADOW_BARRAGE }, + { "Blood Barrage", GUI_SPELL_BLOOD_BARRAGE }, + { "Ice Barrage", GUI_SPELL_ICE_BARRAGE }, + { "Vengeance", GUI_SPELL_VENGEANCE }, +}; +#define GUI_SPELL_GRID_COUNT 17 + +/* which spells are castable in this env (others render greyed out). */ +static inline int gui_spell_castable(GuiSpellIdx s) { + return (s == GUI_SPELL_VENGEANCE) + || (s >= GUI_SPELL_ICE_RUSH && s <= GUI_SPELL_ICE_BURST) /* ice */ + || (s >= GUI_SPELL_BLOOD_RUSH && s <= GUI_SPELL_BLOOD_BURST) /* blood */ + || (s == GUI_SPELL_ICE_BLITZ || s == GUI_SPELL_ICE_BARRAGE) + || (s == GUI_SPELL_BLOOD_BLITZ || s == GUI_SPELL_BLOOD_BARRAGE); +} + +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); + + GuiSpellIdx sidx_here = GUI_SPELL_GRID[i].idx; + /* active highlight for vengeance */ + int active = (sidx_here == GUI_SPELL_VENGEANCE && p->veng_active); + /* spell-targeting mode: highlight the pending spell cell */ + int targeting = (gs->pending_spell_highlight >= 0 && + (int)sidx_here == gs->pending_spell_highlight); + + /* 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); + } + if (targeting) { + /* yellow 2px border around the targeted spell */ + DrawRectangleLinesEx((Rectangle){(float)ix, (float)iy, (float)icon_sz, (float)icon_sz}, 2.0f, YELLOW); + } + + /* draw spell sprite. non-castable spells (smoke/shadow in this env) + use the greyed "off" sprite so the panel looks authentic. */ + GuiSpellIdx sidx = GUI_SPELL_GRID[i].idx; + if (gs->sprites_loaded) { + int castable = gui_spell_castable(sidx); + Texture2D tex = castable ? gs->spell_on[sidx] : gs->spell_off[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); +} + + +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_GUI_H */ diff --git a/ocean/osrs/osrs_human_input.h b/ocean/osrs/osrs_human_input.h new file mode 100644 index 0000000000..79f4c724fe --- /dev/null +++ b/ocean/osrs/osrs_human_input.h @@ -0,0 +1,447 @@ +/** + * @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_HUMAN_INPUT_H +#define OSRS_HUMAN_INPUT_H + +#include "osrs_types.h" +#include "osrs_human_input_types.h" +#include "osrs_encounter.h" + +/* forward declare — full struct lives in osrs_pvp_render.h */ +struct RenderClient; + + +/** 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; +} + + +/** 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; +} + +typedef int (*human_can_attack_entity_fn)( + void* ctx, const RenderEntity* entity, int entity_idx, int gui_entity_idx); + +/** 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, + human_can_attack_entity_fn can_attack_entity, + void* can_attack_ctx) { + /* check if an attackable entity occupies this tile (bounding box) */ + for (int i = 0; i < entity_count; i++) { + if (i == gui_entity_idx && entities[i].entity_type == ENTITY_PLAYER) continue; + if (!entities[i].npc_visible && entities[i].entity_type == ENTITY_NPC) continue; + if (human_tile_hits_entity(&entities[i], wx, wy)) { + if (can_attack_entity && + !can_attack_entity(can_attack_ctx, &entities[i], i, gui_entity_idx)) { + continue; + } + 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; + human_input_queue_spell_target(hi, hi->selected_spell, hi->pending_target_idx); + hi->cursor_mode = CURSOR_NORMAL; + } else { + human_input_queue_attack_npc(hi, hi->pending_target_idx); + } + 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_input_queue_walk(hi, wx, 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, + human_can_attack_entity_fn can_attack_entity, + void* can_attack_ctx, + 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, + can_attack_entity, can_attack_ctx); +} + +/** 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 = (GuiPrayerIdx)idx; + (void)p; /* current-state check no longer needed — toggle is handled by env */ + + /* emit toggle actions directly (new ENCOUNTER_OVERHEAD_* / ENCOUNTER_OFFENSIVE_* encoding). + the env handles the target-already-active → off transition. */ + switch (pidx) { + case GUI_PRAY_PROTECT_MAGIC: + hi->pending_prayer = ENCOUNTER_OVERHEAD_TOGGLE_MAGIC; + human_input_queue_overhead_prayer(hi, hi->pending_prayer); + break; + case GUI_PRAY_PROTECT_MISSILES: + hi->pending_prayer = ENCOUNTER_OVERHEAD_TOGGLE_RANGED; + human_input_queue_overhead_prayer(hi, hi->pending_prayer); + break; + case GUI_PRAY_PROTECT_MELEE: + hi->pending_prayer = ENCOUNTER_OVERHEAD_TOGGLE_MELEE; + human_input_queue_overhead_prayer(hi, hi->pending_prayer); + break; + case GUI_PRAY_SMITE: + hi->pending_prayer = ENCOUNTER_OVERHEAD_TOGGLE_SMITE; + human_input_queue_overhead_prayer(hi, hi->pending_prayer); + break; + case GUI_PRAY_REDEMPTION: + hi->pending_prayer = ENCOUNTER_OVERHEAD_TOGGLE_REDEMPTION; + human_input_queue_overhead_prayer(hi, hi->pending_prayer); + break; + case GUI_PRAY_PIETY: + hi->pending_offensive_prayer = ENCOUNTER_OFFENSIVE_TOGGLE_PIETY; + human_input_queue_offensive_prayer(hi, hi->pending_offensive_prayer); + break; + case GUI_PRAY_RIGOUR: + hi->pending_offensive_prayer = ENCOUNTER_OFFENSIVE_TOGGLE_RIGOUR; + human_input_queue_offensive_prayer(hi, hi->pending_offensive_prayer); + break; + case GUI_PRAY_AUGURY: + hi->pending_offensive_prayer = ENCOUNTER_OFFENSIVE_TOGGLE_AUGURY; + human_input_queue_offensive_prayer(hi, hi->pending_offensive_prayer); + 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; + + /* only castable spells respond to clicks (Smoke/Shadow are greyed out) */ + if (!gui_spell_castable(sidx)) return; + + 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_BURST || + sidx == GUI_SPELL_ICE_BLITZ || sidx == GUI_SPELL_ICE_BARRAGE) { + hi->cursor_mode = CURSOR_SPELL_TARGET; + hi->selected_spell = ATTACK_ICE; + hi->selected_spell_gui_idx = (int)sidx; + } else if (sidx == GUI_SPELL_BLOOD_RUSH || sidx == GUI_SPELL_BLOOD_BURST || + sidx == GUI_SPELL_BLOOD_BLITZ || sidx == GUI_SPELL_BLOOD_BARRAGE) { + hi->cursor_mode = CURSOR_SPELL_TARGET; + hi->selected_spell = ATTACK_BLOOD; + hi->selected_spell_gui_idx = (int)sidx; + } +} + +/** 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) { + if (hi->enabled) + human_input_queue_fight_style(hi, i); + else + 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; + human_input_queue_spec_toggle(hi); + } +} + + +/** 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; + } + } + + (void)agent; +} + +/* shared translate helpers (encounter_translate_movement/prayer/target) + live in osrs_encounter.h so encounter headers can use them directly. */ + + +/* 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 { + 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_HUMAN_INPUT_H */ diff --git a/ocean/osrs/osrs_human_input_types.h b/ocean/osrs/osrs_human_input_types.h new file mode 100644 index 0000000000..d1a8845741 --- /dev/null +++ b/ocean/osrs/osrs_human_input_types.h @@ -0,0 +1,222 @@ +/** + * @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_HUMAN_INPUT_TYPES_H +#define OSRS_HUMAN_INPUT_TYPES_H + +#include +#include +#include + +typedef enum { + CURSOR_NORMAL = 0, + CURSOR_SPELL_TARGET, /* clicked a combat spell, waiting for target click */ +} CursorMode; + +typedef enum { + HUMAN_COMMAND_NONE = 0, + HUMAN_COMMAND_WALK, + HUMAN_COMMAND_ATTACK_NPC, + HUMAN_COMMAND_OVERHEAD_PRAYER, + HUMAN_COMMAND_OFFENSIVE_PRAYER, + HUMAN_COMMAND_EAT, + HUMAN_COMMAND_DRINK, + HUMAN_COMMAND_SPELL_TARGET, + HUMAN_COMMAND_SPEC_TOGGLE, + HUMAN_COMMAND_EQUIP_INVENTORY_ITEM, + HUMAN_COMMAND_FIGHT_STYLE, +} HumanCommandKind; + +typedef struct { + HumanCommandKind kind; + int world_x, world_y; + int npc_slot; + int overhead_prayer; + int offensive_prayer; + int food; + int potion; + int spell; + int inventory_slot; + int item_db_idx; + int gear_slot; + int fight_style; +} HumanCommand; + +typedef struct { + HumanCommand* items; + int count; + int capacity; +} HumanCommandQueue; + +typedef struct HumanInput { + int enabled; /* H key toggle: 1 = human controls active */ + + HumanCommandQueue commands; + + /* 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-style intent, 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 */ + int selected_spell_gui_idx; /* GuiSpellIdx of the exact spell cell clicked, for UI highlight. -1 = none */ + + /* 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; + +static inline void human_command_queue_reserve(HumanCommandQueue* q, int min_capacity) { + if (q->capacity >= min_capacity) return; + int new_capacity = q->capacity > 0 ? q->capacity : 8; + while (new_capacity < min_capacity) + new_capacity *= 2; + HumanCommand* next = (HumanCommand*)realloc(q->items, (size_t)new_capacity * sizeof(HumanCommand)); + if (!next) { + fprintf(stderr, "human command queue: out of memory\n"); + abort(); + } + q->items = next; + q->capacity = new_capacity; +} + +static inline void human_input_queue_command(HumanInput* hi, HumanCommand cmd) { + human_command_queue_reserve(&hi->commands, hi->commands.count + 1); + hi->commands.items[hi->commands.count++] = cmd; +} + +static inline void human_input_clear_commands(HumanInput* hi) { + hi->commands.count = 0; +} + +static inline void human_input_destroy(HumanInput* hi) { + free(hi->commands.items); + hi->commands.items = NULL; + hi->commands.count = 0; + hi->commands.capacity = 0; +} + +static inline 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; + human_command_queue_reserve(&hi->commands, 8); +} + +static inline void human_input_clear_pending(HumanInput* hi) { + 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; + human_input_clear_commands(hi); +} + +static inline void human_input_clear_move(HumanInput* hi) { + hi->pending_move_x = -1; + hi->pending_move_y = -1; +} + +static inline void human_input_queue_walk(HumanInput* hi, int world_x, int world_y) { + human_input_queue_command(hi, (HumanCommand){ + .kind = HUMAN_COMMAND_WALK, + .world_x = world_x, + .world_y = world_y, + }); +} + +static inline void human_input_queue_attack_npc(HumanInput* hi, int npc_slot) { + human_input_queue_command(hi, (HumanCommand){ + .kind = HUMAN_COMMAND_ATTACK_NPC, + .npc_slot = npc_slot, + }); +} + +static inline void human_input_queue_overhead_prayer(HumanInput* hi, int overhead_prayer) { + human_input_queue_command(hi, (HumanCommand){ + .kind = HUMAN_COMMAND_OVERHEAD_PRAYER, + .overhead_prayer = overhead_prayer, + }); +} + +static inline void human_input_queue_offensive_prayer(HumanInput* hi, int offensive_prayer) { + human_input_queue_command(hi, (HumanCommand){ + .kind = HUMAN_COMMAND_OFFENSIVE_PRAYER, + .offensive_prayer = offensive_prayer, + }); +} + +static inline void human_input_queue_eat(HumanInput* hi, int food) { + human_input_queue_command(hi, (HumanCommand){ + .kind = HUMAN_COMMAND_EAT, + .food = food, + }); +} + +static inline void human_input_queue_drink(HumanInput* hi, int potion, int inventory_slot) { + human_input_queue_command(hi, (HumanCommand){ + .kind = HUMAN_COMMAND_DRINK, + .potion = potion, + .inventory_slot = inventory_slot, + }); +} + +static inline void human_input_queue_spell_target(HumanInput* hi, int spell, int npc_slot) { + human_input_queue_command(hi, (HumanCommand){ + .kind = HUMAN_COMMAND_SPELL_TARGET, + .spell = spell, + .npc_slot = npc_slot, + }); +} + +static inline void human_input_queue_spec_toggle(HumanInput* hi) { + human_input_queue_command(hi, (HumanCommand){ + .kind = HUMAN_COMMAND_SPEC_TOGGLE, + }); +} + +static inline void human_input_queue_equip_inventory_item( + HumanInput* hi, int inventory_slot, int item_db_idx, int gear_slot +) { + human_input_queue_command(hi, (HumanCommand){ + .kind = HUMAN_COMMAND_EQUIP_INVENTORY_ITEM, + .inventory_slot = inventory_slot, + .item_db_idx = item_db_idx, + .gear_slot = gear_slot, + }); +} + +static inline void human_input_queue_fight_style(HumanInput* hi, int fight_style) { + human_input_queue_command(hi, (HumanCommand){ + .kind = HUMAN_COMMAND_FIGHT_STYLE, + .fight_style = fight_style, + }); +} + +#endif /* OSRS_HUMAN_INPUT_TYPES_H */ diff --git a/ocean/osrs/osrs_interaction.h b/ocean/osrs/osrs_interaction.h new file mode 100644 index 0000000000..023bdc4122 --- /dev/null +++ b/ocean/osrs/osrs_interaction.h @@ -0,0 +1,82 @@ +/** + * @file osrs_interaction.h + * @brief shared entity interaction system + spec toggle helpers. + * + * in real OSRS, clicking to attack an entity starts a persistent interaction + * that auto-walks + auto-attacks until explicitly interrupted. + * + * ref: OSRS reverse engineering docs "Entity Interactions" + * interrupts: ground click, inventory item actions (food/potion/gear) + * NOT interrupts: prayer toggle, spec toggle, interface clicks, delays + */ + +#ifndef OSRS_INTERACTION_H +#define OSRS_INTERACTION_H + + +typedef struct { + int target_slot; /* target entity slot index, -1 = no interaction */ +} OsrsInteraction; + + +static inline void osrs_interaction_set(OsrsInteraction* ix, int target_slot) { + ix->target_slot = target_slot; +} + +static inline void osrs_interaction_clear(OsrsInteraction* ix) { + ix->target_slot = -1; +} + +static inline int osrs_interaction_active(const OsrsInteraction* ix) { + return ix->target_slot >= 0; +} + +static inline void osrs_interaction_init(OsrsInteraction* ix) { + ix->target_slot = -1; +} + + +#define OSRS_IACT_NONE 0 /* no action / idle — does NOT interrupt */ +#define OSRS_IACT_MOVE 1 /* explicit ground click — INTERRUPTS */ +#define OSRS_IACT_EAT 2 /* eat food from inventory — INTERRUPTS */ +#define OSRS_IACT_DRINK 3 /* drink potion from inventory — INTERRUPTS */ +#define OSRS_IACT_EQUIP 4 /* equip/switch gear from inventory — INTERRUPTS */ +#define OSRS_IACT_PRAYER 5 /* prayer toggle — does NOT interrupt */ +#define OSRS_IACT_SPEC 6 /* spec toggle — does NOT interrupt */ +#define OSRS_IACT_ATTACK 7 /* click to attack entity — SETS new interaction (not an interrupt) */ + + +/* check if an action type interrupts the current interaction. + if it does, clears the interaction and returns 1. otherwise returns 0. + ATTACK is handled separately (it sets a new interaction, not an interrupt). */ +static inline int osrs_interaction_check_interrupt(OsrsInteraction* ix, int action_type) { + switch (action_type) { + case OSRS_IACT_MOVE: + case OSRS_IACT_EAT: + case OSRS_IACT_DRINK: + case OSRS_IACT_EQUIP: + osrs_interaction_clear(ix); + return 1; + case OSRS_IACT_NONE: + case OSRS_IACT_PRAYER: + case OSRS_IACT_SPEC: + case OSRS_IACT_ATTACK: + default: + return 0; + } +} + + +/* spec toggle: arm/disarm special attack. + in real OSRS: clicking the spec orb toggles spec_armed. + when attack fires with spec_armed=1, weapon spec is used and spec_armed auto-disarms. + spec toggle does NOT interrupt entity interactions. */ +static inline void osrs_spec_toggle(int* spec_armed) { + *spec_armed = !(*spec_armed); +} + +static inline void osrs_spec_disarm(int* spec_armed) { + *spec_armed = 0; +} + +#endif /* OSRS_INTERACTION_H */ diff --git a/ocean/osrs/osrs_inventory.h b/ocean/osrs/osrs_inventory.h new file mode 100644 index 0000000000..1199e68d5d --- /dev/null +++ b/ocean/osrs/osrs_inventory.h @@ -0,0 +1,184 @@ +/** + * @file osrs_inventory.h + * @brief shared 28-slot inventory + 11-slot equipment management + * + * real OSRS inventory model: 11 equipment slots (worn gear) + 28 inventory + * slots (bag). handles equip/unequip with two-handed weapon logic and + * inventory<->equipment swaps. + * + * replaces: osrs_pvp_gear.h slot_equip_item(), item_to_gear_slot(), + * osrs_pvp_combat.h has_free_inventory_slot() + */ + +#ifndef OSRS_INVENTORY_H +#define OSRS_INVENTORY_H + +#include "osrs_types.h" +#include "osrs_items.h" + +#define OSRS_INVENTORY_SIZE 28 + +/* a complete player equipment state: worn gear + inventory bag. + encounters that don't need full inventory (like current inferno) can ignore + the inventory array and just use equipment[]. */ +typedef struct { + uint8_t equipment[NUM_GEAR_SLOTS]; + uint8_t inventory[OSRS_INVENTORY_SIZE]; +} OsrsInventory; + + +/** initialize inventory: all slots to ITEM_NONE. */ +static inline void osrs_inventory_init(OsrsInventory* inv) { + memset(inv->equipment, ITEM_NONE, NUM_GEAR_SLOTS); + memset(inv->inventory, ITEM_NONE, OSRS_INVENTORY_SIZE); +} + +/** count occupied inventory slots. */ +static inline int osrs_inventory_count(const OsrsInventory* inv) { + int count = 0; + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) { + if (inv->inventory[i] != ITEM_NONE) count++; + } + return count; +} + +/** count free inventory slots. */ +static inline int osrs_inventory_free_slots(const OsrsInventory* inv) { + return OSRS_INVENTORY_SIZE - osrs_inventory_count(inv); +} + +/** find first inventory slot containing item_idx. returns slot index or -1. */ +static inline int osrs_inventory_find(const OsrsInventory* inv, uint8_t item_idx) { + if (item_idx == ITEM_NONE) return -1; + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) { + if (inv->inventory[i] == item_idx) return i; + } + return -1; +} + +/** add item to first free inventory slot. returns slot index or -1 if full. */ +static inline int osrs_inventory_add(OsrsInventory* inv, uint8_t item_idx) { + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) { + if (inv->inventory[i] == ITEM_NONE) { + inv->inventory[i] = item_idx; + return i; + } + } + return -1; +} + +/** remove item from specific inventory slot. returns the item removed (ITEM_NONE if empty). */ +static inline uint8_t osrs_inventory_remove(OsrsInventory* inv, int slot) { + if (slot < 0 || slot >= OSRS_INVENTORY_SIZE) return ITEM_NONE; + uint8_t item = inv->inventory[slot]; + inv->inventory[slot] = ITEM_NONE; + return item; +} + +/** remove first occurrence of item_idx. returns 1 if found+removed, 0 if not found. */ +static inline int osrs_inventory_remove_item(OsrsInventory* inv, uint8_t item_idx) { + int slot = osrs_inventory_find(inv, item_idx); + if (slot < 0) return 0; + inv->inventory[slot] = ITEM_NONE; + return 1; +} + + +/** map item index to its gear slot. returns GearSlotIndex or -1 if unmapped. + replaces item_to_gear_slot() in osrs_pvp_gear.h:820. */ +static inline int osrs_item_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; + } +} + + +/** equip item directly (not from inventory -- used for initial setup). + places item in correct gear slot, no inventory interaction. + two-handed weapons clear the shield slot. */ +static inline void osrs_equip_direct(OsrsInventory* inv, uint8_t item_idx) { + int slot = osrs_item_gear_slot(item_idx); + if (slot < 0) return; + inv->equipment[slot] = item_idx; + if (slot == GEAR_SLOT_WEAPON && item_is_two_handed(item_idx)) { + inv->equipment[GEAR_SLOT_SHIELD] = ITEM_NONE; + } +} + +/** equip item from inventory slot to its gear slot. + if gear slot occupied, old item swaps to inventory. + two-handed weapons unequip shield to inventory first (fails if no space). + returns 1 on success, 0 on failure. + ref: osrs_pvp_gear.h slot_equip_item() line 283. */ +static inline int osrs_equip_from_inventory(OsrsInventory* inv, int inventory_slot) { + if (inventory_slot < 0 || inventory_slot >= OSRS_INVENTORY_SIZE) return 0; + uint8_t item_idx = inv->inventory[inventory_slot]; + if (item_idx == ITEM_NONE) return 0; + + int gear_slot = osrs_item_gear_slot(item_idx); + if (gear_slot < 0) return 0; + + /* two-handed: need to unequip shield if present */ + if (gear_slot == GEAR_SLOT_WEAPON && item_is_two_handed(item_idx)) { + uint8_t shield = inv->equipment[GEAR_SLOT_SHIELD]; + if (shield != ITEM_NONE) { + /* need a free slot for the shield (the inventory_slot will be freed + by the equip, so count that as available) */ + int free = osrs_inventory_free_slots(inv); + /* inventory_slot is occupied by the item we're equipping, so after + we remove it there's +1 free. but we also need to place the old + weapon (if any) back. */ + uint8_t old_weapon = inv->equipment[GEAR_SLOT_WEAPON]; + int slots_needed = 1; /* shield */ + if (old_weapon != ITEM_NONE) slots_needed++; /* old weapon swap */ + /* we free 1 slot (inventory_slot) by equipping from it */ + if (free + 1 < slots_needed) return 0; + + /* clear shield slot */ + inv->equipment[GEAR_SLOT_SHIELD] = ITEM_NONE; + /* place shield in inventory */ + if (old_weapon != ITEM_NONE) { + /* swap: remove item from inventory, equip it, put old weapon + shield back */ + inv->inventory[inventory_slot] = old_weapon; + inv->equipment[gear_slot] = item_idx; + osrs_inventory_add(inv, shield); + } else { + /* no old weapon: remove item, equip it, put shield in freed slot */ + inv->inventory[inventory_slot] = shield; + inv->equipment[gear_slot] = item_idx; + } + return 1; + } + } + + /* standard equip: swap with whatever is in the gear slot */ + uint8_t old_item = inv->equipment[gear_slot]; + inv->equipment[gear_slot] = item_idx; + inv->inventory[inventory_slot] = old_item; /* ITEM_NONE if slot was empty */ + return 1; +} + +/** unequip gear slot to inventory. returns 1 if successful (had space), 0 if full. */ +static inline int osrs_unequip_to_inventory(OsrsInventory* inv, int gear_slot) { + if (gear_slot < 0 || gear_slot >= NUM_GEAR_SLOTS) return 0; + uint8_t item = inv->equipment[gear_slot]; + if (item == ITEM_NONE) return 0; + int slot = osrs_inventory_add(inv, item); + if (slot < 0) return 0; + inv->equipment[gear_slot] = ITEM_NONE; + return 1; +} + +#endif /* OSRS_INVENTORY_H */ diff --git a/ocean/osrs/osrs_item_effects.h b/ocean/osrs/osrs_item_effects.h new file mode 100644 index 0000000000..9dbc9f5034 --- /dev/null +++ b/ocean/osrs/osrs_item_effects.h @@ -0,0 +1,365 @@ +/** + * @fileoverview osrs_item_effects.h — shared passive item effects for current ocean OSRS envs. + * + * owns derived equipment effect profiles, mutable passive proc state, and the + * shared attack/damage/spec-regeneration helpers that consume them. activated + * weapon specials stay in osrs_special_attacks.h. + */ + +#ifndef OSRS_ITEM_EFFECTS_H +#define OSRS_ITEM_EFFECTS_H + +#include + +#include "osrs_damage.h" + +#define OSRS_SPEC_REGEN_INTERVAL 50 +#define OSRS_SPEC_REGEN_LIGHTBEARER 25 +#define OSRS_SPEC_REGEN_AMOUNT 10 + +typedef struct { + int attack_roll; + int max_hit; + int use_double_accuracy; +} OsrsPreparedAttackEffects; + +typedef struct { + int heal_amount; +} OsrsPostAttackEffects; + +static inline OsrsTargetRef osrs_target_ref_none(void) { + OsrsTargetRef target = { .kind = OSRS_TARGET_NONE, .id = -1 }; + return target; +} + +static inline int osrs_target_ref_equal(OsrsTargetRef lhs, OsrsTargetRef rhs) { + return lhs.kind == rhs.kind && lhs.id == rhs.id; +} + +static inline int osrs_magic_attack_is_ancient(OsrsMagicAttackKind kind) { + return kind == OSRS_MAGIC_ATTACK_ANCIENT_ICE || + kind == OSRS_MAGIC_ATTACK_ANCIENT_BLOOD; +} + +static inline int osrs_effect_profile_has( + const OsrsEquipmentEffectProfile* profile, uint32_t effect_mask +) { + return (profile->effect_mask & effect_mask) != 0; +} + +static inline OsrsRecoilSource osrs_recoil_source_from_ring(uint8_t ring_item) { + if (ring_item == ITEM_RING_OF_RECOIL) { + return OSRS_RECOIL_SOURCE_RING_OF_RECOIL; + } + if (ring_item == ITEM_RING_OF_SUFFERING_RI) { + return OSRS_RECOIL_SOURCE_RING_OF_SUFFERING_RI; + } + return OSRS_RECOIL_SOURCE_NONE; +} + +static inline OsrsSpecRegenMode osrs_spec_regen_mode_from_ring(uint8_t ring_item) { + if (ring_item == ITEM_LIGHTBEARER) { + return OSRS_SPEC_REGEN_MODE_LIGHTBEARER; + } + return OSRS_SPEC_REGEN_MODE_NORMAL; +} + +static inline void osrs_item_effect_state_init(OsrsItemEffectState* state) { + memset(state, 0, sizeof(*state)); + state->confliction_weapon_item = ITEM_NONE; + state->confliction_target = osrs_target_ref_none(); +} + +static inline void osrs_clear_confliction_state(OsrsItemEffectState* state) { + state->confliction_is_primed = 0; + state->confliction_weapon_item = ITEM_NONE; + state->confliction_magic_kind = OSRS_MAGIC_ATTACK_NONE; + state->confliction_target = osrs_target_ref_none(); +} + +static inline GearBonuses osrs_gear_bonuses_from_equipment_bonuses( + const EquipmentBonuses* equipment_bonuses +) { + GearBonuses total = {0}; + total.stab_attack = equipment_bonuses->attack_stab; + total.slash_attack = equipment_bonuses->attack_slash; + total.crush_attack = equipment_bonuses->attack_crush; + total.magic_attack = equipment_bonuses->attack_magic; + total.ranged_attack = equipment_bonuses->attack_ranged; + total.stab_defence = equipment_bonuses->defence_stab; + total.slash_defence = equipment_bonuses->defence_slash; + total.crush_defence = equipment_bonuses->defence_crush; + total.magic_defence = equipment_bonuses->defence_magic; + total.ranged_defence = equipment_bonuses->defence_ranged; + total.melee_strength = equipment_bonuses->melee_strength; + total.ranged_strength = equipment_bonuses->ranged_strength; + total.magic_strength = equipment_bonuses->magic_damage; + total.attack_speed = equipment_bonuses->attack_speed; + total.attack_range = equipment_bonuses->attack_range; + return total; +} + +static inline void osrs_derive_equipment_effect_profile( + const uint8_t equipped[NUM_GEAR_SLOTS], + OsrsEquipmentEffectProfile* out +) { + memset(out, 0, sizeof(*out)); + out->weapon_item = equipped[GEAR_SLOT_WEAPON]; + out->ring_item = equipped[GEAR_SLOT_RING]; + out->shield_item = equipped[GEAR_SLOT_SHIELD]; + out->recoil_source = osrs_recoil_source_from_ring(out->ring_item); + out->spec_regen_mode = osrs_spec_regen_mode_from_ring(out->ring_item); + + for (int slot = 0; slot < NUM_GEAR_SLOTS; slot++) { + uint8_t item_index = equipped[slot]; + if (item_index >= NUM_ITEMS) { + continue; + } + + uint32_t effect_mask = ITEM_DATABASE[item_index].effect_mask; + out->effect_mask |= effect_mask; + + if (effect_mask & OSRS_ITEM_EFFECT_VIRTUS_PIECE) { + out->virtus_piece_count += 1; + } + if (effect_mask & OSRS_ITEM_EFFECT_DHAROK_PIECE) { + out->dharok_piece_count += 1; + } + } +} + +static inline void osrs_sync_item_effect_state( + Player* player, const OsrsEquipmentEffectProfile* previous_profile +) { + const OsrsEquipmentEffectProfile* current_profile = &player->equipment_effect_profile; + + if (previous_profile->spec_regen_mode != current_profile->spec_regen_mode) { + if (current_profile->spec_regen_mode == OSRS_SPEC_REGEN_MODE_LIGHTBEARER) { + if (player->item_effect_state.special_regen_ticks > OSRS_SPEC_REGEN_LIGHTBEARER) { + player->item_effect_state.special_regen_ticks = 0; + } + } else { + player->item_effect_state.special_regen_ticks = 0; + } + } + + if (previous_profile->recoil_source != current_profile->recoil_source) { + if (current_profile->recoil_source == OSRS_RECOIL_SOURCE_RING_OF_RECOIL) { + player->item_effect_state.recoil_charges = RECOIL_MAX_CHARGES; + } else if (current_profile->recoil_source == OSRS_RECOIL_SOURCE_RING_OF_SUFFERING_RI) { + player->item_effect_state.recoil_charges = RECOIL_MAX_CHARGES; + } else { + player->item_effect_state.recoil_charges = 0; + } + } + + if (!osrs_effect_profile_has(current_profile, OSRS_ITEM_EFFECT_CONFLICTION)) { + osrs_clear_confliction_state(&player->item_effect_state); + } +} + +static inline void osrs_refresh_player_equipment(Player* player) { + OsrsEquipmentEffectProfile previous_profile = player->equipment_effect_profile; + EquipmentBonuses equipment_bonuses; + osrs_sum_equipment_bonuses(player->equipped, &equipment_bonuses); + player->slot_cached_bonuses = osrs_gear_bonuses_from_equipment_bonuses(&equipment_bonuses); + osrs_derive_equipment_effect_profile(player->equipped, &player->equipment_effect_profile); + osrs_sync_item_effect_state(player, &previous_profile); + player->slot_gear_dirty = 0; +} + +static inline void osrs_ensure_player_equipment(Player* player) { + if (player->slot_gear_dirty) { + osrs_refresh_player_equipment(player); + } +} + +static inline int osrs_confliction_can_apply( + const OsrsEquipmentEffectProfile* profile, + AttackStyle style, + uint8_t weapon_item, + int is_primary_target +) { + return style == ATTACK_STYLE_MAGIC && + is_primary_target && + osrs_effect_profile_has(profile, OSRS_ITEM_EFFECT_CONFLICTION) && + !item_is_two_handed(weapon_item); +} + +static inline int osrs_confliction_is_match( + const OsrsItemEffectState* state, + uint8_t weapon_item, + OsrsMagicAttackKind magic_kind, + OsrsTargetRef target_ref +) { + return state->confliction_is_primed && + state->confliction_weapon_item == weapon_item && + state->confliction_magic_kind == magic_kind && + osrs_target_ref_equal(state->confliction_target, target_ref); +} + +static inline OsrsPreparedAttackEffects osrs_prepare_attack_effects( + const OsrsEquipmentEffectProfile* profile, + const OsrsItemEffectState* state, + uint8_t weapon_item, + AttackStyle style, + OsrsMagicAttackKind magic_kind, + OsrsTargetRef target_ref, + int is_primary_target, + int base_attack_roll, + int base_max_hit, + int target_magic_level, + int target_magic_attack_bonus, + int attacker_current_hitpoints, + int attacker_base_hitpoints +) { + OsrsPreparedAttackEffects result = { + .attack_roll = base_attack_roll, + .max_hit = base_max_hit, + .use_double_accuracy = 0, + }; + + if (style == ATTACK_STYLE_RANGED && + weapon_item == ITEM_TWISTED_BOW && + osrs_effect_profile_has(profile, OSRS_ITEM_EFFECT_TWISTED_BOW)) { + int target_magic = max_int(target_magic_level, target_magic_attack_bonus); + result.attack_roll = (int)(result.attack_roll * osrs_tbow_acc_mult(target_magic)); + result.max_hit = (int)(result.max_hit * osrs_tbow_dmg_mult(target_magic)); + } + + if (style == ATTACK_STYLE_MAGIC && osrs_magic_attack_is_ancient(magic_kind) && + profile->virtus_piece_count > 0) { + result.max_hit = result.max_hit * (100 + 3 * profile->virtus_piece_count) / 100; + } + + if (style == ATTACK_STYLE_MELEE && profile->dharok_piece_count >= 4) { + float hp_ratio = 1.0f - ((float)attacker_current_hitpoints / (float)attacker_base_hitpoints); + result.max_hit = (int)(result.max_hit * (1.0f + hp_ratio * hp_ratio)); + } + + if (osrs_confliction_can_apply(profile, style, weapon_item, is_primary_target) && + osrs_confliction_is_match(state, weapon_item, magic_kind, target_ref)) { + result.use_double_accuracy = 1; + } + + return result; +} + +static inline OsrsPostAttackEffects osrs_finalize_attack_effects( + const OsrsEquipmentEffectProfile* profile, + OsrsItemEffectState* state, + uint8_t weapon_item, + AttackStyle style, + OsrsMagicAttackKind magic_kind, + OsrsTargetRef target_ref, + int is_primary_target, + int used_double_accuracy, + int hit_landed, + int damage_dealt, + uint32_t* rng_state +) { + OsrsPostAttackEffects result = { .heal_amount = 0 }; + + if (osrs_confliction_can_apply(profile, style, weapon_item, is_primary_target)) { + if (used_double_accuracy) { + osrs_clear_confliction_state(state); + } else if (hit_landed) { + osrs_clear_confliction_state(state); + } else { + state->confliction_is_primed = 1; + state->confliction_weapon_item = weapon_item; + state->confliction_magic_kind = magic_kind; + state->confliction_target = target_ref; + } + } + + if (damage_dealt > 0 && + style == ATTACK_STYLE_MAGIC && + weapon_item == ITEM_SANGUINESTI_STAFF && + osrs_effect_profile_has(profile, OSRS_ITEM_EFFECT_SANG_HEAL) && + encounter_rand_int(rng_state, 6) == 0) { + result.heal_amount = damage_dealt / 2; + } + + return result; +} + +static inline int osrs_has_recoil_available( + const OsrsEquipmentEffectProfile* profile, + const OsrsItemEffectState* state +) { + if (profile->recoil_source == OSRS_RECOIL_SOURCE_RING_OF_SUFFERING_RI) { + return 1; + } + if (profile->recoil_source == OSRS_RECOIL_SOURCE_RING_OF_RECOIL) { + return state->recoil_charges > 0; + } + return 0; +} + +static inline DamageResult osrs_apply_passive_damage_pipeline( + int raw_damage, + int attack_style, + int target_prayer, + int is_pvp, + int target_veng_active, + int attacker_smite_active, + const OsrsEquipmentEffectProfile* defender_profile, + const OsrsItemEffectState* defender_state, + uint32_t* rng_state +) { + int prayer_correct = encounter_prayer_correct_for_style(target_prayer, attack_style); + int final_damage = osrs_prayer_reduce_damage(raw_damage, target_prayer, attack_style, is_pvp); + int elysian_reduced = 0; + + if (final_damage > 0 && + osrs_effect_profile_has(defender_profile, OSRS_ITEM_EFFECT_ELYSIAN) && + encounter_rand_int(rng_state, 10) < 7) { + final_damage = final_damage * 75 / 100; + elysian_reduced = 1; + } + + DamageResult result = osrs_apply_post_mitigation_pipeline( + final_damage, + prayer_correct, + target_veng_active, + osrs_has_recoil_available(defender_profile, defender_state), + attacker_smite_active + ); + result.elysian_reduced = elysian_reduced; + return result; +} + +static inline void osrs_consume_recoil_charges(Player* defender, int recoil_damage) { + osrs_ensure_player_equipment(defender); + if (defender->equipment_effect_profile.recoil_source != OSRS_RECOIL_SOURCE_RING_OF_RECOIL) { + return; + } + + defender->item_effect_state.recoil_charges -= recoil_damage; + if (defender->item_effect_state.recoil_charges > 0) { + return; + } + + defender->item_effect_state.recoil_charges = 0; + defender->equipped[GEAR_SLOT_RING] = ITEM_NONE; + osrs_refresh_player_equipment(defender); +} + +static inline void osrs_tick_special_regen(Player* player) { + osrs_ensure_player_equipment(player); + if (player->special_energy >= 100) { + player->item_effect_state.special_regen_ticks = 0; + return; + } + + int regen_interval = player->equipment_effect_profile.spec_regen_mode == + OSRS_SPEC_REGEN_MODE_LIGHTBEARER ? OSRS_SPEC_REGEN_LIGHTBEARER : OSRS_SPEC_REGEN_INTERVAL; + player->item_effect_state.special_regen_ticks += 1; + if (player->item_effect_state.special_regen_ticks >= regen_interval) { + player->special_energy = clamp(player->special_energy + OSRS_SPEC_REGEN_AMOUNT, 0, 100); + player->item_effect_state.special_regen_ticks = 0; + } +} + +#endif // OSRS_ITEM_EFFECTS_H diff --git a/ocean/osrs/osrs_items.h b/ocean/osrs/osrs_items.h new file mode 100644 index 0000000000..d258f432f5 --- /dev/null +++ b/ocean/osrs/osrs_items.h @@ -0,0 +1,321 @@ +/** + * @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. + * Item indices and stats are auto-generated from equipment.json via + * osrs_items_generated.h; this file owns the shared types (EquipmentSlot, + * Item struct) and lookup tables. + */ + +#ifndef OSRS_ITEMS_H +#define OSRS_ITEMS_H + +#include +#include + +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; + +typedef enum { + OSRS_ITEM_EFFECT_NONE = 0, + OSRS_ITEM_EFFECT_TWISTED_BOW = 1u << 0, + OSRS_ITEM_EFFECT_VIRTUS_PIECE = 1u << 1, + OSRS_ITEM_EFFECT_CONFLICTION = 1u << 2, + OSRS_ITEM_EFFECT_SANG_HEAL = 1u << 3, + OSRS_ITEM_EFFECT_RECOIL_RING = 1u << 4, + OSRS_ITEM_EFFECT_LIGHTBEARER = 1u << 5, + OSRS_ITEM_EFFECT_DHAROK_PIECE = 1u << 6, + OSRS_ITEM_EFFECT_ELYSIAN = 1u << 7, +} OsrsItemEffectMask; + +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; + uint32_t effect_mask; +} Item; +// ITEM DATABASE INDICES + STATIC DATABASE (auto-generated from equipment.json) + +#include "osrs_items_generated.h" + +// 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_VIRTUS_MASK, + ITEM_TORAGS_HELM, ITEM_DHAROKS_HELM, ITEM_VERACS_HELM, ITEM_GUTHANS_HELM, + 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_VIRTUS_ROBE_TOP, + ITEM_AHRIMS_ROBETOP, ITEM_KARILS_TOP, + ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_SHIELD] = {ITEM_DRAGON_DEFENDER, ITEM_AVERNIC_DEFENDER, ITEM_ELYSIAN_SPIRIT_SHIELD, + ITEM_SPIRIT_SHIELD, ITEM_BLESSED_SPIRIT_SHIELD, ITEM_SPECTRAL_SPIRIT_SHIELD, + ITEM_CRYSTAL_SHIELD, ITEM_MAGES_BOOK, ITEM_ELIDINIS_WARD_F, ITEM_DRAGONFIRE_SHIELD}, + [SLOT_LEGS] = {ITEM_RUNE_PLATELEGS, ITEM_MYSTIC_BOTTOM, ITEM_ANCESTRAL_BOTTOM, ITEM_VIRTUS_ROBE_BOTTOM, + ITEM_AHRIMS_ROBESKIRT, + ITEM_BANDOS_TASSETS, ITEM_TORAGS_PLATELEGS, ITEM_DHAROKS_PLATELEGS, ITEM_VERACS_PLATESKIRT, + ITEM_NONE}, + [SLOT_HANDS] = {ITEM_BARROWS_GLOVES, ITEM_CONFLICTION_GAUNTLETS, ITEM_TORMENTED_BRACELET, + ITEM_ZARYTE_VAMBRACES, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_FEET] = {ITEM_CLIMBING_BOOTS, ITEM_ETERNAL_BOOTS, ITEM_AVERNIC_TREADS, ITEM_INFINITY_BOOTS, + ITEM_BLESSED_DHIDE_BOOTS, ITEM_MYSTIC_BOOTS, ITEM_NONE, ITEM_NONE, ITEM_NONE, ITEM_NONE}, + [SLOT_RING] = {ITEM_BERSERKER_RING, ITEM_SEERS_RING_I, ITEM_LIGHTBEARER, ITEM_VENATOR_RING, + ITEM_RING_OF_RECOIL, ITEM_RING_OF_SUFFERING_RI, ITEM_MAGUS_RING, ITEM_ULTOR_RING, + 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] = 7, // neitiznot, ancestral/virtus, 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] = 6, // dhide, mystic, ancestral, virtus, ahrim, karil + [SLOT_SHIELD] = 10, // defenders, spirit variants, crystal, elysian, ward, mages book, dfs + [SLOT_LEGS] = 9, // rune, mystic, ancestral, virtus, ahrim, bandos, torag/dharok/verac legs + [SLOT_HANDS] = 4, // barrows, confliction, tormented, zaryte + [SLOT_FEET] = 6, // climbing, eternal, avernic, infinity, blessed d'hide, mystic + [SLOT_RING] = 8, // berserker, seers (i), lightbearer, venator, recoil, suffering, magus, ultor + [SLOT_AMMO] = 3, // diamond bolts (e), dragon arrows, opal dragon bolts (e) +}; + +/** 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: + case ITEM_MAGIC_SHORTBOW_I: + case ITEM_BOW_OF_FAERDHINEN: + case ITEM_TWISTED_BOW: + case ITEM_TOXIC_BLOWPIPE: + 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: + case ITEM_TRIDENT_OF_SWAMP: + case ITEM_SANGUINESTI_STAFF: + case ITEM_EYE_OF_AYAK: + 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: + case ITEM_MAGIC_SHORTBOW_I: + case ITEM_BOW_OF_FAERDHINEN: + case ITEM_TWISTED_BOW: + case ITEM_TOXIC_BLOWPIPE: + 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_items_generated.h b/ocean/osrs/osrs_items_generated.h new file mode 100644 index 0000000000..f294b7c5f0 --- /dev/null +++ b/ocean/osrs/osrs_items_generated.h @@ -0,0 +1,1360 @@ +/** + * @file osrs_items_generated.h + * @brief AUTO-GENERATED item database from equipment.json + * + * DO NOT EDIT — regenerate with: + * python ocean/osrs/tools/generate_items.py + */ + +#ifndef OSRS_ITEMS_GENERATED_H +#define OSRS_ITEMS_GENERATED_H + +typedef enum { + ITEM_HELM_NEITIZNOT = 0, /* Helm of Neitiznot */ + ITEM_GOD_CAPE = 1, /* Imbued god cape */ + ITEM_GLORY = 2, /* Amulet of glory */ + ITEM_BLACK_DHIDE_BODY = 3, /* Black d'hide body */ + ITEM_MYSTIC_TOP = 4, /* Mystic robe top */ + ITEM_RUNE_PLATELEGS = 5, /* Rune platelegs */ + ITEM_MYSTIC_BOTTOM = 6, /* Mystic robe bottom */ + ITEM_WHIP = 7, /* Abyssal whip */ + ITEM_RUNE_CROSSBOW = 8, /* Rune crossbow */ + ITEM_AHRIM_STAFF = 9, /* Ahrim's staff */ + ITEM_DRAGON_DAGGER = 10, /* Dragon dagger */ + ITEM_DRAGON_DEFENDER = 11, /* Dragon defender */ + ITEM_SPIRIT_SHIELD = 12, /* Spirit shield */ + ITEM_BARROWS_GLOVES = 13, /* Barrows gloves */ + ITEM_CLIMBING_BOOTS = 14, /* Climbing boots */ + ITEM_BERSERKER_RING = 15, /* Berserker ring */ + ITEM_DIAMOND_BOLTS_E = 16, /* Diamond bolts (e) */ + ITEM_GHRAZI_RAPIER = 17, /* Ghrazi rapier */ + ITEM_INQUISITORS_MACE = 18, /* Inquisitor's mace */ + ITEM_STAFF_OF_DEAD = 19, /* Staff of the dead */ + ITEM_KODAI_WAND = 20, /* Kodai wand */ + ITEM_VOLATILE_STAFF = 21, /* Volatile nightmare staff */ + ITEM_ZURIELS_STAFF = 22, /* Zuriel's staff (LMS-only, not in wiki equipment.json) */ + ITEM_ARMADYL_CROSSBOW = 23, /* Armadyl crossbow */ + ITEM_ZARYTE_CROSSBOW = 24, /* Zaryte crossbow */ + ITEM_DRAGON_CLAWS = 25, /* Dragon claws */ + ITEM_AGS = 26, /* Armadyl godsword */ + ITEM_ANCIENT_GS = 27, /* Ancient godsword */ + ITEM_GRANITE_MAUL = 28, /* Granite maul */ + ITEM_ELDER_MAUL = 29, /* Elder maul */ + ITEM_DARK_BOW = 30, /* Dark bow */ + ITEM_HEAVY_BALLISTA = 31, /* Heavy ballista */ + ITEM_VESTAS = 32, /* Vesta's longsword */ + ITEM_VOIDWAKER = 33, /* Voidwaker */ + ITEM_STATIUS_WARHAMMER = 34, /* Statius's warhammer */ + ITEM_MORRIGANS_JAVELIN = 35, /* Morrigan's javelin */ + ITEM_ANCESTRAL_HAT = 36, /* Ancestral hat */ + ITEM_ANCESTRAL_TOP = 37, /* Ancestral robe top */ + ITEM_ANCESTRAL_BOTTOM = 38, /* Ancestral robe bottom */ + ITEM_AHRIMS_ROBETOP = 39, /* Ahrim's robetop */ + ITEM_AHRIMS_ROBESKIRT = 40, /* Ahrim's robeskirt */ + ITEM_KARILS_TOP = 41, /* Karil's leathertop */ + ITEM_BANDOS_TASSETS = 42, /* Bandos tassets */ + ITEM_BLESSED_SPIRIT_SHIELD = 43, /* Blessed spirit shield */ + ITEM_FURY = 44, /* Amulet of fury */ + ITEM_OCCULT_NECKLACE = 45, /* Occult necklace */ + ITEM_INFERNAL_CAPE = 46, /* Infernal cape */ + ITEM_ETERNAL_BOOTS = 47, /* Eternal boots */ + ITEM_SEERS_RING_I = 48, /* Seers ring (i) */ + ITEM_LIGHTBEARER = 49, /* Lightbearer */ + ITEM_MAGES_BOOK = 50, /* Mage's book */ + ITEM_DRAGON_ARROWS = 51, /* Dragon arrows */ + ITEM_TORAGS_PLATELEGS = 52, /* Torag's platelegs */ + ITEM_DHAROKS_PLATELEGS = 53, /* Dharok's platelegs */ + ITEM_VERACS_PLATESKIRT = 54, /* Verac's plateskirt */ + ITEM_TORAGS_HELM = 55, /* Torag's helm */ + ITEM_DHAROKS_HELM = 56, /* Dharok's helm */ + ITEM_VERACS_HELM = 57, /* Verac's helm */ + ITEM_GUTHANS_HELM = 58, /* Guthan's helm */ + ITEM_OPAL_DRAGON_BOLTS = 59, /* Opal dragon bolts (e) */ + ITEM_IMBUED_SARA_CAPE = 60, /* Imbued saradomin cape */ + ITEM_EYE_OF_AYAK = 61, /* Eye of ayak */ + ITEM_ELIDINIS_WARD_F = 62, /* Elidinis' ward (f) */ + ITEM_CONFLICTION_GAUNTLETS = 63, /* Confliction gauntlets */ + ITEM_AVERNIC_TREADS = 64, /* Avernic treads (max) */ + ITEM_RING_OF_SUFFERING_RI = 65, /* Ring of suffering (ri) */ + ITEM_TWISTED_BOW = 66, /* Twisted bow */ + ITEM_MASORI_MASK_F = 67, /* Masori mask (f) */ + ITEM_MASORI_BODY_F = 68, /* Masori body (f) */ + ITEM_MASORI_CHAPS_F = 69, /* Masori chaps (f) */ + ITEM_NECKLACE_OF_ANGUISH = 70, /* Necklace of anguish */ + ITEM_DIZANAS_QUIVER = 71, /* Dizana's quiver */ + ITEM_ZARYTE_VAMBRACES = 72, /* Zaryte vambraces */ + ITEM_TOXIC_BLOWPIPE = 73, /* Toxic blowpipe */ + ITEM_AHRIMS_HOOD = 74, /* Ahrim's hood */ + ITEM_TORMENTED_BRACELET = 75, /* Tormented bracelet */ + ITEM_SANGUINESTI_STAFF = 76, /* Sanguinesti staff */ + ITEM_INFINITY_BOOTS = 77, /* Infinity boots */ + ITEM_GOD_BLESSING = 78, /* Holy blessing */ + ITEM_RING_OF_RECOIL = 79, /* Ring of recoil */ + ITEM_CRYSTAL_HELM = 80, /* Crystal helm */ + ITEM_AVAS_ASSEMBLER = 81, /* Ava's assembler */ + ITEM_CRYSTAL_BODY = 82, /* Crystal body */ + ITEM_CRYSTAL_LEGS = 83, /* Crystal legs */ + ITEM_BOW_OF_FAERDHINEN = 84, /* Bow of faerdhinen (c) */ + ITEM_BLESSED_DHIDE_BOOTS = 85, /* Blessed d'hide boots */ + ITEM_MYSTIC_HAT = 86, /* Mystic hat */ + ITEM_TRIDENT_OF_SWAMP = 87, /* Trident of the swamp */ + ITEM_BOOK_OF_DARKNESS = 88, /* Book of darkness */ + ITEM_AMETHYST_ARROW = 89, /* Amethyst arrow */ + ITEM_MYSTIC_BOOTS = 90, /* Mystic boots */ + ITEM_BLESSED_COIF = 91, /* Blessed coif */ + ITEM_BLACK_DHIDE_CHAPS = 92, /* Black d'hide chaps */ + ITEM_MAGIC_SHORTBOW_I = 93, /* Magic shortbow (i) */ + ITEM_AVAS_ACCUMULATOR = 94, /* Ava's accumulator */ + ITEM_CRYSTAL_SHIELD = 95, /* Crystal shield */ + ITEM_PEGASIAN_BOOTS = 96, /* Pegasian boots */ + ITEM_JUSTICIAR_FACEGUARD = 97, /* Justiciar faceguard */ + ITEM_JUSTICIAR_CHESTGUARD = 98, /* Justiciar chestguard */ + ITEM_JUSTICIAR_LEGGUARDS = 99, /* Justiciar legguards */ + ITEM_DRAGON_DART = 100, /* Dragon dart */ + ITEM_SCYTHE_OF_VITUR = 101, /* Scythe of vitur */ + ITEM_BLADE_OF_SAELDOR = 102, /* Blade of saeldor (c) */ + ITEM_OSMUMTENS_FANG = 103, /* Osmumten's fang */ + ITEM_SOULREAPER_AXE = 104, /* Soulreaper axe */ + ITEM_TORVA_FULL_HELM = 105, /* Torva full helm */ + ITEM_TORVA_PLATEBODY = 106, /* Torva platebody */ + ITEM_TORVA_PLATELEGS = 107, /* Torva platelegs */ + ITEM_BANDOS_CHESTPLATE = 108, /* Bandos chestplate */ + ITEM_BANDOS_BOOTS = 109, /* Bandos boots */ + ITEM_PRIMORDIAL_BOOTS = 110, /* Primordial boots */ + ITEM_FEROCIOUS_GLOVES = 111, /* Ferocious gloves */ + ITEM_AMULET_OF_TORTURE = 112, /* Amulet of torture */ + ITEM_BERSERKER_RING_I = 113, /* Berserker ring (i) */ + ITEM_ULTOR_RING = 114, /* Ultor ring */ + ITEM_AVERNIC_DEFENDER = 115, /* Avernic defender */ + ITEM_VENATOR_RING = 116, /* Venator ring */ + ITEM_VIRTUS_MASK = 117, /* Virtus mask */ + ITEM_VIRTUS_ROBE_TOP = 118, /* Virtus robe top */ + ITEM_VIRTUS_ROBE_BOTTOM = 119, /* Virtus robe bottom */ + ITEM_MAGUS_RING = 120, /* Magus ring */ + ITEM_TUMEKENS_SHADOW = 121, /* Tumeken's shadow */ + ITEM_BGS = 122, /* Bandos godsword */ + ITEM_SGS = 123, /* Saradomin godsword */ + ITEM_ZGS = 124, /* Zamorak godsword */ + ITEM_CRYSTAL_HALBERD = 125, /* Crystal halberd */ + ITEM_DRAGON_BATTLEAXE = 126, /* Dragon battleaxe */ + ITEM_RUBY_DRAGON_BOLTS_E = 127, /* Ruby dragon bolts (e) */ + ITEM_DIAMOND_DRAGON_BOLTS_E = 128, /* Diamond dragon bolts (e) */ + ITEM_RUNE_ARROW = 129, /* Rune arrow */ + ITEM_DRAGON_JAVELIN = 130, /* Dragon javelin */ + ITEM_SPECTRAL_SPIRIT_SHIELD = 131, /* Spectral spirit shield */ + ITEM_ELYSIAN_SPIRIT_SHIELD = 132, /* Elysian spirit shield */ + ITEM_DRAGONFIRE_SHIELD = 133, /* Dragonfire shield */ + NUM_ITEMS = 134, + ITEM_NONE = 255 +} ItemIndex; + +static const Item ITEM_DATABASE[NUM_ITEMS] = { + [ITEM_HELM_NEITIZNOT] = { /* Helm of 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_GOD_CAPE] = { /* Imbued god cape */ + .item_id = 21795, .name = "Imbued zamorak 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_GLORY] = { /* Amulet of 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 = 3, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BLACK_DHIDE_BODY] = { /* Black d'hide 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 = 30, .defence_slash = 38, .defence_crush = 45, + .defence_magic = 45, .defence_ranged = 50, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_MYSTIC_TOP] = { /* Mystic robe 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_RUNE_PLATELEGS] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_MYSTIC_BOTTOM] = { /* Mystic robe 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_WHIP] = { /* Abyssal 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_RUNE_CROSSBOW] = { /* Rune crossbow */ + .item_id = 9185, .name = "Rune crossbow", .slot = SLOT_WEAPON, + .attack_speed = 6, .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_AHRIM_STAFF] = { /* Ahrim's staff */ + .item_id = 4710, .name = "Ahrim's staff", .slot = SLOT_WEAPON, + .attack_speed = 6, .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DRAGON_DAGGER] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DRAGON_DEFENDER] = { /* 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 = -2, + .melee_strength = 6, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_SPIRIT_SHIELD] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BARROWS_GLOVES] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_CLIMBING_BOOTS] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BERSERKER_RING] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DIAMOND_BOLTS_E] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_GHRAZI_RAPIER] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_INQUISITORS_MACE] = { /* Inquisitor's mace */ + .item_id = 24417, .name = "Inquisitor's mace", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 52, .attack_slash = -4, .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_STAFF_OF_DEAD] = { /* Staff of the dead */ + .item_id = 11791, .name = "Staff of the dead", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 10, + .attack_stab = 55, .attack_slash = 70, .attack_crush = 0, + .attack_magic = 17, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 3, .defence_crush = 3, + .defence_magic = 17, .defence_ranged = 0, + .melee_strength = 72, .ranged_strength = 0, .magic_damage = 15, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_KODAI_WAND] = { /* Kodai wand */ + .item_id = 21006, .name = "Kodai wand", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 10, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 28, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 3, .defence_crush = 3, + .defence_magic = 20, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 15, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_VOLATILE_STAFF] = { /* Volatile nightmare 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 = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 14, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 15, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ZURIELS_STAFF] = { /* Zuriel's staff (LMS-only, not in wiki equipment.json) (manual) */ + .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ARMADYL_CROSSBOW] = { /* Armadyl crossbow */ + .item_id = 11785, .name = "Armadyl crossbow", .slot = SLOT_WEAPON, + .attack_speed = 6, .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 = 1, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ZARYTE_CROSSBOW] = { /* Zaryte crossbow */ + .item_id = 26374, .name = "Zaryte crossbow", .slot = SLOT_WEAPON, + .attack_speed = 6, .attack_range = 7, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 110, + .defence_stab = 14, .defence_slash = 14, .defence_crush = 12, + .defence_magic = 15, .defence_ranged = 16, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DRAGON_CLAWS] = { /* 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 = 7, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 56, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_AGS] = { /* Armadyl godsword */ + .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ANCIENT_GS] = { /* Ancient godsword */ + .item_id = 26233, .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_GRANITE_MAUL] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ELDER_MAUL] = { /* 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 = 0, .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DARK_BOW] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_HEAVY_BALLISTA] = { /* 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 = 15, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_VESTAS] = { /* Vesta's longsword */ + .item_id = 22613, .name = "Vesta's longsword (Deadman Mode", .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_VOIDWAKER] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_STATIUS_WARHAMMER] = { /* Statius's warhammer */ + .item_id = 22622, .name = "Statius's warhammer (Deadman Mo", .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_MORRIGANS_JAVELIN] = { /* Morrigan's javelin */ + .item_id = 22636, .name = "Morrigan's javelin (Deadman Mod", .slot = SLOT_WEAPON, + .attack_speed = 6, .attack_range = 5, + .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ANCESTRAL_HAT] = { /* 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 = -2, + .defence_stab = 12, .defence_slash = 11, .defence_crush = 13, + .defence_magic = 5, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 3, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ANCESTRAL_TOP] = { /* Ancestral robe 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 = -8, + .defence_stab = 42, .defence_slash = 31, .defence_crush = 51, + .defence_magic = 28, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 3, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ANCESTRAL_BOTTOM] = { /* Ancestral robe 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 = -7, + .defence_stab = 27, .defence_slash = 24, .defence_crush = 30, + .defence_magic = 20, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 3, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_AHRIMS_ROBETOP] = { /* Ahrim's 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 = -10, + .defence_stab = 52, .defence_slash = 37, .defence_crush = 63, + .defence_magic = 30, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 1, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_AHRIMS_ROBESKIRT] = { /* Ahrim's 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 = -7, + .defence_stab = 33, .defence_slash = 30, .defence_crush = 36, + .defence_magic = 22, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 1, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_KARILS_TOP] = { /* Karil's leathertop */ + .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 = 47, .defence_slash = 42, .defence_crush = 50, + .defence_magic = 65, .defence_ranged = 57, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BANDOS_TASSETS] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BLESSED_SPIRIT_SHIELD] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_FURY] = { /* Amulet of 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_OCCULT_NECKLACE] = { /* 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 = 5, .prayer = 2, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_INFERNAL_CAPE] = { /* 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 = 12, .defence_ranged = 12, + .melee_strength = 8, .ranged_strength = 0, .magic_damage = 0, .prayer = 2, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ETERNAL_BOOTS] = { /* 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 = 5, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 1, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_SEERS_RING_I] = { /* 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 = 1, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_LIGHTBEARER] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_LIGHTBEARER + }, + [ITEM_MAGES_BOOK] = { /* Mage's 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DRAGON_ARROWS] = { /* Dragon arrows */ + .item_id = 11212, .name = "Dragon 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 = 60, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_TORAGS_PLATELEGS] = { /* Torag's 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DHAROKS_PLATELEGS] = { /* Dharok's 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, .effect_mask = OSRS_ITEM_EFFECT_DHAROK_PIECE + }, + [ITEM_VERACS_PLATESKIRT] = { /* Verac's 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_TORAGS_HELM] = { /* Torag's 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DHAROKS_HELM] = { /* Dharok's 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, .effect_mask = OSRS_ITEM_EFFECT_DHAROK_PIECE + }, + [ITEM_VERACS_HELM] = { /* Verac's 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_GUTHANS_HELM] = { /* Guthan's 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_OPAL_DRAGON_BOLTS] = { /* Opal dragon bolts (e) */ + .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_IMBUED_SARA_CAPE] = { /* Imbued saradomin 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_EYE_OF_AYAK] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ELIDINIS_WARD_F] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_CONFLICTION_GAUNTLETS] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_CONFLICTION + }, + [ITEM_AVERNIC_TREADS] = { /* Avernic treads (max) */ + .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_RING_OF_SUFFERING_RI] = { /* Ring of suffering (ri) */ + .item_id = 20657, .name = "Ring of suffering (i)", .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, .effect_mask = OSRS_ITEM_EFFECT_RECOIL_RING + }, + [ITEM_TWISTED_BOW] = { /* Twisted bow */ + .item_id = 20997, .name = "Twisted bow", .slot = SLOT_WEAPON, + .attack_speed = 6, .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, .effect_mask = OSRS_ITEM_EFFECT_TWISTED_BOW + }, + [ITEM_MASORI_MASK_F] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_MASORI_BODY_F] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_MASORI_CHAPS_F] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_NECKLACE_OF_ANGUISH] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DIZANAS_QUIVER] = { /* Dizana's 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ZARYTE_VAMBRACES] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_TOXIC_BLOWPIPE] = { /* Toxic blowpipe */ + .item_id = 12926, .name = "Toxic blowpipe", .slot = SLOT_WEAPON, + .attack_speed = 3, .attack_range = 5, + .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, + .melee_strength = 0, .ranged_strength = 20, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_AHRIMS_HOOD] = { /* Ahrim's 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_TORMENTED_BRACELET] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_SANGUINESTI_STAFF] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_SANG_HEAL + }, + [ITEM_INFINITY_BOOTS] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_GOD_BLESSING] = { /* Holy 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_RING_OF_RECOIL] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_RECOIL_RING + }, + [ITEM_CRYSTAL_HELM] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_AVAS_ASSEMBLER] = { /* Ava's 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_CRYSTAL_BODY] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_CRYSTAL_LEGS] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BOW_OF_FAERDHINEN] = { /* Bow of faerdhinen (c) */ + .item_id = 25865, .name = "Bow of faerdhinen", .slot = SLOT_WEAPON, + .attack_speed = 5, .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BLESSED_DHIDE_BOOTS] = { /* Blessed d'hide boots */ + .item_id = 19921, .name = "Ancient 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_MYSTIC_HAT] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_TRIDENT_OF_SWAMP] = { /* Trident of the 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BOOK_OF_DARKNESS] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_AMETHYST_ARROW] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_MYSTIC_BOOTS] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BLESSED_COIF] = { /* Blessed coif */ + .item_id = 10382, .name = "Guthix 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BLACK_DHIDE_CHAPS] = { /* Black d'hide 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_MAGIC_SHORTBOW_I] = { /* 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_AVAS_ACCUMULATOR] = { /* Ava's 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_CRYSTAL_SHIELD] = { /* Crystal shield */ + .item_id = 4224, .name = "Crystal shield (historical)", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -10, .attack_ranged = -10, + .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_PEGASIAN_BOOTS] = { /* 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 = 5, .defence_crush = 5, + .defence_magic = 5, .defence_ranged = 5, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_JUSTICIAR_FACEGUARD] = { /* 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 = 60, .defence_slash = 63, .defence_crush = 59, + .defence_magic = -6, .defence_ranged = 67, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 2, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_JUSTICIAR_CHESTGUARD] = { /* 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 = -40, .attack_ranged = -20, + .defence_stab = 132, .defence_slash = 130, .defence_crush = 117, + .defence_magic = -16, .defence_ranged = 142, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 4, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_JUSTICIAR_LEGGUARDS] = { /* 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 = -31, .attack_ranged = -17, + .defence_stab = 95, .defence_slash = 92, .defence_crush = 93, + .defence_magic = -14, .defence_ranged = 102, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 4, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DRAGON_DART] = { /* Dragon dart */ + .item_id = 11230, .name = "Dragon dart", .slot = SLOT_WEAPON, + .attack_speed = 3, .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 = 35, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_SCYTHE_OF_VITUR] = { /* Scythe of vitur */ + .item_id = 22325, .name = "Scythe of vitur", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 2, + .attack_stab = 70, .attack_slash = 125, .attack_crush = 30, + .attack_magic = -6, .attack_ranged = 0, + .defence_stab = -2, .defence_slash = 8, .defence_crush = 10, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 75, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BLADE_OF_SAELDOR] = { /* Blade of saeldor (c) */ + .item_id = 24551, .name = "Blade of saeldor (c)", .slot = SLOT_WEAPON, + .attack_speed = 4, .attack_range = 1, + .attack_stab = 55, .attack_slash = 94, .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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_OSMUMTENS_FANG] = { /* Osmumten's fang */ + .item_id = 26219, .name = "Osmumten's fang", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 1, + .attack_stab = 105, .attack_slash = 75, .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 = 103, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_SOULREAPER_AXE] = { /* Soulreaper axe */ + .item_id = 28338, .name = "Soulreaper axe", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 1, + .attack_stab = 28, .attack_slash = 134, .attack_crush = 66, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 121, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_TORVA_FULL_HELM] = { /* Torva full helm */ + .item_id = 26382, .name = "Torva full helm", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -5, .attack_ranged = -5, + .defence_stab = 59, .defence_slash = 60, .defence_crush = 62, + .defence_magic = -2, .defence_ranged = 57, + .melee_strength = 8, .ranged_strength = 0, .magic_damage = 0, .prayer = 1, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_TORVA_PLATEBODY] = { /* Torva platebody */ + .item_id = 26384, .name = "Torva platebody", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -18, .attack_ranged = -14, + .defence_stab = 117, .defence_slash = 111, .defence_crush = 117, + .defence_magic = -11, .defence_ranged = 142, + .melee_strength = 6, .ranged_strength = 0, .magic_damage = 0, .prayer = 1, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_TORVA_PLATELEGS] = { /* Torva platelegs */ + .item_id = 26386, .name = "Torva platelegs", .slot = SLOT_LEGS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -24, .attack_ranged = -11, + .defence_stab = 87, .defence_slash = 78, .defence_crush = 79, + .defence_magic = -9, .defence_ranged = 102, + .melee_strength = 4, .ranged_strength = 0, .magic_damage = 0, .prayer = 1, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BANDOS_CHESTPLATE] = { /* Bandos chestplate */ + .item_id = 11832, .name = "Bandos chestplate", .slot = SLOT_BODY, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -15, .attack_ranged = -10, + .defence_stab = 98, .defence_slash = 93, .defence_crush = 105, + .defence_magic = -6, .defence_ranged = 133, + .melee_strength = 4, .ranged_strength = 0, .magic_damage = 0, .prayer = 1, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BANDOS_BOOTS] = { /* Bandos boots */ + .item_id = 11836, .name = "Bandos boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -5, .attack_ranged = -3, + .defence_stab = 17, .defence_slash = 18, .defence_crush = 19, + .defence_magic = 0, .defence_ranged = 15, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_PRIMORDIAL_BOOTS] = { /* Primordial boots */ + .item_id = 13239, .name = "Primordial boots", .slot = SLOT_FEET, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 2, .attack_slash = 2, .attack_crush = 2, + .attack_magic = -4, .attack_ranged = -1, + .defence_stab = 22, .defence_slash = 22, .defence_crush = 22, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 5, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_FEROCIOUS_GLOVES] = { /* Ferocious gloves */ + .item_id = 22981, .name = "Ferocious gloves", .slot = SLOT_HANDS, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 16, .attack_slash = 16, .attack_crush = 16, + .attack_magic = -16, .attack_ranged = -16, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 14, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_AMULET_OF_TORTURE] = { /* Amulet of torture */ + .item_id = 19553, .name = "Amulet of torture", .slot = SLOT_NECK, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 15, .attack_slash = 15, .attack_crush = 15, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 10, .ranged_strength = 0, .magic_damage = 0, .prayer = 2, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BERSERKER_RING_I] = { /* Berserker ring (i) */ + .item_id = 11773, .name = "Berserker ring (i)", .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 = 8, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 8, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ULTOR_RING] = { /* Ultor ring */ + .item_id = 28307, .name = "Ultor 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 = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 12, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_AVERNIC_DEFENDER] = { /* Avernic defender */ + .item_id = 22322, .name = "Avernic defender", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 30, .attack_slash = 29, .attack_crush = 28, + .attack_magic = -5, .attack_ranged = -4, + .defence_stab = 30, .defence_slash = 29, .defence_crush = 28, + .defence_magic = -5, .defence_ranged = -4, + .melee_strength = 8, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_VENATOR_RING] = { /* Venator ring */ + .item_id = 28310, .name = "Venator ring", .slot = SLOT_RING, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 0, .attack_ranged = 10, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 2, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_VIRTUS_MASK] = { /* Virtus mask */ + .item_id = 26241, .name = "Virtus mask", .slot = SLOT_HEAD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = 8, .attack_ranged = -3, + .defence_stab = 15, .defence_slash = 14, .defence_crush = 16, + .defence_magic = 6, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 1, .effect_mask = OSRS_ITEM_EFFECT_VIRTUS_PIECE + }, + [ITEM_VIRTUS_ROBE_TOP] = { /* Virtus robe top */ + .item_id = 26243, .name = "Virtus 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 = -11, + .defence_stab = 47, .defence_slash = 36, .defence_crush = 56, + .defence_magic = 31, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 2, .effect_mask = OSRS_ITEM_EFFECT_VIRTUS_PIECE + }, + [ITEM_VIRTUS_ROBE_BOTTOM] = { /* Virtus robe bottom */ + .item_id = 26245, .name = "Virtus 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 = -9, + .defence_stab = 31, .defence_slash = 28, .defence_crush = 34, + .defence_magic = 22, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 1, .effect_mask = OSRS_ITEM_EFFECT_VIRTUS_PIECE + }, + [ITEM_MAGUS_RING] = { /* Magus ring */ + .item_id = 28313, .name = "Magus ring", .slot = SLOT_RING, + .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 = 0, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 2, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_TUMEKENS_SHADOW] = { /* Tumeken's shadow */ + .item_id = 27275, .name = "Tumeken's shadow", .slot = SLOT_WEAPON, + .attack_speed = 5, .attack_range = 10, + .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 = 20, .defence_ranged = 0, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 1, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_BGS] = { /* Bandos godsword */ + .item_id = 11804, .name = "Bandos 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_SGS] = { /* Saradomin godsword */ + .item_id = 11806, .name = "Saradomin 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ZGS] = { /* Zamorak godsword */ + .item_id = 11808, .name = "Zamorak 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_CRYSTAL_HALBERD] = { /* Crystal halberd */ + .item_id = 23987, .name = "Crystal halberd", .slot = SLOT_WEAPON, + .attack_speed = 7, .attack_range = 2, + .attack_stab = 85, .attack_slash = 110, .attack_crush = 5, + .attack_magic = -4, .attack_ranged = 0, + .defence_stab = -1, .defence_slash = 4, .defence_crush = 5, + .defence_magic = 0, .defence_ranged = 0, + .melee_strength = 118, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DRAGON_BATTLEAXE] = { /* Dragon battleaxe */ + .item_id = 1377, .name = "Dragon battleaxe", .slot = SLOT_WEAPON, + .attack_speed = 6, .attack_range = 1, + .attack_stab = -2, .attack_slash = 70, .attack_crush = 65, + .attack_magic = 0, .attack_ranged = 0, + .defence_stab = 0, .defence_slash = 0, .defence_crush = 0, + .defence_magic = 0, .defence_ranged = -1, + .melee_strength = 85, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_RUBY_DRAGON_BOLTS_E] = { /* Ruby dragon bolts (e) */ + .item_id = 21944, .name = "Ruby 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DIAMOND_DRAGON_BOLTS_E] = { /* Diamond dragon bolts (e) */ + .item_id = 21946, .name = "Diamond 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, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_RUNE_ARROW] = { /* Rune arrow */ + .item_id = 892, .name = "Rune 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 = 49, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_DRAGON_JAVELIN] = { /* Dragon javelin */ + .item_id = 19484, .name = "Dragon javelin", .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 = 150, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_SPECTRAL_SPIRIT_SHIELD] = { /* Spectral spirit shield */ + .item_id = 12821, .name = "Spectral 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 = 30, .defence_ranged = 52, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 3, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, + [ITEM_ELYSIAN_SPIRIT_SHIELD] = { /* Elysian spirit shield */ + .item_id = 12817, .name = "Elysian 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 = 63, .defence_slash = 65, .defence_crush = 75, + .defence_magic = 2, .defence_ranged = 57, + .melee_strength = 0, .ranged_strength = 0, .magic_damage = 0, .prayer = 3, .effect_mask = OSRS_ITEM_EFFECT_ELYSIAN + }, + [ITEM_DRAGONFIRE_SHIELD] = { /* Dragonfire shield */ + .item_id = 11283, .name = "Dragonfire shield", .slot = SLOT_SHIELD, + .attack_speed = 0, .attack_range = 0, + .attack_stab = 0, .attack_slash = 0, .attack_crush = 0, + .attack_magic = -10, .attack_ranged = -5, + .defence_stab = 70, .defence_slash = 75, .defence_crush = 72, + .defence_magic = 10, .defence_ranged = 72, + .melee_strength = 7, .ranged_strength = 0, .magic_damage = 0, .prayer = 0, .effect_mask = OSRS_ITEM_EFFECT_NONE + }, +}; + +#endif /* OSRS_ITEMS_GENERATED_H */ diff --git a/ocean/osrs/osrs_models.h b/ocean/osrs/osrs_models.h new file mode 100644 index 0000000000..c0a4ddfb55 --- /dev/null +++ b/ocean/osrs/osrs_models.h @@ -0,0 +1,204 @@ +/** + * @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_MODELS_H +#define OSRS_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; + + +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; +} + + +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; +} + + +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_MODELS_H */ diff --git a/ocean/osrs/osrs_monsters_generated.h b/ocean/osrs/osrs_monsters_generated.h new file mode 100644 index 0000000000..b330885183 --- /dev/null +++ b/ocean/osrs/osrs_monsters_generated.h @@ -0,0 +1,255 @@ +/** + * @file osrs_monsters_generated.h + * @brief AUTO-GENERATED monster database from monsters.json + * + * DO NOT EDIT — regenerate with: + * python ocean/osrs/tools/generate_monsters.py + */ + +#ifndef OSRS_MONSTERS_GENERATED_H +#define OSRS_MONSTERS_GENERATED_H + +#include + +typedef enum { + MON_JAL_NIB = 0, /* Nibbler */ + MON_JAL_MEJRAH = 1, /* Bat */ + MON_JAL_AK = 2, /* Blob */ + MON_JAL_AKREK_MEJ = 3, /* Blob mage split */ + MON_JAL_AKREK_XIL = 4, /* Blob range split */ + MON_JAL_AKREK_KET = 5, /* Blob melee split */ + MON_JAL_IMKOT = 6, /* Meleer */ + MON_JAL_XIL = 7, /* Ranger */ + MON_JAL_ZEK = 8, /* Mager */ + MON_JALTOK_JAD = 9, /* Jad */ + MON_YT_HURKOT = 10, /* Jad healer */ + MON_TZKAL_ZUK = 11, /* Zuk */ + MON_ZUK_SHIELD = 12, /* Ancestral Glyph */ + MON_JAL_MEJJAK = 13, /* Zuk healer */ + MON_ZULRAH_GREEN = 14, /* Zulrah green/ranged form */ + MON_ZULRAH_RED = 15, /* Zulrah red/melee form */ + MON_ZULRAH_BLUE = 16, /* Zulrah blue/magic form */ + MON_ZULRAH_SNAKELING_MELEE = 17, /* Snakeling melee variant */ + MON_ZULRAH_SNAKELING_MAGIC = 18, /* Snakeling magic variant */ + NUM_MONSTERS = 19 +} MonsterIndex; + +typedef struct { + uint16_t npc_id; + char name[32]; + int16_t hp; + int16_t att_level; + int16_t str_level; + int16_t def_level; + int16_t magic_level; + int16_t range_level; + uint8_t attack_speed; + uint8_t size; + int16_t max_hit; + /* offensive bonuses */ + int16_t melee_att_bonus; + int16_t melee_str_bonus; + int16_t magic_att_bonus; + int16_t magic_str_bonus; + int16_t range_att_bonus; + int16_t ranged_str_bonus; + /* defensive bonuses */ + int16_t stab_def; + int16_t slash_def; + int16_t crush_def; + int16_t magic_def; + int16_t ranged_def; +} MonsterStats; + +static const MonsterStats MONSTER_DATABASE[NUM_MONSTERS] = { + [MON_JAL_NIB] = { /* Nibbler */ + .npc_id = 7691, .name = "Jal-Nib", + .hp = 10, .att_level = 1, .str_level = 1, .def_level = 15, + .magic_level = 15, .range_level = 1, + .attack_speed = 4, .size = 1, .max_hit = 4, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 0, .magic_str_bonus = 0, + .range_att_bonus = 0, .ranged_str_bonus = 0, + .stab_def = -20, .slash_def = -20, .crush_def = -20, + .magic_def = -20, .ranged_def = -20 + }, + [MON_JAL_MEJRAH] = { /* Bat */ + .npc_id = 7692, .name = "Jal-MejRah", + .hp = 25, .att_level = 0, .str_level = 0, .def_level = 55, + .magic_level = 120, .range_level = 120, + .attack_speed = 3, .size = 2, .max_hit = 19, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 0, .magic_str_bonus = 0, + .range_att_bonus = 30, .ranged_str_bonus = 30, + .stab_def = 30, .slash_def = 30, .crush_def = 30, + .magic_def = -20, .ranged_def = 45 + }, + [MON_JAL_AK] = { /* Blob */ + .npc_id = 7693, .name = "Jal-Ak", + .hp = 40, .att_level = 160, .str_level = 160, .def_level = 95, + .magic_level = 160, .range_level = 160, + .attack_speed = 6, .size = 3, .max_hit = 29, + .melee_att_bonus = 0, .melee_str_bonus = 45, .magic_att_bonus = 45, .magic_str_bonus = 45, + .range_att_bonus = 45, .ranged_str_bonus = 45, + .stab_def = 25, .slash_def = 25, .crush_def = 25, + .magic_def = 25, .ranged_def = 25 + }, + [MON_JAL_AKREK_MEJ] = { /* Blob mage split */ + .npc_id = 7694, .name = "Jal-AkRek-Mej", + .hp = 15, .att_level = 1, .str_level = 1, .def_level = 95, + .magic_level = 120, .range_level = 1, + .attack_speed = 4, .size = 1, .max_hit = 18, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 25, .magic_str_bonus = 25, + .range_att_bonus = 0, .ranged_str_bonus = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = 25, .ranged_def = 0 + }, + [MON_JAL_AKREK_XIL] = { /* Blob range split */ + .npc_id = 7695, .name = "Jal-AkRek-Xil", + .hp = 15, .att_level = 1, .str_level = 1, .def_level = 95, + .magic_level = 1, .range_level = 120, + .attack_speed = 4, .size = 1, .max_hit = 18, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 0, .magic_str_bonus = 0, + .range_att_bonus = 25, .ranged_str_bonus = 25, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = 0, .ranged_def = 25 + }, + [MON_JAL_AKREK_KET] = { /* Blob melee split */ + .npc_id = 7696, .name = "Jal-AkRek-Ket", + .hp = 15, .att_level = 120, .str_level = 120, .def_level = 95, + .magic_level = 1, .range_level = 1, + .attack_speed = 4, .size = 1, .max_hit = 18, + .melee_att_bonus = 0, .melee_str_bonus = 25, .magic_att_bonus = 0, .magic_str_bonus = 0, + .range_att_bonus = 0, .ranged_str_bonus = 0, + .stab_def = 25, .slash_def = 25, .crush_def = 25, + .magic_def = 0, .ranged_def = 0 + }, + [MON_JAL_IMKOT] = { /* Meleer */ + .npc_id = 7697, .name = "Jal-ImKot", + .hp = 75, .att_level = 210, .str_level = 290, .def_level = 120, + .magic_level = 120, .range_level = 220, + .attack_speed = 4, .size = 4, .max_hit = 49, + .melee_att_bonus = 0, .melee_str_bonus = 40, .magic_att_bonus = 0, .magic_str_bonus = 0, + .range_att_bonus = 0, .ranged_str_bonus = 0, + .stab_def = 65, .slash_def = 65, .crush_def = 65, + .magic_def = 30, .ranged_def = 50 + }, + [MON_JAL_XIL] = { /* Ranger */ + .npc_id = 7698, .name = "Jal-Xil", + .hp = 125, .att_level = 140, .str_level = 180, .def_level = 60, + .magic_level = 90, .range_level = 250, + .attack_speed = 4, .size = 3, .max_hit = 46, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 0, .magic_str_bonus = 0, + .range_att_bonus = 40, .ranged_str_bonus = 50, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = 0, .ranged_def = 0 + }, + [MON_JAL_ZEK] = { /* Mager */ + .npc_id = 7699, .name = "Jal-Zek", + .hp = 220, .att_level = 370, .str_level = 510, .def_level = 260, + .magic_level = 300, .range_level = 510, + .attack_speed = 4, .size = 4, .max_hit = 70, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 80, .magic_str_bonus = 0, + .range_att_bonus = 0, .ranged_str_bonus = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = 0, .ranged_def = 0 + }, + [MON_JALTOK_JAD] = { /* Jad */ + .npc_id = 7700, .name = "JalTok-Jad", + .hp = 350, .att_level = 750, .str_level = 1020, .def_level = 480, + .magic_level = 510, .range_level = 1020, + .attack_speed = 8, .size = 5, .max_hit = 113, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 100, .magic_str_bonus = 75, + .range_att_bonus = 80, .ranged_str_bonus = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = 0, .ranged_def = 0 + }, + [MON_YT_HURKOT] = { /* Jad healer */ + .npc_id = 7701, .name = "Yt-HurKot", + .hp = 90, .att_level = 165, .str_level = 125, .def_level = 100, + .magic_level = 150, .range_level = 150, + .attack_speed = 4, .size = 1, .max_hit = 18, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 100, .magic_str_bonus = 0, + .range_att_bonus = 80, .ranged_str_bonus = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = 130, .ranged_def = 130 + }, + [MON_TZKAL_ZUK] = { /* Zuk */ + .npc_id = 7706, .name = "TzKal-Zuk", + .hp = 1200, .att_level = 350, .str_level = 600, .def_level = 260, + .magic_level = 150, .range_level = 400, + .attack_speed = 10, .size = 7, .max_hit = 148, + .melee_att_bonus = 0, .melee_str_bonus = 200, .magic_att_bonus = 550, .magic_str_bonus = 450, + .range_att_bonus = 550, .ranged_str_bonus = 200, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = 350, .ranged_def = 100 + }, + [MON_ZUK_SHIELD] = { /* Ancestral Glyph (manual) */ + .npc_id = 7707, .name = "Ancestral Glyph", + .hp = 600, .att_level = 0, .str_level = 0, .def_level = 0, + .magic_level = 0, .range_level = 0, + .attack_speed = 0, .size = 5, .max_hit = 0, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 0, .magic_str_bonus = 0, .range_att_bonus = 0, .ranged_str_bonus = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, .magic_def = 0, .ranged_def = 0 + }, + [MON_JAL_MEJJAK] = { /* Zuk healer */ + .npc_id = 7708, .name = "Jal-MejJak", + .hp = 75, .att_level = 1, .str_level = 1, .def_level = 100, + .magic_level = 1, .range_level = 1, + .attack_speed = 3, .size = 1, .max_hit = 10, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 0, .magic_str_bonus = 0, + .range_att_bonus = 0, .ranged_str_bonus = 0, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = 0, .ranged_def = 0 + }, + [MON_ZULRAH_GREEN] = { /* Zulrah green/ranged form */ + .npc_id = 2042, .name = "Zulrah", + .hp = 500, .att_level = 1, .str_level = 1, .def_level = 300, + .magic_level = 300, .range_level = 300, + .attack_speed = 3, .size = 5, .max_hit = 41, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 50, .magic_str_bonus = 20, + .range_att_bonus = 50, .ranged_str_bonus = 20, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = -45, .ranged_def = 50 + }, + [MON_ZULRAH_RED] = { /* Zulrah red/melee form */ + .npc_id = 2043, .name = "Zulrah", + .hp = 500, .att_level = 1, .str_level = 1, .def_level = 300, + .magic_level = 300, .range_level = 300, + .attack_speed = 3, .size = 5, .max_hit = 30, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 50, .magic_str_bonus = 20, + .range_att_bonus = 50, .ranged_str_bonus = 20, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = 0, .ranged_def = 300 + }, + [MON_ZULRAH_BLUE] = { /* Zulrah blue/magic form */ + .npc_id = 2044, .name = "Zulrah", + .hp = 500, .att_level = 1, .str_level = 1, .def_level = 300, + .magic_level = 300, .range_level = 300, + .attack_speed = 3, .size = 5, .max_hit = 41, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 50, .magic_str_bonus = 20, + .range_att_bonus = 50, .ranged_str_bonus = 20, + .stab_def = 0, .slash_def = 0, .crush_def = 0, + .magic_def = 300, .ranged_def = 0 + }, + [MON_ZULRAH_SNAKELING_MELEE] = { /* Snakeling melee variant */ + .npc_id = 2045, .name = "Snakeling", + .hp = 1, .att_level = 140, .str_level = 138, .def_level = 1, + .magic_level = 1, .range_level = 1, + .attack_speed = 3, .size = 1, .max_hit = 15, + .melee_att_bonus = 120, .melee_str_bonus = 0, .magic_att_bonus = 0, .magic_str_bonus = 0, + .range_att_bonus = 0, .ranged_str_bonus = 0, + .stab_def = -40, .slash_def = -40, .crush_def = -40, + .magic_def = -40, .ranged_def = -40 + }, + [MON_ZULRAH_SNAKELING_MAGIC] = { /* Snakeling magic variant */ + .npc_id = 2046, .name = "Snakeling", + .hp = 1, .att_level = 1, .str_level = 1, .def_level = 1, + .magic_level = 185, .range_level = 1, + .attack_speed = 3, .size = 1, .max_hit = 13, + .melee_att_bonus = 0, .melee_str_bonus = 0, .magic_att_bonus = 80, .magic_str_bonus = -20, + .range_att_bonus = 0, .ranged_str_bonus = 0, + .stab_def = -40, .slash_def = -40, .crush_def = -40, + .magic_def = -40, .ranged_def = -40 + }, +}; + +#endif /* OSRS_MONSTERS_GENERATED_H */ diff --git a/ocean/osrs/osrs_objects.h b/ocean/osrs/osrs_objects.h new file mode 100644 index 0000000000..de37463567 --- /dev/null +++ b/ocean/osrs/osrs_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_OBJECTS_H +#define OSRS_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_OBJECTS_H */ diff --git a/ocean/osrs/osrs_pathfinding.h b/ocean/osrs/osrs_pathfinding.h new file mode 100644 index 0000000000..d6d19f0091 --- /dev/null +++ b/ocean/osrs/osrs_pathfinding.h @@ -0,0 +1,530 @@ +/** + * @file osrs_pathfinding.h + * @brief BFS pathfinder for OSRS tile-based movement + * + * BFS pathfinder (OSRS uses BFS despite some implementations naming it "Dijkstra"). + * Default grid is 104x104 (OSRS client scene size). Encounters with smaller arenas + * can use pathfind_step_arena() with custom dimensions to reduce stack/memset cost. + * + * Returns the next step direction for the agent to take. Full path reconstruction + * is not needed since agents re-plan every tick. + */ + +#ifndef OSRS_PATHFINDING_H +#define OSRS_PATHFINDING_H + +#include "osrs_collision.h" + +#define PATHFIND_GRID_SIZE 104 +#define PATHFIND_ARENA_MAX 48 /* max arena dimension for pathfind_step_arena */ +#define PATHFIND_MAX_QUEUE_FULL 9000 +#define PATHFIND_MAX_QUEUE_ARENA 2500 /* 48*48 + margin */ +#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_FULL]; + int queue_y[PATHFIND_MAX_QUEUE_FULL]; + 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_FULL) { + 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; +} + +/** + * Arena-scoped BFS: same algorithm as pathfind_step but with a smaller grid. + * Caller provides the arena origin and dimensions. All coordinates are in the + * same world-space as the full pathfinder — the function subtracts the origin + * internally. + * + * Stack cost: ~2 * W * H * 4 + 2 * QUEUE * 4 bytes. + * For a 32x32 arena: ~8KB + ~20KB queue = ~28KB (vs ~155KB for 104x104). + */ +static inline PathResult pathfind_step_arena( + 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, + int arena_origin_x, int arena_origin_y, int arena_w, int arena_h +) { + PathResult result = {0, 0, 0, dest_x, dest_y}; + + if (src_x == dest_x && src_y == dest_y) { + result.found = 1; + return result; + } + + /* convert to arena-local coordinates */ + int local_src_x = src_x - arena_origin_x; + int local_src_y = src_y - arena_origin_y; + int local_dest_x = dest_x - arena_origin_x; + int local_dest_y = dest_y - arena_origin_y; + + /* bounds check */ + if (local_src_x < 0 || local_src_x >= arena_w || + local_src_y < 0 || local_src_y >= arena_h || + local_dest_x < 0 || local_dest_x >= arena_w || + local_dest_y < 0 || local_dest_y >= arena_h) { + return result; + } + + /* BFS working arrays with generation counter — no memset needed. + a cell is "visited" when gen[x][y] == current_gen. via/cost are only + valid when gen matches. this eliminates the ~18KB memset that was the + dominant cost (651K calls × 18KB = ~11GB of zeroing per training run). */ + static _Thread_local uint16_t bfs_gen[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; + static _Thread_local int8_t bfs_via[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; + static _Thread_local int16_t bfs_cost[PATHFIND_ARENA_MAX][PATHFIND_ARENA_MAX]; + static _Thread_local uint16_t bfs_gen_counter = 0; + bfs_gen_counter++; + if (bfs_gen_counter == 0) { + /* wraparound: rare (every 65536 calls), just clear the gen array */ + memset(bfs_gen, 0, sizeof(bfs_gen)); + bfs_gen_counter = 1; + } + uint16_t gen = bfs_gen_counter; + #define BFS_VISITED(x, y) (bfs_gen[(x)][(y)] == gen) + #define BFS_VISIT(x, y, v, c) do { \ + bfs_gen[(x)][(y)] = gen; bfs_via[(x)][(y)] = (v); bfs_cost[(x)][(y)] = (c); \ + } while(0) + #define BFS_VIA(x, y) bfs_via[(x)][(y)] + #define BFS_COST(x, y) bfs_cost[(x)][(y)] + + int queue_x[PATHFIND_MAX_QUEUE_ARENA]; + int queue_y[PATHFIND_MAX_QUEUE_ARENA]; + int head = 0, tail = 0; + + BFS_VISIT(local_src_x, local_src_y, VIA_START, 1); + queue_x[tail] = local_src_x; + queue_y[tail] = local_src_y; + tail++; + + int found_path = 0; + int cur_x, cur_y; + + while (head < tail && tail < PATHFIND_MAX_QUEUE_ARENA) { + cur_x = queue_x[head]; + cur_y = queue_y[head]; + head++; + + if (cur_x == local_dest_x && cur_y == local_dest_y) { + found_path = 1; + break; + } + + int abs_x = arena_origin_x + cur_x; + int abs_y = arena_origin_y + cur_y; + int next_cost = BFS_COST(cur_x, cur_y) + 1; + + #define EB(ax, ay) (extra_blocked && extra_blocked(blocked_ctx, (ax), (ay))) + + /* south */ + if (cur_y > 0 && !BFS_VISITED(cur_x, cur_y - 1) + && 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++; + BFS_VISIT(cur_x, cur_y - 1, VIA_S, next_cost); + } + /* west */ + if (cur_x > 0 && !BFS_VISITED(cur_x - 1, cur_y) + && 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++; + BFS_VISIT(cur_x - 1, cur_y, VIA_W, next_cost); + } + /* north */ + if (cur_y < arena_h - 1 && !BFS_VISITED(cur_x, cur_y + 1) + && 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++; + BFS_VISIT(cur_x, cur_y + 1, VIA_N, next_cost); + } + /* east */ + if (cur_x < arena_w - 1 && !BFS_VISITED(cur_x + 1, cur_y) + && 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++; + BFS_VISIT(cur_x + 1, cur_y, VIA_E, next_cost); + } + /* south-west */ + if (cur_x > 0 && cur_y > 0 && !BFS_VISITED(cur_x - 1, cur_y - 1) + && 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++; + BFS_VISIT(cur_x - 1, cur_y - 1, VIA_SW, next_cost); + } + /* north-west */ + if (cur_x > 0 && cur_y < arena_h - 1 && !BFS_VISITED(cur_x - 1, cur_y + 1) + && 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++; + BFS_VISIT(cur_x - 1, cur_y + 1, VIA_NW, next_cost); + } + /* south-east */ + if (cur_x < arena_w - 1 && cur_y > 0 && !BFS_VISITED(cur_x + 1, cur_y - 1) + && 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++; + BFS_VISIT(cur_x + 1, cur_y - 1, VIA_SE, next_cost); + } + /* north-east */ + if (cur_x < arena_w - 1 && cur_y < arena_h - 1 && !BFS_VISITED(cur_x + 1, cur_y + 1) + && 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++; + BFS_VISIT(cur_x + 1, cur_y + 1, VIA_NE, next_cost); + } + + #undef EB + } + + /* fallback: 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 >= arena_w || fy < 0 || fy >= arena_h) continue; + if (!BFS_VISITED(fx, fy) || BFS_COST(fx, fy) == 0) continue; + int ddx = fx - local_dest_x, ddy = fy - local_dest_y; + int dist_sq = ddx * ddx + ddy * ddy; + if (dist_sq < best_dist_sq || + (dist_sq == best_dist_sq && BFS_COST(fx, fy) < best_cost)) { + best_dist_sq = dist_sq; + best_cost = BFS_COST(fx, fy); + best_x = fx; best_y = fy; + } + } + } + + if (best_x == -1) return result; + cur_x = best_x; cur_y = best_y; + found_path = 1; + result.dest_x = arena_origin_x + best_x; + result.dest_y = arena_origin_y + best_y; + } + + /* backtrack to find first step */ + while (1) { + int v = BFS_VIA(cur_x, cur_y); + int prev_x = cur_x, prev_y = cur_y; + if (v & VIA_W) prev_x++; else if (v & VIA_E) prev_x--; + if (v & VIA_S) prev_y++; else if (v & VIA_N) prev_y--; + if (prev_x == local_src_x && prev_y == local_src_y) { + 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; + if (BFS_VIA(cur_x, cur_y) == VIA_NONE || BFS_VIA(cur_x, cur_y) == VIA_START) break; + } + + return result; +} + +#endif /* OSRS_PATHFINDING_H */ diff --git a/ocean/osrs/osrs_pvp_actions.h b/ocean/osrs/osrs_pvp_actions.h new file mode 100644 index 0000000000..3eacde8e3d --- /dev/null +++ b/ocean/osrs/osrs_pvp_actions.h @@ -0,0 +1,941 @@ +/** + * @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_consumables.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. +#include "osrs_encounter.h" // For ENCOUNTER_OVERHEAD_*, encounter_apply_*_action, encounter_drain_all_prayers +// prayer drain: encounter_drain_all_prayers() in osrs_encounter.h drives both +// overhead and offensive drain in a single call with activation-tick skip. + +// 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 + +/** + * 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 = osrs_food_heal_amount(FOOD_KARAMBWAN); + 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--; + int heal_amount = osrs_food_heal_amount(FOOD_SHARK); + 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--; + // pass current levels for drain params — brew drains 10% of CURRENT not base + BrewResult br = osrs_brew_effect(p->base_hitpoints, p->current_attack, + p->current_strength, p->current_ranged, + p->current_magic); + int hp_before = p->current_hitpoints; + int max_hp = p->base_hitpoints + br.hp_healed; + int actual_heal = max_int(0, min_int(br.hp_healed, max_hp - hp_before)); + int waste = br.hp_healed - actual_heal; + int def_before = p->current_defence; + int max_def = p->is_lms ? p->base_defence : p->base_defence + br.def_boost; + p->current_defence = clamp(def_before + br.def_boost, 0, max_def); + p->current_hitpoints = clamp(hp_before + br.hp_healed, 0, max_hp); + p->last_brew_heal = actual_heal; + p->last_brew_waste = waste; + p->current_attack = clamp(p->current_attack - br.att_drain, 0, 255); + p->current_strength = clamp(p->current_strength - br.str_drain, 0, 255); + p->current_magic = clamp(p->current_magic - br.magic_drain, 0, 255); + p->current_ranged = clamp(p->current_ranged - br.range_drain, 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 + ); + // super restore: 8 + floor(level/4) for prayer and all stats + DrinkResult dr = osrs_drink_potion(POTION_SUPER_RESTORE, 0, p->base_prayer, 0); + p->current_prayer = clamp(p->current_prayer + dr.prayer_restored, 0, p->base_prayer); + int atk_restore = osrs_drink_potion(POTION_SUPER_RESTORE, 0, p->base_attack, 0).prayer_restored; + int str_restore = osrs_drink_potion(POTION_SUPER_RESTORE, 0, p->base_strength, 0).prayer_restored; + int def_restore = osrs_drink_potion(POTION_SUPER_RESTORE, 0, p->base_defence, 0).prayer_restored; + int rng_restore = osrs_drink_potion(POTION_SUPER_RESTORE, 0, p->base_ranged, 0).prayer_restored; + int mag_restore = osrs_drink_potion(POTION_SUPER_RESTORE, 0, p->base_magic, 0).prayer_restored; + 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 = osrs_drink_potion(POTION_SUPER_COMBAT, 0, p->base_attack, 0).level_boost; + int str_boost = osrs_drink_potion(POTION_SUPER_COMBAT, 0, p->base_strength, 0).level_boost; + int def_boost = osrs_drink_potion(POTION_SUPER_COMBAT, 0, p->base_defence, 0).level_boost; + 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 = osrs_drink_potion(POTION_RANGING, 0, p->base_ranged, 0).level_boost; + 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; +} + +/** 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 — shared encounter_drain_all_prayers handles both overhead + and offensive, activation-tick skip, and pp=0 auto-clear of both slots. + LMS has no prayer drain (prayer points are unlimited) — still clear + just-activated flags so they don't leak to next tick. */ + if (!p->is_lms) { + encounter_drain_all_prayers(p, PRAYER_BONUS); + } else { + p->prayer_just_activated = 0; + p->offensive_prayer_just_activated = 0; + } + + 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; + } + + if (p->spec_regen_active && p->special_energy < 100) { + encounter_tick_spec_regen(p); + } else if (p->spec_regen_active) { + p->item_effect_state.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; +} + +// Forward declarations for phased execution +static void execute_switches(OsrsEnv* env, int agent_idx, int* actions); +static void execute_attacks(OsrsEnv* 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(OsrsEnv* 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; + + int overhead_action = actions[HEAD_OVERHEAD]; + int offensive_action = actions[HEAD_OFFENSIVE]; + + /* LMS restricts smite/redemption — clear those toggle actions before apply. */ + if (env->is_lms && + (overhead_action == ENCOUNTER_OVERHEAD_TOGGLE_SMITE || + overhead_action == ENCOUNTER_OVERHEAD_TOGGLE_REDEMPTION)) { + overhead_action = ENCOUNTER_OVERHEAD_NO_CHANGE; + } + /* agent cannot activate a prayer with 0 pp. toggle-off (when target already on) + is always allowed — but drain will clear it anyway. here we just block pure + activations. this is intentionally conservative: if current prayer matches target, + the toggle turns it off (allowed); otherwise we check pp. */ + if (p->current_prayer <= 0) { + /* disallow overhead activation when OOC */ + if (overhead_action > 0) { + /* if toggle matches current (would deactivate) still allow */ + int would_deactivate = + (overhead_action == ENCOUNTER_OVERHEAD_TOGGLE_MELEE && p->prayer == PRAYER_PROTECT_MELEE) || + (overhead_action == ENCOUNTER_OVERHEAD_TOGGLE_RANGED && p->prayer == PRAYER_PROTECT_RANGED) || + (overhead_action == ENCOUNTER_OVERHEAD_TOGGLE_MAGIC && p->prayer == PRAYER_PROTECT_MAGIC) || + (overhead_action == ENCOUNTER_OVERHEAD_TOGGLE_SMITE && p->prayer == PRAYER_SMITE) || + (overhead_action == ENCOUNTER_OVERHEAD_TOGGLE_REDEMPTION && p->prayer == PRAYER_REDEMPTION); + if (!would_deactivate) overhead_action = ENCOUNTER_OVERHEAD_NO_CHANGE; + } + if (offensive_action > 0) { + int would_deactivate = + (offensive_action == ENCOUNTER_OFFENSIVE_TOGGLE_PIETY && p->offensive_prayer == OFFENSIVE_PRAYER_PIETY) || + (offensive_action == ENCOUNTER_OFFENSIVE_TOGGLE_RIGOUR && p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR) || + (offensive_action == ENCOUNTER_OFFENSIVE_TOGGLE_AUGURY && p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY); + if (!would_deactivate) offensive_action = ENCOUNTER_OFFENSIVE_NO_CHANGE; + } + } + + OverheadPrayer prev_prayer = p->prayer; + OffensivePrayer prev_offensive = p->offensive_prayer; + if (encounter_apply_overhead_action(&p->prayer, overhead_action)) { + p->prayer_just_activated = 1; + } + if (encounter_apply_offensive_action(&p->offensive_prayer, offensive_action)) { + p->offensive_prayer_just_activated = 1; + } + if (p->prayer != prev_prayer || p->offensive_prayer != prev_offensive) p->clicks_this_tick++; + int loadout_action = actions[HEAD_LOADOUT]; + int loadout_switches = apply_loadout(p, loadout_action); + p->clicks_this_tick += loadout_switches; + if (loadout_switches > 0) + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_EQUIP); + + /* spec toggle: LOADOUT_SPEC_* arms spec for next attack */ + if (loadout_action == LOADOUT_SPEC_MELEE || loadout_action == LOADOUT_SPEC_RANGE || + loadout_action == LOADOUT_SPEC_MAGIC || loadout_action == LOADOUT_GMAUL) { + p->spec_armed = 1; + } + 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++; + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_EAT); + } + + 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++; + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_DRINK); + } + 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++; + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_DRINK); + } + 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++; + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_DRINK); + } + 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++; + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_DRINK); + } + 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++; + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_EAT); + } + 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; + } + if (move_action != MOVE_NONE) + osrs_interaction_check_interrupt(&p->interaction, OSRS_IACT_MOVE); + 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(OsrsEnv* 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; + + /* GMAUL is instant: forces attack (spec armed by execute_switches) */ + int is_gmaul = (loadout_action == LOADOUT_GMAUL); + if (is_gmaul) { + 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_gmaul) { + attack_action = ATTACK_NONE; + } + + /* set interaction target when explicit attack action is issued */ + if (attack_action != ATTACK_NONE) + osrs_interaction_set(&p->interaction, 1 - agent_idx); + + /* has_attack: explicit attack OR persistent interaction (auto-walk) */ + int has_attack = (attack_action != ATTACK_NONE) || osrs_interaction_active(&p->interaction); + int dist = chebyshev_distance(p->x, p->y, t->x, t->y); + + /* resolve attack style for movement range checks */ + AttackStyle attack_style = ATTACK_STYLE_NONE; + if (attack_action != ATTACK_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; + } + } else if (osrs_interaction_active(&p->interaction)) { + /* auto-attack: use current weapon style for movement */ + attack_style = get_slot_weapon_attack_style(p); + } + 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/interaction active */ + 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: 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(OsrsEnv* 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; + + /* GMAUL is instant: forces attack (spec armed by execute_switches) */ + int is_gmaul = (loadout_action == LOADOUT_GMAUL); + if (is_gmaul) { + 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_gmaul) { + attack_action = ATTACK_NONE; + } + + /* auto-attack: if interaction active and no explicit attack, use weapon style. + mage/tank auto-attack is filtered above (no autocast modeled). */ + if (attack_action == ATTACK_NONE && osrs_interaction_active(&p->interaction)) { + AttackStyle weapon_style = get_slot_weapon_attack_style(p); + if (weapon_style != ATTACK_STYLE_MAGIC) { + attack_action = ATTACK_ATK; + } + } + + 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 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) { + /* spec check: use spec_armed toggle instead of loadout-based */ + int is_special = p->spec_armed && is_special_ready(p, actual_style); + perform_attack(env, agent_idx, 1 - agent_idx, actual_style, is_special, 0, dist); + if (is_special) + osrs_spec_disarm(&p->spec_armed); + 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/interaction active but out of range */ + 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(OsrsEnv* 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. + */ +__attribute__((unused)) +static void execute_actions(OsrsEnv* env, int agent_idx, int* actions) { + execute_switches(env, agent_idx, actions); + execute_attacks(env, agent_idx, actions); +} + +/** + * 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(OsrsEnv* 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, 0 loss (forfeiting future rewards is the penalty) + if (env->episode_over) { + if (env->winner == 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; + } + + // Always-on positive signals (dense reward for bootstrapping learning) + float base_hp = (float)p->base_hitpoints; + if (p->damage_dealt_scale > 0.0f) { + reward += p->damage_dealt_scale * base_hp * 0.005f; + } + if (t->just_attacked && p->player_prayed_correct) { + reward += 0.01f; + } + + 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; + + // 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; + } + } + + // 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_api.h b/ocean/osrs/osrs_pvp_api.h new file mode 100644 index 0000000000..e65d84c61d --- /dev/null +++ b/ocean/osrs/osrs_pvp_api.h @@ -0,0 +1,713 @@ +/** + * @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" + +/** + * 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->spec_regen_active = 0; + p->spec_armed = 0; + osrs_interaction_init(&p->interaction); + osrs_item_effect_state_init(&p->item_effect_state); + + 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->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->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 +} + +/** + * 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(OsrsEnv* 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; +} + +/** + * 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(OsrsEnv* 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->pvp_runtime.is_pvp_arena = 0; + env->auto_reset = 1; + env->pvp_runtime.use_c_opponent = 0; + env->pvp_runtime.use_c_opponent_p0 = 0; + env->pvp_runtime.use_external_opponent_actions = 0; + env->ocean_io.agent_obs_p1 = NULL; + env->ocean_io.selfplay_mask = NULL; + memset(env->pvp_runtime.external_opponent_actions, 0, sizeof(env->pvp_runtime.external_opponent_actions)); + memset(&env->pvp_runtime.opponent, 0, sizeof(env->pvp_runtime.opponent)); + memset(&env->pvp_runtime.opponent_p0, 0, sizeof(env->pvp_runtime.opponent_p0)); + memset(&env->pvp_runtime.pfsp, 0, sizeof(env->pvp_runtime.pfsp)); + memset(env->pvp_runtime.gear_tier_weights, 0, sizeof(env->pvp_runtime.gear_tier_weights)); + memset(&env->shaping, 0, sizeof(env->shaping)); + memset(&env->log, 0, sizeof(env->log)); +} + +/* pvp_render: forward declaration only. + binding.c provides the stub, or osrs_render.h provides the real impl. */ +void pvp_render(OsrsEnv* env); + +/** + * 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(OsrsEnv* 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 + + env->pvp_runtime.is_pvp_arena = 0; + + env->_episode_return = 0.0f; + + memset(env->rewards, 0, NUM_AGENTS * sizeof(float)); + memset(env->terminals, 0, NUM_AGENTS); + + memset(env->pending_actions, 0, sizeof(env->pending_actions)); + memset(env->last_executed_actions, 0, sizeof(env->last_executed_actions)); + + // 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->pvp_runtime.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]); + osrs_refresh_player_equipment(&env->players[i]); + } + + // Reset C-side opponent state for new episode + // Always reset when PFSP is configured (selfplay toggle happens inside opponent_reset) + if (env->pvp_runtime.use_c_opponent || env->pvp_runtime.opponent.type == OPP_PFSP) { + opponent_reset(env, &env->pvp_runtime.opponent); + } + if (env->pvp_runtime.use_c_opponent_p0) { + opponent_reset(env, &env->pvp_runtime.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(OsrsEnv* 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]); + + // Copy model's actions (player 0) or clear if C opponent controls p0 + if (env->pvp_runtime.use_c_opponent_p0) { + memset(env->actions, 0, NUM_ACTION_HEADS * sizeof(int)); + } else { + memcpy(env->actions, env->ocean_io.agent_actions, NUM_ACTION_HEADS * sizeof(int)); + } + + // Copy external opponent actions (player 1) or clear for C opponent + if (env->pvp_runtime.use_external_opponent_actions) { + memcpy( + env->actions + NUM_ACTION_HEADS, + env->pvp_runtime.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->pvp_runtime.use_c_opponent && !env->pvp_runtime.use_external_opponent_actions) { + generate_opponent_action(env, &env->pvp_runtime.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->pvp_runtime.use_c_opponent_p0) { + generate_opponent_action_for_player0(env, &env->pvp_runtime.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; + + // 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]); + + 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; + } + + int* agent_actions[NUM_AGENTS]; + agent_actions[0] = actions_p0; + agent_actions[1] = actions_p1; + + 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; + } + + execute_switches(env, first, agent_actions[first]); + execute_switches(env, second, agent_actions[second]); + + 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--; + } + + 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); + } + + execute_attack_movement(env, first, agent_actions[first]); + execute_attack_movement(env, second, agent_actions[second]); + + 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); + } + + execute_attack_combat(env, first, agent_actions[first]); + execute_attack_combat(env, second, agent_actions[second]); + + 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); + } + + 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; + } + 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 + } + } + + memcpy(env->pending_actions, env->actions, + NUM_AGENTS * NUM_ACTION_HEADS * sizeof(int)); + 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; + } + 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]; + 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); + } + } + + // Write observations to PufferLib shared buffer + ocean_write_obs(env); + if (env->ocean_io.agent_obs_p1 != NULL) { + ocean_write_obs_p1(env); + } + env->ocean_io.agent_rewards[0] = env->rewards[0]; + + if (env->episode_over) { + env->ocean_io.agent_terminals[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->pvp_runtime.opponent.type == OPP_PFSP && env->pvp_runtime.pfsp.active_pool_idx >= 0) { + int idx = env->pvp_runtime.pfsp.active_pool_idx; + env->pvp_runtime.pfsp.episodes[idx] += 1.0f; + if (env->winner == 0) { + env->pvp_runtime.pfsp.wins[idx] += 1.0f; + } + } + + // Write final episode stats to log + Player* p0 = &env->players[0]; + env->log.episode_return = env->_episode_return; + env->log.episode_length = (float)env->tick; + env->log.damage_dealt = p0->total_damage_dealt; + env->log.damage_received = p0->total_damage_received; + env->log.wins = (env->winner == 0) ? 1.0f : 0.0f; + env->log.prayer_correct = (float)p0->target_pray_correct_count; + env->log.prayer_total = (float)(p0->target_pray_melee_count + + p0->target_pray_ranged_count + p0->target_pray_magic_count); + env->log.idle_ticks = (float)(p0->food_count + p0->karambwan_count); /* food remaining */ + env->log.brews_used = (float)p0->brew_doses; /* brews remaining */ + env->log.wave = (float)p0->special_energy; /* spec energy remaining */ + env->log.npc_kills = (float)p0->total_target_hit_count; /* attacks landed */ + env->log.blood_healed = (float)p0->target_hit_off_prayer_count; /* off-prayer hits */ + env->log.n = 1.0f; + + // Auto-reset for next episode + if (env->auto_reset) { + pvp_reset(env); + } + } else { + env->ocean_io.agent_terminals[0] = 0; + } +} + +/** + * Set RNG seed for deterministic runs. + * + * @param env Environment + * @param seed Seed value (must be non-zero) + */ +void pvp_seed(OsrsEnv* 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(OsrsEnv* 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..063f8e77ed --- /dev/null +++ b/ocean/osrs/osrs_pvp_combat.h @@ -0,0 +1,1133 @@ +/** + * @file osrs_pvp_combat.h + * @brief PvP combat orchestration: spec dispatch, damage application, attack availability. + * + * delegates core math to shared modules: + * osrs_combat.h — hit chance, effective levels, max hits, prayer reduction + * osrs_special_attacks.h — osrs_resolve_spec(), osrs_spec_cost() + * osrs_damage.h — osrs_apply_damage_pipeline(), pending hit helpers + * osrs_bolt_procs.h — osrs_resolve_bolt_proc() + * + * what stays PvP-specific: + * - enum-based spec cost/multiplier tables (used by osrs_pvp_observations.h) + * - PvP pending hit queue (PendingHit struct with drain/heal/morr fields) + * - combat history tracking for RL observations + * - attack availability / action masking + * - perform_attack orchestration + */ + +#ifndef OSRS_PVP_COMBAT_H +#define OSRS_PVP_COMBAT_H + +#include "osrs_types.h" +#include "osrs_combat.h" +#include "osrs_special_attacks.h" +#include "osrs_damage.h" +#include "osrs_bolt_procs.h" +#include "osrs_pvp_gear.h" + +static void register_hit_calculated(OsrsEnv* env, int attacker_idx, int defender_idx, + AttackStyle style, int total_damage); + +/* maps PvP MeleeSpecWeapon enum → item index for osrs_resolve_spec / osrs_spec_cost. + used internally by perform_attack and availability checks. */ +static inline int pvp_melee_spec_to_item(MeleeSpecWeapon w) { + switch (w) { + case MELEE_SPEC_AGS: return ITEM_AGS; + case MELEE_SPEC_DRAGON_CLAWS: return ITEM_DRAGON_CLAWS; + case MELEE_SPEC_GRANITE_MAUL: return ITEM_GRANITE_MAUL; + case MELEE_SPEC_DRAGON_DAGGER: return ITEM_DRAGON_DAGGER; + case MELEE_SPEC_VOIDWAKER: return ITEM_VOIDWAKER; + case MELEE_SPEC_DWH: return ITEM_STATIUS_WARHAMMER; + case MELEE_SPEC_BGS: return ITEM_BGS; + case MELEE_SPEC_ZGS: return ITEM_ZGS; + case MELEE_SPEC_SGS: return ITEM_SGS; + case MELEE_SPEC_ANCIENT_GS: return ITEM_ANCIENT_GS; + case MELEE_SPEC_VESTAS: return ITEM_VESTAS; + default: return ITEM_NONE; + } +} + +static inline int pvp_ranged_spec_to_item(RangedSpecWeapon w) { + switch (w) { + case RANGED_SPEC_DARK_BOW: return ITEM_DARK_BOW; + case RANGED_SPEC_BALLISTA: return ITEM_HEAVY_BALLISTA; + case RANGED_SPEC_ACB: return ITEM_ARMADYL_CROSSBOW; + case RANGED_SPEC_ZCB: return ITEM_ZARYTE_CROSSBOW; + case RANGED_SPEC_MSB: return ITEM_MAGIC_SHORTBOW_I; + case RANGED_SPEC_MORRIGANS: return ITEM_MORRIGANS_JAVELIN; + default: return ITEM_NONE; + } +} + +static inline int pvp_magic_spec_to_item(MagicSpecWeapon w) { + switch (w) { + case MAGIC_SPEC_VOLATILE_STAFF: return ITEM_VOLATILE_STAFF; + default: return ITEM_NONE; + } +} +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 50; + 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 50; + 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; + } +} +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.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 2.0f; + case MELEE_SPEC_ZGS: return 2.0f; + case MELEE_SPEC_SGS: return 2.0f; + 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; + } +} + +__attribute__((unused)) +static float get_magic_spec_acc_mult(MagicSpecWeapon weapon) { + switch (weapon) { + case MAGIC_SPEC_VOLATILE_STAFF: return 1.5f; + default: return 1.0f; + } +} + +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 ADAPTERS (delegate to osrs_player_eff_level) + +static int calculate_effective_attack(Player* p, AttackStyle style) { + int base_level; + float prayer_mult = 1.0f; + + 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; + } + + int style_bonus = osrs_stance_att_bonus(p->fight_style, style); + + /* magic uses +9 instead of +8 (invisible +1 for magic attack) */ + if (style == ATTACK_STYLE_MAGIC) + return osrs_player_eff_level(base_level, prayer_mult, style_bonus) + 1; + return osrs_player_eff_level(base_level, prayer_mult, style_bonus); +} + +static int calculate_effective_strength(Player* p, AttackStyle style) { + int base_level; + float prayer_mult = 1.0f; + + 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; + } + + /* str bonus only applies to melee (aggressive/controlled); osrs_stance_str_bonus + returns 0 for any non-melee stance. */ + int style_bonus = (style == ATTACK_STYLE_MELEE) ? osrs_stance_str_bonus(p->fight_style) : 0; + + return osrs_player_eff_level(base_level, prayer_mult, style_bonus); +} + +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 = osrs_stance_def_bonus(p->fight_style); + + if (incoming_style == ATTACK_STYLE_MAGIC) { + /* PvP magic defence: floor(magic * prayer * 0.7 + def * prayer * 0.3) + style + 8. + augury boosts magic component via offensive prayer mult. */ + 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); + return (int)(magic_level * 0.7f + def_level * 0.3f) + style_bonus + 8; + } + + return osrs_player_eff_level(base_level, prayer_mult, style_bonus); +} + +static MeleeBonusType get_melee_bonus_type(Player* p) { + if (p->current_gear == GEAR_SPEC) { + return MELEE_SPEC_BONUS_TYPES[p->melee_spec_weapon]; + } + GearBonuses* g = get_slot_gear_bonuses(p); + MeleeBonusType best = MELEE_BONUS_STAB; + int best_val = g->stab_attack; + if (g->slash_attack > best_val) { best = MELEE_BONUS_SLASH; best_val = g->slash_attack; } + if (g->crush_attack > best_val) { best = MELEE_BONUS_CRUSH; } + return best; +} + +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 (delegate to shared formulas) + +static float calculate_hit_chance(OsrsEnv* 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); + + return clampf(osrs_hit_chance(attack_roll, defence_roll), 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; + 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 if (style == ATTACK_STYLE_RANGED) { + max_hit = (int)(osrs_player_ranged_max_hit(eff_strength, strength_bonus) * str_mult); + } else { + max_hit = (int)(osrs_player_melee_max_hit(eff_strength, strength_bonus) * str_mult); + } + + osrs_ensure_player_equipment(p); + if (p->equipment_effect_profile.dharok_piece_count >= 4 && + 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; +} + +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; +} + +/* PvP-specific: dark bow second arrow and weapon-specific ranged delays. + standard delays use encounter_magic_hit_delay / encounter_ranged_hit_delay + from osrs_combat.h. PvP players are always is_player=1 but PvP hit delays + historically did NOT include the +1 player offset. keep these for PvP compat. */ + +static inline int pvp_magic_hit_delay(int distance) { + return 1 + ((1 + distance) / 3); +} + +static inline int pvp_ranged_hit_delay(int distance) { + return 1 + ((3 + distance) / 6); +} + +static inline int pvp_ranged_hit_delay_fast(int distance) { + return 1 + (distance / 6); +} + +static inline int pvp_ranged_hit_delay_ballista(int distance) { + return 2 + ((1 + distance) / 6); +} + +static inline int pvp_ranged_hit_delay_dbow_second(int distance) { + return 1 + ((2 + distance) / 3); +} + +static inline int pvp_ranged_hit_delay_for_weapon(int distance, int is_special, RangedSpecWeapon weapon) { + if (!is_special) return pvp_ranged_hit_delay(distance); + switch (weapon) { + case RANGED_SPEC_DRAGON_KNIFE: + case RANGED_SPEC_MORRIGANS: + return pvp_ranged_hit_delay_fast(distance); + case RANGED_SPEC_BALLISTA: + return pvp_ranged_hit_delay_ballista(distance); + default: + return pvp_ranged_hit_delay(distance); + } +} + +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; + + int actual_damage = osrs_prayer_reduce_damage(damage, defender->prayer, style, 1); + attacker->last_queued_hit_damage += actual_damage; +} +// DAMAGE APPLICATION (uses osrs_apply_damage_pipeline for core pipeline) + +static void apply_damage(OsrsEnv* env, int attacker_idx, int defender_idx, + PendingHit* hit) { + Player* attacker = &env->players[attacker_idx]; + Player* defender = &env->players[defender_idx]; + + osrs_ensure_player_equipment(defender); + + /* shared passive pipeline: prayer → elysian → veng → recoil → smite */ + DamageResult dr = osrs_apply_passive_damage_pipeline( + hit->damage, hit->attack_type, + hit->defender_prayer_at_attack, + /* is_pvp */ 1, + defender->veng_active, + attacker->prayer == PRAYER_SMITE && !defender->is_lms + , + &defender->equipment_effect_profile, + &defender->item_effect_state, + &env->rng_state + ); + + int damage = dr.final_damage; + + /* hit event recording for observations */ + defender->hit_landed_this_tick = 1; + defender->hit_was_successful = hit->hit_success; + defender->hit_damage += damage; + defender->hit_style = hit->attack_type; + defender->hit_defender_prayer = hit->defender_prayer_at_attack; + defender->hit_was_on_prayer = dr.prayer_blocked; + defender->hit_attacker_idx = attacker_idx; + defender->damage_applied_this_tick = damage; + + /* apply vengeance reflection */ + if (dr.veng_damage > 0) { + attacker->current_hitpoints -= dr.veng_damage; + if (attacker->current_hitpoints < 0) attacker->current_hitpoints = 0; + float reflect_scale = (float)dr.veng_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; + } + + /* apply recoil reflection + charge tracking */ + if (dr.recoil_damage > 0) { + int recoil = dr.recoil_damage; + if (defender->equipment_effect_profile.recoil_source == OSRS_RECOIL_SOURCE_RING_OF_RECOIL && + recoil > defender->item_effect_state.recoil_charges) { + recoil = defender->item_effect_state.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; + osrs_consume_recoil_charges(defender, recoil); + } + + /* apply damage to defender */ + 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; + + /* PvP-specific hit effects: drain, freeze, heal, morr bleed */ + if (hit->hit_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 && 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); + } + } + + if (hit->is_morr_bleed && hit->hit_success && damage > 0) { + defender->morr_dot_remaining = damage; + } + + /* apply smite prayer drain */ + if (dr.smite_drain > 0) { + defender->current_prayer = clamp(defender->current_prayer - dr.smite_drain, 0, defender->base_prayer); + } +} + +static void process_pending_hits(OsrsEnv* 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--; + } + } +} + +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( + OsrsEnv* 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 = encounter_prayer_correct_for_style(defender->prayer, 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); + } + + 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 (encounter_prayer_correct_for_style(defender->prayer, 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); + } +} + +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; +} + +static inline int can_cast_ice_spell(Player* p) { + if (p->is_lunar_spellbook) return 0; + return p->current_magic >= ICE_RUSH_LEVEL; +} + +static inline int can_cast_blood_spell(Player* p) { + if (p->is_lunar_spellbook) return 0; + return p->current_magic >= BLOOD_RUSH_LEVEL; +} + +static inline int is_ranged_attack_available(Player* p) { + if (!is_attack_available(p)) return 0; + return is_ranged_weapon_equipped(p); +} + +static inline int can_melee(Player* p, Player* t) { + return is_in_melee_range(p, t) || can_move(p); +} + +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); +} + +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; + default: + return 0; + } +} + +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; +} + +static inline int can_equip_two_handed_weapon(Player* p) { + return has_free_inventory_slot(p) || p->equipped[GEAR_SLOT_SHIELD] == ITEM_NONE; +} + +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; +} + +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); +} + +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); +} + +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); +} + +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); +} + +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); +} + +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; +} + +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; + } +} + +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; +} + +typedef enum { + WEAPON_TYPE_STANDARD = 0, + WEAPON_TYPE_HALBERD +} WeaponType; + +static inline int is_halberd_weapon(MeleeSpecWeapon weapon) { + (void)weapon; + return 0; +} + +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: + case ATTACK_STYLE_MAGIC: + return get_slot_gear_bonuses(p)->attack_range; + default: + return 1; + } +} +// ATTACK EXECUTION (uses osrs_resolve_spec + osrs_resolve_bolt_proc) + +static void perform_attack(OsrsEnv* 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 spec_cost = 0; + int was_special_requested = is_special; + int spec_item_idx = ITEM_NONE; + + if (is_special) { + switch (style) { + case ATTACK_STYLE_MELEE: { + MeleeSpecWeapon weapon = attacker->melee_spec_weapon; + spec_cost = get_melee_spec_cost(weapon); + spec_item_idx = pvp_melee_spec_to_item(weapon); + break; + } + case ATTACK_STYLE_RANGED: { + RangedSpecWeapon weapon = attacker->ranged_spec_weapon; + spec_cost = get_ranged_spec_cost(weapon); + spec_item_idx = pvp_ranged_spec_to_item(weapon); + break; + } + case ATTACK_STYLE_MAGIC: { + MagicSpecWeapon weapon = attacker->magic_spec_weapon; + spec_cost = get_magic_spec_cost(weapon); + spec_item_idx = pvp_magic_spec_to_item(weapon); + break; + } + default: + break; + } + + if (attacker->special_energy < spec_cost) { + is_special = 0; + spec_item_idx = ITEM_NONE; + } else { + /* spec energy deducted here, not via encounter_use_spec (PvP manages its own step loop) */ + attacker->special_energy -= spec_cost; + if (!attacker->spec_regen_active && attacker->special_energy < 100) { + attacker->spec_regen_active = 1; + attacker->item_effect_state.special_regen_ticks = 0; + } + } + } + + /* update gear based on attack style */ + 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; + + /* === SPECIAL ATTACK DISPATCH via osrs_resolve_spec === */ + if (is_special && spec_item_idx != ITEM_NONE) { + int eff_attack = calculate_effective_attack(attacker, style); + int attack_bonus = get_attack_bonus(attacker, style); + int att_roll = eff_attack * (attack_bonus + 64); + + int eff_defence = calculate_effective_defence(defender, style); + int defence_bonus = get_defence_bonus(defender, style, attacker); + int def_roll = eff_defence * (defence_bonus + 64); + + int magic_base_hit = 30; + int max_hit = calculate_max_hit(attacker, style, 1.0f, magic_base_hit); + + SpecResult sr = osrs_resolve_spec( + spec_item_idx, att_roll, max_hit, def_roll, + defender->current_defence, &env->rng_state + ); + + /* queue each hit from the SpecResult */ + int total_damage = sr.total_damage; + int hit_delay; + if (style == ATTACK_STYLE_MELEE) + hit_delay = 0; + else if (style == ATTACK_STYLE_RANGED) + hit_delay = pvp_ranged_hit_delay_for_weapon(distance, 1, attacker->ranged_spec_weapon); + else + hit_delay = pvp_magic_hit_delay(distance); + + /* determine PvP-specific hit effects */ + int drain_type = 0, drain_percent = 0; + int freeze_ticks = sr.freeze_ticks; + int heal_percent = 0, flat_heal = 0; + + if (sr.def_drain > 0) { + if (spec_item_idx == ITEM_BGS) { + drain_type = 2; /* drain def by damage dealt */ + } else { + drain_type = 1; + drain_percent = (spec_item_idx == ITEM_STATIUS_WARHAMMER) ? 30 : + (spec_item_idx == ITEM_ELDER_MAUL) ? 35 : 0; + } + } + if (sr.heal > 0 && spec_item_idx == ITEM_SGS) { + heal_percent = 50; + } + + /* voidwaker deals magic damage */ + AttackStyle hit_style = (spec_item_idx == ITEM_VOIDWAKER) ? ATTACK_STYLE_MAGIC : style; + + for (int i = 0; i < sr.num_hits; i++) { + int this_delay = hit_delay; + /* dark bow second arrow uses different delay formula */ + if (spec_item_idx == ITEM_DARK_BOW && i == 1) + this_delay = pvp_ranged_hit_delay_dbow_second(distance); + queue_hit(attacker, defender, sr.damage[i], hit_style, this_delay, 1, + sr.damage[i] > 0, freeze_ticks, heal_percent, drain_type, drain_percent, flat_heal); + } + + register_hit_calculated(env, attacker_idx, defender_idx, hit_style, total_damage); + + /* ancient godsword blood sacrifice: 25 magic damage at 8 ticks + heal */ + if (spec_item_idx == ITEM_ANCIENT_GS && total_damage > 0) { + int ags_heal = clamp((int)(defender->base_hitpoints * 0.15f), 0, 15); + queue_hit(attacker, defender, 25, ATTACK_STYLE_MAGIC, 8, 1, 1, 0, 0, 0, 0, ags_heal); + } + + /* morrigan's javelin phantom strike bleed */ + if (spec_item_idx == ITEM_MORRIGANS_JAVELIN && total_damage > 0) { + attacker->pending_hits[attacker->num_pending_hits - 1].is_morr_bleed = 1; + defender->morr_dot_tick_counter = 3; + } + + goto post_attack; + } + + /* === NORMAL 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 && 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) { + 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; + if (style == ATTACK_STYLE_MELEE) + hit_delay = 0; + else if (style == ATTACK_STYLE_RANGED) + hit_delay = pvp_ranged_hit_delay(distance); + else + hit_delay = pvp_magic_hit_delay(distance); + + int freeze_ticks = 0, heal_percent = 0; + + if (style == ATTACK_STYLE_MAGIC) { + if (magic_type == 1) { + freeze_ticks = get_ice_freeze_ticks(attacker->current_magic); + if (has_zuriels) freeze_ticks = (int)(freeze_ticks * 1.10f); + } else if (magic_type == 2) { + heal_percent = get_blood_heal_percent(attacker->current_magic); + if (has_zuriels) heal_percent = (int)(heal_percent * 1.50f); + } + } + + int total_damage = 0; + int apply_magic_freeze_on_calc = (style == ATTACK_STYLE_MAGIC && magic_type == 1); + + /* bolt proc setup */ + int ammo_item = attacker->equipped[GEAR_SLOT_AMMO]; + int is_crossbow_ranged = (style == ATTACK_STYLE_RANGED && !is_special); + + int hit_count = 1; + for (int i = 0; i < hit_count; i++) { + int damage = 0; + int hit_success = 0; + + if (rand_float(env) < hit_chance) { + hit_success = 1; + damage = rand_int(env, max_hit + 1); + } + + /* bolt proc: resolve after accuracy roll, may override damage */ + if (is_crossbow_ranged) { + BoltProcResult bp = osrs_resolve_bolt_proc( + ammo_item, damage, hit_success, max_hit, + attacker->current_ranged, + defender->current_hitpoints, + 0, &env->rng_state + ); + if (bp.proc_triggered) { + damage = bp.modified_damage; + hit_success = 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, 0, 0, 0); + } + register_hit_calculated(env, attacker_idx, defender_idx, style, total_damage); + } + +post_attack: + attacker->just_attacked = 1; + attacker->last_attack_style = (is_special && spec_item_idx == ITEM_VOIDWAKER) ? ATTACK_STYLE_MAGIC : style; + attacker->attack_style_this_tick = attacker->last_attack_style; + attacker->magic_type_this_tick = magic_type; + attacker->used_special_this_tick = is_special; + + int attack_speed = get_slot_gear_bonuses(attacker)->attack_speed; + int is_instant = (is_special && spec_item_idx == ITEM_GRANITE_MAUL); + 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..0b48c9ff5c --- /dev/null +++ b/ocean/osrs/osrs_pvp_effects.h @@ -0,0 +1,363 @@ +/** + * @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_models.h" +#include "osrs_anim.h" +#include + +#define MAX_ACTIVE_EFFECTS 16 + + +/* 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_TEKTON_METEOR_SPLAT 659 +#define GFX_TEKTON_METEOR_PROJ 660 +#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_DRAGON_ARROW 1120 /* dragon arrow projectile (tbow) */ +#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 */ + +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_TEKTON_METEOR_SPLAT, 14760, 3941, 128, 128 }, + { GFX_TEKTON_METEOR_PROJ, 14759, 3942, 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_DRAGON_ARROW, 26377, 6622, 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; +} + + +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; + + +/** 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, + ModelCache* secondary_model_cache +) { + if (!e->meta || e->meta->anim_seq_id < 0) return; + + OsrsModel* om = model_cache_get(model_cache, e->meta->model_id); + if (!om && secondary_model_cache) + om = model_cache_get(secondary_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); +} + + +/** + * 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, + ModelCache* secondary_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, secondary_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, ModelCache* secondary_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, secondary_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, + ModelCache* secondary_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, secondary_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..6edb05cd79 --- /dev/null +++ b/ocean/osrs/osrs_pvp_gear.h @@ -0,0 +1,1021 @@ +/** + * @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" +#include "osrs_inventory.h" +#include "osrs_combat.h" +#include "osrs_item_effects.h" + +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 + +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. + * Delegates to osrs_sum_equipment_bonuses() from osrs_combat.h, then maps + * EquipmentBonuses field names to GearBonuses field names. + */ +static inline GearBonuses compute_slot_gear_bonuses(Player* p) { + EquipmentBonuses eb; + osrs_sum_equipment_bonuses(p->equipped, &eb); + return osrs_gear_bonuses_from_equipment_bonuses(&eb); +} + +/** Get cached slot-based gear bonuses, recomputing if dirty. */ +static inline GearBonuses* get_slot_gear_bonuses(Player* p) { + osrs_ensure_player_equipment(p); + return &p->slot_cached_bonuses; +} + +/** 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 item in slot-based mode. + * Returns 1 if equipment changed, 0 if already equipped. + * + * NOT using osrs_equip_from_inventory(): PvP uses per-slot arrays (each gear + * slot has its own item pool for the LMS upgrade system), not the flat 28-slot + * bag model that osrs_inventory.h provides. + */ +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; + } + + osrs_refresh_player_equipment(p); + return 1; +} + +/** 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); +} + +/** + * 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: { + 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 { + 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; +} + +/** 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; + } +} + +/** 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; + + osrs_refresh_player_equipment(p); + 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; +} + +// 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 GearSlotIndex. Thin wrapper over osrs_item_gear_slot(). */ +static inline int item_to_gear_slot(uint8_t item_idx) { + return osrs_item_gear_slot(item_idx); +} +// 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; +} + +static const uint8_t CHEST_LOOT[] = { + 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, + 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, + 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]); + } + + osrs_refresh_player_equipment(p); + 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; +} + +#endif // OSRS_PVP_GEAR_H diff --git a/ocean/osrs/osrs_pvp_movement.h b/ocean/osrs/osrs_pvp_movement.h new file mode 100644 index 0000000000..07f90e7be8 --- /dev/null +++ b/ocean/osrs/osrs_pvp_movement.h @@ -0,0 +1,507 @@ +/** + * @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 + */ +__attribute__((unused)) +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_observations.h b/ocean/osrs/osrs_pvp_observations.h new file mode 100644 index 0000000000..570eda4410 --- /dev/null +++ b/ocean/osrs/osrs_pvp_observations.h @@ -0,0 +1,773 @@ +/** + * @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_encounter.h" // for ENCOUNTER_OVERHEAD_* / ENCOUNTER_OFFENSIVE_* encoding +#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(OsrsEnv* 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(OsrsEnv* 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; +} + +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(39)] = 373 floats. + */ +static void ocean_write_obs(OsrsEnv* env) { + ensure_obs_norm_initialized(); + float* dst = env->ocean_io.agent_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_io.agent_obs_p1 is set (self-play enabled). + */ +static void ocean_write_obs_p1(OsrsEnv* env) { + ensure_obs_norm_initialized(); + float* dst = env->ocean_io.agent_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]; + } +} + +/** + * Generate slot-mode observations with per-slot item stats. + * + * Observation layout (190 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] Voidwaker magic damage flag + * [183-189] Reward shaping signals + */ +static void generate_slot_observations(OsrsEnv* 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] = (float)p->spec_armed; /* 1 = spec armed for next attack */ + 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; + osrs_ensure_player_equipment(p); + obs[118] = (p->equipment_effect_profile.dharok_piece_count >= 4) ? 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->pvp_runtime.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 removed: 144 features (8 slots x 18 stats) were redundant + // with gear bonuses (obs 119-132) and per-slot equipped indices (obs 160-181) + + // Voidwaker magic damage flag (182) + uint8_t best_mspec = find_best_melee_spec(p); + obs[182] = (best_mspec == ITEM_VOIDWAKER) ? 1.0f : 0.0f; + + // Reward shaping signals (183-189) + obs[183] = p->used_special_this_tick ? 1.0f : 0.0f; + obs[184] = p->ate_food_this_tick ? 1.0f : 0.0f; + obs[185] = p->ate_karambwan_this_tick ? 1.0f : 0.0f; + AttackStyle current_weapon_style = get_slot_weapon_attack_style(p); + obs[186] = (current_weapon_style == ATTACK_STYLE_MAGIC) ? 1.0f : 0.0f; + obs[187] = (current_weapon_style == ATTACK_STYLE_RANGED) ? 1.0f : 0.0f; + obs[188] = (current_weapon_style == ATTACK_STYLE_MELEE) ? 1.0f : 0.0f; + obs[189] = 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(OsrsEnv* 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 frozen_no_melee = !can_move(p) && !is_in_melee_range(p, t); + + /* SPEC_MELEE: available if melee spec weapon exists + enough energy. + no timer check — spec is a toggle (arm now, fires on next attack). */ + uint8_t best_melee_spec = find_best_melee_spec(p); + int melee_spec_cost = 25; + 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) && !frozen_no_melee; + + /* SPEC_RANGE: available if ranged spec weapon exists + enough energy */ + uint8_t best_range_spec = find_best_ranged_spec(p); + int range_spec_cost = 50; + mask[offset + LOADOUT_SPEC_RANGE] = (best_range_spec != ITEM_NONE) && + (p->special_energy >= range_spec_cost); + + /* SPEC_MAGIC: available if magic spec weapon (volatile) exists + enough energy */ + uint8_t best_magic_spec = find_best_magic_spec(p); + mask[offset + LOADOUT_SPEC_MAGIC] = (best_magic_spec != ITEM_NONE) && + (p->special_energy >= 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 in new toggle-semantic encoding: + // 0=no_change, 1-5=toggle_{melee,ranged,magic,smite,redemption} + // a toggle is valid if pp>0 (activation) OR if it would deactivate the currently-active prayer. + int has_prayer = p->current_prayer > 0; + mask[offset + ENCOUNTER_OVERHEAD_NO_CHANGE] = 1; + mask[offset + ENCOUNTER_OVERHEAD_TOGGLE_MELEE] = has_prayer || p->prayer == PRAYER_PROTECT_MELEE; + mask[offset + ENCOUNTER_OVERHEAD_TOGGLE_RANGED] = has_prayer || p->prayer == PRAYER_PROTECT_RANGED; + mask[offset + ENCOUNTER_OVERHEAD_TOGGLE_MAGIC] = has_prayer || p->prayer == PRAYER_PROTECT_MAGIC; + mask[offset + ENCOUNTER_OVERHEAD_TOGGLE_SMITE] = (has_prayer || p->prayer == PRAYER_SMITE) && !env->is_lms; + mask[offset + ENCOUNTER_OVERHEAD_TOGGLE_REDEMPTION] = (has_prayer || p->prayer == PRAYER_REDEMPTION) && !env->is_lms; + 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; + + // OFFENSIVE head — 4 options, new toggle-semantic encoding: + // 0=no_change, 1-3=toggle_{piety,rigour,augury} + // valid if pp>0 (activation) OR toggle would deactivate currently-active offensive. + mask[offset + ENCOUNTER_OFFENSIVE_NO_CHANGE] = 1; + mask[offset + ENCOUNTER_OFFENSIVE_TOGGLE_PIETY] = has_prayer || p->offensive_prayer == OFFENSIVE_PRAYER_PIETY; + mask[offset + ENCOUNTER_OFFENSIVE_TOGGLE_RIGOUR] = has_prayer || p->offensive_prayer == OFFENSIVE_PRAYER_RIGOUR; + mask[offset + ENCOUNTER_OFFENSIVE_TOGGLE_AUGURY] = has_prayer || p->offensive_prayer == OFFENSIVE_PRAYER_AUGURY; + offset += OFFENSIVE_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..25e881185f --- /dev/null +++ b/ocean/osrs/osrs_pvp_opponents.h @@ -0,0 +1,3564 @@ +/** + * @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 + +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); +} + +static inline void opp_apply_fake_switch(int* actions, int style) { + actions[HEAD_LOADOUT] = opp_style_to_loadout(style); +} + +static inline void opp_apply_tank_gear(int* actions) { + actions[HEAD_LOADOUT] = LOADOUT_TANK; +} + +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; +} + +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; +} + +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; +} + +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; +} + +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(OsrsEnv* 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; +} + +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(OsrsEnv* 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(OsrsEnv* 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(OsrsEnv* 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(OsrsEnv* 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; +} + +/* 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 + +/* Weighted delay sampling from cumulative weight array */ +static inline int opp_sample_delay(OsrsEnv* 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(OsrsEnv* env, OpponentState* opp, int* actions, + Player* self, int attack_style, int potion_used) { + (void)env; + 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(OsrsEnv* 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) { + 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; +} + +/* map an OverheadPrayer to the toggle action that flips it on/off. returns + ENCOUNTER_OVERHEAD_NO_CHANGE for PRAYER_NONE (nothing to toggle). */ +static inline int opp_toggle_for_prayer(OverheadPrayer p) { + switch (p) { + case PRAYER_PROTECT_MAGIC: return ENCOUNTER_OVERHEAD_TOGGLE_MAGIC; + case PRAYER_PROTECT_RANGED: return ENCOUNTER_OVERHEAD_TOGGLE_RANGED; + case PRAYER_PROTECT_MELEE: return ENCOUNTER_OVERHEAD_TOGGLE_MELEE; + case PRAYER_SMITE: return ENCOUNTER_OVERHEAD_TOGGLE_SMITE; + case PRAYER_REDEMPTION: return ENCOUNTER_OVERHEAD_TOGGLE_REDEMPTION; + default: return ENCOUNTER_OVERHEAD_NO_CHANGE; + } +} + +/* Convert opponent prayer intent into the toggle action required by the + encounter overhead encoding. */ +static inline void opp_emit_prayer(int* actions, Player* self, int target_overhead_action) { + OverheadPrayer target_prayer; + switch (target_overhead_action) { + case OVERHEAD_NONE: target_prayer = PRAYER_NONE; break; + case OVERHEAD_MAGE: target_prayer = PRAYER_PROTECT_MAGIC; break; + case OVERHEAD_RANGED: target_prayer = PRAYER_PROTECT_RANGED; break; + case OVERHEAD_MELEE: target_prayer = PRAYER_PROTECT_MELEE; break; + case OVERHEAD_SMITE: target_prayer = PRAYER_SMITE; break; + case OVERHEAD_REDEMPTION: target_prayer = PRAYER_REDEMPTION; break; + default: return; /* invalid: no-op */ + } + if (self->prayer == target_prayer) return; + /* deactivation path: toggle current-on prayer off. activation/replace path: + emit target toggle (toggle semantics replace whatever's currently on). */ + int toggle = (target_prayer == PRAYER_NONE) + ? opp_toggle_for_prayer(self->prayer) + : opp_toggle_for_prayer(target_prayer); + if (toggle != ENCOUNTER_OVERHEAD_NO_CHANGE) + actions[HEAD_OVERHEAD] = toggle; +} + +/* Process pending prayer delay: decrement, apply if ready. Returns 1 if applied. */ +static inline int opp_process_pending_prayer(OpponentState* opp, int* actions, Player* self) { + 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; + } + OverheadPrayer target_prayer = PRAYER_NONE; + int toggle = ENCOUNTER_OVERHEAD_NO_CHANGE; + switch (opp->pending_prayer_value) { + case OVERHEAD_MAGE: target_prayer = PRAYER_PROTECT_MAGIC; toggle = ENCOUNTER_OVERHEAD_TOGGLE_MAGIC; break; + case OVERHEAD_RANGED: target_prayer = PRAYER_PROTECT_RANGED; toggle = ENCOUNTER_OVERHEAD_TOGGLE_RANGED; break; + case OVERHEAD_MELEE: target_prayer = PRAYER_PROTECT_MELEE; toggle = ENCOUNTER_OVERHEAD_TOGGLE_MELEE; break; + case OVERHEAD_SMITE: target_prayer = PRAYER_SMITE; toggle = ENCOUNTER_OVERHEAD_TOGGLE_SMITE; break; + case OVERHEAD_REDEMPTION: target_prayer = PRAYER_REDEMPTION; toggle = ENCOUNTER_OVERHEAD_TOGGLE_REDEMPTION; break; + default: break; + } + /* only emit toggle if we need to change — if already on target, no-op. */ + if (self->prayer != target_prayer) actions[HEAD_OVERHEAD] = toggle; + 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(OsrsEnv* 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, self); +} + +/* --- TrueRandom: random value per action head --- */ +static void opp_true_random(OsrsEnv* 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(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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(OsrsEnv* 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}; + opp_emit_prayer(actions, self, 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(OsrsEnv* 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}; + opp_emit_prayer(actions, self, 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(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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(OsrsEnv* 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); + opp_emit_prayer(actions, self, 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(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } +} + +static void opp_novice_nh(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } + } +} + +static void opp_apprentice_nh(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } + } +} + +static void opp_competent_nh(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } +} + +static void opp_intermediate_nh(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } +} + +static void opp_advanced_nh(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } +} + +static void opp_proficient_nh(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } +} + +static void opp_expert_nh(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } +} + +static void opp_onetick(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; +} + +static void opp_unpredictable_improved(OsrsEnv* 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; +} + +static void opp_unpredictable_onetick(OsrsEnv* 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; + } + } +} + +static void opp_read_agent_action(OsrsEnv* 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 intent from agent's toggle action. since action is + a toggle, the "intent" is the agent's target — if the toggle would activate + or replace, we read it as intent; if it would deactivate, we read no intent. + we approximate by looking up the target based on the toggle id; the agent's + actual prayer state is observed separately via Player.prayer by opponent AI. */ + int overhead = agent_actions[HEAD_OVERHEAD]; + if (overhead == ENCOUNTER_OVERHEAD_TOGGLE_MELEE) opp->read_agent_prayer = PRAYER_PROTECT_MELEE; + else if (overhead == ENCOUNTER_OVERHEAD_TOGGLE_RANGED) opp->read_agent_prayer = PRAYER_PROTECT_RANGED; + else if (overhead == ENCOUNTER_OVERHEAD_TOGGLE_MAGIC) opp->read_agent_prayer = PRAYER_PROTECT_MAGIC; + else if (overhead == ENCOUNTER_OVERHEAD_TOGGLE_SMITE) opp->read_agent_prayer = PRAYER_SMITE; + else if (overhead == ENCOUNTER_OVERHEAD_TOGGLE_REDEMPTION) opp->read_agent_prayer = PRAYER_REDEMPTION; + + opp->read_agent_moving = is_move_action(attack) ? 1 : 0; +} + +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; +} + +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 */ +} + +static void opp_master_nh(OsrsEnv* env, OpponentState* opp, int* actions) { + Player* self = &env->players[1]; + Player* target = &env->players[0]; + 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)) { + opp_emit_prayer(actions, self, 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; +} + +static void opp_savant_nh(OsrsEnv* 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); +} + +static void opp_nightmare_nh(OsrsEnv* 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); +} + +static void opp_veng_fighter(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } +} + +static void opp_blood_healer(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } + } +} + +#define COMBO_IDLE 0 +#define COMBO_SPEC_FIRED 1 + +static void opp_gmaul_combo(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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; + } + } +} + +static void opp_range_kiter(OsrsEnv* 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)) { + opp_emit_prayer(actions, self, 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 */ + } + + opp_apply_boost_potion(env, opp, actions, self, attack_style, 0); + + 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; + } + } + } +} + +/* 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( + OsrsEnv* 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]; +} + +static void opponent_reset(OsrsEnv* env, OpponentState* opp) { + opp->food_cooldown = 0; + opp->potion_cooldown = 0; + opp->karambwan_cooldown = 0; + opp->current_prayer_set = 0; + + 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->pvp_runtime.pfsp.pool_size > 0) { + int idx = 0; + int r = rand_int(env, 1000); + for (int i = 0; i < env->pvp_runtime.pfsp.pool_size; i++) { + if (r < env->pvp_runtime.pfsp.cum_weights[i]) { idx = i; break; } + } + env->pvp_runtime.pfsp.active_pool_idx = idx; + opp->active_sub_policy = env->pvp_runtime.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->pvp_runtime.use_c_opponent = 0; + env->pvp_runtime.use_external_opponent_actions = 1; + if (env->ocean_io.selfplay_mask) *env->ocean_io.selfplay_mask = 1; + } else { + env->pvp_runtime.use_c_opponent = 1; + env->pvp_runtime.use_external_opponent_actions = 0; + if (env->ocean_io.selfplay_mask) *env->ocean_io.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->pvp_runtime.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(OsrsEnv* 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(OsrsEnv* 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(OsrsEnv* 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_render.h b/ocean/osrs/osrs_render.h new file mode 100644 index 0000000000..13882d9bbc --- /dev/null +++ b/ocean/osrs/osrs_render.h @@ -0,0 +1,4589 @@ +/** + * @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_VISUAL define. + * + * Follows PufferLib's Client + make_client + c_render pattern. + */ + +#ifndef OSRS_RENDER_H +#define OSRS_RENDER_H + +#include "raylib.h" +#include "rlgl.h" +#include "raymath.h" +#include "osrs_models.h" +#include "osrs_anim.h" +#include "osrs_combat.h" +#include "osrs_pvp_combat.h" +#include "osrs_pvp_effects.h" +#include "data/player_models.h" +#include "data/npc_models.h" +#include "osrs_terrain.h" +#include "osrs_objects.h" +#include "osrs_gui.h" +#include "osrs_human_input.h" +#include +#include +#include + + +#define RENDER_TILE_SIZE 20 +/* window sized at 1.5x the OSRS fixed-client layout (765x503 → 1148x755) + so it's comfortable to look at on modern monitors while keeping the + same aspect ratio as the real game. */ +#define RENDER_WINDOW_W 1148 +#define RENDER_WINDOW_H 755 +#define RENDER_PANEL_WIDTH 285 /* 190 * 1.5 */ +#define RENDER_HEADER_HEIGHT 0 /* OSRS client has no top header strip */ +#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 (RENDER_WINDOW_W - RENDER_PANEL_WIDTH) /* = 575, OSRS ≈ 512 */ +#define RENDER_GRID_H (RENDER_WINDOW_H - RENDER_HEADER_HEIGHT) /* = 503, OSRS ≈ 334 */ + +/* 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 } + + +/* 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. + */ + +/* inferno can keep multiple Zuk healer spark volleys and other flights alive + at once. size this from the real encounter envelope instead of a tiny demo + value so visuals never silently disappear. */ +#define MAX_FLIGHT_PROJECTILES 64 +#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 */ + int start_delay; /* client ticks before projectile becomes visible/moves */ + int motion_mode; /* EncounterProjectileMotionMode */ + float offset_x, offset_y, offset_z; + uint32_t model_id; /* GFX model from cache (0 = style-based fallback) */ + int anim_id; /* spotanim animation sequence (-1 = static model) */ + int anim_frame; + int anim_tick_counter; + AnimModelState* anim_state; + int impact_gfx_id; /* landing spotanim to spawn on arrival */ +} FlightProjectile; + + +/* 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; + + +#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; +} + + +/* 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; + + +#define CONTEXT_MENU_MAX_ITEMS 8 +#define CONTEXT_MENU_ROW_H 15 +#define CONTEXT_MENU_PADDING 4 +#define CONTEXT_MENU_MIN_W 150 + +typedef enum { + CMENU_ACTION_NONE = 0, + CMENU_ACTION_WALK_HERE, + CMENU_ACTION_ATTACK, + CMENU_ACTION_CANCEL, +} ContextMenuAction; + +typedef struct { + ContextMenuAction action; + int entity_idx; /* render entity index for ATTACK, -1 for walk/cancel */ + char label[64]; /* display text, e.g. "Attack Jal-Zek" */ +} ContextMenuItem; + +typedef struct { + int visible; + int screen_x, screen_y; /* top-left of the menu popup */ + int width; /* computed from widest label */ + int item_count; + ContextMenuItem items[CONTEXT_MENU_MAX_ITEMS]; + int walk_tile_x, walk_tile_y; /* world tile for "Walk here" */ + int hover_idx; /* item currently hovered, -1 = none */ +} ContextMenu; + +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]; + float entity_visual_top_y[MAX_RENDER_ENTITIES]; /* world-space top of animated mesh */ + float entity_visual_mid_y[MAX_RENDER_ENTITIES]; /* world-space middle of animated mesh */ + + /* 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; + ObjectMesh* objects_zuk; /* post-Zuk variant (prison walls removed) */ + int zuk_active; /* set when Zuk NPC (7706) is present */ + + /* 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 24 + 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 */ + OsrsEnv* 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; + + /* cursor hover tile: tile under mouse cursor, updated every frame. + -1 = no valid tile under cursor (off-arena or off-screen). */ + int hover_tile_x, hover_tile_y; + + /* right-click context menu (OSRS-style popup) */ + ContextMenu context_menu; +} 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(OsrsEnv* 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; +} + + +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; +} + +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); + +/* forward declaration: inferno_npc_name is defined later in drawing section */ +static const char* inferno_npc_name(int npc_def_id); + + +/** Resolve display name for a render entity (NPC or player). + Uses the same lookup chain as render_draw_panel_npc: zulrah forms, + inferno NPCs, then fallback to "NPC ". */ +static const char* render_entity_display_name(RenderEntity* ent) { + if (ent->entity_type == ENTITY_PLAYER) return "Player"; + + /* zulrah forms */ + if (ent->npc_def_id == 2042) return "Zulrah"; + if (ent->npc_def_id == 2043) return "Zulrah"; + if (ent->npc_def_id == 2044) return "Zulrah"; + + /* inferno NPCs */ + const char* inf = inferno_npc_name(ent->npc_def_id); + if (inf) return inf; + + return TextFormat("NPC %d", ent->npc_def_id); +} + +typedef struct { + RenderClient* rc; + OsrsEnv* env; +} RenderHumanAttackCtx; + +static int render_can_human_attack_entity( + void* ctx, const RenderEntity* entity, int entity_idx, int gui_entity_idx +) { + RenderHumanAttackCtx* attack_ctx = (RenderHumanAttackCtx*)ctx; + if (entity_idx == gui_entity_idx && entity->entity_type == ENTITY_PLAYER) { + return 0; + } + + if (entity->entity_type == ENTITY_NPC && !entity->npc_visible) { + return 0; + } + + if (attack_ctx->env->encounter_def && attack_ctx->env->encounter_state) { + const EncounterDef* def = (const EncounterDef*)attack_ctx->env->encounter_def; + if (entity->entity_type == ENTITY_NPC && def->is_human_targetable_npc_slot) { + return def->is_human_targetable_npc_slot( + attack_ctx->env->encounter_state, entity->npc_slot); + } + } + + return 1; +} + +/** Clear/hide the context menu. */ +static void context_menu_dismiss(ContextMenu* cm) { + cm->visible = 0; + cm->item_count = 0; + cm->hover_idx = -1; +} + +/** Add an item to the context menu. */ +static void context_menu_add(ContextMenu* cm, ContextMenuAction action, + int entity_idx, const char* label) { + if (cm->item_count >= CONTEXT_MENU_MAX_ITEMS) return; + ContextMenuItem* item = &cm->items[cm->item_count++]; + item->action = action; + item->entity_idx = entity_idx; + snprintf(item->label, sizeof(item->label), "%s", label); +} + +/** Build context menu from a right-click at screen position (mx, my). + Performs entity hull hit-testing (3D) or tile hit-testing (2D), + then builds the appropriate menu items. */ +static void context_menu_build(RenderClient* rc, OsrsEnv* env, int mx, int my) { + ContextMenu* cm = &rc->context_menu; + RenderHumanAttackCtx attack_ctx = { .rc = rc, .env = env }; + cm->item_count = 0; + cm->hover_idx = -1; + cm->walk_tile_x = -1; + cm->walk_tile_y = -1; + + /* collect all NPCs/entities under cursor (3D hull test or 2D tile test) */ + int hit_entities[MAX_RENDER_ENTITIES]; + int hit_count = 0; + + if (rc->mode_3d) { + /* 3D: test against convex hulls */ + for (int ei = 0; ei < rc->entity_count; ei++) { + RenderEntity* ent = &rc->entities[ei]; + if (!render_can_human_attack_entity( + &attack_ctx, ent, ei, rc->gui.gui_entity_idx)) { + continue; + } + if (hull_contains(&rc->entity_hulls[ei], mx, my)) { + if (hit_count < MAX_RENDER_ENTITIES) + hit_entities[hit_count++] = ei; + } + } + + /* resolve ground tile under cursor via ray-box intersection */ + Camera3D cam = render_build_3d_camera(rc); + Ray ray = GetScreenToWorldRay((Vector2){ (float)mx, (float)my }, cam); + float best_dist = 1e30f; + 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; + cm->walk_tile_x = wx; + cm->walk_tile_y = wy; + } + } + } + } else { + /* 2D: check tile under cursor, then check entities on that tile */ + if (my >= RENDER_HEADER_HEIGHT) { + int grid_pixel_w = rc->arena_width * RENDER_TILE_SIZE; + int grid_pixel_h = rc->arena_height * RENDER_TILE_SIZE; + if (mx >= 0 && mx < grid_pixel_w && + my < RENDER_HEADER_HEIGHT + grid_pixel_h) { + int wx = human_screen_to_world_x(mx, rc->arena_base_x, RENDER_TILE_SIZE); + int wy = human_screen_to_world_y(my, rc->arena_base_y, rc->arena_height, + RENDER_HEADER_HEIGHT, RENDER_TILE_SIZE); + cm->walk_tile_x = wx; + cm->walk_tile_y = wy; + + for (int ei = 0; ei < rc->entity_count; ei++) { + RenderEntity* ent = &rc->entities[ei]; + if (!render_can_human_attack_entity( + &attack_ctx, ent, ei, rc->gui.gui_entity_idx)) { + continue; + } + if (human_tile_hits_entity(ent, wx, wy)) { + if (hit_count < MAX_RENDER_ENTITIES) + hit_entities[hit_count++] = ei; + } + } + } + } + } + + /* build menu items: "Attack " for each hit entity, then "Walk here" */ + for (int i = 0; i < hit_count; i++) { + int ei = hit_entities[i]; + RenderEntity* ent = &rc->entities[ei]; + const char* name = render_entity_display_name(ent); + char label[64]; + snprintf(label, sizeof(label), "Attack %s", name); + context_menu_add(cm, CMENU_ACTION_ATTACK, ei, label); + } + + if (cm->walk_tile_x >= 0) + context_menu_add(cm, CMENU_ACTION_WALK_HERE, -1, "Walk here"); + + context_menu_add(cm, CMENU_ACTION_CANCEL, -1, "Cancel"); + + /* compute menu width from widest label */ + int max_w = CONTEXT_MENU_MIN_W; + for (int i = 0; i < cm->item_count; i++) { + int w = MeasureText(cm->items[i].label, 10) + CONTEXT_MENU_PADDING * 2 + 4; + if (w > max_w) max_w = w; + } + cm->width = max_w; + + /* position: at cursor, clamped to screen bounds */ + int menu_h = cm->item_count * CONTEXT_MENU_ROW_H + CONTEXT_MENU_PADDING * 2; + cm->screen_x = mx; + cm->screen_y = my; + if (cm->screen_x + cm->width > RENDER_WINDOW_W) + cm->screen_x = RENDER_WINDOW_W - cm->width; + if (cm->screen_y + menu_h > RENDER_WINDOW_H) + cm->screen_y = RENDER_WINDOW_H - menu_h; + if (cm->screen_x < 0) cm->screen_x = 0; + if (cm->screen_y < 0) cm->screen_y = 0; + + cm->visible = (cm->item_count > 0); +} + +/** Execute a context menu item action on the HumanInput staging buffer. */ +static void context_menu_execute(RenderClient* rc, int item_idx) { + ContextMenu* cm = &rc->context_menu; + if (item_idx < 0 || item_idx >= cm->item_count) return; + ContextMenuItem* item = &cm->items[item_idx]; + + switch (item->action) { + case CMENU_ACTION_WALK_HERE: + rc->human_input.pending_move_x = cm->walk_tile_x; + rc->human_input.pending_move_y = cm->walk_tile_y; + rc->human_input.pending_attack = 0; + human_input_queue_walk(&rc->human_input, cm->walk_tile_x, cm->walk_tile_y); + human_set_click_cross(&rc->human_input, cm->screen_x, cm->screen_y, 0); + break; + + case CMENU_ACTION_ATTACK: { + int ei = item->entity_idx; + if (ei >= 0 && ei < rc->entity_count) { + rc->human_input.pending_attack = 1; + rc->human_input.pending_target_idx = rc->entities[ei].npc_slot; + 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; + human_input_queue_spell_target( + &rc->human_input, + rc->human_input.selected_spell, + rc->human_input.pending_target_idx); + rc->human_input.cursor_mode = CURSOR_NORMAL; + } else { + human_input_queue_attack_npc( + &rc->human_input, + rc->human_input.pending_target_idx); + } + human_set_click_cross(&rc->human_input, cm->screen_x, cm->screen_y, 1); + } + break; + } + + case CMENU_ACTION_CANCEL: + case CMENU_ACTION_NONE: + break; + } + + context_menu_dismiss(cm); +} + +/** Draw the context menu as a 2D overlay. Call just before EndDrawing(). + OSRS style: dark brown/black rectangle, white text, yellow highlight on hover. */ +static void context_menu_draw(RenderClient* rc) { + ContextMenu* cm = &rc->context_menu; + if (!cm->visible || cm->item_count == 0) return; + + int mx = GetMouseX(); + int my = GetMouseY(); + int menu_h = cm->item_count * CONTEXT_MENU_ROW_H + CONTEXT_MENU_PADDING * 2; + + /* update hover state */ + cm->hover_idx = -1; + if (mx >= cm->screen_x && mx < cm->screen_x + cm->width && + my >= cm->screen_y && my < cm->screen_y + menu_h) { + int row = (my - cm->screen_y - CONTEXT_MENU_PADDING) / CONTEXT_MENU_ROW_H; + if (row >= 0 && row < cm->item_count) + cm->hover_idx = row; + } + + /* OSRS menu colors */ + Color bg = (Color){ 57, 49, 35, 240 }; + Color border = (Color){ 90, 80, 60, 255 }; + Color text_normal = (Color){ 255, 255, 255, 255 }; + Color text_hover = (Color){ 255, 255, 0, 255 }; + Color hover_bg = (Color){ 80, 70, 50, 200 }; + + /* draw background with border */ + DrawRectangle(cm->screen_x - 1, cm->screen_y - 1, + cm->width + 2, menu_h + 2, border); + DrawRectangle(cm->screen_x, cm->screen_y, cm->width, menu_h, bg); + + /* draw items */ + for (int i = 0; i < cm->item_count; i++) { + int iy = cm->screen_y + CONTEXT_MENU_PADDING + i * CONTEXT_MENU_ROW_H; + + if (i == cm->hover_idx) { + DrawRectangle(cm->screen_x + 1, iy, cm->width - 2, CONTEXT_MENU_ROW_H, hover_bg); + } + + Color tc = (i == cm->hover_idx) ? text_hover : text_normal; + DrawText(cm->items[i].label, + cm->screen_x + CONTEXT_MENU_PADDING + 2, + iy + 2, 10, tc); + } +} + + +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 = (OsrsEnv*)calloc(RENDER_HISTORY_SIZE, sizeof(OsrsEnv)); + 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; + rc->hover_tile_x = -1; + rc->hover_tile_y = -1; + 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 */ + gui_reset_inventory_ui_state(&rc->gui); + + /* human input control */ + human_input_init(&rc->human_input); + + /* context menu (calloc zeroes everything, just set hover_idx sentinel) */ + rc->context_menu.hover_idx = -1; + + /* 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 an explicit projectile model by GFX model ID. + * Searches both model_cache and npc_model_cache. model_id 0 means the + * caller intentionally wants style-based fallback; missing explicit models + * abort so backend/render drift fails loudly. */ +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) { + fprintf(stderr, "render: projectile model cache exhausted while loading model %u\n", + model_id); + abort(); + } + 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); + } + if (!rc->proj_models[idx].ready) { + fprintf(stderr, "render: explicit projectile model %u is missing from loaded caches\n", + model_id); + abort(); + } + return &rc->proj_models[idx].model; +} + +static OsrsModel* render_get_projectile_osrs_model(RenderClient* rc, uint32_t model_id) { + if (model_id == 0) return NULL; + OsrsModel* om = model_cache_get(rc->model_cache, model_id); + if (!om && rc->npc_model_cache) + om = model_cache_get(rc->npc_model_cache, model_id); + if (!om) { + fprintf(stderr, "render: explicit projectile model %u is missing from loaded caches\n", + model_id); + abort(); + } + return om; +} + +static AnimModelState* render_create_projectile_anim_state( + RenderClient* rc, uint32_t model_id, int anim_id +) { + if (anim_id < 0 || model_id == 0) return NULL; + OsrsModel* om = render_get_projectile_osrs_model(rc, model_id); + if (!om->vertex_skins || om->base_vert_count == 0) { + fprintf(stderr, "render: projectile model %u cannot play animation %d\n", + model_id, anim_id); + abort(); + } + return anim_model_state_create(om->vertex_skins, om->base_vert_count); +} + +/** + * 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"); +} + + +/** + * 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_deactivate(FlightProjectile* fp) { + if (fp->anim_state) { + anim_model_state_free(fp->anim_state); + fp->anim_state = NULL; + } + fp->active = 0; +} + +static void flight_clear_all(RenderClient* rc) { + for (int i = 0; i < MAX_FLIGHT_PROJECTILES; i++) { + flight_deactivate(&rc->flights[i]); + } +} + +static void flight_finish(RenderClient* rc, FlightProjectile* fp) { + if (fp->impact_gfx_id > 0) { + effect_spawn_spotanim_subtile( + rc->effects, fp->impact_gfx_id, + fp->dst_x * 128.0f, fp->dst_y * 128.0f, + rc->effect_client_tick_counter + 1, + rc->anim_cache, rc->model_cache, rc->npc_model_cache); + } + flight_deactivate(fp); +} + +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 anim_id, int impact_gfx_id, + int start_delay, int motion_mode, + float offset_x, float offset_y, float offset_z) { + 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; + fp->anim_id = anim_id; + fp->anim_frame = 0; + fp->anim_tick_counter = 0; + fp->anim_state = render_create_projectile_anim_state(rc, model_id, anim_id); + fp->impact_gfx_id = impact_gfx_id; + fp->start_delay = start_delay; + fp->motion_mode = motion_mode; + fp->offset_x = offset_x; + fp->offset_y = offset_y; + fp->offset_z = offset_z; + + /* 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); +} + +static void flight_advance_animation(RenderClient* rc, FlightProjectile* fp) { + if (fp->anim_id < 0) return; + AnimSequence* seq = render_get_anim_sequence(rc, (uint16_t)fp->anim_id); + if (!seq || seq->frame_count <= 0) { + fprintf(stderr, "render: projectile animation %d is missing\n", fp->anim_id); + abort(); + } + fp->anim_tick_counter++; + while (fp->anim_tick_counter >= seq->frames[fp->anim_frame].delay) { + fp->anim_tick_counter -= seq->frames[fp->anim_frame].delay; + fp->anim_frame++; + if (fp->anim_frame >= seq->frame_count) { + fp->anim_frame = 0; + } + } +} + +static inline Matrix render_projectile_transform( + float scale_x, float scale_y, float scale_z, + float yaw, float pitch, Vector3 position +) { + Matrix transform = MatrixScale(-scale_x, scale_y, scale_z); + transform = MatrixMultiply( + transform, + MatrixMultiply(MatrixRotateY(yaw + 1.5707963f), MatrixRotateX(pitch))); + transform = MatrixMultiply(transform, MatrixTranslate(position.x, position.y, position.z)); + return transform; +} + +static inline Matrix render_projectile_transform_offset( + float scale_x, float scale_y, float scale_z, + float yaw, float pitch, Vector3 position, + float offset_x, float offset_y, float offset_z +) { + Matrix transform = MatrixScale(-scale_x, scale_y, scale_z); + transform = MatrixMultiply( + transform, MatrixTranslate(offset_x, offset_z, offset_y)); + transform = MatrixMultiply( + transform, + MatrixMultiply(MatrixRotateY(yaw + 1.5707963f), MatrixRotateX(pitch))); + transform = MatrixMultiply(transform, MatrixTranslate(position.x, position.y, position.z)); + return transform; +} + +static inline int render_pvp_ranged_spec_weapon_for_item(uint8_t weapon_db_idx) { + switch (weapon_db_idx) { + case ITEM_DARK_BOW: return RANGED_SPEC_DARK_BOW; + case ITEM_HEAVY_BALLISTA: return RANGED_SPEC_BALLISTA; + case ITEM_ARMADYL_CROSSBOW: return RANGED_SPEC_ACB; + case ITEM_ZARYTE_CROSSBOW: return RANGED_SPEC_ZCB; + case ITEM_MAGIC_SHORTBOW_I: return RANGED_SPEC_MSB; + case ITEM_MORRIGANS_JAVELIN: return RANGED_SPEC_MORRIGANS; + default: return RANGED_SPEC_NONE; + } +} + +static inline int render_pvp_distance_to_target( + const RenderEntity* attacker, const RenderEntity* target +) { + int target_size = target->entity_type == ENTITY_NPC && target->npc_size > 1 + ? target->npc_size : 1; + return encounter_dist_to_npc( + attacker->x, attacker->y, target->x, target->y, target_size); +} + +/** + * 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; + + /* start delay: count down before projectile becomes visible/moves */ + if (fp->start_delay > 0) { + fp->start_delay--; + continue; + } + + if (fp->motion_mode == ENCOUNTER_PROJECTILE_MOTION_TARGET_ANCHORED) { + fp->x = fp->dst_x; + fp->y = fp->dst_y; + fp->pitch = 0.0f; + flight_advance_animation(rc, fp); + fp->progress += fp->speed; + if (fp->progress >= 1.0f) { + flight_finish(rc, fp); + } + 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); + } + + flight_advance_animation(rc, fp); + fp->progress += fp->speed; + if (fp->progress >= 1.0f) { + flight_finish(rc, fp); + } + } +} + +/** + * Get the interpolated world position of a flight projectile. + */ +static Vector3 flight_get_position(const FlightProjectile* fp, float src_ground, float dst_ground) { + if (fp->motion_mode == ENCOUNTER_PROJECTILE_MOTION_TARGET_ANCHORED) { + return (Vector3){ fp->x + 0.5f, dst_ground + fp->end_height, + -(fp->y + 1.0f) + 0.5f }; + } + + 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) { + flight_clear_all(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->objects_zuk) { + objects_free(rc->objects_zuk); + rc->objects_zuk = NULL; + } + if (rc->npcs) { + objects_free(rc->npcs); + rc->npcs = NULL; + } + human_input_destroy(&rc->human_input); + CloseWindow(); + free(rc->history); + free(rc); +} + + +static void render_handle_input(RenderClient* rc, OsrsEnv* env) { + RenderHumanAttackCtx attack_ctx = { .rc = rc, .env = 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 (disabled in human mode where + right-click opens context menu — use middle-drag for camera instead), + scroll to zoom */ + if (!rc->human_input.enabled && 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: orbit in human mode (since right-click opens menu), + pan in non-human mode */ + if (IsMouseButtonDown(MOUSE_BUTTON_MIDDLE)) { + Vector2 delta = GetMouseDelta(); + if (rc->human_input.enabled) { + /* orbit */ + 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; + } else { + /* pan: world drags with the mouse (grab-and-drag convention) */ + 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; + } + } + + /* number keys 1-5: GUI tab switching */ + if (IsKeyPressed(KEY_ONE)) rc->gui.active_tab = GUI_TAB_INVENTORY; + if (IsKeyPressed(KEY_TWO)) rc->gui.active_tab = GUI_TAB_COMBAT; + if (IsKeyPressed(KEY_THREE)) rc->gui.active_tab = GUI_TAB_PRAYER; + if (IsKeyPressed(KEY_FOUR)) rc->gui.active_tab = GUI_TAB_SPELLBOOK; + if (IsKeyPressed(KEY_FIVE)) rc->gui.active_tab = GUI_TAB_EQUIPMENT; + + /* 9/0: replay speed control (discrete steps) */ + { + static const float speed_steps[] = { 0.5f, 1.0f, 1.667f, 5.0f, 15.0f, 50.0f, 0.0f }; + static const int num_steps = sizeof(speed_steps) / sizeof(speed_steps[0]); + if (IsKeyPressed(KEY_NINE) || IsKeyPressed(KEY_ZERO)) { + /* find current step index */ + int cur = -1; + for (int i = 0; i < num_steps; i++) { + if (speed_steps[i] == rc->ticks_per_second) { cur = i; break; } + } + if (cur < 0) cur = 2; /* default to OSRS speed if not on a step */ + if (IsKeyPressed(KEY_NINE) && cur > 0) rc->ticks_per_second = speed_steps[cur - 1]; + if (IsKeyPressed(KEY_ZERO) && cur < num_steps - 1) rc->ticks_per_second = speed_steps[cur + 1]; + } + } + + /* H key: toggle human control */ + if (IsKeyPressed(KEY_H)) { + rc->human_input.enabled = !rc->human_input.enabled; + if (!rc->human_input.enabled) { + human_input_clear_pending(&rc->human_input); + human_input_clear_move(&rc->human_input); + rc->human_input.cursor_mode = CURSOR_NORMAL; + context_menu_dismiss(&rc->context_menu); + } + fprintf(stderr, "human control: %s\n", rc->human_input.enabled ? "ON" : "OFF"); + } + + /* ESC: dismiss context menu first, then cancel spell targeting */ + if (IsKeyPressed(KEY_ESCAPE)) { + if (rc->context_menu.visible) { + context_menu_dismiss(&rc->context_menu); + } else if (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; + + /* 0. context menu: if visible, intercept click for item selection or dismissal */ + if (rc->context_menu.visible) { + ContextMenu* cm = &rc->context_menu; + int menu_h = cm->item_count * CONTEXT_MENU_ROW_H + CONTEXT_MENU_PADDING * 2; + if (mx >= cm->screen_x && mx < cm->screen_x + cm->width && + my >= cm->screen_y && my < cm->screen_y + menu_h) { + int row = (my - cm->screen_y - CONTEXT_MENU_PADDING) / CONTEXT_MENU_ROW_H; + if (row >= 0 && row < cm->item_count) + context_menu_execute(rc, row); + else + context_menu_dismiss(cm); + } else { + context_menu_dismiss(cm); + } + handled = 1; + } + + /* 1. tab bar click */ + if (!handled) + 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++) { + RenderEntity* ent = &rc->entities[ei]; + if (!render_can_human_attack_entity( + &attack_ctx, ent, ei, rc->gui.gui_entity_idx)) { + 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; + human_input_queue_spell_target( + &rc->human_input, + rc->human_input.selected_spell, + rc->human_input.pending_target_idx); + rc->human_input.cursor_mode = CURSOR_NORMAL; + } else { + human_input_queue_attack_npc( + &rc->human_input, + rc->human_input.pending_target_idx); + } + 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 while spell-targeting: walk AND cancel + (matches OSRS — clicking ground doesn't just cancel) */ + if (rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } + rc->human_input.pending_move_x = best_wx; + rc->human_input.pending_move_y = best_wy; + human_input_queue_walk(&rc->human_input, best_wx, 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_can_human_attack_entity, + &attack_ctx, + RENDER_TILE_SIZE, RENDER_HEADER_HEIGHT); + } + } + } + + /* right-click: open context menu (human mode) or cancel spell targeting */ + if (IsMouseButtonPressed(MOUSE_BUTTON_RIGHT)) { + if (rc->human_input.enabled) { + int rmx = GetMouseX(); + int rmy = GetMouseY(); + /* only open menu in game area (left of GUI panel) */ + if (rmx < rc->gui.panel_x) { + /* cancel spell targeting on right-click (OSRS behavior) */ + if (rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) + rc->human_input.cursor_mode = CURSOR_NORMAL; + context_menu_build(rc, env, rmx, rmy); + } else { + context_menu_dismiss(&rc->context_menu); + } + } else if (rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->human_input.cursor_mode = CURSOR_NORMAL; + } + } + + /* hover tile: raycast from cursor to ground every frame (3D mode only). + uses the same ray-box intersection as the click handler so the highlighted + tile always matches what a click would select. */ + rc->hover_tile_x = -1; + rc->hover_tile_y = -1; + if (rc->mode_3d) { + int hmx = GetMouseX(); + int hmy = GetMouseY(); + /* only raycast inside the game area (left of GUI panel) */ + if (hmx >= 0 && hmx < rc->gui.panel_x && + hmy >= 0 && hmy < RENDER_WINDOW_H) { + Camera3D hcam = render_build_3d_camera(rc); + Ray hray = GetScreenToWorldRay((Vector2){ (float)hmx, (float)hmy }, hcam); + float best_dist = 1e30f; + 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(hray, box); + if (col.hit && col.distance < best_dist) { + best_dist = col.distance; + rc->hover_tile_x = wx; + rc->hover_tile_y = wy; + } + } + } + } + } +} + + +/* save current env state to history ring buffer (call after each pvp_step) */ +static void render_save_snapshot(RenderClient* rc, OsrsEnv* 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, OsrsEnv* 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_io.agent_obs; + int* saved_ocean_acts = env->ocean_io.agent_actions; + float* saved_ocean_rew = env->ocean_io.agent_rewards; + unsigned char* saved_ocean_term = env->ocean_io.agent_terminals; + + *env = rc->history[rc->history_cursor]; + + env->client = saved_client; + env->collision_map = saved_cmap; + env->ocean_io.agent_obs = saved_ocean_obs; + env->ocean_io.agent_actions = saved_ocean_acts; + env->ocean_io.agent_rewards = saved_ocean_rew; + env->ocean_io.agent_terminals = 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); + + +/* populate rc->entities from env->players 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, OsrsEnv* 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; + /* detect Zuk presence for object variant swap */ + rc->zuk_active = 0; + for (int zi = 0; zi < count; zi++) { + if (rc->entities[zi].npc_def_id == 7706) { rc->zuk_active = 1; break; } + } + } else { + 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]); + } + } +} + + +/** + * Call BEFORE pvp_step to record pre-tick positions for movement direction. + */ +static void render_pre_tick(RenderClient* rc, OsrsEnv* 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, OsrsEnv* 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) { + /* distance to destination in tiles — uses dest center, not SW anchor, + so large NPCs (size 5 shield) don't false-trigger the snap. */ + int tile_dx = abs(rc->sub_x[i] - new_dest_x) / 128; + int tile_dy = abs(rc->sub_y[i] - new_dest_y) / 128; + 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; + } + } + + /* shield always faces south (yaw = PI) */ + if (p->npc_def_id == 7707) { + rc->target_yaw[i] = 3.14159265f; + 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]; + int dist = render_pvp_distance_to_target(p, t); + int duration_ticks = pvp_magic_hit_delay(dist) * 30; + 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, duration_ticks, 40 * 4, 30 * 4, 16, ct, + rc->model_cache, rc->npc_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, rc->npc_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 dist = render_pvp_distance_to_target(p, t); + 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) { + gfx = GFX_RUNE_ARROW; + } else if (wpn == ITEM_TWISTED_BOW) { + gfx = GFX_DRAGON_ARROW; + } else { + gfx = GFX_BOLT; /* crossbows, default */ + } + int duration_ticks = p->used_special_this_tick + ? pvp_ranged_hit_delay_for_weapon( + dist, 1, render_pvp_ranged_spec_weapon_for_item(wpn)) * 30 + : pvp_ranged_hit_delay(dist) * 30; + /* 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, duration_ticks, 43 * 4, 31 * 4, 16, ct, + rc->model_cache, rc->npc_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, rc->npc_model_cache); + } else { + effect_spawn_spotanim(rc->effects, GFX_SPLASH, + p->x, p->y, ct, rc->anim_cache, + rc->model_cache, rc->npc_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, rc->npc_model_cache); + } else { + effect_spawn_spotanim_subtile(rc->effects, GFX_SPLASH, + fx, fy, ct, rc->anim_cache, + rc->model_cache, rc->npc_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, + ov->projectiles[i].anim_id, + ov->projectiles[i].impact_gfx_id, + ov->projectiles[i].start_delay, + ov->projectiles[i].motion_mode, + ov->projectiles[i].offset_x, + ov->projectiles[i].offset_y, + ov->projectiles[i].offset_z); + } + + /* 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] && rc->entities[player_idx].npc_def_id != 7707) { + 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; + } +} + + +/* 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, + }; +} + + + +static void render_draw_grid(RenderClient* rc, OsrsEnv* 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: area hazards and encounter adds in 2D */ + if (env->encounter_def) { + EncounterOverlay* ov = &rc->encounter_overlay; + + /* current hazard renderer: 3x3 poison cloud footprint from the SW tile. */ + for (int i = 0; i < ov->hazard_count; i++) { + if (!ov->hazards[i].active) continue; + for (int cdx = 0; cdx < 3; cdx++) { + for (int cdy = 0; cdy < 3; cdy++) { + int cx = ov->hazards[i].x + cdx; + int cy = ov->hazards[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 }); + } + + /* encounter adds: current renderer uses Zulrah snakeling colors. */ + for (int i = 0; i < ov->add_count; i++) { + if (!ov->adds[i].active) continue; + int ssx = render_world_to_screen_x_rc(rc, ov->adds[i].x); + int ssy = render_world_to_screen_y_rc(rc, ov->adds[i].y); + Color sc = ov->adds[i].variant + ? 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 || fp->start_delay > 0) continue; + float t = fp->progress; + float cur_x = fp->motion_mode == ENCOUNTER_PROJECTILE_MOTION_TARGET_ANCHORED + ? fp->dst_x : fp->src_x + (fp->dst_x - fp->src_x) * t; + float cur_y = fp->motion_mode == ENCOUNTER_PROJECTILE_MOTION_TARGET_ANCHORED + ? fp->dst_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; + } + if (fp->motion_mode != ENCOUNTER_PROJECTILE_MOTION_TARGET_ANCHORED) + DrawLine(psx, psy, pcx, pcy, pc); + DrawCircle(pcx, pcy, 4.0f, pc); + } + } +} + + +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, OsrsEnv* 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); + } + } +} + + +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); + } + } +} + + +/* 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); + } + } +} + + +static void render_draw_header(RenderClient* rc, OsrsEnv* env) { + DrawRectangle(0, 0, RENDER_WINDOW_W, RENDER_HEADER_HEIGHT, COLOR_HEADER_BG); + + /* left: tick + speed + pause/rewind */ + const char* speed_txt; + if (rc->ticks_per_second <= 0.0f) speed_txt = "max"; + else if (rc->ticks_per_second < 1.0f) speed_txt = TextFormat("%.1f t/s", rc->ticks_per_second); + else speed_txt = TextFormat("%.0f t/s", rc->ticks_per_second); + 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); + } +} + + +/** 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, OsrsEnv* 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 */ + + +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 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; +} + + +/** + * 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) { + fprintf(stderr, "render: missing NPC model mapping for npc_def_id=%d\n", npc_def_id); + abort(); + } + model_id = mapping->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) { + fprintf(stderr, "render: npc_def_id=%d mapped model %u is missing from loaded caches\n", + npc_def_id, model_id); + abort(); + } + 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; +} + + +/** + * 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) + + /* current hazard renderer: object 11700 centered on a 3x3 damage area */ + float ms = 1.0f / 128.0f; + for (int i = 0; i < ov->hazard_count; i++) { + if (!ov->hazards[i].active) continue; + float ground = OV_GROUND(ov->hazards[i].x + 1, ov->hazards[i].y + 1); + + if (rc->cloud_model_ready) { + float cx = (float)ov->hazards[i].x + 1.5f; + float cz = -(float)(ov->hazards[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->hazards[i].x + cdx); + float fz = -(float)(ov->hazards[i].y + cdy + 1); + float tg = OV_GROUND(ov->hazards[i].x + cdx, ov->hazards[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 }); + } + + /* encounter adds: current renderer uses the snakeling model or cubes. */ + for (int i = 0; i < ov->add_count; i++) { + if (!ov->adds[i].active) continue; + float ground = OV_GROUND(ov->adds[i].x, ov->adds[i].y); + float sx = (float)ov->adds[i].x + 0.5f; + float sz = -(float)(ov->adds[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->adds[i].variant + ? 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 || fp->start_delay > 0) 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->anim_id >= 0 && fp->model_id > 0) { + OsrsModel* om = render_get_projectile_osrs_model(rc, fp->model_id); + AnimSequence* seq = render_get_anim_sequence(rc, (uint16_t)fp->anim_id); + if (!fp->anim_state || !seq || seq->frame_count <= 0 || !om->face_indices) { + fprintf(stderr, "render: projectile model %u cannot render animation %d\n", + fp->model_id, fp->anim_id); + abort(); + } + if (fp->anim_frame >= seq->frame_count) fp->anim_frame = 0; + AnimSequenceFrame* sf = &seq->frames[fp->anim_frame]; + AnimFrameBase* fb = render_get_framebase(rc, sf->frame.framebase_id); + if (!fb) { + fprintf(stderr, "render: projectile animation framebase %u is missing\n", + sf->frame.framebase_id); + abort(); + } + anim_apply_frame(fp->anim_state, om->base_vertices, &sf->frame, fb); + anim_update_mesh(om->mesh.vertices, fp->anim_state, + om->face_indices, om->mesh.triangleCount); + UpdateMeshBuffer(om->mesh, 0, om->mesh.vertices, + om->mesh.triangleCount * 9 * sizeof(float), 0); + proj_model = &om->model; + } else if (fp->model_id > 0) { + proj_model = render_get_proj_model(rc, fp->model_id); + } + if (!proj_model) { + /* model_id 0 intentionally falls back to the generic style mesh */ + 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 = render_projectile_transform_offset( + pms, pms, pms, fp->yaw, fp->pitch, pos, + fp->offset_x, fp->offset_y, fp->offset_z); + DrawModel(*proj_model, (Vector3){0,0,0}, 1.0f, WHITE); + rlEnableBackfaceCulling(); + } + + /* trail line from source to current position */ + if (rc->show_debug && + fp->motion_mode != ENCOUNTER_PROJECTILE_MOTION_TARGET_ANCHORED) { + 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) */ + { + /* use post-Zuk objects (prison walls removed) when Zuk is present */ + ObjectMesh* obj = (rc->objects_zuk && rc->objects_zuk->loaded && rc->zuk_active) + ? rc->objects_zuk : rc->objects; + if (obj && obj->loaded) { + rlDisableBackfaceCulling(); + DrawModel(obj->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; + float min_model_y = 1000000.0f; + float max_model_y = -1000000.0f; + /* stack arrays for projection — max 200 sampled points */ + int hull_xs[256], hull_ys[256]; + for (int vi = 0; vi < nv; vi++) { + float vy = comp->mesh.vertices[vi * 3 + 1]; + if (vy < min_model_y) min_model_y = vy; + if (vy > max_model_y) max_model_y = vy; + } + if (nv > 0 && min_model_y <= max_model_y) { + rc->entity_visual_mid_y[i] = ground + min_model_y * ms + + (max_model_y - min_model_y) * ms * 0.5f; + rc->entity_visual_top_y[i] = ground + max_model_y * ms; + } else { + int ent_size = (ep->entity_type == ENTITY_NPC && ep->npc_size > 1) ? ep->npc_size : 1; + rc->entity_visual_mid_y[i] = ground + 0.75f + 0.25f * (float)ent_size; + rc->entity_visual_top_y[i] = ground + 1.5f + 0.5f * (float)ent_size; + } + 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 && rc->npc_model_cache) + om = model_cache_get(rc->npc_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; + + /* 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 = render_projectile_transform(sx, sx, sz, yaw, pitch, + (Vector3){ ex, ey, ez }); + } else { + t = MatrixMultiply(MatrixScale(-sx, sx, sz), 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); + } + } + + /* hover tile outline: semi-transparent cyan border on the tile under cursor. + similar to RuneLite's "Tile Indicators" plugin. drawn as 4 lines slightly + above ground to avoid z-fighting with terrain/floor. */ + if (rc->hover_tile_x >= 0) { + float htx = (float)rc->hover_tile_x; + float htz = -(float)(rc->hover_tile_y + 1); + float hgy = rc->terrain + ? terrain_height_avg(rc->terrain, rc->hover_tile_x, rc->hover_tile_y) + : 2.0f; + float hy = hgy + 0.03f; /* slight offset above ground */ + Color hcol = CLITERAL(Color){ 0, 220, 220, 180 }; + DrawLine3D((Vector3){ htx, hy, htz }, (Vector3){ htx + 1.0f, hy, htz }, hcol); + DrawLine3D((Vector3){ htx + 1.0f, hy, htz }, (Vector3){ htx + 1.0f, hy, htz + 1.0f }, hcol); + DrawLine3D((Vector3){ htx + 1.0f, hy, htz + 1.0f }, (Vector3){ htx, hy, htz + 1.0f }, hcol); + DrawLine3D((Vector3){ htx, hy, htz + 1.0f }, (Vector3){ htx, hy, htz }, hcol); + } + + EndMode3D(); +} + + +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(); +} + + +/** + * 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, OsrsEnv* 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. + head height scales with NPC size — larger models need higher overhead bars. + approximate: model height in tiles ~ 1.5 + 0.5*size (player=2.0, zuk=5.0). */ + float px, pz, ground; + render_get_visual_pos(rc, i, &px, &pz, &ground); + int ent_size = (p->entity_type == ENTITY_NPC && p->npc_size > 1) ? p->npc_size : 1; + float head_y = rc->entity_visual_top_y[i]; + float abdomen_y = rc->entity_visual_mid_y[i]; + if (head_y <= abdomen_y) { + head_y = ground + 1.5f + 0.5f * (float)ent_size; + abdomen_y = ground + 0.75f + 0.25f * (float)ent_size; + } + 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: width scales with NPC size, matching OSRS HealthBarDefinition + widths (30 for size 1, up to ~160 for size 7). plain colored rectangle + matches the no-sprite fallback path in the engine. */ + if (env->tick < rc->hp_bar_visible_until[i]) { + /* OSRS bar widths by common NPC sizes (from cache HealthBarDefinitions): + size 1→30, 2→40, 3→50, 4→60, 5→80, 7→120 */ + static const int BAR_WIDTH_BY_SIZE[] = { + 30, 30, 40, 50, 60, 80, 100, 120 + }; + int bw_idx = ent_size; + if (bw_idx > 7) bw_idx = 7; + int bar_w = BAR_WIDTH_BY_SIZE[bw_idx]; + 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); + } + } + } + } +} + + +void pvp_render(OsrsEnv* 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 */ + int tgt = is->interaction.target_slot; + if (tgt >= 0 && tgt < INF_MAX_NPCS) { + InfNPC* tn = &is->npcs[tgt]; + const char* tname = inferno_npc_name(INF_NPC_DEF_IDS[tn->type]); + DrawText(TextFormat("TARGET: %s [#%d]", tname, tgt), 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); + /* plumb spell-targeting state into GUI so the selected spell shows a + highlight border while awaiting enemy click */ + rc->gui.pending_spell_highlight = -1; + if (rc->human_input.cursor_mode == CURSOR_SPELL_TARGET) { + rc->gui.pending_spell_highlight = rc->human_input.selected_spell_gui_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; + } + } + } + } + + /* right-click context menu: drawn last so it renders on top of everything */ + context_menu_draw(rc); + + EndDrawing(); +} + +#endif /* OSRS_RENDER_H */ diff --git a/ocean/osrs/osrs_special_attacks.h b/ocean/osrs/osrs_special_attacks.h new file mode 100644 index 0000000000..59c777c821 --- /dev/null +++ b/ocean/osrs/osrs_special_attacks.h @@ -0,0 +1,461 @@ +/** + * @fileoverview osrs_special_attacks.h — weapon special attack dispatch. + * + * pure function that resolves a special attack given pre-computed combat stats. + * encounters call osrs_resolve_spec() and apply the returned SpecResult. + * osrs_spec_cost() returns spec energy cost by weapon item index (0 = no spec). + * + * SHARED FUNCTIONS: + * osrs_spec_cost(weapon_idx) spec energy cost for a weapon + * osrs_resolve_spec(weapon, ...) resolve spec attack, return result + * osrs_blowpipe_spec_resolve(...) blowpipe spec helper + * + * ref: .refs/osrs-dps-calc/src/lib/ for multipliers, + * .refs/osrs-sdk/src/weapons/ for behavior, + * osrs_pvp_combat.h for existing claws/VLS implementations, + * encounter_zulrah.h for eye of ayak/MSB/blowpipe specs. + */ + +#ifndef OSRS_SPECIAL_ATTACKS_H +#define OSRS_SPECIAL_ATTACKS_H + +#include "osrs_combat.h" +#include "osrs_items.h" + + +#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 + +/* Blowpipe special helper used by encounter code and focused tests. */ +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; +} + + +typedef struct { + int num_hits; /* number of hits (1-4) */ + int damage[4]; /* per-hit damage values */ + int total_damage; /* sum of damage[] */ + int heal; /* HP healed (blowpipe, SGS) */ + int def_drain; /* def levels to drain (DWH=30%, BGS=dmg) */ + int magic_def_drain; /* magic def bonus drained (eye of ayak) */ + int prayer_restore; /* prayer points restored (SGS) */ + int freeze_ticks; /* freeze duration (ZGS) */ + int spec_cost; /* energy consumed */ + int attack_speed_override; /* 0 = use weapon speed, >0 = override */ +} SpecResult; + + +static inline int osrs_spec_cost(int weapon_item_idx) { + switch (weapon_item_idx) { + /* melee */ + case ITEM_AGS: return 50; + case ITEM_DRAGON_CLAWS: return 50; + case ITEM_STATIUS_WARHAMMER: return 35; + case ITEM_BGS: return 50; + case ITEM_ZGS: return 50; + case ITEM_SGS: return 50; + case ITEM_ANCIENT_GS: return 50; + case ITEM_VESTAS: return 25; + case ITEM_VOIDWAKER: return 50; + case ITEM_GRANITE_MAUL: return 50; + case ITEM_DRAGON_DAGGER: return 25; + case ITEM_ELDER_MAUL: return 50; + /* ranged */ + case ITEM_TOXIC_BLOWPIPE: return 50; + case ITEM_MAGIC_SHORTBOW_I: return 50; + case ITEM_DARK_BOW: return 55; + case ITEM_ZARYTE_CROSSBOW: return 75; + case ITEM_HEAVY_BALLISTA: return 65; + case ITEM_MORRIGANS_JAVELIN: return 50; + case ITEM_ARMADYL_CROSSBOW: return 50; + /* magic */ + case ITEM_VOLATILE_STAFF: return 55; + case ITEM_EYE_OF_AYAK: return 50; + default: return 0; + } +} +/** Resolve a special attack by weapon item index. */ +static inline SpecResult osrs_resolve_spec( + int weapon_item_idx, int att_roll, int max_hit, + int def_roll, int target_def_level, uint32_t* rng_state +) { + SpecResult r = {0, {0, 0, 0, 0}, 0, 0, 0, 0, 0, 0, 0, 0}; + + switch (weapon_item_idx) { + + /* AGS: 2x accuracy, 1.375x max hit (godsword 1.1 * 1.25). + ref: osrs-dps-calc [2,1] acc, [11,10]*[5,4] str */ + case ITEM_AGS: { + int spec_att = att_roll * 2; + int spec_max = max_hit * 11 / 8; + r.spec_cost = 50; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) + r.damage[0] = encounter_rand_int(rng_state, spec_max + 1); + r.total_damage = r.damage[0]; + break; + } + + /* dragon claws: 4-hit cascade at BASE accuracy (no multiplier). + each successive roll uses a lower total damage range, split into 4 hitsplats. + ref: osrs-dps-calc src/lib/dists/claws.ts dClawDist() + generateTotals: low = floor(max * (4-accRoll) / 4), high = max + low - 1 + roll 0: total in [max, 2*max-1], split [total/2, total/4, total/8, total/8+1] + roll 1: total in [3*max/4, 7*max/4-1], split [total/2, total/4, total/4+1, 0] + roll 2: total in [max/2, 3*max/2-1], split [total/2, total/2+1, 0, 0] + roll 3: total in [max/4, 5*max/4-1], split [total+1, 0, 0, 0] + all miss: 2/3 chance [1,1,0,0], 1/3 chance [0,0,0,0] */ + case ITEM_DRAGON_CLAWS: { + float hit_chance = osrs_hit_chance(att_roll, def_roll); /* no acc mult */ + r.spec_cost = 50; + r.num_hits = 4; + + int roll1 = encounter_rand_float(rng_state) < hit_chance; + int roll2 = encounter_rand_float(rng_state) < hit_chance; + int roll3 = encounter_rand_float(rng_state) < hit_chance; + int roll4 = encounter_rand_float(rng_state) < hit_chance; + + if (roll1) { + int low = max_hit; + int high = max_hit + low - 1; + int total = low + encounter_rand_int(rng_state, high - low + 1); + r.damage[0] = total / 2; + r.damage[1] = total / 4; + r.damage[2] = total / 8; + r.damage[3] = total / 8 + 1; + } else if (roll2) { + int low = max_hit * 3 / 4; + int high = max_hit + low - 1; + int total = low + encounter_rand_int(rng_state, high - low + 1); + r.damage[0] = total / 2; + r.damage[1] = total / 4; + r.damage[2] = total / 4 + 1; + r.damage[3] = 0; + } else if (roll3) { + int low = max_hit / 2; + int high = max_hit + low - 1; + int total = low + encounter_rand_int(rng_state, high - low + 1); + r.damage[0] = total / 2; + r.damage[1] = total / 2 + 1; + r.damage[2] = 0; + r.damage[3] = 0; + } else if (roll4) { + int low = max_hit / 4; + int high = max_hit + low - 1; + int total = low + encounter_rand_int(rng_state, high - low + 1); + r.damage[0] = total + 1; + r.damage[1] = 0; + r.damage[2] = 0; + r.damage[3] = 0; + } else { + /* all 4 rolls miss: 2/3 chance [1,1,0,0], 1/3 chance [0,0,0,0] */ + if (encounter_rand_int(rng_state, 3) < 2) { + r.damage[0] = 1; r.damage[1] = 1; + } + r.damage[2] = 0; r.damage[3] = 0; + } + r.total_damage = r.damage[0] + r.damage[1] + r.damage[2] + r.damage[3]; + break; + } + + /* statius warhammer (LMS): 1.25x accuracy, 1.25x str, 30% def drain on hit. + ref: osrs-dps-calc PlayerVsNPCCalc.ts [5,4] acc, [5,4] str */ + case ITEM_STATIUS_WARHAMMER: { + int spec_att = att_roll * 5 / 4; /* 1.25x */ + int spec_max = max_hit * 5 / 4; /* 1.25x */ + r.spec_cost = 35; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) { + r.damage[0] = encounter_rand_int(rng_state, spec_max + 1); + r.def_drain = target_def_level * 30 / 100; /* 30% of current def */ + } + r.total_damage = r.damage[0]; + break; + } + + /* BGS: 2.0x accuracy, 1.21x str (godsword 1.1 * 1.1), drain def by damage. + cascade drain order: def > str > atk > magic > ranged (encounter applies). + ref: osrs-dps-calc [2,1] acc, [11,10]^2 str */ + case ITEM_BGS: { + int spec_att = att_roll * 2; + int spec_max = max_hit * 121 / 100; + r.spec_cost = 50; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) { + r.damage[0] = encounter_rand_int(rng_state, spec_max + 1); + r.def_drain = r.damage[0]; /* drain def by damage dealt */ + } + r.total_damage = r.damage[0]; + break; + } + + /* ZGS: 2x accuracy, 1.1x str (godsword), 32-tick freeze on hit. + ref: osrs-dps-calc [2,1] acc, [11,10] str */ + case ITEM_ZGS: { + int spec_att = att_roll * 2; + int spec_max = max_hit * 11 / 10; + r.spec_cost = 50; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) { + r.damage[0] = encounter_rand_int(rng_state, spec_max + 1); + if (r.damage[0] > 0) r.freeze_ticks = 32; + } + r.total_damage = r.damage[0]; + break; + } + + /* SGS: 2.0x accuracy, 1.1x str (godsword), heals floor(dmg/2) HP, + restores floor(dmg/4) prayer. + ref: osrs-dps-calc [2,1] acc, [11,10] str */ + case ITEM_SGS: { + int spec_att = att_roll * 2; + int spec_max = max_hit * 11 / 10; + r.spec_cost = 50; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) + r.damage[0] = encounter_rand_int(rng_state, spec_max + 1); + r.total_damage = r.damage[0]; + r.heal = r.total_damage / 2; + r.prayer_restore = r.total_damage / 4; + break; + } + + /* ancient godsword: 2x accuracy, 1.1x str (godsword). + blood prison effect: on hit, after 8 ticks deals 25 + heals 25. + not implemented (dps-calc also marks PARTIALLY_IMPLEMENTED). + ref: osrs-dps-calc [2,1] acc, [11,10] str */ + case ITEM_ANCIENT_GS: { + int spec_att = att_roll * 2; + int spec_max = max_hit * 11 / 10; + r.spec_cost = 50; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) + r.damage[0] = encounter_rand_int(rng_state, spec_max + 1); + r.total_damage = r.damage[0]; + break; + } + + /* VLS "Feint": 20-120% of (1.2x base max), accuracy vs 25% def roll. + ref: osrs_pvp_combat.h:928-962 */ + case ITEM_VESTAS: { + int vls_max = max_hit * 6 / 5; /* 1.2x */ + int vls_min = max_hit / 5; /* 0.2x */ + int reduced_def = def_roll / 4; /* 25% of def roll */ + r.spec_cost = 25; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(att_roll, reduced_def)) + r.damage[0] = vls_min + encounter_rand_int(rng_state, vls_max - vls_min + 1); + r.total_damage = r.damage[0]; + break; + } + + /* voidwaker: guaranteed magic damage at 50-150% of base max hit, vs 25% def. + ref: osrs_pvp_combat.h:913-925, osrs wiki "voidwaker" */ + case ITEM_VOIDWAKER: { + int vw_min = max_hit / 2; /* 50% */ + int vw_max = max_hit * 3 / 2; /* 150% */ + int reduced_def = def_roll / 4; /* 25% of def roll */ + r.spec_cost = 50; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(att_roll, reduced_def)) + r.damage[0] = vw_min + encounter_rand_int(rng_state, vw_max - vw_min + 1); + r.total_damage = r.damage[0]; + break; + } + + /* granite maul: 1.0x accuracy, 1.0x str, instant (resets attack timer). + ref: osrs wiki "granite maul" */ + case ITEM_GRANITE_MAUL: { + r.spec_cost = 50; + r.num_hits = 1; + r.attack_speed_override = 1; /* instant */ + if (encounter_rand_float(rng_state) < osrs_hit_chance(att_roll, def_roll)) + r.damage[0] = encounter_rand_int(rng_state, max_hit + 1); + r.total_damage = r.damage[0]; + break; + } + + /* dragon dagger: [23,20] = 1.15x accuracy and 1.15x str, 2 independent hits. + ref: osrs-dps-calc PlayerVsNPCCalc.ts:300 */ + case ITEM_DRAGON_DAGGER: { + int spec_att = att_roll * 23 / 20; /* 1.15x per dps-calc */ + int spec_max = max_hit * 23 / 20; /* 1.15x */ + r.spec_cost = 25; + r.num_hits = 2; + for (int i = 0; i < 2; i++) { + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) + r.damage[i] = encounter_rand_int(rng_state, spec_max + 1); + } + r.total_damage = r.damage[0] + r.damage[1]; + break; + } + + /* elder maul: 1.25x accuracy, 1.0x str, 35% def drain on hit. + ref: osrs wiki "elder maul" */ + case ITEM_ELDER_MAUL: { + int spec_att = att_roll * 5 / 4; + r.spec_cost = 50; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) { + r.damage[0] = encounter_rand_int(rng_state, max_hit + 1); + r.def_drain = target_def_level * 35 / 100; + } + r.total_damage = r.damage[0]; + break; + } + + /* blowpipe: 2x accuracy, 1.5x max hit, heal 50% of damage. + ref: osrs-sdk Blowpipe.ts, osrs_combat.h (moved here) */ + case ITEM_TOXIC_BLOWPIPE: { + int spec_att = att_roll * 2; + int spec_max = max_hit * 3 / 2; + r.spec_cost = 50; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) + r.damage[0] = encounter_rand_int(rng_state, spec_max + 1); + r.total_damage = r.damage[0]; + r.heal = r.total_damage / 2; + break; + } + + /* MSB(i) Snapshot: 10/7 accuracy boost (~1.43x), 2 arrows. + ref: encounter_zulrah.h:1052-1075 */ + case ITEM_MAGIC_SHORTBOW_I: { + int spec_att = att_roll * 10 / 7; + r.spec_cost = 50; + r.num_hits = 2; + for (int i = 0; i < 2; i++) { + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) + r.damage[i] = encounter_rand_int(rng_state, max_hit + 1); + } + r.total_damage = r.damage[0] + r.damage[1]; + break; + } + + /* dark bow: 1.0x accuracy, 1.5x str, 2 arrows, min 8 each (dragon arrows). + ref: osrs_pvp_combat.h:989-1015 */ + case ITEM_DARK_BOW: { + int spec_max = max_hit * 3 / 2; + if (spec_max > 48) spec_max = 48; + r.spec_cost = 55; + r.num_hits = 2; + for (int i = 0; i < 2; i++) { + if (encounter_rand_float(rng_state) < osrs_hit_chance(att_roll, def_roll)) { + int dmg = encounter_rand_int(rng_state, spec_max + 1); + r.damage[i] = dmg < 8 ? 8 : dmg; + } else { + r.damage[i] = 8; /* guaranteed min on miss */ + } + } + r.total_damage = r.damage[0] + r.damage[1]; + break; + } + + /* heavy ballista: 1.25x accuracy, 1.25x str. + ref: osrs-dps-calc [5,4] acc, [5,4] str */ + case ITEM_HEAVY_BALLISTA: { + int spec_att = att_roll * 5 / 4; + int spec_max = max_hit * 5 / 4; + r.spec_cost = 65; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) + r.damage[0] = encounter_rand_int(rng_state, spec_max + 1); + r.total_damage = r.damage[0]; + break; + } + + /* zaryte crossbow: 2.0x accuracy, guaranteed enhanced bolt proc. + the bolt proc system (osrs_bolt_procs.h) handles the actual damage enhancement. + encounters pass is_zcb_spec=1 to osrs_resolve_bolt_proc() after this spec. + ref: osrs-dps-calc PlayerVsNPCCalc.ts:580, bolts.ts */ + case ITEM_ZARYTE_CROSSBOW: { + int spec_att = att_roll * 2; + r.spec_cost = 75; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) + r.damage[0] = encounter_rand_int(rng_state, max_hit + 1); + r.total_damage = r.damage[0]; + break; + } + + /* morrigan's javelin: APPROXIMATE — real mechanic is initial hit + bleed + (5x initial over 5 ticks). dps-calc doesn't implement this weapon. + using VLS-like pattern for LMS sim: 20-120% max, vs 25% def roll. + different LMS variant (item 22636) vs wilderness variant exists. */ + case ITEM_MORRIGANS_JAVELIN: { + int morr_max = max_hit * 6 / 5; + int morr_min = max_hit / 5; + int reduced_def = def_roll / 4; + r.spec_cost = 50; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(att_roll, reduced_def)) + r.damage[0] = morr_min + encounter_rand_int(rng_state, morr_max - morr_min + 1); + r.total_damage = r.damage[0]; + break; + } + + /* ACB: 2x accuracy, normal damage, no special effect. + ref: osrs wiki "armadyl crossbow", PvP-only spec (dps-calc marks UNIMPLEMENTED for PvNPC) */ + case ITEM_ARMADYL_CROSSBOW: { + int spec_att = att_roll * 2; + r.spec_cost = 50; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) + r.damage[0] = encounter_rand_int(rng_state, max_hit + 1); + r.total_damage = r.damage[0]; + break; + } + + /* volatile nightmare staff: 1.5x accuracy, max hit = min(58, 58*floor(magic_lvl/99)+1). + at 99 magic (our sim): max 58. below 99: max 1 (hard level gate). + ref: osrs-dps-calc PlayerVsNPCCalc.ts:924-925 */ + case ITEM_VOLATILE_STAFF: { + int spec_att = att_roll * 3 / 2; + /* max hit = min(58, 58 * floor(magic_level/99) + 1). + at 99 magic: min(58, 59) = 58. we assume 99 magic. */ + int vol_max = 58; + r.spec_cost = 55; + r.num_hits = 1; + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) + r.damage[0] = encounter_rand_int(rng_state, vol_max + 1); + r.total_damage = r.damage[0]; + break; + } + + /* eye of ayak Soul Rend: 2x accuracy, 1.3x max hit, drains target magic def. + ref: encounter_zulrah.h:1098-1122 */ + case ITEM_EYE_OF_AYAK: { + int spec_att = att_roll * 2; + int spec_max = max_hit * 13 / 10; + r.spec_cost = 50; + r.num_hits = 1; + r.attack_speed_override = 5; /* 5-tick, slower than normal */ + if (encounter_rand_float(rng_state) < osrs_hit_chance(spec_att, def_roll)) { + r.damage[0] = encounter_rand_int(rng_state, spec_max + 1); + r.magic_def_drain = r.damage[0]; /* drain magic def by damage */ + } + r.total_damage = r.damage[0]; + break; + } + + default: + break; + } + + return r; +} + +#endif /* OSRS_SPECIAL_ATTACKS_H */ diff --git a/ocean/osrs/osrs_terrain.h b/ocean/osrs/osrs_terrain.h new file mode 100644 index 0000000000..6387bbc4d1 --- /dev/null +++ b/ocean/osrs/osrs_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_TERRAIN_H +#define OSRS_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_TERRAIN_H */ diff --git a/ocean/osrs/osrs_types.h b/ocean/osrs/osrs_types.h new file mode 100644 index 0000000000..9d3036231a --- /dev/null +++ b/ocean/osrs/osrs_types.h @@ -0,0 +1,1071 @@ +/** + * @fileoverview osrs_types.h — shared core types for the current ocean OSRS envs. + * + * Contains the enums, structs, and constants used by the current simulation + * layer. Not every field is meaningful for every encounter; treat this as a + * shared ABI, not universal game truth. + */ + +#ifndef OSRS_TYPES_H +#define OSRS_TYPES_H + +#include +#include +#include +#include +#include +#include "osrs_interaction.h" + +#define NUM_AGENTS 2 +#define MAX_PENDING_HITS 8 +#define HISTORY_SIZE 5 + +#define TICK_DURATION_MS 600 +#define MAX_EPISODE_TICKS 300 + +#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 + +#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 + +#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 + +// Number of equipment slots (HEAD, CAPE, NECK, AMMO, WEAPON, SHIELD, BODY, LEGS, HANDS, FEET, RING) +#define NUM_GEAR_SLOTS 11 +// 8 action heads: one decision per head per tick. no click encoding. +// current ocean envs use a loadout preset plus separate combat/prayer/etc. heads. + +#define NUM_ACTION_HEADS 8 + +// Action head indices +#define HEAD_LOADOUT 0 +#define HEAD_COMBAT 1 // current combined attack + movement head for loadout-backed envs +#define HEAD_OVERHEAD 2 +#define HEAD_FOOD 3 +#define HEAD_POTION 4 +#define HEAD_KARAMBWAN 5 +#define HEAD_VENG 6 +#define HEAD_OFFENSIVE 7 // toggle piety/rigour/augury — agent-controlled, no longer auto-assigned + +// 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 // ENCOUNTER_OVERHEAD_DIM_PVP: no_change, toggle_{melee,ranged,magic,smite,redemption} +#define FOOD_DIM 2 // NONE, EAT +#define POTION_DIM 5 // PvP head only: NONE, BREW, RESTORE, COMBAT, RANGED +#define KARAMBWAN_DIM 2 // NONE, EAT +#define VENG_DIM 2 // NONE, CAST +#define OFFENSIVE_DIM 4 // ENCOUNTER_OFFENSIVE_DIM: no_change, toggle_{piety,rigour,augury} + +// Total action mask size: sum of all head dims +#define ACTION_MASK_SIZE (LOADOUT_DIM + COMBAT_DIM + OVERHEAD_DIM + \ + FOOD_DIM + POTION_DIM + KARAMBWAN_DIM + VENG_DIM + OFFENSIVE_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, + OFFENSIVE_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 + 1 voidwaker flag + 7 reward signals = 190 */ +#define SLOT_NUM_OBSERVATIONS 190 +// 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 + +typedef enum { + ATTACK_STYLE_NONE = 0, + ATTACK_STYLE_MELEE, + ATTACK_STYLE_RANGED, + ATTACK_STYLE_MAGIC +} AttackStyle; + +typedef enum { + MELEE_STYLE_STAB = 0, + MELEE_STYLE_SLASH, + MELEE_STYLE_CRUSH, +} MeleeStyle; + +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; + +/* combat stance — the axis the player picks per-weapon in the OSRS combat tab. + orthogonal to AttackStyle (damage category). the stance drives: + - invisible level bonuses (att / str / def) per osrs wiki "Combat Options" + - attack speed modifier (rapid = base - 1) + - attack range modifier (longrange = base + 2) + the 4 melee stances keep the existing enum order where 0 means accurate. */ +typedef enum { + FIGHT_STYLE_ACCURATE = 0, /* melee: +3 att. ranged: +3 att. powered staff: +3 magic. */ + FIGHT_STYLE_AGGRESSIVE, /* melee: +3 str. */ + FIGHT_STYLE_CONTROLLED, /* melee: +1 att/str/def. */ + FIGHT_STYLE_DEFENSIVE, /* melee: +3 def. */ + FIGHT_STYLE_RAPID, /* ranged: speed - 1, no bonus. */ + FIGHT_STYLE_LONGRANGE, /* ranged / powered staff: +3 def, +2 range. powered staff also +1 magic. */ + FIGHT_STYLE_AUTOCAST, /* magic (non-powered staff): no invisible bonus. */ + FIGHT_STYLE_DEFENSIVE_AUTOCAST, /* magic: no invisible bonus (defence XP split only). */ +} FightStyle; + +typedef enum { + MELEE_BONUS_STAB = 0, + MELEE_BONUS_SLASH, + MELEE_BONUS_CRUSH +} MeleeBonusType; + +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; + +/** 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 intent values. + The PvP potion action head still only exposes POTION_DIM = 5. + Extra values below are human-mode/debug intents for encounters that use + different consumable layouts. */ +typedef enum { + POTION_NONE = 0, + POTION_BREW, + POTION_RESTORE, + POTION_COMBAT, + POTION_RANGED, + POTION_ANTIVENOM, /* zulrah only — outside PvP action space (POTION_DIM=5) */ + POTION_BASTION, /* inferno human mode only */ + POTION_STAMINA, /* inferno human mode only */ + POTION_PRAYER_POT, /* distinct from super restore; not auto-aliased */ +} 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; + +/* Slot-based gear bonus struct used by the current ocean envs. same data as + EquipmentBonuses (osrs_combat.h) but with a different naming convention + (stab_attack vs attack_stab). the adapter compute_slot_gear_bonuses() + in osrs_pvp_gear.h bridges them. */ +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; + +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; + +typedef enum { + OSRS_MAGIC_ATTACK_NONE = 0, + OSRS_MAGIC_ATTACK_ANCIENT_ICE, + OSRS_MAGIC_ATTACK_ANCIENT_BLOOD, + OSRS_MAGIC_ATTACK_STANDARD_SPELL, + OSRS_MAGIC_ATTACK_POWERED_STAFF, +} OsrsMagicAttackKind; + +typedef enum { + OSRS_TARGET_NONE = 0, + OSRS_TARGET_PLAYER, + OSRS_TARGET_NPC, +} OsrsTargetKind; + +typedef struct { + OsrsTargetKind kind; + int id; +} OsrsTargetRef; + +typedef enum { + OSRS_RECOIL_SOURCE_NONE = 0, + OSRS_RECOIL_SOURCE_RING_OF_RECOIL, + OSRS_RECOIL_SOURCE_RING_OF_SUFFERING_RI, +} OsrsRecoilSource; + +typedef enum { + OSRS_SPEC_REGEN_MODE_NORMAL = 0, + OSRS_SPEC_REGEN_MODE_LIGHTBEARER, +} OsrsSpecRegenMode; + +typedef struct { + uint32_t effect_mask; + uint8_t weapon_item; + uint8_t ring_item; + uint8_t shield_item; + uint8_t virtus_piece_count; + uint8_t dharok_piece_count; + OsrsRecoilSource recoil_source; + OsrsSpecRegenMode spec_regen_mode; +} OsrsEquipmentEffectProfile; + +typedef struct { + int special_regen_ticks; + int recoil_charges; + uint8_t confliction_is_primed; + uint8_t confliction_weapon_item; + OsrsMagicAttackKind confliction_magic_kind; + OsrsTargetRef confliction_target; +} OsrsItemEffectState; + +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 spec_regen_active; + int spec_armed; /* 1 = next attack uses special (shared across encounters) */ + OsrsInteraction interaction; /* shared entity interaction state */ + OsrsItemEffectState item_effect_state; + + // 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 bastion_doses; + int stamina_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; + + // Prayer and style + OverheadPrayer prayer; + OffensivePrayer offensive_prayer; + FightStyle fight_style; + int prayer_drain_counter; // Accumulates drain, triggers at drain_resistance + /* activation-tick bookkeeping: OSRS does not drain a prayer on the tick it + was activated (wiki: "the game does not drain prayer for prayers on the + tick they are activated"). these flags are set to 1 on OFF→ON transitions + in encounter_apply_{overhead,offensive}_action() and cleared by + encounter_drain_all_prayers(). required for 1-tick prayer flicking. */ + uint8_t prayer_just_activated; + uint8_t offensive_prayer_just_activated; + + // 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; + + // 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; + OsrsEquipmentEffectProfile equipment_effect_profile; + 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; + +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; + 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 */ + /* Zuk diagnostics */ + float behind_shield_pct; /* fraction of Zuk ticks behind shield */ + float zuk_hp_remaining; /* Zuk HP at episode end (0 if killed) */ + float min_zuk_hp_seen; /* lowest Zuk HP reached during the episode */ + float hp_restored; /* HP restored to enemies (healers + mager) this episode */ + float zuk_healer_damage; /* total damage dealt to Zuk healers this episode */ + /* Inferno action noop rates by named head. */ + float noop_move; + float noop_prayer; + float noop_target; + float noop_gear; + float noop_eat; + float noop_potion; + float noop_spell; + float noop_spec; + /* per-NPC-type stats (14 types each, for wandb only — not shown on dashboard) */ + float prayer_correct_by_type[14]; + float attacks_by_type[14]; + float dmg_from_type[14]; + float killed_by_type[14]; + float start_wave; /* config start_wave (for score formula branching) */ + float n; +} Log; + +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 + 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; + + 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 */ + + 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] */ + + 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] */ + + 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) */ + + int prev_dist_to_target; /* previous tick distance for flee tracking */ + int target_fleeing_ticks; /* consecutive ticks distance has been increasing */ + + 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; + +typedef struct { + int is_pvp_arena; + 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; + float gear_tier_weights[4]; /* 4 tiers, sum to 1.0 */ +} OsrsPvpRuntime; + +typedef struct { + float* agent_obs; /* OCEAN_OBS_SIZE floats (normalized obs + mask) */ + float* agent_obs_p1; /* player 1 obs when self-play is enabled */ + unsigned char* selfplay_mask; /* 1 byte: 1 when this env is in self-play */ + int* agent_actions; /* NUM_ACTION_HEADS ints (agent 0 actions) */ + float* agent_rewards; /* 1 float (agent 0 reward) */ + unsigned char* agent_terminals; /* 1 byte (agent 0 terminal) */ +} OsrsOceanBuffers; + +// Combined observation size: raw obs + action masks (for ocean mode) +#define OCEAN_OBS_SIZE (SLOT_NUM_OBSERVATIONS + ACTION_MASK_SIZE) + +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; + + 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]; + + // Reward shaping configuration (coefficients + annealing scale) + RewardShapingConfig shaping; + + // PvP-only runtime state. encounters that bypass the PvP stack can ignore this. + OsrsPvpRuntime pvp_runtime; + + // Encounter dispatch. NULL uses the default PvP step/reset path. + 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 render call. + void* client; + + // PufferLib / ocean-side agent buffers. + OsrsOceanBuffers ocean_io; + 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]; + +} OsrsEnv; + +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); +} + +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(OsrsEnv* env, int max) { + if (max <= 0) return 0; + return xorshift32(&env->rng_state) % max; +} + +static inline float rand_float(OsrsEnv* 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; +} + +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 spec regeneration is active from equipped gear. */ +static inline int is_lightbearer_equipped(Player* p) { + return p->equipment_effect_profile.spec_regen_mode == OSRS_SPEC_REGEN_MODE_LIGHTBEARER; +} + +#define RECOIL_MAX_CHARGES 40 + +#endif // OSRS_TYPES_H diff --git a/ocean/osrs/osrs_visual.c b/ocean/osrs/osrs_visual.c new file mode 100644 index 0000000000..5bf6bc519e --- /dev/null +++ b/ocean/osrs/osrs_visual.c @@ -0,0 +1,763 @@ +/** + * @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_env.h" +#include "osrs_encounter.h" +#include "encounters/encounter_nh_pvp.h" +#include "encounters/encounter_zulrah.h" +#include "encounters/encounter_inferno.h" + +#ifdef OSRS_VISUAL +#include "osrs_render.h" +#endif + +#ifdef __EMSCRIPTEN__ +#include +#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(OsrsEnv* 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(OsrsEnv* 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(OsrsEnv* 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); +} + +static void run_profile(OsrsEnv* env, const char* encounter_name) { + printf("Profiling %s for 10 seconds...\n", encounter_name ? encounter_name : "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(); + + 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; + } + } 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; + } + } + edef->reset(env->encounter_state, 0); + } else { + env->pvp_runtime.use_c_opponent = 1; + env->pvp_runtime.opponent.type = OPP_IMPROVED; + env->is_lms = 1; + pvp_reset(env); + } + + clock_t start = clock(); + double elapsed = 0; + int total_steps = 0; + int enc_actions[16] = {0}; + + while (elapsed < 10.0) { + if (env->encounter_def && env->encounter_state) { + const EncounterDef* edef = (const EncounterDef*)env->encounter_def; + 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); + if (edef->is_terminal(env->encounter_state)) { + edef->reset(env->encounter_state, (uint32_t)rand()); + } + } 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); + if (env->episode_over) { + pvp_reset(env); + } + } + + total_steps++; + if (total_steps % 1000 == 0) { + clock_t now = clock(); + elapsed = (double)(now - start) / CLOCKS_PER_SEC; + } + } + + printf("Results:\n"); + printf(" Total steps: %d\n", total_steps); + printf(" Time: %.3f seconds\n", elapsed); + printf(" Steps/sec: %.0f\n", total_steps / elapsed); + + if (env->encounter_def && env->encounter_state) { + ((const EncounterDef*)env->encounter_def)->destroy(env->encounter_state); + env->encounter_state = NULL; + } +} + +#ifdef OSRS_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); } +} + +typedef struct { + OsrsEnv* env; + const char* encounter_name; + ReplayFile* replay; + int start_wave; + /* per-frame state */ + double episode_end_time; /* >0 when holding final frame */ + int episode_ended; +} VisualState; + +static void visual_frame(void* arg) { + VisualState* vs = (VisualState*)arg; + OsrsEnv* env = vs->env; + RenderClient* rc = (RenderClient*)env->client; + + /* 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); + return; + } + + /* in rewind mode viewing history: just render, don't step */ + if (rc->history_cursor >= 0) { + pvp_render(env); + return; + } + + /* episode ended: hold final frame for 2 seconds then reset */ + if (vs->episode_ended) { + pvp_render(env); + if (GetTime() - vs->episode_end_time >= 2.0) { + vs->episode_ended = 0; + render_clear_history(rc); + effect_clear_all(rc->effects); + flight_clear_all(rc); + gui_reset_inventory_ui_state(&rc->gui); + 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); + } + return; + } + + /* paused: render but don't step */ + if (rc->is_paused && !rc->step_once) { + pvp_render(env); + return; + } + 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; + if (GetTime() - rc->last_tick_time < interval) { + pvp_render(env); + 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}; + int used_human_step = 0; + + if (rc->human_input.enabled && edef->step_human_commands) { + edef->step_human_commands(env->encounter_state, &rc->human_input); + used_human_step = 1; + } else 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 (vs->replay && replay_get_actions(vs->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]; + } + } + if (!used_human_step) + 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) { + vs->episode_ended = 1; + vs->episode_end_time = GetTime(); + } +} + +static void run_visual(OsrsEnv* env, const char* encounter_name, const char* replay_path, int start_wave) { + 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); + } + } + + if (start_wave >= 0 && edef->put_int) + edef->put_int(env->encounter_state, "start_wave", start_wave); + edef->reset(env->encounter_state, 0); + fprintf(stderr, "encounter: %s (obs=%d, heads=%d)%s\n", + edef->name, edef->obs_size, edef->num_action_heads, + start_wave >= 0 ? "" : ""); + if (start_wave >= 0) + fprintf(stderr, "start_wave: %d\n", start_wave); + } else { + env->pvp_runtime.use_c_opponent = 1; + env->pvp_runtime.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; + + rc->npc_model_cache = model_cache_load("data/zulrah.models"); + rc->npc_anim_cache = anim_cache_load("data/zulrah.anims"); + fprintf(stderr, "zulrah: npc_models=%d, npc_anims=%d seqs\n", + rc->npc_model_cache ? rc->npc_model_cache->count : 0, + rc->npc_anim_cache ? rc->npc_anim_cache->seq_count : 0); + } else if (encounter_name && strcmp(encounter_name, "inferno") == 0) { + rc->terrain = terrain_load("data/inferno.terrain"); + rc->objects = objects_load("data/inferno.objects"); + rc->objects_zuk = objects_load("data/inferno_zuk.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); + if (rc->objects_zuk) + objects_offset(rc->objects_zuk, 2246, 5315); + + rc->npc_model_cache = model_cache_load("data/inferno.models"); + rc->npc_anim_cache = anim_cache_load("data/inferno.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); + + VisualState vs = { + .env = env, + .encounter_name = encounter_name, + .replay = replay, + .start_wave = start_wave, + .episode_end_time = 0, + .episode_ended = 0, + }; + +#ifdef __EMSCRIPTEN__ + emscripten_set_main_loop_arg(visual_frame, &vs, 0, 1); +#else + while (!WindowShouldClose()) { + visual_frame(&vs); + } +#endif + + 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 use_profile = 0; + int gear_tier = -1; /* -1 = random (default LMS distribution) */ + int start_wave = -1; /* -1 = default (wave 0) */ + 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], "--profile") == 0) { use_profile = 1; use_visual = 0; } + 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]); + else if (strcmp(argv[i], "--wave") == 0 && i + 1 < argc) + start_wave = atoi(argv[++i]); + } + +#ifdef __EMSCRIPTEN__ + if (!encounter_name) encounter_name = "inferno"; +#endif + + srand((unsigned int)time(NULL)); + + OsrsEnv env; + memset(&env, 0, sizeof(OsrsEnv)); + + if (use_profile) { + 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_io.agent_actions = env.actions; + env.ocean_io.agent_obs = (float*)calloc(OCEAN_OBS_SIZE, sizeof(float)); + env.ocean_io.agent_rewards = env.rewards; + env.ocean_io.agent_terminals = env.terminals; + + run_profile(&env, encounter_name); + + free(env.observations); + free(env.actions); + free(env.rewards); + free(env.terminals); + free(env.action_masks); + free(env.ocean_io.agent_obs); + pvp_close(&env); + return 0; + } + + if (use_visual) { +#ifdef OSRS_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.pvp_runtime.gear_tier_weights[t] = 0.0f; + env.pvp_runtime.gear_tier_weights[gear_tier] = 1.0f; + } else { + /* default LMS: 60% tier 0, 25% tier 1, 10% tier 2, 5% tier 3 */ + env.pvp_runtime.gear_tier_weights[0] = 0.60f; + env.pvp_runtime.gear_tier_weights[1] = 0.25f; + env.pvp_runtime.gear_tier_weights[2] = 0.10f; + env.pvp_runtime.gear_tier_weights[3] = 0.05f; + } + env.ocean_io.agent_actions = env.actions; + env.ocean_io.agent_obs = env._obs_buf; + env.ocean_io.agent_rewards = env.rewards; + env.ocean_io.agent_terminals = env.terminals; + run_visual(&env, encounter_name, replay_path, start_wave); + 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_io.agent_actions = env.actions; + env.ocean_io.agent_obs = (float*)calloc(OCEAN_OBS_SIZE, sizeof(float)); + env.ocean_io.agent_rewards = env.rewards; + env.ocean_io.agent_terminals = env.terminals; + + printf("OSRS PvP C Environment Demo\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_io.agent_obs); + pvp_close(&env); + } + + return 0; +} diff --git a/ocean/osrs/scripts/ExportItemSprites.java b/ocean/osrs/scripts/ExportItemSprites.java new file mode 100644 index 0000000000..053fcb59b4 --- /dev/null +++ b/ocean/osrs/scripts/ExportItemSprites.java @@ -0,0 +1,211 @@ +/** + * Export item inventory sprites from OpenRS2 flat cache using RuneLite's + * ItemSpriteFactory (3D model → 2D sprite rendering). + * + * Reads the modern cache at repo-root .refs/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 .refs/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 = ".refs/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(); + + TextureManager textureManager = new TextureManager(store); + textureManager.load(); + + // 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; + + 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()); + } + + System.out.println("\nexported " + exported + " item sprites, " + failed + " failed"); + System.out.println("output: " + outDir.getAbsolutePath()); + } + } +} 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..79087a37ee --- /dev/null +++ b/ocean/osrs/scripts/export_animations.py @@ -0,0 +1,621 @@ +"""Export OSRS animation data from cache to a binary .anims file. + +Reads framebases, frame archives, and sequence (animation) definitions +from a modern OpenRS2 flat file cache. Outputs a compact binary +consumable by osrs_pvp_anim.h. + +Modern cache sources: + - frame bases: index 1 (each group is a framebase) + - sequences: config index 2, group 12 + - frame archives: index 0 + +Usage: + uv run python scripts/export_animations.py \ + --modern-cache ../reference/osrs-cache-modern \ + --output data/equipment.anims +""" + +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 modern_cache_reader import ( + ModernCacheReader, + parse_sequence as parse_modern_sequence, +) + +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 + + +# --- 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_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 + + +# --- 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 modern OpenRS2 cache.""" + parser = argparse.ArgumentParser(description="export OSRS animations from cache") + parser.add_argument("--modern-cache", type=Path, required=True, 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) + cache_path = args.modern_cache + + print(f"reading modern cache from {cache_path}") + reader = ModernCacheReader(cache_path) + + # 1. load sequences + print("loading sequences...") + seq_files = 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") + + # 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...") + + # 3. 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 = 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(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 + + print(f" loaded {loaded} frame archives ({sum(len(v) for v in all_frames.values())} total frames), {errors} errors") + + # 4. 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_modern.py b/ocean/osrs/scripts/export_collision_map_modern.py new file mode 100644 index 0000000000..6f81fc5702 --- /dev/null +++ b/ocean/osrs/scripts/export_collision_map_modern.py @@ -0,0 +1,996 @@ +"""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(). + +Usage: + uv run python scripts/export_collision_map_modern.py \ + --cache ../../../.refs/osrs-cache-modern \ + --keys ../../../.refs/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 ../../../.refs/osrs-cache-modern \ + --keys ../../../.refs/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 ../../../.refs/osrs-cache-modern \ + --keys ../../../.refs/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 modern_cache_reader import ( + ModernCacheReader, + read_big_smart, + read_smart, + read_string, + read_u16, + read_u8, +) + +DEFAULT_MODERN_CACHE = Path(__file__).resolve().parents[3] / ".refs" / "osrs-cache-modern" +DEFAULT_MODERN_KEYS = DEFAULT_MODERN_CACHE / "keys.json" + +# --- 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 + +# 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 + + +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 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) + + +# --- .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(" 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=DEFAULT_MODERN_CACHE, + help="path to modern cache directory", + ) + parser.add_argument( + "--keys", + type=Path, + default=DEFAULT_MODERN_KEYS, + 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..10e5e8b2b2 --- /dev/null +++ b/ocean/osrs/scripts/export_inferno_npcs.py @@ -0,0 +1,818 @@ +"""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 io +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_i32, + read_string, + read_u8, + read_u16, + read_u24, + read_u32, +) +from export_models import ( + ModelData, + _merge_models, + decode_model, + load_model_modern, + write_models_binary, +) +from export_animations import ( + FrameDef, + SequenceDef, + _parse_normal_frame, + load_modern_framebases, + 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) +} + +INFERNO_EXTRA_ANIMS: dict[int, dict[str, int]] = { + 7691: { + "DEFEND": 7575, + "DEATH": 7576, + }, + 7692: { + "DEFEND": 7579, + "DEATH": 7580, + }, + 7693: { + "ATTACK_MELEE": 7582, + "ATTACK_RANGED": 7583, + "DEATH": 7584, + "DEFEND": 7585, + }, + 7697: { + "DEFEND": 7598, + "DEATH": 7599, + "DIG_DOWN": 7600, + "DIG_UP": 7601, + }, + 7698: { + "ATTACK_MELEE": 7604, + "DEATH": 7606, + "DEFEND": 7607, + }, + 7699: { + "RESURRECT": 7611, + "ATTACK_MELEE": 7612, + "DEATH": 7613, + }, + 7700: { + "ATTACK_MELEE": 7590, + "DEFEND": 7591, + "ATTACK_MAGIC": 7592, + "ATTACK_RANGED": 7593, + "DEATH": 7594, + }, + 7706: { + "DEATH": 7562, + "SPAWN": 7563, + "DEFEND": 7565, + }, + 7707: { + "HIT": 7568, + "DEATH": 7569, + }, +} + +# known inferno projectile/effect GFX IDs to check +# from OSRS wiki inferno page and runelite inferno plugin +INFERNO_SPOTANIM_IDS = { + # jad attacks + 448: "Jad magic projectile (front)", + 449: "Jad magic projectile (middle)", + 450: "Jad magic projectile (rear)", + 451: "Jad ranged projectile", + 659: "Tekton meteor splat", + 660: "Tekton meteor projectile", + 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) + 1120: "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) + 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) + # attack anims come from INFERNO_ATTACK_ANIMS (not in cache NPC config) + attack_anim = INFERNO_ATTACK_ANIMS.get(npc_id, 65535) + if attack_anim != 65535: + all_anim_ids.add(attack_anim) + for anim_id in INFERNO_EXTRA_ANIMS.get(npc_id, {}).values(): + all_anim_ids.add(anim_id) + 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)}") + 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.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}") + 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.anims" + available_seqs = all_anim_ids & set(sequences.keys()) + write_animations_binary(anims_path, framebases, all_frames, sequences, available_seqs) + print("\n\nupdating npc_models_inferno.h...") + header_path = Path(__file__).resolve().parent.parent / "data" / "npc_models_inferno.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("/* generated by scripts/export_inferno_npcs.py -- do not edit */\n") + f.write("#ifndef NPC_MODELS_INFERNO_H\n") + f.write("#define NPC_MODELS_INFERNO_H\n\n") + f.write("#include \n") + f.write('#include "npc_models.h" /* for NpcModelMapping typedef */\n\n') + + f.write("static const NpcModelMapping NPC_MODEL_MAP_INFERNO_GEN[] = {\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") + + f.write("/* inferno 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.idle_anim >= 0: + f.write(f"#define INF_GEN_ANIM_{safe_name.upper()}_IDLE {npc.idle_anim}\n") + attack_anim = INFERNO_ATTACK_ANIMS.get(npc_id, 0xFFFF) + if attack_anim != 0xFFFF: + f.write(f"#define INF_GEN_ANIM_{safe_name.upper()}_ATTACK {attack_anim}\n") + if npc.walk_anim >= 0: + f.write(f"#define INF_GEN_ANIM_{safe_name.upper()}_WALK {npc.walk_anim}\n") + extra_anims = INFERNO_EXTRA_ANIMS.get(npc_id, {}) + for macro_suffix, anim_id in extra_anims.items(): + f.write(f"#define INF_GEN_ANIM_{safe_name.upper()}_{macro_suffix} {anim_id}\n") + f.write("\n") + + f.write("/* inferno spotanim GFX model + animation IDs */\n") + for gfx_id, model_id, seq_id, label in spotanim_entries: + f.write(f"#define INF_GEN_GFX_{gfx_id}_MODEL {model_id} /* {label} */\n") + if seq_id >= 0: + f.write(f"#define INF_GEN_GFX_{gfx_id}_ANIM {seq_id}\n") + f.write("\n") + f.write("#endif /* NPC_MODELS_INFERNO_H */\n") + + print(f"wrote {header_path}") + print("\n\nINF_NPC_DEF_IDS mapping table for encounter_inferno.h:") + 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_items.sh b/ocean/osrs/scripts/export_items.sh new file mode 100755 index 0000000000..de708d9552 --- /dev/null +++ b/ocean/osrs/scripts/export_items.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# Export item inventory sprites from an OSRS modern cache. +# +# Uses RuneLite's ItemSpriteFactory (Java) to render 3D item models to 2D +# inventory icons (36x32 PNG), matching the real OSRS client exactly. +# +# Usage: +# cd ocean/osrs +# ./scripts/export_items.sh [,...] +# +# Example: +# ./scripts/export_items.sh ~/osrs-cache 11230,22461,22464,22467,22470 +# +# Dependencies are auto-fetched from Maven Central on first run. Requires +# Java 11+ and curl. Output goes to repo-root data/sprites/items/.png. + +set -eo pipefail + +if [ $# -lt 2 ]; then + echo "usage: $0 [,...]" + echo "" + echo "cache_dir: OpenRS2 flat-file cache (download from https://archive.openrs2.org/)" + echo "item_ids: comma-separated OSRS item IDs to export" + echo "" + echo "example:" + echo " $0 ~/osrs-cache 11230,22461,22464,22467,22470,12625,12627,12629,12631" + exit 1 +fi + +CACHE_DIR="$1" +ITEM_IDS="$2" + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +OSRS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +REPO_ROOT="$(cd "$OSRS_DIR/../.." && pwd)" +BUILD_DIR="$OSRS_DIR/build/item_exporter" +DEPS_DIR="$BUILD_DIR/deps" +OUTPUT_DIR="$REPO_ROOT/data/sprites/items" + +mkdir -p "$BUILD_DIR" "$DEPS_DIR" "$OUTPUT_DIR" + +# Jar name | download URL (RuneLite cache jar from RuneLite repo, rest from Maven Central) +JARS=( + "cache-1.11.9.jar|https://repo.runelite.net/net/runelite/cache/1.11.9/cache-1.11.9.jar" + "commons-compress-1.21.jar|https://repo1.maven.org/maven2/org/apache/commons/commons-compress/1.21/commons-compress-1.21.jar" + "commons-cli-1.4.jar|https://repo1.maven.org/maven2/commons-cli/commons-cli/1.4/commons-cli-1.4.jar" + "guava-30.1.1-jre.jar|https://repo1.maven.org/maven2/com/google/guava/guava/30.1.1-jre/guava-30.1.1-jre.jar" + "gson-2.10.1.jar|https://repo1.maven.org/maven2/com/google/code/gson/gson/2.10.1/gson-2.10.1.jar" + "slf4j-api-1.7.36.jar|https://repo1.maven.org/maven2/org/slf4j/slf4j-api/1.7.36/slf4j-api-1.7.36.jar" + "slf4j-simple-1.7.36.jar|https://repo1.maven.org/maven2/org/slf4j/slf4j-simple/1.7.36/slf4j-simple-1.7.36.jar" + "failureaccess-1.0.1.jar|https://repo1.maven.org/maven2/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar" + "jna-5.9.0.jar|https://repo1.maven.org/maven2/net/java/dev/jna/jna/5.9.0/jna-5.9.0.jar" +) + +for entry in "${JARS[@]}"; do + jar="${entry%%|*}" + url="${entry#*|}" + if [ ! -f "$DEPS_DIR/$jar" ]; then + echo "downloading $jar..." + curl -sL -o "$DEPS_DIR/$jar" "$url" + fi +done + +# Build classpath +CP="$BUILD_DIR" +for jar in "$DEPS_DIR"/*.jar; do + CP="$CP:$jar" +done + +# Compile if needed +if [ ! -f "$BUILD_DIR/ExportItemSprites.class" ] || \ + [ "$SCRIPT_DIR/ExportItemSprites.java" -nt "$BUILD_DIR/ExportItemSprites.class" ]; then + echo "compiling ExportItemSprites.java..." + javac -cp "$CP" -d "$BUILD_DIR" "$SCRIPT_DIR/ExportItemSprites.java" +fi + +echo "exporting item sprites for IDs: $ITEM_IDS" +java -cp "$CP" ExportItemSprites \ + --cache "$CACHE_DIR" \ + --output "$OUTPUT_DIR" \ + --ids "$ITEM_IDS" + +echo "" +echo "done. sprites in $OUTPUT_DIR/" diff --git a/ocean/osrs/scripts/export_models.py b/ocean/osrs/scripts/export_models.py new file mode 100644 index 0000000000..8caa522819 --- /dev/null +++ b/ocean/osrs/scripts/export_models.py @@ -0,0 +1,1836 @@ +"""Export OSRS 3D models from modern OpenRS2 flat file cache to a binary .models file. + +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: + uv run python scripts/export_models.py \ + --modern-cache ../../../.refs/osrs-cache-modern \ + --output data/equipment.models +""" + +import argparse +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 modern_cache_reader import ModernCacheReader, decompress_container, read_string +from export_textures import TextureAtlas + +_read_string = read_string + +# --- constants --- + +MODERN_MODEL_INDEX = 7 +MODERN_CONFIG_OBJ_GROUP = 10 +MODERN_CONFIG_IDK_GROUP = 3 + + +@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_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 (KeyError, FileNotFoundError): + 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 _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 + _ = _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 + _ = _read_ushort(data, off + 15) # vertex Z len + var21 = _read_ushort(data, off + 17) # face index len + var22 = _read_ushort(data, off + 19) # tex len (= face_tex_len not used by us, but 0xFF-2) + + # 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 + + 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 + + if var14 == 1: + var24 += var10 + + var31 = var24 # face index data offset + var24 += var21 + + var32 = var24 # face color offset + var24 += var10 * 2 + + 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 + _ = _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 + + 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 + + if var14 == 1: + var22 += var10 + + var29 = var22 # face index offset + var22 += var20 + + var30 = var22 # face color offset + var22 += var10 * 2 + + 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 + if var12 == 1: + var26 += var10 + + var28 = var26 # face strip type offset + var26 += var10 + + var29 = var26 # face priority offset + if var13 == 255: + var26 += var10 + + if var15 == 1: + var26 += var10 + + var31 = var26 # vertex skin offset + if var17 == 1: + var26 += var9 + + if var14 == 1: + var26 += var10 + + var33 = var26 # face index data offset + var26 += var21 + + var34 = var26 # face texture offset + if var16 == 1: + var26 += var10 * 2 + + 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 + var26 += tex_type0 * 6 + 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 + _ = _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 + if var12 == 1: + var28 += var10 + + var30 = var28 # face strip type offset + var28 += var10 + + var31 = var28 # face priority offset + if var13 == 255: + var28 += var10 + + if var15 == 1: + var28 += var10 + + var33 = var28 # tex index / vertex skin region + var28 += var24 # tex_index_len + + 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 + + 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) + var28 += tex_type0 * 6 + 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 + 12817, # Elysian spirit shield + 26243, # Virtus robe top + 26245, # Virtus robe bottom + 28310, # Venator ring +] + + +def main() -> None: + parser = argparse.ArgumentParser( + description="export OSRS 3D models from modern OpenRS2 cache" + ) + parser.add_argument( + "--modern-cache", + type=Path, + required=True, + 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() + + cache_path = args.modern_cache + if not cache_path.exists(): + sys.exit(f"cache directory not found: {cache_path}") + + print(f"reading modern cache from {cache_path}") + modern_reader = ModernCacheReader(cache_path) + + print("loading item definitions...") + item_defs = decode_item_definitions_modern(modern_reader) + 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: + raw_m = load_model_modern(modern_reader, mid) + 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...") + idk_defs = decode_identity_kits_modern(modern_reader) + 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 + 26377, # GFX 1120 dragon 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(" 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): + raw = load_model_modern(modern_reader, model_id) + + 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_objects.py b/ocean/osrs/scripts/export_objects.py new file mode 100644 index 0000000000..3a88c0b201 --- /dev/null +++ b/ocean/osrs/scripts/export_objects.py @@ -0,0 +1,965 @@ +"""Export placed map objects (walls, props, trees) from modern OpenRS2 OSRS cache. + +Reads object placements from each region's object file, resolves their 3D models +from object definitions, decodes model geometry, applies rotation and +positioning, and outputs a binary .objects file for the raylib viewer. + +Object types exported: + - Walls (types 0-3, 9): straight walls, corners, diagonal walls + - Wall decorations (types 4-8): windows, torches, banners on walls + - Props (types 10-11): buildings, trees, statues, interactive objects + - Roofing (types 12-21): roof pieces + - Ground decorations (type 22): flowers, grass patches, paving, dirt marks + +Usage: + uv run python scripts/export_objects.py \ + --modern-cache ../reference/osrs-cache-modern \ + --keys ../reference/osrs-cache-modern/keys.json \ + --regions "35,47 35,48" \ + --output data/zulrah.objects +""" + +import argparse +import io +import math +import struct +import sys +from collections.abc import Callable +from dataclasses import dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) +from export_terrain import RegionTerrain, build_heightmap, parse_terrain_full +from export_models import ( + ModelData, + decode_model, + expand_model, + hsl15_to_rgb, + load_model_modern, +) +from export_textures import TextureAtlas +from export_collision_map_modern import ( + _read_extended_smart, + find_map_groups, + load_xtea_keys, +) +from export_terrain import stitch_region_edges +from modern_cache_reader import ModernCacheReader, read_smart + +# --- object definition with model IDs --- + +# object types we want to render +WALL_TYPES = {0, 1, 2, 3, 9} +WALL_DECO_TYPES = {4, 5, 6, 7, 8} +PROP_TYPES = {10, 11} +ROOF_TYPES = {12, 13, 14, 15, 16, 17, 18, 19, 20, 21} +GROUND_DECO_TYPES = {22} +EXPORTED_TYPES = WALL_TYPES | WALL_DECO_TYPES | PROP_TYPES | ROOF_TYPES | GROUND_DECO_TYPES + + +@dataclass +class LocDef: + """Object definition with model IDs and transform properties.""" + + obj_id: int = 0 + name: str = "" + width: int = 1 + length: int = 1 + solid: bool = True + # model IDs and their associated type contexts + model_ids: list[int] = field(default_factory=list) + model_types: list[int] = field(default_factory=list) + has_typed_models: bool = False # True if opcode 1 was used (typed models) + # transform + model_size_x: int = 128 # scale factor (128 = 1.0x) + model_size_h: int = 128 + model_size_y: int = 128 + offset_x: int = 0 + offset_h: int = 0 + offset_y: int = 0 + # rendering flags + rotated: bool = False # mirror model horizontally + contoured_ground: bool = False + decor_offset: int = 16 # wall decoration displacement (type 5), default 16 OSRS units + # color remapping + recolor_from: list[int] = field(default_factory=list) + recolor_to: list[int] = field(default_factory=list) + + +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_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 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]}") + + # filter excluded object IDs + if args.exclude_id_set: + before = len(all_placements) + all_placements = [p for p in all_placements if p.obj_id not in args.exclude_id_set] + print(f" excluded {before - len(all_placements)} placements ({len(args.exclude_id_set)} object IDs filtered)") + + # 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_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 modern OpenRS2 cache") + parser.add_argument( + "--modern-cache", + type=Path, + required=True, + help="path to modern OpenRS2 cache directory", + ) + 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( + "--exclude-ids", + type=str, + default=None, + help="comma-separated object IDs to exclude from export (e.g. 30327,30328,...)", + ) + args = parser.parse_args() + + # parse exclude IDs into a set + args.exclude_id_set = set() + if args.exclude_ids: + args.exclude_id_set = {int(x.strip()) for x in args.exclude_ids.split(",")} + + _main_modern(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..cc7002c165 --- /dev/null +++ b/ocean/osrs/scripts/export_spotanims.py @@ -0,0 +1,186 @@ +"""Parse spotanim data from modern OpenRS2 flat file cache and export spotanim metadata. + +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: + 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 \ + --modern-cache ../reference/osrs-cache-modern \ + --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 modern_cache_reader import ModernCacheReader + +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 _parse_modern_spotanim_entry(gfx_id: int, data: bytes) -> SpotAnimDef: + """Parse a single spotanim from modern cache opcode stream.""" + 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") + parser.add_argument( + "--modern-cache", + type=Path, + required=True, + 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() + + cache_path = args.modern_cache + print(f"reading modern cache from {cache_path}") + + modern_reader = ModernCacheReader(cache_path) + spotanims = decode_spotanims_modern(modern_reader) + 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_modern.py b/ocean/osrs/scripts/export_sprites_modern.py new file mode 100644 index 0000000000..9fcc3f1c9d --- /dev/null +++ b/ocean/osrs/scripts/export_sprites_modern.py @@ -0,0 +1,371 @@ +"""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 ../../../.refs/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 + +DEFAULT_MODERN_CACHE = Path(__file__).resolve().parents[3] / ".refs" / "osrs-cache-modern" + + +@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) — full book (smoke/shadow/blood/ice) + "spell_ancient": [ + 325, 326, 327, 328, # ice rush/burst/blitz/barrage + 329, 330, 331, 332, # smoke rush/burst/blitz/barrage + 333, 334, 335, 336, # blood rush/burst/blitz/barrage + 337, 338, 339, 340, # shadow rush/burst/blitz/barrage + 375, 376, 377, 378, # ice disabled + 379, 380, 381, 382, # smoke disabled + 383, 384, 385, 386, # blood disabled + 387, 388, 389, 390, # shadow 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", + # prayer/spell icons are loaded BY NUMERIC ID in osrs_gui.h, so keep them + # as numeric filenames (no names here → fallback to str(sprite_id)). + 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=DEFAULT_MODERN_CACHE, + 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 (IndexError, struct.error, ValueError) 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..6f96d0b5e0 --- /dev/null +++ b/ocean/osrs/scripts/export_terrain.py @@ -0,0 +1,931 @@ +"""Export terrain mesh from modern OpenRS2 OSRS cache to a binary .terrain file. + +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: + 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 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 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 + lightness = (mn + mx) / 2.0 + + if mn != mx: + if lightness < 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(lightness * 256.0))) + flo.luminance = flo.lightness + + if lightness > 0.5: + flo.blend_hue_multiplier = int((1.0 - lightness) * s * 512.0) + else: + flo.blend_hue_multiplier = int(lightness * 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, lightness: int) -> int: + """Convert 24-bit HSL to 16-bit packed HSL (FloorDefinition.hsl24to16).""" + if lightness > 179: + s //= 2 + if lightness > 192: + s //= 2 + if lightness > 217: + s //= 2 + if lightness > 243: + s //= 2 + return ((h // 4) << 10) + ((s // 32) << 7) + lightness // 2 + + + +# --- 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 + packed_lightness = (light * (hsl16 & 0x7F)) // 128 + if packed_lightness < 2: + packed_lightness = 2 + elif packed_lightness > 126: + packed_lightness = 126 + return (hsl16 & 0xFF80) + packed_lightness + + +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) + + 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 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 modern OpenRS2 cache") + parser.add_argument( + "--modern-cache", + type=Path, + required=True, + help="path to modern OpenRS2 cache directory", + ) + 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( + "--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() + + _main_modern(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..edb62ea0a2 --- /dev/null +++ b/ocean/osrs/scripts/export_textures.py @@ -0,0 +1,153 @@ +"""Texture atlas data structures and utilities for OSRS raylib rendering. + +Provides SpriteData / TextureAtlas dataclasses and atlas-building helpers +used by other exporters (e.g. export_objects.py). Texture pixel data is +loaded externally (OpenRS2 cache) and passed in as SpriteData dicts. + +Usage: + from export_textures import TextureAtlas, build_atlas +""" + +import struct +from dataclasses import dataclass, field +from pathlib import Path + +TEXTURE_SIZE = 128 # atlas cell size (vanilla sprites are 64x64 or 128x128) +ATLAS_COLS = 16 # textures per row in atlas + + +@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 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 + +DEFAULT_MODERN_CACHE = Path(__file__).resolve().parents[3] / ".refs" / "osrs-cache-modern" + + +# --- 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 = DEFAULT_MODERN_CACHE + 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/tests/README.md b/ocean/osrs/tests/README.md new file mode 100644 index 0000000000..6a84759cf5 --- /dev/null +++ b/ocean/osrs/tests/README.md @@ -0,0 +1,76 @@ +# OSRS test suite + +standalone C test binaries that verify combat math, item interactions, and special +attacks against the osrs-dps-calc TypeScript reference. no test framework — each file +is a self-contained binary with its own `main()`. + +## building and running + +all commands run from the repo root (`pufferlib-metal/`): + +```bash +# build + run all tests (copy-paste block): +cc -std=c11 -O0 -g -I. -o test_combat_math ocean/osrs/tests/test_combat_math.c -lm && ./test_combat_math +cc -std=c11 -O0 -g -I. -o test_item_effects ocean/osrs/tests/test_item_effects.c -lm && ./test_item_effects +cc -std=c11 -O0 -g -I. -o test_special_attacks ocean/osrs/tests/test_special_attacks.c -lm && ./test_special_attacks +cc -std=c11 -O0 -g -I. -o test_player_combat ocean/osrs/tests/test_player_combat.c -lm && ./test_player_combat +cc -std=c11 -O0 -g -I. -o test_consumables ocean/osrs/tests/test_consumables.c -lm && ./test_consumables +cc -std=c11 -O0 -g -I. -o test_bolt_procs ocean/osrs/tests/test_bolt_procs.c -lm && ./test_bolt_procs +cc -std=c11 -O0 -g -I. -o test_damage ocean/osrs/tests/test_damage.c -lm && ./test_damage +cc -std=c11 -O0 -g -I. -o test_inventory ocean/osrs/tests/test_inventory.c -lm && ./test_inventory +cc -std=c11 -O0 -g -I. -o test_interaction ocean/osrs/tests/test_interaction.c -lm && ./test_interaction +``` + +each binary prints `=== results: N/N passed ===` on the last line. exit code 0 = all passed. + +## test files + +| file | tests | what it covers | +|---|---|---| +| `test_combat_math.c` | 155 | NPC combat formulas (hit chance, tbow scaling, barrage AoE, NPC max hits, NPC attack rolls), player defence rolls vs NPCs, loadout stat computation from ITEM_DATABASE, prayer drain | +| `test_item_effects.c` | 164 | tbow accuracy/damage edge cases and monotonicity, PvP/PvE prayer reduction, NPC defence rolls for specific monsters, player attack rolls with full gear loadouts, two-handed weapon logic, end-to-end hit chance, defence bonus selection by style | +| `test_special_attacks.c` | 222+ | spec weapon costs, accuracy/strength multipliers, dragon claws cascade, DWH/BGS defence drain, dark bow double-hit clamping, morrigan's bleed, voidwaker magic hit, VLS reduced defence, volatile staff, godsword variants, blowpipe spec | +| `test_player_combat.c` | ~30+ | player effective level (all prayers + style bonuses), player attack roll, player melee/ranged/magic max hit, prayer damage reduction (PvE vs PvP), osmumten's fang double accuracy roll, equipment bonus summation | +| `test_consumables.c` | ~25+ | food healing amounts, eat timing/clamping, anglerfish overheal, potion restore formulas, antivenom immunity, saradomin brew effects, combo eat timing | +| `test_bolt_procs.c` | 131 | diamond/opal/ruby bolt proc chances, effect formulas, ZCB guaranteed procs, miss behavior, caps, edge cases | +| `test_damage.c` | 66 | damage pipeline (prayer reduction, vengeance reflect, recoil, smite drain), full PvP/PvE pipeline, edge cases, osrs_has_recoil_ring helper | +| `test_inventory.c` | 148 | inventory add/remove/find, equip from inventory, equip swap, two-handed weapon logic, unequip, gear slot mapping | +| `test_interaction.c` | — | entity interaction system (attack/follow/spec toggle), shared across encounters | +| `test_collision.c` | — | collision map loading, tile walkability, BFS pathfinding (moved from osrs/ root) | + +## reference data + +tests are cross-referenced against: + +- `.refs/osrs-dps-calc/src/lib/` — authoritative TypeScript reference for all player combat formulas +- `.refs/osrs-dps-calc/src/tests/` — existing reference test values +- `.refs/osrs-sdk/src/weapons/` — special attack implementations +- `.refs/InfernoTrainer/src/` — NPC behavior, hit delays +- OSRS wiki — high-level formula descriptions + +## adding tests + +each test file uses the same harness pattern: + +```c +static int total_tests = 0; +static int passed_tests = 0; + +#define ASSERT_EQ(a, b, msg) do { \ + total_tests++; \ + if ((a) != (b)) { \ + printf("FAIL: %s: got %d, expected %d\n", msg, (int)(a), (int)(b)); \ + } else { passed_tests++; } \ +} while(0) +``` + +group related tests in `static void test_*()` functions, call them from `main()`, +print the section name with `printf("--- section name ---\n")`. + +## when to run + +- after any change to combat math (`osrs_combat.h`) +- after any change to items (`osrs_items.h`, `osrs_items_generated.h`, codegen) +- after any change to monsters (`osrs_monsters_generated.h`, codegen) +- after any change to encounter combat logic +- before committing changes to any of the above diff --git a/ocean/osrs/tests/test_bolt_procs.c b/ocean/osrs/tests/test_bolt_procs.c new file mode 100644 index 0000000000..0b14dd10f1 --- /dev/null +++ b/ocean/osrs/tests/test_bolt_procs.c @@ -0,0 +1,334 @@ +/** + * @file test_bolt_procs.c + * @brief tests for enchanted crossbow bolt proc system (diamond, opal, ruby). + * + * validates proc chances, effect formulas, ZCB enhanced procs, miss behavior, + * and edge cases against .refs/osrs-dps-calc/src/lib/dists/bolts.ts. + * + * BUILD: + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_bolt_procs \ + * ocean/osrs/tests/test_bolt_procs.c -lm + * ./test_bolt_procs + */ + +#include +#include +#include + +#include "ocean/osrs/osrs_bolt_procs.h" + + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + if ((actual) == (expected)) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %d, expected %d\n", (label), (actual), (expected)); \ + } \ +} while (0) + +#define ASSERT_FLOAT_RANGE(label, actual, lo, hi) do { \ + tests_run++; \ + float _a = (actual); \ + if (_a >= (lo) && _a <= (hi)) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %.4f, expected [%.4f, %.4f]\n", \ + (label), _a, (float)(lo), (float)(hi)); \ + } \ +} while (0) + + +static void test_diamond_proc_chance(void) { + printf("test_diamond_proc_chance\n"); + uint32_t rng = 12345; + int procs = 0; + int trials = 10000; + for (int i = 0; i < trials; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_DIAMOND_BOLTS_E, 30, 1, 50, 99, 200, 0, &rng); + if (r.proc_triggered) procs++; + } + float rate = (float)procs / (float)trials; + ASSERT_FLOAT_RANGE("diamond proc rate ~11%", rate, 0.08f, 0.14f); +} + + +static void test_diamond_effect_max(void) { + printf("test_diamond_effect_max\n"); + /* max_hit=50: normal effectMax = 50*115/100 = 57 + ZCB effectMax = 50*126/100 = 63 */ + int max_hit = 50; + int normal_max = max_hit * 115 / 100; /* 57 */ + int zcb_max = max_hit * 126 / 100; /* 63 */ + ASSERT_INT_EQ("diamond normal effectMax", normal_max, 57); + ASSERT_INT_EQ("diamond ZCB effectMax", zcb_max, 63); + + /* verify no proc damage exceeds effectMax over many trials */ + uint32_t rng = 99999; + int max_seen_normal = 0; + int max_seen_zcb = 0; + for (int i = 0; i < 50000; i++) { + uint32_t rng_copy = rng; + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_DIAMOND_BOLTS_E, 30, 1, max_hit, 99, 200, 0, &rng); + if (r.proc_triggered && r.modified_damage > max_seen_normal) + max_seen_normal = r.modified_damage; + + BoltProcResult rz = osrs_resolve_bolt_proc( + ITEM_DIAMOND_BOLTS_E, 30, 1, max_hit, 99, 200, 1, &rng_copy); + if (rz.proc_triggered && rz.modified_damage > max_seen_zcb) + max_seen_zcb = rz.modified_damage; + } + /* max seen should be <= effectMax */ + ASSERT_INT_EQ("diamond normal max_seen <= 57", max_seen_normal <= 57, 1); + ASSERT_INT_EQ("diamond ZCB max_seen <= 63", max_seen_zcb <= 63, 1); +} + + +static void test_diamond_zcb_spec(void) { + printf("test_diamond_zcb_spec\n"); + uint32_t rng = 42; + int procs = 0; + int trials = 100; + for (int i = 0; i < trials; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_DIAMOND_BOLTS_E, 30, 1, 50, 99, 200, 1, &rng); + if (r.proc_triggered) procs++; + } + ASSERT_INT_EQ("diamond ZCB spec guaranteed proc", procs, trials); +} + + +static void test_diamond_miss(void) { + printf("test_diamond_miss\n"); + uint32_t rng = 777; + int procs = 0; + for (int i = 0; i < 1000; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_DIAMOND_BOLTS_E, 0, 0, 50, 99, 200, 0, &rng); + if (r.proc_triggered) procs++; + } + ASSERT_INT_EQ("diamond miss = no proc", procs, 0); + + /* ZCB spec on miss should still proc */ + rng = 888; + int zcb_procs = 0; + for (int i = 0; i < 100; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_DIAMOND_BOLTS_E, 0, 0, 50, 99, 200, 1, &rng); + if (r.proc_triggered) zcb_procs++; + } + ASSERT_INT_EQ("diamond ZCB spec procs on miss", zcb_procs, 100); +} + + +static void test_opal_bonus_damage(void) { + printf("test_opal_bonus_damage\n"); + /* force proc with ZCB spec to get deterministic bonus */ + uint32_t rng = 111; + int base = 25; + BoltProcResult r_normal = osrs_resolve_bolt_proc( + ITEM_OPAL_DRAGON_BOLTS, base, 1, 50, 99, 200, 1, &rng); + /* ZCB spec uses divisor 9: floor(99/9) = 11 */ + ASSERT_INT_EQ("opal ZCB bonus", r_normal.modified_damage, base + 11); + + /* to test normal divisor: run many trials, check proc damage */ + rng = 222; + int found_normal_bonus = 0; + for (int i = 0; i < 50000; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_OPAL_DRAGON_BOLTS, base, 1, 50, 99, 200, 0, &rng); + if (r.proc_triggered) { + /* normal bonus = floor(99/10) = 9 */ + ASSERT_INT_EQ("opal normal bonus", r.modified_damage, base + 9); + found_normal_bonus = 1; + break; + } + } + ASSERT_INT_EQ("opal normal proc found", found_normal_bonus, 1); +} + + +static void test_opal_works_on_miss(void) { + printf("test_opal_works_on_miss\n"); + uint32_t rng = 333; + int procs = 0; + for (int i = 0; i < 20000; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_OPAL_DRAGON_BOLTS, 0, 0, 50, 99, 200, 0, &rng); + if (r.proc_triggered) procs++; + } + /* should see some procs even on misses */ + ASSERT_INT_EQ("opal procs on miss", procs > 0, 1); +} + + +static void test_opal_proc_chance(void) { + printf("test_opal_proc_chance\n"); + uint32_t rng = 444; + int procs = 0; + int trials = 20000; + for (int i = 0; i < trials; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_OPAL_DRAGON_BOLTS, 25, 1, 50, 99, 200, 0, &rng); + if (r.proc_triggered) procs++; + } + float rate = (float)procs / (float)trials; + ASSERT_FLOAT_RANGE("opal proc rate ~5.5%", rate, 0.04f, 0.07f); +} + + +static void test_ruby_hp_based_damage(void) { + printf("test_ruby_hp_based_damage\n"); + uint32_t rng = 555; + /* force proc with ZCB spec */ + BoltProcResult r_zcb = osrs_resolve_bolt_proc( + ITEM_RUBY_DRAGON_BOLTS_E, 30, 1, 50, 99, 500, 1, &rng); + /* floor(500 * 22/100) = 110, cap 110 → 110 */ + ASSERT_INT_EQ("ruby ZCB 500hp", r_zcb.modified_damage, 110); + + /* normal: floor(500 * 20/100) = 100, cap 100 → 100 */ + rng = 666; + int found = 0; + for (int i = 0; i < 50000; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_RUBY_DRAGON_BOLTS_E, 30, 1, 50, 99, 500, 0, &rng); + if (r.proc_triggered) { + ASSERT_INT_EQ("ruby normal 500hp", r.modified_damage, 100); + found = 1; + break; + } + } + ASSERT_INT_EQ("ruby normal proc found", found, 1); +} + + +static void test_ruby_cap(void) { + printf("test_ruby_cap\n"); + uint32_t rng = 777; + /* ZCB: floor(1000*22/100) = 220, capped to 110 */ + BoltProcResult r_zcb = osrs_resolve_bolt_proc( + ITEM_RUBY_DRAGON_BOLTS_E, 30, 1, 50, 99, 1000, 1, &rng); + ASSERT_INT_EQ("ruby ZCB 1000hp capped", r_zcb.modified_damage, 110); + + /* normal: floor(1000*20/100) = 200, capped to 100 */ + rng = 888; + int found = 0; + for (int i = 0; i < 50000; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_RUBY_DRAGON_BOLTS_E, 30, 1, 50, 99, 1000, 0, &rng); + if (r.proc_triggered) { + ASSERT_INT_EQ("ruby normal 1000hp capped", r.modified_damage, 100); + found = 1; + break; + } + } + ASSERT_INT_EQ("ruby normal cap proc found", found, 1); +} + + +static void test_ruby_miss(void) { + printf("test_ruby_miss\n"); + uint32_t rng = 999; + int procs = 0; + for (int i = 0; i < 5000; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_RUBY_DRAGON_BOLTS_E, 0, 0, 50, 99, 500, 0, &rng); + if (r.proc_triggered) procs++; + } + ASSERT_INT_EQ("ruby miss = no proc", procs, 0); +} + + +static void test_ruby_zcb_spec(void) { + printf("test_ruby_zcb_spec\n"); + uint32_t rng = 1111; + int procs = 0; + int trials = 100; + for (int i = 0; i < trials; i++) { + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_RUBY_DRAGON_BOLTS_E, 30, 1, 50, 99, 300, 1, &rng); + if (r.proc_triggered) procs++; + /* floor(300*22/100) = 66, under cap 110 */ + ASSERT_INT_EQ("ruby ZCB 300hp dmg", r.modified_damage, 66); + } + ASSERT_INT_EQ("ruby ZCB spec guaranteed", procs, trials); +} + + +static void test_unknown_bolt(void) { + printf("test_unknown_bolt\n"); + uint32_t rng = 2222; + BoltProcResult r = osrs_resolve_bolt_proc( + ITEM_DRAGON_ARROWS, 30, 1, 50, 99, 200, 0, &rng); + ASSERT_INT_EQ("unknown bolt no proc", r.proc_triggered, 0); + ASSERT_INT_EQ("unknown bolt damage unchanged", r.modified_damage, 30); + + /* even with ZCB spec flag, unknown bolt should not proc */ + BoltProcResult r2 = osrs_resolve_bolt_proc( + ITEM_DRAGON_ARROWS, 30, 1, 50, 99, 200, 1, &rng); + ASSERT_INT_EQ("unknown bolt + ZCB no proc", r2.proc_triggered, 0); +} + + +static void test_edge_cases(void) { + printf("test_edge_cases\n"); + uint32_t rng = 3333; + + /* diamond with max_hit=0: effectMax = 0*115/100 = 0, re-roll from [0,0] = 0 */ + BoltProcResult rd = osrs_resolve_bolt_proc( + ITEM_DIAMOND_BOLTS_E, 0, 1, 0, 1, 1, 1, &rng); + ASSERT_INT_EQ("diamond max_hit=0 proc", rd.proc_triggered, 1); + ASSERT_INT_EQ("diamond max_hit=0 dmg", rd.modified_damage, 0); + + /* opal with ranged_level=1: bonus = floor(1/9) = 0 (ZCB) */ + BoltProcResult ro = osrs_resolve_bolt_proc( + ITEM_OPAL_DRAGON_BOLTS, 5, 1, 0, 1, 1, 1, &rng); + ASSERT_INT_EQ("opal rlvl=1 proc", ro.proc_triggered, 1); + ASSERT_INT_EQ("opal rlvl=1 dmg", ro.modified_damage, 5); /* 5 + 0 */ + + /* ruby with target_hp=1: floor(1*22/100) = 0 */ + BoltProcResult rr = osrs_resolve_bolt_proc( + ITEM_RUBY_DRAGON_BOLTS_E, 30, 1, 50, 99, 1, 1, &rng); + ASSERT_INT_EQ("ruby hp=1 proc", rr.proc_triggered, 1); + ASSERT_INT_EQ("ruby hp=1 dmg", rr.modified_damage, 0); + + /* diamond dragon bolts (e) should also work (same case as diamond bolts e) */ + BoltProcResult rdd = osrs_resolve_bolt_proc( + ITEM_DIAMOND_DRAGON_BOLTS_E, 30, 1, 50, 99, 200, 1, &rng); + ASSERT_INT_EQ("diamond dragon bolts proc", rdd.proc_triggered, 1); +} + + +int main(void) { + printf("=== bolt proc tests ===\n\n"); + + test_diamond_proc_chance(); + test_diamond_effect_max(); + test_diamond_zcb_spec(); + test_diamond_miss(); + test_opal_bonus_damage(); + test_opal_works_on_miss(); + test_opal_proc_chance(); + test_ruby_hp_based_damage(); + test_ruby_cap(); + test_ruby_miss(); + test_ruby_zcb_spec(); + test_unknown_bolt(); + test_edge_cases(); + + printf("\n%d/%d passed", tests_passed, tests_run); + if (tests_failed > 0) + printf(", %d FAILED", tests_failed); + printf("\n"); + + return tests_failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_collision.c b/ocean/osrs/tests/test_collision.c new file mode 100644 index 0000000000..8b577cbc7f --- /dev/null +++ b/ocean/osrs/tests/test_collision.c @@ -0,0 +1,389 @@ +/** + * @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) + +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); +} + +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); +} + +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); +} + +TEST(test_pathfind_already_at_dest) { + PathResult r = pathfind_step(NULL, 0, 100, 100, 100, 100, NULL, NULL); + 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, NULL, NULL); + 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, NULL, NULL); + 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, NULL, NULL); + 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, NULL, NULL); + /* 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, NULL, NULL); + 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); +} + +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); +} + +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); +} + +int main(void) { + printf("collision system tests\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("%d passed, %d failed\n", tests_passed, tests_failed); + return tests_failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_combat_math.c b/ocean/osrs/tests/test_combat_math.c new file mode 100644 index 0000000000..86b15baf8d --- /dev/null +++ b/ocean/osrs/tests/test_combat_math.c @@ -0,0 +1,1024 @@ +/** + * @file test_combat_math.c + * @brief combat math tests cross-referenced against osrs-dps-calc reference. + * + * tests the pure combat math functions in osrs_combat_shared.h and the loadout + * stat computation in osrs_encounter.h against hand-computed expected values + * derived from the TypeScript reference (.refs/osrs-dps-calc/). + * + * BUILD: + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_combat_math \ + * ocean/osrs/tests/test_combat_math.c -lm + * ./test_combat_math + * + * REFERENCE FILES: + * .refs/osrs-dps-calc/src/lib/BaseCalc.ts — getNormalAccuracyRoll + * .refs/osrs-dps-calc/src/lib/PlayerVsNPCCalc.ts — player formulas, tbow scaling + * .refs/osrs-dps-calc/src/tests/calc/BasicRolls.test.ts — reference test values + * .refs/osrs-dps-calc/src/tests/calc/DefenceRolls.test.ts + * .refs/osrs-dps-calc/src/tests/calc/Prayers.test.ts + */ + +#include +#include +#include +#include + +#include "ocean/osrs/osrs_encounter.h" +#include "ocean/osrs/osrs_special_attacks.h" + + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + if ((actual) == (expected)) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %d, expected %d\n", (label), (actual), (expected)); \ + } \ +} while (0) + +#define ASSERT_FLOAT_NEAR(label, actual, expected, tolerance) do { \ + tests_run++; \ + float _a = (actual), _e = (expected), _t = (tolerance); \ + if (fabsf(_a - _e) <= _t) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %.6f, expected %.6f (tol %.6f)\n", \ + (label), _a, _e, _t); \ + } \ +} while (0) +/* test: osrs_hit_chance */ +/* */ +/* ref: BaseCalc.ts getNormalAccuracyRoll */ +/* att > def: 1 - (def + 2) / (2 * (att + 1)) */ +/* att <= def: att / (2 * (def + 1)) */ +static void test_hit_chance(void) { + printf("--- osrs_hit_chance ---\n"); + + /* att == def == 0: 0 / (2 * 1) = 0.0 */ + ASSERT_FLOAT_NEAR("att=0 def=0", osrs_hit_chance(0, 0), 0.0f, 1e-5f); + + /* att > def: 1 - (def+2) / (2*(att+1)) */ + /* att=10000, def=5000: 1 - 5002 / 20002 = 0.749975... */ + ASSERT_FLOAT_NEAR("att=10000 def=5000", + osrs_hit_chance(10000, 5000), + 1.0f - 5002.0f / 20002.0f, 1e-4f); + + /* att < def: att / (2*(def+1)) */ + /* att=5000, def=10000: 5000 / 20002 = 0.249975... */ + ASSERT_FLOAT_NEAR("att=5000 def=10000", + osrs_hit_chance(5000, 10000), + 5000.0f / 20002.0f, 1e-4f); + + /* att == def (non-zero): att / (2*(def+1)) */ + /* att=1000, def=1000: 1000 / 2002 = 0.49950... */ + ASSERT_FLOAT_NEAR("att=1000 def=1000", + osrs_hit_chance(1000, 1000), + 1000.0f / 2002.0f, 1e-4f); + + /* very high att, def=0: near 100% */ + /* 1 - 2 / (2 * 100001) = 1 - 0.00001 = 0.99999 */ + ASSERT_FLOAT_NEAR("att=100000 def=0", + osrs_hit_chance(100000, 0), + 1.0f - 2.0f / 200002.0f, 1e-4f); + + /* att=0, def=100: 0 / 202 = 0.0 */ + ASSERT_FLOAT_NEAR("att=0 def=100", osrs_hit_chance(0, 100), 0.0f, 1e-5f); + + /* exact boundary: att == def+1 (att just barely > def) */ + /* att=101, def=100: 1 - 102 / 204 = 1 - 0.5 = 0.5 */ + ASSERT_FLOAT_NEAR("att=101 def=100", + osrs_hit_chance(101, 100), + 1.0f - 102.0f / 204.0f, 1e-4f); + + /* realistic combat scenario: player att ~20000, NPC def ~12000 */ + /* 1 - 12002 / 40002 = 0.69995... */ + ASSERT_FLOAT_NEAR("att=20000 def=12000", + osrs_hit_chance(20000, 12000), + 1.0f - 12002.0f / 40002.0f, 1e-4f); +} +/* test: NPC melee max hit */ +/* */ +/* formula: floor((str + 8) * (bonus + 64) + 320) / 640) */ +/* this is a simplified NPC formula (not player). ref: OSRS wiki. */ +static void test_npc_melee_max_hit(void) { + printf("--- osrs_npc_melee_max_hit ---\n"); + + /* str=1, bonus=0: ((1+8)*(0+64)+320)/640 = (576+320)/640 = 896/640 = 1 */ + ASSERT_INT_EQ("str=1 bonus=0", osrs_npc_melee_max_hit(1, 0), 1); + + /* str=250, bonus=0: ((258)*(64)+320)/640 = (16512+320)/640 = 16832/640 = 26 */ + ASSERT_INT_EQ("str=250 bonus=0", osrs_npc_melee_max_hit(250, 0), 26); + + /* str=200, bonus=50: ((208)*(114)+320)/640 = (23712+320)/640 = 24032/640 = 37 */ + ASSERT_INT_EQ("str=200 bonus=50", osrs_npc_melee_max_hit(200, 50), 37); + + /* str=120, bonus=80: ((128)*(144)+320)/640 = (18432+320)/640 = 18752/640 = 29 */ + ASSERT_INT_EQ("str=120 bonus=80", osrs_npc_melee_max_hit(120, 80), 29); + + /* str=0, bonus=0: ((8)*(64)+320)/640 = (512+320)/640 = 832/640 = 1 */ + ASSERT_INT_EQ("str=0 bonus=0", osrs_npc_melee_max_hit(0, 0), 1); +} +/* test: NPC ranged max hit */ +/* */ +/* formula: floor(0.5 + (range + 8) * (bonus + 64) / 640) */ +static void test_npc_ranged_max_hit(void) { + printf("--- osrs_npc_ranged_max_hit ---\n"); + + /* range=1, bonus=0: 0.5 + 9*64/640 = 0.5 + 0.9 = 1.4 -> 1 */ + ASSERT_INT_EQ("range=1 bonus=0", osrs_npc_ranged_max_hit(1, 0), 1); + + /* range=250, bonus=0: 0.5 + 258*64/640 = 0.5 + 25.8 = 26.3 -> 26 */ + ASSERT_INT_EQ("range=250 bonus=0", osrs_npc_ranged_max_hit(250, 0), 26); + + /* range=120, bonus=80: 0.5 + 128*144/640 = 0.5 + 28.8 = 29.3 -> 29 */ + ASSERT_INT_EQ("range=120 bonus=80", osrs_npc_ranged_max_hit(120, 80), 29); + + /* range=0, bonus=0: 0.5 + 8*64/640 = 0.5 + 0.8 = 1.3 -> 1 */ + ASSERT_INT_EQ("range=0 bonus=0", osrs_npc_ranged_max_hit(0, 0), 1); +} +/* test: NPC magic max hit */ +/* */ +/* formula: base_spell_dmg * magic_dmg_pct / 100 (integer division) */ +static void test_npc_magic_max_hit(void) { + printf("--- osrs_npc_magic_max_hit ---\n"); + + ASSERT_INT_EQ("base=30 pct=100", osrs_npc_magic_max_hit(30, 100), 30); + ASSERT_INT_EQ("base=30 pct=175", osrs_npc_magic_max_hit(30, 175), 52); + ASSERT_INT_EQ("base=46 pct=100", osrs_npc_magic_max_hit(46, 100), 46); + ASSERT_INT_EQ("base=0 pct=200", osrs_npc_magic_max_hit(0, 200), 0); + ASSERT_INT_EQ("base=50 pct=150", osrs_npc_magic_max_hit(50, 150), 75); +} +/* test: NPC attack roll */ +/* */ +/* formula: (att_level + 9) * (att_bonus + 64) */ +/* NPCs have +9 invisible boost (not +8 like players with no stance). */ +/* ref: OSRS wiki, PlayerVsNPCCalc.ts getNPCDefenceRoll (uses +9 for NPCs) */ +static void test_npc_attack_roll(void) { + printf("--- osrs_npc_attack_roll ---\n"); + + /* att=250, bonus=0: (259)*(64) = 16576 */ + ASSERT_INT_EQ("att=250 bonus=0", osrs_npc_attack_roll(250, 0), 16576); + + /* att=1, bonus=0: (10)*(64) = 640 */ + ASSERT_INT_EQ("att=1 bonus=0", osrs_npc_attack_roll(1, 0), 640); + + /* att=0, bonus=0: (9)*(64) = 576 */ + ASSERT_INT_EQ("att=0 bonus=0", osrs_npc_attack_roll(0, 0), 576); + + /* att=180, bonus=176: (189)*(240) = 45360 */ + ASSERT_INT_EQ("att=180 bonus=176", osrs_npc_attack_roll(180, 176), 45360); + + /* Abyssal demon defence roll check: + ref: DefenceRolls.test.ts — Abyssal demon def_level=135, def_bonus=20 (stab) + NPC def roll = (135+9) * (20+64) = 144 * 84 = 12096 */ + ASSERT_INT_EQ("abyssal demon (135,20)", osrs_npc_attack_roll(135, 20), 12096); +} +/* test: osrs_npc_max_hit dispatch */ +/* */ +/* verifies the style-based dispatcher returns correct values. */ +static void test_npc_max_hit_dispatch(void) { + printf("--- osrs_npc_max_hit (dispatch) ---\n"); + + /* melee: str=200, melee_str=50 -> same as osrs_npc_melee_max_hit(200, 50) */ + ASSERT_INT_EQ("melee dispatch", + osrs_npc_max_hit(1 /*melee*/, 200, 0, 50, 0, 0, 100), + osrs_npc_melee_max_hit(200, 50)); + + /* ranged: range=120, ranged_str=80 -> same as osrs_npc_ranged_max_hit(120, 80) */ + ASSERT_INT_EQ("ranged dispatch", + osrs_npc_max_hit(2 /*ranged*/, 0, 120, 0, 80, 0, 100), + osrs_npc_ranged_max_hit(120, 80)); + + /* magic: base=30, pct=175 -> same as osrs_npc_magic_max_hit(30, 175) */ + ASSERT_INT_EQ("magic dispatch", + osrs_npc_max_hit(3 /*magic*/, 0, 0, 0, 0, 30, 175), + osrs_npc_magic_max_hit(30, 175)); + + /* style=0 (none): should return 0 */ + ASSERT_INT_EQ("none dispatch", osrs_npc_max_hit(0, 200, 200, 50, 50, 30, 175), 0); +} +/* test: player defence roll vs NPC */ +/* */ +/* ref: BaseCalc.ts / OSRS wiki */ +/* vs melee/ranged: (def_level + 8) * (def_bonus + 64) */ +/* vs magic: (floor(magic*0.7 + def*0.3) + 8) * (def_bonus + 64) */ +static void test_player_def_roll(void) { + printf("--- osrs_player_def_roll_vs_npc ---\n"); + + /* vs melee, def=99, magic=99, def_bonus=200 */ + /* (99 + 8) * (200 + 64) = 107 * 264 = 28248 */ + ASSERT_INT_EQ("vs melee def=99 bonus=200", + osrs_player_def_roll_vs_npc(99, 99, 200, 1 /* melee */), 28248); + + /* vs ranged, def=70, magic=99, def_bonus=100 */ + /* (70 + 8) * (100 + 64) = 78 * 164 = 12792 */ + ASSERT_INT_EQ("vs ranged def=70 bonus=100", + osrs_player_def_roll_vs_npc(70, 99, 100, 2 /* ranged */), 12792); + + /* vs magic, def=70, magic=99, def_bonus=100 */ + /* floor(99*0.7 + 70*0.3) = floor(69.3 + 21.0) = floor(90.3) = 90 */ + /* (90 + 8) * (100 + 64) = 98 * 164 = 16072 */ + ASSERT_INT_EQ("vs magic def=70 magic=99 bonus=100", + osrs_player_def_roll_vs_npc(70, 99, 100, 3 /* magic */), 16072); + + /* vs magic with equal levels, should give same eff as melee */ + /* def=99, magic=99: floor(99*0.7+99*0.3) = floor(99) = 99 */ + ASSERT_INT_EQ("vs magic equal levels", + osrs_player_def_roll_vs_npc(99, 99, 200, 3 /* magic */), 28248); + + /* vs magic low magic, high def: floor(1*0.7+99*0.3) = floor(0.7+29.7) = floor(30.4) = 30 */ + /* (30+8)*264 = 38*264 = 10032 */ + ASSERT_INT_EQ("vs magic low_magic high_def", + osrs_player_def_roll_vs_npc(99, 1, 200, 3 /* magic */), 10032); +} +/* test: encounter_player_def_bonus */ +/* */ +/* selects the correct defence bonus for incoming NPC attack. */ +static void test_player_def_bonus(void) { + printf("--- encounter_player_def_bonus ---\n"); + + int stab=100, slash=110, crush=120, magic=130, ranged=140; + + ASSERT_INT_EQ("ranged attack", + encounter_player_def_bonus(stab, slash, crush, magic, ranged, 2, 0), 140); + ASSERT_INT_EQ("magic attack", + encounter_player_def_bonus(stab, slash, crush, magic, ranged, 3, 0), 130); + ASSERT_INT_EQ("melee stab", + encounter_player_def_bonus(stab, slash, crush, magic, ranged, 1, 0), 100); + ASSERT_INT_EQ("melee slash", + encounter_player_def_bonus(stab, slash, crush, magic, ranged, 1, 1), 110); + ASSERT_INT_EQ("melee crush", + encounter_player_def_bonus(stab, slash, crush, magic, ranged, 1, 2), 120); +} +/* test: overhead prayer style check */ +/* */ +/* ref: OSRS wiki prayer mechanics */ +/* melee attack (1) blocked by protect melee (3) */ +/* ranged attack (2) blocked by protect ranged (2) */ +/* magic attack (3) blocked by protect magic (1) */ +static void test_prayer_correct(void) { + printf("--- encounter_prayer_correct_for_style ---\n"); + + /* correct prayers */ + ASSERT_INT_EQ("melee->protect melee", + encounter_prayer_correct_for_style(3 /* PRAYER_PROTECT_MELEE */, 1 /* MELEE */), 1); + ASSERT_INT_EQ("ranged->protect ranged", + encounter_prayer_correct_for_style(2 /* PRAYER_PROTECT_RANGED */, 2 /* RANGED */), 1); + ASSERT_INT_EQ("magic->protect magic", + encounter_prayer_correct_for_style(1 /* PRAYER_PROTECT_MAGIC */, 3 /* MAGIC */), 1); + + /* wrong prayers */ + ASSERT_INT_EQ("melee->protect magic", + encounter_prayer_correct_for_style(1, 1), 0); + ASSERT_INT_EQ("melee->protect ranged", + encounter_prayer_correct_for_style(2, 1), 0); + ASSERT_INT_EQ("ranged->protect melee", + encounter_prayer_correct_for_style(3, 2), 0); + ASSERT_INT_EQ("magic->protect ranged", + encounter_prayer_correct_for_style(2, 3), 0); + + /* no prayer blocks nothing */ + ASSERT_INT_EQ("none->melee", + encounter_prayer_correct_for_style(0, 1), 0); + ASSERT_INT_EQ("none->ranged", + encounter_prayer_correct_for_style(0, 2), 0); + ASSERT_INT_EQ("none->magic", + encounter_prayer_correct_for_style(0, 3), 0); +} +/* test: hit delay formulas */ +/* */ +/* ref: osrs-sdk, InfernoTrainer blowpipe.ts, MagicWeapon.ts */ +/* magic: floor((1 + distance) / 3) + 1 [+1 if player] */ +/* ranged: floor((3 + distance) / 6) + 1 [+1 if player] */ +/* blowpipe: floor(distance / 6) + 1 [+1 if player] */ +static void test_hit_delays(void) { + printf("--- hit delay formulas ---\n"); + + /* magic: floor((1+d)/3) + 1 + is_player */ + ASSERT_INT_EQ("magic d=1 npc", encounter_magic_hit_delay(1, 0), 1); /* (2/3)=0, +1 = 1 */ + ASSERT_INT_EQ("magic d=1 plr", encounter_magic_hit_delay(1, 1), 2); + ASSERT_INT_EQ("magic d=5 npc", encounter_magic_hit_delay(5, 0), 3); /* (6/3)=2, +1 = 3 */ + ASSERT_INT_EQ("magic d=5 plr", encounter_magic_hit_delay(5, 1), 4); + ASSERT_INT_EQ("magic d=10 npc", encounter_magic_hit_delay(10, 0), 4); /* (11/3)=3, +1 = 4 */ + + /* ranged: floor((3+d)/6) + 1 + is_player */ + ASSERT_INT_EQ("ranged d=1 npc", encounter_ranged_hit_delay(1, 0), 1); /* (4/6)=0, +1 = 1 */ + ASSERT_INT_EQ("ranged d=1 plr", encounter_ranged_hit_delay(1, 1), 2); + ASSERT_INT_EQ("ranged d=6 npc", encounter_ranged_hit_delay(6, 0), 2); /* (9/6)=1, +1 = 2 */ + ASSERT_INT_EQ("ranged d=10 npc", encounter_ranged_hit_delay(10, 0), 3); /* (13/6)=2, +1 = 3 */ + + /* blowpipe: floor(d/6) + 1 + is_player */ + ASSERT_INT_EQ("bp d=1 npc", encounter_blowpipe_hit_delay(1, 0), 1); /* 0+1=1 */ + ASSERT_INT_EQ("bp d=1 plr", encounter_blowpipe_hit_delay(1, 1), 2); + ASSERT_INT_EQ("bp d=6 npc", encounter_blowpipe_hit_delay(6, 0), 2); /* 1+1=2 */ + ASSERT_INT_EQ("bp d=6 plr", encounter_blowpipe_hit_delay(6, 1), 3); + ASSERT_INT_EQ("bp d=12 npc", encounter_blowpipe_hit_delay(12, 0), 3); /* 2+1=3 */ +} +/* test: chebyshev distance to multi-tile NPC */ +/* */ +/* encounter_dist_to_npc(px, py, nx, ny, npc_size) */ +/* returns chebyshev distance from (px,py) to nearest tile of NPC at (nx,ny)*/ +static void test_dist_to_npc(void) { + printf("--- encounter_dist_to_npc ---\n"); + + /* 1x1 NPC at (5,5), player at (8,5): dx=3, dy=0 -> 3 */ + ASSERT_INT_EQ("1x1 same row", encounter_dist_to_npc(8, 5, 5, 5, 1), 3); + + /* 1x1 NPC at (5,5), player ON NPC tile: 0 */ + ASSERT_INT_EQ("1x1 on top", encounter_dist_to_npc(5, 5, 5, 5, 1), 0); + + /* 3x3 NPC at (5,5) occupies (5,5)-(7,7). player at (5,5): inside -> 0 */ + ASSERT_INT_EQ("3x3 inside", encounter_dist_to_npc(5, 5, 5, 5, 3), 0); + + /* 3x3 NPC at (5,5). player at (9,6): nearest tile (7,6), dx=2, dy=0 -> 2 */ + ASSERT_INT_EQ("3x3 right", encounter_dist_to_npc(9, 6, 5, 5, 3), 2); + + /* 5x5 NPC at (10,10) occupies (10-14, 10-14). player at (10,8): nearest (10,10), dist=2 */ + ASSERT_INT_EQ("5x5 below", encounter_dist_to_npc(10, 8, 10, 10, 5), 2); + + /* 5x5 NPC at (10,10). player at (17,17): nearest (14,14), dx=3,dy=3 -> 3 */ + ASSERT_INT_EQ("5x5 diagonal", encounter_dist_to_npc(17, 17, 10, 10, 5), 3); + + /* player adjacent to 3x3: (5,5)-(7,7), player at (4,4): nearest(5,5), dx=1,dy=1 -> 1 */ + ASSERT_INT_EQ("3x3 diagonal adj", encounter_dist_to_npc(4, 4, 5, 5, 3), 1); +} +/* test: twisted bow multipliers */ +/* */ +/* our C code returns a float multiplier. the reference uses integer */ +/* truncation on intermediates which causes small divergences. */ +/* */ +/* ref: PlayerVsNPCCalc.ts tbowScaling() */ +/* accuracy: factor=10, base=140 */ +/* t2 = trunc((3*m - 10) / 100) */ +/* t3 = trunc((trunc(3*m/10) - 100)^2 / 100) */ +/* bonus = 140 + t2 - t3 */ +/* damage: factor=14, base=250 */ +/* t2 = trunc((3*m - 14) / 100) */ +/* t3 = trunc((trunc(3*m/10) - 140)^2 / 100) */ +/* bonus = 250 + t2 - t3 */ +/* */ +/* our C uses float division (no integer truncation of intermediates). */ +/* expected divergence: up to ~0.01 in the multiplier. */ +/* reference tbow accuracy multiplier using integer truncation (matching TS) */ +static float ref_tbow_acc_mult(int magic) { + int m = magic < 250 ? magic : 250; + int t2 = (3 * m - 10) / 100; /* C integer division truncates toward zero */ + int inner = (3 * m / 10) - 100; + int t3 = (inner * inner) / 100; + int bonus = 140 + t2 - t3; + float mult = (float)bonus / 100.0f; + if (mult > 1.4f) mult = 1.4f; + if (mult < 0.0f) mult = 0.0f; + return mult; +} + +/* reference tbow damage multiplier using integer truncation (matching TS) */ +static float ref_tbow_dmg_mult(int magic) { + int m = magic < 250 ? magic : 250; + int t2 = (3 * m - 14) / 100; + int inner = (3 * m / 10) - 140; + int t3 = (inner * inner) / 100; + int bonus = 250 + t2 - t3; + float mult = (float)bonus / 100.0f; + if (mult > 2.5f) mult = 2.5f; + if (mult < 0.0f) mult = 0.0f; + return mult; +} + +static void test_tbow_multipliers(void) { + printf("--- osrs_tbow_acc_mult / osrs_tbow_dmg_mult ---\n"); + + /* test accuracy multiplier shape: + low magic -> low mult, high magic -> high mult (up to 1.4 cap) */ + + /* magic=0: reference bonus = 140 + 0 - 100 = 40, mult = 0.40 */ + ASSERT_FLOAT_NEAR("acc m=0 vs ref", osrs_tbow_acc_mult(0), ref_tbow_acc_mult(0), 0.01f); + + /* magic=100: reference bonus = 140 + 2 - 49 = 93, mult = 0.93 */ + ASSERT_FLOAT_NEAR("acc m=100 vs ref", osrs_tbow_acc_mult(100), ref_tbow_acc_mult(100), 0.01f); + + /* magic=200: reference bonus = 140 + 5 - 16 = 129, mult = 1.29 */ + ASSERT_FLOAT_NEAR("acc m=200 vs ref", osrs_tbow_acc_mult(200), ref_tbow_acc_mult(200), 0.01f); + + /* magic=250: should be at or near cap (1.40) */ + ASSERT_FLOAT_NEAR("acc m=250 vs ref", osrs_tbow_acc_mult(250), ref_tbow_acc_mult(250), 0.01f); + float acc250 = osrs_tbow_acc_mult(250); + ASSERT_INT_EQ("acc m=250 capped at 1.4", acc250 <= 1.4f + 0.001f, 1); + + /* magic=350: should be capped same as 250 */ + ASSERT_FLOAT_NEAR("acc m=350 == m=250", osrs_tbow_acc_mult(350), osrs_tbow_acc_mult(250), 1e-5f); + + /* damage multiplier */ + /* magic=0: reference bonus = 250 + 0 - 196 = 54, mult = 0.54 */ + ASSERT_FLOAT_NEAR("dmg m=0 vs ref", osrs_tbow_dmg_mult(0), ref_tbow_dmg_mult(0), 0.015f); + + /* magic=100: reference bonus = 250 + 2 - 16 = 236, mult = 2.36 */ + ASSERT_FLOAT_NEAR("dmg m=100 vs ref", osrs_tbow_dmg_mult(100), ref_tbow_dmg_mult(100), 0.01f); + + /* magic=250: reference bonus = 250 + 7 - 42 = 215, mult = 2.15 */ + ASSERT_FLOAT_NEAR("dmg m=250 vs ref", osrs_tbow_dmg_mult(250), ref_tbow_dmg_mult(250), 0.015f); + + /* verify monotonicity: higher magic = higher multipliers */ + ASSERT_INT_EQ("acc monotonic 0<100", osrs_tbow_acc_mult(0) < osrs_tbow_acc_mult(100), 1); + ASSERT_INT_EQ("acc monotonic 100<200", osrs_tbow_acc_mult(100) < osrs_tbow_acc_mult(200), 1); + ASSERT_INT_EQ("dmg monotonic 0<100", osrs_tbow_dmg_mult(0) < osrs_tbow_dmg_mult(100), 1); +} +/* test: blowpipe special attack */ +/* */ +/* ref: Blowpipe.ts — 2x accuracy, 1.5x max hit */ +/* spec_att_roll = base_att_roll * 2 */ +/* spec_max = base_max_hit * 3 / 2 */ +/* def_roll = (target_def + 8) * (target_ranged_def + 64) */ +static void test_blowpipe_spec(void) { + printf("--- osrs_blowpipe_spec_resolve ---\n"); + + /* test the spec constants */ + ASSERT_INT_EQ("spec acc mult", BLOWPIPE_SPEC_ACC_MULT, 2); + ASSERT_INT_EQ("spec dmg num", BLOWPIPE_SPEC_DMG_NUM, 3); + ASSERT_INT_EQ("spec dmg den", BLOWPIPE_SPEC_DMG_DEN, 2); + ASSERT_INT_EQ("spec heal pct", BLOWPIPE_SPEC_HEAL_PCT, 50); + ASSERT_INT_EQ("spec cost", BLOWPIPE_SPEC_COST, 50); + + /* verify the formula produces values in range with fixed RNG */ + uint32_t rng = 12345; + int base_att = 20000; + int base_max = 30; + int target_def = 100; + int target_ranged_def = 50; + + int dmg = osrs_blowpipe_spec_resolve( + base_att, base_max, target_def, target_ranged_def, &rng); + + /* spec_max = 30 * 3 / 2 = 45 */ + ASSERT_INT_EQ("spec dmg in range", dmg >= 0 && dmg <= 45, 1); + + /* run many trials to check range */ + int max_seen = 0, num_zeros = 0; + rng = 42; + for (int i = 0; i < 10000; i++) { + int d = osrs_blowpipe_spec_resolve( + base_att, base_max, target_def, target_ranged_def, &rng); + if (d > max_seen) max_seen = d; + if (d == 0) num_zeros++; + } + /* max should be close to spec_max=45 */ + ASSERT_INT_EQ("spec max close to 45", max_seen >= 40 && max_seen <= 45, 1); + /* should have some misses */ + ASSERT_INT_EQ("spec has misses", num_zeros > 0, 1); +} +/* test: encounter_compute_loadout_stats (player loadout) */ +/* */ +/* ref: PlayerVsNPCCalc.ts getPlayerMaxMeleeHit, getPlayerMaxMeleeAttackRoll*/ +/* */ +/* tests loadout stat computation using items from ITEM_DATABASE. */ +/* base_level=99 to match typical inferno/pvm scenarios. */ +/* helper: fill loadout with ITEM_NONE */ +static void clear_loadout(uint8_t loadout[NUM_GEAR_SLOTS]) { + memset(loadout, 255, NUM_GEAR_SLOTS); +} + +static void test_loadout_melee_no_prayer(void) { + printf("--- loadout: ghrazi rapier, no prayer, aggressive ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_GHRAZI_RAPIER; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, + ATTACK_STYLE_MELEE, + OFFENSIVE_PRAYER_NONE, + 99, + FIGHT_STYLE_AGGRESSIVE, + 0, /* spell_base_damage */ + &stats + ); + + /* rapier: attack_stab=94, attack_slash=55, attack_crush=0 -> best = 94 */ + ASSERT_INT_EQ("attack_bonus", stats.attack_bonus, 94); + + /* rapier: melee_strength = 89 */ + ASSERT_INT_EQ("strength_bonus", stats.strength_bonus, 89); + + /* aggressive: +3 str only, no att bonus. + eff_level (attack) = floor(99 * 1.0) + 0 + 8 = 107 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 107); + + /* eff_str = floor(99 * 1.0) + 3 + 8 = 110 */ + /* max_hit = floor(0.5 + 110 * (89+64) / 640) = floor(0.5 + 110*153/640) */ + /* = floor(0.5 + 26.296875) = floor(26.796875) = 26 */ + ASSERT_INT_EQ("max_hit", stats.max_hit, 26); + + /* attack_roll = eff_level * (attack_bonus + 64) = 107 * 158 = 16906 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 16906); + + ASSERT_INT_EQ("attack_speed", stats.attack_speed, 4); +} + +static void test_loadout_melee_piety(void) { + printf("--- loadout: ghrazi rapier, piety, aggressive ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_GHRAZI_RAPIER; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, + ATTACK_STYLE_MELEE, + OFFENSIVE_PRAYER_PIETY, + 99, + FIGHT_STYLE_AGGRESSIVE, + 0, /* spell_base_damage */ + &stats + ); + + /* piety: att_mult=1.20, str_mult=1.23. + aggressive: +3 str, no att bonus. */ + /* ref: PlayerVsNPCCalc.ts — Piety factorAccuracy=[120,100], factorStrength=[123,100] */ + + /* eff_att_level = floor(99 * 1.20) + 0 + 8 = 126 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 126); + + /* eff_str = floor(99 * 1.23) + 3 + 8 = 121 + 11 = 132 */ + /* max_hit = floor(0.5 + 132 * 153 / 640) = floor(0.5 + 31.55625) = 32 */ + ASSERT_INT_EQ("max_hit", stats.max_hit, 32); + + /* attack_roll = 126 * (94 + 64) = 126 * 158 = 19908 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 19908); +} + +static void test_loadout_ranged_rigour(void) { + printf("--- loadout: ACB + diamond bolts (e), rigour, rapid ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_ARMADYL_CROSSBOW; + loadout[GEAR_SLOT_AMMO] = ITEM_DIAMOND_BOLTS_E; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, + ATTACK_STYLE_RANGED, + OFFENSIVE_PRAYER_RIGOUR, + 99, + FIGHT_STYLE_RAPID, + 0, /* spell_base_damage */ + &stats + ); + + /* rigour: att_mult=1.20, str_mult=1.23. rapid stance: 0 level bonus, -1 attack_speed. */ + /* ref: Prayer.ts — Rigour factorAccuracy=[120,100], factorStrength=[123,100] */ + + /* attack_bonus = ACB.attack_ranged(100) + bolts.attack_ranged(0) = 100 */ + ASSERT_INT_EQ("attack_bonus", stats.attack_bonus, 100); + + /* strength_bonus = ACB.ranged_strength(0) + bolts.ranged_strength(105) = 105 */ + ASSERT_INT_EQ("strength_bonus", stats.strength_bonus, 105); + + /* eff_att = floor(99 * 1.20) + 0 + 8 = 118 + 8 = 126 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 126); + + /* eff_str = floor(99 * 1.23) + 0 + 8 = 121 + 8 = 129 */ + /* max_hit = floor(0.5 + 129 * (105 + 64) / 640) = floor(0.5 + 129*169/640) */ + /* = floor(0.5 + 34.064...) = floor(34.564) = 34 */ + ASSERT_INT_EQ("max_hit", stats.max_hit, 34); + + /* attack_roll = 126 * (100 + 64) = 126 * 164 = 20664 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 20664); +} + +static void test_loadout_magic_augury(void) { + printf("--- loadout: kodai wand, augury, autocast barrage ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, + ATTACK_STYLE_MAGIC, + OFFENSIVE_PRAYER_AUGURY, + 99, + FIGHT_STYLE_AUTOCAST, + 30, /* spell_base_damage (ice barrage = 30) */ + &stats + ); + + /* augury: att_mult=1.25, magic_dmg_mult=1.04 */ + /* ref: Prayer.ts — Augury factorAccuracy=[125,100], magicDamageBonus=40 (4%) */ + + /* attack_bonus = kodai.attack_magic(28) = 28 */ + ASSERT_INT_EQ("attack_bonus", stats.attack_bonus, 28); + + /* strength_bonus = kodai.magic_damage(15) = 15 */ + ASSERT_INT_EQ("strength_bonus", stats.strength_bonus, 15); + + /* eff_level = floor(99 * 1.25) + 9 = 123 + 9 = 132 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 132); + + /* max_hit = floor(30 * (1.0 + 15/100.0) * 1.04) = floor(30 * 1.15 * 1.04) */ + /* = floor(35.88) = 35 */ + ASSERT_INT_EQ("max_hit", stats.max_hit, 35); + + /* attack_roll = 132 * (28 + 64) = 132 * 92 = 12144 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 12144); +} + +static void test_loadout_magic_no_prayer(void) { + printf("--- loadout: kodai wand, no prayer, autocast barrage ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, + ATTACK_STYLE_MAGIC, + OFFENSIVE_PRAYER_NONE, + 99, + FIGHT_STYLE_AUTOCAST, + 30, /* ice barrage */ + &stats + ); + + /* eff_level = floor(99*1.0) + 9 = 108 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 108); + + /* max_hit = floor(30 * (1.0 + 15/100.0) * 1.0) = floor(30 * 1.15) = floor(34.5) = 34 */ + ASSERT_INT_EQ("max_hit", stats.max_hit, 34); +} +/* test: loadout with full gear (multi-slot) */ +/* */ +/* verifies that stats from multiple gear slots sum correctly. */ +static void test_loadout_full_ranged(void) { + printf("--- loadout: tbow + masori + anguish + vambs, rigour ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_TWISTED_BOW; + loadout[GEAR_SLOT_HEAD] = ITEM_MASORI_MASK_F; + loadout[GEAR_SLOT_BODY] = ITEM_MASORI_BODY_F; + loadout[GEAR_SLOT_LEGS] = ITEM_MASORI_CHAPS_F; + loadout[GEAR_SLOT_NECK] = ITEM_NECKLACE_OF_ANGUISH; + loadout[GEAR_SLOT_CAPE] = ITEM_DIZANAS_QUIVER; + loadout[GEAR_SLOT_HANDS] = ITEM_ZARYTE_VAMBRACES; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, + ATTACK_STYLE_RANGED, + OFFENSIVE_PRAYER_RIGOUR, + 99, + FIGHT_STYLE_RAPID, + 0, + &stats + ); + + /* sum attack_ranged: tbow(70) + mask(12) + body(43) + chaps(27) + anguish(15) + quiver(18) + vambs(18) = 203 */ + ASSERT_INT_EQ("attack_bonus", stats.attack_bonus, 203); + + /* sum ranged_strength: tbow(20) + mask(2) + body(4) + chaps(2) + anguish(5) + quiver(3) + vambs(2) = 38 */ + ASSERT_INT_EQ("strength_bonus", stats.strength_bonus, 38); + + /* eff_att = floor(99 * 1.20) + 0 + 8 = 126 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 126); + + /* eff_str = floor(99 * 1.23) + 0 + 8 = 129 */ + /* max_hit = floor(0.5 + 129 * (38 + 64) / 640) = floor(0.5 + 129*102/640) */ + /* = floor(0.5 + 13158/640) = floor(0.5 + 20.559375) = floor(21.059375) = 21 */ + ASSERT_INT_EQ("max_hit", stats.max_hit, 21); + + /* attack_roll = 126 * (203 + 64) = 126 * 267 = 33642 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 33642); +} +/* test: encounter_update_loadout_level (brew drain / boost recomputation) */ +/* */ +/* verifies that eff_level and max_hit update correctly after stat changes. */ +static void test_update_loadout_level(void) { + printf("--- encounter_update_loadout_level ---\n"); + + /* set up a melee loadout with piety */ + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_GHRAZI_RAPIER; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_PIETY, + 99, FIGHT_STYLE_AGGRESSIVE, 0, &stats); + + /* base (aggressive: +3 str, +0 att): eff_att=126, max=32 */ + ASSERT_INT_EQ("base eff", stats.eff_level, 126); + ASSERT_INT_EQ("base max", stats.max_hit, 32); + + /* simulate brew drain: att drops to 90, str drops to 90 */ + encounter_update_loadout_level(&stats, OFFENSIVE_PRAYER_PIETY, 90, 90); + + /* eff_att = floor(90 * 1.20) + 0 + 8 = 108 + 8 = 116 */ + ASSERT_INT_EQ("drained eff", stats.eff_level, 116); + + /* eff_str = floor(90 * 1.23) + 3 + 8 = 110 + 11 = 121 */ + /* max_hit = floor(0.5 + 121 * 153 / 640) = floor(0.5 + 18513/640) = floor(0.5 + 28.926...) */ + /* = floor(29.426) = 29 */ + ASSERT_INT_EQ("drained max", stats.max_hit, 29); + + /* restore back to 99 */ + encounter_update_loadout_level(&stats, OFFENSIVE_PRAYER_PIETY, 99, 99); + ASSERT_INT_EQ("restored eff", stats.eff_level, 126); + ASSERT_INT_EQ("restored max", stats.max_hit, 32); + + /* magic loadout: max_hit doesn't depend on level (spell-based) */ + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_AUGURY, + 99, FIGHT_STYLE_AUTOCAST, 30, &stats); + + ASSERT_INT_EQ("magic base eff", stats.eff_level, 132); + ASSERT_INT_EQ("magic base max", stats.max_hit, 35); + + /* drain magic to 80: eff changes, max_hit stays (spell-based) */ + encounter_update_loadout_level(&stats, OFFENSIVE_PRAYER_AUGURY, 80, 80); + /* eff = floor(80 * 1.25) + 0 + 9 = 100 + 9 = 109 */ + ASSERT_INT_EQ("magic drained eff", stats.eff_level, 109); + /* max_hit still = floor(30 * 1.15 * 1.04) = 35 */ + ASSERT_INT_EQ("magic drained max", stats.max_hit, 35); +} +/* test: full combat scenario (NPC attacks player) */ +/* */ +/* combines NPC attack roll + player defence roll + hit chance into one */ +/* end-to-end check. uses realistic inferno-style stats. */ +static void test_full_npc_attack_scenario(void) { + printf("--- full NPC attack scenario ---\n"); + + /* scenario: Jad (melee), att_level=480, att_bonus=0 */ + /* NPC attack roll = (480 + 9) * (0 + 64) = 489 * 64 = 31296 */ + int npc_att_roll = osrs_npc_attack_roll(480, 0); + ASSERT_INT_EQ("jad att roll", npc_att_roll, 31296); + + /* player in justiciar: def=99, bonus=stab=200 (approx), vs melee */ + int player_def_roll = osrs_player_def_roll_vs_npc(99, 99, 200, 1 /* melee */); + /* (99+8)*(200+64) = 107*264 = 28248 */ + ASSERT_INT_EQ("player def roll", player_def_roll, 28248); + + /* hit chance: att=31296 > def=28248 */ + /* 1 - (28248+2)/(2*(31296+1)) = 1 - 28250/62594 = 1 - 0.45131... = 0.54869... */ + float chance = osrs_hit_chance(npc_att_roll, player_def_roll); + float expected = 1.0f - 28250.0f / 62594.0f; + ASSERT_FLOAT_NEAR("jad hit chance", chance, expected, 1e-3f); + + /* Jad melee max hit: str=480, bonus=0 */ + /* ((480+8)*(0+64)+320)/640 = (488*64+320)/640 = (31232+320)/640 = 31552/640 = 49 */ + int jad_max = osrs_npc_melee_max_hit(480, 0); + ASSERT_INT_EQ("jad melee max", jad_max, 49); +} +/* test: RNG sanity checks */ +/* */ +/* verify encounter_xorshift, encounter_rand_int, encounter_rand_float */ +/* produce values in expected ranges and aren't degenerate. */ +static void test_rng(void) { + printf("--- RNG sanity ---\n"); + + uint32_t state = 1; + + /* xorshift should produce non-zero non-degenerate values */ + uint32_t v1 = encounter_xorshift(&state); + uint32_t v2 = encounter_xorshift(&state); + ASSERT_INT_EQ("xor not equal", v1 != v2, 1); + ASSERT_INT_EQ("xor nonzero 1", v1 != 0, 1); + ASSERT_INT_EQ("xor nonzero 2", v2 != 0, 1); + + /* rand_int should be in [0, max) */ + state = 42; + int in_range = 1; + for (int i = 0; i < 10000; i++) { + int r = encounter_rand_int(&state, 10); + if (r < 0 || r >= 10) { in_range = 0; break; } + } + ASSERT_INT_EQ("rand_int [0,10)", in_range, 1); + + /* rand_int with max=1 should always return 0 */ + state = 123; + int all_zero = 1; + for (int i = 0; i < 100; i++) { + if (encounter_rand_int(&state, 1) != 0) { all_zero = 0; break; } + } + ASSERT_INT_EQ("rand_int max=1 always 0", all_zero, 1); + + /* rand_int with max=0 should return 0 */ + ASSERT_INT_EQ("rand_int max=0", encounter_rand_int(&state, 0), 0); + + /* rand_float should be in [0, 1) */ + state = 999; + int float_ok = 1; + for (int i = 0; i < 10000; i++) { + float f = encounter_rand_float(&state); + if (f < 0.0f || f >= 1.0f) { float_ok = 0; break; } + } + ASSERT_INT_EQ("rand_float [0,1)", float_ok, 1); +} +/* test: barrage AoE resolve */ +/* */ +/* verify primary target always rolled, AoE only within 1 tile, */ +/* damage bounds, and freeze application. */ +static void test_barrage_resolve(void) { + printf("--- osrs_barrage_resolve ---\n"); + + uint32_t rng = 77; + int att_roll = 30000; + int max_hit = 30; + + /* single target */ + BarrageTarget targets[3]; + memset(targets, 0, sizeof(targets)); + targets[0].active = 1; + targets[0].x = 5; targets[0].y = 5; + targets[0].def_level = 100; + targets[0].magic_def_bonus = 50; + + BarrageResult res = osrs_barrage_resolve(targets, 1, att_roll, max_hit, &rng, 0, 0); + ASSERT_INT_EQ("single num_hits", res.num_hits, 1); + ASSERT_INT_EQ("single dmg range", res.total_damage >= 0 && res.total_damage <= 30, 1); + + /* two targets: one in range, one out of range */ + rng = 88; + memset(targets, 0, sizeof(targets)); + targets[0].active = 1; + targets[0].x = 5; targets[0].y = 5; + targets[0].def_level = 50; targets[0].magic_def_bonus = 20; + + targets[1].active = 1; + targets[1].x = 6; targets[1].y = 6; /* within 1 tile of primary */ + targets[1].def_level = 50; targets[1].magic_def_bonus = 20; + + targets[2].active = 1; + targets[2].x = 10; targets[2].y = 10; /* far away, NOT in AoE */ + targets[2].def_level = 50; targets[2].magic_def_bonus = 20; + + res = osrs_barrage_resolve(targets, 3, att_roll, max_hit, &rng, 0, 0); + /* should roll primary + 1 in-range, skip the far one */ + ASSERT_INT_EQ("aoe num_hits", res.num_hits, 2); + /* far target should not have been hit */ + ASSERT_INT_EQ("far target untouched", targets[2].hit, 0); + + /* freeze test: ice barrage (spell_type=1) should set frozen_ticks */ + rng = 99; + int frozen = 0; + memset(targets, 0, sizeof(targets)); + targets[0].active = 1; + targets[0].x = 5; targets[0].y = 5; + targets[0].def_level = 1; /* very low def = near-guaranteed hit */ + targets[0].magic_def_bonus = 0; + targets[0].frozen_ticks = &frozen; + + /* run a few times to get at least one hit */ + int freeze_applied = 0; + for (int i = 0; i < 100 && !freeze_applied; i++) { + frozen = 0; + osrs_barrage_resolve(targets, 1, 50000, max_hit, &rng, 1 /* ICE */, 0); + if (frozen == BARRAGE_FREEZE_TICKS) freeze_applied = 1; + } + ASSERT_INT_EQ("ice freeze applied", freeze_applied, 1); + ASSERT_INT_EQ("freeze duration", BARRAGE_FREEZE_TICKS, 32); +} +/* test: defence bonus sum verification */ +/* */ +/* verifies that encounter_compute_loadout_stats correctly sums defence */ +/* bonuses across all gear slots (needed for player_def_roll calculations). */ +static void test_loadout_def_bonuses(void) { + printf("--- loadout defence bonus sums ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + + /* justiciar set: faceguard + chestguard + legguards */ + loadout[GEAR_SLOT_HEAD] = ITEM_JUSTICIAR_FACEGUARD; + loadout[GEAR_SLOT_BODY] = ITEM_JUSTICIAR_CHESTGUARD; + loadout[GEAR_SLOT_LEGS] = ITEM_JUSTICIAR_LEGGUARDS; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_NONE, + 99, FIGHT_STYLE_ACCURATE, 0, &stats); + + /* verify defence bonuses sum correctly from ITEM_DATABASE. + exact values depend on item stats — verify against DB directly */ + int exp_stab = ITEM_DATABASE[ITEM_JUSTICIAR_FACEGUARD].defence_stab + + ITEM_DATABASE[ITEM_JUSTICIAR_CHESTGUARD].defence_stab + + ITEM_DATABASE[ITEM_JUSTICIAR_LEGGUARDS].defence_stab; + int exp_slash = ITEM_DATABASE[ITEM_JUSTICIAR_FACEGUARD].defence_slash + + ITEM_DATABASE[ITEM_JUSTICIAR_CHESTGUARD].defence_slash + + ITEM_DATABASE[ITEM_JUSTICIAR_LEGGUARDS].defence_slash; + int exp_crush = ITEM_DATABASE[ITEM_JUSTICIAR_FACEGUARD].defence_crush + + ITEM_DATABASE[ITEM_JUSTICIAR_CHESTGUARD].defence_crush + + ITEM_DATABASE[ITEM_JUSTICIAR_LEGGUARDS].defence_crush; + int exp_magic = ITEM_DATABASE[ITEM_JUSTICIAR_FACEGUARD].defence_magic + + ITEM_DATABASE[ITEM_JUSTICIAR_CHESTGUARD].defence_magic + + ITEM_DATABASE[ITEM_JUSTICIAR_LEGGUARDS].defence_magic; + int exp_ranged = ITEM_DATABASE[ITEM_JUSTICIAR_FACEGUARD].defence_ranged + + ITEM_DATABASE[ITEM_JUSTICIAR_CHESTGUARD].defence_ranged + + ITEM_DATABASE[ITEM_JUSTICIAR_LEGGUARDS].defence_ranged; + + ASSERT_INT_EQ("def_stab", stats.def_stab, exp_stab); + ASSERT_INT_EQ("def_slash", stats.def_slash, exp_slash); + ASSERT_INT_EQ("def_crush", stats.def_crush, exp_crush); + ASSERT_INT_EQ("def_magic", stats.def_magic, exp_magic); + ASSERT_INT_EQ("def_ranged", stats.def_ranged, exp_ranged); +} +/* test: edge cases for level 1 and no gear */ +/* */ +/* verifies formulas don't break at minimum values. */ +static void test_edge_cases(void) { + printf("--- edge cases: level 1, no gear ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + + /* melee, level 1, no gear, no prayer, accurate stance (+3 att) */ + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_NONE, + 1, FIGHT_STYLE_ACCURATE, 0, &stats); + + /* eff = floor(1*1.0) + 3 + 8 = 12 (accurate +3 att) */ + ASSERT_INT_EQ("lv1 melee eff", stats.eff_level, 12); + + /* eff_str = 1 + 0 + 8 = 9 (accurate gives 0 to str), str_bonus = 0 */ + /* max_hit = floor(0.5 + 9 * (0+64) / 640) = floor(0.5 + 576/640) = floor(0.5 + 0.9) = 1 */ + ASSERT_INT_EQ("lv1 melee max", stats.max_hit, 1); + + /* attack_bonus = 0 (no weapon) */ + ASSERT_INT_EQ("lv1 melee att_bonus", stats.attack_bonus, 0); + + /* magic, level 1, no gear, barrage, autocast (no invisible bonus) */ + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, + 1, FIGHT_STYLE_AUTOCAST, 30, &stats); + + /* eff = floor(1*1.0) + 0 + 9 = 10 */ + ASSERT_INT_EQ("lv1 magic eff", stats.eff_level, 10); + + /* max_hit = floor(30 * (1.0 + 0/100.0) * 1.0) = 30 */ + ASSERT_INT_EQ("lv1 magic max", stats.max_hit, 30); +} + + +int main(void) { + printf("=== combat math tests (cross-referenced with osrs-dps-calc) ===\n\n"); + + /* pure math (osrs_combat_shared.h) */ + test_hit_chance(); + test_npc_melee_max_hit(); + test_npc_ranged_max_hit(); + test_npc_magic_max_hit(); + test_npc_attack_roll(); + test_npc_max_hit_dispatch(); + test_player_def_roll(); + test_player_def_bonus(); + test_prayer_correct(); + test_hit_delays(); + test_dist_to_npc(); + test_tbow_multipliers(); + test_blowpipe_spec(); + test_rng(); + test_barrage_resolve(); + + /* loadout stat computation (osrs_encounter.h) */ + test_loadout_melee_no_prayer(); + test_loadout_melee_piety(); + test_loadout_ranged_rigour(); + test_loadout_magic_augury(); + test_loadout_magic_no_prayer(); + test_loadout_full_ranged(); + test_update_loadout_level(); + test_loadout_def_bonuses(); + test_edge_cases(); + + /* end-to-end */ + test_full_npc_attack_scenario(); + + printf("\n=== results: %d/%d passed", tests_passed, tests_run); + if (tests_failed > 0) { + printf(", %d FAILED", tests_failed); + } + printf(" ===\n"); + + return tests_failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_consumables.c b/ocean/osrs/tests/test_consumables.c new file mode 100644 index 0000000000..76a4c3f32b --- /dev/null +++ b/ocean/osrs/tests/test_consumables.c @@ -0,0 +1,124 @@ +/** + * @file test_consumables.c + * @brief Tests for shared food/potion/brew functions in osrs_consumables.h. + * + * Build: cc -std=c11 -O0 -g -I. -o test_consumables ocean/osrs/tests/test_consumables.c -lm + */ + +#include +#include +#include "ocean/osrs/osrs_consumables.h" + +static int total_tests = 0; +static int passed_tests = 0; + +#define ASSERT_EQ(a, b, msg) do { \ + total_tests++; \ + if ((a) != (b)) { \ + printf("FAIL: %s: got %d, expected %d\n", msg, (int)(a), (int)(b)); \ + } else { passed_tests++; } \ +} while(0) + +/* --- food healing amounts --- */ +static void test_food_heal_amount(void) { + printf("--- food heal amounts ---\n"); + ASSERT_EQ(osrs_food_heal_amount(FOOD_SHARK), 20, "shark heals 20"); + ASSERT_EQ(osrs_food_heal_amount(FOOD_KARAMBWAN), 18, "karambwan heals 18"); + ASSERT_EQ(osrs_food_heal_amount(FOOD_MANTA_RAY), 22, "manta ray heals 22"); + ASSERT_EQ(osrs_food_heal_amount(FOOD_ANGLERFISH), 22, "anglerfish heals 22"); +} + +/* --- eating food --- */ +static void test_eat_food(void) { + printf("--- osrs_eat_food ---\n"); + + /* shark at 50/99 HP: heals 20 → 70 */ + EatResult r = osrs_eat_food(FOOD_SHARK, 50, 99, 0); + ASSERT_EQ(r.consumed, 1, "shark consumed"); + ASSERT_EQ(r.hp_healed, 20, "shark heals 20"); + + /* shark at 90/99: heals 9 (clamped to max) */ + r = osrs_eat_food(FOOD_SHARK, 90, 99, 0); + ASSERT_EQ(r.consumed, 1, "shark at 90 consumed"); + ASSERT_EQ(r.hp_healed, 9, "shark at 90 heals 9 (clamped)"); + + /* shark at 99/99: can't eat (full HP) */ + r = osrs_eat_food(FOOD_SHARK, 99, 99, 0); + ASSERT_EQ(r.consumed, 0, "shark at full HP not consumed"); + + /* shark with food_timer active: can't eat */ + r = osrs_eat_food(FOOD_SHARK, 50, 99, 2); + ASSERT_EQ(r.consumed, 0, "shark timer active not consumed"); + + /* anglerfish can overheal: at 99/99, heals to 121 */ + r = osrs_eat_food(FOOD_ANGLERFISH, 99, 99, 0); + ASSERT_EQ(r.consumed, 1, "anglerfish at full HP consumed (overheal)"); + ASSERT_EQ(r.hp_healed, 22, "anglerfish overheals 22"); +} + +/* --- potions --- */ +static void test_drink_potion(void) { + printf("--- osrs_drink_potion ---\n"); + + /* prayer pot: 7 + floor(prayer_level / 4). at 77: 7 + 19 = 26 */ + DrinkResult dr = osrs_drink_potion(POTION_PRAYER_RESTORE, 30, 77, 0); + ASSERT_EQ(dr.consumed, 1, "prayer pot consumed"); + ASSERT_EQ(dr.prayer_restored, 26, "prayer pot restores 26 at lvl 77"); + + /* super restore: 8 + floor(prayer_level / 4). at 77: 8 + 19 = 27 */ + dr = osrs_drink_potion(POTION_SUPER_RESTORE, 30, 77, 0); + ASSERT_EQ(dr.consumed, 1, "super restore consumed"); + ASSERT_EQ(dr.prayer_restored, 27, "super restore restores 27 at lvl 77"); + + /* prayer pot at full prayer: can't drink */ + dr = osrs_drink_potion(POTION_PRAYER_RESTORE, 77, 77, 0); + ASSERT_EQ(dr.consumed, 0, "prayer pot at full prayer not consumed"); + + /* potion timer active: can't drink */ + dr = osrs_drink_potion(POTION_PRAYER_RESTORE, 30, 77, 2); + ASSERT_EQ(dr.consumed, 0, "prayer pot timer active not consumed"); + + /* antivenom+: cures venom, grants immunity */ + dr = osrs_drink_potion(POTION_ANTIVENOM_PLUS, 50, 77, 0); + ASSERT_EQ(dr.consumed, 1, "antivenom consumed"); + ASSERT_EQ(dr.venom_cured, 1, "antivenom cures venom"); + ASSERT_EQ(dr.antivenom_ticks, 300, "antivenom grants 300 tick immunity"); +} + +/* --- brew --- */ +static void test_brew(void) { + printf("--- osrs_brew ---\n"); + + /* sara brew: heals 15% + 2 of max HP = floor(99*0.15)+2 = 16. + boosts def: floor(99*0.20)+2 = 21. + drains att/str/range/magic: floor(99*0.10)+2 = 11 each. */ + BrewResult br = osrs_brew_effect(99, 99, 99, 99, 99); + ASSERT_EQ(br.hp_healed, 16, "brew heals floor(99*0.15)+2=16"); + ASSERT_EQ(br.def_boost, 21, "brew def boost floor(99*0.20)+2=21"); + ASSERT_EQ(br.att_drain, 11, "brew att drain floor(99*0.10)+2=11"); + ASSERT_EQ(br.str_drain, 11, "brew str drain"); + ASSERT_EQ(br.range_drain, 11, "brew range drain"); + ASSERT_EQ(br.magic_drain, 11, "brew magic drain"); +} + +/* --- combo eat timing --- */ +static void test_combo_timing(void) { + printf("--- combo eat timing ---\n"); + + ASSERT_EQ(osrs_can_eat(0), 1, "can eat when timer=0"); + ASSERT_EQ(osrs_can_eat(1), 0, "can't eat when timer=1"); + ASSERT_EQ(osrs_can_eat(3), 0, "can't eat when timer=3"); + ASSERT_EQ(osrs_can_drink(0), 1, "can drink when timer=0"); + ASSERT_EQ(osrs_can_drink(2), 0, "can't drink when timer=2"); +} + +int main(void) { + test_food_heal_amount(); + test_eat_food(); + test_drink_potion(); + test_brew(); + test_combo_timing(); + + printf("\n=== results: %d/%d passed ===\n", passed_tests, total_tests); + return (passed_tests == total_tests) ? 0 : 1; +} diff --git a/ocean/osrs/tests/test_damage.c b/ocean/osrs/tests/test_damage.c new file mode 100644 index 0000000000..00aa4c9d80 --- /dev/null +++ b/ocean/osrs/tests/test_damage.c @@ -0,0 +1,359 @@ +/** + * @file test_damage.c + * @brief tests for osrs_damage.h: pending hit queue and damage pipeline + * (prayer reduction, vengeance, recoil, smite). + * + * BUILD: + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_damage \ + * ocean/osrs/tests/test_damage.c -lm + * ./test_damage + * + * REFERENCE FILES: + * ocean/osrs/osrs_damage.h — shared damage pipeline + * ocean/osrs/osrs_combat.h — osrs_prayer_reduce_damage + * ocean/osrs/osrs_pvp_combat.h:570-691 — original PvP apply_damage + * ocean/osrs/encounters/encounter_zulrah.h:651-675 — original zulrah recoil + */ + +#include +#include +#include +#include + +#include "ocean/osrs/osrs_damage.h" + + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + if ((actual) == (expected)) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %d, expected %d\n", (label), (actual), (expected)); \ + } \ +} while (0) + + +static void test_prayer_reduction(void) { + printf("--- prayer reduction ---\n"); + + /* PvE: correct prayer blocks 100% */ + DamageResult r = osrs_apply_damage_pipeline( + 30, ATTACK_STYLE_MELEE, PRAYER_PROTECT_MELEE, + /*is_pvp=*/0, /*veng=*/0, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("pve melee prayer block", r.final_damage, 0); + ASSERT_INT_EQ("pve melee prayer_blocked flag", r.prayer_blocked, 1); + + /* PvP: correct prayer reduces by 40% → floor(30 * 0.6) = 18 */ + r = osrs_apply_damage_pipeline( + 30, ATTACK_STYLE_MELEE, PRAYER_PROTECT_MELEE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("pvp melee prayer 40% reduction", r.final_damage, 18); + ASSERT_INT_EQ("pvp melee prayer_blocked flag", r.prayer_blocked, 1); + + /* PvE: wrong prayer passthrough */ + r = osrs_apply_damage_pipeline( + 30, ATTACK_STYLE_MELEE, PRAYER_PROTECT_MAGIC, + /*is_pvp=*/0, /*veng=*/0, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("pve wrong prayer passthrough", r.final_damage, 30); + ASSERT_INT_EQ("pve wrong prayer_blocked flag", r.prayer_blocked, 0); + + /* PvP: wrong prayer passthrough */ + r = osrs_apply_damage_pipeline( + 30, ATTACK_STYLE_RANGED, PRAYER_PROTECT_MELEE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("pvp wrong prayer passthrough", r.final_damage, 30); + + /* PvE: no prayer */ + r = osrs_apply_damage_pipeline( + 25, ATTACK_STYLE_MAGIC, PRAYER_NONE, + /*is_pvp=*/0, /*veng=*/0, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("pve no prayer passthrough", r.final_damage, 25); + ASSERT_INT_EQ("pve no prayer_blocked flag", r.prayer_blocked, 0); + + /* PvP: ranged prayer block (40% reduction) → floor(50 * 0.6) = 30 */ + r = osrs_apply_damage_pipeline( + 50, ATTACK_STYLE_RANGED, PRAYER_PROTECT_RANGED, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("pvp ranged prayer reduction", r.final_damage, 30); + + /* PvE: magic prayer blocks 100% */ + r = osrs_apply_damage_pipeline( + 40, ATTACK_STYLE_MAGIC, PRAYER_PROTECT_MAGIC, + /*is_pvp=*/0, /*veng=*/0, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("pve magic prayer block", r.final_damage, 0); +} + + +static void test_vengeance(void) { + printf("--- vengeance ---\n"); + + /* veng active: floor(20 * 0.75) = 15 */ + DamageResult r = osrs_apply_damage_pipeline( + 20, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/1, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("veng reflect 20 -> 15", r.veng_damage, 15); + ASSERT_INT_EQ("veng final_damage unchanged", r.final_damage, 20); + + /* veng on 0 damage: no reflect */ + r = osrs_apply_damage_pipeline( + 0, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/1, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("veng 0 damage no reflect", r.veng_damage, 0); + + /* veng inactive: no reflect */ + r = osrs_apply_damage_pipeline( + 20, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("veng inactive no reflect", r.veng_damage, 0); + + /* veng with prayer reduction: floor(18 * 0.75) = 13 + (30 raw, 40% reduction = 18, then veng on 18) */ + r = osrs_apply_damage_pipeline( + 30, ATTACK_STYLE_MELEE, PRAYER_PROTECT_MELEE, + /*is_pvp=*/1, /*veng=*/1, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("veng after prayer reduction", r.veng_damage, 13); + ASSERT_INT_EQ("veng after prayer final_damage", r.final_damage, 18); + + /* veng on 1 damage: floor(1 * 0.75) = 0 */ + r = osrs_apply_damage_pipeline( + 1, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/1, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("veng 1 damage reflect 0", r.veng_damage, 0); + + /* veng on PvE full prayer block: 0 damage after prayer, no reflect */ + r = osrs_apply_damage_pipeline( + 30, ATTACK_STYLE_MELEE, PRAYER_PROTECT_MELEE, + /*is_pvp=*/0, /*veng=*/1, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("veng pve prayer block no reflect", r.veng_damage, 0); +} + + +static void test_recoil(void) { + printf("--- recoil ---\n"); + + /* recoil: floor(20 / 10) + 1 = 3 */ + DamageResult r = osrs_apply_damage_pipeline( + 20, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*recoil ring=*/0, /*recoil=*/1, /*smite=*/0); + ASSERT_INT_EQ("recoil 20 -> 3", r.recoil_damage, 3); + + /* recoil: floor(1 / 10) + 1 = 1 */ + r = osrs_apply_damage_pipeline( + 1, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/1, /*smite=*/0); + ASSERT_INT_EQ("recoil 1 -> 1", r.recoil_damage, 1); + + /* recoil: floor(10 / 10) + 1 = 2 */ + r = osrs_apply_damage_pipeline( + 10, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/0, /*veng=*/0, /*recoil=*/1, /*smite=*/0); + ASSERT_INT_EQ("recoil 10 -> 2", r.recoil_damage, 2); + + /* no recoil ring: 0 */ + r = osrs_apply_damage_pipeline( + 20, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("no recoil ring 0", r.recoil_damage, 0); + + /* recoil on 0 damage: 0 */ + r = osrs_apply_damage_pipeline( + 0, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/1, /*smite=*/0); + ASSERT_INT_EQ("recoil 0 damage -> 0", r.recoil_damage, 0); + + /* recoil after PvE full prayer block: 0 */ + r = osrs_apply_damage_pipeline( + 30, ATTACK_STYLE_RANGED, PRAYER_PROTECT_RANGED, + /*is_pvp=*/0, /*veng=*/0, /*recoil=*/1, /*smite=*/0); + ASSERT_INT_EQ("recoil pve prayer block -> 0", r.recoil_damage, 0); + + /* recoil: floor(99 / 10) + 1 = 10 */ + r = osrs_apply_damage_pipeline( + 99, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/0, /*veng=*/0, /*recoil=*/1, /*smite=*/0); + ASSERT_INT_EQ("recoil 99 -> 10", r.recoil_damage, 10); +} + + +static void test_smite(void) { + printf("--- smite ---\n"); + + /* smite: floor(20 / 4) = 5 */ + DamageResult r = osrs_apply_damage_pipeline( + 20, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/1); + ASSERT_INT_EQ("smite 20 -> 5", r.smite_drain, 5); + + /* smite: floor(1 / 4) = 0 */ + r = osrs_apply_damage_pipeline( + 1, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/1); + ASSERT_INT_EQ("smite 1 -> 0", r.smite_drain, 0); + + /* smite: floor(7 / 4) = 1 */ + r = osrs_apply_damage_pipeline( + 7, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/1); + ASSERT_INT_EQ("smite 7 -> 1", r.smite_drain, 1); + + /* smite inactive: 0 */ + r = osrs_apply_damage_pipeline( + 20, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/0); + ASSERT_INT_EQ("smite inactive -> 0", r.smite_drain, 0); + + /* smite on 0 damage: 0 */ + r = osrs_apply_damage_pipeline( + 0, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/1); + ASSERT_INT_EQ("smite 0 damage -> 0", r.smite_drain, 0); + + /* smite after PvP prayer reduction: floor(18 / 4) = 4 + (30 raw, 40% reduction = 18) */ + r = osrs_apply_damage_pipeline( + 30, ATTACK_STYLE_MELEE, PRAYER_PROTECT_MELEE, + /*is_pvp=*/1, /*veng=*/0, /*recoil=*/0, /*smite=*/1); + ASSERT_INT_EQ("smite after pvp prayer", r.smite_drain, 4); +} + + +static void test_full_pipeline_pvp(void) { + printf("--- full pipeline PvP ---\n"); + + /* 40 raw, melee, protect melee (PvP 40% reduction) + final_damage = floor(40 * 0.6) = 24 + veng = floor(24 * 0.75) = 18 + recoil = floor(24 / 10) + 1 = 3 + smite = floor(24 / 4) = 6 */ + DamageResult r = osrs_apply_damage_pipeline( + 40, ATTACK_STYLE_MELEE, PRAYER_PROTECT_MELEE, + /*is_pvp=*/1, /*veng=*/1, /*recoil=*/1, /*smite=*/1); + ASSERT_INT_EQ("full pvp final_damage", r.final_damage, 24); + ASSERT_INT_EQ("full pvp veng", r.veng_damage, 18); + ASSERT_INT_EQ("full pvp recoil", r.recoil_damage, 3); + ASSERT_INT_EQ("full pvp smite", r.smite_drain, 6); + ASSERT_INT_EQ("full pvp prayer_blocked", r.prayer_blocked, 1); + + /* no prayer, 50 raw melee hit + final_damage = 50 + veng = floor(50 * 0.75) = 37 + recoil = floor(50 / 10) + 1 = 6 + smite = floor(50 / 4) = 12 */ + r = osrs_apply_damage_pipeline( + 50, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/1, /*recoil=*/1, /*smite=*/1); + ASSERT_INT_EQ("full pvp no prayer final", r.final_damage, 50); + ASSERT_INT_EQ("full pvp no prayer veng", r.veng_damage, 37); + ASSERT_INT_EQ("full pvp no prayer recoil", r.recoil_damage, 6); + ASSERT_INT_EQ("full pvp no prayer smite", r.smite_drain, 12); + ASSERT_INT_EQ("full pvp no prayer_blocked", r.prayer_blocked, 0); +} + + +static void test_full_pipeline_pve(void) { + printf("--- full pipeline PvE ---\n"); + + /* 30 raw, ranged, protect ranged (PvE = 100% block) + final_damage = 0 → all secondary effects = 0 */ + DamageResult r = osrs_apply_damage_pipeline( + 30, ATTACK_STYLE_RANGED, PRAYER_PROTECT_RANGED, + /*is_pvp=*/0, /*veng=*/1, /*recoil=*/1, /*smite=*/0); + ASSERT_INT_EQ("pve full block final", r.final_damage, 0); + ASSERT_INT_EQ("pve full block veng", r.veng_damage, 0); + ASSERT_INT_EQ("pve full block recoil", r.recoil_damage, 0); + ASSERT_INT_EQ("pve full block smite", r.smite_drain, 0); + ASSERT_INT_EQ("pve full block prayer_blocked", r.prayer_blocked, 1); + + /* 30 raw, magic, no prayer (PvE, veng + recoil active) + final_damage = 30 + veng = floor(30 * 0.75) = 22 + recoil = floor(30 / 10) + 1 = 4 */ + r = osrs_apply_damage_pipeline( + 30, ATTACK_STYLE_MAGIC, PRAYER_NONE, + /*is_pvp=*/0, /*veng=*/1, /*recoil=*/1, /*smite=*/0); + ASSERT_INT_EQ("pve no prayer final", r.final_damage, 30); + ASSERT_INT_EQ("pve no prayer veng", r.veng_damage, 22); + ASSERT_INT_EQ("pve no prayer recoil", r.recoil_damage, 4); + ASSERT_INT_EQ("pve no prayer smite", r.smite_drain, 0); +} + + +static void test_edge_cases(void) { + printf("--- edge cases ---\n"); + + /* 0 damage: all effects should be 0 */ + DamageResult r = osrs_apply_damage_pipeline( + 0, ATTACK_STYLE_MELEE, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/1, /*recoil=*/1, /*smite=*/1); + ASSERT_INT_EQ("zero damage final", r.final_damage, 0); + ASSERT_INT_EQ("zero damage veng", r.veng_damage, 0); + ASSERT_INT_EQ("zero damage recoil", r.recoil_damage, 0); + ASSERT_INT_EQ("zero damage smite", r.smite_drain, 0); + + /* 1 damage: recoil = floor(1/10)+1 = 1, veng = floor(0.75) = 0, smite = 0 */ + r = osrs_apply_damage_pipeline( + 1, ATTACK_STYLE_RANGED, PRAYER_NONE, + /*is_pvp=*/0, /*veng=*/1, /*recoil=*/1, /*smite=*/1); + ASSERT_INT_EQ("1 damage final", r.final_damage, 1); + ASSERT_INT_EQ("1 damage veng", r.veng_damage, 0); + ASSERT_INT_EQ("1 damage recoil", r.recoil_damage, 1); + ASSERT_INT_EQ("1 damage smite", r.smite_drain, 0); + + /* large hit: 99 damage, all active + veng = floor(99 * 0.75) = 74 + recoil = floor(99 / 10) + 1 = 10 + smite = floor(99 / 4) = 24 */ + r = osrs_apply_damage_pipeline( + 99, ATTACK_STYLE_MAGIC, PRAYER_NONE, + /*is_pvp=*/1, /*veng=*/1, /*recoil=*/1, /*smite=*/1); + ASSERT_INT_EQ("99 damage veng", r.veng_damage, 74); + ASSERT_INT_EQ("99 damage recoil", r.recoil_damage, 10); + ASSERT_INT_EQ("99 damage smite", r.smite_drain, 24); +} + + +static void test_has_recoil_ring(void) { + printf("--- has_recoil_ring ---\n"); + + uint8_t equipped[16]; + memset(equipped, ITEM_NONE, sizeof(equipped)); + + /* no ring */ + ASSERT_INT_EQ("no ring", osrs_has_recoil_ring(equipped), 0); + + /* ring of recoil */ + equipped[GEAR_SLOT_RING] = ITEM_RING_OF_RECOIL; + ASSERT_INT_EQ("ring of recoil", osrs_has_recoil_ring(equipped), 1); + + /* ring of suffering (i) */ + equipped[GEAR_SLOT_RING] = ITEM_RING_OF_SUFFERING_RI; + ASSERT_INT_EQ("ring of suffering (i)", osrs_has_recoil_ring(equipped), 1); + + /* some other ring */ + equipped[GEAR_SLOT_RING] = 42; + ASSERT_INT_EQ("other ring", osrs_has_recoil_ring(equipped), 0); +} + + +int main(void) { + printf("=== osrs_damage.h test suite ===\n\n"); + + test_prayer_reduction(); + test_vengeance(); + test_recoil(); + test_smite(); + test_full_pipeline_pvp(); + test_full_pipeline_pve(); + test_edge_cases(); + test_has_recoil_ring(); + + printf("\n=== results: %d passed, %d failed, %d total ===\n", + tests_passed, tests_failed, tests_run); + return tests_failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_gui_inventory.c b/ocean/osrs/tests/test_gui_inventory.c new file mode 100644 index 0000000000..b22ed99ad7 --- /dev/null +++ b/ocean/osrs/tests/test_gui_inventory.c @@ -0,0 +1,238 @@ +/** + * @file test_gui_inventory.c + * @brief Regression tests for GUI inventory snapshot/reset logic used by inferno human mode. + * + * BUILD: + * cc -std=c11 -O0 -g -I. -Iocean/osrs -I./raylib-5.5_macos/include \ + * -o /tmp/test_gui_inventory \ + * ocean/osrs/tests/test_gui_inventory.c ./raylib-5.5_macos/lib/libraylib.a \ + * -framework Cocoa -framework OpenGL -framework IOKit -framework CoreVideo -lm + * /tmp/test_gui_inventory + */ + +#include +#include + +#include "ocean/osrs/osrs_pvp_actions.h" +#include "ocean/osrs/encounters/encounter_inferno.h" +#include "ocean/osrs/osrs_gui.h" +#include "ocean/osrs/osrs_human_input.h" + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + if ((actual) == (expected)) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %d, expected %d\n", (label), (actual), (expected)); \ + } \ +} while (0) + +static int find_slot_of_type(const GuiState* gs, InvSlotType type) { + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == type) return i; + } + return -1; +} + +static int count_slots_of_type(const GuiState* gs, InvSlotType type) { + int count = 0; + for (int i = 0; i < INV_GRID_SLOTS; i++) { + if (gs->inv_grid[i].type == type) count++; + } + return count; +} + +static int expected_vial_count(int doses) { + return (doses + 3) / 4; +} + +static void test_gui_populate_tracks_bastion_and_stamina(void) { + printf("--- gui populate tracks inferno potion snapshots ---\n"); + + GuiState gs; + Player p; + memset(&gs, 0, sizeof(gs)); + memset(&p, 0, sizeof(p)); + + p.bastion_doses = 8; + p.stamina_doses = 4; + + gui_populate_inventory(&gs, &p); + + ASSERT_INT_EQ("snapshot keeps bastion doses", gs.inv_prev_bastion_doses, 8); + ASSERT_INT_EQ("snapshot keeps stamina doses", gs.inv_prev_stamina_doses, 4); + ASSERT_INT_EQ("bastion vials present", count_slots_of_type(&gs, INV_SLOT_BASTION_POT), 2); + ASSERT_INT_EQ("stamina vial present", find_slot_of_type(&gs, INV_SLOT_STAMINA_POT) >= 0, 1); +} + +static void test_gui_update_tracks_bastion_and_stamina(void) { + printf("--- gui update tracks inferno potion deltas ---\n"); + + GuiState gs; + Player p; + memset(&gs, 0, sizeof(gs)); + memset(&p, 0, sizeof(p)); + + p.bastion_doses = 8; + p.stamina_doses = 4; + gui_populate_inventory(&gs, &p); + + int bastion_slot = find_slot_of_type(&gs, INV_SLOT_BASTION_POT); + int stamina_slot = find_slot_of_type(&gs, INV_SLOT_STAMINA_POT); + + gs.human_clicked_inv_slot = bastion_slot; + p.bastion_doses = 7; + gui_update_inventory(&gs, &p); + + ASSERT_INT_EQ("bastion click latch clears after use", gs.human_clicked_inv_slot, -1); + ASSERT_INT_EQ("bastion snapshot updates", gs.inv_prev_bastion_doses, 7); + ASSERT_INT_EQ("bastion keeps two vials after one sip", count_slots_of_type(&gs, INV_SLOT_BASTION_POT), 2); + ASSERT_INT_EQ("bastion sprite downgrades to 3-dose", + gs.inv_grid[bastion_slot].osrs_id, gui_consumable_osrs_id(INV_SLOT_BASTION_POT, 3)); + + gs.human_clicked_inv_slot = stamina_slot; + p.stamina_doses = 3; + gui_update_inventory(&gs, &p); + + ASSERT_INT_EQ("stamina click latch clears after use", gs.human_clicked_inv_slot, -1); + ASSERT_INT_EQ("stamina snapshot updates", gs.inv_prev_stamina_doses, 3); + ASSERT_INT_EQ("stamina sprite downgrades to 3-dose", + gs.inv_grid[stamina_slot].osrs_id, gui_consumable_osrs_id(INV_SLOT_STAMINA_POT, 3)); +} + +static void test_gui_reset_helper_clears_inventory_interaction_state(void) { + printf("--- gui reset helper clears inventory interaction state ---\n"); + + GuiState gs; + memset(&gs, 0, sizeof(gs)); + gs.inv_grid_dirty = 0; + gs.human_clicked_inv_slot = 7; + gs.inv_dim_slot = 3; + gs.inv_dim_timer = 9; + gs.inv_drag_active = 1; + gs.inv_drag_src_slot = 12; + gs.inv_drag_start_x = 100; + gs.inv_drag_start_y = 120; + gs.inv_drag_mouse_x = 130; + gs.inv_drag_mouse_y = 140; + + gui_reset_inventory_ui_state(&gs); + + ASSERT_INT_EQ("grid marked dirty", gs.inv_grid_dirty, 1); + ASSERT_INT_EQ("clicked slot cleared", gs.human_clicked_inv_slot, -1); + ASSERT_INT_EQ("dim slot cleared", gs.inv_dim_slot, -1); + ASSERT_INT_EQ("dim timer cleared", gs.inv_dim_timer, 0); + ASSERT_INT_EQ("drag deactivated", gs.inv_drag_active, 0); + ASSERT_INT_EQ("drag source cleared", gs.inv_drag_src_slot, -1); +} + +static void test_gui_reset_rebuild_restores_potions(void) { + printf("--- gui reset rebuild restores inferno potions ---\n"); + + GuiState gs; + Player p; + memset(&gs, 0, sizeof(gs)); + memset(&p, 0, sizeof(p)); + + p.bastion_doses = 8; + p.stamina_doses = 4; + gui_populate_inventory(&gs, &p); + + p.bastion_doses = 0; + p.stamina_doses = 0; + gui_update_inventory(&gs, &p); + ASSERT_INT_EQ("bastion gone after depletion", find_slot_of_type(&gs, INV_SLOT_BASTION_POT), -1); + ASSERT_INT_EQ("stamina gone after depletion", find_slot_of_type(&gs, INV_SLOT_STAMINA_POT), -1); + + p.bastion_doses = 8; + p.stamina_doses = 4; + gui_reset_inventory_ui_state(&gs); + if (gs.inv_grid_dirty) { + gui_populate_inventory(&gs, &p); + gs.inv_grid_dirty = 0; + } + + ASSERT_INT_EQ("bastion restored after rebuild", count_slots_of_type(&gs, INV_SLOT_BASTION_POT), 2); + ASSERT_INT_EQ("stamina restored after rebuild", find_slot_of_type(&gs, INV_SLOT_STAMINA_POT) >= 0, 1); + ASSERT_INT_EQ("bastion snapshot restored", gs.inv_prev_bastion_doses, 8); + ASSERT_INT_EQ("stamina snapshot restored", gs.inv_prev_stamina_doses, 4); +} + +static void test_gui_populate_late_start_inferno_supplies(void) { + printf("--- gui populate late-start inferno supplies ---\n"); + + GuiState gs; + Player p; + memset(&gs, 0, sizeof(gs)); + memset(&p, 0, sizeof(p)); + + InfSupplyDoses full = inf_full_starting_supplies(); + InfSupplyDoses late = inf_supplies_for_start_wave(full, INF_NUM_WAVES - 1, 1.0f); + p.brew_doses = late.brew_doses; + p.restore_doses = late.restore_doses; + p.bastion_doses = late.bastion_doses; + p.stamina_doses = late.stamina_doses; + + gui_populate_inventory(&gs, &p); + + ASSERT_INT_EQ("late-start brew vial count", + count_slots_of_type(&gs, INV_SLOT_BREW), expected_vial_count(late.brew_doses)); + ASSERT_INT_EQ("late-start restore vial count", + count_slots_of_type(&gs, INV_SLOT_RESTORE), expected_vial_count(late.restore_doses)); + ASSERT_INT_EQ("late-start bastion vial count", + count_slots_of_type(&gs, INV_SLOT_BASTION_POT), expected_vial_count(late.bastion_doses)); + ASSERT_INT_EQ("late-start stamina vial count", + count_slots_of_type(&gs, INV_SLOT_STAMINA_POT), expected_vial_count(late.stamina_doses)); +} + +static void test_human_equipment_click_queues_without_mutating_player(void) { + printf("--- human equipment click queues without mutating player ---\n"); + + GuiState gs; + Player p; + HumanInput hi; + memset(&gs, 0, sizeof(gs)); + memset(&p, 0, sizeof(p)); + human_input_init(&hi); + + hi.enabled = 1; + p.equipped[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; + gs.inv_grid[0].type = INV_SLOT_EQUIPMENT; + gs.inv_grid[0].item_db_idx = ITEM_TOXIC_BLOWPIPE; + gs.inv_grid[0].osrs_id = ITEM_DATABASE[ITEM_TOXIC_BLOWPIPE].item_id; + + InvAction action = gui_inv_click(&gs, &p, 0, &hi); + + ASSERT_INT_EQ("equipment click returns equip action", action, INV_ACTION_EQUIP); + ASSERT_INT_EQ("weapon not mutated before tick", + p.equipped[GEAR_SLOT_WEAPON], ITEM_KODAI_WAND); + ASSERT_INT_EQ("one command queued", hi.commands.count, 1); + ASSERT_INT_EQ("queued equip command", + hi.commands.items[0].kind, HUMAN_COMMAND_EQUIP_INVENTORY_ITEM); + ASSERT_INT_EQ("queued source slot", hi.commands.items[0].inventory_slot, 0); + ASSERT_INT_EQ("queued item", hi.commands.items[0].item_db_idx, ITEM_TOXIC_BLOWPIPE); + + human_input_destroy(&hi); +} + +int main(void) { + test_gui_populate_tracks_bastion_and_stamina(); + test_gui_update_tracks_bastion_and_stamina(); + test_gui_reset_helper_clears_inventory_interaction_state(); + test_gui_reset_rebuild_restores_potions(); + test_gui_populate_late_start_inferno_supplies(); + test_human_equipment_click_queues_without_mutating_player(); + + printf("\n%d/%d tests passed", tests_passed, tests_run); + if (tests_failed > 0) { + printf(" (%d failed)\n", tests_failed); + return 1; + } + printf("\n"); + return 0; +} diff --git a/ocean/osrs/tests/test_human_commands.c b/ocean/osrs/tests/test_human_commands.c new file mode 100644 index 0000000000..e3a8656bbf --- /dev/null +++ b/ocean/osrs/tests/test_human_commands.c @@ -0,0 +1,101 @@ +/** + * @file test_human_commands.c + * @brief tests for human command queue staging used by encounter human mode + * + * BUILD: + * cc -std=c11 -O0 -g -I. -o /tmp/test_human_commands \ + * ocean/osrs/tests/test_human_commands.c -lm + * /tmp/test_human_commands + */ + +#include +#include + +#include "ocean/osrs/osrs_types.h" +#include "ocean/osrs/osrs_items.h" +#include "ocean/osrs/osrs_human_input_types.h" + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + int _actual = (actual); \ + int _expected = (expected); \ + if (_actual == _expected) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s: got %d, expected %d\n", \ + (label), _actual, _expected); \ + } \ +} while (0) + +static void test_command_queue_preserves_order(void) { + printf("--- human command queue preserves order ---\n"); + + HumanInput input; + human_input_init(&input); + + human_input_queue_walk(&input, 10, 20); + human_input_queue_attack_npc(&input, 7); + human_input_queue_drink(&input, POTION_BASTION, 3); + + ASSERT_INT_EQ("queue count", input.commands.count, 3); + ASSERT_INT_EQ("first kind", input.commands.items[0].kind, HUMAN_COMMAND_WALK); + ASSERT_INT_EQ("first x", input.commands.items[0].world_x, 10); + ASSERT_INT_EQ("second kind", input.commands.items[1].kind, HUMAN_COMMAND_ATTACK_NPC); + ASSERT_INT_EQ("second npc slot", input.commands.items[1].npc_slot, 7); + ASSERT_INT_EQ("third kind", input.commands.items[2].kind, HUMAN_COMMAND_DRINK); + ASSERT_INT_EQ("third potion", input.commands.items[2].potion, POTION_BASTION); + + human_input_destroy(&input); +} + +static void test_command_queue_grows_without_silent_cap(void) { + printf("--- human command queue grows without silent cap ---\n"); + + HumanInput input; + human_input_init(&input); + + for (int i = 0; i < 40; i++) { + human_input_queue_equip_inventory_item(&input, i, ITEM_TOXIC_BLOWPIPE, GEAR_SLOT_WEAPON); + } + + ASSERT_INT_EQ("queue count after growth", input.commands.count, 40); + ASSERT_INT_EQ("first slot", input.commands.items[0].inventory_slot, 0); + ASSERT_INT_EQ("last slot", input.commands.items[39].inventory_slot, 39); + + human_input_destroy(&input); +} + +static void test_command_queue_clear_drains_commands(void) { + printf("--- human command queue clear drains commands ---\n"); + + HumanInput input; + human_input_init(&input); + + human_input_queue_walk(&input, 1, 2); + human_input_queue_spec_toggle(&input); + human_input_clear_commands(&input); + + ASSERT_INT_EQ("queue count after clear", input.commands.count, 0); + ASSERT_INT_EQ("queue capacity retained", input.commands.capacity > 0, 1); + + human_input_destroy(&input); +} + +int main(void) { + test_command_queue_preserves_order(); + test_command_queue_grows_without_silent_cap(); + test_command_queue_clear_drains_commands(); + + printf("\n%d/%d tests passed", tests_passed, tests_run); + if (tests_failed > 0) { + printf(" (%d failed)\n", tests_failed); + return 1; + } + printf("\n"); + return 0; +} diff --git a/ocean/osrs/tests/test_inferno_attack_styles.c b/ocean/osrs/tests/test_inferno_attack_styles.c new file mode 100644 index 0000000000..e6bb448520 --- /dev/null +++ b/ocean/osrs/tests/test_inferno_attack_styles.c @@ -0,0 +1,1583 @@ +/** + * @file test_inferno_attack_styles.c + * @brief regression tests for inferno NPC attack-style selection and melee + * fallback geometry. + * + * BUILD: + * cc -std=c11 -O0 -g -I. -o /tmp/test_inferno_attack_styles \ + * ocean/osrs/tests/test_inferno_attack_styles.c -lm + * /tmp/test_inferno_attack_styles + */ + +#include +#include +#include + +#include "ocean/osrs/encounters/encounter_inferno.h" + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + if ((actual) == (expected)) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %d, expected %d\n", (label), (actual), (expected)); \ + } \ +} while (0) + +#define ASSERT_FLOAT_NEAR(label, actual, expected, tol) do { \ + tests_run++; \ + float diff = (float)((actual) - (expected)); \ + if (diff < 0.0f) diff = -diff; \ + if (diff <= (tol)) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %.6f, expected %.6f (tol %.6f)\n", \ + (label), (float)(actual), (float)(expected), (float)(tol)); \ + } \ +} while (0) + +static InfernoState make_test_state(int player_x, int player_y) { + InfernoState state; + memset(&state, 0, sizeof(state)); + memset(state.npc_los_cache, -1, sizeof(state.npc_los_cache)); + state.player.x = player_x; + state.player.y = player_y; + state.player_last_interaction_target_slot = -1; + state.player_last_interaction_age = 1; + return state; +} + +static InfNPC make_test_npc(InfNPCType type, int x, int y, int size) { + InfNPC npc; + memset(&npc, 0, sizeof(npc)); + npc.type = type; + npc.x = x; + npc.y = y; + npc.size = size; + npc.aggro_target = -1; + npc.attack_visual_target = -1; + npc.jad_owner_idx = -1; + npc.blob_scanned_prayer = -1; + npc.jad_attack_style = ATTACK_STYLE_NONE; + return npc; +} + +static InfNPCStats make_test_stats(int default_style) { + InfNPCStats stats; + memset(&stats, 0, sizeof(stats)); + stats.default_style = default_style; + stats.can_melee = 1; + return stats; +} + +static HumanInput make_human_input(void) { + HumanInput input; + memset(&input, 0, sizeof(input)); + input.pending_move_x = -1; + input.pending_move_y = -1; + input.pending_prayer = -1; + input.pending_offensive_prayer = -1; + input.pending_target_idx = -1; + return input; +} + +static void init_jad_timing_test_state(InfernoState* state, int player_x, int player_y, int jad_x, int jad_y) { + memset(state, 0, sizeof(*state)); + memset(state->npc_los_cache, -1, sizeof(state->npc_los_cache)); + state->rng_state = 12345; + state->wave = 66; + state->player.entity_type = ENTITY_PLAYER; + state->player.x = player_x; + state->player.y = player_y; + state->player.base_hitpoints = 99; + state->player.current_hitpoints = 99; + state->player.base_prayer = 99; + state->player.current_prayer = 99; + state->player.base_attack = 99; + state->player.base_strength = 99; + state->player.base_defence = 99; + state->player.base_ranged = 99; + state->player.base_magic = 99; + state->player.current_attack = 99; + state->player.current_strength = 99; + state->player.current_defence = 99; + state->player.current_ranged = 99; + state->player.current_magic = 99; + state->player.prayer = PRAYER_NONE; + state->weapon_set = INF_GEAR_MAGE; + state->player_last_interaction_target_slot = -1; + state->player_last_interaction_age = 1; + state->player_dest_x = -1; + state->player_dest_y = -1; + osrs_interaction_init(&state->interaction); + encounter_compute_loadout_stats(INF_MAGE_LOADOUT, ATTACK_STYLE_MAGIC, + OFFENSIVE_PRAYER_NONE, 99, FIGHT_STYLE_AUTOCAST, 30, + &state->loadout_stats[INF_GEAR_MAGE]); + + state->npcs[0] = make_test_npc( + INF_NPC_JAD, jad_x, jad_y, INF_NPC_STATS[INF_NPC_JAD].size); + state->npcs[0].active = 1; + state->npcs[0].attack_timer = 0; + state->npcs[0].jad_attack_style = ATTACK_STYLE_MAGIC; + state->npcs[0].attack_style = ATTACK_STYLE_RANGED; +} + +static void step_inferno_with_prayer(InfernoState* state, int prayer_action) { + int actions[INF_NUM_ACTION_HEADS]; + memset(actions, 0, sizeof(actions)); + actions[INF_HEAD_PRAYER] = prayer_action; + inf_step((EncounterState*)state, actions); +} + +static int force_mager_resurrect(InfernoState* s, int idx) { + for (uint32_t seed = 1; seed < 100000; seed++) { + InfernoState probe = *s; + probe.rng_state = seed; + if (inf_mager_resurrect(&probe, idx)) { + s->rng_state = seed; + return inf_mager_resurrect(s, idx); + } + } + return 0; +} + +static int distance_to_player(const InfernoState* state, const InfNPC* npc) { + return encounter_dist_to_npc( + state->player.x, state->player.y, npc->x, npc->y, npc->size); +} + +static int test_profiled_supply_count(int full_doses, float profile_fraction, float scale) { + float effective_fraction = 1.0f - scale * (1.0f - profile_fraction); + int doses = (int)((float)full_doses * effective_fraction + 0.5f); + if (doses < 0) doses = 0; + if (doses > full_doses) doses = full_doses; + return doses; +} + +static void reset_inferno_at_public_wave(EncounterState* raw_state, + int public_wave, + float supply_profile_scale) { + inf_put_int(raw_state, "start_wave", public_wave); + inf_put_float(raw_state, "damage_reward_coeff", 0.01f); + inf_put_float(raw_state, "shield_penalty_coeff", 0.01f); + inf_put_float(raw_state, "tag_reward_coeff", 0.25f); + inf_put_float(raw_state, "late_start_supply_profile_scale", supply_profile_scale); + inf_reset(raw_state, 123); +} + +static void assert_supply_doses(const char* label, + const Player* player, + InfSupplyDoses expected) { + char buf[128]; + snprintf(buf, sizeof(buf), "%s brew doses", label); + ASSERT_INT_EQ(buf, player->brew_doses, expected.brew_doses); + snprintf(buf, sizeof(buf), "%s restore doses", label); + ASSERT_INT_EQ(buf, player->restore_doses, expected.restore_doses); + snprintf(buf, sizeof(buf), "%s bastion doses", label); + ASSERT_INT_EQ(buf, player->bastion_doses, expected.bastion_doses); + snprintf(buf, sizeof(buf), "%s stamina doses", label); + ASSERT_INT_EQ(buf, player->stamina_doses, expected.stamina_doses); +} + +static void test_start_wave_public_to_internal_mapping(void) { + printf("--- inferno start-wave public to internal mapping ---\n"); + + EncounterState* raw_state = inf_create(); + InfernoState* state = (InfernoState*)raw_state; + + inf_put_int(raw_state, "start_wave", 0); + ASSERT_INT_EQ("public wave 0 aliases internal wave 0", state->start_wave, 0); + inf_put_int(raw_state, "start_wave", 1); + ASSERT_INT_EQ("public wave 1 maps to internal wave 0", state->start_wave, 0); + inf_put_int(raw_state, "start_wave", 67); + ASSERT_INT_EQ("public wave 67 maps to single Jad internal wave", state->start_wave, 66); + inf_put_int(raw_state, "start_wave", 68); + ASSERT_INT_EQ("public wave 68 maps to triple Jad internal wave", state->start_wave, 67); + inf_put_int(raw_state, "start_wave", 69); + ASSERT_INT_EQ("public wave 69 maps to Zuk internal wave", state->start_wave, 68); + + inf_destroy(raw_state); +} + +static void test_reward_switches_between_healer_tags_and_damage(void) { + printf("--- inferno reward switches between healer tags and damage ---\n"); + + InfernoState healing_state = make_test_state(24, 24); + InfernoState damage_state = make_test_state(24, 24); + + inf_put_float((EncounterState*)&healing_state, "damage_reward_coeff", 0.01f); + inf_put_float((EncounterState*)&healing_state, "shield_penalty_coeff", 0.01f); + inf_put_float((EncounterState*)&healing_state, "tag_reward_coeff", 0.25f); + healing_state.wave = INF_NUM_WAVES - 1; + healing_state.damage_dealt_this_tick = 50.0f; + healing_state.hp_restored_this_tick = 10.0f; + healing_state.shield_damage_this_tick = 7.0f; + healing_state.healer_tags_this_tick = 2; + healing_state.npcs[0] = make_test_npc(INF_NPC_HEALER_ZUK, 26, 24, 1); + healing_state.npcs[0].active = 1; + healing_state.npcs[0].aggro_target = 1; + healing_state.npcs[1] = make_test_npc(INF_NPC_ZUK, 28, 24, 5); + healing_state.npcs[1].active = 1; + healing_state.min_zuk_hp_seen = 1200.0f; + + damage_state = healing_state; + damage_state.wave = 0; + damage_state.npcs[0].aggro_target = -1; + + ASSERT_FLOAT_NEAR("final-wave active healer reward uses tag path", + inf_compute_reward(&healing_state), 0.43f, 0.0001f); + ASSERT_FLOAT_NEAR("non-final-wave reward still uses damage path", + inf_compute_reward(&damage_state), 0.33f, 0.0001f); +} + +static void test_final_wave_reward_uses_zuk_low_watermark_progress(void) { + printf("--- final-wave reward uses zuk low-watermark progress ---\n"); + + InfernoState state = make_test_state(24, 24); + + inf_put_float((EncounterState*)&state, "damage_reward_coeff", 0.01f); + inf_put_float((EncounterState*)&state, "shield_penalty_coeff", 0.01f); + inf_put_float((EncounterState*)&state, "tag_reward_coeff", 0.25f); + state.wave = INF_NUM_WAVES - 1; + state.min_zuk_hp_seen = 1200.0f; + state.npcs[0] = make_test_npc(INF_NPC_ZUK, 22, 50, 5); + state.npcs[0].active = 1; + state.npcs[0].hp = 1150; + state.npcs[0].max_hp = 1200; + state.npcs[1] = make_test_npc(INF_NPC_JAD, 24, 32, 5); + state.npcs[1].active = 1; + + state.damage_dealt_this_tick = 250.0f; + state.hp_restored_this_tick = 100.0f; + state.shield_damage_this_tick = 7.0f; + ASSERT_FLOAT_NEAR("first zuk low watermark pays progress minus shield penalty", + inf_compute_reward(&state), 0.43f, 0.0001f); + ASSERT_FLOAT_NEAR("first zuk low watermark updates state", + state.min_zuk_hp_seen, 1150.0f, 0.0001f); + + state.damage_dealt_this_tick = 400.0f; + state.hp_restored_this_tick = 0.0f; + state.shield_damage_this_tick = 0.0f; + ASSERT_FLOAT_NEAR("repeated hits at same zuk hp give zero reward", + inf_compute_reward(&state), 0.0f, 0.0001f); + ASSERT_FLOAT_NEAR("same-hp hits keep low watermark", + state.min_zuk_hp_seen, 1150.0f, 0.0001f); + + state.damage_dealt_this_tick = 600.0f; + state.npcs[0].hp = 1180; + ASSERT_FLOAT_NEAR("healed zuk above low watermark gives zero reward", + inf_compute_reward(&state), 0.0f, 0.0001f); + ASSERT_FLOAT_NEAR("healed zuk does not revoke low watermark", + state.min_zuk_hp_seen, 1150.0f, 0.0001f); + + state.damage_dealt_this_tick = 900.0f; + ASSERT_FLOAT_NEAR("non-zuk damage without new low watermark gives zero reward", + inf_compute_reward(&state), 0.0f, 0.0001f); + ASSERT_FLOAT_NEAR("non-zuk damage leaves low watermark unchanged", + state.min_zuk_hp_seen, 1150.0f, 0.0001f); + + state.npcs[0].hp = 1140; + state.damage_dealt_this_tick = 50.0f; + ASSERT_FLOAT_NEAR("new lower zuk hp pays only incremental progress", + inf_compute_reward(&state), 0.10f, 0.0001f); + ASSERT_FLOAT_NEAR("new lower zuk hp refreshes low watermark", + state.min_zuk_hp_seen, 1140.0f, 0.0001f); +} + +static void test_inferno_reset_supplies_match_current_inventory(void) { + printf("--- inferno reset supplies match current inventory ---\n"); + + EncounterState* raw_state = inf_create(); + InfernoState* state = (InfernoState*)raw_state; + InfSupplyDoses full = inf_full_starting_supplies(); + + reset_inferno_at_public_wave(raw_state, 1, 1.0f); + + assert_supply_doses("wave 1", &state->player, full); + + inf_destroy(raw_state); +} + +static void test_late_start_supply_profile_anchor_waves(void) { + printf("--- inferno late-start supply profile anchor waves ---\n"); + + struct { + int public_wave; + float brew_fraction; + float restore_fraction; + float bastion_fraction; + float stamina_fraction; + } anchors[] = { + { 20, 1.0000f, 0.9500f, 1.0000f, 1.0000f }, + { 40, 0.9167f, 0.8750f, 1.0000f, 1.0000f }, + { 61, 0.8333f, 0.7500f, 1.0000f, 1.0000f }, + { 64, 0.5833f, 0.5000f, 0.7500f, 1.0000f }, + { 68, 0.5833f, 0.4250f, 0.6250f, 1.0000f }, + { 69, 0.5000f, 0.3000f, 0.3750f, 1.0000f }, + }; + + EncounterState* raw_state = inf_create(); + InfernoState* state = (InfernoState*)raw_state; + InfSupplyDoses full = inf_full_starting_supplies(); + + for (int i = 0; i < (int)(sizeof(anchors) / sizeof(anchors[0])); i++) { + reset_inferno_at_public_wave(raw_state, anchors[i].public_wave, 1.0f); + InfSupplyDoses expected = { + .brew_doses = test_profiled_supply_count(full.brew_doses, + anchors[i].brew_fraction, 1.0f), + .restore_doses = test_profiled_supply_count(full.restore_doses, + anchors[i].restore_fraction, 1.0f), + .bastion_doses = test_profiled_supply_count(full.bastion_doses, + anchors[i].bastion_fraction, 1.0f), + .stamina_doses = test_profiled_supply_count(full.stamina_doses, + anchors[i].stamina_fraction, 1.0f), + }; + char label[64]; + snprintf(label, sizeof(label), "wave %d", anchors[i].public_wave); + assert_supply_doses(label, &state->player, expected); + } + + inf_destroy(raw_state); +} + +static void test_late_start_supply_profile_interpolation_and_scale(void) { + printf("--- inferno late-start supply profile interpolation and scale ---\n"); + + EncounterState* raw_state = inf_create(); + InfernoState* state = (InfernoState*)raw_state; + InfSupplyDoses full = inf_full_starting_supplies(); + float t = 1.0f / 3.0f; + float brew_fraction = 0.8333f + (0.5833f - 0.8333f) * t; + float restore_fraction = 0.7500f + (0.5000f - 0.7500f) * t; + float bastion_fraction = 1.0000f + (0.7500f - 1.0000f) * t; + + reset_inferno_at_public_wave(raw_state, 62, 1.0f); + InfSupplyDoses interpolated = { + .brew_doses = test_profiled_supply_count(full.brew_doses, brew_fraction, 1.0f), + .restore_doses = test_profiled_supply_count(full.restore_doses, restore_fraction, 1.0f), + .bastion_doses = test_profiled_supply_count(full.bastion_doses, bastion_fraction, 1.0f), + .stamina_doses = full.stamina_doses, + }; + assert_supply_doses("wave 62", &state->player, interpolated); + + reset_inferno_at_public_wave(raw_state, 69, 0.0f); + assert_supply_doses("wave 69 scale 0", &state->player, full); + + reset_inferno_at_public_wave(raw_state, 69, 0.5f); + InfSupplyDoses half_scale = { + .brew_doses = test_profiled_supply_count(full.brew_doses, 0.5000f, 0.5f), + .restore_doses = test_profiled_supply_count(full.restore_doses, 0.3000f, 0.5f), + .bastion_doses = test_profiled_supply_count(full.bastion_doses, 0.3750f, 0.5f), + .stamina_doses = test_profiled_supply_count(full.stamina_doses, 1.0000f, 0.5f), + }; + assert_supply_doses("wave 69 scale 0.5", &state->player, half_scale); + + inf_destroy(raw_state); +} + +static void test_late_start_supply_observations(void) { + printf("--- inferno late-start supply observations ---\n"); + + EncounterState* raw_state = inf_create(); + InfernoState* state = (InfernoState*)raw_state; + InfSupplyDoses full = inf_full_starting_supplies(); + float obs[INF_NUM_OBS]; + + reset_inferno_at_public_wave(raw_state, 69, 1.0f); + inf_write_obs(raw_state, obs); + + enum { + INF_OBS_BREW_DOSES = 11, + INF_OBS_RESTORE_DOSES = 12, + INF_OBS_BASTION_DOSES = 20, + INF_OBS_STAMINA_DOSES = 21, + }; + ASSERT_FLOAT_NEAR("brew obs uses full-kit denominator", + obs[INF_OBS_BREW_DOSES], + (float)state->player.brew_doses / (float)full.brew_doses, 0.0001f); + ASSERT_FLOAT_NEAR("restore obs uses full-kit denominator", + obs[INF_OBS_RESTORE_DOSES], + (float)state->player.restore_doses / (float)full.restore_doses, 0.0001f); + ASSERT_FLOAT_NEAR("bastion obs uses full-kit denominator", + obs[INF_OBS_BASTION_DOSES], + (float)state->player.bastion_doses / (float)full.bastion_doses, 0.0001f); + ASSERT_FLOAT_NEAR("stamina obs uses full-kit denominator", + obs[INF_OBS_STAMINA_DOSES], + (float)state->player.stamina_doses / (float)full.stamina_doses, 0.0001f); + + inf_destroy(raw_state); +} + +static void test_tagged_jad_healer_melee_geometry(void) { + printf("--- tagged jad healer melee geometry ---\n"); + + InfernoState diagonal_state = make_test_state(5, 5); + InfernoState cardinal_state = make_test_state(5, 5); + + diagonal_state.player.current_defence = 99; + diagonal_state.player.current_magic = 99; + diagonal_state.player.prayer = PRAYER_NONE; + diagonal_state.weapon_set = INF_GEAR_MAGE; + + cardinal_state.player.current_defence = 99; + cardinal_state.player.current_magic = 99; + cardinal_state.player.prayer = PRAYER_NONE; + cardinal_state.weapon_set = INF_GEAR_MAGE; + + diagonal_state.npcs[0] = make_test_npc(INF_NPC_HEALER_JAD, 6, 6, 1); + diagonal_state.npcs[0].active = 1; + diagonal_state.npcs[0].aggro_target = -1; + + cardinal_state.npcs[0] = make_test_npc(INF_NPC_HEALER_JAD, 6, 5, 1); + cardinal_state.npcs[0].active = 1; + cardinal_state.npcs[0].aggro_target = -1; + + inf_npc_attack(&diagonal_state, 0); + inf_npc_attack(&cardinal_state, 0); + + ASSERT_INT_EQ("diagonal healer does not attack", diagonal_state.npcs[0].attacked_this_tick, 0); + ASSERT_INT_EQ("diagonal healer keeps attack style none", + diagonal_state.npcs[0].attack_style_this_tick, ATTACK_STYLE_NONE); + ASSERT_INT_EQ("cardinal healer attacks", cardinal_state.npcs[0].attacked_this_tick, 1); + ASSERT_INT_EQ("cardinal healer uses melee", + cardinal_state.npcs[0].attack_style_this_tick, ATTACK_STYLE_MELEE); +} + +static void test_overlap_shuffle_hold_after_recent_target_click(void) { + printf("--- overlap shuffle held after recent target click ---\n"); + + InfernoState state = make_test_state(20, 20); + state.player_last_interaction_target_slot = 0; + state.player_last_interaction_age = 0; + + state.npcs[0] = make_test_npc( + INF_NPC_RANGER, 20, 20, INF_NPC_STATS[INF_NPC_RANGER].size); + state.npcs[0].active = 1; + + inf_npc_move(&state, 0); + + ASSERT_INT_EQ("held overlap keeps x", state.npcs[0].x, 20); + ASSERT_INT_EQ("held overlap keeps y", state.npcs[0].y, 20); + ASSERT_INT_EQ("held overlap does not mark moved", state.npcs[0].moved_this_tick, 0); +} + +static void test_overlap_shuffle_respects_npc_collision_flags(void) { + printf("--- overlap shuffle respects npc collision flags ---\n"); + + InfernoState state = make_test_state(20, 20); + state.rng_state = 12345; + + state.npcs[0] = make_test_npc(INF_NPC_HEALER_JAD, 20, 20, 1); + state.npcs[0].active = 1; + state.npcs[1] = make_test_npc(INF_NPC_HEALER_JAD, 21, 20, 1); + state.npcs[1].active = 1; + state.npcs[2] = make_test_npc(INF_NPC_HEALER_JAD, 19, 20, 1); + state.npcs[2].active = 1; + state.npcs[3] = make_test_npc(INF_NPC_HEALER_JAD, 20, 21, 1); + state.npcs[3].active = 1; + + inf_rebuild_entity_collision_flags(&state); + inf_npc_move(&state, 0); + + ASSERT_INT_EQ("overlap shuffle picks the only free tile x", state.npcs[0].x, 20); + ASSERT_INT_EQ("overlap shuffle picks the only free tile y", state.npcs[0].y, 19); +} + +static void test_tagged_jad_healer_stops_at_melee_contact(void) { + printf("--- tagged jad healer stops at melee contact ---\n"); + + InfernoState state = make_test_state(20, 20); + state.npcs[0] = make_test_npc(INF_NPC_HEALER_JAD, 19, 20, 1); + state.npcs[0].active = 1; + state.npcs[0].aggro_target = -1; + + inf_rebuild_entity_collision_flags(&state); + inf_npc_move(&state, 0); + + ASSERT_INT_EQ("healer keeps melee contact x", state.npcs[0].x, 19); + ASSERT_INT_EQ("healer keeps melee contact y", state.npcs[0].y, 20); + ASSERT_INT_EQ("healer does not mark moved", state.npcs[0].moved_this_tick, 0); +} + +static void test_tagged_jad_healers_queue_behind_front_healer(void) { + printf("--- tagged jad healers queue behind front healer ---\n"); + + InfernoState state = make_test_state(20, 20); + state.player.current_defence = 99; + state.player.current_magic = 99; + state.player.prayer = PRAYER_PROTECT_MAGIC; + state.weapon_set = INF_GEAR_MAGE; + + for (int i = 0; i < 5; i++) { + state.npcs[i] = make_test_npc(INF_NPC_HEALER_JAD, 19 - i, 20, 1); + state.npcs[i].active = 1; + state.npcs[i].aggro_target = -1; + state.npcs[i].attack_timer = 0; + } + + inf_rebuild_entity_collision_flags(&state); + inf_tick_npcs(&state); + + int attacks = 0; + int on_player = 0; + for (int i = 0; i < 5; i++) { + if (state.npcs[i].attacked_this_tick) attacks++; + if (state.npcs[i].x == state.player.x && state.npcs[i].y == state.player.y) + on_player++; + } + + ASSERT_INT_EQ("only front healer attacks", attacks, 1); + ASSERT_INT_EQ("no healer steps onto player", on_player, 0); + ASSERT_INT_EQ("front healer remains first in queue", state.npcs[0].x, 19); + ASSERT_INT_EQ("second healer remains blocked behind front", state.npcs[1].x, 18); +} + +static void test_stacked_npc_unclipping_clears_flag_when_one_leaves(void) { + printf("--- stacked npc unclipping clears flag when one leaves ---\n"); + + InfernoState state = make_test_state(25, 25); + state.npcs[0] = make_test_npc(INF_NPC_HEALER_JAD, 20, 20, 1); + state.npcs[0].active = 1; + state.npcs[1] = make_test_npc(INF_NPC_HEALER_JAD, 20, 20, 1); + state.npcs[1].active = 1; + + inf_rebuild_entity_collision_flags(&state); + ASSERT_INT_EQ("stacked tile initially flagged", + state.npc_collision_flags[20 - INF_ARENA_MIN_X][20 - INF_ARENA_MIN_Y], 1); + + inf_update_npc_collision_flags(&state, 0, 20, 20, 21, 20, 1); + + ASSERT_INT_EQ("old stacked tile unclipped", + state.npc_collision_flags[20 - INF_ARENA_MIN_X][20 - INF_ARENA_MIN_Y], 0); + ASSERT_INT_EQ("new tile flagged", + state.npc_collision_flags[21 - INF_ARENA_MIN_X][20 - INF_ARENA_MIN_Y], 1); +} + +static void test_jad_healer_spawn_offsets_match_wave_67_reference(void) { + printf("--- jad healer spawn offsets match wave 67 reference ---\n"); + + InfernoState state = make_test_state(18, 32); + state.rng_state = 12345; + state.wave = 66; + state.npcs[0] = make_test_npc(INF_NPC_JAD, 23, 30, INF_NPC_STATS[INF_NPC_JAD].size); + state.npcs[0].active = 1; + state.npcs[0].hp = 100; + state.npcs[0].max_hp = 300; + + inf_rebuild_entity_collision_flags(&state); + inf_jad_check_healers(&state, 0); + + int healers = 0; + for (int i = 1; i < INF_MAX_NPCS; i++) { + if (!state.npcs[i].active || state.npcs[i].type != INF_NPC_HEALER_JAD) continue; + healers++; + int dx = state.npcs[i].x - state.npcs[0].x; + int dy = state.npcs[i].y - state.npcs[0].y; + ASSERT_INT_EQ("wave 67 healer owner", state.npcs[i].jad_owner_idx, 0); + ASSERT_INT_EQ("wave 67 healer aggro", state.npcs[i].aggro_target, 0); + ASSERT_INT_EQ("wave 67 healer x min", dx >= -5, 1); + ASSERT_INT_EQ("wave 67 healer x max", dx <= 5, 1); + ASSERT_INT_EQ("wave 67 healer y min", dy >= -4, 1); + ASSERT_INT_EQ("wave 67 healer y max", dy <= 10, 1); + ASSERT_INT_EQ("wave 67 healer outside jad footprint", + encounter_entity_footprints_overlap( + state.npcs[i].x, state.npcs[i].y, 1, + state.npcs[0].x, state.npcs[0].y, state.npcs[0].size), + 0); + } + ASSERT_INT_EQ("wave 67 healer count", healers, 5); +} + +static void test_jad_healer_spawn_offsets_match_zuk_reference(void) { + printf("--- jad healer spawn offsets match zuk reference ---\n"); + + InfernoState state = make_test_state(INF_ZUK_PLAYER_START_X, INF_ZUK_PLAYER_START_Y); + state.rng_state = 67890; + state.wave = 68; + state.npcs[0] = make_test_npc(INF_NPC_JAD, 24, 32, INF_NPC_STATS[INF_NPC_JAD].size); + state.npcs[0].active = 1; + state.npcs[0].hp = 100; + state.npcs[0].max_hp = 300; + + inf_rebuild_entity_collision_flags(&state); + inf_jad_check_healers(&state, 0); + + int healers = 0; + for (int i = 1; i < INF_MAX_NPCS; i++) { + if (!state.npcs[i].active || state.npcs[i].type != INF_NPC_HEALER_JAD) continue; + healers++; + int dx = state.npcs[i].x - state.npcs[0].x; + int dy = state.npcs[i].y - state.npcs[0].y; + ASSERT_INT_EQ("zuk healer x min", dx >= 0, 1); + ASSERT_INT_EQ("zuk healer x max", dx <= 5, 1); + ASSERT_INT_EQ("zuk healer y min", dy >= 5, 1); + ASSERT_INT_EQ("zuk healer y max", dy <= 8, 1); + ASSERT_INT_EQ("zuk healer outside jad footprint", + encounter_entity_footprints_overlap( + state.npcs[i].x, state.npcs[i].y, 1, + state.npcs[0].x, state.npcs[0].y, state.npcs[0].size), + 0); + } + ASSERT_INT_EQ("zuk healer count", healers, 3); +} + +static void test_meleer_dig_landing_order(void) { + printf("--- meleer dig landing order ---\n"); + + InfernoState state = make_test_state(20, 20); + state.npcs[0] = make_test_npc( + INF_NPC_MELEER, 5, 5, INF_NPC_STATS[INF_NPC_MELEER].size); + state.npcs[0].active = 1; + state.npcs[0].dig_freeze_timer = 1; + + state.pillars[0].active = 1; + state.pillars[0].x = 17; + state.pillars[0].y = 17; + + inf_meleer_dig_check(&state, 0); + + ASSERT_INT_EQ("blocked first landing candidate falls through to player tile x", state.npcs[0].x, 20); + ASSERT_INT_EQ("blocked first landing candidate falls through to player tile y", state.npcs[0].y, 20); + ASSERT_INT_EQ("dig freeze consumed", state.npcs[0].dig_freeze_timer, 0); + ASSERT_INT_EQ("post-dig stun applied", state.npcs[0].stun_timer, 2); + ASSERT_INT_EQ("post-dig attack delay applied", state.npcs[0].dig_attack_delay, 6); +} + +static void test_melee_fallback_geometry(void) { + printf("--- inferno melee fallback geometry ---\n"); + + InfernoState diagonal_state = make_test_state(5, 5); + InfernoState cardinal_state = make_test_state(5, 5); + InfernoState distant_state = make_test_state(5, 5); + + InfNPC ranger_diagonal = make_test_npc(INF_NPC_RANGER, 6, 6, 1); + InfNPC mager_diagonal = make_test_npc(INF_NPC_MAGER, 6, 6, 1); + InfNPC blob_diagonal = make_test_npc(INF_NPC_BLOB, 6, 6, 1); + InfNPC blob_cardinal = make_test_npc(INF_NPC_BLOB, 6, 5, 1); + InfNPC jad_diagonal = make_test_npc(INF_NPC_JAD, 6, 6, 1); + InfNPC jad_cardinal = make_test_npc(INF_NPC_JAD, 6, 5, 1); + InfNPC blob_distant = make_test_npc(INF_NPC_BLOB, 7, 5, 1); + + InfNPCStats ranged_stats = make_test_stats(ATTACK_STYLE_RANGED); + InfNPCStats magic_stats = make_test_stats(ATTACK_STYLE_MAGIC); + + ASSERT_INT_EQ( + "ranger diagonal melee fallback", + inf_melee_fallback_possible( + &diagonal_state, &ranger_diagonal, &ranged_stats, + ATTACK_STYLE_RANGED, distance_to_player(&diagonal_state, &ranger_diagonal)), + 1); + ASSERT_INT_EQ( + "mager diagonal melee fallback", + inf_melee_fallback_possible( + &diagonal_state, &mager_diagonal, &magic_stats, + ATTACK_STYLE_MAGIC, distance_to_player(&diagonal_state, &mager_diagonal)), + 1); + ASSERT_INT_EQ( + "blob diagonal melee fallback blocked", + inf_melee_fallback_possible( + &diagonal_state, &blob_diagonal, &magic_stats, + ATTACK_STYLE_MAGIC, distance_to_player(&diagonal_state, &blob_diagonal)), + 0); + ASSERT_INT_EQ( + "blob cardinal melee fallback", + inf_melee_fallback_possible( + &cardinal_state, &blob_cardinal, &magic_stats, + ATTACK_STYLE_MAGIC, distance_to_player(&cardinal_state, &blob_cardinal)), + 1); + ASSERT_INT_EQ( + "jad diagonal melee fallback blocked", + inf_melee_fallback_possible( + &diagonal_state, &jad_diagonal, &ranged_stats, + ATTACK_STYLE_RANGED, distance_to_player(&diagonal_state, &jad_diagonal)), + 0); + ASSERT_INT_EQ( + "jad cardinal melee fallback", + inf_melee_fallback_possible( + &cardinal_state, &jad_cardinal, &ranged_stats, + ATTACK_STYLE_RANGED, distance_to_player(&cardinal_state, &jad_cardinal)), + 1); + ASSERT_INT_EQ( + "fallback blocked outside melee distance", + inf_melee_fallback_possible( + &distant_state, &blob_distant, &magic_stats, + ATTACK_STYLE_MAGIC, distance_to_player(&distant_state, &blob_distant)), + 0); + ASSERT_INT_EQ( + "fallback blocked when planned style already melee", + inf_melee_fallback_possible( + &cardinal_state, &blob_cardinal, &magic_stats, + ATTACK_STYLE_MELEE, distance_to_player(&cardinal_state, &blob_cardinal)), + 0); +} + +static void test_style_mask_preview(void) { + printf("--- inferno style mask preview ---\n"); + + InfernoState diagonal_state = make_test_state(5, 5); + InfernoState cardinal_state = make_test_state(5, 5); + InfNPC ranger_diagonal = make_test_npc(INF_NPC_RANGER, 6, 6, 1); + InfNPC blob_cardinal = make_test_npc(INF_NPC_BLOB, 6, 5, 1); + InfNPC blob_diagonal = make_test_npc(INF_NPC_BLOB, 6, 6, 1); + InfNPCStats ranged_stats = make_test_stats(ATTACK_STYLE_RANGED); + InfNPCStats magic_stats = make_test_stats(ATTACK_STYLE_MAGIC); + + int ranger_mask = inf_attack_style_options_mask( + &diagonal_state, &ranger_diagonal, &ranged_stats, + ATTACK_STYLE_RANGED, distance_to_player(&diagonal_state, &ranger_diagonal)); + int blob_cardinal_mask = inf_attack_style_options_mask( + &cardinal_state, &blob_cardinal, &magic_stats, + ATTACK_STYLE_MAGIC, distance_to_player(&cardinal_state, &blob_cardinal)); + int blob_diagonal_mask = inf_attack_style_options_mask( + &diagonal_state, &blob_diagonal, &magic_stats, + ATTACK_STYLE_MAGIC, distance_to_player(&diagonal_state, &blob_diagonal)); + + ASSERT_INT_EQ( + "ranger diagonal preview mask", + ranger_mask, + INF_STYLE_MASK_MELEE | INF_STYLE_MASK_RANGED); + ASSERT_INT_EQ( + "ranger diagonal preview style is uncertain", + inf_attack_style_from_mask(ranger_mask), + ATTACK_STYLE_NONE); + ASSERT_INT_EQ( + "ranger diagonal obs preview keeps ranged primary", + inf_attack_style_obs_preview(ranger_mask), + ATTACK_STYLE_RANGED); + ASSERT_INT_EQ( + "blob cardinal preview mask", + blob_cardinal_mask, + INF_STYLE_MASK_MELEE | INF_STYLE_MASK_MAGIC); + ASSERT_INT_EQ( + "blob cardinal obs preview keeps magic primary", + inf_attack_style_obs_preview(blob_cardinal_mask), + ATTACK_STYLE_MAGIC); + ASSERT_INT_EQ( + "blob diagonal preview keeps magic only", + blob_diagonal_mask, + INF_STYLE_MASK_MAGIC); + ASSERT_INT_EQ( + "blob diagonal preview style is magic", + inf_attack_style_from_mask(blob_diagonal_mask), + ATTACK_STYLE_MAGIC); +} + +static void test_style_choice_sampling(void) { + printf("--- inferno style choice sampling ---\n"); + + uint32_t rng_state = 12345; + int saw_melee = 0; + int saw_ranged = 0; + + for (int i = 0; i < 128; i++) { + int style = inf_choose_attack_style_for_tick( + &rng_state, INF_STYLE_MASK_MELEE | INF_STYLE_MASK_RANGED); + if (style == ATTACK_STYLE_MELEE) saw_melee = 1; + if (style == ATTACK_STYLE_RANGED) saw_ranged = 1; + } + + ASSERT_INT_EQ("50-50 branch can emit melee", saw_melee, 1); + ASSERT_INT_EQ("50-50 branch can emit primary style", saw_ranged, 1); + ASSERT_INT_EQ( + "single-style mask stays deterministic", + inf_choose_attack_style_for_tick(&rng_state, INF_STYLE_MASK_MAGIC), + ATTACK_STYLE_MAGIC); +} + +static void test_dead_mob_store_eligibility(void) { + printf("--- inferno dead mob resurrection eligibility ---\n"); + + ASSERT_INT_EQ("bat resurrectable", inf_dead_mob_is_resurrectable(INF_NPC_BAT), 1); + ASSERT_INT_EQ("blob parent resurrectable", inf_dead_mob_is_resurrectable(INF_NPC_BLOB), 1); + ASSERT_INT_EQ("meleer resurrectable", inf_dead_mob_is_resurrectable(INF_NPC_MELEER), 1); + ASSERT_INT_EQ("ranger resurrectable", inf_dead_mob_is_resurrectable(INF_NPC_RANGER), 1); + ASSERT_INT_EQ("mager resurrectable", inf_dead_mob_is_resurrectable(INF_NPC_MAGER), 1); + + ASSERT_INT_EQ("nibbler not resurrectable", inf_dead_mob_is_resurrectable(INF_NPC_NIBBLER), 0); + ASSERT_INT_EQ("blob melee split not resurrectable", inf_dead_mob_is_resurrectable(INF_NPC_BLOB_MELEE), 0); + ASSERT_INT_EQ("blob range split not resurrectable", inf_dead_mob_is_resurrectable(INF_NPC_BLOB_RANGE), 0); + ASSERT_INT_EQ("blob mage split not resurrectable", inf_dead_mob_is_resurrectable(INF_NPC_BLOB_MAGE), 0); + ASSERT_INT_EQ("jad not resurrectable", inf_dead_mob_is_resurrectable(INF_NPC_JAD), 0); +} + +static void test_resurrected_mob_does_not_reenter_dead_store(void) { + printf("--- resurrected mob does not reenter dead store ---\n"); + + InfernoState state = make_test_state(25, 16); + state.wave = 35; + + state.npcs[0] = make_test_npc(INF_NPC_MAGER, 20, 20, INF_NPC_STATS[INF_NPC_MAGER].size); + state.npcs[0].active = 1; + state.npcs[0].hp = state.npcs[0].max_hp = INF_NPC_STATS[INF_NPC_MAGER].hp; + + state.dead_mobs[0].type = INF_NPC_RANGER; + state.dead_mobs[0].x = 18; + state.dead_mobs[0].y = 18; + state.dead_mobs[0].hp = INF_NPC_STATS[INF_NPC_RANGER].hp / 2; + state.dead_mobs[0].max_hp = INF_NPC_STATS[INF_NPC_RANGER].hp; + state.dead_mob_count = 1; + + ASSERT_INT_EQ("resurrection succeeds", force_mager_resurrect(&state, 0), 1); + ASSERT_INT_EQ("dead store consumed", state.dead_mob_count, 0); + + int resurrected_slot = -1; + for (int i = 1; i < INF_MAX_NPCS; i++) { + if (state.npcs[i].active && state.npcs[i].type == INF_NPC_RANGER) { + resurrected_slot = i; + break; + } + } + ASSERT_INT_EQ("resurrected ranger spawned", resurrected_slot >= 0, 1); + ASSERT_INT_EQ("respawned ranger marked resurrected", + state.npcs[resurrected_slot].resurrection_count, 1); + + inf_store_dead_mob(&state, &state.npcs[resurrected_slot]); + ASSERT_INT_EQ("resurrected ranger not re-added", state.dead_mob_count, 0); +} + +static void test_double_mager_wave_resurrection_limit(void) { + printf("--- double mager wave respects once-only resurrection ---\n"); + + InfernoState state = make_test_state(25, 16); + state.wave = 65; + + state.npcs[0] = make_test_npc(INF_NPC_MAGER, 18, 18, INF_NPC_STATS[INF_NPC_MAGER].size); + state.npcs[0].active = 1; + state.npcs[0].hp = state.npcs[0].max_hp = INF_NPC_STATS[INF_NPC_MAGER].hp; + + state.dead_mobs[0].type = INF_NPC_MAGER; + state.dead_mobs[0].x = 22; + state.dead_mobs[0].y = 22; + state.dead_mobs[0].hp = INF_NPC_STATS[INF_NPC_MAGER].hp / 2; + state.dead_mobs[0].max_hp = INF_NPC_STATS[INF_NPC_MAGER].hp; + state.dead_mob_count = 1; + + ASSERT_INT_EQ("first mage resurrection succeeds", force_mager_resurrect(&state, 0), 1); + + int resurrected_slot = -1; + for (int i = 1; i < INF_MAX_NPCS; i++) { + if (state.npcs[i].active && state.npcs[i].type == INF_NPC_MAGER) { + resurrected_slot = i; + break; + } + } + ASSERT_INT_EQ("second mager spawned", resurrected_slot >= 0, 1); + ASSERT_INT_EQ("respawned mager marked resurrected", + state.npcs[resurrected_slot].resurrection_count, 1); + + inf_store_dead_mob(&state, &state.npcs[0]); + ASSERT_INT_EQ("original mage can still enter dead store once", state.dead_mob_count, 1); + + ASSERT_INT_EQ("resurrected mage can resurrect the original once", + force_mager_resurrect(&state, resurrected_slot), 1); + + int original_respawn_slot = -1; + for (int i = 1; i < INF_MAX_NPCS; i++) { + if (i == resurrected_slot) continue; + if (state.npcs[i].active && state.npcs[i].type == INF_NPC_MAGER && + state.npcs[i].resurrection_count == 1) { + original_respawn_slot = i; + break; + } + } + ASSERT_INT_EQ("original mage respawned once", original_respawn_slot >= 0, 1); + + inf_store_dead_mob(&state, &state.npcs[resurrected_slot]); + ASSERT_INT_EQ("already-resurrected mage stays out of store", state.dead_mob_count, 0); + inf_store_dead_mob(&state, &state.npcs[original_respawn_slot]); + ASSERT_INT_EQ("re-resurrected original mage stays out of store", state.dead_mob_count, 0); +} + +static void test_pending_hit_obs_timer_prefers_prayer_window(void) { + printf("--- pending hit obs timer prefers prayer window ---\n"); + + EncounterPendingHit jad_hit = {0}; + jad_hit.check_prayer = 1; + jad_hit.prayer_check_delay = 3; + jad_hit.ticks_remaining = 4; + + EncounterPendingHit normal_hit = {0}; + normal_hit.check_prayer = 0; + normal_hit.prayer_check_delay = 0; + normal_hit.ticks_remaining = 2; + + ASSERT_INT_EQ("jad timer uses prayer window", inf_pending_hit_obs_timer(&jad_hit), 3); + ASSERT_INT_EQ("normal timer uses travel time", inf_pending_hit_obs_timer(&normal_hit), 2); +} + +static void test_jad_has_no_pre_fire_style_preview(void) { + printf("--- jad has no pre-fire style preview ---\n"); + + InfernoState state = make_test_state(10, 10); + state.player.current_defence = 99; + state.player.current_magic = 99; + state.player.prayer = PRAYER_NONE; + state.weapon_set = INF_GEAR_MAGE; + state.wave = 66; + + state.npcs[0] = make_test_npc( + INF_NPC_JAD, 20, 10, INF_NPC_STATS[INF_NPC_JAD].size); + state.npcs[0].active = 1; + state.npcs[0].attack_timer = 2; + + inf_npc_attack(&state, 0); + + ASSERT_INT_EQ("jad timer decrements without preview", state.npcs[0].attack_timer, 1); + ASSERT_INT_EQ("jad style stays hidden before fire", state.npcs[0].jad_attack_style, ATTACK_STYLE_NONE); + + float obs[INF_NUM_OBS]; + inf_write_obs((EncounterState*)&state, obs); + ASSERT_FLOAT_NEAR("prayer-critical timer ignores hidden jad style", obs[37], 1.0f, 1e-6f); + ASSERT_INT_EQ("prayer-critical style stays zero before fire", (int)(obs[38] + obs[39] + obs[40]), 0); +} + +static void test_jad_fire_tick_exposes_three_tick_prayer_deadline(void) { + printf("--- jad fire tick exposes three tick prayer deadline ---\n"); + + InfernoState state; + init_jad_timing_test_state(&state, 10, 10, 16, 10); + + step_inferno_with_prayer(&state, 0); + + ASSERT_INT_EQ("jad attack queued one pending hit", state.player_pending_hit_count, 1); + ASSERT_INT_EQ("jad style resets after firing", state.npcs[0].jad_attack_style, ATTACK_STYLE_NONE); + ASSERT_INT_EQ("jad pending hit shows three tick prayer delay after fire", state.player_pending_hits[0].prayer_check_delay, 3); + ASSERT_INT_EQ("jad close-range hit lands four ticks after fire", state.player_pending_hits[0].ticks_remaining, 4); + + float obs[INF_NUM_OBS]; + memset(obs, 0, sizeof(obs)); + inf_write_obs((EncounterState*)&state, obs); + ASSERT_FLOAT_NEAR("prayer-critical timer exposes jad fire deadline", obs[37], 0.3f, 1e-6f); + ASSERT_FLOAT_NEAR("prayer-critical magic style exposed after fire", obs[40], 1.0f, 1e-6f); + int pending_start = INF_NUM_OBS - INF_FEATURES_PER_HIT * ENCOUNTER_MAX_PENDING_HITS; + ASSERT_FLOAT_NEAR("pending hit obs timer uses prayer window", obs[pending_start + 3], 0.3f, 1e-6f); + ASSERT_FLOAT_NEAR("pending hit pre-check damage exposes max threat", obs[pending_start + 4], 113.0f / 150.0f, 1e-6f); +} + +static void test_jad_prayer_on_third_tick_blocks(void) { + printf("--- jad prayer on third tick blocks ---\n"); + + InfernoState state; + init_jad_timing_test_state(&state, 10, 10, 16, 10); + + step_inferno_with_prayer(&state, 0); + step_inferno_with_prayer(&state, 0); + step_inferno_with_prayer(&state, 0); + step_inferno_with_prayer(&state, ENCOUNTER_OVERHEAD_TOGGLE_MAGIC); + + ASSERT_INT_EQ("jad prayer check consumed pending protection", state.player_pending_hits[0].check_prayer, 0); + ASSERT_INT_EQ("jad protected damage is frozen at zero", state.player_pending_hits[0].damage, 0); + ASSERT_INT_EQ("jad prayer check counted correct prayer", state.prayer_correct_this_tick, 1); + + step_inferno_with_prayer(&state, 0); + ASSERT_INT_EQ("jad protected hit removed after landing", state.player_pending_hit_count, 0); + ASSERT_INT_EQ("jad protected hit leaves player hp unchanged", state.player.current_hitpoints, 99); +} + +static void test_jad_prayer_first_on_fourth_tick_does_not_block(void) { + printf("--- jad prayer first on fourth tick does not block ---\n"); + + int saw_late_damage = 0; + for (uint32_t seed = 1; seed < 10000 && !saw_late_damage; seed++) { + InfernoState state; + init_jad_timing_test_state(&state, 10, 10, 16, 10); + state.rng_state = seed; + + step_inferno_with_prayer(&state, 0); + step_inferno_with_prayer(&state, 0); + step_inferno_with_prayer(&state, 0); + step_inferno_with_prayer(&state, 0); + ASSERT_INT_EQ("late-prayer test reaches checked pending hit", state.player_pending_hits[0].check_prayer, 0); + + step_inferno_with_prayer(&state, ENCOUNTER_OVERHEAD_TOGGLE_MAGIC); + if (state.damage_received_this_tick > 0.0f) { + saw_late_damage = 1; + ASSERT_INT_EQ("late prayer did not block queued jad damage", state.player.current_hitpoints < 99, 1); + } + } + ASSERT_INT_EQ("found a seed where late jad prayer takes damage", saw_late_damage, 1); +} + +static void test_jad_long_distance_damage_uses_delayed_projectile_landing(void) { + printf("--- jad long distance damage uses delayed projectile landing ---\n"); + + int saw_expected_landing = 0; + for (uint32_t seed = 1; seed < 10000 && !saw_expected_landing; seed++) { + InfernoState state; + init_jad_timing_test_state(&state, 10, 10, 36, 10); + state.rng_state = seed; + + int dist = encounter_dist_to_npc(state.player.x, state.player.y, + state.npcs[0].x, state.npcs[0].y, state.npcs[0].size); + int hit_delay = encounter_magic_hit_delay(dist, 0); + int expected_landing_after_fire = 3 + (hit_delay - 3); + if (expected_landing_after_fire < 4) + expected_landing_after_fire = 4; + + step_inferno_with_prayer(&state, 0); + for (int t = 1; t < expected_landing_after_fire; t++) { + step_inferno_with_prayer(&state, 0); + ASSERT_FLOAT_NEAR("jad long-distance hit has not landed early", state.damage_received_this_tick, 0.0f, 1e-6f); + } + step_inferno_with_prayer(&state, 0); + if (state.damage_received_this_tick > 0.0f) { + saw_expected_landing = 1; + } + } + ASSERT_INT_EQ("found a seed where long-distance jad damage lands on expected tick", saw_expected_landing, 1); +} + +static void test_triple_jad_pending_threats_preserve_obs_shape(void) { + printf("--- triple jad pending threats preserve obs shape ---\n"); + + InfernoState state; + init_jad_timing_test_state(&state, 25, 30, 18, 33); + state.wave = 67; + state.npcs[1] = make_test_npc(INF_NPC_JAD, 28, 33, INF_NPC_STATS[INF_NPC_JAD].size); + state.npcs[1].active = 1; + state.npcs[1].attack_timer = 0; + state.npcs[1].jad_attack_style = ATTACK_STYLE_RANGED; + state.npcs[2] = make_test_npc(INF_NPC_JAD, 23, 22, INF_NPC_STATS[INF_NPC_JAD].size); + state.npcs[2].active = 1; + state.npcs[2].attack_timer = 0; + state.npcs[2].jad_attack_style = ATTACK_STYLE_MAGIC; + + step_inferno_with_prayer(&state, 0); + + ASSERT_INT_EQ("triple jad queues three pending threats", state.player_pending_hit_count, 3); + for (int h = 0; h < state.player_pending_hit_count; h++) { + ASSERT_INT_EQ("each jad threat keeps three tick prayer deadline", state.player_pending_hits[h].prayer_check_delay, 3); + } + + float obs[INF_NUM_OBS]; + inf_write_obs((EncounterState*)&state, obs); + ASSERT_INT_EQ("inferno obs shape remains unchanged", INF_NUM_OBS, 386); +} + +static void test_jad_special_wave_spawn_cadence_matches_reference(void) { + printf("--- jad special wave spawn cadence matches reference ---\n"); + + InfernoState single = make_test_state(0, 0); + single.wave = 66; + inf_spawn_wave(&single); + + int single_jad = -1; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (single.npcs[i].active && single.npcs[i].type == INF_NPC_JAD) { + single_jad = i; + break; + } + } + ASSERT_INT_EQ("wave 67 spawns one jad", single_jad >= 0, 1); + ASSERT_INT_EQ("wave 67 jad stun", single.npcs[single_jad].stun_timer, 1); + ASSERT_INT_EQ("wave 67 jad attack speed timer", single.npcs[single_jad].attack_timer, 8); + + InfernoState triple = make_test_state(0, 0); + triple.wave = 67; + triple.rng_state = 12345; + inf_spawn_wave(&triple); + + int num_jads = 0; + int stun_sum = 0; + int stun_product = 1; + for (int i = 0; i < INF_MAX_NPCS; i++) { + if (!triple.npcs[i].active || triple.npcs[i].type != INF_NPC_JAD) + continue; + num_jads++; + stun_sum += triple.npcs[i].stun_timer; + stun_product *= triple.npcs[i].stun_timer; + ASSERT_INT_EQ("wave 68 jad attack speed timer", triple.npcs[i].attack_timer, 9); + } + ASSERT_INT_EQ("wave 68 spawns three jads", num_jads, 3); + ASSERT_INT_EQ("wave 68 shuffled stun sum", stun_sum, 12); + ASSERT_INT_EQ("wave 68 shuffled stun product", stun_product, 28); +} + +static void test_jad_melee_stays_instant_and_untelegraphed(void) { + printf("--- jad melee stays instant and untelegraphed ---\n"); + + InfernoState preview_state = make_test_state(5, 5); + preview_state.player.current_defence = 99; + preview_state.player.current_magic = 99; + preview_state.player.prayer = PRAYER_NONE; + preview_state.weapon_set = INF_GEAR_MAGE; + preview_state.wave = 66; + + preview_state.npcs[0] = make_test_npc( + INF_NPC_JAD, 6, 5, INF_NPC_STATS[INF_NPC_JAD].size); + preview_state.npcs[0].active = 1; + preview_state.npcs[0].attack_timer = 1; + preview_state.npcs[0].jad_attack_style = ATTACK_STYLE_RANGED; + + float obs[INF_NUM_OBS]; + inf_write_obs((EncounterState*)&preview_state, obs); + + ASSERT_FLOAT_NEAR( + "jad prayer-critical preview does not advertise melee fallback", + obs[41], 1.0f / 3.0f, 1e-6f); + ASSERT_INT_EQ( + "jad prayer-critical preview keeps ranged one-hot", + (int)(obs[38] + obs[39] + obs[40]), + 1); + ASSERT_FLOAT_NEAR( + "jad preview keeps ranged as the visible style", + obs[39], 1.0f, 1e-6f); + + int saw_melee = 0; + for (uint32_t seed = 0; seed < 256; seed++) { + InfernoState attack_state = make_test_state(5, 5); + attack_state.rng_state = seed; + attack_state.player.current_defence = 99; + attack_state.player.current_magic = 99; + attack_state.player.prayer = PRAYER_NONE; + attack_state.weapon_set = INF_GEAR_MAGE; + attack_state.wave = 66; + + attack_state.npcs[0] = make_test_npc( + INF_NPC_JAD, 6, 5, INF_NPC_STATS[INF_NPC_JAD].size); + attack_state.npcs[0].active = 1; + attack_state.npcs[0].attack_timer = 0; + attack_state.npcs[0].jad_attack_style = ATTACK_STYLE_RANGED; + + inf_npc_attack(&attack_state, 0); + + if (attack_state.npcs[0].attack_style_this_tick == ATTACK_STYLE_MELEE) { + saw_melee = 1; + ASSERT_INT_EQ( + "jad melee fallback does not queue a pending hit", + attack_state.player_pending_hit_count, 0); + break; + } + } + + ASSERT_INT_EQ("jad can still choose melee instantly at fire time", saw_melee, 1); +} + +static int inferno_obs_slot_type(int slot_idx) { + if (slot_idx >= 0 && slot_idx < 2) return INF_NPC_MAGER; + if (slot_idx >= 2 && slot_idx < 4) return INF_NPC_RANGER; + if (slot_idx >= 4 && slot_idx < 6) return INF_NPC_MELEER; + if (slot_idx >= 6 && slot_idx < 8) return INF_NPC_BLOB; + if (slot_idx >= 8 && slot_idx < 10) return INF_NPC_BAT; + if (slot_idx >= 10 && slot_idx < 12) return INF_NPC_BLOB_MAGE; + if (slot_idx >= 12 && slot_idx < 14) return INF_NPC_BLOB_RANGE; + if (slot_idx >= 14 && slot_idx < 16) return INF_NPC_BLOB_MELEE; + if (slot_idx >= 16 && slot_idx < 22) return INF_NPC_NIBBLER; + if (slot_idx >= 22 && slot_idx < 25) return INF_NPC_JAD; + if (slot_idx == 25) return INF_NPC_ZUK; + if (slot_idx == 26) return INF_NPC_ZUK_SHIELD; + if (slot_idx >= 27 && slot_idx < 33) return INF_NPC_HEALER_JAD; + if (slot_idx >= 33 && slot_idx < 37) return INF_NPC_HEALER_ZUK; + return -1; +} + +static int inferno_obs_slot_feature_count(int slot_idx) { + int type = inferno_obs_slot_type(slot_idx); + int has_style = (type == INF_NPC_BLOB || type == INF_NPC_JAD); + int has_scan = (type == INF_NPC_BLOB); + int has_los = (type != INF_NPC_NIBBLER && type != INF_NPC_MELEER && + type != INF_NPC_HEALER_JAD && type != INF_NPC_ZUK_SHIELD); + int has_aggro = (type != INF_NPC_NIBBLER && type != INF_NPC_ZUK_SHIELD); + int has_timer = (type != INF_NPC_NIBBLER && type != INF_NPC_HEALER_JAD && + type != INF_NPC_ZUK_SHIELD); + int has_targeted = 1; + + return 4 + has_timer + 3 * has_style + has_los + 3 * has_scan + + has_aggro + has_targeted; +} + +static int inferno_obs_slot_start(int slot_idx) { + int start = INF_PLAYER_OBS_SIZE + 12; + for (int i = 0; i < slot_idx; i++) { + start += inferno_obs_slot_feature_count(i); + } + return start; +} + +static int inferno_target_mask_slot_offset(int slot_idx) { + return ENCOUNTER_MOVE_ACTIONS + ENCOUNTER_OVERHEAD_DIM_PVE + 1 + slot_idx; +} + +static void test_zuk_obs_tracks_shield_and_mager_aggro(void) { + printf("--- zuk obs tracks shield hp/death and mager aggro ---\n"); + + InfernoState state = make_test_state(INF_ZUK_PLAYER_START_X, INF_ZUK_PLAYER_START_Y); + state.wave = 68; + state.player.current_defence = 99; + state.player.current_magic = 99; + state.player.current_ranged = 99; + state.player.current_hitpoints = 99; + state.player.base_hitpoints = 99; + state.player.base_prayer = 99; + state.player.current_prayer = 99; + state.player.prayer = PRAYER_NONE; + state.weapon_set = INF_GEAR_TBOW; + + state.npcs[0] = make_test_npc( + INF_NPC_MAGER, 20, 36, INF_NPC_STATS[INF_NPC_MAGER].size); + state.npcs[0].active = 1; + state.npcs[0].hp = state.npcs[0].max_hp = INF_NPC_STATS[INF_NPC_MAGER].hp; + state.npcs[0].attack_timer = 4; + + state.npcs[1] = make_test_npc( + INF_NPC_ZUK, 22, 14, INF_NPC_STATS[INF_NPC_ZUK].size); + state.npcs[1].active = 1; + state.npcs[1].hp = state.npcs[1].max_hp = INF_NPC_STATS[INF_NPC_ZUK].hp; + + state.npcs[2] = make_test_npc( + INF_NPC_ZUK_SHIELD, 23, 44, INF_NPC_STATS[INF_NPC_ZUK_SHIELD].size); + state.npcs[2].active = 1; + state.npcs[2].hp = 300; + state.npcs[2].max_hp = INF_NPC_STATS[INF_NPC_ZUK_SHIELD].hp; + + state.zuk.shield_idx = 2; + state.zuk.shield_dir = -1; + state.zuk.shield_freeze = 3; + state.npcs[0].aggro_target = 2; + + float obs[INF_NUM_OBS]; + float mask[INF_ACTION_MASK_SIZE]; + inf_write_obs((EncounterState*)&state, obs); + inf_write_mask((EncounterState*)&state, mask); + + int mager_slot = 0; + int shield_slot = 26; + int mager_start = inferno_obs_slot_start(mager_slot); + int shield_start = inferno_obs_slot_start(shield_slot); + + ASSERT_INT_EQ("first mager occupies mager slot 0", state.current_obs_slots[mager_slot], 0); + ASSERT_INT_EQ("shield occupies dedicated shield slot", state.current_obs_slots[shield_slot], 2); + ASSERT_FLOAT_NEAR("shield hp ratio visible in shield slot", obs[shield_start], 0.5f, 1e-6f); + ASSERT_FLOAT_NEAR("mager aggro bit is 0 while on shield", obs[mager_start + 6], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("shield direction visible while alive", obs[43], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("shield freeze visible while alive", obs[44], 0.6f, 1e-6f); + ASSERT_FLOAT_NEAR("mager target mask is valid", mask[inferno_target_mask_slot_offset(mager_slot)], 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("shield target mask stays invalid", mask[inferno_target_mask_slot_offset(shield_slot)], 0.0f, 1e-6f); + + state.npcs[2].active = 0; + state.zuk.shield_idx = -1; + state.npcs[0].aggro_target = -1; + + memset(obs, 0, sizeof(obs)); + memset(mask, 0, sizeof(mask)); + inf_write_obs((EncounterState*)&state, obs); + inf_write_mask((EncounterState*)&state, mask); + + ASSERT_INT_EQ("dead shield drops out of shield slot", state.current_obs_slots[shield_slot], -1); + ASSERT_FLOAT_NEAR("dead shield slot hp zeros out", obs[shield_start], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("dead shield zeroes stale direction", obs[43], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("dead shield zeroes stale freeze", obs[44], 0.0f, 1e-6f); + ASSERT_FLOAT_NEAR("mager aggro bit flips to player", obs[mager_start + 6], 1.0f, 1e-6f); +} + +static void test_human_target_and_potion_translation(void) { + printf("--- inferno human target and potion translation ---\n"); + + InfernoState state = make_test_state(20, 20); + state.player.base_hitpoints = 99; + state.player.current_hitpoints = 80; + state.player.base_prayer = 99; + state.player.current_prayer = 60; + state.player.current_attack = 99; + state.player.current_strength = 99; + state.player.current_defence = 99; + state.player.current_ranged = 99; + state.player.current_magic = 99; + + state.npcs[0] = make_test_npc( + INF_NPC_MAGER, 24, 24, INF_NPC_STATS[INF_NPC_MAGER].size); + state.npcs[0].active = 1; + state.npcs[0].hp = state.npcs[0].max_hp = INF_NPC_STATS[INF_NPC_MAGER].hp; + + state.npcs[1] = make_test_npc( + INF_NPC_MAGER, 26, 24, INF_NPC_STATS[INF_NPC_MAGER].size); + state.npcs[1].active = 1; + state.npcs[1].hp = state.npcs[1].max_hp = INF_NPC_STATS[INF_NPC_MAGER].hp; + + state.npcs[2] = make_test_npc( + INF_NPC_MAGER, 28, 24, INF_NPC_STATS[INF_NPC_MAGER].size); + state.npcs[2].active = 1; + state.npcs[2].hp = state.npcs[2].max_hp = INF_NPC_STATS[INF_NPC_MAGER].hp; + + state.npcs[3] = make_test_npc( + INF_NPC_ZUK_SHIELD, 23, 44, INF_NPC_STATS[INF_NPC_ZUK_SHIELD].size); + state.npcs[3].active = 1; + state.npcs[3].hp = state.npcs[3].max_hp = INF_NPC_STATS[INF_NPC_ZUK_SHIELD].hp; + + { + float obs[INF_NUM_OBS]; + inf_write_obs((EncounterState*)&state, obs); + } + + ASSERT_INT_EQ("first visible mager is targetable", + inf_is_human_targetable_npc_slot((EncounterState*)&state, 0), 1); + ASSERT_INT_EQ("second visible mager is targetable", + inf_is_human_targetable_npc_slot((EncounterState*)&state, 1), 1); + ASSERT_INT_EQ("third capped-out mager is not targetable", + inf_is_human_targetable_npc_slot((EncounterState*)&state, 2), 0); + ASSERT_INT_EQ("shield is never targetable", + inf_is_human_targetable_npc_slot((EncounterState*)&state, 3), 0); + + { + HumanInput hi; + int actions[INF_NUM_ACTION_HEADS]; + + hi = make_human_input(); + hi.pending_target_idx = 0; + inf_translate_human_input(&hi, actions, (EncounterState*)&state); + ASSERT_INT_EQ("visible mager click maps into target head", + actions[INF_HEAD_TARGET], 1); + + hi = make_human_input(); + hi.pending_target_idx = 2; + inf_translate_human_input(&hi, actions, (EncounterState*)&state); + ASSERT_INT_EQ("capped-out mager click is rejected", + actions[INF_HEAD_TARGET], 0); + + hi = make_human_input(); + hi.pending_target_idx = 3; + inf_translate_human_input(&hi, actions, (EncounterState*)&state); + ASSERT_INT_EQ("shield click is rejected", + actions[INF_HEAD_TARGET], 0); + + hi = make_human_input(); + hi.pending_potion = POTION_BREW; + inf_translate_human_input(&hi, actions, (EncounterState*)&state); + ASSERT_INT_EQ("brew still maps to eat head", actions[INF_HEAD_EAT], 1); + ASSERT_INT_EQ("brew does not touch potion head", actions[INF_HEAD_POTION], 0); + + hi = make_human_input(); + hi.pending_potion = POTION_RESTORE; + inf_translate_human_input(&hi, actions, (EncounterState*)&state); + ASSERT_INT_EQ("restore maps to potion 1", actions[INF_HEAD_POTION], 1); + + hi = make_human_input(); + hi.pending_potion = POTION_BASTION; + inf_translate_human_input(&hi, actions, (EncounterState*)&state); + ASSERT_INT_EQ("bastion maps to potion 2", actions[INF_HEAD_POTION], 2); + + hi = make_human_input(); + hi.pending_potion = POTION_STAMINA; + inf_translate_human_input(&hi, actions, (EncounterState*)&state); + ASSERT_INT_EQ("stamina maps to potion 3", actions[INF_HEAD_POTION], 3); + + hi = make_human_input(); + hi.pending_potion = POTION_PRAYER_POT; + inf_translate_human_input(&hi, actions, (EncounterState*)&state); + ASSERT_INT_EQ("prayer pot no longer aliases to restore", + actions[INF_HEAD_POTION], 0); + } +} + +static void test_action_noop_count_matches_action_heads(void) { + printf("--- action noop count matches inferno action heads ---\n"); + + int noop_slots = (int)( + sizeof(((InfernoState*)0)->action_noop_count) / + sizeof(((InfernoState*)0)->action_noop_count[0])); + ASSERT_INT_EQ("noop counter slots", noop_slots, INF_NUM_ACTION_HEADS); +} + +static void test_inferno_human_equip_does_not_snap_loadout(void) { + printf("--- inferno human equip does not snap full loadout ---\n"); + + EncounterState* raw = inf_create(); + InfernoState* state = (InfernoState*)raw; + inf_reset(raw, 123); + + HumanInput input; + human_input_init(&input); + input.enabled = 1; + + uint8_t old_body = state->player.equipped[GEAR_SLOT_BODY]; + human_input_queue_equip_inventory_item( + &input, 0, ITEM_TOXIC_BLOWPIPE, GEAR_SLOT_WEAPON); + + inf_step_human_commands(raw, &input); + + ASSERT_INT_EQ("weapon changed to clicked blowpipe", + state->player.equipped[GEAR_SLOT_WEAPON], ITEM_TOXIC_BLOWPIPE); + ASSERT_INT_EQ("body slot did not snap to ranged preset", + state->player.equipped[GEAR_SLOT_BODY], old_body); + ASSERT_INT_EQ("2h weapon clears shield", + state->player.equipped[GEAR_SLOT_SHIELD], ITEM_NONE); + ASSERT_INT_EQ("queued command drained", input.commands.count, 0); + + human_input_destroy(&input); + inf_destroy(raw); +} + +static void test_jad_render_uses_style_specific_attack_animation(void) { + printf("--- jad render uses style-specific attack animation ---\n"); + + InfernoState magic_state; + init_jad_timing_test_state(&magic_state, 10, 10, 16, 10); + magic_state.npcs[0].attacked_this_tick = 1; + magic_state.npcs[0].attack_style_this_tick = ATTACK_STYLE_MAGIC; + + RenderEntity magic_entities[4]; + int magic_count = 0; + inf_fill_render_entities((EncounterState*)&magic_state, magic_entities, 4, &magic_count); + + InfernoState range_state; + init_jad_timing_test_state(&range_state, 10, 10, 16, 10); + range_state.npcs[0].attacked_this_tick = 1; + range_state.npcs[0].attack_style_this_tick = ATTACK_STYLE_RANGED; + + RenderEntity range_entities[4]; + int range_count = 0; + inf_fill_render_entities((EncounterState*)&range_state, range_entities, 4, &range_count); + + ASSERT_INT_EQ("jad magic render entity count", magic_count, 2); + ASSERT_INT_EQ("jad ranged render entity count", range_count, 2); + ASSERT_INT_EQ("jad magic attack animation", magic_entities[1].npc_anim_id, 7592); + ASSERT_INT_EQ("jad ranged attack animation", range_entities[1].npc_anim_id, 7593); +} + +static void test_jad_magic_render_emits_three_offset_projectiles(void) { + printf("--- jad magic render emits three offset projectiles ---\n"); + + InfernoState state; + init_jad_timing_test_state(&state, 10, 10, 16, 10); + state.npcs[0].attacked_this_tick = 1; + state.npcs[0].attack_style_this_tick = ATTACK_STYLE_MAGIC; + + EncounterOverlay ov; + memset(&ov, 0, sizeof(ov)); + inf_render_post_tick((EncounterState*)&state, &ov); + + ASSERT_INT_EQ("jad magic emits three projectile models", ov.projectile_count, 3); + ASSERT_INT_EQ("jad magic front model", ov.projectiles[0].model_id, INF_GFX_448_MODEL); + ASSERT_INT_EQ("jad magic middle model", ov.projectiles[1].model_id, INF_GFX_449_MODEL); + ASSERT_INT_EQ("jad magic rear model", ov.projectiles[2].model_id, INF_GFX_450_MODEL); + ASSERT_INT_EQ("jad magic front anim", ov.projectiles[0].anim_id, INF_GFX_448_ANIM); + ASSERT_INT_EQ("jad magic middle anim", ov.projectiles[1].anim_id, INF_GFX_449_ANIM); + ASSERT_INT_EQ("jad magic rear anim", ov.projectiles[2].anim_id, INF_GFX_450_ANIM); + ASSERT_INT_EQ("jad magic visible duration is two ticks close range", ov.projectiles[0].duration_ticks, 2 * 30); + ASSERT_INT_EQ("jad magic start delay is three ticks", ov.projectiles[0].start_delay, 3 * 30); + ASSERT_FLOAT_NEAR("jad magic arc height", ov.projectiles[0].arc_height, 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("jad magic front offset", ov.projectiles[0].offset_y, 1.0f, 1e-6f); + ASSERT_FLOAT_NEAR("jad magic middle offset", ov.projectiles[1].offset_y, 0.5f, 1e-6f); + ASSERT_FLOAT_NEAR("jad magic rear offset", ov.projectiles[2].offset_y, 0.0f, 1e-6f); +} + +static void test_jad_ranged_render_uses_target_anchored_two_tick_visual(void) { + printf("--- jad ranged render uses target anchored two tick visual ---\n"); + + InfernoState state; + init_jad_timing_test_state(&state, 10, 10, 16, 10); + state.npcs[0].attacked_this_tick = 1; + state.npcs[0].attack_style_this_tick = ATTACK_STYLE_RANGED; + + EncounterOverlay ov; + memset(&ov, 0, sizeof(ov)); + inf_render_post_tick((EncounterState*)&state, &ov); + + ASSERT_INT_EQ("jad ranged emits one projectile", ov.projectile_count, 1); + ASSERT_INT_EQ("jad ranged model", ov.projectiles[0].model_id, INF_GFX_451_MODEL); + ASSERT_INT_EQ("jad ranged anim", ov.projectiles[0].anim_id, INF_GFX_451_ANIM); + ASSERT_INT_EQ("jad ranged target-anchored motion", + ov.projectiles[0].motion_mode, ENCOUNTER_PROJECTILE_MOTION_TARGET_ANCHORED); + ASSERT_INT_EQ("jad ranged start height is player target height", ov.projectiles[0].start_h, 64); + ASSERT_INT_EQ("jad ranged end height is player target height", ov.projectiles[0].end_h, 64); + ASSERT_INT_EQ("jad ranged visible duration is two ticks close range", ov.projectiles[0].duration_ticks, 2 * 30); + ASSERT_INT_EQ("jad ranged start delay is three ticks", ov.projectiles[0].start_delay, 3 * 30); +} + +static void test_jad_projectile_long_distance_visual_duration_uses_reference_formula(void) { + printf("--- jad long-distance projectile visual duration uses reference formula ---\n"); + + InfernoState range_state; + init_jad_timing_test_state(&range_state, 10, 10, 36, 10); + range_state.npcs[0].attacked_this_tick = 1; + range_state.npcs[0].attack_style_this_tick = ATTACK_STYLE_RANGED; + + EncounterOverlay range_ov; + memset(&range_ov, 0, sizeof(range_ov)); + inf_render_post_tick((EncounterState*)&range_state, &range_ov); + + int range_dist = encounter_dist_to_npc( + range_state.player.x, range_state.player.y, + range_state.npcs[0].x, range_state.npcs[0].y, + range_state.npcs[0].size); + int range_flight_ticks = encounter_ranged_hit_delay(range_dist, 0) - INF_JAD_PROJECTILE_DELAY; + if (range_flight_ticks < 1) range_flight_ticks = 1; + ASSERT_INT_EQ("jad ranged long-distance duration", + range_ov.projectiles[0].duration_ticks, (range_flight_ticks + 1) * 30); + + InfernoState magic_state; + init_jad_timing_test_state(&magic_state, 10, 10, 36, 10); + magic_state.npcs[0].attacked_this_tick = 1; + magic_state.npcs[0].attack_style_this_tick = ATTACK_STYLE_MAGIC; + + EncounterOverlay magic_ov; + memset(&magic_ov, 0, sizeof(magic_ov)); + inf_render_post_tick((EncounterState*)&magic_state, &magic_ov); + + int magic_dist = encounter_dist_to_npc( + magic_state.player.x, magic_state.player.y, + magic_state.npcs[0].x, magic_state.npcs[0].y, + magic_state.npcs[0].size); + int magic_flight_ticks = encounter_magic_hit_delay(magic_dist, 0) - INF_JAD_PROJECTILE_DELAY; + if (magic_flight_ticks < 1) magic_flight_ticks = 1; + ASSERT_INT_EQ("jad magic long-distance duration", + magic_ov.projectiles[0].duration_ticks, (magic_flight_ticks + 1) * 30); +} + +int main(void) { + inf_build_npc_stats(); + + test_melee_fallback_geometry(); + test_style_mask_preview(); + test_style_choice_sampling(); + test_tagged_jad_healer_melee_geometry(); + test_overlap_shuffle_hold_after_recent_target_click(); + test_overlap_shuffle_respects_npc_collision_flags(); + test_tagged_jad_healer_stops_at_melee_contact(); + test_tagged_jad_healers_queue_behind_front_healer(); + test_stacked_npc_unclipping_clears_flag_when_one_leaves(); + test_jad_healer_spawn_offsets_match_wave_67_reference(); + test_jad_healer_spawn_offsets_match_zuk_reference(); + test_meleer_dig_landing_order(); + test_reward_switches_between_healer_tags_and_damage(); + test_final_wave_reward_uses_zuk_low_watermark_progress(); + test_start_wave_public_to_internal_mapping(); + test_inferno_reset_supplies_match_current_inventory(); + test_late_start_supply_profile_anchor_waves(); + test_late_start_supply_profile_interpolation_and_scale(); + test_late_start_supply_observations(); + test_dead_mob_store_eligibility(); + test_resurrected_mob_does_not_reenter_dead_store(); + test_double_mager_wave_resurrection_limit(); + test_pending_hit_obs_timer_prefers_prayer_window(); + test_jad_has_no_pre_fire_style_preview(); + test_jad_fire_tick_exposes_three_tick_prayer_deadline(); + test_jad_prayer_on_third_tick_blocks(); + test_jad_prayer_first_on_fourth_tick_does_not_block(); + test_jad_long_distance_damage_uses_delayed_projectile_landing(); + test_triple_jad_pending_threats_preserve_obs_shape(); + test_jad_special_wave_spawn_cadence_matches_reference(); + test_jad_melee_stays_instant_and_untelegraphed(); + test_zuk_obs_tracks_shield_and_mager_aggro(); + test_human_target_and_potion_translation(); + test_action_noop_count_matches_action_heads(); + test_inferno_human_equip_does_not_snap_loadout(); + test_jad_render_uses_style_specific_attack_animation(); + test_jad_magic_render_emits_three_offset_projectiles(); + test_jad_ranged_render_uses_target_anchored_two_tick_visual(); + test_jad_projectile_long_distance_visual_duration_uses_reference_formula(); + + printf("\n%d/%d tests passed", tests_passed, tests_run); + if (tests_failed > 0) { + printf(" (%d failed)\n", tests_failed); + return 1; + } + printf("\n"); + return 0; +} diff --git a/ocean/osrs/tests/test_interaction.c b/ocean/osrs/tests/test_interaction.c new file mode 100644 index 0000000000..e14c154962 --- /dev/null +++ b/ocean/osrs/tests/test_interaction.c @@ -0,0 +1,213 @@ +/** + * @file test_interaction.c + * @brief tests for osrs_interaction.h: entity interaction system + spec toggle + * + * BUILD: + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_interaction \ + * ocean/osrs/tests/test_interaction.c -lm + * ./test_interaction + */ + +#include +#include + +#include "ocean/osrs/osrs_interaction.h" + + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + int _a = (actual), _e = (expected); \ + if (_a == _e) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %d, expected %d\n", (label), _a, _e); \ + } \ +} while (0) + + +static void test_init(void) { + printf("--- init ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + ASSERT_INT_EQ("target_slot is -1", ix.target_slot, -1); + ASSERT_INT_EQ("not active", osrs_interaction_active(&ix), 0); +} + + +static void test_set(void) { + printf("--- set ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + ASSERT_INT_EQ("target_slot is 5", ix.target_slot, 5); + ASSERT_INT_EQ("active", osrs_interaction_active(&ix), 1); +} + + +static void test_clear(void) { + printf("--- clear ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + osrs_interaction_clear(&ix); + ASSERT_INT_EQ("target_slot is -1", ix.target_slot, -1); + ASSERT_INT_EQ("not active", osrs_interaction_active(&ix), 0); +} + + +static void test_interrupt_move(void) { + printf("--- interrupt: MOVE ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + int result = osrs_interaction_check_interrupt(&ix, OSRS_IACT_MOVE); + ASSERT_INT_EQ("returns 1", result, 1); + ASSERT_INT_EQ("target cleared", ix.target_slot, -1); +} + + +static void test_interrupt_eat(void) { + printf("--- interrupt: EAT ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + int result = osrs_interaction_check_interrupt(&ix, OSRS_IACT_EAT); + ASSERT_INT_EQ("returns 1", result, 1); + ASSERT_INT_EQ("target cleared", ix.target_slot, -1); +} + + +static void test_interrupt_drink(void) { + printf("--- interrupt: DRINK ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + int result = osrs_interaction_check_interrupt(&ix, OSRS_IACT_DRINK); + ASSERT_INT_EQ("returns 1", result, 1); + ASSERT_INT_EQ("target cleared", ix.target_slot, -1); +} + + +static void test_interrupt_equip(void) { + printf("--- interrupt: EQUIP ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + int result = osrs_interaction_check_interrupt(&ix, OSRS_IACT_EQUIP); + ASSERT_INT_EQ("returns 1", result, 1); + ASSERT_INT_EQ("target cleared", ix.target_slot, -1); +} + + +static void test_no_interrupt_none(void) { + printf("--- no interrupt: NONE ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + int result = osrs_interaction_check_interrupt(&ix, OSRS_IACT_NONE); + ASSERT_INT_EQ("returns 0", result, 0); + ASSERT_INT_EQ("target persists", ix.target_slot, 5); +} + + +static void test_no_interrupt_prayer(void) { + printf("--- no interrupt: PRAYER ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + int result = osrs_interaction_check_interrupt(&ix, OSRS_IACT_PRAYER); + ASSERT_INT_EQ("returns 0", result, 0); + ASSERT_INT_EQ("target persists", ix.target_slot, 5); +} + + +static void test_no_interrupt_spec(void) { + printf("--- no interrupt: SPEC ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + int result = osrs_interaction_check_interrupt(&ix, OSRS_IACT_SPEC); + ASSERT_INT_EQ("returns 0", result, 0); + ASSERT_INT_EQ("target persists", ix.target_slot, 5); +} + + +static void test_no_interrupt_attack(void) { + printf("--- no interrupt: ATTACK ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + int result = osrs_interaction_check_interrupt(&ix, OSRS_IACT_ATTACK); + ASSERT_INT_EQ("returns 0", result, 0); + ASSERT_INT_EQ("target persists", ix.target_slot, 5); +} + + +static void test_interrupt_when_inactive(void) { + printf("--- interrupt when no interaction ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + int result = osrs_interaction_check_interrupt(&ix, OSRS_IACT_MOVE); + ASSERT_INT_EQ("returns 1 (idempotent)", result, 1); + ASSERT_INT_EQ("target still -1", ix.target_slot, -1); +} + + +static void test_set_replaces(void) { + printf("--- set replaces ---\n"); + OsrsInteraction ix; + osrs_interaction_init(&ix); + osrs_interaction_set(&ix, 5); + osrs_interaction_set(&ix, 3); + ASSERT_INT_EQ("target_slot is 3", ix.target_slot, 3); +} + + +static void test_spec_toggle(void) { + printf("--- spec toggle ---\n"); + int spec_armed = 0; + osrs_spec_toggle(&spec_armed); + ASSERT_INT_EQ("armed after toggle", spec_armed, 1); + osrs_spec_toggle(&spec_armed); + ASSERT_INT_EQ("disarmed after second toggle", spec_armed, 0); +} + + +static void test_spec_disarm(void) { + printf("--- spec disarm ---\n"); + int spec_armed = 1; + osrs_spec_disarm(&spec_armed); + ASSERT_INT_EQ("disarmed", spec_armed, 0); +} + + +int main(void) { + printf("=== osrs_interaction tests ===\n\n"); + + test_init(); + test_set(); + test_clear(); + test_interrupt_move(); + test_interrupt_eat(); + test_interrupt_drink(); + test_interrupt_equip(); + test_no_interrupt_none(); + test_no_interrupt_prayer(); + test_no_interrupt_spec(); + test_no_interrupt_attack(); + test_interrupt_when_inactive(); + test_set_replaces(); + test_spec_toggle(); + test_spec_disarm(); + + printf("\n=== results: %d passed, %d failed, %d total ===\n", + tests_passed, tests_failed, tests_run); + + return tests_failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_inventory.c b/ocean/osrs/tests/test_inventory.c new file mode 100644 index 0000000000..3197857f39 --- /dev/null +++ b/ocean/osrs/tests/test_inventory.c @@ -0,0 +1,369 @@ +/** + * @file test_inventory.c + * @brief tests for osrs_inventory.h: 28-slot inventory + equipment management + * + * BUILD: + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_inventory \ + * ocean/osrs/tests/test_inventory.c -lm + * ./test_inventory + */ + +#include +#include +#include + +#include "ocean/osrs/osrs_inventory.h" + + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + int _a = (actual), _e = (expected); \ + if (_a == _e) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %d, expected %d\n", (label), _a, _e); \ + } \ +} while (0) + +#define ASSERT_UINT8_EQ(label, actual, expected) do { \ + tests_run++; \ + uint8_t _a = (actual), _e = (expected); \ + if (_a == _e) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %u, expected %u\n", (label), (unsigned)_a, (unsigned)_e); \ + } \ +} while (0) + + +static void test_init(void) { + printf("--- init ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + for (int i = 0; i < NUM_GEAR_SLOTS; i++) { + ASSERT_UINT8_EQ("equipment slot empty", inv.equipment[i], ITEM_NONE); + } + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) { + ASSERT_UINT8_EQ("inventory slot empty", inv.inventory[i], ITEM_NONE); + } + ASSERT_INT_EQ("count 0", osrs_inventory_count(&inv), 0); + ASSERT_INT_EQ("free 28", osrs_inventory_free_slots(&inv), 28); +} + + +static void test_add_remove(void) { + printf("--- inventory add/remove ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + /* add items */ + int s0 = osrs_inventory_add(&inv, ITEM_WHIP); + int s1 = osrs_inventory_add(&inv, ITEM_DRAGON_DAGGER); + int s2 = osrs_inventory_add(&inv, ITEM_AGS); + ASSERT_INT_EQ("whip slot 0", s0, 0); + ASSERT_INT_EQ("dds slot 1", s1, 1); + ASSERT_INT_EQ("ags slot 2", s2, 2); + ASSERT_INT_EQ("count 3", osrs_inventory_count(&inv), 3); + ASSERT_INT_EQ("free 25", osrs_inventory_free_slots(&inv), 25); + + /* remove by slot */ + uint8_t removed = osrs_inventory_remove(&inv, 1); + ASSERT_UINT8_EQ("removed dds", removed, ITEM_DRAGON_DAGGER); + ASSERT_INT_EQ("count 2", osrs_inventory_count(&inv), 2); + ASSERT_UINT8_EQ("slot 1 now empty", inv.inventory[1], ITEM_NONE); + + /* remove by item */ + int ok = osrs_inventory_remove_item(&inv, ITEM_AGS); + ASSERT_INT_EQ("remove ags ok", ok, 1); + ASSERT_INT_EQ("count 1", osrs_inventory_count(&inv), 1); + + /* remove nonexistent */ + ok = osrs_inventory_remove_item(&inv, ITEM_AGS); + ASSERT_INT_EQ("remove ags again fails", ok, 0); +} + + +static void test_inventory_full(void) { + printf("--- inventory full ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + for (int i = 0; i < OSRS_INVENTORY_SIZE; i++) { + int s = osrs_inventory_add(&inv, ITEM_WHIP); + ASSERT_INT_EQ("add succeeds", s, i); + } + ASSERT_INT_EQ("count 28", osrs_inventory_count(&inv), 28); + ASSERT_INT_EQ("free 0", osrs_inventory_free_slots(&inv), 0); + + /* 29th item fails */ + int s = osrs_inventory_add(&inv, ITEM_DRAGON_DAGGER); + ASSERT_INT_EQ("29th fails", s, -1); +} + + +static void test_find(void) { + printf("--- find ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + osrs_inventory_add(&inv, ITEM_WHIP); + osrs_inventory_add(&inv, ITEM_DRAGON_DAGGER); + osrs_inventory_add(&inv, ITEM_WHIP); + + ASSERT_INT_EQ("find whip first", osrs_inventory_find(&inv, ITEM_WHIP), 0); + ASSERT_INT_EQ("find dds", osrs_inventory_find(&inv, ITEM_DRAGON_DAGGER), 1); + ASSERT_INT_EQ("find missing", osrs_inventory_find(&inv, ITEM_AGS), -1); +} + + +static void test_equip_direct(void) { + printf("--- equip direct ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + osrs_equip_direct(&inv, ITEM_WHIP); + ASSERT_UINT8_EQ("weapon = whip", inv.equipment[GEAR_SLOT_WEAPON], ITEM_WHIP); + ASSERT_INT_EQ("inventory untouched", osrs_inventory_count(&inv), 0); + + osrs_equip_direct(&inv, ITEM_HELM_NEITIZNOT); + ASSERT_UINT8_EQ("head = neit", inv.equipment[GEAR_SLOT_HEAD], ITEM_HELM_NEITIZNOT); + + osrs_equip_direct(&inv, ITEM_DRAGON_DEFENDER); + ASSERT_UINT8_EQ("shield = defender", inv.equipment[GEAR_SLOT_SHIELD], ITEM_DRAGON_DEFENDER); + + /* equip 2h direct: clears shield */ + osrs_equip_direct(&inv, ITEM_AGS); + ASSERT_UINT8_EQ("weapon = ags", inv.equipment[GEAR_SLOT_WEAPON], ITEM_AGS); + ASSERT_UINT8_EQ("shield cleared", inv.equipment[GEAR_SLOT_SHIELD], ITEM_NONE); +} + + +static void test_equip_from_inventory(void) { + printf("--- equip from inventory ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + osrs_inventory_add(&inv, ITEM_WHIP); /* slot 0 */ + int ok = osrs_equip_from_inventory(&inv, 0); + ASSERT_INT_EQ("equip ok", ok, 1); + ASSERT_UINT8_EQ("weapon = whip", inv.equipment[GEAR_SLOT_WEAPON], ITEM_WHIP); + ASSERT_UINT8_EQ("inv slot 0 cleared", inv.inventory[0], ITEM_NONE); + ASSERT_INT_EQ("inv count 0", osrs_inventory_count(&inv), 0); +} + + +static void test_equip_swap(void) { + printf("--- equip swap ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + /* equip whip directly */ + osrs_equip_direct(&inv, ITEM_WHIP); + /* put rapier in inventory */ + osrs_inventory_add(&inv, ITEM_GHRAZI_RAPIER); /* slot 0 */ + + int ok = osrs_equip_from_inventory(&inv, 0); + ASSERT_INT_EQ("swap ok", ok, 1); + ASSERT_UINT8_EQ("weapon = rapier", inv.equipment[GEAR_SLOT_WEAPON], ITEM_GHRAZI_RAPIER); + ASSERT_UINT8_EQ("whip in inv slot 0", inv.inventory[0], ITEM_WHIP); + ASSERT_INT_EQ("inv count 1", osrs_inventory_count(&inv), 1); +} + + +static void test_two_handed_equip(void) { + printf("--- two-handed equip ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + /* setup: whip + defender equipped */ + osrs_equip_direct(&inv, ITEM_WHIP); + osrs_equip_direct(&inv, ITEM_DRAGON_DEFENDER); + /* put AGS in inventory */ + osrs_inventory_add(&inv, ITEM_AGS); /* slot 0 */ + + int ok = osrs_equip_from_inventory(&inv, 0); + ASSERT_INT_EQ("2h equip ok", ok, 1); + ASSERT_UINT8_EQ("weapon = ags", inv.equipment[GEAR_SLOT_WEAPON], ITEM_AGS); + ASSERT_UINT8_EQ("shield cleared", inv.equipment[GEAR_SLOT_SHIELD], ITEM_NONE); + /* old weapon (whip) goes to inv slot 0 (where AGS was), defender goes to next free */ + ASSERT_UINT8_EQ("whip in inv", inv.inventory[0], ITEM_WHIP); + int def_slot = osrs_inventory_find(&inv, ITEM_DRAGON_DEFENDER); + ASSERT_INT_EQ("defender in inv", def_slot >= 0, 1); + ASSERT_INT_EQ("inv count 2", osrs_inventory_count(&inv), 2); +} + + +static void test_two_handed_no_old_weapon(void) { + printf("--- two-handed no old weapon ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + /* setup: only defender equipped, no weapon */ + osrs_equip_direct(&inv, ITEM_DRAGON_DEFENDER); + /* put AGS in inventory */ + osrs_inventory_add(&inv, ITEM_AGS); /* slot 0 */ + + int ok = osrs_equip_from_inventory(&inv, 0); + ASSERT_INT_EQ("2h equip ok", ok, 1); + ASSERT_UINT8_EQ("weapon = ags", inv.equipment[GEAR_SLOT_WEAPON], ITEM_AGS); + ASSERT_UINT8_EQ("shield cleared", inv.equipment[GEAR_SLOT_SHIELD], ITEM_NONE); + /* defender goes to inv slot 0 (where AGS was) */ + ASSERT_UINT8_EQ("defender in slot 0", inv.inventory[0], ITEM_DRAGON_DEFENDER); + ASSERT_INT_EQ("inv count 1", osrs_inventory_count(&inv), 1); +} + + +static void test_two_handed_fail(void) { + printf("--- two-handed fail ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + /* setup: whip + defender equipped, inventory full except AGS slot */ + osrs_equip_direct(&inv, ITEM_WHIP); + osrs_equip_direct(&inv, ITEM_DRAGON_DEFENDER); + /* fill 27 slots, then put AGS in slot 27 */ + for (int i = 0; i < 27; i++) { + osrs_inventory_add(&inv, ITEM_CLIMBING_BOOTS); + } + osrs_inventory_add(&inv, ITEM_AGS); /* slot 27 */ + ASSERT_INT_EQ("inv full", osrs_inventory_free_slots(&inv), 0); + + /* equipping AGS frees slot 27, but we need 2 slots (old whip + defender) — only 1 free */ + int ok = osrs_equip_from_inventory(&inv, 27); + ASSERT_INT_EQ("2h equip fails", ok, 0); + /* nothing changed */ + ASSERT_UINT8_EQ("weapon still whip", inv.equipment[GEAR_SLOT_WEAPON], ITEM_WHIP); + ASSERT_UINT8_EQ("shield still defender", inv.equipment[GEAR_SLOT_SHIELD], ITEM_DRAGON_DEFENDER); + ASSERT_UINT8_EQ("ags still in inv", inv.inventory[27], ITEM_AGS); +} + + +static void test_two_handed_exact_space(void) { + printf("--- two-handed exact space ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + /* setup: whip + defender equipped, 26 items + AGS = 27 occupied, 1 free */ + osrs_equip_direct(&inv, ITEM_WHIP); + osrs_equip_direct(&inv, ITEM_DRAGON_DEFENDER); + for (int i = 0; i < 26; i++) { + osrs_inventory_add(&inv, ITEM_CLIMBING_BOOTS); + } + osrs_inventory_add(&inv, ITEM_AGS); /* slot 26 */ + ASSERT_INT_EQ("1 free slot", osrs_inventory_free_slots(&inv), 1); + + /* equipping AGS: frees slot 26 (+1) plus 1 existing free = 2 free, need 2 (whip + defender) */ + int ok = osrs_equip_from_inventory(&inv, 26); + ASSERT_INT_EQ("2h equip ok", ok, 1); + ASSERT_UINT8_EQ("weapon = ags", inv.equipment[GEAR_SLOT_WEAPON], ITEM_AGS); + ASSERT_UINT8_EQ("shield cleared", inv.equipment[GEAR_SLOT_SHIELD], ITEM_NONE); +} + + +static void test_unequip(void) { + printf("--- unequip ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + osrs_equip_direct(&inv, ITEM_WHIP); + int ok = osrs_unequip_to_inventory(&inv, GEAR_SLOT_WEAPON); + ASSERT_INT_EQ("unequip ok", ok, 1); + ASSERT_UINT8_EQ("weapon cleared", inv.equipment[GEAR_SLOT_WEAPON], ITEM_NONE); + ASSERT_INT_EQ("whip in inv", osrs_inventory_find(&inv, ITEM_WHIP), 0); + + /* unequip empty slot */ + ok = osrs_unequip_to_inventory(&inv, GEAR_SLOT_WEAPON); + ASSERT_INT_EQ("unequip empty fails", ok, 0); + + /* unequip with full inventory */ + for (int i = 1; i < OSRS_INVENTORY_SIZE; i++) { + osrs_inventory_add(&inv, ITEM_CLIMBING_BOOTS); + } + ASSERT_INT_EQ("inv full", osrs_inventory_free_slots(&inv), 0); + osrs_equip_direct(&inv, ITEM_HELM_NEITIZNOT); + ok = osrs_unequip_to_inventory(&inv, GEAR_SLOT_HEAD); + ASSERT_INT_EQ("unequip full fails", ok, 0); + ASSERT_UINT8_EQ("head still equipped", inv.equipment[GEAR_SLOT_HEAD], ITEM_HELM_NEITIZNOT); +} + + +static void test_gear_slot_mapping(void) { + printf("--- gear slot mapping ---\n"); + + ASSERT_INT_EQ("whip -> weapon", osrs_item_gear_slot(ITEM_WHIP), GEAR_SLOT_WEAPON); + ASSERT_INT_EQ("neit -> head", osrs_item_gear_slot(ITEM_HELM_NEITIZNOT), GEAR_SLOT_HEAD); + ASSERT_INT_EQ("defender -> shield", osrs_item_gear_slot(ITEM_DRAGON_DEFENDER), GEAR_SLOT_SHIELD); + ASSERT_INT_EQ("fury -> neck", osrs_item_gear_slot(ITEM_FURY), GEAR_SLOT_NECK); + ASSERT_INT_EQ("infernal -> cape", osrs_item_gear_slot(ITEM_INFERNAL_CAPE), GEAR_SLOT_CAPE); + ASSERT_INT_EQ("tassets -> legs", osrs_item_gear_slot(ITEM_BANDOS_TASSETS), GEAR_SLOT_LEGS); + ASSERT_INT_EQ("bgloves -> hands", osrs_item_gear_slot(ITEM_BARROWS_GLOVES), GEAR_SLOT_HANDS); + ASSERT_INT_EQ("cboots -> feet", osrs_item_gear_slot(ITEM_CLIMBING_BOOTS), GEAR_SLOT_FEET); + ASSERT_INT_EQ("bring -> ring", osrs_item_gear_slot(ITEM_BERSERKER_RING), GEAR_SLOT_RING); + ASSERT_INT_EQ("dbolts -> ammo", osrs_item_gear_slot(ITEM_DIAMOND_BOLTS_E), GEAR_SLOT_AMMO); + ASSERT_INT_EQ("ags -> weapon", osrs_item_gear_slot(ITEM_AGS), GEAR_SLOT_WEAPON); +} + + +static void test_edge_cases(void) { + printf("--- edge cases ---\n"); + OsrsInventory inv; + osrs_inventory_init(&inv); + + /* ITEM_NONE operations */ + ASSERT_INT_EQ("gear slot ITEM_NONE", osrs_item_gear_slot(ITEM_NONE), -1); + ASSERT_INT_EQ("find ITEM_NONE in empty", osrs_inventory_find(&inv, ITEM_NONE), -1); + + /* remove from empty slot */ + uint8_t r = osrs_inventory_remove(&inv, 0); + ASSERT_UINT8_EQ("remove empty = ITEM_NONE", r, ITEM_NONE); + + /* remove out of bounds */ + r = osrs_inventory_remove(&inv, -1); + ASSERT_UINT8_EQ("remove -1 = ITEM_NONE", r, ITEM_NONE); + r = osrs_inventory_remove(&inv, 28); + ASSERT_UINT8_EQ("remove 28 = ITEM_NONE", r, ITEM_NONE); + + /* equip from invalid inventory slot */ + ASSERT_INT_EQ("equip slot -1", osrs_equip_from_inventory(&inv, -1), 0); + ASSERT_INT_EQ("equip slot 28", osrs_equip_from_inventory(&inv, 28), 0); + + /* equip from empty inventory slot */ + ASSERT_INT_EQ("equip empty slot", osrs_equip_from_inventory(&inv, 0), 0); + + /* unequip invalid gear slot */ + ASSERT_INT_EQ("unequip slot -1", osrs_unequip_to_inventory(&inv, -1), 0); + ASSERT_INT_EQ("unequip slot 11", osrs_unequip_to_inventory(&inv, NUM_GEAR_SLOTS), 0); +} + + +int main(void) { + printf("=== osrs_inventory.h tests ===\n\n"); + + test_init(); + test_add_remove(); + test_inventory_full(); + test_find(); + test_equip_direct(); + test_equip_from_inventory(); + test_equip_swap(); + test_two_handed_equip(); + test_two_handed_no_old_weapon(); + test_two_handed_fail(); + test_two_handed_exact_space(); + test_unequip(); + test_gear_slot_mapping(); + test_edge_cases(); + + printf("\n=== results: %d passed, %d failed, %d total ===\n", + tests_passed, tests_failed, tests_run); + + return tests_failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_item_effects.c b/ocean/osrs/tests/test_item_effects.c new file mode 100644 index 0000000000..ddc17dc9e1 --- /dev/null +++ b/ocean/osrs/tests/test_item_effects.c @@ -0,0 +1,918 @@ +/** + * @file test_item_effects.c + * @brief tests for item interactions, prayer reduction, tbow scaling edges, + * defensive rolls, player attack rolls with gear, and loadout edge cases. + * + * cross-referenced against osrs-dps-calc reference and OSRS wiki formulas. + * + * BUILD: + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_item_effects \ + * ocean/osrs/tests/test_item_effects.c -lm + * ./test_item_effects + * + * REFERENCE FILES: + * .refs/osrs-dps-calc/src/lib/PlayerVsNPCCalc.ts — tbow scaling, specific bonuses + * .refs/osrs-dps-calc/src/tests/calc/DefenceRolls.test.ts — NPC defence values + * .refs/osrs-dps-calc/src/tests/calc/Prayers.test.ts — prayer mechanics + * ocean/osrs/osrs_pvp_combat.h — PvP prayer 40% reduction + * ocean/osrs/encounters/encounter_inferno.h — PvE prayer full block + */ + +#include +#include +#include +#include + +#include "ocean/osrs/osrs_encounter.h" + + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + if ((actual) == (expected)) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %d, expected %d\n", (label), (actual), (expected)); \ + } \ +} while (0) + +#define ASSERT_FLOAT_NEAR(label, actual, expected, tolerance) do { \ + tests_run++; \ + float _a = (actual), _e = (expected), _t = (tolerance); \ + if (fabsf(_a - _e) <= _t) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %.6f, expected %.6f (tol %.6f)\n", \ + (label), _a, _e, _t); \ + } \ +} while (0) + +/* helper: fill loadout with ITEM_NONE */ +static void clear_loadout(uint8_t loadout[NUM_GEAR_SLOTS]) { + memset(loadout, 255, NUM_GEAR_SLOTS); +} +/* reference tbow multipliers with integer truncation (matching TS impl) */ +/* */ +/* ref: PlayerVsNPCCalc.ts tbowScaling() */ +/* accuracy: factor=10, base=140, cap=1.40 */ +/* damage: factor=14, base=250, cap=2.50 */ +static float ref_tbow_acc(int magic) { + int m = magic < 250 ? magic : 250; + int t2 = (3 * m - 10) / 100; + int inner = (3 * m / 10) - 100; + int t3 = (inner * inner) / 100; + int bonus = 140 + t2 - t3; + float mult = (float)bonus / 100.0f; + if (mult > 1.4f) mult = 1.4f; + if (mult < 0.0f) mult = 0.0f; + return mult; +} + +static float ref_tbow_dmg(int magic) { + int m = magic < 250 ? magic : 250; + int t2 = (3 * m - 14) / 100; + int inner = (3 * m / 10) - 140; + int t3 = (inner * inner) / 100; + int bonus = 250 + t2 - t3; + float mult = (float)bonus / 100.0f; + if (mult > 2.5f) mult = 2.5f; + if (mult < 0.0f) mult = 0.0f; + return mult; +} +/* test: tbow accuracy multiplier — edge cases and boundary behavior */ +/* */ +/* magic is clamped to [0, 250]. accuracy cap = 1.40, floor = 0.00. */ +/* our C uses float division; TS ref uses integer truncation on intermediates*/ +/* so we allow up to 0.01 tolerance. */ +static void test_tbow_acc_edge_cases(void) { + printf("--- tbow accuracy: edge cases ---\n"); + + /* magic=0: parabola leftmost point. low accuracy */ + ASSERT_FLOAT_NEAR("acc m=0 vs ref", osrs_tbow_acc_mult(0), ref_tbow_acc(0), 0.01f); + ASSERT_INT_EQ("acc m=0 >= 0", osrs_tbow_acc_mult(0) >= 0.0f, 1); + + /* magic=1: just above minimum */ + ASSERT_FLOAT_NEAR("acc m=1 vs ref", osrs_tbow_acc_mult(1), ref_tbow_acc(1), 0.01f); + + /* magic=50: low-magic NPC */ + ASSERT_FLOAT_NEAR("acc m=50 vs ref", osrs_tbow_acc_mult(50), ref_tbow_acc(50), 0.01f); + + /* magic=100: mid-range */ + ASSERT_FLOAT_NEAR("acc m=100 vs ref", osrs_tbow_acc_mult(100), ref_tbow_acc(100), 0.01f); + + /* magic=150: typical high-magic NPC (e.g. Zulrah) */ + ASSERT_FLOAT_NEAR("acc m=150 vs ref", osrs_tbow_acc_mult(150), ref_tbow_acc(150), 0.01f); + + /* magic=200: very high magic */ + ASSERT_FLOAT_NEAR("acc m=200 vs ref", osrs_tbow_acc_mult(200), ref_tbow_acc(200), 0.01f); + + /* magic=249: just below cap threshold */ + ASSERT_FLOAT_NEAR("acc m=249 vs ref", osrs_tbow_acc_mult(249), ref_tbow_acc(249), 0.01f); + + /* magic=250: cap boundary. should be at or near 1.40 */ + ASSERT_FLOAT_NEAR("acc m=250 vs ref", osrs_tbow_acc_mult(250), ref_tbow_acc(250), 0.01f); + ASSERT_INT_EQ("acc m=250 <= 1.4", osrs_tbow_acc_mult(250) <= 1.4f + 0.001f, 1); + + /* magic=251: above cap, clamps m to 250 internally, same result as 250 */ + ASSERT_FLOAT_NEAR("acc m=251 == m=250", + osrs_tbow_acc_mult(251), osrs_tbow_acc_mult(250), 1e-5f); + + /* magic=350: well above cap */ + ASSERT_FLOAT_NEAR("acc m=350 == m=250", + osrs_tbow_acc_mult(350), osrs_tbow_acc_mult(250), 1e-5f); + + /* magic=500: extreme above-cap value */ + ASSERT_FLOAT_NEAR("acc m=500 == m=250", + osrs_tbow_acc_mult(500), osrs_tbow_acc_mult(250), 1e-5f); + + /* strict monotonicity from 0 to 250 (parabola opens downward, peak >= 250) */ + int monotonic = 1; + for (int m = 1; m <= 250; m++) { + if (osrs_tbow_acc_mult(m) < osrs_tbow_acc_mult(m - 1)) { + monotonic = 0; + break; + } + } + ASSERT_INT_EQ("acc monotonic 0..250", monotonic, 1); +} +/* test: tbow damage multiplier — edge cases and inverted-U shape */ +/* */ +/* damage mult peaks around magic~100 and decreases at extremes. */ +/* cap = 2.50, floor = 0.00. */ +static void test_tbow_dmg_edge_cases(void) { + printf("--- tbow damage: edge cases ---\n"); + + /* magic=0: low end of parabola */ + ASSERT_FLOAT_NEAR("dmg m=0 vs ref", osrs_tbow_dmg_mult(0), ref_tbow_dmg(0), 0.015f); + ASSERT_INT_EQ("dmg m=0 >= 0", osrs_tbow_dmg_mult(0) >= 0.0f, 1); + + /* magic=1 */ + ASSERT_FLOAT_NEAR("dmg m=1 vs ref", osrs_tbow_dmg_mult(1), ref_tbow_dmg(1), 0.015f); + + /* magic=50 */ + ASSERT_FLOAT_NEAR("dmg m=50 vs ref", osrs_tbow_dmg_mult(50), ref_tbow_dmg(50), 0.015f); + + /* magic=100: near peak of inverted-U */ + ASSERT_FLOAT_NEAR("dmg m=100 vs ref", osrs_tbow_dmg_mult(100), ref_tbow_dmg(100), 0.015f); + + /* magic=150 */ + ASSERT_FLOAT_NEAR("dmg m=150 vs ref", osrs_tbow_dmg_mult(150), ref_tbow_dmg(150), 0.015f); + + /* magic=200 */ + ASSERT_FLOAT_NEAR("dmg m=200 vs ref", osrs_tbow_dmg_mult(200), ref_tbow_dmg(200), 0.015f); + + /* magic=250: cap boundary */ + ASSERT_FLOAT_NEAR("dmg m=250 vs ref", osrs_tbow_dmg_mult(250), ref_tbow_dmg(250), 0.015f); + ASSERT_INT_EQ("dmg m=250 <= 2.5", osrs_tbow_dmg_mult(250) <= 2.5f + 0.001f, 1); + + /* magic above 250: clamped to 250 */ + ASSERT_FLOAT_NEAR("dmg m=350 == m=250", + osrs_tbow_dmg_mult(350), osrs_tbow_dmg_mult(250), 1e-5f); + ASSERT_FLOAT_NEAR("dmg m=500 == m=250", + osrs_tbow_dmg_mult(500), osrs_tbow_dmg_mult(250), 1e-5f); + + /* damage mult increases monotonically from 0 to 250 (unlike accuracy, + the damage parabola doesn't peak before 250 in the capped range) */ + ASSERT_INT_EQ("dmg monotonic 0<100", + osrs_tbow_dmg_mult(0) < osrs_tbow_dmg_mult(100), 1); + ASSERT_INT_EQ("dmg monotonic 100<250", + osrs_tbow_dmg_mult(100) < osrs_tbow_dmg_mult(250), 1); +} +/* test: tbow cap and floor bounds sweep */ +/* */ +/* sweep magic 0..350 and verify both multipliers stay in valid range. */ +static void test_tbow_cap_behavior(void) { + printf("--- tbow cap and floor bounds ---\n"); + + int acc_ok = 1, dmg_ok = 1; + for (int m = 0; m <= 350; m++) { + float a = osrs_tbow_acc_mult(m); + float d = osrs_tbow_dmg_mult(m); + if (a < 0.0f || a > 1.4f + 0.001f) acc_ok = 0; + if (d < 0.0f || d > 2.5f + 0.001f) dmg_ok = 0; + } + ASSERT_INT_EQ("acc in [0, 1.4] for m=0..350", acc_ok, 1); + ASSERT_INT_EQ("dmg in [0, 2.5] for m=0..350", dmg_ok, 1); +} +/* test: PvP prayer protection — correct overhead reduces damage by 40% */ +/* */ +/* ref: osrs_pvp_combat.h line 561 — actual_damage = (int)(damage * 0.6f) */ +/* in PvP, correct overhead prayer reduces incoming damage by 40%. */ +static void test_prayer_pvp_reduction(void) { + printf("--- PvP prayer: 40%% damage reduction ---\n"); + + /* direct formula test: (int)(damage * 0.6f) + this is the exact expression used in osrs_pvp_combat.h */ + ASSERT_INT_EQ("dmg=0 -> 0", (int)(0 * 0.6f), 0); + ASSERT_INT_EQ("dmg=1 -> 0", (int)(1 * 0.6f), 0); + ASSERT_INT_EQ("dmg=2 -> 1", (int)(2 * 0.6f), 1); + ASSERT_INT_EQ("dmg=5 -> 3", (int)(5 * 0.6f), 3); + ASSERT_INT_EQ("dmg=10 -> 6", (int)(10 * 0.6f), 6); + ASSERT_INT_EQ("dmg=50 -> 30", (int)(50 * 0.6f), 30); + ASSERT_INT_EQ("dmg=97 -> 58", (int)(97 * 0.6f), 58); + ASSERT_INT_EQ("dmg=100 -> 60", (int)(100 * 0.6f), 60); + + /* verify correct prayer check identifies match for each style */ + ASSERT_INT_EQ("melee blocked", + encounter_prayer_correct_for_style(3 /* PROTECT_MELEE */, 1 /* MELEE */), 1); + ASSERT_INT_EQ("ranged blocked", + encounter_prayer_correct_for_style(2 /* PROTECT_RANGED */, 2 /* RANGED */), 1); + ASSERT_INT_EQ("magic blocked", + encounter_prayer_correct_for_style(1 /* PROTECT_MAGIC */, 3 /* MAGIC */), 1); + + /* combined: prayer match + reduction formula */ + for (int dmg = 0; dmg <= 100; dmg += 10) { + int actual = dmg; + if (encounter_prayer_correct_for_style(3, 1)) /* melee -> protect melee */ + actual = (int)(dmg * 0.6f); + int expected = (int)(dmg * 0.6f); + char label[64]; + snprintf(label, sizeof(label), "pvp melee reduce dmg=%d", dmg); + ASSERT_INT_EQ(label, actual, expected); + } +} +/* test: PvE prayer protection — correct overhead blocks damage entirely */ +/* */ +/* ref: encounter_inferno.h — if (prayer_matches) { dmg = 0; } */ +/* in PvE (inferno, Zulrah, etc.), correct overhead sets damage to 0. */ +static void test_prayer_pve_block(void) { + printf("--- PvE prayer: full damage block ---\n"); + + /* simulate PvE prayer check: prayer_matches -> dmg = 0 */ + int test_damages[] = {1, 10, 50, 97, 100}; + for (int i = 0; i < 5; i++) { + int dmg = test_damages[i]; + int prayer_matches = encounter_prayer_correct_for_style( + 3 /* PROTECT_MELEE */, 1 /* MELEE */); + if (prayer_matches) dmg = 0; + char label[64]; + snprintf(label, sizeof(label), "pve melee dmg=%d blocked", test_damages[i]); + ASSERT_INT_EQ(label, dmg, 0); + } + + /* ranged: protect ranged blocks ranged */ + for (int i = 0; i < 5; i++) { + int dmg = test_damages[i]; + if (encounter_prayer_correct_for_style(2, 2)) dmg = 0; + char label[64]; + snprintf(label, sizeof(label), "pve ranged dmg=%d blocked", test_damages[i]); + ASSERT_INT_EQ(label, dmg, 0); + } + + /* magic: protect magic blocks magic */ + for (int i = 0; i < 5; i++) { + int dmg = test_damages[i]; + if (encounter_prayer_correct_for_style(1, 3)) dmg = 0; + char label[64]; + snprintf(label, sizeof(label), "pve magic dmg=%d blocked", test_damages[i]); + ASSERT_INT_EQ(label, dmg, 0); + } + + /* wrong prayer: damage passes through unchanged */ + int dmg = 50; + int prayer_matches = encounter_prayer_correct_for_style( + 1 /* PROTECT_MAGIC */, 1 /* MELEE attack */); + if (prayer_matches) dmg = 0; + ASSERT_INT_EQ("pve wrong prayer passthrough", dmg, 50); +} +/* test: wrong prayer — exhaustive no-reduction check */ +/* */ +/* prayer enum: NONE=0, MAGIC=1, RANGED=2, MELEE=3 */ +/* style enum: NONE=0, MELEE=1, RANGED=2, MAGIC=3 */ +/* every wrong prayer+style pair must return 0. */ +static void test_prayer_wrong_no_reduction(void) { + printf("--- wrong prayer: no reduction ---\n"); + + /* no prayer (0) blocks nothing */ + ASSERT_INT_EQ("none vs melee", encounter_prayer_correct_for_style(0, 1), 0); + ASSERT_INT_EQ("none vs ranged", encounter_prayer_correct_for_style(0, 2), 0); + ASSERT_INT_EQ("none vs magic", encounter_prayer_correct_for_style(0, 3), 0); + + /* protect magic (1) only blocks magic (3) */ + ASSERT_INT_EQ("prot_magic vs melee", encounter_prayer_correct_for_style(1, 1), 0); + ASSERT_INT_EQ("prot_magic vs ranged", encounter_prayer_correct_for_style(1, 2), 0); + + /* protect ranged (2) only blocks ranged (2) */ + ASSERT_INT_EQ("prot_ranged vs melee", encounter_prayer_correct_for_style(2, 1), 0); + ASSERT_INT_EQ("prot_ranged vs magic", encounter_prayer_correct_for_style(2, 3), 0); + + /* protect melee (3) only blocks melee (1) */ + ASSERT_INT_EQ("prot_melee vs ranged", encounter_prayer_correct_for_style(3, 2), 0); + ASSERT_INT_EQ("prot_melee vs magic", encounter_prayer_correct_for_style(3, 3), 0); + + /* attack style NONE (0) is never blocked by any prayer */ + ASSERT_INT_EQ("prot_magic vs none", encounter_prayer_correct_for_style(1, 0), 0); + ASSERT_INT_EQ("prot_ranged vs none", encounter_prayer_correct_for_style(2, 0), 0); + ASSERT_INT_EQ("prot_melee vs none", encounter_prayer_correct_for_style(3, 0), 0); +} +/* test: NPC defensive rolls against player attacks */ +/* */ +/* NPC defence roll = (def_level + 9) * (style_def_bonus + 64). */ +/* same formula as osrs_npc_attack_roll (NPCs use +9 invisible boost). */ +/* */ +/* ref: PlayerVsNPCCalc.ts getNPCDefenceRoll, DefenceRolls.test.ts */ +static void test_npc_def_roll_vs_player(void) { + printf("--- NPC defence rolls vs player attacks ---\n"); + + /* Abyssal demon: def=135, stab_def=20 + vs melee (stab): (135+9) * (20+64) = 144 * 84 = 12096 + ref: DefenceRolls.test.ts */ + ASSERT_INT_EQ("abyssal demon vs stab", + osrs_npc_attack_roll(135, 20), 12096); + + /* same NPC vs magic (magic_def=0): + (135+9) * (0+64) = 144 * 64 = 9216 */ + ASSERT_INT_EQ("abyssal demon vs magic", + osrs_npc_attack_roll(135, 0), 9216); + + /* Verzik P3: def=90, crush_def=-10 + vs crush: (90+9) * (-10+64) = 99 * 54 = 5346 */ + ASSERT_INT_EQ("verzik p3 vs crush", + osrs_npc_attack_roll(90, -10), 5346); + + /* Zulrah: def=300, ranged_def=50 + vs ranged: (300+9) * (50+64) = 309 * 114 = 35226 */ + ASSERT_INT_EQ("zulrah vs ranged", + osrs_npc_attack_roll(300, 50), 35226); + + /* Zulrah vs magic (magic_def=300): + (300+9) * (300+64) = 309 * 364 = 112476 */ + ASSERT_INT_EQ("zulrah vs magic", + osrs_npc_attack_roll(300, 300), 112476); + + /* low-level NPC: def=10, stab_def=0 + (10+9) * (0+64) = 19 * 64 = 1216 */ + ASSERT_INT_EQ("weak npc vs stab", + osrs_npc_attack_roll(10, 0), 1216); + + /* absolute minimum: def=0, bonus=0 + (0+9) * (0+64) = 9 * 64 = 576 */ + ASSERT_INT_EQ("def=0 bonus=0", + osrs_npc_attack_roll(0, 0), 576); + + /* negative defence bonus (crush-weak monster): + def=200, bonus=-20: (209) * (44) = 9196 */ + ASSERT_INT_EQ("negative def bonus", + osrs_npc_attack_roll(200, -20), 9196); +} +/* test: player attack roll — full mage gear */ +/* */ +/* kodai + ancestral hat/top/bottom + occult + ward (f) + tormented + */ +/* eternal boots + seers ring (i) + god cape. augury prayer. */ +/* */ +/* ref: PlayerVsNPCCalc.ts getPlayerMaxMagicAttackRoll */ +static void test_player_att_roll_full_mage(void) { + printf("--- player att roll: full mage (10 slots, augury) ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; + loadout[GEAR_SLOT_HEAD] = ITEM_ANCESTRAL_HAT; + loadout[GEAR_SLOT_BODY] = ITEM_ANCESTRAL_TOP; + loadout[GEAR_SLOT_LEGS] = ITEM_ANCESTRAL_BOTTOM; + loadout[GEAR_SLOT_NECK] = ITEM_OCCULT_NECKLACE; + loadout[GEAR_SLOT_SHIELD] = ITEM_ELIDINIS_WARD_F; + loadout[GEAR_SLOT_HANDS] = ITEM_TORMENTED_BRACELET; + loadout[GEAR_SLOT_FEET] = ITEM_ETERNAL_BOOTS; + loadout[GEAR_SLOT_RING] = ITEM_SEERS_RING_I; + loadout[GEAR_SLOT_CAPE] = ITEM_GOD_CAPE; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_AUGURY, + 99, FIGHT_STYLE_AUTOCAST, 30 /* ice barrage */, &stats); + + /* sum attack_magic: + kodai(28) + hat(8) + top(35) + bottom(26) + occult(12) + + ward(25) + tormented(10) + eternal(8) + seers(12) + cape(15) = 179 */ + ASSERT_INT_EQ("attack_bonus", stats.attack_bonus, 179); + + /* sum magic_damage: + kodai(15) + hat(3) + top(3) + bottom(3) + occult(5) + + ward(5) + tormented(5) + eternal(1) + seers(1) + cape(2) = 43 */ + ASSERT_INT_EQ("strength_bonus", stats.strength_bonus, 43); + + /* eff_level = floor(99 * 1.25) + 9 = 123 + 9 = 132 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 132); + + /* max_hit = floor(30 * (1.0 + 43/100.0) * 1.04) = floor(30 * 1.43 * 1.04) + = floor(44.616) = 44 */ + ASSERT_INT_EQ("max_hit", stats.max_hit, 44); + + /* attack_roll = 132 * (179 + 64) = 132 * 243 = 32076 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 32076); +} +/* test: player attack roll — rapier + defender + infernal cape (piety) */ +/* */ +/* ref: PlayerVsNPCCalc.ts getPlayerMaxMeleeAttackRoll */ +static void test_player_att_roll_melee_with_defender(void) { + printf("--- player att roll: rapier + defender + infernal cape, piety ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_GHRAZI_RAPIER; + loadout[GEAR_SLOT_SHIELD] = ITEM_DRAGON_DEFENDER; + loadout[GEAR_SLOT_CAPE] = ITEM_INFERNAL_CAPE; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_PIETY, + 99, FIGHT_STYLE_AGGRESSIVE, 0, &stats); + + /* best melee attack bonus: + stab: rapier(94) + defender(25) + cape(4) = 123 + slash: rapier(55) + defender(24) + cape(4) = 83 + crush: rapier(0) + defender(23) + cape(4) = 27 + best = 123 */ + ASSERT_INT_EQ("attack_bonus", stats.attack_bonus, 123); + + /* melee_strength: rapier(89) + defender(6) + cape(8) = 103 */ + ASSERT_INT_EQ("strength_bonus", stats.strength_bonus, 103); + + /* aggressive stance: +3 to STR only (not attack, which is accurate's role). + eff_level (attack) = floor(99 * 1.20) + 0 + 8 = 118 + 8 = 126 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 126); + + /* eff_str = floor(99 * 1.23) + 3 + 8 = 121 + 11 = 132 + max_hit = floor(0.5 + 132 * (103+64) / 640) = floor(0.5 + 132*167/640) + = floor(0.5 + 34.44375) = floor(34.94375) = 34 */ + ASSERT_INT_EQ("max_hit", stats.max_hit, 34); + + /* attack_roll = 126 * (123 + 64) = 126 * 187 = 23562 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 23562); +} +/* test: player attack roll — blowpipe (rigour) */ +/* */ +/* ref: PlayerVsNPCCalc.ts getPlayerMaxRangedAttackRoll */ +static void test_player_att_roll_ranged_blowpipe(void) { + printf("--- player att roll: blowpipe, rigour ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_TOXIC_BLOWPIPE; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_RIGOUR, + 99, FIGHT_STYLE_RAPID, 0, &stats); + + /* blowpipe: attack_ranged=30, ranged_strength=20 */ + ASSERT_INT_EQ("attack_bonus", stats.attack_bonus, 30); + ASSERT_INT_EQ("strength_bonus", stats.strength_bonus, 20); + + /* blowpipe attack_speed: equipment.json stores base=3 (accurate/longrange), + rapid stance subtracts 1 → 2. ref: osrs-sdk Blowpipe.ts:79-84. */ + ASSERT_INT_EQ("attack_speed", stats.attack_speed, 2); + + /* eff_att = floor(99 * 1.20) + 0 + 8 = 118 + 8 = 126 */ + ASSERT_INT_EQ("eff_level", stats.eff_level, 126); + + /* eff_str = floor(99 * 1.23) + 0 + 8 = 121 + 8 = 129 + max_hit = floor(0.5 + 129 * (20+64) / 640) = floor(0.5 + 129*84/640) + = floor(0.5 + 16.93125) = floor(17.43125) = 17 */ + ASSERT_INT_EQ("max_hit", stats.max_hit, 17); + + /* attack_roll = 126 * (30 + 64) = 126 * 94 = 11844 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("attack_roll", att_roll, 11844); +} +/* test: loadout edge case — all empty slots, all 3 styles */ +/* */ +/* with no gear (all ITEM_NONE), stats should reflect bare-handed combat. */ +static void test_loadout_empty_all_styles(void) { + printf("--- loadout: all empty, all 3 styles ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + + /* melee, level 99, no prayer, accurate stance (+3 att, +0 str) */ + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_NONE, + 99, FIGHT_STYLE_ACCURATE, 0, &stats); + + ASSERT_INT_EQ("empty melee att_bonus", stats.attack_bonus, 0); + ASSERT_INT_EQ("empty melee str_bonus", stats.strength_bonus, 0); + /* eff = 99 + 3 + 8 = 110 (accurate +3 to attack) */ + ASSERT_INT_EQ("empty melee eff", stats.eff_level, 110); + /* max_hit uses eff_str = 99 + 0 + 8 = 107 (accurate gives 0 to str). + max_hit = floor(0.5 + 107 * 64 / 640) = floor(0.5 + 10.7) = 11 */ + ASSERT_INT_EQ("empty melee max", stats.max_hit, 11); + ASSERT_INT_EQ("empty melee def_stab", stats.def_stab, 0); + ASSERT_INT_EQ("empty melee def_magic", stats.def_magic, 0); + + /* ranged, no prayer, rapid stance (no level bonus, -1 speed) */ + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_NONE, + 99, FIGHT_STYLE_RAPID, 0, &stats); + + ASSERT_INT_EQ("empty ranged att_bonus", stats.attack_bonus, 0); + ASSERT_INT_EQ("empty ranged str_bonus", stats.strength_bonus, 0); + /* eff_level = 99 + 0 + 8 = 107, max_hit = floor(0.5 + 107*64/640) = 11 */ + ASSERT_INT_EQ("empty ranged max", stats.max_hit, 11); + + /* magic with ice barrage, autocast (no invisible bonus per wiki) */ + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, + 99, FIGHT_STYLE_AUTOCAST, 30, &stats); + + ASSERT_INT_EQ("empty magic att_bonus", stats.attack_bonus, 0); + ASSERT_INT_EQ("empty magic str_bonus", stats.strength_bonus, 0); + /* magic eff = 99 + 0 + 9 = 108 (autocast has no invisible bonus) */ + ASSERT_INT_EQ("empty magic eff", stats.eff_level, 108); + /* max_hit = floor(30 * (1.0 + 0/100.0) * 1.0) = 30 */ + ASSERT_INT_EQ("empty magic max", stats.max_hit, 30); +} +/* test: loadout with all 11 gear slots filled */ +/* */ +/* verifies stats sum across every slot. uses a full mage setup with */ +/* god blessing in ammo slot to hit all 11 slots. */ +static void test_loadout_all_slots_filled(void) { + printf("--- loadout: all 11 slots filled (full mage) ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_HEAD] = ITEM_ANCESTRAL_HAT; + loadout[GEAR_SLOT_CAPE] = ITEM_GOD_CAPE; + loadout[GEAR_SLOT_NECK] = ITEM_OCCULT_NECKLACE; + loadout[GEAR_SLOT_AMMO] = ITEM_GOD_BLESSING; + loadout[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; + loadout[GEAR_SLOT_SHIELD] = ITEM_ELIDINIS_WARD_F; + loadout[GEAR_SLOT_BODY] = ITEM_ANCESTRAL_TOP; + loadout[GEAR_SLOT_LEGS] = ITEM_ANCESTRAL_BOTTOM; + loadout[GEAR_SLOT_HANDS] = ITEM_TORMENTED_BRACELET; + loadout[GEAR_SLOT_FEET] = ITEM_ETERNAL_BOOTS; + loadout[GEAR_SLOT_RING] = ITEM_SEERS_RING_I; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_AUGURY, + 99, FIGHT_STYLE_AUTOCAST, 30, &stats); + + /* god_blessing has attack_magic=0, so same total as 10-slot mage = 179 */ + ASSERT_INT_EQ("all_slots attack_bonus", stats.attack_bonus, 179); + + /* verify all defence bonuses reflect 11 items via DB cross-check */ + int exp_def_magic = 0; + for (int s = 0; s < NUM_GEAR_SLOTS; s++) { + if (loadout[s] != 255) + exp_def_magic += ITEM_DATABASE[loadout[s]].defence_magic; + } + ASSERT_INT_EQ("all_slots def_magic", stats.def_magic, exp_def_magic); + + /* same for def_stab */ + int exp_def_stab = 0; + for (int s = 0; s < NUM_GEAR_SLOTS; s++) { + if (loadout[s] != 255) + exp_def_stab += ITEM_DATABASE[loadout[s]].defence_stab; + } + ASSERT_INT_EQ("all_slots def_stab", stats.def_stab, exp_def_stab); + + /* weapon properties come through */ + ASSERT_INT_EQ("all_slots attack_speed", stats.attack_speed, 4); /* kodai */ + ASSERT_INT_EQ("all_slots attack_range", stats.attack_range, 10); +} +/* test: two-handed weapon loadout (AGS) */ +/* */ +/* 2H weapons use ITEM_NONE in shield slot. verifies no shield bonus leaks */ +/* and weapon stats compute correctly. */ +static void test_loadout_two_handed_weapon(void) { + printf("--- loadout: AGS (two-handed), piety ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_AGS; + /* shield slot stays ITEM_NONE — correct for 2H weapons */ + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_PIETY, + 99, FIGHT_STYLE_AGGRESSIVE, 0, &stats); + + /* AGS: stab=0, slash=132, crush=80. best = 132 */ + ASSERT_INT_EQ("2h attack_bonus", stats.attack_bonus, 132); + + /* AGS melee_strength = 132 */ + ASSERT_INT_EQ("2h strength_bonus", stats.strength_bonus, 132); + + /* attack_speed = 6 (AGS is slow) */ + ASSERT_INT_EQ("2h attack_speed", stats.attack_speed, 6); + + /* aggressive: +3 str, +0 att. + eff_level (attack) = floor(99 * 1.20) + 0 + 8 = 126 */ + ASSERT_INT_EQ("2h eff_level", stats.eff_level, 126); + + /* eff_str = floor(99 * 1.23) + 3 + 8 = 132 + max_hit = floor(0.5 + 132 * (132+64) / 640) + = floor(0.5 + 132*196/640) = floor(0.5 + 40.425) = 40 */ + ASSERT_INT_EQ("2h max_hit", stats.max_hit, 40); + + /* no shield: all defence bonuses from AGS only (which are all 0) */ + ASSERT_INT_EQ("2h def_stab", stats.def_stab, 0); + ASSERT_INT_EQ("2h def_magic", stats.def_magic, 0); + ASSERT_INT_EQ("2h def_ranged", stats.def_ranged, 0); + + /* attack_roll = 126 * (132+64) = 126 * 196 = 24696 */ + int att_roll = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("2h attack_roll", att_roll, 24696); +} +/* test: item_is_two_handed classification */ +/* */ +/* verify all known 2H weapons return 1, and 1H weapons / non-weapons */ +/* return 0. */ +static void test_two_handed_classification(void) { + printf("--- item_is_two_handed ---\n"); + + /* known 2H weapons */ + ASSERT_INT_EQ("AGS 2h", item_is_two_handed(ITEM_AGS), 1); + ASSERT_INT_EQ("ancient GS 2h", item_is_two_handed(ITEM_ANCIENT_GS), 1); + ASSERT_INT_EQ("d claws 2h", item_is_two_handed(ITEM_DRAGON_CLAWS), 1); + ASSERT_INT_EQ("g maul 2h", item_is_two_handed(ITEM_GRANITE_MAUL), 1); + ASSERT_INT_EQ("elder maul 2h", item_is_two_handed(ITEM_ELDER_MAUL), 1); + ASSERT_INT_EQ("dark bow 2h", item_is_two_handed(ITEM_DARK_BOW), 1); + ASSERT_INT_EQ("ballista 2h", item_is_two_handed(ITEM_HEAVY_BALLISTA), 1); + + /* known 1H weapons */ + ASSERT_INT_EQ("whip 1h", item_is_two_handed(ITEM_WHIP), 0); + ASSERT_INT_EQ("rapier 1h", item_is_two_handed(ITEM_GHRAZI_RAPIER), 0); + ASSERT_INT_EQ("kodai 1h", item_is_two_handed(ITEM_KODAI_WAND), 0); + ASSERT_INT_EQ("dagger 1h", item_is_two_handed(ITEM_DRAGON_DAGGER), 0); + ASSERT_INT_EQ("ACB 1h", item_is_two_handed(ITEM_ARMADYL_CROSSBOW), 0); + ASSERT_INT_EQ("voidwaker 1h", item_is_two_handed(ITEM_VOIDWAKER), 0); + ASSERT_INT_EQ("vestas 1h", item_is_two_handed(ITEM_VESTAS), 0); + + /* non-weapon items / special sentinel */ + ASSERT_INT_EQ("defender 1h", item_is_two_handed(ITEM_DRAGON_DEFENDER), 0); + ASSERT_INT_EQ("ITEM_NONE 1h", item_is_two_handed(ITEM_NONE), 0); +} +/* test: end-to-end hit chance — player attacks NPC */ +/* */ +/* combines player attack roll (from loadout) with NPC defence roll to */ +/* compute final hit chance via osrs_hit_chance. realistic scenarios. */ +static void test_hit_chance_player_vs_npc(void) { + printf("--- hit chance: player vs NPC (end-to-end) ---\n"); + + /* scenario 1: full mage (augury) vs Zulrah magic form + Zulrah: def=300, magic_def=300 — near-impossible to mage */ + { + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; + loadout[GEAR_SLOT_HEAD] = ITEM_ANCESTRAL_HAT; + loadout[GEAR_SLOT_BODY] = ITEM_ANCESTRAL_TOP; + loadout[GEAR_SLOT_LEGS] = ITEM_ANCESTRAL_BOTTOM; + loadout[GEAR_SLOT_NECK] = ITEM_OCCULT_NECKLACE; + loadout[GEAR_SLOT_SHIELD] = ITEM_ELIDINIS_WARD_F; + loadout[GEAR_SLOT_HANDS] = ITEM_TORMENTED_BRACELET; + loadout[GEAR_SLOT_FEET] = ITEM_ETERNAL_BOOTS; + loadout[GEAR_SLOT_RING] = ITEM_SEERS_RING_I; + loadout[GEAR_SLOT_CAPE] = ITEM_GOD_CAPE; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_AUGURY, + 99, FIGHT_STYLE_AUTOCAST, 30, &stats); + + int player_att = stats.eff_level * (stats.attack_bonus + 64); /* 32076 */ + int npc_def = osrs_npc_attack_roll(300, 300); /* 112476 */ + float chance = osrs_hit_chance(player_att, npc_def); + + /* att < def: chance = att / (2*(def+1)) = 32076 / 224954 ~ 0.1426 */ + float expected = 32076.0f / (2.0f * 112477.0f); + ASSERT_FLOAT_NEAR("mage vs zulrah magic", chance, expected, 1e-3f); + ASSERT_INT_EQ("mage vs zulrah < 0.2", chance < 0.2f, 1); + } + + /* scenario 2: rapier (piety) vs low-def NPC (def=10, stab_def=0) */ + { + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_GHRAZI_RAPIER; + loadout[GEAR_SLOT_SHIELD] = ITEM_DRAGON_DEFENDER; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_PIETY, + 99, FIGHT_STYLE_ACCURATE, 0, &stats); + + /* rapier(94)+defender(25) stab = 119 best */ + int player_att = stats.eff_level * (stats.attack_bonus + 64); + int npc_def = osrs_npc_attack_roll(10, 0); /* 19*64 = 1216 */ + float chance = osrs_hit_chance(player_att, npc_def); + + /* att >> def: near 100%. 1 - (1218)/(2*(player_att+1)) */ + ASSERT_INT_EQ("melee vs weak npc > 0.9", chance > 0.9f, 1); + } + + /* scenario 3: empty-handed mage vs same weak NPC + should still have decent accuracy from level alone */ + { + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_AUGURY, + 99, FIGHT_STYLE_AUTOCAST, 30, &stats); + + /* autocast, no invisible bonus. eff=132 (99*1.25 + 0 + 9), bonus=0, + att_roll = 132*64 = 8448 */ + int player_att = stats.eff_level * (stats.attack_bonus + 64); + ASSERT_INT_EQ("empty mage att_roll", player_att, 8448); + + int npc_def = osrs_npc_attack_roll(10, 0); /* 1216 */ + float chance = osrs_hit_chance(player_att, npc_def); + /* 8448 > 1216: 1 - 1218/16898 ~ 0.928 */ + ASSERT_INT_EQ("empty mage vs weak > 0.9", chance > 0.9f, 1); + } +} +/* test: defence bonus selection picks correct stat per attack style */ +/* */ +/* ref: osrs_combat_shared.h encounter_player_def_bonus */ +/* uses asymmetric values so any cross-wiring is detectable. */ +static void test_def_bonus_selection(void) { + printf("--- defence bonus selection by style ---\n"); + + int stab = 11, slash = 22, crush = 33, magic = 44, ranged = 55; + + ASSERT_INT_EQ("melee stab", + encounter_player_def_bonus(stab, slash, crush, magic, ranged, 1, 0), 11); + ASSERT_INT_EQ("melee slash", + encounter_player_def_bonus(stab, slash, crush, magic, ranged, 1, 1), 22); + ASSERT_INT_EQ("melee crush", + encounter_player_def_bonus(stab, slash, crush, magic, ranged, 1, 2), 33); + ASSERT_INT_EQ("ranged", + encounter_player_def_bonus(stab, slash, crush, magic, ranged, 2, 0), 55); + ASSERT_INT_EQ("magic", + encounter_player_def_bonus(stab, slash, crush, magic, ranged, 3, 0), 44); +} +/* test: gear defence bonuses feed into player_def_roll correctly */ +/* */ +/* rapier + defender + infernal cape defence sums -> */ +/* osrs_player_def_roll_vs_npc with those bonuses. */ +static void test_loadout_defence_into_def_roll(void) { + printf("--- loadout def bonuses -> player_def_roll ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_GHRAZI_RAPIER; + loadout[GEAR_SLOT_SHIELD] = ITEM_DRAGON_DEFENDER; + loadout[GEAR_SLOT_CAPE] = ITEM_INFERNAL_CAPE; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MELEE, OFFENSIVE_PRAYER_NONE, + 99, FIGHT_STYLE_ACCURATE, 0, &stats); + + /* rapier def: 0,0,0,0,0. defender: 25,24,23,-3,-2. cape: 12,12,12,12,12 */ + ASSERT_INT_EQ("def_stab", stats.def_stab, 0 + 25 + 12); /* 37 */ + ASSERT_INT_EQ("def_slash", stats.def_slash, 0 + 24 + 12); /* 36 */ + ASSERT_INT_EQ("def_crush", stats.def_crush, 0 + 23 + 12); /* 35 */ + ASSERT_INT_EQ("def_magic", stats.def_magic, 0 + (-3) + 12); /* 9 */ + ASSERT_INT_EQ("def_ranged", stats.def_ranged, 0 + (-2) + 12); /* 10 */ + + /* player def roll vs incoming melee stab: + (def_level + 8) * (def_stab + 64) = (99+8) * (37+64) = 107 * 101 = 10807 */ + int def_roll_stab = osrs_player_def_roll_vs_npc(99, 99, stats.def_stab, 1); + ASSERT_INT_EQ("def roll vs stab", def_roll_stab, 10807); + + /* vs incoming ranged: + (99+8) * (10+64) = 107 * 74 = 7918 */ + int def_roll_ranged = osrs_player_def_roll_vs_npc(99, 99, stats.def_ranged, 2); + ASSERT_INT_EQ("def roll vs ranged", def_roll_ranged, 7918); + + /* vs incoming magic: uses (floor(magic*0.7 + def*0.3) + 8) * (def_magic + 64) + = (floor(69.3 + 29.7) + 8) * (9+64) = (99+8) * 73 = 107 * 73 = 7811 */ + int def_roll_magic = osrs_player_def_roll_vs_npc(99, 99, stats.def_magic, 3); + ASSERT_INT_EQ("def roll vs magic", def_roll_magic, 7811); +} + + +static void test_shared_prepare_attack_virtus_ancient_bonus(void) { + printf("--- shared attack prep: virtus ancient bonus ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; + loadout[GEAR_SLOT_BODY] = ITEM_VIRTUS_ROBE_TOP; + loadout[GEAR_SLOT_LEGS] = ITEM_VIRTUS_ROBE_BOTTOM; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_MAGIC, OFFENSIVE_PRAYER_NONE, + 99, FIGHT_STYLE_AUTOCAST, 30, &stats + ); + + OsrsEquipmentEffectProfile profile; + OsrsItemEffectState state; + osrs_derive_equipment_effect_profile(loadout, &profile); + osrs_item_effect_state_init(&state); + + OsrsPreparedAttackEffects ancient = osrs_prepare_attack_effects( + &profile, &state, ITEM_KODAI_WAND, ATTACK_STYLE_MAGIC, + OSRS_MAGIC_ATTACK_ANCIENT_ICE, osrs_target_ref_none(), 1, + stats.eff_level * (stats.attack_bonus + 64), stats.max_hit, + 0, 0, 99, 99 + ); + OsrsPreparedAttackEffects non_ancient = osrs_prepare_attack_effects( + &profile, &state, ITEM_KODAI_WAND, ATTACK_STYLE_MAGIC, + OSRS_MAGIC_ATTACK_STANDARD_SPELL, osrs_target_ref_none(), 1, + stats.eff_level * (stats.attack_bonus + 64), stats.max_hit, + 0, 0, 99, 99 + ); + + ASSERT_INT_EQ("virtus ancient adds 6%", ancient.max_hit, stats.max_hit * 106 / 100); + ASSERT_INT_EQ("virtus non-ancient unchanged", non_ancient.max_hit, stats.max_hit); +} + + +static void test_shared_prepare_attack_tbow_scaling(void) { + printf("--- shared attack prep: tbow scaling ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_TWISTED_BOW; + + EncounterLoadoutStats stats; + encounter_compute_loadout_stats( + loadout, ATTACK_STYLE_RANGED, OFFENSIVE_PRAYER_RIGOUR, + 99, FIGHT_STYLE_RAPID, 0, &stats + ); + + OsrsEquipmentEffectProfile profile; + OsrsItemEffectState state; + osrs_derive_equipment_effect_profile(loadout, &profile); + osrs_item_effect_state_init(&state); + + int base_attack_roll = stats.eff_level * (stats.attack_bonus + 64); + OsrsPreparedAttackEffects prepared = osrs_prepare_attack_effects( + &profile, &state, ITEM_TWISTED_BOW, ATTACK_STYLE_RANGED, + OSRS_MAGIC_ATTACK_NONE, osrs_target_ref_none(), 1, + base_attack_roll, stats.max_hit, + 150, 550, 99, 99 + ); + + int target_magic = max_int(150, 550); + ASSERT_INT_EQ( + "tbow attack roll scaled", + prepared.attack_roll, + (int)(base_attack_roll * osrs_tbow_acc_mult(target_magic)) + ); + ASSERT_INT_EQ( + "tbow max hit scaled", + prepared.max_hit, + (int)(stats.max_hit * osrs_tbow_dmg_mult(target_magic)) + ); +} + + +int main(void) { + printf("=== item effects tests (cross-referenced with osrs-dps-calc) ===\n\n"); + + /* tbow scaling edge cases */ + test_tbow_acc_edge_cases(); + test_tbow_dmg_edge_cases(); + test_tbow_cap_behavior(); + + /* prayer protection */ + test_prayer_pvp_reduction(); + test_prayer_pve_block(); + test_prayer_wrong_no_reduction(); + + /* NPC defensive rolls */ + test_npc_def_roll_vs_player(); + + /* player attack rolls with gear */ + test_player_att_roll_full_mage(); + test_player_att_roll_melee_with_defender(); + test_player_att_roll_ranged_blowpipe(); + + /* loadout edge cases */ + test_loadout_empty_all_styles(); + test_loadout_all_slots_filled(); + test_loadout_two_handed_weapon(); + test_two_handed_classification(); + + /* end-to-end hit chance */ + test_hit_chance_player_vs_npc(); + test_def_bonus_selection(); + test_loadout_defence_into_def_roll(); + test_shared_prepare_attack_virtus_ancient_bonus(); + test_shared_prepare_attack_tbow_scaling(); + + printf("\n=== results: %d/%d passed", tests_passed, tests_run); + if (tests_failed > 0) { + printf(", %d FAILED", tests_failed); + } + printf(" ===\n"); + + return tests_failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_item_effects_core.c b/ocean/osrs/tests/test_item_effects_core.c new file mode 100644 index 0000000000..800c72af6e --- /dev/null +++ b/ocean/osrs/tests/test_item_effects_core.c @@ -0,0 +1,251 @@ +/** + * @file test_item_effects_core.c + * @brief tests for shared passive item effects in osrs_item_effects.h + * + * BUILD: + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_item_effects_core \ + * ocean/osrs/tests/test_item_effects_core.c -lm + * ./test_item_effects_core + */ + +#include +#include +#include + +#include "ocean/osrs/osrs_item_effects.h" + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + int _actual = (actual); \ + int _expected = (expected); \ + if (_actual == _expected) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %d, expected %d\n", (label), _actual, _expected); \ + } \ +} while (0) + +static void clear_loadout(uint8_t loadout[NUM_GEAR_SLOTS]) { + memset(loadout, ITEM_NONE, NUM_GEAR_SLOTS); +} + +static void test_generated_effect_tags(void) { + printf("--- generated effect tags ---\n"); + ASSERT_INT_EQ( + "tbow tagged", + (ITEM_DATABASE[ITEM_TWISTED_BOW].effect_mask & OSRS_ITEM_EFFECT_TWISTED_BOW) != 0, + 1 + ); + ASSERT_INT_EQ( + "confliction tagged", + (ITEM_DATABASE[ITEM_CONFLICTION_GAUNTLETS].effect_mask & OSRS_ITEM_EFFECT_CONFLICTION) != 0, + 1 + ); + ASSERT_INT_EQ( + "virtus tagged", + (ITEM_DATABASE[ITEM_VIRTUS_ROBE_TOP].effect_mask & OSRS_ITEM_EFFECT_VIRTUS_PIECE) != 0, + 1 + ); + ASSERT_INT_EQ( + "sang tagged", + (ITEM_DATABASE[ITEM_SANGUINESTI_STAFF].effect_mask & OSRS_ITEM_EFFECT_SANG_HEAL) != 0, + 1 + ); + ASSERT_INT_EQ( + "elysian tagged", + (ITEM_DATABASE[ITEM_ELYSIAN_SPIRIT_SHIELD].effect_mask & OSRS_ITEM_EFFECT_ELYSIAN) != 0, + 1 + ); +} + +static void test_profile_derivation(void) { + printf("--- profile derivation ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_EYE_OF_AYAK; + loadout[GEAR_SLOT_SHIELD] = ITEM_ELYSIAN_SPIRIT_SHIELD; + loadout[GEAR_SLOT_BODY] = ITEM_VIRTUS_ROBE_TOP; + loadout[GEAR_SLOT_LEGS] = ITEM_VIRTUS_ROBE_BOTTOM; + loadout[GEAR_SLOT_HANDS] = ITEM_CONFLICTION_GAUNTLETS; + loadout[GEAR_SLOT_RING] = ITEM_VENATOR_RING; + + OsrsEquipmentEffectProfile profile; + osrs_derive_equipment_effect_profile(loadout, &profile); + + ASSERT_INT_EQ("virtus count", profile.virtus_piece_count, 2); + ASSERT_INT_EQ("confliction effect", osrs_effect_profile_has(&profile, OSRS_ITEM_EFFECT_CONFLICTION), 1); + ASSERT_INT_EQ("elysian effect", osrs_effect_profile_has(&profile, OSRS_ITEM_EFFECT_ELYSIAN), 1); + ASSERT_INT_EQ("recoil source none", profile.recoil_source, OSRS_RECOIL_SOURCE_NONE); + ASSERT_INT_EQ("spec regen normal", profile.spec_regen_mode, OSRS_SPEC_REGEN_MODE_NORMAL); +} + +static void test_confliction_state_machine(void) { + printf("--- confliction state machine ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_WEAPON] = ITEM_EYE_OF_AYAK; + loadout[GEAR_SLOT_HANDS] = ITEM_CONFLICTION_GAUNTLETS; + + OsrsEquipmentEffectProfile profile; + OsrsItemEffectState state; + osrs_derive_equipment_effect_profile(loadout, &profile); + osrs_item_effect_state_init(&state); + + OsrsTargetRef target_a = { .kind = OSRS_TARGET_NPC, .id = 7 }; + OsrsTargetRef target_b = { .kind = OSRS_TARGET_NPC, .id = 8 }; + + OsrsPreparedAttackEffects prepared = osrs_prepare_attack_effects( + &profile, &state, ITEM_EYE_OF_AYAK, ATTACK_STYLE_MAGIC, + OSRS_MAGIC_ATTACK_POWERED_STAFF, target_a, 1, + 12000, 30, 0, 0, 99, 99 + ); + ASSERT_INT_EQ("unprimed attack single-roll", prepared.use_double_accuracy, 0); + + osrs_finalize_attack_effects( + &profile, &state, ITEM_EYE_OF_AYAK, ATTACK_STYLE_MAGIC, + OSRS_MAGIC_ATTACK_POWERED_STAFF, target_a, 1, + 0, 0, 0, &(uint32_t){123} + ); + ASSERT_INT_EQ("miss primes confliction", state.confliction_is_primed, 1); + + prepared = osrs_prepare_attack_effects( + &profile, &state, ITEM_EYE_OF_AYAK, ATTACK_STYLE_MAGIC, + OSRS_MAGIC_ATTACK_POWERED_STAFF, target_a, 1, + 12000, 30, 0, 0, 99, 99 + ); + ASSERT_INT_EQ("same target double-roll", prepared.use_double_accuracy, 1); + + prepared = osrs_prepare_attack_effects( + &profile, &state, ITEM_EYE_OF_AYAK, ATTACK_STYLE_MAGIC, + OSRS_MAGIC_ATTACK_POWERED_STAFF, target_b, 1, + 12000, 30, 0, 0, 99, 99 + ); + ASSERT_INT_EQ("different target no double-roll", prepared.use_double_accuracy, 0); + + prepared = osrs_prepare_attack_effects( + &profile, &state, ITEM_EYE_OF_AYAK, ATTACK_STYLE_MAGIC, + OSRS_MAGIC_ATTACK_POWERED_STAFF, target_a, 0, + 12000, 30, 0, 0, 99, 99 + ); + ASSERT_INT_EQ("aoe secondary no double-roll", prepared.use_double_accuracy, 0); + + osrs_finalize_attack_effects( + &profile, &state, ITEM_EYE_OF_AYAK, ATTACK_STYLE_MAGIC, + OSRS_MAGIC_ATTACK_POWERED_STAFF, target_a, 1, + 1, 1, 20, &(uint32_t){456} + ); + ASSERT_INT_EQ("double-roll attack clears prime", state.confliction_is_primed, 0); + + osrs_finalize_attack_effects( + &profile, &state, ITEM_EYE_OF_AYAK, ATTACK_STYLE_MAGIC, + OSRS_MAGIC_ATTACK_POWERED_STAFF, target_a, 0, + 0, 0, 0, &(uint32_t){789} + ); + ASSERT_INT_EQ("aoe secondary miss does not prime", state.confliction_is_primed, 0); +} + +static void test_elysian_damage_reduction(void) { + printf("--- elysian damage reduction ---\n"); + + uint8_t loadout[NUM_GEAR_SLOTS]; + clear_loadout(loadout); + loadout[GEAR_SLOT_SHIELD] = ITEM_ELYSIAN_SPIRIT_SHIELD; + + OsrsEquipmentEffectProfile profile; + OsrsItemEffectState state; + osrs_derive_equipment_effect_profile(loadout, &profile); + osrs_item_effect_state_init(&state); + + uint32_t proc_seed = 4; + DamageResult proc = osrs_apply_passive_damage_pipeline( + 20, ATTACK_STYLE_MELEE, PRAYER_NONE, 0, 0, 0, &profile, &state, &proc_seed + ); + ASSERT_INT_EQ("elysian proc flag", proc.elysian_reduced, 1); + ASSERT_INT_EQ("elysian proc damage", proc.final_damage, 15); + + uint32_t miss_seed = 1; + DamageResult miss = osrs_apply_passive_damage_pipeline( + 20, ATTACK_STYLE_MELEE, PRAYER_NONE, 0, 0, 0, &profile, &state, &miss_seed + ); + ASSERT_INT_EQ("elysian miss flag", miss.elysian_reduced, 0); + ASSERT_INT_EQ("elysian miss damage", miss.final_damage, 20); +} + +static void test_lightbearer_regen_timing(void) { + printf("--- lightbearer regen timing ---\n"); + + Player player; + memset(&player, 0, sizeof(player)); + memset(player.equipped, ITEM_NONE, sizeof(player.equipped)); + osrs_item_effect_state_init(&player.item_effect_state); + player.special_energy = 90; + + osrs_refresh_player_equipment(&player); + for (int tick = 0; tick < 49; tick++) { + osrs_tick_special_regen(&player); + } + ASSERT_INT_EQ("normal regen before 50 ticks", player.special_energy, 90); + osrs_tick_special_regen(&player); + ASSERT_INT_EQ("normal regen at 50 ticks", player.special_energy, 100); + + player.special_energy = 80; + player.item_effect_state.special_regen_ticks = 30; + player.equipped[GEAR_SLOT_RING] = ITEM_LIGHTBEARER; + osrs_refresh_player_equipment(&player); + ASSERT_INT_EQ("lightbearer switch resets overlong timer", player.item_effect_state.special_regen_ticks, 0); + + for (int tick = 0; tick < 24; tick++) { + osrs_tick_special_regen(&player); + } + ASSERT_INT_EQ("lightbearer before 25 ticks", player.special_energy, 80); + osrs_tick_special_regen(&player); + ASSERT_INT_EQ("lightbearer at 25 ticks", player.special_energy, 90); +} + +static void test_recoil_ring_shatters(void) { + printf("--- recoil ring shatters ---\n"); + + Player player; + memset(&player, 0, sizeof(player)); + memset(player.equipped, ITEM_NONE, sizeof(player.equipped)); + osrs_item_effect_state_init(&player.item_effect_state); + player.equipped[GEAR_SLOT_RING] = ITEM_RING_OF_RECOIL; + osrs_refresh_player_equipment(&player); + + ASSERT_INT_EQ("recoil charges init", player.item_effect_state.recoil_charges, RECOIL_MAX_CHARGES); + osrs_consume_recoil_charges(&player, RECOIL_MAX_CHARGES); + ASSERT_INT_EQ("recoil charges empty", player.item_effect_state.recoil_charges, 0); + ASSERT_INT_EQ("recoil ring removed", player.equipped[GEAR_SLOT_RING], ITEM_NONE); +} + +int main(void) { + printf("=== osrs passive item effects tests ===\n"); + + test_generated_effect_tags(); + test_profile_derivation(); + test_confliction_state_machine(); + test_elysian_damage_reduction(); + test_lightbearer_regen_timing(); + test_recoil_ring_shatters(); + + printf("\n=== results ===\n"); + printf(" run: %d\n", tests_run); + printf(" passed: %d\n", tests_passed); + printf(" failed: %d\n", tests_failed); + + if (tests_failed > 0) { + printf("\nFAIL\n"); + return 1; + } + + printf("\nPASS\n"); + return 0; +} diff --git a/ocean/osrs/tests/test_npc_movement.c b/ocean/osrs/tests/test_npc_movement.c new file mode 100644 index 0000000000..87345ef33c --- /dev/null +++ b/ocean/osrs/tests/test_npc_movement.c @@ -0,0 +1,173 @@ +/** + * @file test_npc_movement.c + * @brief Tests for encounter_npc_step_toward (shared OSRS NPC chase step). + * + * Regression coverage for: ranged NPCs without LOS must keep walking, while + * melee NPCs using stop_at_melee_distance must not step onto their target. + * + * Compile: cc -std=c11 -O0 -g -I. -Iocean/osrs -o test_npc_movement + * ocean/osrs/tests/test_npc_movement.c -lm + */ + +#include +#include +#include +#include "osrs_encounter.h" +#include "osrs_collision.h" + +static int tests_run = 0; +static int tests_passed = 0; + +#define ASSERT_EQ(label, actual, expected) do { \ + tests_run++; \ + if ((actual) == (expected)) { tests_passed++; } \ + else { printf(" FAIL %s: expected %d, got %d\n", label, (int)(expected), (int)(actual)); } \ +} while (0) + +#define ASSERT(label, cond) do { \ + tests_run++; \ + if (cond) { tests_passed++; } \ + else { printf(" FAIL %s\n", label); } \ +} while (0) + +/* open-tile callback: nothing blocks */ +static int blocked_never(void* ctx, int x, int y, int size) { + (void)ctx; (void)x; (void)y; (void)size; + return 0; +} + +/* context: a single blocked rect (simulates a pillar) */ +typedef struct { int bx, by, bsize; } BlockRect; +static int blocked_rect(void* ctx, int x, int y, int size) { + BlockRect* r = (BlockRect*)ctx; + (void)size; + return (x >= r->bx && x < r->bx + r->bsize && + y >= r->by && y < r->by + r->bsize); +} + +/* --- regression: in-range NPC still moves (no range-stop in helper) --- */ +static void test_in_range_still_steps(void) { + printf("--- in-range NPC still steps (no helper-level range stop) ---\n"); + int x = 0, y = 0; + /* target at (5, 0), attack_range=10 (ranger scenario), no blockers. + pre-fix: dist=5 <= 10, early-return, no move. + post-fix: step toward, moves to (1, 0). */ + int moved = encounter_npc_step_toward(&x, &y, 5, 0, 1, 1, 0, blocked_never, NULL); + ASSERT_EQ("helper returns moved=1", moved, 1); + ASSERT_EQ("x advanced by 1", x, 1); + ASSERT_EQ("y unchanged", y, 0); +} + +/* --- pillar between NPC and target: NPC gets stuck (matches OSRS) --- */ +static void test_pillar_stuck(void) { + printf("--- pillar directly in path: NPC stuck (matches OSRS greedy) ---\n"); + /* NPC at (0,0), target at (5,0), pillar covers tile (1,0). + dx=1, dy=0 → helper only tries cardinal X, which is blocked → no move. + greedy doesn't try y-detour because dy=0. matches OSRS: mobs get + stuck on obstacles in their direct path and stand there. */ + BlockRect pillar = { 1, 0, 1 }; + int x = 0, y = 0; + int moved = encounter_npc_step_toward(&x, &y, 5, 0, 1, 1, 0, blocked_rect, &pillar); + ASSERT_EQ("moved=0 (stuck on pillar)", moved, 0); + ASSERT_EQ("x unchanged", x, 0); + ASSERT_EQ("y unchanged", y, 0); +} + +/* --- pillar to the side, diagonal path clear: NPC moves diagonal --- */ +static void test_pillar_diagonal_path(void) { + printf("--- pillar partially blocking: diagonal path still works ---\n"); + /* NPC at (0,0), target at (5,5) (both dx and dy nonzero), pillar at (1,0). + greedy tries diagonal (1,1) first — clear → move there. */ + BlockRect pillar = { 1, 0, 1 }; + int x = 0, y = 0; + int moved = encounter_npc_step_toward(&x, &y, 5, 5, 1, 1, 0, blocked_rect, &pillar); + ASSERT_EQ("moved=1 (took diagonal)", moved, 1); + ASSERT_EQ("x=1", x, 1); + ASSERT_EQ("y=1", y, 1); +} + +/* --- melee adjacent: step helper tries, fails naturally (player tile blocked) --- */ +static void test_melee_cardinal_contact_stops_without_target_tile_block(void) { + printf("--- melee cardinal contact stops without target tile block ---\n"); + int x = 4, y = 5; + int moved = encounter_npc_step_toward(&x, &y, 5, 5, 1, 1, 1, blocked_never, NULL); + ASSERT_EQ("melee contact does not move", moved, 0); + ASSERT_EQ("x unchanged", x, 4); + ASSERT_EQ("y unchanged", y, 5); +} + +static void test_melee_diagonal_contact_tries_x_only(void) { + printf("--- melee diagonal contact tries x-only ---\n"); + int x = 4, y = 4; + int moved = encounter_npc_step_toward(&x, &y, 5, 5, 1, 1, 1, blocked_never, NULL); + ASSERT_EQ("diagonal contact moves", moved, 1); + ASSERT_EQ("x advanced", x, 5); + ASSERT_EQ("y stayed", y, 4); +} + +static void test_melee_diagonal_contact_does_not_fall_back_to_y(void) { + printf("--- melee diagonal contact does not fall back to y-only ---\n"); + BlockRect x_block = { 5, 4, 1 }; + int x = 4, y = 4; + int moved = encounter_npc_step_toward(&x, &y, 5, 5, 1, 1, 1, blocked_rect, &x_block); + ASSERT_EQ("x-only blocked means no move", moved, 0); + ASSERT_EQ("x unchanged", x, 4); + ASSERT_EQ("y unchanged", y, 4); +} + +/* --- far NPC walks normally toward target --- */ +static void test_far_npc_walks(void) { + printf("--- far NPC walks greedy toward target ---\n"); + int x = 0, y = 0; + int moved = encounter_npc_step_toward(&x, &y, 10, 7, 1, 1, 0, blocked_never, NULL); + ASSERT_EQ("moved", moved, 1); + ASSERT_EQ("diagonal step x+1", x, 1); + ASSERT_EQ("diagonal step y+1", y, 1); +} +/* entity_has_line_of_sight: regression for current-target LOS. inferno */ +/* movement needs size-aware mob->mob LOS (e.g. mager -> Zuk shield), and */ +/* melee helpers must stay cardinal-only. */ +static void test_entity_los_to_multi_tile_target_in_range_clear(void) { + printf("--- LOS to multi-tile target: in range, clear ray ---\n"); + int has = entity_has_line_of_sight(NULL, 0, 20, 36, 4, 23, 44, 5, 15); + ASSERT_EQ("has_los to shield = 1", has, 1); +} + +static void test_entity_los_to_multi_tile_target_blocked_by_pillar(void) { + printf("--- LOS to multi-tile target: in range, pillar blocks ---\n"); + LOSBlocker pillar = { 15, 40, 3, LOS_FULL_MASK }; + int has = entity_has_line_of_sight(&pillar, 1, 10, 40, 4, 23, 40, 5, 15); + ASSERT_EQ("has_los through pillar = 0", has, 0); +} + +static void test_entity_los_to_multi_tile_target_out_of_range(void) { + printf("--- LOS to multi-tile target: out of range ---\n"); + int has = entity_has_line_of_sight(NULL, 0, 0, 0, 4, 25, 25, 5, 15); + ASSERT_EQ("out of range = 0", has, 0); +} + +static void test_entity_melee_cardinal_only(void) { + printf("--- melee LOS is cardinal only ---\n"); + ASSERT_EQ("cardinal contact allowed", + entity_has_line_of_sight(NULL, 0, 6, 5, 1, 5, 5, 1, 1), 1); + ASSERT_EQ("diagonal contact blocked", + entity_has_line_of_sight(NULL, 0, 6, 6, 1, 5, 5, 1, 1), 0); +} + +int main(void) { + test_in_range_still_steps(); + test_pillar_stuck(); + test_pillar_diagonal_path(); + test_melee_cardinal_contact_stops_without_target_tile_block(); + test_melee_diagonal_contact_tries_x_only(); + test_melee_diagonal_contact_does_not_fall_back_to_y(); + test_far_npc_walks(); + + test_entity_los_to_multi_tile_target_in_range_clear(); + test_entity_los_to_multi_tile_target_blocked_by_pillar(); + test_entity_los_to_multi_tile_target_out_of_range(); + test_entity_melee_cardinal_only(); + + printf("\n=== results: %d/%d passed ===\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +} diff --git a/ocean/osrs/tests/test_osrs_visual_asset_exports.c b/ocean/osrs/tests/test_osrs_visual_asset_exports.c new file mode 100644 index 0000000000..7a63cfcffe --- /dev/null +++ b/ocean/osrs/tests/test_osrs_visual_asset_exports.c @@ -0,0 +1,61 @@ +/** + * @fileoverview Regression checks for OSRS visual animation export coverage. + * + * BUILD: + * cc -std=c11 -O0 -g -I. -o /tmp/test_osrs_visual_asset_exports \ + * ocean/osrs/tests/test_osrs_visual_asset_exports.c -lm + * /tmp/test_osrs_visual_asset_exports + */ + +#include + +#include "ocean/osrs/encounters/encounter_inferno.h" +#include "ocean/osrs/osrs_anim.h" + +static int tests_run = 0; +static int tests_failed = 0; + +static int has_anim(AnimCache* first, AnimCache* second, int seq_id) { + return anim_get_sequence(first, (uint16_t)seq_id) || + anim_get_sequence(second, (uint16_t)seq_id); +} + +#define ASSERT_ANIM_PRESENT(label, first, second, seq_id) do { \ + tests_run++; \ + if (!has_anim((first), (second), (seq_id))) { \ + tests_failed++; \ + printf(" FAIL: %s missing seq %d\n", (label), (seq_id)); \ + } \ +} while (0) + +int main(void) { + AnimCache* equipment = anim_cache_load("data/equipment.anims"); + AnimCache* inferno = anim_cache_load("data/inferno.anims"); + AnimCache* zulrah = anim_cache_load("data/zulrah.anims"); + if (!equipment || !inferno || !zulrah) return 1; + + printf("--- inferno runtime animation export coverage ---\n"); + ASSERT_ANIM_PRESENT("jad magic attack", equipment, inferno, INF_ANIM_JALTOK_JAD_MAGIC_ATTACK); + ASSERT_ANIM_PRESENT("jad ranged attack", equipment, inferno, INF_ANIM_JALTOK_JAD_RANGED_ATTACK); + ASSERT_ANIM_PRESENT("jad melee attack", equipment, inferno, INF_ANIM_JALTOK_JAD_MELEE_ATTACK); + ASSERT_ANIM_PRESENT("meleer dig down", equipment, inferno, INF_GEN_ANIM_MELEER_DIG_DOWN); + ASSERT_ANIM_PRESENT("meleer dig up", equipment, inferno, INF_GEN_ANIM_MELEER_DIG_UP); + + printf("--- zulrah runtime animation export coverage ---\n"); + ASSERT_ANIM_PRESENT("zulrah attack", equipment, zulrah, ZULRAH_ANIM_ATTACK); + ASSERT_ANIM_PRESENT("zulrah idle", equipment, zulrah, ZULRAH_ANIM_IDLE); + ASSERT_ANIM_PRESENT("zulrah dive", equipment, zulrah, ZULRAH_ANIM_DIVE); + ASSERT_ANIM_PRESENT("zulrah rise", equipment, zulrah, ZULRAH_ANIM_RISE); + ASSERT_ANIM_PRESENT("snakeling idle", equipment, zulrah, SNAKELING_ANIM_IDLE); + ASSERT_ANIM_PRESENT("snakeling melee attack", equipment, zulrah, SNAKELING_ANIM_MELEE); + ASSERT_ANIM_PRESENT("snakeling magic attack", equipment, zulrah, SNAKELING_ANIM_MAGIC); + ASSERT_ANIM_PRESENT("snakeling death", equipment, zulrah, SNAKELING_ANIM_DEATH); + ASSERT_ANIM_PRESENT("snakeling walk", equipment, zulrah, SNAKELING_ANIM_WALK); + + if (tests_failed > 0) { + printf("\n%d/%d animation export checks failed\n", tests_failed, tests_run); + return 1; + } + printf("\n%d/%d animation export checks passed\n", tests_run, tests_run); + return 0; +} diff --git a/ocean/osrs/tests/test_player_combat.c b/ocean/osrs/tests/test_player_combat.c new file mode 100644 index 0000000000..8c0d0db13a --- /dev/null +++ b/ocean/osrs/tests/test_player_combat.c @@ -0,0 +1,230 @@ +/** + * @file test_player_combat.c + * @brief Tests for player-side combat primitives in osrs_combat.h. + * + * Verifies effective level, attack roll, max hit, prayer reduction, + * double accuracy, and equipment bonus summation against osrs-dps-calc + * reference values. + * + * Build: cc -std=c11 -O0 -g -I. -o test_player_combat ocean/osrs/tests/test_player_combat.c -lm + */ + +#include +#include +#include +#include "ocean/osrs/osrs_combat.h" + +static int total_tests = 0; +static int passed_tests = 0; + +#define ASSERT_EQ(a, b, msg) do { \ + total_tests++; \ + if ((a) != (b)) { \ + printf("FAIL: %s: got %d, expected %d\n", msg, (int)(a), (int)(b)); \ + } else { passed_tests++; } \ +} while(0) + +#define ASSERT_FLOAT_NEAR(a, b, tol, msg) do { \ + total_tests++; \ + if (fabsf((float)(a) - (float)(b)) > (float)(tol)) { \ + printf("FAIL: %s: got %.6f, expected %.6f\n", msg, (float)(a), (float)(b)); \ + } else { passed_tests++; } \ +} while(0) + +/* --- osrs_player_eff_level --- */ +static void test_eff_level(void) { + printf("--- osrs_player_eff_level ---\n"); + + /* no prayer, no style bonus: floor(99 * 1.0) + 0 + 8 = 107 */ + ASSERT_EQ(osrs_player_eff_level(99, 1.0f, 0), 107, "base 99, no prayer, no style"); + + /* rigour: floor(99 * 1.20) + 0 + 8 = floor(118.8) + 8 = 118 + 8 = 126 */ + ASSERT_EQ(osrs_player_eff_level(99, 1.20f, 0), 126, "base 99, rigour att mult"); + + /* augury: floor(99 * 1.25) + 0 + 8 = floor(123.75) + 8 = 123 + 8 = 131 */ + ASSERT_EQ(osrs_player_eff_level(99, 1.25f, 0), 131, "base 99, augury"); + + /* piety: floor(99 * 1.20) + 0 + 8 = 126 */ + ASSERT_EQ(osrs_player_eff_level(99, 1.20f, 0), 126, "base 99, piety att"); + + /* piety str: floor(99 * 1.23) + 0 + 8 = floor(121.77) + 8 = 121 + 8 = 129 */ + ASSERT_EQ(osrs_player_eff_level(99, 1.23f, 0), 129, "base 99, piety str"); + + /* accurate style (+3): floor(99 * 1.0) + 3 + 8 = 110 */ + ASSERT_EQ(osrs_player_eff_level(99, 1.0f, 3), 110, "base 99, accurate +3"); + + /* rigour + rapid (+0): floor(99 * 1.20) + 0 + 8 = 126 */ + ASSERT_EQ(osrs_player_eff_level(99, 1.20f, 0), 126, "rigour, rapid"); + + /* level 1, no prayer: floor(1 * 1.0) + 0 + 8 = 9 */ + ASSERT_EQ(osrs_player_eff_level(1, 1.0f, 0), 9, "level 1"); + + /* boosted level (99+13 = 112 from imbued heart): floor(112*1.25)+8 = 148 */ + ASSERT_EQ(osrs_player_eff_level(112, 1.25f, 0), 148, "boosted 112, augury"); +} + +/* --- osrs_player_att_roll --- */ +static void test_att_roll(void) { + printf("--- osrs_player_att_roll ---\n"); + + /* eff_level * (bonus + 64) */ + ASSERT_EQ(osrs_player_att_roll(107, 0), 107 * 64, "eff 107, bonus 0"); + ASSERT_EQ(osrs_player_att_roll(126, 100), 126 * 164, "eff 126, bonus 100"); + ASSERT_EQ(osrs_player_att_roll(148, 182), 148 * 246, "eff 148, bonus 182 (BIS mage)"); + ASSERT_EQ(osrs_player_att_roll(9, 0), 9 * 64, "eff 9, bonus 0 (level 1)"); +} + +/* --- osrs_player_melee_max_hit --- */ +static void test_melee_max_hit(void) { + printf("--- osrs_player_melee_max_hit ---\n"); + + /* formula: floor((eff * (str + 64) + 320) / 640) + ref: BaseCalc.ts:107 — floor((effectiveLevel * gearBonus + 320) / 640) + where gearBonus = str_bonus + 64 */ + + /* whip (str 82) + piety str eff 129: floor((129 * (82+64) + 320) / 640) */ + int eff = 129, str = 82; + int expected = (eff * (str + 64) + 320) / 640; + ASSERT_EQ(osrs_player_melee_max_hit(129, 82), expected, "whip, piety"); + + /* level 1, str 0: floor((9*(0+64)+320)/640) = floor(896/640) = 1 */ + ASSERT_EQ(osrs_player_melee_max_hit(9, 0), (9 * 64 + 320) / 640, "level 1, no gear"); +} + +/* --- osrs_player_ranged_max_hit --- */ +static void test_ranged_max_hit(void) { + printf("--- osrs_player_ranged_max_hit ---\n"); + + /* same formula as melee. rigour str eff = floor(99*1.23)+8 = 129 + ranged str 98 (BIS from spec): floor((129*(98+64)+320)/640) = floor((129*162+320)/640) */ + int eff = 129, str = 98; + int expected = (eff * (str + 64) + 320) / 640; + ASSERT_EQ(osrs_player_ranged_max_hit(129, 98), expected, "rigour, BIS ranged str"); +} + +/* --- osrs_player_magic_max_hit --- */ +static void test_magic_max_hit(void) { + printf("--- osrs_player_magic_max_hit ---\n"); + + /* ice barrage base 30, magic dmg bonus 30%: floor(30 * (100 + 30) / 100) = floor(39) = 39 */ + ASSERT_EQ(osrs_player_magic_max_hit(30, 30), 30 * 130 / 100, "barrage, 30% dmg"); + + /* trident base: floor(magic_level / 3) - 6 for seas = floor(99/3)-6 = 27 + with 0% bonus: 27 * 100 / 100 = 27 */ + ASSERT_EQ(osrs_player_magic_max_hit(27, 0), 27, "trident, no bonus"); + + /* sang staff base: floor(magic_level / 3) - 1 = 32 + with 15% bonus (occult + tormented): floor(32 * 115 / 100) = 36 */ + ASSERT_EQ(osrs_player_magic_max_hit(32, 15), 32 * 115 / 100, "sang, 15% bonus"); +} + +/* --- osrs_prayer_reduce_damage --- */ +static void test_prayer_reduce(void) { + printf("--- osrs_prayer_reduce_damage ---\n"); + + /* PvE: correct prayer blocks 100% */ + ASSERT_EQ(osrs_prayer_reduce_damage(50, PRAYER_PROTECT_MAGIC, ATTACK_STYLE_MAGIC, 0), 0, + "PvE magic pray vs magic"); + ASSERT_EQ(osrs_prayer_reduce_damage(50, PRAYER_PROTECT_RANGED, ATTACK_STYLE_RANGED, 0), 0, + "PvE range pray vs range"); + ASSERT_EQ(osrs_prayer_reduce_damage(50, PRAYER_PROTECT_MELEE, ATTACK_STYLE_MELEE, 0), 0, + "PvE melee pray vs melee"); + + /* PvE: wrong prayer passes through */ + ASSERT_EQ(osrs_prayer_reduce_damage(50, PRAYER_PROTECT_MAGIC, ATTACK_STYLE_MELEE, 0), 50, + "PvE magic pray vs melee"); + ASSERT_EQ(osrs_prayer_reduce_damage(50, PRAYER_NONE, ATTACK_STYLE_MAGIC, 0), 50, + "PvE no pray vs magic"); + + /* PvP: correct prayer reduces by 40% (player takes 60%) */ + ASSERT_EQ(osrs_prayer_reduce_damage(50, PRAYER_PROTECT_MAGIC, ATTACK_STYLE_MAGIC, 1), 30, + "PvP magic pray vs magic: 50*0.6=30"); + ASSERT_EQ(osrs_prayer_reduce_damage(41, PRAYER_PROTECT_RANGED, ATTACK_STYLE_RANGED, 1), (int)(41 * 0.6f), + "PvP range pray vs range: 41*0.6"); + + /* PvP: wrong prayer passes through */ + ASSERT_EQ(osrs_prayer_reduce_damage(50, PRAYER_PROTECT_MAGIC, ATTACK_STYLE_MELEE, 1), 50, + "PvP magic pray vs melee"); + + /* zero damage stays zero */ + ASSERT_EQ(osrs_prayer_reduce_damage(0, PRAYER_PROTECT_MAGIC, ATTACK_STYLE_MAGIC, 0), 0, + "zero damage PvE"); + ASSERT_EQ(osrs_prayer_reduce_damage(0, PRAYER_PROTECT_MAGIC, ATTACK_STYLE_MAGIC, 1), 0, + "zero damage PvP"); +} + +/* --- osrs_hit_chance_double --- */ +static void test_hit_chance_double(void) { + printf("--- osrs_hit_chance_double ---\n"); + + /* osmumten's fang / confliction gauntlets formula. + when att >= def: 1 - (def+2)(2*def+3) / (6*(att+1)^2) + when att < def: att*(4*att+5) / (6*(att+1)*(def+1)) + ref: zul_hit_chance_double in encounter_zulrah.h:782-789 */ + + /* large att vs small def: should be near 1.0 */ + float c1 = osrs_hit_chance_double(50000, 1000); + ASSERT_FLOAT_NEAR(c1, 1.0f, 0.01f, "large att vs small def"); + + /* equal rolls: should be higher than single roll at equal */ + float single = osrs_hit_chance(10000, 10000); + float double_r = osrs_hit_chance_double(10000, 10000); + total_tests++; + if (double_r > single) { passed_tests++; } + else { printf("FAIL: double roll should exceed single at equal rolls\n"); } + + /* zero att: should be 0 */ + ASSERT_FLOAT_NEAR(osrs_hit_chance_double(0, 10000), 0.0f, 0.001f, "zero att"); +} + +/* --- osrs_sum_equipment_bonuses --- */ +static void test_equipment_bonuses(void) { + printf("--- osrs_sum_equipment_bonuses ---\n"); + + /* empty loadout: all zeros */ + uint8_t empty[NUM_GEAR_SLOTS]; + for (int i = 0; i < NUM_GEAR_SLOTS; i++) empty[i] = ITEM_NONE; + EquipmentBonuses eb; + osrs_sum_equipment_bonuses(empty, &eb); + ASSERT_EQ(eb.attack_stab, 0, "empty: stab 0"); + ASSERT_EQ(eb.attack_magic, 0, "empty: magic 0"); + ASSERT_EQ(eb.melee_strength, 0, "empty: melee str 0"); + ASSERT_EQ(eb.attack_speed, 0, "empty: speed 0"); + + /* single item: whip in weapon slot */ + uint8_t whip_only[NUM_GEAR_SLOTS]; + for (int i = 0; i < NUM_GEAR_SLOTS; i++) whip_only[i] = ITEM_NONE; + whip_only[GEAR_SLOT_WEAPON] = ITEM_WHIP; + osrs_sum_equipment_bonuses(whip_only, &eb); + const Item* whip = &ITEM_DATABASE[ITEM_WHIP]; + ASSERT_EQ(eb.attack_slash, whip->attack_slash, "whip slash matches DB"); + ASSERT_EQ(eb.melee_strength, whip->melee_strength, "whip str matches DB"); + ASSERT_EQ(eb.attack_speed, whip->attack_speed, "whip speed matches DB"); + ASSERT_EQ(eb.attack_range, whip->attack_range, "whip range matches DB"); + + /* full loadout: ancestral hat + occult + kodai wand — verify they sum */ + uint8_t mage_partial[NUM_GEAR_SLOTS]; + for (int i = 0; i < NUM_GEAR_SLOTS; i++) mage_partial[i] = ITEM_NONE; + mage_partial[GEAR_SLOT_HEAD] = ITEM_ANCESTRAL_HAT; + mage_partial[GEAR_SLOT_NECK] = ITEM_OCCULT_NECKLACE; + mage_partial[GEAR_SLOT_WEAPON] = ITEM_KODAI_WAND; + osrs_sum_equipment_bonuses(mage_partial, &eb); + int expected_magic = ITEM_DATABASE[ITEM_ANCESTRAL_HAT].attack_magic + + ITEM_DATABASE[ITEM_OCCULT_NECKLACE].attack_magic + + ITEM_DATABASE[ITEM_KODAI_WAND].attack_magic; + ASSERT_EQ(eb.attack_magic, expected_magic, "mage partial: magic att sum"); +} + +int main(void) { + test_eff_level(); + test_att_roll(); + test_melee_max_hit(); + test_ranged_max_hit(); + test_magic_max_hit(); + test_prayer_reduce(); + test_hit_chance_double(); + test_equipment_bonuses(); + + printf("\n=== results: %d/%d passed ===\n", passed_tests, total_tests); + return (passed_tests == total_tests) ? 0 : 1; +} diff --git a/ocean/osrs/tests/test_prayer_flicking.c b/ocean/osrs/tests/test_prayer_flicking.c new file mode 100644 index 0000000000..94b56bbbe4 --- /dev/null +++ b/ocean/osrs/tests/test_prayer_flicking.c @@ -0,0 +1,217 @@ +/** + * @file test_prayer_flicking.c + * @brief Tests for toggle-semantic prayer actions + activation-tick drain skip. + * + * Covers the mechanics that make OSRS prayer flicking work: + * - toggle semantics (click-to-on, click-again-to-off, click-different-to-replace) + * - activation-tick drain skip (wiki: no drain on the tick a prayer is activated) + * - pp=0 auto-clear of all prayer slots + * - 1-tick flicking burns 0 pp across many ticks (the whole point of this work) + * - tick ordering: prayer activated in pretick is effective for same-tick attack resolution + */ + +#include +#include +#include +#include "osrs_encounter.h" + +static int tests_run = 0; +static int tests_passed = 0; + +#define ASSERT_EQ(label, actual, expected) do { \ + tests_run++; \ + if ((actual) == (expected)) { tests_passed++; } \ + else { printf(" FAIL %s: expected %d, got %d\n", label, (int)(expected), (int)(actual)); } \ +} while (0) + +#define ASSERT(label, cond) do { \ + tests_run++; \ + if (cond) { tests_passed++; } \ + else { printf(" FAIL %s\n", label); } \ +} while (0) + +/* zero a player to a fresh prayer-ready state. */ +static void reset_player(Player* p, int prayer_bonus) { + memset(p, 0, sizeof(*p)); + p->base_prayer = 99; + p->current_prayer = 99; + p->base_hitpoints = 99; + p->current_hitpoints = 99; + (void)prayer_bonus; /* passed to drain calls separately */ +} + +/* --- overhead toggle: activate, toggle off, replace --- */ +static void test_overhead_toggle(void) { + printf("--- overhead toggle semantics ---\n"); + Player p; reset_player(&p, 0); + + /* inactive + TOGGLE_MELEE → active on melee, just_activated set. */ + int act = encounter_apply_overhead_action(&p.prayer, ENCOUNTER_OVERHEAD_TOGGLE_MELEE); + ASSERT_EQ("activation returns 1", act, 1); + ASSERT_EQ("prayer == melee", p.prayer, PRAYER_PROTECT_MELEE); + + /* active melee + TOGGLE_MELEE → off, no activation. */ + act = encounter_apply_overhead_action(&p.prayer, ENCOUNTER_OVERHEAD_TOGGLE_MELEE); + ASSERT_EQ("same-toggle returns 0", act, 0); + ASSERT_EQ("prayer == none", p.prayer, PRAYER_NONE); + + /* activate ranged, then toggle magic → magic active, replaced ranged, activation=0 (replace). */ + encounter_apply_overhead_action(&p.prayer, ENCOUNTER_OVERHEAD_TOGGLE_RANGED); + act = encounter_apply_overhead_action(&p.prayer, ENCOUNTER_OVERHEAD_TOGGLE_MAGIC); + ASSERT_EQ("replace ranged→magic returns 0 (was already on)", act, 0); + ASSERT_EQ("prayer == magic", p.prayer, PRAYER_PROTECT_MAGIC); + + /* NO_CHANGE is a pure no-op. */ + OverheadPrayer before = p.prayer; + act = encounter_apply_overhead_action(&p.prayer, ENCOUNTER_OVERHEAD_NO_CHANGE); + ASSERT_EQ("no-change returns 0", act, 0); + ASSERT_EQ("prayer unchanged after no-change", p.prayer, before); +} + +/* --- offensive toggle: mirror overhead semantics --- */ +static void test_offensive_toggle(void) { + printf("--- offensive toggle semantics ---\n"); + Player p; reset_player(&p, 0); + + int act = encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_PIETY); + ASSERT_EQ("activate piety returns 1", act, 1); + ASSERT_EQ("offensive == piety", p.offensive_prayer, OFFENSIVE_PRAYER_PIETY); + + act = encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_PIETY); + ASSERT_EQ("toggle off returns 0", act, 0); + ASSERT_EQ("offensive == none", p.offensive_prayer, OFFENSIVE_PRAYER_NONE); + + encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_RIGOUR); + act = encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_AUGURY); + ASSERT_EQ("replace rigour→augury returns 0", act, 0); + ASSERT_EQ("offensive == augury", p.offensive_prayer, OFFENSIVE_PRAYER_AUGURY); +} + +/* --- drain: activation tick does not charge --- */ +static void test_activation_tick_skip(void) { + printf("--- activation-tick drain skip ---\n"); + Player p; reset_player(&p, 0); + + /* simulate a pretick where agent activates piety. */ + int act = encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_PIETY); + if (act) p.offensive_prayer_just_activated = 1; + + int pp_before = p.current_prayer; + encounter_drain_all_prayers(&p, 0); + ASSERT_EQ("no drain on activation tick", p.current_prayer, pp_before); + ASSERT_EQ("just_activated cleared after drain", p.offensive_prayer_just_activated, 0); + + /* next tick: no activation, piety still on → drain should accumulate. */ + for (int i = 0; i < 3; i++) encounter_drain_all_prayers(&p, 0); + /* piety drain effect = 24, resistance = 60 (bonus=0) → +24*3=72 counter, crosses 60 once → -1 pp. */ + ASSERT_EQ("after 3 non-activation ticks: pp drops by 1", p.current_prayer, pp_before - 1); +} + +/* --- 1-tick flick: toggle on + off + on within drain cycle saves everything --- */ +static void test_one_tick_flick_saves_pp(void) { + printf("--- 1-tick flicking burns 0 pp over many ticks ---\n"); + Player p; reset_player(&p, 0); + + /* The key mechanic: every tick, the agent fires TOGGLE_PIETY twice (via env-internal + two-click chain — we simulate by calling apply twice). If piety was active at tick + start, that means: first click turns off, second click turns on. The second + activation sets just_activated; drain skips piety this tick. Prayer stays active + without ever draining. */ + encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_PIETY); + p.offensive_prayer_just_activated = 1; + encounter_drain_all_prayers(&p, 0); + + int pp_before = p.current_prayer; + for (int tick = 0; tick < 100; tick++) { + /* flick: deactivate then reactivate — net state is "active, just_activated". */ + encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_PIETY); /* off */ + int r = encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_PIETY); /* on */ + if (r) p.offensive_prayer_just_activated = 1; + encounter_drain_all_prayers(&p, 0); + } + ASSERT_EQ("100 flicks burn 0 pp", p.current_prayer, pp_before); + ASSERT_EQ("prayer still active after flicks", p.offensive_prayer, OFFENSIVE_PRAYER_PIETY); +} + +/* --- pp=0 auto-clears both slots --- */ +static void test_pp_zero_clears_all(void) { + printf("--- pp=0 auto-clears overhead + offensive ---\n"); + Player p; reset_player(&p, 0); + p.current_prayer = 1; /* about to run out */ + + encounter_apply_overhead_action(&p.prayer, ENCOUNTER_OVERHEAD_TOGGLE_MELEE); + encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_PIETY); + /* both just-activated → no drain this tick */ + p.prayer_just_activated = 1; + p.offensive_prayer_just_activated = 1; + encounter_drain_all_prayers(&p, 0); + ASSERT_EQ("pp unchanged on activation tick", p.current_prayer, 1); + + /* next tick: both prayers active, drain charges — overhead(12) + piety(24) = 36/tick. + resistance = 60, so takes 2 ticks to drain 1 pp. pp=1 → after enough ticks, pp=0. */ + for (int i = 0; i < 10; i++) encounter_drain_all_prayers(&p, 0); + ASSERT_EQ("pp floors at 0", p.current_prayer, 0); + ASSERT_EQ("overhead cleared at pp=0", p.prayer, PRAYER_NONE); + ASSERT_EQ("offensive cleared at pp=0", p.offensive_prayer, OFFENSIVE_PRAYER_NONE); +} + +/* --- lazy flick: activate-tick-only (no deactivate-then-activate chain) lets drain accumulate --- */ +static void test_lazy_flick_partial_cost(void) { + printf("--- lazy flicking (one click per tick) still costs pp ---\n"); + Player p; reset_player(&p, 0); + + /* activate piety, drain (skipped), 4 idle ticks (drain), deactivate, repeat. */ + int pp_start = p.current_prayer; + for (int cycle = 0; cycle < 5; cycle++) { + encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_PIETY); /* on */ + p.offensive_prayer_just_activated = 1; + encounter_drain_all_prayers(&p, 0); /* tick 0: skipped */ + /* tick 1-4: piety active, no activation → drains */ + for (int i = 0; i < 4; i++) encounter_drain_all_prayers(&p, 0); + encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_PIETY); /* off */ + } + /* 5 cycles × 4 draining ticks × 24 drain = 480 counter units. / 60 resistance = 8 pp drained. */ + ASSERT("lazy flick drains some pp (not 0)", p.current_prayer < pp_start); + ASSERT("lazy flick doesn't drain everything", p.current_prayer > 0); +} + +/* --- regression: activating a prayer at pp=0 must not leave enum set --- + observed in eval playback: agent's overhead enum stuck on after pp hit 0. + root cause: pretick apply_*_action() doesn't gate on pp; drain then returned + early on pp<=0 without clearing the enum. */ +static void test_activate_at_zero_pp_clears(void) { + printf("--- activation at pp=0 does not persist in enum ---\n"); + Player p; reset_player(&p, 0); + p.current_prayer = 0; + + encounter_apply_overhead_action(&p.prayer, ENCOUNTER_OVERHEAD_TOGGLE_MELEE); + encounter_apply_offensive_action(&p.offensive_prayer, ENCOUNTER_OFFENSIVE_TOGGLE_PIETY); + p.prayer_just_activated = 1; + p.offensive_prayer_just_activated = 1; + + encounter_drain_all_prayers(&p, 0); + ASSERT_EQ("overhead cleared after activation at pp=0", p.prayer, PRAYER_NONE); + ASSERT_EQ("offensive cleared after activation at pp=0", p.offensive_prayer, OFFENSIVE_PRAYER_NONE); + ASSERT_EQ("pp stays 0", p.current_prayer, 0); + + /* second tick: stale enum from before drain=0 path should also be cleared + if somehow pp enters at 0 with enum already set. */ + p.prayer = PRAYER_PROTECT_MAGIC; + p.offensive_prayer = OFFENSIVE_PRAYER_AUGURY; + encounter_drain_all_prayers(&p, 0); + ASSERT_EQ("overhead cleared from stale state", p.prayer, PRAYER_NONE); + ASSERT_EQ("offensive cleared from stale state", p.offensive_prayer, OFFENSIVE_PRAYER_NONE); +} + +int main(void) { + test_overhead_toggle(); + test_offensive_toggle(); + test_activation_tick_skip(); + test_one_tick_flick_saves_pp(); + test_pp_zero_clears_all(); + test_lazy_flick_partial_cost(); + test_activate_at_zero_pp_clears(); + + printf("\n=== results: %d/%d passed ===\n", tests_passed, tests_run); + return (tests_passed == tests_run) ? 0 : 1; +} diff --git a/ocean/osrs/tests/test_special_attacks.c b/ocean/osrs/tests/test_special_attacks.c new file mode 100644 index 0000000000..3cd0f1c514 --- /dev/null +++ b/ocean/osrs/tests/test_special_attacks.c @@ -0,0 +1,1079 @@ +/** + * @file test_special_attacks.c + * @brief special attack tests cross-referenced against osrs-dps-calc reference. + * + * tests spec weapon costs, accuracy multipliers, strength multipliers, and + * special mechanics (claws cascade, DWH/BGS defence drain, dark bow double hit + * with min/max clamping, morrigan's bleed, voidwaker magic hit, VLS reduced + * defence roll, volatile staff, godsword variants, double-hit specs). + * + * BUILD: + * cd pufferlib-metal + * cc -std=c11 -O0 -g -I. -o test_special_attacks \ + * ocean/osrs/tests/test_special_attacks.c -lm + * ./test_special_attacks + * + * REFERENCE FILES: + * .refs/osrs-dps-calc/src/lib/PlayerVsNPCCalc.ts -- spec accuracy/damage mults + * .refs/osrs-dps-calc/src/lib/dists/claws.ts -- dragon claws cascade dist + * ocean/osrs/osrs_pvp_combat.h -- our spec implementations + * ocean/osrs/osrs_combat.h -- blowpipe spec + */ + +#include +#include +#include +#include + +#include "ocean/osrs/osrs_pvp_combat.h" +#include "ocean/osrs/osrs_combat.h" +#include "ocean/osrs/osrs_special_attacks.h" + + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + if ((actual) == (expected)) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %d, expected %d\n", (label), (actual), (expected)); \ + } \ +} while (0) + +#define ASSERT_FLOAT_EQ(label, actual, expected, tolerance) do { \ + tests_run++; \ + float _a = (actual), _e = (expected), _t = (tolerance); \ + if (fabsf(_a - _e) <= _t) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s — got %.6f, expected %.6f (tol %.6f)\n", \ + (label), _a, _e, _t); \ + } \ +} while (0) +/* test: melee spec energy costs */ +/* */ +/* ref: OSRS wiki special attack page, osrs_pvp_combat.h:38-53 */ +static void test_melee_spec_costs(void) { + printf("--- melee spec energy costs ---\n"); + + ASSERT_INT_EQ("AGS cost", get_melee_spec_cost(MELEE_SPEC_AGS), 50); + ASSERT_INT_EQ("dragon claws cost", get_melee_spec_cost(MELEE_SPEC_DRAGON_CLAWS), 50); + ASSERT_INT_EQ("granite maul cost", get_melee_spec_cost(MELEE_SPEC_GRANITE_MAUL), 50); + ASSERT_INT_EQ("dragon dagger cost", get_melee_spec_cost(MELEE_SPEC_DRAGON_DAGGER), 25); + ASSERT_INT_EQ("voidwaker cost", get_melee_spec_cost(MELEE_SPEC_VOIDWAKER), 50); + ASSERT_INT_EQ("DWH cost", get_melee_spec_cost(MELEE_SPEC_DWH), 35); + ASSERT_INT_EQ("BGS cost", get_melee_spec_cost(MELEE_SPEC_BGS), 50); + ASSERT_INT_EQ("ZGS cost", get_melee_spec_cost(MELEE_SPEC_ZGS), 50); + ASSERT_INT_EQ("SGS cost", get_melee_spec_cost(MELEE_SPEC_SGS), 50); + ASSERT_INT_EQ("ancient GS cost", get_melee_spec_cost(MELEE_SPEC_ANCIENT_GS), 50); + ASSERT_INT_EQ("VLS cost", get_melee_spec_cost(MELEE_SPEC_VESTAS), 25); + ASSERT_INT_EQ("abyssal dagger cost", get_melee_spec_cost(MELEE_SPEC_ABYSSAL_DAGGER), 50); + ASSERT_INT_EQ("dragon longsword cost", get_melee_spec_cost(MELEE_SPEC_DRAGON_LONGSWORD), 25); + ASSERT_INT_EQ("dragon mace cost", get_melee_spec_cost(MELEE_SPEC_DRAGON_MACE), 25); + ASSERT_INT_EQ("abyssal bludgeon cost", get_melee_spec_cost(MELEE_SPEC_ABYSSAL_BLUDGEON), 50); +} + + +static void test_ranged_spec_costs(void) { + printf("--- ranged spec energy costs ---\n"); + + ASSERT_INT_EQ("dark bow cost", get_ranged_spec_cost(RANGED_SPEC_DARK_BOW), 55); + ASSERT_INT_EQ("ballista cost", get_ranged_spec_cost(RANGED_SPEC_BALLISTA), 65); + ASSERT_INT_EQ("ZCB cost", get_ranged_spec_cost(RANGED_SPEC_ZCB), 75); + ASSERT_INT_EQ("dragon knife cost", get_ranged_spec_cost(RANGED_SPEC_DRAGON_KNIFE), 25); + ASSERT_INT_EQ("MSB cost", get_ranged_spec_cost(RANGED_SPEC_MSB), 50); + ASSERT_INT_EQ("morrigan's cost", get_ranged_spec_cost(RANGED_SPEC_MORRIGANS), 50); +} + + +static void test_magic_spec_costs(void) { + printf("--- magic spec energy costs ---\n"); + + ASSERT_INT_EQ("volatile staff cost", get_magic_spec_cost(MAGIC_SPEC_VOLATILE_STAFF), 55); +} +/* test: melee spec accuracy multipliers */ +/* */ +/* ref: PlayerVsNPCCalc.ts:292-311 (godswords [2,1]=2x, DDS [23,20]=1.15x, */ +/* abyssal dagger [5,4]=1.25x, etc.) */ +static void test_melee_spec_acc_multipliers(void) { + printf("--- melee spec accuracy multipliers ---\n"); + + /* AGS: godsword family [2,1] = 2.0x */ + ASSERT_FLOAT_EQ("AGS acc mult", get_melee_spec_acc_mult(MELEE_SPEC_AGS), 2.0f, 1e-5f); + /* dragon claws: not listed in PvNPC (custom cascade), our impl uses 1.35x + this is the PvP value from OSRS wiki (not in ref calc for PvNPC) */ + /* claws: no accuracy multiplier — cascade rolls 4x at base acc. + ref: osrs-dps-calc dists/claws.ts (no acc mult in PlayerVsNPCCalc.ts:292-309) */ + ASSERT_FLOAT_EQ("claws acc mult", get_melee_spec_acc_mult(MELEE_SPEC_DRAGON_CLAWS), 1.0f, 1e-5f); + /* granite maul: no accuracy bonus */ + ASSERT_FLOAT_EQ("gmaul acc mult", get_melee_spec_acc_mult(MELEE_SPEC_GRANITE_MAUL), 1.0f, 1e-5f); + /* DDS: [23,20] = 1.15x. ref: dps-calc PlayerVsNPCCalc.ts:300 */ + ASSERT_FLOAT_EQ("DDS acc mult", get_melee_spec_acc_mult(MELEE_SPEC_DRAGON_DAGGER), 1.15f, 1e-5f); + /* voidwaker: guaranteed hit in PvNPC; PvP uses normal accuracy */ + ASSERT_FLOAT_EQ("VW acc mult", get_melee_spec_acc_mult(MELEE_SPEC_VOIDWAKER), 1.0f, 1e-5f); + /* statius warhammer (LMS): [5,4] = 1.25x */ + ASSERT_FLOAT_EQ("DWH acc mult", get_melee_spec_acc_mult(MELEE_SPEC_DWH), 1.25f, 1e-5f); + /* BGS: [2,1] = 2.0x per dps-calc */ + ASSERT_FLOAT_EQ("BGS acc mult", get_melee_spec_acc_mult(MELEE_SPEC_BGS), 2.0f, 1e-5f); + /* ZGS: godsword family [2,1] = 2.0x */ + ASSERT_FLOAT_EQ("ZGS acc mult", get_melee_spec_acc_mult(MELEE_SPEC_ZGS), 2.0f, 1e-5f); + /* SGS: [2,1] = 2.0x per dps-calc */ + ASSERT_FLOAT_EQ("SGS acc mult", get_melee_spec_acc_mult(MELEE_SPEC_SGS), 2.0f, 1e-5f); + /* ancient GS: godsword [2,1] = 2.0x */ + ASSERT_FLOAT_EQ("ancient GS acc", get_melee_spec_acc_mult(MELEE_SPEC_ANCIENT_GS), 2.0f, 1e-5f); + /* VLS: no acc mult (uses reduced def roll instead) */ + ASSERT_FLOAT_EQ("VLS acc mult", get_melee_spec_acc_mult(MELEE_SPEC_VESTAS), 1.0f, 1e-5f); + /* abyssal dagger: [5,4] = 1.25x */ + ASSERT_FLOAT_EQ("abyssal dagger acc", get_melee_spec_acc_mult(MELEE_SPEC_ABYSSAL_DAGGER), 1.25f, 1e-5f); + /* dragon longsword: [5,4] = 1.25x */ + ASSERT_FLOAT_EQ("dlong acc", get_melee_spec_acc_mult(MELEE_SPEC_DRAGON_LONGSWORD), 1.25f, 1e-5f); + /* dragon mace: [5,4] = 1.25x */ + ASSERT_FLOAT_EQ("dmace acc", get_melee_spec_acc_mult(MELEE_SPEC_DRAGON_MACE), 1.25f, 1e-5f); + /* abyssal bludgeon: no accuracy bonus */ + ASSERT_FLOAT_EQ("bludgeon acc", get_melee_spec_acc_mult(MELEE_SPEC_ABYSSAL_BLUDGEON), 1.0f, 1e-5f); +} +/* test: melee spec strength multipliers */ +/* */ +/* ref: PlayerVsNPCCalc.ts:453-485 */ +/* godswords get [11,10]=1.1x base, then additional per weapon: */ +/* AGS: [5,4]=1.25x total => 1.1*1.25 = 1.375x */ +/* BGS: [11,10]=1.21x total => 1.1*1.1 = 1.21x */ +/* ZGS/SGS: [11,10] only => 1.1x (no additional) */ +/* DDS: [23,20] = 1.15x; statius: [5,4] = 1.25x; dmace: [3,2] = 1.5x */ +static void test_melee_spec_str_multipliers(void) { + printf("--- melee spec strength multipliers ---\n"); + + /* AGS: godsword 1.1x * [5,4]=1.25x = 1.375x */ + ASSERT_FLOAT_EQ("AGS str mult", get_melee_spec_str_mult(MELEE_SPEC_AGS), 1.375f, 1e-3f); + /* dragon claws: 1.0x (cascade handles damage distribution) */ + ASSERT_FLOAT_EQ("claws str mult", get_melee_spec_str_mult(MELEE_SPEC_DRAGON_CLAWS), 1.0f, 1e-5f); + /* granite maul: 1.0x */ + ASSERT_FLOAT_EQ("gmaul str mult", get_melee_spec_str_mult(MELEE_SPEC_GRANITE_MAUL), 1.0f, 1e-5f); + /* DDS: [23,20] = 1.15x */ + ASSERT_FLOAT_EQ("DDS str mult", get_melee_spec_str_mult(MELEE_SPEC_DRAGON_DAGGER), 1.15f, 1e-3f); + /* voidwaker: 1.0x (50-150% range handled in perform_voidwaker_spec) */ + ASSERT_FLOAT_EQ("VW str mult", get_melee_spec_str_mult(MELEE_SPEC_VOIDWAKER), 1.0f, 1e-5f); + /* statius warhammer (LMS): 1.25x str */ + ASSERT_FLOAT_EQ("DWH str mult", get_melee_spec_str_mult(MELEE_SPEC_DWH), 1.25f, 1e-3f); + /* BGS: godsword 1.1x * [11,10]=1.1x = 1.21x */ + ASSERT_FLOAT_EQ("BGS str mult", get_melee_spec_str_mult(MELEE_SPEC_BGS), 1.21f, 1e-3f); + /* ZGS: godsword 1.1x only */ + ASSERT_FLOAT_EQ("ZGS str mult", get_melee_spec_str_mult(MELEE_SPEC_ZGS), 1.1f, 1e-3f); + /* SGS: godsword 1.1x only */ + ASSERT_FLOAT_EQ("SGS str mult", get_melee_spec_str_mult(MELEE_SPEC_SGS), 1.1f, 1e-3f); + /* ancient GS: godsword 1.1x only (blood sacrifice is separate) */ + ASSERT_FLOAT_EQ("ancient GS str", get_melee_spec_str_mult(MELEE_SPEC_ANCIENT_GS), 1.1f, 1e-3f); + /* VLS: 1.20x */ + ASSERT_FLOAT_EQ("VLS str mult", get_melee_spec_str_mult(MELEE_SPEC_VESTAS), 1.20f, 1e-3f); + /* abyssal dagger: 0.85x */ + ASSERT_FLOAT_EQ("abyssal dagger str", get_melee_spec_str_mult(MELEE_SPEC_ABYSSAL_DAGGER), 0.85f, 1e-3f); + /* dragon longsword: 1.15x */ + ASSERT_FLOAT_EQ("dlong str mult", get_melee_spec_str_mult(MELEE_SPEC_DRAGON_LONGSWORD), 1.15f, 1e-3f); + /* dragon mace: [3,2] = 1.5x */ + ASSERT_FLOAT_EQ("dmace str mult", get_melee_spec_str_mult(MELEE_SPEC_DRAGON_MACE), 1.5f, 1e-3f); + /* abyssal bludgeon: 1.20x (base; real spec adds missing prayer %) */ + ASSERT_FLOAT_EQ("bludgeon str", get_melee_spec_str_mult(MELEE_SPEC_ABYSSAL_BLUDGEON), 1.20f, 1e-3f); +} +/* test: ranged spec accuracy multipliers */ +/* */ +/* ref: PlayerVsNPCCalc.ts:579-589 */ +/* dark bow: no acc bonus. ballista: [5,4]=1.25x. */ +/* ZCB: [2,1]=2.0x. MSB: [10,7]~1.43x. dragon knife: none. */ +static void test_ranged_spec_acc_multipliers(void) { + printf("--- ranged spec accuracy multipliers ---\n"); + + ASSERT_FLOAT_EQ("dark bow acc", get_ranged_spec_acc_mult(RANGED_SPEC_DARK_BOW), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("ballista acc", get_ranged_spec_acc_mult(RANGED_SPEC_BALLISTA), 1.25f, 1e-3f); + ASSERT_FLOAT_EQ("ZCB acc", get_ranged_spec_acc_mult(RANGED_SPEC_ZCB), 2.0f, 1e-5f); + ASSERT_FLOAT_EQ("dragon knife acc", get_ranged_spec_acc_mult(RANGED_SPEC_DRAGON_KNIFE), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("MSB acc", get_ranged_spec_acc_mult(RANGED_SPEC_MSB), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("morrigan's acc", get_ranged_spec_acc_mult(RANGED_SPEC_MORRIGANS), 1.0f, 1e-5f); +} +/* test: ranged spec strength multipliers */ +/* */ +/* ref: PlayerVsNPCCalc.ts:738-757 */ +/* dark bow with dragon arrows: [15,10]=1.5x, min 8, max 48 clamped. */ +/* ballista: [5,4]=1.25x. */ +static void test_ranged_spec_str_multipliers(void) { + printf("--- ranged spec strength multipliers ---\n"); + + ASSERT_FLOAT_EQ("dark bow str", get_ranged_spec_str_mult(RANGED_SPEC_DARK_BOW), 1.5f, 1e-3f); + ASSERT_FLOAT_EQ("ballista str", get_ranged_spec_str_mult(RANGED_SPEC_BALLISTA), 1.25f, 1e-3f); + ASSERT_FLOAT_EQ("ZCB str", get_ranged_spec_str_mult(RANGED_SPEC_ZCB), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("dragon knife str", get_ranged_spec_str_mult(RANGED_SPEC_DRAGON_KNIFE), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("MSB str", get_ranged_spec_str_mult(RANGED_SPEC_MSB), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("morrigan's str", get_ranged_spec_str_mult(RANGED_SPEC_MORRIGANS), 1.0f, 1e-5f); +} +/* test: magic spec accuracy multiplier */ +/* */ +/* ref: PlayerVsNPCCalc.ts:855-856 volatile staff [3,2]=1.5x */ +static void test_magic_spec_acc_multiplier(void) { + printf("--- magic spec accuracy multiplier ---\n"); + + ASSERT_FLOAT_EQ("volatile staff acc", get_magic_spec_acc_mult(MAGIC_SPEC_VOLATILE_STAFF), 1.5f, 1e-5f); +} +/* test: blowpipe spec constants (osrs_combat.h) */ +/* */ +/* ref: osrs-sdk Blowpipe.ts: 2x accuracy, 1.5x damage, 50% heal, 50 cost */ +static void test_blowpipe_spec_constants(void) { + printf("--- blowpipe spec constants ---\n"); + + ASSERT_INT_EQ("blowpipe acc mult", BLOWPIPE_SPEC_ACC_MULT, 2); + /* 1.5x = 3/2 */ + ASSERT_INT_EQ("blowpipe dmg num", BLOWPIPE_SPEC_DMG_NUM, 3); + ASSERT_INT_EQ("blowpipe dmg den", BLOWPIPE_SPEC_DMG_DEN, 2); + ASSERT_INT_EQ("blowpipe heal pct", BLOWPIPE_SPEC_HEAL_PCT, 50); + ASSERT_INT_EQ("blowpipe spec cost", BLOWPIPE_SPEC_COST, 50); +} +/* test: blowpipe spec damage calculation */ +/* */ +/* osrs_blowpipe_spec_resolve: 2x att roll, 1.5x max hit, single hit. */ +/* with forced-hit scenario (high att, low def) we can check max hit cap. */ +static void test_blowpipe_spec_resolve(void) { + printf("--- blowpipe spec resolve ---\n"); + + /* base max hit 30, spec max = 30 * 3 / 2 = 45. + with huge att_roll vs 0 def, should always hit -> damage in [0, 45]. + run many trials to verify range. */ + uint32_t rng = 12345; + int base_att_roll = 50000; + int base_max_hit = 30; + int target_def_level = 1; + int target_def_bonus = 0; + + int min_seen = 9999, max_seen = -1; + int hit_count = 0; + int trials = 10000; + for (int i = 0; i < trials; i++) { + int dmg = osrs_blowpipe_spec_resolve(base_att_roll, base_max_hit, + target_def_level, target_def_bonus, &rng); + if (dmg > 0) hit_count++; + if (dmg < min_seen) min_seen = dmg; + if (dmg > max_seen) max_seen = dmg; + } + + /* spec max = 30 * 3 / 2 = 45 */ + int expected_spec_max = base_max_hit * BLOWPIPE_SPEC_DMG_NUM / BLOWPIPE_SPEC_DMG_DEN; + ASSERT_INT_EQ("blowpipe spec max formula", expected_spec_max, 45); + + /* with overwhelming accuracy, hit rate should be very high */ + ASSERT_INT_EQ("blowpipe high hit rate", hit_count > trials / 2, 1); + + /* observed max should not exceed spec_max */ + ASSERT_INT_EQ("blowpipe max bounded", max_seen <= expected_spec_max, 1); + + /* min_seen should be 0 (possible miss or 0 roll) */ + ASSERT_INT_EQ("blowpipe min is 0", min_seen, 0); +} +/* test: dragon claws cascade structure */ +/* */ +/* ref: .refs/osrs-dps-calc/src/lib/dists/claws.ts */ +/* */ +/* the claws spec uses 4 accuracy rolls. cascade logic: */ +/* roll1 hits: total=rand[max/2, max-1], split d/2, d/4, d/8, d/8+1 */ +/* roll2 hits: total=rand[3/8*max, 7/8*max], split 0, d/2, d/4, d/4+1 */ +/* roll3 hits: total=rand[max/4, 3/4*max], split 0, 0, d/2, d/2+1 */ +/* roll4 hits: total=rand[max/4, 5/4*max], split 0, 0, 0, d+1 */ +/* all miss: 0, 0, rand(0-1), same */ +/* */ +/* our impl differs slightly from ref (PvP variant) but structure matches. */ +/* we test: 4 hits always queued, acc mult = 1.35x, str mult = 1.0x. */ +static void test_dragon_claws_cascade(void) { + printf("--- dragon claws cascade ---\n"); + + /* verify the multiplier table values */ + ASSERT_FLOAT_EQ("claws acc_mult", get_melee_spec_acc_mult(MELEE_SPEC_DRAGON_CLAWS), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("claws str_mult", get_melee_spec_str_mult(MELEE_SPEC_DRAGON_CLAWS), 1.0f, 1e-5f); + ASSERT_INT_EQ("claws cost", get_melee_spec_cost(MELEE_SPEC_DRAGON_CLAWS), 50); + + /* verify cascade total damage ranges from dps-calc dClawDist. + generateTotals: low = floor(max * (4-accRoll) / 4), high = max + low - 1 + with max_hit = 40: + roll 0: low=40, high=79. total in [40, 79], split [t/2, t/4, t/8, t/8+1] + roll 1: low=30, high=69. total in [30, 69], split [t/2, t/4, t/4+1, 0] + roll 2: low=20, high=59. total in [20, 59], split [t/2, t/2+1, 0, 0] + roll 3: low=10, high=49. total in [10, 49], split [t+1, 0, 0, 0] + all miss: 2/3 chance [1,1,0,0], 1/3 chance [0,0,0,0] */ + int max_hit = 40; + /* roll 0 total range */ + ASSERT_INT_EQ("claws roll0 low", max_hit * 4 / 4, 40); + ASSERT_INT_EQ("claws roll0 high", max_hit + max_hit * 4 / 4 - 1, 79); + /* roll 1 total range */ + ASSERT_INT_EQ("claws roll1 low", max_hit * 3 / 4, 30); + ASSERT_INT_EQ("claws roll1 high", max_hit + max_hit * 3 / 4 - 1, 69); + /* roll 2 total range */ + ASSERT_INT_EQ("claws roll2 low", max_hit * 2 / 4, 20); + ASSERT_INT_EQ("claws roll2 high", max_hit + max_hit * 2 / 4 - 1, 59); + /* roll 3 total range */ + ASSERT_INT_EQ("claws roll3 low", max_hit * 1 / 4, 10); + ASSERT_INT_EQ("claws roll3 high", max_hit + max_hit * 1 / 4 - 1, 49); +} +/* test: voidwaker spec mechanics */ +/* */ +/* voidwaker: guaranteed magic damage at 50-150% of melee max hit. */ +/* ref: PlayerVsNPCCalc.ts:464-466 — min=floor(maxHit/2), max=maxHit+min */ +/* also: accuracy is 1.0 (guaranteed hit, line 1207-1208) */ +static void test_voidwaker_mechanics(void) { + printf("--- voidwaker mechanics ---\n"); + + ASSERT_FLOAT_EQ("VW acc mult", get_melee_spec_acc_mult(MELEE_SPEC_VOIDWAKER), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("VW str mult", get_melee_spec_str_mult(MELEE_SPEC_VOIDWAKER), 1.0f, 1e-5f); + ASSERT_INT_EQ("VW cost", get_melee_spec_cost(MELEE_SPEC_VOIDWAKER), 50); + + /* damage range: 50% to 150% of melee max hit. + with max_melee_hit = 50: min_damage = floor(50 * 0.5) = 25, max_damage = floor(50 * 1.5) = 75 */ + int max_melee_hit = 50; + int min_damage = (int)(max_melee_hit * 0.5f); + int max_damage = (int)(max_melee_hit * 1.5f); + ASSERT_INT_EQ("VW min damage (max=50)", min_damage, 25); + ASSERT_INT_EQ("VW max damage (max=50)", max_damage, 75); + + /* edge case: max_melee_hit = 1 -> min=0, max=1 */ + int min_d1 = (int)(1 * 0.5f); + int max_d1 = (int)(1 * 1.5f); + ASSERT_INT_EQ("VW min damage (max=1)", min_d1, 0); + ASSERT_INT_EQ("VW max damage (max=1)", max_d1, 1); +} +/* test: VLS (Vesta's longsword) spec mechanics */ +/* */ +/* "Feint": 20-120% of base max hit, accuracy vs 25% of opponent's def */ +/* this is a custom PvP spec (not in reference PvNPC calc). */ +/* our code: osrs_pvp_combat.h:928-962 */ +static void test_vls_mechanics(void) { + printf("--- VLS spec mechanics ---\n"); + + ASSERT_INT_EQ("VLS cost", get_melee_spec_cost(MELEE_SPEC_VESTAS), 25); + ASSERT_FLOAT_EQ("VLS acc mult", get_melee_spec_acc_mult(MELEE_SPEC_VESTAS), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("VLS str mult", get_melee_spec_str_mult(MELEE_SPEC_VESTAS), 1.20f, 1e-3f); + + /* damage range: 20-120% of base max hit. + with base_max = 40: min = floor(40 * 0.20) = 8, max = floor(40 * 1.20) = 48 */ + int base_max = 40; + int vls_max = (int)(base_max * 1.20f); + int vls_min = (int)(base_max * 0.20f); + ASSERT_INT_EQ("VLS min (base=40)", vls_min, 8); + ASSERT_INT_EQ("VLS max (base=40)", vls_max, 48); + + /* defence reduction: 25% of opponent's defence roll. + if eff_def * (def_bonus + 64) = 10000, reduced to 2500 */ + int full_def_roll = 10000; + int reduced_def = (int)(full_def_roll * 0.25f); + ASSERT_INT_EQ("VLS def reduction", reduced_def, 2500); +} +/* test: statius warhammer spec (LMS, DWH path in our code) */ +/* */ +/* statius warhammer (LMS): 35% cost, 1.25x acc, 1.25x str, */ +/* 30% defence drain on hit. no minimum hit floor. */ +static void test_statius_warhammer_mechanics(void) { + printf("--- statius warhammer (LMS) spec ---\n"); + + ASSERT_INT_EQ("DWH cost", get_melee_spec_cost(MELEE_SPEC_DWH), 35); + ASSERT_FLOAT_EQ("DWH acc", get_melee_spec_acc_mult(MELEE_SPEC_DWH), 1.25f, 1e-5f); + ASSERT_FLOAT_EQ("DWH str", get_melee_spec_str_mult(MELEE_SPEC_DWH), 1.25f, 1e-3f); + + /* 30% defence drain: if current_def = 70, drain = floor(70 * 0.30) = 21, new def = 49 */ + int current_def = 70; + int drain = (int)(current_def * 30 / 100.0f); + int new_def = current_def - drain; + ASSERT_INT_EQ("DWH def drain (70)", drain, 21); + ASSERT_INT_EQ("DWH new def (70)", new_def, 49); + + /* edge case: minimum defence is 1 */ + int low_def = 2; + int low_drain = (int)(low_def * 30 / 100.0f); + int clamped = low_def - low_drain; + if (clamped < 1) clamped = 1; + ASSERT_INT_EQ("DWH drain (def=2)", low_drain, 0); + ASSERT_INT_EQ("DWH clamp (def=2)", clamped, 2); +} +/* test: BGS spec mechanics */ +/* */ +/* BGS drains defence by the damage dealt (drain_type=2). */ +/* cost=50%, acc=2.0x, str=1.21x (godsword 1.1 * bgs 1.1). */ +/* ref: PlayerVsNPCCalc.ts:458-459 [11,10] for godsword + BGS damage. */ +static void test_bgs_mechanics(void) { + printf("--- BGS spec ---\n"); + + ASSERT_INT_EQ("BGS cost", get_melee_spec_cost(MELEE_SPEC_BGS), 50); + ASSERT_FLOAT_EQ("BGS acc", get_melee_spec_acc_mult(MELEE_SPEC_BGS), 2.0f, 1e-3f); + ASSERT_FLOAT_EQ("BGS str", get_melee_spec_str_mult(MELEE_SPEC_BGS), 1.21f, 1e-3f); + + /* drain_type=2: defence reduced by damage dealt. + if damage = 35, current_def = 80: new_def = clamp(80-35, 1, 255) = 45 */ + int damage = 35; + int current_def = 80; + int new_def = current_def - damage; + if (new_def < 1) new_def = 1; + ASSERT_INT_EQ("BGS drain (dmg=35, def=80)", new_def, 45); + + /* edge case: damage > defence */ + int big_dmg = 90; + int result = current_def - big_dmg; + if (result < 1) result = 1; + ASSERT_INT_EQ("BGS drain clamp (dmg=90, def=80)", result, 1); +} +/* test: ZGS spec mechanics */ +/* */ +/* ZGS: 50% cost, 2.0x acc, 1.1x str, applies 32-tick freeze on hit. */ +static void test_zgs_mechanics(void) { + printf("--- ZGS spec ---\n"); + + ASSERT_INT_EQ("ZGS cost", get_melee_spec_cost(MELEE_SPEC_ZGS), 50); + ASSERT_FLOAT_EQ("ZGS acc", get_melee_spec_acc_mult(MELEE_SPEC_ZGS), 2.0f, 1e-5f); + ASSERT_FLOAT_EQ("ZGS str", get_melee_spec_str_mult(MELEE_SPEC_ZGS), 1.1f, 1e-3f); + /* freeze ticks set to 32 in perform_attack when applies_freeze=1 (line 1502-1503) */ +} +/* test: SGS spec mechanics */ +/* */ +/* SGS: 50% cost, 2.0x acc, 1.1x str, heals 50% of damage dealt. */ +static void test_sgs_mechanics(void) { + printf("--- SGS spec ---\n"); + + ASSERT_INT_EQ("SGS cost", get_melee_spec_cost(MELEE_SPEC_SGS), 50); + ASSERT_FLOAT_EQ("SGS acc", get_melee_spec_acc_mult(MELEE_SPEC_SGS), 2.0f, 1e-3f); + ASSERT_FLOAT_EQ("SGS str", get_melee_spec_str_mult(MELEE_SPEC_SGS), 1.1f, 1e-3f); + /* heal_percent=50 set in perform_attack when heals_attacker=1 (line 1505-1506) */ +} +/* test: ancient godsword spec mechanics */ +/* */ +/* ancient GS: 50% cost, 2.0x acc, 1.1x str. */ +/* blood sacrifice: 25 magic damage at 8 tick delay if any hit lands, */ +/* + heals attacker 15% of target max HP (capped at 15 in PvP). */ +static void test_ancient_gs_mechanics(void) { + printf("--- ancient godsword spec ---\n"); + + ASSERT_INT_EQ("ancient GS cost", get_melee_spec_cost(MELEE_SPEC_ANCIENT_GS), 50); + ASSERT_FLOAT_EQ("ancient GS acc", get_melee_spec_acc_mult(MELEE_SPEC_ANCIENT_GS), 2.0f, 1e-5f); + ASSERT_FLOAT_EQ("ancient GS str", get_melee_spec_str_mult(MELEE_SPEC_ANCIENT_GS), 1.1f, 1e-3f); + + /* blood sacrifice: 25 fixed magic damage (bleed_damage constant from line 1368) */ + /* heal = clamp(target_base_hp * 0.15, 0, 15) */ + int target_hp = 99; + int heal = (int)(target_hp * 0.15f); + if (heal > 15) heal = 15; + ASSERT_INT_EQ("ancient GS heal (hp=99)", heal, 14); + + int target_hp2 = 120; + int heal2 = (int)(target_hp2 * 0.15f); + if (heal2 > 15) heal2 = 15; + ASSERT_INT_EQ("ancient GS heal capped (hp=120)", heal2, 15); +} +/* test: dark bow spec mechanics */ +/* */ +/* dark bow: 55% cost, 1.0x acc, 1.5x str, 2 hits. */ +/* with dragon arrows: min 8, max 48 clamped per hit. */ +/* ref: PlayerVsNPCCalc.ts:751-757 — min=8 (dragon arrows), max=48, */ +/* [15,10]=1.5x damage with dragon arrows. */ +/* our code: osrs_pvp_combat.h:989-1015 */ +static void test_dark_bow_mechanics(void) { + printf("--- dark bow spec ---\n"); + + ASSERT_INT_EQ("dark bow cost", get_ranged_spec_cost(RANGED_SPEC_DARK_BOW), 55); + ASSERT_FLOAT_EQ("dark bow acc", get_ranged_spec_acc_mult(RANGED_SPEC_DARK_BOW), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("dark bow str", get_ranged_spec_str_mult(RANGED_SPEC_DARK_BOW), 1.5f, 1e-3f); + + /* clamping: damage = clamp(rand(0, max_hit), 8, 48) */ + /* if hit: damage in [8, 48]. if miss: damage = 8 (minimum guaranteed). */ + int test_damage; + + /* simulate hit: rand gives 3 -> clamped to 8 */ + test_damage = 3; + test_damage = test_damage < 8 ? 8 : (test_damage > 48 ? 48 : test_damage); + ASSERT_INT_EQ("dbow clamp low (3->8)", test_damage, 8); + + /* simulate hit: rand gives 60 -> clamped to 48 */ + test_damage = 60; + test_damage = test_damage < 8 ? 8 : (test_damage > 48 ? 48 : test_damage); + ASSERT_INT_EQ("dbow clamp high (60->48)", test_damage, 48); + + /* simulate hit: rand gives 25 -> stays 25 */ + test_damage = 25; + test_damage = test_damage < 8 ? 8 : (test_damage > 48 ? 48 : test_damage); + ASSERT_INT_EQ("dbow no clamp (25)", test_damage, 25); + + /* miss case: damage = 8 (minimum guaranteed per our code line 1008) */ + ASSERT_INT_EQ("dbow miss min", 8, 8); +} +/* test: morrigan's javelin (Phantom Strike) bleed mechanics */ +/* */ +/* morrigan's: 50% cost, 1.0x acc, 1.0x str (just ranged hit). */ +/* on hit: sets morr_dot_remaining = damage dealt (post-prayer). */ +/* bleed: 5 HP every 3 ticks until remaining exhausted. */ +/* our code: osrs_pvp_api.h:633-646 */ +static void test_morrigans_bleed(void) { + printf("--- morrigan's javelin bleed ---\n"); + + ASSERT_INT_EQ("morrigan's cost", get_ranged_spec_cost(RANGED_SPEC_MORRIGANS), 50); + ASSERT_FLOAT_EQ("morrigan's acc", get_ranged_spec_acc_mult(RANGED_SPEC_MORRIGANS), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("morrigan's str", get_ranged_spec_str_mult(RANGED_SPEC_MORRIGANS), 1.0f, 1e-5f); + + /* bleed tick simulation: damage=23, 5 per 3 ticks. + tick 3: 5 dealt (23-5=18 remaining) + tick 6: 5 dealt (18-5=13 remaining) + tick 9: 5 dealt (13-5=8 remaining) + tick 12: 5 dealt (8-5=3 remaining) + tick 15: 3 dealt (3-3=0 remaining, done) */ + int remaining = 23; + int total_bleed = 0; + int bleed_ticks = 0; + + while (remaining > 0) { + int dot = remaining >= 5 ? 5 : remaining; + remaining -= dot; + total_bleed += dot; + bleed_ticks++; + } + + ASSERT_INT_EQ("morr bleed total (23)", total_bleed, 23); + ASSERT_INT_EQ("morr bleed ticks (23)", bleed_ticks, 5); + + /* edge case: damage = 5 -> exactly 1 bleed tick */ + remaining = 5; + total_bleed = 0; + bleed_ticks = 0; + while (remaining > 0) { + int dot = remaining >= 5 ? 5 : remaining; + remaining -= dot; + total_bleed += dot; + bleed_ticks++; + } + ASSERT_INT_EQ("morr bleed total (5)", total_bleed, 5); + ASSERT_INT_EQ("morr bleed ticks (5)", bleed_ticks, 1); + + /* edge case: damage = 1 -> 1 bleed tick of 1 */ + remaining = 1; + total_bleed = 0; + bleed_ticks = 0; + while (remaining > 0) { + int dot = remaining >= 5 ? 5 : remaining; + remaining -= dot; + total_bleed += dot; + bleed_ticks++; + } + ASSERT_INT_EQ("morr bleed total (1)", total_bleed, 1); + ASSERT_INT_EQ("morr bleed ticks (1)", bleed_ticks, 1); +} +/* test: volatile nightmare staff spec */ +/* */ +/* volatile staff: 55% cost, 1.5x acc. */ +/* max hit = min(58, 58 * floor(magicLevel/99) + 1) at 99 magic = 58. */ +/* ref: PlayerVsNPCCalc.ts:924-925 */ +static void test_volatile_staff_mechanics(void) { + printf("--- volatile nightmare staff spec ---\n"); + + ASSERT_INT_EQ("volatile cost", get_magic_spec_cost(MAGIC_SPEC_VOLATILE_STAFF), 55); + ASSERT_FLOAT_EQ("volatile acc", get_magic_spec_acc_mult(MAGIC_SPEC_VOLATILE_STAFF), 1.5f, 1e-5f); + + /* max hit at magic level 99: min(58, 58 * floor(99/99) + 1) = min(58, 59) = 58 */ + int magic_level = 99; + int vol_max = 58 * (magic_level / 99) + 1; + if (vol_max > 58) vol_max = 58; + if (vol_max < 1) vol_max = 1; + ASSERT_INT_EQ("volatile max (lvl 99)", vol_max, 58); + + /* at magic level 98: floor(98/99) = 0, so max = min(58, 1) = 1 */ + magic_level = 98; + vol_max = 58 * (magic_level / 99) + 1; + if (vol_max > 58) vol_max = 58; + if (vol_max < 1) vol_max = 1; + ASSERT_INT_EQ("volatile max (lvl 98)", vol_max, 1); +} +/* test: double-hit spec weapons */ +/* */ +/* DDS (dragon dagger): 2 hits, 25% cost, 1.20x acc, 1.15x str. */ +/* abyssal dagger: 2 hits, 50% cost, 1.25x acc, 0.85x str. */ +/* dragon knife: 2 hits, 25% cost, 1.0x acc, 1.0x str. */ +/* MSB (magic shortbow i): 2 hits, 55% cost, 1.0x acc, 1.0x str. */ +static void test_double_hit_specs(void) { + printf("--- double-hit spec weapons ---\n"); + + /* DDS */ + ASSERT_INT_EQ("DDS cost", get_melee_spec_cost(MELEE_SPEC_DRAGON_DAGGER), 25); + ASSERT_FLOAT_EQ("DDS acc", get_melee_spec_acc_mult(MELEE_SPEC_DRAGON_DAGGER), 1.15f, 1e-3f); + ASSERT_FLOAT_EQ("DDS str", get_melee_spec_str_mult(MELEE_SPEC_DRAGON_DAGGER), 1.15f, 1e-3f); + + /* abyssal dagger */ + ASSERT_INT_EQ("abyssal dagger cost", get_melee_spec_cost(MELEE_SPEC_ABYSSAL_DAGGER), 50); + ASSERT_FLOAT_EQ("abyssal dagger acc", get_melee_spec_acc_mult(MELEE_SPEC_ABYSSAL_DAGGER), 1.25f, 1e-3f); + ASSERT_FLOAT_EQ("abyssal dagger str", get_melee_spec_str_mult(MELEE_SPEC_ABYSSAL_DAGGER), 0.85f, 1e-3f); + + /* dragon knife */ + ASSERT_INT_EQ("dragon knife cost", get_ranged_spec_cost(RANGED_SPEC_DRAGON_KNIFE), 25); + ASSERT_FLOAT_EQ("dragon knife acc", get_ranged_spec_acc_mult(RANGED_SPEC_DRAGON_KNIFE), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("dragon knife str", get_ranged_spec_str_mult(RANGED_SPEC_DRAGON_KNIFE), 1.0f, 1e-5f); + + /* MSB (magic shortbow i) */ + ASSERT_INT_EQ("MSB cost", get_ranged_spec_cost(RANGED_SPEC_MSB), 50); + ASSERT_FLOAT_EQ("MSB acc", get_ranged_spec_acc_mult(RANGED_SPEC_MSB), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("MSB str", get_ranged_spec_str_mult(RANGED_SPEC_MSB), 1.0f, 1e-5f); +} +/* test: granite maul spec (instant) */ +/* */ +/* gmaul: 50% cost, 1.0x acc, 1.0x str, instant attack (resets timer). */ +static void test_granite_maul_mechanics(void) { + printf("--- granite maul spec ---\n"); + + ASSERT_INT_EQ("gmaul cost", get_melee_spec_cost(MELEE_SPEC_GRANITE_MAUL), 50); + ASSERT_FLOAT_EQ("gmaul acc", get_melee_spec_acc_mult(MELEE_SPEC_GRANITE_MAUL), 1.0f, 1e-5f); + ASSERT_FLOAT_EQ("gmaul str", get_melee_spec_str_mult(MELEE_SPEC_GRANITE_MAUL), 1.0f, 1e-5f); +} +/* test: heavy ballista spec */ +/* */ +/* ballista: 65% cost, 1.25x acc, 1.25x str. */ +/* ref: PlayerVsNPCCalc.ts:584 [5,4]=1.25x acc, line 744 [5,4]=1.25x str */ +static void test_ballista_mechanics(void) { + printf("--- heavy ballista spec ---\n"); + + ASSERT_INT_EQ("ballista cost", get_ranged_spec_cost(RANGED_SPEC_BALLISTA), 65); + ASSERT_FLOAT_EQ("ballista acc", get_ranged_spec_acc_mult(RANGED_SPEC_BALLISTA), 1.25f, 1e-3f); + ASSERT_FLOAT_EQ("ballista str", get_ranged_spec_str_mult(RANGED_SPEC_BALLISTA), 1.25f, 1e-3f); +} +/* test: ZCB spec (ACB removed — non-DPS bolt proc spec, UNIMPLEMENTED_SPECS) */ +/* */ +/* ZCB: 75% cost, 2.0x acc, 1.0x str. */ +/* ref: PlayerVsNPCCalc.ts:580 [2,1]=2.0x acc for ZCB. */ +static void test_crossbow_specs(void) { + printf("--- crossbow specs (ZCB) ---\n"); + + /* ACB removed: non-DPS spec (bolt proc related, dps-calc UNIMPLEMENTED_SPECS) */ + + ASSERT_INT_EQ("ZCB cost", get_ranged_spec_cost(RANGED_SPEC_ZCB), 75); + ASSERT_FLOAT_EQ("ZCB acc", get_ranged_spec_acc_mult(RANGED_SPEC_ZCB), 2.0f, 1e-5f); + ASSERT_FLOAT_EQ("ZCB str", get_ranged_spec_str_mult(RANGED_SPEC_ZCB), 1.0f, 1e-5f); +} +/* test: melee spec two-handed classification */ +/* */ +/* godswords, dragon claws, abyssal bludgeon are two-handed. */ +/* DDS, gmaul, VW, DWH, VLS, abyssal dagger, dlong, dmace are one-handed. */ +static void test_melee_spec_two_handed(void) { + printf("--- melee spec two-handed classification ---\n"); + + ASSERT_INT_EQ("AGS two-handed", is_melee_spec_two_handed(MELEE_SPEC_AGS), 1); + ASSERT_INT_EQ("claws two-handed", is_melee_spec_two_handed(MELEE_SPEC_DRAGON_CLAWS), 1); + ASSERT_INT_EQ("BGS two-handed", is_melee_spec_two_handed(MELEE_SPEC_BGS), 1); + ASSERT_INT_EQ("ZGS two-handed", is_melee_spec_two_handed(MELEE_SPEC_ZGS), 1); + ASSERT_INT_EQ("SGS two-handed", is_melee_spec_two_handed(MELEE_SPEC_SGS), 1); + ASSERT_INT_EQ("ancient GS two-hand", is_melee_spec_two_handed(MELEE_SPEC_ANCIENT_GS), 1); + ASSERT_INT_EQ("bludgeon two-handed", is_melee_spec_two_handed(MELEE_SPEC_ABYSSAL_BLUDGEON), 1); + + ASSERT_INT_EQ("gmaul one-handed", is_melee_spec_two_handed(MELEE_SPEC_GRANITE_MAUL), 0); + ASSERT_INT_EQ("DDS one-handed", is_melee_spec_two_handed(MELEE_SPEC_DRAGON_DAGGER), 0); + ASSERT_INT_EQ("VW one-handed", is_melee_spec_two_handed(MELEE_SPEC_VOIDWAKER), 0); + ASSERT_INT_EQ("DWH one-handed", is_melee_spec_two_handed(MELEE_SPEC_DWH), 0); + ASSERT_INT_EQ("VLS one-handed", is_melee_spec_two_handed(MELEE_SPEC_VESTAS), 0); + ASSERT_INT_EQ("abyssal dag one-hand",is_melee_spec_two_handed(MELEE_SPEC_ABYSSAL_DAGGER), 0); + ASSERT_INT_EQ("dlong one-handed", is_melee_spec_two_handed(MELEE_SPEC_DRAGON_LONGSWORD), 0); + ASSERT_INT_EQ("dmace one-handed", is_melee_spec_two_handed(MELEE_SPEC_DRAGON_MACE), 0); +} +/* test: melee spec bonus types (stab/slash/crush) */ +/* */ +/* ref: osrs_pvp_gear.h MELEE_SPEC_BONUS_TYPES[] */ +static void test_melee_spec_bonus_types(void) { + printf("--- melee spec bonus types ---\n"); + + ASSERT_INT_EQ("AGS is slash", MELEE_SPEC_BONUS_TYPES[MELEE_SPEC_AGS], MELEE_BONUS_SLASH); + ASSERT_INT_EQ("claws is slash", MELEE_SPEC_BONUS_TYPES[MELEE_SPEC_DRAGON_CLAWS], MELEE_BONUS_SLASH); + ASSERT_INT_EQ("gmaul is crush", MELEE_SPEC_BONUS_TYPES[MELEE_SPEC_GRANITE_MAUL], MELEE_BONUS_CRUSH); + ASSERT_INT_EQ("DDS is stab", MELEE_SPEC_BONUS_TYPES[MELEE_SPEC_DRAGON_DAGGER], MELEE_BONUS_STAB); + ASSERT_INT_EQ("DWH is crush", MELEE_SPEC_BONUS_TYPES[MELEE_SPEC_DWH], MELEE_BONUS_CRUSH); +} +/* test: ranged spec hit delays */ +/* */ +/* ref: osrs_pvp_combat.h:497-513 */ +/* dragon knife / morrigan's: 1 tick. ballista: 3 ticks. */ +/* dark bow / MSB / ZCB: default ranged formula. */ +static void test_ranged_spec_hit_delays(void) { + printf("--- ranged spec hit delays ---\n"); + + int distance = 5; + + /* dragon knife: always 1 tick delay */ + ASSERT_INT_EQ("dragon knife delay", + pvp_ranged_hit_delay_for_weapon(distance, 1, RANGED_SPEC_DRAGON_KNIFE), 1); + + /* morrigan's: always 1 tick delay */ + ASSERT_INT_EQ("morrigan's delay", + pvp_ranged_hit_delay_for_weapon(distance, 1, RANGED_SPEC_MORRIGANS), 1); + + /* ballista: always 3 ticks delay */ + ASSERT_INT_EQ("ballista delay", + pvp_ranged_hit_delay_for_weapon(distance, 1, RANGED_SPEC_BALLISTA), 3); + + /* dark bow spec: uses default ranged formula */ + int expected_default = (3 + distance) / 6 + 1; + ASSERT_INT_EQ("dark bow delay", + pvp_ranged_hit_delay_for_weapon(distance, 1, RANGED_SPEC_DARK_BOW), expected_default); + + /* non-special: always uses default formula regardless of weapon */ + ASSERT_INT_EQ("non-special delay", + pvp_ranged_hit_delay_for_weapon(distance, 0, RANGED_SPEC_DRAGON_KNIFE), expected_default); +} +/* test: spec energy sufficiency checks */ +/* */ +/* verify can_spec / energy checks respect costs correctly. */ +static void test_spec_energy_checks(void) { + printf("--- spec energy sufficiency ---\n"); + + /* AGS needs 50: player with 50 can spec, player with 49 cannot */ + ASSERT_INT_EQ("AGS: 50 >= 50", 50 >= get_melee_spec_cost(MELEE_SPEC_AGS), 1); + ASSERT_INT_EQ("AGS: 49 >= 50", 49 >= get_melee_spec_cost(MELEE_SPEC_AGS), 0); + + /* DDS needs 25: can double-spec from 50 */ + ASSERT_INT_EQ("DDS: 50 >= 25", 50 >= get_melee_spec_cost(MELEE_SPEC_DRAGON_DAGGER), 1); + ASSERT_INT_EQ("DDS: 25 >= 25", 25 >= get_melee_spec_cost(MELEE_SPEC_DRAGON_DAGGER), 1); + ASSERT_INT_EQ("DDS: 24 >= 25", 24 >= get_melee_spec_cost(MELEE_SPEC_DRAGON_DAGGER), 0); + + /* BGS needs 50 */ + ASSERT_INT_EQ("BGS: 50 >= 50", 50 >= get_melee_spec_cost(MELEE_SPEC_BGS), 1); + ASSERT_INT_EQ("BGS: 49 >= 50", 49 >= get_melee_spec_cost(MELEE_SPEC_BGS), 0); + + /* DWH needs 35 */ + ASSERT_INT_EQ("DWH: 35 >= 35", 35 >= get_melee_spec_cost(MELEE_SPEC_DWH), 1); + ASSERT_INT_EQ("DWH: 34 >= 35", 34 >= get_melee_spec_cost(MELEE_SPEC_DWH), 0); + + /* dark bow needs 55 */ + ASSERT_INT_EQ("dbow: 55 >= 55", 55 >= get_ranged_spec_cost(RANGED_SPEC_DARK_BOW), 1); + ASSERT_INT_EQ("dbow: 54 >= 55", 54 >= get_ranged_spec_cost(RANGED_SPEC_DARK_BOW), 0); + + /* volatile staff needs 55 */ + ASSERT_INT_EQ("volatile: 55 >= 55", 55 >= get_magic_spec_cost(MAGIC_SPEC_VOLATILE_STAFF), 1); + ASSERT_INT_EQ("volatile: 54 >= 55", 54 >= get_magic_spec_cost(MAGIC_SPEC_VOLATILE_STAFF), 0); +} +/* test: max hit calculation with spec str multiplier */ +/* */ +/* uses calculate_max_hit directly with a synthetic player to verify that */ +/* str_mult is applied correctly. */ +/* formula: floor(((eff_str * (str_bonus + 64) + 320) / 640) * str_mult) */ +static void test_max_hit_with_spec_mult(void) { + printf("--- max hit with spec str multiplier ---\n"); + + Player p; + memset(&p, 0, sizeof(p)); + p.current_strength = 99; + p.current_ranged = 99; + p.offensive_prayer = OFFENSIVE_PRAYER_NONE; + p.fight_style = FIGHT_STYLE_ACCURATE; + + /* need to set up gear bonuses for strength. use a simple approach: + set the slot gear bonuses array directly. */ + GearBonuses gb; + memset(&gb, 0, sizeof(gb)); + gb.melee_strength = 100; /* typical high str bonus */ + gb.ranged_strength = 80; + p.current_gear = GEAR_MELEE; + + /* get_slot_gear_bonuses reads slot_cached_bonuses when slot_gear_dirty=0 */ + p.slot_cached_bonuses = gb; + p.slot_gear_dirty = 0; + + /* effective_strength = floor(99 * 1.0) + 0 (accurate doesn't boost str) + 8 = 107 + base max hit = floor((107 * (100 + 64) + 320) / 640) = floor((17548 + 320) / 640) + = floor(17868 / 640) = floor(27.91875) = 27 */ + int base_max = calculate_max_hit(&p, ATTACK_STYLE_MELEE, 1.0f, 30); + ASSERT_INT_EQ("base melee max hit (str=99, bonus=100)", base_max, 27); + + /* base max hit = floor((107 * 164 + 320) / 640) = floor(17868 / 640) = 27. + spec multipliers now apply to the truncated base (matching dps-calc Math.trunc). + osrs_resolve_spec uses integer multipliers (e.g. max*11/8 for AGS) which is + the correct OSRS formula. calculate_max_hit with float str_mult truncates + floor(27 * mult). */ + + /* AGS: 1.375x -> floor(27 * 1.375) = floor(37.125) = 37 */ + int ags_max = calculate_max_hit(&p, ATTACK_STYLE_MELEE, 1.375f, 30); + ASSERT_INT_EQ("AGS max hit (1.375x)", ags_max, 37); + + /* DDS: 1.15x -> floor(27 * 1.15) = floor(31.05) = 31 */ + int dds_max = calculate_max_hit(&p, ATTACK_STYLE_MELEE, 1.15f, 30); + ASSERT_INT_EQ("DDS max hit (1.15x)", dds_max, 31); + + /* BGS: 1.21x -> floor(27 * 1.21) = floor(32.67) = 32 */ + int bgs_max = calculate_max_hit(&p, ATTACK_STYLE_MELEE, 1.21f, 30); + ASSERT_INT_EQ("BGS max hit (1.21x)", bgs_max, 32); + + /* DWH/statius: 1.25x -> floor(27 * 1.25) = floor(33.75) = 33 */ + int dwh_max = calculate_max_hit(&p, ATTACK_STYLE_MELEE, 1.25f, 30); + ASSERT_INT_EQ("DWH max hit (1.25x)", dwh_max, 33); + + /* ZGS/SGS: 1.1x -> floor(27 * 1.1) = floor(29.7) = 29 */ + int zgs_max = calculate_max_hit(&p, ATTACK_STYLE_MELEE, 1.1f, 30); + ASSERT_INT_EQ("ZGS max hit (1.1x)", zgs_max, 29); + + /* abyssal dagger: 0.85x -> floor(27 * 0.85) = floor(22.95) = 22 */ + int abd_max = calculate_max_hit(&p, ATTACK_STYLE_MELEE, 0.85f, 30); + ASSERT_INT_EQ("abyssal dagger max hit (0.85x)", abd_max, 22); + + /* dragon mace: 1.5x -> floor(27 * 1.5) = floor(40.5) = 40 */ + int dmace_max = calculate_max_hit(&p, ATTACK_STYLE_MELEE, 1.5f, 30); + ASSERT_INT_EQ("dragon mace max hit (1.5x)", dmace_max, 40); + + /* ranged: with str_bonus=80, eff_str = floor(99*1.0) + 0 + 8 = 107 + base = floor((107 * (80+64) + 320) / 640) = floor((15408+320)/640) = floor(24.575) = 24 */ + GearBonuses rgb; + memset(&rgb, 0, sizeof(rgb)); + rgb.ranged_strength = 80; + p.slot_cached_bonuses = rgb; + p.slot_gear_dirty = 0; + p.fight_style = FIGHT_STYLE_ACCURATE; + + int ranged_base = calculate_max_hit(&p, ATTACK_STYLE_RANGED, 1.0f, 30); + ASSERT_INT_EQ("base ranged max hit (range=99, bonus=80)", ranged_base, 24); + + /* dark bow: 1.5x -> floor(24 * 1.5) = floor(36.0) = 36 */ + int dbow_max = calculate_max_hit(&p, ATTACK_STYLE_RANGED, 1.5f, 30); + ASSERT_INT_EQ("dark bow max hit (1.5x)", dbow_max, 36); + + /* ballista: 1.25x -> floor(24 * 1.25) = floor(30.0) = 30 */ + int bal_max = calculate_max_hit(&p, ATTACK_STYLE_RANGED, 1.25f, 30); + ASSERT_INT_EQ("ballista max hit (1.25x)", bal_max, 30); +} +/* test: spec accuracy affects hit chance correctly */ +/* */ +/* calculate_hit_chance applies acc_mult to the attack roll. */ +/* formula: attack_roll = eff_attack * (att_bonus + 64) * acc_mult */ +/* then normal accuracy formula. */ +static void test_hit_chance_with_spec_acc(void) { + printf("--- hit chance with spec accuracy ---\n"); + + /* set up minimal OsrsEnv env + attacker/defender */ + OsrsEnv env; + memset(&env, 0, sizeof(env)); + + Player attacker; + memset(&attacker, 0, sizeof(attacker)); + attacker.current_attack = 99; + attacker.current_strength = 99; + attacker.current_defence = 70; + attacker.current_magic = 99; + attacker.offensive_prayer = OFFENSIVE_PRAYER_NONE; + attacker.fight_style = FIGHT_STYLE_ACCURATE; + + GearBonuses att_gb; + memset(&att_gb, 0, sizeof(att_gb)); + att_gb.slash_attack = 120; + att_gb.melee_strength = 100; + attacker.slot_cached_bonuses = att_gb; + attacker.slot_gear_dirty = 0; + attacker.current_gear = GEAR_MELEE; + + Player defender; + memset(&defender, 0, sizeof(defender)); + defender.current_defence = 70; + defender.current_magic = 70; + defender.offensive_prayer = OFFENSIVE_PRAYER_NONE; + defender.fight_style = FIGHT_STYLE_DEFENSIVE; + + GearBonuses def_gb; + memset(&def_gb, 0, sizeof(def_gb)); + def_gb.slash_defence = 100; + defender.slot_cached_bonuses = def_gb; + defender.slot_gear_dirty = 0; + defender.current_gear = GEAR_MELEE; + + env.players[0] = attacker; + env.players[1] = defender; + + /* base accuracy (1.0x mult) */ + float base_acc = calculate_hit_chance(&env, &env.players[0], &env.players[1], + ATTACK_STYLE_MELEE, 1.0f); + + /* AGS accuracy (2.0x mult) should be higher */ + float ags_acc = calculate_hit_chance(&env, &env.players[0], &env.players[1], + ATTACK_STYLE_MELEE, 2.0f); + + ASSERT_INT_EQ("AGS acc > base acc", ags_acc > base_acc, 1); + ASSERT_INT_EQ("base acc > 0", base_acc > 0.0f, 1); + ASSERT_INT_EQ("AGS acc <= 1.0", ags_acc <= 1.0f, 1); + + /* with 2.0x mult, attack_roll doubles. verify the relationship. + eff_attack = floor(99*1.0) + 3 + 8 = 110 + attack_roll_base = 110 * (120+64) * 1.0 = 110 * 184 = 20240 + attack_roll_ags = 110 * 184 * 2.0 = 40480 + eff_def = floor(70*1.0) + 3 + 8 = 81 + def_roll = 81 * (100+64) = 81 * 164 = 13284 + + base: att(20240) > def(13284): 1 - 13286/(2*20241) = 1 - 0.32834 = 0.67166 + ags: att(40480) > def(13284): 1 - 13286/(2*40481) = 1 - 0.16407 = 0.83593 */ + float expected_base = 1.0f - 13286.0f / (2.0f * 20241.0f); + float expected_ags = 1.0f - 13286.0f / (2.0f * 40481.0f); + + ASSERT_FLOAT_EQ("base acc value", base_acc, expected_base, 1e-3f); + ASSERT_FLOAT_EQ("AGS acc value", ags_acc, expected_ags, 1e-3f); +} +/* test: osrs_resolve_spec dispatch */ +/* */ +/* verifies the shared spec dispatch returns correct costs and sensible */ +/* damage values for each weapon category. */ +static void test_spec_dispatch(void) { + printf("--- osrs_resolve_spec dispatch ---\n"); + uint32_t rng = 12345; + + /* spec costs via osrs_spec_cost() */ + ASSERT_INT_EQ("AGS cost (dispatch)", osrs_spec_cost(ITEM_AGS), 50); + ASSERT_INT_EQ("claws cost (dispatch)", osrs_spec_cost(ITEM_DRAGON_CLAWS), 50); + ASSERT_INT_EQ("DWH cost (dispatch)", osrs_spec_cost(ITEM_STATIUS_WARHAMMER), 35); + ASSERT_INT_EQ("BGS cost (dispatch)", osrs_spec_cost(ITEM_BGS), 50); + ASSERT_INT_EQ("ZGS cost (dispatch)", osrs_spec_cost(ITEM_ZGS), 50); + ASSERT_INT_EQ("SGS cost (dispatch)", osrs_spec_cost(ITEM_SGS), 50); + ASSERT_INT_EQ("ancient GS cost (dispatch)",osrs_spec_cost(ITEM_ANCIENT_GS), 50); + ASSERT_INT_EQ("VLS cost (dispatch)", osrs_spec_cost(ITEM_VESTAS), 25); + ASSERT_INT_EQ("VW cost (dispatch)", osrs_spec_cost(ITEM_VOIDWAKER), 50); + ASSERT_INT_EQ("gmaul cost (dispatch)", osrs_spec_cost(ITEM_GRANITE_MAUL), 50); + ASSERT_INT_EQ("DDS cost (dispatch)", osrs_spec_cost(ITEM_DRAGON_DAGGER), 25); + ASSERT_INT_EQ("elder maul cost (dispatch)",osrs_spec_cost(ITEM_ELDER_MAUL), 50); + ASSERT_INT_EQ("blowpipe cost (dispatch)", osrs_spec_cost(ITEM_TOXIC_BLOWPIPE), 50); + ASSERT_INT_EQ("MSB cost (dispatch)", osrs_spec_cost(ITEM_MAGIC_SHORTBOW_I), 50); + ASSERT_INT_EQ("dark bow cost (dispatch)", osrs_spec_cost(ITEM_DARK_BOW), 55); + ASSERT_INT_EQ("ZCB cost (dispatch)", osrs_spec_cost(ITEM_ZARYTE_CROSSBOW), 75); + ASSERT_INT_EQ("ballista cost (dispatch)", osrs_spec_cost(ITEM_HEAVY_BALLISTA), 65); + ASSERT_INT_EQ("morr cost (dispatch)", osrs_spec_cost(ITEM_MORRIGANS_JAVELIN), 50); + ASSERT_INT_EQ("volatile cost (dispatch)", osrs_spec_cost(ITEM_VOLATILE_STAFF), 55); + ASSERT_INT_EQ("eye of ayak cost (dispatch)", osrs_spec_cost(ITEM_EYE_OF_AYAK), 50); + ASSERT_INT_EQ("zuriel cost (no spec)", osrs_spec_cost(ITEM_ZURIELS_STAFF), 0); + ASSERT_INT_EQ("ACB cost (dispatch)", osrs_spec_cost(ITEM_ARMADYL_CROSSBOW), 50); + ASSERT_INT_EQ("non-weapon cost", osrs_spec_cost(ITEM_BARROWS_GLOVES), 0); + + /* resolve AGS with guaranteed hit (huge att vs tiny def) */ + rng = 42; + SpecResult sr = osrs_resolve_spec(ITEM_AGS, 100000, 50, 100, 99, &rng); + ASSERT_INT_EQ("AGS num_hits", sr.num_hits, 1); + tests_run++; + if (sr.total_damage >= 0 && sr.total_damage <= 50 * 11 / 8) { tests_passed++; } + else { tests_failed++; printf(" FAIL: AGS damage %d out of range [0, %d]\n", sr.total_damage, 50 * 11 / 8); } + + /* resolve blowpipe spec with guaranteed hit */ + rng = 99; + sr = osrs_resolve_spec(ITEM_TOXIC_BLOWPIPE, 100000, 30, 100, 99, &rng); + ASSERT_INT_EQ("blowpipe num_hits", sr.num_hits, 1); + ASSERT_INT_EQ("blowpipe heal", sr.heal, sr.total_damage / 2); + + /* resolve dragon claws — always 4 hits */ + rng = 777; + sr = osrs_resolve_spec(ITEM_DRAGON_CLAWS, 100000, 40, 100, 99, &rng); + ASSERT_INT_EQ("claws num_hits", sr.num_hits, 4); + ASSERT_INT_EQ("claws total = sum", sr.total_damage, + sr.damage[0] + sr.damage[1] + sr.damage[2] + sr.damage[3]); + + /* resolve DDS — always 2 hits */ + rng = 555; + sr = osrs_resolve_spec(ITEM_DRAGON_DAGGER, 100000, 40, 100, 99, &rng); + ASSERT_INT_EQ("DDS num_hits", sr.num_hits, 2); + + /* resolve SGS — heals half of damage */ + rng = 333; + sr = osrs_resolve_spec(ITEM_SGS, 100000, 50, 100, 99, &rng); + ASSERT_INT_EQ("SGS heal", sr.heal, sr.total_damage / 2); + + /* resolve ZGS — freezes on hit */ + rng = 111; + sr = osrs_resolve_spec(ITEM_ZGS, 100000, 50, 100, 99, &rng); + if (sr.total_damage > 0) { + ASSERT_INT_EQ("ZGS freeze", sr.freeze_ticks, 32); + } + + /* resolve DWH — drains def on hit */ + rng = 222; + sr = osrs_resolve_spec(ITEM_STATIUS_WARHAMMER, 100000, 50, 100, 70, &rng); + if (sr.total_damage > 0) { + ASSERT_INT_EQ("DWH def drain", sr.def_drain, 70 * 30 / 100); + } + + /* resolve BGS — drains def by damage */ + rng = 444; + sr = osrs_resolve_spec(ITEM_BGS, 100000, 50, 100, 99, &rng); + if (sr.total_damage > 0) { + ASSERT_INT_EQ("BGS drain = dmg", sr.def_drain, sr.total_damage); + } + + /* resolve dark bow — 2 hits, each >= 8 */ + rng = 888; + sr = osrs_resolve_spec(ITEM_DARK_BOW, 100000, 30, 100, 99, &rng); + ASSERT_INT_EQ("dbow num_hits", sr.num_hits, 2); + ASSERT_INT_EQ("dbow hit1 >= 8", sr.damage[0] >= 8, 1); + ASSERT_INT_EQ("dbow hit2 >= 8", sr.damage[1] >= 8, 1); + + /* resolve MSB — 2 hits */ + rng = 666; + sr = osrs_resolve_spec(ITEM_MAGIC_SHORTBOW_I, 100000, 20, 100, 99, &rng); + ASSERT_INT_EQ("MSB num_hits", sr.num_hits, 2); + + /* resolve eye of ayak — magic def drain */ + rng = 1234; + sr = osrs_resolve_spec(ITEM_EYE_OF_AYAK, 100000, 40, 100, 99, &rng); + ASSERT_INT_EQ("ayak num_hits", sr.num_hits, 1); + if (sr.total_damage > 0) { + ASSERT_INT_EQ("ayak magic drain = dmg", sr.magic_def_drain, sr.total_damage); + } + ASSERT_INT_EQ("ayak speed override", sr.attack_speed_override, 5); + + /* resolve non-spec weapon — empty result */ + rng = 9999; + sr = osrs_resolve_spec(ITEM_BARROWS_GLOVES, 100000, 50, 100, 99, &rng); + ASSERT_INT_EQ("non-weapon num_hits", sr.num_hits, 0); + ASSERT_INT_EQ("non-weapon damage", sr.total_damage, 0); +} + + +int main(void) { + printf("=== special attack tests (cross-referenced with osrs-dps-calc) ===\n\n"); + + /* spec costs */ + test_melee_spec_costs(); + test_ranged_spec_costs(); + test_magic_spec_costs(); + + /* accuracy multipliers */ + test_melee_spec_acc_multipliers(); + test_ranged_spec_acc_multipliers(); + test_magic_spec_acc_multiplier(); + + /* strength multipliers */ + test_melee_spec_str_multipliers(); + test_ranged_spec_str_multipliers(); + + /* blowpipe spec */ + test_blowpipe_spec_constants(); + test_blowpipe_spec_resolve(); + + /* special mechanics */ + test_dragon_claws_cascade(); + test_voidwaker_mechanics(); + test_vls_mechanics(); + test_statius_warhammer_mechanics(); + test_bgs_mechanics(); + test_zgs_mechanics(); + test_sgs_mechanics(); + test_ancient_gs_mechanics(); + test_dark_bow_mechanics(); + test_morrigans_bleed(); + test_volatile_staff_mechanics(); + test_double_hit_specs(); + test_granite_maul_mechanics(); + test_ballista_mechanics(); + test_crossbow_specs(); + + /* classification + metadata */ + test_melee_spec_two_handed(); + test_melee_spec_bonus_types(); + test_ranged_spec_hit_delays(); + test_spec_energy_checks(); + + /* integration: max hit + hit chance with spec multipliers */ + test_max_hit_with_spec_mult(); + test_hit_chance_with_spec_acc(); + + /* shared spec dispatch */ + test_spec_dispatch(); + + printf("\n=== results: %d/%d passed", tests_passed, tests_run); + if (tests_failed > 0) { + printf(", %d FAILED", tests_failed); + } + printf(" ===\n"); + + return tests_failed > 0 ? 1 : 0; +} diff --git a/ocean/osrs/tests/test_zulrah_human_commands.c b/ocean/osrs/tests/test_zulrah_human_commands.c new file mode 100644 index 0000000000..51ad470ba2 --- /dev/null +++ b/ocean/osrs/tests/test_zulrah_human_commands.c @@ -0,0 +1,102 @@ +/** + * @file test_zulrah_human_commands.c + * @brief tests for Zulrah encounter human command execution. + * + * BUILD: + * cc -std=c11 -O0 -g -I. -o /tmp/test_zulrah_human_commands \ + * ocean/osrs/tests/test_zulrah_human_commands.c -lm + * /tmp/test_zulrah_human_commands + */ + +#include +#include + +#include "ocean/osrs/encounters/encounter_zulrah.h" + +static int tests_run = 0; +static int tests_passed = 0; +static int tests_failed = 0; + +#define ASSERT_INT_EQ(label, actual, expected) do { \ + tests_run++; \ + int _actual = (actual); \ + int _expected = (expected); \ + if (_actual == _expected) { \ + tests_passed++; \ + } else { \ + tests_failed++; \ + printf(" FAIL: %s: got %d, expected %d\n", \ + (label), _actual, _expected); \ + } \ +} while (0) + +static void test_zulrah_human_equip_is_item_by_item(void) { + printf("--- zulrah human equip is item by item ---\n"); + + EncounterState* raw = zul_create(); + ZulrahState* state = (ZulrahState*)raw; + zul_reset(raw, 123); + + HumanInput input; + human_input_init(&input); + input.enabled = 1; + + uint8_t old_head = state->player.equipped[GEAR_SLOT_HEAD]; + human_input_queue_equip_inventory_item( + &input, 0, ITEM_TOXIC_BLOWPIPE, GEAR_SLOT_WEAPON); + + zul_step_human_commands(raw, &input); + + ASSERT_INT_EQ("weapon changed to clicked item", + state->player.equipped[GEAR_SLOT_WEAPON], ITEM_TOXIC_BLOWPIPE); + ASSERT_INT_EQ("head slot did not snap to range preset", + state->player.equipped[GEAR_SLOT_HEAD], old_head); + ASSERT_INT_EQ("queued command drained", input.commands.count, 0); + ASSERT_INT_EQ("human gear style follows weapon", state->player_gear, ZUL_GEAR_RANGE); + + human_input_destroy(&input); + zul_destroy(raw); +} + +static void test_zulrah_human_attack_uses_queued_weapon_style(void) { + printf("--- zulrah human attack uses queued weapon style ---\n"); + + EncounterState* raw = zul_create(); + ZulrahState* state = (ZulrahState*)raw; + zul_reset(raw, 123); + + HumanInput input; + human_input_init(&input); + int actions[ZUL_NUM_ACTION_HEADS]; + + human_input_queue_equip_inventory_item( + &input, 0, ITEM_TOXIC_BLOWPIPE, GEAR_SLOT_WEAPON); + human_input_queue_attack_npc(&input, -1); + zul_translate_human_commands(&input, actions, state); + ASSERT_INT_EQ("queued ranged weapon selects range attack", + actions[ZUL_HEAD_ATTACK], ZUL_ATK_RANGE); + + human_input_clear_pending(&input); + human_input_queue_equip_inventory_item( + &input, 0, ITEM_TRIDENT_OF_SWAMP, GEAR_SLOT_WEAPON); + human_input_queue_attack_npc(&input, -1); + zul_translate_human_commands(&input, actions, state); + ASSERT_INT_EQ("queued magic weapon selects mage attack", + actions[ZUL_HEAD_ATTACK], ZUL_ATK_MAGE); + + human_input_destroy(&input); + zul_destroy(raw); +} + +int main(void) { + test_zulrah_human_equip_is_item_by_item(); + test_zulrah_human_attack_uses_queued_weapon_style(); + + printf("\n%d/%d tests passed", tests_passed, tests_run); + if (tests_failed > 0) { + printf(" (%d failed)\n", tests_failed); + return 1; + } + printf("\n"); + return 0; +} diff --git a/ocean/osrs/tools/README.md b/ocean/osrs/tools/README.md new file mode 100644 index 0000000000..173d35620a --- /dev/null +++ b/ocean/osrs/tools/README.md @@ -0,0 +1,176 @@ +# OSRS asset pipeline + +tools for generating game data (stats, 3D models, animations, effects) from the +OSRS cache and RuneLite gameval constants. + +## how OSRS assets work + +- **cache**: contains 3D models, textures, NPC definitions (with idle/walk anims), item + definitions, spotanim/GFX definitions (projectile models + animations). the cache does + NOT contain attack/death animations — those are server-driven. +- **gameval**: RuneLite's deob client has Java constant files that name every animation, + NPC, spotanim, and item ID in the game. these are at + `.refs/osrs-client-deob/runelite-api/src/main/java/net/runelite/api/gameval/`. + attack anims and projectile GFX IDs come from here. +- **monsters.json / equipment.json**: wiki-sourced stat data from osrs-dps-calc at + `.refs/osrs-dps-calc/cdn/json/`. used for combat stats, not visuals. + +## adding a new NPC — full pipeline + +### 1. stats (combat data) + +add the NPC ID to `tools/monsters_manifest.json`: +```json +{"index": "MON_MY_NPC", "npc_id": 12345, "comment": "My NPC"} +``` + +regenerate: +```bash +python tools/generate_monsters.py +``` + +this produces `osrs_monsters_generated.h` with hp, def, att, max_hit, etc. + +### 2. discover visual assets + +find the gameval constant names for the NPC's animations and projectiles: +```bash +uv run python tools/discover_npc_assets.py --npc-id 12345 +``` + +this outputs categorized animations (attack, death, idle, walk) and spotanims, +plus a suggested manifest visual section you can copy-paste. + +if the NPC name in gameval is non-obvious (e.g. "SNAKEBOSS" for Zulrah), try: +```bash +uv run python tools/discover_npc_assets.py --search "zulrah" +``` + +this searches both gameval constant names and javadoc comments for in-game names. + +### 3. add visual section to manifest + +add the `visual` section to the manifest entry with the gameval names you picked: +```json +{ + "index": "MON_MY_NPC", "npc_id": 12345, "comment": "My NPC", + "visual": { + "group": "my_encounter", + "attack_anims": ["MYNPC_ATTACK_MAGIC", "MYNPC_ATTACK_MELEE"], + "extra_anims": ["MYNPC_DEATH", "MYNPC_SPAWN"], + "spotanims": ["MYNPC_PROJECTILE", "MYNPC_IMPACT"] + } +} +``` + +fields: +- `group`: encounter name. NPCs in the same group share .models + .anims binaries. +- `attack_anims`: gameval AnimationID names for attack animations. first is the default. +- `extra_anims`: other animations to export (death, spawn, defend, etc.). + idle + walk come from the cache NPC config automatically — don't list them here. +- `spotanims`: gameval SpotanimID names for projectile/effect GFX. + +### 4. export visual assets + +```bash +cd ocean/osrs +uv run python tools/export_encounter_npcs.py \ + --group my_encounter \ + --modern-cache ../../../.refs/osrs-cache-modern \ + --output-dir data +``` + +this produces: +- `data/my_encounter.models` — MDL2 binary with all NPC + spotanim 3D meshes +- `data/my_encounter.anims` — ANIM binary with all animation sequences +- `data/npc_models_my_encounter.h` — C header with NPC model mappings + anim/GFX defines + +### 5. include in your encounter + +in your encounter header: +```c +#include "../data/npc_models_my_encounter.h" +``` + +the header provides: +- `NPC_MODEL_MAP__GEN[]` — NPC ID to model/anim mapping array +- `_GEN_ANIM_*` — animation ID defines +- `_GEN_GFX_*_MODEL` / `_GEN_GFX_*_ANIM` — spotanim model/anim defines + +### 6. build + +```bash +cd ocean/osrs && make visual # visual binary +cd ../../.. && ./build.sh osrs_my_encounter # training env +``` + +## manifest format reference + +each entry in `tools/monsters_manifest.json`: + +| field | required | description | +|---|---|---| +| `index` | yes | C enum name (e.g. MON_MY_NPC) | +| `npc_id` | yes | OSRS NPC ID | +| `version` | no | NPC version string for monsters.json lookup | +| `comment` | no | human-readable label | +| `manual_stats` | no | override stats when NPC not in monsters.json | +| `visual` | no | visual asset section (see below) | + +visual section fields: + +| field | description | +|---|---| +| `group` | encounter group name for output files | +| `attack_anims` | list of gameval AnimationID constant names | +| `extra_anims` | list of gameval AnimationID names (death, spawn, etc.) | +| `spotanims` | list of gameval SpotanimID constant names | + +## gameval files + +location: `.refs/osrs-client-deob/runelite-api/src/main/java/net/runelite/api/gameval/` + +| file | what it contains | +|---|---| +| `NpcID.java` | NPC ID constants (e.g. SNAKEBOSS_BOSS_RANGED = 2042) | +| `AnimationID.java` | animation sequence IDs (e.g. SNAKEBOSS_ATTACK_ACIDX3 = 5068) | +| `SpotanimID.java` | GFX/spotanim IDs (e.g. SNAKEBOSS_ORB = 1044) | +| `ItemID.java` | item IDs | +| `ObjectID.java` | game object IDs | + +naming conventions vary by content area: +- inferno: `INFERNO_*`, `JAL*`, `JALTOKJAD_*`, `ZUK_*` +- zulrah: `SNAKEBOSS_*` +- fight caves: `FIGHT_CAVE_*`, `TZHAAR_*` + +use `discover_npc_assets.py --search ` to find the right prefix. + +## output files + +| file | format | description | +|---|---|---| +| `data/.models` | MDL2 binary | 3D meshes for all NPCs + spotanims in group | +| `data/.anims` | ANIM binary | animation sequences referenced by the group | +| `data/npc_models_.h` | C header | NPC model map + anim/GFX defines | +| `osrs_monsters_generated.h` | C header | NPC combat stats (from generate_monsters.py) | +| `osrs_items_generated.h` | C header | item stats (from generate_items.py) | + +the renderer loads .models + .anims at startup and uses the C header mappings +to look up which model/anim to use for a given NPC ID. + +## script reference + +| script | what it does | +|---|---| +| `tools/generate_monsters.py` | monsters.json → osrs_monsters_generated.h (stats) | +| `tools/generate_items.py` | equipment.json → osrs_items_generated.h (stats) | +| `tools/export_encounter_npcs.py` | manifest + gameval + cache → .models + .anims + header | +| `tools/discover_npc_assets.py` | gameval lookup helper for manifest authors | +| `tools/gameval_parser.py` | shared module: parse gameval Java constants | +| `scripts/export_inferno_npcs.py` | inferno-specific export (legacy, predates manifest system) | +| `scripts/export_models.py` | equipment model export (player body + worn gear) | +| `scripts/export_animations.py` | shared animation export library | +| `scripts/export_sprites.py` | GUI sprite export (hit splats, prayer icons) | +| `scripts/export_collision_map.py` | collision data export | +| `scripts/export_terrain.py` | terrain geometry export | +| `scripts/export_spotanims.py` | spotanim query tool (cache lookup, no auto-output) | diff --git a/ocean/osrs/tools/discover_npc_assets.py b/ocean/osrs/tools/discover_npc_assets.py new file mode 100644 index 0000000000..1b2f9e95f3 --- /dev/null +++ b/ocean/osrs/tools/discover_npc_assets.py @@ -0,0 +1,270 @@ +"""Discover gameval visual asset constants for OSRS NPCs. + +Standalone helper for manifest authors. Scans RuneLite gameval Java constants +(AnimationID, SpotanimID) to find all animations and spotanims associated with +an NPC, categorizes them by purpose, and prints a suggested manifest visual +section. + +Usage: + cd ocean/osrs + uv run python tools/discover_npc_assets.py --npc-id 2042 + uv run python tools/discover_npc_assets.py --npc-ids 2042,2043,2044 + uv run python tools/discover_npc_assets.py --search zulrah +""" + +import argparse +import json +import re +import sys +from collections import defaultdict +from pathlib import Path + +from gameval_parser import DEFAULT_GAMEVAL_DIR, load_gameval, reverse_lookup + +# regex to extract javadoc comment + constant pairs from gameval Java files +# matches: /** In-game Name */\n\tpublic static final int CONST_NAME = 123; +_COMMENTED_CONST_PATTERN = re.compile( + r"/\*\*\s*\n\s*\*\s*(.+?)\s*\n\s*\*/\s*\n\s*public\s+static\s+final\s+int\s+(\w+)\s*=\s*(\d+)\s*;", +) + +# -- animation suffix categorization -- + +# suffixes that indicate attack animations +_ATTACK_SUFFIXES = ("_ATTACK", "_ACIDX") + +# suffixes auto-loaded from cache (idle/walk) -- no need in manifest +_AUTO_SUFFIXES = ("_IDLE", "_READY", "_WALK", "_RUN") + +# suffixes to skip entirely (pets, cosmetics, unrelated) +_SKIP_SUFFIXES = ( + "_PET_", + "_CHATHEAD", + "_FLETCHING", + "_ORNAMENT", + "_SLICEEEL", +) + +# known NPC type suffixes to strip when extracting prefix +_NPC_TYPE_SUFFIXES = ( + "_BOSS_RANGED", + "_BOSS_MELEE", + "_BOSS_MAGIC", + "_BOSS_RANGE", + "_BOSS_MAGE", + "_BOSS_", + "_CREATURE_", + "_MASTER_", + "_MASTER", + "_MINION_MELEE", + "_MINION_MAGIC", + "_MINION_RANGE", + "_MINION_DYING", + "_MINION_", + "_HIGHPRIEST", + "_PRIEST_", + "_PRIEST", + "_OGRE_", + "_GNOME_", + "_GNOME_VICTIM", + "_FISHERMAN", + "_TYRASGUARD_", + "_SPECTATOR", + "_FISHINGSPOT", + "_FISHINGSPOT_FAKE", +) + + +def _extract_prefix(npc_name: str, anim_ids: dict[str, int]) -> str: + """Extract the naming prefix from a gameval NPC constant name. + + Strategy: + 1. Try stripping known NPC type suffixes. + 2. Fallback: try progressively shorter underscore-delimited prefixes + until one matches >3 animation constants. + """ + # strategy 1: strip known type suffixes + for suffix in _NPC_TYPE_SUFFIXES: + if npc_name.endswith(suffix) or suffix in npc_name: + candidate = npc_name.split(suffix)[0] + if candidate and _count_anim_matches(candidate, anim_ids) > 3: + return candidate + + # strategy 2: progressively shorter prefixes + parts = npc_name.split("_") + for length in range(len(parts) - 1, 0, -1): + candidate = "_".join(parts[:length]) + if _count_anim_matches(candidate, anim_ids) > 3: + return candidate + + # last resort: use the full name + return npc_name + + +def _count_anim_matches(prefix: str, anim_ids: dict[str, int]) -> int: + """Count how many animation constants start with prefix_.""" + prefix_with_sep = prefix + "_" + return sum(1 for name in anim_ids if name.startswith(prefix_with_sep)) + + +def _categorize_suffix(name: str, prefix: str) -> str: + """Categorize an animation constant by its suffix relative to the prefix.""" + suffix = name[len(prefix):] + + for s in _SKIP_SUFFIXES: + if s in suffix: + return "skip" + + for s in _ATTACK_SUFFIXES: + if s in suffix: + return "attack" + + for s in _AUTO_SUFFIXES: + if suffix.startswith(s) or suffix == s: + return "auto" + + if suffix == "_DEATH": + return "death" + + return "extra" + + +def _discover_npc( + npc_id: int, + anim_ids: dict[str, int], + npc_ids: dict[str, int], + spotanim_ids: dict[str, int], +) -> None: + """Discover and print visual asset info for one NPC ID.""" + npc_name = reverse_lookup(npc_ids, npc_id) + if npc_name is None: + print(f" NPC ID {npc_id}: not found in gameval NpcID constants") + return + + prefix = _extract_prefix(npc_name, anim_ids) + + print(f"\n NPC {npc_id}: {npc_name}") + print(f" prefix: {prefix}") + + # scan animations + prefix_with_sep = prefix + "_" + categorized: dict[str, list[tuple[str, int]]] = defaultdict(list) + for name, value in sorted(anim_ids.items()): + if name.startswith(prefix_with_sep): + category = _categorize_suffix(name, prefix) + categorized[category].append((name, value)) + + # print categorized animations + category_order = ["attack", "death", "extra", "auto", "skip"] + for category in category_order: + entries = categorized.get(category, []) + if not entries: + continue + label = { + "attack": "ATTACK anims", + "death": "DEATH anims", + "extra": "EXTRA anims (spawn, defend, etc.)", + "auto": "AUTO anims (idle/walk, from cache)", + "skip": "SKIP (pet, cosmetic, unrelated)", + }[category] + print(f"\n {label}:") + for name, value in entries: + print(f" {name} = {value}") + + # scan spotanims + spotanims = [ + (name, value) + for name, value in sorted(spotanim_ids.items()) + if name.startswith(prefix_with_sep) + ] + if spotanims: + print(f"\n SPOTANIMS:") + for name, value in spotanims: + print(f" {name} = {value}") + + # build suggested manifest visual section + attack_anim_names = [name for name, _ in categorized.get("attack", [])] + death_anim_names = [name for name, _ in categorized.get("death", [])] + extra_anim_names = [name for name, _ in categorized.get("extra", [])] + spotanim_names = [name for name, _ in spotanims] + + suggested = { + "group": "REPLACE_ME", + "attack_anims": attack_anim_names, + "extra_anims": death_anim_names + extra_anim_names, + "spotanims": spotanim_names, + } + + print(f"\n suggested manifest visual section:") + print(f" {json.dumps(suggested, indent=4)}") + + +def _load_npc_ingame_names( + gameval_dir: Path = DEFAULT_GAMEVAL_DIR, +) -> dict[str, str]: + """Parse NpcID.java to extract in-game names from javadoc comments. + + Returns {GAMEVAL_CONST_NAME: "In-Game Name"} for constants that have comments. + """ + npc_java = (Path(gameval_dir) / "NpcID.java").read_text() + return { + const_name: ingame_name + for ingame_name, const_name, _ in _COMMENTED_CONST_PATTERN.findall(npc_java) + } + + +def _search_npcs( + query: str, npc_ids: dict[str, int], ingame_names: dict[str, str] +) -> list[tuple[str, int, str]]: + """Search NPC names case-insensitively, matching both gameval and in-game names.""" + query_upper = query.upper() + results = [] + for name, value in sorted(npc_ids.items(), key=lambda x: x[1]): + ingame = ingame_names.get(name, "") + if query_upper in name or query_upper in ingame.upper(): + results.append((name, value, ingame)) + return results + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Discover gameval visual asset constants for OSRS NPCs." + ) + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument("--npc-id", type=int, help="single NPC ID to discover") + group.add_argument( + "--npc-ids", type=str, help="comma-separated NPC IDs (e.g. 2042,2043,2044)" + ) + group.add_argument( + "--search", type=str, help="search NPC names case-insensitively" + ) + + args = parser.parse_args() + anim_ids, npc_ids, spotanim_ids = load_gameval() + + if args.search is not None: + ingame_names = _load_npc_ingame_names() + matches = _search_npcs(args.search, npc_ids, ingame_names) + if not matches: + print(f"no NPCs matching '{args.search}'") + sys.exit(1) + print(f"NPCs matching '{args.search}' ({len(matches)} results):\n") + for name, value, ingame in matches: + label = f" ({ingame})" if ingame else "" + print(f" {value:>6} {name}{label}") + print( + f"\nuse --npc-id or --npc-ids to discover visual assets for specific NPCs" + ) + return + + if args.npc_id is not None: + npc_id_list = [args.npc_id] + else: + npc_id_list = [int(x.strip()) for x in args.npc_ids.split(",")] + + print(f"discovering visual assets for {len(npc_id_list)} NPC(s)...") + for npc_id in npc_id_list: + _discover_npc(npc_id, anim_ids, npc_ids, spotanim_ids) + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/tools/export_encounter_npcs.py b/ocean/osrs/tools/export_encounter_npcs.py new file mode 100644 index 0000000000..a8968e30d0 --- /dev/null +++ b/ocean/osrs/tools/export_encounter_npcs.py @@ -0,0 +1,644 @@ +"""Manifest-driven visual export for encounter NPCs. + +Reads monster manifest visual sections + gameval constants + OSRS cache to +produce .models and .anims binaries plus a C header for encounter NPC rendering. +Replaces hardcoded per-encounter export scripts with a single manifest-driven tool. + +Usage: + uv run python tools/export_encounter_npcs.py \ + --group zulrah \ + --modern-cache ../../../.refs/osrs-cache-modern \ + --manifest tools/monsters_manifest.json \ + --output-dir data +""" + +import argparse +import io +import json +import sys +from dataclasses import dataclass, field +from pathlib import Path + +# add scripts dir for existing infrastructure +sys.path.insert(0, str(Path(__file__).parent.parent / "scripts")) +from modern_cache_reader import ( + ModernCacheReader, + read_i32, + read_string, + read_u8, + read_u16, + read_u24, + read_u32, +) +from modern_cache_reader import parse_sequence as parse_modern_sequence +from export_models import ( + ModelData, + _merge_models, + decode_model, + load_model_modern, + write_models_binary, +) +from export_animations import ( + FrameDef, + SequenceDef, + _parse_normal_frame, + load_modern_framebases, + write_animations_binary, +) + +# gameval parser from tools dir +sys.path.insert(0, str(Path(__file__).parent)) +from gameval_parser import load_gameval, resolve_names + +# modern cache layout +MODERN_NPC_CONFIG_GROUP = 9 +MODERN_SPOTANIM_CONFIG_GROUP = 13 +MODERN_SEQ_CONFIG_GROUP = 12 +MODERN_FRAME_INDEX = 0 +MODERN_FRAMEBASE_INDEX = 1 + +@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 + death_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) + 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.""" + 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) + elif opcode == 5: + 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) + elif opcode == 16: + d.turn_cw_anim = read_u16(buf) + elif opcode == 17: + 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) + elif 30 <= opcode <= 34: + read_string(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): + 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) + elif opcode == 93: + pass + 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 + elif opcode == 100: + read_u8(buf) + elif opcode == 101: + read_u8(buf) + elif opcode == 102: + 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): + 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) + 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) + elif opcode == 106: + read_u16(buf) + read_u16(buf) + length = read_u8(buf) + for _ in range(length + 1): + read_u16(buf) + elif opcode == 107: + pass + elif opcode == 108: + pass + elif opcode == 109: + pass + elif opcode == 111: + pass + elif opcode == 114: + read_u16(buf) + elif opcode == 115: + read_u16(buf) + read_u16(buf) + read_u16(buf) + read_u16(buf) + elif opcode == 116: + read_u16(buf) + elif opcode == 117: + read_u16(buf) + read_u16(buf) + read_u16(buf) + elif opcode == 118: + read_u16(buf) + read_u16(buf) + read_u16(buf) + length = read_u8(buf) + for _ in range(length + 1): + read_u16(buf) + elif opcode == 122: + pass + elif opcode == 123: + pass + elif opcode == 124: + read_u16(buf) + elif opcode == 125: + read_u8(buf) + elif opcode == 126: + read_u16(buf) + elif opcode == 128: + read_u8(buf) + elif opcode == 129: + pass + elif opcode == 130: + pass + elif opcode == 145: + pass + elif opcode == 146: + read_u16(buf) + elif opcode == 147: + pass + elif opcode == 249: + count_val = read_u8(buf) + for _ in range(count_val): + is_string = read_u8(buf) + read_u24(buf) + 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.""" + 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: + """Manifest-driven export of encounter NPC models + animations.""" + parser = argparse.ArgumentParser( + description="export encounter NPC models + animations from modern cache via manifest", + ) + parser.add_argument( + "--group", required=True, + help="encounter group name to export (matches visual.group in manifest)", + ) + parser.add_argument( + "--modern-cache", type=Path, required=True, + help="path to modern OpenRS2 flat-file cache", + ) + parser.add_argument( + "--manifest", type=Path, default=Path("tools/monsters_manifest.json"), + help="path to monsters manifest JSON", + ) + parser.add_argument( + "--output-dir", type=Path, default=Path("data"), + help="output directory for generated files", + ) + args = parser.parse_args() + group = args.group + group_upper = group.upper() + print("loading gameval constants...") + anim_ids, npc_ids, spotanim_ids = load_gameval() + print(f" {len(anim_ids)} anims, {len(npc_ids)} npcs, {len(spotanim_ids)} spotanims") + print(f"\nloading manifest {args.manifest}, filtering group={group!r}...") + with open(args.manifest) as f: + manifest = json.load(f) + + entries = [e for e in manifest if e.get("visual", {}).get("group") == group] + if not entries: + print(f"error: no manifest entries with visual.group={group!r}", file=sys.stderr) + sys.exit(1) + print(f" {len(entries)} NPCs in group {group!r}") + reader = ModernCacheReader(args.modern_cache) + output_dir = args.output_dir + output_dir.mkdir(parents=True, exist_ok=True) + + print("\nreading NPC definitions from modern cache (index 2, group 9)...") + npc_files = reader.read_group(2, MODERN_NPC_CONFIG_GROUP) + + npc_defs: dict[int, NpcDef] = {} + npc_attack_anims: dict[int, list[int]] = {} + npc_extra_anims: dict[int, list[int]] = {} + npc_comments: dict[int, str] = {} + all_anim_ids: set[int] = set() + all_spotanim_names: set[str] = set() + anim_name_to_id: dict[str, int] = {} + + for entry in entries: + npc_id = entry["npc_id"] + vis = entry["visual"] + comment = entry.get("comment", entry.get("index", "")) + + if npc_id not in npc_files: + print(f" NPC {npc_id} ({comment}): NOT FOUND in cache", file=sys.stderr) + sys.exit(1) + + npc = parse_modern_npc_def(npc_id, npc_files[npc_id]) + npc_defs[npc_id] = npc + npc_comments[npc_id] = comment + + # resolve attack anim names -> IDs + attack_names = vis.get("attack_anims", []) + if attack_names: + attack_ids = resolve_names(attack_names, anim_ids, context=f"NPC {npc_id} attack_anims") + else: + attack_ids = [] + npc_attack_anims[npc_id] = attack_ids + for name, aid in zip(attack_names, attack_ids): + anim_name_to_id[name] = aid + + # resolve extra anim names -> IDs + extra_names = vis.get("extra_anims", []) + if extra_names: + extra_ids = resolve_names(extra_names, anim_ids, context=f"NPC {npc_id} extra_anims") + else: + extra_ids = [] + npc_extra_anims[npc_id] = extra_ids + for name, eid in zip(extra_names, extra_ids): + anim_name_to_id[name] = eid + + # collect all anim IDs: idle + walk from cache, attack + extra from gameval + for anim_id in [npc.idle_anim, npc.walk_anim]: + if anim_id >= 0: + all_anim_ids.add(anim_id) + all_anim_ids.update(attack_ids) + all_anim_ids.update(extra_ids) + + # collect spotanim names + all_spotanim_names.update(vis.get("spotanims", [])) + + print(f" NPC {npc_id} ({comment}): models={npc.model_ids}, " + f"idle={npc.idle_anim}, walk={npc.walk_anim}, " + f"attacks={attack_ids}, extras={extra_ids}") + print("\nexporting NPC models...") + all_models: list[ModelData] = [] + + 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) + + if npc.recolor_src: + apply_recolors(merged, npc.recolor_src, npc.recolor_dst) + apply_scale(merged, npc.width_scale, npc.height_scale) + + 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") + spotanim_defs: dict[int, SpotAnimDef] = {} + spotanim_name_for_id: dict[int, str] = {} + + if all_spotanim_names: + print(f"\nresolving {len(all_spotanim_names)} unique spotanim names...") + spotanim_files = reader.read_group(2, MODERN_SPOTANIM_CONFIG_GROUP) + + for name in sorted(all_spotanim_names): + gfx_ids = resolve_names([name], spotanim_ids, context="spotanims") + gfx_id = gfx_ids[0] + spotanim_name_for_id[gfx_id] = name + + if gfx_id not in spotanim_files: + print(f" GFX {gfx_id} ({name}): NOT FOUND in cache", file=sys.stderr) + sys.exit(1) + + sa = parse_modern_spotanim(gfx_id, spotanim_files[gfx_id]) + spotanim_defs[gfx_id] = sa + print(f" GFX {gfx_id} ({name}): model={sa.model_id}, seq={sa.seq_id}") + + if sa.seq_id >= 0: + all_anim_ids.add(sa.seq_id) + + # export GFX models + print("\nexporting GFX models...") + 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 + print(f" GFX {gfx_id} model {sa.model_id}: {md.vertex_count} verts") + exported_gfx_models.add(md.model_id) + all_models.append(md) + models_path = output_dir / f"{group}.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}") + print("\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") + + # 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 / f"{group}.anims" + available_seqs = all_anim_ids & set(sequences.keys()) + write_animations_binary(anims_path, framebases, all_frames, sequences, available_seqs) + anims_size = anims_path.stat().st_size + print(f"wrote {len(available_seqs)} sequences ({anims_size:,} bytes) to {anims_path}") + prefix = group_upper[:3] + "_GEN" + + header_path = output_dir / f"npc_models_{group}.h" + guard = f"NPC_MODELS_{group_upper}_H" + + print(f"\nwriting C header {header_path}...") + + with open(header_path, "w") as f: + f.write("/* generated by tools/export_encounter_npcs.py -- do not edit */\n") + f.write(f"#ifndef {guard}\n") + f.write(f"#define {guard}\n\n") + f.write('#include \n') + f.write('#include "npc_models.h" /* for NpcModelMapping typedef */\n\n') + + # NPC model mapping array + f.write(f"static const NpcModelMapping NPC_MODEL_MAP_{group_upper}_GEN[] = {{\n") + 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 + attacks = npc_attack_anims.get(npc_id, []) + attack = attacks[0] if attacks else 0xFFFF + walk = npc.walk_anim if npc.walk_anim >= 0 else 0xFFFF + comment = npc_comments.get(npc_id, npc.name) + f.write(f" {{{npc_id}, 0x{synth_model:X}, {idle}, {attack}, {walk}}}, /* {comment} */\n") + f.write("};\n\n") + + # animation ID defines + if anim_name_to_id: + f.write(f"/* {group} animation IDs */\n") + for name, aid in sorted(anim_name_to_id.items(), key=lambda x: x[1]): + f.write(f"#define {prefix}_ANIM_{name} {aid}\n") + f.write("\n") + + # spotanim GFX model + animation defines + if spotanim_defs: + f.write(f"/* {group} spotanim GFX model + animation IDs */\n") + for gfx_id, sa in sorted(spotanim_defs.items()): + gv_name = spotanim_name_for_id.get(gfx_id, f"GFX_{gfx_id}") + if sa.recolor_src: + emit_model_id = 0xD0000 | gfx_id + else: + emit_model_id = sa.model_id + f.write(f"#define {prefix}_GFX_{gfx_id}_MODEL {emit_model_id} /* {gv_name} */\n") + if sa.seq_id >= 0: + f.write(f"#define {prefix}_GFX_{gfx_id}_ANIM {sa.seq_id}\n") + f.write("\n") + + f.write(f"#endif /* {guard} */\n") + + print(f"wrote {header_path}") + print("\ndone.") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/tools/gameval_parser.py b/ocean/osrs/tools/gameval_parser.py new file mode 100644 index 0000000000..4676612601 --- /dev/null +++ b/ocean/osrs/tools/gameval_parser.py @@ -0,0 +1,82 @@ +"""Parse RuneLite gameval Java constant files into Python dicts. + +RuneLite's gameval directory contains auto-generated Java files (AnimationID.java, +NpcID.java, SpotanimID.java, etc.) that define integer constants for OSRS game +entities. Each file has lines like: + + public static final int SNAKEBOSS_ATTACK_ACIDX3 = 5068; + +This module extracts those constants into {name: int} dicts for use in Python +tools that need to reference OSRS IDs by their gameval names. +""" + +import re +from pathlib import Path + +_CONST_PATTERN = re.compile( + r"public\s+static\s+final\s+int\s+(\w+)\s*=\s*(\d+)\s*;" +) + +DEFAULT_GAMEVAL_DIR = ( + Path(__file__).resolve().parents[3] + / ".refs" + / "osrs-client-deob" + / "runelite-api" + / "src" + / "main" + / "java" + / "net" + / "runelite" + / "api" + / "gameval" +) + + +def parse_gameval_file(path: Path) -> dict[str, int]: + """Parse a single gameval Java file into a {CONSTANT_NAME: int_value} dict.""" + text = Path(path).read_text() + return {name: int(value) for name, value in _CONST_PATTERN.findall(text)} + + +def load_gameval( + gameval_dir: Path = DEFAULT_GAMEVAL_DIR, +) -> tuple[dict[str, int], dict[str, int], dict[str, int]]: + """Load AnimationID, NpcID, and SpotanimID constants. + + Returns: + (anim_ids, npc_ids, spotanim_ids) -- three dicts mapping constant names + to their integer values. + """ + gameval_dir = Path(gameval_dir) + anim_ids = parse_gameval_file(gameval_dir / "AnimationID.java") + npc_ids = parse_gameval_file(gameval_dir / "NpcID.java") + spotanim_ids = parse_gameval_file(gameval_dir / "SpotanimID.java") + return anim_ids, npc_ids, spotanim_ids + + +def resolve_names( + names: list[str], lookup: dict[str, int], context: str = "" +) -> list[int]: + """Resolve a list of gameval constant names to their integer IDs. + + Raises: + KeyError: if any name is not found in the lookup dict. + """ + result = [] + for name in names: + if name not in lookup: + prefix = f"[{context}] " if context else "" + raise KeyError(f"{prefix}gameval constant not found: {name!r}") + result.append(lookup[name]) + return result + + +def reverse_lookup(lookup: dict[str, int], value: int) -> str | None: + """Find the gameval constant name for a given integer ID. + + Returns None if no match is found. + """ + for name, v in lookup.items(): + if v == value: + return name + return None diff --git a/ocean/osrs/tools/generate_items.py b/ocean/osrs/tools/generate_items.py new file mode 100644 index 0000000000..9284a475e6 --- /dev/null +++ b/ocean/osrs/tools/generate_items.py @@ -0,0 +1,426 @@ +"""Generate osrs_items_generated.h from equipment.json reference data. + +Reads the osrs-dps-calc equipment.json (wiki-sourced item stats) and a manifest +of which items to include, then outputs a C header with the full Item struct +database. This makes encounters data-driven: add an OSRS item ID to the manifest +and all stats are auto-populated from wiki data. + +Usage: + cd pufferlib-metal + python ocean/osrs/tools/generate_items.py + + # with custom paths: + python ocean/osrs/tools/generate_items.py \ + --json .refs/osrs-dps-calc/cdn/json/equipment.json \ + --manifest ocean/osrs/tools/items_manifest.json \ + --output ocean/osrs/osrs_items_generated.h + +Input: + equipment.json — wiki-sourced item stats from osrs-dps-calc + items_manifest.json — our item list: index name, OSRS ID, manual overrides + +Output: + osrs_items_generated.h — drop-in replacement for osrs_items.h + +Stat field mapping (equipment.json -> Item struct): + id -> item_id + name -> name (truncated to 31 chars) + slot -> slot (string -> EquipmentSlot enum) + speed -> attack_speed (weapons only, 0 for non-weapons) + (manual) -> attack_range (not in JSON, must be in manifest) + offensive.stab -> attack_stab + offensive.slash -> attack_slash + offensive.crush -> attack_crush + offensive.magic -> attack_magic + offensive.ranged-> attack_ranged + defensive.stab -> defence_stab + defensive.slash -> defence_slash + defensive.crush -> defence_crush + defensive.magic -> defence_magic + defensive.ranged-> defence_ranged + bonuses.str -> melee_strength + bonuses.ranged_str -> ranged_strength + bonuses.magic_str -> magic_damage + bonuses.prayer -> prayer + (manifest) -> effect_mask +""" + +import argparse +import json +import re +import sys +from pathlib import Path + +SLOT_MAP = { + "head": "SLOT_HEAD", + "cape": "SLOT_CAPE", + "neck": "SLOT_NECK", + "weapon": "SLOT_WEAPON", + "body": "SLOT_BODY", + "shield": "SLOT_SHIELD", + "legs": "SLOT_LEGS", + "hands": "SLOT_HANDS", + "feet": "SLOT_FEET", + "ring": "SLOT_RING", + "ammo": "SLOT_AMMO", +} + +# category -> default attack_range for weapons missing manual override. +# these are common OSRS ranges, but the manifest should override when needed. +CATEGORY_RANGE_DEFAULTS = { + "Bow": 10, + "Crossbow": 7, + "Thrown": 4, + "Chinchompas": 9, + "Staff": 10, + "Powered Staff": 10, + "Bladed Staff": 10, + "Polestaff": 10, + "Salamander": 10, + "Gun": 8, + "Blaster": 8, +} + +EFFECT_TAG_MAP = { + "TWISTED_BOW": "OSRS_ITEM_EFFECT_TWISTED_BOW", + "VIRTUS_PIECE": "OSRS_ITEM_EFFECT_VIRTUS_PIECE", + "CONFLICTION": "OSRS_ITEM_EFFECT_CONFLICTION", + "SANG_HEAL": "OSRS_ITEM_EFFECT_SANG_HEAL", + "RECOIL_RING": "OSRS_ITEM_EFFECT_RECOIL_RING", + "LIGHTBEARER": "OSRS_ITEM_EFFECT_LIGHTBEARER", + "DHAROK_PIECE": "OSRS_ITEM_EFFECT_DHAROK_PIECE", + "ELYSIAN": "OSRS_ITEM_EFFECT_ELYSIAN", +} + + +def load_equipment_json(path: str) -> dict[int, dict]: + """Load equipment.json and index by OSRS item ID.""" + json_path = Path(path) + if not json_path.exists(): + raise FileNotFoundError( + f"{json_path} not found. pass --json pointing at osrs-dps-calc/cdn/json/equipment.json" + ) + with open(json_path) as f: + items = json.load(f) + by_id: dict[int, list[dict]] = {} + for item in items: + item_id = item["id"] + if item_id not in by_id: + by_id[item_id] = [] + by_id[item_id].append(item) + return by_id + + +def load_manifest(path: str) -> list[dict]: + """Load the item manifest. + + Each entry: {"index": "ITEM_WHIP", "item_id": 4151, "version": "", "attack_range": 1, "comment": "..."} + version and attack_range are optional. + """ + with open(path) as f: + return json.load(f) + + +def extract_manifest_from_source(items_h_path: str) -> list[dict]: + """Extract item manifest from existing osrs_items.h (bootstrap helper). + + Parses the C source to get index names, OSRS IDs, attack_range values, + and item names. Outputs a manifest list for items_manifest.json. + """ + with open(items_h_path) as f: + content = f.read() + + pattern = ( + r"\[(ITEM_\w+)\]\s*=\s*\{" + r"[^}]*\.item_id\s*=\s*(\d+)" + r"[^}]*\.name\s*=\s*\"([^\"]+)\"" + r"[^}]*\.attack_speed\s*=\s*(\d+)" + r"[^}]*\.attack_range\s*=\s*(\d+)" + ) + entries = [] + for match in re.finditer(pattern, content): + idx_name, item_id, name, speed, attack_range = match.groups() + entry = { + "index": idx_name, + "item_id": int(item_id), + "attack_range": int(attack_range), + "comment": name, + } + entries.append(entry) + return entries + + +def find_item_in_json( + by_id: dict[int, list[dict]], item_id: int, version: str = "" +) -> dict | None: + """Find an item in equipment.json by ID and optional version.""" + candidates = by_id.get(item_id, []) + if not candidates: + return None + if version: + for c in candidates: + if c.get("version", "") == version: + return c + # default: prefer unversioned, else first + for c in candidates: + if not c.get("version", ""): + return c + return candidates[0] + + +def generate_header( + manifest: list[dict], + by_id: dict[int, list[dict]], +) -> str: + """Generate the C header content.""" + lines = [] + lines.append("/**") + lines.append( + " * @file osrs_items_generated.h" + ) + lines.append( + " * @brief AUTO-GENERATED item database from equipment.json" + ) + lines.append(" *") + lines.append( + " * DO NOT EDIT — regenerate with:" + ) + lines.append( + " * python ocean/osrs/tools/generate_items.py" + ) + lines.append(" */") + lines.append("") + lines.append("#ifndef OSRS_ITEMS_GENERATED_H") + lines.append("#define OSRS_ITEMS_GENERATED_H") + lines.append("") + + # item index enum (EquipmentSlot enum and Item struct live in osrs_items.h) + lines.append("typedef enum {") + for i, entry in enumerate(manifest): + comment = entry.get("comment", "") + suffix = f" /* {comment} */" if comment else "" + lines.append(f" {entry['index']} = {i},{suffix}") + lines.append(f" NUM_ITEMS = {len(manifest)},") + lines.append(" ITEM_NONE = 255") + lines.append("} ItemIndex;") + lines.append("") + + # item database + lines.append( + f"static const Item ITEM_DATABASE[NUM_ITEMS] = {{" + ) + warnings = [] + + for entry in manifest: + idx_name = entry["index"] + item_id = entry["item_id"] + version = entry.get("version", "") + manual_range = entry.get("attack_range", None) + effect_tags = entry.get("effect_tags", []) + effect_mask = " | ".join(EFFECT_TAG_MAP[tag] for tag in effect_tags) if effect_tags else "OSRS_ITEM_EFFECT_NONE" + + json_item = find_item_in_json(by_id, item_id, version) + + if json_item is None: + # check for manual_stats override in manifest (for LMS-only items etc) + manual = entry.get("manual_stats") + if manual: + comment = entry.get("comment", f"id={item_id}") + name = manual.get("name", comment)[:31] + slot_enum = SLOT_MAP.get(manual.get("slot", "weapon"), "0") + lines.append(f" [{idx_name}] = {{ /* {comment} (manual) */") + lines.append( + f" .item_id = {item_id}, " + f'.name = "{name}", ' + f".slot = {slot_enum}," + ) + lines.append( + f" .attack_speed = {manual.get('attack_speed', 0)}, " + f".attack_range = {entry.get('attack_range', 0)}," + ) + lines.append( + f" .attack_stab = {manual.get('attack_stab', 0)}, " + f".attack_slash = {manual.get('attack_slash', 0)}, " + f".attack_crush = {manual.get('attack_crush', 0)}," + ) + lines.append( + f" .attack_magic = {manual.get('attack_magic', 0)}, " + f".attack_ranged = {manual.get('attack_ranged', 0)}," + ) + lines.append( + f" .defence_stab = {manual.get('defence_stab', 0)}, " + f".defence_slash = {manual.get('defence_slash', 0)}, " + f".defence_crush = {manual.get('defence_crush', 0)}," + ) + lines.append( + f" .defence_magic = {manual.get('defence_magic', 0)}, " + f".defence_ranged = {manual.get('defence_ranged', 0)}," + ) + lines.append( + f" .melee_strength = {manual.get('melee_strength', 0)}, " + f".ranged_strength = {manual.get('ranged_strength', 0)}, " + f".magic_damage = {manual.get('magic_damage', 0)}, " + f".prayer = {manual.get('prayer', 0)}, " + f".effect_mask = {effect_mask}" + ) + lines.append(" },") + continue + + warnings.append( + f"WARNING: {idx_name} (id={item_id}) not found in equipment.json " + f"and no manual_stats in manifest" + ) + lines.append(f" /* WARNING: {idx_name} (id={item_id}) NOT FOUND */") + lines.append(f" [{idx_name}] = {{") + lines.append(f" .item_id = {item_id}, " + f'.name = "MISSING", .slot = 0,') + lines.append(" .attack_speed = 0, .attack_range = 0,") + lines.append( + " .attack_stab = 0, .attack_slash = 0, .attack_crush = 0," + ) + lines.append(" .attack_magic = 0, .attack_ranged = 0,") + lines.append( + " .defence_stab = 0, .defence_slash = 0, .defence_crush = 0," + ) + lines.append(" .defence_magic = 0, .defence_ranged = 0,") + lines.append( + " .melee_strength = 0, .ranged_strength = 0, " + f".magic_damage = 0, .prayer = 0, .effect_mask = {effect_mask}" + ) + lines.append(" },") + continue + + name = json_item["name"][:31] + slot_str = json_item.get("slot", "") + slot_enum = SLOT_MAP.get(slot_str, "0") + + speed = json_item.get("speed", 0) if slot_str == "weapon" else 0 + + # attack_range: prefer manual override, then category default, then 0 + if manual_range is not None: + attack_range = manual_range + elif slot_str == "weapon": + category = json_item.get("category", "") + attack_range = CATEGORY_RANGE_DEFAULTS.get(category, 1) + else: + attack_range = 0 + + off = json_item.get("offensive", {}) + defe = json_item.get("defensive", {}) + bon = json_item.get("bonuses", {}) + + # magic_str in equipment.json is in tenths of a percent (150 = 15.0%). + # our Item struct uses whole percent (15 = 15%). convert with rounding. + raw_magic_str = bon.get("magic_str", 0) + magic_damage_pct = (raw_magic_str + 5) // 10 if raw_magic_str > 0 else 0 + + comment = entry.get("comment", name) + lines.append(f" [{idx_name}] = {{ /* {comment} */") + lines.append( + f" .item_id = {item_id}, " + f'.name = "{name}", ' + f".slot = {slot_enum}," + ) + lines.append( + f" .attack_speed = {speed}, .attack_range = {attack_range}," + ) + lines.append( + f" .attack_stab = {off.get('stab', 0)}, " + f".attack_slash = {off.get('slash', 0)}, " + f".attack_crush = {off.get('crush', 0)}," + ) + lines.append( + f" .attack_magic = {off.get('magic', 0)}, " + f".attack_ranged = {off.get('ranged', 0)}," + ) + lines.append( + f" .defence_stab = {defe.get('stab', 0)}, " + f".defence_slash = {defe.get('slash', 0)}, " + f".defence_crush = {defe.get('crush', 0)}," + ) + lines.append( + f" .defence_magic = {defe.get('magic', 0)}, " + f".defence_ranged = {defe.get('ranged', 0)}," + ) + lines.append( + f" .melee_strength = {bon.get('str', 0)}, " + f".ranged_strength = {bon.get('ranged_str', 0)}, " + f".magic_damage = {magic_damage_pct}, " + f".prayer = {bon.get('prayer', 0)}, " + f".effect_mask = {effect_mask}" + ) + lines.append(" },") + + lines.append("};") + lines.append("") + lines.append("#endif /* OSRS_ITEMS_GENERATED_H */") + lines.append("") + + return "\n".join(lines), warnings + + +def main(): + parser = argparse.ArgumentParser( + description="Generate C item database from equipment.json" + ) + parser.add_argument( + "--json", + default=".refs/osrs-dps-calc/cdn/json/equipment.json", + help="path to equipment.json", + ) + parser.add_argument( + "--manifest", + default="ocean/osrs/tools/items_manifest.json", + help="path to items manifest JSON", + ) + parser.add_argument( + "--output", + default="ocean/osrs/osrs_items_generated.h", + help="output C header path", + ) + parser.add_argument( + "--bootstrap", + action="store_true", + help="extract manifest from existing osrs_items.h and write to --manifest", + ) + parser.add_argument( + "--items-h", + default="ocean/osrs/osrs_items.h", + help="path to existing osrs_items.h (for --bootstrap)", + ) + args = parser.parse_args() + + if args.bootstrap: + print(f"bootstrapping manifest from {args.items_h}...") + manifest = extract_manifest_from_source(args.items_h) + Path(args.manifest).parent.mkdir(parents=True, exist_ok=True) + with open(args.manifest, "w") as f: + json.dump(manifest, f, indent=2) + print(f"wrote {len(manifest)} items to {args.manifest}") + return + + print(f"loading equipment.json from {args.json}...") + by_id = load_equipment_json(args.json) + print(f" {sum(len(v) for v in by_id.values())} items indexed by {len(by_id)} unique IDs") + + print(f"loading manifest from {args.manifest}...") + manifest = load_manifest(args.manifest) + print(f" {len(manifest)} items in manifest") + + print("generating header...") + header_content, warnings = generate_header(manifest, by_id) + + for w in warnings: + print(f" {w}", file=sys.stderr) + + Path(args.output).parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w") as f: + f.write(header_content) + + print(f"wrote {args.output}") + if warnings: + print(f" {len(warnings)} warnings — check stderr") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/tools/generate_monsters.py b/ocean/osrs/tools/generate_monsters.py new file mode 100644 index 0000000000..2e3eac4aa4 --- /dev/null +++ b/ocean/osrs/tools/generate_monsters.py @@ -0,0 +1,313 @@ +"""Generate osrs_monsters_generated.h from monsters.json reference data. + +Reads the osrs-dps-calc monsters.json (wiki-sourced monster stats) and a manifest +of which monsters to include, then outputs a C header with the full monster database. +This makes encounters data-driven: add an OSRS NPC ID to the manifest and all stats +are auto-populated from wiki data. + +Usage: + cd pufferlib-metal + python ocean/osrs/tools/generate_monsters.py + + # bootstrap manifest from encounter_inferno.h: + python ocean/osrs/tools/generate_monsters.py --bootstrap + +Input: + monsters.json — wiki-sourced monster stats from osrs-dps-calc + monsters_manifest.json — our monster list: index name, NPC ID, version, overrides + +Output: + osrs_monsters_generated.h — generated monster database + +Generated types: MonsterIndex enum, MonsterStats struct, MONSTER_DATABASE array. + +Stat field mapping (monsters.json -> MonsterStats struct): + id -> npc_id + name -> name + skills.hp -> hp + skills.atk -> att_level + skills.str -> str_level + skills.def -> def_level + skills.magic -> magic_level + skills.ranged -> range_level + speed -> attack_speed + size -> size + max_hit -> max_hit (parsed from string, may contain HTML entities) + offensive.atk -> melee_att_bonus + offensive.str -> melee_str_bonus + offensive.magic -> magic_att_bonus + offensive.magic_str -> magic_str_bonus + offensive.ranged -> range_att_bonus + offensive.ranged_str-> ranged_str_bonus + defensive.stab -> stab_def + defensive.slash -> slash_def + defensive.crush -> crush_def + defensive.magic -> magic_def + defensive.light -> ranged_def (light/standard/heavy are equal for most NPCs) + style -> attack_styles (list of strings) +""" + +import argparse +import json +import re +import sys +from pathlib import Path + + +def load_monsters_json(path: str) -> dict[int, list[dict]]: + """Load monsters.json and index by NPC ID.""" + with open(path) as f: + monsters = json.load(f) + by_id: dict[int, list[dict]] = {} + for m in monsters: + npc_id = m["id"] + if npc_id not in by_id: + by_id[npc_id] = [] + by_id[npc_id].append(m) + return by_id + + +def load_manifest(path: str) -> list[dict]: + """Load the monster manifest.""" + with open(path) as f: + return json.load(f) + + +def find_monster(by_id: dict[int, list[dict]], npc_id: int, version: str = "") -> dict | None: + """Find a monster in monsters.json by ID and optional version.""" + candidates = by_id.get(npc_id, []) + if not candidates: + return None + if version: + for c in candidates: + if c.get("version", "") == version: + return c + for c in candidates: + if not c.get("version", ""): + return c + return candidates[0] + + +def parse_max_hit(raw: str | int) -> int: + """Parse max_hit from monsters.json (may contain HTML entities or ranges).""" + if isinstance(raw, int): + return raw + cleaned = re.sub(r"&\w+;", " ", str(raw)) + cleaned = re.split(r"\s*\(", cleaned)[0].strip() + range_matches = [ + int(value) + for value in re.findall(r"\d+", cleaned.replace("–", "-").replace("—", "-")) + ] + if range_matches: + return max(range_matches) + return 0 + + +def generate_header(manifest: list[dict], by_id: dict[int, list[dict]]) -> str: + """Generate the C header content.""" + lines = [] + lines.append("/**") + lines.append(" * @file osrs_monsters_generated.h") + lines.append(" * @brief AUTO-GENERATED monster database from monsters.json") + lines.append(" *") + lines.append(" * DO NOT EDIT — regenerate with:") + lines.append(" * python ocean/osrs/tools/generate_monsters.py") + lines.append(" */") + lines.append("") + lines.append("#ifndef OSRS_MONSTERS_GENERATED_H") + lines.append("#define OSRS_MONSTERS_GENERATED_H") + lines.append("") + lines.append("#include ") + lines.append("") + + # monster index enum + lines.append("typedef enum {") + for i, entry in enumerate(manifest): + comment = entry.get("comment", "") + suffix = f" /* {comment} */" if comment else "" + lines.append(f" {entry['index']} = {i},{suffix}") + lines.append(f" NUM_MONSTERS = {len(manifest)}") + lines.append("} MonsterIndex;") + lines.append("") + + # monster struct + lines.append("typedef struct {") + lines.append(" uint16_t npc_id;") + lines.append(" char name[32];") + lines.append(" int16_t hp;") + lines.append(" int16_t att_level;") + lines.append(" int16_t str_level;") + lines.append(" int16_t def_level;") + lines.append(" int16_t magic_level;") + lines.append(" int16_t range_level;") + lines.append(" uint8_t attack_speed;") + lines.append(" uint8_t size;") + lines.append(" int16_t max_hit;") + lines.append(" /* offensive bonuses */") + lines.append(" int16_t melee_att_bonus;") + lines.append(" int16_t melee_str_bonus;") + lines.append(" int16_t magic_att_bonus;") + lines.append(" int16_t magic_str_bonus;") + lines.append(" int16_t range_att_bonus;") + lines.append(" int16_t ranged_str_bonus;") + lines.append(" /* defensive bonuses */") + lines.append(" int16_t stab_def;") + lines.append(" int16_t slash_def;") + lines.append(" int16_t crush_def;") + lines.append(" int16_t magic_def;") + lines.append(" int16_t ranged_def;") + lines.append("} MonsterStats;") + lines.append("") + + # monster database + lines.append(f"static const MonsterStats MONSTER_DATABASE[NUM_MONSTERS] = {{") + warnings = [] + + for entry in manifest: + idx_name = entry["index"] + npc_id = entry["npc_id"] + version = entry.get("version", "") + + m = find_monster(by_id, npc_id, version) + + if m is None: + manual = entry.get("manual_stats") + if manual: + comment = entry.get("comment", f"id={npc_id}") + name = manual.get("name", comment)[:31] + lines.append(f" [{idx_name}] = {{ /* {comment} (manual) */") + lines.append(f" .npc_id = {npc_id}, .name = \"{name}\",") + lines.append(f" .hp = {manual.get('hp', 0)}, " + f".att_level = {manual.get('att_level', 0)}, " + f".str_level = {manual.get('str_level', 0)}, " + f".def_level = {manual.get('def_level', 0)},") + lines.append(f" .magic_level = {manual.get('magic_level', 0)}, " + f".range_level = {manual.get('range_level', 0)},") + lines.append(f" .attack_speed = {manual.get('attack_speed', 0)}, " + f".size = {manual.get('size', 1)}, " + f".max_hit = {manual.get('max_hit', 0)},") + lines.append(f" .melee_att_bonus = 0, .melee_str_bonus = 0, " + f".magic_att_bonus = 0, .magic_str_bonus = 0, " + f".range_att_bonus = 0, .ranged_str_bonus = 0,") + lines.append(f" .stab_def = 0, .slash_def = 0, .crush_def = 0, " + f".magic_def = 0, .ranged_def = 0") + lines.append(" },") + continue + + warnings.append(f"WARNING: {idx_name} (id={npc_id}) not found in monsters.json") + lines.append(f" /* WARNING: {idx_name} (id={npc_id}) NOT FOUND */") + lines.append(f" [{idx_name}] = {{ .npc_id = {npc_id}, .name = \"MISSING\" }},") + continue + + name = m["name"][:31] + skills = m.get("skills", {}) + off = m.get("offensive", {}) + defe = m.get("defensive", {}) + max_hit = parse_max_hit(m.get("max_hit", 0)) + comment = entry.get("comment", name) + + lines.append(f" [{idx_name}] = {{ /* {comment} */") + lines.append(f" .npc_id = {npc_id}, .name = \"{name}\",") + lines.append(f" .hp = {skills.get('hp', 0)}, " + f".att_level = {skills.get('atk', 0)}, " + f".str_level = {skills.get('str', 0)}, " + f".def_level = {skills.get('def', 0)},") + lines.append(f" .magic_level = {skills.get('magic', 0)}, " + f".range_level = {skills.get('ranged', 0)},") + lines.append(f" .attack_speed = {m.get('speed', 0)}, " + f".size = {m.get('size', 1)}, " + f".max_hit = {max_hit},") + lines.append(f" .melee_att_bonus = {off.get('atk', 0)}, " + f".melee_str_bonus = {off.get('str', 0)}, " + f".magic_att_bonus = {off.get('magic', 0)}, " + f".magic_str_bonus = {off.get('magic_str', 0)},") + lines.append(f" .range_att_bonus = {off.get('ranged', 0)}, " + f".ranged_str_bonus = {off.get('ranged_str', 0)},") + lines.append(f" .stab_def = {defe.get('stab', 0)}, " + f".slash_def = {defe.get('slash', 0)}, " + f".crush_def = {defe.get('crush', 0)},") + lines.append(f" .magic_def = {defe.get('magic', 0)}, " + f".ranged_def = {defe.get('light', 0)}") + lines.append(" },") + + lines.append("};") + lines.append("") + lines.append("#endif /* OSRS_MONSTERS_GENERATED_H */") + lines.append("") + + for w in warnings: + print(w, file=sys.stderr) + + return "\n".join(lines) + + +def bootstrap_inferno_manifest() -> list[dict]: + """Generate manifest from inferno encounter NPC IDs.""" + return [ + {"index": "MON_JAL_NIB", "npc_id": 7691, "comment": "Nibbler"}, + {"index": "MON_JAL_MEJRAH", "npc_id": 7692, "comment": "Bat"}, + {"index": "MON_JAL_AK", "npc_id": 7693, "comment": "Blob"}, + {"index": "MON_JAL_AKREK_MEJ", "npc_id": 7694, "comment": "Blob mage split"}, + {"index": "MON_JAL_AKREK_XIL", "npc_id": 7695, "comment": "Blob range split"}, + {"index": "MON_JAL_AKREK_KET", "npc_id": 7696, "comment": "Blob melee split"}, + {"index": "MON_JAL_IMKOT", "npc_id": 7697, "comment": "Meleer"}, + {"index": "MON_JAL_XIL", "npc_id": 7698, "comment": "Ranger"}, + {"index": "MON_JAL_ZEK", "npc_id": 7699, "comment": "Mager"}, + {"index": "MON_JALTOK_JAD", "npc_id": 7700, "comment": "Jad"}, + {"index": "MON_YT_HURKOT", "npc_id": 7701, "comment": "Jad healer"}, + {"index": "MON_TZKAL_ZUK", "npc_id": 7706, "comment": "Zuk", + "version": "Normal"}, + {"index": "MON_ZUK_SHIELD", "npc_id": 7707, "comment": "Ancestral Glyph", + "manual_stats": {"name": "Ancestral Glyph", "hp": 600, "size": 5, + "attack_speed": 0, "max_hit": 0}}, + {"index": "MON_JAL_MEJJAK", "npc_id": 7708, "comment": "Zuk healer"}, + ] + + +def main(): + parser = argparse.ArgumentParser(description="generate monster database from monsters.json") + parser.add_argument( + "--json", type=Path, + default=Path(".refs/osrs-dps-calc/cdn/json/monsters.json"), + help="path to monsters.json", + ) + parser.add_argument( + "--manifest", type=Path, + default=Path("ocean/osrs/tools/monsters_manifest.json"), + help="path to monster manifest JSON", + ) + parser.add_argument( + "--output", type=Path, + default=Path("ocean/osrs/osrs_monsters_generated.h"), + help="output header file", + ) + parser.add_argument( + "--bootstrap", action="store_true", + help="generate initial manifest from inferno NPCs and exit", + ) + args = parser.parse_args() + + if args.bootstrap: + manifest = bootstrap_inferno_manifest() + args.manifest.parent.mkdir(parents=True, exist_ok=True) + with open(args.manifest, "w") as f: + json.dump(manifest, f, indent=2) + print(f"bootstrapped {len(manifest)} monsters to {args.manifest}") + return + + by_id = load_monsters_json(str(args.json)) + print(f"loaded {sum(len(v) for v in by_id.values())} monsters from {args.json}") + + manifest = load_manifest(str(args.manifest)) + print(f"manifest: {len(manifest)} monsters") + + header = generate_header(manifest, by_id) + + args.output.parent.mkdir(parents=True, exist_ok=True) + with open(args.output, "w") as f: + f.write(header) + print(f"wrote {len(header):,} bytes to {args.output}") + + +if __name__ == "__main__": + main() diff --git a/ocean/osrs/tools/items_manifest.json b/ocean/osrs/tools/items_manifest.json new file mode 100644 index 0000000000..738f99b3b0 --- /dev/null +++ b/ocean/osrs/tools/items_manifest.json @@ -0,0 +1,838 @@ +[ + { + "index": "ITEM_HELM_NEITIZNOT", + "item_id": 10828, + "attack_range": 0, + "comment": "Helm of Neitiznot" + }, + { + "index": "ITEM_GOD_CAPE", + "item_id": 21795, + "attack_range": 0, + "comment": "Imbued god cape" + }, + { + "index": "ITEM_GLORY", + "item_id": 1712, + "attack_range": 0, + "comment": "Amulet of glory" + }, + { + "index": "ITEM_BLACK_DHIDE_BODY", + "item_id": 2503, + "attack_range": 0, + "comment": "Black d'hide body" + }, + { + "index": "ITEM_MYSTIC_TOP", + "item_id": 4091, + "attack_range": 0, + "comment": "Mystic robe top" + }, + { + "index": "ITEM_RUNE_PLATELEGS", + "item_id": 1079, + "attack_range": 0, + "comment": "Rune platelegs" + }, + { + "index": "ITEM_MYSTIC_BOTTOM", + "item_id": 4093, + "attack_range": 0, + "comment": "Mystic robe bottom" + }, + { + "index": "ITEM_WHIP", + "item_id": 4151, + "attack_range": 1, + "comment": "Abyssal whip" + }, + { + "index": "ITEM_RUNE_CROSSBOW", + "item_id": 9185, + "attack_range": 7, + "comment": "Rune crossbow" + }, + { + "index": "ITEM_AHRIM_STAFF", + "item_id": 4710, + "attack_range": 10, + "comment": "Ahrim's staff" + }, + { + "index": "ITEM_DRAGON_DAGGER", + "item_id": 5698, + "attack_range": 1, + "comment": "Dragon dagger" + }, + { + "index": "ITEM_DRAGON_DEFENDER", + "item_id": 12954, + "attack_range": 0, + "comment": "Dragon defender" + }, + { + "index": "ITEM_SPIRIT_SHIELD", + "item_id": 12829, + "attack_range": 0, + "comment": "Spirit shield" + }, + { + "index": "ITEM_BARROWS_GLOVES", + "item_id": 7462, + "attack_range": 0, + "comment": "Barrows gloves" + }, + { + "index": "ITEM_CLIMBING_BOOTS", + "item_id": 3105, + "attack_range": 0, + "comment": "Climbing boots" + }, + { + "index": "ITEM_BERSERKER_RING", + "item_id": 6737, + "attack_range": 0, + "comment": "Berserker ring" + }, + { + "index": "ITEM_DIAMOND_BOLTS_E", + "item_id": 9243, + "attack_range": 0, + "comment": "Diamond bolts (e)" + }, + { + "index": "ITEM_GHRAZI_RAPIER", + "item_id": 22324, + "attack_range": 1, + "comment": "Ghrazi rapier" + }, + { + "index": "ITEM_INQUISITORS_MACE", + "item_id": 24417, + "attack_range": 1, + "comment": "Inquisitor's mace" + }, + { + "index": "ITEM_STAFF_OF_DEAD", + "item_id": 11791, + "attack_range": 10, + "comment": "Staff of the dead" + }, + { + "index": "ITEM_KODAI_WAND", + "item_id": 21006, + "attack_range": 10, + "comment": "Kodai wand" + }, + { + "index": "ITEM_VOLATILE_STAFF", + "item_id": 24424, + "attack_range": 10, + "comment": "Volatile nightmare staff" + }, + { + "index": "ITEM_ZURIELS_STAFF", + "item_id": 13867, + "attack_range": 10, + "comment": "Zuriel's staff (LMS-only, not in wiki equipment.json)", + "manual_stats": { + "name": "Zuriel's staff", + "slot": "weapon", + "attack_speed": 5, + "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 + } + }, + { + "index": "ITEM_ARMADYL_CROSSBOW", + "item_id": 11785, + "attack_range": 7, + "comment": "Armadyl crossbow" + }, + { + "index": "ITEM_ZARYTE_CROSSBOW", + "item_id": 26374, + "attack_range": 7, + "comment": "Zaryte crossbow" + }, + { + "index": "ITEM_DRAGON_CLAWS", + "item_id": 13652, + "attack_range": 1, + "comment": "Dragon claws" + }, + { + "index": "ITEM_AGS", + "item_id": 11802, + "attack_range": 1, + "comment": "Armadyl godsword" + }, + { + "index": "ITEM_ANCIENT_GS", + "item_id": 26233, + "attack_range": 1, + "comment": "Ancient godsword" + }, + { + "index": "ITEM_GRANITE_MAUL", + "item_id": 4153, + "attack_range": 1, + "comment": "Granite maul" + }, + { + "index": "ITEM_ELDER_MAUL", + "item_id": 21003, + "attack_range": 1, + "comment": "Elder maul" + }, + { + "index": "ITEM_DARK_BOW", + "item_id": 11235, + "attack_range": 10, + "comment": "Dark bow" + }, + { + "index": "ITEM_HEAVY_BALLISTA", + "item_id": 19481, + "attack_range": 10, + "comment": "Heavy ballista" + }, + { + "index": "ITEM_VESTAS", + "item_id": 22613, + "attack_range": 1, + "comment": "Vesta's longsword" + }, + { + "index": "ITEM_VOIDWAKER", + "item_id": 27690, + "attack_range": 1, + "comment": "Voidwaker" + }, + { + "index": "ITEM_STATIUS_WARHAMMER", + "item_id": 22622, + "attack_range": 1, + "comment": "Statius's warhammer" + }, + { + "index": "ITEM_MORRIGANS_JAVELIN", + "item_id": 22636, + "attack_range": 5, + "comment": "Morrigan's javelin" + }, + { + "index": "ITEM_ANCESTRAL_HAT", + "item_id": 21018, + "attack_range": 0, + "comment": "Ancestral hat" + }, + { + "index": "ITEM_ANCESTRAL_TOP", + "item_id": 21021, + "attack_range": 0, + "comment": "Ancestral robe top" + }, + { + "index": "ITEM_ANCESTRAL_BOTTOM", + "item_id": 21024, + "attack_range": 0, + "comment": "Ancestral robe bottom" + }, + { + "index": "ITEM_AHRIMS_ROBETOP", + "item_id": 4712, + "attack_range": 0, + "comment": "Ahrim's robetop" + }, + { + "index": "ITEM_AHRIMS_ROBESKIRT", + "item_id": 4714, + "attack_range": 0, + "comment": "Ahrim's robeskirt" + }, + { + "index": "ITEM_KARILS_TOP", + "item_id": 4736, + "attack_range": 0, + "comment": "Karil's leathertop" + }, + { + "index": "ITEM_BANDOS_TASSETS", + "item_id": 11834, + "attack_range": 0, + "comment": "Bandos tassets" + }, + { + "index": "ITEM_BLESSED_SPIRIT_SHIELD", + "item_id": 12831, + "attack_range": 0, + "comment": "Blessed spirit shield" + }, + { + "index": "ITEM_FURY", + "item_id": 6585, + "attack_range": 0, + "comment": "Amulet of fury" + }, + { + "index": "ITEM_OCCULT_NECKLACE", + "item_id": 12002, + "attack_range": 0, + "comment": "Occult necklace" + }, + { + "index": "ITEM_INFERNAL_CAPE", + "item_id": 21295, + "attack_range": 0, + "comment": "Infernal cape" + }, + { + "index": "ITEM_ETERNAL_BOOTS", + "item_id": 13235, + "attack_range": 0, + "comment": "Eternal boots" + }, + { + "index": "ITEM_SEERS_RING_I", + "item_id": 11770, + "attack_range": 0, + "comment": "Seers ring (i)" + }, + { + "index": "ITEM_LIGHTBEARER", + "item_id": 25975, + "attack_range": 0, + "comment": "Lightbearer", + "effect_tags": ["LIGHTBEARER"] + }, + { + "index": "ITEM_MAGES_BOOK", + "item_id": 6889, + "attack_range": 0, + "comment": "Mage's book" + }, + { + "index": "ITEM_DRAGON_ARROWS", + "item_id": 11212, + "attack_range": 0, + "comment": "Dragon arrows" + }, + { + "index": "ITEM_TORAGS_PLATELEGS", + "item_id": 4751, + "attack_range": 0, + "comment": "Torag's platelegs" + }, + { + "index": "ITEM_DHAROKS_PLATELEGS", + "item_id": 4722, + "attack_range": 0, + "comment": "Dharok's platelegs", + "effect_tags": ["DHAROK_PIECE"] + }, + { + "index": "ITEM_VERACS_PLATESKIRT", + "item_id": 4759, + "attack_range": 0, + "comment": "Verac's plateskirt" + }, + { + "index": "ITEM_TORAGS_HELM", + "item_id": 4745, + "attack_range": 0, + "comment": "Torag's helm" + }, + { + "index": "ITEM_DHAROKS_HELM", + "item_id": 4716, + "attack_range": 0, + "comment": "Dharok's helm", + "effect_tags": ["DHAROK_PIECE"] + }, + { + "index": "ITEM_VERACS_HELM", + "item_id": 4753, + "attack_range": 0, + "comment": "Verac's helm" + }, + { + "index": "ITEM_GUTHANS_HELM", + "item_id": 4724, + "attack_range": 0, + "comment": "Guthan's helm" + }, + { + "index": "ITEM_OPAL_DRAGON_BOLTS", + "item_id": 21932, + "attack_range": 0, + "comment": "Opal dragon bolts (e)" + }, + { + "index": "ITEM_IMBUED_SARA_CAPE", + "item_id": 21791, + "attack_range": 0, + "comment": "Imbued saradomin cape" + }, + { + "index": "ITEM_EYE_OF_AYAK", + "item_id": 31113, + "attack_range": 6, + "comment": "Eye of ayak" + }, + { + "index": "ITEM_ELIDINIS_WARD_F", + "item_id": 27251, + "attack_range": 0, + "comment": "Elidinis' ward (f)" + }, + { + "index": "ITEM_CONFLICTION_GAUNTLETS", + "item_id": 31106, + "attack_range": 0, + "comment": "Confliction gauntlets", + "effect_tags": ["CONFLICTION"] + }, + { + "index": "ITEM_AVERNIC_TREADS", + "item_id": 31097, + "attack_range": 0, + "comment": "Avernic treads (max)" + }, + { + "index": "ITEM_RING_OF_SUFFERING_RI", + "item_id": 20657, + "attack_range": 0, + "comment": "Ring of suffering (ri)", + "effect_tags": ["RECOIL_RING"] + }, + { + "index": "ITEM_TWISTED_BOW", + "item_id": 20997, + "attack_range": 10, + "comment": "Twisted bow", + "effect_tags": ["TWISTED_BOW"] + }, + { + "index": "ITEM_MASORI_MASK_F", + "item_id": 27235, + "attack_range": 0, + "comment": "Masori mask (f)" + }, + { + "index": "ITEM_MASORI_BODY_F", + "item_id": 27238, + "attack_range": 0, + "comment": "Masori body (f)" + }, + { + "index": "ITEM_MASORI_CHAPS_F", + "item_id": 27241, + "attack_range": 0, + "comment": "Masori chaps (f)" + }, + { + "index": "ITEM_NECKLACE_OF_ANGUISH", + "item_id": 19547, + "attack_range": 0, + "comment": "Necklace of anguish" + }, + { + "index": "ITEM_DIZANAS_QUIVER", + "item_id": 28947, + "attack_range": 0, + "comment": "Dizana's quiver" + }, + { + "index": "ITEM_ZARYTE_VAMBRACES", + "item_id": 26235, + "attack_range": 0, + "comment": "Zaryte vambraces" + }, + { + "index": "ITEM_TOXIC_BLOWPIPE", + "item_id": 12926, + "attack_range": 5, + "comment": "Toxic blowpipe" + }, + { + "index": "ITEM_AHRIMS_HOOD", + "item_id": 4708, + "attack_range": 0, + "comment": "Ahrim's hood" + }, + { + "index": "ITEM_TORMENTED_BRACELET", + "item_id": 19544, + "attack_range": 0, + "comment": "Tormented bracelet" + }, + { + "index": "ITEM_SANGUINESTI_STAFF", + "item_id": 22481, + "attack_range": 7, + "comment": "Sanguinesti staff", + "effect_tags": ["SANG_HEAL"] + }, + { + "index": "ITEM_INFINITY_BOOTS", + "item_id": 6920, + "attack_range": 0, + "comment": "Infinity boots" + }, + { + "index": "ITEM_GOD_BLESSING", + "item_id": 20220, + "attack_range": 0, + "comment": "Holy blessing" + }, + { + "index": "ITEM_RING_OF_RECOIL", + "item_id": 2550, + "attack_range": 0, + "comment": "Ring of recoil", + "effect_tags": ["RECOIL_RING"] + }, + { + "index": "ITEM_CRYSTAL_HELM", + "item_id": 23971, + "attack_range": 0, + "comment": "Crystal helm" + }, + { + "index": "ITEM_AVAS_ASSEMBLER", + "item_id": 22109, + "attack_range": 0, + "comment": "Ava's assembler" + }, + { + "index": "ITEM_CRYSTAL_BODY", + "item_id": 23975, + "attack_range": 0, + "comment": "Crystal body" + }, + { + "index": "ITEM_CRYSTAL_LEGS", + "item_id": 23979, + "attack_range": 0, + "comment": "Crystal legs" + }, + { + "index": "ITEM_BOW_OF_FAERDHINEN", + "item_id": 25865, + "attack_range": 10, + "comment": "Bow of faerdhinen (c)" + }, + { + "index": "ITEM_BLESSED_DHIDE_BOOTS", + "item_id": 19921, + "attack_range": 0, + "comment": "Blessed d'hide boots" + }, + { + "index": "ITEM_MYSTIC_HAT", + "item_id": 4089, + "attack_range": 0, + "comment": "Mystic hat" + }, + { + "index": "ITEM_TRIDENT_OF_SWAMP", + "item_id": 12899, + "attack_range": 7, + "comment": "Trident of the swamp" + }, + { + "index": "ITEM_BOOK_OF_DARKNESS", + "item_id": 12612, + "attack_range": 0, + "comment": "Book of darkness" + }, + { + "index": "ITEM_AMETHYST_ARROW", + "item_id": 21326, + "attack_range": 0, + "comment": "Amethyst arrow" + }, + { + "index": "ITEM_MYSTIC_BOOTS", + "item_id": 4097, + "attack_range": 0, + "comment": "Mystic boots" + }, + { + "index": "ITEM_BLESSED_COIF", + "item_id": 10382, + "attack_range": 0, + "comment": "Blessed coif" + }, + { + "index": "ITEM_BLACK_DHIDE_CHAPS", + "item_id": 2497, + "attack_range": 0, + "comment": "Black d'hide chaps" + }, + { + "index": "ITEM_MAGIC_SHORTBOW_I", + "item_id": 12788, + "attack_range": 7, + "comment": "Magic shortbow (i)" + }, + { + "index": "ITEM_AVAS_ACCUMULATOR", + "item_id": 10499, + "attack_range": 0, + "comment": "Ava's accumulator" + }, + { + "index": "ITEM_CRYSTAL_SHIELD", + "item_id": 4224, + "attack_range": 0, + "comment": "Crystal shield" + }, + { + "index": "ITEM_PEGASIAN_BOOTS", + "item_id": 13237, + "attack_range": 0, + "comment": "Pegasian boots" + }, + { + "index": "ITEM_JUSTICIAR_FACEGUARD", + "item_id": 22326, + "attack_range": 0, + "comment": "Justiciar faceguard" + }, + { + "index": "ITEM_JUSTICIAR_CHESTGUARD", + "item_id": 22327, + "attack_range": 0, + "comment": "Justiciar chestguard" + }, + { + "index": "ITEM_JUSTICIAR_LEGGUARDS", + "item_id": 22328, + "attack_range": 0, + "comment": "Justiciar legguards" + }, + { + "index": "ITEM_DRAGON_DART", + "item_id": 11230, + "attack_range": 0, + "comment": "Dragon dart" + }, + { + "index": "ITEM_SCYTHE_OF_VITUR", + "item_id": 22325, + "version": "Charged", + "attack_range": 2, + "comment": "Scythe of vitur" + }, + { + "index": "ITEM_BLADE_OF_SAELDOR", + "item_id": 24551, + "attack_range": 1, + "comment": "Blade of saeldor (c)" + }, + { + "index": "ITEM_OSMUMTENS_FANG", + "item_id": 26219, + "attack_range": 1, + "comment": "Osmumten's fang" + }, + { + "index": "ITEM_SOULREAPER_AXE", + "item_id": 28338, + "attack_range": 1, + "comment": "Soulreaper axe" + }, + { + "index": "ITEM_TORVA_FULL_HELM", + "item_id": 26382, + "version": "Restored", + "attack_range": 0, + "comment": "Torva full helm" + }, + { + "index": "ITEM_TORVA_PLATEBODY", + "item_id": 26384, + "version": "Restored", + "attack_range": 0, + "comment": "Torva platebody" + }, + { + "index": "ITEM_TORVA_PLATELEGS", + "item_id": 26386, + "version": "Restored", + "attack_range": 0, + "comment": "Torva platelegs" + }, + { + "index": "ITEM_BANDOS_CHESTPLATE", + "item_id": 11832, + "attack_range": 0, + "comment": "Bandos chestplate" + }, + { + "index": "ITEM_BANDOS_BOOTS", + "item_id": 11836, + "attack_range": 0, + "comment": "Bandos boots" + }, + { + "index": "ITEM_PRIMORDIAL_BOOTS", + "item_id": 13239, + "attack_range": 0, + "comment": "Primordial boots" + }, + { + "index": "ITEM_FEROCIOUS_GLOVES", + "item_id": 22981, + "attack_range": 0, + "comment": "Ferocious gloves" + }, + { + "index": "ITEM_AMULET_OF_TORTURE", + "item_id": 19553, + "attack_range": 0, + "comment": "Amulet of torture" + }, + { + "index": "ITEM_BERSERKER_RING_I", + "item_id": 11773, + "attack_range": 0, + "comment": "Berserker ring (i)" + }, + { + "index": "ITEM_ULTOR_RING", + "item_id": 28307, + "attack_range": 0, + "comment": "Ultor ring" + }, + { + "index": "ITEM_AVERNIC_DEFENDER", + "item_id": 22322, + "version": "Normal", + "attack_range": 0, + "comment": "Avernic defender" + }, + { + "index": "ITEM_VENATOR_RING", + "item_id": 28310, + "attack_range": 0, + "comment": "Venator ring" + }, + { + "index": "ITEM_VIRTUS_MASK", + "item_id": 26241, + "attack_range": 0, + "comment": "Virtus mask", + "effect_tags": ["VIRTUS_PIECE"] + }, + { + "index": "ITEM_VIRTUS_ROBE_TOP", + "item_id": 26243, + "attack_range": 0, + "comment": "Virtus robe top", + "effect_tags": ["VIRTUS_PIECE"] + }, + { + "index": "ITEM_VIRTUS_ROBE_BOTTOM", + "item_id": 26245, + "attack_range": 0, + "comment": "Virtus robe bottom", + "effect_tags": ["VIRTUS_PIECE"] + }, + { + "index": "ITEM_MAGUS_RING", + "item_id": 28313, + "attack_range": 0, + "comment": "Magus ring" + }, + { + "index": "ITEM_TUMEKENS_SHADOW", + "item_id": 27275, + "version": "Charged", + "attack_range": 10, + "comment": "Tumeken's shadow" + }, + { + "index": "ITEM_BGS", + "item_id": 11804, + "attack_range": 1, + "comment": "Bandos godsword" + }, + { + "index": "ITEM_SGS", + "item_id": 11806, + "attack_range": 1, + "comment": "Saradomin godsword" + }, + { + "index": "ITEM_ZGS", + "item_id": 11808, + "attack_range": 1, + "comment": "Zamorak godsword" + }, + { + "index": "ITEM_CRYSTAL_HALBERD", + "item_id": 23987, + "version": "Active", + "attack_range": 2, + "comment": "Crystal halberd" + }, + { + "index": "ITEM_DRAGON_BATTLEAXE", + "item_id": 1377, + "attack_range": 1, + "comment": "Dragon battleaxe" + }, + { + "index": "ITEM_RUBY_DRAGON_BOLTS_E", + "item_id": 21944, + "attack_range": 0, + "comment": "Ruby dragon bolts (e)" + }, + { + "index": "ITEM_DIAMOND_DRAGON_BOLTS_E", + "item_id": 21946, + "attack_range": 0, + "comment": "Diamond dragon bolts (e)" + }, + { + "index": "ITEM_RUNE_ARROW", + "item_id": 892, + "version": "Unpoisoned", + "attack_range": 0, + "comment": "Rune arrow" + }, + { + "index": "ITEM_DRAGON_JAVELIN", + "item_id": 19484, + "version": "Unpoisoned", + "attack_range": 0, + "comment": "Dragon javelin" + }, + { + "index": "ITEM_SPECTRAL_SPIRIT_SHIELD", + "item_id": 12821, + "attack_range": 0, + "comment": "Spectral spirit shield" + }, + { + "index": "ITEM_ELYSIAN_SPIRIT_SHIELD", + "item_id": 12817, + "attack_range": 0, + "comment": "Elysian spirit shield", + "effect_tags": ["ELYSIAN"] + }, + { + "index": "ITEM_DRAGONFIRE_SHIELD", + "item_id": 11283, + "version": "Charged", + "attack_range": 0, + "comment": "Dragonfire shield" + } +] diff --git a/ocean/osrs/tools/monsters_manifest.json b/ocean/osrs/tools/monsters_manifest.json new file mode 100644 index 0000000000..def912e8b8 --- /dev/null +++ b/ocean/osrs/tools/monsters_manifest.json @@ -0,0 +1,224 @@ +[ + { + "index": "MON_JAL_NIB", + "npc_id": 7691, + "comment": "Nibbler", + "visual": { + "group": "inferno", + "attack_anims": ["JALNIB_ATTACK"], + "extra_anims": ["JALNIB_DEATH", "JALNIB_DEFEND"], + "spotanims": [] + } + }, + { + "index": "MON_JAL_MEJRAH", + "npc_id": 7692, + "comment": "Bat", + "visual": { + "group": "inferno", + "attack_anims": ["JALMEJRAH_ATTACK"], + "extra_anims": ["JALMEJRAH_DEATH", "JALMEJRAH_DEFEND"], + "spotanims": ["SLAYER_MAGICDART_ENCHANTED_IMPACT"] + } + }, + { + "index": "MON_JAL_AK", + "npc_id": 7693, + "comment": "Blob", + "visual": { + "group": "inferno", + "attack_anims": ["JALAK_ATTACK_MAGIC", "JALAK_ATTACK_MELEE", "JALAK_ATTACK_RANGED"], + "extra_anims": ["JALAK_DEATH", "JALAK_DEFEND"], + "spotanims": ["INFERNO_HARPIE_PROJ", "DOUBLE_AMETHYST_ARROW_LAUNCH", "AMETHYST_ARROW_TRAVEL"] + } + }, + { + "index": "MON_JAL_AKREK_MEJ", + "npc_id": 7694, + "comment": "Blob mage split", + "visual": { + "group": "inferno", + "attack_anims": [], + "extra_anims": [], + "spotanims": ["INFERNO_BABYSPLITTER_MAGE"] + } + }, + { + "index": "MON_JAL_AKREK_XIL", + "npc_id": 7695, + "comment": "Blob range split", + "visual": { + "group": "inferno", + "attack_anims": [], + "extra_anims": [], + "spotanims": ["INFERNO_BABYSPLITTER_RANGE"] + } + }, + { + "index": "MON_JAL_AKREK_KET", + "npc_id": 7696, + "comment": "Blob melee split", + "visual": { + "group": "inferno", + "attack_anims": [], + "extra_anims": [], + "spotanims": [] + } + }, + { + "index": "MON_JAL_IMKOT", + "npc_id": 7697, + "comment": "Meleer", + "visual": { + "group": "inferno", + "attack_anims": ["JALIMKOT_ATTACK"], + "extra_anims": ["JALIMKOT_DEATH", "JALIMKOT_DEFEND", "JALIMKOT_DIGDOWN", "JALIMKOT_DIGUP"], + "spotanims": [] + } + }, + { + "index": "MON_JAL_XIL", + "npc_id": 7698, + "comment": "Ranger", + "visual": { + "group": "inferno", + "attack_anims": ["JALXIL_ATTACK_RANGED", "JALXIL_ATTACK_MELEE"], + "extra_anims": ["JALXIL_DEATH", "JALXIL_DEFEND"], + "spotanims": ["INFERNO_XIL_PROJECTILE", "INFERNO_SPLITTER_RANGE"] + } + }, + { + "index": "MON_JAL_ZEK", + "npc_id": 7699, + "comment": "Mager", + "visual": { + "group": "inferno", + "attack_anims": ["JALAKXIL_ATTACK_MAGIC", "JALAKXIL_ATTACK_MELEE"], + "extra_anims": ["JALAKXIL_DEATH", "JALAKXIL_RESURRECT"], + "spotanims": ["INFERNO_ZEK_PROJECTILE", "INFERNO_SPLITTER_MAGE"] + } + }, + { + "index": "MON_JALTOK_JAD", + "npc_id": 7700, + "comment": "Jad", + "visual": { + "group": "inferno", + "attack_anims": ["JALTOKJAD_ATTACK_RANGED", "JALTOKJAD_ATTACK_MAGIC", "JALTOKJAD_ATTACK_MELEE"], + "extra_anims": ["JALTOKJAD_DEATH", "JALTOKJAD_DEFEND"], + "spotanims": ["TZHAAR_FIRE_SPIT_LAUNCH", "TZHAAR_FIRE_SPIT_TRAVEL", "TZHAAR_ROCK_SMASH", "FIREWAVE_IMPACT"] + } + }, + { + "index": "MON_YT_HURKOT", + "npc_id": 7701, + "comment": "Jad healer", + "visual": { + "group": "inferno", + "attack_anims": [], + "extra_anims": [], + "spotanims": [] + } + }, + { + "index": "MON_TZKAL_ZUK", + "npc_id": 7706, + "comment": "Zuk", + "version": "Normal", + "visual": { + "group": "inferno", + "attack_anims": ["ZUK_ATTACK"], + "extra_anims": ["ZUK_DEATH", "ZUK_DEFEND", "ZUK_SPAWN"], + "spotanims": ["INFERNO_ZUK_PROJECTILE"] + } + }, + { + "index": "MON_ZUK_SHIELD", + "npc_id": 7707, + "comment": "Ancestral Glyph", + "manual_stats": { + "name": "Ancestral Glyph", + "hp": 600, + "size": 5, + "attack_speed": 0, + "max_hit": 0 + }, + "visual": { + "group": "inferno", + "attack_anims": [], + "extra_anims": ["MOVING_SAFE_SPOT_DEATH", "MOVING_SAFE_SPOT_HIT"], + "spotanims": [] + } + }, + { + "index": "MON_JAL_MEJJAK", + "npc_id": 7708, + "comment": "Zuk healer", + "visual": { + "group": "inferno", + "attack_anims": [], + "extra_anims": [], + "spotanims": ["AMETHYST_ARROW_LAUNCH"] + } + }, + { + "index": "MON_ZULRAH_GREEN", + "npc_id": 2042, + "version": "Serpentine", + "comment": "Zulrah green/ranged form", + "visual": { + "group": "zulrah", + "attack_anims": ["SNAKEBOSS_ATTACK_ACIDX3", "SNAKEBOSS_ATTACK_ACIDX1"], + "extra_anims": ["SNAKEBOSS_SPAWN", "SNAKEBOSS_SINKFAST", "SNAKEBOSS_EMERGEFAST", "SNAKEBOSS_DEATH", "SNAKEBOSS_DEFEND"], + "spotanims": ["SNAKEBOSS_ORB", "SNAKEBOSS_DOUBLE_ORB", "SNAKEBOSS_FIREBALL", "SNAKEBOSS_EGG", "SNAKEBOSS_MINION_SPELL"] + } + }, + { + "index": "MON_ZULRAH_RED", + "npc_id": 2043, + "version": "Magma", + "comment": "Zulrah red/melee form", + "visual": { + "group": "zulrah", + "attack_anims": ["SNAKEBOSS_ATTACK_TAIL_LEFT", "SNAKEBOSS_ATTACK_TAIL_RIGHT"], + "extra_anims": ["SNAKEBOSS_DEATH", "SNAKEBOSS_DEFEND"], + "spotanims": [] + } + }, + { + "index": "MON_ZULRAH_BLUE", + "npc_id": 2044, + "version": "Tanzanite", + "comment": "Zulrah blue/magic form", + "visual": { + "group": "zulrah", + "attack_anims": ["SNAKEBOSS_ATTACK_ACIDX3"], + "extra_anims": ["SNAKEBOSS_DEATH", "SNAKEBOSS_DEFEND"], + "spotanims": ["SNAKEBOSS_ORB", "SNAKEBOSS_FIREBALL"] + } + }, + { + "index": "MON_ZULRAH_SNAKELING_MELEE", + "npc_id": 2045, + "version": "Melee", + "comment": "Snakeling melee variant", + "visual": { + "group": "zulrah", + "attack_anims": [], + "extra_anims": [], + "spotanims": [] + } + }, + { + "index": "MON_ZULRAH_SNAKELING_MAGIC", + "npc_id": 2046, + "version": "Magic", + "comment": "Snakeling magic variant", + "visual": { + "group": "zulrah", + "attack_anims": [], + "extra_anims": [], + "spotanims": ["SNAKEBOSS_MINION_SPELL"] + } + } +] diff --git a/ocean/osrs_inferno/binding.c b/ocean/osrs_inferno/binding.c new file mode 100644 index 0000000000..818b41d883 --- /dev/null +++ b/ocean/osrs_inferno/binding.c @@ -0,0 +1,678 @@ +/** + * @file binding.c + * @brief Static-native binding for OSRS Inferno encounter. + * + * Bridges vecenv.h's contract (float actions, float terminals) with the + * Inferno encounter's vtable interface. + */ + +#include +#include +#include + +#include "osrs_env.h" /* pulls in osrs_types, encounter, pvp stack */ + +/* encounter headers + render.h have many static helpers only used by the + standalone viewer (not c_render) — suppress unused-function noise. */ +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-function" +#include "encounters/encounter_inferno.h" +#include "encounters/encounter_zulrah.h" /* render.h references ZulrahState */ +#include "osrs_render.h" +#pragma GCC diagnostic pop + +#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 config_start_wave; /* internal start_wave from config, not curriculum override */ + + 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 */ + + /* replay playback: when PLAY_REPLAY=path is set, override the policy's + actions with the recorded ones. first env only (env 0). */ + int* replay_actions; /* full action buffer: num_ticks * NUM_ATNS */ + int replay_num_ticks; + int replay_cursor; /* ticks consumed so far */ + uint32_t replay_rng_seed; + + float ticks_per_second; + double last_step_time; + + /* set by c_step on terminal-reset; consumed by c_render on its next call + to mirror the standalone viewer's post-reset cleanup (osrs_visual.c:186). */ + int pending_render_reset; + + OsrsEnv render_env; /* minimal env wrapper for pvp_render() */ +} InfernoEnv; + +#define OBS_SIZE INF_TOTAL_OBS +#define NUM_ATNS INF_NUM_ACTION_HEADS +#define ACT_SIZES { ENCOUNTER_MOVE_ACTIONS, ENCOUNTER_OVERHEAD_DIM_PVE, INF_OBS_NPCS+1, 5, 2, 4, 3, 2, ENCOUNTER_OFFENSIVE_DIM } +#define OBS_TENSOR_T FloatTensor +#define Env InfernoEnv + +static int inferno_public_wave_from_config(DictItem* item, const char* key, int allow_zero) { + if (!item) return 0; + int wave = (int)item->value; + if ((double)wave != item->value) { + fprintf(stderr, "%s must be an integer public Inferno wave, got %.6f\n", + key, item->value); + abort(); + } + if ((allow_zero && (wave < 0 || wave > INF_NUM_WAVES)) || + (!allow_zero && (wave < 1 || wave > INF_NUM_WAVES))) { + fprintf(stderr, "%s must be in [%d, %d], got %d\n", + key, allow_zero ? 0 : 1, INF_NUM_WAVES, wave); + abort(); + } + return wave; +} + +/* global best episode tracking */ +static int g_best_wave = 0; +static int g_best_ticks = 999999; +static int g_best_min_zuk_hp = 999999; /* lowest Zuk HP reached (for Zuk-only training) */ + +void c_step(Env* env) { + int used_human_commands = 0; + RenderClient* render_client = (RenderClient*)env->render_env.client; + + /* replay playback: if this env has a loaded replay, override policy actions */ + if (env->replay_actions && env->replay_cursor < env->replay_num_ticks) { + int off = env->replay_cursor * NUM_ATNS; + for (int i = 0; i < NUM_ATNS; i++) + env->acts_staging[i] = env->replay_actions[off + i]; + env->replay_cursor++; + if (render_client) { + human_input_clear_pending(&render_client->human_input); + human_input_clear_move(&render_client->human_input); + ENCOUNTER_INFERNO.put_int(env->enc_state, "player_dest_x", -1); + ENCOUNTER_INFERNO.put_int(env->enc_state, "player_dest_y", -1); + ENCOUNTER_INFERNO.put_int(env->enc_state, "human_command_mode", 0); + } + } else if (render_client && render_client->human_input.enabled && + ENCOUNTER_INFERNO.step_human_commands) { + if (env->episode_actions && render_client->human_input.commands.count > 0) { + fprintf(stderr, "RECORD_REPLAY cannot record human command mode\n"); + abort(); + } + ENCOUNTER_INFERNO.step_human_commands(env->enc_state, &render_client->human_input); + used_human_commands = 1; + } else { + if (render_client) { + human_input_clear_pending(&render_client->human_input); + human_input_clear_move(&render_client->human_input); + ENCOUNTER_INFERNO.put_int(env->enc_state, "player_dest_x", -1); + ENCOUNTER_INFERNO.put_int(env->enc_state, "player_dest_y", -1); + ENCOUNTER_INFERNO.put_int(env->enc_state, "human_command_mode", 0); + } + 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 && !used_human_commands) { + /* 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++; + } + } + + if (!used_human_commands) + 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; + + /* terminal-only logging: accumulate completed episode stats into env->log. + vecenv polls with static_vec_log() which sums across agents, divides by + total n, then clears. this gives proper per-completed-episode averages + instead of noisy mid-episode snapshots. */ + if (is_term) { + InfernoState* s = (InfernoState*)env->enc_state; + + /* only count episodes that match the configured start_wave. + curriculum agents (overridden wave) are excluded from metrics. */ + if (s->start_wave != env->config_start_wave) goto skip_log; + + env->log.episode_return += s->episode_return; + env->log.episode_length += (float)s->tick; + env->log.damage_dealt += s->total_damage_dealt; + env->log.zuk_healer_damage += s->total_zuk_healer_damage; + env->log.damage_received += s->total_damage_received; + env->log.hp_restored += s->total_hp_restored; + env->log.wins += (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.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; + env->log.start_wave += (float)env->config_start_wave; + + for (int t = 0; t < INF_NUM_NPC_TYPES; t++) { + env->log.prayer_correct_by_type[t] += (float)s->prayer_correct_by_type[t]; + env->log.attacks_by_type[t] += (float)s->attacks_by_type[t]; + env->log.dmg_from_type[t] += s->dmg_from_type[t]; + env->log.killed_by_type[t] += (float)s->killed_by_type[t]; + } + + /* Zuk shield tracking */ + env->log.behind_shield_pct += (s->total_zuk_ticks > 0) + ? (float)s->behind_shield_ticks / (float)s->total_zuk_ticks : 0.0f; + + /* Zuk HP remaining at episode end */ + { + float zhp = 1200.0f; + for (int n = 0; n < INF_MAX_NPCS; n++) { + if (s->npcs[n].type == INF_NPC_ZUK) { + zhp = (float)s->npcs[n].hp; + break; + } + } + if (s->winner == 0) zhp = 0.0f; + env->log.zuk_hp_remaining += zhp; + } + env->log.min_zuk_hp_seen += (s->winner == 0) + ? 0.0f + : (s->min_zuk_hp_seen > 0.0f ? s->min_zuk_hp_seen : 1200.0f); + + /* action noop rates (per-episode ratios, averaged across episodes by aggregator) */ + float at = (float)s->action_total_count; + if (at > 0.0f) { + env->log.noop_move += (float)s->action_noop_count[0] / at; + env->log.noop_prayer += (float)s->action_noop_count[1] / at; + env->log.noop_target += (float)s->action_noop_count[2] / at; + env->log.noop_gear += (float)s->action_noop_count[3] / at; + env->log.noop_eat += (float)s->action_noop_count[4] / at; + env->log.noop_potion += (float)s->action_noop_count[5] / at; + env->log.noop_spell += (float)s->action_noop_count[6] / at; + env->log.noop_spec += (float)s->action_noop_count[7] / at; + } + + env->log.n += 1.0f; + skip_log:; + } + + if (is_term) { + /* check if this episode is a new global best — if so, flush replay to disk. + for full runs (internal start_wave 0): best = highest wave reached, then fewest ticks. + for Zuk-only (internal start_wave 68): best = most damage to Zuk, then fewest ticks. + curriculum starts from mid-waves also record. */ + 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; + int is_new_best = 0; + if (st->start_wave == 0) { + /* full run: best wave, then fewest ticks */ + is_new_best = (wave > g_best_wave || (wave == g_best_wave && ticks < g_best_ticks)); + } else { + /* partial/zuk run: best = lowest Zuk HP reached. + if Zuk is dead (winner==0), fastest kill (fewest ticks) wins. */ + int min_zuk_hp = (st->winner == 0) + ? 0 + : (st->min_zuk_hp_seen > 0.0f ? (int)st->min_zuk_hp_seen : 1200); + is_new_best = (min_zuk_hp < g_best_min_zuk_hp || + (min_zuk_hp == g_best_min_zuk_hp && min_zuk_hp == 0 && ticks < g_best_ticks)); + if (is_new_best) g_best_min_zuk_hp = min_zuk_hp; + } + if (is_new_best) { + 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) { + fprintf(stderr, "record_best_replay_path: cannot open %s\n", rpath); + abort(); + } + 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); + if (st->start_wave >= 68) { + fprintf(stderr, "replay: new best min zuk hp=%d (%d ticks, rng=%u) saved to %s\n", + g_best_min_zuk_hp, env->episode_action_len, env->episode_rng_start, rpath); + } else { + 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); + + /* render-side cleanup needs the RenderClient which lives in c_render. + raise a flag so the next c_render call does the cleanup. */ + env->pending_render_reset = 1; + } +} + +void c_reset(Env* env) { + /* if replaying, seed the encounter RNG to match the recording */ + uint32_t seed = env->replay_actions ? env->replay_rng_seed : 0; + ENCOUNTER_INFERNO.reset(env->enc_state, seed); + env->replay_cursor = 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; + free(env->replay_actions); + env->replay_actions = NULL; + if (env->enc_state) { + ENCOUNTER_INFERNO.destroy(env->enc_state); + env->enc_state = NULL; + } + if (env->render_env.client) { + render_destroy_client((RenderClient*)env->render_env.client); + env->render_env.client = NULL; + } +} + +void c_render(Env* env) { + OsrsEnv* re = &env->render_env; + re->encounter_def = (void*)&ENCOUNTER_INFERNO; + re->encounter_state = env->enc_state; + + /* first pvp_render creates the RenderClient via lazy init. after that, + load encounter-specific terrain/objects/models (standalone viewer does + this in run_visual before the render loop). */ + int first_call = (re->client == NULL); + pvp_render(re); + + if (first_call) { + RenderClient* rc = (RenderClient*)re->client; + rc->ticks_per_second = env->ticks_per_second; + 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); + rc->terrain = terrain_load("data/inferno.terrain"); + rc->objects = objects_load("data/inferno.objects"); + rc->objects_zuk = objects_load("data/inferno_zuk.objects"); + /* inferno region (35,83) starts at world (2246, 5315) */ + if (rc->terrain) terrain_offset(rc->terrain, 2246, 5315); + if (rc->objects) objects_offset(rc->objects, 2246, 5315); + if (rc->objects_zuk) objects_offset(rc->objects_zuk, 2246, 5315); + rc->npc_model_cache = model_cache_load("data/inferno.models"); + rc->npc_anim_cache = anim_cache_load("data/inferno.anims"); + + /* inferno renders in encounter-local tiles, but render_make_client() + initializes the camera to wilderness PvP world coords. mirror the + standalone viewer's post-load bootstrap so the first live frame uses + inferno arena bounds and entity positions. */ + render_populate_entities(rc, re); + 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]; + } + env->last_step_time = GetTime(); + } + + RenderClient* rc = (RenderClient*)re->client; + if (!rc) return; + + /* post-reset cleanup: c_step raised the flag after resetting the encounter. + mirror osrs_visual.c:186-201 so damage splats, in-flight effects, stale + inventory state, and last-frame sub-tile coordinates (from dead-player + body) don't leak into the next episode. */ + if (env->pending_render_reset) { + render_clear_history(rc); + effect_clear_all(rc->effects); + gui_reset_inventory_ui_state(&rc->gui); + render_populate_entities(rc, re); + 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]; + } + env->pending_render_reset = 0; + } + + /* update NPC visual positions once per tick (not per frame). + render_post_tick snaps sub_x/sub_y/dest_x/dest_y for spawned/moved NPCs + and resets composite state on npc_slot changes. */ + render_populate_entities(rc, re); + render_post_tick(rc, re); + + /* match the standalone viewer's visual_frame pattern: spin pvp_render at + ~60fps until the next sim tick is due. pvp_render uses GetFrameTime() + to accumulate client_tick_accumulator and steps render_client_tick every + 20ms, which is what animates sub_x/sub_y interpolation smoothly between + sim ticks. calling pvp_render only once per 600ms collapses all 30 + client-ticks into a single frame → entities snap instantly, no motion. + so: keep rendering until the tick deadline, then return. c_step's sim + step follows immediately and the loop repeats. */ + float tps = rc->ticks_per_second > 0.0f ? rc->ticks_per_second : 1.667f; + double interval = 1.0 / (double)tps; + double deadline = env->last_step_time + interval; + while (GetTime() < deadline) { + pvp_render(re); + } + env->last_step_time = GetTime(); +} + +#define MY_VEC_INIT +#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"); + int sw = inferno_public_wave_from_config(start_wave, "start_wave", 1); + if (start_wave) + ENCOUNTER_INFERNO.put_int(env->enc_state, "start_wave", sw); + ENCOUNTER_INFERNO.put_float( + env->enc_state, "damage_reward_coeff", + (float)dict_get_unsafe(kwargs, "damage_reward_coeff")->value); + ENCOUNTER_INFERNO.put_float( + env->enc_state, "shield_penalty_coeff", + (float)dict_get_unsafe(kwargs, "shield_penalty_coeff")->value); + ENCOUNTER_INFERNO.put_float( + env->enc_state, "tag_reward_coeff", + (float)dict_get_unsafe(kwargs, "tag_reward_coeff")->value); + DictItem* supply_profile_scale = + dict_get_unsafe(kwargs, "late_start_supply_profile_scale"); + if (supply_profile_scale) { + ENCOUNTER_INFERNO.put_float( + env->enc_state, "late_start_supply_profile_scale", + (float)supply_profile_scale->value); + } + env->config_start_wave = inf_public_start_wave_to_internal(sw); + + const char* record_path = getenv("RECORD_REPLAY"); + const char* play_path = getenv("PLAY_REPLAY"); + if (record_path && record_path[0] && play_path && play_path[0]) { + fprintf(stderr, "RECORD_REPLAY and PLAY_REPLAY cannot both be set\n"); + abort(); + } + + if (record_path && record_path[0]) { + env->episode_actions = (int*)malloc(REPLAY_MAX_TICKS * NUM_ATNS * sizeof(int)); + if (!env->episode_actions) { + fprintf(stderr, "RECORD_REPLAY: out of memory\n"); + abort(); + } + env->episode_action_cap = REPLAY_MAX_TICKS; + } else { + env->episode_actions = NULL; + env->episode_action_cap = 0; + } + env->episode_action_len = 0; + + /* playback: first env (env 0) loads the replay if PLAY_REPLAY is set. + we use g_play_replay_loaded to ensure only one env loads it. */ + env->replay_actions = NULL; + env->replay_num_ticks = 0; + env->replay_cursor = 0; + env->replay_rng_seed = 0; + env->ticks_per_second = 1.667f; + env->last_step_time = 0.0; + static int g_play_replay_loaded = 0; + if (play_path && play_path[0] && !g_play_replay_loaded) { + FILE* fp = fopen(play_path, "rb"); + if (!fp) { + fprintf(stderr, "PLAY_REPLAY: cannot open %s\n", play_path); + abort(); + } + int num_ticks = 0; + uint32_t rng_seed = 0; + if (fread(&num_ticks, sizeof(int), 1, fp) != 1 || + fread(&rng_seed, sizeof(uint32_t), 1, fp) != 1 || + num_ticks <= 0 || num_ticks > REPLAY_MAX_TICKS) { + fprintf(stderr, "PLAY_REPLAY: invalid replay header in %s\n", play_path); + fclose(fp); + abort(); + } + int* buf = (int*)malloc(num_ticks * NUM_ATNS * sizeof(int)); + if (!buf) { + fprintf(stderr, "PLAY_REPLAY: out of memory\n"); + fclose(fp); + abort(); + } + if (fread(buf, sizeof(int), num_ticks * NUM_ATNS, fp) != (size_t)(num_ticks * NUM_ATNS)) { + fprintf(stderr, "PLAY_REPLAY: short read from %s\n", play_path); + free(buf); + fclose(fp); + abort(); + } + fclose(fp); + env->replay_actions = buf; + env->replay_num_ticks = num_ticks; + env->replay_rng_seed = rng_seed; + g_play_replay_loaded = 1; + fprintf(stderr, "PLAY_REPLAY: loaded %d ticks, rng=%u from %s\n", + num_ticks, rng_seed, play_path); + ENCOUNTER_INFERNO.reset(env->enc_state, rng_seed); + } +} + +/* curriculum wave mixing: start some agents at later waves for late-game gradient signal. + base-start agents are scored normally; curriculum agents train but don't affect sweep metric. */ +#define MAX_CURRICULUM_TIERS 4 + +Env* my_vec_init(int* num_envs_out, int* buffer_env_starts, int* buffer_env_counts, + Dict* vec_kwargs, Dict* env_kwargs) { + int total_agents = (int)dict_get(vec_kwargs, "total_agents")->value; + int num_buffers = (int)dict_get(vec_kwargs, "num_buffers")->value; + int agents_per_buffer = total_agents / num_buffers; + DictItem* base_start_wave_item = dict_get_unsafe(env_kwargs, "start_wave"); + int base_start_wave = inferno_public_wave_from_config( + base_start_wave_item, "start_wave", 1); + + /* parse curriculum tiers from env config */ + static const char* wave_keys[] = { + "curriculum_wave_1","curriculum_wave_2","curriculum_wave_3","curriculum_wave_4" + }; + static const char* frac_keys[] = { + "curriculum_frac_1","curriculum_frac_2","curriculum_frac_3","curriculum_frac_4" + }; + int curriculum_waves[MAX_CURRICULUM_TIERS]; + float curriculum_fracs[MAX_CURRICULUM_TIERS]; + int num_tiers = 0; + for (int i = 0; i < MAX_CURRICULUM_TIERS; i++) { + DictItem* w = dict_get_unsafe(env_kwargs, wave_keys[i]); + DictItem* f = dict_get_unsafe(env_kwargs, frac_keys[i]); + if (w && f && f->value > 0.0) { + curriculum_waves[num_tiers] = + inferno_public_wave_from_config(w, wave_keys[i], 0); + curriculum_fracs[num_tiers] = (float)f->value; + num_tiers++; + } + } + + /* allocate and init all envs (same as default my_vec_init) */ + Env* envs = (Env*)calloc(total_agents, sizeof(Env)); + int num_envs = 0; + int agents_created = 0; + while (agents_created < total_agents) { + srand(num_envs); + envs[num_envs].rng = num_envs; + my_init(&envs[num_envs], env_kwargs); + agents_created += envs[num_envs].num_agents; + num_envs++; + } + envs = (Env*)realloc(envs, num_envs * sizeof(Env)); + + /* assign curriculum start_waves to agents at the end of the array */ + if (num_tiers > 0) { + int tier_counts[MAX_CURRICULUM_TIERS]; + int curriculum_total = 0; + for (int t = 0; t < num_tiers; t++) { + tier_counts[t] = (int)(curriculum_fracs[t] * num_envs); + if (tier_counts[t] < 1) tier_counts[t] = 1; + curriculum_total += tier_counts[t]; + } + int base_count = num_envs - curriculum_total; + int cursor = base_count; + for (int t = 0; t < num_tiers; t++) { + for (int i = 0; i < tier_counts[t] && cursor < num_envs; i++, cursor++) { + ENCOUNTER_INFERNO.put_int(envs[cursor].enc_state, + "start_wave", curriculum_waves[t]); + } + } + fprintf(stderr, "curriculum: %d wave-%d", base_count, base_start_wave); + for (int t = 0; t < num_tiers; t++) + fprintf(stderr, ", %d wave-%d", tier_counts[t], curriculum_waves[t]); + fprintf(stderr, " (%d total)\n", num_envs); + } + + /* fill buffer info (same as default) */ + int buf = 0; + int buf_agents = 0; + buffer_env_starts[0] = 0; + buffer_env_counts[0] = 0; + for (int i = 0; i < num_envs; i++) { + buf_agents += envs[i].num_agents; + buffer_env_counts[buf]++; + if (buf_agents >= agents_per_buffer && buf < num_buffers - 1) { + buf++; + buffer_env_starts[buf] = i + 1; + buffer_env_counts[buf] = 0; + buf_agents = 0; + } + } + + *num_envs_out = num_envs; + return envs; +} + +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, "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, "current_ranged", log->current_ranged); + dict_set(out, "current_magic", log->current_magic); + dict_set(out, "behind_shield_pct", log->behind_shield_pct); + dict_set(out, "zuk_hp_remaining", log->zuk_hp_remaining); + dict_set(out, "min_zuk_hp_seen", log->min_zuk_hp_seen); + dict_set(out, "hp_restored", log->hp_restored); + dict_set(out, "zuk_healer_damage", log->zuk_healer_damage); + dict_set(out, "deaths_to_jad", log->killed_by_type[INF_NPC_JAD] / log->n); + 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 score; + int internal_start_wave = (int)(log->start_wave + 0.5f); + if (internal_start_wave >= 68) { + /* Zuk-only: score = fraction of lowest Zuk HP reached (0..1), wins = 1.0 */ + score = (1200.0f - log->min_zuk_hp_seen) / 1200.0f; + } else { + /* full runs: wave progress (0..0.5) + win bonus (0..1) */ + float wave_frac = log->wave / (float)INF_NUM_WAVES; + score = wr + (1.0f - wr) * wave_frac * 0.5f; + } + dict_set(out, "score", score); + + /* per-NPC-type prayer rates and damage (wandb only). + keys must be string literals — dict_set stores the pointer, not a copy. */ + /* + static const char* pray_keys[] = { + "pray_nibbler","pray_bat","pray_blob","pray_blob_mel","pray_blob_rng","pray_blob_mag", + "pray_meleer","pray_ranger","pray_mager","pray_jad","pray_zuk","pray_heal_jad","pray_heal_zuk","pray_shield" + }; + static const char* dmg_keys[] = { + "dmg_from_nibbler","dmg_from_bat","dmg_from_blob","dmg_from_blob_mel","dmg_from_blob_rng","dmg_from_blob_mag", + "dmg_from_meleer","dmg_from_ranger","dmg_from_mager","dmg_from_jad","dmg_from_zuk","dmg_from_heal_jad","dmg_from_heal_zuk","dmg_from_shield" + }; + static const char* kill_keys[] = { + "killed_by_nibbler","killed_by_bat","killed_by_blob","killed_by_blob_mel","killed_by_blob_rng","killed_by_blob_mag", + "killed_by_meleer","killed_by_ranger","killed_by_mager","killed_by_jad","killed_by_zuk","killed_by_heal_jad","killed_by_heal_zuk","killed_by_shield" + }; + for (int t = 0; t < INF_NUM_NPC_TYPES; t++) { + if (log->attacks_by_type[t] > 0.0f) { + dict_set(out, pray_keys[t], log->prayer_correct_by_type[t] / log->attacks_by_type[t]); + dict_set(out, dmg_keys[t], log->dmg_from_type[t]); + } + if (log->killed_by_type[t] > 0.0f) + dict_set(out, kill_keys[t], log->killed_by_type[t]); + } + */ +} diff --git a/ocean/osrs_pvp/binding.c b/ocean/osrs_pvp/binding.c new file mode 100644 index 0000000000..8e6048e40f --- /dev/null +++ b/ocean/osrs_pvp/binding.c @@ -0,0 +1,220 @@ +/** + * @file binding.c + * @brief 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_env.h" + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-function" +#include "encounters/encounter_inferno.h" /* render.h references InfernoState */ +#include "encounters/encounter_zulrah.h" /* render.h references ZulrahState */ +#include "osrs_render.h" +#pragma GCC diagnostic pop + +/* vecenv-compatible header fields must stay first. */ +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + OsrsEnv pvp; + + int ocean_acts_staging[NUM_ACTION_HEADS]; + unsigned char ocean_term_staging; +} PvpEnv; + +#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, OFFENSIVE_DIM} +#define OBS_TENSOR_T FloatTensor +#define Env PvpEnv + +void c_step(Env* env) { + for (int i = 0; i < NUM_ATNS; i++) { + env->ocean_acts_staging[i] = (int)env->actions[i]; + } + + pvp_step(&env->pvp); + + env->terminals[0] = (float)env->ocean_term_staging; + + 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.prayer_correct += env->pvp.log.prayer_correct; + env->log.prayer_total += env->pvp.log.prayer_total; + env->log.idle_ticks += env->pvp.log.idle_ticks; + env->log.brews_used += env->pvp.log.brews_used; + env->log.wave += env->pvp.log.wave; + env->log.npc_kills += env->pvp.log.npc_kills; + env->log.blood_healed += env->pvp.log.blood_healed; + 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) { + env->pvp.ocean_io.agent_obs = (float*)env->observations; + env->pvp.ocean_io.agent_rewards = env->rewards; + env->pvp.ocean_io.agent_terminals = &env->ocean_term_staging; + env->pvp.ocean_io.agent_actions = env->ocean_acts_staging; + + pvp_reset(&env->pvp); + ocean_write_obs(&env->pvp); + env->pvp.ocean_io.agent_rewards[0] = 0.0f; + env->pvp.ocean_io.agent_terminals[0] = 0; + env->terminals[0] = 0.0f; +} + +void c_close(Env* env) { pvp_close(&env->pvp); } +void c_render(Env* env) { + pvp_render(&env->pvp); +} + +#include "vecenv.h" + +void my_init(Env* env, Dict* kwargs) { + env->num_agents = 1; + + pvp_init(&env->pvp); + + env->pvp.ocean_io.agent_obs = NULL; + env->pvp.ocean_io.agent_rewards = env->pvp._rews_buf; + env->pvp.ocean_io.agent_terminals = &env->ocean_term_staging; + env->pvp.ocean_io.agent_actions = env->ocean_acts_staging; + env->pvp.ocean_io.agent_obs_p1 = NULL; + env->pvp.ocean_io.selfplay_mask = NULL; + + env->pvp.pvp_runtime.use_c_opponent = 1; + env->pvp.auto_reset = 1; + env->pvp.is_lms = 1; + + DictItem* opp = dict_get_unsafe(kwargs, "opponent_type"); + env->pvp.pvp_runtime.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; + + 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; + + env->pvp.pvp_runtime.gear_tier_weights[0] = 1.0f; + env->pvp.pvp_runtime.gear_tier_weights[1] = 0.0f; + env->pvp.pvp_runtime.gear_tier_weights[2] = 0.0f; + env->pvp.pvp_runtime.gear_tier_weights[3] = 0.0f; + + 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); + + /* prayer correctness rate */ + float prayer_rate = (log->prayer_total > 0.0f) + ? log->prayer_correct / log->prayer_total : 0.0f; + dict_set(out, "prayer_correct_rate", prayer_rate); + + /* combat stats (stored in reused Log fields) */ + dict_set(out, "food_remaining", log->idle_ticks); + dict_set(out, "brews_remaining", log->brews_used); + dict_set(out, "spec_remaining", log->wave); + dict_set(out, "attacks_landed", log->npc_kills); + dict_set(out, "off_prayer_hits", log->blood_healed); + + /* damage per hit */ + float dph = (log->npc_kills > 0.0f) + ? log->damage_dealt / log->npc_kills : 0.0f; + dict_set(out, "damage_per_hit", dph); + + /* composite score: winrate + damage fraction */ + float wr = log->wins; + float dmg_frac = log->damage_dealt / 99.0f; /* normalized to max HP */ + float score = wr + (1.0f - wr) * dmg_frac * 0.5f; + dict_set(out, "score", score); +} + +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.pvp_runtime.pfsp.pool_size == 0); + envs[e].pvp.pvp_runtime.pfsp.pool_size = pool_size; + for (int i = 0; i < pool_size; i++) { + envs[e].pvp.pvp_runtime.pfsp.pool[i] = (OpponentType)pool[i]; + envs[e].pvp.pvp_runtime.pfsp.cum_weights[i] = cum_weights[i]; + } + 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.pvp_runtime.pfsp.pool_size > pool_size) + pool_size = envs[e].pvp.pvp_runtime.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; + } + + for (int e = 0; e < vec->size; e++) { + for (int i = 0; i < envs[e].pvp.pvp_runtime.pfsp.pool_size; i++) { + out_wins[i] += envs[e].pvp.pvp_runtime.pfsp.wins[i]; + out_episodes[i] += envs[e].pvp.pvp_runtime.pfsp.episodes[i]; + } + memset(envs[e].pvp.pvp_runtime.pfsp.wins, 0, sizeof(envs[e].pvp.pvp_runtime.pfsp.wins)); + memset(envs[e].pvp.pvp_runtime.pfsp.episodes, 0, sizeof(envs[e].pvp.pvp_runtime.pfsp.episodes)); + } +} 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..b0483ff274 --- /dev/null +++ b/ocean/osrs_zulrah/binding.c @@ -0,0 +1,202 @@ +/** + * @file binding.c + * @brief Static-native binding for OSRS Zulrah encounter. + * + * Bridges vecenv.h's contract (float actions, float terminals) with the + * Zulrah encounter's vtable interface. Uses the encounter system (EncounterDef) + * rather than OsrsEnv directly. + */ + +#include +#include +#include + +#include "osrs_env.h" /* pulls in osrs_types, encounter, pvp stack */ + +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wunused-function" +#include "encounters/encounter_zulrah.h" +#include "encounters/encounter_inferno.h" /* render.h references InfernoState */ +#include "osrs_render.h" +#pragma GCC diagnostic pop + +#define ZUL_TOTAL_OBS (ZUL_NUM_OBS + ZUL_ACTION_MASK_SIZE) + +/* vecenv-compatible header fields must stay first. */ +typedef struct { + void* observations; + float* actions; + float* rewards; + float* terminals; + int num_agents; + int rng; + Log log; + + EncounterState* enc_state; + + int acts_staging[ZUL_NUM_ACTION_HEADS]; + unsigned char term_staging; + + OsrsEnv render_env; +} 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, ZUL_OFFENSIVE_DIM} +#define OBS_TENSOR_T FloatTensor +#define Env ZulrahEnv + +void c_step(Env* env) { + int used_human_commands = 0; + RenderClient* render_client = (RenderClient*)env->render_env.client; + + if (render_client && render_client->human_input.enabled && + ENCOUNTER_ZULRAH.step_human_commands) { + const char* record_path = getenv("RECORD_REPLAY"); + if (record_path && record_path[0] && render_client->human_input.commands.count > 0) { + fprintf(stderr, "RECORD_REPLAY cannot record human command mode\n"); + abort(); + } + ENCOUNTER_ZULRAH.step_human_commands(env->enc_state, &render_client->human_input); + used_human_commands = 1; + } else { + if (render_client) { + human_input_clear_pending(&render_client->human_input); + human_input_clear_move(&render_client->human_input); + ENCOUNTER_ZULRAH.put_int(env->enc_state, "player_dest_x", -1); + ENCOUNTER_ZULRAH.put_int(env->enc_state, "player_dest_y", -1); + ENCOUNTER_ZULRAH.put_int(env->enc_state, "human_command_mode", 0); + } + for (int i = 0; i < NUM_ATNS; i++) { + env->acts_staging[i] = (int)env->actions[i]; + } + } + + if (!used_human_commands) + ENCOUNTER_ZULRAH.step(env->enc_state, env->acts_staging); + + 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] = ENCOUNTER_ZULRAH.get_reward(env->enc_state); + + 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->episode_return; + 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.prayer_correct += (float)zs->total_prayer_correct; + env->log.prayer_total += (float)zs->total_prayer_total; + env->log.gear_switches += (float)zs->total_gear_switches; + env->log.npc_kills += (float)zs->total_food_eaten; + env->log.idle_ticks += (float)zs->total_potions_used; + env->log.brews_used += (float)zs->total_venom_ticks; + env->log.wave += (float)zs->total_phases_completed; + env->log.n += 1.0f; + + 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; + } + if (env->render_env.client) { + render_destroy_client((RenderClient*)env->render_env.client); + env->render_env.client = NULL; + } +} + +void c_render(Env* env) { + OsrsEnv* re = &env->render_env; + re->encounter_def = (void*)&ENCOUNTER_ZULRAH; + re->encounter_state = env->enc_state; + + int first_call = (re->client == NULL); + pvp_render(re); + + if (first_call) { + RenderClient* rc = (RenderClient*)re->client; + rc->terrain = terrain_load("data/zulrah.terrain"); + rc->objects = objects_load("data/zulrah.objects"); + /* zulrah regions (35,47)+(35,48) start at world (2240, 3008); + island platform at world ~(2256, 3061) → offset by (2256, 3061). */ + if (rc->terrain) terrain_offset(rc->terrain, 2256, 3061); + if (rc->objects) objects_offset(rc->objects, 2256, 3061); + rc->npc_model_cache = model_cache_load("data/zulrah.models"); + rc->npc_anim_cache = anim_cache_load("data/zulrah.anims"); + } + + RenderClient* rc = (RenderClient*)re->client; + if (rc && rc->ticks_per_second > 0.0f) { + double interval = 1.0 / rc->ticks_per_second; + double elapsed = GetTime() - rc->last_tick_time; + if (elapsed < interval) WaitTime(interval - elapsed); + rc->last_tick_time = GetTime(); + } +} + +#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)); + + 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); + + float prayer_rate = (log->prayer_total > 0.0f) + ? log->prayer_correct / log->prayer_total : 0.0f; + dict_set(out, "prayer_correct_rate", prayer_rate); + + /* behavioral metrics (stored in reused Log fields) */ + dict_set(out, "gear_switches", log->gear_switches); + dict_set(out, "food_eaten", log->npc_kills); /* reused field */ + dict_set(out, "potions_used", log->idle_ticks); /* reused field */ + dict_set(out, "venom_ticks", log->brews_used); /* reused field */ + dict_set(out, "phases_completed", log->wave); /* reused field */ + + 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; + dict_set(out, "score", score); +} diff --git a/pufferlib/config/ocean/osrs_inferno.ini b/pufferlib/config/ocean/osrs_inferno.ini new file mode 100644 index 0000000000..189321cf1f --- /dev/null +++ b/pufferlib/config/ocean/osrs_inferno.ini @@ -0,0 +1,187 @@ +# Config for OSRS Inferno encounter. +# 8 action heads (79 logits), 1058 obs, long episodes (300-8000+ ticks). +# Base anchor is the best local Zuk sweep run so far: +# logs/osrs_inferno/1776841634625.json (zuk_hp_remaining ~= 379.54). + +[base] +env_name = osrs_inferno +policy_name = MinGRU +rnn_name = Recurrent +score_metric = episode_return + +[env] +start_wave = 69 +damage_reward_coeff = 0.01 +shield_penalty_coeff = 0.01 +tag_reward_coeff = 0.25 +late_start_supply_profile_scale = 1.0 +mask_in_obs = 1.0 +record_best_replay_path = "" +play_replay_path = "" +# curriculum: fraction of agents starting at later waves (rest at start_wave) +curriculum_wave_1 = 20 +curriculum_frac_1 = 0.0 +curriculum_wave_2 = 40 +curriculum_frac_2 = 0.0 +curriculum_wave_3 = 60 +curriculum_frac_3 = 0.0 + +[vec] +total_agents = 256 +num_buffers = 2 +num_threads = 16 + +[policy] +hidden_size = 256 +num_layers = 3 + +[train] +total_timesteps = 67_907_217 +horizon = 64 +min_lr_ratio = 0.5544664329830781 +learning_rate = 0.007342913951242895 +beta1 = 0.9077345426199611 +beta2 = 0.9998701883037843 +eps = 0.000011107053106255366 +ent_coef = 0.035000561348824843 +gamma = 0.9999778934320869 +gae_lambda = 0.900343737151439 +vtrace_rho_clip = 1.7475641754896718 +vtrace_c_clip = 1.3981828660058588 +prio_alpha = 0.0 +prio_beta0 = 0.441357822328451 +clip_coef = 0.08710137806213623 +vf_coef = 0.18246784374201588 +vf_clip_coef = 0.23959086025245466 +max_grad_norm = 2.015204763920358 +replay_ratio = 1.2124850932570845 +minibatch_size = 4096 + +[sweep] +min_sps = 100000 +max_suggestion_cost = 720 +max_runs = 0 +gpus = 1 +metric = episode_return +metric_distribution = linear +sweep_only = total_timesteps, horizon, learning_rate, ent_coef, gamma, min_lr_ratio, beta1, eps, gae_lambda, vtrace_rho_clip, vtrace_c_clip, prio_alpha, prio_beta0, clip_coef, vf_coef, vf_clip_coef, max_grad_norm, replay_ratio, total_agents, num_layers + +[sweep.train.total_timesteps] +distribution = log_normal +min = 50000000 +max = 150000000 +scale = time + +[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.1 +scale = auto + +[sweep.train.gamma] +distribution = logit_normal +min = 0.99 +max = 0.999999 +scale = auto + +[sweep.train.min_lr_ratio] +distribution = uniform +min = 0.0 +max = 0.75 +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 = 1.6 +scale = auto + +[sweep.train.prio_alpha] +distribution = logit_normal +min = 0.0 +max = 0.2 +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.02 +max = 0.5 +scale = auto + +[sweep.train.vf_coef] +distribution = log_normal +min = 0.1 +max = 0.75 +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 = 2.0 +scale = auto + +[sweep.vec.total_agents] +distribution = uniform_pow2 +min = 128 +max = 512 +scale = auto + +[sweep.policy.num_layers] +distribution = int_uniform +min = 2 +max = 3 +scale = auto diff --git a/pufferlib/pufferl.py b/pufferlib/pufferl.py index 8fc0c03a89..79643c1145 100644 --- a/pufferlib/pufferl.py +++ b/pufferlib/pufferl.py @@ -233,7 +233,10 @@ def _train(env_name, args, sweep_obj=None, result_queue=None, verbose=False): if epoch < train_epochs: backend.train(pufferl) - if (epoch % args['checkpoint_interval'] == 0 or epoch == train_epochs - 1) and sweep_obj is None: + # checkpoint_interval <= 0 disables periodic saves (still writes final epoch) + interval = args['checkpoint_interval'] + should_save = (interval > 0 and epoch % interval == 0) or epoch == train_epochs - 1 + if should_save and sweep_obj is None: model_path = os.path.join(checkpoint_dir, f'{pufferl.global_step:016d}.bin') backend.save_weights(pufferl, model_path) @@ -407,11 +410,22 @@ def eval(env_name, args=None, load_path=None): backend = _resolve_backend(args) pufferl = backend.create_pufferl(args) - # Resolve load path + # Resolve load path. If --load-model-path is omitted, auto-resolve the latest + # checkpoint for this env so `puffer eval ` "just works" after a training + # run. Pass --load-model-path latest explicitly if you want a hard error when + # no checkpoint exists. Explicit file paths are loaded as-is. 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') + checkpoint_dir = args['checkpoint_dir'] + pattern = os.path.join(checkpoint_dir, args['env_name'], '**', '*.bin') + if load_path is None: + candidates = glob.glob(pattern, recursive=True) + if candidates: + load_path = max(candidates, key=os.path.getctime) + else: + print(f'WARNING: no checkpoint found in {checkpoint_dir}/{args["env_name"]}/ ' + f'— running with random weights. ' + f'Train first with `puffer train {args["env_name"]}`.', flush=True) + elif load_path == 'latest': candidates = glob.glob(pattern, recursive=True) if not candidates: raise FileNotFoundError(f'No .bin checkpoints found in {checkpoint_dir}/{args["env_name"]}/') @@ -419,7 +433,7 @@ def eval(env_name, args=None, load_path=None): if load_path is not None: backend.load_weights(pufferl, load_path) - print(f'Loaded weights from {load_path}') + print(f'Loaded weights from {load_path}', flush=True) while True: backend.render(pufferl, 0) diff --git a/src/bindings.cu b/src/bindings.cu index 4469cb512c..4f413e8983 100644 --- a/src/bindings.cu +++ b/src/bindings.cu @@ -106,7 +106,7 @@ pybind11::dict puf_eval_log(pybind11::object pufferl_obj) { pufferl.last_log_step = pufferl.global_step; pybind11::dict env_dict; - Dict* env_out = create_dict(32); + Dict* env_out = create_dict(64); static_vec_eval_log(pufferl.vec, env_out); for (int i = 0; i < env_out->size; i++) { env_dict[env_out->items[i].key] = env_out->items[i].value; @@ -318,7 +318,7 @@ void cpu_vec_step_py(VecEnv& ve, long long actions_ptr) { } py::dict vec_log(VecEnv& ve) { - Dict* out = create_dict(32); + Dict* out = create_dict(64); static_vec_log(ve.vec, out); py::dict result; for (int i = 0; i < out->size; i++) { diff --git a/src/bindings_cpu.cpp b/src/bindings_cpu.cpp index 5ba4dc81e5..a4e0b7633c 100644 --- a/src/bindings_cpu.cpp +++ b/src/bindings_cpu.cpp @@ -141,7 +141,7 @@ static void cpu_vec_step_py(VecEnv& ve, long long actions_ptr) { } static py::dict vec_log(VecEnv& ve) { - Dict* out = create_dict(32); + Dict* out = create_dict(64); static_vec_log(ve.vec, out); py::dict result; for (int i = 0; i < out->size; i++) diff --git a/src/pufferlib.cu b/src/pufferlib.cu index 6c513c97b7..1d7c5966cb 100644 --- a/src/pufferlib.cu +++ b/src/pufferlib.cu @@ -330,7 +330,7 @@ typedef struct { } PuffeRL; Dict* log_environments_impl(PuffeRL& pufferl) { - Dict* out = create_dict(32); + Dict* out = create_dict(64); static_vec_log(pufferl.vec, out); return out; } diff --git a/tests/ocean_osrs/test_generate_monsters.py b/tests/ocean_osrs/test_generate_monsters.py new file mode 100644 index 0000000000..ce3d56e143 --- /dev/null +++ b/tests/ocean_osrs/test_generate_monsters.py @@ -0,0 +1,29 @@ +"""Regression checks for monster codegen parsing helpers.""" + +import importlib.util +from pathlib import Path + + +def load_generate_monsters_module(): + """Load the codegen module directly from the repo path.""" + repo_root = Path(__file__).resolve().parents[2] + module_path = repo_root / "ocean/osrs/tools/generate_monsters.py" + spec = importlib.util.spec_from_file_location("generate_monsters", module_path) + module = importlib.util.module_from_spec(spec) + assert spec.loader is not None + spec.loader.exec_module(module) + return module + + +def test_parse_max_hit_accepts_style_suffix(): + """Parses max hit strings that include a style label.""" + generate_monsters = load_generate_monsters_module() + + assert generate_monsters.parse_max_hit("46 (Ranged)") == 46 + + +def test_parse_max_hit_accepts_malformed_html_suffix(): + """Parses the broken Zuk max-hit string from the reference dump.""" + generate_monsters = load_generate_monsters_module() + + assert generate_monsters.parse_max_hit("148 \x7f\x7f") == 148